Merge branch 'stable-2.13' into stable-2.14

* stable-2.13:
  Change bouncycastle urls
  ListAccess: Fix incorrect behavior when group appears twice for same rule

Change-Id: I5c5f4522d4297e3b98f880bdbdad519cf5dfb187
diff --git a/.bazelignore b/.bazelignore
new file mode 100644
index 0000000..30f1613
--- /dev/null
+++ b/.bazelignore
@@ -0,0 +1 @@
+eclipse-out
diff --git a/.bazelproject b/.bazelproject
new file mode 100644
index 0000000..41bb27f
--- /dev/null
+++ b/.bazelproject
@@ -0,0 +1,20 @@
+# The project view file (.bazelproject) is used to import Gerrit Bazel packages into the IDE.
+#
+# See: https://ij.bazel.io/docs/project-views.html
+
+directories:
+  .
+  -eclipse-out
+  -contrib
+  -gerrit-package-plugins
+  -logs
+  -./.metadata
+  -./.settings
+  -./.apt_generated
+
+targets:
+  //...:all
+
+java_language_level: 8
+
+workspace_type: java
diff --git a/.bazelrc b/.bazelrc
index 00acd27..bf3aa6c 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1 +1,17 @@
-build --strategy=Javac=worker
+build --workspace_status_command="python ./tools/workspace_status.py" --strategy=Closure=worker
+build --repository_cache=~/.gerritcodereview/bazel-cache/repository
+build --action_env=PATH
+build --disk_cache=~/.gerritcodereview/bazel-cache/cas
+build --java_toolchain //tools:error_prone_warnings_toolchain
+
+# Enable strict_action_env flag to. For more information on this feature see
+# https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
+# This will be the new default behavior at some point (and the flag was flipped
+# shortly in 0.21.0 - https://github.com/bazelbuild/bazel/issues/7026). Remove
+# this flag here once flipped in Bazel again.
+build --incompatible_strict_action_env
+
+test --build_tests_only
+test --test_output=errors
+
+import %workspace%/tools/remote-bazelrc
diff --git a/.bazelversion b/.bazelversion
new file mode 100644
index 0000000..227cea2
--- /dev/null
+++ b/.bazelversion
@@ -0,0 +1 @@
+2.0.0
diff --git a/.buckconfig b/.buckconfig
deleted file mode 100644
index 3ce69da..0000000
--- a/.buckconfig
+++ /dev/null
@@ -1,35 +0,0 @@
-[alias]
-  api = //:api
-  chrome = //:chrome
-  docs = //Documentation:searchfree
-  firefox = //:firefox
-  gerrit = //:gerrit
-  gwtgerrit = //:gwtgerrit
-  headless = //:headless
-  polygerrit = //:polygerrit
-  release = //:release
-  releasenotes = //ReleaseNotes:html
-  safari = //:safari
-  soyc = //gerrit-gwtui:ui_soyc
-  soyc_r = //gerrit-gwtui:ui_soyc_r
-  withdocs = //:withdocs
-
-[buildfile]
-  includes = //tools/default.defs
-
-[java]
-  jar_spool_mode = direct_to_jar
-  src_roots = java, resources, src
-  source_level = 8
-  target_level = 8
-
-[project]
-  ignore = .git, eclipse-out, bazel-gerrit, bin
-  parallel_parsing = true
-
-[cache]
-  mode = dir
-  dir = ~/.gerritcodereview/buck-cache/locally-built-artifacts
-
-[test]
-  excluded_labels = manual
diff --git a/.buckversion b/.buckversion
deleted file mode 100644
index f5fe016..0000000
--- a/.buckversion
+++ /dev/null
@@ -1 +0,0 @@
-e64a2e2ada022f81e42be750b774024469551398
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..434dc95
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,24 @@
+# git hyper-blame master ignore list.
+#
+# This file contains a list of git hashes of revisions to be ignored by git
+# hyper-blame. These revisions are considered "unimportant" in that they are
+# unlikely to be what you are interested in when blaming.
+#
+# Instructions:
+# - Only large (generally automated) reformatting or renaming commits should be
+#   added to this list. Do not put things here just because you feel they are
+#   trivial or unimportant. If in doubt, do not put it on this list.
+# - Precede each revision with a comment containing the first line of its log.
+#   For bulk work over many commits, place all commits in a block with a single
+#   comment at the top describing the work done in those commits.
+# - Only put full 40-character hashes on this list (not short hashes or any
+#   other revision reference).
+# - Append to the bottom of the file (revisions should be in chronological order
+#   from oldest to newest).
+# - Because you must use a hash, you need to append to this list in a follow-up
+#   commit to the actual reformatting commit that you are trying to ignore.
+
+# Format all Java files with google-java-format
+292fa154c18b5a203c1aa211dce4efd54e6e8e0a
+111168482164db0cbe6a31630908ab607abc9989
+42ace3c4f41363b41486fe6cf8a3519f296ba4f4
diff --git a/.gitignore b/.gitignore
index 0fe7572..b1ad00c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,36 +1,32 @@
+# Keep following lines sorted according to `LC_COLLATE=C sort`
+*.asc
+*.eml
+*.iml
+*.pyc
+*.sublime-*
+*.swp
+*~
+.DS_Store
+.gwt_work_dir
 /.apt_generated
+/.apt_generated_tests
+/.bazel_path
 /.classpath
 /.factorypath
-/.project
-/.settings/org.maven.ide.eclipse.prefs
-/.settings/org.eclipse.m2e.core.prefs
-/.settings/org.eclipse.ltk.core.refactoring.prefs
-/test_site
 /.idea
-*.iml
-*.eml
-*.sublime-*
-/gerrit-package-plugins
-/.bazel_path
-/.buckconfig.local
-/.buckjavaargs
-/.buckd
-/bazel-bin
-/bazel-genfiles
-/bazel-gerrit
-/bazel-out
-/bazel-testlogs
-/buck-cache
-/buck-out
+/.metadata
+/.project
+/.settings/org.eclipse.ltk.core.refactoring.prefs
+/.settings/org.eclipse.m2e.core.prefs
+/.settings/org.maven.ide.eclipse.prefs
+/bazel-*
+/bin/
 /eclipse-out
 /extras
-/local.properties
-*.pyc
+/gerrit-package-plugins
 /gwt-unitCache
-.DS_Store
-*.swp
-*.asc
-/bin/
-*~
-.primary_build_tool
-.gwt_work_dir
+/infer-out
+/local.properties
+/plugins/cookbook-plugin/
+/test_site
+/tools/format
diff --git a/.gitmodules b/.gitmodules
index 6c4d53c..ec8afee 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -3,11 +3,6 @@
 	url = ../plugins/commit-message-length-validator
 	branch = .
 
-[submodule "plugins/cookbook-plugin"]
-	path = plugins/cookbook-plugin
-	url = ../plugins/cookbook-plugin
-	branch = .
-
 [submodule "plugins/download-commands"]
 	path = plugins/download-commands
 	url = ../plugins/download-commands
diff --git a/.mailmap b/.mailmap
index 598d52d..bd4d222 100644
--- a/.mailmap
+++ b/.mailmap
@@ -1,28 +1,45 @@
 Adrian Görler <adrian.goerler@sap.com>                                                      Adrian Goerler <adrian.goerler@sap.com>
 Ahaan Ugale <ahaanugale@gmail.com>                                                          <augale@codeaurora.org>
 Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@gs.com>
+Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@credit-suisse.com>
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex <alex.ryazantsev@gmail.com>
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex.ryazantsev <alex.ryazantsev@gmail.com>
+Andrew Bonventre <andybons@chromium.org>                                                    <andybons@google.com>
+Becky Siegel <beckysiegel@google.com>                                                       beckysiegel <beckysiegel@google.com>
 Brad Larson <bklarson@gmail.com>                                                            <brad.larson@garmin.com>
-Bruce Zu <bruce.zu@sonymobile.com>                                                          <bruce.zu@sonyericsson.com>
+Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonyericsson.com>
+Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
+Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
 David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
+David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
+David Pursehouse <dpursehouse@collab.net>                                                   <david.pursehouse@sonymobile.com>
 Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Türkoglu <deniz@spotify.com>
 Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Turkoglu <deniz@spotify.com>
+Doug Kelly <dougk.ff7@gmail.com>                                                            <doug.kelly@garmin.com>
 Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@gmail.com>
 Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@sap.com>
+Edwin Kempin <ekempin@google.com>                                                           ekempin <ekempin@google.com>
 Eryk Szymanski <eryksz@gmail.com>                                                           <eryksz@google.com>
 Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik@gandaraj.com>
 Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik.luthander@sonyericsson.com>
-Gustaf Lundh <gustaf.lundh@sonymobile.com>                                                  <gustaf.lundh@sonyericsson.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@axis.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonyericsson.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonymobile.com>
 Hugo Arès <hugo.ares@ericsson.com>                                                          Hugo Ares <hugo.ares@ericsson.com>
+Jacek Centkowski <jcentkowski@collab.net>                                                   <gemincia.programs@gmail.com>
+Jacek Centkowski <jcentkowski@collab.net>                                                   <geminica.programs@gmail.com>
 Jason Huntley <jhuntley@houghtonassociates.com>                                             jhuntley <jhuntley@houghtonassociates.com>
 Jiří Engelthaler <EngyCZ@gmail.com>                                                         <engycz@gmail.com>
 Joe Onorato <onoratoj@gmail.com>                                                            <joeo@android.com>
+Joel Dodge <dodgejoel@gmail.com>                                                            dodgejoel <dodgejoel@gmail.com>
 Johan Björk <jbjoerk@gmail.com>                                                             Johan Bjork <phb@spotify.com>
+JT Olds <hello@jtolds.com>                                                                  <jtolds@gmail.com>
+Lei Sun <lei.sun01@sap.com>                                                                 LeiSun <lei.sun01@sap.com>
 Lincoln Oliveira Campos Do Nascimento <lincoln.oliveiracamposdonascimento@sonyericsson.com> lincoln <lincoln.oliveiracamposdonascimento@sonyericsson.com>
 Luca Milanesio <luca.milanesio@gmail.com>                                                   <luca@gitent-scm.com>
-Magnus Bäck <baeck@google.com>                                                              <magnus.back@sonyericsson.com>
+Magnus Bäck <magnus.back@axis.com>                                                          <baeck@google.com>
+Magnus Bäck <magnus.back@axis.com>                                                          <magnus.back@sonyericsson.com>
 Mark Derricutt <mark.derricutt@smxemail.com>                                                <mark@talios.com>
 Martin Fick <mfick@codeaurora.org>                                                          <mogulguy10@gmail.com>
 Martin Fick <mfick@codeaurora.org>                                                          <mogulguy@yahoo.com>
@@ -33,12 +50,21 @@
 Peter Jönsson <peter.joensson@gmail.com>                                                    Peter Jönsson <peter.joensson@gmail.com>
 Rafael Rabelo Silva <rafael.rabelosilva@sonyericsson.com>                                   rafael.rabelosilva <rafael.rabelosilva@sonyericsson.com>
 Richard Möhn <richard.moehn@posteo.de>                                                      <richard.moehn@fu-berlin.de>
+Sam Saccone <samccone@google.com>                                                           <samccone@gmail.com>
 Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <sasa.zivkov@sap.com>
 Saša Živkov <sasa.zivkov@sap.com>                                                           Saša Živkov <zivkov@gmail.com>
+Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <zivkov@gmail.com>
+Scott Dial <scott@scottdial.com>                                                            <geekmug@gmail.com>
 Shawn Pearce <sop@google.com>                                                               Shawn O. Pearce <sop@google.com>
+Sixin Li <sixin210@gmail.com>                                                               sixin li <sixin210@gmail.com>
+Sven Selberg <svense@axis.com>                                                              <sven.selberg@axis.com>
+Sven Selberg <svense@axis.com>                                                              <sven.selberg@sonymobile.com>
+Tom Wang <twang10@gmail.com>                                                                Tom <twang10@gmail.com>
 Tomas Westling <thomas.westling@sonyericsson.com>                                           thomas.westling <thomas.westling@sonyericsson.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                <ulrik.sjolin@gmail.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@gmail.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@sonyericsson.com>
+Yuxuan 'fishy' Wang <fishywang@google.com>                                                  Yuxuan Wang <fishywang@google.com>
 Zalán Blénessy <zalanb@axis.com>                                                            Zalan Blenessy <zalanb@axis.com>
+飞 李 <lifei@7v1.net>                                                                       lifei <lifei@7v1.net>
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 828234b..40e022d 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -2,18 +2,22 @@
 org.eclipse.jdt.core.compiler.annotation.inheritNullAnnotations=disabled
 org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
 org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull
+org.eclipse.jdt.core.compiler.annotation.nonnull.secondary=
 org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault
+org.eclipse.jdt.core.compiler.annotation.nonnullbydefault.secondary=
 org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable
+org.eclipse.jdt.core.compiler.annotation.nullable.secondary=
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
 org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
 org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
-org.eclipse.jdt.core.compiler.compliance=1.7
+org.eclipse.jdt.core.compiler.compliance=1.8
 org.eclipse.jdt.core.compiler.debug.lineNumber=generate
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
 org.eclipse.jdt.core.compiler.doc.comment.support=enabled
+org.eclipse.jdt.core.compiler.problem.APILeak=warning
 org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
 org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
 org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
@@ -64,12 +68,14 @@
 org.eclipse.jdt.core.compiler.problem.noImplicitStringConversion=warning
 org.eclipse.jdt.core.compiler.problem.nonExternalizedStringLiteral=ignore
 org.eclipse.jdt.core.compiler.problem.nonnullParameterAnnotationDropped=warning
+org.eclipse.jdt.core.compiler.problem.nonnullTypeVariableFromLegacyInvocation=warning
 org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error
 org.eclipse.jdt.core.compiler.problem.nullReference=warning
 org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error
 org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning
 org.eclipse.jdt.core.compiler.problem.overridingPackageDefaultMethod=warning
 org.eclipse.jdt.core.compiler.problem.parameterAssignment=ignore
+org.eclipse.jdt.core.compiler.problem.pessimisticNullAnalysisForFreeTypeVariables=warning
 org.eclipse.jdt.core.compiler.problem.possibleAccidentalBooleanAssignment=warning
 org.eclipse.jdt.core.compiler.problem.potentialNullReference=ignore
 org.eclipse.jdt.core.compiler.problem.potentiallyUnclosedCloseable=ignore
@@ -86,12 +92,16 @@
 org.eclipse.jdt.core.compiler.problem.suppressWarnings=enabled
 org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=disabled
 org.eclipse.jdt.core.compiler.problem.syntheticAccessEmulation=ignore
+org.eclipse.jdt.core.compiler.problem.terminalDeprecation=warning
 org.eclipse.jdt.core.compiler.problem.typeParameterHiding=warning
 org.eclipse.jdt.core.compiler.problem.unavoidableGenericTypeProblems=enabled
 org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
 org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
 org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
 org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
+org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentType=warning
+org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentTypeStrict=disabled
+org.eclipse.jdt.core.compiler.problem.unlikelyEqualsArgumentType=warning
 org.eclipse.jdt.core.compiler.problem.unnecessaryElse=warning
 org.eclipse.jdt.core.compiler.problem.unnecessaryTypeCheck=warning
 org.eclipse.jdt.core.compiler.problem.unqualifiedFieldAccess=ignore
@@ -113,290 +123,4 @@
 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
 org.eclipse.jdt.core.compiler.processAnnotations=enabled
-org.eclipse.jdt.core.compiler.source=1.7
-org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation=0
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_assignment=16
-org.eclipse.jdt.core.formatter.alignment_for_binary_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_compact_if=16
-org.eclipse.jdt.core.formatter.alignment_for_conditional_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_enum_constants=16
-org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer=16
-org.eclipse.jdt.core.formatter.alignment_for_method_declaration=0
-org.eclipse.jdt.core.formatter.alignment_for_multiple_fields=16
-org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_resources_in_try=80
-org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation=16
-org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_union_type_in_multicatch=16
-org.eclipse.jdt.core.formatter.blank_lines_after_imports=1
-org.eclipse.jdt.core.formatter.blank_lines_after_package=1
-org.eclipse.jdt.core.formatter.blank_lines_before_field=0
-org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration=0
-org.eclipse.jdt.core.formatter.blank_lines_before_imports=0
-org.eclipse.jdt.core.formatter.blank_lines_before_member_type=0
-org.eclipse.jdt.core.formatter.blank_lines_before_method=1
-org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk=1
-org.eclipse.jdt.core.formatter.blank_lines_before_package=0
-org.eclipse.jdt.core.formatter.blank_lines_between_import_groups=1
-org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations=2
-org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_array_initializer=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_block=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_block_in_case=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_enum_constant=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_lambda_body=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_method_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_switch=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment=false
-org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment=false
-org.eclipse.jdt.core.formatter.comment.format_block_comments=true
-org.eclipse.jdt.core.formatter.comment.format_header=true
-org.eclipse.jdt.core.formatter.comment.format_html=true
-org.eclipse.jdt.core.formatter.comment.format_javadoc_comments=true
-org.eclipse.jdt.core.formatter.comment.format_line_comments=true
-org.eclipse.jdt.core.formatter.comment.format_source_code=true
-org.eclipse.jdt.core.formatter.comment.indent_parameter_description=false
-org.eclipse.jdt.core.formatter.comment.indent_root_tags=true
-org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags=insert
-org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter=do not insert
-org.eclipse.jdt.core.formatter.comment.line_length=80
-org.eclipse.jdt.core.formatter.comment.new_lines_at_block_boundaries=true
-org.eclipse.jdt.core.formatter.comment.new_lines_at_javadoc_boundaries=true
-org.eclipse.jdt.core.formatter.comment.preserve_white_space_between_code_and_line_comments=false
-org.eclipse.jdt.core.formatter.compact_else_if=true
-org.eclipse.jdt.core.formatter.continuation_indentation=2
-org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer=2
-org.eclipse.jdt.core.formatter.disabling_tag=@formatter\:off
-org.eclipse.jdt.core.formatter.enabling_tag=@formatter\:on
-org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line=false
-org.eclipse.jdt.core.formatter.format_line_comment_starting_on_first_column=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header=true
-org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases=true
-org.eclipse.jdt.core.formatter.indent_empty_lines=false
-org.eclipse.jdt.core.formatter.indent_statements_compare_to_block=true
-org.eclipse.jdt.core.formatter.indent_statements_compare_to_body=true
-org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases=true
-org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch=true
-org.eclipse.jdt.core.formatter.indentation.size=4
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_member=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_package=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_label=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_type_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing=insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_annotation_declaration=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_declaration=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter=insert
-org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_binary_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_ellipsis=insert
-org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow=insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_try=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_try_resources=insert
-org.eclipse.jdt.core.formatter.insert_space_after_unary_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter=insert
-org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_binary_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_try=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_ellipsis=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_try=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while=insert
-org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return=insert
-org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw=insert
-org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_try_resources=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_unary_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.join_lines_in_comments=true
-org.eclipse.jdt.core.formatter.join_wrapped_lines=true
-org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line=false
-org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line=false
-org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line=true
-org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line=false
-org.eclipse.jdt.core.formatter.lineSplit=80
-org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column=false
-org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column=false
-org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body=0
-org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve=3
-org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line=false
-org.eclipse.jdt.core.formatter.tabulation.char=space
-org.eclipse.jdt.core.formatter.tabulation.size=2
-org.eclipse.jdt.core.formatter.use_on_off_tags=false
-org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations=false
-org.eclipse.jdt.core.formatter.wrap_before_binary_operator=true
-org.eclipse.jdt.core.formatter.wrap_before_or_operator_multicatch=true
-org.eclipse.jdt.core.formatter.wrap_outer_expressions_when_nested=true
-org.eclipse.jdt.core.javaFormatter=org.eclipse.jdt.core.defaultJavaFormatter
+org.eclipse.jdt.core.compiler.source=1.8
diff --git a/.settings/org.eclipse.jdt.ui.prefs b/.settings/org.eclipse.jdt.ui.prefs
index d990610..3d5f5f6 100644
--- a/.settings/org.eclipse.jdt.ui.prefs
+++ b/.settings/org.eclipse.jdt.ui.prefs
@@ -1,60 +1,5 @@
 eclipse.preferences.version=1
-editor_save_participant_org.eclipse.jdt.ui.postsavelistener.cleanup=true
-formatter_profile=_Google Format
-formatter_settings_version=12
 org.eclipse.jdt.ui.ignorelowercasenames=true
-org.eclipse.jdt.ui.importorder=\#;com.google;com;dk;eu;junit;net;org;java;javax;
 org.eclipse.jdt.ui.ondemandthreshold=99
 org.eclipse.jdt.ui.staticondemandthreshold=99
 org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates/>
-sp_cleanup.add_default_serial_version_id=true
-sp_cleanup.add_generated_serial_version_id=false
-sp_cleanup.add_missing_annotations=false
-sp_cleanup.add_missing_deprecated_annotations=true
-sp_cleanup.add_missing_methods=false
-sp_cleanup.add_missing_nls_tags=false
-sp_cleanup.add_missing_override_annotations=true
-sp_cleanup.add_serial_version_id=false
-sp_cleanup.always_use_blocks=true
-sp_cleanup.always_use_parentheses_in_expressions=false
-sp_cleanup.always_use_this_for_non_static_field_access=false
-sp_cleanup.always_use_this_for_non_static_method_access=false
-sp_cleanup.convert_to_enhanced_for_loop=false
-sp_cleanup.correct_indentation=false
-sp_cleanup.format_source_code=false
-sp_cleanup.format_source_code_changes_only=false
-sp_cleanup.make_local_variable_final=true
-sp_cleanup.make_parameters_final=true
-sp_cleanup.make_private_fields_final=true
-sp_cleanup.make_type_abstract_if_missing_method=false
-sp_cleanup.make_variable_declarations_final=false
-sp_cleanup.never_use_blocks=false
-sp_cleanup.never_use_parentheses_in_expressions=true
-sp_cleanup.on_save_use_additional_actions=true
-sp_cleanup.organize_imports=false
-sp_cleanup.qualify_static_field_accesses_with_declaring_class=false
-sp_cleanup.qualify_static_member_accesses_through_instances_with_declaring_class=true
-sp_cleanup.qualify_static_member_accesses_through_subtypes_with_declaring_class=true
-sp_cleanup.qualify_static_member_accesses_with_declaring_class=false
-sp_cleanup.qualify_static_method_accesses_with_declaring_class=false
-sp_cleanup.remove_private_constructors=true
-sp_cleanup.remove_trailing_whitespaces=true
-sp_cleanup.remove_trailing_whitespaces_all=true
-sp_cleanup.remove_trailing_whitespaces_ignore_empty=false
-sp_cleanup.remove_unnecessary_casts=false
-sp_cleanup.remove_unnecessary_nls_tags=false
-sp_cleanup.remove_unused_imports=false
-sp_cleanup.remove_unused_local_variables=false
-sp_cleanup.remove_unused_private_fields=true
-sp_cleanup.remove_unused_private_members=false
-sp_cleanup.remove_unused_private_methods=true
-sp_cleanup.remove_unused_private_types=true
-sp_cleanup.sort_members=false
-sp_cleanup.sort_members_all=false
-sp_cleanup.use_blocks=false
-sp_cleanup.use_blocks_only_for_return_and_throw=false
-sp_cleanup.use_parentheses_in_expressions=false
-sp_cleanup.use_this_for_non_static_field_access=false
-sp_cleanup.use_this_for_non_static_field_access_only_if_necessary=true
-sp_cleanup.use_this_for_non_static_method_access=false
-sp_cleanup.use_this_for_non_static_method_access_only_if_necessary=true
diff --git a/.watchmanconfig b/.watchmanconfig
deleted file mode 100644
index 4467aec..0000000
--- a/.watchmanconfig
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "ignore_dirs": [
-    "buck-out",
-    "eclipse-out"
-  ],
-  "ignore_vcs": [
-    ".git"
-  ]
-}
diff --git a/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch b/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch
new file mode 100644
index 0000000..3ccf5cd
--- /dev/null
+++ b/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch
@@ -0,0 +1,133 @@
+Date: Wed, 30 May 2018 21:22:18 +0200
+Subject: [PATCH] Replace native {http,git}_archive with Skylark rules
+
+See [1] for more details.
+
+Test Plan:
+
+* Apply this CL on Bazel master: [2] and build bazel
+* Run with this custom built bazel version:
+
+  $ bazel test //javatests/...
+  $ bazel test //closure/...
+
+[1] https://groups.google.com/d/topic/bazel-discuss/dO2MHQLwJF0/discussion
+[2] https://bazel-review.googlesource.com/#/c/bazel/+/55932/
+---
+ closure/repositories.bzl | 23 ++++++++++++-----------
+ 1 file changed, 12 insertions(+), 11 deletions(-)
+
+diff --git a/closure/repositories.bzl b/closure/repositories.bzl
+index 9b84a72..2816fb6 100644
+--- closure/repositories.bzl
++++ closure/repositories.bzl
+@@ -14,6 +14,7 @@
+ 
+ """External dependencies for Closure Rules."""
+ 
++load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
+ load("//closure/private:java_import_external.bzl", "java_import_external")
+ load("//closure/private:platform_http_file.bzl", "platform_http_file")
+ load("//closure:filegroup_external.bzl", "filegroup_external")
+@@ -405,7 +406,7 @@ def com_google_common_html_types():
+   )
+ 
+ def com_google_common_html_types_html_proto():
+-  native.http_file(
++  http_file(
+       name = "com_google_common_html_types_html_proto",
+       sha256 = "6ece202f11574e37d0c31d9cf2e9e11a0dbc9218766d50d211059ebd495b49c3",
+       urls = [
+@@ -633,7 +634,7 @@ def com_google_javascript_closure_compiler():
+ 
+ def com_google_javascript_closure_library():
+   # After updating: bazel run //closure/library:regenerate -- "$PWD"
+-  native.new_http_archive(
++  http_archive(
+       name = "com_google_javascript_closure_library",
+       urls = [
+           "https://mirror.bazel.build/github.com/google/closure-library/archive/v20180405.tar.gz",
+@@ -658,7 +659,7 @@ def com_google_jsinterop_annotations():
+ 
+ def com_google_protobuf():
+   # Note: Protobuf 3.6.0+ is going to use C++11
+-  native.http_archive(
++  http_archive(
+       name = "com_google_protobuf",
+       strip_prefix = "protobuf-3.5.1",
+       sha256 = "826425182ee43990731217b917c5c3ea7190cfda141af4869e6d4ad9085a740f",
+@@ -669,7 +670,7 @@ def com_google_protobuf():
+   )
+ 
+ def com_google_protobuf_js():
+-  native.new_http_archive(
++  http_archive(
+       name = "com_google_protobuf_js",
+       urls = [
+           "https://mirror.bazel.build/github.com/google/protobuf/archive/v3.5.1.tar.gz",
+@@ -722,7 +723,7 @@ def com_google_template_soy():
+   )
+ 
+ def com_google_template_soy_jssrc():
+-  native.new_http_archive(
++  http_archive(
+       name = "com_google_template_soy_jssrc",
+       sha256 = "c76ab4cb6e46a7c76336640b3c40d6897b420209a6c0905cdcd32533dda8126a",
+       urls = [
+@@ -757,7 +758,7 @@ def com_squareup_javapoet():
+   )
+ 
+ def fonts_noto_hinted_deb():
+-  native.http_file(
++  http_file(
+       name = "fonts_noto_hinted_deb",
+       urls = [
+           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fonts-noto/fonts-noto-hinted_20161116-1_all.deb",
+@@ -767,7 +768,7 @@ def fonts_noto_hinted_deb():
+   )
+ 
+ def fonts_noto_mono_deb():
+-  native.http_file(
++  http_file(
+       name = "fonts_noto_mono_deb",
+       urls = [
+           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fonts-noto/fonts-noto-mono_20161116-1_all.deb",
+@@ -801,7 +802,7 @@ def javax_inject():
+   )
+ 
+ def libexpat_amd64_deb():
+-  native.http_file(
++  http_file(
+       name = "libexpat_amd64_deb",
+       urls = [
+           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/e/expat/libexpat1_2.1.0-6+deb8u3_amd64.deb",
+@@ -811,7 +812,7 @@ def libexpat_amd64_deb():
+   )
+ 
+ def libfontconfig_amd64_deb():
+-  native.http_file(
++  http_file(
+       name = "libfontconfig_amd64_deb",
+       urls = [
+           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fontconfig/libfontconfig1_2.11.0-6.3+deb8u1_amd64.deb",
+@@ -821,7 +822,7 @@ def libfontconfig_amd64_deb():
+   )
+ 
+ def libfreetype_amd64_deb():
+-  native.http_file(
++  http_file(
+       name = "libfreetype_amd64_deb",
+       urls = [
+           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/freetype/libfreetype6_2.5.2-3+deb8u1_amd64.deb",
+@@ -831,7 +832,7 @@ def libfreetype_amd64_deb():
+   )
+ 
+ def libpng_amd64_deb():
+-  native.http_file(
++  http_file(
+       name = "libpng_amd64_deb",
+       urls = [
+           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/libp/libpng/libpng12-0_1.2.50-2+deb8u2_amd64.deb",
+-- 
+2.16.3
+
diff --git a/BUCK b/BUCK
deleted file mode 100644
index 9657ff3..0000000
--- a/BUCK
+++ /dev/null
@@ -1,31 +0,0 @@
-include_defs('//tools/build.defs')
-
-gerrit_war(name = 'gerrit')
-gerrit_war(name = 'gwtgerrit',   ui = 'ui_dbg')
-gerrit_war(name = 'headless',    ui = None)
-gerrit_war(name = 'chrome',      ui = 'ui_chrome')
-gerrit_war(name = 'firefox',     ui = 'ui_firefox')
-gerrit_war(name = 'safari',      ui = 'ui_safari')
-gerrit_war(name = 'polygerrit',  ui = 'polygerrit')
-gerrit_war(name = 'withdocs', docs = True)
-gerrit_war(name = 'release',  ui = 'ui_optdbg_r', docs = True, context = ['//plugins:core'],  visibility = ['//tools/maven:'])
-
-API_DEPS = [
-  '//gerrit-acceptance-framework:acceptance-framework',
-  '//gerrit-acceptance-framework:acceptance-framework-src',
-  '//gerrit-acceptance-framework:acceptance-framework-javadoc',
-  '//gerrit-extension-api:extension-api',
-  '//gerrit-extension-api:extension-api-src',
-  '//gerrit-extension-api:extension-api-javadoc',
-  '//gerrit-plugin-api:plugin-api',
-  '//gerrit-plugin-api:plugin-api-src',
-  '//gerrit-plugin-api:plugin-api-javadoc',
-  '//gerrit-plugin-gwtui:gwtui-api',
-  '//gerrit-plugin-gwtui:gwtui-api-src',
-  '//gerrit-plugin-gwtui:gwtui-api-javadoc',
-]
-
-zip_file(
-  name = 'api',
-  srcs = API_DEPS,
-)
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..fee80fc
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,70 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:pkg_war.bzl", "pkg_war")
+
+package(default_visibility = ["//visibility:public"])
+
+genrule(
+    name = "gen_version",
+    outs = ["version.txt"],
+    cmd = ("cat bazel-out/volatile-status.txt bazel-out/stable-status.txt | " +
+           "grep STABLE_BUILD_GERRIT_LABEL | cut -d ' ' -f 2 > $@"),
+    stamp = 1,
+)
+
+genrule(
+    name = "LICENSES",
+    srcs = ["//Documentation:licenses.txt"],
+    outs = ["LICENSES.txt"],
+    cmd = "cp $< $@",
+)
+
+pkg_war(name = "gerrit")
+
+pkg_war(
+    name = "headless",
+    ui = None,
+)
+
+pkg_war(
+    name = "polygerrit",
+    ui = "polygerrit",
+)
+
+pkg_war(
+    name = "release",
+    context = ["//plugins:core"],
+    doc = True,
+    ui = "ui_optdbg_r",
+)
+
+pkg_war(
+    name = "withdocs",
+    doc = True,
+)
+
+API_DEPS = [
+    "//gerrit-acceptance-framework:acceptance-framework_deploy.jar",
+    "//gerrit-acceptance-framework:liblib-src.jar",
+    "//gerrit-acceptance-framework:acceptance-framework-javadoc",
+    "//gerrit-extension-api:extension-api_deploy.jar",
+    "//gerrit-extension-api:libapi-src.jar",
+    "//gerrit-extension-api:extension-api-javadoc",
+    "//gerrit-plugin-api:plugin-api_deploy.jar",
+    "//gerrit-plugin-api:plugin-api-sources_deploy.jar",
+    "//gerrit-plugin-api:plugin-api-javadoc",
+    "//gerrit-plugin-gwtui:gwtui-api_deploy.jar",
+    "//gerrit-plugin-gwtui:gwtui-api-source_deploy.jar",
+    "//gerrit-plugin-gwtui:gwtui-api-javadoc",
+]
+
+genrule2(
+    name = "api",
+    testonly = 1,
+    srcs = API_DEPS,
+    outs = ["api.zip"],
+    cmd = " && ".join([
+        "cp $(SRCS) $$TMP",
+        "cd $$TMP",
+        "zip -qr $$ROOT/$@ .",
+    ]),
+)
diff --git a/Documentation/BUCK b/Documentation/BUCK
deleted file mode 100644
index 48ca579..0000000
--- a/Documentation/BUCK
+++ /dev/null
@@ -1,80 +0,0 @@
-include_defs('//Documentation/asciidoc.defs')
-include_defs('//Documentation/config.defs')
-include_defs('//Documentation/license.defs')
-include_defs('//tools/git.defs')
-
-DOC_DIR = 'Documentation'
-
-JSUI_JAVA_DEPS = ['//gerrit-gwtui:ui_module']
-JSUI_NON_JAVA_DEPS = ['//polygerrit-ui/app:polygerrit_ui']
-MAIN_JAVA_DEPS = ['//gerrit-pgm:pgm']
-SRCS = glob(['*.txt'], excludes = ['licenses.txt'])
-
-
-genasciidoc(
-  name = 'html',
-  out = 'html.zip',
-  directory = DOC_DIR,
-  srcs = SRCS + [':licenses.txt'],
-  attributes = documentation_attributes(git_describe()),
-  backend = 'html5',
-  visibility = ['PUBLIC'],
-)
-
-genasciidoc(
-  name = 'searchfree',
-  out = 'searchfree.zip',
-  directory = DOC_DIR,
-  srcs = SRCS + [':licenses.txt'],
-  attributes = documentation_attributes(git_describe()),
-  backend = 'html5',
-  searchbox = False,
-  visibility = ['PUBLIC'],
-)
-
-genlicenses(
-  name = 'licenses.txt',
-  opts = ['--asciidoc'],
-  java_deps = JSUI_JAVA_DEPS + MAIN_JAVA_DEPS,
-  non_java_deps = JSUI_NON_JAVA_DEPS,
-  out = 'licenses.txt',
-)
-
-# Required by Google for gerrit-review.
-genlicenses(
-  name = 'js_licenses.txt',
-  opts = ['--partial'],
-  java_deps = JSUI_JAVA_DEPS,
-  non_java_deps = JSUI_NON_JAVA_DEPS,
-  out = 'js_licenses.txt',
-)
-
-python_binary(
-  name = 'gen_licenses',
-  main = 'gen_licenses.py',
-)
-
-python_binary(
-  name = 'replace_macros',
-  main = 'replace_macros.py',
-  visibility = ['//ReleaseNotes:'],
-)
-
-genrule(
-  name = 'index',
-  cmd = '$(exe //lib/asciidoctor:doc_indexer) ' +
-      '-o $OUT ' +
-      '--prefix "%s/" ' % DOC_DIR +
-      '--in-ext ".txt" ' +
-      '--out-ext ".html" ' +
-      '$SRCS ' +
-      '$(location :licenses.txt)',
-  srcs = SRCS,
-  out = 'index.jar',
-)
-
-prebuilt_jar(
-  name = 'index_lib',
-  binary_jar = ':index',
-  visibility = ['PUBLIC'],
-)
diff --git a/Documentation/BUILD b/Documentation/BUILD
new file mode 100644
index 0000000..edb7e2e
--- /dev/null
+++ b/Documentation/BUILD
@@ -0,0 +1,103 @@
+load("//tools/bzl:asciidoc.bzl", "documentation_attributes", "genasciidoc", "genasciidoc_zip")
+load("//tools/bzl:license.bzl", "license_map")
+
+package(default_visibility = ["//visibility:public"])
+
+exports_files([
+    "replace_macros.py",
+])
+
+filegroup(
+    name = "prettify_files",
+    srcs = [
+        ":prettify.min.css",
+        ":prettify.min.js",
+    ],
+)
+
+genrule(
+    name = "prettify_min_css",
+    srcs = ["//gerrit-prettify:src/main/resources/com/google/gerrit/prettify/client/prettify.css"],
+    outs = ["prettify.min.css"],
+    cmd = "cp $< $@",
+)
+
+genrule(
+    name = "prettify_min_js",
+    srcs = ["//gerrit-prettify:src/main/resources/com/google/gerrit/prettify/client/prettify.js"],
+    outs = ["prettify.min.js"],
+    cmd = "cp $< $@",
+)
+
+filegroup(
+    name = "resources",
+    srcs = glob([
+        "images/*.jpg",
+        "images/*.png",
+    ]) + [
+        ":prettify_files",
+        "//:LICENSES.txt",
+    ],
+)
+
+license_map(
+    name = "licenses",
+    opts = ["--asciidoctor"],
+    targets = [
+        "//gerrit-pgm:pgm",
+        "//gerrit-gwtui:ui_module",
+        "//polygerrit-ui/app:polygerrit_ui",
+    ],
+)
+
+license_map(
+    name = "js_licenses",
+    targets = [
+        "//gerrit-gwtui:ui_module",
+        "//polygerrit-ui/app:polygerrit_ui",
+    ],
+)
+
+DOC_DIR = "Documentation"
+
+SRCS = glob(["*.txt"]) + [":licenses.txt"]
+
+genrule(
+    name = "index",
+    srcs = SRCS,
+    outs = ["index.jar"],
+    cmd = "$(location //lib/asciidoctor:doc_indexer) " +
+          "-o $(OUTS) " +
+          "--prefix \"%s/\" " % DOC_DIR +
+          "--in-ext \".txt\" " +
+          "--out-ext \".html\" " +
+          "$(SRCS)",
+    tools = ["//lib/asciidoctor:doc_indexer"],
+)
+
+# For the same srcs, we can have multiple genasciidoc_zip rules, but only one
+# genasciidoc rule. Because multiple genasciidoc rules will have conflicting
+# output files.
+genasciidoc(
+    name = "Documentation",
+    srcs = SRCS,
+    attributes = documentation_attributes(),
+    backend = "html5",
+)
+
+genasciidoc_zip(
+    name = "html",
+    srcs = SRCS,
+    attributes = documentation_attributes(),
+    backend = "html5",
+    directory = DOC_DIR,
+)
+
+genasciidoc_zip(
+    name = "searchfree",
+    srcs = SRCS,
+    attributes = documentation_attributes(),
+    backend = "html5",
+    directory = DOC_DIR,
+    searchbox = False,
+)
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 2cc8c05..e55378f 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -442,6 +442,11 @@
 to projects in Gerrit. It can give permission to abandon a specific
 change to a given ref.
 
+The uploader of a change, anyone granted the <<category_owner,`Owner`>>
+permission at the ref or project level, and anyone granted the
+<<capability_administrateServer,`Administrate Server`>>
+permission can also Abandon changes.
+
 This also grants the permission to restore a change if the user also
 has link:#category_push[push permission] on the change's destination
 ref.
@@ -466,7 +471,10 @@
 
 To push lightweight (non-annotated) tags, grant
 `Create Reference` for reference name `+refs/tags/*+`, as lightweight
-tags are implemented just like branches in Git.
+tags are implemented just like branches in Git. To push a lightweight
+tag on a new commit (commit not reachable from any branch/tag) grant
+`Push` permission on `+refs/tags/*+` too. The `Push` permission on
+`+refs/tags/*+` also allows fast-forwarding of lightweight tags.
 
 For example, to grant the possibility to create new branches under the
 namespace `foo`, you have to grant this permission on
@@ -480,6 +488,19 @@
 you grant the users the push force permission to be able to clean up
 stale branches.
 
+[[category_delete]]
+=== Delete Reference
+
+The delete reference category controls whether it is possible to delete
+references, branches or tags. It doesn't allow any other update of
+references.
+
+Deletion of references is also possible if `Push` with the force option
+is granted, however that includes the permission to fast-forward and
+force-update references to exiting and new commits. Being able to push
+references for new commits is bad if bypassing of code review must be
+prevented.
+
 
 [[category_forge_author]]
 === Forge Author
@@ -553,6 +574,12 @@
 `refs/heads/qa/`. See <<project_owners,project owners>> to find
 out more about this role.
 
+For the `All-Projects` root project any `Owner` access right on
+'refs/*' is ignored since this permission would allow users to edit the
+global capabilities, which is the same as being able to administrate
+the Gerrit server (e.g. the user could assign the `Administrate Server`
+capability to the own account).
+
 
 [[category_push]]
 === Push
@@ -575,7 +602,7 @@
 
 * Force option
 +
-Allows an existing branch to be deleted. Since a force push is
+Implies <<category_delete,Delete Reference>>. Since a force push is
 effectively a delete immediately followed by a create, but performed
 atomically on the server and logged, this option also permits forced
 push updates to branches.  Enabling this option allows existing commits
@@ -599,11 +626,10 @@
 a new commit on their local system, so in practice they must also
 have the `Read` access granted to upload a change.
 
-For an open source, public Gerrit installation, it is common to
-grant `Read` and `Push` for `+refs/for/refs/heads/*+`
-to `Registered Users` in the `All-Projects` ACL.  For more
-private installations, its common to simply grant `Read` and
-`Push` for `+refs/for/refs/heads/*+` to all users of a project.
+For an open source, public Gerrit installation, it is common to grant
+`Push` for `+refs/for/refs/heads/*+` to `Registered Users` in the
+`All-Projects` ACL.  For more private installations, its common to
+grant `Push` for `+refs/for/refs/heads/*+` to all users of a project.
 
 * Force option
 +
@@ -644,7 +670,8 @@
 
 
 [[category_push_annotated]]
-=== Push Annotated Tag
+[[category_create_annotated]]
+=== Create Annotated Tag
 
 This category permits users to push an annotated tag object into the
 project's repository.  Typically this would be done with a command line
@@ -671,7 +698,7 @@
 
 To push tags created by users other than the current user (such
 as tags mirrored from an upstream project), `Forge Committer Identity`
-must be also granted in addition to `Push Annotated Tag`.
+must be also granted in addition to `Create Annotated Tag`.
 
 To push lightweight (non annotated) tags, grant
 <<category_create,`Create Reference`>> for reference name
@@ -682,9 +709,16 @@
 option enabled for reference name `+refs/tags/*+`, as deleting a tag
 requires the same permission as deleting a branch.
 
+To push an annotated tag on a new commit (commit not reachable from any
+branch/tag) grant `Push` permission on `+refs/tags/*+` too.
+The `Push` permission on `+refs/tags/*+` does *not* allow updating of annotated
+tags, not even fast-forwarding of annotated tags. Update of annotated tags
+is only allowed by granting `Push` with `force` option on `+refs/tags/*+`.
+
 
 [[category_push_signed]]
-=== Push Signed Tag
+[[category_create_signed]]
+=== Create Signed Tag
 
 This category permits users to push a PGP signed tag object into the
 project's repository.  Typically this would be done with a command
@@ -796,6 +830,15 @@
 the caller needs to have the Submit permission on `refs/for/<ref>`
 (e.g. on `refs/for/refs/heads/master`).
 
+Submitting to the `refs/meta/config` branch is only allowed to project
+owners. Any explicit submit permissions for non-project-owners on this
+branch are ignored. By submitting to the `refs/meta/config` branch the
+configuration of the project is changed, which can include changes to
+the access rights of the project. Allowing this to be done by a
+non-project-owner would open a security hole enabling editing of access
+rights, and thus granting of powers beyond submitting to the
+configuration.
+
 [[category_submit_on_behalf_of]]
 === Submit (On Behalf Of)
 
@@ -838,6 +881,23 @@
 the `Delete Drafts` access right assigned).
 
 
+[[category_delete_own_changes]]
+=== Delete Own Changes
+
+This category permits users to delete their own changes if they are not merged
+yet. This means only own changes that are open or abandoned can be deleted.
+
+[[category_delete_changes]]
+=== Delete Changes
+
+This category permits users to delete other users' changes if they are not merged
+yet. This means only changes that are open or abandoned can be deleted.
+
+Having this permission implies having the link:#category_delete_own_changes[
+Delete Own Changes] permission.
+
+Administrators may always delete changes without having this permission.
+
 [[category_edit_topic_name]]
 === Edit Topic Name
 
@@ -863,6 +923,14 @@
 can always edit or remove hashtags (even without having the `Edit Hashtags`
 access right assigned).
 
+[[category_edit_assigned_to]]
+=== Edit Assignee
+
+This category permits users to set who is assigned to a change that is
+uploaded for review.
+
+The change owner, ref owners, and the user currently assigned to a change
+can always change the assignee.
 
 [[example_roles]]
 == Examples of typical roles in a project
@@ -997,7 +1065,7 @@
 * <<category_push_merge,`Push merge commit`>> to 'refs/heads/*'
 * <<category_forge_committer,`Forge Committer Identity`>> to 'refs/for/refs/heads/*'
 * <<category_create,`Create Reference`>> to 'refs/heads/*'
-* <<category_push_annotated,`Push Annotated Tag`>> to 'refs/tags/*'
+* <<category_create_annotated,`Create Annotated Tag`>> to 'refs/tags/*'
 
 
 [[examples_project-owner]]
@@ -1067,12 +1135,15 @@
 [[block]]
 === 'BLOCK' access rule
 
-The 'BLOCK' rule blocks a permission globally. An inherited 'BLOCK' rule cannot
-be overridden in the inheriting project. Any 'ALLOW' rule, from a different
-access section or from an inheriting project, which conflicts with an
-inherited 'BLOCK' rule will not be honored.  Searching for 'BLOCK' rules, in
-the chain of parent projects, ignores the Exclusive flag that is normally
-applied to access sections.
+The 'BLOCK' rule blocks a permission globally. An inherited 'BLOCK'
+rule cannot be overridden in the inheriting project. Any 'ALLOW' rule
+from an inheriting project, which conflicts with an inherited 'BLOCK'
+rule will not be honored. Searching for 'BLOCK' rules, in the chain
+of parent projects, ignores the Exclusive flag, unless the rule with
+the Exclusive flag is defined on the same project as the 'BLOCK'
+rule. This means within the same project a 'BLOCK' rule can be
+overruled by 'ALLOW' rules on the same access section and 'ALLOW'
+rules with Exclusive flag on access section for more specific refs.
 
 A 'BLOCK' rule that blocks the 'push' permission blocks any type of push,
 force or not. A blocking force push rule blocks only force pushes, but
diff --git a/Documentation/asciidoc.defs b/Documentation/asciidoc.defs
deleted file mode 100644
index 4b17071..0000000
--- a/Documentation/asciidoc.defs
+++ /dev/null
@@ -1,113 +0,0 @@
-# Copyright (C) 2013 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.
-
-def genasciidoc_htmlonly(
-    name,
-    out,
-    srcs = [],
-    attributes = [],
-    backend = None,
-    searchbox = True,
-    visibility = []):
-  EXPN = '.' + name + '_expn'
-
-  asciidoc = [
-      '$(exe //lib/asciidoctor:asciidoc)',
-      '-z', '$OUT',
-      '--base-dir', '$SRCDIR',
-      '--tmp', '$TMP',
-      '--in-ext', '".txt%s"' % EXPN,
-      '--out-ext', '".html"',
-  ]
-  if backend:
-    asciidoc.extend(['-b', backend])
-  for attribute in attributes:
-    asciidoc.extend(['-a', attribute])
-  asciidoc.append('$SRCS')
-  newsrcs = []
-  for src in srcs:
-    fn = src
-    # We have two cases: regular source files and generated files.
-    # Generated files are passed as targets ':foo', and ':' is removed.
-    # 1. regular files: cmd = '-s foo', srcs = ['foo']
-    # 2. generated files: cmd = '-s $(location :foo)', srcs = []
-    srcs = [src]
-    passed_src = fn
-    if fn.startswith(':') :
-      fn = src[1:]
-      srcs = []
-      passed_src = '$(location :%s)' % fn
-    ex = fn + EXPN
-
-    genrule(
-      name = ex,
-      cmd = '$(exe //Documentation:replace_macros) --suffix="%s"' % EXPN +
-        ' -s ' + passed_src + ' -o $OUT' +
-        (' --searchbox' if searchbox else ' --no-searchbox'),
-      srcs = srcs,
-      out = ex,
-    )
-
-    newsrcs.append(':%s' % ex)
-
-  genrule(
-    name = name,
-    cmd = ' '.join(asciidoc),
-    srcs = newsrcs,
-    out = out,
-    visibility = visibility,
-  )
-
-def genasciidoc(
-    name,
-    out,
-    directory,
-    srcs = [],
-    attributes = [],
-    backend = None,
-    searchbox = True,
-    resources = True,
-    visibility = []):
-  SUFFIX = '_htmlonly'
-
-  genasciidoc_htmlonly(
-    name = name + SUFFIX if resources else name,
-    srcs = srcs,
-    attributes = attributes,
-    backend = backend,
-    searchbox = searchbox,
-    out = (name + SUFFIX + '.zip') if resources else (name + '.zip'),
-  )
-
-  if resources:
-    genrule(
-      name = name,
-      cmd = 'cd $TMP;' +
-        'mkdir -p %s/images;' % directory +
-        'unzip -q $(location %s) -d %s/;'
-        % (':' + name + SUFFIX, directory) +
-        'for s in $SRCS;do ln -s $s %s/;done;' % directory +
-        'mv %s/*.{jpg,png} %s/images;' % (directory, directory) +
-        'cp $(location %s) LICENSES.txt;' % ':licenses.txt' +
-        'zip -qr $OUT *',
-      srcs = glob([
-          'images/*.jpg',
-          'images/*.png',
-        ]) + [
-          '//gerrit-prettify:prettify.min.css',
-          '//gerrit-prettify:prettify.min.js',
-        ],
-      out = out,
-      visibility = visibility,
-    )
diff --git a/Documentation/cmd-apropos.txt b/Documentation/cmd-apropos.txt
index 31d21c1..2ef71bf 100644
--- a/Documentation/cmd-apropos.txt
+++ b/Documentation/cmd-apropos.txt
@@ -15,7 +15,7 @@
 from the matched documents.
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 503bd12..e8c3857 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -60,7 +60,14 @@
 
 --owner::
 -o::
-	Name of the group(s) which will initially own this repository.
+	Identifier of the group(s) which will initially own this repository.
+
+        This can be:
+
+        * the UUID of the group
+        * the legacy numeric ID of the group
+        * the name of the group if it is unique
+
 	The specified group(s) must already be defined within Gerrit.
 	Several groups can be specified on the command line.
 +
@@ -102,6 +109,7 @@
 * FAST_FORWARD_ONLY: produces a strictly linear history.
 * MERGE_IF_NECESSARY: create a merge commit when required.
 * REBASE_IF_NECESSARY: rebase the commit when required.
+* REBASE_ALWAYS: always rebase the commit including dependencies.
 * MERGE_ALWAYS: always create a merge commit.
 * CHERRY_PICK: always cherry-pick the commit.
 
diff --git a/Documentation/cmd-index-activate.txt b/Documentation/cmd-index-activate.txt
index 418e872..4428d12 100644
--- a/Documentation/cmd-index-activate.txt
+++ b/Documentation/cmd-index-activate.txt
@@ -31,12 +31,13 @@
   Currently supported values:
     * changes
     * accounts
+    * groups
 
 == EXAMPLES
 Activate the latest change index:
 
 ----
-  $ ssh -p 29418 review.example.com gerrit activate changes
+  $ ssh -p 29418 review.example.com gerrit index activate changes
 ----
 
 GERRIT
diff --git a/Documentation/cmd-index-project.txt b/Documentation/cmd-index-project.txt
new file mode 100644
index 0000000..2196a26
--- /dev/null
+++ b/Documentation/cmd-index-project.txt
@@ -0,0 +1,37 @@
+= gerrit index project
+
+== NAME
+gerrit index project - Index all the changes in one or more projects.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit index project_ <PROJECT> [<PROJECT> ...]
+--
+
+== DESCRIPTION
+Index all the changes in one or more projects.
+
+== ACCESS
+Caller must have the 'Maintain Server' capability.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== OPTIONS
+<PROJECT>::
+    Required; name of the project to be indexed.
+
+== EXAMPLES
+Index all changes in projects MyProject and NiceProject.
+
+----
+    $ ssh -p 29418 user@review.example.com gerrit index project MyProject NiceProject
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-index-start.txt b/Documentation/cmd-index-start.txt
index fbe4f3f..9264999 100644
--- a/Documentation/cmd-index-start.txt
+++ b/Documentation/cmd-index-start.txt
@@ -20,6 +20,8 @@
 Gerrit. This command will not start the indexer if it is already running or if
 the active index is the latest.
 
+The link:cmd-show-queue.html[show-queue] command provides online index status.
+
 == ACCESS
 Caller must be a member of the privileged 'Administrators' group.
 
@@ -32,6 +34,7 @@
   Currently supported values:
     * changes
     * accounts
+    * groups
 
 --force::
   Force an online re-index.
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 7af65ce..2880ec7 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -18,12 +18,14 @@
 For more details on how to determine the correct SSH port number,
 see link:user-upload.html#test_ssh[Testing Your SSH Connection].
 
-=== [[client_commands]]Commands
+[[client_commands]]
+=== Commands
 
 link:cmd-cherry-pick.html[gerrit-cherry-pick]::
 	Download and cherry-pick one or more changes (commits).
 
-=== [[client_hooks]]Hooks
+[[client_hooks]]
+=== Hooks
 
 Client hooks can be installed into a local Git repository, improving
 the developer experience when working with a Gerrit Code Review
@@ -47,7 +49,8 @@
 For more details on how to determine the correct SSH port number,
 see link:user-upload.html#test_ssh[Testing Your SSH Connection].
 
-=== [[user_commands]]User Commands
+[[user_commands]]
+=== User Commands
 
 link:cmd-apropos.html[gerrit apropos]::
 	Search Gerrit documentation index.
@@ -103,8 +106,8 @@
 git upload-pack::
 	Standard Git server side command for client side `git fetch`.
 
-[[admin_commands]]Administrator Commands
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+[[admin_commands]]
+=== Administrator Commands
 
 link:cmd-close-connection.html[gerrit close-connection]::
 	Close the specified SSH connection.
@@ -136,6 +139,9 @@
 link:cmd-index-changes.html[gerrit index changes]::
 	Index one or more changes.
 
+link:cmd-index-project.html[gerrit index project]::
+	Index all the changes in one or more projects.
+
 link:cmd-logging-ls-level.html[gerrit logging ls-level]::
 	List loggers and their logging level.
 
diff --git a/Documentation/cmd-ls-groups.txt b/Documentation/cmd-ls-groups.txt
index d8eef8b..6d4bdc5 100644
--- a/Documentation/cmd-ls-groups.txt
+++ b/Documentation/cmd-ls-groups.txt
@@ -23,7 +23,7 @@
 all groups are listed.
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/cmd-ls-members.txt b/Documentation/cmd-ls-members.txt
index a6d492c..273451b 100644
--- a/Documentation/cmd-ls-members.txt
+++ b/Documentation/cmd-ls-members.txt
@@ -16,7 +16,7 @@
 shown tab-separated.
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts. Output is either an error
diff --git a/Documentation/cmd-ls-projects.txt b/Documentation/cmd-ls-projects.txt
index e2e71ff..c5d73ca 100644
--- a/Documentation/cmd-ls-projects.txt
+++ b/Documentation/cmd-ls-projects.txt
@@ -14,6 +14,7 @@
   [--format {text | json | json_compact}]
   [--all]
   [--limit <N>]
+  [--prefix | -p <prefix>]
   [--has-acl-for GROUP]
 --
 
@@ -25,7 +26,7 @@
 group, all projects are listed.
 
 == ACCESS
-Any user who has configured an SSH key, or by an user over HTTP.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts.
@@ -87,6 +88,9 @@
 --limit::
 	Cap the number of results to the first N matches.
 
+--prefix::
+	Limit the results to those projects that start with the specified prefix.
+
 --has-acl-for::
 	Display only projects on which access rights for this group are
 	directly assigned. Projects which only inherit access rights for
diff --git a/Documentation/cmd-query.txt b/Documentation/cmd-query.txt
index 1faf1b0..90e5cdd 100644
--- a/Documentation/cmd-query.txt
+++ b/Documentation/cmd-query.txt
@@ -108,7 +108,7 @@
 	will be used to cut the result set.
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/cmd-receive-pack.txt b/Documentation/cmd-receive-pack.txt
index 798f872..b62b9a9 100644
--- a/Documentation/cmd-receive-pack.txt
+++ b/Documentation/cmd-receive-pack.txt
@@ -37,7 +37,7 @@
 	Deprecated, use `refs/for/branch%cc=address` instead.
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == EXAMPLES
 
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 53e2385..8f40d6c 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -63,7 +63,7 @@
 
 --json::
 -j::
-	Read review input from JSON file. See
+	Read review input json from stdin. See
 	link:rest-api-changes.html#review-input[ReviewInput] entity for the
 	format.
 	(option is mutually exclusive with --submit, --restore, --publish, --delete,
@@ -150,7 +150,7 @@
   invocations of the SSH command are required.
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/cmd-set-members.txt b/Documentation/cmd-set-members.txt
index ae44843..5fb2bb9 100644
--- a/Documentation/cmd-set-members.txt
+++ b/Documentation/cmd-set-members.txt
@@ -49,7 +49,7 @@
 order: `--remove`, `--exclude`, `--add`, `--include`
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/cmd-set-project.txt b/Documentation/cmd-set-project.txt
index 62d6e92..7282e28 100644
--- a/Documentation/cmd-set-project.txt
+++ b/Documentation/cmd-set-project.txt
@@ -53,6 +53,7 @@
 * FAST_FORWARD_ONLY: produces a strictly linear history.
 * MERGE_IF_NECESSARY: create a merge commit when required.
 * REBASE_IF_NECESSARY: rebase the commit when required.
+* REBASE_ALWAYS: always rebase the commit including dependencies.
 * MERGE_ALWAYS: always create a merge commit.
 * CHERRY_PICK: always cherry-pick the commit.
 
diff --git a/Documentation/cmd-set-reviewers.txt b/Documentation/cmd-set-reviewers.txt
index 3d53456..eb4335b 100644
--- a/Documentation/cmd-set-reviewers.txt
+++ b/Documentation/cmd-set-reviewers.txt
@@ -20,7 +20,7 @@
 
 Changes can be specified in the
 link:rest-api-changes.html#change-id[same format] supported by the REST
-API.
+API, as well as with the commit SHA-1 (at least the 7 first characters).
 
 == OPTIONS
 
@@ -47,7 +47,7 @@
 	Display site-specific usage information
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts.
@@ -77,6 +77,13 @@
 	  Iac6b2ac2
 ----
 
+Add all project owners as reviewers to commit 13dff08acca571b22542ebd2e31acf4572ea0b86.
+----
+	$ ssh -p 29418 review.example.com gerrit set-reviewers \
+	  -a "'Project Owners'" \
+	  13dff08acca571b22542ebd2e31acf4572ea0b86
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/cmd-show-queue.txt b/Documentation/cmd-show-queue.txt
index 02f1c5b..141f7e2 100644
--- a/Documentation/cmd-show-queue.txt
+++ b/Documentation/cmd-show-queue.txt
@@ -1,7 +1,7 @@
 = gerrit show-queue
 
 == NAME
-gerrit show-queue - Display the background work queues, including replication
+gerrit show-queue - Display the background work queues, including replication and indexing
 
 == SYNOPSIS
 [verse]
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 1cfb8b9..1fdf3a8 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -1,5 +1,4 @@
 = gerrit stream-events
-
 == NAME
 gerrit stream-events - Monitor events occurring in real time
 
@@ -59,6 +58,21 @@
 
 [[events]]
 == EVENTS
+=== Assignee Changed
+
+Sent when the assignee of a change has been modified.
+
+type:: "assignee-changed"
+
+change:: link:json.html#change[change attribute]
+
+changer:: link:json.html#account[account attribute]
+
+oldAssignee:: Assignee before it was changed.
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 === Change Abandoned
 
 Sent when a change has been abandoned.
@@ -76,6 +90,16 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+== Change Deleted
+
+Sent when a change has been deleted.
+
+type:: "change-deleted"
+
+change:: link:json.html#change[change attribute]
+
+deleter:: link:json.html#account[account attribute]
+
 === Change Merged
 
 Sent when a change has been merged into the git repository.
@@ -241,9 +265,9 @@
 
 patchSet:: link:json.html#patchSet[patchSet attribute]
 
-reviewer:: link:json.html#account[account attribute]
+reviewer:: reviewer that was removed as link:json.html#account[account attribute]
 
-author:: link:json.html#account[account attribute]
+remover:: user that removed the reviewer as link:json.html#account[account attribute]
 
 approvals:: All link:json.html#approval[approval attributes] removed.
 
@@ -267,6 +291,24 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+=== Vote Deleted
+
+Sent when a vote was removed from a change.
+
+type:: "vote-deleted"
+
+change:: link:json.html#change[change attribute]
+
+patchSet:: link:json.html#patchSet[patchSet attribute]
+
+reviewer:: user whose vote was removed as link:json.html#account[account attribute]
+
+remover:: user who removed the vote as link:json.html#account[account attribute]
+
+approvals:: all votes as link:json.html#approval[approval attributes]
+
+comment:: Review comment cover message.
+
 == SEE ALSO
 
 * link:json.html[JSON Data Formats]
diff --git a/Documentation/cmd-version.txt b/Documentation/cmd-version.txt
index cc797cc..85b0491 100644
--- a/Documentation/cmd-version.txt
+++ b/Documentation/cmd-version.txt
@@ -26,7 +26,7 @@
 `<n>` is computed.
 
 == ACCESS
-Any user who has configured an SSH key.
+Any user who has SSH access to Gerrit.
 
 == SCRIPTING
 This command is intended to be used in scripts.
diff --git a/Documentation/config-cla.txt b/Documentation/config-cla.txt
index c07a24f..2234808 100644
--- a/Documentation/config-cla.txt
+++ b/Documentation/config-cla.txt
@@ -37,8 +37,13 @@
 Each `contributor-agreement` section within the `project.config` file must
 have a unique name. The section name will appear in the web UI.
 
-If not already present, add the UUID of the groups used in the
-`autoVerify` and `accepted` variables in the groups file.
+If not already present, add the group(s) used in the `autoVerify` and
+`accepted` variables in the `groups` file:
+----
+    # UUID                                  	Group Name
+    #
+    3dedb32915ecdbef5fced9f0a2587d164cd614d4	CLA Accepted - Individual
+----
 
 Commit the configuration change, and push it back:
 ----
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index e8d504a..3d7b084 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -24,9 +24,9 @@
 
 [[accountPatchReviewDb.url]]accountPatchReviewDb.url::
 +
-The url of accountPatchReviewDb. Supported types are `H2`, `POSTGRESQL`, and
-`MYSQL`. Drop the driver jar in the lib folder of the site path if the Jdbc
-driver of the corresponding Database is not yet in the class path.
+The url of accountPatchReviewDb. Supported types are `H2`, `POSTGRESQL`,
+`MARIADB`, and `MYSQL`. Drop the driver jar in the lib folder of the site path
+if the Jdbc driver of the corresponding Database is not yet in the class path.
 +
 Default is to create H2 database in the db folder of the site path.
 +
@@ -42,6 +42,51 @@
   url = jdbc:postgresql://<host>:<port>/<db_name>?user=<user>&password=<password>
 ----
 
+[[accountPatchReviewDb.poolLimit]]accountPatchReviewDb.poolLimit::
++
+Maximum number of open database connections.  If the server needs
+more than this number, request processing threads will wait up
+to <<accountPatchReviewDb.poolMaxWait, poolMaxWait>> seconds for a
+connection to be released before they abort with an exception.
+This limit must be several units higher than the total number of
+httpd and sshd threads as some request processing code paths may
+need multiple connections.
++
+Default is <<sshd.threads, sshd.threads>>
+ + <<httpd.maxThreads, httpd.maxThreads>> + 2.
++
+
+[[accountPatchReviewDb.poolMinIdle]]database.poolMinIdle::
++
+Minimum number of connections to keep idle in the pool.
+Default is 4.
++
+
+[[accountPatchReviewDb.poolMaxIdle]]accountPatchReviewDb.poolMaxIdle::
++
+Maximum number of connections to keep idle in the pool.  If there
+are more idle connections, connections will be closed instead of
+being returned back to the pool.
+Default is min(<<accountPatchReviewDb.poolLimit, accountPatchReviewDb.poolLimit>>, 16).
++
+
+[[accountPatchReviewDb.poolMaxWait]]accountPatchReviewDb.poolMaxWait::
++
+Maximum amount of time a request processing thread will wait to
+acquire a database connection from the pool.  If no connection is
+released within this time period, the processing thread will abort
+its current operations and return an error to the client.
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
+
++
+If a unit suffix is not specified, `milliseconds` is assumed.
+Default is `30 seconds`.
+
 [[accounts]]
 === Section accounts
 
@@ -90,6 +135,15 @@
 +
 Default is 20.
 
+[[addReviewer.baseWeight]]addReviewer.baseWeight::
++
+The weight that will be applied in the default reviewer ranking algorithm.
+This can be increased or decreased to give more or less influence to plugins.
+If set to zero, the base ranking will not have any effect. Reviewers will then
+be ordered as ranked by the plugins (if there are any).
++
+By default 1.
+
 [[auth]]
 === Section auth
 
@@ -126,7 +180,9 @@
 a user's full name and email address based on information obtained
 from the user's account object in LDAP.  The user's group membership
 is also pulled from LDAP, making any LDAP groups that a user is a
-member of available as groups in Gerrit.
+member of available as groups in Gerrit. Hence the `_LDAP` suffix in
+the name of this authentication type. Gerrit does NOT authenticate
+the user via LDAP.
 +
 * `CLIENT_SSL_CERT_LDAP`
 +
@@ -137,7 +193,8 @@
 into the <review-site>/etc/keystore.
 After the authentication is done Gerrit will obtain basic user
 registration (name and email) from LDAP, and some group memberships.
-Therefore, the "_LDAP" suffix in the name of this authentication type.
+Hence the `_LDAP` suffix in the name of this authentication type.
+Gerrit does NOT authenticate the user via LDAP.
 This authentication type can only be used under hosted daemon mode, and
 the httpd.listenUrl must use https:// as the protocol.
 Optionally, certificate revocation list file can be used
@@ -155,6 +212,14 @@
 directory using either an anonymous request, or the configured
 <<ldap.username,ldap.username>> identity. Gerrit can also use kerberos if
 <<ldap.authentication,ldap.authentication>> is set to `GSSAPI`.
++
+If link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
+the randomly generated HTTP password is used for authentication. On the other hand,
+if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
+the password in the request is first checked against the HTTP password and, if
+it does not match, it is then validated against the LDAP password.
+Service users that only exist in the Gerrit database are authenticated by their
+HTTP passwords.
 
 * `LDAP_BIND`
 +
@@ -178,6 +243,12 @@
 Site owners have to register their application before getting started. Note
 that provider specific plugins must be used with this authentication scheme.
 +
+Git clients may send OAuth 2 access tokens instead of passwords in the Basic
+authentication header. Note that provider specific plugins must be installed to
+facilitate this authentication scheme. If multiple OAuth 2 provider plugins are
+installed one of them must be selected as default with the
+`auth.gitOAuthProvider` option.
++
 * `DEVELOPMENT_BECOME_ANY_ACCOUNT`
 +
 *DO NOT USE*.  Only for use in a development environment.
@@ -293,7 +364,7 @@
 [[auth.httpHeader]]auth.httpHeader::
 +
 HTTP header to trust the username from, or unset to select HTTP basic
-or digest authentication.  Only used if `auth.type` is set to `HTTP`.
+authentication.  Only used if `auth.type` is set to `HTTP`.
 
 [[auth.httpDisplaynameHeader]]auth.httpDisplaynameHeader::
 +
@@ -459,62 +530,39 @@
 the container.
 +
 This parameter only affects git over http traffic. If set to false
-then Gerrit will do the authentication (using DIGEST authentication).
+then Gerrit will do the authentication (using Basic authentication).
 +
 By default this is set to false.
 
-[[auth.gitBasicAuth]]auth.gitBasicAuth::
-+
-If true then Git over HTTP and HTTP/S traffic is authenticated using
-standard BasicAuth. Depending on the configured `auth.type`, credentials
-are validated against the randomly generated HTTP password, against LDAP
-(`auth.type = LDAP`) or against an OAuth 2 provider (`auth.type = OAUTH`).
-+
-This parameter affects git over HTTP traffic and access to the REST
-API. If set to false then Gerrit will authenticate through DIGEST
-authentication and the randomly generated HTTP password in the Gerrit
-database.
-+
-When `auth.type` is `LDAP`, users should authenticate using their LDAP passwords.
-However, if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP`,
-the randomly generated HTTP password is used exclusively. In the other hand,
-if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
-the password in the request is first checked against the HTTP password and, if
-it does not match, it is then validated against the LDAP password.
-Service users that only exist in the Gerrit database are authenticated by their
-HTTP passwords.
-+
-When `auth.type` is `OAUTH`, Git clients may send OAuth 2 access tokens
-instead of passwords in the Basic authentication header. Note that provider
-specific plugins must be installed to facilitate this authentication scheme.
-If multiple OAuth 2 provider plugins are installed one of them must be
-selected as default with the `auth.gitOAuthProvider` option.
-+
-By default this is set to false.
 
 [[auth.gitBasicAuthPolicy]]auth.gitBasicAuthPolicy::
 +
-When `auth.type` is `LDAP` and BasicAuth (i.e., link:#auth.gitBasicAuth[`auth.gitBasicAuth`]
-is set to true), it allows using either the generated HTTP password, the LDAP
-password or both to authenticate Git over HTTP and REST API requests. The
-supported values are:
+When `auth.type` is `LDAP`, `LDAP_BIND` or `OAUTH`, it allows using either the generated
+HTTP password, the LDAP or OAUTH password, or a combination of HTTP and LDAP
+authentication, to authenticate Git over HTTP and REST API requests.
+The supported values are:
 +
 *`HTTP`
 +
-Only the randomly generated HTTP password is accepted when doing Git over HTTP
-and REST API requests.
+Only the HTTP password is accepted when doing Git over HTTP and REST API requests.
 +
 *`LDAP`
 +
 Only the `LDAP` password is allowed when doing Git over HTTP and REST API
 requests.
 +
+*`OAUTH`
++
+Only the `OAUTH` authentication is allowed when doing Git over HTTP and REST API
+requests.
++
 *`HTTP_LDAP`
 +
 The password in the request is first checked against the HTTP password and, if
 it does not match, it is then validated against the `LDAP` password.
 +
-By default this is set to `LDAP` when link:#auth.type[`auth.type`] is `LDAP`.
+By default this is set to `LDAP` when link:#auth.type[`auth.type`] is `LDAP`
+and `OAUTH` when link:#auth.type[`auth.type`] is `OAUTH`.
 Otherwise, the default value is `HTTP`.
 
 [[auth.gitOAuthProvider]]auth.gitOAuthProvider::
@@ -600,7 +648,7 @@
 allows to limit the memory used by H2 and thus prevent out-of-memory
 caused by the H2 database using too much memory.
 +
-See <<database.h2.cachesize,database.h2.cachesize>> for a detailed discussion.
+See <<database.h2.cacheSize,database.h2.cacheSize>> for a detailed discussion.
 +
 Default is unset, using up to half of the available memory.
 +
@@ -665,6 +713,7 @@
 * `"adv_bases"`: default is `4096`
 * `"diff"`: default is `10m` (10 MiB of memory)
 * `"diff_intraline"`: default is `10m` (10 MiB of memory)
+* `"diff_summary"`: default is `10m` (10 MiB of memory)
 * `"plugin_resources"`: default is 2m (2 MiB of memory)
 
 +
@@ -680,7 +729,10 @@
 grow larger than this during the day, as the size check is only
 performed once every 24 hours.
 +
-Default is 128 MiB per cache.
+Default is 128 MiB per cache, except:
++
+* `"diff_summary"`: default is `1g` (1 GiB of disk space)
+
 +
 If 0, disk storage for the cache is disabled.
 
@@ -752,6 +804,16 @@
 cache.diff.memoryLimit to fit all files users will view in a 1 or 2
 day span.
 
+cache `"diff_summary"`::
++
+Each item caches list of file paths which are different between two
+commits. Gerrit uses this cache to accelerate computing of the list
+of paths of changed files.
++
+Ideally, disk limit of this cache is large enough to cover all changes.
+This should significantly speed up change reindexing, especially
+full offline reindexing.
+
 cache `"git_tags"`::
 +
 If branch or reference level READ access controls are used, this
@@ -827,10 +889,6 @@
 is per-user, so 1024 items translates to 1024 unique user accounts.
 As each individual user account may configure multiple SSH keys,
 the total number of keys may be larger than the item count.
-+
-This cache is based off the `account_ssh_keys` table and the
-`accounts.ssh_user_name` column in the database.  If either is
-modified directly, this cache should be flushed.
 
 cache `"web_sessions"`::
 +
@@ -1034,6 +1092,13 @@
 +
 Default is true.
 
+[[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
++
+Show assignee field in changes table. If set to false, assignees will
+not be visible in changes table.
++
+Default is false.
+
 [[change.submitLabel]]change.submitLabel::
 +
 Label name for the submit button.
@@ -1185,19 +1250,6 @@
 link:#schedule-examples[Schedule examples] can be found in the
 link:#gc[gc] section.
 
-[[changeMerge]]
-=== Section changeMerge
-
-[[changeMerge.checkFrequency]]changeMerge.checkFrequency::
-+
-How often the database should be rescanned for changes that have been
-submitted but not merged due to transient errors. Values can be
-specified using standard time unit abbreviations ('ms', 'sec', 'min',
-etc.). Set to 0 to disable periodic rescanning, only scanning once on
-master node startup.
-+
-Default is 300 seconds (5 minutes).
-
 [[commentlink]]
 === Section commentlink
 
@@ -1538,12 +1590,16 @@
 +
 * `MAXDB`
 +
-Connect to an SAP MaxDb database server.
+Connect to an SAP MaxDB database server.
 +
 * `MYSQL`
 +
 Connect to a MySQL database server.
 +
+* `MARIADB`
++
+Connect to a MariaDB database server.
++
 * `ORACLE`
 +
 Connect to an Oracle database server.
@@ -1827,6 +1883,15 @@
 For this reason `zip` format is always excluded from formats offered
 through the `Download` drop down or accessible in the REST API.
 
+[[download.maxBundleSize]]download.maxBundleSize::
++
+Specifies the maximum size of a bundle in bytes that can be downloaded.
+As bundles are kept in memory this setting is to protect the server
+from a single request consuming too much heap when generating
+a bundle and thereby impacting other users.
++
+Defaults to 100MB.
+
 [[gc]]
 === Section gc
 
@@ -1996,6 +2061,23 @@
 by the system administrator, and might not even be running on the
 same host as Gerrit.
 
+[[gerrit.installModule]]gerrit.installModule::
++
+Repeatable list of class name of additional Guice modules to load at
+Gerrit startup and init phases.
+Classes are resolved using the primary Gerrit class loader, hence the
+class needs to be either declared in Gerrit or an additional JAR
+located under the `/lib` directory.
++
+By default unset.
++
+Example:
+----
+[gerrit]
+  installModule = com.googlesource.gerrit.libmodule.MyModule
+  installModule = com.example.abc.OurSpecialSauceModule
+----
+
 [[gerrit.reportBugUrl]]gerrit.reportBugUrl::
 +
 URL to direct users to when they need to report a bug.
@@ -2034,6 +2116,24 @@
 +
 By default false.
 
+[[gerrit.cdnPath]]gerrit.cdnPath::
++
+Path prefix for PolyGerrit's static resources if using a CDN.
+
+[[gerrit.ui]]gerrit.ui::
++
+Default UI when the user does not request a different preference via argument
+or cookie.
++
+* `GWT` for the old-style Google Web Toolkit-based interface.
+* `POLYGERRIT` for the new Polymer-based HTML5 Web interface.
++
+A sanity check during startup is performed that the value of
+gerrit.ui is an enabled UI.
++
+Defaults to GWT (if GWT is enabled) or POLYGERRIT (if POLYGERRIT is
+enabled and GWT is disabled)
+
 [[gitweb]]
 === Section gitweb
 
@@ -2092,6 +2192,14 @@
 Valid replacements are `${project}` for the project name in Gerrit
 and `${branch}` for the name of the branch.
 
+[[gitweb.tag]]gitweb.tag::
++
+Optional pattern to use for constructing the gitweb URL when pointing
+at a specific tag when `gitweb.type` is set to `custom`.
++
+Valid replacements are `${project}` for the project name in Gerrit
+and `${tag}` for the name of the tag.
+
 [[gitweb.roottree]]gitweb.roottree::
 +
 Optional pattern to use for constructing the gitweb URL when pointing
@@ -2168,6 +2276,29 @@
 +
 By default, false.
 
+[[groups.uuid.name]]groups.<uuid>.name::
++
+Display name for group with the given UUID.
++
+This option is only supported for system groups (scheme 'global').
++
+E.g. this parameter can be used to configure another name for the
+`Anonymous Users` group:
++
+----
+[groups "global:Anonymous-Users"]
+  name = All Users
+----
++
+When setting this parameter it should be verified that there is no
+existing group with the same name (case-insensitive). Configuring an
+ambiguous name makes Gerrit fail on startup. Once set Gerrit ensures
+that it is not possible to create a group with this name. Gerrit also
+keeps the default name reserved so that it cannot be used for new
+groups either. This means there is no danger of ambiguous group names
+when this parameter is removed and the system group uses the default
+name again.
+
 [[http]]
 === Section http
 
@@ -2277,6 +2408,21 @@
 +
 By default, true.
 
+[[httpd.gracefulStopTimeout]]httpd.gracefulStopTimeout::
++
+Set a graceful stop time. If set, the daemon ensures that all incoming
+calls are preserved for a maximum period of time, before starting
+the graceful shutdown process. Sites behind a workload balancer such as
+HAProxy would need this to be set for avoiding serving errors during
+rolling restarts.
++
+Values should use common unit suffixes to express their setting:
++
+* s, sec, second, seconds
+* m, min, minute, minutes
++
+By default, 0 seconds (immediate shutdown).
+
 [[httpd.inheritChannel]]httpd.inheritChannel::
 +
 If true, permits the daemon to inherit its server socket channel
@@ -2430,9 +2576,12 @@
 of an Apache HTTP proxy layer as security enforcement on top of Gerrit
 by returning a trusted username as HTTP Header.
 +
+Allow multiple values to install multiple servlet filters.
++
 Example of using a security library secure.jar under $GERRIT_SITE/lib
-that provides a org.anyorg.MySecureFilter Servlet Filter that enforces
-a trusted username in the `TRUSTED_USER` HTTP Header:
+that provides a org.anyorg.MySecureHeaderFilter Servlet Filter that enforces
+a trusted username in the `TRUSTED_USER` HTTP Header and
+org.anyorg.MySecureIPFilter that performs source IP security filtering:
 
 ----
 [auth]
@@ -2440,7 +2589,8 @@
 	httpHeader = TRUSTED_USER
 
 [httpd]
-	filterClass = org.anyorg.MySecureFilter
+	filterClass = org.anyorg.MySecureHeaderFilter
+	filterClass = org.anyorg.MySecureIPFilter
 ----
 
 [[httpd.idleTimeout]]httpd.idleTimeout::
@@ -2494,6 +2644,12 @@
 +
 A link:http://lucene.apache.org/[Lucene] index is used.
 +
++
+* `ELASTICSEARCH` look into link:#elasticsearch[Elasticsearch section]
++
+An link:https://www.elastic.co/products/elasticsearch[Elasticsearch] index is
+used. Refer to the link:#elasticsearch[Elasticsearch section] for further
+configuration details.
 
 +
 By default, `LUCENE`.
@@ -2533,7 +2689,12 @@
 limit will truncate the list (but will still set `_more_changes` on
 result lists). Set to 0 for no limit.
 +
-Defaults to no limit.
+When `index.type` is set to `ELASTICSEARCH`, this value should not exceed
+the `index.max_result_window` value configured on the Elasticsearch
+server. If a value is not configured during site initialization, defaults to
+10000, which is the default value of `index.max_result_window` in Elasticsearch.
++
+When `index.type` is set to `LUCENE`, defaults to no limit.
 
 [[index.maxPages]]index.maxPages::
 +
@@ -2558,6 +2719,15 @@
 +
 Defaults to 1024.
 
+[[index.autoReindexIfStale]]index.autoReindexIfStale::
++
+Whether to automatically check if a document became stale in the index
+immediately after indexing it. If false, there is a race condition during two
+simultaneous writes that may cause one of the writes to not be reflected in the
+index. The check to avoid this does consume some resources.
++
+Defaults to false.
+
 ==== Lucene configuration
 
 Open and closed changes are indexed in separate indexes named
@@ -2665,6 +2835,75 @@
 
 ----
 
+[[elasticsearch]]
+=== Section elasticsearch
+
+WARNING: Support for Elasticsearch is still experimental and is not recommended
+for production use. For compatibility information, please refer to the
+link:https://www.gerritcodereview.com/elasticsearch.html[project homepage].
+
+When using Elasticsearch versions 2.4 and 5.6, the open and closed changes are
+indexed in a single index, separated into types `open_changes` and `closed_changes`
+respectively. When using version 6.2 or later, the open and closed changes are
+merged into the default `_doc` type. The latter is also used for the accounts and
+groups indices starting with Elasticsearch 6.2.
+
+Note that when Gerrit is configured to use Elasticsearch, the Elasticsearch
+server(s) must be reachable during the site initialization.
+
+[[elasticsearch.prefix]]elasticsearch.prefix::
++
+This setting can be used to prefix index names to allow multiple Gerrit
+instances in a single Elasticsearch cluster. Prefix `gerrit1_` would result in a
+change index named `gerrit1_changes_0001`.
++
+Not set by default.
+
+[[elasticsearch.server]]elasticsearch.server::
++
+Elasticsearch server URI in the form `http[s]://hostname:port`. The `port` is
+optional and defaults to `9200` if not specified.
++
+At least one server must be specified. May be specified multiple times to
+configure multiple Elasticsearch servers.
++
+Note that the site initialization program only allows to configure a single
+server. To configure multiple servers the `gerrit.config` file must be edited
+manually.
+
+[[elasticsearch.maxRetryTimeout]]elasticsearch.maxRetryTimeout::
++
+Sets the maximum timeout to honor in case of multiple retries of the same request.
++
+The value is in the usual time-unit format like `1 m`, `5 m`.
++
+Defaults to `30000 ms`.
+
+==== Elasticsearch Security
+
+When security is enabled in Elasticsearch, the username and password must be provided.
+Note that the same username and password are used for all servers.
+
+For further information about Elasticsearch security, please refer to the documentation:
+
+* link:https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/security.html[Elasticsearch 2.4]
+* link:https://www.elastic.co/guide/en/x-pack/5.6/security-getting-started.html[Elasticsearch 5.6]
+* link:https://www.elastic.co/guide/en/x-pack/6.2/security-getting-started.html[Elasticsearch 6.2]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.3/security-getting-started.html[Elasticsearch 6.3]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.4/security-getting-started.html[Elasticsearch 6.4]
+
+[[elasticsearch.username]]elasticsearch.username::
++
+Username used to connect to Elasticsearch.
++
+If a password is set, defaults to `elastic`, otherwise not set by default.
+
+[[elasticsearch.password]]elasticsearch.password::
++
+Password used to connect to Elasticsearch.
++
+Not set by default.
+
 [[ldap]]
 === Section ldap
 
@@ -2676,9 +2915,9 @@
 An example LDAP configuration follows, and then discussion of
 the parameters introduced here.  Suitable defaults for most
 parameters are automatically guessed based on the type of server
-detected during startup.  The guessed defaults support both
-link:http://www.ietf.org/rfc/rfc2307.txt[RFC 2307] and Active
-Directory.
+detected during startup.  The guessed defaults support
+link:http://www.ietf.org/rfc/rfc2307.txt[RFC 2307], Active
+Directory and link:https://www.freeipa.org[FreeIPA].
 
 ----
 [ldap]
@@ -2693,6 +2932,19 @@
   groupMemberPattern = (&(objectClass=group)(member=${dn}))
 ----
 
+[[ldap.guessRelevantGroups]]ldap.guessRelevantGroups::
++
+Filter the groups found in LDAP by guessing the ones relevant to
+Gerrit and removing the others from list completions and ACL evaluations.
+The guess is based on two elements: the projects most recently
+accessed in the cache and the list of LDAP groups included in their ACLs.
++
+Please note that projects rarely used and thus not cached may be
+temporarily inaccessible by users even with LDAP membership and grants
+referenced in the ACLs.
++
+By default, true.
+
 [[ldap.server]]ldap.server::
 +
 URL of the organization's LDAP server to query for user information
@@ -2782,7 +3034,7 @@
 is `(uid=${username})` or `(cn=${username})`, but the proper
 setting depends on the LDAP schema used by the directory server.
 +
-Default is `(uid=${username})` for RFC 2307 servers,
+Default is `(uid=${username})` for FreeIPA and RFC 2307 servers,
 and `(&(objectClass=user)(sAMAccountName=${username}))`
 for Active Directory.
 
@@ -2800,7 +3052,7 @@
 If set, users will be unable to modify their full name field, as
 Gerrit will populate it only from the LDAP data.
 +
-Default is `displayName` for RFC 2307 servers,
+Default is `displayName` for FreeIPA and RFC 2307 servers,
 and `${givenName} ${sn}` for Active Directory.
 
 [[ldap.accountEmailAddress]]ldap.accountEmailAddress::
@@ -2843,17 +3095,25 @@
 recommended not to make changes to this setting that would cause the
 value to differ, as this will prevent users from logging in.
 +
-Default is `uid` for RFC 2307 servers,
+Default is `uid` for FreeIPA and RFC 2307 servers,
 and `${sAMAccountName.toLowerCase}` for Active Directory.
 
 [[ldap.accountMemberField]]ldap.accountMemberField::
 +
 _(Optional)_ Name of an attribute on the user account object which
 contains the groups the user is part of. Typically used for Active
-Directory servers.
+Directory and FreeIPA servers.
 +
 Default is unset for RFC 2307 servers (disabled)
-and `memberOf` for Active Directory.
+and `memberOf` for Active Directory and FreeIPA.
+
+[[ldap.accountMemberExpandGroups]]ldap.accountMemberExpandGroups::
++
+_(Optional)_ Whether to expand nested groups recursively. This
+setting is used only if `ldap.accountMemberField` is set.
++
+Default is unset for FreeIPA and `true` for RFC 2307 servers
+and Active Directory.
 
 [[ldap.fetchMemberOfEagerly]]ldap.fetchMemberOfEagerly::
 +
@@ -2863,7 +3123,7 @@
 as this will result in a much faster LDAP login.
 +
 Default is unset for RFC 2307 servers (disabled) and `true` for
-Active Directory.
+Active Directory and FreeIPA.
 
 [[ldap.groupBase]]ldap.groupBase::
 +
@@ -2892,7 +3152,7 @@
 `${groupname}` is replaced with the search term supplied by the
 group owner.
 +
-Default is `(cn=${groupname})` for RFC 2307,
+Default is `(cn=${groupname})` for FreeIPA and RFC 2307 servers,
 and `(&(objectClass=group)(cn=${groupname}))` for Active Directory.
 
 [[ldap.groupMemberPattern]]ldap.groupMemberPattern::
@@ -2910,7 +3170,7 @@
 Attributes such as `${dn}` or `${uidNumber}` may be useful.
 +
 Default is `(|(memberUid=${username})(gidNumber=${gidNumber}))` for
-RFC 2307, and unset (disabled) for Active Directory.
+RFC 2307, and unset (disabled) for Active Directory and FreeIPA.
 
 [[ldap.groupName]]ldap.groupName::
 +
@@ -2927,6 +3187,15 @@
 +
 Default is `cn`.
 
+[[ldap.mandatoryGroup]]ldap.mandatoryGroup::
++
+All users must be a member of this group to allow account creation or
+authentication.
++
+Setting mandatoryGroup implies enabling of `ldap.fetchMemberOfEagerly`
++
+By default, unset.
+
 [[ldap.localUsernameToLowerCase]]ldap.localUsernameToLowerCase::
 +
 Converts the local username, that is used to login into the Gerrit
@@ -3076,6 +3345,13 @@
   safe = true
 ----
 
+[[note-db]]
+=== Section noteDb
+
+NoteDb is the next generation of Gerrit storage backend, currently powering
+`googlesource.com`. It is not (yet) recommended for general use, but if you want
+to learn more, see the link:dev-note-db.html[developer documentation].
+
 [[oauth]]
 === Section oauth
 
@@ -3271,7 +3547,7 @@
 from pushing objects which are too large to Gerrit.
 +
 This setting can also be set in the `project.config`
-link:config-project-config.html[receive.maxObjectSizeLimit] in order
+(link:config-project-config.html[receive.maxObjectSizeLimit]) in order
 to further reduce the global setting. The project specific setting is
 only honored when it further reduces the global limit.
 +
@@ -3279,6 +3555,14 @@
 +
 Common unit suffixes of 'k', 'm', or 'g' are supported.
 
+[[receive.inheritProjectMaxObjectSizeLimit]]receive.inheritProjectMaxObjectSizeLimit::
++
+Controls whether the project-level link:config-project-config.html[`receive.maxObjectSizeLimit`]
+value is inherited from the parent project. When `true`, the value is
+inherited, otherwise it is not inherited.
++
+Default is false, the value is not inherited.
+
 [[receive.maxTrustDepth]]receive.maxTrustDepth::
 +
 If signed push validation is link:#receive.enableSignedPush[enabled],
@@ -3371,7 +3655,9 @@
 +
 The default submit type for newly created projects. Supported values
 are `MERGE_IF_NECESSARY`, `FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`,
-`MERGE_ALWAYS` and `CHERRY_PICK`.
+`REBASE_ALWAYS`, `MERGE_ALWAYS` and `CHERRY_PICK`.
++
+For more details see link:project-configuration.html#submit_type[Submit Types].
 +
 By default, `MERGE_IF_NECESSARY`.
 
@@ -3449,6 +3735,87 @@
 +
 Default is 1.
 
+[[receiveemail]]
+=== Section receiveemail
+
+[[receiveemail.protocol]]receiveemail.protocol::
++
+Specifies the protocol used for receiving emails. Valid options are
+'POP3', 'IMAP' and 'NONE'. Note that Gerrit will automatically switch between
+POP3 and POP3s as well as IMAP and IMAPS depending on the specified
+link:#receiveemail.encryption[encryption].
++
+Defaults to 'NONE' which means that receiving emails is disabled.
+
+[[receiveemail.host]]receiveemail.host::
++
+The hostname of the mailserver. Example: 'imap.gmail.com'.
++
+Defaults to an empty string which means that receiving emails is disabled.
+
+[[receiveemail.port]]receiveemail.port::
++
+The port the email server exposes for receiving emails.
++
+Defaults to the industry standard for a given protocol and encryption:
+POP3: 110; POP3S: 995; IMAP: 143; IMAPS: 993.
+
+[[receiveemail.username]]receiveemail.username::
++
+Username used for authenticating with the email server.
++
+Defaults to an empty string.
+
+[[receiveemail.password]]receiveemail.password::
++
+Password used for authenticating with the email server.
++
+Defaults to an empty string.
+
+[[receiveemail.encryption]]receiveemail.encryption::
++
+Encryption standard used for transport layer security between Gerrit and the
+email server. Possible values include 'NONE', 'SSL' and 'TLS'.
++
+Defaults to 'NONE'.
+
+[[receiveemail.fetchInterval]]receiveemail.fetchInterval::
++
+Time between two consecutive fetches from the email server. Communication with
+the email server is not kept alive. Examples: 60s, 10m, 1h.
++
+Defaults to 60 seconds.
+
+[[receiveemail.enableImapIdle]]receiveemail.enableImapIdle::
++
+If the IMAP protocol is used for retrieving emails, IMAPv4 IDLE can be used to
+keep the connection with the email server alive and receive a push when a new
+email is delivered to the inbox. In this case, Gerrit will process the email
+immediately and will not have a fetch delay.
++
+Defaults to false.
+
+[[receiveemail.filter.mode]]receiveemail.filter.mode::
++
+A black- and whitelist filter to filter incoming emails.
++
+If `OFF`, emails are not filtered by the list filter.
++
+If `WHITELIST`, only emails where a pattern from
+<<receiveemail.filter.patterns,receiveemail.filter.patterns>>
+matches 'From' will be processed.
++
+If `BLACKLIST`, only emails where no pattern from
+<<receiveemail.filter.patterns,receiveemail.filter.patterns>>
+matches 'From' will be processed.
++
+Defaults to `OFF`.
+
+[[receiveemail.filter.patterns]]receiveemail.filter.patterns::
++
+A list of regular expressions to match the email sender against. This can also
+be a list of addresses when regular expression characters are escaped.
+
 [[sendemail]]
 === Section sendemail
 
@@ -3459,6 +3826,14 @@
 +
 By default, true, allowing notifications to be sent.
 
+[[sendemail.html]]sendemail.html::
++
+If false, Gerrit will only send plain-text emails.
+If true, Gerrit will send multi-part emails with an HTML and
+plain text part.
++
+By default, true, allowing HTML in the emails Gerrit sends.
+
 [[sendemail.connectTimeout]]sendemail.connectTimeout::
 +
 The connection timeout of opening a socket connected to a
@@ -3490,7 +3865,9 @@
 Full Name and Preferred Email.  This may cause messages to be
 classified as spam if the user's domain has SPF or DKIM enabled
 and <<sendemail.smtpServer,sendemail.smtpServer>> is not a trusted
-relay for that domain.
+relay for that domain. You can specify
+<<sendemail.allowedDomain,sendemail.allowedDomain>> to instruct Gerrit to only
+send as USER if USER is from those domains.
 +
 * `MIXED`
 +
@@ -3516,6 +3893,16 @@
 +
 By default, MIXED.
 
+[[sendemail.allowedDomain]]sendemail.allowedDomain::
++
+Only used when `sendemail.from` is set to `USER`.
+List of allowed domains. If user's email matches one of the domains, emails will
+be sent as USER, otherwise as MIXED mode. Wildcards may be specified by
+including `\*` to match any number of characters, for example `*.example.com`
+matches any subdomain of `example.com`.
++
+By default, `*`.
+
 [[sendemail.smtpServer]]sendemail.smtpServer::
 +
 Hostname (or IP address) of a SMTP server that will relay
@@ -3597,10 +3984,29 @@
 +
 By default, unset, so no Expiry-Date header is generated.
 
+[[sendemail.replyToAddress]]sendemail.replyToAddress::
++
+A custom Reply-To address should only be provided if Gerrit is set up to
+receive emails and the inbound address differs from
+<<sendemail.from,sendemail.from>>.
+It will be set as Reply-To header on all types of outgoing email where
+Gerrit can parse back a user's reply.
++
+Defaults to an empty string which adds <<sendemail.from,sendemail.from>> as
+Reply-To if inbound email is enabled and the review's author otherwise.
 
 [[site]]
 === Section site
 
+[[site.allowOriginRegex]]site.allowOriginRegex::
++
+List of regular expressions matching origins that should be permitted
+to use the Gerrit REST API to read content. These should be trusted
+applications as the sites may be able to use the user's credentials.
+Only applies to GET and HEAD requests.
++
+By default, unset, denying all cross-origin requests.
+
 [[site.refreshHeaderFooter]]site.refreshHeaderFooter::
 +
 If true the server checks the site header, footer and CSS files for
@@ -3642,8 +4048,8 @@
 [[sshd.backend]]sshd.backend::
 +
 Starting from version 0.9.0 Apache SSHD project added support for NIO2
-IoSession. To use the new NIO2 session the `backend` option must be set
-to `NIO2`. Otherwise, this option must be set to `MINA`.
+IoSession. To use the old MINA session the `backend` option must be set
+to `MINA`.
 +
 By default, `NIO2`.
 
@@ -3813,10 +4219,24 @@
 to the default ciphers, cipher names starting with `-` are removed
 from the default cipher set.
 +
-Supported ciphers: `aes128-cbc`, `aes128-cbc`, `aes256-cbc`, `blowfish-cbc`,
-`3des-cbc`, `none`.
+Supported ciphers:
++
+* `aes128-ctr`
+* `aes192-ctr`
+* `aes256-ctr`
+* `aes128-cbc`
+* `aes192-cbc`
+* `aes256-cbc`
+* `blowfish-cbc`
+* `3des-cbc`
+* `arcfour128`
+* `arcfour256`
+* `none`
 +
 By default, all supported ciphers except `none` are available.
++
+If your setup allows for it, it's recommended to disable all ciphers except
+the AES-CTR modes.
 
 [[sshd.mac]]sshd.mac::
 +
@@ -3826,8 +4246,14 @@
 are enabled in addition to the default MACs, MAC names starting with
 `-` are removed from the default MACs.
 +
-Supported MACs: `hmac-md5`, `hmac-md5-96`, `hmac-sha1`, `hmac-sha1-96`,
-`hmac-sha2-256`, `hmac-sha2-512`.
+Supported MACs:
++
+* `hmac-md5`
+* `hmac-md5-96`
+* `hmac-sha1`
+* `hmac-sha1-96`
+* `hmac-sha2-256`
+* `hmac-sha2-512`
 +
 By default, all supported MACs are available.
 
@@ -3861,8 +4287,11 @@
 * `diffie-hellman-group1-sha1`
 
 By default, all supported key exchange algorithms are available.
-Without Bouncy Castle, `diffie-hellman-group1-sha1` is the only
-available algorithm.
+
+It is strongly recommended to disable at least `diffie-hellman-group1-sha1`
+as it's known to be vulnerable (logjam attack). Additionally, if your setup
+allows for it, it is recommended to disable the remaining two `sha1` key
+exchange algorithms.
 --
 
 [[sshd.kerberosKeytab]]sshd.kerberosKeytab::
@@ -3933,11 +4362,11 @@
 [[suggest.from]]suggest.from::
 +
 The number of characters that a user must have typed before suggestions
-are provided. If set to 0, suggestions are always provided.
+are provided. If set to 0, suggestions are always provided. This is only
+used for suggesting accounts when adding members to a group.
 +
 By default 0.
 
-
 [[theme]]
 === Section theme
 
@@ -4224,8 +4653,9 @@
 +
 By default "Anonymous Coward" is used.
 
+[[secure.config]]
+== File `etc/secure.config`
 
-== [[secure.config]]File `etc/secure.config`
 The optional file `'$site_path'/etc/secure.config` overrides (or
 supplements) the settings supplied by `'$site_path'/etc/gerrit.config`.
 The file should be readable only by the daemon process and can be
diff --git a/Documentation/config-gitweb.txt b/Documentation/config-gitweb.txt
index fcfd0e1..d49acfee 100644
--- a/Documentation/config-gitweb.txt
+++ b/Documentation/config-gitweb.txt
@@ -17,8 +17,9 @@
 Linux distributions.
 
 ----
-  git config --file $site_path/etc/gerrit.config gitweb.cgi /usr/lib/cgi-bin/gitweb.cgi
-  git config --file $site_path/etc/gerrit.config --unset gitweb.url
+  git config -f $site_path/etc/gerrit.config gitweb.type gitweb
+  git config -f $site_path/etc/gerrit.config gitweb.cgi /usr/lib/cgi-bin/gitweb.cgi
+  git config -f $site_path/etc/gerrit.config --unset gitweb.url
 ----
 
 Alternatively, if Gerrit is served behind reverse proxy, it can
@@ -28,8 +29,9 @@
 To enable this feature, set both: `gitweb.cgi` and `gitweb.url`.
 
 ----
-  git config --file $site_path/etc/gerrit.config gitweb.cgi /usr/lib/cgi-bin/gitweb.cgi
-  git config --file $site_path/etc/gerrit.config gitweb.url /pretty/path/to/gitweb
+  git config -f $site_path/etc/gerrit.config gitweb.type gitweb
+  git config -f $site_path/etc/gerrit.config gitweb.cgi /usr/lib/cgi-bin/gitweb.cgi
+  git config -f $site_path/etc/gerrit.config gitweb.url /pretty/path/to/gitweb
 ----
 
 After updating `'$site_path'/etc/gerrit.config`, the Gerrit server must
@@ -77,13 +79,13 @@
 On Ubuntu:
 
 ----
-  $ sudo apt-get install gitweb
+  sudo apt-get install gitweb
 ----
 
 With Yum:
 
 ----
-  $ yum install gitweb
+  yum install gitweb
 ----
 
 ===== Configure Gitweb
@@ -125,14 +127,14 @@
 Link gitweb to `/var/www/gitweb`, check `/etc/gitweb.conf` if unsure of paths:
 
 ----
-  $ sudo ln -s /usr/share/gitweb /var/www/gitweb
+  sudo ln -s /usr/share/gitweb /var/www/gitweb
 ----
 
 Add the gitweb directory to the Apache configuration by creating a "gitweb"
 file inside the Apache conf.d directory:
 
 ----
-  $ touch /etc/apache/conf.d/gitweb
+  touch /etc/apache/conf.d/gitweb
 ----
 
 Add the following to /etc/apache/conf.d/gitweb:
@@ -152,7 +154,7 @@
 ===== Restart the Apache Web Server
 
 ----
-  $ sudo /etc/init.d/apache2 restart
+  sudo /etc/init.d/apache2 restart
 ----
 
 Now you should be able to view your repository projects online:
@@ -184,7 +186,7 @@
 following to check:
 
 ----
-$ perl -mCGI -mEncode -mFcntl -mFile::Find -mFile::Basename -e ""
+  perl -mCGI -mEncode -mFcntl -mFile::Find -mFile::Basename -e ""
 ----
 
 You may encounter the following exception:
@@ -220,21 +222,22 @@
 namespace is available.
 
 ----
-$ git config -f $site_path/etc/gerrit.config --unset gitweb.cgi
-$ git config -f $site_path/etc/gerrit.config gitweb.url https://gitweb.corporation.com
+  git config -f $site_path/etc/gerrit.config gitweb.type gitweb
+  git config -f $site_path/etc/gerrit.config --unset gitweb.cgi
+  git config -f $site_path/etc/gerrit.config gitweb.url https://gitweb.corporation.com
 ----
 
-If you're not following the traditional \{projectName\}.git project naming conventions,
+If you're not following the traditional `\{projectName\}.git` project naming conventions,
 you will want to customize Gerrit to read them. Add the following:
 
 ----
-$ git config -f $site_path/etc/gerrit.config gitweb.type custom
-$ git config -f $site_path/etc/gerrit.config gitweb.project ?p=\${project}\;a=summary
-$ git config -f $site_path/etc/gerrit.config gitweb.revision ?p=\${project}\;a=commit\;h=\${commit}
-$ git config -f $site_path/etc/gerrit.config gitweb.branch ?p=\${project}\;a=shortlog\;h=\${branch}
-$ git config -f $site_path/etc/gerrit.config gitweb.roottree ?p=\${project}\;a=tree\;hb=\${commit}
-$ git config -f $site_path/etc/gerrit.config gitweb.file ?p=\${project}\;hb=\${commit}\;f=\${file}
-$ git config -f $site_path/etc/gerrit.config gitweb.filehistory ?p=\${project}\;a=history\;hb=\${branch}\;f=\${file}
+  git config -f $site_path/etc/gerrit.config gitweb.type custom
+  git config -f $site_path/etc/gerrit.config gitweb.project ?p=\${project}\;a=summary
+  git config -f $site_path/etc/gerrit.config gitweb.revision ?p=\${project}\;a=commit\;h=\${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.branch ?p=\${project}\;a=shortlog\;h=\${branch}
+  git config -f $site_path/etc/gerrit.config gitweb.roottree ?p=\${project}\;a=tree\;hb=\${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.file ?p=\${project}\;hb=\${commit}\;f=\${file}
+  git config -f $site_path/etc/gerrit.config gitweb.filehistory ?p=\${project}\;a=history\;hb=\${branch}\;f=\${file}
 ----
 
 After updating `'$site_path'/etc/gerrit.config`, the Gerrit server must
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 1f9dd33..47396ef 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -11,11 +11,10 @@
 [[label_Code-Review]]
 == Label: Code-Review
 
-The code review label is the second of two default labels that is
-configured upon the creation of a Gerrit instance.  It may have any
-meaning the project desires.  It was originally invented by the Android
-Open Source Project to mean 'I read the code and it seems reasonably
-correct'.
+The Code-Review label is configured upon the creation of a Gerrit
+instance.  It may have any meaning the project desires.  It was
+originally invented by the Android Open Source Project to mean
+'I read the code and it seems reasonably correct'.
 
 The range of values is:
 
@@ -87,8 +86,10 @@
 Project to mean 'compiles, passes basic unit tests'.  Some CI tools
 expect to use the Verified label to vote on a change after running.
 
-Administrators can install the Verified label by adding the following
-text to `project.config`:
+During site initialization the administrator may have chosen to
+configure the default Verified label for all projects.  In case it is
+desired to configure it at a later time, administrators can do this by
+adding the following to `project.config` in `All-Projects`:
 
 ----
   [label "Verified"]
@@ -96,6 +97,7 @@
       value = -1 Fails
       value =  0 No score
       value = +1 Verified
+      copyAllScoresIfNoCodeChange = true
 ----
 
 The range of values is:
@@ -128,9 +130,10 @@
 
 
 [[label_custom]]
-== Your Label Here
+== Customized Labels
 
-Site administrators and project owners can also define their own labels.
+Site administrators and project owners can define their own labels,
+or customize labels inherited from parent projects.
 
 See above for descriptions of how <<label_Verified,`Verified`>>
 and <<label_Code-Review,`Code-Review`>> work, and add your own
@@ -144,15 +147,28 @@
 permission editor web UI.
 
 Labels may be added to any project's `project.config`; the default
-labels are defined in `All-Projects`. Labels are inherited from parent
-projects; a child project may add, override, or remove labels defined in
-its parents.  Overriding a label in a child project overrides all its
-properties and values.  To remove a label in a child project, add an
-empty label with the same name as in the parent.
+labels are defined in `All-Projects`.
 
-Labels are laid out in the order they are specified in project.config,
-with inherited labels appearing first, providing some layout control to
-the administrator.
+[[label_inheritance]]
+=== Inheritance
+
+Labels are inherited from parent projects. A child project may add,
+override, or remove labels defined in its parents.
+
+Overriding a label in a child project overrides all its properties and
+values. It is not possible to modify an inherited label by adding
+properties in the child project's configuration; all properties from
+the parent definition must be redefined in the child.
+
+To remove a label in a child project, add an empty label with the same
+name as in the parent. This will override the parent label with
+a label containing the defaults (`function = MaxWithBlock`,
+`defaultValue = 0` and no further allowed values)
+
+[[label_layout]]
+=== Layout
+
+Labels are laid out in alphabetical order.
 
 [[label_name]]
 === `label.Label-Name`
@@ -230,6 +246,19 @@
 Allowed range of values are 0 (Patch Set Unlocked) to 1 (Patch Set
 Locked).
 
+[[label_allowPostSubmit]]
+=== `label.Label-Name.allowPostSubmit`
+
+If true, the label may be voted on for changes that have already been
+submitted. If false, the label will not appear in the UI and will not
+be accepted when reviewing a closed change.
+
+In either case, voting on a label after submission is only permitted if
+the new vote is at least as high as the old vote by that user. This
+avoids creating the false impression that a post-submit vote can change
+the past and affect submission somehow.
+
+Defaults to true.
 
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
@@ -246,8 +275,8 @@
 sticky approvals, reducing turn-around for trivial cleanups prior to
 submitting a change. Defaults to false.
 
-[[label_copyAllScoresOnMergeCommitFirstParentUpdate]]
-=== `label.Label-Name.copyAllScoresOnMergeCommitFirstParentUpdate`
+[[label_copyAllScoresOnMergeFirstParentUpdate]]
+=== `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
 
 This policy is useful if you don't want to trigger CI or human
 verification again if your target branch moved on but the feature
@@ -288,8 +317,8 @@
 the commit message is different. This can be used to enable sticky
 approvals on labels that only depend on the code, reducing turn-around
 if only the commit message is changed prior to submitting a change.
-For the Verified label that is installed by the link:pgm-init.html[init]
-site program this is enabled by default.
+For the Verified label that is optionally installed by the
+link:pgm-init.html[init] site program this is enabled by default.
 
 Defaults to false.
 
@@ -337,6 +366,8 @@
 parts in `project.config` file. That means from the UI a user can always
 assign permissions for that label on a branch, but this permission is then
 ignored if the label doesn't apply for that branch.
+Additionally, the `branch` modifier has no effect when the submit rule
+is customized in the rules.pl of the project or inherited from parent projects.
 
 [[label_example]]
 === Example
diff --git a/Documentation/config-login-register.txt b/Documentation/config-login-register.txt
index ffeae62..1639c8a 100644
--- a/Documentation/config-login-register.txt
+++ b/Documentation/config-login-register.txt
@@ -135,11 +135,3 @@
 
   user@host:~$
 ----
-
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 51ea9c5..9eb31bf 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -1,163 +1,167 @@
 = Gerrit Code Review - Mail Templates
 
-Gerrit uses velocity templates for the bulk of the standard mails it sends out.
+Gerrit uses link:https://developers.google.com/closure/templates/[Closure Templates]
+(Soy) for the bulk of the standard mails it sends out.
 There are builtin default templates which are used if they are not overridden.
 These defaults are also provided as examples so that administrators may copy
 them and easily modify them to tweak their contents.
 
+*Compatibility Note:* previously, Velocity Template Language (VTL) was used as
+the template language for Gerrit emails. VTL has now been deprecated in favor of
+Soy, but Velocity templates that modify text emails remain supported for now.
 
-== Template Locations and Extensions:
+== Template Locations and Extensions
 
 The default example templates reside under:  `'$site_path'/etc/mail` and are
-terminated with the double extension `.vm.example`. Modifying these example
+terminated with the double extension `.soy.example`. Modifying these example
 files will have no effect on the behavior of Gerrit.  However, copying an
 example template to an equivalently named file without the `.example` extension
 and modifying it will allow an administrator to customize the template.
 
-
-== Supported Mail Templates:
+== Supported Mail Templates
 
 Each mail that Gerrit sends out is controlled by at least one template.  These
 are listed below.  Change emails are influenced by two additional templates,
 one to set the subject line, and one to set the footer which gets appended to
-all the change emails (see `ChangeSubject.vm` and `ChangeFooter.vm` below.)
+all the change emails (see `ChangeSubject.soy` and `ChangeFooter.soy` below.)
 
-=== Abandoned.vm
+Many types of Gerrit email message support HTML in addition to plain-text. Where
+both are supported, templates to control the HTML part have `...Html` appended
+in their file names. For example, for "Abandoned" emails, the `Abandoned.soy`
+template determines the text part of the message, whereas `AbandonedHtml.soy`
+determines the HTML part.
 
-The `Abandoned.vm` template will determine the contents of the email related
-to a change being abandoned.  It is a `ChangeEmail`: see `ChangeSubject.vm` and
-`ChangeFooter.vm`.
+=== Abandoned.soy and AbandonedHtml.soy
 
-=== AddKey.vm
+The "Abandoned" templates will determine the contents of the email related to a
+change being abandoned.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
+ChangeFooter.
 
-The `AddKey.vm` template will determine the contents of the email related to
-SSH and GPG keys being added to a user account. This notification is not sent
-when the key is administratively added to another user account.
+=== AddKey.soy and AddKeyHtml.soy
 
-=== ChangeFooter.vm
+AddKey templates will determine the contents of the email related to SSH and GPG
+keys being added to a user account. This notification is not sent when the key
+is administratively added to another user account.
 
-The `ChangeFooter.vm` template will determine the contents of the footer
-text that will be appended to emails related to changes (all `ChangeEmail`s).
+=== ChangeFooter.soy and ChangeFooterHtml.soy
 
-=== ChangeSubject.vm
+The ChangeFooter templates will determine the contents of the footer that will
+be appended to emails related to changes (all `ChangeEmail`s).
 
-The `ChangeSubject.vm` template will determine the contents of the email
+=== ChangeSubject.soy
+
+The `ChangeSubject.soy` template will determine the contents of the email
 subject line for ALL emails related to changes.
 
-=== Comment.vm
+=== Comment.soy
 
-The `Comment.vm` template will determine the contents of the email related to
+The `Comment.soy` template will determine the contents of the email related to
 a user submitting comments on changes.  It is a `ChangeEmail`: see
-`ChangeSubject.vm`, `ChangeFooter.vm` and `CommentFooter.vm`.
+`ChangeSubject.soy`, ChangeFooter and CommentFooter.
 
-=== CommentFooter.vm
+=== CommentFooter.soy and CommentFooterHtml.soy
 
-The `CommentFooter.vm` template will determine the contents of the footer
-text that will be appended to emails related to a user submitting comments on
-changes.  See `ChangeSubject.vm`, `Comment.vm` and `ChangeFooter.vm`.
+The CommentFooter templates will determine the contents of the footer text that
+will be appended to emails related to a user submitting comments on changes.
+See `ChangeSubject.soy`, Comment and ChangeFooter.
 
-=== DeleteVote.vm
+=== DeleteVote.soy and DeleteVoteHtml.soy
 
-The `DeleteVote.vm` template will determine the contents of the email related
-to removing votes on changes.  It is a `ChangeEmail`: see `ChangeSubject.vm`
-and `ChangeFooter.vm`.
+The DeleteVote templates will determine the contents of the email related to
+removing votes on changes.  It is a `ChangeEmail`: see `ChangeSubject.soy`
+and ChangeFooter.
 
-=== DeleteReviewer.vm
+=== DeleteReviewer.soy and DeleteReviewerHtml.soy
 
-The `DeleteReviewer.vm` template will determine the contents of the email related
-to a user removing a reviewer (with a vote) from a change.  It is a
-`ChangeEmail`: see `ChangeSubject.vm` and `ChangeFooter.vm`.
+The DeleteReviewer templates will determine the contents of the email related to
+a user removing a reviewer (with a vote) from a change.  It is a
+`ChangeEmail`: see `ChangeSubject.soy` and ChangeFooter.
 
-=== Footer.vm
+=== Footer.soy and FooterHtml.soy
 
-The `Footer.vm` template will determine the contents of the footer text
-appended to the end of all outgoing emails after the ChangeFooter and
-CommentFooter.
+The Footer templates will determine the contents of the footer text appended to
+the end of all outgoing emails after the ChangeFooter and CommentFooter.
 
-=== Merged.vm
+=== Merged.soy and MergedHtml.soy
 
-The `Merged.vm` template will determine the contents of the email related to
-a change successfully merged to the head.  It is a `ChangeEmail`: see
-`ChangeSubject.vm` and `ChangeFooter.vm`.
+The Merged templates will determine the contents of the email related to a
+change successfully merged to the head.  It is a `ChangeEmail`: see
+`ChangeSubject.soy` and ChangeFooter.
 
-=== NewChange.vm
+=== NewChange.soy and NewChangeHtml.soy
 
-The `NewChange.vm` template will determine the contents of the email related
-to a user submitting a new change for review. This includes changes created
-by actions made by the user in the Web UI such as cherry picking a commit or
-reverting a change.  It is a `ChangeEmail`: see `ChangeSubject.vm` and
-`ChangeFooter.vm`.
+The NewChange templates will determine the contents of the email related to a
+user submitting a new change for review. This includes changes created by
+actions made by the user in the Web UI such as cherry picking a commit or
+reverting a change.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
+ChangeFooter.
 
-=== RegisterNewEmail.vm
+=== RegisterNewEmail.soy
 
-The `RegisterNewEmail.vm` template will determine the contents of the email
+The `RegisterNewEmail.soy` template will determine the contents of the email
 related to registering new email accounts.
 
-=== ReplacePatchSet.vm
+=== ReplacePatchSet.soy and ReplacePatchSetHtml.soy
 
-The `ReplacePatchSet.vm` template will determine the contents of the email
-related to a user submitting a new patchset for a change.  This includes
-patchsets created by actions made by the user in the Web UI such as editing
-the commit message, cherry picking a commit, or rebasing a change.  It is a
-`ChangeEmail`: see `ChangeSubject.vm` and `ChangeFooter.vm`.
+The ReplacePatchSet templates will determine the contents of the email related
+to a user submitting a new patchset for a change.  This includes patchsets
+created by actions made by the user in the Web UI such as editing the commit
+message, cherry picking a commit, or rebasing a change.  It is a `ChangeEmail`:
+see `ChangeSubject.soy` and ChangeFooter.
 
-=== Restored.vm
+=== Restored.soy and RestoredHtml.soy
 
-The `Restored.vm` template will determine the contents of the email related
-to a change being restored.  It is a `ChangeEmail`: see `ChangeSubject.vm` and
-`ChangeFooter.vm`.
+The Restored templates will determine the contents of the email related to a
+change being restored.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
+ChangeFooter.
 
-=== Reverted.vm
+=== Reverted.soy and RevertedHtml.soy
 
-The `Reverted.vm` template will determine the contents of the email related
-to a change being reverted.  It is a `ChangeEmail`: see `ChangeSubject.vm` and
-`ChangeFooter.vm`.
+The Reverted templates will determine the contents of the email related to a
+change being reverted.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
+ChangeFooter.
+
+=== SetAssignee.soy and SetAssigneeHtml.soy
+
+The SetAssignee templates will determine the contents of the email related to a
+user being assigned to a change. It is a `ChangeEmail`: see `ChangeSubject.soy`
+and ChangeFooter.
 
 
 == Mail Variables and Methods
 
 Mail templates can access and display objects currently made available to them
-via the velocity context.  While the base objects are documented here, it is
-possible to call public methods on these objects from templates.  Those methods
-are not documented here since they could change with every release.  As these
-templates are meant to be modified only by a qualified sysadmin, it is accepted
-that writing templates for Gerrit emails is likely to require some basic
-knowledge of the class structure to be useful.  Browsing the source code might
-be necessary for anything more than a minor formatting change.
+via the Soy context.
 
 === Warning
 
 Be aware that modifying templates can cause them to fail to parse and therefore
-not send out the actual email, or worse, calling methods on the available
-objects could have internal side effects which would adversely affect the
-health of your Gerrit server and/or data.
+not send out the actual email.
 
 === All OutgoingEmails
 
 All outgoing emails have the following variables available to them:
 
-$email::
+$email.settingsUrl::
 +
-A reference to the class constructing the current `OutgoingEmail`.  With this
-reference it is possible to call any public method on the OutgoingEmail class
-or the current child class inherited from it.
+The URL to view the user's settings in the Gerrit web UI.
+
+$email.gerritHost::
++
+The name of the Gerrit instance.
+
+$email.gerritUrl::
++
+The URL to the Gerrit web UI.
 
 $messageClass::
 +
 A String containing the messageClass.
 
-$StringUtils::
-+
-A reference to the Apache `StringUtils` class.  This can be very useful for
-formatting strings.
-
 === Change Emails
 
-All change related emails have the following additional variables available to them:
-
-$change::
-+
-A reference to the current `Change` object.
+Change related emails have the following template data available to them, in
+addition to what's available to all outgoing emails.
 
 $changeId::
 +
@@ -167,30 +171,65 @@
 +
 The text of the `ChangeMessage`.
 
-$branch::
-+
-A reference to the branch of this change (a `Branch.NameKey`).
-
 $fromName::
 +
 The name of the from user.
 
+$email.unifiedDiff::
++
+The diff of the change.
+
+$email.changeDetail::
++
+The details of the change, including the commit message.
+
+$email.changeUrl::
++
+The URL to the change in the web UI.
+
+$email.includeDiff::
++
+Whether the Gerrit instance is configured to include diffs in emails.
+
+$change.subject::
++
+The subject of the current change.
+
+$change.originalSubject::
++
+The subject corresponding to the first patch set of the current change.
+
+$change.shortSubject::
++
+The subject limited to 72 characters, with an ellipsis if it exceeds that.
+
+$change.ownerEmail::
++
+The email address of the owner of the change.
+
+$branch.shortName::
++
+The name of the branch targeted by the current change.
+
 $projectName::
 +
 The name of this change's project.
 
-$patchSet::
+$shortProjectName::
 +
-A reference to the current `PatchSet`.
+The project name with the path abbreviated.
 
-$patchSetInfo::
+$sshHost::
 +
-A reference to the current `PatchSetInfo`.
+SSH hostname for the Gerrit instance.
 
+$patchSet.patchSetId::
++
+The current patch set number.
 
-== SEE ALSO
-
-* link:http://velocity.apache.org/[velocity]
+$patchSet.refname::
++
+The refname of the patch set.
 
 GERRIT
 ------
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index b7c1415..a652136 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -121,7 +121,7 @@
 
 link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/review-strategy[
 Project] |
-link:https://gerrit.googlesource.com/plugins/review-strategy/+/master/src/main/resources/Documentation/about.md[
+link:https://gerrit.googlesource.com/plugins/review-strategy/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
 [[singleusergroup]]
@@ -163,6 +163,18 @@
 link:https://gerrit.googlesource.com/plugins/admin-console/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
+[[analytics]]
+=== analytics
+
+Plugin to extract commit and review data from Gerrit projects and
+expose aggregated metrics over REST and SSH API.
+Metrics are extracted in JSON format with one record per line, ready to be
+archived and processed with popular BigData transformation tools such
+Apache Spark or published and visualized in dashboards.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/analytics[Project] |
+link:https://gerrit.googlesource.com/plugins/analytics/+doc/master/README.md[Documentation]
+
 [[avatars-external]]
 === avatars-external
 
@@ -281,6 +293,26 @@
 link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/gitiles[
 Project]
 
+[[healthcheck]]
+=== healthcheck
+
+Plugin for monitoring and alerting when Gerrit does not behave properrly.
+
+When Gerrit Server needs to be available 24x7, it is important to know
+*beforehand* if something isn't working correctly: this plugin exposes a
+REST-API that provides the real-time status of the Gerrit internals and can
+be integrated with real-time monitoring systems and paging platforms.
+
+Healthcheck metrics (latency and subsystem healthiness) are published as
+Gerrit internal metrics and can be published to dashboards.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/healthcheck[
+Project] |
+link:https://gerrit.googlesource.com/plugins/healthcheck/+doc/master/src/main/resources/Documentation/about.md[
+Documentation] |
+link:https://gerrit.googlesource.com/plugins/healthcheck/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[imagare]]
 === imagare
 
@@ -475,7 +507,7 @@
 appends `label('Owner-Approval', need(_))` to a provided list.
 
 link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/owners[Project] |
-link:https://gerrit.googlesource.com/plugins/owners/+/refs/heads/master/README.md[Documentation]
+link:https://gerrit.googlesource.com/plugins/owners/+doc/master/README.md[Documentation]
 
 [[project-download-commands]]
 === project-download-commands
@@ -509,6 +541,19 @@
 link:https://gerrit.googlesource.com/plugins/quota/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[readonly]]
+=== readonly
+
+A plugin that makes the Gerrit server read-only by rejecting git pushes,
+blocking HTTP PUT/POST/DELETE requests, and disabling SSH commands.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/readonly[
+Project] |
+link:https://gerrit.googlesource.com/plugins/readonly/+doc/master/src/main/resources/Documentation/about.md[
+Documentation] |
+link:https://gerrit.googlesource.com/plugins/readonly/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[ref-protection]]
 === ref-protection
 
@@ -519,7 +564,7 @@
 
 link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/ref-protection[
 Project] |
-link:https://gerrit.googlesource.com/plugins/ref-protection/+/refs/heads/stable-2.11/src/main/resources/Documentation/about.md[
+link:https://gerrit.googlesource.com/plugins/ref-protection/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
 [[reparent]]
@@ -658,7 +703,7 @@
 
 This plugin replaces the built-in Gerrit H2 based websession cache with
 a flatfile based implementation. This implementation is shareable
-amongst multiple Gerrit servers, making it useful for multi-master
+among multiple Gerrit servers, making it useful for multi-master
 Gerrit installations.
 
 link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/websession-flatfile[
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 7121265..ed0b151 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -20,6 +20,11 @@
 that you will have to configure push rights for the +refs/meta/config+ name
 space if you'd like to use the possibility to automate permission updates.
 
+== Property inheritance
+
+If a property is set to INHERIT, then the value from the parent project is
+used. If the property is not set in any parent project, the default value is
+FALSE.
 
 [[file-project_config]]
 == The file +project.config+
@@ -79,6 +84,11 @@
 also redefine the text and behavior of the built in label types `Code-Review`
 and `Verified`.
 
+Optionally a +commentlink+ section can be added to define project-specific
+comment links. The +commentlink+ section has the same format as the
+link:config-gerrit.html#commentlink[+commentlink+ section in gerrit.config]
+which is used to define global comment links.
+
 [[project-section]]
 === Project section
 
@@ -126,12 +136,18 @@
 operation will fail. If set to zero then there is no limit.
 +
 Project owners can use this setting to prevent developers from pushing
-objects which are too large to Gerrit. This setting can also be set it
-`gerrit.config` globally link:config-gerrit.html#receive.maxObjectSizeLimit[
-receive.maxObjectSizeLimit].
+objects which are too large to Gerrit. This setting can also be set in
+`gerrit.config` globally (link:config-gerrit.html#receive.maxObjectSizeLimit[
+receive.maxObjectSizeLimit]).
 +
-The project specific setting in `project.config` is only honored when it
-further reduces the global limit.
+The project specific setting in `project.config` may not set a value higher
+than the global limit (if configured). In other words, it is only honored when
+it further reduces the global limit.
++
+When link:config-gerrit.html#receive.inheritProjectMaxObjectSizeLimit[
+`receive.inheritProjectmaxObjectSizeLimit`] is enabled in the global config,
+the value is inherited from the parent project. Otherwise, it is not inherited
+and must be explicitly set per project.
 +
 Default is zero.
 +
@@ -166,9 +182,10 @@
 +
 Controls whether server-side signed push validation is required on the
 project. Only has an effect if signed push validation is enabled on the
-server, and link:#receive.enableSignedPush is set on the project. See
-the link:config-gerrit.html#receive.enableSignedPush[global
-configuration] for details.
+server, and link:#receive.enableSignedPush[`receive.enableSignedPush`] is
+set on the project. See the
+link:config-gerrit.html#receive.enableSignedPush[global configuration]
+for details.
 +
 Default is `INHERIT`, which means that this property is inherited from
 the parent project.
diff --git a/Documentation/config-robot-comments.txt b/Documentation/config-robot-comments.txt
new file mode 100644
index 0000000..cf5de10
--- /dev/null
+++ b/Documentation/config-robot-comments.txt
@@ -0,0 +1,49 @@
+= Gerrit Code Review - Robot Comments
+
+Gerrit has special support for inline comments that are generated by
+automated third-party systems, so called "robot comments". For example
+robot comments can be used to represent the results of code analyzers.
+
+In contrast to regular inline comments which are free-text comments,
+robot comments are more structured and can contain additional data,
+such as a robot ID, a robot run ID and a URL, see
+link:rest-api-changes.html#robot-comment-info[RobotCommentInfo] for
+details.
+
+It is planned to visualize robot comments differently in the web UI so
+that they can be easily distinguished from human comments. Users should
+also be able to use filtering on robot comments, so that only part of
+the robot comments or no robot comments are shown. In addition it is
+planned that robot comments can contain fixes, that users can apply by
+a single click.
+
+== REST endpoints
+
+* Posting robot comments is done by the
+  link:rest-api-changes.html[Set Review] REST endpoint. The
+  link:rest-api-changes.html#review-input[input] for this REST endpoint
+  can contain robot comments in its `robot_comments` field.
+* link:rest-api-changes.html#list-robot-comments[List Robot Comments]
+* link:rest-api-changes.html#get-robot-comment[Get Robot Comment]
+
+== Storage
+
+Robot comments are stored per change in a
+`refs/changes/XX/YYYY/robot-comments` ref, where `XX/YYYY` is the
+sharded change ID.
+
+Robot comments can be dropped by deleting this ref.
+
+== Limitations
+
+* Robot comments are only supported with NoteDb, but not with ReviewDb.
+* Robot comments are not displayed in the web UI yet.
+* There is no support for draft robot comments, but robot comments are
+  always published and visible to everyone who can see the change.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/config-sso.txt b/Documentation/config-sso.txt
index 684b87c..7814061 100644
--- a/Documentation/config-sso.txt
+++ b/Documentation/config-sso.txt
@@ -68,14 +68,13 @@
 Merging Gerrit User Accounts] on the Gerrit Wiki for details.
 
 Linking another identity is also useful for users whose primary OpenID provider
-shuts down. For example Google will
+shuts down. For example Google
 link:https://developers.google.com/+/api/auth-migration[shut down their OpenID
-service on 20th April 2015]. Users must add an alternative identity, using another
-OpenID provider, before that shutdown date. User who fail to add an alternative
-identity before that date, and end up with their account only having a disabled
-Google identity, will need to create a separate account with an alternative
-provider and then ask the administrator to merge the accounts using the previously
-mentioned method.
+service on 20th April 2015]. Users who failed to add an alternative identity with
+another OpenID provider before that date will end up with their account only having
+a disabled Google identity. After creating a separate account with an alternative
+provider, they will need to ask the administrator to merge the accounts using the
+previously mentioned method.
 
 To link another identity to an existing account:
 
@@ -88,7 +87,7 @@
 Login using the other identity can only be performed after the linking is
 successful.
 
-== HTTP Basic/Digest Authentication
+== HTTP Basic Authentication
 
 When using HTTP authentication, Gerrit assumes that the servlet
 container or the frontend web server has performed all user
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index 2707e5c..c54717b 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -45,6 +45,18 @@
 If the commit fails the validation, the plugin can throw an exception
 which will cause the merge to fail.
 
+[[on-submit-validation]]
+== On submit validation
+
+
+Plugins implementing the `OnSubmitValidationListener` interface can
+perform additional validation checks against ref operations resulting
+from execution of submit operation before they are applied to any git
+repositories (there could be more than one in case of topic submits).
+
+Plugin can throw an exception which will cause submit operation to be
+aborted.
+
 [[pre-upload-validation]]
 == Pre-upload validation
 
@@ -80,6 +92,13 @@
 E.g. a plugin could use this to enforce a certain name scheme for
 group names.
 
+[[assignee-validation]]
+== Assignee validation
+
+
+Plugins implementing the `AssigneeValidationListener` interface can perform
+validation of assignees before they are assigned to a change.
+
 [[hashtag-validation]]
 == Hashtag validation
 
diff --git a/Documentation/config.defs b/Documentation/config.defs
deleted file mode 100644
index 7f814d3..0000000
--- a/Documentation/config.defs
+++ /dev/null
@@ -1,22 +0,0 @@
-DOCUMENTATION_DEPS = {
-  "install-quick.txt": ["config-login-register.txt"],
-  "install.txt": ["database-setup.txt"],
-}
-
-def documentation_attributes(revision):
-  return [
-    'toc',
-    'newline="\\n"',
-    'asterisk="&#42;"',
-    'plus="&#43;"',
-    'caret="&#94;"',
-    'startsb="&#91;"',
-    'endsb="&#93;"',
-    'tilde="&#126;"',
-    'last-update-label!',
-    'source-highlighter=prettify',
-    'stylesheet=DEFAULT',
-    'linkcss=true',
-    'prettifydir=.',
-    'revnumber="%s"' % revision,
-  ]
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index 0f73bd3..2dbec2d 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -55,6 +55,8 @@
 [[createdb_mysql]]
 === MySQL
 
+Requirements: MySQL version 5.1 or later.
+
 This option is also more complicated than the H2 option. Just as with
 PostgreSQL it's also recommended for larger installations.
 
@@ -66,7 +68,7 @@
   mysql
 
   CREATE USER 'gerrit2'@'localhost' IDENTIFIED BY 'secret';
-  CREATE DATABASE reviewdb;
+  CREATE DATABASE reviewdb DEFAULT CHARACTER SET 'utf8';
   GRANT ALL ON reviewdb.* TO 'gerrit2'@'localhost';
   FLUSH PRIVILEGES;
 ----
@@ -74,6 +76,16 @@
 Visit MySQL's link:http://dev.mysql.com/doc/[documentation] for further
 information regarding using MySQL.
 
+[[createdb_mariadb]]
+=== MariaDB
+
+Requirements: MariaDB version 5.5 or later.
+
+Refer to MySQL section above how to create MariaDB database.
+
+Visit MariaDB's link:https://mariadb.com/kb/en/mariadb/[documentation] for further
+information regarding using MariaDB.
+
 [[createdb_oracle]]
 === Oracle
 
@@ -115,7 +127,7 @@
 
 ----
 [database]
-        password = secret_pasword
+        password = secret_password
 ----
 
 [[createdb_maxdb]]
@@ -204,7 +216,7 @@
 
 ----
 [database]
-        password = secret_pasword
+        password = secret_password
 ----
 
 [[createdb_hana]]
@@ -250,11 +262,3 @@
 
 Visit SAP HANA's link:http://help.sap.com/hana_appliance/[documentation] for
 further information regarding using SAP HANA.
-
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
new file mode 100644
index 0000000..3cbb609
--- /dev/null
+++ b/Documentation/dev-bazel.txt
@@ -0,0 +1,442 @@
+= Gerrit Code Review - Building with Bazel
+
+[[installation]]
+== Installation
+
+You need to use Python (2 or 3), Java 8, and Node.js for building gerrit.
+
+You can install Bazel from the bazel.io:
+https://www.bazel.io/versions/master/docs/install.html
+
+Alternatively use Bazelisk to use the latest supported Bazel version on
+different stable branches: https://github.com/bazelbuild/bazelisk.
+
+[[build]]
+== Building on the Command Line
+
+=== Gerrit Development WAR File
+
+To build the Gerrit web application that includes the GWT UI and the
+PolyGerrit UI:
+
+----
+  bazel build gerrit
+----
+
+[NOTE]
+PolyGerrit UI may require additional tools (such as npm). Please read
+the polygerrit-ui/README.md for more info.
+
+The output executable WAR will be placed in:
+
+----
+  bazel-bin/gerrit.war
+----
+
+[[release]]
+=== Gerrit Release WAR File
+
+To build the Gerrit web application that includes the GWT UI, the
+PolyGerrit UI and documentation:
+
+----
+  bazel build release
+----
+
+The output executable WAR will be placed in:
+
+----
+  bazel-bin/release.war
+----
+
+=== Headless Mode
+
+To build Gerrit in headless mode, i.e. without the GWT Web UI:
+
+----
+  bazel build headless
+----
+
+The output executable WAR will be placed in:
+
+----
+  bazel-bin/headless.war
+----
+
+=== Extension and Plugin API JAR Files
+
+To build the extension, plugin and GWT API JAR files:
+
+----
+  bazel build api
+----
+
+The output archive that contains Java binaries, Java sources and
+Java docs will be placed in:
+
+----
+  bazel-bin/api.zip
+----
+
+Install {extension,plugin,gwt}-api to the local maven repository:
+
+----
+  tools/maven/api.sh install
+----
+
+Install gerrit.war to the local maven repository:
+
+----
+  tools/maven/api.sh war_install
+----
+
+=== Plugins
+
+----
+  bazel build plugins:core
+----
+
+The output JAR files for individual plugins will be placed in:
+
+----
+  bazel-bin/plugins/<name>/<name>.jar
+----
+
+The JAR files will also be packaged in:
+
+----
+  bazel-bin/plugins/core.zip
+----
+
+To build a specific plugin:
+
+----
+  bazel build plugins/<name>
+----
+
+The output JAR file will be be placed in:
+
+----
+  bazel-bin/plugins/<name>/<name>.jar
+----
+
+Note that when building an individual plugin, the `core.zip` package
+is not regenerated.
+
+To build with all Error Prone warnings activated, run:
+
+----
+  bazel build --java_toolchain //tools:error_prone_warnings_toolchain //...
+----
+
+
+[[IDEs]]
+== Using an IDE.
+
+=== IntelliJ
+
+The Gerrit build works with Bazel's link:https://ij.bazel.io[IntelliJ plugin].
+Please follow the instructions on <<dev-intellij#,IntelliJ Setup>>.
+
+=== Eclipse
+
+==== Generating the Eclipse Project
+
+Create the Eclipse project:
+
+----
+  tools/eclipse/project.py
+----
+
+and then follow the link:dev-eclipse.html#setup[setup instructions].
+
+==== Refreshing the Classpath
+
+If an updated classpath is needed, the Eclipse project can be
+refreshed and missing dependency JARs can be downloaded by running
+`project.py` again. For IntelliJ, you need to click the `Sync Project
+with BUILD Files` button of link:https://ij.bazel.io[IntelliJ plugin].
+
+[[documentation]]
+=== Documentation
+
+To build only the documentation for testing or static hosting:
+
+----
+  bazel build Documentation:searchfree
+----
+
+The html files will be bundled into `searchfree.zip` in this location:
+
+----
+  bazel-bin/Documentation/searchfree.zip
+----
+
+To build the executable WAR with the documentation included:
+
+----
+  bazel build withdocs
+----
+
+The WAR file will be placed in:
+
+----
+  bazel-bin/withdocs.war
+----
+
+[[tests]]
+== Running Unit Tests
+
+----
+  bazel test --build_tests_only //...
+----
+
+Debugging tests:
+
+----
+  bazel test --test_output=streamed --test_filter=com.gerrit.TestClass.testMethod  testTarget
+----
+
+Debug test example:
+
+----
+  bazel test --test_output=streamed --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change:api_change
+----
+
+To run a specific test group, e.g. the rest-account test group:
+
+----
+  bazel test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest_account
+----
+
+To run the tests against NoteDb backend with write
+to NoteDb, but not read from it:
+
+----
+  bazel test --test_env=GERRIT_NOTEDB=WRITE //...
+----
+
+Write and read from NoteDb:
+
+----
+  bazel test --test_env=GERRIT_NOTEDB=READ_WRITE //...
+----
+
+Primary storage NoteDb:
+
+----
+  bazel test --test_env=GERRIT_NOTEDB=PRIMARY //...
+----
+
+Primary storage NoteDb and ReviewDb disabled:
+
+----
+  bazel test --test_env=GERRIT_NOTEDB=DISABLE_CHANGE_REVIEW_DB //...
+----
+
+To run only tests that do not use SSH:
+
+----
+  bazel test --test_env=GERRIT_USE_SSH=NO //...
+----
+
+To exclude tests that have been marked as flaky:
+
+----
+  bazel test --test_tag_filters=-flaky //...
+----
+
+To exclude tests that require a Docker host:
+
+----
+  bazel test --test_tag_filters=-docker //...
+----
+
+To ignore cached test results:
+
+----
+  bazel test --cache_test_results=NO //...
+----
+
+To run one or more specific groups of tests:
+
+----
+  bazel test --test_tag_filters=api,git //...
+----
+
+The following values are currently supported for the group name:
+
+* annotation
+* api
+* docker
+* edit
+* elastic
+* git
+* notedb
+* pgm
+* rest
+* server
+* ssh
+
+[[elasticsearch]]
+=== Elasticsearch
+
+Successfully running the Elasticsearch tests requires Docker, and
+may require setting the local
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html[virtual memory].
+
+If Docker is not available, the Elasticsearch tests will be skipped.
+Note that Bazel currently does not show
+link:https://github.com/bazelbuild/bazel/issues/3476[the skipped tests].
+
+== Dependencies
+
+Dependency JARs are normally downloaded as needed, but you can
+download everything upfront.  This is useful to enable
+subsequent builds to run without network access:
+
+----
+  bazel fetch //...
+----
+
+When downloading from behind a proxy (which is common in some corporate
+environments), it might be necessary to explicitly specify the proxy that
+is then used by `curl`:
+
+----
+  export http_proxy=http://<proxy_user_id>:<proxy_password>@<proxy_server>:<proxy_port>
+----
+
+Redirection to local mirrors of Maven Central and the Gerrit storage
+bucket is supported by defining specific properties in
+`local.properties`, a file that is not tracked by Git:
+
+----
+  echo download.GERRIT = http://nexus.my-company.com/ >>local.properties
+  echo download.MAVEN_CENTRAL = http://nexus.my-company.com/ >>local.properties
+----
+
+The `local.properties` file may be placed in the root of the gerrit repository
+being built, or in `~/.gerritcodereview/`.  The file in the root of the gerrit
+repository has precedence.
+
+== Building against unpublished Maven JARs
+
+To build against unpublished Maven JARs, like gwtorm or PrologCafe, the custom
+JARs must be installed in the local Maven repository (`mvn clean install`) and
+`maven_jar()` must be updated to point to the `MAVEN_LOCAL` Maven repository for
+that artifact:
+
+[source,python]
+----
+ maven_jar(
+   name = 'gwtorm',
+   artifact = 'gwtorm:gwtorm:42',
+   repository = MAVEN_LOCAL,
+ )
+----
+
+== Building against artifacts from custom Maven repositories
+
+To build against custom Maven repositories, two modes of operations are
+supported: with rewrite in local.properties and without.
+
+Without rewrite the URL of custom Maven repository can be directly passed
+to the maven_jar() function:
+
+[source,python]
+----
+  GERRIT_FORGE = 'http://gerritforge.com/snapshot'
+
+  maven_jar(
+    name = 'gitblit',
+    artifact = 'com.gitblit:gitblit:1.4.0',
+    sha1 = '1b130dbf5578ace37507430a4a523f6594bf34fa',
+    repository = GERRIT_FORGE,
+ )
+----
+
+When the custom URL has to be rewritten, then the same logic as with Gerrit
+known Maven repository is used: Repo name must be defined that matches an entry
+in local.properties file:
+
+----
+  download.GERRIT_FORGE = http://my.company.mirror/gerrit-forge
+----
+
+And corresponding WORKSPACE excerpt:
+
+[source,python]
+----
+  GERRIT_FORGE = 'GERRIT_FORGE:'
+
+  maven_jar(
+    name = 'gitblit',
+    artifact = 'com.gitblit:gitblit:1.4.0',
+    sha1 = '1b130dbf5578ace37507430a4a523f6594bf34fa',
+    repository = GERRIT_FORGE,
+ )
+----
+
+[[consume-jgit-from-development-tree]]
+
+To consume the JGit dependency from the development tree, edit
+`lib/jgit/jgit.bzl` setting LOCAL_JGIT_REPO to a directory holding a
+JGit repository.
+
+[[bazel-local-caches]]
+
+To accelerate builds, several caches are activated per default:
+
+* ~/.gerritcodereview/bazel-cache/downloaded-artifacts
+* ~/.gerritcodereview/bazel-cache/repository
+* ~/.gerritcodereview/bazel-cache/cas
+
+Currently none of these caches have a maximum size limit. See
+link:https://github.com/bazelbuild/bazel/issues/5139[this bazel issue] for
+details. Users should watch the cache sizes and clean them manually if
+necessary.
+
+
+[[RBE]]
+== Google Remote Build Support
+
+The Bazel build can be used with Google's Remote Build Execution.
+
+
+This needs the following setup steps:
+
+```
+gcloud auth application-default login
+gcloud services enable remotebuildexecution.googleapis.com  --project=${PROJECT}
+```
+
+Create a worker pool. The instances should have at least 4 CPUs each
+for adequate performance.
+
+```
+gcloud alpha remote-build-execution worker-pools create default \
+    --project=${PROJECT} \
+    --instance=default_instance \
+    --worker-count=50 \
+    --machine-type=n1-highcpu-4 \
+    --disk-size=200
+```
+
+To use RBE, execute
+
+```
+bazel test --config=remote \
+    --remote_instance_name=projects/${PROJECT}/instances/default_instance \
+    javatests/...
+```
+
+
+
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-buck.txt b/Documentation/dev-buck.txt
deleted file mode 100644
index dd707f5..0000000
--- a/Documentation/dev-buck.txt
+++ /dev/null
@@ -1,668 +0,0 @@
-= Gerrit Code Review - Building with Buck
-
-
-== Installation
-
-You need to use Java 8 and Node.js for building gerrit.
-
-There is currently no binary distribution of Buck, so it has to be manually
-built and installed.  Apache Ant and gcc are required.  Currently only Linux
-and Mac OS are supported.
-
-Clone the git and build it:
-
-----
-  git clone https://github.com/facebook/buck
-  cd buck
-  git checkout $(cat ../gerrit/.buckversion)
-  ant
-----
-
-If you don't have a `bin/` directory in your home directory, create one:
-
-----
-  mkdir ~/bin
-----
-
-Add the `~/bin` folder to the path:
-
-----
-  PATH=~/bin:$PATH
-----
-
-Note that the buck executable needs to be available in all shell sessions,
-so also make sure it is appended to the path globally.
-
-Add a symbolic link in `~/bin` to the buck and buckd executables:
-
-----
-  ln -s `pwd`/bin/buck ~/bin/
-  ln -s `pwd`/bin/buckd ~/bin/
-----
-
-Verify that `buck` is accessible:
-
-----
-  which buck
-----
-
-To enable autocompletion of buck commands, install the autocompletion
-script from `./scripts/buck-completion.bash` in the buck project.  Refer
-to the script's header comments for installation instructions.
-
-== Prerequisites
-
-Buck requires Python version 2.7 to be installed. The Maven download toolchain
-requires `curl` to be installed.
-
-[[eclipse]]
-== Eclipse Integration
-
-
-=== Generating the Eclipse Project
-
-Create the Eclipse project:
-
-----
-  tools/eclipse/project.py
-----
-
-and then follow the link:dev-eclipse.html#setup[setup instructions].
-
-=== Refreshing the Classpath
-
-If an updated classpath is needed, the Eclipse project can be
-refreshed and missing dependency JARs can be downloaded:
-
-----
-  tools/eclipse/project.py
-----
-
-
-=== Attaching Sources
-
-Source JARs are downloaded by default. This allows Eclipse to show
-documentation or dive into the implementation of a library JAR.
-
-To save time and bandwidth, download of source JARs can be restricted
-to only those that are necessary to compile Java source into JavaScript
-using the GWT compiler:
-
-----
-  tools/eclipse/project.py --no-src
-----
-
-
-[[build]]
-== Building on the Command Line
-
-
-=== Gerrit Development WAR File
-
-To build the Gerrit web application that includes GWT UI and PolyGerrit UI:
-
-----
-  buck build gerrit
-----
-
-[NOTE]
-PolyGerrit UI may require additional tools (such as npm). Please read
-the polygerrit-ui/README.md for more info.
-
-The output executable WAR will be placed in:
-
-----
-  buck-out/gen/gerrit/gerrit.war
-----
-
-To build the Gerrit web application that includes only GWT UI:
-
-----
-  buck build gwtgerrit
-----
-
-The output executable WAR will be placed in:
-
-----
-  buck-out/gen/gwtgerrit/gwtgerrit.war
-----
-
-
-=== Headless Mode
-
-To build Gerrit in headless mode, i.e. without the GWT Web UI:
-
-----
-  buck build headless
-----
-
-The output executable WAR will be placed in:
-
-----
-  buck-out/gen/headless/headless.war
-----
-
-=== Extension and Plugin API JAR Files
-
-To build the extension, plugin and GWT API JAR files:
-
-----
-  buck build api
-----
-
-Java binaries, Java sources and Java docs are generated into corresponding
-project directories in `buck-out/gen`, here as example for plugin API:
-
-----
-  buck-out/gen/gerrit-plugin-api/plugin-api.jar
-  buck-out/gen/gerrit-plugin-api/plugin-api-javadoc/plugin-api-javadoc.jar
-  buck-out/gen/gerrit-plugin-api/plugin-api-src.jar
-----
-
-Install {extension,plugin,gwt}-api to the local maven repository:
-
-----
-  tools/maven/api.sh install
-----
-
-Install gerrit.war to the local maven repository:
-
-----
-  tools/maven/api.sh war_install
-----
-
-=== Plugins
-
-To build all core plugins:
-
-----
-  buck build plugins:core
-----
-
-The output JAR files for individual plugins will be placed in:
-
-----
-  buck-out/gen/plugins/<name>/<name>.jar
-----
-
-The JAR files will also be packaged in:
-
-----
-  buck-out/gen/plugins/core/core.zip
-----
-
-To build a specific plugin:
-
-----
-  buck build plugins/<name>:<name>
-----
-
-The output JAR file will be be placed in:
-
-----
-  buck-out/gen/plugins/<name>/<name>.jar
-----
-
-Note that when building an individual plugin, the `core.zip` package
-is not regenerated.
-
-Additional plugins with BUCK files can be added to the build
-environment by cloning the source repository into the plugins
-subdirectory:
-
-----
-  git clone https://gerrit.googlesource.com/plugins/<name> plugins/<name>
-  echo /plugins/<name> >>.git/info/exclude
-----
-
-Additional plugin sources will be automatically added to Eclipse the
-next time project.py is run:
-
-----
-  tools/eclipse/project.py
-----
-
-
-[[documentation]]
-=== Documentation
-
-To build only the documentation for testing or static hosting:
-
-----
-  buck build docs
-----
-
-The generated html files will NOT come with the search box, and will be
-placed in:
-
-----
-  buck-out/gen/Documentation/searchfree__tmp/Documentation
-----
-
-The html files will also be bundled into `searchfree.zip` in this location:
-
-----
-  buck-out/gen/Documentation/searchfree/searchfree.zip
-----
-
-To build the executable WAR with the documentation included:
-
-----
-  buck build withdocs
-----
-
-The WAR file will be placed in:
-
-----
-  buck-out/gen/withdocs/withdocs.war
-----
-
-[[soyc]]
-=== GWT Compile Report
-
-The GWT compiler can output a compile report (or "story of your compile"),
-describing the size of the JavaScript and which source classes contributed
-to the overall download size.
-
-----
-  buck build soyc
-----
-
-The report will be written as an HTML page to the extras directory, and
-can be opened and viewed in any web browser:
-
-----
-  extras/gerrit_ui/soycReport/compile-report/index.html
-----
-
-Only the "Split Point Report" is created, "Compiler Metrics" are not output.
-
-[[release]]
-=== Gerrit Release WAR File
-
-To build the release of the Gerrit web application, including documentation and
-all core plugins:
-
-----
-  buck build release
-----
-
-The output release WAR will be placed in:
-
-----
-  buck-out/gen/release/release.war
-----
-
-[[tests]]
-== Running Unit Tests
-
-To run all tests including acceptance tests (but not flaky tests):
-
-----
-  buck test --exclude flaky
-----
-
-To exclude flaky and slow tests:
-
-----
-  buck test --exclude flaky slow
-----
-
-To run only a specific group of acceptance tests:
-
-----
-  buck test --include api
-----
-
-The following groups of tests are currently supported:
-
-* acceptance
-* api
-* edit
-* flaky
-* git
-* pgm
-* rest
-* server
-* ssh
-* slow
-
-To run a specific test group, e.g. the rest-account test group:
-
-----
-  buck test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest-account
-----
-
-To create test coverage report:
-
-----
-  buck test --code-coverage --code-coverage-format html --no-results-cache
-----
-
-The HTML report is created in `buck-out/gen/jacoco/code-coverage/index.html`.
-
-== Dependencies
-
-Dependency JARs are normally downloaded automatically, but Buck can inspect
-its graph and download any missing JAR files.  This is useful to enable
-subsequent builds to run without network access:
-
-----
-  tools/download_all.py
-----
-
-When downloading from behind a proxy (which is common in some corporate
-environments), it might be necessary to explicitly specify the proxy that
-is then used by `curl`:
-
-----
-  export http_proxy=http://<proxy_user_id>:<proxy_password>@<proxy_server>:<proxy_port>
-----
-
-Redirection to local mirrors of Maven Central and the Gerrit storage
-bucket is supported by defining specific properties in
-`local.properties`, a file that is not tracked by Git:
-
-----
-  echo download.GERRIT = http://nexus.my-company.com/ >>local.properties
-  echo download.MAVEN_CENTRAL = http://nexus.my-company.com/ >>local.properties
-----
-
-The `local.properties` file may be placed in the root of the gerrit repository
-being built, or in `~/.gerritcodereview/`.  The file in the root of the gerrit
-repository has precedence.
-
-== Building against unpublished Maven JARs
-
-To build against unpublished Maven JARs, like gwtorm or PrologCafe, the custom
-JARs must be installed in the local Maven repository (`mvn clean install`) and
-`maven_jar()` must be updated to point to the `MAVEN_LOCAL` Maven repository for
-that artifact:
-
-[source,python]
-----
- maven_jar(
-   name = 'gwtorm',
-   id = 'gwtorm:gwtorm:42',
-   license = 'Apache2.0',
-   repository = MAVEN_LOCAL,
- )
-----
-
-== Building against artifacts from custom Maven repositories
-
-To build against custom Maven repositories, two modes of operations are
-supported: with rewrite in local.properties and without.
-
-Without rewrite the URL of custom Maven repository can be directly passed
-to the maven_jar() function:
-
-[source,python]
-----
-  GERRIT_FORGE = 'http://gerritforge.com/snapshot'
-
-  maven_jar(
-    name = 'gitblit',
-    id = 'com.gitblit:gitblit:1.4.0',
-    sha1 = '1b130dbf5578ace37507430a4a523f6594bf34fa',
-    license = 'Apache2.0',
-    repository = GERRIT_FORGE,
- )
-----
-
-When the custom URL has to be rewritten, then the same logic as with Gerrit
-known Maven repository is used: Repo name must be defined that matches an entry
-in local.properties file:
-
-----
-  download.GERRIT_FORGE = http://my.company.mirror/gerrit-forge
-----
-
-And corresponding BUCK excerpt:
-
-[source,python]
-----
-  GERRIT_FORGE = 'GERRIT_FORGE:'
-
-  maven_jar(
-    name = 'gitblit',
-    id = 'com.gitblit:gitblit:1.4.0',
-    sha1 = '1b130dbf5578ace37507430a4a523f6594bf34fa',
-    license = 'Apache2.0',
-    repository = GERRIT_FORGE,
- )
-----
-
-=== Caching Build Results
-
-Build results can be locally cached, saving rebuild time when
-switching between Git branches. Buck's documentation covers
-caching in link:http://facebook.github.io/buck/concept/buckconfig.html[buckconfig].
-The trivial case using a local directory is:
-
-----
-  cat >.buckconfig.local <<EOF
-  [cache]
-    mode = dir
-    dir = buck-cache
-  EOF
-----
-
-[[clean-cache]]
-=== Cleaning The Buck Cache
-
-The cache for the Gerrit Code Review project is located in
-`~/.gerritcodereview/buck-cache/locally-built-artifacts`.
-
-The Buck cache should never need to be manually deleted. If you find yourself
-deleting the Buck cache regularly, then it is likely that there is something
-wrong with your environment or your workflow.
-
-If you really do need to clean the cache manually, then:
-
-----
- rm -rf ~/.gerritcodereview/buck-cache/locally-built-artifacts
-----
-
-Note that the root `buck-cache` folder should not be deleted as it also contains
-the `downloaded-artifacts` directory, which holds the artifacts that got
-downloaded (not built locally).
-
-[[buck-daemon]]
-=== Using Buck daemon
-
-Buck ships with a daemon command `buckd`, which uses the
-link:https://github.com/martylamb/nailgun[Nailgun] protocol for running
-Java programs from the command line without incurring the JVM startup
-overhead.
-
-Using a Buck daemon can save significant amounts of time as it avoids the
-overhead of starting a Java virtual machine, loading the buck class files
-and parsing the build files for each command.
-
-It is safe to run several buck daemons started from different project
-directories and they will not interfere with each other. Buck's documentation
-covers daemon in http://facebook.github.io/buck/command/buckd.html[buckd].
-
-To use `buckd` the additional
-link:https://facebook.github.io/watchman[watchman] program must be installed.
-
-To disable `buckd`, the environment variable `NO_BUCKD` must be set. It's not
-recommended to put it in the shell config, as it can be forgotten about it and
-then assumed Buck was working as it should when it should be using buckd.
-Prepend the variable to Buck invocation instead:
-
-----
-  NO_BUCKD=1 buck build gerrit
-----
-
-[[watchman]]
-=== Installing watchman
-
-Watchman is used internally by Buck to monitor directory trees and is needed
-for buck daemon to work properly. Because buckd is activated by default in the
-latest version of Buck, it searches for the watchman executable in the
-path and issues a warning when it is not found and kills buckd.
-
-To prepare watchman installation on Linux:
-
-----
-  git clone https://github.com/facebook/watchman.git
-  cd watchman
-  ./autogen.sh
-----
-
-To install it in user home directory (without root privileges):
-
-----
-  ./configure --prefix $HOME/watchman
-  make install
-----
-
-To install it system wide:
-
-----
-  ./configure
-  make
-  sudo make install
-----
-
-Put $HOME/watchman/bin/watchman in path or link to $HOME/bin/watchman.
-
-To install watchman on OS X:
-
-----
-  brew install --HEAD watchman
-----
-
-See the original documentation for more information:
-link:https://facebook.github.io/watchman/docs/install.html[Watchman
-installation].
-
-=== Override Buck's settings
-
-Additional JVM args for Buck can be set in `.buckjavaargs` in the
-project root directory. For example to override Buck's default 1GB
-heap size:
-
-----
-  cat > .buckjavaargs <<EOF
-  -XX:MaxPermSize=512m -Xms8000m -Xmx16000m
-  EOF
-----
-
-== Rerun unit tests
-
-Test execution results are cached by Buck. If a test that was already run
-needs to be repeated, the unit test cache for that test must be removed first:
-
-----
-  rm -rf buck-out/bin/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/.rest-account/
-----
-
-After clearing the cache, the test can be run again:
-
-----
-  buck test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest-account
-  [-] TESTING...FINISHED 12,3s (12 PASS/0 FAIL)
-  RESULTS FOR //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest-account
-  PASS     970ms  2 Passed   0 Skipped   0 Failed   com.google.gerrit.acceptance.rest.account.CapabilitiesIT
-  PASS     999ms  1 Passed   0 Skipped   0 Failed   com.google.gerrit.acceptance.rest.account.EditPreferencesIT
-  PASS      1,2s  1 Passed   0 Skipped   0 Failed   com.google.gerrit.acceptance.rest.account.GetAccountDetailIT
-  PASS     951ms  2 Passed   0 Skipped   0 Failed   com.google.gerrit.acceptance.rest.account.GetAccountIT
-  PASS      6,4s  2 Passed   0 Skipped   0 Failed   com.google.gerrit.acceptance.rest.account.GetDiffPreferencesIT
-  PASS      1,2s  4 Passed   0 Skipped   0 Failed   com.google.gerrit.acceptance.rest.account.PutUsernameIT
-  TESTS PASSED
-----
-
-An alternative approach is to use Buck's `--filters` (`-f`) option:
-
-----
-  buck test -f 'com.google.gerrit.acceptance.rest.account.CapabilitiesIT'
-  Using buckd.
-  [-] PROCESSING BUCK FILES...FINISHED 1,0s [100%]
-  [-] BUILDING...FINISHED 2,8s [100%] (334/701 JOBS, 110 UPDATED, 5,1% CACHE MISS)
-  [-] TESTING...FINISHED 9,2s (6 PASS/0 FAIL)
-  RESULTS FOR SELECTED TESTS
-  PASS      8,0s  2 Passed   0 Skipped   0 Failed   com.google.gerrit.acceptance.rest.account.CapabilitiesIT
-  PASS    <100ms  4 Passed   0 Skipped   0 Failed   //tools:util_test
-  TESTS PASSED
-----
-
-When this option is used, the cache is disabled per design and doesn't need to
-be explicitly deleted. Note, that this is a known issue, that python tests are
-always executed.
-
-Note that when this option is used, the whole unit test cache is dropped, so
-repeating the
-
-----
-buck test
-----
-
-causes all tests to be executed again.
-
-To run tests without using cached results at all, use the `--no-results-cache`
-option:
-
-----
-buck test --no-results-cache
-----
-
-== Upgrading Buck
-
-The following tests should be executed, when Buck version is upgraded:
-
-* buck build release
-* tools/maven/api.sh install
-* buck test
-* buck build gerrit, change some sources in gerrit-server project,
-  repeat buck build gerrit and verify that gerrit.war was updated
-* install and verify new gerrit site
-* upgrade and verify existing gerrit site
-* reindex existing gerrit site
-* verify that tools/eclipse/project.py produces sane Eclipse project
-* verify that tools/eclipse/project.py --src generates sources as well
-* verify that unit test execution from Eclipse works
-* verify that daemon started from Eclipse works
-* verify that GWT SDM debug session started from Eclipse works
-
-== Known issues and bugs
-
-=== Symbolic links and `watchman`
-
-`Buck` with activated `Watchman` has currently a
-[known bug](https://github.com/facebook/buck/issues/341) related to
-symbolic links. The symbolic links are used very often with external
-plugins, that are linked per symbolic link to the plugins directory.
-With this use case Buck is failing to rebuild the plugin artifact
-after it was built. All attempts to convince Buck to rebuild will fail.
-The only known way to recover is to weep out `buck-out` directory. The
-better workaround is to avoid using Watchman in this specific use case.
-Watchman can either be de-installed or disabled. See
-link:#buck-daemon[Using Buck daemon] section above how to temporarily
-disable `buckd`.
-
-== Troubleshooting Buck
-
-In some cases problems with Buck itself need to be investigated. See for example
-link:https://gerrit-review.googlesource.com/62411[this attempt to upgrade Buck]
-and link:https://github.com/facebook/buck/pull/227[the fix that was needed] to
-make the update possible.
-
-To build Gerrit with a custom version of Buck, the following steps are necessary:
-
-1. In the Buck git apply any necessary changes from pull requests
-2. Compile Buck with `ant`
-3. In the root of the Gerrit project create a `.nobuckcheck` file to prevent Buck
-from updating itself
-4. Replace the sha1 in Gerrit's `.buckversion` file with the required version from
-the custom Buck build
-5. Build Gerrit as usual
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index 13071df..9bf41e2 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -4,15 +4,15 @@
 From build process perspective there are three types of plugins:
 
 * Maven driven
-* Buck in tree driven
-* Buck standalone driven
+* Bazel tree driven
+* Bazel standalone
 
 These types can be combined: if both files in plugin's root directory exist:
 
-* `BUCK`
+* `BUILD`
 * `pom.xml`
 
-the plugin can be built with both Buck and Maven.
+the plugin can be built with both Bazel and Maven.
 
 
 == Maven driven build
@@ -52,35 +52,101 @@
 Repeat step 1. above.
 
 
-== Buck in tree driven
+== Bazel in tree driven
 
 
-The fact that plugin contains `BUCK` file doesn't mean that building this
-plugin from the plugin directory works. For now it doesn't. Buck in tree driven
-means it can only be built from within Gerrit tree. Clone or link the plugin
-into gerrit/plugins directory:
+The fact that plugin contains `BUILD` file doesn't mean that building this
+plugin from the plugin directory works.
+
+Bazel in tree driven means it can only be built from within Gerrit tree. Clone
+or link the plugin into gerrit/plugins directory:
 
 ----
 cd gerrit
-buck build plugins/<plugin-name>:<plugin-name>
+bazel build plugins/<plugin-name>:<plugin-name>
 ----
 
 The output can be normally found in the following directory:
 
 ----
-buck-out/gen/plugins/<plugin-name>/<plugin-name>.jar
+bazel-bin/plugins/<plugin-name>/<plugin-name>.jar
 ----
 
 Some plugins describe their build process in `src/main/resources/Documentation/build.md`
 file. It may worth checking.
 
-== Buck standalone driven
+=== Plugins with external dependencies ===
+
+If the plugin has external dependencies, then they must be included from Gerrit's
+own WORKSPACE file. This can be achieved by including them in `external_plugin_deps.bzl`.
+During the build in Gerrit tree, this file must be copied over the dummy one in
+`plugins` directory.
+
+Example for content of `external_plugin_deps.bzl` file:
+
+----
+load("//tools/bzl:maven_jar.bzl", "maven_jar")
+
+def external_plugin_deps():
+  maven_jar(
+      name = 'org_apache_tika_tika_core',
+      artifact = 'org.apache.tika:tika-core:1.12',
+      sha1 = '5ab95580d22fe1dee79cffbcd98bb509a32da09b',
+  )
+----
+
+=== Bundle custom plugin in release.war ===
+
+To bundle custom plugin(s) in the link:dev-bazel.html#release[release.war] artifact,
+add them to the CUSTOM_PLUGINS list in `tools/bzl/plugins.bzl`.
+
+Example of `tools/bzl/plugins.bzl` with custom plugin `my-plugin`:
+
+----
+CORE_PLUGINS = [
+    "commit-message-length-validator",
+    "download-commands",
+    "hooks",
+    "replication",
+    "reviewnotes",
+    "singleusergroup",
+]
+
+CUSTOM_PLUGINS = [
+    "my-plugin",
+]
+
+CUSTOM_PLUGINS_TEST_DEPS = [
+    # Add custom core plugins with tests deps here
+]
+----
+
+If the plugin(s) being bundled in the release have external dependencies, include them
+in `plugins/external_plugin_deps`. You should alias `external_plugin_deps()` so it
+can be imported for multiple plugins. For example:
+
+----
+load(":my-plugin/external_plugin_deps.bzl", my_plugin="external_plugin_deps")
+load(":my-other-plugin/external_plugin_deps.bzl", my_other_plugin="external_plugin_deps")
+
+def external_plugin_deps():
+  my_plugin()
+  my_other_plugin()
+----
+
+[NOTE]
+Since `tools/bzl/plugins.bzl` and `plugins/external_plugin_deps.bzl` are part of
+Gerrit's source code and the version of the war is based on the state of the git
+repository that is built; you should commit this change before building, otherwise
+the version will be marked as 'dirty'.
+
+== Bazel standalone driven
 
 Only few plugins support that mode for now:
 
 ----
 cd reviewers
-buck build plugin
+bazel build reviewers
 ----
 
 GERRIT
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 775fe21..41b718e 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -97,6 +97,7 @@
 ====
 
 
+[[git_commit_settings]]
 === A sample good Gerrit commit message:
 ====
   Add sample commit message to guidelines doc
@@ -143,30 +144,31 @@
 link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
 Password tab of the user settings page].
 
-
+[[style]]
 === Style
 
-The basic coding style is covered by the tools/GoogleFormat.xml
-doc, see the link:dev-eclipse.html#Formatting[Eclipse Setup]
-for that.
+Gerrit generally follows the
+link:https://google.github.io/styleguide/javaguide.html[Google Java Style
+Guide].
 
-Highlighted/additional styling notes:
+To format Java source code, Gerrit uses the
+link:https://github.com/google/google-java-format[`google-java-format`]
+tool (version 1.7), and to format Bazel BUILD, WORKSPACE and .bzl files the
+link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`]
+tool (version 0.29.0).
+These tools automatically apply format according to the style guides; this
+streamlines code review by reducing the need for time-consuming, tedious,
+and contentious discussions about trivial issues like whitespace.
 
-  * It is generally more important to match the style of the nearby
-    code which you are modifying than it is to match the style
-    in the formatting guidelines.  This is especially true within the
-    same file.
-  * Review your change in Gerrit to see if it highlights
-    mistakenly deleted/added spaces on lines, trailing spaces.
-  * Line length should be 80 or less, unless the code reads
-    better with something slightly longer.  Shorter lines not only
-    help reviewers who may use a tablet to review the code, but future
-    contributors may also like to open several editors side by
-    side while editing new changes.
-  * Use 2 spaces for indent (no tabs)
-  * Use braces in all if/else/for/do/while/catch blocks, spaces before/after
-    if/for/while/catch parens.
-  * Use /** */ style Javadocs for variables.
+You may download and run `google-java-format` on your own, or you may
+run `./tools/setup_gjf.sh` to download a local copy and set up a
+wrapper script. If you run your own copy, please use the same version,
+as there may be slight differences between versions.
+
+When considering the style beyond just formatting rules, it is often
+more important to match the style of the nearby code which you are
+modifying than it is to match the style guide exactly. This is
+especially true within the same file.
 
 Additionally, you will notice that most of the newline spacing
 is fairly consistent throughout the code in Gerrit, it helps to
@@ -175,8 +177,6 @@
 
   * Keep a blank line between all class and method declarations.
   * Do not add blank lines at the beginning or end of class/methods.
-  * Put a blank line between external import sources, but not
-    between internal ones.
 
 When to use `final` modifier and when not (in new code):
 
@@ -194,7 +194,7 @@
 
 Never:
 
-  * local variables: it clutters the code, and make the code less
+  * local variables: it clutters the code, and makes the code less
   readable. When copying old code to new location, finals should
   be removed
   * method parameters: similar to local variables
@@ -210,10 +210,10 @@
   * Define any static interfaces next in your class.
   * Define non static interfaces after static interfaces in your
     class.
-  * Next you should define static types, members, and methods, in
-    decreasing order of visibility (public to private).
-  * Finally instance members, then constructors, and then instance
-    methods.
+  * Next you should define static types, static members, and
+    static methods, in decreasing order of visibility (public to private).
+  * Finally instance types, instance members, then constructors,
+    and then instance methods.
   * Some common exceptions are private helper static methods, which
     might appear near the instance methods which they help (but may
     also appear at the top).
@@ -223,10 +223,6 @@
     should be before the instance members.
   * Annotations should go before language keywords (`final`, `private`, etc) +
     Example: `@Assisted @Nullable final type varName`
-  * The `@Inject`-ed constructor arguments should be listed one per line.
-  * Imports should be mostly alphabetical (uppercase sorts before
-    all lowercase, which means classes come before packages at the
-    same level).
   * Prefer to open multiple AutoCloseable resources in the same
     try-with-resources block instead of nesting the try-with-resources
     blocks and increasing the indentation level more than necessary.
@@ -341,32 +337,12 @@
 * Update to the same GWT version in the `gwtjsonrpc` project, and release a
 new version.
 
-=== Updating to new version of CodeMirror
+=== Finding starter projects to work on
 
-* Clone the git from https://github.com/codemirror/CodeMirror
-* Checkout the version needed
-* If the needed version is not a tagged version, use `git describe` to determine
-the version number:
-+
-----
- git describe --tags
-----
-
-* Create the release zip file:
-+
-----
- git archive --format=zip --prefix=codemirror-4.10.0-6-gd0a2dda/ d0a2dda > codemirror-4.10.0-6-gd0a2dda.zip
-----
-
-* Determine the sha1 hash of the zip file:
-+
-----
- openssl sha1 codemirror-4.10.0-6-gd0a2dda.zip
-----
-
-* Upload the zip file to the
-link:https://console.developers.google.com/project/164060093628/storage/gerrit-maven/[
-gerrit-maven] storage bucket
+We have created a
+link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject]
+category in the issue tracker and try to assign easy hack projects to it. If in
+doubt, do not hesitate to ask on the developer mailing list.
 
 GERRIT
 ------
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 4fa542d..810a0ba 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -33,7 +33,7 @@
 In Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
 
-Expand the `gerrit` project, right-click on the `buck-out` folder, select
+Expand the `gerrit` project, right-click on the `eclipse-out` folder, select
 'Properties', and then under 'Attributes' check 'Derived'.
 
 Note that if you make any changes in the project configuration
@@ -45,17 +45,21 @@
 [[Formatting]]
 == Code Formatter Settings
 
-Import `tools/GoogleFormat.xml` using Window -> Preferences ->
-Java -> Code Style -> Formatter -> Import...
-
-This will define the 'Google Format' profile, which the project
-settings prefer when formatting source code.
-
+To format source code, Gerrit uses the
+link:https://github.com/google/google-java-format[`google-java-format`]
+tool (version 1.7), which automatically formats code to follow the
+style guide. See link:dev-contributing.html#style[Code Style] for the
+instruction how to set up command line tool that uses this formatter.
+The Eclipse plugin is provided that allows to format with the same
+formatter from within the Eclipse IDE. See
+link:https://github.com/google/google-java-format#eclipse[Eclipse plugin]
+for details how to install it. It's important to use the same plugin version
+as the `google-java-format` script.
 
 == Site Initialization
 
 Build once on the command line with
-link:dev-buck.html#build[Buck] and then follow
+link:dev-bazel.html#build[Bazel] and then follow
 link:dev-readme.html#init[Site Initialization] in the
 Developer Setup guide to configure a local site for testing.
 
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
new file mode 100644
index 0000000..8bedd08
--- /dev/null
+++ b/Documentation/dev-intellij.txt
@@ -0,0 +1,200 @@
+= Gerrit Code Review - IntelliJ Setup
+
+== Prerequisites
+You need an installation of IntelliJ version 2016.2 or later. The latest version
+might not yet be in-sync with the Bazel plugin for IntelliJ. It usually becomes
+so quite quickly after new IDEA versions get released, though. It should then be
+possible to use the fairly latest IntelliJ release with an updated Bazel plugin.
+
+In addition, Java 8 must be specified on your path or via `JAVA_HOME` so that
+building with Bazel via the Bazel plugin is possible.
+
+TIP: If the synchronization of the project with the BUILD files using the Bazel
+plugin fails and IntelliJ reports the error **Could not get Bazel roots**, this
+indicates that the Bazel plugin couldn't find Java 8.
+
+Bazel must be installed as described by
+<<dev-bazel#installation,Building with Bazel - Installation>>.
+
+== Installation of the Bazel plugin
+
+. Go to *File -> Settings -> Plugins*.
+. Click on *Browse Repositories*.
+. Search for the plugin `IntelliJ with Bazel`.
+. Install it.
+. Restart IntelliJ.
+
+== Creation of IntelliJ project
+
+. Go to *File -> Import Bazel Project*.
+. For *Use existing bazel workspace -> Workspace*, select the directory
+containing the Gerrit source code.
+. Choose *Import from workspace* and select the `.bazelproject` file which is
+located in the top directory of the Gerrit source code.
+. Adjust the path of the project data directory and the name of the project if
+desired.
+
+TIP: The project data directory can be separate from the source code. One
+advantage of this is that project files don't need to be excluded from version
+control.
+
+Unfortunately, the created project seems to have a broken output path. To fix
+it, please complete the following steps:
+
+. Go to *File -> Project Structure -> Project Settings -> Modules*.
+. Switch to the tab *Paths*.
+. Click on *Inherit project compile output path*.
+. Click on *Use module compile output path*.
+
+== Recommended settings
+
+=== Code style
+
+==== google-java-format plugin
+Install the `google-java-format` plugin by following these steps:
+
+. Go to *File -> Settings -> Plugins*.
+. Click on *Browse Repositories*.
+. Search for the plugin `google-java-format`.
+. Install it.
+. Restart IntelliJ.
+
+Every time you start IntelliJ, make sure to use *Code -> Reformat with
+google-java-format* on an arbitrary line of code. This replaces the default
+CodeStyleManager with a custom one. Thus, uses of *Reformat Code* either via
+*Code -> Reformat Code*, keyboard shortcuts, or the commit dialog will use the
+custom style defined by the `google-java-format` plugin.
+
+==== Code style settings
+The `google-java-format` plugin is the preferred way to format the code. As it
+only kicks in on demand, it's also recommended to have code style settings
+which help to create properly formatted code as-you-go. Those settings can't
+completely mimic the format enforced by the `google-java-format` plugin but try
+to be as close as possible. So before submitting code, please make sure to run
+*Reformat Code*.
+
+. Download
+https://raw.githubusercontent.com/google/styleguide/gh-pages/intellij-java-google-style.xml[
+intellij-java-google-style.xml].
+. Go to *File -> Settings -> Editor -> Code Style*.
+. Click on *Manage*.
+. Click on *Import*.
+. Choose `IntelliJ IDEA Code Style XML`.
+. Select the previously downloaded file `intellij-java-google-style.xml`.
+. Make sure that `Google Style` is chosen as *Scheme*.
+
+In addition, the EditorConfig settings (which ensure a consistent style between
+Eclipse, IntelliJ, and other editors) should be applied on top of that. Those
+settings are in the file `.editorconfig` of the Gerrit source code. IntelliJ
+will automatically pick up those settings if the EditorConfig plugin is enabled
+and configured correctly as can be verified by:
+
+. Go to *File -> Settings -> Plugins*.
+. Ensure that the EditorConfig plugin is enabled.
+. Go to *File -> Settings -> Editor -> Code Style*.
+. Ensure that *Enable EditorConfig support* is checked.
+
+NOTE: If IntelliJ notifies you later on that the EditorConfig settings override
+the code style settings, simply confirm that.
+
+=== Copyright
+Copy the folder `$(gerrit_source_code)/tools/intellij/copyright` (not just the
+contents) to `$(project_data_directory)/.idea`. If it already exists, replace
+it.
+
+=== File header
+By default, IntelliJ adds a file header containing the name of the author and
+the current date to new files. To disable that, follow these steps:
+
+. Go to *File -> Settings -> Editor -> File and Code Templates*.
+. Select the tab *Includes*.
+. Select *File Header*.
+. Remove the template code in the right editor.
+
+=== Commit message
+To simplify the creation of commit messages which are compliant with the
+<<dev-contributing#commit-message,Commit Message>> format, do the following:
+
+. Go to *File -> Settings -> Version Control*.
+. Check *Commit message right margin (columns)*.
+. Make sure that 72 is specified as value.
+. Check *Wrap when typing reaches right margin*.
+
+In addition, you should follow the instructions of
+<<dev-contributing#git_commit_settings,this section>> (if you haven't
+done so already):
+
+* Install the Git hook for the `Change-Id` line.
+* Set up the HTTP access.
+
+Setting up the HTTP access will allow you to commit changes via IntelliJ without
+specifying your credentials. The Git hook won't be noticeable during a commit
+as it's executed after the commit dialog of IntelliJ was closed.
+
+== Run configurations
+Run configurations can be accessed on the toolbar. To edit them or add new ones,
+choose *Edit Configurations* on the drop-down list of the run configurations
+or go to *Run -> Edit Configurations*.
+
+=== Pre-configured run configurations
+
+In order to be able to use the pre-configured run configurations, the following
+steps are necessary:
+
+. Make sure that the folder `runConfigurations` exists within
+`$(project_data_directory)/.idea`. If it doesn't exist, create it.
+. Specify the IntelliJ path variable `GERRIT_TESTSITE`. (This configuration is
+shared among all IntelliJ projects.)
+.. Go to *Settings -> Appearance & Behavior -> Path Variables*.
+.. Click on the *+* to add a new path variable.
+.. Specify `GERRIT_TESTSITE` as name and the path to your local test site as
+value.
+
+The copied run configurations will be added automatically to the available run
+configurations of the IntelliJ project.
+
+==== Gerrit Daemon
+WARNING: At the moment running this configuration results in a
+`java.io.FileNotFoundException`. To debug a local Gerrit server with IntelliJ,
+use the instructions of <<dev-readme#run_daemon,Running the Daemon>> in
+combination with <<remote-debug,Debugging a remote Gerrit server>>.
+
+Copy `$(gerrit_source_code)/tools/intellij/gerrit_daemon.xml` to
+`$(project_data_directory)/.idea/runConfigurations/`.
+
+This run configuration starts the Gerrit daemon similarly as
+<<dev-readme#run_daemon,Running the Daemon>>.
+
+NOTE: The <<dev-readme#init,Site Initialization>> has to be completed
+before this run configuration works properly.
+
+=== Unit tests
+To create run configurations for unit tests, run or debug them via a right-click
+on a method, class, file, or package. The created run configuration is a
+temporary one and can be saved to make it permanent.
+
+Normally, this approach generates JUnit run configurations. When the Bazel
+plugin manages a project, it intercepts the creation and creates a Bazel test
+run configuration instead, which can be used just like the standard ones.
+
+TIP: If you would like to execute a test in NoteDb mode, add
+`--test_env=GERRIT_NOTEDB=READ_WRITE` to the *Bazel flags* of your run
+configuration.
+
+[[remote-debug]]
+=== Debugging a remote Gerrit server
+If a remote Gerrit server is running and has opened a debug port, you can attach
+IntelliJ via a `Remote debug configuration`.
+
+. Go to *Run -> Edit Configurations*.
+. Click on the *+* to add a new configuration.
+. Choose *Remote*.
+. Adjust *Configuration -> Settings -> Host* and *Port*.
+. Start this configuration in `Debug` mode.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-note-db.txt b/Documentation/dev-note-db.txt
new file mode 100644
index 0000000..dd3b316
--- /dev/null
+++ b/Documentation/dev-note-db.txt
@@ -0,0 +1,137 @@
+= Gerrit Code Review - NoteDb Backend
+
+NoteDb is the next generation of Gerrit storage backend, which replaces the
+traditional SQL backend for change and account metadata with storing data in the
+same repository as code changes.
+
+.Advantages
+- *Simplicity*: All data is stored in one location in the site directory, rather
+  than being split between the site directory and a possibly external database
+  server.
+- *Consistency*: Replication and backups can use a snapshot of the Git
+  repository refs, which will include both the branch and patch set refs, and
+  the change metadata that points to them.
+- *Auditability*: Rather than storing mutable rows in a database, modifications
+  to changes are stored as a sequence of Git commits, automatically preserving
+  history of the metadata. +
+  There are no strict guarantees, and meta refs may be rewritten, but the
+  default assumption is that all operations are logged.
+- *Extensibility*: Plugin developers can add new fields to metadata without the
+  core database schema having to know about them.
+- *New features*: Enables simple federation between Gerrit servers, as well as
+  offline code review and interoperation with other tools.
+
+== Current Status
+
+- Storing change metadata is fully implemented in master, and is live on the
+  servers behind `googlesource.com`. In other words, if you use
+  link:https://gerrit-review.googlesource.com/[gerrit-review], you're already
+  using NoteDb. +
+- Storing some account data, e.g. user preferences, is implemented in releases
+  back to 2.13.
+- Storing the rest of account data is a work in progress.
+- Storing group data is a work in progress.
+
+To match the current configuration of `googlesource.com`, paste the following
+config snippet in your `gerrit.config`:
+
+----
+[noteDb "changes"]
+  write = true
+  read = true
+  primaryStorage = NOTE_DB
+  disableReviewDb = true
+----
+
+
+For an example NoteDb change, poke around at this one:
+----
+  git fetch https://gerrit.googlesource.com/gerrit refs/changes/70/98070/meta \
+      && git log -p FETCH_HEAD
+----
+
+== Configuration
+
+Account and group data is migrated to NoteDb automatically using the normal
+schema upgrade process during updates. The remainder of this section details the
+configuration options that control migration of the change data, which is mostly
+but not fully implemented.
+
+Change migration state is configured in `gerrit.config` with options like
+`noteDb.changes.*`. These options are undocumented outside of this file, and the
+general approach has been to add one new option for each phase of the migration.
+Assume that each config option in the following list requires all of the
+previous options, unless otherwise noted.
+
+- `noteDb.changes.write=true`: During a ReviewDb write, the state of the change
+  in NoteDb is written to the `note_db_state` field in the `Change` entity.
+  After the ReviewDb write, this state is written into NoteDb, resulting in
+  effectively double the time for write operations. NoteDb write errors are
+  dropped on the floor, and no attempt is made to read from ReviewDb or correct
+  errors (without additional configuration, below). +
+  This state allows for a rolling update in a multi-master setting, where some
+  servers can start reading from NoteDb, but older servers are still reading
+  only from ReviewDb.
+- `noteDb.changes.read=true`: Change data is written
+  to and read from NoteDb, but ReviewDb is still the source of truth. During
+  reads, first read the change from ReviewDb, and compare its `note_db_state`
+  with what is in NoteDb. If it doesn't match, immediately "auto-rebuild" the
+  change, copying data from ReviewDb to NoteDb and returning the result.
+- `noteDb.changes.primaryStorage=NOTE_DB`: New changes are written only to
+  NoteDb, but changes whose primary storage is ReviewDb are still supported.
+  Continues to read from ReviewDb first as in the previous stage, but if the
+  change is not in ReviewDb, falls back to reading from NoteDb. +
+  Migration of existing changes is described in the link:#migration[Migration]
+  section below. +
+  Due to an implementation detail, writes to Changes or related tables still
+  result in write calls to the database layer, but they are inside a transaction
+  that is always rolled back.
+- `noteDb.changes.disableReviewDb=true`: All access to Changes or related tables
+  is disabled; reads return no results, and writes are no-ops. Assumes the state
+  of all changes in NoteDb is accurate, and so is only safe once all changes are
+  NoteDb primary. Otherwise, reading changes only from NoteDb might result in
+  inaccurate results, and writing to NoteDb would compound the problem. +
+  Thus it is up to an admin of a previously-ReviewDb site to ensure
+  MigratePrimaryStorage has been run for all changes. Note that the current
+  implementation of the `rebuild-note-db` program does not do this. +
+  In this phase, it would be possible to delete the Changes tables out from
+  under a running server with no effect.
+
+[[migration]]
+== Migration
+
+Once configuration options are set, migration to NoteDb is primarily
+accomplished by running the `rebuild-note-db` program. Currently, this program
+bulk copies ReviewDb data into NoteDb, but leaves primary storage of these
+changes in ReviewDb, so the site is runnable with
+`noteDb.changes.{write,read}=true`, but ReviewDb is still required.
+
+Eventually, `rebuild-note-db` will set primary storage to NoteDb for all
+changes by default, so a site will be able to stop using ReviewDb for changes
+immediately after a successful run.
+
+There is code in `PrimaryStorageMigrator.java` to migrate individual changes
+from NoteDb primary to ReviewDb primary. This code is not intended to be used
+except in the event of a critical bug in NoteDb primary changes in production.
+It will likely never be used by `rebuild-note-db`, and in fact it's not
+recommended to run `rebuild-note-db` until the code is stable enough that the
+reverse migration won't be necessary.
+
+=== Zero-Downtime Multi-Master Migration
+
+Single-master Gerrit sites can use `rebuild-note-db` on an offline site to
+rebuild NoteDb, but this doesn't work in a zero-downtime environment like
+googlesource.com.
+
+Here, the migration process looks like:
+
+- Turn on `noteDb.changes.write=true` to start writing to NoteDb.
+- Run a parallel link:https://research.google.com/pubs/pub35650.html[FlumeJava]
+  pipeline to write NoteDb data for all changes, and update all `note_db_state`
+  fields. (Sorry, this implementation is entirely closed-source.)
+- Turn on `noteDb.changes.read=true` to start reading from NoteDb.
+- Turn on `noteDb.changes.primaryStorage=NOTE_DB` to start writing new changes
+  to NoteDb only.
+- Run a Flume to migrate all existing changes to NoteDb primary. (Also
+  closed-source, but basically just a wrapper around `PrimaryStorageMigrator`.)
+- Turn off access to ReviewDb changes tables.
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 4d636cbb..3092909 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -25,43 +25,19 @@
 [[getting-started]]
 == Getting started
 
-To get started with the development of a plugin there are two
-recommended ways:
+To get started with the development of a plugin clone the sample
+plugin:
 
-. use the Gerrit Plugin Maven archetype to create a new plugin project:
-+
-With the Gerrit Plugin Maven archetype you can create a skeleton for a
-plugin project.
-+
-----
-mvn archetype:generate -DarchetypeGroupId=com.google.gerrit \
-    -DarchetypeArtifactId=gerrit-plugin-archetype \
-    -DarchetypeVersion=2.13.14 \
-    -DgroupId=com.googlesource.gerrit.plugins.testplugin \
-    -DartifactId=testplugin
-----
-+
-Maven will ask for additional properties and then create the plugin in
-the current directory. To change the default property values answer 'n'
-when Maven asks to confirm the properties configuration. It will then
-ask again for all properties including those with predefined default
-values.
-
-. clone the sample plugin:
-+
-This is a project that demonstrates the various features of the
-plugin API. It can be taken as an example to develop an own plugin.
-+
 ----
 $ git clone https://gerrit.googlesource.com/plugins/cookbook-plugin
 ----
-+
+
+This is a project that demonstrates the various features of the
+plugin API. It can be taken as an example to develop an own plugin.
+
 When starting from this example one should take care to adapt the
-`Gerrit-ApiVersion` in the `pom.xml` to the version of Gerrit for which
-the plugin is developed. If the plugin is developed for a released
-Gerrit version (no `SNAPSHOT` version) then the URL for the
-`gerrit-api-repository` in the `pom.xml` needs to be changed to
-`https://gerrit-api.storage.googleapis.com/release/`.
+`Gerrit-ApiVersion` in the `BUILD` to the version of Gerrit for which
+the plugin is developed.
 
 [[API]]
 == API
@@ -156,7 +132,7 @@
 </manifestEntries>
 ----
 
-For Buck driven plugins, the following line must be included in the BUCK
+For Bazel driven plugins, the following line must be included in the BUILD
 configuration file:
 
 [source,python]
@@ -422,6 +398,18 @@
 +
 Update of the account secondary index
 
+* `com.google.gerrit.server.extensions.events.GroupIndexedListener`:
++
+Update of the group secondary index
+
+* `com.google.gerrit.httpd.WebLoginListener`:
++
+User login or logout interactively on the Web user interface.
+
+The event listener is under the Gerrit http package to automatically
+inherit the javax.servlet.http dependencies and allowing to influence
+the login or logout flow with additional redirections.
+
 [[stream-events]]
 == Sending Events to the Events Stream
 
@@ -478,6 +466,14 @@
 Certain operations in Gerrit can be validated by plugins by
 implementing the corresponding link:config-validation.html[listeners].
 
+[[change-message-modifier]]
+== Change Message Modifier
+
+`com.google.gerrit.server.git.ChangeMessageModifier`:
+plugins implementing this can modify commit message of the change being
+submitted by Rebase Always and Cherry Pick submit strategies as well as
+change being queried with COMMIT_FOOTERS option.
+
 [[receive-pack]]
 == Receive Pack Initializers
 
@@ -639,7 +635,7 @@
 ----
 
 [[search_operators]]
-=== Search Operators ===
+== Search Operators
 
 Plugins can define new search operators to extend change searching by
 implementing the `ChangeQueryBuilder.ChangeOperatorFactory` interface
@@ -680,6 +676,43 @@
 }
 ----
 
+[[search_operands]]
+=== Search Operands ===
+
+Plugins can define new search operands to extend change searching.
+Plugin methods implementing search operands (returning a
+`Predicate<ChangeData>`), must be defined on a class implementing
+one of the `ChangeQueryBuilder.ChangeOperandsFactory` interfaces
+(.e.g., ChangeQueryBuilder.ChangeHasOperandFactory).  The specific
+`ChangeOperandFactory` class must also be bound to the `DynamicSet` from
+a module's `configure()` method in the plugin.
+
+The new operand, when used in a search would appear as:
+  operatorName:operandName_pluginName
+
+A sample `ChangeHasOperandFactory` class implementing, and registering, a
+new `has:sample_pluginName` operand is shown below:
+
+====
+  @Singleton
+  public class SampleHasOperand implements ChangeHasOperandFactory {
+    public static class Module extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(ChangeHasOperandFactory.class)
+            .annotatedWith(Exports.named("sample")
+            .to(SampleHasOperand.class);
+      }
+    }
+
+    @Override
+    public Predicate<ChangeData> create(ChangeQueryBuilder builder)
+        throws QueryParseException {
+      return new HasSamplePredicate();
+    }
+====
+
+
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
 
@@ -879,12 +912,15 @@
 
 Plugins can refer to groups so that when they are renamed, the project
 config will also be updated in this section. The proper format to use is
-the string representation of a GroupReference, as shown below.
+the same as for any other group reference in the `project.config`, as shown below.
 
 ----
-Group[group_name / group_uuid]
+group group_name
 ----
 
+The file `groups` must also contains the mapping of the group name and its UUID,
+refer to link:config-project-config.html#file-groups[file groups]
+
 [[project-specific-configuration]]
 == Project Specific Configuration in own config file
 
@@ -1116,6 +1152,10 @@
 +
 Panel will be shown below the related info block.
 
+** `GerritUiExtensionPoint.CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS`:
++
+Panel will be shown in the history bar on the right side of the buttons.
+
 ** The following parameters are provided:
 *** `GerritUiExtensionPoint.Key.CHANGE_INFO`:
 +
@@ -1280,7 +1320,7 @@
       db, project.getNameKey(), user, TimeUtil.nowTs())) {
     bu.addOp(change.getId(), new BatchUpdate.Op() {
       @Override
-      public boolean updateChange(BatchUpdate.ChangeContext ctx) {
+      public boolean updateChange(ChangeContext ctx) {
         return true;
       }
     });
@@ -1318,7 +1358,7 @@
 </manifestEntries>
 ----
 
-or in the `BUCK` configuration file for Buck driven plugins:
+or in the `BUILD` configuration file for Bazel driven plugins:
 
 [source,python]
 ----
@@ -1364,7 +1404,7 @@
 
 [source,java]
 ----
-public class HttpModule extends HttpPluginModule {
+public class HttpModule extends ServletModule {
   @Override
   protected void configureServlets() {
     DynamicSet.bind(binder(), WebUiPlugin.class)
@@ -1383,7 +1423,7 @@
 </manifestEntries>
 ----
 
-or in the `BUCK` configuration file for Buck driven plugins
+or in the `BUILD` configuration file for Bazel driven plugins
 
 [source,python]
 ----
@@ -1414,7 +1454,7 @@
 ----
   curl -X POST -H "Content-Type: application/json" \
     -d '{message: "François", french: true}' \
-    --digest --user joe:secret \
+    --user joe:secret \
     http://host:port/a/changes/1/revisions/1/cookbook~say-hello
   "Bonjour François from change 1, patch set 1!"
 ----
@@ -1454,6 +1494,52 @@
 });
 ----
 
+
+[[action-visitor]]
+=== Action Visitors
+
+In addition to providing new actions, plugins can have fine-grained control
+over the link:rest-api-changes.html#action-info[ActionInfo] map, modifying or
+removing existing actions, including those contributed by core.
+
+Visitors are provided the link:rest-api-changes.html#action-info[ActionInfo],
+which is mutable, along with copies of the
+link:rest-api-changes.html#change-info[ChangeInfo] and
+link:rest-api-changes.html#revision-info[RevisionInfo]. They can modify the
+action, or return `false` to exclude it from the resulting map.
+
+These operations only affect the action buttons that are displayed in the UI;
+the underlying REST API endpoints are not affected. Multiple plugins may
+implement the visitor interface, but the order in which they are run is
+undefined.
+
+For example, to exclude "Cherry-Pick" only from certain projects, and rename
+"Abandon":
+
+[source,java]
+----
+public class MyActionVisitor implements ActionVisitor {
+  @Override
+  public boolean visit(String name, ActionInfo actionInfo,
+      ChangeInfo changeInfo) {
+    if (name.equals("abandon")) {
+      actionInfo.label = "Drop";
+    }
+    return true;
+  }
+
+  @Override
+  public boolean visit(String name, ActionInfo actionInfo,
+      ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+    if (project.startsWith("some-team/") && name.equals("cherrypick")) {
+      return false;
+    }
+    return true;
+  }
+}
+----
+
+
 [[top-menu-extensions]]
 == Top Menu Extensions
 
@@ -1591,30 +1677,11 @@
 }
 ----
 
+
 [[gwt_ui_extension]]
 == GWT UI Extension
 Plugins can extend the Gerrit UI with own GWT code.
 
-The Maven archetype 'gerrit-plugin-gwt-archetype' can be used to
-generate a GWT plugin skeleton. How to use the Maven plugin archetypes
-is described in the link:#getting-started[Getting started] section.
-
-The generated GWT plugin has a link:#top-menu-extensions[top menu] that
-opens a GWT dialog box when the user clicks on it.
-
-In addition to the Gerrit-Plugin API a GWT plugin depends on
-`gerrit-plugin-gwtui`. This dependency must be specified in the
-`pom.xml`:
-
-[source,xml]
-----
-<dependency>
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>${Gerrit-ApiVersion}</version>
-</dependency>
-----
-
 A GWT plugin must contain a GWT module file, e.g. `HelloPlugin.gwt.xml`,
 that bundles together all the configuration settings of the GWT plugin:
 
@@ -1658,7 +1725,7 @@
 
 [source,java]
 ----
-public class HttpModule extends HttpPluginModule {
+public class HttpModule extends ServletModule {
 
   @Override
   protected void configureServlets() {
@@ -2078,6 +2145,16 @@
 e.g. a plugin can provide a list of servers on which the change was
 deployed.
 
+[[change-report-formatting]]
+== Change Report Formatting
+
+When a change is pushed for review from the command line, Gerrit reports
+the change(s) received with their URL and subject.
+
+By implementing the
+`com.google.gerrit.server.git.ChangeReportFormatter` interface, a plugin
+may change the formatting of the report.
+
 [[links-to-external-tools]]
 == Links To External Tools
 
@@ -2129,6 +2206,8 @@
 
 FileHistoryWebLinks will appear on the access rights screen.
 
+TagWebLinks will appear in the tag list in the last column.
+
 [[lfs-extension]]
 == LFS Storage Plugins
 
@@ -2166,9 +2245,9 @@
 /** Register the LfsApiServlet to listen on the default LFS protocol endpoint */
 import static com.google.gerrit.httpd.plugins.LfsPluginServlet.URL_REGEX;
 
-import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.inject.servlet.ServletModule;
 
-public class HttpModule extends HttpPluginModule {
+public class HttpModule extends ServletModule {
 
   @Override
   protected void configureServlets() {
@@ -2265,6 +2344,7 @@
 }
 ----
 
+
 [[documentation]]
 == Documentation
 
@@ -2394,18 +2474,18 @@
 shows the error dialog. This means currently plugins cannot do any
 error handling and e.g. ignore expected errors.
 
-In the following example the REST endpoint would return '404 Not Found'
-if there is no HTTP password and the Gerrit core UI would display an
-error dialog for this. However having no HTTP password is not an error
-and the plugin may like to handle this case.
+In the following example the REST endpoint would return '404 Not
+Found' if the user has no username and the Gerrit core UI would
+display an error dialog for this. However having no username is
+not an error and the plugin may like to handle this case.
 
 [source,java]
 ----
-new RestApi("accounts").id("self").view("password.http")
+new RestApi("accounts").id("self").view("username")
     .get(new AsyncCallback<NativeString>() {
 
   @Override
-  public void onSuccess(NativeString httpPassword) {
+  public void onSuccess(NativeString username) {
     // TODO
   }
 
@@ -2417,6 +2497,89 @@
 ----
 
 
+[[reviewer-suggestion]]
+== Reviewer Suggestion Plugins
+
+Gerrit provides an extension point that enables Plugins to rank
+the list of reviewer suggestion a user receives upon clicking "Add Reviewer" on
+the change screen.
+
+Gerrit supports both a default suggestion that appears when the user has not yet
+typed anything and a filtered suggestion that is shown as the user starts
+typing.
+
+Plugins receive a candidate list and can return a `Set` of suggested reviewers
+containing the `Account.Id` and a score for each reviewer. The candidate list is
+non-binding and plugins can choose to return reviewers not initially contained in
+the candidate list.
+
+Server administrators can configure the overall weight of each plugin by setting
+the `addreviewer.pluginName-exportName.weight` value in `gerrit.config`.
+
+[source, java]
+----
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+
+import java.util.Set;
+
+public class MyPlugin implements ReviewerSuggestion {
+  public Set<SuggestedReviewer> suggestReviewers(Project.NameKey project,
+      @Nullable Change.Id changeId, @Nullable String query,
+      Set<Account.Id> candidates) {
+    Set<SuggestedReviewer> suggestions = new HashSet<>();
+    // Implement your ranking logic here
+    return suggestions;
+  }
+}
+----
+
+
+[[mail-filter]]
+== Mail Filter Plugins
+
+Gerrit provides an extension point that enables Plugins to discard incoming
+messages and prevent further processing by Gerrit.
+
+This can be used to implement spam checks, signature validations or organization
+specific checks like IP filters.
+
+[source, java]
+----
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.mail.receive.MailMessage;
+
+public class MyPlugin implements MailFilter {
+  boolean shouldProcessMessage(MailMessage message) {
+    // Implement your filter logic here
+    return true;
+  }
+}
+----
+
+[[ssh-command-interception]]
+== SSH Command Interception
+
+Gerrit provides an extension point that allows a plugin to intercept
+creation of SSH commands and override the functionality with its own
+implementation.
+
+[source, java]
+----
+import com.google.gerrit.sshd.SshCreateCommandInterceptor;
+
+class MyCommandInterceptor implements SshCreateCommandInterceptor {
+  @Override
+  public String intercept(String in) {
+    return pluginName + " mycommand";
+  }
+}
+----
+
+
 == SEE ALSO
 
 * link:js-api.html[JavaScript API]
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 4a5c0bd..5c24731 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -1,6 +1,6 @@
 = Gerrit Code Review - Developer Setup
 
-Facebook Buck is needed to compile the code, and an SQL database to
+Google Bazel is needed to compile the code, and an SQL database to
 house the review metadata.  H2 is recommended for development
 databases, as it requires no external server process.
 
@@ -18,12 +18,10 @@
 the core plugins, which are included as git submodules, are also
 cloned.
 
-
+[[compile_project]]
 == Compiling
 
-For details on how to build the source code with Buck, refer to:
-link:dev-buck.html#build[Building on the command line with Buck].
-
+Please refer to <<dev-bazel#,Building with Bazel>>.
 
 == Switching between branches
 
@@ -40,33 +38,24 @@
   git clean -fdx
 ----
 
+CAUTION: If you decide to store your Eclipse/IntelliJ project files in the
+Gerrit source directories, executing `git clean -fdx` will remove them and hence
+screw up your project.
+
 
 == Configuring Eclipse
 
 To use the Eclipse IDE for development, please see
 link:dev-eclipse.html[Eclipse Setup].
 
-For details on how to configure the Eclipse workspace with Buck,
-refer to: link:dev-buck.html#eclipse[Eclipse integration with Buck].
+For details on how to configure the Eclipse workspace with Bazel,
+refer to: link:dev-bazel.html#eclipse[Eclipse integration with Bazel].
 
 
 == Configuring IntelliJ IDEA
 
-To use IntelliJ IDEA for development, the easiest way is to follow
-Eclipse integration and then open it as Eclipse project in IDEA.
-You need the Eclipse plugin activated in IntelliJ IDEA.
-
-Once you start compiling using both buck and your Gerrit project in
-IDEA, you will likely need to mark the below directories as generated
-sources roots. You can do so using the IDEA "Project" view. In the
-context menu of each one of these, use "Mark Directory As" to mark
-them as "Generated Sources Root":
-
-----
-  __auto_value_tests_gen__
-  __httpd_gen__
-  __server_gen__
-----
+Please refer to <<dev-intellij#,IntelliJ Setup>> for detailed
+instructions.
 
 == Mac OS X
 
@@ -83,29 +72,62 @@
 [[init]]
 == Site Initialization
 
-After compiling (above), run Gerrit's 'init' command to create a
-testing site for development use:
+After compiling <<compile_project,(above)>>, run Gerrit's 'init' command to
+create a testing site for development use:
 
 ----
-  java -jar buck-out/gen/gerrit/gerrit.war init -d ../gerrit_testsite
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/gerrit.war init -d ../gerrit_testsite
 ----
 
-Accept defaults by pressing Enter until 'init' completes, or add
-the '--batch' command line option to avoid them entirely.  It is
-recommended to change the listen addresses from '*' to 'localhost' to
-prevent outside connections from contacting the development instance.
+[[special_bazel_java_version]]
+NOTE: You must use the same Java version that Bazel used for the build.
+This Java version is available at
+`$(bazel info output_base)/external/local_jdk/bin/java`.
 
-The daemon will automatically start in the background and a web
-browser will launch to the start page, enabling login via OpenID.
+During initialization, make two changes to the default settings:
 
-Shutdown the daemon after registering the administrator account
-through the web interface:
+* Change the listen addresses from '*' to 'localhost' to prevent outside
+  connections from contacting the development instance; and
+* Change the auth type from 'OPENID' to 'DEVELOPMENT_BECOME_ANY_ACCOUNT' to
+  allow yourself to create and act as arbitrary test accounts on your
+  development instance.
+
+Continue through init until it completes. The daemon will automatically start in
+the background and a web browser will launch to the start page. From here you
+can sign in as the account created during init, register additional accounts,
+create projects, and more.
+
+When you want to shut down the daemon, simply run:
 
 ----
   ../gerrit_testsite/bin/gerrit.sh stop
 ----
 
 
+[[localdev]]
+== Working with the Local Server
+
+If you need to create additional accounts on your development instance, click
+'become' in the upper right corner, select 'Switch User', and then register
+a new account.
+
+Use the `ssh` protocol to clone from and push to the local server. For
+example, to clone a repository that you've created through the admin
+interface, run:
+
+----
+git clone ssh://username@localhost:29418/projectname
+----
+
+Then you'll be able to create changes the same way users do, with
+
+----
+git push origin HEAD:refs/for/master
+----
+
+
+
 == Testing
 
 
@@ -119,20 +141,35 @@
 started on that site. When the test has finished the Gerrit daemon is
 shutdown.
 
-For instructions on running the integration tests with Buck,
-please refer to:
-link:dev-buck.html#tests[Running integration tests with Buck].
+For instructions on running the integration tests with Bazel,
+please refer to:  <<dev-bazel#tests,Running Unit Tests with Bazel>>.
 
-
+[[run_daemon]]
 === Running the Daemon
 
 The daemon can be directly launched from the build area, without
 copying to the test site:
 
 ----
-  java -jar buck-out/gen/gerrit/gerrit.war daemon -d ../gerrit_testsite
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite \
+     --console-log
 ----
 
+NOTE: Please refer to <<special_bazel_java_version,this explanation>>
+for details why using `java -jar` isn't sufficient.
+
+If you want to debug the Gerrit server of this test site, you can open a debug
+port (for example port 5005) by inserting
+
+----
+-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
+----
+
+directly after `-jar` of the previous command. Please refer to
+<<dev-intellij#remote-debug,Debugging a remote Gerrit server>> for instructions
+of how to attach IntelliJ.
+
 === Running the Daemon with Gerrit Inspector
 
 link:dev-inspector.html[Gerrit Inspector] is an interactive scriptable
@@ -149,9 +186,13 @@
 command used to launch the daemon:
 
 ----
-  java -jar buck-out/gen/gerrit/gerrit.war daemon -d ../gerrit_testsite -s
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/gerrit.war daemon -d ../gerrit_testsite -s
 ----
 
+NOTE: Please refer to <<special_bazel_java_version,this explanation>>
+for details why using `java -jar` isn't sufficient.
+
 Gerrit Inspector examines Java libraries first, then loads
 its initialization scripts and then starts a command line
 prompt on the console:
@@ -176,9 +217,13 @@
 command line.  If the daemon is not currently running:
 
 ----
-  java -jar buck-out/gen/gerrit/gerrit.war gsql -d ../gerrit_testsite
+  $(bazel info output_base)/external/local_jdk/bin/java \
+     -jar bazel-bin/gerrit.war gsql -d ../gerrit_testsite -s
 ----
 
+NOTE: Please refer to <<special_bazel_java_version,this explanation>>
+for details why using `java -jar` isn't sufficient.
+
 Or, if it is running and the database is in use, connect over SSH
 using an administrator user account:
 
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index 921244f..d43c863 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -89,17 +89,15 @@
 
 To upload artifacts to a bucket the user must authenticate with a
 username and password. The username and password need to be retrieved
-from the link:https://console.developers.google.com/project/164060093628[
-Google Developers Console]:
+from the link:https://console.cloud.google.com/storage/settings?project=api-project-164060093628[
+Storage Setting in the Google Cloud Platform Console]:
 
-* In the menu on the left select `Storage` -> `Cloud Storage` >
-> `Storage access`
-* Select the `Interoperability` tab
-* If no keys are listed under `Interoperable storage access keys`, select "Create a new key"
-* Use the `Access Key` as username, and `Secret` as the password
+Select the `Interoperability` tab, and if no keys are listed under
+`Interoperable storage access keys`, select 'Create a new key'.
 
-To make the username and password known to Maven, they must be
-configured in the `~/.m2/settings.xml` file.
+Using `Access Key` as username and `Secret` as the password, add the
+configuration in the `~/.m2/settings.xml` file to make the credentials
+known to Maven:
 
 ----
   <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
@@ -143,10 +141,9 @@
 ----
 
 [NOTE]
-In case of JGit the `pom.xml` already contains a distributionManagement
-section.  Replace the existing distributionManagement section with this snippet
-in order to deploy the artifacts only in the gerrit-maven repository.
-
+In case of JGit the `pom.xml` already contains a `distributionManagement`
+section.  To deploy the artifacts to the `gerrit-maven` repository, replace
+the existing `distributionManagement` section with this snippet.
 
 * Add these two snippets to the `pom.xml` to enable the wagon provider:
 
diff --git a/Documentation/dev-release-jgit.txt b/Documentation/dev-release-jgit.txt
index f6d4d68..1a8b501 100644
--- a/Documentation/dev-release-jgit.txt
+++ b/Documentation/dev-release-jgit.txt
@@ -1,33 +1,44 @@
-= Making a Release of JGit
+= Making a Snapshot Release of JGit
 
 This step is only necessary if we need to create an unofficial JGit
 snapshot release and publish it to the
 link:https://developers.google.com/storage/[Google Cloud Storage].
 
+[[prepare-environment]]
+== Prepare the Maven Environment
+
+First, make sure you have done the necessary
+link:dev-release-deploy-config.html#deploy-configuration-settings-xml[
+configuration in Maven `settings.xml`].
+
+To apply the necessary settings in JGit's `pom.xml`, follow the instructions
+in link:dev-release-deploy-config.html#deploy-configuration-subprojects[
+Configuration for Subprojects in `pom.xml`], or apply the provided diff by
+executing the following command in the JGit workspace:
+
+----
+  git apply /path/to/gerrit/tools/jgit-snapshot-deploy-pom.diff
+----
 
 [[prepare-release]]
 == Prepare the Release
 
-Since JGit has its own release process we do not push any release tags
-for JGit. Instead we will use the output of the `git describe` as the
-version of the current JGit snapshot.
+Since JGit has its own release process we do not push any release tags. Instead
+we will use the output of `git describe` as the version of the current JGit
+snapshot.
+
+In the JGit workspace, execute the following command:
 
 ----
   ./tools/version.sh --release $(git describe)
 ----
 
-
 [[publish-release]]
 == Publish the Release
 
-* Make sure you have done the configuration needed for deployment:
-** link:dev-release-deploy-config.html#deploy-configuration-settings-xml[
-Configuration in Maven `settings.xml`]
-** link:dev-release-deploy-config.html#deploy-configuration-subprojects[
-Configuration for Subprojects in `pom.xml`]
+To deploy the new snapshot, execute the following command in the JGit
+workspace:
 
-* Deploy the new snapshot. From JGit workspace execute:
-+
 ----
   mvn deploy
 ----
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 96695db..2a857b2 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -83,6 +83,7 @@
 .. link:#push-stable[Push the Stable Branch]
 .. link:#push-tag[Push the Release Tag]
 .. link:#upload-documentation[Upload the Documentation]
+.. link:#finalize-release-notes[Finalize Release Notes]
 .. link:#update-issues[Update the Issues]
 .. link:#announce[Announce on Mailing List]
 . link:#increase-version[Increase Gerrit Version for Current Development]
@@ -110,8 +111,8 @@
 * link:dev-release-subproject.html#prepare-release[Prepare the Release]
 * link:dev-release-subproject.html#publish-release[Publish the Release]
 
-* Update the `id`, `bin_sha1`, and `src_sha1` values in the `maven_jar`
-for the Subproject in `/lib/BUCK` to the released version.
+* Update the `artifact`, `sha1`, and `src_sha1` values in the `maven_jar`
+for the Subproject in `WORKSPACE` to the released version.
 
 [[update-versions]]
 === Update Versions and Create Release Tag
@@ -128,11 +129,6 @@
   ./tools/version.py 2.5
 ----
 
-Also check and update the referenced `archetypeVersion` and the
-`archetypeRepository` in the `Documentation/dev-plugins.txt` file.
-If the referenced `archetypeVersion` will be available in the Maven central,
-delete the line with the `archetypeRepository`.
-
 Commit the changes and create the release tag on the new commit:
 
 ----
@@ -151,8 +147,7 @@
 * Build the Gerrit WAR, API JARs and documentation
 +
 ----
-  buck clean
-  buck build --no-cache release docs
+  bazel build release Documentation:searchfree
   ./tools/maven/api.sh install
 ----
 
@@ -161,11 +156,10 @@
 
 * Verify plugin versions
 +
-Sometimes `buck` doesn't rebuild plugins after they are tagged, and the
-versions don't reflect the tag. Verify the versions:
+Verify the versions:
 +
 ----
-  java -jar ./buck-out/gen/release/release.war init --list-plugins
+  java -jar bazel-bin/release.war init --list-plugins
 ----
 
 [[publish-gerrit]]
@@ -178,7 +172,7 @@
 link:dev-release-deploy-config.html#deploy-configuration-setting-maven-central[
 configuration] for deploying to Maven Central
 
-* Make sure that the version is updated in the `VERSION` file and in
+* Make sure that the version is updated in the `version.bzl` file and in
 the `pom.xml` files as described in the link:#update-versions[Update
 Versions and Create Release Tag] section.
 
@@ -193,21 +187,9 @@
 ----
   ./tools/maven/api.sh deploy
 ----
-+
-If no artifacts are uploaded, clean the `buck-out` folder and retry:
-+
-----
-  buck clean ; rm -rf buck-out
-----
-
-* Push the plugin Maven archetypes to Maven Central:
-+
-----
-  ./tools/plugin_archetype_deploy.sh
-----
 
 * To where the artifacts are uploaded depends on the `GERRIT_VERSION` in
-the `VERSION` file:
+the `version.bzl` file:
 
 ** SNAPSHOT versions are directly uploaded into the Sonatype snapshots
 repository and no further action is needed:
@@ -327,23 +309,24 @@
 [[upload-documentation]]
 ==== Upload the Documentation
 
-* Build the release notes:
-+
-----
-  buck build releasenotes
-----
-
-* Extract the release notes files from the zip file generated from the previous
-step: `buck-out/gen/ReleaseNotes/html/html.zip`.
-
 * Extract the documentation files from the zip file generated from
-`buck build docs`: `buck-out/gen/Documentation/searchfree/searchfree.zip`.
+`bazel build searchfree`: `bazel-bin/Documentation/searchfree.zip`.
 
 * Upload the files manually via web browser to the appropriate folder
 in the
 link:https://console.cloud.google.com/storage/browser/gerrit-documentation/?project=api-project-164060093628[
 gerrit-documentation] storage bucket.
 
+[[finalize-release-notes]]
+=== Finalize the Release Notes
+
+Upload a change on the homepage project to:
+
+* Remove 'In Development' caveat from the relevant section.
+
+* Add links to the released documentation and the .war file, and make the
+latest version bold.
+
 [[update-links]]
 ==== Update homepage links
 
@@ -370,14 +353,14 @@
 
 * Send an email to the mailing list to announce the release, consider
 including some or all of the following in the email:
-** A link to the release and the release notes (if a final release)
+** A link to the release and the release notes
 ** A link to the docs
 ** Describe the type of release (stable, bug fix, RC)
 ** Hash values (SHA1, SHA256, MD5) for the release WAR file.
 +
 The SHA1 and MD5 can be taken from the artifact page on Sonatype. The
 SHA256 can be generated with
-`openssl sha -sha256 buck-out/gen/release/release.war` or an equivalent
+`openssl sha -sha256 bazel-bin/release.war` or an equivalent
 command.
 
 * Update the new discussion group announcement to be sticky
@@ -397,8 +380,7 @@
 next Gerrit release. The Gerrit version should be set to the snapshot version
 for the next release.
 
-Use the `version` tool to set the version in the `VERSION` file and plugin
-archetypes' `pom.xml` files:
+Use the `version` tool to set the version in the `version.bzl` file:
 
 ----
  ./tools/version.py 2.11-SNAPSHOT
diff --git a/Documentation/dev-rest-api.txt b/Documentation/dev-rest-api.txt
index 308d4bd..fec9c97 100644
--- a/Documentation/dev-rest-api.txt
+++ b/Documentation/dev-rest-api.txt
@@ -47,7 +47,7 @@
 Example to set a Gerrit project's link:rest-api-projects.html#set-project-description[description]:
 
 ----
- curl -X PUT --digest --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
+ curl -X PUT --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
 ----
 
 === Authentication
@@ -56,7 +56,7 @@
 the command line:
 
 ----
- curl --digest --user username:password http://localhost:8080/a/path/to/api/
+ curl --user username:password http://localhost:8080/a/path/to/api/
 ----
 
 This makes it easy to switch users for testing of permissions.
@@ -65,7 +65,7 @@
 file (on Windows, `_netrc`):
 
 ----
- curl --digest -n http://localhost:8080/a/path/to/api/
+ curl -n http://localhost:8080/a/path/to/api/
 ----
 
 In both cases, the password should be the user's link:user-upload.html#http[HTTP password].
@@ -75,7 +75,7 @@
 To verify the headers returned from a REST API call, use `curl` in verbose mode:
 
 ----
-  curl -v -n --digest -X DELETE http://localhost:8080/a/path/to/api/
+  curl -v -n -X DELETE http://localhost:8080/a/path/to/api/
 ----
 
 The headers on both the request and the response will be printed.
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
index dfcbb6f..553ac5b 100644
--- a/Documentation/dev-stars.txt
+++ b/Documentation/dev-stars.txt
@@ -55,9 +55,9 @@
 watch a project will always get the email notifications for the change
 creation. Only then the change can be ignored.
 
-Users that are added as reviewer to a change that they have ignored
-will be notified about this, so that they know about the review
-request. They can the decide to remove the ignore star.
+Users that are added as reviewer or assignee to a change that they have
+ignored will be notified about this, so that they know about the review
+request. They can then decide to remove the ignore star.
 
 The ignore star is represented by the special star label 'ignore'.
 
diff --git a/Documentation/error-no-new-changes.txt b/Documentation/error-no-new-changes.txt
index a5c805c..17422ad 100644
--- a/Documentation/error-no-new-changes.txt
+++ b/Documentation/error-no-new-changes.txt
@@ -40,10 +40,11 @@
 If you need to re-push a commit you may rewrite this commit by
 link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[amending] it or doing an interactive link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase]. By rewriting the
 commit you actually create a new commit (with a new commit ID in
-project scope) which can then be pushed to Gerrit. If the old commit
-contains a Change-Id in the commit message you also need to replace
-it with a new Change-Id (case 1. and 3. above), otherwise the push
-will fail with another error message.
+project scope) which can then be pushed to Gerrit.
+
+If you are pushing the new change to the same destination branch as
+the old commit (case 1 above), you also need to replace it with a new
+Change-Id, otherwise the push will fail with another error message.
 
 == Fast-forward merges
 
diff --git a/Documentation/error-permission-denied.txt b/Documentation/error-permission-denied.txt
index 574818d..879273d 100644
--- a/Documentation/error-permission-denied.txt
+++ b/Documentation/error-permission-denied.txt
@@ -3,15 +3,20 @@
 With this error message an SSH command to Gerrit is rejected if the
 SSH authentication is not successful.
 
-The link:http://en.wikipedia.org/wiki/Secure_Shell[SSH] protocol uses link:http://en.wikipedia.org/wiki/Public-key_cryptography[Public-key Cryptography] for authentication.
-This means for a successful SSH authentication you need your private
-SSH key and the corresponding public SSH key must be known to Gerrit.
+The link:http://en.wikipedia.org/wiki/Secure_Shell[SSH] protocol can use
+link:http://en.wikipedia.org/wiki/Public-key_cryptography[Public-key Cryptography]
+for authentication.
+In general configurations, Gerrit will authenticate you by the public keys
+known to you. Optionally, it can be configured by the administrator to allow
+for link:config-gerrit.html#sshd.kerberosKeytab[kerberos] authentication
+instead.
 
-If you are facing this problem, do the following:
+In any case, verify that you are using the correct username for the SSH command
+and that it is typed correctly (case sensitive). You can look up your username
+in the Gerrit Web UI under 'Settings' -> 'Profile'.
 
-. Verify that you are using the correct username for the SSH command
-  and that it is typed correctly (case sensitive). You can look up
-  your username in the Gerrit Web UI under 'Settings' -> 'Profile'.
+If you are facing this problem and using an SSH keypair, do the following:
+
 . Verify that you have uploaded your public SSH key for your Gerrit
   account. To do this go in the Gerrit Web UI to 'Settings' ->
   'SSH Public Keys' and check that your public SSH key is there. If
@@ -21,6 +26,19 @@
   described below. From the trace you should see which private SSH
   key is used.
 
+Debugging kerberos issues can be quite hard given the complexity of the
+protocol. In case you are using kerberos authentication, do the following:
+
+. Verify that you have acquired a valid initial ticket. On a Linux machine, you
+  can acquire one using the `kinit` command. List all your tickets using the
+  `klist` command. It should list all principals for which you have acquired a
+  ticket and include a principal name corresponding to your Gerrit server, for
+  example `HOST/gerrit.mydomain.tld@MYDOMAIN.TLD`.
+  Note that tickets can expire and require you to re-run `kinit` periodically.
+. Verify that your SSH client is using kerberos authentication. For OpenSSH
+  clients this can be controlled using the `GSSAPIAuthentication` setting.
+  For more information see
+  link:user-upload.html#configure_ssh_kerberos[SSH kerberos configuration].
 
 == Test SSH authentication
 
diff --git a/Documentation/error-prohibited-by-gerrit.txt b/Documentation/error-prohibited-by-gerrit.txt
index 3d9bbad..3e5f23b 100644
--- a/Documentation/error-prohibited-by-gerrit.txt
+++ b/Documentation/error-prohibited-by-gerrit.txt
@@ -17,10 +17,10 @@
    link:access-control.html#category_create['Create Reference'] access
    right on `+refs/heads/*+`
 4. if you push an annotated tag without
-   link:access-control.html#category_push_annotated['Push Annotated Tag']
+   link:access-control.html#category_create_annotated['Create Annotated Tag']
    access right on `+refs/tags/*+`
 5. if you push a signed tag without
-   link:access-control.html#category_push_signed['Push Signed Tag']
+   link:access-control.html#category_create_signed['Create Signed Tag']
    access right on `+refs/tags/*+`
 6. if you push a lightweight tag without the access right link:access-control.html#category_create['Create
    Reference'] for the reference name `+refs/tags/*+`
diff --git a/Documentation/gen_licenses.py b/Documentation/gen_licenses.py
deleted file mode 100755
index 15f470c..0000000
--- a/Documentation/gen_licenses.py
+++ /dev/null
@@ -1,178 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2013 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.
-#
-# TODO(sop): Be more detailed: version, link to Maven Central
-
-from __future__ import print_function
-
-import argparse
-from collections import defaultdict, deque
-import json
-from os import chdir, path
-from shutil import copyfileobj
-from subprocess import Popen, PIPE
-from sys import stdout, stderr
-
-parser = argparse.ArgumentParser()
-parser.add_argument('--asciidoc', action='store_true')
-parser.add_argument('--partial', action='store_true')
-parser.add_argument('targets', nargs='+')
-args = parser.parse_args()
-
-KNOWN_PROVIDED_DEPS = [
-  '//lib/bouncycastle:bcpg',
-  '//lib/bouncycastle:bcpkix',
-  '//lib/bouncycastle:bcprov',
-]
-
-for target in args.targets:
-  if not target.startswith('//'):
-    print('Target must be absolute: %s' % target, file=stderr)
-
-def parse_graph():
-  graph = defaultdict(list)
-  while not path.isfile('.buckconfig'):
-    chdir('..')
-  query = ' + '.join('deps(%s)' % t for t in args.targets)
-  p = Popen([
-      'buck', 'query', query,
-      '--output-attributes=buck.direct_dependencies'], stdout=PIPE)
-  obj = json.load(p.stdout)
-  for target, attrs in obj.iteritems():
-    for dep in attrs['buck.direct_dependencies']:
-
-      if target in KNOWN_PROVIDED_DEPS:
-        continue
-
-      if (args.partial
-          and dep == '//gerrit-gwtexpui:CSS'
-          and target == '//gerrit-gwtui:ui_module'):
-        continue
-
-      graph[target].append(dep)
-  r = p.wait()
-  if r != 0:
-    exit(r)
-  return graph
-
-graph = parse_graph()
-licenses = defaultdict(set)
-
-do_not_distribute = False
-queue = deque(args.targets)
-while queue:
-  target = queue.popleft()
-  for dep in graph[target]:
-    if not dep.startswith('//lib:LICENSE-'):
-      continue
-    if 'DO_NOT_DISTRIBUTE' in dep:
-      do_not_distribute = True
-    licenses[dep].add(target)
-  queue.extend(graph[target])
-
-if do_not_distribute:
-  print('DO_NOT_DISTRIBUTE license found', file=stderr)
-  for target in args.targets:
-    print('...via %s:' % target)
-    Popen(['buck', 'query',
-           'allpaths(%s, //lib:LICENSE-DO_NOT_DISTRIBUTE)' % target],
-          stdout=stderr).communicate()
-  exit(1)
-
-used = sorted(licenses.keys())
-
-if args.asciidoc:
-  print("""\
-= Gerrit Code Review - Licenses
-
-Gerrit open source software is licensed under the <<Apache2_0,Apache
-License 2.0>>.  Executable distributions also include other software
-components that are provided under additional licenses.
-
-[[cryptography]]
-== Cryptography Notice
-
-This distribution includes cryptographic software.  The country
-in which you currently reside may have restrictions on the import,
-possession, use, and/or re-export to another country, of encryption
-software.  BEFORE using any encryption software, please check
-your country's laws, regulations and policies concerning the
-import, possession, or use, and re-export of encryption software,
-to see if this is permitted.  See the
-link:http://www.wassenaar.org/[Wassenaar Arrangement]
-for more information.
-
-The U.S. Government Department of Commerce, Bureau of Industry
-and Security (BIS), has classified this software as Export
-Commodity Control Number (ECCN) 5D002.C.1, which includes
-information security software using or performing cryptographic
-functions with asymmetric algorithms.  The form and manner of
-this distribution makes it eligible for export under the License
-Exception ENC Technology Software Unrestricted (TSU) exception
-(see the BIS Export Administration Regulations, Section 740.13)
-for both object code and source code.
-
-Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
-uploads of changes directly from `git push` command line clients.
-
-Gerrit includes an SSH client (JSch), to support authenticated
-replication of changes to remote systems, such as for automatic
-updates of mirror servers, or realtime backups.
-
-For either feature to function, Gerrit requires the
-link:http://java.sun.com/javase/technologies/security/[Java Cryptography extensions]
-and/or the
-link:http://www.bouncycastle.org/java.html[Bouncy Castle Crypto API]
-to be installed by the end-user.
-
-== Licenses
-""")
-
-for n in used:
-  libs = sorted(licenses[n])
-  name = n[len('//lib:LICENSE-'):]
-  if args.asciidoc:
-    print()
-    print('[[%s]]' % name.replace('.', '_'))
-    print("=== " + name)
-    print()
-  else:
-    print()
-    print(name)
-    print()
-    print('----')
-  for d in libs:
-    if d.startswith('//lib:') or d.startswith('//lib/'):
-      p = d[len('//lib:'):]
-    else:
-      p = d[d.index(':')+1:].lower()
-    if '__' in p:
-      p = p[:p.index('__')]
-    print('* ' + p)
-  if args.asciidoc:
-    print()
-    print('[[%s_license]]' % name.replace('.', '_'))
-    print('----')
-  with open(n[2:].replace(':', '/')) as fd:
-    copyfileobj(fd, stdout)
-  print()
-  print('----')
-
-if args.asciidoc:
-  print("""
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-""")
diff --git a/Documentation/index.txt b/Documentation/index.txt
index f53463c..511f19a 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -5,7 +5,7 @@
 .. link:intro-quick.html[A Quick Introduction to Gerrit]
 .. link:intro-user.html[User Guide]
 .. link:intro-project-owner.html[Project Owner Guide]
-.. link:http://source.android.com/submit-patches/workflow[Default Android Workflow] (external)
+.. link:http://source.android.com/source/life-of-a-patch[Default Android Workflow] (external)
 . Web
 .. link:user-review-ui.html[Reviewing Changes]
 .. link:user-search.html[Searching Changes]
@@ -45,6 +45,7 @@
 . link:config-hooks.html[Hooks]
 . link:config-mail.html[Mail Templates]
 . link:config-cla.html[Contributor Agreements]
+. link:config-robot-comments.html[Robot Comments]
 
 == Server Administration
 . link:install.html[Installation Guide]
@@ -60,8 +61,9 @@
 == Developer
 . Getting Started
 .. link:dev-readme.html[Developer Setup]
+.. link:dev-bazel.html[Building with Bazel]
 .. link:dev-eclipse.html[Eclipse Setup]
-.. link:dev-buck.html[Building with Buck]
+.. link:dev-intellij.html[IntelliJ Setup]
 .. link:dev-contributing.html[Contributing to Gerrit]
 . Plugin Development
 .. link:dev-plugins.html[Developing Plugins]
@@ -71,6 +73,7 @@
 .. link:dev-stars.html[Starring Changes]
 . link:dev-design.html[System Design]
 . link:i18n-readme.html[i18n Support]
+. link:dev-note-db.html[NoteDb]
 
 == Maintainer
 . link:dev-release.html[Making a Gerrit Release]
diff --git a/Documentation/install-quick.txt b/Documentation/install-quick.txt
index 2623256..3ab2d4b 100644
--- a/Documentation/install-quick.txt
+++ b/Documentation/install-quick.txt
@@ -26,14 +26,14 @@
 
 ----
   $ java -version
-  java version "1.7.0_21"
-  Java(TM) SE Runtime Environment (build 1.7.0_21-b11)
-  Java HotSpot(TM) 64-Bit Server VM (build 23.21-b01, mixed mode)
+  openjdk version "1.8.0_72"
+  OpenJDK Runtime Environment (build 1.8.0_72-b15)
+  OpenJDK 64-Bit Server VM (build 25.72-b15, mixed mode)
 ----
 
 If Java isn't installed, get it:
 
-* JDK, minimum version 1.7 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
 
 
 [[user]]
@@ -211,7 +211,7 @@
 
 This covers the scope of getting Gerrit started and your first change uploaded.
 It doesn't give any clue as to how the review workflow works, please read
-link:http://source.android.com/submit-patches/workflow[Default Workflow] to
+link:http://source.android.com/source/life-of-a-patch[Default Workflow] to
 learn more about the workflow of Gerrit.
 
 To read more on the installation of Gerrit please see link:install.html[the detailed
diff --git a/Documentation/install.txt b/Documentation/install.txt
index e3fb28d..87d757e 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -5,7 +5,9 @@
 To run the Gerrit service, the following requirements must be met on
 the host:
 
-* JDK, minimum version 1.7 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JRE, version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
++
+Gerrit is not yet compatible with Java 9 or newer at this time.
 
 You'll also need an SQL database to house the review metadata. You have the
 choice of either using the embedded H2 or to host your own MySQL or PostgreSQL.
@@ -172,6 +174,50 @@
 the embedded Jetty server, see
 link:install-j2ee.html[J2EE installation].
 
+[[installation_on_windows]]
+== Installation on Windows
+
+The `ssh-keygen` command must be available during the init phase to
+generate SSH host keys. If you have
+link:https://git-for-windows.github.io/[Git for Windows] installed,
+start Command Prompt and temporary add directory with ssh-keygen to the
+PATH environment variable just before running init command:
+
+====
+  PATH=%PATH%;c:\Program Files\Git\usr\bin
+====
+
+Please note that the path in the above example must not be
+double-quoted.
+
+To run the daemon after site initialization execute:
+
+====
+  cd C:\MY\GERRIT\SITE
+  java.exe -jar bin\gerrit.war daemon --console-log
+====
+
+To stop the daemon press Ctrl+C.
+
+=== Install the daemon as Windows Service
+
+To install Gerrit as Windows Service use the
+link:http://commons.apache.org/proper/commons-daemon/procrun.html[Apache
+Commons Daemon Procrun].
+
+Sample install command:
+
+====
+  prunsrv.exe //IS//Gerrit --DisplayName="Gerrit Code Review" --Startup=auto ^
+        --Jvm="C:\Program Files\Java\jre1.8.0_65\bin\server\jvm.dll" ^
+        --Classpath=C:\MY\GERRIT\SITE\bin\gerrit.war ^
+        --LogPath=C:\MY\GERRIT\SITE\logs ^
+        --StartPath=C:\MY\GERRIT\SITE ^
+        --StartMode=jvm --StopMode=jvm ^
+        --StartClass=com.google.gerrit.launcher.GerritLauncher --StartMethod=daemonStart ^
+        --StopClass=com.google.gerrit.launcher.GerritLauncher --StopMethod=daemonStop ^
+        ++DependsOn=postgresql-x64-9.4
+====
 
 [[customize]]
 == Site Customization
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 7a724f7..38cfeac 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -70,8 +70,8 @@
 commands:
 
 ----
-  $ git fetch origin refs/meta/config:config
-  $ git checkout config
+  $ git fetch ssh://localhost:29418/project refs/meta/config
+  $ git checkout FETCH_HEAD
   $ git log project.config
 ----
 
@@ -330,7 +330,7 @@
 
 A Prolog submit rule has access to link:prolog-change-facts.html[
 information] about the change for which it is testing the
-submittability. Amongst others the list of the modified files can be
+submittability. Among others the list of the modified files can be
 accessed, which allows special logic if certain files are touched. For
 example, a common practice is to require a vote on an additional label,
 like `Library-Compliance`, if the dependencies of the project are
@@ -424,10 +424,13 @@
 
 As project owner you can administrate the branches of your project in
 the Gerrit Web UI under `Projects` > `List` > <your project> >
-`Branches`. In the Web UI both link:project-configuration.html#branch-creation[
-branch creation] and link:project-configuration.html#branch-deletion[branch
-deletion] are allowed for project owners without requiring any
-additional access rights.
+`Branches`. In the Web UI link:project-configuration.html#branch-creation[
+branch creation] is allowed if you have
+link:access-control.html#category_create[Create Reference] access right and
+link:project-configuration.html#branch-deletion[branch deletion] is allowed if
+you have the link:access-control.html#category_delete[Delete Reference] or the
+link:access-control.html#category_push[Push] access right with the `force`
+option.
 
 By setting `HEAD` on the project you can define its
 link:project-configuration.html#default-branch[default branch]. For convenience
@@ -596,7 +599,7 @@
 +
 ----
   [plugin "project-download-commands"]
-    Build = git fetch ${url} ${ref} && git checkout FETCH_HEAD && buck build ${project}
+    Build = git fetch ${url} ${ref} && git checkout FETCH_HEAD && bazel build ${project}
     Update = git fetch ${url} ${ref} && git checkout FETCH_HEAD && git submodule update
 ----
 +
diff --git a/Documentation/intro-quick.txt b/Documentation/intro-quick.txt
index bb80134..d72c696 100644
--- a/Documentation/intro-quick.txt
+++ b/Documentation/intro-quick.txt
@@ -208,7 +208,7 @@
 can add file comment by double clicking anywhere (not just on the
 "Patch Set" words) in the table header or single clicking on the icon
 in the line-number column header. Once published these comments are
-viewable to all, allowing discussion of the change to take place.
+visible to all, allowing discussion of the change to take place.
 
 .Side By Side Patch View
 image::images/intro-quick-review-line-comment.jpg[Adding a Comment]
@@ -317,8 +317,8 @@
 paste this command and run it in our Gerrit checkout.
 
 ----
-$ git fetch http://gerrithost:8080/p/RecipeBook refs/changes/68/68/2
-From http://gerrithost:8080/p/RecipeBook
+$ git fetch ssh://gerrithost:29418/RecipeBook refs/changes/68/68/2
+From ssh://gerrithost:29418/RecipeBook
  * branch            refs/changes/68/68/2 -> FETCH_HEAD
 $ git checkout FETCH_HEAD
 Note: checking out 'FETCH_HEAD'.
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 9bf6842..86962b9 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -56,8 +56,8 @@
 and the link:user-upload.html#http[HTTP/HTTPS] protocols.
 
 [NOTE]
-To use SSH you must link:user-upload.html#configure_ssh[generate an SSH
-key pair and upload the public SSH key to Gerrit].
+To use SSH you may need to link:user-upload.html#ssh[configure your SSH public
+key in your `Settings`].
 
 [[code-review]]
 == Code Review Workflow
@@ -470,11 +470,16 @@
 link:user-review-ui.html#project-branch-topic[change screen].
 
 It is also possible to link:user-upload.html#topic[set a topic on
-push].
+push], either by appending `%topic=...` to the ref name or through
+the use of the command line flag `--push-option`, aliased to `-o`,
+followed by `topic=...`.
 
 .Set Topic on Push
 ----
   $ git push origin HEAD:refs/for/master%topic=multi-master
+
+  // this is the same as:
+  $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
 [[drafts]]
@@ -486,12 +491,15 @@
 changes can also be used to backup unfinished changes.
 
 A draft change is created by pushing to the magic
-`refs/drafts/<target-branch>` ref.
+`refs/drafts/<target-branch>` ref, or by pushing with the 'draft'
+option to `refs/for/<target-branch>%draft`.
 
 .Push a Draft Change
 ----
   $ git commit
   $ git push origin HEAD:refs/drafts/master
+  # or
+  $ git push origin HEAD:refs/for/master%draft
 ----
 
 Draft changes have the state link:user-review-ui.html#draft[Draft] and
@@ -639,6 +647,37 @@
 +
 Email notifications are disabled.
 
+- [[email-format]]`Email Format`:
++
+This setting controls the email format Gerrit sends. Note that this
+setting has no effect if the administrator has disabled HTML emails
+for the Gerrit instance.
++
+** `Plaintext Only`:
++
+Email notifications contain only plaintext content.
++
+** `HTML and Plaintext`:
++
+Email notifications contain both HTML and plaintext content.
+
+- [[default-base-for-merges]]`Default Base For Merges`:
++
+This setting controls which base should be pre-selected in the
+`Diff Against` drop-down list when the change screen is opened for a
+merge commit.
++
+** `Auto Merge`:
++
+Pre-selects `Auto Merge` in the `Diff Against` drop-down list when the
+change screen is opened for a merge commit.
++
+** `First Parent`:
++
+Pre-selects `Parent 1` in the `Diff Against` drop-down list when the
+change screen is opened for a merge commit.
++
+
 - [[diff-view]]`Diff View`:
 +
 Whether the Side-by-Side diff view or the Unified diff view should be
@@ -690,6 +729,105 @@
 menu. This can be used to make the navigation to frequently used
 screens, e.g. configured link:#dashboards[dashboards], quick.
 
+[[reply-by-email]]
+== Reply by Email
+
+Gerrit sends out email notifications to users and supports parsing back replies
+on some of them (when link:config-gerrit.html#receiveemail[configured]).
+
+Gerrit supports replies on these notification emails:
+
+* Notifications about new comments
+* Notifications about new labels that were applied or removed
+
+While Gerrit supports a wide range of email clients, the following ones have
+been tested and are known to work:
+
+* Gmail
+* Gmail Mobile
+
+Gerrit supports parsing back all comment types that can be applied to a change
+via the WebUI:
+
+* Change messages
+* Inline comments
+* File comments
+
+Please note that comments can only be sent in reply to a comment in the original
+notification email, while the change message is independent of those.
+
+Gerrit supports parsing a user's reply from both HTML and plaintext. Please note
+that some email clients extract the text from the HTML email they have received
+and send this back as a quoted reply if you have set the client to plaintext
+mode. In this case, Gerrit only supports parsing a change message. To work
+around this issue, consider setting a <<email-format,User Preference>> to
+receive only plaintext emails.
+
+Example notification:
+----
+Some User has posted comments on this change.
+(https://gerrit-review.googlesource.com/123123 )
+
+Change subject: My new change
+......................................................................
+
+
+Patch Set 3:
+
+Just a couple of smaller things I found.
+
+https://gerrit-review.googlesource.com/#/c/123123/3/MyFile.java
+File
+MyFile.java:
+
+https://gerrit-review.googlesource.com/#/c/123123/3/MyFile@420
+PS3, Line 420:     someMethodCall(param);
+Seems to be failing the tests.
+
+
+--
+To view, visit https://gerrit-review.googlesource.com/123123
+To unsubscribe, visit https://gerrit-review.googlesource.com/settings
+
+(Footers omitted for brevity, must be included in all emails)
+----
+
+Example response from the user:
+----
+Thanks, I'll fix it.
+> Some User has posted comments on this change.
+> (https://gerrit-review.googlesource.com/123123 )
+>
+> Change subject: My new change
+> ......................................................................
+>
+>
+> Patch Set 3:
+>
+> Just a couple of smaller things I found.
+>
+> https://gerrit-review.googlesource.com/#/c/123123/3/MyFile.java
+> File
+> MyFile.java:
+Rename this file to File.java
+>
+> https://gerrit-review.googlesource.com/#/c/123123/3/MyFile@420
+> PS3, Line 420:     someMethodCall(param);
+> Seems to be failing the tests.
+>
+Yeah, I see why, let me try again.
+>
+> --
+> To view, visit https://gerrit-review.googlesource.com/123123
+> To unsubscribe, visit https://gerrit-review.googlesource.com/settings
+>
+> (Footers omitted for brevity, must be included in all emails)
+----
+
+In this case, Gerrit will persist a change message ("Thanks, I'll fix it."),
+a file comment ("Rename this file to File.java") as well as a reply to an
+inline comment ("Yeah, I see why, let me try again.").
+
 
 GERRIT
 ------
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
index 8c9950e..8a19720 100644
--- a/Documentation/js-api.txt
+++ b/Documentation/js-api.txt
@@ -701,8 +701,7 @@
 accessed through this name.
 
 [[Gerrit_css]]
-Gerrit.css()
-~~~~~~~~~~~~
+=== Gerrit.css()
 Creates a new unique CSS class and injects it into the document.
 The name of the class is returned and can be used by the plugin.
 See link:#Gerrit_html[`Gerrit.html()`] for an easy way to use
@@ -770,7 +769,7 @@
 ----
 Gerrit.get('/changes/?q=status:open', function (open) {
   for (var i = 0; i < open.length; i++) {
-    console.log(open.get(i).change_id);
+    console.log(open[i].change_id);
   }
 });
 ----
@@ -805,8 +804,7 @@
 The user can return to Gerrit with the back button.
 
 [[Gerrit_html]]
-Gerrit.html()
-~~~~~~~~~~~~~
+=== Gerrit.html()
 Parses an HTML fragment after performing template replacements.  If
 the HTML has a single root element or node that node is returned,
 otherwise it is wrapped inside a `<div>` and the div is returned.
@@ -900,8 +898,7 @@
 ----
 
 [[Gerrit_injectCss]]
-Gerrit.injectCss()
-~~~~~~~~~~~~~~~~~~
+=== Gerrit.injectCss()
 Injects CSS rules into the document by appending onto the end of the
 existing rule list.  CSS rules are global to the entire application
 and must be manually scoped by each plugin.  For an automatic scoping
diff --git a/Documentation/json.txt b/Documentation/json.txt
index ef40aee..fa61d01 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -155,7 +155,8 @@
 
 oldRev:: The old value of the ref, prior to the update.
 
-newRev:: The new value the ref was updated to.
+newRev:: The new value the ref was updated to. Zero value (`0000000000000000000000000000000000000000`)
+indicates that the ref was deleted.
 
 refName:: Full ref name within project.
 
diff --git a/Documentation/license.defs b/Documentation/license.defs
deleted file mode 100644
index 42dd3eb..0000000
--- a/Documentation/license.defs
+++ /dev/null
@@ -1,29 +0,0 @@
-def genlicenses(
-    name,
-    out,
-    opts = [],
-    java_deps = [],
-    non_java_deps = [],
-    visibility = []):
-  cmd = ['$(exe :gen_licenses)']
-  cmd.extend(opts)
-  cmd.append('>$OUT')
-  cmd.extend(java_deps)
-  cmd.extend(non_java_deps)
-
-  # Must use $(classpath) for Java deps, since transitive dependencies are not
-  # first-order dependencies of the output jar, so changes would not cause
-  # invalidation of the build cache key for the genrule.
-  cmd.extend('; true $(classpath %s)' % d for d in java_deps)
-
-  # Must use $(location) for non-Java deps, since $(classpath) will fail with an
-  # error. This is ok, because transitive dependencies are included in the
-  # output artifacts for everything _except_ Java libraries.
-  cmd.extend('; true $(location %s)' % d for d in non_java_deps)
-
-  genrule(
-    name = name,
-    out = out,
-    cmd = ' '.join(cmd),
-    visibility = visibility,
-  )
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 1270971..eae33c2 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -53,6 +53,29 @@
 * `query/query_latency`: Successful query latency, accumulated over the life
 of the process.
 
+=== Core Queues
+
+The following queues support metrics:
+
+* default `WorkQueue`
+* index batch
+* index interactive
+* receive commits
+* send email
+* ssh batch worker
+* ssh command start
+* ssh interactive worker
+* ssh stream worker
+
+Each queue provides the following metrics:
+
+* `queue/<queue_name>/pool_size`: Current number of threads in the pool
+* `queue/<queue_name>/max_pool_size`: Maximum allowed number of threads in the pool
+* `queue/<queue_name>/active_threads`: Number of threads that are actively executing tasks
+* `queue/<queue_name>/scheduled_tasks`: Number of scheduled tasks in the queue
+* `queue/<queue_name>/total_scheduled_tasks_count`: Total number of tasks that have been scheduled
+* `queue/<queue_name>/total_completed_tasks_count`: Total number of tasks that have completed execution
+
 === SSH sessions
 
 * `sshd/sessions/connected`: Number of currently connected SSH sessions.
@@ -76,6 +99,11 @@
 * `git/upload-pack/phase_writing`: Time spent transferring bytes to client.
 * `git/upload-pack/pack_bytes`: Distribution of sizes of packs sent to clients.
 
+=== BatchUpdate
+
+* `batch_update/execute_change_ops`: BatchUpdate change update latency,
+excluding reindexing
+
 === NoteDb
 
 * `notedb/update_latency`: NoteDb update latency by table.
@@ -90,6 +118,10 @@
 
 * `reviewer_suggestion/query_accounts`: Latency for querying accounts for
 reviewer suggestion.
+* `reviewer_suggestion/recommend_accounts`: Latency for recommending accounts
+for reviewer suggestion.
+* `reviewer_suggestion/load_accounts`: Latency for loading accounts for
+reviewer suggestion.
 * `reviewer_suggestion/query_groups`: Latency for querying groups for reviewer
 suggestion.
 
@@ -102,6 +134,10 @@
 * `plugins/replication/replication_retries`: Number of retries when pushing to
 remote destination.
 
+=== License
+
+* `license/cla_check_count`: Total number of CLA check requests.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/pgm-LocalUsernamesToLowerCase.txt b/Documentation/pgm-LocalUsernamesToLowerCase.txt
index 1136ced..03aaabf 100644
--- a/Documentation/pgm-LocalUsernamesToLowerCase.txt
+++ b/Documentation/pgm-LocalUsernamesToLowerCase.txt
@@ -7,9 +7,8 @@
 == SYNOPSIS
 [verse]
 --
-_java_ -jar gerrit.war _LocalUsernamesToLowerCase
+_java_ -jar gerrit.war _LocalUsernamesToLowerCase_
   -d <SITE_PATH>
-  [--threads]
 --
 
 == DESCRIPTION
@@ -29,9 +28,11 @@
 same local username, but with different case. In this case the local
 username for these accounts is not converted to lower case.
 
-This task can run in the background concurrently to the server if the
-database is MySQL or PostgreSQL. If the database is H2, this task
-must be run by itself.
+After all usernames have been migrated, the link:pgm-reindex.html[
+reindex] program is automatically invoked to reindex all accounts.
+
+This task cannot run in the background concurrently to the server;
+it must be run by itself.
 
 == OPTIONS
 
@@ -40,10 +41,6 @@
 	Location of the gerrit.config file, and all other per-site
 	configuration data, supporting libraries and log files.
 
---threads::
-	Number of threads to perform the scan work with.  Defaults to
-	twice the number of CPUs available.
-
 == CONTEXT
 This command can only be run on a server which has direct
 connectivity to the metadata database.
diff --git a/Documentation/pgm-MigrateAccountPatchReviewDb.txt b/Documentation/pgm-MigrateAccountPatchReviewDb.txt
index 5718a8a..c8ab193 100644
--- a/Documentation/pgm-MigrateAccountPatchReviewDb.txt
+++ b/Documentation/pgm-MigrateAccountPatchReviewDb.txt
@@ -27,6 +27,12 @@
 * Migrate data using this command
 * Start Gerrit
 
+[NOTE]
+When using MySQL, the file_name column length in the account_patch_reviews table will be shortened
+from the standard 4096 characters down to 255 characters. This is due to a
+link:https://dev.mysql.com/doc/refman/5.7/en/innodb-restrictions.html[MySQL limitation]
+on the max size of 767 bytes for each column in an index.
+
 == OPTIONS
 
 -d::
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index d71d19a..7d93c64 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -103,7 +103,8 @@
 link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
 is enabled and depending changes share the same topic. So generally
 submitters must remember to submit changes in the right order when using this
-submit type.
+submit type. If all you want is extra information in the commit message,
+consider using the Rebase Always submit strategy.
 
 [[rebase_if_necessary]]
 * Rebase If Necessary
@@ -117,6 +118,17 @@
 succeed if there is no path conflict.  A path conflict occurs when
 the same file has also been changed on the other side of the merge.
 
+[[rebase_always]]
+* Rebase Always
++
+Basically, the same as Rebase If Necessary, but it creates a new patchset even
+if fast forward is possible AND like Cherry Pick it ensures footers such as
+Change-Id, Reviewed-On, and others are present in resulting commit that is
+merged.
+
+Thus, Rebase Always can be considered similar to Cherry Pick, but with
+the important distinction that Rebase Always does not ignore dependencies.
+
 [[content_merge]]
 If `Allow content merges` is enabled, Gerrit will try
 to do a content merge when a path conflict occurs.
@@ -237,9 +249,7 @@
 
 To be able to create new branches the user must have the
 link:access-control.html#category_create[Create Reference] access
-right. In addition, project owners and Gerrit administrators can create
-new branches from the Web UI or via REST even without having the
-`Create Reference` access right.
+right.
 
 When using the Web UI, the REST endpoint or the SSH command it is only
 possible to create branches on commits that already exist in the
@@ -260,17 +270,22 @@
 - in the Web UI under 'Projects' > 'List' > <project> > 'Branches'
 - via the link:rest-api-projects.html#delete-branch[Delete Branch]
   REST endpoint
-- by using a git client to force push nothing to an existing branch
+- by using a git client
++
+----
+  $ git push origin --delete refs/heads/<branch-to-delete>
+----
++
+another method, by force pushing nothing to an existing branch:
 +
 ----
   $ git push --force origin :refs/heads/<branch-to-delete>
 ----
 
 To be able to delete branches, the user must have the
+link:access-control.html#category_delete[Delete Reference] or the
 link:access-control.html#category_push[Push] access right with the
-`force` option. In addition, project owners and Gerrit administrators
-can delete branches from the Web UI or via REST even without having the
-`Force Push` access right.
+`force` option.
 
 [[default-branch]]
 === Default Branch
diff --git a/Documentation/prolog-change-facts.txt b/Documentation/prolog-change-facts.txt
index cbd4070..11d17b8 100644
--- a/Documentation/prolog-change-facts.txt
+++ b/Documentation/prolog-change-facts.txt
@@ -60,6 +60,9 @@
 
 |`uploader/1`     |`uploader(user(1000000)).`
     |Uploader as `user(ID)` term. ID is the numeric account ID
+
+|`unresolved_comments_count/1`     |`unresolved_comments_count(0).`
+    |The number of unresolved comments as an integer atom
 |=============================================================================
 
 In addition Gerrit provides a set of built-in helper predicates that can be used
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index cced53e2..78497eb 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -544,6 +544,7 @@
     Author = label('Author-is-John-Doe', need(_)).
 
 submit_rule(submit(Author)) :-
+    A = user(1000000),
     gerrit:commit_author(A, 'John Doe', 'john.doe@example.com'),
     Author = label('Author-is-John-Doe', ok(A)).
 ----
@@ -899,7 +900,10 @@
     findall(X, gerrit:commit_label(label(Category,X),R),Z),
     sum_list(Z, Sum),
     Sum >= Min, !,
-    P = [label(Category,ok(R)) | In].
+    gerrit:commit_label(label(Category, V), U),
+    V >= 1,
+    !,
+    P = [label(Category,ok(U)) | In].
 
 add_category_min_score(In, Category,Min,P) :-
     P = [label(Category,need(Min)) | In].
@@ -998,6 +1002,56 @@
 only_allow_author_to_submit(S1, [label('Only-Author-Can-Submit', need(_)) | S1]).
 ----
 
+=== Example 16: Make change submittable if all comments have been resolved
+In this example we will use the `unresolved_comments_count` fact about a
+change. Our goal is to block the submission of any change with some
+unresolved comments. Basically, it can be achieved by the following rules:
+
+`rules.pl`
+[source,prolog]
+----
+submit_rule(submit(R)) :-
+    gerrit:unresolved_comments_count(0),
+    !,
+    gerrit:commit_author(A),
+    R = label('All-Comments-Resolved', ok(A)).
+
+submit_rule(submit(R)) :-
+    gerrit:unresolved_comments_count(U),
+    U > 0,
+    R = label('All-Comments-Resolved', need(_)).
+----
+
+Suppose currently a change is submittable if it gets `+2` for `Code-Review`
+and `+1` for `Verified`. It can be extended to support the above rules as
+follows:
+
+`rules.pl`
+[source,prolog]
+----
+submit_rule(submit(CR, V, R)) :-
+    base(CR, V),
+    gerrit:unresolved_comments_count(0),
+    !,
+    gerrit:commit_author(A),
+    R = label('All-Comments-Resolved', ok(A)).
+
+submit_rule(submit(CR, V, R)) :-
+    base(CR, V),
+    gerrit:unresolved_comments_count(U),
+    U > 0,
+    R = label('All-Comments-Resolved', need(_)).
+
+base(CR, V) :-
+    gerrit:max_with_block(-2, 2, 'Code-Review', CR),
+    gerrit:max_with_block(-1, 1, 'Verified', V).
+----
+
+Note that a new label as `All-Comments-Resolved` should not be configured.
+It's only used to show `'Needs All-Comments-Resolved'` in the UI to clearly
+indicate to the user that all the comments have to be resolved for the
+change to become submittable.
+
 == Examples - Submit Type
 The following examples show how to implement own submit type rules.
 
diff --git a/Documentation/replace_macros.py b/Documentation/replace_macros.py
index baf08e7..c76d133 100755
--- a/Documentation/replace_macros.py
+++ b/Documentation/replace_macros.py
@@ -229,12 +229,16 @@
 options, _ = opts.parse_args()
 
 try:
-  out_file = open(options.out, 'w')
-  src_file = open(options.src, 'r')
+  try:
+    out_file = open(options.out, 'w', errors='ignore')
+    src_file = open(options.src, 'r', errors='ignore')
+  except TypeError:
+    out_file = open(options.out, 'w')
+    src_file = open(options.src, 'r')
   last_line = ''
   ignore_next_line = False
   last_title = ''
-  for line in src_file.xreadlines():
+  for line in src_file:
     if PAT_GERRIT.match(last_line):
       # Case of "GERRIT\n------" at the footer
       out_file.write(GERRIT_UPLINK)
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 61ea582..a90ea1a 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -132,7 +132,7 @@
         },
         "refs/tags/*": {
           "permissions": {
-            "pushSignedTag": {
+            "createSignedTag": {
               "rules": {
                 "53a4f647a89ea57992571187d8025f830625192a": {
                   "action": "ALLOW"
@@ -142,7 +142,7 @@
                 }
               }
             },
-            "pushTag": {
+            "createTag": {
               "rules": {
                 "53a4f647a89ea57992571187d8025f830625192a": {
                   "action": "ALLOW"
@@ -263,6 +263,7 @@
       ],
       "can_upload": true,
       "can_add": true,
+      "can_add_tags": true,
       "config_visible": true
     },
     "MyProject": {
@@ -279,6 +280,7 @@
       ],
       "can_upload": true,
       "can_add": true,
+      "can_add_tags": true,
       "config_visible": true
     }
   }
@@ -365,6 +367,8 @@
 Whether the calling user can upload to any ref.
 |`can_add`            |not set if `false`|
 Whether the calling user can add any ref.
+|`can_add_tags`       |not set if `false`|
+Whether the calling user can add any tag ref.
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index e784c1c..4409d1f 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -285,6 +285,66 @@
   HTTP/1.1 204 No Content
 ----
 
+[[get-account-status]]
+=== Get Account Status
+--
+'GET /accounts/link:#account-id[\{account-id\}]/status'
+--
+
+Retrieves the status of an account.
+
+.Request
+----
+  GET /accounts/self/status HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Available"
+----
+
+If the account does not have a status an empty string is returned.
+
+[[set-account-status]]
+=== Set Account Status
+--
+'PUT /accounts/link:#account-id[\{account-id\}]/status'
+--
+
+Sets the status of an account.
+
+The new account status must be provided in the request body inside
+an link:#account-status-input[AccountStatusInput] entity.
+
+.Request
+----
+  PUT /accounts/self/status HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "status": "Out Of Office"
+  }
+----
+
+As response the new account status is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Out Of Office"
+----
+
+If the name was deleted the response is "`204 No Content`".
+
 [[get-username]]
 === Get Username
 --
@@ -396,32 +456,7 @@
   HTTP/1.1 204 No Content
 ----
 
-If the account was already inactive the response is "`404 Not Found`".
-
-[[get-http-password]]
-=== Get HTTP Password
---
-'GET /accounts/link:#account-id[\{account-id\}]/password.http'
---
-
-Retrieves the HTTP password of an account.
-
-.Request
-----
-  GET /accounts/john.doe@example.com/password.http HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  "Qmxlc21ydCB1YmVyIGFsbGVzIGluIGRlciBXZWx0IQ"
-----
-
-If the account does not have an HTTP password the response is "`404 Not Found`".
+If the account was already inactive the response is "`409 Conflict`".
 
 [[set-http-password]]
 === Set/Generate HTTP Password
@@ -968,12 +1003,12 @@
   }
 ----
 
-Administrator that has authenticated with digest authentication:
+Administrator that has authenticated with basic authentication:
 
 .Request
 ----
   GET /a/accounts/self/capabilities HTTP/1.0
-  Authorization: Digest username="admin", realm="Gerrit Code Review", nonce="...
+  Authorization: Basic ABCDECF..
 ----
 
 .Response
@@ -1015,7 +1050,7 @@
 .Request
 ----
   GET /a/accounts/self/capabilities?q=createAccount&q=createGroup HTTP/1.0
-  Authorization: Digest username="admin", realm="Gerrit Code Review", nonce="...
+  Authorization: Basic ABCDEF...
 ----
 
 .Response
@@ -1213,6 +1248,7 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "ABBREV",
     "mute_common_path_prefixes": true,
+    "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
         "url": "#/dashboard/self",
@@ -1237,7 +1273,8 @@
       {
         "url": "#/groups/self",
         "name": "Groups"
-      }
+      },
+      change_table: []
     ]
   }
 ----
@@ -1262,6 +1299,7 @@
     "changes_per_page": 50,
     "show_site_header": true,
     "use_flash_clipboard": true,
+    "expand_inline_diffs": true,
     "download_command": "CHECKOUT",
     "date_format": "STD",
     "time_format": "HHMM_12",
@@ -1294,6 +1332,10 @@
         "url": "#/groups/self",
         "name": "Groups"
       }
+    ],
+    "change_table": [
+      "Subject",
+      "Owner"
     ]
   }
 ----
@@ -1312,6 +1354,7 @@
     "changes_per_page": 50,
     "show_site_header": true,
     "use_flash_clipboard": true,
+    "expand_inline_diffs": true,
     "download_command": "CHECKOUT",
     "date_format": "STD",
     "time_format": "HHMM_12",
@@ -1344,6 +1387,10 @@
         "url": "#/groups/self",
         "name": "Groups"
       }
+    ],
+    "change_table": [
+      "Subject",
+      "Owner"
     ]
   }
 ----
@@ -1381,7 +1428,8 @@
     "show_tabs": true,
     "show_whitespace_errors": true,
     "syntax_highlighting": true,
-    "tab_size": 8
+    "tab_size": 8,
+    "font_size": 12
   }
 ----
 
@@ -1412,7 +1460,8 @@
     "show_tabs": true,
     "show_whitespace_errors": true,
     "syntax_highlighting": true,
-    "tab_size": 8
+    "tab_size": 8,
+    "font_size": 12
   }
 ----
 
@@ -1436,7 +1485,8 @@
     "show_tabs": true,
     "show_whitespace_errors": true,
     "syntax_highlighting": true,
-    "tab_size": 8
+    "tab_size": 8,
+    "font_size": 12
   }
 ----
 
@@ -1475,6 +1525,7 @@
     "hide_line_numbers": true,
     "match_brackets": true,
     "line_wrapping": false,
+    "indent_with_tabs": false,
     "auto_close_brackets": true
   }
 ----
@@ -1660,6 +1711,64 @@
   HTTP/1.1 204 No Content
 ----
 
+[[get-account-external-ids]]
+=== Get Account External IDs
+--
+'GET /accounts/link:#account-id[\{account-id\}]/external.ids'
+--
+
+Retrieves the external ids of a user account.
+
+.Request
+----
+  GET /a/accounts/self/external.ids HTTP/1.0
+----
+
+As result the external ids of the user are returned as a list of
+link:#account-external-id-info[AccountExternalIdInfo] entities.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "identity": "username:john",
+      "email": "john.doe@example.com",
+      "trusted": true
+    }
+  ]
+----
+
+[[delete-account-external-ids]]
+=== Delete Account External IDs
+--
+'POST /accounts/link:#account-id[\{account-id\}]/external.ids:delete'
+--
+
+Delete a list of external ids for a user account. The target external ids must
+be provided as a list in the request body.
+
+Only external ids belonging to the caller may be deleted.
+
+.Request
+----
+  POST /a/accounts/self/external.ids:delete HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "mailto:john.doe@example.com"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[default-star-endpoints]]
 == Default Star Endpoints
 
@@ -2019,6 +2128,22 @@
 registered.
 |=================================
 
+[[account-external-id-info]]
+=== AccountExternalIdInfo
+The `AccountExternalIdInfo` entity contains information for an external id of
+an account.
+
+[options="header",cols="1,^1,5"]
+|============================
+|Field Name        ||Description
+|`identity`        ||The account external id.
+|`email`           |optional|The email address for the external id.
+|`trusted`         |not set if `false`|
+Whether the external id is trusted.
+|`can_delete`      |not set if `false`|
+Whether the external id can be deleted by the calling user.
+|============================
+
 [[account-info]]
 === AccountInfo
 The `AccountInfo` entity contains information about an account.
@@ -2084,6 +2209,18 @@
 If not set or if set to an empty string, the account name is deleted.
 |=============================
 
+[[account-status-input]]
+=== AccountStatusInput
+The `AccountStatusInput` entity contains information for setting a status
+for an account.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name ||Description
+|`status`   |optional|The new status of the account. +
+If not set or if set to an empty string, the account status is deleted.
+|=============================
+
 [[capability-info]]
 === CapabilityInfo
 The `CapabilityInfo` entity contains information about the global
@@ -2226,6 +2363,8 @@
 If true the line numbers are hidden.
 |`tab_size`                    ||
 Number of spaces that should be used to display one tab.
+|`font_size`                    ||
+Default font size in pixels for change to be displayed in the diff view.
 |'hide_empty_pane'             |not set if `false`|
 Whether empty panes should be hidden. The left pane is empty when a
 file was added; the right pane is empty when a file was deleted.
@@ -2286,8 +2425,12 @@
 True if the line numbers should be hidden.
 |`tab_size`                    |optional|
 Number of spaces that should be used to display one tab.
+|`font_size`                   |optional|
+Default font size in pixels for change to be displayed in the diff view.
 |`line_wrapping`               |optional|
 Whether to enable line wrapping or not.
+|`indent_with_tabs`            |optional|
+Whether to enable indent with tabs or not.
 |===========================================
 
 [[edit-preferences-info]]
@@ -2327,8 +2470,12 @@
 Whether matching brackets should be highlighted.
 |`line_wrapping`               |not set if `false`|
 Whether to enable line wrapping or not.
+|`indent_with_tabs`            |not set if `false`|
+Whether to indent with tabs or not.
 |`auto_close_brackets`         |not set if `false`|
 Whether brackets and quotes should be auto-closed during typing.
+|`show_base`                   |not set if `false`|
+Whether to show the inline edit base version or not.
 |===========================================
 
 [[email-info]]
@@ -2452,14 +2599,15 @@
 Whether the site header should be shown.
 |`use_flash_clipboard`          |not set if `false`|
 Whether to use the flash clipboard widget.
+|`expand_inline_diffs`          |not set if `false`|
+Whether to expand diffs inline instead of opening as separate page
+(PolyGerrit only).
 |`download_scheme`              |optional|
 The type of download URL the user prefers to use. May be any key from
 the `schemes` map in
 link:rest-api-config.html#download-info[DownloadInfo].
 |`download_command`             ||
 The type of download command the user prefers to use.
-|`copy_self_on_email`           |not set if `false`|
-Whether to CC me on comments I write.
 |`date_format`                  ||
 The format to display the date in.
 Allowed values are `STD`, `US`, `ISO`, `EURO`, `UK`.
@@ -2468,24 +2616,27 @@
 Allowed values are `HHMM_12`, `HHMM_24`.
 |`relative_date_in_change_table`|not set if `false`|
 Whether to show relative dates in the changes table.
+|`diff_view`                    ||
+The type of diff view to show.
+Allowed values are `SIDE_BY_SIDE`, `UNIFIED_DIFF`.
 |`size_bar_in_change_table`     |not set if `false`|
 Whether to show the change sizes as colored bars in the change table.
 |`legacycid_in_change_table`    |not set if `false`|
 Whether to show change number in the change table.
+|`review_category_strategy`     ||
+The strategy used to displayed info in the review category column.
+Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
 |`mute_common_path_prefixes`    |not set if `false`|
 Whether to mute common path prefixes in file names in the file table.
 |`signed_off_by`                |not set if `false`|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
-|`review_category_strategy`     ||
-The strategy used to displayed info in the review category column.
-Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
-|`diff_view`                    ||
-The type of diff view to show.
-Allowed values are `SIDE_BY_SIDE`, `UNIFIED_DIFF`.
 |`my`                           ||
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
+|`change_table`                           ||
+The columns to display in the change table (PolyGerrit only). The default is
+empty, which will default columns as determined by the frontend.
 |`url_aliases`                  |optional|
 A map of URL path pairs, where the first URL path is an alias for the
 second URL path.
@@ -2495,6 +2646,10 @@
 their own comments. On `DISABLED` the user will not receive any email
 notifications from Gerrit.
 Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+|`default_base_for_merges`      ||
+The base which should be pre-selected in the 'Diff Against' drop-down
+list when the change screen is opened for a merge commit.
+Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
 |============================================
 
 [[preferences-input]]
@@ -2512,12 +2667,13 @@
 Whether the site header should be shown.
 |`use_flash_clipboard`          |optional|
 Whether to use the flash clipboard widget.
+|`expand_inline_diffs`          |not set if `false`|
+Whether to expand diffs inline instead of opening as separate page
+(PolyGerrit only).
 |`download_scheme`              |optional|
 The type of download URL the user prefers to use.
 |`download_command`             |optional|
 The type of download command the user prefers to use.
-|`copy_self_on_email`           |optional|
-Whether to CC me on comments I write.
 |`date_format`                  |optional|
 The format to display the date in.
 Allowed values are `STD`, `US`, `ISO`, `EURO`, `UK`.
@@ -2526,24 +2682,27 @@
 Allowed values are `HHMM_12`, `HHMM_24`.
 |`relative_date_in_change_table`|optional|
 Whether to show relative dates in the changes table.
+|`diff_view`                    |optional|
+The type of diff view to show.
+Allowed values are `SIDE_BY_SIDE`, `UNIFIED_DIFF`.
 |`size_bar_in_change_table`     |optional|
 Whether to show the change sizes as colored bars in the change table.
 |`legacycid_in_change_table`    |optional|
 Whether to show change number in the change table.
+|`review_category_strategy`     |optional|
+The strategy used to displayed info in the review category column.
+Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
 |`mute_common_path_prefixes`    |optional|
 Whether to mute common path prefixes in file names in the file table.
 |`signed_off_by`                |optional|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
-|`review_category_strategy`     |optional|
-The strategy used to displayed info in the review category column.
-Allowed values are `NONE`, `NAME`, `EMAIL`, `USERNAME`, `ABBREV`.
-|`diff_view`                    |optional|
-The type of diff view to show.
-Allowed values are `SIDE_BY_SIDE`, `UNIFIED_DIFF`.
 |`my`                           |optional|
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
+|`change_table`                           ||
+The columns to display in the change table (PolyGerrit only). The default is
+empty, which will default columns as determined by the frontend.
 |`url_aliases`                  |optional|
 A map of URL path pairs, where the first URL path is an alias for the
 second URL path.
@@ -2553,6 +2712,10 @@
 their own comments. On `DISABLED` the user will not receive any email
 notifications from Gerrit.
 Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+|`default_base_for_merges`      |optional|
+The base which should be pre-selected in the 'Diff Against' drop-down
+list when the change screen is opened for a merge commit.
+Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
 |============================================
 
 [[query-limit-info]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 77feb18..9880d54 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -10,7 +10,7 @@
 [[create-change]]
 === Create Change
 --
-'POST /changes'
+'POST /changes/'
 --
 
 The change input link:#change-input[ChangeInput] entity must be provided in the
@@ -18,7 +18,7 @@
 
 .Request
 ----
-  POST /changes HTTP/1.0
+  POST /changes/ HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
   {
@@ -35,7 +35,7 @@
 
 .Response
 ----
-  HTTP/1.1 200 OK
+  HTTP/1.1 201 OK
   Content-Disposition: attachment
   Content-Type: application/json; charset=UTF-8
 
@@ -246,17 +246,17 @@
 
 [[current-files]]
 --
-* `CURRENT_FILES`: list files modified by the commit, including
-  basic line counts inserted/deleted per file. Only valid when
-  the `CURRENT_REVISION` or `ALL_REVISIONS` option is selected.
+* `CURRENT_FILES`: list files modified by the commit and magic files,
+  including basic line counts inserted/deleted per file. Only valid
+  when the `CURRENT_REVISION` or `ALL_REVISIONS` option is selected.
 --
 
 [[all-files]]
 --
-* `ALL_FILES`: list files modified by the commit, including
-  basic line counts inserted/deleted per file. If only the
-  `CURRENT_REVISION` was requested then only that commit's
-  modified files will be output.
+* `ALL_FILES`: list files modified by the commit and magic files,
+  including basic line counts inserted/deleted per file. If only the
+  `CURRENT_REVISION` was requested then only that commit's modified
+  files will be output.
 --
 
 [[detailed-accounts]]
@@ -294,13 +294,19 @@
 --
 * `REVIEWED`: include the `reviewed` field if all of the following are
   true:
-  * the change is open
-  * the caller is authenticated
-  * the caller has commented on the change more recently than the last update
+  - the change is open
+  - the caller is authenticated
+  - the caller has commented on the change more recently than the last update
     from the change owner, i.e. this change would show up in the results of
     link:user-search.html#reviewedby[reviewedby:self].
 --
 
+[[submittable]]
+--
+* `SUBMITTABLE`: include the `submittable` field in link:#change-info[ChangeInfo],
+  which can be used to tell if the change is reviewed and ready for submit.
+--
+
 [[web-links]]
 --
 * `WEB_LINKS`: include the `web_links` field in link:#commit-info[CommitInfo],
@@ -511,6 +517,64 @@
   }
 ----
 
+[[create-merge-patch-set-for-change]]
+=== Create Merge Patch Set For Change
+--
+'POST /changes/link:#change-id[\{change-id\}]/merge'
+--
+
+Update an existing change by using a
+link:#merge-patch-set-input[MergePatchSetInput] entity.
+
+Gerrit will create a merge commit based on the information of
+MergePatchSetInput and add a new patch set to the change corresponding
+to the new merge commit.
+
+.Request
+----
+  POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "subject": "Merge dev_branch into master",
+    "merge": {
+      "source": "refs/changes/34/1234/1"
+    }
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity with current revision is
+returned that describes the resulting change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc",
+    "project": "test",
+    "branch": "master",
+    "hashtags": [],
+    "change_id": "Ic5466d107c5294414710935a8ef3b0180fb848dc",
+    "subject": "Merge dev_branch into master",
+    "status": "NEW",
+    "created": "2016-09-23 18:08:53.238000000",
+    "updated": "2016-09-23 18:09:25.934000000",
+    "submit_type": "MERGE_IF_NECESSARY",
+    "mergeable": true,
+    "insertions": 5,
+    "deletions": 0,
+    "_number": 72,
+    "owner": {
+      "_account_id": 1000000
+    },
+    "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
+  }
+----
+
 [[get-change-detail]]
 === Get Change Detail
 --
@@ -824,6 +888,156 @@
   HTTP/1.1 204 No Content
 ----
 
+[[get-assignee]]
+=== Get Assignee
+--
+'GET /changes/link:#change-id[\{change-id\}]/assignee'
+--
+
+Retrieves the account of the user assigned to a change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
+----
+
+As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
+describing the assigned account is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000096,
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "username": "jdoe"
+  }
+----
+
+If the change has no assignee the response is "`204 No Content`".
+
+[[get-past-assignees]]
+=== Get Past Assignees
+--
+'GET /changes/link:#change-id[\{change-id\}]/past_assignees'
+--
+
+Returns a list of every user ever assigned to a change, in the order in which
+they were first assigned.
+
+[NOTE] Past assignees are only available when NoteDb is enabled.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/past_assignees HTTP/1.0
+----
+
+As a response a list of link:rest-api-accounts.html#account-info[AccountInfo]
+entities is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "_account_id": 1000051,
+      "name": "Jane Doe",
+      "email": "jane.doe@example.com",
+      "username": "janed"
+    },
+    {
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com",
+      "username": "jdoe"
+    }
+  ]
+
+----
+
+
+[[set-assignee]]
+=== Set Assignee
+--
+'PUT /changes/link:#change-id[\{change-id\}]/assignee'
+--
+
+Sets the assignee of a change.
+
+The new assignee must be provided in the request body inside a
+link:#assignee-input[AssigneeInput] entity.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "assignee": "jdoe"
+  }
+----
+
+As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
+describing the assigned account is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000096,
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "username": "jdoe"
+  }
+----
+
+[[delete-assignee]]
+=== Delete Assignee
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/assignee'
+--
+
+Deletes the assignee of a change.
+
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
+----
+
+As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
+describing the account of the deleted assignee is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000096,
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "username": "jdoe"
+  }
+----
+
+If the change had no assignee the response is "`204 No Content`".
+
 [[abandon-change]]
 === Abandon Change
 --
@@ -1272,8 +1486,13 @@
 The listed changes use the same format as in
 link:#list-changes[Query Changes] with the
 link:#labels[`LABELS`], link:#detailed-labels[`DETAILED_LABELS`],
-link:#current-revision[`CURRENT_REVISION`], and
-link:#current-commit[`CURRENT_COMMIT`] options set.
+link:#current-revision[`CURRENT_REVISION`],
+link:#current-commit[`CURRENT_COMMIT`], and
+link:#submittable[`SUBMITTABLE`] options set.
+
+Standard link:#query-options[formatting options] can be specified
+with the `o` parameter, as well as the `submitted_together` specific
+option `NON_VISIBLE_CHANGES`.
 
 .Response
 ----
@@ -1553,13 +1772,21 @@
   HTTP/1.1 204 No Content
 ----
 
-[[delete-draft-change]]
-=== Delete Draft Change
+[[delete-change]]
+=== Delete Change
 --
 'DELETE /changes/link:#change-id[\{change-id\}]'
 --
 
-Deletes a draft change.
+Deletes a change.
+
+New or abandoned changes can be deleted by their owner if the user is granted
+the link:access-control.html#category_delete_own_changes[Delete Own Changes] permission,
+otherwise only by administrators.
+
+Draft changes can only be deleted by their owner or other users who have the
+permissions to view and delete drafts. If the draft workflow is disabled, only
+administrators with those permissions may delete draft changes.
 
 .Request
 ----
@@ -1675,6 +1902,62 @@
   }
 ----
 
+[[list-change-robot-comments]]
+=== List Change Robot Comments
+--
+'GET /changes/link:#change-id[\{change-id\}]/robotcomments'
+--
+
+Lists the robot comments of all revisions of the change.
+
+Return a map that maps the file path to a list of
+link:#robot-comment-info[RobotCommentInfo] entries. The entries in the
+map are sorted by file path.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/robotcomments/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": [
+      {
+        "id": "TvcXrmjM",
+        "line": 23,
+        "message": "unused import",
+        "updated": "2016-02-26 15:40:43.986000000",
+        "author": {
+          "_account_id": 1000110,
+          "name": "Code Analyzer",
+          "email": "code.analyzer@example.com"
+        },
+        "robotId": "importChecker",
+        "robotRunId": "76b1375aa8626ea7149792831fe2ed85e80d9e04"
+      },
+      {
+        "id": "TveXwFiA",
+        "line": 49,
+        "message": "wrong indention",
+        "updated": "2016-02-26 15:40:45.328000000",
+        "author": {
+          "_account_id": 1000110,
+          "name": "Code Analyzer",
+          "email": "code.analyzer@example.com"
+        },
+        "robotId": "styleChecker",
+        "robotRunId": "5c606c425dd45184484f9d0a2ffd725a7607839b"
+      }
+    ]
+  }
+----
+
 [[list-change-drafts]]
 === List Change Drafts
 --
@@ -1829,6 +2112,79 @@
   }
 ----
 
+[[get-hashtags]]
+=== Get Hashtags
+--
+'GET /changes/link:#change-id[\{change-id\}]/hashtags'
+--
+
+Gets the hashtags associated with a change.
+
+[NOTE] Hashtags are only available when NoteDb is enabled.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/hashtags HTTP/1.0
+----
+
+As response the change's hashtags are returned as a list of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    "hashtag1",
+    "hashtag2"
+  ]
+----
+
+[[set-hashtags]]
+=== Set Hashtags
+--
+'POST /changes/link:#change-id[\{change-id\}]/hashtags'
+--
+
+Adds and/or removes hashtags from a change.
+
+[NOTE] Hashtags are only available when NoteDb is enabled.
+
+The hashtags to add or remove must be provided in the request body inside a
+link:#hashtags-input[HashtagsInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/hashtags HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "add" : [
+      "hashtag3"
+    ],
+    "remove" : [
+      "hashtag2"
+    ]
+  }
+----
+
+As response the change's hashtags are returned as a list of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    "hashtag1",
+    "hashtag3"
+  ]
+----
+
 [[edit-endpoints]]
 == Change Edit Endpoints
 
@@ -2135,9 +2491,17 @@
 
 Promotes change edit to a regular patch set.
 
+Options can be provided in the request body as a
+link:#publish-change-edit-input[PublishChangeEditInput] entity.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:publish HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "notify": "NONE"
+  }
 ----
 
 As response "`204 No Content`" is returned.
@@ -2408,14 +2772,33 @@
 [[delete-reviewer]]
 === Delete Reviewer
 --
-'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]'
+'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]' +
+'POST /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/delete'
 --
 
 Deletes a reviewer from a change.
 
+Options can be provided in the request body as a
+link:#delete-reviewer-input[DeleteReviewerInput] entity.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe HTTP/1.0
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/delete HTTP/1.0
+----
+
+Please note that some proxies prohibit request bodies for DELETE
+requests. In this case, if you want to specify options, use a POST
+request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "notify": "NONE"
+  }
 ----
 
 .Response
@@ -2456,7 +2839,7 @@
 [[delete-vote]]
 === Delete Vote
 --
-'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]'
+'DELETE /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]' +
 'POST /changes/link:#change-id[\{change-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]/delete'
 --
 
@@ -2545,6 +2928,118 @@
 Adding query parameter `links` (for example `/changes/.../commit?links`)
 returns a link:#commit-info[CommitInfo] with the additional field `web_links`.
 
+[[get-description]]
+=== Get Description
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/description'
+--
+
+Retrieves the description of a patch set.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/description HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Added Documentation"
+----
+
+If the patch set does not have a description an empty string is returned.
+
+[[set-description]]
+=== Set Description
+--
+'PUT /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/description'
+--
+
+Sets the description of a patch set.
+
+The new description must be provided in the request body inside a
+link:#description-input[DescriptionInput] entity.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/description HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "description": "Added Documentation"
+  }
+----
+
+As response the new description is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Added Documentation"
+----
+
+[[get-merge-list]]
+=== Get Merge List
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/mergelist'
+--
+
+Returns the list of commits that are being integrated into a target
+branch by a merge commit. By default the first parent is assumed to be
+uninteresting. By using the `parent` option another parent can be set
+as uninteresting (parents are 1-based).
+
+The list of commits is returned as a list of
+link:#commit-info[CommitInfo] entities. Web links are only included if
+the `links` option was set.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/7e30d802b890ec8d0be45b1cc2a8ef092bcfc858/mergelist HTTP/1.0
+----
+
+.Response
+----
+HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "commit": "674ac754f91e64a0efb8087e59a176484bd534d1",
+      "parents": [
+        {
+          "commit": "1eee2c9d8f352483781e772f35dc586a69ff5646",
+          "subject": "Migrate contributor agreements to All-Projects."
+        }
+      ],
+      "author": {
+        "name": "Shawn O. Pearce",
+        "email": "sop@google.com",
+        "date": "2012-04-24 18:08:08.000000000",
+        "tz": -420
+      },
+      "committer": {
+        "name": "Shawn O. Pearce",
+        "email": "sop@google.com",
+        "date": "2012-04-24 18:08:08.000000000",
+        "tz": -420
+      },
+      "subject": "Use an EventBus to manage star icons",
+      "message": "Use an EventBus to manage star icons\n\nImage widgets that need to ..."
+    }
+  ]
+----
+
 [[get-revision-actions]]
 === Get Revision Actions
 --
@@ -3176,6 +3671,63 @@
 will suggest the browser save the patch as `commitsha1.diff.base64`,
 for later processing by command line tools.
 
+If the `path` parameter is set, the returned content is a diff of the single
+file that the path refers to.
+
+[[submit-preview]]
+=== Submit Preview
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/preview_submit'
+--
+Gets a file containing thin bundles of all modified projects if this
+change was submitted. The bundles are named `${ProjectName}.git`.
+Each thin bundle contains enough to construct the state in which a project would
+be in if this change were submitted. The base of the thin bundles are the
+current target branches, so to make use of this call in a non-racy way, first
+get the bundles and then fetch all projects contained in the bundle.
+(This assumes no non-fastforward pushes).
+
+You need to give a parameter '?format=zip' or '?format=tar' to specify the
+format for the outer container. It is always possible to use tgz, even if
+tgz is not in the list of allowed archive formats.
+
+To make good use of this call, you would roughly need code as found at:
+----
+ $ curl -Lo preview_submit_test.sh http://review.example.com:8080/tools/scripts/preview_submit_test.sh
+----
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/preview_submit?zip HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Date: Tue, 13 Sep 2016 19:13:46 GMT
+  Content-Disposition: attachment; filename="submit-preview-147.zip"
+  X-Content-Type-Options: nosniff
+  Cache-Control: no-cache, no-store, max-age=0, must-revalidate
+  Pragma: no-cache
+  Expires: Mon, 01 Jan 1990 00:00:00 GMT
+  Content-Type: application/x-zip
+  Transfer-Encoding: chunked
+
+  [binary stuff]
+----
+
+In case of an error, the response is not a zip file but a regular json response,
+containing only the error message:
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "Anonymous users cannot submit"
+----
+
 [[get-mergeable]]
 === Get Mergeable
 --
@@ -3207,7 +3759,9 @@
 ----
 
 If the `other-branches` parameter is specified, the mergeability will also be
-checked for all other branches.
+checked for all other branches which are listed in the
+link:config-project-config.html#branchOrder-section[branchOrder] section in the
+project.config file.
 
 .Request
 ----
@@ -3611,6 +4165,102 @@
   }
 ----
 
+[[list-robot-comments]]
+=== List Robot Comments
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/'
+--
+
+Lists the link:config-robot-comments.html[robot comments] of a
+revision.
+
+As result a map is returned that maps the file path to a list of
+link:#robot-comment-info[RobotCommentInfo] entries. The entries in the
+map are sorted by file path.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/robotcomments/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": [
+      {
+        "id": "TvcXrmjM",
+        "line": 23,
+        "message": "unused import",
+        "updated": "2016-02-26 15:40:43.986000000",
+        "author": {
+          "_account_id": 1000110,
+          "name": "Code Analyzer",
+          "email": "code.analyzer@example.com"
+        },
+        "robotId": "importChecker",
+        "robotRunId": "76b1375aa8626ea7149792831fe2ed85e80d9e04"
+      },
+      {
+        "id": "TveXwFiA",
+        "line": 49,
+        "message": "wrong indention",
+        "updated": "2016-02-26 15:40:45.328000000",
+        "author": {
+          "_account_id": 1000110,
+          "name": "Code Analyzer",
+          "email": "code.analyzer@example.com"
+        },
+        "robotId": "styleChecker",
+        "robotRunId": "5c606c425dd45184484f9d0a2ffd725a7607839b"
+      }
+    ]
+  }
+----
+
+[[get-robot-comment]]
+=== Get Robot Comment
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/link:#comment-id[\{comment-id\}]'
+--
+
+Retrieves a link:config-robot-comments.html[robot comment] of a
+revision.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/robotcomments/TvcXrmjM HTTP/1.0
+----
+
+As response a link:#robot-comment-info[RobotCommentInfo] entity is
+returned that describes the robot comment.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "TvcXrmjM",
+    "line": 23,
+    "message": "unused import",
+    "updated": "2016-02-26 15:40:43.986000000",
+    "author": {
+      "_account_id": 1000110,
+      "name": "Code Analyzer",
+      "email": "code.analyzer@example.com"
+    },
+    "robotId": "importChecker",
+    "robotRunId": "76b1375aa8626ea7149792831fe2ed85e80d9e04"
+  }
+----
+
 [[list-files]]
 === List Files
 --
@@ -3624,8 +4274,8 @@
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/ HTTP/1.0
 ----
 
-As result a map is returned that maps the file path to a list of
-link:#file-info[FileInfo] entries. The entries in the map are
+As result a map is returned that maps the link:#file-id[file path] to a
+link:#file-info[FileInfo] entry. The entries in the map are
 sorted by file path.
 
 .Response
@@ -4107,6 +4757,131 @@
   }
 ----
 
+[[revision-reviewer-endpoints]]
+== Revision Reviewer Endpoints
+
+[[list-revision-reviewers]]
+=== List Revision Reviewers
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/reviewers/'
+--
+
+Lists the reviewers of a revision.
+
+Please note that only the current revision is supported.
+
+As result a list of link:#reviewer-info[ReviewerInfo] entries is returned.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "approvals": {
+        "Verified": "+1",
+        "Code-Review": "+2"
+      },
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com"
+    },
+    {
+      "approvals": {
+        "Verified": " 0",
+        "Code-Review": "-1"
+      },
+      "_account_id": 1000097,
+      "name": "Jane Roe",
+      "email": "jane.roe@example.com"
+    }
+  ]
+----
+
+[[list-revision-votes]]
+=== List Revision Votes
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/'
+--
+
+Lists the votes for a specific reviewer of the revision.
+
+Please note that only the current revision is supported.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/ HTTP/1.0
+----
+
+As result a map is returned that maps the label name to the label value.
+The entries in the map are sorted by label name.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "Code-Review": -1,
+    "Verified": 1,
+    "Work-In-Progress": 1
+  }
+----
+
+[[delete-revision-vote]]
+=== Delete Revision Vote
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]
+/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]' +
+'POST /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]
+/reviewers/link:rest-api-accounts.html#account-id[\{account-id\}]/votes/link:#label-id[\{label-id\}]/delete'
+--
+
+Deletes a single vote from a revision. The deletion will be possible only
+if the revision is the current revision. By using this endpoint you can prevent
+deleting the vote (with same label) from a newer patch set by mistake.
+
+Note, that even when the last vote of a reviewer is removed the reviewer itself
+is still listed on the change.
+
+Options can be provided in the request body as a
+link:#delete-vote-input[DeleteVoteInput] entity.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
+----
+
+Please note that some proxies prohibit request bodies for DELETE
+requests. In this case, if you want to specify options, use a POST
+request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "notify": "NONE"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[ids]]
 == IDs
 
@@ -4141,10 +4916,25 @@
 The name of the label.
 
 [[file-id]]
-\{file-id\}
-~~~~~~~~~~~~
+=== \{file-id\}
 The path of the file.
 
+The following magic paths are supported:
+
+* `/COMMIT_MSG`:
++
+The commit message and headers with the parent commit(s), the author
+information and the committer information.
+
+* `/MERGE_LIST` (for merge commits only):
++
+The list of commits that are being integrated into the destination
+branch by submitting the merge commit.
+
+[[fix-id]]
+=== \{fix-id\}
+UUID of a suggested fix.
+
 [[revision-id]]
 === \{revision-id\}
 Identifier that uniquely identifies one revision of a change.
@@ -4166,17 +4956,20 @@
 The `AbandonInput` entity contains information for abandoning a change.
 
 [options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
-|`message`     |optional|
+|=============================
+|Field Name      ||Description
+|`message`       |optional|
 Message to be added as review comment to the change when abandoning the
 change.
-|`notify`      |optional|
+|`notify`        |optional|
 Notify handling that defines to whom email notifications should be sent after
 the change is abandoned. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|===========================
+|`notify_details`|optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|=============================
 
 [[action-info]]
 === ActionInfo
@@ -4242,18 +5035,37 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`value`       |optional|
+|Field Name               ||Description
+|`value`                  |optional|
 The vote that the user has given for the label. If present and zero, the
 user is permitted to vote on the label. If absent, the user is not
 permitted to vote on that label.
-|`date`        |optional|
+|`permitted_voting_range` |optional|
+The link:#voting-range-info[VotingRangeInfo] the user is authorized to vote
+on that label. If present, the user is permitted to vote on the label
+regarding the range values. If absent, the user is not permitted to vote
+on that label.
+|`date`                   |optional|
 The time and date describing when the approval was made.
-|`tag`                 |optional|
+|`tag`                    |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review.
 NOTE: To apply different tags on on different votes/comments multiple
 invocations of the REST call are required.
+|`post_submit` |not set if `false`|
+If true, this vote was made after the change was submitted.
+|===========================
+
+[[assignee-input]]
+=== AssigneeInput
+The `AssigneeInput` entity contains the identity of the user to be set as assignee.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`assignee`     ||
+The link:rest-api-accounts.html#account-id[ID] of one account that
+should be added as assignee.
 |===========================
 
 [[blame-info]]
@@ -4340,10 +5152,15 @@
 |`mergeable`          |optional|
 Whether the change is mergeable. +
 Not set for merged changes, or if the change has not yet been tested.
+|`submittable`        |optional|
+Whether the change has been approved by the project submit rules. +
+Only set if link:#submittable[requested].
 |`insertions`         ||
 Number of inserted lines.
 |`deletions`          ||
 Number of deleted lines.
+|`unresolved_comment_count`  |optional|
+Number of unresolved comments. Not set if the current change index doesn't have the data.
 |`_number`            ||The legacy numeric ID of the change.
 |`owner`              ||
 The owner of the change as an link:rest-api-accounts.html#account-info[
@@ -4413,7 +5230,8 @@
 The name of the target branch. +
 The `refs/heads/` prefix is omitted.
 |`subject`            ||
-The subject of the change (header line of the commit message).
+The commit message of the change. Comment lines (beginning with `#`) will
+be removed.
 |`topic`              |optional|The topic to which this change belongs.
 |`status`             |optional, default to `NEW`|
 The status of the change (only `NEW` and `DRAFT` accepted here).
@@ -4424,6 +5242,14 @@
 Allow creating a new branch when set to `true`.
 |`merge`              |optional|
 The detail of a merge commit as a link:#merge-input[MergeInput] entity.
+|`notify`             |optional|
+Notify handling that defines to whom email notifications should be sent
+after the change is created. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
+|`notify_details`     |optional|
+Additional information about whom to notify about the change creation
+as a map of recipient type to link:#notify-info[NotifyInfo] entity.
 |==================================
 
 [[change-message-info]]
@@ -4455,11 +5281,13 @@
 === CherryPickInput
 The `CherryPickInput` entity contains information for cherry-picking a change to a new branch.
 
-[options="header",cols="1,6"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name    |Description
-|`message`     |Commit message for the cherry-picked change
-|`destination` |Destination branch
+|Field Name         ||Description
+|`message`          ||Commit message for the cherry-picked change
+|`destination`      ||Destination branch
+|`parent`           |optional, defaults to 1|
+Number of the parent relative to which the cherry-pick should be considered.
 |===========================
 
 [[comment-info]]
@@ -4505,6 +5333,10 @@
 while posting the review.
 NOTE: To apply different tags on on different votes/comments multiple
 invocations of the REST call are required.
+|`unresolved`        |optional|
+Whether or not the comment must be addressed by the user. The state of
+resolution of a comment thread is stored in the last comment in that thread
+chronologically.
 |===========================
 
 [[comment-input]]
@@ -4547,6 +5379,10 @@
 Value of the `tag` field. Only allowed on link:#create-draft[draft comment] +
 inputs; for published comments, use the `tag` field in +
 link#review-input[ReviewInput]
+|`unresolved`        |optional|
+Whether or not the comment must be addressed by the user. This value will
+default to false if the comment is an orphan, or the value of the `in_reply_to`
+comment if it is supplied.
 |===========================
 
 [[comment-range]]
@@ -4556,10 +5392,10 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name    ||Description
-|`start_line`        ||The start line number of the range.
-|`start_character`   ||The character position in the start line.
-|`end_line`          ||The end line number of the range.
-|`end_character`     ||The character position in the end line.
+|`start_line`        ||The start line number of the range. (1-based, inclusive)
+|`start_character`   ||The character position in the start line. (0-based, inclusive)
+|`end_line`          ||The end line number of the range. (1-based, exclusive)
+|`end_character`     ||The character position in the end line. (0-based, exclusive)
 |===========================
 
 [[commit-info]]
@@ -4589,23 +5425,54 @@
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
+[[delete-reviewer-input]]
+=== DeleteReviewerInput
+The `DeleteReviewerInput` entity contains options for the deletion of a
+reviewer.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name      ||Description
+|`notify`        |optional|
+Notify handling that defines to whom email notifications should be sent
+after the reviewer is deleted. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `ALL`.
+|`notify_details`|optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|=============================
+
 [[delete-vote-input]]
 === DeleteVoteInput
 The `DeleteVoteInput` entity contains options for the deletion of a
 vote.
 
 [options="header",cols="1,^1,5"]
-|=======================
-|Field Name||Description
-|`label`   |optional|
+|=============================
+|Field Name      ||Description
+|`label`         |optional|
 The label for which the vote should be deleted. +
 If set, must match the label in the URL.
-|`notify`  |optional|
+|`notify`        |optional|
 Notify handling that defines to whom email notifications should be sent
 after the vote is deleted. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|=======================
+|`notify_details`|optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|=============================
+
+[[description-input]]
+=== DescriptionInput
+The `DescriptionInput` entity contains information for setting a description.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name     |Description
+|`description`  |The description text.
+|===========================
 
 [[diff-content]]
 === DiffContent
@@ -4798,6 +5665,37 @@
 a new patch set referring to this commit.
 |==========================
 
+[[fix-suggestion-info]]
+=== FixSuggestionInfo
+The `FixSuggestionInfo` entity represents a suggested fix.
+
+[options="header",cols="1,^1,5"]
+|==========================
+|Field Name         ||Description
+|`fix_id`           |generated, don't set|The <<fix-id,UUID>> of the suggested
+fix. It will be generated automatically and hence will be ignored if it's set
+for input objects.
+|`description`      ||A description of the suggested fix.
+|`replacements`     ||A list of <<fix-replacement-info,FixReplacementInfo>>
+entities indicating how the content of the file on which the comment was placed
+should be modified. They should refer to non-overlapping regions.
+|==========================
+
+[[fix-replacement-info]]
+=== FixReplacementInfo
+The `FixReplacementInfo` entity describes how the content of a file should be
+replaced by another content.
+
+[options="header",cols="1,6"]
+|==========================
+|Field Name      |Description
+|`path`          |The path of the file which should be modified. Modifications
+are only allowed for the file on which the corresponding comment was placed.
+|`range`         |A <<comment-range,CommentRange>> indicating which content
+of the file should be replaced.
+|`replacement`   |The content which should be used instead of the current one.
+|==========================
+
 [[git-person-info]]
 === GitPersonInfo
 The `GitPersonInfo` entity contains information about the
@@ -4821,10 +5719,23 @@
 [options="header",cols="1,6"]
 |==========================
 |Field Name    |Description
-|`id`          |The id of the group.
+|`id`          |The UUID of the group.
 |`name`        |The name of the group.
 |==========================
 
+[[hashtags-input]]
+=== HashtagsInput
+
+The `HashtagsInput` entity contains information about hashtags to add to,
+and/or remove from, a change.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`add`     |optional|The list of hashtags to be added to the change.
+|`remove   |optional|The list of hashtags to be removed from the change.
+|=======================
+
 [[included-in-info]]
 === IncludedInInfo
 The `IncludedInInfo` entity contains information about the branches a
@@ -4910,7 +5821,7 @@
 |Field Name      ||Description
 |`submit_type`   ||
 Submit type used for this change, can be `MERGE_IF_NECESSARY`,
-`FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `MERGE_ALWAYS` or
+`FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `REBASE_ALWAYS`, `MERGE_ALWAYS` or
 `CHERRY_PICK`.
 |`strategy`     |optional|
 The strategy of the merge, can be `recursive`, `resolve`,
@@ -4936,13 +5847,32 @@
 |Field Name      ||Description
 |`source`   ||
 The source to merge from, e.g. a complete or abbreviated commit SHA-1,
-a complete reference name, a short reference name under refs/heads, refs/tags,
-or refs/remotes namespace, etc.
+a complete reference name, a short reference name under `refs/heads`, `refs/tags`,
+or `refs/remotes` namespace, etc.
 |`strategy`     |optional|
 The strategy of the merge, can be `recursive`, `resolve`,
 `simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
 |============================
 
+[[merge-patch-set-input]]
+=== MergePatchSetInput
+The `MergePatchSetInput` entity contains information about updating a new
+change by creating a new merge commit.
+
+[options="header",cols="1,^1,5"]
+|==================================
+|Field Name           ||Description
+|`subject`            |optional|
+The new subject for the change, if not specified, will reuse the current patch
+set's subject
+|`inheritParent`      |optional, default to `false`|
+Use the current patch set's first parent as the merge tip when set to `true`.
+Otherwise, use the current branch tip of the destination branch.
+|`merge`              ||
+The detail of the source commit for merge as a link:#merge-input[MergeInput]
+entity.
+|==================================
+
 [[move-input]]
 === MoveInput
 The `MoveInput` entity contains information for moving a change to a new branch.
@@ -4955,6 +5885,23 @@
 A message to be posted in this change's comments
 |===========================
 
+[[notify-info]]
+=== NotifyInfo
+The `NotifyInfo` entity contains detailed information about who should
+be notified about an update. These notifications are sent out even if a
+`notify` option in the request input disables normal notifications.
+`NotifyInfo` entities are normally contained in a `notify_details` map
+in the request input where the key is the recipient type. The recipient
+type can be `TO`, `CC` and `BCC`.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`accounts`|optional|
+A list of link:rest-api-accounts.html#account-id[account IDs] that
+identify the accounts that should be should be notified.
+|=======================
+
 [[problem-info]]
 === ProblemInfo
 The `ProblemInfo` entity contains a description of a potential consistency problem
@@ -4974,6 +5921,24 @@
 outcome of the fix.
 |===========================
 
+[[publish-change-edit-input]]
+=== PublishChangeEditInput
+The `PublishChangeEditInput` entity contains options for the publishing of
+change edit.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name      ||Description
+|`notify`        |optional|
+Notify handling that defines to whom email notifications should be sent
+after the change edit is published. +
+Allowed values are `NONE` and `ALL`. +
+If not set, the default is `ALL`.
+|`notify_details`|optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|=============================
+
 [[push-certificate-info]]
 === PushCertificateInfo
 The `PushCertificateInfo` entity contains information about a push
@@ -5127,6 +6092,9 @@
 |`comments`               |optional|
 The comments that should be added as a map that maps a file path to a
 list of link:#comment-input[CommentInput] entities.
+|`robot_comments`         |optional|
+The robot comments that should be added as a map that maps a file path
+to a list of link:#robot-comment-input[RobotCommentInput] entities.
 |`strict_labels`          |`true` if not set|
 Whether all labels are required to be within the user's permitted ranges
 based on access controls. +
@@ -5141,12 +6109,17 @@
 Allowed values are `DELETE`, `PUBLISH`, `PUBLISH_ALL_REVISIONS` and
 `KEEP`. All values except `PUBLISH_ALL_REVISIONS` operate only on drafts
 for a single revision. +
-If not set, the default is `DELETE`.
+Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
+If not set, the default is `DELETE`, unless `on_behalf_of` is set, in
+which case the default is `KEEP` and any other value is disallowed.
 |`notify`                 |optional|
 Notify handling that defines to whom email notifications should be sent
 after the review is stored. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
+|`notify_details`         |optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
 |`omit_duplicate_comments`|optional|
 If `true`, comments with the same content at the same place will be omitted.
 |`on_behalf_of`           |optional|
@@ -5179,28 +6152,31 @@
 to a change.
 
 [options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
-|`reviewer`    ||
+|=============================
+|Field Name      ||Description
+|`reviewer`      ||
 The link:rest-api-accounts.html#account-id[ID] of one account that
 should be added as reviewer or the link:rest-api-groups.html#group-id[
 ID] of one group for which all members should be added as reviewers. +
 If an ID identifies both an account and a group, only the account is
 added as reviewer to the change.
-|`state`       |optional|
+|`state`         |optional|
 Add reviewer in this state. Possible reviewer states are `REVIEWER`
 and `CC`. If not given, defaults to `REVIEWER`.
-|`confirmed`   |optional|
+|`confirmed`     |optional|
 Whether adding the reviewer is confirmed. +
 The Gerrit server may be configured to
 link:config-gerrit.html#addreviewer.maxWithoutConfirmation[require a
 confirmation] when adding a group as reviewer that has many members.
-|`notify`  |optional|
+|`notify`        |optional|
 Notify handling that defines to whom email notifications should be sent
 after the reviewer is added. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|===========================
+|`notify_details`|optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|=============================
 
 [[revision-info]]
 === RevisionInfo
@@ -5258,6 +6234,34 @@
 certificate was provided, it is set to an empty object.
 |===========================
 
+[[robot-comment-info]]
+=== RobotCommentInfo
+The `RobotCommentInfo` entity contains information about a robot inline
+comment.
+
+`RobotCommentInfo` has the same fields as <<comment-info,CommentInfo>>.
+In addition `RobotCommentInfo` has the following fields:
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name       ||Description
+|`robot_id`       ||The ID of the robot that generated this comment.
+|`robot_run_id`   ||An ID of the run of the robot.
+|`url`            |optional|URL to more information.
+|`properties`     |optional|Robot specific properties as map that maps arbitrary
+keys to values.
+|`fix_suggestions`|optional|Suggested fixes for this robot comment as a list of
+<<fix-suggestion-info,FixSuggestionInfo>> entities.
+|===========================
+
+[[robot-comment-input]]
+=== RobotCommentInput
+The `RobotCommentInput` entity contains information for creating an inline
+robot comment.
+
+`RobotCommentInput` has the same fields as
+<<robot-comment-info,RobotCommentInfo>>.
+
 [[rule-input]]
 === RuleInput
 The `RuleInput` entity contains information to test a Prolog rule.
@@ -5301,24 +6305,29 @@
 The `SubmitInput` entity contains information for submitting a change.
 
 [options="header",cols="1,^1,5"]
-|===========================
+|=============================
 |Field Name      ||Description
-|`on_behalf_of`|optional|
+|`on_behalf_of`  |optional|
 If set, submit the change on behalf of the given user. The value may take any
 format link:rest-api-accounts.html#account-id[accepted by the accounts REST
 API]. Using this option requires
 link:access-control.html#category_submit_on_behalf_of[Submit (On Behalf Of)]
 permission on the branch.
-|`notify`|optional|
+|`notify`        |optional|
 Notify handling that defines to whom email notifications should be sent after
 the change is submitted. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|===========================
+|`notify_details`|optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|=============================
 
 [[submit-record]]
 === SubmitRecord
 The `SubmitRecord` entity describes results from a submit_rule.
+Fields in this entity roughly correspond to the fields set by `LABELS`
+in link:#label-info[LabelInfo].
 
 [options="header",cols="1,^1,5"]
 |===========================
@@ -5408,6 +6417,18 @@
 The topic will be deleted if not set.
 |===========================
 
+[[voting-range-info]]
+=== VotingRangeInfo
+The `VotingRangeInfo` entity describes the continuous voting range from min
+to max values.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`min`     |The minimum voting value.
+|`max`     |The maximum voting value.
+|======================
+
 [[web-link-info]]
 === WebLinkInfo
 The `WebLinkInfo` entity describes a link to an external site.
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 7b96a1c..a311f0b9 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -54,6 +54,14 @@
   {
     "auth": {
       "auth_type": "LDAP",
+      "use_contributor_agreements": true,
+      "contributor_agreements": [
+        {
+          "name": "Individual",
+          "description": "If you are going to be contributing code on your own, this is the one you want. You can sign this one online.",
+          "url": "static/cla_individual.html"
+        }
+      ],
       "editable_account_fields": [
         "FULL_NAME",
         "REGISTER_NEW_EMAIL"
@@ -115,7 +123,10 @@
     "gerrit": {
       "all_projects": "All-Projects",
       "all_users": "All-Users"
-      "doc_search": true
+      "doc_search": true,
+      "web_uis": [
+        "gwt"
+      ]
     },
     "sshd": {},
     "suggest": {
@@ -396,7 +407,7 @@
 +
 Returns the cache names as JSON list.
 +
-The cache names are alphabetically sorted.
+The cache names are lexicographically sorted.
 +
 .Request
 ----
@@ -459,9 +470,9 @@
 E.g. this could be used to flush all caches:
 +
 ----
-  for c in $(curl --digest --user jdoe:TNAuLkXsIV7w http://gerrit/a/config/server/caches/?format=TEXT_LIST | base64 -D)
+  for c in $(curl --user jdoe:TNAuLkXsIV7w http://gerrit/a/config/server/caches/?format=TEXT_LIST | base64 -D)
   do
-    curl --digest --user jdoe:TNAuLkXsIV7w -X POST http://gerrit/a/config/server/caches/$c/flush
+    curl --user jdoe:TNAuLkXsIV7w -X POST http://gerrit/a/config/server/caches/$c/flush
   done
 ----
 
@@ -1226,6 +1237,9 @@
 |`use_contributor_agreements` |not set if `false`|
 Whether link:config-gerrit.html#auth.contributorAgreements[contributor
 agreements] are required.
+|`contributor_agreements`     |not set if `use_contributor_agreements` is `false`|
+List of contributor agreements as link:rest-api-accounts.html#contributor-agreement-info[
+ContributorAgreementInfo] entities.
 |`editable_account_fields`    ||
 List of account fields that are editable. Possible values are
 `FULL_NAME`, `USER_NAME` and `REGISTER_NEW_EMAIL`.
@@ -1256,16 +1270,10 @@
 The link:config-gerrit.html#auth.httpPasswordUrl[URL to obtain an HTTP
 password]. Only set if link:config-gerrit.html#auth.type[authentication
 type] is `CUSTOM_EXTENSION`.
-|`is_git_basic_auth`          |optional, not set if `false`|
-Whether link:config-gerrit.html#auth.gitBasicAuth[basic authentication
-is used for Git over HTTP/HTTPS]. Only set if
-link:config-gerrit.html#auth.type[authentication type] is is `LDAP` or
-`LDAP_BIND`.
 |`git_basic_auth_policy`      |optional|
 The link:config-gerrit.html#auth.gitBasicAuthPolicy[policy] to authenticate
 Git over HTTP and REST API requests when
-link:config-gerrit.html#auth.type[authentication type] is `LDAP` and
-link:config-gerrit.html#auth.gitBasicAuth[basic authentication] is set to true.
+link:config-gerrit.html#auth.type[authentication type] is `LDAP`.
 Can be `HTTP`, `LDAP` or `HTTP_LDAP`.
 |==========================================
 
@@ -1464,6 +1472,9 @@
 |`report_bug_text`   |optional, not set if default|
 link:config-gerrit.html#gerrit.reportBugText[Display text for report
 bugs link].
+|`web_uis`           ||
+List of web UIs supported by the HTTP server. Possible values are `GWT`
+and `POLYGERRIT`.
 |=================================
 
 [[hit-ration-info]]
@@ -1579,7 +1590,7 @@
 GerritInfo] entity.
 |`note_db_enabled`         |not set if `false`|
 Whether the NoteDb storage backend is fully enabled.
-|`plugin `                 ||
+|`plugin`                  ||
 Information about Gerrit extensions by plugins as
 link:#plugin-config-info[PluginConfigInfo] entity.
 |`receive`                 |optional|
@@ -1704,13 +1715,13 @@
 |`counts`       |
 Detailed thread counts as a map that maps a thread kind to a map that
 maps a thread state to the thread count. The thread kinds group the
-counts by threads that have the same name prefix (`HTTP`,
+counts by threads that have the same name prefix (`H2`, `HTTP`,
 `IntraLineDiff`, `ReceiveCommits`, `SSH git-receive-pack`,
 `SSH git-upload-pack`, `SSH-Interactive-Worker`, `SSH-Stream-Worker`,
-`SshCommandStart`). The counts for other threads are available under
-the thread kind `Other`. Counts for the following thread states can be
-included: `NEW`, `RUNNABLE`, `BLOCKED`, `WAITING`, `TIMED_WAITING` and
-`TERMINATED`.
+`SshCommandStart`, `sshd-SshServer`). The counts for other threads are
+available under the thread kind `Other`. Counts for the following thread
+states can be included: `NEW`, `RUNNABLE`, `BLOCKED`, `WAITING`,
+`TIMED_WAITING` and `TERMINATED`.
 |===========================
 
 [[top-menu-entry-info]]
diff --git a/Documentation/rest-api-documentation.txt b/Documentation/rest-api-documentation.txt
index 4c9db2b..0a7ff16 100644
--- a/Documentation/rest-api-documentation.txt
+++ b/Documentation/rest-api-documentation.txt
@@ -6,9 +6,9 @@
 
 Please note that this feature is only usable with documentation built-in.
 You'll need to
-`buck build withdocs`
+`bazel build withdocs`
 or
-`buck build release`
+`bazel build release`
 to test this feature.
 
 [[documentation-endpoints]]
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index 23d4c5b..61b746d 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -120,8 +120,13 @@
 
 ==== Check if a group is owned by the calling user
 By setting the option `owned` and specifying a group to inspect with
-the option `q`, it is possible to find out, if this group is owned by
-the calling user.
+the option `group`/`g`, it is possible to find out if this group is
+owned by the calling user.
+
+[NOTE] Earlier the `group`/`g` option was named `query`/`q`. Using
+`query`/`q` still works, but this option is deprecated and may be
+removed in future. Hence all users should be adapted to use
+`group`/`g` instead.
 
 .Request
 ----
@@ -181,8 +186,8 @@
 When using this option, the `project` or `p` option can be used to
 name the current project, to allow context-dependent suggestions.
 
-Not compatible with `visible-to-all`, `owned`, `user`, `match`, `q`,
-or `S`.
+Not compatible with `visible-to-all`, `owned`, `user`, `match`,
+`group`, or `S`.
 (Attempts to use one of those options combined with `suggest` will
 error out.)
 
@@ -211,6 +216,120 @@
   }
 ----
 
+Substring(m)::
+Limit the results to those groups that match the specified substring.
++
+The match is case insensitive.
++
+List all groups that match substring `test/`:
++
+.Request
+----
+  GET /groups/?m=test%2F HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "test/test": {
+      "url": "#/admin/groups/uuid-786a95e85f9a2223a96545f10003f396aba871f2",
+      "options": {},
+      "group_id": 15,
+      "owner": "test/test",
+      "owner_id": "786a95e85f9a2223a96545f10003f396aba871f2",
+      "created_on": "2017-07-11 13:56:24.000000000",
+      "id": "786a95e85f9a2223a96545f10003f396aba871f2"
+    }
+  }
+----
+
+[[query-groups]]
+=== Query Groups
+--
+'GET /groups/?query2=<query>'
+--
+
+Queries internal groups visible to the caller. The
+link:user-search-groups.html#_search_operators[query string] must be
+provided by the `query2` parameter. The `start` and `limit` parameters
+can be used to skip/limit results.
+
+As result a list of link:#group-info[GroupInfo] entities is returned.
+
+[NOTE] `query2` is a temporary name and in future this option may be
+renamed to `query`. `query2` was chosen to maintain backwards
+compatibility with the deprecated `query` parameter on the
+link:#list-groups[List Groups] endpoint.
+
+.Request
+----
+  GET /groups/?query2=inname:test HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "url": "#/admin/groups/uuid-68236a40ca78de8be630312d8ba50250bc5638ae",
+      "options": {},
+      "description": "Group for running tests on MyProject",
+      "group_id": 20,
+      "owner": "MyProject-Test-Group",
+      "owner_id": "59b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "id": "68236a40ca78de8be630312d8ba50250bc5638ae"
+    },
+    {
+      "url": "#/admin/groups/uuid-99a534526313324a2667025c3f4e089199b736aa",
+      "options": {},
+      "description": "Testers for ProjectX",
+      "group_id": 17,
+      "owner": "ProjectX-Testers",
+      "owner_id": "59b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "id": "99a534526313324a2667025c3f4e089199b736aa"
+    }
+  ]
+----
+
+If the number of groups matching the query exceeds either the internal
+limit or a supplied `limit` query parameter, the last group object has
+a `_more_groups: true` JSON field set.
+
+[[group-query-limit]]
+==== Group Limit
+The `/groups/?query2=<query>` URL also accepts a limit integer in the
+`limit` parameter. This limits the results to `limit` groups.
+
+Query the first 25 groups in group list.
+----
+  GET /groups/?query2=<query>&limit=25 HTTP/1.0
+----
+
+The `/groups/` URL also accepts a start integer in the `start`
+parameter. The results will skip `start` groups from group list.
+
+Query 25 groups starting from index 50.
+----
+  GET /groups/?query2=<query>&limit=25&start=50 HTTP/1.0
+----
+
+[[group-query-options]]
+==== Group Options
+Additional fields can be obtained by adding `o` parameters. Each option
+requires more lookups and slows down the query response time to the
+client so they are generally disabled by default. The supported fields
+are described in the context of the link:#group-options[List Groups]
+REST endpoint.
+
 [[get-group]]
 === Get Group
 --
@@ -600,7 +719,7 @@
 
 .Request
 ----
-  PUT /groups/9999c971bb4ab872aab759d8c49833ee6b9ff320/description HTTP/1.0
+  PUT /groups/9999c971bb4ab872aab759d8c49833ee6b9ff320/owner HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
   {
@@ -714,6 +833,24 @@
   ]
 ----
 
+[[index-group]]
+=== Index Group
+--
+'POST /groups/link:#group-id[\{group-id\}]/index'
+--
+
+Adds or updates the internal group in the secondary index.
+
+.Request
+----
+  POST /groups/fdda826a0815859ab48d22a05a43472f0f55f89a/index HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[group-member-endpoints]]
 == Group Member Endpoints
 
@@ -1207,11 +1344,6 @@
 [[ids]]
 == IDs
 
-[[account-id]]
-=== link:rest-api-accounts.html#account-id[\{account-id\}]
---
---
-
 [[group-id]]
 === \{group-id\}
 Identifier for a group.
@@ -1272,8 +1404,11 @@
 |Field Name    ||Description
 |`id`          ||The URL encoded UUID of the group.
 |`name`        |
-not set if returned in a map where the group name is used as map key|
-The name of the group.
+optional, not set if returned in a map where the group name is used as map key|
+The name of the group. +
+For external groups the group name is missing if there is no group
+backend that can resolve the group UUID. E.g. this can happen when a
+plugin that provided a group backend was uninstalled.
 |`url`         |optional|
 URL to information about the group. Typically a URL to a web page that
 permits users to apply to join the group, or manage their membership.
@@ -1282,6 +1417,10 @@
 |`group_id`    |only for internal groups|The numeric ID of the group.
 |`owner`       |only for internal groups|The name of the owner group.
 |`owner_id`    |only for internal groups|The URL encoded UUID of the owner group.
+|`_more_groups`|optional, only for internal groups, not set if `false`|
+Whether the query would deliver more results if not limited. +
+Only set on the last group that is returned by a
+link:#query-groups[group query].
 |`members`     |optional, only for internal groups|
 A list of link:rest-api-accounts.html#account-info[AccountInfo]
 entities describing the direct members. +
@@ -1319,7 +1458,7 @@
 name. +
 If not set, the new group will be self-owned.
 |`members`       |optional|The initial members in a list of +
-link:#account-id[account ids].
+link:rest-api-accounts.html#account-id[account ids].
 |===========================
 
 [[group-options-info]]
@@ -1360,8 +1499,7 @@
 |==========================
 
 [[members-input]]
-MembersInput
-~~~~~~~~~~~
+=== MembersInput
 The `MembersInput` entity contains information about accounts that should
 be added as members to a group or that should be deleted from the group.
 
@@ -1369,11 +1507,11 @@
 |==========================
 |Field Name   ||Description
 |`_one_member`|optional|
-The link:#account-id[id] of one account that should be added or
-deleted.
-|`members`    |optional|
-A list of link:#account-id[account ids] that identify the accounts that
+The link:rest-api-accounts.html#account-id[id] of one account that
 should be added or deleted.
+|`members`    |optional|
+A list of link:rest-api-accounts.html#account-id[account ids] that
+identify the accounts that should be added or deleted.
 |==========================
 
 
diff --git a/Documentation/rest-api-plugins.txt b/Documentation/rest-api-plugins.txt
index dfe9f0e..53f4bb5 100644
--- a/Documentation/rest-api-plugins.txt
+++ b/Documentation/rest-api-plugins.txt
@@ -33,6 +33,32 @@
 
 .Request
 ----
+  GET /plugins/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "delete-project": {
+      "id": "delete-project",
+      "index_url": "plugins/delete-project/",
+      "version": "2.9-SNAPSHOT"
+    }
+  }
+----
+
+[[plugin-options]]
+==== Plugin Options
+All(a)::
+List all plugins including those that are disabled.
+
+.Request
+----
   GET /plugins/?all HTTP/1.0
 ----
 
@@ -87,7 +113,7 @@
 following curl command can be used:
 
 ----
-  curl --digest --user admin:TNNuLkWsIV8w -X PUT --data-binary @delete-project-2.8.jar 'http://gerrit:8080/a/plugins/delete-project'
+  curl --user admin:TNNuLkWsIV8w -X PUT --data-binary @delete-project-2.8.jar 'http://gerrit:8080/a/plugins/delete-project'
 ----
 
 As response a link:#plugin-info[PluginInfo] entity is returned that
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 457a287..3d53130 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -149,6 +149,8 @@
 Limit the results to those projects that start with the specified
 prefix.
 +
+The match is case sensitive. May not be used together with `m` or `r`.
++
 List all projects that start with `platform/`:
 +
 .Request
@@ -182,6 +184,8 @@
 match any projects that start with 'test' and regex '.*test' will match any
 project that end with 'test'.
 +
+The match is case sensitive. May not be used together with `m` or `p`.
++
 List all projects that match regex `test.*project`:
 +
 .Request
@@ -234,6 +238,8 @@
 Substring(m)::
 Limit the results to those projects that match the specified substring.
 +
+The match is case insensitive. May not be used together with `r` or `p`.
++
 List all projects that match substring `test/`:
 +
 .Request
@@ -318,6 +324,33 @@
   }
 ----
 
+All::
+Get all projects, including those whose state is "HIDDEN".
++
+.Request
+----
+GET /projects/?all HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "All-Projects" {
+      "id": "All-Projects",
+      "state": "ACTIVE"
+    },
+    "some-other-project": {
+      "id": "some-other-project",
+      "state": "HIDDEN"
+    }
+  }
+----
+
 [[get-project]]
 === Get Project
 --
@@ -981,6 +1014,7 @@
     ],
     "can_upload": true,
     "can_add": true,
+    "can_add_tags": true,
     "config_visible": true
   }
 ----
@@ -1014,12 +1048,14 @@
 
   {
     "remove": [
-      "refs/*": {
-        "permissions": {
-          "read": {
-            "rules": {
-              "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
-                "action": "ALLOW"
+      {
+        "refs/*": {
+          "permissions": {
+            "read": {
+              "rules": {
+                "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+                  "action": "ALLOW"
+                }
               }
             }
           }
@@ -1062,10 +1098,29 @@
     ],
     "can_upload": true,
     "can_add": true,
+    "can_add_tags": true,
     "config_visible": true
   }
 ----
 
+[[index]]
+=== Index all changes in a project
+
+Adds or updates all the changes belonging to a project in the secondary index.
+The indexing task is executed asynchronously in background, so this command
+returns immediately.
+
+.Request
+----
+  POST /projects/MyProject/index HTTP/1.0
+----
+
+.Response
+----
+HTTP/1.1 202 Accepted
+Content-Disposition: attachment
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -1140,7 +1195,7 @@
   ]
 ----
 
-Skip(s)::
+Skip(S)::
 Skip the given number of branches from the beginning of the list.
 +
 .Request
@@ -1165,9 +1220,11 @@
 ----
 
 Substring(m)::
-Limit the results to those projects that match the specified substring.
+Limit the results to those branches that match the specified substring.
 +
-List all projects that match substring `test`:
+The match is case insensitive. May not be used together with `r`.
++
+List all branches that match substring `test`:
 +
 .Request
 ----
@@ -1193,8 +1250,10 @@
 Regex(r)::
 Limit the results to those branches that match the specified regex.
 Boundary matchers '^' and '$' are implicit. For example: the regex 't*' will
-match any branches that start with 'test' and regex '*t' will match any
-branches that end with 'test'.
+match any branches that start with 't' and regex '*t' will match any
+branches that end with 't'.
++
+The match is case sensitive. May not be used together with `m`.
 +
 List all branches that match regex `t.*1`:
 +
@@ -1814,7 +1873,7 @@
   ]
 ----
 
-Skip(s)::
+Skip(S)::
 Skip the given number of tags from the beginning of the list.
 +
 .Request
@@ -1849,6 +1908,87 @@
   ]
 ----
 
+Substring(m)::
+Limit the results to those tags that match the specified substring.
++
+The match is case insensitive.  May not be used together with `r`.
++
+List all tags that match substring `v2`:
+
++
+.Request
+----
+  GET /projects/testproject/tags?m=v2 HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "ref": "refs/tags/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    },
+  ]
+----
+
+Regex(r)::
+Limit the results to those tags that match the specified regex.
+Boundary matchers '^' and '$' are implicit. For example: the regex 't*' will
+match any tags that start with 't' and regex '*t' will match any
+tags that end with 't'.
++
+The match is case sensitive.  May not be used together with `m`.
++
+List all tags that match regex `v.*0`:
++
+.Request
+----
+  GET /projects/testproject/tags?r=v.*0 HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "ref": "refs/tags/v1.0",
+      "revision": "49ce77fdcfd3398dc0dedbe016d1a425fd52d666",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Annotated tag",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 07:35:03.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    },
+    {
+      "ref": "refs/tags/v3.0",
+      "revision": "c628685b3c5a3614571ecb5c8fceb85db9112313",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Signed tag\n-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v1.4.11 (GNU/Linux)\n\niQEcBAABAgAGBQJUMlqYAAoJEPI2qVPgglptp7MH/j+KFcittFbxfSnZjUl8n5IZ\nveZo7wE+syjD9sUbMH4EGv0WYeVjphNTyViBof+stGTNkB0VQzLWI8+uWmOeiJ4a\nzj0LsbDOxodOEMI5tifo02L7r4Lzj++EbqtKv8IUq2kzYoQ2xjhKfFiGjeYOn008\n9PGnhNblEHZgCHguGR6GsfN8bfA2XNl9B5Ysl5ybX1kAVs/TuLZR4oDMZ/pW2S75\nhuyNnSgcgq7vl2gLGefuPs9lxkg5Fj3GZr7XPZk4pt/x1oiH7yXxV4UzrUwg2CC1\nfHrBlNbQ4uJNY8TFcwof52Z0cagM5Qb/ZSLglHbqEDGA68HPqbwf5z2hQyU2/U4\u003d\n\u003dZtUX\n-----END PGP SIGNATURE-----",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 09:02:16.000000000",
+        "tz": 540
+      }
+    }
+  ]
+----
 
 [[get-tag]]
 === Get Tag
@@ -1886,6 +2026,55 @@
   }
 ----
 
+[[delete-tag]]
+=== Delete Tag
+--
+'DELETE /projects/link:#project-name[\{project-name\}]/tags/link:#tag-id[\{tag-id\}]'
+--
+
+Deletes a tag.
+
+.Request
+----
+  DELETE /projects/MyProject/tags/v1.0 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+[[delete-tags]]
+=== Delete Tags
+--
+'POST /projects/link:#project-name[\{project-name\}]/tags:delete'
+--
+
+Delete one or more tags.
+
+The tags to be deleted must be provided in the request body as a
+link:#delete-tags-input[DeleteTagsInput] entity.
+
+.Request
+----
+  POST /projects/MyProject/tags:delete HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "tags": [
+      "v1.0",
+      "v2.0"
+    ]
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+If some tags could not be deleted, the response is "`409 Conflict`" and the
+error message is contained in the response body.
 
 [[commit-endpoints]]
 == Commit Endpoints
@@ -1940,6 +2129,35 @@
   }
 ----
 
+[[get-included-in]]
+=== Get Included In
+--
+'GET /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/in'
+--
+
+Retrieves the branches and tags in which a change is included. As result
+an link:rest-api-changes.html#included-in-info[IncludedInInfo] entity is returned.
+
+.Request
+----
+  GET /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/in HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "branches": [
+      "master"
+    ],
+    "tags": []
+  }
+----
+
 [[get-content-from-commit]]
 === Get Content
 --
@@ -2320,7 +2538,7 @@
 MaxObjectSizeLimitInfo] entity.
 |`submit_type`               ||
 The default submit type of the project, can be `MERGE_IF_NECESSARY`,
-`FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `MERGE_ALWAYS` or
+`FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `REBASE_ALWAYS`, `MERGE_ALWAYS` or
 `CHERRY_PICK`.
 |`state`                     |optional|
 The state of the project, can be `ACTIVE`, `READ_ONLY` or `HIDDEN`. +
@@ -2395,7 +2613,7 @@
 If not set, this setting is not updated.
 |`submit_type`                             |optional|
 The default submit type of the project, can be `MERGE_IF_NECESSARY`,
-`FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `MERGE_ALWAYS` or
+`FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `REBASE_ALWAYS`, `MERGE_ALWAYS` or
 `CHERRY_PICK`. +
 If not set, the submit type is not updated.
 |`state`                                   |optional|
@@ -2408,8 +2626,7 @@
 |======================================================
 
 [[config-parameter-info]]
-ConfigParameterInfo
-~~~~~~~~~~~~~~~~~~~
+=== ConfigParameterInfo
 The `ConfigParameterInfo` entity describes a project configuration
 parameter.
 
@@ -2523,6 +2740,18 @@
 deleted.
 |==========================
 
+[[delete-tags-input]]
+=== DeleteTagsInput
+The `DeleteTagsInput` entity contains information about tags that should
+be deleted.
+
+[options="header",width="50%",cols="1,6"]
+|==========================
+|Field Name   |Description
+|`tags`       |A list of tag names that identify the tags that should be
+deleted.
+|==========================
+
 [[gc-input]]
 === GCInput
 The `GCInput` entity contains information to run the Git garbage
@@ -2578,16 +2807,17 @@
 |===============================
 |Field Name        ||Description
 |`value`           |optional|
-The effective value of the max object size limit as a formatted string. +
+The effective value in bytes of the max object size limit. +
 Not set if there is no limit for the object size.
 |`configured_value`|optional|
 The max object size limit that is configured on the project as a
 formatted string. +
 Not set if there is no limit for the object size configured on project
 level.
-|`inherited_value` |optional|
-The max object size limit that is inherited as a formatted string. +
-Not set if there is no global limit for the object size.
+|`summary`         |optional|
+A string describing whether the value was inherited or overridden from
+the parent project or global config. +
+Not set if not inherited or overridden.
 |===============================
 
 [[project-access-input]]
@@ -2672,8 +2902,8 @@
 Whether an empty initial commit should be created.
 |`submit_type`               |optional|
 The submit type that should be set for the project
-(`MERGE_IF_NECESSARY`, `REBASE_IF_NECESSARY`, `FAST_FORWARD_ONLY`,
-`MERGE_ALWAYS`, `CHERRY_PICK`). +
+(`MERGE_IF_NECESSARY`, `REBASE_IF_NECESSARY`, `REBASE_ALWAYS`,
+`FAST_FORWARD_ONLY`, `MERGE_ALWAYS`, `CHERRY_PICK`). +
 If not set, `MERGE_IF_NECESSARY` is set as submit type unless
 link:config-gerrit.html#repository.name.defaultSubmitType[
 repository.<name>.defaultSubmitType] is set to a different value.
@@ -2773,6 +3003,11 @@
 the signature.
 |`tagger`|Only set for annotated tags, if present in the tag.|The tagger as a
 link:rest-api-changes.html#git-person-info[GitPersonInfo] entity.
+|`can_delete`|`false` if not set|
+Whether the calling user can delete this tag.
+|`web_links` |optional|
+Links to the tag in external sites as a list of
+link:rest-api-changes.html#web-link-info[WebLinkInfo] entries.
 |=========================
 
 [[tag-input]]
@@ -2806,8 +3041,6 @@
 The path to the `GerritSiteFooter.html` file.
 |=============================
 
-----
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 7f7e62e..7928512 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -36,10 +36,8 @@
 `/a/`. For example to authenticate to `/projects/`, request the URL
 `/a/projects/`.
 
-By default Gerrit uses HTTP digest authentication with the HTTP password
-from the user's account settings page. HTTP basic authentication is used
-if link:config-gerrit.html#auth.gitBasicAuth[`auth.gitBasicAuth`] is set
-to true in the Gerrit configuration.
+Gerrit uses HTTP basic authentication with the HTTP password from the
+user's account settings page.
 
 [[preconditions]]
 === Preconditions
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 838a433..1ddaed0 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -271,12 +271,17 @@
 
 ** [[delete]]`Delete Change` / `Delete Revision`:
 +
-Deletes the draft change / the currently viewed draft patch set.
+Deletes the change / the currently viewed draft patch set.
 +
-The `Delete Change` / `Delete Revision` buttons are only available if a
-draft patch set is viewed and the user is the change owner or has the
-link:access-control.html#category_delete_drafts[Delete Drafts] access
-right assigned.
+For open or abandoned changes, the `Delete Change` button will be available
+and if the user is the change owner and is granted the
+link:access-control.html#category_delete_own_changes[Delete Own Changes]
+permission, if they are granted the
+link:access-control.html#category_delete_changes[Delete Changes] permission,
+or if they are an administrator. For draft changes,
+the `Delete Change` / `Delete Revision` buttons will be available if the user is
+the change owner or has the
+link:access-control.html#category_delete_drafts[Delete Drafts] access right assigned.
 
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
@@ -297,6 +302,23 @@
 
 image::images/user-review-ui-change-screen-file-list.png[width=800, link="images/user-review-ui-change-screen-file-list.png"]
 
+[[magic-files]]
+In addition to the modified files the file list contains magic files
+that are generated by Gerrit and which don't exist in the repository.
+The magic files contain additional commit data that should be
+reviewable and allow users to comment on this data. The magic files are
+always listed first. The following magic files exist:
+
+* `Commit Message`:
++
+The commit message and headers with the parent commit(s), the author
+information and the committer information.
+
+* `Merge List` (for merge commits only):
++
+The list of commits that are being integrated into the destination
+branch by submitting the merge commit.
+
 [[change-screen-mark-reviewed]]
 The checkboxes in front of the file names allow files to be marked as reviewed.
 
@@ -952,7 +974,7 @@
 - `h` / `j` / `k` / `l` moves the cursor left / down / up / right
 - `0` / `$` moves the cursor to the start / end of the line
 - `gg` / `G` moves to cursor to the start / end of the file
-- `Ctrl-D` / `Ctrl-U` scolls downwards / upwards
+- `Ctrl-D` / `Ctrl-U` scrolls downwards / upwards
 
 Please check the link:http://www.vim.org/docs.php[Vim documentation]
 for further information.
@@ -1110,7 +1132,7 @@
 
 - [[line-wrapping]]`Line Wrapping`:
 +
-Controls weather to enable line wrapping or not.
+Controls whether to enable line wrapping or not.
 +
 If `false` is selected then line wrapping is disabled.
 This is the default option.
diff --git a/Documentation/user-search-accounts.txt b/Documentation/user-search-accounts.txt
index 15d87b0..6bcd18e 100644
--- a/Documentation/user-search-accounts.txt
+++ b/Documentation/user-search-accounts.txt
@@ -1,6 +1,6 @@
 = Gerrit Code Review - Searching Accounts
 
-== Basic Change Search
+== Basic Account Search
 
 Similar to many popular search engines on the web, just enter some
 text and let Gerrit figure out the meaning:
@@ -23,6 +23,11 @@
 returned results. Search can also be performed by typing only a
 text with no operator, which will match against a variety of fields.
 
+[[cansee]]
+cansee:'CHANGE'::
++
+Matches accounts that can see the change 'CHANGE'.
+
 [[email]]
 email:'EMAIL'::
 +
@@ -57,7 +62,7 @@
 is:visible::
 +
 Magical internal flag to prove the current user has access to read
-the change. This flag is always added to any query.
+the account. This flag is always added to any query.
 
 [[is-active-magic]]
 is:active::
diff --git a/Documentation/user-search-groups.txt b/Documentation/user-search-groups.txt
new file mode 100644
index 0000000..fccad65
--- /dev/null
+++ b/Documentation/user-search-groups.txt
@@ -0,0 +1,83 @@
+= Gerrit Code Review - Searching Groups
+
+Group queries only match internal groups. External groups and system
+groups are not included in the query result.
+
+== Basic Group Search
+
+Similar to many popular search engines on the web, just enter some
+text and let Gerrit figure out the meaning:
+
+[options="header"]
+|======================================================
+|Description | Examples
+|Name        | Foo-Verifiers
+|UUID        | 6a1e70e1a88782771a91808c8af9bbb7a9871389
+|Description | deprecated
+|======================================================
+
+[[search-operators]]
+== Search Operators
+
+Operators act as restrictions on the search. As more operators
+are added to the same query string, they further restrict the
+returned results. Search can also be performed by typing only a text
+with no operator, which will match against a variety of fields.
+
+[[description]]
+description:'DESCRIPTION'::
++
+Matches groups that have a description that contains 'DESCRIPTION'
+(case-insensitive).
+
+[[inname]]
+inname:'NAMEPART'::
++
+Matches groups that have a name part that starts with 'NAMEPART'
+(case-insensitive).
+
+[[is]]
+[[is-visibletoall]]
+is:visibletoall::
++
+Matches groups that are in the groups options marked as visible to all
+registered users.
+
+[[name]]
+name:'NAME'::
++
+Matches groups that have the name 'NAME' (case-insensitive).
+
+[[owner]]
+owner:'OWNER'::
++
+Matches groups that are owned by the group whose name best matches
+'OWNER' or that has the UUID 'OWNER'.
+
+[[uuid]]
+uuid:'UUID'::
++
+Matches groups that have the UUID 'UUID'.
+
+== Magical Operators
+
+[[is-visible]]
+is:visible::
++
+Magical internal flag to prove the current user has access to read
+the group. This flag is always added to any query.
+
+[[limit]]
+limit:'CNT'::
++
+Limit the returned results to no more than 'CNT' records. This is
+automatically set to the page size configured in the current user's
+preferences. Including it in a web query may lead to unpredictable
+results with regards to pagination.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index b04898e..4207e3f 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -4,9 +4,7 @@
 
 Most basic searches can be viewed by clicking on a link along the top
 menu bar.  The link will prefill the search box with a common search
-query, execute it, and present the results.  If exactly one change
-matches the search, the change will be presented instead of a list.
-
+query, execute it, and present the results.
 
 [options="header"]
 |=================================================
@@ -15,7 +13,7 @@
 |All > Merged         | status:merged
 |All > Abandoned      | status:abandoned
 |My > Drafts          | owner:self is:draft
-|My > Watched Changes | status:open is:watched
+|My > Watched Changes | is:watched is:open
 |My > Starred Changes | is:starred
 |My > Draft Comments  | has:draft
 |Open changes in Foo  | status:open project:Foo
@@ -34,9 +32,14 @@
 |Full or abbreviated Change-Id    | Ic0ff33
 |Full or abbreviated commit SHA-1 | d81b32ef
 |Email address                    | user@example.com
-|Approval requirement             | Code-Review>=+2, Verified=1
 |=============================================================
 
+For change searches (i.e. those using a numerical id, Change-Id, or commit
+SHA1), if the search results in a single change that change will be
+presented instead of a list.
+
+For more predictable results, use explicit search operators as described
+in the following section.
 
 [[search-operators]]
 == Search Operators
@@ -61,6 +64,11 @@
 * mon, month, months (`1 month` is treated as `30 days`)
 * y, year, years (`1 year` is treated as `365 days`)
 
+[[assignee]]
+assignee:'USER'::
++
+Changes assigned to the given user.
+
 [[before_until]]
 before:'TIME'/until:'TIME'::
 +
@@ -103,7 +111,9 @@
 [[ownerin]]
 ownerin:'GROUP'::
 +
-Changes originally submitted by a user in 'GROUP'.
+Changes originally submitted by a user in 'GROUP'. When no other index
+predicate is explicitly added in the query, defaults to only include
+changes in status 'OPEN'.
 
 [[query]]
 query:'NAME'::
@@ -118,10 +128,18 @@
 special case of `reviewer:self` will find changes where the caller
 has been added as a reviewer.
 
+[[cc]]
+cc:'USER'::
++
+Changes that have the given user CC'ed on them. The special case of `cc:self`
+will find changes where the caller has been CC'ed.
+
 [[reviewerin]]
 reviewerin:'GROUP'::
 +
-Changes that have been, or need to be, reviewed by a user in 'GROUP'.
+Changes that have been, or need to be, reviewed by a user in 'GROUP'. When
+no other index predicate is explicitly added in the query, defaults to only
+include changes in status 'OPEN'.
 
 [[commit]]
 commit:'SHA1'::
@@ -278,13 +296,25 @@
 +
 True if the change has inline edit created by the current user.
 
+has:unresolved::
++
+True if the change has unresolved comments.
+
 [[is]]
+is:assigned::
++
+True if the change has an assignee.
+
 [[is-starred]]
 is:starred::
 +
 Same as 'has:star', true if the change has been starred by the
 current user with the default label.
 
+is:unassigned::
++
+True if the change does not have an assignee.
+
 is:watched::
 +
 True if this change matches one of the current user's watch filters,
@@ -305,6 +335,11 @@
 True on any change where the current user is a reviewer.
 Same as `reviewer:self`.
 
+is:cc::
++
+True on any change where the current user is in CC.
+Same as `cc:self`.
+
 is:open, is:pending::
 +
 True if the change is open.
@@ -321,11 +356,28 @@
 +
 Same as <<status,status:'STATE'>>.
 
+is:submittable::
++
+True if the change is submittable according to the submit rules for
+the project, for example if all necessary labels have been voted on.
++
+This operator only takes into account one change at a time, not any
+related changes, and does not guarantee that the submit button will
+appear for matching changes. To check whether a submit button appears,
+use the
+link:rest-api-changes.html#get-revision-actions[Get Revision Actions]
+API.
++
+Equivalent to <<submittable,submittable:ok>>.
+
 [[mergeable]]
 is:mergeable::
 +
 True if the change has no merge conflicts and could be merged into its
 destination branch.
++
+Mergeability of abandoned changes is not computed. This operator will
+not find any abandoned but mergeable changes.
 
 [[status]]
 status:open, status:pending::
@@ -394,6 +446,25 @@
 'COMMITTER' may be the committer's exact email address, or part of the name or
 email address.
 
+[[submittable]]
+submittable:'SUBMIT_STATUS'::
++
+Changes having the given submit record status after applying submit
+rules. Valid statuses are in the `status` field of
+link:rest-api-changes.html#submit-record[SubmitRecord]. This operator
+only applies to the top-level status; individual label statuses can be
+searched link:#labels[by label].
+
+[[unresolved]]
+unresolved:'RELATION''NUMBER'::
++
+True if the number of unresolved comments satisfies the given relation for the given number.
++
+For example, unresolved:>0 will be true for any change which has at least one unresolved
+comment while unresolved:0 will be true for any change which has all comments resolved.
++
+Valid relations are >=, >, <=, <, or no relation, which will match if the number of unresolved
+comments is exactly equal.
 
 == Argument Quoting
 
@@ -448,8 +519,10 @@
   ('user=' or 'group=').  If an LDAP group is being referenced make
   sure to use 'ldap/<groupname>'.
 
-A label name must be followed by a score, or an operator and a score.
-The easiest way to explain this is by example.
+A label name must be followed by either a score with optional operator,
+or a label status. The easiest way to explain this is by example.
++
+First, some examples of scores with operators:
 
 `label:Code-Review=2`::
 `label:Code-Review=+2`::
@@ -473,8 +546,20 @@
 `label:Code-Review>=1`::
 +
 Matches changes with either a +1, +2, or any higher score.
++
+Instead of a numeric vote, you can provide a label status corresponding
+to one of the fields in the
+link:rest-api-changes.html#submit-record[SubmitRecord] REST API entity.
+
+`label:Non-Author-Code-Review=need`::
++
+Matches changes where the submit rules indicate that a label named
+`Non-Author-Code-Review` is needed. (See the
+link:prolog-cookbook.html#NonAuthorCodeReview[Prolog Cookbook] for how
+this label can be configured.)
 
 `label:Code-Review=+2,aname`::
+`label:Code-Review=ok,aname`::
 +
 Matches changes with a +2 code review where the reviewer or group is aname.
 
@@ -482,6 +567,14 @@
 +
 Matches changes with a +2 code review where the reviewer is jsmith.
 
+`label:Code-Review=+2,user=owner`::
+`label:Code-Review=ok,user=owner`::
+`label:Code-Review=+2,owner`::
+`label:Code-Review=ok,owner`::
++
+The special "owner" parameter corresponds to the change owner.  Matches
+all changes that have a +2 vote from the change owner.
+
 `label:Code-Review=+1,group=ldap/linux.workflow`::
 +
 Matches changes with a +1 code review where the reviewer is in the
@@ -492,14 +585,17 @@
 Matches changes with either a -1, -2, or any lower score.
 
 `is:open label:Code-Review+2 label:Verified+1 NOT label:Verified-1 NOT label:Code-Review-2`::
+`is:open label:Code-Review=ok label:Verified=ok`::
 +
-Matches changes that are ready to be submitted.
+Matches changes that are ready to be submitted according to one common
+label configuration. (For a more general check, use
+link:#submittable[submittable:ok].)
 
 `is:open (label:Verified-1 OR label:Code-Review-2)`::
+`is:open (label:Verified=reject OR label:Code-Review=reject)`::
 +
 Changes that are blocked from submission due to a blocking score.
 
-
 == Magical Operators
 
 Most of these operators exist to support features of Gerrit Code
diff --git a/Documentation/user-submodules.txt b/Documentation/user-submodules.txt
index 2754b45..a2a080b 100644
--- a/Documentation/user-submodules.txt
+++ b/Documentation/user-submodules.txt
@@ -27,8 +27,8 @@
 .. a url that starts with the link:config-gerrit.html#gerrit.canonicalWebUrl[`gerrit.canonicalWebUrl`]
 
 When a commit in a project is merged, Gerrit checks for superprojects
-that are subscribed to the the project and automatically updates those
-superprojects with a commit that updates the gilink for the project.
+that are subscribed to the project and automatically updates those
+superprojects with a commit that updates the gitlink for the project.
 
 This feature is enabled by default and can be disabled
 via link:config-gerrit.html#submodule.enableSuperProjectSubscriptions[submodule.enableSuperProjectSubscriptions]
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index ba3445a..25ab3ca 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -9,8 +9,8 @@
 All three methods rely on authentication, which must first be configured
 by the uploading user.
 
-Gerrit supports two methods of authenticating the uploading user.  SSH
-public key, and HTTP/HTTPS.
+Gerrit supports two protocols for uploading changes; SSH and HTTP/HTTPS. These
+may not all be available for you, depending on the server configuration.
 
 [[http]]
 == HTTP/HTTPS
@@ -18,10 +18,9 @@
 On Gerrit installations that do not support SSH authentication, the
 user must authenticate via HTTP/HTTPS.
 
-When link:config-gerrit.html#auth.gitBasicAuth[gitBasicAuth] is enabled,
-the user is authenticated using standard BasicAuth. Depending on the value of
-link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy], credentials are
-validated using:
+The user is authenticated using standard BasicAuth. Depending on the
+value of link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy],
+credentials are validated using:
 
 * The randomly generated HTTP password on the `HTTP Password` tab
   in the user settings page if `gitBasicAuthPolicy` is `HTTP`.
@@ -29,9 +28,10 @@
 * Both, the HTTP and the LDAP passwords (in this order) if `gitBasicAuthPolicy`
   is `HTTP_LDAP`.
 
-When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can be
-accessed within Gerrit by going to `Settings`, and then accessing the `HTTP
-Password` tab.
+When gitBasicAuthPolicy is not `LDAP`, the user's HTTP credentials can
+be regenerated by going to `Settings`, and then accessing the `HTTP
+Password` tab. Revocation can effectively be done by regenerating the
+password and then forgetting it.
 
 For Gerrit installations where an link:config-gerrit.html#auth.httpPasswordUrl[HTTP password URL]
 is configured, the password can be obtained by clicking on `Obtain Password`
@@ -41,13 +41,15 @@
 [[ssh]]
 == SSH
 
-Each user uploading changes to Gerrit must configure one or more SSH
-public keys.  The per-user SSH key list can be accessed over the web
-within Gerrit by `Settings`, and then accessing the `SSH Public Keys`
-tab.
+To upload changes over SSH, Gerrit supports two forms of authentication: a
+user's public key or kerberos.
 
-[[configure_ssh]]
-=== Configuration
+Unless your Gerrit instance is configured to support
+link:config-gerrit.html#sshd.kerberosKeytab[kerberos] in your domain, only
+public key authentication can be used.
+
+[[configure_ssh_public_keys]]
+=== Public keys
 
 To register a new SSH key for use with Gerrit, paste the contents of
 your `id_rsa.pub` or `id_dsa.pub` file into the text box and click
@@ -79,10 +81,29 @@
 documentation, for more details on configuration of the agent
 process and how to add the private key.
 
+[[configure_ssh_kerberos]]
+=== Kerberos
+
+A kerberos-enabled server configuration allows for zero configuration in an
+existing single-sign-on environment.
+
+Your SSH client should be configured to enable kerberos authentication. For
+OpenSSH clients, this is controlled by the option `GSSAPIAuthentication` which
+should be set to `yes`.
+
+Some Linux distributions have packaged OpenSSH to enable this by default (e.g.
+Debian, Ubuntu). If this is not the case for your distribution, enable it for
+Gerrit with this entry in your local SSH configuration:
+
+----
+  Host gerrit.mydomain.tld
+      GSSAPIAuthentication yes
+----
+
 [[test_ssh]]
 === Testing Connections
 
-To verify your SSH key is working correctly, try using an SSH client
+To verify your SSH authentication is working correctly, try using an SSH client
 to connect to Gerrit's SSHD port.  By default Gerrit runs on
 port 29418, using the same hostname as the web server:
 
@@ -120,6 +141,30 @@
 The returned output from this URL is always `'hostname' SP 'port'`,
 or `NOT_AVAILABLE` if the SSHD server is not currently running.
 
+[[configure_ssh_host_entry]]
+=== OpenSSH Host entry
+
+If you are frequently uploading changes to the same Gerrit server, consider
+adding an SSH `Host` entry in your OpenSSH client configuration
+(`~/.ssh/config`) for that Gerrit server.  It allows you use a single alias
+defining your username, hostname and port number whenever you're accessing
+this Gerrit server in an SSH context (also command line SSH or SCP).  Use this
+for easier to remember, shorter URLs, e.g.:
+
+----
+  $ cat ~/.ssh/config
+  ...
+  Host mygerrit
+      Hostname git.example.com
+      Port 29418
+      User john.doe
+
+  $ git clone mygerrit:myproject
+
+  $ ssh mygerrit gerrit version
+
+  $ scp -p mygerrit:hooks/commit-msg .git/hooks/
+----
 
 == git push
 
@@ -177,17 +222,39 @@
   git push ssh://bot@git.example.com:29418/kernel/common HEAD:refs/for/master%notify=NONE
 ----
 
+In addition uploaders can explicitly specify accounts that should be
+notified, regardless of the value that is given for the `notify`
+option. To notify a specific account specify it by an
+`notify-to='email'`, `notify-cc='email'` or `notify-bcc='email'`
+option. These options can be specified as many times as necessary to
+cover all interested parties. Gerrit will automatically avoid sending
+duplicate email notifications, such as if one of the specified accounts
+had also requested to receive all new change notifications. The
+accounts that are specified by `notify-to='email'`, `notify-cc='email'`
+and `notify-bcc='email'` will only be notified about this one push.
+They are not added as link:#reviewers[reviewers or CCs], hence they are
+not automatically signed up to be notified on further updates of the
+change.
+
+----
+  git push ssh://bot@git.example.com:29418/kernel/common HEAD:refs/for/master%notify=NONE,notify-to=a@a.com
+----
+
 [[topic]]
 ==== Topic
 
 To include a short tag associated with all of the changes in the
 same group, such as the local topic branch name, append it after
-the destination branch name. In this example the short topic tag
-'driver/i42' will be saved on each change this push creates or
+the destination branch name or add it with the command line flag
+`--push-option`, aliased to `-o`. In this example the short topic
+tag 'driver/i42' will be saved on each change this push creates or
 updates:
 
 ----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%topic=driver/i42
+
+  // this is the same as:
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental -o topic=driver/i42
 ----
 
 [[message]]
@@ -243,22 +310,6 @@
 rebase a change edit on the newest patch set when the rebase of the
 change edit in the web UI fails due to conflicts.
 
-If you are frequently uploading changes to the same Gerrit server,
-consider adding an SSH host block in `~/.ssh/config` to remember
-your username, hostname and port number.  This permits the use of
-shorter URLs on the command line, such as:
-
-----
-  $ cat ~/.ssh/config
-  ...
-  Host tr
-    Hostname git.example.com
-    Port 29418
-    User john.doe
-
-  $ git push tr:kernel/common HEAD:refs/for/experimental
-----
-
 [[reviewers]]
 ==== Reviewers
 
@@ -267,7 +318,7 @@
 `reviewer` (or `r`) and `cc` options in the reference:
 
 ----
-  git push tr:kernel/common HEAD:refs/for/experimental%r=a@a.com,cc=b@o.com
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%r=a@a.com,cc=b@o.com
 ----
 
 The `r='email'` and `cc='email'` options may be specified as many
@@ -284,7 +335,7 @@
   $ cat .git/config
   ...
   [remote "exp"]
-    url = tr:kernel/common
+    url = ssh://john.doe@git.example.com:29418/kernel/common
     push = HEAD:refs/for/experimental%r=a@a.com,cc=b@o.com
 
   $ git push exp
@@ -405,11 +456,11 @@
 link:access-control.html#category_push_direct['Push'] with the
 'Force' option ticked.
 
-To push annotated tags, the `Push Annotated Tag` project right must
+To push annotated tags, the `Create Annotated Tag` project right must
 be granted to one (or more) of the user's groups.  There is only
 one level of access in this category.
 
-Project owners may wish to grant themselves `Push Annotated Tag`
+Project owners may wish to grant themselves `Create Annotated Tag`
 only at times when a new release is being prepared, and otherwise
 grant nothing at all.  This ensures that accidental pushes don't
 make undesired changes to the public repository.
@@ -458,6 +509,23 @@
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%base=commit-id1,base=commit-id2
 ----
 
+[[merged]]
+=== Creating Changes for Merged Commits
+
+Normally, changes are only created for commits that have not yet
+been merged into the branch. In some cases, you may want to review a
+change that has already been merged. A new change for a merged commit
+can be created by using the '%merged' argument:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common my-merged-commit:refs/for/master%merged
+----
+
+This only creates one merged change at a time, corresponding to
+exactly `my-merged-commit`. It doesn't walk all of history up to that
+point, which could be slow and create lots of unintended new changes.
+To create multiple new changes, run push multiple times.
+
 
 == repo upload
 
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 0000000..565dd45
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1 @@
+gerritPipeline()
diff --git a/README.md b/README.md
index 020602f..78c8477 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,8 @@
 [Gerrit](https://www.gerritcodereview.com) is a code review and project
 management tool for Git based projects.
 
+[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-master/)
+
 ## Objective
 
 Gerrit makes reviews easier by showing changes in a side-by-side display,
@@ -48,10 +50,10 @@
 
 ## Build
 
-Install [Buck](http://facebook.github.io/buck/setup/install.html) and run the following:
+Install [Bazel](https://bazel.build/versions/master/docs/install.html) and run the following:
 
         git clone --recursive https://gerrit.googlesource.com/gerrit
-        cd gerrit && buck build release
+        cd gerrit && bazel build release
 
 ## Install binary packages (Deb/Rpm)
 
@@ -69,5 +71,21 @@
 
         yum clean all && yum install gerrit-<version>[-<release>]
 
+On Fedora run:
+
+        dnf clean all && dnf install gerrit-<version>[-<release>]
+
+## Use pre-built Gerrit images on Docker
+
+Docker images of Gerrit are available on [DockerHub](https://hub.docker.com/u/gerritforge/)
+
+To run a CentOS 7 based Gerrit image:
+
+        docker run -p 8080:8080 gerritforge/gerrit-centos7[:version]
+
+To run a Ubuntu 15.04 based Gerrit image:
+
+        docker run -p 8080:8080 gerritforge/gerrit-ubuntu15.04[:version]
+
 _NOTE: release is optional. Last released package of the version is installed if the release
 number is omitted._
diff --git a/ReleaseNotes/BUCK b/ReleaseNotes/BUCK
deleted file mode 100644
index 0f47808..0000000
--- a/ReleaseNotes/BUCK
+++ /dev/null
@@ -1,19 +0,0 @@
-include_defs('//Documentation/asciidoc.defs')
-include_defs('//ReleaseNotes/config.defs')
-
-DIR = 'ReleaseNotes'
-
-SRCS = glob(['*.txt'])
-
-
-genasciidoc(
-  name = 'html',
-  out = 'html.zip',
-  directory = DIR,
-  srcs = SRCS,
-  attributes = release_notes_attributes(),
-  backend = 'html5',
-  searchbox = False,
-  resources = False,
-  visibility = ['PUBLIC'],
-)
diff --git a/ReleaseNotes/BUILD b/ReleaseNotes/BUILD
new file mode 100644
index 0000000..9083a45
--- /dev/null
+++ b/ReleaseNotes/BUILD
@@ -0,0 +1,23 @@
+load("//tools/bzl:asciidoc.bzl", "genasciidoc", "genasciidoc_zip", "release_notes_attributes")
+
+SRCS = glob(["*.txt"])
+
+genasciidoc(
+    name = "ReleaseNotes",
+    srcs = SRCS,
+    attributes = release_notes_attributes(),
+    backend = "html5",
+    resources = False,
+    searchbox = False,
+    visibility = ["//visibility:public"],
+)
+
+genasciidoc_zip(
+    name = "html",
+    srcs = SRCS,
+    attributes = release_notes_attributes(),
+    backend = "html5",
+    resources = False,
+    searchbox = False,
+    visibility = ["//visibility:public"],
+)
diff --git a/ReleaseNotes/ReleaseNotes-2.12.1.txt b/ReleaseNotes/ReleaseNotes-2.12.1.txt
index e746d6e..8f94810 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.1.txt
@@ -1,228 +1,5 @@
 = Release notes for Gerrit 2.12.1
 
-Gerrit 2.12.1 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.1.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.1.war]
-
-Gerrit 2.12.1 includes the bug fixes done with
-link:ReleaseNotes-2.11.6.html[Gerrit 2.11.6] and
-link:ReleaseNotes-2.11.7.html[Gerrit 2.11.7]. These bug fixes are *not*
-listed in these release notes.
-
-== Schema Upgrade
-
-*WARNING:* This version includes a manual schema upgrade when upgrading
-from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, this manual step is not
-necessary and should be omitted.
-
-
-== Bug Fixes
-
-=== General
-
-* Fix column type for signed push certificates.
-+
-The column type `VARCHAR(255)` was too small, preventing some PGP push
-certificates from being stored.
-
-* Add the `DRAFT_COMMENTS` option to the list changes REST API endpoint
-and mark it as deprecated.
-+
-It was removed in version 2.12 because it's not needed any more by the UI,
-but this caused failures for clients that still use it.
-+
-Now it is added back, although it does not do anything and is marked as
-deprecated.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3669[Issue 3669]:
-Fix schema migration when migrating to 2.12.x directly from a version
-earlier than 2.11.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3733[Issue 3733]:
-Correctly detect symlinked log directory on startup.
-+
-If `$site_path/logs` was a symlink, the server would not start.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3871[Issue 3871]:
-Throw an explicit exception when failing to load a change from the database.
-+
-If a change could not be loaded from the database, for example if it was
-manually removed from the changes table but references to it were remaining
-in other tables, a null change was returned which would then lead to an
-'Internal Server Error' that was difficult to track down. Now an error is
-raised earlier which will help administrators to find the root cause.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3743[Issue 3743]:
-Use submitter identity as committer when using 'Rebase if Necessary' merge
-strategy.
-+
-When submitting a change that required rebase, the committer was being
-set to 'Gerrit Code Review' instead of the name of the submitter.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3758[Issue 3758]:
-Fix serving of static resources when deployed in application container.
-+
-When deployed in a container, for example Tomcat, it was not possible to
-load the UI because static content could not be loaded from the WAR file.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3790[Issue 3790]:
-When deployed in a container, for example Tomcat, the 'Documentation' menu
-was missing.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3786[Issue 3786]:
-Fix SQL statement syntax in schema migration.
-+
-An extra semicolon was preventing migration from 2.11.x to 2.12 when using
-an Oracle database.
-
-* Send email using email queue instead of the default queue.
-+
-Some emails sent asynchronously were already being sent using that queue
-but some were not. This was confusing for a gerrit administrator because
-if there is a build up of `send-email` tasks in the queue, he would
-think that increasing `sendemail.threadPoolSize` would help but it did not
-because some of the email were sent using the default queue which is
-configurable using `execution.defaultThreadPoolSize`.
-
-* Fix XSRF token cookie to honor `auth.cookieSecure` setting.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3767[Issue 3767]:
-Fix replication of first patch set for new changes.
-+
-When new changes were pushed from the command line, the first patch
-set did not get replicated to destinations.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3771[Issue 3771]:
-Remove `index.defaultMaxClauseCount` configuration option.
-+
-When `index.maxTerms` was either not set (thus no limit) or set to a value
-higher than `index.defaultMaxClauseCount` it was possible that viewing the
-related changes tab could cause a 'Too many clauses' error for changes that
-have a lot of related changes.
-+
-The `index.defaultMaxClauseCount` configuration option is removed, and the
-existing `index.maxTerms` is reused. The default value of `index.maxTerms`
-is reduced from 'no limit' to 1024.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3919[Issue 3919]:
-Explicitly set parent project to 'All-Projects' when a project is created
-without giving the parent.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3948[Issue 3948]:
-Fix submit of project parent updates on `refs/meta/config`.
-+
-When submitting a change on `refs/meta/config` to update a project's parent,
-the error 'The change must be submitted by a Gerrit administrator' was being
-displayed even when the submitter was an admin. The submit was successful
-when clicking 'Submit' a second time.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3811[Issue 3811]:
-Fix submittability of merge commits that resolve merge conflicts.
-+
-If a series of changes contained a change that conflicted with the destination
-branch, but the conflict was solved by a merge commit at the tip of the
-series, the series was not submittable.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3883[Issue 3883]:
-Respect the `core.commentchar` setting from `.gitconfig` in `commit-msg` hook.
-
-=== UI
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3894[Issue 3894]:
-Fix display of 'Related changes' after change is rebased in web UI:
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3071[Issue 3071]:
-Fix display of submodule differences in side-by-side view.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3718[Issue 3718]:
-Hide avatar images when no avatars are available.
-+
-The UI was showing a transparent empty image with a border.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3731[Issue 3731]:
-Fix syntax higlighting of tcl files.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3863[Issue 3863]:
-Fix display of active row marker in tag list.
-+
-Clicking on one of the rows would cause the tag name to disappear.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=1207[Issue 1207]:
-Fix keyboard shortcuts for non-US keyboards on side-by-side diff screen.
-+
-The forward/backward navigation keys `[` and `]` only worked on keyboards where
-these characters could be typed without using any modifier key (like CTRL, ALT,
-etc..).
-+
-Note that the problem still exists on the unified diff screen.
-
-* Improve tooltip on 'Submit' button when 'Submit whole topic' is enabled
-and the topic can't be submitted due to some changes not being ready.
-
-=== Plugins
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3821[Issue 3821]:
-Fix repeated reloading of plugins when running on OpenJDK 8.
-+
-OpenJDK 8 uses nanotime precision for file modification time on systems that
-are POSIX 2008 compatible. This leads to precision incompatibility when
-comparing the plugin's JAR file timestamp, resulting in the plugin being
-reloaded every minute.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3741[Issue 3741]:
-Fix handling of merge validation exceptions emitted by plugins.
-+
-If a plugin raised an exception, it was reported to the user as 'Change is
-new', rather than 'Missing dependency'.
-
-* Allow plugins to get the caller in merge validation requests.
-+
-Plugins that implement the `MergeValidationListener` interface now get the
-caller (the user who initiated the merge) in the `onPreMerge` method.
-+
-Existing plugins that implement this interface must be adapted to the new
-method signature.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3892[Issue 3892]:
-Allow plugins to suggest reviewers based on either change or project
-resources.
-
-=== Documentation
-
-* Update documentation of `commentlink` to reflect changed search URL.
-
-* Add missing documentation of valid `database.type` values.
-
-== Upgrades
-
-* Upgrade JGit to 4.1.2.201602141800-r.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.1[
+Release notes for Gerrit 2.12.1].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.2.txt b/ReleaseNotes/ReleaseNotes-2.12.2.txt
index 8292eb5..35682ed 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.2.txt
@@ -1,70 +1,5 @@
 = Release notes for Gerrit 2.12.2
 
-Gerrit 2.12.2 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.2.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.2.war]
-
-== Schema Upgrade
-
-*WARNING:* There are no schema changes from link:ReleaseNotes-2.12.1.html[
-2.12.1] but a manual schema upgrade is necessary when upgrading from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, or from 2.12.1 having already
-done the migration, this manual step is not necessary and should be omitted.
-
-
-== Bug Fixes
-
-* Upgrade Apache commons-collections to version 3.2.2.
-+
-Includes a fix for a link:https://issues.apache.org/jira/browse/COLLECTIONS-580[
-remote code execution exploit].
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3919[Issue 3919]:
-Explicitly set parent project to 'All-Projects' when a project is created
-without giving the parent.
-
-* Don't add message twice on abandon or restore via ssh review command.
-+
-When abandoning or reviewing a change via the ssh `review` command, and
-providing a message with the `--message` option, the message was added to
-the change twice.
-
-* Clear the input box after cancelling add reviewer action.
-+
-When the action was cancelled, the content of the input box was still
-there when opening it again.
-
-* Fix internal server error when aborting ssh command.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3969[Issue 3969]:
-Fix internal server error when submitting a change with 'Rebase If Necessary'
-strategy.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.2[
+Release notes for Gerrit 2.12.2].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.3.txt b/ReleaseNotes/ReleaseNotes-2.12.3.txt
index f51d739..06b18da 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.3.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.3.txt
@@ -1,113 +1,5 @@
 = Release notes for Gerrit 2.12.3
 
-Gerrit 2.12.3 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.3.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.3.war]
-
-Gerrit 2.12.3 includes the bug fixes done with
-link:ReleaseNotes-2.11.8.html[Gerrit 2.11.8] and
-link:ReleaseNotes-2.11.9.html[Gerrit 2.11.9]. These bug fixes are *not*
-listed in these release notes.
-
-== Schema Upgrade
-
-*WARNING:* There are no schema changes from link:ReleaseNotes-2.12.2.html[
-2.12.2] but a manual schema upgrade is necessary when upgrading from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, or from 2.12.1 or 2.12.2
-having already done the migration, this manual step is not necessary and
-should be omitted.
-
-
-== Bug Fixes
-
-* Fix SSL security issue in the SMTP email relay.
-+
-The hostname of the SSL socket was not verified. This made the read
-from the socket insecure since without verifying the hostname it may
-be link:https://www.cs.utexas.edu/~shmat/shmat_ccs12.pdf[vulnerable
-to a man-in-the-middle attack].
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3895[Issue 3895]:
-Fix failure to submit with 'Rebase if Necessary' after changes were reordered
-with interactive rebase.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4052[Issue 4052]:
-Fix failure to start server after upgrade from version 2.9.4.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3891[Issue 3891]:
-Fix query with `label:` operator and zero value.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4112[Issue 4112]:
-Fix failure to submit changes caused by empty user edit ref.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4087[Issue 4087]:
-Fix failure to submit change when a branch is created on the change ref.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4155[Issue 4155]:
-Fix tags REST API to correctly return all tags.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4154[Issue 4154]:
-Add support for `.team` and several more TLDs in email address validation.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4163[Issue 4163]:
-Prevent removal of non-voting reviewers on submit of change.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=2647[Issue 2647]:
-Fix usage of `CTRL-C` on change screen.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4236[Issue 4236]:
-Fix internal error when pushing an amended commit with the `%edit` option.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3426[Issue 3426]:
-Fix pushing changes with `%base` option or `newChangeForAllNotInTarget` option.
-
-* Show 'Submitted Together' tab for changes with same topic.
-
-* Improve submit button tooltip messages shown when change is not submittable.
-
-* Fix firing of the `topic-changed` hook.
-
-* Remove `--dry-run` option from the `Reindex` site program.
-+
-The implementation of the option was removed, but the option was mistakenly
-added back to the command and did not actually work.
-
-* Print proper task names in the output of the `show-queues` command.
-
-* Replication plugin: Double check if a ref is missing locally before deleting
-from remote.
-
-* Show an error message when trying to add a non-existent group to an ACL.
-
-== Updates
-
-* Update commons-validator to 1.5.1.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.3[
+Release notes for Gerrit 2.12.3].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.4.txt b/ReleaseNotes/ReleaseNotes-2.12.4.txt
index 64252c6..8321efa 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.4.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.4.txt
@@ -1,128 +1,5 @@
 = Release notes for Gerrit 2.12.4
 
-Gerrit 2.12.4 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.4.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.4.war]
-
-== Schema Upgrade
-
-*WARNING:* There are no schema changes from link:ReleaseNotes-2.12.3.html[
-2.12.3] but a manual schema upgrade is necessary when upgrading from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, or from 2.12.1 or 2.12.2
-having already done the migration, this manual step is not necessary and
-should be omitted.
-
-== Known Issues
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4323[Issue 4323]:
-'value too long for type character varying(255)' in patch_sets table when
-migrating to schema version 108.
-+
-This error may occur under some circumstances when running the schema
-migration from an earlier version of Gerrit.
-+
-On sites where this occurs, it can be fixed with a manual schema update
-according to the comments in the issue.
-
-== Bug Fixes
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4400[Issue 4400]:
-Fix `AlreadyClosedException` in Lucene index.
-+
-If a Lucene indexing thread was interrupted by an SSH connection being
-closed, this would also close file handles being used to read the index.
-+
-Lucene queries are now executed on background threads to isolate them
-from SSH threads.
-+
-This may also reduce latency for user dashboards on a multi-core system as
-each query for the different sections can now run on separate threads and
-return results when ready.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4249[Issue 4249]:
-Fix 'Duplicate stages not allowed' error during indexing.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4238[Issue 4238]:
-Fix 'not found' error when browsing tree in gitweb.
-+
-The `refs/heads/` prefix was incorrectly being added to `HEAD`, causing a
-'404 Not Found' error.
-
-* Allow to read repositories that do not end with `.git`.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4262[Issue 4262]:
-Fix GPG push certificate for first patch set of new changes.
-+
-The GPG certificate was not being set for the first patch set of new
-changes.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4296[Issue 4296]:
-Fix internal error when a query does not contain any token.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4241[Issue 4241]:
-Fix 'Cannot format velocity template' error when sending notification emails.
-
-* Fix `sshd.idleTimeout` setting being ignored.
-+
-The `sshd.idleTimeout` setting was not being correctly set on the SSHD
-backend, causing idle sessions to not time out.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4324[Issue 4324]:
-Set the correct uploader on new patch sets created via the inline editor.
-
-* Log a warning instead of failing when invalid commentlinks are configured.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4136[Issue 4136]:
-Fix support for `HEAD` requests in the REST API.
-+
-Sending a `HEAD` request failed with '404 Not Found'.
-
-* Return proper error response when trying to confirm an email that is already
-used by another user.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4318[Issue 4318]
-Fix 'Rebase if Necessary' merge strategy to prevent introducing a duplicate
-commit when submitting a merge commit.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4332[Issue 4332]:
-Allow `local` as a valid TLD for outgoing emails.
-
-* Bypass hostname verification when `sendemail.sslVerify` is disabled.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4398[Issue 4398]:
-Replication: Consider ref visibility when scheduling replication.
-+
-It was possible for refs to be replicated to remotes despite not being
-visible to groups mentioned in the `authGroup` setting.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4036[Issue 4036]:
-Fix hanging query when using `is:watched` without authentication.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.4[
+Release notes for Gerrit 2.12.4].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.5.txt b/ReleaseNotes/ReleaseNotes-2.12.5.txt
index 12d6870..4199fe0 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.5.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.5.txt
@@ -1,101 +1,5 @@
 = Release notes for Gerrit 2.12.5
 
-Gerrit 2.12.5 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.12.5.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.12.5.war]
-
-== Schema Upgrade
-
-*WARNING:* There are no schema changes from link:ReleaseNotes-2.12.4.html[
-2.12.4] but a manual schema upgrade is necessary when upgrading from 2.12.
-
-When upgrading a site that is already running version 2.12, the `patch_sets`
-table must be manually migrated using the `gerrit gsql` SSH command or the
-`gqsl` site program.
-
-For the default H2 database, execute the command:
-
-----
-  alter table patch_sets modify push_certficate clob;
-----
-
-For MySQL, execute the command:
-
-----
-  alter table patch_sets modify push_certficate text;
-----
-
-For PostgreSQL, execute the command:
-
-----
-  alter table patch_sets alter column push_certficate type text;
-----
-
-For other database types, execute the appropriate equivalent command.
-
-Note that the misspelled `push_certficate` is the actual name of the
-column.
-
-When upgrading from a version earlier than 2.12, or from 2.12.1 or 2.12.2
-having already done the migration, this manual step is not necessary and
-should be omitted.
-
-== Known Issues
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4323[Issue 4323]:
-'value too long for type character varying(255)' in patch_sets table when
-migrating to schema version 108.
-+
-This error may occur under some circumstances when running the schema
-migration from an earlier version of Gerrit.
-+
-On sites where this occurs, it can be fixed with a manual schema update
-according to the comments in the issue.
-
-== New Features
-
-* New preference to enable line wrapping in diff screen and inline editor.
-
-== Bug Fixes
-
-* Fix the diff and edit preference dialogs for smaller screens.
-+
-On smaller screens the options at the bottom of the dialogs would
-get cut off, making it difficult to change them.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4521[Issue 4521]:
-Fix internal server error during validation of email addresses.
-+
-When creating a new account or adding a new email address to an existing
-account, the email validation crashed.
-
-* Lucene stability improvements.
-+
-Each Lucene index is now written using a dedicated background thread. Lucene
-threads may not be cancelled, to prevent interruptions while writing.
-
-* Don't try to change username that is already set.
-+
-Since Gerrit version 2.1.4 it is not allowed to change the username once
-it has been set, and attempting to do so results in an exception.
-+
-If `ldap.accountSshUserName` is set in the `gerrit.config` using
-`${userPrincipalName.localPart}` to initialize the username from the user's
-email address, and then the email address is changed, the username gets
-resolved to something different and the account manager tried to change it.
-As a result, an exception was raised and the user could no longer log in.
-+
-Instead of trying to change the username, a warning is logged.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4006[Issue 4006]:
-Prevent search limit parameter from exceeding maximum integer value.
-
-* Fix internal server error when generating task names.
-
-* Print proper names for query tasks in the output of the `show-queue` command.
-
-* Double-check change status when auto-abandoning changes.
-+
-It was possible that changes could be updated in the time between the query
-results being returned and the change being abandoned.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md#2.12.5[
+Release notes for Gerrit 2.12.5].
diff --git a/ReleaseNotes/ReleaseNotes-2.12.txt b/ReleaseNotes/ReleaseNotes-2.12.txt
index 84644e8..3eae5e4 100644
--- a/ReleaseNotes/ReleaseNotes-2.12.txt
+++ b/ReleaseNotes/ReleaseNotes-2.12.txt
@@ -1,562 +1,5 @@
 = Release notes for Gerrit 2.12
 
-
-Gerrit 2.12 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.12.war[
-https://www.gerritcodereview.com/download/gerrit-2.12.war]
-
-== Important Notes
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* To use online reindexing when upgrading to 2.12.x, the server must
-first be upgraded to 2.8 (or 2.9) and then through 2.10 and 2.11 to 2.12.x. If
-reindexing will be done offline, you may ignore this warning and upgrade directly
-to 2.12.x.
-
-*WARNING:* When upgrading from version 2.8.4 or older with a site that uses
-Bouncy Castle Crypto, new versions of the libraries will be downloaded. The old
-libraries should be manually removed from site's `lib` folder to prevent the
-startup failure described in
-link:https://code.google.com/p/gerrit/issues/detail?id=3084[issue 3084].
-
-*WARNING:* The Solr secondary index is no longer supported. With this release
-the only supported secondary index is Lucene.
-
-*WARNING:* The format of the `ref-updated` event has changed. Users of the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[
-Jenkins Gerrit Trigger plugin] with jobs triggering on `ref-updated` should
-upgrade to at least
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger#GerritTrigger-Version2.15.1%28releasedSept142015%29[
-version 2.15.1]. If an upgrade of the plugin is not possible, a workaround is
-to change the branch configuration to type `Path` with a pattern like
-`refs/*/master` instead of `Plain` and `master`.
-
-
-== Release Highlights
-
-This release includes the following new features. See the sections below for
-further details.
-
-* New change submission workflows: 'Submit Whole Topic' and 'Submitted Together'.
-
-* Support for GPG Keys and signed pushes.
-
-
-== New Features
-
-=== New Change Submission Workflows
-
-* New 'Submit Whole Topic' setting.
-+
-When the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#change.submitWholeTopic[
-`change.submitWholeTopic`] setting is enabled, all changes belonging to the same
-topic will be submitted at the same time.
-+
-This setting should be considered experimental, and is disabled by default.
-
-* Submission of changes may include ancestors.
-+
-If a change is submitted that has submittable ancestor changes, those changes
-will also be submitted.
-
-* The merge queue is removed.
-+
-Changes that cannot be submitted due to missing dependencies will no longer
-enter the 'Submitted, Merge Pending' state.
-
-
-=== GPG Keys and Signed Pushes
-
-* Signed push can be enabled by setting
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#receive.enableSignedPush[
-`receive.enableSignedPush`] to true.
-+
-When a client pushes with `git push --signed`, Gerrit ensures that the push
-certificate is valid and signed with a valid public key stored in the
-`refs/meta/gpg-keys` branch of the `All-Users` repository.
-
-* When signed push is enabled, and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#gerrit.editGpgKeys[
-`gerrit.editGpgKeys`] is set to true, users may upload their public GPG
-key via the REST API or UI.
-+
-If this setting is not enabled, GPG keys may only be added by administrators
-with direct access to the `All-Users` repository.
-
-* Administrators may also configure
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#receive.certNonceSeed[
-`receive.certNonceSeed`] and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#receive.certNonceSlop[
-`receive.certNonceSlop`].
-
-
-=== Secondary Index
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3333[Issue 3333]:
-Support searching for changes by author and committer.
-+
-Changes are indexed by the git author and committer of the latest patch set,
-and can be searched with the `author:` and `committer:` operators.
-+
-Changes are matched on either the exact whole email address, or on parts of the
-name or email address.
-
-* Add `from:` search operator to match by owner of change or author of comments.
-
-* Add `commentby:` search operator to search by author of comments.
-
-* Change the `topic:` search operator to search by the exact topic name.
-
-* Add `intopic:` search operator to search by topics containing the search term.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3291[Issue 3291]:
-Add `has:edit` search operator to match changes that have edit revisions on them.
-
-* Allow configuration of maximum query size.
-+
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#index.maxTerms[
-`index.maxTerms`] can be set to limit the number of leaf index terms.
-
-* Expose Lucene index writers for plugins.
-+
-Plugins can now reconfigure various Lucene performance related parameters
-at runtime.
-
-* Make Lucene index writers auto-commit writers.
-+
-Plugins can now temporarily turn on auto-committing in situations where it makes
-sense to enforce all changes to be written to disk ASAP.
-
-
-=== UI
-
-==== General
-
-* Edit and diff preferences can be modified from the user preferences screen.
-+
-Previously it was only possible to edit these preferences from the actual
-diff and edit screens.
-
-* Add 'Edits' to the 'My' dashboard menu to list changes on which the user
-has an unpublished edit revision.
-
-* Support for URL aliases.
-+
-Administrators may define
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#urlAlias[
-URL aliases] to map plugin screens into the Gerrit URL namespace.
-+
-Plugins may use user-specific URL aliases to replace certain screens for certain
-users.
-
-
-==== Project Screen
-
-* New tab to list the project's tags, similar to the branch list.
-
-
-==== Inline Editor
-
-* Store and load edit preferences in git.
-+
-Edit preferences are stored and loaded to/from the `All-Users` repository.
-
-* Add 'auto close brackets' feature.
-
-* Add 'match brackets' feature.
-
-* Make the cursor blink rate customizable.
-
-* Add support for Emacs and Vim key maps.
-
-
-==== Change Screen
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3318[Issue 3318]:
-Highlight 'Reply' button if there are draft comments on any patch set.
-+
-If any patch set of the change has a draft comment by the current user,
-the 'Reply' button is highlighted.
-+
-The icons depicting draft comments are removed from the revisions drop-down
-list.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1100[Issue 1100]:
-Publish all draft comments when replying to a change.
-+
-All draft comments, including those on older patch sets, are published when
-replying to a change.
-
-* Show file size increase/decrease for binary files.
-
-* Show uploader if different from change owner.
-
-* Show push certificate status.
-
-* Show change subject as tooltip on related changes list.
-+
-This helps to identify changes when the subject is truncated in the list.
-
-
-==== Side-By-Side Diff
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3293[Issue 3293]:
-Add syntax highlighting for Puppet.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3447[Issue 3447]:
-Add syntax highlighting for VHDL.
-
-
-==== Group Screen
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1479[Issue 1479]:
-The group screen now includes an 'Audit Log' panel showing member additions,
-removals, and the user who made the change.
-
-
-=== API
-
-Several new APIs are added.
-
-==== Accounts
-
-* Suggest accounts.
-
-==== Tags
-
-* List tags.
-
-* Get tag.
-
-
-=== REST API
-
-New REST API endpoints and new options on existing endpoints.
-
-
-==== Accounts
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#set-username[
-Set Username]: Set the username of an account.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#get-detail[
-Get Account Details]: Get the details of an account.
-+
-In addition to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#account-info[
-AccountInfo] fields returned by the existing
- link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-accounts.html#get-account[
-Get Account] endpoint, the new REST endpoint returns the registration date of
-the account and the timestamp of when contact information was filed for this
-account.
-
-
-==== Changes
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#set-review[
-Set Review]: Add an option to omit duplicate comments.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#get-safe-content[
-Download Content]: Download the content of a file from a certain revision, in a
-safe format that poses no risk for inadvertent execution of untrusted code.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#submitted-together[
-Get Submitted Together]: Get the list of all changes that will be submitted at
-the same time as the change.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=1100[Issue 1100]:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-changes.html#set-review[
-Set Review]: Add an option to publish draft comments on all revisions.
-
-==== Config
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-config.html#get-info[
-Get Server Info]: Return information about the Gerrit server configuration.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-config.html#confirm-email[
-Confirm Email]: Confirm that the user owns an email address.
-
-
-==== Groups
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-groups.html#list-group[
-List Groups]: Add option to suggest groups.
-+
-This allows group auto-completion to be used in a plugin's UI.
-
-*  link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-groups.html#get-audit-log[
-Get Audit Log]: Get the audit log of a Gerrit internal group, showing member
-additions, removals, and the user who made the change.
-
-
-==== Projects
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-projects.html#run-gc[
-Run GC]: Add `aggressive` option to specify whether or not to run an aggressive
-garbage collection.
-
-* link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/rest-api-projects.html#list-tags[
-List Tags]: Support filtering by substring and regex, and pagination with
-`--start` and `--end`.
-
-
-=== SSH
-
-* Add support for ZLib Compression.
-+
-To enable compression use the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#sshd.enableCompression[
-`sshd.enableCompression` setting].
-
-* Add support for hmac-sha2-256 and hmac-sha2-512 as MACs.
-
-=== Plugins
-
-==== General
-
-* Gerrit client can now pass JavaScriptObjects to extension panels.
-
-* New UI extension point for header bar in change screen.
-
-* New UI extension point to password screen.
-
-* New UI extension points to project info screen.
-
-* New UI extension point for pop down buttons on change screen.
-
-* New UI extension point for buttons in header bar on change screen.
-
-* New UI extension point at bottom of the user preferences screen.
-
-* New UI extension point for the 'Included In' drop-down panel.
-+
-By implementing the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/dev-plugins.html#included-in[
-Included In interface], plugins may add entries to the 'Included In' dropdown
-menu on the change screen.
-
-* Plugins can extend Gerrit screens with GWT controls.
-
-* Plugins can add custom settings screens.
-
-* Referencing groups in `project.config`.
-+
-Plugins can refer to groups so that when they are renamed, the project
-config will also be updated in this section.
-
-* API
-
-** Allow to use `CurrentSchemaVersion`.
-
-** Allow to use `InternalChangeQuery.query()`.
-
-** Allow to use `JdbcUtil.port()`.
-
-** Allow to use GWTORM `Key` classes.
-
-
-=== Other
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3401[Issue 3401]:
-Add option to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#sendemail.allowRegisterNewEmail[
-disable registration of new email addresses].
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=2061[Issue 2061]
-Add Support for `git-upload-archive`.
-+
-This allows use the standard `git archive` command to create an archive
-of the content of a repository.
-
-* Add a background job to automatically abandon inactive changes.
-+
-The
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/config-gerrit.html#changeCleanup[
-changeCleanup] configuration can be set to periodically check for inactive
-changes and automatically abandon them.
-
-* Add support for the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/database-setup.html#createdb_db2[
-DB2 database].
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3441[Issue 3441]:
-Add support for the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/database-setup.html#createdb_derby[
-Apache Derby database].
-
-* Download commands plugin: Use commit IDs for download commands when change refs are hidden.
-+
-Git has a configuration option to hide refs from the initial advertisement
-(`uploadpack.hideRefs`). This option can be used to hide the change refs from
-the client. As consequence this prevented fetching changes by change ref from
-working.
-+
-Setting `download.checkForHiddenChangeRefs` in the `gerrit.config` to true
-allows the download commands plugin to check for hidden change refs.
-
-* Add a new 'Maintain Server' global capability.
-+
-Members of a group with the 'Maintain Server' capability may view caches, tasks,
-and queues, and invoke the index REST API on changes.
-
-
-== Bug Fixes
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3499[Issue 3499]:
-Fix syntax highlighting of raw string literals in go.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3643[Issue 3643]:
-Fix syntax highlighting of ES6 string templating using backticks.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3653[Issue 3653]:
-Correct timezone in sshd log after DST change.
-+
-When encountering a DST switch, the timezone wasn't updated until
-the server was reloaded.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3306[Issue 3306]:
-Allow admins to read, push and create on `refs/users/default`.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3212[Issue 3212]:
-Fix failure to run `init` when `--site-path` option is not explicitly given.
-
-* Make email validation case insensitive.
-+
-While link:https://tools.ietf.org/html/rfc5321#section-2.3.11[
-RFC 5321 section 2.3.11] allows for the local-part (the part left of
-the '@') of an email address to be case sensitive, the domain portion is
-case insensitive according to
-link:https://tools.ietf.org/html/rfc1035#section-3.1[RFC 1035 section 3.1].
-And in practice, even the local-part is typically case insensitive also.
-
-* `commit-msg` hook: Don't add `Change-Id` line on temporary commits.
-+
-Commits created with `git commit --fixup` or `git commit --squash` are not
-intended to be pushed to Gerrit, and don't need a `Change-Id` line.
-+
-This also prevents changes from being accidentally uploaded, at least for
-projects that have the 'Require Change-Id' configuration enabled.
-
-* link:http://code.google.com/p/gerrit/issues/detail?id=3444[Issue 3444]:
-download-commands plugin: Fix clone with commit-msg hook when project name
-contains '/'.
-
-* Use full ref name in `refName` attribute of `ref-updated` events.
-+
-The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.12/json.html#refUpdate[
-refUpdate attribute] in `ref-updated` events did not include the full name
-of the ref in the `refName` attribute, i.e. `master` was used instead of
-`refs/heads/master`.
-+
-Support for the new format is added in
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger#GerritTrigger-Version2.15.1%28releasedSept142015%29[
-version 2.15.1 of the Jenkins Gerrit Trigger plugin].
-+
-Users who are unable to upgrade the plugin may instead change the
-trigger's branch configuration to type `Path` with a pattern like
-`refs/*/master` instead of `Plain` and `master`.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3714[Issue 3714]:
-Improve visibility of comments on dark themes.
-
-* Fix highlighting of search results and trailing whitespaces in intraline
-diff chunks.
-
-* Fix server error when listing annotated/signed tag that has no tagger info.
-
-* Don't create new account when claimed OAuth identity is unknown.
-+
-The Claimed Identity feature was enabled to support old Google OpenID accounts,
-that cannot be activated anymore. In some corner cases, when for example the URL
-is not from the production Gerrit site, for example on a staging instance, the
-OpenID identity may deviate from the original one. In case of mismatch, the lookup
-of the user for the claimed identity would fail, causing a new account to be
-created.
-
-* Suggest to upgrade installed plugins per default during site initialization
-to new Gerrit version.
-+
-The default was 'No' which resulted in some sites not upgrading core
-plugins and running the wrong versions.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3698[Issue 3698]:
-Fix creation of the administrator user on databases with pre-allocated
-auto-increment column values.
-+
-When using a database configuration where auto-increment column values are
-pre-allocated, it was possible that the 'Administrators' group was created
-with an ID other than `1`. In this case, the created admin user was not added
-to the correct group, and did not have the correct admin permissions.
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=3018[Issue 3018]:
-Fix query for changes using a label with a group operator.
-+
-The `group` operator was being ignored when searching for changes with labels
-because the search index does not contain group information.
-
-* Fix online reindexing of changes that don't already exist in the index.
-+
-Changes are now always reloaded from the database during online reindex.
-
-* Fix reading of plugin documentation.
-+
-Under some circumstances it was possible to fail with an IO error.
-
-== Documentation Updates
-
-* link:https://code.google.com/p/gerrit/issues/detail?id=412[Issue 412]:
-Update documentation of `commentlink.match` regular expression to clarify
-that the expression is applied to the rendered HTML.
-
-* Remove warning about unstable change edit REST API endpoints.
-+
-These endpoints should be considered stable since version 2.11.
-
-* Document that `ldap.groupBase` and `ldap.accountBase` are repeatable.
-
-== Upgrades
-
-* Upgrade Asciidoctor to 1.5.2
-
-* Upgrade AutoValue to 1.1
-
-* Upgrade Bouncy Castle to 1.52
-
-* Upgrade CodeMirror to 5.7
-
-* Upgrade gson to 2.3.1
-
-* Upgrade guava to 19.0-RC2
-
-* Upgrade gwtorm to 1.14-20-gec13fdc
-
-* Upgrade H2 to 1.3.176
-
-* Upgrade httpcomponents to 4.4.1
-
-* Upgrade Jetty to 9.2.13.v20150730
-
-* Upgrade JGit to 4.1.1.201511131810-r
-
-* Upgrade joda-time to 2.8
-
-* Upgrade JRuby to 1.7.18
-
-* Upgrade jsch to 0.1.53
-
-* Upgrade JUnit to 4.11
-
-* Upgrade Lucene to 5.3.0
-
-* Upgrade Prolog Cafe 1.4.1
-
-* Upgrade servlet API to 8.0.24
-
-* Upgrade Truth to version 0.27
-
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.12.md[
+Release notes for Gerrit 2.12].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.1.txt b/ReleaseNotes/ReleaseNotes-2.13.1.txt
index 958e726..7b27ad3 100644
--- a/ReleaseNotes/ReleaseNotes-2.13.1.txt
+++ b/ReleaseNotes/ReleaseNotes-2.13.1.txt
@@ -1,21 +1,5 @@
 = Release notes for Gerrit 2.13.1
 
-Gerrit 2.13.1 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.13.1.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.13.1.war]
-
-== Schema Upgrade
-
-There are no schema changes from link:ReleaseNotes-2.13.html[2.13].
-
-== Bug Fixes
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4618[Issue 4618]:
-Fix internal server error after online reindexing completed.
-
-* Fix internal server error when cloning from slaves and not all refs are
-visible.
-
-* Fix JSON deserialization error causing stream event client to no longer receive
-events.
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.13.md#2.13.1[
+Release notes for Gerrit 2.13.1].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.2.txt b/ReleaseNotes/ReleaseNotes-2.13.2.txt
index c7be976..72bd218 100644
--- a/ReleaseNotes/ReleaseNotes-2.13.2.txt
+++ b/ReleaseNotes/ReleaseNotes-2.13.2.txt
@@ -1,46 +1,5 @@
 = Release notes for Gerrit 2.13.2
 
-Gerrit 2.13.2 is now available:
-
-link:https://gerrit-releases.storage.googleapis.com/gerrit-2.13.2.war[
-https://gerrit-releases.storage.googleapis.com/gerrit-2.13.2.war]
-
-== Schema Upgrade
-
-There are no schema changes from link:ReleaseNotes-2.13.1.html[2.13.1].
-
-== Bug Fixes
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4630[Issue 4630]:
-Fix server error when navigating up to change while 'Working' is displayed.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4631[Issue 4631]:
-Read project watches from database.
-+
-Project watches were being read from the git backend by default, but the
-migration to git is not yet completed.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4632[Issue 4632]:
-Fix server error when deleting multiple SSH keys from the Web UI.
-+
-Attempting to delete multiple keys in parallel resulted in a lock failure
-when removing the keys from the git backend.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4645[Issue 4645]:
-Fix malformed account suggestions.
-+
-If the query contained several query terms and one of the query terms was
-a substring of 'strong', the suggestion was malformed.
-
-* Hooks plugin: Fix incorrect value passed to `--change-url` parameter.
-+
-The URL was being generated using the change's Change-Id rather than the
-change number.
-
-* Check for CLA when creating project config changes from the web UI.
-+
-If contributor agreements were enabled and required for a project, and
-the user had not signed a CLA, it was still possible to upload changes
-for review on `refs/meta/config` by making changes in the project access
-editor and pressing 'Save for Review'.
-
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.13.md#2.13.2[
+Release notes for Gerrit 2.13.2].
diff --git a/ReleaseNotes/ReleaseNotes-2.13.txt b/ReleaseNotes/ReleaseNotes-2.13.txt
index 0afca1a..b3e125d 100644
--- a/ReleaseNotes/ReleaseNotes-2.13.txt
+++ b/ReleaseNotes/ReleaseNotes-2.13.txt
@@ -1,471 +1,5 @@
 = Release notes for Gerrit 2.13
 
-
-Gerrit 2.13 is now available:
-
-link:https://www.gerritcodereview.com/download/gerrit-2.13.war[
-https://www.gerritcodereview.com/download/gerrit-2.13.war]
-
-
-== Important Notes
-
-*WARNING:* This release contains schema changes.  To upgrade:
-----
-  java -jar gerrit.war init -d site_path
-----
-
-*WARNING:* To use online reindexing for `changes` secondary index when upgrading
-to 2.13.x, the server must first be upgraded to 2.8 (or 2.9) and then through
-2.10, 2.11 and 2.12. Skipping a version will prevent the online reindexer from
-working.
-
-Gerrit 2.13 introduces a new secondary index for accounts, and this must be
-indexed offline before starting Gerrit:
-----
-  java -jar gerrit.war reindex --index accounts -d site_path
-----
-
-If reindexing will be done offline, you may ignore these warnings and upgrade
-directly to 2.13.x using the following command that will reindex both `changes`
-and `accounts` secondary indexes:
-----
-  java -jar gerrit.war reindex -d site_path
-----
-
-*WARNING:* The server side hooks functionality is moved to a core plugin. Sites
-that make use of server side hooks must install this plugin during site init.
-
-
-== Release Highlights
-
-* Support for Large File Storage (LFS).
-
-* Metrics interface.
-
-* Hooks plugin.
-
-* Secondary index for accounts.
-
-* File annotations (blame) in side-by-side diff.
-
-== New Features
-
-=== Large File Storage (LFS)
-
-Gerrit provides an
-link:https://gerrit-review.googlesource.com/Documentation/2.13/dev-plugins.html#lfs-extension[
-extension point] that enables development of plugins implementing the
-link:https://github.com/github/git-lfs/blob/master/docs/api/v1/http-v1-batch.md[
-LFS protocol].
-
-By setting
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#lfs.plugin[
-`lfs.plugin`] the administrator can configure the name of the plugin
-which handles LFS requests.
-
-=== Access control for git submodule subscriptions
-
-To prevent potential security breaches as described in
-link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3311[issue 3311],
-it is now only possible for a project to subscribe to a submodule if the
-submodule explicitly allows itself to be subscribed.
-
-Please see the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-submodules.html[
-submodules user guide] for details.
-
-Note that when upgrading from an earlier version of Gerrit, permissions for
-any existing subscriptions will be automatically added during the database
-schema migration.
-
-=== Metrics
-
-Metrics about Gerrit's internal state can be sent to external
-monitoring systems.
-
-Plugins can provide implementations of the metrics interface to
-report metrics to different monitoring systems. The following
-plugins are available:
-
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-jmx[
-JMX]
-
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-graphite[
-Graphite]
-
-* link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-elasticsearch[
-Elasticsearch]
-
-Plugins can also provide their own metrics.
-
-See the link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/metrics.html[
-metrics documentation] for further details.
-
-=== Hooks
-
-Server side hooks are moved to the
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/hooks[
-hooks plugin]. Sites that make use of server side hooks should install this
-plugin. After installing the plugin, no additional configuration is needed.
-The plugin uses the same configuration settings in `gerrit.config`.
-
-=== Secondary Index
-
-* The secondary index now supports indexing of accounts.
-+
-The link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/pgm-reindex.html[
-reindex program] by default reindexes all changes and accounts. A new
-option allows to explicitly specify whether to reindex changes or accounts.
-+
-The `suggest.fullTextSearch`, `suggest.fullTextSearchMaxMatches` and
-`suggest.fullTextSearchRefresh` configuration options are removed. Full text
-search is supported by default with the account secondary index.
-
-* New ssh command to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/cmd-index-changes.html[
-reindex changes].
-
-
-=== UI
-
-* The UI can now be loaded in an iFrame by enabling
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#gerrit.canLoadInIFrame[
-gerrit.canLoadInIFrame] in the site configuration.
-
-==== Change Screen
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=106[Issue 106]:
-Allow to select merge commit's parent for diff base in change screen.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3035[Issue 3035]:
-Allow to remove specific votes from a change, while leaving the reviewer on the
-change.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3487[Issue 3487]:
-Use 'Ctrl-Alt-e' instead of 'e' to open edit mode.
-
-==== Diff Screens
-
-* Add all syntax highlighting available in CodeMirror.
-
-* Improve search experience in diff screen
-+
-Ctrl-F, Ctrl-G and Shift-Ctrl-G now bind to the search dialog box provided by
-CodeMirror's search add-on. Enter and Shift-Enter navigate among the search
-results from the CodeMirror search, just like they do in a normal browser
-search. Esc now clears the search result.
-+
-If the user sets `Render` to `Slow` in the diff preferences and the file is less
-than 4000 lines (huge), then Ctrl-F, Ctrl-G and Shift-Ctrl-G fall back to the
-browser search.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=2968[Issue 2968]:
-Allow to go back to change list by keyboard shortcut from diff screens.
-
-==== Side-By-Side Diff Screen
-
-* Blame annotations
-+
-By enabling
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#change.allowBlame[
-`change.allowBlame`], blame annotations can be shown in the side-by-side diff
-screen gutter. Clicking the annotation opens the relevant change.
-
-==== User Preferences
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=989[Issue 989]:
-New option to control email notifications.
-+
-Users can now choose between 'Enabled', 'Disabled' and 'CC Me on Comments I Write'.
-
-* New option to control adding 'Signed-off-by' footer in commit message of new changes
-created online.
-
-* New option to control auto-indent width in inline editor.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=890[Issue 890]:
-New diff option to control whether to skip unchanged files when navigating to
-the previous or the next file.
-
-=== Changes
-
-In order to avoid potentially confusing behavior, when submitting changes in a
-batch, submit type rules may not be used to mix submit types on a single branch,
-and trying to submit such a batch will fail.
-
-=== REST API
-
-==== Accounts
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3766[Issue 3766]:
-Allow users with the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/access-control.html#capability_modifyAccount[
-'ModifyAccount' capability] to get the preferences for other users via the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-user-preferences[
-Get User Preferences] endpoint.
-
-* Rename 'Suggest Account' to
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#query-account[
-'Query Account'] and add support for arbitrary account queries.
-+
-The `_more_accounts` flag is set on the last result when there are more results
-than the limit. The `DETAILS` and `ALL_EMAILS` options may be set to control
-whether the results should include details (full name, email, username, avatars)
-and all emails, respectively.
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-watched-projects[
-Get Watched Projects].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#set-watched-projects[
-Set Watched Projects].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#delete-watched-projects[
-Delete Watched Projects].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-stars[
-Get Star Labels from Change].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#set-stars[
-Update Star Labels on Change].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#get-oauth-token[
-Get OAuth Access Token].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#list-contributor-agreements[
-List Contributor Agreements].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-accounts.html#sign-contributor-agreement[
-Sign Contributor Agreement].
-
-==== Changes
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3579[Issue 3579]:
-Append submitted info to ChangeInfo.
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-changes.html#move-change[
-Move Change].
-
-==== Groups
-
-* Add `-s` as an alias for `--suggest` on the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-groups.html#suggest-group[
-Suggest Group] endpoint.
-
-==== Projects
-
-* Add `async` option to the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#run-gc[
-Run GC] endpoint to allow garbage collection to run asynchronously.
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#get-access[
-List Access Rights].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#set-access[
-Add, Update and Delete Access Rights].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#create-tag[
-Create Tag].
-
-* New endpoint:
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/rest-api-projects.html#get-mergeable-info[
-Get Mergeable Information].
-
-=== Plugins
-
-* Secure settings
-+
-Plugins may now store secure settings in `etc/$PLUGIN.secure.config` where they
-will be decoded by the Secure Store implementation.
-
-* Exported dependencies
-+
-Gson is now an exported dependency. Plugins no longer need to explicitly add
-a dependency on it.
-
-=== Misc
-
-* New project option to reject implicit merge commits.
-+
-The 'Reject Implicit Merges' option can be enabled to prevent non-merge commits
-from implicitly bringing unwanted changes into a branch. This can happen for
-example when a commit is made based on one branch but is mistakenly pushed to
-another, for example based on `refs/heads/master` but pushed to `refs/for/stable`.
-
-* New link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/access-control.html#category_add_patch_set[
-Add Patch Set capability] to control who is allowed to upload a new patch
-set to an existing change.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4015[Issue 4015]:
-Allow setting a
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-upload.html#message[
-comment message] when uploading a change.
-
-* Allow to specify
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/user-upload.html#notify[
-who should be notified by email] when uploading a change.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3220[Issue 3220]:
-Append approval info to every comment-added stream event and hook.
-
-* The `administrateServer` capability can be assigned to groups by setting
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#capability.administrateServer[
-capability.administrateServer] in the site configuration.
-+
-Configuring this option can be a useful fail-safe to recover a server in the
-event an administrator removed all groups from the `administrateServer`
-capability, or to ensure that specific groups always have administration
-capabilities.
-
-* New configuration options to configure JGit repository cache parameters.
-+
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#core.repositoryCacheCleanupDelay[
-core.repositoryCacheCleanupDelay] and
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/config-gerrit.html#core.repositoryCacheExpireAfter[
-core.repositoryCacheExpireAfter] can be configured.
-
-* Accept `-b` as an alias of `--batch` in the
-link:https://gerrit-documentation.storage.googleapis.com/Documentation/2.13/pgm-init.html[
-init program].
-
-
-== Bug Fixes
-
-* Don't add the same SSH key multiple times.
-+
-If an already existing SSH key was added, a duplicate entry was added to the
-list of user's SSH keys.
-
-* Respect the 'Require a valid contributor agreement to upload' setting
-when creating changes via the UI.
-+
-If a user had not signed a CLA, it was still possible for them to create a new
-change with the 'Revert' or 'Cherry Pick' button.
-
-* Make Lucene index more stable when being interrupted.
-
-* Don't show the `start` and `idle` columns in the `show-connections`
-output when the ssh backend is NIO2.
-+
-The NIO2 backend doesn't provide the start and idle times, and the
-values being displayed were just dummy values. Now these values are
-only displayed for the MINA backend.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4150[Issue 4150]:
-Deleting a draft inline comment no longer causes the change's `Updated` field to
-be bumped.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4099[Issue 4099]:
-Fix SubmitWholeTopic does not update subscriptions.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3603[Issue 3603]:
-Fix editing a submodule via inline edit.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4069[Issue 4069]:
-Fix highlights in scrollbar overview ruler not moved when extending the
-displayed area.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3446[Issue 3446]:
-Respect the `Skip Deleted` diff preference.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3445[Issue 3445]:
-Respect the `Skip Uncommented` diff preference.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4051[Issue 4051]:
-Fix empty `From` email header.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3423[Issue 3423]:
-Fix intraline diff for added spaces.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=1867[Issue 1867]:
-Remove `no changes made` error case when the only difference between a new
-commit and the previous patch set of the change is the committer.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3831[Issue 3831]:
-Prevent creating groups with the same name as a system group.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=3754[Issue 3754]:
-Fix `View All Accounts` permission to allow accounts REST endpoint to access
-email info.
-
-* Make `gitweb.type` default to `disabled` when not explicitly set.
-+
-Previously the behavior was not documented and it would default to type
-`gitweb`. In cases where there was no gitweb config at all, this would
-result in broken links due to `null` being used as the URL.
-
-* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4488[Issue 4488]:
-Improve error message when `Change-Id` line is missing in commit message.
-+
-The error message now includes the sha1 of the commit, so that it is
-easier to track down which commit failed validation when multiple commits
-are pushed at the same time.
-
-* Don't check mergeability of draft changes.
-+
-Draft changes can be deleted but not abandoned so there is no way for
-an administrator to get rid of the them on behalf of the users. This can
-become a problem when there many draft changes because the mergeability
-check can be costly.
-+
-The mergeability check is no longer done for draft changes, but will be
-done when the draft change is published.
-
-* Fix internal server error when plugin-provided file history weblink
-is null.
-+
-It is valid for a plugin to provide a null weblink, but doing so resulted
-in an internal server error.
-
-== Dependency updates
-
-* Add dependency on blame-cache 0.1-9
-
-* Add dependency on guava-retrying 2.0.0
-
-* Add dependency on jsr305 3.0.1
-
-* Add dependency on metrics-core 3.1.2
-
-* Upgrade auto-value to 1.3-rc1
-
-* Upgrade commons-net to 3.5
-
-* Upgrade CodeMirror to 5.17.0
-
-* Upgrade Guava to 19.0
-
-* Upgrade Gson to 2.7
-
-* Upgrade Guice to 4.1.0
-
-* Upgrade gwtjsonrpc to 1.9
-
-* Upgrade gwtorm to 1.15
-
-* Upgrade javassist to 3.20.0-GA
-
-* Upgrade Jetty to 9.2.14.v20151106
-
-* Upgrade JGit to 4.5.0.201609210915-r
-
-* Upgrade joda-convert to 1.8.1
-
-* Upgrade joda-time to 2.9.4
-
-* Upgrade Lucene to 5.5.0
-
-* Upgrade mina to 2.0.10
-
-* Upgrade sshd-core to 1.2.0
+Release notes have been moved to the project homepage:
+link:https://www.gerritcodereview.com/releases/2.13.md[
+Release notes for Gerrit 2.13].
diff --git a/ReleaseNotes/config.defs b/ReleaseNotes/config.defs
deleted file mode 100644
index 86b7603..0000000
--- a/ReleaseNotes/config.defs
+++ /dev/null
@@ -1,14 +0,0 @@
-def release_notes_attributes():
-  return [
-    'toc',
-    'newline="\\n"',
-    'asterisk="&#42;"',
-    'plus="&#43;"',
-    'caret="&#94;"',
-    'startsb="&#91;"',
-    'endsb="&#93;"',
-    'tilde="&#126;"',
-    'last-update-label!',
-    'stylesheet=DEFAULT',
-    'linkcss=true',
-  ]
diff --git a/ReleaseNotes/index.txt b/ReleaseNotes/index.txt
index bba07dc..79d0827 100644
--- a/ReleaseNotes/index.txt
+++ b/ReleaseNotes/index.txt
@@ -2,18 +2,18 @@
 
 [[s2_13]]
 == Version 2.13.x
-* link:ReleaseNotes-2.13.2.html[2.13.2]
-* link:ReleaseNotes-2.13.1.html[2.13.1]
-* link:ReleaseNotes-2.13.html[2.13]
+* link:https://www.gerritcodereview.com/releases/2.13.md#2.13.2[2.13.2]
+* link:https://www.gerritcodereview.com/releases/2.13.md#2.13.1[2.13.1]
+* link:https://www.gerritcodereview.com/releases/2.13.md[2.13]
 
 [[s2_12]]
 == Version 2.12.x
-* link:ReleaseNotes-2.12.5.html[2.12.5]
-* link:ReleaseNotes-2.12.4.html[2.12.4]
-* link:ReleaseNotes-2.12.3.html[2.12.3]
-* link:ReleaseNotes-2.12.2.html[2.12.2]
-* link:ReleaseNotes-2.12.1.html[2.12.1]
-* link:ReleaseNotes-2.12.html[2.12]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.5[2.12.5]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.4[2.12.4]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.3[2.12.3]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.2[2.12.2]
+* link:https://www.gerritcodereview.com/releases/2.12.md#2.12.1[2.12.1]
+* link:https://www.gerritcodereview.com/releases/2.12.md[2.12]
 
 [[s2_11]]
 == Version 2.11.x
diff --git a/VERSION b/VERSION
deleted file mode 100644
index 8bcf86b..0000000
--- a/VERSION
+++ /dev/null
@@ -1,5 +0,0 @@
-# Maven style API version (e.g. '2.x-SNAPSHOT').
-# Used by :api_install and :api_deploy targets
-# when talking to the destination repository.
-#
-GERRIT_VERSION = '2.13.14'
diff --git a/WORKSPACE b/WORKSPACE
index e9ad5e1..49060bc 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,729 +1,1077 @@
-ANTLR_VERS = '3.5.2'
+workspace(name = "gerrit")
 
-maven_jar(
-  name = 'java_runtime',
-  artifact = 'org.antlr:antlr-runtime:' + ANTLR_VERS,
-  sha1 = 'cd9cd41361c155f3af0f653009dcecb08d8b4afd',
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
+load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
+load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
+
+http_archive(
+    name = "bazel_toolchains",
+    sha256 = "88e818f9f03628eef609c8429c210ecf265ffe46c2af095f36c7ef8b1855fef5",
+    strip_prefix = "bazel-toolchains-92dd8a7a518a2fb7ba992d47c8b38299fe0be825",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
+        "https://github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
+    ],
 )
 
-maven_jar(
-  name = 'stringtemplate',
-  artifact = 'org.antlr:stringtemplate:4.0.2',
-  sha1 = 'e28e09e2d44d60506a7bcb004d6c23ff35c6ac08',
+load("@bazel_toolchains//rules:rbe_repo.bzl", "rbe_autoconfig")
+
+# Creates a default toolchain config for RBE.
+# Use this as is if you are using the rbe_ubuntu16_04 container,
+# otherwise refer to RBE docs.
+rbe_autoconfig(name = "rbe_default")
+
+http_archive(
+    name = "bazel_skylib",
+    sha256 = "2ea8a5ed2b448baf4a6855d3ce049c4c452a6470b1efd1504fdb7c1c134d220a",
+    strip_prefix = "bazel-skylib-0.8.0",
+    urls = ["https://github.com/bazelbuild/bazel-skylib/archive/0.8.0.tar.gz"],
 )
 
-maven_jar(
-  name = 'org_antlr',
-  artifact = 'org.antlr:antlr:' + ANTLR_VERS,
-  sha1 = 'c4a65c950bfc3e7d04309c515b2177c00baf7764',
+http_archive(
+    name = "io_bazel_rules_closure",
+    sha256 = "03c3b16f205085817fd89cfdcb2220a0138647ee7992be9cef291b069dd90301",
+    strip_prefix = "rules_closure-196a45f0ede2faec11dcc6c60fbc5e7471f4bd58",
+    urls = ["https://github.com/bazelbuild/rules_closure/archive/196a45f0ede2faec11dcc6c60fbc5e7471f4bd58.tar.gz"],
 )
 
-maven_jar(
-  name = 'antlr27',
-  artifact = 'antlr:antlr:2.7.7',
-  sha1 = '83cd2cd674a217ade95a4bb83a8a14f351f48bd0',
-)
-
-GUICE_VERS = '4.0'
-
-maven_jar(
-  name = 'guice_library',
-  artifact = 'com.google.inject:guice:' + GUICE_VERS,
-  sha1 = '0f990a43d3725781b6db7cd0acf0a8b62dfd1649',
-)
-
-maven_jar(
-  name = 'guice_assistedinject',
-  artifact = 'com.google.inject.extensions:guice-assistedinject:' + GUICE_VERS,
-  sha1 = '8fa6431da1a2187817e3e52e967535899e2e46ca',
-)
-
-maven_jar(
-  name = 'guice_servlet',
-  artifact = 'com.google.inject.extensions:guice-servlet:' + GUICE_VERS,
-  sha1 = '4503da866f4c402b5090579b40c1c4aaefabb164',
-)
-
-maven_jar(
-  name = 'aopalliance',
-  artifact = 'aopalliance:aopalliance:1.0',
-  sha1 = '0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8',
-)
-
-maven_jar(
-  name = 'javax_inject',
-  artifact = 'javax.inject:javax.inject:1',
-  sha1 = '6975da39a7040257bd51d21a231b76c915872d38',
-)
-
-maven_jar(
-  name = 'servlet_api_3_1',
-  artifact = 'org.apache.tomcat:tomcat-servlet-api:8.0.24',
-  sha1 = '5d9e2e895e3111622720157d0aa540066d5fce3a',
-)
-
-GWT_VERS = '2.8.0'
-
-maven_jar(
-  name = 'user',
-  artifact = 'com.google.gwt:gwt-user:' + GWT_VERS,
-  sha1 = '518579870499e15531f454f35dca0772d7fa31f7',
-)
-
-maven_jar(
-  name = 'dev',
-  artifact = 'com.google.gwt:gwt-dev:' + GWT_VERS,
-  sha1 = 'f160a61272c5ebe805cd2d3d3256ed3ecf14893f',
-)
-
-maven_jar(
-  name = 'javax_validation',
-  artifact = 'javax.validation:validation-api:1.0.0.GA',
-  sha1 = 'b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e',
-)
-
-maven_jar(
-  name = 'jsinterop_annotations',
-  artifact = 'com.google.jsinterop:jsinterop-annotations:1.0.0',
-  sha1 = '23c3a3c060ffe4817e67673cc8294e154b0a4a95',
-)
-
-maven_jar(
-  name = 'ant',
-  artifact = 'ant:ant:1.6.5',
-  sha1 = '7d18faf23df1a5c3a43613952e0e8a182664564b',
-)
-
-maven_jar(
-  name = 'colt',
-  artifact = 'colt:colt:1.2.0',
-  sha1 = '0abc984f3adc760684d49e0f11ddf167ba516d4f',
-)
-
-maven_jar(
-  name = 'tapestry',
-  artifact = 'tapestry:tapestry:4.0.2',
-  sha1 = 'e855a807425d522e958cbce8697f21e9d679b1f7',
-)
-
-maven_jar(
-  name = 'w3c_css_sac',
-  artifact = 'org.w3c.css:sac:1.3',
-  sha1 = 'cdb2dcb4e22b83d6b32b93095f644c3462739e82',
-)
-
-JGIT_VERS = '4.4.1.201607150455-r.105-g81ba2be'
-
-maven_jar(
-  name = 'jgit',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'org.eclipse.jgit:org.eclipse.jgit:' + JGIT_VERS,
-  sha1 = 'c07c9c66da7983095a40945c0bfab211a473c4c5',
-)
-
-maven_jar(
-  name = 'jgit_servlet',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + JGIT_VERS,
-  sha1 = 'bb01841b74a48abe506c2e44f238e107188e6c8f',
-)
-
-# TODO(davido): Remove this hack when maven_jar supports pulling sources
-# https://github.com/bazelbuild/bazel/issues/308
+# File is specific to Polymer and copied from the Closure Github -- should be
+# synced any time there are major changes to Polymer.
+# https://github.com/google/closure-compiler/blob/master/contrib/externs/polymer-1.0.js
 http_file(
-  name = 'jgit_src',
-  sha256 = '881906cb1e6743cb78df6dd3788cab7e974308fbb98cab4915e6591a62aa9374',
-  url = 'http://gerrit-maven.storage.googleapis.com/org/eclipse/jgit/org.eclipse.jgit/' +
-      '%s/org.eclipse.jgit-%s-sources.jar' % (JGIT_VERS, JGIT_VERS),
+    name = "polymer_closure",
+    downloaded_file_path = "polymer_closure.js",
+    sha256 = "5a589bdba674e1fec7188e9251c8624ebf2d4d969beb6635f9148f420d1e08b1",
+    urls = ["https://raw.githubusercontent.com/google/closure-compiler/775609aad61e14aef289ebec4bfc09ad88877f9e/contrib/externs/polymer-1.0.js"],
+)
+
+# Check Bazel version when invoked by Bazel directly
+load("//tools/bzl:bazelisk_version.bzl", "bazelisk_version")
+
+bazelisk_version(name = "bazelisk_version")
+
+load("@bazelisk_version//:check.bzl", "check_bazel_version")
+
+check_bazel_version()
+
+load("@io_bazel_rules_closure//closure:repositories.bzl", "rules_closure_dependencies", "rules_closure_toolchains")
+
+# Prevent redundant loading of dependencies.
+rules_closure_dependencies(
+    omit_aopalliance = True,
+    omit_args4j = True,
+    omit_bazel_skylib = True,
+    omit_javax_inject = True,
+    omit_rules_cc = True,
 )
 
+rules_closure_toolchains()
+
+# This has to be done after loading of rules_closure, because it loads rules_java
+load("//lib/codemirror:cm.bzl", "CM_VERSION", "DIFF_MATCH_PATCH_VERSION")
+
+ANTLR_VERS = "3.5.2"
+
 maven_jar(
-  name = 'ewah',
-  artifact = 'com.googlecode.javaewah:JavaEWAH:0.7.9',
-  sha1 = 'eceaf316a8faf0e794296ebe158ae110c7d72a5a',
+    name = "java-runtime",
+    artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS,
+    sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd",
 )
 
 maven_jar(
-  name = 'jgit_archive',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + JGIT_VERS,
-  sha1 = 'fc3bc40e070c54198a046fcd3a1f7cac47163961',
+    name = "stringtemplate",
+    artifact = "org.antlr:stringtemplate:4.0.2",
+    sha1 = "e28e09e2d44d60506a7bcb004d6c23ff35c6ac08",
 )
 
 maven_jar(
-  name = 'jgit_junit',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + JGIT_VERS,
-  sha1 = 'b4565ee84a6e1d0952010282b9fcf705ac6171a7',
+    name = "org-antlr",
+    artifact = "org.antlr:antlr:" + ANTLR_VERS,
+    sha1 = "c4a65c950bfc3e7d04309c515b2177c00baf7764",
 )
 
 maven_jar(
-  name = 'gwtjsonrpc',
-  artifact = 'com.google.gerrit:gwtjsonrpc:1.10',
-  sha1 = '25adea6ef102b761993688e80dfc7203e0f5edf0',
+    name = "antlr27",
+    artifact = "antlr:antlr:2.7.7",
+    attach_source = False,
+    sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
 )
 
-http_jar(
-  name = 'gwtjsonrpc_src',
-  sha256 = '009c4c7574eaddf49d2c72dd015cfbd5b495fbeea4c3958c2ec548af2c186733',
-  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtjsonrpc/1.10/gwtjsonrpc-1.10-sources.jar',
+GUICE_VERS = "4.2.0"
+
+maven_jar(
+    name = "guice-library",
+    artifact = "com.google.inject:guice:" + GUICE_VERS,
+    sha1 = "25e1f4c1d528a1cffabcca0d432f634f3132f6c8",
 )
 
 maven_jar(
-  name = 'gson',
-  artifact = 'com.google.code.gson:gson:2.6.2',
-  sha1 = 'f1bc476cc167b18e66c297df599b2377131a8947',
+    name = "guice-assistedinject",
+    artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
+    sha1 = "e7270305960ad7db56f7e30cb9df6be9ff1cfb45",
 )
 
 maven_jar(
-  name = 'gwtorm_client',
-  artifact = 'com.google.gerrit:gwtorm:1.15',
-  sha1 = '26a2459f543ed78977535f92e379dc0d6cdde8bb',
+    name = "guice-servlet",
+    artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
+    sha1 = "f57581625c36c148f088d9f52a568d5bdf12c61d",
 )
 
-http_jar(
-  name = 'gwtorm_client_src',
-  sha256 = 'e0cf9382ed8c3cd1f0884ab77dabe634a04546676c4960d8b4c4b64a20132ef6',
-  url = 'http://repo.maven.apache.org/maven2/com/google/gerrit/gwtorm/1.15/gwtorm-1.15-sources.jar',
+maven_jar(
+    name = "aopalliance",
+    artifact = "aopalliance:aopalliance:1.0",
+    sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
 )
 
 maven_jar(
-  name = 'protobuf',
-  artifact = 'com.google.protobuf:protobuf-java:2.5.0',
-  sha1 = 'a10732c76bfacdbd633a7eb0f7968b1059a65dfa',
+    name = "javax_inject",
+    artifact = "javax.inject:javax.inject:1",
+    sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
 )
 
 maven_jar(
-  name = 'joda_time',
-  artifact = 'joda-time:joda-time:2.8',
-  sha1 = '9f2785d7184b97d005a44241ccaf980f43b9ccdb',
+    name = "servlet-api-3_1",
+    artifact = "org.apache.tomcat:tomcat-servlet-api:8.0.24",
+    sha1 = "5d9e2e895e3111622720157d0aa540066d5fce3a",
 )
 
+GWT_VERS = "2.8.2"
+
 maven_jar(
-  name = 'joda_convert',
-  artifact = 'org.joda:joda-convert:1.2',
-  sha1 = '35ec554f0cd00c956cc69051514d9488b1374dec',
+    name = "user",
+    artifact = "com.google.gwt:gwt-user:" + GWT_VERS,
+    sha1 = "a2b9be2c996a658c4e009ba652a9c6a81c88a797",
 )
 
 maven_jar(
-  name = 'guava',
-  artifact = 'com.google.guava:guava:19.0',
-  sha1 = '6ce200f6b23222af3d8abb6b6459e6c44f4bb0e9',
+    name = "dev",
+    artifact = "com.google.gwt:gwt-dev:" + GWT_VERS,
+    sha1 = "7a87e060bbf129386b7ae772459fb9f87297c332",
 )
 
 maven_jar(
-  name = 'velocity',
-  artifact = 'org.apache.velocity:velocity:1.7',
-  sha1 = '2ceb567b8f3f21118ecdec129fe1271dbc09aa7a',
+    name = "javax-validation",
+    artifact = "javax.validation:validation-api:1.0.0.GA",
+    sha1 = "b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e",
+    src_sha1 = "7a561191db2203550fbfa40d534d4997624cd369",
 )
 
 maven_jar(
-  name = 'jsch',
-  artifact = 'com.jcraft:jsch:0.1.53',
-  sha1 = '658b682d5c817b27ae795637dfec047c63d29935',
+    name = "jsinterop-annotations",
+    artifact = "com.google.jsinterop:jsinterop-annotations:1.0.2",
+    sha1 = "abd7319f53d018e11108a88f599bd16492448dd2",
+    src_sha1 = "33716f8aef043f2f02b78ab4a1acda6cd90a7602",
 )
 
 maven_jar(
-  name = 'juniversalchardet',
-  artifact = 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3',
-  sha1 = 'cd49678784c46aa8789c060538e0154013bb421b',
+    name = "ant",
+    artifact = "ant:ant:1.6.5",
+    attach_source = False,
+    sha1 = "7d18faf23df1a5c3a43613952e0e8a182664564b",
 )
 
-SLF4J_VERS = '1.7.7'
+maven_jar(
+    name = "colt",
+    artifact = "colt:colt:1.2.0",
+    attach_source = False,
+    sha1 = "0abc984f3adc760684d49e0f11ddf167ba516d4f",
+)
 
 maven_jar(
-  name = 'log_api',
-  artifact = 'org.slf4j:slf4j-api:' + SLF4J_VERS,
-  sha1 = '2b8019b6249bb05d81d3a3094e468753e2b21311',
+    name = "tapestry",
+    artifact = "tapestry:tapestry:4.0.2",
+    attach_source = False,
+    sha1 = "e855a807425d522e958cbce8697f21e9d679b1f7",
 )
 
 maven_jar(
-  name = 'log_nop',
-  artifact = 'org.slf4j:slf4j-nop:' + SLF4J_VERS,
-  sha1 = '6cca9a3b999ff28b7a35ca762b3197cd7e4c2ad1',
+    name = "w3c-css-sac",
+    artifact = "org.w3c.css:sac:1.3",
+    sha1 = "cdb2dcb4e22b83d6b32b93095f644c3462739e82",
 )
 
+load("//lib/jgit:jgit.bzl", "jgit_repos")
+
+jgit_repos()
+
 maven_jar(
-  name = 'impl_log4j',
-  artifact = 'org.slf4j:slf4j-log4j12:' + SLF4J_VERS,
-  sha1 = '58f588119ffd1702c77ccab6acb54bfb41bed8bd',
+    name = "javaewah",
+    artifact = "com.googlecode.javaewah:JavaEWAH:1.1.6",
+    attach_source = False,
+    sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
 )
 
 maven_jar(
-  name = 'jcl_over_slf4j',
-  artifact = 'org.slf4j:jcl-over-slf4j:' + SLF4J_VERS,
-  sha1 = '56003dcd0a31deea6391b9e2ef2f2dc90b205a92',
+    name = "gwtjsonrpc",
+    artifact = "com.google.gerrit:gwtjsonrpc:1.11",
+    sha1 = "0990e7eec9eec3a15661edcf9232acbac4aeacec",
+    src_sha1 = "a682afc46284fb58197a173cb5818770a1e7834a",
 )
 
 maven_jar(
-  name = 'log4j',
-  artifact = 'log4j:log4j:1.2.17',
-  sha1 = '5af35056b4d257e4b64b9e8069c0746e8b08629f',
+    name = "gson",
+    artifact = "com.google.code.gson:gson:2.8.0",
+    sha1 = "c4ba5371a29ac9b2ad6129b1d39ea38750043eff",
 )
 
 maven_jar(
-  name = 'jsonevent_layout',
-  artifact = 'net.logstash.log4j:jsonevent-layout:1.7',
-  sha1 = '507713504f0ddb75ba512f62763519c43cf46fde',
+    name = "gwtorm-client",
+    artifact = "com.google.gerrit:gwtorm:1.18",
+    sha1 = "f326dec463439a92ccb32f05b38345e21d0b5ecf",
+    src_sha1 = "e0b973d5cafef3d145fa80cdf032fcead1186d29",
 )
 
 maven_jar(
-  name = 'json_smart',
-  artifact = 'net.minidev:json-smart:1.1.1',
-  sha1 = '24a2f903d25e004de30ac602c5b47f2d4e420a59',
+    name = "joda-time",
+    artifact = "joda-time:joda-time:2.9.4",
+    sha1 = "1c295b462f16702ebe720bbb08f62e1ba80da41b",
 )
 
 maven_jar(
-  name = 'args4j',
-  artifact = 'args4j:args4j:2.0.26',
-  sha1 = '01ebb18ebb3b379a74207d5af4ea7c8338ebd78b',
+    name = "joda-convert",
+    artifact = "org.joda:joda-convert:1.8.1",
+    sha1 = "675642ac208e0b741bc9118dcbcae44c271b992a",
 )
 
+load("//lib:guava.bzl", "GUAVA_BIN_SHA1", "GUAVA_VERSION")
+
 maven_jar(
-  name = 'commons_codec',
-  artifact = 'commons-codec:commons-codec:1.4',
-  sha1 = '4216af16d38465bbab0f3dff8efa14204f7a399a',
+    name = "guava",
+    artifact = "com.google.guava:guava:" + GUAVA_VERSION,
+    sha1 = GUAVA_BIN_SHA1,
 )
 
 maven_jar(
-  name = 'commons_collections',
-  artifact = 'commons-collections:commons-collections:3.2.2',
-  sha1 = '8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5',
+    name = "j2objc",
+    artifact = "com.google.j2objc:j2objc-annotations:1.1",
+    sha1 = "ed28ded51a8b1c6b112568def5f4b455e6809019",
+)
+
+maven_jar(
+    name = "velocity",
+    artifact = "org.apache.velocity:velocity:1.7",
+    sha1 = "2ceb567b8f3f21118ecdec129fe1271dbc09aa7a",
+)
+
+maven_jar(
+    name = "jsch",
+    artifact = "com.jcraft:jsch:0.1.54",
+    sha1 = "da3584329a263616e277e15462b387addd1b208d",
+)
+
+maven_jar(
+    name = "juniversalchardet",
+    artifact = "com.googlecode.juniversalchardet:juniversalchardet:1.0.3",
+    sha1 = "cd49678784c46aa8789c060538e0154013bb421b",
+)
+
+SLF4J_VERS = "1.7.26"
+
+maven_jar(
+    name = "log-api",
+    artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
+    sha1 = "77100a62c2e6f04b53977b9f541044d7d722693d",
+)
+
+maven_jar(
+    name = "log-nop",
+    artifact = "org.slf4j:slf4j-nop:" + SLF4J_VERS,
+    sha1 = "6e211fdfb9a8723677031b95ac075ac54c879a0e",
+)
+
+maven_jar(
+    name = "log-ext",
+    artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
+    sha1 = "31cdf122e000322e9efcb38913e9ab07825b17ef",
+)
+
+maven_jar(
+    name = "impl-log4j",
+    artifact = "org.slf4j:slf4j-log4j12:" + SLF4J_VERS,
+    sha1 = "12f5c685b71c3027fd28bcf90528ec4ec74bf818",
+)
+
+maven_jar(
+    name = "jcl-over-slf4j",
+    artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
+    sha1 = "33fbc2d93de829fa5e263c5ce97f5eab8f57d53e",
+)
+
+maven_jar(
+    name = "log4j",
+    artifact = "log4j:log4j:1.2.17",
+    sha1 = "5af35056b4d257e4b64b9e8069c0746e8b08629f",
+)
+
+maven_jar(
+    name = "jsonevent-layout",
+    artifact = "net.logstash.log4j:jsonevent-layout:1.7",
+    sha1 = "507713504f0ddb75ba512f62763519c43cf46fde",
+)
+
+maven_jar(
+    name = "json-smart",
+    artifact = "net.minidev:json-smart:1.1.1",
+    sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
+)
+
+maven_jar(
+    name = "args4j",
+    artifact = "args4j:args4j:2.0.26",
+    sha1 = "01ebb18ebb3b379a74207d5af4ea7c8338ebd78b",
+)
+
+maven_jar(
+    name = "commons-codec",
+    artifact = "commons-codec:commons-codec:1.4",
+    sha1 = "4216af16d38465bbab0f3dff8efa14204f7a399a",
+)
+
+maven_jar(
+    name = "commons-collections",
+    artifact = "commons-collections:commons-collections:3.2.2",
+    sha1 = "8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5",
+)
+
+maven_jar(
+    name = "commons-compress",
+    artifact = "org.apache.commons:commons-compress:1.12",
+    sha1 = "84caa68576e345eb5e7ae61a0e5a9229eb100d7b",
+)
+
+maven_jar(
+    name = "commons-lang",
+    artifact = "commons-lang:commons-lang:2.6",
+    sha1 = "0ce1edb914c94ebc388f086c6827e8bdeec71ac2",
+)
+
+maven_jar(
+    name = "commons-dbcp",
+    artifact = "commons-dbcp:commons-dbcp:1.4",
+    sha1 = "30be73c965cc990b153a100aaaaafcf239f82d39",
+)
+
+maven_jar(
+    name = "commons-pool",
+    artifact = "commons-pool:commons-pool:1.5.5",
+    sha1 = "7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b",
+)
+
+maven_jar(
+    name = "commons-net",
+    artifact = "commons-net:commons-net:3.5",
+    sha1 = "342fc284019f590e1308056990fdb24a08f06318",
+)
+
+maven_jar(
+    name = "commons-oro",
+    artifact = "oro:oro:2.0.8",
+    sha1 = "5592374f834645c4ae250f4c9fbb314c9369d698",
 )
 
 maven_jar(
-  name = 'commons_compress',
-  artifact = 'org.apache.commons:commons-compress:1.7',
-  sha1 = 'ab365c96ee9bc88adcc6fa40d185c8e15a31410d',
+    name = "commons-validator",
+    artifact = "commons-validator:commons-validator:1.5.1",
+    sha1 = "86d05a46e8f064b300657f751b5a98c62807e2a0",
 )
 
 maven_jar(
-  name = 'commons_lang',
-  artifact = 'commons-lang:commons-lang:2.6',
-  sha1 = '0ce1edb914c94ebc388f086c6827e8bdeec71ac2',
+    name = "automaton",
+    artifact = "dk.brics.automaton:automaton:1.11-8",
+    sha1 = "6ebfa65eb431ff4b715a23be7a750cbc4cc96d0f",
 )
 
 maven_jar(
-  name = 'commons_dbcp',
-  artifact = 'commons-dbcp:commons-dbcp:1.4',
-  sha1 = '30be73c965cc990b153a100aaaaafcf239f82d39',
+    name = "pegdown",
+    artifact = "org.pegdown:pegdown:1.4.2",
+    sha1 = "d96db502ed832df867ff5d918f05b51ba3879ea7",
 )
 
 maven_jar(
-  name = 'commons_pool',
-  artifact = 'commons-pool:commons-pool:1.5.5',
-  sha1 = '7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b',
+    name = "grappa",
+    artifact = "com.github.parboiled1:grappa:1.0.4",
+    sha1 = "ad4b44b9c305dad7aa1e680d4b5c8eec9c4fd6f5",
 )
 
 maven_jar(
-  name = 'commons_net',
-  artifact = 'commons-net:commons-net:2.2',
-  sha1 = '07993c12f63c78378f8c90de4bc2ee62daa7ca3a',
+    name = "jitescript",
+    artifact = "me.qmx.jitescript:jitescript:0.4.0",
+    sha1 = "2e35862b0435c1b027a21f3d6eecbe50e6e08d54",
 )
 
+GREENMAIL_VERS = "1.5.3"
+
 maven_jar(
-  name = 'commons_oro',
-  artifact = 'oro:oro:2.0.8',
-  sha1 = '5592374f834645c4ae250f4c9fbb314c9369d698',
+    name = "greenmail",
+    artifact = "com.icegreen:greenmail:" + GREENMAIL_VERS,
+    sha1 = "afabf8178312f7f220f74f1558e457bf54fa4253",
 )
 
+MAIL_VERS = "1.5.6"
+
 maven_jar(
-  name = 'commons_validator',
-  artifact = 'commons-validator:commons-validator:1.5.1',
-  sha1 = '86d05a46e8f064b300657f751b5a98c62807e2a0',
+    name = "mail",
+    artifact = "com.sun.mail:javax.mail:" + MAIL_VERS,
+    sha1 = "ab5daef2f881c42c8e280cbe918ec4d7fdfd7efe",
 )
 
+MIME4J_VERS = "0.8.0"
+
 maven_jar(
-  name = 'automaton',
-  artifact = 'dk.brics.automaton:automaton:1.11-8',
-  sha1 = '6ebfa65eb431ff4b715a23be7a750cbc4cc96d0f',
+    name = "mime4j-core",
+    artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS,
+    sha1 = "d54f45fca44a2f210569656b4ca3574b42911c95",
 )
 
 maven_jar(
-  name = 'pegdown',
-  artifact = 'org.pegdown:pegdown:1.4.2',
-  sha1 = 'd96db502ed832df867ff5d918f05b51ba3879ea7',
+    name = "mime4j-dom",
+    artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS,
+    sha1 = "6720c93d14225c3e12c4a69768a0370c80e376a3",
 )
 
 maven_jar(
-  name = 'grappa',
-  artifact = 'com.github.parboiled1:grappa:1.0.4',
-  sha1 = 'ad4b44b9c305dad7aa1e680d4b5c8eec9c4fd6f5',
+    name = "jsoup",
+    artifact = "org.jsoup:jsoup:1.9.2",
+    sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
+OW2_VERS = "5.1"
+
 maven_jar(
-  name = 'jitescript',
-  artifact = 'me.qmx.jitescript:jitescript:0.4.0',
-  sha1 = '2e35862b0435c1b027a21f3d6eecbe50e6e08d54',
+    name = "ow2-asm",
+    artifact = "org.ow2.asm:asm:" + OW2_VERS,
+    sha1 = "5ef31c4fe953b1fd00b8a88fa1d6820e8785bb45",
 )
 
-OW2_VERS = '5.0.3'
+maven_jar(
+    name = "ow2-asm-analysis",
+    artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
+    sha1 = "6d1bf8989fc7901f868bee3863c44f21aa63d110",
+)
 
 maven_jar(
-  name = 'ow2_asm',
-  artifact = 'org.ow2.asm:asm:' + OW2_VERS,
-  sha1 = 'dcc2193db20e19e1feca8b1240dbbc4e190824fa',
+    name = "ow2-asm-commons",
+    artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
+    sha1 = "25d8a575034dd9cfcb375a39b5334f0ba9c8474e",
 )
 
 maven_jar(
-  name = 'ow2_asm_analysis',
-  artifact = 'org.ow2.asm:asm-analysis:' + OW2_VERS,
-  sha1 = 'c7126aded0e8e13fed5f913559a0dd7b770a10f3',
+    name = "ow2-asm-tree",
+    artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
+    sha1 = "87b38c12a0ea645791ead9d3e74ae5268d1d6c34",
 )
 
 maven_jar(
-  name = 'ow2_asm_commons',
-  artifact = 'org.ow2.asm:asm-commons:' + OW2_VERS,
-  sha1 = 'a7111830132c7f87d08fe48cb0ca07630f8cb91c',
+    name = "ow2-asm-util",
+    artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
+    sha1 = "b60e33a6bd0d71831e0c249816d01e6c1dd90a47",
 )
 
+AUTO_VALUE_VERSION = "1.6.2"
+
 maven_jar(
-  name = 'ow2_asm_tree',
-  artifact = 'org.ow2.asm:asm-tree:' + OW2_VERS,
-  sha1 = '287749b48ba7162fb67c93a026d690b29f410bed',
+    name = "auto-value",
+    artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+    sha1 = "e7eae562942315a983eea3e191b72d755c153620",
 )
 
 maven_jar(
-  name = 'ow2_asm_util',
-  artifact = 'org.ow2.asm:asm-util:' + OW2_VERS,
-  sha1 = '1512e5571325854b05fb1efce1db75fcced54389',
+    name = "auto-value-annotations",
+    artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+    sha1 = "ed193d86e0af90cc2342aedbe73c5d86b03fa09b",
 )
 
 maven_jar(
-  name = 'auto_value',
-  artifact = 'com.google.auto.value:auto-value:1.2',
-  sha1 = '6873fed014fe1de1051aae2af68ba266d2934471',
+    name = "tukaani-xz",
+    artifact = "org.tukaani:xz:1.4",
+    sha1 = "18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3",
 )
 
+LUCENE_VERS = "5.5.5"
+
 maven_jar(
-  name = 'tukaani_xz',
-  artifact = 'org.tukaani:xz:1.4',
-  sha1 = '18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3',
+    name = "lucene-core",
+    artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
+    sha1 = "c34bcd9274859dc07cfed2a935aaca90c4f4b861",
 )
 
-LUCENE_VERS = '5.4.1'
+maven_jar(
+    name = "lucene-analyzers-common",
+    artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
+    sha1 = "e6b3f5d1b33ed24da7eef0a72f8062bd4652700c",
+)
 
 maven_jar(
-  name = 'lucene_core',
-  artifact = 'org.apache.lucene:lucene-core:' + LUCENE_VERS,
-  sha1 = 'c52b2088e2c30dfd95fd296ab6fb9cf8de9855ab',
+    name = "backward-codecs",
+    artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
+    sha1 = "d1dee5c7676a313758adb30d7b0bd4c69a4cd214",
 )
 
 maven_jar(
-  name = 'lucene_analyzers_common',
-  artifact = 'org.apache.lucene:lucene-analyzers-common:' + LUCENE_VERS,
-  sha1 = 'c2aa2c4e00eb9cdeb5ac00dc0495e70c441f681e',
+    name = "lucene-misc",
+    artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
+    sha1 = "bc0eb46ba0377594cac7b0cdaab35562d7877521",
 )
 
 maven_jar(
-  name = 'backward_codecs',
-  artifact = 'org.apache.lucene:lucene-backward-codecs:' + LUCENE_VERS,
-  sha1 = '5273da96380dfab302ad06c27fe58100db4c4e2f',
+    name = "lucene-queryparser",
+    artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
+    sha1 = "6c965eb5838a2ba58b0de0fd860a420dcda11937",
 )
 
 maven_jar(
-  name = 'lucene_misc',
-  artifact = 'org.apache.lucene:lucene-misc:' + LUCENE_VERS,
-  sha1 = '95f433b9d7dd470cc0aa5076e0f233907745674b',
+    name = "mime-util",
+    artifact = "eu.medsea.mimeutil:mime-util:2.1.3",
+    attach_source = False,
+    sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47",
 )
 
+PROLOG_VERS = "1.4.3"
+
+PROLOG_REPO = GERRIT
+
 maven_jar(
-  name = 'lucene_queryparser',
-  artifact = 'org.apache.lucene:lucene-queryparser:' + LUCENE_VERS,
-  sha1 = 'dccd5279bfa656dec21af444a7a66820eb1cd618',
+    name = "prolog-runtime",
+    artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS,
+    attach_source = False,
+    repository = PROLOG_REPO,
+    sha1 = "d5206556cbc76ffeab21313ffc47b586a1efbcbb",
 )
 
 maven_jar(
-  name = 'mime_util',
-  artifact = 'eu.medsea.mimeutil:mime-util:2.1.3',
-  sha1 = '0c9cfae15c74f62491d4f28def0dff1dabe52a47',
+    name = "prolog-compiler",
+    artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS,
+    attach_source = False,
+    repository = PROLOG_REPO,
+    sha1 = "f37032cf1dec3e064427745bc59da5a12757a3b2",
 )
 
-PROLOG_VERS = '1.4.1'
+maven_jar(
+    name = "prolog-io",
+    artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS,
+    attach_source = False,
+    repository = PROLOG_REPO,
+    sha1 = "d02b2640b26f64036b6ba2b45e4acc79281cea17",
+)
 
 maven_jar(
-  name = 'prolog_runtime',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'com.googlecode.prolog-cafe:prolog-runtime:' + PROLOG_VERS,
-  sha1 = 'c5d9f92e49c485969dcd424dfc0c08125b5f8246',
+    name = "cafeteria",
+    artifact = "com.googlecode.prolog-cafe:prolog-cafeteria:" + PROLOG_VERS,
+    attach_source = False,
+    repository = PROLOG_REPO,
+    sha1 = "e3b1860c63e57265e5435f890263ad82dafa724f",
 )
 
 maven_jar(
-  name = 'prolog_compiler',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'com.googlecode.prolog-cafe:prolog-compiler:' + PROLOG_VERS,
-  sha1 = 'ac24044c6ec166fdcb352b78b80d187ead3eff41',
+    name = "guava-retrying",
+    artifact = "com.github.rholder:guava-retrying:2.0.0",
+    sha1 = "974bc0a04a11cc4806f7c20a34703bd23c34e7f4",
 )
 
 maven_jar(
-  name = 'prolog_io',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'com.googlecode.prolog-cafe:prolog-io:' + PROLOG_VERS,
-  sha1 = 'b072426a4b1b8af5e914026d298ee0358a8bb5aa',
+    name = "jsr305",
+    artifact = "com.google.code.findbugs:jsr305:3.0.1",
+    sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
 )
 
 maven_jar(
-  name = 'cafeteria',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'com.googlecode.prolog-cafe:prolog-cafeteria:' + PROLOG_VERS,
-  sha1 = '8cbc3b0c19e7167c42d3f11667b21cb21ddec641',
+    name = "blame-cache",
+    artifact = "com/google/gitiles:blame-cache:0.2-1",
+    attach_source = False,
+    repository = GERRIT,
+    sha1 = "da7977e8b140b63f18054214c1d1b86ffa6896cb",
 )
 
+# Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
-  name = 'guava_retrying',
-  artifact = 'com.github.rholder:guava-retrying:2.0.0',
-  sha1 = '974bc0a04a11cc4806f7c20a34703bd23c34e7f4',
+    name = "soy",
+    artifact = "com.google.template:soy:2017-02-01",
+    sha1 = "8638940b207779fe3b75e55b6e65abbefb6af678",
 )
 
 maven_jar(
-  name = 'jsr305',
-  artifact = 'com.google.code.findbugs:jsr305:2.0.2',
-  sha1 = '516c03b21d50a644d538de0f0369c620989cd8f0',
+    name = "html-types",
+    artifact = "com.google.common.html.types:types:1.0.4",
+    sha1 = "2adf4c8bfccc0ff7346f9186ac5aa57d829ad065",
 )
 
 maven_jar(
-  name = 'blame_cache',
-  repository = 'http://gerrit-maven.storage.googleapis.com/',
-  artifact = 'com/google/gitiles:blame-cache:0.1-9',
-  sha1 = '51d35e6f8bbc2412265066cea9653dd758c95826',
+    name = "icu4j",
+    artifact = "com.ibm.icu:icu4j:57.1",
+    sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
 )
 
 maven_jar(
-  name = 'dropwizard_core',
-  artifact = 'io.dropwizard.metrics:metrics-core:3.1.2',
-  sha1 = '224f03afd2521c6c94632f566beb1bb5ee32cf07',
+    name = "dropwizard-core",
+    artifact = "io.dropwizard.metrics:metrics-core:4.0.3",
+    sha1 = "bb562ee73f740bb6b2bf7955f97be6b870d9e9f0",
 )
 
-# This version must match the version that also appears in
-# gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
-BC_VERS = '1.52'
+# When updating Bouncy Castle, also update it in bazlets.
+BC_VERS = "1.60"
+
+maven_jar(
+    name = "bcprov",
+    artifact = "org.bouncycastle:bcprov-jdk15on:" + BC_VERS,
+    sha1 = "bd47ad3bd14b8e82595c7adaa143501e60842a84",
+)
 
 maven_jar(
-  name = 'bcprov',
-  artifact = 'org.bouncycastle:bcprov-jdk15on:' + BC_VERS,
-  sha1 = '88a941faf9819d371e3174b5ed56a3f3f7d73269',
+    name = "bcpg",
+    artifact = "org.bouncycastle:bcpg-jdk15on:" + BC_VERS,
+    sha1 = "13c7a199c484127daad298996e95818478431a2c",
 )
 
 maven_jar(
-  name = 'bcpg',
-  artifact = 'org.bouncycastle:bcpg-jdk15on:' + BC_VERS,
-  sha1 = 'ff4665a4b5633ff6894209d5dd10b7e612291858',
+    name = "bcpkix",
+    artifact = "org.bouncycastle:bcpkix-jdk15on:" + BC_VERS,
+    sha1 = "d0c46320fbc07be3a24eb13a56cee4e3d38e0c75",
 )
 
+# TODO(davido): Remove exlusion of file system provider, when this issue is fixed:
+# https://issues.apache.org/jira/browse/SSHD-736
 maven_jar(
-  name = 'bcpkix',
-  artifact = 'org.bouncycastle:bcpkix-jdk15on:' + BC_VERS,
-  sha1 = 'b8ffac2bbc6626f86909589c8cc63637cc936504',
+    name = "sshd",
+    artifact = "org.apache.sshd:sshd-core:1.4.0",
+    exclude = ["META-INF/services/java.nio.file.spi.FileSystemProvider"],
+    sha1 = "c8f3d7457fc9979d1b9ec319f0229b89793c8e56",
 )
 
 maven_jar(
-  name = 'sshd',
-  artifact = 'org.apache.sshd:sshd-core:1.4.0',
-  sha1 = 'c8f3d7457fc9979d1b9ec319f0229b89793c8e56',
+    name = "eddsa",
+    artifact = "net.i2p.crypto:eddsa:0.1.0",
+    sha1 = "8f5a3b165164e222da048d8136b21428ee0b9122",
 )
 
 maven_jar(
-  name = 'mina_core',
-  artifact = 'org.apache.mina:mina-core:2.0.16',
-  sha1 = 'f720f17643eaa7b0fec07c1d7f6272972c02bba4',
+    name = "mina-core",
+    artifact = "org.apache.mina:mina-core:2.0.16",
+    sha1 = "f720f17643eaa7b0fec07c1d7f6272972c02bba4",
 )
 
 maven_jar(
-  name = 'h2',
-  artifact = 'com.h2database:h2:1.3.176',
-  sha1 = 'fd369423346b2f1525c413e33f8cf95b09c92cbd',
+    name = "h2",
+    artifact = "com.h2database:h2:1.3.176",
+    sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
 )
 
-HTTPCOMP_VERS = '4.4.1'
+HTTPCOMP_VERS = "4.4.1"
 
 maven_jar(
-  name = 'fluent_hc',
-  artifact = 'org.apache.httpcomponents:fluent-hc:' + HTTPCOMP_VERS,
-  sha1 = '96fb842b68a44cc640c661186828b60590c71261',
+    name = "fluent-hc",
+    artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS,
+    sha1 = "96fb842b68a44cc640c661186828b60590c71261",
 )
 
 maven_jar(
-  name = 'httpclient',
-  artifact = 'org.apache.httpcomponents:httpclient:' + HTTPCOMP_VERS,
-  sha1 = '016d0bc512222f1253ee6b64d389c84e22f697f0',
+    name = "httpclient",
+    artifact = "org.apache.httpcomponents:httpclient:" + HTTPCOMP_VERS,
+    sha1 = "016d0bc512222f1253ee6b64d389c84e22f697f0",
+)
+
+maven_jar(
+    name = "httpcore",
+    artifact = "org.apache.httpcomponents:httpcore:" + HTTPCOMP_VERS,
+    sha1 = "f5aa318bda4c6c8d688c9d00b90681dcd82ce636",
 )
 
+# elasticsearch-rest-client explicitly depends on this version
 maven_jar(
-  name = 'httpcore',
-  artifact = 'org.apache.httpcomponents:httpcore:' + HTTPCOMP_VERS,
-  sha1 = 'f5aa318bda4c6c8d688c9d00b90681dcd82ce636',
+    name = "httpasyncclient",
+    artifact = "org.apache.httpcomponents:httpasyncclient:4.1.2",
+    sha1 = "95aa3e6fb520191a0970a73cf09f62948ee614be",
 )
 
+# elasticsearch-rest-client explicitly depends on this version
 maven_jar(
-  name = 'httpmime',
-  artifact = 'org.apache.httpcomponents:httpmime:' + HTTPCOMP_VERS,
-  sha1 = '2f8757f5ac5e38f46c794e5229d1f3c522e9b1df',
+    name = "httpcore-nio",
+    artifact = "org.apache.httpcomponents:httpcore-nio:4.4.5",
+    sha1 = "f4be009e7505f6ceddf21e7960c759f413f15056",
 )
 
 # Test-only dependencies below.
 
 maven_jar(
-  name = 'jimfs',
-  artifact = 'com.google.jimfs:jimfs:1.0',
-  sha1 = 'edd65a2b792755f58f11134e76485a928aab4c97',
+    name = "jimfs",
+    artifact = "com.google.jimfs:jimfs:1.1",
+    sha1 = "8fbd0579dc68aba6186935cc1bee21d2f3e7ec1c",
 )
 
 maven_jar(
-  name = 'junit',
-  artifact = 'junit:junit:4.11',
-  sha1 = '4e031bb61df09069aeb2bffb4019e7a5034a4ee0',
+    name = "junit",
+    artifact = "junit:junit:4.11",
+    sha1 = "4e031bb61df09069aeb2bffb4019e7a5034a4ee0",
 )
 
 maven_jar(
-  name = 'hamcrest_core',
-  artifact = 'org.hamcrest:hamcrest-core:1.3',
-  sha1 = '42a25dc3219429f0e5d060061f71acb49bf010a0',
+    name = "hamcrest-core",
+    artifact = "org.hamcrest:hamcrest-core:1.3",
+    sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
+)
+
+TRUTH_VERS = "0.32"
+
+maven_jar(
+    name = "truth",
+    artifact = "com.google.truth:truth:" + TRUTH_VERS,
+    sha1 = "e996fb4b41dad04365112786796c945f909cfdf7",
 )
 
 maven_jar(
-  name = 'truth',
-  artifact = 'com.google.truth:truth:0.28',
-  sha1 = '0a388c7877c845ff4b8e19689dda5ac9d34622c4',
+    name = "truth-java8-extension",
+    artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
+    sha1 = "2862787ce34cb6f385ada891e36ec7f9e7bd0902",
 )
 
 maven_jar(
-  name = 'easymock',
-  artifact = 'org.easymock:easymock:3.4', # When bumping the version
-  sha1 = '9fdeea183a399f25c2469497612cad131e920fa3',
+    name = "easymock",
+    artifact = "org.easymock:easymock:3.1",  # When bumping the version
+    sha1 = "3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e",
 )
 
 maven_jar(
-  name = 'cglib_2_2',
-  artifact = 'cglib:cglib-nodep:2.2.2',
-  sha1 = '00d456bb230c70c0b95c76fb28e429d42f275941',
+    name = "cglib-3_2",
+    artifact = "cglib:cglib-nodep:3.2.0",
+    sha1 = "cf1ca207c15b04ace918270b6cb3f5601160cdfd",
 )
 
 maven_jar(
-  name = 'objenesis',
-  artifact = 'org.objenesis:objenesis:2.2',
-  sha1 = '3fb533efdaa50a768c394aa4624144cf8df17845',
+    name = "objenesis",
+    artifact = "org.objenesis:objenesis:1.3",
+    sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
 )
 
-POWERM_VERS = '1.6.4'
+POWERM_VERS = "1.6.1"
 
 maven_jar(
-  name = 'powermock_module_junit4',
-  artifact = 'org.powermock:powermock-module-junit4:' + POWERM_VERS,
-  sha1 = '8692eb1d9bb8eb1310ffe8a20c2da7ee6d1b5994',
+    name = "powermock-module-junit4",
+    artifact = "org.powermock:powermock-module-junit4:" + POWERM_VERS,
+    sha1 = "ea8530b2848542624f110a393513af397b37b9cf",
 )
 
 maven_jar(
-  name = 'powermock_module_junit4_common',
-  artifact = 'org.powermock:powermock-module-junit4-common:' + POWERM_VERS,
-  sha1 = 'b0b578da443794ceb8224bd5f5f852aaf40f1b81',
+    name = "powermock-module-junit4-common",
+    artifact = "org.powermock:powermock-module-junit4-common:" + POWERM_VERS,
+    sha1 = "7222ced54dabc310895d02e45c5428ca05193cda",
 )
 
 maven_jar(
-  name = 'powermock_reflect',
-  artifact = 'org.powermock:powermock-reflect:' + POWERM_VERS,
-  sha1 = '5532f4e7c42db4bca4778bc9f1afcd4b0ee0b893',
+    name = "powermock-reflect",
+    artifact = "org.powermock:powermock-reflect:" + POWERM_VERS,
+    sha1 = "97d25eda8275c11161bcddda6ef8beabd534c878",
 )
 
 maven_jar(
-  name = 'powermock_api_easymock',
-  artifact = 'org.powermock:powermock-api-easymock:' + POWERM_VERS,
-  sha1 = '5c385a0d8c13f84b731b75c6e90319c532f80b45',
+    name = "powermock-api-easymock",
+    artifact = "org.powermock:powermock-api-easymock:" + POWERM_VERS,
+    sha1 = "aa740ecf89a2f64d410b3d93ef8cd6833009ef00",
 )
 
 maven_jar(
-  name = 'powermock_api_support',
-  artifact = 'org.powermock:powermock-api-support:' + POWERM_VERS,
-  sha1 = '314daafb761541293595630e10a3699ebc07881d',
+    name = "powermock-api-support",
+    artifact = "org.powermock:powermock-api-support:" + POWERM_VERS,
+    sha1 = "592ee6d929c324109d3469501222e0c76ccf0869",
 )
 
 maven_jar(
-  name = 'powermock_core',
-  artifact = 'org.powermock:powermock-core:' + POWERM_VERS,
-  sha1 = '85fb32e9ccba748d569fc36aef92e0b9e7f40b87',
+    name = "powermock-core",
+    artifact = "org.powermock:powermock-core:" + POWERM_VERS,
+    sha1 = "5afc1efce8d44ed76b30af939657bd598e45d962",
 )
 
 maven_jar(
-  name = 'javassist',
-  artifact = 'org.javassist:javassist:3.20.0-GA',
-  sha1 = 'a9cbcdfb7e9f86fbc74d3afae65f2248bfbf82a0',
+    name = "javassist",
+    artifact = "org.javassist:javassist:3.20.0-GA",
+    sha1 = "a9cbcdfb7e9f86fbc74d3afae65f2248bfbf82a0",
 )
 
 maven_jar(
-  name = 'derby',
-  artifact = 'org.apache.derby:derby:10.11.1.1',
-  sha1 = 'df4b50061e8e4c348ce243b921f53ee63ba9bbe1',
+    name = "derby",
+    artifact = "org.apache.derby:derby:10.12.1.1",
+    attach_source = False,
+    sha1 = "75070c744a8e52a7d17b8b476468580309d5cd09",
 )
 
-JETTY_VERS = '9.2.14.v20151106'
+JETTY_VERS = "9.3.24.v20180605"
 
 maven_jar(
-  name = 'jetty_servlet',
-  artifact = 'org.eclipse.jetty:jetty-servlet:' + JETTY_VERS,
-  sha1 = '3a2cd4d8351a38c5d60e0eee010fee11d87483ef',
+    name = "jetty-servlet",
+    artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
+    sha1 = "db09c8e226c07c46dc3d84626fc97955ec6bf8bf",
 )
 
 maven_jar(
-  name = 'jetty_security',
-  artifact = 'org.eclipse.jetty:jetty-security:' + JETTY_VERS,
-  sha1 = '2d36974323fcb31e54745c1527b996990835db67',
+    name = "jetty-security",
+    artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
+    sha1 = "dfc4e2169f3dd91954804e7fdff9c4f67c63f385",
 )
 
 maven_jar(
-  name = 'jetty_servlets',
-  artifact = 'org.eclipse.jetty:jetty-servlets:' + JETTY_VERS,
-  sha1 = 'a75c78a0ee544073457ca5ee9db20fdc6ed55225',
+    name = "jetty-servlets",
+    artifact = "org.eclipse.jetty:jetty-servlets:" + JETTY_VERS,
+    sha1 = "189db52691aacab9e13546429583765d143faf81",
 )
 
 maven_jar(
-  name = 'jetty_server',
-  artifact = 'org.eclipse.jetty:jetty-server:' + JETTY_VERS,
-  sha1 = '70b22c1353e884accf6300093362b25993dac0f5',
+    name = "jetty-server",
+    artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
+    sha1 = "0e629740cf0a08b353ec07c35eeab8fd06590041",
 )
 
 maven_jar(
-  name = 'jetty_jmx',
-  artifact = 'org.eclipse.jetty:jetty-jmx:' + JETTY_VERS,
-  sha1 = '617edc5e966b4149737811ef8b289cd94b831bab',
+    name = "jetty-jmx",
+    artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
+    sha1 = "aaeda444192a42389d2ac17a786329a1b6f4cf68",
 )
 
 maven_jar(
-  name = 'jetty_continuation',
-  artifact = 'org.eclipse.jetty:jetty-continuation:' + JETTY_VERS,
-  sha1 = '8909d62fd7e28351e2da30de6fb4105539b949c0',
+    name = "jetty-continuation",
+    artifact = "org.eclipse.jetty:jetty-continuation:" + JETTY_VERS,
+    sha1 = "44d7b4a9aef498abef268f3aade92daa459050f6",
 )
 
 maven_jar(
-  name = 'jetty_http',
-  artifact = 'org.eclipse.jetty:jetty-http:' + JETTY_VERS,
-  sha1 = '699ad1f2fa6fb0717e1b308a8c9e1b8c69d81ef6',
+    name = "jetty-http",
+    artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
+    sha1 = "f3d614a7c82b5ee028df78bdb3cdadb6c3be89bc",
 )
 
 maven_jar(
-  name = 'jetty_io',
-  artifact = 'org.eclipse.jetty:jetty-io:' + JETTY_VERS,
-  sha1 = 'dfa4137371a3f08769820138ca1a2184dacda267',
+    name = "jetty-io",
+    artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
+    sha1 = "f12a02ab2cb79eb9c3fa01daf28a58e8ea7cbea9",
 )
 
 maven_jar(
-  name = 'jetty_util',
-  artifact = 'org.eclipse.jetty:jetty-util:' + JETTY_VERS,
-  sha1 = '0057e00b912ae0c35859ac81594a996007706a0b',
+    name = "jetty-util",
+    artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
+    sha1 = "f74fb3f999e658a2ddea397155e20da5b9126b5d",
 )
 
 maven_jar(
-  name = 'openid_consumer',
-  artifact = 'org.openid4java:openid4java:0.9.8',
-  sha1 = 'de4f1b33d3b0f0b2ab1d32834ec1190b39db4160',
+    name = "openid-consumer",
+    artifact = "org.openid4java:openid4java:0.9.8",
+    sha1 = "de4f1b33d3b0f0b2ab1d32834ec1190b39db4160",
 )
 
 maven_jar(
-  name = 'nekohtml',
-  artifact = 'net.sourceforge.nekohtml:nekohtml:1.9.10',
-  sha1 = '14052461031a7054aa094f5573792feb6686d3de',
+    name = "nekohtml",
+    artifact = "net.sourceforge.nekohtml:nekohtml:1.9.10",
+    sha1 = "14052461031a7054aa094f5573792feb6686d3de",
 )
 
 maven_jar(
-  name = 'xerces',
-  artifact = 'xerces:xercesImpl:2.8.1',
-  sha1 = '25101e37ec0c907db6f0612cbf106ee519c1aef1',
+    name = "xerces",
+    artifact = "xerces:xercesImpl:2.8.1",
+    attach_source = False,
+    sha1 = "25101e37ec0c907db6f0612cbf106ee519c1aef1",
 )
+
+maven_jar(
+    name = "postgresql",
+    artifact = "org.postgresql:postgresql:42.2.5",
+    sha1 = "951b7eda125f3137538a94e2cbdcf744088ad4c2",
+)
+
+maven_jar(
+    name = "codemirror-minified",
+    artifact = "org.webjars.npm:codemirror-minified:" + CM_VERSION,
+    sha1 = "f84c178b11a188f416b4380bfb2b24f126453d28",
+)
+
+maven_jar(
+    name = "codemirror-original",
+    artifact = "org.webjars.npm:codemirror:" + CM_VERSION,
+    sha1 = "5a1f6c10d5aef0b9d2ce513dcc1e2657e4af730d",
+)
+
+maven_jar(
+    name = "diff-match-patch",
+    artifact = "org.webjars:google-diff-match-patch:" + DIFF_MATCH_PATCH_VERSION,
+    attach_source = False,
+    sha1 = "0cf1782dbcb8359d95070da9176059a5a9d37709",
+)
+
+maven_jar(
+    name = "commons-io",
+    artifact = "commons-io:commons-io:2.2",
+    sha1 = "83b5b8a7ba1c08f9e8c8ff2373724e33d3c1e22a",
+)
+
+maven_jar(
+    name = "asciidoctor",
+    artifact = "org.asciidoctor:asciidoctorj:1.5.7",
+    sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987",
+)
+
+maven_jar(
+    name = "jruby",
+    artifact = "org.jruby:jruby-complete:9.1.17.0",
+    sha1 = "76716d529710fc03d1d429b43e3cedd4419f78d4",
+)
+
+# When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
+# and httpasyncclient as necessary.
+maven_jar(
+    name = "elasticsearch-rest-client",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.4.3",
+    sha1 = "5c24325430971ba2fa4769eb446f026b7680d5e7",
+)
+
+JACKSON_VERSION = "2.9.8"
+
+maven_jar(
+    name = "jackson-core",
+    artifact = "com.fasterxml.jackson.core:jackson-core:" + JACKSON_VERSION,
+    sha1 = "0f5a654e4675769c716e5b387830d19b501ca191",
+)
+
+TESTCONTAINERS_VERSION = "1.11.2"
+
+maven_jar(
+    name = "testcontainers",
+    artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
+    sha1 = "eae47ed24bb07270d4b60b5e2c3444c5bf3c8ea9",
+)
+
+maven_jar(
+    name = "duct-tape",
+    artifact = "org.rnorth.duct-tape:duct-tape:1.0.7",
+    sha1 = "a26b5d90d88c91321dc7a3734ea72d2fc019ebb6",
+)
+
+maven_jar(
+    name = "visible-assertions",
+    artifact = "org.rnorth.visible-assertions:visible-assertions:2.1.2",
+    sha1 = "20d31a578030ec8e941888537267d3123c2ad1c1",
+)
+
+maven_jar(
+    name = "jna",
+    artifact = "net.java.dev.jna:jna:5.2.0",
+    sha1 = "ed8b772eb077a9cb50e44e90899c66a9a6c00e67",
+)
+
+load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
+
+npm_binary(
+    name = "bower",
+)
+
+npm_binary(
+    name = "vulcanize",
+    repository = GERRIT,
+)
+
+npm_binary(
+    name = "crisper",
+    repository = GERRIT,
+)
+
+# bower_archive() seed components.
+bower_archive(
+    name = "iron-autogrow-textarea",
+    package = "polymerelements/iron-autogrow-textarea",
+    sha1 = "b9b6874c9a2b5be435557a827ff8bd6661672ee3",
+    version = "1.0.12",
+)
+
+bower_archive(
+    name = "es6-promise",
+    package = "stefanpenner/es6-promise",
+    sha1 = "a3a797bb22132f1ef75f9a2556173f81870c2e53",
+    version = "3.3.0",
+)
+
+bower_archive(
+    name = "fetch",
+    package = "fetch",
+    sha1 = "1b05a2bb40c73232c2909dc196de7519fe4db7a9",
+    version = "1.0.0",
+)
+
+bower_archive(
+    name = "iron-dropdown",
+    package = "polymerelements/iron-dropdown",
+    sha1 = "63e3d669a09edaa31c4f05afc76b53b919ef0595",
+    version = "1.4.0",
+)
+
+bower_archive(
+    name = "iron-input",
+    package = "polymerelements/iron-input",
+    sha1 = "9bc0c8e81de2527125383cbcf74dd9f27e7fa9ac",
+    version = "1.0.10",
+)
+
+bower_archive(
+    name = "iron-overlay-behavior",
+    package = "polymerelements/iron-overlay-behavior",
+    sha1 = "83181085fda59446ce74fd0d5ca30c223f38ee4a",
+    version = "1.7.6",
+)
+
+bower_archive(
+    name = "iron-selector",
+    package = "polymerelements/iron-selector",
+    sha1 = "c57235dfda7fbb987c20ad0e97aac70babf1a1bf",
+    version = "1.5.2",
+)
+
+bower_archive(
+    name = "moment",
+    package = "moment/moment",
+    sha1 = "fc8ce2c799bab21f6ced7aff928244f4ca8880aa",
+    version = "2.13.0",
+)
+
+bower_archive(
+    name = "page",
+    package = "visionmedia/page.js",
+    sha1 = "51a05428dd4f68fae1df5f12d0e2b61ba67f7757",
+    version = "1.7.1",
+)
+
+bower_archive(
+    name = "polymer",
+    package = "polymer/polymer",
+    sha1 = "62ce80a5079c1b97f6c5c6ebf6b350e741b18b9c",
+    version = "1.11.0",
+)
+
+bower_archive(
+    name = "promise-polyfill",
+    package = "polymerlabs/promise-polyfill",
+    sha1 = "a3b598c06cbd7f441402e666ff748326030905d6",
+    version = "1.0.0",
+)
+
+# bower test stuff
+
+bower_archive(
+    name = "iron-test-helpers",
+    package = "polymerelements/iron-test-helpers",
+    sha1 = "433b03b106f5ff32049b84150cd70938e18b67ac",
+    version = "1.2.5",
+)
+
+bower_archive(
+    name = "test-fixture",
+    package = "polymerelements/test-fixture",
+    sha1 = "e373bd21c069163c3a754e234d52c07c77b20d3c",
+    version = "1.1.1",
+)
+
+bower_archive(
+    name = "web-component-tester",
+    package = "web-component-tester",
+    sha1 = "a4a9bc7815a22d143e8f8593e37b3c2028b8c20f",
+    version = "5.0.0",
+)
+
+# Bower component transitive dependencies.
+load("//lib/js:bower_archives.bzl", "load_bower_archives")
+
+load_bower_archives()
+
+external_plugin_deps()
diff --git a/bucklets/gerrit_plugin.bucklet b/bucklets/gerrit_plugin.bucklet
deleted file mode 100644
index 367fe71..0000000
--- a/bucklets/gerrit_plugin.bucklet
+++ /dev/null
@@ -1,21 +0,0 @@
-#
-# Dummy to make the co-existence of core and standalone plugins possible.
-# Intentionaly left empty as this doesn't suppose to have any side effects
-# in tree build, i. e.:
-#
-# cookbook-plugin/BUCK include this line:
-# include_defs('//bucklets/gerrit_plugin.bucklet')
-#
-# When executing from the Gerrit tree:
-# buck build plugins/cookbook-plugin
-#
-# this line has no effect.
-#
-# When compiling from standalone cookbook-plugin, bucklets directory points
-# to cloned bucklets library that includes real gerrit_plugin.bucklet code.
-
-GERRIT_GWT_API = ['//gerrit-plugin-gwtui:gwtui-api']
-GERRIT_PLUGIN_API = ['//gerrit-plugin-api:lib']
-GERRIT_TESTS = ['//gerrit-acceptance-framework:lib']
-
-STANDALONE_MODE = False
diff --git a/bucklets/java_doc.bucklet b/bucklets/java_doc.bucklet
deleted file mode 120000
index cc8b6db..0000000
--- a/bucklets/java_doc.bucklet
+++ /dev/null
@@ -1 +0,0 @@
-../tools/java_doc.defs
\ No newline at end of file
diff --git a/bucklets/java_sources.bucklet b/bucklets/java_sources.bucklet
deleted file mode 120000
index 8a1a5dd..0000000
--- a/bucklets/java_sources.bucklet
+++ /dev/null
@@ -1 +0,0 @@
-../tools/java_sources.defs
\ No newline at end of file
diff --git a/bucklets/maven_jar.bucklet b/bucklets/maven_jar.bucklet
deleted file mode 120000
index 130a747..0000000
--- a/bucklets/maven_jar.bucklet
+++ /dev/null
@@ -1 +0,0 @@
-../lib/maven.defs
\ No newline at end of file
diff --git a/bucklets/maven_package.bucklet b/bucklets/maven_package.bucklet
deleted file mode 120000
index b5f5ea8..0000000
--- a/bucklets/maven_package.bucklet
+++ /dev/null
@@ -1 +0,0 @@
-../tools/maven/package.defs
\ No newline at end of file
diff --git a/contrib/.pylintrc b/contrib/.pylintrc
deleted file mode 100644
index 9e8882e..0000000
--- a/contrib/.pylintrc
+++ /dev/null
@@ -1,301 +0,0 @@
-# lint Python modules using external checkers.
-#
-# This is the main checker controling the other ones and the reports
-# generation. It is itself both a raw checker and an astng checker in order
-# to:
-# * handle message activation / deactivation at the module level
-# * handle some basic but necessary stats'data (number of classes, methods...)
-#
-[MASTER]
-
-# Specify a configuration file.
-#rcfile=
-
-# Python code to execute, usually for sys.path manipulation such as
-# pygtk.require().
-#init-hook=
-
-# Profiled execution.
-profile=no
-
-# Add <file or directory> to the black list. It should be a base name, not a
-# path. You may set this option multiple times.
-ignore=SVN
-
-# Pickle collected data for later comparisons.
-persistent=yes
-
-# Set the cache size for astng objects.
-cache-size=500
-
-# List of plugins (as comma separated values of python modules names) to load,
-# usually to register additional checkers.
-load-plugins=
-
-
-[MESSAGES CONTROL]
-
-# Enable only checker(s) with the given id(s). This option conflicts with the
-# disable-checker option
-#enable-checker=
-
-# Enable all checker(s) except those with the given id(s). This option
-# conflicts with the enable-checker option
-#disable-checker=
-
-# Enable all messages in the listed categories.
-#enable-msg-cat=
-
-# Disable all messages in the listed categories.
-#disable-msg-cat=
-
-# Enable the message(s) with the given id(s).
-enable=RP0004
-
-# Disable the message(s) with the given id(s).
-disable=R0903,R0912,R0913,R0914,R0915,W0141,C0111,C0103,W0603,W0703,R0911,C0301,C0302,R0902,R0904,W0142,W0212,E1101,E1103,R0201,W0201,W0122,W0232,RP0001,RP0003,RP0101,RP0002,RP0401,RP0701,RP0801
-
-[REPORTS]
-
-# set the output format. Available formats are text, parseable, colorized, msvs
-# (visual studio) and html
-output-format=text
-
-# Include message's id in output
-include-ids=yes
-
-# Put messages in a separate file for each module / package specified on the
-# command line instead of printing them on stdout. Reports (if any) will be
-# written in a file name "pylint_global.[txt|html]".
-files-output=no
-
-# Tells whether to display a full report or only the messages
-reports=yes
-
-# Python expression which should return a note less than 10 (10 is the highest
-# note).You have access to the variables errors warning, statement which
-# respectivly contain the number of errors / warnings messages and the total
-# number of statements analyzed. This is used by the global evaluation report
-# (R0004).
-evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
-
-# Add a comment according to your evaluation note. This is used by the global
-# evaluation report (R0004).
-comment=no
-
-# checks for
-# * unused variables / imports
-# * undefined variables
-# * redefinition of variable from builtins or from an outer scope
-# * use of variable before assigment
-#
-[VARIABLES]
-
-# Tells whether we should check for unused import in __init__ files.
-init-import=no
-
-# A regular expression matching names used for dummy variables (i.e. not used).
-dummy-variables-rgx=_|dummy
-
-# List of additional names supposed to be defined in builtins. Remember that
-# you should avoid to define new builtins when possible.
-additional-builtins=
-
-
-# try to find bugs in the code using type inference
-#
-[TYPECHECK]
-
-# Tells whether missing members accessed in mixin class should be ignored. A
-# mixin class is detected if its name ends with "mixin" (case insensitive).
-ignore-mixin-members=yes
-
-# List of classes names for which member attributes should not be checked
-# (useful for classes with attributes dynamicaly set).
-ignored-classes=SQLObject
-
-# When zope mode is activated, consider the acquired-members option to ignore
-# access to some undefined attributes.
-zope=no
-
-# List of members which are usually get through zope's acquisition mecanism and
-# so shouldn't trigger E0201 when accessed (need zope=yes to be considered).
-acquired-members=REQUEST,acl_users,aq_parent
-
-
-# checks for :
-# * doc strings
-# * modules / classes / functions / methods / arguments / variables name
-# * number of arguments, local variables, branchs, returns and statements in
-# functions, methods
-# * required module attributes
-# * dangerous default values as arguments
-# * redefinition of function / method / class
-# * uses of the global statement
-#
-[BASIC]
-
-# Required attributes for module, separated by a comma
-required-attributes=
-
-# Regular expression which should only match functions or classes name which do
-# not require a docstring
-no-docstring-rgx=_main|__.*__
-
-# Regular expression which should only match correct module names
-module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
-
-# Regular expression which should only match correct module level names
-const-rgx=(([A-Z_][A-Z1-9_]*)|(__.*__))|(log)$
-
-# Regular expression which should only match correct class names
-class-rgx=[A-Z_][a-zA-Z0-9]+$
-
-# Regular expression which should only match correct function names
-function-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct method names
-method-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct instance attribute names
-attr-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct argument names
-argument-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct variable names
-variable-rgx=[a-z_][a-z0-9_]{2,30}$
-
-# Regular expression which should only match correct list comprehension /
-# generator expression variable names
-inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
-
-# Good variable names which should always be accepted, separated by a comma
-good-names=i,j,k,ex,Run,_,e,d1,d2,v,f,l,d
-
-# Bad variable names which should always be refused, separated by a comma
-bad-names=foo,bar,baz,toto,tutu,tata
-
-# List of builtins function names that should not be used, separated by a comma
-bad-functions=map,filter,apply,input
-
-
-# checks for sign of poor/misdesign:
-# * number of methods, attributes, local variables...
-# * size, complexity of functions, methods
-#
-[DESIGN]
-
-# Maximum number of arguments for function / method
-max-args=5
-
-# Maximum number of locals for function / method body
-max-locals=15
-
-# Maximum number of return / yield for function / method body
-max-returns=6
-
-# Maximum number of branch for function / method body
-max-branchs=12
-
-# Maximum number of statements in function / method body
-max-statements=50
-
-# Maximum number of parents for a class (see R0901).
-max-parents=7
-
-# Maximum number of attributes for a class (see R0902).
-max-attributes=20
-
-# Minimum number of public methods for a class (see R0903).
-min-public-methods=2
-
-# Maximum number of public methods for a class (see R0904).
-max-public-methods=30
-
-
-# checks for
-# * external modules dependencies
-# * relative / wildcard imports
-# * cyclic imports
-# * uses of deprecated modules
-#
-[IMPORTS]
-
-# Deprecated modules which should not be used, separated by a comma
-deprecated-modules=regsub,string,TERMIOS,Bastion,rexec
-
-# Create a graph of every (i.e. internal and external) dependencies in the
-# given file (report R0402 must not be disabled)
-import-graph=
-
-# Create a graph of external dependencies in the given file (report R0402 must
-# not be disabled)
-ext-import-graph=
-
-# Create a graph of internal dependencies in the given file (report R0402 must
-# not be disabled)
-int-import-graph=
-
-
-# checks for :
-# * methods without self as first argument
-# * overridden methods signature
-# * access only to existant members via self
-# * attributes not defined in the __init__ method
-# * supported interfaces implementation
-# * unreachable code
-#
-[CLASSES]
-
-# List of interface methods to ignore, separated by a comma. This is used for
-# instance to not check methods defines in Zope's Interface base class.
-ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by
-
-# List of method names used to declare (i.e. assign) instance attributes.
-defining-attr-methods=__init__,__new__,setUp
-
-
-# checks for similarities and duplicated code. This computation may be
-# memory / CPU intensive, so you should disable it if you experiments some
-# problems.
-#
-[SIMILARITIES]
-
-# Minimum lines number of a similarity.
-min-similarity-lines=4
-
-# Ignore comments when computing similarities.
-ignore-comments=yes
-
-# Ignore docstrings when computing similarities.
-ignore-docstrings=yes
-
-
-# checks for:
-# * warning notes in the code like FIXME, XXX
-# * PEP 263: source code with non ascii character but no encoding declaration
-#
-[MISCELLANEOUS]
-
-# List of note tags to take in consideration, separated by a comma.
-notes=FIXME,XXX,TODO
-
-
-# checks for :
-# * unauthorized constructions
-# * strict indentation
-# * line length
-# * use of <> instead of !=
-#
-[FORMAT]
-
-# Maximum number of characters on a single line.
-max-line-length=80
-
-# Maximum number of lines in a module
-max-module-lines=1000
-
-# String used as indentation unit. This is usually "    " (4 spaces) or "\t" (1
-# tab).  In repo it is 2 spaces.
-indent-string='  '
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index 5f5b9ef..f62c767 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -38,7 +38,7 @@
 Supports dry-run mode to only list the stale changes but not actually
 abandon them.
 
-Requires pygerrit (https://github.com/sonyxperiadev/pygerrit).
+Requires pygerrit2 (https://github.com/dpursehouse/pygerrit2).
 
 """
 
@@ -47,8 +47,8 @@
 import re
 import sys
 
-from pygerrit.rest import GerritRestAPI
-from pygerrit.rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc
+from pygerrit2.rest import GerritRestAPI
+from pygerrit2.rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc
 
 
 def _main():
diff --git a/contrib/convertkey/BUCK b/contrib/convertkey/BUCK
deleted file mode 100644
index 40ad9c4..0000000
--- a/contrib/convertkey/BUCK
+++ /dev/null
@@ -1,50 +0,0 @@
-include_defs('//lib/maven.defs')
-
-genrule(
-  name = 'bcprov__unsign',
-  cmd = ' && '.join([
-    'unzip -qd $TMP $(location //lib/bouncycastle:bcprov)',
-    'cd $TMP',
-    'zip -Drq $OUT . -x META-INF/\*.RSA META-INF/\*.DSA META-INF/\*.SF META-INF/\*.LIST',
-  ]),
-  out = 'bcprov-unsigned.jar',
-)
-
-prebuilt_jar(
-  name = 'bcprov',
-  binary_jar = ':bcprov__unsign',
-)
-
-genrule(
-  name = 'bcpkix__unsign',
-  cmd = ' && '.join([
-    'unzip -qd $TMP $(location //lib/bouncycastle:bcpkix)',
-    'cd $TMP',
-    'zip -Drq $OUT . -x META-INF/\*.RSA META-INF/\*.DSA META-INF/\*.SF META-INF/\*.LIST',
-  ]),
-  out = 'bcpkix-unsigned.jar',
-)
-
-prebuilt_jar(
-  name = 'bcpkix',
-  binary_jar = ':bcpkix__unsign',
-)
-
-java_library(
-  name = 'convertkey__lib',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    ':bcprov',
-    ':bcpkix',
-    '//lib:jsch',
-    '//lib/log:nop',
-    '//lib/mina:sshd',
-  ],
-)
-
-java_binary(
-  name = 'convertkey',
-  deps = [':convertkey__lib'],
-  main_class = 'com.googlesource.gerrit.convertkey.ConvertKey',
-)
-
diff --git a/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java b/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
index 5c6ef58..08a529c 100644
--- a/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
+++ b/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
@@ -16,17 +16,14 @@
 
 import com.jcraft.jsch.HostKey;
 import com.jcraft.jsch.JSchException;
-
-import org.apache.sshd.common.util.Buffer;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-
-import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
-
 import java.io.File;
 import java.io.IOException;
 import java.io.StringWriter;
-import java.security.KeyPair;
 import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import org.apache.sshd.common.util.Buffer;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
 
 public class ConvertKey {
   public static void main(String[] args)
@@ -69,5 +66,4 @@
       System.out.println(privout);
     }
   }
-
 }
diff --git a/contrib/git-push-review b/contrib/git-push-review
index e77785a..87eaa4c 100755
--- a/contrib/git-push-review
+++ b/contrib/git-push-review
@@ -46,8 +46,8 @@
                  help='remote name or URL to push to')
   p.add_argument('-b', '--branch', default='', metavar='BRANCH',
                  help='remote branch name, refs/for/BRANCH')
-  p.add_argument('reviewers', nargs='*', metavar='REVIEWER',
-                 help='reviewer names or aliases')
+  p.add_argument('args', nargs='*', metavar='REVIEWER_OR_HASHTAG',
+                 help='reviewer names or aliases, or #hashtags')
   p.add_argument('-t', '--topic', default='', metavar='TOPIC',
                  help='topic for new changes')
   p.add_argument('--dry-run', action='store_true',
@@ -68,8 +68,13 @@
     args.remote = args.remote or def_remote
     args.branch = args.branch or def_branch
 
+
   opts = collections.defaultdict(list)
-  opts['r'].extend((get_config('reviewer.' + r) or r) for r in args.reviewers)
+  is_hashtag = lambda x: x.startswith('#')
+  opts['r'].extend(
+      (get_config('reviewer.' + r) or r)
+      for r in args.args if not is_hashtag(r))
+  opts['t'].extend(t[1:] for t in args.args if is_hashtag(t))
   if args.topic:
     opts['topic'].append(args.topic)
   opts_str = ','.join('%s=%s' % (k, v) for k in opts for v in opts[k])
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index c35f82c..b77c41a 100644
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -182,14 +182,15 @@
 
 
 def get_random_users(num_users):
-  users = [(f, l) for f in FIRST_NAMES for l in LAST_NAMES][:num_users]
+  users = random.sample([(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",
+                  "email": u[0] + "." + u[1] + "@gerritcodereview.com",
                   "http_password": "secret",
                   "groups": []})
   return names
@@ -293,6 +294,7 @@
   project_names = create_gerrit_projects(group_names)
 
   for idx, u in enumerate(gerrit_users):
-    create_change(u, project_names[4 * idx / len(gerrit_users)])
+    for _ in xrange(random.randint(1, 5)):
+      create_change(u, project_names[4 * idx / len(gerrit_users)])
 
 main()
diff --git a/gerrit-acceptance-framework/BUCK b/gerrit-acceptance-framework/BUCK
deleted file mode 100644
index ba68fa3..0000000
--- a/gerrit-acceptance-framework/BUCK
+++ /dev/null
@@ -1,92 +0,0 @@
-SRCS = glob(['src/test/java/com/google/gerrit/acceptance/*.java'])
-
-DEPS = [
-  '//gerrit-gpg:gpg',
-  '//gerrit-launcher:launcher',
-  '//gerrit-openid:openid',
-  '//gerrit-pgm:daemon',
-  '//gerrit-pgm:http-jetty',
-  '//gerrit-pgm:util-nodep',
-  '//gerrit-server/src/main/prolog:common',
-  '//gerrit-server:testutil',
-  '//lib/auto:auto-value',
-  '//lib/httpcomponents:fluent-hc',
-  '//lib/httpcomponents:httpclient',
-  '//lib/httpcomponents:httpcore',
-  '//lib/jetty:servlet',
-  '//lib/jgit/org.eclipse.jgit.junit:junit',
-  '//lib/log:impl_log4j',
-  '//lib/log:log4j',
-]
-
-PROVIDED = [
-  '//gerrit-common:annotations',
-  '//gerrit-common:server',
-  '//gerrit-extension-api:api',
-  '//gerrit-httpd:httpd',
-  '//gerrit-lucene:lucene',
-  '//gerrit-pgm:init',
-  '//gerrit-reviewdb:server',
-  '//gerrit-server:server',
-  '//lib:gson',
-  '//lib:jsch',
-  '//lib/jgit/org.eclipse.jgit:jgit',
-  '//lib/mina:sshd',
-  '//lib:servlet-api-3_1',
-]
-
-java_binary(
-  name = 'acceptance-framework',
-  deps = [':lib'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'lib',
-  srcs = SRCS,
-  exported_deps = DEPS + [
-    '//lib:truth',
-  ],
-  provided_deps = PROVIDED + [
-    '//lib:gwtorm',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_sources(
-  name = 'src',
-  srcs = SRCS,
-  visibility = ['PUBLIC'],
-)
-
-# The above java_sources produces a .jar somewhere in the depths of
-# buck-out, but it does not bring it to
-# buck-out/gen/gerrit-acceptance-framework/gerrit-acceptance-framework-src.jar.
-# We fix that by the following java_binary.
-java_binary(
-  name = 'acceptance-framework-src',
-  deps = [ ':src' ],
-  visibility = ['PUBLIC'],
-)
-
-java_doc(
-  name = 'acceptance-framework-javadoc',
-  title = 'Gerrit Acceptance Test Framework Documentation',
-  pkgs = [' com.google.gerrit.acceptance'],
-  paths = ['src/test/java'],
-  srcs = SRCS,
-  deps = DEPS + PROVIDED + [
-    '//lib:guava',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice_library',
-    '//lib/guice:guice-servlet',
-    '//lib/guice:javax-inject',
-    '//lib:gwtorm_client',
-    '//lib:junit',
-    '//lib:truth',
-  ],
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-acceptance-framework/BUILD b/gerrit-acceptance-framework/BUILD
index 1439ba9..795dedd 100644
--- a/gerrit-acceptance-framework/BUILD
+++ b/gerrit-acceptance-framework/BUILD
@@ -1,60 +1,77 @@
-load('//tools/bzl:java.bzl', 'java_library2')
+load("@rules_java//java:defs.bzl", "java_binary")
+load("//tools/bzl:java.bzl", "java_library2")
+load("//tools/bzl:javadoc.bzl", "java_doc")
 
-SRCS = glob(['src/test/java/com/google/gerrit/acceptance/*.java'])
-
-DEPS = [
-  '//gerrit-gpg:gpg',
-  '//gerrit-launcher:launcher',
-  '//gerrit-openid:openid',
-  '//gerrit-pgm:daemon',
-  '//gerrit-pgm:http-jetty',
-  '//gerrit-pgm:util-nodep',
-  '//gerrit-server/src/main/prolog:common',
-  '//gerrit-server:testutil',
-  '//lib/auto:auto-value',
-  '//lib/httpcomponents:fluent-hc',
-  '//lib/httpcomponents:httpclient',
-  '//lib/httpcomponents:httpcore',
-  '//lib/jetty:servlet',
-  '//lib/jgit/org.eclipse.jgit.junit:junit',
-  '//lib/log:impl_log4j',
-  '//lib/log:log4j',
-]
+SRCS = glob(["src/test/java/com/google/gerrit/acceptance/*.java"])
 
 PROVIDED = [
-  '//gerrit-common:annotations',
-  '//gerrit-common:server',
-  '//gerrit-extension-api:api',
-  '//gerrit-httpd:httpd',
-  '//gerrit-lucene:lucene',
-  '//gerrit-pgm:init',
-  '//gerrit-reviewdb:server',
-  '//gerrit-server:server',
-  '//lib:gson',
-  '//lib:jsch',
-  '//lib/jgit/org.eclipse.jgit:jgit',
-  '//lib/mina:sshd',
-  '//lib:servlet-api-3_1',
+    "//gerrit-common:annotations",
+    "//gerrit-common:server",
+    "//gerrit-extension-api:api",
+    "//gerrit-httpd:httpd",
+    "//gerrit-lucene:lucene",
+    "//gerrit-pgm:init",
+    "//gerrit-reviewdb:server",
+    "//gerrit-server:server",
+    "//lib:gson",
+    "//lib:jsch",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib/mina:sshd",
+    "//lib:servlet-api-3_1",
 ]
 
 java_binary(
-  name = 'acceptance-framework',
-  main_class = 'Dummy',
-  runtime_deps = [':lib'],
-  visibility = ['//visibility:public'],
+    name = "acceptance-framework",
+    testonly = 1,
+    main_class = "Dummy",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":lib"],
 )
 
 java_library2(
-  name = 'lib',
-  srcs = SRCS,
-  exported_deps = DEPS + [
-    '//lib:truth',
-  ],
-  deps = PROVIDED + [ # We want these deps to be exported_deps
-    '//lib:gwtorm',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-  ],
-  visibility = ['//visibility:public'],
+    name = "lib",
+    testonly = 1,
+    srcs = SRCS,
+    exported_deps = [
+        "//gerrit-antlr:query_exception",
+        "//gerrit-gpg:gpg",
+        "//gerrit-launcher:launcher",
+        "//gerrit-openid:openid",
+        "//gerrit-pgm:daemon",
+        "//gerrit-pgm:http-jetty",
+        "//gerrit-pgm:util-nodep",
+        "//gerrit-server:testutil",
+        "//gerrit-server/src/main/prolog:common",
+        "//lib:jimfs",
+        "//lib:truth",
+        "//lib:truth-java8-extension",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/httpcomponents:fluent-hc",
+        "//lib/httpcomponents:httpclient",
+        "//lib/httpcomponents:httpcore",
+        "//lib/jetty:servlet",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/log:impl-log4j",
+        "//lib/log:log4j",
+    ],
+    visibility = ["//visibility:public"],
+    deps = PROVIDED + [
+        # We want these deps to be exported_deps
+        "//lib/greenmail:greenmail",
+        "//lib:gwtorm",
+        "//lib/guice:guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/mail:mail",
+    ],
+)
+
+java_doc(
+    name = "acceptance-framework-javadoc",
+    testonly = 1,
+    libs = [":lib"],
+    pkgs = ["com.google.gerrit.acceptance"],
+    title = "Gerrit Acceptance Test Framework Documentation",
+    visibility = ["//visibility:public"],
 )
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index 4ba3720..be1e177 100644
--- a/gerrit-acceptance-framework/pom.xml
+++ b/gerrit-acceptance-framework/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.13.14</version>
+  <version>2.14.23-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -23,7 +23,10 @@
 
   <developers>
     <developer>
-      <name>Andrew Bonventre</name>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -38,16 +41,28 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
-      <name>Saša Živkov</name>
+      <name>Ole Rehmsen</name>
     </developer>
     <developer>
-      <name>Shawn Pearce</name>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
     </developer>
   </developers>
 
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 a21b7d0..8868987 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
@@ -15,38 +15,49 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.initSsh;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
+import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
+import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.ContributorAgreement;
+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.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -62,17 +73,23 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.change.Abandon;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.Revisions;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.mail.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
@@ -83,6 +100,7 @@
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
+import com.google.gerrit.testutil.SshMode;
 import com.google.gerrit.testutil.TempFileUtil;
 import com.google.gerrit.testutil.TestNotesMigration;
 import com.google.gson.Gson;
@@ -90,7 +108,24 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -98,125 +133,96 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.FetchResult;
+import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.Transport;
+import org.eclipse.jgit.transport.TransportBundleStream;
+import org.eclipse.jgit.transport.URIish;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.rules.ExpectedException;
-import org.junit.rules.TemporaryFolder;
 import org.junit.rules.TestRule;
 import org.junit.runner.Description;
 import org.junit.runner.RunWith;
 import org.junit.runners.model.Statement;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Map;
-import java.util.regex.Pattern;
-
 @RunWith(ConfigSuite.class)
 public abstract class AbstractDaemonTest {
   private static GerritServer commonServer;
 
-  @ConfigSuite.Parameter
-  public Config baseConfig;
+  @ConfigSuite.Parameter public Config baseConfig;
 
-  @ConfigSuite.Name
-  private String configName;
+  @ConfigSuite.Name private String configName;
 
-  @Inject
-  protected AllProjectsName allProjects;
+  @Inject protected AllProjectsName allProjects;
 
-  @Inject
-  protected AccountCreator accounts;
+  @Inject protected AccountCreator accounts;
 
-  @Inject
-  private SchemaFactory<ReviewDb> reviewDbProvider;
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
 
-  @Inject
-  protected GerritApi gApi;
+  @Inject protected GerritApi gApi;
 
-  @Inject
-  protected AcceptanceTestRequestScope atrScope;
+  @Inject protected AcceptanceTestRequestScope atrScope;
 
-  @Inject
-  protected AccountCache accountCache;
+  @Inject protected AccountCache accountCache;
 
-  @Inject
-  protected IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
 
-  @Inject
-  protected PushOneCommit.Factory pushFactory;
+  @Inject protected PushOneCommit.Factory pushFactory;
 
-  @Inject
-  protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
 
-  @Inject
-  protected ProjectCache projectCache;
+  @Inject protected ProjectCache projectCache;
 
-  @Inject
-  protected GroupCache groupCache;
+  @Inject protected GroupCache groupCache;
 
-  @Inject
-  protected GitRepositoryManager repoManager;
+  @Inject protected GitRepositoryManager repoManager;
 
-  @Inject
-  protected ChangeIndexer indexer;
+  @Inject protected ChangeIndexer indexer;
 
-  @Inject
-  protected Provider<InternalChangeQuery> queryProvider;
+  @Inject protected Provider<InternalChangeQuery> queryProvider;
 
-  @Inject
-  @CanonicalWebUrl
-  protected Provider<String> canonicalWebUrl;
+  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
 
-  @Inject
-  @GerritServerConfig
-  protected Config cfg;
+  @Inject @GerritServerConfig protected Config cfg;
 
-  @Inject
-  private InProcessProtocol inProcessProtocol;
+  @Inject protected PluginConfigFactory pluginConfig;
 
-  @Inject
-  private Provider<AnonymousUser> anonymousUser;
+  @Inject private InProcessProtocol inProcessProtocol;
 
-  @Inject
-  @GerritPersonIdent
-  protected Provider<PersonIdent> serverIdent;
+  @Inject private Provider<AnonymousUser> anonymousUser;
 
-  @Inject
-  protected ChangeData.Factory changeDataFactory;
+  @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
 
-  @Inject
-  protected PatchSetUtil psUtil;
+  @Inject protected ChangeData.Factory changeDataFactory;
 
-  @Inject
-  protected ChangeFinder changeFinder;
+  @Inject protected PatchSetUtil psUtil;
 
-  @Inject
-  protected Revisions revisions;
+  @Inject protected ChangeFinder changeFinder;
 
-  @Inject
-  protected FakeEmailSender sender;
+  @Inject protected Revisions revisions;
 
-  @Inject
-  protected ChangeNoteUtil changeNoteUtil;
+  @Inject protected FakeEmailSender sender;
 
-  @Inject
-  protected ChangeResource.Factory changeResourceFactory;
+  @Inject protected ChangeNoteUtil changeNoteUtil;
 
-  @Inject
-  private EventRecorder.Factory eventRecorderFactory;
+  @Inject protected ChangeResource.Factory changeResourceFactory;
+
+  @Inject protected SystemGroupBackend systemGroupBackend;
+
+  @Inject private EventRecorder.Factory eventRecorderFactory;
+
+  @Inject private ChangeIndexCollection changeIndexes;
 
   protected TestRepository<InMemoryRepository> testRepo;
   protected GerritServer server;
@@ -230,38 +236,36 @@
   protected Project.NameKey project;
   protected EventRecorder eventRecorder;
 
-  @Inject
-  protected TestNotesMigration notesMigration;
+  @Inject protected TestNotesMigration notesMigration;
 
-  @Inject
-  protected ChangeNotes.Factory notesFactory;
+  @Inject protected ChangeNotes.Factory notesFactory;
 
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+  @Inject protected Abandon changeAbandoner;
+
+  @Rule public ExpectedException exception = ExpectedException.none();
 
   private String resourcePrefix;
   private List<Repository> toClose;
+  private boolean useSsh;
 
   @Rule
-  public TestRule testRunner = new TestRule() {
-    @Override
-    public Statement apply(final Statement base, final Description description) {
-      return new Statement() {
+  public TestRule testRunner =
+      new TestRule() {
         @Override
-        public void evaluate() throws Throwable {
-          beforeTest(description);
-          try {
-            base.evaluate();
-          } finally {
-            afterTest();
-          }
+        public Statement apply(final Statement base, final Description description) {
+          return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+              beforeTest(description);
+              try {
+                base.evaluate();
+              } finally {
+                afterTest();
+              }
+            }
+          };
         }
       };
-    }
-  };
-
-  @Rule
-  public TemporaryFolder tempSiteDir = new TemporaryFolder();
 
   @Before
   public void clearSender() {
@@ -273,6 +277,16 @@
     eventRecorder = eventRecorderFactory.create(admin);
   }
 
+  @Before
+  public void assumeSshIfRequired() {
+    if (useSsh) {
+      // If the test uses ssh, we use assume() to make sure ssh is enabled on
+      // the test suite. JUnit will skip tests annotated with @UseSsh if we
+      // disable them using the command line flag.
+      assume().that(SshMode.useSsh()).isTrue();
+    }
+  }
+
   @After
   public void closeEventRecorder() {
     eventRecorder.close();
@@ -282,7 +296,7 @@
   public static void stopCommonServer() throws Exception {
     if (commonServer != null) {
       try {
-        commonServer.stop();
+        commonServer.close();
       } finally {
         commonServer = null;
       }
@@ -316,21 +330,18 @@
 
   protected void beforeTest(Description description) throws Exception {
     GerritServer.Description classDesc =
-      GerritServer.Description.forTestClass(description, configName);
+        GerritServer.Description.forTestClass(description, configName);
     GerritServer.Description methodDesc =
-      GerritServer.Description.forTestMethod(description, configName);
+        GerritServer.Description.forTestMethod(description, configName);
 
-    baseConfig.setString("gerrit", null, "tempSiteDir",
-        tempSiteDir.getRoot().getPath());
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
-    if (classDesc.equals(methodDesc) && !classDesc.sandboxed() &&
-        !methodDesc.sandboxed()) {
+    if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
       if (commonServer == null) {
-        commonServer = GerritServer.start(classDesc, baseConfig);
+        commonServer = GerritServer.initAndStart(classDesc, baseConfig);
       }
       server = commonServer;
     } else {
-      server = GerritServer.start(methodDesc, baseConfig);
+      server = GerritServer.initAndStart(methodDesc, baseConfig);
     }
 
     server.getTestInjector().injectMembers(this);
@@ -346,20 +357,34 @@
 
     adminRestSession = new RestSession(server, admin);
     userRestSession = new RestSession(server, user);
-    initSsh(admin);
-    db = reviewDbProvider.open();
-    Context ctx = newRequestContext(user);
-    atrScope.set(ctx);
-    userSshSession = ctx.getSession();
-    userSshSession.open();
-    ctx = newRequestContext(admin);
-    atrScope.set(ctx);
-    adminSshSession = ctx.getSession();
-    adminSshSession.open();
-    resourcePrefix = UNSAFE_PROJECT_NAME.matcher(
-        description.getClassName() + "_"
-        + description.getMethodName() + "_").replaceAll("");
 
+    db = reviewDbProvider.open();
+
+    if (classDesc.useSsh() || methodDesc.useSsh()) {
+      useSsh = true;
+      if (SshMode.useSsh() && (adminSshSession == null || userSshSession == null)) {
+        // Create Ssh sessions
+        initSsh(admin);
+        Context ctx = newRequestContext(user);
+        atrScope.set(ctx);
+        userSshSession = ctx.getSession();
+        userSshSession.open();
+        ctx = newRequestContext(admin);
+        atrScope.set(ctx);
+        adminSshSession = ctx.getSession();
+        adminSshSession.open();
+      }
+    } else {
+      useSsh = false;
+    }
+
+    resourcePrefix =
+        UNSAFE_PROJECT_NAME
+            .matcher(description.getClassName() + "_" + description.getMethodName() + "_")
+            .replaceAll("");
+
+    Context ctx = newRequestContext(admin);
+    atrScope.set(ctx);
     project = createProject(projectInput(description));
     testRepo = cloneProject(project, getCloneAsAccount(description));
   }
@@ -389,8 +414,7 @@
     return in;
   }
 
-  private static final Pattern UNSAFE_PROJECT_NAME =
-      Pattern.compile("[^a-zA-Z0-9._/-]+");
+  private static final Pattern UNSAFE_PROJECT_NAME = Pattern.compile("[^a-zA-Z0-9._/-]+");
 
   protected Git git() {
     return testRepo.git();
@@ -402,10 +426,9 @@
 
   /**
    * Return a resource name scoped to this test method.
-   * <p>
-   * Test methods in a single class by default share a running server. For any
-   * resource name you require to be unique to a test method, wrap it in a call
-   * to this method.
+   *
+   * <p>Test methods in a single class by default share a running server. For any resource name you
+   * require to be unique to a test method, wrap it in a call to this method.
    *
    * @param name resource name (group, project, topic, etc.)
    * @return name prefixed by a string unique to this test method.
@@ -414,31 +437,31 @@
     return resourcePrefix + name;
   }
 
-  protected Project.NameKey createProject(String nameSuffix)
-      throws RestApiException {
+  protected Project.NameKey createProject(String nameSuffix) throws RestApiException {
     return createProject(nameSuffix, null);
   }
 
-  protected Project.NameKey createProject(String nameSuffix,
-      Project.NameKey parent) throws RestApiException {
+  protected Project.NameKey createProject(String nameSuffix, Project.NameKey parent)
+      throws RestApiException {
     // Default for createEmptyCommit should match TestProjectConfig.
     return createProject(nameSuffix, parent, true, null);
   }
 
-  protected Project.NameKey createProject(String nameSuffix,
-      Project.NameKey parent, boolean createEmptyCommit) throws RestApiException {
+  protected Project.NameKey createProject(
+      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit)
+      throws RestApiException {
     // Default for createEmptyCommit should match TestProjectConfig.
     return createProject(nameSuffix, parent, createEmptyCommit, null);
   }
 
-  protected Project.NameKey createProject(String nameSuffix,
-      Project.NameKey parent, SubmitType submitType) throws RestApiException {
+  protected Project.NameKey createProject(
+      String nameSuffix, Project.NameKey parent, SubmitType submitType) throws RestApiException {
     // Default for createEmptyCommit should match TestProjectConfig.
     return createProject(nameSuffix, parent, true, submitType);
   }
 
-  protected Project.NameKey createProject(String nameSuffix,
-      Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
+  protected Project.NameKey createProject(
+      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
       throws RestApiException {
     ProjectInput in = new ProjectInput();
     in.name = name(nameSuffix);
@@ -448,8 +471,7 @@
     return createProject(in);
   }
 
-  private Project.NameKey createProject(ProjectInput in)
-      throws RestApiException {
+  private Project.NameKey createProject(ProjectInput in) throws RestApiException {
     gApi.projects().create(in);
     return new Project.NameKey(in.name);
   }
@@ -463,31 +485,35 @@
     // Default implementation does nothing.
   }
 
-  protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p)
-      throws Exception {
+  protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p) throws Exception {
     return cloneProject(p, admin);
   }
 
-  protected TestRepository<InMemoryRepository> cloneProject(Project.NameKey p,
-      TestAccount testAccount) throws Exception {
-    InProcessProtocol.Context ctx = new InProcessProtocol.Context(
-        reviewDbProvider, identifiedUserFactory, testAccount.getId(), p);
+  protected TestRepository<InMemoryRepository> cloneProject(
+      Project.NameKey p, TestAccount testAccount) throws Exception {
+    InProcessProtocol.Context ctx =
+        new InProcessProtocol.Context(
+            reviewDbProvider, identifiedUserFactory, testAccount.getId(), p);
     Repository repo = repoManager.openRepository(p);
     toClose.add(repo);
-    return GitUtil.cloneProject(
-        p, inProcessProtocol.register(ctx, repo).toString());
+    return GitUtil.cloneProject(p, inProcessProtocol.register(ctx, repo).toString());
   }
 
-  private void afterTest() throws Exception {
+  protected void afterTest() throws Exception {
     Transport.unregister(inProcessProtocol);
     for (Repository repo : toClose) {
       repo.close();
     }
     db.close();
-    adminSshSession.close();
-    userSshSession.close();
+    if (adminSshSession != null) {
+      adminSshSession.close();
+    }
+    if (userSshSession != null) {
+      userSshSession.close();
+    }
     if (server != commonServer) {
-      server.stop();
+      server.close();
+      server = null;
     }
   }
 
@@ -520,23 +546,43 @@
     return result;
   }
 
-  protected PushOneCommit.Result createMergeCommitChange(String ref)
-      throws Exception {
+  protected PushOneCommit.Result createMergeCommitChange(String ref) throws Exception {
+    return createMergeCommitChange(ref, "foo");
+  }
+
+  protected PushOneCommit.Result createMergeCommitChange(String ref, String file) throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
-    PushOneCommit.Result p1 = pushFactory.create(db, admin.getIdent(),
-        testRepo, "parent 1", ImmutableMap.of("foo", "foo-1", "bar", "bar-1"))
-        .to(ref);
+    PushOneCommit.Result p1 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                "parent 1",
+                ImmutableMap.of(file, "foo-1", "bar", "bar-1"))
+            .to(ref);
 
     // reset HEAD in order to create a sibling of the first change
     testRepo.reset(initial);
 
-    PushOneCommit.Result p2 = pushFactory.create(db, admin.getIdent(),
-        testRepo, "parent 2", ImmutableMap.of("foo", "foo-2", "bar", "bar-2"))
-        .to(ref);
+    PushOneCommit.Result p2 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                "parent 2",
+                ImmutableMap.of(file, "foo-2", "bar", "bar-2"))
+            .to(ref);
 
-    PushOneCommit m = pushFactory.create(db, admin.getIdent(), testRepo, "merge",
-        ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    PushOneCommit m =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "merge",
+            ImmutableMap.of(file, "foo-1", "bar", "bar-2"));
     m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
     PushOneCommit.Result result = m.to(ref);
     result.assertOkStatus();
@@ -547,26 +593,29 @@
     return pushTo("refs/drafts/master");
   }
 
-  protected PushOneCommit.Result createChange(String subject,
-      String fileName, String content) throws Exception {
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), testRepo, subject, fileName, content);
+  protected PushOneCommit.Result createChange(String subject, String fileName, String content)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
     return push.to("refs/for/master");
   }
 
-  protected PushOneCommit.Result createChange(String subject,
-      String fileName, String content, String topic)
-          throws Exception {
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), testRepo, subject, fileName, content);
+  protected PushOneCommit.Result createChange(
+      String subject, String fileName, String content, String topic) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
     return push.to("refs/for/master/" + name(topic));
   }
 
-  protected PushOneCommit.Result createChange(TestRepository<?> repo,
-      String branch, String subject, String fileName, String content,
-      String topic) throws Exception {
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), repo, subject, fileName, content);
+  protected PushOneCommit.Result createChange(
+      TestRepository<?> repo,
+      String branch,
+      String subject,
+      String fileName,
+      String content,
+      String topic)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
     return push.to("refs/for/" + branch + "/" + name(topic));
   }
 
@@ -577,35 +626,54 @@
         .create(new BranchInput());
   }
 
-  protected BranchApi createBranchWithRevision(Branch.NameKey branch,
-      String revision) throws Exception {
+  protected BranchApi createBranchWithRevision(Branch.NameKey branch, String revision)
+      throws Exception {
     BranchInput in = new BranchInput();
     in.revision = revision;
-    return gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get())
-        .create(in);
+    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get()).create(in);
   }
 
   private static final List<Character> RANDOM =
-      Chars.asList(new char[]{'a','b','c','d','e','f','g','h'});
-  protected PushOneCommit.Result amendChange(String changeId)
-      throws Exception {
+      Chars.asList(new char[] {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'});
+
+  protected PushOneCommit.Result amendChange(String changeId) throws Exception {
     return amendChange(changeId, "refs/for/master");
   }
 
-  protected PushOneCommit.Result amendChange(String changeId, String ref)
-      throws Exception {
+  protected PushOneCommit.Result amendChange(String changeId, String ref) throws Exception {
     return amendChange(changeId, ref, admin, testRepo);
   }
 
-  protected PushOneCommit.Result amendChange(String changeId, String ref,
-      TestAccount testAccount, TestRepository<?> repo) throws Exception {
+  protected PushOneCommit.Result amendChange(
+      String changeId, String ref, TestAccount testAccount, TestRepository<?> repo)
+      throws Exception {
     Collections.shuffle(RANDOM);
+    return amendChange(
+        changeId,
+        ref,
+        testAccount,
+        repo,
+        PushOneCommit.SUBJECT,
+        PushOneCommit.FILE_NAME,
+        new String(Chars.toArray(RANDOM)));
+  }
+
+  protected PushOneCommit.Result amendChange(
+      String changeId, String subject, String fileName, String content) throws Exception {
+    return amendChange(changeId, "refs/for/master", admin, testRepo, subject, fileName, content);
+  }
+
+  protected PushOneCommit.Result amendChange(
+      String changeId,
+      String ref,
+      TestAccount testAccount,
+      TestRepository<?> repo,
+      String subject,
+      String fileName,
+      String content)
+      throws Exception {
     PushOneCommit push =
-        pushFactory.create(db, testAccount.getIdent(), repo,
-            PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
-            new String(Chars.toArray(RANDOM)), changeId);
+        pushFactory.create(db, testAccount.getIdent(), repo, subject, fileName, content, changeId);
     return push.to(ref);
   }
 
@@ -614,30 +682,26 @@
     revision(r).submit();
   }
 
-  protected PushOneCommit.Result amendChangeAsDraft(String changeId)
-      throws Exception {
+  protected PushOneCommit.Result amendChangeAsDraft(String changeId) throws Exception {
     return amendChange(changeId, "refs/drafts/master");
   }
 
-  protected ChangeInfo info(String id)
-      throws RestApiException {
+  protected ChangeInfo info(String id) throws RestApiException {
     return gApi.changes().id(id).info();
   }
 
-  protected ChangeInfo get(String id)
-      throws RestApiException {
+  protected ChangeInfo get(String id) throws RestApiException {
     return gApi.changes().id(id).get();
   }
 
-  protected EditInfo getEdit(String id)
-      throws RestApiException {
-    return gApi.changes().id(id).getEdit();
+  protected Optional<EditInfo> getEdit(String id) throws RestApiException {
+    return gApi.changes().id(id).edit().get();
   }
 
-  protected ChangeInfo get(String id, ListChangesOption... options)
-      throws RestApiException {
-    return gApi.changes().id(id).get(
-        Sets.newEnumSet(Arrays.asList(options), ListChangesOption.class));
+  protected ChangeInfo get(String id, ListChangesOption... options) throws RestApiException {
+    return gApi.changes()
+        .id(id)
+        .get(Sets.newEnumSet(Arrays.asList(options), ListChangesOption.class));
   }
 
   protected List<ChangeInfo> query(String q) throws RestApiException {
@@ -645,17 +709,28 @@
   }
 
   private Context newRequestContext(TestAccount account) {
-    return atrScope.newContext(reviewDbProvider, new SshSession(server, account),
+    return atrScope.newContext(
+        reviewDbProvider,
+        new SshSession(server, account),
         identifiedUserFactory.create(account.getId()));
   }
 
+  /**
+   * Enforce a new request context for the current API user.
+   *
+   * <p>This recreates the IdentifiedUser, hence everything which is cached in the IdentifiedUser is
+   * reloaded (e.g. the email addresses of the user).
+   */
+  protected Context resetCurrentApiUser() {
+    return atrScope.set(newRequestContext(atrScope.get().getSession().getAccount()));
+  }
+
   protected Context setApiUser(TestAccount account) {
     return atrScope.set(newRequestContext(account));
   }
 
   protected Context setApiUserAnonymous() {
-    return atrScope.set(
-        atrScope.newContext(reviewDbProvider, null, anonymousUser.get()));
+    return atrScope.set(atrScope.newContext(reviewDbProvider, null, anonymousUser.get()));
   }
 
   protected Context disableDb() {
@@ -668,30 +743,43 @@
     atrScope.set(preDisableContext);
   }
 
+  protected void disableChangeIndexWrites() {
+    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
+      if (!(i instanceof ReadOnlyChangeIndex)) {
+        changeIndexes.addWriteIndex(new ReadOnlyChangeIndex(i));
+      }
+    }
+  }
+
+  protected void enableChangeIndexWrites() {
+    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
+      if (i instanceof ReadOnlyChangeIndex) {
+        changeIndexes.addWriteIndex(((ReadOnlyChangeIndex) i).unwrap());
+      }
+    }
+  }
+
   protected static Gson newGson() {
     return OutputFormat.JSON_COMPACT.newGson();
   }
 
   protected RevisionApi revision(PushOneCommit.Result r) throws Exception {
-    return gApi.changes()
-        .id(r.getChangeId())
-        .current();
+    return gApi.changes().id(r.getChangeId()).current();
   }
 
-  protected void allow(String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
+  protected void allow(String permission, AccountGroup.UUID id, String ref) throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     Util.allow(cfg, permission, id, ref);
     saveProjectConfig(project, cfg);
   }
 
-  protected void allowGlobalCapabilities(AccountGroup.UUID id,
-      String... capabilityNames) throws Exception {
+  protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
+      throws Exception {
     allowGlobalCapabilities(id, Arrays.asList(capabilityNames));
   }
 
-  protected void allowGlobalCapabilities(AccountGroup.UUID id,
-      Iterable<String> capabilityNames) throws Exception {
+  protected void allowGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
+      throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
     for (String capabilityName : capabilityNames) {
       Util.allow(cfg, capabilityName, id);
@@ -699,13 +787,13 @@
     saveProjectConfig(allProjects, cfg);
   }
 
-  protected void removeGlobalCapabilities(AccountGroup.UUID id,
-      String... capabilityNames) throws Exception {
+  protected void removeGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
+      throws Exception {
     removeGlobalCapabilities(id, Arrays.asList(capabilityNames));
   }
 
-  protected void removeGlobalCapabilities(AccountGroup.UUID id,
-      Iterable<String> capabilityNames) throws Exception {
+  protected void removeGlobalCapabilities(AccountGroup.UUID id, Iterable<String> capabilityNames)
+      throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
     for (String capabilityName : capabilityNames) {
       Util.remove(cfg, capabilityName, id);
@@ -713,8 +801,7 @@
     saveProjectConfig(allProjects, cfg);
   }
 
-  protected void setUseContributorAgreements(InheritableBoolean value)
-      throws Exception {
+  protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = ProjectConfig.read(md);
       config.getProject().setUseContributorAgreements(value);
@@ -723,8 +810,7 @@
     }
   }
 
-  protected void setUseSignedOffBy(InheritableBoolean value)
-      throws Exception {
+  protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = ProjectConfig.read(md);
       config.getProject().setUseSignedOffBy(value);
@@ -742,13 +828,12 @@
     }
   }
 
-  protected void deny(String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
+  protected void deny(String permission, AccountGroup.UUID id, String ref) throws Exception {
     deny(project, permission, id, ref);
   }
 
-  protected void deny(Project.NameKey p, String permission,
-      AccountGroup.UUID id, String ref) throws Exception {
+  protected void deny(Project.NameKey p, String permission, AccountGroup.UUID id, String ref)
+      throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
     Util.deny(cfg, permission, id, ref);
     saveProjectConfig(p, cfg);
@@ -759,8 +844,8 @@
     return block(permission, id, ref, project);
   }
 
-  protected PermissionRule block(String permission,
-      AccountGroup.UUID id, String ref, Project.NameKey project)
+  protected PermissionRule block(
+      String permission, AccountGroup.UUID id, String ref, Project.NameKey project)
       throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     PermissionRule rule = Util.block(cfg, permission, id, ref);
@@ -768,8 +853,15 @@
     return rule;
   }
 
-  protected void saveProjectConfig(Project.NameKey p, ProjectConfig cfg)
+  protected void blockLabel(
+      String label, int min, int max, AccountGroup.UUID id, String ref, Project.NameKey project)
       throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.LABEL + label, min, max, id, ref);
+    saveProjectConfig(project, cfg);
+  }
+
+  protected void saveProjectConfig(Project.NameKey p, ProjectConfig cfg) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(p)) {
       md.setAuthor(identifiedUserFactory.create(admin.getId()));
       cfg.commit(md);
@@ -786,18 +878,19 @@
     grant(permission, project, ref, false);
   }
 
-  protected void grant(String permission, Project.NameKey project, String ref,
-      boolean force) throws RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    AccountGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators"));
+  protected void grant(String permission, Project.NameKey project, String ref, boolean force)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
     grant(permission, project, ref, force, adminGroup.getGroupUUID());
   }
 
-  protected void grant(String permission, Project.NameKey project, String ref,
-      boolean force, AccountGroup.UUID groupUUID)
-          throws RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
+  protected void grant(
+      String permission,
+      Project.NameKey project,
+      String ref,
+      boolean force,
+      AccountGroup.UUID groupUUID)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Grant %s on %s", permission, ref));
       ProjectConfig config = ProjectConfig.read(md);
@@ -811,12 +904,51 @@
     }
   }
 
+  protected void grantLabel(
+      String label,
+      int min,
+      int max,
+      Project.NameKey project,
+      String ref,
+      boolean force,
+      AccountGroup.UUID groupUUID,
+      boolean exclusive)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    String permission = Permission.LABEL + label;
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      md.setMessage(String.format("Grant %s on %s", permission, ref));
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection s = config.getAccessSection(ref, true);
+      Permission p = s.getPermission(permission, true);
+      p.setExclusiveGroup(exclusive);
+      PermissionRule rule = Util.newRule(config, groupUUID);
+      rule.setForce(force);
+      rule.setMin(min);
+      rule.setMax(max);
+      p.add(rule);
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
+  protected void removePermission(String permission, Project.NameKey project, String ref)
+      throws IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      md.setMessage(String.format("Remove %s on %s", permission, ref));
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection s = config.getAccessSection(ref, true);
+      Permission p = s.getPermission(permission, true);
+      p.getRules().clear();
+      config.commit(md);
+      projectCache.evict(config.getProject());
+    }
+  }
+
   protected void blockRead(String ref) throws Exception {
     block(Permission.READ, REGISTERED_USERS, ref);
   }
 
-  protected void blockForgeCommitter(Project.NameKey project, String ref)
-      throws Exception {
+  protected void blockForgeCommitter(Project.NameKey project, String ref) throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     Util.block(cfg, Permission.FORGE_COMMITTER, REGISTERED_USERS, ref);
     saveProjectConfig(project, cfg);
@@ -828,78 +960,62 @@
   }
 
   protected void approve(String id) throws Exception {
-    gApi.changes()
-      .id(id)
-      .revision("current")
-      .review(ReviewInput.approve());
+    gApi.changes().id(id).revision("current").review(ReviewInput.approve());
+  }
+
+  protected void recommend(String id) throws Exception {
+    gApi.changes().id(id).revision("current").review(ReviewInput.recommend());
   }
 
   protected Map<String, ActionInfo> getActions(String id) throws Exception {
-    return gApi.changes()
-      .id(id)
-      .revision(1)
-      .actions();
+    return gApi.changes().id(id).revision(1).actions();
+  }
+
+  protected String getETag(String id) throws Exception {
+    return gApi.changes().id(id).current().etag();
   }
 
   private static Iterable<String> changeIds(Iterable<ChangeInfo> changes) {
-    return Iterables.transform(changes,
-        new Function<ChangeInfo, String>() {
-          @Override
-          public String apply(ChangeInfo input) {
-            return input.changeId;
-          }
-        });
+    return Iterables.transform(changes, i -> i.changeId);
   }
 
-  protected void assertSubmittedTogether(String chId, String... expected)
-      throws Exception {
+  protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
     List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
     SubmittedTogetherInfo info =
-        gApi.changes()
-            .id(chId)
-            .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+        gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
 
     assertThat(info.nonVisibleChanges).isEqualTo(0);
     assertThat(actual).hasSize(expected.length);
-    assertThat(changeIds(actual))
-        .containsExactly((Object[])expected).inOrder();
-    assertThat(changeIds(info.changes))
-        .containsExactly((Object[])expected).inOrder();
+    assertThat(changeIds(actual)).containsExactly((Object[]) expected).inOrder();
+    assertThat(changeIds(info.changes)).containsExactly((Object[]) expected).inOrder();
   }
 
   protected PatchSet getPatchSet(PatchSet.Id psId) throws OrmException {
-    return changeDataFactory.create(db, project, psId.getParentKey())
-        .patchSet(psId);
+    return changeDataFactory.create(db, project, psId.getParentKey()).patchSet(psId);
   }
 
   protected IdentifiedUser user(TestAccount testAccount) {
     return identifiedUserFactory.create(testAccount.getId());
   }
 
-  protected RevisionResource parseCurrentRevisionResource(String changeId)
-      throws Exception {
+  protected RevisionResource parseCurrentRevisionResource(String changeId) throws Exception {
     ChangeResource cr = parseChangeResource(changeId);
     int psId = cr.getChange().currentPatchSetId().get();
-    return revisions.parse(cr,
-        IdString.fromDecoded(Integer.toString(psId)));
+    return revisions.parse(cr, IdString.fromDecoded(Integer.toString(psId)));
   }
 
-  protected RevisionResource parseRevisionResource(String changeId, int n)
-      throws Exception {
-    return revisions.parse(parseChangeResource(changeId),
-        IdString.fromDecoded(Integer.toString(n)));
+  protected RevisionResource parseRevisionResource(String changeId, int n) throws Exception {
+    return revisions.parse(
+        parseChangeResource(changeId), IdString.fromDecoded(Integer.toString(n)));
   }
 
-  protected RevisionResource parseRevisionResource(PushOneCommit.Result r)
-      throws Exception {
+  protected RevisionResource parseRevisionResource(PushOneCommit.Result r) throws Exception {
     PatchSet.Id psId = r.getPatchSetId();
     return parseRevisionResource(psId.getParentKey().toString(), psId.get());
   }
 
-  protected ChangeResource parseChangeResource(String changeId)
-      throws Exception {
-    List<ChangeControl> ctls = changeFinder.find(
-        changeId, atrScope.get().getUser());
+  protected ChangeResource parseChangeResource(String changeId) throws Exception {
+    List<ChangeControl> ctls = changeFinder.find(changeId, atrScope.get().getUser());
     assertThat(ctls).hasSize(1);
     return changeResourceFactory.create(ctls.get(0));
   }
@@ -908,6 +1024,14 @@
     return createGroup(name, "Administrators");
   }
 
+  protected String createGroupWithRealName(String name) throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.ownerId = "Administrators";
+    gApi.groups().create(in);
+    return name;
+  }
+
   protected String createGroup(String name, String owner) throws Exception {
     name = name(name);
     GroupInput in = new GroupInput();
@@ -919,7 +1043,8 @@
 
   protected RevCommit getHead(Repository repo, String name) throws Exception {
     try (RevWalk rw = new RevWalk(repo)) {
-      return rw.parseCommit(repo.exactRef(name).getObjectId());
+      Ref r = repo.exactRef(name);
+      return r != null ? rw.parseCommit(r.getObjectId()) : null;
     }
   }
 
@@ -927,16 +1052,13 @@
     return getHead(repo, "HEAD");
   }
 
-  protected RevCommit getRemoteHead(Project.NameKey project, String branch)
-      throws Exception {
+  protected RevCommit getRemoteHead(Project.NameKey project, String branch) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      return getHead(repo,
-          branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
+      return getHead(repo, branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
     }
   }
 
-  protected RevCommit getRemoteHead(String project, String branch)
-      throws Exception {
+  protected RevCommit getRemoteHead(String project, String branch) throws Exception {
     return getRemoteHead(new Project.NameKey(project), branch);
   }
 
@@ -944,20 +1066,232 @@
     return getRemoteHead(project, "master");
   }
 
-  protected void assertMailFrom(Message message, String email)
-      throws Exception {
-    assertThat(message.headers()).containsKey("Reply-To");
-    EmailHeader.String replyTo =
-        (EmailHeader.String)message.headers().get("Reply-To");
-    assertThat(replyTo.getString()).isEqualTo(email);
+  protected void grantTagPermissions() throws Exception {
+    grant(Permission.CREATE, project, R_TAGS + "*");
+    grant(Permission.DELETE, project, R_TAGS + "");
+    grant(Permission.CREATE_TAG, project, R_TAGS + "*");
+    grant(Permission.CREATE_SIGNED_TAG, project, R_TAGS + "*");
   }
 
-  protected TestRepository<?> createProjectWithPush(String name,
-      @Nullable Project.NameKey parent,
-      SubmitType submitType) throws Exception {
+  protected void assertMailReplyTo(Message message, String email) throws Exception {
+    assertThat(message.headers()).containsKey("Reply-To");
+    EmailHeader.String replyTo = (EmailHeader.String) message.headers().get("Reply-To");
+    assertThat(replyTo.getString()).contains(email);
+  }
+
+  protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
+      throws Exception {
+    ContributorAgreement ca;
+    if (autoVerify) {
+      String g = createGroup("cla-test-group");
+      GroupApi groupApi = gApi.groups().id(g);
+      groupApi.description("CLA test group");
+      AccountGroup caGroup = groupCache.get(new AccountGroup.UUID(groupApi.detail().id));
+      GroupReference groupRef = GroupReference.forGroup(caGroup);
+      PermissionRule rule = new PermissionRule(groupRef);
+      rule.setAction(PermissionRule.Action.ALLOW);
+      ca = new ContributorAgreement("cla-test");
+      ca.setAutoVerify(groupRef);
+      ca.setAccepted(ImmutableList.of(rule));
+    } else {
+      ca = new ContributorAgreement("cla-test-no-auto-verify");
+    }
+    ca.setDescription("description");
+    ca.setAgreementUrl("agreement-url");
+
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    cfg.replace(ca);
+    saveProjectConfig(allProjects, cfg);
+    return ca;
+  }
+
+  protected BinaryResult submitPreview(String changeId) throws Exception {
+    return gApi.changes().id(changeId).current().submitPreview();
+  }
+
+  protected BinaryResult submitPreview(String changeId, String format) throws Exception {
+    return gApi.changes().id(changeId).current().submitPreview(format);
+  }
+
+  protected Map<Branch.NameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
+    try (BinaryResult result = submitPreview(changeId)) {
+      return fetchFromBundles(result);
+    }
+  }
+
+  /**
+   * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
+   * resulting tree id.
+   */
+  protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
+    assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
+
+    FileSystem fs = Jimfs.newFileSystem();
+    Path previewPath = fs.getPath("preview.zip");
+    try (OutputStream out = Files.newOutputStream(previewPath)) {
+      bundles.writeTo(out);
+    }
+    Map<Branch.NameKey, ObjectId> ret = new HashMap<>();
+    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, null);
+        DirectoryStream<Path> dirStream =
+            Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
+      for (Path p : dirStream) {
+        if (!Files.isRegularFile(p)) {
+          continue;
+        }
+        String bundleName = p.getFileName().toString();
+        int len = bundleName.length();
+        assertThat(bundleName).endsWith(".git");
+        String repoName = bundleName.substring(0, len - 4);
+        Project.NameKey proj = new Project.NameKey(repoName);
+        TestRepository<?> localRepo = cloneProject(proj);
+
+        try (InputStream bundleStream = Files.newInputStream(p);
+            TransportBundleStream tbs =
+                new TransportBundleStream(
+                    localRepo.getRepository(), new URIish(bundleName), bundleStream)) {
+          FetchResult fr =
+              tbs.fetch(
+                  NullProgressMonitor.INSTANCE,
+                  Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
+          for (Ref r : fr.getAdvertisedRefs()) {
+            String branchName = r.getName();
+            Branch.NameKey n = new Branch.NameKey(proj, branchName);
+
+            RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
+            ret.put(n, c.getTree().copy());
+          }
+        }
+      }
+    }
+    assertThat(ret).isNotEmpty();
+    return ret;
+  }
+
+  /** Assert that the given branches have the given tree ids. */
+  protected void assertTrees(Project.NameKey proj, Map<Branch.NameKey, ObjectId> trees)
+      throws Exception {
+    TestRepository<?> localRepo = cloneProject(proj);
+    GitUtil.fetch(localRepo, "refs/*:refs/*");
+    Map<String, Ref> refs = localRepo.getRepository().getAllRefs();
+    Map<Branch.NameKey, RevTree> refValues = new HashMap<>();
+
+    for (Branch.NameKey b : trees.keySet()) {
+      if (!b.getParentKey().equals(proj)) {
+        continue;
+      }
+
+      Ref r = refs.get(b.get());
+      assertThat(r).isNotNull();
+      RevWalk rw = localRepo.getRevWalk();
+      RevCommit c = rw.parseCommit(r.getObjectId());
+      refValues.put(b, c.getTree());
+
+      assertThat(trees.get(b)).isEqualTo(refValues.get(b));
+    }
+    assertThat(refValues.keySet()).containsAnyIn(trees.keySet());
+  }
+
+  protected void assertDiffForNewFile(
+      DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
+    List<String> expectedLines = new ArrayList<>();
+    for (String line : expectedContentSideB.split("\n")) {
+      expectedLines.add(line);
+    }
+
+    assertThat(diff.binary).isNull();
+    assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
+    assertThat(diff.diffHeader).isNotNull();
+    assertThat(diff.intralineStatus).isNull();
+    assertThat(diff.webLinks).isNull();
+
+    assertThat(diff.metaA).isNull();
+    assertThat(diff.metaB).isNotNull();
+    assertThat(diff.metaB.commitId).isEqualTo(commit.name());
+
+    String expectedContentType = "text/plain";
+    if (COMMIT_MSG.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
+    } else if (MERGE_LIST.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
+    }
+    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
+
+    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.webLinks).isNull();
+
+    assertThat(diff.content).hasSize(1);
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines).inOrder();
+    assertThat(contentEntry.a).isNull();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
+
+  protected TestRepository<?> createProjectWithPush(
+      String name, @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
     Project.NameKey project = createProject(name, parent, true, submitType);
     grant(Permission.PUSH, project, "refs/heads/*");
     grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
     return cloneProject(project);
   }
+
+  protected void assertPermitted(ChangeInfo info, String label, Integer... expected) {
+    assertThat(info.permittedLabels).isNotNull();
+    Collection<String> strs = info.permittedLabels.get(label);
+    if (expected.length == 0) {
+      assertThat(strs).isNull();
+    } else {
+      assertThat(strs.stream().map(s -> Integer.valueOf(s.trim())).collect(toList()))
+          .containsExactlyElementsIn(Arrays.asList(expected));
+    }
+  }
+
+  protected void assertNotifyTo(TestAccount expected) {
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
+        .containsExactly(expected.emailAddress);
+    assertThat(m.headers().get("CC").isEmpty()).isTrue();
+  }
+
+  protected void assertNotifyCc(TestAccount expected) {
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.headers().get("To").isEmpty()).isTrue();
+    assertThat(((EmailHeader.AddressList) m.headers().get("CC")).getAddressList())
+        .containsExactly(expected.emailAddress);
+  }
+
+  protected void assertNotifyBcc(TestAccount expected) {
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.headers().get("To").isEmpty()).isTrue();
+    assertThat(m.headers().get("CC").isEmpty()).isTrue();
+  }
+
+  protected void watch(String project, String filter) throws RestApiException {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project;
+    pwi.filter = filter;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+  }
+
+  protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
index 71d738c..b6547ef 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
@@ -30,14 +30,12 @@
 import com.google.inject.Provider;
 import com.google.inject.Scope;
 import com.google.inject.util.Providers;
-
 import java.util.HashMap;
 import java.util.Map;
 
 /** Guice scopes for state during an Acceptance Test connection. */
 public class AcceptanceTestRequestScope {
-  private static final Key<RequestCleanup> RC_KEY =
-      Key.get(RequestCleanup.class);
+  private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
 
   private static final Key<RequestScopedReviewDbProvider> DB_KEY =
       Key.get(RequestScopedReviewDbProvider.class);
@@ -53,16 +51,13 @@
     volatile long started;
     volatile long finished;
 
-    private Context(SchemaFactory<ReviewDb> sf, SshSession s,
-        CurrentUser u, long at) {
+    private Context(SchemaFactory<ReviewDb> sf, SshSession s, CurrentUser u, long at) {
       schemaFactory = sf;
       session = s;
       user = u;
       created = started = finished = at;
       map.put(RC_KEY, cleanup);
-      map.put(DB_KEY, new RequestScopedReviewDbProvider(
-          schemaFactory,
-          Providers.of(cleanup)));
+      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
     }
 
     private Context(Context p, SshSession s, CurrentUser c) {
@@ -117,7 +112,9 @@
     private final AcceptanceTestRequestScope atrScope;
 
     @Inject
-    Propagator(AcceptanceTestRequestScope atrScope, ThreadLocalRequestContext local,
+    Propagator(
+        AcceptanceTestRequestScope atrScope,
+        ThreadLocalRequestContext local,
         Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
       super(REQUEST, current, local, dbProviderProvider);
       this.atrScope = atrScope;
@@ -169,12 +166,13 @@
 
   public Context disableDb() {
     Context old = current.get();
-    SchemaFactory<ReviewDb> sf = new SchemaFactory<ReviewDb>() {
-      @Override
-      public ReviewDb open() {
-        return new DisabledReviewDb();
-      }
-    };
+    SchemaFactory<ReviewDb> sf =
+        new SchemaFactory<ReviewDb>() {
+          @Override
+          public ReviewDb open() {
+            return new DisabledReviewDb();
+          }
+        };
     Context ctx = new Context(sf, old.session, old.user, old.created);
 
     current.set(ctx);
@@ -186,30 +184,30 @@
     // Setting a new context with the same fields is enough to get the ReviewDb
     // provider to reopen the database.
     Context old = current.get();
-    return set(
-        new Context(old.schemaFactory, old.session, old.user, old.created));
+    return set(new Context(old.schemaFactory, old.session, old.user, old.created));
   }
 
   /** Returns exactly one instance per command executed. */
-  static final Scope REQUEST = new Scope() {
-    @Override
-    public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
-      return new Provider<T>() {
+  static final Scope REQUEST =
+      new Scope() {
         @Override
-        public T get() {
-          return requireContext().get(key, creator);
+        public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
+          return new Provider<T>() {
+            @Override
+            public T get() {
+              return requireContext().get(key, creator);
+            }
+
+            @Override
+            public String toString() {
+              return String.format("%s[%s]", creator, REQUEST);
+            }
+          };
         }
 
         @Override
         public String toString() {
-          return String.format("%s[%s]", creator, REQUEST);
+          return "Acceptance Test Scope.REQUEST";
         }
       };
-    }
-
-    @Override
-    public String toString() {
-      return "Acceptance Test Scope.REQUEST";
-    }
-  };
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
index bce0b5a..20ae2d1 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -14,33 +14,35 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.US_ASCII;
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gerrit.testutil.SshMode;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import com.jcraft.jsch.JSch;
 import com.jcraft.jsch.JSchException;
 import com.jcraft.jsch.KeyPair;
-
 import java.io.ByteArrayOutputStream;
 import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 @Singleton
@@ -53,16 +55,17 @@
   private final SshKeyCache sshKeyCache;
   private final AccountCache accountCache;
   private final AccountByEmailCache byEmailCache;
-  private final AccountIndexer indexer;
+  private final ExternalIdsUpdate.Server externalIdsUpdate;
 
   @Inject
-  AccountCreator(SchemaFactory<ReviewDb> schema,
+  AccountCreator(
+      SchemaFactory<ReviewDb> schema,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       GroupCache groupCache,
       SshKeyCache sshKeyCache,
       AccountCache accountCache,
       AccountByEmailCache byEmailCache,
-      AccountIndexer indexer) {
+      ExternalIdsUpdate.Server externalIdsUpdate) {
     accounts = new HashMap<>();
     reviewDbProvider = schema;
     this.authorizedKeys = authorizedKeys;
@@ -70,11 +73,11 @@
     this.sshKeyCache = sshKeyCache;
     this.accountCache = accountCache;
     this.byEmailCache = byEmailCache;
-    this.indexer = indexer;
+    this.externalIdsUpdate = externalIdsUpdate;
   }
 
-  public synchronized TestAccount create(String username, String email,
-      String fullName, String... groups) throws Exception {
+  public synchronized TestAccount create(
+      String username, String email, String fullName, String... groups) throws Exception {
     TestAccount account = accounts.get(username);
     if (account != null) {
       return account;
@@ -82,18 +85,14 @@
     try (ReviewDb db = reviewDbProvider.open()) {
       Account.Id id = new Account.Id(db.nextAccountId());
 
-      AccountExternalId extUser =
-          new AccountExternalId(id, new AccountExternalId.Key(
-              AccountExternalId.SCHEME_USERNAME, username));
+      List<ExternalId> extIds = new ArrayList<>(2);
       String httpPass = "http-pass";
-      extUser.setPassword(httpPass);
-      db.accountExternalIds().insert(Collections.singleton(extUser));
+      extIds.add(ExternalId.createUsername(username, id, httpPass));
 
       if (email != null) {
-        AccountExternalId extMailto = new AccountExternalId(id, getEmailKey(email));
-        extMailto.setEmailAddress(email);
-        db.accountExternalIds().insert(Collections.singleton(extMailto));
+        extIds.add(ExternalId.createEmail(id, email));
       }
+      externalIdsUpdate.create().insert(db, extIds);
 
       Account a = new Account(id, TimeUtil.nowTs());
       a.setFullName(fullName);
@@ -104,23 +103,24 @@
         for (String n : groups) {
           AccountGroup.NameKey k = new AccountGroup.NameKey(n);
           AccountGroup g = groupCache.get(k);
-          AccountGroupMember m =
-              new AccountGroupMember(new AccountGroupMember.Key(id, g.getId()));
+          checkArgument(g != null, "group not found: %s", n);
+          AccountGroupMember m = new AccountGroupMember(new AccountGroupMember.Key(id, g.getId()));
           db.accountGroupMembers().insert(Collections.singleton(m));
         }
       }
 
-      KeyPair sshKey = genSshKey();
-      authorizedKeys.addKey(id, publicKey(sshKey, email));
-      sshKeyCache.evict(username);
+      KeyPair sshKey = null;
+      if (SshMode.useSsh()) {
+        sshKey = genSshKey();
+        authorizedKeys.addKey(id, publicKey(sshKey, email));
+        sshKeyCache.evict(username);
+      }
 
+      accountCache.evict(id);
       accountCache.evictByUsername(username);
       byEmailCache.evict(email);
 
-      indexer.index(id);
-
-      account =
-          new TestAccount(id, username, email, fullName, sshKey, httpPass);
+      account = new TestAccount(id, username, email, fullName, sshKey, httpPass);
       accounts.put(username, account);
       return account;
     }
@@ -135,13 +135,11 @@
   }
 
   public TestAccount admin() throws Exception {
-    return create("admin", "admin@example.com", "Administrator",
-      "Administrators");
+    return create("admin", "admin@example.com", "Administrator", "Administrators");
   }
 
   public TestAccount admin2() throws Exception {
-    return create("admin2", "admin2@example.com", "Administrator2",
-      "Administrators");
+    return create("admin2", "admin2@example.com", "Administrator2", "Administrators");
   }
 
   public TestAccount user() throws Exception {
@@ -153,13 +151,7 @@
   }
 
   public TestAccount get(String username) {
-    return checkNotNull(
-        accounts.get(username),
-        "No TestAccount created for %s", username);
-  }
-
-  private AccountExternalId.Key getEmailKey(String email) {
-    return new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email);
+    return checkNotNull(accounts.get(username), "No TestAccount created for %s", username);
   }
 
   public static KeyPair genSshKey() throws JSchException {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java
index a325feb..a1d3e79 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AssertUtil.java
@@ -23,9 +23,8 @@
 import java.util.Set;
 
 public class AssertUtil {
-  public static <T> void assertPrefs(T actual, T expected,
-      String... fieldsToExclude)
-          throws IllegalArgumentException, IllegalAccessException {
+  public static <T> void assertPrefs(T actual, T expected, String... fieldsToExclude)
+      throws IllegalArgumentException, IllegalAccessException {
     Set<String> exludedFields = new HashSet<>(Arrays.asList(fieldsToExclude));
     for (Field field : actual.getClass().getDeclaredFields()) {
       if (exludedFields.contains(field.getName()) || skipField(field)) {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
new file mode 100644
index 0000000..4c3e021
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+
+public class ChangeIndexedCounter implements ChangeIndexedListener {
+  private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
+
+  @Override
+  public void onChangeIndexed(int id) {
+    countsByChange.incrementAndGet(id);
+  }
+
+  @Override
+  public void onChangeDeleted(int id) {
+    countsByChange.incrementAndGet(id);
+  }
+
+  public void clear() {
+    countsByChange.clear();
+  }
+
+  long getCount(ChangeInfo info) {
+    return countsByChange.get(info._number);
+  }
+
+  public void assertReindexOf(ChangeInfo info) {
+    assertReindexOf(info, 1);
+  }
+
+  public void assertReindexOf(ChangeInfo info, int expectedCount) {
+    assertThat(getCount(info)).isEqualTo(expectedCount);
+    assertThat(countsByChange).hasSize(1);
+    clear();
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
index b07ed30..0aa56cf 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
@@ -17,11 +17,12 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-
-import org.eclipse.jgit.lib.Config;
-
+import java.lang.annotation.Annotation;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
 
 class ConfigAnnotationParser {
   private static Splitter splitter = Splitter.on(".").trimResults();
@@ -44,6 +45,67 @@
     return cfg;
   }
 
+  static class GlobalPluginConfigToGerritConfig implements GerritConfig {
+    private final GlobalPluginConfig delegate;
+
+    GlobalPluginConfigToGerritConfig(GlobalPluginConfig delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public Class<? extends Annotation> annotationType() {
+      return delegate.annotationType();
+    }
+
+    @Override
+    public String name() {
+      return delegate.name();
+    }
+
+    @Override
+    public String value() {
+      return delegate.value();
+    }
+
+    @Override
+    public String[] values() {
+      return delegate.values();
+    }
+  }
+
+  static Map<String, Config> parse(GlobalPluginConfig annotation) {
+    if (annotation == null) {
+      return null;
+    }
+    Map<String, Config> result = new HashMap<>();
+    Config cfg = new Config();
+    parseAnnotation(cfg, new GlobalPluginConfigToGerritConfig(annotation));
+    result.put(annotation.pluginName(), cfg);
+    return result;
+  }
+
+  static Map<String, Config> parse(GlobalPluginConfigs annotation) {
+    if (annotation == null || annotation.value().length < 1) {
+      return null;
+    }
+
+    HashMap<String, Config> result = new HashMap<>();
+
+    for (GlobalPluginConfig c : annotation.value()) {
+      String pluginName = c.pluginName();
+      Config config;
+      if (result.containsKey(pluginName)) {
+        config = result.get(pluginName);
+      } else {
+        config = new Config();
+        result.put(pluginName, config);
+      }
+      parseAnnotation(config, new GlobalPluginConfigToGerritConfig(c));
+    }
+
+    return result;
+  }
+
   private static void parseAnnotation(Config cfg, GerritConfig c) {
     ArrayList<String> l = Lists.newArrayList(splitter.split(c.name()));
     if (l.size() == 2) {
@@ -57,13 +119,11 @@
       if (!Strings.isNullOrEmpty(c.value())) {
         cfg.setString(l.get(0), l.get(1), l.get(2), c.value());
       } else {
-        cfg.setStringList(l.get(0), l.get(1), l.get(2),
-            Arrays.asList(c.value()));
+        cfg.setStringList(l.get(0), l.get(1), l.get(2), Arrays.asList(c.values()));
       }
     } else {
       throw new IllegalArgumentException(
-          "GerritConfig.name must be of the format"
-              + " section.subsection.name or section.name");
+          "GerritConfig.name must be of the format section.subsection.name or section.name");
     }
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
index 6cc8d3c..9b77411 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -16,17 +16,17 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.UserScopedEventListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.ChangeDeletedEvent;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.RefEvent;
@@ -34,13 +34,12 @@
 import com.google.gerrit.server.events.ReviewerDeletedEvent;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public class EventRecorder {
   private final RegistrationHandle eventListenerRegistration;
-  private final Multimap<String, RefEvent> recordedEvents;
+  private final ListMultimap<String, RefEvent> recordedEvents;
 
   @Singleton
   public static class Factory {
@@ -48,7 +47,8 @@
     private final IdentifiedUser.GenericFactory userFactory;
 
     @Inject
-    Factory(DynamicSet<UserScopedEventListener> eventListeners,
+    Factory(
+        DynamicSet<UserScopedEventListener> eventListeners,
         IdentifiedUser.GenericFactory userFactory) {
       this.eventListeners = eventListeners;
       this.userFactory = userFactory;
@@ -59,49 +59,41 @@
     }
   }
 
-  public EventRecorder(DynamicSet<UserScopedEventListener> eventListeners,
-      final IdentifiedUser user) {
+  public EventRecorder(
+      DynamicSet<UserScopedEventListener> eventListeners, final IdentifiedUser user) {
     recordedEvents = LinkedListMultimap.create();
 
-    eventListenerRegistration = eventListeners.add(
-        new UserScopedEventListener() {
-          @Override
-          public void onEvent(Event e) {
-            if (e instanceof ReviewerDeletedEvent) {
-              recordedEvents.put(
-                  ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
-            } else if (e instanceof RefEvent) {
-              RefEvent event = (RefEvent) e;
-              String key = refEventKey(event.getType(),
-                  event.getProjectNameKey().get(),
-                  event.getRefName());
-              recordedEvents.put(key, event);
-            }
-          }
+    eventListenerRegistration =
+        eventListeners.add(
+            new UserScopedEventListener() {
+              @Override
+              public void onEvent(Event e) {
+                if (e instanceof ReviewerDeletedEvent) {
+                  recordedEvents.put(ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+                } else if (e instanceof ChangeDeletedEvent) {
+                  recordedEvents.put(ChangeDeletedEvent.TYPE, (ChangeDeletedEvent) e);
+                } else if (e instanceof RefEvent) {
+                  RefEvent event = (RefEvent) e;
+                  String key =
+                      refEventKey(
+                          event.getType(), event.getProjectNameKey().get(), event.getRefName());
+                  recordedEvents.put(key, event);
+                }
+              }
 
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-        });
+              @Override
+              public CurrentUser getUser() {
+                return user;
+              }
+            });
   }
 
   private static String refEventKey(String type, String project, String ref) {
     return String.format("%s-%s-%s", type, project, ref);
   }
 
-  private static class RefEventTransformer<T extends RefEvent>
-      implements Function<RefEvent, T> {
-
-    @SuppressWarnings("unchecked")
-    @Override
-    public T apply(RefEvent e) {
-      return (T) e;
-    }
-  }
-
-  private ImmutableList<RefUpdatedEvent> getRefUpdatedEvents(String project,
-      String refName, int expectedSize) {
+  private ImmutableList<RefUpdatedEvent> getRefUpdatedEvents(
+      String project, String refName, int expectedSize) {
     String key = refEventKey(RefUpdatedEvent.TYPE, project, refName);
     if (expectedSize == 0) {
       assertThat(recordedEvents).doesNotContainKey(key);
@@ -109,16 +101,16 @@
     }
 
     assertThat(recordedEvents).containsKey(key);
-    ImmutableList<RefUpdatedEvent> events = FluentIterable
-        .from(recordedEvents.get(key))
-        .transform(new RefEventTransformer<RefUpdatedEvent>())
-        .toList();
+    ImmutableList<RefUpdatedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(RefUpdatedEvent.class::cast)
+            .toList();
     assertThat(events).hasSize(expectedSize);
     return events;
   }
 
-  private ImmutableList<ChangeMergedEvent> getChangeMergedEvents(String project,
-      String branch, int expectedSize) {
+  private ImmutableList<ChangeMergedEvent> getChangeMergedEvents(
+      String project, String branch, int expectedSize) {
     String key = refEventKey(ChangeMergedEvent.TYPE, project, branch);
     if (expectedSize == 0) {
       assertThat(recordedEvents).doesNotContainKey(key);
@@ -126,90 +118,111 @@
     }
 
     assertThat(recordedEvents).containsKey(key);
-    ImmutableList<ChangeMergedEvent> events = FluentIterable
-        .from(recordedEvents.get(key))
-        .transform(new RefEventTransformer<ChangeMergedEvent>())
-        .toList();
+    ImmutableList<ChangeMergedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(ChangeMergedEvent.class::cast)
+            .toList();
     assertThat(events).hasSize(expectedSize);
     return events;
   }
 
-  private ImmutableList<ReviewerDeletedEvent> getReviewerDeletedEvents(
-      int expectedSize) {
+  private ImmutableList<ReviewerDeletedEvent> getReviewerDeletedEvents(int expectedSize) {
     String key = ReviewerDeletedEvent.TYPE;
     if (expectedSize == 0) {
       assertThat(recordedEvents).doesNotContainKey(key);
       return ImmutableList.of();
     }
     assertThat(recordedEvents).containsKey(key);
-    ImmutableList<ReviewerDeletedEvent> events = FluentIterable
-        .from(recordedEvents.get(key))
-        .transform(new RefEventTransformer<ReviewerDeletedEvent>())
-        .toList();
+    ImmutableList<ReviewerDeletedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(ReviewerDeletedEvent.class::cast)
+            .toList();
     assertThat(events).hasSize(expectedSize);
     return events;
   }
 
-  public void assertRefUpdatedEvents(String project, String branch,
-      String... expected) throws Exception {
-    ImmutableList<RefUpdatedEvent> events = getRefUpdatedEvents(project,
-        branch, expected.length / 2);
+  private ImmutableList<ChangeDeletedEvent> getChangeDeletedEvents(int expectedSize) {
+    String key = ChangeDeletedEvent.TYPE;
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ChangeDeletedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(ChangeDeletedEvent.class::cast)
+            .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
+  public void assertNoRefUpdatedEvents(String project, String branch) throws Exception {
+    getRefUpdatedEvents(project, branch, 0);
+  }
+
+  public void assertRefUpdatedEvents(String project, String branch, String... expected)
+      throws Exception {
+    ImmutableList<RefUpdatedEvent> events =
+        getRefUpdatedEvents(project, branch, expected.length / 2);
     int i = 0;
     for (RefUpdatedEvent event : events) {
       RefUpdateAttribute actual = event.refUpdate.get();
-      String oldRev = expected[i] == null
-          ? ObjectId.zeroId().name()
-          : expected[i];
-      String newRev = expected[i+1] == null
-          ? ObjectId.zeroId().name()
-          : expected[i+1];
+      String oldRev = expected[i] == null ? ObjectId.zeroId().name() : expected[i];
+      String newRev = expected[i + 1] == null ? ObjectId.zeroId().name() : expected[i + 1];
       assertThat(actual.oldRev).isEqualTo(oldRev);
       assertThat(actual.newRev).isEqualTo(newRev);
       i += 2;
     }
   }
 
-  public void assertRefUpdatedEvents(String project, String branch,
-      RevCommit... expected) throws Exception {
-    ImmutableList<RefUpdatedEvent> events = getRefUpdatedEvents(project,
-        branch, expected.length / 2);
+  public void assertRefUpdatedEvents(String project, String branch, RevCommit... expected)
+      throws Exception {
+    ImmutableList<RefUpdatedEvent> events =
+        getRefUpdatedEvents(project, branch, expected.length / 2);
     int i = 0;
     for (RefUpdatedEvent event : events) {
       RefUpdateAttribute actual = event.refUpdate.get();
-      String oldRev = expected[i] == null
-          ? ObjectId.zeroId().name()
-          : expected[i].name();
-      String newRev = expected[i+1] == null
-          ? ObjectId.zeroId().name()
-          : expected[i+1].name();
+      String oldRev = expected[i] == null ? ObjectId.zeroId().name() : expected[i].name();
+      String newRev = expected[i + 1] == null ? ObjectId.zeroId().name() : expected[i + 1].name();
       assertThat(actual.oldRev).isEqualTo(oldRev);
       assertThat(actual.newRev).isEqualTo(newRev);
       i += 2;
     }
   }
 
-  public void assertChangeMergedEvents(String project, String branch,
-      String... expected) throws Exception {
-    ImmutableList<ChangeMergedEvent> events = getChangeMergedEvents(project,
-        branch, expected.length / 2);
+  public void assertChangeMergedEvents(String project, String branch, String... expected)
+      throws Exception {
+    ImmutableList<ChangeMergedEvent> events =
+        getChangeMergedEvents(project, branch, expected.length / 2);
     int i = 0;
     for (ChangeMergedEvent event : events) {
       String id = event.change.get().id;
       assertThat(id).isEqualTo(expected[i]);
-      assertThat(event.newRev).isEqualTo(expected[i+1]);
+      assertThat(event.newRev).isEqualTo(expected[i + 1]);
       i += 2;
     }
   }
 
   public void assertReviewerDeletedEvents(String... expected) {
-    ImmutableList<ReviewerDeletedEvent> events =
-        getReviewerDeletedEvents(expected.length / 2);
+    ImmutableList<ReviewerDeletedEvent> events = getReviewerDeletedEvents(expected.length / 2);
     int i = 0;
     for (ReviewerDeletedEvent event : events) {
       String id = event.change.get().id;
       assertThat(id).isEqualTo(expected[i]);
       String reviewer = event.reviewer.get().email;
-      assertThat(reviewer).isEqualTo(expected[i+1]);
+      assertThat(reviewer).isEqualTo(expected[i + 1]);
+      i += 2;
+    }
+  }
+
+  public void assertChangeDeletedEvents(String... expected) {
+    ImmutableList<ChangeDeletedEvent> events = getChangeDeletedEvents(expected.length / 2);
+    int i = 0;
+    for (ChangeDeletedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      String reviewer = event.deleter.get().email;
+      assertThat(reviewer).isEqualTo(expected[i + 1]);
       i += 2;
     }
   }
@@ -217,4 +230,4 @@
   public void close() {
     eventListenerRegistration.remove();
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java
index 5f8a8ed..0cc72ec 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java
@@ -19,13 +19,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.File;
 import java.io.FilenameFilter;
 import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
 
 public class GcAssert {
 
@@ -40,9 +38,9 @@
       throws RepositoryNotFoundException, IOException {
     for (Project.NameKey p : projects) {
       assert_()
-        .withFailureMessage("Project " + p.get() + " has no pack files.")
-        .that(getPackFiles(p))
-        .isNotEmpty();
+          .withFailureMessage("Project " + p.get() + " has no pack files.")
+          .that(getPackFiles(p))
+          .isNotEmpty();
     }
   }
 
@@ -50,22 +48,22 @@
       throws RepositoryNotFoundException, IOException {
     for (Project.NameKey p : projects) {
       assert_()
-        .withFailureMessage("Project " + p.get() + " has pack files.")
-        .that(getPackFiles(p))
-        .isEmpty();
+          .withFailureMessage("Project " + p.get() + " has pack files.")
+          .that(getPackFiles(p))
+          .isEmpty();
     }
   }
 
-  private String[] getPackFiles(Project.NameKey p)
-      throws RepositoryNotFoundException, IOException {
+  private String[] getPackFiles(Project.NameKey p) throws RepositoryNotFoundException, IOException {
     try (Repository repo = repoManager.openRepository(p)) {
       File packDir = new File(repo.getDirectory(), "objects/pack");
-      return packDir.list(new FilenameFilter() {
-        @Override
-        public boolean accept(File dir, String name) {
-          return name.endsWith(".pack");
-        }
-      });
+      return packDir.list(
+          new FilenameFilter() {
+            @Override
+            public boolean accept(File dir, String name) {
+              return name.endsWith(".pack");
+            }
+          });
     }
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java
index 4b956a2..fe0c628 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritConfig.java
@@ -17,13 +17,26 @@
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
+import java.lang.annotation.Repeatable;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
 @Target({METHOD})
 @Retention(RUNTIME)
+@Repeatable(GerritConfigs.class)
 public @interface GerritConfig {
+  /**
+   * Setting name in the form {@code "section.name"} or {@code "section.subsection.name"} where
+   * {@code section}, {@code subsection} and {@code name} correspond to the parameters of the same
+   * names in JGit's {@code Config#getString} method.
+   *
+   * @see org.eclipse.jgit.lib.Config#getString(String, String, String)
+   */
   String name();
+
+  /** Single value. Takes precedence over values specified in {@code values}. */
   String value() default "";
+
+  /** Multiple values (list). Ignored if {@code value} is specified. */
   String[] values() default "";
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
index d1ec9e6..b489076 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
@@ -25,65 +28,84 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
 import com.google.gerrit.server.ssh.NoSshModule;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.NoteDbChecker;
 import com.google.gerrit.testutil.NoteDbMode;
+import com.google.gerrit.testutil.SshMode;
 import com.google.gerrit.testutil.TempFileUtil;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
-
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.RepositoryCache;
 import org.eclipse.jgit.util.FS;
 
-import java.io.File;
-import java.lang.annotation.Annotation;
-import java.lang.reflect.Field;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.URI;
-import java.util.concurrent.BrokenBarrierException;
-import java.util.concurrent.Callable;
-import java.util.concurrent.CyclicBarrier;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
+public class GerritServer implements AutoCloseable {
+  public static class StartupException extends Exception {
+    private static final long serialVersionUID = 1L;
 
-public class GerritServer {
+    StartupException(String msg, Throwable cause) {
+      super(msg, cause);
+    }
+  }
+
   @AutoValue
-  abstract static class Description {
-    static Description forTestClass(org.junit.runner.Description testDesc,
-        String configName) {
+  public abstract static class Description {
+    public static Description forTestClass(
+        org.junit.runner.Description testDesc, String configName) {
       return new AutoValue_GerritServer_Description(
+          testDesc,
           configName,
-          true, // @UseLocalDisk is only valid on methods.
+          !has(UseLocalDisk.class, testDesc.getTestClass()),
           !has(NoHttpd.class, testDesc.getTestClass()),
           has(Sandboxed.class, testDesc.getTestClass()),
+          has(UseSsh.class, testDesc.getTestClass()),
           null, // @GerritConfig is only valid on methods.
-          null); // @GerritConfigs is only valid on methods.
-
+          null, // @GerritConfigs is only valid on methods.
+          null, // @GlobalPluginConfig is only valid on methods.
+          null); // @GlobalPluginConfigs is only valid on methods.
     }
 
-    static Description forTestMethod(org.junit.runner.Description testDesc,
-        String configName) {
+    public static Description forTestMethod(
+        org.junit.runner.Description testDesc, String configName) {
       return new AutoValue_GerritServer_Description(
+          testDesc,
           configName,
-          testDesc.getAnnotation(UseLocalDisk.class) == null,
+          testDesc.getAnnotation(UseLocalDisk.class) == null
+              && !has(UseLocalDisk.class, testDesc.getTestClass()),
           testDesc.getAnnotation(NoHttpd.class) == null
-            && !has(NoHttpd.class, testDesc.getTestClass()),
-          testDesc.getAnnotation(Sandboxed.class) != null ||
-              has(Sandboxed.class, testDesc.getTestClass()),
+              && !has(NoHttpd.class, testDesc.getTestClass()),
+          testDesc.getAnnotation(Sandboxed.class) != null
+              || has(Sandboxed.class, testDesc.getTestClass()),
+          testDesc.getAnnotation(UseSsh.class) != null
+              || has(UseSsh.class, testDesc.getTestClass()),
           testDesc.getAnnotation(GerritConfig.class),
-          testDesc.getAnnotation(GerritConfigs.class));
+          testDesc.getAnnotation(GerritConfigs.class),
+          testDesc.getAnnotation(GlobalPluginConfig.class),
+          testDesc.getAnnotation(GlobalPluginConfigs.class));
     }
 
-    private static boolean has(
-        Class<? extends Annotation> annotation, Class<?> clazz) {
+    private static boolean has(Class<? extends Annotation> annotation, Class<?> clazz) {
       for (; clazz != null; clazz = clazz.getSuperclass()) {
         if (clazz.getAnnotation(annotation) != null) {
           return true;
@@ -92,18 +114,45 @@
       return false;
     }
 
-    @Nullable abstract String configName();
+    abstract org.junit.runner.Description testDescription();
+
+    @Nullable
+    abstract String configName();
+
     abstract boolean memory();
+
     abstract boolean httpd();
+
     abstract boolean sandboxed();
-    @Nullable abstract GerritConfig config();
-    @Nullable abstract GerritConfigs configs();
+
+    abstract boolean useSsh();
+
+    @Nullable
+    abstract GerritConfig config();
+
+    @Nullable
+    abstract GerritConfigs configs();
+
+    @Nullable
+    abstract GlobalPluginConfig pluginConfig();
+
+    @Nullable
+    abstract GlobalPluginConfigs pluginConfigs();
+
+    private void checkValidAnnotations() {
+      if (configs() != null && config() != null) {
+        throw new IllegalStateException("Use either @GerritConfigs or @GerritConfig not both");
+      }
+      if (pluginConfigs() != null && pluginConfig() != null) {
+        throw new IllegalStateException(
+            "Use either @GlobalPluginConfig or @GlobalPluginConfigs not both");
+      }
+      if ((pluginConfigs() != null || pluginConfig() != null) && memory()) {
+        throw new IllegalStateException("Must use @UseLocalDisk with @GlobalPluginConfig(s)");
+      }
+    }
 
     private Config buildConfig(Config baseConfig) {
-      if (configs() != null && config() != null) {
-        throw new IllegalStateException(
-            "Use either @GerritConfigs or @GerritConfig not both");
-      }
       if (configs() != null) {
         return ConfigAnnotationParser.parse(baseConfig, configs());
       } else if (config() != null) {
@@ -112,90 +161,175 @@
         return baseConfig;
       }
     }
-  }
 
-  /** Returns fully started Gerrit server */
-  static GerritServer start(Description desc, Config baseConfig)
-      throws Exception {
-    Config cfg = desc.buildConfig(baseConfig);
-    Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
-    final CyclicBarrier serverStarted = new CyclicBarrier(2);
-    final Daemon daemon = new Daemon(new Runnable() {
-      @Override
-      public void run() {
-        try {
-          serverStarted.await();
-        } catch (InterruptedException | BrokenBarrierException e) {
-          throw new RuntimeException(e);
-        }
+    private Map<String, Config> buildPluginConfigs() {
+      if (pluginConfigs() != null) {
+        return ConfigAnnotationParser.parse(pluginConfigs());
+      } else if (pluginConfig() != null) {
+        return ConfigAnnotationParser.parse(pluginConfig());
       }
-    });
-    daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
-
-    final File site;
-    ExecutorService daemonService = null;
-    if (desc.memory()) {
-      site = null;
-      mergeTestConfig(cfg);
-      // Set the log4j configuration to an invalid one to prevent system logs
-      // from getting configured and creating log files.
-      System.setProperty(SystemLog.LOG4J_CONFIGURATION, "invalidConfiguration");
-      cfg.setBoolean("httpd", null, "requestLog", false);
-      cfg.setBoolean("sshd", null, "requestLog", false);
-      cfg.setBoolean("index", "lucene", "testInmemory", true);
-      cfg.setString("gitweb", null, "cgi", "");
-      daemon.setEnableHttpd(desc.httpd());
-      daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0));
-      daemon.setDatabaseForTesting(ImmutableList.<Module>of(
-          new InMemoryTestingDatabaseModule(cfg)));
-      daemon.start();
-    } else {
-      site = initSite(cfg);
-      daemonService = Executors.newSingleThreadExecutor();
-      daemonService.submit(new Callable<Void>() {
-        @Override
-        public Void call() throws Exception {
-          int rc = daemon.main(new String[] {
-              "-d", site.getPath(),
-              "--headless", "--console-log", "--show-stack-trace",});
-          if (rc != 0) {
-            System.err.println("Failed to start Gerrit daemon");
-            serverStarted.reset();
-          }
-          return null;
-        }
-      });
-      serverStarted.await();
-      System.out.println("Gerrit Server Started");
+      return new HashMap<>();
     }
-
-    Injector i = createTestInjector(daemon);
-    return new GerritServer(desc, i, daemon, daemonService);
   }
 
-  private static File initSite(Config base) throws Exception {
-    File tmp = TempFileUtil.createTempDirectory();
+  /**
+   * Initializes on-disk site but does not start server.
+   *
+   * @param desc server description
+   * @param baseConfig default config values; merged with config from {@code desc} and then written
+   *     into {@code site/etc/gerrit.config}.
+   * @param site temp directory where site will live.
+   * @throws Exception
+   */
+  public static void init(Description desc, Config baseConfig, Path site) throws Exception {
+    checkArgument(!desc.memory(), "can't initialize site path for in-memory test: %s", desc);
+    Config cfg = desc.buildConfig(baseConfig);
+    Map<String, Config> pluginConfigs = desc.buildPluginConfigs();
+
+    MergeableFileBasedConfig gerritConfig =
+        new MergeableFileBasedConfig(
+            site.resolve("etc").resolve("gerrit.config").toFile(), FS.DETECTED);
+    gerritConfig.load();
+    gerritConfig.merge(cfg);
+    mergeTestConfig(gerritConfig);
+    gerritConfig.save();
+
     Init init = new Init();
-    int rc = init.main(new String[] {
-        "-d", tmp.getPath(), "--batch", "--no-auto-start",
-        "--skip-plugins",});
+    int rc =
+        init.main(
+            new String[] {
+              "-d", site.toString(), "--batch", "--no-auto-start", "--skip-plugins",
+            });
     if (rc != 0) {
       throw new RuntimeException("Couldn't initialize site");
     }
 
-    MergeableFileBasedConfig cfg = new MergeableFileBasedConfig(
-        new File(new File(tmp, "etc"), "gerrit.config"),
-        FS.DETECTED);
-    cfg.load();
-    cfg.merge(base);
+    for (String pluginName : pluginConfigs.keySet()) {
+      MergeableFileBasedConfig pluginCfg =
+          new MergeableFileBasedConfig(
+              site.resolve("etc").resolve(pluginName + ".config").toFile(), FS.DETECTED);
+      pluginCfg.load();
+      pluginCfg.merge(pluginConfigs.get(pluginName));
+      pluginCfg.save();
+    }
+  }
+
+  /**
+   * Initializes new Gerrit site and returns started server.
+   *
+   * <p>A new temporary directory for the site will be created with {@link TempFileUtil}, even in
+   * the server is otherwise configured in-memory. Closing the server stops the daemon but does not
+   * delete the temporary directory. Callers may either get the directory with {@link
+   * #getSitePath()} and delete it manually, or call {@link TempFileUtil#cleanup()}.
+   *
+   * @param desc server description.
+   * @param baseConfig default config values; merged with config from {@code desc}.
+   * @return started server.
+   * @throws Exception
+   */
+  public static GerritServer initAndStart(Description desc, Config baseConfig) throws Exception {
+    Path site = TempFileUtil.createTempDirectory().toPath();
+    try {
+      if (!desc.memory()) {
+        init(desc, baseConfig, site);
+      }
+      return start(desc, baseConfig, site);
+    } catch (Exception e) {
+      TempFileUtil.recursivelyDelete(site.toFile());
+      throw e;
+    }
+  }
+
+  /**
+   * Starts Gerrit server from existing on-disk site.
+   *
+   * @param desc server description.
+   * @param baseConfig default config values; merged with config from {@code desc}.
+   * @param site existing temporary directory for site. Required, but may be empty, for in-memory
+   *     servers. For on-disk servers, assumes that {@link #init} was previously called to
+   *     initialize this directory.
+   * @return started server.
+   * @throws Exception
+   */
+  public static GerritServer start(Description desc, Config baseConfig, Path site)
+      throws Exception {
+    checkArgument(site != null, "site is required (even for in-memory server");
+    desc.checkValidAnnotations();
+    Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
+    final CyclicBarrier serverStarted = new CyclicBarrier(2);
+    final Daemon daemon =
+        new Daemon(
+            new Runnable() {
+              @Override
+              public void run() {
+                try {
+                  serverStarted.await();
+                } catch (InterruptedException | BrokenBarrierException e) {
+                  throw new RuntimeException(e);
+                }
+              }
+            },
+            site);
+    daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
+    daemon.setEnableSshd(SshMode.useSsh());
+
+    if (desc.memory()) {
+      return startInMemory(desc, site, baseConfig, daemon);
+    }
+    return startOnDisk(desc, site, daemon, serverStarted);
+  }
+
+  private static GerritServer startInMemory(
+      Description desc, Path site, Config baseConfig, Daemon daemon) throws Exception {
+    Config cfg = desc.buildConfig(baseConfig);
     mergeTestConfig(cfg);
-    cfg.save();
-    return tmp;
+    // Set the log4j configuration to an invalid one to prevent system logs
+    // from getting configured and creating log files.
+    System.setProperty(SystemLog.LOG4J_CONFIGURATION, "invalidConfiguration");
+    cfg.setBoolean("httpd", null, "requestLog", false);
+    cfg.setBoolean("sshd", null, "requestLog", false);
+    cfg.setBoolean("index", "lucene", "testInmemory", true);
+    cfg.setString("gitweb", null, "cgi", "");
+    daemon.setEnableHttpd(desc.httpd());
+    daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0));
+    daemon.setDatabaseForTesting(
+        ImmutableList.<Module>of(new InMemoryTestingDatabaseModule(cfg, site)));
+    daemon.start();
+    return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
+  }
+
+  private static GerritServer startOnDisk(
+      Description desc, Path site, Daemon daemon, CyclicBarrier serverStarted) throws Exception {
+    checkNotNull(site);
+    ExecutorService daemonService = Executors.newSingleThreadExecutor();
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        daemonService.submit(
+            () -> {
+              int rc =
+                  daemon.main(
+                      new String[] {
+                        "-d", site.toString(), "--headless", "--console-log", "--show-stack-trace",
+                      });
+              if (rc != 0) {
+                System.err.println("Failed to start Gerrit daemon");
+                serverStarted.reset();
+              }
+              return null;
+            });
+    try {
+      serverStarted.await();
+    } catch (BrokenBarrierException e) {
+      daemon.stop();
+      throw new StartupException("Failed to start Gerrit daemon; see log", e);
+    }
+    System.out.println("Gerrit Server Started");
+
+    return new GerritServer(desc, site, createTestInjector(daemon), daemon, daemonService);
   }
 
   private static void mergeTestConfig(Config cfg) {
-    String forceEphemeralPort = String.format("%s:0",
-        getLocalHost().getHostName());
+    String forceEphemeralPort = String.format("%s:0", getLocalHost().getHostName());
     String url = "http://" + forceEphemeralPort + "/";
     cfg.setString("gerrit", null, "canonicalWebUrl", url);
     cfg.setString("httpd", null, "listenUrl", url);
@@ -216,22 +350,24 @@
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
     Injector sysInjector = get(daemon, "sysInjector");
-    Module module = new FactoryModule() {
-      @Override
-      protected void configure() {
-        bind(AccountCreator.class);
-        factory(PushOneCommit.Factory.class);
-        install(InProcessProtocol.module());
-        install(new NoSshModule());
-        install(new AsyncReceiveCommits.Module());
-      }
-    };
+    Module module =
+        new FactoryModule() {
+          @Override
+          protected void configure() {
+            bind(AccountCreator.class);
+            factory(PushOneCommit.Factory.class);
+            install(InProcessProtocol.module());
+            install(new NoSshModule());
+            install(new AsyncReceiveCommits.Module());
+          }
+        };
     return sysInjector.createChildInjector(module);
   }
 
   @SuppressWarnings("unchecked")
-  private static <T> T get(Object obj, String field) throws SecurityException,
-      NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
+  private static <T> T get(Object obj, String field)
+      throws SecurityException, NoSuchFieldException, IllegalArgumentException,
+          IllegalAccessException {
     Field f = obj.getClass().getDeclaredField(field);
     f.setAccessible(true);
     return (T) f.get(obj);
@@ -242,6 +378,7 @@
   }
 
   private final Description desc;
+  private final Path sitePath;
 
   private Daemon daemon;
   private ExecutorService daemonService;
@@ -250,21 +387,23 @@
   private InetSocketAddress sshdAddress;
   private InetSocketAddress httpAddress;
 
-  private GerritServer(Description desc, Injector testInjector, Daemon daemon,
-      ExecutorService daemonService) {
-    this.desc = desc;
-    this.testInjector = testInjector;
-    this.daemon = daemon;
+  private GerritServer(
+      Description desc,
+      @Nullable Path sitePath,
+      Injector testInjector,
+      Daemon daemon,
+      @Nullable ExecutorService daemonService) {
+    this.desc = checkNotNull(desc);
+    this.sitePath = sitePath;
+    this.testInjector = checkNotNull(testInjector);
+    this.daemon = checkNotNull(daemon);
     this.daemonService = daemonService;
 
-    Config cfg = testInjector.getInstance(
-      Key.get(Config.class, GerritServerConfig.class));
+    Config cfg = testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     url = cfg.getString("gerrit", null, "canonicalWebUrl");
     URI uri = URI.create(url);
 
-    sshdAddress = SocketUtil.resolve(
-        cfg.getString("sshd", null, "listenAddress"),
-        0);
+    sshdAddress = SocketUtil.resolve(cfg.getString("sshd", null, "listenAddress"), 0);
     httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
   }
 
@@ -280,7 +419,7 @@
     return httpAddress;
   }
 
-  Injector getTestInjector() {
+  public Injector getTestInjector() {
     return testInjector;
   }
 
@@ -288,12 +427,10 @@
     return desc;
   }
 
-  void stop() throws Exception {
+  @Override
+  public void close() throws Exception {
     try {
-      if (NoteDbMode.get().equals(NoteDbMode.CHECK)) {
-        testInjector.getInstance(NoteDbChecker.class)
-            .rebuildAndCheckAllChanges();
-      }
+      checkNoteDbState();
     } finally {
       daemon.getLifecycleManager().stop();
       if (daemonService != null) {
@@ -305,6 +442,27 @@
     }
   }
 
+  public Path getSitePath() {
+    return sitePath;
+  }
+
+  private void checkNoteDbState() throws Exception {
+    NoteDbMode mode = NoteDbMode.get();
+    if (mode != NoteDbMode.CHECK && mode != NoteDbMode.PRIMARY) {
+      return;
+    }
+    NoteDbChecker checker = testInjector.getInstance(NoteDbChecker.class);
+    OneOffRequestContext oneOffRequestContext =
+        testInjector.getInstance(OneOffRequestContext.class);
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      if (mode == NoteDbMode.CHECK) {
+        checker.rebuildAndCheckAllChanges();
+      } else if (mode == NoteDbMode.PRIMARY) {
+        checker.assertNoReviewDbChanges(desc.testDescription());
+      }
+    }
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this).addValue(desc).toString();
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
index f1700a7..7e27e67 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GitUtil.java
@@ -16,24 +16,31 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.reviewdb.client.Project;
-
 import com.jcraft.jsch.JSch;
 import com.jcraft.jsch.JSchException;
 import com.jcraft.jsch.Session;
-
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.FetchCommand;
 import org.eclipse.jgit.api.PushCommand;
+import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.FetchResult;
@@ -45,12 +52,6 @@
 import org.eclipse.jgit.transport.SshSessionFactory;
 import org.eclipse.jgit.util.FS;
 
-import java.io.IOException;
-import java.util.List;
-import java.util.Properties;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-
 public class GitUtil {
   private static final AtomicInteger testRepoCount = new AtomicInteger();
   private static final int TEST_REPO_WINDOW_DAYS = 2;
@@ -63,66 +64,64 @@
     // register a JschConfigSessionFactory that adds the private key as identity
     // to the JSch instance of JGit so that SSH communication via JGit can
     // succeed
-    SshSessionFactory.setInstance(new JschConfigSessionFactory() {
-      @Override
-      protected void configure(Host hc, Session session) {
-        try {
-          final JSch jsch = getJSch(hc, FS.DETECTED);
-          jsch.addIdentity("KeyPair", a.privateKey(),
-              a.sshKey.getPublicKeyBlob(), null);
-        } catch (JSchException e) {
-          throw new RuntimeException(e);
-        }
-      }
-    });
+    SshSessionFactory.setInstance(
+        new JschConfigSessionFactory() {
+          @Override
+          protected void configure(Host hc, Session session) {
+            try {
+              final JSch jsch = getJSch(hc, FS.DETECTED);
+              jsch.addIdentity("KeyPair", a.privateKey(), a.sshKey.getPublicKeyBlob(), null);
+            } catch (JSchException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        });
   }
 
   /**
    * Create a new {@link TestRepository} with a distinct commit clock.
-   * <p>
-   * It is very easy for tests to create commits with identical subjects and
-   * trees; if such commits also have identical authors/committers, then the
-   * computed Change-Id is identical as well. Tests may generally assume that
-   * Change-Ids are unique, so to ensure this, we provision TestRepository
-   * instances with non-overlapping commit clock times.
-   * <p>
-   * Space test repos 1 day apart, which allows for about 86k ticks per repo
-   * before overlapping, and about 8k instances per process before hitting
-   * JGit's year 2038 limit.
+   *
+   * <p>It is very easy for tests to create commits with identical subjects and trees; if such
+   * commits also have identical authors/committers, then the computed Change-Id is identical as
+   * well. Tests may generally assume that Change-Ids are unique, so to ensure this, we provision
+   * TestRepository instances with non-overlapping commit clock times.
+   *
+   * <p>Space test repos 1 day apart, which allows for about 86k ticks per repo before overlapping,
+   * and about 8k instances per process before hitting JGit's year 2038 limit.
    *
    * @param repo repository to wrap.
    * @return wrapped test repository with distinct commit time space.
    */
-  public static <R extends Repository> TestRepository<R> newTestRepository(
-      R repo) throws IOException {
+  public static <R extends Repository> TestRepository<R> newTestRepository(R repo)
+      throws IOException {
     TestRepository<R> tr = new TestRepository<>(repo);
-    tr.tick(Ints.checkedCast(TimeUnit.SECONDS.convert(
-        testRepoCount.getAndIncrement() * TEST_REPO_WINDOW_DAYS,
-        TimeUnit.DAYS)));
+    tr.tick(
+        Ints.checkedCast(
+            TimeUnit.SECONDS.convert(
+                testRepoCount.getAndIncrement() * TEST_REPO_WINDOW_DAYS, TimeUnit.DAYS)));
     return tr;
   }
 
-  public static TestRepository<InMemoryRepository> cloneProject(
-      Project.NameKey project, String uri) throws Exception {
-    DfsRepositoryDescription desc =
-        new DfsRepositoryDescription("clone of " + project.get());
+  public static TestRepository<InMemoryRepository> cloneProject(Project.NameKey project, String uri)
+      throws Exception {
+    DfsRepositoryDescription desc = new DfsRepositoryDescription("clone of " + project.get());
 
     FS fs = FS.detect();
 
     // Avoid leaking user state into our tests.
     fs.setUserHome(null);
 
-    InMemoryRepository dest = new InMemoryRepository.Builder()
-        .setRepositoryDescription(desc)
-        // SshTransport depends on a real FS to read ~/.ssh/config, but
-        // InMemoryRepository by default uses a null FS.
-        // TODO(dborowitz): Remove when we no longer depend on SSH.
-        .setFS(fs)
-        .build();
+    InMemoryRepository dest =
+        new InMemoryRepository.Builder()
+            .setRepositoryDescription(desc)
+            // SshTransport depends on a real FS to read ~/.ssh/config, but
+            // InMemoryRepository by default uses a null FS.
+            // TODO(dborowitz): Remove when we no longer depend on SSH.
+            .setFS(fs)
+            .build();
     Config cfg = dest.getConfig();
     cfg.setString("remote", "origin", "url", uri);
-    cfg.setString("remote", "origin", "fetch",
-        "+refs/heads/*:refs/remotes/origin/*");
+    cfg.setString("remote", "origin", "fetch", "+refs/heads/*:refs/remotes/origin/*");
     TestRepository<InMemoryRepository> testRepo = newTestRepository(dest);
     FetchResult result = testRepo.git().fetch().setRemote("origin").call();
     String originMaster = "refs/remotes/origin/master";
@@ -137,28 +136,67 @@
     return cloneProject(project, sshSession.getUrl() + "/" + project.get());
   }
 
-  public static void fetch(TestRepository<?> testRepo, String spec)
+  public static Ref createAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
       throws GitAPIException {
+    TagCommand cmd =
+        testRepo.git().tag().setName(name).setAnnotated(true).setMessage(name).setTagger(tagger);
+    return cmd.call();
+  }
+
+  public static Ref updateAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
+      throws GitAPIException {
+    TagCommand tc = testRepo.git().tag().setName(name);
+    return tc.setAnnotated(true).setMessage(name).setTagger(tagger).setForceUpdate(true).call();
+  }
+
+  public static void fetch(TestRepository<?> testRepo, String spec) throws GitAPIException {
     FetchCommand fetch = testRepo.git().fetch();
     fetch.setRefSpecs(new RefSpec(spec));
     fetch.call();
   }
 
-  public static PushResult pushHead(TestRepository<?> testRepo, String ref)
-      throws GitAPIException {
+  public static PushResult pushHead(TestRepository<?> testRepo, String ref) throws GitAPIException {
     return pushHead(testRepo, ref, false);
   }
 
-  public static PushResult pushHead(TestRepository<?> testRepo, String ref,
-      boolean pushTags) throws GitAPIException {
+  public static PushResult pushHead(TestRepository<?> testRepo, String ref, boolean pushTags)
+      throws GitAPIException {
     return pushHead(testRepo, ref, pushTags, false);
   }
 
-  public static PushResult pushHead(TestRepository<?> testRepo, String ref,
-      boolean pushTags, boolean force) throws GitAPIException {
+  public static PushResult pushHead(
+      TestRepository<?> testRepo, String ref, boolean pushTags, boolean force)
+      throws GitAPIException {
+    return pushOne(testRepo, "HEAD", ref, pushTags, force, null);
+  }
+
+  public static PushResult pushHead(
+      TestRepository<?> testRepo,
+      String ref,
+      boolean pushTags,
+      boolean force,
+      List<String> pushOptions)
+      throws GitAPIException {
+    return pushOne(testRepo, "HEAD", ref, pushTags, force, pushOptions);
+  }
+
+  public static PushResult deleteRef(TestRepository<?> testRepo, String ref)
+      throws GitAPIException {
+    return pushOne(testRepo, "", ref, false, true, null);
+  }
+
+  public static PushResult pushOne(
+      TestRepository<?> testRepo,
+      String source,
+      String target,
+      boolean pushTags,
+      boolean force,
+      List<String> pushOptions)
+      throws GitAPIException {
     PushCommand pushCmd = testRepo.git().push();
     pushCmd.setForce(force);
-    pushCmd.setRefSpecs(new RefSpec("HEAD:" + ref));
+    pushCmd.setPushOptions(pushOptions);
+    pushCmd.setRefSpecs(new RefSpec(source + ":" + target));
     if (pushTags) {
       pushCmd.setPushTags();
     }
@@ -168,26 +206,33 @@
 
   public static void assertPushOk(PushResult result, String ref) {
     RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus()).named(rru.toString())
-        .isEqualTo(RemoteRefUpdate.Status.OK);
+    assertThat(rru.getStatus()).named(rru.toString()).isEqualTo(RemoteRefUpdate.Status.OK);
   }
 
-  public static void assertPushRejected(PushResult result, String ref,
-      String expectedMessage) {
+  public static void assertPushRejected(PushResult result, String ref, String expectedMessage) {
     RemoteRefUpdate rru = result.getRemoteUpdate(ref);
-    assertThat(rru.getStatus()).named(rru.toString())
+    assertThat(rru.getStatus())
+        .named(rru.toString())
         .isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
     assertThat(rru.getMessage()).isEqualTo(expectedMessage);
   }
 
-  public static Optional<String> getChangeId(TestRepository<?> tr, ObjectId id)
-      throws IOException {
+  public static PushResult pushTag(TestRepository<?> testRepo, String tag) throws GitAPIException {
+    return pushTag(testRepo, tag, false);
+  }
+
+  public static PushResult pushTag(TestRepository<?> testRepo, String tag, boolean force)
+      throws GitAPIException {
+    PushCommand pushCmd = testRepo.git().push();
+    pushCmd.setForce(force);
+    pushCmd.setRefSpecs(new RefSpec("refs/tags/" + tag + ":refs/tags/" + tag));
+    Iterable<PushResult> r = pushCmd.call();
+    return Iterables.getOnlyElement(r);
+  }
+
+  public static Optional<String> getChangeId(TestRepository<?> tr, ObjectId id) throws IOException {
     RevCommit c = tr.getRevWalk().parseCommit(id);
     tr.getRevWalk().parseBody(c);
-    List<String> ids = c.getFooterLines(FooterConstants.CHANGE_ID);
-    if (ids.isEmpty()) {
-      return Optional.absent();
-    }
-    return Optional.of(ids.get(ids.size() - 1));
+    return Lists.reverse(c.getFooterLines(FooterConstants.CHANGE_ID)).stream().findFirst();
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
new file mode 100644
index 0000000..43477ae
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({METHOD})
+@Retention(RUNTIME)
+@Repeatable(GlobalPluginConfigs.class)
+public @interface GlobalPluginConfig {
+  /** Name of the plugin, corresponding to {@code $site/etc/@pluginName.config}. */
+  String pluginName();
+
+  /** @see GerritConfig#name() */
+  String name();
+
+  /** @see GerritConfig#value() */
+  String value() default "";
+
+  /** @see GerritConfig#values() */
+  String[] values() default "";
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
new file mode 100644
index 0000000..dfcf955
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({METHOD})
+@Retention(RUNTIME)
+public @interface GlobalPluginConfigs {
+  GlobalPluginConfig[] value();
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java
index 390cae3..6c03793 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.acceptance;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.common.base.Preconditions;
-
-import org.eclipse.jgit.util.IO;
-import org.eclipse.jgit.util.RawParseUtils;
-
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.nio.ByteBuffer;
+import org.apache.http.Header;
+import org.eclipse.jgit.util.IO;
+import org.eclipse.jgit.util.RawParseUtils;
 
 public class HttpResponse {
 
@@ -35,7 +36,7 @@
 
   public Reader getReader() throws IllegalStateException, IOException {
     if (reader == null && response.getEntity() != null) {
-      reader = new InputStreamReader(response.getEntity().getContent());
+      reader = new InputStreamReader(response.getEntity().getContent(), UTF_8);
     }
     return reader;
   }
@@ -52,27 +53,23 @@
   }
 
   public String getContentType() {
-    return response.getFirstHeader("X-FYI-Content-Type").getValue();
+    return getHeader("X-FYI-Content-Type");
+  }
+
+  public String getHeader(String name) {
+    Header hdr = response.getFirstHeader(name);
+    return hdr != null ? hdr.getValue() : null;
   }
 
   public boolean hasContent() {
-    Preconditions.checkNotNull(response,
-        "Response is not initialized.");
+    Preconditions.checkNotNull(response, "Response is not initialized.");
     return response.getEntity() != null;
   }
 
   public String getEntityContent() throws IOException {
-    Preconditions.checkNotNull(response,
-        "Response is not initialized.");
-    Preconditions.checkNotNull(response.getEntity(),
-        "Response.Entity is not initialized.");
-      ByteBuffer buf = IO.readWholeStream(
-          response.getEntity().getContent(),
-          1024);
-      return RawParseUtils.decode(
-          buf.array(),
-          buf.arrayOffset(),
-          buf.limit())
-          .trim();
+    Preconditions.checkNotNull(response, "Response is not initialized.");
+    Preconditions.checkNotNull(response.getEntity(), "Response.Entity is not initialized.");
+    ByteBuffer buf = IO.readWholeStream(response.getEntity().getContent(), 1024);
+    return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()).trim();
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
index 1e0920e..fe446f4 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
@@ -15,29 +15,34 @@
 package com.google.gerrit.acceptance;
 
 import com.google.common.base.CharMatcher;
-
+import com.google.gerrit.common.Nullable;
+import java.io.IOException;
+import java.net.URI;
 import org.apache.http.HttpHost;
 import org.apache.http.client.fluent.Executor;
 import org.apache.http.client.fluent.Request;
 
-import java.io.IOException;
-import java.net.URI;
-
 public class HttpSession {
-
+  protected TestAccount account;
   protected final String url;
   private final Executor executor;
 
-  public HttpSession(GerritServer server, TestAccount account) {
+  public HttpSession(GerritServer server, @Nullable TestAccount account) {
     this.url = CharMatcher.is('/').trimTrailingFrom(server.getUrl());
     URI uri = URI.create(url);
-    this.executor = Executor
-        .newInstance()
-        .auth(new HttpHost(uri.getHost(), uri.getPort()),
-            account.username, account.httpPassword);
+    this.executor = Executor.newInstance();
+    this.account = account;
+    if (account != null) {
+      executor.auth(
+          new HttpHost(uri.getHost(), uri.getPort()), account.username, account.httpPassword);
+    }
   }
 
-  protected RestResponse execute(Request request) throws IOException {
+  public String url() {
+    return url;
+  }
+
+  public RestResponse execute(Request request) throws IOException {
     return new RestResponse(executor.execute(request).returnResponse());
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index 443c580..551c26b 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -27,6 +27,8 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
@@ -46,38 +48,31 @@
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
-
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 import org.eclipse.jgit.lib.Config;
 
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-
 class InMemoryTestingDatabaseModule extends LifecycleModule {
   private final Config cfg;
+  private final Path sitePath;
 
-  InMemoryTestingDatabaseModule(Config cfg) {
+  InMemoryTestingDatabaseModule(Config cfg, Path sitePath) {
     this.cfg = cfg;
+    this.sitePath = sitePath;
+    makeSiteDirs(sitePath);
   }
 
   @Override
   protected void configure() {
-    bind(Config.class)
-      .annotatedWith(GerritServerConfig.class)
-      .toInstance(cfg);
+    bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
 
     // TODO(dborowitz): Use jimfs.
-    Path p = Paths.get(cfg.getString("gerrit", null, "tempSiteDir"));
-    bind(Path.class)
-      .annotatedWith(SitePath.class)
-      .toInstance(p);
-    makeSiteDirs(p);
+    bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
 
-    bind(GitRepositoryManager.class)
-      .to(InMemoryRepositoryManager.class);
+    bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
 
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
@@ -87,16 +82,14 @@
     TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
         new TypeLiteral<SchemaFactory<ReviewDb>>() {};
     bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-    bind(Key.get(schemaFactory, ReviewDbFactory.class))
-        .to(InMemoryDatabase.class);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
     bind(InMemoryDatabase.class).in(SINGLETON);
+    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
 
     listener().to(CreateDatabase.class);
 
     bind(SitePaths.class);
-    bind(TrackingFooters.class)
-      .toProvider(TrackingFootersProvider.class)
-      .in(SINGLETON);
+    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
 
     install(new SchemaModule());
     bind(SchemaVersion.class).to(SchemaVersion.C);
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 1d03acd..0977e24 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -55,7 +55,11 @@
 import com.google.inject.Scope;
 import com.google.inject.servlet.RequestScoped;
 import com.google.inject.util.Providers;
-
+import java.io.IOException;
+import java.net.SocketAddress;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PostReceiveHook;
 import org.eclipse.jgit.transport.PostReceiveHookChain;
@@ -68,12 +72,6 @@
 import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
 import org.eclipse.jgit.transport.resolver.UploadPackFactory;
 
-import java.io.IOException;
-import java.net.SocketAddress;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
 class InProcessProtocol extends TestProtocol<Context> {
   static Module module() {
     return new AbstractModule() {
@@ -94,36 +92,37 @@
     };
   }
 
-  private static final Scope REQUEST = new Scope() {
-    @Override
-    public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
-      return new Provider<T>() {
+  private static final Scope REQUEST =
+      new Scope() {
         @Override
-        public T get() {
-          Context ctx = current.get();
-          if (ctx == null) {
-            throw new OutOfScopeException("Not in TestProtocol scope");
-          }
-          return ctx.get(key, creator);
+        public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
+          return new Provider<T>() {
+            @Override
+            public T get() {
+              Context ctx = current.get();
+              if (ctx == null) {
+                throw new OutOfScopeException("Not in TestProtocol scope");
+              }
+              return ctx.get(key, creator);
+            }
+
+            @Override
+            public String toString() {
+              return String.format("%s[%s]", creator, REQUEST);
+            }
+          };
         }
 
         @Override
         public String toString() {
-          return String.format("%s[%s]", creator, REQUEST);
+          return "InProcessProtocol.REQUEST";
         }
       };
-    }
 
-    @Override
-    public String toString() {
-      return "InProcessProtocol.REQUEST";
-    }
-  };
-
-  private static class Propagator
-      extends ThreadLocalRequestScopePropagator<Context> {
+  private static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
     @Inject
-    Propagator(ThreadLocalRequestContext local,
+    Propagator(
+        ThreadLocalRequestContext local,
         Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
       super(REQUEST, current, local, dbProviderProvider);
     }
@@ -139,22 +138,20 @@
   // TODO(dborowitz): Merge this with AcceptanceTestRequestScope.
   /**
    * Multi-purpose session/context object.
-   * <p>
-   * Confusingly, Gerrit has two ideas of what a "context" object is:
-   * one for Guice {@link RequestScoped}, and one for its own simplified
-   * version of request scoping using {@link ThreadLocalRequestContext}.
-   * This class provides both, in essence just delegating the {@code
+   *
+   * <p>Confusingly, Gerrit has two ideas of what a "context" object is: one for Guice {@link
+   * RequestScoped}, and one for its own simplified version of request scoping using {@link
+   * ThreadLocalRequestContext}. This class provides both, in essence just delegating the {@code
    * ThreadLocalRequestContext} scoping to the Guice scoping mechanism.
-   * <p>
-   * It is also used as the session type for {@code UploadPackFactory} and
-   * {@code ReceivePackFactory}, since, after all, it encapsulates all the
-   * information about a single request.
+   *
+   * <p>It is also used as the session type for {@code UploadPackFactory} and {@code
+   * ReceivePackFactory}, since, after all, it encapsulates all the information about a single
+   * request.
    */
   static class Context implements RequestContext {
     private static final Key<RequestScopedReviewDbProvider> DB_KEY =
         Key.get(RequestScopedReviewDbProvider.class);
-    private static final Key<RequestCleanup> RC_KEY =
-        Key.get(RequestCleanup.class);
+    private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
     private static final Key<CurrentUser> USER_KEY = Key.get(CurrentUser.class);
 
     private final SchemaFactory<ReviewDb> schemaFactory;
@@ -164,7 +161,8 @@
     private final RequestCleanup cleanup;
     private final Map<Key<?>, Object> map;
 
-    Context(SchemaFactory<ReviewDb> schemaFactory,
+    Context(
+        SchemaFactory<ReviewDb> schemaFactory,
         IdentifiedUser.GenericFactory userFactory,
         Account.Id accountId,
         Project.NameKey project) {
@@ -174,9 +172,7 @@
       this.project = project;
       map = new HashMap<>();
       cleanup = new RequestCleanup();
-      map.put(DB_KEY,
-          new RequestScopedReviewDbProvider(
-            schemaFactory, Providers.of(cleanup)));
+      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
       map.put(RC_KEY, cleanup);
 
       IdentifiedUser user = userFactory.create(accountId);
@@ -255,8 +251,7 @@
       threadContext.setContext(req);
       current.set(req);
       try {
-        ProjectControl ctl = projectControlFactory.controlFor(
-            req.project, userProvider.get());
+        ProjectControl ctl = projectControlFactory.controlFor(req.project, userProvider.get());
         if (!ctl.canRunUploadPack()) {
           throw new ServiceNotAuthorizedException();
         }
@@ -264,12 +259,11 @@
         UploadPack up = new UploadPack(repo);
         up.setPackConfig(transferConfig.getPackConfig());
         up.setTimeout(transferConfig.getTimeout());
-        up.setAdvertiseRefsHook(new VisibleRefFilter(
-            tagCache, changeNotesFactory, changeCache, repo, ctl,
-            dbProvider.get(), true));
+        up.setAdvertiseRefsHook(
+            new VisibleRefFilter(
+                tagCache, changeNotesFactory, changeCache, repo, ctl, dbProvider.get(), true));
         List<PreUploadHook> hooks = Lists.newArrayList(preUploadHooks);
-        hooks.add(uploadValidatorsFactory.create(
-            ctl.getProject(), repo, "localhost-test"));
+        hooks.add(uploadValidatorsFactory.create(ctl.getProject(), repo, "localhost-test"));
         up.setPreUploadHook(PreUploadHookChain.newChain(hooks));
         return up;
       } catch (NoSuchProjectException | IOException e) {
@@ -315,8 +309,7 @@
       threadContext.setContext(req);
       current.set(req);
       try {
-        ProjectControl ctl =
-            projectControlFactory.controlFor(req.project, userProvider.get());
+        ProjectControl ctl = projectControlFactory.controlFor(req.project, userProvider.get());
         if (!ctl.canRunReceivePack()) {
           throw new ServiceNotAuthorizedException();
         }
@@ -337,8 +330,7 @@
           initializer.init(ctl.getProject().getNameKey(), rp);
         }
 
-        rp.setPostReceiveHook(PostReceiveHookChain.newChain(
-            Lists.newArrayList(postReceiveHooks)));
+        rp.setPostReceiveHook(PostReceiveHookChain.newChain(Lists.newArrayList(postReceiveHooks)));
         return rp;
       } catch (NoSuchProjectException | IOException e) {
         throw new RuntimeException(e);
@@ -347,8 +339,7 @@
   }
 
   @Inject
-  InProcessProtocol(Upload uploadPackFactory,
-      Receive receivePackFactory) {
+  InProcessProtocol(Upload uploadPackFactory, Receive receivePackFactory) {
     super(uploadPackFactory, receivePackFactory);
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
new file mode 100644
index 0000000..62cc8ce
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
+import com.google.gerrit.server.plugins.TestServerPlugin;
+import com.google.inject.Inject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.rules.TemporaryFolder;
+
+public class LightweightPluginDaemonTest extends AbstractDaemonTest {
+  @Inject private PluginGuiceEnvironment env;
+
+  @Inject private PluginUser.Factory pluginUserFactory;
+
+  @Rule public TemporaryFolder tempDataDir = new TemporaryFolder();
+
+  protected TestServerPlugin plugin;
+
+  @Before
+  public void setUp() throws Exception {
+    TestPlugin testPlugin = getTestPlugin(getClass());
+    String name = testPlugin.name();
+    plugin =
+        new TestServerPlugin(
+            name,
+            canonicalWebUrl.get() + "plugins/" + name,
+            pluginUserFactory.create(name),
+            getClass().getClassLoader(),
+            testPlugin.sysModule(),
+            testPlugin.httpModule(),
+            testPlugin.sshModule(),
+            tempDataDir.getRoot().toPath());
+
+    plugin.start(env);
+    env.onStartPlugin(plugin);
+  }
+
+  @After
+  public void tearDown() {
+    if (plugin != null) {
+      // plugin will be null if the plugin test requires ssh, but the command
+      // line flag says we are running tests without ssh as the assume()
+      // statement in AbstractDaemonTest will prevent the execution of setUp()
+      // in this class
+      plugin.stop(env);
+      env.onStopPlugin(plugin);
+    }
+  }
+
+  private static TestPlugin getTestPlugin(Class<?> clazz) {
+    for (; clazz != null; clazz = clazz.getSuperclass()) {
+      if (clazz.getAnnotation(TestPlugin.class) != null) {
+        return clazz.getAnnotation(TestPlugin.class);
+      }
+    }
+    throw new IllegalStateException("TestPlugin annotation missing");
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
index 3b59d28..c651d48 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
@@ -15,16 +15,12 @@
 package com.google.gerrit.acceptance;
 
 import com.google.common.collect.Lists;
-
+import java.io.File;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 
-import java.io.File;
-
-/**
- * A file based Config that can merge another Config instance.
- */
+/** A file based Config that can merge another Config instance. */
 public class MergeableFileBasedConfig extends FileBasedConfig {
   public MergeableFileBasedConfig(File cfgLocation, FS fs) {
     super(cfgLocation, fs);
@@ -33,9 +29,8 @@
   /**
    * Merge another Config into this Config.
    *
-   * In case a configuration parameter exists both in this instance and in the
-   * merged instance then the value in this instance will simply replaced by
-   * the value from the merged instance.
+   * <p>In case a configuration parameter exists both in this instance and in the merged instance
+   * then the value in this instance will simply replaced by the value from the merged instance.
    *
    * @param s Config to merge into this instance
    */
@@ -46,14 +41,17 @@
     for (String section : s.getSections()) {
       for (String subsection : s.getSubsections(section)) {
         for (String name : s.getNames(section, subsection)) {
-          setStringList(section, subsection, name, Lists.newArrayList(s
-              .getStringList(section, subsection, name)));
+          setStringList(
+              section,
+              subsection,
+              name,
+              Lists.newArrayList(s.getStringList(section, subsection, name)));
         }
       }
 
       for (String name : s.getNames(section, true)) {
-        setStringList(section, null, name,
-            Lists.newArrayList(s.getStringList(section, null, name)));
+        setStringList(
+            section, null, name, Lists.newArrayList(s.getStringList(section, null, name)));
       }
     }
   }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/NoHttpd.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/NoHttpd.java
index 378439c..e8d6103 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/NoHttpd.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/NoHttpd.java
@@ -23,5 +23,4 @@
 
 @Target({TYPE, METHOD})
 @Retention(RUNTIME)
-public @interface NoHttpd {
-}
+public @interface NoHttpd {}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
deleted file mode 100644
index dde1875..0000000
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PluginDaemonTest.java
+++ /dev/null
@@ -1,227 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.server.config.SitePaths;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.junit.runner.Description;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.lang.ProcessBuilder.Redirect;
-import java.net.URL;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.StandardCopyOption;
-import java.util.Properties;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public abstract class PluginDaemonTest extends AbstractDaemonTest {
-
-  private static final String BUCKLC = "buck";
-  private static final String BUCKOUT = "buck-out";
-  private static final String ECLIPSE = "eclipse-out";
-
-  private Path gen;
-  private Path pluginRoot;
-  private Path pluginsSitePath;
-  private Path pluginSubPath;
-  private Path pluginSource;
-  private boolean standalone;
-
-  protected String pluginName;
-  protected Path testSite;
-
-  @Override
-  protected void beforeTest(Description description) throws Exception {
-    locatePaths();
-    retrievePluginName();
-    buildPluginJar();
-    createTestSiteDirs();
-    copyJarToTestSite();
-    beforeTestServerStarts();
-    super.beforeTest(description);
-  }
-
-  protected void beforeTestServerStarts() throws Exception {
-  }
-
-  protected void setPluginConfigString(String name, String value)
-      throws IOException, ConfigInvalidException {
-    SitePaths sitePath = new SitePaths(testSite);
-    FileBasedConfig cfg = getGerritConfigFile(sitePath);
-    cfg.load();
-    cfg.setString("plugin", pluginName, name, value);
-    cfg.save();
-  }
-
-  private FileBasedConfig getGerritConfigFile(SitePaths sitePath)
-      throws IOException {
-    FileBasedConfig cfg =
-        new FileBasedConfig(sitePath.gerrit_config.toFile(), FS.DETECTED);
-    if (!cfg.getFile().exists()) {
-      Path etc_path = Files.createDirectories(sitePath.etc_dir);
-      Files.createFile(etc_path.resolve("gerrit.config"));
-    }
-    return cfg;
-  }
-
-  private void locatePaths() {
-    URL pluginClassesUrl =
-        getClass().getProtectionDomain().getCodeSource().getLocation();
-    Path basePath = Paths.get(pluginClassesUrl.getPath()).getParent();
-
-    int idx = 0;
-    int buckOutIdx = 0;
-    int pluginsIdx = 0;
-    for (Path subPath : basePath) {
-      if (subPath.endsWith("plugins")) {
-        pluginsIdx = idx;
-      }
-      if (subPath.endsWith(BUCKOUT) || subPath.endsWith(ECLIPSE)) {
-        buckOutIdx = idx;
-      }
-      idx++;
-    }
-    standalone = checkStandalone(basePath);
-    pluginRoot = basePath.getRoot().resolve(basePath.subpath(0, buckOutIdx));
-    gen = pluginRoot.resolve(BUCKOUT).resolve("gen");
-
-    if (standalone) {
-      pluginSource = pluginRoot;
-    } else {
-      pluginSubPath = basePath.subpath(pluginsIdx, pluginsIdx + 2);
-      pluginSource = pluginRoot.resolve(pluginSubPath);
-    }
-  }
-
-  private boolean checkStandalone(Path basePath) {
-    String pathCharStringOrNone = "[a-zA-Z0-9._-]*?";
-    Pattern pattern = Pattern.compile(pathCharStringOrNone + "gerrit" +
-        pathCharStringOrNone);
-    Path partialPath = basePath;
-    for (int i = basePath.getNameCount(); i > 0; i--) {
-      int count = partialPath.getNameCount();
-      if (count > 1) {
-        String gerritDirCandidate =
-            partialPath.subpath(count - 2, count - 1).toString();
-        if (pattern.matcher(gerritDirCandidate).matches()) {
-          if (partialPath.endsWith(gerritDirCandidate + "/" + BUCKOUT) ||
-              partialPath.endsWith(gerritDirCandidate + "/" + ECLIPSE)) {
-            return false;
-          }
-        }
-      }
-      partialPath = partialPath.getParent();
-    }
-    return true;
-  }
-
-  private void retrievePluginName() throws IOException {
-    Path buckFile = pluginSource.resolve("BUCK");
-    byte[] bytes = Files.readAllBytes(buckFile);
-    String buckContent =
-        new String(bytes, UTF_8).replaceAll("\\s+", "");
-    Matcher matcher =
-        Pattern.compile("gerrit_plugin\\(name='(.*?)'").matcher(buckContent);
-    if (matcher.find()) {
-      pluginName = matcher.group(1);
-    }
-    if (Strings.isNullOrEmpty(pluginName)) {
-      if (standalone) {
-        pluginName = pluginRoot.getFileName().toString();
-      } else {
-        pluginName = pluginSubPath.getFileName().toString();
-      }
-    }
-  }
-
-  private void buildPluginJar() throws IOException, InterruptedException {
-    Properties properties = loadBuckProperties();
-    String buck =
-        MoreObjects.firstNonNull(properties.getProperty(BUCKLC), BUCKLC);
-    String target;
-    if (standalone) {
-      target = "//:" + pluginName;
-    } else {
-      target = pluginSubPath.toString();
-    }
-
-    ProcessBuilder processBuilder =
-        new ProcessBuilder(buck, "build", target).directory(pluginRoot.toFile())
-            .redirectErrorStream(true);
-    // otherwise plugin jar creation fails:
-    processBuilder.environment().put("NO_BUCKD", "1");
-
-    Path forceJar = pluginSource.resolve("src/main/java/ForceJarIfMissing.java");
-    // if exists after cancelled test:
-    Files.deleteIfExists(forceJar);
-
-    Files.createFile(forceJar);
-    testSite = tempSiteDir.getRoot().toPath();
-
-    // otherwise process often hangs:
-    Path log = testSite.resolve("log");
-    processBuilder.redirectErrorStream(true);
-    processBuilder.redirectOutput(Redirect.appendTo(log.toFile()));
-
-    try {
-      processBuilder.start().waitFor();
-    } finally {
-      Files.delete(forceJar);
-      // otherwise jar not made next time if missing again:
-      processBuilder.start().waitFor();
-    }
-  }
-
-  private Properties loadBuckProperties() throws IOException {
-    Properties properties = new Properties();
-    Path propertiesPath = gen.resolve(Paths.get("tools/buck/buck.properties"));
-    if (Files.exists(propertiesPath)) {
-      try (InputStream in = Files.newInputStream(propertiesPath)) {
-        properties.load(in);
-      }
-    }
-    return properties;
-  }
-
-  private void createTestSiteDirs() throws IOException {
-    SitePaths sitePath = new SitePaths(testSite);
-    pluginsSitePath = Files.createDirectories(sitePath.plugins_dir);
-    Files.createDirectories(sitePath.tmp_dir);
-    Files.createDirectories(sitePath.etc_dir);
-  }
-
-  private void copyJarToTestSite() throws IOException {
-    Path pluginOut;
-    if (standalone) {
-      pluginOut = gen;
-    } else {
-      pluginOut = gen.resolve(pluginSubPath);
-    }
-    Path jar = pluginOut.resolve(pluginName + ".jar");
-    Path dest = pluginsSitePath.resolve(jar.getFileName());
-    Files.copy(jar, dest, StandardCopyOption.REPLACE_EXISTING);
-  }
-}
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 d79e573..c7d52fe 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
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static org.junit.Assert.assertEquals;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
@@ -28,14 +29,15 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -44,36 +46,32 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 
-import java.util.List;
-import java.util.Map;
-
 public class PushOneCommit {
   public static final String SUBJECT = "test commit";
   public static final String FILE_NAME = "a.txt";
   public static final String FILE_CONTENT = "some content";
+  public static final String PATCH_FILE_ONLY =
+      "diff --git a/a.txt b/a.txt\n"
+          + "new file mode 100644\n"
+          + "index 0000000..f0eec86\n"
+          + "--- /dev/null\n"
+          + "+++ b/a.txt\n"
+          + "@@ -0,0 +1 @@\n"
+          + "+some content\n"
+          + "\\ No newline at end of file\n";
   public static final String PATCH =
-      "From %s Mon Sep 17 00:00:00 2001\n" +
-      "From: Administrator <admin@example.com>\n" +
-      "Date: %s\n" +
-      "Subject: [PATCH] test commit\n" +
-      "\n" +
-      "Change-Id: %s\n" +
-      "---\n" +
-      "\n" +
-      "diff --git a/a.txt b/a.txt\n" +
-      "new file mode 100644\n" +
-      "index 0000000..f0eec86\n" +
-      "--- /dev/null\n" +
-      "+++ b/a.txt\n" +
-      "@@ -0,0 +1 @@\n" +
-      "+some content\n" +
-      "\\ No newline at end of file\n";
+      "From %s Mon Sep 17 00:00:00 2001\n"
+          + "From: Administrator <admin@example.com>\n"
+          + "Date: %s\n"
+          + "Subject: [PATCH] test commit\n"
+          + "\n"
+          + "Change-Id: %s\n"
+          + "---\n"
+          + "\n"
+          + PATCH_FILE_ONLY;
 
   public interface Factory {
-    PushOneCommit create(
-        ReviewDb db,
-        PersonIdent i,
-        TestRepository<?> testRepo);
+    PushOneCommit create(ReviewDb db, PersonIdent i, TestRepository<?> testRepo);
 
     PushOneCommit create(
         ReviewDb db,
@@ -125,6 +123,18 @@
     }
   }
 
+  private static AtomicInteger CHANGE_ID_COUNTER = new AtomicInteger();
+
+  private static String nextChangeId() {
+    // Tests use a variety of mechanisms for setting temporary timestamps, so we can't guarantee
+    // that the PersonIdent (or any other field used by the Change-Id generator) for any two test
+    // methods in the same acceptance test class are going to be different. But tests generally
+    // assume that Change-Ids are unique unless otherwise specified. So, don't even bother trying to
+    // reuse JGit's Change-Id generator, just do the simplest possible thing and convert a counter
+    // to hex.
+    return String.format("%040x", CHANGE_ID_COUNTER.incrementAndGet());
+  }
+
   private final ChangeNotes.Factory notesFactory;
   private final ApprovalsUtil approvalsUtil;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -136,34 +146,57 @@
   private String changeId;
   private Tag tag;
   private boolean force;
+  private List<String> pushOptions;
 
   private final TestRepository<?>.CommitBuilder commitBuilder;
 
   @AssistedInject
-  PushOneCommit(ChangeNotes.Factory notesFactory,
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
-      @Assisted TestRepository<?> testRepo) throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider,
-        db, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT);
+      @Assisted TestRepository<?> testRepo)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        db,
+        i,
+        testRepo,
+        SUBJECT,
+        FILE_NAME,
+        FILE_CONTENT);
   }
 
   @AssistedInject
-  PushOneCommit(ChangeNotes.Factory notesFactory,
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
-      @Assisted("changeId") String changeId) throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider,
-        db, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT, changeId);
+      @Assisted("changeId") String changeId)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        db,
+        i,
+        testRepo,
+        SUBJECT,
+        FILE_NAME,
+        FILE_CONTENT,
+        changeId);
   }
 
   @AssistedInject
-  PushOneCommit(ChangeNotes.Factory notesFactory,
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
       @Assisted ReviewDb db,
@@ -171,26 +204,38 @@
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
       @Assisted("fileName") String fileName,
-      @Assisted("content") String content) throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider,
-        db, i, testRepo, subject, fileName, content, null);
+      @Assisted("content") String content)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        db,
+        i,
+        testRepo,
+        subject,
+        fileName,
+        content,
+        null);
   }
 
   @AssistedInject
-  PushOneCommit(ChangeNotes.Factory notesFactory,
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted String subject,
-      @Assisted Map<String, String> files) throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider, db, i, testRepo,
-        subject, files, null);
+      @Assisted Map<String, String> files)
+      throws Exception {
+    this(notesFactory, approvalsUtil, queryProvider, db, i, testRepo, subject, files, null);
   }
 
   @AssistedInject
-  PushOneCommit(ChangeNotes.Factory notesFactory,
+  PushOneCommit(
+      ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
       @Assisted ReviewDb db,
@@ -199,12 +244,22 @@
       @Assisted("subject") String subject,
       @Assisted("fileName") String fileName,
       @Assisted("content") String content,
-      @Nullable @Assisted("changeId") String changeId) throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider, db, i, testRepo,
-        subject, ImmutableMap.of(fileName, content), changeId);
+      @Nullable @Assisted("changeId") String changeId)
+      throws Exception {
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        db,
+        i,
+        testRepo,
+        subject,
+        ImmutableMap.of(fileName, content),
+        changeId);
   }
 
-  private PushOneCommit(ChangeNotes.Factory notesFactory,
+  private PushOneCommit(
+      ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
       ReviewDb db,
@@ -212,7 +267,8 @@
       TestRepository<?> testRepo,
       String subject,
       Map<String, String> files,
-      String changeId) throws Exception {
+      String changeId)
+      throws Exception {
     this.db = db;
     this.testRepo = testRepo;
     this.notesFactory = notesFactory;
@@ -222,14 +278,11 @@
     this.files = files;
     this.changeId = changeId;
     if (changeId != null) {
-      commitBuilder = testRepo.amendRef("HEAD")
-          .insertChangeId(changeId.substring(1));
+      commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     } else {
-      commitBuilder = testRepo.branch("HEAD").commit().insertChangeId();
+      commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
     }
-    commitBuilder.message(subject)
-      .author(i)
-      .committer(new PersonIdent(i, testRepo.getDate()));
+    commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
   }
 
   public void setParents(List<RevCommit> parents) throws Exception {
@@ -266,17 +319,17 @@
     if (tag != null) {
       TagCommand tagCommand = testRepo.git().tag().setName(tag.name);
       if (tag instanceof AnnotatedTag) {
-        AnnotatedTag annotatedTag = (AnnotatedTag)tag;
-        tagCommand.setAnnotated(true)
-          .setMessage(annotatedTag.message)
-          .setTagger(annotatedTag.tagger);
+        AnnotatedTag annotatedTag = (AnnotatedTag) tag;
+        tagCommand
+            .setAnnotated(true)
+            .setMessage(annotatedTag.message)
+            .setTagger(annotatedTag.tagger);
       } else {
         tagCommand.setAnnotated(false);
       }
       tagCommand.call();
     }
-    return new Result(ref, pushHead(testRepo, ref, tag != null, force), c,
-        subject);
+    return new Result(ref, pushHead(testRepo, ref, tag != null, force, pushOptions), c, subject);
   }
 
   public void setTag(final Tag tag) {
@@ -287,6 +340,14 @@
     this.force = force;
   }
 
+  public List<String> getPushOptions() {
+    return pushOptions;
+  }
+
+  public void setPushOptions(List<String> pushOptions) {
+    this.pushOptions = pushOptions;
+  }
+
   public void noParents() {
     commitBuilder.noParents();
   }
@@ -297,8 +358,7 @@
     private final RevCommit commit;
     private final String resSubj;
 
-    private Result(String ref, PushResult resSubj, RevCommit commit,
-        String subject) {
+    private Result(String ref, PushResult resSubj, RevCommit commit, String subject) {
       this.ref = ref;
       this.result = resSubj;
       this.commit = commit;
@@ -306,8 +366,7 @@
     }
 
     public ChangeData getChange() throws OrmException {
-      return Iterables.getOnlyElement(
-          queryProvider.get().byKeyPrefix(changeId));
+      return Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(changeId));
     }
 
     public PatchSet getPatchSet() throws OrmException {
@@ -326,9 +385,13 @@
       return commit;
     }
 
-    public void assertChange(Change.Status expectedStatus,
-        String expectedTopic, TestAccount... expectedReviewers)
-        throws OrmException, NoSuchChangeException {
+    public void assertPushOptions(List<String> pushOptions) {
+      assertEquals(pushOptions, getPushOptions());
+    }
+
+    public void assertChange(
+        Change.Status expectedStatus, String expectedTopic, TestAccount... expectedReviewers)
+        throws OrmException {
       Change c = getChange().change();
       assertThat(c.getSubject()).isEqualTo(resSubj);
       assertThat(c.getStatus()).isEqualTo(expectedStatus);
@@ -336,13 +399,11 @@
       assertReviewers(c, expectedReviewers);
     }
 
-    private void assertReviewers(Change c, TestAccount... expectedReviewers)
-        throws OrmException, NoSuchChangeException {
-      Iterable<Account.Id> actualIds = approvalsUtil
-          .getReviewers(db, notesFactory.createChecked(db, c))
-          .all();
-      assertThat(actualIds).containsExactlyElementsIn(
-          Sets.newHashSet(TestAccount.ids(expectedReviewers)));
+    private void assertReviewers(Change c, TestAccount... expectedReviewers) throws OrmException {
+      Iterable<Account.Id> actualIds =
+          approvalsUtil.getReviewers(db, notesFactory.createChecked(db, c)).all();
+      assertThat(actualIds)
+          .containsExactlyElementsIn(Sets.newHashSet(TestAccount.ids(expectedReviewers)));
     }
 
     public void assertOkStatus() {
@@ -355,27 +416,28 @@
 
     public void assertErrorStatus() {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      assertThat(refUpdate).isNotNull();
       assertThat(refUpdate.getStatus())
-        .named(message(refUpdate))
-        .isEqualTo(Status.REJECTED_OTHER_REASON);
+          .named(message(refUpdate))
+          .isEqualTo(Status.REJECTED_OTHER_REASON);
     }
 
     private void assertStatus(Status expectedStatus, String expectedMessage) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
-      assertThat(refUpdate.getStatus())
-        .named(message(refUpdate))
-        .isEqualTo(expectedStatus);
+      assertThat(refUpdate).isNotNull();
+      assertThat(refUpdate.getStatus()).named(message(refUpdate)).isEqualTo(expectedStatus);
       assertThat(refUpdate.getMessage()).isEqualTo(expectedMessage);
     }
 
     public void assertMessage(String expectedMessage) {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
-      assertThat(message(refUpdate).toLowerCase())
-        .contains(expectedMessage.toLowerCase());
+      assertThat(refUpdate).isNotNull();
+      assertThat(message(refUpdate).toLowerCase()).contains(expectedMessage.toLowerCase());
     }
 
     public String getMessage() {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      assertThat(refUpdate).isNotNull();
       return message(refUpdate);
     }
 
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
new file mode 100644
index 0000000..7912c08
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.io.IOException;
+
+class ReadOnlyChangeIndex implements ChangeIndex {
+  private final ChangeIndex index;
+
+  ReadOnlyChangeIndex(ChangeIndex index) {
+    this.index = index;
+  }
+
+  ChangeIndex unwrap() {
+    return index;
+  }
+
+  @Override
+  public Schema<ChangeData> getSchema() {
+    return index.getSchema();
+  }
+
+  @Override
+  public void close() {
+    index.close();
+  }
+
+  @Override
+  public void replace(ChangeData obj) throws IOException {
+    // do nothing
+  }
+
+  @Override
+  public void delete(Id key) throws IOException {
+    // do nothing
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    // do nothing
+  }
+
+  @Override
+  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    return index.getSource(p, opts);
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    // do nothing
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
index d76cb81..e08132a 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestResponse.java
@@ -18,11 +18,10 @@
 import static com.google.gerrit.httpd.restapi.RestApiServlet.JSON_MAGIC;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import org.apache.http.HttpStatus;
-
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
+import org.apache.http.HttpStatus;
 
 public class RestResponse extends HttpResponse {
 
@@ -33,8 +32,7 @@
   @Override
   public Reader getReader() throws IllegalStateException, IOException {
     if (reader == null && response.getEntity() != null) {
-      reader = new InputStreamReader(
-          response.getEntity().getContent(), UTF_8);
+      reader = new InputStreamReader(response.getEntity().getContent(), UTF_8);
       reader.skip(JSON_MAGIC.length);
     }
     return reader;
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
index 9c59e10..1a3c029 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
@@ -18,9 +18,10 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.net.HttpHeaders;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.server.OutputFormat;
-
+import java.io.IOException;
 import org.apache.http.Header;
 import org.apache.http.client.fluent.Request;
 import org.apache.http.entity.BufferedHttpEntity;
@@ -28,11 +29,9 @@
 import org.apache.http.entity.StringEntity;
 import org.apache.http.message.BasicHeader;
 
-import java.io.IOException;
-
 public class RestSession extends HttpSession {
 
-  public RestSession(GerritServer server, TestAccount account) {
+  public RestSession(GerritServer server, @Nullable TestAccount account) {
     super(server, account);
   }
 
@@ -41,13 +40,11 @@
   }
 
   public RestResponse getJsonAccept(String endPoint) throws IOException {
-    return getWithHeader(endPoint,
-        new BasicHeader(HttpHeaders.ACCEPT, "application/json"));
+    return getWithHeader(endPoint, new BasicHeader(HttpHeaders.ACCEPT, "application/json"));
   }
 
-  private RestResponse getWithHeader(String endPoint, Header header)
-      throws IOException {
-    Request get = Request.Get(url + "/a" + endPoint);
+  public RestResponse getWithHeader(String endPoint, Header header) throws IOException {
+    Request get = Request.Get(getUrl(endPoint));
     if (header != null) {
       get.addHeader(header);
     }
@@ -55,7 +52,7 @@
   }
 
   public RestResponse head(String endPoint) throws IOException {
-    return execute(Request.Head(url + "/a" + endPoint));
+    return execute(Request.Head(getUrl(endPoint)));
   }
 
   public RestResponse put(String endPoint) throws IOException {
@@ -66,34 +63,30 @@
     return putWithHeader(endPoint, null, content);
   }
 
-  public RestResponse putWithHeader(String endPoint, Header header)
-      throws IOException {
+  public RestResponse putWithHeader(String endPoint, Header header) throws IOException {
     return putWithHeader(endPoint, header, null);
   }
 
-  public RestResponse putWithHeader(String endPoint, Header header,
-      Object content) throws IOException {
-    Request put = Request.Put(url + "/a" + endPoint);
+  public RestResponse putWithHeader(String endPoint, Header header, Object content)
+      throws IOException {
+    Request put = Request.Put(getUrl(endPoint));
     if (header != null) {
       put.addHeader(header);
     }
     if (content != null) {
       put.addHeader(new BasicHeader("Content-Type", "application/json"));
-      put.body(new StringEntity(
-          OutputFormat.JSON_COMPACT.newGson().toJson(content),
-          UTF_8));
+      put.body(new StringEntity(OutputFormat.JSON_COMPACT.newGson().toJson(content), UTF_8));
     }
     return execute(put);
   }
 
   public RestResponse putRaw(String endPoint, RawInput stream) throws IOException {
     Preconditions.checkNotNull(stream);
-    Request put = Request.Put(url + "/a" + endPoint);
+    Request put = Request.Put(getUrl(endPoint));
     put.addHeader(new BasicHeader("Content-Type", stream.getContentType()));
-    put.body(new BufferedHttpEntity(
-        new InputStreamEntity(
-            stream.getInputStream(),
-            stream.getContentLength())));
+    put.body(
+        new BufferedHttpEntity(
+            new InputStreamEntity(stream.getInputStream(), stream.getContentLength())));
     return execute(put);
   }
 
@@ -102,17 +95,27 @@
   }
 
   public RestResponse post(String endPoint, Object content) throws IOException {
-    Request post = Request.Post(url + "/a" + endPoint);
+    return postWithHeader(endPoint, null, content);
+  }
+
+  public RestResponse postWithHeader(String endPoint, Header header, Object content)
+      throws IOException {
+    Request post = Request.Post(getUrl(endPoint));
+    if (header != null) {
+      post.addHeader(header);
+    }
     if (content != null) {
       post.addHeader(new BasicHeader("Content-Type", "application/json"));
-      post.body(new StringEntity(
-          OutputFormat.JSON_COMPACT.newGson().toJson(content),
-          UTF_8));
+      post.body(new StringEntity(OutputFormat.JSON_COMPACT.newGson().toJson(content), UTF_8));
     }
     return execute(post);
   }
 
   public RestResponse delete(String endPoint) throws IOException {
-    return execute(Request.Delete(url + "/a" + endPoint));
+    return execute(Request.Delete(getUrl(endPoint)));
+  }
+
+  private String getUrl(String endPoint) {
+    return url + (account != null ? "/a" : "") + endPoint;
   }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java
index 11446e0..2128c3c 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/Sandboxed.java
@@ -23,5 +23,4 @@
 
 @Target({TYPE, METHOD})
 @Retention(RUNTIME)
-public @interface Sandboxed {
-}
+public @interface Sandboxed {}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java
index 794f832..c433cad 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.jcraft.jsch.ChannelExec;
 import com.jcraft.jsch.JSch;
 import com.jcraft.jsch.JSchException;
 import com.jcraft.jsch.Session;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.InetSocketAddress;
@@ -42,27 +44,26 @@
   }
 
   @SuppressWarnings("resource")
-  public String exec(String command, InputStream opt) throws JSchException,
-      IOException {
+  public String exec(String command, InputStream opt) throws JSchException, IOException {
     ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
     try {
       channel.setCommand(command);
       channel.setInputStream(opt);
       InputStream in = channel.getInputStream();
+      InputStream err = channel.getErrStream();
       channel.connect();
 
-      Scanner s = new Scanner(channel.getErrStream()).useDelimiter("\\A");
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
       error = s.hasNext() ? s.next() : null;
 
-      s = new Scanner(in).useDelimiter("\\A");
+      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
       return s.hasNext() ? s.next() : "";
     } finally {
       channel.disconnect();
     }
   }
 
-  public InputStream exec2(String command, InputStream opt) throws JSchException,
-      IOException {
+  public InputStream exec2(String command, InputStream opt) throws JSchException, IOException {
     ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
     channel.setCommand(command);
     channel.setInputStream(opt);
@@ -75,7 +76,7 @@
     return exec(command, null);
   }
 
-  public boolean hasError() {
+  private boolean hasError() {
     return error != null;
   }
 
@@ -83,6 +84,19 @@
     return error;
   }
 
+  public void assertSuccess() {
+    assert_().withFailureMessage(getError()).that(hasError()).isFalse();
+  }
+
+  public void assertFailure() {
+    assertThat(hasError()).isTrue();
+  }
+
+  public void assertFailure(String error) {
+    assertThat(hasError()).isTrue();
+    assertThat(getError()).contains(error);
+  }
+
   public void close() {
     if (session != null) {
       session.disconnect();
@@ -93,12 +107,9 @@
   private Session getSession() throws JSchException {
     if (session == null) {
       JSch jsch = new JSch();
-      jsch.addIdentity("KeyPair",
-          account.privateKey(), account.sshKey.getPublicKeyBlob(), null);
-      session = jsch.getSession(
-          account.username,
-          addr.getAddress().getHostAddress(),
-          addr.getPort());
+      jsch.addIdentity("KeyPair", account.privateKey(), account.sshKey.getPublicKeyBlob(), null);
+      session =
+          jsch.getSession(account.username, addr.getAddress().getHostAddress(), addr.getPort());
       session.setConfig("StrictHostKeyChecking", "no");
       session.connect();
     }
@@ -116,4 +127,8 @@
     b.append(session.getPort());
     return b.toString();
   }
+
+  public TestAccount getAccount() {
+    return account;
+  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
new file mode 100644
index 0000000..9aa09db
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -0,0 +1,157 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Streams;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.launcher.GerritLauncher;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import java.util.Arrays;
+import java.util.Collections;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.rules.RuleChain;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.model.Statement;
+
+@RunWith(ConfigSuite.class)
+@UseLocalDisk
+public abstract class StandaloneSiteTest {
+  protected class ServerContext implements RequestContext, AutoCloseable {
+    private final GerritServer server;
+    private final ManualRequestContext ctx;
+
+    private ServerContext(GerritServer server) throws Exception {
+      this.server = server;
+      Injector i = server.getTestInjector();
+      if (adminId == null) {
+        adminId = i.getInstance(AccountCreator.class).admin().getId();
+      }
+      ctx = i.getInstance(OneOffRequestContext.class).openAs(adminId);
+      GerritApi gApi = i.getInstance(GerritApi.class);
+
+      try {
+        // ServerContext ctor is called multiple times but the group can be only created once
+        gApi.groups().id("Group");
+      } catch (ResourceNotFoundException e) {
+        GroupInput in = new GroupInput();
+        in.members = Collections.singletonList("admin");
+        in.name = "Group";
+        gApi.groups().create(in);
+      }
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return ctx.getUser();
+    }
+
+    @Override
+    public Provider<ReviewDb> getReviewDbProvider() {
+      return ctx.getReviewDbProvider();
+    }
+
+    public Injector getInjector() {
+      return server.getTestInjector();
+    }
+
+    @Override
+    public void close() throws Exception {
+      try {
+        ctx.close();
+      } finally {
+        server.close();
+      }
+    }
+  }
+
+  @ConfigSuite.Parameter public Config baseConfig;
+  @ConfigSuite.Name private String configName;
+
+  private final TemporaryFolder tempSiteDir = new TemporaryFolder();
+
+  private final TestRule testRunner =
+      new TestRule() {
+        @Override
+        public Statement apply(Statement base, Description description) {
+          return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+              beforeTest(description);
+              base.evaluate();
+            }
+          };
+        }
+      };
+
+  @Rule public RuleChain ruleChain = RuleChain.outerRule(tempSiteDir).around(testRunner);
+
+  protected SitePaths sitePaths;
+  protected Account.Id adminId;
+
+  private GerritServer.Description serverDesc;
+
+  private void beforeTest(Description description) throws Exception {
+    serverDesc = GerritServer.Description.forTestMethod(description, configName);
+    sitePaths = new SitePaths(tempSiteDir.getRoot().toPath());
+    GerritServer.init(serverDesc, baseConfig, sitePaths.site_path);
+  }
+
+  protected ServerContext startServer() throws Exception {
+    return new ServerContext(startImpl());
+  }
+
+  protected void assertServerStartupFails() throws Exception {
+    try (GerritServer server = startImpl()) {
+      fail("expected server startup to fail");
+    } catch (GerritServer.StartupException e) {
+      // Expected.
+    }
+  }
+
+  private GerritServer startImpl() throws Exception {
+    return GerritServer.start(serverDesc, baseConfig, sitePaths.site_path);
+  }
+
+  protected static void runGerrit(String... args) throws Exception {
+    assertThat(GerritLauncher.mainImpl(args))
+        .named("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
+        .isEqualTo(0);
+  }
+
+  @SafeVarargs
+  protected static void runGerrit(Iterable<String>... multiArgs) throws Exception {
+    runGerrit(
+        Arrays.stream(multiArgs).flatMap(args -> Streams.stream(args)).toArray(String[]::new));
+  }
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
index 7f08b6f..5117328 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
@@ -14,45 +14,30 @@
 
 package com.google.gerrit.acceptance;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.mail.Address;
-
 import com.jcraft.jsch.KeyPair;
-
-import org.eclipse.jgit.lib.PersonIdent;
-
 import java.io.ByteArrayOutputStream;
 import java.util.Arrays;
+import java.util.List;
+import org.eclipse.jgit.lib.PersonIdent;
 
 public class TestAccount {
-  public static FluentIterable<Account.Id> ids(
-      Iterable<TestAccount> accounts) {
-    return FluentIterable.from(accounts)
-        .transform(new Function<TestAccount, Account.Id>() {
-          @Override
-          public Account.Id apply(TestAccount in) {
-            return in.id;
-          }
-        });
+  public static List<Account.Id> ids(List<TestAccount> accounts) {
+    return accounts.stream().map(a -> a.id).collect(toList());
   }
 
-  public static FluentIterable<Account.Id> ids(TestAccount... accounts) {
+  public static List<Account.Id> ids(TestAccount... accounts) {
     return ids(Arrays.asList(accounts));
   }
 
-  public static FluentIterable<String> names(Iterable<TestAccount> accounts) {
-    return FluentIterable.from(accounts)
-        .transform(new Function<TestAccount, String>() {
-          @Override
-          public String apply(TestAccount in) {
-            return in.fullName;
-          }
-        });
+  public static List<String> names(List<TestAccount> accounts) {
+    return accounts.stream().map(a -> a.fullName).collect(toList());
   }
 
-  public static FluentIterable<String> names(TestAccount... accounts) {
+  public static List<String> names(TestAccount... accounts) {
     return names(Arrays.asList(accounts));
   }
 
@@ -63,9 +48,15 @@
   public final String fullName;
   public final KeyPair sshKey;
   public final String httpPassword;
+  public String status;
 
-  TestAccount(Account.Id id, String username, String email, String fullName,
-      KeyPair sshKey, String httpPassword) {
+  TestAccount(
+      Account.Id id,
+      String username,
+      String email,
+      String fullName,
+      KeyPair sshKey,
+      String httpPassword) {
     this.id = id;
     this.username = username;
     this.email = email;
@@ -86,7 +77,8 @@
   }
 
   public String getHttpUrl(GerritServer server) {
-    return String.format("http://%s:%s@%s:%d",
+    return String.format(
+        "http://%s:%s@%s:%d",
         username,
         httpPassword,
         server.getHttpAddress().getAddress().getHostAddress(),
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestPlugin.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestPlugin.java
new file mode 100644
index 0000000..cafc775
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestPlugin.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({TYPE})
+@Retention(RUNTIME)
+public @interface TestPlugin {
+  String name();
+
+  String sysModule() default "";
+
+  String httpModule() default "";
+
+  String sshModule() default "";
+}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java
index 4ad37e2..739d4f5 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java
@@ -19,7 +19,6 @@
 
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
-
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
@@ -29,18 +28,22 @@
   // Fields from ProjectInput for creating the project.
 
   String parent() default "";
+
   boolean createEmptyCommit() default true;
+
   String description() default "";
 
   // These may be null in a ProjectInput, but annotations do not allow null
   // default values. Thus these defaults should match ProjectConfig.
   SubmitType submitType() default SubmitType.MERGE_IF_NECESSARY;
-  InheritableBoolean useContributorAgreements()
-      default InheritableBoolean.INHERIT;
-  InheritableBoolean useSignedOffBy() default InheritableBoolean.INHERIT;
-  InheritableBoolean useContentMerge() default InheritableBoolean.INHERIT;
-  InheritableBoolean requireChangeId() default InheritableBoolean.INHERIT;
 
+  InheritableBoolean useContributorAgreements() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean useSignedOffBy() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean useContentMerge() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean requireChangeId() default InheritableBoolean.INHERIT;
 
   // Fields specific to acceptance test behavior.
 
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseLocalDisk.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseLocalDisk.java
index f9367ec..e177bb4 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseLocalDisk.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseLocalDisk.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.acceptance;
 
 import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
-@Target({METHOD})
+@Target({TYPE, METHOD})
 @Retention(RUNTIME)
-public @interface UseLocalDisk {
-}
+public @interface UseLocalDisk {}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseSsh.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseSsh.java
new file mode 100644
index 0000000..5509140
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/UseSsh.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface UseSsh {}
diff --git a/gerrit-acceptance-tests/BUCK b/gerrit-acceptance-tests/BUCK
deleted file mode 100644
index d5d0b0d..0000000
--- a/gerrit-acceptance-tests/BUCK
+++ /dev/null
@@ -1,44 +0,0 @@
-java_library(
-  name = 'lib',
-  srcs = glob(['src/test/java/com/google/gerrit/acceptance/*.java']),
-  exported_deps = [
-    '//gerrit-acceptance-framework:lib',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gpg:testutil',
-    '//gerrit-launcher:launcher',
-    '//gerrit-lucene:lucene',
-    '//gerrit-httpd:httpd',
-    '//gerrit-pgm:init',
-    '//gerrit-pgm:pgm',
-    '//gerrit-pgm:util',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-server:testutil',
-    '//gerrit-server/src/main/prolog:common',
-    '//gerrit-sshd:sshd',
-
-    '//lib:args4j',
-    '//lib:gson',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:h2',
-    '//lib:jsch',
-    '//lib:servlet-api-3_1',
-
-    '//lib/bouncycastle:bcpg',
-    '//lib/bouncycastle:bcprov',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/log:api',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/mina:sshd',
-  ],
-  visibility = [
-    '//gerrit-plugin-api/...',
-    '//tools/eclipse:classpath',
-    '//gerrit-acceptance-tests/...',
-  ],
-)
diff --git a/gerrit-acceptance-tests/BUILD b/gerrit-acceptance-tests/BUILD
index 2ec7a05..8317482 100644
--- a/gerrit-acceptance-tests/BUILD
+++ b/gerrit-acceptance-tests/BUILD
@@ -1,42 +1,47 @@
-load('//tools/bzl:java.bzl', 'java_library2')
+load("@rules_java//java:defs.bzl", "java_library")
 
-java_library2(
-  name = 'lib',
-  srcs = glob(['src/test/java/com/google/gerrit/acceptance/*.java']),
-  exported_deps = [
-    '//gerrit-acceptance-framework:lib',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gpg:testutil',
-    '//gerrit-launcher:launcher',
-    '//gerrit-lucene:lucene',
-    '//gerrit-httpd:httpd',
-    '//gerrit-pgm:init',
-    '//gerrit-pgm:pgm',
-    '//gerrit-pgm:util',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-server:testutil',
-    '//gerrit-server/src/main/prolog:common',
-    '//gerrit-sshd:sshd',
-
-    '//lib:args4j',
-    '//lib:gson',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:h2',
-    '//lib:jsch',
-    '//lib:servlet-api-3_1-without-neverlink',
-
-    '//lib/bouncycastle:bcpg',
-    '//lib/bouncycastle:bcprov',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/log:api',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/mina:sshd',
-  ],
-  visibility = ['//visibility:public'],
+java_library(
+    name = "lib",
+    testonly = 1,
+    srcs = ["src/test/java/com/google/gerrit/acceptance/Dummy.java"],
+    visibility = ["//visibility:public"],
+    exports = [
+        "//gerrit-acceptance-framework:lib",
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-elasticsearch:elasticsearch",
+        "//gerrit-elasticsearch:elasticsearch_test_utils",
+        "//gerrit-extension-api:api",
+        "//gerrit-gpg:testutil",
+        "//gerrit-httpd:httpd",
+        "//gerrit-launcher:launcher",
+        "//gerrit-lucene:lucene",
+        "//gerrit-pgm:init",
+        "//gerrit-pgm:pgm",
+        "//gerrit-pgm:util",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//gerrit-server:testutil",
+        "//gerrit-server/src/main/prolog:common",
+        "//gerrit-sshd:sshd",
+        "//gerrit-test-util:test_util",
+        "//lib:args4j",
+        "//lib:gson",
+        "//lib:guava-retrying",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:h2",
+        "//lib:jimfs",
+        "//lib:jsch",
+        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib/bouncycastle:bcpg",
+        "//lib/bouncycastle:bcprov",
+        "//lib/commons:compress",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/mina:sshd",
+    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/Dummy.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/Dummy.java
new file mode 100644
index 0000000..fb4783b
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/Dummy.java
@@ -0,0 +1,17 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+public class Dummy {}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java
deleted file mode 100644
index 29aadc1..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/SandboxTest.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.After;
-import org.junit.Test;
-
-@Sandboxed
-public class SandboxTest extends AbstractDaemonTest {
-  @After
-  public void addUser() throws Exception {
-    gApi.accounts().create("sandboxuser");
-  }
-
-  private void testUserNotPresent() throws Exception {
-    assertThat(gApi.accounts().query("sandboxuser").get()).isEmpty();
-  }
-
-  @Test
-  public void testUserNotPresent1() throws Exception {
-    testUserNotPresent();
-  }
-
-  @Test
-  public void testUserNotPresent2() throws Exception {
-    testUserNotPresent();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java
deleted file mode 100644
index 2f50480..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/UseGerritConfigAnnotationTest.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-import org.junit.Test;
-
-public class UseGerritConfigAnnotationTest extends AbstractDaemonTest {
-
-  @Inject
-  @GerritServerConfig
-  Config serverConfig;
-
-  @Test
-  @GerritConfig(name = "x.y", value = "z")
-  public void testOne() {
-    assertThat(serverConfig.getString("x", null, "y")).isEqualTo("z");
-  }
-
-  @Test
-  @GerritConfigs({
-      @GerritConfig(name = "x.y", value = "z"),
-      @GerritConfig(name = "a.b", value = "c")
-  })
-  public void testMultiple() {
-    assertThat(serverConfig.getString("x", null, "y")).isEqualTo("z");
-    assertThat(serverConfig.getString("a", null, "b")).isEqualTo("c");
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/BUILD
new file mode 100644
index 0000000..d16b64a
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/BUILD
@@ -0,0 +1,7 @@
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*.java"]),
+    group = "annotation",
+    labels = ["annotation"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/SandboxTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/SandboxTest.java
new file mode 100644
index 0000000..8d7bc3d
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/SandboxTest.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.annotation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import org.junit.After;
+import org.junit.Test;
+
+@Sandboxed
+public class SandboxTest extends AbstractDaemonTest {
+  @After
+  public void addUser() throws Exception {
+    gApi.accounts().create("sandboxuser");
+  }
+
+  @Test
+  public void userNotPresent1() throws Exception {
+    assertThat(gApi.accounts().query("sandboxuser").get()).isEmpty();
+  }
+
+  @Test
+  public void userNotPresent2() throws Exception {
+    assertThat(gApi.accounts().query("sandboxuser").get()).isEmpty();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
new file mode 100644
index 0000000..d5ac2f7
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2013 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.annotation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import org.junit.Test;
+
+public class UseGerritConfigAnnotationTest extends AbstractDaemonTest {
+  @Test
+  @GerritConfig(name = "section.name", value = "value")
+  public void testOne() {
+    assertThat(cfg.getString("section", null, "name")).isEqualTo("value");
+  }
+
+  @Test
+  @GerritConfig(name = "section.subsection.name", value = "value")
+  public void testOneWithSubsection() {
+    assertThat(cfg.getString("section", "subsection", "name")).isEqualTo("value");
+  }
+
+  @Test
+  @GerritConfig(name = "section.name", value = "value")
+  @GerritConfig(name = "section1.name", value = "value1")
+  @GerritConfig(name = "section.subsection.name", value = "value")
+  @GerritConfig(name = "section.subsection1.name", value = "value1")
+  public void testMultiple() {
+    assertThat(cfg.getString("section", null, "name")).isEqualTo("value");
+    assertThat(cfg.getString("section1", null, "name")).isEqualTo("value1");
+    assertThat(cfg.getString("section", "subsection", "name")).isEqualTo("value");
+    assertThat(cfg.getString("section", "subsection1", "name")).isEqualTo("value1");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "section.name",
+      values = {"value-1", "value-2"})
+  public void testList() {
+    assertThat(cfg.getStringList("section", null, "name"))
+        .asList()
+        .containsExactly("value-1", "value-2");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "section.subsection.name",
+      values = {"value-1", "value-2"})
+  public void testListWithSubsection() {
+    assertThat(cfg.getStringList("section", "subsection", "name"))
+        .asList()
+        .containsExactly("value-1", "value-2");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "section.name",
+      value = "value-1",
+      values = {"value-2", "value-3"})
+  public void valueHasPrecedenceOverValues() {
+    assertThat(cfg.getStringList("section", null, "name")).asList().containsExactly("value-1");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
new file mode 100644
index 0000000..44d9e46
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.annotation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GlobalPluginConfig;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class UseGlobalPluginConfigAnnotationTest extends AbstractDaemonTest {
+  private Config cfg() {
+    return pluginConfig.getGlobalPluginConfig("test");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(pluginName = "test", name = "section.name", value = "value")
+  public void testOne() {
+    assertThat(cfg().getString("section", null, "name")).isEqualTo("value");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(pluginName = "test", name = "section.subsection.name", value = "value")
+  public void testOneWithSubsection() {
+    assertThat(cfg().getString("section", "subsection", "name")).isEqualTo("value");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(pluginName = "test", name = "section.name", value = "value")
+  @GlobalPluginConfig(pluginName = "test", name = "section1.name", value = "value1")
+  @GlobalPluginConfig(pluginName = "test", name = "section.subsection.name", value = "value")
+  @GlobalPluginConfig(pluginName = "test", name = "section.subsection1.name", value = "value1")
+  public void testMultiple() {
+    assertThat(cfg().getString("section", null, "name")).isEqualTo("value");
+    assertThat(cfg().getString("section1", null, "name")).isEqualTo("value1");
+    assertThat(cfg().getString("section", "subsection", "name")).isEqualTo("value");
+    assertThat(cfg().getString("section", "subsection1", "name")).isEqualTo("value1");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(
+      pluginName = "test",
+      name = "section.name",
+      values = {"value-1", "value-2"})
+  public void testList() {
+    assertThat(cfg().getStringList("section", null, "name"))
+        .asList()
+        .containsExactly("value-1", "value-2");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(
+      pluginName = "test",
+      name = "section.subsection.name",
+      values = {"value-1", "value-2"})
+  public void testListWithSubsection() {
+    assertThat(cfg().getStringList("section", "subsection", "name"))
+        .asList()
+        .containsExactly("value-1", "value-2");
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(
+      pluginName = "test",
+      name = "section.name",
+      value = "value-1",
+      values = {"value-2", "value-3"})
+  public void valueHasPrecedenceOverValues() {
+    assertThat(cfg().getStringList("section", null, "name")).asList().containsExactly("value-1");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 6ad20ff..42a82ac 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -29,17 +29,22 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -53,15 +58,19 @@
 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.RestApiException;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.testutil.TestKey;
+import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountByEmailCache;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
@@ -70,7 +79,25 @@
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
@@ -87,16 +114,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.ByteArrayOutputStream;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-
 public class AccountIT extends AbstractDaemonTest {
   @ConfigSuite.Default
   public static Config enableSignedPushConfig() {
@@ -105,28 +122,48 @@
     return cfg;
   }
 
-  @Inject
-  private Provider<PublicKeyStore> publicKeyStoreProvider;
+  @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
 
-  @Inject
-  private AllUsersName allUsers;
+  @Inject private AllUsersName allUsers;
 
-  private List<AccountExternalId> savedExternalIds;
+  @Inject private AccountByEmailCache byEmailCache;
+
+  @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
+
+  private ExternalIdsUpdate externalIdsUpdate;
+  private List<ExternalId> savedExternalIds;
+  private BasicCookieStore httpCookieStore;
+  private CloseableHttpClient httpclient;
 
   @Before
   public void saveExternalIds() throws Exception {
+    externalIdsUpdate = externalIdsUpdateFactory.create();
+
     savedExternalIds = new ArrayList<>();
     savedExternalIds.addAll(getExternalIds(admin));
     savedExternalIds.addAll(getExternalIds(user));
   }
 
+  @Before
+  public void createHttpClient() {
+    httpCookieStore = new BasicCookieStore();
+    httpclient =
+        HttpClientBuilder.create()
+            .disableRedirectHandling()
+            .setDefaultCookieStore(httpCookieStore)
+            .build();
+  }
+
   @After
   public void restoreExternalIds() throws Exception {
-    db.accountExternalIds().delete(getExternalIds(admin));
-    db.accountExternalIds().delete(getExternalIds(user));
-    db.accountExternalIds().insert(savedExternalIds);
-    accountCache.evict(admin.getId());
-    accountCache.evict(user.getId());
+    if (savedExternalIds != null) {
+      // savedExternalIds is null when we don't run SSH tests and the assume in
+      // @Before in AbstractDaemonTest prevents this class' @Before method from
+      // being executed.
+      externalIdsUpdate.delete(db, getExternalIds(admin));
+      externalIdsUpdate.delete(db, getExternalIds(user));
+      externalIdsUpdate.insert(db, savedExternalIds);
+    }
   }
 
   @After
@@ -141,8 +178,7 @@
     }
   }
 
-  private Collection<AccountExternalId> getExternalIds(TestAccount account)
-      throws Exception {
+  private Collection<ExternalId> getExternalIds(TestAccount account) throws Exception {
     return accountCache.get(account.getId()).getExternalIds();
   }
 
@@ -153,18 +189,17 @@
       if (repo.getRefDatabase().exactRef(ref) != null) {
         RefUpdate ru = repo.updateRef(ref);
         ru.setForceUpdate(true);
-        assert_().withFailureMessage("Failed to delete " + ref)
-            .that(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+        assert_()
+            .withFailureMessage("Failed to delete " + ref)
+            .that(ru.delete())
+            .isEqualTo(RefUpdate.Result.FORCED);
       }
     }
   }
 
   @Test
   public void get() throws Exception {
-    AccountInfo info = gApi
-        .accounts()
-        .id("admin")
-        .get();
+    AccountInfo info = gApi.accounts().id("admin").get();
     assertThat(info.name).isEqualTo("Administrator");
     assertThat(info.email).isEqualTo("admin@example.com");
     assertThat(info.username).isEqualTo("admin");
@@ -172,46 +207,97 @@
 
   @Test
   public void getByIntId() throws Exception {
-    AccountInfo info = gApi
-        .accounts()
-        .id("admin")
-        .get();
-    AccountInfo infoByIntId = gApi
-        .accounts()
-        .id(info._accountId)
-        .get();
+    AccountInfo info = gApi.accounts().id("admin").get();
+    AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
     assertThat(info.name).isEqualTo(infoByIntId.name);
   }
 
   @Test
   public void self() throws Exception {
-    AccountInfo info = gApi
-        .accounts()
-        .self()
-        .get();
+    AccountInfo info = gApi.accounts().self().get();
     assertUser(info, admin);
 
-    info = gApi
-        .accounts()
-        .id("self")
-        .get();
+    info = gApi.accounts().id("self").get();
     assertUser(info, admin);
   }
 
   @Test
+  public void active() throws Exception {
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    gApi.accounts().id("user").setActive(false);
+    assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    gApi.accounts().id("user").setActive(true);
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void activeUserGetSessionCookieOnLogin() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    assertThat(accountIdApi().getActive()).isTrue();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).contains(CacheBasedWebSession.ACCOUNT_COOKIE);
+  }
+
+  @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void inactiveUserDoesNotGetCookieOnLogin() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    accountIdApi().setActive(false);
+    assertThat(accountIdApi().getActive()).isFalse();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void userDeactivatedAfterLoginDoesNotGetCookie() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    assertThat(accountIdApi().getActive()).isTrue();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).contains(CacheBasedWebSession.ACCOUNT_COOKIE);
+    httpGetAndAssertStatus("accounts/self/detail", HttpServletResponse.SC_OK);
+
+    accountIdApi().setActive(false);
+    assertThat(accountIdApi().getActive()).isFalse();
+
+    httpGetAndAssertStatus("accounts/self/detail", HttpServletResponse.SC_FORBIDDEN);
+  }
+
+  @Test
+  public void deactivateSelf() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("cannot deactivate own account");
+    gApi.accounts().self().setActive(false);
+  }
+
+  @Test
+  public void deactivateNotActive() throws Exception {
+    assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    gApi.accounts().id("user").setActive(false);
+    assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    try {
+      gApi.accounts().id("user").setActive(false);
+      fail("Expected exception");
+    } catch (ResourceConflictException e) {
+      assertThat(e.getMessage()).isEqualTo("account not active");
+    }
+    gApi.accounts().id("user").setActive(true);
+  }
+
+  @Test
   public void starUnstarChange() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
-    gApi.accounts()
-        .self()
-        .starChange(triplet);
+    gApi.accounts().self().starChange(triplet);
     ChangeInfo change = info(triplet);
     assertThat(change.starred).isTrue();
     assertThat(change.stars).contains(DEFAULT_LABEL);
 
-    gApi.accounts()
-        .self()
-        .unstarChange(triplet);
+    gApi.accounts().self().unstarChange(triplet);
     change = info(triplet);
     assertThat(change.starred).isNull();
     assertThat(change.stars).isNull();
@@ -224,31 +310,31 @@
     assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
     assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
 
-    gApi.accounts().self().setStars(triplet,
-        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "red", "blue")));
+    gApi.accounts()
+        .self()
+        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "red", "blue")));
     ChangeInfo change = info(triplet);
     assertThat(change.starred).isTrue();
-    assertThat(change.stars)
-        .containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+    assertThat(change.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
     assertThat(gApi.accounts().self().getStars(triplet))
-        .containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
-    List<ChangeInfo> starredChanges =
-        gApi.accounts().self().getStarredChanges();
+        .containsExactly("blue", "red", DEFAULT_LABEL)
+        .inOrder();
+    List<ChangeInfo> starredChanges = gApi.accounts().self().getStarredChanges();
     assertThat(starredChanges).hasSize(1);
     ChangeInfo starredChange = starredChanges.get(0);
     assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
     assertThat(starredChange.starred).isTrue();
-    assertThat(starredChange.stars)
-        .containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+    assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
 
-    gApi.accounts().self().setStars(triplet,
-        new StarsInput(ImmutableSet.of("yellow"),
-            ImmutableSet.of(DEFAULT_LABEL, "blue")));
+    gApi.accounts()
+        .self()
+        .setStars(
+            triplet,
+            new StarsInput(ImmutableSet.of("yellow"), ImmutableSet.of(DEFAULT_LABEL, "blue")));
     change = info(triplet);
     assertThat(change.starred).isNull();
     assertThat(change.stars).containsExactly("red", "yellow").inOrder();
-    assertThat(gApi.accounts().self().getStars(triplet)).containsExactly(
-        "red", "yellow").inOrder();
+    assertThat(gApi.accounts().self().getStars(triplet)).containsExactly("red", "yellow").inOrder();
     starredChanges = gApi.accounts().self().getStarredChanges();
     assertThat(starredChanges).hasSize(1);
     starredChange = starredChanges.get(0);
@@ -267,11 +353,13 @@
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
     exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        "invalid labels: another invalid label, invalid label");
-    gApi.accounts().self().setStars(triplet,
-        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "invalid label", "blue",
-            "another invalid label")));
+    exception.expectMessage("invalid labels: another invalid label, invalid label");
+    gApi.accounts()
+        .self()
+        .setStars(
+            triplet,
+            new StarsInput(
+                ImmutableSet.of(DEFAULT_LABEL, "invalid label", "blue", "another invalid label")));
   }
 
   @Test
@@ -279,11 +367,16 @@
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
     exception.expect(BadRequestException.class);
-    exception.expectMessage("The labels " + DEFAULT_LABEL
-        + " and " + IGNORE_LABEL + " are mutually exclusive."
-        + " Only one of them can be set.");
-    gApi.accounts().self().setStars(triplet,
-        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL)));
+    exception.expectMessage(
+        "The labels "
+            + DEFAULT_LABEL
+            + " and "
+            + IGNORE_LABEL
+            + " are mutually exclusive."
+            + " Only one of them can be set.");
+    gApi.accounts()
+        .self()
+        .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL)));
   }
 
   @Test
@@ -292,26 +385,19 @@
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(r.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     TestAccount user2 = accounts.user2();
     in = new AddReviewerInput();
     in.reviewer = user2.email;
-    gApi.changes()
-        .id(r.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     setApiUser(user);
-    gApi.accounts().self().setStars(r.getChangeId(),
-        new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
 
     sender.clear();
     setApiUser(admin);
-    gApi.changes()
-        .id(r.getChangeId())
-        .abandon();
+    gApi.changes().id(r.getChangeId()).abandon();
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
@@ -322,62 +408,184 @@
     PushOneCommit.Result r = createChange();
 
     setApiUser(user);
-    gApi.accounts().self().setStars(r.getChangeId(),
-        new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
 
     sender.clear();
     setApiUser(admin);
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(r.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
     assertThat(message.rcpt()).containsExactly(user.emailAddress);
-    assertMailFrom(message, admin.email);
+    assertMailReplyTo(message, admin.email);
   }
 
   @Test
   public void suggestAccounts() throws Exception {
     String adminUsername = "admin";
-    List<AccountInfo> result = gApi.accounts()
-        .suggestAccounts().withQuery(adminUsername).get();
+    List<AccountInfo> result = gApi.accounts().suggestAccounts().withQuery(adminUsername).get();
     assertThat(result).hasSize(1);
     assertThat(result.get(0).username).isEqualTo(adminUsername);
 
-    List<AccountInfo> resultShortcutApi = gApi.accounts()
-        .suggestAccounts(adminUsername).get();
+    List<AccountInfo> resultShortcutApi = gApi.accounts().suggestAccounts(adminUsername).get();
     assertThat(resultShortcutApi).hasSize(result.size());
 
-    List<AccountInfo> emptyResult = gApi.accounts()
-        .suggestAccounts("unknown").get();
+    List<AccountInfo> emptyResult = gApi.accounts().suggestAccounts("unknown").get();
     assertThat(emptyResult).isEmpty();
   }
 
   @Test
   public void addEmail() throws Exception {
-    List<String> emails = ImmutableList.of(
-        "new.email@example.com", "new.email@example.systems");
+    List<String> emails =
+        ImmutableList.of(
+            "new.email@example.com",
+            "new.email@example.systems",
+
+            // Not in the list of TLDs but added to override in OutgoingEmailValidator
+            "new.email@example.local");
+    Set<String> currentEmails = getEmails();
     for (String email : emails) {
+      assertThat(currentEmails).doesNotContain(email);
       EmailInput input = new EmailInput();
       input.email = email;
       input.noConfirmation = true;
       gApi.accounts().self().addEmail(input);
     }
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).containsAllIn(emails);
   }
 
   @Test
   public void addInvalidEmail() throws Exception {
-    EmailInput input  = new EmailInput();
-    input.email = "invalid@";
-    input.noConfirmation = true;
+    List<String> emails =
+        ImmutableList.of(
+            // Missing domain part
+            "new.email",
 
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid email address");
+            // Missing domain part
+            "new.email@",
+
+            // Missing user part
+            "@example.com",
+
+            // Non-supported TLD  (see tlds-alpha-by-domain.txt)
+            "new.email@example.blog");
+    for (String email : emails) {
+      EmailInput input = new EmailInput();
+      input.email = email;
+      input.noConfirmation = true;
+      try {
+        gApi.accounts().self().addEmail(input);
+        fail("Expected BadRequestException for invalid email address: " + email);
+      } catch (BadRequestException e) {
+        assertThat(e).hasMessageThat().isEqualTo("invalid email address");
+      }
+    }
+  }
+
+  @Test
+  public void deleteEmail() throws Exception {
+    String email = "foo.bar@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = true;
     gApi.accounts().self().addEmail(input);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).contains(email);
+
+    gApi.accounts().self().deleteEmail(input.email);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).doesNotContain(email);
+  }
+
+  @Test
+  public void deleteEmailFromCustomExternalIdSchemes() throws Exception {
+    String email = "foo.bar@example.com";
+    String extId1 = "foo:bar";
+    String extId2 = "foo:baz";
+    List<ExternalId> extIds =
+        ImmutableList.of(
+            ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email),
+            ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email));
+    externalIdsUpdateFactory.create().insert(db, extIds);
+    assertThat(
+            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
+        .containsAllOf(extId1, extId2);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).contains(email);
+
+    gApi.accounts().self().deleteEmail(email);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).doesNotContain(email);
+    assertThat(
+            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
+        .containsNoneOf(extId1, extId2);
+  }
+
+  @Test
+  public void deleteEmailOfOtherUser() throws Exception {
+    String email = "foo.bar@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = true;
+    gApi.accounts().id(user.id.get()).addEmail(input);
+
+    setApiUser(user);
+    assertThat(getEmails()).contains(email);
+
+    // admin can delete email of user
+    setApiUser(admin);
+    gApi.accounts().id(user.id.get()).deleteEmail(email);
+
+    setApiUser(user);
+    assertThat(getEmails()).doesNotContain(email);
+
+    // user cannot delete email of admin
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to delete email address");
+    gApi.accounts().id(admin.id.get()).deleteEmail(admin.email);
+  }
+
+  @Test
+  public void lookUpFromCacheByEmail() throws Exception {
+    // exact match with scheme "mailto:"
+    assertEmail(byEmailCache.get(admin.email), admin);
+
+    // exact match with other scheme
+    String email = "foo.bar@example.com";
+    externalIdsUpdateFactory
+        .create()
+        .insert(db, ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email));
+    assertEmail(byEmailCache.get(email), admin);
+
+    // wrong case doesn't match
+    assertThat(byEmailCache.get(admin.email.toUpperCase(Locale.US))).isEmpty();
+
+    // prefix doesn't match
+    assertThat(byEmailCache.get(admin.email.substring(0, admin.email.indexOf('@')))).isEmpty();
+
+    // non-existing doesn't match
+    assertThat(byEmailCache.get("non-existing@example.com")).isEmpty();
+  }
+
+  @Test
+  public void putStatus() throws Exception {
+    List<String> statuses = ImmutableList.of("OOO", "Busy");
+    AccountInfo info;
+    for (String status : statuses) {
+      gApi.accounts().self().setStatus(status);
+      admin.status = status;
+      info = gApi.accounts().self().get();
+      assertUser(info, admin);
+    }
   }
 
   @Test
@@ -386,18 +594,15 @@
     // is created
     setApiUser(user);
     GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage =
-        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
     gApi.accounts().self().setPreferences(input);
 
-    TestRepository<InMemoryRepository> allUsersRepo =
-        cloneProject(allUsers, user);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
     String userRefName = RefNames.refsUsers(user.id);
 
     // remove default READ permissions
     ProjectConfig cfg = projectCache.checkedGet(allUsers).getConfig();
-    cfg.getAccessSection(
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
+    cfg.getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
         .remove(new Permission(Permission.READ));
     saveProjectConfig(allUsers, cfg);
 
@@ -407,15 +612,17 @@
     // fetching user branch without READ permission fails
     try {
       fetch(allUsersRepo, userRefName + ":userRef");
-      Assert.fail(
-          "user branch is visible although no READ permission is granted");
+      Assert.fail("user branch is visible although no READ permission is granted");
     } catch (TransportException e) {
       // expected because no READ granted on user branch
     }
 
     // allow each user to read its own user branch
-    grant(Permission.READ, allUsers,
-        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", false,
+    grant(
+        Permission.READ,
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        false,
         REGISTERED_USERS);
 
     // fetch user branch using refs/users/YY/XXXXXXX
@@ -425,16 +632,14 @@
 
     // fetch user branch using refs/users/self
     fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userSelfRef");
-    Ref userSelfRef =
-        allUsersRepo.getRepository().getRefDatabase().exactRef("userSelfRef");
+    Ref userSelfRef = allUsersRepo.getRepository().getRefDatabase().exactRef("userSelfRef");
     assertThat(userSelfRef).isNotNull();
     assertThat(userSelfRef.getObjectId()).isEqualTo(userRef.getObjectId());
 
     // fetching user branch of another user fails
     String otherUserRefName = RefNames.refsUsers(admin.id);
     exception.expect(TransportException.class);
-    exception.expectMessage(
-        "Remote does not have " + otherUserRefName + " available for fetch.");
+    exception.expectMessage("Remote does not have " + otherUserRefName + " available for fetch.");
     fetch(allUsersRepo, otherUserRefName + ":otherUserRef");
   }
 
@@ -443,8 +648,7 @@
     // change something in the user preferences to ensure that the user branch
     // is created
     GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage =
-        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
     gApi.accounts().self().setPreferences(input);
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
@@ -462,8 +666,7 @@
     // change something in the user preferences to ensure that the user branch
     // is created
     GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage =
-        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
     gApi.accounts().self().setPreferences(input);
 
     String userRefName = RefNames.refsUsers(admin.id);
@@ -490,8 +693,7 @@
     // change something in the user preferences to ensure that the user branch
     // is created
     GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage =
-        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
     gApi.accounts().self().setPreferences(input);
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
@@ -499,24 +701,37 @@
     allUsersRepo.reset("userRef");
 
     Config wc = new Config();
-    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY,
-        WatchConfig.NotifyValue
-            .create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo,
-        "Add project watch", WatchConfig.WATCH_CONFIG, wc.toText());
+    wc.setString(
+        WatchConfig.PROJECT,
+        project.get(),
+        WatchConfig.KEY_NOTIFY,
+        WatchConfig.NotifyValue.create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            allUsersRepo,
+            "Add project watch",
+            WatchConfig.WATCH_CONFIG,
+            wc.toText());
     push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
 
     String invalidNotifyValue = "]invalid[";
-    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY,
-        invalidNotifyValue);
-    push = pushFactory.create(db, admin.getIdent(), allUsersRepo,
-        "Add invalid project watch", WatchConfig.WATCH_CONFIG, wc.toText());
+    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY, invalidNotifyValue);
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            allUsersRepo,
+            "Add invalid project watch",
+            WatchConfig.WATCH_CONFIG,
+            wc.toText());
     PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
     r.assertErrorStatus("invalid watch configuration");
-    r.assertMessage(String.format(
-        "%s: Invalid project watch of account %d for project %s: %s",
-        WatchConfig.WATCH_CONFIG, admin.getId().get(), project.get(),
-        invalidNotifyValue));
+    r.assertMessage(
+        String.format(
+            "%s: Invalid project watch of account %d for project %s: %s",
+            WatchConfig.WATCH_CONFIG, admin.getId().get(), project.get(), invalidNotifyValue));
   }
 
   @Test
@@ -555,11 +770,7 @@
   public void addOtherUsersGpgKey_Conflict() throws Exception {
     // Both users have a matching external ID for this key.
     addExternalIdEmail(admin, "test5@example.com");
-    AccountExternalId extId = new AccountExternalId(
-        user.getId(), new AccountExternalId.Key("foo:myId"));
-
-    db.accountExternalIds().insert(Collections.singleton(extId));
-    accountCache.evict(user.getId());
+    externalIdsUpdate.insert(db, ExternalId.create("foo", "myId", user.getId()));
 
     TestKey key = validKeyWithSecondUserId();
     addGpgKey(key.getPublicKeyArmored());
@@ -575,11 +786,10 @@
     List<TestKey> keys = allValidKeys();
     List<String> toAdd = new ArrayList<>(keys.size());
     for (TestKey key : keys) {
-      addExternalIdEmail(admin,
-          PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
+      addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
       toAdd.add(key.getPublicKeyArmored());
     }
-    gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String> of());
+    gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String>of());
     assertKeys(keys);
   }
 
@@ -602,41 +812,54 @@
   @Test
   public void addAndRemoveGpgKeys() throws Exception {
     for (TestKey key : allValidKeys()) {
-      addExternalIdEmail(admin,
-          PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
+      addExternalIdEmail(admin, PushCertificateIdent.parse(key.getFirstUserId()).getEmailAddress());
     }
     TestKey key1 = validKeyWithoutExpiration();
     TestKey key2 = validKeyWithExpiration();
     TestKey key5 = validKeyWithSecondUserId();
 
-    Map<String, GpgKeyInfo> infos = gApi.accounts().self().putGpgKeys(
-        ImmutableList.of(
-          key1.getPublicKeyArmored(),
-          key2.getPublicKeyArmored()),
-        ImmutableList.of(key5.getKeyIdString()));
-    assertThat(infos.keySet())
-        .containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
+    Map<String, GpgKeyInfo> infos =
+        gApi.accounts()
+            .self()
+            .putGpgKeys(
+                ImmutableList.of(key1.getPublicKeyArmored(), key2.getPublicKeyArmored()),
+                ImmutableList.of(key5.getKeyIdString()));
+    assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
     assertKeys(key1, key2);
 
-    infos = gApi.accounts().self().putGpgKeys(
-        ImmutableList.of(key5.getPublicKeyArmored()),
-        ImmutableList.of(key1.getKeyIdString()));
-    assertThat(infos.keySet())
-        .containsExactly(key1.getKeyIdString(), key5.getKeyIdString());
+    infos =
+        gApi.accounts()
+            .self()
+            .putGpgKeys(
+                ImmutableList.of(key5.getPublicKeyArmored()),
+                ImmutableList.of(key1.getKeyIdString()));
+    assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key5.getKeyIdString());
     assertKeyMapContains(key5, infos);
     assertThat(infos.get(key1.getKeyIdString()).key).isNull();
     assertKeys(key2, key5);
 
     exception.expect(BadRequestException.class);
-    exception.expectMessage("Cannot both add and delete key: "
-        + keyToString(key2.getPublicKey()));
-    infos = gApi.accounts().self().putGpgKeys(
-        ImmutableList.of(key2.getPublicKeyArmored()),
-        ImmutableList.of(key2.getKeyIdString()));
+    exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
+    infos =
+        gApi.accounts()
+            .self()
+            .putGpgKeys(
+                ImmutableList.of(key2.getPublicKeyArmored()),
+                ImmutableList.of(key2.getKeyIdString()));
   }
 
   @Test
+  public void addMalformedGpgKey() throws Exception {
+    String key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ntest\n-----END PGP PUBLIC KEY BLOCK-----";
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Failed to parse GPG keys");
+    addGpgKey(key);
+  }
+
+  @Test
+  @UseSsh
   public void sshKeys() throws Exception {
+    //
     // The test account should initially have exactly one ssh key
     List<SshKeyInfo> info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(1);
@@ -646,8 +869,7 @@
     assertThat(key.sshPublicKey).isEqualTo(inital);
 
     // Add a new key
-    String newKey = AccountCreator.publicKey(
-        AccountCreator.genSshKey(), admin.email);
+    String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
     gApi.accounts().self().addSshKey(newKey);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
@@ -660,8 +882,7 @@
     assertSequenceNumbers(info);
 
     // Add another new key
-    String newKey2 = AccountCreator.publicKey(
-        AccountCreator.genSshKey(), admin.email);
+    String newKey2 = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
     gApi.accounts().self().addSshKey(newKey2);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(3);
@@ -692,6 +913,38 @@
     gApi.accounts().id(admin.username).index();
   }
 
+  @Test
+  public void createUserWithValidUsername() throws Exception {
+    ImmutableList<String> names =
+        ImmutableList.of(
+            "user@domain",
+            "user-name",
+            "user_name",
+            "1234",
+            "user1234",
+            "1234@domain",
+            "user!+alias{*}#$%&’^=~|@domain");
+    for (String name : names) {
+      gApi.accounts().create(name);
+    }
+  }
+
+  @Test
+  public void createUserWithInvalidUsername() throws Exception {
+    ImmutableList<String> invalidNames =
+        ImmutableList.of(
+            "@", "@foo", "-", "-foo", "_", "_foo", "!", "+", "{", "}", "*", "%", "#", "$", "&", "’",
+            "^", "=", "~");
+    for (String name : invalidNames) {
+      try {
+        gApi.accounts().create(name);
+        fail(String.format("Expected BadRequestException for username [%s]", name));
+      } catch (BadRequestException e) {
+        assertThat(e).hasMessageThat().isEqualTo(String.format("Invalid username '%s'", name));
+      }
+    }
+  }
+
   private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
     int seq = 1;
     for (SshKeyInfo key : sshKeys) {
@@ -715,13 +968,12 @@
     return new String(out.toByteArray(), UTF_8);
   }
 
-  @SuppressWarnings({"unchecked", "rawtypes"})
-  private static void assertIteratorSize(int size, Iterator it) {
-    assertThat(ImmutableList.copyOf(it)).hasSize(size);
+  private static void assertIteratorSize(int size, Iterator<?> it) {
+    List<?> lst = ImmutableList.copyOf(it);
+    assertThat(lst).hasSize(size);
   }
 
-  private static void assertKeyMapContains(TestKey expected,
-      Map<String, GpgKeyInfo> actualMap) {
+  private static void assertKeyMapContains(TestKey expected, Map<String, GpgKeyInfo> actualMap) {
     GpgKeyInfo actual = actualMap.get(expected.getKeyIdString());
     assertThat(actual).isNotNull();
     assertThat(actual.id).isNull();
@@ -739,41 +991,26 @@
     Map<String, GpgKeyInfo> keyMap = gApi.accounts().self().listGpgKeys();
     assertThat(keyMap.keySet())
         .named("keys returned by listGpgKeys()")
-        .containsExactlyElementsIn(
-          expected.transform(new Function<TestKey, String>() {
-            @Override
-            public String apply(TestKey in) {
-              return in.getKeyIdString();
-            }
-          }));
+        .containsExactlyElementsIn(expected.transform(TestKey::getKeyIdString));
 
     for (TestKey key : expected) {
-      assertKeyEquals(key, gApi.accounts().self().gpgKey(
-          key.getKeyIdString()).get());
-      assertKeyEquals(key, gApi.accounts().self().gpgKey(
-          Fingerprint.toString(key.getPublicKey().getFingerprint())).get());
+      assertKeyEquals(key, gApi.accounts().self().gpgKey(key.getKeyIdString()).get());
+      assertKeyEquals(
+          key,
+          gApi.accounts()
+              .self()
+              .gpgKey(Fingerprint.toString(key.getPublicKey().getFingerprint()))
+              .get());
       assertKeyMapContains(key, keyMap);
     }
 
     // Check raw external IDs.
     Account.Id currAccountId = atrScope.get().getUser().getAccountId();
-    assertThat(
-        GpgKeys.getGpgExtIds(db, currAccountId)
-          .transform(new Function<AccountExternalId, String>() {
-            @Override
-            public String apply(AccountExternalId in) {
-              return in.getSchemeRest();
-            }
-          }))
-        .named("external IDs in database")
-        .containsExactlyElementsIn(
-            expected.transform(new Function<TestKey, String>() {
-              @Override
-              public String apply(TestKey in) {
-                return BaseEncoding.base16().encode(
-                    in.getPublicKey().getFingerprint());
-              }
-            }));
+    Iterable<String> expectedFps =
+        expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
+    Iterable<String> actualFps =
+        GpgKeys.getGpgExtIds(db, currAccountId).transform(e -> e.key().id());
+    assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
     for (TestKey key : expected) {
@@ -784,40 +1021,64 @@
   private static void assertKeyEquals(TestKey expected, GpgKeyInfo actual) {
     String id = expected.getKeyIdString();
     assertThat(actual.id).named(id).isEqualTo(id);
-    assertThat(actual.fingerprint).named(id).isEqualTo(
-        Fingerprint.toString(expected.getPublicKey().getFingerprint()));
-    @SuppressWarnings("unchecked")
-    List<String> userIds =
-        ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
+    assertThat(actual.fingerprint)
+        .named(id)
+        .isEqualTo(Fingerprint.toString(expected.getPublicKey().getFingerprint()));
+    List<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
     assertThat(actual.userIds).named(id).containsExactlyElementsIn(userIds);
-    assertThat(actual.key).named(id)
-        .startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
+    assertThat(actual.key).named(id).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
     assertThat(actual.status).isEqualTo(GpgKeyInfo.Status.TRUSTED);
     assertThat(actual.problems).isEmpty();
   }
 
-  private void addExternalIdEmail(TestAccount account, String email)
-      throws Exception {
+  private void addExternalIdEmail(TestAccount account, String email) throws Exception {
     checkNotNull(email);
-    AccountExternalId extId = new AccountExternalId(
-        account.getId(), new AccountExternalId.Key(name("test"), email));
-    extId.setEmailAddress(email);
-    db.accountExternalIds().insert(Collections.singleton(extId));
-    // Clear saved AccountState and AccountExternalIds.
-    accountCache.evict(account.getId());
+    externalIdsUpdate.insert(
+        db, ExternalId.createWithEmail(name("test"), email, account.getId(), email));
     setApiUser(account);
   }
 
   private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
-    return gApi.accounts().self().putGpgKeys(
-        ImmutableList.of(armored),
-        ImmutableList.<String> of());
+    return gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
   }
 
-  private void assertUser(AccountInfo info, TestAccount account)
-      throws Exception {
+  private void assertUser(AccountInfo info, TestAccount account) throws Exception {
     assertThat(info.name).isEqualTo(account.fullName);
     assertThat(info.email).isEqualTo(account.email);
     assertThat(info.username).isEqualTo(account.username);
+    assertThat(info.status).isEqualTo(account.status);
+  }
+
+  private Set<String> getEmails() throws RestApiException {
+    return gApi.accounts().self().getEmails().stream().map(e -> e.email).collect(toSet());
+  }
+
+  private void assertEmail(Set<Account.Id> accounts, TestAccount expectedAccount) {
+    assertThat(accounts).hasSize(1);
+    assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.getId());
+  }
+
+  private AccountApi accountIdApi() throws RestApiException {
+    return gApi.accounts().id("user");
+  }
+
+  private Set<String> getCookiesNames() {
+    Set<String> cookieNames =
+        httpCookieStore.getCookies().stream()
+            .map(cookie -> cookie.getName())
+            .collect(Collectors.toSet());
+    return cookieNames;
+  }
+
+  private void webLogin(Integer accountId) throws IOException, ClientProtocolException {
+    httpGetAndAssertStatus(
+        "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
+  }
+
+  private void httpGetAndAssertStatus(String urlPath, int expectedHttpStatus)
+      throws ClientProtocolException, IOException {
+    HttpGet httpGet = new HttpGet(canonicalWebUrl.get() + urlPath);
+    HttpResponse loginResponse = httpclient.execute(httpGet);
+    assertThat(loginResponse.getStatusLine().getStatusCode()).isEqualTo(expectedHttpStatus);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 8cd696c..10acae4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -19,41 +19,34 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.junit.Assert.fail;
 
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestTimeUtil;
-
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
-import java.util.List;
-
 public class AgreementsIT extends AbstractDaemonTest {
-  private ContributorAgreement ca;
-  private ContributorAgreement ca2;
+  private ContributorAgreement caAutoVerify;
+  private ContributorAgreement caNoAutoVerify;
 
   @ConfigSuite.Config
   public static Config enableAgreementsConfig() {
@@ -74,32 +67,26 @@
 
   @Before
   public void setUp() throws Exception {
-    String g = createGroup("cla-test-group");
-    GroupApi groupApi = gApi.groups().id(g);
-    groupApi.description("CLA test group");
-    AccountGroup caGroup = groupCache.get(
-        new AccountGroup.UUID(groupApi.detail().id));
-    GroupReference groupRef = GroupReference.forGroup(caGroup);
-    PermissionRule rule = new PermissionRule(groupRef);
-    rule.setAction(PermissionRule.Action.ALLOW);
-    ca = new ContributorAgreement("cla-test");
-    ca.setDescription("description");
-    ca.setAgreementUrl("agreement-url");
-    ca.setAutoVerify(groupRef);
-    ca.setAccepted(ImmutableList.of(rule));
-
-    ca2 = new ContributorAgreement("cla-test-no-auto-verify");
-    ca2.setDescription("description");
-    ca2.setAgreementUrl("agreement-url");
-
-    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
-    cfg.replace(ca);
-    cfg.replace(ca2);
-    saveProjectConfig(allProjects, cfg);
+    caAutoVerify = configureContributorAgreement(true);
+    caNoAutoVerify = configureContributorAgreement(false);
     setApiUser(user);
   }
 
   @Test
+  public void getAvailableAgreements() throws Exception {
+    ServerInfo info = gApi.config().server().getInfo();
+    if (isContributorAgreementsEnabled()) {
+      assertThat(info.auth.useContributorAgreements).isTrue();
+      assertThat(info.auth.contributorAgreements).hasSize(2);
+      assertAgreement(info.auth.contributorAgreements.get(0), caAutoVerify);
+      assertAgreement(info.auth.contributorAgreements.get(1), caNoAutoVerify);
+    } else {
+      assertThat(info.auth.useContributorAgreements).isNull();
+      assertThat(info.auth.contributorAgreements).isNull();
+    }
+  }
+
+  @Test
   public void signNonExistingAgreement() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
     exception.expect(UnprocessableEntityException.class);
@@ -112,7 +99,7 @@
     assume().that(isContributorAgreementsEnabled()).isTrue();
     exception.expect(BadRequestException.class);
     exception.expectMessage("cannot enter a non-autoVerify agreement");
-    gApi.accounts().self().signAgreement(ca2.getName());
+    gApi.accounts().self().signAgreement(caNoAutoVerify.getName());
   }
 
   @Test
@@ -124,7 +111,7 @@
     assertThat(result).isEmpty();
 
     // Sign the agreement
-    gApi.accounts().self().signAgreement(ca.getName());
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
 
     // Explicitly reset the user to force a new request context
     setApiUser(user);
@@ -133,22 +120,37 @@
     result = gApi.accounts().self().listAgreements();
     assertThat(result).hasSize(1);
     AgreementInfo info = result.get(0);
-    assertThat(info.name).isEqualTo(ca.getName());
-    assertThat(info.description).isEqualTo(ca.getDescription());
-    assertThat(info.url).isEqualTo(ca.getAgreementUrl());
+    assertAgreement(info, caAutoVerify);
 
     // Signing the same agreement again has no effect
-    gApi.accounts().self().signAgreement(ca.getName());
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
     result = gApi.accounts().self().listAgreements();
     assertThat(result).hasSize(1);
   }
 
   @Test
+  public void signAgreementAsOtherUser() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+    assertThat(gApi.accounts().self().get().name).isNotEqualTo("admin");
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to enter contributor agreement");
+    gApi.accounts().id("admin").signAgreement(caAutoVerify.getName());
+  }
+
+  @Test
+  public void signAgreementAnonymous() throws Exception {
+    setApiUserAnonymous();
+    exception.expect(AuthException.class);
+    exception.expectMessage("Authentication required");
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
+  }
+
+  @Test
   public void agreementsDisabledSign() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isFalse();
     exception.expect(MethodNotAllowedException.class);
     exception.expectMessage("contributor agreements disabled");
-    gApi.accounts().self().signAgreement(ca.getName());
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
   }
 
   @Test
@@ -186,8 +188,12 @@
 
     // Create a new branch
     setApiUser(admin);
-    BranchInfo dest = gApi.projects().name(project.get())
-        .branch("cherry-pick-to").create(new BranchInput()).get();
+    BranchInfo dest =
+        gApi.projects()
+            .name(project.get())
+            .branch("cherry-pick-to")
+            .create(new BranchInput())
+            .get();
 
     // Create a change succeeds when agreement is not required
     setUseContributorAgreements(InheritableBoolean.FALSE);
@@ -222,12 +228,11 @@
       gApi.changes().create(newChangeInput());
       fail("Expected AuthException");
     } catch (AuthException e) {
-      assertThat(e.getMessage()).contains(
-          "A Contributor Agreement must be completed");
+      assertThat(e.getMessage()).contains("A Contributor Agreement must be completed");
     }
 
     // Sign the agreement
-    gApi.accounts().self().signAgreement(ca.getName());
+    gApi.accounts().self().signAgreement(caAutoVerify.getName());
 
     // Explicitly reset the user to force a new request context
     setApiUser(user);
@@ -236,6 +241,17 @@
     gApi.changes().create(newChangeInput());
   }
 
+  private void assertAgreement(AgreementInfo info, ContributorAgreement ca) {
+    assertThat(info.name).isEqualTo(ca.getName());
+    assertThat(info.description).isEqualTo(ca.getDescription());
+    assertThat(info.url).isEqualTo(ca.getAgreementUrl());
+    if (ca.getAutoVerify() != null) {
+      assertThat(info.autoVerifyGroup.name).isEqualTo(ca.getAutoVerify().getName());
+    } else {
+      assertThat(info.autoVerifyGroup).isNull();
+    }
+  }
+
   private ChangeInput newChangeInput() {
     ChangeInput in = new ChangeInput();
     in.branch = "master";
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUCK
deleted file mode 100644
index 4e3c880..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'api_account',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
index 9935eeb..3d62cfc 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -1,7 +1,10 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'api_account',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
+    srcs = glob(["*IT.java"]),
+    group = "api_account",
+    labels = [
+        "api",
+        "noci",
+    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
index 9236176..fcf939d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.Theme;
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.account.VersionedAccountPreferences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -36,38 +36,41 @@
 import org.junit.Test;
 
 @NoHttpd
+@Sandboxed
 public class DiffPreferencesIT extends AbstractDaemonTest {
-  @Inject
-  private AllUsersName allUsers;
+  @Inject private AllUsersName allUsers;
 
   @After
   public void cleanUp() throws Exception {
-    gApi.accounts().id(admin.getId().toString())
-        .setDiffPreferences(DiffPreferencesInfo.defaults());
+    gApi.accounts().id(admin.getId().toString()).setDiffPreferences(DiffPreferencesInfo.defaults());
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     try {
       fetch(allUsersRepo, RefNames.REFS_USERS_DEFAULT + ":defaults");
     } catch (TransportException e) {
-      if (e.getMessage().equals("Remote does not have "
-          + RefNames.REFS_USERS_DEFAULT + " available for fetch.")) {
+      if (e.getMessage()
+          .equals(
+              "Remote does not have " + RefNames.REFS_USERS_DEFAULT + " available for fetch.")) {
         return;
       }
       throw e;
     }
     allUsersRepo.reset("defaults");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo,
-        "Delete default preferences", VersionedAccountPreferences.PREFERENCES,
-        "");
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            allUsersRepo,
+            "Delete default preferences",
+            VersionedAccountPreferences.PREFERENCES,
+            "");
     push.rm(RefNames.REFS_USERS_DEFAULT).assertOkStatus();
   }
 
   @Test
   public void getDiffPreferences() throws Exception {
     DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
-    DiffPreferencesInfo o = gApi.accounts()
-        .id(admin.getId().toString())
-        .getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
     assertPrefs(o, d);
   }
 
@@ -78,6 +81,7 @@
     // change all default values
     i.context *= -1;
     i.tabSize *= -1;
+    i.fontSize *= -1;
     i.lineLength *= -1;
     i.cursorBlinkRate = 500;
     i.theme = Theme.MIDNIGHT;
@@ -101,17 +105,13 @@
     i.matchBrackets ^= true;
     i.lineWrapping ^= true;
 
-    DiffPreferencesInfo o = gApi.accounts()
-        .id(admin.getId().toString())
-        .setDiffPreferences(i);
+    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
     assertPrefs(o, i);
 
     // Partially fill input record
     i = new DiffPreferencesInfo();
     i.tabSize = 42;
-    DiffPreferencesInfo a = gApi.accounts()
-        .id(admin.getId().toString())
-        .setDiffPreferences(i);
+    DiffPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
     assertPrefs(a, o, "tabSize");
     assertThat(a.tabSize).isEqualTo(42);
   }
@@ -121,20 +121,56 @@
     DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
     int newLineLength = d.lineLength + 10;
     int newTabSize = d.tabSize * 2;
+    int newFontSize = d.fontSize - 2;
     DiffPreferencesInfo update = new DiffPreferencesInfo();
     update.lineLength = newLineLength;
     update.tabSize = newTabSize;
+    update.fontSize = newFontSize;
     gApi.config().server().setDefaultDiffPreferences(update);
 
-    DiffPreferencesInfo o = gApi.accounts()
-        .id(admin.getId().toString())
-        .getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
 
     // assert configured defaults
     assertThat(o.lineLength).isEqualTo(newLineLength);
     assertThat(o.tabSize).isEqualTo(newTabSize);
+    assertThat(o.fontSize).isEqualTo(newFontSize);
 
     // assert hard-coded defaults
-    assertPrefs(o, d, "lineLength", "tabSize");
+    assertPrefs(o, d, "lineLength", "tabSize", "fontSize");
+  }
+
+  @Test
+  public void overwriteConfiguredDefaults() throws Exception {
+    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
+    int configuredDefaultLineLength = d.lineLength + 10;
+    DiffPreferencesInfo update = new DiffPreferencesInfo();
+    update.lineLength = configuredDefaultLineLength;
+    gApi.config().server().setDefaultDiffPreferences(update);
+
+    DiffPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    assertThat(o.lineLength).isEqualTo(configuredDefaultLineLength);
+    assertPrefs(o, d, "lineLength");
+
+    int newLineLength = configuredDefaultLineLength + 10;
+    DiffPreferencesInfo i = new DiffPreferencesInfo();
+    i.lineLength = newLineLength;
+    DiffPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    assertThat(a.lineLength).isEqualTo(newLineLength);
+    assertPrefs(a, d, "lineLength");
+
+    a = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    assertThat(a.lineLength).isEqualTo(newLineLength);
+    assertPrefs(a, d, "lineLength");
+
+    // overwrite the configured default with original hard-coded default
+    i = new DiffPreferencesInfo();
+    i.lineLength = d.lineLength;
+    a = gApi.accounts().id(admin.getId().toString()).setDiffPreferences(i);
+    assertThat(a.lineLength).isEqualTo(d.lineLength);
+    assertPrefs(a, d, "lineLength");
+
+    a = gApi.accounts().id(admin.getId().toString()).getDiffPreferences();
+    assertThat(a.lineLength).isEqualTo(d.lineLength);
+    assertPrefs(a, d, "lineLength");
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
index 9eb6918..c1d9bcb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -21,16 +21,13 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.KeyMapType;
 import com.google.gerrit.extensions.client.Theme;
-
 import org.junit.Test;
 
 @NoHttpd
 public class EditPreferencesIT extends AbstractDaemonTest {
   @Test
   public void getSetEditPreferences() throws Exception {
-    EditPreferencesInfo out = gApi.accounts()
-        .id(admin.getId().toString())
-        .getEditPreferences();
+    EditPreferencesInfo out = gApi.accounts().id(admin.getId().toString()).getEditPreferences();
 
     assertThat(out.lineLength).isEqualTo(100);
     assertThat(out.indentUnit).isEqualTo(2);
@@ -43,6 +40,7 @@
     assertThat(out.hideLineNumbers).isNull();
     assertThat(out.matchBrackets).isTrue();
     assertThat(out.lineWrapping).isNull();
+    assertThat(out.indentWithTabs).isNull();
     assertThat(out.autoCloseBrackets).isNull();
     assertThat(out.showBase).isNull();
     assertThat(out.theme).isEqualTo(Theme.DEFAULT);
@@ -60,14 +58,13 @@
     out.hideLineNumbers = true;
     out.matchBrackets = false;
     out.lineWrapping = true;
+    out.indentWithTabs = true;
     out.autoCloseBrackets = true;
     out.showBase = true;
     out.theme = Theme.TWILIGHT;
     out.keyMapType = KeyMapType.EMACS;
 
-    EditPreferencesInfo info = gApi.accounts()
-        .id(admin.getId().toString())
-        .setEditPreferences(out);
+    EditPreferencesInfo info = gApi.accounts().id(admin.getId().toString()).setEditPreferences(out);
 
     assertEditPreferences(info, out);
 
@@ -75,16 +72,14 @@
     EditPreferencesInfo in = new EditPreferencesInfo();
     in.tabSize = 42;
 
-    info = gApi.accounts()
-        .id(admin.getId().toString())
-        .setEditPreferences(in);
+    info = gApi.accounts().id(admin.getId().toString()).setEditPreferences(in);
 
     out.tabSize = in.tabSize;
     assertEditPreferences(info, out);
   }
 
-  private void assertEditPreferences(EditPreferencesInfo out,
-      EditPreferencesInfo in) throws Exception {
+  private void assertEditPreferences(EditPreferencesInfo out, EditPreferencesInfo in)
+      throws Exception {
     assertThat(out.lineLength).isEqualTo(in.lineLength);
     assertThat(out.indentUnit).isEqualTo(in.indentUnit);
     assertThat(out.tabSize).isEqualTo(in.tabSize);
@@ -96,6 +91,7 @@
     assertThat(out.hideLineNumbers).isEqualTo(in.hideLineNumbers);
     assertThat(out.matchBrackets).isNull();
     assertThat(out.lineWrapping).isEqualTo(in.lineWrapping);
+    assertThat(out.indentWithTabs).isEqualTo(in.indentWithTabs);
     assertThat(out.autoCloseBrackets).isEqualTo(in.autoCloseBrackets);
     assertThat(out.showBase).isEqualTo(in.showBase);
     assertThat(out.theme).isEqualTo(in.theme);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index f45bfbbe..fbeeafd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -19,11 +19,14 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
@@ -31,20 +34,18 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.inject.Inject;
-
+import java.util.ArrayList;
+import java.util.HashMap;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.ArrayList;
-import java.util.HashMap;
-
 @NoHttpd
+@Sandboxed
 public class GeneralPreferencesIT extends AbstractDaemonTest {
-  @Inject
-  private AllUsersName allUsers;
+  @Inject private AllUsersName allUsers;
 
   private TestAccount user42;
 
@@ -56,8 +57,7 @@
 
   @After
   public void cleanUp() throws Exception {
-    gApi.accounts().id(user42.getId().toString())
-        .setPreferences(GeneralPreferencesInfo.defaults());
+    gApi.accounts().id(user42.getId().toString()).setPreferences(GeneralPreferencesInfo.defaults());
 
     try (Repository git = repoManager.openRepository(allUsers)) {
       if (git.exactRef(RefNames.REFS_USERS_DEFAULT) != null) {
@@ -71,11 +71,10 @@
 
   @Test
   public void getAndSetPreferences() throws Exception {
-    GeneralPreferencesInfo o = gApi.accounts()
-        .id(user42.id.toString())
-        .getPreferences();
-    assertPrefs(o, GeneralPreferencesInfo.defaults(), "my");
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id.toString()).getPreferences();
+    assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
     assertThat(o.my).hasSize(7);
+    assertThat(o.changeTable).isEmpty();
 
     GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
 
@@ -87,6 +86,10 @@
     i.dateFormat = DateFormat.US;
     i.timeFormat = TimeFormat.HHMM_24;
     i.emailStrategy = EmailStrategy.DISABLED;
+    i.emailFormat = EmailFormat.PLAINTEXT;
+    i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
+    i.expandInlineDiffs ^= true;
+    i.highlightAssigneeInChangeTable ^= true;
     i.relativeDateInChangeTable ^= true;
     i.sizeBarInChangeTable ^= true;
     i.legacycidInChangeTable ^= true;
@@ -96,14 +99,15 @@
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", "url"));
+    i.changeTable = new ArrayList<>();
+    i.changeTable.add("Status");
     i.urlAliases = new HashMap<>();
     i.urlAliases.put("foo", "bar");
 
-    o = gApi.accounts()
-        .id(user42.getId().toString())
-        .setPreferences(i);
+    o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
     assertPrefs(o, i, "my");
     assertThat(o.my).hasSize(1);
+    assertThat(o.changeTable).hasSize(1);
   }
 
   @Test
@@ -114,14 +118,47 @@
     update.changesPerPage = newChangesPerPage;
     gApi.config().server().setDefaultPreferences(update);
 
-    GeneralPreferencesInfo o = gApi.accounts()
-        .id(user42.getId().toString())
-        .getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).getPreferences();
 
     // assert configured defaults
     assertThat(o.changesPerPage).isEqualTo(newChangesPerPage);
 
     // assert hard-coded defaults
-    assertPrefs(o, d, "my", "changesPerPage");
+    assertPrefs(o, d, "my", "changeTable", "changesPerPage");
+  }
+
+  @Test
+  public void overwriteConfiguredDefaults() throws Exception {
+    GeneralPreferencesInfo d = GeneralPreferencesInfo.defaults();
+    int configuredChangesPerPage = d.changesPerPage * 2;
+    GeneralPreferencesInfo update = new GeneralPreferencesInfo();
+    update.changesPerPage = configuredChangesPerPage;
+    gApi.config().server().setDefaultPreferences(update);
+
+    GeneralPreferencesInfo o = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    assertThat(o.changesPerPage).isEqualTo(configuredChangesPerPage);
+    assertPrefs(o, d, "my", "changeTable", "changesPerPage");
+
+    int newChangesPerPage = configuredChangesPerPage * 2;
+    GeneralPreferencesInfo i = new GeneralPreferencesInfo();
+    i.changesPerPage = newChangesPerPage;
+    GeneralPreferencesInfo a = gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+    assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
+    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
+
+    a = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
+    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
+
+    // overwrite the configured default with original hard-coded default
+    i = new GeneralPreferencesInfo();
+    i.changesPerPage = d.changesPerPage;
+    a = gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+    assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
+    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
+
+    a = gApi.accounts().id(admin.getId().toString()).getPreferences();
+    assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
+    assertPrefs(a, d, "my", "changeTable", "changesPerPage");
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUCK
deleted file mode 100644
index e8963be..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'api_change',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
index 2502cad..3c4e219 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/BUILD
@@ -1,7 +1,10 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'api_change',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
+    srcs = glob(["*IT.java"]),
+    group = "api_change",
+    labels = [
+        "api",
+        "noci",
+    ],
 )
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 15ac366..e2d7715 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
@@ -25,65 +26,109 @@
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.Util.blockLabel;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.GerritConfigs;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
-import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestTimeUtil;
-
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
@@ -96,19 +141,14 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-
 @NoHttpd
 public class ChangeIT extends AbstractDaemonTest {
   private String systemTimeZone;
 
+  @Inject private BatchUpdate.Factory updateFactory;
+
+  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
   @Before
   public void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
@@ -151,14 +191,17 @@
 
     BranchInput b = new BranchInput();
     b.revision = repo().exactRef("HEAD").getObjectId().name();
-    gApi.projects()
-        .name(project.get())
-        .branch("other")
-        .create(b);
+    gApi.projects().name(project.get()).branch("other").create(b);
 
-    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
-        PushOneCommit.FILE_CONTENT, changeId);
+    PushOneCommit push2 =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT,
+            changeId);
     PushOneCommit.Result r2 = push2.to("refs/for/other");
     assertThat(r2.getChangeId()).isEqualTo(changeId);
 
@@ -172,19 +215,60 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes()
-        .id(changeId)
-        .abandon();
+    gApi.changes().id(changeId).abandon();
     ChangeInfo info = get(changeId);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase())
-        .contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
 
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("change is abandoned");
-    gApi.changes()
-        .id(changeId)
-        .abandon();
+    gApi.changes().id(changeId).abandon();
+  }
+
+  @Test
+  public void batchAbandon() throws Exception {
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange();
+    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
+    assertThat(controlA).hasSize(1);
+    PushOneCommit.Result b = createChange();
+    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
+    assertThat(controlB).hasSize(1);
+    List<ChangeControl> list = ImmutableList.of(controlA.get(0), controlB.get(0));
+    changeAbandoner.batchAbandon(controlA.get(0).getProject().getNameKey(), user, list, "deadbeef");
+
+    ChangeInfo info = get(a.getChangeId());
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+
+    info = get(b.getChangeId());
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+  }
+
+  @Test
+  public void batchAbandonChangeProject() throws Exception {
+    String project1Name = name("Project1");
+    String project2Name = name("Project2");
+    gApi.projects().create(project1Name);
+    gApi.projects().create(project2Name);
+    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
+    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
+    assertThat(controlA).hasSize(1);
+    PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
+    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
+    assertThat(controlB).hasSize(1);
+    List<ChangeControl> list = ImmutableList.of(controlA.get(0), controlB.get(0));
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
+    changeAbandoner.batchAbandon(new Project.NameKey(project1Name), user, list);
   }
 
   @Test
@@ -195,9 +279,7 @@
 
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("draft changes cannot be abandoned");
-    gApi.changes()
-        .id(changeId)
-        .abandon();
+    gApi.changes().id(changeId).abandon();
   }
 
   @Test
@@ -205,183 +287,363 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes()
-        .id(changeId)
-        .abandon();
+    gApi.changes().id(changeId).abandon();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
 
-    gApi.changes()
-        .id(changeId)
-        .restore();
+    gApi.changes().id(changeId).restore();
     ChangeInfo info = get(changeId);
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase())
-        .contains("restored");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
 
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("change is new");
-    gApi.changes()
-        .id(changeId)
-        .restore();
+    gApi.changes().id(changeId).restore();
   }
 
   @Test
   public void revert() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .submit();
-    ChangeInfo revertChange =
-        gApi.changes()
-            .id(r.getChangeId())
-            .revert().get();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
 
     // expected messages on source change:
     // 1. Uploaded patch set 1.
     // 2. Patch Set 1: Code-Review+2
     // 3. Change has been successfully merged by Administrator
     // 4. Patch Set 1: Reverted
-    List<ChangeMessageInfo> sourceMessages = new ArrayList<>(
-        gApi.changes().id(r.getChangeId()).get().messages);
+    List<ChangeMessageInfo> sourceMessages =
+        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
     assertThat(sourceMessages).hasSize(4);
-    String expectedMessage = String.format(
-        "Created a revert of this change as %s",
-        revertChange.changeId);
+    String expectedMessage =
+        String.format("Created a revert of this change as %s", revertChange.changeId);
     assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
 
     assertThat(revertChange.messages).hasSize(1);
-    assertThat(revertChange.messages.iterator().next().message)
-        .isEqualTo("Uploaded patch set 1.");
+    assertThat(revertChange.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
   }
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void revertInitialCommit() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .submit();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("Cannot revert initial commit");
-    gApi.changes()
-        .id(r.getChangeId())
-        .revert();
+    gApi.changes().id(r.getChangeId()).revert();
+  }
+
+  @FunctionalInterface
+  private interface Rebase {
+    void call(String id) throws RestApiException;
   }
 
   @Test
-  public void rebase() throws Exception {
+  public void rebaseViaRevisionApi() throws Exception {
+    testRebase(id -> gApi.changes().id(id).current().rebase());
+  }
+
+  @Test
+  public void rebaseViaChangeApi() throws Exception {
+    testRebase(id -> gApi.changes().id(id).rebase());
+  }
+
+  private void testRebase(Rebase rebase) throws Exception {
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
     testRepo.reset("HEAD~1");
     PushOneCommit.Result r2 = createChange();
 
     // Approve and submit the first change
-    RevisionApi revision = gApi.changes()
-        .id(r.getChangeId())
-        .current();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
     revision.review(ReviewInput.approve());
     revision.submit();
 
     String changeId = r2.getChangeId();
     // Rebase the second change
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .rebase();
+    rebase.call(changeId);
 
     // Second change should have 2 patch sets
     ChangeInfo c2 = gApi.changes().id(changeId).get();
     assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
 
-    // ...and the committer should be correct
-    ChangeInfo info = gApi.changes()
-        .id(changeId).get(EnumSet.of(
-            ListChangesOption.CURRENT_REVISION,
-            ListChangesOption.CURRENT_COMMIT));
-    GitPerson committer = info.revisions.get(
-        info.currentRevision).commit.committer;
+    // ...and the committer and description should be correct
+    ChangeInfo info =
+        gApi.changes()
+            .id(changeId)
+            .get(EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT));
+    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
     assertThat(committer.name).isEqualTo(admin.fullName);
     assertThat(committer.email).isEqualTo(admin.email);
+    String description = info.revisions.get(info.currentRevision).description;
+    assertThat(description).isEqualTo("Rebase");
 
     // Rebasing the second change again should fail
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("Change is already up to date");
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .rebase();
+    gApi.changes().id(changeId).current().rebase();
+  }
+
+  @Test
+  public void rebaseNotAllowedWithoutPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("rebase not permitted");
+    gApi.changes().id(changeId).rebase();
+  }
+
+  @Test
+  public void rebaseAllowedWithPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    grant(Permission.REBASE, project, "refs/heads/master", false, REGISTERED_USERS);
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    setApiUser(user);
+    gApi.changes().id(changeId).rebase();
   }
 
   @Test
   public void publish() throws Exception {
     PushOneCommit.Result r = createChange("refs/drafts/master");
     assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.DRAFT);
-    gApi.changes()
-      .id(r.getChangeId())
-      .publish();
+    gApi.changes().id(r.getChangeId()).publish();
     assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.NEW);
   }
 
   @Test
-  public void delete() throws Exception {
+  public void deleteDraftChange() throws Exception {
     PushOneCommit.Result r = createChange("refs/drafts/master");
-    assertThat(query(r.getChangeId())).hasSize(1);
-    assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.DRAFT);
-    gApi.changes()
-      .id(r.getChangeId())
-      .delete();
-    assertThat(query(r.getChangeId())).isEmpty();
+    String changeId = r.getChangeId();
+    assertThat(query(changeId)).hasSize(1);
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.DRAFT);
+    gApi.changes().id(changeId).delete();
+    assertThat(query(changeId)).isEmpty();
+
+    eventRecorder.assertChangeDeletedEvents(changeId, admin.email);
   }
 
   @Test
-  public void voteOnClosedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    merge(r);
+  public void deleteNewChangeAsAdmin() throws Exception {
+    deleteChangeAsUser(admin, admin);
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteNewChangeAsNormalUser() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  public void deleteNewChangeAsUserWithDeleteChangesPermissionForGroup() throws Exception {
+    allow(Permission.DELETE_CHANGES, REGISTERED_USERS, "refs/*");
+    deleteChangeAsUser(admin, user);
+  }
+
+  @Test
+  public void deleteNewChangeAsUserWithDeleteChangesPermissionForProjectOwners() throws Exception {
+    GroupApi groupApi = gApi.groups().create(name("delete-change"));
+    groupApi.addMembers("user");
+
+    ProjectInput in = new ProjectInput();
+    in.name = name("delete-change");
+    in.owners = Lists.newArrayListWithCapacity(1);
+    in.owners.add(groupApi.name());
+    in.createEmptyCommit = true;
+    ProjectApi api = gApi.projects().create(in);
+
+    Project.NameKey nameKey = new Project.NameKey(api.get().name);
+
+    ProjectConfig cfg = projectCache.checkedGet(nameKey).getConfig();
+    Util.allow(cfg, Permission.DELETE_CHANGES, PROJECT_OWNERS, "refs/*");
+    saveProjectConfig(nameKey, cfg);
+
+    deleteChangeAsUser(nameKey, admin, user);
+  }
+
+  @Test
+  public void deleteChangeAsUserWithDeleteOwnChangesPermissionForGroup() throws Exception {
+    allow(Permission.DELETE_OWN_CHANGES, REGISTERED_USERS, "refs/*");
+    deleteChangeAsUser(user, user);
+  }
+
+  @Test
+  public void deleteChangeAsUserWithDeleteOwnChangesPermissionForOwners() throws Exception {
+    allow(Permission.DELETE_OWN_CHANGES, CHANGE_OWNER, "refs/*");
+    deleteChangeAsUser(user, user);
+  }
+
+  private void deleteChangeAsUser(TestAccount owner, TestAccount deleteAs) throws Exception {
+    deleteChangeAsUser(project, owner, deleteAs);
+  }
+
+  private void deleteChangeAsUser(
+      Project.NameKey projectName, TestAccount owner, TestAccount deleteAs) throws Exception {
+    try {
+      setApiUser(owner);
+      ChangeInput in = new ChangeInput();
+      in.project = projectName.get();
+      in.branch = "refs/heads/master";
+      in.subject = "test";
+      String changeId = gApi.changes().create(in).get().changeId;
+
+      assertThat(gApi.changes().id(changeId).info().owner._accountId).isEqualTo(owner.id.get());
+
+      setApiUser(deleteAs);
+      gApi.changes().id(changeId).delete();
+
+      assertThat(query(changeId)).isEmpty();
+
+      eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email);
+    } finally {
+      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
+      removePermission(Permission.DELETE_CHANGES, project, "refs/*");
+    }
+  }
+
+  @Test
+  public void deleteNewChangeOfAnotherUserAsAdmin() throws Exception {
+    deleteChangeAsUser(user, admin);
+  }
+
+  @Test
+  public void deleteNewChangeOfAnotherUserWithDeleteOwnChangesPermission() throws Exception {
+    allow(Permission.DELETE_OWN_CHANGES, REGISTERED_USERS, "refs/*");
+
+    try {
+      PushOneCommit.Result changeResult = createChange();
+      String changeId = changeResult.getChangeId();
+      Change.Id id = changeResult.getChange().getId();
+
+      setApiUser(user);
+      exception.expect(AuthException.class);
+      exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+      gApi.changes().id(changeId).delete();
+    } finally {
+      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
+    }
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void deleteNewChangeForBranchWithoutCommits() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+
+    gApi.changes().id(changeId).delete();
+
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteAbandonedChangeAsNormalUser() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).abandon();
+
+    exception.expect(AuthException.class);
+    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    String changeId = changeResult.getChangeId();
+
+    gApi.changes().id(changeId).abandon();
+
+    gApi.changes().id(changeId).delete();
+
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  public void deleteMergedChange() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    merge(changeResult);
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage(String.format("Deleting merged change %s is not allowed", id));
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
+    allow(Permission.DELETE_OWN_CHANGES, REGISTERED_USERS, "refs/*");
+
+    try {
+      PushOneCommit.Result changeResult =
+          pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+      String changeId = changeResult.getChangeId();
+      Change.Id id = changeResult.getChange().getId();
+
+      merge(changeResult);
+
+      setApiUser(user);
+      exception.expect(MethodNotAllowedException.class);
+      exception.expectMessage(String.format("Deleting merged change %s is not allowed", id));
+      gApi.changes().id(changeId).delete();
+    } finally {
+      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
+    }
+  }
+
+  @Test
+  public void deleteNewChangeWithMergedPatchSet() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    merge(changeResult);
+    setChangeStatus(id, Change.Status.NEW);
+
     exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is closed");
-    revision(r).review(ReviewInput.reject());
-  }
-
-  @Test
-  public void voteOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReviewType = Util.codeReview();
-    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
-    String heads = "refs/heads/*";
-    AccountGroup.UUID owner =
-        SystemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-    Util.allow(cfg, forCodeReviewAs, -1, 1, owner, heads);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes()
-        .id(r.getChangeId())
-        .current();
-
-    ReviewInput in = ReviewInput.recommend();
-    in.onBehalfOf = user.id.toString();
-    revision.review(in);
-
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
-
-    LabelInfo codeReview = c.labels.get("Code-Review");
-    assertThat(codeReview.all).hasSize(1);
-    ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
-    assertThat(approval.value).isEqualTo(1);
+    exception.expectMessage(
+        String.format("Cannot delete change %s: patch set 1 is already merged", id));
+    gApi.changes().id(changeId).delete();
   }
 
   @Test
@@ -389,35 +651,29 @@
     PushOneCommit.Result r = createChange();
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("Change is already up to date");
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .rebase();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
   }
 
   @Test
   public void rebaseConflict() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .submit();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME, "other content",
-        "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "other content",
+            "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
     r = push.to("refs/for/master");
     r.assertOkStatus();
 
     exception.expect(ResourceConflictException.class);
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .rebase();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase();
   }
 
   @Test
@@ -429,37 +685,25 @@
 
     // rebase r3 directly onto master (break dep. towards r2)
     ri.base = "";
-    gApi.changes()
-        .id(r3.getChangeId())
-        .revision(r3.getCommit().name())
-        .rebase(ri);
+    gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
     PatchSet ps3 = r3.getPatchSet();
     assertThat(ps3.getId().get()).isEqualTo(2);
 
     // rebase r2 onto r3 (referenced by ref)
     ri.base = ps3.getId().toRefName();
-    gApi.changes()
-        .id(r2.getChangeId())
-        .revision(r2.getCommit().name())
-        .rebase(ri);
+    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
     PatchSet ps2 = r2.getPatchSet();
     assertThat(ps2.getId().get()).isEqualTo(2);
 
     // rebase r1 onto r2 (referenced by commit)
     ri.base = ps2.getRevision().get();
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .rebase(ri);
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
     PatchSet ps1 = r1.getPatchSet();
     assertThat(ps1.getId().get()).isEqualTo(2);
 
     // rebase r1 onto r3 (referenced by change number)
     ri.base = String.valueOf(r3.getChange().getId().get());
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(ps1.getRevision().get())
-        .rebase(ri);
+    gApi.changes().id(r1.getChangeId()).revision(ps1.getRevision().get()).rebase(ri);
     assertThat(r1.getPatchSetId().get()).isEqualTo(3);
   }
 
@@ -470,14 +714,13 @@
 
     RebaseInput ri = new RebaseInput();
     ri.base = r2.getCommit().name();
-    String expectedMessage = "base change " + r2.getChangeId()
-        + " is a descendant of the current change - recursion not allowed";
+    String expectedMessage =
+        "base change "
+            + r2.getChangeId()
+            + " is a descendant of the current change - recursion not allowed";
     exception.expect(ResourceConflictException.class);
     exception.expectMessage(expectedMessage);
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .rebase(ri);
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
   }
 
   @Test
@@ -485,18 +728,13 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes()
-        .id(changeId)
-        .abandon();
+    gApi.changes().id(changeId).abandon();
     ChangeInfo info = get(changeId);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
 
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("change is abandoned");
-    gApi.changes()
-        .id(changeId)
-        .revision(r.getCommit().name())
-        .rebase();
+    gApi.changes().id(changeId).revision(r.getCommit().name()).rebase();
   }
 
   @Test
@@ -509,9 +747,7 @@
     // Abandon the first change
     String changeId = r.getChangeId();
     assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes()
-        .id(changeId)
-        .abandon();
+    gApi.changes().id(changeId).abandon();
     ChangeInfo info = get(changeId);
     assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
 
@@ -520,10 +756,7 @@
 
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("base change is abandoned: " + changeId);
-    gApi.changes()
-        .id(r2.getChangeId())
-        .revision(r2.getCommit().name())
-        .rebase(ri);
+    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
   }
 
   @Test
@@ -535,10 +768,7 @@
     ri.base = commit;
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("cannot rebase change onto itself");
-    gApi.changes()
-        .id(changeId)
-        .revision(commit)
-        .rebase(ri);
+    gApi.changes().id(changeId).revision(commit).rebase(ri);
   }
 
   @Test
@@ -546,8 +776,7 @@
   public void changeNoParentToOneParent() throws Exception {
     // create initial commit with no parent and push it as change, so that patch
     // set 1 has no parent
-    RevCommit c =
-        testRepo.commit().message("Initial commit").insertChangeId().create();
+    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
     String id = GitUtil.getChangeId(testRepo, c).get();
     testRepo.reset(c);
 
@@ -555,8 +784,7 @@
     assertPushOk(pr, "refs/for/master");
 
     ChangeInfo change = gApi.changes().id(id).get();
-    assertThat(change.revisions.get(change.currentRevision).commit.parents)
-        .isEmpty();
+    assertThat(change.revisions.get(change.currentRevision).commit.parents).isEmpty();
 
     // create another initial commit with no parent and push it directly into
     // the remote repository
@@ -567,8 +795,13 @@
 
     // create a successor commit and push it as second patch set to the change,
     // so that patch set 2 has 1 parent
-    RevCommit c2 = testRepo.commit().message("Initial commit").parent(c)
-        .insertChangeId(id.substring(1)).create();
+    RevCommit c2 =
+        testRepo
+            .commit()
+            .message("Initial commit")
+            .parent(c)
+            .insertChangeId(id.substring(1))
+            .create();
     testRepo.reset(c2);
 
     pr = pushHead(testRepo, "refs/for/master", false);
@@ -584,14 +817,167 @@
   }
 
   @Test
+  public void pushCommitOfOtherUser() throws Exception {
+    // admin pushes commit of user
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    CommitInfo commit = change.revisions.get(change.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(user.email);
+    assertThat(commit.committer.email).isEqualTo(user.email);
+
+    // check that the author/committer was added as reviewer
+    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    assertThat(change.reviewers.get(CC)).isNull();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailReplyTo(m, admin.email);
+  }
+
+  @Test
+  public void pushCommitOfOtherUserThatCannotSeeChange() throws Exception {
+    // create hidden project that is only visible to administrators
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(
+        cfg,
+        Permission.READ,
+        groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID(),
+        "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // admin pushes commit of user
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+    CommitInfo commit = change.revisions.get(change.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(user.email);
+    assertThat(commit.committer.email).isEqualTo(user.email);
+
+    // check the user cannot see the change
+    setApiUser(user);
+    try {
+      gApi.changes().id(result.getChangeId()).get();
+      fail("Expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      // Expected.
+    }
+
+    // check that the author/committer was NOT added as reviewer (he can't see
+    // the change)
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
+    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void pushCommitWithFooterOfOtherUser() throws Exception {
+    // admin pushes commit that references 'user' in a footer
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT
+                + "\n\n"
+                + FooterConstants.REVIEWED_BY.getName()
+                + ": "
+                + user.getIdent().toExternalString(),
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    // check that 'user' was added as reviewer
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
+    assertThat(change.reviewers.get(CC)).isNull();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailReplyTo(m, admin.email);
+  }
+
+  @Test
+  public void pushCommitWithFooterOfOtherUserThatCannotSeeChange() throws Exception {
+    // create hidden project that is only visible to administrators
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(
+        cfg,
+        Permission.READ,
+        groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID(),
+        "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // admin pushes commit that references 'user' in a footer
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            repo,
+            PushOneCommit.SUBJECT
+                + "\n\n"
+                + FooterConstants.REVIEWED_BY.getName()
+                + ": "
+                + user.getIdent().toExternalString(),
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    // check that 'user' cannot see the change
+    setApiUser(user);
+    try {
+      gApi.changes().id(result.getChangeId()).get();
+      fail("Expected ResourceNotFoundException");
+    } catch (ResourceNotFoundException e) {
+      // Expected.
+    }
+
+    // check that 'user' was NOT added as cc ('user' can't see the change)
+    setApiUser(admin);
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
+    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void addReviewerThatCannotSeeChange() throws Exception {
     // create hidden project that is only visible to administrators
     Project.NameKey p = createProject("p");
     ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(cfg,
+    Util.allow(
+        cfg,
         Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators"))
-            .getGroupUUID(),
+        groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID(),
         "refs/*");
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(p, cfg);
@@ -617,9 +1003,21 @@
     in.reviewer = user.email;
     exception.expect(UnprocessableEntityException.class);
     exception.expectMessage("Change not visible to " + user.email);
-    gApi.changes()
-        .id(result.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(result.getChangeId()).addReviewer(in);
+  }
+
+  @Test
+  public void addReviewerThatIsInactive() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String username = name("new-user");
+    gApi.accounts().create(username).setActive(false);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = username;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account of " + username + " is inactive.");
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
   }
 
   @Test
@@ -632,9 +1030,7 @@
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(r.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -643,22 +1039,17 @@
     assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
-    assertMailFrom(m, admin.email);
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
+    assertMailReplyTo(m, admin.email);
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     // When NoteDb is enabled adding a reviewer records that user as reviewer
     // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
     // approval on the change which is treated as CC when the ChangeInfo is
     // created.
-    Collection<AccountInfo> reviewers = NoteDbMode.readWrite()
-        ? c.reviewers.get(REVIEWER)
-        : c.reviewers.get(CC);
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId)
-        .isEqualTo(user.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -667,6 +1058,119 @@
   }
 
   @Test
+  public void addReviewerThatIsNotPerfectMatch() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    PushOneCommit.Result r = createChange();
+    ChangeResource rsrc = parseResource(r);
+    String oldETag = rsrc.getETag();
+    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+
+    // create a group named "ab" with one user: testUser
+    TestAccount testUser = accounts.create("abcd", "abcd@test.com", "abcd");
+    String testGroup = createGroupWithRealName("ab");
+    GroupApi groupApi = gApi.groups().id(testGroup);
+    groupApi.description("test group");
+    groupApi.addMembers(user.fullName);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = "abc";
+    gApi.changes().id(r.getChangeId()).addReviewer(in.reviewer);
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(testUser.emailAddress);
+    assertThat(m.body()).contains("Hello " + testUser.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailReplyTo(m, testUser.email);
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+
+    // When NoteDb is enabled adding a reviewer records that user as reviewer
+    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
+    // approval on the change which is treated as CC when the ChangeInfo is
+    // created.
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(testUser.getId().get());
+
+    // Ensure ETag and lastUpdatedOn are updated.
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
+  }
+
+  @Test
+  public void addGroupAsReviewersWhenANotPerfectMatchedUserExists() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    PushOneCommit.Result r = createChange();
+    ChangeResource rsrc = parseResource(r);
+    String oldETag = rsrc.getETag();
+    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+
+    // create a group named "kobe" with one user: lee
+    TestAccount testUser = accounts.create("kobebryant", "kobebryant@test.com", "kobebryant");
+    TestAccount myGroupUser = accounts.create("lee", "lee@test.com", "lee");
+
+    String testGroup = createGroupWithRealName("kobe");
+    GroupApi groupApi = gApi.groups().id(testGroup);
+    groupApi.description("test group");
+    groupApi.addMembers(myGroupUser.fullName);
+
+    // ensure that user "user" is not in the group
+    groupApi.removeMembers(testUser.fullName);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = testGroup;
+    gApi.changes().id(r.getChangeId()).addReviewer(in.reviewer);
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(myGroupUser.emailAddress);
+    assertThat(m.body()).contains("Hello " + myGroupUser.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertMailReplyTo(m, myGroupUser.email);
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+
+    // When NoteDb is enabled adding a reviewer records that user as reviewer
+    // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
+    // approval on the change which is treated as CC when the ChangeInfo is
+    // created.
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(myGroupUser.getId().get());
+
+    // Ensure ETag and lastUpdatedOn are updated.
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
+  }
+
+  @Test
+  public void addReviewerWithNoteDbWhenDummyApprovalInReviewDbExists() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+
+    PushOneCommit.Result r = createChange();
+
+    // insert dummy approval in ReviewDb
+    PatchSetApproval psa =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(r.getPatchSetId(), user.id, new LabelId("Code-Review")),
+            (short) 0,
+            TimeUtil.nowTs());
+    db.patchSetApprovals().insert(Collections.singleton(psa));
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+  }
+
+  @Test
   public void addSelfAsReviewer() throws Exception {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
     PushOneCommit.Result r = createChange();
@@ -677,9 +1181,7 @@
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
     setApiUser(user);
-    gApi.changes()
-        .id(r.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     // There should be no email notification when adding self
     assertThat(sender.getMessages()).isEmpty();
@@ -688,16 +1190,11 @@
     // in NoteDb. When NoteDb is disabled adding a reviewer results in a dummy 0
     // approval on the change which is treated as CC when the ChangeInfo is
     // created.
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
-    Collection<AccountInfo> reviewers = NoteDbMode.readWrite()
-        ? c.reviewers.get(REVIEWER)
-        : c.reviewers.get(CC);
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId)
-        .isEqualTo(user.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
 
     // Ensure ETag and lastUpdatedOn are updated.
     rsrc = parseResource(r);
@@ -706,188 +1203,257 @@
   }
 
   @Test
-  public void addReviewerToClosedChange() throws Exception {
+  public void implicitlyCcOnNonVotingReviewPgStyle() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .submit();
+    setApiUser(user);
+    assertThat(getReviewerState(r.getChangeId(), user.id)).isEmpty();
 
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
-    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
-    assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId)
-        .isEqualTo(admin.getId().get());
-    assertThat(c.reviewers).doesNotContainKey(CC);
+    // Exact request format made by PG UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
+    ReviewInput in = new ReviewInput();
+    in.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    in.labels = ImmutableMap.of();
+    in.message = "comment";
+    in.reviewers = ImmutableList.of();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
 
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email;
-    gApi.changes()
-        .id(r.getChangeId())
-        .addReviewer(in);
-
-    c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
-    reviewers = c.reviewers.get(REVIEWER);
-    if (NoteDbMode.readWrite()) {
-      // When NoteDb is enabled adding a reviewer records that user as reviewer
-      // in NoteDb.
-      assertThat(reviewers).hasSize(2);
-      Iterator<AccountInfo> reviewerIt = reviewers.iterator();
-      assertThat(reviewerIt.next()._accountId)
-          .isEqualTo(admin.getId().get());
-      assertThat(reviewerIt.next()._accountId)
-          .isEqualTo(user.getId().get());
-      assertThat(c.reviewers).doesNotContainKey(CC);
-    } else {
-      // When NoteDb is disabled adding a reviewer results in a dummy 0 approval
-      // on the change which is treated as CC when the ChangeInfo is created.
-      assertThat(reviewers).hasSize(1);
-      assertThat(reviewers.iterator().next()._accountId)
-          .isEqualTo(admin.getId().get());
-      Collection<AccountInfo> ccs = c.reviewers.get(CC);
-      assertThat(ccs).hasSize(1);
-      assertThat(ccs.iterator().next()._accountId)
-          .isEqualTo(user.getId().get());
-    }
+    // If we're not reading from NoteDb, then the CCed user will be returned in the REVIEWER state.
+    assertThat(getReviewerState(r.getChangeId(), user.id))
+        .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
   }
 
   @Test
-  public void listVotes() throws Exception {
+  public void implicitlyCcOnNonVotingReviewGwtStyle() throws Exception {
     PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    assertThat(getReviewerState(r.getChangeId(), user.id)).isEmpty();
+
+    // Exact request format made by GWT UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
+    ReviewInput in = new ReviewInput();
+    in.labels = ImmutableMap.of("Code-Review", (short) 0);
+    in.strictLabels = true;
+    in.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+
+    // If we're not reading from NoteDb, then the CCed user will be returned in the REVIEWER state.
+    assertThat(getReviewerState(r.getChangeId(), user.id))
+        .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
+  }
+
+  @Test
+  public void implicitlyAddReviewerOnVotingReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
+        .review(ReviewInput.recommend().message("LGTM"));
 
-    Map<String, Short> m = gApi.changes()
-        .id(r.getChangeId())
-        .reviewer(admin.getId().toString())
-        .votes();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(user.id.get());
 
-    assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short)2));
+    // Further test: remove the vote, then comment again. The user should be
+    // implicitly re-added to the ReviewerSet, as a CC if we're using NoteDb.
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).remove();
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertThat(c.reviewers.values()).isEmpty();
 
     setApiUser(user);
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
-        .review(ReviewInput.dislike());
+        .review(new ReviewInput().message("hi"));
+    c = gApi.changes().id(r.getChangeId()).get();
+    ReviewerState state = notesMigration.readChanges() ? CC : REVIEWER;
+    assertThat(c.reviewers.get(state).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(user.id.get());
+  }
 
-    m = gApi.changes()
-        .id(r.getChangeId())
-        .reviewer(user.getId().toString())
-        .votes();
+  @Test
+  public void addReviewerToClosedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(c.reviewers).doesNotContainKey(CC);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    c = gApi.changes().id(r.getChangeId()).get();
+    reviewers = c.reviewers.get(REVIEWER);
+    assertThat(reviewers).hasSize(2);
+    Iterator<AccountInfo> reviewerIt = reviewers.iterator();
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(user.getId().get());
+    assertThat(c.reviewers).doesNotContainKey(CC);
+  }
+
+  @Test
+  public void emailNotificationForFileLevelComment() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(changeId).addReviewer(in);
+    sender.clear();
+
+    ReviewInput review = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.path = PushOneCommit.FILE_NAME;
+    comment.side = Side.REVISION;
+    comment.message = "comment 1";
+    review.comments = new HashMap<>();
+    review.comments.put(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(review);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+  }
+
+  @Test
+  public void invalidRange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    ReviewInput review = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+
+    comment.range = new Range();
+    comment.range.startLine = 1;
+    comment.range.endLine = 1;
+    comment.range.startCharacter = -1;
+    comment.range.endCharacter = 0;
+
+    comment.path = PushOneCommit.FILE_NAME;
+    comment.side = Side.REVISION;
+    comment.message = "comment 1";
+    review.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
+
+    exception.expect(BadRequestException.class);
+    gApi.changes().id(changeId).current().review(review);
+  }
+
+  @Test
+  public void listVotes() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    Map<String, Short> m =
+        gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short)-1));
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.dislike());
+
+    m = gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).votes();
+
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) -1));
   }
 
   @Test
   public void removeReviewerNoVotes() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
 
-    LabelType verified = category("Verified", value(1, "Passes"),
-        value(0, "No score"), value(-1, "Failed"));
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     cfg.getLabelSections().put(verified.getName(), verified);
 
-    AccountGroup.UUID registeredUsers =
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1,
-        registeredUsers, heads);
+    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, registeredUsers, heads);
     saveProjectConfig(project, cfg);
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    gApi.changes()
-        .id(changeId)
-        .addReviewer(user.getId().toString());
+    gApi.changes().id(changeId).addReviewer(user.getId().toString());
 
     // ReviewerState will vary between ReviewDb and NoteDb; we just care that it
     // shows up somewhere.
-    Iterable<AccountInfo> reviewers = Iterables.concat(
-        gApi.changes().id(changeId).get().reviewers.values());
+    Iterable<AccountInfo> reviewers =
+        Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId)
-        .isEqualTo(user.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
 
-    gApi.changes()
-        .id(changeId)
-        .reviewer(user.getId().toString())
-        .remove();
-    assertThat(gApi.changes().id(changeId).get().reviewers.isEmpty());
+    sender.clear();
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body()).contains("Removed reviewer " + user.fullName + ".");
+    assertThat(message.body()).doesNotContain("with the following votes");
 
     // Make sure the reviewer can still be added again.
-    gApi.changes()
-        .id(changeId)
-        .addReviewer(user.getId().toString());
+    gApi.changes().id(changeId).addReviewer(user.getId().toString());
     reviewers = Iterables.concat(gApi.changes().id(changeId).get().reviewers.values());
     assertThat(reviewers).hasSize(1);
-    assertThat(reviewers.iterator().next()._accountId)
-        .isEqualTo(user.getId().get());
+    assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.getId().get());
 
     // Remove again, and then try to remove once more to verify 404 is
     // returned.
-    gApi.changes()
-        .id(changeId)
-        .reviewer(user.getId().toString())
-        .remove();
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
     exception.expect(ResourceNotFoundException.class);
-    gApi.changes()
-        .id(changeId)
-        .reviewer(user.getId().toString())
-        .remove();
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove();
   }
 
   @Test
   public void removeReviewer() throws Exception {
+    testRemoveReviewer(true);
+  }
+
+  @Test
+  public void removeNoNotify() throws Exception {
+    testRemoveReviewer(false);
+  }
+
+  private void testRemoveReviewer(boolean notify) throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    gApi.changes()
-        .id(changeId)
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     setApiUser(user);
-    gApi.changes()
-        .id(changeId)
-        .revision(r.getCommit().name())
-        .review(ReviewInput.recommend());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.recommend());
 
-    Collection<AccountInfo> reviewers = gApi.changes()
-        .id(changeId)
-        .get()
-        .reviewers.get(REVIEWER);
+    Collection<AccountInfo> reviewers = gApi.changes().id(changeId).get().reviewers.get(REVIEWER);
 
     assertThat(reviewers).hasSize(2);
     Iterator<AccountInfo> reviewerIt = reviewers.iterator();
-    assertThat(reviewerIt.next()._accountId)
-        .isEqualTo(admin.getId().get());
-    assertThat(reviewerIt.next()._accountId)
-        .isEqualTo(user.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(user.getId().get());
 
+    sender.clear();
     setApiUser(admin);
-    gApi.changes()
-        .id(changeId)
-        .reviewer(user.getId().toString())
-        .remove();
+    DeleteReviewerInput input = new DeleteReviewerInput();
+    if (!notify) {
+      input.notify = NotifyHandling.NONE;
+    }
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).remove(input);
 
-    reviewers = gApi.changes()
-        .id(changeId)
-        .get()
-        .reviewers.get(REVIEWER);
+    if (notify) {
+      assertThat(sender.getMessages()).hasSize(1);
+      Message message = sender.getMessages().get(0);
+      assertThat(message.body())
+          .contains("Removed reviewer " + user.fullName + " with the following votes");
+      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName);
+    } else {
+      assertThat(sender.getMessages()).isEmpty();
+    }
+
+    reviewers = gApi.changes().id(changeId).get().reviewers.get(REVIEWER);
     assertThat(reviewers).hasSize(1);
     reviewerIt = reviewers.iterator();
-    assertThat(reviewerIt.next()._accountId)
-      .isEqualTo(admin.getId().get());
+    assertThat(reviewerIt.next()._accountId).isEqualTo(admin.getId().get());
 
     eventRecorder.assertReviewerDeletedEvents(changeId, user.email);
   }
@@ -896,152 +1462,129 @@
   public void removeReviewerNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    gApi.changes()
-        .id(changeId)
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     setApiUser(user);
     exception.expect(AuthException.class);
     exception.expectMessage("delete reviewer not permitted");
-    gApi.changes()
-        .id(r.getChangeId())
-        .reviewer(admin.getId().toString())
-        .remove();
+    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).remove();
   }
 
   @Test
   public void deleteVote() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     setApiUser(user);
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.recommend());
+    recommend(r.getChangeId());
 
     setApiUser(admin);
     sender.clear();
-    gApi.changes()
-        .id(r.getChangeId())
-        .reviewer(user.getId().toString())
-        .deleteVote("Code-Review");
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote("Code-Review");
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message msg = messages.get(0);
     assertThat(msg.rcpt()).containsExactly(user.emailAddress);
-    assertThat(msg.body()).contains(
-        admin.fullName + " has removed a vote on this change.\n");
-    assertThat(msg.body()).contains(
-        "Removed Code-Review+1 by "
-            + user.fullName + " <" + user.email + ">" + "\n");
+    assertThat(msg.body()).contains(admin.fullName + " has removed a vote on this change.\n");
+    assertThat(msg.body())
+        .contains("Removed Code-Review+1 by " + user.fullName + " <" + user.email + ">\n");
 
-    Map<String, Short> m = gApi.changes()
-        .id(r.getChangeId())
-        .reviewer(user.getId().toString())
-        .votes();
+    Map<String, Short> m =
+        gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).votes();
 
-    if (NoteDbMode.readWrite()) {
-      // When NoteDb is enabled each reviewer is explicitly recorded in the
-      // NoteDb and this record stays even when all votes of that user have been
-      // deleted, hence there is no dummy 0 approval left when a vote is
-      // deleted.
-      assertThat(m).isEmpty();
-    } else {
-      // 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", Short.valueOf((short)0));
-    }
+    // Dummy 0 approval on the change to block vote copying to this patch set.
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
 
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.getId().get());
-    assertThat(message.message).isEqualTo(
-        "Removed Code-Review+1 by User <user@example.com>\n");
-    if (NoteDbMode.readWrite()) {
-      // When NoteDb is enabled each reviewer is explicitly recorded in the
-      // NoteDb and this record stays even when all votes of that user have been
-      // deleted.
-      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-          .containsExactlyElementsIn(
-              ImmutableSet.of(admin.getId(), user.getId()));
-    } else {
-      // When NoteDb is disabled users that have only dummy 0 approvals on the
-      // change are returned as CC and not as REVIEWER.
-      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-          .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
-      assertThat(getReviewers(c.reviewers.get(CC)))
-          .containsExactlyElementsIn(ImmutableSet.of(user.getId()));
-    }
+    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
   }
 
   @Test
   public void deleteVoteNotifyNone() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     setApiUser(user);
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.recommend());
+    recommend(r.getChangeId());
 
     setApiUser(admin);
     sender.clear();
     DeleteVoteInput in = new DeleteVoteInput();
     in.label = "Code-Review";
     in.notify = NotifyHandling.NONE;
-    gApi.changes()
-        .id(r.getChangeId())
-        .reviewer(user.getId().toString())
-        .deleteVote(in);
-    assertThat(sender.getMessages()).hasSize(0);
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void deleteVoteNotifyAccount() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = "Code-Review";
+    in.notify = NotifyHandling.NONE;
+
+    // notify unrelated account as TO
+    TestAccount user2 = accounts.user2();
+    setApiUser(user);
+    recommend(r.getChangeId());
+    setApiUser(admin);
+    sender.clear();
+    in.notifyDetails = new HashMap<>();
+    in.notifyDetails.put(RecipientType.TO, new NotifyInfo(ImmutableList.of(user2.email)));
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    assertNotifyTo(user2);
+
+    // notify unrelated account as CC
+    setApiUser(user);
+    recommend(r.getChangeId());
+    setApiUser(admin);
+    sender.clear();
+    in.notifyDetails = new HashMap<>();
+    in.notifyDetails.put(RecipientType.CC, new NotifyInfo(ImmutableList.of(user2.email)));
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    assertNotifyCc(user2);
+
+    // notify unrelated account as BCC
+    setApiUser(user);
+    recommend(r.getChangeId());
+    setApiUser(admin);
+    sender.clear();
+    in.notifyDetails = new HashMap<>();
+    in.notifyDetails.put(RecipientType.BCC, new NotifyInfo(ImmutableList.of(user2.email)));
+    gApi.changes().id(r.getChangeId()).reviewer(user.getId().toString()).deleteVote(in);
+    assertNotifyBcc(user2);
   }
 
   @Test
   public void deleteVoteNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     setApiUser(user);
     exception.expect(AuthException.class);
     exception.expectMessage("delete vote not permitted");
-    gApi.changes()
-        .id(r.getChangeId())
-        .reviewer(admin.getId().toString())
-        .deleteVote("Code-Review");
+    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).deleteVote("Code-Review");
   }
 
   @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
-    LabelType verified = category("Verified",
-        value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     cfg.getLabelSections().put(verified.getName(), verified);
     String heads = "refs/heads/*";
-    AccountGroup.UUID owners =
-        SystemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-    AccountGroup.UUID registered =
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(cfg,
-        Permission.forLabel(verified.getName()), -1, 1, owners, heads);
-    Util.allow(cfg,
-        Permission.forLabel("Code-Review"), -2, +2, registered, heads);
+    AccountGroup.UUID owners = systemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
+    AccountGroup.UUID registered = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, owners, heads);
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, registered, heads);
     saveProjectConfig(project, cfg);
 
     // Set Code-Review+2 and Verified+1 as admin (change owner)
@@ -1050,10 +1593,7 @@
     String commit = r.getCommit().name();
     ReviewInput input = ReviewInput.approve();
     input.label(verified.getName(), 1);
-    gApi.changes()
-        .id(changeId)
-        .revision(commit)
-        .review(input);
+    gApi.changes().id(changeId).revision(commit).review(input);
 
     // Reviewers should only be "admin"
     ChangeInfo c = gApi.changes().id(changeId).get();
@@ -1064,52 +1604,25 @@
     // Add the user as reviewer
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(changeId)
-        .addReviewer(in);
+    gApi.changes().id(changeId).addReviewer(in);
     c = gApi.changes().id(changeId).get();
-    if (NoteDbMode.readWrite()) {
-      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-          .containsExactlyElementsIn(ImmutableSet.of(
-              admin.getId(), user.getId()));
-    } else {
-      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-          .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
-      assertThat(getReviewers(c.reviewers.get(CC)))
-          .containsExactlyElementsIn(ImmutableSet.of(user.getId()));
-    }
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
 
     // Approve the change as user, then remove the approval
     // (only to confirm that the user does have Code-Review+2 permission)
     setApiUser(user);
-    gApi.changes()
-        .id(changeId)
-        .revision(commit)
-        .review(ReviewInput.approve());
-    gApi.changes()
-        .id(changeId)
-        .revision(commit)
-        .review(ReviewInput.noScore());
+    gApi.changes().id(changeId).revision(commit).review(ReviewInput.approve());
+    gApi.changes().id(changeId).revision(commit).review(ReviewInput.noScore());
 
     // Submit the change
     setApiUser(admin);
-    gApi.changes()
-        .id(changeId)
-        .revision(commit)
-        .submit();
+    gApi.changes().id(changeId).revision(commit).submit();
 
     // User should still be on the change
     c = gApi.changes().id(changeId).get();
-    if (NoteDbMode.readWrite()) {
-      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-          .containsExactlyElementsIn(ImmutableSet.of(
-              admin.getId(), user.getId()));
-    } else {
-      assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-          .containsExactlyElementsIn(ImmutableSet.of(admin.getId()));
-      assertThat(getReviewers(c.reviewers.get(CC)))
-          .containsExactlyElementsIn(ImmutableSet.of(user.getId()));
-    }
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
   }
 
   @Test
@@ -1118,15 +1631,11 @@
     in.branch = Constants.MASTER;
     in.subject = "Create a change from the API";
     in.project = project.get();
-    ChangeInfo info = gApi
-        .changes()
-        .create(in)
-        .get();
+    ChangeInfo info = gApi.changes().create(in).get();
     assertThat(info.project).isEqualTo(in.project);
     assertThat(info.branch).isEqualTo(in.branch);
     assertThat(info.subject).isEqualTo(in.subject);
-    assertThat(Iterables.getOnlyElement(info.messages).message)
-        .isEqualTo("Uploaded patch set 1.");
+    assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1.");
   }
 
   @Test
@@ -1150,18 +1659,15 @@
   public void queryChangesNoResults() throws Exception {
     createChange();
     assertThat(query("message:test")).isNotEmpty();
-    assertThat(query("message:{" + getClass().getName() + "fhqwhgads}"))
-        .isEmpty();
+    assertThat(query("message:{" + getClass().getName() + "fhqwhgads}")).isEmpty();
   }
 
   @Test
   public void queryChanges() throws Exception {
     PushOneCommit.Result r1 = createChange();
     createChange();
-    List<ChangeInfo> results =
-        query("project:{" + project.get() + "} " + r1.getChangeId());
-    assertThat(Iterables.getOnlyElement(results).changeId)
-        .isEqualTo(r1.getChangeId());
+    List<ChangeInfo> results = query("project:{" + project.get() + "} " + r1.getChangeId());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r1.getChangeId());
   }
 
   @Test
@@ -1170,18 +1676,16 @@
     PushOneCommit.Result r2 = createChange();
     List<ChangeInfo> results = gApi.changes().query().withLimit(1).get();
     assertThat(results).hasSize(1);
-    assertThat(Iterables.getOnlyElement(results).changeId)
-        .isEqualTo(r2.getChangeId());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r2.getChangeId());
   }
 
   @Test
   public void queryChangesStart() throws Exception {
     PushOneCommit.Result r1 = createChange();
     createChange();
-    List<ChangeInfo> results = gApi.changes()
-        .query("project:{" + project.get() + "}").withStart(1).get();
-    assertThat(Iterables.getOnlyElement(results).changeId)
-        .isEqualTo(r1.getChangeId());
+    List<ChangeInfo> results =
+        gApi.changes().query("project:{" + project.get() + "}").withStart(1).get();
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r1.getChangeId());
   }
 
   @Test
@@ -1198,26 +1702,22 @@
   public void queryChangesOptions() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    ChangeInfo result = Iterables.getOnlyElement(gApi.changes()
-        .query(r.getChangeId())
-        .get());
+    ChangeInfo result = Iterables.getOnlyElement(gApi.changes().query(r.getChangeId()).get());
     assertThat(result.labels).isNull();
     assertThat(result.messages).isNull();
     assertThat(result.actions).isNull();
     assertThat(result.revisions).isNull();
 
-    EnumSet<ListChangesOption> options = EnumSet.of(
-        ListChangesOption.ALL_REVISIONS,
-        ListChangesOption.CHANGE_ACTIONS,
-        ListChangesOption.CURRENT_ACTIONS,
-        ListChangesOption.DETAILED_LABELS,
-        ListChangesOption.MESSAGES);
-    result = Iterables.getOnlyElement(gApi.changes()
-        .query(r.getChangeId())
-        .withOptions(options)
-        .get());
-    assertThat(Iterables.getOnlyElement(result.labels.keySet()))
-        .isEqualTo("Code-Review");
+    EnumSet<ListChangesOption> options =
+        EnumSet.of(
+            ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.CHANGE_ACTIONS,
+            ListChangesOption.CURRENT_ACTIONS,
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.MESSAGES);
+    result =
+        Iterables.getOnlyElement(gApi.changes().query(r.getChangeId()).withOptions(options).get());
+    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo("Code-Review");
     assertThat(result.messages).hasSize(1);
     assertThat(result.actions).isNotEmpty();
 
@@ -1232,8 +1732,8 @@
   @Test
   public void queryChangesOwnerWithDifferentUsers() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(Iterables.getOnlyElement(
-            query("project:{" + project.get() + "} owner:self")).changeId)
+    assertThat(
+            Iterables.getOnlyElement(query("project:{" + project.get() + "} owner:self")).changeId)
         .isEqualTo(r.getChangeId());
     setApiUser(user);
     assertThat(query("owner:self")).isEmpty();
@@ -1244,9 +1744,7 @@
     PushOneCommit.Result r = createChange();
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(r.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     setApiUser(user);
     assertThat(get(r.getChangeId()).reviewed).isNull();
@@ -1258,40 +1756,37 @@
   @Test
   public void topic() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .topic()).isEqualTo("");
-    gApi.changes()
-        .id(r.getChangeId())
-        .topic("mytopic");
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .topic()).isEqualTo("mytopic");
-    gApi.changes()
-        .id(r.getChangeId())
-        .topic("");
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .topic()).isEqualTo("");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
+    gApi.changes().id(r.getChangeId()).topic("");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
   }
 
   @Test
   public void submitted() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(ReviewInput.approve());
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .info().submitted).isNull();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .submit();
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .info().submitted).isNotNull();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    assertThat(gApi.changes().id(r.getChangeId()).info().submitted).isNull();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    assertThat(gApi.changes().id(r.getChangeId()).info().submitted).isNotNull();
+  }
+
+  @Test
+  public void submitStaleChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    disableChangeIndexWrites();
+    try {
+      r = amendChange(r.getChangeId());
+    } finally {
+      enableChangeIndexWrites();
+    }
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
   }
 
   @Test
@@ -1299,31 +1794,25 @@
     // TODO(dborowitz): Re-enable when ConsistencyChecker supports NoteDb.
     assume().that(notesMigration.enabled()).isFalse();
     PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .get()
-        .problems).isNull();
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .get(EnumSet.of(ListChangesOption.CHECK))
-        .problems).isEmpty();
+    assertThat(gApi.changes().id(r.getChangeId()).get().problems).isNull();
+    assertThat(gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.CHECK)).problems)
+        .isEmpty();
   }
 
   @Test
   public void commitFooters() throws Exception {
-    LabelType verified = category("Verified",
-        value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-    LabelType custom1 = category("Custom1",
-        value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-    LabelType custom2 = category("Custom2",
-        value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    LabelType custom1 =
+        category("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+    LabelType custom2 =
+        category("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     cfg.getLabelSections().put(verified.getName(), verified);
     cfg.getLabelSections().put(custom1.getName(), custom1);
     cfg.getLabelSections().put(custom2.getName(), custom2);
     String heads = "refs/heads/*";
-    AccountGroup.UUID anon =
-        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
     Util.allow(cfg, Permission.forLabel("Verified"), -1, 1, anon, heads);
     Util.allow(cfg, Permission.forLabel("Custom1"), -1, 1, anon, heads);
     Util.allow(cfg, Permission.forLabel("Custom2"), -1, 1, anon, heads);
@@ -1331,10 +1820,11 @@
 
     PushOneCommit.Result r1 = createChange();
     r1.assertOkStatus();
-    PushOneCommit.Result r2 = pushFactory.create(
-          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content",
-          r1.getChangeId())
-        .to("refs/for/master");
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .to("refs/for/master");
     r2.assertOkStatus();
 
     ReviewInput in = new ReviewInput();
@@ -1344,35 +1834,74 @@
     in.label("Custom2", 1);
     gApi.changes().id(r2.getChangeId()).current().review(in);
 
-    EnumSet<ListChangesOption> options = EnumSet.of(
-        ListChangesOption.ALL_REVISIONS, ListChangesOption.COMMIT_FOOTERS);
+    EnumSet<ListChangesOption> options =
+        EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.COMMIT_FOOTERS);
     ChangeInfo actual = gApi.changes().id(r2.getChangeId()).get(options);
     assertThat(actual.revisions).hasSize(2);
 
     // No footers except on latest patch set.
-    assertThat(actual.revisions.get(r1.getCommit().getName()).commitWithFooters)
-        .isNull();
+    assertThat(actual.revisions.get(r1.getCommit().getName()).commitWithFooters).isNull();
 
     List<String> footers =
-        new ArrayList<>(Arrays.asList(
-            actual.revisions.get(r2.getCommit().getName())
-            .commitWithFooters.split("\\n")));
+        new ArrayList<>(
+            Arrays.asList(
+                actual.revisions.get(r2.getCommit().getName()).commitWithFooters.split("\\n")));
     // remove subject + blank line
     footers.remove(0);
     footers.remove(0);
 
-    List<String> expectedFooters = Arrays.asList(
-        "Change-Id: " + r2.getChangeId(),
-        "Reviewed-on: "
-            + canonicalWebUrl.get() + r2.getChange().getId(),
-        "Reviewed-by: Administrator <admin@example.com>",
-        "Custom2: Administrator <admin@example.com>",
-        "Tested-by: Administrator <admin@example.com>");
+    List<String> expectedFooters =
+        Arrays.asList(
+            "Change-Id: " + r2.getChangeId(),
+            "Reviewed-on: " + canonicalWebUrl.get() + r2.getChange().getId(),
+            "Reviewed-by: Administrator <admin@example.com>",
+            "Custom2: Administrator <admin@example.com>",
+            "Tested-by: Administrator <admin@example.com>");
 
     assertThat(footers).containsExactlyElementsIn(expectedFooters);
   }
 
   @Test
+  public void customCommitFooters() throws Exception {
+    PushOneCommit.Result change = createChange();
+    RegistrationHandle handle =
+        changeMessageModifiers.add(
+            new ChangeMessageModifier() {
+              @Override
+              public String onSubmit(
+                  String newCommitMessage,
+                  RevCommit original,
+                  RevCommit mergeTip,
+                  Branch.NameKey destination) {
+                assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+                return newCommitMessage + "Custom: " + destination.get();
+              }
+            });
+    ChangeInfo actual;
+    try {
+      EnumSet<ListChangesOption> options =
+          EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.COMMIT_FOOTERS);
+      actual = gApi.changes().id(change.getChangeId()).get(options);
+    } finally {
+      handle.remove();
+    }
+    List<String> footers =
+        new ArrayList<>(
+            Arrays.asList(
+                actual.revisions.get(change.getCommit().getName()).commitWithFooters.split("\\n")));
+    // remove subject + blank line
+    footers.remove(0);
+    footers.remove(0);
+
+    List<String> expectedFooters =
+        Arrays.asList(
+            "Change-Id: " + change.getChangeId(),
+            "Reviewed-on: " + canonicalWebUrl.get() + change.getChange().getId(),
+            "Custom: refs/heads/master");
+    assertThat(footers).containsExactlyElementsIn(expectedFooters);
+  }
+
+  @Test
   public void defaultSearchDoesNotTouchDatabase() throws Exception {
     setApiUser(admin);
     PushOneCommit.Result r1 = createChange();
@@ -1380,10 +1909,7 @@
         .id(r1.getChangeId())
         .revision(r1.getCommit().name())
         .review(ReviewInput.approve());
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .submit();
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
     createChange();
     createDraftChange();
@@ -1391,14 +1917,15 @@
     setApiUser(user);
     AcceptanceTestRequestScope.Context ctx = disableDb();
     try {
-      assertThat(gApi.changes().query()
-            .withQuery(
-              "project:{" + project.get() + "} (status:open OR status:closed)")
-            // Options should match defaults in AccountDashboardScreen.
-            .withOption(ListChangesOption.LABELS)
-            .withOption(ListChangesOption.DETAILED_ACCOUNTS)
-            .withOption(ListChangesOption.REVIEWED)
-            .get())
+      assertThat(
+              gApi.changes()
+                  .query()
+                  .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+                  // Options should match defaults in AccountDashboardScreen.
+                  .withOption(ListChangesOption.LABELS)
+                  .withOption(ListChangesOption.DETAILED_ACCOUNTS)
+                  .withOption(ListChangesOption.REVIEWED)
+                  .get())
           .hasSize(2);
     } finally {
       enableDb(ctx);
@@ -1410,8 +1937,7 @@
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(triplet).addReviewer(user.username);
-    ChangeInfo c = gApi.changes().id(triplet).get(EnumSet.of(
-        ListChangesOption.DETAILED_LABELS));
+    ChangeInfo c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
     LabelInfo codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
@@ -1419,10 +1945,9 @@
     assertThat(approval.value).isEqualTo(0);
 
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
+    Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
     saveProjectConfig(project, cfg);
-    c = gApi.changes().id(triplet).get(EnumSet.of(
-        ListChangesOption.DETAILED_LABELS));
+    c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
     codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
@@ -1431,19 +1956,16 @@
   }
 
   @Test
-  @GerritConfigs({
-    @GerritConfig(name = "gerrit.editGpgKeys", value = "true"),
-    @GerritConfig(name = "receive.enableSignedPush", value = "true"),
-  })
+  @GerritConfig(name = "gerrit.editGpgKeys", value = "true")
+  @GerritConfig(name = "receive.enableSignedPush", value = "true")
   public void pushCertificates() throws Exception {
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 = amendChange(r1.getChangeId());
 
-    ChangeInfo info = gApi.changes()
-        .id(r1.getChangeId())
-        .get(EnumSet.of(
-            ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.PUSH_CERTIFICATES));
+    ChangeInfo info =
+        gApi.changes()
+            .id(r1.getChangeId())
+            .get(EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.PUSH_CERTIFICATES));
 
     RevisionInfo rev1 = info.revisions.get(r1.getCommit().name());
     assertThat(rev1).isNotNull();
@@ -1474,10 +1996,7 @@
     assertThat(info.changeId).isEqualTo(r.getChangeId());
 
     exception.expect(AuthException.class);
-    gApi.changes()
-        .id(triplet)
-        .current()
-        .review(ReviewInput.approve());
+    gApi.changes().id(triplet).current().review(ReviewInput.approve());
   }
 
   @Test
@@ -1485,34 +2004,36 @@
     assume().that(notesMigration.readChanges()).isTrue();
 
     PushOneCommit.Result r = createChange();
-    pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-        "b.txt", "4711", r.getChangeId()).to("refs/for/master").assertOkStatus();
+    pushFactory
+        .create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId())
+        .to("refs/for/master")
+        .assertOkStatus();
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit commitPatchSetCreation = rw.parseCommit(
-          repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+      RevCommit commitPatchSetCreation =
+          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
 
-      assertThat(commitPatchSetCreation.getShortMessage())
-          .isEqualTo("Create patch set 2");
-      PersonIdent expectedAuthor = changeNoteUtil.newIdent(
-          accountCache.get(admin.id).getAccount(), c.updated,
-          serverIdent.get(), AnonymousCowardNameProvider.DEFAULT);
-      assertThat(commitPatchSetCreation.getAuthorIdent())
-          .isEqualTo(expectedAuthor);
+      assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
+      PersonIdent expectedAuthor =
+          changeNoteUtil.newIdent(
+              accountCache.get(admin.id).getAccount(), c.updated,
+              serverIdent.get(), AnonymousCowardNameProvider.DEFAULT);
+      assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
       assertThat(commitPatchSetCreation.getParentCount()).isEqualTo(1);
 
-      RevCommit commitChangeCreation =
-          rw.parseCommit(commitPatchSetCreation.getParent(0));
-      assertThat(commitChangeCreation.getShortMessage())
-          .isEqualTo("Create change");
-      expectedAuthor = changeNoteUtil.newIdent(
-          accountCache.get(admin.id).getAccount(), c.created, serverIdent.get(),
-          AnonymousCowardNameProvider.DEFAULT);
-      assertThat(commitChangeCreation.getAuthorIdent())
-          .isEqualTo(expectedAuthor);
+      RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
+      assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
+      expectedAuthor =
+          changeNoteUtil.newIdent(
+              accountCache.get(admin.id).getAccount(),
+              c.created,
+              serverIdent.get(),
+              AnonymousCowardNameProvider.DEFAULT);
+      assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
       assertThat(commitChangeCreation.getParentCount()).isEqualTo(0);
@@ -1526,15 +2047,11 @@
     in.subject = "Create a change on new branch from the API";
     in.project = project.get();
     in.newBranch = true;
-    ChangeInfo info = gApi
-        .changes()
-        .create(in)
-        .get();
+    ChangeInfo info = gApi.changes().create(in).get();
     assertThat(info.project).isEqualTo(in.project);
     assertThat(info.branch).isEqualTo(in.branch);
     assertThat(info.subject).isEqualTo(in.subject);
-    assertThat(Iterables.getOnlyElement(info.messages).message)
-        .isEqualTo("Uploaded patch set 1.");
+    assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1.");
   }
 
   @Test
@@ -1546,64 +2063,54 @@
     in.newBranch = true;
 
     exception.expect(ResourceConflictException.class);
-    gApi.changes()
-        .create(in)
-        .get();
+    gApi.changes().create(in).get();
   }
 
   @Test
   public void createNewPatchSetOnVisibleDraftPatchSet() throws Exception {
     // Clone separate repositories of the same project as admin and as user
-    TestRepository<InMemoryRepository> adminTestRepo =
-        cloneProject(project, admin);
-    TestRepository<InMemoryRepository> userTestRepo =
-        cloneProject(project, user);
+    TestRepository<InMemoryRepository> adminTestRepo = cloneProject(project, admin);
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
     // Amend draft as admin
-    PushOneCommit.Result r2 = amendChange(
-        r1.getChangeId(), "refs/drafts/master", admin, adminTestRepo);
+    PushOneCommit.Result r2 =
+        amendChange(r1.getChangeId(), "refs/drafts/master", admin, adminTestRepo);
     r2.assertOkStatus();
 
     // Add user as reviewer to make this patch set visible
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(r1.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r1.getChangeId()).addReviewer(in);
 
     // Fetch change
     GitUtil.fetch(userTestRepo, r2.getPatchSet().getRefName() + ":ps");
     userTestRepo.reset("ps");
 
     // Amend change as user
-    PushOneCommit.Result r3 = amendChange(
-        r2.getChangeId(), "refs/drafts/master", user, userTestRepo);
+    PushOneCommit.Result r3 =
+        amendChange(r2.getChangeId(), "refs/drafts/master", user, userTestRepo);
     r3.assertOkStatus();
   }
 
   @Test
   public void createNewPatchSetOnInvisibleDraftPatchSet() throws Exception {
     // Clone separate repositories of the same project as admin and as user
-    TestRepository<InMemoryRepository> adminTestRepo =
-        cloneProject(project, admin);
-    TestRepository<InMemoryRepository> userTestRepo =
-        cloneProject(project, user);
+    TestRepository<InMemoryRepository> adminTestRepo = cloneProject(project, admin);
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
     // Amend draft as admin
-    PushOneCommit.Result r2 = amendChange(
-        r1.getChangeId(), "refs/drafts/master", admin, adminTestRepo);
+    PushOneCommit.Result r2 =
+        amendChange(r1.getChangeId(), "refs/drafts/master", admin, adminTestRepo);
     r2.assertOkStatus();
 
     // Fetch change
@@ -1611,10 +2118,8 @@
     userTestRepo.reset("ps");
 
     // Amend change as user
-    PushOneCommit.Result r3 = amendChange(
-        r1.getChangeId(), "refs/for/master", user, userTestRepo);
-    r3.assertErrorStatus("cannot add patch set to "
-        + r3.getChange().change().getChangeId() + ".");
+    PushOneCommit.Result r3 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r3.assertErrorStatus("cannot add patch set to " + r3.getChange().change().getChangeId() + ".");
   }
 
   @Test
@@ -1623,18 +2128,14 @@
     Project.NameKey p = createProject("addPatchSet1");
 
     // Clone separate repositories of the same project as admin and as user
-    TestRepository<InMemoryRepository> adminTestRepo =
-        cloneProject(p, admin);
-    TestRepository<InMemoryRepository> userTestRepo =
-        cloneProject(p, user);
+    TestRepository<InMemoryRepository> adminTestRepo = cloneProject(p, admin);
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(Permission.ADD_PATCH_SET,
-        REGISTERED_USERS, "refs/for/*", p);
+    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
@@ -1643,10 +2144,8 @@
     userTestRepo.reset("ps");
 
     // Amend change as user
-    PushOneCommit.Result r2 =
-        amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
-    r2.assertErrorStatus("cannot add patch set to "
-        + r1.getChange().getId().id + ".");
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().id + ".");
   }
 
   @Test
@@ -1656,8 +2155,7 @@
     TestRepository<?> userTestRepo = cloneProject(project, user);
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
@@ -1666,8 +2164,7 @@
     userTestRepo.reset("ps");
 
     // Amend change as user
-    PushOneCommit.Result r2 = amendChange(
-        r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
     r2.assertOkStatus();
   }
 
@@ -1682,8 +2179,7 @@
     block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
 
     // Create change as admin
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
@@ -1692,8 +2188,8 @@
     adminTestRepo.reset("ps");
 
     // Amend change as admin
-    PushOneCommit.Result r2 = amendChange(
-        r1.getChangeId(), "refs/for/master", admin, adminTestRepo);
+    PushOneCommit.Result r2 =
+        amendChange(r1.getChangeId(), "refs/for/master", admin, adminTestRepo);
     r2.assertOkStatus();
   }
 
@@ -1704,25 +2200,21 @@
     TestRepository<?> userTestRepo = cloneProject(project, user);
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/drafts/master");
     r1.assertOkStatus();
 
     // Add user as reviewer
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(r1.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r1.getChangeId()).addReviewer(in);
 
     // Fetch change
     GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
     userTestRepo.reset("ps");
 
     // Amend change as user
-    PushOneCommit.Result r2 = amendChange(
-        r1.getChangeId(), "refs/for/master", user, userTestRepo);
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId(), "refs/for/master", user, userTestRepo);
     r2.assertOkStatus();
   }
 
@@ -1738,44 +2230,477 @@
     block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
 
     // Create change as admin
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), adminTestRepo);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
     PushOneCommit.Result r1 = push.to("refs/drafts/master");
     r1.assertOkStatus();
 
     // Add user as reviewer
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(r1.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r1.getChangeId()).addReviewer(in);
 
     // Fetch change
     GitUtil.fetch(userTestRepo, r1.getPatchSet().getRefName() + ":ps");
     userTestRepo.reset("ps");
 
     // Amend change as user
-    PushOneCommit.Result r2 = amendChange(
-        r1.getChangeId(), "refs/drafts/master", user, userTestRepo);
-    r2.assertErrorStatus("cannot add patch set to "
-        + r1.getChange().getId().id + ".");
+    PushOneCommit.Result r2 =
+        amendChange(r1.getChangeId(), "refs/drafts/master", user, userTestRepo);
+    r2.assertErrorStatus("cannot add patch set to " + r1.getChange().getId().id + ".");
   }
 
-  private static Iterable<Account.Id> getReviewers(
-      Collection<AccountInfo> r) {
-    return Iterables.transform(r, new Function<AccountInfo, Account.Id>() {
-      @Override
-      public Account.Id apply(AccountInfo account) {
-        return new Account.Id(account._accountId);
-      }
-    });
+  @Test
+  public void createMergePatchSet() throws Exception {
+    PushOneCommit.Result start = pushTo("refs/heads/master");
+    start.assertOkStatus();
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    String changeId = r.getChangeId();
+
+    testRepo.reset(start.getCommit());
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    createBranch(new Branch.NameKey(project, "dev"));
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeId)
+            .get(
+                EnumSet.of(
+                    ListChangesOption.ALL_REVISIONS,
+                    ListChangesOption.CURRENT_COMMIT,
+                    ListChangesOption.CURRENT_REVISION));
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
   }
 
-  private ChangeResource parseResource(PushOneCommit.Result r)
+  @Test
+  public void createMergePatchSetInheritParent() throws Exception {
+    PushOneCommit.Result start = pushTo("refs/heads/master");
+    start.assertOkStatus();
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    String changeId = r.getChangeId();
+    String parent = r.getCommit().getParent(0).getName();
+
+    // advance master branch
+    testRepo.reset(start.getCommit());
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    createBranch(new Branch.NameKey(project, "dev"));
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(db, user.getIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.inheritParent = true;
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo =
+        gApi.changes()
+            .id(changeId)
+            .get(
+                EnumSet.of(
+                    ListChangesOption.ALL_REVISIONS,
+                    ListChangesOption.CURRENT_COMMIT,
+                    ListChangesOption.CURRENT_REVISION));
+
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isNotEqualTo(currentMaster.getCommit().getName());
+  }
+
+  @Test
+  public void checkLabelsForOpenChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+
+    // add new label and assert that it's returned for existing changes
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
+    assertPermitted(change, "Code-Review", -2, -1, 0, 1, 2);
+    assertPermitted(change, "Verified", -1, 0, 1);
+
+    // add an approval on the new label
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
+
+    // remove label and assert that it's no longer returned for existing
+    // changes, even if there is an approval for it
+    cfg.getLabelSections().remove(verified.getName());
+    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+  }
+
+  @Test
+  public void checkLabelsForMergedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 2);
+
+    // add new label and assert that it's returned for existing changes
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(verified.getName()), -1, 1, registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified", 0, 1);
+
+    // ignore the new label by Prolog submit rule and assert that the label is
+    // no longer returned
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push2 =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Ignore Verified",
+            "rules.pl",
+            "submit_rule(submit(CR)) :-\n  gerrit:max_with_block(-2, 2, 'Code-Review', CR).");
+    push2.to(RefNames.REFS_CONFIG);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified");
+
+    // add an approval on the new label and assert that the label is now
+    // returned although it is ignored by the Prolog submit rule and hence not
+    // included in the submit records
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
+    assertPermitted(change, "Code-Review", 2);
+    assertPermitted(change, "Verified");
+
+    // remove label and assert that it's no longer returned for existing
+    // changes, even if there is an approval for it
+    cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().remove(verified.getName());
+    Util.remove(cfg, Permission.forLabel(verified.getName()), registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 2);
+  }
+
+  @Test
+  public void checkLabelsForMergedChangeWithNonAuthorCodeReview() throws Exception {
+    // Configure Non-Author-Code-Review
+    RevCommit oldHead = getRemoteHead();
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push2 =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Configure Non-Author-Code-Review",
+            "rules.pl",
+            "submit_rule(S) :-\n"
+                + "  gerrit:default_submit(X),\n"
+                + "  X =.. [submit | Ls],\n"
+                + "  add_non_author_approval(Ls, R),\n"
+                + "  S =.. [submit | R].\n"
+                + "\n"
+                + "add_non_author_approval(S1, S2) :-\n"
+                + "  gerrit:commit_author(A),\n"
+                + "  gerrit:commit_label(label('Code-Review', 2), R),\n"
+                + "  R \\= A, !,\n"
+                + "  S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
+                + "add_non_author_approval(S1,"
+                + " [label('Non-Author-Code-Review', need(_)) | S1]).");
+    push2.to(RefNames.REFS_CONFIG);
+    testRepo.reset(oldHead);
+
+    // Allow user to approve
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(
+        cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2, registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Non-Author-Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 0, 1, 2);
+  }
+
+  @Test
+  public void checkLabelsForAutoClosedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/heads/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 0, 1, 2);
+  }
+
+  @Test
+  public void checkLabelsForAbandonedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).abandon();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(change.labels).isEmpty();
+    assertThat(change.permittedLabels).isEmpty();
+  }
+
+  @Test
+  public void maxPermittedValueAllowed() throws Exception {
+    final int minPermittedValue = -2;
+    final int maxPermittedValue = +2;
+    String heads = "refs/heads/*";
+
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    gApi.changes().id(triplet).addReviewer(user.username);
+
+    ChangeInfo c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNotNull();
+    // default values
+    assertThat(approval.permittedVotingRange.min).isEqualTo(-1);
+    assertThat(approval.permittedVotingRange.max).isEqualTo(1);
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(
+        cfg,
+        Permission.forLabel("Code-Review"),
+        minPermittedValue,
+        maxPermittedValue,
+        REGISTERED_USERS,
+        heads);
+    saveProjectConfig(project, cfg);
+
+    c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNotNull();
+    assertThat(approval.permittedVotingRange.min).isEqualTo(minPermittedValue);
+    assertThat(approval.permittedVotingRange.max).isEqualTo(maxPermittedValue);
+  }
+
+  @Test
+  public void maxPermittedValueBlocked() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    gApi.changes().id(triplet).addReviewer(user.username);
+
+    ChangeInfo c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    LabelInfo codeReview = c.labels.get("Code-Review");
+    assertThat(codeReview.all).hasSize(1);
+    ApprovalInfo approval = codeReview.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.id.get());
+    assertThat(approval.permittedVotingRange).isNull();
+  }
+
+  @Sandboxed
+  @Test
+  public void unresolvedCommentsBlocked() throws Exception {
+    RevCommit oldHead = getRemoteHead();
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Configure",
+            "rules.pl",
+            "submit_rule(submit(R)) :- \n"
+                + "gerrit:unresolved_comments_count(0), \n"
+                + "!,"
+                + "gerrit:commit_author(A), \n"
+                + "R = label('All-Comments-Resolved', ok(A)).\n"
+                + "submit_rule(submit(R)) :- \n"
+                + "gerrit:unresolved_comments_count(U), \n"
+                + "U > 0,"
+                + "R = label('All-Comments-Resolved', need(_)). \n\n");
+
+    push.to(RefNames.REFS_CONFIG);
+    testRepo.reset(oldHead);
+
+    oldHead = getRemoteHead();
+    PushOneCommit.Result result1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    testRepo.reset(oldHead);
+    PushOneCommit.Result result2 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+
+    addComment(result1, "comment 1", true, false, null);
+    addComment(result2, "comment 2", true, true, null);
+
+    gApi.changes().id(result1.getChangeId()).current().submit();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change 2: needs All-Comments-Resolved");
+    gApi.changes().id(result2.getChangeId()).current().submit();
+  }
+
+  private void addComment(
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo)
       throws Exception {
-    List<ChangeControl> ctls = changeFinder.find(
-        r.getChangeId(), atrScope.get().getUser());
+    ReviewInput.CommentInput c = new ReviewInput.CommentInput();
+    c.line = 1;
+    c.message = message;
+    c.path = FILE_NAME;
+    c.unresolved = unresolved;
+    c.inReplyTo = inReplyTo;
+    ReviewInput in = new ReviewInput();
+    in.comments = new HashMap<>();
+    in.comments.put(c.path, Lists.newArrayList(c));
+    in.omitDuplicateComments = omitDuplicateComments;
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+  }
+
+  private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
+    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+  }
+
+  private ChangeResource parseResource(PushOneCommit.Result r) throws Exception {
+    List<ChangeControl> ctls = changeFinder.find(r.getChangeId(), atrScope.get().getUser());
     assertThat(ctls).hasSize(1);
     return changeResourceFactory.create(ctls.get(0));
   }
+
+  private Optional<ReviewerState> getReviewerState(String changeId, Account.Id accountId)
+      throws Exception {
+    ChangeInfo c = gApi.changes().id(changeId).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    Set<ReviewerState> states =
+        c.reviewers.entrySet().stream()
+            .filter(e -> e.getValue().stream().anyMatch(a -> a._accountId == accountId.get()))
+            .map(e -> e.getKey())
+            .collect(toSet());
+    assertThat(states.size()).named(states.toString()).isAtMost(1);
+    return states.stream().findFirst();
+  }
+
+  private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
+    try (BatchUpdate batchUpdate =
+        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+      batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
+      batchUpdate.execute();
+    }
+
+    ChangeStatus changeStatus = gApi.changes().id(id.get()).get().status;
+    assertThat(changeStatus).isEqualTo(newStatus.asChangeStatus());
+  }
+
+  private static class ChangeStatusUpdateOp implements BatchUpdateOp {
+    private final Change.Status newStatus;
+
+    ChangeStatusUpdateOp(Change.Status newStatus) {
+      this.newStatus = newStatus;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Change change = ctx.getChange();
+
+      // Change status in database.
+      change.setStatus(newStatus);
+
+      // Change status in NoteDb.
+      PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+      ctx.getUpdate(currentPatchSetId).setStatus(newStatus);
+
+      return true;
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/MergeListIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/MergeListIT.java
new file mode 100644
index 0000000..1acd71c
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/MergeListIT.java
@@ -0,0 +1,209 @@
+// 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.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class MergeListIT extends AbstractDaemonTest {
+
+  private String changeId;
+  private RevCommit merge;
+  private RevCommit parent1;
+  private RevCommit grandParent1;
+  private RevCommit parent2;
+  private RevCommit grandParent2;
+
+  @Before
+  public void setup() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result gp1 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                "grand parent 1",
+                ImmutableMap.of("foo", "foo-1.1", "bar", "bar-1.1"))
+            .to("refs/for/master");
+    grandParent1 = gp1.getCommit();
+
+    PushOneCommit.Result p1 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                "parent 1",
+                ImmutableMap.of("foo", "foo-1.2", "bar", "bar-1.2"))
+            .to("refs/for/master");
+    parent1 = p1.getCommit();
+
+    // reset HEAD in order to create a sibling of the first change
+    testRepo.reset(initial);
+
+    PushOneCommit.Result gp2 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                "grand parent 2",
+                ImmutableMap.of("foo", "foo-2.1", "bar", "bar-2.1"))
+            .to("refs/for/master");
+    grandParent2 = gp2.getCommit();
+
+    PushOneCommit.Result p2 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                "parent 2",
+                ImmutableMap.of("foo", "foo-2.2", "bar", "bar-2.2"))
+            .to("refs/for/master");
+    parent2 = p2.getCommit();
+
+    PushOneCommit m =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "merge",
+            ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    merge = result.getCommit();
+    changeId = result.getChangeId();
+  }
+
+  @Test
+  public void getMergeList() throws Exception {
+    List<CommitInfo> mergeList = current(changeId).getMergeList().get();
+    assertThat(mergeList).hasSize(2);
+    assertThat(mergeList.get(0).commit).isEqualTo(parent2.name());
+    assertThat(mergeList.get(1).commit).isEqualTo(grandParent2.name());
+
+    mergeList = current(changeId).getMergeList().withUninterestingParent(2).get();
+    assertThat(mergeList).hasSize(2);
+    assertThat(mergeList.get(0).commit).isEqualTo(parent1.name());
+    assertThat(mergeList.get(1).commit).isEqualTo(grandParent1.name());
+  }
+
+  @Test
+  public void getMergeListContent() throws Exception {
+    BinaryResult bin = current(changeId).file(MERGE_LIST).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String content = new String(os.toByteArray(), UTF_8);
+    assertThat(content).isEqualTo(getMergeListContent(parent2, grandParent2));
+  }
+
+  @Test
+  public void getFileList() throws Exception {
+    assertThat(getFiles(changeId)).contains(MERGE_LIST);
+    assertThat(getFiles(changeId, 1)).contains(MERGE_LIST);
+    assertThat(getFiles(changeId, 2)).contains(MERGE_LIST);
+
+    assertThat(getFiles(createChange().getChangeId())).doesNotContain(MERGE_LIST);
+  }
+
+  @Test
+  public void getDiffForMergeList() throws Exception {
+    DiffInfo diff = getMergeListDiff(changeId);
+    assertDiffForNewFile(diff, merge, MERGE_LIST, getMergeListContent(parent2, grandParent2));
+
+    diff = getMergeListDiff(changeId, 1);
+    assertDiffForNewFile(diff, merge, MERGE_LIST, getMergeListContent(parent2, grandParent2));
+
+    diff = getMergeListDiff(changeId, 2);
+    assertDiffForNewFile(diff, merge, MERGE_LIST, getMergeListContent(parent1, grandParent1));
+  }
+
+  @Test
+  public void editMergeList() throws Exception {
+    gApi.changes().id(changeId).edit().create();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Invalid path: " + MERGE_LIST);
+    gApi.changes().id(changeId).edit().modifyFile(MERGE_LIST, RawInputUtil.create("new content"));
+  }
+
+  @Test
+  public void deleteMergeList() throws Exception {
+    gApi.changes().id(changeId).edit().create();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("no changes were made");
+    gApi.changes().id(changeId).edit().deleteFile(MERGE_LIST);
+  }
+
+  private String getMergeListContent(RevCommit... commits) {
+    StringBuilder mergeList = new StringBuilder("Merge List:\n\n");
+    for (RevCommit c : commits) {
+      mergeList
+          .append("* ")
+          .append(c.abbreviate(8).name())
+          .append(" ")
+          .append(c.getShortMessage())
+          .append("\n");
+    }
+    return mergeList.toString();
+  }
+
+  private Set<String> getFiles(String changeId) throws Exception {
+    return current(changeId).files().keySet();
+  }
+
+  private Set<String> getFiles(String changeId, int parent) throws Exception {
+    return current(changeId).files(parent).keySet();
+  }
+
+  private DiffInfo getMergeListDiff(String changeId) throws Exception {
+    return current(changeId).file(MERGE_LIST).diff();
+  }
+
+  private DiffInfo getMergeListDiff(String changeId, int parent) throws Exception {
+    return current(changeId).file(MERGE_LIST).diff(parent);
+  }
+
+  private RevisionApi current(String changeId) throws Exception {
+    return gApi.changes().id(changeId).current();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 54fe28f..94f8494 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
+import static com.google.gerrit.extensions.client.ChangeKind.NO_CHANGE;
 import static com.google.gerrit.extensions.client.ChangeKind.NO_CODE_CHANGE;
 import static com.google.gerrit.extensions.client.ChangeKind.REWORK;
 import static com.google.gerrit.extensions.client.ChangeKind.TRIVIAL_REBASE;
@@ -44,9 +45,10 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
-
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -54,10 +56,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.EnumSet;
-import java.util.List;
-import java.util.Set;
-
 @NoHttpd
 public class StickyApprovalsIT extends AbstractDaemonTest {
   @Before
@@ -67,31 +65,33 @@
     // Overwrite "Code-Review" label that is inherited from All-Projects.
     // This way changes to the "Code Review" label don't affect other tests.
     LabelType codeReview =
-        category("Code-Review", value(2, "Looks good to me, approved"),
+        category(
+            "Code-Review",
+            value(2, "Looks good to me, approved"),
             value(1, "Looks good to me, but someone else must approve"),
             value(0, "No score"),
             value(-1, "I would prefer that you didn't submit this"),
             value(-2, "Do not submit"));
+    codeReview.setCopyAllScoresIfNoChange(false);
     cfg.getLabelSections().put(codeReview.getName(), codeReview);
 
-    LabelType verified = category("Verified", value(1, "Passes"),
-        value(0, "No score"), value(-1, "Failed"));
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    verified.setCopyAllScoresIfNoChange(false);
     cfg.getLabelSections().put(verified.getName(), verified);
 
-    AccountGroup.UUID registeredUsers =
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
     String heads = RefNames.REFS_HEADS + "*";
-    Util.allow(cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2,
-        registeredUsers, heads);
-    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1,
-        registeredUsers, heads);
+    Util.allow(
+        cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2, registeredUsers, heads);
+    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, registeredUsers, heads);
     saveProjectConfig(project, cfg);
   }
 
   @Test
   public void notSticky() throws Exception {
-    assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE,
-        MERGE_FIRST_PARENT_UPDATE));
+    assertNotSticky(
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE));
   }
 
   @Test
@@ -100,8 +100,8 @@
     cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
     saveProjectConfig(project, cfg);
 
-    for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
-        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(getRemoteHead());
 
       String changeId = createChange(changeKind);
@@ -121,8 +121,8 @@
     cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
     saveProjectConfig(project, cfg);
 
-    for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
-        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(getRemoteHead());
 
       String changeId = createChange(changeKind);
@@ -139,21 +139,24 @@
   @Test
   public void stickyOnTrivialRebase() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review")
-        .setCopyAllScoresOnTrivialRebase(true);
+    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
     saveProjectConfig(project, cfg);
 
     String changeId = createChange(TRIVIAL_REBASE);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, TRIVIAL_REBASE);
+    updateChange(changeId, NO_CHANGE);
     ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, NO_CHANGE);
+    assertVotes(c, user, -2, 0, NO_CHANGE);
+
+    updateChange(changeId, TRIVIAL_REBASE);
+    c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
     assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
 
-    assertNotSticky(
-        EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
+    assertNotSticky(EnumSet.of(REWORK, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE));
 
     // check that votes are sticky when trivial rebase is done by cherry-pick
     testRepo.reset(getRemoteHead());
@@ -181,36 +184,43 @@
   @Test
   public void stickyOnNoCodeChange() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Verified")
-        .setCopyAllScoresIfNoCodeChange(true);
+    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
     saveProjectConfig(project, cfg);
 
     String changeId = createChange(NO_CODE_CHANGE);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CODE_CHANGE);
+    updateChange(changeId, NO_CHANGE);
     ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 1, NO_CHANGE);
+    assertVotes(c, user, 0, -1, NO_CHANGE);
+
+    updateChange(changeId, NO_CODE_CHANGE);
+    c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
     assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
 
-    assertNotSticky(
-        EnumSet.of(REWORK, TRIVIAL_REBASE, MERGE_FIRST_PARENT_UPDATE));
+    assertNotSticky(EnumSet.of(REWORK, TRIVIAL_REBASE, MERGE_FIRST_PARENT_UPDATE));
   }
 
   @Test
   public void stickyOnMergeFirstParentUpdate() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review")
-        .setCopyAllScoresOnMergeFirstParentUpdate(true);
+    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnMergeFirstParentUpdate(true);
     saveProjectConfig(project, cfg);
 
     String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    updateChange(changeId, NO_CHANGE);
     ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, NO_CHANGE);
+    assertVotes(c, user, -2, 0, NO_CHANGE);
+
+    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
     assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
 
@@ -220,13 +230,12 @@
   @Test
   public void removedVotesNotSticky() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review")
-        .setCopyAllScoresOnTrivialRebase(true);
+    cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
     cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
     saveProjectConfig(project, cfg);
 
-    for (ChangeKind changeKind : EnumSet.of(REWORK, TRIVIAL_REBASE,
-        NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE)) {
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(getRemoteHead());
 
       String changeId = createChange(changeKind);
@@ -250,10 +259,8 @@
   @Test
   public void stickyAcrossMultiplePatchSets() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review")
-        .setCopyMaxScore(true);
-    cfg.getLabelSections().get("Verified")
-        .setCopyAllScoresIfNoCodeChange(true);
+    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
+    cfg.getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
     saveProjectConfig(project, cfg);
 
     String changeId = createChange(REWORK);
@@ -273,10 +280,8 @@
   @Test
   public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    cfg.getLabelSections().get("Code-Review")
-        .setCopyMaxScore(true);
-    cfg.getLabelSections().get("Code-Review")
-        .setCopyMinScore(true);
+    cfg.getLabelSections().get("Code-Review").setCopyMaxScore(true);
+    cfg.getLabelSections().get("Code-Review").setCopyMinScore(true);
     saveProjectConfig(project, cfg);
 
     // Vote max score on PS1
@@ -311,11 +316,33 @@
     assertVotes(c, user, 0, 0, REWORK);
   }
 
+  @Test
+  public void deleteStickyVote() throws Exception {
+    String label = "Code-Review";
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get(label).setCopyMaxScore(true);
+    saveProjectConfig(project, cfg);
+
+    // Vote max score on PS1
+    String changeId = createChange(REWORK);
+    vote(admin, changeId, label, 2);
+    assertVotes(detailedChange(changeId), admin, label, 2, null);
+    updateChange(changeId, REWORK);
+    assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
+
+    // Delete vote that was copied via sticky approval
+    deleteVote(admin, changeId, "Code-Review");
+    assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
+  }
+
   private ChangeInfo detailedChange(String changeId) throws Exception {
-    return gApi.changes().id(changeId)
-        .get(EnumSet.of(ListChangesOption.DETAILED_LABELS,
-            ListChangesOption.CURRENT_REVISION,
-            ListChangesOption.CURRENT_COMMIT));
+    return gApi.changes()
+        .id(changeId)
+        .get(
+            EnumSet.of(
+                ListChangesOption.DETAILED_LABELS,
+                ListChangesOption.CURRENT_REVISION,
+                ListChangesOption.CURRENT_COMMIT));
   }
 
   private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
@@ -347,8 +374,7 @@
     }
   }
 
-  private void updateChange(String changeId, ChangeKind changeKind)
-      throws Exception {
+  private void updateChange(String changeId, ChangeKind changeKind) throws Exception {
     switch (changeKind) {
       case NO_CODE_CHANGE:
         noCodeChange(changeId);
@@ -363,6 +389,8 @@
         updateFirstParent(changeId);
         return;
       case NO_CHANGE:
+        noChange(changeId);
+        return;
       default:
         fail("unexpected change kind: " + changeKind);
     }
@@ -371,7 +399,8 @@
   private void noCodeChange(String changeId) throws Exception {
     TestRepository<?>.CommitBuilder commitBuilder =
         testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder.message("New subject " + System.nanoTime())
+    commitBuilder
+        .message("New subject " + System.nanoTime())
         .author(admin.getIdent())
         .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
     commitBuilder.create();
@@ -379,10 +408,31 @@
     assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
   }
 
+  private void noChange(String changeId) throws Exception {
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
+
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message(commitMessage)
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
+  }
+
   private void rework(String changeId) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, PushOneCommit.FILE_NAME,
-        "new content " + System.nanoTime(), changeId);
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "new content " + System.nanoTime(),
+            changeId);
     push.to("refs/for/master").assertOkStatus();
     assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
   }
@@ -391,41 +441,36 @@
     setApiUser(admin);
     testRepo.reset(getRemoteHead());
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Other Change",
-            "a" + System.nanoTime() + ".txt", PushOneCommit.FILE_CONTENT);
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Other Change",
+            "a" + System.nanoTime() + ".txt",
+            PushOneCommit.FILE_CONTENT);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
-    RevisionApi revision = gApi.changes()
-        .id(r.getChangeId())
-        .current();
-    ReviewInput in = new ReviewInput()
-        .label("Code-Review", 2)
-        .label("Verified", 1);
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    ReviewInput in = new ReviewInput().label("Code-Review", 2).label("Verified", 1);
     revision.review(in);
     revision.submit();
 
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .rebase();
+    gApi.changes().id(changeId).current().rebase();
     assertThat(getChangeKind(changeId)).isEqualTo(TRIVIAL_REBASE);
   }
 
   private String createChangeForMergeCommit() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
-    PushOneCommit.Result parent1 =
-        createChange("parent 1", "p1.txt", "content 1");
+    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1");
 
     testRepo.reset(initial);
-    PushOneCommit.Result parent2 =
-        createChange("parent 2", "p2.txt", "content 2");
+    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2");
 
     testRepo.reset(parent1.getCommit());
 
     PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo);
-    merge.setParents(
-        ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
     PushOneCommit.Result result = merge.to("refs/for/master");
     result.assertOkStatus();
     return result.getChangeId();
@@ -436,17 +481,13 @@
     List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
     String parent1 = parents.get(0).commit;
     String parent2 = parents.get(1).commit;
-    RevCommit commitParent2 =
-        testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
+    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
 
     testRepo.reset(parent1);
-    PushOneCommit.Result newParent1 =
-        createChange("new parent 1", "p1-1.txt", "content 1-1");
+    PushOneCommit.Result newParent1 = createChange("new parent 1", "p1-1.txt", "content 1-1");
 
-    PushOneCommit merge =
-        pushFactory.create(db, admin.getIdent(), testRepo, changeId);
-    merge.setParents(
-        ImmutableList.of(newParent1.getCommit(), commitParent2));
+    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
     PushOneCommit.Result result = merge.to("refs/for/master");
     result.assertOkStatus();
 
@@ -466,57 +507,66 @@
     }
 
     testRepo.reset(getRemoteHead());
-    PushOneCommit.Result r = pushFactory
-        .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "other.txt", "new content " + System.nanoTime())
-        .to("refs/for/master");
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                "other.txt",
+                "new content " + System.nanoTime())
+            .to("refs/for/master");
     r.assertOkStatus();
     vote(admin, r.getChangeId(), 2, 1);
     merge(r);
 
-    String subject = TRIVIAL_REBASE.equals(changeKind)
-        ? PushOneCommit.SUBJECT
-        : "Reworked change " + System.nanoTime();
+    String subject =
+        TRIVIAL_REBASE.equals(changeKind)
+            ? PushOneCommit.SUBJECT
+            : "Reworked change " + System.nanoTime();
     CherryPickInput in = new CherryPickInput();
     in.destination = "master";
-    in.message =
-        String.format("%s\n\nChange-Id: %s", subject, changeId);
-    ChangeInfo c = gApi.changes()
-        .id(changeId)
-        .revision("current")
-        .cherryPick(in)
-        .get();
+    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
+    ChangeInfo c = gApi.changes().id(changeId).revision("current").cherryPick(in).get();
     return c.changeId;
   }
 
   private ChangeKind getChangeKind(String changeId) throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId)
-        .get(EnumSet.of(ListChangesOption.CURRENT_REVISION));
+    ChangeInfo c = gApi.changes().id(changeId).get(EnumSet.of(ListChangesOption.CURRENT_REVISION));
     return c.revisions.get(c.currentRevision).kind;
   }
 
-  private void vote(TestAccount user, String changeId, int codeReviewVote,
-      int verifiedVote) throws Exception {
+  private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
     setApiUser(user);
-    ReviewInput in = new ReviewInput()
-        .label("Code-Review", codeReviewVote)
-        .label("Verified", verifiedVote);
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));
+  }
+
+  private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    setApiUser(user);
+    ReviewInput in =
+        new ReviewInput().label("Code-Review", codeReviewVote).label("Verified", verifiedVote);
     gApi.changes().id(changeId).current().review(in);
   }
 
-  private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote,
-      int verifiedVote) {
+  private void deleteVote(TestAccount user, String changeId, String label) throws Exception {
+    setApiUser(user);
+    gApi.changes().id(changeId).reviewer(user.getId().toString()).deleteVote(label);
+  }
+
+  private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote) {
     assertVotes(c, user, codeReviewVote, verifiedVote, null);
   }
 
-  private void assertVotes(ChangeInfo c, TestAccount user, int codeReviewVote,
-      int verifiedVote, ChangeKind changeKind) {
+  private void assertVotes(
+      ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote, ChangeKind changeKind) {
     assertVotes(c, user, "Code-Review", codeReviewVote, changeKind);
     assertVotes(c, user, "Verified", verifiedVote, changeKind);
   }
 
-  private void assertVotes(ChangeInfo c, TestAccount user, String label,
-      int expectedVote, ChangeKind changeKind) {
+  private void assertVotes(
+      ChangeInfo c, TestAccount user, String label, int expectedVote, ChangeKind changeKind) {
     Integer vote = 0;
     if (c.labels.get(label) != null && c.labels.get(label).all != null) {
       for (ApprovalInfo approval : c.labels.get(label).all) {
@@ -531,8 +581,6 @@
     if (changeKind != null) {
       name += "; changeKind = " + changeKind.name();
     }
-    assertThat(vote)
-        .named(name)
-        .isEqualTo(expectedVote);
+    assertThat(vote).named(name).isEqualTo(expectedVote);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index 1033164..6036dc5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.extensions.client.SubmitType.FAST_FORWARD_ONLY;
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_ALWAYS;
 import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
+import static com.google.gerrit.extensions.client.SubmitType.REBASE_ALWAYS;
 import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
 import static org.junit.Assert.fail;
 
@@ -37,7 +38,9 @@
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.VersionedMetaData;
 import com.google.gerrit.testutil.ConfigSuite;
-
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -48,10 +51,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicInteger;
-
 @NoHttpd
 public class SubmitTypeRuleIT extends AbstractDaemonTest {
   @ConfigSuite.Default
@@ -75,8 +74,7 @@
     }
 
     @Override
-    protected boolean onSave(CommitBuilder commit)
-        throws IOException, ConfigInvalidException {
+    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
       TestSubmitRuleInput in = new TestSubmitRuleInput();
       in.rule = rule;
       try {
@@ -96,8 +94,7 @@
   @Before
   public void setUp() throws Exception {
     fileCounter = new AtomicInteger();
-    gApi.projects().name(project.get()).branch("test")
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch("test").create(new BranchInput());
     testChangeId = createChange("test", "test change").getChange().getId();
   }
 
@@ -112,32 +109,40 @@
 
   private static final String SUBMIT_TYPE_FROM_SUBJECT =
       "submit_type(fast_forward_only) :-"
-      + "gerrit:commit_message(M),"
-      + "regex_matches('.*FAST_FORWARD_ONLY.*', M),"
-      + "!.\n"
-      + "submit_type(merge_if_necessary) :-"
-      + "gerrit:commit_message(M),"
-      + "regex_matches('.*MERGE_IF_NECESSARY.*', M),"
-      + "!.\n"
-      + "submit_type(rebase_if_necessary) :-"
-      + "gerrit:commit_message(M),"
-      + "regex_matches('.*REBASE_IF_NECESSARY.*', M),"
-      + "!.\n"
-      + "submit_type(merge_always) :-"
-      + "gerrit:commit_message(M),"
-      + "regex_matches('.*MERGE_ALWAYS.*', M),"
-      + "!.\n"
-      + "submit_type(cherry_pick) :-"
-      + "gerrit:commit_message(M),"
-      + "regex_matches('.*CHERRY_PICK.*', M),"
-      + "!.\n"
-      + "submit_type(T) :- gerrit:project_default_submit_type(T).";
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*FAST_FORWARD_ONLY.*', M),"
+          + "!.\n"
+          + "submit_type(merge_if_necessary) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*MERGE_IF_NECESSARY.*', M),"
+          + "!.\n"
+          + "submit_type(rebase_if_necessary) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*REBASE_IF_NECESSARY.*', M),"
+          + "!.\n"
+          + "submit_type(rebase_always) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*REBASE_ALWAYS.*', M),"
+          + "!.\n"
+          + "submit_type(merge_always) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*MERGE_ALWAYS.*', M),"
+          + "!.\n"
+          + "submit_type(cherry_pick) :-"
+          + "gerrit:commit_message(M),"
+          + "regex_matches('.*CHERRY_PICK.*', M),"
+          + "!.\n"
+          + "submit_type(T) :- gerrit:project_default_submit_type(T).";
 
-  private PushOneCommit.Result createChange(String dest, String subject)
-      throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        subject, "file" + fileCounter.incrementAndGet(),
-        PushOneCommit.FILE_CONTENT);
+  private PushOneCommit.Result createChange(String dest, String subject) throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            subject,
+            "file" + fileCounter.incrementAndGet(),
+            PushOneCommit.FILE_CONTENT);
     PushOneCommit.Result r = push.to("refs/for/" + dest);
     r.assertOkStatus();
     return r;
@@ -157,8 +162,9 @@
     PushOneCommit.Result r2 = createChange("master", "FAST_FORWARD_ONLY 2");
     PushOneCommit.Result r3 = createChange("master", "MERGE_IF_NECESSARY 3");
     PushOneCommit.Result r4 = createChange("master", "REBASE_IF_NECESSARY 4");
-    PushOneCommit.Result r5 = createChange("master", "MERGE_ALWAYS 5");
-    PushOneCommit.Result r6 = createChange("master", "CHERRY_PICK 6");
+    PushOneCommit.Result r5 = createChange("master", "REBASE_ALWAYS 5");
+    PushOneCommit.Result r6 = createChange("master", "MERGE_ALWAYS 6");
+    PushOneCommit.Result r7 = createChange("master", "CHERRY_PICK 7");
 
     assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
     assertSubmitType(MERGE_IF_NECESSARY, r2.getChangeId());
@@ -166,6 +172,7 @@
     assertSubmitType(MERGE_IF_NECESSARY, r4.getChangeId());
     assertSubmitType(MERGE_IF_NECESSARY, r5.getChangeId());
     assertSubmitType(MERGE_IF_NECESSARY, r6.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r7.getChangeId());
 
     setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
 
@@ -173,8 +180,9 @@
     assertSubmitType(FAST_FORWARD_ONLY, r2.getChangeId());
     assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
     assertSubmitType(REBASE_IF_NECESSARY, r4.getChangeId());
-    assertSubmitType(MERGE_ALWAYS, r5.getChangeId());
-    assertSubmitType(CHERRY_PICK, r6.getChangeId());
+    assertSubmitType(REBASE_ALWAYS, r5.getChangeId());
+    assertSubmitType(MERGE_ALWAYS, r6.getChangeId());
+    assertSubmitType(CHERRY_PICK, r7.getChangeId());
   }
 
   @Test
@@ -189,8 +197,7 @@
     List<RevCommit> log = log("master", 1);
     assertThat(log.get(0).getShortMessage()).isEqualTo("CHERRY_PICK 1");
     assertThat(log.get(0).name()).isNotEqualTo(r.getCommit().name());
-    assertThat(log.get(0).getFullMessage())
-        .contains("Change-Id: " + r.getChangeId());
+    assertThat(log.get(0).getFullMessage()).contains("Change-Id: " + r.getChangeId());
     assertThat(log.get(0).getFullMessage()).contains("Reviewed-on: ");
   }
 
@@ -218,8 +225,7 @@
 
     List<RevCommit> branchLog = log("branch", 1);
     assertThat(branchLog.get(0).getParents()).hasLength(2);
-    assertThat(branchLog.get(0).getParent(1).name())
-        .isEqualTo(r2.getCommit().name());
+    assertThat(branchLog.get(0).getParent(1).name()).isEqualTo(r2.getCommit().name());
   }
 
   @Test
@@ -236,11 +242,17 @@
       gApi.changes().id(r2.getChangeId()).current().submit();
       fail("Expected ResourceConflictException");
     } catch (ResourceConflictException e) {
-      assertThat(e).hasMessage(
-          "Failed to submit 2 changes due to the following problems:\n"
-          + "Change " + r1.getChange().getId() + ": Change has submit type "
-          + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
-          + "from change " + r2.getChange().getId() + " in the same batch");
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Failed to submit 2 changes due to the following problems:\n"
+                  + "Change "
+                  + r1.getChange().getId()
+                  + ": Change has submit type "
+                  + "CHERRY_PICK, but previously chose submit type MERGE_IF_NECESSARY "
+                  + "from change "
+                  + r2.getChange().getId()
+                  + " in the same batch");
     }
   }
 
@@ -253,9 +265,7 @@
     }
   }
 
-  private void assertSubmitType(SubmitType expected, String id)
-      throws Exception {
-    assertThat(gApi.changes().id(id).current().submitType())
-        .isEqualTo(expected);
+  private void assertSubmitType(SubmitType expected, String id) throws Exception {
+    assertThat(gApi.changes().id(id).current().submitType()).isEqualTo(expected);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUCK
deleted file mode 100644
index 3b3d362..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'api_config',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD
index da8274d..6d39131 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/BUILD
@@ -1,7 +1,7 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'api_config',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
+    srcs = glob(["*IT.java"]),
+    group = "api_config",
+    labels = ["api"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
index 047305c..fd08838 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-
 import org.junit.Test;
 
 @NoHttpd
@@ -28,8 +27,7 @@
 
   @Test
   public void getDiffPreferences() throws Exception {
-    DiffPreferencesInfo result =
-        gApi.config().server().getDefaultDiffPreferences();
+    DiffPreferencesInfo result = gApi.config().server().getDefaultDiffPreferences();
     assertPrefs(result, DiffPreferencesInfo.defaults());
   }
 
@@ -38,8 +36,7 @@
     int newLineLength = DiffPreferencesInfo.defaults().lineLength + 10;
     DiffPreferencesInfo update = new DiffPreferencesInfo();
     update.lineLength = newLineLength;
-    DiffPreferencesInfo result =
-        gApi.config().server().setDefaultDiffPreferences(update);
+    DiffPreferencesInfo result = gApi.config().server().setDefaultDiffPreferences(update);
     assertThat(result.lineLength).named("lineLength").isEqualTo(newLineLength);
 
     result = gApi.config().server().getDefaultDiffPreferences();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
index 1dcdaed..2337246 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
@@ -31,8 +30,7 @@
 
 @NoHttpd
 public class GeneralPreferencesIT extends AbstractDaemonTest {
-  @Inject
-  private AllUsersName allUsers;
+  @Inject private AllUsersName allUsers;
 
   @After
   public void cleanUp() throws Exception {
@@ -48,8 +46,7 @@
 
   @Test
   public void getGeneralPreferences() throws Exception {
-    GeneralPreferencesInfo result =
-        gApi.config().server().getDefaultPreferences();
+    GeneralPreferencesInfo result = gApi.config().server().getDefaultPreferences();
     assertPrefs(result, GeneralPreferencesInfo.defaults(), "my");
   }
 
@@ -58,8 +55,7 @@
     boolean newSignedOffBy = !GeneralPreferencesInfo.defaults().signedOffBy;
     GeneralPreferencesInfo update = new GeneralPreferencesInfo();
     update.signedOffBy = newSignedOffBy;
-    GeneralPreferencesInfo result =
-        gApi.config().server().setDefaultPreferences(update);
+    GeneralPreferencesInfo result = gApi.config().server().setDefaultPreferences(update);
     assertThat(result.signedOffBy).named("signedOffBy").isEqualTo(newSignedOffBy);
 
     result = gApi.config().server().getDefaultPreferences();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/ServerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/ServerIT.java
index 8646aff..88503be 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/ServerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/config/ServerIT.java
@@ -19,14 +19,12 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.common.Version;
-
 import org.junit.Test;
 
 @NoHttpd
 public class ServerIT extends AbstractDaemonTest {
   @Test
   public void getVersion() throws Exception {
-    assertThat(gApi.config().server().getVersion())
-        .isEqualTo(Version.getVersion());
+    assertThat(gApi.config().server().getVersion()).isEqualTo(Version.getVersion());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUCK
deleted file mode 100644
index cea23dd..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUCK
+++ /dev/null
@@ -1,23 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'api_group',
-  srcs = glob(['*IT.java']),
-  deps = [
-    ':util',
-    '//gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:util',
-  ],
-  labels = ['api'],
-)
-
-java_library(
-  name = 'util',
-  srcs = ['GroupAssert.java'],
-  deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:gwtorm',
-    '//lib:truth',
-  ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD
index 1a374f0..44f4813 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/BUILD
@@ -1,23 +1,24 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'api_group',
-  srcs = glob(['*IT.java']),
-  deps = [
-    ':util',
-    '//gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:util',
-  ],
-  labels = ['api'],
+    srcs = glob(["*IT.java"]),
+    group = "api_group",
+    labels = ["api"],
+    deps = [
+        ":util",
+        "//gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:util",
+    ],
 )
 
 java_library(
-  name = 'util',
-  srcs = ['GroupAssert.java'],
-  deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:gwtorm',
-    '//lib:truth',
-  ],
+    name = "util",
+    srcs = ["GroupAssert.java"],
+    deps = [
+        "//gerrit-extension-api:api",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//lib:gwtorm",
+        "//lib:truth",
+    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
index c3c2224..c9d5a8f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -20,18 +20,15 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
 import java.util.Set;
 
 public class GroupAssert {
 
   public static void assertGroups(Iterable<String> expected, Set<String> actual) {
     for (String g : expected) {
-      assert_().withFailureMessage("missing group " + g)
-        .that(actual.remove(g)).isTrue();
+      assert_().withFailureMessage("missing group " + g).that(actual.remove(g)).isTrue();
     }
-    assert_().withFailureMessage("unexpected groups: " + actual)
-      .that((Iterable<?>)actual).isEmpty();
+    assert_().withFailureMessage("unexpected groups: " + actual).that(actual).isEmpty();
   }
 
   public static void assertGroupInfo(AccountGroup group, GroupInfo info) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
index f699f61..76e1160 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -17,17 +17,19 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.api.group.GroupAssert.assertGroupInfo;
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.groups.Groups;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo.GroupMemberAuditEventInfo;
@@ -36,6 +38,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 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.UnprocessableEntityException;
@@ -43,14 +46,13 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
-
-import org.junit.Test;
-
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.LinkedList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.junit.Test;
 
 @NoHttpd
 public class GroupsIT extends AbstractDaemonTest {
@@ -103,10 +105,9 @@
   public void addMembersWithAtSign() throws Exception {
     String g = createGroup("users");
     TestAccount u10 = accounts.create("u10", "u10@example.com", "Full Name 10");
-    TestAccount u11_at = accounts.create("u11@something", "u11@example.com",
-                                         "Full Name 11 With At");
-    accounts.create("u11", "u11.another@example.com",
-                    "Full Name 11 Without At");
+    TestAccount u11_at =
+        accounts.create("u11@something", "u11@example.com", "Full Name 11 With At");
+    accounts.create("u11", "u11.another@example.com", "Full Name 11 Without At");
     gApi.groups().id(g).addMembers(u10.username, u11_at.username);
     assertMembers(g, u10, u11_at);
   }
@@ -137,7 +138,7 @@
     String p = createGroup("parent");
     String g1 = createGroup("newGroup1");
     String g2 = createGroup("newGroup2");
-    List<String> groups = new LinkedList<>();
+    List<String> groups = new ArrayList<>();
     groups.add(g1);
     groups.add(g2);
     gApi.groups().id(p).addGroups(g1, g2);
@@ -145,15 +146,14 @@
   }
 
   @Test
-  public void testCreateGroup() throws Exception {
+  public void createGroup() throws Exception {
     String newGroupName = name("newGroup");
     GroupInfo g = gApi.groups().create(newGroupName).get();
     assertGroupInfo(getFromCache(newGroupName), g);
   }
 
   @Test
-  public void testCreateDuplicateInternalGroupCaseSensitiveName_Conflict()
-      throws Exception {
+  public void createDuplicateInternalGroupCaseSensitiveName_Conflict() throws Exception {
     String dupGroupName = name("dupGroup");
     gApi.groups().create(dupGroupName);
     exception.expect(ResourceConflictException.class);
@@ -162,8 +162,7 @@
   }
 
   @Test
-  public void testCreateDuplicateInternalGroupCaseInsensitiveName()
-      throws Exception {
+  public void createDuplicateInternalGroupCaseInsensitiveName() throws Exception {
     String dupGroupName = name("dupGroupA");
     String dupGroupNameLowerCase = name("dupGroupA").toLowerCase();
     gApi.groups().create(dupGroupName);
@@ -173,8 +172,7 @@
   }
 
   @Test
-  public void testCreateDuplicateSystemGroupCaseSensitiveName_Conflict()
-      throws Exception {
+  public void createDuplicateSystemGroupCaseSensitiveName_Conflict() throws Exception {
     String newGroupName = "Registered Users";
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("group 'Registered Users' already exists");
@@ -182,8 +180,7 @@
   }
 
   @Test
-  public void testCreateDuplicateSystemGroupCaseInsensitiveName_Conflict()
-      throws Exception {
+  public void createDuplicateSystemGroupCaseInsensitiveName_Conflict() throws Exception {
     String newGroupName = "registered users";
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("group 'Registered Users' already exists");
@@ -191,7 +188,23 @@
   }
 
   @Test
-  public void testCreateGroupWithProperties() throws Exception {
+  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
+  public void createGroupWithConfiguredNameOfSystemGroup_Conflict() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group 'All Users' already exists");
+    gApi.groups().create("all users");
+  }
+
+  @Test
+  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
+  public void createGroupWithDefaultNameOfSystemGroup_Conflict() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("group name 'Anonymous Users' is reserved");
+    gApi.groups().create("anonymous users");
+  }
+
+  @Test
+  public void createGroupWithProperties() throws Exception {
     GroupInput in = new GroupInput();
     in.name = name("newGroup");
     in.description = "Test description";
@@ -204,28 +217,55 @@
   }
 
   @Test
-  public void testCreateGroupWithoutCapability_Forbidden() throws Exception {
+  public void createGroupWithoutCapability_Forbidden() throws Exception {
     setApiUser(user);
     exception.expect(AuthException.class);
     gApi.groups().create(name("newGroup"));
   }
 
   @Test
-  public void testGetGroup() throws Exception {
+  public void getGroup() throws Exception {
     AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
     testGetGroup(adminGroup.getGroupUUID().get(), adminGroup);
     testGetGroup(adminGroup.getName(), adminGroup);
     testGetGroup(adminGroup.getId().get(), adminGroup);
   }
 
-  private void testGetGroup(Object id, AccountGroup expectedGroup)
-      throws Exception {
+  private void testGetGroup(Object id, AccountGroup expectedGroup) throws Exception {
     GroupInfo group = gApi.groups().id(id.toString()).get();
     assertGroupInfo(expectedGroup, group);
   }
 
   @Test
-  public void testGroupName() throws Exception {
+  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
+  public void getSystemGroupByConfiguredName() throws Exception {
+    GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+    assertThat(anonymousUsersGroup.getName()).isEqualTo("All Users");
+
+    GroupInfo group = gApi.groups().id(anonymousUsersGroup.getUUID().get()).get();
+    assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
+
+    group = gApi.groups().id(anonymousUsersGroup.getName()).get();
+    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+  }
+
+  @Test
+  public void getSystemGroupByDefaultName() throws Exception {
+    GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+    GroupInfo group = gApi.groups().id("Anonymous Users").get();
+    assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
+    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+  }
+
+  @Test
+  @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
+  public void getSystemGroupByDefaultName_NotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.groups().id("Anonymous-Users").get();
+  }
+
+  @Test
+  public void groupName() throws Exception {
     String name = name("group");
     gApi.groups().create(name);
 
@@ -244,7 +284,7 @@
   }
 
   @Test
-  public void testGroupRename() throws Exception {
+  public void groupRename() throws Exception {
     String name = name("group");
     gApi.groups().create(name);
 
@@ -259,7 +299,7 @@
   }
 
   @Test
-  public void testGroupDescription() throws Exception {
+  public void groupDescription() throws Exception {
     String name = name("group");
     gApi.groups().create(name);
 
@@ -281,7 +321,7 @@
   }
 
   @Test
-  public void testGroupOptions() throws Exception {
+  public void groupOptions() throws Exception {
     String name = name("group");
     gApi.groups().create(name);
 
@@ -296,25 +336,22 @@
   }
 
   @Test
-  public void testGroupOwner() throws Exception {
+  public void groupOwner() throws Exception {
     String name = name("group");
     GroupInfo info = gApi.groups().create(name).get();
     String adminUUID = getFromCache("Administrators").getGroupUUID().get();
     String registeredUUID = SystemGroupBackend.REGISTERED_USERS.get();
 
     // get owner
-    assertThat(Url.decode(gApi.groups().id(name).owner().id))
-        .isEqualTo(info.id);
+    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(info.id);
 
     // set owner by name
     gApi.groups().id(name).owner("Registered Users");
-    assertThat(Url.decode(gApi.groups().id(name).owner().id))
-        .isEqualTo(registeredUUID);
+    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(registeredUUID);
 
     // set owner by UUID
     gApi.groups().id(name).owner(adminUUID);
-    assertThat(Url.decode(gApi.groups().id(name).owner().id))
-        .isEqualTo(adminUUID);
+    assertThat(Url.decode(gApi.groups().id(name).owner().id)).isEqualTo(adminUUID);
 
     // set non existing owner
     exception.expect(UnprocessableEntityException.class);
@@ -405,27 +442,21 @@
   @Test
   public void defaultGroupsCreated() throws Exception {
     Iterable<String> names = gApi.groups().list().getAsMap().keySet();
-    assertThat(names).containsAllOf("Administrators", "Non-Interactive Users")
+    assertThat(names).containsAllOf("Administrators", "Non-Interactive Users").inOrder();
+  }
+
+  @Test
+  public void listAllGroups() throws Exception {
+    List<String> expectedGroups =
+        groupCache.all().stream().map(a -> a.getName()).sorted().collect(toList());
+    assertThat(expectedGroups.size()).isAtLeast(2);
+    assertThat(gApi.groups().list().getAsMap().keySet())
+        .containsExactlyElementsIn(expectedGroups)
         .inOrder();
   }
 
   @Test
-  public void testListAllGroups() throws Exception {
-    List<String> expectedGroups = FluentIterable
-          .from(groupCache.all())
-          .transform(new Function<AccountGroup, String>() {
-            @Override
-            public String apply(AccountGroup group) {
-              return group.getName();
-            }
-          }).toSortedList(Ordering.natural());
-    assertThat(expectedGroups.size()).isAtLeast(2);
-    assertThat(gApi.groups().list().getAsMap().keySet())
-        .containsExactlyElementsIn(expectedGroups).inOrder();
-  }
-
-  @Test
-  public void testOnlyVisibleGroupsReturned() throws Exception {
+  public void onlyVisibleGroupsReturned() throws Exception {
     String newGroupName = name("newGroup");
     GroupInput in = new GroupInput();
     in.name = newGroupName;
@@ -435,8 +466,7 @@
     gApi.groups().create(in);
 
     setApiUser(user);
-    assertThat(gApi.groups().list().getAsMap())
-        .doesNotContainKey(newGroupName);
+    assertThat(gApi.groups().list().getAsMap()).doesNotContainKey(newGroupName);
 
     setApiUser(admin);
     gApi.groups().id(newGroupName).addMembers(user.username);
@@ -446,17 +476,42 @@
   }
 
   @Test
-  public void testSuggestGroup() throws Exception {
+  public void suggestGroup() throws Exception {
     Map<String, GroupInfo> groups = gApi.groups().list().withSuggest("adm").getAsMap();
     assertThat(groups).containsKey("Administrators");
     assertThat(groups).hasSize(1);
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withSubstring("foo"));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withUser("user"));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withOwned(true));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withVisibleToAll(true));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withStart(1));
   }
 
   @Test
-  public void testAllGroupInfoFieldsSetCorrectly() throws Exception {
+  public void withSubstring() throws Exception {
+    Map<String, GroupInfo> groups = gApi.groups().list().withSubstring("dmin").getAsMap();
+    assertThat(groups).containsKey("Administrators");
+    assertThat(groups).hasSize(1);
+
+    groups = gApi.groups().list().withSubstring("admin").getAsMap();
+    assertThat(groups).containsKey("Administrators");
+    assertThat(groups).hasSize(1);
+
+    String other = name("Administrators");
+    gApi.groups().create(other);
+    groups = gApi.groups().list().withSubstring("dmin").getAsMap();
+    assertThat(groups).hasSize(2);
+    assertThat(groups).containsKey("Administrators");
+    assertThat(groups).containsKey(other);
+
+    groups = gApi.groups().list().withSubstring("foo").getAsMap();
+    assertThat(groups).isEmpty();
+  }
+
+  @Test
+  public void allGroupInfoFieldsSetCorrectly() throws Exception {
     AccountGroup adminGroup = getFromCache("Administrators");
-    Map<String, GroupInfo> groups =
-        gApi.groups().list().addGroup(adminGroup.getName()).getAsMap();
+    Map<String, GroupInfo> groups = gApi.groups().list().addGroup(adminGroup.getName()).getAsMap();
     assertThat(groups).hasSize(1);
     assertThat(groups).containsKey("Administrators");
     assertGroupInfo(adminGroup, Iterables.getOnlyElement(groups.values()));
@@ -500,68 +555,79 @@
     }
   }
 
-  private void assertAuditEvent(GroupAuditEventInfo info, Type expectedType,
-      Account.Id expectedUser, Account.Id expectedMember) {
+  // reindex is tested by {@link AbstractQueryGroupsTest#reindex}
+  @Test
+  public void reindexPermissions() throws Exception {
+    TestAccount groupOwner = accounts.user2();
+    GroupInput in = new GroupInput();
+    in.name = name("group");
+    in.members =
+        Collections.singleton(groupOwner).stream().map(u -> u.id.toString()).collect(toList());
+    in.visibleToAll = true;
+    GroupInfo group = gApi.groups().create(in).get();
+
+    // admin can reindex any group
+    setApiUser(admin);
+    gApi.groups().id(group.id).index();
+
+    // group owner can reindex own group (group is owned by itself)
+    setApiUser(groupOwner);
+    gApi.groups().id(group.id).index();
+
+    // user cannot reindex any group
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to index group");
+    gApi.groups().id(group.id).index();
+  }
+
+  private void assertAuditEvent(
+      GroupAuditEventInfo info,
+      Type expectedType,
+      Account.Id expectedUser,
+      Account.Id expectedMember) {
     assertThat(info.user._accountId).isEqualTo(expectedUser.get());
     assertThat(info.type).isEqualTo(expectedType);
     assertThat(info).isInstanceOf(UserMemberAuditEventInfo.class);
-    assertThat(((UserMemberAuditEventInfo) info).member._accountId).isEqualTo(
-        expectedMember.get());
+    assertThat(((UserMemberAuditEventInfo) info).member._accountId).isEqualTo(expectedMember.get());
   }
 
-  private void assertAuditEvent(GroupAuditEventInfo info, Type expectedType,
-      Account.Id expectedUser, String expectedMemberGroupName) {
+  private void assertAuditEvent(
+      GroupAuditEventInfo info,
+      Type expectedType,
+      Account.Id expectedUser,
+      String expectedMemberGroupName) {
     assertThat(info.user._accountId).isEqualTo(expectedUser.get());
     assertThat(info.type).isEqualTo(expectedType);
     assertThat(info).isInstanceOf(GroupMemberAuditEventInfo.class);
-    assertThat(((GroupMemberAuditEventInfo) info).member.name).isEqualTo(
-        expectedMemberGroupName);
+    assertThat(((GroupMemberAuditEventInfo) info).member.name).isEqualTo(expectedMemberGroupName);
   }
 
-  private void assertMembers(String group, TestAccount... expectedMembers)
-      throws Exception {
+  private void assertMembers(String group, TestAccount... expectedMembers) throws Exception {
     assertMembers(
         gApi.groups().id(group).members(),
-        TestAccount.names(expectedMembers).toArray(String.class));
-    assertAccountInfos(
-        Arrays.asList(expectedMembers),
-        gApi.groups().id(group).members());
+        TestAccount.names(expectedMembers).stream().toArray(String[]::new));
+    assertAccountInfos(Arrays.asList(expectedMembers), gApi.groups().id(group).members());
   }
 
-  private void assertMembers(Iterable<AccountInfo> members,
-      String... expectedNames) {
-    Iterable<String> memberNames = Iterables.transform(members,
-        new Function<AccountInfo, String>() {
-          @Override
-          public String apply(@Nullable AccountInfo info) {
-            return info.name;
-          }
-        });
-    assertThat(memberNames)
-        .containsExactlyElementsIn(Arrays.asList(expectedNames)).inOrder();
+  private void assertMembers(Iterable<AccountInfo> members, String... expectedNames) {
+    assertThat(Iterables.transform(members, i -> i.name))
+        .containsExactlyElementsIn(Arrays.asList(expectedNames))
+        .inOrder();
   }
 
   private void assertNoMembers(String group) throws Exception {
     assertThat(gApi.groups().id(group).members()).isEmpty();
   }
 
-  private void assertIncludes(String group, String... expectedNames)
-      throws Exception {
+  private void assertIncludes(String group, String... expectedNames) throws Exception {
     assertIncludes(gApi.groups().id(group).includedGroups(), expectedNames);
   }
 
-  private static void assertIncludes(
-      Iterable<GroupInfo> includes, String... expectedNames) {
-    Iterable<String> includeNames = Iterables.transform(
-        includes,
-        new Function<GroupInfo, String>() {
-          @Override
-          public String apply(@Nullable GroupInfo info) {
-            return info.name;
-          }
-        });
-    assertThat(includeNames)
-        .containsExactlyElementsIn(Arrays.asList(expectedNames)).inOrder();
+  private static void assertIncludes(Iterable<GroupInfo> includes, String... expectedNames) {
+    assertThat(Iterables.transform(includes, i -> i.name))
+        .containsExactlyElementsIn(Arrays.asList(expectedNames))
+        .inOrder();
   }
 
   private void assertNoIncludes(String group) throws Exception {
@@ -577,4 +643,13 @@
     accounts.create(name, group);
     return name;
   }
+
+  private void assertBadRequest(Groups.ListRequest req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException e) {
+      // Expected
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUCK
deleted file mode 100644
index 0b293f3..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'api_project',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD
index 4fb65ff..8be3101 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/BUILD
@@ -1,7 +1,7 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'api_project',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
+    srcs = glob(["*IT.java"]),
+    group = "api_project",
+    labels = ["api"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 6892893..56bf554 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -16,8 +16,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
+import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_PARENT;
+import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_GLOBAL;
+import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_PARENT;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -28,46 +33,34 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 @NoHttpd
-public class ProjectIT extends AbstractDaemonTest  {
+public class ProjectIT extends AbstractDaemonTest {
 
   @Test
   public void createProject() throws Exception {
     String name = name("foo");
-    assertThat(name).isEqualTo(
-        gApi.projects()
-            .create(name)
-            .get()
-            .name);
+    assertThat(name).isEqualTo(gApi.projects().create(name).get().name);
 
     RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG,
-        null, head);
 
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master",
-        new String[]{});
+    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
+    eventRecorder.assertNoRefUpdatedEvents(name, "refs/heads/master");
   }
 
   @Test
   public void createProjectWithGitSuffix() throws Exception {
     String name = name("foo");
-    assertThat(name).isEqualTo(
-        gApi.projects()
-            .create(name + ".git")
-            .get()
-            .name);
+    assertThat(name).isEqualTo(gApi.projects().create(name + ".git").get().name);
 
     RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG,
-        null, head);
 
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master",
-        new String[]{});
+    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
+    eventRecorder.assertNoRefUpdatedEvents(name, "refs/heads/master");
   }
 
   @Test
@@ -76,19 +69,13 @@
     ProjectInput input = new ProjectInput();
     input.name = name;
     input.createEmptyCommit = true;
-    assertThat(name).isEqualTo(
-        gApi.projects()
-            .create(input)
-            .get()
-            .name);
+    assertThat(name).isEqualTo(gApi.projects().create(input).get().name);
 
     RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG,
-        null, head);
+    eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
 
     head = getRemoteHead(name, "refs/heads/master");
-    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master",
-        null, head);
+    eventRecorder.assertRefUpdatedEvents(name, "refs/heads/master", null, head);
   }
 
   @Test
@@ -97,9 +84,7 @@
     in.name = name("foo");
     exception.expect(BadRequestException.class);
     exception.expectMessage("name must match input.name");
-    gApi.projects()
-        .name("bar")
-        .create(in);
+    gApi.projects().name("bar").create(in);
   }
 
   @Test
@@ -107,68 +92,247 @@
     ProjectInput in = new ProjectInput();
     exception.expect(BadRequestException.class);
     exception.expectMessage("input.name is required");
-    gApi.projects()
-        .create(in);
+    gApi.projects().create(in);
   }
 
   @Test
   public void createProjectDuplicate() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("baz");
-    gApi.projects()
-        .create(in);
+    gApi.projects().create(in);
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("Project already exists");
-    gApi.projects()
-        .create(in);
+    gApi.projects().create(in);
   }
 
   @Test
   public void createBranch() throws Exception {
     allow(Permission.READ, ANONYMOUS_USERS, "refs/*");
-    gApi.projects()
-        .name(project.get())
-        .branch("foo")
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
   }
 
   @Test
   public void description() throws Exception {
     RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
-    assertThat(gApi.projects()
-            .name(project.get())
-            .description())
-        .isEmpty();
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
     DescriptionInput in = new DescriptionInput();
     in.description = "new project description";
-    gApi.projects()
-        .name(project.get())
-        .description(in);
-    assertThat(gApi.projects()
-            .name(project.get())
-            .description())
-        .isEqualTo(in.description);
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
 
     RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(project.get(), RefNames.REFS_CONFIG,
-        initialHead, updatedHead);
+    eventRecorder.assertRefUpdatedEvents(
+        project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
 
   @Test
-  public void config() throws Exception {
+  public void submitType() throws Exception {
     RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
 
-    ConfigInfo info = gApi.projects().name(project.get()).config();
+    ConfigInfo info = getConfig();
     assertThat(info.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
     ConfigInput input = new ConfigInput();
     input.submitType = SubmitType.CHERRY_PICK;
-    info = gApi.projects().name(project.get()).config(input);
+    info = setConfig(input);
     assertThat(info.submitType).isEqualTo(SubmitType.CHERRY_PICK);
-    info = gApi.projects().name(project.get()).config();
+    info = getConfig();
     assertThat(info.submitType).isEqualTo(SubmitType.CHERRY_PICK);
 
     RevCommit updatedHead = getRemoteHead(project, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(project.get(), RefNames.REFS_CONFIG,
-        initialHead, updatedHead);
+    eventRecorder.assertRefUpdatedEvents(
+        project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
+  }
+
+  @Test
+  public void maxObjectSizeIsNotSetByDefault() throws Exception {
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeCanBeSetAndCleared() throws Exception {
+    // Set a value
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    // Clear the value
+    info = setMaxObjectSize("0");
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeIsInheritedFromParentProject() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary)
+        .isEqualTo(String.format(INHERITED_FROM_PARENT, project));
+  }
+
+  @Test
+  public void maxObjectSizeIsNotInheritedFromParentProject() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeOverridesParentProjectWhenNotSetOnParent() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("0");
+    assertThat(info.maxObjectSizeLimit.value).isNull();
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  public void maxObjectSizeOverridesParentProjectWhenLower() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeDoesNotOverrideParentProjectWhenHigher() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary)
+        .isEqualTo(String.format(OVERRIDDEN_BY_PARENT, project));
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeIsInheritedFromGlobalConfig() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = getConfig();
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(INHERITED_FROM_GLOBAL);
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(INHERITED_FROM_GLOBAL);
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  public void maxObjectSizeOverridesGlobalConfigWhenLower() throws Exception {
+    ConfigInfo info = setMaxObjectSize("100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "300k")
+  public void inheritedMaxObjectSizeOverridesGlobalConfigWhenLower() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("200k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("200k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+
+    info = setMaxObjectSize(child, "100k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("102400");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("100k");
+    assertThat(info.maxObjectSizeLimit.summary).isNull();
+  }
+
+  @Test
+  @GerritConfig(name = "receive.maxObjectSizeLimit", value = "200k")
+  @GerritConfig(name = "receive.inheritProjectMaxObjectSizeLimit", value = "true")
+  public void maxObjectSizeDoesNotOverrideGlobalConfigWhenHigher() throws Exception {
+    Project.NameKey child = createProject(name("child"), project);
+
+    ConfigInfo info = setMaxObjectSize("300k");
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo("300k");
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(OVERRIDDEN_BY_GLOBAL);
+
+    info = getConfig(child);
+    assertThat(info.maxObjectSizeLimit.value).isEqualTo("204800");
+    assertThat(info.maxObjectSizeLimit.configuredValue).isNull();
+    assertThat(info.maxObjectSizeLimit.summary).isEqualTo(OVERRIDDEN_BY_GLOBAL);
+  }
+
+  @Test
+  public void invalidMaxObjectSizeIsRejected() throws Exception {
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("100 foo");
+    setMaxObjectSize("100 foo");
+  }
+
+  private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
+    return gApi.projects().name(name.get()).config(input);
+  }
+
+  private ConfigInfo setConfig(ConfigInput input) throws Exception {
+    return setConfig(project, input);
+  }
+
+  private ConfigInfo setMaxObjectSize(String value) throws Exception {
+    return setMaxObjectSize(project, value);
+  }
+
+  private ConfigInfo setMaxObjectSize(Project.NameKey name, String value) throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.maxObjectSizeLimit = value;
+    return setConfig(name, input);
+  }
+
+  private ConfigInfo getConfig(Project.NameKey name) throws Exception {
+    return gApi.projects().name(name.get()).config();
+  }
+
+  private ConfigInfo getConfig() throws Exception {
+    return getConfig(project);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUCK
deleted file mode 100644
index 76ae637..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'api_revision',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD
index e527b9d..4f15ec0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/BUILD
@@ -1,7 +1,7 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'api_revision',
-  srcs = glob(['*IT.java']),
-  labels = ['api'],
+    srcs = glob(["*IT.java"]),
+    group = "api_revision",
+    labels = ["api"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index ee2dbfe..3f7a7e5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -18,19 +18,26 @@
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
+import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
+import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Predicate;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Iterators;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -38,56 +45,57 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ETagView;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.change.GetRevisionActions;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.io.ByteArrayOutputStream;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
 
-  @Inject
-  private GetRevisionActions getRevisionActions;
-
-  private TestAccount admin2;
-
-  @Before
-  public void setUp() throws Exception {
-    admin2 = accounts.admin2();
-  }
+  @Inject private GetRevisionActions getRevisionActions;
 
   @Test
   public void reviewTriplet() throws Exception {
@@ -101,114 +109,173 @@
   @Test
   public void reviewCurrent() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .current()
-        .review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
   }
 
   @Test
   public void reviewNumber() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(1)
-        .review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(1).review(ReviewInput.approve());
 
     r = updateChange(r, "new content");
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(2)
-        .review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(2).review(ReviewInput.approve());
   }
 
   @Test
   public void submit() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .review(ReviewInput.approve());
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .submit();
-    assertThat(gApi.changes().id(changeId).get().status)
-        .isEqualTo(ChangeStatus.MERGED);
-  }
-
-  private void allowSubmitOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg,
-        Permission.SUBMIT_AS,
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
-        "refs/heads/*");
-    saveProjectConfig(project, cfg);
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId).current().submit();
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
   }
 
   @Test
-  public void submitOnBehalfOf() throws Exception {
-    allowSubmitOnBehalfOf();
+  public void postSubmitApproval() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .submit(in);
-    assertThat(gApi.changes().id(changeId).get().status)
-        .isEqualTo(ChangeStatus.MERGED);
+    gApi.changes().id(changeId).current().review(ReviewInput.recommend());
+
+    String label = "Code-Review";
+    ApprovalInfo approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Submit by direct push.
+    git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
+
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+    assertPermitted(
+        gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)), "Code-Review", 1, 2);
+
+    // Repeating the current label is allowed. Does not flip the postSubmit bit
+    // due to deduplication codepath.
+    gApi.changes().id(changeId).current().review(ReviewInput.recommend());
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Reducing vote is not allowed.
+    try {
+      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+      fail("expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
+    }
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(1);
+    assertThat(approval.postSubmit).isNull();
+
+    // Increasing vote is allowed.
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(2);
+    assertThat(approval.postSubmit).isTrue();
+    assertPermitted(gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)), "Code-Review", 2);
+
+    // Decreasing to previous post-submit vote is still not allowed.
+    try {
+      gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+      fail("expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
+    }
+    approval = getApproval(changeId, label);
+    assertThat(approval.value).isEqualTo(2);
+    assertThat(approval.postSubmit).isTrue();
   }
 
   @Test
-  public void submitOnBehalfOfInvalidUser() throws Exception {
-    allowSubmitOnBehalfOf();
+  public void postSubmitApprovalAfterVoteRemoved() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = "doesnotexist";
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account Not Found: doesnotexist");
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .submit(in);
+
+    setApiUser(admin);
+    revision(r).review(ReviewInput.approve());
+
+    setApiUser(user);
+    revision(r).review(ReviewInput.recommend());
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).reviewer(user.username).deleteVote("Code-Review");
+    Optional<ApprovalInfo> crUser =
+        get(changeId, DETAILED_LABELS).labels.get("Code-Review").all.stream()
+            .filter(a -> a._accountId == user.id.get())
+            .findFirst();
+    assertThat(crUser.isPresent()).isTrue();
+    assertThat(crUser.get().value).isEqualTo(0);
+
+    revision(r).submit();
+
+    setApiUser(user);
+    ReviewInput in = new ReviewInput();
+    in.label("Code-Review", 1);
+    in.message = "Still LGTM";
+    revision(r).review(in);
+
+    ApprovalInfo cr =
+        gApi.changes().id(changeId).get(EnumSet.of(ListChangesOption.DETAILED_LABELS)).labels
+            .get("Code-Review").all.stream()
+            .filter(a -> a._accountId == user.getId().get())
+            .findFirst()
+            .get();
+    assertThat(cr.postSubmit).isTrue();
   }
 
   @Test
-  public void submitOnBehalfOfNotPermitted() throws Exception {
+  public void postSubmitDeleteApprovalNotAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of not permitted");
-    gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .current()
-        .submit(in);
+
+    revision(r).review(ReviewInput.approve());
+    revision(r).submit();
+
+    ReviewInput in = new ReviewInput();
+    in.label("Code-Review", 0);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Cannot reduce vote on labels for closed change: Code-Review");
+    revision(r).review(in);
+  }
+
+  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
+  @Test
+  public void approvalCopiedDuringSubmitIsNotPostSubmit() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+
+    ChangeData cd = r.getChange();
+    assertThat(cd.patchSets()).hasSize(2);
+    PatchSetApproval psa =
+        Iterators.getOnlyElement(
+            cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(2);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getValue()).isEqualTo(2);
+    assertThat(psa.isPostSubmit()).isFalse();
+  }
+
+  @Test
+  public void voteOnAbandonedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).abandon();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is closed");
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
   }
 
   @Test
   public void deleteDraft() throws Exception {
     PushOneCommit.Result r = createDraft();
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .delete();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).delete();
   }
 
   @Test
@@ -217,27 +284,22 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = "it goes to stable branch";
-    gApi.projects()
-        .name(project.get())
-        .branch(in.destination)
-        .create(new BranchInput());
-    ChangeApi orig = gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId());
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
 
     assertThat(orig.get().messages).hasSize(1);
-    ChangeApi cherry = orig.revision(r.getCommit().name())
-        .cherryPick(in);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
 
-    Collection<ChangeMessageInfo> messages = gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .get().messages;
+    Collection<ChangeMessageInfo> messages =
+        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
     assertThat(messages).hasSize(2);
 
     String cherryPickedRevision = cherry.get().currentRevision;
-    String expectedMessage = String.format(
-        "Patch Set 1: Cherry Picked\n\n" +
-        "This patchset was cherry picked to branch %s as commit %s",
-        in.destination, cherryPickedRevision);
+    String expectedMessage =
+        String.format(
+            "Patch Set 1: Cherry Picked\n\n"
+                + "This patchset was cherry picked to branch %s as commit %s",
+            in.destination, cherryPickedRevision);
 
     Iterator<ChangeMessageInfo> origIt = messages.iterator();
     origIt.next();
@@ -260,15 +322,10 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = "it goes to stable branch";
-    gApi.projects()
-        .name(project.get())
-        .branch(in.destination)
-        .create(new BranchInput());
-    ChangeApi orig = gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId());
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
 
-    ChangeApi cherry = orig.revision(r.getCommit().name())
-        .cherryPick(in);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
     assertThat(cherry.get().topic).isNull();
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
@@ -280,11 +337,12 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "master";
     in.message = "it generates a new patch set\n\nChange-Id: " + r.getChangeId();
-    ChangeInfo cherryInfo = gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .revision(r.getCommit().name())
-        .cherryPick(in)
-        .get();
+    ChangeInfo cherryInfo =
+        gApi.changes()
+            .id(project.get() + "~master~" + r.getChangeId())
+            .revision(r.getCommit().name())
+            .cherryPick(in)
+            .get();
     assertThat(cherryInfo.messages).hasSize(2);
     Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
@@ -304,16 +362,14 @@
     PushOneCommit.Result r1 = createChange();
 
     // Push another new change (change 2)
-    String subject = "Test change\n\n" +
-        "Change-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String subject = "Test change\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, subject,
-            "another_file.txt", "another content");
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, subject, "another_file.txt", "another content");
     PushOneCommit.Result r2 = push.to("refs/for/master");
 
     // Change 2's parent should be change 1
-    assertThat(r2.getCommit().getParents()[0].name())
-      .isEqualTo(r1.getCommit().name());
+    assertThat(r2.getCommit().getParents()[0].name()).isEqualTo(r1.getCommit().name());
 
     // Cherry pick change 2 onto the same branch
     triplet = project.get() + "~master~" + r2.getChangeId();
@@ -330,8 +386,8 @@
 
     // Parent of change 2 should now be the change that was merged, i.e.
     // change 2 is rebased onto the head of the master branch.
-    String newParent = cherryInfo.revisions.get(cherryInfo.currentRevision)
-        .commit.parents.get(0).commit;
+    String newParent =
+        cherryInfo.revisions.get(cherryInfo.currentRevision).commit.parents.get(0).commit;
     assertThat(newParent).isEqualTo(baseChange.getCommit().name());
   }
 
@@ -341,20 +397,14 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = "it goes to stable branch";
-    gApi.projects()
-        .name(project.get())
-        .branch(in.destination)
-        .create(new BranchInput());
-    ChangeApi orig = gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId());
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
 
     assertThat(orig.get().messages).hasSize(1);
-    ChangeApi cherry = orig.revision(r.getCommit().name())
-        .cherryPick(in);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
 
-    Collection<ChangeMessageInfo> messages = gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .get().messages;
+    Collection<ChangeMessageInfo> messages =
+        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
     assertThat(messages).hasSize(2);
 
     assertThat(cherry.get().subject).contains(in.message);
@@ -372,14 +422,16 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = "it goes to stable branch";
-    gApi.projects()
-        .name(project.get())
-        .branch(in.destination)
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
 
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME, "another content");
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "another content");
     push.to("refs/heads/foo");
 
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -393,22 +445,20 @@
 
   @Test
   public void cherryPickToExistingChange() throws Exception {
-    PushOneCommit.Result r1 = pushFactory.create(
-          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "a")
-        .to("refs/for/master");
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "a")
+            .to("refs/for/master");
     String t1 = project.get() + "~master~" + r1.getChangeId();
 
     BranchInput bin = new BranchInput();
     bin.revision = r1.getCommit().getParent(0).name();
-    gApi.projects()
-        .name(project.get())
-        .branch("foo")
-        .create(bin);
+    gApi.projects().name(project.get()).branch("foo").create(bin);
 
-    PushOneCommit.Result r2 = pushFactory.create(
-          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "b",
-          r1.getChangeId())
-        .to("refs/for/foo");
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
+            .to("refs/for/foo");
     String t2 = project.get() + "~foo~" + r2.getChangeId();
     gApi.changes().id(t2).abandon();
 
@@ -419,17 +469,114 @@
       gApi.changes().id(t1).current().cherryPick(in);
       fail();
     } catch (ResourceConflictException e) {
-      assertThat(e.getMessage()).isEqualTo(
-          "Cannot create new patch set of change " + info(t2)._number
-          + " because it is abandoned");
+      assertThat(e.getMessage())
+          .isEqualTo(
+              "Cannot create new patch set of change "
+                  + info(t2)._number
+                  + " because it is abandoned");
     }
 
     gApi.changes().id(t2).restore();
     gApi.changes().id(t1).current().cherryPick(in);
     assertThat(get(t2).revisions).hasSize(2);
-    assertThat(
-          gApi.changes().id(t2).current().file(FILE_NAME).content().asString())
-        .isEqualTo("a");
+    assertThat(gApi.changes().id(t2).current().file(FILE_NAME).content().asString()).isEqualTo("a");
+  }
+
+  @Test
+  public void cherryPickMergeRelativeToDefaultParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+
+    ChangeInfo cherryPickedChangeInfo =
+        gApi.changes()
+            .id(mergeChangeResult.getChangeId())
+            .current()
+            .cherryPick(cherryPickInput)
+            .get();
+
+    Map<String, FileInfo> cherryPickedFilesByName =
+        cherryPickedChangeInfo.revisions.get(cherryPickedChangeInfo.currentRevision).files;
+    assertThat(cherryPickedFilesByName).containsKey(parent2FileName);
+    assertThat(cherryPickedFilesByName).doesNotContainKey(parent1FileName);
+  }
+
+  @Test
+  public void cherryPickMergeRelativeToSpecificParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 2;
+
+    ChangeInfo cherryPickedChangeInfo =
+        gApi.changes()
+            .id(mergeChangeResult.getChangeId())
+            .current()
+            .cherryPick(cherryPickInput)
+            .get();
+
+    Map<String, FileInfo> cherryPickedFilesByName =
+        cherryPickedChangeInfo.revisions.get(cherryPickedChangeInfo.currentRevision).files;
+    assertThat(cherryPickedFilesByName).containsKey(parent1FileName);
+    assertThat(cherryPickedFilesByName).doesNotContainKey(parent2FileName);
+  }
+
+  @Test
+  public void cherryPickMergeUsingInvalidParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 0;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
+    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
+  }
+
+  @Test
+  public void cherryPickMergeUsingNonExistentParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 3;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
+    gApi.changes().id(mergeChangeResult.getChangeId()).current().cherryPick(cherryPickInput);
   }
 
   @Test
@@ -440,10 +587,8 @@
 
     push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r2 = push.to("refs/for/master");
-    boolean canRebase = gApi.changes()
-        .id(r2.getChangeId())
-        .revision(r2.getCommit().name())
-        .canRebase();
+    boolean canRebase =
+        gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).canRebase();
     assertThat(canRebase).isFalse();
     merge(r2);
 
@@ -451,10 +596,7 @@
     push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r3 = push.to("refs/for/master");
 
-    canRebase = gApi.changes()
-        .id(r3.getChangeId())
-        .revision(r3.getCommit().name())
-        .canRebase();
+    canRebase = gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).canRebase();
     assertThat(canRebase).isTrue();
   }
 
@@ -463,24 +605,14 @@
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
 
-    gApi.changes()
-        .id(r.getChangeId())
-        .current()
-        .setReviewed(PushOneCommit.FILE_NAME, true);
+    gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);
 
-    assertThat(Iterables.getOnlyElement(
-            gApi.changes()
-                .id(r.getChangeId())
-                .current()
-                .reviewed())).isEqualTo(PushOneCommit.FILE_NAME);
+    assertThat(Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().reviewed()))
+        .isEqualTo(PushOneCommit.FILE_NAME);
 
-    gApi.changes()
-        .id(r.getChangeId())
-        .current()
-        .setReviewed(PushOneCommit.FILE_NAME, false);
+    gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, false);
 
-    assertThat(gApi.changes().id(r.getChangeId()).current().reviewed())
-        .isEmpty();
+    assertThat(gApi.changes().id(r.getChangeId()).current().reviewed()).isEmpty();
   }
 
   @Test
@@ -488,8 +620,13 @@
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
     PushOneCommit push1 =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME, "push 1 content");
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "push 1 content");
 
     PushOneCommit.Result r1 = push1.to("refs/for/master");
     assertMergeable(r1.getChangeId(), true);
@@ -501,8 +638,13 @@
     assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
 
     PushOneCommit push2 =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME, "push 2 content");
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "push 2 content");
     PushOneCommit.Result r2 = push2.to("refs/for/master");
     assertMergeable(r2.getChangeId(), false);
     // TODO(dborowitz): Test for other-branches.
@@ -511,17 +653,11 @@
   @Test
   public void files() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(Iterables.all(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .files()
-        .keySet(), new Predicate<String>() {
-            @Override
-            public boolean apply(String file) {
-              return file.matches(FILE_NAME + '|' + Patch.COMMIT_MSG);
-            }
-         }))
-      .isTrue();
+    Map<String, FileInfo> files =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files();
+    assertThat(files).hasSize(2);
+    assertThat(Iterables.all(files.keySet(), f -> f.matches(FILE_NAME + '|' + COMMIT_MSG)))
+        .isTrue();
   }
 
   @Test
@@ -529,40 +665,34 @@
     PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
 
     // list files against auto-merge
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .files()
-        .keySet()
-      ).containsExactly(Patch.COMMIT_MSG, "foo", "bar");
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files().keySet())
+        .containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
 
     // list files against parent 1
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .files(1)
-        .keySet()
-      ).containsExactly(Patch.COMMIT_MSG, "bar");
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(1).keySet())
+        .containsExactly(COMMIT_MSG, MERGE_LIST, "bar");
 
     // list files against parent 2
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .files(2)
-        .keySet()
-      ).containsExactly(Patch.COMMIT_MSG, "foo");
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(2).keySet())
+        .containsExactly(COMMIT_MSG, MERGE_LIST, "foo");
   }
 
   @Test
   public void diff() throws Exception {
     PushOneCommit.Result r = createChange();
-    DiffInfo diff = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .file(FILE_NAME)
-        .diff();
-    assertThat(diff.metaA).isNull();
-    assertThat(diff.metaB.lines).isEqualTo(1);
+    assertDiffForNewFile(r, FILE_NAME, FILE_CONTENT);
+    assertDiffForNewFile(r, COMMIT_MSG, r.getCommit().getFullMessage());
+  }
+
+  @Test
+  public void diffDeletedFile() throws Exception {
+    pushFactory.create(db, admin.getIdent(), testRepo).to("refs/heads/master");
+    PushOneCommit.Result r =
+        pushFactory.create(db, admin.getIdent(), testRepo).rm("refs/for/master");
+    DiffInfo diff =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file(FILE_NAME).diff();
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB).isNull();
   }
 
   @Test
@@ -572,63 +702,57 @@
     DiffInfo diff;
 
     // automerge
-    diff = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .file("foo")
-        .diff();
+    diff = gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file("foo").diff();
     assertThat(diff.metaA.lines).isEqualTo(5);
     assertThat(diff.metaB.lines).isEqualTo(1);
 
-    diff = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .file("bar")
-        .diff();
+    diff = gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file("bar").diff();
     assertThat(diff.metaA.lines).isEqualTo(5);
     assertThat(diff.metaB.lines).isEqualTo(1);
 
     // parent 1
-    diff = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .file("bar")
-        .diff(1);
+    diff = gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file("bar").diff(1);
     assertThat(diff.metaA.lines).isEqualTo(1);
     assertThat(diff.metaB.lines).isEqualTo(1);
 
     // parent 2
-    diff = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .file("foo")
-        .diff(2);
+    diff = gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file("foo").diff(2);
     assertThat(diff.metaA.lines).isEqualTo(1);
     assertThat(diff.metaB.lines).isEqualTo(1);
   }
 
   @Test
+  public void description() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
+        .isEqualTo("");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
+        .isEqualTo("test");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("");
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
+        .isEqualTo("");
+  }
+
+  @Test
   public void content() throws Exception {
     PushOneCommit.Result r = createChange();
-    BinaryResult bin = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .file(FILE_NAME)
-        .content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String res = new String(os.toByteArray(), UTF_8);
-    assertThat(res).isEqualTo(FILE_CONTENT);
+    assertContent(r, FILE_NAME, FILE_CONTENT);
+    assertContent(r, COMMIT_MSG, r.getCommit().getFullMessage());
   }
 
   @Test
   public void contentType() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    String endPoint = "/changes/" + r.getChangeId()
-      + "/revisions/" + r.getCommit().name()
-      + "/files/" + FILE_NAME
-      + "/content";
+    String endPoint =
+        "/changes/"
+            + r.getChangeId()
+            + "/revisions/"
+            + r.getCommit().name()
+            + "/files/"
+            + FILE_NAME
+            + "/content";
     RestResponse response = adminRestSession.head(endPoint);
     response.assertOK();
     assertThat(response.getContentType()).startsWith("text/plain");
@@ -652,51 +776,43 @@
     in.message = "nit: trailing whitespace";
     in.path = FILE_NAME;
 
-    DraftApi draftApi = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .createDraft(in);
-    assertThat(draftApi
-        .get()
-        .message)
-      .isEqualTo(in.message);
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .draft(draftApi.get().id)
-        .get()
-        .message)
-      .isEqualTo(in.message);
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .drafts())
-      .hasSize(1);
+    DraftApi draftApi =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).createDraft(in);
+    assertThat(draftApi.get().message).isEqualTo(in.message);
+    assertThat(
+            gApi.changes()
+                .id(r.getChangeId())
+                .revision(r.getCommit().name())
+                .draft(draftApi.get().id)
+                .get()
+                .message)
+        .isEqualTo(in.message);
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
+        .hasSize(1);
 
     in.message = "good catch!";
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .draft(draftApi.get().id)
-        .update(in)
-        .message)
-      .isEqualTo(in.message);
+    assertThat(
+            gApi.changes()
+                .id(r.getChangeId())
+                .revision(r.getCommit().name())
+                .draft(draftApi.get().id)
+                .update(in)
+                .message)
+        .isEqualTo(in.message);
 
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .draft(draftApi.get().id)
-        .get()
-        .author
-        .email)
-      .isEqualTo(admin.email);
+    assertThat(
+            gApi.changes()
+                .id(r.getChangeId())
+                .revision(r.getCommit().name())
+                .draft(draftApi.get().id)
+                .get()
+                .author
+                .email)
+        .isEqualTo(admin.email);
 
     draftApi.delete();
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .drafts())
-      .isEmpty();
+    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
+        .isEmpty();
   }
 
   @Test
@@ -711,25 +827,18 @@
     comments.put(FILE_NAME, Collections.singletonList(in));
     reviewInput.comments = comments;
     reviewInput.message = "comment test";
-    gApi.changes()
-       .id(r.getChangeId())
-       .current()
-       .review(reviewInput);
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
 
-    Map<String, List<CommentInfo>> out = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .comments();
+    Map<String, List<CommentInfo>> out =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).comments();
     assertThat(out).hasSize(1);
     CommentInfo comment = Iterables.getOnlyElement(out.get(FILE_NAME));
     assertThat(comment.message).isEqualTo(in.message);
     assertThat(comment.author.email).isEqualTo(admin.email);
     assertThat(comment.path).isNull();
 
-    List<CommentInfo> list = gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .commentsAsList();
+    List<CommentInfo> list =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).commentsAsList();
     assertThat(list).hasSize(1);
 
     CommentInfo comment2 = list.get(0);
@@ -738,49 +847,58 @@
     assertThat(comment2.message).isEqualTo(comment.message);
     assertThat(comment2.author.email).isEqualTo(comment.author.email);
 
-    assertThat(gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .comment(comment.id)
-        .get()
-        .message)
-      .isEqualTo(in.message);
+    assertThat(
+            gApi.changes()
+                .id(r.getChangeId())
+                .revision(r.getCommit().name())
+                .comment(comment.id)
+                .get()
+                .message)
+        .isEqualTo(in.message);
   }
 
   @Test
   public void patch() throws Exception {
     PushOneCommit.Result r = createChange();
-    ChangeApi changeApi = gApi.changes()
-        .id(r.getChangeId());
-    BinaryResult bin = changeApi
-        .revision(r.getCommit().name())
-        .patch();
+    ChangeApi changeApi = gApi.changes().id(r.getChangeId());
+    BinaryResult bin = changeApi.revision(r.getCommit().name()).patch();
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String res = new String(os.toByteArray(), UTF_8);
     ChangeInfo change = changeApi.get();
     RevisionInfo rev = change.revisions.get(change.currentRevision);
-    DateFormat df = new SimpleDateFormat(
-        "EEE, dd MMM yyyy HH:mm:ss Z",
-        Locale.US);
+    DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
     String date = df.format(rev.commit.author.date);
-    assertThat(res).isEqualTo(
-        String.format(PATCH, r.getCommit().name(), date, r.getChangeId()));
+    assertThat(res).isEqualTo(String.format(PATCH, r.getCommit().name(), date, r.getChangeId()));
+  }
+
+  @Test
+  public void patchWithPath() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeApi changeApi = gApi.changes().id(r.getChangeId());
+    BinaryResult bin = changeApi.revision(r.getCommit().name()).patch(FILE_NAME);
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    assertThat(res).isEqualTo(PATCH_FILE_ONLY);
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("File not found: nonexistent-file.");
+    changeApi.revision(r.getCommit().name()).patch("nonexistent-file");
   }
 
   @Test
   public void actions() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(current(r).actions().keySet())
-        .containsExactly("cherrypick", "rebase");
+        .containsExactly("cherrypick", "description", "rebase");
 
     current(r).review(ReviewInput.approve());
     assertThat(current(r).actions().keySet())
-        .containsExactly("submit", "cherrypick", "rebase");
+        .containsExactly("submit", "cherrypick", "description", "rebase");
 
     current(r).submit();
-    assertThat(current(r).actions().keySet())
-        .containsExactly("cherrypick");
+    assertThat(current(r).actions().keySet()).containsExactly("cherrypick");
   }
 
   @Test
@@ -800,10 +918,66 @@
     oldETag = checkETag(getRevisionActions, r2, oldETag);
   }
 
-  private PushOneCommit.Result updateChange(PushOneCommit.Result r,
-      String content) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "test commit", "a.txt", content, r.getChangeId());
+  @Test
+  public void deleteVoteOnNonCurrentPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange(); // patch set 1
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    // patch set 2
+    amendChange(r.getChangeId());
+
+    // code-review
+    setApiUser(user);
+    recommend(r.getChangeId());
+
+    // check if it's blocked to delete a vote on a non-current patch set.
+    setApiUser(admin);
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("Cannot access on non-current patch set");
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().getName())
+        .reviewer(user.getId().toString())
+        .deleteVote("Code-Review");
+  }
+
+  @Test
+  public void deleteVoteOnCurrentPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange(); // patch set 1
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    // patch set 2
+    amendChange(r.getChangeId());
+
+    // code-review
+    setApiUser(user);
+    recommend(r.getChangeId());
+
+    setApiUser(admin);
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(user.getId().toString())
+        .deleteVote("Code-Review");
+
+    Map<String, Short> m =
+        gApi.changes().id(r.getChangeId()).current().reviewer(user.getId().toString()).votes();
+
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    ChangeMessageInfo message = Iterables.getLast(c.messages);
+    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+  }
+
+  private PushOneCommit.Result updateChange(PushOneCommit.Result r, String content)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, "test commit", "a.txt", content, r.getChangeId());
     return push.to("refs/for/master");
   }
 
@@ -816,10 +990,109 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  private String checkETag(ETagView<RevisionResource> view,
-      PushOneCommit.Result r, String oldETag) throws Exception {
+  private String checkETag(ETagView<RevisionResource> view, PushOneCommit.Result r, String oldETag)
+      throws Exception {
     String eTag = view.getETag(parseRevisionResource(r));
     assertThat(eTag).isNotEqualTo(oldETag);
     return eTag;
   }
+
+  private void assertContent(PushOneCommit.Result pushResult, String path, String expectedContent)
+      throws Exception {
+    BinaryResult bin =
+        gApi.changes()
+            .id(pushResult.getChangeId())
+            .revision(pushResult.getCommit().name())
+            .file(path)
+            .content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    assertThat(res).isEqualTo(expectedContent);
+  }
+
+  private void assertDiffForNewFile(
+      PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
+    DiffInfo diff =
+        gApi.changes()
+            .id(pushResult.getChangeId())
+            .revision(pushResult.getCommit().name())
+            .file(path)
+            .diff();
+
+    List<String> headers = new ArrayList<>();
+    if (path.equals(COMMIT_MSG)) {
+      RevCommit c = pushResult.getCommit();
+
+      RevCommit parentCommit = c.getParents()[0];
+      String parentCommitId =
+          testRepo.getRevWalk().getObjectReader().abbreviate(parentCommit.getId(), 8).name();
+      headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
+
+      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
+      PersonIdent author = c.getAuthorIdent();
+      dtfmt.setTimeZone(author.getTimeZone());
+      headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
+      headers.add("AuthorDate: " + dtfmt.format(Long.valueOf(author.getWhen().getTime())));
+
+      PersonIdent committer = c.getCommitterIdent();
+      dtfmt.setTimeZone(committer.getTimeZone());
+      headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
+      headers.add("CommitDate: " + dtfmt.format(Long.valueOf(committer.getWhen().getTime())));
+      headers.add("");
+    }
+
+    if (!headers.isEmpty()) {
+      String header = Joiner.on("\n").join(headers);
+      expectedContentSideB = header + "\n" + expectedContentSideB;
+    }
+
+    assertDiffForNewFile(diff, pushResult.getCommit(), path, expectedContentSideB);
+  }
+
+  private PushOneCommit.Result createCherryPickableMerge(
+      String parent1FileName, String parent2FileName) throws Exception {
+    RevCommit initialCommit = getHead(repo());
+
+    String branchAName = "branchA";
+    createBranch(new Branch.NameKey(project, branchAName));
+    String branchBName = "branchB";
+    createBranch(new Branch.NameKey(project, branchBName));
+
+    PushOneCommit.Result changeAResult =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "change a", parent1FileName, "Content of a")
+            .to("refs/for/" + branchAName);
+
+    testRepo.reset(initialCommit);
+    PushOneCommit.Result changeBResult =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "change b", parent2FileName, "Content of b")
+            .to("refs/for/" + branchBName);
+
+    PushOneCommit pushableMergeCommit =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "merge",
+            ImmutableMap.of(parent1FileName, "Content of a", parent2FileName, "Content of b"));
+    pushableMergeCommit.setParents(
+        ImmutableList.of(changeAResult.getCommit(), changeBResult.getCommit()));
+    PushOneCommit.Result mergeChangeResult = pushableMergeCommit.to("refs/for/" + branchAName);
+    mergeChangeResult.assertOkStatus();
+    return mergeChangeResult;
+  }
+
+  private ApprovalInfo getApproval(String changeId, String label) throws Exception {
+    ChangeInfo info = gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS));
+    LabelInfo li = info.labels.get(label);
+    assertThat(li).isNotNull();
+    int accountId = atrScope.get().getUser().getAccountId().get();
+    return li.all.stream().filter(a -> a._accountId == accountId).findFirst().get();
+  }
+
+  private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
+    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
new file mode 100644
index 0000000..11df473
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -0,0 +1,473 @@
+// 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.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.common.RobotCommentInfoSubject.assertThatList;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.hamcrest.core.StringContains;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RobotCommentsIT extends AbstractDaemonTest {
+  private String changeId;
+  private FixReplacementInfo fixReplacementInfo;
+  private FixSuggestionInfo fixSuggestionInfo;
+  private RobotCommentInput withFixRobotCommentInput;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    changeId = changeResult.getChangeId();
+
+    fixReplacementInfo = createFixReplacementInfo();
+    fixSuggestionInfo = createFixSuggestionInfo(fixReplacementInfo);
+    withFixRobotCommentInput = createRobotCommentInput(fixSuggestionInfo);
+  }
+
+  @Test
+  public void retrievingRobotCommentsBeforeAddingAnyDoesNotRaiseAnException() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    Map<String, List<RobotCommentInfo>> robotComments =
+        gApi.changes().id(changeId).current().robotComments();
+
+    assertThat(robotComments).isNotNull();
+    assertThat(robotComments).isEmpty();
+  }
+
+  @Test
+  public void addedRobotCommentsCanBeRetrieved() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    RobotCommentInput in = createRobotCommentInput();
+    addRobotComment(changeId, in);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).current().robotComments();
+
+    assertThat(out).hasSize(1);
+    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
+    assertRobotComment(comment, in, false);
+  }
+
+  @Test
+  public void addedRobotCommentsCanBeRetrievedByChange() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    RobotCommentInput in = createRobotCommentInput();
+    addRobotComment(changeId, in);
+
+    pushFactory.create(db, admin.getIdent(), testRepo, changeId).to("refs/for/master");
+
+    RobotCommentInput in2 = createRobotCommentInput();
+    addRobotComment(changeId, in2);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).robotComments();
+
+    assertThat(out).hasSize(1);
+    assertThat(out.get(in.path)).hasSize(2);
+
+    RobotCommentInfo comment1 = out.get(in.path).get(0);
+    assertRobotComment(comment1, in, false);
+    RobotCommentInfo comment2 = out.get(in.path).get(1);
+    assertRobotComment(comment2, in2, false);
+  }
+
+  @Test
+  public void robotCommentsCanBeRetrievedAsList() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    RobotCommentInput robotCommentInput = createRobotCommentInput();
+    addRobotComment(changeId, robotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos =
+        gApi.changes().id(changeId).current().robotCommentsAsList();
+
+    assertThat(robotCommentInfos).hasSize(1);
+    RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
+    assertRobotComment(robotCommentInfo, robotCommentInput);
+  }
+
+  @Test
+  public void specificRobotCommentCanBeRetrieved() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    RobotCommentInput robotCommentInput = createRobotCommentInput();
+    addRobotComment(changeId, robotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
+
+    RobotCommentInfo specificRobotCommentInfo =
+        gApi.changes().id(changeId).current().robotComment(robotCommentInfo.id).get();
+    assertRobotComment(specificRobotCommentInfo, robotCommentInput);
+  }
+
+  @Test
+  public void robotCommentWithoutOptionalFieldsCanBeAdded() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
+    addRobotComment(changeId, in);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).current().robotComments();
+    assertThat(out).hasSize(1);
+    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
+    assertRobotComment(comment, in, false);
+  }
+
+  @Test
+  public void addedFixSuggestionCanBeRetrieved() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().isNotNull();
+  }
+
+  @Test
+  public void fixIdIsGeneratedForFixSuggestion() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().fixId().isNotEmpty();
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .fixId()
+        .isNotEqualTo(fixSuggestionInfo.fixId);
+  }
+
+  @Test
+  public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .description()
+        .isEqualTo(fixSuggestionInfo.description);
+  }
+
+  @Test
+  public void descriptionOfFixSuggestionIsMandatory() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixSuggestionInfo.description = null;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "A description is required for the suggested fix of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void addedFixReplacementCanBeRetrieved() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .isNotNull();
+  }
+
+  @Test
+  public void fixReplacementsAreMandatory() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixSuggestionInfo.replacements = Collections.emptyList();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "At least one replacement is required"
+                + " for the suggested fix of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .path()
+        .isEqualTo(fixReplacementInfo.path);
+  }
+
+  @Test
+  public void pathOfFixReplacementIsMandatory() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = null;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "A file path must be given for the replacement of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void pathOfFixReplacementMustReferToFileOfComment() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.path = "anotherFile.txt";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "Replacements may only be specified "
+                + "for the file %s on which the robot comment was added",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .range()
+        .isEqualTo(fixReplacementInfo.range);
+  }
+
+  @Test
+  public void rangeOfFixReplacementIsMandatory() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.range = null;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "A range must be given for the replacement of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.range = createRange(13, 9, 5, 10);
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(new StringContains("Range (13:9 - 5:10)"));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    assertThatList(robotCommentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .replacement()
+        .isEqualTo(fixReplacementInfo.replacement);
+  }
+
+  @Test
+  public void replacementStringOfFixReplacementIsMandatory() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    fixReplacementInfo.replacement = null;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format(
+            "A content for replacement must be "
+                + "indicated for the replacement of the robot comment on %s",
+            withFixRobotCommentInput.path));
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void robotCommentsNotSupportedWithoutNoteDb() throws Exception {
+    assume().that(notesMigration.enabled()).isFalse();
+
+    RobotCommentInput in = createRobotCommentInput();
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
+    robotComments.put(FILE_NAME, Collections.singletonList(in));
+    reviewInput.robotComments = robotComments;
+    reviewInput.message = "comment test";
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("robot comments not supported");
+    gApi.changes().id(changeId).current().review(reviewInput);
+  }
+
+  @Test
+  public void queryChangesWithUnresolvedCommentCount() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .to("refs/for/master");
+
+    addRobotComment(r2.getChangeId(), createRobotCommentInputWithMandatoryFields());
+
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      ChangeInfo result = Iterables.getOnlyElement(query(r2.getChangeId()));
+      // currently, we create all robot comments as 'resolved' by default.
+      // if we allow users to resolve a robot comment, then this test should
+      // be modified.
+      assertThat(result.unresolvedCommentCount).isEqualTo(0);
+    } finally {
+      enableDb(ctx);
+    }
+  }
+
+  private RobotCommentInput createRobotCommentInputWithMandatoryFields() {
+    RobotCommentInput in = new RobotCommentInput();
+    in.robotId = "happyRobot";
+    in.robotRunId = "1";
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = FILE_NAME;
+    return in;
+  }
+
+  private RobotCommentInput createRobotCommentInput(FixSuggestionInfo... fixSuggestionInfos) {
+    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
+    in.url = "http://www.happy-robot.com";
+    in.properties = new HashMap<>();
+    in.properties.put("key1", "value1");
+    in.properties.put("key2", "value2");
+    in.fixSuggestions = Arrays.asList(fixSuggestionInfos);
+    return in;
+  }
+
+  private FixSuggestionInfo createFixSuggestionInfo(FixReplacementInfo... fixReplacementInfos) {
+    FixSuggestionInfo newFixSuggestionInfo = new FixSuggestionInfo();
+    newFixSuggestionInfo.fixId = "An ID which must be overwritten.";
+    newFixSuggestionInfo.description = "A description for a suggested fix.";
+    newFixSuggestionInfo.replacements = Arrays.asList(fixReplacementInfos);
+    return newFixSuggestionInfo;
+  }
+
+  private FixReplacementInfo createFixReplacementInfo() {
+    FixReplacementInfo newFixReplacementInfo = new FixReplacementInfo();
+    newFixReplacementInfo.path = FILE_NAME;
+    newFixReplacementInfo.replacement = "some replacement code";
+    newFixReplacementInfo.range = createRange(3, 12, 15, 4);
+    return newFixReplacementInfo;
+  }
+
+  private Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
+  private void addRobotComment(String targetChangeId, RobotCommentInput robotCommentInput)
+      throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.robotComments =
+        Collections.singletonMap(
+            robotCommentInput.path, Collections.singletonList(robotCommentInput));
+    reviewInput.message = "robot comment test";
+    gApi.changes().id(targetChangeId).current().review(reviewInput);
+  }
+
+  private List<RobotCommentInfo> getRobotComments() throws RestApiException {
+    return gApi.changes().id(changeId).current().robotCommentsAsList();
+  }
+
+  private void assertRobotComment(RobotCommentInfo c, RobotCommentInput expected) {
+    assertRobotComment(c, expected, true);
+  }
+
+  private void assertRobotComment(
+      RobotCommentInfo c, RobotCommentInput expected, boolean expectPath) {
+    assertThat(c.robotId).isEqualTo(expected.robotId);
+    assertThat(c.robotRunId).isEqualTo(expected.robotRunId);
+    assertThat(c.url).isEqualTo(expected.url);
+    assertThat(c.properties).isEqualTo(expected.properties);
+    assertThat(c.line).isEqualTo(expected.line);
+    assertThat(c.message).isEqualTo(expected.message);
+
+    assertThat(c.author.email).isEqualTo(admin.email);
+
+    if (expectPath) {
+      assertThat(c.path).isEqualTo(expected.path);
+    } else {
+      assertThat(c.path).isNull();
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUCK
deleted file mode 100644
index c3274db..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUCK
+++ /dev/null
@@ -1,11 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'edit',
-  srcs = ['ChangeEditIT.java'],
-  deps = [
-    '//lib/commons:codec',
-    '//lib/joda:joda-time',
-  ],
-  labels = ['edit'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
index 3fcf2d8..990bad6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/BUILD
@@ -1,11 +1,10 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'edit',
-  srcs = ['ChangeEditIT.java'],
-  deps = [
-    '//lib/commons:codec',
-    '//lib/joda:joda-time',
-  ],
-  labels = ['edit'],
+    srcs = ["ChangeEditIT.java"],
+    group = "edit",
+    labels = ["edit"],
+    deps = [
+        "//lib/joda:joda-time",
+    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index e47d570..82f91cb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -16,14 +16,15 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
+import static com.google.gerrit.extensions.restapi.BinaryResultSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
@@ -31,44 +32,43 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.change.ChangeEdits.Post;
 import com.google.gerrit.server.change.ChangeEdits.Put;
-import com.google.gerrit.server.change.FileContentUtil;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.edit.UnchangedCommitMessageException;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gson.reflect.TypeToken;
 import com.google.gson.stream.JsonReader;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
-
-import org.apache.commons.codec.binary.StringUtils;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -78,13 +78,6 @@
 import org.junit.BeforeClass;
 import org.junit.Test;
 
-import java.io.ByteArrayOutputStream;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-
 public class ChangeEditIT extends AbstractDaemonTest {
 
   private static final String FILE_NAME = "foo";
@@ -95,24 +88,11 @@
   private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
   private static final byte[] CONTENT_NEW2 = CONTENT_NEW2_STR.getBytes(UTF_8);
 
-  @Inject
-  private SchemaFactory<ReviewDb> reviewDbProvider;
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
 
-  @Inject
-  private ChangeEditUtil editUtil;
-
-  @Inject
-  private ChangeEditModifier modifier;
-
-  @Inject
-  private FileContentUtil fileUtil;
-
-  private Change change;
   private String changeId;
-  private Change change2;
   private String changeId2;
   private PatchSet ps;
-  private PatchSet ps2;
 
   @BeforeClass
   public static void setTimeForTesting() {
@@ -129,14 +109,9 @@
     db = reviewDbProvider.open();
     changeId = newChange(admin.getIdent());
     ps = getCurrentPatchSet(changeId);
-    amendChange(admin.getIdent(), changeId);
-    change = getChange(changeId);
     assertThat(ps).isNotNull();
+    amendChange(admin.getIdent(), changeId);
     changeId2 = newChange2(admin.getIdent());
-    change2 = getChange(changeId2);
-    assertThat(change2).isNotNull();
-    ps2 = getCurrentPatchSet(changeId2);
-    assertThat(ps2).isNotNull();
   }
 
   @After
@@ -146,37 +121,44 @@
 
   @Test
   public void parseEditRevision() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
+    createArbitraryEditFor(changeId);
 
     // check that '0' is parsed as edit revision
-    gApi.changes().id(change.getChangeId()).revision(0).comments();
+    gApi.changes().id(changeId).revision(0).comments();
 
     // check that 'edit' is parsed as edit revision
-    gApi.changes().id(change.getChangeId()).revision("edit").comments();
+    gApi.changes().id(changeId).revision("edit").comments();
   }
 
   @Test
-  public void deleteEdit() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    editUtil.delete(editUtil.byChange(change).get());
-    assertThat(editUtil.byChange(change).isPresent()).isFalse();
+  public void deleteEditOfCurrentPatchSet() throws Exception {
+    createArbitraryEditFor(changeId);
+    gApi.changes().id(changeId).edit().delete();
+    assertThat(getEdit(changeId)).isAbsent();
+  }
+
+  @Test
+  public void deleteEditOfOlderPatchSet() throws Exception {
+    createArbitraryEditFor(changeId2);
+    amendChange(admin.getIdent(), changeId2);
+
+    gApi.changes().id(changeId2).edit().delete();
+    assertThat(getEdit(changeId2)).isAbsent();
   }
 
   @Test
   public void publishEdit() throws Exception {
-    assertThat(modifier.createEdit(change, getCurrentPatchSet(changeId)))
-        .isEqualTo(RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW2))).isEqualTo(RefUpdate.Result.FORCED);
-    editUtil.publish(editUtil.byChange(change).get());
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(edit.isPresent()).isFalse();
-    assertChangeMessages(change,
-        ImmutableList.of("Uploaded patch set 1.",
+    createArbitraryEditFor(changeId);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+
+    assertThat(getEdit(changeId)).isAbsent();
+    assertChangeMessages(
+        changeId,
+        ImmutableList.of(
+            "Uploaded patch set 1.",
             "Uploaded patch set 2.",
             "Patch Set 3: Published edit on patch set 2."));
   }
@@ -184,124 +166,129 @@
   @Test
   public void publishEditRest() throws Exception {
     PatchSet oldCurrentPatchSet = getCurrentPatchSet(changeId);
-    assertThat(modifier.createEdit(change, oldCurrentPatchSet)).isEqualTo(
-        RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    adminRestSession.post(urlPublish()).assertNoContent();
-    edit = editUtil.byChange(change);
-    assertThat(edit.isPresent()).isFalse();
+    createArbitraryEditFor(changeId);
+
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+    assertThat(getEdit(changeId)).isAbsent();
     PatchSet newCurrentPatchSet = getCurrentPatchSet(changeId);
     assertThat(newCurrentPatchSet.getId()).isNotEqualTo(oldCurrentPatchSet.getId());
-    assertChangeMessages(change,
-        ImmutableList.of("Uploaded patch set 1.",
+    assertChangeMessages(
+        changeId,
+        ImmutableList.of(
+            "Uploaded patch set 1.",
             "Uploaded patch set 2.",
             "Patch Set 3: Published edit on patch set 2."));
   }
 
   @Test
+  public void publishEditNotifyRest() throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(changeId).addReviewer(in);
+
+    createArbitraryEditFor(changeId);
+
+    sender.clear();
+    PublishChangeEditInput input = new PublishChangeEditInput();
+    input.notify = NotifyHandling.NONE;
+    adminRestSession.post(urlPublish(changeId), input).assertNoContent();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void publishEditWithDefaultNotify() throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(changeId).addReviewer(in);
+
+    createArbitraryEditFor(changeId);
+
+    sender.clear();
+    gApi.changes().id(changeId).edit().publish();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
+  @Test
   public void deleteEditRest() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    adminRestSession.delete(urlEdit()).assertNoContent();
-    edit = editUtil.byChange(change);
-    assertThat(edit.isPresent()).isFalse();
+    createArbitraryEditFor(changeId);
+    adminRestSession.delete(urlEdit(changeId)).assertNoContent();
+    assertThat(getEdit(changeId)).isAbsent();
   }
 
   @Test
   public void publishEditRestWithoutCLA() throws Exception {
+    createArbitraryEditFor(changeId);
     setUseContributorAgreements(InheritableBoolean.TRUE);
-    PatchSet oldCurrentPatchSet = getCurrentPatchSet(changeId);
-    assertThat(modifier.createEdit(change, oldCurrentPatchSet)).isEqualTo(
-        RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    adminRestSession.post(urlPublish()).assertForbidden();
+    adminRestSession.post(urlPublish(changeId)).assertForbidden();
     setUseContributorAgreements(InheritableBoolean.FALSE);
-    adminRestSession.post(urlPublish()).assertNoContent();
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
   }
 
   @Test
   public void rebaseEdit() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    ChangeEdit edit = editUtil.byChange(change).get();
-    PatchSet current = getCurrentPatchSet(changeId);
-    assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
-        current.getPatchSetId() - 1);
-    Date beforeRebase = edit.getEditCommit().getCommitterIdent().getWhen();
-    modifier.rebaseEdit(edit, current);
-    edit = editUtil.byChange(change).get();
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.getChange().getProject()),
-        ObjectId.fromString(edit.getRevision().get()), FILE_NAME), CONTENT_NEW);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.getChange().getProject()),
-        ObjectId.fromString(edit.getRevision().get()), FILE_NAME2), CONTENT_NEW2);
-    assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
-        current.getPatchSetId());
-    Date afterRebase = edit.getEditCommit().getCommitterIdent().getWhen();
-    assertThat(beforeRebase.equals(afterRebase)).isFalse();
+    PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    amendChange(admin.getIdent(), changeId2);
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
+
+    Optional<EditInfo> originalEdit = getEdit(changeId2);
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
+    gApi.changes().id(changeId2).edit().rebase();
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
+    Optional<EditInfo> rebasedEdit = getEdit(changeId2);
+    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(rebasedEdit).value().commit().committer().creationDate().isNotEqualTo(beforeRebase);
   }
 
   @Test
   public void rebaseEditRest() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    ChangeEdit edit = editUtil.byChange(change).get();
-    PatchSet current = getCurrentPatchSet(changeId);
-    assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
-        current.getPatchSetId() - 1);
-    Date beforeRebase = edit.getEditCommit().getCommitterIdent().getWhen();
-    adminRestSession.post(urlRebase()).assertNoContent();
-    edit = editUtil.byChange(change).get();
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.getChange().getProject()),
-        ObjectId.fromString(edit.getRevision().get()), FILE_NAME), CONTENT_NEW);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.getChange().getProject()),
-        ObjectId.fromString(edit.getRevision().get()), FILE_NAME2), CONTENT_NEW2);
-    assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
-        current.getPatchSetId());
-    Date afterRebase = edit.getEditCommit().getCommitterIdent().getWhen();
-    assertThat(afterRebase).isNotEqualTo(beforeRebase);
+    PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    amendChange(admin.getIdent(), changeId2);
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
+
+    Optional<EditInfo> originalEdit = getEdit(changeId2);
+    assertThat(originalEdit).value().baseRevision().isEqualTo(previousPatchSet.getRevision().get());
+    Timestamp beforeRebase = originalEdit.get().commit.committer.date;
+    adminRestSession.post(urlRebase(changeId2)).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME2), CONTENT_NEW2);
+    Optional<EditInfo> rebasedEdit = getEdit(changeId2);
+    assertThat(rebasedEdit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
+    assertThat(rebasedEdit).value().commit().committer().creationDate().isNotEqualTo(beforeRebase);
   }
 
   @Test
   public void rebaseEditWithConflictsRest_Conflict() throws Exception {
-    PatchSet current = getCurrentPatchSet(changeId2);
-    assertThat(modifier.createEdit(change2, current)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change2).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    ChangeEdit edit = editUtil.byChange(change2).get();
-    assertThat(edit.getBasePatchSet().getPatchSetId()).isEqualTo(
-        current.getPatchSetId());
+    PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    Optional<EditInfo> edit = getEdit(changeId2);
+    assertThat(edit).value().baseRevision().isEqualTo(currentPatchSet.getRevision().get());
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, FILE_NAME,
-            new String(CONTENT_NEW2), changeId2);
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            FILE_NAME,
+            new String(CONTENT_NEW2, UTF_8),
+            changeId2);
     push.to("refs/for/master").assertOkStatus();
-    adminRestSession.post(urlRebase()).assertConflict();
+    adminRestSession.post(urlRebase(changeId2)).assertConflict();
   }
 
   @Test
   public void updateExistingFile() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
-    editUtil.delete(edit.get());
-    edit = editUtil.byChange(change);
-    assertThat(edit.isPresent()).isFalse();
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    assertThat(getEdit(changeId)).isPresent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
   }
 
   @Test
@@ -310,380 +297,309 @@
     // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
     testRepo = cloneProject(project);
     changeId = newChange(admin.getIdent());
-    change = getChange(changeId);
 
-    assertThat(modifier.createEdit(change, getCurrentPatchSet(changeId)))
-        .isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(edit.get().getEditCommit().getParentCount()).isEqualTo(0);
+    createEmptyEditFor(changeId);
+    Optional<EditInfo> edit = getEdit(changeId);
+    assertThat(edit).value().commit().parents().isEmpty();
 
-    String msg = String.format("New commit message\n\nChange-Id: %s\n",
-        change.getKey());
-    assertThat(modifier.modifyMessage(edit.get(), msg))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertThat(edit.get().getEditCommit().getFullMessage()).isEqualTo(msg);
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeId);
+    gApi.changes().id(changeId).edit().modifyCommitMessage(msg);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(msg);
   }
 
   @Test
   public void updateMessageNoChange() throws Exception {
-    assertThat(modifier.createEdit(change, getCurrentPatchSet(changeId)))
-        .isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
+    createEmptyEditFor(changeId);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
 
-    exception.expect(UnchangedCommitMessageException.class);
-    exception.expectMessage(
-        "New commit message cannot be same as existing commit message");
-    modifier.modifyMessage(
-        edit.get(),
-        edit.get().getEditCommit().getFullMessage());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("New commit message cannot be same as existing commit message");
+    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage);
   }
 
   @Test
   public void updateMessageOnlyAddTrailingNewLines() throws Exception {
-    assertThat(modifier.createEdit(change, getCurrentPatchSet(changeId)))
-        .isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
+    createEmptyEditFor(changeId);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
 
-    exception.expect(UnchangedCommitMessageException.class);
-    exception.expectMessage(
-        "New commit message cannot be same as existing commit message");
-    modifier.modifyMessage(
-        edit.get(),
-        edit.get().getEditCommit().getFullMessage() + "\n\n");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("New commit message cannot be same as existing commit message");
+    gApi.changes().id(changeId).edit().modifyCommitMessage(commitMessage + "\n\n");
+  }
+
+  @Test
+  public void updateMessageEditChangeIdShouldThrowResourceConflictException() throws Exception {
+    createEmptyEditFor(changeId);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Editing of the Change-Id footer is not allowed");
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyCommitMessage(commitMessage.replaceAll(changeId, changeId2));
+  }
+
+  @Test
+  public void updateMessageEditRemoveChangeIdShouldThrowResourceConflictException()
+      throws Exception {
+    createEmptyEditFor(changeId);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Editing of the Change-Id footer is not allowed");
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyCommitMessage(commitMessage.replaceAll("(Change-Id:).*", ""));
   }
 
   @Test
   public void updateMessage() throws Exception {
-    assertThat(modifier.createEdit(change, getCurrentPatchSet(changeId)))
-        .isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    String msg = String.format("New commit message\n\nChange-Id: %s\n",
-        change.getKey());
-    assertThat(modifier.modifyMessage(edit.get(), msg)).isEqualTo(
-        RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertThat(edit.get().getEditCommit().getFullMessage()).isEqualTo(msg);
+    createEmptyEditFor(changeId);
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeId);
+    gApi.changes().id(changeId).edit().modifyCommitMessage(msg);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(msg);
 
-    editUtil.publish(edit.get());
-    assertThat(editUtil.byChange(change).isPresent()).isFalse();
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+    assertThat(getEdit(changeId)).isAbsent();
 
-    ChangeInfo info = get(changeId, ListChangesOption.CURRENT_COMMIT,
-        ListChangesOption.CURRENT_REVISION);
-    assertThat(info.revisions.get(info.currentRevision).commit.message)
-        .isEqualTo(msg);
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    assertThat(info.revisions.get(info.currentRevision).commit.message).isEqualTo(msg);
+    assertThat(info.revisions.get(info.currentRevision).description)
+        .isEqualTo("Edit commit message");
 
-    assertChangeMessages(change,
-        ImmutableList.of("Uploaded patch set 1.",
+    assertChangeMessages(
+        changeId,
+        ImmutableList.of(
+            "Uploaded patch set 1.",
             "Uploaded patch set 2.",
             "Patch Set 3: Commit message was updated."));
   }
 
   @Test
   public void updateMessageRest() throws Exception {
-    adminRestSession.get(urlEditMessage(false)).assertNotFound();
+    adminRestSession.get(urlEditMessage(changeId, false)).assertNotFound();
     EditMessage.Input in = new EditMessage.Input();
-    in.message = String.format("New commit message\n\n" +
-        CONTENT_NEW2_STR + "\n\nChange-Id: %s\n",
-        change.getKey());
-    adminRestSession.put(urlEditMessage(false), in).assertNoContent();
-    RestResponse r = adminRestSession.getJsonAccept(urlEditMessage(false));
+    in.message =
+        String.format(
+            "New commit message\n\n" + CONTENT_NEW2_STR + "\n\nChange-Id: %s\n", changeId);
+    adminRestSession.put(urlEditMessage(changeId, false), in).assertNoContent();
+    RestResponse r = adminRestSession.getJsonAccept(urlEditMessage(changeId, false));
     r.assertOK();
     assertThat(readContentFromJson(r)).isEqualTo(in.message);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(edit.get().getEditCommit().getFullMessage())
-        .isEqualTo(in.message);
-    in.message = String.format("New commit message2\n\nChange-Id: %s\n",
-        change.getKey());
-    adminRestSession.put(urlEditMessage(false), in).assertNoContent();
-    edit = editUtil.byChange(change);
-    assertThat(edit.get().getEditCommit().getFullMessage())
-        .isEqualTo(in.message);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(in.message);
+    in.message = String.format("New commit message2\n\nChange-Id: %s\n", changeId);
+    adminRestSession.put(urlEditMessage(changeId, false), in).assertNoContent();
+    String updatedCommitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(updatedCommitMessage).isEqualTo(in.message);
 
-    r = adminRestSession.getJsonAccept(urlEditMessage(true));
+    r = adminRestSession.getJsonAccept(urlEditMessage(changeId, true));
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(
-          ObjectId.fromString(ps.getRevision().get()));
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
       assertThat(readContentFromJson(r)).isEqualTo(commit.getFullMessage());
     }
 
-    editUtil.publish(edit.get());
-    assertChangeMessages(change,
-        ImmutableList.of("Uploaded patch set 1.",
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+    assertChangeMessages(
+        changeId,
+        ImmutableList.of(
+            "Uploaded patch set 1.",
             "Uploaded patch set 2.",
             "Patch Set 3: Commit message was updated."));
   }
 
   @Test
   public void retrieveEdit() throws Exception {
-    adminRestSession.get(urlEdit()).assertNoContent();
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    EditInfo info = toEditInfo(false);
-    assertThat(info.commit.commit).isEqualTo(edit.get().getRevision().get());
-    assertThat(info.commit.parents).hasSize(1);
+    adminRestSession.get(urlEdit(changeId)).assertNoContent();
+    createArbitraryEditFor(changeId);
+    EditInfo editInfo = getEditInfo(changeId, false);
+    ChangeInfo changeInfo = get(changeId);
+    assertThat(editInfo.commit.commit).isNotEqualTo(changeInfo.currentRevision);
+    assertThat(editInfo).commit().parents().hasSize(1);
+    assertThat(editInfo).baseRevision().isEqualTo(changeInfo.currentRevision);
 
-    edit = editUtil.byChange(change);
-    editUtil.delete(edit.get());
+    gApi.changes().id(changeId).edit().delete();
 
-    adminRestSession.get(urlEdit()).assertNoContent();
+    adminRestSession.get(urlEdit(changeId)).assertNoContent();
   }
 
   @Test
   public void retrieveFilesInEdit() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
-        .isEqualTo(RefUpdate.Result.FORCED);
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
 
-    EditInfo info = toEditInfo(true);
-    assertThat(info.files).hasSize(2);
-    List<String> l = Lists.newArrayList(info.files.keySet());
-    assertThat(l.get(0)).isEqualTo("/COMMIT_MSG");
-    assertThat(l.get(1)).isEqualTo("foo");
+    EditInfo info = getEditInfo(changeId, true);
+    assertThat(info.files).isNotNull();
+    assertThat(info.files.keySet()).containsExactly(Patch.COMMIT_MSG, FILE_NAME, FILE_NAME2);
   }
 
   @Test
   public void deleteExistingFile() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.deleteFile(edit.get(), FILE_NAME)).isEqualTo(
-        RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    exception.expect(ResourceNotFoundException.class);
-    fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME);
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
   }
 
   @Test
   public void renameExistingFile() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.renameFile(edit.get(), FILE_NAME, FILE_NAME3))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME3), CONTENT_OLD);
-    exception.expect(ResourceNotFoundException.class);
-    fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME);
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, FILE_NAME3);
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_OLD);
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
   }
 
   @Test
   public void createEditByDeletingExistingFileRest() throws Exception {
-    adminRestSession.delete(urlEditFile()).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    exception.expect(ResourceNotFoundException.class);
-    fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME);
+    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
   }
 
   @Test
   public void deletingNonExistingEditRest() throws Exception {
-    adminRestSession.delete(urlEdit()).assertNotFound();
+    adminRestSession.delete(urlEdit(changeId)).assertNotFound();
   }
 
   @Test
   public void deleteExistingFileRest() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    adminRestSession.delete(urlEditFile()).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    exception.expect(ResourceNotFoundException.class);
-    fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME);
+    createEmptyEditFor(changeId);
+    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
   }
 
   @Test
   public void restoreDeletedFileInPatchSet() throws Exception {
-    assertThat(modifier.createEdit(change2, ps2)).isEqualTo(
-        RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change2);
-    assertThat(modifier.restoreFile(edit.get(), FILE_NAME)).isEqualTo(
-        RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change2);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
   }
 
   @Test
   public void revertChanges() throws Exception {
-    assertThat(modifier.createEdit(change2, ps2)).isEqualTo(
-        RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change2);
-    assertThat(modifier.restoreFile(edit.get(), FILE_NAME)).isEqualTo(
-        RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change2);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change2).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change2);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
-    assertThat(modifier.restoreFile(edit.get(), FILE_NAME)).isEqualTo(
-        RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change2);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
-    editUtil.delete(edit.get());
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_NEW);
+    gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
   }
 
   @Test
   public void renameFileRest() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
+    createEmptyEditFor(changeId);
     Post.Input in = new Post.Input();
     in.oldPath = FILE_NAME;
     in.newPath = FILE_NAME3;
-    adminRestSession.post(urlEdit(), in).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME3), CONTENT_OLD);
-    exception.expect(ResourceNotFoundException.class);
-    fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME);
+    adminRestSession.post(urlEdit(changeId), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_OLD);
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
   }
 
   @Test
   public void restoreDeletedFileInPatchSetRest() throws Exception {
     Post.Input in = new Post.Input();
     in.restorePath = FILE_NAME;
-    adminRestSession.post(urlEdit2(), in).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change2);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
+    adminRestSession.post(urlEdit(changeId2), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId2, FILE_NAME), CONTENT_OLD);
   }
 
   @Test
   public void amendExistingFile() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW2)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW2);
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW2);
   }
 
   @Test
   public void createAndChangeEditInOneRequestRest() throws Exception {
     Put.Input in = new Put.Input();
     in.content = RawInputUtil.create(CONTENT_NEW);
-    adminRestSession.putRaw(urlEditFile(), in.content).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
+    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
     in.content = RawInputUtil.create(CONTENT_NEW2);
-    adminRestSession.putRaw(urlEditFile(), in.content).assertNoContent();
-    edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW2);
+    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW2);
   }
 
   @Test
   public void changeEditRest() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
+    createEmptyEditFor(changeId);
     Put.Input in = new Put.Input();
     in.content = RawInputUtil.create(CONTENT_NEW);
-    adminRestSession.putRaw(urlEditFile(), in.content).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_NEW);
+    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
   }
 
   @Test
   public void emptyPutRequest() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    adminRestSession.put(urlEditFile()).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), "".getBytes());
+    createEmptyEditFor(changeId);
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), "".getBytes(UTF_8));
   }
 
   @Test
   public void createEmptyEditRest() throws Exception {
-    adminRestSession.post(urlEdit()).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME), CONTENT_OLD);
+    adminRestSession.post(urlEdit(changeId)).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_OLD);
   }
 
   @Test
   public void getFileContentRest() throws Exception {
     Put.Input in = new Put.Input();
     in.content = RawInputUtil.create(CONTENT_NEW);
-    adminRestSession.putRaw(urlEditFile(), in.content).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME, RawInputUtil.create(CONTENT_NEW2)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    RestResponse r = adminRestSession.getJsonAccept(urlEditFile());
+    adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
+    RestResponse r = adminRestSession.getJsonAccept(urlEditFile(changeId, FILE_NAME));
     r.assertOK();
-    assertThat(readContentFromJson(r)).isEqualTo(
-        StringUtils.newStringUtf8(CONTENT_NEW2));
+    assertThat(readContentFromJson(r)).isEqualTo(new String(CONTENT_NEW2, UTF_8));
 
-    r = adminRestSession.getJsonAccept(urlEditFile(true));
+    r = adminRestSession.getJsonAccept(urlEditFile(changeId, FILE_NAME, true));
     r.assertOK();
-    assertThat(readContentFromJson(r)).isEqualTo(
-        StringUtils.newStringUtf8(CONTENT_OLD));
+    assertThat(readContentFromJson(r)).isEqualTo(new String(CONTENT_OLD, UTF_8));
   }
 
   @Test
   public void getFileNotFoundRest() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    adminRestSession.delete(urlEditFile()).assertNoContent();
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    adminRestSession.get(urlEditFile()).assertNoContent();
-    exception.expect(ResourceNotFoundException.class);
-    fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME);
+    createEmptyEditFor(changeId);
+    adminRestSession.delete(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    adminRestSession.get(urlEditFile(changeId, FILE_NAME)).assertNoContent();
+    assertThat(getFileContentOfEdit(changeId, FILE_NAME)).isAbsent();
   }
 
   @Test
   public void addNewFile() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RawInputUtil.create(CONTENT_NEW)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME2), CONTENT_NEW);
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
   }
 
   @Test
   public void addNewFileAndAmend() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RawInputUtil.create(CONTENT_NEW)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME2), CONTENT_NEW);
-    assertThat(modifier.modifyFile(edit.get(), FILE_NAME2, RawInputUtil.create(CONTENT_NEW2)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    assertByteArray(fileUtil.getContent(projectCache.get(edit.get().getChange().getProject()),
-        ObjectId.fromString(edit.get().getRevision().get()), FILE_NAME2), CONTENT_NEW2);
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW2));
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW2);
   }
 
   @Test
   public void writeNoChanges() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    exception.expect(InvalidChangeOperationException.class);
+    createEmptyEditFor(changeId);
+    exception.expect(ResourceConflictException.class);
     exception.expectMessage("no changes were made");
-    modifier.modifyFile(
-        editUtil.byChange(change).get(),
-        FILE_NAME,
-        RawInputUtil.create(CONTENT_OLD));
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_OLD));
   }
 
   @Test
@@ -695,23 +611,17 @@
     cfg.getLabelSections().put(cr, codeReview);
     saveProjectConfig(project, cfg);
 
-    String changeId = change.getKey().get();
     ReviewInput r = new ReviewInput();
-    r.labels = ImmutableMap.<String, Short> of(cr, (short) 1);
-    gApi.changes()
-        .id(changeId)
-        .revision(change.currentPatchSetId().get())
-        .review(r);
+    r.labels = ImmutableMap.of(cr, (short) 1);
+    gApi.changes().id(changeId).current().review(r);
 
-    assertThat(modifier.createEdit(change, getCurrentPatchSet(changeId)))
-        .isEqualTo(RefUpdate.Result.NEW);
-    Optional<ChangeEdit> edit = editUtil.byChange(change);
+    createEmptyEditFor(changeId);
     String newSubj = "New commit message";
     String newMsg = newSubj + "\n\nChange-Id: " + changeId + "\n";
-    assertThat(modifier.modifyMessage(edit.get(), newMsg))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change);
-    editUtil.publish(edit.get());
+    gApi.changes().id(changeId).edit().modifyCommitMessage(newMsg);
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
 
     ChangeInfo info = get(changeId);
     assertThat(info.subject).isEqualTo(newSubj);
@@ -721,65 +631,61 @@
   }
 
   @Test
-  public void testHasEditPredicate() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
+  public void hasEditPredicate() throws Exception {
+    createEmptyEditFor(changeId);
     assertThat(queryEdits()).hasSize(1);
 
-    PatchSet current = getCurrentPatchSet(changeId2);
-    assertThat(modifier.createEdit(change2, current)).isEqualTo(RefUpdate.Result.NEW);
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change2).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
+    createEmptyEditFor(changeId2);
+    gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
     assertThat(queryEdits()).hasSize(2);
 
-    assertThat(
-        modifier.modifyFile(editUtil.byChange(change).get(), FILE_NAME,
-            RawInputUtil.create(CONTENT_NEW))).isEqualTo(RefUpdate.Result.FORCED);
-    editUtil.delete(editUtil.byChange(change).get());
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    gApi.changes().id(changeId).edit().delete();
     assertThat(queryEdits()).hasSize(1);
 
-    editUtil.publish(editUtil.byChange(change2).get());
-    assertThat(queryEdits()).hasSize(0);
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId2).edit().publish(publishInput);
+    assertThat(queryEdits()).isEmpty();
 
     setApiUser(user);
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
+    createEmptyEditFor(changeId);
     assertThat(queryEdits()).hasSize(1);
 
     setApiUser(admin);
-    assertThat(queryEdits()).hasSize(0);
+    assertThat(queryEdits()).isEmpty();
   }
 
   @Test
   public void files() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    ChangeEdit edit = editUtil.byChange(change).get();
-    assertThat(modifier.modifyFile(edit, FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change).get();
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    Optional<EditInfo> edit = getEdit(changeId);
+    assertThat(edit).isPresent();
+    String editCommitId = edit.get().commit.commit;
 
-    RestResponse r = adminRestSession.getJsonAccept(urlRevisionFiles(edit));
-    Map<String, FileInfo> files = readContentFromJson(
-        r, new TypeToken<Map<String, FileInfo>>() {});
+    RestResponse r = adminRestSession.getJsonAccept(urlRevisionFiles(changeId, editCommitId));
+    Map<String, FileInfo> files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
     assertThat(files).containsKey(FILE_NAME);
 
-    r = adminRestSession.getJsonAccept(urlRevisionFiles());
+    r = adminRestSession.getJsonAccept(urlRevisionFiles(changeId));
     files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
     assertThat(files).containsKey(FILE_NAME);
   }
 
   @Test
   public void diff() throws Exception {
-    assertThat(modifier.createEdit(change, ps)).isEqualTo(RefUpdate.Result.NEW);
-    ChangeEdit edit = editUtil.byChange(change).get();
-    assertThat(modifier.modifyFile(edit, FILE_NAME, RawInputUtil.create(CONTENT_NEW)))
-        .isEqualTo(RefUpdate.Result.FORCED);
-    edit = editUtil.byChange(change).get();
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+    Optional<EditInfo> edit = getEdit(changeId);
+    assertThat(edit).isPresent();
+    String editCommitId = edit.get().commit.commit;
 
-    RestResponse r = adminRestSession.getJsonAccept(urlDiff(edit));
+    RestResponse r = adminRestSession.getJsonAccept(urlDiff(changeId, editCommitId, FILE_NAME));
     DiffInfo diff = readContentFromJson(r, DiffInfo.class);
     assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
 
-    r = adminRestSession.getJsonAccept(urlDiff());
+    r = adminRestSession.getJsonAccept(urlDiff(changeId, FILE_NAME));
     diff = readContentFromJson(r, DiffInfo.class);
     assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
   }
@@ -789,21 +695,37 @@
     // Create new project with clean permissions
     Project.NameKey p = createProject("addPatchSetEdit");
     // Clone repository as user
-    TestRepository<InMemoryRepository> userTestRepo =
-        cloneProject(p, user);
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
     block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
 
     // Create change as user
-    PushOneCommit push = pushFactory.create(
-        db, user.getIdent(), userTestRepo);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
     r1.assertOkStatus();
 
     // Try to create edit as admin
-    assertThat(modifier.createEdit(r1.getChange().change(),
-        r1.getPatchSet())).isEqualTo(RefUpdate.Result.REJECTED);
+    exception.expect(AuthException.class);
+    createEmptyEditFor(r1.getChangeId());
+  }
+
+  private void createArbitraryEditFor(String changeId) throws Exception {
+    createEmptyEditFor(changeId);
+    arbitrarilyModifyEditOf(changeId);
+  }
+
+  private void createEmptyEditFor(String changeId) throws Exception {
+    gApi.changes().id(changeId).edit().create();
+  }
+
+  private void arbitrarilyModifyEditOf(String changeId) throws Exception {
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+  }
+
+  private Optional<BinaryResult> getFileContentOfEdit(String changeId, String filePath)
+      throws Exception {
+    return gApi.changes().id(changeId).edit().getFile(filePath);
   }
 
   private List<ChangeInfo> queryEdits() throws Exception {
@@ -812,135 +734,107 @@
 
   private String newChange(PersonIdent ident) throws Exception {
     PushOneCommit push =
-        pushFactory.create(db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME,
-            new String(CONTENT_OLD, UTF_8));
+        pushFactory.create(
+            db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
     return push.to("refs/for/master").getChangeId();
   }
 
   private String amendChange(PersonIdent ident, String changeId) throws Exception {
     PushOneCommit push =
-        pushFactory.create(db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME2,
-            new String(CONTENT_NEW2, UTF_8), changeId);
+        pushFactory.create(
+            db,
+            ident,
+            testRepo,
+            PushOneCommit.SUBJECT,
+            FILE_NAME2,
+            new String(CONTENT_NEW2, UTF_8),
+            changeId);
     return push.to("refs/for/master").getChangeId();
   }
 
   private String newChange2(PersonIdent ident) throws Exception {
     PushOneCommit push =
-        pushFactory.create(db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME,
-            new String(CONTENT_OLD, UTF_8));
+        pushFactory.create(
+            db, ident, testRepo, PushOneCommit.SUBJECT, FILE_NAME, new String(CONTENT_OLD, UTF_8));
     return push.rm("refs/for/master").getChangeId();
   }
 
-  private Change getChange(String changeId) throws Exception {
-    return getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
-  }
-
   private PatchSet getCurrentPatchSet(String changeId) throws Exception {
-    return getOnlyElement(queryProvider.get().byKeyPrefix(changeId))
-        .currentPatchSet();
+    return getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).currentPatchSet();
   }
 
-  private static void assertByteArray(BinaryResult result, byte[] expected)
-        throws Exception {
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    result.writeTo(os);
-    assertThat(os.toByteArray()).isEqualTo(expected);
+  private void ensureSameBytes(Optional<BinaryResult> fileContent, byte[] expectedFileBytes)
+      throws IOException {
+    assertThat(fileContent).value().bytes().isEqualTo(expectedFileBytes);
   }
 
-  private String urlEdit() {
+  private String urlEdit(String changeId) {
+    return "/changes/" + changeId + "/edit";
+  }
+
+  private String urlEditMessage(String changeId, boolean base) {
+    return "/changes/" + changeId + "/edit:message" + (base ? "?base" : "");
+  }
+
+  private String urlEditFile(String changeId, String fileName) {
+    return urlEditFile(changeId, fileName, false);
+  }
+
+  private String urlEditFile(String changeId, String fileName, boolean base) {
+    return urlEdit(changeId) + "/" + fileName + (base ? "?base" : "");
+  }
+
+  private String urlGetFiles(String changeId) {
+    return urlEdit(changeId) + "?list";
+  }
+
+  private String urlRevisionFiles(String changeId, String revisionId) {
+    return "/changes/" + changeId + "/revisions/" + revisionId + "/files";
+  }
+
+  private String urlRevisionFiles(String changeId) {
+    return "/changes/" + changeId + "/revisions/0/files";
+  }
+
+  private String urlPublish(String changeId) {
+    return "/changes/" + changeId + "/edit:publish";
+  }
+
+  private String urlRebase(String changeId) {
+    return "/changes/" + changeId + "/edit:rebase";
+  }
+
+  private String urlDiff(String changeId, String fileName) {
     return "/changes/"
-        + change.getChangeId()
-        + "/edit";
-  }
-
-  private String urlEdit2() {
-    return "/changes/"
-        + change2.getChangeId()
-        + "/edit/";
-  }
-
-  private String urlEditMessage(boolean base) {
-    return "/changes/"
-        + change.getChangeId()
-        + "/edit:message"
-        + (base ? "?base" : "");
-  }
-
-  private String urlEditFile() {
-    return urlEditFile(false);
-  }
-
-  private String urlEditFile(boolean base) {
-    return urlEdit()
-        + "/"
-        + FILE_NAME
-        + (base ? "?base" : "");
-  }
-
-  private String urlGetFiles() {
-    return urlEdit()
-        + "?list";
-  }
-
-  private String urlRevisionFiles(ChangeEdit edit) {
-    return "/changes/"
-      + change.getChangeId()
-      + "/revisions/"
-      + edit.getRevision().get()
-      + "/files";
-  }
-
-  private String urlRevisionFiles() {
-    return "/changes/"
-      + change.getChangeId()
-      + "/revisions/0/files";
-  }
-
-  private String urlPublish() {
-    return "/changes/"
-        + change.getChangeId()
-        + "/edit:publish";
-  }
-
-  private String urlRebase() {
-    return "/changes/"
-        + change.getChangeId()
-        + "/edit:rebase";
-  }
-
-  private String urlDiff() {
-    return "/changes/"
-        + change.getChangeId()
+        + changeId
         + "/revisions/0/files/"
-        + FILE_NAME
+        + fileName
         + "/diff?context=ALL&intraline";
   }
 
-  private String urlDiff(ChangeEdit edit) {
+  private String urlDiff(String changeId, String revisionId, String fileName) {
     return "/changes/"
-        + change.getChangeId()
+        + changeId
         + "/revisions/"
-        + edit.getRevision().get()
+        + revisionId
         + "/files/"
-        + FILE_NAME
+        + fileName
         + "/diff?context=ALL&intraline";
   }
 
-  private EditInfo toEditInfo(boolean files) throws Exception {
-    RestResponse r = adminRestSession.get(files ? urlGetFiles() : urlEdit());
+  private EditInfo getEditInfo(String changeId, boolean files) throws Exception {
+    RestResponse r = adminRestSession.get(files ? urlGetFiles(changeId) : urlEdit(changeId));
     return readContentFromJson(r, EditInfo.class);
   }
 
-  private <T> T readContentFromJson(RestResponse r, Class<T> clazz)
-      throws Exception {
+  private <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
     r.assertOK();
     JsonReader jsonReader = new JsonReader(r.getReader());
     jsonReader.setLenient(true);
     return newGson().fromJson(jsonReader, clazz);
   }
 
-  private <T> T readContentFromJson(RestResponse r, TypeToken<T> typeToken)
-      throws Exception {
+  private <T> T readContentFromJson(RestResponse r, TypeToken<T> typeToken) throws Exception {
     r.assertOK();
     JsonReader jsonReader = new JsonReader(r.getReader());
     jsonReader.setLenient(true);
@@ -951,18 +845,13 @@
     return readContentFromJson(r, String.class);
   }
 
-  private void assertChangeMessages(Change c, List<String> expectedMessages)
+  private void assertChangeMessages(String changeId, List<String> expectedMessages)
       throws Exception {
-    ChangeInfo ci = get(c.getId().toString());
+    ChangeInfo ci = get(changeId);
     assertThat(ci.messages).isNotNull();
     assertThat(ci.messages).hasSize(expectedMessages.size());
-    List<String> actualMessages = new ArrayList<>();
-    Iterator<ChangeMessageInfo> it = ci.messages.iterator();
-    while (it.hasNext()) {
-      actualMessages.add(it.next().message);
-    }
-    assertThat(actualMessages)
-      .containsExactlyElementsIn(expectedMessages)
-      .inOrder();
+    List<String> actualMessages =
+        ci.messages.stream().map(message -> message.message).collect(toList());
+    assertThat(actualMessages).containsExactlyElementsIn(expectedMessages).inOrder();
   }
 }
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 5cdd2f4..d05b601 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
@@ -19,6 +19,8 @@
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
@@ -29,10 +31,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -40,21 +42,31 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
-
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -68,20 +80,13 @@
 import org.junit.BeforeClass;
 import org.junit.Test;
 
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 public abstract class AbstractPushForReview extends AbstractDaemonTest {
   protected enum Protocol {
     // TODO(dborowitz): TEST.
-    SSH, HTTP
+    SSH,
+    HTTP
   }
 
-  private String sshUrl;
   private LabelType patchSetLock;
 
   @BeforeClass
@@ -96,14 +101,12 @@
 
   @Before
   public void setUp() throws Exception {
-    sshUrl = adminSshSession.getUrl();
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     patchSetLock = Util.patchSetLock();
     cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
-    AccountGroup.UUID anonymousUsers =
-        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers,
-        "refs/heads/*");
+    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(
+        cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers, "refs/heads/*");
     saveProjectConfig(cfg);
     grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
   }
@@ -112,7 +115,7 @@
     String url;
     switch (p) {
       case SSH:
-        url = sshUrl;
+        url = adminSshSession.getUrl();
         break;
       case HTTP:
         url = admin.getHttpUrl(server);
@@ -131,6 +134,26 @@
   }
 
   @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void pushInitialCommitForMasterBranch() throws Exception {
+    RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    String r = "refs/for/master";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    ChangeInfo change = gApi.changes().id(id).info();
+    assertThat(change.branch).isEqualTo("master");
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("master")).isNull();
+    }
+  }
+
+  @Test
   public void output() throws Exception {
     String url = canonicalWebUrl.get();
     ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
@@ -139,28 +162,38 @@
     r1.assertOkStatus();
     r1.assertChange(Change.Status.NEW, null);
     r1.assertMessage(
-        "New changes:\n"
-        + "  " + url + id1 + " " + r1.getCommit().getShortMessage() + "\n");
+        "New changes:\n  " + url + id1 + " " + r1.getCommit().getShortMessage() + "\n");
 
     testRepo.reset(initialHead);
     String newMsg = r1.getCommit().getShortMessage() + " v2";
-    testRepo.branch("HEAD").commit()
+    testRepo
+        .branch("HEAD")
+        .commit()
         .message(newMsg)
         .insertChangeId(r1.getChangeId().substring(1))
         .create();
-    PushOneCommit.Result r2 = pushFactory.create(
-            db, admin.getIdent(), testRepo, "another commit", "b.txt", "bbb")
-        .to("refs/for/master");
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "another commit", "b.txt", "bbb")
+            .to("refs/for/master");
     Change.Id id2 = r2.getChange().getId();
     r2.assertOkStatus();
     r2.assertChange(Change.Status.NEW, null);
     r2.assertMessage(
         "New changes:\n"
-        + "  " + url + id2 + " another commit\n"
-        + "\n"
-        + "\n"
-        + "Updated changes:\n"
-        + "  " + url + id1 + " " + newMsg + "\n");
+            + "  "
+            + url
+            + id2
+            + " another commit\n"
+            + "\n"
+            + "\n"
+            + "Updated changes:\n"
+            + "  "
+            + url
+            + id1
+            + " "
+            + newMsg
+            + "\n");
   }
 
   @Test
@@ -178,23 +211,46 @@
   }
 
   @Test
+  public void pushForMasterWithTopicOption() throws Exception {
+    String topicOption = "topic=myTopic";
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add(topicOption);
+
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, "myTopic");
+    r.assertPushOptions(pushOptions);
+  }
+
+  @Test
   public void pushForMasterWithNotify() throws Exception {
+    // create a user that watches the project
+    TestAccount user3 = accounts.create("user3", "user3@example.com", "User3");
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project.get();
+    pwi.filter = "*";
+    pwi.notifyNewChanges = true;
+    projectsToWatch.add(pwi);
+    setApiUser(user3);
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+
     TestAccount user2 = accounts.user2();
-    String pushSpec = "refs/for/master"
-        + "%reviewer=" + user.email
-        + ",cc=" + user2.email;
+    String pushSpec = "refs/for/master%reviewer=" + user.email + ",cc=" + user2.email;
 
     sender.clear();
-    PushOneCommit.Result r =
-        pushTo(pushSpec + ",notify=" + NotifyHandling.NONE);
+    PushOneCommit.Result r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE);
     r.assertOkStatus();
-    assertThat(sender.getMessages()).hasSize(0);
+    assertThat(sender.getMessages()).isEmpty();
 
     sender.clear();
     r = pushTo(pushSpec + ",notify=" + NotifyHandling.OWNER);
     r.assertOkStatus();
     // no email notification about own changes
-    assertThat(sender.getMessages()).hasSize(0);
+    assertThat(sender.getMessages()).isEmpty();
 
     sender.clear();
     r = pushTo(pushSpec + ",notify=" + NotifyHandling.OWNER_REVIEWERS);
@@ -208,7 +264,38 @@
     r.assertOkStatus();
     assertThat(sender.getMessages()).hasSize(1);
     m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(user.emailAddress, user2.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress, user2.emailAddress, user3.emailAddress);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + user3.email);
+    r.assertOkStatus();
+    assertNotifyTo(user3);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + user3.email);
+    r.assertOkStatus();
+    assertNotifyCc(user3);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + user3.email);
+    r.assertOkStatus();
+    assertNotifyBcc(user3);
+
+    // request that sender gets notified as TO, CC and BCC, email should be sent
+    // even if the sender is the only recipient
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-to=" + admin.email);
+    assertNotifyTo(admin);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-cc=" + admin.email);
+    r.assertOkStatus();
+    assertNotifyCc(admin);
+
+    sender.clear();
+    r = pushTo(pushSpec + ",notify=" + NotifyHandling.NONE + ",notify-bcc=" + admin.email);
+    r.assertOkStatus();
+    assertNotifyBcc(admin);
   }
 
   @Test
@@ -220,17 +307,32 @@
     r.assertChange(Change.Status.NEW, topic);
 
     // cc several users
-    TestAccount user2 =
-        accounts.create("another-user", "another.user@example.com", "Another User");
-    r = pushTo("refs/for/master/" + topic + "%cc=" + admin.email + ",cc="
-        + user.email + ",cc=" + user2.email);
+    TestAccount user2 = accounts.create("another-user", "another.user@example.com", "Another User");
+    r =
+        pushTo(
+            "refs/for/master/"
+                + topic
+                + "%cc="
+                + admin.email
+                + ",cc="
+                + user.email
+                + ",cc="
+                + user2.email);
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, topic);
 
     // cc non-existing user
     String nonExistingEmail = "non.existing@example.com";
-    r = pushTo("refs/for/master/" + topic + "%cc=" + admin.email + ",cc="
-        + nonExistingEmail + ",cc=" + user.email);
+    r =
+        pushTo(
+            "refs/for/master/"
+                + topic
+                + "%cc="
+                + admin.email
+                + ",cc="
+                + nonExistingEmail
+                + ",cc="
+                + user.email);
     r.assertErrorStatus("user \"" + nonExistingEmail + "\" not found");
   }
 
@@ -243,18 +345,33 @@
     r.assertChange(Change.Status.NEW, topic, user);
 
     // add several reviewers
-    TestAccount user2 =
-        accounts.create("another-user", "another.user@example.com", "Another User");
-    r = pushTo("refs/for/master/" + topic + "%r=" + admin.email + ",r=" + user.email
-        + ",r=" + user2.email);
+    TestAccount user2 = accounts.create("another-user", "another.user@example.com", "Another User");
+    r =
+        pushTo(
+            "refs/for/master/"
+                + topic
+                + "%r="
+                + admin.email
+                + ",r="
+                + user.email
+                + ",r="
+                + user2.email);
     r.assertOkStatus();
     // admin is the owner of the change and should not appear as reviewer
     r.assertChange(Change.Status.NEW, topic, user, user2);
 
     // add non-existing user as reviewer
     String nonExistingEmail = "non.existing@example.com";
-    r = pushTo("refs/for/master/" + topic + "%r=" + admin.email + ",r="
-        + nonExistingEmail + ",r=" + user.email);
+    r =
+        pushTo(
+            "refs/for/master/"
+                + topic
+                + "%r="
+                + admin.email
+                + ",r="
+                + nonExistingEmail
+                + ",r="
+                + user.email);
     r.assertErrorStatus("user \"" + nonExistingEmail + "\" not found");
   }
 
@@ -288,18 +405,26 @@
   public void pushForMasterAsEdit() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
-    EditInfo edit = getEdit(r.getChangeId());
-    assertThat(edit).isNull();
+    Optional<EditInfo> edit = getEdit(r.getChangeId());
+    assertThat(edit).isAbsent();
+    assertThat(query("has:edit")).isEmpty();
 
     // specify edit as option
     r = amendChange(r.getChangeId(), "refs/for/master%edit");
     r.assertOkStatus();
     edit = getEdit(r.getChangeId());
-    assertThat(edit).isNotNull();
-    r.assertMessage("Updated Changes:\n  "
-        + canonicalWebUrl.get()
-        + r.getChange().getId()
-        + " " + edit.commit.subject + " [EDIT]\n");
+    assertThat(edit).isPresent();
+    EditInfo editInfo = edit.get();
+    r.assertMessage(
+        "Updated Changes:\n  "
+            + canonicalWebUrl.get()
+            + r.getChange().getId()
+            + " "
+            + editInfo.commit.subject
+            + " [EDIT]\n");
+
+    // verify that the re-indexing was triggered for the change
+    assertThat(query("has:edit")).hasSize(1);
   }
 
   @Test
@@ -311,8 +436,46 @@
     Collection<ChangeMessageInfo> changeMessages = ci.messages;
     assertThat(changeMessages).hasSize(1);
     for (ChangeMessageInfo cm : changeMessages) {
-      assertThat(cm.message).isEqualTo(
-          "Uploaded patch set 1.\nmy test message");
+      assertThat(cm.message).isEqualTo("Uploaded patch set 1.\nmy test message");
+    }
+    Collection<RevisionInfo> revisions = ci.revisions.values();
+    assertThat(revisions).hasSize(1);
+    for (RevisionInfo ri : revisions) {
+      assertThat(ri.description).isEqualTo("my test message");
+    }
+  }
+
+  @Test
+  public void pushForMasterWithMessageTwiceWithDifferentMessages() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master/%m=my_test_message");
+    r.assertOkStatus();
+
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master/%m=new_test_message");
+    r.assertOkStatus();
+
+    ChangeInfo ci = get(r.getChangeId());
+    Collection<RevisionInfo> revisions = ci.revisions.values();
+    assertThat(revisions).hasSize(2);
+    for (RevisionInfo ri : revisions) {
+      if (ri.isCurrent) {
+        assertThat(ri.description).isEqualTo("new test message");
+      } else {
+        assertThat(ri.description).isEqualTo("my test message");
+      }
     }
   }
 
@@ -325,52 +488,95 @@
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).name).isEqualTo("Administrator");
     assertThat(cr.all.get(0).value).isEqualTo(1);
-    assertThat(Iterables.getLast(ci.messages).message).isEqualTo(
-        "Uploaded patch set 1: Code-Review+1.");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo("Uploaded patch set 1: Code-Review+1.");
 
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "b.txt", "anotherContent", r.getChangeId());
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
     r = push.to("refs/for/master/%l=Code-Review+2");
 
     ci = get(r.getChangeId());
     cr = ci.labels.get("Code-Review");
-    assertThat(Iterables.getLast(ci.messages).message).isEqualTo(
-        "Uploaded patch set 2: Code-Review+2.");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo("Uploaded patch set 2: Code-Review+2.");
+    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).name).isEqualTo("Administrator");
     assertThat(cr.all.get(0).value).isEqualTo(2);
 
     push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "c.txt", "moreContent", r.getChangeId());
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "c.txt",
+            "moreContent",
+            r.getChangeId());
     r = push.to("refs/for/master/%l=Code-Review+2");
     ci = get(r.getChangeId());
-    assertThat(Iterables.getLast(ci.messages).message).isEqualTo(
-        "Uploaded patch set 3.");
+    assertThat(Iterables.getLast(ci.messages).message).isEqualTo("Uploaded patch set 3.");
+  }
+
+  @Test
+  public void pushNewPatchSetForMasterWithApprovals() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master/%l=Code-Review+2");
+
+    ChangeInfo ci = get(r.getChangeId());
+    LabelInfo cr = ci.labels.get("Code-Review");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo("Uploaded patch set 2: Code-Review+2.");
+
+    // Check that the user who pushed the new patch set was added as a reviewer since they added
+    // a vote
+    assertThatUserIsOnlyReviewer(ci, admin);
+
+    assertThat(cr.all).hasSize(1);
+    assertThat(cr.all.get(0).name).isEqualTo("Administrator");
+    assertThat(cr.all.get(0).value).isEqualTo(2);
   }
 
   /**
-   * There was a bug that allowed a user with Forge Committer Identity access
-   * right to upload a commit and put *votes on behalf of another user* on it.
-   * This test checks that this is not possible, but that the votes that are
-   * specified on push are applied only on behalf of the uploader.
+   * There was a bug that allowed a user with Forge Committer Identity access right to upload a
+   * commit and put *votes on behalf of another user* on it. This test checks that this is not
+   * possible, but that the votes that are specified on push are applied only on behalf of the
+   * uploader.
    *
-   * This particular bug only occurred when there was more than one label
-   * defined. However to test that the votes that are specified on push are
-   * applied on behalf of the uploader a single label is sufficient.
+   * <p>This particular bug only occurred when there was more than one label defined. However to
+   * test that the votes that are specified on push are applied on behalf of the uploader a single
+   * label is sufficient.
    */
   @Test
-  public void pushForMasterWithApprovalsForgeCommitterButNoForgeVote()
-      throws Exception {
+  public void pushForMasterWithApprovalsForgeCommitterButNoForgeVote() throws Exception {
     // Create a commit with "User" as author and committer
-    RevCommit c = commitBuilder()
-        .author(user.getIdent())
-        .committer(user.getIdent())
-        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
-        .message(PushOneCommit.SUBJECT)
-        .create();
+    RevCommit c =
+        commitBuilder()
+            .author(user.getIdent())
+            .committer(user.getIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
 
     // Push this commit as "Administrator" (requires Forge Committer Identity)
     pushHead(testRepo, "refs/for/master/%l=Code-Review+1", false);
@@ -391,31 +597,30 @@
     assertThat(cr.all.get(indexAdmin).value.intValue()).isEqualTo(1);
     assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName);
     assertThat(cr.all.get(indexUser).value.intValue()).isEqualTo(0);
-    assertThat(Iterables.getLast(ci.messages).message).isEqualTo(
-        "Uploaded patch set 1: Code-Review+1.");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo("Uploaded patch set 1: Code-Review+1.");
+    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    assertThatUserIsOnlyReviewer(ci, admin);
   }
 
   @Test
-  public void pushWithMultipleApprovals()
-      throws Exception {
-    LabelType Q = category("Custom-Label",
-        value(1, "Positive"),
-        value(0, "No score"),
-        value(-1, "Negative"));
+  public void pushWithMultipleApprovals() throws Exception {
+    LabelType Q =
+        category("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID anon =
-        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    AccountGroup.UUID anon = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
     String heads = "refs/heads/*";
     Util.allow(config, Permission.forLabel("Custom-Label"), -1, 1, anon, heads);
     config.getLabelSections().put(Q.getName(), Q);
     saveProjectConfig(project, config);
 
-    RevCommit c = commitBuilder()
-        .author(admin.getIdent())
-        .committer(admin.getIdent())
-        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
-        .message(PushOneCommit.SUBJECT)
-        .create();
+    RevCommit c =
+        commitBuilder()
+            .author(admin.getIdent())
+            .committer(admin.getIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
 
     pushHead(testRepo, "refs/for/master/%l=Code-Review+1,l=Custom-Label-1", false);
 
@@ -424,6 +629,8 @@
     assertThat(cr.all).hasSize(1);
     cr = ci.labels.get("Custom-Label");
     assertThat(cr.all).hasSize(1);
+    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    assertThatUserIsOnlyReviewer(ci, admin);
   }
 
   @Test
@@ -431,8 +638,14 @@
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "b.txt", "anotherContent", r.getChangeId());
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
     r = push.to("refs/changes/" + r.getChange().change().getId().get());
     r.assertOkStatus();
   }
@@ -441,19 +654,27 @@
   public void pushNewPatchsetToPatchSetLockedChange() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, "b.txt", "anotherContent", r.getChangeId());
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
     r = push.to("refs/for/master");
-    r.assertErrorStatus("cannot add patch set to "
-        + r.getChange().change().getChangeId()
-        + ". Change is patch set locked.");
+    r.assertErrorStatus(
+        "cannot add patch set to "
+            + r.getChange().change().getChangeId()
+            + ". Change is patch set locked.");
   }
 
   @Test
   public void pushForMasterWithApprovals_MissingLabel() throws Exception {
-      PushOneCommit.Result r = pushTo("refs/for/master/%l=Verify");
-      r.assertErrorStatus("label \"Verify\" is not a configured label");
+    PushOneCommit.Result r = pushTo("refs/for/master/%l=Verify");
+    r.assertErrorStatus("label \"Verify\" is not a configured label");
   }
 
   @Test
@@ -487,8 +708,14 @@
     // specify a single hashtag as option in new patch set
     String hashtag2 = "tag2";
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "b.txt", "anotherContent", r.getChangeId());
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
     r = push.to("refs/for/master/%hashtag=" + hashtag2);
     r.assertOkStatus();
     expected = ImmutableSet.of(hashtag1, hashtag2);
@@ -505,8 +732,8 @@
     String hashtag1 = "tag1";
     String hashtag2 = "tag2";
     Set<String> expected = ImmutableSet.of(hashtag1, hashtag2);
-    PushOneCommit.Result r = pushTo("refs/for/master%hashtag=#" + hashtag1
-        + ",hashtag=##" + hashtag2);
+    PushOneCommit.Result r =
+        pushTo("refs/for/master%hashtag=#" + hashtag1 + ",hashtag=##" + hashtag2);
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, null);
 
@@ -517,8 +744,14 @@
     String hashtag3 = "tag3";
     String hashtag4 = "tag4";
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "b.txt", "anotherContent", r.getChangeId());
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
     r = push.to("refs/for/master%hashtag=" + hashtag3 + ",hashtag=" + hashtag4);
     r.assertOkStatus();
     expected = ImmutableSet.of(hashtag1, hashtag2, hashtag3, hashtag4);
@@ -537,26 +770,31 @@
   @Test
   public void pushCommitUsingSignedOffBy() throws Exception {
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "b.txt", "anotherContent");
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
 
     setUseSignedOffBy(InheritableBoolean.TRUE);
     blockForgeCommitter(project, "refs/heads/master");
 
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT + String.format(
-            "\n\nSigned-off-by: %s <%s>", admin.fullName, admin.email),
-        "b.txt", "anotherContent");
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT
+                + String.format("\n\nSigned-off-by: %s <%s>", admin.fullName, admin.email),
+            "b.txt",
+            "anotherContent");
     r = push.to("refs/for/master");
     r.assertOkStatus();
 
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
-    r.assertErrorStatus(
-        "not Signed-off-by author/committer/uploader in commit message footer");
+    r.assertErrorStatus("not Signed-off-by author/committer/uploader in commit message footer");
   }
 
   @Test
@@ -564,21 +802,18 @@
     enableCreateNewChangeForAllNotInTarget();
 
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "a.txt", "content");
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
 
     push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "b.txt", "anotherContent");
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
     r.assertOkStatus();
 
-    gApi.projects()
-        .name(project.get())
-        .branch("otherBranch")
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch("otherBranch").create(new BranchInput());
 
     PushOneCommit.Result r2 = push.to("refs/for/otherBranch");
     r2.assertOkStatus();
@@ -586,33 +821,105 @@
   }
 
   @Test
-  public void pushSameCommitTwiceUsingMagicBranchBaseOption()
+  public void pushChangeBasedOnChangeOfOtherUserWithCreateNewChangeForAllNotInTarget()
       throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+
+    // create a change as admin
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevCommit commitChange1 = r.getCommit();
+
+    // create a second change as user (depends on the change from admin)
+    TestRepository<?> userRepo = cloneProject(project, user);
+    GitUtil.fetch(userRepo, r.getPatchSet().getRefName() + ":change");
+    userRepo.reset("change");
+    push =
+        pushFactory.create(
+            db, user.getIdent(), userRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert that no new change was created for the commit of the predecessor change
+    assertThat(query(commitChange1.name())).hasSize(1);
+  }
+
+  @Test
+  public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
     grant(Permission.PUSH, project, "refs/heads/master");
     PushOneCommit.Result rBase = pushTo("refs/heads/master");
     rBase.assertOkStatus();
 
-    gApi.projects()
-        .name(project.get())
-        .branch("foo")
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
 
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "b.txt", "anotherContent");
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
 
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
 
-    PushResult pr = GitUtil.pushHead(
-        testRepo, "refs/for/foo%base=" + rBase.getCommit().name(), false, false);
+    PushResult pr =
+        GitUtil.pushHead(testRepo, "refs/for/foo%base=" + rBase.getCommit().name(), false, false);
     assertThat(pr.getMessages()).containsMatch("changes: .*new: 1.*done");
 
     assertTwoChangesWithSameRevision(r);
   }
 
-  private void assertTwoChangesWithSameRevision(PushOneCommit.Result result)
-      throws Exception {
+  @Test
+  public void pushSameCommitTwice() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    assertPushRejected(
+        pushHead(testRepo, "refs/for/master", false),
+        "refs/for/master",
+        "commit(s) already exists (as current patchset)");
+  }
+
+  @Test
+  public void pushSameCommitTwiceWhenIndexFailed() throws Exception {
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
+    saveProjectConfig(project, config);
+
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    indexer.delete(r.getChange().getId());
+
+    assertPushRejected(
+        pushHead(testRepo, "refs/for/master", false),
+        "refs/for/master",
+        "commit(s) already exists (as current patchset)");
+  }
+
+  private void assertTwoChangesWithSameRevision(PushOneCommit.Result result) throws Exception {
     List<ChangeInfo> changes = query(result.getCommit().name());
     assertThat(changes).hasSize(2);
     ChangeInfo c1 = get(changes.get(0).id);
@@ -625,6 +932,16 @@
 
   @Test
   public void pushAFewChanges() throws Exception {
+    testPushAFewChanges();
+  }
+
+  @Test
+  public void pushAFewChangesWithCreateNewChangeForAllNotInTarget() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    testPushAFewChanges();
+  }
+
+  private void testPushAFewChanges() throws Exception {
     int n = 10;
     String r = "refs/for/master";
     ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
@@ -645,11 +962,10 @@
       RevCommit c2 = commits2.get(i);
       String name = "change for " + c2.name();
       ChangeData cd = byCommit(c);
-      assertThat(cd.change().getSubject())
+      assertThat(cd.change().getSubject()).named(name).isEqualTo(c2.getShortMessage());
+      assertThat(getPatchSetRevisions(cd))
           .named(name)
-          .isEqualTo(c2.getShortMessage());
-      assertThat(getPatchSetRevisions(cd)).named(name).containsExactlyEntriesIn(
-          ImmutableMap.of(1, c.name(), 2, c2.name()));
+          .containsExactlyEntriesIn(ImmutableMap.of(1, c.name(), 2, c2.name()));
     }
 
     // Pushing again results in "no new changes".
@@ -662,8 +978,7 @@
   }
 
   @Test
-  public void pushWithoutChangeIdWithCreateNewChangeForAllNotInTarget()
-      throws Exception {
+  public void pushWithoutChangeIdWithCreateNewChangeForAllNotInTarget() throws Exception {
     enableCreateNewChangeForAllNotInTarget();
     testPushWithoutChangeId();
   }
@@ -671,8 +986,7 @@
   private void testPushWithoutChangeId() throws Exception {
     RevCommit c = createCommit(testRepo, "Message without Change-Id");
     assertThat(GitUtil.getChangeId(testRepo, c).isPresent()).isFalse();
-    pushForReviewRejected(testRepo,
-        "missing Change-Id in commit message footer");
+    pushForReviewRejected(testRepo, "missing Change-Id in commit message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
     pushForReviewOk(testRepo);
@@ -684,24 +998,22 @@
   }
 
   @Test
-  public void pushWithMultipleChangeIdsWithCreateNewChangeForAllNotInTarget()
-      throws Exception {
+  public void pushWithMultipleChangeIdsWithCreateNewChangeForAllNotInTarget() throws Exception {
     enableCreateNewChangeForAllNotInTarget();
     testPushWithMultipleChangeIds();
   }
 
   private void testPushWithMultipleChangeIds() throws Exception {
-    createCommit(testRepo,
+    createCommit(
+        testRepo,
         "Message with multiple Change-Id\n"
             + "\n"
             + "Change-Id: I10f98c2ef76e52e23aa23be5afeb71e40b350e86\n"
             + "Change-Id: Ie9a132e107def33bdd513b7854b50de911edba0a\n");
-    pushForReviewRejected(testRepo,
-        "multiple Change-Id lines in commit message footer");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo,
-        "multiple Change-Id lines in commit message footer");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
   }
 
   @Test
@@ -710,22 +1022,17 @@
   }
 
   @Test
-  public void pushWithInvalidChangeIdWithCreateNewChangeForAllNotInTarget()
-      throws Exception {
+  public void pushWithInvalidChangeIdWithCreateNewChangeForAllNotInTarget() throws Exception {
     enableCreateNewChangeForAllNotInTarget();
     testpushWithInvalidChangeId();
   }
 
   private void testpushWithInvalidChangeId() throws Exception {
-    createCommit(testRepo, "Message with invalid Change-Id\n"
-        + "\n"
-        + "Change-Id: X\n");
-    pushForReviewRejected(testRepo,
-        "invalid Change-Id line format in commit message footer");
+    createCommit(testRepo, "Message with invalid Change-Id\n\nChange-Id: X\n");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo,
-        "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
   }
 
   @Test
@@ -741,32 +1048,93 @@
   }
 
   private void testPushWithInvalidChangeIdFromEgit() throws Exception {
-    createCommit(testRepo, "Message with invalid Change-Id\n"
-        + "\n"
-        + "Change-Id: I0000000000000000000000000000000000000000\n");
-    pushForReviewRejected(testRepo,
-        "invalid Change-Id line format in commit message footer");
+    createCommit(
+        testRepo,
+        "Message with invalid Change-Id\n"
+            + "\n"
+            + "Change-Id: I0000000000000000000000000000000000000000\n");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo,
-        "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
   }
 
   @Test
   public void pushWithChangeIdInSubjectLine() throws Exception {
     createCommit(testRepo, "Change-Id: I1234000000000000000000000000000000000000");
-    pushForReviewRejected(testRepo,
-        "missing subject; Change-Id must be in commit message footer");
+    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in commit message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo,
-        "missing subject; Change-Id must be in commit message footer");
+    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in commit message footer");
   }
 
-  private static RevCommit createCommit(TestRepository<?> testRepo,
-      String message) throws Exception {
-    return testRepo.branch("HEAD").commit().message(message)
-        .add("a.txt", "content").create();
+  @Test
+  public void pushCommitWithSameChangeIdAsPredecessorChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevCommit commitChange1 = r.getCommit();
+
+    createCommit(testRepo, commitChange1.getFullMessage());
+
+    pushForReviewRejected(
+        testRepo,
+        "same Change-Id in multiple changes.\n"
+            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
+            + " commit");
+
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject().setRequireChangeID(InheritableBoolean.FALSE);
+    saveProjectConfig(project, config);
+
+    pushForReviewRejected(
+        testRepo,
+        "same Change-Id in multiple changes.\n"
+            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
+            + " commit");
+  }
+
+  @Test
+  public void pushTwoCommitWithSameChangeId() throws Exception {
+    RevCommit commitChange1 = createCommitWithChangeId(testRepo, "some change");
+
+    createCommit(testRepo, commitChange1.getFullMessage());
+
+    pushForReviewRejected(
+        testRepo,
+        "same Change-Id in multiple changes.\n"
+            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
+            + " commit");
+
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    config.getProject().setRequireChangeID(InheritableBoolean.FALSE);
+    saveProjectConfig(project, config);
+
+    pushForReviewRejected(
+        testRepo,
+        "same Change-Id in multiple changes.\n"
+            + "Squash the commits with the same Change-Id or ensure Change-Ids are unique for each"
+            + " commit");
+  }
+
+  private static RevCommit createCommit(TestRepository<?> testRepo, String message)
+      throws Exception {
+    return testRepo.branch("HEAD").commit().message(message).add("a.txt", "content").create();
+  }
+
+  private static RevCommit createCommitWithChangeId(TestRepository<?> testRepo, String message)
+      throws Exception {
+    RevCommit c =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .message(message)
+            .insertChangeId()
+            .add("a.txt", "content")
+            .create();
+    return testRepo.getRevWalk().parseCommit(c);
   }
 
   @Test
@@ -782,20 +1150,16 @@
       tr.branch("refs/heads/master").update(r1.getCommit());
     }
 
-    assertThat(gApi.changes().id(id1.get()).info().status)
-        .isEqualTo(ChangeStatus.NEW);
-    assertThat(gApi.changes().id(id2.get()).info().status)
-        .isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id1.get()).info().status).isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id2.get()).info().status).isEqualTo(ChangeStatus.NEW);
     r2 = amendChange(r2.getChangeId());
     r2.assertOkStatus();
 
     // Change 1 is still new despite being merged into the branch, because
     // ReceiveCommits only considers commits between the branch tip (which is
     // now the merged change 1) and the push tip (new patch set of change 2).
-    assertThat(gApi.changes().id(id1.get()).info().status)
-        .isEqualTo(ChangeStatus.NEW);
-    assertThat(gApi.changes().id(id2.get()).info().status)
-        .isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id1.get()).info().status).isEqualTo(ChangeStatus.NEW);
+    assertThat(gApi.changes().id(id2.get()).info().status).isEqualTo(ChangeStatus.NEW);
   }
 
   @Test
@@ -803,8 +1167,7 @@
       throws Exception {
     Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
     ChangeData cd = byChangeId(id);
-    String ps1Rev =
-        Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
 
     String r = "refs/changes/" + id;
     assertPushOk(pushHead(testRepo, r, false), r);
@@ -812,10 +1175,9 @@
     // Added a new patch set and auto-closed the change.
     cd = byChangeId(id);
     assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
-        ImmutableMap.of(
-            1, ps1Rev,
-            2, testRepo.getRepository().resolve("HEAD").name()));
+    assertThat(getPatchSetRevisions(cd))
+        .containsExactlyEntriesIn(
+            ImmutableMap.of(1, ps1Rev, 2, testRepo.getRepository().resolve("HEAD").name()));
   }
 
   @Test
@@ -823,8 +1185,7 @@
       throws Exception {
     Change.Id id = accidentallyPushNewPatchSetDirectlyToBranch();
     ChangeData cd = byChangeId(id);
-    String ps1Rev =
-        Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
+    String ps1Rev = Iterables.getOnlyElement(cd.patchSets()).getRevision().get();
 
     String r = "refs/for/master";
     assertPushRejected(pushHead(testRepo, r, false), r, "no new changes");
@@ -832,12 +1193,32 @@
     // Change not updated.
     cd = byChangeId(id);
     assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
-    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
-        ImmutableMap.of(1, ps1Rev));
+    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(ImmutableMap.of(1, ps1Rev));
   }
 
-  private Change.Id accidentallyPushNewPatchSetDirectlyToBranch()
-      throws Exception {
+  @Test
+  public void forcePushAbandonedChange() throws Exception {
+    grant(Permission.PUSH, project, "refs/*", true);
+    PushOneCommit push1 =
+        pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+    PushOneCommit.Result r = push1.to("refs/for/master");
+    r.assertOkStatus();
+
+    // abandon the change
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    ChangeInfo info = get(changeId);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+    push1.setForce(true);
+    PushOneCommit.Result r1 = push1.to("refs/heads/master");
+    r1.assertOkStatus();
+    ChangeInfo result = Iterables.getOnlyElement(gApi.changes().query(r.getChangeId()).get());
+    assertThat(result.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  private Change.Id accidentallyPushNewPatchSetDirectlyToBranch() throws Exception {
     PushOneCommit.Result r = createChange();
     RevCommit ps1Commit = r.getCommit();
     Change c = r.getChange().change();
@@ -849,20 +1230,21 @@
       // create and push this behind Gerrit's back (e.g. an admin accidentally
       // using direct ssh access to the repo), but that's harder to do in tests.
       TestRepository<?> tr = new TestRepository<>(repo);
-      ps2Commit = tr.branch("refs/heads/master").commit()
-          .message(ps1Commit.getShortMessage() + " v2")
-          .insertChangeId(r.getChangeId().substring(1))
-          .create();
+      ps2Commit =
+          tr.branch("refs/heads/master")
+              .commit()
+              .message(ps1Commit.getShortMessage() + " v2")
+              .insertChangeId(r.getChangeId().substring(1))
+              .create();
     }
 
-    testRepo.git().fetch()
-        .setRefSpecs(new RefSpec("refs/heads/master")).call();
+    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/master")).call();
     testRepo.reset(ps2Commit);
 
     ChangeData cd = byCommit(ps1Commit);
     assertThat(cd.change().getStatus()).isEqualTo(Change.Status.NEW);
-    assertThat(getPatchSetRevisions(cd)).containsExactlyEntriesIn(
-        ImmutableMap.of(1, ps1Commit.name()));
+    assertThat(getPatchSetRevisions(cd))
+        .containsExactlyEntriesIn(ImmutableMap.of(1, ps1Commit.name()));
     return c.getId();
   }
 
@@ -878,9 +1260,7 @@
 
   @Test
   public void pushWithEmailInFooterNotFound() throws Exception {
-    pushWithReviewerInFooter(
-        new Address("No Body", "notarealuser@example.com").toString(),
-        null);
+    pushWithReviewerInFooter(new Address("No Body", "notarealuser@example.com").toString(), null);
   }
 
   @Test
@@ -889,31 +1269,183 @@
   }
 
   @Test
-  // TODO(dborowitz): This is to exercise a specific case in the database search
-  // path. Once the account index becomes obligatory this method can be removed.
-  @GerritConfig(name = "index.testDisable", value = "accounts")
-  public void pushWithNameInFooterNotFoundWithDbSearch() throws Exception {
-    pushWithReviewerInFooter("Notauser", null);
+  public void pushNewPatchsetOverridingStickyLabel() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReview = Util.codeReview();
+    codeReview.setCopyMaxScore(true);
+    cfg.getLabelSections().put(codeReview.getName(), codeReview);
+    saveProjectConfig(cfg);
+
+    PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
+    r = push.to("refs/for/master%l=Code-Review+1");
+    r.assertOkStatus();
   }
 
-  private void pushWithReviewerInFooter(String nameEmail,
-      TestAccount expectedReviewer) throws Exception {
+  @Test
+  public void createChangeForMergedCommit() throws Exception {
+    String master = "refs/heads/master";
+    grant(Permission.PUSH, project, master, true);
+
+    // Update master with a direct push.
+    RevCommit c1 = testRepo.commit().message("Non-change 1").create();
+    RevCommit c2 =
+        testRepo.parseBody(
+            testRepo.commit().parent(c1).message("Non-change 2").insertChangeId().create());
+    String changeId = Iterables.getOnlyElement(c2.getFooterLines(CHANGE_ID));
+
+    testRepo.reset(c2);
+    assertPushOk(pushHead(testRepo, master, false, true), master);
+
+    String q = "commit:" + c1.name() + " OR commit:" + c2.name() + " OR change:" + changeId;
+    assertThat(gApi.changes().query(q).get()).isEmpty();
+
+    // Push c2 as a merged change.
+    String r = "refs/for/master%merged";
+    assertPushOk(pushHead(testRepo, r, false), r);
+
+    EnumSet<ListChangesOption> opts = EnumSet.of(ListChangesOption.CURRENT_REVISION);
+    ChangeInfo info = gApi.changes().id(changeId).get(opts);
+    assertThat(info.currentRevision).isEqualTo(c2.name());
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+
+    // Only c2 was created as a change.
+    String q1 = "commit: " + c1.name();
+    assertThat(gApi.changes().query(q1).get()).isEmpty();
+
+    // Push c1 as a merged change.
+    testRepo.reset(c1);
+    assertPushOk(pushHead(testRepo, r, false), r);
+    List<ChangeInfo> infos = gApi.changes().query(q1).withOptions(opts).get();
+    assertThat(infos).hasSize(1);
+    info = infos.get(0);
+    assertThat(info.currentRevision).isEqualTo(c1.name());
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void mergedOptionFailsWhenCommitIsNotMerged() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%merged");
+    r.assertErrorStatus("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionFailsWhenCommitIsMergedOnOtherBranch() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      tr.branch("refs/heads/branch").commit().message("Initial commit on branch").create();
+    }
+
+    pushTo("refs/for/master%merged").assertErrorStatus("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionFailsWhenChangeExists() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    testRepo.reset(r.getCommit());
+    String ref = "refs/for/master%merged";
+    PushResult pr = pushHead(testRepo, ref, false);
+    RemoteRefUpdate rru = pr.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).contains("no new changes");
+  }
+
+  @Test
+  public void mergedOptionWithNewCommitWithSameChangeIdFails() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    RevCommit c2 =
+        testRepo
+            .amend(r.getCommit())
+            .message("New subject")
+            .insertChangeId(r.getChangeId().substring(1))
+            .create();
+    testRepo.reset(c2);
+
+    String ref = "refs/for/master%merged";
+    PushResult pr = pushHead(testRepo, ref, false);
+    RemoteRefUpdate rru = pr.getRemoteUpdate(ref);
+    assertThat(rru.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(rru.getMessage()).contains("not merged into branch");
+  }
+
+  @Test
+  public void mergedOptionWithExistingChangeInsertsPatchSet() throws Exception {
+    String master = "refs/heads/master";
+    grant(Permission.PUSH, project, master, true);
+
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    ObjectId c1 = r.getCommit().copy();
+
+    // Create a PS2 commit directly on master in the server's repo. This
+    // simulates the client amending locally and pushing directly to the branch,
+    // expecting the change to be auto-closed, but the change metadata update
+    // fails.
+    ObjectId c2;
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      RevCommit commit2 =
+          tr.amend(c1).message("New subject").insertChangeId(r.getChangeId().substring(1)).create();
+      c2 = commit2.copy();
+      tr.update(master, c2);
+    }
+
+    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/master")).call();
+    testRepo.reset(c2);
+
+    String ref = "refs/for/master%merged";
+    assertPushOk(pushHead(testRepo, ref, false), ref);
+
+    EnumSet<ListChangesOption> opts = EnumSet.of(ListChangesOption.ALL_REVISIONS);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(opts);
+    assertThat(info.currentRevision).isEqualTo(c2.name());
+    assertThat(info.revisions.keySet()).containsExactly(c1.name(), c2.name());
+    // TODO(dborowitz): Fix ReceiveCommits to also auto-close the change.
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  private void assertThatUserIsOnlyReviewer(ChangeInfo ci, TestAccount reviewer) {
+    assertThat(ci.reviewers).isNotNull();
+    assertThat(ci.reviewers.keySet()).containsExactly(ReviewerState.REVIEWER);
+    assertThat(ci.reviewers.get(ReviewerState.REVIEWER).iterator().next().email)
+        .isEqualTo(reviewer.email);
+  }
+
+  private void pushWithReviewerInFooter(String nameEmail, TestAccount expectedReviewer)
+      throws Exception {
     int n = 5;
     String r = "refs/for/master";
     ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
-    List<RevCommit> commits =
-        createChanges(n, r, ImmutableList.of("Acked-By: " + nameEmail));
+    List<RevCommit> commits = createChanges(n, r, ImmutableList.of("Acked-By: " + nameEmail));
     for (int i = 0; i < n; i++) {
       RevCommit c = commits.get(i);
       ChangeData cd = byCommit(c);
       String name = "reviewers for " + (i + 1);
       if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name)
-            .containsExactly(expectedReviewer.getId());
-        gApi.changes()
-            .id(cd.getId().get())
-            .reviewer(expectedReviewer.getId().toString())
-            .remove();
+        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.getId());
+        gApi.changes().id(cd.getId().get()).reviewer(expectedReviewer.getId().toString()).remove();
       }
       assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
     }
@@ -924,21 +1456,19 @@
       ChangeData cd = byCommit(c);
       String name = "reviewers for " + (i + 1);
       if (expectedReviewer != null) {
-        assertThat(cd.reviewers().all()).named(name)
-            .containsExactly(expectedReviewer.getId());
+        assertThat(cd.reviewers().all()).named(name).containsExactly(expectedReviewer.getId());
       } else {
         assertThat(byCommit(c).reviewers().all()).named(name).isEmpty();
       }
     }
   }
 
-  private List<RevCommit> createChanges(int n, String refsFor)
-      throws Exception {
+  private List<RevCommit> createChanges(int n, String refsFor) throws Exception {
     return createChanges(n, refsFor, ImmutableList.<String>of());
   }
 
-  private List<RevCommit> createChanges(int n, String refsFor,
-      List<String> footerLines) throws Exception {
+  private List<RevCommit> createChanges(int n, String refsFor, List<String> footerLines)
+      throws Exception {
     List<RevCommit> commits = new ArrayList<>(n);
     for (int i = 1; i <= n; i++) {
       String msg = "Change " + i;
@@ -949,8 +1479,8 @@
         }
         msg = sb.toString();
       }
-      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit()
-          .message(msg).insertChangeId();
+      TestRepository<?>.CommitBuilder cb =
+          testRepo.branch("HEAD").commit().message(msg).insertChangeId();
       if (!commits.isEmpty()) {
         cb.parent(commits.get(commits.size() - 1));
       }
@@ -962,8 +1492,8 @@
     return commits;
   }
 
-  private List<RevCommit> amendChanges(ObjectId initialHead,
-      List<RevCommit> origCommits, String refsFor) throws Exception {
+  private List<RevCommit> amendChanges(
+      ObjectId initialHead, List<RevCommit> origCommits, String refsFor) throws Exception {
     testRepo.reset(initialHead);
     List<RevCommit> newCommits = new ArrayList<>(origCommits.size());
     for (RevCommit c : origCommits) {
@@ -971,8 +1501,7 @@
       if (!c.getShortMessage().equals(c.getFullMessage())) {
         msg = msg + c.getFullMessage().substring(c.getShortMessage().length());
       }
-      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit()
-          .message(msg);
+      TestRepository<?>.CommitBuilder cb = testRepo.branch("HEAD").commit().message(msg);
       if (!newCommits.isEmpty()) {
         cb.parent(origCommits.get(newCommits.size() - 1));
       }
@@ -984,8 +1513,7 @@
     return newCommits;
   }
 
-  private static Map<Integer, String> getPatchSetRevisions(ChangeData cd)
-      throws Exception {
+  private static Map<Integer, String> getPatchSetRevisions(ChangeData cd) throws Exception {
     Map<Integer, String> revisions = new HashMap<>();
     for (PatchSet ps : cd.patchSets()) {
       revisions.put(ps.getPatchSetId(), ps.getRevision().get());
@@ -1005,20 +1533,18 @@
     return cds.get(0);
   }
 
-  private static void pushForReviewOk(TestRepository<?> testRepo)
-      throws GitAPIException {
+  private static void pushForReviewOk(TestRepository<?> testRepo) throws GitAPIException {
     pushForReview(testRepo, RemoteRefUpdate.Status.OK, null);
   }
 
-  private static void pushForReviewRejected(TestRepository<?> testRepo,
-      String expectedMessage) throws GitAPIException {
-    pushForReview(testRepo, RemoteRefUpdate.Status.REJECTED_OTHER_REASON,
-        expectedMessage);
+  private static void pushForReviewRejected(TestRepository<?> testRepo, String expectedMessage)
+      throws GitAPIException {
+    pushForReview(testRepo, RemoteRefUpdate.Status.REJECTED_OTHER_REASON, expectedMessage);
   }
 
-  private static void pushForReview(TestRepository<?> testRepo,
-      RemoteRefUpdate.Status expectedStatus, String expectedMessage)
-          throws GitAPIException {
+  private static void pushForReview(
+      TestRepository<?> testRepo, RemoteRefUpdate.Status expectedStatus, String expectedMessage)
+      throws GitAPIException {
     String ref = "refs/for/master";
     PushResult r = pushHead(testRepo, ref);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(ref);
@@ -1027,11 +1553,4 @@
       assertThat(refUpdate.getMessage()).contains(expectedMessage);
     }
   }
-
-  private void enableCreateNewChangeForAllNotInTarget() throws Exception {
-    ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject()
-        .setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
-    saveProjectConfig(project, config);
-  }
 }
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 8b77238..42463c7 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
@@ -19,12 +19,13 @@
 import com.google.common.collect.Iterables;
 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.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -38,8 +39,6 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 
-import java.util.concurrent.atomic.AtomicInteger;
-
 public abstract class AbstractSubmoduleSubscription extends AbstractDaemonTest {
 
   protected SubmitType getSubmitType() {
@@ -60,53 +59,60 @@
     return cfg;
   }
 
-  protected static Config submitByCherryPickConifg() {
+  protected static Config submitByCherryPickConfig() {
     Config cfg = new Config();
     cfg.setBoolean("change", null, "submitWholeTopic", true);
     cfg.setEnum("project", null, "submitType", SubmitType.CHERRY_PICK);
     return cfg;
   }
 
-  protected static Config submitByRebaseConifg() {
+  protected static Config submitByRebaseAlwaysConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    cfg.setEnum("project", null, "submitType", SubmitType.REBASE_ALWAYS);
+    return cfg;
+  }
+
+  protected static Config submitByRebaseIfNecessaryConfig() {
     Config cfg = new Config();
     cfg.setBoolean("change", null, "submitWholeTopic", true);
     cfg.setEnum("project", null, "submitType", SubmitType.REBASE_IF_NECESSARY);
     return cfg;
   }
 
-  protected TestRepository<?> createProjectWithPush(String name,
-      @Nullable Project.NameKey parent, boolean createEmptyCommit,
-      SubmitType submitType) throws Exception {
+  protected TestRepository<?> createProjectWithPush(
+      String name,
+      @Nullable Project.NameKey parent,
+      boolean createEmptyCommit,
+      SubmitType submitType)
+      throws Exception {
     Project.NameKey project = createProject(name, parent, createEmptyCommit, submitType);
-    grant("push", project, "refs/heads/*");
-    grant("submit", project, "refs/for/refs/heads/*");
+    grant(Permission.PUSH, project, "refs/heads/*");
+    grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
     return cloneProject(project);
   }
 
-  protected TestRepository<?> createProjectWithPush(String name,
-      @Nullable Project.NameKey parent) throws Exception {
+  protected TestRepository<?> createProjectWithPush(String name, @Nullable Project.NameKey parent)
+      throws Exception {
     return createProjectWithPush(name, parent, true, getSubmitType());
   }
 
-  protected TestRepository<?> createProjectWithPush(String name,
-      boolean createEmptyCommit) throws Exception {
+  protected TestRepository<?> createProjectWithPush(String name, boolean createEmptyCommit)
+      throws Exception {
     return createProjectWithPush(name, null, createEmptyCommit, getSubmitType());
   }
 
-  protected TestRepository<?> createProjectWithPush(String name)
-      throws Exception {
+  protected TestRepository<?> createProjectWithPush(String name) throws Exception {
     return createProjectWithPush(name, null, true, getSubmitType());
   }
 
   private static AtomicInteger contentCounter = new AtomicInteger(0);
 
-  protected ObjectId pushChangeTo(TestRepository<?> repo, String ref,
-      String file, String content, String message, String topic)
+  protected ObjectId pushChangeTo(
+      TestRepository<?> repo, String ref, String file, String content, String message, String topic)
       throws Exception {
-    ObjectId ret = repo.branch("HEAD").commit().insertChangeId()
-      .message(message)
-      .add(file, content)
-      .create();
+    ObjectId ret =
+        repo.branch("HEAD").commit().insertChangeId().message(message).add(file, content).create();
 
     String pushedRef = ref;
     if (!topic.isEmpty()) {
@@ -114,8 +120,8 @@
     }
     String refspec = "HEAD:" + pushedRef;
 
-    Iterable<PushResult> res = repo.git().push()
-        .setRemote("origin").setRefSpecs(new RefSpec(refspec)).call();
+    Iterable<PushResult> res =
+        repo.git().push().setRemote("origin").setRefSpecs(new RefSpec(refspec)).call();
 
     RemoteRefUpdate u = Iterables.getOnlyElement(res).getRemoteUpdate(pushedRef);
     assertThat(u).isNotNull();
@@ -125,19 +131,18 @@
     return ret;
   }
 
-  protected ObjectId pushChangeTo(TestRepository<?> repo, String ref,
-      String message, String topic) throws Exception {
-    return pushChangeTo(repo, ref, "a.txt",
-        "a contents: " + contentCounter.incrementAndGet(), message, topic);
+  protected ObjectId pushChangeTo(TestRepository<?> repo, String ref, String message, String topic)
+      throws Exception {
+    return pushChangeTo(
+        repo, ref, "a.txt", "a contents: " + contentCounter.incrementAndGet(), message, topic);
   }
 
-  protected ObjectId pushChangeTo(TestRepository<?> repo, String branch)
-      throws Exception {
+  protected ObjectId pushChangeTo(TestRepository<?> repo, String branch) throws Exception {
     return pushChangeTo(repo, "refs/heads/" + branch, "some change", "");
   }
 
-  protected void allowSubmoduleSubscription(String submodule,
-      String subBranch, String superproject, String superBranch, boolean match)
+  protected void allowSubmoduleSubscription(
+      String submodule, String subBranch, String superproject, String superBranch, boolean match)
       throws Exception {
     Project.NameKey sub = new Project.NameKey(name(submodule));
     Project.NameKey superName = new Project.NameKey(name(superproject));
@@ -169,31 +174,37 @@
     }
   }
 
-  protected void allowMatchingSubmoduleSubscription(String submodule,
-      String subBranch, String superproject, String superBranch)
+  protected void allowMatchingSubmoduleSubscription(
+      String submodule, String subBranch, String superproject, String superBranch)
       throws Exception {
-    allowSubmoduleSubscription(submodule, subBranch, superproject,
-        superBranch, true);
+    allowSubmoduleSubscription(submodule, subBranch, superproject, superBranch, true);
   }
 
-  protected void createSubmoduleSubscription(TestRepository<?> repo, String branch,
-      String subscribeToRepo, String subscribeToBranch) throws Exception {
+  protected void createSubmoduleSubscription(
+      TestRepository<?> repo, String branch, String subscribeToRepo, String subscribeToBranch)
+      throws Exception {
     Config config = new Config();
     prepareSubmoduleConfigEntry(config, subscribeToRepo, subscribeToBranch);
     pushSubmoduleConfig(repo, branch, config);
   }
 
-  protected void createRelativeSubmoduleSubscription(TestRepository<?> repo,
-      String branch, String subscribeToRepoPrefix, String subscribeToRepo,
-      String subscribeToBranch) throws Exception {
+  protected void createRelativeSubmoduleSubscription(
+      TestRepository<?> repo,
+      String branch,
+      String subscribeToRepoPrefix,
+      String subscribeToRepo,
+      String subscribeToBranch)
+      throws Exception {
     Config config = new Config();
-    prepareRelativeSubmoduleConfigEntry(config, subscribeToRepoPrefix,
-        subscribeToRepo, subscribeToBranch);
+    prepareRelativeSubmoduleConfigEntry(
+        config, subscribeToRepoPrefix, subscribeToRepo, subscribeToBranch);
     pushSubmoduleConfig(repo, branch, config);
   }
 
-  protected void prepareRelativeSubmoduleConfigEntry(Config config,
-      String subscribeToRepoPrefix, String subscribeToRepo,
+  protected void prepareRelativeSubmoduleConfigEntry(
+      Config config,
+      String subscribeToRepoPrefix,
+      String subscribeToRepo,
       String subscribeToBranch) {
     subscribeToRepo = name(subscribeToRepo);
     String url = subscribeToRepoPrefix + subscribeToRepo;
@@ -204,23 +215,22 @@
     }
   }
 
-  protected void prepareSubmoduleConfigEntry(Config config,
-      String subscribeToRepo, String subscribeToBranch) {
+  protected void prepareSubmoduleConfigEntry(
+      Config config, String subscribeToRepo, String subscribeToBranch) {
     // The submodule subscription module checks for gerrit.canonicalWebUrl to
     // detect if it's configured for automatic updates. It doesn't matter if
     // it serves from that URL.
     prepareSubmoduleConfigEntry(config, subscribeToRepo, subscribeToRepo, subscribeToBranch);
   }
 
-  protected void prepareSubmoduleConfigEntry(Config config,
-      String subscribeToRepo, String subscribeToRepoPath, String subscribeToBranch) {
+  protected void prepareSubmoduleConfigEntry(
+      Config config, String subscribeToRepo, String subscribeToRepoPath, String subscribeToBranch) {
     subscribeToRepo = name(subscribeToRepo);
     subscribeToRepoPath = name(subscribeToRepoPath);
     // The submodule subscription module checks for gerrit.canonicalWebUrl to
     // detect if it's configured for automatic updates. It doesn't matter if
     // it serves from that URL.
-    String url = cfg.getString("gerrit", null, "canonicalWebUrl") + "/"
-        + subscribeToRepo;
+    String url = cfg.getString("gerrit", null, "canonicalWebUrl") + "/" + subscribeToRepo;
     config.setString("submodule", subscribeToRepoPath, "path", subscribeToRepoPath);
     config.setString("submodule", subscribeToRepoPath, "url", url);
     if (subscribeToBranch != null) {
@@ -228,28 +238,48 @@
     }
   }
 
-  protected void pushSubmoduleConfig(TestRepository<?> repo,
-      String branch, Config config) throws Exception {
+  protected void pushSubmoduleConfig(TestRepository<?> repo, String branch, Config config)
+      throws Exception {
 
-    repo.branch("HEAD").commit().insertChangeId()
-      .message("subject: adding new subscription")
-      .add(".gitmodules", config.toText().toString())
-      .create();
+    repo.branch("HEAD")
+        .commit()
+        .insertChangeId()
+        .message("subject: adding new subscription")
+        .add(".gitmodules", config.toText().toString())
+        .create();
 
-    repo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/" + branch)).call();
+    repo.git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/" + branch))
+        .call();
   }
 
-  protected void expectToHaveSubmoduleState(TestRepository<?> repo,
-      String branch, String submodule, TestRepository<?> subRepo,
-      String subBranch) throws Exception {
+  protected void expectToHaveSubmoduleState(
+      TestRepository<?> repo,
+      String branch,
+      String submodule,
+      TestRepository<?> subRepo,
+      String subBranch)
+      throws Exception {
 
     submodule = name(submodule);
-    ObjectId commitId = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/" + branch).getObjectId();
+    ObjectId commitId =
+        repo.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/" + branch)
+            .getObjectId();
 
-    ObjectId subHead = subRepo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/" + subBranch).getObjectId();
+    ObjectId subHead =
+        subRepo
+            .git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/" + subBranch)
+            .getObjectId();
 
     RevWalk rw = repo.getRevWalk();
     RevCommit c = rw.parseCommit(commitId);
@@ -261,12 +291,18 @@
     assertThat(actualId).isEqualTo(subHead);
   }
 
-  protected void expectToHaveSubmoduleState(TestRepository<?> repo,
-      String branch, String submodule, ObjectId expectedId) throws Exception {
+  protected void expectToHaveSubmoduleState(
+      TestRepository<?> repo, String branch, String submodule, ObjectId expectedId)
+      throws Exception {
 
     submodule = name(submodule);
-    ObjectId commitId = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/" + branch).getObjectId();
+    ObjectId commitId =
+        repo.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/" + branch)
+            .getObjectId();
 
     RevWalk rw = repo.getRevWalk();
     RevCommit c = rw.parseCommit(commitId);
@@ -278,46 +314,66 @@
     assertThat(actualId).isEqualTo(expectedId);
   }
 
-  protected void deleteAllSubscriptions(TestRepository<?> repo, String branch)
-      throws Exception {
+  protected void deleteAllSubscriptions(TestRepository<?> repo, String branch) throws Exception {
     repo.git().fetch().setRemote("origin").call();
     repo.reset("refs/remotes/origin/" + branch);
 
-    ObjectId expectedId = repo.branch("HEAD").commit().insertChangeId()
-        .message("delete contents in .gitmodules")
-        .add(".gitmodules", "") // Just remove the contents of the file!
-        .create();
-    repo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/" + branch)).call();
+    ObjectId expectedId =
+        repo.branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("delete contents in .gitmodules")
+            .add(".gitmodules", "") // Just remove the contents of the file!
+            .create();
+    repo.git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/" + branch))
+        .call();
 
-    ObjectId actualId = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/master").getObjectId();
+    ObjectId actualId =
+        repo.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId();
     assertThat(actualId).isEqualTo(expectedId);
   }
 
-  protected void deleteGitModulesFile(TestRepository<?> repo, String branch)
-      throws Exception {
+  protected void deleteGitModulesFile(TestRepository<?> repo, String branch) throws Exception {
     repo.git().fetch().setRemote("origin").call();
     repo.reset("refs/remotes/origin/" + branch);
 
-    ObjectId expectedId = repo.branch("HEAD").commit().insertChangeId()
-        .message("delete .gitmodules")
-        .rm(".gitmodules")
-        .create();
-    repo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/" + branch)).call();
+    ObjectId expectedId =
+        repo.branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("delete .gitmodules")
+            .rm(".gitmodules")
+            .create();
+    repo.git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/" + branch))
+        .call();
 
-    ObjectId actualId = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/master").getObjectId();
+    ObjectId actualId =
+        repo.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId();
     assertThat(actualId).isEqualTo(expectedId);
   }
 
-  protected boolean hasSubmodule(TestRepository<?> repo, String branch,
-      String submodule) throws Exception {
+  protected boolean hasSubmodule(TestRepository<?> repo, String branch, String submodule)
+      throws Exception {
 
     submodule = name(submodule);
-    Ref branchTip = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/" + branch);
+    Ref branchTip =
+        repo.git().fetch().setRemote("origin").call().getAdvertisedRef("refs/heads/" + branch);
     if (branchTip == null) {
       return false;
     }
@@ -337,11 +393,16 @@
     }
   }
 
-  protected void expectToHaveCommitMessage(TestRepository<?> repo,
-      String branch, String expectedMessage) throws Exception {
+  protected void expectToHaveCommitMessage(
+      TestRepository<?> repo, String branch, String expectedMessage) throws Exception {
 
-    ObjectId commitId = repo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/" + branch).getObjectId();
+    ObjectId commitId =
+        repo.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/" + branch)
+            .getObjectId();
 
     RevWalk rw = repo.getRevWalk();
     RevCommit c = rw.parseCommit(commitId);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUCK
deleted file mode 100644
index f6796a5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUCK
+++ /dev/null
@@ -1,26 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'git',
-  srcs = glob(['*IT.java']),
-  deps = [
-    ':submodule_util',
-    ':push_for_review',
-  ],
-  labels = ['git'],
-)
-
-java_library(
-  name = 'push_for_review',
-  srcs = ['AbstractPushForReview.java'],
-  deps = [
-    '//gerrit-acceptance-tests:lib',
-    '//lib/joda:joda-time',
-  ],
-)
-
-java_library(
-  name = 'submodule_util',
-  srcs = ['AbstractSubmoduleSubscription.java',],
-  deps = ['//gerrit-acceptance-tests:lib',]
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
index db0d8e9..79d0cb8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/BUILD
@@ -1,26 +1,29 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-  group = 'git',
-  srcs = glob(['*IT.java']),
-  deps = [
-    ':submodule_util',
-    ':push_for_review',
-  ],
-  labels = ['git'],
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
+    labels = ["git"],
+    deps = [
+        ":push_for_review",
+        ":submodule_util",
+    ],
+) for f in glob(["*IT.java"])]
+
+java_library(
+    name = "push_for_review",
+    testonly = 1,
+    srcs = ["AbstractPushForReview.java"],
+    deps = [
+        "//gerrit-acceptance-tests:lib",
+        "//lib/joda:joda-time",
+    ],
 )
 
 java_library(
-  name = 'push_for_review',
-  srcs = ['AbstractPushForReview.java'],
-  deps = [
-    '//gerrit-acceptance-tests:lib',
-    '//lib/joda:joda-time',
-  ],
-)
-
-java_library(
-  name = 'submodule_util',
-  srcs = ['AbstractSubmoduleSubscription.java',],
-  deps = ['//gerrit-acceptance-tests:lib',]
+    name = "submodule_util",
+    testonly = 1,
+    srcs = ["AbstractSubmoduleSubscription.java"],
+    deps = ["//gerrit-acceptance-tests:lib"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
index 41f47a2..f2dc8d5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
-
 import org.junit.Before;
 import org.junit.Test;
 
@@ -33,14 +32,14 @@
   }
 
   @Test
-  public void testPushDraftChange_Blocked() throws Exception {
+  public void pushDraftChange_Blocked() throws Exception {
     // create draft by pushing to 'refs/drafts/'
     PushOneCommit.Result r = pushTo("refs/drafts/master");
     r.assertErrorStatus("cannot upload drafts");
   }
 
   @Test
-  public void testPushDraftChangeMagic_Blocked() throws Exception {
+  public void pushDraftChangeMagic_Blocked() throws Exception {
     // create draft by using 'draft' option
     PushOneCommit.Result r = pushTo("refs/for/master%draft");
     r.assertErrorStatus("cannot upload drafts");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
index 38f28df..da8302b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -15,16 +15,23 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static org.eclipse.jgit.lib.Constants.HEAD;
+import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;
+import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.common.data.Permission;
-
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
+@NoHttpd
 public class ForcePushIT extends AbstractDaemonTest {
 
   @Test
@@ -67,4 +74,36 @@
     PushOneCommit.Result r2 = push2.to("refs/heads/master");
     r2.assertOkStatus();
   }
+
+  @Test
+  public void deleteNotAllowed() throws Exception {
+    assertDeleteRef(REJECTED_OTHER_REASON);
+  }
+
+  @Test
+  public void deleteNotAllowedWithOnlyPushPermission() throws Exception {
+    grant(Permission.PUSH, project, "refs/*", false);
+    assertDeleteRef(REJECTED_OTHER_REASON);
+  }
+
+  @Test
+  public void deleteAllowedWithForcePushPermission() throws Exception {
+    grant(Permission.PUSH, project, "refs/*", true);
+    assertDeleteRef(OK);
+  }
+
+  @Test
+  public void deleteAllowedWithDeletePermission() throws Exception {
+    grant(Permission.DELETE, project, "refs/*", true);
+    assertDeleteRef(OK);
+  }
+
+  private void assertDeleteRef(RemoteRefUpdate.Status expectedStatus) throws Exception {
+    BranchInput in = new BranchInput();
+    in.ref = "refs/heads/test";
+    gApi.projects().name(project.get()).branch(in.ref).create(in);
+    PushResult result = deleteRef(testRepo, in.ref);
+    RemoteRefUpdate refUpdate = result.getRemoteUpdate(in.ref);
+    assertThat(refUpdate.getStatus()).isEqualTo(expectedStatus);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitmodulesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitmodulesIT.java
new file mode 100644
index 0000000..a13c8c8
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitmodulesIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Test;
+
+public class GitmodulesIT extends AbstractDaemonTest {
+  @Test
+  public void invalidSubmoduleURLIsRejected() throws Exception {
+    pushGitmodules("name", "-invalid-url", "path", "Invalid submodule URL");
+  }
+
+  @Test
+  public void invalidSubmodulePathIsRejected() throws Exception {
+    pushGitmodules("name", "http://somewhere", "-invalid-path", "Invalid submodule path");
+  }
+
+  @Test
+  public void invalidSubmoduleNameIsRejected() throws Exception {
+    pushGitmodules("-invalid-name", "http://somewhere", "path", "Invalid submodule name");
+  }
+
+  private void pushGitmodules(String name, String url, String path, String expectedErrorMessage)
+      throws Exception {
+    Config config = new Config();
+    config.setString("submodule", name, "url", url);
+    config.setString("submodule", name, "path", path);
+    TestRepository<?> repo = cloneProject(project);
+    repo.branch("HEAD")
+        .commit()
+        .insertChangeId()
+        .message("subject: adding new subscription")
+        .add(".gitmodules", config.toText().toString())
+        .create();
+
+    exception.expectMessage(expectedErrorMessage);
+    exception.expect(TransportException.class);
+    repo.git().push().setRemote("origin").setRefSpecs(new RefSpec("HEAD:refs/for/master")).call();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
index 9f884c8..ed17c38 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
@@ -21,8 +21,8 @@
 public class HttpPushForReviewIT extends AbstractPushForReview {
   @Before
   public void selectHttpUrl() throws Exception {
-    CredentialsProvider.setDefault(new UsernamePasswordCredentialsProvider(
-        admin.username, admin.httpPassword));
+    CredentialsProvider.setDefault(
+        new UsernamePasswordCredentialsProvider(admin.username, admin.httpPassword));
     selectProtocol(Protocol.HTTP);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index e7097f0..0064570 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.git.ProjectConfig;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -61,8 +60,7 @@
     PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
     PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
 
-    assertThat(c.getMessage().toLowerCase()).doesNotContain(
-        implicitMergeOf(m.getCommit()));
+    assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
   }
 
   @Test
@@ -75,8 +73,7 @@
     PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
     PushOneCommit.Result c = push("refs/for/master", "2", "f", "2");
 
-    assertThat(c.getMessage().toLowerCase()).doesNotContain(
-        implicitMergeOf(m.getCommit()));
+    assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
   }
 
   private static String implicitMergeOf(ObjectId commit) {
@@ -89,10 +86,10 @@
     saveProjectConfig(project, cfg);
   }
 
-  private PushOneCommit.Result push(String ref, String subject,
-      String fileName, String content) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        subject, fileName, content);
+  private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
     return push.to(ref);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
new file mode 100644
index 0000000..b900cc7
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -0,0 +1,552 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+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.projects.BranchInput;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.ReceiveCommitsAdvertiseRefsHook;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.DisabledReviewDb;
+import com.google.gerrit.testutil.TestChanges;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class RefAdvertisementIT extends AbstractDaemonTest {
+  @Inject private ProjectControl.GenericFactory projectControlFactory;
+
+  @Inject @Nullable private SearchingChangeCacheImpl changeCache;
+
+  @Inject private TagCache tagCache;
+
+  @Inject private Provider<CurrentUser> userProvider;
+
+  @Inject private ChangeNoteUtil noteUtil;
+
+  @Inject @AnonymousCowardName private String anonymousCowardName;
+
+  private AccountGroup.UUID admins;
+
+  private ChangeData c1;
+  private ChangeData c2;
+  private ChangeData c3;
+  private ChangeData c4;
+  private String r1;
+  private String r2;
+  private String r3;
+  private String r4;
+
+  @Before
+  public void setUp() throws Exception {
+    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID();
+    setUpPermissions();
+    setUpChanges();
+  }
+
+  private void setUpPermissions() throws Exception {
+    // Remove read permissions for all users besides admin. This method is
+    // idempotent, so is safe to call on every test setup.
+    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
+    for (AccessSection sec : pc.getAccessSections()) {
+      sec.removePermission(Permission.READ);
+    }
+    Util.allow(pc, Permission.READ, admins, "refs/*");
+    saveProjectConfig(allProjects, pc);
+  }
+
+  private static String changeRefPrefix(Change.Id id) {
+    String ps = new PatchSet.Id(id, 1).toRefName();
+    return ps.substring(0, ps.length() - 1);
+  }
+
+  private void setUpChanges() throws Exception {
+    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
+
+    // First 2 changes are merged, which means the tags pointing to them are
+    // visible.
+    allow(Permission.SUBMIT, admins, "refs/for/refs/heads/*");
+    PushOneCommit.Result mr =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%submit");
+    mr.assertOkStatus();
+    c1 = mr.getChange();
+    r1 = changeRefPrefix(c1.getId());
+    PushOneCommit.Result br =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/branch%submit");
+    br.assertOkStatus();
+    c2 = br.getChange();
+    r2 = changeRefPrefix(c2.getId());
+
+    // Second 2 changes are unmerged.
+    mr = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
+    mr.assertOkStatus();
+    c3 = mr.getChange();
+    r3 = changeRefPrefix(c3.getId());
+    br = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/branch");
+    br.assertOkStatus();
+    c4 = br.getChange();
+    r4 = changeRefPrefix(c4.getId());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // master-tag -> master
+      RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
+      mtu.setExpectedOldObjectId(ObjectId.zeroId());
+      mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
+      assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
+
+      // branch-tag -> branch
+      RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
+      btu.setExpectedOldObjectId(ObjectId.zeroId());
+      btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
+      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+    }
+  }
+
+  @Test
+  public void uploadPackAllRefsVisibleNoRefsMetaConfig() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.READ, admins, RefNames.REFS_CONFIG);
+    Util.doNotInherit(cfg, Permission.READ, RefNames.REFS_CONFIG);
+    saveProjectConfig(project, cfg);
+
+    setApiUser(user);
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        "refs/heads/branch",
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackAllRefsVisibleWithRefsMetaConfig() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/*");
+    allow(Permission.READ, REGISTERED_USERS, RefNames.REFS_CONFIG);
+
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        "refs/heads/branch",
+        "refs/heads/master",
+        RefNames.REFS_CONFIG,
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+
+    setApiUser(user);
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
+    deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+
+    setApiUser(user);
+    assertUploadPackRefs(
+        r2 + "1",
+        r2 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        "refs/heads/branch",
+        "refs/tags/branch-tag",
+        // master branch is not visible but master-tag is reachable from branch
+        // (since PushOneCommit always bases changes on each other).
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+
+    Change c = notesFactory.createChecked(db, project, c1.getId()).getChange();
+    String changeId = c.getKey().get();
+
+    // Admin's edit is not visible.
+    setApiUser(admin);
+    gApi.changes().id(changeId).edit().create();
+
+    // User's edit is visible.
+    setApiUser(user);
+    gApi.changes().id(changeId).edit().create();
+
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/master-tag",
+        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
+  }
+
+  @Test
+  public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    try {
+      deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+      allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+
+      String changeId = c1.change().getKey().get();
+      setApiUser(admin);
+      gApi.changes().id(changeId).edit().create();
+      setApiUser(user);
+
+      assertUploadPackRefs(
+          // Change 1 is visible due to accessDatabase capability, even though
+          // refs/heads/master is not.
+          r1 + "1",
+          r1 + "meta",
+          r2 + "1",
+          r2 + "meta",
+          r3 + "1",
+          r3 + "meta",
+          r4 + "1",
+          r4 + "meta",
+          "refs/heads/branch",
+          "refs/tags/branch-tag",
+          // See comment in subsetOfBranchesVisibleNotIncludingHead.
+          "refs/tags/master-tag",
+          // All edits are visible due to accessDatabase capability.
+          "refs/users/00/1000000/edit-" + c1.getId() + "/1");
+    } finally {
+      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    }
+  }
+
+  @Test
+  public void uploadPackDraftRefs() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
+
+    PushOneCommit.Result br =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    br.assertOkStatus();
+    Change.Id c5 = br.getChange().getId();
+    String r5 = changeRefPrefix(c5);
+
+    // Only admin can see admin's draft change (5).
+    setApiUser(admin);
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        r5 + "1",
+        r5 + "meta",
+        "refs/heads/branch",
+        "refs/heads/master",
+        RefNames.REFS_CONFIG,
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+
+    // user can't.
+    setApiUser(user);
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r2 + "1",
+        r2 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        r4 + "1",
+        r4 + "meta",
+        "refs/heads/branch",
+        "refs/heads/master",
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+  }
+
+  @Test
+  public void uploadPackNoSearchingChangeCacheImpl() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
+
+    setApiUser(user);
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertRefs(
+          repo,
+          new VisibleRefFilter(tagCache, notesFactory, null, repo, projectControl(), db, true),
+          // Can't use stored values from the index so DB must be enabled.
+          false,
+          "HEAD",
+          r1 + "1",
+          r1 + "meta",
+          r2 + "1",
+          r2 + "meta",
+          r3 + "1",
+          r3 + "meta",
+          r4 + "1",
+          r4 + "meta",
+          "refs/heads/branch",
+          "refs/heads/master",
+          "refs/tags/branch-tag",
+          "refs/tags/master-tag");
+    }
+  }
+
+  @Test
+  public void uploadPackSequencesWithAccessDatabase() throws Exception {
+    assume().that(notesMigration.readChangeSequence()).isTrue();
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      setApiUser(user);
+      assertRefs(repo, newFilter(db, repo, allProjects), true);
+
+      allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      try {
+        setApiUser(user);
+        assertRefs(repo, newFilter(db, repo, allProjects), true, "refs/sequences/changes");
+      } finally {
+        removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+      }
+    }
+  }
+
+  @Test
+  public void receivePackListsOpenChangesAsAdditionalHaves() throws Exception {
+    ReceiveCommitsAdvertiseRefsHook.Result r = getReceivePackRefs();
+    assertThat(r.allRefs().keySet())
+        .containsExactly(
+            // meta refs are excluded even when NoteDb is enabled.
+            "HEAD",
+            "refs/heads/branch",
+            "refs/heads/master",
+            "refs/meta/config",
+            "refs/tags/branch-tag",
+            "refs/tags/master-tag");
+    assertThat(r.additionalHaves()).containsExactly(obj(c3, 1), obj(c4, 1));
+  }
+
+  @Test
+  public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+    setApiUser(user);
+
+    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 1));
+  }
+
+  @Test
+  public void receivePackListsOnlyLatestPatchSet() throws Exception {
+    testRepo.reset(obj(c3, 1));
+    PushOneCommit.Result r = amendChange(c3.change().getKey().get());
+    r.assertOkStatus();
+    c3 = r.getChange();
+    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 2), obj(c4, 1));
+  }
+
+  @Test
+  public void receivePackOmitsMissingObject() throws Exception {
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> tr = new TestRepository<>(repo);
+      String subject = "Subject for missing commit";
+      Change c = new Change(c3.change());
+      PatchSet.Id psId = new PatchSet.Id(c3.getId(), 2);
+      c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
+
+      if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
+        PatchSet ps = TestChanges.newPatchSet(psId, rev, admin.getId());
+        db.patchSets().insert(Collections.singleton(ps));
+        db.changes().update(Collections.singleton(c));
+      }
+
+      if (notesMigration.commitChangeWrites()) {
+        PersonIdent committer = serverIdent.get();
+        PersonIdent author =
+            noteUtil.newIdent(
+                accountCache.get(admin.getId()).getAccount(),
+                committer.getWhen(),
+                committer,
+                anonymousCowardName);
+        tr.branch(RefNames.changeMetaRef(c3.getId()))
+            .commit()
+            .author(author)
+            .committer(committer)
+            .message(
+                "Update patch set "
+                    + psId.get()
+                    + "\n"
+                    + "\n"
+                    + "Patch-set: "
+                    + psId.get()
+                    + "\n"
+                    + "Commit: "
+                    + rev
+                    + "\n"
+                    + "Subject: "
+                    + subject
+                    + "\n")
+            .create();
+      }
+      indexer.index(db, c.getProject(), c.getId());
+    }
+
+    assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c4, 1));
+  }
+
+  /**
+   * Assert that refs seen by a non-admin user match expected.
+   *
+   * @param expectedWithMeta expected refs, in order. If NoteDb is disabled by the configuration,
+   *     any NoteDb refs (i.e. ending in "/meta") are removed from the expected list before
+   *     comparing to the actual results.
+   * @throws Exception
+   */
+  private void assertUploadPackRefs(String... expectedWithMeta) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertRefs(
+          repo,
+          new VisibleRefFilter(
+              tagCache,
+              notesFactory,
+              changeCache,
+              repo,
+              projectControl(),
+              new DisabledReviewDb(),
+              true),
+          true,
+          expectedWithMeta);
+    }
+  }
+
+  private void assertRefs(
+      Repository repo, VisibleRefFilter filter, boolean disableDb, String... expectedWithMeta)
+      throws Exception {
+    List<String> expected = new ArrayList<>(expectedWithMeta.length);
+    for (String r : expectedWithMeta) {
+      if (notesMigration.writeChanges() || !r.endsWith(RefNames.META_SUFFIX)) {
+        expected.add(r);
+      }
+    }
+
+    AcceptanceTestRequestScope.Context ctx = null;
+    if (disableDb) {
+      ctx = disableDb();
+    }
+    try {
+      Map<String, Ref> all = repo.getAllRefs();
+      assertThat(filter.filter(all, false).keySet()).containsExactlyElementsIn(expected);
+    } finally {
+      if (disableDb) {
+        enableDb(ctx);
+      }
+    }
+  }
+
+  private ReceiveCommitsAdvertiseRefsHook.Result getReceivePackRefs() throws Exception {
+    ReceiveCommitsAdvertiseRefsHook hook =
+        new ReceiveCommitsAdvertiseRefsHook(queryProvider, project);
+    try (Repository repo = repoManager.openRepository(project)) {
+      return hook.advertiseRefs(repo.getAllRefs());
+    }
+  }
+
+  private ProjectControl projectControl() throws Exception {
+    return projectControlFactory.controlFor(project, userProvider.get());
+  }
+
+  private VisibleRefFilter newFilter(ReviewDb db, Repository repo, Project.NameKey project)
+      throws Exception {
+    return new VisibleRefFilter(
+        tagCache,
+        notesFactory,
+        null,
+        repo,
+        projectControlFactory.controlFor(project, userProvider.get()),
+        db,
+        true);
+  }
+
+  private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
+    PatchSet.Id psId = new PatchSet.Id(cd.getId(), psNum);
+    PatchSet ps = cd.patchSet(psId);
+    assertWithMessage("%s not found in %s", psId, cd.patchSets()).that(ps).isNotNull();
+    return ObjectId.fromString(ps.getRevision().get());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SshPushForReviewIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SshPushForReviewIT.java
index c7da993..5ec6cea 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SshPushForReviewIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SshPushForReviewIT.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.acceptance.git;
 
 import com.google.gerrit.acceptance.NoHttpd;
-
+import com.google.gerrit.acceptance.UseSsh;
 import org.junit.Before;
 
 @NoHttpd
+@UseSsh
 public class SshPushForReviewIT extends AbstractPushForReview {
   @Before
   public void selectSshUrl() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 848b428..a685141 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -32,7 +33,9 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.api.errors.InvalidRemoteException;
+import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -41,12 +44,12 @@
 import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
 @NoHttpd
 public class SubmitOnPushIT extends AbstractDaemonTest {
-  @Inject
-  private ApprovalsUtil approvalsUtil;
+  @Inject private ApprovalsUtil approvalsUtil;
 
   @Test
   public void submitOnPush() throws Exception {
@@ -77,6 +80,7 @@
   @Test
   public void submitOnPushWithAnnotatedTag() throws Exception {
     grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(Permission.PUSH, project, "refs/tags/*");
     PushOneCommit.AnnotatedTag tag =
         new PushOneCommit.AnnotatedTag("v1.0", "annotation", admin.getIdent());
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
@@ -114,8 +118,8 @@
         push("refs/for/master%submit", "other change", "a.txt", "other content");
     r.assertErrorStatus();
     r.assertChange(Change.Status.NEW, null);
-    r.assertMessage("Change " + r.getChange().getId()
-        + ": change could not be merged due to a path conflict.");
+    r.assertMessage(
+        "Change " + r.getChange().getId() + ": change could not be merged due to a path conflict.");
   }
 
   @Test
@@ -139,12 +143,13 @@
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
 
     grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    r = push("refs/for/master%submit", PushOneCommit.SUBJECT, "a.txt",
-        "other content", r.getChangeId());
+    r =
+        push(
+            "refs/for/master%submit",
+            PushOneCommit.SUBJECT, "a.txt", "other content", r.getChangeId());
     r.assertOkStatus();
     r.assertChange(Change.Status.MERGED, null, admin);
-    ChangeData cd = Iterables.getOnlyElement(
-        queryProvider.get().byKeyPrefix(r.getChangeId()));
+    ChangeData cd = Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(r.getChangeId()));
     assertThat(cd.patchSets()).hasSize(2);
     assertSubmitApproval(r.getPatchSetId());
     assertCommit(project, "refs/heads/master");
@@ -161,8 +166,10 @@
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
 
-    r = push("refs/for/master%submit", PushOneCommit.SUBJECT, "a.txt",
-        "other content", r.getChangeId());
+    r =
+        push(
+            "refs/for/master%submit",
+            PushOneCommit.SUBJECT, "a.txt", "other content", r.getChangeId());
     r.assertErrorStatus("submit not allowed");
   }
 
@@ -186,13 +193,11 @@
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
     r.assertOkStatus();
 
-    git().push()
-        .setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master"))
-        .call();
+    git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
     assertCommit(project, "refs/heads/master");
 
-    ChangeData cd = Iterables.getOnlyElement(
-        queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+    ChangeData cd =
+        Iterables.getOnlyElement(queryProvider.get().byKey(new Change.Key(r.getChangeId())));
     RevCommit c = r.getCommit();
     PatchSet.Id psId = cd.currentPatchSet().getId();
     assertThat(psId.get()).isEqualTo(1);
@@ -204,6 +209,46 @@
   }
 
   @Test
+  public void mergeOnPushToBranchWithChangeMergedInOther() throws Exception {
+    enableCreateNewChangeForAllNotInTarget();
+    String master = "refs/heads/master";
+    String other = "refs/heads/other";
+    grant(Permission.PUSH, project, master);
+    grant(Permission.CREATE, project, other);
+    grant(Permission.PUSH, project, other);
+    RevCommit masterRev = getRemoteHead();
+    pushCommitTo(masterRev, other);
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    RevCommit commit = r.getCommit();
+    pushCommitTo(commit, master);
+    assertCommit(project, master);
+    ChangeData cd =
+        Iterables.getOnlyElement(queryProvider.get().byKey(new Change.Key(r.getChangeId())));
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+
+    RemoteRefUpdate.Status status = pushCommitTo(commit, "refs/for/other");
+    assertThat(status).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    pushCommitTo(commit, other);
+    assertCommit(project, other);
+
+    for (ChangeData c : queryProvider.get().byKey(new Change.Key(r.getChangeId()))) {
+      if (c.change().getDest().get().equals(other)) {
+        assertThat(c.change().getStatus()).isEqualTo(Change.Status.MERGED);
+      }
+    }
+  }
+
+  private RemoteRefUpdate.Status pushCommitTo(RevCommit commit, String ref)
+      throws GitAPIException, InvalidRemoteException, TransportException {
+    return Iterables.getOnlyElement(
+            git().push().setRefSpecs(new RefSpec(commit.name() + ":" + ref)).call())
+        .getRemoteUpdate(ref)
+        .getStatus();
+  }
+
+  @Test
   public void mergeOnPushToBranchWithNewPatchset() throws Exception {
     grant(Permission.PUSH, project, "refs/heads/master");
     PushOneCommit.Result r = pushTo("refs/for/master");
@@ -213,8 +258,14 @@
     assertThat(psId1.get()).isEqualTo(1);
 
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-            "b.txt", "anotherContent", r.getChangeId());
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "anotherContent",
+            r.getChangeId());
 
     r = push.to("refs/heads/master");
     r.assertOkStatus();
@@ -233,6 +284,33 @@
   }
 
   @Test
+  public void mergeOnPushToBranchWithOldPatchset() throws Exception {
+    grant(Permission.PUSH, project, "refs/heads/master");
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    RevCommit c1 = r.getCommit();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    String changeId = r.getChangeId();
+    assertThat(psId1.get()).isEqualTo(1);
+
+    r = amendChange(changeId);
+    ChangeData cd = r.getChange();
+    PatchSet.Id psId2 = cd.change().currentPatchSetId();
+    assertThat(psId2.getParentKey()).isEqualTo(psId1.getParentKey());
+    assertThat(psId2.get()).isEqualTo(2);
+
+    testRepo.reset(c1);
+    assertPushOk(pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
+
+    cd = changeDataFactory.create(db, project, psId1.getParentKey());
+    Change c = cd.change();
+    assertThat(c.getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(c.currentPatchSetId()).isEqualTo(psId1);
+    assertThat(cd.patchSets().stream().map(ps -> ps.getId()).collect(toList()))
+        .containsExactly(psId1, psId2);
+  }
+
+  @Test
   public void mergeMultipleOnPushToBranchWithNewPatchset() throws Exception {
     grant(Permission.PUSH, project, "refs/heads/master");
 
@@ -252,40 +330,35 @@
 
     // Amend both changes.
     testRepo.reset(initialHead);
-    RevCommit c1_2 = testRepo.branch("HEAD").commit()
-        .message(c1_1.getShortMessage() + "v2")
-        .insertChangeId(r1.getChangeId().substring(1))
-        .create();
+    RevCommit c1_2 =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .message(c1_1.getShortMessage() + "v2")
+            .insertChangeId(r1.getChangeId().substring(1))
+            .create();
     RevCommit c2_2 = testRepo.cherryPick(c2_1);
 
     // Push directly to branch.
-    assertPushOk(
-        pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
+    assertPushOk(pushHead(testRepo, "refs/heads/master", false), "refs/heads/master");
 
     ChangeData cd2 = r2.getChange();
     assertThat(cd2.change().getStatus()).isEqualTo(Change.Status.MERGED);
     PatchSet.Id psId2_2 = cd2.change().currentPatchSetId();
     assertThat(psId2_2.get()).isEqualTo(2);
-    assertThat(cd2.patchSet(psId2_1).getRevision().get())
-        .isEqualTo(c2_1.name());
-    assertThat(cd2.patchSet(psId2_2).getRevision().get())
-        .isEqualTo(c2_2.name());
+    assertThat(cd2.patchSet(psId2_1).getRevision().get()).isEqualTo(c2_1.name());
+    assertThat(cd2.patchSet(psId2_2).getRevision().get()).isEqualTo(c2_2.name());
 
     ChangeData cd1 = r1.getChange();
     assertThat(cd1.change().getStatus()).isEqualTo(Change.Status.MERGED);
     PatchSet.Id psId1_2 = cd1.change().currentPatchSetId();
     assertThat(psId1_2.get()).isEqualTo(2);
-    assertThat(cd1.patchSet(psId1_1).getRevision().get())
-        .isEqualTo(c1_1.name());
-    assertThat(cd1.patchSet(psId1_2).getRevision().get())
-        .isEqualTo(c1_2.name());
+    assertThat(cd1.patchSet(psId1_1).getRevision().get()).isEqualTo(c1_1.name());
+    assertThat(cd1.patchSet(psId1_2).getRevision().get()).isEqualTo(c1_2.name());
   }
 
-  private PatchSetApproval getSubmitter(PatchSet.Id patchSetId)
-      throws Exception {
-    ChangeNotes notes =
-        notesFactory.createChecked(db, project, patchSetId.getParentKey())
-            .load();
+  private PatchSetApproval getSubmitter(PatchSet.Id patchSetId) throws Exception {
+    ChangeNotes notes = notesFactory.createChecked(db, project, patchSetId.getParentKey()).load();
     return approvalsUtil.getSubmitter(db, notes, patchSetId);
   }
 
@@ -296,15 +369,13 @@
     assertThat(a.getAccountId()).isEqualTo(admin.id);
   }
 
-  private void assertCommit(Project.NameKey project, String branch)
-      throws Exception {
+  private void assertCommit(Project.NameKey project, String branch) throws Exception {
     try (Repository r = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(r)) {
       RevCommit c = rw.parseCommit(r.exactRef(branch).getObjectId());
       assertThat(c.getShortMessage()).isEqualTo(PushOneCommit.SUBJECT);
       assertThat(c.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email);
-      assertThat(c.getCommitterIdent().getEmailAddress()).isEqualTo(
-          admin.email);
+      assertThat(c.getCommitterIdent().getEmailAddress()).isEqualTo(admin.email);
     }
   }
 
@@ -315,25 +386,24 @@
       assertThat(c.getParentCount()).isEqualTo(2);
       assertThat(c.getShortMessage()).isEqualTo("Merge \"" + subject + "\"");
       assertThat(c.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email);
-      assertThat(c.getCommitterIdent().getEmailAddress()).isEqualTo(
-          serverIdent.get().getEmailAddress());
+      assertThat(c.getCommitterIdent().getEmailAddress())
+          .isEqualTo(serverIdent.get().getEmailAddress());
     }
   }
 
-  private void assertTag(Project.NameKey project, String branch,
-      PushOneCommit.Tag tag) throws Exception {
+  private void assertTag(Project.NameKey project, String branch, PushOneCommit.Tag tag)
+      throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       Ref tagRef = repo.findRef(tag.name);
       assertThat(tagRef).isNotNull();
       ObjectId taggedCommit = null;
       if (tag instanceof PushOneCommit.AnnotatedTag) {
-        PushOneCommit.AnnotatedTag annotatedTag = (PushOneCommit.AnnotatedTag)tag;
+        PushOneCommit.AnnotatedTag annotatedTag = (PushOneCommit.AnnotatedTag) tag;
         try (RevWalk rw = new RevWalk(repo)) {
           RevObject object = rw.parseAny(tagRef.getObjectId());
           assertThat(object).isInstanceOf(RevTag.class);
           RevTag tagObject = (RevTag) object;
-          assertThat(tagObject.getFullMessage())
-              .isEqualTo(annotatedTag.message);
+          assertThat(tagObject.getFullMessage()).isEqualTo(annotatedTag.message);
           assertThat(tagObject.getTaggerIdent()).isEqualTo(annotatedTag.tagger);
           taggedCommit = tagObject.getObject();
         }
@@ -346,17 +416,18 @@
     }
   }
 
-  private PushOneCommit.Result push(String ref, String subject,
-      String fileName, String content) throws Exception {
+  private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
+      throws Exception {
     PushOneCommit push =
         pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content);
     return push.to(ref);
   }
 
-  private PushOneCommit.Result push(String ref, String subject,
-      String fileName, String content, String changeId) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo, subject,
-        fileName, content, changeId);
+  private PushOneCommit.Result push(
+      String ref, String subject, String fileName, String content, String changeId)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, subject, fileName, content, changeId);
     return push.to(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
index 09e498f..d0225c7 100644
--- 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
@@ -22,371 +22,404 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.server.util.SubmoduleSectionParser;
-
+import java.util.Set;
 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 {
+  public void followMasterBranch() 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");
+    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> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
-    Set<SubmoduleSubscription> expected = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
-            p, "master"), "localpath-to-a"));
+    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 {
+  public void followMatchingBranch() 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");
+    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");
+    Branch.NameKey targetBranch1 = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res1 = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch1).parseAllSections();
+    Set<SubmoduleSubscription> res1 =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch1).parseAllSections();
 
-    Set<SubmoduleSubscription> expected1 = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch1, new Branch.NameKey(
-            p, "master"), "a"));
+    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");
+    Branch.NameKey targetBranch2 = new Branch.NameKey(new Project.NameKey("project"), "somebranch");
 
-    Set<SubmoduleSubscription> res2 = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch2).parseAllSections();
+    Set<SubmoduleSubscription> res2 =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch2).parseAllSections();
 
-    Set<SubmoduleSubscription> expected2 = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch2, new Branch.NameKey(
-            p, "somebranch"), "a"));
+    Set<SubmoduleSubscription> expected2 =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch2, new Branch.NameKey(p, "somebranch"), "a"));
 
     assertThat(res2).containsExactlyElementsIn(expected2);
   }
 
   @Test
-  public void testFollowAnotherBranch() throws Exception {
+  public void followAnotherBranch() 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");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
-    Set<SubmoduleSubscription> expected = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
-            p, "anotherbranch"), "a"));
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "anotherbranch"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
-  public void testWithAnotherURI() throws Exception {
+  public void withAnotherURI() 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");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res =new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
-    Set<SubmoduleSubscription> expected = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
-            p, "master"), "a"));
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
-  public void testWithSlashesInProjectName() throws Exception {
+  public void withSlashesInProjectName() 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");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
-    Set<SubmoduleSubscription> expected = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
-            p, "master"), "a"));
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
-  public void testWithSlashesInPath() throws Exception {
+  public void withSlashesInPath() 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");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    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"));
+    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 {
+  public void withMoreSections() 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");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    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"));
+    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 {
+  public void withSubProjectFound() 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");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    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"));
+    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 {
+  public void withAnInvalidSection() 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"
+    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 = 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 = .\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");
+            + "[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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    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"));
+    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 {
+  public void withSectionOfNonexistingProject() 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");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     assertThat(res).isEmpty();
   }
 
   @Test
-  public void testWithSectionToOtherServer() throws Exception {
+  public void withSectionToOtherServer() 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 = .");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
     assertThat(res).isEmpty();
   }
 
   @Test
-  public void testWithRelativeURI() throws Exception {
+  public void withRelativeURI() 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");
+    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");
+    Branch.NameKey targetBranch = new Branch.NameKey(new Project.NameKey("project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
-    Set<SubmoduleSubscription> expected = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
-            p1, "master"), "a"));
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
-  public void testWithDeepRelativeURI() throws Exception {
+  public void withDeepRelativeURI() 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");
+    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");
+    Branch.NameKey targetBranch =
+        new Branch.NameKey(new Project.NameKey("nested/project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
-    Set<SubmoduleSubscription> expected = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
-            p1, "master"), "a"));
+    Set<SubmoduleSubscription> expected =
+        Sets.newHashSet(
+            new SubmoduleSubscription(targetBranch, new Branch.NameKey(p1, "master"), "a"));
 
     assertThat(res).containsExactlyElementsIn(expected);
   }
 
   @Test
-  public void testWithOverlyDeepRelativeURI() throws Exception {
+  public void withOverlyDeepRelativeURI() throws Exception {
     Project.NameKey p1 = createProject("nested/a");
     Config cfg = new Config();
-    cfg.fromText(""
-        + "[submodule \"a\"]\n"
-        + "path = a\n"
-        + "url = ../../" + p1.get() + "\n"
-        + "branch = master\n");
+    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");
+    Branch.NameKey targetBranch =
+        new Branch.NameKey(new Project.NameKey("nested/project"), "master");
 
-    Set<SubmoduleSubscription> res = new SubmoduleSectionParser(
-        cfg, THIS_SERVER, targetBranch).parseAllSections();
+    Set<SubmoduleSubscription> res =
+        new SubmoduleSectionParser(cfg, THIS_SERVER, targetBranch).parseAllSections();
 
-    Set<SubmoduleSubscription> expected = Sets.newHashSet(
-        new SubmoduleSubscription(targetBranch, new Branch.NameKey(
-            p1, "master"), "a"));
+    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 6684e85..f72e978 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
@@ -21,7 +21,6 @@
 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;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -44,189 +43,162 @@
   public void testSubscriptionWithoutGlobalServerSetting() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
-    assertThat(hasSubmodule(superRepo, "master",
-        "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
   }
 
   @Test
-  public void testSubscriptionWithoutSpecificSubscription() throws Exception {
+  public void subscriptionWithoutSpecificSubscription() throws Exception {
     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
-  public void testSubscriptionToEmptyRepo() throws Exception {
+  public void subscriptionToEmptyRepo() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
-    assertThat(hasSubmodule(superRepo, "master",
-        "subscribed-to-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEAD);
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
   }
 
   @Test
-  public void testSubscriptionToExistingRepo() throws Exception {
+  public void subscriptionToExistingRepo() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    assertThat(hasSubmodule(superRepo, "master",
-        "subscribed-to-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEAD);
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
   }
 
   @Test
-  public void testSubscriptionWildcardACLForSingleBranch() throws Exception {
+  public void subscriptionWildcardACLForSingleBranch() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
     // master is allowed to be subscribed to master branch only:
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", null);
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", null);
     // create 'branch':
     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");
 
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEAD);
-    assertThat(hasSubmodule(superRepo, "branch",
-        "subscribed-to-project")).isFalse();
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
+    assertThat(hasSubmodule(superRepo, "branch", "subscribed-to-project")).isFalse();
   }
 
   @Test
-  public void testSubscriptionWildcardACLForMissingProject() throws Exception {
+  public void subscriptionWildcardACLForMissingProject() throws Exception {
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
-        "not-existing-super-project", "refs/heads/*");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "not-existing-super-project", "refs/heads/*");
     pushChangeTo(subRepo, "master");
   }
 
   @Test
-  public void testSubscriptionWildcardACLForMissingBranch() throws Exception {
+  public void subscriptionWildcardACLForMissingBranch() throws Exception {
     createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
-        "super-project", "refs/heads/*");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
     pushChangeTo(subRepo, "foo");
   }
 
   @Test
-  public void testSubscriptionWildcardACLForMissingGitmodules() throws Exception {
+  public void subscriptionWildcardACLForMissingGitmodules() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
-        "super-project", "refs/heads/*");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
     pushChangeTo(superRepo, "master");
     pushChangeTo(subRepo, "master");
   }
 
   @Test
-  public void testSubscriptionWildcardACLOneOnOneMapping() throws Exception {
+  public void subscriptionWildcardACLOneOnOneMapping() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
     // any branch is allowed to be subscribed to the same superprojects branch:
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
-        "super-project", "refs/heads/*");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "super-project", "refs/heads/*");
 
     // create 'branch' in both repos:
     pushChangeTo(superRepo, "branch");
     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");
 
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEAD1);
-    expectToHaveSubmoduleState(superRepo, "branch",
-        "subscribed-to-project", subHEAD2);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD1);
+    expectToHaveSubmoduleState(superRepo, "branch", "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",
-        "subscribed-to-project", subHEAD1);
-    expectToHaveSubmoduleState(superRepo, "branch",
-        "subscribed-to-project", subHEAD3);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD1);
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD3);
   }
 
   @Test
-  public void testSubscriptionWildcardACLForManyBranches() throws Exception {
+  public void subscriptionWildcardACLForManyBranches() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
     // Any branch is allowed to be subscribed to any superproject branch:
-    allowSubmoduleSubscription("subscribed-to-project", "refs/heads/*",
-        "super-project", null, false);
+    allowSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/*", "super-project", null, false);
     pushChangeTo(superRepo, "branch");
     pushChangeTo(subRepo, "another-branch");
-    createSubmoduleSubscription(superRepo, "branch",
-        "subscribed-to-project", "another-branch");
+    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "another-branch");
     ObjectId subHEAD = pushChangeTo(subRepo, "another-branch");
-    expectToHaveSubmoduleState(superRepo, "branch",
-        "subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
   }
 
   @Test
-  public void testSubscriptionWildcardACLOneToManyBranches() throws Exception {
+  public void subscriptionWildcardACLOneToManyBranches() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
     // Any branch is allowed to be subscribed to any superproject branch:
-    allowSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/*", false);
+    allowSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/*", false);
     pushChangeTo(superRepo, "branch");
-    createSubmoduleSubscription(superRepo, "branch",
-        "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "master");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "branch",
-        "subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
 
-    createSubmoduleSubscription(superRepo, "branch",
-        "subscribed-to-project", "branch");
+    createSubmoduleSubscription(superRepo, "branch", "subscribed-to-project", "branch");
     pushChangeTo(subRepo, "branch");
 
     // no change expected, as only master is subscribed:
-    expectToHaveSubmoduleState(superRepo, "branch",
-        "subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "branch", "subscribed-to-project", subHEAD);
   }
 
   @Test
@@ -234,26 +206,21 @@
   public void testSubmoduleShortCommitMessage() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
 
     // The first update doesn't include any commit messages
     ObjectId subRepoId = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subRepoId);
-    expectToHaveCommitMessage(superRepo, "master",
-        "Update git submodules\n\n");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+    expectToHaveCommitMessage(superRepo, "master", "Update git submodules\n\n");
 
     // Any following update also has a short message
     subRepoId = pushChangeTo(subRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subRepoId);
-    expectToHaveCommitMessage(superRepo, "master",
-        "Update git submodules\n\n");
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+    expectToHaveCommitMessage(superRepo, "master", "Update git submodules\n\n");
   }
 
   @Test
@@ -261,286 +228,279 @@
   public void testSubmoduleSubjectCommitMessage() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
     // The first update doesn't include the rev log
     RevWalk rw = subRepo.getRevWalk();
-    expectToHaveCommitMessage(superRepo, "master",
-        "Update git submodules\n\n" +
-            "* Update " + name("subscribed-to-project") + " from branch 'master'");
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        "Update git submodules\n\n"
+            + "* Update "
+            + name("subscribed-to-project")
+            + " from branch 'master'\n  to "
+            + subHEAD.getName());
 
     // The next commit should generate only its commit message,
     // omitting previous commit logs
     subHEAD = pushChangeTo(subRepo, "master");
     RevCommit subCommitMsg = rw.parseCommit(subHEAD);
-    expectToHaveCommitMessage(superRepo, "master",
-        "Update git submodules\n\n" +
-            "* Update " + name("subscribed-to-project") + " from branch 'master'"
-            + "\n  - " + subCommitMsg.getShortMessage());
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        "Update git submodules\n\n"
+            + "* Update "
+            + name("subscribed-to-project")
+            + " from branch 'master'\n  to "
+            + subHEAD.getName()
+            + "\n  - "
+            + subCommitMsg.getShortMessage());
   }
 
   @Test
-  public void testSubmoduleCommitMessage() throws Exception {
+  public void submoduleCommitMessage() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
     // The first update doesn't include the rev log
     RevWalk rw = subRepo.getRevWalk();
-    expectToHaveCommitMessage(superRepo, "master",
-        "Update git submodules\n\n" +
-            "* Update " + name("subscribed-to-project") + " from branch 'master'");
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        "Update git submodules\n\n"
+            + "* Update "
+            + name("subscribed-to-project")
+            + " from branch 'master'\n  to "
+            + subHEAD.getName());
 
     // The next commit should generate only its commit message,
     // omitting previous commit logs
     subHEAD = pushChangeTo(subRepo, "master");
     RevCommit subCommitMsg = rw.parseCommit(subHEAD);
-    expectToHaveCommitMessage(superRepo, "master",
-        "Update git submodules\n\n" +
-            "* Update " + name("subscribed-to-project") + " from branch 'master'"
-             + "\n  - " + subCommitMsg.getFullMessage().replace("\n", "\n    "));
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        "Update git submodules\n\n"
+            + "* Update "
+            + name("subscribed-to-project")
+            + " from branch 'master'\n  to "
+            + subHEAD.getName()
+            + "\n  - "
+            + subCommitMsg.getFullMessage().replace("\n", "\n    "));
   }
 
   @Test
-  public void testSubscriptionUnsubscribe() throws Exception {
+  public void subscriptionUnsubscribe() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
 
     deleteAllSubscriptions(superRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEADbeforeUnsubscribing);
+    expectToHaveSubmoduleState(
+        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
 
-    pushChangeTo(superRepo, "refs/heads/master",
-        "commit after unsubscribe", "");
-    pushChangeTo(subRepo, "refs/heads/master",
-        "commit after unsubscribe", "");
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEADbeforeUnsubscribing);
+    pushChangeTo(superRepo, "refs/heads/master", "commit after unsubscribe", "");
+    pushChangeTo(subRepo, "refs/heads/master", "commit after unsubscribe", "");
+    expectToHaveSubmoduleState(
+        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
   }
 
   @Test
-  public void testSubscriptionUnsubscribeByDeletingGitModules()
-      throws Exception {
+  public void subscriptionUnsubscribeByDeletingGitModules() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
 
     deleteGitModulesFile(superRepo, "master");
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEADbeforeUnsubscribing);
+    expectToHaveSubmoduleState(
+        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
 
-    pushChangeTo(superRepo, "refs/heads/master",
-        "commit after unsubscribe", "");
-    pushChangeTo(subRepo, "refs/heads/master",
-        "commit after unsubscribe", "");
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subHEADbeforeUnsubscribing);
+    pushChangeTo(superRepo, "refs/heads/master", "commit after unsubscribe", "");
+    pushChangeTo(subRepo, "refs/heads/master", "commit after unsubscribe", "");
+    expectToHaveSubmoduleState(
+        superRepo, "master", "subscribed-to-project", subHEADbeforeUnsubscribing);
   }
 
   @Test
-  public void testSubscriptionToDifferentBranches() throws Exception {
+  public void subscriptionToDifferentBranches() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/foo",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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");
 
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subFoo);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subFoo);
   }
 
   @Test
-  public void testBranchCircularSubscription() throws Exception {
+  public void branchCircularSubscription() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("super-project", "refs/heads/master",
-        "subscribed-to-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "super-project", "refs/heads/master", "subscribed-to-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
     pushChangeTo(superRepo, "master");
-    createSubmoduleSubscription(superRepo, "master",
-        "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
     createSubmoduleSubscription(subRepo, "master", "super-project", "master");
 
     pushChangeTo(subRepo, "master");
     pushChangeTo(superRepo, "master");
 
-    assertThat(hasSubmodule(subRepo, "master",
-        "super-project")).isFalse();
-    assertThat(hasSubmodule(superRepo, "master",
-        "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(subRepo, "master", "super-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
   }
 
   @Test
-  public void testProjectCircularSubscription() throws Exception {
+  public void projectCircularSubscription() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("super-project", "refs/heads/dev",
-        "subscribed-to-project", "refs/heads/dev");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "super-project", "refs/heads/dev", "subscribed-to-project", "refs/heads/dev");
 
     pushChangeTo(subRepo, "master");
     pushChangeTo(superRepo, "master");
     pushChangeTo(subRepo, "dev");
     pushChangeTo(superRepo, "dev");
 
-    createSubmoduleSubscription(superRepo, "master",
-        "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
     createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
 
     ObjectId subMasterHead = pushChangeTo(subRepo, "master");
     ObjectId superDevHead = pushChangeTo(superRepo, "dev");
 
-    assertThat(hasSubmodule(superRepo, "master",
-        "subscribed-to-project")).isTrue();
-    assertThat(hasSubmodule(subRepo, "dev",
-        "super-project")).isTrue();
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subMasterHead);
-    expectToHaveSubmoduleState(subRepo, "dev",
-        "super-project", superDevHead);
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isTrue();
+    assertThat(hasSubmodule(subRepo, "dev", "super-project")).isTrue();
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subMasterHead);
+    expectToHaveSubmoduleState(subRepo, "dev", "super-project", superDevHead);
   }
 
   @Test
-  public void testSubscriptionFailOnMissingACL() throws Exception {
+  public void subscriptionFailOnMissingACL() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     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
-  public void testSubscriptionFailOnWrongProjectACL() throws Exception {
+  public void subscriptionFailOnWrongProjectACL() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "wrong-super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "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
-  public void testSubscriptionFailOnWrongBranchACL() throws Exception {
+  public void subscriptionFailOnWrongBranchACL() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/wrong-branch");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "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 {
+  public void subscriptionInheritACL() throws Exception {
     createProjectWithPush("config-repo");
-    createProjectWithPush("config-repo2",
-        new Project.NameKey(name("config-repo")));
+    createProjectWithPush("config-repo2", new Project.NameKey(name("config-repo")));
     TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project",
-        new Project.NameKey(name("config-repo2")));
-    allowMatchingSubmoduleSubscription("config-repo", "refs/heads/*",
-        "super-project", "refs/heads/*");
+    TestRepository<?> subRepo =
+        createProjectWithPush("subscribed-to-project", new Project.NameKey(name("config-repo2")));
+    allowMatchingSubmoduleSubscription(
+        "config-repo", "refs/heads/*", "super-project", "refs/heads/*");
 
     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);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subHEAD);
   }
 
   @Test
-  public void testAllowedButNotSubscribed() throws Exception {
+  public void allowedButNotSubscribed() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
 
     pushChangeTo(subRepo, "master");
-    subRepo.branch("HEAD").commit().insertChangeId()
+    subRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("some change")
         .add("b.txt", "b contents for testing")
         .create();
     String refspec = "HEAD:refs/heads/master";
-    PushResult r = Iterables.getOnlyElement(subRepo.git().push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec(refspec))
-        .call());
+    PushResult r =
+        Iterables.getOnlyElement(
+            subRepo.git().push().setRemote("origin").setRefSpecs(new RefSpec(refspec)).call());
     assertThat(r.getMessages()).doesNotContain("error");
     assertThat(r.getRemoteUpdate("refs/heads/master").getStatus())
-    .isEqualTo(RemoteRefUpdate.Status.OK);
+        .isEqualTo(RemoteRefUpdate.Status.OK);
 
-    assertThat(hasSubmodule(superRepo, "master",
-        "subscribed-to-project")).isFalse();
+    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project")).isFalse();
   }
 
   @Test
-  public void testSubscriptionDeepRelative() throws Exception {
+  public void subscriptionDeepRelative() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
-    TestRepository<?> subRepo = createProjectWithPush(
-        "nested/subscribed-to-project");
+    TestRepository<?> subRepo = createProjectWithPush("nested/subscribed-to-project");
     // master is allowed to be subscribed to any superprojects branch:
-    allowMatchingSubmoduleSubscription("nested/subscribed-to-project",
-        "refs/heads/master", "super-project", null);
+    allowMatchingSubmoduleSubscription(
+        "nested/subscribed-to-project", "refs/heads/master", "super-project", null);
 
     pushChangeTo(subRepo, "master");
-    createRelativeSubmoduleSubscription(superRepo, "master",
-        "../", "nested/subscribed-to-project", "master");
+    createRelativeSubmoduleSubscription(
+        superRepo, "master", "../", "nested/subscribed-to-project", "master");
 
     ObjectId subHEAD = pushChangeTo(subRepo, "master");
 
-    expectToHaveSubmoduleState(superRepo, "master",
-        "nested/subscribed-to-project", subHEAD);
+    expectToHaveSubmoduleState(superRepo, "master", "nested/subscribed-to-project", subHEAD);
   }
 }
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 98405d7..2ec3810 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
@@ -21,8 +21,11 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testutil.ConfigSuite;
-
+import java.util.Map;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -31,8 +34,7 @@
 import org.junit.Test;
 
 @NoHttpd
-public class SubmoduleSubscriptionsWholeTopicMergeIT
-  extends AbstractSubmoduleSubscription {
+public class SubmoduleSubscriptionsWholeTopicMergeIT extends AbstractSubmoduleSubscription {
 
   @ConfigSuite.Default
   public static Config mergeIfNecessary() {
@@ -46,53 +48,84 @@
 
   @ConfigSuite.Config
   public static Config cherryPick() {
-    return submitByCherryPickConifg();
+    return submitByCherryPickConfig();
   }
 
   @ConfigSuite.Config
-  public static Config rebase() {
-    return submitByRebaseConifg();
+  public static Config rebaseAlways() {
+    return submitByRebaseAlwaysConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config rebaseIfNecessary() {
+    return submitByRebaseIfNecessaryConfig();
   }
 
   @Test
-  public void testSubscriptionUpdateOfManyChanges() throws Exception {
+  public void subscriptionUpdateOfManyChanges() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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();
+    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();
+    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 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 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();
 
     String id1 = getChangeId(subRepo, c1).get();
     String id2 = getChangeId(subRepo, c2).get();
@@ -101,61 +134,121 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
+    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
     gApi.changes().id(id1).current().submit();
-    ObjectId subRepoId = subRepo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/master").getObjectId();
+    ObjectId subRepoId =
+        subRepo
+            .git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId();
 
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subRepoId);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
+
+    // As the submodules have changed commits, the superproject tree will be
+    // different, so we cannot directly compare the trees here, so make
+    // assumptions only about the changed branches:
+    Project.NameKey p1 = new Project.NameKey(name("super-project"));
+    Project.NameKey p2 = new Project.NameKey(name("subscribed-to-project"));
+    assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
+    assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
+
+    if ((getSubmitType() == SubmitType.CHERRY_PICK)
+        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
+      // each change is updated and the respective target branch is updated:
+      assertThat(preview).hasSize(5);
+    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
+      // Either the first is used first as is, then the second and third need
+      // rebasing, or those two stay as is and the first is rebased.
+      // add in 2 master branches, expect 3 or 4:
+      assertThat(preview.size()).isAnyOf(3, 4);
+    } else {
+      assertThat(preview).hasSize(2);
+    }
   }
 
   @Test
-  public void testSubscriptionUpdateIncludingChangeInSuperproject() throws Exception {
+  public void subscriptionUpdateIncludingChangeInSuperproject() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "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();
+    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();
+    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 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 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();
+    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();
@@ -167,26 +260,31 @@
     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();
+    ObjectId subRepoId =
+        subRepo
+            .git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId();
 
-    expectToHaveSubmoduleState(superRepo, "master",
-        "subscribed-to-project", subRepoId);
+    expectToHaveSubmoduleState(superRepo, "master", "subscribed-to-project", subRepoId);
   }
 
   @Test
-  public void testUpdateManySubmodules() throws Exception {
+  public void updateManySubmodules() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> sub1 = createProjectWithPush("sub1");
     TestRepository<?> sub2 = createProjectWithPush("sub2");
     TestRepository<?> sub3 = createProjectWithPush("sub3");
 
-    allowMatchingSubmoduleSubscription("sub1", "refs/heads/master",
-        "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("sub2", "refs/heads/master",
-        "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("sub3", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub3", "refs/heads/master", "super-project", "refs/heads/master");
 
     Config config = new Config();
     prepareSubmoduleConfigEntry(config, "sub1", "master");
@@ -196,12 +294,9 @@
 
     ObjectId superPreviousId = pushChangeTo(superRepo, "master");
 
-    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master",
-        "some message", "same-topic");
-    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master",
-        "some message", "same-topic");
-    ObjectId sub3Id = pushChangeTo(sub3, "refs/for/master",
-        "some message", "same-topic");
+    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", "same-topic");
+    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", "same-topic");
+    ObjectId sub3Id = pushChangeTo(sub3, "refs/for/master", "some message", "same-topic");
 
     approve(getChangeId(sub1, sub1Id).get());
     approve(getChangeId(sub2, sub2Id).get());
@@ -213,30 +308,78 @@
     expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
     expectToHaveSubmoduleState(superRepo, "master", "sub3", sub3, "master");
 
-    superRepo.git().fetch().setRemote("origin").call()
-      .getAdvertisedRef("refs/heads/master").getObjectId();
+    String sub1HEAD =
+        sub1.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId()
+            .name();
 
-    assertWithMessage("submodule subscription update "
-        + "should have made one commit")
+    String sub2HEAD =
+        sub2.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId()
+            .name();
+
+    String sub3HEAD =
+        sub3.git()
+            .fetch()
+            .setRemote("origin")
+            .call()
+            .getAdvertisedRef("refs/heads/master")
+            .getObjectId()
+            .name();
+
+    if (getSubmitType() == SubmitType.MERGE_IF_NECESSARY) {
+      expectToHaveCommitMessage(
+          superRepo,
+          "master",
+          "Update git submodules\n\n"
+              + "* Update "
+              + name("sub3")
+              + " from branch 'master'\n  to "
+              + sub3HEAD
+              + "\n\n* Update "
+              + name("sub2")
+              + " from branch 'master'\n  to "
+              + sub2HEAD
+              + "\n\n* Update "
+              + name("sub1")
+              + " from branch 'master'\n  to "
+              + sub1HEAD);
+    }
+
+    superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/master")
+        .getObjectId();
+
+    assertWithMessage("submodule subscription update should have made one commit")
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
 
   @Test
-  public void testDoNotUseFastForward() throws Exception {
+  public void doNotUseFastForward() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project", false);
     TestRepository<?> sub = createProjectWithPush("sub", false);
 
-    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/master", "super-project", "refs/heads/master");
 
     createSubmoduleSubscription(superRepo, "master", "sub", "master");
 
-    ObjectId subId =
-        pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
 
-    ObjectId superId =
-        pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId superId = pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
 
     String subChangeId = getChangeId(sub, subId).get();
     approve(subChangeId);
@@ -251,15 +394,13 @@
   }
 
   @Test
-  public void testUseFastForwardWhenNoSubmodule() throws Exception {
+  public void useFastForwardWhenNoSubmodule() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project", false);
     TestRepository<?> sub = createProjectWithPush("sub", false);
 
-    ObjectId subId =
-        pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
 
-    ObjectId superId =
-        pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId superId = pushChangeTo(superRepo, "refs/for/master", "some message", "same-topic");
 
     String subChangeId = getChangeId(sub, subId).get();
     approve(subChangeId);
@@ -273,12 +414,12 @@
   }
 
   @Test
-  public void testSameProjectSameBranchDifferentPaths() throws Exception {
+  public void sameProjectSameBranchDifferentPaths() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> sub = createProjectWithPush("sub");
 
-    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/master", "super-project", "refs/heads/master");
 
     Config config = new Config();
     prepareSubmoduleConfigEntry(config, "sub", "master");
@@ -296,24 +437,28 @@
     expectToHaveSubmoduleState(superRepo, "master", "sub", sub, "master");
     expectToHaveSubmoduleState(superRepo, "master", "sub-copy", sub, "master");
 
-    superRepo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/master").getObjectId();
+    superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/master")
+        .getObjectId();
 
-    assertWithMessage("submodule subscription update "
-        + "should have made one commit")
+    assertWithMessage("submodule subscription update should have made one commit")
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
 
   @Test
-  public void testSameProjectDifferentBranchDifferentPaths() throws Exception {
+  public void sameProjectDifferentBranchDifferentPaths() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> sub = createProjectWithPush("sub");
 
-    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
-        "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("sub", "refs/heads/dev",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/dev", "super-project", "refs/heads/master");
 
     ObjectId devHead = pushChangeTo(sub, "dev");
     Config config = new Config();
@@ -322,13 +467,12 @@
     pushSubmoduleConfig(superRepo, "master", config);
 
     ObjectId subMasterId =
-        pushChangeTo(sub, "refs/for/master", "some message", "b.txt",
-            "content b", "same-topic");
+        pushChangeTo(sub, "refs/for/master", "some message", "b.txt", "content b", "same-topic");
 
     sub.reset(devHead);
     ObjectId subDevId =
-        pushChangeTo(sub, "refs/for/dev", "some message in dev", "b.txt",
-            "content b", "same-topic");
+        pushChangeTo(
+            sub, "refs/for/dev", "some message in dev", "b.txt", "content b", "same-topic");
 
     approve(getChangeId(sub, subMasterId).get());
     approve(getChangeId(sub, subDevId).get());
@@ -340,33 +484,35 @@
     expectToHaveSubmoduleState(superRepo, "master", "sub-master", sub, "master");
     expectToHaveSubmoduleState(superRepo, "master", "sub-dev", sub, "dev");
 
-    superRepo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/master").getObjectId();
+    superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/master")
+        .getObjectId();
 
-    assertWithMessage("submodule subscription update "
-        + "should have made one commit")
+    assertWithMessage("submodule subscription update should have made one commit")
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
 
   @Test
-  public void testNonSubmoduleInSameTopic() throws Exception {
+  public void nonSubmoduleInSameTopic() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> sub = createProjectWithPush("sub");
     TestRepository<?> standAlone = createProjectWithPush("standalone");
 
-    allowMatchingSubmoduleSubscription("sub", "refs/heads/master",
-        "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub", "refs/heads/master", "super-project", "refs/heads/master");
 
     createSubmoduleSubscription(superRepo, "master", "sub", "master");
 
     ObjectId superPreviousId = pushChangeTo(superRepo, "master");
 
-    ObjectId subId =
-        pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
+    ObjectId subId = pushChangeTo(sub, "refs/for/master", "some message", "same-topic");
     ObjectId standAloneId =
-        pushChangeTo(standAlone, "refs/for/master", "some message",
-            "same-topic");
+        pushChangeTo(standAlone, "refs/for/master", "some message", "same-topic");
 
     String subChangeId = getChangeId(sub, subId).get();
     String standAloneChangeId = getChangeId(standAlone, standAloneId).get();
@@ -380,33 +526,35 @@
     ChangeStatus status = gApi.changes().id(standAloneChangeId).info().status;
     assertThat(status).isEqualTo(ChangeStatus.MERGED);
 
-    superRepo.git().fetch().setRemote("origin").call()
-        .getAdvertisedRef("refs/heads/master").getObjectId();
+    superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/master")
+        .getObjectId();
 
-    assertWithMessage("submodule subscription update "
-        + "should have made one commit")
+    assertWithMessage("submodule subscription update should have made one commit")
         .that(superRepo.getRepository().resolve("origin/master^"))
         .isEqualTo(superPreviousId);
   }
 
   @Test
-  public void testRecursiveSubmodules() throws Exception {
+  public void recursiveSubmodules() throws Exception {
     TestRepository<?> topRepo = createProjectWithPush("top-project");
     TestRepository<?> midRepo = createProjectWithPush("mid-project");
     TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
 
-    allowMatchingSubmoduleSubscription("mid-project", "refs/heads/master",
-        "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("bottom-project", "refs/heads/master",
-        "mid-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
 
     createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
     createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
 
-    ObjectId bottomHead =
-        pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
-    ObjectId topHead =
-        pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId bottomHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId topHead = pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
 
     String id1 = getChangeId(bottomRepo, bottomHead).get();
     String id2 = getChangeId(topRepo, topHead).get();
@@ -421,17 +569,17 @@
   }
 
   @Test
-  public void testTriangleSubmodules() throws Exception {
+  public void triangleSubmodules() throws Exception {
     TestRepository<?> topRepo = createProjectWithPush("top-project");
     TestRepository<?> midRepo = createProjectWithPush("mid-project");
     TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
 
-    allowMatchingSubmoduleSubscription("mid-project", "refs/heads/master",
-        "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("bottom-project", "refs/heads/master",
-        "mid-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("bottom-project", "refs/heads/master",
-        "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "bottom-project", "refs/heads/master", "top-project", "refs/heads/master");
 
     createSubmoduleSubscription(midRepo, "master", "bottom-project", "master");
     Config config = new Config();
@@ -439,10 +587,8 @@
     prepareSubmoduleConfigEntry(config, "mid-project", "master");
     pushSubmoduleConfig(topRepo, "master", config);
 
-    ObjectId bottomHead =
-        pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
-    ObjectId topHead =
-        pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId bottomHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "same-topic");
+    ObjectId topHead = pushChangeTo(topRepo, "refs/for/master", "some message", "same-topic");
 
     String id1 = getChangeId(bottomRepo, bottomHead).get();
     String id2 = getChangeId(topRepo, topHead).get();
@@ -457,8 +603,7 @@
     expectToHaveSubmoduleState(topRepo, "master", "bottom-project", bottomRepo, "master");
   }
 
-  @Test
-  public void testBranchCircularSubscription() throws Exception {
+  private String prepareBranchCircularSubscription() throws Exception {
     TestRepository<?> topRepo = createProjectWithPush("top-project");
     TestRepository<?> midRepo = createProjectWithPush("mid-project");
     TestRepository<?> bottomRepo = createProjectWithPush("bottom-project");
@@ -467,52 +612,56 @@
     createSubmoduleSubscription(topRepo, "master", "mid-project", "master");
     createSubmoduleSubscription(bottomRepo, "master", "top-project", "master");
 
-    allowMatchingSubmoduleSubscription("bottom-project", "refs/heads/master",
-        "mid-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("mid-project", "refs/heads/master",
-        "top-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("top-project", "refs/heads/master",
-        "bottom-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "bottom-project", "refs/heads/master", "mid-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "mid-project", "refs/heads/master", "top-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "top-project", "refs/heads/master", "bottom-project", "refs/heads/master");
 
-    ObjectId bottomMasterHead =
-        pushChangeTo(bottomRepo, "refs/for/master", "some message", "");
+    ObjectId bottomMasterHead = pushChangeTo(bottomRepo, "refs/for/master", "some message", "");
     String changeId = getChangeId(bottomRepo, bottomMasterHead).get();
 
     approve(changeId);
-
     exception.expectMessage("Branch level circular subscriptions detected");
     exception.expectMessage("top-project,refs/heads/master");
     exception.expectMessage("mid-project,refs/heads/master");
     exception.expectMessage("bottom-project,refs/heads/master");
-    gApi.changes().id(changeId).current().submit();
-
-    assertThat(hasSubmodule(midRepo, "master", "bottom-project")).isFalse();
-    assertThat(hasSubmodule(topRepo, "master", "mid-project")).isFalse();
+    return changeId;
   }
 
   @Test
-  public void testProjectCircularSubscriptionWholeTopic() throws Exception {
+  public void branchCircularSubscription() throws Exception {
+    String changeId = prepareBranchCircularSubscription();
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  @Test
+  public void branchCircularSubscriptionPreview() throws Exception {
+    String changeId = prepareBranchCircularSubscription();
+    gApi.changes().id(changeId).current().submitPreview();
+  }
+
+  @Test
+  public void projectCircularSubscriptionWholeTopic() throws Exception {
     TestRepository<?> superRepo = createProjectWithPush("super-project");
     TestRepository<?> subRepo = createProjectWithPush("subscribed-to-project");
 
-    allowMatchingSubmoduleSubscription("subscribed-to-project", "refs/heads/master",
-        "super-project", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("super-project", "refs/heads/dev",
-        "subscribed-to-project", "refs/heads/dev");
+    allowMatchingSubmoduleSubscription(
+        "subscribed-to-project", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "super-project", "refs/heads/dev", "subscribed-to-project", "refs/heads/dev");
 
     pushChangeTo(subRepo, "dev");
     pushChangeTo(superRepo, "dev");
 
-    createSubmoduleSubscription(superRepo, "master",
-        "subscribed-to-project", "master");
+    createSubmoduleSubscription(superRepo, "master", "subscribed-to-project", "master");
     createSubmoduleSubscription(subRepo, "dev", "super-project", "dev");
 
     ObjectId subMasterHead =
-        pushChangeTo(subRepo, "refs/for/master", "b.txt", "content b",
-            "some message", "same-topic");
-    ObjectId superDevHead =
-        pushChangeTo(superRepo, "refs/for/dev",
-            "some message", "same-topic");
+        pushChangeTo(
+            subRepo, "refs/for/master", "b.txt", "content b", "some message", "same-topic");
+    ObjectId superDevHead = pushChangeTo(superRepo, "refs/for/dev", "some message", "same-topic");
 
     approve(getChangeId(subRepo, subMasterHead).get());
     approve(getChangeId(superRepo, superDevHead).get());
@@ -520,16 +669,11 @@
     exception.expectMessage("Project level circular subscriptions detected");
     exception.expectMessage("subscribed-to-project");
     exception.expectMessage("super-project");
-    gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current()
-        .submit();
-
-    assertThat(hasSubmodule(superRepo, "master", "subscribed-to-project"))
-        .isFalse();
-    assertThat(hasSubmodule(subRepo, "dev", "super-project")).isFalse();
+    gApi.changes().id(getChangeId(subRepo, subMasterHead).get()).current().submit();
   }
 
   @Test
-  public void testProjectNoSubscriptionWholeTopic() throws Exception {
+  public void projectNoSubscriptionWholeTopic() throws Exception {
     TestRepository<?> repoA = createProjectWithPush("project-a");
     TestRepository<?> repoB = createProjectWithPush("project-b");
     // bootstrap the dev branch
@@ -540,25 +684,45 @@
 
     // create a change for master branch in repo a
     ObjectId aHead =
-        pushChangeTo(repoA, "refs/for/master", "master.txt", "content master A",
-            "some message in a master.txt", "same-topic");
+        pushChangeTo(
+            repoA,
+            "refs/for/master",
+            "master.txt",
+            "content master A",
+            "some message in a master.txt",
+            "same-topic");
 
     // create a change for master branch in repo b
     ObjectId bHead =
-        pushChangeTo(repoB, "refs/for/master", "master.txt", "content master B",
-            "some message in b master.txt", "same-topic");
+        pushChangeTo(
+            repoB,
+            "refs/for/master",
+            "master.txt",
+            "content master B",
+            "some message in b master.txt",
+            "same-topic");
 
     // create a change for dev branch in repo a
     repoA.reset(a0);
     ObjectId aDevHead =
-        pushChangeTo(repoA, "refs/for/dev", "dev.txt", "content dev A",
-            "some message in a dev.txt", "same-topic");
+        pushChangeTo(
+            repoA,
+            "refs/for/dev",
+            "dev.txt",
+            "content dev A",
+            "some message in a dev.txt",
+            "same-topic");
 
     // create a change for dev branch in repo b
     repoB.reset(b0);
     ObjectId bDevHead =
-        pushChangeTo(repoB, "refs/for/dev", "dev.txt", "content dev B",
-            "some message in b dev.txt", "same-topic");
+        pushChangeTo(
+            repoB,
+            "refs/for/dev",
+            "dev.txt",
+            "content dev B",
+            "some message in b dev.txt",
+            "same-topic");
 
     approve(getChangeId(repoA, aHead).get());
     approve(getChangeId(repoB, bHead).get());
@@ -566,22 +730,18 @@
     approve(getChangeId(repoB, bDevHead).get());
 
     gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
-    assertThat(
-        getRemoteHead(name("project-a"), "refs/heads/master").getShortMessage())
+    assertThat(getRemoteHead(name("project-a"), "refs/heads/master").getShortMessage())
         .contains("some message in a master.txt");
-    assertThat(
-        getRemoteHead(name("project-a"), "refs/heads/dev").getShortMessage())
+    assertThat(getRemoteHead(name("project-a"), "refs/heads/dev").getShortMessage())
         .contains("some message in a dev.txt");
-    assertThat(
-        getRemoteHead(name("project-b"), "refs/heads/master").getShortMessage())
+    assertThat(getRemoteHead(name("project-b"), "refs/heads/master").getShortMessage())
         .contains("some message in b master.txt");
-    assertThat(
-        getRemoteHead(name("project-b"), "refs/heads/dev").getShortMessage())
+    assertThat(getRemoteHead(name("project-b"), "refs/heads/dev").getShortMessage())
         .contains("some message in b dev.txt");
   }
 
   @Test
-  public void testTwoProjectsMultipleBranchesWholeTopic() throws Exception {
+  public void twoProjectsMultipleBranchesWholeTopic() throws Exception {
     TestRepository<?> repoA = createProjectWithPush("project-a");
     TestRepository<?> repoB = createProjectWithPush("project-b");
     // bootstrap the dev branch
@@ -590,25 +750,34 @@
     // bootstrap the dev branch
     ObjectId b0 = pushChangeTo(repoB, "dev");
 
-    allowMatchingSubmoduleSubscription("project-b",
-        "refs/heads/master", "project-a", "refs/heads/master");
-    allowMatchingSubmoduleSubscription("project-b", "refs/heads/dev",
-        "project-a", "refs/heads/dev");
+    allowMatchingSubmoduleSubscription(
+        "project-b", "refs/heads/master", "project-a", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "project-b", "refs/heads/dev", "project-a", "refs/heads/dev");
 
     createSubmoduleSubscription(repoA, "master", "project-b", "master");
     createSubmoduleSubscription(repoA, "dev", "project-b", "dev");
 
-
     // create a change for master branch in repo b
     ObjectId bHead =
-        pushChangeTo(repoB, "refs/for/master", "master.txt", "content master B",
-            "some message in b master.txt", "same-topic");
+        pushChangeTo(
+            repoB,
+            "refs/for/master",
+            "master.txt",
+            "content master B",
+            "some message in b master.txt",
+            "same-topic");
 
     // create a change for dev branch in repo b
     repoB.reset(b0);
     ObjectId bDevHead =
-        pushChangeTo(repoB, "refs/for/dev", "dev.txt", "content dev B",
-            "some message in b dev.txt", "same-topic");
+        pushChangeTo(
+            repoB,
+            "refs/for/dev",
+            "dev.txt",
+            "content dev B",
+            "some message in b dev.txt",
+            "same-topic");
 
     approve(getChangeId(repoB, bHead).get());
     approve(getChangeId(repoB, bDevHead).get());
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
deleted file mode 100644
index fd2385b..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/VisibleRefFilterIT.java
+++ /dev/null
@@ -1,405 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.Nullable;
-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.projects.BranchInput;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.Util;
-import com.google.gerrit.testutil.DisabledReviewDb;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Test;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-
-@NoHttpd
-public class VisibleRefFilterIT extends AbstractDaemonTest {
-  @Inject
-  private ChangeEditModifier editModifier;
-
-  @Inject
-  private ProjectControl.GenericFactory projectControlFactory;
-
-  @Inject
-  @Nullable
-  private SearchingChangeCacheImpl changeCache;
-
-  @Inject
-  private TagCache tagCache;
-
-  @Inject
-  private Provider<CurrentUser> userProvider;
-
-  private AccountGroup.UUID admins;
-
-  private Change.Id c1;
-  private Change.Id c2;
-  private String r1;
-  private String r2;
-
-  @Before
-  public void setUp() throws Exception {
-    admins = groupCache.get(new AccountGroup.NameKey("Administrators"))
-        .getGroupUUID();
-    setUpPermissions();
-    setUpChanges();
-  }
-
-  private void setUpPermissions() throws Exception {
-    // Remove read permissions for all users besides admin. This method is
-    // idempotent, so is safe to call on every test setup.
-    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
-    }
-    Util.allow(pc, Permission.READ, admins, "refs/*");
-    saveProjectConfig(allProjects, pc);
-  }
-
-  private static String changeRefPrefix(Change.Id id) {
-    String ps = new PatchSet.Id(id, 1).toRefName();
-    return ps.substring(0, ps.length() - 1);
-  }
-
-  private void setUpChanges() throws Exception {
-    gApi.projects()
-        .name(project.get())
-        .branch("branch")
-        .create(new BranchInput());
-
-    allow(Permission.SUBMIT, admins, "refs/for/refs/heads/*");
-    PushOneCommit.Result mr = pushFactory.create(db, admin.getIdent(), testRepo)
-        .to("refs/for/master%submit");
-    mr.assertOkStatus();
-    c1 = mr.getChange().getId();
-    r1 = changeRefPrefix(c1);
-    PushOneCommit.Result br = pushFactory.create(db, admin.getIdent(), testRepo)
-        .to("refs/for/branch%submit");
-    br.assertOkStatus();
-    c2 = br.getChange().getId();
-    r2 = changeRefPrefix(c2);
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      // master-tag -> master
-      RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
-      mtu.setExpectedOldObjectId(ObjectId.zeroId());
-      mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
-      assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
-
-      // branch-tag -> branch
-      RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
-      btu.setExpectedOldObjectId(ObjectId.zeroId());
-      btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
-      assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
-    }
-  }
-
-  @Test
-  public void allRefsVisibleNoRefsMetaConfig() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.READ, admins, RefNames.REFS_CONFIG);
-    Util.doNotInherit(cfg, Permission.READ, RefNames.REFS_CONFIG);
-    saveProjectConfig(project, cfg);
-
-    setApiUser(user);
-    assertRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        "refs/heads/branch",
-        "refs/heads/master",
-        "refs/tags/branch-tag",
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void allRefsVisibleWithRefsMetaConfig() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/*");
-    allow(Permission.READ, REGISTERED_USERS, RefNames.REFS_CONFIG);
-
-    assertRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        "refs/heads/branch",
-        "refs/heads/master",
-        RefNames.REFS_CONFIG,
-        "refs/tags/branch-tag",
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void subsetOfBranchesVisibleIncludingHead() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
-
-    setApiUser(user);
-    assertRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        "refs/heads/master",
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void subsetOfBranchesVisibleNotIncludingHead() throws Exception {
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
-
-    setApiUser(user);
-    assertRefs(
-        r2 + "1",
-        r2 + "meta",
-        "refs/heads/branch",
-        "refs/tags/branch-tag",
-        // master branch is not visible but master-tag is reachable from branch
-        // (since PushOneCommit always bases changes on each other).
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void subsetOfBranchesVisibleWithEdit() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
-
-    Change c = notesFactory.createChecked(db, project, c1).getChange();
-    PatchSet ps1 = getPatchSet(new PatchSet.Id(c1, 1));
-
-    // Admin's edit is not visible.
-    setApiUser(admin);
-    editModifier.createEdit(c, ps1);
-
-    // User's edit is visible.
-    setApiUser(user);
-    editModifier.createEdit(c, ps1);
-
-    assertRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        "refs/heads/master",
-        "refs/tags/master-tag",
-        "refs/users/01/1000001/edit-" + c1.get() + "/1");
-  }
-
-  @Test
-  public void subsetOfRefsVisibleWithAccessDatabase() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    try {
-      deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-      allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
-
-      Change c = notesFactory.createChecked(db, project, c1).getChange();
-      PatchSet ps1 = getPatchSet(new PatchSet.Id(c1, 1));
-      setApiUser(admin);
-      editModifier.createEdit(c, ps1);
-      setApiUser(user);
-
-      assertRefs(
-          // Change 1 is visible due to accessDatabase capability, even though
-          // refs/heads/master is not.
-          r1 + "1",
-          r1 + "meta",
-          r2 + "1",
-          r2 + "meta",
-          "refs/heads/branch",
-          "refs/tags/branch-tag",
-          // See comment in subsetOfBranchesVisibleNotIncludingHead.
-          "refs/tags/master-tag",
-          // All edits are visible due to accessDatabase capability.
-          "refs/users/00/1000000/edit-" + c1.get() + "/1");
-    } finally {
-      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-    }
-  }
-
-  @Test
-  public void draftRefs() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
-
-    PushOneCommit.Result br = pushFactory.create(db, admin.getIdent(), testRepo)
-        .to("refs/drafts/master");
-    br.assertOkStatus();
-    Change.Id c3 = br.getChange().getId();
-    String r3 = changeRefPrefix(c3);
-
-    // Only admin can see admin's draft change.
-    setApiUser(admin);
-    assertRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        "refs/heads/branch",
-        "refs/heads/master",
-        RefNames.REFS_CONFIG,
-        "refs/tags/branch-tag",
-        "refs/tags/master-tag");
-
-    // user can't.
-    setApiUser(user);
-    assertRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        "refs/heads/branch",
-        "refs/heads/master",
-        "refs/tags/branch-tag",
-        "refs/tags/master-tag");
-  }
-
-  @Test
-  public void noSearchingChangeCacheImpl() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
-
-    setApiUser(user);
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(
-          repo,
-          new VisibleRefFilter(tagCache, notesFactory, null, repo,
-              projectControl(), db, true),
-          // Can't use stored values from the index so DB must be enabled.
-          false,
-          "HEAD",
-          r1 + "1",
-          r1 + "meta",
-          r2 + "1",
-          r2 + "meta",
-          "refs/heads/branch",
-          "refs/heads/master",
-          "refs/tags/branch-tag",
-          "refs/tags/master-tag");
-    }
-  }
-
-  @Test
-  public void sequencesWithAccessDatabase() throws Exception {
-    assume().that(notesMigration.readChangeSequence()).isTrue();
-    try (Repository repo = repoManager.openRepository(allProjects)) {
-      setApiUser(user);
-      assertRefs(repo, newFilter(db, repo, allProjects), true);
-
-      allowGlobalCapabilities(
-          REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      try {
-        setApiUser(user);
-        assertRefs(
-            repo, newFilter(db, repo, allProjects), true,
-            "refs/sequences/changes");
-      } finally {
-        removeGlobalCapabilities(
-            REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      }
-    }
-  }
-
-  /**
-   * Assert that refs seen by a non-admin user match expected.
-   *
-   * @param expectedWithMeta expected refs, in order. If NoteDb is disabled by
-   *     the configuration, any NoteDb refs (i.e. ending in "/meta") are removed
-   *     from the expected list before comparing to the actual results.
-   * @throws Exception
-   */
-  private void assertRefs(String... expectedWithMeta) throws Exception {
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(
-          repo,
-          new VisibleRefFilter(tagCache, notesFactory, changeCache, repo,
-              projectControl(), new DisabledReviewDb(), true),
-          true,
-          expectedWithMeta);
-    }
-  }
-
-  private void assertRefs(Repository repo, VisibleRefFilter filter,
-      boolean disableDb, String... expectedWithMeta) throws Exception {
-    List<String> expected = new ArrayList<>(expectedWithMeta.length);
-    for (String r : expectedWithMeta) {
-      if (notesMigration.writeChanges() || !r.endsWith(RefNames.META_SUFFIX)) {
-        expected.add(r);
-      }
-    }
-
-    AcceptanceTestRequestScope.Context ctx = null;
-    if (disableDb) {
-      ctx = disableDb();
-    }
-    try {
-      Map<String, Ref> all = repo.getAllRefs();
-      assertThat(filter.filter(all, false).keySet())
-          .containsExactlyElementsIn(expected);
-    } finally {
-      if (disableDb) {
-        enableDb(ctx);
-      }
-    }
-  }
-
-  private ProjectControl projectControl() throws Exception {
-    return projectControlFactory.controlFor(project, userProvider.get());
-  }
-
-  private VisibleRefFilter newFilter(ReviewDb db, Repository repo,
-      Project.NameKey project) throws Exception {
-    return new VisibleRefFilter(
-        tagCache, notesFactory, null, repo,
-        projectControlFactory.controlFor(project, userProvider.get()),
-        db, true);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
new file mode 100644
index 0000000..cca66b3
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.extensions.client.ListGroupsOption.MEMBERS;
+
+import com.google.common.io.MoreFiles;
+import com.google.common.io.RecursiveDeleteOption;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Injector;
+import java.nio.file.Files;
+import org.junit.Test;
+
+@NoHttpd
+public abstract class AbstractReindexTests extends StandaloneSiteTest {
+  /** @param injector injector */
+  public abstract void configureIndex(Injector injector) throws Exception;
+
+  @Test
+  public void reindexFromScratch() throws Exception {
+    Project.NameKey project = new Project.NameKey("project");
+    String changeId;
+    try (ServerContext ctx = startServer()) {
+      configureIndex(ctx.getInjector());
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      gApi.projects().create("project");
+
+      ChangeInput in = new ChangeInput();
+      in.project = project.get();
+      in.branch = "master";
+      in.subject = "Test change";
+      in.newBranch = true;
+      changeId = gApi.changes().create(in).info().changeId;
+    }
+
+    MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
+    Files.createDirectory(sitePaths.index_dir);
+    assertServerStartupFails();
+
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+
+    try (ServerContext ctx = startServer()) {
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      // Query change index
+      assertThat(gApi.changes().query("message:Test").get().stream().map(c -> c.changeId))
+          .containsExactly(changeId);
+      // Query account index
+      assertThat(gApi.accounts().query("admin").get().stream().map(a -> a._accountId))
+          .containsExactly(adminId.get());
+      // Query group index
+      assertThat(
+              gApi.groups().query("Group").withOption(MEMBERS).get().stream()
+                  .flatMap(g -> g.members.stream())
+                  .map(a -> a._accountId))
+          .containsExactly(adminId.get());
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUCK
deleted file mode 100644
index ff167ac..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUCK
+++ /dev/null
@@ -1,8 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'pgm',
-  srcs = glob(['*IT.java']),
-  source_under_test = ['//gerrit-pgm:pgm'],
-  labels = ['pgm'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
index 806acd2..c094b5b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/BUILD
@@ -1,8 +1,33 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'pgm',
-  srcs = glob(['*IT.java']),
-  source_under_test = ['//gerrit-pgm:pgm'],
-  labels = ['pgm'],
+    srcs = glob(
+        ["*IT.java"],
+        exclude = ["ElasticReindexIT.java"],
+    ),
+    group = "pgm",
+    labels = ["pgm"],
+    deps = [":util"],
+)
+
+acceptance_tests(
+    srcs = [
+        "ElasticReindexIT.java",
+    ],
+    group = "elastic",
+    labels = [
+        "docker",
+        "elastic",
+        "exclusive",
+        "pgm",
+    ],
+    deps = [":util"],
+)
+
+java_library(
+    name = "util",
+    testonly = 1,
+    srcs = ["AbstractReindexTests.java"],
+    deps = ["//gerrit-acceptance-tests:lib"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
new file mode 100644
index 0000000..4e1ec08
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import com.google.gerrit.elasticsearch.ElasticVersion;
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Injector;
+import java.util.UUID;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+
+public class ElasticReindexIT extends AbstractReindexTests {
+  private static ElasticContainer<?> container;
+
+  private static Config getConfig(ElasticVersion version) {
+    ElasticNodeInfo elasticNodeInfo;
+    container = ElasticContainer.createAndStart(version);
+    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    String indicesPrefix = UUID.randomUUID().toString();
+    Config cfg = new Config();
+    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
+    return cfg;
+  }
+
+  @ConfigSuite.Default
+  public static Config elasticsearchV2() {
+    return getConfig(ElasticVersion.V2_4);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV5() {
+    return getConfig(ElasticVersion.V5_6);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV6() {
+    return getConfig(ElasticVersion.V6_4);
+  }
+
+  @Override
+  public void configureIndex(Injector injector) throws Exception {
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Before
+  public void reindexFirstSinceElastic() throws Exception {
+    assertServerStartupFails();
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+  }
+
+  @After
+  public void stopElasticServer() {
+    if (container != null) {
+      container.stop();
+      container = null;
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNoteDbIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNoteDbIT.java
index 66e0c73..b4e06d0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNoteDbIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNoteDbIT.java
@@ -17,17 +17,16 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.io.FileWriteMode;
 import com.google.common.io.Files;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.notedb.ConfigNotesMigration;
 import com.google.gerrit.testutil.TempFileUtil;
-
+import java.io.File;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.File;
-
 public class RebuildNoteDbIT {
   private File sitePath;
 
@@ -46,16 +45,21 @@
   @Test
   public void rebuildEmptySite() throws Exception {
     initSite();
-    Files.append(ConfigNotesMigration.allEnabledConfig().toText(),
-        new File(sitePath.toString(), "etc/gerrit.config"),
-        UTF_8);
-    runGerrit("RebuildNoteDb", "-d", sitePath.toString(),
-        "--show-stack-trace");
+    Files.asCharSink(
+            new File(sitePath.toString(), "etc/gerrit.config"), UTF_8, FileWriteMode.APPEND)
+        .write(ConfigNotesMigration.allEnabledConfig().toText());
+    runGerrit("RebuildNoteDb", "-d", sitePath.toString(), "--show-stack-trace");
   }
 
   private void initSite() throws Exception {
-    runGerrit("init", "-d", sitePath.getPath(),
-        "--batch", "--no-auto-start", "--skip-plugins", "--show-stack-trace");
+    runGerrit(
+        "init",
+        "-d",
+        sitePath.getPath(),
+        "--batch",
+        "--no-auto-start",
+        "--skip-plugins",
+        "--show-stack-trace");
   }
 
   private static void runGerrit(String... args) throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ReindexIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ReindexIT.java
index 968456b..223851e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ReindexIT.java
@@ -14,45 +14,9 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import static com.google.common.truth.Truth.assertThat;
+import com.google.inject.Injector;
 
-import com.google.gerrit.launcher.GerritLauncher;
-import com.google.gerrit.testutil.TempFileUtil;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-import java.io.File;
-
-public class ReindexIT {
-  private File sitePath;
-
-  @Before
-  public void createTempDirectory() throws Exception {
-    sitePath = TempFileUtil.createTempDirectory();
-  }
-
-  @After
-  public void destroySite() throws Exception {
-    if (sitePath != null) {
-      TempFileUtil.cleanup();
-    }
-  }
-
-  @Test
-  public void reindexEmptySite() throws Exception {
-    initSite();
-    runGerrit("reindex", "-d", sitePath.toString(),
-        "--show-stack-trace");
-  }
-
-  private void initSite() throws Exception {
-    runGerrit("init", "-d", sitePath.getPath(),
-        "--batch", "--no-auto-start", "--skip-plugins", "--show-stack-trace");
-  }
-
-  private static void runGerrit(String... args) throws Exception {
-    assertThat(GerritLauncher.mainImpl(args)).isEqualTo(0);
-  }
+public class ReindexIT extends AbstractReindexTests {
+  @Override
+  public void configureIndex(Injector injector) {}
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java
new file mode 100644
index 0000000..b3ae01f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import java.lang.management.ManagementFactory;
+import java.lang.management.ThreadMXBean;
+import org.junit.Test;
+import org.junit.runner.Description;
+
+@Sandboxed
+public class StartStopDaemonIT extends AbstractDaemonTest {
+  Description suiteDescription = Description.createSuiteDescription(StartStopDaemonIT.class);
+
+  @Test
+  public void sandboxedDaemonDoesNotLeakThreads() throws Exception {
+    ThreadMXBean thbean = ManagementFactory.getThreadMXBean();
+    int startThreads = thbean.getThreadCount();
+    beforeTest(suiteDescription);
+    afterTest();
+    assertThat(Thread.activeCount()).isLessThan(startThreads);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 91ee332..2baaef8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -16,12 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.reviewdb.client.Account;
-
 import java.util.List;
 
 public class AccountAssert {
@@ -32,17 +30,9 @@
     assertThat(a.email).isEqualTo(ai.email);
   }
 
-  public static void assertAccountInfos(List<TestAccount> expected,
-      List<AccountInfo> actual) {
+  public static void assertAccountInfos(List<TestAccount> expected, List<AccountInfo> actual) {
     Iterable<Account.Id> expectedIds = TestAccount.ids(expected);
-    Iterable<Account.Id> actualIds = Iterables.transform(
-        actual,
-        new Function<AccountInfo, Account.Id>() {
-          @Override
-          public Account.Id apply(AccountInfo in) {
-            return new Account.Id(in._accountId);
-          }
-        });
+    Iterable<Account.Id> actualIds = Iterables.transform(actual, a -> new Account.Id(a._accountId));
     assertThat(actualIds).containsExactlyElementsIn(expectedIds).inOrder();
     for (int i = 0; i < expected.size(); i++) {
       AccountAssert.assertAccountInfo(expected.get(i), actual.get(i));
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK
deleted file mode 100644
index 76c918b..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK
+++ /dev/null
@@ -1,23 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'rest_account',
-  srcs = glob(['*IT.java']),
-  deps = [':util'],
-  labels = ['rest'],
-)
-
-java_library(
-  name = 'util',
-  srcs = [
-    'AccountAssert.java',
-    'CapabilityInfo.java',
-  ],
-  deps = [
-    '//gerrit-acceptance-tests:lib',
-    '//gerrit-reviewdb:server',
-    '//lib:gwtorm',
-    '//lib:junit',
-  ],
-  visibility = ['//gerrit-acceptance-tests/...'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD
index 558d0a9..f6e8dde 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUILD
@@ -1,23 +1,25 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'rest_account',
-  srcs = glob(['*IT.java']),
-  deps = [':util'],
-  labels = ['rest']
+    srcs = glob(["*IT.java"]),
+    group = "rest_account",
+    labels = ["rest"],
+    deps = [":util"],
 )
 
 java_library(
-  name = 'util',
-  srcs = [
-    'AccountAssert.java',
-    'CapabilityInfo.java',
-  ],
-  deps = [
-    '//gerrit-acceptance-tests:lib',
-    '//gerrit-reviewdb:server',
-    '//lib:gwtorm',
-    '//lib:junit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "util",
+    testonly = 1,
+    srcs = [
+        "AccountAssert.java",
+        "CapabilityInfo.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-acceptance-tests:lib",
+        "//gerrit-reviewdb:server",
+        "//lib:gwtorm",
+        "//lib:junit",
+    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index ce82270..ec0197a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -26,35 +26,29 @@
 import static com.google.gerrit.common.data.GlobalCapability.RUN_AS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
-
 import org.junit.Test;
 
 public class CapabilitiesIT extends AbstractDaemonTest {
 
   @Test
-  public void testCapabilitiesUser() throws Exception {
-    Iterable<String> all = Iterables.filter(GlobalCapability.getAllNames(),
-        new Predicate<String>() {
-          @Override
-          public boolean apply(String in) {
-            return !ADMINISTRATE_SERVER.equals(in) && !PRIORITY.equals(in);
-          }
-        });
+  public void capabilitiesUser() throws Exception {
+    Iterable<String> all =
+        Iterables.filter(
+            GlobalCapability.getAllNames(),
+            c -> !ADMINISTRATE_SERVER.equals(c) && !PRIORITY.equals(c));
 
     allowGlobalCapabilities(REGISTERED_USERS, all);
     try {
-      RestResponse r =
-          userRestSession.get("/accounts/self/capabilities");
+      RestResponse r = userRestSession.get("/accounts/self/capabilities");
       r.assertOK();
-      CapabilityInfo info = (new Gson()).fromJson(r.getReader(),
-          new TypeToken<CapabilityInfo>() {}.getType());
+      CapabilityInfo info =
+          (new Gson()).fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
       for (String c : GlobalCapability.getAllNames()) {
         if (ADMINISTRATE_SERVER.equals(c)) {
           assertThat(info.administrateServer).isFalse();
@@ -67,8 +61,10 @@
           assertThat(info.queryLimit.min).isEqualTo((short) 0);
           assertThat(info.queryLimit.max).isEqualTo((short) DEFAULT_MAX_QUERY_LIMIT);
         } else {
-          assert_().withFailureMessage(String.format("capability %s was not granted", c))
-            .that((Boolean) CapabilityInfo.class.getField(c).get(info)).isTrue();
+          assert_()
+              .withFailureMessage(String.format("capability %s was not granted", c))
+              .that((Boolean) CapabilityInfo.class.getField(c).get(info))
+              .isTrue();
         }
       }
     } finally {
@@ -77,12 +73,11 @@
   }
 
   @Test
-  public void testCapabilitiesAdmin() throws Exception {
-    RestResponse r =
-        adminRestSession.get("/accounts/self/capabilities");
+  public void capabilitiesAdmin() throws Exception {
+    RestResponse r = adminRestSession.get("/accounts/self/capabilities");
     r.assertOK();
-    CapabilityInfo info = (new Gson()).fromJson(r.getReader(),
-        new TypeToken<CapabilityInfo>() {}.getType());
+    CapabilityInfo info =
+        (new Gson()).fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
     for (String c : GlobalCapability.getAllNames()) {
       if (BATCH_CHANGES_LIMIT.equals(c)) {
         // It does not have default value for any user as it can override the
@@ -91,8 +86,7 @@
       } else if (PRIORITY.equals(c)) {
         assertThat(info.priority).isFalse();
       } else if (QUERY_LIMIT.equals(c)) {
-        assert_().withFailureMessage("missing queryLimit")
-          .that(info.queryLimit).isNotNull();
+        assert_().withFailureMessage("missing queryLimit").that(info.queryLimit).isNotNull();
         assertThat(info.queryLimit.min).isEqualTo((short) 0);
         assertThat(info.queryLimit.max).isEqualTo((short) DEFAULT_MAX_QUERY_LIMIT);
       } else if (ACCESS_DATABASE.equals(c)) {
@@ -100,8 +94,10 @@
       } else if (RUN_AS.equals(c)) {
         assertThat(info.runAs).isFalse();
       } else {
-        assert_().withFailureMessage(String.format("capability %s was not granted", c))
-          .that((Boolean) CapabilityInfo.class.getField(c).get(info)).isTrue();
+        assert_()
+            .withFailureMessage(String.format("capability %s was not granted", c))
+            .that((Boolean) CapabilityInfo.class.getField(c).get(info))
+            .isTrue();
       }
     }
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EmailIT.java
new file mode 100644
index 0000000..a1dc8a2
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gson.reflect.TypeToken;
+import java.util.List;
+import java.util.Set;
+import org.junit.Test;
+
+public class EmailIT extends AbstractDaemonTest {
+
+  @Test
+  public void addEmail() throws Exception {
+    String email = "foo.bar@example.com";
+    assertThat(getEmails()).doesNotContain(email);
+
+    createEmail(email);
+    assertThat(getEmails()).contains(email);
+  }
+
+  @Test
+  public void addUrlEncodedEmail() throws Exception {
+    String email = "foo.bar2@example.com";
+    assertThat(getEmails()).doesNotContain(email);
+
+    createEmail(email.replace("@", "%40"));
+    assertThat(getEmails()).contains(email);
+  }
+
+  @Test
+  public void addEmailWithLeadingAndTrailingWhitespace() throws Exception {
+    String email = "foo.bar3@example.com";
+    assertThat(getEmails()).doesNotContain(email);
+
+    createEmail(IdString.fromDecoded(" " + email + " ").encoded());
+    assertThat(getEmails()).contains(email);
+  }
+
+  @Test
+  public void deleteEmail() throws Exception {
+    String email = "foo.baz@example.com";
+    assertThat(getEmails()).doesNotContain(email);
+
+    createEmail(email);
+    assertThat(getEmails()).contains(email);
+
+    RestResponse r = adminRestSession.delete("/accounts/self/emails/" + email);
+    r.assertNoContent();
+    assertThat(getEmails()).doesNotContain(email);
+  }
+
+  @Test
+  public void deleteUrlEncodedEmail() throws Exception {
+    String email = "foo.baz2@example.com";
+    assertThat(getEmails()).doesNotContain(email);
+
+    createEmail(email);
+    assertThat(getEmails()).contains(email);
+
+    RestResponse r = adminRestSession.delete("/accounts/self/emails/" + email.replace("@", "%40"));
+    r.assertNoContent();
+    assertThat(getEmails()).doesNotContain(email);
+  }
+
+  private Set<String> getEmails() throws Exception {
+    RestResponse r = adminRestSession.get("/accounts/self/emails");
+    r.assertOK();
+    List<EmailInfo> emails =
+        newGson().fromJson(r.getReader(), new TypeToken<List<EmailInfo>>() {}.getType());
+    return emails.stream().map(e -> e.email).collect(toSet());
+  }
+
+  private void createEmail(String email) throws Exception {
+    EmailInput input = new EmailInput();
+    input.noConfirmation = true;
+    RestResponse r = adminRestSession.put("/accounts/self/emails/" + email, input);
+    r.assertCreated();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
new file mode 100644
index 0000000..c96780a
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gson.reflect.TypeToken;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+
+@Sandboxed
+public class ExternalIdIT extends AbstractDaemonTest {
+
+  @Test
+  public void getExternalIDs() throws Exception {
+    Collection<ExternalId> expectedIds = accountCache.get(user.getId()).getExternalIds();
+
+    List<AccountExternalIdInfo> expectedIdInfos = new ArrayList<>();
+    for (ExternalId id : expectedIds) {
+      AccountExternalIdInfo info = new AccountExternalIdInfo();
+      info.identity = id.key().get();
+      info.emailAddress = id.email();
+      info.canDelete = !id.isScheme(SCHEME_USERNAME) ? true : null;
+      info.trusted = true;
+      expectedIdInfos.add(info);
+    }
+
+    RestResponse response = userRestSession.get("/accounts/self/external.ids");
+    response.assertOK();
+
+    List<AccountExternalIdInfo> results =
+        newGson()
+            .fromJson(
+                response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
+
+    Collections.sort(expectedIdInfos);
+    Collections.sort(results);
+    assertThat(results).containsExactlyElementsIn(expectedIdInfos);
+  }
+
+  @Test
+  public void deleteExternalIDs() throws Exception {
+    setApiUser(user);
+    List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
+
+    List<String> toDelete = new ArrayList<>();
+    List<AccountExternalIdInfo> expectedIds = new ArrayList<>();
+    for (AccountExternalIdInfo id : externalIds) {
+      if (id.canDelete != null && id.canDelete) {
+        toDelete.add(id.identity);
+        continue;
+      }
+      expectedIds.add(id);
+    }
+
+    assertThat(toDelete).hasSize(1);
+
+    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
+    response.assertNoContent();
+    List<AccountExternalIdInfo> results = gApi.accounts().self().getExternalIds();
+    // The external ID in WebSession will not be set for tests, resulting that
+    // "mailto:user@example.com" can be deleted while "username:user" can't.
+    assertThat(results).hasSize(1);
+    assertThat(results).containsExactlyElementsIn(expectedIds);
+  }
+
+  @Test
+  public void deleteExternalIDs_Conflict() throws Exception {
+    List<String> toDelete = new ArrayList<>();
+    String externalIdStr = "username:" + user.username;
+    toDelete.add(externalIdStr);
+    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
+    response.assertConflict();
+    assertThat(response.getEntityContent())
+        .isEqualTo(String.format("External id %s cannot be deleted", externalIdStr));
+  }
+
+  @Test
+  public void deleteExternalIDs_UnprocessableEntity() throws Exception {
+    List<String> toDelete = new ArrayList<>();
+    String externalIdStr = "mailto:user@domain.com";
+    toDelete.add(externalIdStr);
+    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
+    response.assertUnprocessableEntity();
+    assertThat(response.getEntityContent())
+        .isEqualTo(String.format("External id %s does not exist", externalIdStr));
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index f48f9fa..dcd40b9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.GetDetail.AccountDetailInfo;
-
 import org.junit.Test;
 
 public class GetAccountDetailIT extends AbstractDaemonTest {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 2944a57..ed7abd2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-
 import org.junit.Test;
 
 @NoHttpd
@@ -51,8 +50,7 @@
     testGetAccount("self", admin);
   }
 
-  private void testGetAccount(String id, TestAccount expectedAccount)
-      throws Exception {
+  private void testGetAccount(String id, TestAccount expectedAccount) throws Exception {
     assertAccountInfo(expectedAccount, gApi.accounts().id(id).get());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
new file mode 100644
index 0000000..54943e7
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -0,0 +1,576 @@
+// 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.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import org.apache.http.Header;
+import org.apache.http.message.BasicHeader;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ImpersonationIT extends AbstractDaemonTest {
+  @Inject private AccountControl.Factory accountControlFactory;
+
+  @Inject private ApprovalsUtil approvalsUtil;
+
+  @Inject private ChangeMessagesUtil cmUtil;
+
+  @Inject private CommentsUtil commentsUtil;
+
+  private RestSession anonRestSession;
+  private TestAccount admin2;
+  private GroupInfo newGroup;
+
+  @Before
+  public void setUp() throws Exception {
+    anonRestSession = new RestSession(server, null);
+    admin2 = accounts.admin2();
+    GroupInput gi = new GroupInput();
+    gi.name = name("New-Group");
+    gi.members = ImmutableList.of(user.id.toString());
+    newGroup = gApi.groups().create(gi).get();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    removeRunAs();
+  }
+
+  @Test
+  public void voteOnBehalfOf() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(user.id);
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void voteOnBehalfOfRequiresLabel() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("label required to post review on behalf of \"" + in.onBehalfOf + '"');
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfInvalidLabel() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.strictLabels = true;
+    in.label("Not-A-Label", 5);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("label \"Not-A-Label\" is not a configured label");
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfInvalidLabelIgnoredWithoutStrictLabels() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.strictLabels = false;
+    in.label("Code-Review", 1);
+    in.label("Not-A-Label", 5);
+
+    revision.review(in);
+
+    assertThat(gApi.changes().id(r.getChangeId()).get().labels).doesNotContainKey("Not-A-Label");
+  }
+
+  @Test
+  public void voteOnBehalfOfLabelNotPermitted() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Verified", 1);
+
+    exception.expect(AuthException.class);
+    exception.expectMessage(
+        "not permitted to modify label \"Verified\" on behalf of \"" + in.onBehalfOf + '"');
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfWithComment() throws Exception {
+    testVoteOnBehalfOfWithComment();
+  }
+
+  @GerritConfig(name = "notedb.writeJson", value = "true")
+  @Test
+  public void voteOnBehalfOfWithCommentWritingJson() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    testVoteOnBehalfOfWithComment();
+  }
+
+  private void testVoteOnBehalfOfWithComment() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    CommentInput ci = new CommentInput();
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "message";
+    in.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+
+    ChangeData cd = r.getChange();
+    Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(db, cd.notes()));
+    assertThat(c.message).isEqualTo(ci.message);
+    assertThat(c.author.getId()).isEqualTo(user.id);
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+  }
+
+  @GerritConfig(name = "notedb.writeJson", value = "true")
+  @Test
+  public void voteOnBehalfOfWithRobotComment() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    RobotCommentInput ci = new RobotCommentInput();
+    ci.robotId = "my-robot";
+    ci.robotRunId = "abcd1234";
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "message";
+    in.robotComments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    ChangeData cd = r.getChange();
+    RobotComment c = Iterables.getOnlyElement(commentsUtil.robotCommentsByChange(cd.notes()));
+    assertThat(c.message).isEqualTo(ci.message);
+    assertThat(c.robotId).isEqualTo(ci.robotId);
+    assertThat(c.robotRunId).isEqualTo(ci.robotRunId);
+    assertThat(c.author.getId()).isEqualTo(user.id);
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void voteOnBehalfOfCannotModifyDrafts() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "message";
+    gApi.changes().id(r.getChangeId()).current().createDraft(di);
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    in.drafts = DraftHandling.PUBLISH;
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to modify other user's drafts");
+    gApi.changes().id(r.getChangeId()).current().review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfMissingUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = "doesnotexist";
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfFailsWhenUserCannotSeeDestinationRef() throws Exception {
+    blockRead(newGroup);
+
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see destination ref");
+    revision.review(in);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @Test
+  public void voteOnBehalfOfInvisibleUserNotAllowed() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    setApiUser(accounts.user2());
+    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
+    revision.review(in);
+  }
+
+  @Test
+  public void submitOnBehalfOf() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    gApi.changes().id(changeId).current().submit(in);
+
+    ChangeData cd = r.getChange();
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSetApproval submitter =
+        approvalsUtil.getSubmitter(db, cd.notes(), cd.change().currentPatchSetId());
+    assertThat(submitter.getAccountId()).isEqualTo(admin2.id);
+    assertThat(submitter.getRealAccountId()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void submitOnBehalfOfInvalidUser() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = "doesnotexist";
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    gApi.changes().id(changeId).current().submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    exception.expect(AuthException.class);
+    exception.expectMessage("submit on behalf of not permitted");
+    gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfFailsWhenUserCannotSeeDestinationRef() throws Exception {
+    blockRead(newGroup);
+
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see destination ref");
+    gApi.changes().id(changeId).current().submit(in);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @Test
+  public void submitOnBehalfOfInvisibleUserNotAllowed() throws Exception {
+    allowSubmitOnBehalfOf();
+    setApiUser(accounts.user2());
+    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
+    gApi.changes().id(changeId).current().submit(in);
+  }
+
+  @Test
+  public void runAsValidUser() throws Exception {
+    allowRunAs();
+    RestResponse res = adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id));
+    res.assertOK();
+    AccountInfo account = newGson().fromJson(res.getEntityContent(), AccountInfo.class);
+    assertThat(account._accountId).isEqualTo(user.id.get());
+  }
+
+  @GerritConfig(name = "auth.enableRunAs", value = "false")
+  @Test
+  public void runAsDisabledByConfig() throws Exception {
+    allowRunAs();
+    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("X-Gerrit-RunAs disabled by auth.enableRunAs = false");
+  }
+
+  @Test
+  public void runAsNotPermitted() throws Exception {
+    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void runAsNeverPermittedForAnonymousUsers() throws Exception {
+    allowRunAs();
+    RestResponse res = anonRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void runAsInvalidUser() throws Exception {
+    allowRunAs();
+    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader("doesnotexist"));
+    res.assertForbidden();
+    assertThat(res.getEntityContent()).isEqualTo("no account matches X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void voteUsingRunAsAvoidsRestrictionsOfOnBehalfOf() throws Exception {
+    allowRunAs();
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "inline comment";
+    gApi.changes().id(r.getChangeId()).current().createDraft(di);
+    setApiUser(admin);
+
+    // Things that aren't allowed with on_behalf_of:
+    //  - no labels.
+    //  - publish other user's drafts.
+    ReviewInput in = new ReviewInput();
+    in.message = "message";
+    in.drafts = DraftHandling.PUBLISH;
+    RestResponse res =
+        adminRestSession.postWithHeader(
+            "/changes/" + r.getChangeId() + "/revisions/current/review", runAsHeader(user.id), in);
+    res.assertOK();
+
+    ChangeMessageInfo m = Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(m.message).endsWith(in.message);
+    assertThat(m.author._accountId).isEqualTo(user.id.get());
+
+    CommentInfo c =
+        Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).comments().get(di.path));
+    assertThat(c.author._accountId).isEqualTo(user.id.get());
+    assertThat(c.message).isEqualTo(di.message);
+
+    setApiUser(user);
+    assertThat(gApi.changes().id(r.getChangeId()).drafts()).isEmpty();
+  }
+
+  @Test
+  public void runAsWithOnBehalfOf() throws Exception {
+    // - Has the same restrictions as on_behalf_of (e.g. requires labels).
+    // - Takes the effective user from on_behalf_of (user).
+    // - Takes the real user from the real caller, not the intermediate
+    //   X-Gerrit-RunAs user (user2).
+    allowRunAs();
+    allowCodeReviewOnBehalfOf();
+    TestAccount user2 = accounts.user2();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+
+    String endpoint = "/changes/" + r.getChangeId() + "/revisions/current/review";
+    RestResponse res = adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id), in);
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("label required to post review on behalf of \"" + in.onBehalfOf + '"');
+
+    in.label("Code-Review", 1);
+    adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id), in).assertOK();
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id); // not user2
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(user.id);
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
+  }
+
+  private void allowCodeReviewOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReviewType = Util.codeReview();
+    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
+    String heads = "refs/heads/*";
+    AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, forCodeReviewAs, -1, 1, uuid, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  private void allowSubmitOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    String heads = "refs/heads/*";
+    AccountGroup.UUID uuid = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, Permission.SUBMIT_AS, uuid, heads);
+    Util.allow(cfg, Permission.SUBMIT, uuid, heads);
+    LabelType codeReviewType = Util.codeReview();
+    Util.allow(cfg, Permission.forLabel(codeReviewType.getName()), -2, 2, uuid, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  private void blockRead(GroupInfo group) throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
+    saveProjectConfig(project, cfg);
+  }
+
+  private void allowRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.allow(
+        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private void removeRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.remove(
+        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private static Header runAsHeader(Object user) {
+    return new BasicHeader("X-Gerrit-RunAs", user.toString());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index 3297c60..9378591 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -24,49 +24,38 @@
 import com.google.gerrit.server.account.PutUsername;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
-
+import java.util.Collections;
 import org.junit.Test;
 
-import java.util.Collections;
-
 public class PutUsernameIT extends AbstractDaemonTest {
-  @Inject
-  private SchemaFactory<ReviewDb> reviewDbProvider;
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
 
   @Test
   public void set() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = "myUsername";
-    RestResponse r =
-        adminRestSession.put("/accounts/" + createUser().get() + "/username", in);
+    RestResponse r = adminRestSession.put("/accounts/" + createUser().get() + "/username", in);
     r.assertOK();
-    assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(
-        in.username);
+    assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(in.username);
   }
 
   @Test
   public void setExisting_Conflict() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = admin.username;
-    adminRestSession
-        .put("/accounts/" + createUser().get() + "/username", in)
-        .assertConflict();
+    adminRestSession.put("/accounts/" + createUser().get() + "/username", in).assertConflict();
   }
 
   @Test
   public void setNew_MethodNotAllowed() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = "newUsername";
-    adminRestSession
-        .put("/accounts/" + admin.username + "/username", in)
-        .assertMethodNotAllowed();
+    adminRestSession.put("/accounts/" + admin.username + "/username", in).assertMethodNotAllowed();
   }
 
   @Test
   public void delete_MethodNotAllowed() throws Exception {
-    adminRestSession
-        .put("/accounts/" + admin.username + "/username")
-        .assertMethodNotAllowed();
+    adminRestSession.put("/accounts/" + admin.username + "/username").assertMethodNotAllowed();
   }
 
   private Account.Id createUser() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index 32cfc9b..9edafb8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -21,12 +21,9 @@
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-
-import org.junit.Test;
-
 import java.util.ArrayList;
-import java.util.LinkedList;
 import java.util.List;
+import org.junit.Test;
 
 public class WatchedProjectsIT extends AbstractDaemonTest {
 
@@ -55,8 +52,7 @@
 
     List<ProjectWatchInfo> persistedWatchedProjects =
         gApi.accounts().self().setWatchedProjects(projectsToWatch);
-    assertThat(persistedWatchedProjects)
-        .containsAllIn(projectsToWatch).inOrder();
+    assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch).inOrder();
   }
 
   @Test
@@ -64,7 +60,7 @@
     String projectName1 = createProject(NEW_PROJECT_NAME).get();
     String projectName2 = createProject(NEW_PROJECT_NAME + "2").get();
 
-    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = projectName1;
@@ -87,8 +83,7 @@
     gApi.accounts().self().deleteWatchedProjects(d);
     projectsToWatch.remove(pwi);
 
-    List<ProjectWatchInfo> persistedWatchedProjects =
-        gApi.accounts().self().getWatchedProjects();
+    List<ProjectWatchInfo> persistedWatchedProjects = gApi.accounts().self().getWatchedProjects();
 
     assertThat(persistedWatchedProjects).doesNotContain(pwi);
     assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
@@ -98,7 +93,7 @@
   public void setConflictingWatches() throws Exception {
     String projectName = createProject(NEW_PROJECT_NAME).get();
 
-    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = projectName;
@@ -122,15 +117,14 @@
   public void setAndGetEmptyWatch() throws Exception {
     String projectName = createProject(NEW_PROJECT_NAME).get();
 
-    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = projectName;
     projectsToWatch.add(pwi);
 
     gApi.accounts().self().setWatchedProjects(projectsToWatch);
-    List<ProjectWatchInfo> persistedWatchedProjects =
-        gApi.accounts().self().getWatchedProjects();
+    List<ProjectWatchInfo> persistedWatchedProjects = gApi.accounts().self().getWatchedProjects();
     assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
   }
 
@@ -157,7 +151,7 @@
 
     // Let another user watch a project
     setApiUser(admin);
-    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = projectName;
@@ -183,7 +177,7 @@
 
     // Let another user watch a project
     setApiUser(admin);
-    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = projectName;
@@ -203,18 +197,16 @@
     // Perform update
     gApi.accounts().self().setWatchedProjects(projectsToWatch);
 
-    List<ProjectWatchInfo> watchedProjects =
-        gApi.accounts().self().getWatchedProjects();
+    List<ProjectWatchInfo> watchedProjects = gApi.accounts().self().getWatchedProjects();
 
     assertThat(watchedProjects).containsAllIn(projectsToWatch);
   }
 
   @Test
-  public void setAndDeleteWatchedProjectsWithDifferentFilter()
-      throws Exception {
+  public void setAndDeleteWatchedProjectsWithDifferentFilter() throws Exception {
     String projectName = project.get();
 
-    List<ProjectWatchInfo> projectsToWatch = new LinkedList<>();
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
 
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = projectName;
@@ -238,8 +230,7 @@
     gApi.accounts().self().deleteWatchedProjects(d);
     projectsToWatch.remove(pwi);
 
-    List<ProjectWatchInfo> persistedWatchedProjects =
-        gApi.accounts().self().getWatchedProjects();
+    List<ProjectWatchInfo> persistedWatchedProjects = gApi.accounts().self().getWatchedProjects();
 
     assertThat(persistedWatchedProjects).doesNotContain(pwi);
     assertThat(persistedWatchedProjects).containsAllIn(projectsToWatch);
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 c0e6306..7d55c66 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
@@ -20,18 +20,24 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -40,9 +46,11 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -52,16 +60,33 @@
 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.server.ApprovalsUtil;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -74,9 +99,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.ByteArrayOutputStream;
-import java.util.List;
-
 @NoHttpd
 public abstract class AbstractSubmit extends AbstractDaemonTest {
   @ConfigSuite.Config
@@ -84,11 +106,16 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject
-  private ApprovalsUtil approvalsUtil;
+  @Inject private ApprovalsUtil approvalsUtil;
 
-  @Inject
-  private Submit submitHandler;
+  @Inject private Submit submitHandler;
+
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private BatchUpdate.Factory updateFactory;
+
+  @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
+  private RegistrationHandle onSubmitValidatorHandle;
 
   private String systemTimeZone;
 
@@ -109,14 +136,233 @@
     db.close();
   }
 
+  @After
+  public void removeOnSubmitValidator() {
+    if (onSubmitValidatorHandle != null) {
+      onSubmitValidatorHandle.remove();
+    }
+  }
+
   protected abstract SubmitType getSubmitType();
 
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void submitToEmptyRepo() throws Exception {
+    RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange();
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmitPreview = getRemoteHead();
+    assertThat(headAfterSubmitPreview).isEqualTo(initialHead);
+    assertThat(actual).hasSize(1);
+
     submit(change.getChangeId());
     assertThat(getRemoteHead().getId()).isEqualTo(change.getCommit());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  public void submitSingleChange() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+    assertThat(headAfterSubmit).isEqualTo(initialHead);
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+
+    if ((getSubmitType() == SubmitType.CHERRY_PICK)
+        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
+      // The change is updated as well:
+      assertThat(actual).hasSize(2);
+    } else {
+      assertThat(actual).hasSize(1);
+    }
+
+    submit(change.getChangeId());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  public void submitMultipleChangesOtherMergeConflictPreview() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    // change 2 is not approved, but we ignore labels
+    approve(change3.getChangeId());
+
+    try (BinaryResult request = submitPreview(change4.getChangeId())) {
+      assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
+      submit(change4.getChangeId());
+    } catch (RestApiException e) {
+      switch (getSubmitType()) {
+        case FAST_FORWARD_ONLY:
+          assertThat(e.getMessage())
+              .isEqualTo(
+                  "Failed to submit 3 changes due to the following problems:\n"
+                      + "Change "
+                      + change2.getChange().getId()
+                      + ": internal error: "
+                      + "change not processed by merge strategy\n"
+                      + "Change "
+                      + change3.getChange().getId()
+                      + ": internal error: "
+                      + "change not processed by merge strategy\n"
+                      + "Change "
+                      + change4.getChange().getId()
+                      + ": Project policy "
+                      + "requires all submissions to be a fast-forward. Please "
+                      + "rebase the change locally and upload again for review.");
+          break;
+        case REBASE_IF_NECESSARY:
+        case REBASE_ALWAYS:
+          String change2hash = change2.getChange().currentPatchSet().getRevision().get();
+          assertThat(e.getMessage())
+              .isEqualTo(
+                  "Cannot rebase "
+                      + change2hash
+                      + ": The change could "
+                      + "not be rebased due to a conflict during merge.");
+          break;
+        case MERGE_ALWAYS:
+        case MERGE_IF_NECESSARY:
+          assertThat(e.getMessage())
+              .isEqualTo(
+                  "Failed to submit 3 changes due to the following problems:\n"
+                      + "Change "
+                      + change2.getChange().getId()
+                      + ": Change could not be "
+                      + "merged due to a path conflict. Please rebase the change "
+                      + "locally and upload the rebased commit for review.\n"
+                      + "Change "
+                      + change3.getChange().getId()
+                      + ": Change could not be "
+                      + "merged due to a path conflict. Please rebase the change "
+                      + "locally and upload the rebased commit for review.\n"
+                      + "Change "
+                      + change4.getChange().getId()
+                      + ": Change could not be "
+                      + "merged due to a path conflict. Please rebase the change "
+                      + "locally and upload the rebased commit for review.");
+          break;
+        case CHERRY_PICK:
+        default:
+          fail("Should not reach here.");
+          break;
+      }
+
+      RevCommit headAfterSubmit = getRemoteHead();
+      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
+      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+      assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
+    }
+  }
+
+  @Test
+  public void submitMultipleChangesPreview() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
+    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
+    // change 2 is not approved, but we ignore labels
+    approve(change3.getChangeId());
+    Map<Branch.NameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
+    Map<String, Map<String, Integer>> expected = new HashMap<>();
+    expected.put(project.get(), new HashMap<>());
+    expected.get(project.get()).put("refs/heads/master", 3);
+
+    assertThat(actual).containsKey(new Branch.NameKey(project, "refs/heads/master"));
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      // CherryPick ignores dependencies, thus only change and destination
+      // branch refs are modified.
+      assertThat(actual).hasSize(2);
+    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
+      // destination branch will be modified.
+      assertThat(actual).hasSize(4);
+    } else {
+      assertThat(actual).hasSize(1);
+    }
+
+    // check that the submit preview did not actually submit
+    RevCommit headAfterSubmit = getRemoteHead();
+    assertThat(headAfterSubmit).isEqualTo(initialHead);
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+
+    // now check we actually have the same content:
+    approve(change2.getChangeId());
+    submit(change4.getChangeId());
+    assertTrees(project, actual);
+  }
+
+  @Test
+  public void submitNoPermission() throws Exception {
+    // create project where submit is blocked
+    Project.NameKey p = createProject("p");
+    block(Permission.SUBMIT, REGISTERED_USERS, "refs/*", p);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
+  }
+
+  @Test
+  public void noSelfSubmit() throws Exception {
+    // create project where submit is blocked for the change owner
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.block(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
+
+    setApiUser(user);
+    submit(result.getChangeId());
+  }
+
+  @Test
+  public void onlySelfSubmit() throws Exception {
+    // create project where only the change owner can submit
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.block(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, CHANGE_OWNER, "refs/*");
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get();
+    assertThat(change.owner._accountId).isEqualTo(admin.id.get());
+
+    setApiUser(user);
+    submit(result.getChangeId(), new SubmitInput(), AuthException.class, "submit not permitted");
+
+    setApiUser(admin);
+    submit(result.getChangeId());
   }
 
   @Test
@@ -125,10 +371,8 @@
     String topic = "test-topic";
 
     // Create test projects
-    TestRepository<?> repoA = createProjectWithPush(
-        "project-a", null, getSubmitType());
-    TestRepository<?> repoB = createProjectWithPush(
-        "project-b", null, getSubmitType());
+    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
+    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
 
     // Create changes on project-a
     PushOneCommit.Result change1 =
@@ -162,11 +406,9 @@
 
     // Create test project
     String projectName = "project-a";
-    TestRepository<?> repoA = createProjectWithPush(
-        projectName, null, getSubmitType());
+    TestRepository<?> repoA = createProjectWithPush(projectName, null, getSubmitType());
 
-    RevCommit initialHead =
-        getRemoteHead(new Project.NameKey(name(projectName)), "master");
+    RevCommit initialHead = getRemoteHead(new Project.NameKey(name(projectName)), "master");
 
     // Create the dev branch on the test project
     BranchInput in = new BranchInput();
@@ -203,12 +445,9 @@
   public void submitWholeTopic() throws Exception {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
     String topic = "test-topic";
-    PushOneCommit.Result change1 =
-        createChange("Change 1", "a.txt", "content", topic);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "content", topic);
-    PushOneCommit.Result change3 =
-        createChange("Change 3", "c.txt", "content", topic);
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "content", topic);
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic);
     approve(change1.getChangeId());
     approve(change2.getChangeId());
     approve(change3.getChangeId());
@@ -226,23 +465,17 @@
 
     // Check that the repo has the expected commits
     List<RevCommit> log = getRemoteLog();
-    List<String> commitsInRepo = Lists.transform(log,
-        new Function<RevCommit, String>() {
-          @Override
-          public String apply(RevCommit input) {
-            return input.getShortMessage();
-          }
-        });
-    int expectedCommitCount = getSubmitType() == SubmitType.MERGE_ALWAYS
-        ? 5 // initial commit + 3 commits + merge commit
-        : 4; // initial commit + 3 commits
+    List<String> commitsInRepo = log.stream().map(c -> c.getShortMessage()).collect(toList());
+    int expectedCommitCount =
+        getSubmitType() == SubmitType.MERGE_ALWAYS
+            ? 5 // initial commit + 3 commits + merge commit
+            : 4; // initial commit + 3 commits
     assertThat(log).hasSize(expectedCommitCount);
 
-    assertThat(commitsInRepo).containsAllOf(
-        "Initial empty repository", "Change 1", "Change 2", "Change 3");
+    assertThat(commitsInRepo)
+        .containsAllOf("Initial empty repository", "Change 1", "Change 2", "Change 3");
     if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
-      assertThat(commitsInRepo).contains(
-          "Merge changes from topic '" + expectedTopic + "'");
+      assertThat(commitsInRepo).contains("Merge changes from topic '" + expectedTopic + "'");
     }
   }
 
@@ -250,9 +483,14 @@
   public void submitDraftChange() throws Exception {
     PushOneCommit.Result draft = createDraftChange();
     Change.Id num = draft.getChange().getId();
-    submitWithConflict(draft.getChangeId(),
+    submitWithConflict(
+        draft.getChangeId(),
         "Failed to submit 1 change due to the following problems:\n"
-        + "Change " + num + ": Change " + num + " is draft");
+            + "Change "
+            + num
+            + ": Change "
+            + num
+            + " is draft");
   }
 
   @Test
@@ -261,26 +499,30 @@
     PushOneCommit.Result draft = amendChangeAsDraft(change.getChangeId());
     Change.Id num = draft.getChange().getId();
 
-    submitWithConflict(draft.getChangeId(),
+    submitWithConflict(
+        draft.getChangeId(),
         "Failed to submit 1 change due to the following problems:\n"
-        + "Change " + num + ": submit rule error: "
-        + "Cannot submit draft patch sets");
+            + "Change "
+            + num
+            + ": submit rule error: "
+            + "Cannot submit draft patch sets");
   }
 
   @Test
   public void submitWithHiddenBranchInSameTopic() throws Exception {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    PushOneCommit.Result visible =
-        createChange("refs/for/master/" + name("topic"));
+    PushOneCommit.Result visible = createChange("refs/for/master/" + name("topic"));
     Change.Id num = visible.getChange().getId();
 
     createBranch(new Branch.NameKey(project, "hidden"));
-    PushOneCommit.Result hidden =
-        createChange("refs/for/hidden/" + name("topic"));
+    PushOneCommit.Result hidden = createChange("refs/for/hidden/" + name("topic"));
     approve(hidden.getChangeId());
     blockRead("refs/heads/hidden");
 
-    submit(visible.getChangeId(), new SubmitInput(), AuthException.class,
+    submit(
+        visible.getChangeId(),
+        new SubmitInput(),
+        AuthException.class,
         "A change to be submitted with " + num + " is not visible");
   }
 
@@ -297,16 +539,17 @@
     // C0 -- Master
     //
     ProjectConfig config = projectCache.checkedGet(project).getConfig();
-    config.getProject().setCreateNewChangeForAllNotInTarget(
-        InheritableBoolean.TRUE);
+    config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
     saveProjectConfig(project, config);
 
-    PushOneCommit push1 = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, "a.txt", "content");
+    PushOneCommit push1 =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     PushOneCommit.Result c1 = push1.to("refs/heads/topic");
     c1.assertOkStatus();
-    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, "b.txt", "anotherContent");
+    PushOneCommit push2 =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     PushOneCommit.Result c2 = push2.to("refs/heads/topic");
     c2.assertOkStatus();
 
@@ -329,11 +572,12 @@
     // I   -- master
     //
     RevCommit master = getRemoteHead(project, "master");
-    PushOneCommit stableTip = pushFactory.create(db, admin.getIdent(), testRepo,
-        "Tip of branch stable", "stable.txt", "");
+    PushOneCommit stableTip =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, "Tip of branch stable", "stable.txt", "");
     PushOneCommit.Result stable = stableTip.to("refs/heads/stable");
-    PushOneCommit mergeCommit = pushFactory.create(db, admin.getIdent(),
-        testRepo, "The merge commit", "merge.txt", "");
+    PushOneCommit mergeCommit =
+        pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
     mergeCommit.setParents(ImmutableList.of(master, stable.getCommit()));
     PushOneCommit.Result mergeReview = mergeCommit.to("refs/for/master");
     approve(mergeReview.getChangeId());
@@ -358,24 +602,26 @@
     //
     RevCommit initial = getRemoteHead(project, "master");
     // push directly to stable to S1
-    PushOneCommit.Result s1 = pushFactory.create(
-      db, admin.getIdent(), testRepo, "new commit into stable", "stable1.txt", "")
-      .to("refs/heads/stable");
+    PushOneCommit.Result s1 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "new commit into stable", "stable1.txt", "")
+            .to("refs/heads/stable");
     // move the stable tip ahead to S2
-    pushFactory.create(
-      db, admin.getIdent(), testRepo, "Tip of branch stable", "stable2.txt", "")
-      .to("refs/heads/stable");
+    pushFactory
+        .create(db, admin.getIdent(), testRepo, "Tip of branch stable", "stable2.txt", "")
+        .to("refs/heads/stable");
 
     testRepo.reset(initial);
 
     // move the master ahead
-    PushOneCommit.Result m = pushFactory.create(
-      db, admin.getIdent(), testRepo, "Move master ahead", "master.txt", "")
-      .to("refs/heads/master");
+    PushOneCommit.Result m =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "Move master ahead", "master.txt", "")
+            .to("refs/heads/master");
 
     // create merge change
     PushOneCommit mc =
-      pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
+        pushFactory.create(db, admin.getIdent(), testRepo, "The merge commit", "merge.txt", "");
     mc.setParents(ImmutableList.of(m.getCommit(), s1.getCommit()));
     PushOneCommit.Result mergeReview = mc.to("refs/for/master");
     approve(mergeReview.getChangeId());
@@ -386,24 +632,188 @@
     assertThat(log).contains(mergeReview.getCommit());
   }
 
+  @Test
+  public void submitChangeWithCommitThatWasAlreadyMerged() throws Exception {
+    // create and submit a change
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    // set the status of the change back to NEW to simulate a failed submit that
+    // merged the commit but failed to update the change status
+    setChangeStatusToNew(change);
+
+    // submitting the change again should detect that the commit was already
+    // merged and just fix the change status to be MERGED
+    submit(change.getChangeId());
+    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+  }
+
+  @Test
+  public void submitChangesWithCommitsThatWereAlreadyMerged() throws Exception {
+    // create and submit 2 changes
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    approve(change1.getChangeId());
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      submit(change1.getChangeId());
+    }
+    submit(change2.getChangeId());
+    assertMerged(change1.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    // set the status of the changes back to NEW to simulate a failed submit that
+    // merged the commits but failed to update the change status
+    setChangeStatusToNew(change1, change2);
+
+    // submitting the changes again should detect that the commits were already
+    // merged and just fix the change status to be MERGED
+    submit(change1.getChangeId());
+    submit(change2.getChangeId());
+    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+  }
+
+  @Test
+  public void submitTopicWithCommitsThatWereAlreadyMerged() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    // create and submit 2 changes with the same topic
+    String topic = name("topic");
+    PushOneCommit.Result change1 = createChange("refs/for/master/" + topic);
+    PushOneCommit.Result change2 = createChange("refs/for/master/" + topic);
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+    assertMerged(change1.getChangeId());
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    // set the status of the second change back to NEW to simulate a failed
+    // submit that merged the commits but failed to update the change status of
+    // some changes in the topic
+    setChangeStatusToNew(change2);
+
+    // submitting the topic again should detect that the commits were already
+    // merged and just fix the change status to be MERGED
+    submit(change2.getChangeId());
+    assertThat(getRemoteHead()).isEqualTo(headAfterSubmit);
+  }
+
+  @Test
+  public void submitWithValidation() throws Exception {
+    AtomicBoolean called = new AtomicBoolean(false);
+    this.addOnSubmitValidationListener(
+        new OnSubmitValidationListener() {
+          @Override
+          public void preBranchUpdate(Arguments args) throws ValidationException {
+            called.set(true);
+            HashSet<String> refs = Sets.newHashSet(args.getCommands().keySet());
+            assertThat(refs).contains("refs/heads/master");
+            refs.remove("refs/heads/master");
+            if (!refs.isEmpty()) {
+              // Some submit strategies need to insert new patchset.
+              assertThat(refs).hasSize(1);
+              assertThat(refs.iterator().next()).startsWith(RefNames.REFS_CHANGES);
+            }
+          }
+        });
+
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+    submit(change.getChangeId());
+    assertThat(called.get()).isTrue();
+  }
+
+  @Test
+  public void submitWithValidationMultiRepo() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic = "test-topic";
+
+    // Create test projects
+    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
+    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
+
+    // Create changes on project-a
+    PushOneCommit.Result change1 =
+        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 =
+        createChange(repoA, "master", "Change 2", "b.txt", "content", topic);
+
+    // Create changes on project-b
+    PushOneCommit.Result change3 =
+        createChange(repoB, "master", "Change 3", "a.txt", "content", topic);
+    PushOneCommit.Result change4 =
+        createChange(repoB, "master", "Change 4", "b.txt", "content", topic);
+
+    List<PushOneCommit.Result> changes = Lists.newArrayList(change1, change2, change3, change4);
+    for (PushOneCommit.Result change : changes) {
+      approve(change.getChangeId());
+    }
+
+    // Construct validator which will throw on a second call.
+    // Since there are 2 repos, first submit attempt will fail, the second will
+    // succeed.
+    List<String> projectsCalled = new ArrayList<>(4);
+    this.addOnSubmitValidationListener(
+        new OnSubmitValidationListener() {
+          @Override
+          public void preBranchUpdate(Arguments args) throws ValidationException {
+            assertThat(args.getCommands().keySet()).contains("refs/heads/master");
+            try (RevWalk rw = args.newRevWalk()) {
+              rw.parseBody(rw.parseCommit(args.getCommands().get("refs/heads/master").getNewId()));
+            } catch (IOException e) {
+              throw new ValidationException("Unexpected exception", e);
+            }
+            projectsCalled.add(args.getProject().get());
+            if (projectsCalled.size() == 2) {
+              throw new ValidationException("time to fail");
+            }
+          }
+        });
+    submitWithConflict(change4.getChangeId(), "time to fail");
+    assertThat(projectsCalled).containsExactly(name("project-a"), name("project-b"));
+    for (PushOneCommit.Result change : changes) {
+      change.assertChange(Change.Status.NEW, name(topic), admin);
+    }
+
+    submit(change4.getChangeId());
+    assertThat(projectsCalled)
+        .containsExactly(
+            name("project-a"), name("project-b"), name("project-a"), name("project-b"));
+    for (PushOneCommit.Result change : changes) {
+      change.assertChange(Change.Status.MERGED, name(topic), admin);
+    }
+  }
+
+  private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
+    for (PushOneCommit.Result change : changes) {
+      try (BatchUpdate bu =
+          updateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
+        bu.addOp(
+            change.getChange().getId(),
+            new BatchUpdateOp() {
+              @Override
+              public boolean updateChange(ChangeContext ctx) throws OrmException {
+                ctx.getChange().setStatus(Change.Status.NEW);
+                ctx.getUpdate(ctx.getChange().currentPatchSetId()).setStatus(Change.Status.NEW);
+                return true;
+              }
+            });
+        bu.execute();
+      }
+    }
+  }
+
   private void assertSubmitter(PushOneCommit.Result change) throws Exception {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
-    Iterable<String> messages = Iterables.transform(info.messages,
-        new Function<ChangeMessageInfo, String>() {
-          @Override
-          public String apply(ChangeMessageInfo in) {
-            return in.message;
-          }
-        });
+    Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
     assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      assertThat(last).startsWith(
-          "Change has been successfully cherry-picked as ");
+      assertThat(last).startsWith("Change has been successfully cherry-picked as ");
+    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertThat(last).startsWith("Change has been successfully rebased and submitted as");
     } else {
-      assertThat(last).isEqualTo(
-          "Change has been successfully merged by Administrator");
+      assertThat(last).isEqualTo("Change has been successfully merged by Administrator");
     }
   }
 
@@ -423,15 +833,16 @@
     submit(changeId, input, null, null);
   }
 
-  protected void submitWithConflict(String changeId,
-      String expectedError) throws Exception {
-    submit(changeId, new SubmitInput(), ResourceConflictException.class,
-        expectedError);
+  protected void submitWithConflict(String changeId, String expectedError) throws Exception {
+    submit(changeId, new SubmitInput(), ResourceConflictException.class, expectedError);
   }
 
-  protected void submit(String changeId, SubmitInput input,
+  protected void submit(
+      String changeId,
+      SubmitInput input,
       Class<? extends RestApiException> expectedExceptionType,
-      String expectedExceptionMsg) throws Exception {
+      String expectedExceptionMsg)
+      throws Exception {
     approve(changeId);
     if (expectedExceptionType == null) {
       assertSubmittable(changeId);
@@ -439,8 +850,7 @@
     try {
       gApi.changes().id(changeId).current().submit(input);
       if (expectedExceptionType != null) {
-        fail("Expected exception of type "
-            + expectedExceptionType.getSimpleName());
+        fail("Expected exception of type " + expectedExceptionType.getSimpleName());
       }
     } catch (RestApiException e) {
       if (expectedExceptionType == null) {
@@ -450,11 +860,17 @@
       // us the stack trace.
       if (!expectedExceptionType.isAssignableFrom(e.getClass())
           || !e.getMessage().equals(expectedExceptionMsg)) {
-        throw new AssertionError("Expected exception of type "
-            + expectedExceptionType.getSimpleName() + " with message: \""
-            + expectedExceptionMsg + "\" but got exception of type "
-            + e.getClass().getSimpleName() + " with message \""
-            + e.getMessage() + "\"", e);
+        throw new AssertionError(
+            "Expected exception of type "
+                + expectedExceptionType.getSimpleName()
+                + " with message: \""
+                + expectedExceptionMsg
+                + "\" but got exception of type "
+                + e.getClass().getSimpleName()
+                + " with message \""
+                + e.getMessage()
+                + "\"",
+            e);
       }
       return;
     }
@@ -463,7 +879,7 @@
   }
 
   protected void assertSubmittable(String changeId) throws Exception {
-    assertThat(gApi.changes().id(changeId).info().submittable)
+    assertThat(get(changeId, SUBMITTABLE).submittable)
         .named("submit bit on ChangeInfo")
         .isEqualTo(true);
     RevisionResource rsrc = parseCurrentRevisionResource(changeId);
@@ -473,25 +889,20 @@
   }
 
   protected void assertChangeMergedEvents(String... expected) throws Exception {
-    eventRecorder.assertChangeMergedEvents(
-        project.get(), "refs/heads/master", expected);
+    eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
   }
 
-  protected void assertRefUpdatedEvents(RevCommit... expected)
+  protected void assertRefUpdatedEvents(RevCommit... expected) throws Exception {
+    eventRecorder.assertRefUpdatedEvents(project.get(), "refs/heads/master", expected);
+  }
+
+  protected void assertCurrentRevision(String changeId, int expectedNum, ObjectId expectedId)
       throws Exception {
-    eventRecorder.assertRefUpdatedEvents(
-        project.get(), "refs/heads/master", expected);
-  }
-
-  protected void assertCurrentRevision(String changeId, int expectedNum,
-      ObjectId expectedId) throws Exception {
     ChangeInfo c = get(changeId, CURRENT_REVISION);
     assertThat(c.currentRevision).isEqualTo(expectedId.name());
     assertThat(c.revisions.get(expectedId.name())._number).isEqualTo(expectedNum);
-    try (Repository repo =
-        repoManager.openRepository(new Project.NameKey(c.project))) {
-      String refName = new PatchSet.Id(new Change.Id(c._number), expectedNum)
-          .toRefName();
+    try (Repository repo = repoManager.openRepository(new Project.NameKey(c.project))) {
+      String refName = new PatchSet.Id(new Change.Id(c._number), expectedNum).toRefName();
       Ref ref = repo.exactRef(refName);
       assertThat(ref).named(refName).isNotNull();
       assertThat(ref.getObjectId()).isEqualTo(expectedId);
@@ -506,14 +917,12 @@
     assertApproved(changeId, admin);
   }
 
-  protected void assertApproved(String changeId, TestAccount user)
-      throws Exception {
+  protected void assertApproved(String changeId, TestAccount user) throws Exception {
     ChangeInfo c = get(changeId, DETAILED_LABELS);
     LabelInfo cr = c.labels.get("Code-Review");
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(2);
-    assertThat(new Account.Id(cr.all.get(0)._accountId))
-        .isEqualTo(user.getId());
+    assertThat(new Account.Id(cr.all.get(0)._accountId)).isEqualTo(user.getId());
   }
 
   protected void assertMerged(String changeId) throws RestApiException {
@@ -521,59 +930,50 @@
     assertThat(status).isEqualTo(ChangeStatus.MERGED);
   }
 
-  protected void assertPersonEquals(PersonIdent expected,
-      PersonIdent actual) {
-    assertThat(actual.getEmailAddress())
-        .isEqualTo(expected.getEmailAddress());
-    assertThat(actual.getName())
-        .isEqualTo(expected.getName());
-    assertThat(actual.getTimeZone())
-        .isEqualTo(expected.getTimeZone());
+  protected void assertPersonEquals(PersonIdent expected, PersonIdent actual) {
+    assertThat(actual.getEmailAddress()).isEqualTo(expected.getEmailAddress());
+    assertThat(actual.getName()).isEqualTo(expected.getName());
+    assertThat(actual.getTimeZone()).isEqualTo(expected.getTimeZone());
   }
 
-  protected void assertSubmitter(String changeId, int psId)
-      throws Exception {
+  protected void assertSubmitter(String changeId, int psId) throws Exception {
     assertSubmitter(changeId, psId, admin);
   }
 
-  protected void assertSubmitter(String changeId, int psId, TestAccount user)
-      throws Exception {
-    Change c =
-        getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
+  protected void assertSubmitter(String changeId, int psId, TestAccount user) throws Exception {
+    Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
     ChangeNotes cn = notesFactory.createChecked(db, c);
-    PatchSetApproval submitter = approvalsUtil.getSubmitter(db, cn,
-        new PatchSet.Id(cn.getChangeId(), psId));
+    PatchSetApproval submitter =
+        approvalsUtil.getSubmitter(db, cn, new PatchSet.Id(cn.getChangeId(), psId));
     assertThat(submitter).isNotNull();
     assertThat(submitter.isLegacySubmit()).isTrue();
     assertThat(submitter.getAccountId()).isEqualTo(user.getId());
   }
 
-  protected void assertNoSubmitter(String changeId, int psId)
-      throws Exception {
-    Change c =
-        getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
+  protected void assertNoSubmitter(String changeId, int psId) throws Exception {
+    Change c = getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).change();
     ChangeNotes cn = notesFactory.createChecked(db, c);
-    PatchSetApproval submitter = approvalsUtil.getSubmitter(
-        db, cn, new PatchSet.Id(cn.getChangeId(), psId));
+    PatchSetApproval submitter =
+        approvalsUtil.getSubmitter(db, cn, new PatchSet.Id(cn.getChangeId(), psId));
     assertThat(submitter).isNull();
   }
 
-  protected void assertCherryPick(TestRepository<?> testRepo,
-      boolean contentMerge) throws Exception {
+  protected void assertCherryPick(TestRepository<?> testRepo, boolean contentMerge)
+      throws Exception {
     assertRebase(testRepo, contentMerge);
     RevCommit remoteHead = getRemoteHead();
     assertThat(remoteHead.getFooterLines("Reviewed-On")).isNotEmpty();
     assertThat(remoteHead.getFooterLines("Reviewed-By")).isNotEmpty();
   }
 
-  protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge)
-      throws Exception {
+  protected void assertRebase(TestRepository<?> testRepo, boolean contentMerge) throws Exception {
     Repository repo = testRepo.getRepository();
     RevCommit localHead = getHead(repo);
     RevCommit remoteHead = getRemoteHead();
-    assert_().withFailureMessage(
-        String.format("%s not equal %s", localHead.name(), remoteHead.name()))
-          .that(localHead.getId()).isNotEqualTo(remoteHead.getId());
+    assert_()
+        .withFailureMessage(String.format("%s not equal %s", localHead.name(), remoteHead.name()))
+        .that(localHead.getId())
+        .isNotEqualTo(remoteHead.getId());
     assertThat(remoteHead.getParentCount()).isEqualTo(1);
     if (!contentMerge) {
       assertThat(getLatestRemoteDiff()).isEqualTo(getLatestDiff(repo));
@@ -581,12 +981,10 @@
     assertThat(remoteHead.getShortMessage()).isEqualTo(localHead.getShortMessage());
   }
 
-  protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch)
-      throws Exception {
+  protected List<RevCommit> getRemoteLog(Project.NameKey project, String branch) throws Exception {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      rw.markStart(rw.parseCommit(
-          repo.exactRef("refs/heads/" + branch).getObjectId()));
+      rw.markStart(rw.parseCommit(repo.exactRef("refs/heads/" + branch).getObjectId()));
       return Lists.newArrayList(rw);
     }
   }
@@ -595,6 +993,11 @@
     return getRemoteLog(project, "master");
   }
 
+  protected void addOnSubmitValidationListener(OnSubmitValidationListener listener) {
+    assertThat(onSubmitValidatorHandle).isNull();
+    onSubmitValidatorHandle = onSubmitValidationListeners.add(listener);
+  }
+
   private String getLatestDiff(Repository repo) throws Exception {
     ObjectId oldTreeId = repo.resolve("HEAD~1^{tree}");
     ObjectId newTreeId = repo.resolve("HEAD^{tree}");
@@ -610,8 +1013,8 @@
     }
   }
 
-  private String getLatestDiff(Repository repo, ObjectId oldTreeId,
-      ObjectId newTreeId) throws Exception {
+  private String getLatestDiff(Repository repo, ObjectId oldTreeId, ObjectId newTreeId)
+      throws Exception {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
     try (DiffFormatter fmt = new DiffFormatter(out)) {
       fmt.setRepository(repo);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 741864a..0250db1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -18,9 +18,11 @@
 import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -28,11 +30,11 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.Submit.TestSubmitInput;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
 public abstract class AbstractSubmitByMerge extends AbstractSubmit {
@@ -40,14 +42,12 @@
   @Test
   public void submitWithMerge() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
     RevCommit oldHead = getRemoteHead();
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     RevCommit head = getRemoteHead();
     assertThat(head.getParentCount()).isEqualTo(2);
@@ -58,17 +58,14 @@
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
     RevCommit oldHead = getRemoteHead();
     testRepo.reset(change.getCommit());
-    PushOneCommit.Result change3 =
-        createChange("Change 3", "a.txt", "bbb\nccc\n");
+    PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     RevCommit head = getRemoteHead();
     assertThat(head.getParentCount()).isEqualTo(2);
@@ -80,20 +77,21 @@
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge_Conflict() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
     RevCommit oldHead = getRemoteHead();
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "a.txt", "other content");
-    submitWithConflict(change2.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n" +
-        "Change " + change2.getChange().getId() + ": " +
-        "Change could not be merged due to a path conflict. " +
-        "Please rebase the change locally " +
-        "and upload the rebased commit for review.");
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2.getChange().getId()
+            + ": "
+            + "Change could not be merged due to a path conflict. "
+            + "Please rebase the change locally "
+            + "and upload the rebased commit for review.");
     assertThat(getRemoteHead()).isEqualTo(oldHead);
   }
 
@@ -111,12 +109,12 @@
   @TestProjectInput(createEmptyCommit = false)
   public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Exception {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    PushOneCommit.Result change1 = pushFactory.create(
-          db, admin.getIdent(), testRepo, "Change 1", "a", "a")
-        .to("refs/for/master/" + name("topic"));
+    PushOneCommit.Result change1 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "Change 1", "a", "a")
+            .to("refs/for/master/" + name("topic"));
 
-    PushOneCommit push2 = pushFactory.create(
-          db, admin.getIdent(), testRepo, "Change 2", "b", "b");
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo, "Change 2", "b", "b");
     push2.noParents();
     PushOneCommit.Result change2 = push2.to("refs/for/master/" + name("topic"));
     change2.assertOkStatus();
@@ -133,19 +131,19 @@
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
     RevCommit afterChange1Head = getRemoteHead();
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     Change.Id id2 = change2.getChange().getId();
-    SubmitInput failAfterRefUpdates =
-        new TestSubmitInput(new SubmitInput(), true);
-    submit(change2.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates");
+    SubmitInput failAfterRefUpdates = new TestSubmitInput(new SubmitInput(), true);
+    submit(
+        change2.getChangeId(),
+        failAfterRefUpdates,
+        ResourceConflictException.class,
+        "Failing after ref updates");
 
     // Bad: ref advanced but change wasn't updated.
     PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
@@ -176,8 +174,78 @@
         .isEqualTo("Change has been successfully merged by Administrator");
 
     try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(tip);
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(tip);
     }
   }
+
+  @Test
+  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    RevCommit initialHead = getRemoteHead();
+
+    // Create a stable branch and bootstrap it.
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
+    PushOneCommit push =
+        pushFactory.create(db, user.getIdent(), testRepo, "initial commit", "a.txt", "a");
+    PushOneCommit.Result change = push.to("refs/heads/stable");
+
+    RevCommit stable = getRemoteHead(project, "stable");
+    RevCommit master = getRemoteHead(project, "master");
+
+    assertThat(master).isEqualTo(initialHead);
+    assertThat(stable).isEqualTo(change.getCommit());
+
+    testRepo.git().fetch().call();
+    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
+    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
+
+    // Create a fix in stable branch.
+    testRepo.reset(stable);
+    RevCommit fix =
+        testRepo
+            .commit()
+            .parent(stable)
+            .message("small fix")
+            .add("b.txt", "b")
+            .insertChangeId()
+            .create();
+    testRepo.branch("refs/heads/stable").update(fix);
+    testRepo
+        .git()
+        .push()
+        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable/" + name("topic")))
+        .call();
+
+    // Merge the fix into master.
+    testRepo.reset(master);
+    RevCommit merge =
+        testRepo
+            .commit()
+            .parent(master)
+            .parent(fix)
+            .message("Merge stable into master")
+            .insertChangeId()
+            .create();
+    testRepo.branch("refs/heads/master").update(merge);
+    testRepo
+        .git()
+        .push()
+        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master/" + name("topic")))
+        .call();
+
+    // Submit together.
+    String fixId = GitUtil.getChangeId(testRepo, fix).get();
+    String mergeId = GitUtil.getChangeId(testRepo, merge).get();
+    approve(fixId);
+    approve(mergeId);
+    submit(mergeId);
+    assertMerged(fixId);
+    assertMerged(mergeId);
+    testRepo.git().fetch().call();
+    RevWalk rw = testRepo.getRevWalk();
+    master = rw.parseCommit(getRemoteHead(project, "master"));
+    assertThat(rw.isMergedInto(merge, master)).isTrue();
+    assertThat(rw.isMergedInto(fix, master)).isTrue();
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
new file mode 100644
index 0000000..d8aa35c
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -0,0 +1,454 @@
+// Copyright (C) 2013 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.getChangeId;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.Util;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Test;
+
+public abstract class AbstractSubmitByRebase extends AbstractSubmit {
+
+  @Override
+  protected abstract SubmitType getSubmitType();
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithRebase() throws Exception {
+    submitWithRebase(admin);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(cfg, Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
+    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
+    Util.allow(
+        cfg,
+        Permission.forLabel(Util.codeReview().getName()),
+        -2,
+        2,
+        REGISTERED_USERS,
+        "refs/heads/*");
+    saveProjectConfig(project, cfg);
+
+    submitWithRebase(user);
+  }
+
+  private void submitWithRebase(TestAccount submitter) throws Exception {
+    setApiUser(submitter);
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    submit(change2.getChangeId());
+    assertRebase(testRepo, false);
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
+    assertApproved(change2.getChangeId(), submitter);
+    assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
+    assertSubmitter(change2.getChangeId(), 1, submitter);
+    assertSubmitter(change2.getChangeId(), 2, submitter);
+    assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
+    assertPersonEquals(submitter.getIdent(), headAfterSecondSubmit.getCommitterIdent());
+
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitWithRebaseMultipleChanges() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content");
+    submit(change1.getChangeId());
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change1.getChangeId(), 2, headAfterFirstSubmit);
+    } else {
+      assertThat(headAfterFirstSubmit.name()).isEqualTo(change1.getCommit().name());
+    }
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    assertThat(change2.getCommit().getParent(0)).isNotEqualTo(change1.getCommit());
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "third content");
+    PushOneCommit.Result change4 = createChange("Change 4", "d.txt", "fourth content");
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    submit(change4.getChangeId());
+
+    assertRebase(testRepo, false);
+    assertApproved(change2.getChangeId());
+    assertApproved(change3.getChangeId());
+    assertApproved(change4.getChangeId());
+
+    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
+    assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
+    assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
+
+    RevCommit parent = parse(headAfterSecondSubmit.getParent(0));
+    assertThat(parent.getShortMessage()).isEqualTo("Change 3");
+    assertThat(parent).isNotEqualTo(change3.getCommit());
+    assertCurrentRevision(change3.getChangeId(), 2, parent);
+
+    RevCommit grandparent = parse(parent.getParent(0));
+    assertThat(grandparent).isNotEqualTo(change2.getCommit());
+    assertCurrentRevision(change2.getChangeId(), 2, grandparent);
+
+    RevCommit greatgrandparent = parse(grandparent.getParent(0));
+    assertThat(greatgrandparent).isEqualTo(headAfterFirstSubmit);
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change1.getChangeId(), 2, greatgrandparent);
+    } else {
+      assertCurrentRevision(change1.getChangeId(), 1, greatgrandparent);
+    }
+
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change1.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change3.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change4.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  @Test
+  public void submitWithRebaseMergeCommit() throws Exception {
+    /*
+       *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
+       |\
+       | *   Merge branch 'master' into origin/master
+       | |\
+       | | * SHA Added a
+       | |/
+       * | Before
+       |/
+       * Initial empty repository
+    */
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
+
+    PushOneCommit change2Push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Merge to master", "m.txt", "");
+    change2Push.setParents(ImmutableList.of(initialHead, change1.getCommit()));
+    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change3 = createChange("Before", "b.txt", "");
+
+    approve(change3.getChangeId());
+    submit(change3.getChangeId());
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    submit(change2.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    assertThat(newHead.getParentCount()).isEqualTo(2);
+
+    RevCommit headParent1 = parse(newHead.getParent(0).getId());
+    RevCommit headParent2 = parse(newHead.getParent(1).getId());
+
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change3.getChangeId(), 2, headParent1.getId());
+    } else {
+      assertThat(change3.getCommit().getId()).isEqualTo(headParent1.getId());
+    }
+    assertThat(headParent1.getParentCount()).isEqualTo(1);
+    assertThat(headParent1.getParent(0)).isEqualTo(initialHead);
+
+    assertThat(headParent2.getId()).isEqualTo(change2.getCommit().getId());
+    assertThat(headParent2.getParentCount()).isEqualTo(2);
+
+    RevCommit headGrandparent1 = parse(headParent2.getParent(0).getId());
+    RevCommit headGrandparent2 = parse(headParent2.getParent(1).getId());
+
+    assertThat(headGrandparent1.getId()).isEqualTo(initialHead.getId());
+    assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithContentMerge_Conflict() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    submitWithConflict(
+        change2.getChangeId(),
+        "Cannot rebase "
+            + change2.getCommit().name()
+            + ": The change could not be rebased due to a conflict during merge.");
+    RevCommit head = getRemoteHead();
+    assertThat(head).isEqualTo(headAfterFirstSubmit);
+    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
+    assertNoSubmitter(change2.getChangeId(), 1);
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
+  }
+
+  @Test
+  public void repairChangeStateAfterFailure() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
+    submit(change.getChangeId());
+
+    RevCommit headAfterFirstSubmit = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
+    Change.Id id2 = change2.getChange().getId();
+    SubmitInput failAfterRefUpdates = new TestSubmitInput(new SubmitInput(), true);
+    submit(
+        change2.getChangeId(),
+        failAfterRefUpdates,
+        ResourceConflictException.class,
+        "Failing after ref updates");
+    RevCommit headAfterFailedSubmit = getRemoteHead();
+
+    // Bad: ref advanced but change wasn't updated.
+    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
+    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
+    ChangeInfo info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
+    assertThat(getPatchSet(psId2)).isNull();
+
+    ObjectId rev2;
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
+      assertThat(rev1).isNotNull();
+
+      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
+      assertThat(rev2).isNotNull();
+      assertThat(rev2).isNotEqualTo(rev1);
+      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
+
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
+    }
+
+    submit(change2.getChangeId());
+    RevCommit headAfterSecondSubmit = getRemoteHead();
+    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
+
+    // Change status and patch set entities were updated, and branch tip stayed
+    // the same.
+    info = gApi.changes().id(id2.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
+    PatchSet ps2 = getPatchSet(psId2);
+    assertThat(ps2).isNotNull();
+    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo(
+            "Change has been successfully rebased and submitted as "
+                + rev2.name()
+                + " by Administrator");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
+    }
+
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name());
+  }
+
+  protected RevCommit parse(ObjectId id) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = rw.parseCommit(id);
+      rw.parseBody(c);
+      return c;
+    }
+  }
+
+  @Test
+  public void submitAfterReorderOfCommits() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    // Create two commits and push.
+    RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    String id1 = getChangeId(testRepo, c1).get();
+    String id2 = getChangeId(testRepo, c2).get();
+
+    // Swap the order of commits and push again.
+    testRepo.reset("HEAD~2");
+    testRepo.cherryPick(c2);
+    testRepo.cherryPick(c1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    approve(id1);
+    approve(id2);
+    submit(id1);
+    RevCommit headAfterSubmit = getRemoteHead();
+
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(id2, headAfterSubmit.name(), id1, headAfterSubmit.name());
+  }
+
+  @Test
+  public void submitChangesAfterBranchOnSecond() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+
+    PushOneCommit.Result change2 = createChange();
+    approve(change2.getChangeId());
+    Project.NameKey project = change2.getChange().change().getProject();
+    Branch.NameKey branch = new Branch.NameKey(project, "branch");
+    createBranchWithRevision(branch, change2.getCommit().getName());
+    gApi.changes().id(change2.getChangeId()).current().submit();
+    assertMerged(change2.getChangeId());
+    assertMerged(change.getChangeId());
+
+    RevCommit newHead = getRemoteHead();
+    assertRefUpdatedEvents(initialHead, newHead);
+    assertChangeMergedEvents(
+        change.getChangeId(), newHead.name(), change2.getChangeId(), newHead.name());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitFastForwardIdenticalTree() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "a");
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "a");
+
+    assertThat(change1.getCommit().getTree()).isEqualTo(change2.getCommit().getTree());
+
+    // for rebase if necessary, otherwise, the manual rebase of change2 will
+    // fail since change1 would be merged as fast forward
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change0 = createChange("Change 0", "b.txt", "b");
+    submit(change0.getChangeId());
+    RevCommit headAfterChange0 = getRemoteHead();
+    assertThat(headAfterChange0.getShortMessage()).isEqualTo("Change 0");
+
+    submit(change1.getChangeId());
+    RevCommit headAfterChange1 = getRemoteHead();
+    assertThat(headAfterChange1.getShortMessage()).isEqualTo("Change 1");
+    assertThat(headAfterChange0).isEqualTo(headAfterChange1.getParent(0));
+
+    // Do manual rebase first.
+    gApi.changes().id(change2.getChangeId()).current().rebase();
+    submit(change2.getChangeId());
+    RevCommit headAfterChange2 = getRemoteHead();
+    assertThat(headAfterChange2.getShortMessage()).isEqualTo("Change 2");
+    assertThat(headAfterChange1).isEqualTo(headAfterChange2.getParent(0));
+
+    ChangeInfo info2 = get(change2.getChangeId());
+    assertThat(info2.status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitChainOneByOne() throws Exception {
+    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
+    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
+    submit(change1.getChangeId());
+    submit(change2.getChangeId());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitChainFailsOnRework() throws Exception {
+    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
+    RevCommit headAfterChange1 = change1.getCommit();
+    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
+    testRepo.reset(headAfterChange1);
+    change1 =
+        amendChange(change1.getChangeId(), "subject 1 amend", "fileName 2", "rework content 2");
+    submit(change1.getChangeId());
+    headAfterChange1 = getRemoteHead();
+
+    submitWithConflict(
+        change2.getChangeId(),
+        "Cannot rebase "
+            + change2.getCommit().getName()
+            + ": "
+            + "The change could not be rebased due to a conflict during merge.");
+    assertThat(getRemoteHead()).isEqualTo(headAfterChange1);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitChainOneByOneManualRebase() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result change1 = createChange("subject 1", "fileName 1", "content 1");
+    PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
+
+    // for rebase if necessary, otherwise, the manual rebase of change2 will
+    // fail since change1 would be merged as fast forward
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+
+    submit(change1.getChangeId());
+    // Do manual rebase first.
+    gApi.changes().id(change2.getChangeId()).current().rebase();
+    submit(change2.getChangeId());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index 880fe89..87436e7 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -15,41 +15,71 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.server.change.GetRevisionActions;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.inject.Inject;
-
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
-import java.util.Map;
-
 public class ActionsIT extends AbstractDaemonTest {
   @ConfigSuite.Config
   public static Config submitWholeTopicEnabled() {
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject
-  private GetRevisionActions getRevisionActions;
+  @Inject private ChangeJson.Factory changeJsonFactory;
+
+  @Inject private DynamicSet<ActionVisitor> actionVisitors;
+
+  private RegistrationHandle visitorHandle;
+
+  @Before
+  public void setUp() {
+    visitorHandle = null;
+  }
+
+  @After
+  public void tearDown() {
+    if (visitorHandle != null) {
+      visitorHandle.remove();
+    }
+  }
 
   @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     Map<String, ActionInfo> actions = getActions(changeId);
+    assertThat(actions).hasSize(3);
     assertThat(actions).containsKey("cherrypick");
     assertThat(actions).containsKey("rebase");
-    assertThat(actions).hasSize(2);
+    assertThat(actions).containsKey("description");
   }
 
   @Test
@@ -75,8 +105,7 @@
       assertThat(info.enabled).isNull();
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).isEqualTo("This change depends on other " +
-          "changes which are not ready");
+      assertThat(info.title).isEqualTo("This change depends on other changes which are not ready");
     } else {
       noSubmitWholeTopicAssertions(actions, 1);
 
@@ -91,16 +120,16 @@
     String parent = createChange().getChangeId();
     String change = createChangeWithTopic().getChangeId();
     approve(change);
-    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag1 = getETag(change);
 
     approve(parent);
-    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag2 = getETag(change);
 
     String changeWithSameTopic = createChangeWithTopic().getChangeId();
-    String etag3 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag3 = getETag(change);
 
     approve(changeWithSameTopic);
-    String etag4 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag4 = getETag(change);
 
     if (isSubmitWholeTopicEnabled()) {
       assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
@@ -117,14 +146,14 @@
     approve(change);
 
     setApiUser(user);
-    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag1 = getETag(change);
 
     setApiUser(admin);
     String draft = createDraftWithTopic().getChangeId();
     approve(draft);
 
     setApiUser(user);
-    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag2 = getETag(change);
 
     if (isSubmitWholeTopicEnabled()) {
       assertThat(etag2).isNotEqualTo(etag1);
@@ -140,25 +169,25 @@
     approve(change);
 
     setApiUserAnonymous();
-    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag1 = getETag(change);
 
     setApiUser(admin);
     approve(parent);
 
     setApiUserAnonymous();
-    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag2 = getETag(change);
 
     setApiUser(admin);
     String changeWithSameTopic = createChangeWithTopic().getChangeId();
 
     setApiUserAnonymous();
-    String etag3 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag3 = getETag(change);
 
     setApiUser(admin);
     approve(changeWithSameTopic);
 
     setApiUserAnonymous();
-    String etag4 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag4 = getETag(change);
 
     if (isSubmitWholeTopicEnabled()) {
       assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
@@ -177,13 +206,13 @@
     approve(change);
 
     setApiUserAnonymous();
-    String etag1 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag1 = getETag(change);
 
     setApiUser(admin);
     approve(parent);
 
     setApiUserAnonymous();
-    String etag2 = getRevisionActions.getETag(parseCurrentRevisionResource(change));
+    String etag2 = getETag(change);
     assertThat(etag2).isEqualTo(etag1);
   }
 
@@ -193,14 +222,17 @@
     approve(changeId);
 
     // create another change with the same topic
-    String changeId2 = createChangeWithTopic(testRepo, "foo2", "touching b",
-        "b.txt", "real content").getChangeId();
+    String changeId2 =
+        createChangeWithTopic(testRepo, "foo2", "touching b", "b.txt", "real content")
+            .getChangeId();
     approve(changeId2);
 
     // collide with the other change in the same topic
     testRepo.reset("HEAD~2");
-    String collidingChange = createChangeWithTopic(testRepo, "off_topic",
-        "rewriting file b", "b.txt", "garbage\ngarbage\ngarbage").getChangeId();
+    String collidingChange =
+        createChangeWithTopic(
+                testRepo, "off_topic", "rewriting file b", "b.txt", "garbage\ngarbage\ngarbage")
+            .getChangeId();
     gApi.changes().id(collidingChange).current().review(ReviewInput.approve());
     gApi.changes().id(collidingChange).current().submit();
 
@@ -218,8 +250,7 @@
   }
 
   @Test
-  public void revisionActionsTwoChangesInTopicWithAncestorReady()
-      throws Exception {
+  public void revisionActionsTwoChangesInTopicWithAncestorReady() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
     approve(changeId);
@@ -235,9 +266,11 @@
       assertThat(info.enabled).isTrue();
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).isEqualTo("Submit all 2 changes of the same " +
-          "topic (3 changes including ancestors " +
-          "and other changes related by topic)");
+      assertThat(info.title)
+          .isEqualTo(
+              "Submit all 2 changes of the same "
+                  + "topic (3 changes including ancestors "
+                  + "and other changes related by topic)");
     } else {
       noSubmitWholeTopicAssertions(actions, 2);
     }
@@ -258,8 +291,7 @@
     noSubmitWholeTopicAssertions(actions, 3);
   }
 
-  private void noSubmitWholeTopicAssertions(Map<String, ActionInfo> actions,
-      int nrChanges) {
+  private void noSubmitWholeTopicAssertions(Map<String, ActionInfo> actions, int nrChanges) {
     ActionInfo info = actions.get("submit");
     assertThat(info.enabled).isTrue();
     if (nrChanges == 1) {
@@ -271,44 +303,161 @@
     if (nrChanges == 1) {
       assertThat(info.title).isEqualTo("Submit patch set 1 into master");
     } else {
-      assertThat(info.title).isEqualTo(String.format(
-          "Submit patch set 1 and ancestors (%d changes " +
-          "altogether) into master", nrChanges));
+      assertThat(info.title)
+          .isEqualTo(
+              String.format(
+                  "Submit patch set 1 and ancestors (%d changes altogether) into master",
+                  nrChanges));
     }
   }
 
+  @Test
+  public void changeActionVisitor() throws Exception {
+    String id = createChange().getChangeId();
+    ChangeInfo origChange = gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS));
+
+    class Visitor implements ActionVisitor {
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
+        assertThat(changeInfo).isNotNull();
+        assertThat(changeInfo._number).isEqualTo(origChange._number);
+        if (name.equals("followup")) {
+          return false;
+        }
+        if (name.equals("abandon")) {
+          actionInfo.label = "Abandon All Hope";
+        }
+        return true;
+      }
+
+      @Override
+      public boolean visit(
+          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+        throw new UnsupportedOperationException();
+      }
+    }
+
+    Map<String, ActionInfo> origActions = origChange.actions;
+    assertThat(origActions.keySet()).containsAllOf("followup", "abandon");
+    assertThat(origActions.get("abandon").label).isEqualTo("Abandon");
+
+    Visitor v = new Visitor();
+    visitorHandle = actionVisitors.add(v);
+
+    Map<String, ActionInfo> newActions =
+        gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS)).actions;
+
+    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
+    expectedNames.remove("followup");
+    assertThat(newActions.keySet()).isEqualTo(expectedNames);
+
+    ActionInfo abandon = newActions.get("abandon");
+    assertThat(abandon).isNotNull();
+    assertThat(abandon.label).isEqualTo("Abandon All Hope");
+  }
+
+  @Test
+  public void revisionActionVisitor() throws Exception {
+    String id = createChange().getChangeId();
+    ChangeInfo origChange = gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS));
+
+    class Visitor implements ActionVisitor {
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
+        return true; // Do nothing; implicitly called for CURRENT_ACTIONS.
+      }
+
+      @Override
+      public boolean visit(
+          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+        assertThat(changeInfo).isNotNull();
+        assertThat(changeInfo._number).isEqualTo(origChange._number);
+        assertThat(revisionInfo).isNotNull();
+        assertThat(revisionInfo._number).isEqualTo(1);
+        if (name.equals("cherrypick")) {
+          return false;
+        }
+        if (name.equals("rebase")) {
+          actionInfo.label = "All Your Base";
+        }
+        return true;
+      }
+    }
+
+    Map<String, ActionInfo> origActions = gApi.changes().id(id).current().actions();
+    assertThat(origActions.keySet()).containsAllOf("cherrypick", "rebase");
+    assertThat(origActions.get("rebase").label).isEqualTo("Rebase");
+
+    Visitor v = new Visitor();
+    visitorHandle = actionVisitors.add(v);
+
+    // Test different codepaths within ActionJson...
+    // ...via revision API.
+    visitedRevisionActionsAssertions(origActions, gApi.changes().id(id).current().actions());
+
+    // ...via change API with option.
+    EnumSet<ListChangesOption> opts = EnumSet.of(CURRENT_ACTIONS, CURRENT_REVISION);
+    ChangeInfo changeInfo = gApi.changes().id(id).get(opts);
+    RevisionInfo revisionInfo = Iterables.getOnlyElement(changeInfo.revisions.values());
+    visitedRevisionActionsAssertions(origActions, revisionInfo.actions);
+
+    // ...via ChangeJson directly.
+    ChangeData cd = changeDataFactory.create(db, project, new Change.Id(origChange._number));
+    revisionInfo =
+        changeJsonFactory
+            .create(opts)
+            .getRevisionInfo(cd.changeControl(), Iterables.getOnlyElement(cd.patchSets()));
+    visitedRevisionActionsAssertions(origActions, revisionInfo.actions);
+  }
+
+  private void visitedRevisionActionsAssertions(
+      Map<String, ActionInfo> origActions, Map<String, ActionInfo> newActions) {
+    assertThat(newActions).isNotNull();
+    Set<String> expectedNames = new TreeSet<>(origActions.keySet());
+    expectedNames.remove("cherrypick");
+    assertThat(newActions.keySet()).isEqualTo(expectedNames);
+
+    ActionInfo rebase = newActions.get("rebase");
+    assertThat(rebase).isNotNull();
+    assertThat(rebase.label).isEqualTo("All Your Base");
+  }
+
   private void commonActionsAssertions(Map<String, ActionInfo> actions) {
-    assertThat(actions).hasSize(3);
+    assertThat(actions).hasSize(4);
     assertThat(actions).containsKey("cherrypick");
     assertThat(actions).containsKey("submit");
+    assertThat(actions).containsKey("description");
     assertThat(actions).containsKey("rebase");
   }
 
   private PushOneCommit.Result createCommitAndPush(
-      TestRepository<InMemoryRepository> repo, String ref,
-      String commitMsg, String fileName, String content) throws Exception {
-    return pushFactory
-        .create(db, admin.getIdent(), repo, commitMsg, fileName, content)
-        .to(ref);
+      TestRepository<InMemoryRepository> repo,
+      String ref,
+      String commitMsg,
+      String fileName,
+      String content)
+      throws Exception {
+    return pushFactory.create(db, admin.getIdent(), repo, commitMsg, fileName, content).to(ref);
   }
 
   private PushOneCommit.Result createChangeWithTopic(
-      TestRepository<InMemoryRepository> repo, String topic,
-      String commitMsg, String fileName, String content) throws Exception {
+      TestRepository<InMemoryRepository> repo,
+      String topic,
+      String commitMsg,
+      String fileName,
+      String content)
+      throws Exception {
     assertThat(topic).isNotEmpty();
-    return createCommitAndPush(repo, "refs/for/master/" + name(topic),
-        commitMsg, fileName, content);
+    return createCommitAndPush(
+        repo, "refs/for/master/" + name(topic), commitMsg, fileName, content);
   }
 
-  private PushOneCommit.Result createChangeWithTopic()
-      throws Exception {
-    return createChangeWithTopic(testRepo, "foo2",
-        "a message", "a.txt", "content\n");
+  private PushOneCommit.Result createChangeWithTopic() throws Exception {
+    return createChangeWithTopic(testRepo, "foo2", "a message", "a.txt", "content\n");
   }
 
-  private PushOneCommit.Result createDraftWithTopic()
-      throws Exception {
-    return createCommitAndPush(testRepo, "refs/drafts/master/" + name("foo2"),
-        "a message", "a.txt", "content\n");
+  private PushOneCommit.Result createDraftWithTopic() throws Exception {
+    return createCommitAndPush(
+        testRepo, "refs/drafts/master/" + name("foo2"), "a message", "a.txt", "content\n");
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
new file mode 100644
index 0000000..35ba1a2
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -0,0 +1,149 @@
+// 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import com.google.gerrit.testutil.TestTimeUtil;
+import java.util.Iterator;
+import java.util.List;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+@NoHttpd
+public class AssigneeIT extends AbstractDaemonTest {
+
+  @BeforeClass
+  public static void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @AfterClass
+  public static void restoreTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void getNoAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(getAssignee(r)).isNull();
+  }
+
+  @Test
+  public void addGetAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    assertThat(getAssignee(r)._accountId).isEqualTo(user.getId().get());
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+  }
+
+  @Test
+  public void setNewAssigneeWhenExists() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void getPastAssignees() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    setAssignee(r, admin.email);
+    List<AccountInfo> assignees = getPastAssignees(r);
+    assertThat(assignees).hasSize(2);
+    Iterator<AccountInfo> itr = assignees.iterator();
+    assertThat(itr.next()._accountId).isEqualTo(user.getId().get());
+    assertThat(itr.next()._accountId).isEqualTo(admin.getId().get());
+  }
+
+  @Test
+  public void assigneeAddedAsReviewer() throws Exception {
+    ReviewerState state;
+    // Assignee is added as CC, if back-end is reviewDb (that does not support
+    // CC) CC is stored as REVIEWER
+    if (notesMigration.readChanges()) {
+      state = ReviewerState.CC;
+    } else {
+      state = ReviewerState.REVIEWER;
+    }
+    PushOneCommit.Result r = createChange();
+    Iterable<AccountInfo> reviewers = getReviewers(r, state);
+    assertThat(reviewers).isNull();
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    reviewers = getReviewers(r, state);
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getFirst(reviewers, null);
+    assertThat(reviewer._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void setAlreadyExistingAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setAssignee(r, user.email);
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void deleteAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+    assertThat(deleteAssignee(r)._accountId).isEqualTo(user.getId().get());
+    assertThat(getAssignee(r)).isNull();
+  }
+
+  @Test
+  public void deleteAssigneeWhenNoAssignee() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(deleteAssignee(r)).isNull();
+  }
+
+  private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).getAssignee();
+  }
+
+  private List<AccountInfo> getPastAssignees(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).getPastAssignees();
+  }
+
+  private Iterable<AccountInfo> getReviewers(PushOneCommit.Result r, ReviewerState state)
+      throws Exception {
+    return get(r.getChangeId()).reviewers.get(state);
+  }
+
+  private AccountInfo setAssignee(PushOneCommit.Result r, String identifieer) throws Exception {
+    AssigneeInput input = new AssigneeInput();
+    input.assignee = identifieer;
+    return gApi.changes().id(r.getChange().getId().get()).setAssignee(input);
+  }
+
+  private AccountInfo deleteAssignee(PushOneCommit.Result r) throws Exception {
+    return gApi.changes().id(r.getChange().getId().get()).deleteAssignee();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
deleted file mode 100644
index 04e71eb..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
+++ /dev/null
@@ -1,38 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-SUBMIT_UTIL_SRCS = [
-  'AbstractSubmit.java',
-  'AbstractSubmitByMerge.java',
-]
-
-SUBMIT_TESTS = glob(['Submit*IT.java'])
-OTHER_TESTS = glob(['*IT.java'], excludes = SUBMIT_TESTS)
-
-acceptance_tests(
-  group = 'rest_change_other',
-  srcs = OTHER_TESTS,
-  deps = [
-    ':submit_util',
-    '//gerrit-server:server',
-    '//lib/guice:guice',
-    '//lib/joda:joda-time',
-  ],
-  labels = ['rest'],
-)
-
-acceptance_tests(
-  group = 'rest_change_submit',
-  srcs = SUBMIT_TESTS,
-  deps = [
-    ':submit_util',
-  ],
-  labels = ['rest'],
-)
-
-java_library(
-  name = 'submit_util',
-  srcs = SUBMIT_UTIL_SRCS,
-  deps = [
-    '//gerrit-acceptance-tests:lib',
-  ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
index c06f02f..c2a9d2c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUILD
@@ -1,36 +1,39 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
-SUBMIT_UTIL_SRCS = [
-  'AbstractSubmit.java',
-  'AbstractSubmitByMerge.java',
-]
+SUBMIT_UTIL_SRCS = glob(["AbstractSubmit*.java"])
 
-SUBMIT_TESTS = glob(['Submit*IT.java'])
-OTHER_TESTS = glob(['*IT.java'], exclude = SUBMIT_TESTS)
+SUBMIT_TESTS = glob(["Submit*IT.java"])
 
-acceptance_tests(
-  group = 'rest_change_other',
-  srcs = OTHER_TESTS,
-  deps = [
-    ':submit_util',
-    '//lib/joda:joda-time',
-  ],
-  labels = ['rest'],
+OTHER_TESTS = glob(
+    ["*IT.java"],
+    exclude = SUBMIT_TESTS,
 )
 
 acceptance_tests(
-  group = 'rest_change_submit',
-  srcs = SUBMIT_TESTS,
-  deps = [
-    ':submit_util',
-  ],
-  labels = ['rest'],
+    srcs = OTHER_TESTS,
+    group = "rest_change_other",
+    labels = ["rest"],
+    deps = [
+        ":submit_util",
+        "//lib/joda:joda-time",
+    ],
+)
+
+acceptance_tests(
+    srcs = SUBMIT_TESTS,
+    group = "rest_change_submit",
+    labels = ["rest"],
+    deps = [
+        ":submit_util",
+    ],
 )
 
 java_library(
-  name = 'submit_util',
-  srcs = SUBMIT_UTIL_SRCS,
-  deps = [
-    '//gerrit-acceptance-tests:lib',
-  ],
+    name = "submit_util",
+    testonly = 1,
+    srcs = SUBMIT_UTIL_SRCS,
+    deps = [
+        "//gerrit-acceptance-tests:lib",
+    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
new file mode 100644
index 0000000..fbd55bb
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.reviewdb.client.Branch;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeIncludedInIT extends AbstractDaemonTest {
+
+  @Test
+  public void includedInOpenChange() throws Exception {
+    Result result = createChange();
+    assertThat(gApi.changes().id(result.getChangeId()).includedIn().branches).isEmpty();
+    assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags).isEmpty();
+  }
+
+  @Test
+  public void includedInMergedChange() throws Exception {
+    Result result = createChange();
+    gApi.changes()
+        .id(result.getChangeId())
+        .revision(result.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+
+    assertThat(gApi.changes().id(result.getChangeId()).includedIn().branches)
+        .containsExactly("master");
+    assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags).isEmpty();
+
+    grantTagPermissions();
+    gApi.projects().name(project.get()).tag("test-tag").create(new TagInput());
+
+    assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags)
+        .containsExactly("test-tag");
+
+    createBranch(new Branch.NameKey(project.get(), "test-branch"));
+
+    assertThat(gApi.changes().id(result.getChangeId()).includedIn().branches)
+        .containsExactly("master", "test-branch");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 40b0391..4c49e4c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -23,14 +23,12 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestTimeUtil;
-
+import java.util.Iterator;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-import java.util.Iterator;
-
 @RunWith(ConfigSuite.class)
 public class ChangeMessagesIT extends AbstractDaemonTest {
   private String systemTimeZone;
@@ -61,8 +59,7 @@
     ChangeInfo c = get(changeId);
     assertThat(c.messages).isNotNull();
     assertThat(c.messages).hasSize(1);
-    assertThat(c.messages.iterator().next().message)
-      .isEqualTo("Uploaded patch set 1.");
+    assertThat(c.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 875725f..6a00d59 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -14,22 +14,18 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.gerrit.common.data.Permission.LABEL;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.group.SystemGroupBackend;
-
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -46,21 +42,68 @@
   @Test
   @TestProjectInput(cloneAs = "user")
   public void testChangeOwner_OwnerACLNotGranted() throws Exception {
-    assertApproveFails(user, createMyChange());
+    assertApproveFails(user, createMyChange(testRepo));
   }
 
   @Test
   @TestProjectInput(cloneAs = "user")
   public void testChangeOwner_OwnerACLGranted() throws Exception {
-    grantApproveToChangeOwner();
-    approve(user, createMyChange());
+    grantApproveToChangeOwner(project);
+    approve(user, createMyChange(testRepo));
   }
 
   @Test
   @TestProjectInput(cloneAs = "user")
   public void testChangeOwner_NotOwnerACLGranted() throws Exception {
-    grantApproveToChangeOwner();
-    assertApproveFails(user2, createMyChange());
+    grantApproveToChangeOwner(project);
+    assertApproveFails(user2, createMyChange(testRepo));
+  }
+
+  @Test
+  public void testChangeOwner_OwnerACLGrantedOnParentProject() throws Exception {
+    setApiUser(admin);
+    grantApproveToChangeOwner(project);
+    Project.NameKey child = createProject("child", project);
+
+    setApiUser(user);
+    TestRepository<InMemoryRepository> childRepo = cloneProject(child, user);
+    approve(user, createMyChange(childRepo));
+  }
+
+  @Test
+  public void testChangeOwner_BlockedOnParentProject() throws Exception {
+    setApiUser(admin);
+    blockApproveForChangeOwner(project);
+    Project.NameKey child = createProject("child", project);
+
+    setApiUser(user);
+    grantApproveToAll(child);
+    TestRepository<InMemoryRepository> childRepo = cloneProject(child, user);
+    String changeId = createMyChange(childRepo);
+
+    // change owner cannot approve because Change-Owner group is blocked on parent
+    assertApproveFails(user, changeId);
+
+    // other user can approve
+    approve(user2, changeId);
+  }
+
+  @Test
+  public void testChangeOwner_BlockedOnParentProjectAndExclusiveAllowOnChild() throws Exception {
+    setApiUser(admin);
+    blockApproveForChangeOwner(project);
+    Project.NameKey child = createProject("child", project);
+
+    setApiUser(user);
+    grantExclusiveApproveToAll(child);
+    TestRepository<InMemoryRepository> childRepo = cloneProject(child, user);
+    String changeId = createMyChange(childRepo);
+
+    // change owner cannot approve because Change-Owner group is blocked on parent
+    assertApproveFails(user, changeId);
+
+    // other user can approve
+    approve(user2, changeId);
   }
 
   private void approve(TestAccount a, String changeId) throws Exception {
@@ -77,23 +120,28 @@
     approve(a, changeId);
   }
 
-  private void grantApproveToChangeOwner() throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
-      md.setMessage(String.format("Grant approve to change owner"));
-      ProjectConfig config = ProjectConfig.read(md);
-      AccessSection s = config.getAccessSection("refs/heads/*", true);
-      Permission p = s.getPermission(LABEL + "Code-Review", true);
-      PermissionRule rule = new PermissionRule(config
-          .resolve(SystemGroupBackend.getGroup(SystemGroupBackend.CHANGE_OWNER)));
-      rule.setMin(-2);
-      rule.setMax(+2);
-      p.add(rule);
-      config.commit(md);
-      projectCache.evict(config.getProject());
-    }
+  private void grantApproveToChangeOwner(Project.NameKey project) throws Exception {
+    grantApprove(project, SystemGroupBackend.CHANGE_OWNER, false);
   }
 
-  private String createMyChange() throws Exception {
+  private void grantApproveToAll(Project.NameKey project) throws Exception {
+    grantApprove(project, SystemGroupBackend.REGISTERED_USERS, false);
+  }
+
+  private void grantExclusiveApproveToAll(Project.NameKey project) throws Exception {
+    grantApprove(project, SystemGroupBackend.REGISTERED_USERS, true);
+  }
+
+  private void grantApprove(Project.NameKey project, AccountGroup.UUID groupUUID, boolean exclusive)
+      throws Exception {
+    grantLabel("Code-Review", -2, 2, project, "refs/heads/*", false, groupUUID, exclusive);
+  }
+
+  private void blockApproveForChangeOwner(Project.NameKey project) throws Exception {
+    blockLabel("Code-Review", -2, 2, SystemGroupBackend.CHANGE_OWNER, "refs/heads/*", project);
+  }
+
+  private String createMyChange(TestRepository<InMemoryRepository> testRepo) throws Exception {
     PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
     return push.to("refs/for/master").getChangeId();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index a42f5cd..66966c3 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -32,19 +32,21 @@
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
-
-import org.junit.Test;
-
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
+import org.junit.Test;
 
 public class ChangeReviewersIT extends AbstractDaemonTest {
   @Test
@@ -56,18 +58,18 @@
 
     int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
     int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
-    List<TestAccount> users =
-        createAccounts(largeGroupSize, "addGroupAsReviewer");
+    List<TestAccount> users = createAccounts(largeGroupSize, "addGroupAsReviewer");
     List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
     for (TestAccount u : users) {
       largeGroupUsernames.add(u.username);
     }
-    List<String> mediumGroupUsernames =
-        largeGroupUsernames.subList(0, mediumGroupSize);
-    gApi.groups().id(largeGroup).addMembers(
-        largeGroupUsernames.toArray(new String[largeGroupSize]));
-    gApi.groups().id(mediumGroup).addMembers(
-        mediumGroupUsernames.toArray(new String[mediumGroupSize]));
+    List<String> mediumGroupUsernames = largeGroupUsernames.subList(0, mediumGroupSize);
+    gApi.groups()
+        .id(largeGroup)
+        .addMembers(largeGroupUsernames.toArray(new String[largeGroupSize]));
+    gApi.groups()
+        .id(mediumGroup)
+        .addMembers(mediumGroupUsernames.toArray(new String[mediumGroupSize]));
 
     // Attempt to add overly large group as reviewers.
     PushOneCommit.Result r = createChange();
@@ -75,8 +77,7 @@
     AddReviewerResult result = addReviewer(changeId, largeGroup);
     assertThat(result.input).isEqualTo(largeGroup);
     assertThat(result.confirm).isNull();
-    assertThat(result.error)
-        .contains("has too many members to add them all as reviewers");
+    assertThat(result.error).contains("has too many members to add them all as reviewers");
     assertThat(result.reviewers).isNull();
 
     // Attempt to add medium group without confirmation.
@@ -84,8 +85,7 @@
     assertThat(result.input).isEqualTo(mediumGroup);
     assertThat(result.confirm).isTrue();
     assertThat(result.error)
-        .contains("has " + mediumGroupSize + " members. Do you want to add them"
-            + " all as reviewers?");
+        .contains("has " + mediumGroupSize + " members. Do you want to add them all as reviewers?");
     assertThat(result.reviewers).isNull();
 
     // Add medium group with confirmation.
@@ -100,8 +100,7 @@
 
     // Verify that group members were added as reviewers.
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, notesMigration.readChanges() ? REVIEWER : CC,
-        users.subList(0, mediumGroupSize));
+    assertReviewers(c, REVIEWER, users.subList(0, mediumGroupSize));
   }
 
   @Test
@@ -128,7 +127,7 @@
       assertThat(result.reviewers).hasSize(1);
       AccountInfo ai = result.reviewers.get(0);
       assertThat(ai._accountId).isEqualTo(user.id.get());
-      assertReviewers(c, CC, user);
+      assertReviewers(c, REVIEWER, user);
     }
 
     // Verify email was sent to CCed account.
@@ -137,8 +136,7 @@
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.emailAddress);
     if (notesMigration.readChanges()) {
-      assertThat(m.body())
-          .contains(admin.fullName + " has uploaded a new change for review.");
+      assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review.");
     } else {
       assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
       assertThat(m.body()).contains("I'd like you to do a code review.");
@@ -161,7 +159,8 @@
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = createGroup("cc1");
     in.state = CC;
-    gApi.groups().id(in.reviewer)
+    gApi.groups()
+        .id(in.reviewer)
         .addMembers(firstUsernames.toArray(new String[firstUsernames.size()]));
     AddReviewerResult result = addReviewer(changeId, in);
 
@@ -174,7 +173,12 @@
       assertThat(result.ccs).isNull();
     }
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, CC, firstUsers);
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, CC, firstUsers);
+    } else {
+      assertReviewers(c, REVIEWER, firstUsers);
+      assertReviewers(c, CC);
+    }
 
     // Verify emails were sent to each of the group's accounts.
     List<Message> messages = sender.getMessages();
@@ -187,14 +191,13 @@
     assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
 
     // CC a group that overlaps with some existing reviewers and CCed accounts.
-    TestAccount reviewer = accounts.create(name("reviewer"),
-        "addCcGroup-reviewer@example.com", "Reviewer");
+    TestAccount reviewer =
+        accounts.create(name("reviewer"), "addCcGroup-reviewer@example.com", "Reviewer");
     result = addReviewer(changeId, reviewer.username);
     assertThat(result.error).isNull();
     sender.clear();
     in.reviewer = createGroup("cc2");
-    gApi.groups().id(in.reviewer)
-        .addMembers(usernames.toArray(new String[usernames.size()]));
+    gApi.groups().id(in.reviewer).addMembers(usernames.toArray(new String[usernames.size()]));
     gApi.groups().id(in.reviewer).addMembers(reviewer.username);
     result = addReviewer(changeId, in);
     assertThat(result.input).isEqualTo(in.reviewer);
@@ -212,7 +215,7 @@
       List<TestAccount> expectedUsers = new ArrayList<>(users.size() + 2);
       expectedUsers.addAll(users);
       expectedUsers.add(reviewer);
-      assertReviewers(c, CC, expectedUsers);
+      assertReviewers(c, REVIEWER, expectedUsers);
     }
 
     messages = sender.getMessages();
@@ -222,9 +225,12 @@
     for (int i = 0; i < 3; i++) {
       expectedAddresses.add(users.get(users.size() - i - 1).emailAddress);
     }
-    if (notesMigration.readChanges()) {
-      expectedAddresses.add(reviewer.emailAddress);
+    if (!notesMigration.readChanges()) {
+      for (int i = 0; i < 3; i++) {
+        expectedAddresses.add(users.get(i).emailAddress);
+      }
     }
+    expectedAddresses.add(reviewer.emailAddress);
     assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
   }
 
@@ -237,29 +243,185 @@
     in.state = CC;
     addReviewer(changeId, in);
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
-    assertReviewers(c, REVIEWER);
-    assertReviewers(c, CC, user);
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER);
+      assertReviewers(c, CC, user);
+    } else {
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
+    }
 
     in.state = REVIEWER;
     addReviewer(changeId, in);
     c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, user);
+    assertReviewers(c, CC);
+  }
+
+  @Test
+  public void driveByComment() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // Post drive-by message as user.
+    ReviewInput input = new ReviewInput().message("hello");
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
+            input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNull();
+
+    // Verify user is added to CC list.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     if (notesMigration.readChanges()) {
-      assertReviewers(c, REVIEWER, user);
-      assertReviewers(c, CC);
-    } else {
-      // If NoteDb not enabled, should have had no effect.
       assertReviewers(c, REVIEWER);
       assertReviewers(c, CC, user);
+    } else {
+      // If we aren't reading from NoteDb, the user will appear as a
+      // reviewer.
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
     }
   }
 
   @Test
+  public void addSelfAsReviewer() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // user adds self as REVIEWER.
+    ReviewInput input = new ReviewInput().reviewer(user.username);
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
+            input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, user);
+    assertReviewers(c, CC);
+    LabelInfo label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(1);
+    ApprovalInfo approval = label.all.get(0);
+    assertThat(approval._accountId).isEqualTo(user.getId().get());
+  }
+
+  @Test
+  public void addSelfAsCc() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // user adds self as CC.
+    ReviewInput input = new ReviewInput().reviewer(user.username, CC, false);
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
+            input);
+    ReviewResult result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER);
+      assertReviewers(c, CC, user);
+      // Verify no approvals were added.
+      assertThat(c.labels).isNotNull();
+      LabelInfo label = c.labels.get("Code-Review");
+      assertThat(label).isNotNull();
+      assertThat(label.all).isNull();
+    } else {
+      // When approvals are stored in ReviewDb, we still create a label for
+      // the reviewing user, and force them into the REVIEWER state.
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
+      LabelInfo label = c.labels.get("Code-Review");
+      assertThat(label).isNotNull();
+      assertThat(label.all).isNotNull();
+      assertThat(label.all).hasSize(1);
+      ApprovalInfo approval = label.all.get(0);
+      assertThat(approval._accountId).isEqualTo(user.getId().get());
+    }
+  }
+
+  @Test
+  public void reviewerReplyWithoutVote() throws Exception {
+    // Create change owned by admin.
+    PushOneCommit.Result r = createChange();
+
+    // Verify reviewer state.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC);
+    LabelInfo label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNull();
+
+    // Add user as REVIEWER.
+    ReviewInput input = new ReviewInput().reviewer(user.username);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Verify reviewer state. Both admin and user should be REVIEWERs now,
+    // because admin gets forced into REVIEWER state by virtue of being owner.
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, CC);
+    label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(2);
+    Map<Integer, Integer> approvals = new HashMap<>();
+    for (ApprovalInfo approval : label.all) {
+      approvals.put(approval._accountId, approval.value);
+    }
+    assertThat(approvals).containsEntry(admin.getId().get(), 0);
+    assertThat(approvals).containsEntry(user.getId().get(), 0);
+
+    // Comment as user without voting. This should delete the approval and
+    // then replace it with the default value.
+    input = new ReviewInput().message("hello");
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + r.getChangeId() + "/revisions/" + r.getCommit().getName() + "/review",
+            input);
+    result = readContentFromJson(resp, 200, ReviewResult.class);
+    assertThat(result.labels).isNull();
+
+    // Verify reviewer state.
+    c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER, admin, user);
+    assertReviewers(c, CC);
+    label = c.labels.get("Code-Review");
+    assertThat(label).isNotNull();
+    assertThat(label.all).isNotNull();
+    assertThat(label.all).hasSize(2);
+    approvals.clear();
+    for (ApprovalInfo approval : label.all) {
+      approvals.put(approval._accountId, approval.value);
+    }
+    assertThat(approvals).containsEntry(admin.getId().get(), 0);
+    assertThat(approvals).containsEntry(user.getId().get(), 0);
+  }
+
+  @Test
   public void reviewAndAddReviewers() throws Exception {
     TestAccount observer = accounts.user2();
     PushOneCommit.Result r = createChange();
-    ReviewInput input = ReviewInput.approve()
-        .reviewer(user.email)
-        .reviewer(observer.email, CC, false);
+    ReviewInput input =
+        ReviewInput.approve().reviewer(user.email).reviewer(observer.email, CC, false);
 
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.labels).isNotNull();
@@ -268,16 +430,14 @@
 
     // Verify reviewer and CC were added. If not in NoteDb read mode, both
     // parties will be returned as CCed.
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     if (notesMigration.readChanges()) {
       assertReviewers(c, REVIEWER, admin, user);
       assertReviewers(c, CC, observer);
     } else {
-      // In legacy mode, change owner should be the only reviewer.
-      assertReviewers(c, REVIEWER, admin);
-      assertReviewers(c, CC, user, observer);
+      // In legacy mode, everyone should be a reviewer.
+      assertReviewers(c, REVIEWER, admin, user, observer);
+      assertReviewers(c, CC);
     }
 
     // Verify emails were sent to added reviewers.
@@ -285,17 +445,13 @@
     assertThat(messages).hasSize(2);
 
     Message m = messages.get(0);
-    assertThat(m.rcpt())
-        .containsExactly(user.emailAddress,observer.emailAddress);
-    assertThat(m.body())
-        .contains(admin.fullName + " has posted comments on this change.");
-    assertThat(m.body())
-        .contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
+    assertThat(m.body()).contains(admin.fullName + " has posted comments on this change.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
 
     m = messages.get(1);
-    assertThat(m.rcpt())
-        .containsExactly(user.emailAddress, observer.emailAddress);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
     assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
   }
@@ -304,8 +460,7 @@
   public void reviewAndAddGroupReviewers() throws Exception {
     int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
     int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
-    List<TestAccount> users =
-        createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
+    List<TestAccount> users = createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
     List<String> usernames = new ArrayList<>(largeGroupSize);
     for (TestAccount u : users) {
       usernames.add(u.username);
@@ -313,22 +468,21 @@
 
     String largeGroup = createGroup("largeGroup");
     String mediumGroup = createGroup("mediumGroup");
-    gApi.groups().id(largeGroup).addMembers(
-        usernames.toArray(new String[largeGroupSize]));
-    gApi.groups().id(mediumGroup).addMembers(
-        usernames.subList(0, mediumGroupSize)
-            .toArray(new String[mediumGroupSize]));
+    gApi.groups().id(largeGroup).addMembers(usernames.toArray(new String[largeGroupSize]));
+    gApi.groups()
+        .id(mediumGroup)
+        .addMembers(usernames.subList(0, mediumGroupSize).toArray(new String[mediumGroupSize]));
 
     TestAccount observer = accounts.user2();
     PushOneCommit.Result r = createChange();
 
     // Attempt to add overly large group as reviewers.
-    ReviewInput input = ReviewInput.approve()
-        .reviewer(user.email)
-        .reviewer(observer.email, CC, false)
-        .reviewer(largeGroup);
-    ReviewResult result = review(
-        r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
+    ReviewInput input =
+        ReviewInput.approve()
+            .reviewer(user.email)
+            .reviewer(observer.email, CC, false)
+            .reviewer(largeGroup);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
     assertThat(result.labels).isNull();
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(3);
@@ -339,21 +493,19 @@
     assertThat(reviewerResult.error).contains("has too many members to add them all as reviewers");
 
     // No labels should have changed, and no reviewers/CCs should have been added.
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertThat(c.messages).hasSize(1);
     assertThat(c.reviewers.get(REVIEWER)).isNull();
     assertThat(c.reviewers.get(CC)).isNull();
 
     // Attempt to add group large enough to require confirmation, without
     // confirmation, as reviewers.
-    input = ReviewInput.approve()
-        .reviewer(user.email)
-        .reviewer(observer.email, CC, false)
-        .reviewer(mediumGroup);
-    result = review(r.getChangeId(), r.getCommit().name(), input,
-        SC_BAD_REQUEST);
+    input =
+        ReviewInput.approve()
+            .reviewer(user.email)
+            .reviewer(observer.email, CC, false)
+            .reviewer(mediumGroup);
+    result = review(r.getChangeId(), r.getCommit().name(), input, SC_BAD_REQUEST);
     assertThat(result.labels).isNull();
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(3);
@@ -361,40 +513,34 @@
     assertThat(reviewerResult).isNotNull();
     assertThat(reviewerResult.confirm).isTrue();
     assertThat(reviewerResult.error)
-        .contains("has " + mediumGroupSize + " members. Do you want to add them all"
-            + " as reviewers?");
+        .contains("has " + mediumGroupSize + " members. Do you want to add them all as reviewers?");
 
     // No labels should have changed, and no reviewers/CCs should have been added.
-    c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
+    c = gApi.changes().id(r.getChangeId()).get();
     assertThat(c.messages).hasSize(1);
     assertThat(c.reviewers.get(REVIEWER)).isNull();
     assertThat(c.reviewers.get(CC)).isNull();
 
     // Retrying with confirmation should successfully approve and add reviewers/CCs.
-    input = ReviewInput.approve()
-        .reviewer(user.email)
-        .reviewer(mediumGroup, CC, true);
+    input = ReviewInput.approve().reviewer(user.email).reviewer(mediumGroup, CC, true);
     result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.labels).isNotNull();
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(2);
 
-    c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
+    c = gApi.changes().id(r.getChangeId()).get();
     assertThat(c.messages).hasSize(2);
 
     if (notesMigration.readChanges()) {
       assertReviewers(c, REVIEWER, admin, user);
       assertReviewers(c, CC, users.subList(0, mediumGroupSize));
     } else {
-      // If not in NoteDb mode, then user is returned with the CC group.
-      assertReviewers(c, REVIEWER, admin);
-      List<TestAccount> expectedCC = users.subList(0, mediumGroupSize);
-      expectedCC.add(user);
-      assertReviewers(c, CC, expectedCC);
+      // If not in NoteDb mode, then everyone is a REVIEWER.
+      List<TestAccount> expected = users.subList(0, mediumGroupSize);
+      expected.add(admin);
+      expected.add(user);
+      assertReviewers(c, REVIEWER, expected);
+      assertReviewers(c, CC);
     }
   }
 
@@ -427,32 +573,24 @@
     Iterator<ReviewerUpdateInfo> it = c.reviewerUpdates.iterator();
     ReviewerUpdateInfo reviewerChange = it.next();
     assertThat(reviewerChange.state).isEqualTo(CC);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(
-        user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(
-        admin.getId().get());
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
 
     reviewerChange = it.next();
     assertThat(reviewerChange.state).isEqualTo(REVIEWER);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(
-        user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(
-        admin.getId().get());
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
 
     reviewerChange = it.next();
     assertThat(reviewerChange.state).isEqualTo(REMOVED);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(
-        user.getId().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(
-        admin.getId().get());
+    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.getId().get());
+    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.getId().get());
   }
 
   @Test
   public void addDuplicateReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
-    ReviewInput input = ReviewInput.approve()
-        .reviewer(user.email)
-        .reviewer(user.email);
+    ReviewInput input = ReviewInput.approve().reviewer(user.email).reviewer(user.email);
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(1);
@@ -465,21 +603,16 @@
   @Test
   public void addOverlappingGroups() throws Exception {
     String emailPrefix = "addOverlappingGroups-";
-    TestAccount user1 = accounts.create(name("user1"),
-        emailPrefix + "user1@example.com", "User1");
-    TestAccount user2 = accounts.create(name("user2"),
-        emailPrefix + "user2@example.com", "User2");
-    TestAccount user3 = accounts.create(name("user3"),
-        emailPrefix + "user3@example.com", "User3");
+    TestAccount user1 = accounts.create(name("user1"), emailPrefix + "user1@example.com", "User1");
+    TestAccount user2 = accounts.create(name("user2"), emailPrefix + "user2@example.com", "User2");
+    TestAccount user3 = accounts.create(name("user3"), emailPrefix + "user3@example.com", "User3");
     String group1 = createGroup("group1");
     String group2 = createGroup("group2");
     gApi.groups().id(group1).addMembers(user1.username, user2.username);
     gApi.groups().id(group2).addMembers(user2.username, user3.username);
 
     PushOneCommit.Result r = createChange();
-    ReviewInput input = ReviewInput.approve()
-        .reviewer(group1)
-        .reviewer(group2);
+    ReviewInput input = ReviewInput.approve().reviewer(group1).reviewer(group2);
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(2);
@@ -495,9 +628,7 @@
       return;
     }
     r = createChange();
-    input = ReviewInput.approve()
-        .reviewer(group1, CC, false)
-        .reviewer(group2, CC, false);
+    input = ReviewInput.approve().reviewer(group1, CC, false).reviewer(group2, CC, false);
     result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(2);
@@ -511,9 +642,7 @@
     // Repeat again with one group REVIEWER, the other CC. The overlapping
     // member should end up as a REVIEWER.
     r = createChange();
-    input = ReviewInput.approve()
-        .reviewer(group1, REVIEWER, false)
-        .reviewer(group2, CC, false);
+    input = ReviewInput.approve().reviewer(group1, REVIEWER, false).reviewer(group2, CC, false);
     result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(2);
@@ -526,52 +655,43 @@
     assertThat(reviewerResult.ccs).hasSize(1);
   }
 
-  private AddReviewerResult addReviewer(String changeId, String reviewer)
-      throws Exception {
+  private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
     return addReviewer(changeId, reviewer, SC_OK);
   }
 
-  private AddReviewerResult addReviewer(
-      String changeId, String reviewer, int expectedStatus) throws Exception {
+  private AddReviewerResult addReviewer(String changeId, String reviewer, int expectedStatus)
+      throws Exception {
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = reviewer;
     return addReviewer(changeId, in, expectedStatus);
   }
 
-  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in)
-      throws Exception {
+  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in) throws Exception {
     return addReviewer(changeId, in, SC_OK);
   }
 
-  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in,
-      int expectedStatus) throws Exception {
-    RestResponse resp =
-        adminRestSession.post("/changes/" + changeId + "/reviewers", in);
-    return readContentFromJson(
-        resp, expectedStatus, AddReviewerResult.class);
-  }
-
-  private RestResponse deleteReviewer(String changeId, TestAccount account)
+  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in, int expectedStatus)
       throws Exception {
-    return adminRestSession.delete("/changes/" + changeId + "/reviewers/" +
-        account.getId().get());
+    RestResponse resp = adminRestSession.post("/changes/" + changeId + "/reviewers", in);
+    return readContentFromJson(resp, expectedStatus, AddReviewerResult.class);
   }
 
-  private ReviewResult review(
-      String changeId, String revisionId, ReviewInput in) throws Exception {
+  private RestResponse deleteReviewer(String changeId, TestAccount account) throws Exception {
+    return adminRestSession.delete("/changes/" + changeId + "/reviewers/" + account.getId().get());
+  }
+
+  private ReviewResult review(String changeId, String revisionId, ReviewInput in) throws Exception {
     return review(changeId, revisionId, in, SC_OK);
   }
 
   private ReviewResult review(
-      String changeId, String revisionId, ReviewInput in, int expectedStatus)
-      throws Exception {
-    RestResponse resp = adminRestSession.post(
-        "/changes/" + changeId + "/revisions/" + revisionId + "/review", in);
+      String changeId, String revisionId, ReviewInput in, int expectedStatus) throws Exception {
+    RestResponse resp =
+        adminRestSession.post("/changes/" + changeId + "/revisions/" + revisionId + "/review", in);
     return readContentFromJson(resp, expectedStatus, ReviewResult.class);
   }
 
-  private static <T> T readContentFromJson(
-      RestResponse r, int expectedStatus, Class<T> clazz)
+  private static <T> T readContentFromJson(RestResponse r, int expectedStatus, Class<T> clazz)
       throws Exception {
     r.assertStatus(expectedStatus);
     JsonReader jsonReader = new JsonReader(r.getReader());
@@ -579,8 +699,8 @@
     return newGson().fromJson(jsonReader, clazz);
   }
 
-  private static void assertReviewers(ChangeInfo c, ReviewerState reviewerState,
-      TestAccount... accounts) throws Exception {
+  private static void assertReviewers(
+      ChangeInfo c, ReviewerState reviewerState, TestAccount... accounts) throws Exception {
     List<TestAccount> accountList = new ArrayList<>(accounts.length);
     for (TestAccount a : accounts) {
       accountList.add(a);
@@ -588,8 +708,8 @@
     assertReviewers(c, reviewerState, accountList);
   }
 
-  private static void assertReviewers(ChangeInfo c, ReviewerState reviewerState,
-      Iterable<TestAccount> accounts) throws Exception {
+  private static void assertReviewers(
+      ChangeInfo c, ReviewerState reviewerState, Iterable<TestAccount> accounts) throws Exception {
     Collection<AccountInfo> actualAccounts = c.reviewers.get(reviewerState);
     if (actualAccounts == null) {
       assertThat(accounts.iterator().hasNext()).isFalse();
@@ -607,12 +727,11 @@
     assertThat(actualAccountIds).containsExactlyElementsIn(expectedAccountIds);
   }
 
-  private List<TestAccount> createAccounts(int n, String emailPrefix)
-      throws Exception {
+  private List<TestAccount> createAccounts(int n, String emailPrefix) throws Exception {
     List<TestAccount> result = new ArrayList<>(n);
     for (int i = 0; i < n; i++) {
-      result.add(accounts.create(name("u" + i),
-          emailPrefix + "-" + i + "@example.com", "Full Name " + i));
+      result.add(
+          accounts.create(name("u" + i), emailPrefix + "-" + i + "@example.com", "Full Name " + i));
     }
     return result;
   }
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 b7f09d1..3b49b59 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
@@ -20,6 +20,7 @@
 import static org.junit.Assert.fail;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.data.Permission;
@@ -28,10 +29,12 @@
 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.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.Util;
-
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectLoader;
 import org.eclipse.jgit.revwalk.RevObject;
@@ -46,8 +49,7 @@
   public void setUp() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     Util.allow(cfg, Permission.OWNER, REGISTERED_USERS, "refs/*");
-    Util.allow(
-        cfg, Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
+    Util.allow(cfg, Permission.PUSH, REGISTERED_USERS, "refs/for/refs/meta/config");
     Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, RefNames.REFS_CONFIG);
     saveProjectConfig(project, cfg);
 
@@ -81,16 +83,13 @@
     gApi.changes().id(id).current().review(ReviewInput.approve());
     gApi.changes().id(id).current().submit();
 
-    assertThat(gApi.changes().id(id).info().status)
-        .isEqualTo(ChangeStatus.MERGED);
-    assertThat(gApi.projects().name(project.get()).get().description)
-        .isEqualTo(desc);
+    assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.projects().name(project.get()).get().description).isEqualTo(desc);
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("project", null, "description"))
-        .isEqualTo(desc);
+    assertThat(readProjectConfig().getString("project", null, "description")).isEqualTo(desc);
     String changeRev = gApi.changes().id(id).get().currentRevision;
-    String branchRev = gApi.projects().name(project.get())
-        .branch(RefNames.REFS_CONFIG).get().revision;
+    String branchRev =
+        gApi.projects().name(project.get()).branch(RefNames.REFS_CONFIG).get().revision;
     assertThat(changeRev).isEqualTo(branchRev);
     return id;
   }
@@ -106,8 +105,7 @@
 
     setApiUser(user);
     Config cfg = readProjectConfig();
-    assertThat(cfg.getString("access", null, "inheritFrom"))
-        .isAnyOf(null, allProjects.get());
+    assertThat(cfg.getString("access", null, "inheritFrom")).isAnyOf(null, allProjects.get());
     cfg.setString("access", null, "inheritFrom", parent.name);
 
     PushOneCommit.Result r = createConfigChange(cfg);
@@ -119,33 +117,62 @@
       fail("expected submit to fail");
     } catch (ResourceConflictException e) {
       int n = gApi.changes().id(id).info()._number;
-      assertThat(e).hasMessage(
-          "Failed to submit 1 change due to the following problems:\n"
-          + "Change " + n + ": Change contains a project configuration that"
-          + " changes the parent project.\n"
-          + "The change must be submitted by a Gerrit administrator.");
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(
+              "Failed to submit 1 change due to the following problems:\n"
+                  + "Change "
+                  + n
+                  + ": Change contains a project configuration that"
+                  + " changes the parent project.\n"
+                  + "The change must be submitted by a Gerrit administrator.");
     }
 
-    assertThat(gApi.projects().name(project.get()).get().parent)
-        .isEqualTo(allProjects.get());
+    assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(allProjects.get());
     fetchRefsMetaConfig();
     assertThat(readProjectConfig().getString("access", null, "inheritFrom"))
         .isAnyOf(null, allProjects.get());
 
     setApiUser(admin);
     gApi.changes().id(id).current().submit();
-    assertThat(gApi.changes().id(id).info().status)
-        .isEqualTo(ChangeStatus.MERGED);
-    assertThat(gApi.projects().name(project.get()).get().parent)
-        .isEqualTo(parent.name);
+    assertThat(gApi.changes().id(id).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.projects().name(project.get()).get().parent).isEqualTo(parent.name);
     fetchRefsMetaConfig();
-    assertThat(readProjectConfig().getString("access", null, "inheritFrom"))
-        .isEqualTo(parent.name);
+    assertThat(readProjectConfig().getString("access", null, "inheritFrom")).isEqualTo(parent.name);
+  }
+
+  @Test
+  public void rejectDoubleInheritance() throws Exception {
+    setApiUser(admin);
+    // Create separate projects to test the config
+    Project.NameKey parent = createProject("projectToInheritFrom");
+    Project.NameKey child = createProject("projectWithMalformedConfig");
+
+    String config =
+        gApi.projects()
+            .name(child.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file("project.config")
+            .asString();
+
+    // Append and push malformed project config
+    String pattern = "[access]\n\tinheritFrom = " + allProjects.get() + "\n";
+    String doubleInherit = pattern + "\tinheritFrom = " + parent.get() + "\n";
+    config = config.replace(pattern, doubleInherit);
+
+    TestRepository<InMemoryRepository> childRepo = cloneProject(child, admin);
+    // Fetch meta ref
+    GitUtil.fetch(childRepo, RefNames.REFS_CONFIG + ":cfg");
+    childRepo.reset("cfg");
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), childRepo, "Subject", "project.config", config);
+    PushOneCommit.Result res = push.to(RefNames.REFS_CONFIG);
+    res.assertErrorStatus();
+    res.assertMessage("cannot inherit from multiple projects");
   }
 
   private void fetchRefsMetaConfig() throws Exception {
-    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config"))
-        .call();
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
   }
 
@@ -161,12 +188,16 @@
   }
 
   private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
-    PushOneCommit.Result r = pushFactory.create(
-            db, user.getIdent(), testRepo,
-            "Update project config",
-            "project.config",
-            cfg.toText())
-        .to("refs/for/refs/meta/config");
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                user.getIdent(),
+                testRepo,
+                "Update project config",
+                "project.config",
+                cfg.toText())
+            .to("refs/for/refs/meta/config");
     r.assertOkStatus();
     return r;
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
new file mode 100644
index 0000000..4f2d2bd
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -0,0 +1,158 @@
+// 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.change;
+
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.testutil.ConfigSuite;
+import org.apache.http.Header;
+import org.apache.http.client.fluent.Request;
+import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class CorsIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config allowExampleDotCom() {
+    Config cfg = new Config();
+    cfg.setStringList(
+        "site",
+        null,
+        "allowOriginRegex",
+        ImmutableList.of("https?://(.+[.])?example[.]com", "http://friend[.]ly"));
+    return cfg;
+  }
+
+  @Test
+  public void origin() throws Exception {
+    Result change = createChange();
+
+    String url = "/changes/" + change.getChangeId() + "/detail";
+    RestResponse r = adminRestSession.get(url);
+    r.assertOK();
+    assertThat(r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
+    assertThat(r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS)).isNull();
+
+    check(url, true, "http://example.com");
+    check(url, true, "https://sub.example.com");
+    check(url, true, "http://friend.ly");
+
+    check(url, false, "http://evil.attacker");
+    check(url, false, "http://friendsly");
+  }
+
+  @Test
+  public void putWithOriginRefused() throws Exception {
+    Result change = createChange();
+    String origin = "http://example.com";
+    RestResponse r =
+        adminRestSession.putWithHeader(
+            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+    r.assertOK();
+    checkCors(r, false, origin);
+  }
+
+  @Test
+  public void preflightOk() throws Exception {
+    Result change = createChange();
+
+    String origin = "http://example.com";
+    Request req =
+        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, origin);
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Requested-With");
+
+    RestResponse res = adminRestSession.execute(req);
+    res.assertOK();
+    checkCors(res, true, origin);
+  }
+
+  @Test
+  public void preflightBadOrigin() throws Exception {
+    Result change = createChange();
+
+    Request req =
+        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, "http://evil.attacker");
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+
+    adminRestSession.execute(req).assertBadRequest();
+  }
+
+  @Test
+  public void preflightBadMethod() throws Exception {
+    Result change = createChange();
+
+    for (String method : new String[] {"POST", "PUT", "DELETE", "PATCH"}) {
+      Request req =
+          Request.Options(
+              adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+      req.addHeader(ORIGIN, "http://example.com");
+      req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, method);
+      adminRestSession.execute(req).assertBadRequest();
+    }
+  }
+
+  @Test
+  public void preflightBadHeader() throws Exception {
+    Result change = createChange();
+
+    Request req =
+        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, "http://example.com");
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
+    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Gerrit-Auth");
+
+    adminRestSession.execute(req).assertBadRequest();
+  }
+
+  private RestResponse check(String url, boolean accept, String origin) throws Exception {
+    Header hdr = new BasicHeader(ORIGIN, origin);
+    RestResponse r = adminRestSession.getWithHeader(url, hdr);
+    r.assertOK();
+    checkCors(r, accept, origin);
+    return r;
+  }
+
+  private void checkCors(RestResponse r, boolean accept, String origin) {
+    String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
+    String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
+    String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
+    String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
+    if (accept) {
+      assertThat(allowOrigin).isEqualTo(origin);
+      assertThat(allowCred).isEqualTo("true");
+      assertThat(allowMethods).isEqualTo("GET, OPTIONS");
+      assertThat(allowHeaders).isEqualTo("X-Requested-With");
+    } else {
+      assertThat(allowOrigin).isNull();
+      assertThat(allowCred).isNull();
+      assertThat(allowMethods).isNull();
+      assertThat(allowHeaders).isNull();
+    }
+  }
+}
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 993f3f5..9c68712 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
@@ -28,6 +28,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -43,8 +44,9 @@
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.git.ChangeAlreadyMergedException;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
-
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -76,8 +78,7 @@
   public void createEmptyChange_MissingBranch() throws Exception {
     ChangeInput ci = new ChangeInput();
     ci.project = project.get();
-    assertCreateFails(ci, BadRequestException.class,
-        "branch must be non-empty");
+    assertCreateFails(ci, BadRequestException.class, "branch must be non-empty");
   }
 
   @Test
@@ -85,31 +86,40 @@
     ChangeInput ci = new ChangeInput();
     ci.project = project.get();
     ci.branch = "master";
-    assertCreateFails(ci, BadRequestException.class,
-        "commit message must be non-empty");
+    assertCreateFails(ci, BadRequestException.class, "commit message must be non-empty");
   }
 
   @Test
   public void createEmptyChange_InvalidStatus() throws Exception {
     ChangeInput ci = newChangeInput(ChangeStatus.MERGED);
-    assertCreateFails(ci, BadRequestException.class,
-        "unsupported change status");
+    assertCreateFails(ci, BadRequestException.class, "unsupported change status");
   }
 
   @Test
   public void createEmptyChange_InvalidChangeId() throws Exception {
-   ChangeInput ci = newChangeInput(ChangeStatus.NEW);
-   ci.subject = "Subject\n\nChange-Id: I0000000000000000000000000000000000000000";
-   assertCreateFails(ci, ResourceConflictException.class,
-       "invalid Change-Id line format in commit message footer");
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject\n\nChange-Id: I0000000000000000000000000000000000000000";
+    assertCreateFails(
+        ci,
+        ResourceConflictException.class,
+        "invalid Change-Id line format in commit message footer");
   }
 
   @Test
   public void createEmptyChange_InvalidSubject() throws Exception {
-   ChangeInput ci = newChangeInput(ChangeStatus.NEW);
-   ci.subject = "Change-Id: I1234000000000000000000000000000000000000";
-   assertCreateFails(ci, ResourceConflictException.class,
-       "missing subject; Change-Id must be in commit message footer");
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Change-Id: I1234000000000000000000000000000000000000";
+    assertCreateFails(
+        ci,
+        ResourceConflictException.class,
+        "missing subject; Change-Id must be in commit message footer");
+  }
+
+  @Test
+  public void createNewChange_InvalidCommentInCommitMessage() throws Exception {
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "#12345 Test";
+    assertCreateFails(ci, BadRequestException.class, "commit message must be non-empty");
   }
 
   @Test
@@ -120,6 +130,15 @@
   }
 
   @Test
+  public void createNewChangeWithCommentsInCommitMessage() throws Exception {
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject += "\n# Comment line";
+    ChangeInfo info = gApi.changes().create(ci).get();
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .doesNotContain("# Comment line");
+  }
+
+  @Test
   public void createNewChangeWithChangeId() throws Exception {
     ChangeInput ci = newChangeInput(ChangeStatus.NEW);
     String changeId = "I1234000000000000000000000000000000000000";
@@ -131,14 +150,38 @@
   }
 
   @Test
+  public void notificationsOnChangeCreation() throws Exception {
+    setApiUser(user);
+    watch(project.get(), null);
+
+    // check that watcher is notified
+    setApiUser(admin);
+    assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains(admin.fullName + " has uploaded this change for review.");
+
+    // check that watcher is not notified if notify=NONE
+    sender.clear();
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.notify = NotifyHandling.NONE;
+    assertCreateSucceeds(input);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void createNewChangeSignedOffByFooter() throws Exception {
     setSignedOffByFooter(true);
     try {
       ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
       String message = info.revisions.get(info.currentRevision).commit.message;
-      assertThat(message).contains(
-          String.format("%sAdministrator <%s>", SIGNED_OFF_BY_TAG,
-              admin.getIdent().getEmailAddress()));
+      assertThat(message)
+          .contains(
+              String.format(
+                  "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.getIdent().getEmailAddress()));
     } finally {
       setSignedOffByFooter(false);
     }
@@ -156,9 +199,10 @@
       assertThat(info.changeId).isEqualTo(changeId);
       String message = info.revisions.get(info.currentRevision).commit.message;
       assertThat(message).contains(changeIdLine);
-      assertThat(message).contains(
-          String.format("%sAdministrator <%s>", SIGNED_OFF_BY_TAG,
-              admin.getIdent().getEmailAddress()));
+      assertThat(message)
+          .contains(
+              String.format(
+                  "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.getIdent().getEmailAddress()));
     } finally {
       setSignedOffByFooter(false);
     }
@@ -174,8 +218,7 @@
   public void createNewDraftChangeNotAllowed() throws Exception {
     assume().that(isAllowDrafts()).isFalse();
     ChangeInput ci = newChangeInput(ChangeStatus.DRAFT);
-    assertCreateFails(ci, MethodNotAllowedException.class,
-        "draft workflow is disabled");
+    assertCreateFails(ci, MethodNotAllowedException.class, "draft workflow is disabled");
   }
 
   @Test
@@ -185,14 +228,17 @@
     ChangeInfo c = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(
-          repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
+      RevCommit commit =
+          rw.parseCommit(repo.exactRef(changeMetaRef(new Change.Id(c._number))).getObjectId());
 
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
-      PersonIdent expectedAuthor = changeNoteUtil.newIdent(
-          accountCache.get(admin.id).getAccount(), c.created, serverIdent.get(),
-          AnonymousCowardNameProvider.DEFAULT);
+      PersonIdent expectedAuthor =
+          changeNoteUtil.newIdent(
+              accountCache.get(admin.id).getAccount(),
+              c.created,
+              serverIdent.get(),
+              AnonymousCowardNameProvider.DEFAULT);
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
@@ -204,75 +250,89 @@
   @Test
   public void createMergeChange() throws Exception {
     changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
-    ChangeInput in =
-        newMergeChangeInput("branchA", "branchB", "");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
     assertCreateSucceeds(in);
   }
 
   @Test
   public void createMergeChange_Conflicts() throws Exception {
     changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
-    ChangeInput in =
-        newMergeChangeInput("branchA", "branchB", "");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
     assertCreateFails(in, RestApiException.class, "merge conflict");
   }
 
   @Test
   public void createMergeChange_Conflicts_Ours() throws Exception {
     changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt");
-    ChangeInput in =
-        newMergeChangeInput("branchA", "branchB", "ours");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "ours");
     assertCreateSucceeds(in);
   }
 
   @Test
   public void invalidSource() throws Exception {
     changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
-    ChangeInput in =
-        newMergeChangeInput("branchA", "invalid", "");
-    assertCreateFails(in, BadRequestException.class,
-        "Cannot resolve 'invalid' to a commit");
+    ChangeInput in = newMergeChangeInput("branchA", "invalid", "");
+    assertCreateFails(in, BadRequestException.class, "Cannot resolve 'invalid' to a commit");
   }
 
   @Test
   public void invalidStrategy() throws Exception {
     changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
-    ChangeInput in =
-        newMergeChangeInput("branchA", "branchB", "octopus");
-    assertCreateFails(in, BadRequestException.class,
-        "invalid merge strategy: octopus");
+    ChangeInput in = newMergeChangeInput("branchA", "branchB", "octopus");
+    assertCreateFails(in, BadRequestException.class, "invalid merge strategy: octopus");
   }
 
   @Test
   public void alreadyMerged() throws Exception {
-    ObjectId c0 = testRepo.branch("HEAD").commit().insertChangeId()
-        .message("first commit")
-        .add("a.txt", "a contents ")
-        .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    ObjectId c0 =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("first commit")
+            .add("a.txt", "a contents ")
+            .create();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("second commit")
         .add("b.txt", "b contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
-    ChangeInput in =
-        newMergeChangeInput("master", c0.getName(), "");
-    assertCreateFails(in, ChangeAlreadyMergedException.class,
-        "'" + c0.getName() + "' has already been merged");
+    ChangeInput in = newMergeChangeInput("master", c0.getName(), "");
+    assertCreateFails(
+        in, ChangeAlreadyMergedException.class, "'" + c0.getName() + "' has already been merged");
   }
 
   @Test
   public void onlyContentMerged() throws Exception {
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("first commit")
         .add("a.txt", "a contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
     // create a change, and cherrypick into master
     PushOneCommit.Result cId = createChange();
@@ -280,8 +340,7 @@
     CherryPickInput cpi = new CherryPickInput();
     cpi.destination = "master";
     cpi.message = "cherry pick the commit";
-    ChangeApi orig = gApi.changes()
-        .id(cId.getChangeId());
+    ChangeApi orig = gApi.changes().id(cId.getChangeId());
     ChangeApi cherry = orig.current().cherryPick(cpi);
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
@@ -289,8 +348,7 @@
     ObjectId remoteId = getRemoteHead();
     assertThat(remoteId).isNotEqualTo(commitId);
 
-    ChangeInput in =
-        newMergeChangeInput("master", commitId.getName(), "");
+    ChangeInput in = newMergeChangeInput("master", commitId.getName(), "");
     assertCreateSucceeds(in);
   }
 
@@ -306,6 +364,7 @@
 
   private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
     ChangeInfo out = gApi.changes().create(in).get();
+    assertThat(out.project).isEqualTo(in.project);
     assertThat(out.branch).isEqualTo(in.branch);
     assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
     assertThat(out.topic).isEqualTo(in.topic);
@@ -317,8 +376,8 @@
     return out;
   }
 
-  private void assertCreateFails(ChangeInput in,
-      Class<? extends RestApiException> errType, String errSubstring)
+  private void assertCreateFails(
+      ChangeInput in, Class<? extends RestApiException> errType, String errSubstring)
       throws Exception {
     exception.expect(errType);
     exception.expectMessage(errSubstring);
@@ -334,17 +393,14 @@
 
   // TODO(davido): Expose setting of account preferences in the API
   private void setSignedOffByFooter(boolean value) throws Exception {
-    RestResponse r = adminRestSession.get("/accounts/" + admin.email
-        + "/preferences");
+    RestResponse r = adminRestSession.get("/accounts/" + admin.email + "/preferences");
     r.assertOK();
-    GeneralPreferencesInfo i =
-        newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
+    GeneralPreferencesInfo i = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
     i.signedOffBy = value;
 
     r = adminRestSession.put("/accounts/" + admin.email + "/preferences", i);
     r.assertOK();
-    GeneralPreferencesInfo o = newGson().fromJson(r.getReader(),
-        GeneralPreferencesInfo.class);
+    GeneralPreferencesInfo o = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
 
     if (value) {
       assertThat(o.signedOffBy).isTrue();
@@ -353,8 +409,7 @@
     }
   }
 
-  private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef,
-      String strategy) {
+  private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef, String strategy) {
     // create a merge change from branchA to master in gerrit
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -370,13 +425,13 @@
     return in;
   }
 
-  private void changeInTwoBranches(String branchA, String fileA, String branchB,
-      String fileB) throws Exception {
+  private void changeInTwoBranches(String branchA, String fileA, String branchB, String fileB)
+      throws Exception {
     // create a initial commit in master
-    Result initialCommit = pushFactory
-        .create(db, user.getIdent(), testRepo, "initial commit", "readme.txt",
-            "initial commit")
-        .to("refs/heads/master");
+    Result initialCommit =
+        pushFactory
+            .create(db, user.getIdent(), testRepo, "initial commit", "readme.txt", "initial commit")
+            .to("refs/heads/master");
     initialCommit.assertOkStatus();
 
     // create two new branches
@@ -384,14 +439,15 @@
     createBranch(new Branch.NameKey(project, branchB));
 
     // create a commit in branchA
-    Result changeA = pushFactory
-        .create(db, user.getIdent(), testRepo, "change A", fileA, "A content")
-        .to("refs/heads/" + branchA);
+    Result changeA =
+        pushFactory
+            .create(db, user.getIdent(), testRepo, "change A", fileA, "A content")
+            .to("refs/heads/" + branchA);
     changeA.assertOkStatus();
 
     // create a commit in branchB
-    PushOneCommit commitB = pushFactory
-        .create(db, user.getIdent(), testRepo, "change B", fileB, "B content");
+    PushOneCommit commitB =
+        pushFactory.create(db, user.getIdent(), testRepo, "change B", fileB, "B content");
     commitB.setParent(initialCommit.getCommit());
     Result changeB = commitB.to("refs/heads/" + branchB);
     changeB.assertOkStatus();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
index 31e52f7..38f73c4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
@@ -33,26 +33,23 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-
+import java.util.HashMap;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
-import java.util.HashMap;
-
 @NoHttpd
 public class DeleteDraftPatchSetIT extends AbstractDaemonTest {
 
-  @Inject
-  private AllUsersName allUsers;
+  @Inject private AllUsersName allUsers;
 
   @Test
   public void deletePatchSetNotDraft() throws Exception {
@@ -63,9 +60,9 @@
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
 
+    setApiUser(admin);
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("Patch set is not a draft");
-    setApiUser(admin);
     deletePatchSet(changeId, ps);
   }
 
@@ -78,9 +75,9 @@
     assertThat(c.id).isEqualTo(triplet);
     assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
 
+    setApiUser(user);
     exception.expect(ResourceNotFoundException.class);
     exception.expectMessage("Not found: " + changeId);
-    setApiUser(user);
     deletePatchSet(changeId, ps);
   }
 
@@ -145,15 +142,14 @@
 
     cd = getChange(changeId);
     assertThat(cd.patchSets()).hasSize(1);
-    assertThat(Iterables.getOnlyElement(cd.patchSets()).getId().get())
-        .isEqualTo(2);
+    assertThat(Iterables.getOnlyElement(cd.patchSets()).getId().get()).isEqualTo(2);
 
     // Other entities based on deleted patch sets are also deleted.
     for (ChangeMessage m : cd.messages()) {
       assertThat(m.getPatchSetId()).named(m.toString()).isNotEqualTo(delPsId);
     }
-    for (PatchLineComment c : cd.publishedComments()) {
-      assertThat(c.getPatchSetId()).named(c.toString()).isNotEqualTo(delPsId);
+    for (Comment c : cd.publishedComments()) {
+      assertThat(c.key.patchSetId).named(c.toString()).isNotEqualTo(delPsId.get());
     }
   }
 
@@ -180,67 +176,70 @@
 
     cd = getChange(changeId);
     assertThat(cd.patchSets()).hasSize(1);
-    assertThat(Iterables.getOnlyElement(cd.patchSets()).getId().get())
-        .isEqualTo(1);
+    assertThat(Iterables.getOnlyElement(cd.patchSets()).getId().get()).isEqualTo(1);
 
     // Other entities based on deleted patch sets are also deleted.
     for (ChangeMessage m : cd.messages()) {
       assertThat(m.getPatchSetId()).named(m.toString()).isNotEqualTo(delPsId);
     }
-    for (PatchLineComment c : cd.publishedComments()) {
-      assertThat(c.getPatchSetId()).named(c.toString()).isNotEqualTo(delPsId);
+    for (Comment c : cd.publishedComments()) {
+      assertThat(c.key.patchSetId).named(c.toString()).isNotEqualTo(delPsId.get());
     }
   }
 
   @Test
+  public void deleteCurrentDraftPatchSetWhenPreviousPatchSetDoesNotExist() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    String changeId = push.to("refs/for/master").getChangeId();
+    pushFactory
+        .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "foo", changeId)
+        .to("refs/drafts/master");
+    pushFactory
+        .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "bar", changeId)
+        .to("refs/drafts/master");
+
+    deletePatchSet(changeId, 2);
+    deletePatchSet(changeId, 3);
+
+    ChangeData cd = getChange(changeId);
+    assertThat(cd.patchSets()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cd.patchSets()).getId().get()).isEqualTo(1);
+    assertThat(cd.currentPatchSet().getId().get()).isEqualTo(1);
+  }
+
+  @Test
   public void deleteDraftPatchSetAndPushNewDraftPatchSet() throws Exception {
     String ref = "refs/drafts/master";
 
     // Clone repository
-    TestRepository<InMemoryRepository> testRepo =
-        cloneProject(project, admin);
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, admin);
 
     // Create change
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), testRepo);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r1 = push.to(ref);
     r1.assertOkStatus();
     String revPs1 = r1.getChange().currentPatchSet().getRevision().get();
 
     // Push draft patch set
-    PushOneCommit.Result r2 = amendChange(
-        r1.getChangeId(), ref, admin, testRepo);
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId(), ref, admin, testRepo);
     r2.assertOkStatus();
     String revPs2 = r2.getChange().currentPatchSet().getRevision().get();
 
-    assertThat(
-        gApi.changes()
-            .id(r1.getChange().getId().get()).get()
-            .currentRevision)
+    assertThat(gApi.changes().id(r1.getChange().getId().get()).get().currentRevision)
         .isEqualTo(revPs2);
 
     // Remove draft patch set
-    gApi.changes()
-        .id(r1.getChange().getId().get())
-        .revision(revPs2)
-        .delete();
+    gApi.changes().id(r1.getChange().getId().get()).revision(revPs2).delete();
 
-    assertThat(
-        gApi.changes()
-            .id(r1.getChange().getId().get()).get()
-            .currentRevision)
+    assertThat(gApi.changes().id(r1.getChange().getId().get()).get().currentRevision)
         .isEqualTo(revPs1);
 
     // Push new draft patch set
-    PushOneCommit.Result r3 = amendChange(
-        r1.getChangeId(), ref, admin, testRepo);
+    PushOneCommit.Result r3 = amendChange(r1.getChangeId(), ref, admin, testRepo);
     r3.assertOkStatus();
     String revPs3 = r2.getChange().currentPatchSet().getRevision().get();
 
-    assertThat(
-        gApi.changes()
-            .id(r1.getChange().getId().get()).get()
-            .currentRevision)
+    assertThat(gApi.changes().id(r1.getChange().getId().get()).get().currentRevision)
         .isEqualTo(revPs3);
 
     // Check that all patch sets have different SHA1s
@@ -248,8 +247,7 @@
     assertThat(revPs2).doesNotMatch(revPs3);
   }
 
-  private Ref getDraftRef(TestAccount account, Change.Id changeId)
-      throws Exception {
+  private Ref getDraftRef(TestAccount account, Change.Id changeId) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       return repo.exactRef(RefNames.refsDraftComments(changeId, account.id));
     }
@@ -264,8 +262,15 @@
   private String createDraftChangeWith2PS() throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     Result result = push.to("refs/drafts/master");
-    push = pushFactory.create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT,
-        "b.txt", "4711", result.getChangeId());
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "4711",
+            result.getChangeId());
     return push.to("refs/drafts/master").getChangeId();
   }
 
@@ -278,6 +283,10 @@
   }
 
   private void deletePatchSet(String changeId, PatchSet ps) throws Exception {
-    gApi.changes().id(changeId).revision(ps.getId().get()).delete();
+    deletePatchSet(changeId, ps.getId().get());
+  }
+
+  private void deletePatchSet(String changeId, int ps) throws Exception {
+    gApi.changes().id(changeId).revision(ps).delete();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
new file mode 100644
index 0000000..ff4eb3d
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.testutil.FakeEmailSender;
+import com.google.gson.reflect.TypeToken;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+public class DeleteVoteIT extends AbstractDaemonTest {
+  @Test
+  public void deleteVoteOnChange() throws Exception {
+    deleteVote(false);
+  }
+
+  @Test
+  public void deleteVoteOnRevision() throws Exception {
+    deleteVote(true);
+  }
+
+  private void deleteVote(boolean onRevisionLevel) throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    PushOneCommit.Result r2 = amendChange(r.getChangeId());
+
+    setApiUser(user);
+    recommend(r.getChangeId());
+
+    sender.clear();
+    String endPoint =
+        "/changes/"
+            + r.getChangeId()
+            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+            + "/reviewers/"
+            + user.getId().toString()
+            + "/votes/Code-Review";
+
+    RestResponse response = adminRestSession.delete(endPoint);
+    response.assertNoContent();
+
+    List<FakeEmailSender.Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    FakeEmailSender.Message msg = messages.get(0);
+    assertThat(msg.rcpt()).containsExactly(user.emailAddress);
+    assertThat(msg.body()).contains(admin.fullName + " has removed a vote on this change.\n");
+    assertThat(msg.body())
+        .contains("Removed Code-Review+1 by " + user.fullName + " <" + user.email + ">\n");
+
+    endPoint =
+        "/changes/"
+            + r.getChangeId()
+            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+            + "/reviewers/"
+            + user.getId().toString()
+            + "/votes";
+
+    response = adminRestSession.get(endPoint);
+    response.assertOK();
+
+    Map<String, Short> m =
+        newGson().fromJson(response.getReader(), new TypeToken<Map<String, Short>>() {}.getType());
+
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+
+    ChangeMessageInfo message = Iterables.getLast(c.messages);
+    assertThat(message.author._accountId).isEqualTo(admin.getId().get());
+    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+        .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
+  }
+
+  private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
+    return Iterables.transform(r, a -> new Account.Id(a._accountId));
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
index 3228a22a..3008f39 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
@@ -16,48 +16,49 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.PatchSetState;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.testutil.ConfigSuite;
-import com.google.gerrit.testutil.NoteDbMode;
-
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
-import java.util.Collection;
-
 public class DraftChangeIT extends AbstractDaemonTest {
   @ConfigSuite.Config
   public static Config allowDraftsDisabled() {
     return allowDraftsDisabledConfig();
   }
 
-  @Test
-  public void deleteChange() throws Exception {
-    PushOneCommit.Result result = createChange();
-    result.assertOkStatus();
-    String changeId = result.getChangeId();
-    String triplet = project.get() + "~master~" + changeId;
-    ChangeInfo c = get(triplet);
-    assertThat(c.id).isEqualTo(triplet);
-    assertThat(c.status).isEqualTo(ChangeStatus.NEW);
-    RestResponse response = deleteChange(changeId, adminRestSession);
-    assertThat(response.getEntityContent())
-        .isEqualTo("Change is not a draft: " + c._number);
-    response.assertConflict();
-  }
+  @Inject private BatchUpdate.Factory updateFactory;
 
   @Test
   public void deleteDraftChange() throws Exception {
@@ -76,6 +77,90 @@
   }
 
   @Test
+  public void deleteDraftChangeOfAnotherUser() throws Exception {
+    assume().that(isAllowDrafts()).isTrue();
+    PushOneCommit.Result changeResult = createDraftChange();
+    changeResult.assertOkStatus();
+    String changeId = changeResult.getChangeId();
+    Change.Id id = changeResult.getChange().getId();
+
+    // The user needs to be able to see the draft change (which reviewers can).
+    gApi.changes().id(changeId).addReviewer(user.fullName);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteDraftChangeWhenDraftsNotAllowedAsNormalUser() throws Exception {
+    assume().that(isAllowDrafts()).isFalse();
+
+    setApiUser(user);
+    // We can't create a draft change while the draft workflow is disabled.
+    // For this reason, we create a normal change and modify the database.
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    Change.Id id = changeResult.getChange().getId();
+    markChangeAsDraft(id);
+    setDraftStatusOfPatchSetsOfChange(id, true);
+
+    String changeId = changeResult.getChangeId();
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("Draft workflow is disabled");
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void deleteDraftChangeWhenDraftsNotAllowedAsAdmin() throws Exception {
+    assume().that(isAllowDrafts()).isFalse();
+
+    setApiUser(user);
+    // We can't create a draft change while the draft workflow is disabled.
+    // For this reason, we create a normal change and modify the database.
+    PushOneCommit.Result changeResult =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    Change.Id id = changeResult.getChange().getId();
+    markChangeAsDraft(id);
+    setDraftStatusOfPatchSetsOfChange(id, true);
+
+    String changeId = changeResult.getChangeId();
+
+    // Grant those permissions to admins.
+    grant(Permission.VIEW_DRAFTS, project, "refs/*");
+    grant(Permission.DELETE_DRAFTS, project, "refs/*");
+
+    try {
+      setApiUser(admin);
+      gApi.changes().id(changeId).delete();
+    } finally {
+      removePermission(Permission.DELETE_DRAFTS, project, "refs/*");
+      removePermission(Permission.VIEW_DRAFTS, project, "refs/*");
+    }
+
+    setApiUser(user);
+    assertThat(query(changeId)).isEmpty();
+  }
+
+  @Test
+  public void deleteDraftChangeWithNonDraftPatchSet() throws Exception {
+    assume().that(isAllowDrafts()).isTrue();
+
+    PushOneCommit.Result changeResult = createDraftChange();
+    Change.Id id = changeResult.getChange().getId();
+    setDraftStatusOfPatchSetsOfChange(id, false);
+
+    String changeId = changeResult.getChangeId();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format("Cannot delete draft change %s: patch set 1 is not a draft", id));
+    gApi.changes().id(changeId).delete();
+  }
+
+  @Test
   public void publishDraftChange() throws Exception {
     assume().that(isAllowDrafts()).isTrue();
     PushOneCommit.Result result = createDraftChange();
@@ -129,9 +214,7 @@
     assertThat(label.all.get(0)._accountId).isEqualTo(user.id.get());
     assertThat(label.all.get(0).value).isEqualTo(0);
 
-    ReviewerState rs = NoteDbMode.readWrite()
-        ? ReviewerState.REVIEWER : ReviewerState.CC;
-    Collection<AccountInfo> ccs = info.reviewers.get(rs);
+    Collection<AccountInfo> ccs = info.reviewers.get(ReviewerState.REVIEWER);
     assertThat(ccs).hasSize(1);
     assertThat(ccs.iterator().next()._accountId).isEqualTo(user.id.get());
 
@@ -145,8 +228,7 @@
     assertThat(label.all.get(0).value).isEqualTo(1);
   }
 
-  private static RestResponse deleteChange(String changeId,
-      RestSession s) throws Exception {
+  private static RestResponse deleteChange(String changeId, RestSession s) throws Exception {
     return s.delete("/changes/" + changeId);
   }
 
@@ -155,12 +237,85 @@
   }
 
   private RestResponse publishPatchSet(String changeId) throws Exception {
-    PatchSet patchSet = Iterables.getOnlyElement(
-        queryProvider.get().byKeyPrefix(changeId)).currentPatchSet();
-    return adminRestSession.post("/changes/"
-        + changeId
-        + "/revisions/"
-        + patchSet.getRevision().get()
-        + "/publish");
+    PatchSet patchSet =
+        Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).currentPatchSet();
+    return adminRestSession.post(
+        "/changes/" + changeId + "/revisions/" + patchSet.getRevision().get() + "/publish");
+  }
+
+  private void markChangeAsDraft(Change.Id id) throws Exception {
+    try (BatchUpdate batchUpdate =
+        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+      batchUpdate.addOp(id, new MarkChangeAsDraftUpdateOp());
+      batchUpdate.execute();
+    }
+
+    ChangeStatus changeStatus = gApi.changes().id(id.get()).get().status;
+    assertThat(changeStatus).isEqualTo(ChangeStatus.DRAFT);
+  }
+
+  private void setDraftStatusOfPatchSetsOfChange(Change.Id id, boolean draftStatus)
+      throws Exception {
+    try (BatchUpdate batchUpdate =
+        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+      batchUpdate.addOp(id, new DraftStatusOfPatchSetsUpdateOp(draftStatus));
+      batchUpdate.execute();
+    }
+
+    Boolean expectedDraftStatus = draftStatus ? Boolean.TRUE : null;
+    List<Boolean> patchSetDraftStatuses = getPatchSetDraftStatuses(id);
+    patchSetDraftStatuses.forEach(status -> assertThat(status).isEqualTo(expectedDraftStatus));
+  }
+
+  private List<Boolean> getPatchSetDraftStatuses(Change.Id id) throws Exception {
+    Collection<RevisionInfo> revisionInfos =
+        gApi.changes()
+            .id(id.get())
+            .get(EnumSet.of(ListChangesOption.ALL_REVISIONS))
+            .revisions
+            .values();
+    return revisionInfos.stream().map(revisionInfo -> revisionInfo.draft).collect(toList());
+  }
+
+  private static class MarkChangeAsDraftUpdateOp implements BatchUpdateOp {
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Change change = ctx.getChange();
+
+      // Change status in database.
+      change.setStatus(Change.Status.DRAFT);
+
+      // Change status in NoteDb.
+      PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+      ctx.getUpdate(currentPatchSetId).setStatus(Change.Status.DRAFT);
+
+      return true;
+    }
+  }
+
+  private class DraftStatusOfPatchSetsUpdateOp implements BatchUpdateOp {
+    private final boolean draftStatus;
+
+    DraftStatusOfPatchSetsUpdateOp(boolean draftStatus) {
+      this.draftStatus = draftStatus;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Collection<PatchSet> patchSets = psUtil.byChange(db, ctx.getNotes());
+
+      // Change status in database.
+      patchSets.forEach(patchSet -> patchSet.setDraft(draftStatus));
+      db.patchSets().update(patchSets);
+
+      // Change status in NoteDb.
+      PatchSetState patchSetState = draftStatus ? PatchSetState.DRAFT : PatchSetState.PUBLISHED;
+      patchSets.stream()
+          .map(PatchSet::getId)
+          .map(ctx::getUpdate)
+          .forEach(changeUpdate -> changeUpdate.setPatchSetState(patchSetState));
+
+      return true;
+    }
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index a044772..18925b4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
@@ -25,10 +26,11 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.testutil.TestTimeUtil;
-
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
@@ -52,14 +54,14 @@
   }
 
   @Test
-  public void testGetNoHashtags() throws Exception {
+  public void getNoHashtags() throws Exception {
     // Get on a change with no hashtags returns an empty list.
     PushOneCommit.Result r = createChange();
     assertThatGet(r).isEmpty();
   }
 
   @Test
-  public void testAddSingleHashtag() throws Exception {
+  public void addSingleHashtag() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Adding a single hashtag returns a single hashtag.
@@ -75,7 +77,7 @@
   }
 
   @Test
-  public void testAddMultipleHashtags() throws Exception {
+  public void addMultipleHashtags() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Adding multiple hashtags returns a sorted list of hashtags.
@@ -91,7 +93,7 @@
   }
 
   @Test
-  public void testAddAlreadyExistingHashtag() throws Exception {
+  public void addAlreadyExistingHashtag() throws Exception {
     // Adding a hashtag that already exists on the change returns a sorted list
     // of hashtags without duplicates.
     PushOneCommit.Result r = createChange();
@@ -110,7 +112,7 @@
   }
 
   @Test
-  public void testHashtagsWithPrefix() throws Exception {
+  public void hashtagsWithPrefix() throws Exception {
     PushOneCommit.Result r = createChange();
 
     // Leading # is stripped from added tag.
@@ -150,7 +152,7 @@
   }
 
   @Test
-  public void testRemoveSingleHashtag() throws Exception {
+  public void removeSingleHashtag() throws Exception {
     // Removing a single tag from a change that only has that tag returns an
     // empty list.
     PushOneCommit.Result r = createChange();
@@ -169,7 +171,7 @@
   }
 
   @Test
-  public void testRemoveMultipleHashtags() throws Exception {
+  public void removeMultipleHashtags() throws Exception {
     // Removing multiple tags from a change that only has those tags returns an
     // empty list.
     PushOneCommit.Result r = createChange();
@@ -189,7 +191,7 @@
   }
 
   @Test
-  public void testRemoveNotExistingHashtag() throws Exception {
+  public void removeNotExistingHashtag() throws Exception {
     // Removing a single hashtag from change that has no hashtags returns an
     // empty list.
     PushOneCommit.Result r = createChange();
@@ -216,7 +218,7 @@
   }
 
   @Test
-  public void testAddAndRemove() throws Exception {
+  public void addAndRemove() throws Exception {
     // Adding and remove hashtags in a single request performs correctly.
     PushOneCommit.Result r = createChange();
     addHashtags(r, "tag1", "tag2");
@@ -238,57 +240,63 @@
   }
 
   @Test
-  public void testHashtagWithMixedCase() throws Exception {
+  public void hashtagWithMixedCase() throws Exception {
     PushOneCommit.Result r = createChange();
     addHashtags(r, "MyHashtag");
     assertThatGet(r).containsExactly("MyHashtag");
     assertMessage(r, "Hashtag added: MyHashtag");
   }
 
-  private IterableSubject<
-        ? extends IterableSubject<?, String, Iterable<String>>,
-        String, Iterable<String>>
-      assertThatGet(PushOneCommit.Result r) throws Exception {
-    return assertThat(gApi.changes()
-        .id(r.getChange().getId().get())
-        .getHashtags());
+  @Test
+  public void addHashtagWithoutPermissionNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("Editing hashtags not permitted");
+    addHashtags(r, "MyHashtag");
   }
 
-  private void addHashtags(PushOneCommit.Result r, String... toAdd)
-      throws Exception {
+  @Test
+  public void addHashtagWithPermissionAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    grant(Permission.EDIT_HASHTAGS, project, "refs/heads/master", false, REGISTERED_USERS);
+    setApiUser(user);
+    addHashtags(r, "MyHashtag");
+    assertThatGet(r).containsExactly("MyHashtag");
+    assertMessage(r, "Hashtag added: MyHashtag");
+  }
+
+  private IterableSubject assertThatGet(PushOneCommit.Result r) throws Exception {
+    return assertThat(gApi.changes().id(r.getChange().getId().get()).getHashtags());
+  }
+
+  private void addHashtags(PushOneCommit.Result r, String... toAdd) throws Exception {
     HashtagsInput input = new HashtagsInput();
     input.add = Sets.newHashSet(toAdd);
-    gApi.changes()
-        .id(r.getChange().getId().get())
-        .setHashtags(input);
+    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
   }
 
-  private void removeHashtags(PushOneCommit.Result r, String... toRemove)
-      throws Exception {
+  private void removeHashtags(PushOneCommit.Result r, String... toRemove) throws Exception {
     HashtagsInput input = new HashtagsInput();
     input.remove = Sets.newHashSet(toRemove);
-    gApi.changes()
-        .id(r.getChange().getId().get())
-        .setHashtags(input);
+    gApi.changes().id(r.getChange().getId().get()).setHashtags(input);
   }
 
-  private void assertMessage(PushOneCommit.Result r, String expectedMessage)
-      throws Exception {
+  private void assertMessage(PushOneCommit.Result r, String expectedMessage) throws Exception {
     assertThat(getLastMessage(r).message).isEqualTo(expectedMessage);
   }
 
-  private void assertNoNewMessageSince(PushOneCommit.Result r,
-      ChangeMessageInfo expected) throws Exception {
+  private void assertNoNewMessageSince(PushOneCommit.Result r, ChangeMessageInfo expected)
+      throws Exception {
     checkNotNull(expected);
     ChangeMessageInfo last = getLastMessage(r);
     assertThat(last.message).isEqualTo(expected.message);
     assertThat(last.id).isEqualTo(expected.id);
   }
 
-  private ChangeMessageInfo getLastMessage(PushOneCommit.Result r)
-      throws Exception {
-    ChangeMessageInfo lastMessage = Iterables.getLast(
-        gApi.changes().id(r.getChange().getId().get()).get().messages, null);
+  private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
+    ChangeMessageInfo lastMessage =
+        Iterables.getLast(gApi.changes().id(r.getChange().getId().get()).get().messages, null);
     assertThat(lastMessage).named(lastMessage.message).isNotNull();
     return lastMessage;
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 9dba788b..09a0a3e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -14,25 +14,92 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import com.google.gerrit.acceptance.AbstractDaemonTest;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.Util;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
 public class IndexChangeIT extends AbstractDaemonTest {
   @Test
   public void indexChange() throws Exception {
     String changeId = createChange().getChangeId();
-    adminRestSession
-        .post("/changes/" + changeId + "/index/")
-        .assertNoContent();
+    adminRestSession.post("/changes/" + changeId + "/index/").assertNoContent();
   }
 
   @Test
   public void indexChangeOnNonVisibleBranch() throws Exception {
     String changeId = createChange().getChangeId();
     blockRead("refs/heads/master");
-    userRestSession
-        .post("/changes/" + changeId + "/index/")
-        .assertNotFound();
+    userRestSession.post("/changes/" + changeId + "/index/").assertNotFound();
+  }
+
+  @Test
+  public void indexChangeAfterOwnerLosesVisibility() throws Exception {
+    // Create a test group with 2 users as members
+    TestAccount user2 = accounts.user2();
+    String group = createGroup("test");
+    gApi.groups().id(group).addMembers("admin", "user", user2.username);
+
+    // Create a project and restrict its visibility to the group
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(
+        cfg,
+        Permission.READ,
+        groupCache.get(new AccountGroup.NameKey(group)).getGroupUUID(),
+        "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // Clone it and push a change as a regular user
+    TestRepository<InMemoryRepository> repo = cloneProject(p, user);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    assertThat(result.getChange().change().getOwner()).isEqualTo(user.id);
+    String changeId = result.getChangeId();
+
+    // User can see the change and it is mergeable
+    setApiUser(user);
+    List<ChangeInfo> changes = gApi.changes().query(changeId).get();
+    assertThat(changes).hasSize(1);
+    assertThat(changes.get(0).mergeable).isNotNull();
+
+    // Other user can see the change and it is mergeable
+    setApiUser(user2);
+    changes = gApi.changes().query(changeId).get();
+    assertThat(changes).hasSize(1);
+    assertThat(changes.get(0).mergeable).isTrue();
+
+    // Remove the user from the group so they can no longer see the project
+    setApiUser(admin);
+    gApi.groups().id(group).removeMembers("user");
+
+    // User can no longer see the change
+    setApiUser(user);
+    changes = gApi.changes().query(changeId).get();
+    assertThat(changes).isEmpty();
+
+    // Reindex the change
+    setApiUser(admin);
+    gApi.changes().id(changeId).index();
+
+    // Other user can still see the change and it is still mergeable
+    setApiUser(user2);
+    changes = gApi.changes().query(changeId).get();
+    assertThat(changes).hasSize(1);
+    assertThat(changes.get(0).mergeable).isTrue();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
index b0d34f0..174280d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ListChangesOptionsIT.java
@@ -24,12 +24,10 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.common.ChangeInfo;
-
-import org.junit.Before;
-import org.junit.Test;
-
 import java.util.ArrayList;
 import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
 
 @NoHttpd
 public class ListChangesOptionsIT extends AbstractDaemonTest {
@@ -46,12 +44,12 @@
     results.add(push("new contents 2", changeId));
   }
 
-  private PushOneCommit.Result push(String content, String baseChangeId)
-      throws Exception {
+  private PushOneCommit.Result push(String content, String baseChangeId) throws Exception {
     String subject = "Change subject";
     String fileName = "a.txt";
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), testRepo, subject, fileName, content, baseChangeId);
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), testRepo, subject, fileName, content, baseChangeId);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     return r;
@@ -68,8 +66,7 @@
   public void currentRevision() throws Exception {
     ChangeInfo c = get(changeId, CURRENT_REVISION);
     assertThat(c.currentRevision).isEqualTo(commitId(2));
-    assertThat(c.revisions.keySet()).containsAllIn(
-        ImmutableSet.of(commitId(2)));
+    assertThat(c.revisions.keySet()).containsAllIn(ImmutableSet.of(commitId(2)));
     assertThat(c.revisions.get(commitId(2))._number).isEqualTo(3);
   }
 
@@ -78,8 +75,7 @@
     ChangeInfo c = get(changeId, CURRENT_REVISION, MESSAGES);
     assertThat(c.revisions).hasSize(1);
     assertThat(c.currentRevision).isEqualTo(commitId(2));
-    assertThat(c.revisions.keySet()).containsAllIn(
-        ImmutableSet.of(commitId(2)));
+    assertThat(c.revisions.keySet()).containsAllIn(ImmutableSet.of(commitId(2)));
     assertThat(c.revisions.get(commitId(2))._number).isEqualTo(3);
   }
 
@@ -87,8 +83,8 @@
   public void allRevisions() throws Exception {
     ChangeInfo c = get(changeId, ALL_REVISIONS);
     assertThat(c.currentRevision).isEqualTo(commitId(2));
-    assertThat(c.revisions.keySet()).containsAllIn(
-        ImmutableSet.of(commitId(0), commitId(1), commitId(2)));
+    assertThat(c.revisions.keySet())
+        .containsAllIn(ImmutableSet.of(commitId(0), commitId(1), commitId(2)));
     assertThat(c.revisions.get(commitId(0))._number).isEqualTo(1);
     assertThat(c.revisions.get(commitId(1))._number).isEqualTo(2);
     assertThat(c.revisions.get(commitId(2))._number).isEqualTo(3);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index b66358f..36f8452 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -33,9 +33,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
-
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -47,8 +45,7 @@
   public void moveChangeWithShortRef() throws Exception {
     // Move change to a different branch using short ref name
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     move(r.getChangeId(), newBranch.getShortName());
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
@@ -58,8 +55,7 @@
   public void moveChangeWithFullRef() throws Exception {
     // Move change to a different branch using full ref name
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     move(r.getChangeId(), newBranch.get());
     assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
@@ -69,8 +65,7 @@
   public void moveChangeWithMessage() throws Exception {
     // Provide a message using --message flag
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     String moveMessage = "Moving for the move test";
     move(r.getChangeId(), newBranch.get(), moveMessage);
@@ -79,8 +74,7 @@
     expectedMessage.append("Change destination moved from master to moveTest");
     expectedMessage.append("\n\n");
     expectedMessage.append(moveMessage);
-    assertThat(r.getChange().messages().get(1).getMessage())
-        .isEqualTo(expectedMessage.toString());
+    assertThat(r.getChange().messages().get(1).getMessage()).isEqualTo(expectedMessage.toString());
   }
 
   @Test
@@ -96,14 +90,16 @@
   public void moveChangeToSameChangeId() throws Exception {
     // Move change to a branch with existing change with same change ID
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     int changeNum = r.getChange().change().getChangeId();
     createChange(newBranch.get(), r.getChangeId());
     exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Destination " + newBranch.getShortName()
-        + " has a different change with same change key " + r.getChangeId());
+    exception.expectMessage(
+        "Destination "
+            + newBranch.getShortName()
+            + " has a different change with same change key "
+            + r.getChangeId());
     move(changeNum, newBranch.get());
   }
 
@@ -111,11 +107,10 @@
   public void moveChangeToNonExistentRef() throws Exception {
     // Move change to a non-existing branch
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch = new Branch.NameKey(
-        r.getChange().change().getProject(), "does_not_exist");
+    Branch.NameKey newBranch =
+        new Branch.NameKey(r.getChange().change().getProject(), "does_not_exist");
     exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Destination " + newBranch.get()
-        + " not found in the project");
+    exception.expectMessage("Destination " + newBranch.get() + " not found in the project");
     move(r.getChangeId(), newBranch.get());
   }
 
@@ -123,8 +118,7 @@
   public void moveClosedChange() throws Exception {
     // Move a change which is not open
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     merge(r);
     exception.expect(ResourceConflictException.class);
@@ -141,17 +135,16 @@
     TestRepository<?>.CommitBuilder commitBuilder =
         testRepo.branch("HEAD").commit().insertChangeId();
     commitBuilder
-      .parent(r1.getCommit())
-      .parent(r2.getCommit())
-      .message("Move change Merge Commit")
-      .author(admin.getIdent())
-      .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
+        .parent(r1.getCommit())
+        .parent(r2.getCommit())
+        .message("Move change Merge Commit")
+        .author(admin.getIdent())
+        .committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
     RevCommit c = commitBuilder.create();
     pushHead(testRepo, "refs/for/master", false, false);
 
     // Try to move the merge commit to another branch
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r1.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r1.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("Merge commit cannot be moved");
@@ -165,8 +158,9 @@
     Branch.NameKey newBranch =
         new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
     createBranch(newBranch);
-    block(Permission.PUSH,
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
+    block(
+        Permission.PUSH,
+        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
         "refs/for/" + newBranch.get());
     exception.expect(AuthException.class);
     exception.expectMessage("Move not permitted");
@@ -177,11 +171,11 @@
   public void moveChangeFromBranchWithoutAbandonPerms() throws Exception {
     // Move change for which user does not have abandon permissions
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    block(Permission.ABANDON,
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
+    block(
+        Permission.ABANDON,
+        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
         r.getChange().change().getDest().get());
     setApiUser(user);
     exception.expect(AuthException.class);
@@ -199,20 +193,15 @@
     int changeNum = r.getChange().change().getChangeId();
 
     // Create a branch with that same commit
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     BranchInput bi = new BranchInput();
     bi.revision = r.getCommit().name();
-    gApi.projects()
-      .name(newBranch.getParentKey().get())
-      .branch(newBranch.get())
-      .create(bi);
+    gApi.projects().name(newBranch.getParentKey().get()).branch(newBranch.get()).create(bi);
 
     // Try to move the change to the branch with the same commit
     exception.expect(ResourceConflictException.class);
-    exception
-        .expectMessage("Current patchset revision is reachable from tip of "
-            + newBranch.get());
+    exception.expectMessage(
+        "Current patchset revision is reachable from tip of " + newBranch.get());
     move(changeNum, newBranch.get());
   }
 
@@ -220,17 +209,15 @@
   public void moveChangeWithCurrentPatchSetLocked() throws Exception {
     // Move change that is locked
     PushOneCommit.Result r = createChange();
-    Branch.NameKey newBranch =
-        new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
+    Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
 
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     LabelType patchSetLock = Util.patchSetLock();
     cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
-    AccountGroup.UUID registeredUsers =
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers,
-        "refs/heads/*");
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(
+        cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers, "refs/heads/*");
     saveProjectConfig(cfg);
     grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
@@ -240,28 +227,23 @@
     move(r.getChangeId(), newBranch.get());
   }
 
-  private void move(int changeNum, String destination)
-      throws RestApiException {
+  private void move(int changeNum, String destination) throws RestApiException {
     gApi.changes().id(changeNum).move(destination);
   }
 
-  private void move(String changeId, String destination)
-      throws RestApiException {
+  private void move(String changeId, String destination) throws RestApiException {
     gApi.changes().id(changeId).move(destination);
   }
 
-  private void move(String changeId, String destination, String message)
-      throws RestApiException {
+  private void move(String changeId, String destination, String message) throws RestApiException {
     MoveInput in = new MoveInput();
     in.destinationBranch = destination;
     in.message = message;
     gApi.changes().id(changeId).move(in);
   }
 
-  private PushOneCommit.Result createChange(String branch, String changeId)
-      throws Exception {
-    PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+  private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
     PushOneCommit.Result result = push.to("refs/for/" + branch);
     result.assertOkStatus();
     return result;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index af43373..26a91aa 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -19,27 +19,32 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.Submit.TestSubmitInput;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.strategy.CommitMergeStatus;
-
+import com.google.inject.Inject;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
-import java.util.List;
-
 public class SubmitByCherryPickIT extends AbstractSubmit {
+  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -53,8 +58,7 @@
     submit(change.getChangeId());
     assertCherryPick(testRepo, false);
     RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParent(0))
-      .isEqualTo(change.getCommit().getParent(0));
+    assertThat(newHead.getParent(0)).isEqualTo(change.getCommit().getParent(0));
 
     assertRefUpdatedEvents(initialHead, newHead);
     assertChangeMergedEvents(change.getChangeId(), newHead.name());
@@ -63,14 +67,12 @@
   @Test
   public void submitWithCherryPick() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
     RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     submit(change2.getChangeId());
     assertCherryPick(testRepo, false);
     RevCommit newHead = getRemoteHead();
@@ -82,63 +84,95 @@
     assertPersonEquals(admin.getIdent(), newHead.getAuthorIdent());
     assertPersonEquals(admin.getIdent(), newHead.getCommitterIdent());
 
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, newHead);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), newHead.name());
+    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit, headAfterFirstSubmit, newHead);
+    assertChangeMergedEvents(
+        change.getChangeId(), headAfterFirstSubmit.name(), change2.getChangeId(), newHead.name());
+  }
+
+  @Test
+  public void changeMessageOnSubmit() throws Exception {
+    PushOneCommit.Result change = createChange();
+    RegistrationHandle handle =
+        changeMessageModifiers.add(
+            new ChangeMessageModifier() {
+              @Override
+              public String onSubmit(
+                  String newCommitMessage,
+                  RevCommit original,
+                  RevCommit mergeTip,
+                  Branch.NameKey destination) {
+                return newCommitMessage + "Custom: " + destination.get();
+              }
+            });
+    try {
+      submit(change.getChangeId());
+    } finally {
+      handle.remove();
+    }
+    testRepo.git().fetch().setRemote("origin").call();
+    ChangeInfo info = get(change.getChangeId());
+    RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
+    testRepo.getRevWalk().parseBody(c);
+    assertThat(c.getFooterLines("Custom")).containsExactly("refs/heads/master");
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).hasSize(1);
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
     RevCommit headAfterFirstSubmit = getRemoteHead();
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
     RevCommit headAfterSecondSubmit = getRemoteHead();
 
     testRepo.reset(change.getCommit());
-    PushOneCommit.Result change3 =
-        createChange("Change 3", "a.txt", "bbb\nccc\n");
+    PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, true);
     RevCommit headAfterThirdSubmit = getRemoteHead();
-    assertThat(headAfterThirdSubmit.getParent(0))
-        .isEqualTo(headAfterSecondSubmit);
+    assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
     assertSubmitter(change2.getChangeId(), 1);
     assertSubmitter(change2.getChangeId(), 2);
 
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit,
-        headAfterSecondSubmit, headAfterThirdSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name(),
-        change3.getChangeId(), headAfterThirdSubmit.name());
+    assertRefUpdatedEvents(
+        initialHead,
+        headAfterFirstSubmit,
+        headAfterFirstSubmit,
+        headAfterSecondSubmit,
+        headAfterSecondSubmit,
+        headAfterThirdSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change3.getChangeId(),
+        headAfterThirdSubmit.name());
   }
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge_Conflict() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
     RevCommit newHead = getRemoteHead();
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "a.txt", "other content");
-    submitWithConflict(change2.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n" +
-        "Change " + change2.getChange().getId() + ": Change could not be " +
-        "merged due to a path conflict. Please rebase the change locally and " +
-        "upload the rebased commit for review.");
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2.getChange().getId()
+            + ": Change could not be "
+            + "merged due to a path conflict. Please rebase the change locally and "
+            + "upload the rebased commit for review.");
 
     assertThat(getRemoteHead()).isEqualTo(newHead);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -151,48 +185,49 @@
   @Test
   public void submitOutOfOrder() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
     RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
-    PushOneCommit.Result change3 =
-        createChange("Change 3", "c.txt", "different content");
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "different content");
     submit(change3.getChangeId());
     assertCherryPick(testRepo, false);
     RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit.getParent(0))
-        .isEqualTo(headAfterFirstSubmit);
+    assertThat(headAfterSecondSubmit.getParent(0)).isEqualTo(headAfterFirstSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterSecondSubmit);
     assertSubmitter(change3.getChangeId(), 1);
     assertSubmitter(change3.getChangeId(), 2);
 
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change3.getChangeId(), headAfterSecondSubmit.name());
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change3.getChangeId(),
+        headAfterSecondSubmit.name());
   }
 
   @Test
   public void submitOutOfOrder_Conflict() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
     RevCommit newHead = getRemoteHead();
     testRepo.reset(initialHead);
     createChange("Change 2", "b.txt", "other content");
-    PushOneCommit.Result change3 =
-        createChange("Change 3", "b.txt", "different content");
-    submitWithConflict(change3.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n" +
-        "Change " + change3.getChange().getId() + ": Change could not be " +
-        "merged due to a path conflict. Please rebase the change locally and " +
-        "upload the rebased commit for review.");
+    PushOneCommit.Result change3 = createChange("Change 3", "b.txt", "different content");
+    submitWithConflict(
+        change3.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change3.getChange().getId()
+            + ": Change could not be "
+            + "merged due to a path conflict. Please rebase the change locally and "
+            + "upload the rebased commit for review.");
 
     assertThat(getRemoteHead()).isEqualTo(newHead);
     assertCurrentRevision(change3.getChangeId(), 1, change3.getCommit());
@@ -220,8 +255,7 @@
     submit(change3.getChangeId());
 
     List<RevCommit> log = getRemoteLog();
-    assertThat(log.get(0).getShortMessage()).isEqualTo(
-        change3.getCommit().getShortMessage());
+    assertThat(log.get(0).getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
     assertThat(log.get(1).getId()).isEqualTo(initialHead.getId());
 
     assertNew(change.getChangeId());
@@ -250,20 +284,21 @@
 
     // change is the new tip.
     List<RevCommit> log = getRemoteLog();
-    assertThat(log.get(0).getShortMessage()).isEqualTo(
-        change.getCommit().getShortMessage());
+    assertThat(log.get(0).getShortMessage()).isEqualTo(change.getCommit().getShortMessage());
     assertThat(log.get(0).getParent(0)).isEqualTo(log.get(1));
 
-    assertThat(log.get(1).getShortMessage()).isEqualTo(
-        change2.getCommit().getShortMessage());
+    assertThat(log.get(1).getShortMessage()).isEqualTo(change2.getCommit().getShortMessage());
     assertThat(log.get(1).getParent(0)).isEqualTo(log.get(2));
 
     assertThat(log.get(2).getId()).isEqualTo(initialHead.getId());
 
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change2.getChangeId(), headAfterFirstSubmit.name(),
-        change.getChangeId(), headAfterSecondSubmit.name());
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change2.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change.getChangeId(),
+        headAfterSecondSubmit.name());
   }
 
   @Test
@@ -277,11 +312,14 @@
 
     // Submit fails; change2 contains the delta "b1" -> "b2", which cannot be
     // applied against tip.
-    submitWithConflict(change2.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n" +
-        "Change " + change2.getChange().getId() + ": Change could not be " +
-        "merged due to a path conflict. Please rebase the change locally and " +
-        "upload the rebased commit for review.");
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2.getChange().getId()
+            + ": Change could not be "
+            + "merged due to a path conflict. Please rebase the change locally and "
+            + "upload the rebased commit for review.");
 
     ChangeInfo info3 = get(change2.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info3.status).isEqualTo(ChangeStatus.NEW);
@@ -341,26 +379,29 @@
         .isEqualTo(CommitMergeStatus.SKIPPED_IDENTICAL_TREE.getMessage());
 
     assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterFirstSubmit.name());
+    assertChangeMergedEvents(
+        change1.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterFirstSubmit.name());
   }
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
     RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
     Change.Id id2 = change2.getChange().getId();
-    SubmitInput failAfterRefUpdates =
-        new TestSubmitInput(new SubmitInput(), true);
-    submit(change2.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates");
+    SubmitInput failAfterRefUpdates = new TestSubmitInput(new SubmitInput(), true);
+    submit(
+        change2.getChangeId(),
+        failAfterRefUpdates,
+        ResourceConflictException.class,
+        "Failing after ref updates");
     RevCommit headAfterFailedSubmit = getRemoteHead();
 
     // Bad: ref advanced but change wasn't updated.
@@ -380,11 +421,9 @@
       rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
       assertThat(rev2).isNotNull();
       assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0))
-          .isEqualTo(headAfterFirstSubmit);
+      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
 
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(rev2);
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
     }
 
     submit(change2.getChangeId());
@@ -400,16 +439,18 @@
     assertThat(ps2).isNotNull();
     assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
     assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully cherry-picked as "
-            + rev2.name() + " by Administrator");
+        .isEqualTo(
+            "Change has been successfully cherry-picked as " + rev2.name() + " by Administrator");
 
     try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(rev2);
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev2);
     }
 
     assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name());
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 65f3fc8..65ad499 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.Submit.TestSubmitInput;
-
+import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -38,8 +38,6 @@
 import org.eclipse.jgit.transport.PushResult;
 import org.junit.Test;
 
-import java.util.Map;
-
 public class SubmitByFastForwardIT extends AbstractSubmit {
 
   @Override
@@ -89,9 +87,8 @@
     assertSubmittedTogether(id3, id3, id2, id1);
 
     assertRefUpdatedEvents(initialHead, updatedHead);
-    assertChangeMergedEvents(id1, updatedHead.name(),
-        id2, updatedHead.name(),
-        id3, updatedHead.name());
+    assertChangeMergedEvents(
+        id1, updatedHead.name(), id2, updatedHead.name(), id3, updatedHead.name());
   }
 
   @Test
@@ -101,9 +98,12 @@
     PushOneCommit.Result change2 = createChange();
 
     Change.Id id1 = change1.getPatchSetId().getParentKey();
-    submitWithConflict(change2.getChangeId(),
+    submitWithConflict(
+        change2.getChangeId(),
         "Failed to submit 2 changes due to the following problems:\n"
-        + "Change " + id1 + ": needs Code-Review");
+            + "Change "
+            + id1
+            + ": needs Code-Review");
 
     RevCommit updatedHead = getRemoteHead();
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
@@ -114,14 +114,12 @@
   @Test
   public void submitFastForwardNotPossible_Conflict() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
 
     RevCommit headAfterFirstSubmit = getRemoteHead();
     testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "other content");
 
     approve(change2.getChangeId());
     Map<String, ActionInfo> actions = getActions(change2.getChangeId());
@@ -130,11 +128,14 @@
     ActionInfo info = actions.get("submit");
     assertThat(info.enabled).isNull();
 
-    submitWithConflict(change2.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n" +
-        "Change " + change2.getChange().getId() + ": Project policy requires " +
-        "all submissions to be a fast-forward. Please rebase the change " +
-        "locally and upload again for review.");
+    submitWithConflict(
+        change2.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2.getChange().getId()
+            + ": Project policy requires "
+            + "all submissions to be a fast-forward. Please rebase the change "
+            + "locally and upload again for review.");
     assertThat(getRemoteHead()).isEqualTo(headAfterFirstSubmit);
     assertSubmitter(change.getChangeId(), 1);
 
@@ -146,10 +147,12 @@
   public void repairChangeStateAfterFailure() throws Exception {
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     Change.Id id = change.getChange().getId();
-    SubmitInput failAfterRefUpdates =
-        new TestSubmitInput(new SubmitInput(), true);
-    submit(change.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates");
+    SubmitInput failAfterRefUpdates = new TestSubmitInput(new SubmitInput(), true);
+    submit(
+        change.getChangeId(),
+        failAfterRefUpdates,
+        ResourceConflictException.class,
+        "Failing after ref updates");
 
     // Bad: ref advanced but change wasn't updated.
     PatchSet.Id psId = new PatchSet.Id(id, 1);
@@ -162,8 +165,7 @@
         RevWalk rw = new RevWalk(repo)) {
       rev = repo.exactRef(psId.toRefName()).getObjectId();
       assertThat(rev).isNotNull();
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(rev);
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev);
     }
 
     submit(change.getChangeId());
@@ -176,8 +178,7 @@
         .isEqualTo("Change has been successfully merged by Administrator");
 
     try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(rev);
+      assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(rev);
     }
 
     assertRefUpdatedEvents();
@@ -191,15 +192,11 @@
     grant(Permission.CREATE, project, "refs/heads/*");
     grant(Permission.PUSH, project, "refs/heads/experimental");
 
-    RevCommit c1 = commitBuilder()
-        .add("b.txt", "1")
-        .message("commit at tip")
-        .create();
+    RevCommit c1 = commitBuilder().add("b.txt", "1").message("commit at tip").create();
     String id1 = GitUtil.getChangeId(testRepo, c1).get();
 
     PushResult r1 = pushHead(testRepo, "refs/for/master", false);
-    assertThat(r1.getRemoteUpdate("refs/for/master").getNewObjectId())
-        .isEqualTo(c1.getId());
+    assertThat(r1.getRemoteUpdate("refs/for/master").getNewObjectId()).isEqualTo(c1.getId());
 
     PushResult r2 = pushHead(testRepo, "refs/heads/experimental", false);
     assertThat(r2.getRemoteUpdate("refs/heads/experimental").getNewObjectId())
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
index 315971f..4af27ab 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeAlwaysIT.java
@@ -18,7 +18,6 @@
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.client.SubmitType;
-
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
@@ -57,12 +56,11 @@
     // The remote head should now be a merge of the previous head
     // and "Change 1"
     RevCommit headAfterFirstSubmit = getRemoteLog().get(0);
-    assertThat(headAfterFirstSubmit.getParent(1).getShortMessage()).isEqualTo(
-        change.getCommit().getShortMessage());
-    assertThat(headAfterFirstSubmit.getParent(0).getShortMessage()).isEqualTo(
-        initialHead.getShortMessage());
-    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(
-        initialHead.getId());
+    assertThat(headAfterFirstSubmit.getParent(1).getShortMessage())
+        .isEqualTo(change.getCommit().getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getShortMessage())
+        .isEqualTo(initialHead.getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(initialHead.getId());
 
     // Submit three changes at the same time
     PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
@@ -79,21 +77,24 @@
     // The remote head should now be a merge of the new head after
     // the previous submit, and "Change 4".
     RevCommit headAfterSecondSubmit = getRemoteLog().get(0);
-    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage()).isEqualTo(
-        change4.getCommit().getShortMessage());
-    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage()).isEqualTo(
-        headAfterFirstSubmit.getShortMessage());
-    assertThat(headAfterSecondSubmit.getParent(0).getId()).isEqualTo(
-        headAfterFirstSubmit.getId());
+    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage())
+        .isEqualTo(change4.getCommit().getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage())
+        .isEqualTo(headAfterFirstSubmit.getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(0).getId()).isEqualTo(headAfterFirstSubmit.getId());
     assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
-    assertPersonEquals(serverIdent.get(),
-        headAfterSecondSubmit.getCommitterIdent());
+    assertPersonEquals(serverIdent.get(), headAfterSecondSubmit.getCommitterIdent());
 
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name(),
-        change3.getChangeId(), headAfterSecondSubmit.name(),
-        change4.getChangeId(), headAfterSecondSubmit.name());
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change3.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change4.getChangeId(),
+        headAfterSecondSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 29fda2d..0ac263f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -24,15 +25,26 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-
+import java.io.File;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+import org.apache.commons.compress.archivers.ArchiveStreamFactory;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
-import java.util.List;
-
 public class SubmitByMergeIfNecessaryIT extends AbstractSubmitByMerge {
 
   @Override
@@ -75,10 +87,9 @@
     submit(change2.getChangeId());
 
     RevCommit headAfterFirstSubmit = getRemoteLog().get(0);
-    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo(
-        change2.getCommit().getShortMessage());
-    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(
-        initialHead.getId());
+    assertThat(headAfterFirstSubmit.getShortMessage())
+        .isEqualTo(change2.getCommit().getShortMessage());
+    assertThat(headAfterFirstSubmit.getParent(0).getId()).isEqualTo(initialHead.getId());
     assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getAuthorIdent());
     assertPersonEquals(admin.getIdent(), headAfterFirstSubmit.getCommitterIdent());
 
@@ -88,10 +99,10 @@
     submit(change5.getChangeId());
 
     RevCommit headAfterSecondSubmit = getRemoteLog().get(0);
-    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage()).isEqualTo(
-        change5.getCommit().getShortMessage());
-    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage()).isEqualTo(
-        change2.getCommit().getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(1).getShortMessage())
+        .isEqualTo(change5.getCommit().getShortMessage());
+    assertThat(headAfterSecondSubmit.getParent(0).getShortMessage())
+        .isEqualTo(change2.getCommit().getShortMessage());
 
     assertPersonEquals(admin.getIdent(), headAfterSecondSubmit.getAuthorIdent());
     assertPersonEquals(serverIdent.get(), headAfterSecondSubmit.getCommitterIdent());
@@ -101,12 +112,17 @@
 
     // The two submit operations should have resulted in two ref-update events
     // and three change-merged events.
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change2.getChangeId(), headAfterFirstSubmit.name(),
-        change3.getChangeId(), headAfterSecondSubmit.name(),
-        change4.getChangeId(), headAfterSecondSubmit.name(),
-        change5.getChangeId(), headAfterSecondSubmit.name());
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change2.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change3.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change4.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change5.getChangeId(),
+        headAfterSecondSubmit.name());
   }
 
   @Test
@@ -122,47 +138,67 @@
     TestRepository<?> repo2 = cloneProject(p2);
     TestRepository<?> repo3 = cloneProject(p3);
 
-    PushOneCommit.Result change1a = createChange(repo1, "master",
-        "An ancestor of the change we want to submit",
-        "a.txt", "1", "dependent-topic");
-    PushOneCommit.Result change1b = createChange(repo1, "master",
-        "We're interested in submitting this change",
-        "a.txt", "2", "topic-to-submit");
+    PushOneCommit.Result change1a =
+        createChange(
+            repo1,
+            "master",
+            "An ancestor of the change we want to submit",
+            "a.txt",
+            "1",
+            "dependent-topic");
+    PushOneCommit.Result change1b =
+        createChange(
+            repo1,
+            "master",
+            "We're interested in submitting this change",
+            "a.txt",
+            "2",
+            "topic-to-submit");
 
-    PushOneCommit.Result change2a = createChange(repo2, "master",
-        "indirection level 1",
-        "a.txt", "1", "topic-indirect");
-    PushOneCommit.Result change2b = createChange(repo2, "master",
-        "should go in with first change",
-        "a.txt", "2", "dependent-topic");
+    PushOneCommit.Result change2a =
+        createChange(repo2, "master", "indirection level 1", "a.txt", "1", "topic-indirect");
+    PushOneCommit.Result change2b =
+        createChange(
+            repo2, "master", "should go in with first change", "a.txt", "2", "dependent-topic");
 
-    PushOneCommit.Result change3 = createChange(repo3, "master",
-        "indirection level 2",
-        "a.txt", "1", "topic-indirect");
+    PushOneCommit.Result change3 =
+        createChange(repo3, "master", "indirection level 2", "a.txt", "1", "topic-indirect");
 
     approve(change1a.getChangeId());
     approve(change2a.getChangeId());
     approve(change2b.getChangeId());
     approve(change3.getChangeId());
+
+    // get a preview before submitting:
+    Map<Branch.NameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
     submit(change1b.getChangeId());
 
-    RevCommit tip1  = getRemoteLog(p1, "master").get(0);
-    RevCommit tip2  = getRemoteLog(p2, "master").get(0);
-    RevCommit tip3  = getRemoteLog(p3, "master").get(0);
+    RevCommit tip1 = getRemoteLog(p1, "master").get(0);
+    RevCommit tip2 = getRemoteLog(p2, "master").get(0);
+    RevCommit tip3 = getRemoteLog(p3, "master").get(0);
 
-    assertThat(tip1.getShortMessage()).isEqualTo(
-        change1b.getCommit().getShortMessage());
+    assertThat(tip1.getShortMessage()).isEqualTo(change1b.getCommit().getShortMessage());
 
     if (isSubmitWholeTopicEnabled()) {
-      assertThat(tip2.getShortMessage()).isEqualTo(
-          change2b.getCommit().getShortMessage());
-      assertThat(tip3.getShortMessage()).isEqualTo(
-          change3.getCommit().getShortMessage());
+      assertThat(tip2.getShortMessage()).isEqualTo(change2b.getCommit().getShortMessage());
+      assertThat(tip3.getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
+
+      // check that the preview matched what happened:
+      assertThat(preview).hasSize(3);
+
+      assertThat(preview).containsKey(new Branch.NameKey(p1, "refs/heads/master"));
+      assertTrees(p1, preview);
+
+      assertThat(preview).containsKey(new Branch.NameKey(p2, "refs/heads/master"));
+      assertTrees(p2, preview);
+
+      assertThat(preview).containsKey(new Branch.NameKey(p3, "refs/heads/master"));
+      assertTrees(p3, preview);
     } else {
-      assertThat(tip2.getShortMessage()).isEqualTo(
-          initialHead2.getShortMessage());
-      assertThat(tip3.getShortMessage()).isEqualTo(
-          initialHead3.getShortMessage());
+      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
+      assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
+      assertThat(preview).hasSize(1);
+      assertThat(preview.get(new Branch.NameKey(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
@@ -180,34 +216,41 @@
     RevCommit initialHead2 = getRemoteHead(p2, "master");
     RevCommit initialHead3 = getRemoteHead(p3, "master");
 
-    PushOneCommit.Result change1a = createChange(repo1, "master",
-        "An ancestor of the change we want to submit",
-        "a.txt", "1", "dependent-topic");
-    PushOneCommit.Result change1b = createChange(repo1, "master",
-        "we're interested to submit this change",
-        "a.txt", "2", "topic-to-submit");
+    PushOneCommit.Result change1a =
+        createChange(
+            repo1,
+            "master",
+            "An ancestor of the change we want to submit",
+            "a.txt",
+            "1",
+            "dependent-topic");
+    PushOneCommit.Result change1b =
+        createChange(
+            repo1,
+            "master",
+            "we're interested to submit this change",
+            "a.txt",
+            "2",
+            "topic-to-submit");
 
-    PushOneCommit.Result change2a = createChange(repo2, "master",
-        "indirection level 2a",
-        "a.txt", "1", "topic-indirect");
-    PushOneCommit.Result change2b = createChange(repo2, "master",
-        "should go in with first change",
-        "a.txt", "2", "dependent-topic");
+    PushOneCommit.Result change2a =
+        createChange(repo2, "master", "indirection level 2a", "a.txt", "1", "topic-indirect");
+    PushOneCommit.Result change2b =
+        createChange(
+            repo2, "master", "should go in with first change", "a.txt", "2", "dependent-topic");
 
-    PushOneCommit.Result change3 = createChange(repo3, "master",
-        "indirection level 2b",
-        "a.txt", "1", "topic-indirect");
+    PushOneCommit.Result change3 =
+        createChange(repo3, "master", "indirection level 2b", "a.txt", "1", "topic-indirect");
 
     // Create a merge conflict for change3 which is only indirectly related
     // via topics.
     repo3.reset(initialHead3);
-    PushOneCommit.Result change3Conflict = createChange(repo3, "master",
-        "conflicting change",
-        "a.txt", "2\n2", "conflicting-topic");
+    PushOneCommit.Result change3Conflict =
+        createChange(repo3, "master", "conflicting change", "a.txt", "2\n2", "conflicting-topic");
     submit(change3Conflict.getChangeId());
     RevCommit tipConflict = getRemoteLog(p3, "master").get(0);
-    assertThat(tipConflict.getShortMessage()).isEqualTo(
-        change3Conflict.getCommit().getShortMessage());
+    assertThat(tipConflict.getShortMessage())
+        .isEqualTo(change3Conflict.getCommit().getShortMessage());
 
     approve(change1a.getChangeId());
     approve(change2a.getChangeId());
@@ -215,36 +258,44 @@
     approve(change3.getChangeId());
 
     if (isSubmitWholeTopicEnabled()) {
-      submitWithConflict(change1b.getChangeId(),
-          "Failed to submit 5 changes due to the following problems:\n" +
-          "Change " + change3.getChange().getId() + ": Change could not be " +
-          "merged due to a path conflict. Please rebase the change locally " +
-          "and upload the rebased commit for review.");
+      String msg =
+          "Failed to submit 5 changes due to the following problems:\n"
+              + "Change "
+              + change3.getChange().getId()
+              + ": Change could not be "
+              + "merged due to a path conflict. Please rebase the change locally "
+              + "and upload the rebased commit for review.";
+
+      // Get a preview before submitting:
+      try (BinaryResult r = submitPreview(change1b.getChangeId())) {
+        // We cannot just use the ExpectedException infrastructure as provided
+        // by AbstractDaemonTest, as then we'd stop early and not test the
+        // actual submit.
+
+        fail("expected failure");
+      } catch (RestApiException e) {
+        assertThat(e.getMessage()).isEqualTo(msg);
+      }
+      submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
     }
 
-    RevCommit tip1  = getRemoteLog(p1, "master").get(0);
-    RevCommit tip2  = getRemoteLog(p2, "master").get(0);
-    RevCommit tip3  = getRemoteLog(p3, "master").get(0);
+    RevCommit tip1 = getRemoteLog(p1, "master").get(0);
+    RevCommit tip2 = getRemoteLog(p2, "master").get(0);
+    RevCommit tip3 = getRemoteLog(p3, "master").get(0);
     if (isSubmitWholeTopicEnabled()) {
-      assertThat(tip1.getShortMessage()).isEqualTo(
-          initialHead1.getShortMessage());
-      assertThat(tip2.getShortMessage()).isEqualTo(
-          initialHead2.getShortMessage());
-      assertThat(tip3.getShortMessage()).isEqualTo(
-          change3Conflict.getCommit().getShortMessage());
+      assertThat(tip1.getShortMessage()).isEqualTo(initialHead1.getShortMessage());
+      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
+      assertThat(tip3.getShortMessage()).isEqualTo(change3Conflict.getCommit().getShortMessage());
       assertNoSubmitter(change1a.getChangeId(), 1);
       assertNoSubmitter(change2a.getChangeId(), 1);
       assertNoSubmitter(change2b.getChangeId(), 1);
       assertNoSubmitter(change3.getChangeId(), 1);
     } else {
-      assertThat(tip1.getShortMessage()).isEqualTo(
-          change1b.getCommit().getShortMessage());
-      assertThat(tip2.getShortMessage()).isEqualTo(
-          initialHead2.getShortMessage());
-      assertThat(tip3.getShortMessage()).isEqualTo(
-          change3Conflict.getCommit().getShortMessage());
+      assertThat(tip1.getShortMessage()).isEqualTo(change1b.getCommit().getShortMessage());
+      assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
+      assertThat(tip3.getShortMessage()).isEqualTo(change3Conflict.getCommit().getShortMessage());
       assertNoSubmitter(change2a.getChangeId(), 1);
       assertNoSubmitter(change2b.getChangeId(), 1);
       assertNoSubmitter(change3.getChangeId(), 1);
@@ -255,140 +306,141 @@
   public void submitWithMergedAncestorsOnOtherBranch() throws Exception {
     RevCommit initialHead = getRemoteHead();
 
-    PushOneCommit.Result change1 = createChange(testRepo,  "master",
-        "base commit",
-        "a.txt", "1", "");
+    PushOneCommit.Result change1 =
+        createChange(testRepo, "master", "base commit", "a.txt", "1", "");
     submit(change1.getChangeId());
     RevCommit headAfterFirstSubmit = getRemoteHead();
 
-    gApi.projects()
-        .name(project.get())
-        .branch("branch")
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
-    PushOneCommit.Result change2 = createChange(testRepo,  "master",
-        "We want to commit this to master first",
-        "a.txt", "2", "");
+    PushOneCommit.Result change2 =
+        createChange(
+            testRepo, "master", "We want to commit this to master first", "a.txt", "2", "");
 
     submit(change2.getChangeId());
 
     RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
-    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo(
-        change2.getCommit().getShortMessage());
+    assertThat(headAfterSecondSubmit.getShortMessage())
+        .isEqualTo(change2.getCommit().getShortMessage());
 
     RevCommit tip2 = getRemoteLog(project, "branch").get(0);
-    assertThat(tip2.getShortMessage()).isEqualTo(
-        change1.getCommit().getShortMessage());
+    assertThat(tip2.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
 
-    PushOneCommit.Result change3 = createChange(testRepo,  "branch",
-        "This commit is based on master, which includes change2, "
-        + "but is targeted at branch, which doesn't include it.",
-        "a.txt", "3", "");
+    PushOneCommit.Result change3 =
+        createChange(
+            testRepo,
+            "branch",
+            "This commit is based on master, which includes change2, "
+                + "but is targeted at branch, which doesn't include it.",
+            "a.txt",
+            "3",
+            "");
 
     submit(change3.getChangeId());
 
     List<RevCommit> log3 = getRemoteLog(project, "branch");
-    assertThat(log3.get(0).getShortMessage()).isEqualTo(
-        change3.getCommit().getShortMessage());
-    assertThat(log3.get(1).getShortMessage()).isEqualTo(
-        change2.getCommit().getShortMessage());
+    assertThat(log3.get(0).getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
+    assertThat(log3.get(1).getShortMessage()).isEqualTo(change2.getCommit().getShortMessage());
 
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name());
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
+    assertChangeMergedEvents(
+        change1.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name());
   }
 
   @Test
   public void submitWithOpenAncestorsOnOtherBranch() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 = createChange(testRepo, "master",
-        "base commit",
-        "a.txt", "1", "");
+    PushOneCommit.Result change1 =
+        createChange(testRepo, "master", "base commit", "a.txt", "1", "");
     submit(change1.getChangeId());
     RevCommit headAfterFirstSubmit = getRemoteHead();
 
-    gApi.projects()
-        .name(project.get())
-        .branch("branch")
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
 
-    PushOneCommit.Result change2 = createChange(testRepo, "master",
-        "We want to commit this to master first",
-        "a.txt", "2", "");
+    PushOneCommit.Result change2 =
+        createChange(
+            testRepo, "master", "We want to commit this to master first", "a.txt", "2", "");
 
     approve(change2.getChangeId());
 
     RevCommit tip1 = getRemoteLog(project, "master").get(0);
-    assertThat(tip1.getShortMessage()).isEqualTo(
-        change1.getCommit().getShortMessage());
+    assertThat(tip1.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
 
     RevCommit tip2 = getRemoteLog(project, "branch").get(0);
-    assertThat(tip2.getShortMessage()).isEqualTo(
-        change1.getCommit().getShortMessage());
+    assertThat(tip2.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
 
-    PushOneCommit.Result change3a = createChange(testRepo, "branch",
-        "This commit is based on change2 pending for master, "
-        + "but is targeted itself at branch, which doesn't include it.",
-        "a.txt", "3", "a-topic-here");
+    PushOneCommit.Result change3a =
+        createChange(
+            testRepo,
+            "branch",
+            "This commit is based on change2 pending for master, "
+                + "but is targeted itself at branch, which doesn't include it.",
+            "a.txt",
+            "3",
+            "a-topic-here");
 
     Project.NameKey p3 = createProject("project-related-to-change3");
     TestRepository<?> repo3 = cloneProject(p3);
     RevCommit repo3Head = getRemoteHead(p3, "master");
-    PushOneCommit.Result change3b = createChange(repo3, "master",
-        "some accompanying changes for change3a in another repo "
-        + "tied together via topic",
-        "a.txt", "1", "a-topic-here");
+    PushOneCommit.Result change3b =
+        createChange(
+            repo3,
+            "master",
+            "some accompanying changes for change3a in another repo tied together via topic",
+            "a.txt",
+            "1",
+            "a-topic-here");
     approve(change3b.getChangeId());
 
     String cnt = isSubmitWholeTopicEnabled() ? "2 changes" : "1 change";
-    submitWithConflict(change3a.getChangeId(),
-        "Failed to submit " + cnt + " due to the following problems:\n"
-        + "Change " + change3a.getChange().getId() + ": depends on change that"
-        + " was not submitted");
+    submitWithConflict(
+        change3a.getChangeId(),
+        "Failed to submit "
+            + cnt
+            + " due to the following problems:\n"
+            + "Change "
+            + change3a.getChange().getId()
+            + ": depends on change that"
+            + " was not submitted");
 
     RevCommit tipbranch = getRemoteLog(project, "branch").get(0);
-    assertThat(tipbranch.getShortMessage()).isEqualTo(
-        change1.getCommit().getShortMessage());
+    assertThat(tipbranch.getShortMessage()).isEqualTo(change1.getCommit().getShortMessage());
 
     RevCommit tipmaster = getRemoteLog(p3, "master").get(0);
-    assertThat(tipmaster.getShortMessage()).isEqualTo(
-        repo3Head.getShortMessage());
+    assertThat(tipmaster.getShortMessage()).isEqualTo(repo3Head.getShortMessage());
 
     assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
     assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name());
   }
 
   @Test
-  public void testGerritWorkflow() throws Exception {
+  public void gerritWorkflow() throws Exception {
     RevCommit initialHead = getRemoteHead();
 
     // We'll setup a master and a stable branch.
     // Then we create a change to be applied to master, which is
     // then cherry picked back to stable. The stable branch will
     // be merged up into master again.
-    gApi.projects()
-        .name(project.get())
-        .branch("stable")
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
 
     // Push a change to master
     PushOneCommit push =
-        pushFactory.create(db, user.getIdent(), testRepo,
-            "small fix", "a.txt", "2");
+        pushFactory.create(db, user.getIdent(), testRepo, "small fix", "a.txt", "2");
     PushOneCommit.Result change = push.to("refs/for/master");
     submit(change.getChangeId());
     RevCommit headAfterFirstSubmit = getRemoteLog(project, "master").get(0);
-    assertThat(headAfterFirstSubmit.getShortMessage()).isEqualTo(
-        change.getCommit().getShortMessage());
+    assertThat(headAfterFirstSubmit.getShortMessage())
+        .isEqualTo(change.getCommit().getShortMessage());
 
     // Now cherry pick to stable
     CherryPickInput in = new CherryPickInput();
     in.destination = "stable";
-    in.message = "This goes to stable as well\n"
-        + headAfterFirstSubmit.getFullMessage();
-    ChangeApi orig = gApi.changes()
-        .id(change.getChangeId());
+    in.message = "This goes to stable as well\n" + headAfterFirstSubmit.getFullMessage();
+    ChangeApi orig = gApi.changes().id(change.getChangeId());
     String cherryId = orig.current().cherryPick(in).id();
     gApi.changes().id(cherryId).current().review(ReviewInput.approve());
     gApi.changes().id(cherryId).current().submit();
@@ -397,62 +449,47 @@
     RevCommit stable = getRemoteHead(project, "stable");
     RevCommit master = getRemoteHead(project, "master");
     testRepo.git().fetch().call();
-    testRepo.git()
-        .branchCreate()
-        .setName("stable")
-        .setStartPoint(stable)
-        .call();
-    testRepo.git()
-        .branchCreate()
-        .setName("master")
-        .setStartPoint(master)
-        .call();
+    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
+    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
 
-    RevCommit merge = testRepo.commit()
-        .parent(master)
-        .parent(stable)
-        .message("Merge stable into master")
-        .insertChangeId()
-        .create();
+    RevCommit merge =
+        testRepo
+            .commit()
+            .parent(master)
+            .parent(stable)
+            .message("Merge stable into master")
+            .insertChangeId()
+            .create();
 
     testRepo.branch("refs/heads/master").update(merge);
-    testRepo.git().push()
-        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master"))
-        .call();
+    testRepo.git().push().setRefSpecs(new RefSpec("refs/heads/master:refs/for/master")).call();
 
     String changeId = GitUtil.getChangeId(testRepo, merge).get();
     approve(changeId);
     submit(changeId);
     RevCommit headAfterSecondSubmit = getRemoteLog(project, "master").get(0);
-    assertThat(headAfterSecondSubmit.getShortMessage())
-        .isEqualTo(merge.getShortMessage());
+    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo(merge.getShortMessage());
 
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
+    assertRefUpdatedEvents(
+        initialHead, headAfterFirstSubmit, headAfterFirstSubmit, headAfterSecondSubmit);
     assertChangeMergedEvents(
-        change.getChangeId(), headAfterFirstSubmit.name(),
-        changeId, headAfterSecondSubmit.name());
+        change.getChangeId(), headAfterFirstSubmit.name(), changeId, headAfterSecondSubmit.name());
   }
 
   @Test
   public void openChangeForTargetBranchPreventsMerge() throws Exception {
-    gApi.projects()
-        .name(project.get())
-        .branch("stable")
-        .create(new BranchInput());
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
 
     // Propose a change for master, but leave it open for master!
     PushOneCommit change =
-        pushFactory.create(db, user.getIdent(), testRepo,
-            "small fix", "a.txt", "2");
+        pushFactory.create(db, user.getIdent(), testRepo, "small fix", "a.txt", "2");
     PushOneCommit.Result change2result = change.to("refs/for/master");
 
     // Now cherry pick to stable
     CherryPickInput in = new CherryPickInput();
     in.destination = "stable";
     in.message = "it goes to stable branch";
-    ChangeApi orig = gApi.changes()
-        .id(change2result.getChangeId());
+    ChangeApi orig = gApi.changes().id(change2result.getChangeId());
     ChangeApi cherry = orig.current().cherryPick(in);
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
@@ -460,12 +497,13 @@
     // Create a commit locally
     testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/stable")).call();
 
-    PushOneCommit.Result change3 = createChange(testRepo, "stable",
-        "test","a.txt", "3", "");
-    submitWithConflict(change3.getChangeId(),
-        "Failed to submit 1 change due to the following problems:\n" +
-        "Change " + change3.getPatchSetId().getParentKey().get() +
-        ": depends on change that was not submitted");
+    PushOneCommit.Result change3 = createChange(testRepo, "stable", "test", "a.txt", "3", "");
+    submitWithConflict(
+        change3.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change3.getPatchSetId().getParentKey().get()
+            + ": depends on change that was not submitted");
 
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
@@ -484,12 +522,43 @@
     gApi.changes().id(draftResult.getChangeId()).delete();
 
     // approve and submit the change
-    submitWithConflict(changeResult.getChangeId(),
+    submitWithConflict(
+        changeResult.getChangeId(),
         "Failed to submit 1 change due to the following problems:\n"
-            + "Change " + changeResult.getChange().getId()
+            + "Change "
+            + changeResult.getChange().getId()
             + ": depends on change that was not submitted");
 
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
+
+  @Test
+  public void testPreviewSubmitTgz() throws Exception {
+    Project.NameKey p1 = createProject("project-name");
+
+    TestRepository<?> repo1 = cloneProject(p1);
+    PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
+    approve(change1.getChangeId());
+
+    // get a preview before submitting:
+    File tempfile;
+    try (BinaryResult request = submitPreview(change1.getChangeId(), "tgz")) {
+      assertThat(request.getContentType()).isEqualTo("application/x-gzip");
+      tempfile = File.createTempFile("test", null);
+      request.writeTo(Files.newOutputStream(tempfile.toPath()));
+    }
+
+    InputStream is = new GZIPInputStream(Files.newInputStream(tempfile.toPath()));
+
+    List<String> untarredFiles = new ArrayList<>();
+    try (TarArchiveInputStream tarInputStream =
+        (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
+      TarArchiveEntry entry = null;
+      while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
+        untarredFiles.add(entry.getName());
+      }
+    }
+    assertThat(untarredFiles).containsExactly(name("project-name") + ".git");
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
new file mode 100644
index 0000000..e4c929a
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -0,0 +1,134 @@
+// 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class SubmitByRebaseAlwaysIT extends AbstractSubmitByRebase {
+  @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
+  @Override
+  protected SubmitType getSubmitType() {
+    return SubmitType.REBASE_ALWAYS;
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void submitWithPossibleFastForward() throws Exception {
+    RevCommit oldHead = getRemoteHead();
+    PushOneCommit.Result change = createChange();
+    submit(change.getChangeId());
+
+    RevCommit head = getRemoteHead();
+    assertThat(head.getId()).isNotEqualTo(change.getCommit());
+    assertThat(head.getParent(0)).isEqualTo(oldHead);
+    assertApproved(change.getChangeId());
+    assertCurrentRevision(change.getChangeId(), 2, head);
+    assertSubmitter(change.getChangeId(), 1);
+    assertSubmitter(change.getChangeId(), 2);
+    assertPersonEquals(admin.getIdent(), head.getAuthorIdent());
+    assertPersonEquals(admin.getIdent(), head.getCommitterIdent());
+    assertRefUpdatedEvents(oldHead, head);
+    assertChangeMergedEvents(change.getChangeId(), head.name());
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void alwaysAddFooters() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+
+    assertThat(getCurrentCommit(change1).getFooterLines(FooterConstants.REVIEWED_BY)).isEmpty();
+    assertThat(getCurrentCommit(change2).getFooterLines(FooterConstants.REVIEWED_BY)).isEmpty();
+
+    // change1 is a fast-forward, but should be rebased in cherry pick style
+    // anyway, making change2 not a fast-forward, requiring a rebase.
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+    // ... but both changes should get reviewed-by footers.
+    assertLatestRevisionHasFooters(change1);
+    assertLatestRevisionHasFooters(change2);
+  }
+
+  @Test
+  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
+  public void changeMessageOnSubmit() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+
+    RegistrationHandle handle =
+        changeMessageModifiers.add(
+            new ChangeMessageModifier() {
+              @Override
+              public String onSubmit(
+                  String newCommitMessage,
+                  RevCommit original,
+                  RevCommit mergeTip,
+                  Branch.NameKey destination) {
+                List<String> custom = mergeTip.getFooterLines("Custom");
+                if (!custom.isEmpty()) {
+                  newCommitMessage += "Custom-Parent: " + custom.get(0) + "\n";
+                }
+                return newCommitMessage + "Custom: " + destination.get();
+              }
+            });
+    try {
+      // change1 is a fast-forward, but should be rebased in cherry pick style
+      // anyway, making change2 not a fast-forward, requiring a rebase.
+      approve(change1.getChangeId());
+      submit(change2.getChangeId());
+    } finally {
+      handle.remove();
+    }
+    // ... but both changes should get custom footers.
+    assertThat(getCurrentCommit(change1).getFooterLines("Custom"))
+        .containsExactly("refs/heads/master");
+    assertThat(getCurrentCommit(change2).getFooterLines("Custom"))
+        .containsExactly("refs/heads/master");
+    assertThat(getCurrentCommit(change2).getFooterLines("Custom-Parent"))
+        .containsExactly("refs/heads/master");
+  }
+
+  private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Exception {
+    RevCommit c = getCurrentCommit(change);
+    assertThat(c.getFooterLines(FooterConstants.CHANGE_ID)).isNotEmpty();
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_BY)).isNotEmpty();
+    assertThat(c.getFooterLines(FooterConstants.REVIEWED_ON)).isNotEmpty();
+  }
+
+  private RevCommit getCurrentCommit(PushOneCommit.Result change) throws Exception {
+    testRepo.git().fetch().setRemote("origin").call();
+    ChangeInfo info = get(change.getChangeId());
+    RevCommit c = testRepo.getRevWalk().parseCommit(ObjectId.fromString(info.currentRevision));
+    testRepo.getRevWalk().parseBody(c);
+    return c;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
index aa5386f..19f1706 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByRebaseIfNecessaryIT.java
@@ -15,37 +15,15 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.getChangeId;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.Util;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
-public class SubmitByRebaseIfNecessaryIT extends AbstractSubmit {
+public class SubmitByRebaseIfNecessaryIT extends AbstractSubmitByRebase {
 
   @Override
   protected SubmitType getSubmitType() {
@@ -72,346 +50,39 @@
 
   @Test
   @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebase() throws Exception {
-    submitWithRebase(admin);
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithRebaseWithoutAddPatchSetPermission() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.block(cfg, Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/*");
-    Util.allow(cfg, Permission.SUBMIT, REGISTERED_USERS, "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2,
-        REGISTERED_USERS, "refs/heads/*");
-    saveProjectConfig(project, cfg);
-
-    submitWithRebase(user);
-  }
-
-  private void submitWithRebase(TestAccount submitter) throws Exception {
-    setApiUser(submitter);
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
-    submit(change2.getChangeId());
-    assertRebase(testRepo, false);
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit.getParent(0))
-        .isEqualTo(headAfterFirstSubmit);
-    assertApproved(change2.getChangeId(), submitter);
-    assertCurrentRevision(change2.getChangeId(), 2, headAfterSecondSubmit);
-    assertSubmitter(change2.getChangeId(), 1, submitter);
-    assertSubmitter(change2.getChangeId(), 2, submitter);
-    assertPersonEquals(admin.getIdent(),
-        headAfterSecondSubmit.getAuthorIdent());
-    assertPersonEquals(submitter.getIdent(),
-        headAfterSecondSubmit.getCommitterIdent());
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitWithRebaseMultipleChanges() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 =
-        createChange("Change 1", "a.txt", "content");
-    submit(change1.getChangeId());
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    assertThat(headAfterFirstSubmit.name())
-        .isEqualTo(change1.getCommit().name());
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
-    assertThat(change2.getCommit().getParent(0))
-        .isNotEqualTo(change1.getCommit());
-    PushOneCommit.Result change3 =
-        createChange("Change 3", "c.txt", "third content");
-    PushOneCommit.Result change4 =
-        createChange("Change 4", "d.txt", "fourth content");
-    approve(change2.getChangeId());
-    approve(change3.getChangeId());
-    submit(change4.getChangeId());
-
-    assertRebase(testRepo, false);
-    assertApproved(change2.getChangeId());
-    assertApproved(change3.getChangeId());
-    assertApproved(change4.getChangeId());
-
-    RevCommit headAfterSecondSubmit = parse(getRemoteHead());
-    assertThat(headAfterSecondSubmit.getShortMessage()).isEqualTo("Change 4");
-    assertThat(headAfterSecondSubmit).isNotEqualTo(change4.getCommit());
-    assertCurrentRevision(change4.getChangeId(), 2, headAfterSecondSubmit);
-
-    RevCommit parent = parse(headAfterSecondSubmit.getParent(0));
-    assertThat(parent.getShortMessage()).isEqualTo("Change 3");
-    assertThat(parent).isNotEqualTo(change3.getCommit());
-    assertCurrentRevision(change3.getChangeId(), 2, parent);
-
-    RevCommit grandparent = parse(parent.getParent(0));
-    assertThat(grandparent).isNotEqualTo(change2.getCommit());
-    assertCurrentRevision(change2.getChangeId(), 2, grandparent);
-
-    RevCommit greatgrandparent = parse(grandparent.getParent(0));
-    assertThat(greatgrandparent).isEqualTo(change1.getCommit());
-    assertCurrentRevision(change1.getChangeId(), 1, greatgrandparent);
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit);
-    assertChangeMergedEvents(change1.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name(),
-        change3.getChangeId(), headAfterSecondSubmit.name(),
-        change4.getChangeId(), headAfterSecondSubmit.name());
-  }
-
-  @Test
-  public void submitWithRebaseMergeCommit() throws Exception {
-    /*
-        *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
-        |\
-        | *   Merge branch 'master' into origin/master
-        | |\
-        | | * SHA Added a
-        | |/
-        * | Before
-        |/
-        * Initial empty repository
-     */
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
-
-    PushOneCommit change2Push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "Merge to master", "m.txt", "");
-    change2Push.setParents(ImmutableList.of(initialHead, change1.getCommit()));
-    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Before", "b.txt", "");
-
-    approve(change3.getChangeId());
-    submit(change3.getChangeId());
-
-    approve(change1.getChangeId());
-    approve(change2.getChangeId());
-    submit(change2.getChangeId());
-
-    RevCommit newHead = getRemoteHead();
-    assertThat(newHead.getParentCount()).isEqualTo(2);
-
-    RevCommit headParent1 = parse(newHead.getParent(0).getId());
-    RevCommit headParent2 = parse(newHead.getParent(1).getId());
-
-    assertThat(headParent1.getId()).isEqualTo(change3.getCommit().getId());
-    assertThat(headParent1.getParentCount()).isEqualTo(1);
-    assertThat(headParent1.getParent(0)).isEqualTo(initialHead);
-
-    assertThat(headParent2.getId()).isEqualTo(change2.getCommit().getId());
-    assertThat(headParent2.getParentCount()).isEqualTo(2);
-
-    RevCommit headGrandparent1 = parse(headParent2.getParent(0).getId());
-    RevCommit headGrandparent2 = parse(headParent2.getParent(1).getId());
-
-    assertThat(headGrandparent1.getId()).isEqualTo(initialHead.getId());
-    assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
   public void submitWithContentMerge() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
+    PushOneCommit.Result change = createChange("Change 1", "a.txt", "aaa\nbbb\nccc\n");
     submit(change.getChangeId());
     RevCommit headAfterFirstSubmit = getRemoteHead();
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "aaa\nbbb\nccc\nddd\n");
     submit(change2.getChangeId());
 
     RevCommit headAfterSecondSubmit = getRemoteHead();
     testRepo.reset(change.getCommit());
-    PushOneCommit.Result change3 =
-        createChange("Change 3", "a.txt", "bbb\nccc\n");
+    PushOneCommit.Result change3 = createChange("Change 3", "a.txt", "bbb\nccc\n");
     submit(change3.getChangeId());
     assertRebase(testRepo, true);
     RevCommit headAfterThirdSubmit = getRemoteHead();
-    assertThat(headAfterThirdSubmit.getParent(0))
-        .isEqualTo(headAfterSecondSubmit);
+    assertThat(headAfterThirdSubmit.getParent(0)).isEqualTo(headAfterSecondSubmit);
     assertApproved(change3.getChangeId());
     assertCurrentRevision(change3.getChangeId(), 2, headAfterThirdSubmit);
     assertSubmitter(change3.getChangeId(), 1);
     assertSubmitter(change3.getChangeId(), 2);
 
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit,
-        headAfterFirstSubmit, headAfterSecondSubmit,
-        headAfterSecondSubmit, headAfterThirdSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name(),
-        change3.getChangeId(), headAfterThirdSubmit.name());
-  }
-
-  @Test
-  @TestProjectInput(useContentMerge = InheritableBoolean.TRUE)
-  public void submitWithContentMerge_Conflict() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "a.txt", "other content");
-    submitWithConflict(change2.getChangeId(),
-        "Cannot rebase " + change2.getCommit().name()
-        + ": The change could not be rebased due to a conflict during merge.");
-    RevCommit head = getRemoteHead();
-    assertThat(head).isEqualTo(headAfterFirstSubmit);
-    assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
-    assertNoSubmitter(change2.getChangeId(), 1);
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-  }
-
-  @Test
-  public void repairChangeStateAfterFailure() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-    PushOneCommit.Result change =
-        createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = getRemoteHead();
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 =
-        createChange("Change 2", "b.txt", "other content");
-    Change.Id id2 = change2.getChange().getId();
-    SubmitInput failAfterRefUpdates =
-        new TestSubmitInput(new SubmitInput(), true);
-    submit(change2.getChangeId(), failAfterRefUpdates,
-        ResourceConflictException.class, "Failing after ref updates");
-    RevCommit headAfterFailedSubmit = getRemoteHead();
-
-    // Bad: ref advanced but change wasn't updated.
-    PatchSet.Id psId1 = new PatchSet.Id(id2, 1);
-    PatchSet.Id psId2 = new PatchSet.Id(id2, 2);
-    ChangeInfo info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(1);
-    assertThat(getPatchSet(psId2)).isNull();
-
-    ObjectId rev2;
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev1 = repo.exactRef(psId1.toRefName()).getObjectId();
-      assertThat(rev1).isNotNull();
-
-      rev2 = repo.exactRef(psId2.toRefName()).getObjectId();
-      assertThat(rev2).isNotNull();
-      assertThat(rev2).isNotEqualTo(rev1);
-      assertThat(rw.parseCommit(rev2).getParent(0)).isEqualTo(headAfterFirstSubmit);
-
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(rev2);
-    }
-
-    submit(change2.getChangeId());
-    RevCommit headAfterSecondSubmit = getRemoteHead();
-    assertThat(headAfterSecondSubmit).isEqualTo(headAfterFailedSubmit);
-
-    // Change status and patch set entities were updated, and branch tip stayed
-    // the same.
-    info = gApi.changes().id(id2.get()).get();
-    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
-    assertThat(info.revisions.get(info.currentRevision)._number).isEqualTo(2);
-    PatchSet ps2 = getPatchSet(psId2);
-    assertThat(ps2).isNotNull();
-    assertThat(ps2.getRevision().get()).isEqualTo(rev2.name());
-    assertThat(Iterables.getLast(info.messages).message)
-        .isEqualTo("Change has been successfully rebased as "
-            + rev2.name() + " by Administrator");
-
-    try (Repository repo = repoManager.openRepository(project)) {
-      assertThat(repo.exactRef("refs/heads/master").getObjectId())
-          .isEqualTo(rev2);
-    }
-
-    assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-    assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name(),
-        change2.getChangeId(), headAfterSecondSubmit.name());
-  }
-
-  private RevCommit parse(ObjectId id) throws Exception {
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit c = rw.parseCommit(id);
-      rw.parseBody(c);
-      return c;
-    }
-  }
-
-  @Test
-  public void submitAfterReorderOfCommits() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    // Create two commits and push.
-    RevCommit c1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    String id1 = getChangeId(testRepo, c1).get();
-    String id2 = getChangeId(testRepo, c2).get();
-
-    // Swap the order of commits and push again.
-    testRepo.reset("HEAD~2");
-    testRepo.cherryPick(c2);
-    testRepo.cherryPick(c1);
-    pushHead(testRepo, "refs/for/master", false);
-
-    approve(id1);
-    approve(id2);
-    submit(id1);
-    RevCommit headAfterSubmit = getRemoteHead();
-
-    assertRefUpdatedEvents(initialHead, headAfterSubmit);
-    assertChangeMergedEvents(id2, headAfterSubmit.name(),
-        id1, headAfterSubmit.name());
-  }
-
-  @Test
-  public void submitChangesAfterBranchOnSecond() throws Exception {
-    RevCommit initialHead = getRemoteHead();
-
-    PushOneCommit.Result change = createChange();
-    approve(change.getChangeId());
-
-    PushOneCommit.Result change2 = createChange();
-    approve(change2.getChangeId());
-    Project.NameKey project = change2.getChange().change().getProject();
-    Branch.NameKey branch = new Branch.NameKey(project, "branch");
-    createBranchWithRevision(branch, change2.getCommit().getName());
-    gApi.changes().id(change2.getChangeId()).current().submit();
-    assertMerged(change2.getChangeId());
-    assertMerged(change.getChangeId());
-
-    RevCommit newHead = getRemoteHead();
-    assertRefUpdatedEvents(initialHead, newHead);
-    assertChangeMergedEvents(change.getChangeId(), newHead.name(),
-        change2.getChangeId(), newHead.name());
+    assertRefUpdatedEvents(
+        initialHead,
+        headAfterFirstSubmit,
+        headAfterFirstSubmit,
+        headAfterSecondSubmit,
+        headAfterSecondSubmit,
+        headAfterThirdSubmit);
+    assertChangeMergedEvents(
+        change.getChangeId(),
+        headAfterFirstSubmit.name(),
+        change2.getChangeId(),
+        headAfterSecondSubmit.name(),
+        change3.getChangeId(),
+        headAfterThirdSubmit.name());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index d5b6f14..308c9a5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -30,7 +30,11 @@
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -39,18 +43,11 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
 @NoHttpd
 public class SubmitResolvingMergeCommitIT extends AbstractDaemonTest {
-  @Inject
-  private MergeSuperSet mergeSuperSet;
+  @Inject private Provider<MergeSuperSet> mergeSuperSet;
 
-  @Inject
-  private Submit submit;
+  @Inject private Submit submit;
 
   @ConfigSuite.Default
   public static Config submitWholeTopicEnabled() {
@@ -70,15 +67,15 @@
     */
 
     PushOneCommit.Result a = createChange("A");
-    PushOneCommit.Result b = createChange("B", "new.txt", "No conflict line",
-        ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result b =
+        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
     PushOneCommit.Result c = createChange("C", ImmutableList.of(b.getCommit()));
     PushOneCommit.Result d = createChange("D", ImmutableList.of(c.getCommit()));
 
     PushOneCommit.Result e = createChange("E", ImmutableList.of(a.getCommit()));
     PushOneCommit.Result f = createChange("F", ImmutableList.of(e.getCommit()));
-    PushOneCommit.Result g = createChange("G", "new.txt", "Conflicting line",
-        ImmutableList.of(f.getCommit()));
+    PushOneCommit.Result g =
+        createChange("G", "new.txt", "Conflicting line", ImmutableList.of(f.getCommit()));
     PushOneCommit.Result h = createChange("H", ImmutableList.of(g.getCommit()));
 
     approve(a.getChangeId());
@@ -97,8 +94,9 @@
     assertNotMergeable(g.getChange());
     assertNotMergeable(h.getChange());
 
-    PushOneCommit.Result m = createChange("M", "new.txt", "Resolved conflict",
-        ImmutableList.of(d.getCommit(), h.getCommit()));
+    PushOneCommit.Result m =
+        createChange(
+            "M", "new.txt", "Resolved conflict", ImmutableList.of(d.getCommit(), h.getCommit()));
     approve(m.getChangeId());
 
     assertChangeSetMergeable(m.getChange(), true);
@@ -126,17 +124,18 @@
     */
 
     PushOneCommit.Result a = createChange("A");
-    PushOneCommit.Result b = createChange("B", "new.txt", "No conflict line",
-        ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result c = createChange("C", "new.txt", "No conflict line #2",
-        ImmutableList.of(b.getCommit()));
+    PushOneCommit.Result b =
+        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result c =
+        createChange("C", "new.txt", "No conflict line #2", ImmutableList.of(b.getCommit()));
     PushOneCommit.Result d = createChange("D", ImmutableList.of(c.getCommit()));
-    PushOneCommit.Result e = createChange("E", "new.txt", "Conflicting line",
-        ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result f = createChange("F", "new.txt", "Resolved conflict",
-        ImmutableList.of(b.getCommit(), e.getCommit()));
-    PushOneCommit.Result g = createChange("G", "new.txt", "Conflicting line #2",
-        ImmutableList.of(f.getCommit()));
+    PushOneCommit.Result e =
+        createChange("E", "new.txt", "Conflicting line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result f =
+        createChange(
+            "F", "new.txt", "Resolved conflict", ImmutableList.of(b.getCommit(), e.getCommit()));
+    PushOneCommit.Result g =
+        createChange("G", "new.txt", "Conflicting line #2", ImmutableList.of(f.getCommit()));
 
     assertMergeable(e.getChange());
 
@@ -187,37 +186,50 @@
     String project2Name = name("Project2");
     gApi.projects().create(project1Name);
     gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 =
-        cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 =
-        cloneProject(new Project.NameKey(project2Name));
+    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
 
     PushOneCommit.Result a = createChange(project1, "A");
-    PushOneCommit.Result b = createChange(project1, "B", "new.txt",
-        "No conflict line", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result c = createChange(project1, "C", "new.txt",
-        "No conflict line #2", ImmutableList.of(b.getCommit()));
+    PushOneCommit.Result b =
+        createChange(project1, "B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result c =
+        createChange(
+            project1, "C", "new.txt", "No conflict line #2", ImmutableList.of(b.getCommit()));
 
     approve(a.getChangeId());
     approve(b.getChangeId());
     approve(c.getChangeId());
     submit(c.getChangeId());
 
-    PushOneCommit.Result e = createChange(project1, "E", "new.txt",
-        "Conflicting line", ImmutableList.of(a.getCommit()));
-    PushOneCommit.Result f = createChange(project1, "F", "new.txt",
-        "Resolved conflict", ImmutableList.of(b.getCommit(), e.getCommit()));
-    PushOneCommit.Result g = createChange(project1, "G", "new.txt",
-        "Conflicting line #2", ImmutableList.of(f.getCommit()),
-        "refs/for/master/" + name("topic1"));
+    PushOneCommit.Result e =
+        createChange(project1, "E", "new.txt", "Conflicting line", ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result f =
+        createChange(
+            project1,
+            "F",
+            "new.txt",
+            "Resolved conflict",
+            ImmutableList.of(b.getCommit(), e.getCommit()));
+    PushOneCommit.Result g =
+        createChange(
+            project1,
+            "G",
+            "new.txt",
+            "Conflicting line #2",
+            ImmutableList.of(f.getCommit()),
+            "refs/for/master/" + name("topic1"));
 
     PushOneCommit.Result h = createChange(project2, "H");
-    PushOneCommit.Result i = createChange(project2, "I", "new.txt",
-        "No conflict line", ImmutableList.of(h.getCommit()));
-    PushOneCommit.Result j = createChange(project2, "J", "new.txt",
-        "Conflicting line", ImmutableList.of(h.getCommit()));
+    PushOneCommit.Result i =
+        createChange(project2, "I", "new.txt", "No conflict line", ImmutableList.of(h.getCommit()));
+    PushOneCommit.Result j =
+        createChange(project2, "J", "new.txt", "Conflicting line", ImmutableList.of(h.getCommit()));
     PushOneCommit.Result k =
-        createChange(project2, "K", "new.txt", "Sadly conflicting topic-wise",
+        createChange(
+            project2,
+            "K",
+            "new.txt",
+            "Sadly conflicting topic-wise",
             ImmutableList.of(i.getCommit(), j.getCommit()),
             "refs/for/master/" + name("topic1"));
 
@@ -235,7 +247,11 @@
     assertChangeSetMergeable(k.getChange(), false);
 
     PushOneCommit.Result l =
-        createChange(project1, "L", "new.txt", "Resolving conflicts again",
+        createChange(
+            project1,
+            "L",
+            "new.txt",
+            "Resolving conflicts again",
             ImmutableList.of(c.getCommit(), g.getCommit()),
             "refs/for/master/" + name("topic1"));
 
@@ -262,18 +278,19 @@
     */
 
     PushOneCommit.Result a = createChange("A");
-    PushOneCommit.Result b = createChange("B", "new.txt", "No conflict line",
-        ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result b =
+        createChange("B", "new.txt", "No conflict line", ImmutableList.of(a.getCommit()));
 
     approve(a.getChangeId());
     approve(b.getChangeId());
     submit(b.getChangeId());
 
-    PushOneCommit.Result c = createChange("C", "new.txt", "Create conflicts",
-        ImmutableList.of(a.getCommit()));
+    PushOneCommit.Result c =
+        createChange("C", "new.txt", "Create conflicts", ImmutableList.of(a.getCommit()));
     PushOneCommit.Result e = createChange("E", ImmutableList.of(c.getCommit()));
-    PushOneCommit.Result d = createChange("D", "new.txt", "Resolves conflicts",
-        ImmutableList.of(c.getCommit(), e.getCommit()));
+    PushOneCommit.Result d =
+        createChange(
+            "D", "new.txt", "Resolves conflicts", ImmutableList.of(c.getCommit(), e.getCommit()));
 
     approve(c.getChangeId());
     approve(e.getChangeId());
@@ -283,17 +300,12 @@
   }
 
   private void submit(String changeId) throws Exception {
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .submit();
+    gApi.changes().id(changeId).current().submit();
   }
 
   private void assertChangeSetMergeable(ChangeData change, boolean expected)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-      OrmException {
-    ChangeSet cs =
-        mergeSuperSet.completeChangeSet(db, change.change(), user(admin));
+      throws MissingObjectException, IncorrectObjectTypeException, IOException, OrmException {
+    ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, change.change(), user(admin));
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
@@ -308,18 +320,18 @@
   }
 
   private void assertMerged(String changeId) throws Exception {
-    assertThat(gApi
-        .changes()
-        .id(changeId)
-        .get()
-        .status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
   }
 
-  private PushOneCommit.Result createChange(TestRepository<?> repo,
-      String subject, String fileName, String content, List<RevCommit> parents,
-      String ref) throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo,
-        subject, fileName, content);
+  private PushOneCommit.Result createChange(
+      TestRepository<?> repo,
+      String subject,
+      String fileName,
+      String content,
+      List<RevCommit> parents,
+      String ref)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo, subject, fileName, content);
 
     if (!parents.isEmpty()) {
       push.setParents(parents);
@@ -335,33 +347,34 @@
     return result;
   }
 
-  private PushOneCommit.Result createChange(TestRepository<?> repo,
-      String subject) throws Exception {
-    return createChange(repo, subject, "x", "x", new ArrayList<RevCommit>(),
-        "refs/for/master");
+  private PushOneCommit.Result createChange(TestRepository<?> repo, String subject)
+      throws Exception {
+    return createChange(repo, subject, "x", "x", new ArrayList<RevCommit>(), "refs/for/master");
   }
 
-  private PushOneCommit.Result createChange(TestRepository<?> repo,
-      String subject, String fileName, String content, List<RevCommit> parents)
-          throws Exception {
-    return createChange(repo, subject, fileName, content, parents,
-        "refs/for/master");
+  private PushOneCommit.Result createChange(
+      TestRepository<?> repo,
+      String subject,
+      String fileName,
+      String content,
+      List<RevCommit> parents)
+      throws Exception {
+    return createChange(repo, subject, fileName, content, parents, "refs/for/master");
   }
 
   @Override
   protected PushOneCommit.Result createChange(String subject) throws Exception {
-    return createChange(testRepo, subject, "", "",
-        Collections.<RevCommit> emptyList(), "refs/for/master");
+    return createChange(
+        testRepo, subject, "", "", Collections.<RevCommit>emptyList(), "refs/for/master");
   }
 
-  private PushOneCommit.Result createChange(String subject,
-      List<RevCommit> parents) throws Exception {
+  private PushOneCommit.Result createChange(String subject, List<RevCommit> parents)
+      throws Exception {
     return createChange(testRepo, subject, "", "", parents, "refs/for/master");
   }
 
-  private PushOneCommit.Result createChange(String subject, String fileName,
-      String content, List<RevCommit> parents) throws Exception {
-    return createChange(testRepo, subject, fileName, content, parents,
-        "refs/for/master");
+  private PushOneCommit.Result createChange(
+      String subject, String fileName, String content, List<RevCommit> parents) throws Exception {
+    return createChange(testRepo, subject, fileName, content, parents, "refs/for/master");
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 7fab6b1..6534810 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -16,40 +16,39 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.GerritConfigs;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.group.CreateGroup;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.inject.Inject;
-
+import java.util.Arrays;
+import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.Arrays;
-import java.util.List;
-
 @Sandboxed
 public class SuggestReviewersIT extends AbstractDaemonTest {
-  @Inject
-  private CreateGroup.Factory createGroupFactory;
+  @Inject private CreateGroup.Factory createGroupFactory;
 
-  @Inject
-  private GroupsCollection groups;
+  @Inject private GroupsCollection groups;
 
   private AccountGroup group1;
   private AccountGroup group2;
@@ -76,42 +75,42 @@
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   public void suggestReviewersNoResult1() throws Exception {
     String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(changeId, name("u"), 6);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
     assertThat(reviewers).isEmpty();
   }
 
   @Test
-  @GerritConfigs(
-      {@GerritConfig(name = "suggest.from", value = "1"),
-       @GerritConfig(name = "accounts.visibility", value = "NONE")
-      })
+  @GerritConfig(name = "suggest.from", value = "1")
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   public void suggestReviewersNoResult2() throws Exception {
     String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(changeId, name("u"), 6);
-    assertThat(reviewers).isEmpty();
-  }
-
-  @Test
-  @GerritConfig(name = "suggest.from", value = "2")
-  public void suggestReviewersNoResult3() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(changeId, name("").substring(0, 1), 6);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
     assertThat(reviewers).isEmpty();
   }
 
   @Test
   public void suggestReviewersChange() throws Exception {
     String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(changeId, name("u"), 6);
-    assertThat(reviewers).hasSize(6);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+    assertReviewers(
+        reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2, group3));
+
     reviewers = suggestReviewers(changeId, name("u"), 5);
-    assertThat(reviewers).hasSize(5);
+    assertReviewers(
+        reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2));
+
     reviewers = suggestReviewers(changeId, group3.getName(), 10);
+    assertReviewers(reviewers, ImmutableList.of(), ImmutableList.of(group3));
+
+    // Suggested accounts are ordered by activity. All users have no activity,
+    // hence we don't know which of the matching accounts we get when the query
+    // is limited to 1.
+    reviewers = suggestReviewers(changeId, name("u"), 1);
     assertThat(reviewers).hasSize(1);
+    assertThat(reviewers.get(0).account).isNotNull();
+    assertThat(ImmutableList.of(reviewers.get(0).account._accountId))
+        .containsAnyIn(
+            ImmutableList.of(user1, user2, user3).stream().map(u -> u.id.get()).collect(toList()));
   }
 
   @Test
@@ -122,8 +121,7 @@
 
     reviewers = suggestReviewers(changeId, user2.username, 2);
     assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name)
-        .isEqualTo(user2.fullName);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
 
     setApiUser(user1);
     reviewers = suggestReviewers(changeId, user2.fullName, 2);
@@ -132,14 +130,12 @@
     setApiUser(user2);
     reviewers = suggestReviewers(changeId, user2.username, 2);
     assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name)
-        .isEqualTo(user2.fullName);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
 
     setApiUser(user3);
     reviewers = suggestReviewers(changeId, user2.username, 2);
     assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name)
-        .isEqualTo(user2.fullName);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
   }
 
   @Test
@@ -165,20 +161,17 @@
     assertThat(reviewers).isEmpty();
 
     setApiUser(user1); // Clear cached group info.
-    allowGlobalCapabilities(group1.getGroupUUID(),
-        GlobalCapability.VIEW_ALL_ACCOUNTS);
+    allowGlobalCapabilities(group1.getGroupUUID(), GlobalCapability.VIEW_ALL_ACCOUNTS);
     reviewers = suggestReviewers(changeId, user2.username, 2);
     assertThat(reviewers).hasSize(1);
-    assertThat(Iterables.getOnlyElement(reviewers).account.name)
-        .isEqualTo(user2.fullName);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName);
   }
 
   @Test
   @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "2")
   public void suggestReviewersMaxNbrSuggestions() throws Exception {
     String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(changeId, name("user"), 5);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("user"), 5);
     assertThat(reviewers).hasSize(2);
   }
 
@@ -212,7 +205,7 @@
     assertThat(reviewers).hasSize(1);
 
     reviewers = suggestReviewers(changeId, "first1 last2");
-    assertThat(reviewers).hasSize(0);
+    assertThat(reviewers).isEmpty();
 
     reviewers = suggestReviewers(changeId, name("user"));
     assertThat(reviewers).hasSize(6);
@@ -221,7 +214,7 @@
     assertThat(reviewers).hasSize(1);
 
     reviewers = suggestReviewers(changeId, "example.com");
-    assertThat(reviewers).hasSize(6);
+    assertThat(reviewers).hasSize(5);
 
     reviewers = suggestReviewers(changeId, user1.email);
     assertThat(reviewers).hasSize(1);
@@ -238,18 +231,14 @@
   public void suggestReviewersWithoutLimitOptionSpecified() throws Exception {
     String changeId = createChange().getChangeId();
     String query = user3.username;
-    List<SuggestedReviewerInfo> suggestedReviewerInfos = gApi.changes()
-        .id(changeId)
-        .suggestReviewers(query)
-        .get();
+    List<SuggestedReviewerInfo> suggestedReviewerInfos =
+        gApi.changes().id(changeId).suggestReviewers(query).get();
     assertThat(suggestedReviewerInfos).hasSize(1);
   }
 
   @Test
-  @GerritConfigs({
-    @GerritConfig(name = "addreviewer.maxAllowed", value="2"),
-    @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value="1"),
-  })
+  @GerritConfig(name = "addreviewer.maxAllowed", value = "2")
+  @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value = "1")
   public void suggestReviewersGroupSizeConsiderations() throws Exception {
     AccountGroup largeGroup = group("large");
     AccountGroup mediumGroup = group("medium");
@@ -284,45 +273,196 @@
     assertThat(reviewer.confirm).isTrue();
   }
 
-  private List<SuggestedReviewerInfo> suggestReviewers(String changeId,
-      String query) throws Exception {
-    return gApi.changes()
-        .id(changeId)
-        .suggestReviewers(query)
-        .get();
+  @Test
+  public void defaultReviewerSuggestion() throws Exception {
+    TestAccount user1 = user("customuser1", "User1");
+    TestAccount reviewer1 = user("customuser2", "User2");
+    TestAccount reviewer2 = user("customuser3", "User3");
+
+    setApiUser(user1);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(user1);
+    String changeId2 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId2);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    setApiUser(user1);
+    String changeId3 = createChangeFromApi();
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId3, null, 4);
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
+        .inOrder();
+
+    // check that existing reviewers are filtered out
+    gApi.changes().id(changeId3).addReviewer(reviewer1.email);
+    reviewers = suggestReviewers(changeId3, null, 4);
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(reviewer2.id.get())
+        .inOrder();
   }
 
-  private List<SuggestedReviewerInfo> suggestReviewers(String changeId,
-      String query, int n) throws Exception {
-    return gApi.changes()
-        .id(changeId)
-        .suggestReviewers(query)
-        .withLimit(n)
-        .get();
+  @Test
+  public void defaultReviewerSuggestionOnFirstChange() throws Exception {
+    TestAccount user1 = user("customuser1", "User1");
+    setApiUser(user1);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChange().getChangeId(), "", 4);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "10")
+  public void reviewerRanking() throws Exception {
+    // Assert that user are ranked by the number of times they have applied a
+    // a label to a change (highest), added comments (medium) or owned a
+    // change (low).
+    String fullName = "Primum Finalis";
+    TestAccount userWhoOwns = user("customuser1", fullName);
+    TestAccount reviewer1 = user("customuser2", fullName);
+    TestAccount reviewer2 = user("customuser3", fullName);
+    TestAccount userWhoComments = user("customuser4", fullName);
+    TestAccount userWhoLooksForSuggestions = user("customuser5", fullName);
+
+    // Create a change as userWhoOwns and add some reviews
+    setApiUser(userWhoOwns);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(user1);
+    String changeId2 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId2);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    // Create a comment as a different user
+    setApiUser(userWhoComments);
+    ReviewInput ri = new ReviewInput();
+    ri.message = "Test";
+    gApi.changes().id(changeId1).revision(1).review(ri);
+
+    // Create a change as a new user to assert that we receive the correct
+    // ranking
+
+    setApiUser(userWhoLooksForSuggestions);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Pri", 4);
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(
+            reviewer1.id.get(), reviewer2.id.get(), userWhoOwns.id.get(), userWhoComments.id.get())
+        .inOrder();
+  }
+
+  @Test
+  public void reviewerRankingProjectIsolation() throws Exception {
+    // Create new project
+    Project.NameKey newProject = createProject("test");
+
+    // Create users who review changes in both the default and the new project
+    String fullName = "Primum Finalis";
+    TestAccount userWhoOwns = user("customuser1", fullName);
+    TestAccount reviewer1 = user("customuser2", fullName);
+    TestAccount reviewer2 = user("customuser3", fullName);
+
+    setApiUser(userWhoOwns);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(userWhoOwns);
+    String changeId2 = createChangeFromApi(newProject);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    setApiUser(userWhoOwns);
+    String changeId3 = createChangeFromApi(newProject);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId3);
+
+    setApiUser(userWhoOwns);
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(createChangeFromApi(), "Prim", 4);
+
+    // Assert that reviewer1 is on top, even though reviewer2 has more reviews
+    // in other projects
+    assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
+        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
+        .inOrder();
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(String changeId, String query)
+      throws Exception {
+    return gApi.changes().id(changeId).suggestReviewers(query).get();
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(String changeId, String query, int n)
+      throws Exception {
+    return gApi.changes().id(changeId).suggestReviewers(query).withLimit(n).get();
   }
 
   private AccountGroup group(String name) throws Exception {
-    GroupInfo group = createGroupFactory.create(name(name))
-        .apply(TopLevelResource.INSTANCE, null);
+    GroupInfo group = createGroupFactory.create(name(name)).apply(TopLevelResource.INSTANCE, null);
     GroupDescription.Basic d = groups.parseInternal(Url.decode(group.id));
     return GroupDescriptions.toAccountGroup(d);
   }
 
-  private TestAccount user(String name, String fullName, String emailName,
-      AccountGroup... groups) throws Exception {
-    String[] groupNames = FluentIterable.from(Arrays.asList(groups))
-        .transform(new Function<AccountGroup, String>() {
-          @Override
-          public String apply(AccountGroup in) {
-            return in.getName();
-          }
-        }).toArray(String.class);
-    return accounts.create(name(name), name(emailName) + "@example.com",
-        fullName, groupNames);
+  private TestAccount user(String name, String fullName, String emailName, AccountGroup... groups)
+      throws Exception {
+    String[] groupNames = Arrays.stream(groups).map(AccountGroup::getName).toArray(String[]::new);
+    return accounts.create(name(name), name(emailName) + "@example.com", fullName, groupNames);
   }
 
-  private TestAccount user(String name, String fullName, AccountGroup... groups)
-      throws Exception {
+  private TestAccount user(String name, String fullName, AccountGroup... groups) throws Exception {
     return user(name, fullName, name, groups);
   }
+
+  private void reviewChange(String changeId) throws RestApiException {
+    ReviewInput ri = new ReviewInput();
+    ri.label("Code-Review", 1);
+    gApi.changes().id(changeId).current().review(ri);
+  }
+
+  private String createChangeFromApi() throws RestApiException {
+    return createChangeFromApi(project);
+  }
+
+  private String createChangeFromApi(Project.NameKey project) throws RestApiException {
+    ChangeInput ci = new ChangeInput();
+    ci.project = project.get();
+    ci.subject = "Test change at" + System.nanoTime();
+    ci.branch = "master";
+    return gApi.changes().create(ci).get().changeId;
+  }
+
+  private void assertReviewers(
+      List<SuggestedReviewerInfo> actual,
+      List<TestAccount> expectedUsers,
+      List<AccountGroup> expectedGroups) {
+    List<Integer> actualAccountIds =
+        actual.stream()
+            .filter(i -> i.account != null)
+            .map(i -> i.account._accountId)
+            .collect(toList());
+    assertThat(actualAccountIds)
+        .containsExactlyElementsIn(expectedUsers.stream().map(u -> u.id.get()).collect(toList()));
+
+    List<String> actualGroupIds =
+        actual.stream().filter(i -> i.group != null).map(i -> i.group.id).collect(toList());
+    assertThat(actualGroupIds)
+        .containsExactlyElementsIn(
+            expectedGroups.stream().map(g -> g.getGroupUUID().get()).collect(toList()))
+        .inOrder();
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
index f52fccd..3121812 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
-
 import org.junit.Test;
 
 public class TopicIT extends AbstractDaemonTest {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUCK
deleted file mode 100644
index d65b84a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'rest_config',
-  srcs = glob(['*IT.java']),
-  labels = ['rest']
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD
index b9d3ffb..6becf0f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/BUILD
@@ -1,7 +1,7 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'rest_config',
-  srcs = glob(['*IT.java']),
-  labels = ['rest']
+    srcs = glob(["*IT.java"]),
+    group = "rest_config",
+    labels = ["rest"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 13f7070..d448fa5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -24,16 +24,15 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.PostCaches;
-
-import org.junit.Test;
-
 import java.util.Arrays;
+import org.junit.Test;
 
 public class CacheOperationsIT extends AbstractDaemonTest {
 
   @Test
   public void flushAll() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
@@ -42,97 +41,105 @@
     r.consume();
 
     r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isNull();
   }
 
   @Test
   public void flushAll_Forbidden() throws Exception {
-    userRestSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH_ALL)).assertForbidden();
+    userRestSession
+        .post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL))
+        .assertForbidden();
   }
 
   @Test
   public void flushAll_BadRequest() throws Exception {
     adminRestSession
-        .post("/config/server/caches/",
-            new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")))
+        .post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")))
         .assertBadRequest();
   }
 
   @Test
   public void flush() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long)0);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
     r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long)1);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
 
-    r = adminRestSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
+    r =
+        adminRestSession.post(
+            "/config/server/caches/",
+            new PostCaches.Input(FLUSH, Arrays.asList("accounts", "project_list")));
     r.assertOK();
     r.consume();
 
     r = adminRestSession.get("/config/server/caches/project_list");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(cacheInfo.entries.mem).isNull();
 
     r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long)1);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 1);
   }
 
   @Test
   public void flush_Forbidden() throws Exception {
     userRestSession
-        .post("/config/server/caches/",
-            new PostCaches.Input(FLUSH, Arrays.asList("projects")))
+        .post("/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("projects")))
         .assertForbidden();
   }
 
   @Test
   public void flush_BadRequest() throws Exception {
-    adminRestSession
-        .post("/config/server/caches/",
-            new PostCaches.Input(FLUSH))
-        .assertBadRequest();
+    adminRestSession.post("/config/server/caches/", new PostCaches.Input(FLUSH)).assertBadRequest();
   }
 
   @Test
   public void flush_UnprocessableEntity() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     CacheInfo cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long)0);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
 
-    r = adminRestSession.post("/config/server/caches/",
-        new PostCaches.Input(FLUSH, Arrays.asList("projects", "unprocessable")));
+    r =
+        adminRestSession.post(
+            "/config/server/caches/",
+            new PostCaches.Input(FLUSH, Arrays.asList("projects", "unprocessable")));
     r.assertUnprocessableEntity();
     r.consume();
 
     r = adminRestSession.get("/config/server/caches/projects");
+    r.assertOK();
     cacheInfo = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(cacheInfo.entries.mem).isGreaterThan((long)0);
+    assertThat(cacheInfo.entries.mem).isGreaterThan((long) 0);
   }
 
   @Test
   public void flushWebSessions_Forbidden() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS,
-        GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+    allowGlobalCapabilities(
+        REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
     try {
-      RestResponse r = userRestSession.post("/config/server/caches/",
-          new PostCaches.Input(FLUSH, Arrays.asList("projects")));
+      RestResponse r =
+          userRestSession.post(
+              "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("projects")));
       r.assertOK();
       r.consume();
 
       userRestSession
-          .post("/config/server/caches/",
-              new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
+          .post(
+              "/config/server/caches/", new PostCaches.Input(FLUSH, Arrays.asList("web_sessions")))
           .assertForbidden();
     } finally {
-      removeGlobalCapabilities(REGISTERED_USERS,
-          GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
+      removeGlobalCapabilities(
+          REGISTERED_USERS, GlobalCapability.FLUSH_CACHES, GlobalCapability.VIEW_CACHES);
     }
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
index bdcdfae..dea9174 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -28,47 +27,37 @@
   @ConfigSuite.Default
   public static Config defaultConfig() {
     Config cfg = new Config();
-    cfg.setString("auth", null, "registerEmailPrivateKey",
-        SignedToken.generateRandomKey());
+    cfg.setString("auth", null, "registerEmailPrivateKey", SignedToken.generateRandomKey());
     return cfg;
   }
 
-  @Inject
-  private EmailTokenVerifier emailTokenVerifier;
+  @Inject private EmailTokenVerifier emailTokenVerifier;
 
   @Test
   public void confirm() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = emailTokenVerifier.encode(admin.getId(), "new.mail@example.com");
-    adminRestSession
-        .put("/config/server/email.confirm", in)
-        .assertNoContent();
+    adminRestSession.put("/config/server/email.confirm", in).assertNoContent();
   }
 
   @Test
   public void confirmForOtherUser_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = emailTokenVerifier.encode(user.getId(), "new.mail@example.com");
-    adminRestSession
-        .put("/config/server/email.confirm", in)
-        .assertUnprocessableEntity();
+    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
   }
 
   @Test
   public void confirmInvalidToken_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = "invalidToken";
-    adminRestSession
-        .put("/config/server/email.confirm", in)
-        .assertUnprocessableEntity();
+    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
   }
 
   @Test
   public void confirmAlreadyInUse_UnprocessableEntity() throws Exception {
     ConfirmEmail.Input in = new ConfirmEmail.Input();
     in.token = emailTokenVerifier.encode(admin.getId(), user.email);
-    adminRestSession
-        .put("/config/server/email.confirm", in)
-        .assertUnprocessableEntity();
+    adminRestSession.put("/config/server/email.confirm", in).assertUnprocessableEntity();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index 149d05f..3f675ef 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
-
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
@@ -30,7 +29,7 @@
   public void flushCache() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/caches/groups");
     CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
-    assertThat(result.entries.mem).isGreaterThan((long)0);
+    assertThat(result.entries.mem).isGreaterThan((long) 0);
 
     r = adminRestSession.post("/config/server/caches/groups/flush");
     r.assertOK();
@@ -43,47 +42,37 @@
 
   @Test
   public void flushCache_Forbidden() throws Exception {
-    userRestSession
-        .post("/config/server/caches/accounts/flush")
-        .assertForbidden();
+    userRestSession.post("/config/server/caches/accounts/flush").assertForbidden();
   }
 
   @Test
   public void flushCache_NotFound() throws Exception {
-    adminRestSession
-        .post("/config/server/caches/nonExisting/flush")
-        .assertNotFound();
+    adminRestSession.post("/config/server/caches/nonExisting/flush").assertNotFound();
   }
 
   @Test
   public void flushCacheWithGerritPrefix() throws Exception {
-    adminRestSession
-        .post("/config/server/caches/gerrit-accounts/flush")
-        .assertOK();
+    adminRestSession.post("/config/server/caches/gerrit-accounts/flush").assertOK();
   }
 
   @Test
   public void flushWebSessionsCache() throws Exception {
-    adminRestSession
-        .post("/config/server/caches/web_sessions/flush")
-        .assertOK();
+    adminRestSession.post("/config/server/caches/web_sessions/flush").assertOK();
   }
 
   @Test
   public void flushWebSessionsCache_Forbidden() throws Exception {
-    allowGlobalCapabilities(REGISTERED_USERS,
-        GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+    allowGlobalCapabilities(
+        REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
     try {
       RestResponse r = userRestSession.post("/config/server/caches/accounts/flush");
       r.assertOK();
       r.consume();
 
-      userRestSession
-          .post("/config/server/caches/web_sessions/flush")
-          .assertForbidden();
+      userRestSession.post("/config/server/caches/web_sessions/flush").assertForbidden();
     } finally {
-      removeGlobalCapabilities(REGISTERED_USERS,
-          GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
+      removeGlobalCapabilities(
+          REGISTERED_USERS, GlobalCapability.VIEW_CACHES, GlobalCapability.FLUSH_CACHES);
     }
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
index 1a1ccd90..fe600cc 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.ListCaches.CacheType;
-
 import org.junit.Test;
 
 public class GetCacheIT extends AbstractDaemonTest {
@@ -51,22 +50,16 @@
 
   @Test
   public void getCache_Forbidden() throws Exception {
-    userRestSession
-        .get("/config/server/caches/accounts")
-        .assertForbidden();
+    userRestSession.get("/config/server/caches/accounts").assertForbidden();
   }
 
   @Test
   public void getCache_NotFound() throws Exception {
-    adminRestSession
-        .get("/config/server/caches/nonExisting")
-        .assertNotFound();
+    adminRestSession.get("/config/server/caches/nonExisting").assertNotFound();
   }
 
   @Test
   public void getCacheWithGerritPrefix() throws Exception {
-    adminRestSession
-        .get("/config/server/caches/gerrit-accounts")
-        .assertOK();
+    adminRestSession.get("/config/server/caches/gerrit-accounts").assertOK();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index 1321650..900b4be 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -20,21 +20,16 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
-
-import org.junit.Test;
-
 import java.util.List;
+import org.junit.Test;
 
 public class GetTaskIT extends AbstractDaemonTest {
 
   @Test
   public void getTask() throws Exception {
-    RestResponse r =
-        adminRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
+    RestResponse r = adminRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId());
     r.assertOK();
-    TaskInfo info =
-        newGson().fromJson(r.getReader(),
-            new TypeToken<TaskInfo>() {}.getType());
+    TaskInfo info = newGson().fromJson(r.getReader(), new TypeToken<TaskInfo>() {}.getType());
     assertThat(info.id).isNotNull();
     Long.parseLong(info.id, 16);
     assertThat(info.command).isEqualTo("Log File Compressor");
@@ -43,16 +38,13 @@
 
   @Test
   public void getTask_NotFound() throws Exception {
-    userRestSession
-        .get("/config/server/tasks/" + getLogFileCompressorTaskId())
-        .assertNotFound();
+    userRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId()).assertNotFound();
   }
 
   private String getLogFileCompressorTaskId() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/tasks/");
     List<TaskInfo> result =
-        newGson().fromJson(r.getReader(),
-            new TypeToken<List<TaskInfo>>() {}.getType());
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
     for (TaskInfo info : result) {
       if ("Log File Compressor".equals(info.command)) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
index 306bb58..2c907e5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -15,47 +15,51 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
-
-import org.junit.Test;
-
 import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.junit.Test;
 
 public class KillTaskIT extends AbstractDaemonTest {
 
   private void killTask() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/tasks/");
-    List<TaskInfo> result = newGson().fromJson(r.getReader(),
-        new TypeToken<List<TaskInfo>>() {}.getType());
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
-    int taskCount = result.size();
-    assertThat(taskCount).isGreaterThan(0);
 
-    r = adminRestSession.delete("/config/server/tasks/" + result.get(0).id);
+    Optional<String> id =
+        result.stream()
+            .filter(t -> "Log File Compressor".equals(t.command))
+            .map(t -> t.id)
+            .findFirst();
+    assertThat(id.isPresent()).isTrue();
+
+    r = adminRestSession.delete("/config/server/tasks/" + id.get());
     r.assertNoContent();
     r.consume();
 
     r = adminRestSession.get("/config/server/tasks/");
-    result = newGson().fromJson(r.getReader(),
-        new TypeToken<List<TaskInfo>>() {}.getType());
+    result = newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
-    assertThat(result).hasSize(taskCount - 1);
+    Set<String> ids = result.stream().map(t -> t.id).collect(toSet());
+    assertThat(ids).doesNotContain(id.get());
   }
 
   private void killTask_NotFound() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/tasks/");
-    List<TaskInfo> result = newGson().fromJson(r.getReader(),
-        new TypeToken<List<TaskInfo>>() {}.getType());
+    List<TaskInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
     assertThat(result.size()).isGreaterThan(0);
 
-    userRestSession
-        .delete("/config/server/tasks/" + result.get(0).id)
-        .assertNotFound();
+    userRestSession.delete("/config/server/tasks/" + result.get(0).id).assertNotFound();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
index d0a5070..4d48bf4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -23,13 +23,11 @@
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.ListCaches.CacheType;
 import com.google.gson.reflect.TypeToken;
-
-import org.eclipse.jgit.util.Base64;
-import org.junit.Test;
-
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.util.Base64;
+import org.junit.Test;
 
 public class ListCachesIT extends AbstractDaemonTest {
 
@@ -38,8 +36,7 @@
     RestResponse r = adminRestSession.get("/config/server/caches/");
     r.assertOK();
     Map<String, CacheInfo> result =
-        newGson().fromJson(r.getReader(),
-            new TypeToken<Map<String, CacheInfo>>() {}.getType());
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
 
     assertThat(result).containsKey("accounts");
     CacheInfo accountsCacheInfo = result.get("accounts");
@@ -56,16 +53,14 @@
     userRestSession.get("/config/server/version").consume();
     r = adminRestSession.get("/config/server/caches/");
     r.assertOK();
-    result = newGson().fromJson(r.getReader(),
-        new TypeToken<Map<String, CacheInfo>>() {}.getType());
+    result =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
     assertThat(result.get("accounts").entries.mem).isEqualTo(2);
   }
 
   @Test
   public void listCaches_Forbidden() throws Exception {
-    userRestSession
-        .get("/config/server/caches/")
-        .assertForbidden();
+    userRestSession.get("/config/server/caches/").assertForbidden();
   }
 
   @Test
@@ -73,8 +68,7 @@
     RestResponse r = adminRestSession.get("/config/server/caches/?format=LIST");
     r.assertOK();
     List<String> result =
-        newGson().fromJson(r.getReader(),
-            new TypeToken<List<String>>() {}.getType());
+        newGson().fromJson(r.getReader(), new TypeToken<List<String>>() {}.getType());
     assertThat(result).contains("accounts");
     assertThat(result).contains("projects");
     assertThat(Ordering.natural().isOrdered(result)).isTrue();
@@ -93,8 +87,6 @@
 
   @Test
   public void listCaches_BadRequest() throws Exception {
-    adminRestSession
-        .get("/config/server/caches/?format=NONSENSE")
-        .assertBadRequest();
+    adminRestSession.get("/config/server/caches/?format=NONSENSE").assertBadRequest();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
index c405ff2..ee6411a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
@@ -20,10 +20,8 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
-
-import org.junit.Test;
-
 import java.util.List;
+import org.junit.Test;
 
 public class ListTasksIT extends AbstractDaemonTest {
 
@@ -32,8 +30,7 @@
     RestResponse r = adminRestSession.get("/config/server/tasks/");
     r.assertOK();
     List<TaskInfo> result =
-        newGson().fromJson(r.getReader(),
-            new TypeToken<List<TaskInfo>>() {}.getType());
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
     assertThat(result).isNotEmpty();
     boolean foundLogFileCompressorTask = false;
     for (TaskInfo info : result) {
@@ -53,8 +50,7 @@
     RestResponse r = userRestSession.get("/config/server/tasks/");
     r.assertOK();
     List<TaskInfo> result =
-        newGson().fromJson(r.getReader(),
-            new TypeToken<List<TaskInfo>>() {}.getType());
+        newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
 
     assertThat(result).isEmpty();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 54fa74c..55ca719 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -19,67 +19,69 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
-import com.google.gerrit.acceptance.GerritConfigs;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.GetServerInfo.ServerInfo;
-
-import org.junit.Test;
-
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import org.junit.Test;
 
+@NoHttpd
 public class ServerInfoIT extends AbstractDaemonTest {
+  @Inject private SitePaths sitePaths;
 
   @Test
-  @GerritConfigs({
-    // auth
-    @GerritConfig(name = "auth.type", value = "HTTP"),
-    @GerritConfig(name = "auth.contributorAgreements", value = "true"),
-    @GerritConfig(name = "auth.loginUrl", value = "https://example.com/login"),
-    @GerritConfig(name = "auth.loginText", value = "LOGIN"),
-    @GerritConfig(name = "auth.switchAccountUrl", value = "https://example.com/switch"),
+  // auth
+  @GerritConfig(name = "auth.type", value = "HTTP")
+  @GerritConfig(name = "auth.contributorAgreements", value = "true")
+  @GerritConfig(name = "auth.loginUrl", value = "https://example.com/login")
+  @GerritConfig(name = "auth.loginText", value = "LOGIN")
+  @GerritConfig(name = "auth.switchAccountUrl", value = "https://example.com/switch")
 
-    // auth fields ignored when auth == HTTP
-    @GerritConfig(name = "auth.registerUrl", value = "https://example.com/register"),
-    @GerritConfig(name = "auth.registerText", value = "REGISTER"),
-    @GerritConfig(name = "auth.editFullNameUrl", value = "https://example.com/editname"),
-    @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password"),
+  // auth fields ignored when auth == HTTP
+  @GerritConfig(name = "auth.registerUrl", value = "https://example.com/register")
+  @GerritConfig(name = "auth.registerText", value = "REGISTER")
+  @GerritConfig(name = "auth.editFullNameUrl", value = "https://example.com/editname")
+  @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
-    // change
-    @GerritConfig(name = "change.allowDrafts", value = "false"),
-    @GerritConfig(name = "change.largeChange", value = "300"),
-    @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments"),
-    @GerritConfig(name = "change.replyLabel", value = "Vote"),
-    @GerritConfig(name = "change.updateDelay", value = "50s"),
+  // change
+  @GerritConfig(name = "change.allowDrafts", value = "false")
+  @GerritConfig(name = "change.largeChange", value = "300")
+  @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
+  @GerritConfig(name = "change.replyLabel", value = "Vote")
+  @GerritConfig(name = "change.updateDelay", value = "50s")
 
-    // download
-    @GerritConfig(name = "download.archive", values = {"tar",
-        "tbz2", "tgz", "txz"}),
+  // download
+  @GerritConfig(
+      name = "download.archive",
+      values = {"tar", "tbz2", "tgz", "txz"})
 
-    // gerrit
-    @GerritConfig(name = "gerrit.allProjects", value = "Root"),
-    @GerritConfig(name = "gerrit.allUsers", value = "Users"),
-    @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report"),
-    @GerritConfig(name = "gerrit.reportBugText", value = "REPORT BUG"),
+  // gerrit
+  @GerritConfig(name = "gerrit.allProjects", value = "Root")
+  @GerritConfig(name = "gerrit.allUsers", value = "Users")
+  @GerritConfig(name = "gerrit.enableGwtUi", value = "true")
+  @GerritConfig(name = "gerrit.reportBugText", value = "REPORT BUG")
+  @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
 
-    // suggest
-    @GerritConfig(name = "suggest.from", value = "3"),
+  // suggest
+  @GerritConfig(name = "suggest.from", value = "3")
 
-    // user
-    @GerritConfig(name = "user.anonymousCoward", value = "Unnamed User"),
-  })
+  // user
+  @GerritConfig(name = "user.anonymousCoward", value = "Unnamed User")
   public void serverConfig() throws Exception {
-    ServerInfo i = getServerConfig();
+    ServerInfo i = gApi.config().server().getInfo();
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.HTTP);
-    assertThat(i.auth.editableAccountFields).containsExactly(
-        Account.FieldName.REGISTER_NEW_EMAIL, Account.FieldName.FULL_NAME);
+    assertThat(i.auth.editableAccountFields)
+        .containsExactly(AccountFieldName.REGISTER_NEW_EMAIL, AccountFieldName.FULL_NAME);
     assertThat(i.auth.useContributorAgreements).isTrue();
     assertThat(i.auth.loginUrl).isEqualTo("https://example.com/login");
     assertThat(i.auth.loginText).isEqualTo("LOGIN");
@@ -88,7 +90,6 @@
     assertThat(i.auth.registerText).isNull();
     assertThat(i.auth.editFullNameUrl).isNull();
     assertThat(i.auth.httpPasswordUrl).isNull();
-    assertThat(i.auth.isGitBasicAuth).isNull();
 
     // change
     assertThat(i.change.allowDrafts).isNull();
@@ -107,6 +108,9 @@
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
     assertThat(i.gerrit.reportBugText).isEqualTo("REPORT BUG");
 
+    // Acceptance tests force --headless even when UIs are specified in config.
+    assertThat(i.gerrit.webUis).isEmpty();
+
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
 
@@ -121,20 +125,22 @@
 
     // notedb
     notesMigration.setReadChanges(true);
-    assertThat(getServerConfig().noteDbEnabled).isTrue();
+    assertThat(gApi.config().server().getInfo().noteDbEnabled).isTrue();
     notesMigration.setReadChanges(false);
-    assertThat(getServerConfig().noteDbEnabled).isNull();
+    assertThat(gApi.config().server().getInfo().noteDbEnabled).isNull();
   }
 
   @Test
+  @UseSsh
   @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
   public void serverConfigWithPlugin() throws Exception {
-    Path plugins = tempSiteDir.newFolder("plugins").toPath();
+    Path plugins = sitePaths.plugins_dir;
+    Files.createDirectory(plugins);
     Path jsplugin = plugins.resolve("js-plugin-1.js");
     Files.write(jsplugin, "Gerrit.install(function(self){});\n".getBytes(UTF_8));
     adminSshSession.exec("gerrit plugin reload");
 
-    ServerInfo i = getServerConfig();
+    ServerInfo i = gApi.config().server().getInfo();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).hasSize(1);
@@ -142,13 +148,15 @@
 
   @Test
   public void serverConfigWithDefaults() throws Exception {
-    ServerInfo i = getServerConfig();
+    ServerInfo i = gApi.config().server().getInfo();
 
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.OPENID);
-    assertThat(i.auth.editableAccountFields).containsExactly(
-        Account.FieldName.REGISTER_NEW_EMAIL, Account.FieldName.FULL_NAME,
-        Account.FieldName.USER_NAME);
+    assertThat(i.auth.editableAccountFields)
+        .containsExactly(
+            AccountFieldName.REGISTER_NEW_EMAIL,
+            AccountFieldName.FULL_NAME,
+            AccountFieldName.USER_NAME);
     assertThat(i.auth.useContributorAgreements).isNull();
     assertThat(i.auth.loginUrl).isNull();
     assertThat(i.auth.loginText).isNull();
@@ -157,7 +165,6 @@
     assertThat(i.auth.registerText).isNull();
     assertThat(i.auth.editFullNameUrl).isNull();
     assertThat(i.auth.httpPasswordUrl).isNull();
-    assertThat(i.auth.isGitBasicAuth).isNull();
 
     // change
     assertThat(i.change.allowDrafts).isTrue();
@@ -189,9 +196,12 @@
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
   }
 
-  private ServerInfo getServerConfig() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/info/");
-    r.assertOK();
-    return newGson().fromJson(r.getReader(), ServerInfo.class);
+  @Test
+  @GerritConfig(name = "auth.contributorAgreements", value = "true")
+  public void anonymousAccess() throws Exception {
+    configureContributorAgreement(true);
+
+    setApiUserAnonymous();
+    gApi.config().server().getInfo();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
index e37567c..fe0f42d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/AddMemberIT.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.acceptance.rest.group;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-
 import org.junit.Test;
 
 public class AddMemberIT extends AbstractDaemonTest {
   @Test
   public void addNonExistingMember_NotFound() throws Exception {
-    adminRestSession
-        .put("/groups/Administrators/members/non-existing")
-        .assertNotFound();
+    adminRestSession.put("/groups/Administrators/members/non-existing").assertNotFound();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUCK
deleted file mode 100644
index 1947148..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUCK
+++ /dev/null
@@ -1,8 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'rest_group',
-  srcs = glob(['*IT.java']),
-  labels = ['rest']
-)
-
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD
index d9a400c..b3672ee 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/BUILD
@@ -1,8 +1,7 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'rest_group',
-  srcs = glob(['*IT.java']),
-  labels = ['rest']
+    srcs = glob(["*IT.java"]),
+    group = "rest_group",
+    labels = ["rest"],
 )
-
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/CreateGroupIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/CreateGroupIT.java
deleted file mode 100644
index 31e7382..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/CreateGroupIT.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.group;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-
-import org.junit.Test;
-
-public class CreateGroupIT extends AbstractDaemonTest {
-
-  @Test
-  public void createGroup() throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name("group");
-    gApi.groups().create(in);
-    AccountGroup accountGroup =
-        groupCache.get(new AccountGroup.NameKey(in.name));
-    assertThat(accountGroup).isNotNull();
-    assertThat(accountGroup.getName()).isEqualTo(in.name);
-  }
-
-  @Test
-  public void createGroupAlreadyExists() throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name("group");
-    gApi.groups().create(in);
-    assertThat(groupCache.get(new AccountGroup.NameKey(in.name))).isNotNull();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group '" + in.name + "' already exists");
-    gApi.groups().create(in);
-  }
-
-  @Test
-  public void createGroupWithDifferentCase() throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name("group");
-    gApi.groups().create(in);
-    assertThat(groupCache.get(new AccountGroup.NameKey(in.name))).isNotNull();
-
-    GroupInput inLowerCase = new GroupInput();
-    inLowerCase.name =  in.name.toUpperCase();
-    gApi.groups().create(inLowerCase);
-    assertThat(groupCache.get(new AccountGroup.NameKey(inLowerCase.name)))
-        .isNotNull();
-  }
-
-  @Test
-  public void createSystemGroupWithDifferentCase() throws Exception {
-    String registeredUsers = "Registered Users";
-    GroupInput in = new GroupInput();
-    in.name = registeredUsers.toUpperCase();
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("group '" + registeredUsers + "' already exists");
-    gApi.groups().create(in);
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/GroupsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/GroupsIT.java
new file mode 100644
index 0000000..e153e561
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/group/GroupsIT.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.acceptance.rest.group;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import org.junit.Test;
+
+public class GroupsIT extends AbstractDaemonTest {
+  @Test
+  public void invalidQueryOptions() throws Exception {
+    RestResponse r = adminRestSession.put("/groups/?query=foo&query2=bar");
+    r.assertBadRequest();
+    assertThat(r.getEntityContent())
+        .isEqualTo("\"query\" and \"query2\" options are mutually exclusive");
+  }
+}
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 c78b291..839f166 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
@@ -35,7 +35,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.group.SystemGroupBackend;
-
+import java.util.HashMap;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -44,8 +44,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.HashMap;
-
 public class AccessIT extends AbstractDaemonTest {
 
   private final String PROJECT_NAME = "newProject";
@@ -59,7 +57,7 @@
   private ProjectApi pApi;
 
   @Before
-  public void setUp() throws Exception  {
+  public void setUp() throws Exception {
     newProjectName = createProject(PROJECT_NAME).get();
     pApi = gApi.projects().name(newProjectName);
   }
@@ -84,9 +82,8 @@
     assertThat(pApi.access().local).isEqualTo(accessInput.add);
 
     RevCommit updatedHead = getRemoteHead(p, RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(p.get(), RefNames.REFS_CONFIG,
-        null, initialHead,
-        initialHead, updatedHead);
+    eventRecorder.assertRefUpdatedEvents(
+        p.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
   }
 
   @Test
@@ -100,15 +97,14 @@
 
     // Remove specific permission
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    accessSectionToRemove.permissions
-        .put(Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
+    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);
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
 
     // Check
     assertThat(pApi.access().local).isEqualTo(accessInput.add);
@@ -127,20 +123,21 @@
     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);
+    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
+    accessInput
+        .add
+        .get(REFS_HEADS)
+        .permissions
         .get(Permission.LABEL + LABEL_CODE_REVIEW)
-        .rules.remove(SystemGroupBackend.REGISTERED_USERS.get());
+        .rules
+        .remove(SystemGroupBackend.REGISTERED_USERS.get());
 
     // Check
     assertThat(pApi.access().local).isEqualTo(accessInput.add);
@@ -159,23 +156,17 @@
     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);
+    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);
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
 
     // Check
     assertThat(pApi.access().local).isEqualTo(accessInput.add);
@@ -208,8 +199,7 @@
 
     // Create a change to apply
     ProjectAccessInput accessInfoToApply = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfoToApply =
-        createDefaultAccessSectionInfo();
+    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
     accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
 
     setApiUser(user);
@@ -249,11 +239,9 @@
   @Test
   public void addGlobalCapabilityAsUser() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo =
-        createDefaultGlobalCapabilitiesAccessSectionInfo();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
 
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
-        accessSectionInfo);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     setApiUser(user);
     exception.expect(AuthException.class);
@@ -263,27 +251,27 @@
   @Test
   public void addGlobalCapabilityAsAdmin() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo =
-        createDefaultGlobalCapabilitiesAccessSectionInfo();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
 
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
-        accessSectionInfo);
+    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())
+    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();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
 
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
-        accessSectionInfo);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     exception.expect(BadRequestException.class);
     pApi.access(accessInput);
@@ -291,20 +279,16 @@
 
   @Test
   public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
-    AccountGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators"));
+    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);
+    permissionInfo.rules.put(adminGroup.getGroupUUID().get(), null);
+    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
 
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES,
-        accessSectionInfo);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     exception.expect(BadRequestException.class);
     gApi.projects().name(allProjects.get()).access(accessInput);
@@ -313,11 +297,9 @@
   @Test
   public void removeGlobalCapabilityAsUser() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo =
-        createDefaultGlobalCapabilitiesAccessSectionInfo();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
 
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES,
-        accessSectionInfo);
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     setApiUser(user);
     exception.expect(AuthException.class);
@@ -326,38 +308,40 @@
 
   @Test
   public void removeGlobalCapabilityAsAdmin() throws Exception {
-    AccountGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators"));
+    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);
+    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);
+    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())
+    assertThat(
+            updatedProjectAccessInfo
+                .local
+                .get(AccessSection.GLOBAL_CAPABILITIES)
+                .permissions
+                .keySet())
         .containsAllIn(accessSectionInfo.permissions.keySet());
 
     // Remove
     accessInput.add.clear();
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES,
-        accessSectionInfo);
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
-    updatedProjectAccessInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThat(updatedProjectAccessInfo.local.get(
-        AccessSection.GLOBAL_CAPABILITIES).permissions.keySet())
+    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThat(
+            updatedProjectAccessInfo
+                .local
+                .get(AccessSection.GLOBAL_CAPABILITIES)
+                .permissions
+                .keySet())
         .containsNoneIn(accessSectionInfo.permissions.keySet());
   }
 
@@ -368,35 +352,39 @@
     String registeredUsers = "group Registered Users";
     String refsFor = "refs/for/*";
     // Clone repository to forcefully add permission
-    TestRepository<InMemoryRepository> allProjectsRepo =
-        cloneProject(allProjects, admin);
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
 
     // Fetch permission ref
     GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
     allProjectsRepo.reset("cfg");
 
     // Load current permissions
-    String config = gApi.projects()
-        .name(allProjects.get())
-        .branch(RefNames.REFS_CONFIG).file("project.config").asString();
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file("project.config")
+            .asString();
 
     // Append and push unknown permission
     Config cfg = new Config();
     cfg.fromText(config);
     cfg.setString(access, refsFor, unknownPermission, registeredUsers);
     config = cfg.toText();
-    PushOneCommit push = pushFactory.create(
-        db, admin.getIdent(), allProjectsRepo, "Subject", "project.config",
-        config);
+    PushOneCommit push =
+        pushFactory.create(
+            db, admin.getIdent(), allProjectsRepo, "Subject", "project.config", config);
     push.to(RefNames.REFS_CONFIG).assertOkStatus();
 
     // Verify that unknownPermission is present
-    config = gApi.projects()
-        .name(allProjects.get())
-        .branch(RefNames.REFS_CONFIG).file("project.config").asString();
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file("project.config")
+            .asString();
     cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission))
-        .isEqualTo(registeredUsers);
+    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
 
     // Make permission change through API
     ProjectAccessInput accessInput = newProjectAccessInput();
@@ -408,12 +396,14 @@
     gApi.projects().name(allProjects.get()).access(accessInput);
 
     // Verify that unknownPermission is still present
-    config = gApi.projects()
-        .name(allProjects.get())
-        .branch(RefNames.REFS_CONFIG).file("project.config").asString();
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file("project.config")
+            .asString();
     cfg.fromText(config);
-    assertThat(cfg.getString(access, refsFor, unknownPermission))
-        .isEqualTo(registeredUsers);
+    assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
   }
 
   private ProjectAccessInput newProjectAccessInput() {
@@ -439,40 +429,30 @@
     AccessSectionInfo accessSection = newAccessSectionInfo();
 
     PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(
-        PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(
-        SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    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.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
 
-    pri = new PermissionRuleInfo(
-        PermissionRuleInfo.Action.ALLOW, false);
+    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);
+    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);
+    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;
@@ -482,10 +462,8 @@
     AccessSectionInfo accessSection = newAccessSectionInfo();
 
     PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(
-        PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(
-        SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    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/rest/project/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
deleted file mode 100644
index d53e69a..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUCK
+++ /dev/null
@@ -1,37 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'rest_project',
-  srcs = glob(['*IT.java']),
-  deps = [
-    ':branch',
-    ':project',
-  ],
-  labels = ['rest'],
-)
-
-java_library(
-  name = 'branch',
-  srcs = [
-    'BranchAssert.java',
-  ],
-  deps = [
-    '//lib:truth',
-    '//gerrit-extension-api:api',
-    '//gerrit-server:server',
-  ],
-)
-
-java_library(
-  name = 'project',
-  srcs = [
-    'ProjectAssert.java',
-  ],
-  deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:gwtorm',
-    '//lib:truth',
-  ],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
index 579171f..ac022e9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BUILD
@@ -1,37 +1,38 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'rest_project',
-  srcs = glob(['*IT.java']),
-  deps = [
-    ':branch',
-    ':project',
-  ],
-  labels = ['rest'],
+    srcs = glob(["*IT.java"]),
+    group = "rest_project",
+    labels = ["rest"],
+    deps = [
+        ":project",
+        ":refassert",
+    ],
 )
 
 java_library(
-  name = 'branch',
-  srcs = [
-    'BranchAssert.java',
-  ],
-  deps = [
-    '//lib:truth',
-    '//gerrit-extension-api:api',
-    '//gerrit-server:server',
-  ],
+    name = "refassert",
+    srcs = [
+        "RefAssert.java",
+    ],
+    deps = [
+        "//gerrit-extension-api:api",
+        "//gerrit-server:server",
+        "//lib:truth",
+    ],
 )
 
 java_library(
-  name = 'project',
-  srcs = [
-    'ProjectAssert.java',
-  ],
-  deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:gwtorm',
-    '//lib:truth',
-  ],
+    name = "project",
+    srcs = [
+        "ProjectAssert.java",
+    ],
+    deps = [
+        "//gerrit-extension-api:api",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//lib:gwtorm",
+        "//lib:truth",
+    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
index 23fe562..90d51e0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BanCommitIT.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.server.project.BanCommit;
 import com.google.gerrit.server.project.BanCommit.BanResultInfo;
-
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
@@ -32,21 +31,19 @@
 
   @Test
   public void banCommit() throws Exception {
-    RevCommit c = commitBuilder()
-        .add("a.txt", "some content")
-        .create();
+    RevCommit c = commitBuilder().add("a.txt", "some content").create();
 
     RestResponse r =
-        adminRestSession.put("/projects/" + project.get() + "/ban/",
-            BanCommit.Input.fromCommits(c.name()));
+        adminRestSession.put(
+            "/projects/" + project.get() + "/ban/", BanCommit.Input.fromCommits(c.name()));
     r.assertOK();
     BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
     assertThat(Iterables.getOnlyElement(info.newlyBanned)).isEqualTo(c.name());
     assertThat(info.alreadyBanned).isNull();
     assertThat(info.ignored).isNull();
 
-    RemoteRefUpdate u = pushHead(testRepo, "refs/heads/master", false)
-        .getRemoteUpdate("refs/heads/master");
+    RemoteRefUpdate u =
+        pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
     assertThat(u).isNotNull();
     assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
     assertThat(u.getMessage()).startsWith("contains banned commit");
@@ -55,16 +52,19 @@
   @Test
   public void banAlreadyBannedCommit() throws Exception {
     RestResponse r =
-        adminRestSession.put("/projects/" + project.get() + "/ban/",
+        adminRestSession.put(
+            "/projects/" + project.get() + "/ban/",
             BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
     r.consume();
 
-    r = adminRestSession.put("/projects/" + project.get() + "/ban/",
-        BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
+    r =
+        adminRestSession.put(
+            "/projects/" + project.get() + "/ban/",
+            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"));
     r.assertOK();
     BanResultInfo info = newGson().fromJson(r.getReader(), BanResultInfo.class);
     assertThat(Iterables.getOnlyElement(info.alreadyBanned))
-      .isEqualTo("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96");
+        .isEqualTo("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96");
     assertThat(info.newlyBanned).isNull();
     assertThat(info.ignored).isNull();
   }
@@ -72,8 +72,9 @@
   @Test
   public void banCommit_Forbidden() throws Exception {
     userRestSession
-        .put("/projects/" + project.get() + "/ban/", BanCommit.Input.fromCommits(
-            "a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"))
+        .put(
+            "/projects/" + project.get() + "/ban/",
+            BanCommit.Input.fromCommits("a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96"))
         .assertForbidden();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
deleted file mode 100644
index c860bf0..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/BranchAssert.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.base.Function;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.extensions.api.projects.BranchInfo;
-
-import java.util.List;
-
-public class BranchAssert {
-  public static void assertBranches(List<BranchInfo> expectedBranches,
-      List<BranchInfo> actualBranches) {
-    assertRefNames(refs(expectedBranches), actualBranches);
-    for (int i = 0; i < expectedBranches.size(); i++) {
-      assertBranchInfo(expectedBranches.get(i), actualBranches.get(i));
-    }
-  }
-
-  public static void assertRefNames(Iterable<String> expectedRefs,
-      Iterable<BranchInfo> actualBranches) {
-    Iterable<String> actualNames = refs(actualBranches);
-    assertThat(actualNames).containsExactlyElementsIn(expectedRefs).inOrder();
-  }
-
-  public static void assertBranchInfo(BranchInfo expected, BranchInfo actual) {
-    assertThat(actual.ref).isEqualTo(expected.ref);
-    if (expected.revision != null) {
-      assertThat(actual.revision).named("revision of " + actual.ref)
-          .isEqualTo(expected.revision);
-    }
-    assertThat(toBoolean(actual.canDelete)).named("can delete " + actual.ref)
-        .isEqualTo(toBoolean(expected.canDelete));
-  }
-
-  private static Iterable<String> refs(Iterable<BranchInfo> infos) {
-    return Iterables.transform(infos, new Function<BranchInfo, String>() {
-      @Override
-      public String apply(BranchInfo in) {
-        return in.ref;
-      }
-    });
-  }
-
-  private static boolean toBoolean(Boolean b) {
-    if (b == null) {
-      return false;
-    }
-    return b.booleanValue();
-  }
-}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
index a094f93..7667fc0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.reviewdb.client.Branch;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
@@ -42,26 +41,41 @@
     branch = new Branch.NameKey(project, "test");
     gApi.projects()
         .name(branch.getParentKey().get())
-        .branch(branch.get()).create(new BranchInput());
+        .branch(branch.get())
+        .create(new BranchInput());
   }
 
   @Test
   public void checkMergeableCommit() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("some change in a")
         .add("a.txt", "a contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
     testRepo.reset(initialHead);
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("some change in b")
         .add("b.txt", "b contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/test")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/test"))
+        .call();
 
     assertMergeable("master", "test", "recursive");
   }
@@ -69,20 +83,34 @@
   @Test
   public void checkUnMergeableCommit() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("some change in a")
         .add("a.txt", "a contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
     testRepo.reset(initialHead);
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("some change in a too")
         .add("a.txt", "a contents too")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/test")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/test"))
+        .call();
 
     assertUnMergeable("master", "test", "recursive", "a.txt");
   }
@@ -90,51 +118,87 @@
   @Test
   public void checkOursMergeStrategy() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("some change in a")
         .add("a.txt", "a contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
     testRepo.reset(initialHead);
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("some change in a too")
         .add("a.txt", "a contents too")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/test")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/test"))
+        .call();
 
     assertMergeable("master", "test", "ours");
   }
 
   @Test
   public void checkAlreadyMergedCommit() throws Exception {
-    ObjectId c0 = testRepo.branch("HEAD").commit().insertChangeId()
-        .message("first commit")
-        .add("a.txt", "a contents ")
-        .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    ObjectId c0 =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .message("first commit")
+            .add("a.txt", "a contents ")
+            .create();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("second commit")
         .add("b.txt", "b contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
     assertCommitMerged("master", c0.getName(), "");
   }
 
   @Test
   public void checkContentMergedCommit() throws Exception {
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("first commit")
         .add("a.txt", "a contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
     // create a change, and cherrypick into master
     PushOneCommit.Result cId = createChange();
@@ -142,8 +206,7 @@
     CherryPickInput cpi = new CherryPickInput();
     cpi.destination = "master";
     cpi.message = "cherry pick the commit";
-    ChangeApi orig = gApi.changes()
-        .id(cId.getChangeId());
+    ChangeApi orig = gApi.changes().id(cId.getChangeId());
     ChangeApi cherry = orig.current().cherryPick(cpi);
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
@@ -155,71 +218,87 @@
 
   @Test
   public void checkInvalidSource() throws Exception {
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("first commit")
         .add("a.txt", "a contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
-    assertBadRequest("master", "fdsafsdf", "recursive",
-        "Cannot resolve 'fdsafsdf' to a commit");
+    assertBadRequest("master", "fdsafsdf", "recursive", "Cannot resolve 'fdsafsdf' to a commit");
   }
 
   @Test
   public void checkInvalidStrategy() throws Exception {
     RevCommit initialHead = getRemoteHead();
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("first commit")
         .add("a.txt", "a contents ")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/master")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
+        .call();
 
     testRepo.reset(initialHead);
-    testRepo.branch("HEAD").commit().insertChangeId()
+    testRepo
+        .branch("HEAD")
+        .commit()
+        .insertChangeId()
         .message("some change in a too")
         .add("a.txt", "a contents too")
         .create();
-    testRepo.git().push().setRemote("origin").setRefSpecs(
-        new RefSpec("HEAD:refs/heads/test")).call();
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/heads/test"))
+        .call();
 
-    assertBadRequest("master", "test", "octopus",
-        "invalid merge strategy: octopus");
+    assertBadRequest("master", "test", "octopus", "invalid merge strategy: octopus");
   }
 
-  private void assertMergeable(String targetBranch, String source,
-      String strategy) throws Exception {
-    MergeableInfo
-        mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
+  private void assertMergeable(String targetBranch, String source, String strategy)
+      throws Exception {
+    MergeableInfo mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
     assertThat(mergeableInfo.mergeable).isTrue();
   }
 
-  private void assertUnMergeable(String targetBranch, String source,
-      String strategy, String... conflicts) throws Exception {
+  private void assertUnMergeable(
+      String targetBranch, String source, String strategy, String... conflicts) throws Exception {
     MergeableInfo mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
     assertThat(mergeableInfo.mergeable).isFalse();
     assertThat(mergeableInfo.conflicts).containsExactly((Object[]) conflicts);
   }
 
-  private void assertCommitMerged(String targetBranch, String source,
-      String strategy) throws Exception {
-    MergeableInfo
-        mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
+  private void assertCommitMerged(String targetBranch, String source, String strategy)
+      throws Exception {
+    MergeableInfo mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
     assertThat(mergeableInfo.mergeable).isTrue();
     assertThat(mergeableInfo.commitMerged).isTrue();
   }
 
-  private void assertContentMerged(String targetBranch, String source,
-      String strategy) throws Exception {
-    MergeableInfo
-        mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
+  private void assertContentMerged(String targetBranch, String source, String strategy)
+      throws Exception {
+    MergeableInfo mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
     assertThat(mergeableInfo.mergeable).isTrue();
     assertThat(mergeableInfo.contentMerged).isTrue();
   }
 
-  private void assertBadRequest(String targetBranch, String source,
-      String strategy, String errMsg) throws Exception {
+  private void assertBadRequest(String targetBranch, String source, String strategy, String errMsg)
+      throws Exception {
     String url = "/projects/" + project.get() + "/branches/" + targetBranch;
     url += "/mergeable?source=" + source;
     if (!Strings.isNullOrEmpty(strategy)) {
@@ -231,8 +310,8 @@
     assertThat(r.getEntityContent()).isEqualTo(errMsg);
   }
 
-  private MergeableInfo getMergeableInfo(String targetBranch, String source,
-      String strategy) throws Exception {
+  private MergeableInfo getMergeableInfo(String targetBranch, String source, String strategy)
+      throws Exception {
     String url = "/projects/" + project.get() + "/branches/" + targetBranch;
     url += "/mergeable?source=" + source;
     if (!Strings.isNullOrEmpty(strategy)) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
new file mode 100644
index 0000000..61f14e4
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.reviewdb.client.Branch;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class CommitIncludedInIT extends AbstractDaemonTest {
+  @Test
+  public void includedInOpenChange() throws Exception {
+    Result result = createChange();
+    assertThat(getIncludedIn(result.getCommit().getId()).branches).isEmpty();
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
+  }
+
+  @Test
+  public void includedInMergedChange() throws Exception {
+    Result result = createChange();
+    gApi.changes()
+        .id(result.getChangeId())
+        .revision(result.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+
+    assertThat(getIncludedIn(result.getCommit().getId()).branches).containsExactly("master");
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
+
+    grantTagPermissions();
+    gApi.projects().name(result.getChange().project().get()).tag("test-tag").create(new TagInput());
+
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
+
+    createBranch(new Branch.NameKey(project.get(), "test-branch"));
+
+    assertThat(getIncludedIn(result.getCommit().getId()).branches)
+        .containsExactly("master", "test-branch");
+  }
+
+  private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
+    RestResponse r =
+        userRestSession.get("/projects/" + project.get() + "/commits/" + id.name() + "/in");
+    IncludedInInfo result = newGson().fromJson(r.getReader(), IncludedInInfo.class);
+    r.consume();
+    return result;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 46f93b6..2c74949 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
-
 import org.eclipse.jgit.lib.Constants;
 import org.junit.Before;
 import org.junit.Test;
@@ -67,14 +66,13 @@
   }
 
   @Test
-  public void createBranchByAdminCreateReferenceBlocked() throws Exception {
+  public void createBranchByAdminCreateReferenceBlocked_Forbidden() throws Exception {
     blockCreateReference();
-    assertCreateSucceeds();
+    assertCreateFails(AuthException.class);
   }
 
   @Test
-  public void createBranchByProjectOwnerCreateReferenceBlocked_Forbidden()
-      throws Exception {
+  public void createBranchByProjectOwnerCreateReferenceBlocked_Forbidden() throws Exception {
     grantOwner();
     blockCreateReference();
     setApiUser(user);
@@ -90,19 +88,15 @@
   }
 
   private BranchApi branch() throws Exception {
-    return gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get());
+    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
   }
 
   private void assertCreateSucceeds() throws Exception {
     BranchInfo created = branch().create(new BranchInput()).get();
-    assertThat(created.ref)
-        .isEqualTo(Constants.R_HEADS + branch.getShortName());
+    assertThat(created.ref).isEqualTo(Constants.R_HEADS + branch.getShortName());
   }
 
-  private void assertCreateFails(Class<? extends RestApiException> errType)
-      throws Exception {
+  private void assertCreateFails(Class<? extends RestApiException> errType) throws Exception {
     exception.expect(errType);
     branch().create(new BranchInput());
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index d09eeec..78c66d6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -39,7 +39,8 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectState;
-
+import java.util.Collections;
+import java.util.Set;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
@@ -48,12 +49,9 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.junit.Test;
 
-import java.util.Collections;
-import java.util.Set;
-
 public class CreateProjectIT extends AbstractDaemonTest {
   @Test
-  public void testCreateProjectHttp() throws Exception {
+  public void createProjectHttp() throws Exception {
     String newProjectName = name("newProject");
     RestResponse r = adminRestSession.put("/projects/" + newProjectName);
     r.assertCreated();
@@ -66,52 +64,40 @@
   }
 
   @Test
-  public void testCreateProjectHttpWhenProjectAlreadyExists_Conflict()
-      throws Exception {
-    adminRestSession
-        .put("/projects/" + allProjects.get())
-        .assertConflict();
+  public void createProjectHttpWhenProjectAlreadyExists_Conflict() throws Exception {
+    adminRestSession.put("/projects/" + allProjects.get()).assertConflict();
   }
 
   @Test
-  public void testCreateProjectHttpWhenProjectAlreadyExists_PreconditionFailed()
-      throws Exception {
+  public void createProjectHttpWhenProjectAlreadyExists_PreconditionFailed() throws Exception {
     adminRestSession
-        .putWithHeader("/projects/" + allProjects.get(),
-            new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"))
+        .putWithHeader(
+            "/projects/" + allProjects.get(), new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"))
         .assertPreconditionFailed();
   }
 
   @Test
   @UseLocalDisk
-  public void testCreateProjectHttpWithUnreasonableName_BadRequest()
-      throws Exception {
-    adminRestSession
-        .put("/projects/" + Url.encode(name("invalid/../name")))
-        .assertBadRequest();
+  public void createProjectHttpWithUnreasonableName_BadRequest() throws Exception {
+    adminRestSession.put("/projects/" + Url.encode(name("invalid/../name"))).assertBadRequest();
   }
 
   @Test
-  public void testCreateProjectHttpWithNameMismatch_BadRequest() throws Exception {
+  public void createProjectHttpWithNameMismatch_BadRequest() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("otherName");
-    adminRestSession
-        .put("/projects/" + name("someName"), in)
-        .assertBadRequest();
+    adminRestSession.put("/projects/" + name("someName"), in).assertBadRequest();
   }
 
   @Test
-  public void testCreateProjectHttpWithInvalidRefName_BadRequest()
-      throws Exception {
+  public void createProjectHttpWithInvalidRefName_BadRequest() throws Exception {
     ProjectInput in = new ProjectInput();
     in.branches = Collections.singletonList(name("invalid ref name"));
-    adminRestSession
-        .put("/projects/" + name("newProject"), in)
-        .assertBadRequest();
+    adminRestSession.put("/projects/" + name("newProject"), in).assertBadRequest();
   }
 
   @Test
-  public void testCreateProject() throws Exception {
+  public void createProject() throws Exception {
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName).get();
     assertThat(p.name).isEqualTo(newProjectName);
@@ -122,7 +108,7 @@
   }
 
   @Test
-  public void testCreateProjectWithGitSuffix() throws Exception {
+  public void createProjectWithGitSuffix() throws Exception {
     String newProjectName = name("newProject");
     ProjectInfo p = gApi.projects().create(newProjectName + ".git").get();
     assertThat(p.name).isEqualTo(newProjectName);
@@ -133,7 +119,7 @@
   }
 
   @Test
-  public void testCreateProjectWithProperties() throws Exception {
+  public void createProjectWithProperties() throws Exception {
     String newProjectName = name("newProject");
     ProjectInput in = new ProjectInput();
     in.name = newProjectName;
@@ -156,7 +142,7 @@
   }
 
   @Test
-  public void testCreateChildProject() throws Exception {
+  public void createChildProject() throws Exception {
     String parentName = name("parent");
     ProjectInput in = new ProjectInput();
     in.name = parentName;
@@ -172,8 +158,7 @@
   }
 
   @Test
-  public void testCreateChildProjectUnderNonExistingParent_UnprocessableEntity()
-      throws Exception {
+  public void createChildProjectUnderNonExistingParent_UnprocessableEntity() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("newProjectName");
     in.parent = "non-existing-project";
@@ -181,15 +166,16 @@
   }
 
   @Test
-  public void testCreateProjectWithOwner() throws Exception {
+  public void createProjectWithOwner() throws Exception {
     String newProjectName = name("newProject");
     ProjectInput in = new ProjectInput();
     in.name = newProjectName;
     in.owners = Lists.newArrayListWithCapacity(3);
     in.owners.add("Anonymous Users"); // by name
     in.owners.add(SystemGroupBackend.REGISTERED_USERS.get()); // by UUID
-    in.owners.add(Integer.toString(groupCache.get(
-        new AccountGroup.NameKey("Administrators")).getId().get())); // by ID
+    in.owners.add(
+        Integer.toString(
+            groupCache.get(new AccountGroup.NameKey("Administrators")).getId().get())); // by ID
     gApi.projects().create(in);
     ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
     Set<AccountGroup.UUID> expectedOwnerIds = Sets.newHashSetWithExpectedSize(3);
@@ -200,8 +186,7 @@
   }
 
   @Test
-  public void testCreateProjectWithNonExistingOwner_UnprocessableEntity()
-      throws Exception {
+  public void createProjectWithNonExistingOwner_UnprocessableEntity() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("newProjectName");
     in.owners = Collections.singletonList("non-existing-group");
@@ -209,7 +194,7 @@
   }
 
   @Test
-  public void testCreatePermissionOnlyProject() throws Exception {
+  public void createPermissionOnlyProject() throws Exception {
     String newProjectName = name("newProject");
     ProjectInput in = new ProjectInput();
     in.name = newProjectName;
@@ -219,7 +204,7 @@
   }
 
   @Test
-  public void testCreateProjectWithEmptyCommit() throws Exception {
+  public void createProjectWithEmptyCommit() throws Exception {
     String newProjectName = name("newProject");
     ProjectInput in = new ProjectInput();
     in.name = newProjectName;
@@ -229,7 +214,7 @@
   }
 
   @Test
-  public void testCreateProjectWithBranches() throws Exception {
+  public void createProjectWithBranches() throws Exception {
     String newProjectName = name("newProject");
     ProjectInput in = new ProjectInput();
     in.name = newProjectName;
@@ -240,14 +225,12 @@
     in.branches.add("release"); // without 'refs/heads' prefix
     gApi.projects().create(in);
     assertHead(newProjectName, "refs/heads/test");
-    assertEmptyCommit(newProjectName, "refs/heads/test", "refs/heads/master",
-        "refs/heads/release");
+    assertEmptyCommit(newProjectName, "refs/heads/test", "refs/heads/master", "refs/heads/release");
   }
 
   @Test
-  public void testCreateProjectWithCapability() throws Exception {
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS,
-        GlobalCapability.CREATE_PROJECT);
+  public void createProjectWithCapability() throws Exception {
+    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
     try {
       setApiUser(user);
       ProjectInput in = new ProjectInput();
@@ -255,13 +238,13 @@
       ProjectInfo p = gApi.projects().create(in).get();
       assertThat(p.name).isEqualTo(in.name);
     } finally {
-      removeGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS,
-          GlobalCapability.CREATE_PROJECT);
+      removeGlobalCapabilities(
+          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
     }
   }
 
   @Test
-  public void testCreateProjectWithoutCapability_Forbidden() throws Exception {
+  public void createProjectWithoutCapability_Forbidden() throws Exception {
     setApiUser(user);
     ProjectInput in = new ProjectInput();
     in.name = name("newProject");
@@ -269,20 +252,17 @@
   }
 
   @Test
-  public void testCreateProjectWhenProjectAlreadyExists_Conflict()
-      throws Exception {
+  public void createProjectWhenProjectAlreadyExists_Conflict() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = allProjects.get();
     assertCreateFails(in, ResourceConflictException.class);
   }
 
   @Test
-  public void testCreateProjectWithCreateProjectCapabilityAndParentNotVisible()
-      throws Exception {
+  public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
     Project parent = projectCache.get(allProjects).getProject();
     parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
-    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS,
-        GlobalCapability.CREATE_PROJECT);
+    allowGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
     try {
       setApiUser(user);
       ProjectInput in = new ProjectInput();
@@ -291,8 +271,8 @@
       assertThat(p.name).isEqualTo(in.name);
     } finally {
       parent.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE);
-      removeGlobalCapabilities(SystemGroupBackend.REGISTERED_USERS,
-          GlobalCapability.CREATE_PROJECT);
+      removeGlobalCapabilities(
+          SystemGroupBackend.REGISTERED_USERS, GlobalCapability.CREATE_PROJECT);
     }
   }
 
@@ -300,17 +280,13 @@
     return groupCache.get(new AccountGroup.NameKey(groupName)).getGroupUUID();
   }
 
-  private void assertHead(String projectName, String expectedRef)
-      throws Exception {
-    try (Repository repo =
-        repoManager.openRepository(new Project.NameKey(projectName))) {
-      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName())
-        .isEqualTo(expectedRef);
+  private void assertHead(String projectName, String expectedRef) throws Exception {
+    try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
+      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
     }
   }
 
-  private void assertEmptyCommit(String projectName, String... refs)
-      throws Exception {
+  private void assertEmptyCommit(String projectName, String... refs) throws Exception {
     Project.NameKey projectKey = new Project.NameKey(projectName);
     try (Repository repo = repoManager.openRepository(projectKey);
         RevWalk rw = new RevWalk(repo);
@@ -325,8 +301,8 @@
     }
   }
 
-  private void assertCreateFails(ProjectInput in,
-      Class<? extends RestApiException> errType) throws Exception {
+  private void assertCreateFails(ProjectInput in, Class<? extends RestApiException> errType)
+      throws Exception {
     exception.expect(errType);
     gApi.projects().create(in);
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 955e580..66c61f7 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -14,28 +14,30 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Branch;
-
 import org.junit.Before;
 import org.junit.Test;
 
-@NoHttpd
 public class DeleteBranchIT extends AbstractDaemonTest {
 
   private Branch.NameKey branch;
 
   @Before
   public void setUp() throws Exception {
+    project = createProject(name("p"));
     branch = new Branch.NameKey(project, "test");
     branch().create(new BranchInput());
   }
@@ -65,40 +67,89 @@
   }
 
   @Test
-  public void deleteBranchByProjectOwnerForcePushBlocked_Forbidden()
-      throws Exception {
+  public void deleteBranchByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
     grantOwner();
     blockForcePush();
     setApiUser(user);
     assertDeleteForbidden();
   }
 
+  @Test
+  public void deleteBranchByUserWithForcePushPermission() throws Exception {
+    grantForcePush();
+    setApiUser(user);
+    assertDeleteSucceeds();
+  }
+
+  @Test
+  public void deleteBranchByUserWithDeletePermission() throws Exception {
+    grantDelete();
+    setApiUser(user);
+    assertDeleteSucceeds();
+  }
+
+  @Test
+  public void deleteBranchByRestWithoutRefsHeadsPrefix() throws Exception {
+    grantDelete();
+    String ref = branch.getShortName();
+    assertThat(ref).doesNotMatch(R_HEADS);
+    assertDeleteByRestSucceeds(ref);
+  }
+
+  @Test
+  public void deleteBranchByRestWithEncodedFullName() throws Exception {
+    grantDelete();
+    assertDeleteByRestSucceeds(Url.encode(branch.get()));
+  }
+
+  @Test
+  public void deleteBranchByRestFailsWithUnencodedFullName() throws Exception {
+    grantDelete();
+    RestResponse r =
+        userRestSession.delete("/projects/" + project.get() + "/branches/" + branch.get());
+    r.assertNotFound();
+    branch().get();
+  }
+
   private void blockForcePush() throws Exception {
     block(Permission.PUSH, ANONYMOUS_USERS, "refs/heads/*").setForce(true);
   }
 
+  private void grantForcePush() throws Exception {
+    grant(Permission.PUSH, project, "refs/heads/*", true, ANONYMOUS_USERS);
+  }
+
+  private void grantDelete() throws Exception {
+    allow(Permission.DELETE, ANONYMOUS_USERS, "refs/*");
+  }
+
   private void grantOwner() throws Exception {
     allow(Permission.OWNER, REGISTERED_USERS, "refs/*");
   }
 
   private BranchApi branch() throws Exception {
-    return gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get());
+    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
+  }
+
+  private void assertDeleteByRestSucceeds(String ref) throws Exception {
+    RestResponse r = userRestSession.delete("/projects/" + project.get() + "/branches/" + ref);
+    r.assertNoContent();
+    exception.expect(ResourceNotFoundException.class);
+    branch().get();
   }
 
   private void assertDeleteSucceeds() throws Exception {
     String branchRev = branch().get().revision;
     branch().delete();
-    eventRecorder.assertRefUpdatedEvents(project.get(), branch.get(),
-        null, branchRev,
-        branchRev, null);
+    eventRecorder.assertRefUpdatedEvents(
+        project.get(), branch.get(), null, branchRev, branchRev, null);
     exception.expect(ResourceNotFoundException.class);
     branch().get();
   }
 
   private void assertDeleteForbidden() throws Exception {
     exception.expect(AuthException.class);
+    exception.expectMessage("Cannot delete branch");
     branch().delete();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index af1383b..7580a16 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableList;
@@ -25,20 +27,20 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.RefNames;
-
+import java.util.HashMap;
+import java.util.List;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.HashMap;
-import java.util.List;
-
 @NoHttpd
 public class DeleteBranchesIT extends AbstractDaemonTest {
-  private static final List<String> BRANCHES = ImmutableList.of(
-      "refs/heads/test-1", "refs/heads/test-2", "refs/heads/test-3");
+  private static final ImmutableList<String> BRANCHES =
+      ImmutableList.of("refs/heads/test-1", "refs/heads/test-2", "test-3");
 
   @Before
   public void setUp() throws Exception {
@@ -59,7 +61,24 @@
   }
 
   @Test
-  public void deleteBranchesForbidden() throws Exception {
+  public void deleteOneBranchWithoutPermissionForbidden() throws Exception {
+    ImmutableList<String> branchToDelete = ImmutableList.of("refs/heads/test-1");
+
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = branchToDelete;
+    setApiUser(user);
+    try {
+      project().deleteBranches(input);
+      fail("Expected AuthException");
+    } catch (AuthException e) {
+      assertThat(e).hasMessageThat().isEqualTo("delete not permitted for refs/heads/test-1");
+    }
+    setApiUser(admin);
+    assertBranches(BRANCHES);
+  }
+
+  @Test
+  public void deleteMultiBranchesWithoutPermissionForbidden() throws Exception {
     DeleteBranchesInput input = new DeleteBranchesInput();
     input.branches = BRANCHES;
     setApiUser(user);
@@ -67,7 +86,7 @@
       project().deleteBranches(input);
       fail("Expected ResourceConflictException");
     } catch (ResourceConflictException e) {
-      assertThat(e).hasMessage(errorMessageForBranches(BRANCHES));
+      assertThat(e).hasMessageThat().isEqualTo(errorMessageForBranches(BRANCHES));
     }
     setApiUser(admin);
     assertBranches(BRANCHES);
@@ -83,8 +102,9 @@
       project().deleteBranches(input);
       fail("Expected ResourceConflictException");
     } catch (ResourceConflictException e) {
-      assertThat(e).hasMessage(errorMessageForBranches(
-          ImmutableList.of("refs/heads/does-not-exist")));
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
     }
     assertBranchesDeleted();
   }
@@ -101,25 +121,51 @@
       project().deleteBranches(input);
       fail("Expected ResourceConflictException");
     } catch (ResourceConflictException e) {
-      assertThat(e).hasMessage(errorMessageForBranches(
-          ImmutableList.of("refs/heads/does-not-exist")));
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
     }
     assertBranchesDeleted();
   }
 
+  @Test
+  public void missingInput() throws Exception {
+    DeleteBranchesInput input = null;
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  @Test
+  public void missingBranchList() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
+  @Test
+  public void emptyBranchList() throws Exception {
+    DeleteBranchesInput input = new DeleteBranchesInput();
+    input.branches = Lists.newArrayList();
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("branches must be specified");
+    project().deleteBranches(input);
+  }
+
   private String errorMessageForBranches(List<String> branches) {
     StringBuilder message = new StringBuilder();
     for (String branch : branches) {
-      message.append("Cannot delete ")
-        .append(branch)
-        .append(": it doesn't exist or you do not have permission ")
-        .append("to delete it\n");
+      message
+          .append("Cannot delete ")
+          .append(prefixRef(branch))
+          .append(": it doesn't exist or you do not have permission ")
+          .append("to delete it\n");
     }
     return message.toString();
   }
 
-  private HashMap<String, RevCommit> initialRevisions(List<String> branches)
-      throws Exception {
+  private HashMap<String, RevCommit> initialRevisions(List<String> branches) throws Exception {
     HashMap<String, RevCommit> result = new HashMap<>();
     for (String branch : branches) {
       result.put(branch, getRemoteHead(project, branch));
@@ -127,24 +173,25 @@
     return result;
   }
 
-  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions)
-      throws Exception {
+  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions) throws Exception {
     for (String branch : revisions.keySet()) {
       RevCommit revision = revisions.get(branch);
-      eventRecorder.assertRefUpdatedEvents(project.get(), branch,
-          null, revision,
-          revision, null);
+      eventRecorder.assertRefUpdatedEvents(
+          project.get(), prefixRef(branch), null, revision, revision, null);
     }
   }
 
+  private String prefixRef(String ref) {
+    return ref.startsWith(R_HEADS) ? ref : R_HEADS + ref;
+  }
+
   private ProjectApi project() throws Exception {
     return gApi.projects().name(project.get());
   }
 
   private void assertBranches(List<String> branches) throws Exception {
-    List<String> expected = Lists.newArrayList(
-        "HEAD", RefNames.REFS_CONFIG, "refs/heads/master");
-    expected.addAll(branches);
+    List<String> expected = Lists.newArrayList("HEAD", RefNames.REFS_CONFIG, "refs/heads/master");
+    expected.addAll(branches.stream().map(b -> prefixRef(b)).collect(toList()));
     assertRefNames(expected, project().branches().get());
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
new file mode 100644
index 0000000..5608fb6
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -0,0 +1,126 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.TagApi;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DeleteTagIT extends AbstractDaemonTest {
+  private static final String TAG = "refs/tags/test";
+
+  @Before
+  public void setUp() throws Exception {
+    tag().create(new TagInput());
+  }
+
+  @Test
+  public void deleteTag_Forbidden() throws Exception {
+    setApiUser(user);
+    assertDeleteForbidden();
+  }
+
+  @Test
+  public void deleteTagByAdmin() throws Exception {
+    assertDeleteSucceeds();
+  }
+
+  @Test
+  public void deleteTagByProjectOwner() throws Exception {
+    grantOwner();
+    setApiUser(user);
+    assertDeleteSucceeds();
+  }
+
+  @Test
+  public void deleteTagByAdminForcePushBlocked() throws Exception {
+    blockForcePush();
+    assertDeleteSucceeds();
+  }
+
+  @Test
+  public void deleteTagByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
+    grantOwner();
+    blockForcePush();
+    setApiUser(user);
+    assertDeleteForbidden();
+  }
+
+  @Test
+  public void deleteTagByUserWithForcePushPermission() throws Exception {
+    grantForcePush();
+    setApiUser(user);
+    assertDeleteSucceeds();
+  }
+
+  @Test
+  public void deleteTagByUserWithDeletePermission() throws Exception {
+    grantDelete();
+    setApiUser(user);
+    assertDeleteSucceeds();
+  }
+
+  @Test
+  public void deleteTagByRestWithoutRefsTagsPrefix() throws Exception {
+    grantDelete();
+    String ref = TAG.substring(R_TAGS.length());
+    RestResponse r = userRestSession.delete("/projects/" + project.get() + "/tags/" + ref);
+    r.assertNoContent();
+  }
+
+  private void blockForcePush() throws Exception {
+    block(Permission.PUSH, ANONYMOUS_USERS, "refs/tags/*").setForce(true);
+  }
+
+  private void grantForcePush() throws Exception {
+    grant(Permission.PUSH, project, "refs/tags/*", true, ANONYMOUS_USERS);
+  }
+
+  private void grantDelete() throws Exception {
+    allow(Permission.DELETE, ANONYMOUS_USERS, "refs/tags/*");
+  }
+
+  private void grantOwner() throws Exception {
+    allow(Permission.OWNER, REGISTERED_USERS, "refs/tags/*");
+  }
+
+  private TagApi tag() throws Exception {
+    return gApi.projects().name(project.get()).tag(TAG);
+  }
+
+  private void assertDeleteSucceeds() throws Exception {
+    String tagRev = tag().get().revision;
+    tag().delete();
+    eventRecorder.assertRefUpdatedEvents(project.get(), TAG, null, tagRev, tagRev, null);
+    exception.expect(ResourceNotFoundException.class);
+    tag().get();
+  }
+
+  private void assertDeleteForbidden() throws Exception {
+    exception.expect(AuthException.class);
+    exception.expectMessage("Cannot delete tag");
+    tag().delete();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
new file mode 100644
index 0000000..8f24609
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagsIT.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import java.util.HashMap;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class DeleteTagsIT extends AbstractDaemonTest {
+  private static final ImmutableList<String> TAGS =
+      ImmutableList.of("refs/tags/test-1", "refs/tags/test-2", "refs/tags/test-3", "test-4");
+
+  @Before
+  public void setUp() throws Exception {
+    for (String name : TAGS) {
+      project().tag(name).create(new TagInput());
+    }
+    assertTags(TAGS);
+  }
+
+  @Test
+  public void deleteTags() throws Exception {
+    HashMap<String, RevCommit> initialRevisions = initialRevisions(TAGS);
+    DeleteTagsInput input = new DeleteTagsInput();
+    input.tags = TAGS;
+    project().deleteTags(input);
+    assertTagsDeleted();
+    assertRefUpdatedEvents(initialRevisions);
+  }
+
+  @Test
+  public void deleteTagsForbidden() throws Exception {
+    DeleteTagsInput input = new DeleteTagsInput();
+    input.tags = TAGS;
+    setApiUser(user);
+    try {
+      project().deleteTags(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e).hasMessageThat().isEqualTo(errorMessageForTags(TAGS));
+    }
+    setApiUser(admin);
+    assertTags(TAGS);
+  }
+
+  @Test
+  public void deleteTagsNotFound() throws Exception {
+    DeleteTagsInput input = new DeleteTagsInput();
+    List<String> tags = Lists.newArrayList(TAGS);
+    tags.add("refs/tags/does-not-exist");
+    input.tags = tags;
+    try {
+      project().deleteTags(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
+    }
+    assertTagsDeleted();
+  }
+
+  @Test
+  public void deleteTagsNotFoundContinue() throws Exception {
+    // If it fails on the first tag in the input, it should still
+    // continue to process the remaining tags.
+    DeleteTagsInput input = new DeleteTagsInput();
+    List<String> tags = Lists.newArrayList("refs/tags/does-not-exist");
+    tags.addAll(TAGS);
+    input.tags = tags;
+    try {
+      project().deleteTags(input);
+      fail("Expected ResourceConflictException");
+    } catch (ResourceConflictException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo(errorMessageForTags(ImmutableList.of("refs/tags/does-not-exist")));
+    }
+    assertTagsDeleted();
+  }
+
+  private String errorMessageForTags(List<String> tags) {
+    StringBuilder message = new StringBuilder();
+    for (String tag : tags) {
+      message
+          .append("Cannot delete ")
+          .append(prefixRef(tag))
+          .append(": it doesn't exist or you do not have permission ")
+          .append("to delete it\n");
+    }
+    return message.toString();
+  }
+
+  private HashMap<String, RevCommit> initialRevisions(List<String> tags) throws Exception {
+    HashMap<String, RevCommit> result = new HashMap<>();
+    for (String tag : tags) {
+      String ref = prefixRef(tag);
+      result.put(ref, getRemoteHead(project, ref));
+    }
+    return result;
+  }
+
+  private void assertRefUpdatedEvents(HashMap<String, RevCommit> revisions) throws Exception {
+    for (String tag : revisions.keySet()) {
+      RevCommit revision = revisions.get(prefixRef(tag));
+      eventRecorder.assertRefUpdatedEvents(
+          project.get(), prefixRef(tag), null, revision, revision, null);
+    }
+  }
+
+  private String prefixRef(String ref) {
+    return ref.startsWith(R_TAGS) ? ref : R_TAGS + ref;
+  }
+
+  private ProjectApi project() throws Exception {
+    return gApi.projects().name(project.get());
+  }
+
+  private void assertTags(List<String> expected) throws Exception {
+    List<TagInfo> actualTags = project().tags().get();
+    Iterable<String> actualNames = Iterables.transform(actualTags, b -> b.ref);
+    assertThat(actualNames)
+        .containsExactlyElementsIn(expected.stream().map(t -> prefixRef(t)).collect(toList()))
+        .inOrder();
+  }
+
+  private void assertTagsDeleted() throws Exception {
+    assertTags(ImmutableList.<String>of());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index 66d04df..63f41ad 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Branch;
-
 import org.junit.Before;
 import org.junit.Test;
 
@@ -52,8 +51,6 @@
   }
 
   private BranchApi branch() throws Exception {
-    return gApi.projects()
-        .name(branch.getParentKey().get())
-        .branch(branch.get());
+    return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
index 8522a4d..78d0270 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GarbageCollectionIT.java
@@ -20,14 +20,12 @@
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
-
 import org.junit.Before;
 import org.junit.Test;
 
 public class GarbageCollectionIT extends AbstractDaemonTest {
 
-  @Inject
-  private GcAssert gcAssert;
+  @Inject private GcAssert gcAssert;
 
   private Project.NameKey project2;
 
@@ -37,15 +35,13 @@
   }
 
   @Test
-  public void testGcNonExistingProject_NotFound() throws Exception {
+  public void gcNonExistingProject_NotFound() throws Exception {
     POST("/projects/non-existing/gc").assertNotFound();
   }
 
   @Test
-  public void testGcNotAllowed_Forbidden() throws Exception {
-    userRestSession
-        .post("/projects/" + allProjects.get() + "/gc")
-        .assertForbidden();
+  public void gcNotAllowed_Forbidden() throws Exception {
+    userRestSession.post("/projects/" + allProjects.get() + "/gc").assertForbidden();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
index f87b921..d5e811d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetChildProjectIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Project;
-
 import org.junit.Test;
 
 @NoHttpd
@@ -43,8 +42,7 @@
   @Test
   public void getChildProject() throws Exception {
     Project.NameKey child = createProject("p1");
-    ProjectInfo childInfo = gApi.projects().name(allProjects.get())
-        .child(child.get()).get();
+    ProjectInfo childInfo = gApi.projects().name(allProjects.get()).child(child.get()).get();
 
     assertProjectInfo(projectCache.get(child).getProject(), childInfo);
   }
@@ -62,14 +60,12 @@
     Project.NameKey child = createProject("p1");
     Project.NameKey grandChild = createProject("p1.1", child);
 
-    ProjectInfo grandChildInfo = gApi.projects().name(allProjects.get())
-        .child(grandChild.get()).get(true);
-    assertProjectInfo(
-        projectCache.get(grandChild).getProject(), grandChildInfo);
+    ProjectInfo grandChildInfo =
+        gApi.projects().name(allProjects.get()).child(grandChild.get()).get(true);
+    assertProjectInfo(projectCache.get(grandChild).getProject(), grandChildInfo);
   }
 
-  private void assertChildNotFound(Project.NameKey parent, String child)
-      throws Exception {
+  private void assertChildNotFound(Project.NameKey parent, String child) throws Exception {
     exception.expect(ResourceNotFoundException.class);
     exception.expectMessage(child);
     gApi.projects().name(parent.get()).child(child).get();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index 307d512..76d17f1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.git.ProjectConfig;
-
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -51,17 +50,14 @@
 
   @Test
   public void getNonExistingCommit_NotFound() throws Exception {
-    assertNotFound(
-        ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    assertNotFound(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
   }
 
   @Test
   public void getMergedCommit_Found() throws Exception {
     unblockRead();
-    RevCommit commit = repo.parseBody(repo.branch("master")
-        .commit()
-        .message("Create\n\nNew commit\n")
-        .create());
+    RevCommit commit =
+        repo.parseBody(repo.branch("master").commit().message("Create\n\nNew commit\n").create());
 
     CommitInfo info = getCommit(commit);
     assertThat(info.commit).isEqualTo(commit.name());
@@ -82,25 +78,22 @@
 
   @Test
   public void getMergedCommit_NotFound() throws Exception {
-    RevCommit commit = repo.parseBody(repo.branch("master")
-        .commit()
-        .message("Create\n\nNew commit\n")
-        .create());
+    RevCommit commit =
+        repo.parseBody(repo.branch("master").commit().message("Create\n\nNew commit\n").create());
     assertNotFound(commit);
   }
 
   @Test
   public void getOpenChange_Found() throws Exception {
     unblockRead();
-    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), testRepo)
-        .to("refs/for/master");
+    PushOneCommit.Result r =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
     r.assertOkStatus();
 
     CommitInfo info = getCommit(r.getCommit());
     assertThat(info.commit).isEqualTo(r.getCommit().name());
     assertThat(info.subject).isEqualTo("test commit");
-    assertThat(info.message).isEqualTo(
-        "test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    assertThat(info.message).isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
     assertThat(info.author.name).isEqualTo("Administrator");
     assertThat(info.author.email).isEqualTo("admin@example.com");
     assertThat(info.committer.name).isEqualTo("Administrator");
@@ -116,8 +109,8 @@
 
   @Test
   public void getOpenChange_NotFound() throws Exception {
-    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), testRepo)
-        .to("refs/for/master");
+    PushOneCommit.Result r =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
     r.assertOkStatus();
     assertNotFound(r.getCommit());
   }
@@ -129,14 +122,11 @@
   }
 
   private void assertNotFound(ObjectId id) throws Exception {
-    userRestSession
-        .get("/projects/" + project.get() + "/commits/" + id.name())
-        .assertNotFound();
+    userRestSession.get("/projects/" + project.get() + "/commits/" + id.name()).assertNotFound();
   }
 
   private CommitInfo getCommit(ObjectId id) throws Exception {
-    RestResponse r = userRestSession.get(
-        "/projects/" + project.get() + "/commits/" + id.name());
+    RestResponse r = userRestSession.get("/projects/" + project.get() + "/commits/" + id.name());
     r.assertOK();
     CommitInfo result = newGson().fromJson(r.getReader(), CommitInfo.class);
     r.consume();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index 24b1770..53e5b55 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-
 import org.junit.Test;
 
 @NoHttpd
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 7c98188..b62fd68 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertBranches;
-import static com.google.gerrit.acceptance.rest.project.BranchAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
+import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
+import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -23,9 +24,9 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.RefNames;
-
 import org.junit.Test;
 
 @NoHttpd
@@ -47,9 +48,8 @@
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void listBranchesOfEmptyProject() throws Exception {
-    assertBranches(ImmutableList.of(
-          branch("HEAD", null, false),
-          branch(RefNames.REFS_CONFIG,  null, false)),
+    assertRefs(
+        ImmutableList.of(branch("HEAD", null, false), branch(RefNames.REFS_CONFIG, null, false)),
         list().get());
   }
 
@@ -57,11 +57,12 @@
   public void listBranches() throws Exception {
     String master = pushTo("refs/heads/master").getCommit().name();
     String dev = pushTo("refs/heads/dev").getCommit().name();
-    assertBranches(ImmutableList.of(
-          branch("HEAD", "master", false),
-          branch(RefNames.REFS_CONFIG,  null, false),
-          branch("refs/heads/dev", dev, true),
-          branch("refs/heads/master", master, false)),
+    assertRefs(
+        ImmutableList.of(
+            branch("HEAD", "master", false),
+            branch(RefNames.REFS_CONFIG, null, false),
+            branch("refs/heads/dev", dev, true),
+            branch("refs/heads/master", master, false)),
         list().get());
   }
 
@@ -72,9 +73,9 @@
     pushTo("refs/heads/dev");
     setApiUser(user);
     // refs/meta/config is hidden since user is no project owner
-    assertBranches(ImmutableList.of(
-          branch("HEAD", "master", false),
-          branch("refs/heads/master", master, false)),
+    assertRefs(
+        ImmutableList.of(
+            branch("HEAD", "master", false), branch("refs/heads/master", master, false)),
         list().get());
   }
 
@@ -85,8 +86,7 @@
     String dev = pushTo("refs/heads/dev").getCommit().name();
     setApiUser(user);
     // refs/meta/config is hidden since user is no project owner
-    assertBranches(ImmutableList.of(branch("refs/heads/dev", dev, false)),
-        list().get());
+    assertRefs(ImmutableList.of(branch("refs/heads/dev", dev, false)), list().get());
   }
 
   @Test
@@ -97,38 +97,37 @@
     pushTo("refs/heads/someBranch3");
 
     // Using only limit.
-    assertRefNames(ImmutableList.of(
-          "HEAD",
-          RefNames.REFS_CONFIG,
-          "refs/heads/master",
-          "refs/heads/someBranch1"),
+    assertRefNames(
+        ImmutableList.of(
+            "HEAD", RefNames.REFS_CONFIG, "refs/heads/master", "refs/heads/someBranch1"),
         list().withLimit(4).get());
 
     // Limit higher than total number of branches.
-    assertRefNames(ImmutableList.of(
-          "HEAD",
-          RefNames.REFS_CONFIG,
-          "refs/heads/master",
-          "refs/heads/someBranch1",
-          "refs/heads/someBranch2",
-          "refs/heads/someBranch3"),
+    assertRefNames(
+        ImmutableList.of(
+            "HEAD",
+            RefNames.REFS_CONFIG,
+            "refs/heads/master",
+            "refs/heads/someBranch1",
+            "refs/heads/someBranch2",
+            "refs/heads/someBranch3"),
         list().withLimit(25).get());
 
     // Using start only.
-    assertRefNames(ImmutableList.of(
-          "refs/heads/master",
-          "refs/heads/someBranch1",
-          "refs/heads/someBranch2",
-          "refs/heads/someBranch3"),
+    assertRefNames(
+        ImmutableList.of(
+            "refs/heads/master",
+            "refs/heads/someBranch1",
+            "refs/heads/someBranch2",
+            "refs/heads/someBranch3"),
         list().withStart(2).get());
 
     // Skip more branches than the number of available branches.
-    assertRefNames(ImmutableList.<String> of(), list().withStart(7).get());
+    assertRefNames(ImmutableList.<String>of(), list().withStart(7).get());
 
     // Ssing start and limit.
-    assertRefNames(ImmutableList.of(
-          "refs/heads/master",
-          "refs/heads/someBranch1"),
+    assertRefNames(
+        ImmutableList.of("refs/heads/master", "refs/heads/someBranch1"),
         list().withStart(2).withLimit(2).get());
   }
 
@@ -140,33 +139,47 @@
     pushTo("refs/heads/someBranch3");
 
     // Using substring.
-    assertRefNames(ImmutableList.of(
-          "refs/heads/someBranch1",
-          "refs/heads/someBranch2",
-          "refs/heads/someBranch3"),
+    assertRefNames(
+        ImmutableList.of(
+            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
         list().withSubstring("some").get());
 
-    assertRefNames(ImmutableList.of(
-          "refs/heads/someBranch1",
-          "refs/heads/someBranch2",
-          "refs/heads/someBranch3"),
+    assertRefNames(
+        ImmutableList.of(
+            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
         list().withSubstring("Branch").get());
 
+    assertRefNames(
+        ImmutableList.of(
+            "refs/heads/someBranch1", "refs/heads/someBranch2", "refs/heads/someBranch3"),
+        list().withSubstring("somebranch").get());
+
     // Using regex.
-    assertRefNames(ImmutableList.of("refs/heads/master"),
-        list().withRegex(".*ast.*r").get());
+    assertRefNames(ImmutableList.of("refs/heads/master"), list().withRegex(".*ast.*r").get());
+    assertRefNames(ImmutableList.of(), list().withRegex(".*AST.*R").get());
+
+    // Conflicting options
+    assertBadRequest(list().withSubstring("somebranch").withRegex(".*ast.*r"));
   }
 
   private ListRefsRequest<BranchInfo> list() throws Exception {
     return gApi.projects().name(project.get()).branches();
   }
 
-  private static BranchInfo branch(String ref, String revision,
-        boolean canDelete) {
+  private static BranchInfo branch(String ref, String revision, boolean canDelete) {
     BranchInfo info = new BranchInfo();
     info.ref = ref;
     info.revision = revision;
     info.canDelete = canDelete ? true : null;
     return info;
   }
+
+  private void assertBadRequest(ListRefsRequest<BranchInfo> req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException e) {
+      // Expected
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 78e0ba2..dd92a7a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Project;
-
 import org.junit.Test;
 
 @NoHttpd
@@ -35,8 +34,7 @@
 
   @Test
   public void listNoChildren() throws Exception {
-    assertThatNameList(gApi.projects().name(project.get()).children())
-        .isEmpty();
+    assertThatNameList(gApi.projects().name(project.get()).children()).isEmpty();
   }
 
   @Test
@@ -46,7 +44,8 @@
     Project.NameKey child1_2 = createProject("p1.2", child1);
 
     assertThatNameList(gApi.projects().name(child1.get()).children())
-        .containsExactly(child1_1, child1_2).inOrder();
+        .containsExactly(child1_1, child1_2)
+        .inOrder();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index e86bb29..a31a34c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -34,23 +33,21 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.Util;
 import com.google.inject.Inject;
-
-import org.junit.Test;
-
 import java.util.List;
 import java.util.Map;
+import org.junit.Test;
 
 @NoHttpd
 public class ListProjectsIT extends AbstractDaemonTest {
 
-  @Inject
-  private AllUsersName allUsers;
+  @Inject private AllUsersName allUsers;
 
   @Test
   public void listProjects() throws Exception {
     Project.NameKey someProject = createProject("some-project");
     assertThatNameList(filter(gApi.projects().list().get()))
-        .containsExactly(allProjects, allUsers, project, someProject).inOrder();
+        .containsExactly(allProjects, allUsers, project, someProject)
+        .inOrder();
   }
 
   @Test
@@ -62,14 +59,12 @@
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(project, cfg);
 
-    assertThatNameList(filter(gApi.projects().list().get()))
-        .doesNotContain(project);
+    assertThatNameList(filter(gApi.projects().list().get())).doesNotContain(project);
   }
 
   @Test
   public void listProjectsWithBranch() throws Exception {
-    Map<String, ProjectInfo> result = gApi.projects().list()
-        .addShowBranch("master").getAsMap();
+    Map<String, ProjectInfo> result = gApi.projects().list().addShowBranch("master").getAsMap();
     assertThat(result).containsKey(project.get());
     ProjectInfo info = result.get(project.get());
     assertThat(info.branches).isNotNull();
@@ -87,8 +82,7 @@
 
     result = gApi.projects().list().withDescription(true).getAsMap();
     assertThat(result).containsKey(project.get());
-    assertThat(result.get(project.get()).description).isEqualTo(
-        "Description of some-project");
+    assertThat(result.get(project.get()).description).isEqualTo("Description of some-project");
   }
 
   @Test
@@ -101,8 +95,7 @@
     // 5, plus p which was automatically created.
     int n = 6;
     for (int i = 1; i <= n + 2; i++) {
-      assertThatNameList(gApi.projects().list().withPrefix(p)
-              .withLimit(i).get())
+      assertThatNameList(gApi.projects().list().withPrefix(p).withLimit(i).get())
           .hasSize(Math.min(i, n));
     }
   }
@@ -117,7 +110,10 @@
     assertBadRequest(gApi.projects().list().withPrefix(p).withRegex(".*"));
     assertBadRequest(gApi.projects().list().withPrefix(p).withSubstring(p));
     assertThatNameList(filter(gApi.projects().list().withPrefix(p).get()))
-        .containsExactly(someOtherProject, someProject).inOrder();
+        .containsExactly(someOtherProject, someProject)
+        .inOrder();
+    p = name("SOME");
+    assertThatNameList(filter(gApi.projects().list().withPrefix(p).get())).isEmpty();
   }
 
   @Test
@@ -136,8 +132,8 @@
     assertThatNameList(filter(gApi.projects().list().withRegex(r).get()))
         .containsExactly(someProject);
     assertThatNameList(filter(gApi.projects().list().withRegex(".*").get()))
-        .containsExactly(allProjects, allUsers, project, projectAwesome,
-            someOtherProject, someProject)
+        .containsExactly(
+            allProjects, allUsers, project, projectAwesome, someOtherProject, someProject)
         .inOrder();
   }
 
@@ -152,8 +148,7 @@
     // 5, plus p which was automatically created.
     int n = 6;
     assertThat(all).hasSize(n);
-    assertThatNameList(gApi.projects().list().withPrefix(p)
-            .withStart(n - 1).get())
+    assertThatNameList(gApi.projects().list().withPrefix(p).withStart(n - 1).get())
         .containsExactly(new Project.NameKey(Iterables.getLast(all).name));
   }
 
@@ -163,12 +158,12 @@
     Project.NameKey someOtherProject = createProject("some-other-project");
     Project.NameKey projectAwesome = createProject("project-awesome");
 
-    assertBadRequest(gApi.projects().list().withSubstring("some")
-        .withRegex(".*"));
-    assertBadRequest(gApi.projects().list().withSubstring("some")
-        .withPrefix("some"));
-    assertThatNameList(filter(gApi.projects().list().withSubstring("some")
-            .get()))
+    assertBadRequest(gApi.projects().list().withSubstring("some").withRegex(".*"));
+    assertBadRequest(gApi.projects().list().withSubstring("some").withPrefix("some"));
+    assertThatNameList(filter(gApi.projects().list().withSubstring("some").get()))
+        .containsExactly(projectAwesome, someOtherProject, someProject)
+        .inOrder();
+    assertThatNameList(filter(gApi.projects().list().withSubstring("SOME").get()))
         .containsExactly(projectAwesome, someOtherProject, someProject)
         .inOrder();
   }
@@ -176,26 +171,23 @@
   @Test
   public void listProjectsWithTree() throws Exception {
     Project.NameKey someParentProject = createProject("some-parent-project");
-    Project.NameKey someChildProject =
-        createProject("some-child-project", someParentProject);
+    Project.NameKey someChildProject = createProject("some-child-project", someParentProject);
 
-    Map<String, ProjectInfo> result = gApi.projects().list().withTree(true)
-        .getAsMap();
+    Map<String, ProjectInfo> result = gApi.projects().list().withTree(true).getAsMap();
     assertThat(result).containsKey(someChildProject.get());
-    assertThat(result.get(someChildProject.get()).parent)
-        .isEqualTo(someParentProject.get());
+    assertThat(result.get(someChildProject.get()).parent).isEqualTo(someParentProject.get());
   }
 
   @Test
   public void listProjectWithType() throws Exception {
-    Map<String, ProjectInfo> result = gApi.projects().list()
-        .withType(FilterType.PERMISSIONS).getAsMap();
+    Map<String, ProjectInfo> result =
+        gApi.projects().list().withType(FilterType.PERMISSIONS).getAsMap();
     assertThat(result).hasSize(1);
     assertThat(result).containsKey(allProjects.get());
 
-    assertThatNameList(filter(gApi.projects().list().withType(FilterType.ALL)
-            .get()))
-        .containsExactly(allProjects, allUsers, project).inOrder();
+    assertThatNameList(filter(gApi.projects().list().withType(FilterType.ALL).get()))
+        .containsExactly(allProjects, allUsers, project)
+        .inOrder();
   }
 
   private void assertBadRequest(ListRequest req) throws Exception {
@@ -208,15 +200,14 @@
   }
 
   private Iterable<ProjectInfo> filter(Iterable<ProjectInfo> infos) {
-    final String prefix = name("");
-    return Iterables.filter(infos, new Predicate<ProjectInfo>() {
-      @Override
-      public boolean apply(ProjectInfo in) {
-        return in.name != null && (
-            in.name.equals(allProjects.get())
-            || in.name.equals(allUsers.get())
-            || in.name.startsWith(prefix));
-      }
-    });
+    String prefix = name("");
+    return Iterables.filter(
+        infos,
+        p -> {
+          return p.name != null
+              && (p.name.equals(allProjects.get())
+                  || p.name.equals(allUsers.get())
+                  || p.name.startsWith(prefix));
+        });
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
index db6df95..3b5a3a4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -27,17 +26,11 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.ProjectState;
-
 import java.util.List;
 import java.util.Set;
 
 public class ProjectAssert {
-  public static IterableSubject<
-        ? extends IterableSubject<
-            ?, Project.NameKey, Iterable<Project.NameKey>>,
-        Project.NameKey,
-        Iterable<Project.NameKey>>
-      assertThatNameList(Iterable<ProjectInfo> actualIt) {
+  public static IterableSubject assertThatNameList(Iterable<ProjectInfo> actualIt) {
     List<ProjectInfo> actual = ImmutableList.copyOf(actualIt);
     for (ProjectInfo info : actual) {
       assertWithMessage("missing project name").that(info.name).isNotNull();
@@ -45,13 +38,7 @@
           .that(Url.decode(info.id))
           .isEqualTo(info.name);
     }
-    return assertThat(Iterables.transform(actual,
-        new Function<ProjectInfo, Project.NameKey>() {
-          @Override
-          public Project.NameKey apply(ProjectInfo in) {
-            return new Project.NameKey(in.name);
-          }
-        }));
+    return assertThat(Iterables.transform(actual, p -> new Project.NameKey(p.name)));
   }
 
   public static void assertProjectInfo(Project project, ProjectInfo info) {
@@ -66,12 +53,11 @@
     } else {
       assertThat(info.parent).isNull();
     }
-    assertThat(Strings.nullToEmpty(info.description)).isEqualTo(
-        project.getDescription());
+    assertThat(Strings.nullToEmpty(info.description)).isEqualTo(project.getDescription());
   }
 
-  public static void assertProjectOwners(Set<AccountGroup.UUID> expectedOwners,
-      ProjectState state) {
+  public static void assertProjectOwners(
+      Set<AccountGroup.UUID> expectedOwners, ProjectState state) {
     for (AccountGroup.UUID g : state.getOwners()) {
       assertThat(expectedOwners.remove(g)).isTrue();
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index 649534b..c78b47b 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.ProjectState;
-
+import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
@@ -42,13 +42,17 @@
     cfg.setString("s1", null, "k1", "v1");
     cfg.setString("s2", "ss", "k2", "v2");
     PushOneCommit push =
-        pushFactory.create(db, admin.getIdent(), testRepo, "Create Project Level Config",
-            configName, cfg.toText());
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Create Project Level Config",
+            configName,
+            cfg.toText());
     push.to(RefNames.REFS_CONFIG);
 
     ProjectState state = projectCache.get(project);
-    assertThat(state.getConfig(configName).get().toText()).isEqualTo(
-        cfg.toText());
+    assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
   }
 
   @Test
@@ -67,9 +71,14 @@
     parentCfg.setString("s2", "ss", "k3", "parentValue3");
     parentCfg.setString("s2", "ss", "k4", "parentValue4");
 
-    pushFactory.create(
-          db, admin.getIdent(), testRepo, "Create Project Level Config",
-          configName, parentCfg.toText())
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Create Project Level Config",
+            configName,
+            parentCfg.toText())
         .to(RefNames.REFS_CONFIG)
         .assertOkStatus();
 
@@ -82,9 +91,14 @@
     cfg.setString("s1", null, "k1", "childValue1");
     cfg.setString("s2", "ss", "k3", "childValue2");
 
-    pushFactory.create(
-          db, admin.getIdent(), childTestRepo, "Create Project Level Config",
-          configName, cfg.toText())
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            childTestRepo,
+            "Create Project Level Config",
+            configName,
+            cfg.toText())
         .to(RefNames.REFS_CONFIG)
         .assertOkStatus();
 
@@ -99,7 +113,69 @@
     assertThat(state.getConfig(configName).getWithInheritance().toText())
         .isEqualTo(expectedCfg.toText());
 
-    assertThat(state.getConfig(configName).get().toText()).isEqualTo(
-        cfg.toText());
+    assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
+  }
+
+  @Test
+  public void withMergedInheritance() throws Exception {
+    String configName = "test.config";
+
+    Config parentCfg = new Config();
+    parentCfg.setString("s1", null, "k1", "parentValue1");
+    parentCfg.setString("s1", null, "k2", "parentValue2");
+    parentCfg.setString("s2", "ss", "k3", "parentValue3");
+    parentCfg.setString("s2", "ss", "k4", "parentValue4");
+
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Create Project Level Config",
+            configName,
+            parentCfg.toText())
+        .to(RefNames.REFS_CONFIG)
+        .assertOkStatus();
+
+    Project.NameKey childProject = createProject("child", project);
+    TestRepository<?> childTestRepo = cloneProject(childProject);
+    fetch(childTestRepo, RefNames.REFS_CONFIG + ":refs/heads/config");
+    childTestRepo.reset("refs/heads/config");
+
+    Config cfg = new Config();
+    cfg.setString("s1", null, "k1", "parentValue1");
+    cfg.setString("s1", null, "k2", "parentValue2");
+    cfg.setString("s2", "ss", "k3", "parentValue3");
+    cfg.setString("s2", "ss", "k4", "parentValue4");
+    cfg.setString("s1", null, "k1", "childValue1");
+    cfg.setString("s2", "ss", "k3", "childValue2");
+    cfg.setString("s3", null, "k5", "childValue3");
+    cfg.setString("s3", "ss", "k6", "childValue4");
+
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            childTestRepo,
+            "Create Project Level Config",
+            configName,
+            cfg.toText())
+        .to(RefNames.REFS_CONFIG)
+        .assertOkStatus();
+
+    ProjectState state = projectCache.get(childProject);
+
+    Config expectedCfg = new Config();
+    expectedCfg.setStringList("s1", null, "k1", Arrays.asList("childValue1", "parentValue1"));
+    expectedCfg.setString("s1", null, "k2", "parentValue2");
+    expectedCfg.setStringList("s2", "ss", "k3", Arrays.asList("childValue2", "parentValue3"));
+    expectedCfg.setString("s2", "ss", "k4", "parentValue4");
+    expectedCfg.setString("s3", null, "k5", "childValue3");
+    expectedCfg.setString("s3", "ss", "k6", "childValue4");
+
+    assertThat(state.getConfig(configName).getWithInheritance(true).toText())
+        .isEqualTo(expectedCfg.toText());
+
+    assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
new file mode 100644
index 0000000..7ed15f4
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
@@ -0,0 +1,275 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.createAnnotatedTag;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.GitUtil.updateAnnotatedTag;
+import static com.google.gerrit.acceptance.rest.project.PushTagIT.TagType.ANNOTATED;
+import static com.google.gerrit.acceptance.rest.project.PushTagIT.TagType.LIGHTWEIGHT;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.RefNames;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class PushTagIT extends AbstractDaemonTest {
+  enum TagType {
+    LIGHTWEIGHT(Permission.CREATE),
+    ANNOTATED(Permission.CREATE_TAG);
+
+    final String createPermission;
+
+    TagType(String createPermission) {
+      this.createPermission = createPermission;
+    }
+  }
+
+  private RevCommit initialHead;
+
+  @Before
+  public void setup() throws Exception {
+    // clone with user to avoid inherited tag permissions of admin user
+    testRepo = cloneProject(project, user);
+
+    initialHead = getRemoteHead();
+  }
+
+  @Test
+  public void createTagForExistingCommit() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      pushTagForExistingCommit(tagType, Status.REJECTED_OTHER_REASON);
+
+      allowTagCreation(tagType);
+      pushTagForExistingCommit(tagType, Status.OK);
+
+      allowPushOnRefsTags();
+      pushTagForExistingCommit(tagType, Status.OK);
+
+      removePushFromRefsTags();
+    }
+  }
+
+  @Test
+  public void createTagForNewCommit() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      pushTagForNewCommit(tagType, Status.REJECTED_OTHER_REASON);
+
+      allowTagCreation(tagType);
+      pushTagForNewCommit(tagType, Status.REJECTED_OTHER_REASON);
+
+      allowPushOnRefsTags();
+      pushTagForNewCommit(tagType, Status.OK);
+
+      removePushFromRefsTags();
+    }
+  }
+
+  @Test
+  public void fastForward() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      allowTagCreation(tagType);
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+
+      fastForwardTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+      fastForwardTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowTagDeletion();
+      fastForwardTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+      fastForwardTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowPushOnRefsTags();
+      Status expectedStatus = tagType == ANNOTATED ? Status.REJECTED_OTHER_REASON : Status.OK;
+      fastForwardTagToExistingCommit(tagType, tagName, expectedStatus);
+      fastForwardTagToNewCommit(tagType, tagName, expectedStatus);
+
+      allowForcePushOnRefsTags();
+      fastForwardTagToExistingCommit(tagType, tagName, Status.OK);
+      fastForwardTagToNewCommit(tagType, tagName, Status.OK);
+
+      removePushFromRefsTags();
+    }
+  }
+
+  @Test
+  public void forceUpdate() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      allowTagCreation(tagType);
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+
+      forceUpdateTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowPushOnRefsTags();
+      forceUpdateTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowTagDeletion();
+      forceUpdateTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowForcePushOnRefsTags();
+      forceUpdateTagToExistingCommit(tagType, tagName, Status.OK);
+      forceUpdateTagToNewCommit(tagType, tagName, Status.OK);
+
+      removePushFromRefsTags();
+    }
+  }
+
+  @Test
+  public void delete() throws Exception {
+    for (TagType tagType : TagType.values()) {
+      allowTagCreation(tagType);
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+
+      pushTagDeletion(tagType, tagName, Status.REJECTED_OTHER_REASON);
+
+      allowPushOnRefsTags();
+      pushTagDeletion(tagType, tagName, Status.REJECTED_OTHER_REASON);
+    }
+
+    allowForcePushOnRefsTags();
+    for (TagType tagType : TagType.values()) {
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+      pushTagDeletion(tagType, tagName, Status.OK);
+    }
+
+    removePushFromRefsTags();
+    allowTagDeletion();
+    for (TagType tagType : TagType.values()) {
+      String tagName = pushTagForExistingCommit(tagType, Status.OK);
+      pushTagDeletion(tagType, tagName, Status.OK);
+    }
+  }
+
+  private String pushTagForExistingCommit(TagType tagType, Status expectedStatus) throws Exception {
+    return pushTag(tagType, null, false, false, expectedStatus);
+  }
+
+  private String pushTagForNewCommit(TagType tagType, Status expectedStatus) throws Exception {
+    return pushTag(tagType, null, true, false, expectedStatus);
+  }
+
+  private void fastForwardTagToExistingCommit(
+      TagType tagType, String tagName, Status expectedStatus) throws Exception {
+    pushTag(tagType, tagName, false, false, expectedStatus);
+  }
+
+  private void fastForwardTagToNewCommit(TagType tagType, String tagName, Status expectedStatus)
+      throws Exception {
+    pushTag(tagType, tagName, true, false, expectedStatus);
+  }
+
+  private void forceUpdateTagToExistingCommit(
+      TagType tagType, String tagName, Status expectedStatus) throws Exception {
+    pushTag(tagType, tagName, false, true, expectedStatus);
+  }
+
+  private void forceUpdateTagToNewCommit(TagType tagType, String tagName, Status expectedStatus)
+      throws Exception {
+    pushTag(tagType, tagName, true, true, expectedStatus);
+  }
+
+  private String pushTag(
+      TagType tagType, String tagName, boolean newCommit, boolean force, Status expectedStatus)
+      throws Exception {
+    if (force) {
+      testRepo.reset(initialHead);
+    }
+    commit(user.getIdent(), "subject");
+
+    boolean createTag = tagName == null;
+    tagName = MoreObjects.firstNonNull(tagName, "v1_" + System.nanoTime());
+    switch (tagType) {
+      case LIGHTWEIGHT:
+        break;
+      case ANNOTATED:
+        if (createTag) {
+          createAnnotatedTag(testRepo, tagName, user.getIdent());
+        } else {
+          updateAnnotatedTag(testRepo, tagName, user.getIdent());
+        }
+        break;
+      default:
+        throw new IllegalStateException("unexpected tag type: " + tagType);
+    }
+
+    if (!newCommit) {
+      grant(Permission.SUBMIT, project, "refs/for/refs/heads/master", false, REGISTERED_USERS);
+      pushHead(testRepo, "refs/for/master%submit");
+    }
+
+    String tagRef = tagRef(tagName);
+    PushResult r =
+        tagType == LIGHTWEIGHT
+            ? pushHead(testRepo, tagRef, false, force)
+            : GitUtil.pushTag(testRepo, tagName, !createTag);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
+    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+    return tagName;
+  }
+
+  private void pushTagDeletion(TagType tagType, String tagName, Status expectedStatus)
+      throws Exception {
+    String tagRef = tagRef(tagName);
+    PushResult r = deleteRef(testRepo, tagRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
+    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+  }
+
+  private void allowTagCreation(TagType tagType) throws Exception {
+    grant(tagType.createPermission, project, "refs/tags/*", false, REGISTERED_USERS);
+  }
+
+  private void allowPushOnRefsTags() throws Exception {
+    removePushFromRefsTags();
+    grant(Permission.PUSH, project, "refs/tags/*", false, REGISTERED_USERS);
+  }
+
+  private void allowForcePushOnRefsTags() throws Exception {
+    removePushFromRefsTags();
+    grant(Permission.PUSH, project, "refs/tags/*", true, REGISTERED_USERS);
+  }
+
+  private void allowTagDeletion() throws Exception {
+    removePushFromRefsTags();
+    grant(Permission.DELETE, project, "refs/tags/*", true, REGISTERED_USERS);
+  }
+
+  private void removePushFromRefsTags() throws Exception {
+    removePermission(Permission.PUSH, project, "refs/tags/*");
+  }
+
+  private void commit(PersonIdent ident, String subject) throws Exception {
+    commitBuilder().ident(ident).message(subject + " (" + System.nanoTime() + ")").create();
+  }
+
+  private static String tagRef(String tagName) {
+    return RefNames.REFS_TAGS + tagName;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
new file mode 100644
index 0000000..b3e3d2f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/RefAssert.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.api.projects.RefInfo;
+import java.util.List;
+
+public class RefAssert {
+  public static void assertRefs(
+      List<? extends RefInfo> expectedRefs, List<? extends RefInfo> actualRefs) {
+    assertRefNames(refs(expectedRefs), actualRefs);
+    for (int i = 0; i < expectedRefs.size(); i++) {
+      assertRefInfo(expectedRefs.get(i), actualRefs.get(i));
+    }
+  }
+
+  public static void assertRefNames(
+      Iterable<String> expectedRefs, Iterable<? extends RefInfo> actualRefs) {
+    Iterable<String> actualNames = refs(actualRefs);
+    assertThat(actualNames).containsExactlyElementsIn(expectedRefs).inOrder();
+  }
+
+  public static void assertRefInfo(RefInfo expected, RefInfo actual) {
+    assertThat(actual.ref).isEqualTo(expected.ref);
+    if (expected.revision != null) {
+      assertThat(actual.revision).named("revision of " + actual.ref).isEqualTo(expected.revision);
+    }
+    assertThat(toBoolean(actual.canDelete))
+        .named("can delete " + actual.ref)
+        .isEqualTo(toBoolean(expected.canDelete));
+  }
+
+  private static Iterable<String> refs(Iterable<? extends RefInfo> infos) {
+    return Iterables.transform(infos, b -> b.ref);
+  }
+
+  private static boolean toBoolean(Boolean b) {
+    if (b == null) {
+      return false;
+    }
+    return b.booleanValue();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
index b106e99..841e398 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/SetParentIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.project.SetParent;
-
 import org.junit.Test;
 
 public class SetParentIT extends AbstractDaemonTest {
@@ -29,8 +28,7 @@
   public void setParent_Forbidden() throws Exception {
     String parent = createProject("parent", null, true).get();
     RestResponse r =
-        userRestSession.put("/projects/" + project.get() + "/parent",
-            newParentInput(parent));
+        userRestSession.put("/projects/" + project.get() + "/parent", newParentInput(parent));
     r.assertForbidden();
     r.consume();
   }
@@ -39,22 +37,19 @@
   public void setParent() throws Exception {
     String parent = createProject("parent", null, true).get();
     RestResponse r =
-        adminRestSession.put("/projects/" + project.get() + "/parent",
-            newParentInput(parent));
+        adminRestSession.put("/projects/" + project.get() + "/parent", newParentInput(parent));
     r.assertOK();
     r.consume();
 
     r = adminRestSession.get("/projects/" + project.get() + "/parent");
     r.assertOK();
-    String newParent =
-        newGson().fromJson(r.getReader(), String.class);
+    String newParent = newGson().fromJson(r.getReader(), String.class);
     assertThat(newParent).isEqualTo(parent);
     r.consume();
 
     // When the parent name is not explicitly set, it should be
     // set to "All-Projects".
-    r = adminRestSession.put("/projects/" + project.get() + "/parent",
-          newParentInput(null));
+    r = adminRestSession.put("/projects/" + project.get() + "/parent", newParentInput(null));
     r.assertOK();
     r.consume();
 
@@ -68,8 +63,8 @@
   @Test
   public void setParentForAllProjects_Conflict() throws Exception {
     RestResponse r =
-        adminRestSession.put("/projects/" + allProjects.get() + "/parent",
-            newParentInput(project.get()));
+        adminRestSession.put(
+            "/projects/" + allProjects.get() + "/parent", newParentInput(project.get()));
     r.assertConflict();
     r.consume();
   }
@@ -77,20 +72,18 @@
   @Test
   public void setInvalidParent_Conflict() throws Exception {
     RestResponse r =
-        adminRestSession.put("/projects/" + project.get() + "/parent",
-            newParentInput(project.get()));
+        adminRestSession.put(
+            "/projects/" + project.get() + "/parent", newParentInput(project.get()));
     r.assertConflict();
     r.consume();
 
     Project.NameKey child = createProject("child", project, true);
-    r = adminRestSession.put("/projects/" + project.get() + "/parent",
-           newParentInput(child.get()));
+    r = adminRestSession.put("/projects/" + project.get() + "/parent", newParentInput(child.get()));
     r.assertConflict();
     r.consume();
 
     String grandchild = createProject("grandchild", child, true).get();
-    r = adminRestSession.put("/projects/" + project.get() + "/parent",
-           newParentInput(grandchild));
+    r = adminRestSession.put("/projects/" + project.get() + "/parent", newParentInput(grandchild));
     r.assertConflict();
     r.consume();
   }
@@ -98,8 +91,8 @@
   @Test
   public void setNonExistingParent_UnprocessibleEntity() throws Exception {
     RestResponse r =
-        adminRestSession.put("/projects/" + project.get() + "/parent",
-            newParentInput("non-existing"));
+        adminRestSession.put(
+            "/projects/" + project.get() + "/parent", newParentInput("non-existing"));
     r.assertUnprocessableEntity();
     r.consume();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 33aa726..ce43b08 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
+import static org.junit.Assert.fail;
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -33,28 +34,27 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-
-import org.junit.Test;
-
 import java.util.List;
+import org.junit.Test;
 
 @NoHttpd
 public class TagsIT extends AbstractDaemonTest {
-  private static final List<String> testTags = ImmutableList.of(
-      "tag-A", "tag-B", "tag-C", "tag-D", "tag-E", "tag-F", "tag-G", "tag-H");
+  private static final List<String> testTags =
+      ImmutableList.of("tag-A", "tag-B", "tag-C", "tag-D", "tag-E", "tag-F", "tag-G", "tag-H");
 
-  private static final String SIGNED_ANNOTATION = "annotation\n"
-      + "-----BEGIN PGP SIGNATURE-----\n"
-      + "Version: GnuPG v1\n"
-      + "\n"
-      + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
-      + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
-      + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
-      + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
-      + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
-      + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
-      + "=XFeC\n"
-      + "-----END PGP SIGNATURE-----";
+  private static final String SIGNED_ANNOTATION =
+      "annotation\n"
+          + "-----BEGIN PGP SIGNATURE-----\n"
+          + "Version: GnuPG v1\n"
+          + "\n"
+          + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
+          + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
+          + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
+          + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
+          + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
+          + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
+          + "=XFeC\n"
+          + "-----END PGP SIGNATURE-----";
 
   @Test
   public void listTagsOfNonExistingProject() throws Exception {
@@ -107,14 +107,19 @@
 
     // With regular expression filter
     result = getTags().withRegex("^tag-[C|D]$").get();
-    assertTagList(
-        FluentIterable.from(ImmutableList.of("tag-C", "tag-D")), result);
+    assertTagList(FluentIterable.from(ImmutableList.of("tag-C", "tag-D")), result);
+
+    result = getTags().withRegex("^tag-[c|d]$").get();
+    assertTagList(FluentIterable.from(ImmutableList.of()), result);
 
     // With substring filter
     result = getTags().withSubstring("tag-").get();
     assertTagList(FluentIterable.from(testTags), result);
     result = getTags().withSubstring("ag-B").get();
     assertTagList(FluentIterable.from(ImmutableList.of("tag-B")), result);
+
+    // With conflicting options
+    assertBadRequest(getTags().withSubstring("ag-B").withRegex("^tag-[c|d]$"));
   }
 
   @Test
@@ -172,14 +177,19 @@
     TagInfo result = tag(input.ref).create(input).get();
     assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
     assertThat(result.revision).isEqualTo(input.revision);
+    assertThat(result.canDelete).isTrue();
 
     input.ref = "refs/tags/v2.0";
     result = tag(input.ref).create(input).get();
     assertThat(result.ref).isEqualTo(input.ref);
     assertThat(result.revision).isEqualTo(input.revision);
+    assertThat(result.canDelete).isTrue();
 
-    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref,
-        null, result.revision);
+    setApiUser(user);
+    result = tag(input.ref).get();
+    assertThat(result.canDelete).isFalse();
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref, null, result.revision);
   }
 
   @Test
@@ -202,8 +212,7 @@
     assertThat(result.tagger.name).isEqualTo(admin.fullName);
     assertThat(result.tagger.email).isEqualTo(admin.email);
 
-    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref,
-        null, result.revision);
+    eventRecorder.assertRefUpdatedEvents(project.get(), result.ref, null, result.revision);
 
     // A second tag pushed on the same ref should have the same ref
     TagInput input2 = new TagInput();
@@ -217,8 +226,7 @@
     assertThat(result2.tagger.name).isEqualTo(admin.fullName);
     assertThat(result2.tagger.email).isEqualTo(admin.email);
 
-    eventRecorder.assertRefUpdatedEvents(project.get(), result2.ref,
-        null, result2.revision);
+    eventRecorder.assertRefUpdatedEvents(project.get(), result2.ref, null, result2.revision);
   }
 
   @Test
@@ -238,6 +246,7 @@
 
   @Test
   public void createTagNotAllowed() throws Exception {
+    block(Permission.CREATE, REGISTERED_USERS, R_TAGS + "*");
     TagInput input = new TagInput();
     input.ref = "test";
     exception.expect(AuthException.class);
@@ -247,13 +256,12 @@
 
   @Test
   public void createAnnotatedTagNotAllowed() throws Exception {
-    block(Permission.PUSH_TAG, REGISTERED_USERS, R_TAGS + "*");
+    block(Permission.CREATE_TAG, REGISTERED_USERS, R_TAGS + "*");
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = "annotation";
     exception.expect(AuthException.class);
-    exception.expectMessage(
-        "Cannot create annotated tag \"" + R_TAGS + "test\"");
+    exception.expectMessage("Cannot create annotated tag \"" + R_TAGS + "test\"");
     tag(input.ref).create(input);
   }
 
@@ -314,10 +322,10 @@
     tag(input.ref).create(input);
   }
 
-  private void assertTagList(FluentIterable<String> expected,
-      List<TagInfo> actual) throws Exception {
+  private void assertTagList(FluentIterable<String> expected, List<TagInfo> actual)
+      throws Exception {
     assertThat(actual).hasSize(expected.size());
-    for (int i = 0; i < expected.size(); i ++) {
+    for (int i = 0; i < expected.size(); i++) {
       assertThat(actual.get(i).ref).isEqualTo(R_TAGS + expected.get(i));
     }
   }
@@ -336,12 +344,6 @@
     }
   }
 
-  private void grantTagPermissions() throws Exception {
-    grant(Permission.CREATE, project, R_TAGS + "*");
-    grant(Permission.PUSH_TAG, project, R_TAGS + "*");
-    grant(Permission.PUSH_SIGNED_TAG, project, R_TAGS + "*");
-  }
-
   private ListRefsRequest<TagInfo> getTags() throws Exception {
     return gApi.projects().name(project.get()).tags();
   }
@@ -349,4 +351,13 @@
   private TagApi tag(String tagname) throws Exception {
     return gApi.projects().name(project.get()).tag(tagname);
   }
+
+  private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException e) {
+      // Expected
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUCK
deleted file mode 100644
index 5384447..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'server_change',
-  srcs = glob(['*IT.java']),
-  labels = ['server'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD
index a5e6d36..ac32b02 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/BUILD
@@ -1,7 +1,7 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'server_change',
-  srcs = glob(['*IT.java']),
-  labels = ['server'],
+    srcs = glob(["*IT.java"]),
+    group = "server_change",
+    labels = ["server"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
index d9f1a5c..7e95da6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -31,10 +32,13 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.PostReview;
@@ -43,29 +47,25 @@
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.junit.Before;
-import org.junit.Test;
-
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.junit.Before;
+import org.junit.Test;
 
 @NoHttpd
 public class CommentsIT extends AbstractDaemonTest {
 
-  @Inject
-  private Provider<ChangesCollection> changes;
+  @Inject private Provider<ChangesCollection> changes;
 
-  @Inject
-  private Provider<PostReview> postReview;
+  @Inject private Provider<PostReview> postReview;
 
-  @Inject
-  private FakeEmailSender email;
+  @Inject private FakeEmailSender email;
 
   private final Integer[] lines = {0, 1};
 
@@ -126,13 +126,13 @@
     for (Integer line : lines) {
       String file = "file";
       String contents = "contents " + line;
-      PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-          "first subject", file, contents);
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1");
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -140,21 +140,77 @@
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
       assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
-      assertThat(comment).isEqualTo(infoToInput(file).apply(
-          getPublishedComment(changeId, revId, actual.id)));
+      assertThat(comment)
+          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
+    }
+  }
+
+  @Test
+  public void postCommentWithReply() throws Exception {
+    for (Integer line : lines) {
+      String file = "file";
+      String contents = "contents " + line;
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+
+      input = new ReviewInput();
+      comment = newComment(file, Side.REVISION, line, "comment 1 reply", false);
+      comment.inReplyTo = actual.id;
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+      result = getPublishedComments(changeId, revId);
+      actual = result.get(comment.path).get(1);
+      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
+      assertThat(comment)
+          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
+    }
+  }
+
+  @Test
+  public void postCommentWithUnresolved() throws Exception {
+    for (Integer line : lines) {
+      String file = "file";
+      String contents = "contents " + line;
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+      PushOneCommit.Result r = push.to("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", true);
+      input.comments = new HashMap<>();
+      input.comments.put(comment.path, Lists.newArrayList(comment));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
+      assertThat(comment)
+          .isEqualTo(infoToInput(file).apply(getPublishedComment(changeId, revId, actual.id)));
     }
   }
 
   @Test
   public void postCommentOnMergeCommitChange() throws Exception {
     for (Integer line : lines) {
-      final String file = "/COMMIT_MSG";
-      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String file = "foo";
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master", file);
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1");
-      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1");
+      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
       CommentInput c3 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
       CommentInput c4 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
       input.comments = new HashMap<>();
@@ -165,13 +221,43 @@
       assertThat(Lists.transform(result.get(file), infoToInput(file)))
           .containsExactly(c1, c2, c3, c4);
     }
+
+    // for the commit message comments on the auto-merge are not possible
+    for (Integer line : lines) {
+      String file = Patch.COMMIT_MSG;
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c3 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      input.comments = new HashMap<>();
+      input.comments.put(file, ImmutableList.of(c1, c2, c3));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      assertThat(Lists.transform(result.get(file), infoToInput(file))).containsExactly(c1, c2, c3);
+    }
+  }
+
+  @Test
+  public void postCommentOnCommitMessageOnAutoMerge() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+    ReviewInput input = new ReviewInput();
+    CommentInput c = newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
+    input.comments = new HashMap<>();
+    input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("cannot comment on " + Patch.COMMIT_MSG + " on auto-merge");
+    revision(r).review(input);
   }
 
   @Test
   public void listComments() throws Exception {
     String file = "file";
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        "first subject", file, "contents");
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, "contents");
     PushOneCommit.Result r = push.to("refs/for/master");
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
@@ -180,7 +266,7 @@
     List<CommentInput> expectedComments = new ArrayList<>();
     for (Integer line : lines) {
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line);
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line, false);
       expectedComments.add(comment);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -215,8 +301,7 @@
       assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
 
       // Posting a draft comment doesn't cause lastUpdatedOn to change.
-      assertThat(r.getChange().change().getLastUpdatedOn())
-          .isEqualTo(origLastUpdated);
+      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
     }
   }
 
@@ -249,8 +334,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(
-          path, Side.REVISION, line, "comment 1");
+      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, comment);
       CommentInfo actual = getDraftComment(changeId, revId, returned.id);
       assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
@@ -271,8 +355,7 @@
       assertThat(drafts).isEmpty();
 
       // Deleting a draft comment doesn't cause lastUpdatedOn to change.
-      assertThat(r.getChange().change().getLastUpdatedOn())
-          .isEqualTo(origLastUpdated);
+      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
     }
   }
 
@@ -282,23 +365,21 @@
     for (Integer line : lines) {
       String file = "file";
       String contents = "contents " + line;
-      PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-          "first subject", file, contents);
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
 
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1");
+      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
       comment.updated = timestamp;
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       ChangeResource changeRsrc =
-          changes.get().parse(TopLevelResource.INSTANCE,
-              IdString.fromDecoded(changeId));
-      RevisionResource revRsrc =
-          revisions.parse(changeRsrc, IdString.fromDecoded(revId));
+          changes.get().parse(TopLevelResource.INSTANCE, IdString.fromDecoded(changeId));
+      RevisionResource revRsrc = revisions.parse(changeRsrc, IdString.fromDecoded(revId));
       postReview.get().apply(revRsrc, input, timestamp);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
@@ -306,12 +387,10 @@
       CommentInput ci = infoToInput(file).apply(actual);
       ci.updated = comment.updated;
       assertThat(comment).isEqualTo(ci);
-      assertThat(actual.updated)
-          .isEqualTo(gApi.changes().id(r.getChangeId()).info().created);
+      assertThat(actual.updated).isEqualTo(gApi.changes().id(r.getChangeId()).info().created);
 
       // Updating historic comments doesn't cause lastUpdatedOn to regress.
-      assertThat(r.getChange().change().getLastUpdatedOn())
-          .isEqualTo(origLastUpdated);
+      assertThat(r.getChange().change().getLastUpdatedOn()).isEqualTo(origLastUpdated);
     }
   }
 
@@ -324,16 +403,17 @@
     addComment(r1, "nit: trailing whitespace");
     Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(2);
-    addComment(r1, "nit: trailing whitespace", true);
+    addComment(r1, "nit: trailing whitespace", true, false, null);
     result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(2);
 
-    PushOneCommit.Result r2 = pushFactory.create(
-          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "content")
-        .to("refs/for/master");
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "content")
+            .to("refs/for/master");
     changeId = r2.getChangeId();
     revId = r2.getCommit().getName();
-    addComment(r2, "nit: trailing whitespace", true);
+    addComment(r2, "nit: trailing whitespace", true, false, null);
     result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(1);
   }
@@ -342,26 +422,31 @@
   public void listChangeDrafts() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
-    PushOneCommit.Result r2 = pushFactory.create(
-          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content",
-          r1.getChangeId())
-        .to("refs/for/master");
-
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
+            .to("refs/for/master");
 
     setApiUser(admin);
-    addDraft(r1.getChangeId(), r1.getCommit().getName(),
+    addDraft(
+        r1.getChangeId(),
+        r1.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
-    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
 
     setApiUser(user);
-    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
 
     setApiUser(admin);
-    Map<String, List<CommentInfo>> actual =
-        gApi.changes().id(r1.getChangeId()).drafts();
-    assertThat((Iterable<?>) actual.keySet()).containsExactly(FILE_NAME);
+    Map<String, List<CommentInfo>> actual = gApi.changes().id(r1.getChangeId()).drafts();
+    assertThat(actual.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> comments = actual.get(FILE_NAME);
     assertThat(comments).hasSize(2);
 
@@ -384,17 +469,16 @@
   public void listChangeComments() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
-    PushOneCommit.Result r2 = pushFactory.create(
-          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent",
-          r1.getChangeId())
-        .to("refs/for/master");
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
+            .to("refs/for/master");
 
     addComment(r1, "nit: trailing whitespace");
     addComment(r2, "typo: content");
 
-    Map<String, List<CommentInfo>> actual = gApi.changes()
-        .id(r2.getChangeId())
-        .comments();
+    Map<String, List<CommentInfo>> actual = gApi.changes().id(r2.getChangeId()).comments();
     assertThat(actual.keySet()).containsExactly(FILE_NAME);
 
     List<CommentInfo> comments = actual.get(FILE_NAME);
@@ -421,11 +505,9 @@
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput comment = newDraft(
-          "file1", Side.REVISION, line, "comment 1");
+      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
-      assertThat(gApi.changes().query(
-          "change:" + changeId + " has:draft").get()).hasSize(1);
+      assertThat(gApi.changes().query("change:" + changeId + " has:draft").get()).hasSize(1);
     }
   }
 
@@ -433,52 +515,65 @@
   public void publishCommentsAllRevisions() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
-    PushOneCommit.Result r2 = pushFactory.create(
-          db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "new\ncntent\n",
-          r1.getChangeId())
-        .to("refs/for/master");
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                "new\ncntent\n",
+                r1.getChangeId())
+            .to("refs/for/master");
 
-    addDraft(r1.getChangeId(), r1.getCommit().getName(),
+    addDraft(
+        r1.getChangeId(),
+        r1.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
-    addDraft(r1.getChangeId(), r1.getCommit().getName(),
+    addDraft(
+        r1.getChangeId(),
+        r1.getCommit().getName(),
         newDraft(FILE_NAME, Side.PARENT, 2, "what happened to this?"));
-    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "join lines"));
-    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
-    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
-    addDraft(r2.getChangeId(), r2.getCommit().getName(),
+    addDraft(
+        r2.getChangeId(),
+        r2.getCommit().getName(),
         newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
 
     PushOneCommit.Result other = createChange();
     // Drafts on other changes aren't returned.
-    addDraft(other.getChangeId(), other.getCommit().getName(),
+    addDraft(
+        other.getChangeId(),
+        other.getCommit().getName(),
         newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
 
     setApiUser(admin);
     // Drafts by other users aren't returned.
-    addDraft(r2.getChangeId(), r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
+    addDraft(
+        r2.getChangeId(), r2.getCommit().getName(), newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
     setApiUser(user);
 
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
     reviewInput.message = "comments";
-    gApi.changes()
-       .id(r2.getChangeId())
-       .current()
-       .review(reviewInput);
+    gApi.changes().id(r2.getChangeId()).current().review(reviewInput);
 
-    assertThat(gApi.changes()
-          .id(r1.getChangeId())
-          .revision(r1.getCommit().name())
-          .drafts())
+    assertThat(gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).drafts())
         .isEmpty();
-    Map<String, List<CommentInfo>> ps1Map = gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .comments();
+    Map<String, List<CommentInfo>> ps1Map =
+        gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).comments();
     assertThat(ps1Map.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> ps1List = ps1Map.get(FILE_NAME);
     assertThat(ps1List).hasSize(2);
@@ -487,15 +582,10 @@
     assertThat(ps1List.get(1).message).isEqualTo("nit: trailing whitespace");
     assertThat(ps1List.get(1).side).isNull();
 
-    assertThat(gApi.changes()
-          .id(r2.getChangeId())
-          .revision(r2.getCommit().name())
-          .drafts())
+    assertThat(gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).drafts())
         .isEmpty();
-    Map<String, List<CommentInfo>> ps2Map = gApi.changes()
-        .id(r2.getChangeId())
-        .revision(r2.getCommit().name())
-        .comments();
+    Map<String, List<CommentInfo>> ps2Map =
+        gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).comments();
     assertThat(ps2Map.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> ps2List = ps2Map.get(FILE_NAME);
     assertThat(ps2List).hasSize(4);
@@ -504,47 +594,78 @@
     assertThat(ps2List.get(2).message).isEqualTo("join lines");
     assertThat(ps2List.get(3).message).isEqualTo("typo: content");
 
-    ImmutableList<Message> messages =
-        email.getMessages(r2.getChangeId(), "comment");
+    List<Message> messages = email.getMessages(r2.getChangeId(), "comment");
     assertThat(messages).hasSize(1);
     String url = canonicalWebUrl.get();
     int c = r1.getChange().getId().get();
-    assertThat(extractComments(messages.get(0).body())).isEqualTo(
-        "Patch Set 2:\n"
-        + "\n"
-        + "(6 comments)\n"
-        + "\n"
-        + "comments\n"
-        + "\n"
-        + url + "#/c/" + c + "/1/a.txt\n"
-        + "File a.txt:\n"
-        + "\n"
-        + "PS1, Line 2: \n"
-        + "what happened to this?\n"
-        + "\n"
-        + "\n"
-        + "PS1, Line 1: ew\n"
-        + "nit: trailing whitespace\n"
-        + "\n"
-        + "\n"
-        + url + "#/c/" + c + "/2/a.txt\n"
-        + "File a.txt:\n"
-        + "\n"
-        + "PS2, Line 1: \n"
-        + "comment 1 on base\n"
-        + "\n"
-        + "\n"
-        + "PS2, Line 2: \n"
-        + "comment 2 on base\n"
-        + "\n"
-        + "\n"
-        + "PS2, Line 1: ew\n"
-        + "join lines\n"
-        + "\n"
-        + "\n"
-        + "PS2, Line 2: nten\n"
-        + "typo: content\n"
-        + "\n");
+    assertThat(extractComments(messages.get(0).body()))
+        .isEqualTo(
+            "Patch Set 2:\n"
+                + "\n"
+                + "(6 comments)\n"
+                + "\n"
+                + "comments\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/1/a.txt\n"
+                + "File a.txt:\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/1/a.txt@a2\n"
+                + "PS1, Line 2: \n"
+                + "what happened to this?\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/1/a.txt@1\n"
+                + "PS1, Line 1: ew\n"
+                + "nit: trailing whitespace\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt\n"
+                + "File a.txt:\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt@a1\n"
+                + "PS2, Line 1: \n"
+                + "comment 1 on base\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt@a2\n"
+                + "PS2, Line 2: \n"
+                + "comment 2 on base\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt@1\n"
+                + "PS2, Line 1: ew\n"
+                + "join lines\n"
+                + "\n"
+                + "\n"
+                + url
+                + "#/c/"
+                + c
+                + "/2/a.txt@2\n"
+                + "PS2, Line 2: nten\n"
+                + "typo: content\n"
+                + "\n"
+                + "\n");
   }
 
   @Test
@@ -559,8 +680,7 @@
     rin.tag = "tag1";
     gApi.changes().id(r.getChangeId()).current().review(rin);
 
-    List<CommentInfo> comments =
-        gApi.changes().id(r.getChangeId()).current().commentsAsList();
+    List<CommentInfo> comments = gApi.changes().id(r.getChangeId()).current().commentsAsList();
     assertThat(comments).hasSize(1);
     assertThat(comments.get(0).tag).isEqualTo("tag1");
 
@@ -571,12 +691,54 @@
     draft.tag = "tag2";
     addDraft(r.getChangeId(), r.getCommit().name(), draft);
 
-    List<CommentInfo> drafts =
-        gApi.changes().id(r.getChangeId()).current().draftsAsList();
+    List<CommentInfo> drafts = gApi.changes().id(r.getChangeId()).current().draftsAsList();
     assertThat(drafts).hasSize(1);
     assertThat(drafts.get(0).tag).isEqualTo("tag2");
   }
 
+  @Test
+  public void queryChangesWithUnresolvedCommentCount() throws Exception {
+    // PS1 has three comments in three different threads, PS2 has one comment in one thread.
+    PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
+    String changeId1 = result.getChangeId();
+    addComment(result, "comment 1", false, true, null);
+    addComment(result, "comment 2", false, null, null);
+    addComment(result, "comment 3", false, false, null);
+    PushOneCommit.Result result2 = amendChange(changeId1);
+    addComment(result2, "comment4", false, true, null);
+
+    // Change2 has two comments in one thread, the first is unresolved and the second is resolved.
+    result = createChange("change 2", FILE_NAME, "content 2");
+    String changeId2 = result.getChangeId();
+    addComment(result, "comment 1", false, true, null);
+    Map<String, List<CommentInfo>> comments =
+        getPublishedComments(changeId2, result.getCommit().name());
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(FILE_NAME)).hasSize(1);
+    addComment(result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
+
+    // Change3 has two comments in one thread, the first is resolved, the second is unresolved.
+    result = createChange("change 3", FILE_NAME, "content 3");
+    String changeId3 = result.getChangeId();
+    addComment(result, "comment 1", false, false, null);
+    comments = getPublishedComments(result.getChangeId(), result.getCommit().name());
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(FILE_NAME)).hasSize(1);
+    addComment(result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
+
+    AcceptanceTestRequestScope.Context ctx = disableDb();
+    try {
+      ChangeInfo changeInfo1 = Iterables.getOnlyElement(query(changeId1));
+      ChangeInfo changeInfo2 = Iterables.getOnlyElement(query(changeId2));
+      ChangeInfo changeInfo3 = Iterables.getOnlyElement(query(changeId3));
+      assertThat(changeInfo1.unresolvedCommentCount).isEqualTo(2);
+      assertThat(changeInfo2.unresolvedCommentCount).isEqualTo(0);
+      assertThat(changeInfo3.unresolvedCommentCount).isEqualTo(1);
+    } finally {
+      enableDb(ctx);
+    }
+  }
+
   private static String extractComments(String msg) {
     // Extract lines between start "....." and end "-- ".
     Pattern p = Pattern.compile(".*[.]{5}\n+(.*)\\n+-- \n.*", Pattern.DOTALL);
@@ -591,91 +753,90 @@
     return in;
   }
 
-  private void addComment(PushOneCommit.Result r, String message)
-      throws Exception {
-    addComment(r, message, false);
+  private void addComment(PushOneCommit.Result r, String message) throws Exception {
+    addComment(r, message, false, false, null);
   }
 
-  private void addComment(PushOneCommit.Result r, String message,
-      boolean omitDuplicateComments) throws Exception {
+  private void addComment(
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo)
+      throws Exception {
     CommentInput c = new CommentInput();
     c.line = 1;
     c.message = message;
     c.path = FILE_NAME;
+    c.unresolved = unresolved;
+    c.inReplyTo = inReplyTo;
     ReviewInput in = newInput(c);
     in.omitDuplicateComments = omitDuplicateComments;
-    gApi.changes()
-        .id(r.getChangeId())
-        .revision(r.getCommit().name())
-        .review(in);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
   }
 
-  private CommentInfo addDraft(String changeId, String revId, DraftInput in)
-      throws Exception {
+  private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
     return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
   }
 
-  private void updateDraft(String changeId, String revId, DraftInput in,
-      String uuid) throws Exception {
+  private void updateDraft(String changeId, String revId, DraftInput in, String uuid)
+      throws Exception {
     gApi.changes().id(changeId).revision(revId).draft(uuid).update(in);
   }
 
-  private void deleteDraft(String changeId, String revId, String uuid)
-      throws Exception {
+  private void deleteDraft(String changeId, String revId, String uuid) throws Exception {
     gApi.changes().id(changeId).revision(revId).draft(uuid).delete();
   }
 
-  private CommentInfo getPublishedComment(String changeId, String revId,
-      String uuid) throws Exception {
+  private CommentInfo getPublishedComment(String changeId, String revId, String uuid)
+      throws Exception {
     return gApi.changes().id(changeId).revision(revId).comment(uuid).get();
   }
 
-  private Map<String, List<CommentInfo>> getPublishedComments(String changeId,
-      String revId) throws Exception {
+  private Map<String, List<CommentInfo>> getPublishedComments(String changeId, String revId)
+      throws Exception {
     return gApi.changes().id(changeId).revision(revId).comments();
   }
 
-  private Map<String, List<CommentInfo>> getDraftComments(String changeId,
-      String revId) throws Exception {
+  private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
+      throws Exception {
     return gApi.changes().id(changeId).revision(revId).drafts();
   }
 
-  private CommentInfo getDraftComment(String changeId, String revId,
-      String uuid) throws Exception {
+  private CommentInfo getDraftComment(String changeId, String revId, String uuid) throws Exception {
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
 
-  private static CommentInput newComment(String path, Side side, int line,
-      String message) {
+  private static CommentInput newComment(
+      String path, Side side, int line, String message, Boolean unresolved) {
     CommentInput c = new CommentInput();
-    return populate(c, path, side, null, line, message);
+    return populate(c, path, side, null, line, message, unresolved);
   }
 
-  private static CommentInput newCommentOnParent(String path, int parent,
-      int line, String message) {
+  private static CommentInput newCommentOnParent(
+      String path, int parent, int line, String message) {
     CommentInput c = new CommentInput();
-    return populate(c, path, Side.PARENT, Integer.valueOf(parent), line, message);
+    return populate(c, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
   }
 
-  private DraftInput newDraft(String path, Side side, int line,
-      String message) {
+  private DraftInput newDraft(String path, Side side, int line, String message) {
     DraftInput d = new DraftInput();
-    return populate(d, path, side, null, line, message);
+    return populate(d, path, side, null, line, message, false);
   }
 
-  private DraftInput newDraftOnParent(String path, int parent, int line,
-      String message) {
+  private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
     DraftInput d = new DraftInput();
-    return populate(d, path, Side.PARENT, Integer.valueOf(parent), line, message);
+    return populate(d, path, Side.PARENT, Integer.valueOf(parent), line, message, false);
   }
 
-  private static <C extends Comment> C populate(C c, String path, Side side,
-      Integer parent, int line, String message) {
+  private static <C extends Comment> C populate(
+      C c, String path, Side side, Integer parent, int line, String message, Boolean unresolved) {
     c.path = path;
     c.side = side;
     c.parent = parent;
     c.line = line != 0 ? line : null;
     c.message = message;
+    c.unresolved = unresolved;
     if (line != 0) {
       Comment.Range range = new Comment.Range();
       range.startLine = line;
@@ -687,29 +848,21 @@
     return c;
   }
 
-  private static Function<CommentInfo, CommentInput> infoToInput(
-      final String path) {
-    return new Function<CommentInfo, CommentInput>() {
-      @Override
-      public CommentInput apply(CommentInfo info) {
-        CommentInput ci = new CommentInput();
-        ci.path = path;
-        copy(info, ci);
-        return ci;
-      }
-    };
+  private static Function<CommentInfo, CommentInput> infoToInput(String path) {
+    return infoToInput(path, CommentInput::new);
   }
 
-  private static Function<CommentInfo, DraftInput> infoToDraft(
-      final String path) {
-    return new Function<CommentInfo, DraftInput>() {
-      @Override
-      public DraftInput apply(CommentInfo info) {
-        DraftInput di = new DraftInput();
-        di.path = path;
-        copy(info, di);
-        return di;
-      }
+  private static Function<CommentInfo, DraftInput> infoToDraft(String path) {
+    return infoToInput(path, DraftInput::new);
+  }
+
+  private static <I extends Comment> Function<CommentInfo, I> infoToInput(
+      String path, Supplier<I> supplier) {
+    return info -> {
+      I i = supplier.get();
+      i.path = path;
+      copy(info, i);
+      return i;
     };
   }
 
@@ -719,5 +872,7 @@
     to.line = from.line;
     to.message = from.message;
     to.range = from.range;
+    to.unresolved = from.unresolved;
+    to.inReplyTo = from.inReplyTo;
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 37e551f..e0346b3 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -45,18 +45,23 @@
 import com.google.gerrit.server.change.ConsistencyChecker;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -67,40 +72,25 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
 @NoHttpd
 public class ConsistencyCheckerIT extends AbstractDaemonTest {
-  @Inject
-  private ChangeControl.GenericFactory changeControlFactory;
+  @Inject private ChangeControl.GenericFactory changeControlFactory;
 
-  @Inject
-  private Provider<ConsistencyChecker> checkerProvider;
+  @Inject private Provider<ConsistencyChecker> checkerProvider;
 
-  @Inject
-  private IdentifiedUser.GenericFactory userFactory;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject
-  private BatchUpdate.Factory updateFactory;
+  @Inject private BatchUpdate.Factory updateFactory;
 
-  @Inject
-  private ChangeInserter.Factory changeInserterFactory;
+  @Inject private ChangeInserter.Factory changeInserterFactory;
 
-  @Inject
-  private PatchSetInserter.Factory patchSetInserterFactory;
+  @Inject private PatchSetInserter.Factory patchSetInserterFactory;
 
-  @Inject
-  private ChangeNoteUtil noteUtil;
+  @Inject private ChangeNoteUtil noteUtil;
 
-  @Inject
-  @AnonymousCowardName
-  private String anonymousCowardName;
+  @Inject @AnonymousCowardName private String anonymousCowardName;
 
-  @Inject
-  private Sequences sequences;
+  @Inject private Sequences sequences;
 
   private RevCommit tip;
   private Account.Id adminId;
@@ -109,10 +99,9 @@
   @Before
   public void setUp() throws Exception {
     // Ignore client clone of project; repurpose as server-side TestRepository.
-    testRepo = new TestRepository<>(
-        (InMemoryRepository) repoManager.openRepository(project));
-    tip = testRepo.getRevWalk().parseCommit(
-        testRepo.getRepository().exactRef("HEAD").getObjectId());
+    testRepo = new TestRepository<>((InMemoryRepository) repoManager.openRepository(project));
+    tip =
+        testRepo.getRevWalk().parseCommit(testRepo.getRepository().exactRef("HEAD").getObjectId());
     adminId = admin.getId();
     checker = checkerProvider.get();
   }
@@ -134,8 +123,7 @@
     ChangeControl ctl = insertChange(owner);
     db.accounts().deleteKeys(singleton(owner.getId()));
 
-    assertProblems(ctl, null,
-        problem("Missing change owner: " + owner.getId()));
+    assertProblems(ctl, null, problem("Missing change owner: " + owner.getId()));
   }
 
   @Test
@@ -147,9 +135,7 @@
     Project.NameKey name = ctl.getProject().getNameKey();
     ((InMemoryRepositoryManager) repoManager).deleteRepository(name);
 
-    assertProblems(
-        ctl, null,
-        problem("Destination repository not found: " + name));
+    assertProblems(ctl, null, problem("Destination repository not found: " + name));
   }
 
   @Test
@@ -159,16 +145,17 @@
     assume().that(notesMigration.enabled()).isFalse();
 
     ChangeControl ctl = insertChange();
-    PatchSet ps = newPatchSet(
-        ctl.getChange().currentPatchSetId(),
-        "fooooooooooooooooooooooooooooooooooooooo",
-        adminId);
+    PatchSet ps =
+        newPatchSet(
+            ctl.getChange().currentPatchSetId(),
+            "fooooooooooooooooooooooooooooooooooooooo",
+            adminId);
     db.patchSets().update(singleton(ps));
 
     assertProblems(
-        ctl, null,
-        problem("Invalid revision on patch set 1:"
-            + " fooooooooooooooooooooooooooooooooooooooo"));
+        ctl,
+        null,
+        problem("Invalid revision on patch set 1: fooooooooooooooooooooooooooooooooooooooo"));
   }
 
   // No test for ref existing but object missing; InMemoryRepository won't let
@@ -181,11 +168,10 @@
     PatchSet ps = insertMissingPatchSet(ctl, rev);
     ctl = reload(ctl);
     assertProblems(
-        ctl, null,
+        ctl,
+        null,
         problem("Ref missing: " + ps.getId().toRefName()),
-        problem(
-            "Object missing: patch set 2:"
-            + " deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+        problem("Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
   }
 
   @Test
@@ -197,7 +183,8 @@
 
     String refName = ps.getId().toRefName();
     assertProblems(
-        ctl, new FixInput(),
+        ctl,
+        new FixInput(),
         problem("Ref missing: " + refName),
         problem("Object missing: patch set 2: " + rev));
   }
@@ -207,8 +194,7 @@
     ChangeControl ctl = insertChange();
     testRepo.update(
         "refs/other/foo",
-        ObjectId.fromString(
-            psUtil.current(db, ctl.getNotes()).getRevision().get()));
+        ObjectId.fromString(psUtil.current(db, ctl.getNotes()).getRevision().get()));
     String refName = ctl.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
@@ -224,15 +210,12 @@
     deleteRef(refName);
 
     assertProblems(
-        ctl, new FixInput(),
-        problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
-    assertThat(testRepo.getRepository().exactRef(refName).getObjectId().name())
-        .isEqualTo(rev);
+        ctl, new FixInput(), problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
+    assertThat(testRepo.getRepository().exactRef(refName).getObjectId().name()).isEqualTo(rev);
   }
 
   @Test
-  public void patchSetObjectAndRefMissingWithDeletingPatchSet()
-      throws Exception {
+  public void patchSetObjectAndRefMissingWithDeletingPatchSet() throws Exception {
     ChangeControl ctl = insertChange();
     PatchSet ps1 = psUtil.current(db, ctl.getNotes());
 
@@ -243,10 +226,10 @@
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
         problem("Ref missing: " + ps2.getId().toRefName()),
-        problem("Object missing: patch set 2: " + rev2,
-            FIXED, "Deleted patch set"));
+        problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"));
 
     ctl = reload(ctl);
     assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(1);
@@ -255,8 +238,7 @@
   }
 
   @Test
-  public void patchSetMultipleObjectsMissingWithDeletingPatchSets()
-      throws Exception {
+  public void patchSetMultipleObjectsMissingWithDeletingPatchSets() throws Exception {
     ChangeControl ctl = insertChange();
     PatchSet ps1 = psUtil.current(db, ctl.getNotes());
 
@@ -273,13 +255,12 @@
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
         problem("Ref missing: " + ps2.getId().toRefName()),
-        problem("Object missing: patch set 2: " + rev2,
-            FIXED, "Deleted patch set"),
+        problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"),
         problem("Ref missing: " + ps4.getId().toRefName()),
-        problem("Object missing: patch set 4: " + rev4,
-            FIXED, "Deleted patch set"));
+        problem("Object missing: patch set 4: " + rev4, FIXED, "Deleted patch set"));
 
     ctl = reload(ctl);
     assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(3);
@@ -291,36 +272,47 @@
 
   @Test
   public void onlyPatchSetObjectMissingWithFix() throws Exception {
-    Change c = TestChanges.newChange(
-        project, admin.getId(), sequences.nextChangeId());
+    Change c = TestChanges.newChange(project, admin.getId(), sequences.nextChangeId());
     PatchSet.Id psId = c.currentPatchSetId();
     String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     PatchSet ps = newPatchSet(psId, rev, adminId);
 
-    db.changes().insert(singleton(c));
-    db.patchSets().insert(singleton(ps));
+    if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
+      db.changes().insert(singleton(c));
+      db.patchSets().insert(singleton(ps));
+    }
     addNoteDbCommit(
         c.getId(),
         "Create change\n"
             + "\n"
             + "Patch-set: 1\n"
-            + "Branch: " + c.getDest().get() + "\n"
-            + "Change-id: " + c.getKey().get() + "\n"
+            + "Branch: "
+            + c.getDest().get()
+            + "\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
             + "Subject: Bogus subject\n"
-            + "Commit: " + rev + "\n"
-            + "Groups: " + rev + "\n");
+            + "Commit: "
+            + rev
+            + "\n"
+            + "Groups: "
+            + rev
+            + "\n");
     indexer.index(db, c.getProject(), c.getId());
     IdentifiedUser user = userFactory.create(admin.getId());
-    ChangeControl ctl = changeControlFactory.controlFor(
-        db, c.getProject(), c.getId(), user);
+    ChangeControl ctl = changeControlFactory.controlFor(db, c.getProject(), c.getId(), user);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
         problem("Ref missing: " + ps.getId().toRefName()),
-        problem("Object missing: patch set 1: " + rev,
-            FIX_FAILED, "Cannot delete patch set; no patch sets would remain"));
+        problem(
+            "Object missing: patch set 1: " + rev,
+            FIX_FAILED,
+            "Cannot delete patch set; no patch sets would remain"));
 
     ctl = reload(ctl);
     assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(1);
@@ -343,12 +335,9 @@
     PatchSet ps1 = psUtil.current(db, ctl.getNotes());
     String rev = ps1.getRevision().get();
 
-    ctl = incrementPatchSet(
-        ctl, testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+    ctl = incrementPatchSet(ctl, testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
-    assertProblems(
-        ctl, null,
-        problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
+    assertProblems(ctl, null, problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
   }
 
   @Test
@@ -362,9 +351,7 @@
     ru.setForceUpdate(true);
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
 
-    assertProblems(
-        ctl, null,
-        problem("Destination ref not found (may be new branch): " + ref));
+    assertProblems(ctl, null, problem("Destination ref not found (may be new branch): " + ref));
   }
 
   @Test
@@ -372,15 +359,16 @@
     ChangeControl ctl = insertChange();
 
     try (BatchUpdate bu = newUpdate(adminId)) {
-      bu.addOp(ctl.getId(), new BatchUpdate.Op() {
-        @Override
-        public boolean updateChange(ChangeContext ctx) throws OrmException {
-          ctx.getChange().setStatus(Change.Status.MERGED);
-          ctx.getUpdate(ctx.getChange().currentPatchSetId())
-            .fixStatus(Change.Status.MERGED);
-          return true;
-        }
-      });
+      bu.addOp(
+          ctl.getId(),
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              ctx.getChange().setStatus(Change.Status.MERGED);
+              ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
+              return true;
+            }
+          });
       bu.execute();
     }
     ctl = reload(ctl);
@@ -388,10 +376,14 @@
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
     ObjectId tip = getDestRef(ctl);
     assertProblems(
-        ctl, null,
+        ctl,
+        null,
         problem(
-            "Patch set 1 (" + rev + ") is not merged into destination ref"
-                + " refs/heads/master (" + tip.name()
+            "Patch set 1 ("
+                + rev
+                + ") is not merged into destination ref"
+                + " refs/heads/master ("
+                + tip.name()
                 + "), but change status is MERGED"));
   }
 
@@ -399,14 +391,19 @@
   public void newChangeIsMerged() throws Exception {
     ChangeControl ctl = insertChange();
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    testRepo.branch(ctl.getChange().getDest().get())
+    testRepo
+        .branch(ctl.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     assertProblems(
-        ctl, null,
+        ctl,
+        null,
         problem(
-            "Patch set 1 (" + rev + ") is merged into destination ref"
-                + " refs/heads/master (" + rev
+            "Patch set 1 ("
+                + rev
+                + ") is merged into destination ref"
+                + " refs/heads/master ("
+                + rev
                 + "), but change status is NEW"));
   }
 
@@ -414,16 +411,22 @@
   public void newChangeIsMergedWithFix() throws Exception {
     ChangeControl ctl = insertChange();
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    testRepo.branch(ctl.getChange().getDest().get())
+    testRepo
+        .branch(ctl.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     assertProblems(
-        ctl, new FixInput(),
+        ctl,
+        new FixInput(),
         problem(
-            "Patch set 1 (" + rev + ") is merged into destination ref"
-                + " refs/heads/master (" + rev
+            "Patch set 1 ("
+                + rev
+                + ") is merged into destination ref"
+                + " refs/heads/master ("
+                + rev
                 + "), but change status is NEW",
-            FIXED, "Marked change as merged"));
+            FIXED,
+            "Marked change as merged"));
 
     ctl = reload(ctl);
     assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
@@ -434,17 +437,14 @@
   public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
     ChangeControl ctl = insertChange();
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    testRepo.branch(ctl.getChange().getDest().get())
+    testRepo
+        .branch(ctl.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
-    ChangeInfo info = gApi.changes()
-        .id(ctl.getId().get())
-        .info();
+    ChangeInfo info = gApi.changes().id(ctl.getId().get()).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
-    info = gApi.changes()
-        .id(ctl.getId().get())
-        .check(new FixInput());
+    info = gApi.changes().id(ctl.getId().get()).check(new FixInput());
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
@@ -452,17 +452,24 @@
   public void expectedMergedCommitIsLatestPatchSet() throws Exception {
     ChangeControl ctl = insertChange();
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    testRepo.branch(ctl.getChange().getDest().get())
+    testRepo
+        .branch(ctl.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev;
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
         problem(
-            "Patch set 1 (" + rev + ") is merged into destination ref"
-                + " refs/heads/master (" + rev + "), but change status is NEW",
-            FIXED, "Marked change as merged"));
+            "Patch set 1 ("
+                + rev
+                + ") is merged into destination ref"
+                + " refs/heads/master ("
+                + rev
+                + "), but change status is NEW",
+            FIXED,
+            "Marked change as merged"));
 
     ctl = reload(ctl);
     assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
@@ -473,33 +480,33 @@
   public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
     ChangeControl ctl = insertChange();
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    RevCommit commit =
-        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
     testRepo.branch(ctl.getChange().getDest().get()).update(commit);
 
     FixInput fix = new FixInput();
-    RevCommit other =
-        testRepo.commit().message(commit.getFullMessage()).create();
+    RevCommit other = testRepo.commit().message(commit.getFullMessage()).create();
     fix.expectMergedAs = other.name();
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
         problem(
-            "Expected merged commit " + other.name()
+            "Expected merged commit "
+                + other.name()
                 + " is not merged into destination ref refs/heads/master"
-                + " (" + commit.name() + ")"));
+                + " ("
+                + commit.name()
+                + ")"));
   }
 
   @Test
-  public void createNewPatchSetForExpectedMergeCommitWithNoChangeId()
-      throws Exception {
+  public void createNewPatchSetForExpectedMergeCommitWithNoChangeId() throws Exception {
     ChangeControl ctl = insertChange();
     String dest = ctl.getChange().getDest().get();
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    RevCommit commit =
-        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
 
-    RevCommit mergedAs = testRepo.commit().parent(commit.getParent(0))
-        .message(commit.getShortMessage()).create();
+    RevCommit mergedAs =
+        testRepo.commit().parent(commit.getParent(0)).message(commit.getShortMessage()).create();
     testRepo.getRevWalk().parseBody(mergedAs);
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).isEmpty();
     testRepo.update(dest, mergedAs);
@@ -509,14 +516,16 @@
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
         problem(
             "No patch set found for merged commit " + mergedAs.name(),
-            FIXED, "Marked change as merged"),
+            FIXED,
+            "Marked change as merged"),
         problem(
-            "Expected merged commit " + mergedAs.name()
-                + " has no associated patch set",
-            FIXED, "Inserted as patch set 2"));
+            "Expected merged commit " + mergedAs.name() + " has no associated patch set",
+            FIXED,
+            "Inserted as patch set 2"));
 
     ctl = reload(ctl);
     PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
@@ -528,18 +537,24 @@
   }
 
   @Test
-  public void createNewPatchSetForExpectedMergeCommitWithChangeId()
-      throws Exception {
+  public void createNewPatchSetForExpectedMergeCommitWithChangeId() throws Exception {
     ChangeControl ctl = insertChange();
     String dest = ctl.getChange().getDest().get();
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    RevCommit commit =
-        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
 
-    RevCommit mergedAs = testRepo.commit().parent(commit.getParent(0))
-        .message(commit.getShortMessage() + "\n"
-            + "\n"
-            + "Change-Id: " + ctl.getChange().getKey().get() + "\n").create();
+    RevCommit mergedAs =
+        testRepo
+            .commit()
+            .parent(commit.getParent(0))
+            .message(
+                commit.getShortMessage()
+                    + "\n"
+                    + "\n"
+                    + "Change-Id: "
+                    + ctl.getChange().getKey().get()
+                    + "\n")
+            .create();
     testRepo.getRevWalk().parseBody(mergedAs);
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
         .containsExactly(ctl.getChange().getKey().get());
@@ -550,14 +565,16 @@
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
         problem(
             "No patch set found for merged commit " + mergedAs.name(),
-            FIXED, "Marked change as merged"),
+            FIXED,
+            "Marked change as merged"),
         problem(
-            "Expected merged commit " + mergedAs.name()
-                + " has no associated patch set",
-            FIXED, "Inserted as patch set 2"));
+            "Expected merged commit " + mergedAs.name() + " has no associated patch set",
+            FIXED,
+            "Inserted as patch set 2"));
 
     ctl = reload(ctl);
     PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
@@ -569,31 +586,36 @@
   }
 
   @Test
-  public void expectedMergedCommitIsOldPatchSetOfSameChange()
-      throws Exception {
+  public void expectedMergedCommitIsOldPatchSetOfSameChange() throws Exception {
     ChangeControl ctl = insertChange();
     PatchSet ps1 = psUtil.current(db, ctl.getNotes());
     String rev1 = ps1.getRevision().get();
     ctl = incrementPatchSet(ctl);
     PatchSet ps2 = psUtil.current(db, ctl.getNotes());
-    testRepo.branch(ctl.getChange().getDest().get())
+    testRepo
+        .branch(ctl.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev1;
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
+        problem("No patch set found for merged commit " + rev1, FIXED, "Marked change as merged"),
         problem(
-            "No patch set found for merged commit " + rev1,
-            FIXED, "Marked change as merged"),
-        problem(
-            "Expected merge commit " + rev1 + " corresponds to patch set 1,"
+            "Expected merge commit "
+                + rev1
+                + " corresponds to patch set 1,"
                 + " not the current patch set 2",
-            FIXED, "Deleted patch set"),
+            FIXED,
+            "Deleted patch set"),
         problem(
-            "Expected merge commit " + rev1 + " corresponds to patch set 1,"
+            "Expected merge commit "
+                + rev1
+                + " corresponds to patch set 1,"
                 + " not the current patch set 2",
-            FIXED, "Inserted as patch set 3"));
+            FIXED,
+            "Inserted as patch set 3"));
 
     ctl = reload(ctl);
     PatchSet.Id psId3 = new PatchSet.Id(ctl.getId(), 3);
@@ -601,13 +623,11 @@
     assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
     assertThat(psUtil.byChangeAsMap(db, ctl.getNotes()).keySet())
         .containsExactly(ps2.getId(), psId3);
-    assertThat(psUtil.get(db, ctl.getNotes(), psId3).getRevision().get())
-        .isEqualTo(rev1);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId3).getRevision().get()).isEqualTo(rev1);
   }
 
   @Test
-  public void expectedMergedCommitIsDanglingPatchSetOlderThanCurrent()
-      throws Exception {
+  public void expectedMergedCommitIsDanglingPatchSetOlderThanCurrent() throws Exception {
     ChangeControl ctl = insertChange();
     PatchSet ps1 = psUtil.current(db, ctl.getNotes());
 
@@ -621,24 +641,30 @@
     PatchSet ps3 = psUtil.current(db, ctl.getNotes());
     assertThat(ps3.getId().get()).isEqualTo(3);
 
-    testRepo.branch(ctl.getChange().getDest().get())
+    testRepo
+        .branch(ctl.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev2;
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
+        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
         problem(
-            "No patch set found for merged commit " + rev2,
-            FIXED, "Marked change as merged"),
-        problem(
-            "Expected merge commit " + rev2 + " corresponds to patch set 2,"
+            "Expected merge commit "
+                + rev2
+                + " corresponds to patch set 2,"
                 + " not the current patch set 3",
-            FIXED, "Deleted patch set"),
+            FIXED,
+            "Deleted patch set"),
         problem(
-            "Expected merge commit " + rev2 + " corresponds to patch set 2,"
+            "Expected merge commit "
+                + rev2
+                + " corresponds to patch set 2,"
                 + " not the current patch set 3",
-            FIXED, "Inserted as patch set 4"));
+            FIXED,
+            "Inserted as patch set 4"));
 
     ctl = reload(ctl);
     PatchSet.Id psId4 = new PatchSet.Id(ctl.getId(), 4);
@@ -646,13 +672,11 @@
     assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
     assertThat(psUtil.byChangeAsMap(db, ctl.getNotes()).keySet())
         .containsExactly(ps1.getId(), ps3.getId(), psId4);
-    assertThat(psUtil.get(db, ctl.getNotes(), psId4).getRevision().get())
-        .isEqualTo(rev2);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId4).getRevision().get()).isEqualTo(rev2);
   }
 
   @Test
-  public void expectedMergedCommitIsDanglingPatchSetNewerThanCurrent()
-      throws Exception {
+  public void expectedMergedCommitIsDanglingPatchSetNewerThanCurrent() throws Exception {
     ChangeControl ctl = insertChange();
     PatchSet ps1 = psUtil.current(db, ctl.getNotes());
 
@@ -662,50 +686,50 @@
     String rev2 = commit2.name();
     testRepo.branch(psId2.toRefName()).update(commit2);
 
-    testRepo.branch(ctl.getChange().getDest().get())
+    testRepo
+        .branch(ctl.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev2;
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
+        problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
         problem(
-            "No patch set found for merged commit " + rev2,
-            FIXED, "Marked change as merged"),
-        problem(
-            "Expected merge commit " + rev2 + " corresponds to patch set 2,"
+            "Expected merge commit "
+                + rev2
+                + " corresponds to patch set 2,"
                 + " not the current patch set 1",
-            FIXED, "Inserted as patch set 2"));
+            FIXED,
+            "Inserted as patch set 2"));
 
     ctl = reload(ctl);
     assertThat(ctl.getChange().currentPatchSetId()).isEqualTo(psId2);
     assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
     assertThat(psUtil.byChangeAsMap(db, ctl.getNotes()).keySet())
         .containsExactly(ps1.getId(), psId2);
-    assertThat(psUtil.get(db, ctl.getNotes(), psId2).getRevision().get())
-        .isEqualTo(rev2);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId2).getRevision().get()).isEqualTo(rev2);
   }
 
   @Test
   public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
     ChangeControl ctl = insertChange();
     String dest = ctl.getChange().getDest().get();
-    RevCommit parent =
-        testRepo.branch(dest).commit().message("parent").create();
+    RevCommit parent = testRepo.branch(dest).commit().message("parent").create();
     String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    RevCommit commit =
-        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
     testRepo.branch(dest).update(commit);
 
     String badId = "I0000000000000000000000000000000000000000";
-    RevCommit mergedAs = testRepo.commit().parent(parent)
-        .message(commit.getShortMessage() + "\n"
-            + "\n"
-            + "Change-Id: " + badId + "\n")
-        .create();
+    RevCommit mergedAs =
+        testRepo
+            .commit()
+            .parent(parent)
+            .message(commit.getShortMessage() + "\n\nChange-Id: " + badId + "\n")
+            .create();
     testRepo.getRevWalk().parseBody(mergedAs);
-    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
-        .containsExactly(badId);
+    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).containsExactly(badId);
     testRepo.update(dest, mergedAs);
 
     assertNoProblems(ctl, null);
@@ -713,21 +737,24 @@
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
     assertProblems(
-        ctl, fix,
+        ctl,
+        fix,
         problem(
-            "Expected merged commit " + mergedAs.name() + " has Change-Id: "
-                + badId + ", but expected " + ctl.getChange().getKey().get()));
+            "Expected merged commit "
+                + mergedAs.name()
+                + " has Change-Id: "
+                + badId
+                + ", but expected "
+                + ctl.getChange().getKey().get()));
   }
 
   @Test
-  public void expectedMergedCommitMatchesMultiplePatchSets()
-      throws Exception {
+  public void expectedMergedCommitMatchesMultiplePatchSets() throws Exception {
     ChangeControl ctl1 = insertChange();
     PatchSet.Id psId1 = psUtil.current(db, ctl1.getNotes()).getId();
     String dest = ctl1.getChange().getDest().get();
     String rev = psUtil.current(db, ctl1.getNotes()).getRevision().get();
-    RevCommit commit =
-        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
     testRepo.branch(dest).update(commit);
 
     ChangeControl ctl2 = insertChange();
@@ -741,62 +768,67 @@
     FixInput fix = new FixInput();
     fix.expectMergedAs = commit.name();
     assertProblems(
-        ctl1, fix,
+        ctl1,
+        fix,
         problem(
-            "Multiple patch sets for expected merged commit " + commit.name()
-                + ": [" + psId1 + ", " + psId2 + ", " + psId3 + "]"));
+            "Multiple patch sets for expected merged commit "
+                + commit.name()
+                + ": ["
+                + psId1
+                + ", "
+                + psId2
+                + ", "
+                + psId3
+                + "]"));
   }
 
   private BatchUpdate newUpdate(Account.Id owner) {
-    return updateFactory.create(
-        db, project, userFactory.create(owner), TimeUtil.nowTs());
+    return updateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
   }
 
   private ChangeControl insertChange() throws Exception {
     return insertChange(admin);
   }
 
-
   private ChangeControl insertChange(TestAccount owner) throws Exception {
     return insertChange(owner, "refs/heads/master");
   }
 
-  private ChangeControl insertChange(TestAccount owner, String dest)
-      throws Exception {
+  private ChangeControl insertChange(TestAccount owner, String dest) throws Exception {
     Change.Id id = new Change.Id(sequences.nextChangeId());
     ChangeInserter ins;
     try (BatchUpdate bu = newUpdate(owner.getId())) {
       RevCommit commit = patchSetCommit(new PatchSet.Id(id, 1));
-      ins = changeInserterFactory
-          .create(id, commit, dest)
-          .setValidatePolicy(CommitValidators.Policy.NONE)
-          .setNotify(NotifyHandling.NONE)
-          .setFireRevisionCreated(false)
-          .setSendMail(false);
+      ins =
+          changeInserterFactory
+              .create(id, commit, dest)
+              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setNotify(NotifyHandling.NONE)
+              .setFireRevisionCreated(false)
+              .setSendMail(false);
       bu.insertChange(ins).execute();
     }
     // Return control for admin regardless of owner.
-    return changeControlFactory.controlFor(
-        db, ins.getChange(), userFactory.create(adminId));
+    return changeControlFactory.controlFor(db, ins.getChange(), userFactory.create(adminId));
   }
 
   private PatchSet.Id nextPatchSetId(ChangeControl ctl) throws Exception {
-    return ChangeUtil.nextPatchSetId(
-        testRepo.getRepository(), ctl.getChange().currentPatchSetId());
+    return ChangeUtil.nextPatchSetId(testRepo.getRepository(), ctl.getChange().currentPatchSetId());
   }
 
   private ChangeControl incrementPatchSet(ChangeControl ctl) throws Exception {
     return incrementPatchSet(ctl, patchSetCommit(nextPatchSetId(ctl)));
   }
 
-  private ChangeControl incrementPatchSet(ChangeControl ctl,
-      RevCommit commit) throws Exception {
+  private ChangeControl incrementPatchSet(ChangeControl ctl, RevCommit commit) throws Exception {
     PatchSetInserter ins;
     try (BatchUpdate bu = newUpdate(ctl.getChange().getOwner())) {
-      ins = patchSetInserterFactory.create(ctl, nextPatchSetId(ctl), commit)
-          .setValidatePolicy(CommitValidators.Policy.NONE)
-          .setFireRevisionCreated(false)
-          .setSendMail(false);
+      ins =
+          patchSetInserterFactory
+              .create(ctl, nextPatchSetId(ctl), commit)
+              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setFireRevisionCreated(false)
+              .setNotify(NotifyHandling.NONE);
       bu.addOp(ctl.getId(), ins).execute();
     }
     return reload(ctl);
@@ -808,34 +840,39 @@
   }
 
   private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
-    RevCommit c = testRepo
-        .commit()
-        .parent(tip)
-        .message("Change " + psId)
-        .create();
+    RevCommit c = testRepo.commit().parent(tip).message("Change " + psId).create();
     return testRepo.parseBody(c);
   }
 
-  private PatchSet insertMissingPatchSet(ChangeControl ctl, String rev)
-      throws Exception {
+  private PatchSet insertMissingPatchSet(ChangeControl ctl, String rev) throws Exception {
     // Don't use BatchUpdate since we're manually updating the meta ref rather
     // than using ChangeUpdate.
     String subject = "Subject for missing commit";
     Change c = new Change(ctl.getChange());
     PatchSet.Id psId = nextPatchSetId(ctl);
     c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
-
     PatchSet ps = newPatchSet(psId, rev, adminId);
-    db.patchSets().insert(singleton(ps));
-    db.changes().update(singleton(c));
+
+    if (PrimaryStorage.of(c) == PrimaryStorage.REVIEW_DB) {
+      db.patchSets().insert(singleton(ps));
+      db.changes().update(singleton(c));
+    }
 
     addNoteDbCommit(
         c.getId(),
-        "Update patch set " + psId.get() + "\n"
+        "Update patch set "
+            + psId.get()
             + "\n"
-            + "Patch-set: " + psId.get() + "\n"
-            + "Commit: " + rev + "\n"
-            + "Subject: " + subject + "\n");
+            + "\n"
+            + "Patch-set: "
+            + psId.get()
+            + "\n"
+            + "Commit: "
+            + rev
+            + "\n"
+            + "Subject: "
+            + subject
+            + "\n");
     indexer.index(db, c.getProject(), c.getId());
 
     return ps;
@@ -847,18 +884,19 @@
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
   }
 
-  private void addNoteDbCommit(Change.Id id, String commitMessage)
-      throws Exception {
+  private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     if (!notesMigration.commitChangeWrites()) {
       return;
     }
     PersonIdent committer = serverIdent.get();
-    PersonIdent author = noteUtil.newIdent(
-        accountCache.get(admin.getId()).getAccount(),
-        committer.getWhen(),
-        committer,
-        anonymousCowardName);
-    testRepo.branch(RefNames.changeMetaRef(id))
+    PersonIdent author =
+        noteUtil.newIdent(
+            accountCache.get(admin.getId()).getAccount(),
+            committer.getWhen(),
+            committer,
+            anonymousCowardName);
+    testRepo
+        .branch(RefNames.changeMetaRef(id))
         .commit()
         .author(author)
         .committer(committer)
@@ -867,32 +905,31 @@
   }
 
   private ObjectId getDestRef(ChangeControl ctl) throws Exception {
-    return testRepo.getRepository()
-        .exactRef(ctl.getChange().getDest().get())
-        .getObjectId();
+    return testRepo.getRepository().exactRef(ctl.getChange().getDest().get()).getObjectId();
   }
 
   private ChangeControl mergeChange(ChangeControl ctl) throws Exception {
     final ObjectId oldId = getDestRef(ctl);
-    final ObjectId newId = ObjectId.fromString(
-        psUtil.current(db, ctl.getNotes()).getRevision().get());
+    final ObjectId newId =
+        ObjectId.fromString(psUtil.current(db, ctl.getNotes()).getRevision().get());
     final String dest = ctl.getChange().getDest().get();
 
     try (BatchUpdate bu = newUpdate(adminId)) {
-      bu.addOp(ctl.getId(), new BatchUpdate.Op() {
-        @Override
-        public void updateRepo(RepoContext ctx) throws IOException {
-          ctx.addRefUpdate(new ReceiveCommand(oldId, newId, dest));
-        }
+      bu.addOp(
+          ctl.getId(),
+          new BatchUpdateOp() {
+            @Override
+            public void updateRepo(RepoContext ctx) throws IOException {
+              ctx.addRefUpdate(new ReceiveCommand(oldId, newId, dest));
+            }
 
-        @Override
-        public boolean updateChange(ChangeContext ctx) throws OrmException {
-          ctx.getChange().setStatus(Change.Status.MERGED);
-          ctx.getUpdate(ctx.getChange().currentPatchSetId())
-            .fixStatus(Change.Status.MERGED);
-          return true;
-        }
-      });
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              ctx.getChange().setStatus(Change.Status.MERGED);
+              ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
+              return true;
+            }
+          });
       bu.execute();
     }
     return reload(ctl);
@@ -904,22 +941,19 @@
     return p;
   }
 
-  private static ProblemInfo problem(String message,
-      ProblemInfo.Status status, String outcome) {
+  private static ProblemInfo problem(String message, ProblemInfo.Status status, String outcome) {
     ProblemInfo p = problem(message);
     p.status = checkNotNull(status);
     p.outcome = checkNotNull(outcome);
     return p;
   }
 
-  private void assertProblems(ChangeControl ctl, @Nullable FixInput fix,
-      ProblemInfo first, ProblemInfo... rest) {
+  private void assertProblems(
+      ChangeControl ctl, @Nullable FixInput fix, ProblemInfo first, ProblemInfo... rest) {
     List<ProblemInfo> expected = new ArrayList<>(1 + rest.length);
     expected.add(first);
     expected.addAll(Arrays.asList(rest));
-    assertThat(checker.check(ctl, fix).problems())
-        .containsExactlyElementsIn(expected)
-        .inOrder();
+    assertThat(checker.check(ctl, fix).problems()).containsExactlyElementsIn(expected).inOrder();
   }
 
   private void assertNoProblems(ChangeControl ctl, @Nullable FixInput fix) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 40ea296..6c06753 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -16,37 +16,39 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.GetRelated.ChangeAndCommit;
 import com.google.gerrit.server.change.GetRelated.RelatedInfo;
-import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.List;
-
 public class GetRelatedIT extends AbstractDaemonTest {
   private String systemTimeZone;
 
@@ -62,17 +64,9 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
-  @Inject
-  private ChangeEditUtil editUtil;
+  @Inject private BatchUpdate.Factory updateFactory;
 
-  @Inject
-  private ChangeEditModifier editModifier;
-
-  @Inject
-  private BatchUpdate.Factory updateFactory;
-
-  @Inject
-  private ChangesCollection changes;
+  @Inject private ChangesCollection changes;
 
   @Test
   public void getRelatedNoResult() throws Exception {
@@ -83,36 +77,22 @@
   @Test
   public void getRelatedLinear() throws Exception {
     // 1,1---2,1
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
 
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps,
-          changeAndCommit(ps2_1, c2_1, 1),
-          changeAndCommit(ps1_1, c1_1, 1));
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
     }
   }
 
   @Test
   public void getRelatedLinearSeparatePushes() throws Exception {
     // 1,1---2,1
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
 
     testRepo.reset(c1_1);
     pushHead(testRepo, "refs/for/master", false);
@@ -124,13 +104,10 @@
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
 
     // Push of change 2 should not affect groups (or anything else) of change 1.
-    assertThat(changes.parse(ps1_1.getParentKey()).getETag())
-        .isEqualTo(oldETag);
+    assertThat(changes.parse(ps1_1.getParentKey()).getETag()).isEqualTo(oldETag);
 
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps,
-          changeAndCommit(ps2_1, c2_1, 1),
-          changeAndCommit(ps1_1, c1_1, 1));
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
     }
   }
 
@@ -141,14 +118,8 @@
     // 2,2---1,2
 
     // Create two commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
@@ -162,15 +133,11 @@
     PatchSet.Id ps2_2 = getPatchSetId(c2_1);
 
     for (PatchSet.Id ps : ImmutableList.of(ps2_2, ps1_2)) {
-      assertRelated(ps,
-          changeAndCommit(ps1_2, c1_2, 2),
-          changeAndCommit(ps2_2, c2_2, 2));
+      assertRelated(ps, changeAndCommit(ps1_2, c1_2, 2), changeAndCommit(ps2_2, c2_2, 2));
     }
 
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps,
-          changeAndCommit(ps2_1, c2_1, 2),
-          changeAndCommit(ps1_1, c1_1, 2));
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 2), changeAndCommit(ps1_1, c1_1, 2));
     }
   }
 
@@ -181,35 +148,23 @@
     // 1,2
 
     // Create two commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
 
     // Amend parent change and push.
     testRepo.reset("HEAD~1");
-    RevCommit c1_2 = amendBuilder()
-        .add("c.txt", "2")
-        .create();
+    RevCommit c1_2 = amendBuilder().add("c.txt", "2").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_2 = getPatchSetId(c1_2);
 
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps,
-          changeAndCommit(ps2_1, c2_1, 1),
-          changeAndCommit(ps1_1, c1_1, 2));
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 2));
     }
 
-    assertRelated(ps1_2,
-        changeAndCommit(ps2_1, c2_1, 1),
-        changeAndCommit(ps1_2, c1_2, 2));
+    assertRelated(ps1_2, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_2, c1_2, 2));
   }
 
   @Test
@@ -220,14 +175,8 @@
 
     // Create two commits and push.
     ObjectId initial = repo().exactRef("HEAD").getObjectId();
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
@@ -236,24 +185,23 @@
     testRepo.reset(initial);
     RevCommit c2_2 = testRepo.cherryPick(c2_1);
     RevCommit c1_2 = testRepo.cherryPick(c1_1);
-    RevCommit c3_1 = commitBuilder()
-        .add("c.txt", "3")
-        .message("subject: 3")
-        .create();
+    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_2 = getPatchSetId(c1_1);
     PatchSet.Id ps2_2 = getPatchSetId(c2_1);
     PatchSet.Id ps3_1 = getPatchSetId(c3_1);
 
     for (PatchSet.Id ps : ImmutableList.of(ps3_1, ps2_2, ps1_2)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_1, c3_1, 1),
           changeAndCommit(ps1_2, c1_2, 2),
           changeAndCommit(ps2_2, c2_2, 2));
     }
 
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_1, c3_1, 1),
           changeAndCommit(ps2_1, c2_1, 2),
           changeAndCommit(ps1_1, c1_1, 2));
@@ -267,18 +215,9 @@
     // 1,2---2,2---3,2
 
     // Create three commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "1")
-        .message("subject: 2")
-        .create();
-    RevCommit c3_1 = commitBuilder()
-        .add("b.txt", "1")
-        .message("subject: 3")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("b.txt", "1").message("subject: 3").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
@@ -286,31 +225,27 @@
 
     // Amend all changes change and push.
     testRepo.reset(c1_1);
-    RevCommit c1_2 = amendBuilder()
-        .add("a.txt", "2")
-        .create();
-    RevCommit c2_2 = commitBuilder()
-        .add("b.txt", "2")
-        .message(parseBody(c2_1).getFullMessage())
-        .create();
-    RevCommit c3_2 = commitBuilder()
-        .add("b.txt", "3")
-        .message(parseBody(c3_1).getFullMessage())
-        .create();
+    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
+    RevCommit c2_2 =
+        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
+    RevCommit c3_2 =
+        commitBuilder().add("b.txt", "3").message(parseBody(c3_1).getFullMessage()).create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_2 = getPatchSetId(c1_2);
     PatchSet.Id ps2_2 = getPatchSetId(c2_2);
     PatchSet.Id ps3_2 = getPatchSetId(c3_2);
 
     for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_1, c3_1, 2),
           changeAndCommit(ps2_1, c2_1, 2),
           changeAndCommit(ps1_1, c1_1, 2));
     }
 
     for (PatchSet.Id ps : ImmutableList.of(ps1_2, ps2_2, ps3_2)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_2, c3_2, 2),
           changeAndCommit(ps2_2, c2_2, 2),
           changeAndCommit(ps1_2, c1_2, 2));
@@ -325,18 +260,9 @@
     //   \---4,1
 
     // Create three commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "1")
-        .message("subject: 2")
-        .create();
-    RevCommit c3_1 = commitBuilder()
-        .add("b.txt", "1")
-        .message("subject: 3")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("b.txt", "1").message("subject: 3").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
@@ -344,17 +270,11 @@
 
     // Amend all changes change and push.
     testRepo.reset(c1_1);
-    RevCommit c1_2 = amendBuilder()
-        .add("a.txt", "2")
-        .create();
-    RevCommit c2_2 = commitBuilder()
-        .add("b.txt", "2")
-        .message(parseBody(c2_1).getFullMessage())
-        .create();
-    RevCommit c3_2 = commitBuilder()
-        .add("b.txt", "3")
-        .message(parseBody(c3_1).getFullMessage())
-        .create();
+    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
+    RevCommit c2_2 =
+        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
+    RevCommit c3_2 =
+        commitBuilder().add("b.txt", "3").message(parseBody(c3_1).getFullMessage()).create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_2 = getPatchSetId(c1_2);
     PatchSet.Id ps2_2 = getPatchSetId(c2_2);
@@ -362,15 +282,13 @@
 
     // Add one more commit 4,1 based on 1,2.
     testRepo.reset(c1_2);
-    RevCommit c4_1 = commitBuilder()
-        .add("d.txt", "4")
-        .message("subject: 4")
-        .create();
+    RevCommit c4_1 = commitBuilder().add("d.txt", "4").message("subject: 4").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps4_1 = getPatchSetId(c4_1);
 
     // 1,1 is related indirectly to 4,1.
-    assertRelated(ps1_1,
+    assertRelated(
+        ps1_1,
         changeAndCommit(ps4_1, c4_1, 1),
         changeAndCommit(ps3_1, c3_1, 2),
         changeAndCommit(ps2_1, c2_1, 2),
@@ -379,14 +297,16 @@
     // 2,1 and 3,1 don't include 4,1 since we don't walk forward after walking
     // backward.
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps3_1)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_1, c3_1, 2),
           changeAndCommit(ps2_1, c2_1, 2),
           changeAndCommit(ps1_1, c1_1, 2));
     }
 
     // 1,2 is related directly to 4,1, and the 2-3 parallel branch stays intact.
-    assertRelated(ps1_2,
+    assertRelated(
+        ps1_2,
         changeAndCommit(ps4_1, c4_1, 1),
         changeAndCommit(ps3_2, c3_2, 2),
         changeAndCommit(ps2_2, c2_2, 2),
@@ -394,14 +314,13 @@
 
     // 4,1 is only related to 1,2, since we don't walk forward after walking
     // backward.
-    assertRelated(ps4_1,
-        changeAndCommit(ps4_1, c4_1, 1),
-        changeAndCommit(ps1_2, c1_2, 2));
+    assertRelated(ps4_1, changeAndCommit(ps4_1, c4_1, 1), changeAndCommit(ps1_2, c1_2, 2));
 
     // 2,2 and 3,2 don't include 4,1 since we don't walk forward after walking
     // backward.
     for (PatchSet.Id ps : ImmutableList.of(ps2_2, ps3_2)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_2, c3_2, 2),
           changeAndCommit(ps2_2, c2_2, 2),
           changeAndCommit(ps1_2, c1_2, 2));
@@ -415,57 +334,44 @@
     // 1,2---2,2---3,1
 
     // Create two commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
 
     // Amend both changes change and push.
     testRepo.reset(c1_1);
-    RevCommit c1_2 = amendBuilder()
-        .add("a.txt", "2")
-        .create();
-    RevCommit c2_2 = commitBuilder()
-        .add("b.txt", "2")
-        .message(parseBody(c2_1).getFullMessage())
-        .create();
+    RevCommit c1_2 = amendBuilder().add("a.txt", "2").create();
+    RevCommit c2_2 =
+        commitBuilder().add("b.txt", "2").message(parseBody(c2_1).getFullMessage()).create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_2 = getPatchSetId(c1_2);
     PatchSet.Id ps2_2 = getPatchSetId(c2_2);
 
     // PS 3,1 depends on 2,2.
-    RevCommit c3_1 = commitBuilder()
-        .add("c.txt", "1")
-        .message("subject: 3")
-        .create();
+    RevCommit c3_1 = commitBuilder().add("c.txt", "1").message("subject: 3").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps3_1 = getPatchSetId(c3_1);
 
     // PS 3,2 depends on 2,1.
     testRepo.reset(c2_1);
-    RevCommit c3_2 = commitBuilder()
-        .add("c.txt", "2")
-        .message(parseBody(c3_1).getFullMessage())
-        .create();
+    RevCommit c3_2 =
+        commitBuilder().add("c.txt", "2").message(parseBody(c3_1).getFullMessage()).create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps3_2 = getPatchSetId(c3_2);
 
     for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_2)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_2, c3_2, 2),
           changeAndCommit(ps2_1, c2_1, 2),
           changeAndCommit(ps1_1, c1_1, 2));
     }
 
     for (PatchSet.Id ps : ImmutableList.of(ps1_2, ps2_2, ps3_1)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_1, c3_1, 2),
           changeAndCommit(ps2_2, c2_2, 2),
           changeAndCommit(ps1_2, c1_2, 2));
@@ -478,52 +384,32 @@
     //   \---4,1---5,1
     //    \--6,1---7,1
 
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
-    RevCommit c3_1 = commitBuilder()
-        .add("c.txt", "3")
-        .message("subject: 3")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
     PatchSet.Id ps3_1 = getPatchSetId(c3_1);
 
     testRepo.reset(c1_1);
-    RevCommit c4_1 = commitBuilder()
-        .add("d.txt", "4")
-        .message("subject: 4")
-        .create();
-    RevCommit c5_1 = commitBuilder()
-        .add("e.txt", "5")
-        .message("subject: 5")
-        .create();
+    RevCommit c4_1 = commitBuilder().add("d.txt", "4").message("subject: 4").create();
+    RevCommit c5_1 = commitBuilder().add("e.txt", "5").message("subject: 5").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps4_1 = getPatchSetId(c4_1);
     PatchSet.Id ps5_1 = getPatchSetId(c5_1);
 
     testRepo.reset(c1_1);
-    RevCommit c6_1 = commitBuilder()
-        .add("f.txt", "6")
-        .message("subject: 6")
-        .create();
-    RevCommit c7_1 = commitBuilder()
-        .add("g.txt", "7")
-        .message("subject: 7")
-        .create();
+    RevCommit c6_1 = commitBuilder().add("f.txt", "6").message("subject: 6").create();
+    RevCommit c7_1 = commitBuilder().add("g.txt", "7").message("subject: 7").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id ps6_1 = getPatchSetId(c6_1);
     PatchSet.Id ps7_1 = getPatchSetId(c7_1);
 
     // All changes are related to 1,1, keeping each of the parallel branches
     // intact.
-    assertRelated(ps1_1,
+    assertRelated(
+        ps1_1,
         changeAndCommit(ps7_1, c7_1, 1),
         changeAndCommit(ps6_1, c6_1, 1),
         changeAndCommit(ps5_1, c5_1, 1),
@@ -534,7 +420,8 @@
 
     // The 2-3 branch is only related back to 1, not the other branches.
     for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps3_1)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_1, c3_1, 1),
           changeAndCommit(ps2_1, c2_1, 1),
           changeAndCommit(ps1_1, c1_1, 1));
@@ -542,7 +429,8 @@
 
     // The 4-5 branch is only related back to 1, not the other branches.
     for (PatchSet.Id ps : ImmutableList.of(ps4_1, ps5_1)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps5_1, c5_1, 1),
           changeAndCommit(ps4_1, c4_1, 1),
           changeAndCommit(ps1_1, c1_1, 1));
@@ -550,7 +438,8 @@
 
     // The 6-7 branch is only related back to 1, not the other branches.
     for (PatchSet.Id ps : ImmutableList.of(ps6_1, ps7_1)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps7_1, c7_1, 1),
           changeAndCommit(ps6_1, c6_1, 1),
           changeAndCommit(ps1_1, c1_1, 1));
@@ -562,26 +451,18 @@
     // 1,1---2,1---3,1
     //   \---2,E---/
 
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
-    RevCommit c3_1 = commitBuilder()
-        .add("c.txt", "3")
-        .message("subject: 3")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    RevCommit c3_1 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
     pushHead(testRepo, "refs/for/master", false);
 
     Change ch2 = getChange(c2_1).change();
-    editModifier.createEdit(ch2, getPatchSet(ch2.currentPatchSetId()));
-    editModifier.modifyFile(editUtil.byChange(ch2).get(), "a.txt",
-        RawInputUtil.create(new byte[] {'a'}));
-    ObjectId editRev =
-        ObjectId.fromString(editUtil.byChange(ch2).get().getRevision().get());
+    String changeId2 = ch2.getKey().get();
+    gApi.changes().id(changeId2).edit().create();
+    gApi.changes().id(changeId2).edit().modifyFile("a.txt", RawInputUtil.create(new byte[] {'a'}));
+    Optional<EditInfo> edit = getEdit(changeId2);
+    assertThat(edit).isPresent();
+    ObjectId editRev = ObjectId.fromString(edit.get().commit.commit);
 
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
     PatchSet.Id ps2_1 = getPatchSetId(c2_1);
@@ -589,13 +470,15 @@
     PatchSet.Id ps3_1 = getPatchSetId(c3_1);
 
     for (PatchSet.Id ps : ImmutableList.of(ps1_1, ps2_1, ps3_1)) {
-      assertRelated(ps,
+      assertRelated(
+          ps,
           changeAndCommit(ps3_1, c3_1, 1),
           changeAndCommit(ps2_1, c2_1, 1),
           changeAndCommit(ps1_1, c1_1, 1));
     }
 
-    assertRelated(ps2_edit,
+    assertRelated(
+        ps2_edit,
         changeAndCommit(ps3_1, c3_1, 1),
         changeAndCommit(new PatchSet.Id(ch2.getId(), 0), editRev, 1),
         changeAndCommit(ps1_1, c1_1, 1));
@@ -606,56 +489,67 @@
     // 1,1---2,1
     //   \---2,2
 
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id psId1_1 = getPatchSetId(c1_1);
     PatchSet.Id psId2_1 = getPatchSetId(c2_1);
 
     for (PatchSet.Id psId : ImmutableList.of(psId1_1, psId2_1)) {
-      assertRelated(psId,
-          changeAndCommit(psId2_1, c2_1, 1),
-          changeAndCommit(psId1_1, c1_1, 1));
+      assertRelated(psId, changeAndCommit(psId2_1, c2_1, 1), changeAndCommit(psId1_1, c1_1, 1));
     }
 
     // Pretend PS1,1 was pushed before the groups field was added.
     clearGroups(psId1_1);
-    indexer.index(
-        changeDataFactory.create(db, project, psId1_1.getParentKey()));
+    indexer.index(changeDataFactory.create(db, project, psId1_1.getParentKey()));
 
     // PS1,1 has no groups, so disappeared from related changes.
     assertRelated(psId2_1);
 
-    RevCommit c2_2 = testRepo.amend(c2_1)
-        .add("c.txt", "2")
-        .create();
+    RevCommit c2_2 = testRepo.amend(c2_1).add("c.txt", "2").create();
     testRepo.reset(c2_2);
     pushHead(testRepo, "refs/for/master", false);
     PatchSet.Id psId2_2 = getPatchSetId(c2_2);
 
     // Push updated the group for PS1,1, so it shows up in related changes even
     // though a new patch set was not pushed.
-    assertRelated(psId2_2,
-        changeAndCommit(psId2_2, c2_2, 2),
-        changeAndCommit(psId1_1, c1_1, 1));
+    assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
+  }
+
+  @Test
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  public void getRelatedForStaleChange() throws Exception {
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+
+    RevCommit c2_1 = commitBuilder().add("b.txt", "1").message("subject: 1").create();
+    pushHead(testRepo, "refs/for/master", false);
+
+    RevCommit c2_2 = testRepo.amend(c2_1).add("b.txt", "2").create();
+    testRepo.reset(c2_2);
+
+    disableChangeIndexWrites();
+    try {
+      pushHead(testRepo, "refs/for/master", false);
+    } finally {
+      enableChangeIndexWrites();
+    }
+
+    PatchSet.Id psId1_1 = getPatchSetId(c1_1);
+    PatchSet.Id psId2_1 = getPatchSetId(c2_1);
+    PatchSet.Id psId2_2 = new PatchSet.Id(psId2_1.changeId, psId2_1.get() + 1);
+
+    assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
   }
 
   private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws Exception {
     return getRelated(ps.getParentKey(), ps.get());
   }
 
-  private List<ChangeAndCommit> getRelated(Change.Id changeId, int ps)
-      throws Exception {
-    String url = String.format("/changes/%d/revisions/%d/related",
-        changeId.get(), ps);
-    return newGson().fromJson(adminRestSession.get(url).getReader(),
-        RelatedInfo.class).changes;
+  private List<ChangeAndCommit> getRelated(Change.Id changeId, int ps) throws Exception {
+    String url = String.format("/changes/%d/revisions/%d/related", changeId.get(), ps);
+    RestResponse r = adminRestSession.get(url);
+    r.assertOK();
+    return newGson().fromJson(r.getReader(), RelatedInfo.class).changes;
   }
 
   private RevCommit parseBody(RevCommit c) throws Exception {
@@ -684,38 +578,35 @@
   }
 
   private void clearGroups(final PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = updateFactory.create(
-        db, project, user(user), TimeUtil.nowTs())) {
-      bu.addOp(psId.getParentKey(), new BatchUpdate.Op() {
-        @Override
-        public boolean updateChange(ChangeContext ctx) throws OrmException {
-          PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-          psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps,
-              ImmutableList.<String> of());
-          ctx.bumpLastUpdatedOn(false);
-          return true;
-        }
-      });
+    try (BatchUpdate bu = updateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
+      bu.addOp(
+          psId.getParentKey(),
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, ImmutableList.<String>of());
+              ctx.bumpLastUpdatedOn(false);
+              return true;
+            }
+          });
       bu.execute();
     }
   }
 
-  private void assertRelated(PatchSet.Id psId, ChangeAndCommit... expected)
-      throws Exception {
+  private void assertRelated(PatchSet.Id psId, ChangeAndCommit... expected) throws Exception {
     List<ChangeAndCommit> actual = getRelated(psId);
     assertThat(actual).named("related to " + psId).hasSize(expected.length);
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
       ChangeAndCommit a = actual.get(i);
       ChangeAndCommit e = expected[i];
-      assertThat(a._changeNumber).named("change ID of " + name)
-          .isEqualTo(e._changeNumber);
+      assertThat(a._changeNumber).named("change ID of " + name).isEqualTo(e._changeNumber);
       // Don't bother checking changeId; assume _changeNumber is sufficient.
-      assertThat(a._revisionNumber).named("revision of " + name)
-          .isEqualTo(e._revisionNumber);
-      assertThat(a.commit.commit).named("commit of " + name)
-          .isEqualTo(e.commit.commit);
-      assertThat(a._currentRevisionNumber).named("current revision of " + name)
+      assertThat(a._revisionNumber).named("revision of " + name).isEqualTo(e._revisionNumber);
+      assertThat(a.commit.commit).named("commit of " + name).isEqualTo(e.commit.commit);
+      assertThat(a._currentRevisionNumber)
+          .named("current revision of " + name)
           .isEqualTo(e._currentRevisionNumber);
     }
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
index 3b4b6a8..05dc219 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
@@ -27,13 +27,11 @@
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.inject.Inject;
-
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
-import java.util.List;
-
 @NoHttpd
 public class PatchListCacheIT extends AbstractDaemonTest {
   private static String SUBJECT_1 = "subject 1";
@@ -44,24 +42,16 @@
   private static String FILE_C = "c.txt";
   private static String FILE_D = "d.txt";
 
-  @Inject
-  private PatchListCache patchListCache;
+  @Inject private PatchListCache patchListCache;
 
   @Test
   public void listPatchesAgainstBase() throws Exception {
-    commitBuilder()
-        .add(FILE_D, "4")
-        .message(SUBJECT_1)
-        .create();
+    commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
     pushHead(testRepo, "refs/heads/master", false);
 
     // Change 1, 1 (+FILE_A, -FILE_D)
-    RevCommit c = commitBuilder()
-        .add(FILE_A, "1")
-        .rm(FILE_D)
-        .message(SUBJECT_2)
-        .insertChangeId()
-        .create();
+    RevCommit c =
+        commitBuilder().add(FILE_A, "1").rm(FILE_D).message(SUBJECT_2).insertChangeId().create();
     String id = getChangeId(testRepo, c).get();
     pushHead(testRepo, "refs/for/master", false);
 
@@ -73,9 +63,7 @@
     assertDeleted(FILE_D, entries.get(2));
 
     // Change 1,2 (+FILE_A, +FILE_B, -FILE_D)
-    c = amendBuilder()
-        .add(FILE_B, "2")
-        .create();
+    c = amendBuilder().add(FILE_B, "2").create();
     pushHead(testRepo, "refs/for/master", false);
     entries = getCurrentPatches(id);
 
@@ -89,18 +77,11 @@
 
   @Test
   public void listPatchesAgainstBaseWithRebase() throws Exception {
-    commitBuilder()
-        .add(FILE_D, "4")
-        .message(SUBJECT_1)
-        .create();
+    commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
     pushHead(testRepo, "refs/heads/master", false);
 
     // Change 1,1 (+FILE_A, -FILE_D)
-    RevCommit c = commitBuilder()
-        .add(FILE_A, "1")
-        .rm(FILE_D)
-        .message(SUBJECT_2)
-        .create();
+    RevCommit c = commitBuilder().add(FILE_A, "1").rm(FILE_D).message(SUBJECT_2).create();
     String id = getChangeId(testRepo, c).get();
     pushHead(testRepo, "refs/for/master", false);
     List<PatchListEntry> entries = getCurrentPatches(id);
@@ -111,10 +92,7 @@
 
     // Change 2,1 (+FILE_B)
     testRepo.reset("HEAD~1");
-    commitBuilder()
-        .add(FILE_B, "2")
-        .message(SUBJECT_3)
-        .create();
+    commitBuilder().add(FILE_B, "2").message(SUBJECT_3).create();
     pushHead(testRepo, "refs/for/master", false);
 
     // Change 1,2 (+FILE_A, -FILE_D))
@@ -131,37 +109,27 @@
 
   @Test
   public void listPatchesAgainstOtherPatchSet() throws Exception {
-    commitBuilder()
-        .add(FILE_D, "4")
-        .message(SUBJECT_1)
-        .create();
+    commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
     pushHead(testRepo, "refs/heads/master", false);
 
     // Change 1,1 (+FILE_A, +FILE_C, -FILE_D)
-    RevCommit a = commitBuilder()
-        .add(FILE_A, "1")
-        .add(FILE_C, "3")
-        .rm(FILE_D)
-        .message(SUBJECT_2)
-        .create();
+    RevCommit a =
+        commitBuilder().add(FILE_A, "1").add(FILE_C, "3").rm(FILE_D).message(SUBJECT_2).create();
     pushHead(testRepo, "refs/for/master", false);
 
     // Change 1,2 (+FILE_A, +FILE_B, -FILE_D)
-    RevCommit b = amendBuilder()
-        .add(FILE_B, "2")
-        .rm(FILE_C)
-        .create();
+    RevCommit b = amendBuilder().add(FILE_B, "2").rm(FILE_C).create();
     pushHead(testRepo, "refs/for/master", false);
 
     // Compare Change 1,1 with Change 1,2 (+FILE_B, -FILE_C)
-    List<PatchListEntry>  entries = getPatches(a, b);
+    List<PatchListEntry> entries = getPatches(a, b);
     assertThat(entries).hasSize(3);
     assertModified(Patch.COMMIT_MSG, entries.get(0));
     assertAdded(FILE_B, entries.get(1));
     assertDeleted(FILE_C, entries.get(2));
 
     // Compare Change 1,2 with Change 1,1 (-FILE_B, +FILE_C)
-    List<PatchListEntry>  entriesReverse = getPatches(b, a);
+    List<PatchListEntry> entriesReverse = getPatches(b, a);
     assertThat(entriesReverse).hasSize(3);
     assertModified(Patch.COMMIT_MSG, entriesReverse.get(0));
     assertDeleted(FILE_B, entriesReverse.get(1));
@@ -170,43 +138,31 @@
 
   @Test
   public void listPatchesAgainstOtherPatchSetWithRebase() throws Exception {
-    commitBuilder()
-        .add(FILE_D, "4")
-        .message(SUBJECT_1)
-        .create();
+    commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
     pushHead(testRepo, "refs/heads/master", false);
 
     // Change 1,1 (+FILE_A, -FILE_D)
-    RevCommit a = commitBuilder()
-        .add(FILE_A, "1")
-        .rm(FILE_D)
-        .message(SUBJECT_2)
-        .create();
+    RevCommit a = commitBuilder().add(FILE_A, "1").rm(FILE_D).message(SUBJECT_2).create();
     pushHead(testRepo, "refs/for/master", false);
 
     // Change 2,1 (+FILE_B)
     testRepo.reset("HEAD~1");
-    commitBuilder()
-        .add(FILE_B, "2")
-        .message(SUBJECT_3)
-        .create();
+    commitBuilder().add(FILE_B, "2").message(SUBJECT_3).create();
     pushHead(testRepo, "refs/for/master", false);
 
     // Change 1,2 (+FILE_A, +FILE_C, -FILE_D)
     testRepo.cherryPick(a);
-    RevCommit b = amendBuilder()
-        .add(FILE_C, "2")
-        .create();
+    RevCommit b = amendBuilder().add(FILE_C, "2").create();
     pushHead(testRepo, "refs/for/master", false);
 
     // Compare Change 1,1 with Change 1,2 (+FILE_C)
-    List<PatchListEntry>  entries = getPatches(a, b);
+    List<PatchListEntry> entries = getPatches(a, b);
     assertThat(entries).hasSize(2);
     assertModified(Patch.COMMIT_MSG, entries.get(0));
     assertAdded(FILE_C, entries.get(1));
 
     // Compare Change 1,2 with Change 1,1 (-FILE_C)
-    List<PatchListEntry>  entriesReverse = getPatches(b, a);
+    List<PatchListEntry> entriesReverse = getPatches(b, a);
     assertThat(entriesReverse).hasSize(2);
     assertModified(Patch.COMMIT_MSG, entriesReverse.get(0));
     assertDeleted(FILE_C, entriesReverse.get(1));
@@ -232,17 +188,13 @@
     assertThat(e.getOldName()).isNull();
   }
 
-  private List<PatchListEntry> getCurrentPatches(String changeId)
-      throws Exception {
-    return patchListCache
-        .get(getKey(null, getCurrentRevisionId(changeId)), project)
-        .getPatches();
+  private List<PatchListEntry> getCurrentPatches(String changeId) throws Exception {
+    return patchListCache.get(getKey(null, getCurrentRevisionId(changeId)), project).getPatches();
   }
 
   private List<PatchListEntry> getPatches(ObjectId revisionIdA, ObjectId revisionIdB)
       throws Exception {
-    return patchListCache.get(getKey(revisionIdA, revisionIdB), project)
-        .getPatches();
+    return patchListCache.get(getKey(revisionIdA, revisionIdB), project).getPatches();
   }
 
   private PatchListKey getKey(ObjectId revisionIdA, ObjectId revisionIdB) {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 06170d0..75bdf4d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -23,20 +23,21 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testutil.ConfigSuite;
-
+import java.util.EnumSet;
+import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
-import java.util.EnumSet;
-import java.util.List;
-
 public class SubmittedTogetherIT extends AbstractDaemonTest {
   @ConfigSuite.Config
   public static Config submitWholeTopicEnabled() {
@@ -44,17 +45,53 @@
   }
 
   @Test
+  public void doesNotIncludeCurrentFiles() throws Exception {
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    SubmittedTogetherInfo info =
+        gApi.changes().id(id2).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+    assertThat(info.changes).hasSize(2);
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    assertThat(info.changes.get(1).currentRevision).isEqualTo(c1_1.name());
+
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    RevisionInfo rev = info.changes.get(0).revisions.get(c2_1.name());
+    assertThat(rev.files).isNull();
+  }
+
+  @Test
+  public void returnsCurrentFilesIfOptionRequested() throws Exception {
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, "refs/for/master", false);
+
+    SubmittedTogetherInfo info =
+        gApi.changes()
+            .id(id2)
+            .submittedTogether(
+                EnumSet.of(ListChangesOption.CURRENT_FILES), EnumSet.of(NON_VISIBLE_CHANGES));
+    assertThat(info.changes).hasSize(2);
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    assertThat(info.changes.get(1).currentRevision).isEqualTo(c1_1.name());
+
+    assertThat(info.changes.get(0).currentRevision).isEqualTo(c2_1.name());
+    RevisionInfo rev = info.changes.get(0).revisions.get(c2_1.name());
+    assertThat(rev).isNotNull();
+    FileInfo file = rev.files.get("b.txt");
+    assertThat(file).isNotNull();
+    assertThat(file.status).isEqualTo('A');
+  }
+
+  @Test
   public void returnsAncestors() throws Exception {
     // Create two commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id2 = getChangeId(c2_1);
     pushHead(testRepo, "refs/for/master", false);
 
@@ -77,18 +114,12 @@
   public void respectsWholeTopicAndAncestors() throws Exception {
     RevCommit initialHead = getRemoteHead();
     // Create two independent commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
     pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
 
     testRepo.reset(initialHead);
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id2 = getChangeId(c2_1);
     pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
 
@@ -135,16 +166,15 @@
     pushHead(testRepo, "refs/drafts/master/" + name("topic"), false);
 
     setApiUser(user);
-    SubmittedTogetherInfo result = gApi.changes()
-        .id(id1)
-        .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+    SubmittedTogetherInfo result =
+        gApi.changes().id(id1).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
 
     if (isSubmitWholeTopicEnabled()) {
       assertThat(result.changes).hasSize(1);
       assertThat(result.changes.get(0).changeId).isEqualTo(id1);
       assertThat(result.nonVisibleChanges).isEqualTo(1);
     } else {
-      assertThat(result.changes).hasSize(0);
+      assertThat(result.changes).isEmpty();
       assertThat(result.nonVisibleChanges).isEqualTo(0);
     }
   }
@@ -163,12 +193,11 @@
     setApiUser(user);
     if (isSubmitWholeTopicEnabled()) {
       exception.expect(AuthException.class);
-      exception.expectMessage(
-          "change would be submitted with a change that you cannot see");
+      exception.expectMessage("change would be submitted with a change that you cannot see");
       gApi.changes().id(id1).submittedTogether();
     } else {
       List<ChangeInfo> result = gApi.changes().id(id1).submittedTogether();
-      assertThat(result).hasSize(0);
+      assertThat(result).isEmpty();
     }
   }
 
@@ -226,9 +255,8 @@
     String id = getChangeId(change);
 
     setApiUser(user);
-    SubmittedTogetherInfo result = gApi.changes()
-        .id(id)
-        .submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+    SubmittedTogetherInfo result =
+        gApi.changes().id(id).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
     if (isSubmitWholeTopicEnabled()) {
       assertThat(result.changes).hasSize(1);
       assertThat(result.changes.get(0).changeId).isEqualTo(id);
@@ -240,28 +268,19 @@
   }
 
   @Test
-  public void testTopicChaining() throws Exception {
+  public void topicChaining() throws Exception {
     RevCommit initialHead = getRemoteHead();
     // Create two independent commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
     pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
 
     testRepo.reset(initialHead);
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id2 = getChangeId(c2_1);
     pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
 
-    RevCommit c3_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c3_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id3 = getChangeId(c3_1);
     pushHead(testRepo, "refs/for/master/" + name("unrelated-topic"), false);
 
@@ -277,21 +296,29 @@
   }
 
   @Test
-  public void testNewBranchTwoChangesTogether() throws Exception {
+  public void newBranchTwoChangesTogether() throws Exception {
     Project.NameKey p1 = createProject("a-new-project", null, false);
     TestRepository<?> repo1 = cloneProject(p1);
 
-    RevCommit c1 = repo1.branch("HEAD").commit().insertChangeId()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
+    RevCommit c1 =
+        repo1
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .add("a.txt", "1")
+            .message("subject: 1")
+            .create();
     String id1 = GitUtil.getChangeId(repo1, c1).get();
     pushHead(repo1, "refs/for/master", false);
 
-    RevCommit c2 = repo1.branch("HEAD").commit().insertChangeId()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c2 =
+        repo1
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .add("b.txt", "2")
+            .message("subject: 2")
+            .create();
     String id2 = GitUtil.getChangeId(repo1, c2).get();
     pushHead(repo1, "refs/for/master", false);
     assertSubmittedTogether(id1);
@@ -302,15 +329,9 @@
   @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
   public void testCherryPickWithoutAncestors() throws Exception {
     // Create two commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id2 = getChangeId(c2_1);
     pushHead(testRepo, "refs/for/master", false);
 
@@ -319,17 +340,11 @@
   }
 
   @Test
-  public void testSubmissionIdSavedOnMergeInOneProject() throws Exception {
+  public void submissionIdSavedOnMergeInOneProject() throws Exception {
     // Create two commits and push.
-    RevCommit c1_1 = commitBuilder()
-        .add("a.txt", "1")
-        .message("subject: 1")
-        .create();
+    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
-    RevCommit c2_1 = commitBuilder()
-        .add("b.txt", "2")
-        .message("subject: 2")
-        .create();
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id2 = getChangeId(c2_1);
     pushHead(testRepo, "refs/for/master", false);
 
@@ -354,17 +369,10 @@
   }
 
   private void submit(String changeId) throws Exception {
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .submit();
+    gApi.changes().id(changeId).current().submit();
   }
 
   private void assertMerged(String changeId) throws Exception {
-    assertThat(gApi
-        .changes()
-        .id(changeId)
-        .get()
-        .status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUCK
deleted file mode 100644
index 4fbc977..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'server_event',
-  srcs = glob(['*IT.java']),
-  labels = ['server'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD
index ff0c51b..3804bea 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/BUILD
@@ -1,7 +1,7 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'server_event',
-  srcs = glob(['*IT.java']),
-  labels = ['server'],
+    srcs = glob(["*IT.java"]),
+    group = "server_event",
+    labels = ["server"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 40ec9da..56c55e4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -33,10 +33,8 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
 import com.google.inject.Inject;
-
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -44,17 +42,13 @@
 @NoHttpd
 public class CommentAddedEventIT extends AbstractDaemonTest {
 
-  @Inject
-  private DynamicSet<CommentAddedListener> source;
+  @Inject private DynamicSet<CommentAddedListener> source;
 
-  private final LabelType label = category("CustomLabel",
-      value(1, "Positive"),
-      value(0, "No score"),
-      value(-1, "Negative"));
+  private final LabelType label =
+      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
 
-  private final LabelType pLabel = category("CustomLabel2",
-      value(1, "Positive"),
-      value(0, "No score"));
+  private final LabelType pLabel =
+      category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   private RegistrationHandle eventListenerRegistration;
   private CommentAddedListener.Event lastCommentAddedEvent;
@@ -62,20 +56,19 @@
   @Before
   public void setUp() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID anonymousUsers =
-        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers,
-        "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(pLabel.getName()), 0, 1, anonymousUsers,
-        "refs/heads/*");
+    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(pLabel.getName()), 0, 1, anonymousUsers, "refs/heads/*");
     saveProjectConfig(project, cfg);
 
-    eventListenerRegistration = source.add(new CommentAddedListener() {
-      @Override
-      public void onCommentAdded(Event event) {
-        lastCommentAddedEvent = event;
-      }
-    });
+    eventListenerRegistration =
+        source.add(
+            new CommentAddedListener() {
+              @Override
+              public void onCommentAdded(Event event) {
+                lastCommentAddedEvent = event;
+              }
+            });
   }
 
   @After
@@ -95,8 +88,7 @@
    */
   private ApprovalValues getApprovalValues(LabelType label) {
     ApprovalValues res = new ApprovalValues();
-    ApprovalInfo info =
-        lastCommentAddedEvent.getApprovals().get(label.getName());
+    ApprovalInfo info = lastCommentAddedEvent.getApprovals().get(label.getName());
     if (info != null) {
       res.value = info.value;
     }
@@ -113,14 +105,13 @@
 
     // push a new change with -1 vote
     PushOneCommit.Result r = createChange();
-    ReviewInput reviewInput = new ReviewInput().label(
-        label.getName(), (short)-1);
+    ReviewInput reviewInput = new ReviewInput().label(label.getName(), (short) -1);
     revision(r).review(reviewInput);
     ApprovalValues attr = getApprovalValues(label);
     assertThat(attr.oldValue).isEqualTo(0);
     assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1: %s-1", label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
   }
 
   @Test
@@ -135,14 +126,13 @@
     // push a new revision with +1 vote
     ChangeInfo c = get(r.getChangeId());
     r = amendChange(c.changeId);
-    reviewInput = new ReviewInput().label(
-        label.getName(), (short)1);
+    reviewInput = new ReviewInput().label(label.getName(), (short) 1);
     revision(r).review(reviewInput);
     ApprovalValues attr = getApprovalValues(label);
     assertThat(attr.oldValue).isEqualTo(0);
     assertThat(attr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 2: %s+1", label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 2: %s+1", label.getName()));
   }
 
   @Test
@@ -159,8 +149,8 @@
     ApprovalValues attr = getApprovalValues(label);
     assertThat(attr.oldValue).isNull();
     assertThat(attr.value).isEqualTo(0);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1:\n\n%s", label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
 
     // transition from un-voted to -1 vote
     reviewInput = new ReviewInput().label(label.getName(), -1);
@@ -168,8 +158,8 @@
     attr = getApprovalValues(label);
     assertThat(attr.oldValue).isEqualTo(0);
     assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1: %s-1", label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
 
     // transition vote from -1 to 0
     reviewInput = new ReviewInput().label(label.getName(), 0);
@@ -177,8 +167,8 @@
     attr = getApprovalValues(label);
     assertThat(attr.oldValue).isEqualTo(-1);
     assertThat(attr.value).isEqualTo(0);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1: -%s", label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: -%s", label.getName()));
 
     // transition vote from 0 to 1
     reviewInput = new ReviewInput().label(label.getName(), 1);
@@ -186,8 +176,8 @@
     attr = getApprovalValues(label);
     assertThat(attr.oldValue).isEqualTo(0);
     assertThat(attr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1: %s+1", label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s+1", label.getName()));
 
     // transition vote from 1 to -1
     reviewInput = new ReviewInput().label(label.getName(), -1);
@@ -195,17 +185,17 @@
     attr = getApprovalValues(label);
     assertThat(attr.oldValue).isEqualTo(1);
     assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1: %s-1", label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s-1", label.getName()));
 
     // review with message only, do not apply votes
     reviewInput = new ReviewInput().message(label.getName());
     revision(r).review(reviewInput);
     attr = getApprovalValues(label);
-    assertThat(attr.oldValue).isNull();  // no vote change so not included
+    assertThat(attr.oldValue).isNull(); // no vote change so not included
     assertThat(attr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1:\n\n%s", label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1:\n\n%s", label.getName()));
   }
 
   @Test
@@ -222,9 +212,8 @@
     ApprovalValues labelAttr = getApprovalValues(label);
     assertThat(labelAttr.oldValue).isEqualTo(0);
     assertThat(labelAttr.value).isEqualTo(-1);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1: %s-1\n\n%s",
-            label.getName(), label.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s-1\n\n%s", label.getName(), label.getName()));
 
     // there should be 3 approval labels (label, pLabel, and CRVV)
     assertThat(lastCommentAddedEvent.getApprovals()).hasSize(3);
@@ -250,9 +239,8 @@
     pLabelAttr = getApprovalValues(pLabel);
     assertThat(pLabelAttr.oldValue).isEqualTo(0);
     assertThat(pLabelAttr.value).isEqualTo(1);
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        String.format("Patch Set 1: %s+1\n\n%s",
-            pLabel.getName(), pLabel.getName()));
+    assertThat(lastCommentAddedEvent.getComment())
+        .isEqualTo(String.format("Patch Set 1: %s+1\n\n%s", pLabel.getName(), pLabel.getName()));
 
     // check the approvals that were not voted on
     labelAttr = getApprovalValues(label);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
new file mode 100644
index 0000000..d6ad269
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -0,0 +1,145 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import java.util.HashMap;
+import org.joda.time.DateTime;
+
+public class AbstractMailIT extends AbstractDaemonTest {
+
+  protected MailMessage.Builder messageBuilderWithDefaultFields() {
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("some id");
+    b.from(user.emailAddress);
+    b.addTo(user.emailAddress); // Not evaluated
+    b.subject("");
+    b.dateReceived(new DateTime());
+    return b;
+  }
+
+  protected String createChangeWithReview() throws Exception {
+    return createChangeWithReview(admin);
+  }
+
+  protected String createChangeWithReview(TestAccount reviewer) throws Exception {
+    // Create change
+    String file = "gerrit-server/test.txt";
+    String contents = "contents \nlorem \nipsum \nlorem";
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "first subject", file, contents);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+
+    // Review it
+    setApiUser(reviewer);
+    ReviewInput input = new ReviewInput();
+    input.message = "I have two comments";
+    input.comments = new HashMap<>();
+    CommentInput c1 = newComment(file, Side.REVISION, 0, "comment on file");
+    CommentInput c2 = newComment(file, Side.REVISION, 2, "inline comment");
+    input.comments.put(c1.path, ImmutableList.of(c1, c2));
+    revision(r).review(input);
+    return changeId;
+  }
+
+  protected static CommentInput newComment(String path, Side side, int line, String message) {
+    CommentInput c = new CommentInput();
+    c.path = path;
+    c.side = side;
+    c.line = line != 0 ? line : null;
+    c.message = message;
+    if (line != 0) {
+      Comment.Range range = new Comment.Range();
+      range.startLine = line;
+      range.startCharacter = 1;
+      range.endLine = line;
+      range.endCharacter = 5;
+      c.range = range;
+    }
+    return c;
+  }
+
+  /**
+   * Create a plaintext message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first inline comment.
+   * @param f1 Comment on file one.
+   * @param fc1 Comment in reply to a comment of file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  protected static String newPlaintextBody(
+      String changeURL, String changeMessage, String c1, String f1, String fc1) {
+    return (changeMessage == null ? "" : changeMessage + "\n")
+        + "> Foo Bar has posted comments on this change. (  \n"
+        + "> "
+        + changeURL
+        + " )\n"
+        + "> \n"
+        + "> Change subject: Test change\n"
+        + "> ...............................................................\n"
+        + "> \n"
+        + "> \n"
+        + "> Patch Set 1: Code-Review+1\n"
+        + "> \n"
+        + "> (3 comments)\n"
+        + "> \n"
+        + "> "
+        + changeURL
+        + "/gerrit-server/test.txt\n"
+        + "> File  \n"
+        + "> gerrit-server/test.txt:\n"
+        + (f1 == null ? "" : f1 + "\n")
+        + "> \n"
+        + "> Patch Set #4:\n"
+        + "> "
+        + changeURL
+        + "/gerrit-server/test.txt\n"
+        + "> \n"
+        + "> Some comment"
+        + "> \n"
+        + (fc1 == null ? "" : fc1 + "\n")
+        + "> "
+        + changeURL
+        + "/gerrit-server/test.txt@2\n"
+        + "> PS1, Line 2: throw new Exception(\"Object has unsupported: \" +\n"
+        + ">               :             entry.getValue() +\n"
+        + ">               :             \" must be java.util.Date\");\n"
+        + "> Should entry.getKey() be included in this message?\n"
+        + "> \n"
+        + (c1 == null ? "" : c1 + "\n")
+        + "> \n";
+  }
+
+  protected static String textFooterForChange(int changeNumber, String timestamp) {
+    return "Gerrit-Change-Number: "
+        + changeNumber
+        + "\n"
+        + "Gerrit-PatchSet: 1\n"
+        + "Gerrit-MessageType: comment\n"
+        + "Gerrit-Comment-Date: "
+        + timestamp
+        + "\n";
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
new file mode 100644
index 0000000..15d8510
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
@@ -0,0 +1,25 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
+
+DEPS = [
+    "//lib/greenmail",
+    "//lib/joda:joda-time",
+    "//lib/mail",
+]
+
+acceptance_tests(
+    srcs = glob(
+        ["*IT.java"],
+        exclude = ["AbstractMailIT.java"],
+    ),
+    group = "server_mail",
+    labels = ["server"],
+    deps = [":util"] + DEPS,
+)
+
+java_library(
+    name = "util",
+    testonly = 1,
+    srcs = ["AbstractMailIT.java"],
+    deps = ["//gerrit-acceptance-tests:lib"] + DEPS,
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
new file mode 100644
index 0000000..ea4f501
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.server.mail.receive.MailProcessor;
+import com.google.inject.Inject;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class ListMailFilterIT extends AbstractMailIT {
+  @Inject private MailProcessor mailProcessor;
+
+  @Test
+  @GerritConfig(name = "receiveemail.filter.mode", value = "OFF")
+  public void listFilterOff() throws Exception {
+    ChangeInfo changeInfo = createChangeAndReplyByEmail();
+    // Check that the comments from the email have been persisted
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
+    assertThat(messages).hasSize(3);
+  }
+
+  @Test
+  @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+  @GerritConfig(
+      name = "receiveemail.filter.patterns",
+      values = {".+ser@example\\.com", "a@b\\.com"})
+  public void listFilterWhitelistDoesNotFilterListedUser() throws Exception {
+    ChangeInfo changeInfo = createChangeAndReplyByEmail();
+    // Check that the comments from the email have been persisted
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
+    assertThat(messages).hasSize(3);
+  }
+
+  @Test
+  @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+  @GerritConfig(
+      name = "receiveemail.filter.patterns",
+      values = {".+@gerritcodereview\\.com", "a@b\\.com"})
+  public void listFilterWhitelistFiltersNotListedUser() throws Exception {
+    ChangeInfo changeInfo = createChangeAndReplyByEmail();
+    // Check that the comments from the email have NOT been persisted
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
+    assertThat(messages).hasSize(2);
+  }
+
+  @Test
+  @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+  @GerritConfig(
+      name = "receiveemail.filter.patterns",
+      values = {".+@gerritcodereview\\.com", "a@b\\.com"})
+  public void listFilterBlacklistDoesNotFilterNotListedUser() throws Exception {
+    ChangeInfo changeInfo = createChangeAndReplyByEmail();
+    // Check that the comments from the email have been persisted
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
+    assertThat(messages).hasSize(3);
+  }
+
+  @Test
+  @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+  @GerritConfig(
+      name = "receiveemail.filter.patterns",
+      values = {".+@example\\.com", "a@b\\.com"})
+  public void listFilterBlacklistFiltersListedUser() throws Exception {
+    ChangeInfo changeInfo = createChangeAndReplyByEmail();
+    // Check that the comments from the email have been persisted
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
+    assertThat(messages).hasSize(2);
+  }
+
+  private ChangeInfo createChangeAndReplyByEmail() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
+            "Test Message",
+            null,
+            null,
+            null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    mailProcessor.process(b.build());
+    return changeInfo;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
new file mode 100644
index 0000000..f995316
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.server.mail.receive.MailReceiver;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Inject;
+import com.icegreen.greenmail.junit.GreenMailRule;
+import com.icegreen.greenmail.user.GreenMailUser;
+import com.icegreen.greenmail.util.GreenMail;
+import com.icegreen.greenmail.util.GreenMailUtil;
+import com.icegreen.greenmail.util.ServerSetupTest;
+import javax.mail.internet.MimeMessage;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@NoHttpd
+@RunWith(ConfigSuite.class)
+public class MailIT extends AbstractDaemonTest {
+  private static final String RECEIVEEMAIL = "receiveemail";
+  private static final String HOST = "localhost";
+  private static final String USERNAME = "user@domain.com";
+  private static final String PASSWORD = "password";
+
+  @Inject private MailReceiver mailReceiver;
+
+  @Inject private GreenMail greenMail;
+
+  @Rule
+  public final GreenMailRule mockPop3Server = new GreenMailRule(ServerSetupTest.SMTP_POP3_IMAP);
+
+  @ConfigSuite.Default
+  public static Config pop3Config() {
+    Config cfg = new Config();
+    cfg.setString(RECEIVEEMAIL, null, "host", HOST);
+    cfg.setString(RECEIVEEMAIL, null, "port", "3110");
+    cfg.setString(RECEIVEEMAIL, null, "username", USERNAME);
+    cfg.setString(RECEIVEEMAIL, null, "password", PASSWORD);
+    cfg.setString(RECEIVEEMAIL, null, "protocol", "POP3");
+    cfg.setString(RECEIVEEMAIL, null, "fetchInterval", Integer.toString(Integer.MAX_VALUE));
+    return cfg;
+  }
+
+  @ConfigSuite.Config
+  public static Config imapConfig() {
+    Config cfg = new Config();
+    cfg.setString(RECEIVEEMAIL, null, "host", HOST);
+    cfg.setString(RECEIVEEMAIL, null, "port", "3143");
+    cfg.setString(RECEIVEEMAIL, null, "username", USERNAME);
+    cfg.setString(RECEIVEEMAIL, null, "password", PASSWORD);
+    cfg.setString(RECEIVEEMAIL, null, "protocol", "IMAP");
+    cfg.setString(RECEIVEEMAIL, null, "fetchInterval", Integer.toString(Integer.MAX_VALUE));
+    return cfg;
+  }
+
+  @Test
+  public void delete() throws Exception {
+    GreenMailUser user = mockPop3Server.setUser(USERNAME, USERNAME, PASSWORD);
+    user.deliver(createSimpleMessage());
+    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
+    // Let Gerrit handle emails
+    mailReceiver.handleEmails(false);
+    // Check that the message is still present
+    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
+    // Mark the message for deletion
+    mailReceiver.requestDeletion(mockPop3Server.getReceivedMessages()[0].getMessageID());
+    // Let Gerrit handle emails
+    mailReceiver.handleEmails(false);
+    // Check that the message was deleted
+    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(0);
+  }
+
+  private MimeMessage createSimpleMessage() {
+    return GreenMailUtil.createTextEmail(
+        USERNAME, "from@localhost.com", "subject", "body", greenMail.getImap().getServerSetup());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
new file mode 100644
index 0000000..7cef8e7
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -0,0 +1,162 @@
+// 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.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.testutil.FakeEmailSender;
+import com.google.gerrit.testutil.TestTimeUtil;
+import java.sql.Timestamp;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests the presence of required metadata in email headers, text and html. */
+public class MailMetadataIT extends AbstractDaemonTest {
+  private String systemTimeZone;
+
+  @Before
+  public void setTimeForTesting() {
+    systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+    System.setProperty("user.timezone", systemTimeZone);
+  }
+
+  @Test
+  public void metadataOnNewChange() throws Exception {
+    PushOneCommit.Result newChange = createChange();
+    gApi.changes().id(newChange.getChangeId()).addReviewer(user.getId().toString());
+
+    List<FakeEmailSender.Message> emails = sender.getMessages();
+    assertThat(emails).hasSize(1);
+    FakeEmailSender.Message message = emails.get(0);
+
+    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
+
+    Map<String, Object> expectedHeaders = new HashMap<>();
+    expectedHeaders.put("Gerrit-PatchSet", "1");
+    expectedHeaders.put(
+        "Gerrit-Change-Number", String.valueOf(newChange.getChange().getId().get()));
+    expectedHeaders.put("Gerrit-MessageType", "newchange");
+    expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
+    expectedHeaders.put("Gerrit-ChangeURL", changeURL);
+
+    assertHeaders(message.headers(), expectedHeaders);
+
+    // Remove metadata that is not present in email
+    expectedHeaders.remove("Gerrit-ChangeURL");
+    expectedHeaders.remove("Gerrit-Commit");
+    assertTextFooter(message.body(), expectedHeaders);
+  }
+
+  @Test
+  public void metadataOnNewComment() throws Exception {
+    PushOneCommit.Result newChange = createChange();
+    gApi.changes().id(newChange.getChangeId()).addReviewer(user.getId().toString());
+    sender.clear();
+
+    // Review change
+    ReviewInput input = new ReviewInput();
+    input.message = "Test";
+    revision(newChange).review(input);
+    setApiUser(user);
+    Collection<ChangeMessageInfo> result =
+        gApi.changes().id(newChange.getChangeId()).get().messages;
+    assertThat(result).isNotEmpty();
+
+    List<FakeEmailSender.Message> emails = sender.getMessages();
+    assertThat(emails).hasSize(1);
+    FakeEmailSender.Message message = emails.get(0);
+
+    String changeURL = "<" + canonicalWebUrl.get() + newChange.getChange().getId().get() + ">";
+    Map<String, Object> expectedHeaders = new HashMap<>();
+    expectedHeaders.put("Gerrit-PatchSet", "1");
+    expectedHeaders.put(
+        "Gerrit-Change-Number", String.valueOf(newChange.getChange().getId().get()));
+    expectedHeaders.put("Gerrit-MessageType", "comment");
+    expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
+    expectedHeaders.put("Gerrit-ChangeURL", changeURL);
+    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date);
+
+    assertHeaders(message.headers(), expectedHeaders);
+
+    // Remove metadata that is not present in email
+    expectedHeaders.remove("Gerrit-ChangeURL");
+    expectedHeaders.remove("Gerrit-Commit");
+    assertTextFooter(message.body(), expectedHeaders);
+  }
+
+  private static void assertHeaders(Map<String, EmailHeader> have, Map<String, Object> want)
+      throws Exception {
+    for (Map.Entry<String, Object> entry : want.entrySet()) {
+      if (entry.getValue() instanceof String) {
+        assertThat(have)
+            .containsEntry(
+                "X-" + entry.getKey(), new EmailHeader.String((String) entry.getValue()));
+      } else if (entry.getValue() instanceof Date) {
+        assertThat(have)
+            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Date) entry.getValue()));
+      } else {
+        throw new Exception(
+            "Object has unsupported type: "
+                + entry.getValue().getClass().getName()
+                + " must be java.util.Date or java.lang.String for key "
+                + entry.getKey());
+      }
+    }
+  }
+
+  private static void assertTextFooter(String body, Map<String, Object> want) throws Exception {
+    for (Map.Entry<String, Object> entry : want.entrySet()) {
+      if (entry.getValue() instanceof String) {
+        assertThat(body).contains(entry.getKey() + ": " + entry.getValue());
+      } else if (entry.getValue() instanceof Timestamp) {
+        assertThat(body)
+            .contains(
+                entry.getKey()
+                    + ": "
+                    + MailUtil.rfcDateformatter.format(
+                        ZonedDateTime.ofInstant(
+                            ((Timestamp) entry.getValue()).toInstant(), ZoneId.of("UTC"))));
+      } else {
+        throw new Exception(
+            "Object has unsupported type: "
+                + entry.getValue().getClass().getName()
+                + " must be java.util.Date or java.lang.String for key "
+                + entry.getKey());
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
new file mode 100644
index 0000000..9de4797
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -0,0 +1,229 @@
+// 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.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.server.mail.receive.MailProcessor;
+import com.google.inject.Inject;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.List;
+import org.junit.Test;
+
+public class MailProcessorIT extends AbstractMailIT {
+  @Inject private MailProcessor mailProcessor;
+
+  @Test
+  public void parseAndPersistChangeMessage() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
+            "Test Message",
+            null,
+            null,
+            null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    mailProcessor.process(b.build());
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(messages).hasSize(3);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1:\n\nTest Message");
+    assertThat(Iterables.getLast(messages).tag).isEqualTo("mailMessageId=some id");
+  }
+
+  @Test
+  public void parseAndPersistInlineComment() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
+            null,
+            "Some Inline Comment",
+            null,
+            null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    mailProcessor.process(b.build());
+
+    // Assert messages
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(messages).hasSize(3);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1:\n\n(1 comment)");
+    assertThat(Iterables.getLast(messages).tag).isEqualTo("mailMessageId=some id");
+
+    // Assert comment
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+    assertThat(comments.get(2).message).isEqualTo("Some Inline Comment");
+    assertThat(comments.get(2).tag).isEqualTo("mailMessageId=some id");
+    assertThat(comments.get(2).inReplyTo).isEqualTo(comments.get(1).id);
+  }
+
+  @Test
+  public void parseAndPersistFileComment() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
+            null,
+            null,
+            "Some Comment on File 1",
+            null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    mailProcessor.process(b.build());
+
+    // Assert messages
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
+    assertThat(messages).hasSize(3);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1:\n\n(1 comment)");
+    assertThat(Iterables.getLast(messages).tag).isEqualTo("mailMessageId=some id");
+
+    // Assert comment
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+    assertThat(comments.get(0).message).isEqualTo("Some Comment on File 1");
+    assertThat(comments.get(0).inReplyTo).isNull();
+    assertThat(comments.get(0).tag).isEqualTo("mailMessageId=some id");
+    assertThat(comments.get(0).path).isEqualTo("gerrit-server/test.txt");
+  }
+
+  @Test
+  public void parseAndPersistMessageTwice() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
+            null,
+            "Some Inline Comment",
+            null,
+            null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    mailProcessor.process(b.build());
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+
+    // Check that the comment has not been persisted a second time
+    mailProcessor.process(b.build());
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+  }
+
+  @Test
+  public void parseAndPersistMessageFromInactiveAccount() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+    assertThat(comments).hasSize(2);
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
+            null,
+            "Some Inline Comment",
+            null,
+            null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    // Set account state to inactive
+    gApi.accounts().id("user").setActive(false);
+
+    mailProcessor.process(b.build());
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+
+    // Check that comment size has not changed
+    assertThat(comments).hasSize(2);
+
+    // Reset
+    gApi.accounts().id("user").setActive(true);
+  }
+
+  @Test
+  public void sendNotificationAfterPersistingComments() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(2);
+    String ts =
+        MailUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    // Build Message
+    String txt =
+        newPlaintextBody(
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
+            "Test Message",
+            null,
+            null,
+            null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.emailAddress)
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(admin);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
new file mode 100644
index 0000000..43f046a
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import java.util.Map;
+import org.junit.Test;
+
+public class MailSenderIT extends AbstractMailIT {
+
+  @Test
+  @GerritConfig(name = "sendemail.replyToAddress", value = "custom@gerritcodereview.com")
+  @GerritConfig(name = "receiveemail.protocol", value = "POP3")
+  public void outgoingMailHasCustomReplyToHeader() throws Exception {
+    createChangeWithReview(user);
+    // Check that the custom address was added as Reply-To
+    assertThat(sender.getMessages()).hasSize(1);
+    Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
+    assertThat(headers.get("Reply-To")).isInstanceOf(EmailHeader.String.class);
+    assertThat(((EmailHeader.String) headers.get("Reply-To")).getString())
+        .isEqualTo("custom@gerritcodereview.com");
+  }
+
+  @Test
+  public void outgoingMailHasUserEmailInReplyToHeader() throws Exception {
+    createChangeWithReview(user);
+    // Check that the user's email was added as Reply-To
+    assertThat(sender.getMessages()).hasSize(1);
+    Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
+    assertThat(headers.get("Reply-To")).isInstanceOf(EmailHeader.String.class);
+    assertThat(((EmailHeader.String) headers.get("Reply-To")).getString()).contains(user.email);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
new file mode 100644
index 0000000..8485012
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.testutil.FakeEmailSender;
+import org.junit.Test;
+
+public class NotificationMailFormatIT extends AbstractDaemonTest {
+
+  @Test
+  public void userReceivesPlaintextEmail() throws Exception {
+    // Set user preference to receive only plaintext content
+    GeneralPreferencesInfo i = new GeneralPreferencesInfo();
+    i.emailFormat = EmailFormat.PLAINTEXT;
+    gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+
+    // Create change as admin and review as user
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+
+    // Check that admin has received only plaintext content
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertThat(m.body()).isNotNull();
+    assertThat(m.htmlBody()).isNull();
+    assertMailReplyTo(m, admin.email);
+    assertMailReplyTo(m, user.email);
+
+    // Reset user preference
+    setApiUser(admin);
+    i.emailFormat = EmailFormat.HTML_PLAINTEXT;
+    gApi.accounts().id(admin.getId().toString()).setPreferences(i);
+  }
+
+  @Test
+  public void userReceivesHtmlAndPlaintextEmail() throws Exception {
+    // Create change as admin and review as user
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+
+    // Check that admin has received both HTML and plaintext content
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertThat(m.body()).isNotNull();
+    assertThat(m.htmlBody()).isNotNull();
+    assertMailReplyTo(m, admin.email);
+    assertMailReplyTo(m, user.email);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUCK
deleted file mode 100644
index d9976e5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'server_notedb',
-  srcs = glob(['*IT.java']),
-  labels = ['notedb', 'server'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD
index 17c4cdc..d314f16 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/BUILD
@@ -1,7 +1,10 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'server_notedb',
-  srcs = glob(['*IT.java']),
-  labels = ['notedb', 'server'],
+    srcs = glob(["*IT.java"]),
+    group = "server_notedb",
+    labels = [
+        "notedb",
+        "server",
+    ],
 )
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 b443e66..ed9cd90 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
@@ -15,25 +15,34 @@
 package com.google.gerrit.acceptance.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
 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 com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
+import com.google.common.collect.Ordering;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -42,36 +51,55 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 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.RevId;
+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.ChangeUtil;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.change.Rebuild;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.RepoRefCache;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeRebuilder.NoPatchSetsException;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.NoteDbChecker;
 import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.apache.http.Header;
+import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -82,51 +110,47 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-
 public class ChangeRebuilderIT extends AbstractDaemonTest {
   @ConfigSuite.Default
   public static Config defaultConfig() {
     Config cfg = new Config();
     cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
+
+    // Disable async reindex-if-stale check after index update. This avoids
+    // unintentional auto-rebuilding of the change in NoteDb during the read
+    // path of the reindex-if-stale check. For the purposes of this test, we
+    // want precise control over when auto-rebuilding happens.
+    cfg.setBoolean("index", null, "autoReindexIfStale", false);
+
     return cfg;
   }
 
-  @Inject
-  private AllUsersName allUsers;
+  @Inject private AllUsersName allUsers;
 
-  @Inject
-  private NoteDbChecker checker;
+  @Inject private NoteDbChecker checker;
 
-  @Inject
-  private Rebuild rebuildHandler;
+  @Inject private Rebuild rebuildHandler;
 
-  @Inject
-  private Provider<ReviewDb> dbProvider;
+  @Inject private Provider<ReviewDb> dbProvider;
 
-  @Inject
-  private PatchLineCommentsUtil plcUtil;
+  @Inject private CommentsUtil commentsUtil;
 
-  @Inject
-  private Provider<PostReview> postReview;
+  @Inject private Provider<PostReview> postReview;
 
-  @Inject
-  private TestChangeRebuilderWrapper rebuilderWrapper;
+  @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
 
-  @Inject
-  private BatchUpdate.Factory batchUpdateFactory;
+  @Inject private BatchUpdate.Factory batchUpdateFactory;
 
-  @Inject
-  private Sequences seq;
+  @Inject private Sequences seq;
+
+  @Inject private ChangeBundleReader bundleReader;
+
+  @Inject private PatchSetInfoFactory patchSetInfoFactory;
 
   @Before
   public void setUp() throws Exception {
     assume().that(NoteDbMode.readWrite()).isFalse();
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
     setNotesMigration(false, false);
   }
 
@@ -136,8 +160,7 @@
   }
 
   @SuppressWarnings("deprecation")
-  private void setNotesMigration(boolean writeChanges, boolean readChanges)
-      throws Exception {
+  private void setNotesMigration(boolean writeChanges, boolean readChanges) throws Exception {
     notesMigration.setWriteChanges(writeChanges);
     notesMigration.setReadChanges(readChanges);
     db = atrScope.reopenDb().getReviewDbProvider().get();
@@ -171,7 +194,18 @@
   public void publishedComment() throws Exception {
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getPatchSetId().getParentKey();
-    putComment(user, id, 1, "comment");
+    putComment(user, id, 1, "comment", null);
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void publishedCommentAndReply() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putComment(user, id, 1, "comment", null);
+    Map<String, List<CommentInfo>> comments = getPublishedComments(id);
+    String parentUuid = comments.get("a.txt").get(0).id;
+    putComment(user, id, 1, "comment", parentUuid);
     checker.rebuildAndCheckChanges(id);
   }
 
@@ -181,9 +215,9 @@
     Change c = TestChanges.newChange(project, user.getId(), seq.nextChangeId());
     c.setCreatedOn(ts);
     c.setLastUpdatedOn(ts);
-    PatchSet ps = TestChanges.newPatchSet(
-        c.currentPatchSetId(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
-        user.getId());
+    PatchSet ps =
+        TestChanges.newPatchSet(
+            c.currentPatchSetId(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", user.getId());
     ps.setCreatedOn(ts);
     db.changes().insert(Collections.singleton(c));
     db.patchSets().insert(Collections.singleton(ps));
@@ -196,7 +230,7 @@
   public void draftComment() throws Exception {
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment");
+    putDraft(user, id, 1, "comment", null);
     checker.rebuildAndCheckChanges(id);
   }
 
@@ -204,8 +238,8 @@
   public void draftAndPublishedComment() throws Exception {
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "draft comment");
-    putComment(user, id, 1, "published comment");
+    putDraft(user, id, 1, "draft comment", null);
+    putComment(user, id, 1, "published comment", null);
     checker.rebuildAndCheckChanges(id);
   }
 
@@ -213,7 +247,7 @@
   public void publishDraftComment() throws Exception {
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "draft comment");
+    putDraft(user, id, 1, "draft comment", null);
     publishDrafts(user, id);
     checker.rebuildAndCheckChanges(id);
   }
@@ -225,8 +259,7 @@
     Change.Id id = psId.getParentKey();
 
     // Events need to be otherwise identical for the account ID to be compared.
-    ChangeMessage msg1 =
-        insertMessage(id, psId, user.getId(), TimeUtil.nowTs(), "message 1");
+    ChangeMessage msg1 = insertMessage(id, psId, user.getId(), TimeUtil.nowTs(), "message 1");
     insertMessage(id, psId, null, msg1.getWrittenOn(), "message 2");
 
     checker.rebuildAndCheckChanges(id);
@@ -239,14 +272,12 @@
     Change.Id id = psId1.getParentKey();
 
     // Events need to be otherwise identical for the PatchSet.ID to be compared.
-    ChangeMessage msg1 =
-        insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 1");
+    ChangeMessage msg1 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 1");
     insertMessage(id, null, user.getId(), msg1.getWrittenOn(), "message 2");
 
     PatchSet.Id psId2 = amendChange(r.getChangeId()).getPatchSetId();
 
-    ChangeMessage msg3 =
-        insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 3");
+    ChangeMessage msg3 = insertMessage(id, null, user.getId(), TimeUtil.nowTs(), "message 3");
     insertMessage(id, null, user.getId(), msg3.getWrittenOn(), "message 4");
 
     checker.rebuildAndCheckChanges(id);
@@ -292,9 +323,7 @@
   public void restApiNotFoundWhenNoteDbDisabled() throws Exception {
     PushOneCommit.Result r = createChange();
     exception.expect(ResourceNotFoundException.class);
-    rebuildHandler.apply(
-        parseChangeResource(r.getChangeId()),
-        new Rebuild.Input());
+    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input());
   }
 
   @Test
@@ -304,9 +333,7 @@
     setNotesMigration(true, false);
 
     checker.assertNoChangeRef(project, id);
-    rebuildHandler.apply(
-        parseChangeResource(r.getChangeId()),
-        new Rebuild.Input());
+    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input());
     checker.checkChanges(id);
   }
 
@@ -337,32 +364,41 @@
     Change.Id id = r.getPatchSetId().getParentKey();
 
     ObjectId changeMetaId = getMetaRef(project, changeMetaRef(id));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
-        changeMetaId.name());
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(changeMetaId.name());
 
-    putDraft(user, id, 1, "comment by user");
-    ObjectId userDraftsId = getMetaRef(
-        allUsers, refsDraftComments(id, user.getId()));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
-        changeMetaId.name()
-        + "," + user.getId() + "=" + userDraftsId.name());
+    putDraft(user, id, 1, "comment by user", null);
+    ObjectId userDraftsId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
+        .isEqualTo(changeMetaId.name() + "," + user.getId() + "=" + userDraftsId.name());
 
-    putDraft(admin, id, 2, "comment by admin");
-    ObjectId adminDraftsId = getMetaRef(
-        allUsers, refsDraftComments(id, admin.getId()));
+    putDraft(admin, id, 2, "comment by admin", null);
+    ObjectId adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId()));
     assertThat(admin.getId().get()).isLessThan(user.getId().get());
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
-        changeMetaId.name()
-        + "," + admin.getId() + "=" + adminDraftsId.name()
-        + "," + user.getId() + "=" + userDraftsId.name());
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
+        .isEqualTo(
+            changeMetaId.name()
+                + ","
+                + admin.getId()
+                + "="
+                + adminDraftsId.name()
+                + ","
+                + user.getId()
+                + "="
+                + userDraftsId.name());
 
-    putDraft(admin, id, 2, "revised comment by admin");
-    adminDraftsId = getMetaRef(
-        allUsers, refsDraftComments(id, admin.getId()));
-    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
-        changeMetaId.name()
-        + "," + admin.getId() + "=" + adminDraftsId.name()
-        + "," + user.getId() + "=" + userDraftsId.name());
+    putDraft(admin, id, 2, "revised comment by admin", null);
+    adminDraftsId = getMetaRef(allUsers, refsDraftComments(id, admin.getId()));
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState())
+        .isEqualTo(
+            changeMetaId.name()
+                + ","
+                + admin.getId()
+                + "="
+                + adminDraftsId.name()
+                + ","
+                + user.getId()
+                + "="
+                + userDraftsId.name());
   }
 
   @Test
@@ -381,14 +417,13 @@
 
     // On next NoteDb read, the change is transparently rebuilt.
     setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).info().topic)
-        .isEqualTo(name("a-topic"));
+    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
     assertChangeUpToDate(true, id);
 
     // Check that the bundles are equal.
-    ChangeBundle actual = ChangeBundle.fromNotes(
-        plcUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual =
+        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id));
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
   }
 
@@ -400,10 +435,14 @@
     final Change.Id id = r.getPatchSetId().getParentKey();
     assertChangeUpToDate(true, id);
 
-    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
-    setNotesMigration(false, false);
-    gApi.changes().id(id.get()).topic(name("a-topic"));
-    setInvalidNoteDbState(id);
+    // Update ReviewDb and NoteDb, then revert the corresponding NoteDb change
+    // to simulate it failing.
+    NoteDbChangeState oldState = NoteDbChangeState.parse(getUnwrappedDb().changes().get(id));
+    String topic = name("a-topic");
+    gApi.changes().id(id.get()).topic(topic);
+    try (Repository repo = repoManager.openRepository(project)) {
+      new TestRepository<>(repo).update(RefNames.changeMetaRef(id), oldState.getChangeMetaId());
+    }
     assertChangeUpToDate(false, id);
 
     // Next NoteDb read comes inside the transaction started by BatchUpdate. In
@@ -411,46 +450,55 @@
     // the change is parsed by ChangesCollection and when the BatchUpdate
     // executes. We simulate it here by using BatchUpdate directly and not going
     // through an API handler.
-    setNotesMigration(true, true);
     final String msg = "message from BatchUpdate";
-    try (BatchUpdate bu = batchUpdateFactory.create(db, project,
-          identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
-      bu.addOp(id, new BatchUpdate.Op() {
-        @Override
-        public boolean updateChange(ChangeContext ctx) throws OrmException {
-          PatchSet.Id psId = ctx.getChange().currentPatchSetId();
-          ChangeMessage cm = new ChangeMessage(
-              new ChangeMessage.Key(id, ChangeUtil.messageUUID(ctx.getDb())),
-                  ctx.getAccountId(), ctx.getWhen(), psId);
-          cm.setMessage(msg);
-          ctx.getDb().changeMessages().insert(Collections.singleton(cm));
-          ctx.getUpdate(psId).setChangeMessage(msg);
-          return true;
-        }
-      });
-      bu.execute();
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+              ChangeMessage cm =
+                  new ChangeMessage(
+                      new ChangeMessage.Key(id, ChangeUtil.messageUuid()),
+                      ctx.getAccountId(),
+                      ctx.getWhen(),
+                      psId);
+              cm.setMessage(msg);
+              ctx.getDb().changeMessages().insert(Collections.singleton(cm));
+              ctx.getUpdate(psId).setChangeMessage(msg);
+              return true;
+            }
+          });
+      try {
+        bu.execute();
+        fail("expected update to fail");
+      } catch (UpdateException e) {
+        assertThat(e.getMessage()).contains("cannot copy ChangeNotesState");
+      }
     }
-    // As an implementation detail, change wasn't actually rebuilt inside the
-    // BatchUpdate transaction, but it was rebuilt during read for the
-    // subsequent reindex. Thus it's impossible to actually observe an
-    // out-of-date state in the caller.
-    assertChangeUpToDate(true, id);
 
-    // Check that the bundles are equal.
-    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
-    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
-    assertThat(actual.differencesFrom(expected)).isEmpty();
-    assertThat(
-            Iterables.transform(
-                notes.getChangeMessages(),
-                new Function<ChangeMessage, String>() {
-                  @Override
-                  public String apply(ChangeMessage in) {
-                    return in.getMessage();
-                  }
-                }))
-        .contains(msg);
+    // TODO(dborowitz): Re-enable these assertions once we fix auto-rebuilding
+    // in the BatchUpdate path.
+    //// As an implementation detail, change wasn't actually rebuilt inside the
+    //// BatchUpdate transaction, but it was rebuilt during read for the
+    //// subsequent reindex. Thus it's impossible to actually observe an
+    //// out-of-date state in the caller.
+    // assertChangeUpToDate(true, id);
+
+    //// Check that the bundles are equal.
+    // ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    // ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    // ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    // assertThat(actual.differencesFrom(expected)).isEmpty();
+    // assertThat(
+    //        Iterables.transform(
+    //            notes.getChangeMessages(),
+    //            ChangeMessage::getMessage))
+    //    .contains(msg);
+    // assertThat(actual.getChange().getTopic()).isEqualTo(topic);
   }
 
   @Test
@@ -471,20 +519,18 @@
     // background.
     rebuilderWrapper.stealNextUpdate();
     setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).info().topic)
-        .isEqualTo(name("a-topic"));
+    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
     assertChangeUpToDate(true, id);
 
     // Check that the bundles are equal.
-    ChangeBundle actual = ChangeBundle.fromNotes(
-        plcUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual =
+        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(dbProvider.get(), project, id));
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
   }
 
   @Test
-  public void rebuildReturnsCorrectResultEvenIfSavingToNoteDbFailed()
-      throws Exception {
+  public void rebuildReturnsCorrectResultEvenIfSavingToNoteDbFailed() throws Exception {
     setNotesMigration(true, true);
 
     PushOneCommit.Result r = createChange();
@@ -507,8 +553,8 @@
     // Not up to date, but the actual returned state matches anyway.
     assertChangeUpToDate(false, id);
     assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
-    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
     assertChangeUpToDate(false, id);
 
@@ -519,102 +565,96 @@
   }
 
   @Test
-  public void rebuildReturnsDraftResultWhenRebuildingInChangeNotesFails()
-      throws Exception {
+  public void rebuildReturnsDraftResultWhenRebuildingInChangeNotesFails() throws Exception {
     setNotesMigration(true, true);
 
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment by user");
+    putDraft(user, id, 1, "comment by user", null);
     assertChangeUpToDate(true, id);
 
-    ObjectId oldMetaId =
-        getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+    ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
 
     // Add a draft behind NoteDb's back.
     setNotesMigration(false, false);
-    putDraft(user, id, 1, "second comment by user");
+    putDraft(user, id, 1, "second comment by user", null);
     setInvalidNoteDbState(id);
     assertDraftsUpToDate(false, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
-        .isEqualTo(oldMetaId);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
 
     // Force the next rebuild attempt to fail (in ChangeNotes).
     rebuilderWrapper.failNextUpdate();
     setNotesMigration(true, true);
     ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
     notes.getDraftComments(user.getId());
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
-        .isEqualTo(oldMetaId);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
 
     // Not up to date, but the actual returned state matches anyway.
     assertDraftsUpToDate(false, id, user);
-    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
 
     // Another rebuild attempt succeeds
     notesFactory.create(dbProvider.get(), project, id);
     assertChangeUpToDate(true, id);
     assertDraftsUpToDate(true, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
-        .isNotEqualTo(oldMetaId);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId);
   }
 
   @Test
-  public void rebuildReturnsDraftResultWhenRebuildingInDraftCommentNotesFails()
-      throws Exception {
+  public void rebuildReturnsDraftResultWhenRebuildingInDraftCommentNotesFails() throws Exception {
     setNotesMigration(true, true);
 
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment by user");
+    putDraft(user, id, 1, "comment by user", null);
     assertChangeUpToDate(true, id);
 
-    ObjectId oldMetaId =
-        getMetaRef(allUsers, refsDraftComments(id, user.getId()));
+    ObjectId oldMetaId = getMetaRef(allUsers, refsDraftComments(id, user.getId()));
 
     // Add a draft behind NoteDb's back.
     setNotesMigration(false, false);
-    putDraft(user, id, 1, "second comment by user");
+    putDraft(user, id, 1, "second comment by user", null);
 
     ReviewDb db = getUnwrappedDb();
     Change c = db.changes().get(id);
     // Leave change meta ID alone so DraftCommentNotes does the rebuild.
-    NoteDbChangeState bogusState = new NoteDbChangeState(
-        id, NoteDbChangeState.parse(c).getChangeMetaId(),
-        ImmutableMap.<Account.Id, ObjectId>of(
-            user.getId(),
-            ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")));
+    ObjectId badSha = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    NoteDbChangeState bogusState =
+        new NoteDbChangeState(
+            id,
+            PrimaryStorage.REVIEW_DB,
+            Optional.of(
+                NoteDbChangeState.RefState.create(
+                    NoteDbChangeState.parse(c).getChangeMetaId(),
+                    ImmutableMap.of(user.getId(), badSha))),
+            Optional.empty());
     c.setNoteDbState(bogusState.toString());
     db.changes().update(Collections.singleton(c));
 
     assertDraftsUpToDate(false, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
-        .isEqualTo(oldMetaId);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
 
     // Force the next rebuild attempt to fail (in DraftCommentNotes).
     rebuilderWrapper.failNextUpdate();
     setNotesMigration(true, true);
     ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
     notes.getDraftComments(user.getId());
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
-        .isEqualTo(oldMetaId);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isEqualTo(oldMetaId);
 
     // Not up to date, but the actual returned state matches anyway.
     assertChangeUpToDate(true, id);
     assertDraftsUpToDate(false, id, user);
-    ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
+    ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
+    ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
 
     // Another rebuild attempt succeeds
-    notesFactory.create(dbProvider.get(), project, id)
-        .getDraftComments(user.getId());
+    notesFactory.create(dbProvider.get(), project, id).getDraftComments(user.getId());
     assertChangeUpToDate(true, id);
     assertDraftsUpToDate(true, id, user);
-    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId())))
-        .isNotEqualTo(oldMetaId);
+    assertThat(getMetaRef(allUsers, refsDraftComments(id, user.getId()))).isNotEqualTo(oldMetaId);
   }
 
   @Test
@@ -624,19 +664,18 @@
 
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getPatchSetId().getParentKey();
-    putDraft(user, id, 1, "comment");
+    putDraft(user, id, 1, "comment", null);
     assertDraftsUpToDate(true, id, user);
 
     // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
     setNotesMigration(false, false);
-    putDraft(user, id, 1, "comment");
+    putDraft(user, id, 1, "comment", null);
     setInvalidNoteDbState(id);
     assertDraftsUpToDate(false, id, user);
 
     // On next NoteDb read, the drafts are transparently rebuilt.
     setNotesMigration(true, true);
-    assertThat(gApi.changes().id(id.get()).current().drafts())
-        .containsKey(PushOneCommit.FILE_NAME);
+    assertThat(gApi.changes().id(id.get()).current().drafts()).containsKey(PushOneCommit.FILE_NAME);
     assertDraftsUpToDate(true, id, user);
   }
 
@@ -645,25 +684,26 @@
     // We don't have the code in our test harness to do signed pushes, so just
     // use a hard-coded cert. This cert was actually generated by C git 2.2.0
     // (albeit not for sending to Gerrit).
-    String cert = "certificate version 0.1\n"
-        + "pusher Dave Borowitz <dborowitz@google.com> 1433954361 -0700\n"
-        + "pushee git://localhost/repo.git\n"
-        + "nonce 1433954361-bde756572d665bba81d8\n"
-        + "\n"
-        + "0000000000000000000000000000000000000000"
-        + "b981a177396fb47345b7df3e4d3f854c6bea7"
-        + "s/heads/master\n"
-        + "-----BEGIN PGP SIGNATURE-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
-        + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
-        + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
-        + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
-        + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
-        + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
-        + "=XFeC\n"
-        + "-----END PGP SIGNATURE-----\n";
+    String cert =
+        "certificate version 0.1\n"
+            + "pusher Dave Borowitz <dborowitz@google.com> 1433954361 -0700\n"
+            + "pushee git://localhost/repo.git\n"
+            + "nonce 1433954361-bde756572d665bba81d8\n"
+            + "\n"
+            + "0000000000000000000000000000000000000000"
+            + "b981a177396fb47345b7df3e4d3f854c6bea7"
+            + "s/heads/master\n"
+            + "-----BEGIN PGP SIGNATURE-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "iQEcBAABAgAGBQJVeGg5AAoJEPfTicJkUdPkUggH/RKAeI9/i/LduuiqrL/SSdIa\n"
+            + "9tYaSqJKLbXz63M/AW4Sp+4u+dVCQvnAt/a35CVEnpZz6hN4Kn/tiswOWVJf4CO7\n"
+            + "htNubGs5ZMwvD6sLYqKAnrM3WxV/2TbbjzjZW6Jkidz3jz/WRT4SmjGYiEO7aA+V\n"
+            + "4ZdIS9f7sW5VsHHYlNThCA7vH8Uu48bUovFXyQlPTX0pToSgrWV3JnTxDNxfn3iG\n"
+            + "IL0zTY/qwVCdXgFownLcs6J050xrrBWIKqfcWr3u4D2aCLyR0v+S/KArr7ulZygY\n"
+            + "+SOklImn8TAZiNxhWtA6ens66IiammUkZYFv7SSzoPLFZT4dC84SmGPWgf94NoQ=\n"
+            + "=XFeC\n"
+            + "-----END PGP SIGNATURE-----\n";
 
     PushOneCommit.Result r = createChange();
     PatchSet.Id psId = r.getPatchSetId();
@@ -711,6 +751,8 @@
     rin.message = "comment";
 
     Timestamp ts = new Timestamp(c.getCreatedOn().getTime() + 2000);
+    assertThat(ts).isGreaterThan(c.getCreatedOn());
+    assertThat(ts).isLessThan(db.patchSets().get(psId).getCreatedOn());
     RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
     postReview.get().apply(revRsrc, rin, ts);
 
@@ -736,14 +778,20 @@
   }
 
   @Test
-  public void noteDbUsesOriginalSubjectFromPatchSetAndIgnoresChangeField()
-      throws Exception {
+  public void noteDbUsesOriginalSubjectFromPatchSetAndIgnoresChangeField() throws Exception {
     PushOneCommit.Result r = createChange();
     String orig = r.getChange().change().getSubject();
-    r = pushFactory.create(
-            db, admin.getIdent(), testRepo, orig + " v2",
-            PushOneCommit.FILE_NAME, "new contents", r.getChangeId())
-        .to("refs/heads/master");
+    r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                testRepo,
+                orig + " v2",
+                PushOneCommit.FILE_NAME,
+                "new contents",
+                r.getChangeId())
+            .to("refs/for/master");
     r.assertOkStatus();
 
     PatchSet.Id psId = r.getPatchSetId();
@@ -768,8 +816,15 @@
   public void deleteDraftPS1WithNoOtherEntities() throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/drafts/master");
-    push = pushFactory.create(db, admin.getIdent(), testRepo,
-        PushOneCommit.SUBJECT, "b.txt", "4711", r.getChangeId());
+    push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            "b.txt",
+            "4711",
+            r.getChangeId());
     r = push.to("refs/drafts/master");
     PatchSet.Id psId = r.getPatchSetId();
     Change.Id id = psId.getParentKey();
@@ -789,11 +844,14 @@
     Change change = r.getChange().change();
     Change.Id id = change.getId();
 
-    PatchLineComment comment = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(new PatchSet.Id(id, 0), PushOneCommit.FILE_NAME),
-            "uuid"),
-        0, user.getId(), null, TimeUtil.nowTs());
+    PatchLineComment comment =
+        new PatchLineComment(
+            new PatchLineComment.Key(
+                new Patch.Key(new PatchSet.Id(id, 0), PushOneCommit.FILE_NAME), "uuid"),
+            0,
+            user.getId(),
+            null,
+            TimeUtil.nowTs());
     comment.setSide((short) 1);
     comment.setMessage("message");
     comment.setStatus(PatchLineComment.Status.PUBLISHED);
@@ -808,32 +866,16 @@
   }
 
   @Test
-  public void skipPatchSetsGreaterThanCurrentPatchSet() throws Exception {
-    PushOneCommit.Result r = createChange();
-    Change change = r.getChange().change();
-    Change.Id id = change.getId();
-
-    PatchSet badPs =
-        new PatchSet(new PatchSet.Id(id, change.currentPatchSetId().get() + 1));
-    badPs.setCreatedOn(TimeUtil.nowTs());
-    badPs.setUploader(new Account.Id(12345));
-    badPs.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    db.patchSets().insert(Collections.singleton(badPs));
-    indexer.index(db, change.getProject(), id);
-
-    checker.rebuildAndCheckChanges(id);
-
-    setNotesMigration(true, true);
-    ChangeNotes notes = notesFactory.create(db, project, id);
-    assertThat(notes.getPatchSets().keySet())
-        .containsExactly(change.currentPatchSetId());
-  }
-
-  @Test
   public void leadingSpacesInSubject() throws Exception {
     String subj = "   " + PushOneCommit.SUBJECT;
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
-        subj, PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT);
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            subj,
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     Change change = r.getChange().change();
@@ -865,18 +907,16 @@
     oldDb.changes().update(Collections.singleton(c));
 
     c = oldDb.changes().get(c.getId());
-    ChangeNotes newNotes =
-        notesFactory.createWithAutoRebuildingDisabled(c, null);
+    ChangeNotes newNotes = notesFactory.createWithAutoRebuildingDisabled(c, null);
     assertThat(newNotes.getChange().getTopic()).isNotEqualTo(topic);
-    assertThat(newNotes.getChange().getTopic())
-        .isEqualTo(oldNotes.getChange().getTopic());
+    assertThat(newNotes.getChange().getTopic()).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");
+    putDraft(user, id, 1, "comment", null);
 
     Account.Id otherAccountId = new Account.Id(user.getId().get() + 1234);
     String otherDraftRef = refsDraftComments(id, otherAccountId);
@@ -937,8 +977,7 @@
 
     // On next NoteDb read, change is rebuilt in-memory but not stored.
     setNotesMigration(false, true);
-    assertThat(gApi.changes().id(id.get()).info().topic)
-        .isEqualTo(name("a-topic"));
+    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
     assertChangeUpToDate(false, id);
 
     // Attempting to write directly causes failure.
@@ -950,8 +989,7 @@
     }
 
     // Update was not written.
-    assertThat(gApi.changes().id(id.get()).info().topic)
-        .isEqualTo(name("a-topic"));
+    assertThat(gApi.changes().id(id.get()).info().topic).isEqualTo(name("a-topic"));
     assertChangeUpToDate(false, id);
   }
 
@@ -971,12 +1009,299 @@
     checker.rebuildAndCheckChanges(id);
   }
 
+  @Test
+  public void rebuildEntitiesCreatedByImpersonation() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    PatchSet.Id psId = new PatchSet.Id(id, 1);
+    String prefix = "/changes/" + id + "/revisions/current/";
+
+    // For each of the entities that have a real user field, create one entity
+    // without impersonation and one with.
+    CommentInput ci = new CommentInput();
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "comment without impersonation";
+    ReviewInput ri = new ReviewInput();
+    ri.label("Code-Review", -1);
+    ri.message = "message without impersonation";
+    ri.drafts = DraftHandling.KEEP;
+    ri.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    userRestSession.post(prefix + "review", ri).assertOK();
+
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "draft without impersonation";
+    userRestSession.put(prefix + "drafts", di).assertCreated();
+
+    allowRunAs();
+    try {
+      Header runAs = new BasicHeader("X-Gerrit-RunAs", user.id.toString());
+      ci.message = "comment with impersonation";
+      ri.message = "message with impersonation";
+      ri.label("Code-Review", 1);
+      adminRestSession.postWithHeader(prefix + "review", runAs, ri).assertOK();
+
+      di.message = "draft with impersonation";
+      adminRestSession.putWithHeader(prefix + "drafts", runAs, di).assertCreated();
+    } finally {
+      removeRunAs();
+    }
+
+    List<ChangeMessage> msgs =
+        Ordering.natural()
+            .onResultOf(ChangeMessage::getWrittenOn)
+            .sortedCopy(db.changeMessages().byChange(id));
+    assertThat(msgs).hasSize(3);
+    assertThat(msgs.get(1).getMessage()).endsWith("message without impersonation");
+    assertThat(msgs.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(1).getRealAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(2).getMessage()).endsWith("message with impersonation");
+    assertThat(msgs.get(2).getAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(2).getRealAuthor()).isEqualTo(admin.id);
+
+    List<PatchSetApproval> psas = db.patchSetApprovals().byChange(id).toList();
+    assertThat(psas).hasSize(1);
+    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).getValue()).isEqualTo(1);
+    assertThat(psas.get(0).getAccountId()).isEqualTo(user.id);
+    assertThat(psas.get(0).getRealAccountId()).isEqualTo(admin.id);
+
+    Ordering<PatchLineComment> commentOrder =
+        Ordering.natural().onResultOf(PatchLineComment::getWrittenOn);
+    List<PatchLineComment> drafts =
+        commentOrder.sortedCopy(db.patchComments().draftByPatchSetAuthor(psId, user.id));
+    assertThat(drafts).hasSize(2);
+    assertThat(drafts.get(0).getMessage()).isEqualTo("draft without impersonation");
+    assertThat(drafts.get(0).getAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(0).getRealAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(1).getMessage()).isEqualTo("draft with impersonation");
+    assertThat(drafts.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(1).getRealAuthor()).isEqualTo(admin.id);
+
+    List<PatchLineComment> pub =
+        commentOrder.sortedCopy(db.patchComments().publishedByPatchSet(psId));
+    assertThat(pub).hasSize(2);
+    assertThat(pub.get(0).getMessage()).isEqualTo("comment without impersonation");
+    assertThat(pub.get(0).getAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(0).getRealAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(1).getMessage()).isEqualTo("comment with impersonation");
+    assertThat(pub.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(1).getRealAuthor()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void laterEventsDependingOnEarlierPatchSetDontIntefereWithOtherPatchSets()
+      throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    ChangeData cd = r1.getChange();
+    Change.Id id = cd.getId();
+    amendChange(cd.change().getKey().get());
+    TestTimeUtil.incrementClock(90, TimeUnit.DAYS);
+
+    ReviewInput rin = ReviewInput.approve();
+    rin.message = "Some very late message on PS1";
+    gApi.changes().id(id.get()).revision(1).review(rin);
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void ignoreChangeMessageBeyondCurrentPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+    gApi.changes().id(id.get()).current().review(ReviewInput.recommend());
+
+    r = amendChange(r.getChangeId());
+    PatchSet.Id psId2 = r.getPatchSetId();
+
+    assertThat(db.patchSets().byChange(id)).hasSize(2);
+    assertThat(db.changeMessages().byPatchSet(psId2)).hasSize(1);
+    db.patchSets().deleteKeys(Collections.singleton(psId2));
+
+    checker.rebuildAndCheckChanges(psId2.getParentKey());
+    setNotesMigration(true, true);
+
+    ChangeData cd = changeDataFactory.create(db, project, id);
+    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId1);
+    assertThat(cd.patchSets().stream().map(ps -> ps.getId()).collect(toList()))
+        .containsExactly(psId1);
+    PatchSet ps = cd.currentPatchSet();
+    assertThat(ps).isNotNull();
+    assertThat(ps.getId()).isEqualTo(psId1);
+  }
+
+  @Test
+  public void highestNumberedPatchSetIsNotCurrent() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PatchSet.Id psId1 = r1.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+    PatchSet.Id psId2 = r2.getPatchSetId();
+
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx)
+                throws PatchSetInfoNotAvailableException {
+              ctx.getChange()
+                  .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), psId1));
+              return true;
+            }
+          });
+      bu.execute();
+    }
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1);
+
+    assertThat(db.changes().get(id).currentPatchSetId()).isEqualTo(psId1);
+
+    checker.rebuildAndCheckChanges(id);
+    setNotesMigration(true, true);
+
+    notes = notesFactory.create(db, project, id);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(psId1, psId2);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId1);
+  }
+
+  @Test
+  public void resolveCommentsInheritsValueFromParentWhenUnspecified() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    putDraft(user, id, 1, "comment", true);
+    putDraft(user, id, 1, "newComment", null);
+
+    Map<String, List<CommentInfo>> comments = gApi.changes().id(id.get()).current().drafts();
+    for (List<CommentInfo> cList : comments.values()) {
+      for (CommentInfo ci : cList) {
+        assertThat(ci.unresolved).isEqualTo(true);
+      }
+    }
+  }
+
+  @Test
+  public void rebuilderRespectsReadOnlyInNoteDbChangeState() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id psId1 = r.getPatchSetId();
+    Change.Id id = psId1.getParentKey();
+
+    checker.rebuildAndCheckChanges(id);
+    setNotesMigration(true, true);
+
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    NoteDbChangeState state = NoteDbChangeState.parse(c);
+    Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
+    state = state.withReadOnlyUntil(until);
+    c.setNoteDbState(state.toString());
+    db.changes().update(Collections.singleton(c));
+
+    try {
+      rebuilderWrapper.rebuild(db, id);
+      assert_().fail("expected rebuild to fail");
+    } catch (OrmRuntimeException e) {
+      assertThat(e.getMessage()).contains("read-only until");
+    }
+
+    TestTimeUtil.setClock(new Timestamp(until.getTime() + MILLISECONDS.convert(1, SECONDS)));
+    rebuilderWrapper.rebuild(db, id);
+  }
+
+  @Test
+  public void commitWithCrLineEndings() throws Exception {
+    PushOneCommit.Result r =
+        createChange("Subject\r\rBody\r", PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT);
+    Change c = r.getChange().change();
+
+    // This assertion demonstrates an arguable bug in JGit's commit subject
+    // parsing, and shows how this kind of data might have gotten into
+    // ReviewDb. If that bug ever gets fixed upstream, this assert may start
+    // failing. If that happens, this test can be rewritten to directly set the
+    // subject field in ReviewDb.
+    assertThat(c.getSubject()).isEqualTo("Subject\r\rBody");
+
+    checker.rebuildAndCheckChanges(c.getId());
+  }
+
+  @Test
+  public void patchSetsOutOfOrder() throws Exception {
+    String id = createChange().getChangeId();
+    amendChange(id);
+    PushOneCommit.Result r = amendChange(id);
+
+    ChangeData cd = r.getChange();
+    PatchSet.Id psId3 = cd.change().currentPatchSetId();
+    assertThat(psId3.get()).isEqualTo(3);
+
+    PatchSet ps1 = db.patchSets().get(new PatchSet.Id(cd.getId(), 1));
+    PatchSet ps3 = db.patchSets().get(psId3);
+    assertThat(ps1.getCreatedOn()).isLessThan(ps3.getCreatedOn());
+
+    // Simulate an old Gerrit bug by setting the created timestamp of the latest
+    // patch set ID to the timestamp of PS1.
+    ps3.setCreatedOn(ps1.getCreatedOn());
+    db.patchSets().update(Collections.singleton(ps3));
+
+    checker.rebuildAndCheckChanges(cd.getId());
+
+    setNotesMigration(true, true);
+    cd = changeDataFactory.create(db, project, cd.getId());
+    assertThat(cd.change().currentPatchSetId()).isEqualTo(psId3);
+
+    List<PatchSet> patchSets = ImmutableList.copyOf(cd.patchSets());
+    assertThat(patchSets).hasSize(3);
+
+    PatchSet newPs1 = patchSets.get(0);
+    assertThat(newPs1.getId()).isEqualTo(ps1.getId());
+    assertThat(newPs1.getCreatedOn()).isEqualTo(ps1.getCreatedOn());
+
+    PatchSet newPs2 = patchSets.get(1);
+    assertThat(newPs2.getCreatedOn()).isGreaterThan(newPs1.getCreatedOn());
+
+    PatchSet newPs3 = patchSets.get(2);
+    assertThat(newPs3.getId()).isEqualTo(ps3.getId());
+    // Migrated with a newer timestamp than the original, to preserve ordering.
+    assertThat(newPs3.getCreatedOn()).isAtLeast(newPs2.getCreatedOn());
+    assertThat(newPs3.getCreatedOn()).isGreaterThan(ps1.getCreatedOn());
+  }
+
+  @Test
+  public void ignoreNoteDbStateWithNoCorrespondingRefWhenWritesAndReadsDisabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    c = db.changes().get(id);
+
+    String refName = RefNames.changeMetaRef(id);
+    assertThat(getMetaRef(project, refName)).isNull();
+
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
+
+    notes = notesFactory.createChecked(dbProvider.get(), project, id);
+    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
+
+    assertThat(getMetaRef(project, refName)).isNull();
+  }
+
   private void assertChangesReadOnly(RestApiException e) throws Exception {
     Throwable cause = e.getCause();
     assertThat(cause).isInstanceOf(UpdateException.class);
     assertThat(cause.getCause()).isInstanceOf(OrmException.class);
-    assertThat(cause.getCause())
-        .hasMessage(NoteDbUpdateManager.CHANGES_READ_ONLY);
+    assertThat(cause.getCause()).hasMessageThat().isEqualTo(NoteDbUpdateManager.CHANGES_READ_ONLY);
   }
 
   private void setInvalidNoteDbState(Change.Id id) throws Exception {
@@ -990,27 +1315,24 @@
     db.changes().update(Collections.singleton(c));
   }
 
-  private void assertChangeUpToDate(boolean expected, Change.Id id)
-      throws Exception {
+  private void assertChangeUpToDate(boolean expected, Change.Id id) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       Change c = getUnwrappedDb().changes().get(id);
       assertThat(c).isNotNull();
       assertThat(c.getNoteDbState()).isNotNull();
-      assertThat(NoteDbChangeState.parse(c).isChangeUpToDate(
-              new RepoRefCache(repo)))
+      assertThat(NoteDbChangeState.parse(c).isChangeUpToDate(new RepoRefCache(repo)))
           .isEqualTo(expected);
     }
   }
 
-  private void assertDraftsUpToDate(boolean expected, Change.Id changeId,
-      TestAccount account) throws Exception {
+  private void assertDraftsUpToDate(boolean expected, Change.Id changeId, TestAccount account)
+      throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       Change c = getUnwrappedDb().changes().get(changeId);
       assertThat(c).isNotNull();
       assertThat(c.getNoteDbState()).isNotNull();
       NoteDbChangeState state = NoteDbChangeState.parse(c);
-      assertThat(state.areDraftsUpToDate(
-              new RepoRefCache(repo), account.getId()))
+      assertThat(state.areDraftsUpToDate(new RepoRefCache(repo), account.getId()))
           .isEqualTo(expected);
     }
   }
@@ -1022,12 +1344,13 @@
     }
   }
 
-  private void putDraft(TestAccount account, Change.Id id, int line, String msg)
+  private void putDraft(TestAccount account, Change.Id id, int line, String msg, Boolean unresolved)
       throws Exception {
     DraftInput in = new DraftInput();
     in.line = line;
     in.message = msg;
     in.path = PushOneCommit.FILE_NAME;
+    in.unresolved = unresolved;
     AcceptanceTestRequestScope.Context old = setApiUser(account);
     try {
       gApi.changes().id(id.get()).current().createDraft(in);
@@ -1036,11 +1359,12 @@
     }
   }
 
-  private void putComment(TestAccount account, Change.Id id, int line, String msg)
+  private void putComment(TestAccount account, Change.Id id, int line, String msg, String inReplyTo)
       throws Exception {
     CommentInput in = new CommentInput();
     in.line = line;
     in.message = msg;
+    in.inReplyTo = inReplyTo;
     ReviewInput rin = new ReviewInput();
     rin.comments = new HashMap<>();
     rin.comments.put(PushOneCommit.FILE_NAME, ImmutableList.of(in));
@@ -1053,8 +1377,7 @@
     }
   }
 
-  private void publishDrafts(TestAccount account, Change.Id id)
-      throws Exception {
+  private void publishDrafts(TestAccount account, Change.Id id) throws Exception {
     ReviewInput rin = new ReviewInput();
     rin.drafts = ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS;
     AcceptanceTestRequestScope.Context old = setApiUser(account);
@@ -1065,11 +1388,11 @@
     }
   }
 
-  private ChangeMessage insertMessage(Change.Id id, PatchSet.Id psId,
-      Account.Id author, Timestamp ts, String message) throws Exception {
-    ChangeMessage msg = new ChangeMessage(
-        new ChangeMessage.Key(id, ChangeUtil.messageUUID(db)),
-        author, ts, psId);
+  private ChangeMessage insertMessage(
+      Change.Id id, PatchSet.Id psId, Account.Id author, Timestamp ts, String message)
+      throws Exception {
+    ChangeMessage msg =
+        new ChangeMessage(new ChangeMessage.Key(id, ChangeUtil.messageUuid()), author, ts, psId);
     msg.setMessage(message);
     db.changeMessages().insert(Collections.singleton(msg));
 
@@ -1086,4 +1409,22 @@
     ReviewDb db = dbProvider.get();
     return ReviewDbUtil.unwrapDb(db);
   }
+
+  private void allowRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.allow(
+        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private void removeRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.remove(
+        cfg, GlobalCapability.RUN_AS, systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private Map<String, List<CommentInfo>> getPublishedComments(Change.Id id) throws Exception {
+    return gApi.changes().id(id.get()).current().comments();
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
new file mode 100644
index 0000000..183ef8f
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
@@ -0,0 +1,530 @@
+// 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.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.formatTime;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+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.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
+import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.NoteDbMode;
+import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.inject.Inject;
+import com.google.inject.util.Providers;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class NoteDbPrimaryIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setString("notedb", null, "concurrentWriterTimeout", "0s");
+    cfg.setString("notedb", null, "primaryStorageMigrationTimeout", "1d");
+    cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
+    return cfg;
+  }
+
+  @Inject private AllUsersName allUsers;
+  @Inject private BatchUpdate.Factory batchUpdateFactory;
+  @Inject private ChangeBundleReader bundleReader;
+  @Inject private CommentsUtil commentsUtil;
+  @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
+  @Inject private ChangeControl.GenericFactory changeControlFactory;
+  @Inject private ChangeUpdate.Factory updateFactory;
+  @Inject private InternalUser.Factory internalUserFactory;
+
+  private PrimaryStorageMigrator migrator;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.READ_WRITE);
+    db = ReviewDbUtil.unwrapDb(db);
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    migrator = newMigrator(null);
+  }
+
+  private PrimaryStorageMigrator newMigrator(
+      @Nullable Retryer<NoteDbChangeState> ensureRebuiltRetryer) {
+    return new PrimaryStorageMigrator(
+        cfg,
+        Providers.of(db),
+        repoManager,
+        allUsers,
+        rebuilderWrapper,
+        ensureRebuiltRetryer,
+        changeControlFactory,
+        queryProvider,
+        updateFactory,
+        internalUserFactory,
+        batchUpdateFactory);
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void updateChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+
+    ChangeInfo info = gApi.changes().id(id.get()).get();
+    assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
+    ApprovalInfo approval = Iterables.getOnlyElement(info.labels.get("Code-Review").all);
+    assertThat(approval._accountId).isEqualTo(admin.id.get());
+    assertThat(approval.value).isEqualTo(2);
+    assertThat(info.messages).hasSize(3);
+    assertThat(Iterables.getLast(info.messages).message)
+        .isEqualTo("Change has been successfully merged by " + admin.fullName);
+
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(notes.getChange().getNoteDbState())
+        .isEqualTo(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+
+    // Writes weren't reflected in ReviewDb.
+    assertThat(db.changes().get(id).getStatus()).isEqualTo(Change.Status.NEW);
+    assertThat(db.patchSetApprovals().byChange(id)).isEmpty();
+    assertThat(db.changeMessages().byChange(id)).hasSize(1);
+  }
+
+  @Test
+  public void deleteDraftComment() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    DraftInput din = new DraftInput();
+    din.path = PushOneCommit.FILE_NAME;
+    din.line = 1;
+    din.message = "A comment";
+    gApi.changes().id(id.get()).current().createDraft(din);
+
+    CommentInfo di =
+        Iterables.getOnlyElement(
+            gApi.changes().id(id.get()).current().drafts().get(PushOneCommit.FILE_NAME));
+    assertThat(di.message).isEqualTo(din.message);
+
+    assertThat(db.patchComments().draftByChangeFileAuthor(id, din.path, admin.id)).isEmpty();
+
+    gApi.changes().id(id.get()).current().draft(di.id).delete();
+    assertThat(gApi.changes().id(id.get()).current().drafts()).isEmpty();
+  }
+
+  @Test
+  public void deleteVote() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(2);
+
+    gApi.changes().id(id.get()).reviewer(admin.id.toString()).deleteVote("Code-Review");
+
+    approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(0);
+  }
+
+  @Test
+  public void deleteVoteViaReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(2);
+
+    gApi.changes().id(id.get()).current().review(ReviewInput.noScore());
+
+    approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.get(0).value).isEqualTo(0);
+  }
+
+  @Test
+  public void deleteReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).addReviewer(user.id.toString());
+    assertThat(getReviewers(id)).containsExactly(user.id);
+    gApi.changes().id(id.get()).reviewer(user.id.toString()).remove();
+    assertThat(getReviewers(id)).isEmpty();
+  }
+
+  @Test
+  public void readOnlyReviewDb() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    testReadOnly(id);
+  }
+
+  @Test
+  public void readOnlyNoteDb() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    setNoteDbPrimary(id);
+    testReadOnly(id);
+  }
+
+  private void testReadOnly(Change.Id id) throws Exception {
+    Timestamp before = TimeUtil.nowTs();
+    Timestamp until = new Timestamp(before.getTime() + 1000 * 3600);
+
+    // Set read-only.
+    Change c = db.changes().get(id);
+    assertThat(c).named("change " + id).isNotNull();
+    NoteDbChangeState state = NoteDbChangeState.parse(c);
+    state = state.withReadOnlyUntil(until);
+    c.setNoteDbState(state.toString());
+    db.changes().update(Collections.singleton(c));
+
+    assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
+    assertThat(gApi.changes().id(id.get()).get().topic).isNull();
+    try {
+      gApi.changes().id(id.get()).topic("a-topic");
+      assert_().fail("expected read-only exception");
+    } catch (RestApiException e) {
+      Optional<Throwable> oe =
+          Throwables.getCausalChain(e).stream()
+              .filter(x -> x instanceof OrmRuntimeException)
+              .findFirst();
+      assertThat(oe.isPresent()).named("OrmRuntimeException in causal chain of " + e).isTrue();
+      assertThat(oe.get().getMessage()).contains("read-only");
+    }
+    assertThat(gApi.changes().id(id.get()).get().topic).isNull();
+
+    TestTimeUtil.setClock(new Timestamp(until.getTime() + 1000));
+    assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
+    gApi.changes().id(id.get()).topic("a-topic");
+    assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
+  }
+
+  @Test
+  public void migrateToNoteDb() throws Exception {
+    testMigrateToNoteDb(createChange().getChange().getId());
+  }
+
+  @Test
+  public void migrateToNoteDbWithRebuildingFirst() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    testMigrateToNoteDb(id);
+  }
+
+  private void testMigrateToNoteDb(Change.Id id) throws Exception {
+    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).topic("a-topic");
+    assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
+    assertThat(db.changes().get(id).getTopic()).isNull();
+  }
+
+  @Test
+  public void migrateToNoteDbFailsRebuildingOnceAndRetries() throws Exception {
+    Change.Id id = createChange().getChange().getId();
+
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    rebuilderWrapper.failNextUpdate();
+
+    migrator =
+        newMigrator(
+            RetryerBuilder.<NoteDbChangeState>newBuilder()
+                .retryIfException()
+                .withStopStrategy(StopStrategies.neverStop())
+                .build());
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+  }
+
+  @Test
+  public void migrateToNoteDbFailsRebuildingAndStops() throws Exception {
+    Change.Id id = createChange().getChange().getId();
+
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    rebuilderWrapper.failNextUpdate();
+
+    migrator =
+        newMigrator(
+            RetryerBuilder.<NoteDbChangeState>newBuilder()
+                .retryIfException()
+                .withStopStrategy(StopStrategies.stopAfterAttempt(1))
+                .build());
+    exception.expect(OrmException.class);
+    exception.expectMessage("Retrying failed");
+    migrator.migrateToNoteDbPrimary(id);
+  }
+
+  @Test
+  public void migrateToNoteDbMissingOldState() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    Change c = db.changes().get(id);
+    c.setNoteDbState(null);
+    db.changes().update(Collections.singleton(c));
+
+    exception.expect(OrmRuntimeException.class);
+    exception.expectMessage("no note_db_state");
+    migrator.migrateToNoteDbPrimary(id);
+  }
+
+  @Test
+  public void migrateToNoteDbLeaseExpires() throws Exception {
+    TestTimeUtil.resetWithClockStep(2, DAYS);
+    exception.expect(OrmRuntimeException.class);
+    exception.expectMessage("read-only lease");
+    migrator.migrateToNoteDbPrimary(createChange().getChange().getId());
+  }
+
+  @Test
+  public void migrateToNoteDbAlreadyReadOnly() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    Change c = db.changes().get(id);
+    NoteDbChangeState state = NoteDbChangeState.parse(c);
+    Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
+    state = state.withReadOnlyUntil(until);
+    c.setNoteDbState(state.toString());
+    db.changes().update(Collections.singleton(c));
+
+    exception.expect(OrmRuntimeException.class);
+    exception.expectMessage("read-only until " + until);
+    migrator.migrateToNoteDbPrimary(id);
+  }
+
+  @Test
+  public void migrateToNoteDbAlreadyMigrated() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+  }
+
+  @Test
+  public void rebuildReviewDb() throws Exception {
+    Change c = createChange().getChange().change();
+    Change.Id id = c.getId();
+
+    CommentInput cin = new CommentInput();
+    cin.line = 1;
+    cin.message = "Published comment";
+    ReviewInput rin = ReviewInput.approve();
+    rin.comments = ImmutableMap.of(PushOneCommit.FILE_NAME, ImmutableList.of(cin));
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+
+    DraftInput din = new DraftInput();
+    din.path = PushOneCommit.FILE_NAME;
+    din.line = 1;
+    din.message = "Draft comment";
+    gApi.changes().id(id.get()).current().createDraft(din);
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().createDraft(din);
+
+    assertThat(db.changeMessages().byChange(id)).isNotEmpty();
+    assertThat(db.patchSets().byChange(id)).isNotEmpty();
+    assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
+    assertThat(db.patchComments().byChange(id)).isNotEmpty();
+
+    ChangeBundle noteDbBundle =
+        ChangeBundle.fromNotes(commentsUtil, notesFactory.create(db, project, id));
+
+    setNoteDbPrimary(id);
+
+    db.changeMessages().delete(db.changeMessages().byChange(id));
+    db.patchSets().delete(db.patchSets().byChange(id));
+    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+    db.patchComments().delete(db.patchComments().byChange(id));
+    ChangeMessage bogusMessage =
+        ChangeMessagesUtil.newMessage(
+            c.currentPatchSetId(),
+            identifiedUserFactory.create(admin.getId()),
+            TimeUtil.nowTs(),
+            "some message",
+            null);
+    db.changeMessages().insert(Collections.singleton(bogusMessage));
+
+    rebuilderWrapper.rebuildReviewDb(db, project, id);
+
+    assertThat(db.changeMessages().byChange(id)).isNotEmpty();
+    assertThat(db.patchSets().byChange(id)).isNotEmpty();
+    assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
+    assertThat(db.patchComments().byChange(id)).isNotEmpty();
+
+    ChangeBundle reviewDbBundle = bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db), id);
+    assertThat(reviewDbBundle.differencesFrom(noteDbBundle)).isEmpty();
+  }
+
+  @Test
+  public void rebuildReviewDbRequiresNoteDbPrimary() throws Exception {
+    Change.Id id = createChange().getChange().getId();
+
+    exception.expect(OrmException.class);
+    exception.expectMessage("primary storage of " + id + " is REVIEW_DB");
+    rebuilderWrapper.rebuildReviewDb(db, project, id);
+  }
+
+  @Test
+  public void migrateBackToReviewDbPrimary() throws Exception {
+    Change c = createChange().getChange().change();
+    Change.Id id = c.getId();
+
+    migrator.migrateToNoteDbPrimary(id);
+    assertNoteDbPrimary(id);
+
+    gApi.changes().id(id.get()).topic("new-topic");
+    assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
+    assertThat(db.changes().get(id).getTopic()).isNotEqualTo("new-topic");
+
+    migrator.migrateToReviewDbPrimary(id, null);
+    ObjectId metaId;
+    try (Repository repo = repoManager.openRepository(c.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      metaId = repo.exactRef(RefNames.changeMetaRef(id)).getObjectId();
+      RevCommit commit = rw.parseCommit(metaId);
+      rw.parseBody(commit);
+      assertThat(commit.getFullMessage())
+          .contains("Read-only-until: " + formatTime(serverIdent.get(), new Timestamp(0)));
+    }
+    NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
+    assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+    assertThat(state.getChangeMetaId()).isEqualTo(metaId);
+    assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
+    assertThat(db.changes().get(id).getTopic()).isEqualTo("new-topic");
+
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getRevision()).isEqualTo(metaId); // No rebuilding, change was up to date.
+    assertThat(notes.getReadOnlyUntil()).isNotNull();
+
+    gApi.changes().id(id.get()).topic("reviewdb-topic");
+    assertThat(db.changes().get(id).getTopic()).isEqualTo("reviewdb-topic");
+  }
+
+  private void setNoteDbPrimary(Change.Id id) throws Exception {
+    Change c = db.changes().get(id);
+    assertThat(c).named("change " + id).isNotNull();
+    NoteDbChangeState state = NoteDbChangeState.parse(c);
+    assertThat(state.getPrimaryStorage()).named("storage of " + id).isEqualTo(REVIEW_DB);
+
+    try (Repository changeRepo = repoManager.openRepository(c.getProject());
+        Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      assertThat(state.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo)))
+          .named("change " + id + " up to date")
+          .isTrue();
+    }
+
+    c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+    db.changes().update(Collections.singleton(c));
+  }
+
+  private void assertNoteDbPrimary(Change.Id id) throws Exception {
+    assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.NOTE_DB);
+  }
+
+  private List<Account.Id> getReviewers(Change.Id id) throws Exception {
+    return gApi.changes().id(id.get()).get().reviewers.values().stream()
+        .flatMap(Collection::stream)
+        .map(a -> new Account.Id(a._accountId))
+        .collect(toList());
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUCK
deleted file mode 100644
index 013115d..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUCK
+++ /dev/null
@@ -1,7 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'server_project',
-  srcs = glob(['*IT.java']),
-  labels = ['server'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
index bcf9c9f..622caf7 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/BUILD
@@ -1,16 +1,7 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
-
-FLAKY_TEST_CASES=['ProjectWatchIT.java']
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-  group = 'server_project',
-  srcs = glob(['*IT.java'], exclude=FLAKY_TEST_CASES),
-  labels = ['server'],
-)
-
-acceptance_tests(
-  group = 'server_project_flaky',
-  flaky = 1,
-  srcs = FLAKY_TEST_CASES,
-  labels = ['server', 'flaky'],
+    srcs = glob(["*IT.java"]),
+    group = "server_project",
+    labels = ["server"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6f4cc45..e110942 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -26,17 +26,18 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.Util;
 import com.google.inject.Inject;
-
+import java.util.Arrays;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -44,17 +45,12 @@
 @NoHttpd
 public class CustomLabelIT extends AbstractDaemonTest {
 
-  @Inject
-  private DynamicSet<CommentAddedListener> source;
+  @Inject private DynamicSet<CommentAddedListener> source;
 
-  private final LabelType label = category("CustomLabel",
-      value(1, "Positive"),
-      value(0, "No score"),
-      value(-1, "Negative"));
+  private final LabelType label =
+      category("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
 
-  private final LabelType P = category("CustomLabel2",
-      value(1, "Positive"),
-      value(0, "No score"));
+  private final LabelType P = category("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   private RegistrationHandle eventListenerRegistration;
   private CommentAddedListener.Event lastCommentAddedEvent;
@@ -62,20 +58,19 @@
   @Before
   public void setUp() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    AccountGroup.UUID anonymousUsers =
-        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
-    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers,
-        "refs/heads/*");
-    Util.allow(cfg, Permission.forLabel(P.getName()), 0, 1, anonymousUsers,
-        "refs/heads/*");
+    AccountGroup.UUID anonymousUsers = systemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID();
+    Util.allow(cfg, Permission.forLabel(label.getName()), -1, 1, anonymousUsers, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(P.getName()), 0, 1, anonymousUsers, "refs/heads/*");
     saveProjectConfig(project, cfg);
 
-    eventListenerRegistration = source.add(new CommentAddedListener() {
-      @Override
-      public void onCommentAdded(Event event) {
-        lastCommentAddedEvent = event;
-      }
-    });
+    eventListenerRegistration =
+        source.add(
+            new CommentAddedListener() {
+              @Override
+              public void onCommentAdded(Event event) {
+                lastCommentAddedEvent = event;
+              }
+            });
   }
 
   @After
@@ -144,9 +139,7 @@
     PushOneCommit.Result r = createChange();
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    gApi.changes()
-        .id(r.getChangeId())
-        .addReviewer(in);
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
 
     ReviewInput input = new ReviewInput().label(P.getName(), 0);
     input.message = "foo";
@@ -158,8 +151,7 @@
     assertThat(q.disliked).isNull();
     assertThat(q.rejected).isNull();
     assertThat(q.blocking).isNull();
-    assertThat(lastCommentAddedEvent.getComment()).isEqualTo(
-        "Patch Set 1:\n\n" + input.message);
+    assertThat(lastCommentAddedEvent.getComment()).isEqualTo("Patch Set 1:\n\n" + input.message);
   }
 
   @Test
@@ -175,6 +167,41 @@
     assertThat(q.blocking).isTrue();
   }
 
+  @Test
+  public void customLabel_DisallowPostSubmit() throws Exception {
+    label.setFunctionName("NoOp");
+    label.setAllowPostSubmit(false);
+    P.setFunctionName("NoOp");
+    saveLabelConfig();
+
+    PushOneCommit.Result r = createChange();
+    revision(r).review(ReviewInput.approve());
+    revision(r).submit();
+
+    ChangeInfo info = get(r.getChangeId(), ListChangesOption.DETAILED_LABELS);
+    assertPermitted(info, "Code-Review", 2);
+    assertPermitted(info, P.getName(), 0, 1);
+    assertPermitted(info, label.getName());
+
+    ReviewInput in = new ReviewInput();
+    in.label(P.getName(), P.getMax().getValue());
+    revision(r).review(in);
+
+    in = new ReviewInput();
+    in.label(label.getName(), label.getMax().getValue());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Voting on labels disallowed after submit: " + label.getName());
+    revision(r).review(in);
+  }
+
+  @Test
+  public void customLabel_withBranch() throws Exception {
+    label.setRefPatterns(Arrays.asList("master"));
+    saveLabelConfig();
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    assertThat(cfg.getLabelSections().get(label.getName()).getRefPatterns()).contains("master");
+  }
+
   private void saveLabelConfig() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     cfg.getLabelSections().put(label.getName(), label);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 0b56f43..174fb76 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -15,25 +15,29 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
-
 import com.google.inject.Inject;
 import java.util.Collections;
 import java.util.EnumSet;
@@ -45,9 +49,8 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
-import java.util.ArrayList;
-
 @NoHttpd
+@Sandboxed
 public class ProjectWatchIT extends AbstractDaemonTest {
   @Inject private WatchConfig.Accessor watchConfig;
 
@@ -65,19 +68,23 @@
     cfg.putNotifyConfig("watch", nc);
     saveProjectConfig(project, cfg);
 
-    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), testRepo,
-          "original subject", "a", "a1")
-        .to("refs/for/master");
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "original subject", "a", "a1")
+            .to("refs/for/master");
     r.assertOkStatus();
 
-    r = pushFactory.create(db, admin.getIdent(), testRepo,
-          "super sekret subject", "a", "a2", r.getChangeId())
-        .to("refs/for/master");
+    r =
+        pushFactory
+            .create(
+                db, admin.getIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master");
     r.assertOkStatus();
 
-    r = pushFactory.create(db, admin.getIdent(), testRepo,
-          "back to original subject", "a", "a3")
-        .to("refs/for/master");
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "back to original subject", "a", "a3")
+            .to("refs/for/master");
     r.assertOkStatus();
 
     List<Message> messages = sender.getMessages();
@@ -89,6 +96,72 @@
   }
 
   @Test
+  public void noNotificationForDraftChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "draft change", "a", "a1")
+            .to("refs/for/master%draft");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForDraftPatchSetsForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%draft");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void watchProject() throws Exception {
     // watch project
     String watchedProject = createProject("watchedProject").get();
@@ -99,9 +172,10 @@
     setApiUser(admin);
     TestRepository<InMemoryRepository> watchedRepo =
         cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r = pushFactory
-        .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
-        .to("refs/for/master");
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master");
     r.assertOkStatus();
 
     // push a change to non-watched project -> should not trigger email
@@ -109,8 +183,10 @@
     String notWatchedProject = createProject("otherProject").get();
     TestRepository<InMemoryRepository> notWatchedRepo =
         cloneProject(new Project.NameKey(notWatchedProject), admin);
-    r = pushFactory.create(db, admin.getIdent(), notWatchedRepo,
-        "DONT_TRIGGER", "a", "a1").to("refs/for/master");
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), notWatchedRepo, "DONT_TRIGGER", "a", "a1")
+            .to("refs/for/master");
     r.assertOkStatus();
 
     // assert email notification
@@ -139,9 +215,10 @@
     setApiUser(admin);
     TestRepository<InMemoryRepository> watchedRepo =
         cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r = pushFactory
-        .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
-        .to("refs/for/master");
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a.txt", "a1")
+            .to("refs/for/master");
     r.assertOkStatus();
 
     // assert email notification for user
@@ -160,8 +237,10 @@
 
     // push a change to non-watched file -> should not trigger email
     // notification for user, only for user2
-    r = pushFactory.create(db, admin.getIdent(), watchedRepo,
-        "TRIGGER_USER2", "b.txt", "b1").to("refs/for/master");
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER_USER2", "b.txt", "b1")
+            .to("refs/for/master");
     r.assertOkStatus();
 
     // assert email notification
@@ -173,17 +252,258 @@
     assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
   }
 
-  private void watch(String project, String filter)
-      throws RestApiException {
-    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
-    ProjectWatchInfo pwi = new ProjectWatchInfo();
-    pwi.project = project;
-    pwi.filter = filter;
-    pwi.notifyAbandonedChanges = true;
-    pwi.notifyNewChanges = true;
-    pwi.notifyAllComments = true;
-    projectsToWatch.add(pwi);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+  @Test
+  public void watchKeyword() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+
+    // watch keyword in project as user
+    watch(watchedProject, "multimaster");
+
+    // push a change with keyword -> should trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "Document multimaster setup", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // push a change without keyword -> should not trigger email notification
+    r =
+        pushFactory
+            .create(
+                db, admin.getIdent(), watchedRepo, "Cleanup cache implementation", "b.txt", "b1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch the All-Projects project to watch all projects
+    watch(allProjects.get(), null);
+
+    // push a change to any project -> should trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchFileAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch file in All-Projects project as user to watch the file in all
+    // projects
+    watch(allProjects.get(), "file:a.txt");
+
+    // push a change to watched file in any project -> should trigger email
+    // notification for user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "TRIGGER", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // watch project as user2
+    TestAccount user2 = accounts.create("user2", "user2@test.com", "User2");
+    setApiUser(user2);
+    watch(anyProject, null);
+
+    // push a change to non-watched file in any project -> should not trigger
+    // email notification for user, only for user2
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "TRIGGER_USER2", "b.txt", "b1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user2.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER_USER2\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchKeywordAllProjects() throws Exception {
+    String anyProject = createProject("anyProject").get();
+    setApiUser(user);
+
+    // watch keyword in project as user
+    watch(allProjects.get(), "multimaster");
+
+    // push a change with keyword to any project -> should trigger email
+    // notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> anyRepo =
+        cloneProject(new Project.NameKey(anyProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "Document multimaster setup", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Change subject: Document multimaster setup\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // push a change without keyword to any project -> should not trigger email
+    // notification
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), anyRepo, "Cleanup cache implementation", "b.txt", "b1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNoNotificationForDraftChange() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // push a draft change to watched project -> should not trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "draft change", "a", "a1")
+            .to("refs/for/master%draft");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNotifyOnDraftChange() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+
+    // create group that can view all drafts
+    GroupInfo groupThatCanViewDrafts = gApi.groups().create("groupThatCanViewDrafts").get();
+    grant(
+        Permission.VIEW_DRAFTS,
+        new Project.NameKey(watchedProject),
+        "refs/*",
+        false,
+        new AccountGroup.UUID(groupThatCanViewDrafts.id));
+
+    // watch project as user that can't view drafts
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // watch project as user that can view all drafts
+    TestAccount userThatCanViewDrafts =
+        accounts.create("user2", "user2@test.com", "User2", groupThatCanViewDrafts.name);
+    setApiUser(userThatCanViewDrafts);
+    watch(watchedProject, null);
+
+    // push a draft change to watched project -> should trigger email notification for
+    // userThatCanViewDrafts, but not for user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master%draft");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(userThatCanViewDrafts.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
+
+  @Test
+  public void watchProjectNoNotificationForIgnoredChange() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // push a change to watched project
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "ignored change", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // ignore the change
+    setApiUser(user);
+    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+
+    sender.clear();
+
+    // post a comment -> should not trigger email notification since user ignored the change
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ReflogIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ReflogIT.java
new file mode 100644
index 0000000..31617bf
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.Util;
+import java.util.List;
+import org.junit.Test;
+
+public class ReflogIT extends AbstractDaemonTest {
+  @Test
+  @UseLocalDisk
+  public void reflogUpdatedBySubmittingChange() throws Exception {
+    BranchApi branchApi = gApi.projects().name(project.get()).branch("master");
+    List<ReflogEntryInfo> reflog = branchApi.reflog();
+    assertThat(reflog).isNotEmpty();
+
+    // Current number of entries in the reflog
+    int refLogLen = reflog.size();
+
+    // Create and submit a change
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revision = r.getCommit().name();
+    ReviewInput in = ReviewInput.approve();
+    gApi.changes().id(changeId).revision(revision).review(in);
+    gApi.changes().id(changeId).revision(revision).submit();
+
+    // Submitting the change causes a new entry in the reflog
+    reflog = branchApi.reflog();
+    assertThat(reflog).hasSize(refLogLen + 1);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void regularUserIsNotAllowedToGetReflog() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  @UseLocalDisk
+  public void ownerUserIsAllowedToGetReflog() throws Exception {
+    GroupApi groupApi = gApi.groups().create(name("get-reflog"));
+    groupApi.addMembers("user");
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg, Permission.OWNER, new AccountGroup.UUID(groupApi.get().id), "refs/*");
+    saveProjectConfig(project, cfg);
+
+    setApiUser(user);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+
+  @Test
+  @UseLocalDisk
+  public void adminUserIsAllowedToGetReflog() throws Exception {
+    setApiUser(admin);
+    gApi.projects().name(project.get()).branch("master").reflog();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
index 56a56ee..2131273 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbandonRestoreIT.java
@@ -15,22 +15,21 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
-
-import org.junit.Test;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
+import org.junit.Test;
 
 @NoHttpd
+@UseSsh
 public class AbandonRestoreIT extends AbstractDaemonTest {
 
   @Test
@@ -39,10 +38,10 @@
     String commit = result.getCommit().name();
     executeCmd(commit, "abandon", "'abandon it'");
     executeCmd(commit, "restore", "'restore it'");
-    assertChangeMessages(result.getChangeId(), ImmutableList.of(
-        "Uploaded patch set 1.",
-        "Abandoned\n\nabandon it",
-        "Restored\n\nrestore it"));
+    assertChangeMessages(
+        result.getChangeId(),
+        ImmutableList.of(
+            "Uploaded patch set 1.", "Abandoned\n\nabandon it", "Restored\n\nrestore it"));
   }
 
   @Test
@@ -51,31 +50,22 @@
     String commit = result.getCommit().name();
     executeCmd(commit, "abandon", null);
     executeCmd(commit, "restore", null);
-    assertChangeMessages(result.getChangeId(), ImmutableList.of(
-        "Uploaded patch set 1.",
-        "Abandoned",
-        "Restored"));
+    assertChangeMessages(
+        result.getChangeId(), ImmutableList.of("Uploaded patch set 1.", "Abandoned", "Restored"));
   }
 
-  private void executeCmd(String commit, String op, String message)
-      throws Exception {
-    StringBuilder command = new StringBuilder("gerrit review ")
-        .append(commit)
-        .append(" --")
-        .append(op);
+  private void executeCmd(String commit, String op, String message) throws Exception {
+    StringBuilder command =
+        new StringBuilder("gerrit review ").append(commit).append(" --").append(op);
     if (message != null) {
       command.append(" --message ").append(message);
     }
     String response = adminSshSession.exec(command.toString());
-    assert_()
-      .withFailureMessage(adminSshSession.getError())
-      .that(adminSshSession.hasError())
-      .isFalse();
+    adminSshSession.assertSuccess();
     assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
   }
 
-  private void assertChangeMessages(String changeId, List<String> expected)
-      throws Exception {
+  private void assertChangeMessages(String changeId, List<String> expected) throws Exception {
     ChangeInfo c = get(changeId);
     Iterable<ChangeMessageInfo> messages = c.messages;
     assertThat(messages).isNotNull();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
new file mode 100644
index 0000000..208f380
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public abstract class AbstractIndexTests extends AbstractDaemonTest {
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+
+  private ChangeIndexedCounter changeIndexedCounter;
+  private RegistrationHandle changeIndexedCounterHandle;
+
+  /** @param injector injector */
+  public abstract void configureIndex(Injector injector) throws Exception;
+
+  @Before
+  public void addChangeIndexedCounter() {
+    changeIndexedCounter = new ChangeIndexedCounter();
+    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
+  }
+
+  @After
+  public void removeChangeIndexedCounter() {
+    if (changeIndexedCounterHandle != null) {
+      changeIndexedCounterHandle.remove();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  public void indexChange() throws Exception {
+    configureIndex(server.getTestInjector());
+
+    PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
+    String changeId = change.getChangeId();
+    String changeLegacyId = change.getChange().getId().toString();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    disableChangeIndexWrites();
+    amendChange(changeId, "second test", "test2.txt", "test2");
+
+    assertChangeQuery("message:second", change.getChange(), false);
+    enableChangeIndexWrites();
+
+    changeIndexedCounter.clear();
+    String cmd = Joiner.on(" ").join("gerrit", "index", "changes", changeLegacyId);
+    adminSshSession.exec(cmd);
+    adminSshSession.assertSuccess();
+
+    changeIndexedCounter.assertReindexOf(changeInfo, 1);
+
+    assertChangeQuery("message:second", change.getChange(), true);
+  }
+
+  @Test
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  public void indexProject() throws Exception {
+    configureIndex(server.getTestInjector());
+
+    PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
+    String changeId = change.getChangeId();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    disableChangeIndexWrites();
+    amendChange(changeId, "second test", "test2.txt", "test2");
+
+    assertChangeQuery("message:second", change.getChange(), false);
+    enableChangeIndexWrites();
+
+    changeIndexedCounter.clear();
+    String cmd = Joiner.on(" ").join("gerrit", "index", "project", project.get());
+    adminSshSession.exec(cmd);
+    adminSshSession.assertSuccess();
+
+    boolean indexing = true;
+    while (indexing) {
+      String out = adminSshSession.exec("gerrit show-queue --wide");
+      adminSshSession.assertSuccess();
+      indexing = out.contains("Index all changes of project " + project.get());
+    }
+
+    changeIndexedCounter.assertReindexOf(changeInfo, 1);
+
+    assertChangeQuery("message:second", change.getChange(), true);
+  }
+
+  protected void assertChangeQuery(String q, ChangeData change, boolean assertTrue)
+      throws Exception {
+    List<Integer> ids = query(q).stream().map(c -> c._number).collect(toList());
+    if (assertTrue) {
+      assertThat(ids).contains(change.getId().get());
+    } else {
+      assertThat(ids).doesNotContain(change.getId().get());
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUCK
deleted file mode 100644
index 0729b68..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUCK
+++ /dev/null
@@ -1,8 +0,0 @@
-include_defs('//gerrit-acceptance-tests/tests.defs')
-
-acceptance_tests(
-  group = 'ssh',
-  srcs = glob(['*IT.java']),
-  deps = ['//lib/commons:compress'],
-  labels = ['ssh'],
-)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD
index 3c91aa1..3b7783f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BUILD
@@ -1,8 +1,37 @@
-load('//gerrit-acceptance-tests:tests.bzl', 'acceptance_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
+
+java_library(
+    name = "util",
+    testonly = 1,
+    srcs = ["AbstractIndexTests.java"],
+    deps = ["//gerrit-acceptance-tests:lib"],
+)
 
 acceptance_tests(
-  group = 'ssh',
-  srcs = glob(['*IT.java']),
-  deps = ['//lib/commons:compress'],
-  labels = ['ssh'],
+    srcs = glob(
+        ["*IT.java"],
+        exclude = ["ElasticIndexIT.java"],
+    ),
+    group = "ssh",
+    labels = ["ssh"],
+    deps = [
+        ":util",
+        "//lib/commons:compress",
+    ],
+)
+
+acceptance_tests(
+    srcs = ["ElasticIndexIT.java"],
+    group = "elastic",
+    labels = [
+        "docker",
+        "elastic",
+        "exclusive",
+        "ssh",
+    ],
+    deps = [
+        ":util",
+        "//lib/commons:compress",
+    ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java
index 025fcfa..7a80f2e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/BanCommitIT.java
@@ -15,36 +15,31 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
-
+import com.google.gerrit.acceptance.UseSsh;
+import java.util.Locale;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
-import java.util.Locale;
-
 @NoHttpd
+@UseSsh
 public class BanCommitIT extends AbstractDaemonTest {
 
   @Test
   public void banCommit() throws Exception {
-    RevCommit c = commitBuilder()
-        .add("a.txt", "some content")
-        .create();
+    RevCommit c = commitBuilder().add("a.txt", "some content").create();
 
-    String response =
-        adminSshSession.exec("gerrit ban-commit " + project.get() + " " + c.name());
-    assert_().withFailureMessage(adminSshSession.getError())
-        .that(adminSshSession.hasError()).isFalse();
+    String response = adminSshSession.exec("gerrit ban-commit " + project.get() + " " + c.name());
+    adminSshSession.assertSuccess();
     assertThat(response.toLowerCase(Locale.US)).doesNotContain("error");
 
-    RemoteRefUpdate u = pushHead(testRepo, "refs/heads/master", false)
-        .getRemoteUpdate("refs/heads/master");
+    RemoteRefUpdate u =
+        pushHead(testRepo, "refs/heads/master", false).getRemoteUpdate("refs/heads/master");
     assertThat(u).isNotNull();
     assertThat(u.getStatus()).isEqualTo(REJECTED_OTHER_REASON);
     assertThat(u.getMessage()).startsWith("contains banned commit");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index 85d460e..e583179 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -15,14 +15,14 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.ProjectState;
-
 import org.junit.Test;
 
+@UseSsh
 public class CreateProjectIT extends AbstractDaemonTest {
 
   @Test
@@ -30,12 +30,10 @@
     String newGroupName = "newGroup";
     adminRestSession.put("/groups/" + newGroupName);
     String newProjectName = "newProject";
-    adminSshSession.exec("gerrit create-project --branch master --owner "
-        + newGroupName + " " + newProjectName);
-    assert_().withFailureMessage(adminSshSession.getError())
-        .that(adminSshSession.hasError()).isFalse();
-    ProjectState projectState =
-        projectCache.get(new Project.NameKey(newProjectName));
+    adminSshSession.exec(
+        "gerrit create-project --branch master --owner " + newGroupName + " " + newProjectName);
+    adminSshSession.assertSuccess();
+    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
     assertThat(projectState).isNotNull();
   }
 
@@ -45,12 +43,10 @@
     adminRestSession.put("/groups/" + newGroupName);
     String wrongGroupName = "newG";
     String newProjectName = "newProject";
-    adminSshSession.exec("gerrit create-project --branch master --owner "
-        + wrongGroupName + " " + newProjectName);
-    assert_().withFailureMessage(adminSshSession.getError())
-        .that(adminSshSession.hasError()).isTrue();
-    ProjectState projectState =
-        projectCache.get(new Project.NameKey(newProjectName));
+    adminSshSession.exec(
+        "gerrit create-project --branch master --owner " + wrongGroupName + " " + newProjectName);
+    adminSshSession.assertFailure();
+    ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
     assertThat(projectState).isNull();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
new file mode 100644
index 0000000..18ad621
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import com.google.gerrit.elasticsearch.ElasticVersion;
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Injector;
+import java.util.UUID;
+import org.eclipse.jgit.lib.Config;
+
+public class ElasticIndexIT extends AbstractIndexTests {
+
+  private static Config getConfig(ElasticVersion version) {
+    ElasticNodeInfo elasticNodeInfo;
+    ElasticContainer<?> container = ElasticContainer.createAndStart(version);
+    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    String indicesPrefix = UUID.randomUUID().toString();
+    Config cfg = new Config();
+    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
+    return cfg;
+  }
+
+  @ConfigSuite.Default
+  public static Config elasticsearchV2() {
+    return getConfig(ElasticVersion.V2_4);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV5() {
+    return getConfig(ElasticVersion.V5_6);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV6() {
+    return getConfig(ElasticVersion.V6_4);
+  }
+
+  @Override
+  public void configureIndex(Injector injector) throws Exception {
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
index 7176254..3f244a8 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
@@ -15,35 +15,31 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GcAssert;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GarbageCollectionQueue;
 import com.google.inject.Inject;
-
+import java.util.Arrays;
+import java.util.Locale;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.Arrays;
-import java.util.Locale;
-
 @NoHttpd
+@UseSsh
 public class GarbageCollectionIT extends AbstractDaemonTest {
 
-  @Inject
-  private GarbageCollection.Factory garbageCollectionFactory;
+  @Inject private GarbageCollection.Factory garbageCollectionFactory;
 
-  @Inject
-  private GarbageCollectionQueue gcQueue;
+  @Inject private GarbageCollectionQueue gcQueue;
 
-  @Inject
-  private GcAssert gcAssert;
+  @Inject private GcAssert gcAssert;
 
   private Project.NameKey project2;
   private Project.NameKey project3;
@@ -58,10 +54,8 @@
   @UseLocalDisk
   public void testGc() throws Exception {
     String response =
-        adminSshSession.exec("gerrit gc \"" + project.get() + "\" \""
-            + project2.get() + "\"");
-    assert_().withFailureMessage(adminSshSession.getError())
-        .that(adminSshSession.hasError()).isFalse();
+        adminSshSession.exec("gerrit gc \"" + project.get() + "\" \"" + project2.get() + "\"");
+    adminSshSession.assertSuccess();
     assertNoError(response);
     gcAssert.assertHasPackFile(project, project2);
     gcAssert.assertHasNoPackFile(allProjects, project3);
@@ -71,33 +65,35 @@
   @UseLocalDisk
   public void testGcAll() throws Exception {
     String response = adminSshSession.exec("gerrit gc --all");
-    assert_().withFailureMessage(adminSshSession.getError())
-        .that(adminSshSession.hasError()).isFalse();
+    adminSshSession.assertSuccess();
     assertNoError(response);
     gcAssert.assertHasPackFile(allProjects, project, project2, project3);
   }
 
   @Test
-  public void testGcWithoutCapability_Error() throws Exception {
+  public void gcWithoutCapability_Error() throws Exception {
     userSshSession.exec("gerrit gc --all");
-    assertThat(userSshSession.hasError()).isTrue();
+    userSshSession.assertFailure();
     String error = userSshSession.getError();
     assertThat(error).isNotNull();
-    assertError("One of the following capabilities is required to access this"
-        + " resource: [runGC, maintainServer]", error);
+    assertError(
+        "One of the following capabilities is required to access this"
+            + " resource: [runGC, maintainServer]",
+        error);
   }
 
   @Test
   @UseLocalDisk
   public void testGcAlreadyScheduled() throws Exception {
     gcQueue.addAll(Arrays.asList(project));
-    GarbageCollectionResult result = garbageCollectionFactory.create().run(
-        Arrays.asList(allProjects, project, project2, project3));
+    GarbageCollectionResult result =
+        garbageCollectionFactory
+            .create()
+            .run(Arrays.asList(allProjects, project, project2, project3));
     assertThat(result.hasErrors()).isTrue();
     assertThat(result.getErrors()).hasSize(1);
     GarbageCollectionResult.Error error = result.getErrors().get(0);
-    assertThat(error.getType()).isEqualTo(
-        GarbageCollectionResult.Error.Type.GC_ALREADY_SCHEDULED);
+    assertThat(error.getType()).isEqualTo(GarbageCollectionResult.Error.Type.GC_ALREADY_SCHEDULED);
     assertThat(error.getProjectName()).isEqualTo(project);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/IndexIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/IndexIT.java
new file mode 100644
index 0000000..196a1e5
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/IndexIT.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import com.google.inject.Injector;
+
+public class IndexIT extends AbstractIndexTests {
+
+  @Override
+  public void configureIndex(Injector injector) throws Exception {}
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java
index 2865ff87..591c6d6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.acceptance.GitUtil.initSsh;
 
 import com.google.common.collect.Lists;
@@ -23,183 +22,175 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.SshSession;
+import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gson.Gson;
-
-import org.junit.Test;
-
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
+import org.junit.Test;
 
 @NoHttpd
+@UseSsh
 public class QueryIT extends AbstractDaemonTest {
 
   private static Gson gson = new Gson();
 
   @Test
-  public void testBasicQueryJSON() throws Exception {
+  public void basicQueryJSON() throws Exception {
     String changeId1 = createChange().getChangeId();
     String changeId2 = createChange().getChangeId();
 
     List<ChangeAttribute> changes = executeSuccessfulQuery("1234");
-    assertThat(changes.size()).isEqualTo(0);
+    assertThat(changes).isEmpty();
 
     changes = executeSuccessfulQuery(changeId1);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).project).isEqualTo(project.toString());
     assertThat(changes.get(0).id).isEqualTo(changeId1);
 
     changes = executeSuccessfulQuery(changeId1 + " OR " + changeId2);
-    assertThat(changes.size()).isEqualTo(2);
+    assertThat(changes).hasSize(2);
     assertThat(changes.get(0).project).isEqualTo(project.toString());
     assertThat(changes.get(0).id).isEqualTo(changeId2);
     assertThat(changes.get(1).project).isEqualTo(project.toString());
     assertThat(changes.get(1).id).isEqualTo(changeId1);
 
-    changes =
-        executeSuccessfulQuery("--start=1 " + changeId1 + " OR " + changeId2);
-    assertThat(changes.size()).isEqualTo(1);
+    changes = executeSuccessfulQuery("--start=1 " + changeId1 + " OR " + changeId2);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).project).isEqualTo(project.toString());
     assertThat(changes.get(0).id).isEqualTo(changeId1);
   }
 
   @Test
-  public void testAllApprovalsOptionJSON() throws Exception {
+  public void allApprovalsOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     List<ChangeAttribute> changes = executeSuccessfulQuery(changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets).isNull();
 
     changes = executeSuccessfulQuery("--all-approvals " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets).isNotNull();
     assertThat(changes.get(0).patchSets.get(0).approvals).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).approvals.size()).isEqualTo(1);
+    assertThat(changes.get(0).patchSets.get(0).approvals).hasSize(1);
   }
 
   @Test
-  public void testAllReviewersOptionJSON() throws Exception {
+  public void allReviewersOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
     gApi.changes().id(changeId).addReviewer(in);
 
     List<ChangeAttribute> changes = executeSuccessfulQuery(changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).allReviewers).isNull();
 
     changes = executeSuccessfulQuery("--all-reviewers " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).allReviewers).isNotNull();
-    assertThat(changes.get(0).allReviewers.size()).isEqualTo(1);
+    assertThat(changes.get(0).allReviewers).hasSize(1);
   }
 
   @Test
-  public void testCommitMessageOptionJSON() throws Exception {
+  public void commitMessageOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
-    List<ChangeAttribute> changes =
-        executeSuccessfulQuery("--commit-message " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    List<ChangeAttribute> changes = executeSuccessfulQuery("--commit-message " + changeId);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).commitMessage).isNotNull();
     assertThat(changes.get(0).commitMessage).contains(PushOneCommit.SUBJECT);
   }
 
   @Test
-  public void testCurrentPatchSetOptionJSON() throws Exception {
+  public void currentPatchSetOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
     amendChange(changeId);
 
     List<ChangeAttribute> changes = executeSuccessfulQuery(changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).currentPatchSet).isNull();
 
     changes = executeSuccessfulQuery("--current-patch-set " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).currentPatchSet).isNotNull();
-    assertThat(changes.get(0).currentPatchSet.number).isEqualTo("2");
+    assertThat(changes.get(0).currentPatchSet.number).isEqualTo(2);
 
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     changes = executeSuccessfulQuery("--current-patch-set " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).currentPatchSet).isNotNull();
     assertThat(changes.get(0).currentPatchSet.approvals).isNotNull();
-    assertThat(changes.get(0).currentPatchSet.approvals.size()).isEqualTo(1);
-
+    assertThat(changes.get(0).currentPatchSet.approvals).hasSize(1);
   }
 
   @Test
-  public void testPatchSetsOptionJSON() throws Exception {
+  public void patchSetsOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
     amendChange(changeId);
     amendChange(changeId);
 
     List<ChangeAttribute> changes = executeSuccessfulQuery(changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets).isNull();
 
     changes = executeSuccessfulQuery("--patch-sets " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets).isNotNull();
-    assertThat(changes.get(0).patchSets.size()).isEqualTo(3);
+    assertThat(changes.get(0).patchSets).hasSize(3);
   }
 
   @Test
-  public void shouldFailWithFilesWithoutPatchSetsOrCurrentPatchSetsOption()
-      throws Exception {
+  public void shouldFailWithFilesWithoutPatchSetsOrCurrentPatchSetsOption() throws Exception {
     String changeId = createChange().getChangeId();
     adminSshSession.exec("gerrit query --files " + changeId);
-    assertThat(adminSshSession.hasError()).isTrue();
-    assertThat(adminSshSession.getError()).contains(
-        "needs --patch-sets or --current-patch-set");
+    adminSshSession.assertFailure("needs --patch-sets or --current-patch-set");
   }
 
   @Test
-  public void testFileOptionJSON() throws Exception {
+  public void fileOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
 
     List<ChangeAttribute> changes =
         executeSuccessfulQuery("--current-patch-set --files " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).currentPatchSet.files).isNotNull();
-    assertThat(changes.get(0).currentPatchSet.files.size()).isEqualTo(2);
+    assertThat(changes.get(0).currentPatchSet.files).hasSize(2);
 
     changes = executeSuccessfulQuery("--patch-sets --files " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets.get(0).files).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).files.size()).isEqualTo(2);
+    assertThat(changes.get(0).patchSets.get(0).files).hasSize(2);
 
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    changes =
-        executeSuccessfulQuery("--patch-sets --files --all-approvals "
-            + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    changes = executeSuccessfulQuery("--patch-sets --files --all-approvals " + changeId);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets.get(0).files).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).files.size()).isEqualTo(2);
+    assertThat(changes.get(0).patchSets.get(0).files).hasSize(2);
     assertThat(changes.get(0).patchSets.get(0).approvals).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).approvals.size()).isEqualTo(1);
+    assertThat(changes.get(0).patchSets.get(0).approvals).hasSize(1);
   }
 
   @Test
-  public void testCommentOptionJSON() throws Exception {
+  public void commentOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
 
     List<ChangeAttribute> changes = executeSuccessfulQuery(changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).comments).isNull();
 
     changes = executeSuccessfulQuery("--comments " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).comments).isNotNull();
-    assertThat(changes.get(0).comments.size()).isEqualTo(1);
+    assertThat(changes.get(0).comments).hasSize(1);
   }
 
   @Test
-  public void testCommentOptionsInCurrentPatchSetJSON() throws Exception {
+  public void commentOptionsInCurrentPatchSetJSON() throws Exception {
     String changeId = createChange().getChangeId();
 
     ReviewInput review = new ReviewInput();
@@ -211,20 +202,18 @@
     review.comments.put(comment.path, Lists.newArrayList(comment));
     gApi.changes().id(changeId).current().review(review);
 
-    List<ChangeAttribute> changes =
-        executeSuccessfulQuery("--current-patch-set " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    List<ChangeAttribute> changes = executeSuccessfulQuery("--current-patch-set " + changeId);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).currentPatchSet.comments).isNull();
 
-    changes =
-        executeSuccessfulQuery("--current-patch-set --comments " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    changes = executeSuccessfulQuery("--current-patch-set --comments " + changeId);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).currentPatchSet.comments).isNotNull();
-    assertThat(changes.get(0).currentPatchSet.comments.size()).isEqualTo(1);
+    assertThat(changes.get(0).currentPatchSet.comments).hasSize(1);
   }
 
   @Test
-  public void testCommentOptionInPatchSetsJSON() throws Exception {
+  public void commentOptionInPatchSetsJSON() throws Exception {
     String changeId = createChange().getChangeId();
 
     ReviewInput review = new ReviewInput();
@@ -236,79 +225,75 @@
     review.comments.put(comment.path, Lists.newArrayList(comment));
     gApi.changes().id(changeId).current().review(review);
 
-    List<ChangeAttribute> changes =
-        executeSuccessfulQuery("--patch-sets " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    List<ChangeAttribute> changes = executeSuccessfulQuery("--patch-sets " + changeId);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets.get(0).comments).isNull();
 
     changes = executeSuccessfulQuery("--patch-sets --comments " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets.get(0).comments).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).comments.size()).isEqualTo(1);
+    assertThat(changes.get(0).patchSets.get(0).comments).hasSize(1);
 
-    changes =
-        executeSuccessfulQuery("--patch-sets --comments --files " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    changes = executeSuccessfulQuery("--patch-sets --comments --files " + changeId);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets.get(0).comments).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).comments.size()).isEqualTo(1);
+    assertThat(changes.get(0).patchSets.get(0).comments).hasSize(1);
     assertThat(changes.get(0).patchSets.get(0).files).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).files.size()).isEqualTo(2);
+    assertThat(changes.get(0).patchSets.get(0).files).hasSize(2);
 
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    changes =
-        executeSuccessfulQuery("--patch-sets --comments --files --all-approvals "
-            + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    changes = executeSuccessfulQuery("--patch-sets --comments --files --all-approvals " + changeId);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets.get(0).comments).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).comments.size()).isEqualTo(1);
+    assertThat(changes.get(0).patchSets.get(0).comments).hasSize(1);
     assertThat(changes.get(0).patchSets.get(0).files).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).files.size()).isEqualTo(2);
+    assertThat(changes.get(0).patchSets.get(0).files).hasSize(2);
     assertThat(changes.get(0).patchSets.get(0).approvals).isNotNull();
-    assertThat(changes.get(0).patchSets.get(0).approvals.size()).isEqualTo(1);
+    assertThat(changes.get(0).patchSets.get(0).approvals).hasSize(1);
   }
 
   @Test
-  public void testDependenciesOptionJSON() throws Exception {
+  public void dependenciesOptionJSON() throws Exception {
     String changeId1 = createChange().getChangeId();
     String changeId2 = createChange().getChangeId();
     List<ChangeAttribute> changes = executeSuccessfulQuery(changeId1);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).dependsOn).isNull();
 
     changes = executeSuccessfulQuery("--dependencies " + changeId1);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).dependsOn).isNull();
 
     changes = executeSuccessfulQuery(changeId2);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).dependsOn).isNull();
 
     changes = executeSuccessfulQuery("--dependencies " + changeId2);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).dependsOn).isNotNull();
-    assertThat(changes.get(0).dependsOn.size()).isEqualTo(1);
+    assertThat(changes.get(0).dependsOn).hasSize(1);
   }
 
   @Test
-  public void testSubmitRecordsOptionJSON() throws Exception {
+  public void submitRecordsOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
     List<ChangeAttribute> changes = executeSuccessfulQuery(changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).submitRecords).isNull();
 
     changes = executeSuccessfulQuery("--submit-records " + changeId);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).submitRecords).isNotNull();
-    assertThat(changes.get(0).submitRecords.size()).isEqualTo(1);
+    assertThat(changes.get(0).submitRecords).hasSize(1);
   }
 
   @Test
-  public void testQueryWithNonVisibleCurrentPatchSet() throws Exception {
+  public void queryWithNonVisibleCurrentPatchSet() throws Exception {
     String changeId = createChange().getChangeId();
     amendChangeAsDraft(changeId);
     String query = "--current-patch-set --patch-sets " + changeId;
     List<ChangeAttribute> changes = executeSuccessfulQuery(query);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets).isNotNull();
     assertThat(changes.get(0).patchSets).hasSize(2);
     assertThat(changes.get(0).currentPatchSet).isNotNull();
@@ -317,23 +302,20 @@
     initSsh(user);
     userSession.open();
     changes = executeSuccessfulQuery(query, userSession);
-    assertThat(changes.size()).isEqualTo(1);
+    assertThat(changes).hasSize(1);
     assertThat(changes.get(0).patchSets).hasSize(1);
     assertThat(changes.get(0).currentPatchSet).isNull();
     userSession.close();
   }
 
-  private List<ChangeAttribute> executeSuccessfulQuery(String params,
-      SshSession session) throws Exception {
-    String rawResponse =
-        session.exec("gerrit query --format=JSON " + params);
-    assert_().withFailureMessage(session.getError())
-        .that(session.hasError()).isFalse();
+  private List<ChangeAttribute> executeSuccessfulQuery(String params, SshSession session)
+      throws Exception {
+    String rawResponse = session.exec("gerrit query --format=JSON " + params);
+    session.assertSuccess();
     return getChanges(rawResponse);
   }
 
-  private List<ChangeAttribute> executeSuccessfulQuery(String params)
-      throws Exception {
+  private List<ChangeAttribute> executeSuccessfulQuery(String params) throws Exception {
     return executeSuccessfulQuery(params, adminSshSession);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
new file mode 100644
index 0000000..237859c
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.reviewdb.client.Account.Id;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseSsh
+@NoHttpd
+public class SetReviewersIT extends AbstractDaemonTest {
+  PushOneCommit.Result change;
+
+  @Before
+  public void setUp() throws Exception {
+    change = createChange();
+  }
+
+  @Test
+  public void byCommitHash() throws Exception {
+    String id = change.getCommit().getId().toString().split("\\s+")[1];
+    addReviewer(id);
+    removeReviewer(id);
+  }
+
+  @Test
+  public void byChangeID() throws Exception {
+    addReviewer(change.getChangeId());
+    removeReviewer(change.getChangeId());
+  }
+
+  private void setReviewer(boolean add, String id) throws Exception {
+    adminSshSession.exec(
+        String.format("gerrit set-reviewers -%s %s %s", add ? "a" : "r", user.email, id));
+    adminSshSession.assertSuccess();
+    ImmutableSet<Id> reviewers = change.getChange().getReviewers().all();
+    if (add) {
+      assertThat(reviewers).contains(user.id);
+    } else {
+      assertThat(reviewers).doesNotContain(user.id);
+    }
+  }
+
+  private void addReviewer(String id) throws Exception {
+    setReviewer(true, id);
+  }
+
+  private void removeReviewer(String id) throws Exception {
+    setReviewer(false, id);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
new file mode 100644
index 0000000..8a55aea
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.sshd.Commands;
+import java.util.List;
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@NoHttpd
+@UseSsh
+public class SshCommandsIT extends AbstractDaemonTest {
+  private static final Logger log = LoggerFactory.getLogger(SshCommandsIT.class);
+
+  // TODO: It would be better to dynamically generate this list
+  private static final ImmutableMap<String, List<String>> COMMANDS =
+      ImmutableMap.of(
+          Commands.ROOT,
+          ImmutableList.of(
+              "apropos",
+              "ban-commit",
+              "close-connection",
+              "create-account",
+              "create-branch",
+              "create-group",
+              "create-project",
+              "flush-caches",
+              "gc",
+              "gsql",
+              "index",
+              "logging",
+              "ls-groups",
+              "ls-members",
+              "ls-projects",
+              "ls-user-refs",
+              "plugin",
+              "query",
+              "receive-pack",
+              "rename-group",
+              "review",
+              "set-account",
+              "set-head",
+              "set-members",
+              "set-project",
+              "set-project-parent",
+              "set-reviewers",
+              "show-caches",
+              "show-connections",
+              "show-queue",
+              "stream-events",
+              "test-submit",
+              "version"),
+          "index",
+          ImmutableList.of("changes", "project"), // "activate" and "start" are not included
+          "logging",
+          ImmutableList.of("ls", "set"),
+          "plugin",
+          ImmutableList.of("add", "enable", "install", "ls", "reload", "remove", "rm"),
+          "test-submit",
+          ImmutableList.of("rule", "type"));
+
+  @Test
+  public void sshCommandCanBeExecuted() throws Exception {
+    // Access Database capability is required to run the "gerrit gsql" command
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    for (String root : COMMANDS.keySet()) {
+      for (String command : COMMANDS.get(root)) {
+        // We can't assert that adminSshSession.hasError() is false, because using the --help
+        // option causes the usage info to be written to stderr. Instead, we assert on the
+        // content of the stderr, which will always start with "gerrit command" when the --help
+        // option is used.
+        String cmd = String.format("gerrit%s%s %s", root.isEmpty() ? "" : " ", root, command);
+        log.debug(cmd);
+        adminSshSession.exec(String.format("%s --help", cmd));
+        String response = adminSshSession.getError();
+        assertWithMessage(String.format("command %s failed: %s", command, response))
+            .that(response)
+            .startsWith(cmd);
+      }
+    }
+  }
+
+  @Test
+  public void nonExistingCommandFails() throws Exception {
+    adminSshSession.exec("gerrit non-existing-command --help");
+    assertThat(adminSshSession.getError())
+        .startsWith("fatal: gerrit: non-existing-command: not found");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
index 32a0175..b8fd95f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
@@ -18,13 +18,17 @@
 import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.base.Splitter;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.testutil.NoteDbMode;
-
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.Set;
+import java.util.TreeSet;
 import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
 import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
 import org.eclipse.jgit.transport.PacketLineIn;
@@ -33,13 +37,8 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
-import java.util.Set;
-import java.util.TreeSet;
-
 @NoHttpd
+@UseSsh
 public class UploadArchiveIT extends AbstractDaemonTest {
 
   @Before
@@ -56,7 +55,9 @@
   }
 
   @Test
-  @GerritConfig(name = "download.archive", values = {"tar", "tbz2", "tgz", "txz"})
+  @GerritConfig(
+      name = "download.archive",
+      values = {"tar", "tbz2", "tgz", "txz"})
   public void zipFormatDisabled() throws Exception {
     archiveNotPermitted();
   }
@@ -68,8 +69,7 @@
     String c = command(r, abbreviated);
 
     InputStream out =
-        adminSshSession.exec2("git-upload-archive " + project.get(),
-            argumentsToInputStream(c));
+        adminSshSession.exec2("git-upload-archive " + project.get(), argumentsToInputStream(c));
 
     // Wrap with PacketLineIn to read ACK bytes from output stream
     PacketLineIn in = new PacketLineIn(out);
@@ -91,17 +91,23 @@
       }
     }
 
-    assertThat(entryNames.size()).isEqualTo(1);
-    assertThat(Iterables.getOnlyElement(entryNames)).isEqualTo(
-        String.format("%s/%s", abbreviated, PushOneCommit.FILE_NAME));
+    assertThat(entryNames)
+        .containsExactly(
+            String.format("%s/", abbreviated),
+            String.format("%s/%s", abbreviated, PushOneCommit.FILE_NAME))
+        .inOrder();
   }
 
   private String command(PushOneCommit.Result r, String abbreviated) {
-    String c = "-f=zip "
-        + "-9 "
-        + "--prefix=" + abbreviated + "/ "
-        + r.getCommit().name() + " "
-        + PushOneCommit.FILE_NAME;
+    String c =
+        "-f=zip "
+            + "-9 "
+            + "--prefix="
+            + abbreviated
+            + "/ "
+            + r.getCommit().name()
+            + " "
+            + PushOneCommit.FILE_NAME;
     return c;
   }
 
@@ -111,8 +117,7 @@
     String c = command(r, abbreviated);
 
     InputStream out =
-        adminSshSession.exec2("git-upload-archive " + project.get(),
-            argumentsToInputStream(c));
+        adminSshSession.exec2("git-upload-archive " + project.get(), argumentsToInputStream(c));
 
     // Wrap with PacketLineIn to read ACK bytes from output stream
     PacketLineIn in = new PacketLineIn(out);
diff --git a/gerrit-acceptance-tests/tests.bzl b/gerrit-acceptance-tests/tests.bzl
index ff2562d..c1e34dd 100644
--- a/gerrit-acceptance-tests/tests.bzl
+++ b/gerrit-acceptance-tests/tests.bzl
@@ -1,28 +1,21 @@
-load('//tools/bzl:junit.bzl', 'junit_tests')
-
-BOUNCYCASTLE = [
-  '//lib/bouncycastle:bcpkix-without-neverlink',
-  '//lib/bouncycastle:bcpg-without-neverlink',
-]
+load("//tools/bzl:junit.bzl", "junit_tests")
 
 def acceptance_tests(
-    group,
-    srcs,
-    flaky = 0,
-    deps = [],
-    labels = [],
-    source_under_test = [], #unused
-    vm_args = ['-Xmx256m']):
-  junit_tests(
-    name = group,
-    srcs = srcs,
-    flaky = flaky,
-    deps = deps + BOUNCYCASTLE + [
-      '//gerrit-acceptance-tests:lib',
-    ],
-    tags = labels + [
-      'acceptance',
-      'slow',
-    ],
-    jvm_flags = vm_args,
-  )
+        group,
+        deps = [],
+        labels = [],
+        vm_args = ["-Xmx256m"],
+        **kwargs):
+    junit_tests(
+        name = group,
+        deps = deps + [
+            "//gerrit-acceptance-tests:lib",
+        ],
+        tags = labels + [
+            "acceptance",
+            "slow",
+        ],
+        size = "large",
+        jvm_flags = vm_args,
+        **kwargs
+    )
diff --git a/gerrit-acceptance-tests/tests.defs b/gerrit-acceptance-tests/tests.defs
deleted file mode 100644
index 85cc78b..0000000
--- a/gerrit-acceptance-tests/tests.defs
+++ /dev/null
@@ -1,33 +0,0 @@
-BOUNCYCASTLE = [
-  '//lib/bouncycastle:bcpkix',
-  '//lib/bouncycastle:bcpg',
-]
-
-def acceptance_tests(
-    group,
-    srcs,
-    deps = [],
-    labels = [],
-    source_under_test = [],
-    vm_args = ['-Xmx256m']):
-  from os import path
-  if path.exists('/dev/urandom'):
-    vm_args = vm_args + ['-Djava.security.egd=file:/dev/./urandom']
-
-  java_test(
-    name = group,
-    srcs = srcs,
-    deps = deps + BOUNCYCASTLE + [
-      '//gerrit-acceptance-tests:lib'
-    ],
-    source_under_test = [
-      '//gerrit-httpd:httpd',
-      '//gerrit-sshd:sshd',
-      '//gerrit-server:server',
-    ] + source_under_test,
-    labels = labels + [
-      'acceptance',
-      'slow',
-    ],
-    vm_args = vm_args,
-  )
diff --git a/gerrit-antlr/BUCK b/gerrit-antlr/BUCK
deleted file mode 100644
index e858a72..0000000
--- a/gerrit-antlr/BUCK
+++ /dev/null
@@ -1,36 +0,0 @@
-PARSER_DEPS = [
-  ':query_exception',
-  '//lib/antlr:java_runtime',
-]
-
-java_library(
-  name = 'query_exception',
-  srcs = ['src/main/java/com/google/gerrit/server/query/QueryParseException.java'],
-  visibility = ['PUBLIC'],
-)
-
-genantlr(
-  name = 'query_antlr',
-  srcs = ['src/main/antlr3/com/google/gerrit/server/query/Query.g'],
-  out = 'query_antlr.src.zip',
-)
-
-java_library(
-  name = 'lib',
-  srcs = [':query_antlr'],
-  deps = PARSER_DEPS,
-)
-
-# Hack necessary to expose ANTLR generated code as JAR to Eclipse.
-genrule(
-  name = 'query_link',
-  cmd = 'ln -s $(location :lib) $OUT',
-  out = 'query_parser.jar',
-)
-
-prebuilt_jar(
-  name = 'query_parser',
-  binary_jar = ':query_link',
-  deps = PARSER_DEPS,
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-antlr/BUILD b/gerrit-antlr/BUILD
index 6c39106..19bcaf6 100644
--- a/gerrit-antlr/BUILD
+++ b/gerrit-antlr/BUILD
@@ -1,15 +1,19 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 
 java_library(
     name = "query_exception",
-    srcs = ["src/main/java/com/google/gerrit/server/query/QueryParseException.java"],
+    srcs = [
+        "src/main/java/com/google/gerrit/server/query/QueryParseException.java",
+        "src/main/java/com/google/gerrit/server/query/QueryRequiresAuthException.java",
+    ],
     visibility = ["//visibility:public"],
 )
 
 genrule2(
     name = "query_antlr",
     srcs = ["src/main/antlr3/com/google/gerrit/server/query/Query.g"],
-    out = "query_antlr.srcjar",
+    outs = ["query_antlr.srcjar"],
     cmd = " && ".join([
         "$(location //lib/antlr:antlr-tool) -o $$TMP $<",
         "cd $$TMP",
@@ -27,6 +31,6 @@
     visibility = ["//visibility:public"],
     deps = [
         ":query_exception",
-        "//lib/antlr:java_runtime",
+        "//lib/antlr:java-runtime",
     ],
 )
diff --git a/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryParseException.java b/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryParseException.java
index 1f69ba7..80cffbb 100644
--- a/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryParseException.java
+++ b/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryParseException.java
@@ -16,8 +16,8 @@
 
 /**
  * Exception thrown when a search query is invalid.
- * <p>
- * <b>NOTE:</b> the message is visible to end users.
+ *
+ * <p><b>NOTE:</b> the message is visible to end users.
  */
 public class QueryParseException extends Exception {
   private static final long serialVersionUID = 1L;
diff --git a/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryRequiresAuthException.java b/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryRequiresAuthException.java
new file mode 100644
index 0000000..a41e54f
--- /dev/null
+++ b/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryRequiresAuthException.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+/**
+ * Exception thrown when a search query is invalid.
+ *
+ * <p><b>NOTE:</b> the message is visible to end users.
+ */
+public class QueryRequiresAuthException extends QueryParseException {
+  private static final long serialVersionUID = 1L;
+
+  public QueryRequiresAuthException(String message) {
+    super(message);
+  }
+
+  public QueryRequiresAuthException(String msg, Throwable why) {
+    super(msg, why);
+  }
+}
diff --git a/gerrit-cache-h2/BUCK b/gerrit-cache-h2/BUCK
deleted file mode 100644
index 0bc1cb12..0000000
--- a/gerrit-cache-h2/BUCK
+++ /dev/null
@@ -1,28 +0,0 @@
-java_library(
-  name = 'cache-h2',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:h2',
-    '//lib/guice:guice',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/log:api',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':cache-h2',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:h2',
-    '//lib/guice:guice',
-    '//lib:junit',
-  ],
-)
diff --git a/gerrit-cache-h2/BUILD b/gerrit-cache-h2/BUILD
index a70393d..4bccfdb 100644
--- a/gerrit-cache-h2/BUILD
+++ b/gerrit-cache-h2/BUILD
@@ -1,30 +1,31 @@
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
 java_library(
-  name = 'cache-h2',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:h2',
-    '//lib/guice:guice',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/log:api',
-  ],
-  visibility = ['//visibility:public'],
+    name = "cache-h2",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-server:server",
+        "//lib:guava",
+        "//lib:h2",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
 )
 
 junit_tests(
-  name = 'tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':cache-h2',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:h2',
-    '//lib/guice:guice',
-    '//lib:junit',
-  ],
+    name = "tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    deps = [
+        ":cache-h2",
+        "//gerrit-server:server",
+        "//lib:guava",
+        "//lib:h2",
+        "//lib:junit",
+        "//lib/guice",
+    ],
 )
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java
deleted file mode 100644
index ae999f6..0000000
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/DefaultCacheFactory.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2012 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.cache.h2;
-
-import com.google.common.base.Strings;
-import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.cache.Weigher;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.cache.CacheBinding;
-import com.google.gerrit.server.cache.ForwardingRemovalListener;
-import com.google.gerrit.server.cache.MemoryCacheFactory;
-import com.google.gerrit.server.cache.PersistentCacheFactory;
-import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-
-import java.util.concurrent.TimeUnit;
-
-public class DefaultCacheFactory implements MemoryCacheFactory {
-  public static class Module extends LifecycleModule {
-    @Override
-    protected void configure() {
-      factory(ForwardingRemovalListener.Factory.class);
-      bind(DefaultCacheFactory.class);
-      bind(MemoryCacheFactory.class).to(DefaultCacheFactory.class);
-      bind(PersistentCacheFactory.class).to(H2CacheFactory.class);
-      listener().to(H2CacheFactory.class);
-    }
-  }
-
-  private final Config cfg;
-  private final ForwardingRemovalListener.Factory forwardingRemovalListenerFactory;
-
-  @Inject
-  public DefaultCacheFactory(@GerritServerConfig Config config,
-      ForwardingRemovalListener.Factory forwardingRemovalListenerFactory) {
-    this.cfg = config;
-    this.forwardingRemovalListenerFactory = forwardingRemovalListenerFactory;
-  }
-
-  @Override
-  public <K, V> Cache<K, V> build(CacheBinding<K, V> def) {
-    return create(def, false).build();
-  }
-
-  @Override
-  public <K, V> LoadingCache<K, V> build(
-      CacheBinding<K, V> def,
-      CacheLoader<K, V> loader) {
-    return create(def, false).build(loader);
-  }
-
-  @SuppressWarnings("unchecked")
-  <K, V> CacheBuilder<K, V> create(
-      CacheBinding<K, V> def,
-      boolean unwrapValueHolder) {
-    CacheBuilder<K,V> builder = newCacheBuilder();
-    builder.recordStats();
-    builder.maximumWeight(cfg.getLong(
-        "cache", def.name(), "memoryLimit",
-        def.maximumWeight()));
-
-    builder = builder.removalListener(forwardingRemovalListenerFactory.create(def.name()));
-
-    Weigher<K, V> weigher = def.weigher();
-    if (weigher != null && unwrapValueHolder) {
-      final Weigher<K, V> impl = weigher;
-      weigher = (Weigher<K, V>) new Weigher<K, ValueHolder<V>> () {
-        @Override
-        public int weigh(K key, ValueHolder<V> value) {
-          return impl.weigh(key, value.value);
-        }
-      };
-    } else if (weigher == null) {
-      weigher = unitWeight();
-    }
-    builder.weigher(weigher);
-
-    Long age = def.expireAfterWrite(TimeUnit.SECONDS);
-    if (has(def.name(), "maxAge")) {
-      builder.expireAfterWrite(ConfigUtil.getTimeUnit(cfg,
-          "cache", def.name(), "maxAge",
-          age != null ? age : 0,
-          TimeUnit.SECONDS), TimeUnit.SECONDS);
-    } else if (age != null) {
-      builder.expireAfterWrite(age, TimeUnit.SECONDS);
-    }
-
-    return builder;
-  }
-
-  private boolean has(String name, String var) {
-    return !Strings.isNullOrEmpty(cfg.getString("cache", name, var));
-  }
-
-  @SuppressWarnings("unchecked")
-  private static <K, V> CacheBuilder<K, V> newCacheBuilder() {
-    return (CacheBuilder<K, V>) CacheBuilder.newBuilder();
-  }
-
-  private static <K, V> Weigher<K, V> unitWeight() {
-    return new Weigher<K, V>() {
-      @Override
-      public int weigh(K key, V value) {
-        return 1;
-      }
-    };
-  }
-}
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.java
new file mode 100644
index 0000000..0d1cf20
--- /dev/null
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheBindingProxy.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.h2;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.Weigher;
+import com.google.gerrit.server.cache.CacheBinding;
+import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
+import com.google.inject.TypeLiteral;
+import java.util.concurrent.TimeUnit;
+
+class H2CacheBindingProxy<K, V> implements CacheBinding<K, V> {
+  private static final String MSG_NOT_SUPPORTED =
+      "This is read-only wrapper. Modifications are not supported";
+
+  private final CacheBinding<K, V> source;
+
+  H2CacheBindingProxy(CacheBinding<K, V> source) {
+    this.source = source;
+  }
+
+  @Override
+  public Long expireAfterWrite(TimeUnit unit) {
+    return source.expireAfterWrite(unit);
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public Weigher<K, V> weigher() {
+    Weigher<K, V> weigher = source.weigher();
+    if (weigher == null) {
+      return null;
+    }
+
+    // introduce weigher that performs calculations
+    // on value that is being stored not on ValueHolder
+    return (Weigher<K, V>)
+        new Weigher<K, ValueHolder<V>>() {
+          @Override
+          public int weigh(K key, ValueHolder<V> value) {
+            return weigher.weigh(key, value.value);
+          }
+        };
+  }
+
+  @Override
+  public String name() {
+    return source.name();
+  }
+
+  @Override
+  public TypeLiteral<K> keyType() {
+    return source.keyType();
+  }
+
+  @Override
+  public TypeLiteral<V> valueType() {
+    return source.valueType();
+  }
+
+  @Override
+  public long maximumWeight() {
+    return source.maximumWeight();
+  }
+
+  @Override
+  public long diskLimit() {
+    return source.diskLimit();
+  }
+
+  @Override
+  public CacheLoader<K, V> loader() {
+    return source.loader();
+  }
+
+  @Override
+  public CacheBinding<K, V> maximumWeight(long weight) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+
+  @Override
+  public CacheBinding<K, V> diskLimit(long limit) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+
+  @Override
+  public CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit durationUnits) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+
+  @Override
+  public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+
+  @Override
+  public CacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz) {
+    throw new RuntimeException(MSG_NOT_SUPPORTED);
+  }
+}
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 5009771..78a32bd 100644
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.cache.CacheBinding;
+import com.google.gerrit.server.cache.MemoryCacheFactory;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
@@ -31,11 +32,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -44,15 +40,18 @@
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 class H2CacheFactory implements PersistentCacheFactory, LifecycleListener {
-  private static final Logger log =
-      LoggerFactory.getLogger(H2CacheFactory.class);
+  private static final Logger log = LoggerFactory.getLogger(H2CacheFactory.class);
 
-  private final DefaultCacheFactory defaultFactory;
+  private final MemoryCacheFactory memCacheFactory;
   private final Config config;
   private final Path cacheDir;
   private final List<H2CacheImpl<?, ?>> caches;
@@ -64,11 +63,11 @@
 
   @Inject
   H2CacheFactory(
-      DefaultCacheFactory defaultCacheFactory,
+      MemoryCacheFactory memCacheFactory,
       @GerritServerConfig Config cfg,
       SitePaths site,
       DynamicMap<Cache<?, ?>> cacheMap) {
-    defaultFactory = defaultCacheFactory;
+    this.memCacheFactory = memCacheFactory;
     config = cfg;
     cacheDir = getCacheDir(site, cfg.getString("cache", null, "directory"));
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
@@ -77,17 +76,16 @@
     this.cacheMap = cacheMap;
 
     if (cacheDir != null) {
-      executor = Executors.newFixedThreadPool(
-          1,
-          new ThreadFactoryBuilder()
-            .setNameFormat("DiskCache-Store-%d")
-            .build());
-      cleanup = Executors.newScheduledThreadPool(
-          1,
-          new ThreadFactoryBuilder()
-            .setNameFormat("DiskCache-Prune-%d")
-            .setDaemon(true)
-            .build());
+      executor =
+          Executors.newFixedThreadPool(
+              1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build());
+      cleanup =
+          Executors.newScheduledThreadPool(
+              1,
+              new ThreadFactoryBuilder()
+                  .setNameFormat("DiskCache-Prune-%d")
+                  .setDaemon(true)
+                  .build());
     } else {
       executor = null;
       cleanup = null;
@@ -103,15 +101,15 @@
       try {
         Files.createDirectories(loc);
       } catch (IOException e) {
-        log.warn("Can't create disk cache: " + loc.toAbsolutePath());
+        log.warn("Can't create disk cache: {}", loc.toAbsolutePath());
         return null;
       }
     }
     if (!Files.isWritable(loc)) {
-      log.warn("Can't write to disk cache: " + loc.toAbsolutePath());
+      log.warn("Can't write to disk cache: {}", loc.toAbsolutePath());
       return null;
     }
-    log.info("Enabling disk cache " + loc.toAbsolutePath());
+    log.info("Enabling disk cache {}", loc.toAbsolutePath());
     return loc;
   }
 
@@ -119,19 +117,10 @@
   public void start() {
     if (executor != null) {
       for (final H2CacheImpl<?, ?> cache : caches) {
-        executor.execute(new Runnable() {
-          @Override
-          public void run() {
-            cache.start();
-          }
-        });
-
-        cleanup.schedule(new Runnable() {
-          @Override
-          public void run() {
-            cache.prune(cleanup);
-          }
-        }, 30, TimeUnit.SECONDS);
+        executor.execute(cache::start);
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError =
+            cleanup.schedule(() -> cache.prune(cleanup), 30, TimeUnit.SECONDS);
       }
     }
   }
@@ -145,7 +134,7 @@
         List<Runnable> pending = executor.shutdownNow();
         if (executor.awaitTermination(15, TimeUnit.MINUTES)) {
           if (pending != null && !pending.isEmpty()) {
-            log.info(String.format("Finishing %d disk cache updates", pending.size()));
+            log.info("Finishing {} disk cache updates", pending.size());
             for (Runnable update : pending) {
               update.run();
             }
@@ -166,18 +155,19 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> build(CacheBinding<K, V> def) {
-    long limit = config.getLong("cache", def.name(), "diskLimit", 128 << 20);
+  public <K, V> Cache<K, V> build(CacheBinding<K, V> in) {
+    long limit = config.getLong("cache", in.name(), "diskLimit", in.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
-      return defaultFactory.build(def);
+      return memCacheFactory.build(in);
     }
 
-    SqlStore<K, V> store = newSqlStore(def.name(), def.keyType(), limit,
-        def.expireAfterWrite(TimeUnit.SECONDS));
-    H2CacheImpl<K, V> cache = new H2CacheImpl<>(
-        executor, store, def.keyType(),
-        (Cache<K, ValueHolder<V>>) defaultFactory.create(def, true).build());
+    H2CacheBindingProxy<K, V> def = new H2CacheBindingProxy<>(in);
+    SqlStore<K, V> store =
+        newSqlStore(def.name(), def.keyType(), limit, def.expireAfterWrite(TimeUnit.SECONDS));
+    H2CacheImpl<K, V> cache =
+        new H2CacheImpl<>(
+            executor, store, def.keyType(), (Cache<K, ValueHolder<V>>) memCacheFactory.build(def));
     synchronized (caches) {
       caches.add(cache);
     }
@@ -186,24 +176,24 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      CacheBinding<K, V> def,
-      CacheLoader<K, V> loader) {
-    long limit = config.getLong("cache", def.name(), "diskLimit", 128 << 20);
+  public <K, V> LoadingCache<K, V> build(CacheBinding<K, V> in, CacheLoader<K, V> loader) {
+    long limit = config.getLong("cache", in.name(), "diskLimit", in.diskLimit());
 
     if (cacheDir == null || limit <= 0) {
-      return defaultFactory.build(def, loader);
+      return memCacheFactory.build(in, loader);
     }
 
-    SqlStore<K, V> store = newSqlStore(def.name(), def.keyType(), limit,
-        def.expireAfterWrite(TimeUnit.SECONDS));
-    Cache<K, ValueHolder<V>> mem = (Cache<K, ValueHolder<V>>)
-        defaultFactory.create(def, true)
-        .build((CacheLoader<K, V>) new H2CacheImpl.Loader<>(
-              executor, store, loader));
-    H2CacheImpl<K, V> cache = new H2CacheImpl<>(
-        executor, store, def.keyType(), mem);
-    caches.add(cache);
+    H2CacheBindingProxy<K, V> def = new H2CacheBindingProxy<>(in);
+    SqlStore<K, V> store =
+        newSqlStore(def.name(), def.keyType(), limit, def.expireAfterWrite(TimeUnit.SECONDS));
+    Cache<K, ValueHolder<V>> mem =
+        (Cache<K, ValueHolder<V>>)
+            memCacheFactory.build(
+                def, (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader));
+    H2CacheImpl<K, V> cache = new H2CacheImpl<>(executor, store, def.keyType(), mem);
+    synchronized (caches) {
+      caches.add(cache);
+    }
     return cache;
   }
 
@@ -221,10 +211,7 @@
   }
 
   private <V, K> SqlStore<K, V> newSqlStore(
-      String name,
-      TypeLiteral<K> keyType,
-      long maxSize,
-      Long expireAfterWrite) {
+      String name, TypeLiteral<K> keyType, long maxSize, Long expireAfterWrite) {
     StringBuilder url = new StringBuilder();
     url.append("jdbc:h2:").append(cacheDir.resolve(name).toUri());
     if (h2CacheSize >= 0) {
@@ -235,7 +222,10 @@
     if (h2AutoServer) {
       url.append(";AUTO_SERVER=TRUE");
     }
-    return new SqlStore<>(url.toString(), keyType, maxSize,
+    return new SqlStore<>(
+        url.toString(),
+        keyType,
+        maxSize,
         expireAfterWrite == null ? 0 : expireAfterWrite.longValue());
   }
 }
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 838f42c..3b86c95 100644
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -28,11 +28,6 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.inject.TypeLiteral;
-
-import org.h2.jdbc.JdbcSQLException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.InvalidClassException;
 import java.io.ObjectOutputStream;
@@ -51,47 +46,48 @@
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
+import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
+import org.h2.jdbc.JdbcSQLException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Hybrid in-memory and database backed cache built on H2.
- * <p>
- * This cache can be used as either a recall cache, or a loading cache if a
- * CacheLoader was supplied to its constructor at build time. Before creating an
- * entry the in-memory cache is checked for the item, then the database is
- * checked, and finally the CacheLoader is used to construct the item. This is
- * mostly useful for CacheLoaders that are computationally intensive, such as
- * the PatchListCache.
- * <p>
- * Cache stores and invalidations are performed on a background thread, hiding
- * the latency associated with serializing the key and value pairs and writing
- * them to the database log.
- * <p>
- * A BloomFilter is used around the database to reduce the number of SELECTs
- * issued against the database for new cache items that have not been seen
- * before, a common operation for the PatchListCache. The BloomFilter is sized
- * when the cache starts to be 64,000 entries or double the number of items
- * currently in the database table.
- * <p>
- * This cache does not export its items as a ConcurrentMap.
+ *
+ * <p>This cache can be used as either a recall cache, or a loading cache if a CacheLoader was
+ * supplied to its constructor at build time. Before creating an entry the in-memory cache is
+ * checked for the item, then the database is checked, and finally the CacheLoader is used to
+ * construct the item. This is mostly useful for CacheLoaders that are computationally intensive,
+ * such as the PatchListCache.
+ *
+ * <p>Cache stores and invalidations are performed on a background thread, hiding the latency
+ * associated with serializing the key and value pairs and writing them to the database log.
+ *
+ * <p>A BloomFilter is used around the database to reduce the number of SELECTs issued against the
+ * database for new cache items that have not been seen before, a common operation for the
+ * PatchListCache. The BloomFilter is sized when the cache starts to be 64,000 entries or double the
+ * number of items currently in the database table.
+ *
+ * <p>This cache does not export its items as a ConcurrentMap.
  *
  * @see H2CacheFactory
  */
-public class H2CacheImpl<K, V> extends AbstractLoadingCache<K, V> implements
-    PersistentCache {
+public class H2CacheImpl<K, V> extends AbstractLoadingCache<K, V> implements PersistentCache {
   private static final Logger log = LoggerFactory.getLogger(H2CacheImpl.class);
 
-  private static final ImmutableSet<String> OLD_CLASS_NAMES = ImmutableSet.of(
-      "com.google.gerrit.server.change.ChangeKind");
+  private static final ImmutableSet<String> OLD_CLASS_NAMES =
+      ImmutableSet.of("com.google.gerrit.server.change.ChangeKind");
 
   private final Executor executor;
   private final SqlStore<K, V> store;
   private final TypeLiteral<K> keyType;
   private final Cache<K, ValueHolder<V>> mem;
 
-  H2CacheImpl(Executor executor,
+  H2CacheImpl(
+      Executor executor,
       SqlStore<K, V> store,
       TypeLiteral<K> keyType,
       Cache<K, ValueHolder<V>> mem) {
@@ -134,8 +130,7 @@
   }
 
   @Override
-  public V get(K key, Callable<? extends V> valueLoader)
-      throws ExecutionException {
+  public V get(K key, Callable<? extends V> valueLoader) throws ExecutionException {
     return mem.get(key, new LoadingCallable(key, valueLoader)).value;
   }
 
@@ -144,24 +139,14 @@
     final ValueHolder<V> h = new ValueHolder<>(val);
     h.created = TimeUtil.nowMs();
     mem.put(key, h);
-    executor.execute(new Runnable() {
-      @Override
-      public void run() {
-        store.put(key, h);
-      }
-    });
+    executor.execute(() -> store.put(key, h));
   }
 
   @SuppressWarnings("unchecked")
   @Override
   public void invalidate(final Object key) {
     if (keyType.getRawType().isInstance(key) && store.mightContain((K) key)) {
-      executor.execute(new Runnable() {
-        @Override
-        public void run() {
-          store.invalidate((K) key);
-        }
-      });
+      executor.execute(() -> store.invalidate((K) key));
     }
     mem.invalidate(key);
   }
@@ -212,12 +197,9 @@
     cal.add(Calendar.DAY_OF_MONTH, 1);
 
     long delay = cal.getTimeInMillis() - TimeUtil.nowMs();
-    service.schedule(new Runnable() {
-      @Override
-      public void run() {
-        prune(service);
-      }
-    }, delay, TimeUnit.MILLISECONDS);
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        service.schedule(() -> prune(service), delay, TimeUnit.MILLISECONDS);
   }
 
   static class ValueHolder<V> {
@@ -252,12 +234,7 @@
 
       final ValueHolder<V> h = new ValueHolder<>(loader.load(key));
       h.created = TimeUtil.nowMs();
-      executor.execute(new Runnable() {
-        @Override
-        public void run() {
-          store.put(key, h);
-        }
-      });
+      executor.execute(() -> store.put(key, h));
       return h;
     }
   }
@@ -280,14 +257,9 @@
         }
       }
 
-      final ValueHolder<V> h = new ValueHolder<V>(loader.call());
+      final ValueHolder<V> h = new ValueHolder<>(loader.call());
       h.created = TimeUtil.nowMs();
-      executor.execute(new Runnable() {
-        @Override
-        public void run() {
-          store.put(key, h);
-        }
-      });
+      executor.execute(() -> store.put(key, h));
       return h;
     }
   }
@@ -312,8 +284,7 @@
 
         @Override
         public void funnel(K from, PrimitiveSink into) {
-          try (ObjectOutputStream ser =
-              new ObjectOutputStream(new SinkOutputStream(into))) {
+          try (ObjectOutputStream ser = new ObjectOutputStream(new SinkOutputStream(into))) {
             ser.writeObject(from);
             ser.flush();
           } catch (IOException err) {
@@ -332,30 +303,30 @@
     }
 
     static final KeyType<?> OTHER = new KeyType<>();
-    static final KeyType<String> STRING = new KeyType<String>() {
-      @Override
-      String columnType() {
-        return "VARCHAR(4096)";
-      }
+    static final KeyType<String> STRING =
+        new KeyType<String>() {
+          @Override
+          String columnType() {
+            return "VARCHAR(4096)";
+          }
 
-      @Override
-      String get(ResultSet rs, int col) throws SQLException {
-        return rs.getString(col);
-      }
+          @Override
+          String get(ResultSet rs, int col) throws SQLException {
+            return rs.getString(col);
+          }
 
-      @Override
-      void set(PreparedStatement ps, int col, String value)
-          throws SQLException {
-        ps.setString(col, value);
-      }
+          @Override
+          void set(PreparedStatement ps, int col, String value) throws SQLException {
+            ps.setString(col, value);
+          }
 
-      @SuppressWarnings("unchecked")
-      @Override
-      Funnel<String> funnel() {
-        Funnel<?> s = Funnels.unencodedCharsFunnel();
-        return (Funnel<String>) s;
-      }
-    };
+          @SuppressWarnings("unchecked")
+          @Override
+          Funnel<String> funnel() {
+            Funnel<?> s = Funnels.unencodedCharsFunnel();
+            return (Funnel<String>) s;
+          }
+        };
   }
 
   static class SqlStore<K, V> {
@@ -369,8 +340,7 @@
     private volatile BloomFilter<K> bloomFilter;
     private int estimatedSize;
 
-    SqlStore(String jdbcUrl, TypeLiteral<K> keyType, long maxSize,
-        long expireAfterWrite) {
+    SqlStore(String jdbcUrl, TypeLiteral<K> keyType, long maxSize, long expireAfterWrite) {
       this.url = jdbcUrl;
       this.keyType = KeyType.create(keyType);
       this.maxSize = maxSize;
@@ -426,9 +396,11 @@
             }
           } catch (JdbcSQLException e) {
             if (e.getCause() instanceof InvalidClassException) {
-              log.warn("Entries cached for " + url
-                  + " have an incompatible class and can't be deserialized. "
-                  + "Cache is flushed.");
+              log.warn(
+                  "Entries cached for "
+                      + url
+                      + " have an incompatible class and can't be deserialized. "
+                      + "Cache is flushed.");
               invalidateAll();
             } else {
               throw e;
@@ -489,8 +461,7 @@
 
     private static boolean isOldClassNameError(Throwable t) {
       for (Throwable c : Throwables.getCausalChain(t)) {
-        if (c instanceof ClassNotFoundException
-            && OLD_CLASS_NAMES.contains(c.getMessage())) {
+        if (c instanceof ClassNotFoundException && OLD_CLASS_NAMES.contains(c.getMessage())) {
           return true;
         }
       }
@@ -533,7 +504,8 @@
       try {
         c = acquire();
         if (c.put == null) {
-          c.put = c.conn.prepareStatement("MERGE INTO data VALUES(?,?,?,?)");
+          c.put =
+              c.conn.prepareStatement("MERGE INTO data (k, v, created, accessed) VALUES(?,?,?,?)");
         }
         try {
           keyType.set(c.put, 1, key);
@@ -600,21 +572,16 @@
         c = acquire();
         try (Statement s = c.conn.createStatement()) {
           long used = 0;
-          try (ResultSet r = s.executeQuery("SELECT"
-              + " SUM(OCTET_LENGTH(k) + OCTET_LENGTH(v))"
-              + " FROM data")) {
+          try (ResultSet r = s.executeQuery("SELECT SUM(space) FROM data")) {
             used = r.next() ? r.getLong(1) : 0;
           }
           if (used <= maxSize) {
             return;
           }
 
-          try (ResultSet r = s.executeQuery("SELECT"
-              + " k"
-              + ",OCTET_LENGTH(k) + OCTET_LENGTH(v)"
-              + ",created"
-              + " FROM data"
-              + " ORDER BY accessed")) {
+          try (ResultSet r =
+              s.executeQuery(
+                  "SELECT" + " k" + ",space" + ",created" + " FROM data" + " ORDER BY accessed")) {
             while (maxSize < used && r.next()) {
               K key = keyType.get(r, 1);
               Timestamp created = r.getTimestamp(3);
@@ -642,10 +609,7 @@
       try {
         c = acquire();
         try (Statement s = c.conn.createStatement();
-            ResultSet r = s.executeQuery("SELECT"
-                + " COUNT(*)"
-                + ",SUM(OCTET_LENGTH(k) + OCTET_LENGTH(v))"
-                + " FROM data")) {
+            ResultSet r = s.executeQuery("SELECT" + " COUNT(*)" + ",SUM(space)" + " FROM data")) {
           if (r.next()) {
             size = r.getLong(1);
             space = r.getLong(2);
@@ -696,12 +660,19 @@
       this.url = url;
       this.conn = org.h2.Driver.load().connect(url, null);
       try (Statement stmt = conn.createStatement()) {
-        stmt.execute("CREATE TABLE IF NOT EXISTS data"
-          + "(k " + type.columnType() + " NOT NULL PRIMARY KEY HASH"
-          + ",v OTHER NOT NULL"
-          + ",created TIMESTAMP NOT NULL"
-          + ",accessed TIMESTAMP NOT NULL"
-          + ")");
+        stmt.addBatch(
+            "CREATE TABLE IF NOT EXISTS data"
+                + "(k "
+                + type.columnType()
+                + " NOT NULL PRIMARY KEY HASH"
+                + ",v OTHER NOT NULL"
+                + ",created TIMESTAMP NOT NULL"
+                + ",accessed TIMESTAMP NOT NULL"
+                + ")");
+        stmt.addBatch(
+            "ALTER TABLE data ADD COLUMN IF NOT EXISTS "
+                + "space BIGINT AS OCTET_LENGTH(k) + OCTET_LENGTH(v)");
+        stmt.executeBatch();
       }
     }
 
@@ -743,7 +714,7 @@
 
     @Override
     public void write(int b) {
-      sink.putByte((byte)b);
+      sink.putByte((byte) b);
     }
 
     @Override
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheModule.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
new file mode 100644
index 0000000..f605578
--- /dev/null
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.h2;
+
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.ModuleImpl;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.PersistentCacheFactory;
+
+@ModuleImpl(name = CacheModule.PERSISTENT_MODULE)
+public class H2CacheModule extends LifecycleModule {
+  @Override
+  protected void configure() {
+    bind(PersistentCacheFactory.class).to(H2CacheFactory.class);
+    listener().to(H2CacheFactory.class);
+  }
+}
diff --git a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java b/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
index c999d71..15e0de0 100644
--- a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -24,13 +24,11 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.inject.TypeLiteral;
-
-import org.junit.Before;
-import org.junit.Test;
-
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Before;
+import org.junit.Test;
 
 public class H2CacheTest {
   private static int dbCnt;
@@ -43,13 +41,9 @@
     mem = CacheBuilder.newBuilder().build();
 
     TypeLiteral<String> keyType = new TypeLiteral<String>() {};
-    SqlStore<String, Boolean> store = new SqlStore<>(
-        "jdbc:h2:mem:" + "Test_" + (++dbCnt),
-        keyType,
-        1 << 20,
-        0);
-    impl =
-        new H2CacheImpl<>(MoreExecutors.directExecutor(), store, keyType, mem);
+    SqlStore<String, Boolean> store =
+        new SqlStore<>("jdbc:h2:mem:Test_" + (++dbCnt), keyType, 1 << 20, 0);
+    impl = new H2CacheImpl<>(MoreExecutors.directExecutor(), store, keyType, mem);
   }
 
   @Test
@@ -57,26 +51,32 @@
     assertNull(impl.getIfPresent("foo"));
 
     final AtomicBoolean called = new AtomicBoolean();
-    assertTrue(impl.get("foo", new Callable<Boolean>() {
-      @Override
-      public Boolean call() throws Exception {
-        called.set(true);
-        return true;
-      }
-    }));
+    assertTrue(
+        impl.get(
+            "foo",
+            new Callable<Boolean>() {
+              @Override
+              public Boolean call() throws Exception {
+                called.set(true);
+                return true;
+              }
+            }));
     assertTrue("used Callable", called.get());
     assertTrue("exists in cache", impl.getIfPresent("foo"));
     mem.invalidate("foo");
     assertTrue("exists on disk", impl.getIfPresent("foo"));
 
     called.set(false);
-    assertTrue(impl.get("foo", new Callable<Boolean>() {
-      @Override
-      public Boolean call() throws Exception {
-        called.set(true);
-        return true;
-      }
-    }));
+    assertTrue(
+        impl.get(
+            "foo",
+            new Callable<Boolean>() {
+              @Override
+              public Boolean call() throws Exception {
+                called.set(true);
+                return true;
+              }
+            }));
     assertFalse("did not invoke Callable", called.get());
   }
 }
diff --git a/gerrit-cache-mem/BUILD b/gerrit-cache-mem/BUILD
new file mode 100644
index 0000000..2ad2e4c
--- /dev/null
+++ b/gerrit-cache-mem/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "mem",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-extension-api:api",
+        "//gerrit-server:server",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-cache-mem/src/main/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/gerrit-cache-mem/src/main/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
new file mode 100644
index 0000000..8091e16
--- /dev/null
+++ b/gerrit-cache-mem/src/main/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2012 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.cache.mem;
+
+import com.google.common.base.Strings;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.gerrit.server.cache.CacheBinding;
+import com.google.gerrit.server.cache.ForwardingRemovalListener;
+import com.google.gerrit.server.cache.MemoryCacheFactory;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+public class DefaultMemoryCacheFactory implements MemoryCacheFactory {
+  private final Config cfg;
+  private final ForwardingRemovalListener.Factory forwardingRemovalListenerFactory;
+
+  @Inject
+  DefaultMemoryCacheFactory(
+      @GerritServerConfig Config config,
+      ForwardingRemovalListener.Factory forwardingRemovalListenerFactory) {
+    this.cfg = config;
+    this.forwardingRemovalListenerFactory = forwardingRemovalListenerFactory;
+  }
+
+  @Override
+  public <K, V> Cache<K, V> build(CacheBinding<K, V> def) {
+    return create(def).build();
+  }
+
+  @Override
+  public <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader) {
+    return create(def).build(loader);
+  }
+
+  @SuppressWarnings("unchecked")
+  private <K, V> CacheBuilder<K, V> create(CacheBinding<K, V> def) {
+    CacheBuilder<K, V> builder = newCacheBuilder();
+    builder.recordStats();
+    builder.maximumWeight(cfg.getLong("cache", def.name(), "memoryLimit", def.maximumWeight()));
+
+    builder = builder.removalListener(forwardingRemovalListenerFactory.create(def.name()));
+
+    Weigher<K, V> weigher = def.weigher();
+    if (weigher == null) {
+      weigher = unitWeight();
+    }
+    builder.weigher(weigher);
+
+    Long age = def.expireAfterWrite(TimeUnit.SECONDS);
+    if (has(def.name(), "maxAge")) {
+      builder.expireAfterWrite(
+          ConfigUtil.getTimeUnit(
+              cfg, "cache", def.name(), "maxAge", age != null ? age : 0, TimeUnit.SECONDS),
+          TimeUnit.SECONDS);
+    } else if (age != null) {
+      builder.expireAfterWrite(age, TimeUnit.SECONDS);
+    }
+
+    return builder;
+  }
+
+  private boolean has(String name, String var) {
+    return !Strings.isNullOrEmpty(cfg.getString("cache", name, var));
+  }
+
+  @SuppressWarnings("unchecked")
+  private static <K, V> CacheBuilder<K, V> newCacheBuilder() {
+    return (CacheBuilder<K, V>) CacheBuilder.newBuilder();
+  }
+
+  private static <K, V> Weigher<K, V> unitWeight() {
+    return new Weigher<K, V>() {
+      @Override
+      public int weigh(K key, V value) {
+        return 1;
+      }
+    };
+  }
+}
diff --git a/gerrit-cache-mem/src/main/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheModule.java b/gerrit-cache-mem/src/main/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheModule.java
new file mode 100644
index 0000000..7beb0bb
--- /dev/null
+++ b/gerrit-cache-mem/src/main/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheModule.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.mem;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.ModuleImpl;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.ForwardingRemovalListener;
+import com.google.gerrit.server.cache.MemoryCacheFactory;
+
+@ModuleImpl(name = CacheModule.MEMORY_MODULE)
+public class DefaultMemoryCacheModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    factory(ForwardingRemovalListener.Factory.class);
+    bind(MemoryCacheFactory.class).to(DefaultMemoryCacheFactory.class);
+  }
+}
diff --git a/gerrit-common/BUCK b/gerrit-common/BUCK
deleted file mode 100644
index 847fd25..0000000
--- a/gerrit-common/BUCK
+++ /dev/null
@@ -1,75 +0,0 @@
-SRC = 'src/main/java/com/google/gerrit/'
-
-ANNOTATIONS = [
-  SRC + x for x in [
-    'common/Nullable.java',
-    'common/audit/Audit.java',
-    'common/auth/SignInRequired.java',
-  ]
-]
-
-java_library(
-  name = 'annotations',
-  srcs = ANNOTATIONS,
-  visibility = ['PUBLIC'],
-)
-
-gwt_module(
-  name = 'client',
-  srcs = glob([SRC + 'common/**/*.java']),
-  gwt_xml = SRC + 'Common.gwt.xml',
-  exported_deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-prettify:client',
-    '//lib:guava',
-    '//lib:gwtorm_client',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-  ],
-  provided_deps = ['//lib:servlet-api-3_1'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'server',
-  srcs = glob([SRC + 'common/**/*.java'], excludes = ANNOTATIONS),
-  deps = [
-    ':annotations',
-    '//gerrit-extension-api:api',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-prettify:server',
-    '//gerrit-reviewdb:server',
-    '//lib:guava',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-  ],
-  provided_deps = ['//lib:servlet-api-3_1'],
-  visibility = ['PUBLIC'],
-)
-
-TEST = 'src/test/java/com/google/gerrit/common/'
-AUTO_VALUE_TEST_SRCS = [TEST + 'AutoValueTest.java']
-
-java_test(
-  name = 'client_tests',
-  srcs = glob(['src/test/java/**/*.java'], excludes = AUTO_VALUE_TEST_SRCS),
-  deps = [
-    ':client',
-    '//lib:guava',
-    '//lib:junit',
-  ],
-  source_under_test = [':client'],
-)
-
-java_test(
-  name = 'auto_value_tests',
-  srcs = AUTO_VALUE_TEST_SRCS,
-  deps = [
-    '//lib:truth',
-    '//lib/auto:auto-value',
-  ],
-)
diff --git a/gerrit-common/BUILD b/gerrit-common/BUILD
index 86ba087..3ebd8ba 100644
--- a/gerrit-common/BUILD
+++ b/gerrit-common/BUILD
@@ -1,77 +1,88 @@
-load('//tools/bzl:gwt.bzl', 'gwt_module')
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:gwt.bzl", "gwt_module")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
-SRC = 'src/main/java/com/google/gerrit/'
+SRC = "src/main/java/com/google/gerrit/"
 
 ANNOTATIONS = [
-  SRC + x for x in [
-    'common/Nullable.java',
-    'common/audit/Audit.java',
-    'common/auth/SignInRequired.java',
-  ]
+    SRC + x
+    for x in [
+        "common/Nullable.java",
+        "common/audit/Audit.java",
+        "common/auth/SignInRequired.java",
+    ]
 ]
 
 java_library(
-  name = 'annotations',
-  srcs = ANNOTATIONS,
-  visibility = ['//visibility:public'],
+    name = "annotations",
+    srcs = ANNOTATIONS,
+    visibility = ["//visibility:public"],
 )
 
 gwt_module(
-  name = 'client',
-  srcs = glob([SRC + 'common/**/*.java']),
-  gwt_xml = SRC + 'Common.gwt.xml',
-  exported_deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-prettify:client',
-    '//lib:guava',
-    '//lib:gwtorm_client',
-    '//lib:servlet-api-3_1',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-  ],
-  visibility = ['//visibility:public'],
+    name = "client",
+    srcs = glob([SRC + "common/**/*.java"]),
+    exported_deps = [
+        "//gerrit-extension-api:api",
+        "//gerrit-prettify:client",
+        "//lib:guava",
+        "//lib:gwtorm-client",
+        "//lib:servlet-api-3_1",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/joda:joda-time",
+        "//lib/log:api",
+    ],
+    gwt_xml = SRC + "Common.gwt.xml",
+    visibility = ["//visibility:public"],
 )
 
 java_library(
-  name = 'server',
-  srcs = glob([SRC + 'common/**/*.java'], exclude = ANNOTATIONS),
-  deps = [
-    ':annotations',
-    '//gerrit-extension-api:api',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-prettify:server',
-    '//gerrit-reviewdb:server',
-    '//lib:guava',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:servlet-api-3_1',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-  ],
-  visibility = ['//visibility:public'],
+    name = "server",
+    srcs = glob(
+        [SRC + "common/**/*.java"],
+        exclude = ANNOTATIONS,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":annotations",
+        "//gerrit-extension-api:api",
+        "//gerrit-patch-jgit:server",
+        "//gerrit-prettify:server",
+        "//gerrit-reviewdb:server",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/joda:joda-time",
+        "//lib/log:api",
+    ],
 )
 
-TEST = 'src/test/java/com/google/gerrit/common/'
-AUTO_VALUE_TEST_SRCS = [TEST + 'AutoValueTest.java']
+TEST = "src/test/java/com/google/gerrit/common/"
+
+AUTO_VALUE_TEST_SRCS = [TEST + "AutoValueTest.java"]
 
 junit_tests(
-  name = 'client_tests',
-  srcs = glob(['src/test/java/**/*.java'], exclude = AUTO_VALUE_TEST_SRCS),
-  deps = [
-    ':client',
-    '//lib:guava',
-    '//lib:junit',
-  ],
+    name = "client_tests",
+    srcs = glob(
+        ["src/test/java/**/*.java"],
+        exclude = AUTO_VALUE_TEST_SRCS,
+    ),
+    deps = [
+        ":client",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib:truth",
+    ],
 )
 
 junit_tests(
-  name = 'auto_value_tests',
-  srcs = AUTO_VALUE_TEST_SRCS,
-  deps = [
-    '//lib:truth',
-    '//lib/auto:auto-value',
-  ],
+    name = "auto_value_tests",
+    srcs = AUTO_VALUE_TEST_SRCS,
+    deps = [
+        "//lib:truth",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+    ],
 )
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java
index 83dc4d8..4c5583f 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/FileUtil.java
@@ -15,17 +15,15 @@
 package com.google.gerrit.common;
 
 import com.google.common.annotations.GwtIncompatible;
-
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.IO;
-
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Arrays;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.IO;
 
 @GwtIncompatible("Unemulated classes in java.io, java.nio and JGit")
 public class FileUtil {
@@ -73,10 +71,10 @@
 
   /**
    * Get the last modified time of a path.
-   * <p>
-   * Equivalent to {@code File#lastModified()}, returning 0 on errors, including
-   * file not found. Callers that prefer exceptions can use {@link
-   * Files#getLastModifiedTime(Path, java.nio.file.LinkOption...)}.
+   *
+   * <p>Equivalent to {@code File#lastModified()}, returning 0 on errors, including file not found.
+   * Callers that prefer exceptions can use {@link Files#getLastModifiedTime(Path,
+   * java.nio.file.LinkOption...)}.
    *
    * @param p path.
    * @return last modified time, in milliseconds since epoch.
@@ -100,6 +98,5 @@
     }
   }
 
-  private FileUtil() {
-  }
+  private FileUtil() {}
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
index 3422a78..624bcea 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
@@ -16,7 +16,6 @@
 
 import com.google.common.annotations.GwtIncompatible;
 import com.google.common.collect.Sets;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -27,13 +26,13 @@
 import java.net.URLClassLoader;
 import java.nio.file.Path;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Set;
 
 @GwtIncompatible("Unemulated methods in Class and OutputStream")
 public final class IoUtil {
-  public static void copyWithThread(final InputStream src,
-      final OutputStream dst) {
+  public static void copyWithThread(final InputStream src, final OutputStream dst) {
     new Thread("IoUtil-Copy") {
       @Override
       public void run() {
@@ -56,7 +55,11 @@
     }.start();
   }
 
-  public static void loadJARs(Iterable<Path> jars) {
+  public static void loadJARs(Collection<Path> jars) {
+    if (jars.isEmpty()) {
+      return;
+    }
+
     ClassLoader cl = IoUtil.class.getClassLoader();
     if (!(cl instanceof URLClassLoader)) {
       throw noAddURL("Not loaded by URLClassLoader", null);
@@ -80,8 +83,7 @@
         if (have.add(url)) {
           addURL.invoke(cl, url);
         }
-      } catch (MalformedURLException | IllegalArgumentException |
-          IllegalAccessException e) {
+      } catch (MalformedURLException | IllegalArgumentException | IllegalAccessException e) {
         throw noAddURL("addURL " + path + " failed", e);
       } catch (InvocationTargetException e) {
         throw noAddURL("addURL " + path + " failed", e.getCause());
@@ -97,6 +99,6 @@
     String prefix = "Cannot extend classpath: ";
     return new UnsupportedOperationException(prefix + m, why);
   }
-  private IoUtil() {
-  }
+
+  private IoUtil() {}
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/Nullable.java b/gerrit-common/src/main/java/com/google/gerrit/common/Nullable.java
index 46db282..f33687f 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/Nullable.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/Nullable.java
@@ -18,9 +18,6 @@
 
 import java.lang.annotation.Retention;
 
-/**
- * Gerrit's own replacement for the javax.annotations.Nullable
- */
+/** Gerrit's own replacement for the javax.annotations.Nullable */
 @Retention(RUNTIME)
-public @interface Nullable {
-}
+public @interface Nullable {}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
index 43d4441..692285f 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/PageLinks.java
@@ -96,6 +96,10 @@
     return toChangeQuery(op("owner", fullname) + " " + status(status));
   }
 
+  public static String toAssigneeQuery(String fullname) {
+    return toChangeQuery(op("assignee", fullname));
+  }
+
   public static String toCustomDashboard(final String params) {
     return "/dashboard/?" + params;
   }
@@ -121,7 +125,7 @@
   }
 
   public static String projectQuery(Project.NameKey proj, Status status) {
-      return status(status) + " " + op("project", proj.get());
+    return status(status) + " " + op("project", proj.get());
   }
 
   public static String topicQuery(Status status, String topic) {
@@ -131,12 +135,11 @@
       case DRAFT:
       case MERGED:
       case NEW:
-        return toChangeQuery(op("topic", topic) + " (" +
-            status(Status.NEW) + " OR " +
-            status(Status.MERGED) + ")");
+        return toChangeQuery(
+            op("topic", topic) + " (" + status(Status.NEW) + " OR " + status(Status.MERGED) + ")");
     }
     return toChangeQuery(status(status) + " " + op("topic", topic));
-}
+  }
 
   public static String toGroup(AccountGroup.UUID uuid) {
     return ADMIN_GROUPS + "uuid-" + uuid;
@@ -181,6 +184,5 @@
     return value.matches("[^\u0000-\u0020!\"#$%&'():;?\\[\\]{}~]+");
   }
 
-  protected PageLinks() {
-  }
+  protected PageLinks() {}
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java b/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java
index 4645158..b14543d 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/PluginData.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.common;
 
 import com.google.common.annotations.GwtIncompatible;
-
 import java.nio.file.Path;
 import java.util.Objects;
 
@@ -35,7 +34,8 @@
   public boolean equals(Object obj) {
     if (obj instanceof PluginData) {
       PluginData o = (PluginData) obj;
-      return Objects.equals(name, o.name) && Objects.equals(version, o.version)
+      return Objects.equals(name, o.name)
+          && Objects.equals(version, o.version)
           && Objects.equals(pluginPath, o.pluginPath);
     }
     return super.equals(obj);
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java
index 5e297d4..5be0878 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
-
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/ProjectUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/ProjectUtil.java
index 0fba41e..bfd5ef9 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/ProjectUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/ProjectUtil.java
@@ -28,6 +28,5 @@
     return name;
   }
 
-  private ProjectUtil() {
-  }
+  private ProjectUtil() {}
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java
index edcd111..961f43a 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java
@@ -19,11 +19,9 @@
 import com.google.common.annotations.GwtIncompatible;
 import com.google.common.base.Preconditions;
 import com.google.gerrit.extensions.restapi.RawInput;
-
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
-
 import javax.servlet.http.HttpServletRequest;
 
 @GwtIncompatible("Unemulated classes in java.io and javax.servlet")
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
index bf87d7b..e8fa896 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
@@ -15,44 +15,48 @@
 package com.google.gerrit.common;
 
 import static com.google.gerrit.common.FileUtil.lastModified;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.GwtIncompatible;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Ordering;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @GwtIncompatible("Unemulated classes in java.nio and Guava")
 public final class SiteLibraryLoaderUtil {
-  private static final Logger log =
-      LoggerFactory.getLogger(SiteLibraryLoaderUtil.class);
+  private static final Logger log = LoggerFactory.getLogger(SiteLibraryLoaderUtil.class);
 
   public static void loadSiteLib(Path libdir) {
     try {
-      IoUtil.loadJARs(listJars(libdir));
+      List<Path> jars = listJars(libdir);
+      IoUtil.loadJARs(jars);
+      log.debug("Loaded site libraries: {}", jarList(jars));
     } catch (IOException e) {
       log.error("Error scanning lib directory " + libdir, e);
     }
   }
 
+  private static String jarList(List<Path> jars) {
+    return jars.stream().map(p -> p.getFileName().toString()).collect(joining(","));
+  }
+
   public static List<Path> listJars(Path dir) throws IOException {
-    DirectoryStream.Filter<Path> filter = new DirectoryStream.Filter<Path>() {
-      @Override
-      public boolean accept(Path entry) throws IOException {
-          String name = entry.getFileName().toString();
-          return (name.endsWith(".jar") || name.endsWith(".zip"))
-              && Files.isRegularFile(entry);
-      }
-    };
+    DirectoryStream.Filter<Path> filter =
+        new DirectoryStream.Filter<Path>() {
+          @Override
+          public boolean accept(Path entry) throws IOException {
+            String name = entry.getFileName().toString();
+            return (name.endsWith(".jar") || name.endsWith(".zip")) && Files.isRegularFile(entry);
+          }
+        };
     try (DirectoryStream<Path> jars = Files.newDirectoryStream(dir, filter)) {
       return new Ordering<Path>() {
         @Override
@@ -69,6 +73,5 @@
     }
   }
 
-  private SiteLibraryLoaderUtil() {
-  }
+  private SiteLibraryLoaderUtil() {}
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
index ec91a81..a8e40c6 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/TimeUtil.java
@@ -15,10 +15,8 @@
 package com.google.gerrit.common;
 
 import com.google.common.annotations.GwtIncompatible;
-
-import org.joda.time.DateTimeUtils;
-
 import java.sql.Timestamp;
+import org.joda.time.DateTimeUtils;
 
 /** Static utility methods for dealing with dates and times. */
 @GwtIncompatible("Unemulated org.joda.time.DateTimeUtils")
@@ -35,6 +33,5 @@
     return new Timestamp((t.getTime() / 1000) * 1000);
   }
 
-  private TimeUtil() {
-  }
+  private TimeUtil() {}
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/audit/Audit.java b/gerrit-common/src/main/java/com/google/gerrit/common/audit/Audit.java
index 90c7f75..25e4caf 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/audit/Audit.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/audit/Audit.java
@@ -22,8 +22,7 @@
 /**
  * Audit annotation for JSON/RPC interfaces.
  *
- * Flag with @Audit all the JSON/RPC methods to
- * be traced in audit-trail and submitted to the
+ * <p>Flag with @Audit all the JSON/RPC methods to be traced in audit-trail and submitted to the
  * AuditService.
  */
 @Retention(RetentionPolicy.RUNTIME)
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/auth/SignInRequired.java b/gerrit-common/src/main/java/com/google/gerrit/common/auth/SignInRequired.java
index 1b9011a..bcebf5c 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/auth/SignInRequired.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/auth/SignInRequired.java
@@ -21,12 +21,10 @@
 
 /**
  * Annotation indicating a service method requires a current user.
- * <p>
- * If there is no current user then
- * {@code com.google.gerrit.common.errors.NotSignedInException} will be given to
- * the callback's onFailure method.
+ *
+ * <p>If there is no current user then {@code com.google.gerrit.common.errors.NotSignedInException}
+ * will be given to the callback's onFailure method.
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Target(ElementType.METHOD)
-public @interface SignInRequired {
-}
+public @interface SignInRequired {}
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 e8d1a3b..4e14514 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
@@ -15,7 +15,6 @@
 package com.google.gerrit.common.data;
 
 import com.google.gerrit.reviewdb.client.Project;
-
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -23,15 +22,13 @@
 import java.util.Set;
 
 /** Portion of a {@link Project} describing access rules. */
-public class AccessSection extends RefConfigSection implements
-    Comparable<AccessSection> {
+public class AccessSection extends RefConfigSection implements Comparable<AccessSection> {
   /** Special name given to the global capabilities; not a valid reference. */
   public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
 
   protected List<Permission> permissions;
 
-  protected AccessSection() {
-  }
+  protected AccessSection() {}
 
   public AccessSection(String refPattern) {
     super(refPattern);
@@ -87,7 +84,7 @@
 
   public void removePermission(String name) {
     if (permissions != null) {
-      for (Iterator<Permission> itr = permissions.iterator(); itr.hasNext();) {
+      for (Iterator<Permission> itr = permissions.iterator(); itr.hasNext(); ) {
         if (name.equalsIgnoreCase(itr.next().getName())) {
           itr.remove();
         }
@@ -128,7 +125,7 @@
     if (!super.equals(obj) || !(obj instanceof AccessSection)) {
       return false;
     }
-    return new HashSet<>(getPermissions()).equals(new HashSet<>(
-        ((AccessSection) obj).getPermissions()));
+    return new HashSet<>(getPermissions())
+        .equals(new HashSet<>(((AccessSection) obj).getPermissions()));
   }
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java
index e8f9fd5..d6ddddb 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfo.java
@@ -23,15 +23,13 @@
   protected String preferredEmail;
   protected String username;
 
-  protected AccountInfo() {
-  }
+  protected AccountInfo() {}
 
   /**
    * Create an 'Anonymous Coward' account info, when only the id is known.
-   * <p>
-   * This constructor should only be a last-ditch effort, when the usual account
-   * lookup has failed and a stale account id has been discovered in the data
-   * store.
+   *
+   * <p>This constructor should only be a last-ditch effort, when the usual account lookup has
+   * failed and a stale account id has been discovered in the data store.
    */
   public AccountInfo(final Account.Id id) {
     this.id = id;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfoCache.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfoCache.java
deleted file mode 100644
index d7803c1..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountInfoCache.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.reviewdb.client.Account;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-
-/** In-memory table of {@link AccountInfo}, indexed by {@code Account.Id}. */
-public class AccountInfoCache {
-  private static final AccountInfoCache EMPTY;
-  static {
-    EMPTY = new AccountInfoCache();
-    EMPTY.accounts = Collections.emptyMap();
-  }
-
-  /** Obtain an empty cache singleton. */
-  public static AccountInfoCache empty() {
-    return EMPTY;
-  }
-
-  protected Map<Account.Id, AccountInfo> accounts;
-
-  protected AccountInfoCache() {
-  }
-
-  public AccountInfoCache(final Iterable<AccountInfo> list) {
-    accounts = new HashMap<>();
-    for (final AccountInfo ai : list) {
-      accounts.put(ai.getId(), ai);
-    }
-  }
-
-  /**
-   * Lookup the account summary
-   * <p>
-   * The return value can take on one of three forms:
-   * <ul>
-   * <li>{@code null}, if {@code id == null}.</li>
-   * <li>a valid info block, if {@code id} was loaded.</li>
-   * <li>an anonymous info block, if {@code id} was not loaded.</li>
-   * </ul>
-   *
-   * @param id the id desired.
-   * @return info block for the account.
-   */
-  public AccountInfo get(final Account.Id id) {
-    if (id == null) {
-      return null;
-    }
-
-    AccountInfo r = accounts.get(id);
-    if (r == null) {
-      r = new AccountInfo(id);
-      accounts.put(id, r);
-    }
-    return r;
-  }
-
-  /** Merge the information from another cache into this one. */
-  public void merge(final AccountInfoCache other) {
-    assert this != EMPTY;
-    accounts.putAll(other.accounts);
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountSecurity.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountSecurity.java
deleted file mode 100644
index 752f0d2..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountSecurity.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.audit.Audit;
-import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-import com.google.gwtjsonrpc.common.VoidResult;
-
-import java.util.List;
-import java.util.Set;
-
-@RpcImpl(version = Version.V2_0)
-public interface AccountSecurity extends RemoteJsonService {
-  @SignInRequired
-  void myExternalIds(AsyncCallback<List<AccountExternalId>> callback);
-
-  @Audit
-  @SignInRequired
-  void deleteExternalIds(Set<AccountExternalId.Key> keys,
-      AsyncCallback<Set<AccountExternalId.Key>> callback);
-
-  @Audit
-  @SignInRequired
-  void updateContact(String fullName, String emailAddr,
-      AsyncCallback<Account> callback);
-
-  @Audit
-  @SignInRequired
-  void enterAgreement(String agreementName,
-      AsyncCallback<VoidResult> callback);
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java
deleted file mode 100644
index 22482c7..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AccountService.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-
-@RpcImpl(version = Version.V2_0)
-public interface AccountService extends RemoteJsonService {
-  @SignInRequired
-  void myAgreements(AsyncCallback<AgreementInfo> callback);
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/AgreementInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/AgreementInfo.java
index 7464bd1..4fb4053 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/AgreementInfo.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/AgreementInfo.java
@@ -21,8 +21,7 @@
   public List<String> accepted;
   public Map<String, ContributorAgreement> agreements;
 
-  public AgreementInfo() {
-  }
+  public AgreementInfo() {}
 
   public void setAccepted(List<String> a) {
     accepted = a;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java
index 1b98b09..ed7c79b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/CommentDetail.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -24,14 +24,13 @@
 import java.util.Map;
 
 public class CommentDetail {
-  protected List<PatchLineComment> a;
-  protected List<PatchLineComment> b;
-  protected AccountInfoCache accounts;
+  protected List<Comment> a;
+  protected List<Comment> b;
 
   private transient PatchSet.Id idA;
   private transient PatchSet.Id idB;
-  private transient Map<Integer, List<PatchLineComment>> forA;
-  private transient Map<Integer, List<PatchLineComment>> forB;
+  private transient Map<Integer, List<Comment>> forA;
+  private transient Map<Integer, List<Comment>> forB;
 
   public CommentDetail(PatchSet.Id idA, PatchSet.Id idB) {
     this.a = new ArrayList<>();
@@ -40,47 +39,28 @@
     this.idB = idB;
   }
 
-  protected CommentDetail() {
-  }
+  protected CommentDetail() {}
 
-  public boolean include(final PatchLineComment p) {
-    final PatchSet.Id psId = p.getKey().getParentKey().getParentKey();
-    switch (p.getSide()) {
-      case 0:
-        if (idA == null && idB.equals(psId)) {
-          a.add(p);
-          return true;
-        }
-        break;
-
-      case 1:
-        if (idA != null && idA.equals(psId)) {
-          a.add(p);
-          return true;
-        }
-
-        if (idB.equals(psId)) {
-          b.add(p);
-          return true;
-        }
-        break;
+  public void include(Change.Id changeId, Comment p) {
+    PatchSet.Id psId = new PatchSet.Id(changeId, p.key.patchSetId);
+    if (p.side == 0) {
+      if (idA == null && idB.equals(psId)) {
+        a.add(p);
+      }
+    } else if (p.side == 1) {
+      if (idA != null && idA.equals(psId)) {
+        a.add(p);
+      } else if (idB.equals(psId)) {
+        b.add(p);
+      }
     }
-    return false;
   }
 
-  public void setAccountInfoCache(final AccountInfoCache a) {
-    accounts = a;
-  }
-
-  public AccountInfoCache getAccounts() {
-    return accounts;
-  }
-
-  public List<PatchLineComment> getCommentsA() {
+  public List<Comment> getCommentsA() {
     return a;
   }
 
-  public List<PatchLineComment> getCommentsB() {
+  public List<Comment> getCommentsB() {
     return b;
   }
 
@@ -88,49 +68,48 @@
     return a.isEmpty() && b.isEmpty();
   }
 
-  public List<PatchLineComment> getForA(final int lineNbr) {
+  public List<Comment> getForA(int lineNbr) {
     if (forA == null) {
       forA = index(a);
     }
     return get(forA, lineNbr);
   }
 
-  public List<PatchLineComment> getForB(final int lineNbr) {
+  public List<Comment> getForB(int lineNbr) {
     if (forB == null) {
       forB = index(b);
     }
     return get(forB, lineNbr);
   }
 
-  private static List<PatchLineComment> get(
-      final Map<Integer, List<PatchLineComment>> m, final int i) {
-    final List<PatchLineComment> r = m.get(i);
-    return r != null ? orderComments(r) : Collections.<PatchLineComment> emptyList();
+  private static List<Comment> get(Map<Integer, List<Comment>> m, int i) {
+    List<Comment> r = m.get(i);
+    return r != null ? orderComments(r) : Collections.<Comment>emptyList();
   }
 
   /**
-   * Order the comments based on their parent_uuid parent.  It is possible to do this by
-   * iterating over the list only once but it's probably overkill since the number of comments
-   * on a given line will be small most of the time.
+   * Order the comments based on their parent_uuid parent. It is possible to do this by iterating
+   * over the list only once but it's probably overkill since the number of comments on a given line
+   * will be small most of the time.
    *
    * @param comments The list of comments for a given line.
    * @return The comments sorted as they should appear in the UI
    */
-  private static List<PatchLineComment> orderComments(List<PatchLineComment> comments) {
+  private static List<Comment> orderComments(List<Comment> comments) {
     // Map of comments keyed by their parent. The values are lists of comments since it is
     // possible for several comments to have the same parent (this can happen if two reviewers
     // click Reply on the same comment at the same time). Such comments will be displayed under
     // their correct parent in chronological order.
-    Map<String, List<PatchLineComment>> parentMap = new HashMap<>();
+    Map<String, List<Comment>> parentMap = new HashMap<>();
 
     // It's possible to have more than one root comment if two reviewers create a comment on the
     // same line at the same time
-    List<PatchLineComment> rootComments = new ArrayList<>();
+    List<Comment> rootComments = new ArrayList<>();
 
     // Store all the comments in parentMap, keyed by their parent
-    for (PatchLineComment c : comments) {
-      String parentUuid = c.getParentUuid();
-      List<PatchLineComment> l = parentMap.get(parentUuid);
+    for (Comment c : comments) {
+      String parentUuid = c.parentUuid;
+      List<Comment> l = parentMap.get(parentUuid);
       if (l == null) {
         l = new ArrayList<>();
         parentMap.put(parentUuid, l);
@@ -143,33 +122,30 @@
 
     // Add the comments in the list, starting with the head and then going through all the
     // comments that have it as a parent, and so on
-    List<PatchLineComment> result = new ArrayList<>();
+    List<Comment> result = new ArrayList<>();
     addChildren(parentMap, rootComments, result);
 
     return result;
   }
 
-  /**
-   * Add the comments to {@code outResult}, depth first
-   */
-  private static void addChildren(Map<String, List<PatchLineComment>> parentMap,
-      List<PatchLineComment> children, List<PatchLineComment> outResult) {
+  /** Add the comments to {@code outResult}, depth first */
+  private static void addChildren(
+      Map<String, List<Comment>> parentMap, List<Comment> children, List<Comment> outResult) {
     if (children != null) {
-      for (PatchLineComment c : children) {
+      for (Comment c : children) {
         outResult.add(c);
-        addChildren(parentMap, parentMap.get(c.getKey().get()), outResult);
+        addChildren(parentMap, parentMap.get(c.key.uuid), outResult);
       }
     }
   }
 
-  private Map<Integer, List<PatchLineComment>> index(
-      List<PatchLineComment> in) {
-    HashMap<Integer, List<PatchLineComment>> r = new HashMap<>();
-    for (final PatchLineComment p : in) {
-      List<PatchLineComment> l = r.get(p.getLine());
+  private Map<Integer, List<Comment>> index(List<Comment> in) {
+    HashMap<Integer, List<Comment>> r = new HashMap<>();
+    for (Comment p : in) {
+      List<Comment> l = r.get(p.lineNbr);
       if (l == null) {
         l = new ArrayList<>();
-        r.put(p.getLine(), l);
+        r.put(p.lineNbr, l);
       }
       l.add(p);
     }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ContributorAgreement.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ContributorAgreement.java
index 17f640d..b43dbfa 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ContributorAgreement.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/ContributorAgreement.java
@@ -15,9 +15,7 @@
 package com.google.gerrit.common.data;
 
 import com.google.gerrit.reviewdb.client.Project;
-
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 /** Portion of a {@link Project} describing a single contributor agreement. */
@@ -28,8 +26,7 @@
   protected GroupReference autoVerify;
   protected String agreementUrl;
 
-  protected ContributorAgreement() {
-  }
+  protected ContributorAgreement() {}
 
   public ContributorAgreement(String name) {
     setName(name);
@@ -87,15 +84,4 @@
   public String toString() {
     return "ContributorAgreement[" + getName() + "]";
   }
-
-  public ContributorAgreement forUi() {
-    ContributorAgreement ca = new ContributorAgreement(name);
-    ca.description = description;
-    ca.accepted = Collections.emptyList();
-    if (autoVerify != null) {
-      ca.autoVerify = new GroupReference();
-    }
-    ca.agreementUrl = agreementUrl ;
-    return ca;
-  }
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java
new file mode 100644
index 0000000..9c34c97
--- /dev/null
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import com.google.gerrit.reviewdb.client.Patch;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.Set;
+
+public class FilenameComparator implements Comparator<String> {
+  public static final FilenameComparator INSTANCE = new FilenameComparator();
+
+  private static final Set<String> cppHeaderSuffixes =
+      new HashSet<>(Arrays.asList(".h", ".hxx", ".hpp"));
+
+  private FilenameComparator() {}
+
+  @Override
+  public int compare(final String path1, final String path2) {
+    if (Patch.COMMIT_MSG.equals(path1) && Patch.COMMIT_MSG.equals(path2)) {
+      return 0;
+    } else if (Patch.COMMIT_MSG.equals(path1)) {
+      return -1;
+    } else if (Patch.COMMIT_MSG.equals(path2)) {
+      return 1;
+    }
+    if (Patch.MERGE_LIST.equals(path1) && Patch.MERGE_LIST.equals(path2)) {
+      return 0;
+    } else if (Patch.MERGE_LIST.equals(path1)) {
+      return -1;
+    } else if (Patch.MERGE_LIST.equals(path2)) {
+      return 1;
+    }
+
+    int s1 = path1.lastIndexOf('.');
+    int s2 = path2.lastIndexOf('.');
+    if (s1 > 0 && s2 > 0 && path1.substring(0, s1).equals(path2.substring(0, s2))) {
+      String suffixA = path1.substring(s1);
+      String suffixB = path2.substring(s2);
+      // C++ and C: give priority to header files (.h/.hpp/...)
+      if (cppHeaderSuffixes.contains(suffixA)) {
+        return -1;
+      } else if (cppHeaderSuffixes.contains(suffixB)) {
+        return 1;
+      }
+    }
+    return path1.compareTo(path2);
+  }
+}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java
index 0156b7d..a6c534c 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GarbageCollectionResult.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.common.data;
 
 import com.google.gerrit.reviewdb.client.Project;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -53,8 +52,7 @@
     protected Type type;
     protected Project.NameKey projectName;
 
-    protected Error() {
-    }
+    protected Error() {}
 
     public Error(Type type, Project.NameKey projectName) {
       this.type = type;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java
index 0ec7701..9cc408b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GitwebType.java
@@ -24,6 +24,7 @@
   private String project;
   private String revision;
   private String rootTree;
+  private String tag;
 
   private char pathSeparator = '/';
   private boolean urlEncode = true;
@@ -56,6 +57,20 @@
     branch = str;
   }
 
+  /** @return parameterized string for the tag URL. */
+  public String getTag() {
+    return tag;
+  }
+
+  /**
+   * Set the parameterized string for the tag URL.
+   *
+   * @param str new string.
+   */
+  public void setTag(String str) {
+    tag = str;
+  }
+
   /** @return parameterized string for the file URL. */
   public String getFile() {
     return file;
@@ -157,10 +172,10 @@
   /**
    * Replace standard path separator with custom configured path separator.
    *
-   * @param urlSegment URL segment (e.g. branch or project name) in which to
-   *     replace the path separator.
-   * @return the segment with the standard path separator replaced by the custom
-   *   {@link #getPathSeparator()}.
+   * @param urlSegment URL segment (e.g. branch or project name) in which to replace the path
+   *     separator.
+   * @return the segment with the standard path separator replaced by the custom {@link
+   *     #getPathSeparator()}.
    */
   public String replacePathSeparator(String urlSegment) {
     if ('/' != pathSeparator) {
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
index 31c2481..4c9b64a 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -26,11 +26,10 @@
 
   /**
    * Denotes the server's administrators.
-   * <p>
-   * This is similar to UNIX root, or Windows SYSTEM account. Any user that
-   * has this capability can perform almost any other action, or can grant
-   * themselves the power to perform any other action on the site. Most of
-   * the other capabilities and permissions fall-back to the predicate
+   *
+   * <p>This is similar to UNIX root, or Windows SYSTEM account. Any user that has this capability
+   * can perform almost any other action, or can grant themselves the power to perform any other
+   * action on the site. Most of the other capabilities and permissions fall-back to the predicate
    * "OR user has capability ADMINISTRATE_SERVER".
    */
   public static final String ADMINISTRATE_SERVER = "administrateServer";
@@ -39,9 +38,8 @@
   public static final String BATCH_CHANGES_LIMIT = "batchChangesLimit";
 
   /**
-   * Default maximum number of changes that may be pushed in a batch, 0 means no
-   * limit. This is just used as a suggestion for prepopulating the field in the
-   * access UI.
+   * Default maximum number of changes that may be pushed in a batch, 0 means no limit. This is just
+   * used as a suggestion for prepopulating the field in the access UI.
    */
   public static final int DEFAULT_MAX_BATCH_CHANGES_LIMIT = 0;
 
@@ -56,12 +54,11 @@
 
   /**
    * Denotes who may email change reviewers and watchers.
-   * <p>
-   * This can be used to deny build bots from emailing reviewers and people who
-   * watch the change. Instead, only the authors of the change and those who
-   * starred it will be emailed. The allow rules are evaluated before deny
-   * rules, however the default is to allow emailing, if no explicit rule is
-   * matched.
+   *
+   * <p>This can be used to deny build bots from emailing reviewers and people who watch the change.
+   * Instead, only the authors of the change and those who starred it will be emailed. The allow
+   * rules are evaluated before deny rules, however the default is to allow emailing, if no explicit
+   * rule is matched.
    */
   public static final String EMAIL_REVIEWERS = "emailReviewers";
 
@@ -73,11 +70,10 @@
 
   /**
    * Can perform limited server maintenance.
-   * <p>
-   * Includes tasks such as reindexing changes and flushing caches that may need
-   * to be performed regularly. Does <strong>not</strong> grant arbitrary
-   * read/write/ACL management permissions as does {@link
-   * #ADMINISTRATE_SERVER}.
+   *
+   * <p>Includes tasks such as reindexing changes and flushing caches that may need to be performed
+   * regularly. Does <strong>not</strong> grant arbitrary read/write/ACL management permissions as
+   * does {@link #ADMINISTRATE_SERVER}.
    */
   public static final String MAINTAIN_SERVER = "maintainServer";
 
@@ -162,23 +158,18 @@
 
   /** @return true if the capability should have a range attached. */
   public static boolean hasRange(String varName) {
-    return QUERY_LIMIT.equalsIgnoreCase(varName)
-        || BATCH_CHANGES_LIMIT.equalsIgnoreCase(varName);
+    return QUERY_LIMIT.equalsIgnoreCase(varName) || BATCH_CHANGES_LIMIT.equalsIgnoreCase(varName);
   }
 
   /** @return the valid range for the capability if it has one, otherwise null. */
   public static PermissionRange.WithDefaults getRange(String varName) {
     if (QUERY_LIMIT.equalsIgnoreCase(varName)) {
       return new PermissionRange.WithDefaults(
-          varName,
-          0, Integer.MAX_VALUE,
-          0, DEFAULT_MAX_QUERY_LIMIT);
+          varName, 0, Integer.MAX_VALUE, 0, DEFAULT_MAX_QUERY_LIMIT);
     }
     if (BATCH_CHANGES_LIMIT.equalsIgnoreCase(varName)) {
       return new PermissionRange.WithDefaults(
-          varName,
-          0, Integer.MAX_VALUE,
-          0, DEFAULT_MAX_BATCH_CHANGES_LIMIT);
+          varName, 0, Integer.MAX_VALUE, 0, DEFAULT_MAX_BATCH_CHANGES_LIMIT);
     }
     return null;
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
index 86b0b39..62a8544 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
@@ -17,13 +17,9 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
-/**
- * Group methods exposed by the GroupBackend.
- */
+/** Group methods exposed by the GroupBackend. */
 public class GroupDescription {
-  /**
-   * The Basic information required to be exposed by any Group.
-   */
+  /** The Basic information required to be exposed by any Group. */
   public interface Basic {
     /** @return the non-null UUID of the group. */
     AccountGroup.UUID getGroupUUID();
@@ -32,31 +28,25 @@
     String getName();
 
     /**
-     * @return optional email address to send to the group's members. If
-     *         provided, Gerrit will use this email address to send
-     *         change notifications to the group.
+     * @return optional email address to send to the group's members. If provided, Gerrit will use
+     *     this email address to send change notifications to the group.
      */
     @Nullable
     String getEmailAddress();
 
     /**
-     * @return optional URL to information about the group. Typically a URL to a
-     *         web page that permits users to apply to join the group, or manage
-     *         their membership.
+     * @return optional URL to information about the group. Typically a URL to a web page that
+     *     permits users to apply to join the group, or manage their membership.
      */
     @Nullable
     String getUrl();
   }
 
-  /**
-   * The extended information exposed by internal groups backed by an
-   * AccountGroup.
-   */
+  /** The extended information exposed by internal groups backed by an AccountGroup. */
   public interface Internal extends Basic {
     /** @return the backing AccountGroup. */
     AccountGroup getAccountGroup();
   }
 
-  private GroupDescription() {
-  }
+  private GroupDescription() {}
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
index 63b4a04..b8e498f 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
@@ -18,9 +18,7 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
-/**
- * Utility class for building GroupDescription objects.
- */
+/** Utility class for building GroupDescription objects. */
 public class GroupDescriptions {
 
   @Nullable
@@ -62,6 +60,5 @@
     };
   }
 
-  private GroupDescriptions() {
-  }
+  private GroupDescriptions() {}
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDetail.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDetail.java
index c890812..cf4cfcd 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDetail.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDetail.java
@@ -17,23 +17,14 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupById;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
-
 import java.util.List;
 
 public class GroupDetail {
-  public AccountInfoCache accounts;
   public AccountGroup group;
   public List<AccountGroupMember> members;
   public List<AccountGroupById> includes;
-  public GroupReference ownerGroup;
-  public boolean canModify;
 
-  public GroupDetail() {
-  }
-
-  public void setAccounts(AccountInfoCache c) {
-    accounts = c;
-  }
+  public GroupDetail() {}
 
   public void setGroup(AccountGroup g) {
     group = g;
@@ -46,12 +37,4 @@
   public void setIncludes(List<AccountGroupById> i) {
     includes = i;
   }
-
-  public void setOwnerGroup(GroupReference g) {
-    ownerGroup = g;
-  }
-
-  public void setCanModify(final boolean canModify) {
-    this.canModify = canModify;
-  }
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
index 1acdd9a..1f746c4 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
@@ -23,15 +23,13 @@
   protected String description;
   protected String url;
 
-  protected GroupInfo() {
-  }
+  protected GroupInfo() {}
 
   /**
    * Create an anonymous group info, when only the id is known.
-   * <p>
-   * This constructor should only be a last-ditch effort, when the usual group
-   * lookup has failed and a stale group id has been discovered in the data
-   * store.
+   *
+   * <p>This constructor should only be a last-ditch effort, when the usual group lookup has failed
+   * and a stale group id has been discovered in the data store.
    */
   public GroupInfo(final AccountGroup.UUID uuid) {
     this.uuid = uuid;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupReference.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupReference.java
index 3362ba2..dc22d62 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupReference.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupReference.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
 /** Describes a group within a projects {@link AccessSection}s. */
 public class GroupReference implements Comparable<GroupReference> {
+
+  private static final String PREFIX = "group ";
+
   /** @return a new reference to the given group description. */
   public static GroupReference forGroup(AccountGroup group) {
     return new GroupReference(group.getGroupUUID(), group.getName());
@@ -27,19 +31,22 @@
     return new GroupReference(group.getGroupUUID(), group.getName());
   }
 
-  public static GroupReference fromString(String ref) {
-    String name =
-        ref.substring(ref.indexOf("[") + 1, ref.lastIndexOf("/")).trim();
-    String uuid =
-        ref.substring(ref.lastIndexOf("/") + 1, ref.lastIndexOf("]")).trim();
-    return new GroupReference(new AccountGroup.UUID(uuid), name);
+  public static boolean isGroupReference(String configValue) {
+    return configValue != null && configValue.startsWith(PREFIX);
+  }
+
+  @Nullable
+  public static String extractGroupName(String configValue) {
+    if (!isGroupReference(configValue)) {
+      return null;
+    }
+    return configValue.substring(PREFIX.length()).trim();
   }
 
   protected String uuid;
   protected String name;
 
-  protected GroupReference() {
-  }
+  protected GroupReference() {}
 
   public GroupReference(AccountGroup.UUID uuid, String name) {
     setUUID(uuid);
@@ -81,6 +88,10 @@
     return o instanceof GroupReference && compareTo((GroupReference) o) == 0;
   }
 
+  public String toConfigValue() {
+    return PREFIX + name;
+  }
+
   @Override
   public String toString() {
     return "Group[" + getName() + " / " + getUUID() + "]";
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java
index 04dcec4..517c520 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/HostPageData.java
@@ -15,21 +15,20 @@
 package com.google.gerrit.common.data;
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-
 import java.util.Date;
 import java.util.List;
 
 /** Data sent as part of the host page, to bootstrap the UI. */
 public class HostPageData {
   /**
-   * Name of the cookie in which the XSRF token is sent from the server to the
-   * client during host page bootstrapping.
+   * Name of the cookie in which the XSRF token is sent from the server to the client during host
+   * page bootstrapping.
    */
   public static final String XSRF_COOKIE_NAME = "XSRF_TOKEN";
 
   /**
-   * Name of the HTTP header in which the client must send the XSRF token to the
-   * server on each request.
+   * Name of the HTTP header in which the client must send the XSRF token to the server on each
+   * request.
    */
   public static final String XSRF_HEADER_NAME = "X-Gerrit-Auth";
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
index b1e1243..6d427e7 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelType.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -25,6 +24,7 @@
 import java.util.Map;
 
 public class LabelType {
+  public static final boolean DEF_ALLOW_POST_SUBMIT = true;
   public static final boolean DEF_CAN_OVERRIDE = true;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
@@ -44,8 +44,7 @@
   public static String checkName(String name) {
     checkNameInternal(name);
     if ("SUBM".equals(name)) {
-      throw new IllegalArgumentException(
-          "Reserved label name \"" + name + "\"");
+      throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
     }
     return name;
   }
@@ -56,13 +55,12 @@
     }
     for (int i = 0; i < name.length(); i++) {
       char c = name.charAt(i);
-      if ((i == 0 && c == '-') ||
-          !((c >= 'a' && c <= 'z') ||
-            (c >= 'A' && c <= 'Z') ||
-            (c >= '0' && c <= '9') ||
-            c == '-')) {
-        throw new IllegalArgumentException(
-            "Illegal label name \"" + name + "\"");
+      if ((i == 0 && c == '-')
+          || !((c >= 'a' && c <= 'z')
+              || (c >= 'A' && c <= 'Z')
+              || (c >= '0' && c <= '9')
+              || c == '-')) {
+        throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
       }
     }
     return name;
@@ -73,12 +71,14 @@
     if (values.size() <= 1) {
       return Collections.unmodifiableList(values);
     }
-    Collections.sort(values, new Comparator<LabelValue>() {
-      @Override
-      public int compare(LabelValue o1, LabelValue o2) {
-        return o1.getValue() - o2.getValue();
-      }
-    });
+    Collections.sort(
+        values,
+        new Comparator<LabelValue>() {
+          @Override
+          public int compare(LabelValue o1, LabelValue o2) {
+            return o1.getValue() - o2.getValue();
+          }
+        });
     short min = values.get(0).getValue();
     short max = values.get(values.size() - 1).getValue();
     short v = min;
@@ -104,6 +104,7 @@
   protected boolean copyAllScoresOnTrivialRebase;
   protected boolean copyAllScoresIfNoCodeChange;
   protected boolean copyAllScoresIfNoChange;
+  protected boolean allowPostSubmit;
   protected short defaultValue;
 
   protected List<LabelValue> values;
@@ -115,8 +116,7 @@
   private transient List<Integer> intList;
   private transient Map<Short, LabelValue> byValue;
 
-  protected LabelType() {
-  }
+  protected LabelType() {}
 
   public LabelType(String name, List<LabelValue> valueList) {
     this.name = checkName(name);
@@ -140,10 +140,10 @@
     setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
     setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
     setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-    setCopyAllScoresOnMergeFirstParentUpdate(
-        DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
     setCopyMaxScore(DEF_COPY_MAX_SCORE);
     setCopyMinScore(DEF_COPY_MIN_SCORE);
+    setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
   }
 
   public String getName() {
@@ -174,6 +174,14 @@
     this.canOverride = canOverride;
   }
 
+  public boolean allowPostSubmit() {
+    return allowPostSubmit;
+  }
+
+  public void setAllowPostSubmit(boolean allowPostSubmit) {
+    this.allowPostSubmit = allowPostSubmit;
+  }
+
   public void setRefPatterns(List<String> refPatterns) {
     this.refPatterns = refPatterns;
   }
@@ -193,8 +201,7 @@
     if (values.isEmpty()) {
       return null;
     }
-    final LabelValue v = values.get(values.size() - 1);
-    return v.getValue() > 0 ? v : null;
+    return values.get(values.size() - 1);
   }
 
   public short getDefaultValue() {
@@ -227,8 +234,7 @@
 
   public void setCopyAllScoresOnMergeFirstParentUpdate(
       boolean copyAllScoresOnMergeFirstParentUpdate) {
-    this.copyAllScoresOnMergeFirstParentUpdate =
-        copyAllScoresOnMergeFirstParentUpdate;
+    this.copyAllScoresOnMergeFirstParentUpdate = copyAllScoresOnMergeFirstParentUpdate;
   }
 
   public boolean isCopyAllScoresOnTrivialRebase() {
@@ -304,8 +310,10 @@
     LabelValue min = getMin();
     LabelValue max = getMax();
     if (min != null && max != null) {
-      sb.append(new PermissionRange(Permission.forLabel(name), min.getValue(),
-          max.getValue()).toString().trim());
+      sb.append(
+          new PermissionRange(Permission.forLabel(name), min.getValue(), max.getValue())
+              .toString()
+              .trim());
     } else if (min != null) {
       sb.append(min.formatValue().trim());
     } else if (max != null) {
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java
index 66f6a8e..e76db30 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.common.data;
 
 import com.google.gerrit.reviewdb.client.LabelId;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -28,12 +27,10 @@
   private transient volatile Map<String, LabelType> byLabel;
   private transient volatile Map<String, Integer> positions;
 
-  protected LabelTypes() {
-  }
+  protected LabelTypes() {}
 
   public LabelTypes(final List<? extends LabelType> approvals) {
-    labelTypes =
-        Collections.unmodifiableList(new ArrayList<>(approvals));
+    labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
   }
 
   public List<LabelType> getLabelTypes() {
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelValue.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelValue.java
index cd29e05..811e751 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelValue.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelValue.java
@@ -33,8 +33,7 @@
     this.text = text;
   }
 
-  protected LabelValue() {
-  }
+  protected LabelValue() {}
 
   public short getValue() {
     return value;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java
index a92af2b..93b7f90 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java
@@ -40,7 +40,7 @@
   private ParameterizedString(final Constant c) {
     pattern = c.text;
     rawPattern = c.text;
-    patternOps = Collections.<Format> singletonList(c);
+    patternOps = Collections.<Format>singletonList(c);
     parameters = Collections.emptyList();
   }
 
@@ -208,25 +208,31 @@
 
   private static Map<String, Function> initFunctions() {
     HashMap<String, Function> m = new HashMap<>();
-    m.put("toLowerCase", new Function() {
-      @Override
-      String apply(String a) {
-        return a.toLowerCase();
-      }
-    });
-    m.put("toUpperCase", new Function() {
-      @Override
-      String apply(String a) {
-        return a.toUpperCase();
-      }
-    });
-    m.put("localPart", new Function() {
-      @Override
-      String apply(String a) {
-        int at = a.indexOf('@');
-        return at < 0 ? a : a.substring(0, at);
-      }
-    });
+    m.put(
+        "toLowerCase",
+        new Function() {
+          @Override
+          String apply(String a) {
+            return a.toLowerCase();
+          }
+        });
+    m.put(
+        "toUpperCase",
+        new Function() {
+          @Override
+          String apply(String a) {
+            return a.toUpperCase();
+          }
+        });
+    m.put(
+        "localPart",
+        new Function() {
+          @Override
+          String apply(String a) {
+            int at = a.indexOf('@');
+            return at < 0 ? a : a.substring(0, at);
+          }
+        });
     return Collections.unmodifiableMap(m);
   }
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
index 5777396..172be09 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
@@ -21,18 +21,20 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-
-import org.eclipse.jgit.diff.Edit;
-
 import java.util.List;
+import org.eclipse.jgit.diff.Edit;
 
 public class PatchScript {
   public enum DisplayMethod {
-    NONE, DIFF, IMG
+    NONE,
+    DIFF,
+    IMG
   }
 
   public enum FileMode {
-    FILE, SYMLINK, GITLINK
+    FILE,
+    SYMLINK,
+    GITLINK
   }
 
   private Change.Key changeId;
@@ -60,15 +62,31 @@
   private transient String commitIdA;
   private transient String commitIdB;
 
-  public PatchScript(final Change.Key ck, final ChangeType ct, final String on,
-      final String nn, final FileMode om, final FileMode nm,
-      final List<String> h, final DiffPreferencesInfo dp,
-      final SparseFileContent ca, final SparseFileContent cb,
-      final List<Edit> e, final DisplayMethod ma, final DisplayMethod mb,
-      final String mta, final String mtb, final CommentDetail cd,
-      final List<Patch> hist, final boolean hf, final boolean id,
-      final boolean idf, final boolean idt, boolean bin,
-      final String cma, final String cmb) {
+  public PatchScript(
+      final Change.Key ck,
+      final ChangeType ct,
+      final String on,
+      final String nn,
+      final FileMode om,
+      final FileMode nm,
+      final List<String> h,
+      final DiffPreferencesInfo dp,
+      final SparseFileContent ca,
+      final SparseFileContent cb,
+      final List<Edit> e,
+      final DisplayMethod ma,
+      final DisplayMethod mb,
+      final String mta,
+      final String mtb,
+      final CommentDetail cd,
+      final List<Patch> hist,
+      final boolean hf,
+      final boolean id,
+      final boolean idf,
+      final boolean idt,
+      boolean bin,
+      final String cma,
+      final String cmb) {
     changeId = ck;
     changeType = ct;
     oldName = on;
@@ -95,8 +113,7 @@
     commitIdB = cmb;
   }
 
-  protected PatchScript() {
-  }
+  protected PatchScript() {}
 
   public Change.Key getChangeId() {
     return changeId;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
index 97f11b4..30bd089 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
@@ -24,8 +24,14 @@
   public static final String ABANDON = "abandon";
   public static final String ADD_PATCH_SET = "addPatchSet";
   public static final String CREATE = "create";
+  public static final String DELETE = "delete";
+  public static final String CREATE_TAG = "createTag";
+  public static final String CREATE_SIGNED_TAG = "createSignedTag";
+  public static final String DELETE_CHANGES = "deleteChanges";
   public static final String DELETE_DRAFTS = "deleteDrafts";
+  public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
   public static final String EDIT_HASHTAGS = "editHashtags";
+  public static final String EDIT_ASSIGNEE = "editAssignee";
   public static final String EDIT_TOPIC_NAME = "editTopicName";
   public static final String FORGE_AUTHOR = "forgeAuthor";
   public static final String FORGE_COMMITTER = "forgeCommitter";
@@ -36,8 +42,6 @@
   public static final String PUBLISH_DRAFTS = "publishDrafts";
   public static final String PUSH = "push";
   public static final String PUSH_MERGE = "pushMerge";
-  public static final String PUSH_TAG = "pushTag";
-  public static final String PUSH_SIGNED_TAG = "pushSignedTag";
   public static final String READ = "read";
   public static final String REBASE = "rebase";
   public static final String REMOVE_REVIEWER = "removeReviewer";
@@ -46,8 +50,8 @@
   public static final String VIEW_DRAFTS = "viewDrafts";
 
   private static final List<String> NAMES_LC;
-  private static final int labelIndex;
-  private static final int labelAsIndex;
+  private static final int LABEL_INDEX;
+  private static final int LABEL_AS_INDEX;
 
   static {
     NAMES_LC = new ArrayList<>();
@@ -56,13 +60,14 @@
     NAMES_LC.add(ABANDON.toLowerCase());
     NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
     NAMES_LC.add(CREATE.toLowerCase());
+    NAMES_LC.add(CREATE_TAG.toLowerCase());
+    NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
+    NAMES_LC.add(DELETE.toLowerCase());
     NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
     NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
     NAMES_LC.add(FORGE_SERVER.toLowerCase());
     NAMES_LC.add(PUSH.toLowerCase());
     NAMES_LC.add(PUSH_MERGE.toLowerCase());
-    NAMES_LC.add(PUSH_TAG.toLowerCase());
-    NAMES_LC.add(PUSH_SIGNED_TAG.toLowerCase());
     NAMES_LC.add(LABEL.toLowerCase());
     NAMES_LC.add(LABEL_AS.toLowerCase());
     NAMES_LC.add(REBASE.toLowerCase());
@@ -72,18 +77,19 @@
     NAMES_LC.add(VIEW_DRAFTS.toLowerCase());
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
+    NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
     NAMES_LC.add(DELETE_DRAFTS.toLowerCase());
+    NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
+    NAMES_LC.add(DELETE_CHANGES.toLowerCase());
     NAMES_LC.add(PUBLISH_DRAFTS.toLowerCase());
 
-    labelIndex = NAMES_LC.indexOf(Permission.LABEL);
-    labelAsIndex = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+    LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
+    LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
   }
 
   /** @return true if the name is recognized as a permission name. */
   public static boolean isPermission(String varName) {
-    return isLabel(varName)
-        || isLabelAs(varName)
-        || NAMES_LC.contains(varName.toLowerCase());
+    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
   }
 
   public static boolean hasRange(String varName) {
@@ -130,8 +136,7 @@
   protected boolean exclusiveGroup;
   protected List<PermissionRule> rules;
 
-  protected Permission() {
-  }
+  protected Permission() {}
 
   public Permission(String name) {
     this.name = name;
@@ -178,7 +183,7 @@
 
   public void removeRule(GroupReference group) {
     if (rules != null) {
-      for (Iterator<PermissionRule> itr = rules.iterator(); itr.hasNext();) {
+      for (Iterator<PermissionRule> itr = rules.iterator(); itr.hasNext(); ) {
         if (sameGroup(itr.next(), group)) {
           itr.remove();
         }
@@ -247,9 +252,9 @@
 
   private static int index(Permission a) {
     if (isLabel(a.getName())) {
-      return labelIndex;
+      return LABEL_INDEX;
     } else if (isLabelAs(a.getName())) {
-      return labelAsIndex;
+      return LABEL_AS_INDEX;
     }
 
     int index = NAMES_LC.indexOf(a.getName().toLowerCase());
@@ -277,8 +282,7 @@
   @Override
   public String toString() {
     StringBuilder bldr = new StringBuilder();
-    bldr.append(name)
-        .append(" ");
+    bldr.append(name).append(" ");
     if (exclusiveGroup) {
       bldr.append("[exclusive] ");
     }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRange.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRange.java
index 8d09b88..8876c02 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRange.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRange.java
@@ -22,8 +22,7 @@
     protected int defaultMin;
     protected int defaultMax;
 
-    protected WithDefaults() {
-    }
+    protected WithDefaults() {}
 
     public WithDefaults(String name, int min, int max, int defMin, int defMax) {
       super(name, min, max);
@@ -62,8 +61,7 @@
   protected int min;
   protected int max;
 
-  protected PermissionRange() {
-  }
+  protected PermissionRange() {}
 
   public PermissionRange(String name, int min, int max) {
     this.name = name;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java
index 6511d69..9098ec3 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/PermissionRule.java
@@ -17,10 +17,14 @@
 public class PermissionRule implements Comparable<PermissionRule> {
   public static final String FORCE_PUSH = "Force Push";
   public static final String FORCE_EDIT = "Force Edit";
-  public enum Action {
-    ALLOW, DENY, BLOCK,
 
-    INTERACTIVE, BATCH
+  public enum Action {
+    ALLOW,
+    DENY,
+    BLOCK,
+
+    INTERACTIVE,
+    BATCH
   }
 
   protected Action action = Action.ALLOW;
@@ -29,8 +33,7 @@
   protected int max;
   protected GroupReference group;
 
-  public PermissionRule() {
-  }
+  public PermissionRule() {}
 
   public PermissionRule(GroupReference group) {
     this.group = group;
@@ -115,7 +118,6 @@
 
       } else if (getAction() == Action.BATCH || src.getAction() == Action.BATCH) {
         setAction(Action.BATCH);
-
       }
     }
 
@@ -202,8 +204,7 @@
       r.append(' ');
     }
 
-    r.append("group ");
-    r.append(getGroup().getName());
+    r.append(getGroup().toConfigValue());
 
     return r.toString();
   }
@@ -236,7 +237,7 @@
       src = src.substring("+force ".length()).trim();
     }
 
-    if (mightUseRange && !src.startsWith("group ")) {
+    if (mightUseRange && !GroupReference.isGroupReference(src)) {
       int sp = src.indexOf(' ');
       String range = src.substring(0, sp);
 
@@ -252,10 +253,10 @@
       src = src.substring(sp + 1).trim();
     }
 
-    if (src.startsWith("group ")) {
-      src = src.substring(6).trim();
+    String groupName = GroupReference.extractGroupName(src);
+    if (groupName != null) {
       GroupReference group = new GroupReference();
-      group.setName(src);
+      group.setName(groupName);
       rule.setGroup(group);
     } else {
       throw new IllegalArgumentException("Rule must include group: " + orig);
@@ -265,8 +266,7 @@
   }
 
   public boolean hasRange() {
-    return (!(getMin() == null || getMin() == 0))
-      || (!(getMax() == null || getMax() == 0));
+    return (!(getMin() == null || getMin() == 0)) || (!(getMax() == null || getMax() == 0));
   }
 
   public static int parseInt(String value) {
@@ -281,9 +281,12 @@
     if (!(obj instanceof PermissionRule)) {
       return false;
     }
-    final PermissionRule other = (PermissionRule)obj;
-    return action.equals(other.action) && force == other.force
-        && min == other.min && max == other.max && group.equals(other.group);
+    final PermissionRule other = (PermissionRule) obj;
+    return action.equals(other.action)
+        && force == other.force
+        && min == other.min
+        && max == other.max
+        && group.equals(other.group);
   }
 
   @Override
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java
index a83f46c..ea17525 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAccess.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -34,8 +33,7 @@
   protected Map<AccountGroup.UUID, GroupInfo> groupInfo;
   protected List<WebLinkInfoCommon> fileHistoryLinks;
 
-  public ProjectAccess() {
-  }
+  public ProjectAccess() {}
 
   public Project.NameKey getProjectName() {
     return projectName;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAdminService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAdminService.java
index 652acac..e9a7c15 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAdminService.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/ProjectAdminService.java
@@ -22,22 +22,28 @@
 import com.google.gwtjsonrpc.common.RemoteJsonService;
 import com.google.gwtjsonrpc.common.RpcImpl;
 import com.google.gwtjsonrpc.common.RpcImpl.Version;
-
 import java.util.List;
 
 @RpcImpl(version = Version.V2_0)
 public interface ProjectAdminService extends RemoteJsonService {
-  void projectAccess(Project.NameKey projectName,
-      AsyncCallback<ProjectAccess> callback);
+  void projectAccess(Project.NameKey projectName, AsyncCallback<ProjectAccess> callback);
 
   @Audit
   @SignInRequired
-  void changeProjectAccess(Project.NameKey projectName, String baseRevision,
-      String message, List<AccessSection> sections, Project.NameKey parentProjectName,
+  void changeProjectAccess(
+      Project.NameKey projectName,
+      String baseRevision,
+      String message,
+      List<AccessSection> sections,
+      Project.NameKey parentProjectName,
       AsyncCallback<ProjectAccess> callback);
 
   @SignInRequired
-  void reviewProjectAccess(Project.NameKey projectName, String baseRevision,
-      String message, List<AccessSection> sections, Project.NameKey parentProjectName,
+  void reviewProjectAccess(
+      Project.NameKey projectName,
+      String baseRevision,
+      String message,
+      List<AccessSection> sections,
+      Project.NameKey parentProjectName,
       AsyncCallback<Change.Id> callback);
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java
index f740464..f8aa6a0 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java
@@ -31,8 +31,7 @@
 
   protected String name;
 
-  public RefConfigSection() {
-  }
+  public RefConfigSection() {}
 
   public RefConfigSection(String name) {
     setName(name);
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java
index 1d4e3c9..bac9294 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java
@@ -20,8 +20,7 @@
   protected String hostKey;
   protected String fingerprint;
 
-  protected SshHostKey() {
-  }
+  protected SshHostKey() {}
 
   public SshHostKey(final String hi, final String hk, final String fp) {
     hostIdent = hi;
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
index 9dccf0c..9151222 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitRecord.java
@@ -15,15 +15,24 @@
 package com.google.gerrit.common.data;
 
 import com.google.gerrit.reviewdb.client.Account;
-
+import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 
-/**
- * Describes the state required to submit a change.
- */
+/** Describes the state required to submit a change. */
 public class SubmitRecord {
+  public static Optional<SubmitRecord> findOkRecord(Collection<SubmitRecord> in) {
+    if (in == null) {
+      return Optional.empty();
+    }
+    return in.stream().filter(r -> r.status == Status.OK).findFirst();
+  }
+
   public enum Status {
+    // NOTE: These values are persisted in the index, so deleting or changing
+    // the name of any values requires a schema upgrade.
+
     /** The change is ready for submission. */
     OK,
 
@@ -38,8 +47,8 @@
 
     /**
      * An internal server error occurred preventing computation.
-     * <p>
-     * Additional detail may be available in {@link SubmitRecord#errorMessage}.
+     *
+     * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
      */
     RULE_ERROR
   }
@@ -50,37 +59,37 @@
 
   public static class Label {
     public enum Status {
+      // NOTE: These values are persisted in the index, so deleting or changing
+      // the name of any values requires a schema upgrade.
+
       /**
        * This label provides what is necessary for submission.
-       * <p>
-       * If provided, {@link Label#appliedBy} describes the user account
-       * that applied this label to the change.
+       *
+       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
+       * to the change.
        */
       OK,
 
       /**
        * This label prevents the change from being submitted.
-       * <p>
-       * If provided, {@link Label#appliedBy} describes the user account
-       * that applied this label to the change.
+       *
+       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
+       * to the change.
        */
       REJECT,
 
-      /**
-       * The label is required for submission, but has not been satisfied.
-       */
+      /** The label is required for submission, but has not been satisfied. */
       NEED,
 
       /**
-       * The label may be set, but it's neither necessary for submission
-       * nor does it block submission if set.
+       * The label may be set, but it's neither necessary for submission nor does it block
+       * submission if set.
        */
       MAY,
 
       /**
-       * The label is required for submission, but is impossible to complete.
-       * The likely cause is access has not been granted correctly by the
-       * project owner or site administrator.
+       * The label is required for submission, but is impossible to complete. The likely cause is
+       * access has not been granted correctly by the project owner or site administrator.
        */
       IMPOSSIBLE
     }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java
index b6ce797..a01d83d 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SubmitTypeRecord.java
@@ -16,17 +16,16 @@
 
 import com.google.gerrit.extensions.client.SubmitType;
 
-/**
- * Describes the submit type for a change.
- */
+/** Describes the submit type for a change. */
 public class SubmitTypeRecord {
   public enum Status {
     /** The type was computed successfully */
     OK,
 
-    /** An internal server error occurred preventing computation.
-     * <p>
-     * Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
+    /**
+     * An internal server error occurred preventing computation.
+     *
+     * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
      */
     RULE_ERROR
   }
@@ -45,9 +44,7 @@
   /** Submit type of the record; never null if {@link #status} is {@code OK}. */
   public final SubmitType type;
 
-  /**
-   * Submit type of the record; always null if {@link #status} is {@code OK}.
-   */
+  /** Submit type of the record; always null if {@link #status} is {@code OK}. */
   public final String errorMessage;
 
   private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
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 3fdc331..a3468d7 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
@@ -17,13 +17,11 @@
 import com.google.common.annotations.GwtIncompatible;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-
-import org.eclipse.jgit.transport.RefSpec;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import org.eclipse.jgit.transport.RefSpec;
 
 /** Portion of a {@link Project} describing superproject subscription rules. */
 @GwtIncompatible("Unemulated org.eclipse.jgit.transport.RefSpec")
@@ -58,8 +56,8 @@
   }
 
   /**
-   * Determines if the <code>branch</code> could trigger a
-   * superproject update as allowed via this subscribe section.
+   * Determines if the <code>branch</code> could trigger a superproject update as allowed via this
+   * subscribe section.
    *
    * @param branch the branch to check
    * @return if the branch could trigger a superproject update
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java
index 272801f..d88b638 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SystemInfoService.java
@@ -14,14 +14,12 @@
 
 package com.google.gerrit.common.data;
 
-import com.google.gerrit.common.auth.SignInRequired;
 import com.google.gwtjsonrpc.common.AllowCrossSiteRequest;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.RemoteJsonService;
 import com.google.gwtjsonrpc.common.RpcImpl;
 import com.google.gwtjsonrpc.common.RpcImpl.Version;
 import com.google.gwtjsonrpc.common.VoidResult;
-
 import java.util.List;
 
 @RpcImpl(version = Version.V2_0)
@@ -29,8 +27,5 @@
   @AllowCrossSiteRequest
   void daemonHostKeys(AsyncCallback<List<SshHostKey>> callback);
 
-  @SignInRequired
-  void contributorAgreements(AsyncCallback<List<ContributorAgreement>> callback);
-
   void clientError(String message, AsyncCallback<VoidResult> callback);
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
index 320d055..ec8a811 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
@@ -20,8 +20,7 @@
 
   public static final String MESSAGE = "Update Parent Project Failed: ";
 
-  public UpdateParentFailedException(final String message,
-      final Throwable why) {
+  public UpdateParentFailedException(final String message, final Throwable why) {
     super(MESSAGE + ": " + message, why);
   }
 }
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/AutoValueTest.java b/gerrit-common/src/test/java/com/google/gerrit/common/AutoValueTest.java
index 5febd80..947fe4a 100644
--- a/gerrit-common/src/test/java/com/google/gerrit/common/AutoValueTest.java
+++ b/gerrit-common/src/test/java/com/google/gerrit/common/AutoValueTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.auto.value.AutoValue;
-
 import org.junit.Test;
 
 public class AutoValueTest {
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/data/EncodePathSeparatorTest.java b/gerrit-common/src/test/java/com/google/gerrit/common/data/EncodePathSeparatorTest.java
index ea3721e..4c4c769 100644
--- a/gerrit-common/src/test/java/com/google/gerrit/common/data/EncodePathSeparatorTest.java
+++ b/gerrit-common/src/test/java/com/google/gerrit/common/data/EncodePathSeparatorTest.java
@@ -20,15 +20,14 @@
 
 public class EncodePathSeparatorTest {
   @Test
-  public void testDefaultBehaviour() {
+  public void defaultBehaviour() {
     assertEquals("a/b", new GitwebType().replacePathSeparator("a/b"));
   }
 
   @Test
-  public void testExclamationMark() {
+  public void exclamationMark() {
     GitwebType gitwebType = new GitwebType();
     gitwebType.setPathSeparator('!');
     assertEquals("a!b", gitwebType.replacePathSeparator("a/b"));
   }
-
 }
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/data/FilenameComparatorTest.java b/gerrit-common/src/test/java/com/google/gerrit/common/data/FilenameComparatorTest.java
new file mode 100644
index 0000000..ec71e05
--- /dev/null
+++ b/gerrit-common/src/test/java/com/google/gerrit/common/data/FilenameComparatorTest.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class FilenameComparatorTest {
+  private FilenameComparator comparator = FilenameComparator.INSTANCE;
+
+  @Test
+  public void basicPaths() {
+    assertThat(comparator.compare("abc/xyz/FileOne.java", "xyz/abc/FileTwo.java")).isLessThan(0);
+    assertThat(comparator.compare("abc/xyz/FileOne.java", "abc/xyz/FileOne.java")).isEqualTo(0);
+    assertThat(comparator.compare("zzz/yyy/FileOne.java", "abc/xyz/FileOne.java")).isGreaterThan(0);
+  }
+
+  @Test
+  public void specialPaths() {
+    assertThat(comparator.compare("ABC/xyz/FileOne.java", "/COMMIT_MSG")).isGreaterThan(0);
+    assertThat(comparator.compare("/COMMIT_MSG", "ABC/xyz/FileOne.java")).isLessThan(0);
+
+    assertThat(comparator.compare("ABC/xyz/FileOne.java", "/MERGE_LIST")).isGreaterThan(0);
+    assertThat(comparator.compare("/MERGE_LIST", "ABC/xyz/FileOne.java")).isLessThan(0);
+
+    assertThat(comparator.compare("/COMMIT_MSG", "/MERGE_LIST")).isLessThan(0);
+    assertThat(comparator.compare("/MERGE_LIST", "/COMMIT_MSG")).isGreaterThan(0);
+
+    assertThat(comparator.compare("/COMMIT_MSG", "/COMMIT_MSG")).isEqualTo(0);
+    assertThat(comparator.compare("/MERGE_LIST", "/MERGE_LIST")).isEqualTo(0);
+  }
+
+  @Test
+  public void cppExtensions() {
+    assertThat(comparator.compare("abc/file.h", "abc/file.cc")).isLessThan(0);
+    assertThat(comparator.compare("abc/file.c", "abc/file.hpp")).isGreaterThan(0);
+    assertThat(comparator.compare("abc..xyz.file.h", "abc.xyz.file.cc")).isLessThan(0);
+  }
+}
diff --git a/gerrit-common/src/test/java/com/google/gerrit/common/data/ParameterizedStringTest.java b/gerrit-common/src/test/java/com/google/gerrit/common/data/ParameterizedStringTest.java
index b350a27..0f067c4 100644
--- a/gerrit-common/src/test/java/com/google/gerrit/common/data/ParameterizedStringTest.java
+++ b/gerrit-common/src/test/java/com/google/gerrit/common/data/ParameterizedStringTest.java
@@ -19,15 +19,13 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.common.collect.ImmutableMap;
-
-import org.junit.Test;
-
 import java.util.HashMap;
 import java.util.Map;
+import org.junit.Test;
 
 public class ParameterizedStringTest {
   @Test
-  public void testEmptyString() {
+  public void emptyString() {
     final ParameterizedString p = new ParameterizedString("");
     assertEquals("", p.getPattern());
     assertEquals("", p.getRawPattern());
@@ -40,7 +38,7 @@
   }
 
   @Test
-  public void testAsis1() {
+  public void asis1() {
     final ParameterizedString p = ParameterizedString.asis("${bar}c");
     assertEquals("${bar}c", p.getPattern());
     assertEquals("${bar}c", p.getRawPattern());
@@ -54,7 +52,7 @@
   }
 
   @Test
-  public void testReplace1() {
+  public void replace1() {
     final ParameterizedString p = new ParameterizedString("${bar}c");
     assertEquals("${bar}c", p.getPattern());
     assertEquals("{0}c", p.getRawPattern());
@@ -70,7 +68,7 @@
   }
 
   @Test
-  public void testReplace2() {
+  public void replace2() {
     final ParameterizedString p = new ParameterizedString("a${bar}c");
     assertEquals("a${bar}c", p.getPattern());
     assertEquals("a{0}c", p.getRawPattern());
@@ -86,7 +84,7 @@
   }
 
   @Test
-  public void testReplace3() {
+  public void replace3() {
     final ParameterizedString p = new ParameterizedString("a${bar}");
     assertEquals("a${bar}", p.getPattern());
     assertEquals("a{0}", p.getRawPattern());
@@ -102,7 +100,7 @@
   }
 
   @Test
-  public void testReplace4() {
+  public void replace4() {
     final ParameterizedString p = new ParameterizedString("a${bar}c");
     assertEquals("a${bar}c", p.getPattern());
     assertEquals("a{0}c", p.getRawPattern());
@@ -117,7 +115,7 @@
   }
 
   @Test
-  public void testReplaceToLowerCase() {
+  public void replaceToLowerCase() {
     final ParameterizedString p = new ParameterizedString("${a.toLowerCase}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
@@ -138,7 +136,7 @@
   }
 
   @Test
-  public void testReplaceToUpperCase() {
+  public void replaceToUpperCase() {
     final ParameterizedString p = new ParameterizedString("${a.toUpperCase}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
@@ -159,7 +157,7 @@
   }
 
   @Test
-  public void testReplaceLocalName() {
+  public void replaceLocalName() {
     final ParameterizedString p = new ParameterizedString("${a.localPart}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
@@ -180,7 +178,7 @@
   }
 
   @Test
-  public void testUndefinedFunctionName() {
+  public void undefinedFunctionName() {
     ParameterizedString p =
         new ParameterizedString(
             "hi, ${userName.toUpperCase},your eamil address is '${email.toLowerCase.localPart}'.right?");
@@ -196,13 +194,13 @@
 
     assertEquals("FIRSTNAME LASTNAME", p.bind(a)[0]);
     assertEquals("firstname.lastname", p.bind(a)[1]);
-    assertEquals("hi, FIRSTNAME LASTNAME,your eamil address is 'firstname.lastname'.right?", p.replace(a));
+    assertEquals(
+        "hi, FIRSTNAME LASTNAME,your eamil address is 'firstname.lastname'.right?", p.replace(a));
   }
 
   @Test
-  public void testReplaceToUpperCaseToLowerCase() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.toUpperCase.toLowerCase}");
+  public void replaceToUpperCaseToLowerCase() {
+    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.toLowerCase}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -222,9 +220,8 @@
   }
 
   @Test
-  public void testReplaceToUpperCaseLocalName() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.toUpperCase.localPart}");
+  public void replaceToUpperCaseLocalName() {
+    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.localPart}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -244,9 +241,8 @@
   }
 
   @Test
-  public void testReplaceToUpperCaseAnUndefinedMethod() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.toUpperCase.anUndefinedMethod}");
+  public void replaceToUpperCaseAnUndefinedMethod() {
+    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.anUndefinedMethod}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -266,9 +262,8 @@
   }
 
   @Test
-  public void testReplaceLocalNameToUpperCase() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.localPart.toUpperCase}");
+  public void replaceLocalNameToUpperCase() {
+    final ParameterizedString p = new ParameterizedString("${a.localPart.toUpperCase}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -288,9 +283,8 @@
   }
 
   @Test
-  public void testReplaceLocalNameToLowerCase() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.localPart.toLowerCase}");
+  public void replaceLocalNameToLowerCase() {
+    final ParameterizedString p = new ParameterizedString("${a.localPart.toLowerCase}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -310,9 +304,8 @@
   }
 
   @Test
-  public void testReplaceLocalNameAnUndefinedMethod() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.localPart.anUndefinedMethod}");
+  public void replaceLocalNameAnUndefinedMethod() {
+    final ParameterizedString p = new ParameterizedString("${a.localPart.anUndefinedMethod}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -332,9 +325,8 @@
   }
 
   @Test
-  public void testReplaceToLowerCaseToUpperCase() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.toLowerCase.toUpperCase}");
+  public void replaceToLowerCaseToUpperCase() {
+    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.toUpperCase}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -354,9 +346,8 @@
   }
 
   @Test
-  public void testReplaceToLowerCaseLocalName() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.toLowerCase.localPart}");
+  public void replaceToLowerCaseLocalName() {
+    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.localPart}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -376,9 +367,8 @@
   }
 
   @Test
-  public void testReplaceToLowerCaseAnUndefinedMethod() {
-    final ParameterizedString p =
-        new ParameterizedString("${a.toLowerCase.anUndefinedMethod}");
+  public void replaceToLowerCaseAnUndefinedMethod() {
+    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.anUndefinedMethod}");
     assertEquals(1, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("a"));
 
@@ -398,15 +388,15 @@
   }
 
   @Test
-  public void testReplaceSubmitTooltipWithVariables() {
-    ParameterizedString p = new ParameterizedString(
-        "Submit patch set ${patchSet} into ${branch}");
+  public void replaceSubmitTooltipWithVariables() {
+    ParameterizedString p = new ParameterizedString("Submit patch set ${patchSet} into ${branch}");
     assertEquals(2, p.getParameterNames().size());
     assertTrue(p.getParameterNames().contains("patchSet"));
 
-    Map<String, String> params = ImmutableMap.of(
-        "patchSet", "42",
-        "branch", "foo");
+    Map<String, String> params =
+        ImmutableMap.of(
+            "patchSet", "42",
+            "branch", "foo");
     assertNotNull(p.bind(params));
     assertEquals(2, p.bind(params).length);
     assertEquals("42", p.bind(params)[0]);
@@ -415,12 +405,12 @@
   }
 
   @Test
-  public void testReplaceSubmitTooltipWithoutVariables() {
-    ParameterizedString p = new ParameterizedString(
-            "Submit patch set 40 into master");
-    Map<String, String> params = ImmutableMap.of(
-        "patchSet", "42",
-        "branch", "foo");
+  public void replaceSubmitTooltipWithoutVariables() {
+    ParameterizedString p = new ParameterizedString("Submit patch set 40 into master");
+    Map<String, String> params =
+        ImmutableMap.of(
+            "patchSet", "42",
+            "branch", "foo");
     assertEquals(0, p.bind(params).length);
     assertEquals("Submit patch set 40 into master", p.replace(params));
   }
diff --git a/gerrit-elasticsearch/BUILD b/gerrit-elasticsearch/BUILD
new file mode 100644
index 0000000..bbf413b
--- /dev/null
+++ b/gerrit-elasticsearch/BUILD
@@ -0,0 +1,121 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+java_library(
+    name = "elasticsearch",
+    srcs = glob(
+        ["src/main/java/**/*.java"],
+        exclude = ["**/testing/**"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-antlr:query_exception",
+        "//gerrit-common:annotations",
+        "//gerrit-extension-api:api",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/commons:codec",
+        "//lib/elasticsearch-rest-client",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/httpcomponents:httpasyncclient",
+        "//lib/httpcomponents:httpclient",
+        "//lib/httpcomponents:httpcore",
+        "//lib/httpcomponents:httpcore-nio",
+        "//lib/jackson:jackson-core",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
+
+java_library(
+    name = "elasticsearch_test_utils",
+    testonly = 1,
+    srcs = glob(["src/main/java/com/google/gerrit/elasticsearch/testing/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":elasticsearch",
+        "//gerrit-server:server",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/httpcomponents:httpcore",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/testcontainers",
+    ],
+)
+
+ELASTICSEARCH_DEPS = [
+    ":elasticsearch",
+    ":elasticsearch_test_utils",
+    "//gerrit-server:query_tests_code",
+    "//gerrit-server:testutil",
+    "//lib:truth",
+    "//lib/guice",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+]
+
+TYPES = [
+    "account",
+    "change",
+    "group",
+]
+
+SUFFIX = "sTest.java"
+
+ELASTICSEARCH_TESTS = {i: "src/test/java/com/google/gerrit/elasticsearch/ElasticQuery" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TESTS_V5 = {i: "src/test/java/com/google/gerrit/elasticsearch/ElasticV5Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TESTS_V6 = {i: "src/test/java/com/google/gerrit/elasticsearch/ElasticV6Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+ELASTICSEARCH_TAGS = [
+    "docker",
+    "elastic",
+    "exclusive",
+]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS,
+) for name, src in ELASTICSEARCH_TESTS.items()]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_v5" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS,
+) for name, src in ELASTICSEARCH_TESTS_V5.items()]
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_v6" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS,
+) for name, src in ELASTICSEARCH_TESTS_V6.items()]
+
+junit_tests(
+    name = "elasticsearch_tests",
+    size = "small",
+    srcs = glob(
+        ["src/test/java/com/google/gerrit/elasticsearch/*Test.java"],
+        exclude = ["src/test/java/com/google/gerrit/elasticsearch/Elastic*Query*" + SUFFIX],
+    ),
+    tags = ["elastic"],
+    deps = [
+        ":elasticsearch",
+        ":elasticsearch_test_utils",
+        "//gerrit-server:testutil",
+        "//lib:guava",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
new file mode 100644
index 0000000..72cc3d0
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -0,0 +1,267 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.commons.codec.binary.Base64.decodeBase64;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
+import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.Schema;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpStatus;
+import org.apache.http.entity.ContentType;
+import org.apache.http.nio.entity.NStringEntity;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+
+abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
+  protected static final String BULK = "_bulk";
+  protected static final String MAPPINGS = "mappings";
+  protected static final String ORDER = "order";
+  protected static final String SEARCH = "_search";
+  protected static final String SETTINGS = "settings";
+
+  protected static <T> List<T> decodeProtos(
+      JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
+    JsonArray field = doc.getAsJsonArray(fieldName);
+    if (field == null) {
+      return null;
+    }
+    return FluentIterable.from(field)
+        .transform(i -> codec.decode(decodeBase64(i.toString())))
+        .toList();
+  }
+
+  static String getContent(Response response) throws IOException {
+    HttpEntity responseEntity = response.getEntity();
+    String content = "";
+    if (responseEntity != null) {
+      InputStream contentStream = responseEntity.getContent();
+      try (Reader reader = new InputStreamReader(contentStream, UTF_8)) {
+        content = CharStreams.toString(reader);
+      }
+    }
+    return content;
+  }
+
+  private final Schema<V> schema;
+  private final SitePaths sitePaths;
+  private final String indexNameRaw;
+
+  protected final String type;
+  protected final ElasticRestClientProvider client;
+  protected final String indexName;
+  protected final Gson gson;
+  protected final ElasticQueryBuilder queryBuilder;
+
+  AbstractElasticIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Schema<V> schema,
+      ElasticRestClientProvider client,
+      String indexName,
+      String indexType) {
+    this.sitePaths = sitePaths;
+    this.schema = schema;
+    this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
+    this.queryBuilder = new ElasticQueryBuilder();
+    this.indexName = cfg.getIndexName(indexName, schema.getVersion());
+    this.indexNameRaw = indexName;
+    this.client = client;
+    this.type = client.adapter().getType(indexType);
+  }
+
+  AbstractElasticIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Schema<V> schema,
+      ElasticRestClientProvider client,
+      String indexName) {
+    this(cfg, sitePaths, schema, client, indexName, indexName);
+  }
+
+  @Override
+  public Schema<V> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {
+    // Do nothing. Client is closed by the provider.
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    IndexUtils.setReady(sitePaths, indexNameRaw, schema.getVersion(), ready);
+  }
+
+  @Override
+  public void delete(K id) throws IOException {
+    String uri = getURI(type, BULK);
+    Response response = postRequest(uri, getDeleteActions(id), getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          String.format("Failed to delete %s from index %s: %s", id, indexName, statusCode));
+    }
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    // Delete the index, if it exists.
+    String endpoint = indexName + client.adapter().indicesExistParam();
+    Response response = performRequest("HEAD", endpoint);
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode == HttpStatus.SC_OK) {
+      response = performRequest("DELETE", indexName);
+      statusCode = response.getStatusLine().getStatusCode();
+      if (statusCode != HttpStatus.SC_OK) {
+        throw new IOException(
+            String.format("Failed to delete index %s: %s", indexName, statusCode));
+      }
+    }
+
+    // Recreate the index.
+    String indexCreationFields = concatJsonString(getSettings(), getMappings());
+    response = performRequest("PUT", indexName, indexCreationFields);
+    statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      String error = String.format("Failed to create index %s: %s", indexName, statusCode);
+      throw new IOException(error);
+    }
+  }
+
+  protected abstract String getDeleteActions(K id);
+
+  protected abstract String getMappings();
+
+  private String getSettings() {
+    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting()));
+  }
+
+  protected abstract String getId(V v);
+
+  protected String getMappingsForSingleType(String candidateType, MappingProperties properties) {
+    return getMappingsFor(client.adapter().getType(candidateType), properties);
+  }
+
+  protected String getMappingsFor(String type, MappingProperties properties) {
+    JsonObject mappingType = new JsonObject();
+    mappingType.add(type, gson.toJsonTree(properties));
+    JsonObject mappings = new JsonObject();
+    mappings.add(MAPPINGS, gson.toJsonTree(mappingType));
+    return gson.toJson(mappings);
+  }
+
+  protected String delete(String type, K id) {
+    return new DeleteRequest(id.toString(), indexName, type, client.adapter()).toString();
+  }
+
+  protected void addNamedElement(String name, JsonObject element, JsonArray array) {
+    JsonObject arrayElement = new JsonObject();
+    arrayElement.add(name, element);
+    array.add(arrayElement);
+  }
+
+  protected Map<String, String> getRefreshParam() {
+    Map<String, String> params = new HashMap<>();
+    params.put("refresh", "true");
+    return params;
+  }
+
+  protected String getSearch(SearchSourceBuilder searchSource, JsonArray sortArray) {
+    JsonObject search = new JsonParser().parse(searchSource.toString()).getAsJsonObject();
+    search.add("sort", sortArray);
+    return gson.toJson(search);
+  }
+
+  protected JsonArray getSortArray(String idFieldName) {
+    JsonObject properties = new JsonObject();
+    properties.addProperty(ORDER, "asc");
+    client.adapter().setIgnoreUnmapped(properties);
+
+    JsonArray sortArray = new JsonArray();
+    addNamedElement(idFieldName, properties, sortArray);
+    return sortArray;
+  }
+
+  protected String getURI(String type, String request) throws UnsupportedEncodingException {
+    String encodedType = URLEncoder.encode(type, UTF_8.toString());
+    String encodedIndexName = URLEncoder.encode(indexName, UTF_8.toString());
+    return encodedIndexName + "/" + encodedType + "/" + request;
+  }
+
+  protected Response postRequest(String uri, Object payload) throws IOException {
+    return performRequest("POST", uri, payload);
+  }
+
+  protected Response postRequest(String uri, Object payload, Map<String, String> params)
+      throws IOException {
+    return performRequest("POST", uri, payload, params);
+  }
+
+  private String concatJsonString(String target, String addition) {
+    return target.substring(0, target.length() - 1) + "," + addition.substring(1);
+  }
+
+  private Response performRequest(String method, String uri) throws IOException {
+    return performRequest(method, uri, null);
+  }
+
+  private Response performRequest(String method, String uri, @Nullable Object payload)
+      throws IOException {
+    return performRequest(method, uri, payload, Collections.emptyMap());
+  }
+
+  private Response performRequest(
+      String method, String uri, @Nullable Object payload, Map<String, String> params)
+      throws IOException {
+    Request request = new Request(method, uri.startsWith("/") ? uri : "/" + uri);
+    if (payload != null) {
+      String payloadStr = payload instanceof String ? (String) payload : payload.toString();
+      request.setEntity(new NStringEntity(payloadStr, ContentType.APPLICATION_JSON));
+    }
+    for (Map.Entry<String, String> entry : params.entrySet()) {
+      request.addParameter(entry.getKey(), entry.getValue());
+    }
+    return client.get().performRequest(request);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
new file mode 100644
index 0000000..aee4177
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -0,0 +1,207 @@
+// 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.elasticsearch;
+
+import static com.google.gerrit.server.index.account.AccountField.ID;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.elasticsearch.builders.QueryBuilder;
+import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
+import com.google.gerrit.elasticsearch.bulk.BulkRequest;
+import com.google.gerrit.elasticsearch.bulk.IndexRequest;
+import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import org.apache.http.HttpStatus;
+import org.apache.http.StatusLine;
+import org.elasticsearch.client.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
+    implements AccountIndex {
+  private static final Logger log = LoggerFactory.getLogger(ElasticAccountIndex.class);
+
+  static class AccountMapping {
+    final MappingProperties accounts;
+
+    AccountMapping(Schema<AccountState> schema, ElasticQueryAdapter adapter) {
+      this.accounts = ElasticMapping.createMapping(schema, adapter);
+    }
+  }
+
+  private static final String ACCOUNTS = "accounts";
+
+  private final AccountMapping mapping;
+  private final Provider<AccountCache> accountCache;
+  private final Schema<AccountState> schema;
+
+  @AssistedInject
+  ElasticAccountIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Provider<AccountCache> accountCache,
+      ElasticRestClientProvider client,
+      @Assisted Schema<AccountState> schema) {
+    super(cfg, sitePaths, schema, client, ACCOUNTS);
+    this.accountCache = accountCache;
+    this.mapping = new AccountMapping(schema, client.adapter());
+    this.schema = schema;
+  }
+
+  @Override
+  public void replace(AccountState as) throws IOException {
+    BulkRequest bulk =
+        new IndexRequest(getId(as), indexName, type, client.adapter())
+            .add(new UpdateRequest<>(schema, as));
+
+    String uri = getURI(type, BULK);
+    Response response = postRequest(uri, bulk, getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          String.format(
+              "Failed to replace account %s in index %s: %s",
+              as.getAccount().getId(), indexName, statusCode));
+    }
+  }
+
+  @Override
+  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
+      throws QueryParseException {
+    return new QuerySource(p, opts);
+  }
+
+  @Override
+  protected String getDeleteActions(Account.Id a) {
+    return delete(type, a);
+  }
+
+  @Override
+  protected String getMappings() {
+    return getMappingsForSingleType(ACCOUNTS, mapping.accounts);
+  }
+
+  @Override
+  protected String getId(AccountState as) {
+    return as.getAccount().getId().toString();
+  }
+
+  private class QuerySource implements DataSource<AccountState> {
+    private final String search;
+    private final Set<String> fields;
+
+    QuerySource(Predicate<AccountState> p, QueryOptions opts) throws QueryParseException {
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      fields = IndexUtils.accountFields(opts);
+      SearchSourceBuilder searchSource =
+          new SearchSourceBuilder(client.adapter())
+              .query(qb)
+              .from(opts.start())
+              .size(opts.limit())
+              .fields(Lists.newArrayList(fields));
+
+      JsonArray sortArray = getSortArray(AccountField.ID.getName());
+      search = getSearch(searchSource, sortArray);
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<AccountState> read() throws OrmException {
+      try {
+        List<AccountState> results = Collections.emptyList();
+        String uri = getURI(type, SEARCH);
+        Response response = postRequest(uri, search);
+        StatusLine statusLine = response.getStatusLine();
+        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
+          String content = getContent(response);
+          JsonObject obj =
+              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              results.add(toAccountState(json.get(i)));
+            }
+          }
+        } else {
+          log.error(statusLine.getReasonPhrase());
+        }
+        final List<AccountState> r = Collections.unmodifiableList(results);
+        return new ResultSet<AccountState>() {
+          @Override
+          public Iterator<AccountState> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<AccountState> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    private AccountState toAccountState(JsonElement json) {
+      JsonElement source = json.getAsJsonObject().get("_source");
+      if (source == null) {
+        source = json.getAsJsonObject().get("fields");
+      }
+
+      Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.getName()).getAsInt());
+      // Use the AccountCache rather than depending on any stored fields in the
+      // document (of which there shouldn't be any). The most expensive part to
+      // compute anyway is the effective group IDs, and we don't have a good way
+      // to reindex when those change.
+      return accountCache.get().get(id);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
new file mode 100644
index 0000000..9b845e0
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -0,0 +1,438 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
+import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.commons.codec.binary.Base64.decodeBase64;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.elasticsearch.builders.QueryBuilder;
+import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
+import com.google.gerrit.elasticsearch.bulk.BulkRequest;
+import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
+import com.google.gerrit.elasticsearch.bulk.IndexRequest;
+import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.http.HttpStatus;
+import org.apache.http.StatusLine;
+import org.elasticsearch.client.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Secondary index implementation using Elasticsearch. */
+public class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
+    implements ChangeIndex {
+  private static final Logger log = LoggerFactory.getLogger(ElasticChangeIndex.class);
+
+  static class ChangeMapping {
+    final MappingProperties changes;
+    final MappingProperties openChanges;
+    final MappingProperties closedChanges;
+
+    ChangeMapping(Schema<ChangeData> schema, ElasticQueryAdapter adapter) {
+      MappingProperties mapping = ElasticMapping.createMapping(schema, adapter);
+      this.changes = mapping;
+      this.openChanges = mapping;
+      this.closedChanges = mapping;
+    }
+  }
+
+  private static final String CHANGES = "changes";
+  private static final String OPEN_CHANGES = "open_" + CHANGES;
+  private static final String CLOSED_CHANGES = "closed_" + CHANGES;
+  private static final String ALL_CHANGES = OPEN_CHANGES + "," + CLOSED_CHANGES;
+  private final ChangeMapping mapping;
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final FillArgs fillArgs;
+  private final Schema<ChangeData> schema;
+
+  @AssistedInject
+  ElasticChangeIndex(
+      ElasticConfiguration cfg,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      FillArgs fillArgs,
+      SitePaths sitePaths,
+      ElasticRestClientProvider client,
+      @Assisted Schema<ChangeData> schema) {
+    super(cfg, sitePaths, schema, client, CHANGES, ALL_CHANGES);
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.fillArgs = fillArgs;
+    this.schema = schema;
+    this.mapping = new ChangeMapping(schema, client.adapter());
+  }
+
+  @Override
+  public void replace(ChangeData cd) throws IOException {
+    String deleteIndex;
+    String insertIndex;
+
+    try {
+      if (cd.change().getStatus().isOpen()) {
+        insertIndex = OPEN_CHANGES;
+        deleteIndex = CLOSED_CHANGES;
+      } else {
+        insertIndex = CLOSED_CHANGES;
+        deleteIndex = OPEN_CHANGES;
+      }
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+
+    ElasticQueryAdapter adapter = client.adapter();
+    BulkRequest bulk =
+        new IndexRequest(getId(cd), indexName, adapter.getType(insertIndex), adapter)
+            .add(new UpdateRequest<>(fillArgs, schema, cd));
+    if (!adapter.usePostV5Type()) {
+      bulk.add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex, adapter));
+    }
+
+    String uri = getURI(type, BULK);
+    Response response = postRequest(uri, bulk, getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          String.format(
+              "Failed to replace change %s in index %s: %s", cd.getId(), indexName, statusCode));
+    }
+  }
+
+  @Override
+  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
+    List<String> indexes = Lists.newArrayListWithCapacity(2);
+    if (client.adapter().usePostV5Type()) {
+      if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()
+          || !Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+        indexes.add(ElasticQueryAdapter.POST_V5_TYPE);
+      }
+    } else {
+      if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
+        indexes.add(OPEN_CHANGES);
+      }
+      if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+        indexes.add(CLOSED_CHANGES);
+      }
+    }
+    return new QuerySource(indexes, p, opts);
+  }
+
+  @Override
+  protected String getDeleteActions(Id c) {
+    if (client.adapter().usePostV5Type()) {
+      return delete(ElasticQueryAdapter.POST_V5_TYPE, c);
+    }
+    return delete(OPEN_CHANGES, c) + delete(CLOSED_CHANGES, c);
+  }
+
+  @Override
+  protected String getMappings() {
+    if (client.adapter().usePostV5Type()) {
+      return getMappingsFor(ElasticQueryAdapter.POST_V5_TYPE, mapping.changes);
+    }
+    return gson.toJson(ImmutableMap.of(MAPPINGS, mapping));
+  }
+
+  @Override
+  protected String getId(ChangeData cd) {
+    return cd.getId().toString();
+  }
+
+  private class QuerySource implements ChangeDataSource {
+    private final String search;
+    private final Set<String> fields;
+    private final List<String> types;
+
+    QuerySource(List<String> types, Predicate<ChangeData> p, QueryOptions opts)
+        throws QueryParseException {
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      fields = IndexUtils.changeFields(opts);
+      SearchSourceBuilder searchSource =
+          new SearchSourceBuilder(client.adapter())
+              .query(qb)
+              .from(opts.start())
+              .size(opts.limit())
+              .fields(Lists.newArrayList(fields));
+
+      search = getSearch(searchSource, getSortArray());
+      this.types = types;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() throws OrmException {
+      try {
+        List<ChangeData> results = Collections.emptyList();
+        String uri = getURI(types);
+        Response response = postRequest(uri, search);
+        StatusLine statusLine = response.getStatusLine();
+        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
+          String content = getContent(response);
+          JsonObject obj =
+              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              results.add(toChangeData(json.get(i)));
+            }
+          }
+        } else {
+          log.error(statusLine.getReasonPhrase());
+        }
+        final List<ChangeData> r = Collections.unmodifiableList(results);
+        return new ResultSet<ChangeData>() {
+          @Override
+          public Iterator<ChangeData> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<ChangeData> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    @Override
+    public boolean hasChange() {
+      return false;
+    }
+
+    private ChangeData toChangeData(JsonElement json) {
+      JsonElement sourceElement = json.getAsJsonObject().get("_source");
+      if (sourceElement == null) {
+        sourceElement = json.getAsJsonObject().get("fields");
+      }
+      JsonObject source = sourceElement.getAsJsonObject();
+      JsonElement c = source.get(ChangeField.CHANGE.getName());
+
+      if (c == null) {
+        int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
+        String projectName = source.get(ChangeField.PROJECT.getName()).getAsString();
+        if (projectName == null) {
+          return changeDataFactory.createOnlyWhenNoteDbDisabled(db.get(), new Change.Id(id));
+        }
+        return changeDataFactory.create(
+            db.get(), new Project.NameKey(projectName), new Change.Id(id));
+      }
+
+      ChangeData cd =
+          changeDataFactory.create(
+              db.get(), CHANGE_CODEC.decode(Base64.decodeBase64(c.getAsString())));
+
+      // Patch sets.
+      cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
+
+      // Approvals.
+      if (source.get(ChangeField.APPROVAL.getName()) != null) {
+        cd.setCurrentApprovals(
+            decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
+      } else if (fields.contains(ChangeField.APPROVAL.getName())) {
+        cd.setCurrentApprovals(Collections.emptyList());
+      }
+
+      JsonElement addedElement = source.get(ChangeField.ADDED.getName());
+      JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
+      if (addedElement != null && deletedElement != null) {
+        // Changed lines.
+        int added = addedElement.getAsInt();
+        int deleted = deletedElement.getAsInt();
+        cd.setChangedLines(added, deleted);
+      }
+
+      // Star.
+      JsonElement starredElement = source.get(ChangeField.STAR.getName());
+      if (starredElement != null) {
+        ListMultimap<Account.Id, String> stars =
+            MultimapBuilder.hashKeys().arrayListValues().build();
+        JsonArray starBy = starredElement.getAsJsonArray();
+        if (starBy.size() > 0) {
+          for (int i = 0; i < starBy.size(); i++) {
+            String[] indexableFields = starBy.get(i).getAsString().split(":");
+            Account.Id id = Account.Id.parse(indexableFields[0]);
+            stars.put(id, indexableFields[1]);
+          }
+        }
+        cd.setStars(stars);
+      }
+
+      // Mergeable.
+      JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
+      if (mergeableElement != null) {
+        String mergeable = mergeableElement.getAsString();
+        if ("1".equals(mergeable)) {
+          cd.setMergeable(true);
+        } else if ("0".equals(mergeable)) {
+          cd.setMergeable(false);
+        }
+      }
+
+      // Reviewed-by.
+      if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
+        JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
+        if (reviewedBy.size() > 0) {
+          Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
+          for (int i = 0; i < reviewedBy.size(); i++) {
+            int aId = reviewedBy.get(i).getAsInt();
+            if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
+              break;
+            }
+            accounts.add(new Account.Id(aId));
+          }
+          cd.setReviewedBy(accounts);
+        }
+      } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
+        cd.setReviewedBy(Collections.emptySet());
+      }
+
+      if (source.get(ChangeField.REVIEWER.getName()) != null) {
+        cd.setReviewers(
+            ChangeField.parseReviewerFieldValues(
+                FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
+                    .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.REVIEWER.getName())) {
+        cd.setReviewers(ReviewerSet.empty());
+      }
+
+      decodeSubmitRecords(
+          source,
+          ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
+          ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
+          cd);
+      decodeSubmitRecords(
+          source,
+          ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
+          ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
+          cd);
+      decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
+
+      if (fields.contains(ChangeField.REF_STATE.getName())) {
+        cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
+      }
+      if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
+        cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
+      }
+
+      return cd;
+    }
+
+    private Iterable<byte[]> getByteArray(JsonObject source, String name) {
+      JsonElement element = source.get(name);
+      return element != null
+          ? Iterables.transform(element.getAsJsonArray(), e -> Base64.decodeBase64(e.getAsString()))
+          : Collections.emptyList();
+    }
+
+    private void decodeSubmitRecords(
+        JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
+      JsonArray records = doc.getAsJsonArray(fieldName);
+      if (records == null) {
+        return;
+      }
+      ChangeField.parseSubmitRecords(
+          FluentIterable.from(records)
+              .transform(i -> new String(decodeBase64(i.toString()), UTF_8))
+              .toList(),
+          opts,
+          out);
+    }
+
+    private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
+      JsonElement count = doc.get(fieldName);
+      if (count == null) {
+        return;
+      }
+      out.setUnresolvedCommentCount(count.getAsInt());
+    }
+
+    private JsonArray getSortArray() {
+      JsonObject properties = new JsonObject();
+      properties.addProperty(ORDER, "desc");
+      client.adapter().setIgnoreUnmapped(properties);
+
+      JsonArray sortArray = new JsonArray();
+      addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
+      addNamedElement(ChangeField.LEGACY_ID.getName(), properties, sortArray);
+      return sortArray;
+    }
+  }
+
+  private String getURI(List<String> types) throws UnsupportedEncodingException {
+    String joinedTypes = String.join(",", types);
+    return getURI(joinedTypes, SEARCH);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
new file mode 100644
index 0000000..dce28019
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.http.HttpHost;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+class ElasticConfiguration {
+  private static final Logger log = LoggerFactory.getLogger(ElasticConfiguration.class);
+
+  static final String SECTION_ELASTICSEARCH = "elasticsearch";
+  static final String KEY_PASSWORD = "password";
+  static final String KEY_USERNAME = "username";
+  static final String KEY_MAX_RETRY_TIMEOUT = "maxRetryTimeout";
+  static final String KEY_PREFIX = "prefix";
+  static final String KEY_SERVER = "server";
+  static final String DEFAULT_PORT = "9200";
+  static final String DEFAULT_USERNAME = "elastic";
+  static final int DEFAULT_MAX_RETRY_TIMEOUT_MS = 30000;
+  static final TimeUnit MAX_RETRY_TIMEOUT_UNIT = TimeUnit.MILLISECONDS;
+
+  private final Config cfg;
+  private final List<HttpHost> hosts;
+
+  final String username;
+  final String password;
+  final int maxRetryTimeout;
+  final String prefix;
+
+  @Inject
+  ElasticConfiguration(@GerritServerConfig Config cfg) {
+    this.cfg = cfg;
+    this.password = cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD);
+    this.username =
+        password == null
+            ? null
+            : firstNonNull(
+                cfg.getString(SECTION_ELASTICSEARCH, null, KEY_USERNAME), DEFAULT_USERNAME);
+    this.maxRetryTimeout =
+        (int)
+            cfg.getTimeUnit(
+                SECTION_ELASTICSEARCH,
+                null,
+                KEY_MAX_RETRY_TIMEOUT,
+                DEFAULT_MAX_RETRY_TIMEOUT_MS,
+                MAX_RETRY_TIMEOUT_UNIT);
+    this.prefix = Strings.nullToEmpty(cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PREFIX));
+    this.hosts = new ArrayList<>();
+    for (String server : cfg.getStringList(SECTION_ELASTICSEARCH, null, KEY_SERVER)) {
+      try {
+        URI uri = new URI(server);
+        int port = uri.getPort();
+        HttpHost httpHost =
+            new HttpHost(
+                uri.getHost(), port == -1 ? Integer.valueOf(DEFAULT_PORT) : port, uri.getScheme());
+        this.hosts.add(httpHost);
+      } catch (URISyntaxException | IllegalArgumentException e) {
+        log.error("Invalid server URI {}: {}", server, e.getMessage());
+      }
+    }
+
+    if (hosts.isEmpty()) {
+      throw new ProvisionException("No valid Elasticsearch servers configured");
+    }
+
+    log.info("Elasticsearch servers: {}", hosts);
+  }
+
+  Config getConfig() {
+    return cfg;
+  }
+
+  HttpHost[] getHosts() {
+    return hosts.toArray(new HttpHost[hosts.size()]);
+  }
+
+  String getIndexName(String name, int schemaVersion) {
+    return String.format("%s%s_%04d", prefix, name, schemaVersion);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticException.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticException.java
new file mode 100644
index 0000000..d4baf75
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+class ElasticException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  ElasticException(String message) {
+    super(message);
+  }
+
+  ElasticException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
new file mode 100644
index 0000000..c01f4b4
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -0,0 +1,204 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.elasticsearch.builders.QueryBuilder;
+import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
+import com.google.gerrit.elasticsearch.bulk.BulkRequest;
+import com.google.gerrit.elasticsearch.bulk.IndexRequest;
+import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import org.apache.http.HttpStatus;
+import org.apache.http.StatusLine;
+import org.elasticsearch.client.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, AccountGroup>
+    implements GroupIndex {
+  private static final Logger log = LoggerFactory.getLogger(ElasticGroupIndex.class);
+
+  static class GroupMapping {
+    final MappingProperties groups;
+
+    GroupMapping(Schema<AccountGroup> schema, ElasticQueryAdapter adapter) {
+      this.groups = ElasticMapping.createMapping(schema, adapter);
+    }
+  }
+
+  private static final String GROUPS = "groups";
+
+  private final GroupMapping mapping;
+  private final Provider<GroupCache> groupCache;
+  private final Schema<AccountGroup> schema;
+
+  @AssistedInject
+  ElasticGroupIndex(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Provider<GroupCache> groupCache,
+      ElasticRestClientProvider client,
+      @Assisted Schema<AccountGroup> schema) {
+    super(cfg, sitePaths, schema, client, GROUPS);
+    this.groupCache = groupCache;
+    this.mapping = new GroupMapping(schema, client.adapter());
+    this.schema = schema;
+  }
+
+  @Override
+  public void replace(AccountGroup group) throws IOException {
+    BulkRequest bulk =
+        new IndexRequest(getId(group), indexName, type, client.adapter())
+            .add(new UpdateRequest<>(schema, group));
+
+    String uri = getURI(type, BULK);
+    Response response = postRequest(uri, bulk, getRefreshParam());
+    int statusCode = response.getStatusLine().getStatusCode();
+    if (statusCode != HttpStatus.SC_OK) {
+      throw new IOException(
+          String.format(
+              "Failed to replace group %s in index %s: %s",
+              group.getGroupUUID().get(), indexName, statusCode));
+    }
+  }
+
+  @Override
+  public DataSource<AccountGroup> getSource(Predicate<AccountGroup> p, QueryOptions opts)
+      throws QueryParseException {
+    return new QuerySource(p, opts);
+  }
+
+  @Override
+  protected String getDeleteActions(AccountGroup.UUID g) {
+    return delete(type, g);
+  }
+
+  @Override
+  protected String getMappings() {
+    return getMappingsForSingleType(GROUPS, mapping.groups);
+  }
+
+  @Override
+  protected String getId(AccountGroup group) {
+    return group.getGroupUUID().get();
+  }
+
+  private class QuerySource implements DataSource<AccountGroup> {
+    private final String search;
+    private final Set<String> fields;
+
+    QuerySource(Predicate<AccountGroup> p, QueryOptions opts) throws QueryParseException {
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      fields = IndexUtils.groupFields(opts);
+      SearchSourceBuilder searchSource =
+          new SearchSourceBuilder(client.adapter())
+              .query(qb)
+              .from(opts.start())
+              .size(opts.limit())
+              .fields(Lists.newArrayList(fields));
+
+      JsonArray sortArray = getSortArray(GroupField.UUID.getName());
+      search = getSearch(searchSource, sortArray);
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<AccountGroup> read() throws OrmException {
+      try {
+        List<AccountGroup> results = Collections.emptyList();
+        String uri = getURI(type, SEARCH);
+        Response response = postRequest(uri, search);
+        StatusLine statusLine = response.getStatusLine();
+        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
+          String content = getContent(response);
+          JsonObject obj =
+              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              results.add(toAccountGroup(json.get(i)));
+            }
+          }
+        } else {
+          log.error(statusLine.getReasonPhrase());
+        }
+        final List<AccountGroup> r = Collections.unmodifiableList(results);
+        return new ResultSet<AccountGroup>() {
+          @Override
+          public Iterator<AccountGroup> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<AccountGroup> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    private AccountGroup toAccountGroup(JsonElement json) {
+      JsonElement source = json.getAsJsonObject().get("_source");
+      if (source == null) {
+        source = json.getAsJsonObject().get("fields");
+      }
+
+      AccountGroup.UUID uuid =
+          new AccountGroup.UUID(
+              source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
+      // Use the GroupCache rather than depending on any stored fields in the
+      // document (of which there shouldn't be any).
+      return groupCache.get().get(uuid);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
new file mode 100644
index 0000000..3d63f3e
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.server.index.AbstractIndexModule;
+import com.google.gerrit.server.index.AbstractVersionManager;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import java.util.Map;
+
+public class ElasticIndexModule extends AbstractIndexModule {
+
+  public static ElasticIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads) {
+    return new ElasticIndexModule(versions, threads);
+  }
+
+  public static ElasticIndexModule latestVersionWithOnlineUpgrade() {
+    return new ElasticIndexModule(null, 0);
+  }
+
+  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads) {
+    super(singleVersions, threads);
+  }
+
+  @Override
+  public void configure() {
+    super.configure();
+    install(ElasticRestClientProvider.module());
+  }
+
+  @Override
+  protected Class<? extends AccountIndex> getAccountIndex() {
+    return ElasticAccountIndex.class;
+  }
+
+  @Override
+  protected Class<? extends ChangeIndex> getChangeIndex() {
+    return ElasticChangeIndex.class;
+  }
+
+  @Override
+  protected Class<? extends GroupIndex> getGroupIndex() {
+    return ElasticGroupIndex.class;
+  }
+
+  @Override
+  protected Class<? extends AbstractVersionManager> getVersionManager() {
+    return ElasticIndexVersionManager.class;
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
new file mode 100644
index 0000000..8b343c2
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gson.JsonParser;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.apache.http.HttpStatus;
+import org.apache.http.StatusLine;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+class ElasticIndexVersionDiscovery {
+  private static final Logger log = LoggerFactory.getLogger(ElasticIndexVersionDiscovery.class);
+
+  private final ElasticRestClientProvider client;
+
+  @Inject
+  ElasticIndexVersionDiscovery(ElasticRestClientProvider client) {
+    this.client = client;
+  }
+
+  List<String> discover(String prefix, String indexName) throws IOException {
+    String name = prefix + indexName + "_";
+    Request request = new Request("GET", client.adapter().getVersionDiscoveryUrl(name));
+    Response response = client.get().performRequest(request);
+
+    StatusLine statusLine = response.getStatusLine();
+    if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
+      String message =
+          String.format(
+              "Failed to discover index versions for %s: %d: %s",
+              name, statusLine.getStatusCode(), statusLine.getReasonPhrase());
+      log.error(message);
+      throw new IOException(message);
+    }
+
+    return new JsonParser()
+        .parse(AbstractElasticIndex.getContent(response)).getAsJsonObject().entrySet().stream()
+            .map(e -> e.getKey().replace(name, ""))
+            .collect(toList());
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
new file mode 100644
index 0000000..42b9110
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.AbstractVersionManager;
+import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.index.Schema;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.TreeMap;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class ElasticIndexVersionManager extends AbstractVersionManager
+    implements LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(ElasticIndexVersionManager.class);
+
+  private final String prefix;
+  private final ElasticIndexVersionDiscovery versionDiscovery;
+
+  @Inject
+  ElasticIndexVersionManager(
+      ElasticConfiguration cfg,
+      SitePaths sitePaths,
+      Collection<IndexDefinition<?, ?, ?>> defs,
+      ElasticIndexVersionDiscovery versionDiscovery) {
+    super(cfg.getConfig(), sitePaths, defs);
+    this.versionDiscovery = versionDiscovery;
+    prefix = cfg.prefix;
+  }
+
+  @Override
+  protected <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, Version<V>> versions = new TreeMap<>();
+    try {
+      List<String> discovered = versionDiscovery.discover(prefix, def.getName());
+      log.debug("Discovered versions for {}: {}", def.getName(), discovered);
+      for (String version : discovered) {
+        Integer v = Ints.tryParse(version);
+        if (v == null || version.length() != 4) {
+          log.warn("Unrecognized version in index {}: {}", def.getName(), version);
+          continue;
+        }
+        versions.put(v, new Version<>(null, v, true, cfg.getReady(def.getName(), v)));
+      }
+    } catch (IOException e) {
+      log.error("Error scanning index: " + def.getName(), e);
+    }
+
+    for (Schema<V> schema : def.getSchemas().values()) {
+      int v = schema.getVersion();
+      boolean exists = versions.containsKey(v);
+      versions.put(v, new Version<>(schema, v, exists, cfg.getReady(def.getName(), v)));
+    }
+    return versions;
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
new file mode 100644
index 0000000..9fcbaab
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -0,0 +1,119 @@
+// 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.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.Schema;
+import java.util.Map;
+
+class ElasticMapping {
+  static MappingProperties createMapping(Schema<?> schema, ElasticQueryAdapter adapter) {
+    ElasticMapping.Builder mapping = new ElasticMapping.Builder(adapter);
+    for (FieldDef<?, ?> field : schema.getFields().values()) {
+      String name = field.getName();
+      FieldType<?> fieldType = field.getType();
+      if (fieldType == FieldType.EXACT) {
+        mapping.addExactField(name);
+      } else if (fieldType == FieldType.TIMESTAMP) {
+        mapping.addTimestamp(name);
+      } else if (fieldType == FieldType.INTEGER
+          || fieldType == FieldType.INTEGER_RANGE
+          || fieldType == FieldType.LONG) {
+        mapping.addNumber(name);
+      } else if (fieldType == FieldType.FULL_TEXT) {
+        mapping.addStringWithAnalyzer(name);
+      } else if (fieldType == FieldType.PREFIX || fieldType == FieldType.STORED_ONLY) {
+        mapping.addString(name);
+      } else {
+        throw new IllegalStateException("Unsupported field type: " + fieldType.getName());
+      }
+    }
+    return mapping.build();
+  }
+
+  static class Builder {
+    private final ElasticQueryAdapter adapter;
+    private final ImmutableMap.Builder<String, FieldProperties> fields =
+        new ImmutableMap.Builder<>();
+
+    Builder(ElasticQueryAdapter adapter) {
+      this.adapter = adapter;
+    }
+
+    MappingProperties build() {
+      MappingProperties properties = new MappingProperties();
+      properties.properties = fields.build();
+      return properties;
+    }
+
+    Builder addExactField(String name) {
+      FieldProperties key = new FieldProperties(adapter.exactFieldType());
+      key.index = adapter.indexProperty();
+      FieldProperties properties;
+      properties = new FieldProperties(adapter.exactFieldType());
+      properties.fields = ImmutableMap.of("key", key);
+      fields.put(name, properties);
+      return this;
+    }
+
+    Builder addTimestamp(String name) {
+      FieldProperties properties = new FieldProperties("date");
+      properties.type = "date";
+      properties.format = "dateOptionalTime";
+      fields.put(name, properties);
+      return this;
+    }
+
+    Builder addNumber(String name) {
+      fields.put(name, new FieldProperties("long"));
+      return this;
+    }
+
+    Builder addString(String name) {
+      fields.put(name, new FieldProperties(adapter.stringFieldType()));
+      return this;
+    }
+
+    Builder addStringWithAnalyzer(String name) {
+      FieldProperties key = new FieldProperties(adapter.stringFieldType());
+      key.analyzer = "custom_with_char_filter";
+      fields.put(name, key);
+      return this;
+    }
+
+    Builder add(String name, String type) {
+      fields.put(name, new FieldProperties(type));
+      return this;
+    }
+  }
+
+  static class MappingProperties {
+    Map<String, FieldProperties> properties;
+  }
+
+  static class FieldProperties {
+    String type;
+    String index;
+    String format;
+    String analyzer;
+    Map<String, FieldProperties> fields;
+
+    FieldProperties(String type) {
+      this.type = type;
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
new file mode 100644
index 0000000..b52499b
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gson.JsonObject;
+
+public class ElasticQueryAdapter {
+  static final String POST_V5_TYPE = "_doc";
+
+  private final boolean ignoreUnmapped;
+  private final boolean usePostV5Type;
+
+  private final String searchFilteringName;
+  private final String indicesExistParam;
+  private final String exactFieldType;
+  private final String stringFieldType;
+  private final String indexProperty;
+  private final String versionDiscoveryUrl;
+
+  ElasticQueryAdapter(ElasticVersion version) {
+    this.ignoreUnmapped = version == ElasticVersion.V2_4;
+    this.usePostV5Type = version.isV6();
+    this.versionDiscoveryUrl = version.isV6() ? "/%s*" : "/%s*/_aliases";
+
+    switch (version) {
+      case V5_6:
+      case V6_2:
+      case V6_3:
+      case V6_4:
+        this.searchFilteringName = "_source";
+        this.indicesExistParam = "?allow_no_indices=false";
+        this.exactFieldType = "keyword";
+        this.stringFieldType = "text";
+        this.indexProperty = "true";
+        break;
+      case V2_4:
+      default:
+        this.searchFilteringName = "fields";
+        this.indicesExistParam = "";
+        this.exactFieldType = "string";
+        this.stringFieldType = "string";
+        this.indexProperty = "not_analyzed";
+        break;
+    }
+  }
+
+  void setIgnoreUnmapped(JsonObject properties) {
+    if (ignoreUnmapped) {
+      properties.addProperty("ignore_unmapped", true);
+    }
+  }
+
+  public void setType(JsonObject properties, String type) {
+    if (!usePostV5Type) {
+      properties.addProperty("_type", type);
+    }
+  }
+
+  public String searchFilteringName() {
+    return searchFilteringName;
+  }
+
+  String indicesExistParam() {
+    return indicesExistParam;
+  }
+
+  String exactFieldType() {
+    return exactFieldType;
+  }
+
+  String stringFieldType() {
+    return stringFieldType;
+  }
+
+  String indexProperty() {
+    return indexProperty;
+  }
+
+  boolean usePostV5Type() {
+    return usePostV5Type;
+  }
+
+  String getType(String preV6Type) {
+    return usePostV5Type() ? POST_V5_TYPE : preV6Type;
+  }
+
+  String getVersionDiscoveryUrl(String name) {
+    return String.format(versionDiscoveryUrl, name);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
new file mode 100644
index 0000000..54b4ca9
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.builders.BoolQueryBuilder;
+import com.google.gerrit.elasticsearch.builders.QueryBuilder;
+import com.google.gerrit.elasticsearch.builders.QueryBuilders;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.IntegerRangePredicate;
+import com.google.gerrit.server.index.RegexPredicate;
+import com.google.gerrit.server.index.TimestampRangePredicate;
+import com.google.gerrit.server.query.AndPredicate;
+import com.google.gerrit.server.query.NotPredicate;
+import com.google.gerrit.server.query.OrPredicate;
+import com.google.gerrit.server.query.PostFilterPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.AfterPredicate;
+import java.time.Instant;
+
+public class ElasticQueryBuilder {
+
+  <T> QueryBuilder toQueryBuilder(Predicate<T> p) throws QueryParseException {
+    if (p instanceof AndPredicate) {
+      return and(p);
+    } else if (p instanceof OrPredicate) {
+      return or(p);
+    } else if (p instanceof NotPredicate) {
+      return not(p);
+    } else if (p instanceof IndexPredicate) {
+      return fieldQuery((IndexPredicate<T>) p);
+    } else if (p instanceof PostFilterPredicate) {
+      return QueryBuilders.matchAllQuery();
+    } else {
+      throw new QueryParseException("cannot create query for index: " + p);
+    }
+  }
+
+  private <T> BoolQueryBuilder and(Predicate<T> p) throws QueryParseException {
+    BoolQueryBuilder b = QueryBuilders.boolQuery();
+    for (Predicate<T> c : p.getChildren()) {
+      b.must(toQueryBuilder(c));
+    }
+    return b;
+  }
+
+  private <T> BoolQueryBuilder or(Predicate<T> p) throws QueryParseException {
+    BoolQueryBuilder q = QueryBuilders.boolQuery();
+    for (Predicate<T> c : p.getChildren()) {
+      q.should(toQueryBuilder(c));
+    }
+    return q;
+  }
+
+  private <T> QueryBuilder not(Predicate<T> p) throws QueryParseException {
+    Predicate<T> n = p.getChild(0);
+    if (n instanceof TimestampRangePredicate) {
+      return notTimestamp((TimestampRangePredicate<T>) n);
+    }
+
+    // Lucene does not support negation, start with all and subtract.
+    BoolQueryBuilder q = QueryBuilders.boolQuery();
+    q.must(QueryBuilders.matchAllQuery());
+    q.mustNot(toQueryBuilder(n));
+    return q;
+  }
+
+  private <T> QueryBuilder fieldQuery(IndexPredicate<T> p) throws QueryParseException {
+    FieldType<?> type = p.getType();
+    FieldDef<?, ?> field = p.getField();
+    String name = field.getName();
+    String value = p.getValue();
+
+    if (type == FieldType.INTEGER) {
+      // QueryBuilder encodes integer fields as prefix coded bits,
+      // which elasticsearch's queryString can't handle.
+      // Create integer terms with string representations instead.
+      return QueryBuilders.termQuery(name, value);
+    } else if (type == FieldType.INTEGER_RANGE) {
+      return intRangeQuery(p);
+    } else if (type == FieldType.TIMESTAMP) {
+      return timestampQuery(p);
+    } else if (type == FieldType.EXACT) {
+      return exactQuery(p);
+    } else if (type == FieldType.PREFIX) {
+      return QueryBuilders.matchPhrasePrefixQuery(name, value);
+    } else if (type == FieldType.FULL_TEXT) {
+      return QueryBuilders.matchPhraseQuery(name, value);
+    } else {
+      throw FieldType.badFieldType(p.getType());
+    }
+  }
+
+  private <T> QueryBuilder intRangeQuery(IndexPredicate<T> p) throws QueryParseException {
+    if (p instanceof IntegerRangePredicate) {
+      IntegerRangePredicate<T> r = (IntegerRangePredicate<T>) p;
+      int minimum = r.getMinimumValue();
+      int maximum = r.getMaximumValue();
+      if (minimum == maximum) {
+        // Just fall back to a standard integer query.
+        return QueryBuilders.termQuery(p.getField().getName(), minimum);
+      }
+      return QueryBuilders.rangeQuery(p.getField().getName()).gte(minimum).lte(maximum);
+    }
+    throw new QueryParseException("not an integer range: " + p);
+  }
+
+  private <T> QueryBuilder notTimestamp(TimestampRangePredicate<T> r) throws QueryParseException {
+    if (r.getMinTimestamp().getTime() == 0) {
+      return QueryBuilders.rangeQuery(r.getField().getName())
+          .gt(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
+    }
+    throw new QueryParseException("cannot negate: " + r);
+  }
+
+  private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException {
+    if (p instanceof TimestampRangePredicate) {
+      TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p;
+      if (p instanceof AfterPredicate) {
+        return QueryBuilders.rangeQuery(r.getField().getName())
+            .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()));
+      }
+      return QueryBuilders.rangeQuery(r.getField().getName())
+          .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()))
+          .lte(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
+    }
+    throw new QueryParseException("not a timestamp: " + p);
+  }
+
+  private <T> QueryBuilder exactQuery(IndexPredicate<T> p) {
+    String name = p.getField().getName();
+    String value = p.getValue();
+
+    if (value.isEmpty()) {
+      return new BoolQueryBuilder().mustNot(QueryBuilders.existsQuery(name));
+    } else if (p instanceof RegexPredicate) {
+      if (value.startsWith("^")) {
+        value = value.substring(1);
+      }
+      if (value.endsWith("$") && !value.endsWith("\\$") && !value.endsWith("\\\\$")) {
+        value = value.substring(0, value.length() - 1);
+      }
+      return QueryBuilders.regexpQuery(name + ".key", value);
+    } else {
+      return QueryBuilders.termQuery(name + ".key", value);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
new file mode 100644
index 0000000..9c1cf02
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -0,0 +1,149 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gson.JsonParser;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.apache.http.HttpStatus;
+import org.apache.http.StatusLine;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.CredentialsProvider;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.RestClient;
+import org.elasticsearch.client.RestClientBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+class ElasticRestClientProvider implements Provider<RestClient>, LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(ElasticRestClientProvider.class);
+
+  private final ElasticConfiguration cfg;
+
+  private volatile RestClient client;
+  private ElasticQueryAdapter adapter;
+
+  @Inject
+  ElasticRestClientProvider(ElasticConfiguration cfg) {
+    this.cfg = cfg;
+  }
+
+  public static LifecycleModule module() {
+    return new LifecycleModule() {
+      @Override
+      protected void configure() {
+        listener().to(ElasticRestClientProvider.class);
+      }
+    };
+  }
+
+  @Override
+  public RestClient get() {
+    if (client == null) {
+      synchronized (this) {
+        if (client == null) {
+          client = build();
+          ElasticVersion version = getVersion();
+          log.info("Elasticsearch integration version {}", version);
+          adapter = new ElasticQueryAdapter(version);
+        }
+      }
+    }
+    return client;
+  }
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {
+    if (client != null) {
+      try {
+        client.close();
+      } catch (IOException e) {
+        // Ignore. We can't do anything about it.
+      }
+    }
+  }
+
+  ElasticQueryAdapter adapter() {
+    get(); // Make sure we're connected
+    return adapter;
+  }
+
+  public static class FailedToGetVersion extends ElasticException {
+    private static final long serialVersionUID = 1L;
+    private static final String MESSAGE = "Failed to get Elasticsearch version";
+
+    FailedToGetVersion(StatusLine status) {
+      super(String.format("%s: %d %s", MESSAGE, status.getStatusCode(), status.getReasonPhrase()));
+    }
+
+    FailedToGetVersion(Throwable cause) {
+      super(MESSAGE, cause);
+    }
+  }
+
+  private ElasticVersion getVersion() throws ElasticException {
+    try {
+      Response response = client.performRequest(new Request("GET", "/"));
+      StatusLine statusLine = response.getStatusLine();
+      if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
+        throw new FailedToGetVersion(statusLine);
+      }
+      String version =
+          new JsonParser()
+              .parse(AbstractElasticIndex.getContent(response))
+              .getAsJsonObject()
+              .get("version")
+              .getAsJsonObject()
+              .get("number")
+              .getAsString();
+      log.info("Connected to Elasticsearch version {}", version);
+      return ElasticVersion.forVersion(version);
+    } catch (IOException e) {
+      throw new FailedToGetVersion(e);
+    }
+  }
+
+  private RestClient build() {
+    RestClientBuilder builder = RestClient.builder(cfg.getHosts());
+    builder.setMaxRetryTimeoutMillis(cfg.maxRetryTimeout);
+    setConfiguredCredentialsIfAny(builder);
+    return builder.build();
+  }
+
+  private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
+    String username = cfg.username;
+    String password = cfg.password;
+    if (username != null && password != null) {
+      CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+      credentialsProvider.setCredentials(
+          AuthScope.ANY, new UsernamePasswordCredentials(username, password));
+      builder.setHttpClientConfigCallback(
+          (HttpAsyncClientBuilder httpClientBuilder) ->
+              httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
new file mode 100644
index 0000000..6fd234d
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+
+class ElasticSetting {
+  /** The custom char mappings of "." to " " and "_" to " " in the form of UTF-8 */
+  private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
+      ImmutableMap.of("\\u002E", "\\u0020", "\\u005F", "\\u0020");
+
+  static SettingProperties createSetting() {
+    ElasticSetting.Builder settings = new ElasticSetting.Builder();
+    settings.addCharFilter();
+    settings.addAnalyzer();
+    return settings.build();
+  }
+
+  static class Builder {
+    private final ImmutableMap.Builder<String, FieldProperties> fields =
+        new ImmutableMap.Builder<>();
+
+    SettingProperties build() {
+      SettingProperties properties = new SettingProperties();
+      properties.analysis = fields.build();
+      return properties;
+    }
+
+    void addCharFilter() {
+      FieldProperties charMapping = new FieldProperties("mapping");
+      charMapping.mappings = getCustomCharMappings(CUSTOM_CHAR_MAPPING);
+
+      FieldProperties charFilter = new FieldProperties();
+      charFilter.customMapping = charMapping;
+      fields.put("char_filter", charFilter);
+    }
+
+    void addAnalyzer() {
+      FieldProperties customAnalyzer = new FieldProperties("custom");
+      customAnalyzer.tokenizer = "standard";
+      customAnalyzer.charFilter = new String[] {"custom_mapping"};
+      customAnalyzer.filter = new String[] {"lowercase"};
+
+      FieldProperties analyzer = new FieldProperties();
+      analyzer.customWithCharFilter = customAnalyzer;
+      fields.put("analyzer", analyzer);
+    }
+
+    private static String[] getCustomCharMappings(ImmutableMap<String, String> map) {
+      int mappingIndex = 0;
+      int numOfMappings = map.size();
+      String[] mapping = new String[numOfMappings];
+      for (Map.Entry<String, String> e : map.entrySet()) {
+        mapping[mappingIndex++] = e.getKey() + "=>" + e.getValue();
+      }
+      return mapping;
+    }
+  }
+
+  static class SettingProperties {
+    Map<String, FieldProperties> analysis;
+  }
+
+  static class FieldProperties {
+    String tokenizer;
+    String type;
+    String[] charFilter;
+    String[] filter;
+    String[] mappings;
+    FieldProperties customMapping;
+    FieldProperties customWithCharFilter;
+
+    FieldProperties() {}
+
+    FieldProperties(String type) {
+      this.type = type;
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
new file mode 100644
index 0000000..dfa5d21
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.common.base.Joiner;
+import java.util.regex.Pattern;
+
+public enum ElasticVersion {
+  V2_4("2.4.*"),
+  V5_6("5.6.*"),
+  V6_2("6.2.*"),
+  V6_3("6.3.*"),
+  V6_4("6.4.*");
+
+  private final String version;
+  private final Pattern pattern;
+
+  ElasticVersion(String version) {
+    this.version = version;
+    this.pattern = Pattern.compile(version);
+  }
+
+  public static class UnsupportedVersion extends ElasticException {
+    private static final long serialVersionUID = 1L;
+
+    UnsupportedVersion(String version) {
+      super(
+          String.format(
+              "Unsupported version: [%s]. Supported versions: %s", version, supportedVersions()));
+    }
+  }
+
+  public static ElasticVersion forVersion(String version) throws UnsupportedVersion {
+    for (ElasticVersion value : ElasticVersion.values()) {
+      if (value.pattern.matcher(version).matches()) {
+        return value;
+      }
+    }
+    throw new UnsupportedVersion(version);
+  }
+
+  public static String supportedVersions() {
+    return Joiner.on(", ").join(ElasticVersion.values());
+  }
+
+  public boolean isV6() {
+    return version.startsWith("6.");
+  }
+
+  @Override
+  public String toString() {
+    return version;
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
new file mode 100644
index 0000000..a204919
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A Query that matches documents matching boolean combinations of other queries.
+ *
+ * <p>A trimmed down version of org.elasticsearch.index.query.BoolQueryBuilder.
+ */
+public class BoolQueryBuilder extends QueryBuilder {
+
+  private final List<QueryBuilder> mustClauses = new ArrayList<>();
+
+  private final List<QueryBuilder> mustNotClauses = new ArrayList<>();
+
+  private final List<QueryBuilder> filterClauses = new ArrayList<>();
+
+  private final List<QueryBuilder> shouldClauses = new ArrayList<>();
+
+  /**
+   * Adds a query that <b>must</b> appear in the matching documents and will contribute to scoring.
+   */
+  public BoolQueryBuilder must(QueryBuilder queryBuilder) {
+    mustClauses.add(queryBuilder);
+    return this;
+  }
+
+  /**
+   * Adds a query that <b>must not</b> appear in the matching documents and will not contribute to
+   * scoring.
+   */
+  public BoolQueryBuilder mustNot(QueryBuilder queryBuilder) {
+    mustNotClauses.add(queryBuilder);
+    return this;
+  }
+
+  /**
+   * Adds a query that <i>should</i> appear in the matching documents. For a boolean query with no
+   * <tt>MUST</tt> clauses one or more <code>SHOULD</code> clauses must match a document for the
+   * BooleanQuery to match.
+   */
+  public BoolQueryBuilder should(QueryBuilder queryBuilder) {
+    shouldClauses.add(queryBuilder);
+    return this;
+  }
+
+  @Override
+  protected void doXContent(XContentBuilder builder) throws IOException {
+    builder.startObject("bool");
+    doXArrayContent("must", mustClauses, builder);
+    doXArrayContent("filter", filterClauses, builder);
+    doXArrayContent("must_not", mustNotClauses, builder);
+    doXArrayContent("should", shouldClauses, builder);
+    builder.endObject();
+  }
+
+  private void doXArrayContent(String field, List<QueryBuilder> clauses, XContentBuilder builder)
+      throws IOException {
+    if (clauses.isEmpty()) {
+      return;
+    }
+    if (clauses.size() == 1) {
+      builder.field(field);
+      clauses.get(0).toXContent(builder);
+    } else {
+      builder.startArray(field);
+      for (QueryBuilder clause : clauses) {
+        clause.toXContent(builder);
+      }
+      builder.endArray();
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
new file mode 100644
index 0000000..1b058d7
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+
+/**
+ * Constructs a query that only match on documents that the field has a value in them.
+ *
+ * <p>A trimmed down version of org.elasticsearch.index.query.ExistsQueryBuilder.
+ */
+class ExistsQueryBuilder extends QueryBuilder {
+
+  private final String name;
+
+  ExistsQueryBuilder(String name) {
+    this.name = name;
+  }
+
+  @Override
+  protected void doXContent(XContentBuilder builder) throws IOException {
+    builder.startObject("exists");
+    builder.field("field", name);
+    builder.endObject();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
new file mode 100644
index 0000000..a3b303c
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+
+/**
+ * A query that matches on all documents.
+ *
+ * <p>A trimmed down version of org.elasticsearch.index.query.MatchAllQueryBuilder.
+ */
+class MatchAllQueryBuilder extends QueryBuilder {
+
+  @Override
+  protected void doXContent(XContentBuilder builder) throws IOException {
+    builder.startObject("match_all");
+    builder.endObject();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
new file mode 100644
index 0000000..c0becd1
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+import java.util.Locale;
+
+/**
+ * Match query is a query that analyzes the text and constructs a query as the result of the
+ * analysis. It can construct different queries based on the type provided.
+ *
+ * <p>A trimmed down version of org.elasticsearch.index.query.MatchQueryBuilder.
+ */
+class MatchQueryBuilder extends QueryBuilder {
+
+  enum Type {
+    /** The text is analyzed and used as a phrase query. */
+    MATCH_PHRASE,
+    /** The text is analyzed and used in a phrase query, with the last term acting as a prefix. */
+    MATCH_PHRASE_PREFIX;
+
+    @Override
+    public String toString() {
+      return name().toLowerCase(Locale.US);
+    }
+  }
+
+  private final String name;
+
+  private final Object text;
+
+  private Type type;
+
+  /** Constructs a new text query. */
+  MatchQueryBuilder(String name, Object text) {
+    this.name = name;
+    this.text = text;
+  }
+
+  /** Sets the type of the text query. */
+  MatchQueryBuilder type(Type type) {
+    this.type = type;
+    return this;
+  }
+
+  @Override
+  protected void doXContent(XContentBuilder builder) throws IOException {
+    builder.startObject(type.toString()).field(name, text).endObject();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
new file mode 100644
index 0000000..d6f154e
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+
+/** A trimmed down version of org.elasticsearch.index.query.QueryBuilder. */
+public abstract class QueryBuilder {
+
+  protected QueryBuilder() {}
+
+  protected void toXContent(XContentBuilder builder) throws IOException {
+    builder.startObject();
+    doXContent(builder);
+    builder.endObject();
+  }
+
+  protected abstract void doXContent(XContentBuilder builder) throws IOException;
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
new file mode 100644
index 0000000..940146f
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+/**
+ * A static factory for simple "import static" usage.
+ *
+ * <p>A trimmed down version of org.elasticsearch.index.query.QueryBuilders.
+ */
+public abstract class QueryBuilders {
+
+  /** A query that match on all documents. */
+  public static MatchAllQueryBuilder matchAllQuery() {
+    return new MatchAllQueryBuilder();
+  }
+
+  /**
+   * Creates a text query with type "PHRASE" for the provided field name and text.
+   *
+   * @param name The field name.
+   * @param text The query text (to be analyzed).
+   */
+  public static MatchQueryBuilder matchPhraseQuery(String name, Object text) {
+    return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE);
+  }
+
+  /**
+   * Creates a match query with type "PHRASE_PREFIX" for the provided field name and text.
+   *
+   * @param name The field name.
+   * @param text The query text (to be analyzed).
+   */
+  public static MatchQueryBuilder matchPhrasePrefixQuery(String name, Object text) {
+    return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE_PREFIX);
+  }
+
+  /**
+   * A Query that matches documents containing a term.
+   *
+   * @param name The name of the field
+   * @param value The value of the term
+   */
+  public static TermQueryBuilder termQuery(String name, String value) {
+    return new TermQueryBuilder(name, value);
+  }
+
+  /**
+   * A Query that matches documents containing a term.
+   *
+   * @param name The name of the field
+   * @param value The value of the term
+   */
+  public static TermQueryBuilder termQuery(String name, int value) {
+    return new TermQueryBuilder(name, value);
+  }
+
+  /**
+   * A Query that matches documents within an range of terms.
+   *
+   * @param name The field name
+   */
+  public static RangeQueryBuilder rangeQuery(String name) {
+    return new RangeQueryBuilder(name);
+  }
+
+  /**
+   * A Query that matches documents containing terms with a specified regular expression.
+   *
+   * @param name The name of the field
+   * @param regexp The regular expression
+   */
+  public static RegexpQueryBuilder regexpQuery(String name, String regexp) {
+    return new RegexpQueryBuilder(name, regexp);
+  }
+
+  /** A Query that matches documents matching boolean combinations of other queries. */
+  public static BoolQueryBuilder boolQuery() {
+    return new BoolQueryBuilder();
+  }
+
+  /**
+   * A filter to filter only documents where a field exists in them.
+   *
+   * @param name The name of the field
+   */
+  public static ExistsQueryBuilder existsQuery(String name) {
+    return new ExistsQueryBuilder(name);
+  }
+
+  private QueryBuilders() {}
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
new file mode 100644
index 0000000..1cb5c82
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+
+/** A trimmed down and modified version of org.elasticsearch.action.support.QuerySourceBuilder. */
+class QuerySourceBuilder {
+
+  private final QueryBuilder queryBuilder;
+
+  QuerySourceBuilder(QueryBuilder queryBuilder) {
+    this.queryBuilder = queryBuilder;
+  }
+
+  void innerToXContent(XContentBuilder builder) throws IOException {
+    builder.field("query");
+    queryBuilder.toXContent(builder);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
new file mode 100644
index 0000000..32dbc0e
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+
+/**
+ * A Query that matches documents within an range of terms.
+ *
+ * <p>A trimmed down version of org.elasticsearch.index.query.RangeQueryBuilder.
+ */
+public class RangeQueryBuilder extends QueryBuilder {
+
+  private final String name;
+  private Object from;
+  private Object to;
+  private boolean includeLower = true;
+  private boolean includeUpper = true;
+
+  /**
+   * A Query that matches documents within an range of terms.
+   *
+   * @param name The field name
+   */
+  RangeQueryBuilder(String name) {
+    this.name = name;
+  }
+
+  /** The from part of the range query. Null indicates unbounded. */
+  public RangeQueryBuilder gt(Object from) {
+    this.from = from;
+    this.includeLower = false;
+    return this;
+  }
+
+  /** The from part of the range query. Null indicates unbounded. */
+  public RangeQueryBuilder gte(Object from) {
+    this.from = from;
+    this.includeLower = true;
+    return this;
+  }
+
+  /** The from part of the range query. Null indicates unbounded. */
+  public RangeQueryBuilder gte(int from) {
+    this.from = from;
+    this.includeLower = true;
+    return this;
+  }
+
+  /** The to part of the range query. Null indicates unbounded. */
+  public RangeQueryBuilder lte(Object to) {
+    this.to = to;
+    this.includeUpper = true;
+    return this;
+  }
+
+  /** The to part of the range query. Null indicates unbounded. */
+  public RangeQueryBuilder lte(int to) {
+    this.to = to;
+    this.includeUpper = true;
+    return this;
+  }
+
+  @Override
+  protected void doXContent(XContentBuilder builder) throws IOException {
+    builder.startObject("range");
+    builder.startObject(name);
+
+    builder.field("from", from);
+    builder.field("to", to);
+    builder.field("include_lower", includeLower);
+    builder.field("include_upper", includeUpper);
+
+    builder.endObject();
+    builder.endObject();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
new file mode 100644
index 0000000..b81ec20
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+
+/**
+ * A Query that does fuzzy matching for a specific value.
+ *
+ * <p>A trimmed down version of org.elasticsearch.index.query.RegexpQueryBuilder.
+ */
+class RegexpQueryBuilder extends QueryBuilder {
+
+  private final String name;
+  private final String regexp;
+
+  /**
+   * Constructs a new term query.
+   *
+   * @param name The name of the field
+   * @param regexp The regular expression
+   */
+  RegexpQueryBuilder(String name, String regexp) {
+    this.name = name;
+    this.regexp = regexp;
+  }
+
+  @Override
+  protected void doXContent(XContentBuilder builder) throws IOException {
+    builder.startObject("regexp");
+    builder.startObject(name);
+
+    builder.field("value", regexp);
+    builder.field("flags_value", 65535);
+
+    builder.endObject();
+    builder.endObject();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
new file mode 100644
index 0000000..35cbea9
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import com.google.gerrit.elasticsearch.ElasticQueryAdapter;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * A search source builder allowing to easily build search source.
+ *
+ * <p>A trimmed down and modified version of org.elasticsearch.search.builder.SearchSourceBuilder.
+ */
+public class SearchSourceBuilder {
+  private final ElasticQueryAdapter adapter;
+
+  private QuerySourceBuilder querySourceBuilder;
+
+  private int from = -1;
+
+  private int size = -1;
+
+  private List<String> fieldNames;
+
+  /** Constructs a new search source builder. */
+  public SearchSourceBuilder(ElasticQueryAdapter adapter) {
+    this.adapter = adapter;
+  }
+
+  /** Constructs a new search source builder with a search query. */
+  public SearchSourceBuilder query(QueryBuilder query) {
+    if (this.querySourceBuilder == null) {
+      this.querySourceBuilder = new QuerySourceBuilder(query);
+    }
+    return this;
+  }
+
+  /** From index to start the search from. Defaults to <tt>0</tt>. */
+  public SearchSourceBuilder from(int from) {
+    this.from = from;
+    return this;
+  }
+
+  /** The number of search hits to return. Defaults to <tt>10</tt>. */
+  public SearchSourceBuilder size(int size) {
+    this.size = size;
+    return this;
+  }
+
+  /**
+   * Sets the fields to load and return as part of the search request. If none are specified, the
+   * source of the document will be returned.
+   */
+  public SearchSourceBuilder fields(List<String> fields) {
+    this.fieldNames = fields;
+    return this;
+  }
+
+  @Override
+  public final String toString() {
+    try {
+      XContentBuilder builder = new XContentBuilder();
+      toXContent(builder);
+      return builder.string();
+    } catch (IOException ioe) {
+      return "";
+    }
+  }
+
+  private void toXContent(XContentBuilder builder) throws IOException {
+    builder.startObject();
+    innerToXContent(builder);
+    builder.endObject();
+  }
+
+  private void innerToXContent(XContentBuilder builder) throws IOException {
+    if (from != -1) {
+      builder.field("from", from);
+    }
+    if (size != -1) {
+      builder.field("size", size);
+    }
+
+    if (querySourceBuilder != null) {
+      querySourceBuilder.innerToXContent(builder);
+    }
+
+    if (fieldNames != null) {
+      if (fieldNames.size() == 1) {
+        builder.field(adapter.searchFilteringName(), fieldNames.get(0));
+      } else {
+        builder.startArray(adapter.searchFilteringName());
+        for (String fieldName : fieldNames) {
+          builder.value(fieldName);
+        }
+        builder.endArray();
+      }
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
new file mode 100644
index 0000000..2b407c6
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import java.io.IOException;
+
+/**
+ * A Query that matches documents containing a term.
+ *
+ * <p>A trimmed down version of org.elasticsearch.index.query.TermQueryBuilder.
+ */
+class TermQueryBuilder extends QueryBuilder {
+
+  private final String name;
+
+  private final Object value;
+
+  /**
+   * Constructs a new term query.
+   *
+   * @param name The name of the field
+   * @param value The value of the term
+   */
+  TermQueryBuilder(String name, String value) {
+    this(name, (Object) value);
+  }
+
+  /**
+   * Constructs a new term query.
+   *
+   * @param name The name of the field
+   * @param value The value of the term
+   */
+  TermQueryBuilder(String name, int value) {
+    this(name, (Object) value);
+  }
+
+  /**
+   * Constructs a new term query.
+   *
+   * @param name The name of the field
+   * @param value The value of the term
+   */
+  private TermQueryBuilder(String name, Object value) {
+    this.name = name;
+    this.value = value;
+  }
+
+  @Override
+  protected void doXContent(XContentBuilder builder) throws IOException {
+    builder.startObject("term");
+    builder.field(name, value);
+    builder.endObject();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
new file mode 100644
index 0000000..06427f1
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
+//
+// 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.elasticsearch.builders;
+
+import static java.time.format.DateTimeFormatter.ISO_INSTANT;
+
+import com.fasterxml.jackson.core.JsonEncoding;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.google.common.base.Charsets;
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.Date;
+
+/** A trimmed down and modified version of org.elasticsearch.common.xcontent.XContentBuilder. */
+public final class XContentBuilder implements Closeable {
+
+  private final JsonGenerator generator;
+
+  private final ByteArrayOutputStream bos = new ByteArrayOutputStream();
+
+  /**
+   * Constructs a new builder. Make sure to call {@link #close()} when the builder is done with.
+   * Inspired from org.elasticsearch.common.xcontent.json.JsonXContent static block.
+   */
+  public XContentBuilder() throws IOException {
+    JsonFactory jsonFactory = new JsonFactory();
+    jsonFactory.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
+    jsonFactory.configure(JsonGenerator.Feature.QUOTE_FIELD_NAMES, true);
+    jsonFactory.configure(JsonParser.Feature.ALLOW_COMMENTS, true);
+    jsonFactory.configure(
+        JsonFactory.Feature.FAIL_ON_SYMBOL_HASH_OVERFLOW,
+        false); // this trips on many mappings now...
+    this.generator = jsonFactory.createGenerator(bos, JsonEncoding.UTF8);
+  }
+
+  public XContentBuilder startObject(String name) throws IOException {
+    field(name);
+    startObject();
+    return this;
+  }
+
+  public XContentBuilder startObject() throws IOException {
+    generator.writeStartObject();
+    return this;
+  }
+
+  public XContentBuilder endObject() throws IOException {
+    generator.writeEndObject();
+    return this;
+  }
+
+  public void startArray(String name) throws IOException {
+    field(name);
+    startArray();
+  }
+
+  private void startArray() throws IOException {
+    generator.writeStartArray();
+  }
+
+  public void endArray() throws IOException {
+    generator.writeEndArray();
+  }
+
+  public XContentBuilder field(String name) throws IOException {
+    generator.writeFieldName(name);
+    return this;
+  }
+
+  public XContentBuilder field(String name, String value) throws IOException {
+    field(name);
+    generator.writeString(value);
+    return this;
+  }
+
+  public XContentBuilder field(String name, int value) throws IOException {
+    field(name);
+    generator.writeNumber(value);
+    return this;
+  }
+
+  public XContentBuilder field(String name, Iterable<?> value) throws IOException {
+    startArray(name);
+    for (Object o : value) {
+      value(o);
+    }
+    endArray();
+    return this;
+  }
+
+  public XContentBuilder field(String name, Object value) throws IOException {
+    field(name);
+    writeValue(value);
+    return this;
+  }
+
+  public XContentBuilder value(Object value) throws IOException {
+    writeValue(value);
+    return this;
+  }
+
+  public XContentBuilder field(String name, boolean value) throws IOException {
+    field(name);
+    generator.writeBoolean(value);
+    return this;
+  }
+
+  public XContentBuilder value(String value) throws IOException {
+    generator.writeString(value);
+    return this;
+  }
+
+  @Override
+  public void close() {
+    try {
+      generator.close();
+    } catch (IOException e) {
+      // ignore
+    }
+  }
+
+  /** Returns a string representation of the builder (only applicable for text based xcontent). */
+  public String string() {
+    close();
+    byte[] bytesArray = bos.toByteArray();
+    return new String(bytesArray, Charsets.UTF_8);
+  }
+
+  private void writeValue(Object value) throws IOException {
+    if (value == null) {
+      generator.writeNull();
+      return;
+    }
+    Class<?> type = value.getClass();
+    if (type == String.class) {
+      generator.writeString((String) value);
+    } else if (type == Integer.class) {
+      generator.writeNumber(((Integer) value));
+    } else if (type == byte[].class) {
+      generator.writeBinary((byte[]) value);
+    } else if (value instanceof Date) {
+      generator.writeString(ISO_INSTANT.format(((Date) value).toInstant()));
+    } else {
+      // if this is a "value" object, like enum, DistanceUnit, ..., just toString it
+      // yea, it can be misleading when toString a Java class, but really, jackson should be used in
+      // that case
+      generator.writeString(value.toString());
+      // throw new ElasticsearchIllegalArgumentException("type not supported for generic value
+      // conversion: " + type);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
new file mode 100644
index 0000000..7392d09
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch.bulk;
+
+import com.google.gerrit.elasticsearch.ElasticQueryAdapter;
+import com.google.gson.JsonObject;
+
+abstract class ActionRequest extends BulkRequest {
+
+  private final String action;
+  private final String id;
+  private final String index;
+  private final String type;
+  private final ElasticQueryAdapter adapter;
+
+  protected ActionRequest(
+      String action, String id, String index, String type, ElasticQueryAdapter adapter) {
+    this.action = action;
+    this.id = id;
+    this.index = index;
+    this.type = type;
+    this.adapter = adapter;
+  }
+
+  @Override
+  protected String getRequest() {
+    JsonObject properties = new JsonObject();
+    properties.addProperty("_id", id);
+    properties.addProperty("_index", index);
+    adapter.setType(properties, type);
+
+    JsonObject jsonAction = new JsonObject();
+    jsonAction.add(action, properties);
+    return jsonAction.toString() + System.lineSeparator();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
new file mode 100644
index 0000000..be5ad8d
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch.bulk;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public abstract class BulkRequest {
+
+  private final List<BulkRequest> requests = new ArrayList<>();
+
+  protected BulkRequest() {
+    add(this);
+  }
+
+  public BulkRequest add(BulkRequest request) {
+    requests.add(request);
+    return this;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder builder = new StringBuilder();
+    for (BulkRequest request : requests) {
+      builder.append(request.getRequest());
+    }
+    return builder.toString();
+  }
+
+  protected abstract String getRequest();
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
new file mode 100644
index 0000000..570d5a0
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch.bulk;
+
+import com.google.gerrit.elasticsearch.ElasticQueryAdapter;
+
+public class DeleteRequest extends ActionRequest {
+
+  public DeleteRequest(String id, String index, String type, ElasticQueryAdapter adapter) {
+    super("delete", id, index, type, adapter);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
new file mode 100644
index 0000000..c571a0e
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch.bulk;
+
+import com.google.gerrit.elasticsearch.ElasticQueryAdapter;
+
+public class IndexRequest extends ActionRequest {
+
+  public IndexRequest(String id, String index, String type, ElasticQueryAdapter adapter) {
+    super("index", id, index, type, adapter);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
new file mode 100644
index 0000000..84f6857
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch.bulk;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.elasticsearch.builders.XContentBuilder;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.Schema.Values;
+import java.io.IOException;
+
+public class UpdateRequest<V> extends BulkRequest {
+
+  private final FillArgs fillArgs;
+  private final Schema<V> schema;
+  private final V v;
+
+  public UpdateRequest(FillArgs fillArgs, Schema<V> schema, V v) {
+    this.fillArgs = fillArgs;
+    this.schema = schema;
+    this.v = v;
+  }
+
+  public UpdateRequest(Schema<V> schema, V v) {
+    this(null, schema, v);
+  }
+
+  @Override
+  protected String getRequest() {
+    try (XContentBuilder closeable = new XContentBuilder()) {
+      XContentBuilder builder = closeable.startObject();
+      for (Values<V> values : schema.buildFields(v, fillArgs)) {
+        String name = values.getField().getName();
+        if (values.getField().isRepeatable()) {
+          builder.field(
+              name,
+              Streams.stream(values.getValues())
+                  .filter(e -> shouldAddElement(e))
+                  .collect(toList()));
+        } else {
+          Object element = Iterables.getOnlyElement(values.getValues(), "");
+          if (shouldAddElement(element)) {
+            builder.field(name, element);
+          }
+        }
+      }
+      return builder.endObject().string() + System.lineSeparator();
+    } catch (IOException e) {
+      return e.toString();
+    }
+  }
+
+  private boolean shouldAddElement(Object element) {
+    return !(element instanceof String) || !((String) element).isEmpty();
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticContainer.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticContainer.java
new file mode 100644
index 0000000..9bdf4eb
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticContainer.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch.testing;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.elasticsearch.ElasticVersion;
+import java.util.Set;
+import org.apache.http.HttpHost;
+import org.junit.internal.AssumptionViolatedException;
+import org.testcontainers.containers.GenericContainer;
+
+/* Helper class for running ES integration tests in docker container */
+public class ElasticContainer<SELF extends ElasticContainer<SELF>> extends GenericContainer<SELF> {
+  private static final int ELASTICSEARCH_DEFAULT_PORT = 9200;
+
+  public static ElasticContainer<?> createAndStart(ElasticVersion version) {
+    // Assumption violation is not natively supported by Testcontainers.
+    // See https://github.com/testcontainers/testcontainers-java/issues/343
+    try {
+      ElasticContainer<?> container = new ElasticContainer<>(version);
+      container.start();
+      return container;
+    } catch (Throwable t) {
+      throw new AssumptionViolatedException("Unable to start container", t);
+    }
+  }
+
+  public static ElasticContainer<?> createAndStart() {
+    return createAndStart(ElasticVersion.V2_4);
+  }
+
+  private static String getImageName(ElasticVersion version) {
+    switch (version) {
+      case V2_4:
+        return "elasticsearch:2.4.6-alpine";
+      case V5_6:
+        return "docker.elastic.co/elasticsearch/elasticsearch:5.6.15";
+      case V6_2:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4";
+      case V6_3:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2";
+      case V6_4:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.3";
+    }
+    throw new IllegalStateException("No tests for version: " + version.name());
+  }
+
+  private ElasticContainer(ElasticVersion version) {
+    super(getImageName(version));
+  }
+
+  @Override
+  protected void configure() {
+    addExposedPort(ELASTICSEARCH_DEFAULT_PORT);
+
+    // https://github.com/docker-library/elasticsearch/issues/58
+    addEnv("-Ees.network.host", "0.0.0.0");
+  }
+
+  @Override
+  public Set<Integer> getLivenessCheckPortNumbers() {
+    return ImmutableSet.of(getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
+  }
+
+  public HttpHost getHttpHost() {
+    return new HttpHost(getContainerIpAddress(), getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticTestUtils.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticTestUtils.java
new file mode 100644
index 0000000..d2e0bc6
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticTestUtils.java
@@ -0,0 +1,62 @@
+// 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.elasticsearch.testing;
+
+import com.google.gerrit.elasticsearch.ElasticVersion;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.lib.Config;
+
+public final class ElasticTestUtils {
+  public static class ElasticNodeInfo {
+    public final int port;
+
+    public ElasticNodeInfo(int port) {
+      this.port = port;
+    }
+  }
+
+  public static void configure(Config config, int port, String prefix, ElasticVersion version) {
+    config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
+    config.setString("elasticsearch", null, "server", "http://localhost:" + port);
+    config.setString("elasticsearch", null, "prefix", prefix);
+    config.setInt("index", null, "maxLimit", 10000);
+    String password = version == ElasticVersion.V5_6 ? "changeme" : null;
+    if (password != null) {
+      config.setString("elasticsearch", null, "password", password);
+    }
+  }
+
+  public static void configure(Config config, int port, String prefix) {
+    configure(config, port, prefix, null);
+  }
+
+  public static void createAllIndexes(Injector injector) throws IOException {
+    Collection<IndexDefinition<?, ?, ?>> indexDefs =
+        injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
+    for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
+      indexDef.getIndexCollection().getSearchIndex().deleteAll();
+    }
+  }
+
+  private ElasticTestUtils() {
+    // hide default constructor
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
new file mode 100644
index 0000000..559b8c7
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_MAX_RETRY_TIMEOUT_MS;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_USERNAME;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_MAX_RETRY_TIMEOUT;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PASSWORD;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PREFIX;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_SERVER;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_USERNAME;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.MAX_RETRY_TIMEOUT_UNIT;
+import static com.google.gerrit.elasticsearch.ElasticConfiguration.SECTION_ELASTICSEARCH;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.ProvisionException;
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class ElasticConfigurationTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  @Test
+  public void singleServerNoOtherConfig() throws Exception {
+    Config cfg = newConfig();
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic:1234");
+    assertThat(esCfg.username).isNull();
+    assertThat(esCfg.password).isNull();
+    assertThat(esCfg.prefix).isEmpty();
+    assertThat(esCfg.maxRetryTimeout).isEqualTo(DEFAULT_MAX_RETRY_TIMEOUT_MS);
+  }
+
+  @Test
+  public void serverWithoutPortSpecified() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic:9200");
+  }
+
+  @Test
+  public void prefix() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PREFIX, "myprefix");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.prefix).isEqualTo("myprefix");
+  }
+
+  @Test
+  public void maxRetryTimeoutInDefaultUnit() {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_MAX_RETRY_TIMEOUT, "45000");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.maxRetryTimeout).isEqualTo(45000);
+  }
+
+  @Test
+  public void maxRetryTimeoutInOtherUnit() {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_MAX_RETRY_TIMEOUT, "45 s");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.maxRetryTimeout)
+        .isEqualTo(MAX_RETRY_TIMEOUT_UNIT.convert(45, TimeUnit.SECONDS));
+  }
+
+  @Test
+  public void withAuthentication() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_USERNAME, "myself");
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.username).isEqualTo("myself");
+    assertThat(esCfg.password).isEqualTo("s3kr3t");
+  }
+
+  @Test
+  public void withAuthenticationPasswordOnlyUsesDefaultUsername() throws Exception {
+    Config cfg = newConfig();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertThat(esCfg.username).isEqualTo(DEFAULT_USERNAME);
+    assertThat(esCfg.password).isEqualTo("s3kr3t");
+  }
+
+  @Test
+  public void multipleServers() throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(
+        SECTION_ELASTICSEARCH,
+        null,
+        KEY_SERVER,
+        ImmutableList.of("http://elastic1:1234", "http://elastic2:1234"));
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic1:1234", "http://elastic2:1234");
+  }
+
+  @Test
+  public void noServers() throws Exception {
+    assertProvisionException(new Config());
+  }
+
+  @Test
+  public void singleServerInvalid() throws Exception {
+    Config cfg = new Config();
+    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "foo");
+    assertProvisionException(cfg);
+  }
+
+  @Test
+  public void multipleServersIncludingInvalid() throws Exception {
+    Config cfg = new Config();
+    cfg.setStringList(
+        SECTION_ELASTICSEARCH, null, KEY_SERVER, ImmutableList.of("http://elastic1:1234", "foo"));
+    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
+    assertHosts(esCfg, "http://elastic1:1234");
+  }
+
+  private static Config newConfig() {
+    Config config = new Config();
+    config.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic:1234");
+    return config;
+  }
+
+  private void assertHosts(ElasticConfiguration cfg, Object... hostURIs) throws Exception {
+    assertThat(Arrays.asList(cfg.getHosts()).stream().map(h -> h.toURI()).collect(toList()))
+        .containsExactly(hostURIs);
+  }
+
+  private void assertProvisionException(Config cfg) throws Exception {
+    exception.expect(ProvisionException.class);
+    exception.expectMessage("No valid Elasticsearch servers configured");
+    new ElasticConfiguration(cfg);
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
new file mode 100644
index 0000000..9b0b71d
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticQueryAccountsTest extends AbstractQueryAccountsTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart();
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
new file mode 100644
index 0000000..4bfa075
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart();
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
new file mode 100644
index 0000000..4236a5b
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticQueryGroupsTest extends AbstractQueryGroupsTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart();
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
new file mode 100644
index 0000000..60657be
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV5QueryAccountsTest extends AbstractQueryAccountsTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
new file mode 100644
index 0000000..076fad9
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV5QueryChangesTest extends AbstractQueryChangesTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
new file mode 100644
index 0000000..d16a52a
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV5QueryGroupsTest extends AbstractQueryGroupsTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V5_6);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(
+        elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
new file mode 100644
index 0000000..b1e70b4
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV6QueryAccountsTest extends AbstractQueryAccountsTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
new file mode 100644
index 0000000..f2b4eff
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV6QueryChangesTest extends AbstractQueryChangesTest {
+
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
new file mode 100644
index 0000000..1cfca5e
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.testing.ElasticContainer;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
+import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV6QueryGroupsTest extends AbstractQueryGroupsTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer<?> container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  private String testName() {
+    return testName.getMethodName().toLowerCase() + "_";
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = testName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
new file mode 100644
index 0000000..b598a0a
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class ElasticVersionTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  @Test
+  public void supportedVersion() throws Exception {
+    assertThat(ElasticVersion.forVersion("2.4.0")).isEqualTo(ElasticVersion.V2_4);
+    assertThat(ElasticVersion.forVersion("2.4.6")).isEqualTo(ElasticVersion.V2_4);
+
+    assertThat(ElasticVersion.forVersion("5.6.0")).isEqualTo(ElasticVersion.V5_6);
+    assertThat(ElasticVersion.forVersion("5.6.11")).isEqualTo(ElasticVersion.V5_6);
+
+    assertThat(ElasticVersion.forVersion("6.2.0")).isEqualTo(ElasticVersion.V6_2);
+    assertThat(ElasticVersion.forVersion("6.2.4")).isEqualTo(ElasticVersion.V6_2);
+
+    assertThat(ElasticVersion.forVersion("6.3.0")).isEqualTo(ElasticVersion.V6_3);
+    assertThat(ElasticVersion.forVersion("6.3.2")).isEqualTo(ElasticVersion.V6_3);
+
+    assertThat(ElasticVersion.forVersion("6.4.0")).isEqualTo(ElasticVersion.V6_4);
+    assertThat(ElasticVersion.forVersion("6.4.1")).isEqualTo(ElasticVersion.V6_4);
+  }
+
+  @Test
+  public void unsupportedVersion() throws Exception {
+    exception.expect(ElasticVersion.UnsupportedVersion.class);
+    exception.expectMessage(
+        "Unsupported version: [4.0.0]. Supported versions: " + ElasticVersion.supportedVersions());
+    ElasticVersion.forVersion("4.0.0");
+  }
+
+  @Test
+  public void version6() throws Exception {
+    assertThat(ElasticVersion.V6_2.isV6()).isTrue();
+    assertThat(ElasticVersion.V6_3.isV6()).isTrue();
+    assertThat(ElasticVersion.V6_4.isV6()).isTrue();
+    assertThat(ElasticVersion.V5_6.isV6()).isFalse();
+  }
+}
diff --git a/gerrit-extension-api/BUCK b/gerrit-extension-api/BUCK
deleted file mode 100644
index 61cd406..0000000
--- a/gerrit-extension-api/BUCK
+++ /dev/null
@@ -1,89 +0,0 @@
-include_defs('//lib/JGIT_VERSION')
-include_defs('//lib/GUAVA_VERSION')
-
-SRC = 'src/main/java/com/google/gerrit/extensions/'
-SRCS = glob([SRC + '**/*.java'])
-
-EXT_API_SRCS = glob([SRC + 'client/*.java'])
-
-gwt_module(
-  name = 'client',
-  srcs = EXT_API_SRCS,
-  gwt_xml = SRC + 'Extensions.gwt.xml',
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'client-lib',
-  srcs = EXT_API_SRCS,
-  resources = EXT_API_SRCS + glob([SRC + 'Extensions.gwt.xml']),
-  visibility = ['PUBLIC'],
-)
-
-java_binary(
-  name = 'extension-api',
-  deps = [':lib'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'lib',
-  exported_deps = [
-    ':api',
-    '//lib:guava',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib:servlet-api-3_1',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'api',
-  srcs = glob([SRC + '**/*.java']),
-  deps = [
-    '//gerrit-common:annotations',
-  ],
-  provided_deps = [
-    '//lib:guava',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_sources(
-  name = 'extension-api-src',
-  srcs = SRCS,
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'api_tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':api',
-    '//lib:truth',
-    '//lib/guice:guice',
-  ],
-  source_under_test = [':api'],
-)
-
-java_doc(
-  name = 'extension-api-javadoc',
-  title = 'Gerrit Review Extension API Documentation',
-  pkgs = ['com.google.gerrit.extensions'],
-  paths = ['src/main/java'],
-  srcs = SRCS,
-  deps = [
-    '//lib:guava',
-    '//lib/guice:javax-inject',
-    '//lib/guice:guice_library',
-    '//lib/guice:guice-assistedinject',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//gerrit-common:annotations',
-  ],
-  visibility = ['PUBLIC'],
-  external_docs = [JGIT_DOC_URL, GUAVA_DOC_URL],
-)
diff --git a/gerrit-extension-api/BUILD b/gerrit-extension-api/BUILD
index 4a5cfe3..f71e2ee 100644
--- a/gerrit-extension-api/BUILD
+++ b/gerrit-extension-api/BUILD
@@ -1,46 +1,73 @@
-load('//tools/bzl:gwt.bzl', 'gwt_module')
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+load("//lib:guava.bzl", "GUAVA_DOC_URL")
+load("//lib/jgit:jgit.bzl", "JGIT_DOC_URL")
+load("//tools/bzl:gwt.bzl", "gwt_module")
+load("//tools/bzl:javadoc.bzl", "java_doc")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
-SRC = 'src/main/java/com/google/gerrit/extensions/'
-SRCS = glob([SRC + '**/*.java'])
+SRC = "src/main/java/com/google/gerrit/extensions/"
 
-EXT_API_SRCS = glob([SRC + 'client/*.java'])
+EXT_API_SRCS = glob([SRC + "client/*.java"])
 
 gwt_module(
-  name = 'client',
-  srcs = EXT_API_SRCS,
-  gwt_xml = SRC + 'Extensions.gwt.xml',
-  visibility = ['//visibility:public'],
+    name = "client",
+    srcs = EXT_API_SRCS,
+    gwt_xml = SRC + "Extensions.gwt.xml",
+    visibility = ["//visibility:public"],
 )
 
 java_binary(
-  name = 'extension-api',
-  main_class = 'Dummy',
-  runtime_deps = [':lib'],
-  visibility = ['//visibility:public'],
+    name = "extension-api",
+    main_class = "Dummy",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":lib"],
 )
 
 java_library(
-  name = 'lib',
-  exports = [
-    ':api',
-    '//lib:guava',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib:servlet-api-3_1',
-  ],
-  visibility = ['//visibility:public'],
+    name = "lib",
+    visibility = ["//visibility:public"],
+    exports = [
+        ":api",
+        "//lib:guava",
+        "//lib:servlet-api-3_1",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+    ],
 )
 
 #TODO(davido): There is no provided_deps argument to java_library rule
 java_library(
-  name = 'api',
-  srcs = glob([SRC + '**/*.java']),
-  deps = [
-    '//gerrit-common:annotations',
-    '//lib:guava',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-  ],
-  visibility = ['//visibility:public'],
+    name = "api",
+    srcs = glob([SRC + "**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-common:annotations",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+    ],
+)
+
+junit_tests(
+    name = "api_tests",
+    srcs = glob(["src/test/java/**/*Test.java"]),
+    deps = [
+        ":api",
+        "//gerrit-test-util:test_util",
+        "//lib:truth",
+        "//lib/guice",
+    ],
+)
+
+java_doc(
+    name = "extension-api-javadoc",
+    external_docs = [
+        JGIT_DOC_URL,
+        GUAVA_DOC_URL,
+    ],
+    libs = [":api"],
+    pkgs = ["com.google.gerrit.extensions"],
+    title = "Gerrit Review Extension API Documentation",
+    visibility = ["//visibility:public"],
 )
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 083e644..d1e940e 100644
--- a/gerrit-extension-api/pom.xml
+++ b/gerrit-extension-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>2.13.14</version>
+  <version>2.14.23-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -23,7 +23,10 @@
 
   <developers>
     <developer>
-      <name>Andrew Bonventre</name>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -38,16 +41,28 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
-      <name>Saša Živkov</name>
+      <name>Ole Rehmsen</name>
     </developer>
     <developer>
-      <name>Shawn Pearce</name>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
     </developer>
   </developers>
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/CapabilityScope.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/CapabilityScope.java
index ee5a6d5..cd4a830 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/CapabilityScope.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/CapabilityScope.java
@@ -19,12 +19,12 @@
   /**
    * Scope is assumed based on the context.
    *
-   * When {@code @RequiresCapability} is used within a plugin the scope of the
-   * capability is assumed to be that plugin.
+   * <p>When {@code @RequiresCapability} is used within a plugin the scope of the capability is
+   * assumed to be that plugin.
    *
-   * If {@code @RequiresCapability} is used within the core Gerrit Code Review
-   * server (and thus is outside of a plugin) the scope is the core server and
-   * will use {@code com.google.gerrit.common.data.GlobalCapability}.
+   * <p>If {@code @RequiresCapability} is used within the core Gerrit Code Review server (and thus
+   * is outside of a plugin) the scope is the core server and will use {@code
+   * com.google.gerrit.common.data.GlobalCapability}.
    */
   CONTEXT,
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Export.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Export.java
index 8cf743a..9badc87 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Export.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Export.java
@@ -17,27 +17,25 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
 /**
  * Annotation applied to auto-registered, exported types.
- * <p>
- * Plugins or extensions using auto-registration should apply this annotation to
- * any non-abstract class they want exported for access.
- * <p>
- * For SSH commands the {@literal @Export} annotation names the subcommand:
+ *
+ * <p>Plugins or extensions using auto-registration should apply this annotation to any non-abstract
+ * class they want exported for access.
+ *
+ * <p>For SSH commands the {@literal @Export} annotation names the subcommand:
  *
  * <pre>
  *   {@literal @Export("print")}
  *   class MyCommand extends SshCommand {
  * </pre>
  *
- * For HTTP servlets, the {@literal @Export} annotation names the URL the
- * servlet is bound to, relative to the plugin or extension's namespace within
- * the Gerrit container.
+ * For HTTP servlets, the {@literal @Export} annotation names the URL the servlet is bound to,
+ * relative to the plugin or extension's namespace within the Gerrit container.
  *
  * <pre>
  *  {@literal @Export("/index.html")}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
index c48bcfb..05fd5b2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
@@ -21,6 +21,5 @@
     return new ExportImpl(name);
   }
 
-  private Exports() {
-  }
+  private Exports() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/ExtensionPoint.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/ExtensionPoint.java
index 4799f5e..7bb1203 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/ExtensionPoint.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/ExtensionPoint.java
@@ -18,24 +18,22 @@
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
 /**
  * Annotation for interfaces that accept auto-registered implementations.
- * <p>
- * Interfaces that accept automatically registered implementations into their
- * {@link DynamicSet} must be tagged with this annotation.
- * <p>
- * Plugins or extensions that implement an {@code @ExtensionPoint} interface
- * should use the {@link Listen} annotation to automatically register.
+ *
+ * <p>Interfaces that accept automatically registered implementations into their {@link DynamicSet}
+ * must be tagged with this annotation.
+ *
+ * <p>Plugins or extensions that implement an {@code @ExtensionPoint} interface should use the
+ * {@link Listen} annotation to automatically register.
  *
  * @see Listen
  */
 @Target({ElementType.TYPE})
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface ExtensionPoint {
-}
+public @interface ExtensionPoint {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Listen.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Listen.java
index e4ba931..2dc0c91 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Listen.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Listen.java
@@ -17,23 +17,21 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
 /**
  * Annotation for auto-registered extension point implementations.
- * <p>
- * Plugins or extensions using auto-registration should apply this annotation to
- * any non-abstract class that implements an unnamed extension point, such as a
- * notification listener. Gerrit will automatically determine which extension
- * points to apply based on the interfaces the type implements.
+ *
+ * <p>Plugins or extensions using auto-registration should apply this annotation to any non-abstract
+ * class that implements an unnamed extension point, such as a notification listener. Gerrit will
+ * automatically determine which extension points to apply based on the interfaces the type
+ * implements.
  *
  * @see Export
  */
 @Target({ElementType.TYPE})
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface Listen {
-}
+public @interface Listen {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginCanonicalWebUrl.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginCanonicalWebUrl.java
index 90295c8..539ff4f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginCanonicalWebUrl.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginCanonicalWebUrl.java
@@ -17,16 +17,15 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
 /**
  * Annotation applied to a String containing the plugin canonical web URL.
- * <p>
- * A plugin or extension may receive this string by Guice injection to discover
- * the canonical web URL under which the plugin is available:
+ *
+ * <p>A plugin or extension may receive this string by Guice injection to discover the canonical web
+ * URL under which the plugin is available:
  *
  * <pre>
  *  {@literal @Inject}
@@ -38,5 +37,4 @@
 @Target({ElementType.PARAMETER, ElementType.FIELD})
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface PluginCanonicalWebUrl {
-}
+public @interface PluginCanonicalWebUrl {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginData.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginData.java
index 4893beff..be9a9eb 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginData.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginData.java
@@ -17,18 +17,17 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
  * Local path where a plugin can store its own private data.
- * <p>
- * A plugin or extension may receive this string by Guice injection to discover
- * a directory where it can store configuration or other data that is private:
- * <p>
- * This binding is on both {@link java.io.File} and {@link java.nio.file.Path},
- * pointing to the same location. The {@code File} version should be considered
- * deprecated and may be removed in a future version.
+ *
+ * <p>A plugin or extension may receive this string by Guice injection to discover a directory where
+ * it can store configuration or other data that is private:
+ *
+ * <p>This binding is on both {@link java.io.File} and {@link java.nio.file.Path}, pointing to the
+ * same location. The {@code File} version should be considered deprecated and may be removed in a
+ * future version.
  *
  * <pre>
  * {@literal @Inject}
@@ -39,5 +38,4 @@
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface PluginData {
-}
+public @interface PluginData {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginName.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginName.java
index efdd3c6c..8934605 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginName.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/PluginName.java
@@ -17,16 +17,15 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
 /**
  * Annotation applied to a String containing the plugin or extension name.
- * <p>
- * A plugin or extension may receive this string by Guice injection to discover
- * the name that an administrator has installed the plugin or extension under:
+ *
+ * <p>A plugin or extension may receive this string by Guice injection to discover the name that an
+ * administrator has installed the plugin or extension under:
  *
  * <pre>
  *  {@literal @Inject}
@@ -38,5 +37,4 @@
 @Target({ElementType.PARAMETER, ElementType.FIELD})
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface PluginName {
-}
+public @interface PluginName {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
index 8f97d77..f97abd9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
@@ -21,9 +21,9 @@
 import java.lang.annotation.Target;
 
 /**
- * Annotation on {@code com.google.gerrit.sshd.SshCommand} or
- * {@code com.google.gerrit.httpd.restapi.RestApiServlet} declaring a set of
- * capabilities of which at least one must be granted.
+ * Annotation on {@code com.google.gerrit.sshd.SshCommand} or {@code
+ * com.google.gerrit.httpd.restapi.RestApiServlet} declaring a set of capabilities of which at least
+ * one must be granted.
  */
 @Target({ElementType.TYPE})
 @Retention(RUNTIME)
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java
index 511ae0c..7717c84 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java
@@ -21,9 +21,8 @@
 import java.lang.annotation.Target;
 
 /**
- * Annotation on {@code com.google.gerrit.sshd.SshCommand} or
- * {@code com.google.gerrit.httpd.restapi.RestApiServlet} declaring a
- * capability must be granted.
+ * Annotation on {@code com.google.gerrit.sshd.SshCommand} or {@code
+ * com.google.gerrit.httpd.restapi.RestApiServlet} declaring a capability must be granted.
  */
 @Target({ElementType.TYPE})
 @Retention(RUNTIME)
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RootRelative.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RootRelative.java
index a812b53..392cd0e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RootRelative.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RootRelative.java
@@ -17,21 +17,17 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
 /**
- * Annotation applied to HttpServletRequest and HttpServletResponse
- * when they are inherited from Gerrit instead of being injected by
- * a plugin's ServletModule.  This means that the path returned by
- * 'javax.servlet.http.HttpServletRequest#getPathInfo()' is
- * relative to the Gerrit root instead of a path within the plugin's
- * URL space.
+ * Annotation applied to HttpServletRequest and HttpServletResponse when they are inherited from
+ * Gerrit instead of being injected by a plugin's ServletModule. This means that the path returned
+ * by 'javax.servlet.http.HttpServletRequest#getPathInfo()' is relative to the Gerrit root instead
+ * of a path within the plugin's URL space.
  */
 @Target({ElementType.PARAMETER, ElementType.FIELD})
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface RootRelative {
-}
+public @interface RootRelative {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
index c2e3787..8dcc49d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/GerritApi.java
@@ -23,15 +23,19 @@
 
 public interface GerritApi {
   Accounts accounts();
+
   Changes changes();
+
   Config config();
+
   Groups groups();
+
   Projects projects();
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements GerritApi {
     @Override
     public Accounts accounts() {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
index 3274313a..995c664 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.api.access;
 
 import com.google.gerrit.extensions.common.ProjectInfo;
-
 import java.util.Map;
 import java.util.Set;
 
@@ -27,5 +26,6 @@
   public Set<String> ownerOf;
   public Boolean canUpload;
   public Boolean canAdd;
+  public Boolean canAddTags;
   public Boolean configVisible;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 8f7f93c..b88097c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -19,14 +19,15 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.List;
 import java.util.Map;
 import java.util.SortedSet;
@@ -34,52 +35,74 @@
 public interface AccountApi {
   AccountInfo get() throws RestApiException;
 
+  boolean getActive() throws RestApiException;
+
+  void setActive(boolean active) throws RestApiException;
+
   String getAvatarUrl(int size) throws RestApiException;
 
   GeneralPreferencesInfo getPreferences() throws RestApiException;
-  GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in)
-      throws RestApiException;
+
+  GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException;
 
   DiffPreferencesInfo getDiffPreferences() throws RestApiException;
-  DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in)
-      throws RestApiException;
+
+  DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException;
 
   EditPreferencesInfo getEditPreferences() throws RestApiException;
-  EditPreferencesInfo setEditPreferences(EditPreferencesInfo in)
-      throws RestApiException;
+
+  EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException;
 
   List<ProjectWatchInfo> getWatchedProjects() throws RestApiException;
-  List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in)
-      throws RestApiException;
-  void deleteWatchedProjects(List<ProjectWatchInfo> in)
-      throws RestApiException;
+
+  List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException;
+
+  void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException;
 
   void starChange(String changeId) throws RestApiException;
+
   void unstarChange(String changeId) throws RestApiException;
+
   void setStars(String changeId, StarsInput input) throws RestApiException;
+
   SortedSet<String> getStars(String changeId) throws RestApiException;
+
   List<ChangeInfo> getStarredChanges() throws RestApiException;
 
+  List<EmailInfo> getEmails() throws RestApiException;
+
   void addEmail(EmailInput input) throws RestApiException;
 
+  void deleteEmail(String email) throws RestApiException;
+
+  void setStatus(String status) throws RestApiException;
+
   List<SshKeyInfo> listSshKeys() throws RestApiException;
+
   SshKeyInfo addSshKey(String key) throws RestApiException;
+
   void deleteSshKey(int seq) throws RestApiException;
 
   Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException;
-  Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> remove)
-      throws RestApiException;
+
+  Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> remove) throws RestApiException;
+
   GpgKeyApi gpgKey(String id) throws RestApiException;
 
   List<AgreementInfo> listAgreements() throws RestApiException;
+
   void signAgreement(String agreementName) throws RestApiException;
 
   void index() throws RestApiException;
 
+  List<AccountExternalIdInfo> getExternalIds() throws RestApiException;
+
+  void deleteExternalIds(List<String> externalIds) throws RestApiException;
+
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements AccountApi {
     @Override
     public AccountInfo get() throws RestApiException {
@@ -87,6 +110,16 @@
     }
 
     @Override
+    public boolean getActive() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setActive(boolean active) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public String getAvatarUrl(int size) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -108,8 +141,7 @@
     }
 
     @Override
-    public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in)
-        throws RestApiException {
+    public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -119,26 +151,23 @@
     }
 
     @Override
-    public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in)
+    public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in)
         throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public List<ProjectWatchInfo> getWatchedProjects()
-        throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public List<ProjectWatchInfo> setWatchedProjects(
-        List<ProjectWatchInfo> in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public void deleteWatchedProjects(List<ProjectWatchInfo> in)
-        throws RestApiException {
+    public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -153,8 +182,7 @@
     }
 
     @Override
-    public void setStars(String changeId, StarsInput input)
-        throws RestApiException {
+    public void setStars(String changeId, StarsInput input) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -169,11 +197,26 @@
     }
 
     @Override
+    public List<EmailInfo> getEmails() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void addEmail(EmailInput input) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public void deleteEmail(String email) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setStatus(String status) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public List<SshKeyInfo> listSshKeys() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -189,8 +232,8 @@
     }
 
     @Override
-    public Map<String, GpgKeyInfo> putGpgKeys(List<String> add,
-        List<String> remove) throws RestApiException {
+    public Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> remove)
+        throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -218,5 +261,15 @@
     public void index() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteExternalIds(List<String> externalIds) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
index 33baf93..259838b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/AccountInput.java
@@ -15,12 +15,10 @@
 package com.google.gerrit.extensions.api.accounts;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
-
 import java.util.List;
 
 public class AccountInput {
-  @DefaultInput
-  public String username;
+  @DefaultInput public String username;
   public String name;
   public String email;
   public String sshKey;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
index a697091..e92d229 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/Accounts.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.List;
@@ -26,23 +25,19 @@
 public interface Accounts {
   /**
    * Look up an account by ID.
-   * <p>
-   * <strong>Note:</strong> This method eagerly reads the account. Methods that
-   * mutate the account do not necessarily re-read the account. Therefore, calling
-   * a getter method on an instance after calling a mutation method on that same
-   * instance is not guaranteed to reflect the mutation. It is not recommended
-   * to store references to {@code AccountApi} instances.
    *
-   * @param id any identifier supported by the REST API, including numeric ID,
-   *     email, or username.
+   * <p><strong>Note:</strong> This method eagerly reads the account. Methods that mutate the
+   * account do not necessarily re-read the account. Therefore, calling a getter method on an
+   * instance after calling a mutation method on that same instance is not guaranteed to reflect the
+   * mutation. It is not recommended to store references to {@code AccountApi} instances.
+   *
+   * @param id any identifier supported by the REST API, including numeric ID, email, or username.
    * @return API for accessing the account.
    * @throws RestApiException if an error occurred.
    */
   AccountApi id(String id) throws RestApiException;
 
-  /**
-   * @see #id(String)
-   */
+  /** @see #id(String) */
   AccountApi id(int id) throws RestApiException;
 
   /**
@@ -60,9 +55,8 @@
 
   /**
    * Suggest users for a given query.
-   * <p>
-   * Example code:
-   * {@code suggestAccounts().withQuery("Reviewer").withLimit(5).get()}
+   *
+   * <p>Example code: {@code suggestAccounts().withQuery("Reviewer").withLimit(5).get()}
    *
    * @return API for setting parameters and getting result.
    */
@@ -70,36 +64,33 @@
 
   /**
    * Suggest users for a given query.
-   * <p>
-   * Shortcut API for {@code suggestAccounts().withQuery(String)}.
+   *
+   * <p>Shortcut API for {@code suggestAccounts().withQuery(String)}.
    *
    * @see #suggestAccounts()
    */
-  SuggestAccountsRequest suggestAccounts(String query)
-    throws RestApiException;
+  SuggestAccountsRequest suggestAccounts(String query) throws RestApiException;
 
   /**
-   * Queries users.
-   * <p>
-   * Example code:
-   * {@code query().withQuery("name:John email:example.com").withLimit(5).get()}
+   * Query users.
+   *
+   * <p>Example code: {@code query().withQuery("name:John email:example.com").withLimit(5).get()}
    *
    * @return API for setting parameters and getting result.
    */
   QueryRequest query() throws RestApiException;
 
   /**
-   * Queries users.
-   * <p>
-   * Shortcut API for {@code query().withQuery(String)}.
+   * Query users.
+   *
+   * <p>Shortcut API for {@code query().withQuery(String)}.
    *
    * @see #query()
    */
   QueryRequest query(String query) throws RestApiException;
 
   /**
-   * API for setting parameters and getting result.
-   * Used for {@code suggestAccounts()}.
+   * API for setting parameters and getting result. Used for {@code suggestAccounts()}.
    *
    * @see #suggestAccounts()
    */
@@ -107,9 +98,7 @@
     private String query;
     private int limit;
 
-    /**
-     * Executes query and returns a list of accounts.
-     */
+    /** Execute query and return a list of accounts. */
     public abstract List<AccountInfo> get() throws RestApiException;
 
     /**
@@ -123,8 +112,7 @@
     }
 
     /**
-     * Set limit for returned list of accounts.
-     * Optional; server-default is used when not provided.
+     * Set limit for returned list of accounts. Optional; server-default is used when not provided.
      */
     public SuggestAccountsRequest withLimit(int limit) {
       this.limit = limit;
@@ -141,8 +129,7 @@
   }
 
   /**
-   * API for setting parameters and getting result.
-   * Used for {@code query()}.
+   * API for setting parameters and getting result. Used for {@code query()}.
    *
    * @see #query()
    */
@@ -150,12 +137,9 @@
     private String query;
     private int limit;
     private int start;
-    private EnumSet<ListAccountsOption> options =
-        EnumSet.noneOf(ListAccountsOption.class);
+    private EnumSet<ListAccountsOption> options = EnumSet.noneOf(ListAccountsOption.class);
 
-    /**
-     * Executes query and returns a list of accounts.
-     */
+    /** Execute query and return a list of accounts. */
     public abstract List<AccountInfo> get() throws RestApiException;
 
     /**
@@ -169,18 +153,14 @@
     }
 
     /**
-     * Set limit for returned list of accounts.
-     * Optional; server-default is used when not provided.
+     * Set limit for returned list of accounts. Optional; server-default is used when not provided.
      */
     public QueryRequest withLimit(int limit) {
       this.limit = limit;
       return this;
     }
 
-    /**
-     * Set number of accounts to skip.
-     * Optional; no accounts are skipped when not provided.
-     */
+    /** Set number of accounts to skip. Optional; no accounts are skipped when not provided. */
     public QueryRequest withStart(int start) {
       this.start = start;
       return this;
@@ -219,9 +199,9 @@
   }
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements Accounts {
     @Override
     public AccountApi id(String id) throws RestApiException {
@@ -254,8 +234,7 @@
     }
 
     @Override
-    public SuggestAccountsRequest suggestAccounts(String query)
-      throws RestApiException {
+    public SuggestAccountsRequest suggestAccounts(String query) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/EmailInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/EmailInput.java
index 5036731..9d6b3c5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/EmailInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/EmailInput.java
@@ -19,8 +19,7 @@
 /** This entity contains information for registering a new email address. */
 public class EmailInput {
   /* The email address. If provided, must match the email address from the URL. */
-  @DefaultInput
-  public String email;
+  @DefaultInput public String email;
 
   /* Whether the new email address should become the preferred email address of
    * the user. Only supported if {@link #noConfirmation} is set or if the
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
index 6f87e8b..6757a05 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
@@ -20,11 +20,12 @@
 
 public interface GpgKeyApi {
   GpgKeyInfo get() throws RestApiException;
+
   void delete() throws RestApiException;
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
    */
   class NotImplemented implements GpgKeyApi {
     @Override
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
index 34726a8..b3ba1e2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
 
 public class AbandonInput {
-  @DefaultInput
-  public String message;
+  @DefaultInput public String message;
   public NotifyHandling notify = NotifyHandling.ALL;
+  public Map<RecipientType, NotifyInfo> notifyDetails;
 }
-
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java
new file mode 100644
index 0000000..c120b78
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ActionVisitor.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+
+/**
+ * Extension point called during population of {@link ActionInfo} maps.
+ *
+ * <p>Each visitor may mutate the input {@link ActionInfo}, or filter it out of the map entirely.
+ * When multiple extensions are registered, the order in which they are executed is undefined.
+ */
+@ExtensionPoint
+public interface ActionVisitor {
+  /**
+   * Visit a change-level action.
+   *
+   * <p>Callers may mutate the input {@link ActionInfo}, or return false to omit the action from the
+   * map entirely. Inputs other than the {@link ActionInfo} should be considered immutable.
+   *
+   * @param name name of the action, as a key into the {@link ActionInfo} map returned by the REST
+   *     API.
+   * @param actionInfo action being visited; caller may mutate.
+   * @param changeInfo information about the change to which this action belongs; caller should
+   *     treat as immutable.
+   * @return true if the action should remain in the map, or false to omit it.
+   */
+  boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo);
+
+  /**
+   * Visit a revision-level action.
+   *
+   * <p>Callers may mutate the input {@link ActionInfo}, or return false to omit the action from the
+   * map entirely. Inputs other than the {@link ActionInfo} should be considered immutable.
+   *
+   * @param name name of the action, as a key into the {@link ActionInfo} map returned by the REST
+   *     API.
+   * @param actionInfo action being visited; caller may mutate.
+   * @param changeInfo information about the change to which this action belongs; caller should
+   *     treat as immutable.
+   * @param revisionInfo information about the revision to which this action belongs; caller should
+   *     treat as immutable.
+   * @return true if the action should remain in the map, or false to omit it.
+   */
+  boolean visit(
+      String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
index 4c535d4..bc8b28a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
@@ -18,13 +18,14 @@
 
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
 
 public class AddReviewerInput {
-  @DefaultInput
-  public String reviewer;
+  @DefaultInput public String reviewer;
   public Boolean confirmed;
   public ReviewerState state;
   public NotifyHandling notify;
+  public Map<RecipientType, NotifyInfo> notifyDetails;
 
   public boolean confirmed() {
     return (confirmed != null) ? confirmed : false;
@@ -33,4 +34,4 @@
   public ReviewerState state() {
     return (state != null) ? state : REVIEWER;
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
index 10f74ff84..a23281a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
@@ -16,46 +16,33 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.AccountInfo;
-
 import java.util.List;
 
-/**
- * Result object representing the outcome of a request to add a reviewer.
- */
+/** Result object representing the outcome of a request to add a reviewer. */
 public class AddReviewerResult {
-  /**
-   * The identifier of an account or group that was to be added as a reviewer.
-   */
+  /** The identifier of an account or group that was to be added as a reviewer. */
   public String input;
 
-  /**
-   * If non-null, a string describing why the reviewer could not be added.
-   */
-  @Nullable
-  public String error;
+  /** If non-null, a string describing why the reviewer could not be added. */
+  @Nullable public String error;
 
   /**
-   * Non-null and true if the reviewer cannot be added without explicit
-   * confirmation. This may be the case for groups of a certain size.
+   * Non-null and true if the reviewer cannot be added without explicit confirmation. This may be
+   * the case for groups of a certain size.
    */
-  @Nullable
-  public Boolean confirm;
+  @Nullable public Boolean confirm;
 
   /**
-   * List of individual reviewers added to the change. The size of this
-   * list may be greater than one (e.g. when a group is added). Null if no
-   * reviewers were added.
+   * List of individual reviewers added to the change. The size of this list may be greater than one
+   * (e.g. when a group is added). Null if no reviewers were added.
    */
-  @Nullable
-  public List<ReviewerInfo> reviewers;
+  @Nullable public List<ReviewerInfo> reviewers;
 
   /**
-   * List of accounts CCed on the change. The size of this list may be
-   * greater than one (e.g. when a group is CCed). Null if no accounts were CCed
-   * or if reviewers is non-null.
+   * List of accounts CCed on the change. The size of this list may be greater than one (e.g. when a
+   * group is CCed). Null if no accounts were CCed or if reviewers is non-null.
    */
-  @Nullable
-  public List<AccountInfo> ccs;
+  @Nullable public List<AccountInfo> ccs;
 
   /**
    * Constructs a partially initialized result for the given reviewer.
@@ -86,4 +73,4 @@
     this(reviewer);
     this.confirm = confirm;
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
new file mode 100644
index 0000000..e17e1c9
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class AssigneeInput {
+  @DefaultInput public String assignee;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index f656c2d..8c1ebf3 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -15,13 +15,15 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
@@ -32,12 +34,11 @@
 
   /**
    * Look up the current revision for the change.
-   * <p>
-   * <strong>Note:</strong> This method eagerly reads the revision. Methods that
-   * mutate the revision do not necessarily re-read the revision. Therefore,
-   * calling a getter method on an instance after calling a mutation method on
-   * that same instance is not guaranteed to reflect the mutation. It is not
-   * recommended to store references to {@code RevisionApi} instances.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the revision. Methods that mutate the
+   * revision do not necessarily re-read the revision. Therefore, calling a getter method on an
+   * instance after calling a mutation method on that same instance is not guaranteed to reflect the
+   * mutation. It is not recommended to store references to {@code RevisionApi} instances.
    *
    * @return API for accessing the revision.
    * @throws RestApiException if an error occurred.
@@ -60,24 +61,28 @@
 
   /**
    * Look up the reviewer of the change.
+   *
    * <p>
-   * @param id ID of the account, can be a string of the format
-   *     "Full Name &lt;mail@example.com&gt;", just the email address, a full name
-   *     if it is unique, an account ID, a user name or 'self' for the
-   *     calling user.
+   *
+   * @param id ID of the account, can be a string of the format "Full Name
+   *     &lt;mail@example.com&gt;", just the email address, a full name if it is unique, an account
+   *     ID, a user name or 'self' for the calling user.
    * @return API for accessing the reviewer.
-   * @throws RestApiException if id is not account ID or is a user that isn't
-   *     known to be a reviewer for this change.
+   * @throws RestApiException if id is not account ID or is a user that isn't known to be a reviewer
+   *     for this change.
    */
   ReviewerApi reviewer(String id) throws RestApiException;
 
   void abandon() throws RestApiException;
+
   void abandon(AbandonInput in) throws RestApiException;
 
   void restore() throws RestApiException;
+
   void restore(RestoreInput in) throws RestApiException;
 
   void move(String destination) throws RestApiException;
+
   void move(MoveInput in) throws RestApiException;
 
   /**
@@ -94,27 +99,42 @@
    */
   ChangeApi revert(RevertInput in) throws RestApiException;
 
-  List<ChangeInfo> submittedTogether() throws RestApiException;
-  SubmittedTogetherInfo submittedTogether(
-      EnumSet<SubmittedTogetherOption> options) throws RestApiException;
+  /** Create a merge patch set for the change. */
+  ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
-  /**
-   * Publishes a draft change.
-   */
+  List<ChangeInfo> submittedTogether() throws RestApiException;
+
+  SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
+      throws RestApiException;
+
+  SubmittedTogetherInfo submittedTogether(
+      EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
+      throws RestApiException;
+
+  /** Publishes a draft change. */
   void publish() throws RestApiException;
 
-  /**
-   * Deletes a draft change.
-   */
+  /** Rebase the current revision of a change using default options. */
+  void rebase() throws RestApiException;
+
+  /** Rebase the current revision of a change. */
+  void rebase(RebaseInput in) throws RestApiException;
+
+  /** Deletes a change. */
   void delete() throws RestApiException;
 
   String topic() throws RestApiException;
+
   void topic(String topic) throws RestApiException;
 
+  IncludedInInfo includedIn() throws RestApiException;
+
   void addReviewer(AddReviewerInput in) throws RestApiException;
+
   void addReviewer(String in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
+
   SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException;
 
   ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException;
@@ -123,41 +143,82 @@
   ChangeInfo get() throws RestApiException;
   /** {@code get} with {@link ListChangesOption} set to none. */
   ChangeInfo info() throws RestApiException;
-  /** Retrieve change edit when exists. */
+
+  /**
+   * Retrieve change edit when exists.
+   *
+   * @deprecated Replaced by {@link ChangeApi#edit()} in combination with {@link
+   *     ChangeEditApi#get()}.
+   */
+  @Deprecated
   EditInfo getEdit() throws RestApiException;
 
   /**
-   * Set hashtags on a change
-   **/
+   * Provides access to an API regarding the change edit of this change.
+   *
+   * @return a {@code ChangeEditApi} for the change edit of this change
+   * @throws RestApiException if the API isn't accessible
+   */
+  ChangeEditApi edit() throws RestApiException;
+
+  /** Set hashtags on a change */
   void setHashtags(HashtagsInput input) throws RestApiException;
 
   /**
    * Get hashtags on a change.
+   *
    * @return hashtags
    * @throws RestApiException
    */
   Set<String> getHashtags() throws RestApiException;
 
+  /** Set the assignee of a change. */
+  AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
+
+  /** Get the assignee of a change. */
+  AccountInfo getAssignee() throws RestApiException;
+
+  /** Get all past assignees. */
+  List<AccountInfo> getPastAssignees() throws RestApiException;
+
+  /**
+   * Delete the assignee of a change.
+   *
+   * @return the assignee that was deleted, or null if there was no assignee.
+   */
+  AccountInfo deleteAssignee() throws RestApiException;
+
   /**
    * Get all published comments on a change.
    *
-   * @return comments in a map keyed by path; comments have the {@code revision}
-   *     field set to indicate their patch set.
+   * @return comments in a map keyed by path; comments have the {@code revision} field set to
+   *     indicate their patch set.
    * @throws RestApiException
    */
   Map<String, List<CommentInfo>> comments() throws RestApiException;
 
   /**
+   * Get all robot comments on a change.
+   *
+   * @return robot comments in a map keyed by path; robot comments have the {@code revision} field
+   *     set to indicate their patch set.
+   * @throws RestApiException
+   */
+  Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException;
+
+  /**
    * Get all draft comments for the current user on a change.
    *
-   * @return drafts in a map keyed by path; comments have the {@code revision}
-   *     field set to indicate their patch set.
+   * @return drafts in a map keyed by path; comments have the {@code revision} field set to indicate
+   *     their patch set.
    * @throws RestApiException
    */
   Map<String, List<CommentInfo>> drafts() throws RestApiException;
 
   ChangeInfo check() throws RestApiException;
+
   ChangeInfo check(FixInput fix) throws RestApiException;
+
   void index() throws RestApiException;
 
   abstract class SuggestedReviewersRequest {
@@ -186,9 +247,9 @@
   }
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements ChangeApi {
     @Override
     public String id() {
@@ -261,6 +322,16 @@
     }
 
     @Override
+    public void rebase() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void rebase(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -276,6 +347,11 @@
     }
 
     @Override
+    public IncludedInInfo includedIn() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void addReviewer(AddReviewerInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -316,6 +392,11 @@
     }
 
     @Override
+    public ChangeEditApi edit() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void setHashtags(HashtagsInput input) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -326,11 +407,36 @@
     }
 
     @Override
+    public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountInfo getAssignee() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<AccountInfo> getPastAssignees() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccountInfo deleteAssignee() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> comments() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -356,8 +462,19 @@
     }
 
     @Override
+    public SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public SubmittedTogetherInfo submittedTogether(
-        EnumSet<SubmittedTogetherOption> options) throws RestApiException {
+        EnumSet<ListChangesOption> a, EnumSet<SubmittedTogetherOption> b) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
       throw new NotImplementedException();
     }
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
new file mode 100644
index 0000000..9d0275a
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
@@ -0,0 +1,223 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Optional;
+
+/**
+ * An API for the change edit of a change. A change edit is similar to a patch set and will become
+ * one if it is published (by {@link #publish(PublishChangeEditInput)}). Whenever the descriptions
+ * below refer to files of a change edit, they actually refer to the files of the Git tree which is
+ * represented by the change edit. A change can have at most one change edit at each point in time.
+ */
+public interface ChangeEditApi {
+
+  /**
+   * Retrieves details regarding the change edit.
+   *
+   * @return an {@code Optional} containing details about the change edit if it exists, or {@code
+   *     Optional.empty()}
+   * @throws RestApiException if the change edit couldn't be retrieved
+   */
+  Optional<EditInfo> get() throws RestApiException;
+
+  /**
+   * Creates a new change edit. It has exactly the same Git tree as the current patch set of the
+   * change.
+   *
+   * @throws RestApiException if the change edit couldn't be created or a change edit already exists
+   */
+  void create() throws RestApiException;
+
+  /**
+   * Deletes the change edit.
+   *
+   * @throws RestApiException if the change edit couldn't be deleted or a change edit wasn't present
+   */
+  void delete() throws RestApiException;
+
+  /**
+   * Rebases the change edit on top of the latest patch set of this change.
+   *
+   * @throws RestApiException if the change edit couldn't be rebased or a change edit wasn't present
+   */
+  void rebase() throws RestApiException;
+
+  /**
+   * Publishes the change edit using default settings. See {@link #publish(PublishChangeEditInput)}
+   * for more details.
+   *
+   * @throws RestApiException if the change edit couldn't be published or a change edit wasn't
+   *     present
+   */
+  void publish() throws RestApiException;
+
+  /**
+   * Publishes the change edit. Publishing means that the change edit is turned into a regular patch
+   * set of the change.
+   *
+   * @param publishChangeEditInput a {@code PublishChangeEditInput} specifying the options which
+   *     should be applied
+   * @throws RestApiException if the change edit couldn't be published or a change edit wasn't
+   *     present
+   */
+  void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException;
+
+  /**
+   * Retrieves the contents of the specified file from the change edit.
+   *
+   * @param filePath the path of the file
+   * @return an {@code Optional} containing the contents of the file as a {@code BinaryResult} if
+   *     the file exists within the change edit, or {@code Optional.empty()}
+   * @throws RestApiException if the contents of the file couldn't be retrieved or a change edit
+   *     wasn't present
+   */
+  Optional<BinaryResult> getFile(String filePath) throws RestApiException;
+
+  /**
+   * Renames a file of the change edit or moves the file to another directory. If the change edit
+   * doesn't exist, it will be created based on the current patch set of the change.
+   *
+   * @param oldFilePath the current file path
+   * @param newFilePath the desired file path
+   * @throws RestApiException if the file couldn't be renamed
+   */
+  void renameFile(String oldFilePath, String newFilePath) throws RestApiException;
+
+  /**
+   * Restores a file of the change edit to the state in which it was before the patch set on which
+   * the change edit is based. This includes the file content as well as the existence or
+   * non-existence of the file. If the change edit doesn't exist, it will be created based on the
+   * current patch set of the change.
+   *
+   * @param filePath the path of the file
+   * @throws RestApiException if the file couldn't be restored to its previous state
+   */
+  void restoreFile(String filePath) throws RestApiException;
+
+  /**
+   * Modify the contents of the specified file of the change edit. If no content is provided, the
+   * content of the file is erased but the file isn't deleted. If the change edit doesn't exist, it
+   * will be created based on the current patch set of the change.
+   *
+   * @param filePath the path of the file which should be modified
+   * @param newContent the desired content of the file
+   * @throws RestApiException if the content of the file couldn't be modified
+   */
+  void modifyFile(String filePath, RawInput newContent) throws RestApiException;
+
+  /**
+   * Deletes the specified file from the change edit. If the change edit doesn't exist, it will be
+   * created based on the current patch set of the change.
+   *
+   * @param filePath the path fo the file which should be deleted
+   * @throws RestApiException if the file couldn't be deleted
+   */
+  void deleteFile(String filePath) throws RestApiException;
+
+  /**
+   * Retrieves the commit message of the change edit.
+   *
+   * @return the commit message of the change edit
+   * @throws RestApiException if the commit message couldn't be retrieved or a change edit wasn't
+   *     present
+   */
+  String getCommitMessage() throws RestApiException;
+
+  /**
+   * Modifies the commit message of the change edit. If the change edit doesn't exist, it will be
+   * created based on the current patch set of the change.
+   *
+   * @param newCommitMessage the desired commit message
+   * @throws RestApiException if the commit message couldn't be modified
+   */
+  void modifyCommitMessage(String newCommitMessage) throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements ChangeEditApi {
+    @Override
+    public Optional<EditInfo> get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void create() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void rebase() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void publish() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public Optional<BinaryResult> getFile(String filePath) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void renameFile(String oldFilePath, String newFilePath) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void restoreFile(String filePath) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void modifyFile(String filePath, RawInput newContent) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteFile(String filePath) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String getCommitMessage() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void modifyCommitMessage(String newCommitMessage) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java
index aa67473..d14ddfe 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.List;
@@ -27,12 +26,11 @@
 public interface Changes {
   /**
    * Look up a change by numeric ID.
-   * <p>
-   * <strong>Note:</strong> This method eagerly reads the change. Methods that
-   * mutate the change do not necessarily re-read the change. Therefore, calling
-   * a getter method on an instance after calling a mutation method on that same
-   * instance is not guaranteed to reflect the mutation. It is not recommended
-   * to store references to {@code ChangeApi} instances.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the change. Methods that mutate the change
+   * do not necessarily re-read the change. Therefore, calling a getter method on an instance after
+   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
+   * is not recommended to store references to {@code ChangeApi} instances.
    *
    * @param id change number.
    * @return API for accessing the change.
@@ -44,8 +42,8 @@
    * Look up a change by string ID.
    *
    * @see #id(int)
-   * @param id any identifier supported by the REST API, including change
-   *     number, Change-Id, or project~branch~Change-Id triplet.
+   * @param id any identifier supported by the REST API, including change number, Change-Id, or
+   *     project~branch~Change-Id triplet.
    * @return API for accessing the change.
    * @throws RestApiException if an error occurred.
    */
@@ -56,12 +54,12 @@
    *
    * @see #id(int)
    */
-  ChangeApi id(String project, String branch, String id)
-      throws RestApiException;
+  ChangeApi id(String project, String branch, String id) throws RestApiException;
 
   ChangeApi create(ChangeInput in) throws RestApiException;
 
   QueryRequest query();
+
   QueryRequest query(String query);
 
   abstract class QueryRequest {
@@ -120,9 +118,7 @@
 
     @Override
     public String toString() {
-      StringBuilder sb =  new StringBuilder(getClass().getSimpleName())
-          .append('{')
-          .append(query);
+      StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append('{').append(query);
       if (limit != 0) {
         sb.append(", limit=").append(limit);
       }
@@ -137,9 +133,9 @@
   }
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements Changes {
     @Override
     public ChangeApi id(int id) throws RestApiException {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 7ae7ef1..2e1bb13 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -17,4 +17,5 @@
 public class CherryPickInput {
   public String message;
   public String destination;
+  public Integer parent;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
index adac284..a6d64a6 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
@@ -22,9 +22,9 @@
   CommentInfo get() throws RestApiException;
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements CommentApi {
     @Override
     public CommentInfo get() throws RestApiException {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
new file mode 100644
index 0000000..34f550b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import java.util.Map;
+
+/** Input passed to {@code DELETE /changes/[id]/reviewers/[id]}. */
+public class DeleteReviewerInput {
+  /** Who to send email notifications to after the reviewer is deleted. */
+  public NotifyHandling notify = NotifyHandling.ALL;
+
+  public Map<RecipientType, NotifyInfo> notifyDetails;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
index 671f43e..ee10a1d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -15,12 +15,14 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
 
 /** Input passed to {@code DELETE /changes/[id]/reviewers/[id]/votes/[label]}. */
 public class DeleteVoteInput {
-  @DefaultInput
-  public String label;
+  @DefaultInput public String label;
 
   /** Who to send email notifications to after vote is deleted. */
   public NotifyHandling notify = NotifyHandling.ALL;
+
+  public Map<RecipientType, NotifyInfo> notifyDetails;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java
index 50335db..fa663a5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftApi.java
@@ -20,14 +20,14 @@
 
 public interface DraftApi extends CommentApi {
   CommentInfo update(DraftInput in) throws RestApiException;
+
   void delete() throws RestApiException;
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
-  class NotImplemented extends CommentApi.NotImplemented
-      implements DraftApi {
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented extends CommentApi.NotImplemented implements DraftApi {
     @Override
     public CommentInfo update(DraftInput in) throws RestApiException {
       throw new NotImplementedException();
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
index 9d94f50..b3c2786 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.extensions.client.Comment;
-
 import java.util.Objects;
 
 public class DraftInput extends Comment {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
index 2536c46..e2bd074 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
@@ -23,25 +23,18 @@
 public interface FileApi {
   BinaryResult content() throws RestApiException;
 
-  /**
-   * Diff against the revision's parent version of the file.
-   */
+  /** Diff against the revision's parent version of the file. */
   DiffInfo diff() throws RestApiException;
 
-  /**
-   * @param base revision id of the revision to be used as the
-   * diff base
-   */
+  /** @param base revision id of the revision to be used as the diff base */
   DiffInfo diff(String base) throws RestApiException;
 
-  /**
-   * @param parent 1-based parent number to diff against
-   */
+  /** @param parent 1-based parent number to diff against */
   DiffInfo diff(int parent) throws RestApiException;
 
   /**
-   * Creates a request to retrieve the diff. On the returned request formatting
-   * options for the diff can be set.
+   * Creates a request to retrieve the diff. On the returned request formatting options for the diff
+   * can be set.
    */
   DiffRequest diffRequest() throws RestApiException;
 
@@ -91,9 +84,9 @@
   }
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements FileApi {
     @Override
     public BinaryResult content() throws RestApiException {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
index c007161..8f66f12 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
@@ -15,16 +15,13 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
-
 import java.util.Set;
 
 public class HashtagsInput {
-  @DefaultInput
-  public Set<String> add;
+  @DefaultInput public Set<String> add;
   public Set<String> remove;
 
-  public HashtagsInput(){
-  }
+  public HashtagsInput() {}
 
   public HashtagsInput(Set<String> add) {
     this.add = add;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
new file mode 100644
index 0000000..d876034
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/IncludedInInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.extensions.api.changes;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+public class IncludedInInfo {
+  public List<String> branches;
+  public List<String> tags;
+  public Map<String, Collection<String>> external;
+
+  public IncludedInInfo(
+      List<String> branches, List<String> tags, Map<String, Collection<String>> external) {
+    this.branches = branches;
+    this.tags = tags;
+    this.external = external;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
index 888e6bf..98ef31c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
@@ -15,5 +15,8 @@
 package com.google.gerrit.extensions.api.changes;
 
 public enum NotifyHandling {
-  NONE, OWNER, OWNER_REVIEWERS, ALL
+  NONE,
+  OWNER,
+  OWNER_REVIEWERS,
+  ALL
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
new file mode 100644
index 0000000..ef49651
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import java.util.List;
+
+/** Detailed information about who should be notified about an update. */
+public class NotifyInfo {
+  public List<String> accounts;
+
+  public NotifyInfo(List<String> accounts) {
+    this.accounts = accounts;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java
new file mode 100644
index 0000000..acf6ceb
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/PublishChangeEditInput.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import java.util.Map;
+
+/** Input passed to {@code POST /changes/[id]/edit:publish/}. */
+public class PublishChangeEditInput {
+  /** Who to send email notifications to after the change edit is published. */
+  public NotifyHandling notify = NotifyHandling.ALL;
+
+  public Map<RecipientType, NotifyInfo> notifyDetails;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RecipientType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RecipientType.java
new file mode 100644
index 0000000..3ddc597
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RecipientType.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+public enum RecipientType {
+  TO,
+  CC,
+  BCC
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RestoreInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RestoreInput.java
index a116dde..c328a04 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RestoreInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RestoreInput.java
@@ -17,7 +17,5 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
 public class RestoreInput {
-  @DefaultInput
-  public String message;
+  @DefaultInput public String message;
 }
-
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index 2c1c688..893472e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -17,6 +17,5 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
 public class RevertInput {
-  @DefaultInput
-  public String message;
+  @DefaultInput public String message;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index cbe16ed..0eb076e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -18,8 +18,8 @@
 
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.DefaultInput;
-
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -27,54 +27,51 @@
 
 /** Input passed to {@code POST /changes/[id]/revisions/[id]/review}. */
 public class ReviewInput {
-  @DefaultInput
-  public String message;
+  @DefaultInput public String message;
 
   public String tag;
 
   public Map<String, Short> labels;
   public Map<String, List<CommentInput>> comments;
+  public Map<String, List<RobotCommentInput>> robotComments;
 
   /**
-   * If true require all labels to be within the user's permitted ranges based
-   * on access controls, attempting to use a label not granted to the user
-   * will fail the entire modify operation early. If false the operation will
-   * execute anyway, but the proposed labels given by the user will be
-   * modified to be the "best" value allowed by the access controls, or
-   * ignored if the label does not exist.
+   * If true require all labels to be within the user's permitted ranges based on access controls,
+   * attempting to use a label not granted to the user will fail the entire modify operation early.
+   * If false the operation will execute anyway, but the proposed labels given by the user will be
+   * modified to be the "best" value allowed by the access controls, or ignored if the label does
+   * not exist.
    */
   public boolean strictLabels = true;
 
   /**
-   * How to process draft comments already in the database that were not also
-   * described in this input request.
+   * How to process draft comments already in the database that were not also described in this
+   * input request.
+   *
+   * <p>Defaults to DELETE, unless {@link #onBehalfOf} is set, in which case it defaults to KEEP and
+   * any other value is disallowed.
    */
-  public DraftHandling drafts = DraftHandling.DELETE;
+  public DraftHandling drafts;
 
   /** Who to send email notifications to after review is stored. */
   public NotifyHandling notify = NotifyHandling.ALL;
 
-  /**
-   * If true check to make sure that the comments being posted aren't already
-   * present.
-   */
+  public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  /** If true check to make sure that the comments being posted aren't already present. */
   public boolean omitDuplicateComments;
 
   /**
-   * Account ID, name, email address or username of another user. The review
-   * will be posted/updated on behalf of this named user instead of the
-   * caller. Caller must have the labelAs-$NAME permission granted for each
-   * label that appears in {@link #labels}. This is in addition to the named
-   * user also needing to have permission to use the labels.
-   * <p>
-   * {@link #strictLabels} impacts how labels is processed for the named user,
-   * not the caller.
+   * Account ID, name, email address or username of another user. The review will be posted/updated
+   * on behalf of this named user instead of the caller. Caller must have the labelAs-$NAME
+   * permission granted for each label that appears in {@link #labels}. This is in addition to the
+   * named user also needing to have permission to use the labels.
+   *
+   * <p>{@link #strictLabels} impacts how labels is processed for the named user, not the caller.
    */
   public String onBehalfOf;
 
-  /**
-   * Reviewers that should be added to this change.
-   */
+  /** Reviewers that should be added to this change. */
   public List<AddReviewerInput> reviewers;
 
   public enum DraftHandling {
@@ -91,7 +88,14 @@
     PUBLISH_ALL_REVISIONS
   }
 
-  public static class CommentInput extends Comment {
+  public static class CommentInput extends Comment {}
+
+  public static class RobotCommentInput extends CommentInput {
+    public String robotId;
+    public String robotRunId;
+    public String url;
+    public Map<String, String> properties;
+    public List<FixSuggestionInfo> fixSuggestions;
   }
 
   public ReviewInput message(String msg) {
@@ -125,8 +129,7 @@
     return reviewer(reviewer, REVIEWER, false);
   }
 
-  public ReviewInput reviewer(String reviewer, ReviewerState state,
-      boolean confirmed) {
+  public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
     AddReviewerInput input = new AddReviewerInput();
     input.reviewer = reviewer;
     input.state = state;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
index b9de2e1..d772924 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
@@ -15,24 +15,19 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.common.Nullable;
-
 import java.util.Map;
 
-/**
- * Result object representing the outcome of a review request.
- */
+/** Result object representing the outcome of a review request. */
 public class ReviewResult {
   /**
-   * Map of labels to values after the review was posted. Null if any
-   * reviewer additions were rejected.
+   * Map of labels to values after the review was posted. Null if any reviewer additions were
+   * rejected.
    */
-  @Nullable
-  public Map<String, Short> labels;
+  @Nullable public Map<String, Short> labels;
 
   /**
-   * Map of account or group identifier to outcome of adding as a reviewer.
-   * Null if no reviewer additions were requested.
+   * Map of account or group identifier to outcome of adding as a reviewer. Null if no reviewer
+   * additions were requested.
    */
-  @Nullable
-  public Map<String, AddReviewerResult> reviewers;
-}
\ No newline at end of file
+  @Nullable public Map<String, AddReviewerResult> reviewers;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
index d1f09e8..70e456d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
@@ -16,20 +16,24 @@
 
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.Map;
 
 public interface ReviewerApi {
 
   Map<String, Short> votes() throws RestApiException;
+
   void deleteVote(String label) throws RestApiException;
+
   void deleteVote(DeleteVoteInput input) throws RestApiException;
+
   void remove() throws RestApiException;
 
+  void remove(DeleteReviewerInput input) throws RestApiException;
+
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements ReviewerApi {
     @Override
     public Map<String, Short> votes() throws RestApiException {
@@ -50,5 +54,10 @@
     public void remove() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void remove(DeleteReviewerInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
index c81f8aa..af61481 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
@@ -16,19 +16,14 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.AccountInfo;
-
 import java.util.Map;
 
-/**
- * Account and approval details for an added reviewer.
- */
+/** Account and approval details for an added reviewer. */
 public class ReviewerInfo extends AccountInfo {
   /**
-   * {@link Map} of label name to initial value for each approval the reviewer
-   * is responsible for.
+   * {@link Map} of label name to initial value for each approval the reviewer is responsible for.
    */
-  @Nullable
-  public Map<String, String> approvals;
+  @Nullable public Map<String, String> approvals;
 
   public ReviewerInfo(Integer id) {
     super(id);
@@ -38,4 +33,4 @@
   public String toString() {
     return username;
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 2731476..f5f6fbf 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -17,13 +17,14 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -31,51 +32,112 @@
 public interface RevisionApi {
   void delete() throws RestApiException;
 
+  String description() throws RestApiException;
+
+  void description(String description) throws RestApiException;
+
   void review(ReviewInput in) throws RestApiException;
 
   void submit() throws RestApiException;
+
   void submit(SubmitInput in) throws RestApiException;
+
+  BinaryResult submitPreview() throws RestApiException;
+
+  BinaryResult submitPreview(String format) throws RestApiException;
+
   void publish() throws RestApiException;
+
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
+
   ChangeApi rebase() throws RestApiException;
+
   ChangeApi rebase(RebaseInput in) throws RestApiException;
+
   boolean canRebase() throws RestApiException;
 
+  RevisionReviewerApi reviewer(String id) throws RestApiException;
+
   void setReviewed(String path, boolean reviewed) throws RestApiException;
+
   Set<String> reviewed() throws RestApiException;
 
   Map<String, FileInfo> files() throws RestApiException;
+
   Map<String, FileInfo> files(String base) throws RestApiException;
+
   Map<String, FileInfo> files(int parentNum) throws RestApiException;
+
   FileApi file(String path);
+
   MergeableInfo mergeable() throws RestApiException;
+
   MergeableInfo mergeableOtherBranches() throws RestApiException;
 
   Map<String, List<CommentInfo>> comments() throws RestApiException;
+
+  Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException;
+
   Map<String, List<CommentInfo>> drafts() throws RestApiException;
 
   List<CommentInfo> commentsAsList() throws RestApiException;
+
   List<CommentInfo> draftsAsList() throws RestApiException;
 
+  List<RobotCommentInfo> robotCommentsAsList() throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
+
   DraftApi draft(String id) throws RestApiException;
 
   CommentApi comment(String id) throws RestApiException;
 
-  /**
-   * Returns patch of revision.
-   */
+  RobotCommentApi robotComment(String id) throws RestApiException;
+
+  String etag() throws RestApiException;
+
+  /** Returns patch of revision. */
   BinaryResult patch() throws RestApiException;
 
+  BinaryResult patch(String path) throws RestApiException;
+
   Map<String, ActionInfo> actions() throws RestApiException;
 
   SubmitType submitType() throws RestApiException;
+
   SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException;
 
+  MergeListRequest getMergeList() throws RestApiException;
+
+  abstract class MergeListRequest {
+    private boolean addLinks;
+    private int uninterestingParent = 1;
+
+    public abstract List<CommitInfo> get() throws RestApiException;
+
+    public MergeListRequest withLinks() {
+      this.addLinks = true;
+      return this;
+    }
+
+    public MergeListRequest withUninterestingParent(int uninterestingParent) {
+      this.uninterestingParent = uninterestingParent;
+      return this;
+    }
+
+    public boolean getAddLinks() {
+      return addLinks;
+    }
+
+    public int getUninterestingParent() {
+      return uninterestingParent;
+    }
+  }
+
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements RevisionApi {
     @Override
     public void delete() throws RestApiException {
@@ -118,7 +180,12 @@
     }
 
     @Override
-    public boolean canRebase() {
+    public boolean canRebase() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public RevisionReviewerApi reviewer(String id) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -168,6 +235,11 @@
     }
 
     @Override
+    public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public List<CommentInfo> commentsAsList() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -178,6 +250,11 @@
     }
 
     @Override
+    public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -198,11 +275,21 @@
     }
 
     @Override
+    public RobotCommentApi robotComment(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public BinaryResult patch() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public BinaryResult patch(String path) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, ActionInfo> actions() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -213,8 +300,37 @@
     }
 
     @Override
-    public SubmitType testSubmitType(TestSubmitRuleInput in)
-        throws RestApiException {
+    public BinaryResult submitPreview() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public BinaryResult submitPreview(String format) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public MergeListRequest getMergeList() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void description(String description) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String description() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String etag() throws RestApiException {
       throw new NotImplementedException();
     }
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java
new file mode 100644
index 0000000..ec2d5d6
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Map;
+
+public interface RevisionReviewerApi {
+  Map<String, Short> votes() throws RestApiException;
+
+  void deleteVote(String label) throws RestApiException;
+
+  void deleteVote(DeleteVoteInput input) throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements RevisionReviewerApi {
+    @Override
+    public Map<String, Short> votes() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteVote(String label) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void deleteVote(DeleteVoteInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
new file mode 100644
index 0000000..e44f21f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface RobotCommentApi {
+  RobotCommentInfo get() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements RobotCommentApi {
+    @Override
+    public RobotCommentInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java
index d3dff98..1207d27 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/StarsInput.java
@@ -20,8 +20,7 @@
   public Set<String> add;
   public Set<String> remove;
 
-  public StarsInput() {
-  }
+  public StarsInput() {}
 
   public StarsInput(Set<String> add) {
     this.add = add;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java
index e415acb..f820318 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmitInput.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.Map;
+
 public class SubmitInput {
   /** Not used anymore, kept for backward compatibility */
-  @Deprecated
-  public boolean waitForMerge;
+  @Deprecated public boolean waitForMerge;
 
   public String onBehalfOf;
 
   public NotifyHandling notify = NotifyHandling.ALL;
+  public Map<RecipientType, NotifyInfo> notifyDetails;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
index 52b6904..4e0e964 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherInfo.java
@@ -14,7 +14,6 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.extensions.common.ChangeInfo;
-
 import java.util.List;
 
 public class SubmittedTogetherInfo {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index 8649e91f..e2cab4d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,15 +16,5 @@
 
 /** Output options available for submitted_together requests. */
 public enum SubmittedTogetherOption {
-  NON_VISIBLE_CHANGES(0);
-
-  private final int value;
-
-  SubmittedTogetherOption(int v) {
-    value = v;
-  }
-
-  public int getValue() {
-    return value;
-  }
+  NON_VISIBLE_CHANGES;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java
index 3f4971e..eb7288d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Config.java
@@ -17,15 +17,13 @@
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 
 public interface Config {
-  /**
-   * @return An API for getting server related configurations.
-   */
+  /** @return An API for getting server related configurations. */
   Server server();
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements Config {
     @Override
     public Server server() {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
index a43c29f..07b3ab2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
@@ -16,26 +16,28 @@
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 public interface Server {
-  /**
-   * @return Version of server.
-   */
+  /** @return Version of server. */
   String getVersion() throws RestApiException;
 
+  ServerInfo getInfo() throws RestApiException;
+
   GeneralPreferencesInfo getDefaultPreferences() throws RestApiException;
-  GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
-      throws RestApiException;
+
+  GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in) throws RestApiException;
+
   DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException;
-  DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in)
-      throws RestApiException;
+
+  DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) throws RestApiException;
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements Server {
     @Override
     public String getVersion() throws RestApiException {
@@ -43,20 +45,23 @@
     }
 
     @Override
-    public GeneralPreferencesInfo getDefaultPreferences()
+    public ServerInfo getInfo() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
         throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public GeneralPreferencesInfo setDefaultPreferences(
-        GeneralPreferencesInfo in) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public DiffPreferencesInfo getDefaultDiffPreferences()
-        throws RestApiException {
+    public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java
index d3f4463..0d4742b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/GroupApi.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.List;
 
 public interface GroupApi {
@@ -94,8 +93,8 @@
   /**
    * Add members to a group.
    *
-   * @param members list of member identifiers, in any format accepted by
-   *     {@link com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
+   * @param members list of member identifiers, in any format accepted by {@link
+   *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
    * @throws RestApiException
    */
   void addMembers(String... members) throws RestApiException;
@@ -103,8 +102,8 @@
   /**
    * Remove members from a group.
    *
-   * @param members list of member identifiers, in any format accepted by
-   *     {@link com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
+   * @param members list of member identifiers, in any format accepted by {@link
+   *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
    * @throws RestApiException
    */
   void removeMembers(String... members) throws RestApiException;
@@ -120,8 +119,7 @@
   /**
    * Add groups to be included in this one.
    *
-   * @param groups list of group identifiers, in any format accepted by
-   *     {@link Groups#id(String)}
+   * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
    * @throws RestApiException
    */
   void addGroups(String... groups) throws RestApiException;
@@ -129,8 +127,7 @@
   /**
    * Remove included groups from this one.
    *
-   * @param groups list of group identifiers, in any format accepted by
-   *     {@link Groups#id(String)}
+   * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
    * @throws RestApiException
    */
   void removeGroups(String... groups) throws RestApiException;
@@ -144,9 +141,18 @@
   List<? extends GroupAuditEventInfo> auditLog() throws RestApiException;
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * Reindexes the group.
+   *
+   * <p>Only supported for internal groups.
+   *
+   * @throws RestApiException
+   */
+  void index() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements GroupApi {
     @Override
     public GroupInfo get() throws RestApiException {
@@ -204,8 +210,7 @@
     }
 
     @Override
-    public List<AccountInfo> members(boolean recursive)
-        throws RestApiException {
+    public List<AccountInfo> members(boolean recursive) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -235,8 +240,12 @@
     }
 
     @Override
-    public List<? extends GroupAuditEventInfo> auditLog()
-        throws RestApiException {
+    public List<? extends GroupAuditEventInfo> auditLog() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void index() throws RestApiException {
       throw new NotImplementedException();
     }
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java
index b58009d..a560fdf 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/groups/Groups.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -29,15 +28,13 @@
 public interface Groups {
   /**
    * Look up a group by ID.
-   * <p>
-   * <strong>Note:</strong> This method eagerly reads the group. Methods that
-   * mutate the group do not necessarily re-read the group. Therefore, calling a
-   * getter method on an instance after calling a mutation method on that same
-   * instance is not guaranteed to reflect the mutation. It is not recommended
-   * to store references to {@code groupApi} instances.
    *
-   * @param id any identifier supported by the REST API, including group name or
-   *     UUID.
+   * <p><strong>Note:</strong> This method eagerly reads the group. Methods that mutate the group do
+   * not necessarily re-read the group. Therefore, calling a getter method on an instance after
+   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
+   * is not recommended to store references to {@code groupApi} instances.
+   *
+   * @param id any identifier supported by the REST API, including group name or UUID.
    * @return API for accessing the group.
    * @throws RestApiException if an error occurred.
    */
@@ -52,9 +49,26 @@
   /** @return new request for listing groups. */
   ListRequest list();
 
+  /**
+   * Query groups.
+   *
+   * <p>Example code: {@code query().withQuery("inname:test").withLimit(10).get()}
+   *
+   * @return API for setting parameters and getting result.
+   */
+  QueryRequest query();
+
+  /**
+   * Query groups.
+   *
+   * <p>Shortcut API for {@code query().withQuery(String)}.
+   *
+   * @see #query()
+   */
+  QueryRequest query(String query);
+
   abstract class ListRequest {
-    private final EnumSet<ListGroupsOption> options =
-        EnumSet.noneOf(ListGroupsOption.class);
+    private final EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
     private final List<String> projects = new ArrayList<>();
     private final List<String> groups = new ArrayList<>();
 
@@ -182,9 +196,79 @@
   }
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * API for setting parameters and getting result. Used for {@code query()}.
+   *
+   * @see #query()
+   */
+  abstract class QueryRequest {
+    private String query;
+    private int limit;
+    private int start;
+    private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
+
+    /** Execute query and returns the matched groups as list. */
+    public abstract List<GroupInfo> get() throws RestApiException;
+
+    /**
+     * Set query.
+     *
+     * @param query needs to be in human-readable form.
+     */
+    public QueryRequest withQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
+    /**
+     * Set limit for returned list of groups. Optional; server-default is used when not provided.
+     */
+    public QueryRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /** Set number of groups to skip. Optional; no groups are skipped when not provided. */
+    public QueryRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public QueryRequest withOption(ListGroupsOption options) {
+      this.options.add(options);
+      return this;
+    }
+
+    public QueryRequest withOptions(ListGroupsOption... options) {
+      this.options.addAll(Arrays.asList(options));
+      return this;
+    }
+
+    public QueryRequest withOptions(EnumSet<ListGroupsOption> options) {
+      this.options = options;
+      return this;
+    }
+
+    public String getQuery() {
+      return query;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+
+    public EnumSet<ListGroupsOption> getOptions() {
+      return options;
+    }
+  }
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements Groups {
     @Override
     public GroupApi id(String id) throws RestApiException {
@@ -205,5 +289,15 @@
     public ListRequest list() {
       throw new NotImplementedException();
     }
+
+    @Override
+    public QueryRequest query() {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public QueryRequest query(String query) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/lfs/LfsDefinitions.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/lfs/LfsDefinitions.java
new file mode 100644
index 0000000..52aa9aa
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/lfs/LfsDefinitions.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.lfs;
+
+import com.google.common.base.Joiner;
+
+public final class LfsDefinitions {
+  public static final String CONTENTTYPE_VND_GIT_LFS_JSON =
+      "application/vnd.git-lfs+json; charset=utf-8";
+
+  public static final String LFS_OBJECTS_PATH = "objects/batch";
+  public static final String LFS_LOCKS_PATH_REGEX = "locks(?:/(.*)(?:/unlock))?";
+  public static final String LFS_VERIFICATION_PATH = "locks/verify";
+  public static final String LFS_UNIFIED_PATHS_REGEX =
+      Joiner.on('|').join(LFS_OBJECTS_PATH, LFS_LOCKS_PATH_REGEX, LFS_VERIFICATION_PATH);
+  public static final String LFS_URL_WO_AUTH_REGEX_TEAMPLATE = "(?:/p/|/)(.+)(?:/info/lfs/)(?:%s)$";
+  public static final String LFS_URL_WO_AUTH_REGEX =
+      String.format(LFS_URL_WO_AUTH_REGEX_TEAMPLATE, LFS_UNIFIED_PATHS_REGEX);
+  public static final String LFS_URL_REGEX_TEMPLATE = "^(?:/a)?" + LFS_URL_WO_AUTH_REGEX_TEAMPLATE;
+  public static final String LFS_URL_REGEX =
+      String.format(LFS_URL_REGEX_TEMPLATE, LFS_UNIFIED_PATHS_REGEX);
+
+  private LfsDefinitions() {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
index 222248d..a1f7327 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.List;
 
 public interface BranchApi {
   BranchApi create(BranchInput in) throws RestApiException;
@@ -25,15 +26,15 @@
 
   void delete() throws RestApiException;
 
-  /**
-   * Returns the content of a file from the HEAD revision.
-   */
+  /** Returns the content of a file from the HEAD revision. */
   BinaryResult file(String path) throws RestApiException;
 
+  List<ReflogEntryInfo> reflog() throws RestApiException;
+
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements BranchApi {
     @Override
     public BranchApi create(BranchInput in) throws RestApiException {
@@ -54,5 +55,10 @@
     public BinaryResult file(String path) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public List<ReflogEntryInfo> reflog() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
index 77513a2..1d89c9e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInfo.java
@@ -16,12 +16,10 @@
 
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
-
 import java.util.List;
 import java.util.Map;
 
 public class BranchInfo extends RefInfo {
-  public Boolean canDelete;
   public Map<String, ActionInfo> actions;
   public List<WebLinkInfo> webLinks;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java
index 506bcd4..aaf69d9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/BranchInput.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
 public class BranchInput {
-  @DefaultInput
-  public String revision;
+  @DefaultInput public String revision;
   public String ref;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
index 3bffac0..146ef27 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
@@ -20,12 +20,13 @@
 
 public interface ChildProjectApi {
   ProjectInfo get() throws RestApiException;
+
   ProjectInfo get(boolean recursive) throws RestApiException;
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements ChildProjectApi {
     @Override
     public ProjectInfo get() throws RestApiException {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index 81c999bc..36c86ed 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
-
 import java.util.List;
 import java.util.Map;
 
@@ -48,9 +48,17 @@
   }
 
   public static class MaxObjectSizeLimitInfo {
-    public String value;
-    public String configuredValue;
-    public String inheritedValue;
+    /** The effective value in bytes. Null if not set. */
+    @Nullable public String value;
+
+    /** The value configured explicitly on the project as a formatted string. Null if not set. */
+    @Nullable public String configuredValue;
+
+    /**
+     * Whether the value was inherited or overridden from the project's parent hierarchy or global
+     * config. Null if not inherited or overridden.
+     */
+    @Nullable public String summary;
   }
 
   public static class ConfigParameterInfo {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 8ab13f6..ae81ea5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
-
 import java.util.Map;
 
 public class ConfigInput {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
index e8108a5..d7b8c68 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteBranchesInput.java
@@ -18,4 +18,4 @@
 
 public class DeleteBranchesInput {
   public List<String> branches;
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java
new file mode 100644
index 0000000..b933624
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DeleteTagsInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import java.util.List;
+
+public class DeleteTagsInput {
+  public List<String> tags;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
index d329510..322b076 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
 public class DescriptionInput {
-  @DefaultInput
-  public String description;
+  @DefaultInput public String description;
   public String commitMessage;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index e111291..a5221b9 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
@@ -19,28 +19,35 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.List;
 
 public interface ProjectApi {
   ProjectApi create() throws RestApiException;
+
   ProjectApi create(ProjectInput in) throws RestApiException;
+
   ProjectInfo get() throws RestApiException;
 
   String description() throws RestApiException;
+
   void description(DescriptionInput in) throws RestApiException;
 
   ProjectAccessInfo access() throws RestApiException;
+
   ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException;
 
   ConfigInfo config() throws RestApiException;
+
   ConfigInfo config(ConfigInput in) throws RestApiException;
 
   ListRefsRequest<BranchInfo> branches();
+
   ListRefsRequest<TagInfo> tags();
 
   void deleteBranches(DeleteBranchesInput in) throws RestApiException;
 
+  void deleteTags(DeleteTagsInput in) throws RestApiException;
+
   abstract class ListRefsRequest<T extends RefInfo> {
     protected int limit;
     protected int start;
@@ -87,17 +94,18 @@
   }
 
   List<ProjectInfo> children() throws RestApiException;
+
   List<ProjectInfo> children(boolean recursive) throws RestApiException;
+
   ChildProjectApi child(String name) throws RestApiException;
 
   /**
    * Look up a branch by refname.
-   * <p>
-   * <strong>Note:</strong> This method eagerly reads the branch. Methods that
-   * mutate the branch do not necessarily re-read the branch. Therefore, calling
-   * a getter method on an instance after calling a mutation method on that same
-   * instance is not guaranteed to reflect the mutation. It is not recommended
-   * to store references to {@code BranchApi} instances.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the branch. Methods that mutate the branch
+   * do not necessarily re-read the branch. Therefore, calling a getter method on an instance after
+   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
+   * is not recommended to store references to {@code BranchApi} instances.
    *
    * @param ref branch name, with or without "refs/heads/" prefix.
    * @throws RestApiException if a problem occurred reading the project.
@@ -107,7 +115,9 @@
 
   /**
    * Look up a tag by refname.
+   *
    * <p>
+   *
    * @param ref tag name, with or without "refs/tags/" prefix.
    * @throws RestApiException if a problem occurred reading the project.
    * @return API for accessing the tag.
@@ -115,9 +125,9 @@
   TagApi tag(String ref) throws RestApiException;
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements ProjectApi {
     @Override
     public ProjectApi create() throws RestApiException {
@@ -155,14 +165,12 @@
     }
 
     @Override
-    public ProjectAccessInfo access(ProjectAccessInput p)
-      throws RestApiException {
+    public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void description(DescriptionInput in)
-        throws RestApiException {
+    public void description(DescriptionInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -177,7 +185,7 @@
     }
 
     @Override
-    public List<ProjectInfo> children() {
+    public List<ProjectInfo> children() throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -205,5 +213,10 @@
     public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void deleteTags(DeleteTagsInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
index bc4674f..7daa9f2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java
@@ -15,5 +15,10 @@
 package com.google.gerrit.extensions.api.projects;
 
 public enum ProjectConfigEntryType {
-  STRING, INT, LONG, BOOLEAN, LIST, ARRAY
+  STRING,
+  INT,
+  LONG,
+  BOOLEAN,
+  LIST,
+  ARRAY
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
index 1cbf54c..612c49c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectInput.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
-
 import java.util.List;
 import java.util.Map;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
index fdbadb2..e4a659c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -27,12 +26,11 @@
 public interface Projects {
   /**
    * Look up a project by name.
-   * <p>
-   * <strong>Note:</strong> This method eagerly reads the project. Methods that
-   * mutate the project do not necessarily re-read the project. Therefore,
-   * calling a getter method on an instance after calling a mutation method on
-   * that same instance is not guaranteed to reflect the mutation. It is not
-   * recommended to store references to {@code ProjectApi} instances.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the project. Methods that mutate the
+   * project do not necessarily re-read the project. Therefore, calling a getter method on an
+   * instance after calling a mutation method on that same instance is not guaranteed to reflect the
+   * mutation. It is not recommended to store references to {@code ProjectApi} instances.
    *
    * @param name project name.
    * @return API for accessing the project.
@@ -62,7 +60,10 @@
 
   abstract class ListRequest {
     public enum FilterType {
-      CODE, PARENT_CANDIDATES, PERMISSIONS, ALL
+      CODE,
+      PARENT_CANDIDATES,
+      PERMISSIONS,
+      ALL
     }
 
     private final List<String> branches = new ArrayList<>();
@@ -86,8 +87,7 @@
       return Collections.unmodifiableList(result);
     }
 
-    public abstract SortedMap<String, ProjectInfo> getAsMap()
-        throws RestApiException;
+    public abstract SortedMap<String, ProjectInfo> getAsMap() throws RestApiException;
 
     public ListRequest withDescription(boolean description) {
       this.description = description;
@@ -172,9 +172,9 @@
   }
 
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements Projects {
     @Override
     public ProjectApi name(String name) throws RestApiException {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
index 1844a76..c573600 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/RefInfo.java
@@ -17,4 +17,5 @@
 public class RefInfo {
   public String ref;
   public String revision;
+  public Boolean canDelete;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ReflogEntryInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ReflogEntryInfo.java
new file mode 100644
index 0000000..a0984ec
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ReflogEntryInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.GitPerson;
+
+public class ReflogEntryInfo {
+  public String oldId;
+  public String newId;
+  public GitPerson who;
+  public String comment;
+
+  public ReflogEntryInfo(String oldId, String newId, GitPerson who, String comment) {
+    this.oldId = oldId;
+    this.newId = newId;
+    this.who = who;
+    this.comment = comment;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
index 4348daf..39efeac 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagApi.java
@@ -22,10 +22,12 @@
 
   TagInfo get() throws RestApiException;
 
+  void delete() throws RestApiException;
+
   /**
-   * A default implementation which allows source compatibility
-   * when adding new methods to the interface.
-   **/
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
   class NotImplemented implements TagApi {
     @Override
     public TagApi create(TagInput input) throws RestApiException {
@@ -36,5 +38,10 @@
     public TagInfo get() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java
index b531d67..c7b1b94 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java
@@ -15,22 +15,34 @@
 package com.google.gerrit.extensions.api.projects;
 
 import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import java.util.List;
 
 public class TagInfo extends RefInfo {
   public String object;
   public String message;
   public GitPerson tagger;
+  public List<WebLinkInfo> webLinks;
 
-  public TagInfo(String ref, String revision) {
+  public TagInfo(String ref, String revision, boolean canDelete, List<WebLinkInfo> webLinks) {
     this.ref = ref;
     this.revision = revision;
+    this.canDelete = canDelete;
+    this.webLinks = webLinks;
   }
 
-  public TagInfo(String ref, String revision, String object,
-      String message, GitPerson tagger) {
-    this(ref, revision);
+  public TagInfo(
+      String ref,
+      String revision,
+      String object,
+      String message,
+      GitPerson tagger,
+      boolean canDelete,
+      List<WebLinkInfo> webLinks) {
+    this(ref, revision, canDelete, webLinks);
     this.object = object;
     this.message = message;
     this.tagger = tagger;
+    this.webLinks = webLinks;
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
index 929d12e..115bb88 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInput.java
@@ -17,8 +17,7 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
 public class TagInput {
-  @DefaultInput
-  public String ref;
+  @DefaultInput public String ref;
   public String revision;
   public String message;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
index 3fa7bb2..4b38c639 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java
@@ -15,27 +15,21 @@
 package com.google.gerrit.extensions.auth.oauth;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
 import java.io.IOException;
 
 @ExtensionPoint
 public interface OAuthLoginProvider {
 
   /**
-   * Performs a login with an OAuth2 provider for Git over HTTP
-   * communication.
+   * Performs a login with an OAuth2 provider for Git over HTTP communication.
    *
-   * An implementation of this interface must transmit the given
-   * user name and secret, which can be either an OAuth2 access token
-   * or a password, to the OAuth2 backend for verification.
+   * <p>An implementation of this interface must transmit the given user name and secret, which can
+   * be either an OAuth2 access token or a password, to the OAuth2 backend for verification.
    *
    * @param username the user's identifier.
-   * @param secret the secret to verify, e.g. a previously received
-   * access token or a password.
-   *
-   * @return information about the logged in user, at least
-   * external id, user name and email address.
-   *
+   * @param secret the secret to verify, e.g. a previously received access token or a password.
+   * @return information about the logged in user, at least external id, user name and email
+   *     address.
    * @throws IOException if the login failed.
    */
   OAuthUserInfo login(String username, String secret) throws IOException;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
index 9be2630..417f55a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.auth.oauth;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
 import java.io.IOException;
 
 /* Contract that OAuth provider must implement */
@@ -23,8 +22,7 @@
 public interface OAuthServiceProvider {
 
   /**
-   * Returns the URL where you should redirect your users to authenticate
-   * your application.
+   * Returns the URL where you should redirect your users to authenticate your application.
    *
    * @return the OAuth service URL to redirect your users for authentication
    */
@@ -39,8 +37,8 @@
   OAuthToken getAccessToken(OAuthVerifier verifier);
 
   /**
-   * After establishing of secure communication channel, this method supossed to
-   * access the protected resoure and retrieve the username.
+   * After establishing of secure communication channel, this method supossed to access the
+   * protected resoure and retrieve the username.
    *
    * @param token
    * @return OAuth user information
@@ -56,8 +54,8 @@
   String getVersion();
 
   /**
-   * Returns the name of this service. This name is resented the user to choose
-   * between multiple service providers
+   * Returns the name of this service. This name is resented the user to choose between multiple
+   * service providers
    *
    * @return name of the service
    */
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
index 788f420..b736262 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthToken.java
@@ -26,14 +26,14 @@
   private final String raw;
 
   /**
-   * Time of expiration of this token, or {@code Long#MAX_VALUE} if this
-   * token never expires, or time of expiration is unknown.
+   * Time of expiration of this token, or {@code Long#MAX_VALUE} if this token never expires, or
+   * time of expiration is unknown.
    */
   private final long expiresAt;
 
   /**
-   * The identifier of the OAuth provider that issued this token
-   * in the form <tt>"plugin-name:provider-name"</tt>, or {@code null}.
+   * The identifier of the OAuth provider that issued this token in the form
+   * <tt>"plugin-name:provider-name"</tt>, or {@code null}.
    */
   private final String providerId;
 
@@ -41,8 +41,7 @@
     this(token, secret, raw, Long.MAX_VALUE, null);
   }
 
-  public OAuthToken(String token, String secret, String raw,
-      long expiresAt, String providerId) {
+  public OAuthToken(String token, String secret, String raw, long expiresAt, String providerId) {
     this.token = token;
     this.secret = secret;
     this.raw = raw;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthUserInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthUserInfo.java
index 388ce36..31dd1d1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthUserInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthUserInfo.java
@@ -22,7 +22,8 @@
   private final String displayName;
   private final String claimedIdentity;
 
-  public OAuthUserInfo(String externalId,
+  public OAuthUserInfo(
+      String externalId,
       String userName,
       String emailAddress,
       String displayName,
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AccountFieldName.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AccountFieldName.java
new file mode 100644
index 0000000..14c92f1
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AccountFieldName.java
@@ -0,0 +1,21 @@
+// 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.client;
+
+public enum AccountFieldName {
+  FULL_NAME,
+  USER_NAME,
+  REGISTER_NEW_EMAIL
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AuthType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AuthType.java
new file mode 100644
index 0000000..2478e10
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/AuthType.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2009 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.client;
+
+public enum AuthType {
+  /** Login relies upon the <a href="http://openid.net/">OpenID standard</a> */
+  OPENID,
+
+  /**
+   * Login relies upon the <a href="http://openid.net/">OpenID standard</a> in Single Sign On mode
+   */
+  OPENID_SSO,
+
+  /**
+   * Login relies upon the container/web server security.
+   *
+   * <p>The container or web server must populate an HTTP header with a unique name for the current
+   * user. Gerrit will implicitly trust the value of this header to supply the unique identity.
+   */
+  HTTP,
+
+  /**
+   * Login relies upon the container/web server security.
+   *
+   * <p>Like {@link #HTTP}, the container or web server must populate an HTTP header with a unique
+   * name for the current user. Gerrit will implicitly trust the value of this header to supply the
+   * unique identity.
+   *
+   * <p>After the authentication is done Gerrit will obtain basic user registration (name and
+   * email), and some group memberships, from LDP. Hence the "_LDAP" suffix in the name of this
+   * authentication type.
+   *
+   * <p>Gerrit will NOT authenticate the user via LDAP.
+   */
+  HTTP_LDAP,
+
+  /**
+   * Login via client SSL certificate.
+   *
+   * <p>This authentication type is actually kind of SSO. Gerrit will configure Jetty's SSL channel
+   * to request client's SSL certificate. For this authentication to work a Gerrit administrator has
+   * to import the root certificate of the trust chain used to issue the client's certificate into
+   * the &lt;review-site&gt;/etc/keystore.
+   *
+   * <p>After the authentication is done Gerrit will obtain basic user registration (name and
+   * email), and some group memberships, from LDP. Hence the "_LDAP" suffix in the name of this
+   * authentication type.
+   *
+   * <p>Gerrit will NOT authenticate the user via LDAP.
+   */
+  CLIENT_SSL_CERT_LDAP,
+
+  /**
+   * Login collects username and password through a web form, and binds to LDAP.
+   *
+   * <p>Unlike {@link #HTTP_LDAP}, Gerrit presents a sign-in dialog to the user and makes the
+   * connection to the LDAP server on their behalf.
+   */
+  LDAP,
+
+  /**
+   * Login collects username and password through a web form, and binds to LDAP.
+   *
+   * <p>Unlike {@link #HTTP_LDAP}, Gerrit presents a sign-in dialog to the user and makes the
+   * connection to the LDAP server on their behalf.
+   *
+   * <p>Unlike the more generic {@link #LDAP} mode, Gerrit can only query the directory via an
+   * actual authenticated user account.
+   */
+  LDAP_BIND,
+
+  /** Login is managed by additional, unspecified code. */
+  CUSTOM_EXTENSION,
+
+  /** Development mode to enable becoming anyone you want. */
+  DEVELOPMENT_BECOME_ANY_ACCOUNT,
+
+  /** Generic OAuth provider over HTTP. */
+  OAUTH
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java
index 661d253..4ecde16c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeStatus.java
@@ -20,16 +20,15 @@
   /**
    * Change is open and pending review, or review is in progress.
    *
-   * <p>
-   * This is the default state assigned to a change when it is first created
-   * in the database. A change stays in the NEW state throughout its review
-   * cycle, until the change is submitted or abandoned.
+   * <p>This is the default state assigned to a change when it is first created in the database. A
+   * change stays in the NEW state throughout its review cycle, until the change is submitted or
+   * abandoned.
    *
-   * <p>
-   * Changes in the NEW state can be moved to:
+   * <p>Changes in the NEW state can be moved to:
+   *
    * <ul>
-   * <li>{@link #MERGED} - when the Submit Patch Set action is used;
-   * <li>{@link #ABANDONED} - when the Abandon action is used.
+   *   <li>{@link #MERGED} - when the Submit Patch Set action is used;
+   *   <li>{@link #ABANDONED} - when the Abandon action is used.
    * </ul>
    */
   NEW,
@@ -37,17 +36,15 @@
   /**
    * Change is a draft change that only consists of draft patchsets.
    *
-   * <p>
-   * This is a change that is not meant to be submitted or reviewed yet. If
-   * the uploader publishes the change, it becomes a NEW change.
-   * Publishing is a one-way action, a change cannot return to DRAFT status.
-   * Draft changes are only visible to the uploader and those explicitly
+   * <p>This is a change that is not meant to be submitted or reviewed yet. If the uploader
+   * publishes the change, it becomes a NEW change. Publishing is a one-way action, a change cannot
+   * return to DRAFT status. Draft changes are only visible to the uploader and those explicitly
    * added as reviewers. Note that currently draft changes cannot be abandoned.
    *
-   * <p>
-   * Changes in the DRAFT state can be moved to:
+   * <p>Changes in the DRAFT state can be moved to:
+   *
    * <ul>
-   * <li>{@link #NEW} - when the change is published, it becomes a new change.
+   *   <li>{@link #NEW} - when the change is published, it becomes a new change.
    * </ul>
    */
   DRAFT,
@@ -55,25 +52,22 @@
   /**
    * Change is closed, and submitted to its destination branch.
    *
-   * <p>
-   * Once a change has been merged, it cannot be further modified by adding a
-   * replacement patch set. Draft comments however may be published,
-   * supporting a post-submit review.
+   * <p>Once a change has been merged, it cannot be further modified by adding a replacement patch
+   * set. Draft comments however may be published, supporting a post-submit review.
    */
   MERGED,
 
   /**
    * Change is closed, but was not submitted to its destination branch.
    *
-   * <p>
-   * Once a change has been abandoned, it cannot be further modified by adding
-   * a replacement patch set, and it cannot be merged. Draft comments however
-   * may be published, permitting reviewers to send constructive feedback.
+   * <p>Once a change has been abandoned, it cannot be further modified by adding a replacement
+   * patch set, and it cannot be merged. Draft comments however may be published, permitting
+   * reviewers to send constructive feedback.
    *
-   * <p>
-   * Changes in the ABANDONED state can be moved to:
+   * <p>Changes in the ABANDONED state can be moved to:
+   *
    * <ul>
-   * <li>{@link #NEW} - when the Restore action is used.
+   *   <li>{@link #NEW} - when the Restore action is used.
    * </ul>
    */
   ABANDONED
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
index 7c8a3e8..2225a99 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
@@ -20,8 +20,8 @@
 public abstract class Comment {
   /**
    * Patch set number containing this commit.
-   * <p>
-   * Only set in contexts where comments may come from multiple patch sets.
+   *
+   * <p>Only set in contexts where comments may come from multiple patch sets.
    */
   public Integer patchSet;
 
@@ -29,17 +29,27 @@
   public String path;
   public Side side;
   public Integer parent;
-  public Integer line;
+  public Integer line; // value 0 or null indicates a file comment, normal lines start at 1
   public Range range;
   public String inReplyTo;
   public Timestamp updated;
   public String message;
+  public Boolean unresolved;
 
   public static class Range {
-    public int startLine;
-    public int startCharacter;
-    public int endLine;
-    public int endCharacter;
+    public int startLine; // 1-based, inclusive
+    public int startCharacter; // 0-based, inclusive
+    public int endLine; // 1-based, exclusive
+    public int endCharacter; // 0-based, exclusive
+
+    public boolean isValid() {
+      return startLine > 0
+          && startCharacter >= 0
+          && endLine > 0
+          && endCharacter >= 0
+          && startLine <= endLine
+          && (startLine != endLine || startCharacter <= endCharacter);
+    }
 
     @Override
     public boolean equals(Object o) {
@@ -57,6 +67,27 @@
     public int hashCode() {
       return Objects.hash(startLine, startCharacter, endLine, endCharacter);
     }
+
+    @Override
+    public String toString() {
+      return "Range{"
+          + "startLine="
+          + startLine
+          + ", startCharacter="
+          + startCharacter
+          + ", endLine="
+          + endLine
+          + ", endCharacter="
+          + endCharacter
+          + '}';
+    }
+  }
+
+  public short side() {
+    if (side == Side.PARENT) {
+      return (short) (parent == null ? 0 : -parent.shortValue());
+    }
+    return 1;
   }
 
   @Override
@@ -75,14 +106,14 @@
           && Objects.equals(range, c.range)
           && Objects.equals(inReplyTo, c.inReplyTo)
           && Objects.equals(updated, c.updated)
-          && Objects.equals(message, c.message);
+          && Objects.equals(message, c.message)
+          && Objects.equals(unresolved, c.unresolved);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(patchSet, id, path, side, parent, line, range,
-        inReplyTo, updated, message);
+    return Objects.hash(patchSet, id, path, side, parent, line, range, inReplyTo, updated, message);
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index d246996..0d5bdfa 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -22,6 +22,9 @@
   /** Default tab size. */
   public static final int DEFAULT_TAB_SIZE = 8;
 
+  /** Default font size. */
+  public static final int DEFAULT_FONT_SIZE = 12;
+
   /** Default line length. */
   public static final int DEFAULT_LINE_LENGTH = 100;
 
@@ -29,8 +32,7 @@
   public static final short WHOLE_FILE_CONTEXT = -1;
 
   /** Typical valid choices for the default context setting. */
-  public static final short[] CONTEXT_CHOICES =
-      {3, 10, 25, 50, 75, 100, WHOLE_FILE_CONTEXT};
+  public static final short[] CONTEXT_CHOICES = {3, 10, 25, 50, 75, 100, WHOLE_FILE_CONTEXT};
 
   public enum Whitespace {
     IGNORE_NONE,
@@ -41,6 +43,7 @@
 
   public Integer context;
   public Integer tabSize;
+  public Integer fontSize;
   public Integer lineLength;
   public Integer cursorBlinkRate;
   public Boolean expandAllComments;
@@ -68,6 +71,7 @@
     DiffPreferencesInfo i = new DiffPreferencesInfo();
     i.context = DEFAULT_CONTEXT;
     i.tabSize = DEFAULT_TAB_SIZE;
+    i.fontSize = DEFAULT_FONT_SIZE;
     i.lineLength = DEFAULT_LINE_LENGTH;
     i.cursorBlinkRate = 0;
     i.ignoreWhitespace = Whitespace.IGNORE_NONE;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index 84c61b7..7ab22e1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -27,6 +27,7 @@
   public Boolean hideLineNumbers;
   public Boolean matchBrackets;
   public Boolean lineWrapping;
+  public Boolean indentWithTabs;
   public Boolean autoCloseBrackets;
   public Boolean showBase;
   public Theme theme;
@@ -45,6 +46,7 @@
     i.hideLineNumbers = false;
     i.matchBrackets = true;
     i.lineWrapping = false;
+    i.indentWithTabs = false;
     i.autoCloseBrackets = false;
     i.showBase = false;
     i.theme = Theme.DEFAULT;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 9754f12..7192ff9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -28,7 +28,11 @@
 
   /** Preferred method to download a change. */
   public enum DownloadCommand {
-    REPO_DOWNLOAD, PULL, CHECKOUT, CHERRY_PICK, FORMAT_PATCH
+    REPO_DOWNLOAD,
+    PULL,
+    CHECKOUT,
+    CHERRY_PICK,
+    FORMAT_PATCH
   }
 
   public enum DateFormat {
@@ -83,6 +87,30 @@
     DISABLED
   }
 
+  public enum EmailFormat {
+    PLAINTEXT,
+    HTML_PLAINTEXT
+  }
+
+  public enum DefaultBase {
+    AUTO_MERGE(null),
+    FIRST_PARENT(-1);
+
+    private final String base;
+
+    DefaultBase(String base) {
+      this.base = base;
+    }
+
+    DefaultBase(int base) {
+      this(Integer.toString(base));
+    }
+
+    public String getBase() {
+      return base;
+    }
+  }
+
   public enum TimeFormat {
     /** 12-hour clock: 1:15 am, 2:13 pm */
     HHMM_12("h:mm a"),
@@ -111,8 +139,11 @@
   public String downloadScheme;
   /** Type of download command the user prefers to use. */
   public DownloadCommand downloadCommand;
+
   public DateFormat dateFormat;
   public TimeFormat timeFormat;
+  public Boolean expandInlineDiffs;
+  public Boolean highlightAssigneeInChangeTable;
   public Boolean relativeDateInChangeTable;
   public DiffView diffView;
   public Boolean sizeBarInChangeTable;
@@ -121,8 +152,11 @@
   public Boolean muteCommonPathPrefixes;
   public Boolean signedOffBy;
   public List<MenuItem> my;
+  public List<String> changeTable;
   public Map<String, String> urlAliases;
   public EmailStrategy emailStrategy;
+  public EmailFormat emailFormat;
+  public DefaultBase defaultBaseForMerges;
 
   public boolean isShowInfoInReviewCategory() {
     return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
@@ -163,23 +197,34 @@
     return emailStrategy;
   }
 
+  public EmailFormat getEmailFormat() {
+    if (emailFormat == null) {
+      return EmailFormat.HTML_PLAINTEXT;
+    }
+    return emailFormat;
+  }
+
   public static GeneralPreferencesInfo defaults() {
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
     p.showSiteHeader = true;
     p.useFlashClipboard = true;
     p.emailStrategy = EmailStrategy.ENABLED;
+    p.emailFormat = EmailFormat.HTML_PLAINTEXT;
     p.reviewCategoryStrategy = ReviewCategoryStrategy.NONE;
     p.downloadScheme = null;
     p.downloadCommand = DownloadCommand.CHECKOUT;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
+    p.expandInlineDiffs = false;
+    p.highlightAssigneeInChangeTable = true;
     p.relativeDateInChangeTable = false;
     p.diffView = DiffView.SIDE_BY_SIDE;
     p.sizeBarInChangeTable = true;
     p.legacycidInChangeTable = false;
     p.muteCommonPathPrefixes = true;
     p.signedOffBy = false;
+    p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     return p;
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java
index 0a5b033..b7e1a5a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GerritTopMenu.java
@@ -15,7 +15,12 @@
 package com.google.gerrit.extensions.client;
 
 public enum GerritTopMenu {
-  ALL, MY, PROJECTS, PEOPLE, PLUGINS, DOCUMENTATION;
+  ALL,
+  MY,
+  PROJECTS,
+  PEOPLE,
+  PLUGINS,
+  DOCUMENTATION;
 
   public final String menuName;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
index 6450b0d..27fc9e5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GitBasicAuthPolicy.java
@@ -15,7 +15,18 @@
 package com.google.gerrit.extensions.client;
 
 public enum GitBasicAuthPolicy {
+  /** Only the HTTP password is accepted when doing Git over HTTP and REST API requests. */
   HTTP,
+
+  /** Only the LDAP password is allowed when doing Git over HTTP and REST API requests. */
   LDAP,
-  HTTP_LDAP
+
+  /**
+   * The password in the request is first checked against the HTTP password and, if it does not
+   * match, it is then validated against the LDAP password.
+   */
+  HTTP_LDAP,
+
+  /** Only the `OAUTH` authentication is allowed when doing Git over HTTP and REST API requests. */
+  OAUTH
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
index 8b6c5e6..787725c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -69,7 +69,10 @@
   PUSH_CERTIFICATES(18),
 
   /** Include change's reviewer updates. */
-  REVIEWER_UPDATES(19);
+  REVIEWER_UPDATES(19),
+
+  /** Set the submittable boolean. */
+  SUBMITTABLE(20);
 
   private final int value;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
index 556dddc..3c20ff7 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
@@ -43,9 +43,14 @@
 
   @Override
   public int hashCode() {
-    return Objects
-        .hash(project, filter, notifyNewChanges, notifyNewPatchSets,
-            notifyAllComments, notifySubmittedChanges, notifyAbandonedChanges);
+    return Objects.hash(
+        project,
+        filter,
+        notifyNewChanges,
+        notifyNewPatchSets,
+        notifyAllComments,
+        notifySubmittedChanges,
+        notifyAbandonedChanges);
   }
 
   @Override
@@ -53,8 +58,7 @@
     StringBuilder b = new StringBuilder();
     b.append(project);
     if (filter != null) {
-      b.append("%filter=")
-          .append(filter);
+      b.append("%filter=").append(filter);
     }
     b.append("(notifyAbandonedChanges=")
         .append(toBoolean(notifyAbandonedChanges))
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
index fcfeb01..b52e89a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/SubmitType.java
@@ -18,6 +18,7 @@
   FAST_FORWARD_ONLY,
   MERGE_IF_NECESSARY,
   REBASE_IF_NECESSARY,
+  REBASE_ALWAYS,
   MERGE_ALWAYS,
   CHERRY_PICK
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java
index 6408f9d..d7a5b80 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java
@@ -18,6 +18,7 @@
   // Light themes
   DEFAULT,
   DAY_3024,
+  DUOTONE_LIGHT,
   BASE16_LIGHT,
   ECLIPSE,
   ELEGANT,
@@ -40,6 +41,7 @@
   COBALT,
   COLORFORTH,
   DRACULA,
+  DUOTONE_DARK,
   ERLANG_DARK,
   HOPSCOTCH,
   ICECODER,
@@ -66,7 +68,6 @@
 
   public boolean isDark() {
     switch (this) {
-      case NIGHT_3024:
       case ABCDEF:
       case AMBIANCE:
       case BASE16_DARK:
@@ -75,6 +76,7 @@
       case COBALT:
       case COLORFORTH:
       case DRACULA:
+      case DUOTONE_DARK:
       case ERLANG_DARK:
       case HOPSCOTCH:
       case ICECODER:
@@ -86,6 +88,7 @@
       case MIDNIGHT:
       case MONOKAI:
       case NIGHT:
+      case NIGHT_3024:
       case PARAISO_DARK:
       case PASTEL_ON_DARK:
       case RAILSCASTS:
@@ -99,9 +102,10 @@
       case XQ_DARK:
       case ZENBURN:
         return true;
+      case BASE16_LIGHT:
       case DEFAULT:
       case DAY_3024:
-      case BASE16_LIGHT:
+      case DUOTONE_LIGHT:
       case ECLIPSE:
       case ELEGANT:
       case MDN_LIKE:
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/UiType.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/UiType.java
new file mode 100644
index 0000000..0d9df39
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/UiType.java
@@ -0,0 +1,32 @@
+// 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.client;
+
+public enum UiType {
+  NONE,
+  GWT,
+  POLYGERRIT;
+
+  public static UiType parse(String str) {
+    if (str != null) {
+      for (UiType type : UiType.values()) {
+        if (type.name().equalsIgnoreCase(str)) {
+          return type;
+        }
+      }
+    }
+    return null;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
new file mode 100644
index 0000000..9c64fd0
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.common.collect.ComparisonChain;
+import java.util.Objects;
+
+public class AccountExternalIdInfo implements Comparable<AccountExternalIdInfo> {
+  public String identity;
+  public String emailAddress;
+  public Boolean trusted;
+  public Boolean canDelete;
+
+  @Override
+  public int compareTo(AccountExternalIdInfo a) {
+    return ComparisonChain.start()
+        .compare(a.identity, identity)
+        .compare(a.emailAddress, emailAddress)
+        .result();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AccountExternalIdInfo) {
+      AccountExternalIdInfo a = (AccountExternalIdInfo) o;
+      return (Objects.equals(a.identity, identity))
+          && (Objects.equals(a.emailAddress, emailAddress))
+          && (Objects.equals(a.trusted, trusted))
+          && (Objects.equals(a.canDelete, canDelete));
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(identity, emailAddress, trusted, canDelete);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
index 2c35d5e..2fb32d7 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -24,6 +24,7 @@
   public String username;
   public List<AvatarInfo> avatars;
   public Boolean _moreAccounts;
+  public String status;
 
   public AccountInfo(Integer id) {
     this._accountId = id;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
index 6ec5b1d..4242fcd 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInfo.java
@@ -18,4 +18,5 @@
   public String name;
   public String description;
   public String url;
+  public GroupInfo autoVerifyGroup;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java
index 060367b..0c6cf2d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AgreementInput.java
@@ -19,6 +19,5 @@
 /** This entity contains information for registering a new contributor agreement. */
 public class AgreementInput {
   /* The agreement name. */
-  @DefaultInput
-  public String name;
+  @DefaultInput public String name;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index 6d28dbc..9125bfd 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -20,6 +20,8 @@
   public String tag;
   public Integer value;
   public Timestamp date;
+  public Boolean postSubmit;
+  public VotingRangeInfo permittedVotingRange;
 
   public ApprovalInfo(Integer id) {
     super(id);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java
new file mode 100644
index 0000000..79c2250
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AuthInfo.java
@@ -0,0 +1,35 @@
+// 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.common;
+
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
+import java.util.List;
+
+public class AuthInfo {
+  public AuthType authType;
+  public Boolean useContributorAgreements;
+  public List<AgreementInfo> contributorAgreements;
+  public List<AccountFieldName> editableAccountFields;
+  public String loginUrl;
+  public String loginText;
+  public String switchAccountUrl;
+  public String registerUrl;
+  public String registerText;
+  public String editFullNameUrl;
+  public String httpPasswordUrl;
+  public GitBasicAuthPolicy gitBasicAuthPolicy;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AvatarInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AvatarInfo.java
index 793aa24..00f1819 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -18,10 +18,8 @@
   /**
    * Size in pixels the UI prefers an avatar image to be.
    *
-   * The web UI prefers avatar images to be square, both
-   * the height and width of the image should be this size.
-   * The height is the more important dimension to match
-   * than the width.
+   * <p>The web UI prefers avatar images to be square, both the height and width of the image should
+   * be this size. The height is the more important dimension to match than the width.
    */
   public static final int DEFAULT_SIZE = 26;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
new file mode 100644
index 0000000..b710121
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -0,0 +1,26 @@
+// 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.common;
+
+public class ChangeConfigInfo {
+  public Boolean allowBlame;
+  public Boolean showAssigneeInChangesTable;
+  public Boolean allowDrafts;
+  public int largeChange;
+  public String replyLabel;
+  public String replyTooltip;
+  public int updateDelay;
+  public Boolean submitWholeTopic;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 003ab24..3803714 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
-
 import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.List;
@@ -28,6 +27,7 @@
   public String project;
   public String branch;
   public String topic;
+  public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
@@ -43,6 +43,7 @@
   public Boolean submittable;
   public Integer insertions;
   public Integer deletions;
+  public Integer unresolvedCommentCount;
 
   public int _number;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
index 88c3ea8..b50bcf3 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import java.util.Map;
 
 public class ChangeInput {
   public String project;
@@ -26,4 +30,9 @@
   public String baseChange;
   public Boolean newBranch;
   public MergeInput merge;
+
+  /** Who to send email notifications to after change is created. */
+  public NotifyHandling notify = NotifyHandling.ALL;
+
+  public Map<RecipientType, NotifyInfo> notifyDetails;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
index 166aaa2..02a2133 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.Comment;
-
 import java.util.Objects;
 
 public class CommentInfo extends Comment {
@@ -26,8 +25,7 @@
   public boolean equals(Object o) {
     if (super.equals(o)) {
       CommentInfo ci = (CommentInfo) o;
-      return Objects.equals(author, ci.author)
-          && Objects.equals(tag, ci.tag);
+      return Objects.equals(author, ci.author) && Objects.equals(tag, ci.tag);
     }
     return false;
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
index 71acca3..9bcf2cf 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
@@ -18,23 +18,27 @@
   public Boolean showOnSideBySideDiffView;
   public Boolean showOnUnifiedDiffView;
 
-  public static DiffWebLinkInfo forSideBySideDiffView(String name,
-      String imageUrl, String url, String target) {
+  public static DiffWebLinkInfo forSideBySideDiffView(
+      String name, String imageUrl, String url, String target) {
     return new DiffWebLinkInfo(name, imageUrl, url, target, true, false);
   }
 
-  public static DiffWebLinkInfo forUnifiedDiffView(String name,
-      String imageUrl, String url, String target) {
+  public static DiffWebLinkInfo forUnifiedDiffView(
+      String name, String imageUrl, String url, String target) {
     return new DiffWebLinkInfo(name, imageUrl, url, target, false, true);
   }
 
-  public static DiffWebLinkInfo forSideBySideAndUnifiedDiffView(String name,
-      String imageUrl, String url, String target) {
+  public static DiffWebLinkInfo forSideBySideAndUnifiedDiffView(
+      String name, String imageUrl, String url, String target) {
     return new DiffWebLinkInfo(name, imageUrl, url, target, true, true);
   }
 
-  private DiffWebLinkInfo(String name, String imageUrl, String url,
-      String target, boolean showOnSideBySideDiffView,
+  private DiffWebLinkInfo(
+      String name,
+      String imageUrl,
+      String url,
+      String target,
+      boolean showOnSideBySideDiffView,
       boolean showOnUnifiedDiffView) {
     super(name, imageUrl, url, target);
     this.showOnSideBySideDiffView = showOnSideBySideDiffView ? true : null;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadInfo.java
new file mode 100644
index 0000000..5ea5992
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadInfo.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.common;
+
+import java.util.List;
+import java.util.Map;
+
+public class DownloadInfo {
+  public Map<String, DownloadSchemeInfo> schemes;
+  public List<String> archives;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
new file mode 100644
index 0000000..6f0b178
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
@@ -0,0 +1,25 @@
+// 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.common;
+
+import java.util.Map;
+
+public class DownloadSchemeInfo {
+  public String url;
+  public Boolean isAuthRequired;
+  public Boolean isAuthSupported;
+  public Map<String, String> commands;
+  public Map<String, String> cloneCommands;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
index 9dc92a8..46ef879 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
@@ -18,6 +18,7 @@
 
 public class EditInfo {
   public CommitInfo commit;
+  public int basePatchSetNumber;
   public String baseRevision;
   public Map<String, FetchInfo> fetch;
   public Map<String, FileInfo> files;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EmailInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EmailInfo.java
new file mode 100644
index 0000000..184a89f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EmailInfo.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class EmailInfo {
+  public String email;
+  public Boolean preferred;
+  public Boolean pendingConfirmation;
+
+  public void preferred(String e) {
+    this.preferred = e != null && e.equals(email) ? true : null;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfo.java
new file mode 100644
index 0000000..9e5890e
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfo.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.common;
+
+import com.google.gerrit.extensions.client.Comment;
+
+public class FixReplacementInfo {
+  public String path;
+  public Comment.Range range;
+  public String replacement;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java
new file mode 100644
index 0000000..7ba7fcc
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfo.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.common;
+
+import java.util.List;
+
+public class FixSuggestionInfo {
+  public String fixId;
+  public String description;
+  public List<FixReplacementInfo> replacements;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GerritInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GerritInfo.java
new file mode 100644
index 0000000..f904b06
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.client.UiType;
+import java.util.Set;
+
+public class GerritInfo {
+  public String allProjects;
+  public String allUsers;
+  public Boolean docSearch;
+  public String docUrl;
+  public Boolean editGpgKeys;
+  public String reportBugUrl;
+  public String reportBugText;
+  public Set<UiType> webUis;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
index 33adbea..7a5c15b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
@@ -19,23 +19,20 @@
 public class GpgKeyInfo {
   /**
    * Status of checking an object like a key or signature.
-   * <p>
-   * Order of values in this enum is significant: OK is "better" than BAD, etc.
+   *
+   * <p>Order of values in this enum is significant: OK is "better" than BAD, etc.
    */
   public enum Status {
     /** Something is wrong with this key. */
     BAD,
 
     /**
-     * Inspecting only this key found no problems, but the system does not fully
-     * trust the key's origin.
+     * Inspecting only this key found no problems, but the system does not fully trust the key's
+     * origin.
      */
     OK,
 
-    /**
-     * This key is valid, and the system knows enough about the key and its
-     * origin to trust it.
-     */
+    /** This key is valid, and the system knows enough about the key and its origin to trust it. */
     TRUSTED;
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
index 1d8839f..3e6f762 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
@@ -18,15 +18,18 @@
 
 public abstract class GroupAuditEventInfo {
   public enum Type {
-    ADD_USER, REMOVE_USER, ADD_GROUP, REMOVE_GROUP
+    ADD_USER,
+    REMOVE_USER,
+    ADD_GROUP,
+    REMOVE_GROUP
   }
 
   public Type type;
   public AccountInfo user;
   public Timestamp date;
 
-  public static UserMemberAuditEventInfo createAddUserEvent(AccountInfo user,
-      Timestamp date, AccountInfo member) {
+  public static UserMemberAuditEventInfo createAddUserEvent(
+      AccountInfo user, Timestamp date, AccountInfo member) {
     return new UserMemberAuditEventInfo(Type.ADD_USER, user, date, member);
   }
 
@@ -35,8 +38,8 @@
     return new UserMemberAuditEventInfo(Type.REMOVE_USER, user, date, member);
   }
 
-  public static GroupMemberAuditEventInfo createAddGroupEvent(AccountInfo user,
-      Timestamp date, GroupInfo member) {
+  public static GroupMemberAuditEventInfo createAddGroupEvent(
+      AccountInfo user, Timestamp date, GroupInfo member) {
     return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date, member);
   }
 
@@ -45,8 +48,7 @@
     return new GroupMemberAuditEventInfo(Type.REMOVE_GROUP, user, date, member);
   }
 
-  protected GroupAuditEventInfo(Type type, AccountInfo user,
-      Timestamp date) {
+  protected GroupAuditEventInfo(Type type, AccountInfo user, Timestamp date) {
     this.type = type;
     this.user = user;
     this.date = date;
@@ -55,8 +57,8 @@
   public static class UserMemberAuditEventInfo extends GroupAuditEventInfo {
     public AccountInfo member;
 
-    public UserMemberAuditEventInfo(Type type, AccountInfo user,
-        Timestamp date, AccountInfo member) {
+    public UserMemberAuditEventInfo(
+        Type type, AccountInfo user, Timestamp date, AccountInfo member) {
       super(type, user, date);
       this.member = member;
     }
@@ -65,8 +67,8 @@
   public static class GroupMemberAuditEventInfo extends GroupAuditEventInfo {
     public GroupInfo member;
 
-    public GroupMemberAuditEventInfo(Type type, AccountInfo user,
-        Timestamp date, GroupInfo member) {
+    public GroupMemberAuditEventInfo(
+        Type type, AccountInfo user, Timestamp date, GroupInfo member) {
       super(type, user, date);
       this.member = member;
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java
index f956a03..55fb92a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java
@@ -25,6 +25,7 @@
   public Integer groupId;
   public String owner;
   public String ownerId;
+  public Boolean _moreGroups;
 
   // These fields are only supplied for internal groups, and only if requested.
   public List<AccountInfo> members;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java
index 598d618..c16a551 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeInput.java
@@ -18,7 +18,8 @@
   /**
    * {@code source} can be any Git object reference expression.
    *
-   * @see <a href="https://www.kernel.org/pub/software/scm/git/docs/gitrevisions.html">gitrevisions(7)</a>
+   * @see <a
+   *     href="https://www.kernel.org/pub/software/scm/git/docs/gitrevisions.html">gitrevisions(7)</a>
    */
   public String source;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
new file mode 100644
index 0000000..263b6c4
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -0,0 +1,21 @@
+// 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.common;
+
+public class MergePatchSetInput {
+  public String subject;
+  public boolean inheritParent;
+  public MergeInput merge;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java
index 50de74a..7ec454c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/MergeableInfo.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.SubmitType;
-
 import java.util.List;
 
 public class MergeableInfo {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
new file mode 100644
index 0000000..2d1d840
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
@@ -0,0 +1,22 @@
+// 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.common;
+
+import java.util.List;
+
+public class PluginConfigInfo {
+  public Boolean hasAvatars;
+  public List<String> jsResourcePaths;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
index ff04fdc..59f9fc9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
@@ -18,7 +18,8 @@
 
 public class ProblemInfo {
   public enum Status {
-    FIXED, FIX_FAILED
+    FIXED,
+    FIX_FAILED
   }
 
   public String message;
@@ -43,11 +44,9 @@
 
   @Override
   public String toString() {
-    StringBuilder sb = new StringBuilder(getClass().getSimpleName())
-        .append('[').append(message);
+    StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append('[').append(message);
     if (status != null || outcome != null) {
-      sb.append(" (").append(status).append(": ").append(outcome)
-          .append(')');
+      sb.append(" (").append(status).append(": ").append(outcome).append(')');
     }
     return sb.append(']').toString();
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java
index 4036740..d8e29ef 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.ProjectState;
-
 import java.util.List;
 import java.util.Map;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReceiveInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReceiveInfo.java
new file mode 100644
index 0000000..9fcd92b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReceiveInfo.java
@@ -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.
+
+package com.google.gerrit.extensions.common;
+
+public class ReceiveInfo {
+  public Boolean enableSignedPush;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
index b3c9cb6..eccdc64 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.ReviewerState;
-
 import java.sql.Timestamp;
 
 public class ReviewerUpdateInfo {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java
index 34a1e63..a3304156c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.ChangeKind;
-
 import java.sql.Timestamp;
 import java.util.Map;
 
@@ -33,4 +32,5 @@
   public Map<String, ActionInfo> actions;
   public String commitWithFooters;
   public PushCertificateInfo pushCertificate;
+  public String description;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
new file mode 100644
index 0000000..8d8731f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
@@ -0,0 +1,26 @@
+// 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.common;
+
+import java.util.List;
+import java.util.Map;
+
+public class RobotCommentInfo extends CommentInfo {
+  public String robotId;
+  public String robotRunId;
+  public String url;
+  public Map<String, String> properties;
+  public List<FixSuggestionInfo> fixSuggestions;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java
new file mode 100644
index 0000000..aa4a63f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.Map;
+
+public class ServerInfo {
+  public AuthInfo auth;
+  public ChangeConfigInfo change;
+  public DownloadInfo download;
+  public GerritInfo gerrit;
+  public Boolean noteDbEnabled;
+  public PluginConfigInfo plugin;
+  public SshdInfo sshd;
+  public SuggestInfo suggest;
+  public Map<String, String> urlAliases;
+  public UserConfigInfo user;
+  public ReceiveInfo receive;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshdInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshdInfo.java
new file mode 100644
index 0000000..fb9cb16
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshdInfo.java
@@ -0,0 +1,17 @@
+// 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.common;
+
+public class SshdInfo {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestInfo.java
new file mode 100644
index 0000000..91ca547
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestInfo.java
@@ -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.
+
+package com.google.gerrit.extensions.common;
+
+public class SuggestInfo {
+  public int from;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
index 697caf1..3b86e42 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SuggestedReviewerInfo.java
@@ -19,4 +19,4 @@
   public GroupBaseInfo group;
   public int count;
   public Boolean confirm;
-}
\ No newline at end of file
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java
index 96a1626..5373cf6 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TestSubmitRuleInput.java
@@ -18,10 +18,10 @@
 
 public class TestSubmitRuleInput {
   public enum Filters {
-    RUN, SKIP
+    RUN,
+    SKIP
   }
 
-  @DefaultInput
-  public String rule;
+  @DefaultInput public String rule;
   public Filters filters;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UserConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UserConfigInfo.java
new file mode 100644
index 0000000..ec03dd0
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UserConfigInfo.java
@@ -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.
+
+package com.google.gerrit.extensions.common;
+
+public class UserConfigInfo {
+  public String anonymousCowardName;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
new file mode 100644
index 0000000..5c35a49
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
@@ -0,0 +1,25 @@
+// 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.common;
+
+public class VotingRangeInfo {
+  public int min;
+  public int max;
+
+  public VotingRangeInfo(int min, int max) {
+    this.min = min;
+    this.max = max;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index d9a34bf..4dd8f02 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.webui.WebLink.Target;
+
 public class WebLinkInfo {
   public String name;
   public String imageUrl;
@@ -26,4 +28,8 @@
     this.url = url;
     this.target = target;
   }
+
+  public WebLinkInfo(String name, String imageUrl, String url) {
+    this(name, imageUrl, url, Target.SELF);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/CloneCommand.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/CloneCommand.java
index f773380..4e459ed 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/CloneCommand.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/CloneCommand.java
@@ -22,8 +22,7 @@
    * Returns the clone command for the given download scheme and project.
    *
    * @param scheme the download scheme for which the command should be returned
-   * @param project the name of the project for which the clone command
-   *        should be returned
+   * @param project the name of the project for which the clone command should be returned
    * @return the clone command
    */
   public abstract String getCommand(DownloadScheme scheme, String project);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/DownloadCommand.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/DownloadCommand.java
index 83f3fc4..5660d04 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/DownloadCommand.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/DownloadCommand.java
@@ -19,15 +19,12 @@
 @ExtensionPoint
 public abstract class DownloadCommand {
   /**
-   * Returns the download command for the given download scheme, project and
-   * ref.
+   * Returns the download command for the given download scheme, project and ref.
    *
    * @param scheme the download scheme for which the command should be returned
-   * @param project the name of the project for which the download command
-   *        should be returned
+   * @param project the name of the project for which the download command should be returned
    * @param ref the change ref
    * @return the download command
    */
-  public abstract String getCommand(DownloadScheme scheme, String project,
-      String ref);
+  public abstract String getCommand(DownloadScheme scheme, String project, String ref);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
index d78fa63..d368ed4 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/ExternalIncludedIn.java
@@ -14,29 +14,27 @@
 
 package com.google.gerrit.extensions.config;
 
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
 import java.util.Collection;
 
 @ExtensionPoint
 public interface ExternalIncludedIn {
 
   /**
-   * Returns additional entries for IncludedInInfo as multimap where the
-   * key is the row title and the the values are a list of systems that include
-   * the given commit (e.g. names of servers on which this commit is deployed).
+   * Returns additional entries for IncludedInInfo as multimap where the key is the row title and
+   * the values are a list of systems that include the given commit (e.g. names of servers on which
+   * this commit is deployed).
    *
-   * The tags and branches in which the commit is included are provided so that
-   * a RevWalk can be avoided when a system runs a certain tag or branch.
+   * <p>The tags and branches in which the commit is included are provided so that a RevWalk can be
+   * avoided when a system runs a certain tag or branch.
    *
    * @param project the name of the project
-   * @param commit the ID of the commit for which it should be checked if it is
-   *        included
+   * @param commit the ID of the commit for which it should be checked if it is included
    * @param tags the tags that include the commit
    * @param branches the branches that include the commit
    * @return additional entries for IncludedInInfo
    */
-  Multimap<String, String> getIncludedIn(String project, String commit,
-      Collection<String> tags, Collection<String> branches);
+  ListMultimap<String, String> getIncludedIn(
+      String project, String commit, Collection<String> tags, Collection<String> branches);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java
index 226a6c2..793a372 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java
@@ -20,10 +20,9 @@
 public abstract class FactoryModule extends AbstractModule {
   /**
    * Register an assisted injection factory.
-   * <p>
-   * This function provides an automatic way to define a factory that creates a
-   * concrete type through assisted injection. For example to configure the
-   * following assisted injection case:
+   *
+   * <p>This function provides an automatic way to define a factory that creates a concrete type
+   * through assisted injection. For example to configure the following assisted injection case:
    *
    * <pre>
    * public class Foo {
@@ -35,8 +34,8 @@
    * }
    * </pre>
    *
-   * Just pass {@code Foo.Factory.class} to this method. The factory will be
-   * generated to return its one return type as declared in the creation method.
+   * Just pass {@code Foo.Factory.class} to this method. The factory will be generated to return its
+   * one return type as declared in the creation method.
    *
    * @param factory interface which specifies the bean factory method.
    */
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java
index 3263e70..25a2b26 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AccountIndexedListener.java
@@ -16,14 +16,13 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 
-/**
- * Notified whenever an account is indexed
- */
+/** Notified whenever an account is indexed */
 @ExtensionPoint
 public interface AccountIndexedListener {
-/**
- * Invoked when an account is indexed
- * @param id of the account
- */
+  /**
+   * Invoked when an account is indexed
+   *
+   * @param id of the account
+   */
   void onAccountIndexed(int id);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
index 5abfc38..798d046 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AgreementSignupListener.java
@@ -22,6 +22,7 @@
 public interface AgreementSignupListener {
   interface Event extends GerritEvent {
     AccountInfo getAccount();
+
     String getAgreementName();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
new file mode 100644
index 0000000..7fc0f03
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
@@ -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.
+
+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 whenever a change assignee is changed. */
+@ExtensionPoint
+public interface AssigneeChangedListener {
+  interface Event extends ChangeEvent {
+    @Nullable
+    AccountInfo getOldAssignee();
+  }
+
+  void onAssigneeChanged(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
index 40b84a3..d18f3e5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeAbandonedListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change is abandoned. */
 @ExtensionPoint
 public interface ChangeAbandonedListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getAbandoner();
     String getReason();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
new file mode 100644
index 0000000..70014f3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a Change is deleted. */
+@ExtensionPoint
+public interface ChangeDeletedListener {
+  interface Event extends ChangeEvent {}
+
+  void onChangeDeleted(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
index f012710..0b51052 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeEvent.java
@@ -16,12 +16,13 @@
 
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-
 import java.sql.Timestamp;
 
 /** Interface to be extended by Events with a Change. */
 public interface ChangeEvent extends GerritEvent {
   ChangeInfo getChange();
+
   AccountInfo getWho();
+
   Timestamp getWhen();
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
index d0ca6d6..de74a86 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeMergedListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change is merged. */
 @ExtensionPoint
 public interface ChangeMergedListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getMerger();
     /**
      * Represents the merged Revision when the submit strategy is cherry-pick or
      * rebase-if-necessary.
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
index e5f3330..f533339 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeRestoredListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change is restored. */
 @ExtensionPoint
 public interface ChangeRestoredListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getRestorer();
     String getReason();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
index 6c82034..071dac1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/CommentAddedListener.java
@@ -15,19 +15,17 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
-
 import java.util.Map;
 
 /** Notified whenever a comment is added to a change. */
 @ExtensionPoint
 public interface CommentAddedListener {
   interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getAuthor();
     String getComment();
+
     Map<String, ApprovalInfo> getApprovals();
+
     Map<String, ApprovalInfo> getOldApprovals();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
index 3857468..edbdcd8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
@@ -15,15 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Draft is published. */
 @ExtensionPoint
 public interface DraftPublishedListener {
-  interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getPublisher();
-  }
+  interface Event extends RevisionEvent {}
 
   void onDraftPublished(Event event);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
index f15dd4d..edb3e69 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
@@ -15,18 +15,14 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
 import java.util.Properties;
 
-/**
- * Notified whenever the garbage collector has run successfully on a project.
- */
+/** Notified whenever the garbage collector has run successfully on a project. */
 @ExtensionPoint
 public interface GarbageCollectorListener {
   interface Event extends ProjectEvent {
     /**
-     * @return Properties describing the result of the garbage collection
-     *         performed by JGit.
+     * @return Properties describing the result of the garbage collection performed by JGit.
      * @see org.eclipse.jgit.api.GarbageCollectCommand#call()
      */
     Properties getStatistics();
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 3f7dfbe..bf922f8 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
@@ -23,15 +23,19 @@
 public interface GitReferenceUpdatedListener {
   interface Event extends ProjectEvent {
     String getRefName();
+
     String getOldObjectId();
+
     String getNewObjectId();
+
     boolean isCreate();
+
     boolean isDelete();
+
     boolean isNonFastForward();
-    /**
-     * The updater, could be null if it's the server.
-     */
-    @Nullable AccountInfo getUpdater();
+    /** The updater, could be null if it's the server. */
+    @Nullable
+    AccountInfo getUpdater();
   }
 
   void onGitReferenceUpdated(Event event);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GroupIndexedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GroupIndexedListener.java
new file mode 100644
index 0000000..d499020
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/GroupIndexedListener.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a group is indexed */
+@ExtensionPoint
+public interface GroupIndexedListener {
+  /**
+   * Invoked when a group is indexed
+   *
+   * @param uuid of the group
+   */
+  void onGroupIndexed(String uuid);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
index c49b0f3..9c8495d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HashtagsEditedListener.java
@@ -15,18 +15,16 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
-
 import java.util.Collection;
 
 /** Notified whenever a Change's Hashtags are edited. */
 @ExtensionPoint
 public interface HashtagsEditedListener {
   interface Event extends ChangeEvent {
-    @Deprecated
-    AccountInfo getEditor();
     Collection<String> getHashtags();
+
     Collection<String> getAddedHashtags();
+
     Collection<String> getRemovedHashtags();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
index e11c857..d876426 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/HeadUpdatedListener.java
@@ -21,6 +21,7 @@
 public interface HeadUpdatedListener {
   interface Event extends ProjectEvent {
     String getOldHeadName();
+
     String getNewHeadName();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
index b3ed37b..dae4b54 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/LifecycleListener.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
 import java.util.EventListener;
 
 /** Listener interested in server startup and shutdown events. */
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
index 07c0bf6..82ea51d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/NewProjectCreatedListener.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 
-
 /** Notified whenever a project is created on the master. */
 @ExtensionPoint
 public interface NewProjectCreatedListener {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
index dfcbdee..2ba2bd5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PluginEventListener.java
@@ -18,7 +18,9 @@
 public interface PluginEventListener {
   interface Event extends GerritEvent {
     String pluginName();
+
     String getType();
+
     String getData();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
index 468950f..dd55166 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectDeletedListener.java
@@ -19,8 +19,7 @@
 /** Notified whenever a project is deleted on the master. */
 @ExtensionPoint
 public interface ProjectDeletedListener {
-  interface Event extends ProjectEvent {
-  }
+  interface Event extends ProjectEvent {}
 
   void onProjectDeleted(Event event);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
index 3cc3fdc..b54b913 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerAddedListener.java
@@ -16,13 +16,14 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.common.AccountInfo;
+import java.util.List;
 
-/** Notified whenever a Reviewer is added to a change. */
+/** Notified whenever one or more Reviewers are added to a change. */
 @ExtensionPoint
 public interface ReviewerAddedListener {
   interface Event extends ChangeEvent {
-    AccountInfo getReviewer();
+    List<AccountInfo> getReviewers();
   }
 
-  void onReviewerAdded(Event event);
+  void onReviewersAdded(Event event);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
index 3c2f723..992b4c5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ReviewerDeletedListener.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
-
 import java.util.Map;
 
 /** Notified whenever a Reviewer is removed from a change. */
@@ -25,8 +24,11 @@
 public interface ReviewerDeletedListener {
   interface Event extends ChangeEvent {
     AccountInfo getReviewer();
+
     String getComment();
+
     Map<String, ApprovalInfo> getNewApprovals();
+
     Map<String, ApprovalInfo> getOldApprovals();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
index 5e4e095..6ff537a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionCreatedListener.java
@@ -15,15 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change Revision is created. */
 @ExtensionPoint
 public interface RevisionCreatedListener {
-  interface Event extends RevisionEvent {
-    @Deprecated
-    AccountInfo getUploader();
-  }
+  interface Event extends RevisionEvent {}
 
   void onRevisionCreated(Event event);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
index 27d1067..f0cfa2c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/RevisionEvent.java
@@ -20,4 +20,3 @@
 public interface RevisionEvent extends ChangeEvent {
   RevisionInfo getRevision();
 }
-
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
index 68ba22c..0c36d9d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/TopicEditedListener.java
@@ -15,14 +15,11 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
 
 /** Notified whenever a Change Topic is changed. */
 @ExtensionPoint
 public interface TopicEditedListener {
   interface Event extends ChangeEvent {
-    @Deprecated
-    AccountInfo getEditor();
     String getOldTopic();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
index 35d49b1..8682b20 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/UsageDataPublishedListener.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
 import java.sql.Timestamp;
 import java.util.List;
 
@@ -25,19 +24,25 @@
 
   interface Event {
     MetaData getMetaData();
+
     Timestamp getInstant();
+
     List<Data> getData();
   }
 
   interface Data {
     long getValue();
+
     String getProjectName();
   }
 
   interface MetaData {
     String getName();
+
     String getUnitName();
+
     String getUnitSymbol();
+
     String getDescription();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java
index 01a83e3..2e2a8f6 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/VoteDeletedListener.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
-
 import java.util.Map;
 
 /** Notified whenever a vote is removed from a change. */
@@ -24,9 +24,12 @@
 public interface VoteDeletedListener {
   interface Event extends RevisionEvent {
     Map<String, ApprovalInfo> getOldApprovals();
+
     Map<String, ApprovalInfo> getApprovals();
-    Map<String, ApprovalInfo> getRemoved();
+
     String getMessage();
+
+    AccountInfo getReviewer();
   }
 
   void onVoteDeleted(Event event);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java
index 52be977..477b666 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -23,16 +23,15 @@
 import com.google.inject.binder.LinkedBindingBuilder;
 import com.google.inject.util.Providers;
 import com.google.inject.util.Types;
-
 import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * A single item that can be modified as plugins reload.
- * <p>
- * DynamicItems are always mapped as singletons in Guice. Items store a Provider
- * internally, and resolve the provider to an instance on demand. This enables
- * registrations to decide between singleton and non-singleton members. If
- * multiple plugins try to provide the same Provider, an exception is thrown.
+ *
+ * <p>DynamicItems are always mapped as singletons in Guice. Items store a Provider internally, and
+ * resolve the provider to an instance on demand. This enables registrations to decide between
+ * singleton and non-singleton members. If multiple plugins try to provide the same Provider, an
+ * exception is thrown.
  */
 public class DynamicItem<T> {
   /** Pair of provider implementation and plugin providing it. */
@@ -48,8 +47,9 @@
 
   /**
    * Declare a singleton {@code DynamicItem<T>} with a binder.
-   * <p>
-   * Items must be defined in a Guice module before they can be bound:
+   *
+   * <p>Items must be defined in a Guice module before they can be bound:
+   *
    * <pre>
    *   DynamicItem.itemOf(binder(), Interface.class);
    *   DynamicItem.bind(binder(), Interface.class).to(Impl.class);
@@ -64,8 +64,9 @@
 
   /**
    * Declare a singleton {@code DynamicItem<T>} with a binder.
-   * <p>
-   * Items must be defined in a Guice module before they can be bound:
+   *
+   * <p>Items must be defined in a Guice module before they can be bound:
+   *
    * <pre>
    *   DynamicSet.itemOf(binder(), new TypeLiteral&lt;Thing&lt;Foo&gt;&gt;() {});
    * </pre>
@@ -75,29 +76,25 @@
    */
   public static <T> void itemOf(Binder binder, TypeLiteral<T> member) {
     Key<DynamicItem<T>> key = keyFor(member);
-    binder.bind(key)
-      .toProvider(new DynamicItemProvider<>(member, key))
-      .in(Scopes.SINGLETON);
+    binder.bind(key).toProvider(new DynamicItemProvider<>(member, key)).in(Scopes.SINGLETON);
   }
 
   /**
    * Construct a single {@code DynamicItem<T>} with a fixed value.
-   * <p>
-   * Primarily useful for passing {@code DynamicItem}s to constructors in tests.
+   *
+   * <p>Primarily useful for passing {@code DynamicItem}s to constructors in tests.
    *
    * @param member type of item.
    * @param item item to store.
    */
   public static <T> DynamicItem<T> itemOf(Class<T> member, T item) {
-    return new DynamicItem<>(
-        keyFor(TypeLiteral.get(member)),
-        Providers.of(item), "gerrit");
+    return new DynamicItem<>(keyFor(TypeLiteral.get(member)), Providers.of(item), "gerrit");
   }
 
   @SuppressWarnings("unchecked")
   private static <T> Key<DynamicItem<T>> keyFor(TypeLiteral<T> member) {
-    return (Key<DynamicItem<T>>) Key.get(
-        Types.newParameterizedType(DynamicItem.class, member.getType()));
+    return (Key<DynamicItem<T>>)
+        Key.get(Types.newParameterizedType(DynamicItem.class, member.getType()));
   }
 
   /**
@@ -118,8 +115,7 @@
    * @param type type of entry to store.
    * @return a binder to continue configuring the new item.
    */
-  public static <T> LinkedBindingBuilder<T> bind(Binder binder,
-      TypeLiteral<T> type) {
+  public static <T> LinkedBindingBuilder<T> bind(Binder binder, TypeLiteral<T> type) {
     return binder.bind(type);
   }
 
@@ -138,9 +134,8 @@
   /**
    * Get the configured item, or null.
    *
-   * @return the configured item instance; null if no implementation has been
-   *         bound to the item. This is common if no plugin registered an
-   *         implementation for the type.
+   * @return the configured item instance; null if no implementation has been bound to the item.
+   *     This is common if no plugin registered an implementation for the type.
    */
   public T get() {
     NamedProvider<T> item = ref.get();
@@ -171,9 +166,10 @@
     while (!ref.compareAndSet(old, item)) {
       old = ref.get();
       if (old != null && !"gerrit".equals(old.pluginName)) {
-        throw new ProvisionException(String.format(
-            "%s already provided by %s, ignoring plugin %s",
-            key.getTypeLiteral(), old.pluginName, pluginName));
+        throw new ProvisionException(
+            String.format(
+                "%s already provided by %s, ignoring plugin %s",
+                key.getTypeLiteral(), old.pluginName, pluginName));
       }
     }
 
@@ -189,29 +185,27 @@
   /**
    * Set the element that may be hot-replaceable in the future.
    *
-   * @param key unique description from the item's Guice binding. This can be
-   *        later obtained from the registration handle to facilitate matching
-   *        with the new equivalent instance during a hot reload.
+   * @param key unique description from the item's Guice binding. This can be later obtained from
+   *     the registration handle to facilitate matching with the new equivalent instance during a
+   *     hot reload.
    * @param impl the item to set as our value right now. Must not be null.
    * @param pluginName the name of the plugin providing the item.
    * @return a handle that can remove this item later, or hot-swap the item.
    */
-  public ReloadableRegistrationHandle<T> set(Key<T> key, Provider<T> impl,
-      String pluginName) {
+  public ReloadableRegistrationHandle<T> set(Key<T> key, Provider<T> impl, String pluginName) {
     final NamedProvider<T> item = new NamedProvider<>(impl, pluginName);
     NamedProvider<T> old = null;
     while (!ref.compareAndSet(old, item)) {
       old = ref.get();
-      if (old != null
-          && !"gerrit".equals(old.pluginName)
-          && !pluginName.equals(old.pluginName)) {
+      if (old != null && !"gerrit".equals(old.pluginName) && !pluginName.equals(old.pluginName)) {
         // We allow to replace:
         // 1. Gerrit core items, e.g. websession cache
         //    can be replaced by plugin implementation
         // 2. Reload of current plugin
-        throw new ProvisionException(String.format(
-            "%s already provided by %s, ignoring plugin %s",
-            this.key.getTypeLiteral(), old.pluginName, pluginName));
+        throw new ProvisionException(
+            String.format(
+                "%s already provided by %s, ignoring plugin %s",
+                this.key.getTypeLiteral(), old.pluginName, pluginName));
       }
     }
     return new ReloadableHandle(key, item, old);
@@ -222,8 +216,7 @@
     private final NamedProvider<T> item;
     private final NamedProvider<T> defaultItem;
 
-    ReloadableHandle(Key<T> handleKey, NamedProvider<T> item,
-        NamedProvider<T> defaultItem) {
+    ReloadableHandle(Key<T> handleKey, NamedProvider<T> item, NamedProvider<T> defaultItem) {
       this.handleKey = handleKey;
       this.item = item;
       this.defaultItem = defaultItem;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
index 01551e6..5b76741 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
@@ -21,15 +21,13 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
-
 import java.util.List;
 
 class DynamicItemProvider<T> implements Provider<DynamicItem<T>> {
   private final TypeLiteral<T> type;
   private final Key<DynamicItem<T>> key;
 
-  @Inject
-  private Injector injector;
+  @Inject private Injector injector;
 
   DynamicItemProvider(TypeLiteral<T> type, Key<DynamicItem<T>> key) {
     this.type = type;
@@ -46,10 +44,11 @@
     if (bindings != null && bindings.size() == 1) {
       return bindings.get(0).getProvider();
     } else if (bindings != null && bindings.size() > 1) {
-      throw new ProvisionException(String.format(
-        "Multiple providers bound for DynamicItem<%s>\n"
-        + "This is not allowed; check the server configuration.",
-        type));
+      throw new ProvisionException(
+          String.format(
+              "Multiple providers bound for DynamicItem<%s>\n"
+                  + "This is not allowed; check the server configuration.",
+              type));
     } else {
       return null;
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMap.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMap.java
index d3db2e9..e0db0c7 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMap.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMap.java
@@ -21,7 +21,6 @@
 import com.google.inject.Scopes;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Types;
-
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.Map;
@@ -34,18 +33,18 @@
 
 /**
  * A map of members that can be modified as plugins reload.
- * <p>
- * Maps index their members by plugin name and export name.
- * <p>
- * DynamicMaps are always mapped as singletons in Guice. Maps store Providers
- * internally, and resolve the provider to an instance on demand. This enables
- * registrations to decide between singleton and non-singleton members.
+ *
+ * <p>Maps index their members by plugin name and export name.
+ *
+ * <p>DynamicMaps are always mapped as singletons in Guice. Maps store Providers internally, and
+ * resolve the provider to an instance on demand. This enables registrations to decide between
+ * singleton and non-singleton members.
  */
 public abstract class DynamicMap<T> implements Iterable<DynamicMap.Entry<T>> {
   /**
    * Declare a singleton {@code DynamicMap<T>} with a binder.
-   * <p>
-   * Maps must be defined in a Guice module before they can be bound:
+   *
+   * <p>Maps must be defined in a Guice module before they can be bound:
    *
    * <pre>
    * DynamicMap.mapOf(binder(), Interface.class);
@@ -63,8 +62,8 @@
 
   /**
    * Declare a singleton {@code DynamicMap<T>} with a binder.
-   * <p>
-   * Maps must be defined in a Guice module before they can be bound:
+   *
+   * <p>Maps must be defined in a Guice module before they can be bound:
    *
    * <pre>
    * DynamicMap.mapOf(binder(), new TypeLiteral&lt;Thing&lt;Bar&gt;&gt;(){});
@@ -78,20 +77,20 @@
    */
   public static <T> void mapOf(Binder binder, TypeLiteral<T> member) {
     @SuppressWarnings("unchecked")
-    Key<DynamicMap<T>> key = (Key<DynamicMap<T>>) Key.get(
-        Types.newParameterizedType(DynamicMap.class, member.getType()));
-    binder.bind(key)
-        .toProvider(new DynamicMapProvider<>(member))
-        .in(Scopes.SINGLETON);
+    Key<DynamicMap<T>> key =
+        (Key<DynamicMap<T>>)
+            Key.get(Types.newParameterizedType(DynamicMap.class, member.getType()));
+    binder.bind(key).toProvider(new DynamicMapProvider<>(member)).in(Scopes.SINGLETON);
   }
 
   final ConcurrentMap<NamePair, Provider<T>> items;
 
   DynamicMap() {
-    items = new ConcurrentHashMap<>(
-        16 /* initial size */,
-        0.75f /* load factor */,
-        1 /* concurrency level of 1, load/unload is single threaded */);
+    items =
+        new ConcurrentHashMap<>(
+            16 /* initial size */,
+            0.75f /* load factor */,
+            1 /* concurrency level of 1, load/unload is single threaded */);
   }
 
   /**
@@ -99,10 +98,10 @@
    *
    * @param pluginName local name of the plugin providing the item.
    * @param exportName name the plugin exports the item as.
-   * @return the implementation. Null if the plugin is not running, or if the
-   *         plugin does not export this name.
-   * @throws ProvisionException if the registered provider is unable to obtain
-   *         an instance of the requested implementation.
+   * @return the implementation. Null if the plugin is not running, or if the plugin does not export
+   *     this name.
+   * @throws ProvisionException if the registered provider is unable to obtain an instance of the
+   *     requested implementation.
    */
   public T get(String pluginName, String exportName) throws ProvisionException {
     Provider<T> p = items.get(new NamePair(pluginName, exportName));
@@ -141,8 +140,7 @@
   /** Iterate through all entries in an undefined order. */
   @Override
   public Iterator<Entry<T>> iterator() {
-    final Iterator<Map.Entry<NamePair, Provider<T>>> i =
-        items.entrySet().iterator();
+    final Iterator<Map.Entry<NamePair, Provider<T>>> i = items.entrySet().iterator();
     return new Iterator<Entry<T>>() {
       @Override
       public boolean hasNext() {
@@ -179,7 +177,9 @@
 
   public interface Entry<T> {
     String getPluginName();
+
     String getExportName();
+
     Provider<T> getProvider();
   }
 
@@ -201,8 +201,7 @@
     public boolean equals(Object other) {
       if (other instanceof NamePair) {
         NamePair np = (NamePair) other;
-        return pluginName.equals(np.pluginName)
-            && exportName.equals(np.exportName);
+        return pluginName.equals(np.pluginName) && exportName.equals(np.exportName);
       }
       return false;
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
index 8fcbdd9..420a356 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicMapProvider.java
@@ -19,14 +19,12 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
-
 import java.util.List;
 
 class DynamicMapProvider<T> implements Provider<DynamicMap<T>> {
   private final TypeLiteral<T> type;
 
-  @Inject
-  private Injector injector;
+  @Inject private Injector injector;
 
   DynamicMapProvider(TypeLiteral<T> type) {
     this.type = type;
@@ -34,8 +32,7 @@
 
   @Override
   public DynamicMap<T> get() {
-    PrivateInternals_DynamicMapImpl<T> m =
-        new PrivateInternals_DynamicMapImpl<>();
+    PrivateInternals_DynamicMapImpl<T> m = new PrivateInternals_DynamicMapImpl<>();
     List<Binding<T>> bindings = injector.findBindingsByType(type);
     if (bindings != null) {
       for (Binding<T> b : bindings) {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 6eb11bc..926818e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -24,7 +24,6 @@
 import com.google.inject.name.Named;
 import com.google.inject.util.Providers;
 import com.google.inject.util.Types;
-
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
@@ -34,16 +33,17 @@
 
 /**
  * A set of members that can be modified as plugins reload.
- * <p>
- * DynamicSets are always mapped as singletons in Guice. Sets store Providers
- * internally, and resolve the provider to an instance on demand. This enables
- * registrations to decide between singleton and non-singleton members.
+ *
+ * <p>DynamicSets are always mapped as singletons in Guice. Sets store Providers internally, and
+ * resolve the provider to an instance on demand. This enables registrations to decide between
+ * singleton and non-singleton members.
  */
 public class DynamicSet<T> implements Iterable<T> {
   /**
    * Declare a singleton {@code DynamicSet<T>} with a binder.
-   * <p>
-   * Sets must be defined in a Guice module before they can be bound:
+   *
+   * <p>Sets must be defined in a Guice module before they can be bound:
+   *
    * <pre>
    *   DynamicSet.setOf(binder(), Interface.class);
    *   DynamicSet.bind(binder(), Interface.class).to(Impl.class);
@@ -59,8 +59,9 @@
 
   /**
    * Declare a singleton {@code DynamicSet<T>} with a binder.
-   * <p>
-   * Sets must be defined in a Guice module before they can be bound:
+   *
+   * <p>Sets must be defined in a Guice module before they can be bound:
+   *
    * <pre>
    *   DynamicSet.setOf(binder(), new TypeLiteral&lt;Thing&lt;Foo&gt;&gt;() {});
    * </pre>
@@ -70,12 +71,11 @@
    */
   public static <T> void setOf(Binder binder, TypeLiteral<T> member) {
     @SuppressWarnings("unchecked")
-    Key<DynamicSet<T>> key = (Key<DynamicSet<T>>) Key.get(
-        Types.newParameterizedType(DynamicSet.class, member.getType()));
+    Key<DynamicSet<T>> key =
+        (Key<DynamicSet<T>>)
+            Key.get(Types.newParameterizedType(DynamicSet.class, member.getType()));
     binder.disableCircularProxies();
-    binder.bind(key)
-      .toProvider(new DynamicSetProvider<>(member))
-      .in(Scopes.SINGLETON);
+    binder.bind(key).toProvider(new DynamicSetProvider<>(member)).in(Scopes.SINGLETON);
   }
 
   /**
@@ -107,13 +107,10 @@
    *
    * @param binder a new binder created in the module.
    * @param type type of entries in the set.
-   * @param name {@code @Named} annotation to apply instead of a unique
-   *        annotation.
+   * @param name {@code @Named} annotation to apply instead of a unique annotation.
    * @return a binder to continue configuring the new set member.
    */
-  public static <T> LinkedBindingBuilder<T> bind(Binder binder,
-      Class<T> type,
-      Named name) {
+  public static <T> LinkedBindingBuilder<T> bind(Binder binder, Class<T> type, Named name) {
     binder.disableCircularProxies();
     return bind(binder, TypeLiteral.get(type));
   }
@@ -123,20 +120,16 @@
    *
    * @param binder a new binder created in the module.
    * @param type type of entries in the set.
-   * @param name {@code @Named} annotation to apply instead of a unique
-   *        annotation.
+   * @param name {@code @Named} annotation to apply instead of a unique annotation.
    * @return a binder to continue configuring the new set member.
    */
-  public static <T> LinkedBindingBuilder<T> bind(Binder binder,
-      TypeLiteral<T> type,
-      Named name) {
+  public static <T> LinkedBindingBuilder<T> bind(Binder binder, TypeLiteral<T> type, Named name) {
     binder.disableCircularProxies();
     return binder.bind(type).annotatedWith(name);
   }
 
   public static <T> DynamicSet<T> emptySet() {
-    return new DynamicSet<>(
-        Collections.<AtomicReference<Provider<T>>> emptySet());
+    return new DynamicSet<>(Collections.<AtomicReference<Provider<T>>>emptySet());
   }
 
   private final CopyOnWriteArrayList<AtomicReference<Provider<T>>> items;
@@ -236,12 +229,12 @@
   /**
    * Add one new element that may be hot-replaceable in the future.
    *
-   * @param key unique description from the item's Guice binding. This can be
-   *        later obtained from the registration handle to facilitate matching
-   *        with the new equivalent instance during a hot reload.
+   * @param key unique description from the item's Guice binding. This can be later obtained from
+   *     the registration handle to facilitate matching with the new equivalent instance during a
+   *     hot reload.
    * @param item the item to add to the collection right now. Must not be null.
-   * @return a handle that can remove this item later, or hot-swap the item
-   *         without it ever leaving the collection.
+   * @return a handle that can remove this item later, or hot-swap the item without it ever leaving
+   *     the collection.
    */
   public ReloadableRegistrationHandle<T> add(Key<T> key, Provider<T> item) {
     AtomicReference<Provider<T>> ref = new AtomicReference<>(item);
@@ -254,9 +247,7 @@
     private final Key<T> key;
     private final Provider<T> item;
 
-    ReloadableHandle(AtomicReference<Provider<T>> ref,
-        Key<T> key,
-        Provider<T> item) {
+    ReloadableHandle(AtomicReference<Provider<T>> ref, Key<T> key, Provider<T> item) {
       this.ref = ref;
       this.key = key;
       this.item = item;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
index d8b027b..707c76a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
@@ -19,7 +19,6 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -28,8 +27,7 @@
 class DynamicSetProvider<T> implements Provider<DynamicSet<T>> {
   private final TypeLiteral<T> type;
 
-  @Inject
-  private Injector injector;
+  @Inject private Injector injector;
 
   DynamicSetProvider(TypeLiteral<T> type) {
     this.type = type;
@@ -40,9 +38,7 @@
     return new DynamicSet<>(find(injector, type));
   }
 
-  private static <T> List<AtomicReference<Provider<T>>> find(
-      Injector src,
-      TypeLiteral<T> type) {
+  private static <T> List<AtomicReference<Provider<T>>> find(Injector src, TypeLiteral<T> type) {
     List<Binding<T>> bindings = src.findBindingsByType(type);
     int cnt = bindings != null ? bindings.size() : 0;
     if (cnt == 0) {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index e930a69..5057529 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -20,8 +20,7 @@
 
 /** <b>DO NOT USE</b> */
 public class PrivateInternals_DynamicMapImpl<T> extends DynamicMap<T> {
-  PrivateInternals_DynamicMapImpl() {
-  }
+  PrivateInternals_DynamicMapImpl() {}
 
   /**
    * Store one new element into the map.
@@ -31,9 +30,7 @@
    * @param item the item to add to the collection. Must not be null.
    * @return handle to remove the item at a later point in time.
    */
-  public RegistrationHandle put(
-      String pluginName, String exportName,
-      final Provider<T> item) {
+  public RegistrationHandle put(String pluginName, String exportName, final Provider<T> item) {
     final NamePair key = new NamePair(pluginName, exportName);
     items.put(key, item);
     return new RegistrationHandle() {
@@ -48,17 +45,14 @@
    * Store one new element that may be hot-replaceable in the future.
    *
    * @param pluginName unique name of the plugin providing the export.
-   * @param key unique description from the item's Guice binding. This can be
-   *        later obtained from the registration handle to facilitate matching
-   *        with the new equivalent instance during a hot reload. The key must
-   *        use an {@link Export} annotation.
+   * @param key unique description from the item's Guice binding. This can be later obtained from
+   *     the registration handle to facilitate matching with the new equivalent instance during a
+   *     hot reload. The key must use an {@link Export} annotation.
    * @param item the item to add to the collection right now. Must not be null.
-   * @return a handle that can remove this item later, or hot-swap the item
-   *         without it ever leaving the collection.
+   * @return a handle that can remove this item later, or hot-swap the item without it ever leaving
+   *     the collection.
    */
-  public ReloadableRegistrationHandle<T> put(
-      String pluginName, Key<T> key,
-      Provider<T> item) {
+  public ReloadableRegistrationHandle<T> put(String pluginName, Key<T> key, Provider<T> item) {
     String exportName = ((Export) key.getAnnotation()).value();
     NamePair np = new NamePair(pluginName, exportName);
     items.put(np, item);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index 683e0b9..e606079 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -20,7 +20,6 @@
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.TypeLiteral;
-
 import java.lang.reflect.ParameterizedType;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -36,7 +35,8 @@
       TypeLiteral<?> type = e.getKey().getTypeLiteral();
       if (type.getRawType() == DynamicItem.class) {
         ParameterizedType p = (ParameterizedType) type.getType();
-        m.put(TypeLiteral.get(p.getActualTypeArguments()[0]),
+        m.put(
+            TypeLiteral.get(p.getActualTypeArguments()[0]),
             (DynamicItem<?>) e.getValue().getProvider().get());
       }
     }
@@ -52,7 +52,8 @@
       TypeLiteral<?> type = e.getKey().getTypeLiteral();
       if (type.getRawType() == DynamicSet.class) {
         ParameterizedType p = (ParameterizedType) type.getType();
-        m.put(TypeLiteral.get(p.getActualTypeArguments()[0]),
+        m.put(
+            TypeLiteral.get(p.getActualTypeArguments()[0]),
             (DynamicSet<?>) e.getValue().getProvider().get());
       }
     }
@@ -68,7 +69,8 @@
       TypeLiteral<?> type = e.getKey().getTypeLiteral();
       if (type.getRawType() == DynamicMap.class) {
         ParameterizedType p = (ParameterizedType) type.getType();
-        m.put(TypeLiteral.get(p.getActualTypeArguments()[0]),
+        m.put(
+            TypeLiteral.get(p.getActualTypeArguments()[0]),
             (DynamicMap<?>) e.getValue().getProvider().get());
       }
     }
@@ -79,8 +81,7 @@
   }
 
   public static List<RegistrationHandle> attachItems(
-      Injector src,
-      Map<TypeLiteral<?>, DynamicItem<?>> items, String pluginName) {
+      Injector src, Map<TypeLiteral<?>, DynamicItem<?>> items, String pluginName) {
     if (src == null || items == null || items.isEmpty()) {
       return Collections.emptyList();
     }
@@ -106,8 +107,7 @@
   }
 
   public static List<RegistrationHandle> attachSets(
-      Injector src,
-      Map<TypeLiteral<?>, DynamicSet<?>> sets) {
+      Injector src, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
     if (src == null || sets == null || sets.isEmpty()) {
       return Collections.emptyList();
     }
@@ -135,9 +135,7 @@
   }
 
   public static List<RegistrationHandle> attachMaps(
-      Injector src,
-      String groupName,
-      Map<TypeLiteral<?>, DynamicMap<?>> maps) {
+      Injector src, String groupName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
     if (src == null || maps == null || maps.isEmpty()) {
       return Collections.emptyList();
     }
@@ -169,8 +167,7 @@
     return new LifecycleListener() {
       private List<RegistrationHandle> handles;
 
-      @Inject
-      private Injector self;
+      @Inject private Injector self;
 
       @Override
       public void start() {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
index 506f281..ba99a7d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
@@ -16,9 +16,9 @@
 
 /**
  * Optional interface for {@link RestCollection}.
- * <p>
- * Collections that implement this interface can accept a {@code PUT} or
- * {@code POST} when the parse method throws {@link ResourceNotFoundException}.
+ *
+ * <p>Collections that implement this interface can accept a {@code PUT} or {@code POST} when the
+ * parse method throws {@link ResourceNotFoundException}.
  */
 public interface AcceptsCreate<P extends RestResource> {
   /**
@@ -26,8 +26,8 @@
    *
    * @param parent parent collection handle.
    * @param id id of the resource being created.
-   * @return a view to perform the creation. The create method must embed the id
-   *         into the newly returned view object, as it will not be passed.
+   * @return a view to perform the creation. The create method must embed the id into the newly
+   *     returned view object, as it will not be passed.
    * @throws RestApiException the view cannot be constructed.
    */
   <I> RestModifyView<P, I> create(P parent, IdString id) throws RestApiException;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
index 2f615c1..eb30140 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.extensions.restapi;
 
-
 /**
  * Optional interface for {@link RestCollection}.
- * <p>
- * Collections that implement this interface can accept a {@code DELETE} directly
- * on the collection itself.
+ *
+ * <p>Collections that implement this interface can accept a {@code DELETE} directly on the
+ * collection itself.
  */
 public interface AcceptsDelete<P extends RestResource> {
   /**
@@ -30,6 +29,5 @@
    * @return a view to perform the deletion.
    * @throws RestApiException the view cannot be constructed.
    */
-  <I> RestModifyView<P, I> delete(P parent, IdString id)
-      throws RestApiException;
+  <I> RestModifyView<P, I> delete(P parent, IdString id) throws RestApiException;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
index 470ea83..ababfcb 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
@@ -16,20 +16,19 @@
 
 /**
  * Optional interface for {@link RestCollection}.
- * <p>
- * Collections that implement this interface can accept a {@code POST} directly
- * on the collection itself when no id was given in the path. This interface is
- * intended to be used with TopLevelResource collections. Nested collections
- * often bind POST on the parent collection to the view implementation handling
- * the insertion of a new member.
+ *
+ * <p>Collections that implement this interface can accept a {@code POST} directly on the collection
+ * itself when no id was given in the path. This interface is intended to be used with
+ * TopLevelResource collections. Nested collections often bind POST on the parent collection to the
+ * view implementation handling the insertion of a new member.
  */
 public interface AcceptsPost<P extends RestResource> {
   /**
    * Handle creation of a child resource by POST on the collection.
    *
    * @param parent parent collection handle.
-   * @return a view to perform the creation. The id of the newly created
-   *         resource should be determined from the input body.
+   * @return a view to perform the creation. The id of the newly created resource should be
+   *     determined from the input body.
    * @throws RestApiException the view cannot be constructed.
    */
   <I> RestModifyView<P, I> post(P parent) throws RestApiException;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java
index d5a9c1f..6e79c3a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BadRequestException.java
@@ -22,4 +22,12 @@
   public BadRequestException(String msg) {
     super(msg);
   }
+
+  /**
+   * @param msg error text for client describing how request is bad.
+   * @param cause cause of this exception.
+   */
+  public BadRequestException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
index 068d9a0..bdddfd9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/BinaryResult.java
@@ -29,11 +29,10 @@
 
 /**
  * Wrapper around a non-JSON result from a {@link RestView}.
- * <p>
- * Views may return this type to signal they want the server glue to write raw
- * data to the client, instead of attempting automatic conversion to JSON. The
- * create form is overloaded to handle plain text from a String, or binary data
- * from a {@code byte[]} or {@code InputSteam}.
+ *
+ * <p>Views may return this type to signal they want the server glue to write raw data to the
+ * client, instead of attempting automatic conversion to JSON. The create form is overloaded to
+ * handle plain text from a String, or binary data from a {@code byte[]} or {@code InputSteam}.
  */
 public abstract class BinaryResult implements Closeable {
   /** Default MIME type for unknown binary data. */
@@ -50,9 +49,8 @@
   }
 
   /**
-   * Produce an {@code application/octet-stream} of unknown length by copying
-   * the InputStream until EOF. The server glue will automatically close this
-   * stream when copying is complete.
+   * Produce an {@code application/octet-stream} of unknown length by copying the InputStream until
+   * EOF. The server glue will automatically close this stream when copying is complete.
    */
   public static BinaryResult create(InputStream data) {
     return new Stream(data);
@@ -86,12 +84,6 @@
   }
 
   /** Set the character set used to encode text data and return {@code this}. */
-  @Deprecated
-  public BinaryResult setCharacterEncoding(String encoding) {
-    return setCharacterEncoding(Charset.forName(encoding));
-  }
-
-  /** Set the character set used to encode text data and return {@code this}. */
   public BinaryResult setCharacterEncoding(Charset encoding) {
     characterEncoding = encoding;
     return this;
@@ -144,22 +136,21 @@
   /**
    * Write or copy the result onto the specified output stream.
    *
-   * @param os stream to write result data onto. This stream will be closed by
-   *        the caller after this method returns.
-   * @throws IOException if the data cannot be produced, or the OutputStream
-   *         {@code os} throws any IOException during a write or flush call.
+   * @param os stream to write result data onto. This stream will be closed by the caller after this
+   *     method returns.
+   * @throws IOException if the data cannot be produced, or the OutputStream {@code os} throws any
+   *     IOException during a write or flush call.
    */
   public abstract void writeTo(OutputStream os) throws IOException;
 
   /**
    * Return a copy of the result as a String.
-   * <p>
-   * The default version of this method copies the result into a temporary byte
-   * array and then tries to decode it using the configured encoding.
+   *
+   * <p>The default version of this method copies the result into a temporary byte array and then
+   * tries to decode it using the configured encoding.
    *
    * @return string version of the result.
-   * @throws IOException if the data cannot be produced or could not be
-   *         decoded to a String.
+   * @throws IOException if the data cannot be produced or could not be decoded to a String.
    */
   public String asString() throws IOException {
     long len = getContentLength();
@@ -175,8 +166,7 @@
 
   /** Close the result and release any resources it holds. */
   @Override
-  public void close() throws IOException {
-  }
+  public void close() throws IOException {}
 
   @Override
   public String toString() {
@@ -186,20 +176,17 @@
           getContentType(), getContentLength());
     }
     return String.format(
-        "BinaryResult[Content-Type: %s, Content-Length: unknown]",
-        getContentType());
+        "BinaryResult[Content-Type: %s, Content-Length: unknown]", getContentType());
   }
 
   private static String decode(byte[] data, Charset enc) {
     try {
-      Charset cs = enc != null
-          ? enc
-          : UTF_8;
+      Charset cs = enc != null ? enc : UTF_8;
       return cs.newDecoder()
-        .onMalformedInput(CodingErrorAction.REPORT)
-        .onUnmappableCharacter(CodingErrorAction.REPORT)
-        .decode(ByteBuffer.wrap(data))
-        .toString();
+          .onMalformedInput(CodingErrorAction.REPORT)
+          .onUnmappableCharacter(CodingErrorAction.REPORT)
+          .decode(ByteBuffer.wrap(data))
+          .toString();
     } catch (UnsupportedCharsetException | CharacterCodingException e) {
       // Fallback to ISO-8850-1 style encoding.
       StringBuilder r = new StringBuilder(data.length);
@@ -235,7 +222,7 @@
     StringResult(String str) {
       super(str.getBytes(UTF_8));
       setContentType("text/plain");
-      setCharacterEncoding(UTF_8.name());
+      setCharacterEncoding(UTF_8);
       this.str = str;
     }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java
index d71732b..eda0cd5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/CacheControl.java
@@ -20,7 +20,9 @@
 
   public enum Type {
     @SuppressWarnings("hiding")
-    NONE, PUBLIC, PRIVATE
+    NONE,
+    PUBLIC,
+    PRIVATE
   }
 
   public static final CacheControl NONE = new CacheControl(Type.NONE, 0, null);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ChildCollection.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ChildCollection.java
index 3bad9f8..59aecc1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ChildCollection.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ChildCollection.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.extensions.restapi;
 
-
 /**
  * Nested collection of {@link RestResource}s below a parent.
  *
@@ -22,5 +21,4 @@
  * @param <C> type of resource operated on by each view.
  */
 public interface ChildCollection<P extends RestResource, C extends RestResource>
-    extends RestView<P>, RestCollection<P, C> {
-}
+    extends RestView<P>, RestCollection<P, C> {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/DefaultInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/DefaultInput.java
index 83342d2..e26ce1f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/DefaultInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/DefaultInput.java
@@ -23,5 +23,4 @@
 /** Applied to a String field to indicate the default input parameter. */
 @Target({ElementType.FIELD})
 @Retention(RUNTIME)
-public @interface DefaultInput {
-}
+public @interface DefaultInput {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
index 3b32829..9ac1706 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ETagView.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.extensions.restapi;
 
-/**
- * A view which may change, although the underlying resource did not change
- */
+/** A view which may change, although the underlying resource did not change */
 public interface ETagView<R extends RestResource> extends RestReadView<R> {
   String getETag(R rsrc);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/IdString.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/IdString.java
index f0f7dea..736c3ba 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/IdString.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/IdString.java
@@ -16,8 +16,8 @@
 
 /**
  * Resource identifier split out from a URL.
- * <p>
- * Identifiers are URL encoded and usually need to be decoded.
+ *
+ * <p>Identifiers are URL encoded and usually need to be decoded.
  */
 public class IdString {
   /** Construct an identifier from an already encoded string. */
@@ -60,8 +60,6 @@
   public boolean equals(Object other) {
     if (other instanceof IdString) {
       return urlEncoded.equals(((IdString) other).urlEncoded);
-    } else if (other instanceof String) {
-      return urlEncoded.equals(other);
     }
     return false;
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/MergeConflictException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/MergeConflictException.java
index a67db0f..06d9024 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/MergeConflictException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/MergeConflictException.java
@@ -16,8 +16,8 @@
 
 /**
  * Indicates that a commit cannot be merged without conflicts.
- * <p>
- * Messages should be viewable by end users.
+ *
+ * <p>Messages should be viewable by end users.
  */
 public class MergeConflictException extends ResourceConflictException {
   private static final long serialVersionUID = 1L;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/NeedsParams.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/NeedsParams.java
new file mode 100644
index 0000000..c6e2151
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/NeedsParams.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+import com.google.common.collect.ListMultimap;
+
+/**
+ * Optional interface for {@link RestCollection}.
+ *
+ * <p>Collections that implement this interface can get to know about the request parameters.
+ */
+public interface NeedsParams {
+  /**
+   * Sets the request parameter.
+   *
+   * @param params the request parameter
+   */
+  void setParams(ListMultimap<String, String> params) throws RestApiException;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
index 86204c8..1fa6cd0 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
@@ -14,14 +14,11 @@
 
 package com.google.gerrit.extensions.restapi;
 
-/**
- * Resource state does not match request state (HTTP 412 Precondition failed).
- */
+/** Resource state does not match request state (HTTP 412 Precondition failed). */
 public class PreconditionFailedException extends RestApiException {
   private static final long serialVersionUID = 1L;
 
-  public PreconditionFailedException() {
-  }
+  public PreconditionFailedException() {}
 
   /** @param msg message to return to the client describing the error. */
   public PreconditionFailedException(String msg) {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RawInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RawInput.java
index 4f195e4..37e11b2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RawInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RawInput.java
@@ -20,6 +20,8 @@
 /** Raw data stream supplied by the body of a PUT or POST. */
 public interface RawInput {
   String getContentType();
+
   long getContentLength();
+
   InputStream getInputStream() throws IOException;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceConflictException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceConflictException.java
index aa503c1..a57accc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceConflictException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceConflictException.java
@@ -16,11 +16,11 @@
 
 /**
  * Resource state does not permit requested operation (HTTP 409 Conflict).
- * <p>
- * {@link RestModifyView} implementations may fail with this exception when the
- * named resource does not permit the modification to take place at this time.
- * An example use is trying to abandon a change that is already merged. The
- * change cannot be abandoned once merged so an operation would throw.
+ *
+ * <p>{@link RestModifyView} implementations may fail with this exception when the named resource
+ * does not permit the modification to take place at this time. An example use is trying to abandon
+ * a change that is already merged. The change cannot be abandoned once merged so an operation would
+ * throw.
  */
 public class ResourceConflictException extends RestApiException {
   private static final long serialVersionUID = 1L;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
index 611812a..e676828 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/ResourceNotFoundException.java
@@ -19,8 +19,7 @@
   private static final long serialVersionUID = 1L;
 
   /** Requested resource is not found, failing portion not specified. */
-  public ResourceNotFoundException() {
-  }
+  public ResourceNotFoundException() {}
 
   public ResourceNotFoundException(String msg) {
     super(msg);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
index 633efea..8f2dd5f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Response.java
@@ -27,8 +27,7 @@
   }
 
   public static <T> Response<T> withMustRevalidate(T value) {
-    return ok(value).caching(
-        CacheControl.PRIVATE(0, TimeUnit.SECONDS).setMustRevalidate());
+    return ok(value).caching(CacheControl.PRIVATE(0, TimeUnit.SECONDS).setMustRevalidate());
   }
 
   /** HTTP 201 Created: typically used when a new resource is made. */
@@ -66,10 +65,15 @@
   }
 
   public abstract boolean isNone();
+
   public abstract int statusCode();
+
   public abstract T value();
+
   public abstract CacheControl caching();
+
   public abstract Response<T> caching(CacheControl c);
+
   @Override
   public abstract String toString();
 
@@ -116,8 +120,7 @@
   }
 
   private static final class None extends Response<Object> {
-    private None() {
-    }
+    private None() {}
 
     @Override
     public boolean isNone() {
@@ -169,8 +172,7 @@
 
     @Override
     public boolean equals(Object o) {
-      return o instanceof Redirect
-        && ((Redirect) o).location.equals(location);
+      return o instanceof Redirect && ((Redirect) o).location.equals(location);
     }
 
     @Override
@@ -198,8 +200,7 @@
 
     @Override
     public boolean equals(Object o) {
-      return o instanceof Accepted
-        && ((Accepted) o).location.equals(location);
+      return o instanceof Accepted && ((Accepted) o).location.equals(location);
     }
 
     @Override
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java
index b6ba730..28398a4 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiException.java
@@ -19,8 +19,7 @@
   private static final long serialVersionUID = 1L;
   private CacheControl caching = CacheControl.NONE;
 
-  public RestApiException() {
-  }
+  public RestApiException() {}
 
   public RestApiException(String msg) {
     super(msg);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiModule.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiModule.java
index 3567300..0db2891 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiModule.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestApiModule.java
@@ -29,56 +29,49 @@
   protected static final String DELETE = "DELETE";
   protected static final String POST = "POST";
 
-  protected <R extends RestResource>
-  ReadViewBinder<R> get(TypeLiteral<RestView<R>> viewType) {
+  protected <R extends RestResource> ReadViewBinder<R> get(TypeLiteral<RestView<R>> viewType) {
     return new ReadViewBinder<>(view(viewType, GET, "/"));
   }
 
-  protected <R extends RestResource>
-  ModifyViewBinder<R> put(TypeLiteral<RestView<R>> viewType) {
+  protected <R extends RestResource> ModifyViewBinder<R> put(TypeLiteral<RestView<R>> viewType) {
     return new ModifyViewBinder<>(view(viewType, PUT, "/"));
   }
 
-  protected <R extends RestResource>
-  ModifyViewBinder<R> post(TypeLiteral<RestView<R>> viewType) {
+  protected <R extends RestResource> ModifyViewBinder<R> post(TypeLiteral<RestView<R>> viewType) {
     return new ModifyViewBinder<>(view(viewType, POST, "/"));
   }
 
-  protected <R extends RestResource>
-  ModifyViewBinder<R> delete(TypeLiteral<RestView<R>> viewType) {
+  protected <R extends RestResource> ModifyViewBinder<R> delete(TypeLiteral<RestView<R>> viewType) {
     return new ModifyViewBinder<>(view(viewType, DELETE, "/"));
   }
 
-  protected <R extends RestResource>
-  ReadViewBinder<R> get(TypeLiteral<RestView<R>> viewType, String name) {
+  protected <R extends RestResource> ReadViewBinder<R> get(
+      TypeLiteral<RestView<R>> viewType, String name) {
     return new ReadViewBinder<>(view(viewType, GET, name));
   }
 
-  protected <R extends RestResource>
-  ModifyViewBinder<R> put(TypeLiteral<RestView<R>> viewType, String name) {
+  protected <R extends RestResource> ModifyViewBinder<R> put(
+      TypeLiteral<RestView<R>> viewType, String name) {
     return new ModifyViewBinder<>(view(viewType, PUT, name));
   }
 
-  protected <R extends RestResource>
-  ModifyViewBinder<R> post(TypeLiteral<RestView<R>> viewType, String name) {
+  protected <R extends RestResource> ModifyViewBinder<R> post(
+      TypeLiteral<RestView<R>> viewType, String name) {
     return new ModifyViewBinder<>(view(viewType, POST, name));
   }
 
-  protected <R extends RestResource>
-  ModifyViewBinder<R> delete(TypeLiteral<RestView<R>> viewType, String name) {
+  protected <R extends RestResource> ModifyViewBinder<R> delete(
+      TypeLiteral<RestView<R>> viewType, String name) {
     return new ModifyViewBinder<>(view(viewType, DELETE, name));
   }
 
-  protected <P extends RestResource>
-  ChildCollectionBinder<P> child(TypeLiteral<RestView<P>> type, String name) {
+  protected <P extends RestResource> ChildCollectionBinder<P> child(
+      TypeLiteral<RestView<P>> type, String name) {
     return new ChildCollectionBinder<>(view(type, GET, name));
   }
 
-  protected <R extends RestResource>
-  LinkedBindingBuilder<RestView<R>> view(
-      TypeLiteral<RestView<R>> viewType,
-      String method,
-      String name) {
+  protected <R extends RestResource> LinkedBindingBuilder<RestView<R>> view(
+      TypeLiteral<RestView<R>> viewType, String method, String name) {
     return bind(viewType).annotatedWith(export(method, name));
   }
 
@@ -99,23 +92,21 @@
       this.binder = binder;
     }
 
-    public <T extends RestReadView<P>>
-    ScopedBindingBuilder to(Class<T> impl) {
+    public <T extends RestReadView<P>> ScopedBindingBuilder to(Class<T> impl) {
       return binder.to(impl);
     }
 
-    public <T extends RestReadView<P>>
-    void toInstance(T impl) {
+    public <T extends RestReadView<P>> void toInstance(T impl) {
       binder.toInstance(impl);
     }
 
-    public <T extends RestReadView<P>>
-    ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+    public <T extends RestReadView<P>> ScopedBindingBuilder toProvider(
+        Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
-    public <T extends RestReadView<P>>
-    ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+    public <T extends RestReadView<P>> ScopedBindingBuilder toProvider(
+        Provider<? extends T> provider) {
       return binder.toProvider(provider);
     }
   }
@@ -127,23 +118,21 @@
       this.binder = binder;
     }
 
-    public <T extends RestModifyView<P, ?>>
-    ScopedBindingBuilder to(Class<T> impl) {
+    public <T extends RestModifyView<P, ?>> ScopedBindingBuilder to(Class<T> impl) {
       return binder.to(impl);
     }
 
-    public <T extends RestModifyView<P, ?>>
-    void toInstance(T impl) {
+    public <T extends RestModifyView<P, ?>> void toInstance(T impl) {
       binder.toInstance(impl);
     }
 
-    public <T extends RestModifyView<P, ?>>
-    ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+    public <T extends RestModifyView<P, ?>> ScopedBindingBuilder toProvider(
+        Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
-    public <T extends RestModifyView<P, ?>>
-    ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+    public <T extends RestModifyView<P, ?>> ScopedBindingBuilder toProvider(
+        Provider<? extends T> provider) {
       return binder.toProvider(provider);
     }
   }
@@ -155,23 +144,22 @@
       this.binder = binder;
     }
 
-    public <C extends RestResource, T extends ChildCollection<P, C>>
-    ScopedBindingBuilder to(Class<T> impl) {
+    public <C extends RestResource, T extends ChildCollection<P, C>> ScopedBindingBuilder to(
+        Class<T> impl) {
       return binder.to(impl);
     }
 
-    public <C extends RestResource, T extends ChildCollection<P, C>>
-    void toInstance(T impl) {
+    public <C extends RestResource, T extends ChildCollection<P, C>> void toInstance(T impl) {
       binder.toInstance(impl);
     }
 
     public <C extends RestResource, T extends ChildCollection<P, C>>
-    ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
+        ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
     public <C extends RestResource, T extends ChildCollection<P, C>>
-    ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
+        ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
       return binder.toProvider(provider);
     }
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestCollection.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestCollection.java
index 96d0dbf..46a4984 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestCollection.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestCollection.java
@@ -18,9 +18,9 @@
 
 /**
  * A collection of resources accessible through a REST API.
- * <p>
- * To build a collection declare a resource, the map in a module, and the
- * collection itself accepting the map:
+ *
+ * <p>To build a collection declare a resource, the map in a module, and the collection itself
+ * accepting the map:
  *
  * <pre>
  * public class MyResource implements RestResource {
@@ -51,19 +51,18 @@
  * }
  * </pre>
  *
- * <p>
- * To build a nested collection, implement {@link ChildCollection}.
+ * <p>To build a nested collection, implement {@link ChildCollection}.
  *
- * @param <P> type of the parent resource. For a top level collection this
- *        should always be {@link TopLevelResource}.
+ * @param <P> type of the parent resource. For a top level collection this should always be {@link
+ *     TopLevelResource}.
  * @param <R> type of resource operated on by each view.
  */
 public interface RestCollection<P extends RestResource, R extends RestResource> {
   /**
    * Create a view to list the contents of the collection.
-   * <p>
-   * The returned view should accept the parent type to scope the search, and
-   * may want to take a "q" parameter option to narrow the results.
+   *
+   * <p>The returned view should accept the parent type to scope the search, and may want to take a
+   * "q" parameter option to narrow the results.
    *
    * @return view to list the collection.
    * @throws ResourceNotFoundException if the collection cannot be listed.
@@ -75,20 +74,20 @@
    * Parse a path component into a resource handle.
    *
    * @param parent the handle to the collection.
-   * @param id string identifier supplied by the client. In a URL such as
-   *        {@code /changes/1234/abandon} this string is {@code "1234"}.
+   * @param id string identifier supplied by the client. In a URL such as {@code
+   *     /changes/1234/abandon} this string is {@code "1234"}.
    * @return a resource handle for the identified object.
-   * @throws ResourceNotFoundException the object does not exist, or the caller
-   *         is not permitted to know if the resource exists.
-   * @throws Exception if the implementation had any errors converting to a
-   *         resource handle. This results in an HTTP 500 Internal Server Error.
+   * @throws ResourceNotFoundException the object does not exist, or the caller is not permitted to
+   *     know if the resource exists.
+   * @throws Exception if the implementation had any errors converting to a resource handle. This
+   *     results in an HTTP 500 Internal Server Error.
    */
   R parse(P parent, IdString id) throws ResourceNotFoundException, Exception;
 
   /**
    * Get the views that support this collection.
-   * <p>
-   * Within a resource the views are accessed as {@code RESOURCE/plugin~view}.
+   *
+   * <p>Within a resource the views are accessed as {@code RESOURCE/plugin~view}.
    *
    * @return map of views.
    */
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestModifyView.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestModifyView.java
index 2fa0278..79053dd 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestModifyView.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestModifyView.java
@@ -16,10 +16,10 @@
 
 /**
  * RestView that supports accepting input and changing a resource.
- * <p>
- * The input must be supplied as JSON as the body of the HTTP request. Modify
- * views can be invoked by any HTTP method that is not {@code GET}, which
- * includes {@code POST}, {@code PUT}, {@code DELETE}.
+ *
+ * <p>The input must be supplied as JSON as the body of the HTTP request. Modify views can be
+ * invoked by any HTTP method that is not {@code GET}, which includes {@code POST}, {@code PUT},
+ * {@code DELETE}.
  *
  * @param <R> type of the resource the view modifies.
  * @param <I> type of input the JSON parser will parse the input into.
@@ -30,17 +30,16 @@
    *
    * @param resource resource to modify.
    * @param input input after parsing from request.
-   * @return result to return to the client. Use {@link BinaryResult} to avoid
-   *         automatic conversion to JSON.
+   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
+   *     to JSON.
    * @throws AuthException the client is not permitted to access this view.
-   * @throws BadRequestException the request was incorrectly specified and
-   *         cannot be handled by this view.
-   * @throws ResourceConflictException the resource state does not permit this
-   *         view to make the changes at this time.
-   * @throws Exception the implementation of the view failed. The exception will
-   *         be logged and HTTP 500 Internal Server Error will be returned to
-   *         the client.
+   * @throws BadRequestException the request was incorrectly specified and cannot be handled by this
+   *     view.
+   * @throws ResourceConflictException the resource state does not permit this view to make the
+   *     changes at this time.
+   * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
+   *     500 Internal Server Error will be returned to the client.
    */
-  Object apply(R resource, I input) throws AuthException, BadRequestException,
-      ResourceConflictException, Exception;
+  Object apply(R resource, I input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestReadView.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestReadView.java
index 21e9ee0..a3c31d3 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestReadView.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestReadView.java
@@ -24,17 +24,16 @@
    * Process the view operation by reading from the resource.
    *
    * @param resource resource to read.
-   * @return result to return to the client. Use {@link BinaryResult} to avoid
-   *         automatic conversion to JSON.
+   * @return result to return to the client. Use {@link BinaryResult} to avoid automatic conversion
+   *     to JSON.
    * @throws AuthException the client is not permitted to access this view.
-   * @throws BadRequestException the request was incorrectly specified and
-   *         cannot be handled by this view.
-   * @throws ResourceConflictException the resource state does not permit this
-   *         view to make the changes at this time.
-   * @throws Exception the implementation of the view failed. The exception will
-   *         be logged and HTTP 500 Internal Server Error will be returned to
-   *         the client.
+   * @throws BadRequestException the request was incorrectly specified and cannot be handled by this
+   *     view.
+   * @throws ResourceConflictException the resource state does not permit this view to make the
+   *     changes at this time.
+   * @throws Exception the implementation of the view failed. The exception will be logged and HTTP
+   *     500 Internal Server Error will be returned to the client.
    */
-  Object apply(R resource) throws AuthException, BadRequestException,
-      ResourceConflictException, Exception;
+  Object apply(R resource)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
index 29c824f..cc5d48d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestResource.java
@@ -18,18 +18,15 @@
 
 /**
  * Generic resource handle defining arguments to views.
- * <p>
- * Resource handle returned by {@link RestCollection} and passed to a
- * {@link RestView} such as {@link RestReadView} or {@link RestModifyView}.
+ *
+ * <p>Resource handle returned by {@link RestCollection} and passed to a {@link RestView} such as
+ * {@link RestReadView} or {@link RestModifyView}.
  */
 public interface RestResource {
 
   /** A resource with a last modification date. */
   public interface HasLastModified {
-    /**
-     * @return time for the Last-Modified header. HTTP truncates the header
-     *         value to seconds.
-     */
+    /** @return time for the Last-Modified header. HTTP truncates the header value to seconds. */
     Timestamp getLastModified();
   }
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestView.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestView.java
index 36adf34..447b959 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestView.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/RestView.java
@@ -15,8 +15,7 @@
 package com.google.gerrit.extensions.restapi;
 
 /**
- * Any type of view, see {@link RestReadView} for reads, {@link RestModifyView}
- * for updates, and {@link RestCollection} for nested collections.
+ * Any type of view, see {@link RestReadView} for reads, {@link RestModifyView} for updates, and
+ * {@link RestCollection} for nested collections.
  */
-public interface RestView<R extends RestResource> {
-}
+public interface RestView<R extends RestResource> {}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/TopLevelResource.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/TopLevelResource.java
index 8ddd207..1c50f42 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/TopLevelResource.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/TopLevelResource.java
@@ -18,6 +18,5 @@
 public class TopLevelResource implements RestResource {
   public static final TopLevelResource INSTANCE = new TopLevelResource();
 
-  private TopLevelResource() {
-  }
+  private TopLevelResource() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
index b63697f..9ed83b2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
@@ -18,7 +18,7 @@
 public class UnprocessableEntityException extends RestApiException {
   private static final long serialVersionUID = 1L;
 
-  public UnprocessableEntityException(String msg)  {
+  public UnprocessableEntityException(String msg) {
     super(msg);
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Url.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Url.java
index debfa20..9c69376 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Url.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/Url.java
@@ -24,17 +24,18 @@
 public final class Url {
   /**
    * Encode a path segment, escaping characters not valid for a URL.
-   * <p>
-   * The following characters are not escaped:
+   *
+   * <p>The following characters are not escaped:
+   *
    * <ul>
-   * <li>{@code a..z, A..Z, 0..9}
-   * <li>{@code . - * _}
+   *   <li>{@code a..z, A..Z, 0..9}
+   *   <li>{@code . - * _}
    * </ul>
-   * <p>
-   * ' ' (space) is encoded as '+'.
-   * <p>
-   * All other characters (including '/') are converted to the triplet "%xy"
-   * where "xy" is the hex representation of the character in UTF-8.
+   *
+   * <p>' ' (space) is encoded as '+'.
+   *
+   * <p>All other characters (including '/') are converted to the triplet "%xy" where "xy" is the
+   * hex representation of the character in UTF-8.
    *
    * @param component a string containing text to encode.
    * @return a string with all invalid URL characters escaped.
@@ -62,6 +63,5 @@
     return null;
   }
 
-  private Url() {
-  }
+  private Url() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/MessageOfTheDay.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/MessageOfTheDay.java
index 033e7b4..180a0e6 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/MessageOfTheDay.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/MessageOfTheDay.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.systemstatus;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
 import java.util.Calendar;
 import java.util.Date;
 import java.util.TimeZone;
@@ -38,22 +37,17 @@
 
   /**
    * Unique identifier for this message.
-   * <p>
-   * Messages with the same identifier will be hidden from the user until
-   * redisplay has occurred.
-   * </p>
    *
-   * @return unique message identifier. This identifier should be unique within
-   *         the server.
+   * <p>Messages with the same identifier will be hidden from the user until redisplay has occurred.
+   *
+   * @return unique message identifier. This identifier should be unique within the server.
    */
   public abstract String getMessageId();
 
   /**
    * When should the message be displayed?
    *
-   * <p>
-   * Default implementation returns {@code tomorrow at 00:00:00 GMT}.
-   * </p>
+   * <p>Default implementation returns {@code tomorrow at 00:00:00 GMT}.
    *
    * @return a future date after which the message should be redisplayed.
    */
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
index 45aec57..ffbbadf 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/systemstatus/ServerInformation.java
@@ -19,21 +19,20 @@
   /** Current state of the server. */
   enum State {
     /**
-     * The server is starting up, and network connections are not yet being
-     * accepted. Plugins or extensions starting during this time are starting
-     * for the first time in this process.
+     * The server is starting up, and network connections are not yet being accepted. Plugins or
+     * extensions starting during this time are starting for the first time in this process.
      */
     STARTUP,
 
     /**
-     * The server is running and handling requests. Plugins starting during this
-     * state may be reloading, or being installed into a running system.
+     * The server is running and handling requests. Plugins starting during this state may be
+     * reloading, or being installed into a running system.
      */
     RUNNING,
 
     /**
-     * The server is attempting a graceful halt of operations and will exit (or
-     * be killed by the operating system) soon.
+     * The server is attempting a graceful halt of operations and will exit (or be killed by the
+     * operating system) soon.
      */
     SHUTDOWN
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/BranchWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/BranchWebLink.java
index 2a11012..4b9676e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/BranchWebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/BranchWebLink.java
@@ -21,18 +21,18 @@
 public interface BranchWebLink extends WebLink {
 
   /**
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo}
-   * describing a link from a branch to an external service.
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a branch to an
+   * external service.
    *
-   * <p>In order for the web link to be visible
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
-   * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
-   * must be set.<p>
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
    *
    * @param projectName Name of the project
    * @param branchName Name of the branch
-   * @return WebLinkInfo that links to branch in external service,
-   * null if there should be no link.
+   * @return WebLinkInfo that links to branch in external service, null if there should be no link.
    */
   WebLinkInfo getBranchWebLink(String projectName, String branchName);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/DiffWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/DiffWebLink.java
index ad53519..ab9eef7 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/DiffWebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/DiffWebLink.java
@@ -21,27 +21,33 @@
 public interface DiffWebLink extends WebLink {
 
   /**
-   * {@link com.google.gerrit.extensions.common.DiffWebLinkInfo}
-   * describing a link from a file diff to an external service.
+   * {@link com.google.gerrit.extensions.common.DiffWebLinkInfo} describing a link from a file diff
+   * to an external service.
    *
-   * <p>In order for the web link to be visible
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
-   * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
-   * must be set.<p>
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
    *
    * @param projectName Name of the project
    * @param changeId ID of the change
-   * @param patchSetIdA Patch set ID of side A, <code>null</code> if no base
-   *        patch set was selected
+   * @param patchSetIdA Patch set ID of side A, <code>null</code> if no base patch set was selected
    * @param revisionA Name of the revision of side A (e.g. branch or commit ID)
    * @param fileNameA Name of the file of side A
    * @param patchSetIdB Patch set ID of side B
    * @param revisionB Name of the revision of side B (e.g. branch or commit ID)
    * @param fileNameB Name of the file of side B
-   * @return WebLinkInfo that links to file diff in external service,
-   * null if there should be no link.
+   * @return WebLinkInfo that links to file diff in external service, null if there should be no
+   *     link.
    */
-  DiffWebLinkInfo getDiffLink(String projectName, int changeId,
-        Integer patchSetIdA, String revisionA, String fileNameA,
-        int patchSetIdB, String revisionB, String fileNameB);
+  DiffWebLinkInfo getDiffLink(
+      String projectName,
+      int changeId,
+      Integer patchSetIdA,
+      String revisionA,
+      String fileNameA,
+      int patchSetIdB,
+      String revisionB,
+      String fileNameB);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java
index f9f9e58..b3b0998 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileHistoryWebLink.java
@@ -18,20 +18,20 @@
 
 public interface FileHistoryWebLink extends WebLink {
   /**
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo}
-   * describing a link from a file to an external service displaying
-   * a log for that file.
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a file to an
+   * external service displaying a log for that file.
    *
-   * <p>In order for the web link to be visible
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
-   * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
-   * must be set.<p>
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
    *
    * @param projectName Name of the project
    * @param revision Name of the revision (e.g. branch or commit ID)
    * @param fileName Name of the file
-   * @return WebLinkInfo that links to a log for the file in external
-   * service, null if there should be no link.
+   * @return WebLinkInfo that links to a log for the file in external service, null if there should
+   *     be no link.
    */
   WebLinkInfo getFileHistoryWebLink(String projectName, String revision, String fileName);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileWebLink.java
index 9136b36..c03d606 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileWebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/FileWebLink.java
@@ -21,19 +21,19 @@
 public interface FileWebLink extends WebLink {
 
   /**
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo}
-   * describing a link from a file to an external service.
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a file to an
+   * external service.
    *
-   * <p>In order for the web link to be visible
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
-   * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
-   * must be set.<p>
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
    *
    * @param projectName Name of the project
    * @param revision Name of the revision (e.g. branch or commit ID)
    * @param fileName Name of the file
-   * @return WebLinkInfo that links to project in external service,
-   * null if there should be no link.
+   * @return WebLinkInfo that links to project in external service, null if there should be no link.
    */
   WebLinkInfo getFileWebLink(String projectName, String revision, String fileName);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/GwtPlugin.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/GwtPlugin.java
index e741a3f..e8041c4 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/GwtPlugin.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/GwtPlugin.java
@@ -19,8 +19,8 @@
   private final String moduleName;
 
   /**
-   * @param moduleName name of GWT module. The resource
-   *        {@code static/$MODULE/$MODULE.nocache.js} will be used.
+   * @param moduleName name of GWT module. The resource {@code static/$MODULE/$MODULE.nocache.js}
+   *     will be used.
    */
   public GwtPlugin(String moduleName) {
     this.moduleName = moduleName;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/JavaScriptPlugin.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/JavaScriptPlugin.java
index 4619a06..9517c34 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/JavaScriptPlugin.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/JavaScriptPlugin.java
@@ -22,8 +22,8 @@
   private final String fileName;
 
   /**
-   * @param fileName of JavaScript source file under {@code static/}
-   *        subdirectory within the plugin's JAR.
+   * @param fileName of JavaScript source file under {@code static/} subdirectory within the
+   *     plugin's JAR.
    */
   public JavaScriptPlugin(String fileName) {
     this.fileName = fileName;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java
index 648dff8..dfc970d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ParentWebLink.java
@@ -21,18 +21,19 @@
 public interface ParentWebLink extends WebLink {
 
   /**
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo}
-   * describing a link from a parent revision to an external service.
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a parent
+   * revision to an external service.
    *
-   * <p>In order for the web link to be visible
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
-   * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
-   * must be set.<p>
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
    *
    * @param projectName Name of the project
    * @param commit Commit sha1 of the parent revision
-   * @return WebLinkInfo that links to parent commit in external service,
-   * null if there should be no link.
+   * @return WebLinkInfo that links to parent commit in external service, null if there should be no
+   *     link.
    */
   WebLinkInfo getParentWebLink(String projectName, String commit);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index b9004f2..93fe8e1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -21,18 +21,19 @@
 public interface PatchSetWebLink extends WebLink {
 
   /**
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo}
-   * describing a link from a patch set to an external service.
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service.
    *
-   * <p>In order for the web link to be visible
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
-   * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
-   * must be set.<p>
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
    *
    * @param projectName Name of the project
    * @param commit Commit of the patch set
-   * @return WebLinkInfo that links to patch set in external service,
-   * null if there should be no link.
+   * @return WebLinkInfo that links to patch set in external service, null if there should be no
+   *     link.
    */
-  WebLinkInfo getPatchSetWebLink(final String projectName, final String commit);
+  WebLinkInfo getPatchSetWebLink(String projectName, String commit);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PrivateInternals_UiActionDescription.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PrivateInternals_UiActionDescription.java
index 6545db8..977fdf4 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PrivateInternals_UiActionDescription.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/PrivateInternals_UiActionDescription.java
@@ -16,8 +16,8 @@
 
 /**
  * Internal implementation helper for Gerrit Code Review server.
- * <p>
- * Extensions and plugins should not invoke this class.
+ *
+ * <p>Extensions and plugins should not invoke this class.
  */
 public class PrivateInternals_UiActionDescription {
   public static void setMethod(UiAction.Description d, String method) {
@@ -28,6 +28,5 @@
     d.setId(id);
   }
 
-  private PrivateInternals_UiActionDescription() {
-  }
+  private PrivateInternals_UiActionDescription() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ProjectWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ProjectWebLink.java
index 2f8e802..f8201c4 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ProjectWebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/ProjectWebLink.java
@@ -21,17 +21,17 @@
 public interface ProjectWebLink extends WebLink {
 
   /**
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo}
-   * describing a link from a project to an external service.
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a project to an
+   * external service.
    *
-   * <p>In order for the web link to be visible
-   * {@link com.google.gerrit.extensions.common.WebLinkInfo#url}
-   * and {@link com.google.gerrit.extensions.common.WebLinkInfo#name}
-   * must be set.<p>
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
    *
    * @param projectName Name of the project
-   * @return WebLinkInfo that links to project in external service,
-   * null if there should be no link.
+   * @return WebLinkInfo that links to project in external service, null if there should be no link.
    */
   WebLinkInfo getProjectWeblink(String projectName);
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TagWebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TagWebLink.java
new file mode 100644
index 0000000..14ccb4a
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TagWebLink.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface TagWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a tag to an
+   * external service.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
+   *
+   * @param projectName Name of the project
+   * @param tagName Name of the tag
+   * @return WebLinkInfo that links to tag in external service, null if there should be no link.
+   */
+  WebLinkInfo getTagWebLink(String projectName, String tagName);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java
index 7ad12cd..4d04efa 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/TopMenu.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.client.GerritTopMenu;
 import com.google.gerrit.extensions.client.MenuItem;
-
 import java.util.List;
 
 @ExtensionPoint
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
index 1807673..62c074e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -21,13 +21,11 @@
   /**
    * Get the description of the action customized for the resource.
    *
-   * @param resource the resource the view would act upon if the action is
-   *        invoked by the client. Information from the resource can be used to
-   *        customize the description.
-   * @return a description of the action. The server will populate the
-   *         {@code id} and {@code method} properties. If null the action will
-   *         assumed unavailable and not presented. This is usually the same as
-   *         {@code setVisible(false)}.
+   * @param resource the resource the view would act upon if the action is invoked by the client.
+   *     Information from the resource can be used to customize the description.
+   * @return a description of the action. The server will populate the {@code id} and {@code method}
+   *     properties. If null the action will assumed unavailable and not presented. This is usually
+   *     the same as {@code setVisible(false)}.
    */
   Description getDescription(R resource);
 
@@ -83,8 +81,8 @@
     }
 
     /**
-     * Set if the action's button is visible on screen for the current client.
-     * If not visible the action description may not be sent to the client.
+     * Set if the action's button is visible on screen for the current client. If not visible the
+     * action description may not be sent to the client.
      */
     public Description setVisible(boolean visible) {
       this.visible = visible;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiResult.java
index 106db04..9ca70d1 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiResult.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/UiResult.java
@@ -54,6 +54,5 @@
   /** When true open {@link #url} in a new tab/window. */
   protected Boolean openWindow;
 
-  private UiResult() {
-  }
+  private UiResult() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java
index fd677ca..7cbeff2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebLink.java
@@ -14,32 +14,17 @@
 
 package com.google.gerrit.extensions.webui;
 
-
-/**
- * Marks that the implementor has a method that provides
- * a weblinkInfo
- *
- */
+/** Marks that the implementor has a method that provides a weblinkInfo */
 public interface WebLink {
-  /**
-   * Class that holds target defaults for WebLink anchors.
-   */
+  /** Class that holds target defaults for WebLink anchors. */
   class Target {
-    /**
-     * Opens the link in a new window or tab
-     */
+    /** Opens the link in a new window or tab */
     public static final String BLANK = "_blank";
-    /**
-     * Opens the link in the frame it was clicked.
-     */
+    /** Opens the link in the frame it was clicked. */
     public static final String SELF = "_self";
-    /**
-     * Opens link in parent frame.
-     */
+    /** Opens link in parent frame. */
     public static final String PARENT = "_parent";
-    /**
-     * Opens link in the full body of the window.
-     */
+    /** Opens link in the full body of the window. */
     public static final String TOP = "_top";
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebUiPlugin.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
index 5cd1981..051d336 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
@@ -20,10 +20,10 @@
 
 /**
  * Specifies JavaScript to dynamically load into the web UI.
- * <p>
- * To automatically register (instead of writing a Guice module), declare the
- * intention with {@code @Listen}, extend the correct class and define a
- * constructor to configure the correct resource:
+ *
+ * <p>To automatically register (instead of writing a Guice module), declare the intention with
+ * {@code @Listen}, extend the correct class and define a constructor to configure the correct
+ * resource:
  *
  * <pre>
  * &#064;Listen
@@ -49,7 +49,7 @@
 
   private String pluginName;
 
-  /** @return installed name of the plugin that provides this UI feature.  */
+  /** @return installed name of the plugin that provides this UI feature. */
   public final String getPluginName() {
     return pluginName;
   }
diff --git a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
new file mode 100644
index 0000000..0be10ee
--- /dev/null
+++ b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/api/lfs/LfsDefinitionsTest.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.lfs;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.Test;
+
+public class LfsDefinitionsTest {
+  private static final String[] URL_PREFIXES = new String[] {"/", "/a/", "/p/", "/a/p/"};
+
+  @Test
+  public void noLfsEndPoint_noMatch() {
+    Pattern p = Pattern.compile(LfsDefinitions.LFS_URL_REGEX);
+    doesNotMatch(p, "/foo");
+    doesNotMatch(p, "/a/foo");
+    doesNotMatch(p, "/p/foo");
+    doesNotMatch(p, "/a/p/foo");
+
+    doesNotMatch(p, "/info/lfs/objects/batch");
+    doesNotMatch(p, "/info/lfs/objects/batch/foo");
+    doesNotMatch(p, "/info/lfs/locks");
+    doesNotMatch(p, "/info/lfs/locks/verify");
+    doesNotMatch(p, "/info/lfs/locks/unlock");
+    doesNotMatch(p, "/info/lfs/locks/lock_id/unlock");
+  }
+
+  @Test
+  public void matchingLfsEndpoint_projectNameCaptured() {
+    Pattern p = Pattern.compile(LfsDefinitions.LFS_URL_REGEX);
+    testProjectGetsMatched(p, "foo/bar/info/lfs/objects/batch", "foo/bar");
+    testProjectGetsMatched(p, "foo/bar/info/lfs/locks", "foo/bar");
+    testProjectGetsMatched(p, "foo/bar/info/lfs/locks/verify", "foo/bar");
+    testProjectAndLockIdGetMatched(
+        p, "foo/bar/info/lfs/locks/lock_id/unlock", "foo/bar", "lock_id");
+  }
+
+  private void testProjectAndLockIdGetMatched(
+      Pattern p, String url, String expectedProject, String expectedLockId) {
+    for (String prefix : URL_PREFIXES) {
+      matches(p, prefix + url, expectedProject, expectedLockId);
+    }
+  }
+
+  private void testProjectGetsMatched(Pattern p, String url, String expected) {
+    for (String prefix : URL_PREFIXES) {
+      matches(p, prefix + url, expected);
+    }
+  }
+
+  private void doesNotMatch(Pattern p, String input) {
+    Matcher m = p.matcher(input);
+    assertThat(m.matches()).isFalse();
+  }
+
+  private void matches(Pattern p, String input, String expectedProjectName) {
+    Matcher m = p.matcher(input);
+    assertThat(m.matches()).isTrue();
+    assertThat(m.group(1)).isEqualTo(expectedProjectName);
+  }
+
+  private void matches(Pattern p, String input, String expectedProjectName, String expectedLockId) {
+    Matcher m = p.matcher(input);
+    assertThat(m.matches()).isTrue();
+    assertThat(m.group(1)).isEqualTo(expectedProjectName);
+    assertThat(m.group(2)).isEqualTo(expectedLockId);
+  }
+}
diff --git a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java
new file mode 100644
index 0000000..9695933
--- /dev/null
+++ b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java
@@ -0,0 +1,110 @@
+// 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.client;
+
+import static com.google.gerrit.extensions.client.RangeSubject.assertThat;
+
+import org.junit.Test;
+
+public class RangeTest {
+
+  @Test
+  public void rangeOverMultipleLinesWithSmallerEndCharacterIsValid() {
+    Comment.Range range = createRange(13, 31, 19, 10);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void rangeInOneLineIsValid() {
+    Comment.Range range = createRange(13, 2, 13, 10);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void startPositionEqualToEndPositionIsValidRange() {
+    Comment.Range range = createRange(13, 11, 13, 11);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void negativeStartLineResultsInInvalidRange() {
+    Comment.Range range = createRange(-1, 2, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void negativeEndLineResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 2, -1, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void negativeStartCharacterResultsInInvalidRange() {
+    Comment.Range range = createRange(13, -1, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void negativeEndCharacterResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 2, 19, -1);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void zeroStartLineResultsInInvalidRange() {
+    Comment.Range range = createRange(0, 2, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void zeroEndLineResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 2, 0, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void zeroStartCharacterResultsInValidRange() {
+    Comment.Range range = createRange(13, 0, 19, 10);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void zeroEndCharacterResultsInValidRange() {
+    Comment.Range range = createRange(13, 31, 19, 0);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void startLineGreaterThanEndLineResultsInInvalidRange() {
+    Comment.Range range = createRange(20, 2, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void startCharGreaterThanEndCharForSameLineResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 11, 13, 10);
+    assertThat(range).isInvalid();
+  }
+
+  private Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+}
diff --git a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/registration/DynamicSetTest.java b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/registration/DynamicSetTest.java
index 299b9b0..117e474 100644
--- a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/registration/DynamicSetTest.java
+++ b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/registration/DynamicSetTest.java
@@ -18,7 +18,6 @@
 
 import com.google.inject.Key;
 import com.google.inject.util.Providers;
-
 import org.junit.Test;
 
 public class DynamicSetTest {
@@ -33,47 +32,47 @@
   // {@code assertThat(ds.contains(...)).isFalse() @} instead.
 
   @Test
-  public void testContainsWithEmpty() throws Exception {
+  public void containsWithEmpty() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
-    assertThat(ds.contains(2)).isFalse(); //See above comment about ds.contains
+    assertThat(ds.contains(2)).isFalse(); // See above comment about ds.contains
   }
 
   @Test
-  public void testContainsTrueWithSingleElement() throws Exception {
+  public void containsTrueWithSingleElement() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
     ds.add(2);
 
-    assertThat(ds.contains(2)).isTrue(); //See above comment about ds.contains
+    assertThat(ds.contains(2)).isTrue(); // See above comment about ds.contains
   }
 
   @Test
-  public void testContainsFalseWithSingleElement() throws Exception {
+  public void containsFalseWithSingleElement() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
     ds.add(2);
 
-    assertThat(ds.contains(3)).isFalse(); //See above comment about ds.contains
+    assertThat(ds.contains(3)).isFalse(); // See above comment about ds.contains
   }
 
   @Test
-  public void testContainsTrueWithTwoElements() throws Exception {
+  public void containsTrueWithTwoElements() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
     ds.add(2);
     ds.add(4);
 
-    assertThat(ds.contains(4)).isTrue(); //See above comment about ds.contains
+    assertThat(ds.contains(4)).isTrue(); // See above comment about ds.contains
   }
 
   @Test
-  public void testContainsFalseWithTwoElements() throws Exception {
+  public void containsFalseWithTwoElements() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
     ds.add(2);
     ds.add(4);
 
-    assertThat(ds.contains(3)).isFalse(); //See above comment about ds.contains
+    assertThat(ds.contains(3)).isFalse(); // See above comment about ds.contains
   }
 
   @Test
-  public void testContainsDynamic() throws Exception {
+  public void containsDynamic() throws Exception {
     DynamicSet<Integer> ds = new DynamicSet<>();
     ds.add(2);
 
@@ -83,12 +82,12 @@
     ds.add(6);
 
     // At first, 4 is contained.
-    assertThat(ds.contains(4)).isTrue(); //See above comment about ds.contains
+    assertThat(ds.contains(4)).isTrue(); // See above comment about ds.contains
 
     // Then we remove 4.
     handle.remove();
 
     // And now 4 should no longer be contained.
-    assertThat(ds.contains(4)).isFalse(); //See above comment about ds.contains
+    assertThat(ds.contains(4)).isFalse(); // See above comment about ds.contains
   }
 }
diff --git a/gerrit-gpg/BUCK b/gerrit-gpg/BUCK
deleted file mode 100644
index 73d9f04..0000000
--- a/gerrit-gpg/BUCK
+++ /dev/null
@@ -1,57 +0,0 @@
-DEPS = [
-  '//gerrit-common:server',
-  '//gerrit-extension-api:api',
-  '//gerrit-reviewdb:server',
-  '//gerrit-server:server',
-  '//lib:guava',
-  '//lib:gwtorm',
-  '//lib/guice:guice',
-  '//lib/guice:guice-assistedinject',
-  '//lib/guice:guice-servlet',
-  '//lib/jgit/org.eclipse.jgit:jgit',
-  '//lib/log:api',
-]
-
-java_library(
-  name = 'gpg',
-  srcs = glob(['src/main/java/**/*.java']),
-  provided_deps = DEPS + [
-    '//lib/bouncycastle:bcpg',
-    '//lib/bouncycastle:bcprov',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-TESTUTIL_SRCS = glob(['src/test/**/testutil/**/*.java'])
-
-java_library(
-  name = 'testutil',
-  srcs = TESTUTIL_SRCS,
-  deps = DEPS + [
-    ':gpg',
-    '//lib/bouncycastle:bcpg',
-    '//lib/bouncycastle:bcprov',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'gpg_tests',
-  srcs = glob(
-    ['src/test/java/**/*.java'],
-    excludes = TESTUTIL_SRCS,
-  ),
-  deps = DEPS + [
-    ':gpg',
-    ':testutil',
-    '//gerrit-cache-h2:cache-h2',
-    '//gerrit-lucene:lucene',
-    '//gerrit-server:testutil',
-    '//lib:truth',
-    '//lib/bouncycastle:bcpg',
-    '//lib/bouncycastle:bcprov',
-    '//lib/jgit/org.eclipse.jgit.junit:junit',
-  ],
-  source_under_test = [':gpg'],
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-gpg/BUILD b/gerrit-gpg/BUILD
index 79f50b1..029480f 100644
--- a/gerrit-gpg/BUILD
+++ b/gerrit-gpg/BUILD
@@ -1,58 +1,61 @@
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
 DEPS = [
-  '//gerrit-common:server',
-  '//gerrit-extension-api:api',
-  '//gerrit-reviewdb:server',
-  '//gerrit-server:server',
-  '//lib:guava',
-  '//lib:gwtorm',
-  '//lib/guice:guice',
-  '//lib/guice:guice-assistedinject',
-  '//lib/guice:guice-servlet',
-  '//lib/jgit/org.eclipse.jgit:jgit',
-  '//lib/log:api',
+    "//gerrit-common:server",
+    "//gerrit-extension-api:api",
+    "//gerrit-reviewdb:server",
+    "//gerrit-server:server",
+    "//lib:guava",
+    "//lib:gwtorm",
+    "//lib/guice:guice",
+    "//lib/guice:guice-assistedinject",
+    "//lib/guice:guice-servlet",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib/log:api",
 ]
 
 java_library(
-  name = 'gpg',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = DEPS + [
-    '//lib/bouncycastle:bcpg',
-    '//lib/bouncycastle:bcprov',
-  ],
-  visibility = ['//visibility:public'],
+    name = "gpg",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = DEPS + [
+        "//lib/bouncycastle:bcpg-neverlink",
+        "//lib/bouncycastle:bcprov-neverlink",
+    ],
 )
 
-TESTUTIL_SRCS = glob(['src/test/**/testutil/**/*.java'])
+TESTUTIL_SRCS = glob(["src/test/**/testutil/**/*.java"])
 
 java_library(
-  name = 'testutil',
-  srcs = TESTUTIL_SRCS,
-  deps = DEPS + [
-    ':gpg',
-    '//lib/bouncycastle:bcpg-without-neverlink',
-    '//lib/bouncycastle:bcprov-without-neverlink',
-  ],
-  visibility = ['//visibility:public'],
+    name = "testutil",
+    testonly = 1,
+    srcs = TESTUTIL_SRCS,
+    visibility = ["//visibility:public"],
+    deps = DEPS + [
+        "//lib/bouncycastle:bcpg-neverlink",
+        "//lib/bouncycastle:bcprov-neverlink",
+        ":gpg",
+    ],
 )
 
 junit_tests(
-  name = 'gpg_tests',
-  srcs = glob(
-    ['src/test/java/**/*.java'],
-    exclude = TESTUTIL_SRCS,
-  ),
-  deps = DEPS + [
-    ':gpg',
-    ':testutil',
-    '//gerrit-cache-h2:cache-h2',
-    '//gerrit-lucene:lucene',
-    '//gerrit-server:testutil',
-    '//lib:truth',
-    '//lib/jgit/org.eclipse.jgit.junit:junit',
-    '//lib/bouncycastle:bcpg-without-neverlink',
-    '//lib/bouncycastle:bcprov-without-neverlink',
-  ],
-  visibility = ['//visibility:public'],
+    name = "gpg_tests",
+    srcs = glob(
+        ["src/test/java/**/*.java"],
+        exclude = TESTUTIL_SRCS,
+    ),
+    visibility = ["//visibility:public"],
+    deps = DEPS + [
+        ":gpg",
+        ":testutil",
+        "//gerrit-cache-h2:cache-h2",
+        "//gerrit-cache-mem:mem",
+        "//gerrit-lucene:lucene",
+        "//gerrit-server:testutil",
+        "//lib:truth",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/bouncycastle:bcpg",
+        "//lib/bouncycastle:bcprov",
+    ],
 )
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/BouncyCastleUtil.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/BouncyCastleUtil.java
index ef065a1..6dc74f2 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/BouncyCastleUtil.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/BouncyCastleUtil.java
@@ -14,19 +14,18 @@
 
 package com.google.gerrit.gpg;
 
-import org.bouncycastle.jce.provider.BouncyCastleProvider;
-import org.bouncycastle.openpgp.PGPPublicKey;
-
 import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
 import java.security.Security;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openpgp.PGPPublicKey;
 
 /** Utility methods for Bouncy Castle. */
 public class BouncyCastleUtil {
   /**
    * Check for Bouncy Castle PGP support.
-   * <p>
-   * As a side effect, adds {@link BouncyCastleProvider} as a security provider.
+   *
+   * <p>As a side effect, adds {@link BouncyCastleProvider} as a security provider.
    *
    * @return whether Bouncy Castle PGP support is enabled.
    */
@@ -35,22 +34,25 @@
       Class.forName(PGPPublicKey.class.getName());
       addBouncyCastleProvider();
       return true;
-    } catch (NoClassDefFoundError | ClassNotFoundException | SecurityException
-        | NoSuchMethodException | InstantiationException
-        | IllegalAccessException | InvocationTargetException
+    } catch (NoClassDefFoundError
+        | ClassNotFoundException
+        | SecurityException
+        | NoSuchMethodException
+        | InstantiationException
+        | IllegalAccessException
+        | InvocationTargetException
         | ClassCastException noBouncyCastle) {
       return false;
     }
   }
 
-  private static void addBouncyCastleProvider() throws ClassNotFoundException,
-          SecurityException, NoSuchMethodException, InstantiationException,
-          IllegalAccessException, InvocationTargetException {
+  private static void addBouncyCastleProvider()
+      throws ClassNotFoundException, SecurityException, NoSuchMethodException,
+          InstantiationException, IllegalAccessException, InvocationTargetException {
     Class<?> clazz = Class.forName(BouncyCastleProvider.class.getName());
     Constructor<?> constructor = clazz.getConstructor();
     Security.addProvider((java.security.Provider) constructor.newInstance());
   }
 
-  private BouncyCastleUtil() {
-  }
+  private BouncyCastleUtil() {}
 }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/CheckResult.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/CheckResult.java
index 74184bd..da891aa 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/CheckResult.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/CheckResult.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.gpg;
 
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -32,25 +31,24 @@
   }
 
   static CheckResult trusted() {
-    return new CheckResult(Status.TRUSTED, Collections.<String> emptyList());
+    return new CheckResult(Status.TRUSTED, Collections.<String>emptyList());
   }
 
   static CheckResult create(Status status, String... problems) {
-    List<String> problemList = problems.length > 0
-        ? Collections.unmodifiableList(Arrays.asList(problems))
-        : Collections.<String> emptyList();
+    List<String> problemList =
+        problems.length > 0
+            ? Collections.unmodifiableList(Arrays.asList(problems))
+            : Collections.<String>emptyList();
     return new CheckResult(status, problemList);
   }
 
   static CheckResult create(Status status, List<String> problems) {
-    return new CheckResult(status,
-        Collections.unmodifiableList(new ArrayList<>(problems)));
+    return new CheckResult(status, Collections.unmodifiableList(new ArrayList<>(problems)));
   }
 
   static CheckResult create(List<String> problems) {
     return new CheckResult(
-        problems.isEmpty() ? Status.OK : Status.BAD,
-        Collections.unmodifiableList(problems));
+        problems.isEmpty() ? Status.OK : Status.BAD, Collections.unmodifiableList(problems));
   }
 
   private final Status status;
@@ -86,8 +84,7 @@
 
   @Override
   public String toString() {
-    StringBuilder sb = new StringBuilder(getClass().getSimpleName())
-        .append('[').append(status);
+    StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append('[').append(status);
     for (int i = 0; i < problems.size(); i++) {
       sb.append(i == 0 ? ": " : ", ").append(problems.get(i));
     }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/Fingerprint.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/Fingerprint.java
index fa78f01..c12ff8b 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/Fingerprint.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/Fingerprint.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import org.eclipse.jgit.util.NB;
-
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.util.NB;
 
 public class Fingerprint {
   private final byte[] fp;
@@ -30,10 +29,16 @@
     checkLength(fp);
     return String.format(
         "%04X %04X %04X %04X %04X  %04X %04X %04X %04X %04X",
-        NB.decodeUInt16(fp, 0), NB.decodeUInt16(fp, 2), NB.decodeUInt16(fp, 4),
-        NB.decodeUInt16(fp, 6), NB.decodeUInt16(fp, 8), NB.decodeUInt16(fp, 10),
-        NB.decodeUInt16(fp, 12), NB.decodeUInt16(fp, 14),
-        NB.decodeUInt16(fp, 16), NB.decodeUInt16(fp, 18));
+        NB.decodeUInt16(fp, 0),
+        NB.decodeUInt16(fp, 2),
+        NB.decodeUInt16(fp, 4),
+        NB.decodeUInt16(fp, 6),
+        NB.decodeUInt16(fp, 8),
+        NB.decodeUInt16(fp, 10),
+        NB.decodeUInt16(fp, 12),
+        NB.decodeUInt16(fp, 14),
+        NB.decodeUInt16(fp, 16),
+        NB.decodeUInt16(fp, 18));
   }
 
   public static long getId(byte[] fp) {
@@ -49,19 +54,16 @@
   }
 
   private static byte[] checkLength(byte[] fp) {
-    checkArgument(fp.length == 20,
-        "fingerprint must be 20 bytes, got %s", fp.length);
+    checkArgument(fp.length == 20, "fingerprint must be 20 bytes, got %s", fp.length);
     return fp;
   }
 
   /**
    * Wrap a fingerprint byte array.
-   * <p>
-   * The newly created Fingerprint object takes ownership of the byte array,
-   * which must not be subsequently modified. (Most callers, such as hex
-   * decoders and {@code
-   * org.bouncycastle.openpgp.PGPPublicKey#getFingerprint()}, already produce
-   * fresh byte arrays).
+   *
+   * <p>The newly created Fingerprint object takes ownership of the byte array, which must not be
+   * subsequently modified. (Most callers, such as hex decoders and {@code
+   * org.bouncycastle.openpgp.PGPPublicKey#getFingerprint()}, already produce fresh byte arrays).
    *
    * @param fp 20-byte fingerprint byte array to wrap.
    */
@@ -71,17 +73,19 @@
 
   /**
    * Wrap a portion of a fingerprint byte array.
-   * <p>
-   * Unlike {@link #Fingerprint(byte[])}, creates a new copy of the byte array.
+   *
+   * <p>Unlike {@link #Fingerprint(byte[])}, creates a new copy of the byte array.
    *
    * @param buf byte array to wrap; must have at least {@code off + 20} bytes.
    * @param off offset in buf.
    */
   public Fingerprint(byte[] buf, int off) {
     int expected = 20 + off;
-    checkArgument(buf.length >= expected,
+    checkArgument(
+        buf.length >= expected,
         "fingerprint buffer must have at least %s bytes, got %s",
-        expected, buf.length);
+        expected,
+        buf.length);
     this.fp = new byte[20];
     System.arraycopy(buf, off, fp, 0, 20);
   }
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 db6cb7a..30c6f84 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
@@ -15,29 +15,29 @@
 package com.google.gerrit.gpg;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPSignature;
@@ -47,27 +47,17 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 /**
  * Checker for GPG public keys including Gerrit-specific checks.
- * <p>
- * For Gerrit, keys must contain a self-signed user ID certification matching a
- * trusted external ID in the database, or an email address thereof.
+ *
+ * <p>For Gerrit, keys must contain a self-signed user ID certification matching a trusted external
+ * ID in the database, or an email address thereof.
  */
 public class GerritPublicKeyChecker extends PublicKeyChecker {
-  private static final Logger log =
-      LoggerFactory.getLogger(GerritPublicKeyChecker.class);
+  private static final Logger log = LoggerFactory.getLogger(GerritPublicKeyChecker.class);
 
   @Singleton
   public static class Factory {
-    private final Provider<ReviewDb> db;
-    private final AccountIndexCollection accountIndexes;
     private final Provider<InternalAccountQuery> accountQueryProvider;
     private final String webUrl;
     private final IdentifiedUser.GenericFactory userFactory;
@@ -75,14 +65,11 @@
     private final ImmutableMap<Long, Fingerprint> trusted;
 
     @Inject
-    Factory(@GerritServerConfig Config cfg,
-        Provider<ReviewDb> db,
-        AccountIndexCollection accountIndexes,
+    Factory(
+        @GerritServerConfig Config cfg,
         Provider<InternalAccountQuery> accountQueryProvider,
         IdentifiedUser.GenericFactory userFactory,
         @CanonicalWebUrl String webUrl) {
-      this.db = db;
-      this.accountIndexes = accountIndexes;
       this.accountQueryProvider = accountQueryProvider;
       this.webUrl = webUrl;
       this.userFactory = userFactory;
@@ -90,8 +77,7 @@
 
       String[] strs = cfg.getStringList("receive", null, "trustedKey");
       if (strs.length != 0) {
-        Map<Long, Fingerprint> fps =
-            Maps.newHashMapWithExpectedSize(strs.length);
+        Map<Long, Fingerprint> fps = Maps.newHashMapWithExpectedSize(strs.length);
         for (String str : strs) {
           str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
           Fingerprint fp = new Fingerprint(BaseEncoding.base16().decode(str));
@@ -107,8 +93,7 @@
       return new GerritPublicKeyChecker(this);
     }
 
-    public GerritPublicKeyChecker create(IdentifiedUser expectedUser,
-        PublicKeyStore store) {
+    public GerritPublicKeyChecker create(IdentifiedUser expectedUser, PublicKeyStore store) {
       GerritPublicKeyChecker checker = new GerritPublicKeyChecker(this);
       checker.setExpectedUser(expectedUser);
       checker.setStore(store);
@@ -116,8 +101,6 @@
     }
   }
 
-  private final Provider<ReviewDb> db;
-  private final AccountIndexCollection accountIndexes;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final String webUrl;
   private final IdentifiedUser.GenericFactory userFactory;
@@ -125,8 +108,6 @@
   private IdentifiedUser expectedUser;
 
   private GerritPublicKeyChecker(Factory factory) {
-    this.db = factory.db;
-    this.accountIndexes = factory.accountIndexes;
     this.accountQueryProvider = factory.accountQueryProvider;
     this.webUrl = factory.webUrl;
     this.userFactory = factory.userFactory;
@@ -135,14 +116,13 @@
     }
   }
 
-   /**
-    * Set the expected user for this checker.
-    * <p>
-    * If set, the top-level key passed to {@link #check(PGPPublicKey)} must
-    * belong to the given user. (Other keys checked in the course of verifying
-    * the web of trust are checked against the set of identities in the database
-    * belonging to the same user as the key.)
-    */
+  /**
+   * Set the expected user for this checker.
+   *
+   * <p>If set, the top-level key passed to {@link #check(PGPPublicKey)} must belong to the given
+   * user. (Other keys checked in the course of verifying the web of trust are checked against the
+   * set of identities in the database belonging to the same user as the key.)
+   */
   public GerritPublicKeyChecker setExpectedUser(IdentifiedUser expectedUser) {
     this.expectedUser = expectedUser;
     return this;
@@ -162,12 +142,11 @@
     }
   }
 
-  private CheckResult checkIdsForExpectedUser(PGPPublicKey key)
-      throws PGPException {
+  private CheckResult checkIdsForExpectedUser(PGPPublicKey key) throws PGPException {
     Set<String> allowedUserIds = getAllowedUserIds(expectedUser);
     if (allowedUserIds.isEmpty()) {
-      return CheckResult.bad("No identities found for user; check "
-          + webUrl + "#" + PageLinks.SETTINGS_WEBIDENT);
+      return CheckResult.bad(
+          "No identities found for user; check " + webUrl + "#" + PageLinks.SETTINGS_WEBIDENT);
     }
     if (hasAllowedUserId(key, allowedUserIds)) {
       return CheckResult.trusted();
@@ -175,27 +154,15 @@
     return CheckResult.bad(missingUserIds(allowedUserIds));
   }
 
-  private CheckResult checkIdsForArbitraryUser(PGPPublicKey key)
-      throws PGPException, OrmException {
-    IdentifiedUser user;
-    if (accountIndexes.getSearchIndex() != null) {
-      List<AccountState> accountStates =
-          accountQueryProvider.get().byExternalId(toExtIdKey(key).get());
-      if (accountStates.isEmpty()) {
-        return CheckResult.bad("Key is not associated with any users");
-      }
-      if (accountStates.size() > 1) {
-        return CheckResult.bad("Key is associated with multiple users");
-      }
-      user = userFactory.create(accountStates.get(0));
-    } else {
-      AccountExternalId extId = db.get().accountExternalIds().get(
-          toExtIdKey(key));
-      if (extId == null) {
-        return CheckResult.bad("Key is not associated with any users");
-      }
-      user = userFactory.create(extId.getAccountId());
+  private CheckResult checkIdsForArbitraryUser(PGPPublicKey key) throws PGPException, OrmException {
+    List<AccountState> accountStates = accountQueryProvider.get().byExternalId(toExtIdKey(key));
+    if (accountStates.isEmpty()) {
+      return CheckResult.bad("Key is not associated with any users");
     }
+    if (accountStates.size() > 1) {
+      return CheckResult.bad("Key is associated with multiple users");
+    }
+    IdentifiedUser user = userFactory.create(accountStates.get(0));
 
     Set<String> allowedUserIds = getAllowedUserIds(user);
     if (allowedUserIds.isEmpty()) {
@@ -204,13 +171,11 @@
     if (hasAllowedUserId(key, allowedUserIds)) {
       return CheckResult.trusted();
     }
-    return CheckResult.bad(
-        "Key does not contain any valid certifications for user's identities");
+    return CheckResult.bad("Key does not contain any valid certifications for user's identities");
   }
 
   private boolean hasAllowedUserId(PGPPublicKey key, Set<String> allowedUserIds)
       throws PGPException {
-    @SuppressWarnings("unchecked")
     Iterator<String> userIds = key.getUserIDs();
     while (userIds.hasNext()) {
       String userId = userIds.next();
@@ -227,34 +192,30 @@
     return false;
   }
 
-  @SuppressWarnings("unchecked")
-  private Iterator<PGPSignature> getSignaturesForId(PGPPublicKey key,
-      String userId) {
-    return MoreObjects.firstNonNull(
-        key.getSignaturesForID(userId),
-        Collections.emptyIterator());
+  private Iterator<PGPSignature> getSignaturesForId(PGPPublicKey key, String userId) {
+    Iterator<PGPSignature> result = key.getSignaturesForID(userId);
+    return result != null ? result : Collections.emptyIterator();
   }
 
   private Set<String> getAllowedUserIds(IdentifiedUser user) {
     Set<String> result = new HashSet<>();
     result.addAll(user.getEmailAddresses());
-    for (AccountExternalId extId : user.state().getExternalIds()) {
+    for (ExternalId extId : user.state().getExternalIds()) {
       if (extId.isScheme(SCHEME_GPGKEY)) {
         continue; // Omit GPG keys.
       }
-      result.add(extId.getExternalId());
+      result.add(extId.key().get());
     }
     return result;
   }
 
   private static boolean isAllowed(String userId, Set<String> allowedUserIds) {
     return allowedUserIds.contains(userId)
-        || allowedUserIds.contains(
-            PushCertificateIdent.parse(userId).getEmailAddress());
+        || allowedUserIds.contains(PushCertificateIdent.parse(userId).getEmailAddress());
   }
 
-  private static boolean isValidCertification(PGPPublicKey key,
-      PGPSignature sig, String userId) throws PGPException {
+  private static boolean isValidCertification(PGPPublicKey key, PGPSignature sig, String userId)
+      throws PGPException {
     if (sig.getSignatureType() != PGPSignature.DEFAULT_CERTIFICATION
         && sig.getSignatureType() != PGPSignature.POSITIVE_CERTIFICATION) {
       return false;
@@ -272,11 +233,10 @@
   }
 
   private static String missingUserIds(Set<String> allowedUserIds) {
-    StringBuilder sb = new StringBuilder("Key must contain a valid"
-        + " certification for one of the following identities:\n");
-    Iterator<String> sorted = FluentIterable.from(allowedUserIds)
-        .toSortedList(Ordering.natural())
-        .iterator();
+    StringBuilder sb =
+        new StringBuilder(
+            "Key must contain a valid certification for one of the following identities:\n");
+    Iterator<String> sorted = allowedUserIds.stream().sorted().iterator();
     while (sorted.hasNext()) {
       sb.append("  ").append(sorted.next());
       if (sorted.hasNext()) {
@@ -286,9 +246,7 @@
     return sb.toString();
   }
 
-  static AccountExternalId.Key toExtIdKey(PGPPublicKey key) {
-    return new AccountExternalId.Key(
-        SCHEME_GPGKEY,
-        BaseEncoding.base16().encode(key.getFingerprint()));
+  static ExternalId.Key toExtIdKey(PGPPublicKey key) {
+    return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint()));
   }
 }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index 30983ac..62d0df7 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -19,10 +19,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
 
 public class GerritPushCertificateChecker extends PushCertificateChecker {
   public interface Factory {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GpgModule.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GpgModule.java
index bbf61b8..d12e921 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GpgModule.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GpgModule.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.gpg.api.GpgApiModule;
 import com.google.gerrit.server.EnableSignedPush;
-
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -33,17 +32,14 @@
 
   @Override
   protected void configure() {
-    boolean configEnableSignedPush =
-        cfg.getBoolean("receive", null, "enableSignedPush", false);
-    boolean configEditGpgKeys =
-        cfg.getBoolean("gerrit", null, "editGpgKeys", true);
+    boolean configEnableSignedPush = cfg.getBoolean("receive", null, "enableSignedPush", false);
+    boolean configEditGpgKeys = cfg.getBoolean("gerrit", null, "editGpgKeys", true);
     boolean havePgp = BouncyCastleUtil.havePGP();
     boolean enableSignedPush = configEnableSignedPush && havePgp;
     bindConstant().annotatedWith(EnableSignedPush.class).to(enableSignedPush);
 
     if (configEnableSignedPush && !havePgp) {
-      log.info("Bouncy Castle PGP not installed; signed push verification is"
-          + " disabled");
+      log.info("Bouncy Castle PGP not installed; signed push verification is disabled");
     }
     if (enableSignedPush) {
       install(new SignedPushModule());
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java
index e4c81df..70e9a24 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -29,7 +29,16 @@
 import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
 
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.bouncycastle.bcpg.SignatureSubpacket;
 import org.bouncycastle.bcpg.SignatureSubpacketTags;
 import org.bouncycastle.bcpg.sig.RevocationKey;
@@ -43,21 +52,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 /** Checker for GPG public keys for use in a push certificate. */
 public class PublicKeyChecker {
-  private static final Logger log =
-      LoggerFactory.getLogger(PublicKeyChecker.class);
+  private static final Logger log = LoggerFactory.getLogger(PublicKeyChecker.class);
 
   // https://tools.ietf.org/html/rfc4880#section-5.2.3.13
   private static final int COMPLETE_TRUST = 120;
@@ -69,27 +66,22 @@
 
   /**
    * Enable web-of-trust checks.
-   * <p>
-   * If enabled, a store must be set with {@link #setStore(PublicKeyStore)}.
-   * (These methods are separate since the store is a closeable resource that
-   * may not be available when reading trusted keys from a config.)
    *
-   * @param maxTrustDepth maximum depth to search while looking for a trusted
-   *     key.
-   * @param trusted ultimately trusted key fingerprints, keyed by fingerprint;
-   *     may not be empty. To construct a map, see {@link
-   *     Fingerprint#byId(Iterable)}.
+   * <p>If enabled, a store must be set with {@link #setStore(PublicKeyStore)}. (These methods are
+   * separate since the store is a closeable resource that may not be available when reading trusted
+   * keys from a config.)
+   *
+   * @param maxTrustDepth maximum depth to search while looking for a trusted key.
+   * @param trusted ultimately trusted key fingerprints, keyed by fingerprint; may not be empty. To
+   *     construct a map, see {@link Fingerprint#byId(Iterable)}.
    * @return a reference to this object.
    */
-  public PublicKeyChecker enableTrust(int maxTrustDepth,
-      Map<Long, Fingerprint> trusted) {
+  public PublicKeyChecker enableTrust(int maxTrustDepth, Map<Long, Fingerprint> trusted) {
     if (maxTrustDepth <= 0) {
-      throw new IllegalArgumentException(
-          "maxTrustDepth must be positive, got: " + maxTrustDepth);
+      throw new IllegalArgumentException("maxTrustDepth must be positive, got: " + maxTrustDepth);
     }
     if (trusted == null || trusted.isEmpty()) {
-        throw new IllegalArgumentException(
-            "at least one trusted key is required");
+      throw new IllegalArgumentException("at least one trusted key is required");
     }
     this.maxTrustDepth = maxTrustDepth;
     this.trusted = trusted;
@@ -113,9 +105,8 @@
 
   /**
    * Set the effective time for checking the key.
-   * <p>
-   * If set, check whether the key should be considered valid (e.g. unexpired)
-   * as of this time.
+   *
+   * <p>If set, check whether the key should be considered valid (e.g. unexpired) as of this time.
    *
    * @param effectiveTime effective time.
    * @return a reference to this object.
@@ -139,40 +130,36 @@
     if (store == null) {
       throw new IllegalStateException("PublicKeyStore is required");
     }
-    return check(key, 0, true,
-        trusted != null ? new HashSet<Fingerprint>() : null);
+    return check(key, 0, true, trusted != null ? new HashSet<Fingerprint>() : null);
   }
 
   /**
    * Perform custom checks.
-   * <p>
-   * Default implementation reports no problems, but may be overridden by
-   * subclasses.
+   *
+   * <p>Default implementation reports no problems, but may be overridden by subclasses.
    *
    * @param key the public key.
-   * @param depth the depth from the initial key passed to {@link #check(
-   *     PGPPublicKey)}: 0 if this was the initial key, up to a maximum of
-   *     {@code maxTrustDepth}.
+   * @param depth the depth from the initial key passed to {@link #check( PGPPublicKey)}: 0 if this
+   *     was the initial key, up to a maximum of {@code maxTrustDepth}.
    * @return the result of the custom check.
    */
   public CheckResult checkCustom(PGPPublicKey key, int depth) {
     return CheckResult.ok();
   }
 
-  private CheckResult check(PGPPublicKey key, int depth, boolean expand,
-      Set<Fingerprint> seen) {
+  private CheckResult check(PGPPublicKey key, int depth, boolean expand, Set<Fingerprint> seen) {
     CheckResult basicResult = checkBasic(key, effectiveTime);
     CheckResult customResult = checkCustom(key, depth);
     CheckResult trustResult = checkWebOfTrust(key, store, depth, seen);
     if (!expand && !trustResult.isTrusted()) {
-      trustResult = CheckResult.create(trustResult.getStatus(),
-          "Key is not trusted");
+      trustResult = CheckResult.create(trustResult.getStatus(), "Key is not trusted");
     }
 
-    List<String> problems = new ArrayList<>(
-        basicResult.getProblems().size()
-        + customResult.getProblems().size()
-        + trustResult.getProblems().size());
+    List<String> problems =
+        new ArrayList<>(
+            basicResult.getProblems().size()
+                + customResult.getProblems().size()
+                + trustResult.getProblems().size());
     problems.addAll(basicResult.getProblems());
     problems.addAll(customResult.getProblems());
     problems.addAll(trustResult.getProblems());
@@ -210,13 +197,11 @@
     return CheckResult.create(problems);
   }
 
-  private void gatherRevocationProblems(PGPPublicKey key, Date now,
-      List<String> problems) {
+  private void gatherRevocationProblems(PGPPublicKey key, Date now, List<String> problems) {
     try {
       List<PGPSignature> revocations = new ArrayList<>();
       Map<Long, RevocationKey> revokers = new HashMap<>();
-      PGPSignature selfRevocation =
-          scanRevocations(key, now, revocations, revokers);
+      PGPSignature selfRevocation = scanRevocations(key, now, revocations, revokers);
       if (selfRevocation != null) {
         RevocationReason reason = getRevocationReason(selfRevocation);
         if (isRevocationValid(selfRevocation, reason, now)) {
@@ -230,8 +215,8 @@
     }
   }
 
-  private static boolean isRevocationValid(PGPSignature revocation,
-      RevocationReason reason, Date now) {
+  private static boolean isRevocationValid(
+      PGPSignature revocation, RevocationReason reason, Date now) {
     // RFC4880 states:
     // "If a key has been revoked because of a compromise, all signatures
     // created by that key are suspect. However, if it was merely superseded or
@@ -244,8 +229,8 @@
         || revocation.getCreationTime().before(now);
   }
 
-  private PGPSignature scanRevocations(PGPPublicKey key, Date now,
-      List<PGPSignature> revocations, Map<Long, RevocationKey> revokers)
+  private PGPSignature scanRevocations(
+      PGPPublicKey key, Date now, List<PGPSignature> revocations, Map<Long, RevocationKey> revokers)
       throws PGPException {
     @SuppressWarnings("unchecked")
     Iterator<PGPSignature> allSigs = key.getSignatures();
@@ -276,13 +261,11 @@
     return null;
   }
 
-  private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig)
-      throws PGPException {
+  private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig) throws PGPException {
     if (sig.getKeyID() != key.getKeyID()) {
       return null;
     }
-    SignatureSubpacket sub =
-        sig.getHashedSubPackets().getSubpacket(REVOCATION_KEY);
+    SignatureSubpacket sub = sig.getHashedSubPackets().getSubpacket(REVOCATION_KEY);
     if (sub == null) {
       return null;
     }
@@ -291,11 +274,13 @@
       return null;
     }
 
-    return new RevocationKey(sub.isCritical(), sub.getData());
+    return new RevocationKey(sub.isCritical(), sub.isLongLength(), sub.getData());
   }
 
-  private void checkRevocations(PGPPublicKey key,
-      List<PGPSignature> revocations, Map<Long, RevocationKey> revokers,
+  private void checkRevocations(
+      PGPPublicKey key,
+      List<PGPSignature> revocations,
+      Map<Long, RevocationKey> revokers,
       List<String> problems)
       throws PGPException, IOException {
     for (PGPSignature revocation : revocations) {
@@ -309,9 +294,12 @@
         // Revoker is authorized and there is a revocation signature by this
         // revoker, but the key is not in the store so we can't verify the
         // signature.
-        log.info("Key " + Fingerprint.toString(key.getFingerprint())
-            + " is revoked by " + Fingerprint.toString(rfp)
-            + ", which is not in the store. Assuming revocation is valid.");
+        log.info(
+            "Key "
+                + Fingerprint.toString(key.getFingerprint())
+                + " is revoked by "
+                + Fingerprint.toString(rfp)
+                + ", which is not in the store. Assuming revocation is valid.");
         problems.add(reasonToString(getRevocationReason(revocation)));
         continue;
       }
@@ -336,12 +324,11 @@
       throw new IllegalArgumentException(
           "Expected KEY_REVOCATION signature, got " + sig.getSignatureType());
     }
-    SignatureSubpacket sub =
-        sig.getHashedSubPackets().getSubpacket(REVOCATION_REASON);
+    SignatureSubpacket sub = sig.getHashedSubPackets().getSubpacket(REVOCATION_REASON);
     if (sub == null) {
       return null;
     }
-    return new RevocationReason(sub.isCritical(), sub.getData());
+    return new RevocationReason(sub.isCritical(), sub.isLongLength(), sub.getData());
   }
 
   private static String reasonToString(RevocationReason reason) {
@@ -363,9 +350,7 @@
         r.append("retired and no longer valid");
         break;
       default:
-        r.append("reason code ")
-            .append(Integer.toString(reason.getRevocationReason()))
-            .append(')');
+        r.append("reason code ").append(Integer.toString(reason.getRevocationReason())).append(')');
         break;
     }
     r.append(')');
@@ -376,8 +361,8 @@
     return r.toString();
   }
 
-  private CheckResult checkWebOfTrust(PGPPublicKey key, PublicKeyStore store,
-      int depth, Set<Fingerprint> seen) {
+  private CheckResult checkWebOfTrust(
+      PGPPublicKey key, PublicKeyStore store, int depth, Set<Fingerprint> seen) {
     if (trusted == null) {
       // Trust checking not configured, server trusts all OK keys.
       return CheckResult.trusted();
@@ -392,12 +377,10 @@
     if (trustedFp != null && trustedFp.equals(fp)) {
       return CheckResult.trusted(); // Directly trusted.
     } else if (depth >= maxTrustDepth) {
-      return CheckResult.ok(
-          "No path of depth <= " + maxTrustDepth + " to a trusted key");
+      return CheckResult.ok("No path of depth <= " + maxTrustDepth + " to a trusted key");
     }
 
     List<CheckResult> signerResults = new ArrayList<>();
-    @SuppressWarnings("unchecked")
     Iterator<String> userIds = key.getUserIDs();
     while (userIds.hasNext()) {
       String userId = userIds.next();
@@ -405,7 +388,6 @@
       // Don't check the timestamp of these certifications. This allows admins
       // to correct untrusted keys by signing them with a trusted key, such that
       // older signatures created by those keys retroactively appear valid.
-      @SuppressWarnings("unchecked")
       Iterator<PGPSignature> sigs = key.getSignaturesForID(userId);
 
       while (sigs.hasNext()) {
@@ -418,8 +400,7 @@
 
         PGPPublicKey signer = getSigner(store, sig, userId, key, signerResults);
         // TODO(dborowitz): Require self certification.
-        if (signer == null
-            || Arrays.equals(signer.getFingerprint(), key.getFingerprint())) {
+        if (signer == null || Arrays.equals(signer.getFingerprint(), key.getFingerprint())) {
           continue;
         }
         String subpacketProblem = checkTrustSubpacket(sig, depth);
@@ -429,9 +410,9 @@
             return CheckResult.trusted();
           }
         }
-        signerResults.add(CheckResult.ok(
-            "Certification by " + keyToString(signer)
-            + " is valid, but key is not trusted"));
+        signerResults.add(
+            CheckResult.ok(
+                "Certification by " + keyToString(signer) + " is valid, but key is not trusted"));
       }
     }
 
@@ -443,34 +424,39 @@
     return CheckResult.create(OK, problems);
   }
 
-  private static PGPPublicKey getSigner(PublicKeyStore store, PGPSignature sig,
-      String userId, PGPPublicKey key, List<CheckResult> results) {
+  private static PGPPublicKey getSigner(
+      PublicKeyStore store,
+      PGPSignature sig,
+      String userId,
+      PGPPublicKey key,
+      List<CheckResult> results) {
     try {
       PGPPublicKeyRingCollection signers = store.get(sig.getKeyID());
       if (!signers.getKeyRings().hasNext()) {
-        results.add(CheckResult.ok(
-            "Key " + keyIdToString(sig.getKeyID())
-            + " used for certification is not in store"));
+        results.add(
+            CheckResult.ok(
+                "Key "
+                    + keyIdToString(sig.getKeyID())
+                    + " used for certification is not in store"));
         return null;
       }
       PGPPublicKey signer = PublicKeyStore.getSigner(signers, sig, userId, key);
       if (signer == null) {
-        results.add(CheckResult.ok(
-            "Certification by " + keyIdToString(sig.getKeyID())
-            + " is not valid"));
+        results.add(
+            CheckResult.ok("Certification by " + keyIdToString(sig.getKeyID()) + " is not valid"));
         return null;
       }
       return signer;
     } catch (PGPException | IOException e) {
-      results.add(CheckResult.ok(
-          "Error checking certification by " + keyIdToString(sig.getKeyID())));
+      results.add(
+          CheckResult.ok("Error checking certification by " + keyIdToString(sig.getKeyID())));
       return null;
     }
   }
 
   private String checkTrustSubpacket(PGPSignature sig, int depth) {
-    SignatureSubpacket trustSub = sig.getHashedSubPackets().getSubpacket(
-        SignatureSubpacketTags.TRUST_SIG);
+    SignatureSubpacket trustSub =
+        sig.getHashedSubPackets().getSubpacket(SignatureSubpacketTags.TRUST_SIG);
     if (trustSub == null || trustSub.getData().length != 2) {
       return "Certification is missing trust information";
     }
@@ -481,8 +467,7 @@
     byte level = trustSub.getData()[0];
     int required = depth + 1;
     if (level < required) {
-      return "Certification trusts to depth " + level
-          + ", but depth " + required + " is required";
+      return "Certification trusts to depth " + level + ", but depth " + required + " is required";
     }
     return null;
   }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java
index b45bce5..8ab5fbd 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -17,6 +17,18 @@
 import static com.google.common.base.Preconditions.checkState;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.bouncycastle.bcpg.ArmoredInputStream;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPException;
@@ -40,34 +52,19 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.NB;
 
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 /**
  * Store of GPG public keys in git notes.
- * <p>
- * Keys are stored in filenames based on their hex key ID, padded out to 40
- * characters to match the length of a SHA-1. (This is to easily reuse existing
- * fanout code in {@link NoteMap}, and may be changed later after an appropriate
- * transition.)
- * <p>
- * The contents of each file is an ASCII armored stream containing one or more
- * public key rings matching the ID. Multiple keys are supported because forging
- * a key ID is possible, but such a key cannot be used to verify signatures
- * produced with the correct key.
- * <p>
- * No additional checks are performed on the key after reading; callers should
- * only trust keys after checking with a {@link PublicKeyChecker}.
+ *
+ * <p>Keys are stored in filenames based on their hex key ID, padded out to 40 characters to match
+ * the length of a SHA-1. (This is to easily reuse existing fanout code in {@link NoteMap}, and may
+ * be changed later after an appropriate transition.)
+ *
+ * <p>The contents of each file is an ASCII armored stream containing one or more public key rings
+ * matching the ID. Multiple keys are supported because forging a key ID is possible, but such a key
+ * cannot be used to verify signatures produced with the correct key.
+ *
+ * <p>No additional checks are performed on the key after reading; callers should only trust keys
+ * after checking with a {@link PublicKeyChecker}.
  */
 public class PublicKeyStore implements AutoCloseable {
   private static final ObjectId EMPTY_TREE =
@@ -78,16 +75,18 @@
 
   /**
    * Choose the public key that produced a signature.
+   *
    * <p>
+   *
    * @param keyRings candidate keys.
    * @param sig signature object.
    * @param data signed payload.
-   * @return the key chosen from {@code keyRings} that was able to verify the
-   *     signature, or {@code null} if none was found.
+   * @return the key chosen from {@code keyRings} that was able to verify the signature, or {@code
+   *     null} if none was found.
    * @throws PGPException if an error occurred verifying the signature.
    */
-  public static PGPPublicKey getSigner(Iterable<PGPPublicKeyRing> keyRings,
-      PGPSignature sig, byte[] data) throws PGPException {
+  public static PGPPublicKey getSigner(
+      Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, byte[] data) throws PGPException {
     for (PGPPublicKeyRing kr : keyRings) {
       PGPPublicKey k = kr.getPublicKey();
       sig.init(new BcPGPContentVerifierBuilderProvider(), k);
@@ -101,17 +100,20 @@
 
   /**
    * Choose the public key that produced a certification.
+   *
    * <p>
+   *
    * @param keyRings candidate keys.
    * @param sig signature object.
    * @param userId user ID being certified.
    * @param key key being certified.
-   * @return the key chosen from {@code keyRings} that was able to verify the
-   *     certification, or {@code null} if none was found.
+   * @return the key chosen from {@code keyRings} that was able to verify the certification, or
+   *     {@code null} if none was found.
    * @throws PGPException if an error occurred verifying the certification.
    */
-  public static PGPPublicKey getSigner(Iterable<PGPPublicKeyRing> keyRings,
-      PGPSignature sig, String userId, PGPPublicKey key) throws PGPException {
+  public static PGPPublicKey getSigner(
+      Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, String userId, PGPPublicKey key)
+      throws PGPException {
     for (PGPPublicKeyRing kr : keyRings) {
       PGPPublicKey k = kr.getPublicKey();
       sig.init(new BcPGPContentVerifierBuilderProvider(), k);
@@ -165,39 +167,36 @@
 
   /**
    * Read public keys with the given key ID.
-   * <p>
-   * Keys should not be trusted unless checked with {@link PublicKeyChecker}.
-   * <p>
-   * Multiple calls to this method use the same state of the key ref; to reread
-   * the ref, call {@link #close()} first.
+   *
+   * <p>Keys should not be trusted unless checked with {@link PublicKeyChecker}.
+   *
+   * <p>Multiple calls to this method use the same state of the key ref; to reread the ref, call
+   * {@link #close()} first.
    *
    * @param keyId key ID.
    * @return any keys found that could be successfully parsed.
    * @throws PGPException if an error occurred parsing the key data.
    * @throws IOException if an error occurred reading the repository data.
    */
-  public PGPPublicKeyRingCollection get(long keyId)
-      throws PGPException, IOException {
+  public PGPPublicKeyRingCollection get(long keyId) throws PGPException, IOException {
     return new PGPPublicKeyRingCollection(get(keyId, null));
   }
 
   /**
    * Read public key with the given fingerprint.
-   * <p>
-   * Keys should not be trusted unless checked with {@link PublicKeyChecker}.
-   * <p>
-   * Multiple calls to this method use the same state of the key ref; to reread
-   * the ref, call {@link #close()} first.
+   *
+   * <p>Keys should not be trusted unless checked with {@link PublicKeyChecker}.
+   *
+   * <p>Multiple calls to this method use the same state of the key ref; to reread the ref, call
+   * {@link #close()} first.
    *
    * @param fingerprint key fingerprint.
    * @return the key if found, or {@code null}.
    * @throws PGPException if an error occurred parsing the key data.
    * @throws IOException if an error occurred reading the repository data.
    */
-  public PGPPublicKeyRing get(byte[] fingerprint)
-      throws PGPException, IOException {
-    List<PGPPublicKeyRing> keyRings =
-        get(Fingerprint.getId(fingerprint), fingerprint);
+  public PGPPublicKeyRing get(byte[] fingerprint) throws PGPException, IOException {
+    List<PGPPublicKeyRing> keyRings = get(Fingerprint.getId(fingerprint), fingerprint);
     return !keyRings.isEmpty() ? keyRings.get(0) : null;
   }
 
@@ -217,21 +216,18 @@
     try (InputStream in = reader.open(note.getData(), OBJ_BLOB).openStream()) {
       while (true) {
         @SuppressWarnings("unchecked")
-        Iterator<Object> it =
-            new BcPGPObjectFactory(new ArmoredInputStream(in)).iterator();
+        Iterator<Object> it = new BcPGPObjectFactory(new ArmoredInputStream(in)).iterator();
         if (!it.hasNext()) {
           break;
         }
         Object obj = it.next();
         if (obj instanceof PGPPublicKeyRing) {
           PGPPublicKeyRing kr = (PGPPublicKeyRing) obj;
-          if (fp == null
-              || Arrays.equals(fp, kr.getPublicKey().getFingerprint())) {
+          if (fp == null || Arrays.equals(fp, kr.getPublicKey().getFingerprint())) {
             keys.add(kr);
           }
         }
-        checkState(!it.hasNext(),
-            "expected one PGP object per ArmoredInputStream");
+        checkState(!it.hasNext(), "expected one PGP object per ArmoredInputStream");
       }
       return keys;
     }
@@ -239,9 +235,9 @@
 
   /**
    * Add a public key to the store.
-   * <p>
-   * Multiple calls may be made to buffer keys in memory, and they are not saved
-   * until {@link #save(CommitBuilder)} is called.
+   *
+   * <p>Multiple calls may be made to buffer keys in memory, and they are not saved until {@link
+   * #save(CommitBuilder)} is called.
    *
    * @param keyRing a key ring containing exactly one public master key.
    */
@@ -256,8 +252,7 @@
     // this master key, but that requires doing actual signature verification
     // here. The alternative is insane but harmless.
     if (numMaster != 1) {
-      throw new IllegalArgumentException(
-          "Exactly 1 master key is required, found " + numMaster);
+      throw new IllegalArgumentException("Exactly 1 master key is required, found " + numMaster);
     }
     Fingerprint fp = new Fingerprint(keyRing.getPublicKey().getFingerprint());
     toAdd.put(fp, keyRing);
@@ -266,9 +261,9 @@
 
   /**
    * Remove a public key from the store.
-   * <p>
-   * Multiple calls may be made to buffer deletes in memory, and they are not
-   * saved until {@link #save(CommitBuilder)} is called.
+   *
+   * <p>Multiple calls may be made to buffer deletes in memory, and they are not saved until {@link
+   * #save(CommitBuilder)} is called.
    *
    * @param fingerprint the fingerprint of the key to remove.
    */
@@ -280,17 +275,15 @@
 
   /**
    * Save pending keys to the store.
-   * <p>
-   * One commit is created and the ref updated. The pending list is cleared if
-   * and only if the ref update succeeds, which allows for easy retries in case
-   * of lock failure.
    *
-   * @param cb commit builder with at least author and identity populated; tree
-   *     and parent are ignored.
+   * <p>One commit is created and the ref updated. The pending list is cleared if and only if the
+   * ref update succeeds, which allows for easy retries in case of lock failure.
+   *
+   * @param cb commit builder with at least author and identity populated; tree and parent are
+   *     ignored.
    * @return result of the ref update.
    */
-  public RefUpdate.Result save(CommitBuilder cb)
-      throws PGPException, IOException {
+  public RefUpdate.Result save(CommitBuilder cb) throws PGPException, IOException {
     if (toAdd.isEmpty() && toRemove.isEmpty()) {
       return RefUpdate.Result.NO_CHANGE;
     }
@@ -309,8 +302,7 @@
         deleteFromNotes(ins, fp);
       }
       cb.setTreeId(notes.writeTree(ins));
-      if (cb.getTreeId().equals(
-          tip != null ? tip.getTree() : EMPTY_TREE)) {
+      if (cb.getTreeId().equals(tip != null ? tip.getTree() : EMPTY_TREE)) {
         return RefUpdate.Result.NO_CHANGE;
       }
 
@@ -319,8 +311,7 @@
       }
       if (cb.getMessage() == null) {
         int n = toAdd.size() + toRemove.size();
-        cb.setMessage(
-            String.format("Update %d public key%s", n, n != 1 ? "s" : ""));
+        cb.setMessage(String.format("Update %d public key%s", n, n != 1 ? "s" : ""));
       }
       newTip = ins.insert(cb);
       ins.flush();
@@ -370,8 +361,7 @@
     if (!replaced) {
       toWrite.add(keyRing);
     }
-    notes.set(keyObjectId(keyId),
-        ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
+    notes.set(keyObjectId(keyId), ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
   }
 
   private void deleteFromNotes(ObjectInserter ins, Fingerprint fp)
@@ -387,20 +377,17 @@
     if (toWrite.size() == existing.size()) {
       return;
     } else if (!toWrite.isEmpty()) {
-      notes.set(keyObjectId(keyId),
-          ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
+      notes.set(keyObjectId(keyId), ins.insert(OBJ_BLOB, keysToArmored(toWrite)));
     } else {
       notes.remove(keyObjectId(keyId));
     }
   }
 
   private static boolean sameKey(PGPPublicKeyRing kr1, PGPPublicKeyRing kr2) {
-    return Arrays.equals(kr1.getPublicKey().getFingerprint(),
-        kr2.getPublicKey().getFingerprint());
+    return Arrays.equals(kr1.getPublicKey().getFingerprint(), kr2.getPublicKey().getFingerprint());
   }
 
-  private static byte[] keysToArmored(List<PGPPublicKeyRing> keys)
-      throws IOException {
+  private static byte[] keysToArmored(List<PGPPublicKeyRing> keys) throws IOException {
     ByteArrayOutputStream out = new ByteArrayOutputStream(4096 * keys.size());
     for (PGPPublicKeyRing kr : keys) {
       try (ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
@@ -411,7 +398,6 @@
   }
 
   public static String keyToString(PGPPublicKey key) {
-    @SuppressWarnings("unchecked")
     Iterator<String> it = key.getUserIDs();
     return String.format(
         "%s %s(%s)",
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PushCertificateChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PushCertificateChecker.java
index 0a0fff7..95b89d0 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PushCertificateChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/PushCertificateChecker.java
@@ -22,7 +22,10 @@
 
 import com.google.common.base.Joiner;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
-
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.bouncycastle.bcpg.ArmoredInputStream;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPObjectFactory;
@@ -38,15 +41,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
 /** Checker for push certificates. */
 public abstract class PushCertificateChecker {
-  private static final Logger log =
-      LoggerFactory.getLogger(PushCertificateChecker.class);
+  private static final Logger log = LoggerFactory.getLogger(PushCertificateChecker.class);
 
   public static class Result {
     private final PGPPublicKey key;
@@ -149,8 +146,8 @@
 
   /**
    * Get the repository that this checker should operate on.
-   * <p>
-   * This method is called once per call to {@link #check(PushCertificate)}.
+   *
+   * <p>This method is called once per call to {@link #check(PushCertificate)}.
    *
    * @return the repository.
    * @throws IOException if an error occurred reading the repository.
@@ -159,16 +156,15 @@
 
   /**
    * @param repo a repository previously returned by {@link #getRepository()}.
-   * @return whether this repository should be closed before returning from
-   *     {@link #check(PushCertificate)}.
+   * @return whether this repository should be closed before returning from {@link
+   *     #check(PushCertificate)}.
    */
   protected abstract boolean shouldClose(Repository repo);
 
   /**
    * Perform custom checks.
-   * <p>
-   * Default implementation reports no problems, but may be overridden by
-   * subclasses.
+   *
+   * <p>Default implementation reports no problems, but may be overridden by subclasses.
    *
    * @param repo a repository previously returned by {@link #getRepository()}.
    * @return the result of the custom check.
@@ -178,8 +174,8 @@
   }
 
   private PGPSignature readSignature(PushCertificate cert) throws IOException {
-    ArmoredInputStream in = new ArmoredInputStream(
-        new ByteArrayInputStream(Constants.encode(cert.getSignature())));
+    ArmoredInputStream in =
+        new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(cert.getSignature())));
     PGPObjectFactory factory = new BcPGPObjectFactory(in);
     Object obj;
     while ((obj = factory.nextObject()) != null) {
@@ -193,32 +189,28 @@
     return null;
   }
 
-  private Result checkSignature(PGPSignature sig, PushCertificate cert,
-      PublicKeyStore store) throws PGPException, IOException {
+  private Result checkSignature(PGPSignature sig, PushCertificate cert, PublicKeyStore store)
+      throws PGPException, IOException {
     PGPPublicKeyRingCollection keys = store.get(sig.getKeyID());
     if (!keys.getKeyRings().hasNext()) {
-      return new Result(null,
-          CheckResult.bad("No public keys found for key ID "
-              + keyIdToString(sig.getKeyID())));
-    }
-    PGPPublicKey signer =
-        PublicKeyStore.getSigner(keys, sig, Constants.encode(cert.toText()));
-    if (signer == null) {
-      return new Result(null,
-          CheckResult.bad("Signature by " + keyIdToString(sig.getKeyID())
-              + " is not valid"));
-    }
-    CheckResult result = publicKeyChecker
-        .setStore(store)
-        .setEffectiveTime(sig.getCreationTime())
-        .check(signer);
-    if (!result.getProblems().isEmpty()) {
-      StringBuilder err = new StringBuilder("Invalid public key ")
-          .append(keyToString(signer))
-          .append(":\n  ")
-          .append(Joiner.on("\n  ").join(result.getProblems()));
       return new Result(
-          signer, CheckResult.create(result.getStatus(), err.toString()));
+          null,
+          CheckResult.bad("No public keys found for key ID " + keyIdToString(sig.getKeyID())));
+    }
+    PGPPublicKey signer = PublicKeyStore.getSigner(keys, sig, Constants.encode(cert.toText()));
+    if (signer == null) {
+      return new Result(
+          null, CheckResult.bad("Signature by " + keyIdToString(sig.getKeyID()) + " is not valid"));
+    }
+    CheckResult result =
+        publicKeyChecker.setStore(store).setEffectiveTime(sig.getCreationTime()).check(signer);
+    if (!result.getProblems().isEmpty()) {
+      StringBuilder err =
+          new StringBuilder("Invalid public key ")
+              .append(keyToString(signer))
+              .append(":\n  ")
+              .append(Joiner.on("\n  ").join(result.getProblems()));
+      return new Result(signer, CheckResult.create(result.getStatus(), err.toString()));
     }
     return new Result(signer, result);
   }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushModule.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushModule.java
index bc027cd..c32e1df 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushModule.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -29,7 +29,12 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PreReceiveHook;
@@ -39,16 +44,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Random;
-
 class SignedPushModule extends AbstractModule {
-  private static final Logger log =
-      LoggerFactory.getLogger(SignedPushModule.class);
+  private static final Logger log = LoggerFactory.getLogger(SignedPushModule.class);
 
   @Override
   protected void configure() {
@@ -56,8 +53,7 @@
       throw new ProvisionException("Bouncy Castle PGP not installed");
     }
     bind(PublicKeyStore.class).toProvider(StoreProvider.class);
-    DynamicSet.bind(binder(), ReceivePackInitializer.class)
-        .to(Initializer.class);
+    DynamicSet.bind(binder(), ReceivePackInitializer.class).to(Initializer.class);
   }
 
   @Singleton
@@ -67,7 +63,8 @@
     private final ProjectCache projectCache;
 
     @Inject
-    Initializer(@GerritServerConfig Config cfg,
+    Initializer(
+        @GerritServerConfig Config cfg,
         @EnableSignedPush boolean enableSignedPush,
         SignedPushPreReceiveHook hook,
         ProjectCache projectCache) {
@@ -95,9 +92,11 @@
         rp.setSignedPushConfig(null);
         return;
       } else if (signedPushConfig == null) {
-        log.error("receive.enableSignedPush is true for project {} but"
-            + " false in gerrit.config, so signed push verification is"
-            + " disabled", project.get());
+        log.error(
+            "receive.enableSignedPush is true for project {} but"
+                + " false in gerrit.config, so signed push verification is"
+                + " disabled",
+            project.get());
         rp.setSignedPushConfig(null);
         return;
       }
@@ -119,8 +118,7 @@
     private final AllUsersName allUsers;
 
     @Inject
-    StoreProvider(GitRepositoryManager repoManager,
-        AllUsersName allUsers) {
+    StoreProvider(GitRepositoryManager repoManager, AllUsersName allUsers) {
       this.repoManager = repoManager;
       this.allUsers = allUsers;
     }
@@ -159,5 +157,4 @@
     }
     return sb.toString();
   }
-
 }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
index 59157bd..2755b91 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
@@ -19,20 +19,18 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.util.Collection;
 import org.eclipse.jgit.transport.PreReceiveHook;
 import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.ReceivePack;
 
-import java.util.Collection;
-
 /**
  * Pre-receive hook to check signed pushes.
- * <p>
- * If configured, prior to processing any push using
- * {@link com.google.gerrit.server.git.ReceiveCommits}, requires that any push
- * certificate present must be valid.
+ *
+ * <p>If configured, prior to processing any push using {@link
+ * com.google.gerrit.server.git.ReceiveCommits}, requires that any push certificate present must be
+ * valid.
  */
 @Singleton
 public class SignedPushPreReceiveHook implements PreReceiveHook {
@@ -47,8 +45,7 @@
       }
     }
 
-    private Required() {
-    }
+    private Required() {}
   }
 
   private final Provider<IdentifiedUser> user;
@@ -56,23 +53,19 @@
 
   @Inject
   public SignedPushPreReceiveHook(
-      Provider<IdentifiedUser> user,
-      GerritPushCertificateChecker.Factory checkerFactory) {
+      Provider<IdentifiedUser> user, GerritPushCertificateChecker.Factory checkerFactory) {
     this.user = user;
     this.checkerFactory = checkerFactory;
   }
 
   @Override
-  public void onPreReceive(ReceivePack rp,
-      Collection<ReceiveCommand> commands) {
+  public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
     PushCertificate cert = rp.getPushCertificate();
     if (cert == null) {
       return;
     }
-    CheckResult result = checkerFactory.create(user.get())
-        .setCheckNonce(true)
-        .check(cert)
-        .getCheckResult();
+    CheckResult result =
+        checkerFactory.create(user.get()).setCheckNonce(true).check(cert).getCheckResult();
     if (!isAllowed(result, commands)) {
       for (String problem : result.getProblems()) {
         rp.sendMessage(problem);
@@ -81,8 +74,7 @@
     }
   }
 
-  private static boolean isAllowed(CheckResult result,
-      Collection<ReceiveCommand> commands) {
+  private static boolean isAllowed(CheckResult result, Collection<ReceiveCommand> commands) {
     if (onlyMagicBranches(commands)) {
       // Only pushing magic branches: allow a valid push certificate even if the
       // key is not ultimately trusted. Assume anyone with Submit permission to
@@ -102,8 +94,7 @@
     return true;
   }
 
-  private static void reject(Collection<ReceiveCommand> commands,
-      String reason) {
+  private static void reject(Collection<ReceiveCommand> commands, String reason) {
     for (ReceiveCommand cmd : commands) {
       if (cmd.getResult() == ReceiveCommand.Result.NOT_ATTEMPTED) {
         cmd.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON, reason);
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 6809234..ba79a6f 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -29,14 +29,13 @@
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.bouncycastle.openpgp.PGPException;
-import org.eclipse.jgit.transport.PushCertificate;
-import org.eclipse.jgit.transport.PushCertificateParser;
-
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import org.bouncycastle.openpgp.PGPException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.PushCertificateParser;
 
 public class GpgApiAdapterImpl implements GpgApiAdapter {
   private final PostGpgKeys postGpgKeys;
@@ -72,15 +71,15 @@
   }
 
   @Override
-  public Map<String, GpgKeyInfo> putGpgKeys(AccountResource account,
-      List<String> add, List<String> delete)
+  public Map<String, GpgKeyInfo> putGpgKeys(
+      AccountResource account, List<String> add, List<String> delete)
       throws RestApiException, GpgException {
     PostGpgKeys.Input in = new PostGpgKeys.Input();
     in.add = add;
     in.delete = delete;
     try {
       return postGpgKeys.apply(account, in);
-    } catch (PGPException | OrmException | IOException e) {
+    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
     }
   }
@@ -96,14 +95,12 @@
   }
 
   @Override
-  public PushCertificateInfo checkPushCertificate(String certStr,
-      IdentifiedUser expectedUser) throws GpgException {
+  public PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser)
+      throws GpgException {
     try {
       PushCertificate cert = PushCertificateParser.fromString(certStr);
-      PushCertificateChecker.Result result = pushCertCheckerFactory
-          .create(expectedUser)
-          .setCheckNonce(false)
-          .check(cert);
+      PushCertificateChecker.Result result =
+          pushCertCheckerFactory.create(expectedUser).setCheckNonce(false).check(cert);
       PushCertificateInfo info = new PushCertificateInfo();
       info.certificate = certStr;
       info.key = GpgKeys.toJson(result.getPublicKey(), result.getCheckResult());
@@ -112,5 +109,4 @@
       throw new GpgException(e);
     }
   }
-
 }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java
index e65ebf2..f7102d8 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiModule.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
-
 import java.util.List;
 import java.util.Map;
 
@@ -72,8 +71,8 @@
     }
 
     @Override
-    public Map<String, GpgKeyInfo> putGpgKeys(AccountResource account,
-        List<String> add, List<String> delete) {
+    public Map<String, GpgKeyInfo> putGpgKeys(
+        AccountResource account, List<String> add, List<String> delete) {
       throw new NotImplementedException(MSG);
     }
 
@@ -83,8 +82,7 @@
     }
 
     @Override
-    public PushCertificateInfo checkPushCertificate(String certStr,
-        IdentifiedUser expectedUser) {
+    public PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser) {
       throw new NotImplementedException(MSG);
     }
   }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index ab30184..9aa18fe 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -23,10 +23,9 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
-import org.bouncycastle.openpgp.PGPException;
-
 import java.io.IOException;
+import org.bouncycastle.openpgp.PGPException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class GpgKeyApiImpl implements GpgKeyApi {
   public interface Factory {
@@ -38,10 +37,7 @@
   private final GpgKey rsrc;
 
   @AssistedInject
-  GpgKeyApiImpl(
-      GpgKeys.Get get,
-      DeleteGpgKey delete,
-      @Assisted GpgKey rsrc) {
+  GpgKeyApiImpl(GpgKeys.Get get, DeleteGpgKey delete, @Assisted GpgKey rsrc) {
     this.get = get;
     this.delete = delete;
     this.rsrc = rsrc;
@@ -60,7 +56,7 @@
   public void delete() throws RestApiException {
     try {
       delete.apply(rsrc, new DeleteGpgKey.Input());
-    } catch (PGPException | OrmException | IOException e) {
+    } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot delete GPG key", e);
     }
   }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index cac0e72..f95cee2 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.gpg.server;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -22,61 +23,60 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.DeleteGpgKey.Input;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 
-import java.io.IOException;
-import java.util.Collections;
-
 public class DeleteGpgKey implements RestModifyView<GpgKey, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   private final Provider<PersonIdent> serverIdent;
   private final Provider<ReviewDb> db;
   private final Provider<PublicKeyStore> storeProvider;
-  private final AccountCache accountCache;
+  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
-  DeleteGpgKey(@GerritPersonIdent Provider<PersonIdent> serverIdent,
+  DeleteGpgKey(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       Provider<ReviewDb> db,
       Provider<PublicKeyStore> storeProvider,
-      AccountCache accountCache) {
+      ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
     this.db = db;
     this.storeProvider = storeProvider;
-    this.accountCache = accountCache;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
   @Override
   public Response<?> apply(GpgKey rsrc, Input input)
-      throws ResourceConflictException, PGPException, OrmException,
-      IOException {
+      throws ResourceConflictException, PGPException, OrmException, IOException,
+          ConfigInvalidException {
     PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
-    AccountExternalId.Key extIdKey = new AccountExternalId.Key(
-        AccountExternalId.SCHEME_GPGKEY,
-        BaseEncoding.base16().encode(key.getFingerprint()));
-    db.get().accountExternalIds().deleteKeys(Collections.singleton(extIdKey));
-    accountCache.evict(rsrc.getUser().getAccountId());
+    externalIdsUpdateFactory
+        .create()
+        .delete(
+            db.get(),
+            rsrc.getUser().getAccountId(),
+            ExternalId.Key.create(
+                SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint())));
 
     try (PublicKeyStore store = storeProvider.get()) {
       store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
 
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(
-          committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
       cb.setCommitter(committer);
       cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
 
@@ -94,8 +94,7 @@
         case REJECTED_CURRENT_BRANCH:
         case RENAMED:
         default:
-          throw new ResourceConflictException(
-              "Failed to delete public key: " + saveResult);
+          throw new ResourceConflictException("Failed to delete public key: " + saveResult);
       }
     }
     return Response.none();
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKey.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKey.java
index 2fe7eb6..aa6b6f4 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKey.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKey.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.TypeLiteral;
-
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 
 public class GpgKey extends AccountResource {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
index 49657c6..efbec80 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Predicate;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.BaseEncoding;
@@ -38,15 +37,20 @@
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
@@ -55,19 +59,11 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.Map;
-
 @Singleton
-public class GpgKeys implements
-    ChildCollection<AccountResource, GpgKey> {
+public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
   private static final Logger log = LoggerFactory.getLogger(GpgKeys.class);
 
-  public static String MIME_TYPE = "application/pgp-keys";
+  public static final String MIME_TYPE = "application/pgp-keys";
 
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<ReviewDb> db;
@@ -76,7 +72,8 @@
   private final GerritPublicKeyChecker.Factory checkerFactory;
 
   @Inject
-  GpgKeys(DynamicMap<RestView<GpgKey>> views,
+  GpgKeys(
+      DynamicMap<RestView<GpgKey>> views,
       Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
@@ -89,15 +86,13 @@
   }
 
   @Override
-  public ListGpgKeys list()
-      throws ResourceNotFoundException, AuthException {
+  public ListGpgKeys list() throws ResourceNotFoundException, AuthException {
     return new ListGpgKeys();
   }
 
   @Override
   public GpgKey parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, PGPException, OrmException,
-      IOException {
+      throws ResourceNotFoundException, PGPException, OrmException, IOException {
     checkVisible(self, parent);
     String str = CharMatcher.whitespace().removeFrom(id.get()).toUpperCase();
     if ((str.length() != 8 && str.length() != 40)
@@ -119,8 +114,7 @@
     throw new ResourceNotFoundException(id);
   }
 
-  static byte[] parseFingerprint(String str,
-      Iterable<AccountExternalId> existingExtIds)
+  static byte[] parseFingerprint(String str, Iterable<ExternalId> existingExtIds)
       throws ResourceNotFoundException {
     str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
     if ((str.length() != 8 && str.length() != 40)
@@ -128,8 +122,8 @@
       throw new ResourceNotFoundException(str);
     }
     byte[] fp = null;
-    for (AccountExternalId extId : existingExtIds) {
-      String fpStr = extId.getSchemeRest();
+    for (ExternalId extId : existingExtIds) {
+      String fpStr = extId.key().id();
       if (!fpStr.endsWith(str)) {
         continue;
       } else if (fp != null) {
@@ -154,30 +148,27 @@
   public class ListGpgKeys implements RestReadView<AccountResource> {
     @Override
     public Map<String, GpgKeyInfo> apply(AccountResource rsrc)
-        throws OrmException, PGPException, IOException,
-        ResourceNotFoundException {
+        throws OrmException, PGPException, IOException, ResourceNotFoundException {
       checkVisible(self, rsrc);
       Map<String, GpgKeyInfo> keys = new HashMap<>();
       try (PublicKeyStore store = storeProvider.get()) {
-        for (AccountExternalId extId : getGpgExtIds(rsrc)) {
-          String fpStr = extId.getSchemeRest();
+        for (ExternalId extId : getGpgExtIds(rsrc)) {
+          String fpStr = extId.key().id();
           byte[] fp = BaseEncoding.base16().decode(fpStr);
           boolean found = false;
           for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
             if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
               found = true;
-              GpgKeyInfo info = toJson(
-                  keyRing.getPublicKey(),
-                  checkerFactory.create(rsrc.getUser(), store),
-                  store);
+              GpgKeyInfo info =
+                  toJson(
+                      keyRing.getPublicKey(), checkerFactory.create(rsrc.getUser(), store), store);
               keys.put(info.id, info);
               info.id = null;
               break;
             }
           }
           if (!found) {
-            log.warn("No public key stored for fingerprint {}",
-                Fingerprint.toString(fp));
+            log.warn("No public key stored for fingerprint {}", Fingerprint.toString(fp));
           }
         }
       }
@@ -191,8 +182,7 @@
     private final GerritPublicKeyChecker.Factory checkerFactory;
 
     @Inject
-    Get(Provider<PublicKeyStore> storeProvider,
-        GerritPublicKeyChecker.Factory checkerFactory) {
+    Get(Provider<PublicKeyStore> storeProvider, GerritPublicKeyChecker.Factory checkerFactory) {
       this.storeProvider = storeProvider;
       this.checkerFactory = checkerFactory;
     }
@@ -209,20 +199,14 @@
   }
 
   @VisibleForTesting
-  public static FluentIterable<AccountExternalId> getGpgExtIds(ReviewDb db,
-      Account.Id accountId) throws OrmException {
-    return FluentIterable
-        .from(db.accountExternalIds().byAccount(accountId))
-        .filter(new Predicate<AccountExternalId>() {
-          @Override
-          public boolean apply(AccountExternalId in) {
-            return in.isScheme(SCHEME_GPGKEY);
-          }
-        });
+  public static FluentIterable<ExternalId> getGpgExtIds(ReviewDb db, Account.Id accountId)
+      throws OrmException {
+    return FluentIterable.from(
+            ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()))
+        .filter(in -> in.isScheme(SCHEME_GPGKEY));
   }
 
-  private Iterable<AccountExternalId> getGpgExtIds(AccountResource rsrc)
-      throws OrmException {
+  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws OrmException {
     return getGpgExtIds(db.get(), rsrc.getUser().getAccountId());
   }
 
@@ -235,19 +219,17 @@
     if (!BouncyCastleUtil.havePGP()) {
       throw new ResourceNotFoundException("GPG not enabled");
     }
-    if (self.get() != rsrc.getUser()) {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       throw new ResourceNotFoundException();
     }
   }
 
-  public static GpgKeyInfo toJson(PGPPublicKey key, CheckResult checkResult)
-      throws IOException {
+  public static GpgKeyInfo toJson(PGPPublicKey key, CheckResult checkResult) throws IOException {
     GpgKeyInfo info = new GpgKeyInfo();
 
     if (key != null) {
       info.id = PublicKeyStore.keyIdToString(key.getKeyID());
       info.fingerprint = Fingerprint.toString(key.getFingerprint());
-      @SuppressWarnings("unchecked")
       Iterator<String> userIds = key.getUserIDs();
       info.userIds = ImmutableList.copyOf(userIds);
 
@@ -268,8 +250,8 @@
     return info;
   }
 
-  static GpgKeyInfo toJson(PGPPublicKey key, PublicKeyChecker checker,
-      PublicKeyStore store) throws IOException {
+  static GpgKeyInfo toJson(PGPPublicKey key, PublicKeyChecker checker, PublicKeyStore store)
+      throws IOException {
     return toJson(key, checker.setStore(store).check(key));
   }
 
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 2deae3f..0e3fb97 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -16,13 +16,13 @@
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -40,33 +40,20 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.PostGpgKeys.Input;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gerrit.server.mail.AddKeySender;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.bouncycastle.bcpg.ArmoredInputStream;
-import org.bouncycastle.openpgp.PGPException;
-import org.bouncycastle.openpgp.PGPPublicKey;
-import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -75,6 +62,18 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPRuntimeOperationException;
+import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class PostGpgKeys implements RestModifyView<AccountResource, Input> {
@@ -90,100 +89,74 @@
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
   private final AddKeySender.Factory addKeyFactory;
-  private final AccountCache accountCache;
-  private final AccountIndexCollection accountIndexes;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
-  PostGpgKeys(@GerritPersonIdent Provider<PersonIdent> serverIdent,
+  PostGpgKeys(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
       AddKeySender.Factory addKeyFactory,
-      AccountCache accountCache,
-      AccountIndexCollection accountIndexes,
-      Provider<InternalAccountQuery> accountQueryProvider) {
+      Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
     this.db = db;
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
     this.addKeyFactory = addKeyFactory;
-    this.accountCache = accountCache;
-    this.accountIndexes = accountIndexes;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
   @Override
   public Map<String, GpgKeyInfo> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, BadRequestException,
-      ResourceConflictException, PGPException, OrmException, IOException {
+      throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
+          PGPException, OrmException, IOException, ConfigInvalidException {
     GpgKeys.checkVisible(self, rsrc);
 
-    List<AccountExternalId> existingExtIds =
+    Collection<ExternalId> existingExtIds =
         GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
-
     try (PublicKeyStore store = storeProvider.get()) {
       Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
       List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
-      List<AccountExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
+      List<ExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
 
       for (PGPPublicKeyRing keyRing : newKeys) {
         PGPPublicKey key = keyRing.getPublicKey();
-        AccountExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
-        if (accountIndexes.getSearchIndex() != null) {
-          Account account = getAccountByExternalId(extIdKey.get());
-          if (account != null) {
-            if (!account.getId().equals(rsrc.getUser().getAccountId())) {
-              throw new ResourceConflictException(
-                  "GPG key already associated with another account");
-            }
-          } else {
-            newExtIds.add(
-                new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
+        ExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
+        Account account = getAccountByExternalId(extIdKey);
+        if (account != null) {
+          if (!account.getId().equals(rsrc.getUser().getAccountId())) {
+            throw new ResourceConflictException("GPG key already associated with another account");
           }
         } else {
-          AccountExternalId existing = db.get().accountExternalIds().get(extIdKey);
-          if (existing != null) {
-            if (!existing.getAccountId().equals(rsrc.getUser().getAccountId())) {
-              throw new ResourceConflictException(
-                  "GPG key already associated with another account");
-            }
-          } else {
-            newExtIds.add(
-                new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
-          }
+          newExtIds.add(ExternalId.create(extIdKey, rsrc.getUser().getAccountId()));
         }
       }
 
       storeKeys(rsrc, newKeys, toRemove);
-      if (!newExtIds.isEmpty()) {
-        db.get().accountExternalIds().insert(newExtIds);
-      }
-      db.get().accountExternalIds().deleteKeys(Iterables.transform(toRemove,
-          new Function<Fingerprint, AccountExternalId.Key>() {
-            @Override
-            public AccountExternalId.Key apply(Fingerprint fp) {
-              return toExtIdKey(fp.get());
-            }
-          }));
-      accountCache.evict(rsrc.getUser().getAccountId());
+
+      List<ExternalId.Key> extIdKeysToRemove =
+          toRemove.stream().map(fp -> toExtIdKey(fp.get())).collect(toList());
+      externalIdsUpdateFactory
+          .create()
+          .replace(db.get(), rsrc.getUser().getAccountId(), extIdKeysToRemove, newExtIds);
       return toJson(newKeys, toRemove, store, rsrc.getUser());
     }
   }
 
-  private Set<Fingerprint> readKeysToRemove(Input input,
-      List<AccountExternalId> existingExtIds) {
+  private Set<Fingerprint> readKeysToRemove(Input input, Collection<ExternalId> existingExtIds) {
     if (input.delete == null || input.delete.isEmpty()) {
       return ImmutableSet.of();
     }
-    Set<Fingerprint> fingerprints =
-        Sets.newHashSetWithExpectedSize(input.delete.size());
+    Set<Fingerprint> fingerprints = Sets.newHashSetWithExpectedSize(input.delete.size());
     for (String id : input.delete) {
       try {
-        fingerprints.add(new Fingerprint(
-            GpgKeys.parseFingerprint(id, existingExtIds)));
+        fingerprints.add(new Fingerprint(GpgKeys.parseFingerprint(id, existingExtIds)));
       } catch (ResourceNotFoundException e) {
         // Skip removal.
       }
@@ -191,8 +164,7 @@
     return fingerprints;
   }
 
-  private List<PGPPublicKeyRing> readKeysToAdd(Input input,
-      Set<Fingerprint> toRemove)
+  private List<PGPPublicKeyRing> readKeysToAdd(Input input, Set<Fingerprint> toRemove)
       throws BadRequestException, IOException {
     if (input.add == null || input.add.isEmpty()) {
       return ImmutableList.of();
@@ -207,32 +179,32 @@
           throw new BadRequestException("Expected exactly one PUBLIC KEY BLOCK");
         }
         PGPPublicKeyRing keyRing = (PGPPublicKeyRing) objs.get(0);
-        if (toRemove.contains(
-            new Fingerprint(keyRing.getPublicKey().getFingerprint()))) {
-          throw new BadRequestException("Cannot both add and delete key: "
-              + keyToString(keyRing.getPublicKey()));
+        if (toRemove.contains(new Fingerprint(keyRing.getPublicKey().getFingerprint()))) {
+          throw new BadRequestException(
+              "Cannot both add and delete key: " + keyToString(keyRing.getPublicKey()));
         }
         keyRings.add(keyRing);
+      } catch (PGPRuntimeOperationException e) {
+        throw new BadRequestException("Failed to parse GPG keys", e);
       }
     }
     return keyRings;
   }
 
-  private void storeKeys(AccountResource rsrc, List<PGPPublicKeyRing> keyRings,
-      Set<Fingerprint> toRemove) throws BadRequestException,
-      ResourceConflictException, PGPException, IOException {
+  private void storeKeys(
+      AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Set<Fingerprint> toRemove)
+      throws BadRequestException, ResourceConflictException, PGPException, IOException {
     try (PublicKeyStore store = storeProvider.get()) {
       List<String> addedKeys = new ArrayList<>();
       for (PGPPublicKeyRing keyRing : keyRings) {
         PGPPublicKey key = keyRing.getPublicKey();
         // Don't check web of trust; admins can fill in certifications later.
-        CheckResult result = checkerFactory.create(rsrc.getUser(), store)
-            .disableTrust()
-            .check(key);
+        CheckResult result = checkerFactory.create(rsrc.getUser(), store).disableTrust().check(key);
         if (!result.isOk()) {
-          throw new BadRequestException(String.format(
-              "Problems with public key %s:\n%s",
-              keyToString(key), Joiner.on('\n').join(result.getProblems())));
+          throw new BadRequestException(
+              String.format(
+                  "Problems with public key %s:\n%s",
+                  keyToString(key), Joiner.on('\n').join(result.getProblems())));
         }
         addedKeys.add(PublicKeyStore.keyToString(key));
         store.add(keyRing);
@@ -242,8 +214,7 @@
       }
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(
-          committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
       cb.setCommitter(committer);
 
       RefUpdate.Result saveResult = store.save(cb);
@@ -254,8 +225,10 @@
           try {
             addKeyFactory.create(rsrc.getUser(), addedKeys).send();
           } catch (EmailException e) {
-            log.error("Cannot send GPG key added message to "
-                + rsrc.getUser().getAccount().getPreferredEmail(), e);
+            log.error(
+                "Cannot send GPG key added message to "
+                    + rsrc.getUser().getAccount().getPreferredEmail(),
+                e);
           }
           break;
         case NO_CHANGE:
@@ -268,22 +241,17 @@
         case RENAMED:
         default:
           // TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
-          throw new ResourceConflictException(
-              "Failed to save public keys: " + saveResult);
+          throw new ResourceConflictException("Failed to save public keys: " + saveResult);
       }
     }
   }
 
-  private AccountExternalId.Key toExtIdKey(byte[] fp) {
-    return new AccountExternalId.Key(
-        AccountExternalId.SCHEME_GPGKEY,
-        BaseEncoding.base16().encode(fp));
+  private ExternalId.Key toExtIdKey(byte[] fp) {
+    return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
   }
 
-  private Account getAccountByExternalId(String externalId)
-      throws OrmException {
-    List<AccountState> accountStates =
-        accountQueryProvider.get().byExternalId(externalId);
+  private Account getAccountByExternalId(ExternalId.Key extIdKey) throws OrmException {
+    List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
 
     if (accountStates.isEmpty()) {
       return null;
@@ -291,11 +259,10 @@
 
     if (accountStates.size() > 1) {
       StringBuilder msg = new StringBuilder();
-      msg.append("GPG key ").append(externalId)
-          .append(" associated with multiple accounts: ");
-      Joiner.on(", ").appendTo(msg,
-          Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
-      log.error(msg.toString());
+      msg.append("GPG key ")
+          .append(extIdKey.get())
+          .append(" associated with multiple accounts: ")
+          .append(Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
       throw new IllegalStateException(msg.toString());
     }
 
@@ -304,13 +271,14 @@
 
   private Map<String, GpgKeyInfo> toJson(
       Collection<PGPPublicKeyRing> keys,
-      Set<Fingerprint> deleted, PublicKeyStore store, IdentifiedUser user)
+      Set<Fingerprint> deleted,
+      PublicKeyStore store,
+      IdentifiedUser user)
       throws IOException {
     // Unlike when storing keys, include web-of-trust checks when producing
     // result JSON, so the user at least knows of any issues.
     PublicKeyChecker checker = checkerFactory.create(user, store);
-    Map<String, GpgKeyInfo> infos =
-        Maps.newHashMapWithExpectedSize(keys.size() + deleted.size());
+    Map<String, GpgKeyInfo> infos = Maps.newHashMapWithExpectedSize(keys.size() + deleted.size());
     for (PGPPublicKeyRing keyRing : keys) {
       PGPPublicKey key = keyRing.getPublicKey();
       CheckResult result = checker.check(key);
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 e39c8ae..886fdcd 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
@@ -23,7 +23,6 @@
 import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyC;
 import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyD;
 import static com.google.gerrit.gpg.testutil.TestTrustKeys.keyE;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
 import static org.eclipse.jgit.lib.RefUpdate.Result.FAST_FORWARD;
 import static org.eclipse.jgit.lib.RefUpdate.Result.FORCED;
 import static org.eclipse.jgit.lib.RefUpdate.Result.NEW;
@@ -34,13 +33,14 @@
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -52,7 +52,10 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -66,34 +69,23 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
 /** Unit tests for {@link GerritPublicKeyChecker}. */
 public class GerritPublicKeyCheckerTest {
-  @Inject
-  private AccountCache accountCache;
+  @Inject private AccountCache accountCache;
 
-  @Inject
-  private AccountManager accountManager;
+  @Inject private AccountManager accountManager;
 
-  @Inject
-  private GerritPublicKeyChecker.Factory checkerFactory;
+  @Inject private GerritPublicKeyChecker.Factory checkerFactory;
 
-  @Inject
-  private IdentifiedUser.GenericFactory userFactory;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject
-  private InMemoryDatabase schemaFactory;
+  @Inject private InMemoryDatabase schemaFactory;
 
-  @Inject
-  private SchemaCreator schemaCreator;
+  @Inject private SchemaCreator schemaCreator;
 
-  @Inject
-  private ThreadLocalRequestContext requestContext;
+  @Inject private ThreadLocalRequestContext requestContext;
+
+  @Inject private ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   private LifecycleManager lifecycle;
   private ReviewDb db;
@@ -106,11 +98,14 @@
   public void setUpInjector() throws Exception {
     Config cfg = InMemoryModule.newDefaultConfig();
     cfg.setInt("receive", null, "maxTrustDepth", 2);
-    cfg.setStringList("receive", null, "trustedKey", ImmutableList.of(
-        Fingerprint.toString(keyB().getPublicKey().getFingerprint()),
-        Fingerprint.toString(keyD().getPublicKey().getFingerprint())));
-    Injector injector = Guice.createInjector(
-        new InMemoryModule(cfg, new TestNotesMigration()));
+    cfg.setStringList(
+        "receive",
+        null,
+        "trustedKey",
+        ImmutableList.of(
+            Fingerprint.toString(keyB().getPublicKey().getFingerprint()),
+            Fingerprint.toString(keyD().getPublicKey().getFingerprint())));
+    Injector injector = Guice.createInjector(new InMemoryModule(cfg, new TestNotesMigration()));
 
     lifecycle = new LifecycleManager();
     lifecycle.add(injector);
@@ -119,25 +114,25 @@
 
     db = schemaFactory.open();
     schemaCreator.create(db);
-    userId =
-        accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     Account userAccount = db.accounts().get(userId);
     // Note: does not match any key in TestKeys.
     userAccount.setPreferredEmail("user@example.com");
     db.accounts().update(ImmutableList.of(userAccount));
     user = reloadUser();
 
-    requestContext.setContext(new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return user;
-      }
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
 
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    });
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
 
     storeRepo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
     store = new PublicKeyStore(storeRepo);
@@ -175,86 +170,76 @@
   @Test
   public void defaultGpgCertificationMatchesEmail() throws Exception {
     TestKey key = validKeyWithSecondUserId();
-    PublicKeyChecker checker = checkerFactory.create(user, store)
-        .disableTrust();
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
     assertProblems(
-        checker.check(key.getPublicKey()), Status.BAD,
+        checker.check(key.getPublicKey()),
+        Status.BAD,
         "Key must contain a valid certification for one of the following "
-          + "identities:\n"
-          + "  gerrit:user\n"
-          + "  username:user");
+            + "identities:\n"
+            + "  gerrit:user\n"
+            + "  username:user");
 
     addExternalId("test", "test", "test5@example.com");
-    checker = checkerFactory.create(user, store)
-        .disableTrust();
+    checker = checkerFactory.create(user, store).disableTrust();
     assertNoProblems(checker.check(key.getPublicKey()));
   }
 
   @Test
   public void defaultGpgCertificationDoesNotMatchEmail() throws Exception {
     addExternalId("test", "test", "nobody@example.com");
-    PublicKeyChecker checker = checkerFactory.create(user, store)
-        .disableTrust();
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
     assertProblems(
-        checker.check(validKeyWithSecondUserId().getPublicKey()), Status.BAD,
+        checker.check(validKeyWithSecondUserId().getPublicKey()),
+        Status.BAD,
         "Key must contain a valid certification for one of the following "
-          + "identities:\n"
-          + "  gerrit:user\n"
-          + "  nobody@example.com\n"
-          + "  test:test\n"
-          + "  username:user");
+            + "identities:\n"
+            + "  gerrit:user\n"
+            + "  nobody@example.com\n"
+            + "  test:test\n"
+            + "  username:user");
   }
 
   @Test
   public void manualCertificationMatchesExternalId() throws Exception {
     addExternalId("foo", "myId", null);
-    PublicKeyChecker checker = checkerFactory.create(user, store)
-        .disableTrust();
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
     assertNoProblems(checker.check(validKeyWithSecondUserId().getPublicKey()));
   }
 
   @Test
   public void manualCertificationDoesNotMatchExternalId() throws Exception {
     addExternalId("foo", "otherId", null);
-    PublicKeyChecker checker = checkerFactory.create(user, store)
-        .disableTrust();
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
     assertProblems(
-        checker.check(validKeyWithSecondUserId().getPublicKey()), Status.BAD,
+        checker.check(validKeyWithSecondUserId().getPublicKey()),
+        Status.BAD,
         "Key must contain a valid certification for one of the following "
-          + "identities:\n"
-          + "  foo:otherId\n"
-          + "  gerrit:user\n"
-          + "  username:user");
+            + "identities:\n"
+            + "  foo:otherId\n"
+            + "  gerrit:user\n"
+            + "  username:user");
   }
 
   @Test
   public void noExternalIds() throws Exception {
-    db.accountExternalIds().delete(
-        db.accountExternalIds().byAccount(user.getAccountId()));
+    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
+    externalIdsUpdate.deleteAll(db, user.getAccountId());
     reloadUser();
 
     TestKey key = validKeyWithSecondUserId();
-    PublicKeyChecker checker = checkerFactory.create(user, store)
-        .disableTrust();
+    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
     assertProblems(
-        checker.check(key.getPublicKey()), Status.BAD,
-        "No identities found for user; check"
-          + " http://test/#/settings/web-identities");
+        checker.check(key.getPublicKey()),
+        Status.BAD,
+        "No identities found for user; check http://test/#/settings/web-identities");
 
-    checker = checkerFactory.create()
-        .setStore(store)
-        .disableTrust();
+    checker = checkerFactory.create().setStore(store).disableTrust();
     assertProblems(
-        checker.check(key.getPublicKey()), Status.BAD,
-        "Key is not associated with any users");
-
-    db.accountExternalIds().insert(Collections.singleton(
-        new AccountExternalId(
-            user.getAccountId(), toExtIdKey(key.getPublicKey()))));
+        checker.check(key.getPublicKey()), Status.BAD, "Key is not associated with any users");
+    externalIdsUpdate.insert(
+        db, ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
     reloadUser();
-    assertProblems(
-        checker.check(key.getPublicKey()), Status.BAD,
-        "No identities found for user");
+    assertProblems(checker.check(key.getPublicKey()), Status.BAD, "No identities found for user");
   }
 
   @Test
@@ -281,14 +266,11 @@
     // Checker for B, checking B. Trust chain and IDs are correct, so the only
     // problem is with the key itself.
     PublicKeyChecker checkerB = checkerFactory.create(userB, store);
-    assertProblems(
-        checkerB.check(keyB.getPublicKey()), Status.BAD,
-        "Key is expired");
+    assertProblems(checkerB.check(keyB.getPublicKey()), Status.BAD, "Key is expired");
   }
 
   @Test
-  public void checkWithValidKeyButWrongExpectedUserInChecker()
-      throws Exception {
+  public void checkWithValidKeyButWrongExpectedUserInChecker() throws Exception {
     // A---Bx
     //  \
     //   \---C---D
@@ -307,7 +289,8 @@
     // Checker for A, checking B.
     PublicKeyChecker checkerA = checkerFactory.create(user, store);
     assertProblems(
-        checkerA.check(keyB.getPublicKey()), Status.BAD,
+        checkerA.check(keyB.getPublicKey()),
+        Status.BAD,
         "Key is expired",
         "Key must contain a valid certification for one of the following"
             + " identities:\n"
@@ -319,7 +302,8 @@
     // Checker for B, checking A.
     PublicKeyChecker checkerB = checkerFactory.create(userB, store);
     assertProblems(
-        checkerB.check(keyA.getPublicKey()), Status.BAD,
+        checkerB.check(keyA.getPublicKey()),
+        Status.BAD,
         "Key must contain a valid certification for one of the following"
             + " identities:\n"
             + "  gerrit:userB\n"
@@ -338,9 +322,11 @@
 
     PublicKeyChecker checker = checkerFactory.create(user, store);
     assertProblems(
-        checker.check(keyA.getPublicKey()), Status.OK,
+        checker.check(keyA.getPublicKey()),
+        Status.OK,
         "No path to a trusted key",
-        "Certification by " + keyToString(keyB.getPublicKey())
+        "Certification by "
+            + keyToString(keyB.getPublicKey())
             + " is valid, but key is not trusted",
         "Key D24FE467 used for certification is not in store");
   }
@@ -363,16 +349,14 @@
 
     // This checker can check any key, so the only problems come from issues
     // with the keys themselves, not having invalid user IDs.
-    PublicKeyChecker checker = checkerFactory.create()
-        .setStore(store);
+    PublicKeyChecker checker = checkerFactory.create().setStore(store);
     assertNoProblems(checker.check(keyA.getPublicKey()));
-    assertProblems(
-        checker.check(keyB.getPublicKey()), Status.BAD,
-        "Key is expired");
+    assertProblems(checker.check(keyB.getPublicKey()), Status.BAD, "Key is expired");
     assertNoProblems(checker.check(keyC.getPublicKey()));
     assertNoProblems(checker.check(keyD.getPublicKey()));
     assertProblems(
-        checker.check(keyE.getPublicKey()), Status.BAD,
+        checker.check(keyE.getPublicKey()),
+        Status.BAD,
         "Key is expired",
         "No path to a trusted key");
   }
@@ -389,34 +373,29 @@
 
     PGPPublicKeyRing keyRingB = keyB().getPublicKeyRing();
     PGPPublicKey keyB = keyRingB.getPublicKey();
-    keyB = PGPPublicKey.removeCertification(
-        keyB, (String) keyB.getUserIDs().next());
+    keyB = PGPPublicKey.removeCertification(keyB, keyB.getUserIDs().next());
     keyRingB = PGPPublicKeyRing.insertPublicKey(keyRingB, keyB);
     add(keyRingB, addUser("userB"));
 
     PublicKeyChecker checkerA = checkerFactory.create(user, store);
-    assertProblems(checkerA.check(keyA.getPublicKey()), Status.OK,
+    assertProblems(
+        checkerA.check(keyA.getPublicKey()),
+        Status.OK,
         "No path to a trusted key",
-        "Certification by " + keyToString(keyB)
-            + " is valid, but key is not trusted",
+        "Certification by " + keyToString(keyB) + " is valid, but key is not trusted",
         "Key D24FE467 used for certification is not in store");
   }
 
   private void add(PGPPublicKeyRing kr, IdentifiedUser user) throws Exception {
     Account.Id id = user.getAccountId();
-    List<AccountExternalId> newExtIds = new ArrayList<>(2);
-    newExtIds.add(new AccountExternalId(id, toExtIdKey(kr.getPublicKey())));
+    List<ExternalId> newExtIds = new ArrayList<>(2);
+    newExtIds.add(ExternalId.create(toExtIdKey(kr.getPublicKey()), id));
 
-    @SuppressWarnings("unchecked")
-    String userId = (String) Iterators.getOnlyElement(
-        kr.getPublicKey().getUserIDs(), null);
+    String userId = Iterators.getOnlyElement(kr.getPublicKey().getUserIDs(), null);
     if (userId != null) {
       String email = PushCertificateIdent.parse(userId).getEmailAddress();
       assertThat(email).contains("@");
-      AccountExternalId mailto = new AccountExternalId(
-          id, new AccountExternalId.Key(SCHEME_MAILTO, email));
-      mailto.setEmailAddress(email);
-      newExtIds.add(mailto);
+      newExtIds.add(ExternalId.createEmail(id, email));
     }
 
     store.add(kr);
@@ -426,8 +405,7 @@
     cb.setCommitter(ident);
     assertThat(store.save(cb)).isAnyOf(NEW, FAST_FORWARD, FORCED);
 
-    db.accountExternalIds().insert(newExtIds);
-    accountCache.evict(user.getAccountId());
+    externalIdsUpdateFactory.create().insert(db, newExtIds);
   }
 
   private TestKey add(TestKey k, IdentifiedUser user) throws Exception {
@@ -435,15 +413,13 @@
     return k;
   }
 
-  private void assertProblems(CheckResult result, Status expectedStatus,
-      String first, String... rest) throws Exception {
+  private void assertProblems(
+      CheckResult result, Status expectedStatus, String first, String... rest) throws Exception {
     List<String> expectedProblems = new ArrayList<>();
     expectedProblems.add(first);
     expectedProblems.addAll(Arrays.asList(rest));
     assertThat(result.getStatus()).isEqualTo(expectedStatus);
-    assertThat(result.getProblems())
-        .containsExactlyElementsIn(expectedProblems)
-        .inOrder();
+    assertThat(result.getProblems()).containsExactlyElementsIn(expectedProblems).inOrder();
   }
 
   private void assertNoProblems(CheckResult result) {
@@ -451,14 +427,10 @@
     assertThat(result.getProblems()).isEmpty();
   }
 
-  private void addExternalId(String scheme, String id, String email)
-      throws Exception {
-    AccountExternalId extId = new AccountExternalId(user.getAccountId(),
-        new AccountExternalId.Key(scheme, id));
-    if (email != null) {
-      extId.setEmailAddress(email);
-    }
-    db.accountExternalIds().insert(Collections.singleton(extId));
+  private void addExternalId(String scheme, String id, String email) throws Exception {
+    externalIdsUpdateFactory
+        .create()
+        .insert(db, ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
     reloadUser();
   }
 }
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index bd71bc5..39e2cb4 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -38,7 +38,15 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testutil.TestKey;
-
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 import org.bouncycastle.openpgp.PGPSignature;
@@ -53,19 +61,8 @@
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
 
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-
 public class PublicKeyCheckerTest {
-  @Rule
-  public ExpectedException thrown = ExpectedException.none();
+  @Rule public ExpectedException thrown = ExpectedException.none();
 
   private InMemoryRepository repo;
   private PublicKeyStore store;
@@ -97,8 +94,7 @@
   public void keyExpiringInFuture() throws Exception {
     TestKey k = validKeyWithExpiration();
 
-    PublicKeyChecker checker = new PublicKeyChecker()
-        .setStore(store);
+    PublicKeyChecker checker = new PublicKeyChecker().setStore(store);
     assertNoProblems(checker, k);
 
     checker.setEffectiveTime(parseDate("2015-07-10 12:00:00 -0400"));
@@ -115,8 +111,7 @@
 
   @Test
   public void selfRevokedKeyIsRevoked() throws Exception {
-    assertProblems(selfRevokedKey(),
-        "Key is revoked (key material has been compromised)");
+    assertProblems(selfRevokedKey(), "Key is revoked (key material has been compromised)");
   }
 
   // Test keys specific to this test are at the bottom of this class. Each test
@@ -165,8 +160,7 @@
     save();
 
     PublicKeyChecker checker = newChecker(1, kd);
-    assertProblems(checker, ka,
-        "No path to a trusted key", notTrusted(kb), notTrusted(kc));
+    assertProblems(checker, ka, "No path to a trusted key", notTrusted(kb), notTrusted(kc));
   }
 
   @Test
@@ -177,10 +171,8 @@
     save();
 
     PublicKeyChecker checker = newChecker(10, keyA());
-    assertProblems(checker, kf,
-        "No path to a trusted key", notTrusted(kg));
-    assertProblems(checker, kg,
-        "No path to a trusted key", notTrusted(kf));
+    assertProblems(checker, kf, "No path to a trusted key", notTrusted(kg));
+    assertProblems(checker, kg, "No path to a trusted key", notTrusted(kf));
   }
 
   @Test
@@ -196,8 +188,7 @@
     // J trusts I to a depth of 1, so I itself is valid, but I's certification
     // of K is not valid.
     assertNoProblems(checker, ki);
-    assertProblems(checker, kh,
-        "No path to a trusted key", notTrusted(ki));
+    assertProblems(checker, kh, "No path to a trusted key", notTrusted(ki));
   }
 
   @Test
@@ -206,9 +197,7 @@
     add(validKeyWithoutExpiration());
     save();
 
-    assertProblems(k,
-        "Key is revoked (key material has been compromised):"
-          + " test6 compromised");
+    assertProblems(k, "Key is revoked (key material has been compromised): test6 compromised");
 
     PGPPublicKeyRing kr = removeRevokers(k.getPublicKeyRing());
     store.add(kr);
@@ -219,20 +208,17 @@
   }
 
   @Test
-  public void revokedKeyDueToCompromiseRevokesKeyRetroactively()
-      throws Exception {
+  public void revokedKeyDueToCompromiseRevokesKeyRetroactively() throws Exception {
     TestKey k = add(revokedCompromisedKey());
     add(validKeyWithoutExpiration());
     save();
 
-    String problem =
-        "Key is revoked (key material has been compromised): test6 compromised";
+    String problem = "Key is revoked (key material has been compromised): test6 compromised";
     assertProblems(k, problem);
 
     SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-    PublicKeyChecker checker = new PublicKeyChecker()
-        .setStore(store)
-        .setEffectiveTime(df.parse("2010-01-01 12:00:00"));
+    PublicKeyChecker checker =
+        new PublicKeyChecker().setStore(store).setEffectiveTime(df.parse("2010-01-01 12:00:00"));
     assertProblems(checker, k, problem);
   }
 
@@ -241,9 +227,7 @@
     TestKey k = add(revokedCompromisedKey());
     save();
 
-    assertProblems(k,
-        "Key is revoked (key material has been compromised):"
-          + " test6 compromised");
+    assertProblems(k, "Key is revoked (key material has been compromised): test6 compromised");
   }
 
   @Test
@@ -252,29 +236,26 @@
     add(validKeyWithoutExpiration());
     save();
 
-    assertProblems(k,
-        "Key is revoked (retired and no longer valid): test7 not used");
+    assertProblems(k, "Key is revoked (retired and no longer valid): test7 not used");
   }
 
   @Test
-  public void revokedKeyDueToNoLongerBeingUsedDoesNotRevokeKeyRetroactively()
-      throws Exception {
+  public void revokedKeyDueToNoLongerBeingUsedDoesNotRevokeKeyRetroactively() throws Exception {
     TestKey k = add(revokedNoLongerUsedKey());
     add(validKeyWithoutExpiration());
     save();
 
-    assertProblems(k,
-        "Key is revoked (retired and no longer valid): test7 not used");
+    assertProblems(k, "Key is revoked (retired and no longer valid): test7 not used");
 
-    PublicKeyChecker checker = new PublicKeyChecker()
-        .setStore(store)
-        .setEffectiveTime(parseDate("2010-01-01 12:00:00 -0400"));
+    PublicKeyChecker checker =
+        new PublicKeyChecker()
+            .setStore(store)
+            .setEffectiveTime(parseDate("2010-01-01 12:00:00 -0400"));
     assertNoProblems(checker, k);
   }
 
   @Test
-  public void keyRevokedByExpiredKeyAfterExpirationIsNotRevoked()
-      throws Exception {
+  public void keyRevokedByExpiredKeyAfterExpirationIsNotRevoked() throws Exception {
     TestKey k = add(keyRevokedByExpiredKeyAfterExpiration());
     add(expiredKey());
     save();
@@ -284,15 +265,13 @@
   }
 
   @Test
-  public void keyRevokedByExpiredKeyBeforeExpirationIsRevoked()
-      throws Exception {
+  public void keyRevokedByExpiredKeyBeforeExpirationIsRevoked() throws Exception {
     TestKey k = add(keyRevokedByExpiredKeyBeforeExpiration());
     add(expiredKey());
     save();
 
     PublicKeyChecker checker = new PublicKeyChecker().setStore(store);
-    assertProblems(checker, k,
-        "Key is revoked (retired and no longer valid): test9 not used");
+    assertProblems(checker, k, "Key is revoked (retired and no longer valid): test9 not used");
 
     // Set time between key creation and revocation.
     checker.setEffectiveTime(parseDate("2005-08-01 13:00:00 -0400"));
@@ -318,9 +297,7 @@
       Fingerprint fp = new Fingerprint(k.getPublicKey().getFingerprint());
       fps.put(fp.getId(), fp);
     }
-    return new PublicKeyChecker()
-        .enableTrust(maxTrustDepth, fps)
-        .setStore(store);
+    return new PublicKeyChecker().enableTrust(maxTrustDepth, fps).setStore(store);
   }
 
   private TestKey add(TestKey k) {
@@ -351,16 +328,13 @@
     }
   }
 
-  private void assertProblems(PublicKeyChecker checker, TestKey k,
-      String first, String... rest) {
-    CheckResult result = checker.setStore(store)
-        .check(k.getPublicKey());
+  private void assertProblems(PublicKeyChecker checker, TestKey k, String first, String... rest) {
+    CheckResult result = checker.setStore(store).check(k.getPublicKey());
     assertEquals(list(first, rest), result.getProblems());
   }
 
   private void assertNoProblems(PublicKeyChecker checker, TestKey k) {
-    CheckResult result = checker.setStore(store)
-        .check(k.getPublicKey());
+    CheckResult result = checker.setStore(store).check(k.getPublicKey());
     assertEquals(Collections.emptyList(), result.getProblems());
   }
 
@@ -373,21 +347,18 @@
   }
 
   private void assertProblems(PGPPublicKey k, String first, String... rest) {
-    CheckResult result = new PublicKeyChecker()
-        .setStore(store)
-        .check(k);
+    CheckResult result = new PublicKeyChecker().setStore(store).check(k);
     assertEquals(list(first, rest), result.getProblems());
   }
 
   private void assertNoProblems(PGPPublicKey k) {
-    CheckResult result = new PublicKeyChecker()
-        .setStore(store)
-        .check(k);
+    CheckResult result = new PublicKeyChecker().setStore(store).check(k);
     assertEquals(Collections.emptyList(), result.getProblems());
   }
 
   private static String notTrusted(TestKey k) {
-    return "Certification by " + keyToString(k.getPublicKey())
+    return "Certification by "
+        + keyToString(k.getPublicKey())
         + " is valid, but key is not trusted";
   }
 
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java
index 11e9768..9a2acff 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -26,8 +26,14 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
+import com.google.common.collect.Iterators;
 import com.google.gerrit.gpg.testutil.TestKey;
-
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
@@ -43,21 +49,13 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import java.util.TreeSet;
-
 public class PublicKeyStoreTest {
   private TestRepository<?> tr;
   private PublicKeyStore store;
 
   @Before
   public void setUp() throws Exception {
-    tr = new TestRepository<>(new InMemoryRepository(
-        new DfsRepositoryDescription("pubkeys")));
+    tr = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("pubkeys")));
     store = new PublicKeyStore(tr.getRepository());
   }
 
@@ -70,8 +68,9 @@
   @Test
   public void testKeyToString() throws Exception {
     PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
-    assertEquals("46328A8C Testuser One <test1@example.com>"
-          + " (04AE A7ED 2F82 1133 E5B1  28D1 ED06 25DC 4632 8A8C)",
+    assertEquals(
+        "46328A8C Testuser One <test1@example.com>"
+            + " (04AE A7ED 2F82 1133 E5B1  28D1 ED06 25DC 4632 8A8C)",
         keyToString(key));
   }
 
@@ -80,23 +79,20 @@
     PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
     String objId = keyObjectId(key.getKeyID()).name();
     assertEquals("ed0625dc46328a8c000000000000000000000000", objId);
-    assertEquals(keyIdToString(key.getKeyID()).toLowerCase(),
-        objId.substring(8, 16));
+    assertEquals(keyIdToString(key.getKeyID()).toLowerCase(), objId.substring(8, 16));
   }
 
   @Test
-  public void testGet() throws Exception {
+  public void get() throws Exception {
     TestKey key1 = validKeyWithoutExpiration();
     tr.branch(REFS_GPG_KEYS)
         .commit()
-        .add(keyObjectId(key1.getKeyId()).name(),
-          key1.getPublicKeyArmored())
+        .add(keyObjectId(key1.getKeyId()).name(), key1.getPublicKeyArmored())
         .create();
     TestKey key2 = validKeyWithExpiration();
     tr.branch(REFS_GPG_KEYS)
         .commit()
-        .add(keyObjectId(key2.getKeyId()).name(),
-          key2.getPublicKeyArmored())
+        .add(keyObjectId(key2.getKeyId()).name(), key2.getPublicKeyArmored())
         .create();
 
     assertKeys(key1.getKeyId(), key1);
@@ -104,15 +100,16 @@
   }
 
   @Test
-  public void testGetMultiple() throws Exception {
+  public void getMultiple() throws Exception {
     TestKey key1 = validKeyWithoutExpiration();
     TestKey key2 = validKeyWithExpiration();
     tr.branch(REFS_GPG_KEYS)
         .commit()
-        .add(keyObjectId(key1.getKeyId()).name(),
+        .add(
+            keyObjectId(key1.getKeyId()).name(),
             key1.getPublicKeyArmored()
-              // Mismatched for this key ID, but we can still read it out.
-              + key2.getPublicKeyArmored())
+                // Mismatched for this key ID, but we can still read it out.
+                + key2.getPublicKeyArmored())
         .create();
     assertKeys(key1.getKeyId(), key1, key2);
   }
@@ -147,12 +144,13 @@
 
     try (ObjectReader reader = tr.getRepository().newObjectReader();
         RevWalk rw = new RevWalk(reader)) {
-      NoteMap notes = NoteMap.read(
-          reader, tr.getRevWalk().parseCommit(
-            tr.getRepository().exactRef(REFS_GPG_KEYS).getObjectId()));
-      String contents = new String(
-          reader.open(notes.get(keyObjectId(key1.getKeyId()))).getBytes(),
-          UTF_8);
+      NoteMap notes =
+          NoteMap.read(
+              reader,
+              tr.getRevWalk()
+                  .parseCommit(tr.getRepository().exactRef(REFS_GPG_KEYS).getObjectId()));
+      String contents =
+          new String(reader.open(notes.get(keyObjectId(key1.getKeyId()))).getBytes(), UTF_8);
       String header = "-----BEGIN PGP PUBLIC KEY BLOCK-----";
       int i1 = contents.indexOf(header);
       assertTrue(i1 >= 0);
@@ -166,16 +164,21 @@
     TestKey key5 = validKeyWithSecondUserId();
     PGPPublicKeyRing keyRing = key5.getPublicKeyRing();
     PGPPublicKey key = keyRing.getPublicKey();
+    PGPPublicKey subKey =
+        keyRing.getPublicKey(Iterators.get(keyRing.getPublicKeys(), 1).getKeyID());
     store.add(keyRing);
     assertEquals(RefUpdate.Result.NEW, store.save(newCommitBuilder()));
 
-    assertUserIds(store.get(key5.getKeyId()).iterator().next(),
+    assertUserIds(
+        store.get(key5.getKeyId()).iterator().next(),
         "Testuser Five <test5@example.com>",
         "foo:myId");
 
+    keyRing = PGPPublicKeyRing.removePublicKey(keyRing, subKey);
     keyRing = PGPPublicKeyRing.removePublicKey(keyRing, key);
     key = PGPPublicKey.removeCertification(key, "foo:myId");
     keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, key);
+    keyRing = PGPPublicKeyRing.insertPublicKey(keyRing, subKey);
     store.add(keyRing);
     assertEquals(RefUpdate.Result.FAST_FORWARD, store.save(newCommitBuilder()));
 
@@ -218,8 +221,7 @@
     assertKeys(key1.getKeyId());
   }
 
-  private void assertKeys(long keyId, TestKey... expected)
-      throws Exception {
+  private void assertKeys(long keyId, TestKey... expected) throws Exception {
     Set<String> expectedStrings = new TreeSet<>();
     for (TestKey k : expected) {
       expectedStrings.add(keyToString(k.getPublicKey()));
@@ -232,12 +234,10 @@
     assertEquals(expectedStrings, actualStrings);
   }
 
-  private void assertUserIds(PGPPublicKeyRing keyRing, String... expected)
-      throws Exception {
+  private void assertUserIds(PGPPublicKeyRing keyRing, String... expected) throws Exception {
     List<String> actual = new ArrayList<>();
-    @SuppressWarnings("unchecked")
-    Iterator<String> userIds = store.get(keyRing.getPublicKey().getKeyID())
-        .iterator().next().getPublicKey().getUserIDs();
+    Iterator<String> userIds =
+        store.get(keyRing.getPublicKey().getKeyID()).iterator().next().getPublicKey().getUserIDs();
     while (userIds.hasNext()) {
       actual.add(userIds.next());
     }
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PushCertificateCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PushCertificateCheckerTest.java
index 12a911e..3f5bc3c 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PushCertificateCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/PushCertificateCheckerTest.java
@@ -23,7 +23,16 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testutil.TestKey;
-
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.bcpg.BCPGOutputStream;
 import org.bouncycastle.openpgp.PGPSignature;
@@ -44,17 +53,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-
 public class PushCertificateCheckerTest {
   private InMemoryRepository repo;
   private PublicKeyStore store;
@@ -99,23 +97,20 @@
 
   @Test
   public void validCert() throws Exception {
-    PushCertificate cert =
-        newSignedCert(validNonce(), validKeyWithoutExpiration());
+    PushCertificate cert = newSignedCert(validNonce(), validKeyWithoutExpiration());
     assertNoProblems(cert);
   }
 
   @Test
   public void invalidNonce() throws Exception {
-    PushCertificate cert =
-        newSignedCert("invalid-nonce", validKeyWithoutExpiration());
+    PushCertificate cert = newSignedCert("invalid-nonce", validKeyWithoutExpiration());
     assertProblems(cert, "Invalid nonce");
   }
 
   @Test
   public void invalidNonceNotChecked() throws Exception {
     checker = newChecker(false);
-    PushCertificate cert =
-        newSignedCert("invalid-nonce", validKeyWithoutExpiration());
+    PushCertificate cert = newSignedCert("invalid-nonce", validKeyWithoutExpiration());
     assertNoProblems(cert);
   }
 
@@ -123,57 +118,58 @@
   public void missingKey() throws Exception {
     TestKey key2 = validKeyWithExpiration();
     PushCertificate cert = newSignedCert(validNonce(), key2);
-    assertProblems(cert,
-        "No public keys found for key ID " + keyIdToString(key2.getKeyId()));
+    assertProblems(cert, "No public keys found for key ID " + keyIdToString(key2.getKeyId()));
   }
 
   @Test
   public void invalidKey() throws Exception {
     TestKey key3 = expiredKey();
     PushCertificate cert = newSignedCert(validNonce(), key3);
-    assertProblems(cert,
-        "Invalid public key " + keyToString(key3.getPublicKey())
-          + ":\n  Key is expired");
+    assertProblems(
+        cert, "Invalid public key " + keyToString(key3.getPublicKey()) + ":\n  Key is expired");
   }
 
   @Test
   public void signatureByExpiredKeyBeforeExpiration() throws Exception {
     TestKey key3 = expiredKey();
-    Date now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z")
-        .parse("2005-07-10 12:00:00 -0400");
+    Date now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse("2005-07-10 12:00:00 -0400");
     PushCertificate cert = newSignedCert(validNonce(), key3, now);
     assertNoProblems(cert);
   }
 
   private String validNonce() {
-    return signedPushConfig.getNonceGenerator()
+    return signedPushConfig
+        .getNonceGenerator()
         .createNonce(repo, System.currentTimeMillis() / 1000);
   }
 
-  private PushCertificate newSignedCert(String nonce, TestKey signingKey)
-      throws Exception {
+  private PushCertificate newSignedCert(String nonce, TestKey signingKey) throws Exception {
     return newSignedCert(nonce, signingKey, null);
   }
 
-  private PushCertificate newSignedCert(String nonce, TestKey signingKey,
-      Date now) throws Exception {
-    PushCertificateIdent ident = new PushCertificateIdent(
-        signingKey.getFirstUserId(), System.currentTimeMillis(), -7 * 60);
-    String payload = "certificate version 0.1\n"
-      + "pusher " + ident.getRaw() + "\n"
-      + "pushee test://localhost/repo.git\n"
-      + "nonce " + nonce + "\n"
-      + "\n"
-      + "0000000000000000000000000000000000000000"
-      + " deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
-      + " refs/heads/master\n";
-    PGPSignatureGenerator gen = new PGPSignatureGenerator(
-        new BcPGPContentSignerBuilder(
-          signingKey.getPublicKey().getAlgorithm(), PGPUtil.SHA1));
+  private PushCertificate newSignedCert(String nonce, TestKey signingKey, Date now)
+      throws Exception {
+    PushCertificateIdent ident =
+        new PushCertificateIdent(signingKey.getFirstUserId(), System.currentTimeMillis(), -7 * 60);
+    String payload =
+        "certificate version 0.1\n"
+            + "pusher "
+            + ident.getRaw()
+            + "\n"
+            + "pushee test://localhost/repo.git\n"
+            + "nonce "
+            + nonce
+            + "\n"
+            + "\n"
+            + "0000000000000000000000000000000000000000"
+            + " deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
+            + " refs/heads/master\n";
+    PGPSignatureGenerator gen =
+        new PGPSignatureGenerator(
+            new BcPGPContentSignerBuilder(signingKey.getPublicKey().getAlgorithm(), PGPUtil.SHA1));
 
     if (now != null) {
-      PGPSignatureSubpacketGenerator subGen =
-          new PGPSignatureSubpacketGenerator();
+      PGPSignatureSubpacketGenerator subGen = new PGPSignatureSubpacketGenerator();
       subGen.setSignatureCreationTime(false, now);
       gen.setHashedSubpackets(subGen.generate());
     }
@@ -183,21 +179,17 @@
     PGPSignature sig = gen.generate();
 
     ByteArrayOutputStream bout = new ByteArrayOutputStream();
-    try (BCPGOutputStream out = new BCPGOutputStream(
-        new ArmoredOutputStream(bout))) {
+    try (BCPGOutputStream out = new BCPGOutputStream(new ArmoredOutputStream(bout))) {
       sig.encode(out);
     }
 
     String cert = payload + new String(bout.toByteArray(), UTF_8);
-    Reader reader =
-        new InputStreamReader(new ByteArrayInputStream(cert.getBytes(UTF_8)));
-    PushCertificateParser parser =
-        new PushCertificateParser(repo, signedPushConfig);
+    Reader reader = new InputStreamReader(new ByteArrayInputStream(cert.getBytes(UTF_8)), UTF_8);
+    PushCertificateParser parser = new PushCertificateParser(repo, signedPushConfig);
     return parser.parse(reader);
   }
 
-  private void assertProblems(PushCertificate cert, String first,
-      String... rest) throws Exception {
+  private void assertProblems(PushCertificate cert, String first, String... rest) throws Exception {
     List<String> expected = new ArrayList<>();
     expected.add(first);
     expected.addAll(Arrays.asList(rest));
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKey.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKey.java
index 494cb2d..b2ef65d 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKey.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKey.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import org.bouncycastle.bcpg.ArmoredInputStream;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPrivateKey;
@@ -28,9 +30,6 @@
 import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider;
 import org.eclipse.jgit.lib.Constants;
 
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-
 public class TestKey {
   private final String pubArmored;
   private final String secArmored;
@@ -78,19 +77,18 @@
   }
 
   public String getFirstUserId() {
-    return (String) getPublicKey().getUserIDs().next();
+    return getPublicKey().getUserIDs().next();
   }
 
   public PGPPrivateKey getPrivateKey() throws PGPException {
-    return getSecretKey().extractPrivateKey(
-        new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider())
-          // All test keys have no passphrase.
-          .build(new char[0]));
+    return getSecretKey()
+        .extractPrivateKey(
+            new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider())
+                // All test keys have no passphrase.
+                .build(new char[0]));
   }
 
-  private static ArmoredInputStream newStream(String armored)
-      throws IOException {
-    return new ArmoredInputStream(
-        new ByteArrayInputStream(Constants.encode(armored)));
+  private static ArmoredInputStream newStream(String armored) throws IOException {
+    return new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(armored)));
   }
 }
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKeys.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKeys.java
index ad944c5..82d7ada 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKeys.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestKeys.java
@@ -20,9 +20,7 @@
 public class TestKeys {
   public static ImmutableList<TestKey> allValidKeys() {
     return ImmutableList.of(
-        validKeyWithoutExpiration(),
-        validKeyWithExpiration(),
-        validKeyWithSecondUserId());
+        validKeyWithoutExpiration(), validKeyWithExpiration(), validKeyWithSecondUserId());
   }
 
   /**
@@ -36,93 +34,94 @@
    * </pre>
    */
   public static TestKey validKeyWithoutExpiration() {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBFWdTIkBCADOaygDKjLuRX6LXAvBAYB91cmTf1MSlmEy+qsG3c9ijjQixPkr\n"
-        + "atdYkocrrT2S0R9UGjksTOI2WN5S0lQfLA1RSk63KURQE+OF+IfNqdD6nQdLBs1w\n"
-        + "va+GDj/uvuI05I0oXf/M7POdFphutrS4EUDBnFPj6ns/0C2sTRTxliD+Y9Y9a84V\n"
-        + "DfVVUbJB6wc3LP3L6ImT+cSM7dLq3hZHya+9FNeYPmPYnBrkJyqf2NDd38Sddsro\n"
-        + "7smw/GgCZHnnuVNS4C7NsHr6900VKC+JDtdx+fqptixcAEJWiGoQfWqU+hYmia3p\n"
-        + "9+Xw02+3FcjOT6ONUCmHX+xlz0pXW4iIYlPpABEBAAG0IFRlc3R1c2VyIE9uZSA8\n"
-        + "dGVzdDFAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJVnUyJAhsDBgsJCAcDAgYVCAIJ\n"
-        + "CgsEFgIDAQIeAQIXgAAKCRDtBiXcRjKKjHblB/9RaFO5+GTDIphAL/aVj2u+d8Lq\n"
-        + "yUpBrDp3P06QDGpKGFMAovBuh+NLH76VKNIzQLQC8rdTj651fLcLMuJ1enQ3Rblg\n"
-        + "RKr1oc+wqqtFHr4QyOQjE/N3C9GQjEzfqn4qnp5KtZxYFnlvU5NGehid7M1HTZMx\n"
-        + "jRcHbM9KQnsE5Z4fh4wmN5ynG+5nbaF4O9otPOpFzYRvIhxFmHscWyOgRaMZiYEX\n"
-        + "7Qkzze+scAlc9E/EWRJQIFcxnxV/SYIT4qCTT1g2aKA8OCBO/ZTOleH8SzvTODjy\n"
-        + "W0lGHnh/ZqH6XGVcGUaJZZ2uHTck1+czuVVShNcXPW1W20T6E9UqzHbJHN0guQEN\n"
-        + "BFWdTIkBCACoLVdPr3gpQwzI+2NGXjdtoyqYoPlgfeyI2M1XQD/7+rLZTbi14ZjN\n"
-        + "vYkS/+/oGtVEmiYOiAVTwmkjCYkKGDgNcCiJVekiPAN6JryVv488wRc999b5LpFE\n"
-        + "fhLGwI0YxjcS4KFFnpMC3wSb6tJUnHRLVoE5d8icdiaOpgYdp7uqWkSx2oxqHgIb\n"
-        + "nuyrk3ydEcS4ZeGD+w+taIxMc9F1DS9kiXALD7xWgUkmqZLEQoNgF6KlwCHXRd3m\n"
-        + "rBCo97sE95yKcq98ZMIWuQtTcEccZsN/6jlsei+9RI0tqs+FbZnIFm/go9zk11Vl\n"
-        + "IQ9QFSj6ruqoKrYvNZuDDLD1lHvZPD4/ABEBAAGJAR8EGAECAAkFAlWdTIkCGwwA\n"
-        + "CgkQ7QYl3EYyiox+HAf/Z/OCQO3jxALAcn3oUb1g/IlHm6qZv7RJOFUsj/16fGiF\n"
-        + "rRTP15zMXzyqV+L/LGV/owvOsdD/o7boZz4C/U98COx0Nl1jOrmPATOl+xqsgpEj\n"
-        + "Fhk+eAR7exO2XxW+u2g4cYoSMosIOX5w1GrdsxQeaZDwiSJMEOR2cVLs3YI19Ci/\n"
-        + "FuzActZ0wJNk0nlNF6l8CAbzwN6pM9OIc/iBIwDjz92KUco0NF8XKZnxqhH4wfHB\n"
-        + "PGkTx8RwOvELUTDMtvYnG5R0QtND0RbOnmp4ZVZmeOjKSLo1mZliUZB1H2PPSxrA\n"
-        + "0oLr8+wLntz1SU7uS4ddvhSQW+j2M/0pa352KUwmrw==\n"
-        + "=o/aU\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFWdTIkBCADOaygDKjLuRX6LXAvBAYB91cmTf1MSlmEy+qsG3c9ijjQixPkr\n"
+            + "atdYkocrrT2S0R9UGjksTOI2WN5S0lQfLA1RSk63KURQE+OF+IfNqdD6nQdLBs1w\n"
+            + "va+GDj/uvuI05I0oXf/M7POdFphutrS4EUDBnFPj6ns/0C2sTRTxliD+Y9Y9a84V\n"
+            + "DfVVUbJB6wc3LP3L6ImT+cSM7dLq3hZHya+9FNeYPmPYnBrkJyqf2NDd38Sddsro\n"
+            + "7smw/GgCZHnnuVNS4C7NsHr6900VKC+JDtdx+fqptixcAEJWiGoQfWqU+hYmia3p\n"
+            + "9+Xw02+3FcjOT6ONUCmHX+xlz0pXW4iIYlPpABEBAAG0IFRlc3R1c2VyIE9uZSA8\n"
+            + "dGVzdDFAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJVnUyJAhsDBgsJCAcDAgYVCAIJ\n"
+            + "CgsEFgIDAQIeAQIXgAAKCRDtBiXcRjKKjHblB/9RaFO5+GTDIphAL/aVj2u+d8Lq\n"
+            + "yUpBrDp3P06QDGpKGFMAovBuh+NLH76VKNIzQLQC8rdTj651fLcLMuJ1enQ3Rblg\n"
+            + "RKr1oc+wqqtFHr4QyOQjE/N3C9GQjEzfqn4qnp5KtZxYFnlvU5NGehid7M1HTZMx\n"
+            + "jRcHbM9KQnsE5Z4fh4wmN5ynG+5nbaF4O9otPOpFzYRvIhxFmHscWyOgRaMZiYEX\n"
+            + "7Qkzze+scAlc9E/EWRJQIFcxnxV/SYIT4qCTT1g2aKA8OCBO/ZTOleH8SzvTODjy\n"
+            + "W0lGHnh/ZqH6XGVcGUaJZZ2uHTck1+czuVVShNcXPW1W20T6E9UqzHbJHN0guQEN\n"
+            + "BFWdTIkBCACoLVdPr3gpQwzI+2NGXjdtoyqYoPlgfeyI2M1XQD/7+rLZTbi14ZjN\n"
+            + "vYkS/+/oGtVEmiYOiAVTwmkjCYkKGDgNcCiJVekiPAN6JryVv488wRc999b5LpFE\n"
+            + "fhLGwI0YxjcS4KFFnpMC3wSb6tJUnHRLVoE5d8icdiaOpgYdp7uqWkSx2oxqHgIb\n"
+            + "nuyrk3ydEcS4ZeGD+w+taIxMc9F1DS9kiXALD7xWgUkmqZLEQoNgF6KlwCHXRd3m\n"
+            + "rBCo97sE95yKcq98ZMIWuQtTcEccZsN/6jlsei+9RI0tqs+FbZnIFm/go9zk11Vl\n"
+            + "IQ9QFSj6ruqoKrYvNZuDDLD1lHvZPD4/ABEBAAGJAR8EGAECAAkFAlWdTIkCGwwA\n"
+            + "CgkQ7QYl3EYyiox+HAf/Z/OCQO3jxALAcn3oUb1g/IlHm6qZv7RJOFUsj/16fGiF\n"
+            + "rRTP15zMXzyqV+L/LGV/owvOsdD/o7boZz4C/U98COx0Nl1jOrmPATOl+xqsgpEj\n"
+            + "Fhk+eAR7exO2XxW+u2g4cYoSMosIOX5w1GrdsxQeaZDwiSJMEOR2cVLs3YI19Ci/\n"
+            + "FuzActZ0wJNk0nlNF6l8CAbzwN6pM9OIc/iBIwDjz92KUco0NF8XKZnxqhH4wfHB\n"
+            + "PGkTx8RwOvELUTDMtvYnG5R0QtND0RbOnmp4ZVZmeOjKSLo1mZliUZB1H2PPSxrA\n"
+            + "0oLr8+wLntz1SU7uS4ddvhSQW+j2M/0pa352KUwmrw==\n"
+            + "=o/aU\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBFWdTIkBCADOaygDKjLuRX6LXAvBAYB91cmTf1MSlmEy+qsG3c9ijjQixPkr\n"
-        + "atdYkocrrT2S0R9UGjksTOI2WN5S0lQfLA1RSk63KURQE+OF+IfNqdD6nQdLBs1w\n"
-        + "va+GDj/uvuI05I0oXf/M7POdFphutrS4EUDBnFPj6ns/0C2sTRTxliD+Y9Y9a84V\n"
-        + "DfVVUbJB6wc3LP3L6ImT+cSM7dLq3hZHya+9FNeYPmPYnBrkJyqf2NDd38Sddsro\n"
-        + "7smw/GgCZHnnuVNS4C7NsHr6900VKC+JDtdx+fqptixcAEJWiGoQfWqU+hYmia3p\n"
-        + "9+Xw02+3FcjOT6ONUCmHX+xlz0pXW4iIYlPpABEBAAEAB/wLoOXEJ+Buo+OZHjpb\n"
-        + "SSZf8GdGs+mOJoKbSJvR6zT/rFsrikUvOPmgt8B9qWjKmJVXO5L09+/Wd/MuX0L1\n"
-        + "7plhdvowP1bl2/j5VyLvZx2qwKXkiCGStFzrBGp9nKtJp4Z8O69pb//ZXaiAtDJC\n"
-        + "HFa1kYT4VgFTevrXtg/z/C0np4Yjx0mZpw4nfISEeHCiYCyRa/B8R1+Pc4uIcoSo\n"
-        + "G3aq6Ow9m/LGvw0MRO5qHvqoF41TLPQpGKjKEsCBKHF1qh0tOOUHnLGrvbmdFnGr\n"
-        + "UXJpRkLdRTnj8ufvA4XVZhImzL+lD+ALtjlV14xh8nsNKYL42880GFl5Cl0OtBcE\n"
-        + "lgQBBADPJ6kHdvUYOe0zugRdukBSYLkZcYwRiphom7dZuavYICIu6B14ljEONzVD\n"
-        + "mPhi2lDOawZOURKwYd9S4K11XWLsTYe7XEwkc+1Fpvu4L/JqnJTTnnvbx05ZsqD5\n"
-        + "j9tybPlrTuLrf2ctfcC03Z55wfo6azsbf89yrr6QX0+l9dlkYQQA/xcMdQJ0Z5vm\n"
-        + "kvyaCPsQzJc/8noVO9PMv7xJm14gJWK7Px3y2eBidzpCbVVFnGWW6CPb3qKerB5U\n"
-        + "pwcF4gCFWyP9C2YtnB0hgqixIPfR+UO8gpqdY6MP8NPspoXouffRn+Zic/P6Cxje\n"
-        + "/MGxNQBeRtqb2IGh1xZ8v/8tmmmxHIkEAP74HkGETcXmlj3/6RlwTBUAovPARSn7\n"
-        + "LDtOCPezg6mQmble1BvnTnAwOHKJVqjx+3qsGqMe8OGGXAxZPSU1xSmOShBFrpDp\n"
-        + "xArE67arE17pT1lyD/gmHRuqnNMvgRrwz1mDm3G2ohWkCVixEiB+8vPQfbZrJBgQ\n"
-        + "WxOF4RCo2WWyRKa0IFRlc3R1c2VyIE9uZSA8dGVzdDFAZXhhbXBsZS5jb20+iQE4\n"
-        + "BBMBAgAiBQJVnUyJAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRDtBiXc\n"
-        + "RjKKjHblB/9RaFO5+GTDIphAL/aVj2u+d8LqyUpBrDp3P06QDGpKGFMAovBuh+NL\n"
-        + "H76VKNIzQLQC8rdTj651fLcLMuJ1enQ3RblgRKr1oc+wqqtFHr4QyOQjE/N3C9GQ\n"
-        + "jEzfqn4qnp5KtZxYFnlvU5NGehid7M1HTZMxjRcHbM9KQnsE5Z4fh4wmN5ynG+5n\n"
-        + "baF4O9otPOpFzYRvIhxFmHscWyOgRaMZiYEX7Qkzze+scAlc9E/EWRJQIFcxnxV/\n"
-        + "SYIT4qCTT1g2aKA8OCBO/ZTOleH8SzvTODjyW0lGHnh/ZqH6XGVcGUaJZZ2uHTck\n"
-        + "1+czuVVShNcXPW1W20T6E9UqzHbJHN0gnQOYBFWdTIkBCACoLVdPr3gpQwzI+2NG\n"
-        + "XjdtoyqYoPlgfeyI2M1XQD/7+rLZTbi14ZjNvYkS/+/oGtVEmiYOiAVTwmkjCYkK\n"
-        + "GDgNcCiJVekiPAN6JryVv488wRc999b5LpFEfhLGwI0YxjcS4KFFnpMC3wSb6tJU\n"
-        + "nHRLVoE5d8icdiaOpgYdp7uqWkSx2oxqHgIbnuyrk3ydEcS4ZeGD+w+taIxMc9F1\n"
-        + "DS9kiXALD7xWgUkmqZLEQoNgF6KlwCHXRd3mrBCo97sE95yKcq98ZMIWuQtTcEcc\n"
-        + "ZsN/6jlsei+9RI0tqs+FbZnIFm/go9zk11VlIQ9QFSj6ruqoKrYvNZuDDLD1lHvZ\n"
-        + "PD4/ABEBAAEAB/4kQnJauehcbRpqktjaqSGmP9HFSp+50CyZbLUJJM8m0uyQsZMr\n"
-        + "k9JQOZc+Q3RERNTKj7m41Fbhsj7c0Qd856/eJdp3kdBME0hko8lxN/X4EWGjeLYe\n"
-        + "z41+iPgfZhCF0Oa66TecPQ5RRihGPaDPoVPpkmMWMt9L7KVviBg1eJ6bobVIY5hu\n"
-        + "a7KFJHZQcCI1OvdJ0cx89KDSbnH8iMM6Kmw1bE3D2FEaWctuKLBo5PNRgyTJvdBd\n"
-        + "PSf56/Rc6csPqmOntQi2Yn8n47eCOTclHNuygSTJeHPpymVuWbhMq6fhJat/xA+V\n"
-        + "kyT8I2c45RQb0dKId+wEytjbKw8AI6Q3GXqhBADOhsr9M+JWc4MpD43mCDZACN4v\n"
-        + "RBRxSrJvO/V6HqQPmKYRmr9Gk3vxgF0zCf5zB1QeBiXpTpShxV87RIbUYReOyavp\n"
-        + "87zH6/SkRxQJiBEpQh5Fu5CoAaxGOivxbPqdWHrBY6jvqkrRoMPNiFJ6/ty5w9jx\n"
-        + "i9kGm9PelQGu2SdLNwQA0HbGo8sC8h5TSTEDCkFHRYzVYONx+32AlkCsJX9mEt0E\n"
-        + "nG8d97Ay24JsbnuXSq04FJrqzjOVyHLUffpXnAGELJZVNCIparSyqIaj43UG/oPc\n"
-        + "ICPmR7zI9G49ICUPSzI7+S2+BwjbiHRQcP0zmxbH92G4abYwKfk7dsDpGyVM+TkD\n"
-        + "/2nUiV0CRqnGipeiLWNjW/Md0ufkwqBvCWxrtxj0rQCyvBOVg3B6DocVNzgOOYa1\n"
-        + "ji3We5A9mSP40JBmMfk2veFrDdsGn4G+OpzMxKQtNfYemqjALfZ2zTdax0mXPXy6\n"
-        + "Gl0jUgSGrxGm8QnRLsrRx7G7ZKnvkcS+YsdQ8dbtzvJtQfiJAR8EGAECAAkFAlWd\n"
-        + "TIkCGwwACgkQ7QYl3EYyiox+HAf/Z/OCQO3jxALAcn3oUb1g/IlHm6qZv7RJOFUs\n"
-        + "j/16fGiFrRTP15zMXzyqV+L/LGV/owvOsdD/o7boZz4C/U98COx0Nl1jOrmPATOl\n"
-        + "+xqsgpEjFhk+eAR7exO2XxW+u2g4cYoSMosIOX5w1GrdsxQeaZDwiSJMEOR2cVLs\n"
-        + "3YI19Ci/FuzActZ0wJNk0nlNF6l8CAbzwN6pM9OIc/iBIwDjz92KUco0NF8XKZnx\n"
-        + "qhH4wfHBPGkTx8RwOvELUTDMtvYnG5R0QtND0RbOnmp4ZVZmeOjKSLo1mZliUZB1\n"
-        + "H2PPSxrA0oLr8+wLntz1SU7uS4ddvhSQW+j2M/0pa352KUwmrw==\n"
-        + "=MuAn\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFWdTIkBCADOaygDKjLuRX6LXAvBAYB91cmTf1MSlmEy+qsG3c9ijjQixPkr\n"
+            + "atdYkocrrT2S0R9UGjksTOI2WN5S0lQfLA1RSk63KURQE+OF+IfNqdD6nQdLBs1w\n"
+            + "va+GDj/uvuI05I0oXf/M7POdFphutrS4EUDBnFPj6ns/0C2sTRTxliD+Y9Y9a84V\n"
+            + "DfVVUbJB6wc3LP3L6ImT+cSM7dLq3hZHya+9FNeYPmPYnBrkJyqf2NDd38Sddsro\n"
+            + "7smw/GgCZHnnuVNS4C7NsHr6900VKC+JDtdx+fqptixcAEJWiGoQfWqU+hYmia3p\n"
+            + "9+Xw02+3FcjOT6ONUCmHX+xlz0pXW4iIYlPpABEBAAEAB/wLoOXEJ+Buo+OZHjpb\n"
+            + "SSZf8GdGs+mOJoKbSJvR6zT/rFsrikUvOPmgt8B9qWjKmJVXO5L09+/Wd/MuX0L1\n"
+            + "7plhdvowP1bl2/j5VyLvZx2qwKXkiCGStFzrBGp9nKtJp4Z8O69pb//ZXaiAtDJC\n"
+            + "HFa1kYT4VgFTevrXtg/z/C0np4Yjx0mZpw4nfISEeHCiYCyRa/B8R1+Pc4uIcoSo\n"
+            + "G3aq6Ow9m/LGvw0MRO5qHvqoF41TLPQpGKjKEsCBKHF1qh0tOOUHnLGrvbmdFnGr\n"
+            + "UXJpRkLdRTnj8ufvA4XVZhImzL+lD+ALtjlV14xh8nsNKYL42880GFl5Cl0OtBcE\n"
+            + "lgQBBADPJ6kHdvUYOe0zugRdukBSYLkZcYwRiphom7dZuavYICIu6B14ljEONzVD\n"
+            + "mPhi2lDOawZOURKwYd9S4K11XWLsTYe7XEwkc+1Fpvu4L/JqnJTTnnvbx05ZsqD5\n"
+            + "j9tybPlrTuLrf2ctfcC03Z55wfo6azsbf89yrr6QX0+l9dlkYQQA/xcMdQJ0Z5vm\n"
+            + "kvyaCPsQzJc/8noVO9PMv7xJm14gJWK7Px3y2eBidzpCbVVFnGWW6CPb3qKerB5U\n"
+            + "pwcF4gCFWyP9C2YtnB0hgqixIPfR+UO8gpqdY6MP8NPspoXouffRn+Zic/P6Cxje\n"
+            + "/MGxNQBeRtqb2IGh1xZ8v/8tmmmxHIkEAP74HkGETcXmlj3/6RlwTBUAovPARSn7\n"
+            + "LDtOCPezg6mQmble1BvnTnAwOHKJVqjx+3qsGqMe8OGGXAxZPSU1xSmOShBFrpDp\n"
+            + "xArE67arE17pT1lyD/gmHRuqnNMvgRrwz1mDm3G2ohWkCVixEiB+8vPQfbZrJBgQ\n"
+            + "WxOF4RCo2WWyRKa0IFRlc3R1c2VyIE9uZSA8dGVzdDFAZXhhbXBsZS5jb20+iQE4\n"
+            + "BBMBAgAiBQJVnUyJAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRDtBiXc\n"
+            + "RjKKjHblB/9RaFO5+GTDIphAL/aVj2u+d8LqyUpBrDp3P06QDGpKGFMAovBuh+NL\n"
+            + "H76VKNIzQLQC8rdTj651fLcLMuJ1enQ3RblgRKr1oc+wqqtFHr4QyOQjE/N3C9GQ\n"
+            + "jEzfqn4qnp5KtZxYFnlvU5NGehid7M1HTZMxjRcHbM9KQnsE5Z4fh4wmN5ynG+5n\n"
+            + "baF4O9otPOpFzYRvIhxFmHscWyOgRaMZiYEX7Qkzze+scAlc9E/EWRJQIFcxnxV/\n"
+            + "SYIT4qCTT1g2aKA8OCBO/ZTOleH8SzvTODjyW0lGHnh/ZqH6XGVcGUaJZZ2uHTck\n"
+            + "1+czuVVShNcXPW1W20T6E9UqzHbJHN0gnQOYBFWdTIkBCACoLVdPr3gpQwzI+2NG\n"
+            + "XjdtoyqYoPlgfeyI2M1XQD/7+rLZTbi14ZjNvYkS/+/oGtVEmiYOiAVTwmkjCYkK\n"
+            + "GDgNcCiJVekiPAN6JryVv488wRc999b5LpFEfhLGwI0YxjcS4KFFnpMC3wSb6tJU\n"
+            + "nHRLVoE5d8icdiaOpgYdp7uqWkSx2oxqHgIbnuyrk3ydEcS4ZeGD+w+taIxMc9F1\n"
+            + "DS9kiXALD7xWgUkmqZLEQoNgF6KlwCHXRd3mrBCo97sE95yKcq98ZMIWuQtTcEcc\n"
+            + "ZsN/6jlsei+9RI0tqs+FbZnIFm/go9zk11VlIQ9QFSj6ruqoKrYvNZuDDLD1lHvZ\n"
+            + "PD4/ABEBAAEAB/4kQnJauehcbRpqktjaqSGmP9HFSp+50CyZbLUJJM8m0uyQsZMr\n"
+            + "k9JQOZc+Q3RERNTKj7m41Fbhsj7c0Qd856/eJdp3kdBME0hko8lxN/X4EWGjeLYe\n"
+            + "z41+iPgfZhCF0Oa66TecPQ5RRihGPaDPoVPpkmMWMt9L7KVviBg1eJ6bobVIY5hu\n"
+            + "a7KFJHZQcCI1OvdJ0cx89KDSbnH8iMM6Kmw1bE3D2FEaWctuKLBo5PNRgyTJvdBd\n"
+            + "PSf56/Rc6csPqmOntQi2Yn8n47eCOTclHNuygSTJeHPpymVuWbhMq6fhJat/xA+V\n"
+            + "kyT8I2c45RQb0dKId+wEytjbKw8AI6Q3GXqhBADOhsr9M+JWc4MpD43mCDZACN4v\n"
+            + "RBRxSrJvO/V6HqQPmKYRmr9Gk3vxgF0zCf5zB1QeBiXpTpShxV87RIbUYReOyavp\n"
+            + "87zH6/SkRxQJiBEpQh5Fu5CoAaxGOivxbPqdWHrBY6jvqkrRoMPNiFJ6/ty5w9jx\n"
+            + "i9kGm9PelQGu2SdLNwQA0HbGo8sC8h5TSTEDCkFHRYzVYONx+32AlkCsJX9mEt0E\n"
+            + "nG8d97Ay24JsbnuXSq04FJrqzjOVyHLUffpXnAGELJZVNCIparSyqIaj43UG/oPc\n"
+            + "ICPmR7zI9G49ICUPSzI7+S2+BwjbiHRQcP0zmxbH92G4abYwKfk7dsDpGyVM+TkD\n"
+            + "/2nUiV0CRqnGipeiLWNjW/Md0ufkwqBvCWxrtxj0rQCyvBOVg3B6DocVNzgOOYa1\n"
+            + "ji3We5A9mSP40JBmMfk2veFrDdsGn4G+OpzMxKQtNfYemqjALfZ2zTdax0mXPXy6\n"
+            + "Gl0jUgSGrxGm8QnRLsrRx7G7ZKnvkcS+YsdQ8dbtzvJtQfiJAR8EGAECAAkFAlWd\n"
+            + "TIkCGwwACgkQ7QYl3EYyiox+HAf/Z/OCQO3jxALAcn3oUb1g/IlHm6qZv7RJOFUs\n"
+            + "j/16fGiFrRTP15zMXzyqV+L/LGV/owvOsdD/o7boZz4C/U98COx0Nl1jOrmPATOl\n"
+            + "+xqsgpEjFhk+eAR7exO2XxW+u2g4cYoSMosIOX5w1GrdsxQeaZDwiSJMEOR2cVLs\n"
+            + "3YI19Ci/FuzActZ0wJNk0nlNF6l8CAbzwN6pM9OIc/iBIwDjz92KUco0NF8XKZnx\n"
+            + "qhH4wfHBPGkTx8RwOvELUTDMtvYnG5R0QtND0RbOnmp4ZVZmeOjKSLo1mZliUZB1\n"
+            + "H2PPSxrA0oLr8+wLntz1SU7uS4ddvhSQW+j2M/0pa352KUwmrw==\n"
+            + "=MuAn\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
@@ -138,93 +137,93 @@
   public static final TestKey validKeyWithExpiration() {
     return new TestKey(
         "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBFWdTP8BCADRxNpasIv0jtNXTK6VYIS2VJ2Xk0ZD6gtxeoXCpjQ+TsB9fxh3\n"
-        + "vAMPt2Zu5LqoGwygKOJj1zquG8xk7GUCCHJk3+qG8xxB1xGtSz2vLyfRm7fOZmHj\n"
-        + "3W/C/25lynSPDrfvcwvwA4PN8iP5EWbWU10L6WOZGMwwwtVDUSEouSOw2LEepxLV\n"
-        + "rkKuZcyHaivheDbUlZliwe9rGXd4hh1h4qyNQWG3q+ytlL28sVkOzUh6IMBTvqhe\n"
-        + "IRsvxvaVSLV8jRVKfUTqw0g57ft4ZD2/L46yUTXzr9aUCBjTNxvWLlyboqql/D8P\n"
-        + "inp51h3cvAg7NW5RdG1GEYmylH8SygT5utPxABEBAAG0IFRlc3R1c2VyIFR3byA8\n"
-        + "dGVzdDJAZXhhbXBsZS5jb20+iQE+BBMBAgAoBQJVnUz/AhsDBQld/A8ABgsJCAcD\n"
-        + "AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBFMRpvN4oK7UZqCACWwQL/YvBK4b0m+R0d\n"
-        + "UdvAXeBx7DwOAnAodis9ZVqChb7RxcZQxF1Ti9mtCBPPQGuEs5wE2Ocrrq+L13r6\n"
-        + "bgW+1WOB1tZSDVxwL1PnZFw/SyADRIDCZrOHiAkp82UnZwWAkk39GzNJtt1wTYDZ\n"
-        + "FMTFUr2SPscXk1k7muS+ZfEFwNPD4tODo/poJKDYEJ80Z5UXXFQLDtsfdeIXMFIT\n"
-        + "449CYoq8XBMBfvyWl/LLpw0r3JI6pV/YdH3Oeuz8XkkEVzRxaxB6Zmeo5jSwjR/T\n"
-        + "8TKDGwwiuwiiT3SfkFSVdcjKulRuXSRNs1Ouf7/UC3cq4bG2WXWa85X1+HQRm7iu\n"
-        + "RHSOuQENBFWdTP8BCADhhGxAA0pX5yBHwIgM1j0gw2h5nSsopDrO6t/sbRUcNxnR\n"
-        + "tBScgKZnP0sjRTYEUIwmZuseHMBohtVCuMaDt06qyZDvDk/98j3AeE5t2dgFnOIe\n"
-        + "qCrm/6aejbFcQOpxe6U29KJRCAxuwNtB15X1VH1Kj7B0gRSTu13n/5sUsi2lunoZ\n"
-        + "oIvpIe9tZH4aXitCY2MCQH+hTyCyNBzlEa44kWz6LxUsPdo7I6rXkTr6Ot7wQh+9\n"
-        + "7HCe042GIq65h0apgujyjhJidjch5ur1mngaSNSEyvbji2MGC+cd3wAIstG5a7xP\n"
-        + "d9MncY5Q/eH+hn96694k5bckottSyGm/3f2Ihfj1ABEBAAGJASUEGAECAA8FAlWd\n"
-        + "TP8CGwwFCV38DwAACgkQRTEabzeKCu1FNwgAif4eK2v7R3QubL2S6wmb1nsgRMgV\n"
-        + "YoxGBeUk2EK6WZ5IPor93ySd0ixRVNMRmJ8BLH3EMjZQTzkDG+BH6zFyxo6lLHw9\n"
-        + "NxQjI06tqQWgyyK0mEweVwB/zqtxiB4lNUpsNbqOZWnBJ3d6o1SsnD2Q3uwvP5fb\n"
-        + "fSIgdmUk3c0VMdgA+KzWjPD/PJIPujE+ckHhjn5cbDNw35/FuyhkLJfqlOG7SPvM\n"
-        + "NmCdJ1Pcqju9t7sf6b0BGPDOCL4gpuWKK7HJz9WxngNb3FSziLbyPLk13ynADO+v\n"
-        + "EOR44LPyXE9kVxPusazsXlt9ayTOhELhwzw7sGFFu8E17Cpn7GnVj3tN9A==\n"
-        + "=1e/A\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFWdTP8BCADRxNpasIv0jtNXTK6VYIS2VJ2Xk0ZD6gtxeoXCpjQ+TsB9fxh3\n"
+            + "vAMPt2Zu5LqoGwygKOJj1zquG8xk7GUCCHJk3+qG8xxB1xGtSz2vLyfRm7fOZmHj\n"
+            + "3W/C/25lynSPDrfvcwvwA4PN8iP5EWbWU10L6WOZGMwwwtVDUSEouSOw2LEepxLV\n"
+            + "rkKuZcyHaivheDbUlZliwe9rGXd4hh1h4qyNQWG3q+ytlL28sVkOzUh6IMBTvqhe\n"
+            + "IRsvxvaVSLV8jRVKfUTqw0g57ft4ZD2/L46yUTXzr9aUCBjTNxvWLlyboqql/D8P\n"
+            + "inp51h3cvAg7NW5RdG1GEYmylH8SygT5utPxABEBAAG0IFRlc3R1c2VyIFR3byA8\n"
+            + "dGVzdDJAZXhhbXBsZS5jb20+iQE+BBMBAgAoBQJVnUz/AhsDBQld/A8ABgsJCAcD\n"
+            + "AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBFMRpvN4oK7UZqCACWwQL/YvBK4b0m+R0d\n"
+            + "UdvAXeBx7DwOAnAodis9ZVqChb7RxcZQxF1Ti9mtCBPPQGuEs5wE2Ocrrq+L13r6\n"
+            + "bgW+1WOB1tZSDVxwL1PnZFw/SyADRIDCZrOHiAkp82UnZwWAkk39GzNJtt1wTYDZ\n"
+            + "FMTFUr2SPscXk1k7muS+ZfEFwNPD4tODo/poJKDYEJ80Z5UXXFQLDtsfdeIXMFIT\n"
+            + "449CYoq8XBMBfvyWl/LLpw0r3JI6pV/YdH3Oeuz8XkkEVzRxaxB6Zmeo5jSwjR/T\n"
+            + "8TKDGwwiuwiiT3SfkFSVdcjKulRuXSRNs1Ouf7/UC3cq4bG2WXWa85X1+HQRm7iu\n"
+            + "RHSOuQENBFWdTP8BCADhhGxAA0pX5yBHwIgM1j0gw2h5nSsopDrO6t/sbRUcNxnR\n"
+            + "tBScgKZnP0sjRTYEUIwmZuseHMBohtVCuMaDt06qyZDvDk/98j3AeE5t2dgFnOIe\n"
+            + "qCrm/6aejbFcQOpxe6U29KJRCAxuwNtB15X1VH1Kj7B0gRSTu13n/5sUsi2lunoZ\n"
+            + "oIvpIe9tZH4aXitCY2MCQH+hTyCyNBzlEa44kWz6LxUsPdo7I6rXkTr6Ot7wQh+9\n"
+            + "7HCe042GIq65h0apgujyjhJidjch5ur1mngaSNSEyvbji2MGC+cd3wAIstG5a7xP\n"
+            + "d9MncY5Q/eH+hn96694k5bckottSyGm/3f2Ihfj1ABEBAAGJASUEGAECAA8FAlWd\n"
+            + "TP8CGwwFCV38DwAACgkQRTEabzeKCu1FNwgAif4eK2v7R3QubL2S6wmb1nsgRMgV\n"
+            + "YoxGBeUk2EK6WZ5IPor93ySd0ixRVNMRmJ8BLH3EMjZQTzkDG+BH6zFyxo6lLHw9\n"
+            + "NxQjI06tqQWgyyK0mEweVwB/zqtxiB4lNUpsNbqOZWnBJ3d6o1SsnD2Q3uwvP5fb\n"
+            + "fSIgdmUk3c0VMdgA+KzWjPD/PJIPujE+ckHhjn5cbDNw35/FuyhkLJfqlOG7SPvM\n"
+            + "NmCdJ1Pcqju9t7sf6b0BGPDOCL4gpuWKK7HJz9WxngNb3FSziLbyPLk13ynADO+v\n"
+            + "EOR44LPyXE9kVxPusazsXlt9ayTOhELhwzw7sGFFu8E17Cpn7GnVj3tN9A==\n"
+            + "=1e/A\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBFWdTP8BCADRxNpasIv0jtNXTK6VYIS2VJ2Xk0ZD6gtxeoXCpjQ+TsB9fxh3\n"
-        + "vAMPt2Zu5LqoGwygKOJj1zquG8xk7GUCCHJk3+qG8xxB1xGtSz2vLyfRm7fOZmHj\n"
-        + "3W/C/25lynSPDrfvcwvwA4PN8iP5EWbWU10L6WOZGMwwwtVDUSEouSOw2LEepxLV\n"
-        + "rkKuZcyHaivheDbUlZliwe9rGXd4hh1h4qyNQWG3q+ytlL28sVkOzUh6IMBTvqhe\n"
-        + "IRsvxvaVSLV8jRVKfUTqw0g57ft4ZD2/L46yUTXzr9aUCBjTNxvWLlyboqql/D8P\n"
-        + "inp51h3cvAg7NW5RdG1GEYmylH8SygT5utPxABEBAAEAB/0WW33OVqzEBwj9b/3X\n"
-        + "i+75I/Gb+yVtDZ/km2NwSJie33PirE4mTNKitTBkt1oxmphw5Yqji4gEkI/rXcqy\n"
-        + "OcY/fCIZ+gVT+yE2MCPF7Se4Tnl7tSvPxoUn6mOQ09AygyYVjlSCY02EAL/WxwUH\n"
-        + "6OCs6VYlNiBlPg7O2vHGzlzAd1aMmlG3ytlhb0SIbilaJn/wlQ2SEGySjIAP1qRH\n"
-        + "UXsTfW7oAjdqAY1CbCWg/0FnMBF+DnChH634dbLrS2OefcB70l61trEfRcHbMNTv\n"
-        + "9nVxDDCpaIdxsOfgWpe0GMG1qddRAxBIOVjNUFOL22xEFyaXnt/uagUtKQ7yejci\n"
-        + "bgTFBADcuhsfQaBX1G095iG2qr8Rx2T5GqNf9oZA+rbweWegqIH7MUXHI1KKwwJx\n"
-        + "C+rR5AgnxTSP614XI/AWB/txdelm8z0jLobpS6B1vzM2vRQ7hpwjJ3UvUkoQ5uYL\n"
-        + "DjaBqQi0w1cPJA79H0Yujc1zgdhATymz0uDL1BC2bHLIMuhelwQA80p07G1w8HLQ\n"
-        + "bTdgNwtDBMKIw39/ZyQy8ppxmpD4J6zf25r95g3er0r+njrHsa+72LnvexbedpKA\n"
-        + "4eiDJPN+l5jJOEWfL2WtGcqJ01bdFBPcl73tuwDJJtieUlKZH0jRjykuuUX8F+tJ\n"
-        + "yrmVoIGtawoeLKq3hMMOK4xi+sh3OrcD+wXIU24eO3YfUde5bhyaQplNMU5smIU0\n"
-        + "+looOEmFsZcTONgoN+FKrnm2TY9d4FHZ+QgtnksWHmmLxQJPtp9rHJ5BgdxMBPcK\n"
-        + "3w5GXRuWlOmqmnAb6vp0Q0yzVDLKCcwba0S23m3tbjZsLDcI7MG/knsp9gtL676D\n"
-        + "AsrpeF2+Apj0OwG0IFRlc3R1c2VyIFR3byA8dGVzdDJAZXhhbXBsZS5jb20+iQE+\n"
-        + "BBMBAgAoBQJVnUz/AhsDBQld/A8ABgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\n"
-        + "CRBFMRpvN4oK7UZqCACWwQL/YvBK4b0m+R0dUdvAXeBx7DwOAnAodis9ZVqChb7R\n"
-        + "xcZQxF1Ti9mtCBPPQGuEs5wE2Ocrrq+L13r6bgW+1WOB1tZSDVxwL1PnZFw/SyAD\n"
-        + "RIDCZrOHiAkp82UnZwWAkk39GzNJtt1wTYDZFMTFUr2SPscXk1k7muS+ZfEFwNPD\n"
-        + "4tODo/poJKDYEJ80Z5UXXFQLDtsfdeIXMFIT449CYoq8XBMBfvyWl/LLpw0r3JI6\n"
-        + "pV/YdH3Oeuz8XkkEVzRxaxB6Zmeo5jSwjR/T8TKDGwwiuwiiT3SfkFSVdcjKulRu\n"
-        + "XSRNs1Ouf7/UC3cq4bG2WXWa85X1+HQRm7iuRHSOnQOYBFWdTP8BCADhhGxAA0pX\n"
-        + "5yBHwIgM1j0gw2h5nSsopDrO6t/sbRUcNxnRtBScgKZnP0sjRTYEUIwmZuseHMBo\n"
-        + "htVCuMaDt06qyZDvDk/98j3AeE5t2dgFnOIeqCrm/6aejbFcQOpxe6U29KJRCAxu\n"
-        + "wNtB15X1VH1Kj7B0gRSTu13n/5sUsi2lunoZoIvpIe9tZH4aXitCY2MCQH+hTyCy\n"
-        + "NBzlEa44kWz6LxUsPdo7I6rXkTr6Ot7wQh+97HCe042GIq65h0apgujyjhJidjch\n"
-        + "5ur1mngaSNSEyvbji2MGC+cd3wAIstG5a7xPd9MncY5Q/eH+hn96694k5bckottS\n"
-        + "yGm/3f2Ihfj1ABEBAAEAB/wP5H+mcTTrhe+57sEHuo9bQDocG+3fMtesHlRCept6\n"
-        + "vg1VQG4Va2GOtCCs7yMz4aNGz4jxOdB7bUkZJyFiRehG0+ahWi5b9JbSegf46Nm2\n"
-        + "54vt4icH2WtaEB04JaD/91k4yrunnzwVEAVDmhhIzjf4KbEjPLeBA7rF7zb0Gexq\n"
-        + "mdxEGO/6KdeQ6KOxkpWEqIIdl/mAGsYCprHeKL/XL+KXYr92nEbUcltmt59TTnoo\n"
-        + "00BQCPuHCdpcUd5nuaxpCZLM+BEpxtj0sinz0ofuWU9RI4K00R01MKXWMucdOhTZ\n"
-        + "kUy5dMx8wA07xbjkE/nH86N76Mty133OB7G3lBBDfO4PBADulfLzbjXUnS1kTKeP\n"
-        + "j/HF1E9qafzTDS/QD55OVajDq66A6zaOazKbURHNZmIqpLO4715+iNtrZQUEP3e1\n"
-        + "mwngeizvAv9luA9kJ1YDTCfsS5H5cYzavhfwuqBu7fQBm/PQqZplQuPCxgXEIBaY\n"
-        + "M0uvR0I/FSwFrepRN2IA6dAkrwQA8fpJEg8C9OLFzDf0rxV3eWwEelemN4E50Obu\n"
-        + "nxtg9IJWZ+QIWkRVLJ8if5+p85s2ieCw8hzEF0FyNfWUnfW5eoN4/j50loR4EbZS\n"
-        + "qOpUJGwr8ezyQN8PpduDOe9OQnUYAv9FY9Rk46L4937GDF2w5gdxyNdKO8yG+Z3A\n"
-        + "6/0DLZsEAOQsRUXIl1XLjkdugfFQ8V9Fv3AYWJt+8zknwcQ+Z3uOtyY2muCi9hX2\n"
-        + "BtuPojjwmN6x8wntMaUkzYHVSdz/cdx+na7VNS2kZHfnECWZGR6IHyRTJN5612yi\n"
-        + "e4MIdTE+BgL1HPq+VIPlMBehEksC5qM0WSq8baMsacGMYeAL8ntoRuyJASUEGAEC\n"
-        + "AA8FAlWdTP8CGwwFCV38DwAACgkQRTEabzeKCu1FNwgAif4eK2v7R3QubL2S6wmb\n"
-        + "1nsgRMgVYoxGBeUk2EK6WZ5IPor93ySd0ixRVNMRmJ8BLH3EMjZQTzkDG+BH6zFy\n"
-        + "xo6lLHw9NxQjI06tqQWgyyK0mEweVwB/zqtxiB4lNUpsNbqOZWnBJ3d6o1SsnD2Q\n"
-        + "3uwvP5fbfSIgdmUk3c0VMdgA+KzWjPD/PJIPujE+ckHhjn5cbDNw35/FuyhkLJfq\n"
-        + "lOG7SPvMNmCdJ1Pcqju9t7sf6b0BGPDOCL4gpuWKK7HJz9WxngNb3FSziLbyPLk1\n"
-        + "3ynADO+vEOR44LPyXE9kVxPusazsXlt9ayTOhELhwzw7sGFFu8E17Cpn7GnVj3tN\n"
-        + "9A==\n"
-        + "=qbV3\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFWdTP8BCADRxNpasIv0jtNXTK6VYIS2VJ2Xk0ZD6gtxeoXCpjQ+TsB9fxh3\n"
+            + "vAMPt2Zu5LqoGwygKOJj1zquG8xk7GUCCHJk3+qG8xxB1xGtSz2vLyfRm7fOZmHj\n"
+            + "3W/C/25lynSPDrfvcwvwA4PN8iP5EWbWU10L6WOZGMwwwtVDUSEouSOw2LEepxLV\n"
+            + "rkKuZcyHaivheDbUlZliwe9rGXd4hh1h4qyNQWG3q+ytlL28sVkOzUh6IMBTvqhe\n"
+            + "IRsvxvaVSLV8jRVKfUTqw0g57ft4ZD2/L46yUTXzr9aUCBjTNxvWLlyboqql/D8P\n"
+            + "inp51h3cvAg7NW5RdG1GEYmylH8SygT5utPxABEBAAEAB/0WW33OVqzEBwj9b/3X\n"
+            + "i+75I/Gb+yVtDZ/km2NwSJie33PirE4mTNKitTBkt1oxmphw5Yqji4gEkI/rXcqy\n"
+            + "OcY/fCIZ+gVT+yE2MCPF7Se4Tnl7tSvPxoUn6mOQ09AygyYVjlSCY02EAL/WxwUH\n"
+            + "6OCs6VYlNiBlPg7O2vHGzlzAd1aMmlG3ytlhb0SIbilaJn/wlQ2SEGySjIAP1qRH\n"
+            + "UXsTfW7oAjdqAY1CbCWg/0FnMBF+DnChH634dbLrS2OefcB70l61trEfRcHbMNTv\n"
+            + "9nVxDDCpaIdxsOfgWpe0GMG1qddRAxBIOVjNUFOL22xEFyaXnt/uagUtKQ7yejci\n"
+            + "bgTFBADcuhsfQaBX1G095iG2qr8Rx2T5GqNf9oZA+rbweWegqIH7MUXHI1KKwwJx\n"
+            + "C+rR5AgnxTSP614XI/AWB/txdelm8z0jLobpS6B1vzM2vRQ7hpwjJ3UvUkoQ5uYL\n"
+            + "DjaBqQi0w1cPJA79H0Yujc1zgdhATymz0uDL1BC2bHLIMuhelwQA80p07G1w8HLQ\n"
+            + "bTdgNwtDBMKIw39/ZyQy8ppxmpD4J6zf25r95g3er0r+njrHsa+72LnvexbedpKA\n"
+            + "4eiDJPN+l5jJOEWfL2WtGcqJ01bdFBPcl73tuwDJJtieUlKZH0jRjykuuUX8F+tJ\n"
+            + "yrmVoIGtawoeLKq3hMMOK4xi+sh3OrcD+wXIU24eO3YfUde5bhyaQplNMU5smIU0\n"
+            + "+looOEmFsZcTONgoN+FKrnm2TY9d4FHZ+QgtnksWHmmLxQJPtp9rHJ5BgdxMBPcK\n"
+            + "3w5GXRuWlOmqmnAb6vp0Q0yzVDLKCcwba0S23m3tbjZsLDcI7MG/knsp9gtL676D\n"
+            + "AsrpeF2+Apj0OwG0IFRlc3R1c2VyIFR3byA8dGVzdDJAZXhhbXBsZS5jb20+iQE+\n"
+            + "BBMBAgAoBQJVnUz/AhsDBQld/A8ABgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\n"
+            + "CRBFMRpvN4oK7UZqCACWwQL/YvBK4b0m+R0dUdvAXeBx7DwOAnAodis9ZVqChb7R\n"
+            + "xcZQxF1Ti9mtCBPPQGuEs5wE2Ocrrq+L13r6bgW+1WOB1tZSDVxwL1PnZFw/SyAD\n"
+            + "RIDCZrOHiAkp82UnZwWAkk39GzNJtt1wTYDZFMTFUr2SPscXk1k7muS+ZfEFwNPD\n"
+            + "4tODo/poJKDYEJ80Z5UXXFQLDtsfdeIXMFIT449CYoq8XBMBfvyWl/LLpw0r3JI6\n"
+            + "pV/YdH3Oeuz8XkkEVzRxaxB6Zmeo5jSwjR/T8TKDGwwiuwiiT3SfkFSVdcjKulRu\n"
+            + "XSRNs1Ouf7/UC3cq4bG2WXWa85X1+HQRm7iuRHSOnQOYBFWdTP8BCADhhGxAA0pX\n"
+            + "5yBHwIgM1j0gw2h5nSsopDrO6t/sbRUcNxnRtBScgKZnP0sjRTYEUIwmZuseHMBo\n"
+            + "htVCuMaDt06qyZDvDk/98j3AeE5t2dgFnOIeqCrm/6aejbFcQOpxe6U29KJRCAxu\n"
+            + "wNtB15X1VH1Kj7B0gRSTu13n/5sUsi2lunoZoIvpIe9tZH4aXitCY2MCQH+hTyCy\n"
+            + "NBzlEa44kWz6LxUsPdo7I6rXkTr6Ot7wQh+97HCe042GIq65h0apgujyjhJidjch\n"
+            + "5ur1mngaSNSEyvbji2MGC+cd3wAIstG5a7xPd9MncY5Q/eH+hn96694k5bckottS\n"
+            + "yGm/3f2Ihfj1ABEBAAEAB/wP5H+mcTTrhe+57sEHuo9bQDocG+3fMtesHlRCept6\n"
+            + "vg1VQG4Va2GOtCCs7yMz4aNGz4jxOdB7bUkZJyFiRehG0+ahWi5b9JbSegf46Nm2\n"
+            + "54vt4icH2WtaEB04JaD/91k4yrunnzwVEAVDmhhIzjf4KbEjPLeBA7rF7zb0Gexq\n"
+            + "mdxEGO/6KdeQ6KOxkpWEqIIdl/mAGsYCprHeKL/XL+KXYr92nEbUcltmt59TTnoo\n"
+            + "00BQCPuHCdpcUd5nuaxpCZLM+BEpxtj0sinz0ofuWU9RI4K00R01MKXWMucdOhTZ\n"
+            + "kUy5dMx8wA07xbjkE/nH86N76Mty133OB7G3lBBDfO4PBADulfLzbjXUnS1kTKeP\n"
+            + "j/HF1E9qafzTDS/QD55OVajDq66A6zaOazKbURHNZmIqpLO4715+iNtrZQUEP3e1\n"
+            + "mwngeizvAv9luA9kJ1YDTCfsS5H5cYzavhfwuqBu7fQBm/PQqZplQuPCxgXEIBaY\n"
+            + "M0uvR0I/FSwFrepRN2IA6dAkrwQA8fpJEg8C9OLFzDf0rxV3eWwEelemN4E50Obu\n"
+            + "nxtg9IJWZ+QIWkRVLJ8if5+p85s2ieCw8hzEF0FyNfWUnfW5eoN4/j50loR4EbZS\n"
+            + "qOpUJGwr8ezyQN8PpduDOe9OQnUYAv9FY9Rk46L4937GDF2w5gdxyNdKO8yG+Z3A\n"
+            + "6/0DLZsEAOQsRUXIl1XLjkdugfFQ8V9Fv3AYWJt+8zknwcQ+Z3uOtyY2muCi9hX2\n"
+            + "BtuPojjwmN6x8wntMaUkzYHVSdz/cdx+na7VNS2kZHfnECWZGR6IHyRTJN5612yi\n"
+            + "e4MIdTE+BgL1HPq+VIPlMBehEksC5qM0WSq8baMsacGMYeAL8ntoRuyJASUEGAEC\n"
+            + "AA8FAlWdTP8CGwwFCV38DwAACgkQRTEabzeKCu1FNwgAif4eK2v7R3QubL2S6wmb\n"
+            + "1nsgRMgVYoxGBeUk2EK6WZ5IPor93ySd0ixRVNMRmJ8BLH3EMjZQTzkDG+BH6zFy\n"
+            + "xo6lLHw9NxQjI06tqQWgyyK0mEweVwB/zqtxiB4lNUpsNbqOZWnBJ3d6o1SsnD2Q\n"
+            + "3uwvP5fbfSIgdmUk3c0VMdgA+KzWjPD/PJIPujE+ckHhjn5cbDNw35/FuyhkLJfq\n"
+            + "lOG7SPvMNmCdJ1Pcqju9t7sf6b0BGPDOCL4gpuWKK7HJz9WxngNb3FSziLbyPLk1\n"
+            + "3ynADO+vEOR44LPyXE9kVxPusazsXlt9ayTOhELhwzw7sGFFu8E17Cpn7GnVj3tN\n"
+            + "9A==\n"
+            + "=qbV3\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
@@ -239,93 +238,93 @@
   public static final TestKey expiredKey() {
     return new TestKey(
         "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBELOp0UBCACxholOPWuKhK+TYb88nvLUSCMvTLIFEpb5u3Eavr0wiluEzq6H\n"
-        + "55nswAD3dQm8DWxA7yUlEYjPr5btpw7V9441bb1+qtgZMJ10RTdEb/WjyctdGA99\n"
-        + "uOKBEarWbt8W+w6lyJ9NXy5bS/x5EwHHfoTFp4ff6ffHI5hbx1a00K8oxmitgd0X\n"
-        + "Mx86UmauFNJYupZOZG9gEcP4RbRp7e2pm4Jy1WLEOeg9Fdgm5e5Hj2nMkCSZ9BKV\n"
-        + "cxuOllSVzM/Zp0/4+RS9R57jKo3/V74Whwh9yQNgL9UxdNk7L0eGqvaT3EjXxjOc\n"
-        + "RCeJiucGN/0W2iq+V01/QGspp4SKtAogWBozABEBAAG0IlRlc3R1c2VyIFRocmVl\n"
-        + "IDx0ZXN0M0BleGFtcGxlLmNvbT6JAT4EEwECACgFAkLOp0UCGwMFCQHhM4AGCwkI\n"
-        + "BwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEDzseBsX3hrNYg0H/2CMm5/JDQNSuRFC\n"
-        + "ECWLrcOeimuvwbmkonNzOkvKbGXl73GStISAksRWAHBQED1rEPC0NkFCDeVZO7df\n"
-        + "SYLlsqKwV6uSh05Ra0F5XeniC12YpAyzoQyCGRS2wLaS822j0zUPXA8XLaO2blCu\n"
-        + "R+8sNu/oecMRcFK4S9NaApi3vdqBNhLiN1/Lpqn1LfB8uIO+eaUf4PmCWbaPgzSk\n"
-        + "qcPfKZmocNXdgLV5Q80n3hc2y2nrl+vDW2M+eVZuDHAok2BOD9uGKFfLAbaXLbX5\n"
-        + "btBW2L0UHtoEyiqkRfD6lX2laSLQmA6+eup7e4GS+s0vXBuVh8XEYddV6Yjt8H7/\n"
-        + "2thO41K5AQ0EQs6nRQEIAM/833UHK1DuFlOm7/n18dRMvs7BkXvg+hPquKWMG3be\n"
-        + "eE4sh1NG5DbRCdo6iacZLarWr3FDz7J9+wswRhtHCh3pGHEuaJk52vRjQxlkNh5F\n"
-        + "p5u2R4WF546bWqX45xPdLfHVTPyWB9q7aVxE+6Q+MHa6lMoyTVnTVCOy3nshiihw\n"
-        + "dxLsxaga+QmaL0bAR+dRcO6ucj7TDQXz1AJAVp26c0LXV9iErhFuuybUZKT0a9Aj\n"
-        + "FoumMZ6l+k30sSdjSjpBMsNvPos0dTPPRXUMu77o5sj+pHa4o8WctgB3o7BHQELp\n"
-        + "KgujZ2sKC9Nm395u6Q4cqUWihzb/Y7rIRuNHJarI7vUAEQEAAYkBJQQYAQIADwUC\n"
-        + "Qs6nRQIbDAUJAeEzgAAKCRA87HgbF94azRiBB/4vAyOOjUjK3lDWjHGs7mvEWJI/\n"
-        + "1MeLlGPswCSInJBa+HMiMI4tzq+hu5ejGThojNbmnL96GdzfDkMlP4Feyxb2rjtb\n"
-        + "NrD/R5tlXHmjX/QLzep4LCeMziP80fu8qUeiOej/Ecdny0w365PlMdt10RaYR8VE\n"
-        + "ZX/DAie6JfElnfQcG5q8TIOH3i71qxV+kIoPqKWfQ0MXrNEJ3BYFfDGdUt8U1Kq9\n"
-        + "OuIHVRgGS7mMSyjgNqqp7MBeMY+PFFZaZel5yoYVjb9d3L8XvVv2eoa/jPj5FUEU\n"
-        + "kE9uxNmwaD1PiV8DvBTYI+eQL4qzfu+3NTG2SfgQYtj5oiGHw8aL3U6QHDJb\n"
-        + "=d/Xp\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBELOp0UBCACxholOPWuKhK+TYb88nvLUSCMvTLIFEpb5u3Eavr0wiluEzq6H\n"
+            + "55nswAD3dQm8DWxA7yUlEYjPr5btpw7V9441bb1+qtgZMJ10RTdEb/WjyctdGA99\n"
+            + "uOKBEarWbt8W+w6lyJ9NXy5bS/x5EwHHfoTFp4ff6ffHI5hbx1a00K8oxmitgd0X\n"
+            + "Mx86UmauFNJYupZOZG9gEcP4RbRp7e2pm4Jy1WLEOeg9Fdgm5e5Hj2nMkCSZ9BKV\n"
+            + "cxuOllSVzM/Zp0/4+RS9R57jKo3/V74Whwh9yQNgL9UxdNk7L0eGqvaT3EjXxjOc\n"
+            + "RCeJiucGN/0W2iq+V01/QGspp4SKtAogWBozABEBAAG0IlRlc3R1c2VyIFRocmVl\n"
+            + "IDx0ZXN0M0BleGFtcGxlLmNvbT6JAT4EEwECACgFAkLOp0UCGwMFCQHhM4AGCwkI\n"
+            + "BwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEDzseBsX3hrNYg0H/2CMm5/JDQNSuRFC\n"
+            + "ECWLrcOeimuvwbmkonNzOkvKbGXl73GStISAksRWAHBQED1rEPC0NkFCDeVZO7df\n"
+            + "SYLlsqKwV6uSh05Ra0F5XeniC12YpAyzoQyCGRS2wLaS822j0zUPXA8XLaO2blCu\n"
+            + "R+8sNu/oecMRcFK4S9NaApi3vdqBNhLiN1/Lpqn1LfB8uIO+eaUf4PmCWbaPgzSk\n"
+            + "qcPfKZmocNXdgLV5Q80n3hc2y2nrl+vDW2M+eVZuDHAok2BOD9uGKFfLAbaXLbX5\n"
+            + "btBW2L0UHtoEyiqkRfD6lX2laSLQmA6+eup7e4GS+s0vXBuVh8XEYddV6Yjt8H7/\n"
+            + "2thO41K5AQ0EQs6nRQEIAM/833UHK1DuFlOm7/n18dRMvs7BkXvg+hPquKWMG3be\n"
+            + "eE4sh1NG5DbRCdo6iacZLarWr3FDz7J9+wswRhtHCh3pGHEuaJk52vRjQxlkNh5F\n"
+            + "p5u2R4WF546bWqX45xPdLfHVTPyWB9q7aVxE+6Q+MHa6lMoyTVnTVCOy3nshiihw\n"
+            + "dxLsxaga+QmaL0bAR+dRcO6ucj7TDQXz1AJAVp26c0LXV9iErhFuuybUZKT0a9Aj\n"
+            + "FoumMZ6l+k30sSdjSjpBMsNvPos0dTPPRXUMu77o5sj+pHa4o8WctgB3o7BHQELp\n"
+            + "KgujZ2sKC9Nm395u6Q4cqUWihzb/Y7rIRuNHJarI7vUAEQEAAYkBJQQYAQIADwUC\n"
+            + "Qs6nRQIbDAUJAeEzgAAKCRA87HgbF94azRiBB/4vAyOOjUjK3lDWjHGs7mvEWJI/\n"
+            + "1MeLlGPswCSInJBa+HMiMI4tzq+hu5ejGThojNbmnL96GdzfDkMlP4Feyxb2rjtb\n"
+            + "NrD/R5tlXHmjX/QLzep4LCeMziP80fu8qUeiOej/Ecdny0w365PlMdt10RaYR8VE\n"
+            + "ZX/DAie6JfElnfQcG5q8TIOH3i71qxV+kIoPqKWfQ0MXrNEJ3BYFfDGdUt8U1Kq9\n"
+            + "OuIHVRgGS7mMSyjgNqqp7MBeMY+PFFZaZel5yoYVjb9d3L8XvVv2eoa/jPj5FUEU\n"
+            + "kE9uxNmwaD1PiV8DvBTYI+eQL4qzfu+3NTG2SfgQYtj5oiGHw8aL3U6QHDJb\n"
+            + "=d/Xp\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBELOp0UBCACxholOPWuKhK+TYb88nvLUSCMvTLIFEpb5u3Eavr0wiluEzq6H\n"
-        + "55nswAD3dQm8DWxA7yUlEYjPr5btpw7V9441bb1+qtgZMJ10RTdEb/WjyctdGA99\n"
-        + "uOKBEarWbt8W+w6lyJ9NXy5bS/x5EwHHfoTFp4ff6ffHI5hbx1a00K8oxmitgd0X\n"
-        + "Mx86UmauFNJYupZOZG9gEcP4RbRp7e2pm4Jy1WLEOeg9Fdgm5e5Hj2nMkCSZ9BKV\n"
-        + "cxuOllSVzM/Zp0/4+RS9R57jKo3/V74Whwh9yQNgL9UxdNk7L0eGqvaT3EjXxjOc\n"
-        + "RCeJiucGN/0W2iq+V01/QGspp4SKtAogWBozABEBAAEAB/4hGI3ckkLMTjRVa7G1\n"
-        + "YYSv4sr8dHXz0CVpZXKOo+Stef3Z4pZTK/BcXOdROvaXooD+EheAs6Yn4fpnT+/K\n"
-        + "IB7ZAx6C0OL8vz17gbPuBFltMZ/COUwaCi/gFCUfWQgqRp/SdHaOfCIuTxpAkDSS\n"
-        + "tpmWJ8eDDSFudMpgweb+SrF9DkCwp+FgUbzDRzO1aqzuu8PGihCHQt/pkhNHQ63/\n"
-        + "srDDqk6lIxxZHhv9+ucr3plDuijkvAa5/QDudQlucKDLtTPSD40UcqYnpg/V/RJU\n"
-        + "eBK0ZXmCIHpG9beHW/xdlwrK3eY4Z2sVDMm9TeeHmRYOCr5wQCyeLpMdAt0Ijk6a\n"
-        + "nINhBADI2lRodgnLvUKbOvVocz8WQjG1IXlL8iXSNuuHONijPXZiWh7XdkNxr9fm\n"
-        + "jRqzvZzYsWGT6MnirX2eXaEWJsWJHxTxJuiuOk0V/iGnV/d+jFduoKXNmB5k/ZB3\n"
-        + "6zySi7+STKNyIvnMATVsRoI/cNUwfmx53m6trFg581CnSiA82QQA4kSPw9OXmTKj\n"
-        + "ctlHrWsapWu+66pDVZw62lW6lvrd7t+m8liNb6VJuTnwIKVXJOQtUo1+GSMs0+YK\n"
-        + "wnd9FGq4jT8l0qBO4K/8B1HxppLC2S0ntC+CusxWMUDbdC2xg+G2W3oLwq3iamgz\n"
-        + "LvPTy1Pzs9PqDd6FXIdzieFy6J8W1+sEAKS3vjh7Z/PIVULZhdaohAd5Igd67S/Z\n"
-        + "BMWYNbBuJTnnb7DiOllLZSd2lR7IAKPKsUd6UY8uskOxI81hI116zNx17mIGFIIq\n"
-        + "DdDgRbvzMNEgNlOxg/BD01kXOS4fhnT2F6ca3VGTgUtOdcdF3M9MtePWQLBzEDPz\n"
-        + "8nx3O20HDupuQmG0IlRlc3R1c2VyIFRocmVlIDx0ZXN0M0BleGFtcGxlLmNvbT6J\n"
-        + "AT4EEwECACgFAkLOp0UCGwMFCQHhM4AGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheA\n"
-        + "AAoJEDzseBsX3hrNYg0H/2CMm5/JDQNSuRFCECWLrcOeimuvwbmkonNzOkvKbGXl\n"
-        + "73GStISAksRWAHBQED1rEPC0NkFCDeVZO7dfSYLlsqKwV6uSh05Ra0F5XeniC12Y\n"
-        + "pAyzoQyCGRS2wLaS822j0zUPXA8XLaO2blCuR+8sNu/oecMRcFK4S9NaApi3vdqB\n"
-        + "NhLiN1/Lpqn1LfB8uIO+eaUf4PmCWbaPgzSkqcPfKZmocNXdgLV5Q80n3hc2y2nr\n"
-        + "l+vDW2M+eVZuDHAok2BOD9uGKFfLAbaXLbX5btBW2L0UHtoEyiqkRfD6lX2laSLQ\n"
-        + "mA6+eup7e4GS+s0vXBuVh8XEYddV6Yjt8H7/2thO41KdA5gEQs6nRQEIAM/833UH\n"
-        + "K1DuFlOm7/n18dRMvs7BkXvg+hPquKWMG3beeE4sh1NG5DbRCdo6iacZLarWr3FD\n"
-        + "z7J9+wswRhtHCh3pGHEuaJk52vRjQxlkNh5Fp5u2R4WF546bWqX45xPdLfHVTPyW\n"
-        + "B9q7aVxE+6Q+MHa6lMoyTVnTVCOy3nshiihwdxLsxaga+QmaL0bAR+dRcO6ucj7T\n"
-        + "DQXz1AJAVp26c0LXV9iErhFuuybUZKT0a9AjFoumMZ6l+k30sSdjSjpBMsNvPos0\n"
-        + "dTPPRXUMu77o5sj+pHa4o8WctgB3o7BHQELpKgujZ2sKC9Nm395u6Q4cqUWihzb/\n"
-        + "Y7rIRuNHJarI7vUAEQEAAQAH+gNBKDf7FDzwdM37Sz8Ej7OsPcIbekzPcOpV3mzM\n"
-        + "u/NIuOY0QSvW7KRE8hwFlXjVZocJU/Z4Qqw+12pN55LusiRUrOq8eKuJIbl4QikI\n"
-        + "Dea8XUqM+CKJPV3YZXs6YVdIuzrRBSLgsB/Glff5JlzkEjsRYVmmnto8edETL/MK\n"
-        + "S9ClJqQiFKE4b01+Eh9oB/DfxzsiEf/a+rdRnWRh/jtpEwgeXcfmjhf+0zrzChu2\n"
-        + "ylQQ5QOuwQNKJP6DvRu/W5pOaKH9tPDR31SccDJDdnDUzBD7oSsXl06DcfMNEa8q\n"
-        + "PaNHLDDRNnqTEhwYSJ4r2emDFMxg7Kky+aatUNjAYk9vkgMEANnvumgr6/KCLWKc\n"
-        + "D3fZE09N7BveGBBDQBYNGPFtx60WbKrSY3e2RSfgWbyEXkzwm1VlB2869T1we0rL\n"
-        + "z6eV/TK5rrJQxJFHZ/anMxbQY0sCiOgqi6PKT03RTpA2N803hTym+oypy+5T6BFM\n"
-        + "rtjXvwIZN/BgAE2JjA70crTAd1mvBAD0UFNAU9oE7K7sgDbni4EhxmDyaviBHfxV\n"
-        + "PJP1ICUXAcEzAsz2T/L5TqZUD+LfYIkbf8wk2/mPZFfrCrQgCrzWn7KV1SHXkhf4\n"
-        + "4Sg6Y6p0g0Jl3mWRPiQ6ALlOVQIkp5V8z4b0hTF2c4oct1Pzaeq+ZkahyvrhW06P\n"
-        + "iaucRZb+mwP/aVTpkd4n/FyKCcbf9/KniYJ+Ou1OunsBQr/jzN+r0PKCb8l/ksig\n"
-        + "i/M0NGetemq9CxYsJDAyJs1aO4SWgx5LbfcMmyXDuJ3sL0ztFLOES31Mih3ZJebg\n"
-        + "xPpj2bB/67i2zeYRcjxQ116y23gOa2TWM8EE4TW7F/mQjw4fIPJ93ClBMIkBJQQY\n"
-        + "AQIADwUCQs6nRQIbDAUJAeEzgAAKCRA87HgbF94azRiBB/4vAyOOjUjK3lDWjHGs\n"
-        + "7mvEWJI/1MeLlGPswCSInJBa+HMiMI4tzq+hu5ejGThojNbmnL96GdzfDkMlP4Fe\n"
-        + "yxb2rjtbNrD/R5tlXHmjX/QLzep4LCeMziP80fu8qUeiOej/Ecdny0w365PlMdt1\n"
-        + "0RaYR8VEZX/DAie6JfElnfQcG5q8TIOH3i71qxV+kIoPqKWfQ0MXrNEJ3BYFfDGd\n"
-        + "Ut8U1Kq9OuIHVRgGS7mMSyjgNqqp7MBeMY+PFFZaZel5yoYVjb9d3L8XvVv2eoa/\n"
-        + "jPj5FUEUkE9uxNmwaD1PiV8DvBTYI+eQL4qzfu+3NTG2SfgQYtj5oiGHw8aL3U6Q\n"
-        + "HDJb\n"
-        + "=RrXv\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBELOp0UBCACxholOPWuKhK+TYb88nvLUSCMvTLIFEpb5u3Eavr0wiluEzq6H\n"
+            + "55nswAD3dQm8DWxA7yUlEYjPr5btpw7V9441bb1+qtgZMJ10RTdEb/WjyctdGA99\n"
+            + "uOKBEarWbt8W+w6lyJ9NXy5bS/x5EwHHfoTFp4ff6ffHI5hbx1a00K8oxmitgd0X\n"
+            + "Mx86UmauFNJYupZOZG9gEcP4RbRp7e2pm4Jy1WLEOeg9Fdgm5e5Hj2nMkCSZ9BKV\n"
+            + "cxuOllSVzM/Zp0/4+RS9R57jKo3/V74Whwh9yQNgL9UxdNk7L0eGqvaT3EjXxjOc\n"
+            + "RCeJiucGN/0W2iq+V01/QGspp4SKtAogWBozABEBAAEAB/4hGI3ckkLMTjRVa7G1\n"
+            + "YYSv4sr8dHXz0CVpZXKOo+Stef3Z4pZTK/BcXOdROvaXooD+EheAs6Yn4fpnT+/K\n"
+            + "IB7ZAx6C0OL8vz17gbPuBFltMZ/COUwaCi/gFCUfWQgqRp/SdHaOfCIuTxpAkDSS\n"
+            + "tpmWJ8eDDSFudMpgweb+SrF9DkCwp+FgUbzDRzO1aqzuu8PGihCHQt/pkhNHQ63/\n"
+            + "srDDqk6lIxxZHhv9+ucr3plDuijkvAa5/QDudQlucKDLtTPSD40UcqYnpg/V/RJU\n"
+            + "eBK0ZXmCIHpG9beHW/xdlwrK3eY4Z2sVDMm9TeeHmRYOCr5wQCyeLpMdAt0Ijk6a\n"
+            + "nINhBADI2lRodgnLvUKbOvVocz8WQjG1IXlL8iXSNuuHONijPXZiWh7XdkNxr9fm\n"
+            + "jRqzvZzYsWGT6MnirX2eXaEWJsWJHxTxJuiuOk0V/iGnV/d+jFduoKXNmB5k/ZB3\n"
+            + "6zySi7+STKNyIvnMATVsRoI/cNUwfmx53m6trFg581CnSiA82QQA4kSPw9OXmTKj\n"
+            + "ctlHrWsapWu+66pDVZw62lW6lvrd7t+m8liNb6VJuTnwIKVXJOQtUo1+GSMs0+YK\n"
+            + "wnd9FGq4jT8l0qBO4K/8B1HxppLC2S0ntC+CusxWMUDbdC2xg+G2W3oLwq3iamgz\n"
+            + "LvPTy1Pzs9PqDd6FXIdzieFy6J8W1+sEAKS3vjh7Z/PIVULZhdaohAd5Igd67S/Z\n"
+            + "BMWYNbBuJTnnb7DiOllLZSd2lR7IAKPKsUd6UY8uskOxI81hI116zNx17mIGFIIq\n"
+            + "DdDgRbvzMNEgNlOxg/BD01kXOS4fhnT2F6ca3VGTgUtOdcdF3M9MtePWQLBzEDPz\n"
+            + "8nx3O20HDupuQmG0IlRlc3R1c2VyIFRocmVlIDx0ZXN0M0BleGFtcGxlLmNvbT6J\n"
+            + "AT4EEwECACgFAkLOp0UCGwMFCQHhM4AGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheA\n"
+            + "AAoJEDzseBsX3hrNYg0H/2CMm5/JDQNSuRFCECWLrcOeimuvwbmkonNzOkvKbGXl\n"
+            + "73GStISAksRWAHBQED1rEPC0NkFCDeVZO7dfSYLlsqKwV6uSh05Ra0F5XeniC12Y\n"
+            + "pAyzoQyCGRS2wLaS822j0zUPXA8XLaO2blCuR+8sNu/oecMRcFK4S9NaApi3vdqB\n"
+            + "NhLiN1/Lpqn1LfB8uIO+eaUf4PmCWbaPgzSkqcPfKZmocNXdgLV5Q80n3hc2y2nr\n"
+            + "l+vDW2M+eVZuDHAok2BOD9uGKFfLAbaXLbX5btBW2L0UHtoEyiqkRfD6lX2laSLQ\n"
+            + "mA6+eup7e4GS+s0vXBuVh8XEYddV6Yjt8H7/2thO41KdA5gEQs6nRQEIAM/833UH\n"
+            + "K1DuFlOm7/n18dRMvs7BkXvg+hPquKWMG3beeE4sh1NG5DbRCdo6iacZLarWr3FD\n"
+            + "z7J9+wswRhtHCh3pGHEuaJk52vRjQxlkNh5Fp5u2R4WF546bWqX45xPdLfHVTPyW\n"
+            + "B9q7aVxE+6Q+MHa6lMoyTVnTVCOy3nshiihwdxLsxaga+QmaL0bAR+dRcO6ucj7T\n"
+            + "DQXz1AJAVp26c0LXV9iErhFuuybUZKT0a9AjFoumMZ6l+k30sSdjSjpBMsNvPos0\n"
+            + "dTPPRXUMu77o5sj+pHa4o8WctgB3o7BHQELpKgujZ2sKC9Nm395u6Q4cqUWihzb/\n"
+            + "Y7rIRuNHJarI7vUAEQEAAQAH+gNBKDf7FDzwdM37Sz8Ej7OsPcIbekzPcOpV3mzM\n"
+            + "u/NIuOY0QSvW7KRE8hwFlXjVZocJU/Z4Qqw+12pN55LusiRUrOq8eKuJIbl4QikI\n"
+            + "Dea8XUqM+CKJPV3YZXs6YVdIuzrRBSLgsB/Glff5JlzkEjsRYVmmnto8edETL/MK\n"
+            + "S9ClJqQiFKE4b01+Eh9oB/DfxzsiEf/a+rdRnWRh/jtpEwgeXcfmjhf+0zrzChu2\n"
+            + "ylQQ5QOuwQNKJP6DvRu/W5pOaKH9tPDR31SccDJDdnDUzBD7oSsXl06DcfMNEa8q\n"
+            + "PaNHLDDRNnqTEhwYSJ4r2emDFMxg7Kky+aatUNjAYk9vkgMEANnvumgr6/KCLWKc\n"
+            + "D3fZE09N7BveGBBDQBYNGPFtx60WbKrSY3e2RSfgWbyEXkzwm1VlB2869T1we0rL\n"
+            + "z6eV/TK5rrJQxJFHZ/anMxbQY0sCiOgqi6PKT03RTpA2N803hTym+oypy+5T6BFM\n"
+            + "rtjXvwIZN/BgAE2JjA70crTAd1mvBAD0UFNAU9oE7K7sgDbni4EhxmDyaviBHfxV\n"
+            + "PJP1ICUXAcEzAsz2T/L5TqZUD+LfYIkbf8wk2/mPZFfrCrQgCrzWn7KV1SHXkhf4\n"
+            + "4Sg6Y6p0g0Jl3mWRPiQ6ALlOVQIkp5V8z4b0hTF2c4oct1Pzaeq+ZkahyvrhW06P\n"
+            + "iaucRZb+mwP/aVTpkd4n/FyKCcbf9/KniYJ+Ou1OunsBQr/jzN+r0PKCb8l/ksig\n"
+            + "i/M0NGetemq9CxYsJDAyJs1aO4SWgx5LbfcMmyXDuJ3sL0ztFLOES31Mih3ZJebg\n"
+            + "xPpj2bB/67i2zeYRcjxQ116y23gOa2TWM8EE4TW7F/mQjw4fIPJ93ClBMIkBJQQY\n"
+            + "AQIADwUCQs6nRQIbDAUJAeEzgAAKCRA87HgbF94azRiBB/4vAyOOjUjK3lDWjHGs\n"
+            + "7mvEWJI/1MeLlGPswCSInJBa+HMiMI4tzq+hu5ejGThojNbmnL96GdzfDkMlP4Fe\n"
+            + "yxb2rjtbNrD/R5tlXHmjX/QLzep4LCeMziP80fu8qUeiOej/Ecdny0w365PlMdt1\n"
+            + "0RaYR8VEZX/DAie6JfElnfQcG5q8TIOH3i71qxV+kIoPqKWfQ0MXrNEJ3BYFfDGd\n"
+            + "Ut8U1Kq9OuIHVRgGS7mMSyjgNqqp7MBeMY+PFFZaZel5yoYVjb9d3L8XvVv2eoa/\n"
+            + "jPj5FUEUkE9uxNmwaD1PiV8DvBTYI+eQL4qzfu+3NTG2SfgQYtj5oiGHw8aL3U6Q\n"
+            + "HDJb\n"
+            + "=RrXv\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
@@ -340,98 +339,98 @@
   public static final TestKey selfRevokedKey() {
     return new TestKey(
         "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBFWdTZwBCAC1jukp5mlitfq2sAmdtx1s1VbWh+buDbBY2kWcxbbssczozFUP\n"
-        + "Ii67wPwjRbn3GM5+jY3GMsqKIrdyDlxeTxGWoU/qa2YkCQzgFGD/XJBqkVpP6osm\n"
-        + "qFYSP0xST1iBkatkMHq5KMjrX2q2bGVLlchLF9eHrWSefMcfff1Vs/Y8F2RCo38y\n"
-        + "gH88mbcvgyC+zq6Q2T3h5RiLK2IaZDNsn3uUoIMYHxI6oYtXXMSXRJlLJvamXVrB\n"
-        + "7QAq8L8cNikJjZMz+bHtLtGDyVVp9tqo4yvMrHe6BYmBUte3tPYQlDVdEo7UqepR\n"
-        + "uT7JbBOGBoD+9ngDrDggPUBAoa0h3VNOTyoDABEBAAGJAR8EIAECAAkFAlWdVXkC\n"
-        + "HQIACgkQCFUqF3yoeCH4lgf/aBdTYqnwL1lreHbQaUXI0/B2zlMuoptoi/x+xjIB\n"
-        + "7RszzaN3w0n4/87kUN2koNtgNymv2ccKTR1PiX+obscJhsWzNbz3/Cjtr/IpEQRd\n"
-        + "E6qRptHDk0U2cHW4BYDSltndOktICdhWCWYLDxJHGjdyXqqqdEEFJ24u2fUJ3yF3\n"
-        + "NF2Bxa6llrmLb2fVeVYBzQSztQopKRWP9nt3ySoeJQqRWjNBN2j7cC93nrLHZTvB\n"
-        + "L/sWuTq5ecbXeeNVzxoBd21jmGrIUPNwGdDKdbTB0CjpLpVHOTwGByeRKQXhMlQB\n"
-        + "pK96wUpxxtShtOjNjN1s9GEyLHwDiHSuHNYs/AxxFzf9nbQhVGVzdHVzZXIgRm91\n"
-        + "ciA8dGVzdDRAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJVnU2cAhsDBgsJCAcDAgYV\n"
-        + "CAIJCgsEFgIDAQIeAQIXgAAKCRAIVSoXfKh4IXsHCACSm9RIdxxqibAaxh+nm6w5\n"
-        + "F5a6Hju5cdmkk9albDoQYh2eM8E5NdDq+r0qSSe2+ujDaQ4C95DZNJQESvIcHHHb\n"
-        + "9AECrBfS8Yk86rX8hxVeYQczMkB9LdBHximTSoOr8L/eAxBE/VXDwust6EAe6Q1A\n"
-        + "a3tlTTvCfcmw4PipvtP7F6UzFaq+QU6fvARpBATOcvVc2JU4JQOrxuNEQ2PKrSti\n"
-        + "75S5mnVWm0pRebM+EorWBtlA0eOAeLNqCp87UwLdvUyOTRZT4DJ51eTxfrFADXrI\n"
-        + "9/ejs3/YxCPYxaPicAlcldduuajU/s+9ifrUn0Npg2ILl8mQkNzqeerlBeecUV4E\n"
-        + "uQENBFWdTZwBCADEOsK+mFQ/2uds9znkmAqrk24waVBpyPGrTTXtXX0dKhtQAsh6\n"
-        + "QkZGkjLTnKxEsa9syqVckw+1JtCh44SP1gjqDUoShpBz5wIuksZ7q96Hx+F0TVG/\n"
-        + "njS6GrWvwKhL2Lb9hYfdlrZiYtOOi0iiOzud25H/Ms15kC8tuQm7NWtANJJF4Sxo\n"
-        + "Bxor6L/F4zunEkTL0L9/dp4qVrw23fJVKE38cSdxjB0u1qSDzLV/u0QJqlYxJAiE\n"
-        + "ciwQN2uVnTY1/XSpouMy6LvbYU7B2uU/WohNmH3RiN/fQ6jJm4x+fCZ8+zqXMiZn\n"
-        + "G2fPkwmxxK9cl64YnNGcTwsVt6BMbCHk9jHxABEBAAGJAR8EGAECAAkFAlWdTZwC\n"
-        + "GwwACgkQCFUqF3yoeCGOdwf/TmoxH3pFBm/MDhY5Ct5FO0KvsgQk2ZgDa68HyQ8j\n"
-        + "QYi1FUCtyDjsxf5KTfyvzpzcTpS7cyOwcJNtTj6UixwATkcivvYWYoOXghAsTo4f\n"
-        + "1+j/x6ECq1+nYE6NpcAN7VRJpYMk2UO2qlhHCesTPGzsHchL7mwiYdhGrdiWGTpd\n"
-        + "KI9WfOYDZZ9ZSw/QINJUyTRxrDnauOvVbhbAXc7jdKCkRQRZpsNlF//1Stg6nstj\n"
-        + "FJ7SrjVdsMJNlihT6fG5ujmrty1/6b1VCLkIQfW5cWvzRzTBFytq7i4PVKh3u7Oz\n"
-        + "tt9lf8s50zt2uBE/AKMkyE6IJLsBWpJPk7iFKkHGDx044Q==\n"
-        + "=477N\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFWdTZwBCAC1jukp5mlitfq2sAmdtx1s1VbWh+buDbBY2kWcxbbssczozFUP\n"
+            + "Ii67wPwjRbn3GM5+jY3GMsqKIrdyDlxeTxGWoU/qa2YkCQzgFGD/XJBqkVpP6osm\n"
+            + "qFYSP0xST1iBkatkMHq5KMjrX2q2bGVLlchLF9eHrWSefMcfff1Vs/Y8F2RCo38y\n"
+            + "gH88mbcvgyC+zq6Q2T3h5RiLK2IaZDNsn3uUoIMYHxI6oYtXXMSXRJlLJvamXVrB\n"
+            + "7QAq8L8cNikJjZMz+bHtLtGDyVVp9tqo4yvMrHe6BYmBUte3tPYQlDVdEo7UqepR\n"
+            + "uT7JbBOGBoD+9ngDrDggPUBAoa0h3VNOTyoDABEBAAGJAR8EIAECAAkFAlWdVXkC\n"
+            + "HQIACgkQCFUqF3yoeCH4lgf/aBdTYqnwL1lreHbQaUXI0/B2zlMuoptoi/x+xjIB\n"
+            + "7RszzaN3w0n4/87kUN2koNtgNymv2ccKTR1PiX+obscJhsWzNbz3/Cjtr/IpEQRd\n"
+            + "E6qRptHDk0U2cHW4BYDSltndOktICdhWCWYLDxJHGjdyXqqqdEEFJ24u2fUJ3yF3\n"
+            + "NF2Bxa6llrmLb2fVeVYBzQSztQopKRWP9nt3ySoeJQqRWjNBN2j7cC93nrLHZTvB\n"
+            + "L/sWuTq5ecbXeeNVzxoBd21jmGrIUPNwGdDKdbTB0CjpLpVHOTwGByeRKQXhMlQB\n"
+            + "pK96wUpxxtShtOjNjN1s9GEyLHwDiHSuHNYs/AxxFzf9nbQhVGVzdHVzZXIgRm91\n"
+            + "ciA8dGVzdDRAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJVnU2cAhsDBgsJCAcDAgYV\n"
+            + "CAIJCgsEFgIDAQIeAQIXgAAKCRAIVSoXfKh4IXsHCACSm9RIdxxqibAaxh+nm6w5\n"
+            + "F5a6Hju5cdmkk9albDoQYh2eM8E5NdDq+r0qSSe2+ujDaQ4C95DZNJQESvIcHHHb\n"
+            + "9AECrBfS8Yk86rX8hxVeYQczMkB9LdBHximTSoOr8L/eAxBE/VXDwust6EAe6Q1A\n"
+            + "a3tlTTvCfcmw4PipvtP7F6UzFaq+QU6fvARpBATOcvVc2JU4JQOrxuNEQ2PKrSti\n"
+            + "75S5mnVWm0pRebM+EorWBtlA0eOAeLNqCp87UwLdvUyOTRZT4DJ51eTxfrFADXrI\n"
+            + "9/ejs3/YxCPYxaPicAlcldduuajU/s+9ifrUn0Npg2ILl8mQkNzqeerlBeecUV4E\n"
+            + "uQENBFWdTZwBCADEOsK+mFQ/2uds9znkmAqrk24waVBpyPGrTTXtXX0dKhtQAsh6\n"
+            + "QkZGkjLTnKxEsa9syqVckw+1JtCh44SP1gjqDUoShpBz5wIuksZ7q96Hx+F0TVG/\n"
+            + "njS6GrWvwKhL2Lb9hYfdlrZiYtOOi0iiOzud25H/Ms15kC8tuQm7NWtANJJF4Sxo\n"
+            + "Bxor6L/F4zunEkTL0L9/dp4qVrw23fJVKE38cSdxjB0u1qSDzLV/u0QJqlYxJAiE\n"
+            + "ciwQN2uVnTY1/XSpouMy6LvbYU7B2uU/WohNmH3RiN/fQ6jJm4x+fCZ8+zqXMiZn\n"
+            + "G2fPkwmxxK9cl64YnNGcTwsVt6BMbCHk9jHxABEBAAGJAR8EGAECAAkFAlWdTZwC\n"
+            + "GwwACgkQCFUqF3yoeCGOdwf/TmoxH3pFBm/MDhY5Ct5FO0KvsgQk2ZgDa68HyQ8j\n"
+            + "QYi1FUCtyDjsxf5KTfyvzpzcTpS7cyOwcJNtTj6UixwATkcivvYWYoOXghAsTo4f\n"
+            + "1+j/x6ECq1+nYE6NpcAN7VRJpYMk2UO2qlhHCesTPGzsHchL7mwiYdhGrdiWGTpd\n"
+            + "KI9WfOYDZZ9ZSw/QINJUyTRxrDnauOvVbhbAXc7jdKCkRQRZpsNlF//1Stg6nstj\n"
+            + "FJ7SrjVdsMJNlihT6fG5ujmrty1/6b1VCLkIQfW5cWvzRzTBFytq7i4PVKh3u7Oz\n"
+            + "tt9lf8s50zt2uBE/AKMkyE6IJLsBWpJPk7iFKkHGDx044Q==\n"
+            + "=477N\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBFWdTZwBCAC1jukp5mlitfq2sAmdtx1s1VbWh+buDbBY2kWcxbbssczozFUP\n"
-        + "Ii67wPwjRbn3GM5+jY3GMsqKIrdyDlxeTxGWoU/qa2YkCQzgFGD/XJBqkVpP6osm\n"
-        + "qFYSP0xST1iBkatkMHq5KMjrX2q2bGVLlchLF9eHrWSefMcfff1Vs/Y8F2RCo38y\n"
-        + "gH88mbcvgyC+zq6Q2T3h5RiLK2IaZDNsn3uUoIMYHxI6oYtXXMSXRJlLJvamXVrB\n"
-        + "7QAq8L8cNikJjZMz+bHtLtGDyVVp9tqo4yvMrHe6BYmBUte3tPYQlDVdEo7UqepR\n"
-        + "uT7JbBOGBoD+9ngDrDggPUBAoa0h3VNOTyoDABEBAAEAB/4jqeZoOiACaV/Nygeh\n"
-        + "iOpJSiDsNDbrFRpKYdnhwT69APIQ2q5sshi+/dopbZVpkeBiIJk0UR7TAp3JVEPV\n"
-        + "rK92SMqjcCRYuMRkMeyZzMt7e4DjiN17ov6BSBjMZFSs4vnpTNKWk4ngHlaebe15\n"
-        + "6vq0sYK/XpKQxU7yAzQjxR190P/F+QEL98zVG/9uqM8PupfdSm4Smp2cIpfta+JD\n"
-        + "mO23HC6jAEm2RFwklovzgK3rbIjyiMuowIkAKx5xxRvpxMHf1l566b9zJrRi0xau\n"
-        + "vp4J/lnBJtTMzCbsaaFxhrj23xvTXaWR+UkaGPCv7wheXQ9K7NAHwmH8YrR+cZx7\n"
-        + "KbDlBADUTHZ+OhNslx/rkjRWrFuK9p49x7qxQc26kcqlGPbW6KOAMdUpwneQbhCG\n"
-        + "a36E/GAZgsgQ4SUqn37EVCtd2Y9Dp0inPAujcZXSwgDHev6ea7fzbxT9KLtEgvQN\n"
-        + "0vrFJDCPIt0wzGqNDw4wgFjF2rAafBO//Wu5K5QLW4hfzSguRQQA2u6DpVja/FYY\n"
-        + "UHVh2HLiB8th4T+qogOsBe5mKEsGRPXtAh7QzJu36C4PJyHeNlmlMx+15cCFnovj\n"
-        + "6cLpGn6ZP4okLyq2+VsW7wh/Vir+UZHoAO/cZRlOc1PsaQconcxxq30SsbaRQrAd\n"
-        + "YargKlXU7HMFiK34nkidBV6vVW0+P6cD/jYRInM983KXqX5bYvqsM1Zyvvlu6otD\n"
-        + "nG0F/nQYT7oaKKR46quDa+xHMxK8/Vu1+TabzY8XapnoYFaFvrl/d2rUBEZSoury\n"
-        + "z2yfTyeomft9MGGQsCGAJ95bVDT+jBoohnYwfwdC7HG3qk0aK/TxFyUqvMOX7SFe\n"
-        + "YT55n3HlD9InST+0IVRlc3R1c2VyIEZvdXIgPHRlc3Q0QGV4YW1wbGUuY29tPokB\n"
-        + "OAQTAQIAIgUCVZ1NnAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQCFUq\n"
-        + "F3yoeCF7BwgAkpvUSHccaomwGsYfp5usOReWuh47uXHZpJPWpWw6EGIdnjPBOTXQ\n"
-        + "6vq9Kkkntvrow2kOAveQ2TSUBEryHBxx2/QBAqwX0vGJPOq1/IcVXmEHMzJAfS3Q\n"
-        + "R8Ypk0qDq/C/3gMQRP1Vw8LrLehAHukNQGt7ZU07wn3JsOD4qb7T+xelMxWqvkFO\n"
-        + "n7wEaQQEznL1XNiVOCUDq8bjRENjyq0rYu+UuZp1VptKUXmzPhKK1gbZQNHjgHiz\n"
-        + "agqfO1MC3b1Mjk0WU+AyedXk8X6xQA16yPf3o7N/2MQj2MWj4nAJXJXXbrmo1P7P\n"
-        + "vYn61J9DaYNiC5fJkJDc6nnq5QXnnFFeBJ0DmARVnU2cAQgAxDrCvphUP9rnbPc5\n"
-        + "5JgKq5NuMGlQacjxq0017V19HSobUALIekJGRpIy05ysRLGvbMqlXJMPtSbQoeOE\n"
-        + "j9YI6g1KEoaQc+cCLpLGe6veh8fhdE1Rv540uhq1r8CoS9i2/YWH3Za2YmLTjotI\n"
-        + "ojs7nduR/zLNeZAvLbkJuzVrQDSSReEsaAcaK+i/xeM7pxJEy9C/f3aeKla8Nt3y\n"
-        + "VShN/HEncYwdLtakg8y1f7tECapWMSQIhHIsEDdrlZ02Nf10qaLjMui722FOwdrl\n"
-        + "P1qITZh90Yjf30OoyZuMfnwmfPs6lzImZxtnz5MJscSvXJeuGJzRnE8LFbegTGwh\n"
-        + "5PYx8QARAQABAAf8CeTumd6jbN7USXXDyQdzjkguR6mfwN29dcY8YF4U52oOm3+w\n"
-        + "bR23XmqTvoDJXONatZEYOm093wP4hBktP3Vq2KZX5Ew9r2JoBUIoWOcHHvCQqSUW\n"
-        + "6KMJBJNBMv3zXnOscmcPvTgStS5HfYn/XRLAhEqkd2ov2x/OiS8p0vM0F7YYSOdu\n"
-        + "X6/nHeBCM5QSJl00kgcaeQYdIGL0bPv9DnoeAC2/yITEvtvs+MHZ7FjH8A45QjWn\n"
-        + "DwfVoLg7WOc3wJtqJ55/r/2pylrWz0YYM8s6I3gbDilCF+Wb8tEIOaWJEwY73J1/\n"
-        + "KQG5qlO3/hBlO80DtzNmi3ylRUuzGhTxQfvemwQA3EuZ+E48LJ3dwtdJhh5mFlWI\n"
-        + "Ket21e5v1mqMxuLhf5/2CYcifM08u3EsEUdIr7egF25Sea8otqmCYcG8FuB37VY/\n"
-        + "Hd4G/+YVVaaAB8EU6u64YfSswhzr0R2qWVLtkJr0EAephzdPdoUEtKDSdTxnXiDV\n"
-        + "3vSqLWtZekScLa979uMEAOQIodJwxSvveKQWILjK67ZJr56X8YQZWA6rFsr1xMY0\n"
-        + "N0GH+5k0k+tr4wT3H9uk9ZM1Z11G3c01mhzCNg5roFoKtftKUZRKxmbfjjDmAofl\n"
-        + "bA6EZ0WHLdOwDLLTuXK09IsjjSHq0YHOxIlgFzIreuoxtz27bEEGhVFQg7xb0Lgb\n"
-        + "A/9LP8i32L7/CHsuN0q4YjhJkkaB6JWUQMFqWwoAXALG3rnw/CGRYHmHpiAuSeHR\n"
-        + "dSlZzndVi5poNC/d27msTx7ZuWlN7nOyywHBCTWV/nstm2I9rDhrHK7Axgq0Vv0y\n"
-        + "bWAurUmEgDJHU3ZpsNVt4e30FooXIDLR4cnpRM7tILv39D4giQEfBBgBAgAJBQJV\n"
-        + "nU2cAhsMAAoJEAhVKhd8qHghjncH/05qMR96RQZvzA4WOQreRTtCr7IEJNmYA2uv\n"
-        + "B8kPI0GItRVArcg47MX+Sk38r86c3E6Uu3MjsHCTbU4+lIscAE5HIr72FmKDl4IQ\n"
-        + "LE6OH9fo/8ehAqtfp2BOjaXADe1USaWDJNlDtqpYRwnrEzxs7B3IS+5sImHYRq3Y\n"
-        + "lhk6XSiPVnzmA2WfWUsP0CDSVMk0caw52rjr1W4WwF3O43SgpEUEWabDZRf/9UrY\n"
-        + "Op7LYxSe0q41XbDCTZYoU+nxubo5q7ctf+m9VQi5CEH1uXFr80c0wRcrau4uD1So\n"
-        + "d7uzs7bfZX/LOdM7drgRPwCjJMhOiCS7AVqST5O4hSpBxg8dOOE=\n"
-        + "=5aNq\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFWdTZwBCAC1jukp5mlitfq2sAmdtx1s1VbWh+buDbBY2kWcxbbssczozFUP\n"
+            + "Ii67wPwjRbn3GM5+jY3GMsqKIrdyDlxeTxGWoU/qa2YkCQzgFGD/XJBqkVpP6osm\n"
+            + "qFYSP0xST1iBkatkMHq5KMjrX2q2bGVLlchLF9eHrWSefMcfff1Vs/Y8F2RCo38y\n"
+            + "gH88mbcvgyC+zq6Q2T3h5RiLK2IaZDNsn3uUoIMYHxI6oYtXXMSXRJlLJvamXVrB\n"
+            + "7QAq8L8cNikJjZMz+bHtLtGDyVVp9tqo4yvMrHe6BYmBUte3tPYQlDVdEo7UqepR\n"
+            + "uT7JbBOGBoD+9ngDrDggPUBAoa0h3VNOTyoDABEBAAEAB/4jqeZoOiACaV/Nygeh\n"
+            + "iOpJSiDsNDbrFRpKYdnhwT69APIQ2q5sshi+/dopbZVpkeBiIJk0UR7TAp3JVEPV\n"
+            + "rK92SMqjcCRYuMRkMeyZzMt7e4DjiN17ov6BSBjMZFSs4vnpTNKWk4ngHlaebe15\n"
+            + "6vq0sYK/XpKQxU7yAzQjxR190P/F+QEL98zVG/9uqM8PupfdSm4Smp2cIpfta+JD\n"
+            + "mO23HC6jAEm2RFwklovzgK3rbIjyiMuowIkAKx5xxRvpxMHf1l566b9zJrRi0xau\n"
+            + "vp4J/lnBJtTMzCbsaaFxhrj23xvTXaWR+UkaGPCv7wheXQ9K7NAHwmH8YrR+cZx7\n"
+            + "KbDlBADUTHZ+OhNslx/rkjRWrFuK9p49x7qxQc26kcqlGPbW6KOAMdUpwneQbhCG\n"
+            + "a36E/GAZgsgQ4SUqn37EVCtd2Y9Dp0inPAujcZXSwgDHev6ea7fzbxT9KLtEgvQN\n"
+            + "0vrFJDCPIt0wzGqNDw4wgFjF2rAafBO//Wu5K5QLW4hfzSguRQQA2u6DpVja/FYY\n"
+            + "UHVh2HLiB8th4T+qogOsBe5mKEsGRPXtAh7QzJu36C4PJyHeNlmlMx+15cCFnovj\n"
+            + "6cLpGn6ZP4okLyq2+VsW7wh/Vir+UZHoAO/cZRlOc1PsaQconcxxq30SsbaRQrAd\n"
+            + "YargKlXU7HMFiK34nkidBV6vVW0+P6cD/jYRInM983KXqX5bYvqsM1Zyvvlu6otD\n"
+            + "nG0F/nQYT7oaKKR46quDa+xHMxK8/Vu1+TabzY8XapnoYFaFvrl/d2rUBEZSoury\n"
+            + "z2yfTyeomft9MGGQsCGAJ95bVDT+jBoohnYwfwdC7HG3qk0aK/TxFyUqvMOX7SFe\n"
+            + "YT55n3HlD9InST+0IVRlc3R1c2VyIEZvdXIgPHRlc3Q0QGV4YW1wbGUuY29tPokB\n"
+            + "OAQTAQIAIgUCVZ1NnAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQCFUq\n"
+            + "F3yoeCF7BwgAkpvUSHccaomwGsYfp5usOReWuh47uXHZpJPWpWw6EGIdnjPBOTXQ\n"
+            + "6vq9Kkkntvrow2kOAveQ2TSUBEryHBxx2/QBAqwX0vGJPOq1/IcVXmEHMzJAfS3Q\n"
+            + "R8Ypk0qDq/C/3gMQRP1Vw8LrLehAHukNQGt7ZU07wn3JsOD4qb7T+xelMxWqvkFO\n"
+            + "n7wEaQQEznL1XNiVOCUDq8bjRENjyq0rYu+UuZp1VptKUXmzPhKK1gbZQNHjgHiz\n"
+            + "agqfO1MC3b1Mjk0WU+AyedXk8X6xQA16yPf3o7N/2MQj2MWj4nAJXJXXbrmo1P7P\n"
+            + "vYn61J9DaYNiC5fJkJDc6nnq5QXnnFFeBJ0DmARVnU2cAQgAxDrCvphUP9rnbPc5\n"
+            + "5JgKq5NuMGlQacjxq0017V19HSobUALIekJGRpIy05ysRLGvbMqlXJMPtSbQoeOE\n"
+            + "j9YI6g1KEoaQc+cCLpLGe6veh8fhdE1Rv540uhq1r8CoS9i2/YWH3Za2YmLTjotI\n"
+            + "ojs7nduR/zLNeZAvLbkJuzVrQDSSReEsaAcaK+i/xeM7pxJEy9C/f3aeKla8Nt3y\n"
+            + "VShN/HEncYwdLtakg8y1f7tECapWMSQIhHIsEDdrlZ02Nf10qaLjMui722FOwdrl\n"
+            + "P1qITZh90Yjf30OoyZuMfnwmfPs6lzImZxtnz5MJscSvXJeuGJzRnE8LFbegTGwh\n"
+            + "5PYx8QARAQABAAf8CeTumd6jbN7USXXDyQdzjkguR6mfwN29dcY8YF4U52oOm3+w\n"
+            + "bR23XmqTvoDJXONatZEYOm093wP4hBktP3Vq2KZX5Ew9r2JoBUIoWOcHHvCQqSUW\n"
+            + "6KMJBJNBMv3zXnOscmcPvTgStS5HfYn/XRLAhEqkd2ov2x/OiS8p0vM0F7YYSOdu\n"
+            + "X6/nHeBCM5QSJl00kgcaeQYdIGL0bPv9DnoeAC2/yITEvtvs+MHZ7FjH8A45QjWn\n"
+            + "DwfVoLg7WOc3wJtqJ55/r/2pylrWz0YYM8s6I3gbDilCF+Wb8tEIOaWJEwY73J1/\n"
+            + "KQG5qlO3/hBlO80DtzNmi3ylRUuzGhTxQfvemwQA3EuZ+E48LJ3dwtdJhh5mFlWI\n"
+            + "Ket21e5v1mqMxuLhf5/2CYcifM08u3EsEUdIr7egF25Sea8otqmCYcG8FuB37VY/\n"
+            + "Hd4G/+YVVaaAB8EU6u64YfSswhzr0R2qWVLtkJr0EAephzdPdoUEtKDSdTxnXiDV\n"
+            + "3vSqLWtZekScLa979uMEAOQIodJwxSvveKQWILjK67ZJr56X8YQZWA6rFsr1xMY0\n"
+            + "N0GH+5k0k+tr4wT3H9uk9ZM1Z11G3c01mhzCNg5roFoKtftKUZRKxmbfjjDmAofl\n"
+            + "bA6EZ0WHLdOwDLLTuXK09IsjjSHq0YHOxIlgFzIreuoxtz27bEEGhVFQg7xb0Lgb\n"
+            + "A/9LP8i32L7/CHsuN0q4YjhJkkaB6JWUQMFqWwoAXALG3rnw/CGRYHmHpiAuSeHR\n"
+            + "dSlZzndVi5poNC/d27msTx7ZuWlN7nOyywHBCTWV/nstm2I9rDhrHK7Axgq0Vv0y\n"
+            + "bWAurUmEgDJHU3ZpsNVt4e30FooXIDLR4cnpRM7tILv39D4giQEfBBgBAgAJBQJV\n"
+            + "nU2cAhsMAAoJEAhVKhd8qHghjncH/05qMR96RQZvzA4WOQreRTtCr7IEJNmYA2uv\n"
+            + "B8kPI0GItRVArcg47MX+Sk38r86c3E6Uu3MjsHCTbU4+lIscAE5HIr72FmKDl4IQ\n"
+            + "LE6OH9fo/8ehAqtfp2BOjaXADe1USaWDJNlDtqpYRwnrEzxs7B3IS+5sImHYRq3Y\n"
+            + "lhk6XSiPVnzmA2WfWUsP0CDSVMk0caw52rjr1W4WwF3O43SgpEUEWabDZRf/9UrY\n"
+            + "Op7LYxSe0q41XbDCTZYoU+nxubo5q7ctf+m9VQi5CEH1uXFr80c0wRcrau4uD1So\n"
+            + "d7uzs7bfZX/LOdM7drgRPwCjJMhOiCS7AVqST5O4hSpBxg8dOOE=\n"
+            + "=5aNq\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
@@ -446,106 +445,107 @@
    * </pre>
    */
   public static TestKey validKeyWithSecondUserId() {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
-        + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
-        + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
-        + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
-        + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
-        + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAG0IVRlc3R1c2VyIEZpdmUg\n"
-        + "PHRlc3Q1QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgC\n"
-        + "CQoLBBYCAwECHgECF4AACgkQUCS7RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v\n"
-        + "3H/PyhvYF1nuKNftmhqIiUHec9RaUHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVO\n"
-        + "RyQ/Tv7/xtpqGZqivV0yn2ZXbCceA627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu\n"
-        + "/zdUofEbFAvcXs+Z1uXnUDdeGn47Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6W\n"
-        + "paCIGno69CyNHNnWjJCSD33oLVaXyvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fk\n"
-        + "t4jtiGu9aze4n59GbtSjmWQgzbLCQWhK9K7UCcSLYNKXVyMha2WapBO156V027QI\n"
-        + "Zm9vOm15SWSJATgEEwECACIFAlW6jwYCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4B\n"
-        + "AheAAAoJEFAku0SYxR2/zZUH/1BwPsResHLDSmo6UdQyQGxvV0NcwBqGAPSLHr+S\n"
-        + "PHEaHEIYvOywNfWXquYrECa/5iIrXuTQmCH0q8WRcz1UapDCeD8Ui82r+3O8m6gk\n"
-        + "hIR5VAeza+x/fGWhG342PvtpDU7JycDA3KMCTWtcAM89tFhffzuEQ3f5p5cMTtZk\n"
-        + "/23iegXbHd61vojYO17QYEj+qp9l0VNiyFymPL3qr5bVj/xn/mXFj+asj0L2ypIj\n"
-        + "zC36FkhzW5EX2xgV9Cl9zu7kLMTm+yM+jxbMLskYkG8z/D+xBQsoX8tEIPlxHLhB\n"
-        + "miEmVuZrp91ArRMWa3B7PYz7hQzs+M/bxKXcmWxacggTOvy5AQ0EVbqN3gEIAOlq\n"
-        + "mwdiXW0BQP/iQvIweP1taNypAvdjI2fpnXkUfBT5X/+E/RjYOHQEAzy8nEkS+Y0l\n"
-        + "MLwKt3S0IVRvdeXxlpL6Tl+P8DkcD5H+uvACrg9rtgbbNSoQtc9/3bknG9hea6xi\n"
-        + "6SBH1k9Y2RInIrwWslfKmuNkyZVhxPKypasBsvyhOWLlpCngGiCa74KJ1th1WKa2\n"
-        + "aaDqcbieBTc1mtsXR6kBhJZqK+JYBoHriUQMs7nyXxn2qyv6Lehs/tHlrBZ7j16S\n"
-        + "faQzYoBi1edVrpFr/CuGk6RNKxG9vi/uAA9q2cLCMjjyfMH4g0G2l0HuDPQLA9wi\n"
-        + "BfusEC+OceaeFKtS9ykAEQEAAYkBHwQYAQIACQUCVbqN3gIbDAAKCRBQJLtEmMUd\n"
-        + "vw/DB/9Qx9m1eSdddqz/fk16wJf7Ncr2teVvdQOjRf/qo43KDKxEzeepjgypG1br\n"
-        + "St7U4/MlPygJLBDB4pXp0kaKt+S/aqLpEGSGzQ1FysM8oY6K0e1Kbf6nMaQS8ATG\n"
-        + "aD377FrUJ42NV4JS+NGlwaM9PhpRVm5n8iCzRs9HtlTyfCBkNGDjGOSdWcah2m6T\n"
-        + "fEQdD+XVDN1ZC8zAnc8FW28YOTeTjX079okP6ZCjLJ16VZ7eiHFkrNbS9Dl4SPNK\n"
-        + "eElvsZLBaf8t4RQXFFKwRq4BW+zS8zm9E2H6bZ9yGrmgIREzyRPpwU98g8yrabu0\n"
-        + "54w16Vp/SVViJs7nTMSug0WREyd2\n"
-        + "=ldwB\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
+            + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
+            + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
+            + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
+            + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
+            + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAG0IVRlc3R1c2VyIEZpdmUg\n"
+            + "PHRlc3Q1QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgC\n"
+            + "CQoLBBYCAwECHgECF4AACgkQUCS7RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v\n"
+            + "3H/PyhvYF1nuKNftmhqIiUHec9RaUHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVO\n"
+            + "RyQ/Tv7/xtpqGZqivV0yn2ZXbCceA627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu\n"
+            + "/zdUofEbFAvcXs+Z1uXnUDdeGn47Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6W\n"
+            + "paCIGno69CyNHNnWjJCSD33oLVaXyvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fk\n"
+            + "t4jtiGu9aze4n59GbtSjmWQgzbLCQWhK9K7UCcSLYNKXVyMha2WapBO156V027QI\n"
+            + "Zm9vOm15SWSJATgEEwECACIFAlW6jwYCGwMGCwkIBwMCBhUIAgkKCwQWAgMBAh4B\n"
+            + "AheAAAoJEFAku0SYxR2/zZUH/1BwPsResHLDSmo6UdQyQGxvV0NcwBqGAPSLHr+S\n"
+            + "PHEaHEIYvOywNfWXquYrECa/5iIrXuTQmCH0q8WRcz1UapDCeD8Ui82r+3O8m6gk\n"
+            + "hIR5VAeza+x/fGWhG342PvtpDU7JycDA3KMCTWtcAM89tFhffzuEQ3f5p5cMTtZk\n"
+            + "/23iegXbHd61vojYO17QYEj+qp9l0VNiyFymPL3qr5bVj/xn/mXFj+asj0L2ypIj\n"
+            + "zC36FkhzW5EX2xgV9Cl9zu7kLMTm+yM+jxbMLskYkG8z/D+xBQsoX8tEIPlxHLhB\n"
+            + "miEmVuZrp91ArRMWa3B7PYz7hQzs+M/bxKXcmWxacggTOvy5AQ0EVbqN3gEIAOlq\n"
+            + "mwdiXW0BQP/iQvIweP1taNypAvdjI2fpnXkUfBT5X/+E/RjYOHQEAzy8nEkS+Y0l\n"
+            + "MLwKt3S0IVRvdeXxlpL6Tl+P8DkcD5H+uvACrg9rtgbbNSoQtc9/3bknG9hea6xi\n"
+            + "6SBH1k9Y2RInIrwWslfKmuNkyZVhxPKypasBsvyhOWLlpCngGiCa74KJ1th1WKa2\n"
+            + "aaDqcbieBTc1mtsXR6kBhJZqK+JYBoHriUQMs7nyXxn2qyv6Lehs/tHlrBZ7j16S\n"
+            + "faQzYoBi1edVrpFr/CuGk6RNKxG9vi/uAA9q2cLCMjjyfMH4g0G2l0HuDPQLA9wi\n"
+            + "BfusEC+OceaeFKtS9ykAEQEAAYkBHwQYAQIACQUCVbqN3gIbDAAKCRBQJLtEmMUd\n"
+            + "vw/DB/9Qx9m1eSdddqz/fk16wJf7Ncr2teVvdQOjRf/qo43KDKxEzeepjgypG1br\n"
+            + "St7U4/MlPygJLBDB4pXp0kaKt+S/aqLpEGSGzQ1FysM8oY6K0e1Kbf6nMaQS8ATG\n"
+            + "aD377FrUJ42NV4JS+NGlwaM9PhpRVm5n8iCzRs9HtlTyfCBkNGDjGOSdWcah2m6T\n"
+            + "fEQdD+XVDN1ZC8zAnc8FW28YOTeTjX079okP6ZCjLJ16VZ7eiHFkrNbS9Dl4SPNK\n"
+            + "eElvsZLBaf8t4RQXFFKwRq4BW+zS8zm9E2H6bZ9yGrmgIREzyRPpwU98g8yrabu0\n"
+            + "54w16Vp/SVViJs7nTMSug0WREyd2\n"
+            + "=ldwB\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
-        + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
-        + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
-        + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
-        + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
-        + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAEAB/9MIlrQiWb+Gf3fWFh+\n"
-        + "mkg0Bva9p4IfNX1n5S7hGFGnjGzqXaRX6W1e16gh1qM5ZO1IVh9j5kLmnrt4SNhb\n"
-        + "/Irqnq3s14trpoJUBC81bm9JMUESHrLSjdo4OIWJncOP4xd0bG7h+SKYXGLE1+Me\n"
-        + "pqLu65RNebqRcFYM1xAxfCdaxatcz+LrW5ZX+6T/Gh/VCHRkkzzVIZO1dDBbyU2C\n"
-        + "JrNcfHSvNrjzfqYHtwfsk/lwcuY9pqkYcuwZ2IM+iWKit+WyCR2BzOpG/Sva1t8b\n"
-        + "7B7ituQCFMCv5IiaAoaSKX/t/0ucWCoT1ttih8LdwgEE0kgij/ZUfRxCiL9HmtLy\n"
-        + "ad9BBADBGYWv6NiTQiBG7+MZ+twCjlSL7vq8iENhQYZShGHF9z+ju7m8U1dteLny\n"
-        + "pC3NcNfCgWyy+8lRn1e6Oe6m7xL83LL3HJT5nIy9mpsCw/TIrrkzkoE+VpkEIL/o\n"
-        + "Yeoxauah4SU7laVD29aAQZ3TqwSwx0sJwPjsj73WjjqtzJfFkQQA410ghqMbQZN1\n"
-        + "yJzXgVAj162ZwTi961N5iYmqTiBtqGz1UfaNBJWdJMkCmhMTsiOtm1h4zUQRuEH+\n"
-        + "yq1xhKOGf15dB/cLSMj2KpVVlvgLoVmYDugSER8Q23juilY7iaf0bqo9q1sTHpn9\n"
-        + "O7Oin/9J3sz+ic45vDh4aa74sOzfhA8EAJwAFEWLrGSxtnYJR5vQNstHIH1wtQ5G\n"
-        + "ZUZ57y9CbDkKrfCQvd0JOBjfUDz+N8qiamNIqfhQBtlhIDYgtswiG+iGP/2G0l6S\n"
-        + "j9DHNe2CYPUKgy+zQiRnyNGE2XUfcE+HuNDfu3AryPqaD8vLLw8TnsAgis3bRGg+\n"
-        + "hhrAC1NyKfDXTg20IVRlc3R1c2VyIEZpdmUgPHRlc3Q1QGV4YW1wbGUuY29tPokB\n"
-        + "OAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQUCS7\n"
-        + "RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v3H/PyhvYF1nuKNftmhqIiUHec9Ra\n"
-        + "UHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVORyQ/Tv7/xtpqGZqivV0yn2ZXbCce\n"
-        + "A627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu/zdUofEbFAvcXs+Z1uXnUDdeGn47\n"
-        + "Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6WpaCIGno69CyNHNnWjJCSD33oLVaX\n"
-        + "yvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fkt4jtiGu9aze4n59GbtSjmWQgzbLC\n"
-        + "QWhK9K7UCcSLYNKXVyMha2WapBO156V0250DmARVuo3eAQgA6WqbB2JdbQFA/+JC\n"
-        + "8jB4/W1o3KkC92MjZ+mdeRR8FPlf/4T9GNg4dAQDPLycSRL5jSUwvAq3dLQhVG91\n"
-        + "5fGWkvpOX4/wORwPkf668AKuD2u2Bts1KhC1z3/duScb2F5rrGLpIEfWT1jZEici\n"
-        + "vBayV8qa42TJlWHE8rKlqwGy/KE5YuWkKeAaIJrvgonW2HVYprZpoOpxuJ4FNzWa\n"
-        + "2xdHqQGElmor4lgGgeuJRAyzufJfGfarK/ot6Gz+0eWsFnuPXpJ9pDNigGLV51Wu\n"
-        + "kWv8K4aTpE0rEb2+L+4AD2rZwsIyOPJ8wfiDQbaXQe4M9AsD3CIF+6wQL45x5p4U\n"
-        + "q1L3KQARAQABAAf8C+2DsJPpPEnFHY5dZ2zssd6mbihA2414YLYCcw6F7Lh1nGQa\n"
-        + "XuulruAJnk/xGJbco8bTv7g4ecE+tsbfWnnG/QnHeYCsgO6bKRXATcWFSYpyidUn\n"
-        + "2VdzQwBAv1ZtSNhCXlPLn/erzvA2X4QadUwfnvbehWJAHt8ZJmHUr3FtyRUHEdCK\n"
-        + "2EXsBWnzPCcqHZOMvcbSINSqBFGzVXkOZsMFvPTNIUYRHz8NbJT/OPiOmyBshXpS\n"
-        + "t8w3QqZhBcTT3NZo3kgxN1RygaTa10ytB2cxTCVuD8hmUBaV9gakdfMYkVJds7/T\n"
-        + "ZY3It68F0vitBnqpppZQ+NFgr/vwVg0p3gbmAQQA79zsWPvyIqYvyJhmiKvLIpev\n"
-        + "569ho8tC9xx+IZ5WnjN8ZADlb9brAdA9cqGfBgZkpZUhngCRVOYUIco+m2NYkEJm\n"
-        + "BsSTTM77dqU55DRloJ3FtBwCPXHkwg9P/FHMMYYGyLpQTSB92hXk8yomo+ozX7kx\n"
-        + "DtUHZIrir/rr0lQe+GkEAPkep9V5jBmfHMArnfji7Nfb1/ZjrSAaK+rtqczgm+6j\n"
-        + "ubY/0DpM/6gm+/8X27WFw2m45ncH3qNvOe4Qm40EmgmHkXsdQyU0Fv7uXc9nBYoo\n"
-        + "G6s7DWLY4VAqWwPsvbqgpSp/qdGn9nlcJjjY1HtfU7HM3xysT7TJ2YVhYHlJdjDB\n"
-        + "A/0alBcYtHvaCJaRLWX4UiashbfETWAf/4oHlERjkXj64qOdsGnD6CD99t9x91Ue\n"
-        + "pClPsLDFvY8/HxWX7STA9pQZAa2ZdJd8b58Rgy9TBShw2mbz2S6Cbw77pP/WEjtJ\n"
-        + "pJuS2gDp70H01fYRaw7YH32CfUr1VeEv7hTjk/SNVteIZkkOiQEfBBgBAgAJBQJV\n"
-        + "uo3eAhsMAAoJEFAku0SYxR2/D8MH/1DH2bV5J112rP9+TXrAl/s1yva15W91A6NF\n"
-        + "/+qjjcoMrETN56mODKkbVutK3tTj8yU/KAksEMHilenSRoq35L9qoukQZIbNDUXK\n"
-        + "wzyhjorR7Upt/qcxpBLwBMZoPfvsWtQnjY1XglL40aXBoz0+GlFWbmfyILNGz0e2\n"
-        + "VPJ8IGQ0YOMY5J1ZxqHabpN8RB0P5dUM3VkLzMCdzwVbbxg5N5ONfTv2iQ/pkKMs\n"
-        + "nXpVnt6IcWSs1tL0OXhI80p4SW+xksFp/y3hFBcUUrBGrgFb7NLzOb0TYfptn3Ia\n"
-        + "uaAhETPJE+nBT3yDzKtpu7TnjDXpWn9JVWImzudMxK6DRZETJ3Y=\n"
-        + "=uND5\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFW6jd4BCACrf+BZS3lntuWq2DWPOG07/BUWhx3RSoiS3JBuKEDmlsjswKcp\n"
+            + "JHT+p2tqH52XbujMlzNjAQcZjJwfOMt6Fg7zd3F8cwYQdCE/W5dpMs/mqdeEz6GL\n"
+            + "VJDZ0Y5wwz54ZQHp91Xq6uejxt5qffeTQk5cToQZ0RVx3iwBc+2P3iYJqMFmJzj8\n"
+            + "djabEoF4D50iI5tY8moE83VcXJ5Y4xn+5Z5AThmlfrMP6gIdG0b4lEe1tsnJC6AG\n"
+            + "GUU6VkzK6E1Tp93Y0brtWpJKi9Gt6eUqvWhZtPEdFVCFbLTpezUdRFEuaFbGg5pn\n"
+            + "9K/DceahFmquDJOHVgawt6erlq/ie7QEEld/ABEBAAEAB/9MIlrQiWb+Gf3fWFh+\n"
+            + "mkg0Bva9p4IfNX1n5S7hGFGnjGzqXaRX6W1e16gh1qM5ZO1IVh9j5kLmnrt4SNhb\n"
+            + "/Irqnq3s14trpoJUBC81bm9JMUESHrLSjdo4OIWJncOP4xd0bG7h+SKYXGLE1+Me\n"
+            + "pqLu65RNebqRcFYM1xAxfCdaxatcz+LrW5ZX+6T/Gh/VCHRkkzzVIZO1dDBbyU2C\n"
+            + "JrNcfHSvNrjzfqYHtwfsk/lwcuY9pqkYcuwZ2IM+iWKit+WyCR2BzOpG/Sva1t8b\n"
+            + "7B7ituQCFMCv5IiaAoaSKX/t/0ucWCoT1ttih8LdwgEE0kgij/ZUfRxCiL9HmtLy\n"
+            + "ad9BBADBGYWv6NiTQiBG7+MZ+twCjlSL7vq8iENhQYZShGHF9z+ju7m8U1dteLny\n"
+            + "pC3NcNfCgWyy+8lRn1e6Oe6m7xL83LL3HJT5nIy9mpsCw/TIrrkzkoE+VpkEIL/o\n"
+            + "Yeoxauah4SU7laVD29aAQZ3TqwSwx0sJwPjsj73WjjqtzJfFkQQA410ghqMbQZN1\n"
+            + "yJzXgVAj162ZwTi961N5iYmqTiBtqGz1UfaNBJWdJMkCmhMTsiOtm1h4zUQRuEH+\n"
+            + "yq1xhKOGf15dB/cLSMj2KpVVlvgLoVmYDugSER8Q23juilY7iaf0bqo9q1sTHpn9\n"
+            + "O7Oin/9J3sz+ic45vDh4aa74sOzfhA8EAJwAFEWLrGSxtnYJR5vQNstHIH1wtQ5G\n"
+            + "ZUZ57y9CbDkKrfCQvd0JOBjfUDz+N8qiamNIqfhQBtlhIDYgtswiG+iGP/2G0l6S\n"
+            + "j9DHNe2CYPUKgy+zQiRnyNGE2XUfcE+HuNDfu3AryPqaD8vLLw8TnsAgis3bRGg+\n"
+            + "hhrAC1NyKfDXTg20IVRlc3R1c2VyIEZpdmUgPHRlc3Q1QGV4YW1wbGUuY29tPokB\n"
+            + "OAQTAQIAIgUCVbqN3gIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQUCS7\n"
+            + "RJjFHb+/MAf9FKZatGcuOIoYqwGQQneyc63v3H/PyhvYF1nuKNftmhqIiUHec9Ra\n"
+            + "UHQkgam6LRoonkDfIpNlQVRv2XBV2VOAOFVORyQ/Tv7/xtpqGZqivV0yn2ZXbCce\n"
+            + "A627Vz7gP4gkO0ZJ0JsYJTc/5wO+nVG5Lohu/zdUofEbFAvcXs+Z1uXnUDdeGn47\n"
+            + "Lf1xZ2XOHOI0aQW4DdNaFoAd+AOTe0W3iB6WpaCIGno69CyNHNnWjJCSD33oLVaX\n"
+            + "yvbgw5UoyITvSqRnPyLGIc6dsqDLT59ok0Fkt4jtiGu9aze4n59GbtSjmWQgzbLC\n"
+            + "QWhK9K7UCcSLYNKXVyMha2WapBO156V0250DmARVuo3eAQgA6WqbB2JdbQFA/+JC\n"
+            + "8jB4/W1o3KkC92MjZ+mdeRR8FPlf/4T9GNg4dAQDPLycSRL5jSUwvAq3dLQhVG91\n"
+            + "5fGWkvpOX4/wORwPkf668AKuD2u2Bts1KhC1z3/duScb2F5rrGLpIEfWT1jZEici\n"
+            + "vBayV8qa42TJlWHE8rKlqwGy/KE5YuWkKeAaIJrvgonW2HVYprZpoOpxuJ4FNzWa\n"
+            + "2xdHqQGElmor4lgGgeuJRAyzufJfGfarK/ot6Gz+0eWsFnuPXpJ9pDNigGLV51Wu\n"
+            + "kWv8K4aTpE0rEb2+L+4AD2rZwsIyOPJ8wfiDQbaXQe4M9AsD3CIF+6wQL45x5p4U\n"
+            + "q1L3KQARAQABAAf8C+2DsJPpPEnFHY5dZ2zssd6mbihA2414YLYCcw6F7Lh1nGQa\n"
+            + "XuulruAJnk/xGJbco8bTv7g4ecE+tsbfWnnG/QnHeYCsgO6bKRXATcWFSYpyidUn\n"
+            + "2VdzQwBAv1ZtSNhCXlPLn/erzvA2X4QadUwfnvbehWJAHt8ZJmHUr3FtyRUHEdCK\n"
+            + "2EXsBWnzPCcqHZOMvcbSINSqBFGzVXkOZsMFvPTNIUYRHz8NbJT/OPiOmyBshXpS\n"
+            + "t8w3QqZhBcTT3NZo3kgxN1RygaTa10ytB2cxTCVuD8hmUBaV9gakdfMYkVJds7/T\n"
+            + "ZY3It68F0vitBnqpppZQ+NFgr/vwVg0p3gbmAQQA79zsWPvyIqYvyJhmiKvLIpev\n"
+            + "569ho8tC9xx+IZ5WnjN8ZADlb9brAdA9cqGfBgZkpZUhngCRVOYUIco+m2NYkEJm\n"
+            + "BsSTTM77dqU55DRloJ3FtBwCPXHkwg9P/FHMMYYGyLpQTSB92hXk8yomo+ozX7kx\n"
+            + "DtUHZIrir/rr0lQe+GkEAPkep9V5jBmfHMArnfji7Nfb1/ZjrSAaK+rtqczgm+6j\n"
+            + "ubY/0DpM/6gm+/8X27WFw2m45ncH3qNvOe4Qm40EmgmHkXsdQyU0Fv7uXc9nBYoo\n"
+            + "G6s7DWLY4VAqWwPsvbqgpSp/qdGn9nlcJjjY1HtfU7HM3xysT7TJ2YVhYHlJdjDB\n"
+            + "A/0alBcYtHvaCJaRLWX4UiashbfETWAf/4oHlERjkXj64qOdsGnD6CD99t9x91Ue\n"
+            + "pClPsLDFvY8/HxWX7STA9pQZAa2ZdJd8b58Rgy9TBShw2mbz2S6Cbw77pP/WEjtJ\n"
+            + "pJuS2gDp70H01fYRaw7YH32CfUr1VeEv7hTjk/SNVteIZkkOiQEfBBgBAgAJBQJV\n"
+            + "uo3eAhsMAAoJEFAku0SYxR2/D8MH/1DH2bV5J112rP9+TXrAl/s1yva15W91A6NF\n"
+            + "/+qjjcoMrETN56mODKkbVutK3tTj8yU/KAksEMHilenSRoq35L9qoukQZIbNDUXK\n"
+            + "wzyhjorR7Upt/qcxpBLwBMZoPfvsWtQnjY1XglL40aXBoz0+GlFWbmfyILNGz0e2\n"
+            + "VPJ8IGQ0YOMY5J1ZxqHabpN8RB0P5dUM3VkLzMCdzwVbbxg5N5ONfTv2iQ/pkKMs\n"
+            + "nXpVnt6IcWSs1tL0OXhI80p4SW+xksFp/y3hFBcUUrBGrgFb7NLzOb0TYfptn3Ia\n"
+            + "uaAhETPJE+nBT3yDzKtpu7TnjDXpWn9JVWImzudMxK6DRZETJ3Y=\n"
+            + "=uND5\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
    * A key revoked by a valid key, due to key compromise.
-   * <p>
-   * Revoked by {@link #validKeyWithoutExpiration()}.
+   *
+   * <p>Revoked by {@link #validKeyWithoutExpiration()}.
    *
    * <pre>
    * pub   2048R/3434B39F 2015-10-20 [revoked: 2015-10-20]
@@ -554,119 +554,120 @@
    * </pre>
    */
   public static TestKey revokedCompromisedKey() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBFYmpXkBCACqaLz51DcWQmfOJnat9iHSySfSHwbKfVvoN43Ba2cf/D/PadRc\n"
-        + "HLgc+91k2yk1kV1LnMdvUGj5zZ84ZqrQx3f1WeItnzZpqxtmQS/GSxCp9EY/s7w6\n"
-        + "5i86R/k9Tzgvk0B7dKZJXbM/OWxxDkkxHWE3Un9wreX7bDU5b9D2knHRiNFqH9ZJ\n"
-        + "KqDIFZqH9WTUxNZcHz20sTCRIMfvsAwf2vRU5N5xTu4Mbk6JFc7BAj7h1f/mYEPo\n"
-        + "CTyB1jV/DSDVdn1FjJVocSg6W/CvsYF9hKFYjJHl4VXdePTpnOjHhJLL0QWk0TMe\n"
-        + "xYeUi/xDr5DeMxTmi7F7BFaQEF+KmUM46e+9ABEBAAGJATAEIAECABoFAlYmq1gT\n"
-        + "HQJ0ZXN0NiBjb21wcm9taXNlZAAKCRDtBiXcRjKKjIm6B/9YwkyG4w+9KUNESywM\n"
-        + "bxC2WWGWrFcQGoKxixzt0uT251UY8qxa1IED0wnLsIQmffTQcnrK3B9svd4HhQlk\n"
-        + "pheKQ3w5iluLeGmGljhDBdAVyS07jYoFUGTXjwzPAgJ3Dxzul8Q8Zj+fOmRcfsP9\n"
-        + "72kl6g2yEEbevnydWIiOj/vWHVLFb54G8bwXTNwH/FXQsHuPYxXZifwyDwdwEQMq\n"
-        + "0VTZcrukgeJ+VbSSuq+uX4I3+kJw5hL49KYAQltQBmTo3yhuY/Q+LkgcBv/umtY/\n"
-        + "DrUqSCBV1bTnfq5SfaObkUu22HWjrtSFSjnXYyh+wyTG3AXG3N9VPrjGQIJIW1j6\n"
-        + "9QM0iQE3BB8BAgAhBQJWJqYUFwyAAQSup+0vghEz5bEo0e0GJdxGMoqMAgcAAAoJ\n"
-        + "EIxP0o40NLOfYd4H/3GpfxfJ+nMzBChn1JqFrKOqqYiOO4sUwubXzpRO33V2jUrU\n"
-        + "V75PTWG/6NlgDbPfKFcU0qZud6M2EQxSS9/I20i/MpRB7qJnWMM/6HxdMDJ0o/pN\n"
-        + "4ImIGj38QTIWx0DS9n3bwlcobl7ZlM8g2N1kv5jQPEuurffeJRS4ny4pEvCCm2IS\n"
-        + "SGOuB0DVtYHGDrJLQ0k4mDkEJuU8fP5un8mN8I8eAINlsTFpsTswMXMiptZTm5SI\n"
-        + "5QZlG3m5MvvckngYdhynvCWc6JHGt1EHXlI4A5Qetr/4FbNE4uYcEEhyzBy4WQfi\n"
-        + "QCPiIzzm3O4cMnr9N+5HzYqRhu2OveYm86G2Rxq0IFRlc3R1c2VyIFNpeCA8dGVz\n"
-        + "dDZAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJWJqV5AhsDBgsJCAcDAgYVCAIJCgsE\n"
-        + "FgIDAQIeAQIXgAAKCRCMT9KONDSzn2XtB/4wl4ctc3cW9Fwp17cktFi6md8fjRiR\n"
-        + "wE/ruVKIKmAHzeMLBoZn4LZVunyNCRGLZfP+MUs4JhLkp8ioTzUB7xPl9k94FXel\n"
-        + "bObn9F0T7htjFLiFAOMeykneylk2kalTt6IBKtaOPn+V6onBwO+YHbwt+xLMhAWj\n"
-        + "Z/WA0TIC1RIukdzWErhd+9lG8B9kupGC5bPo/AgCPoajPhS1qLrth+lCsNJXT/Rt\n"
-        + "k6Jx5omypxMXPzgzNtULMFONszaRnHnrCHQg/yJZDCw3ffW5ShfyfWdFM65jgEKo\n"
-        + "nMKLzy9XV+BM6IJQlgHCBAP8WHKSf4qMG4/hEWLrwA/bTQ7w0DSV88msuQENBFYm\n"
-        + "pXkBCACzIMFDC6kcV58uvF3XwOrS3DmKNPDNzO/4Ay/iOxZbm+9NP8QWEEm+AzCt\n"
-        + "ZMfYdZ8C3DjuzxkhcacI/E5agZICds6bs0+VS7VKEeNYp/UrTF9pkZNXseCrJPgr\n"
-        + "U31eoGVc5bE5c0TGLhAjbMKtR5LZFMpAXgpA7hXJSSuAXGs8gjkJkYSJYnJwIOyd\n"
-        + "xOi5jmnE/U5QuMjBG0bwxFXxkaDa5mcebJ/6C8mgkKyATbQkCe7YJGl1JLK4vY28\n"
-        + "ybSMhMDtZiwgvKzd+HcQr+xUQvmgSMApJaMxKPHRA1IrP/STXUEAjcGfk/HCz/0j\n"
-        + "7mJG2cvCxeOMAmp/pTzhSoXiqUNlABEBAAGJAR8EGAECAAkFAlYmpXkCGwwACgkQ\n"
-        + "jE/SjjQ0s5/kVAf/QvHOhuoBSlSxPcgvnvCl8V3zbNR1P9lgjYGwMsvLhwCT7Wvm\n"
-        + "mkUKvtT913uER93N8xJD2svGhKabpiPj9/eo0p3p64dicijsP1UQfpmWKPa/V9sv\n"
-        + "zep08cpDl/eczSiLqgcTXCoZeewWXoQGqqoXnwa4lwQv4Zvj7TTCN2wRzoGwbRcm\n"
-        + "G2hmc27uOwA+hXbF+bLe6HOZR/7U93j8a22g2X9OgST/QCsLgyiUSw3YYaEan9tn\n"
-        + "wuEgAEY/rchOvgeXe5Sl0wTFLHH6OS4BBGgc1LRKnSCM2dgZqvhOOxOvuuieBWY6\n"
-        + "tULvIEIjNNP8Qizfc4u2O8h7HP2b3yYSrp9MMQ==\n"
-        + "=Dxr7\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFYmpXkBCACqaLz51DcWQmfOJnat9iHSySfSHwbKfVvoN43Ba2cf/D/PadRc\n"
+            + "HLgc+91k2yk1kV1LnMdvUGj5zZ84ZqrQx3f1WeItnzZpqxtmQS/GSxCp9EY/s7w6\n"
+            + "5i86R/k9Tzgvk0B7dKZJXbM/OWxxDkkxHWE3Un9wreX7bDU5b9D2knHRiNFqH9ZJ\n"
+            + "KqDIFZqH9WTUxNZcHz20sTCRIMfvsAwf2vRU5N5xTu4Mbk6JFc7BAj7h1f/mYEPo\n"
+            + "CTyB1jV/DSDVdn1FjJVocSg6W/CvsYF9hKFYjJHl4VXdePTpnOjHhJLL0QWk0TMe\n"
+            + "xYeUi/xDr5DeMxTmi7F7BFaQEF+KmUM46e+9ABEBAAGJATAEIAECABoFAlYmq1gT\n"
+            + "HQJ0ZXN0NiBjb21wcm9taXNlZAAKCRDtBiXcRjKKjIm6B/9YwkyG4w+9KUNESywM\n"
+            + "bxC2WWGWrFcQGoKxixzt0uT251UY8qxa1IED0wnLsIQmffTQcnrK3B9svd4HhQlk\n"
+            + "pheKQ3w5iluLeGmGljhDBdAVyS07jYoFUGTXjwzPAgJ3Dxzul8Q8Zj+fOmRcfsP9\n"
+            + "72kl6g2yEEbevnydWIiOj/vWHVLFb54G8bwXTNwH/FXQsHuPYxXZifwyDwdwEQMq\n"
+            + "0VTZcrukgeJ+VbSSuq+uX4I3+kJw5hL49KYAQltQBmTo3yhuY/Q+LkgcBv/umtY/\n"
+            + "DrUqSCBV1bTnfq5SfaObkUu22HWjrtSFSjnXYyh+wyTG3AXG3N9VPrjGQIJIW1j6\n"
+            + "9QM0iQE3BB8BAgAhBQJWJqYUFwyAAQSup+0vghEz5bEo0e0GJdxGMoqMAgcAAAoJ\n"
+            + "EIxP0o40NLOfYd4H/3GpfxfJ+nMzBChn1JqFrKOqqYiOO4sUwubXzpRO33V2jUrU\n"
+            + "V75PTWG/6NlgDbPfKFcU0qZud6M2EQxSS9/I20i/MpRB7qJnWMM/6HxdMDJ0o/pN\n"
+            + "4ImIGj38QTIWx0DS9n3bwlcobl7ZlM8g2N1kv5jQPEuurffeJRS4ny4pEvCCm2IS\n"
+            + "SGOuB0DVtYHGDrJLQ0k4mDkEJuU8fP5un8mN8I8eAINlsTFpsTswMXMiptZTm5SI\n"
+            + "5QZlG3m5MvvckngYdhynvCWc6JHGt1EHXlI4A5Qetr/4FbNE4uYcEEhyzBy4WQfi\n"
+            + "QCPiIzzm3O4cMnr9N+5HzYqRhu2OveYm86G2Rxq0IFRlc3R1c2VyIFNpeCA8dGVz\n"
+            + "dDZAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJWJqV5AhsDBgsJCAcDAgYVCAIJCgsE\n"
+            + "FgIDAQIeAQIXgAAKCRCMT9KONDSzn2XtB/4wl4ctc3cW9Fwp17cktFi6md8fjRiR\n"
+            + "wE/ruVKIKmAHzeMLBoZn4LZVunyNCRGLZfP+MUs4JhLkp8ioTzUB7xPl9k94FXel\n"
+            + "bObn9F0T7htjFLiFAOMeykneylk2kalTt6IBKtaOPn+V6onBwO+YHbwt+xLMhAWj\n"
+            + "Z/WA0TIC1RIukdzWErhd+9lG8B9kupGC5bPo/AgCPoajPhS1qLrth+lCsNJXT/Rt\n"
+            + "k6Jx5omypxMXPzgzNtULMFONszaRnHnrCHQg/yJZDCw3ffW5ShfyfWdFM65jgEKo\n"
+            + "nMKLzy9XV+BM6IJQlgHCBAP8WHKSf4qMG4/hEWLrwA/bTQ7w0DSV88msuQENBFYm\n"
+            + "pXkBCACzIMFDC6kcV58uvF3XwOrS3DmKNPDNzO/4Ay/iOxZbm+9NP8QWEEm+AzCt\n"
+            + "ZMfYdZ8C3DjuzxkhcacI/E5agZICds6bs0+VS7VKEeNYp/UrTF9pkZNXseCrJPgr\n"
+            + "U31eoGVc5bE5c0TGLhAjbMKtR5LZFMpAXgpA7hXJSSuAXGs8gjkJkYSJYnJwIOyd\n"
+            + "xOi5jmnE/U5QuMjBG0bwxFXxkaDa5mcebJ/6C8mgkKyATbQkCe7YJGl1JLK4vY28\n"
+            + "ybSMhMDtZiwgvKzd+HcQr+xUQvmgSMApJaMxKPHRA1IrP/STXUEAjcGfk/HCz/0j\n"
+            + "7mJG2cvCxeOMAmp/pTzhSoXiqUNlABEBAAGJAR8EGAECAAkFAlYmpXkCGwwACgkQ\n"
+            + "jE/SjjQ0s5/kVAf/QvHOhuoBSlSxPcgvnvCl8V3zbNR1P9lgjYGwMsvLhwCT7Wvm\n"
+            + "mkUKvtT913uER93N8xJD2svGhKabpiPj9/eo0p3p64dicijsP1UQfpmWKPa/V9sv\n"
+            + "zep08cpDl/eczSiLqgcTXCoZeewWXoQGqqoXnwa4lwQv4Zvj7TTCN2wRzoGwbRcm\n"
+            + "G2hmc27uOwA+hXbF+bLe6HOZR/7U93j8a22g2X9OgST/QCsLgyiUSw3YYaEan9tn\n"
+            + "wuEgAEY/rchOvgeXe5Sl0wTFLHH6OS4BBGgc1LRKnSCM2dgZqvhOOxOvuuieBWY6\n"
+            + "tULvIEIjNNP8Qizfc4u2O8h7HP2b3yYSrp9MMQ==\n"
+            + "=Dxr7\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBFYmpXkBCACqaLz51DcWQmfOJnat9iHSySfSHwbKfVvoN43Ba2cf/D/PadRc\n"
-        + "HLgc+91k2yk1kV1LnMdvUGj5zZ84ZqrQx3f1WeItnzZpqxtmQS/GSxCp9EY/s7w6\n"
-        + "5i86R/k9Tzgvk0B7dKZJXbM/OWxxDkkxHWE3Un9wreX7bDU5b9D2knHRiNFqH9ZJ\n"
-        + "KqDIFZqH9WTUxNZcHz20sTCRIMfvsAwf2vRU5N5xTu4Mbk6JFc7BAj7h1f/mYEPo\n"
-        + "CTyB1jV/DSDVdn1FjJVocSg6W/CvsYF9hKFYjJHl4VXdePTpnOjHhJLL0QWk0TMe\n"
-        + "xYeUi/xDr5DeMxTmi7F7BFaQEF+KmUM46e+9ABEBAAEAB/wOspbuA1A3AsY6QRYG\n"
-        + "Xg6/w+rD1Do9N7+4ESaQUqej2hlU1d9jjHSSx2RqgP6WaLG/xkdrQeez9/iuICjG\n"
-        + "dhXSGw0He05xobjswl2RAENxLSjr8KAhAl57a97C23TQoaYzn7WB6Wt+3gCM5bsJ\n"
-        + "WevbHinwuYb2/ve+OvcudSYM+Nhtpv0DoTaizhi9wzc3g/XLbturlpdCffbw4y+h\n"
-        + "gBPd/t3cc/0Ams8Wi2RlmDOoe73ls23nBHcNomgydyIYBn7U5Z3v3YkPNp9VBiXx\n"
-        + "rC4mDtB1ugucMhqjRNAYqinaLP35CiBTU/IB0WLu7ZyytnjY5frly1ShAG8wFL0B\n"
-        + "MOMxBADJjGy1NwGSd/7eMeYyYThyhXDxo5so91/O1+RLnSUVv/Nz6VOPp2TtuVN5\n"
-        + "uTJkpSXtUFyWbf8mkQiFz4++vHW5E/Q6+KomXRalK7JeBzeFMtax64ykQHID9cSu\n"
-        + "TaSHBhOEEeZZuf6BlulYEJEBHYK6EFlPJn+cpZtTFaqDoKh22QQA2HKjfyeppNre\n"
-        + "WRFJ9h1x1hBlSRR+XIPYmDmZUjL37jQUlw8iF+txPclfyNBw2I2Om+Jhcf25peOx\n"
-        + "ow4yvjt8r3qDjNhI2zLE9u4zrQ9xU8CUingT0t4k3NO2vigpKlmp1/w2IHSMctry\n"
-        + "v1v3+BAS8qGIYDY1lgI7QBvle5hxGYUD/00zMyHOIgYg/cM5sR0qafesoj9kRff5\n"
-        + "UMnSy1dw+pGMv6GqKGbcZDoC060hUO9GhQRPZXF8PlYzD30lOLS2Uw4mPXjOmQVv\n"
-        + "lDiyl/vLkfkVfP/alYH0FW6mErDrjtHhrZewqDm3iPLGMVGfGCJsL+N37VBSe+jr\n"
-        + "4rZCnjk/Jo5JRoKJATcEHwECACEFAlYmphQXDIABBK6n7S+CETPlsSjR7QYl3EYy\n"
-        + "iowCBwAACgkQjE/SjjQ0s59h3gf/cal/F8n6czMEKGfUmoWso6qpiI47ixTC5tfO\n"
-        + "lE7fdXaNStRXvk9NYb/o2WANs98oVxTSpm53ozYRDFJL38jbSL8ylEHuomdYwz/o\n"
-        + "fF0wMnSj+k3giYgaPfxBMhbHQNL2fdvCVyhuXtmUzyDY3WS/mNA8S66t994lFLif\n"
-        + "LikS8IKbYhJIY64HQNW1gcYOsktDSTiYOQQm5Tx8/m6fyY3wjx4Ag2WxMWmxOzAx\n"
-        + "cyKm1lOblIjlBmUbebky+9ySeBh2HKe8JZzokca3UQdeUjgDlB62v/gVs0Ti5hwQ\n"
-        + "SHLMHLhZB+JAI+IjPObc7hwyev037kfNipGG7Y695ibzobZHGrQgVGVzdHVzZXIg\n"
-        + "U2l4IDx0ZXN0NkBleGFtcGxlLmNvbT6JATgEEwECACIFAlYmpXkCGwMGCwkIBwMC\n"
-        + "BhUIAgkKCwQWAgMBAh4BAheAAAoJEIxP0o40NLOfZe0H/jCXhy1zdxb0XCnXtyS0\n"
-        + "WLqZ3x+NGJHAT+u5UogqYAfN4wsGhmfgtlW6fI0JEYtl8/4xSzgmEuSnyKhPNQHv\n"
-        + "E+X2T3gVd6Vs5uf0XRPuG2MUuIUA4x7KSd7KWTaRqVO3ogEq1o4+f5XqicHA75gd\n"
-        + "vC37EsyEBaNn9YDRMgLVEi6R3NYSuF372UbwH2S6kYLls+j8CAI+hqM+FLWouu2H\n"
-        + "6UKw0ldP9G2TonHmibKnExc/ODM21QswU42zNpGceesIdCD/IlkMLDd99blKF/J9\n"
-        + "Z0UzrmOAQqicwovPL1dX4EzoglCWAcIEA/xYcpJ/iowbj+ERYuvAD9tNDvDQNJXz\n"
-        + "yaydA5gEVialeQEIALMgwUMLqRxXny68XdfA6tLcOYo08M3M7/gDL+I7Flub700/\n"
-        + "xBYQSb4DMK1kx9h1nwLcOO7PGSFxpwj8TlqBkgJ2zpuzT5VLtUoR41in9StMX2mR\n"
-        + "k1ex4Ksk+CtTfV6gZVzlsTlzRMYuECNswq1HktkUykBeCkDuFclJK4BcazyCOQmR\n"
-        + "hIlicnAg7J3E6LmOacT9TlC4yMEbRvDEVfGRoNrmZx5sn/oLyaCQrIBNtCQJ7tgk\n"
-        + "aXUksri9jbzJtIyEwO1mLCC8rN34dxCv7FRC+aBIwCklozEo8dEDUis/9JNdQQCN\n"
-        + "wZ+T8cLP/SPuYkbZy8LF44wCan+lPOFKheKpQ2UAEQEAAQAH/A1Os+Tb9yiGnuoN\n"
-        + "LuiSKa/YEgNBOxmC7dnuPK6xJpBQNZc200WzWJMf8AwVpl4foNxIyYb+Rjbsl1Ts\n"
-        + "z5JcOWFq+57oE5O7D+EMkqf5tFZO4nC4kqprac41HSW02mW/A0DDRKcIt/WEIwlK\n"
-        + "sWzHmjJ736moAtl/holRYQS0ePgB8bUPDQcFovH6X3SUxlPGTYD1DEX+WNvYRk3r\n"
-        + "pa9YXH65qbG9CEJIFTmwZIRDl+CBtBlN/fKadyMJr9fXtv7Fu9hNsK1K1pUtLqCa\n"
-        + "nc22Zak+o+LCPlZ8vmw/UmOGtp2iZlEragmh2rOywp0dHF7gsdlgoafQf8Q4NIag\n"
-        + "TFyHf1kEAMSOKUUwLBEmPnDVfoEOt5spQLVtlF8sh/Okk9zVazWmw0n/b1Ef72z6\n"
-        + "EZqCW9/XhH5pXfKJeV+08hroHI6a5UESa7/xOIx50TaQdRqjwGciMnH2LJcpIU/L\n"
-        + "f0cGXcnTLKt4Z2GeSPKFTj4VzwmwH5F/RYdc5eiVb7VNoy9DC5RZBADpTVH5pklS\n"
-        + "44VDJIcwSNy1LBEU3oj+Nu+sufCimJ5B7HLokoJtm6q8VQRga5hN1TZkdQcLy+b2\n"
-        + "wzxHYoIsIsYFfG/mqLZ3LJNDFqze1/Kj987DYSUGeNYexMN2Fkzbo35Jf0cpOiao\n"
-        + "390JFOS7qecUak5/yJ/V4xy8/nds37617QP9GWlFBykDoESBC2AIz8wXcpUBVNeH\n"
-        + "BNSthmC+PJPhsS6jTQuipqtXUZBgZBrMHp/bA8gTOkI4rPXycH3+ACbuQMAjbFny\n"
-        + "Kt69lPHD8VWw/82E4EY2J9LmHli+2BcATz89ouC4kqC5zF90qJseviSZPihpnFxA\n"
-        + "1UqMU2ZjsPb4CM9C/YkBHwQYAQIACQUCVialeQIbDAAKCRCMT9KONDSzn+RUB/9C\n"
-        + "8c6G6gFKVLE9yC+e8KXxXfNs1HU/2WCNgbAyy8uHAJPta+aaRQq+1P3Xe4RH3c3z\n"
-        + "EkPay8aEppumI+P396jSnenrh2JyKOw/VRB+mZYo9r9X2y/N6nTxykOX95zNKIuq\n"
-        + "BxNcKhl57BZehAaqqhefBriXBC/hm+PtNMI3bBHOgbBtFyYbaGZzbu47AD6FdsX5\n"
-        + "st7oc5lH/tT3ePxrbaDZf06BJP9AKwuDKJRLDdhhoRqf22fC4SAARj+tyE6+B5d7\n"
-        + "lKXTBMUscfo5LgEEaBzUtEqdIIzZ2Bmq+E47E6+66J4FZjq1Qu8gQiM00/xCLN9z\n"
-        + "i7Y7yHsc/ZvfJhKun0wx\n"
-        + "=M/kw\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFYmpXkBCACqaLz51DcWQmfOJnat9iHSySfSHwbKfVvoN43Ba2cf/D/PadRc\n"
+            + "HLgc+91k2yk1kV1LnMdvUGj5zZ84ZqrQx3f1WeItnzZpqxtmQS/GSxCp9EY/s7w6\n"
+            + "5i86R/k9Tzgvk0B7dKZJXbM/OWxxDkkxHWE3Un9wreX7bDU5b9D2knHRiNFqH9ZJ\n"
+            + "KqDIFZqH9WTUxNZcHz20sTCRIMfvsAwf2vRU5N5xTu4Mbk6JFc7BAj7h1f/mYEPo\n"
+            + "CTyB1jV/DSDVdn1FjJVocSg6W/CvsYF9hKFYjJHl4VXdePTpnOjHhJLL0QWk0TMe\n"
+            + "xYeUi/xDr5DeMxTmi7F7BFaQEF+KmUM46e+9ABEBAAEAB/wOspbuA1A3AsY6QRYG\n"
+            + "Xg6/w+rD1Do9N7+4ESaQUqej2hlU1d9jjHSSx2RqgP6WaLG/xkdrQeez9/iuICjG\n"
+            + "dhXSGw0He05xobjswl2RAENxLSjr8KAhAl57a97C23TQoaYzn7WB6Wt+3gCM5bsJ\n"
+            + "WevbHinwuYb2/ve+OvcudSYM+Nhtpv0DoTaizhi9wzc3g/XLbturlpdCffbw4y+h\n"
+            + "gBPd/t3cc/0Ams8Wi2RlmDOoe73ls23nBHcNomgydyIYBn7U5Z3v3YkPNp9VBiXx\n"
+            + "rC4mDtB1ugucMhqjRNAYqinaLP35CiBTU/IB0WLu7ZyytnjY5frly1ShAG8wFL0B\n"
+            + "MOMxBADJjGy1NwGSd/7eMeYyYThyhXDxo5so91/O1+RLnSUVv/Nz6VOPp2TtuVN5\n"
+            + "uTJkpSXtUFyWbf8mkQiFz4++vHW5E/Q6+KomXRalK7JeBzeFMtax64ykQHID9cSu\n"
+            + "TaSHBhOEEeZZuf6BlulYEJEBHYK6EFlPJn+cpZtTFaqDoKh22QQA2HKjfyeppNre\n"
+            + "WRFJ9h1x1hBlSRR+XIPYmDmZUjL37jQUlw8iF+txPclfyNBw2I2Om+Jhcf25peOx\n"
+            + "ow4yvjt8r3qDjNhI2zLE9u4zrQ9xU8CUingT0t4k3NO2vigpKlmp1/w2IHSMctry\n"
+            + "v1v3+BAS8qGIYDY1lgI7QBvle5hxGYUD/00zMyHOIgYg/cM5sR0qafesoj9kRff5\n"
+            + "UMnSy1dw+pGMv6GqKGbcZDoC060hUO9GhQRPZXF8PlYzD30lOLS2Uw4mPXjOmQVv\n"
+            + "lDiyl/vLkfkVfP/alYH0FW6mErDrjtHhrZewqDm3iPLGMVGfGCJsL+N37VBSe+jr\n"
+            + "4rZCnjk/Jo5JRoKJATcEHwECACEFAlYmphQXDIABBK6n7S+CETPlsSjR7QYl3EYy\n"
+            + "iowCBwAACgkQjE/SjjQ0s59h3gf/cal/F8n6czMEKGfUmoWso6qpiI47ixTC5tfO\n"
+            + "lE7fdXaNStRXvk9NYb/o2WANs98oVxTSpm53ozYRDFJL38jbSL8ylEHuomdYwz/o\n"
+            + "fF0wMnSj+k3giYgaPfxBMhbHQNL2fdvCVyhuXtmUzyDY3WS/mNA8S66t994lFLif\n"
+            + "LikS8IKbYhJIY64HQNW1gcYOsktDSTiYOQQm5Tx8/m6fyY3wjx4Ag2WxMWmxOzAx\n"
+            + "cyKm1lOblIjlBmUbebky+9ySeBh2HKe8JZzokca3UQdeUjgDlB62v/gVs0Ti5hwQ\n"
+            + "SHLMHLhZB+JAI+IjPObc7hwyev037kfNipGG7Y695ibzobZHGrQgVGVzdHVzZXIg\n"
+            + "U2l4IDx0ZXN0NkBleGFtcGxlLmNvbT6JATgEEwECACIFAlYmpXkCGwMGCwkIBwMC\n"
+            + "BhUIAgkKCwQWAgMBAh4BAheAAAoJEIxP0o40NLOfZe0H/jCXhy1zdxb0XCnXtyS0\n"
+            + "WLqZ3x+NGJHAT+u5UogqYAfN4wsGhmfgtlW6fI0JEYtl8/4xSzgmEuSnyKhPNQHv\n"
+            + "E+X2T3gVd6Vs5uf0XRPuG2MUuIUA4x7KSd7KWTaRqVO3ogEq1o4+f5XqicHA75gd\n"
+            + "vC37EsyEBaNn9YDRMgLVEi6R3NYSuF372UbwH2S6kYLls+j8CAI+hqM+FLWouu2H\n"
+            + "6UKw0ldP9G2TonHmibKnExc/ODM21QswU42zNpGceesIdCD/IlkMLDd99blKF/J9\n"
+            + "Z0UzrmOAQqicwovPL1dX4EzoglCWAcIEA/xYcpJ/iowbj+ERYuvAD9tNDvDQNJXz\n"
+            + "yaydA5gEVialeQEIALMgwUMLqRxXny68XdfA6tLcOYo08M3M7/gDL+I7Flub700/\n"
+            + "xBYQSb4DMK1kx9h1nwLcOO7PGSFxpwj8TlqBkgJ2zpuzT5VLtUoR41in9StMX2mR\n"
+            + "k1ex4Ksk+CtTfV6gZVzlsTlzRMYuECNswq1HktkUykBeCkDuFclJK4BcazyCOQmR\n"
+            + "hIlicnAg7J3E6LmOacT9TlC4yMEbRvDEVfGRoNrmZx5sn/oLyaCQrIBNtCQJ7tgk\n"
+            + "aXUksri9jbzJtIyEwO1mLCC8rN34dxCv7FRC+aBIwCklozEo8dEDUis/9JNdQQCN\n"
+            + "wZ+T8cLP/SPuYkbZy8LF44wCan+lPOFKheKpQ2UAEQEAAQAH/A1Os+Tb9yiGnuoN\n"
+            + "LuiSKa/YEgNBOxmC7dnuPK6xJpBQNZc200WzWJMf8AwVpl4foNxIyYb+Rjbsl1Ts\n"
+            + "z5JcOWFq+57oE5O7D+EMkqf5tFZO4nC4kqprac41HSW02mW/A0DDRKcIt/WEIwlK\n"
+            + "sWzHmjJ736moAtl/holRYQS0ePgB8bUPDQcFovH6X3SUxlPGTYD1DEX+WNvYRk3r\n"
+            + "pa9YXH65qbG9CEJIFTmwZIRDl+CBtBlN/fKadyMJr9fXtv7Fu9hNsK1K1pUtLqCa\n"
+            + "nc22Zak+o+LCPlZ8vmw/UmOGtp2iZlEragmh2rOywp0dHF7gsdlgoafQf8Q4NIag\n"
+            + "TFyHf1kEAMSOKUUwLBEmPnDVfoEOt5spQLVtlF8sh/Okk9zVazWmw0n/b1Ef72z6\n"
+            + "EZqCW9/XhH5pXfKJeV+08hroHI6a5UESa7/xOIx50TaQdRqjwGciMnH2LJcpIU/L\n"
+            + "f0cGXcnTLKt4Z2GeSPKFTj4VzwmwH5F/RYdc5eiVb7VNoy9DC5RZBADpTVH5pklS\n"
+            + "44VDJIcwSNy1LBEU3oj+Nu+sufCimJ5B7HLokoJtm6q8VQRga5hN1TZkdQcLy+b2\n"
+            + "wzxHYoIsIsYFfG/mqLZ3LJNDFqze1/Kj987DYSUGeNYexMN2Fkzbo35Jf0cpOiao\n"
+            + "390JFOS7qecUak5/yJ/V4xy8/nds37617QP9GWlFBykDoESBC2AIz8wXcpUBVNeH\n"
+            + "BNSthmC+PJPhsS6jTQuipqtXUZBgZBrMHp/bA8gTOkI4rPXycH3+ACbuQMAjbFny\n"
+            + "Kt69lPHD8VWw/82E4EY2J9LmHli+2BcATz89ouC4kqC5zF90qJseviSZPihpnFxA\n"
+            + "1UqMU2ZjsPb4CM9C/YkBHwQYAQIACQUCVialeQIbDAAKCRCMT9KONDSzn+RUB/9C\n"
+            + "8c6G6gFKVLE9yC+e8KXxXfNs1HU/2WCNgbAyy8uHAJPta+aaRQq+1P3Xe4RH3c3z\n"
+            + "EkPay8aEppumI+P396jSnenrh2JyKOw/VRB+mZYo9r9X2y/N6nTxykOX95zNKIuq\n"
+            + "BxNcKhl57BZehAaqqhefBriXBC/hm+PtNMI3bBHOgbBtFyYbaGZzbu47AD6FdsX5\n"
+            + "st7oc5lH/tT3ePxrbaDZf06BJP9AKwuDKJRLDdhhoRqf22fC4SAARj+tyE6+B5d7\n"
+            + "lKXTBMUscfo5LgEEaBzUtEqdIIzZ2Bmq+E47E6+66J4FZjq1Qu8gQiM00/xCLN9z\n"
+            + "i7Y7yHsc/ZvfJhKun0wx\n"
+            + "=M/kw\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
    * A key revoked by a valid key, due to no longer being used.
-   * <p>
-   * Revoked by {@link #validKeyWithoutExpiration()}.
+   *
+   * <p>Revoked by {@link #validKeyWithoutExpiration()}.
    *
    * <pre>
    * pub   2048R/3D6C52D0 2015-10-20 [revoked: 2015-10-20]
@@ -675,119 +676,120 @@
    * </pre>
    */
   public static TestKey revokedNoLongerUsedKey() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBFYmq3EBCAC9ssY6QhFsnZqKEPlQrx8Zomblj8qV93/B448isOT2L6OVY7UC\n"
-        + "kKPj6afW5UDkYeyZSmLZfTrpePcbAB8FB3uvd/AS9mHC+6zuBlwlkl9xIXlwUXQP\n"
-        + "KER4LKYNTP21AM+/vTJm4+u26tlZECIZlez31KEeqM30EAm+/pO8VkEp8+1ImfLv\n"
-        + "otndIjMoq9gxJvn6KZeexJT2eKCsSa20vVsmAhuFjLZitU3lEjIROfDiyHUZ2cZ+\n"
-        + "qynfppJCKlHJRu/T9L/yxDFVUFDFSajNzSfjG1g3FEveDITyAhRetVfZbhyJptnV\n"
-        + "jfiHSQkLamPsBmMoKfP+aO5SfsTHTJvxgLUdABEBAAGJAS0EIAECABcFAlYmq8AQ\n"
-        + "HQN0ZXN0NyBub3QgdXNlZAAKCRDtBiXcRjKKjPKqB/sF+ypJZaZ5M4jFdoH/YA3s\n"
-        + "4+VkA/NbLKcrlMI0lbnIrax02jdyTo7rBUJfTwuBs5QeQ25+VfaBcz9fWSv4Z8Bk\n"
-        + "9+w61bQZLQkExZ9W7hnhaapyR0aT0rY48KGtHOPNoMQu9Si+RnRiI024jMUUjrau\n"
-        + "w/exgCteY261VtCPRgyZOlpbX43rsBhF8ott0ZzSfLwaNTHhsjFsD1uH6TSFO8La\n"
-        + "/H1nO31sORlY3+rCGiQVuYIJD1qI7bEjDHYO0nq/f7JjfYKmVBg9grwLsX3h1qZ2\n"
-        + "L3Yz+0eCi7/6T/Sm7PavQ+EGL7+WBXX3qJpwc+EFNHs6VxQp86k6csba0c5mNcaQ\n"
-        + "iQE3BB8BAgAhBQJWJqusFwyAAQSup+0vghEz5bEo0e0GJdxGMoqMAgcAAAoJEPrS\n"
-        + "q+I9bFLQ2BYH/jm+t7pZuv8WqZdb8FiBa9CFfhcSKjYarMHjBw7GxWZJMd5VR4DC\n"
-        + "r4T/ZSAGRKBRKQ2uXrkm9H0NPDp0c/UKCHtQMFDnqTk7B63mwSR1d7W0qaRPXYQ1\n"
-        + "bbatnzkEDOj0e+rX6aiqVRMo/q6uMNUFl6UMrUZPSNB5PVRQWPnQ7K11mw3vg0e5\n"
-        + "ycqJbyFvER6EtyDUXGBo8a5/4bK8VBNBMTAIy6GeGpeSM5b7cpQk7/j4dXugCJAV\n"
-        + "fhFNUOgLduoIKM4u+VcFjk3Km/YxOtGi1dLqCbTX/0LiCRA9mgQpyNVyA+Sm48LM\n"
-        + "LUkbcrN/F3SHX1ao/5lm19r8Biu1ziQnLgC0IlRlc3R1c2VyIFNldmVuIDx0ZXN0\n"
-        + "N0BleGFtcGxlLmNvbT6JATgEEwECACIFAlYmq3ECGwMGCwkIBwMCBhUIAgkKCwQW\n"
-        + "AgMBAh4BAheAAAoJEPrSq+I9bFLQvjQH/0K7aBsGU2U/rm4I+u+uPa6BnFTYQJqg\n"
-        + "034pwdD0WfM3M/XgVh7ERjnR9ZViCMVej+K3kW5d2DNaXu5vVpcD6L6jjWwiJHBw\n"
-        + "LIcmpqQrL0TdoCr4F4FKQnBbcH1fNvP8A/hLDHB3k3ERPvEFIo1AkVuK4s/v7yZY\n"
-        + "HAowX0r4ok4ndu/wAc0HI1FkApkAfh18JDTuui53dkKhnkDp7Xnfm/ElAZYjB7Se\n"
-        + "ivxOD9vdhViWSx1VhttPZo5hSyJrEYaJ5u9hsXNUN85DxgLqCmS1v8n3pN1lVY/Q\n"
-        + "TYXtgocakQgHGEG0Tl6a3xpNkn9ihnyCr80mHCxXTyUUBGfygccelB+5AQ0EViar\n"
-        + "cQEIAKxwXb6HGV9QjepADyWW7GMxc2JVZ7pZM2sdf8wrgnQqV2G1rc9gAgwTX4jt\n"
-        + "OY0vSKT1vBq09ZXS3qpYHi/Wwft0KkaX/a7e6vKabDSfhilxC2LuGz2+56f6UOzj\n"
-        + "ggwf5k4LFTQvkDUZumwPjoeC2hqQO3Q/9PW39C6GnvsCr5L0MRdO3PbVJM7lJaOk\n"
-        + "MbGwgysErWgiZXKlxMpIvffIsLC4BAxnjXaCy6zHuBcPMPaRMs7sDRBzeuTV2wnX\n"
-        + "Sd+IXZgdpd1hF7VkuXenzwOqvBGS66C3ILW0ZTFaOtgrloIkTvtYEcJFWvxqWl2F\n"
-        + "+JQ5V6eu2aJ3HIGyr9L1R8MUA6EAEQEAAYkBHwQYAQIACQUCViarcQIbDAAKCRD6\n"
-        + "0qviPWxS0M0PB/9Rbk4/pNW67+uE1lwtaIG7uFiMbJqu8jK6MkD8GdayflroWEZA\n"
-        + "x0Xow9HL8UaRfeRPTZMrDRpjl+fJIXT5qnlB0FPmzSXAKr3piC8migBcbp5m6hWh\n"
-        + "c3ScAqWOeMt9j0TTWHh4hKS8Q+lK392ht65cI/kpFhxm9EEaXmajplNL/2G3PVrl\n"
-        + "fFUgCdOn2DYdVSgJsfBhkcoiy17G3vqtb+We6ulhziae4SIrkUSqdYmRjiFyvqZz\n"
-        + "tmMEoF6CQNCUb1NK0TsSDeIdDacYjUwyq0Qj6TaXrWcbC3kW0GtWoFTNIiX4q9bN\n"
-        + "+B6paw/s8P7XCWznTBRdlFWWgrhcpzQ8fefC\n"
-        + "=CHer\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBFYmq3EBCAC9ssY6QhFsnZqKEPlQrx8Zomblj8qV93/B448isOT2L6OVY7UC\n"
+            + "kKPj6afW5UDkYeyZSmLZfTrpePcbAB8FB3uvd/AS9mHC+6zuBlwlkl9xIXlwUXQP\n"
+            + "KER4LKYNTP21AM+/vTJm4+u26tlZECIZlez31KEeqM30EAm+/pO8VkEp8+1ImfLv\n"
+            + "otndIjMoq9gxJvn6KZeexJT2eKCsSa20vVsmAhuFjLZitU3lEjIROfDiyHUZ2cZ+\n"
+            + "qynfppJCKlHJRu/T9L/yxDFVUFDFSajNzSfjG1g3FEveDITyAhRetVfZbhyJptnV\n"
+            + "jfiHSQkLamPsBmMoKfP+aO5SfsTHTJvxgLUdABEBAAGJAS0EIAECABcFAlYmq8AQ\n"
+            + "HQN0ZXN0NyBub3QgdXNlZAAKCRDtBiXcRjKKjPKqB/sF+ypJZaZ5M4jFdoH/YA3s\n"
+            + "4+VkA/NbLKcrlMI0lbnIrax02jdyTo7rBUJfTwuBs5QeQ25+VfaBcz9fWSv4Z8Bk\n"
+            + "9+w61bQZLQkExZ9W7hnhaapyR0aT0rY48KGtHOPNoMQu9Si+RnRiI024jMUUjrau\n"
+            + "w/exgCteY261VtCPRgyZOlpbX43rsBhF8ott0ZzSfLwaNTHhsjFsD1uH6TSFO8La\n"
+            + "/H1nO31sORlY3+rCGiQVuYIJD1qI7bEjDHYO0nq/f7JjfYKmVBg9grwLsX3h1qZ2\n"
+            + "L3Yz+0eCi7/6T/Sm7PavQ+EGL7+WBXX3qJpwc+EFNHs6VxQp86k6csba0c5mNcaQ\n"
+            + "iQE3BB8BAgAhBQJWJqusFwyAAQSup+0vghEz5bEo0e0GJdxGMoqMAgcAAAoJEPrS\n"
+            + "q+I9bFLQ2BYH/jm+t7pZuv8WqZdb8FiBa9CFfhcSKjYarMHjBw7GxWZJMd5VR4DC\n"
+            + "r4T/ZSAGRKBRKQ2uXrkm9H0NPDp0c/UKCHtQMFDnqTk7B63mwSR1d7W0qaRPXYQ1\n"
+            + "bbatnzkEDOj0e+rX6aiqVRMo/q6uMNUFl6UMrUZPSNB5PVRQWPnQ7K11mw3vg0e5\n"
+            + "ycqJbyFvER6EtyDUXGBo8a5/4bK8VBNBMTAIy6GeGpeSM5b7cpQk7/j4dXugCJAV\n"
+            + "fhFNUOgLduoIKM4u+VcFjk3Km/YxOtGi1dLqCbTX/0LiCRA9mgQpyNVyA+Sm48LM\n"
+            + "LUkbcrN/F3SHX1ao/5lm19r8Biu1ziQnLgC0IlRlc3R1c2VyIFNldmVuIDx0ZXN0\n"
+            + "N0BleGFtcGxlLmNvbT6JATgEEwECACIFAlYmq3ECGwMGCwkIBwMCBhUIAgkKCwQW\n"
+            + "AgMBAh4BAheAAAoJEPrSq+I9bFLQvjQH/0K7aBsGU2U/rm4I+u+uPa6BnFTYQJqg\n"
+            + "034pwdD0WfM3M/XgVh7ERjnR9ZViCMVej+K3kW5d2DNaXu5vVpcD6L6jjWwiJHBw\n"
+            + "LIcmpqQrL0TdoCr4F4FKQnBbcH1fNvP8A/hLDHB3k3ERPvEFIo1AkVuK4s/v7yZY\n"
+            + "HAowX0r4ok4ndu/wAc0HI1FkApkAfh18JDTuui53dkKhnkDp7Xnfm/ElAZYjB7Se\n"
+            + "ivxOD9vdhViWSx1VhttPZo5hSyJrEYaJ5u9hsXNUN85DxgLqCmS1v8n3pN1lVY/Q\n"
+            + "TYXtgocakQgHGEG0Tl6a3xpNkn9ihnyCr80mHCxXTyUUBGfygccelB+5AQ0EViar\n"
+            + "cQEIAKxwXb6HGV9QjepADyWW7GMxc2JVZ7pZM2sdf8wrgnQqV2G1rc9gAgwTX4jt\n"
+            + "OY0vSKT1vBq09ZXS3qpYHi/Wwft0KkaX/a7e6vKabDSfhilxC2LuGz2+56f6UOzj\n"
+            + "ggwf5k4LFTQvkDUZumwPjoeC2hqQO3Q/9PW39C6GnvsCr5L0MRdO3PbVJM7lJaOk\n"
+            + "MbGwgysErWgiZXKlxMpIvffIsLC4BAxnjXaCy6zHuBcPMPaRMs7sDRBzeuTV2wnX\n"
+            + "Sd+IXZgdpd1hF7VkuXenzwOqvBGS66C3ILW0ZTFaOtgrloIkTvtYEcJFWvxqWl2F\n"
+            + "+JQ5V6eu2aJ3HIGyr9L1R8MUA6EAEQEAAYkBHwQYAQIACQUCViarcQIbDAAKCRD6\n"
+            + "0qviPWxS0M0PB/9Rbk4/pNW67+uE1lwtaIG7uFiMbJqu8jK6MkD8GdayflroWEZA\n"
+            + "x0Xow9HL8UaRfeRPTZMrDRpjl+fJIXT5qnlB0FPmzSXAKr3piC8migBcbp5m6hWh\n"
+            + "c3ScAqWOeMt9j0TTWHh4hKS8Q+lK392ht65cI/kpFhxm9EEaXmajplNL/2G3PVrl\n"
+            + "fFUgCdOn2DYdVSgJsfBhkcoiy17G3vqtb+We6ulhziae4SIrkUSqdYmRjiFyvqZz\n"
+            + "tmMEoF6CQNCUb1NK0TsSDeIdDacYjUwyq0Qj6TaXrWcbC3kW0GtWoFTNIiX4q9bN\n"
+            + "+B6paw/s8P7XCWznTBRdlFWWgrhcpzQ8fefC\n"
+            + "=CHer\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBFYmq3EBCAC9ssY6QhFsnZqKEPlQrx8Zomblj8qV93/B448isOT2L6OVY7UC\n"
-        + "kKPj6afW5UDkYeyZSmLZfTrpePcbAB8FB3uvd/AS9mHC+6zuBlwlkl9xIXlwUXQP\n"
-        + "KER4LKYNTP21AM+/vTJm4+u26tlZECIZlez31KEeqM30EAm+/pO8VkEp8+1ImfLv\n"
-        + "otndIjMoq9gxJvn6KZeexJT2eKCsSa20vVsmAhuFjLZitU3lEjIROfDiyHUZ2cZ+\n"
-        + "qynfppJCKlHJRu/T9L/yxDFVUFDFSajNzSfjG1g3FEveDITyAhRetVfZbhyJptnV\n"
-        + "jfiHSQkLamPsBmMoKfP+aO5SfsTHTJvxgLUdABEBAAEAB/9AdCtFJSidcolNKwpC\n"
-        + "/1V+VL9IdYxcWx02CDccjuUkvrgCrL+WcQW2jS/hZMChOKJ2zR78DcBEDr1LF8Xy\n"
-        + "ZAIC8yoHj15VLUUrFM8fVvYFzt1fq9VWxxRIjscW0teLNgzgdYzYB84RtwcFa2Vi\n"
-        + "sx2ycTUTYUClEgP1uLMCtX3rnibJh4vR+lVgnDtKSoh4CLAlW6grAAVdw5sSuV7Q\n"
-        + "i9EJcPezGw1RvBU5PooqNDG6kyw/QqsAS4q3WP4uVJKK1e7S9oqXFEN8k/zfllI0\n"
-        + "SSkoyP2flzz71rJF/wQMfJ8uf/CelKXd+gPO4FbCWiZSTLe20JR23qiOyvZkfCwg\n"
-        + "eFmzBADIJUzspDrg5yaqE+HMc8U3O9G9FHoDSweZTbhiq3aK0BqMAn34u0ps6chy\n"
-        + "VMO6aPWVzgcSHNfTlzpjuN9lwDoimYBH5vZa1HlCHt5eeqTORixkxSerOmILabTi\n"
-        + "QWq5JPdJwYZiSvK45G5k3G37RTd6/QyhTlRYXj59RXYajrYngwQA8qMZRkRYcTop\n"
-        + "aG+5M0x44k6NgIyH7Ap+2vRPpDdUlHs+z+6iRvoutkSfKHeZUYBQjgt+tScfn1hM\n"
-        + "BRB+x146ecmSVh/Dh8yu6uCrhitFlKpyJqNptZo5o+sH41zjefpMd/bc8rtHTw3n\n"
-        + "GiFl57ZbXbze2O8UimUVgRI2DtOebt8EAJHM/8vZahzF0chzL4sNVAb8FcNYxAyn\n"
-        + "95VpnWeAtKX7f0bqUvIN4BNV++o6JdMNvBoYEQpKeQIda7QM59hNiS8f/bxkRikF\n"
-        + "OiHB5YGy2zRX5T1G5rVQ0YqrOu959eEwdGZmOQ8GOqq5B/NoHXUtotV6SGE3R+Tl\n"
-        + "grlV4U5/PT0fM3KJATcEHwECACEFAlYmq6wXDIABBK6n7S+CETPlsSjR7QYl3EYy\n"
-        + "iowCBwAACgkQ+tKr4j1sUtDYFgf+Ob63ulm6/xapl1vwWIFr0IV+FxIqNhqsweMH\n"
-        + "DsbFZkkx3lVHgMKvhP9lIAZEoFEpDa5euSb0fQ08OnRz9QoIe1AwUOepOTsHrebB\n"
-        + "JHV3tbSppE9dhDVttq2fOQQM6PR76tfpqKpVEyj+rq4w1QWXpQytRk9I0Hk9VFBY\n"
-        + "+dDsrXWbDe+DR7nJyolvIW8RHoS3INRcYGjxrn/hsrxUE0ExMAjLoZ4al5Izlvty\n"
-        + "lCTv+Ph1e6AIkBV+EU1Q6At26ggozi75VwWOTcqb9jE60aLV0uoJtNf/QuIJED2a\n"
-        + "BCnI1XID5KbjwswtSRtys38XdIdfVqj/mWbX2vwGK7XOJCcuALQiVGVzdHVzZXIg\n"
-        + "U2V2ZW4gPHRlc3Q3QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCViarcQIbAwYLCQgH\n"
-        + "AwIGFQgCCQoLBBYCAwECHgECF4AACgkQ+tKr4j1sUtC+NAf/QrtoGwZTZT+ubgj6\n"
-        + "7649roGcVNhAmqDTfinB0PRZ8zcz9eBWHsRGOdH1lWIIxV6P4reRbl3YM1pe7m9W\n"
-        + "lwPovqONbCIkcHAshyampCsvRN2gKvgXgUpCcFtwfV828/wD+EsMcHeTcRE+8QUi\n"
-        + "jUCRW4riz+/vJlgcCjBfSviiTid27/ABzQcjUWQCmQB+HXwkNO66Lnd2QqGeQOnt\n"
-        + "ed+b8SUBliMHtJ6K/E4P292FWJZLHVWG209mjmFLImsRhonm72Gxc1Q3zkPGAuoK\n"
-        + "ZLW/yfek3WVVj9BNhe2ChxqRCAcYQbROXprfGk2Sf2KGfIKvzSYcLFdPJRQEZ/KB\n"
-        + "xx6UH50DmARWJqtxAQgArHBdvocZX1CN6kAPJZbsYzFzYlVnulkzax1/zCuCdCpX\n"
-        + "YbWtz2ACDBNfiO05jS9IpPW8GrT1ldLeqlgeL9bB+3QqRpf9rt7q8ppsNJ+GKXEL\n"
-        + "Yu4bPb7np/pQ7OOCDB/mTgsVNC+QNRm6bA+Oh4LaGpA7dD/09bf0Loae+wKvkvQx\n"
-        + "F07c9tUkzuUlo6QxsbCDKwStaCJlcqXEyki998iwsLgEDGeNdoLLrMe4Fw8w9pEy\n"
-        + "zuwNEHN65NXbCddJ34hdmB2l3WEXtWS5d6fPA6q8EZLroLcgtbRlMVo62CuWgiRO\n"
-        + "+1gRwkVa/GpaXYX4lDlXp67ZonccgbKv0vVHwxQDoQARAQABAAf5Ae8xa1mPns1E\n"
-        + "B5yCrvzDl79Dw0F1rED46IWIW/ghpVTzmFHV6ngcvcRFM5TZquxHXSuxLv7YVxRq\n"
-        + "UVszXNJaEwyJYYkDRwAS1E2IKN+gknwapm2eWkchySAajUsQt+XEYHFpDPtQRlA3\n"
-        + "Z6PrCOPJDOLmT9Zcf0R6KurGrhvTGrZkKU6ZCFqZWETfZy5cPfq2qxtw3YEUI+eT\n"
-        + "09AgMmPJ9nDPI3cA69tvy/phVFgpglsS76qgd6uFJ5kcDoIB+YepmJoHnzJeowYt\n"
-        + "lvnmmyGqmVS/KCgvILaD0c73Dp2X0BN64hSZHa3nUU67WbKJzo2OXr+yr0hvofcf\n"
-        + "8vhKJe5+2wQAy+rRKSAOPaFiKT8ZenRucx1pTJLoB8JdediOdR4dtXB2Z59Ze7N3\n"
-        + "sedfrJn1ao+jJEpnKeudlDq7oa9THd7ZojN4gBF/lz0duzfertuQ/MrHaTPeK8YI\n"
-        + "dEPg3SgYVOLDBptaKmo0xr2f6aslGLPHgxCgzOcLuuUNGKJSigZvhdMEANh7VKsX\n"
-        + "nb5shZh+KRET84us/uu74q4iIfc8Q10oXuN9+IPlqfAIclo4uMhvo5rtI9ApFtxs\n"
-        + "oZzqqc+gt+OAbn/fHeb61eT36BA+r61Ka+erxkpWU5r1BPVIqq+biTY/HHchqroJ\n"
-        + "aw81qWudO9h5a0yP1alDiBSwhZWIMCKzp6Q7A/472amrSzgs7u8ToQ/2THDxaMf3\n"
-        + "Se0HgMrIT1/+5es2CWiEoZGSZTXlimDYXJULu/DFC7ia7kXOLrMsO85bEi7SHagA\n"
-        + "eO+mAw3xP3OuNkZDt9x4qtal28fNIz22DH5qg2wtsGdCWXz5C6OdcrtQ736kNxa2\n"
-        + "5QemZ/0VWxHPnvXz40RtiQEfBBgBAgAJBQJWJqtxAhsMAAoJEPrSq+I9bFLQzQ8H\n"
-        + "/1FuTj+k1brv64TWXC1ogbu4WIxsmq7yMroyQPwZ1rJ+WuhYRkDHRejD0cvxRpF9\n"
-        + "5E9NkysNGmOX58khdPmqeUHQU+bNJcAqvemILyaKAFxunmbqFaFzdJwCpY54y32P\n"
-        + "RNNYeHiEpLxD6Urf3aG3rlwj+SkWHGb0QRpeZqOmU0v/Ybc9WuV8VSAJ06fYNh1V\n"
-        + "KAmx8GGRyiLLXsbe+q1v5Z7q6WHOJp7hIiuRRKp1iZGOIXK+pnO2YwSgXoJA0JRv\n"
-        + "U0rROxIN4h0NpxiNTDKrRCPpNpetZxsLeRbQa1agVM0iJfir1s34HqlrD+zw/tcJ\n"
-        + "bOdMFF2UVZaCuFynNDx958I=\n"
-        + "=aoJv\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBFYmq3EBCAC9ssY6QhFsnZqKEPlQrx8Zomblj8qV93/B448isOT2L6OVY7UC\n"
+            + "kKPj6afW5UDkYeyZSmLZfTrpePcbAB8FB3uvd/AS9mHC+6zuBlwlkl9xIXlwUXQP\n"
+            + "KER4LKYNTP21AM+/vTJm4+u26tlZECIZlez31KEeqM30EAm+/pO8VkEp8+1ImfLv\n"
+            + "otndIjMoq9gxJvn6KZeexJT2eKCsSa20vVsmAhuFjLZitU3lEjIROfDiyHUZ2cZ+\n"
+            + "qynfppJCKlHJRu/T9L/yxDFVUFDFSajNzSfjG1g3FEveDITyAhRetVfZbhyJptnV\n"
+            + "jfiHSQkLamPsBmMoKfP+aO5SfsTHTJvxgLUdABEBAAEAB/9AdCtFJSidcolNKwpC\n"
+            + "/1V+VL9IdYxcWx02CDccjuUkvrgCrL+WcQW2jS/hZMChOKJ2zR78DcBEDr1LF8Xy\n"
+            + "ZAIC8yoHj15VLUUrFM8fVvYFzt1fq9VWxxRIjscW0teLNgzgdYzYB84RtwcFa2Vi\n"
+            + "sx2ycTUTYUClEgP1uLMCtX3rnibJh4vR+lVgnDtKSoh4CLAlW6grAAVdw5sSuV7Q\n"
+            + "i9EJcPezGw1RvBU5PooqNDG6kyw/QqsAS4q3WP4uVJKK1e7S9oqXFEN8k/zfllI0\n"
+            + "SSkoyP2flzz71rJF/wQMfJ8uf/CelKXd+gPO4FbCWiZSTLe20JR23qiOyvZkfCwg\n"
+            + "eFmzBADIJUzspDrg5yaqE+HMc8U3O9G9FHoDSweZTbhiq3aK0BqMAn34u0ps6chy\n"
+            + "VMO6aPWVzgcSHNfTlzpjuN9lwDoimYBH5vZa1HlCHt5eeqTORixkxSerOmILabTi\n"
+            + "QWq5JPdJwYZiSvK45G5k3G37RTd6/QyhTlRYXj59RXYajrYngwQA8qMZRkRYcTop\n"
+            + "aG+5M0x44k6NgIyH7Ap+2vRPpDdUlHs+z+6iRvoutkSfKHeZUYBQjgt+tScfn1hM\n"
+            + "BRB+x146ecmSVh/Dh8yu6uCrhitFlKpyJqNptZo5o+sH41zjefpMd/bc8rtHTw3n\n"
+            + "GiFl57ZbXbze2O8UimUVgRI2DtOebt8EAJHM/8vZahzF0chzL4sNVAb8FcNYxAyn\n"
+            + "95VpnWeAtKX7f0bqUvIN4BNV++o6JdMNvBoYEQpKeQIda7QM59hNiS8f/bxkRikF\n"
+            + "OiHB5YGy2zRX5T1G5rVQ0YqrOu959eEwdGZmOQ8GOqq5B/NoHXUtotV6SGE3R+Tl\n"
+            + "grlV4U5/PT0fM3KJATcEHwECACEFAlYmq6wXDIABBK6n7S+CETPlsSjR7QYl3EYy\n"
+            + "iowCBwAACgkQ+tKr4j1sUtDYFgf+Ob63ulm6/xapl1vwWIFr0IV+FxIqNhqsweMH\n"
+            + "DsbFZkkx3lVHgMKvhP9lIAZEoFEpDa5euSb0fQ08OnRz9QoIe1AwUOepOTsHrebB\n"
+            + "JHV3tbSppE9dhDVttq2fOQQM6PR76tfpqKpVEyj+rq4w1QWXpQytRk9I0Hk9VFBY\n"
+            + "+dDsrXWbDe+DR7nJyolvIW8RHoS3INRcYGjxrn/hsrxUE0ExMAjLoZ4al5Izlvty\n"
+            + "lCTv+Ph1e6AIkBV+EU1Q6At26ggozi75VwWOTcqb9jE60aLV0uoJtNf/QuIJED2a\n"
+            + "BCnI1XID5KbjwswtSRtys38XdIdfVqj/mWbX2vwGK7XOJCcuALQiVGVzdHVzZXIg\n"
+            + "U2V2ZW4gPHRlc3Q3QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCViarcQIbAwYLCQgH\n"
+            + "AwIGFQgCCQoLBBYCAwECHgECF4AACgkQ+tKr4j1sUtC+NAf/QrtoGwZTZT+ubgj6\n"
+            + "7649roGcVNhAmqDTfinB0PRZ8zcz9eBWHsRGOdH1lWIIxV6P4reRbl3YM1pe7m9W\n"
+            + "lwPovqONbCIkcHAshyampCsvRN2gKvgXgUpCcFtwfV828/wD+EsMcHeTcRE+8QUi\n"
+            + "jUCRW4riz+/vJlgcCjBfSviiTid27/ABzQcjUWQCmQB+HXwkNO66Lnd2QqGeQOnt\n"
+            + "ed+b8SUBliMHtJ6K/E4P292FWJZLHVWG209mjmFLImsRhonm72Gxc1Q3zkPGAuoK\n"
+            + "ZLW/yfek3WVVj9BNhe2ChxqRCAcYQbROXprfGk2Sf2KGfIKvzSYcLFdPJRQEZ/KB\n"
+            + "xx6UH50DmARWJqtxAQgArHBdvocZX1CN6kAPJZbsYzFzYlVnulkzax1/zCuCdCpX\n"
+            + "YbWtz2ACDBNfiO05jS9IpPW8GrT1ldLeqlgeL9bB+3QqRpf9rt7q8ppsNJ+GKXEL\n"
+            + "Yu4bPb7np/pQ7OOCDB/mTgsVNC+QNRm6bA+Oh4LaGpA7dD/09bf0Loae+wKvkvQx\n"
+            + "F07c9tUkzuUlo6QxsbCDKwStaCJlcqXEyki998iwsLgEDGeNdoLLrMe4Fw8w9pEy\n"
+            + "zuwNEHN65NXbCddJ34hdmB2l3WEXtWS5d6fPA6q8EZLroLcgtbRlMVo62CuWgiRO\n"
+            + "+1gRwkVa/GpaXYX4lDlXp67ZonccgbKv0vVHwxQDoQARAQABAAf5Ae8xa1mPns1E\n"
+            + "B5yCrvzDl79Dw0F1rED46IWIW/ghpVTzmFHV6ngcvcRFM5TZquxHXSuxLv7YVxRq\n"
+            + "UVszXNJaEwyJYYkDRwAS1E2IKN+gknwapm2eWkchySAajUsQt+XEYHFpDPtQRlA3\n"
+            + "Z6PrCOPJDOLmT9Zcf0R6KurGrhvTGrZkKU6ZCFqZWETfZy5cPfq2qxtw3YEUI+eT\n"
+            + "09AgMmPJ9nDPI3cA69tvy/phVFgpglsS76qgd6uFJ5kcDoIB+YepmJoHnzJeowYt\n"
+            + "lvnmmyGqmVS/KCgvILaD0c73Dp2X0BN64hSZHa3nUU67WbKJzo2OXr+yr0hvofcf\n"
+            + "8vhKJe5+2wQAy+rRKSAOPaFiKT8ZenRucx1pTJLoB8JdediOdR4dtXB2Z59Ze7N3\n"
+            + "sedfrJn1ao+jJEpnKeudlDq7oa9THd7ZojN4gBF/lz0duzfertuQ/MrHaTPeK8YI\n"
+            + "dEPg3SgYVOLDBptaKmo0xr2f6aslGLPHgxCgzOcLuuUNGKJSigZvhdMEANh7VKsX\n"
+            + "nb5shZh+KRET84us/uu74q4iIfc8Q10oXuN9+IPlqfAIclo4uMhvo5rtI9ApFtxs\n"
+            + "oZzqqc+gt+OAbn/fHeb61eT36BA+r61Ka+erxkpWU5r1BPVIqq+biTY/HHchqroJ\n"
+            + "aw81qWudO9h5a0yP1alDiBSwhZWIMCKzp6Q7A/472amrSzgs7u8ToQ/2THDxaMf3\n"
+            + "Se0HgMrIT1/+5es2CWiEoZGSZTXlimDYXJULu/DFC7ia7kXOLrMsO85bEi7SHagA\n"
+            + "eO+mAw3xP3OuNkZDt9x4qtal28fNIz22DH5qg2wtsGdCWXz5C6OdcrtQ736kNxa2\n"
+            + "5QemZ/0VWxHPnvXz40RtiQEfBBgBAgAJBQJWJqtxAhsMAAoJEPrSq+I9bFLQzQ8H\n"
+            + "/1FuTj+k1brv64TWXC1ogbu4WIxsmq7yMroyQPwZ1rJ+WuhYRkDHRejD0cvxRpF9\n"
+            + "5E9NkysNGmOX58khdPmqeUHQU+bNJcAqvemILyaKAFxunmbqFaFzdJwCpY54y32P\n"
+            + "RNNYeHiEpLxD6Urf3aG3rlwj+SkWHGb0QRpeZqOmU0v/Ybc9WuV8VSAJ06fYNh1V\n"
+            + "KAmx8GGRyiLLXsbe+q1v5Z7q6WHOJp7hIiuRRKp1iZGOIXK+pnO2YwSgXoJA0JRv\n"
+            + "U0rROxIN4h0NpxiNTDKrRCPpNpetZxsLeRbQa1agVM0iJfir1s34HqlrD+zw/tcJ\n"
+            + "bOdMFF2UVZaCuFynNDx958I=\n"
+            + "=aoJv\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
    * Key revoked by an expired key, after that key's expiration.
-   * <p>
-   * Revoked by {@link #expiredKey()}.
+   *
+   * <p>Revoked by {@link #expiredKey()}.
    *
    * <pre>
    * pub   2048R/78BF7D7E 2005-08-01 [revoked: 2015-10-20]
@@ -796,119 +798,120 @@
    * </pre>
    */
   public static TestKey keyRevokedByExpiredKeyAfterExpiration() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBELuRwABCAC56yhFKybBtuKT4nyb7RdLE98pZR54aGjcDcKH3VKVyBF8Z4Kx\n"
-        + "ptd7Sre0mLPCQiNWVOmCT+JG7GKVE6YeFmyXDUnhX9w4+HAeDEh23S4u9JvwWaF+\n"
-        + "wlJ6jLq/oe5gdT1F6Y2yqNpQ6CztOw52Ko9KSYz7/1zBMPcCkl/4k15ee4iebVdq\n"
-        + "c7qT5Qt49Poiozh0DI5prPQ624uckHkz2mXshjWQVuHWwrkIkCJZ2I/KQN2kBjKw\n"
-        + "/ALxumaWmiB9lQ0nIwLuGzHCh0Xg5RxuCrK8fJp47Aza3ikVuYlNzSxhJVav3OtK\n"
-        + "gftBihQXUlY3Uy/4QTCeH/BdVs5OALtXL3VhABEBAAGJAS0EIAECABcFAlYmr4kQ\n"
-        + "HQN0ZXN0OCBub3QgdXNlZAAKCRA87HgbF94azQJ5B/0TeQk7TSChNp+NqCKPTuw0\n"
-        + "wpflDyc+5ru/Gcs4r358cWzgiLUb3M0Q1+M8CF13BFQdrxT05vjheI9o5PCn3b//\n"
-        + "AHV8m+QFSnRi2J3QslbvuOqOnipz7vc7lyZ7q1sWNC33YN+ZcGZiMuu5HJi9iadf\n"
-        + "ZL7AdInpUb4Zb+XKphbMokDcN3yw7rqSMMcx+rKytUAqUnt9qvaSLrIH/zeazxlp\n"
-        + "YG4jaN53WPfLCcGG+Rw56mW+eCQD2rmzaNHCw8Qr+19sokXLB7OML+rd1wNwZT4q\n"
-        + "stWnL+nOj8ZkbFV0w3zClDYaARr7H+vTckwVStyDVRbnpRitSAtJwbRDzZBaS4Vx\n"
-        + "iQE3BB8BAgAhBQJC7lUQFwyAAR2e63ndOLBJk52crzzseBsX3hrNAgcAAAoJEAAa\n"
-        + "34t4v31+AS4H/0x3Y9E3q9DR5FCuYTXG4BHyrALo2WKoP0CfUWL98Fw9Txl0hF+9\n"
-        + "5wriNlnmd2zvM0quHs78x4/xehQO88cw0lqPx3RARq/ju5/VbOjoNlcHvfGYZiEd\n"
-        + "yWOwHu7O8sZrenFDjeDglD6NArrjncOcC51XIPSSTLvVQpSauQ1FS4tan5Q4aWMb\n"
-        + "s4DzE+Vqu2xMkO/X9toYAZKzyWP29OckpouMbt3GUnS6/o0A8Z7jVX+XOIk3XolP\n"
-        + "Li9tzTQB12Xl23mgFvearDoguR2Bu2SbmTJtdiXz8L3S54kGvxVqak5uOP2dagzU\n"
-        + "vBiqR4SVoAdGoXt6TI6mpA+qdYmPMG8v21S0IlRlc3R1c2VyIEVpZ2h0IDx0ZXN0\n"
-        + "OEBleGFtcGxlLmNvbT6JATgEEwECACIFAkLuRwACGwMGCwkIBwMCBhUIAgkKCwQW\n"
-        + "AgMBAh4BAheAAAoJEAAa34t4v31+8/sIAIuqd+dU8k9c5VQ12k7IfZGGYQHF2Mk/\n"
-        + "8FNuP7hFP/VOXBK3QIxIfGEOHbDX6uIxudYMaDmn2UJbdIqJd8NuQByh1gqXdX/x\n"
-        + "nteUa+4e7U6uTjkp/Ij5UzRed8suINA3NzVOy6qwCu3DTOXIZcjiOZtOA5GTqG6Z\n"
-        + "naDP0hwDssJp+LXIYTJgsvneJQFGSdQhhJSv19oV0JPSbb6Zc7gEIHtPcaJHjuZQ\n"
-        + "Ev+TRcRrI9HPTF0MvgOYgIDo2sbcSFV+8moKsHMC+j1Hmuuqgm/1yKGIZrt0V75s\n"
-        + "D9HYu0tiS3+Wlsry3y1hg/2XBQbwgh6sT/jWkpWar7+uzNxO5GdFYrC5AQ0EQu5H\n"
-        + "AAEIALPFTedbfyK+9B35Uo9cPsmFa3mT3qp/bAQtnOjiTTTiIO3tu0ALnaBjf6On\n"
-        + "fAV1HmGz6hRMRK4LGyHkNTaGDNNPoXO7+t9DWycSHmsCL5d5zp7VevQE8MPR8zHK\n"
-        + "Il2YQlCzdy5TWSUhunKd4guDNZ9GiOS6NQ9feYZ9DQ1kzC8nnu7jLkR2zNT02sYU\n"
-        + "kuOCZUktQhVNszUlavdIFjvToZo3RPcdb/E3kTTy2R9xi89AXjWZf3lSAZe3igkL\n"
-        + "jhwsd+u3RRx0ptOJym7zYl5ZdUZk4QrS7FPI6zEBpjawbS4/r6uEW89P3QAkanDI\n"
-        + "ridIAZP8awLZU3uSPtMwPIJpao0AEQEAAYkBHwQYAQIACQUCQu5HAAIbDAAKCRAA\n"
-        + "Gt+LeL99fqpHB/wOXhdMNtgeVW38bLk8YhcEB23FW6fDjFjBJb9m/yqRTh5CIeG2\n"
-        + "bm29ofT4PTamPb8Gt+YuDLnQQ3K2jURakxNDcYwiurvR/oHVdxsBRU7Px7UPeZk3\n"
-        + "BG5VnIJRT198dF7MWFJ+x5wHbNXwM8DDvUwTjXLH/TlGl1XIheSTHCYd9Pra4ejE\n"
-        + "ockkrDaZlPCQdTwY+P7K2ieb5tsqNpJkQeBrglF2bemY/CtQHnM9qwa6ZJqkyYNR\n"
-        + "F1nkSYn36BPuNpytYw1CaQV9GbePugPHtshECLwA160QzqISQUcJlKXttUqUGnoO\n"
-        + "0d0PyzZT3676mQwmFoebMR9vACAeHjvDxD4F\n"
-        + "=ihWb\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBELuRwABCAC56yhFKybBtuKT4nyb7RdLE98pZR54aGjcDcKH3VKVyBF8Z4Kx\n"
+            + "ptd7Sre0mLPCQiNWVOmCT+JG7GKVE6YeFmyXDUnhX9w4+HAeDEh23S4u9JvwWaF+\n"
+            + "wlJ6jLq/oe5gdT1F6Y2yqNpQ6CztOw52Ko9KSYz7/1zBMPcCkl/4k15ee4iebVdq\n"
+            + "c7qT5Qt49Poiozh0DI5prPQ624uckHkz2mXshjWQVuHWwrkIkCJZ2I/KQN2kBjKw\n"
+            + "/ALxumaWmiB9lQ0nIwLuGzHCh0Xg5RxuCrK8fJp47Aza3ikVuYlNzSxhJVav3OtK\n"
+            + "gftBihQXUlY3Uy/4QTCeH/BdVs5OALtXL3VhABEBAAGJAS0EIAECABcFAlYmr4kQ\n"
+            + "HQN0ZXN0OCBub3QgdXNlZAAKCRA87HgbF94azQJ5B/0TeQk7TSChNp+NqCKPTuw0\n"
+            + "wpflDyc+5ru/Gcs4r358cWzgiLUb3M0Q1+M8CF13BFQdrxT05vjheI9o5PCn3b//\n"
+            + "AHV8m+QFSnRi2J3QslbvuOqOnipz7vc7lyZ7q1sWNC33YN+ZcGZiMuu5HJi9iadf\n"
+            + "ZL7AdInpUb4Zb+XKphbMokDcN3yw7rqSMMcx+rKytUAqUnt9qvaSLrIH/zeazxlp\n"
+            + "YG4jaN53WPfLCcGG+Rw56mW+eCQD2rmzaNHCw8Qr+19sokXLB7OML+rd1wNwZT4q\n"
+            + "stWnL+nOj8ZkbFV0w3zClDYaARr7H+vTckwVStyDVRbnpRitSAtJwbRDzZBaS4Vx\n"
+            + "iQE3BB8BAgAhBQJC7lUQFwyAAR2e63ndOLBJk52crzzseBsX3hrNAgcAAAoJEAAa\n"
+            + "34t4v31+AS4H/0x3Y9E3q9DR5FCuYTXG4BHyrALo2WKoP0CfUWL98Fw9Txl0hF+9\n"
+            + "5wriNlnmd2zvM0quHs78x4/xehQO88cw0lqPx3RARq/ju5/VbOjoNlcHvfGYZiEd\n"
+            + "yWOwHu7O8sZrenFDjeDglD6NArrjncOcC51XIPSSTLvVQpSauQ1FS4tan5Q4aWMb\n"
+            + "s4DzE+Vqu2xMkO/X9toYAZKzyWP29OckpouMbt3GUnS6/o0A8Z7jVX+XOIk3XolP\n"
+            + "Li9tzTQB12Xl23mgFvearDoguR2Bu2SbmTJtdiXz8L3S54kGvxVqak5uOP2dagzU\n"
+            + "vBiqR4SVoAdGoXt6TI6mpA+qdYmPMG8v21S0IlRlc3R1c2VyIEVpZ2h0IDx0ZXN0\n"
+            + "OEBleGFtcGxlLmNvbT6JATgEEwECACIFAkLuRwACGwMGCwkIBwMCBhUIAgkKCwQW\n"
+            + "AgMBAh4BAheAAAoJEAAa34t4v31+8/sIAIuqd+dU8k9c5VQ12k7IfZGGYQHF2Mk/\n"
+            + "8FNuP7hFP/VOXBK3QIxIfGEOHbDX6uIxudYMaDmn2UJbdIqJd8NuQByh1gqXdX/x\n"
+            + "nteUa+4e7U6uTjkp/Ij5UzRed8suINA3NzVOy6qwCu3DTOXIZcjiOZtOA5GTqG6Z\n"
+            + "naDP0hwDssJp+LXIYTJgsvneJQFGSdQhhJSv19oV0JPSbb6Zc7gEIHtPcaJHjuZQ\n"
+            + "Ev+TRcRrI9HPTF0MvgOYgIDo2sbcSFV+8moKsHMC+j1Hmuuqgm/1yKGIZrt0V75s\n"
+            + "D9HYu0tiS3+Wlsry3y1hg/2XBQbwgh6sT/jWkpWar7+uzNxO5GdFYrC5AQ0EQu5H\n"
+            + "AAEIALPFTedbfyK+9B35Uo9cPsmFa3mT3qp/bAQtnOjiTTTiIO3tu0ALnaBjf6On\n"
+            + "fAV1HmGz6hRMRK4LGyHkNTaGDNNPoXO7+t9DWycSHmsCL5d5zp7VevQE8MPR8zHK\n"
+            + "Il2YQlCzdy5TWSUhunKd4guDNZ9GiOS6NQ9feYZ9DQ1kzC8nnu7jLkR2zNT02sYU\n"
+            + "kuOCZUktQhVNszUlavdIFjvToZo3RPcdb/E3kTTy2R9xi89AXjWZf3lSAZe3igkL\n"
+            + "jhwsd+u3RRx0ptOJym7zYl5ZdUZk4QrS7FPI6zEBpjawbS4/r6uEW89P3QAkanDI\n"
+            + "ridIAZP8awLZU3uSPtMwPIJpao0AEQEAAYkBHwQYAQIACQUCQu5HAAIbDAAKCRAA\n"
+            + "Gt+LeL99fqpHB/wOXhdMNtgeVW38bLk8YhcEB23FW6fDjFjBJb9m/yqRTh5CIeG2\n"
+            + "bm29ofT4PTamPb8Gt+YuDLnQQ3K2jURakxNDcYwiurvR/oHVdxsBRU7Px7UPeZk3\n"
+            + "BG5VnIJRT198dF7MWFJ+x5wHbNXwM8DDvUwTjXLH/TlGl1XIheSTHCYd9Pra4ejE\n"
+            + "ockkrDaZlPCQdTwY+P7K2ieb5tsqNpJkQeBrglF2bemY/CtQHnM9qwa6ZJqkyYNR\n"
+            + "F1nkSYn36BPuNpytYw1CaQV9GbePugPHtshECLwA160QzqISQUcJlKXttUqUGnoO\n"
+            + "0d0PyzZT3676mQwmFoebMR9vACAeHjvDxD4F\n"
+            + "=ihWb\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBELuRwABCAC56yhFKybBtuKT4nyb7RdLE98pZR54aGjcDcKH3VKVyBF8Z4Kx\n"
-        + "ptd7Sre0mLPCQiNWVOmCT+JG7GKVE6YeFmyXDUnhX9w4+HAeDEh23S4u9JvwWaF+\n"
-        + "wlJ6jLq/oe5gdT1F6Y2yqNpQ6CztOw52Ko9KSYz7/1zBMPcCkl/4k15ee4iebVdq\n"
-        + "c7qT5Qt49Poiozh0DI5prPQ624uckHkz2mXshjWQVuHWwrkIkCJZ2I/KQN2kBjKw\n"
-        + "/ALxumaWmiB9lQ0nIwLuGzHCh0Xg5RxuCrK8fJp47Aza3ikVuYlNzSxhJVav3OtK\n"
-        + "gftBihQXUlY3Uy/4QTCeH/BdVs5OALtXL3VhABEBAAEAB/wLr88oGuxsoqIHRQZL\n"
-        + "eGm9jc4aQGmcDMcjpwdGilhrwyfrO6f84hWbQdD+rJcnI8hsH7oOd5ZMGkWfpJyt\n"
-        + "eUAh9iNB5ChYGfDVSLUg6KojqDtprj6vNMihvLkr/OI6xL/hZksikwfnLFMPpgXU\n"
-        + "knwPocQ3nn+egsUSL7CR8/SLiIm4MC0brer6jhDxB5LKweExNlfTe4c0MDeYTsWt\n"
-        + "0WGzNPlvRZQXRotJzqemt3wdNZXUnCKR0n7pSQ8EhZr2O6NXr+mUgp6PIOE/3un2\n"
-        + "YGiBEf5uy3qEFe7FjEGIHz+Z3ySRdUDfHOk82TKAzynoJIxRUvLIYVNw4eFB3l5U\n"
-        + "s1w5BADUzfciG7RVLa8UFKJfqQ/5M06QmdS1v1/hMQXg38+3vKe8RgfSSnMJ08Sc\n"
-        + "eAEsmugwpNXAxgRKHcmWzN3NMBHhE3KiyiogWaMGqmSo6swFpu0+dwMvZSxMlfD+\n"
-        + "ka/BWt8YsUdrqW06ow39aTgCV+icbNRV81C7NKe7u0X1JDx2CQQA36gbdo62h/Wd\n"
-        + "gJI8kdz/se3xrt8x6RoWvOnWPNmsZR5XkDqAMTL1dWiEEA/dQTphMcgAe9z3WaP+\n"
-        + "F1TPAfounbiurGCcS3kxJ5tY7ojyU7nYz4DA/V2OU0C/LUoLXhttG5HM+m/i3qn4\n"
-        + "K9bBoWIQY1ijliS7cTSwNqd6IHaQGpkEAMnp5GwSGhY+kUuLw06hmH4xnsuf6agz\n"
-        + "AfhbPylB2nf/ZaX6dt6/mFEAkvQNahcoWEskfS3LGCD8jHm8PvF8K0mciXPDweq2\n"
-        + "gW3/irE0RXNwn3Oa222VSvcgUlocBm9InkfvpFXh20OYFe3dFH7uYkwUqIHJeXjw\n"
-        + "TjpXUX/vC5QJQOyJATcEHwECACEFAkLuVRAXDIABHZ7red04sEmTnZyvPOx4Gxfe\n"
-        + "Gs0CBwAACgkQABrfi3i/fX4BLgf/THdj0Ter0NHkUK5hNcbgEfKsAujZYqg/QJ9R\n"
-        + "Yv3wXD1PGXSEX73nCuI2WeZ3bO8zSq4ezvzHj/F6FA7zxzDSWo/HdEBGr+O7n9Vs\n"
-        + "6Og2Vwe98ZhmIR3JY7Ae7s7yxmt6cUON4OCUPo0CuuOdw5wLnVcg9JJMu9VClJq5\n"
-        + "DUVLi1qflDhpYxuzgPMT5Wq7bEyQ79f22hgBkrPJY/b05ySmi4xu3cZSdLr+jQDx\n"
-        + "nuNVf5c4iTdeiU8uL23NNAHXZeXbeaAW95qsOiC5HYG7ZJuZMm12JfPwvdLniQa/\n"
-        + "FWpqTm44/Z1qDNS8GKpHhJWgB0ahe3pMjqakD6p1iY8wby/bVLQiVGVzdHVzZXIg\n"
-        + "RWlnaHQgPHRlc3Q4QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCQu5HAAIbAwYLCQgH\n"
-        + "AwIGFQgCCQoLBBYCAwECHgECF4AACgkQABrfi3i/fX7z+wgAi6p351TyT1zlVDXa\n"
-        + "Tsh9kYZhAcXYyT/wU24/uEU/9U5cErdAjEh8YQ4dsNfq4jG51gxoOafZQlt0iol3\n"
-        + "w25AHKHWCpd1f/Ge15Rr7h7tTq5OOSn8iPlTNF53yy4g0Dc3NU7LqrAK7cNM5chl\n"
-        + "yOI5m04DkZOobpmdoM/SHAOywmn4tchhMmCy+d4lAUZJ1CGElK/X2hXQk9Jtvplz\n"
-        + "uAQge09xokeO5lAS/5NFxGsj0c9MXQy+A5iAgOjaxtxIVX7yagqwcwL6PUea66qC\n"
-        + "b/XIoYhmu3RXvmwP0di7S2JLf5aWyvLfLWGD/ZcFBvCCHqxP+NaSlZqvv67M3E7k\n"
-        + "Z0VisJ0DmARC7kcAAQgAs8VN51t/Ir70HflSj1w+yYVreZPeqn9sBC2c6OJNNOIg\n"
-        + "7e27QAudoGN/o6d8BXUeYbPqFExErgsbIeQ1NoYM00+hc7v630NbJxIeawIvl3nO\n"
-        + "ntV69ATww9HzMcoiXZhCULN3LlNZJSG6cp3iC4M1n0aI5Lo1D195hn0NDWTMLyee\n"
-        + "7uMuRHbM1PTaxhSS44JlSS1CFU2zNSVq90gWO9OhmjdE9x1v8TeRNPLZH3GLz0Be\n"
-        + "NZl/eVIBl7eKCQuOHCx367dFHHSm04nKbvNiXll1RmThCtLsU8jrMQGmNrBtLj+v\n"
-        + "q4Rbz0/dACRqcMiuJ0gBk/xrAtlTe5I+0zA8gmlqjQARAQABAAf+JNVkZOcGYaQm\n"
-        + "eI3BMMaBxuCjaMG3ec+p3iFKaR0VHKTIgneXSkQXA+nfGTUT4DpjAznN2GLYH6D+\n"
-        + "6i7MCGPm9NT4C7KUcHJoltTLjrlf7vVyNHEhRCZO/pBh9+2mpO6xh799x+wj88u5\n"
-        + "XAqlah50OjJFkjfk70VsrPWqWvgwLejkaQpGbE+pdL+vjy+ol5FHzidzmJvsXDR1\n"
-        + "I1as0vBu5g2XPpexyVanmHJglZdZX07OPYQBhxQKuPXT/2/IRnXsXEpitk4IyJT0\n"
-        + "U5D/iedEUldhBByep1lBcJnAap0CP7iuu2CYhRp6V2wVvdweNPng5Eo7f7LNyjnX\n"
-        + "UMAeaeCjAQQA1A0iKtg3Grxc9+lpFl1znc2/kO3p6ixM13uUvci+yGFNJJninnxo\n"
-        + "99KXEzqqVD0zerjiyyegQmzpITE/+hFIOJZInxEH08WQwZstV/KYeRSJkXf0Um48\n"
-        + "E+Zrh8fpJVW1w3ZCw9Ee2yE6fEhAA4w66+50pM+vBXanWOrG1HDrkxEEANkHc2Rz\n"
-        + "YJsO4v63xo/7/njLSQ31miOglb99ACKBA0Yl/jvj2KqLcomKILqvK3DKP+BHNq86\n"
-        + "LUBUglyKjKuj0wkSWT0tCnfgLzysUpowcoyFhJ36KzAz8hjqIn3TQpMF21HvkZdG\n"
-        + "Mtkcyhu5UDvbfOuWOBaKIeNQWCWv1rNzMme9A/9zU1+esEhKwGWEqa3/B/Te/xQh\n"
-        + "alk180n74sTZid6lXD8o8cEei0CUq7zBSV0P8v6kk8PP9/XyLRl3Rqa95fESUWrL\n"
-        + "xD6TBY1JlHBZS+N6rN/7Ilf5EXSELmnbDFsVxkNGp4elKxajvZxC6uEWYBu62AYy\n"
-        + "wS0dj8mZR3faCEps90YXiQEfBBgBAgAJBQJC7kcAAhsMAAoJEAAa34t4v31+qkcH\n"
-        + "/A5eF0w22B5VbfxsuTxiFwQHbcVbp8OMWMElv2b/KpFOHkIh4bZubb2h9Pg9NqY9\n"
-        + "vwa35i4MudBDcraNRFqTE0NxjCK6u9H+gdV3GwFFTs/HtQ95mTcEblWcglFPX3x0\n"
-        + "XsxYUn7HnAds1fAzwMO9TBONcsf9OUaXVciF5JMcJh30+trh6MShySSsNpmU8JB1\n"
-        + "PBj4/sraJ5vm2yo2kmRB4GuCUXZt6Zj8K1Aecz2rBrpkmqTJg1EXWeRJiffoE+42\n"
-        + "nK1jDUJpBX0Zt4+6A8e2yEQIvADXrRDOohJBRwmUpe21SpQaeg7R3Q/LNlPfrvqZ\n"
-        + "DCYWh5sxH28AIB4eO8PEPgU=\n"
-        + "=cSfw\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBELuRwABCAC56yhFKybBtuKT4nyb7RdLE98pZR54aGjcDcKH3VKVyBF8Z4Kx\n"
+            + "ptd7Sre0mLPCQiNWVOmCT+JG7GKVE6YeFmyXDUnhX9w4+HAeDEh23S4u9JvwWaF+\n"
+            + "wlJ6jLq/oe5gdT1F6Y2yqNpQ6CztOw52Ko9KSYz7/1zBMPcCkl/4k15ee4iebVdq\n"
+            + "c7qT5Qt49Poiozh0DI5prPQ624uckHkz2mXshjWQVuHWwrkIkCJZ2I/KQN2kBjKw\n"
+            + "/ALxumaWmiB9lQ0nIwLuGzHCh0Xg5RxuCrK8fJp47Aza3ikVuYlNzSxhJVav3OtK\n"
+            + "gftBihQXUlY3Uy/4QTCeH/BdVs5OALtXL3VhABEBAAEAB/wLr88oGuxsoqIHRQZL\n"
+            + "eGm9jc4aQGmcDMcjpwdGilhrwyfrO6f84hWbQdD+rJcnI8hsH7oOd5ZMGkWfpJyt\n"
+            + "eUAh9iNB5ChYGfDVSLUg6KojqDtprj6vNMihvLkr/OI6xL/hZksikwfnLFMPpgXU\n"
+            + "knwPocQ3nn+egsUSL7CR8/SLiIm4MC0brer6jhDxB5LKweExNlfTe4c0MDeYTsWt\n"
+            + "0WGzNPlvRZQXRotJzqemt3wdNZXUnCKR0n7pSQ8EhZr2O6NXr+mUgp6PIOE/3un2\n"
+            + "YGiBEf5uy3qEFe7FjEGIHz+Z3ySRdUDfHOk82TKAzynoJIxRUvLIYVNw4eFB3l5U\n"
+            + "s1w5BADUzfciG7RVLa8UFKJfqQ/5M06QmdS1v1/hMQXg38+3vKe8RgfSSnMJ08Sc\n"
+            + "eAEsmugwpNXAxgRKHcmWzN3NMBHhE3KiyiogWaMGqmSo6swFpu0+dwMvZSxMlfD+\n"
+            + "ka/BWt8YsUdrqW06ow39aTgCV+icbNRV81C7NKe7u0X1JDx2CQQA36gbdo62h/Wd\n"
+            + "gJI8kdz/se3xrt8x6RoWvOnWPNmsZR5XkDqAMTL1dWiEEA/dQTphMcgAe9z3WaP+\n"
+            + "F1TPAfounbiurGCcS3kxJ5tY7ojyU7nYz4DA/V2OU0C/LUoLXhttG5HM+m/i3qn4\n"
+            + "K9bBoWIQY1ijliS7cTSwNqd6IHaQGpkEAMnp5GwSGhY+kUuLw06hmH4xnsuf6agz\n"
+            + "AfhbPylB2nf/ZaX6dt6/mFEAkvQNahcoWEskfS3LGCD8jHm8PvF8K0mciXPDweq2\n"
+            + "gW3/irE0RXNwn3Oa222VSvcgUlocBm9InkfvpFXh20OYFe3dFH7uYkwUqIHJeXjw\n"
+            + "TjpXUX/vC5QJQOyJATcEHwECACEFAkLuVRAXDIABHZ7red04sEmTnZyvPOx4Gxfe\n"
+            + "Gs0CBwAACgkQABrfi3i/fX4BLgf/THdj0Ter0NHkUK5hNcbgEfKsAujZYqg/QJ9R\n"
+            + "Yv3wXD1PGXSEX73nCuI2WeZ3bO8zSq4ezvzHj/F6FA7zxzDSWo/HdEBGr+O7n9Vs\n"
+            + "6Og2Vwe98ZhmIR3JY7Ae7s7yxmt6cUON4OCUPo0CuuOdw5wLnVcg9JJMu9VClJq5\n"
+            + "DUVLi1qflDhpYxuzgPMT5Wq7bEyQ79f22hgBkrPJY/b05ySmi4xu3cZSdLr+jQDx\n"
+            + "nuNVf5c4iTdeiU8uL23NNAHXZeXbeaAW95qsOiC5HYG7ZJuZMm12JfPwvdLniQa/\n"
+            + "FWpqTm44/Z1qDNS8GKpHhJWgB0ahe3pMjqakD6p1iY8wby/bVLQiVGVzdHVzZXIg\n"
+            + "RWlnaHQgPHRlc3Q4QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCQu5HAAIbAwYLCQgH\n"
+            + "AwIGFQgCCQoLBBYCAwECHgECF4AACgkQABrfi3i/fX7z+wgAi6p351TyT1zlVDXa\n"
+            + "Tsh9kYZhAcXYyT/wU24/uEU/9U5cErdAjEh8YQ4dsNfq4jG51gxoOafZQlt0iol3\n"
+            + "w25AHKHWCpd1f/Ge15Rr7h7tTq5OOSn8iPlTNF53yy4g0Dc3NU7LqrAK7cNM5chl\n"
+            + "yOI5m04DkZOobpmdoM/SHAOywmn4tchhMmCy+d4lAUZJ1CGElK/X2hXQk9Jtvplz\n"
+            + "uAQge09xokeO5lAS/5NFxGsj0c9MXQy+A5iAgOjaxtxIVX7yagqwcwL6PUea66qC\n"
+            + "b/XIoYhmu3RXvmwP0di7S2JLf5aWyvLfLWGD/ZcFBvCCHqxP+NaSlZqvv67M3E7k\n"
+            + "Z0VisJ0DmARC7kcAAQgAs8VN51t/Ir70HflSj1w+yYVreZPeqn9sBC2c6OJNNOIg\n"
+            + "7e27QAudoGN/o6d8BXUeYbPqFExErgsbIeQ1NoYM00+hc7v630NbJxIeawIvl3nO\n"
+            + "ntV69ATww9HzMcoiXZhCULN3LlNZJSG6cp3iC4M1n0aI5Lo1D195hn0NDWTMLyee\n"
+            + "7uMuRHbM1PTaxhSS44JlSS1CFU2zNSVq90gWO9OhmjdE9x1v8TeRNPLZH3GLz0Be\n"
+            + "NZl/eVIBl7eKCQuOHCx367dFHHSm04nKbvNiXll1RmThCtLsU8jrMQGmNrBtLj+v\n"
+            + "q4Rbz0/dACRqcMiuJ0gBk/xrAtlTe5I+0zA8gmlqjQARAQABAAf+JNVkZOcGYaQm\n"
+            + "eI3BMMaBxuCjaMG3ec+p3iFKaR0VHKTIgneXSkQXA+nfGTUT4DpjAznN2GLYH6D+\n"
+            + "6i7MCGPm9NT4C7KUcHJoltTLjrlf7vVyNHEhRCZO/pBh9+2mpO6xh799x+wj88u5\n"
+            + "XAqlah50OjJFkjfk70VsrPWqWvgwLejkaQpGbE+pdL+vjy+ol5FHzidzmJvsXDR1\n"
+            + "I1as0vBu5g2XPpexyVanmHJglZdZX07OPYQBhxQKuPXT/2/IRnXsXEpitk4IyJT0\n"
+            + "U5D/iedEUldhBByep1lBcJnAap0CP7iuu2CYhRp6V2wVvdweNPng5Eo7f7LNyjnX\n"
+            + "UMAeaeCjAQQA1A0iKtg3Grxc9+lpFl1znc2/kO3p6ixM13uUvci+yGFNJJninnxo\n"
+            + "99KXEzqqVD0zerjiyyegQmzpITE/+hFIOJZInxEH08WQwZstV/KYeRSJkXf0Um48\n"
+            + "E+Zrh8fpJVW1w3ZCw9Ee2yE6fEhAA4w66+50pM+vBXanWOrG1HDrkxEEANkHc2Rz\n"
+            + "YJsO4v63xo/7/njLSQ31miOglb99ACKBA0Yl/jvj2KqLcomKILqvK3DKP+BHNq86\n"
+            + "LUBUglyKjKuj0wkSWT0tCnfgLzysUpowcoyFhJ36KzAz8hjqIn3TQpMF21HvkZdG\n"
+            + "Mtkcyhu5UDvbfOuWOBaKIeNQWCWv1rNzMme9A/9zU1+esEhKwGWEqa3/B/Te/xQh\n"
+            + "alk180n74sTZid6lXD8o8cEei0CUq7zBSV0P8v6kk8PP9/XyLRl3Rqa95fESUWrL\n"
+            + "xD6TBY1JlHBZS+N6rN/7Ilf5EXSELmnbDFsVxkNGp4elKxajvZxC6uEWYBu62AYy\n"
+            + "wS0dj8mZR3faCEps90YXiQEfBBgBAgAJBQJC7kcAAhsMAAoJEAAa34t4v31+qkcH\n"
+            + "/A5eF0w22B5VbfxsuTxiFwQHbcVbp8OMWMElv2b/KpFOHkIh4bZubb2h9Pg9NqY9\n"
+            + "vwa35i4MudBDcraNRFqTE0NxjCK6u9H+gdV3GwFFTs/HtQ95mTcEblWcglFPX3x0\n"
+            + "XsxYUn7HnAds1fAzwMO9TBONcsf9OUaXVciF5JMcJh30+trh6MShySSsNpmU8JB1\n"
+            + "PBj4/sraJ5vm2yo2kmRB4GuCUXZt6Zj8K1Aecz2rBrpkmqTJg1EXWeRJiffoE+42\n"
+            + "nK1jDUJpBX0Zt4+6A8e2yEQIvADXrRDOohJBRwmUpe21SpQaeg7R3Q/LNlPfrvqZ\n"
+            + "DCYWh5sxH28AIB4eO8PEPgU=\n"
+            + "=cSfw\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
    * Key revoked by an expired key, before that key's expiration.
-   * <p>
-   * Revoked by {@link #expiredKey()}.
+   *
+   * <p>Revoked by {@link #expiredKey()}.
    *
    * <pre>
    * pub   2048R/C43BF2E1 2005-08-01 [revoked: 2005-08-01]
@@ -917,112 +920,113 @@
    * </pre>
    */
   public static TestKey keyRevokedByExpiredKeyBeforeExpiration() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBELuRwABCADnf2z5dqp3BMFlpd6iUs5dhROrslfzswak1LmbGirK2IPIl4NX\n"
-        + "arAi76xXK9BcF/Cqcj/X/WqFKBd/qMGxwdvwbSN6PVBP6T1jvuVgrPTjd4x5xPUD\n"
-        + "xZ5VPy9hgQXs+1mugTkHYVTU8GI1eGpZ8Oj3PJIgVyqGxGkjWmcz5APbVIRan6L1\n"
-        + "482bZTidH9Nd9YnYlXNgiJcaOPAVBwO/j/myocQCIohvIo4IT8vc/ODhRgfwA0gD\n"
-        + "GVK+tXwT4f4x3qjG/YRpOOZZjBS09B/gJ9QfEnR6WNxg/Tm3T0uipoISOhR+cP/V\n"
-        + "e5o/73SM+w+WlILk/xpbbOfyCxD4Q3lb8EZFABEBAAGJAS0EIAECABcFAkLuYyAQ\n"
-        + "HQN0ZXN0OSBub3QgdXNlZAAKCRA87HgbF94azV2BB/9Rc1j3XOxKbDyUFAORAGnE\n"
-        + "ezQtpOmQhaSUhFC35GFOdTg4eX53FTFSXLJQleTVzvE+eVkQI5tvUZ+SqHoyjnhU\n"
-        + "DpWlmfRUQy4GTUjUTkpFOK07TVTjhUQwaAxN13UZgByopVKc7hLf+uh1xkRJIqAJ\n"
-        + "Tx6LIFZiSIGwStDO6TJlhl1e8h45J3rAV4N+DsGpMy9S4uYOU7erJDupdXK739/l\n"
-        + "VBsP2SeT85iuAv+4A9Jq3+iq+cjK9q3QZCw1O6iI2v3seAWCI6HH3tVw4THr+M6T\n"
-        + "EdTGmyESjdAl+f7/uK0QNfqIMpvUf+AvMakrLi7WOeDs8mpUIjonpeQVLfz6I0Zo\n"
-        + "iQE3BB8BAgAhBQJC7lUQFwyAAR2e63ndOLBJk52crzzseBsX3hrNAgcAAAoJEGBA\n"
-        + "hmHEO/LhHjUH/R/7+iNBLAfKYbpprkWy/8eXVEJhxfh6DI/ppsKLIA+687gX74R9\n"
-        + "6CM5k6fZDjeND26ZEA0rDZmYrbnGUfsu55aeM0/+jiSOZJ2uTlrLXiHMurbNY0pT\n"
-        + "xv215muhumPBzuL1jsAK2Kc/4oE7Z46jaStsPCvDOcx9PW76wR8/uCPvHVz5H/A7\n"
-        + "3erXAloC43jupXwZB32VZq8L0kZNVfuEsjHUcu3GUoZdGfTb4/Qq5a1FK+CGhwWC\n"
-        + "OwpUWZEIUImwUv4FNE4iNFYEHaHLU9fotmIxIkH8TC4NcO+GvkEyMyJ6NVkBBDP2\n"
-        + "EarncWAJxDBlx1CO4ET+/ULvzDnAcYuTc6G0IVRlc3R1c2VyIE5pbmUgPHRlc3Q5\n"
-        + "QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCQu5HAAIbAwYLCQgHAwIGFQgCCQoLBBYC\n"
-        + "AwECHgECF4AACgkQYECGYcQ78uG78ggA1TjeOZtaXjXNG8Bx2sl4W+ypylWWB6yc\n"
-        + "IeR0suLhVlisZ33yOtV4MsvZw0TJNyYmFXiskPTyOcP8RJjS+a41IHc33i13MUnN\n"
-        + "RI5cqhqsWRhf9chlm7XqXtqv57IjojG9vgSUeZdXSTMdHIDDHAjJ/ryBXflzprSw\n"
-        + "2Sab8OXjLkyo9z6ZytFyfXSc8TNiWU6Duollh/bWIsgPETIe2wGn8LcFiVMfPpsI\n"
-        + "RhkphOdTJb+W/zQwLHUcS22A4xsJtBxIXTH/QSG3lAaw8IRbl25EIpaEAF+gExCr\n"
-        + "QM0haAVMmGgYYWpMHXrDhB7ff3kAiqD2qmhSySA6NLmTO+6qGPYJg7kBDQRC7kcA\n"
-        + "AQgA2wqE3DypQhTcYl26dXc9DZzABRQa6KFRqQbhmUBz95cQpAamQjrwOyl2fg84\n"
-        + "b9o9t+DuZcdLzLF/gPVSznOcNUV9mJNdLAxBPPOMUrP/+Snb83FkNpCscrXhIqSf\n"
-        + "BU5D+FOb3bEI2WTJ7lLe8oCrWPE3JIDVCrpAWgZk9puAk1Z7ZFaHsS6ezsZP0YIM\n"
-        + "qTWdoX0zHMPMnr9GG08c0mniXtvfcgtOCeIRU4WZws28sGYCoLeQXsHVDal+gcLp\n"
-        + "1enPh6dfEWBJuhhBBajzm53fzV2a7khEdffggVVylHPLpvms2nIqoearDQtVNpSK\n"
-        + "uhNiykJSMIUn/Y6g5LMySmL+MwARAQABiQEfBBgBAgAJBQJC7kcAAhsMAAoJEGBA\n"
-        + "hmHEO/LhdwcH/0wAxT1NGaR2boMjpTouVUcnEcEzHc0dSwuu+06mLRggSdAfBC8C\n"
-        + "9fdlAYHQ5tp1sRuPwLfQZjo8wLxJ+wLASnIPLaGrtpEHkIKvDwHqwkOXvXeGD/Bh\n"
-        + "40NbJUa7Ec3Jpo+FPFlM8hDsUyHf8IhUAdRd4d+znOVEaZ6S7c1RrtoVTUqzi59n\n"
-        + "nC6ZewL/Jp+znKZlMTM3X1onAGhd+/XdrS52LM8pE3xRjbTLTYWcjnjyLbm0yoO8\n"
-        + "G3yCfIibAaII4a/jGON2X9ZUwaFNIqJ4iIc8Nme86rD/flXsu6Zv+NXVQWylrIG/\n"
-        + "REW68wsnWjwTtrPG8bqo6cCsOzqGYVt81eU=\n"
-        + "=FnZg\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBELuRwABCADnf2z5dqp3BMFlpd6iUs5dhROrslfzswak1LmbGirK2IPIl4NX\n"
+            + "arAi76xXK9BcF/Cqcj/X/WqFKBd/qMGxwdvwbSN6PVBP6T1jvuVgrPTjd4x5xPUD\n"
+            + "xZ5VPy9hgQXs+1mugTkHYVTU8GI1eGpZ8Oj3PJIgVyqGxGkjWmcz5APbVIRan6L1\n"
+            + "482bZTidH9Nd9YnYlXNgiJcaOPAVBwO/j/myocQCIohvIo4IT8vc/ODhRgfwA0gD\n"
+            + "GVK+tXwT4f4x3qjG/YRpOOZZjBS09B/gJ9QfEnR6WNxg/Tm3T0uipoISOhR+cP/V\n"
+            + "e5o/73SM+w+WlILk/xpbbOfyCxD4Q3lb8EZFABEBAAGJAS0EIAECABcFAkLuYyAQ\n"
+            + "HQN0ZXN0OSBub3QgdXNlZAAKCRA87HgbF94azV2BB/9Rc1j3XOxKbDyUFAORAGnE\n"
+            + "ezQtpOmQhaSUhFC35GFOdTg4eX53FTFSXLJQleTVzvE+eVkQI5tvUZ+SqHoyjnhU\n"
+            + "DpWlmfRUQy4GTUjUTkpFOK07TVTjhUQwaAxN13UZgByopVKc7hLf+uh1xkRJIqAJ\n"
+            + "Tx6LIFZiSIGwStDO6TJlhl1e8h45J3rAV4N+DsGpMy9S4uYOU7erJDupdXK739/l\n"
+            + "VBsP2SeT85iuAv+4A9Jq3+iq+cjK9q3QZCw1O6iI2v3seAWCI6HH3tVw4THr+M6T\n"
+            + "EdTGmyESjdAl+f7/uK0QNfqIMpvUf+AvMakrLi7WOeDs8mpUIjonpeQVLfz6I0Zo\n"
+            + "iQE3BB8BAgAhBQJC7lUQFwyAAR2e63ndOLBJk52crzzseBsX3hrNAgcAAAoJEGBA\n"
+            + "hmHEO/LhHjUH/R/7+iNBLAfKYbpprkWy/8eXVEJhxfh6DI/ppsKLIA+687gX74R9\n"
+            + "6CM5k6fZDjeND26ZEA0rDZmYrbnGUfsu55aeM0/+jiSOZJ2uTlrLXiHMurbNY0pT\n"
+            + "xv215muhumPBzuL1jsAK2Kc/4oE7Z46jaStsPCvDOcx9PW76wR8/uCPvHVz5H/A7\n"
+            + "3erXAloC43jupXwZB32VZq8L0kZNVfuEsjHUcu3GUoZdGfTb4/Qq5a1FK+CGhwWC\n"
+            + "OwpUWZEIUImwUv4FNE4iNFYEHaHLU9fotmIxIkH8TC4NcO+GvkEyMyJ6NVkBBDP2\n"
+            + "EarncWAJxDBlx1CO4ET+/ULvzDnAcYuTc6G0IVRlc3R1c2VyIE5pbmUgPHRlc3Q5\n"
+            + "QGV4YW1wbGUuY29tPokBOAQTAQIAIgUCQu5HAAIbAwYLCQgHAwIGFQgCCQoLBBYC\n"
+            + "AwECHgECF4AACgkQYECGYcQ78uG78ggA1TjeOZtaXjXNG8Bx2sl4W+ypylWWB6yc\n"
+            + "IeR0suLhVlisZ33yOtV4MsvZw0TJNyYmFXiskPTyOcP8RJjS+a41IHc33i13MUnN\n"
+            + "RI5cqhqsWRhf9chlm7XqXtqv57IjojG9vgSUeZdXSTMdHIDDHAjJ/ryBXflzprSw\n"
+            + "2Sab8OXjLkyo9z6ZytFyfXSc8TNiWU6Duollh/bWIsgPETIe2wGn8LcFiVMfPpsI\n"
+            + "RhkphOdTJb+W/zQwLHUcS22A4xsJtBxIXTH/QSG3lAaw8IRbl25EIpaEAF+gExCr\n"
+            + "QM0haAVMmGgYYWpMHXrDhB7ff3kAiqD2qmhSySA6NLmTO+6qGPYJg7kBDQRC7kcA\n"
+            + "AQgA2wqE3DypQhTcYl26dXc9DZzABRQa6KFRqQbhmUBz95cQpAamQjrwOyl2fg84\n"
+            + "b9o9t+DuZcdLzLF/gPVSznOcNUV9mJNdLAxBPPOMUrP/+Snb83FkNpCscrXhIqSf\n"
+            + "BU5D+FOb3bEI2WTJ7lLe8oCrWPE3JIDVCrpAWgZk9puAk1Z7ZFaHsS6ezsZP0YIM\n"
+            + "qTWdoX0zHMPMnr9GG08c0mniXtvfcgtOCeIRU4WZws28sGYCoLeQXsHVDal+gcLp\n"
+            + "1enPh6dfEWBJuhhBBajzm53fzV2a7khEdffggVVylHPLpvms2nIqoearDQtVNpSK\n"
+            + "uhNiykJSMIUn/Y6g5LMySmL+MwARAQABiQEfBBgBAgAJBQJC7kcAAhsMAAoJEGBA\n"
+            + "hmHEO/LhdwcH/0wAxT1NGaR2boMjpTouVUcnEcEzHc0dSwuu+06mLRggSdAfBC8C\n"
+            + "9fdlAYHQ5tp1sRuPwLfQZjo8wLxJ+wLASnIPLaGrtpEHkIKvDwHqwkOXvXeGD/Bh\n"
+            + "40NbJUa7Ec3Jpo+FPFlM8hDsUyHf8IhUAdRd4d+znOVEaZ6S7c1RrtoVTUqzi59n\n"
+            + "nC6ZewL/Jp+znKZlMTM3X1onAGhd+/XdrS52LM8pE3xRjbTLTYWcjnjyLbm0yoO8\n"
+            + "G3yCfIibAaII4a/jGON2X9ZUwaFNIqJ4iIc8Nme86rD/flXsu6Zv+NXVQWylrIG/\n"
+            + "REW68wsnWjwTtrPG8bqo6cCsOzqGYVt81eU=\n"
+            + "=FnZg\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBELuRwABCADnf2z5dqp3BMFlpd6iUs5dhROrslfzswak1LmbGirK2IPIl4NX\n"
-        + "arAi76xXK9BcF/Cqcj/X/WqFKBd/qMGxwdvwbSN6PVBP6T1jvuVgrPTjd4x5xPUD\n"
-        + "xZ5VPy9hgQXs+1mugTkHYVTU8GI1eGpZ8Oj3PJIgVyqGxGkjWmcz5APbVIRan6L1\n"
-        + "482bZTidH9Nd9YnYlXNgiJcaOPAVBwO/j/myocQCIohvIo4IT8vc/ODhRgfwA0gD\n"
-        + "GVK+tXwT4f4x3qjG/YRpOOZZjBS09B/gJ9QfEnR6WNxg/Tm3T0uipoISOhR+cP/V\n"
-        + "e5o/73SM+w+WlILk/xpbbOfyCxD4Q3lb8EZFABEBAAEAB/9GTcWLkUU9tf0B4LjX\n"
-        + "NSyk7ChIKXZadVEcN9pSR0Udq1mCTrk9kBID2iPNqWmyvjaBnQbUkoqJ+93/EAIa\n"
-        + "+NPRlWOD2SEN07ioFS5WCNCqUAEibfU2+woVu4WpJ+TjzoWy4F2wZxe7P3Gj6Xjq\n"
-        + "7aXih8uc9Lveh8GiUe8rrCCbt+BH1RzuV/khZw+2ZDPMCx7yfcfKobc3NWx75WLh\n"
-        + "pki512fawSC6eJHRI50ilPrqAmmhcccfwPji9P+oPj2S6wlhe5kp3R5yU85fWy3b\n"
-        + "C8AtLTfZIn4v6NAtBaurGEjRjzeNEGMJHxnRPWvFc4iD+xvPg6SNPJM/bbTE+yZ3\n"
-        + "16W1BADxjAQLMuGpemaVmOpZ3K02hcNjwniEK2QPp11BnfoQCIwegON+sUD/6AuZ\n"
-        + "S1vOVvS3//eGbPaMM45FK/SQAVHpC9IOL4Tql0C8B6csRhFL824yPfc3WDb4kayQ\n"
-        + "T5oLjlJ0W2r7tWcBcREEzZT6gNi4KI7C4oFF6tU9lsQJuQyAbwQA9Vl6VW/7oG0W\n"
-        + "CC+lcHJc+4rxUB3yak7d4mEccTNb+crOBRH/7dKZOe7A6Fz+ra++MmucDUzsAx0K\n"
-        + "MGT9Xoi5+CBBaNr+Y2lB9fF20N7eRNzQ3Xrz2OPl4cmU4gfECTZ1vZaKlmB+Vt8C\n"
-        + "E/nn49QGRI+BNBOdW+2aEpPoENczFosEAJXi5Cn2l0jOswDD7FU2PER1wfVY629i\n"
-        + "bICunudOSo64GKQslKkQWktc57DgdOQnH15qW1nVO7Z4H0GBxjSTRCu7Z7q08/qM\n"
-        + "ueWIvJ85HcFhOCl+vITOn0fZV0p8/IwsWz8G9h5bb2QgMAwDSdhnLuK/cXaGM09w\n"
-        + "n6k8O2rCvDtXRjqJATcEHwECACEFAkLuVRAXDIABHZ7red04sEmTnZyvPOx4Gxfe\n"
-        + "Gs0CBwAACgkQYECGYcQ78uEeNQf9H/v6I0EsB8phummuRbL/x5dUQmHF+HoMj+mm\n"
-        + "wosgD7rzuBfvhH3oIzmTp9kON40PbpkQDSsNmZitucZR+y7nlp4zT/6OJI5kna5O\n"
-        + "WsteIcy6ts1jSlPG/bXma6G6Y8HO4vWOwArYpz/igTtnjqNpK2w8K8M5zH09bvrB\n"
-        + "Hz+4I+8dXPkf8Dvd6tcCWgLjeO6lfBkHfZVmrwvSRk1V+4SyMdRy7cZShl0Z9Nvj\n"
-        + "9CrlrUUr4IaHBYI7ClRZkQhQibBS/gU0TiI0VgQdoctT1+i2YjEiQfxMLg1w74a+\n"
-        + "QTIzIno1WQEEM/YRqudxYAnEMGXHUI7gRP79Qu/MOcBxi5NzobQhVGVzdHVzZXIg\n"
-        + "TmluZSA8dGVzdDlAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJC7kcAAhsDBgsJCAcD\n"
-        + "AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBgQIZhxDvy4bvyCADVON45m1peNc0bwHHa\n"
-        + "yXhb7KnKVZYHrJwh5HSy4uFWWKxnffI61Xgyy9nDRMk3JiYVeKyQ9PI5w/xEmNL5\n"
-        + "rjUgdzfeLXcxSc1EjlyqGqxZGF/1yGWbtepe2q/nsiOiMb2+BJR5l1dJMx0cgMMc\n"
-        + "CMn+vIFd+XOmtLDZJpvw5eMuTKj3PpnK0XJ9dJzxM2JZToO6iWWH9tYiyA8RMh7b\n"
-        + "AafwtwWJUx8+mwhGGSmE51Mlv5b/NDAsdRxLbYDjGwm0HEhdMf9BIbeUBrDwhFuX\n"
-        + "bkQiloQAX6ATEKtAzSFoBUyYaBhhakwdesOEHt9/eQCKoPaqaFLJIDo0uZM77qoY\n"
-        + "9gmDnQOYBELuRwABCADbCoTcPKlCFNxiXbp1dz0NnMAFFBrooVGpBuGZQHP3lxCk\n"
-        + "BqZCOvA7KXZ+Dzhv2j234O5lx0vMsX+A9VLOc5w1RX2Yk10sDEE884xSs//5Kdvz\n"
-        + "cWQ2kKxyteEipJ8FTkP4U5vdsQjZZMnuUt7ygKtY8TckgNUKukBaBmT2m4CTVntk\n"
-        + "VoexLp7Oxk/RggypNZ2hfTMcw8yev0YbTxzSaeJe299yC04J4hFThZnCzbywZgKg\n"
-        + "t5BewdUNqX6BwunV6c+Hp18RYEm6GEEFqPObnd/NXZruSER19+CBVXKUc8um+aza\n"
-        + "ciqh5qsNC1U2lIq6E2LKQlIwhSf9jqDkszJKYv4zABEBAAEAB/0c76POOw6aazUT\n"
-        + "TZHUnhQ+WHHJefbKuoeWI7w+dD7y+02NzaRoZW7XnJ+fAZW8Dlb5k/O1FayUIEgE\n"
-        + "GjnT336dpE4g5NQkfdifG7Fy5NKGRkWx6viJI3g/OHsYX3+ebNDFMmO0gq7067/9\n"
-        + "WuHsTpvUMRwkF1zi1j4AETjZ7IBXdjuSCSu8OhEwr3d+WXibEmY5ec/d24l/APJx\n"
-        + "c3RMHw9PiDQeAKrByS6N10/yFgRpnouVx3wC7zFmhVewNV476Nyg34OvRoc+lCtk\n"
-        + "ixKdua6KuUJzGRWxgw+q2JD4goXxe0v2qU2KSU63gOYi0kg9tpwpn98lDNQykgmJ\n"
-        + "aQYdNIZJBADdlbkg9qbH1DREs7UF4jXN/SoYRbTh9639GfA4zkbfPmh/RmVIIEKd\n"
-        + "QN7qWK/Xy1bUS9vDzRfFgmoYGtqMmygOOFsVtfm8Y18lSXopN/3vhtai+dn+04Ef\n"
-        + "dl1irmGvm3p7y9Jh3s6uYTEJok0MywA7qBHvgSTVtc1PcZc6j6Bz1QQA/Q+nqyZY\n"
-        + "fLimt4KVYO1y6kSHgEqzggLTxyfGMW5RplTA0V1zCwjM6S+QWNqRxVNdB9Kkzn+S\n"
-        + "YDKHLYs8lXO2zvf8Yk9M7glgqvT4rJ51Zn2rc6lg1YUwFBXup5idTsuZwtqkvvKJ\n"
-        + "eS7L3cSBCqJMRjk47Y3V8zkrrN/HcYmyFecD/A+HPf4eSweUS025Bb+eCk4gTHbR\n"
-        + "uwmnKq7npk2XY4m0A/QdYF9dEWlpadsAr+ZwNQB3f21nQgKG0BudfL4FmpeW9RMt\n"
-        + "35aSIaV7RkxYOt5HEvjFRvLbeL1YYaj+D0dvz8SP1AUPvpWIVlQ03OjRlPyrPW50\n"
-        + "LoqyP8PTb6svnHvmQseJAR8EGAECAAkFAkLuRwACGwwACgkQYECGYcQ78uF3Bwf/\n"
-        + "TADFPU0ZpHZugyOlOi5VRycRwTMdzR1LC677TqYtGCBJ0B8ELwL192UBgdDm2nWx\n"
-        + "G4/At9BmOjzAvEn7AsBKcg8toau2kQeQgq8PAerCQ5e9d4YP8GHjQ1slRrsRzcmm\n"
-        + "j4U8WUzyEOxTId/wiFQB1F3h37Oc5URpnpLtzVGu2hVNSrOLn2ecLpl7Av8mn7Oc\n"
-        + "pmUxMzdfWicAaF379d2tLnYszykTfFGNtMtNhZyOePItubTKg7wbfIJ8iJsBogjh\n"
-        + "r+MY43Zf1lTBoU0ioniIhzw2Z7zqsP9+Vey7pm/41dVBbKWsgb9ERbrzCydaPBO2\n"
-        + "s8bxuqjpwKw7OoZhW3zV5Q==\n"
-        + "=JxsF\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBELuRwABCADnf2z5dqp3BMFlpd6iUs5dhROrslfzswak1LmbGirK2IPIl4NX\n"
+            + "arAi76xXK9BcF/Cqcj/X/WqFKBd/qMGxwdvwbSN6PVBP6T1jvuVgrPTjd4x5xPUD\n"
+            + "xZ5VPy9hgQXs+1mugTkHYVTU8GI1eGpZ8Oj3PJIgVyqGxGkjWmcz5APbVIRan6L1\n"
+            + "482bZTidH9Nd9YnYlXNgiJcaOPAVBwO/j/myocQCIohvIo4IT8vc/ODhRgfwA0gD\n"
+            + "GVK+tXwT4f4x3qjG/YRpOOZZjBS09B/gJ9QfEnR6WNxg/Tm3T0uipoISOhR+cP/V\n"
+            + "e5o/73SM+w+WlILk/xpbbOfyCxD4Q3lb8EZFABEBAAEAB/9GTcWLkUU9tf0B4LjX\n"
+            + "NSyk7ChIKXZadVEcN9pSR0Udq1mCTrk9kBID2iPNqWmyvjaBnQbUkoqJ+93/EAIa\n"
+            + "+NPRlWOD2SEN07ioFS5WCNCqUAEibfU2+woVu4WpJ+TjzoWy4F2wZxe7P3Gj6Xjq\n"
+            + "7aXih8uc9Lveh8GiUe8rrCCbt+BH1RzuV/khZw+2ZDPMCx7yfcfKobc3NWx75WLh\n"
+            + "pki512fawSC6eJHRI50ilPrqAmmhcccfwPji9P+oPj2S6wlhe5kp3R5yU85fWy3b\n"
+            + "C8AtLTfZIn4v6NAtBaurGEjRjzeNEGMJHxnRPWvFc4iD+xvPg6SNPJM/bbTE+yZ3\n"
+            + "16W1BADxjAQLMuGpemaVmOpZ3K02hcNjwniEK2QPp11BnfoQCIwegON+sUD/6AuZ\n"
+            + "S1vOVvS3//eGbPaMM45FK/SQAVHpC9IOL4Tql0C8B6csRhFL824yPfc3WDb4kayQ\n"
+            + "T5oLjlJ0W2r7tWcBcREEzZT6gNi4KI7C4oFF6tU9lsQJuQyAbwQA9Vl6VW/7oG0W\n"
+            + "CC+lcHJc+4rxUB3yak7d4mEccTNb+crOBRH/7dKZOe7A6Fz+ra++MmucDUzsAx0K\n"
+            + "MGT9Xoi5+CBBaNr+Y2lB9fF20N7eRNzQ3Xrz2OPl4cmU4gfECTZ1vZaKlmB+Vt8C\n"
+            + "E/nn49QGRI+BNBOdW+2aEpPoENczFosEAJXi5Cn2l0jOswDD7FU2PER1wfVY629i\n"
+            + "bICunudOSo64GKQslKkQWktc57DgdOQnH15qW1nVO7Z4H0GBxjSTRCu7Z7q08/qM\n"
+            + "ueWIvJ85HcFhOCl+vITOn0fZV0p8/IwsWz8G9h5bb2QgMAwDSdhnLuK/cXaGM09w\n"
+            + "n6k8O2rCvDtXRjqJATcEHwECACEFAkLuVRAXDIABHZ7red04sEmTnZyvPOx4Gxfe\n"
+            + "Gs0CBwAACgkQYECGYcQ78uEeNQf9H/v6I0EsB8phummuRbL/x5dUQmHF+HoMj+mm\n"
+            + "wosgD7rzuBfvhH3oIzmTp9kON40PbpkQDSsNmZitucZR+y7nlp4zT/6OJI5kna5O\n"
+            + "WsteIcy6ts1jSlPG/bXma6G6Y8HO4vWOwArYpz/igTtnjqNpK2w8K8M5zH09bvrB\n"
+            + "Hz+4I+8dXPkf8Dvd6tcCWgLjeO6lfBkHfZVmrwvSRk1V+4SyMdRy7cZShl0Z9Nvj\n"
+            + "9CrlrUUr4IaHBYI7ClRZkQhQibBS/gU0TiI0VgQdoctT1+i2YjEiQfxMLg1w74a+\n"
+            + "QTIzIno1WQEEM/YRqudxYAnEMGXHUI7gRP79Qu/MOcBxi5NzobQhVGVzdHVzZXIg\n"
+            + "TmluZSA8dGVzdDlAZXhhbXBsZS5jb20+iQE4BBMBAgAiBQJC7kcAAhsDBgsJCAcD\n"
+            + "AgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBgQIZhxDvy4bvyCADVON45m1peNc0bwHHa\n"
+            + "yXhb7KnKVZYHrJwh5HSy4uFWWKxnffI61Xgyy9nDRMk3JiYVeKyQ9PI5w/xEmNL5\n"
+            + "rjUgdzfeLXcxSc1EjlyqGqxZGF/1yGWbtepe2q/nsiOiMb2+BJR5l1dJMx0cgMMc\n"
+            + "CMn+vIFd+XOmtLDZJpvw5eMuTKj3PpnK0XJ9dJzxM2JZToO6iWWH9tYiyA8RMh7b\n"
+            + "AafwtwWJUx8+mwhGGSmE51Mlv5b/NDAsdRxLbYDjGwm0HEhdMf9BIbeUBrDwhFuX\n"
+            + "bkQiloQAX6ATEKtAzSFoBUyYaBhhakwdesOEHt9/eQCKoPaqaFLJIDo0uZM77qoY\n"
+            + "9gmDnQOYBELuRwABCADbCoTcPKlCFNxiXbp1dz0NnMAFFBrooVGpBuGZQHP3lxCk\n"
+            + "BqZCOvA7KXZ+Dzhv2j234O5lx0vMsX+A9VLOc5w1RX2Yk10sDEE884xSs//5Kdvz\n"
+            + "cWQ2kKxyteEipJ8FTkP4U5vdsQjZZMnuUt7ygKtY8TckgNUKukBaBmT2m4CTVntk\n"
+            + "VoexLp7Oxk/RggypNZ2hfTMcw8yev0YbTxzSaeJe299yC04J4hFThZnCzbywZgKg\n"
+            + "t5BewdUNqX6BwunV6c+Hp18RYEm6GEEFqPObnd/NXZruSER19+CBVXKUc8um+aza\n"
+            + "ciqh5qsNC1U2lIq6E2LKQlIwhSf9jqDkszJKYv4zABEBAAEAB/0c76POOw6aazUT\n"
+            + "TZHUnhQ+WHHJefbKuoeWI7w+dD7y+02NzaRoZW7XnJ+fAZW8Dlb5k/O1FayUIEgE\n"
+            + "GjnT336dpE4g5NQkfdifG7Fy5NKGRkWx6viJI3g/OHsYX3+ebNDFMmO0gq7067/9\n"
+            + "WuHsTpvUMRwkF1zi1j4AETjZ7IBXdjuSCSu8OhEwr3d+WXibEmY5ec/d24l/APJx\n"
+            + "c3RMHw9PiDQeAKrByS6N10/yFgRpnouVx3wC7zFmhVewNV476Nyg34OvRoc+lCtk\n"
+            + "ixKdua6KuUJzGRWxgw+q2JD4goXxe0v2qU2KSU63gOYi0kg9tpwpn98lDNQykgmJ\n"
+            + "aQYdNIZJBADdlbkg9qbH1DREs7UF4jXN/SoYRbTh9639GfA4zkbfPmh/RmVIIEKd\n"
+            + "QN7qWK/Xy1bUS9vDzRfFgmoYGtqMmygOOFsVtfm8Y18lSXopN/3vhtai+dn+04Ef\n"
+            + "dl1irmGvm3p7y9Jh3s6uYTEJok0MywA7qBHvgSTVtc1PcZc6j6Bz1QQA/Q+nqyZY\n"
+            + "fLimt4KVYO1y6kSHgEqzggLTxyfGMW5RplTA0V1zCwjM6S+QWNqRxVNdB9Kkzn+S\n"
+            + "YDKHLYs8lXO2zvf8Yk9M7glgqvT4rJ51Zn2rc6lg1YUwFBXup5idTsuZwtqkvvKJ\n"
+            + "eS7L3cSBCqJMRjk47Y3V8zkrrN/HcYmyFecD/A+HPf4eSweUS025Bb+eCk4gTHbR\n"
+            + "uwmnKq7npk2XY4m0A/QdYF9dEWlpadsAr+ZwNQB3f21nQgKG0BudfL4FmpeW9RMt\n"
+            + "35aSIaV7RkxYOt5HEvjFRvLbeL1YYaj+D0dvz8SP1AUPvpWIVlQ03OjRlPyrPW50\n"
+            + "LoqyP8PTb6svnHvmQseJAR8EGAECAAkFAkLuRwACGwwACgkQYECGYcQ78uF3Bwf/\n"
+            + "TADFPU0ZpHZugyOlOi5VRycRwTMdzR1LC677TqYtGCBJ0B8ELwL192UBgdDm2nWx\n"
+            + "G4/At9BmOjzAvEn7AsBKcg8toau2kQeQgq8PAerCQ5e9d4YP8GHjQ1slRrsRzcmm\n"
+            + "j4U8WUzyEOxTId/wiFQB1F3h37Oc5URpnpLtzVGu2hVNSrOLn2ecLpl7Av8mn7Oc\n"
+            + "pmUxMzdfWicAaF379d2tLnYszykTfFGNtMtNhZyOePItubTKg7wbfIJ8iJsBogjh\n"
+            + "r+MY43Zf1lTBoU0ioniIhzw2Z7zqsP9+Vey7pm/41dVBbKWsgb9ERbrzCydaPBO2\n"
+            + "s8bxuqjpwKw7OoZhW3zV5Q==\n"
+            + "=JxsF\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 }
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestTrustKeys.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestTrustKeys.java
index 55bb9c2..a469075 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestTrustKeys.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/testutil/TestTrustKeys.java
@@ -16,9 +16,10 @@
 
 /**
  * Test keys specific to web-of-trust checks.
- * <p>
- * In the following diagrams, the notation <code>M---N</code> indicates N trusts
- * M, and an 'x' indicates the key is expired.
+ *
+ * <p>In the following diagrams, the notation <code>M---N</code> indicates N trusts M, and an 'x'
+ * indicates the key is expired.
+ *
  * <p>
  *
  * <pre>
@@ -37,1011 +38,1002 @@
  */
 public class TestTrustKeys {
   /**
-   * pub   2048R/9FD0D396 2010-08-29
-   *       Key fingerprint = E401 17FC 4BF4 17BD 8F93  DEB1 D25A D07A 9FD0 D396
-   * uid                  Testuser A &lt;testa@example.com&gt;
-   * sub   2048R/F5C099DB 2010-08-29
+   * pub 2048R/9FD0D396 2010-08-29 Key fingerprint = E401 17FC 4BF4 17BD 8F93 DEB1 D25A D07A 9FD0
+   * D396 uid Testuser A &lt;testa@example.com&gt; sub 2048R/F5C099DB 2010-08-29
    */
   public static TestKey keyA() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx6npoBCACp0vHePNPeLzm0HM35i70bRChyZXu/ARxOHZHNbh6hWJkq5saI\n"
-        + "zuzZoaqXAr3xZwHftTULlkgoJIt40x6VCT8EBnUHTkOqoHTsFnXg2kNuhFvmn0OX\n"
-        + "7RQFlR1SGoQG4fEy6t/GlOwEknpNSIMLkbDMP2FzEkLVWtlIe2hqqawVIgqzyO3k\n"
-        + "HaQxW8gWyyTx0qeSdSi4DyFZIzdyu/aZa7sj/MhO3DB3UwubW6yE+PMcAVrJD+0d\n"
-        + "EToMT7i8Erncc+xEzuXAoQUHaQfOXV4DG5qSgVpKaLxJ/ABWUri0eMPhj0cT4iDx\n"
-        + "eNTL7cZ4h72B1uJs8byDN74PHrypNiVE+IRHABEBAAG0HlRlc3R1c2VyIEEgPHRl\n"
-        + "c3RhQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqemgIbAwYLCQgHAwIGFQgCCQoL\n"
-        + "BBYCAwECHgECF4AACgkQ0lrQep/Q05ZxMAf+OoRzXWbGfv7kZb7xdrVyAUTAV4bU\n"
-        + "UvLoJZUIQ1ckPBcty2LUvY7l9efgp3c57nvTD6U98dVnsKfaW4PT0CRXlpl1IFyh\n"
-        + "kgbInFS5rO+cJMQn1KyC+FfiwyGNii630SwiHyWRG5+XQ6Iptx9JELwWUMCLJxFp\n"
-        + "B8DZQKlNnvdl+YUgEeQOkWTXfTSaBATdXHiZhskiumnTOGO24jSg8CrZc5O/n6fC\n"
-        + "CgEsAFWL7fnO0ii6EW1JH5btLHPxL9QI+5DJIypgOhGI1lqZW9KrpfmJ3w6N1Gek\n"
-        + "GBda98DmzxxxZ9iyq1cELAAiQMjkvws67cOs/hwXNn9YaK74dzhb49MLGIkBIAQQ\n"
-        + "AQIACgUCTHqf0QMFAXgACgkQV2Bph7AH1JCO/Qf+PBJqeWS7p32+K5r1cA7AeCB2\n"
-        + "pcHs78wLjnSxuimf0l+JItb9JQAKjzcdZTKVGkUivkq3zhsPCCtssgSav2wlG59F\n"
-        + "TaqtpGOxvGjc8TKWHW1TrPhV86wh0yUempKTMWfdZ0RAJVG3krAj60bzUsQNK41/\n"
-        + "0EZi4JI+sm/TRlwQcmEzdaGxhFSJqiJyaBWbPL8AQNA2iRyjMKNeGCrgapEl2IkW\n"
-        + "2ST+/yUPI/485LS0uU1+TLB+NhiJ6j5PoiVqYD+ul8WJ+cy1vvcp1GCQpbRv1yXY\n"
-        + "4GB1mw0JPIinVE1q+eKKQxN38zARPqyupiIuBQaqX9NCHCAdNtFc3kJQ7Nm83YkB\n"
-        + "IAQQAQIACgUCTHqkCwMFAXgACgkQZB8Rk9JP5GfGVQgArMBVQo3AD56p4g5A+DRA\n"
-        + "h0KdQMt4hs/dl+2GLAi+nK0wwuHrHvr9kcZNiQNMtu+YiwvxMpJ/JvXRwOp4wbEx\n"
-        + "6P6Uzp18R2sqbV4agnL5tXFZXfsa3OR2NLm56Ox1ReHnZtAcC6qa1nHqt9z2sTt1\n"
-        + "vh7IfK8GDU/3M3z4XBXPpmpZPAczqujuO/yshz84O6oc3noXfRUJRklbkhNC3WyS\n"
-        + "u5+3nupq4GwIYehQQpxBTD9xXj4hl3KfUnctg/MkgUGweEK3oZ22kObTLJttTP9t\n"
-        + "9q/hLkVyDtFhGorcsYbNZyupm3xhddzYovkReePwOO4WA7VeRqRdiYDU1UjIKvv4\n"
-        + "TrkBDQRMep6aAQgA3NQtBhS8yiEGN8rT4hGtuuprVd5jQVprLz4ImcI2+Gt71+CR\n"
-        + "gv/BZ0zzFp3VPjTGRusungJYkKKOGpEpERiqEG1X/ZyL7EzoyT+iKIMDsVJgmyDN\n"
-        + "cryHTejlKA8Z6GQ1hPlOIws22oLq5zQXxD9pzMDuabHl/s/bYlU5qXc7LhxdtrmT\n"
-        + "b2uBP9a+eneWKrz8OfgtS5m9DgqJ6Bjl0TvbeVJgKHX42pqzJlBTCn3hJjJosy8x\n"
-        + "4qTbqMraENnl9y+qynM7atoHX6TPWsD7vWtWvi+FA5OWGEe3rof8o/sJSj05DQUn\n"
-        + "i8mmSiCYW/tUklPPXOvPRP0GZ/GhBzIUtE3jBwARAQABiQEfBBgBAgAJBQJMep6a\n"
-        + "AhsMAAoJENJa0Hqf0NOW/gkH/jm9FL+S53NjrthdbNjffryhp7KhTmYAsRk3Hc3X\n"
-        + "4TBj3upecarJynpvsz5HlLi/OxDRR6L2yfjKk6/2iKAbV56mdnnu5xG3TG8++naL\n"
-        + "7n/s9TGBhgknb6+vGhSMZ/1dpQ6wkiyuEmgKJo8DzHAh3k3VATHiBeSD7fNSsgtK\n"
-        + "gzK0hi53IFRFDDPYiCca+SS6/pA2zF56JWGETiIa8rSHIQaK4hNJ38vgKOZM80vQ\n"
-        + "fp+CxvJkYY71Yc94oQByaQzrXod7xnukp5SXe/N3BYTFCWoaSTRUI/THRywWwKqa\n"
-        + "rUsttYrqs/EQSy0X3kZ7CAm04uzA8csNyxapEVRvJxbrt5I=\n"
-        + "=DAMW\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6npoBCACp0vHePNPeLzm0HM35i70bRChyZXu/ARxOHZHNbh6hWJkq5saI\n"
+            + "zuzZoaqXAr3xZwHftTULlkgoJIt40x6VCT8EBnUHTkOqoHTsFnXg2kNuhFvmn0OX\n"
+            + "7RQFlR1SGoQG4fEy6t/GlOwEknpNSIMLkbDMP2FzEkLVWtlIe2hqqawVIgqzyO3k\n"
+            + "HaQxW8gWyyTx0qeSdSi4DyFZIzdyu/aZa7sj/MhO3DB3UwubW6yE+PMcAVrJD+0d\n"
+            + "EToMT7i8Erncc+xEzuXAoQUHaQfOXV4DG5qSgVpKaLxJ/ABWUri0eMPhj0cT4iDx\n"
+            + "eNTL7cZ4h72B1uJs8byDN74PHrypNiVE+IRHABEBAAG0HlRlc3R1c2VyIEEgPHRl\n"
+            + "c3RhQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqemgIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQ0lrQep/Q05ZxMAf+OoRzXWbGfv7kZb7xdrVyAUTAV4bU\n"
+            + "UvLoJZUIQ1ckPBcty2LUvY7l9efgp3c57nvTD6U98dVnsKfaW4PT0CRXlpl1IFyh\n"
+            + "kgbInFS5rO+cJMQn1KyC+FfiwyGNii630SwiHyWRG5+XQ6Iptx9JELwWUMCLJxFp\n"
+            + "B8DZQKlNnvdl+YUgEeQOkWTXfTSaBATdXHiZhskiumnTOGO24jSg8CrZc5O/n6fC\n"
+            + "CgEsAFWL7fnO0ii6EW1JH5btLHPxL9QI+5DJIypgOhGI1lqZW9KrpfmJ3w6N1Gek\n"
+            + "GBda98DmzxxxZ9iyq1cELAAiQMjkvws67cOs/hwXNn9YaK74dzhb49MLGIkBIAQQ\n"
+            + "AQIACgUCTHqf0QMFAXgACgkQV2Bph7AH1JCO/Qf+PBJqeWS7p32+K5r1cA7AeCB2\n"
+            + "pcHs78wLjnSxuimf0l+JItb9JQAKjzcdZTKVGkUivkq3zhsPCCtssgSav2wlG59F\n"
+            + "TaqtpGOxvGjc8TKWHW1TrPhV86wh0yUempKTMWfdZ0RAJVG3krAj60bzUsQNK41/\n"
+            + "0EZi4JI+sm/TRlwQcmEzdaGxhFSJqiJyaBWbPL8AQNA2iRyjMKNeGCrgapEl2IkW\n"
+            + "2ST+/yUPI/485LS0uU1+TLB+NhiJ6j5PoiVqYD+ul8WJ+cy1vvcp1GCQpbRv1yXY\n"
+            + "4GB1mw0JPIinVE1q+eKKQxN38zARPqyupiIuBQaqX9NCHCAdNtFc3kJQ7Nm83YkB\n"
+            + "IAQQAQIACgUCTHqkCwMFAXgACgkQZB8Rk9JP5GfGVQgArMBVQo3AD56p4g5A+DRA\n"
+            + "h0KdQMt4hs/dl+2GLAi+nK0wwuHrHvr9kcZNiQNMtu+YiwvxMpJ/JvXRwOp4wbEx\n"
+            + "6P6Uzp18R2sqbV4agnL5tXFZXfsa3OR2NLm56Ox1ReHnZtAcC6qa1nHqt9z2sTt1\n"
+            + "vh7IfK8GDU/3M3z4XBXPpmpZPAczqujuO/yshz84O6oc3noXfRUJRklbkhNC3WyS\n"
+            + "u5+3nupq4GwIYehQQpxBTD9xXj4hl3KfUnctg/MkgUGweEK3oZ22kObTLJttTP9t\n"
+            + "9q/hLkVyDtFhGorcsYbNZyupm3xhddzYovkReePwOO4WA7VeRqRdiYDU1UjIKvv4\n"
+            + "TrkBDQRMep6aAQgA3NQtBhS8yiEGN8rT4hGtuuprVd5jQVprLz4ImcI2+Gt71+CR\n"
+            + "gv/BZ0zzFp3VPjTGRusungJYkKKOGpEpERiqEG1X/ZyL7EzoyT+iKIMDsVJgmyDN\n"
+            + "cryHTejlKA8Z6GQ1hPlOIws22oLq5zQXxD9pzMDuabHl/s/bYlU5qXc7LhxdtrmT\n"
+            + "b2uBP9a+eneWKrz8OfgtS5m9DgqJ6Bjl0TvbeVJgKHX42pqzJlBTCn3hJjJosy8x\n"
+            + "4qTbqMraENnl9y+qynM7atoHX6TPWsD7vWtWvi+FA5OWGEe3rof8o/sJSj05DQUn\n"
+            + "i8mmSiCYW/tUklPPXOvPRP0GZ/GhBzIUtE3jBwARAQABiQEfBBgBAgAJBQJMep6a\n"
+            + "AhsMAAoJENJa0Hqf0NOW/gkH/jm9FL+S53NjrthdbNjffryhp7KhTmYAsRk3Hc3X\n"
+            + "4TBj3upecarJynpvsz5HlLi/OxDRR6L2yfjKk6/2iKAbV56mdnnu5xG3TG8++naL\n"
+            + "7n/s9TGBhgknb6+vGhSMZ/1dpQ6wkiyuEmgKJo8DzHAh3k3VATHiBeSD7fNSsgtK\n"
+            + "gzK0hi53IFRFDDPYiCca+SS6/pA2zF56JWGETiIa8rSHIQaK4hNJ38vgKOZM80vQ\n"
+            + "fp+CxvJkYY71Yc94oQByaQzrXod7xnukp5SXe/N3BYTFCWoaSTRUI/THRywWwKqa\n"
+            + "rUsttYrqs/EQSy0X3kZ7CAm04uzA8csNyxapEVRvJxbrt5I=\n"
+            + "=DAMW\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx6npoBCACp0vHePNPeLzm0HM35i70bRChyZXu/ARxOHZHNbh6hWJkq5saI\n"
-        + "zuzZoaqXAr3xZwHftTULlkgoJIt40x6VCT8EBnUHTkOqoHTsFnXg2kNuhFvmn0OX\n"
-        + "7RQFlR1SGoQG4fEy6t/GlOwEknpNSIMLkbDMP2FzEkLVWtlIe2hqqawVIgqzyO3k\n"
-        + "HaQxW8gWyyTx0qeSdSi4DyFZIzdyu/aZa7sj/MhO3DB3UwubW6yE+PMcAVrJD+0d\n"
-        + "EToMT7i8Erncc+xEzuXAoQUHaQfOXV4DG5qSgVpKaLxJ/ABWUri0eMPhj0cT4iDx\n"
-        + "eNTL7cZ4h72B1uJs8byDN74PHrypNiVE+IRHABEBAAEAB/9BbaG9Bz9zd0tqjrx2\n"
-        + "u/VQR3qz1FCQXtuqZu8RMC+B5zIf2si71clf8c7ZHnfSxWZt65Ez1SMYwDeyBdje\n"
-        + "/7B1Gw3Ekk00tFxHx0GEL2NSdZE4sbynkHIp0nD4/HlIc41rmh08E405F7wiAWFn\n"
-        + "uCpfDr47SNpR/A4BxHYOvi8r9pBxn/fXiHluqYROit0Z4tfKDCvQ47k+wqVD5nOt\n"
-        + "BEbHDfEwUMibgTuJ1qPyHf6HDlSdTQSfYV8QW1/UbHWus9QikfjGfLJpX0Rv3UG+\n"
-        + "WXHmowpRDVixj74UQCYXQ/AZi/OBlcS8PRY6EZV4RLyEWlZrdzKViNLOTUbJNHvA\n"
-        + "ZAQVBADQND7CIO6z4k8e9Z8Lf4iLWP9iIbH9R7ArTZr2mX1vkwp+sk0BNQurL/BQ\n"
-        + "jUHOJZnouwkc+C3pQi/JvGvAe1fLHPA0+NKe/tcuDXMk+L1HH6XmDgKtByac41AR\n"
-        + "txxqhaECNeK9OKXAXaEvenkGFMcqQV3QMiF2q5VlmFxSSXydEwQA0M8tCowz0iZF\n"
-        + "i3fGuuZDTN3Ut4u6Uf9FiLcR4ye2Aa5ppO8vlNjObNqpHz0UqdDjB+e3O/n7BUx3\n"
-        + "A5PRZNQvcMbhgr2U3zjWvFMHS3YuxbuIaZ1Vj69vpOAGkUc98v4i0/3Lk7Lijpto\n"
-        + "n40S0eCVo+eccHA4HRvS5XSdNGHVJn0EAMzfBt3DalOlHm+PrAiZdVdp5IfbJwJv\n"
-        + "xkyI++0p4VaYTZhOxjswTs6vgv30FBmHAlx1FzoUOKLaOhxPyLgamFd9YG+ab4DK\n"
-        + "chc4TxIj3kkx3/m6JufW8DWhKyAJNZ/MW+Iqop5pUIeTbOBlNyaflK+XxjkP71rP\n"
-        + "2gZx4pjYjK5EPDy0HlRlc3R1c2VyIEEgPHRlc3RhQGV4YW1wbGUuY29tPokBOAQT\n"
-        + "AQIAIgUCTHqemgIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ0lrQep/Q\n"
-        + "05ZxMAf+OoRzXWbGfv7kZb7xdrVyAUTAV4bUUvLoJZUIQ1ckPBcty2LUvY7l9efg\n"
-        + "p3c57nvTD6U98dVnsKfaW4PT0CRXlpl1IFyhkgbInFS5rO+cJMQn1KyC+FfiwyGN\n"
-        + "ii630SwiHyWRG5+XQ6Iptx9JELwWUMCLJxFpB8DZQKlNnvdl+YUgEeQOkWTXfTSa\n"
-        + "BATdXHiZhskiumnTOGO24jSg8CrZc5O/n6fCCgEsAFWL7fnO0ii6EW1JH5btLHPx\n"
-        + "L9QI+5DJIypgOhGI1lqZW9KrpfmJ3w6N1GekGBda98DmzxxxZ9iyq1cELAAiQMjk\n"
-        + "vws67cOs/hwXNn9YaK74dzhb49MLGJ0DmARMep6aAQgA3NQtBhS8yiEGN8rT4hGt\n"
-        + "uuprVd5jQVprLz4ImcI2+Gt71+CRgv/BZ0zzFp3VPjTGRusungJYkKKOGpEpERiq\n"
-        + "EG1X/ZyL7EzoyT+iKIMDsVJgmyDNcryHTejlKA8Z6GQ1hPlOIws22oLq5zQXxD9p\n"
-        + "zMDuabHl/s/bYlU5qXc7LhxdtrmTb2uBP9a+eneWKrz8OfgtS5m9DgqJ6Bjl0Tvb\n"
-        + "eVJgKHX42pqzJlBTCn3hJjJosy8x4qTbqMraENnl9y+qynM7atoHX6TPWsD7vWtW\n"
-        + "vi+FA5OWGEe3rof8o/sJSj05DQUni8mmSiCYW/tUklPPXOvPRP0GZ/GhBzIUtE3j\n"
-        + "BwARAQABAAf+KQOPSS3Y0oHHsd0N9VLrPWgEf3JKZPzyI1gWKNiVdRYhbjrbS8VM\n"
-        + "mm8ERxMRY/hRSyKrCdXNtS87zVtgkThPfbWRPh0xL7YpFhenena63Ng78RPqlIDH\n"
-        + "cITs6r/DRBI4jnXvOTr/+R2Pm1llgKF2ePzsSt0rpmPcjyrdBsiKSUnLGxm4tGtW\n"
-        + "wVoEjy3+MRN2ULyTO8Pe4URKTtUkkb23iuQuJZy+k+SfH+H0/3oEb8ERRE3UXNG7\n"
-        + "BIbaj71nsx8+H8+x8ffRm1s5Unn86AJ418oEhxNzQk59NnrrlJ4HH9NNbjjzI3JE\n"
-        + "intSQKhFJsvMARdzX062yartQtnm1v6jwQQA65rpMMHCoh9pxvL6yagw3WjQLEPw\n"
-        + "vOGpD9ossBvcv/SfAe7SgJsx6J6X0IIW6EKIjyRhWTIfK/rVR0cmUFTGStib+y22\n"
-        + "BPcQmt/Oiw9rdUfOmDrnosPC0SB+19tKw1v1AfW5swpJnGBCkGz9UfX4Fr/eTS3e\n"
-        + "2KaMq+r1KALSUVkEAO/x0SWOiBRH3X1ETNE9nLTP6u2W3TAvrd+dXyP7JjXWZPB8\n"
-        + "NOwT7qidvUlhTbxdR7xWNI1W924Ywwgs43cAPGyq95pjdzhvi0Xxab7124UK+MS3\n"
-        + "V4WBvjOYYW8pkdMOydRLETXSkco2mDCRTiVKe3Zi7p+lKlVJj4xrFUPUnetfBADH\n"
-        + "EPwYeeZ8sQnW644J75eoph2e5KLRJaOy5GMPRLNmq+ODtJxdoIGpfQnEA35nSlze\n"
-        + "Ea+1UvLBlWyF+p08bNfnXHp3j5ugucAYbVEs4ptUwTB3vFt7eJ8rkx9GYcuBFiwm\n"
-        + "H47rg7QmS1mWDLyX6v2pI9brsb1SCgBL+oi9CyjypkjqiQEfBBgBAgAJBQJMep6a\n"
-        + "AhsMAAoJENJa0Hqf0NOW/gkH/jm9FL+S53NjrthdbNjffryhp7KhTmYAsRk3Hc3X\n"
-        + "4TBj3upecarJynpvsz5HlLi/OxDRR6L2yfjKk6/2iKAbV56mdnnu5xG3TG8++naL\n"
-        + "7n/s9TGBhgknb6+vGhSMZ/1dpQ6wkiyuEmgKJo8DzHAh3k3VATHiBeSD7fNSsgtK\n"
-        + "gzK0hi53IFRFDDPYiCca+SS6/pA2zF56JWGETiIa8rSHIQaK4hNJ38vgKOZM80vQ\n"
-        + "fp+CxvJkYY71Yc94oQByaQzrXod7xnukp5SXe/N3BYTFCWoaSTRUI/THRywWwKqa\n"
-        + "rUsttYrqs/EQSy0X3kZ7CAm04uzA8csNyxapEVRvJxbrt5I=\n"
-        + "=FLdD\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6npoBCACp0vHePNPeLzm0HM35i70bRChyZXu/ARxOHZHNbh6hWJkq5saI\n"
+            + "zuzZoaqXAr3xZwHftTULlkgoJIt40x6VCT8EBnUHTkOqoHTsFnXg2kNuhFvmn0OX\n"
+            + "7RQFlR1SGoQG4fEy6t/GlOwEknpNSIMLkbDMP2FzEkLVWtlIe2hqqawVIgqzyO3k\n"
+            + "HaQxW8gWyyTx0qeSdSi4DyFZIzdyu/aZa7sj/MhO3DB3UwubW6yE+PMcAVrJD+0d\n"
+            + "EToMT7i8Erncc+xEzuXAoQUHaQfOXV4DG5qSgVpKaLxJ/ABWUri0eMPhj0cT4iDx\n"
+            + "eNTL7cZ4h72B1uJs8byDN74PHrypNiVE+IRHABEBAAEAB/9BbaG9Bz9zd0tqjrx2\n"
+            + "u/VQR3qz1FCQXtuqZu8RMC+B5zIf2si71clf8c7ZHnfSxWZt65Ez1SMYwDeyBdje\n"
+            + "/7B1Gw3Ekk00tFxHx0GEL2NSdZE4sbynkHIp0nD4/HlIc41rmh08E405F7wiAWFn\n"
+            + "uCpfDr47SNpR/A4BxHYOvi8r9pBxn/fXiHluqYROit0Z4tfKDCvQ47k+wqVD5nOt\n"
+            + "BEbHDfEwUMibgTuJ1qPyHf6HDlSdTQSfYV8QW1/UbHWus9QikfjGfLJpX0Rv3UG+\n"
+            + "WXHmowpRDVixj74UQCYXQ/AZi/OBlcS8PRY6EZV4RLyEWlZrdzKViNLOTUbJNHvA\n"
+            + "ZAQVBADQND7CIO6z4k8e9Z8Lf4iLWP9iIbH9R7ArTZr2mX1vkwp+sk0BNQurL/BQ\n"
+            + "jUHOJZnouwkc+C3pQi/JvGvAe1fLHPA0+NKe/tcuDXMk+L1HH6XmDgKtByac41AR\n"
+            + "txxqhaECNeK9OKXAXaEvenkGFMcqQV3QMiF2q5VlmFxSSXydEwQA0M8tCowz0iZF\n"
+            + "i3fGuuZDTN3Ut4u6Uf9FiLcR4ye2Aa5ppO8vlNjObNqpHz0UqdDjB+e3O/n7BUx3\n"
+            + "A5PRZNQvcMbhgr2U3zjWvFMHS3YuxbuIaZ1Vj69vpOAGkUc98v4i0/3Lk7Lijpto\n"
+            + "n40S0eCVo+eccHA4HRvS5XSdNGHVJn0EAMzfBt3DalOlHm+PrAiZdVdp5IfbJwJv\n"
+            + "xkyI++0p4VaYTZhOxjswTs6vgv30FBmHAlx1FzoUOKLaOhxPyLgamFd9YG+ab4DK\n"
+            + "chc4TxIj3kkx3/m6JufW8DWhKyAJNZ/MW+Iqop5pUIeTbOBlNyaflK+XxjkP71rP\n"
+            + "2gZx4pjYjK5EPDy0HlRlc3R1c2VyIEEgPHRlc3RhQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTHqemgIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ0lrQep/Q\n"
+            + "05ZxMAf+OoRzXWbGfv7kZb7xdrVyAUTAV4bUUvLoJZUIQ1ckPBcty2LUvY7l9efg\n"
+            + "p3c57nvTD6U98dVnsKfaW4PT0CRXlpl1IFyhkgbInFS5rO+cJMQn1KyC+FfiwyGN\n"
+            + "ii630SwiHyWRG5+XQ6Iptx9JELwWUMCLJxFpB8DZQKlNnvdl+YUgEeQOkWTXfTSa\n"
+            + "BATdXHiZhskiumnTOGO24jSg8CrZc5O/n6fCCgEsAFWL7fnO0ii6EW1JH5btLHPx\n"
+            + "L9QI+5DJIypgOhGI1lqZW9KrpfmJ3w6N1GekGBda98DmzxxxZ9iyq1cELAAiQMjk\n"
+            + "vws67cOs/hwXNn9YaK74dzhb49MLGJ0DmARMep6aAQgA3NQtBhS8yiEGN8rT4hGt\n"
+            + "uuprVd5jQVprLz4ImcI2+Gt71+CRgv/BZ0zzFp3VPjTGRusungJYkKKOGpEpERiq\n"
+            + "EG1X/ZyL7EzoyT+iKIMDsVJgmyDNcryHTejlKA8Z6GQ1hPlOIws22oLq5zQXxD9p\n"
+            + "zMDuabHl/s/bYlU5qXc7LhxdtrmTb2uBP9a+eneWKrz8OfgtS5m9DgqJ6Bjl0Tvb\n"
+            + "eVJgKHX42pqzJlBTCn3hJjJosy8x4qTbqMraENnl9y+qynM7atoHX6TPWsD7vWtW\n"
+            + "vi+FA5OWGEe3rof8o/sJSj05DQUni8mmSiCYW/tUklPPXOvPRP0GZ/GhBzIUtE3j\n"
+            + "BwARAQABAAf+KQOPSS3Y0oHHsd0N9VLrPWgEf3JKZPzyI1gWKNiVdRYhbjrbS8VM\n"
+            + "mm8ERxMRY/hRSyKrCdXNtS87zVtgkThPfbWRPh0xL7YpFhenena63Ng78RPqlIDH\n"
+            + "cITs6r/DRBI4jnXvOTr/+R2Pm1llgKF2ePzsSt0rpmPcjyrdBsiKSUnLGxm4tGtW\n"
+            + "wVoEjy3+MRN2ULyTO8Pe4URKTtUkkb23iuQuJZy+k+SfH+H0/3oEb8ERRE3UXNG7\n"
+            + "BIbaj71nsx8+H8+x8ffRm1s5Unn86AJ418oEhxNzQk59NnrrlJ4HH9NNbjjzI3JE\n"
+            + "intSQKhFJsvMARdzX062yartQtnm1v6jwQQA65rpMMHCoh9pxvL6yagw3WjQLEPw\n"
+            + "vOGpD9ossBvcv/SfAe7SgJsx6J6X0IIW6EKIjyRhWTIfK/rVR0cmUFTGStib+y22\n"
+            + "BPcQmt/Oiw9rdUfOmDrnosPC0SB+19tKw1v1AfW5swpJnGBCkGz9UfX4Fr/eTS3e\n"
+            + "2KaMq+r1KALSUVkEAO/x0SWOiBRH3X1ETNE9nLTP6u2W3TAvrd+dXyP7JjXWZPB8\n"
+            + "NOwT7qidvUlhTbxdR7xWNI1W924Ywwgs43cAPGyq95pjdzhvi0Xxab7124UK+MS3\n"
+            + "V4WBvjOYYW8pkdMOydRLETXSkco2mDCRTiVKe3Zi7p+lKlVJj4xrFUPUnetfBADH\n"
+            + "EPwYeeZ8sQnW644J75eoph2e5KLRJaOy5GMPRLNmq+ODtJxdoIGpfQnEA35nSlze\n"
+            + "Ea+1UvLBlWyF+p08bNfnXHp3j5ugucAYbVEs4ptUwTB3vFt7eJ8rkx9GYcuBFiwm\n"
+            + "H47rg7QmS1mWDLyX6v2pI9brsb1SCgBL+oi9CyjypkjqiQEfBBgBAgAJBQJMep6a\n"
+            + "AhsMAAoJENJa0Hqf0NOW/gkH/jm9FL+S53NjrthdbNjffryhp7KhTmYAsRk3Hc3X\n"
+            + "4TBj3upecarJynpvsz5HlLi/OxDRR6L2yfjKk6/2iKAbV56mdnnu5xG3TG8++naL\n"
+            + "7n/s9TGBhgknb6+vGhSMZ/1dpQ6wkiyuEmgKJo8DzHAh3k3VATHiBeSD7fNSsgtK\n"
+            + "gzK0hi53IFRFDDPYiCca+SS6/pA2zF56JWGETiIa8rSHIQaK4hNJ38vgKOZM80vQ\n"
+            + "fp+CxvJkYY71Yc94oQByaQzrXod7xnukp5SXe/N3BYTFCWoaSTRUI/THRywWwKqa\n"
+            + "rUsttYrqs/EQSy0X3kZ7CAm04uzA8csNyxapEVRvJxbrt5I=\n"
+            + "=FLdD\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/B007D490 2010-08-29 [expired: 2011-08-29]
-   *       Key fingerprint = 355D 5B98 FECE 6199 83CD  C91D 5760 6987 B007 D490
-   * uid                  Testuser B &lt;testb@example.com&gt;
+   * pub 2048R/B007D490 2010-08-29 [expired: 2011-08-29] Key fingerprint = 355D 5B98 FECE 6199 83CD
+   * C91D 5760 6987 B007 D490 uid Testuser B &lt;testb@example.com&gt;
    */
   public static TestKey keyB() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx6ntMBCADG7j/nuI+VvvNbPY9nnfLrAc3KTj0Z+DMxxUMYoZNLjTw1szQ0\n"
-        + "PuKKACiiSA9Oyj4R0aIhWdIR9iYxp6gQdje3yewzoqMwE+t5onYDpdX9QDFXyEzF\n"
-        + "UPWCjA7OSji1G6fyWakiYxKseqyRXOdHXI5TqMikBalmSpwwvmik0cfRGO+l6qvM\n"
-        + "mVJlcn6mkZB0d8WOPV8j8rFxmVSPn9SVP9L8HaFWv1uI9EY3zXbfNeDNgNeTWIMY\n"
-        + "75saINBA2LALBQ54u52GoSbaR8ukZYAjjkif3WIFI8B9xREwjUBLFy3E357aGyLZ\n"
-        + "jE8nsmPk4MDxDaeDNoSHJjcxtDWQJBub3u1zABEBAAG0HlRlc3R1c2VyIEIgPHRl\n"
-        + "c3RiQGV4YW1wbGUuY29tPokBPgQTAQIAKAUCTHqe0wIbAwUJAeEzgAYLCQgHAwIG\n"
-        + "FQgCCQoLBBYCAwECHgECF4AACgkQV2Bph7AH1JD0nQf/Vm+/Mvl99/y3Qw10S6et\n"
-        + "H6NYWDUeAKXe9mfXBJ39HdtlF50jZ5NzSwksAOSQtQZJ3tQQeElXB29cZDvAscva\n"
-        + "RiTtt+KUxDZSYbEHrC0EO7w0Wi5ltwaWdXnoitMOgPZ/grL7UpUbL8rB1evfLbhm\n"
-        + "AqC/6kgHuXeY/7EAzwU3o0wKbmfx1sh8AyQSi4unUwIDCV1RIAP0+ZfJSg0WwGoS\n"
-        + "JB5+lKajtIE6kMn9m8CWM66/zxSCY3XLcoXvjVxCYPwwgYSyje8dDxxOI+x7uj2I\n"
-        + "IjM5RHQ9hTsR7NQ9JUTFmpKZlcdah93NZLKJAFLUtOPjMa5d5t2O0ZOxZ5ftlhHp\n"
-        + "Q7kBDQRMep7TAQgAwOuLBXnACIsd879ld/vLcn8umpKV8MIUjrqOMjR0rNKpCUDw\n"
-        + "LxL4uVh3q/ksESHnQPPqxFYkgeA66SYrx4jwZjbZ5vv9BW99LHe8lSahqrJA9A9g\n"
-        + "5iw5hH+2ZWrGlu3P65UdQUJW+JaDx1IIBt3BbmdGDuKF/ESsy9qxEKq7tKqHI2JL\n"
-        + "Ed+6OIwWblU7ZogfiNpgZJ0lapxTe84mGsD0TowGTu5re/8wIJf1f2q4PuG+L9OZ\n"
-        + "0ZD5i9s1MAxdw4OD+705owPCQnqsr18nH9aUBHWJn9NCXb3jL7QGaId84Yq8SRlK\n"
-        + "wHSRtHLLJoowJ5fXw5UbZcUtRUergxFRwae87wARAQABiQElBBgBAgAPBQJMep7T\n"
-        + "AhsMBQkB4TOAAAoJEFdgaYewB9SQMbsH/iu1HY7OMJxd8EkfxairRNec/v9uEvYQ\n"
-        + "XqfEPw/Hihdef1TY8vB69ymAPd89e1PRDj1m+0/RivO045qFP7lbWMkjKeR9dXXe\n"
-        + "UzIEsTUJ1CNnA7C3fo11NBVg59E0d84bMKQx7n4AZkljgKFKghUb6OJZiWRdh+8W\n"
-        + "0I95JI2R7nMYw3L8/sSGxt+Vjhs9acB1DldbyYbJitYA4fhVZQH9zgeuhQqdCULQ\n"
-        + "ZexpkQqvG0o4iJKO4yeJNHdeM+NwH38wXfzydtEv6Dxz/YZSTwt08p97l6DQ//H7\n"
-        + "wek1LcqeX47YFa9Ftns8Y8fjh4S8Kyi1F6BhZKbsdDqg2hA+0AFv7LA=\n"
-        + "=tmW1\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6ntMBCADG7j/nuI+VvvNbPY9nnfLrAc3KTj0Z+DMxxUMYoZNLjTw1szQ0\n"
+            + "PuKKACiiSA9Oyj4R0aIhWdIR9iYxp6gQdje3yewzoqMwE+t5onYDpdX9QDFXyEzF\n"
+            + "UPWCjA7OSji1G6fyWakiYxKseqyRXOdHXI5TqMikBalmSpwwvmik0cfRGO+l6qvM\n"
+            + "mVJlcn6mkZB0d8WOPV8j8rFxmVSPn9SVP9L8HaFWv1uI9EY3zXbfNeDNgNeTWIMY\n"
+            + "75saINBA2LALBQ54u52GoSbaR8ukZYAjjkif3WIFI8B9xREwjUBLFy3E357aGyLZ\n"
+            + "jE8nsmPk4MDxDaeDNoSHJjcxtDWQJBub3u1zABEBAAG0HlRlc3R1c2VyIEIgPHRl\n"
+            + "c3RiQGV4YW1wbGUuY29tPokBPgQTAQIAKAUCTHqe0wIbAwUJAeEzgAYLCQgHAwIG\n"
+            + "FQgCCQoLBBYCAwECHgECF4AACgkQV2Bph7AH1JD0nQf/Vm+/Mvl99/y3Qw10S6et\n"
+            + "H6NYWDUeAKXe9mfXBJ39HdtlF50jZ5NzSwksAOSQtQZJ3tQQeElXB29cZDvAscva\n"
+            + "RiTtt+KUxDZSYbEHrC0EO7w0Wi5ltwaWdXnoitMOgPZ/grL7UpUbL8rB1evfLbhm\n"
+            + "AqC/6kgHuXeY/7EAzwU3o0wKbmfx1sh8AyQSi4unUwIDCV1RIAP0+ZfJSg0WwGoS\n"
+            + "JB5+lKajtIE6kMn9m8CWM66/zxSCY3XLcoXvjVxCYPwwgYSyje8dDxxOI+x7uj2I\n"
+            + "IjM5RHQ9hTsR7NQ9JUTFmpKZlcdah93NZLKJAFLUtOPjMa5d5t2O0ZOxZ5ftlhHp\n"
+            + "Q7kBDQRMep7TAQgAwOuLBXnACIsd879ld/vLcn8umpKV8MIUjrqOMjR0rNKpCUDw\n"
+            + "LxL4uVh3q/ksESHnQPPqxFYkgeA66SYrx4jwZjbZ5vv9BW99LHe8lSahqrJA9A9g\n"
+            + "5iw5hH+2ZWrGlu3P65UdQUJW+JaDx1IIBt3BbmdGDuKF/ESsy9qxEKq7tKqHI2JL\n"
+            + "Ed+6OIwWblU7ZogfiNpgZJ0lapxTe84mGsD0TowGTu5re/8wIJf1f2q4PuG+L9OZ\n"
+            + "0ZD5i9s1MAxdw4OD+705owPCQnqsr18nH9aUBHWJn9NCXb3jL7QGaId84Yq8SRlK\n"
+            + "wHSRtHLLJoowJ5fXw5UbZcUtRUergxFRwae87wARAQABiQElBBgBAgAPBQJMep7T\n"
+            + "AhsMBQkB4TOAAAoJEFdgaYewB9SQMbsH/iu1HY7OMJxd8EkfxairRNec/v9uEvYQ\n"
+            + "XqfEPw/Hihdef1TY8vB69ymAPd89e1PRDj1m+0/RivO045qFP7lbWMkjKeR9dXXe\n"
+            + "UzIEsTUJ1CNnA7C3fo11NBVg59E0d84bMKQx7n4AZkljgKFKghUb6OJZiWRdh+8W\n"
+            + "0I95JI2R7nMYw3L8/sSGxt+Vjhs9acB1DldbyYbJitYA4fhVZQH9zgeuhQqdCULQ\n"
+            + "ZexpkQqvG0o4iJKO4yeJNHdeM+NwH38wXfzydtEv6Dxz/YZSTwt08p97l6DQ//H7\n"
+            + "wek1LcqeX47YFa9Ftns8Y8fjh4S8Kyi1F6BhZKbsdDqg2hA+0AFv7LA=\n"
+            + "=tmW1\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx6ntMBCADG7j/nuI+VvvNbPY9nnfLrAc3KTj0Z+DMxxUMYoZNLjTw1szQ0\n"
-        + "PuKKACiiSA9Oyj4R0aIhWdIR9iYxp6gQdje3yewzoqMwE+t5onYDpdX9QDFXyEzF\n"
-        + "UPWCjA7OSji1G6fyWakiYxKseqyRXOdHXI5TqMikBalmSpwwvmik0cfRGO+l6qvM\n"
-        + "mVJlcn6mkZB0d8WOPV8j8rFxmVSPn9SVP9L8HaFWv1uI9EY3zXbfNeDNgNeTWIMY\n"
-        + "75saINBA2LALBQ54u52GoSbaR8ukZYAjjkif3WIFI8B9xREwjUBLFy3E357aGyLZ\n"
-        + "jE8nsmPk4MDxDaeDNoSHJjcxtDWQJBub3u1zABEBAAEAB/wPPV1Om92pc9F3jJsZ\n"
-        + "2F3YZxukLfjnA76tnMEWd/pYGrUhdV3AdY4r/aB0njSeApxdXRlLQ3L2cUxdGCJQ\n"
-        + "mzM1ies7IXCC/w5WaShwAG+zpmFL/5+cq3vDc9tb2Q/IasVOVFQYEE2el7SfW5Cp\n"
-        + "mjZFGR8V1wvdNvC0Q0IHrmfdECYSeftzZBEj7CcoGc2pF5zpCG0XQxq7K6cEeSf5\n"
-        + "TKf//UVHgyBCIso6mzgP5k6DGw2d64843CPhhlHEbirUu/wNnbm1SqJ5xFL2VatH\n"
-        + "w7ij4V/hbgnP0GQkbY5+p/PU74P7fx/Ee8D8mF2HmEKRy6ZQY/SAnrjsAURBYR5S\n"
-        + "GF5RBADfhOYEgseWr81lq6Y1oM4YQz+pXRIZk34BagOJsL767B7+uwhvmxBJKIOS\n"
-        + "nRIxfV8GlvT22hrbqsRRyusoIlo2ZUat94IMAL6Oqm6VFm71PT3z9+ukWK43FIXf\n"
-        + "Bsz4swSV001398e3jpSizI6fGW7LRxvnua+NPN+xJLmDVcsPvwQA49ajm48NorD9\n"
-        + "bIWG87+2ScNTVOnHKryR+/LrGWA0f3G6LUsHZPKHNBdFZ4yza2QtEKw95L3K9D4y\n"
-        + "jIeKGwSRYJPb5oh5tSge58pxwP88eI9J4dL+XF1nsG0vYF9B41+qG1TCsPyUJTp6\n"
-        + "ry7NAgWrbpsZpjB0yJ1kFva3iS/hD00EAMu66p1CtsosoDHhekvRZp8a3svd+8uf\n"
-        + "YEKkEKXZuNNmJJktJBSA2FK1RKl9bV8wuG0Pi1/k39egLO3QTjruWUbSggT+aibR\n"
-        + "RW3hU7G+Z5IBOU3p+kTFLat6+TBg0XhCjJ+Eq366nZy1QIfqTCixIaDwrutZd6DC\n"
-        + "BXOjdoG6ZvLcQia0HlRlc3R1c2VyIEIgPHRlc3RiQGV4YW1wbGUuY29tPokBPgQT\n"
-        + "AQIAKAUCTHqe0wIbAwUJAeEzgAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ\n"
-        + "V2Bph7AH1JD0nQf/Vm+/Mvl99/y3Qw10S6etH6NYWDUeAKXe9mfXBJ39HdtlF50j\n"
-        + "Z5NzSwksAOSQtQZJ3tQQeElXB29cZDvAscvaRiTtt+KUxDZSYbEHrC0EO7w0Wi5l\n"
-        + "twaWdXnoitMOgPZ/grL7UpUbL8rB1evfLbhmAqC/6kgHuXeY/7EAzwU3o0wKbmfx\n"
-        + "1sh8AyQSi4unUwIDCV1RIAP0+ZfJSg0WwGoSJB5+lKajtIE6kMn9m8CWM66/zxSC\n"
-        + "Y3XLcoXvjVxCYPwwgYSyje8dDxxOI+x7uj2IIjM5RHQ9hTsR7NQ9JUTFmpKZlcda\n"
-        + "h93NZLKJAFLUtOPjMa5d5t2O0ZOxZ5ftlhHpQ50DmARMep7TAQgAwOuLBXnACIsd\n"
-        + "879ld/vLcn8umpKV8MIUjrqOMjR0rNKpCUDwLxL4uVh3q/ksESHnQPPqxFYkgeA6\n"
-        + "6SYrx4jwZjbZ5vv9BW99LHe8lSahqrJA9A9g5iw5hH+2ZWrGlu3P65UdQUJW+JaD\n"
-        + "x1IIBt3BbmdGDuKF/ESsy9qxEKq7tKqHI2JLEd+6OIwWblU7ZogfiNpgZJ0lapxT\n"
-        + "e84mGsD0TowGTu5re/8wIJf1f2q4PuG+L9OZ0ZD5i9s1MAxdw4OD+705owPCQnqs\n"
-        + "r18nH9aUBHWJn9NCXb3jL7QGaId84Yq8SRlKwHSRtHLLJoowJ5fXw5UbZcUtRUer\n"
-        + "gxFRwae87wARAQABAAf8DAVBKsyswfuFGMB2vpSiVxaEnV3/2LoHFOOb45XwJSqV\n"
-        + "HL3+mThJ5iaUglMqw0CFC7+HA8fIS41grlFSDgNC02OcjS9rUxDg0En/pp17Gks0\n"
-        + "D+D7bSwZQ1+/yi7ug836lBe89GmBSMj8GgnK9T6RBGOL8nZ72b2ftK4CNWMmAfo4\n"
-        + "NZUy+rnnziV5WoYrkFZhl3dMMd3nITILBy9eYUoiKJl8O1b8amhrNkB/PEMAV7jc\n"
-        + "260XEQ9fgzMMe5/oT8pzIOGyrB+QO5rMu9pGVJ1qeMzTiZjjHXE2CEaEbvEk0F4l\n"
-        + "6w2gp5C6O5GoMpCOPwCy7dOYX5ETdO4Ppjnrob2XEQQAwus5q+EFoBVG8vfEf56x\n"
-        + "czkC15+0VcMe/IM8l/ur/oF1NUlAnPCq7WfgdELvGNszW7R+A625yXJJf7LJE/y/\n"
-        + "5GUGHAK60FUa0ElbVEn0A6kDcvll0dM6rKPQvFguaFpBKXre6k17cdOrf9hasfJk\n"
-        + "+lzaHlh9hJgoM30pAwG4+n8EAP1f+TEkEfVFo4Uy84eO6xVkYVndopDU1gCpfW1a\n"
-        + "84SA2PNjU3vkdIoFsEvOmf1xlfYeDYn37dikFPEZDsHBUzELDMewAXRgmVvnMJrj\n"
-        + "8Zq4FbEQSVjyz3qJOGk5V999qqoVMRXdnlQs5IXgZauPsnIqi5TRQZOMhbaiOVBO\n"
-        + "kqWRBAC9FhxypA3t9j1zGTFDppWmcBxpVzGGsgmzGO+WTVyk6szbZgTsf2+R+gTJ\n"
-        + "ZKVVzE6Mu+iZmPbrn/x7LWzKJuavRz0xSrvCYbIxYyheFz5LOPFHLF181h1g79gY\n"
-        + "E5Tz7uwu3jIldM7rY5RhxS6V5GGDVSfA+/Dsk6Iaujs6Hs7y30C0iQElBBgBAgAP\n"
-        + "BQJMep7TAhsMBQkB4TOAAAoJEFdgaYewB9SQMbsH/iu1HY7OMJxd8EkfxairRNec\n"
-        + "/v9uEvYQXqfEPw/Hihdef1TY8vB69ymAPd89e1PRDj1m+0/RivO045qFP7lbWMkj\n"
-        + "KeR9dXXeUzIEsTUJ1CNnA7C3fo11NBVg59E0d84bMKQx7n4AZkljgKFKghUb6OJZ\n"
-        + "iWRdh+8W0I95JI2R7nMYw3L8/sSGxt+Vjhs9acB1DldbyYbJitYA4fhVZQH9zgeu\n"
-        + "hQqdCULQZexpkQqvG0o4iJKO4yeJNHdeM+NwH38wXfzydtEv6Dxz/YZSTwt08p97\n"
-        + "l6DQ//H7wek1LcqeX47YFa9Ftns8Y8fjh4S8Kyi1F6BhZKbsdDqg2hA+0AFv7LA=\n"
-        + "=uFLT\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6ntMBCADG7j/nuI+VvvNbPY9nnfLrAc3KTj0Z+DMxxUMYoZNLjTw1szQ0\n"
+            + "PuKKACiiSA9Oyj4R0aIhWdIR9iYxp6gQdje3yewzoqMwE+t5onYDpdX9QDFXyEzF\n"
+            + "UPWCjA7OSji1G6fyWakiYxKseqyRXOdHXI5TqMikBalmSpwwvmik0cfRGO+l6qvM\n"
+            + "mVJlcn6mkZB0d8WOPV8j8rFxmVSPn9SVP9L8HaFWv1uI9EY3zXbfNeDNgNeTWIMY\n"
+            + "75saINBA2LALBQ54u52GoSbaR8ukZYAjjkif3WIFI8B9xREwjUBLFy3E357aGyLZ\n"
+            + "jE8nsmPk4MDxDaeDNoSHJjcxtDWQJBub3u1zABEBAAEAB/wPPV1Om92pc9F3jJsZ\n"
+            + "2F3YZxukLfjnA76tnMEWd/pYGrUhdV3AdY4r/aB0njSeApxdXRlLQ3L2cUxdGCJQ\n"
+            + "mzM1ies7IXCC/w5WaShwAG+zpmFL/5+cq3vDc9tb2Q/IasVOVFQYEE2el7SfW5Cp\n"
+            + "mjZFGR8V1wvdNvC0Q0IHrmfdECYSeftzZBEj7CcoGc2pF5zpCG0XQxq7K6cEeSf5\n"
+            + "TKf//UVHgyBCIso6mzgP5k6DGw2d64843CPhhlHEbirUu/wNnbm1SqJ5xFL2VatH\n"
+            + "w7ij4V/hbgnP0GQkbY5+p/PU74P7fx/Ee8D8mF2HmEKRy6ZQY/SAnrjsAURBYR5S\n"
+            + "GF5RBADfhOYEgseWr81lq6Y1oM4YQz+pXRIZk34BagOJsL767B7+uwhvmxBJKIOS\n"
+            + "nRIxfV8GlvT22hrbqsRRyusoIlo2ZUat94IMAL6Oqm6VFm71PT3z9+ukWK43FIXf\n"
+            + "Bsz4swSV001398e3jpSizI6fGW7LRxvnua+NPN+xJLmDVcsPvwQA49ajm48NorD9\n"
+            + "bIWG87+2ScNTVOnHKryR+/LrGWA0f3G6LUsHZPKHNBdFZ4yza2QtEKw95L3K9D4y\n"
+            + "jIeKGwSRYJPb5oh5tSge58pxwP88eI9J4dL+XF1nsG0vYF9B41+qG1TCsPyUJTp6\n"
+            + "ry7NAgWrbpsZpjB0yJ1kFva3iS/hD00EAMu66p1CtsosoDHhekvRZp8a3svd+8uf\n"
+            + "YEKkEKXZuNNmJJktJBSA2FK1RKl9bV8wuG0Pi1/k39egLO3QTjruWUbSggT+aibR\n"
+            + "RW3hU7G+Z5IBOU3p+kTFLat6+TBg0XhCjJ+Eq366nZy1QIfqTCixIaDwrutZd6DC\n"
+            + "BXOjdoG6ZvLcQia0HlRlc3R1c2VyIEIgPHRlc3RiQGV4YW1wbGUuY29tPokBPgQT\n"
+            + "AQIAKAUCTHqe0wIbAwUJAeEzgAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ\n"
+            + "V2Bph7AH1JD0nQf/Vm+/Mvl99/y3Qw10S6etH6NYWDUeAKXe9mfXBJ39HdtlF50j\n"
+            + "Z5NzSwksAOSQtQZJ3tQQeElXB29cZDvAscvaRiTtt+KUxDZSYbEHrC0EO7w0Wi5l\n"
+            + "twaWdXnoitMOgPZ/grL7UpUbL8rB1evfLbhmAqC/6kgHuXeY/7EAzwU3o0wKbmfx\n"
+            + "1sh8AyQSi4unUwIDCV1RIAP0+ZfJSg0WwGoSJB5+lKajtIE6kMn9m8CWM66/zxSC\n"
+            + "Y3XLcoXvjVxCYPwwgYSyje8dDxxOI+x7uj2IIjM5RHQ9hTsR7NQ9JUTFmpKZlcda\n"
+            + "h93NZLKJAFLUtOPjMa5d5t2O0ZOxZ5ftlhHpQ50DmARMep7TAQgAwOuLBXnACIsd\n"
+            + "879ld/vLcn8umpKV8MIUjrqOMjR0rNKpCUDwLxL4uVh3q/ksESHnQPPqxFYkgeA6\n"
+            + "6SYrx4jwZjbZ5vv9BW99LHe8lSahqrJA9A9g5iw5hH+2ZWrGlu3P65UdQUJW+JaD\n"
+            + "x1IIBt3BbmdGDuKF/ESsy9qxEKq7tKqHI2JLEd+6OIwWblU7ZogfiNpgZJ0lapxT\n"
+            + "e84mGsD0TowGTu5re/8wIJf1f2q4PuG+L9OZ0ZD5i9s1MAxdw4OD+705owPCQnqs\n"
+            + "r18nH9aUBHWJn9NCXb3jL7QGaId84Yq8SRlKwHSRtHLLJoowJ5fXw5UbZcUtRUer\n"
+            + "gxFRwae87wARAQABAAf8DAVBKsyswfuFGMB2vpSiVxaEnV3/2LoHFOOb45XwJSqV\n"
+            + "HL3+mThJ5iaUglMqw0CFC7+HA8fIS41grlFSDgNC02OcjS9rUxDg0En/pp17Gks0\n"
+            + "D+D7bSwZQ1+/yi7ug836lBe89GmBSMj8GgnK9T6RBGOL8nZ72b2ftK4CNWMmAfo4\n"
+            + "NZUy+rnnziV5WoYrkFZhl3dMMd3nITILBy9eYUoiKJl8O1b8amhrNkB/PEMAV7jc\n"
+            + "260XEQ9fgzMMe5/oT8pzIOGyrB+QO5rMu9pGVJ1qeMzTiZjjHXE2CEaEbvEk0F4l\n"
+            + "6w2gp5C6O5GoMpCOPwCy7dOYX5ETdO4Ppjnrob2XEQQAwus5q+EFoBVG8vfEf56x\n"
+            + "czkC15+0VcMe/IM8l/ur/oF1NUlAnPCq7WfgdELvGNszW7R+A625yXJJf7LJE/y/\n"
+            + "5GUGHAK60FUa0ElbVEn0A6kDcvll0dM6rKPQvFguaFpBKXre6k17cdOrf9hasfJk\n"
+            + "+lzaHlh9hJgoM30pAwG4+n8EAP1f+TEkEfVFo4Uy84eO6xVkYVndopDU1gCpfW1a\n"
+            + "84SA2PNjU3vkdIoFsEvOmf1xlfYeDYn37dikFPEZDsHBUzELDMewAXRgmVvnMJrj\n"
+            + "8Zq4FbEQSVjyz3qJOGk5V999qqoVMRXdnlQs5IXgZauPsnIqi5TRQZOMhbaiOVBO\n"
+            + "kqWRBAC9FhxypA3t9j1zGTFDppWmcBxpVzGGsgmzGO+WTVyk6szbZgTsf2+R+gTJ\n"
+            + "ZKVVzE6Mu+iZmPbrn/x7LWzKJuavRz0xSrvCYbIxYyheFz5LOPFHLF181h1g79gY\n"
+            + "E5Tz7uwu3jIldM7rY5RhxS6V5GGDVSfA+/Dsk6Iaujs6Hs7y30C0iQElBBgBAgAP\n"
+            + "BQJMep7TAhsMBQkB4TOAAAoJEFdgaYewB9SQMbsH/iu1HY7OMJxd8EkfxairRNec\n"
+            + "/v9uEvYQXqfEPw/Hihdef1TY8vB69ymAPd89e1PRDj1m+0/RivO045qFP7lbWMkj\n"
+            + "KeR9dXXeUzIEsTUJ1CNnA7C3fo11NBVg59E0d84bMKQx7n4AZkljgKFKghUb6OJZ\n"
+            + "iWRdh+8W0I95JI2R7nMYw3L8/sSGxt+Vjhs9acB1DldbyYbJitYA4fhVZQH9zgeu\n"
+            + "hQqdCULQZexpkQqvG0o4iJKO4yeJNHdeM+NwH38wXfzydtEv6Dxz/YZSTwt08p97\n"
+            + "l6DQ//H7wek1LcqeX47YFa9Ftns8Y8fjh4S8Kyi1F6BhZKbsdDqg2hA+0AFv7LA=\n"
+            + "=uFLT\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/D24FE467 2010-08-29
-   *       Key fingerprint = 6C21 10AC F4FC 1C7B F270  C00E 641F 1193 D24F E467
-   * uid                  Testuser C &lt;testc@example.com&gt;
-   * sub   2048R/DBECD4FA 2010-08-29
+   * pub 2048R/D24FE467 2010-08-29 Key fingerprint = 6C21 10AC F4FC 1C7B F270 C00E 641F 1193 D24F
+   * E467 uid Testuser C &lt;testc@example.com&gt; sub 2048R/DBECD4FA 2010-08-29
    */
   public static TestKey keyC() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx6nuMBCADd077pyfsDGbGhHh+7xzipWihMJRrzQnpbSeVJIxA/Js+Z2MW8\n"
-        + "9J98AgnjONjGVlLqtp11O8Bp9xgdoGYWvFl2CrooQrCe+70JORHE30MJT+61mgLQ\n"
-        + "jm9l2WmIIcuzNwoTOKqWlXuaRIKddXMVbwr++Enl/9znx81FCf1KioDijeeHzVZb\n"
-        + "IjELLCtLlhwhGlYNy6LfNhSY+rNHOomIM9CUXkGZU7JvTe3M1plUzYYIFu3tttZI\n"
-        + "b6e1FSfR60yZ/f88fLacloc3fSrPWA261R/gHuFfLCdTt/I3EcYE+x33LZnSSOgz\n"
-        + "v/JtAuFlCaF/oNRTJHeRbALeri+FxBYule15ABEBAAG0HlRlc3R1c2VyIEMgPHRl\n"
-        + "c3RjQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqe4wIbAwYLCQgHAwIGFQgCCQoL\n"
-        + "BBYCAwECHgECF4AACgkQZB8Rk9JP5GcEIgf/cMvYBwH8ENrWec366Txaaeh/TO6n\n"
-        + "4v4P2LUR4/hcrNpHx3+9ikznkyF/b8OCsOE+KstvOO6i9vuRGVBPmfoALVv8iCGs\n"
-        + "5MXZJskjACXOqQav0I7ZY5rDJxuOKq6DrxtpHNxK8n0D1PEZllyk/OZVBAcjL2vu\n"
-        + "WC6ujP3jbMKaV0+heFqOVIghQjdA4McLH2u1XLOGEZdp7hLfmTnClmfzbnslFBSQ\n"
-        + "xU2g3jCq2k2zAPhn+jOGCL0987QGj1e6pHRXdUxcfnLRyNadRied0HO/clIb8vdt\n"
-        + "UaexujHjgg+1KDxj4PBAftN2lRtnnsSG9z4T31aTFz5YVG+pq8UXk9ohCokBIAQQ\n"
-        + "AQIACgUCTHqkKQMFAngACgkQqZHi1Q/dNnexiQf/ba9LcR76+tVvos1cxrGO3VkD\n"
-        + "3R1pvIWsb37/NTypWCvrFhsy4OUEy3bVCfJcqfwdY3Q2XixB9kuKo3qCSom1EjGg\n"
-        + "Qhr5ZsrB3qYqaa6S0AeVusmIwArEr9uuMUDjXhKlUALDX8HfXWGy2UmjNJkkT8Jm\n"
-        + "GtISS4KOfXUuZY04DttvbukEnyxAiLU9V0BnzrI9DARh0gEjqjUZAVyP5lOXJJxt\n"
-        + "sau95mOe8E61GELXPkxDLrnCboX7ys2OxcFO6S7q1xJPkki2SVq0y0k5oY/3jktw\n"
-        + "jO8uC3n7NiyW+BYJK6+zj3u3iA+o0YGm+i6F7aneJEaJrFqRj9L1vbojvuH0cYkB\n"
-        + "IAQQAQIACgUCTHqkOwMFAngACgkQOwm5f0tDh+7dSQf+PnEUftNSOuLVLoJ+2tyD\n"
-        + "DPJpcLIavNCyNR3hCGL86NXRUxOrmYgDVVv8pJuYB6aUTm69rFFZlzNwqQN5pBiX\n"
-        + "Zr3NM1jgJT6gKfXddcg1p/X2S9+xn4RN92R0fn0kEjM65fpE1Do+YWHOuHDZEOrx\n"
-        + "L8OaSo8lr19+r27fn09/HBhz2lOyTYzsdTjHeWdxPVQ3JNiVX11k7iKsttdYtM/V\n"
-        + "mAHzzd54Kvt5So/2qLIAcfSmUe9DQAdmcEcJQpQ2veND9uwccX7tH0cH4n9Cp16o\n"
-        + "quJ2pxWzOvKR3zxSw+cRxyIS4VjT6k+UsG3Lw55QZgdb5IEaJfezPj+tOhQlQz0f\n"
-        + "VrkBDQRMep7jAQgAw+67ahlOGnkF6mTtmg6MOGzAbRQ11MNrORnNtGOccNgtlgrO\n"
-        + "Y8TBqw1HkJ56v26E1FxfRh69CUGkYVXx0tMw0QbI+unX35ce5hJD4aWa8bOA1vfw\n"
-        + "474p/NpI+czWsFvcdOu5K6xIGXHShaQQyf2FQ9QeIFrU60qfaBL5jzuLyujCACqU\n"
-        + "46QGgBgeUjaT54LjrWSdn/Jtsbpv0MPv3Ea8fMdtSMkkBsDkF55jaJDFYq+xbs+e\n"
-        + "IKBjTwtSvrUisnLAC0Z9YY21GXGI3DGYqpVXz+Fe5xMTX1a6K3VKEmxmX2m/ebhm\n"
-        + "1p6EqjAJguOjJbJJQHKHMOol0zU6ANB6SgP26wARAQABiQEfBBgBAgAJBQJMep7j\n"
-        + "AhsMAAoJEGQfEZPST+Rn7AcH/32HACPLdxINsi8OSWa8OccMG5XEUvHTZjmdwVT2\n"
-        + "czMss8nwgifU9D4hEVRu1MWpiyxUgegW94wuSh4PWIVOVd18PmzAYc73aYgonakb\n"
-        + "M+MDIqGVvAH8QtHo79sqZ9vrkQaQXB3Y8cq+WxDQZyl8KLXP2icmq1Rl6Q6+i9oS\n"
-        + "pFe88Wr0cGaTblkfDbbWcih3C6tKAfcFwLLg8u4jYfXjZg/E9eAJf0dIFcQSQoHd\n"
-        + "O8hVXaZwx/rYXA8UFwAuROo2nu6SIof1lrH92p+now95d5zUZ5BYnKVd3uXsln0j\n"
-        + "z585UPQKS2J8PUy9IirmahgTyEYFwO64kZ2B4hYOE2g+rYw=\n"
-        + "=LtMR\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6nuMBCADd077pyfsDGbGhHh+7xzipWihMJRrzQnpbSeVJIxA/Js+Z2MW8\n"
+            + "9J98AgnjONjGVlLqtp11O8Bp9xgdoGYWvFl2CrooQrCe+70JORHE30MJT+61mgLQ\n"
+            + "jm9l2WmIIcuzNwoTOKqWlXuaRIKddXMVbwr++Enl/9znx81FCf1KioDijeeHzVZb\n"
+            + "IjELLCtLlhwhGlYNy6LfNhSY+rNHOomIM9CUXkGZU7JvTe3M1plUzYYIFu3tttZI\n"
+            + "b6e1FSfR60yZ/f88fLacloc3fSrPWA261R/gHuFfLCdTt/I3EcYE+x33LZnSSOgz\n"
+            + "v/JtAuFlCaF/oNRTJHeRbALeri+FxBYule15ABEBAAG0HlRlc3R1c2VyIEMgPHRl\n"
+            + "c3RjQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqe4wIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQZB8Rk9JP5GcEIgf/cMvYBwH8ENrWec366Txaaeh/TO6n\n"
+            + "4v4P2LUR4/hcrNpHx3+9ikznkyF/b8OCsOE+KstvOO6i9vuRGVBPmfoALVv8iCGs\n"
+            + "5MXZJskjACXOqQav0I7ZY5rDJxuOKq6DrxtpHNxK8n0D1PEZllyk/OZVBAcjL2vu\n"
+            + "WC6ujP3jbMKaV0+heFqOVIghQjdA4McLH2u1XLOGEZdp7hLfmTnClmfzbnslFBSQ\n"
+            + "xU2g3jCq2k2zAPhn+jOGCL0987QGj1e6pHRXdUxcfnLRyNadRied0HO/clIb8vdt\n"
+            + "UaexujHjgg+1KDxj4PBAftN2lRtnnsSG9z4T31aTFz5YVG+pq8UXk9ohCokBIAQQ\n"
+            + "AQIACgUCTHqkKQMFAngACgkQqZHi1Q/dNnexiQf/ba9LcR76+tVvos1cxrGO3VkD\n"
+            + "3R1pvIWsb37/NTypWCvrFhsy4OUEy3bVCfJcqfwdY3Q2XixB9kuKo3qCSom1EjGg\n"
+            + "Qhr5ZsrB3qYqaa6S0AeVusmIwArEr9uuMUDjXhKlUALDX8HfXWGy2UmjNJkkT8Jm\n"
+            + "GtISS4KOfXUuZY04DttvbukEnyxAiLU9V0BnzrI9DARh0gEjqjUZAVyP5lOXJJxt\n"
+            + "sau95mOe8E61GELXPkxDLrnCboX7ys2OxcFO6S7q1xJPkki2SVq0y0k5oY/3jktw\n"
+            + "jO8uC3n7NiyW+BYJK6+zj3u3iA+o0YGm+i6F7aneJEaJrFqRj9L1vbojvuH0cYkB\n"
+            + "IAQQAQIACgUCTHqkOwMFAngACgkQOwm5f0tDh+7dSQf+PnEUftNSOuLVLoJ+2tyD\n"
+            + "DPJpcLIavNCyNR3hCGL86NXRUxOrmYgDVVv8pJuYB6aUTm69rFFZlzNwqQN5pBiX\n"
+            + "Zr3NM1jgJT6gKfXddcg1p/X2S9+xn4RN92R0fn0kEjM65fpE1Do+YWHOuHDZEOrx\n"
+            + "L8OaSo8lr19+r27fn09/HBhz2lOyTYzsdTjHeWdxPVQ3JNiVX11k7iKsttdYtM/V\n"
+            + "mAHzzd54Kvt5So/2qLIAcfSmUe9DQAdmcEcJQpQ2veND9uwccX7tH0cH4n9Cp16o\n"
+            + "quJ2pxWzOvKR3zxSw+cRxyIS4VjT6k+UsG3Lw55QZgdb5IEaJfezPj+tOhQlQz0f\n"
+            + "VrkBDQRMep7jAQgAw+67ahlOGnkF6mTtmg6MOGzAbRQ11MNrORnNtGOccNgtlgrO\n"
+            + "Y8TBqw1HkJ56v26E1FxfRh69CUGkYVXx0tMw0QbI+unX35ce5hJD4aWa8bOA1vfw\n"
+            + "474p/NpI+czWsFvcdOu5K6xIGXHShaQQyf2FQ9QeIFrU60qfaBL5jzuLyujCACqU\n"
+            + "46QGgBgeUjaT54LjrWSdn/Jtsbpv0MPv3Ea8fMdtSMkkBsDkF55jaJDFYq+xbs+e\n"
+            + "IKBjTwtSvrUisnLAC0Z9YY21GXGI3DGYqpVXz+Fe5xMTX1a6K3VKEmxmX2m/ebhm\n"
+            + "1p6EqjAJguOjJbJJQHKHMOol0zU6ANB6SgP26wARAQABiQEfBBgBAgAJBQJMep7j\n"
+            + "AhsMAAoJEGQfEZPST+Rn7AcH/32HACPLdxINsi8OSWa8OccMG5XEUvHTZjmdwVT2\n"
+            + "czMss8nwgifU9D4hEVRu1MWpiyxUgegW94wuSh4PWIVOVd18PmzAYc73aYgonakb\n"
+            + "M+MDIqGVvAH8QtHo79sqZ9vrkQaQXB3Y8cq+WxDQZyl8KLXP2icmq1Rl6Q6+i9oS\n"
+            + "pFe88Wr0cGaTblkfDbbWcih3C6tKAfcFwLLg8u4jYfXjZg/E9eAJf0dIFcQSQoHd\n"
+            + "O8hVXaZwx/rYXA8UFwAuROo2nu6SIof1lrH92p+now95d5zUZ5BYnKVd3uXsln0j\n"
+            + "z585UPQKS2J8PUy9IirmahgTyEYFwO64kZ2B4hYOE2g+rYw=\n"
+            + "=LtMR\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx6nuMBCADd077pyfsDGbGhHh+7xzipWihMJRrzQnpbSeVJIxA/Js+Z2MW8\n"
-        + "9J98AgnjONjGVlLqtp11O8Bp9xgdoGYWvFl2CrooQrCe+70JORHE30MJT+61mgLQ\n"
-        + "jm9l2WmIIcuzNwoTOKqWlXuaRIKddXMVbwr++Enl/9znx81FCf1KioDijeeHzVZb\n"
-        + "IjELLCtLlhwhGlYNy6LfNhSY+rNHOomIM9CUXkGZU7JvTe3M1plUzYYIFu3tttZI\n"
-        + "b6e1FSfR60yZ/f88fLacloc3fSrPWA261R/gHuFfLCdTt/I3EcYE+x33LZnSSOgz\n"
-        + "v/JtAuFlCaF/oNRTJHeRbALeri+FxBYule15ABEBAAEAB/sFPLoJDG1eV5QpqEZf\n"
-        + "m/QMOTOn8ZJ9xraQvXFvV7zgVXxJBvTLMbuACrnHnoiCrULS+w8Dt66Nfz7s4yQJ\n"
-        + "5SDtFX2AlMDVWL7wBEPgF1UpN6ox1CzSa6HOaygaUFGeKHO20WDjV4HmBLhQkKIa\n"
-        + "vKbghHA/4Nm1s1z3BHB8GtdGZ1VHc+s1DhPK5w+WHqYpLYjpNmI9yJg3gclEqEG9\n"
-        + "XzBqTZm9mPJRBdDMOD0xLa4nUD3Dkrjimqod3X7EuXE6sT2DuGVa1nuynk/8gIyO\n"
-        + "uS6crY7YJzEQUtQJ2n3y/h+QnZFo9UFuIVpgsxhBDsCnYNFWNR91Q0IM6PohHvqx\n"
-        + "BtFhBADsax1Bc0obP+bIkeAXltGlUYqm3bjOgVZ87XR0qe4TGwXGe8T1Yjfc8rj0\n"
-        + "cfBYCud201r/05CgchojMnTWlFLg308bSIZ9YvN3oOVay8nZ7h62dUIs45zebw3R\n"
-        + "SHwvjE5Sm/VWIdLrUUW1aGfk/VPudNMMMu2C64ev8DF/iwYjoQQA8DM+9oPvFJPA\n"
-        + "kLYg71tP2iIE5GbFqkiIEx59eQUxTsn6ubEfREjI99QliAdcKbyRHc3jc68NopLB\n"
-        + "41L7ny0j6VKuEszOYhhQ0qQK/jlI461aG14qHAylhuQTLrjpsUPE+WelBm9bxli0\n"
-        + "gA8F81WLOvJ2HzuMYVrj3tjGl3AHetkEAI77VKxGCGRzK63qBnmLwQEvqbphpgxH\n"
-        + "ANNAsg5HuWtDUgk85t2nrIgL1kfhu++CfP9duN/qU4dw/bgJaKOamWTfLBwST8qe\n"
-        + "3F8omovi1vLzHVpmvQp6Ly4wggJ4Gl/n0DNFopKw20V8ZTiRYtuLS43H7VsczE+8\n"
-        + "NKjy01EgHDMAP8O0HlRlc3R1c2VyIEMgPHRlc3RjQGV4YW1wbGUuY29tPokBOAQT\n"
-        + "AQIAIgUCTHqe4wIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQZB8Rk9JP\n"
-        + "5GcEIgf/cMvYBwH8ENrWec366Txaaeh/TO6n4v4P2LUR4/hcrNpHx3+9ikznkyF/\n"
-        + "b8OCsOE+KstvOO6i9vuRGVBPmfoALVv8iCGs5MXZJskjACXOqQav0I7ZY5rDJxuO\n"
-        + "Kq6DrxtpHNxK8n0D1PEZllyk/OZVBAcjL2vuWC6ujP3jbMKaV0+heFqOVIghQjdA\n"
-        + "4McLH2u1XLOGEZdp7hLfmTnClmfzbnslFBSQxU2g3jCq2k2zAPhn+jOGCL0987QG\n"
-        + "j1e6pHRXdUxcfnLRyNadRied0HO/clIb8vdtUaexujHjgg+1KDxj4PBAftN2lRtn\n"
-        + "nsSG9z4T31aTFz5YVG+pq8UXk9ohCp0DmARMep7jAQgAw+67ahlOGnkF6mTtmg6M\n"
-        + "OGzAbRQ11MNrORnNtGOccNgtlgrOY8TBqw1HkJ56v26E1FxfRh69CUGkYVXx0tMw\n"
-        + "0QbI+unX35ce5hJD4aWa8bOA1vfw474p/NpI+czWsFvcdOu5K6xIGXHShaQQyf2F\n"
-        + "Q9QeIFrU60qfaBL5jzuLyujCACqU46QGgBgeUjaT54LjrWSdn/Jtsbpv0MPv3Ea8\n"
-        + "fMdtSMkkBsDkF55jaJDFYq+xbs+eIKBjTwtSvrUisnLAC0Z9YY21GXGI3DGYqpVX\n"
-        + "z+Fe5xMTX1a6K3VKEmxmX2m/ebhm1p6EqjAJguOjJbJJQHKHMOol0zU6ANB6SgP2\n"
-        + "6wARAQABAAf9HIsMy8S/92SmE018vQgILrgjwursz1Vgq22HkBNALm2acSnwgzbz\n"
-        + "V8M+0mH5U9ClPSKae+aXzLS+s7IHi++u7uSO0YQmKgZ5PonD+ygFoyxumo0oOfqc\n"
-        + "DJ/oKFaforWJ2jv05S3bRbRVN5l9G0/5jWC7ZXnrXBOqQUkdCLFjXhMPq3zg2Yy3\n"
-        + "XSU83dVteOtrYRZqv33umZNCdk44z6kQOvh9tgSCL/aZ3d7AqjRK99I/IYY1IuVN\n"
-        + "qreFriVcJ0EzlnbPCnva+ReWAd2zt5VEClGu9J0CVnHmZNlwfmbFSiUN1hiMonkr\n"
-        + "sFImlw3adfJ7dsi/GzCC4147ep6jXw7QwQQAzwkeRWR9xc3ndrnXqUbQmgQkAD3D\n"
-        + "p2cwPygyLr0UDBDVX0z+8GKeBhNs3KIFXwUs6GxmDodHh0t4HUJeVLs7ur5ZATqo\n"
-        + "Bx50cSUOoaeSHRFVwicdJRtVgTTQ4UwwmKcLLJe2fWv6hnmyInK7Lp8ThLGQgqo8\n"
-        + "UWg3cdfzCvhKSvsEAPJFYhsFA/E92xUpzP8oYs3AA4mUXB+F0eObe9gqv8lAE6SX\n"
-        + "gB5kWhcd+MGddUGJuJV2LRrgOx3nXu3m3n35AH6iAY4Qi9URPzi/K659oefUU1c5\n"
-        + "BFArHX9bN1k1cOvH28tpQ38eAxaMygLqyR5Q5VbtZ5tYqLKCvHVs3I8lekDRA/4i\n"
-        + "e0vlu34qenppPANPm+Vq/7cSlG3XY4ioxwC/j6Y+92u90DXbbGatOg1SqGSwn1VP\n"
-        + "S034m7bDCNoWOXL0yAcbXrLZV74AyfvVOYOs/WtehehzWeTQRT5lkxX5+xGc1/h6\n"
-        + "9HQvsKKnUK8n1oc5aM5xzRVkU9+kcmqYqXqyOHnIbDbPiQEfBBgBAgAJBQJMep7j\n"
-        + "AhsMAAoJEGQfEZPST+Rn7AcH/32HACPLdxINsi8OSWa8OccMG5XEUvHTZjmdwVT2\n"
-        + "czMss8nwgifU9D4hEVRu1MWpiyxUgegW94wuSh4PWIVOVd18PmzAYc73aYgonakb\n"
-        + "M+MDIqGVvAH8QtHo79sqZ9vrkQaQXB3Y8cq+WxDQZyl8KLXP2icmq1Rl6Q6+i9oS\n"
-        + "pFe88Wr0cGaTblkfDbbWcih3C6tKAfcFwLLg8u4jYfXjZg/E9eAJf0dIFcQSQoHd\n"
-        + "O8hVXaZwx/rYXA8UFwAuROo2nu6SIof1lrH92p+now95d5zUZ5BYnKVd3uXsln0j\n"
-        + "z585UPQKS2J8PUy9IirmahgTyEYFwO64kZ2B4hYOE2g+rYw=\n"
-        + "=5pIh\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6nuMBCADd077pyfsDGbGhHh+7xzipWihMJRrzQnpbSeVJIxA/Js+Z2MW8\n"
+            + "9J98AgnjONjGVlLqtp11O8Bp9xgdoGYWvFl2CrooQrCe+70JORHE30MJT+61mgLQ\n"
+            + "jm9l2WmIIcuzNwoTOKqWlXuaRIKddXMVbwr++Enl/9znx81FCf1KioDijeeHzVZb\n"
+            + "IjELLCtLlhwhGlYNy6LfNhSY+rNHOomIM9CUXkGZU7JvTe3M1plUzYYIFu3tttZI\n"
+            + "b6e1FSfR60yZ/f88fLacloc3fSrPWA261R/gHuFfLCdTt/I3EcYE+x33LZnSSOgz\n"
+            + "v/JtAuFlCaF/oNRTJHeRbALeri+FxBYule15ABEBAAEAB/sFPLoJDG1eV5QpqEZf\n"
+            + "m/QMOTOn8ZJ9xraQvXFvV7zgVXxJBvTLMbuACrnHnoiCrULS+w8Dt66Nfz7s4yQJ\n"
+            + "5SDtFX2AlMDVWL7wBEPgF1UpN6ox1CzSa6HOaygaUFGeKHO20WDjV4HmBLhQkKIa\n"
+            + "vKbghHA/4Nm1s1z3BHB8GtdGZ1VHc+s1DhPK5w+WHqYpLYjpNmI9yJg3gclEqEG9\n"
+            + "XzBqTZm9mPJRBdDMOD0xLa4nUD3Dkrjimqod3X7EuXE6sT2DuGVa1nuynk/8gIyO\n"
+            + "uS6crY7YJzEQUtQJ2n3y/h+QnZFo9UFuIVpgsxhBDsCnYNFWNR91Q0IM6PohHvqx\n"
+            + "BtFhBADsax1Bc0obP+bIkeAXltGlUYqm3bjOgVZ87XR0qe4TGwXGe8T1Yjfc8rj0\n"
+            + "cfBYCud201r/05CgchojMnTWlFLg308bSIZ9YvN3oOVay8nZ7h62dUIs45zebw3R\n"
+            + "SHwvjE5Sm/VWIdLrUUW1aGfk/VPudNMMMu2C64ev8DF/iwYjoQQA8DM+9oPvFJPA\n"
+            + "kLYg71tP2iIE5GbFqkiIEx59eQUxTsn6ubEfREjI99QliAdcKbyRHc3jc68NopLB\n"
+            + "41L7ny0j6VKuEszOYhhQ0qQK/jlI461aG14qHAylhuQTLrjpsUPE+WelBm9bxli0\n"
+            + "gA8F81WLOvJ2HzuMYVrj3tjGl3AHetkEAI77VKxGCGRzK63qBnmLwQEvqbphpgxH\n"
+            + "ANNAsg5HuWtDUgk85t2nrIgL1kfhu++CfP9duN/qU4dw/bgJaKOamWTfLBwST8qe\n"
+            + "3F8omovi1vLzHVpmvQp6Ly4wggJ4Gl/n0DNFopKw20V8ZTiRYtuLS43H7VsczE+8\n"
+            + "NKjy01EgHDMAP8O0HlRlc3R1c2VyIEMgPHRlc3RjQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTHqe4wIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQZB8Rk9JP\n"
+            + "5GcEIgf/cMvYBwH8ENrWec366Txaaeh/TO6n4v4P2LUR4/hcrNpHx3+9ikznkyF/\n"
+            + "b8OCsOE+KstvOO6i9vuRGVBPmfoALVv8iCGs5MXZJskjACXOqQav0I7ZY5rDJxuO\n"
+            + "Kq6DrxtpHNxK8n0D1PEZllyk/OZVBAcjL2vuWC6ujP3jbMKaV0+heFqOVIghQjdA\n"
+            + "4McLH2u1XLOGEZdp7hLfmTnClmfzbnslFBSQxU2g3jCq2k2zAPhn+jOGCL0987QG\n"
+            + "j1e6pHRXdUxcfnLRyNadRied0HO/clIb8vdtUaexujHjgg+1KDxj4PBAftN2lRtn\n"
+            + "nsSG9z4T31aTFz5YVG+pq8UXk9ohCp0DmARMep7jAQgAw+67ahlOGnkF6mTtmg6M\n"
+            + "OGzAbRQ11MNrORnNtGOccNgtlgrOY8TBqw1HkJ56v26E1FxfRh69CUGkYVXx0tMw\n"
+            + "0QbI+unX35ce5hJD4aWa8bOA1vfw474p/NpI+czWsFvcdOu5K6xIGXHShaQQyf2F\n"
+            + "Q9QeIFrU60qfaBL5jzuLyujCACqU46QGgBgeUjaT54LjrWSdn/Jtsbpv0MPv3Ea8\n"
+            + "fMdtSMkkBsDkF55jaJDFYq+xbs+eIKBjTwtSvrUisnLAC0Z9YY21GXGI3DGYqpVX\n"
+            + "z+Fe5xMTX1a6K3VKEmxmX2m/ebhm1p6EqjAJguOjJbJJQHKHMOol0zU6ANB6SgP2\n"
+            + "6wARAQABAAf9HIsMy8S/92SmE018vQgILrgjwursz1Vgq22HkBNALm2acSnwgzbz\n"
+            + "V8M+0mH5U9ClPSKae+aXzLS+s7IHi++u7uSO0YQmKgZ5PonD+ygFoyxumo0oOfqc\n"
+            + "DJ/oKFaforWJ2jv05S3bRbRVN5l9G0/5jWC7ZXnrXBOqQUkdCLFjXhMPq3zg2Yy3\n"
+            + "XSU83dVteOtrYRZqv33umZNCdk44z6kQOvh9tgSCL/aZ3d7AqjRK99I/IYY1IuVN\n"
+            + "qreFriVcJ0EzlnbPCnva+ReWAd2zt5VEClGu9J0CVnHmZNlwfmbFSiUN1hiMonkr\n"
+            + "sFImlw3adfJ7dsi/GzCC4147ep6jXw7QwQQAzwkeRWR9xc3ndrnXqUbQmgQkAD3D\n"
+            + "p2cwPygyLr0UDBDVX0z+8GKeBhNs3KIFXwUs6GxmDodHh0t4HUJeVLs7ur5ZATqo\n"
+            + "Bx50cSUOoaeSHRFVwicdJRtVgTTQ4UwwmKcLLJe2fWv6hnmyInK7Lp8ThLGQgqo8\n"
+            + "UWg3cdfzCvhKSvsEAPJFYhsFA/E92xUpzP8oYs3AA4mUXB+F0eObe9gqv8lAE6SX\n"
+            + "gB5kWhcd+MGddUGJuJV2LRrgOx3nXu3m3n35AH6iAY4Qi9URPzi/K659oefUU1c5\n"
+            + "BFArHX9bN1k1cOvH28tpQ38eAxaMygLqyR5Q5VbtZ5tYqLKCvHVs3I8lekDRA/4i\n"
+            + "e0vlu34qenppPANPm+Vq/7cSlG3XY4ioxwC/j6Y+92u90DXbbGatOg1SqGSwn1VP\n"
+            + "S034m7bDCNoWOXL0yAcbXrLZV74AyfvVOYOs/WtehehzWeTQRT5lkxX5+xGc1/h6\n"
+            + "9HQvsKKnUK8n1oc5aM5xzRVkU9+kcmqYqXqyOHnIbDbPiQEfBBgBAgAJBQJMep7j\n"
+            + "AhsMAAoJEGQfEZPST+Rn7AcH/32HACPLdxINsi8OSWa8OccMG5XEUvHTZjmdwVT2\n"
+            + "czMss8nwgifU9D4hEVRu1MWpiyxUgegW94wuSh4PWIVOVd18PmzAYc73aYgonakb\n"
+            + "M+MDIqGVvAH8QtHo79sqZ9vrkQaQXB3Y8cq+WxDQZyl8KLXP2icmq1Rl6Q6+i9oS\n"
+            + "pFe88Wr0cGaTblkfDbbWcih3C6tKAfcFwLLg8u4jYfXjZg/E9eAJf0dIFcQSQoHd\n"
+            + "O8hVXaZwx/rYXA8UFwAuROo2nu6SIof1lrH92p+now95d5zUZ5BYnKVd3uXsln0j\n"
+            + "z585UPQKS2J8PUy9IirmahgTyEYFwO64kZ2B4hYOE2g+rYw=\n"
+            + "=5pIh\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/0FDD3677 2010-08-29
-   *       Key fingerprint = C96C 5E9D 669C 448A D1B9  BEB5 A991 E2D5 0FDD 3677
-   * uid                  Testuser D &lt;testd@example.com&gt;
-   * sub   2048R/CAB81AE0 2010-08-29
+   * pub 2048R/0FDD3677 2010-08-29 Key fingerprint = C96C 5E9D 669C 448A D1B9 BEB5 A991 E2D5 0FDD
+   * 3677 uid Testuser D &lt;testd@example.com&gt; sub 2048R/CAB81AE0 2010-08-29
    */
   public static TestKey keyD() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx6nwkBCADuztv2tGhjPljwW46qEhth7ZnkdhYXuctZ6lNQuy5LMaEECE3C\n"
-        + "jvVKY+nBrgsLY2Trts+q+mdooBWvxy/qe5PAQTcPR83KjVS4fYwNMBgeRxBEZAZg\n"
-        + "DFwRRCsRrHost+cMgtzLocQ+vL3+9yTRAIe/WmYwbEDXg/c9JSC7kQbZqaAaOshO\n"
-        + "cIOyeB8/QoYee0fEnBzHMmcd0SB1YpwIvRG6v61lXmgpQ9CbovvXO6ZZyEyCX784\n"
-        + "9xprzqP1y03DPrbhuhBAY8EMf3KGJA1dEcU4+lbGEgmlOe2YSbWoLs7mRLFcq5xx\n"
-        + "JroYMtvXF04k4ZHNZAnT3IZc+lJyCqOp4vXpABEBAAG0HlRlc3R1c2VyIEQgPHRl\n"
-        + "c3RkQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqfCQIbAwYLCQgHAwIGFQgCCQoL\n"
-        + "BBYCAwECHgECF4AACgkQqZHi1Q/dNne/0wgApuPzh4J8p2quCK1ScsJHlgGRojGq\n"
-        + "IDPhZFtPn0p2IAkqr5sAhvZAjd3u9A2DqQ7pwOX7gnGRE7dSrK69IAjfbRMc5k16\n"
-        + "aBK2ADq2YgPEmTToots1A0Tj+LaCFOXYUtEkgAC+RfFIkCdt8z86GIr0kg19Q/vY\n"
-        + "I/LtvThAk28D8yIfDnW49Mc4GGq+qvrOytBaGu3dzW0mjYWGEyl0fdSjNqtKyWN7\n"
-        + "Qw70Kqysaoy1KiPRAgwiPQfMCEx6pVaXuAfgRKaJ18kCNOldpajLgQv6yeY7mhgu\n"
-        + "Q3Qe7xQlAtVObxskcTH2CWggl2dPqSMNieLK0g/ER8PIReGDCBXNSJ4qYbkBDQRM\n"
-        + "ep8JAQgAw/o1nhJPLGlIfEMzOGU0Jjj+DwEyB3QIEEc+WKRvgtGsJ4cbZdaGWBJq\n"
-        + "jSo7e9XC9jA2ih0+Gld0vWV7S0LZ84xXxQeadC+AZBFR+b9ga4aUFIji8Tdi2dWX\n"
-        + "QmY76hHIaF8rs6aJB7lRig735VRLxVHOb194t9KLUzZiEKqd71BvLQyuLqAfTEsT\n"
-        + "GRHgmydaxZbGXz+Z57jbQgm11CQEHX1dtS8uqWb64xrV5GAeuEhRj4R6Yiy7OPNi\n"
-        + "xXHxryH2Jd34pA0cGHYVcTgVjXuZ9FFP2SnXuxABONGAIaJuqg7ozYBa2kOdr0DN\n"
-        + "5Pxy5ocR7R2ZoN0pYD5+Cc7oGHjuCQARAQABiQEfBBgBAgAJBQJMep8JAhsMAAoJ\n"
-        + "EKmR4tUP3TZ369QIAKPlfX2TUfhP3otYiaa24zBJ/cvGljGiSfX0KrausBHH161j\n"
-        + "lraJfLzpe7vSOZhwZwgIY/eKoErAkJwVnX1+dLuOcHaqRDi5gnLqa6Yg9a2LWb4z\n"
-        + "rvgsvbiNUs1o9htOcvcpv7e3UUUcRa8lO+aNkO+VoI6DI8RJ3wIfJayboePRXdfr\n"
-        + "8g9of0jSdIOzlaaBPxA2wYSWXm4kv7QXzZooxuGqhn0+JKuq2+oO9y5QUig+c3oG\n"
-        + "a5mpVblmv5ZL6Gc36kCbeEC8j6JkNT4wnceQwpNUNYtPU186cjy3rAD4C58w0Uvp\n"
-        + "HZZSTc0syLOShQr//We39LUNaX6WF3NmyF8K/OM=\n"
-        + "=YDhQ\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6nwkBCADuztv2tGhjPljwW46qEhth7ZnkdhYXuctZ6lNQuy5LMaEECE3C\n"
+            + "jvVKY+nBrgsLY2Trts+q+mdooBWvxy/qe5PAQTcPR83KjVS4fYwNMBgeRxBEZAZg\n"
+            + "DFwRRCsRrHost+cMgtzLocQ+vL3+9yTRAIe/WmYwbEDXg/c9JSC7kQbZqaAaOshO\n"
+            + "cIOyeB8/QoYee0fEnBzHMmcd0SB1YpwIvRG6v61lXmgpQ9CbovvXO6ZZyEyCX784\n"
+            + "9xprzqP1y03DPrbhuhBAY8EMf3KGJA1dEcU4+lbGEgmlOe2YSbWoLs7mRLFcq5xx\n"
+            + "JroYMtvXF04k4ZHNZAnT3IZc+lJyCqOp4vXpABEBAAG0HlRlc3R1c2VyIEQgPHRl\n"
+            + "c3RkQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTHqfCQIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQqZHi1Q/dNne/0wgApuPzh4J8p2quCK1ScsJHlgGRojGq\n"
+            + "IDPhZFtPn0p2IAkqr5sAhvZAjd3u9A2DqQ7pwOX7gnGRE7dSrK69IAjfbRMc5k16\n"
+            + "aBK2ADq2YgPEmTToots1A0Tj+LaCFOXYUtEkgAC+RfFIkCdt8z86GIr0kg19Q/vY\n"
+            + "I/LtvThAk28D8yIfDnW49Mc4GGq+qvrOytBaGu3dzW0mjYWGEyl0fdSjNqtKyWN7\n"
+            + "Qw70Kqysaoy1KiPRAgwiPQfMCEx6pVaXuAfgRKaJ18kCNOldpajLgQv6yeY7mhgu\n"
+            + "Q3Qe7xQlAtVObxskcTH2CWggl2dPqSMNieLK0g/ER8PIReGDCBXNSJ4qYbkBDQRM\n"
+            + "ep8JAQgAw/o1nhJPLGlIfEMzOGU0Jjj+DwEyB3QIEEc+WKRvgtGsJ4cbZdaGWBJq\n"
+            + "jSo7e9XC9jA2ih0+Gld0vWV7S0LZ84xXxQeadC+AZBFR+b9ga4aUFIji8Tdi2dWX\n"
+            + "QmY76hHIaF8rs6aJB7lRig735VRLxVHOb194t9KLUzZiEKqd71BvLQyuLqAfTEsT\n"
+            + "GRHgmydaxZbGXz+Z57jbQgm11CQEHX1dtS8uqWb64xrV5GAeuEhRj4R6Yiy7OPNi\n"
+            + "xXHxryH2Jd34pA0cGHYVcTgVjXuZ9FFP2SnXuxABONGAIaJuqg7ozYBa2kOdr0DN\n"
+            + "5Pxy5ocR7R2ZoN0pYD5+Cc7oGHjuCQARAQABiQEfBBgBAgAJBQJMep8JAhsMAAoJ\n"
+            + "EKmR4tUP3TZ369QIAKPlfX2TUfhP3otYiaa24zBJ/cvGljGiSfX0KrausBHH161j\n"
+            + "lraJfLzpe7vSOZhwZwgIY/eKoErAkJwVnX1+dLuOcHaqRDi5gnLqa6Yg9a2LWb4z\n"
+            + "rvgsvbiNUs1o9htOcvcpv7e3UUUcRa8lO+aNkO+VoI6DI8RJ3wIfJayboePRXdfr\n"
+            + "8g9of0jSdIOzlaaBPxA2wYSWXm4kv7QXzZooxuGqhn0+JKuq2+oO9y5QUig+c3oG\n"
+            + "a5mpVblmv5ZL6Gc36kCbeEC8j6JkNT4wnceQwpNUNYtPU186cjy3rAD4C58w0Uvp\n"
+            + "HZZSTc0syLOShQr//We39LUNaX6WF3NmyF8K/OM=\n"
+            + "=YDhQ\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx6nwkBCADuztv2tGhjPljwW46qEhth7ZnkdhYXuctZ6lNQuy5LMaEECE3C\n"
-        + "jvVKY+nBrgsLY2Trts+q+mdooBWvxy/qe5PAQTcPR83KjVS4fYwNMBgeRxBEZAZg\n"
-        + "DFwRRCsRrHost+cMgtzLocQ+vL3+9yTRAIe/WmYwbEDXg/c9JSC7kQbZqaAaOshO\n"
-        + "cIOyeB8/QoYee0fEnBzHMmcd0SB1YpwIvRG6v61lXmgpQ9CbovvXO6ZZyEyCX784\n"
-        + "9xprzqP1y03DPrbhuhBAY8EMf3KGJA1dEcU4+lbGEgmlOe2YSbWoLs7mRLFcq5xx\n"
-        + "JroYMtvXF04k4ZHNZAnT3IZc+lJyCqOp4vXpABEBAAEAB/0Yf+FiLHz/HYDbW9FF\n"
-        + "kmj7wXgFz7WRho6dsWQNxr5HmZZWxxFPMgJpONnc9GGOsApFAnLIrDraqX3AFFPO\n"
-        + "nxH36djfuPKcYqZ77Olm2vXGeWzqT0a2KN5zKQawH/1CxDUwe+Zx/60V8KAfXbSJ\n"
-        + "up+ymnAcbKa0VYYSYFI82/KTdthJ1jFMNtXkaLskpM8TrDBCgd38m8Dpb5GCrDVY\n"
-        + "faZgkHokTTrvaTcx7ebGOxlOcbfzOPMJyFiz6lHf4JGr5ZVQXymaAG18kRDFxXHm\n"
-        + "AskOJIxnMdcy2IzNximht2CIgRuGznyPoeh/j8KFONKIKf3N6dVfV12uIvGOVV+D\n"
-        + "/ZQZBAD2dennp3Z4IsOWkgHTG3bloOVcIY5n+WvliQY/5G3psKdKeaGZxt6MhMSj\n"
-        + "sJEiUgveYTt5PxvQc5jmFEyjEQJmDAHo3RbycdFVvICrKIhKFyIlcVFCOSwDvLAW\n"
-        + "aZhu/m47jGnnYZ+bDzZl4X8L7Zu8e3TStEiVhjYTRqJfdEdMVQQA+A0ehIhIa1mJ\n"
-        + "ytGKWQVxn9BwKTP583vf2qPzul7yDEsYdGfoA0QGUicVwV4NNK3vK3FQM9MBSevp\n"
-        + "JFpxh2bRS/tgd5tFDyRqekTcagMqTxnJoIpCPUvj5D+WXsS1Kwrcm7OpWoNHOcjD\n"
-        + "Hbhk/966QALO+T6BTVLx32/72jtQ10UD/RsqQfRDzlQUOd6ZYOlH5qCb1+f8f3qJ\n"
-        + "yUmudrmjj8unBK3QbBVrxZ1h9AyaI5evFmsMlLKdTp0y49CmrSQmgEnUYzvBDjse\n"
-        + "/jYanpRKnt69HeZFilHLIF+HBbQfSM66UVXVoJSNTJIsncVa0IcGoZTpCUVOng3/\n"
-        + "MLfW4sh9NX1yRIi0HlRlc3R1c2VyIEQgPHRlc3RkQGV4YW1wbGUuY29tPokBOAQT\n"
-        + "AQIAIgUCTHqfCQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQqZHi1Q/d\n"
-        + "Nne/0wgApuPzh4J8p2quCK1ScsJHlgGRojGqIDPhZFtPn0p2IAkqr5sAhvZAjd3u\n"
-        + "9A2DqQ7pwOX7gnGRE7dSrK69IAjfbRMc5k16aBK2ADq2YgPEmTToots1A0Tj+LaC\n"
-        + "FOXYUtEkgAC+RfFIkCdt8z86GIr0kg19Q/vYI/LtvThAk28D8yIfDnW49Mc4GGq+\n"
-        + "qvrOytBaGu3dzW0mjYWGEyl0fdSjNqtKyWN7Qw70Kqysaoy1KiPRAgwiPQfMCEx6\n"
-        + "pVaXuAfgRKaJ18kCNOldpajLgQv6yeY7mhguQ3Qe7xQlAtVObxskcTH2CWggl2dP\n"
-        + "qSMNieLK0g/ER8PIReGDCBXNSJ4qYZ0DmARMep8JAQgAw/o1nhJPLGlIfEMzOGU0\n"
-        + "Jjj+DwEyB3QIEEc+WKRvgtGsJ4cbZdaGWBJqjSo7e9XC9jA2ih0+Gld0vWV7S0LZ\n"
-        + "84xXxQeadC+AZBFR+b9ga4aUFIji8Tdi2dWXQmY76hHIaF8rs6aJB7lRig735VRL\n"
-        + "xVHOb194t9KLUzZiEKqd71BvLQyuLqAfTEsTGRHgmydaxZbGXz+Z57jbQgm11CQE\n"
-        + "HX1dtS8uqWb64xrV5GAeuEhRj4R6Yiy7OPNixXHxryH2Jd34pA0cGHYVcTgVjXuZ\n"
-        + "9FFP2SnXuxABONGAIaJuqg7ozYBa2kOdr0DN5Pxy5ocR7R2ZoN0pYD5+Cc7oGHju\n"
-        + "CQARAQABAAf/QiN/k9y+/pB7h4BQWXCCNIIYb6zqGuzUSdYZWuYHwiEL1f05SFmp\n"
-        + "VjDE5+ZAU+8U0Gv+BAeRbWdlfQOyI/ioQJL1DggeXqanUF4uCbjGDBPLhtCZsmmM\n"
-        + "QVLdrOl+v+SHe33e7E7AQSyQMaUSkUEtHycYIasZPQRfw9H/L3u9OEWXkMUbPso5\n"
-        + "L0A0StkcsM1isYfC8ApnF4zSTWHO9uqnc+qE4qChCqsGvaSIyLKEpVe4F0vEkbrq\n"
-        + "3usVp3cxJd9apN+JjMoC9dHJcQahgfJZ1jzgJ3rueRxrGZV+keo8VmyrDGFCerX9\n"
-        + "6Ke3RPMHN/evCHyPMtHC82QKYuy4ZTvldwQAyzbNKIIpNjyHRc/hXLMBUtnW0VYS\n"
-        + "dELA1VBMmT/d6Xx6pI9gg9HCjDx+DuQRych7ShxrYLL1pNQD8jwEJhZIeUpSgIFD\n"
-        + "BXdwkiGbmdrU5N0tBhxp8kRcqcGbL68zC9S0X2hNju6Dxu9hbG8ZAdYaCdAavVy0\n"
-        + "O6E66+T0cLRBinsEAPbiL/0rpV15DdITwD3hvzhYDyURE+yxQZe9ngS1uoui3mGn\n"
-        + "bLc/L/nbHf2Z91ViSsUaqJjpb2/eDsJtGJ9pFlFLTndujkA62CktJytD9DIYLlYD\n"
-        + "huXlsKvZkNZEZNDKLC5Tg8YR/28Opz0/ZFzfVuJAQqg7+iWkxklG3SvN71RLA/9x\n"
-        + "wun1AEw6tLJ2R2j8+yXIt8UaWExqAviT/JgZELVXdCTqcYuOmktsM2z+2D+OyUtP\n"
-        + "7+Yyz7MGQKMAU+V/1uOK4YqwUJrcGy501o9Of+xm+5DASsK1oM5e9sBdmNewdLHL\n"
-        + "ZJEllURrEC6zCE/4zzs7qUfakH4l4ZJgjRL6va+ED0HfiQEfBBgBAgAJBQJMep8J\n"
-        + "AhsMAAoJEKmR4tUP3TZ369QIAKPlfX2TUfhP3otYiaa24zBJ/cvGljGiSfX0Krau\n"
-        + "sBHH161jlraJfLzpe7vSOZhwZwgIY/eKoErAkJwVnX1+dLuOcHaqRDi5gnLqa6Yg\n"
-        + "9a2LWb4zrvgsvbiNUs1o9htOcvcpv7e3UUUcRa8lO+aNkO+VoI6DI8RJ3wIfJayb\n"
-        + "oePRXdfr8g9of0jSdIOzlaaBPxA2wYSWXm4kv7QXzZooxuGqhn0+JKuq2+oO9y5Q\n"
-        + "Uig+c3oGa5mpVblmv5ZL6Gc36kCbeEC8j6JkNT4wnceQwpNUNYtPU186cjy3rAD4\n"
-        + "C58w0UvpHZZSTc0syLOShQr//We39LUNaX6WF3NmyF8K/OM=\n"
-        + "=e1xT\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6nwkBCADuztv2tGhjPljwW46qEhth7ZnkdhYXuctZ6lNQuy5LMaEECE3C\n"
+            + "jvVKY+nBrgsLY2Trts+q+mdooBWvxy/qe5PAQTcPR83KjVS4fYwNMBgeRxBEZAZg\n"
+            + "DFwRRCsRrHost+cMgtzLocQ+vL3+9yTRAIe/WmYwbEDXg/c9JSC7kQbZqaAaOshO\n"
+            + "cIOyeB8/QoYee0fEnBzHMmcd0SB1YpwIvRG6v61lXmgpQ9CbovvXO6ZZyEyCX784\n"
+            + "9xprzqP1y03DPrbhuhBAY8EMf3KGJA1dEcU4+lbGEgmlOe2YSbWoLs7mRLFcq5xx\n"
+            + "JroYMtvXF04k4ZHNZAnT3IZc+lJyCqOp4vXpABEBAAEAB/0Yf+FiLHz/HYDbW9FF\n"
+            + "kmj7wXgFz7WRho6dsWQNxr5HmZZWxxFPMgJpONnc9GGOsApFAnLIrDraqX3AFFPO\n"
+            + "nxH36djfuPKcYqZ77Olm2vXGeWzqT0a2KN5zKQawH/1CxDUwe+Zx/60V8KAfXbSJ\n"
+            + "up+ymnAcbKa0VYYSYFI82/KTdthJ1jFMNtXkaLskpM8TrDBCgd38m8Dpb5GCrDVY\n"
+            + "faZgkHokTTrvaTcx7ebGOxlOcbfzOPMJyFiz6lHf4JGr5ZVQXymaAG18kRDFxXHm\n"
+            + "AskOJIxnMdcy2IzNximht2CIgRuGznyPoeh/j8KFONKIKf3N6dVfV12uIvGOVV+D\n"
+            + "/ZQZBAD2dennp3Z4IsOWkgHTG3bloOVcIY5n+WvliQY/5G3psKdKeaGZxt6MhMSj\n"
+            + "sJEiUgveYTt5PxvQc5jmFEyjEQJmDAHo3RbycdFVvICrKIhKFyIlcVFCOSwDvLAW\n"
+            + "aZhu/m47jGnnYZ+bDzZl4X8L7Zu8e3TStEiVhjYTRqJfdEdMVQQA+A0ehIhIa1mJ\n"
+            + "ytGKWQVxn9BwKTP583vf2qPzul7yDEsYdGfoA0QGUicVwV4NNK3vK3FQM9MBSevp\n"
+            + "JFpxh2bRS/tgd5tFDyRqekTcagMqTxnJoIpCPUvj5D+WXsS1Kwrcm7OpWoNHOcjD\n"
+            + "Hbhk/966QALO+T6BTVLx32/72jtQ10UD/RsqQfRDzlQUOd6ZYOlH5qCb1+f8f3qJ\n"
+            + "yUmudrmjj8unBK3QbBVrxZ1h9AyaI5evFmsMlLKdTp0y49CmrSQmgEnUYzvBDjse\n"
+            + "/jYanpRKnt69HeZFilHLIF+HBbQfSM66UVXVoJSNTJIsncVa0IcGoZTpCUVOng3/\n"
+            + "MLfW4sh9NX1yRIi0HlRlc3R1c2VyIEQgPHRlc3RkQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTHqfCQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQqZHi1Q/d\n"
+            + "Nne/0wgApuPzh4J8p2quCK1ScsJHlgGRojGqIDPhZFtPn0p2IAkqr5sAhvZAjd3u\n"
+            + "9A2DqQ7pwOX7gnGRE7dSrK69IAjfbRMc5k16aBK2ADq2YgPEmTToots1A0Tj+LaC\n"
+            + "FOXYUtEkgAC+RfFIkCdt8z86GIr0kg19Q/vYI/LtvThAk28D8yIfDnW49Mc4GGq+\n"
+            + "qvrOytBaGu3dzW0mjYWGEyl0fdSjNqtKyWN7Qw70Kqysaoy1KiPRAgwiPQfMCEx6\n"
+            + "pVaXuAfgRKaJ18kCNOldpajLgQv6yeY7mhguQ3Qe7xQlAtVObxskcTH2CWggl2dP\n"
+            + "qSMNieLK0g/ER8PIReGDCBXNSJ4qYZ0DmARMep8JAQgAw/o1nhJPLGlIfEMzOGU0\n"
+            + "Jjj+DwEyB3QIEEc+WKRvgtGsJ4cbZdaGWBJqjSo7e9XC9jA2ih0+Gld0vWV7S0LZ\n"
+            + "84xXxQeadC+AZBFR+b9ga4aUFIji8Tdi2dWXQmY76hHIaF8rs6aJB7lRig735VRL\n"
+            + "xVHOb194t9KLUzZiEKqd71BvLQyuLqAfTEsTGRHgmydaxZbGXz+Z57jbQgm11CQE\n"
+            + "HX1dtS8uqWb64xrV5GAeuEhRj4R6Yiy7OPNixXHxryH2Jd34pA0cGHYVcTgVjXuZ\n"
+            + "9FFP2SnXuxABONGAIaJuqg7ozYBa2kOdr0DN5Pxy5ocR7R2ZoN0pYD5+Cc7oGHju\n"
+            + "CQARAQABAAf/QiN/k9y+/pB7h4BQWXCCNIIYb6zqGuzUSdYZWuYHwiEL1f05SFmp\n"
+            + "VjDE5+ZAU+8U0Gv+BAeRbWdlfQOyI/ioQJL1DggeXqanUF4uCbjGDBPLhtCZsmmM\n"
+            + "QVLdrOl+v+SHe33e7E7AQSyQMaUSkUEtHycYIasZPQRfw9H/L3u9OEWXkMUbPso5\n"
+            + "L0A0StkcsM1isYfC8ApnF4zSTWHO9uqnc+qE4qChCqsGvaSIyLKEpVe4F0vEkbrq\n"
+            + "3usVp3cxJd9apN+JjMoC9dHJcQahgfJZ1jzgJ3rueRxrGZV+keo8VmyrDGFCerX9\n"
+            + "6Ke3RPMHN/evCHyPMtHC82QKYuy4ZTvldwQAyzbNKIIpNjyHRc/hXLMBUtnW0VYS\n"
+            + "dELA1VBMmT/d6Xx6pI9gg9HCjDx+DuQRych7ShxrYLL1pNQD8jwEJhZIeUpSgIFD\n"
+            + "BXdwkiGbmdrU5N0tBhxp8kRcqcGbL68zC9S0X2hNju6Dxu9hbG8ZAdYaCdAavVy0\n"
+            + "O6E66+T0cLRBinsEAPbiL/0rpV15DdITwD3hvzhYDyURE+yxQZe9ngS1uoui3mGn\n"
+            + "bLc/L/nbHf2Z91ViSsUaqJjpb2/eDsJtGJ9pFlFLTndujkA62CktJytD9DIYLlYD\n"
+            + "huXlsKvZkNZEZNDKLC5Tg8YR/28Opz0/ZFzfVuJAQqg7+iWkxklG3SvN71RLA/9x\n"
+            + "wun1AEw6tLJ2R2j8+yXIt8UaWExqAviT/JgZELVXdCTqcYuOmktsM2z+2D+OyUtP\n"
+            + "7+Yyz7MGQKMAU+V/1uOK4YqwUJrcGy501o9Of+xm+5DASsK1oM5e9sBdmNewdLHL\n"
+            + "ZJEllURrEC6zCE/4zzs7qUfakH4l4ZJgjRL6va+ED0HfiQEfBBgBAgAJBQJMep8J\n"
+            + "AhsMAAoJEKmR4tUP3TZ369QIAKPlfX2TUfhP3otYiaa24zBJ/cvGljGiSfX0Krau\n"
+            + "sBHH161jlraJfLzpe7vSOZhwZwgIY/eKoErAkJwVnX1+dLuOcHaqRDi5gnLqa6Yg\n"
+            + "9a2LWb4zrvgsvbiNUs1o9htOcvcpv7e3UUUcRa8lO+aNkO+VoI6DI8RJ3wIfJayb\n"
+            + "oePRXdfr8g9of0jSdIOzlaaBPxA2wYSWXm4kv7QXzZooxuGqhn0+JKuq2+oO9y5Q\n"
+            + "Uig+c3oGa5mpVblmv5ZL6Gc36kCbeEC8j6JkNT4wnceQwpNUNYtPU186cjy3rAD4\n"
+            + "C58w0UvpHZZSTc0syLOShQr//We39LUNaX6WF3NmyF8K/OM=\n"
+            + "=e1xT\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/4B4387EE 2010-08-29 [expired: 2011-08-29]
-   *       Key fingerprint = F01D 677C 8BDB 854E 1054  406E 3B09 B97F 4B43 87EE
-   * uid                  Testuser E &lt;teste@example.com&gt;
+   * pub 2048R/4B4387EE 2010-08-29 [expired: 2011-08-29] Key fingerprint = F01D 677C 8BDB 854E 1054
+   * 406E 3B09 B97F 4B43 87EE uid Testuser E &lt;teste@example.com&gt;
    */
   public static TestKey keyE() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx6nxoBCADjYOWOFa7ZBJpRuNspRoXBTK0LiK5zqN894b87LgIYEgUM6q5J\n"
-        + "yLNo43x7V+ow1/7BEq0JUAMSQ3uRn2jqXiJskSXvwlFYcTVFb0gY09CSD0ptHvda\n"
-        + "zqYOuM/MU1l9jqmlM+pDw/z0pLTKYmAHi6pKJ64pqccMHPUZHpLywyzSNX+JM86I\n"
-        + "K5KAsyGArtgpT9vfci3idNeXjhMR8rfLPDFbdGvGFOZrYv0cfgTbBpVEWeHjs2FR\n"
-        + "4vHG133AdjdZcvA9Y9VW34ZLeiyBEeFix7+HPVS82rko2kQxZu1UZRu340maKDAo\n"
-        + "+UVirgo0FQ8nNUR+c9oNKgiZtO39IAPJv/WZABEBAAG0HlRlc3R1c2VyIEUgPHRl\n"
-        + "c3RlQGV4YW1wbGUuY29tPokBPgQTAQIAKAUCTHqfGgIbAwUJAeEzgAYLCQgHAwIG\n"
-        + "FQgCCQoLBBYCAwECHgECF4AACgkQOwm5f0tDh+6Fowf9FZgntlW4qc7BHe8zYJ0q\n"
-        + "zoLZrHwCFcaeO3kz53y5Lz3+plMuqVDjoQDOt8DxsPHrXWKiu0qBTjZ28ztN3ef6\n"
-        + "f0MpguTGclvFroevUct0xiyox5r1DfMT8JRvqsojE1XPscR2zJzIgEg3OCPuksT9\n"
-        + "EsHsF+/3RBbsXbQgDpW38g0GzIJI4AiQ/yvG2ON9awN2kzIWoBkthVCGy54lCTGj\n"
-        + "yPhatE7Zu2ABNcerIDstupWww2Psec6pGbPPci8ojc90fzalk3UMXcXHD7m8cTJS\n"
-        + "kgHScOzTElIQqOA1+w6uiHy2oAn+qW7534j6p9Tj+DrSIzUXBedGjXZevaKaurVy\n"
-        + "KLkBDQRMep8aAQgAn5r6toYnEzwDeig8r+t89vqOFtohYcmtyeLeTiTTdAz/xWBW\n"
-        + "HUlqV8sglQ9aINpGtBf37v13RhtU3WkUv8cZMQoRM8P2H3cKDNwkucFO6uKSEQO5\n"
-        + "FdzTm4C4WaoE7QiTRbiekwh7O54mz4Wup6LHuEFQEcSpdRUp8w/qaJIHG9EJad1q\n"
-        + "UEsKNnITW+mWHY3+ccK1hgqPwOPqO3/8QtaipekKOYAtOb+57c1jtDFBZnYIkant\n"
-        + "oKs+kRw0DykXFTyFOMYqaleBMcVG+u7ljwAq18L8Ev+qVIpBIZ5eQ5+6p1w9B69h\n"
-        + "RH0Ebn50ebpoqKOXhN4/bu/wq596y0o4xDB0GQARAQABiQElBBgBAgAPBQJMep8a\n"
-        + "AhsMBQkB4TOAAAoJEDsJuX9LQ4fu0/wH/35/22xina8ktbvGV/kB0pH2LBqeXN/b\n"
-        + "CLdA+CDzfwMDzqG0kU39EJ3Fbux7fj4uMaeiYfbO9U85+NOuDmeH41B2dM9S1AzE\n"
-        + "H+/OiCp/Zf1fdd1qXhsA4Xe5vc/VD9oso9OrZK5CM5u0TPmYFijfVDPNgag6mPnD\n"
-        + "zd8JCsuEj4VEy6NF1KcoCc8edQ8AZ4L6ZQ6qiV24gxLnh8xImVr5YjBKDUCdrl79\n"
-        + "0u4wekfgapSx9Sw9Ycz5dFOL07OOHPiKZwUG0f8td6oJX4Ddxset5JAm1pPcLQHR\n"
-        + "6PRx0hI/Tz7rsAI6O37/BEM15+MVGIgOSLL/SRIpOa0L8qmuUhhS6Bg=\n"
-        + "=uA5x\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx6nxoBCADjYOWOFa7ZBJpRuNspRoXBTK0LiK5zqN894b87LgIYEgUM6q5J\n"
+            + "yLNo43x7V+ow1/7BEq0JUAMSQ3uRn2jqXiJskSXvwlFYcTVFb0gY09CSD0ptHvda\n"
+            + "zqYOuM/MU1l9jqmlM+pDw/z0pLTKYmAHi6pKJ64pqccMHPUZHpLywyzSNX+JM86I\n"
+            + "K5KAsyGArtgpT9vfci3idNeXjhMR8rfLPDFbdGvGFOZrYv0cfgTbBpVEWeHjs2FR\n"
+            + "4vHG133AdjdZcvA9Y9VW34ZLeiyBEeFix7+HPVS82rko2kQxZu1UZRu340maKDAo\n"
+            + "+UVirgo0FQ8nNUR+c9oNKgiZtO39IAPJv/WZABEBAAG0HlRlc3R1c2VyIEUgPHRl\n"
+            + "c3RlQGV4YW1wbGUuY29tPokBPgQTAQIAKAUCTHqfGgIbAwUJAeEzgAYLCQgHAwIG\n"
+            + "FQgCCQoLBBYCAwECHgECF4AACgkQOwm5f0tDh+6Fowf9FZgntlW4qc7BHe8zYJ0q\n"
+            + "zoLZrHwCFcaeO3kz53y5Lz3+plMuqVDjoQDOt8DxsPHrXWKiu0qBTjZ28ztN3ef6\n"
+            + "f0MpguTGclvFroevUct0xiyox5r1DfMT8JRvqsojE1XPscR2zJzIgEg3OCPuksT9\n"
+            + "EsHsF+/3RBbsXbQgDpW38g0GzIJI4AiQ/yvG2ON9awN2kzIWoBkthVCGy54lCTGj\n"
+            + "yPhatE7Zu2ABNcerIDstupWww2Psec6pGbPPci8ojc90fzalk3UMXcXHD7m8cTJS\n"
+            + "kgHScOzTElIQqOA1+w6uiHy2oAn+qW7534j6p9Tj+DrSIzUXBedGjXZevaKaurVy\n"
+            + "KLkBDQRMep8aAQgAn5r6toYnEzwDeig8r+t89vqOFtohYcmtyeLeTiTTdAz/xWBW\n"
+            + "HUlqV8sglQ9aINpGtBf37v13RhtU3WkUv8cZMQoRM8P2H3cKDNwkucFO6uKSEQO5\n"
+            + "FdzTm4C4WaoE7QiTRbiekwh7O54mz4Wup6LHuEFQEcSpdRUp8w/qaJIHG9EJad1q\n"
+            + "UEsKNnITW+mWHY3+ccK1hgqPwOPqO3/8QtaipekKOYAtOb+57c1jtDFBZnYIkant\n"
+            + "oKs+kRw0DykXFTyFOMYqaleBMcVG+u7ljwAq18L8Ev+qVIpBIZ5eQ5+6p1w9B69h\n"
+            + "RH0Ebn50ebpoqKOXhN4/bu/wq596y0o4xDB0GQARAQABiQElBBgBAgAPBQJMep8a\n"
+            + "AhsMBQkB4TOAAAoJEDsJuX9LQ4fu0/wH/35/22xina8ktbvGV/kB0pH2LBqeXN/b\n"
+            + "CLdA+CDzfwMDzqG0kU39EJ3Fbux7fj4uMaeiYfbO9U85+NOuDmeH41B2dM9S1AzE\n"
+            + "H+/OiCp/Zf1fdd1qXhsA4Xe5vc/VD9oso9OrZK5CM5u0TPmYFijfVDPNgag6mPnD\n"
+            + "zd8JCsuEj4VEy6NF1KcoCc8edQ8AZ4L6ZQ6qiV24gxLnh8xImVr5YjBKDUCdrl79\n"
+            + "0u4wekfgapSx9Sw9Ycz5dFOL07OOHPiKZwUG0f8td6oJX4Ddxset5JAm1pPcLQHR\n"
+            + "6PRx0hI/Tz7rsAI6O37/BEM15+MVGIgOSLL/SRIpOa0L8qmuUhhS6Bg=\n"
+            + "=uA5x\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx6nxoBCADjYOWOFa7ZBJpRuNspRoXBTK0LiK5zqN894b87LgIYEgUM6q5J\n"
-        + "yLNo43x7V+ow1/7BEq0JUAMSQ3uRn2jqXiJskSXvwlFYcTVFb0gY09CSD0ptHvda\n"
-        + "zqYOuM/MU1l9jqmlM+pDw/z0pLTKYmAHi6pKJ64pqccMHPUZHpLywyzSNX+JM86I\n"
-        + "K5KAsyGArtgpT9vfci3idNeXjhMR8rfLPDFbdGvGFOZrYv0cfgTbBpVEWeHjs2FR\n"
-        + "4vHG133AdjdZcvA9Y9VW34ZLeiyBEeFix7+HPVS82rko2kQxZu1UZRu340maKDAo\n"
-        + "+UVirgo0FQ8nNUR+c9oNKgiZtO39IAPJv/WZABEBAAEAB/4xKKzYqDVyM/2NN5Mi\n"
-        + "fF3EqegruzRESzlgrqLij5LiU1sGLOLbjunC/pPWMu6t+rTYV0pT3hmb5D0eAcH0\n"
-        + "EcANiuAR0wg1P9yNk36Z54mLWoTzzKMb3dunCSvb+BU8AREKZ4v5dLEGz2lK7DPo\n"
-        + "zbhWaffMiClBpC0VbjfFBo91LrVUVnhRglBYKdPLQm/Lhw5cNCYOw194ZturO+cC\n"
-        + "iQZhGSy52HMoMs4Wr470CeFZvvWaiDCirVLcj4UhMsVANFKsahMARm9c+QrGrkRP\n"
-        + "+654f8M9ptapcQYpGOMmaeZVnpocONXOTkiJd7Hhr4PRUY+QS8C8F0LbmL2ERQbL\n"
-        + "F65RBADkIelztY/8Xy2S0jsW7+xF2ziz9riOR87G6b0wrXDdFz4GHPzLvwsdXOeN\n"
-        + "cODic14d9bf5jtXr9hgbAzx55ANDjOl3jK5qil8Z9qwsrNK9Mz0wT1acQXBwf/5D\n"
-        + "hI/whBK1FsH7Y+wdX64XA3EXmclxB8GZf1JsGXF3jNH30vyS7QQA/ydoMMw8ja9L\n"
-        + "j6MxHtVHcE4A4j6tFljLDuf8icOwwNUfb7SsHTDjUI2+30ZJOv+qISrthsASCSj3\n"
-        + "AN87CGdVR62Xe923DNdW8/moKKDILNaESyOi27qhI5qWrVRgNB5QwbQcSoClUxbj\n"
-        + "V7YZSfrZkiI+GE1gh1QPMOVyCUmqu90D+wc0x0wUj8emX/4xbbujOa5RAvNcNvnD\n"
-        + "mOB2CfPWD10TEeOOlHBhuoy2/GdIl76W0szJaxnzcV82VArllSciCBzpSfkExDZ6\n"
-        + "08hA8GpOsuOmAAPwXWZsb8YZbJeM0ULMgUCGHgvUj1/pGsCVA6c7sPAdkCfAFlmO\n"
-        + "smC9bvpS2VHZPuG0HlRlc3R1c2VyIEUgPHRlc3RlQGV4YW1wbGUuY29tPokBPgQT\n"
-        + "AQIAKAUCTHqfGgIbAwUJAeEzgAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ\n"
-        + "Owm5f0tDh+6Fowf9FZgntlW4qc7BHe8zYJ0qzoLZrHwCFcaeO3kz53y5Lz3+plMu\n"
-        + "qVDjoQDOt8DxsPHrXWKiu0qBTjZ28ztN3ef6f0MpguTGclvFroevUct0xiyox5r1\n"
-        + "DfMT8JRvqsojE1XPscR2zJzIgEg3OCPuksT9EsHsF+/3RBbsXbQgDpW38g0GzIJI\n"
-        + "4AiQ/yvG2ON9awN2kzIWoBkthVCGy54lCTGjyPhatE7Zu2ABNcerIDstupWww2Ps\n"
-        + "ec6pGbPPci8ojc90fzalk3UMXcXHD7m8cTJSkgHScOzTElIQqOA1+w6uiHy2oAn+\n"
-        + "qW7534j6p9Tj+DrSIzUXBedGjXZevaKaurVyKJ0DmARMep8aAQgAn5r6toYnEzwD\n"
-        + "eig8r+t89vqOFtohYcmtyeLeTiTTdAz/xWBWHUlqV8sglQ9aINpGtBf37v13RhtU\n"
-        + "3WkUv8cZMQoRM8P2H3cKDNwkucFO6uKSEQO5FdzTm4C4WaoE7QiTRbiekwh7O54m\n"
-        + "z4Wup6LHuEFQEcSpdRUp8w/qaJIHG9EJad1qUEsKNnITW+mWHY3+ccK1hgqPwOPq\n"
-        + "O3/8QtaipekKOYAtOb+57c1jtDFBZnYIkantoKs+kRw0DykXFTyFOMYqaleBMcVG\n"
-        + "+u7ljwAq18L8Ev+qVIpBIZ5eQ5+6p1w9B69hRH0Ebn50ebpoqKOXhN4/bu/wq596\n"
-        + "y0o4xDB0GQARAQABAAf7Bk9bQCIXo2QJAyhaFd5qh10qhu7CyRnvG/8zKMW98mWd\n"
-        + "KxF+9hNz99qZBCuiNZBLoU0dST6OG6By/3nrDxXxAgZS3cgOj/nl1NJTRWDGHPUu\n"
-        + "LywFgj7Dwu8Y2rqlDTX8lJIS+t8n+BhtkmDHoesGmFtErh8nT/CxQuHLM60qSMgv\n"
-        + "6mSmtOkM+2KfiA5z2o1fDWXjDieW+hdgDPxkaB835wfuDn/Dsn1ch1XHON0xSyTo\n"
-        + "+c35nFXoK1pAXaoalAxZNxcXCAM3NhU37Ih4GejM0K7sSgK72HmgxtNYF77DrTIM\n"
-        + "m5+3960ri1JUuEaJ7ZcqbpKxy/GDldNCYBTx07QMzQQAyYQ+ujT9Pj8zfp1jMLRs\n"
-        + "Xn9GsvYawjo+AIZuHeUmmIXfEoyNmsEUoGHnz9ROLnJzanW5XEStiTys8tHJPIkz\n"
-        + "zL0Ce0oUF93ln0z/jQBIKaSzYB7PMmYCd7ueF94aKqAOrQ/QBb+6JsVjGAtLUoTv\n"
-        + "ey09hGYMogiBV1r0MB2Rsa8EAMrB5VKVQF6+q0XuP6ljFQRaumi4lH7PoQ65E7UD\n"
-        + "6YpyQpLBOE7dV+fHizdUuwsD/wyAOu0EskV1ZLXvXzyk10r3PRoFdpHOvijwZBGt\n"
-        + "jiOiVvK1vkQKDMBczOe74+DaknKn6HzgCsXmLgfk+P8BtLOJnCYsbS9IbnImy2vi\n"
-        + "aJC3A/9wOOK+po8C7JPHVIEfxbe7nwHOoi/h7T4uPrlq/gcQRquqGhQ16nDGYZvX\n"
-        + "ny9aPQ3NcvDR69RM2AaXav03bHVxfhVEyGjP5jLZz7956e4LlnKrsuEhDLfiv30i\n"
-        + "qCC7zNHNA99s5u25vt8AuPVVHfSQ++jifabfv5lU4FHqmK8/4EAoiQElBBgBAgAP\n"
-        + "BQJMep8aAhsMBQkB4TOAAAoJEDsJuX9LQ4fu0/wH/35/22xina8ktbvGV/kB0pH2\n"
-        + "LBqeXN/bCLdA+CDzfwMDzqG0kU39EJ3Fbux7fj4uMaeiYfbO9U85+NOuDmeH41B2\n"
-        + "dM9S1AzEH+/OiCp/Zf1fdd1qXhsA4Xe5vc/VD9oso9OrZK5CM5u0TPmYFijfVDPN\n"
-        + "gag6mPnDzd8JCsuEj4VEy6NF1KcoCc8edQ8AZ4L6ZQ6qiV24gxLnh8xImVr5YjBK\n"
-        + "DUCdrl790u4wekfgapSx9Sw9Ycz5dFOL07OOHPiKZwUG0f8td6oJX4Ddxset5JAm\n"
-        + "1pPcLQHR6PRx0hI/Tz7rsAI6O37/BEM15+MVGIgOSLL/SRIpOa0L8qmuUhhS6Bg=\n"
-        + "=HTKj\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx6nxoBCADjYOWOFa7ZBJpRuNspRoXBTK0LiK5zqN894b87LgIYEgUM6q5J\n"
+            + "yLNo43x7V+ow1/7BEq0JUAMSQ3uRn2jqXiJskSXvwlFYcTVFb0gY09CSD0ptHvda\n"
+            + "zqYOuM/MU1l9jqmlM+pDw/z0pLTKYmAHi6pKJ64pqccMHPUZHpLywyzSNX+JM86I\n"
+            + "K5KAsyGArtgpT9vfci3idNeXjhMR8rfLPDFbdGvGFOZrYv0cfgTbBpVEWeHjs2FR\n"
+            + "4vHG133AdjdZcvA9Y9VW34ZLeiyBEeFix7+HPVS82rko2kQxZu1UZRu340maKDAo\n"
+            + "+UVirgo0FQ8nNUR+c9oNKgiZtO39IAPJv/WZABEBAAEAB/4xKKzYqDVyM/2NN5Mi\n"
+            + "fF3EqegruzRESzlgrqLij5LiU1sGLOLbjunC/pPWMu6t+rTYV0pT3hmb5D0eAcH0\n"
+            + "EcANiuAR0wg1P9yNk36Z54mLWoTzzKMb3dunCSvb+BU8AREKZ4v5dLEGz2lK7DPo\n"
+            + "zbhWaffMiClBpC0VbjfFBo91LrVUVnhRglBYKdPLQm/Lhw5cNCYOw194ZturO+cC\n"
+            + "iQZhGSy52HMoMs4Wr470CeFZvvWaiDCirVLcj4UhMsVANFKsahMARm9c+QrGrkRP\n"
+            + "+654f8M9ptapcQYpGOMmaeZVnpocONXOTkiJd7Hhr4PRUY+QS8C8F0LbmL2ERQbL\n"
+            + "F65RBADkIelztY/8Xy2S0jsW7+xF2ziz9riOR87G6b0wrXDdFz4GHPzLvwsdXOeN\n"
+            + "cODic14d9bf5jtXr9hgbAzx55ANDjOl3jK5qil8Z9qwsrNK9Mz0wT1acQXBwf/5D\n"
+            + "hI/whBK1FsH7Y+wdX64XA3EXmclxB8GZf1JsGXF3jNH30vyS7QQA/ydoMMw8ja9L\n"
+            + "j6MxHtVHcE4A4j6tFljLDuf8icOwwNUfb7SsHTDjUI2+30ZJOv+qISrthsASCSj3\n"
+            + "AN87CGdVR62Xe923DNdW8/moKKDILNaESyOi27qhI5qWrVRgNB5QwbQcSoClUxbj\n"
+            + "V7YZSfrZkiI+GE1gh1QPMOVyCUmqu90D+wc0x0wUj8emX/4xbbujOa5RAvNcNvnD\n"
+            + "mOB2CfPWD10TEeOOlHBhuoy2/GdIl76W0szJaxnzcV82VArllSciCBzpSfkExDZ6\n"
+            + "08hA8GpOsuOmAAPwXWZsb8YZbJeM0ULMgUCGHgvUj1/pGsCVA6c7sPAdkCfAFlmO\n"
+            + "smC9bvpS2VHZPuG0HlRlc3R1c2VyIEUgPHRlc3RlQGV4YW1wbGUuY29tPokBPgQT\n"
+            + "AQIAKAUCTHqfGgIbAwUJAeEzgAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ\n"
+            + "Owm5f0tDh+6Fowf9FZgntlW4qc7BHe8zYJ0qzoLZrHwCFcaeO3kz53y5Lz3+plMu\n"
+            + "qVDjoQDOt8DxsPHrXWKiu0qBTjZ28ztN3ef6f0MpguTGclvFroevUct0xiyox5r1\n"
+            + "DfMT8JRvqsojE1XPscR2zJzIgEg3OCPuksT9EsHsF+/3RBbsXbQgDpW38g0GzIJI\n"
+            + "4AiQ/yvG2ON9awN2kzIWoBkthVCGy54lCTGjyPhatE7Zu2ABNcerIDstupWww2Ps\n"
+            + "ec6pGbPPci8ojc90fzalk3UMXcXHD7m8cTJSkgHScOzTElIQqOA1+w6uiHy2oAn+\n"
+            + "qW7534j6p9Tj+DrSIzUXBedGjXZevaKaurVyKJ0DmARMep8aAQgAn5r6toYnEzwD\n"
+            + "eig8r+t89vqOFtohYcmtyeLeTiTTdAz/xWBWHUlqV8sglQ9aINpGtBf37v13RhtU\n"
+            + "3WkUv8cZMQoRM8P2H3cKDNwkucFO6uKSEQO5FdzTm4C4WaoE7QiTRbiekwh7O54m\n"
+            + "z4Wup6LHuEFQEcSpdRUp8w/qaJIHG9EJad1qUEsKNnITW+mWHY3+ccK1hgqPwOPq\n"
+            + "O3/8QtaipekKOYAtOb+57c1jtDFBZnYIkantoKs+kRw0DykXFTyFOMYqaleBMcVG\n"
+            + "+u7ljwAq18L8Ev+qVIpBIZ5eQ5+6p1w9B69hRH0Ebn50ebpoqKOXhN4/bu/wq596\n"
+            + "y0o4xDB0GQARAQABAAf7Bk9bQCIXo2QJAyhaFd5qh10qhu7CyRnvG/8zKMW98mWd\n"
+            + "KxF+9hNz99qZBCuiNZBLoU0dST6OG6By/3nrDxXxAgZS3cgOj/nl1NJTRWDGHPUu\n"
+            + "LywFgj7Dwu8Y2rqlDTX8lJIS+t8n+BhtkmDHoesGmFtErh8nT/CxQuHLM60qSMgv\n"
+            + "6mSmtOkM+2KfiA5z2o1fDWXjDieW+hdgDPxkaB835wfuDn/Dsn1ch1XHON0xSyTo\n"
+            + "+c35nFXoK1pAXaoalAxZNxcXCAM3NhU37Ih4GejM0K7sSgK72HmgxtNYF77DrTIM\n"
+            + "m5+3960ri1JUuEaJ7ZcqbpKxy/GDldNCYBTx07QMzQQAyYQ+ujT9Pj8zfp1jMLRs\n"
+            + "Xn9GsvYawjo+AIZuHeUmmIXfEoyNmsEUoGHnz9ROLnJzanW5XEStiTys8tHJPIkz\n"
+            + "zL0Ce0oUF93ln0z/jQBIKaSzYB7PMmYCd7ueF94aKqAOrQ/QBb+6JsVjGAtLUoTv\n"
+            + "ey09hGYMogiBV1r0MB2Rsa8EAMrB5VKVQF6+q0XuP6ljFQRaumi4lH7PoQ65E7UD\n"
+            + "6YpyQpLBOE7dV+fHizdUuwsD/wyAOu0EskV1ZLXvXzyk10r3PRoFdpHOvijwZBGt\n"
+            + "jiOiVvK1vkQKDMBczOe74+DaknKn6HzgCsXmLgfk+P8BtLOJnCYsbS9IbnImy2vi\n"
+            + "aJC3A/9wOOK+po8C7JPHVIEfxbe7nwHOoi/h7T4uPrlq/gcQRquqGhQ16nDGYZvX\n"
+            + "ny9aPQ3NcvDR69RM2AaXav03bHVxfhVEyGjP5jLZz7956e4LlnKrsuEhDLfiv30i\n"
+            + "qCC7zNHNA99s5u25vt8AuPVVHfSQ++jifabfv5lU4FHqmK8/4EAoiQElBBgBAgAP\n"
+            + "BQJMep8aAhsMBQkB4TOAAAoJEDsJuX9LQ4fu0/wH/35/22xina8ktbvGV/kB0pH2\n"
+            + "LBqeXN/bCLdA+CDzfwMDzqG0kU39EJ3Fbux7fj4uMaeiYfbO9U85+NOuDmeH41B2\n"
+            + "dM9S1AzEH+/OiCp/Zf1fdd1qXhsA4Xe5vc/VD9oso9OrZK5CM5u0TPmYFijfVDPN\n"
+            + "gag6mPnDzd8JCsuEj4VEy6NF1KcoCc8edQ8AZ4L6ZQ6qiV24gxLnh8xImVr5YjBK\n"
+            + "DUCdrl790u4wekfgapSx9Sw9Ycz5dFOL07OOHPiKZwUG0f8td6oJX4Ddxset5JAm\n"
+            + "1pPcLQHR6PRx0hI/Tz7rsAI6O37/BEM15+MVGIgOSLL/SRIpOa0L8qmuUhhS6Bg=\n"
+            + "=HTKj\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/31FA48C4 2010-09-01
-   *       Key fingerprint = 85CE F045 8113 42DA 14A4  42AA 4A9F AC70 31FA 48C4
-   * uid                  Testuser F &lt;testf@example.com&gt;
-   * sub   2048R/50FF7D5C 2010-09-01
+   * pub 2048R/31FA48C4 2010-09-01 Key fingerprint = 85CE F045 8113 42DA 14A4 42AA 4A9F AC70 31FA
+   * 48C4 uid Testuser F &lt;testf@example.com&gt; sub 2048R/50FF7D5C 2010-09-01
    */
   public static TestKey keyF() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx+aQkBCADycHmQ8wC4GzaDIvw4uv6/HiTWOUmuLcT06PpsvNBdR2sQ6Vyy\n"
-        + "w1SAnaPmskdgxE7TXpDrwIWPmIkg8KzSfPAap6qZy5zyE1ABQa9yD9v6wsew+lM5\n"
-        + "3UdBO6HQodpWSJMbeR48mUQ96z72B7Lb2GvhFLxvcn5od9jQhbQXfb2k67l33hgR\n"
-        + "D427lxXa+qnmL9pMRGhRw6QATFX+icHxsPfpKnzuk0aY3feJm4+jr4RgHP4djH3i\n"
-        + "NZbv3ibZ24Dj3CK07PwbhqUhZwMqueWbo3ChYjLkRGT/UosNTN0EbHjqBMl4N9OT\n"
-        + "Pl2CM6kuzuaLz3ZeAf48B29GX4rAXfuJTKBbABEBAAG0HlRlc3R1c2VyIEYgPHRl\n"
-        + "c3RmQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pCQIbAwYLCQgHAwIGFQgCCQoL\n"
-        + "BBYCAwECHgECF4AACgkQSp+scDH6SMRfqggAh//U/l4JuwFgWx14mo0SB9YWU81L\n"
-        + "EgUYUd2MUzvX4p/HIFQa0c7stj68Za40+O0tG/J0RCMNb7piM9JFii+MQZzOVuza\n"
-        + "4bbO59D9qboc7Anvx9hGlfIdinT+n5rwX9kZvD2D7GMskm8ZgovkvNwNKcW+5W/4\n"
-        + "ciWqCJKE/Fp9XsooJgN94pJfgDQ2WBL5KDx1aGt4wZXhH2Atl6a6oVZJIH4SaizD\n"
-        + "jau7F4vc7hBfbcDhxFcrVX1QMpzpl352cIx6KVw4FRWvQ8VKkga4JiQwosfvCT2Z\n"
-        + "pdMwy3cARynv8BWLc4Uexf88QIeClP9ZhoVeMqvHMfUb3d6Q5362VdZqI4kBIAQQ\n"
-        + "AQIACgUCTH5xcgMFCngACgkQiptSk+LTK6UqsAgAlsEmzC3Xxv4o5ui95AFbWZGi\n"
-        + "es5rI9WoW2P+6OqVUy1E8+5HdlJ8wUbU1H7JAdFTjY9rH3vKXCXsTetF4z0cupER\n"
-        + "Rkx06M9/jl5OSw8i9bPNNJFobHwiiNO00ctC1tT5oUVXVsfPQHlEbMofv8jehfgC\n"
-        + "gMqH/ve/aafKFfYCZkNHugRgLzxeDpXp3IdyXoSAFGiULnGvMDN7n61QOvEYOw2Z\n"
-        + "i63ql+bL2oj4G+/bNOkdYkuIBN4F/P45P7xy80MSOvkMH7IG/aFTKMNQGWSykKwI\n"
-        + "FRkC+y+F5Oqf/WD30GvbSA7q013sb6nHYvsaHS/48cgIJ5TSVd0LTlrF9uv43bkB\n"
-        + "DQRMfmkJAQgAzc1uAF4x16Cx4GtHI0Hvm+v7bUEUtBw2XzyOKu883XC5JmGcY18y\n"
-        + "YItRpchAtmacDpu0/2925/mWF7aS9RMgSYI/1D9LaTeimISM3iGFY35kt78NGZwJ\n"
-        + "DeCPJPI1sbOU0njfrCPTbOQuRDJ6evaBNX9HYArSEp0ygruJdOUYgnepCt4A7W95\n"
-        + "EKp9KPo7XV1K8y86vrKbgpJ+NnEi7dzMqVxnhO4wAWqb6HYcKLrEc2gVnLtzHkBl\n"
-        + "Y/6dOP15jgQKql1/yQIXae/WGT24n/VeaKqrbSmDNkhW5eW5o1Bkgy/M98oNHXd0\n"
-        + "nVrT8Lyf6un5TwMy+vk0l5AjMMtIZKS0GQARAQABiQEfBBgBAgAJBQJMfmkJAhsM\n"
-        + "AAoJEEqfrHAx+kjEvDAH/iO6BHQfFa+kqjfYD3NE+FNosXv3jiXOU7SCD2MG3AwD\n"
-        + "YqM+v1n4UvvMLLdEbtboht1Btys1vuyNM3RAmR45oh9Dfuc4SKtVzSCkKs85jNvH\n"
-        + "7Ik8gxZ9ARzJbawNzTLFyLwDdcdX42Umuvh49Pn7Nc7FDYcZLffEcTh9sZ7KyxLY\n"
-        + "qcjtnblx5oOQnYnpBbM61GvgNXC8Z+g9fg0oHRouKXKE/HDKbsN0siEf9XJFJTKd\n"
-        + "Eg1NgoyKWdaV4+pU/fTzZUvvDqOSRx8he5w64dvW9o7WdARq/3vPvHgy0O8fMTSI\n"
-        + "tmcHxCU8l0jptJz181N36Uhmjyc9oC4dn9ceSn6VDbg=\n"
-        + "=WDx2\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aQkBCADycHmQ8wC4GzaDIvw4uv6/HiTWOUmuLcT06PpsvNBdR2sQ6Vyy\n"
+            + "w1SAnaPmskdgxE7TXpDrwIWPmIkg8KzSfPAap6qZy5zyE1ABQa9yD9v6wsew+lM5\n"
+            + "3UdBO6HQodpWSJMbeR48mUQ96z72B7Lb2GvhFLxvcn5od9jQhbQXfb2k67l33hgR\n"
+            + "D427lxXa+qnmL9pMRGhRw6QATFX+icHxsPfpKnzuk0aY3feJm4+jr4RgHP4djH3i\n"
+            + "NZbv3ibZ24Dj3CK07PwbhqUhZwMqueWbo3ChYjLkRGT/UosNTN0EbHjqBMl4N9OT\n"
+            + "Pl2CM6kuzuaLz3ZeAf48B29GX4rAXfuJTKBbABEBAAG0HlRlc3R1c2VyIEYgPHRl\n"
+            + "c3RmQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pCQIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQSp+scDH6SMRfqggAh//U/l4JuwFgWx14mo0SB9YWU81L\n"
+            + "EgUYUd2MUzvX4p/HIFQa0c7stj68Za40+O0tG/J0RCMNb7piM9JFii+MQZzOVuza\n"
+            + "4bbO59D9qboc7Anvx9hGlfIdinT+n5rwX9kZvD2D7GMskm8ZgovkvNwNKcW+5W/4\n"
+            + "ciWqCJKE/Fp9XsooJgN94pJfgDQ2WBL5KDx1aGt4wZXhH2Atl6a6oVZJIH4SaizD\n"
+            + "jau7F4vc7hBfbcDhxFcrVX1QMpzpl352cIx6KVw4FRWvQ8VKkga4JiQwosfvCT2Z\n"
+            + "pdMwy3cARynv8BWLc4Uexf88QIeClP9ZhoVeMqvHMfUb3d6Q5362VdZqI4kBIAQQ\n"
+            + "AQIACgUCTH5xcgMFCngACgkQiptSk+LTK6UqsAgAlsEmzC3Xxv4o5ui95AFbWZGi\n"
+            + "es5rI9WoW2P+6OqVUy1E8+5HdlJ8wUbU1H7JAdFTjY9rH3vKXCXsTetF4z0cupER\n"
+            + "Rkx06M9/jl5OSw8i9bPNNJFobHwiiNO00ctC1tT5oUVXVsfPQHlEbMofv8jehfgC\n"
+            + "gMqH/ve/aafKFfYCZkNHugRgLzxeDpXp3IdyXoSAFGiULnGvMDN7n61QOvEYOw2Z\n"
+            + "i63ql+bL2oj4G+/bNOkdYkuIBN4F/P45P7xy80MSOvkMH7IG/aFTKMNQGWSykKwI\n"
+            + "FRkC+y+F5Oqf/WD30GvbSA7q013sb6nHYvsaHS/48cgIJ5TSVd0LTlrF9uv43bkB\n"
+            + "DQRMfmkJAQgAzc1uAF4x16Cx4GtHI0Hvm+v7bUEUtBw2XzyOKu883XC5JmGcY18y\n"
+            + "YItRpchAtmacDpu0/2925/mWF7aS9RMgSYI/1D9LaTeimISM3iGFY35kt78NGZwJ\n"
+            + "DeCPJPI1sbOU0njfrCPTbOQuRDJ6evaBNX9HYArSEp0ygruJdOUYgnepCt4A7W95\n"
+            + "EKp9KPo7XV1K8y86vrKbgpJ+NnEi7dzMqVxnhO4wAWqb6HYcKLrEc2gVnLtzHkBl\n"
+            + "Y/6dOP15jgQKql1/yQIXae/WGT24n/VeaKqrbSmDNkhW5eW5o1Bkgy/M98oNHXd0\n"
+            + "nVrT8Lyf6un5TwMy+vk0l5AjMMtIZKS0GQARAQABiQEfBBgBAgAJBQJMfmkJAhsM\n"
+            + "AAoJEEqfrHAx+kjEvDAH/iO6BHQfFa+kqjfYD3NE+FNosXv3jiXOU7SCD2MG3AwD\n"
+            + "YqM+v1n4UvvMLLdEbtboht1Btys1vuyNM3RAmR45oh9Dfuc4SKtVzSCkKs85jNvH\n"
+            + "7Ik8gxZ9ARzJbawNzTLFyLwDdcdX42Umuvh49Pn7Nc7FDYcZLffEcTh9sZ7KyxLY\n"
+            + "qcjtnblx5oOQnYnpBbM61GvgNXC8Z+g9fg0oHRouKXKE/HDKbsN0siEf9XJFJTKd\n"
+            + "Eg1NgoyKWdaV4+pU/fTzZUvvDqOSRx8he5w64dvW9o7WdARq/3vPvHgy0O8fMTSI\n"
+            + "tmcHxCU8l0jptJz181N36Uhmjyc9oC4dn9ceSn6VDbg=\n"
+            + "=WDx2\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx+aQkBCADycHmQ8wC4GzaDIvw4uv6/HiTWOUmuLcT06PpsvNBdR2sQ6Vyy\n"
-        + "w1SAnaPmskdgxE7TXpDrwIWPmIkg8KzSfPAap6qZy5zyE1ABQa9yD9v6wsew+lM5\n"
-        + "3UdBO6HQodpWSJMbeR48mUQ96z72B7Lb2GvhFLxvcn5od9jQhbQXfb2k67l33hgR\n"
-        + "D427lxXa+qnmL9pMRGhRw6QATFX+icHxsPfpKnzuk0aY3feJm4+jr4RgHP4djH3i\n"
-        + "NZbv3ibZ24Dj3CK07PwbhqUhZwMqueWbo3ChYjLkRGT/UosNTN0EbHjqBMl4N9OT\n"
-        + "Pl2CM6kuzuaLz3ZeAf48B29GX4rAXfuJTKBbABEBAAEAB/4vTP+C5s5snS6ZDlHc\n"
-        + "datvOV/hhgLYn2huiigV4A7dLCp4/bbOz+pkP51zTLQ9bn+coLYwsPq+Bfo3OY3W\n"
-        + "cXbdFHpmEEJaPqdc32ZuICcAuVEBuA1V3FTjJtHO5U02iWleMlbSZurYE9ZQZTch\n"
-        + "yotdulB7hACivENKh9OXw7ok+1GZVvBGA8tpIwzLZo0Pkb2lDQHaL0GXAjlMNzwg\n"
-        + "cCPFtzjNu6K4g58nuYrjGiE+yWPMJgfo4fTGXcapqXgvh1tKIVxwr2YQSyEOqfMH\n"
-        + "8EwgBj5NPwv0UXAivQUkTaguUJXrlJLtS3mp45nCEAlGT4PNoMyPdvPEf62gND7C\n"
-        + "y9K1BAD493ADPAx9pWCSQI9wp4ARUelTzwHgZ6fRVIzmwO6MuZN1PrtiOLCwY5Jw\n"
-        + "r+97VvMmem7Ya3khP4vz0IiN7p1oCR5nJazk2eRaQNuim0aB0lqrTsli8OXtBlgQ\n"
-        + "5WtLcRi5798Jw8coczc5OftZKhu1SbQZ1VdDdmTbMTAsSRtMjQQA+UnU6FYJZBjE\n"
-        + "NHNheV6+k45HXHubcCm4Ka3kJK88zbZzyt+nrBLEtElosxDCqT8WbiAH7qmpnd/r\n"
-        + "ly7ryIX08etuWVYnx0Xa02cKQ6TzNcbxijeGQYGHIE0RK29nRo8zRWVmbCydqJz1\n"
-        + "5cHgcvoTu7DWWjM5QEZlLPQytJeAyocEAM6AiWDXYVZVnCB9w0wwK/9cX0v3tfYv\n"
-        + "QrJZCT3/YKxJWnMZ+LgHYO0w1B0YwGEeVTnmXODDy5mRh9lxV1aZnwKCwMR1tXTx\n"
-        + "G1potBR0GJxI2xpMb/MJPxeJCAZPu8NncRpl/8v0stiGnkpYCNR/k3JV5jEXq0u6\n"
-        + "4pDSzRGehOHnOqu0HlRlc3R1c2VyIEYgPHRlc3RmQGV4YW1wbGUuY29tPokBOAQT\n"
-        + "AQIAIgUCTH5pCQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQSp+scDH6\n"
-        + "SMRfqggAh//U/l4JuwFgWx14mo0SB9YWU81LEgUYUd2MUzvX4p/HIFQa0c7stj68\n"
-        + "Za40+O0tG/J0RCMNb7piM9JFii+MQZzOVuza4bbO59D9qboc7Anvx9hGlfIdinT+\n"
-        + "n5rwX9kZvD2D7GMskm8ZgovkvNwNKcW+5W/4ciWqCJKE/Fp9XsooJgN94pJfgDQ2\n"
-        + "WBL5KDx1aGt4wZXhH2Atl6a6oVZJIH4SaizDjau7F4vc7hBfbcDhxFcrVX1QMpzp\n"
-        + "l352cIx6KVw4FRWvQ8VKkga4JiQwosfvCT2ZpdMwy3cARynv8BWLc4Uexf88QIeC\n"
-        + "lP9ZhoVeMqvHMfUb3d6Q5362VdZqI50DmARMfmkJAQgAzc1uAF4x16Cx4GtHI0Hv\n"
-        + "m+v7bUEUtBw2XzyOKu883XC5JmGcY18yYItRpchAtmacDpu0/2925/mWF7aS9RMg\n"
-        + "SYI/1D9LaTeimISM3iGFY35kt78NGZwJDeCPJPI1sbOU0njfrCPTbOQuRDJ6evaB\n"
-        + "NX9HYArSEp0ygruJdOUYgnepCt4A7W95EKp9KPo7XV1K8y86vrKbgpJ+NnEi7dzM\n"
-        + "qVxnhO4wAWqb6HYcKLrEc2gVnLtzHkBlY/6dOP15jgQKql1/yQIXae/WGT24n/Ve\n"
-        + "aKqrbSmDNkhW5eW5o1Bkgy/M98oNHXd0nVrT8Lyf6un5TwMy+vk0l5AjMMtIZKS0\n"
-        + "GQARAQABAAf/T22JFmhESUnSTOBqeK+Sd/WIOJ7lDCxVScVXwzdJINfIBYmnr2yG\n"
-        + "x18NuHOEkkEg2rx6ixksZZRcurMynZZvoB8+Xj69bpLT1JRXv8VlM0SNP6NjPW6M\n"
-        + "ygfQhzxZv8ck2WRgQxIin8SjHJv0zG9F5+1DEUyrzhZQb8dMYkqm/nbZ1FDnMu4F\n"
-        + "1qUZxKx0hU70tAXfywtpH9NQs8jwenUjiXA00k6A48BF7gartYtcGnEG9mk+Z+lh\n"
-        + "/uD+z5j3/ym9XqOJPpFIWhMYTLueSD5yrCT34VdIc1xBOjjtxBsCCbgSFZaewCpB\n"
-        + "5usRr2I4+CK3vbAMny5Hk+/RYZdFQkCA5wQA2JusdhwqPjfzxtcxz13Vu1ZzKR41\n"
-        + "kkno/boGh5afBlf7kL/5FXDhGVVvHMvXtQntU1kHgOcE8b2Jfy38gNGkd3TAh4Oj\n"
-        + "fLavcYyn+9tEkjRVdOeU0P9fszDA1cW5Gjuv6GkbCUSQrv68TKp/mWiTlYm+FT3a\n"
-        + "RSIz2gEyOZNkTzsEAPM6sU/VOwpJ2ppOa5+290sptjSbRNYjKlQ66nHZnbafzLz5\n"
-        + "tKpRc0BzG/N2lXwlVl5+3oXSSSbWhJscA8EFwSnAx8Id10zW5NAEfxNuqxxEXlJg\n"
-        + "kOhqwJ1JMz32xlZFRZYxSdXSycYrX/AhV7I7RQxgC48X9udMb8LIXYq0lzy7A/9p\n"
-        + "Skd2Me9JotuTN3OaR42hXozLx+yERBBEWuI3WXovWRD8b8gCfWL3P40d2UVnjFmP\n"
-        + "TZ8p9aHAd2srWgaPSZaSsHtIyI6dQGScMEOKEaCJxYvF/wuvx/MABDatcaJhMaAc\n"
-        + "W/0w+gb8Lr2hbuRhBSP754V3Amma6LxsmLRAwB6ioT7NiQEfBBgBAgAJBQJMfmkJ\n"
-        + "AhsMAAoJEEqfrHAx+kjEvDAH/iO6BHQfFa+kqjfYD3NE+FNosXv3jiXOU7SCD2MG\n"
-        + "3AwDYqM+v1n4UvvMLLdEbtboht1Btys1vuyNM3RAmR45oh9Dfuc4SKtVzSCkKs85\n"
-        + "jNvH7Ik8gxZ9ARzJbawNzTLFyLwDdcdX42Umuvh49Pn7Nc7FDYcZLffEcTh9sZ7K\n"
-        + "yxLYqcjtnblx5oOQnYnpBbM61GvgNXC8Z+g9fg0oHRouKXKE/HDKbsN0siEf9XJF\n"
-        + "JTKdEg1NgoyKWdaV4+pU/fTzZUvvDqOSRx8he5w64dvW9o7WdARq/3vPvHgy0O8f\n"
-        + "MTSItmcHxCU8l0jptJz181N36Uhmjyc9oC4dn9ceSn6VDbg=\n"
-        + "=ZLpl\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx+aQkBCADycHmQ8wC4GzaDIvw4uv6/HiTWOUmuLcT06PpsvNBdR2sQ6Vyy\n"
+            + "w1SAnaPmskdgxE7TXpDrwIWPmIkg8KzSfPAap6qZy5zyE1ABQa9yD9v6wsew+lM5\n"
+            + "3UdBO6HQodpWSJMbeR48mUQ96z72B7Lb2GvhFLxvcn5od9jQhbQXfb2k67l33hgR\n"
+            + "D427lxXa+qnmL9pMRGhRw6QATFX+icHxsPfpKnzuk0aY3feJm4+jr4RgHP4djH3i\n"
+            + "NZbv3ibZ24Dj3CK07PwbhqUhZwMqueWbo3ChYjLkRGT/UosNTN0EbHjqBMl4N9OT\n"
+            + "Pl2CM6kuzuaLz3ZeAf48B29GX4rAXfuJTKBbABEBAAEAB/4vTP+C5s5snS6ZDlHc\n"
+            + "datvOV/hhgLYn2huiigV4A7dLCp4/bbOz+pkP51zTLQ9bn+coLYwsPq+Bfo3OY3W\n"
+            + "cXbdFHpmEEJaPqdc32ZuICcAuVEBuA1V3FTjJtHO5U02iWleMlbSZurYE9ZQZTch\n"
+            + "yotdulB7hACivENKh9OXw7ok+1GZVvBGA8tpIwzLZo0Pkb2lDQHaL0GXAjlMNzwg\n"
+            + "cCPFtzjNu6K4g58nuYrjGiE+yWPMJgfo4fTGXcapqXgvh1tKIVxwr2YQSyEOqfMH\n"
+            + "8EwgBj5NPwv0UXAivQUkTaguUJXrlJLtS3mp45nCEAlGT4PNoMyPdvPEf62gND7C\n"
+            + "y9K1BAD493ADPAx9pWCSQI9wp4ARUelTzwHgZ6fRVIzmwO6MuZN1PrtiOLCwY5Jw\n"
+            + "r+97VvMmem7Ya3khP4vz0IiN7p1oCR5nJazk2eRaQNuim0aB0lqrTsli8OXtBlgQ\n"
+            + "5WtLcRi5798Jw8coczc5OftZKhu1SbQZ1VdDdmTbMTAsSRtMjQQA+UnU6FYJZBjE\n"
+            + "NHNheV6+k45HXHubcCm4Ka3kJK88zbZzyt+nrBLEtElosxDCqT8WbiAH7qmpnd/r\n"
+            + "ly7ryIX08etuWVYnx0Xa02cKQ6TzNcbxijeGQYGHIE0RK29nRo8zRWVmbCydqJz1\n"
+            + "5cHgcvoTu7DWWjM5QEZlLPQytJeAyocEAM6AiWDXYVZVnCB9w0wwK/9cX0v3tfYv\n"
+            + "QrJZCT3/YKxJWnMZ+LgHYO0w1B0YwGEeVTnmXODDy5mRh9lxV1aZnwKCwMR1tXTx\n"
+            + "G1potBR0GJxI2xpMb/MJPxeJCAZPu8NncRpl/8v0stiGnkpYCNR/k3JV5jEXq0u6\n"
+            + "4pDSzRGehOHnOqu0HlRlc3R1c2VyIEYgPHRlc3RmQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTH5pCQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQSp+scDH6\n"
+            + "SMRfqggAh//U/l4JuwFgWx14mo0SB9YWU81LEgUYUd2MUzvX4p/HIFQa0c7stj68\n"
+            + "Za40+O0tG/J0RCMNb7piM9JFii+MQZzOVuza4bbO59D9qboc7Anvx9hGlfIdinT+\n"
+            + "n5rwX9kZvD2D7GMskm8ZgovkvNwNKcW+5W/4ciWqCJKE/Fp9XsooJgN94pJfgDQ2\n"
+            + "WBL5KDx1aGt4wZXhH2Atl6a6oVZJIH4SaizDjau7F4vc7hBfbcDhxFcrVX1QMpzp\n"
+            + "l352cIx6KVw4FRWvQ8VKkga4JiQwosfvCT2ZpdMwy3cARynv8BWLc4Uexf88QIeC\n"
+            + "lP9ZhoVeMqvHMfUb3d6Q5362VdZqI50DmARMfmkJAQgAzc1uAF4x16Cx4GtHI0Hv\n"
+            + "m+v7bUEUtBw2XzyOKu883XC5JmGcY18yYItRpchAtmacDpu0/2925/mWF7aS9RMg\n"
+            + "SYI/1D9LaTeimISM3iGFY35kt78NGZwJDeCPJPI1sbOU0njfrCPTbOQuRDJ6evaB\n"
+            + "NX9HYArSEp0ygruJdOUYgnepCt4A7W95EKp9KPo7XV1K8y86vrKbgpJ+NnEi7dzM\n"
+            + "qVxnhO4wAWqb6HYcKLrEc2gVnLtzHkBlY/6dOP15jgQKql1/yQIXae/WGT24n/Ve\n"
+            + "aKqrbSmDNkhW5eW5o1Bkgy/M98oNHXd0nVrT8Lyf6un5TwMy+vk0l5AjMMtIZKS0\n"
+            + "GQARAQABAAf/T22JFmhESUnSTOBqeK+Sd/WIOJ7lDCxVScVXwzdJINfIBYmnr2yG\n"
+            + "x18NuHOEkkEg2rx6ixksZZRcurMynZZvoB8+Xj69bpLT1JRXv8VlM0SNP6NjPW6M\n"
+            + "ygfQhzxZv8ck2WRgQxIin8SjHJv0zG9F5+1DEUyrzhZQb8dMYkqm/nbZ1FDnMu4F\n"
+            + "1qUZxKx0hU70tAXfywtpH9NQs8jwenUjiXA00k6A48BF7gartYtcGnEG9mk+Z+lh\n"
+            + "/uD+z5j3/ym9XqOJPpFIWhMYTLueSD5yrCT34VdIc1xBOjjtxBsCCbgSFZaewCpB\n"
+            + "5usRr2I4+CK3vbAMny5Hk+/RYZdFQkCA5wQA2JusdhwqPjfzxtcxz13Vu1ZzKR41\n"
+            + "kkno/boGh5afBlf7kL/5FXDhGVVvHMvXtQntU1kHgOcE8b2Jfy38gNGkd3TAh4Oj\n"
+            + "fLavcYyn+9tEkjRVdOeU0P9fszDA1cW5Gjuv6GkbCUSQrv68TKp/mWiTlYm+FT3a\n"
+            + "RSIz2gEyOZNkTzsEAPM6sU/VOwpJ2ppOa5+290sptjSbRNYjKlQ66nHZnbafzLz5\n"
+            + "tKpRc0BzG/N2lXwlVl5+3oXSSSbWhJscA8EFwSnAx8Id10zW5NAEfxNuqxxEXlJg\n"
+            + "kOhqwJ1JMz32xlZFRZYxSdXSycYrX/AhV7I7RQxgC48X9udMb8LIXYq0lzy7A/9p\n"
+            + "Skd2Me9JotuTN3OaR42hXozLx+yERBBEWuI3WXovWRD8b8gCfWL3P40d2UVnjFmP\n"
+            + "TZ8p9aHAd2srWgaPSZaSsHtIyI6dQGScMEOKEaCJxYvF/wuvx/MABDatcaJhMaAc\n"
+            + "W/0w+gb8Lr2hbuRhBSP754V3Amma6LxsmLRAwB6ioT7NiQEfBBgBAgAJBQJMfmkJ\n"
+            + "AhsMAAoJEEqfrHAx+kjEvDAH/iO6BHQfFa+kqjfYD3NE+FNosXv3jiXOU7SCD2MG\n"
+            + "3AwDYqM+v1n4UvvMLLdEbtboht1Btys1vuyNM3RAmR45oh9Dfuc4SKtVzSCkKs85\n"
+            + "jNvH7Ik8gxZ9ARzJbawNzTLFyLwDdcdX42Umuvh49Pn7Nc7FDYcZLffEcTh9sZ7K\n"
+            + "yxLYqcjtnblx5oOQnYnpBbM61GvgNXC8Z+g9fg0oHRouKXKE/HDKbsN0siEf9XJF\n"
+            + "JTKdEg1NgoyKWdaV4+pU/fTzZUvvDqOSRx8he5w64dvW9o7WdARq/3vPvHgy0O8f\n"
+            + "MTSItmcHxCU8l0jptJz181N36Uhmjyc9oC4dn9ceSn6VDbg=\n"
+            + "=ZLpl\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/E2D32BA5 2010-09-01
-   *       Key fingerprint = CB2B 665B 88DA D56A 7009  C15D 8A9B 5293 E2D3 2BA5
-   * uid                  Testuser G &lt;testg@example.com&gt;
-   * sub   2048R/829DAE8D 2010-09-01
+   * pub 2048R/E2D32BA5 2010-09-01 Key fingerprint = CB2B 665B 88DA D56A 7009 C15D 8A9B 5293 E2D3
+   * 2BA5 uid Testuser G &lt;testg@example.com&gt; sub 2048R/829DAE8D 2010-09-01
    */
   public static TestKey keyG() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx+aRYBCAC77YjBScTjRFHtZvk0yyAy8KAXopbCdMBQs7S7iidFMMxhs0Uu\n"
-        + "D7GeleyVusLFJfEM0Ul0b0pLgfJx9j3cot4BTl71OqnawHp4ktuqFyTjhhYy8kBe\n"
-        + "4mliNP36WW7fYXh+f5SZqDQ6rgyoJCOmiUlosb6CM2yUPH3oDtOKg/9Z0iMUcXfQ\n"
-        + "y+bxRKSQmDtiSIS7hwUZmQoo30iAZNygMBLnYyVau3YFan+xyBMCFLa2/pfE0qaU\n"
-        + "QMy67XP8uP7DXlepfc4Lk/qa/2WnAqmuTT2ty9MG+X8M8LuiPuMWfOEx8ICUWB9s\n"
-        + "kCCMWCagS7EUIPhp6AOqjMqEWGOyLmclkGCJABEBAAG0HlRlc3R1c2VyIEcgPHRl\n"
-        + "c3RnQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pFgIbAwYLCQgHAwIGFQgCCQoL\n"
-        + "BBYCAwECHgECF4AACgkQiptSk+LTK6VSwQf/WnIYkLZoARZIUfH61EDlkUPv8+6G\n"
-        + "1YY3YgFFMjeOKybu47eU3QtATEaKHphvKqFtxdNyEtmti1Zx7Cq2LzReY1KoQQ5E\n"
-        + "OlKeyxVmXAuAqoRWesxuG318rVTrozCqSdKPCHLcC26M5sO+Gd2sKbA4DjoSyfrE\n"
-        + "zEOVS1NA9dtZ7WBMXr8gjH//ob7dvuptSAlADaLYYaJugcmbzkRGRbfiCQHqv30I\n"
-        + "+81d7RAeSx8XS38YEWm2IvBLpiS/d7A/2AQ25SHxf+QMMWt83+uOuEVa9rEOraid\n"
-        + "ZC6T8vnSRu1TKkX/60LnJvAw9tigmedi21O6Gpz3H3uGyjuk9o18+m8dJokBIAQQ\n"
-        + "AQIACgUCTH5xfAMFCngACgkQSp+scDH6SMT42gf9H7K0jp6PF1vD5t90bcjtnP/t\n"
-        + "CkOXgfL3lJK/l0KMkoDzyO5z898PP8IAnAj1veJ2fNPsRP903/3K8kd9/31kBriC\n"
-        + "poTVPWBmeLut16TgSDxAQPDLsBPcKe2VadhszOQwhfmdsUlCXwXcwbiAjweXwKh+\n"
-        + "00UoW1GLnPw0T387ttCjHsLe972SVUPFxb6NUkA7val62qxDKg+6MRcf6tDs8sN8\n"
-        + "orhYgh9VJcI3Iw8qK1wHI0CenNie0U5xEkZ5U6W4lfhnL5sggjoAeVeAVLiQ4eiP\n"
-        + "sFrq4TOYq9qfuThYiRaSuTLXzuWG5NVs7NyXxOGFSkwzXrQsBo+LuPwjSCERLbkB\n"
-        + "DQRMfmkWAQgA1O0I9vfZNSRuYTx++SkJccXXqL4neVWEnQ4Ws9tzfSG0Rch3Gb/d\n"
-        + "+ckDtJhlQOdaayTVX7h5k8tTGx0myg6OjG2UM6i+aTgFAzwGnBh/N3p5tTaJhRCF\n"
-        + "x1IapX0N7ijq6rQPPCISc3CUZhCVBTnp5dk3c0/hNxsyYXlI1AwuoMabygzTFN/c\n"
-        + "b1bXp0UTTVrdN+Sj5hHVDvpxyaljLa77I0V+lI3bCil9VhQ9h/TP4C2iK3ZdXOMb\n"
-        + "uW7ANhd+I9LWulmExZIiD9RIsHvB3bDu32g1847uT+DUynKETbZWlZS0Q93Aly1N\n"
-        + "lBIkvOCVCBt+VatzZ8oBV8vbk5R41W1HywARAQABiQEfBBgBAgAJBQJMfmkWAhsM\n"
-        + "AAoJEIqbUpPi0yul/doH+wR+o6UCdD6OZxGMx7d0a7yDJqQFkFf2DRsJvY2suug0\n"
-        + "CMJZRWiA+hIin5P6Brn/eb5nTdWgzlrHxkvb68YkevHALdOvmrYNQFXbb9uWGgEf\n"
-        + "3qERdI8ayJsSTqYsTqyuh9YVz21kADxTHN3JkJ4evjHpyz0Xbtq+oDADg+uswj1b\n"
-        + "ihHthFif54vNMEIW9rX9T7ufhXKamr4LuGwKTPTxV8gEPW4h4ZoQwFKV2qOjR+su\n"
-        + "tHnuXVL24kTnv8CHXUVzJXVTNz7i7fAJTgWc9drH6Ktp3XHfLDBwzT5/5ZhyxGJk\n"
-        + "Qq2Jm/Q8mNkXi34H2DeQ3VPtjtMLr9JR9pf6ivmvUag=\n"
-        + "=34GE\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aRYBCAC77YjBScTjRFHtZvk0yyAy8KAXopbCdMBQs7S7iidFMMxhs0Uu\n"
+            + "D7GeleyVusLFJfEM0Ul0b0pLgfJx9j3cot4BTl71OqnawHp4ktuqFyTjhhYy8kBe\n"
+            + "4mliNP36WW7fYXh+f5SZqDQ6rgyoJCOmiUlosb6CM2yUPH3oDtOKg/9Z0iMUcXfQ\n"
+            + "y+bxRKSQmDtiSIS7hwUZmQoo30iAZNygMBLnYyVau3YFan+xyBMCFLa2/pfE0qaU\n"
+            + "QMy67XP8uP7DXlepfc4Lk/qa/2WnAqmuTT2ty9MG+X8M8LuiPuMWfOEx8ICUWB9s\n"
+            + "kCCMWCagS7EUIPhp6AOqjMqEWGOyLmclkGCJABEBAAG0HlRlc3R1c2VyIEcgPHRl\n"
+            + "c3RnQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pFgIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQiptSk+LTK6VSwQf/WnIYkLZoARZIUfH61EDlkUPv8+6G\n"
+            + "1YY3YgFFMjeOKybu47eU3QtATEaKHphvKqFtxdNyEtmti1Zx7Cq2LzReY1KoQQ5E\n"
+            + "OlKeyxVmXAuAqoRWesxuG318rVTrozCqSdKPCHLcC26M5sO+Gd2sKbA4DjoSyfrE\n"
+            + "zEOVS1NA9dtZ7WBMXr8gjH//ob7dvuptSAlADaLYYaJugcmbzkRGRbfiCQHqv30I\n"
+            + "+81d7RAeSx8XS38YEWm2IvBLpiS/d7A/2AQ25SHxf+QMMWt83+uOuEVa9rEOraid\n"
+            + "ZC6T8vnSRu1TKkX/60LnJvAw9tigmedi21O6Gpz3H3uGyjuk9o18+m8dJokBIAQQ\n"
+            + "AQIACgUCTH5xfAMFCngACgkQSp+scDH6SMT42gf9H7K0jp6PF1vD5t90bcjtnP/t\n"
+            + "CkOXgfL3lJK/l0KMkoDzyO5z898PP8IAnAj1veJ2fNPsRP903/3K8kd9/31kBriC\n"
+            + "poTVPWBmeLut16TgSDxAQPDLsBPcKe2VadhszOQwhfmdsUlCXwXcwbiAjweXwKh+\n"
+            + "00UoW1GLnPw0T387ttCjHsLe972SVUPFxb6NUkA7val62qxDKg+6MRcf6tDs8sN8\n"
+            + "orhYgh9VJcI3Iw8qK1wHI0CenNie0U5xEkZ5U6W4lfhnL5sggjoAeVeAVLiQ4eiP\n"
+            + "sFrq4TOYq9qfuThYiRaSuTLXzuWG5NVs7NyXxOGFSkwzXrQsBo+LuPwjSCERLbkB\n"
+            + "DQRMfmkWAQgA1O0I9vfZNSRuYTx++SkJccXXqL4neVWEnQ4Ws9tzfSG0Rch3Gb/d\n"
+            + "+ckDtJhlQOdaayTVX7h5k8tTGx0myg6OjG2UM6i+aTgFAzwGnBh/N3p5tTaJhRCF\n"
+            + "x1IapX0N7ijq6rQPPCISc3CUZhCVBTnp5dk3c0/hNxsyYXlI1AwuoMabygzTFN/c\n"
+            + "b1bXp0UTTVrdN+Sj5hHVDvpxyaljLa77I0V+lI3bCil9VhQ9h/TP4C2iK3ZdXOMb\n"
+            + "uW7ANhd+I9LWulmExZIiD9RIsHvB3bDu32g1847uT+DUynKETbZWlZS0Q93Aly1N\n"
+            + "lBIkvOCVCBt+VatzZ8oBV8vbk5R41W1HywARAQABiQEfBBgBAgAJBQJMfmkWAhsM\n"
+            + "AAoJEIqbUpPi0yul/doH+wR+o6UCdD6OZxGMx7d0a7yDJqQFkFf2DRsJvY2suug0\n"
+            + "CMJZRWiA+hIin5P6Brn/eb5nTdWgzlrHxkvb68YkevHALdOvmrYNQFXbb9uWGgEf\n"
+            + "3qERdI8ayJsSTqYsTqyuh9YVz21kADxTHN3JkJ4evjHpyz0Xbtq+oDADg+uswj1b\n"
+            + "ihHthFif54vNMEIW9rX9T7ufhXKamr4LuGwKTPTxV8gEPW4h4ZoQwFKV2qOjR+su\n"
+            + "tHnuXVL24kTnv8CHXUVzJXVTNz7i7fAJTgWc9drH6Ktp3XHfLDBwzT5/5ZhyxGJk\n"
+            + "Qq2Jm/Q8mNkXi34H2DeQ3VPtjtMLr9JR9pf6ivmvUag=\n"
+            + "=34GE\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOXBEx+aRYBCAC77YjBScTjRFHtZvk0yyAy8KAXopbCdMBQs7S7iidFMMxhs0Uu\n"
-        + "D7GeleyVusLFJfEM0Ul0b0pLgfJx9j3cot4BTl71OqnawHp4ktuqFyTjhhYy8kBe\n"
-        + "4mliNP36WW7fYXh+f5SZqDQ6rgyoJCOmiUlosb6CM2yUPH3oDtOKg/9Z0iMUcXfQ\n"
-        + "y+bxRKSQmDtiSIS7hwUZmQoo30iAZNygMBLnYyVau3YFan+xyBMCFLa2/pfE0qaU\n"
-        + "QMy67XP8uP7DXlepfc4Lk/qa/2WnAqmuTT2ty9MG+X8M8LuiPuMWfOEx8ICUWB9s\n"
-        + "kCCMWCagS7EUIPhp6AOqjMqEWGOyLmclkGCJABEBAAEAB/QJiwZmylg1MkL2y0Pc\n"
-        + "anQ4If//M0J0nXkmn/mNjHZyDQhT7caVkDZ01ygsck9xs3uKKxaP0xbyvqaRIvAB\n"
-        + "REQBzPkFevUlJqERfmOpP4OgCi8WZzbdmqG/WvGKxP/cWBbGVbQ2GVSNpkj+QNeO\n"
-        + "nWoc5unFstbQsEG0hww2/Hz7EppYoBvDrDLY1EPKzr0r6sk1O5gk3VWOqMEJVCh+\n"
-        + "K7EV4pPGmzMrfZQ0jSwRpr0HhzzhDYR7+QUbxr4OS5PoSJDFh0+A5kqFagyupe7A\n"
-        + "96L3Lh7wJBQJsOe5xjOu3lkFp+3vU+Mq7VzO9Fnp9BCwjb4mEjI39bJdGeeOVCWR\n"
-        + "sYEEAMjmftMhIHrjGRlbZVrLcZY8Du4CFQqImb2Tluo/6siIEurVp4F2swZFm7fw\n"
-        + "B2v09GGJ6zKpauJuxlbwo3CFnxbk24W39F/SixZLggLPtNOXdSrLIQrQ1AXu5ucQ\n"
-        + "oCnXS5FaVkD3Rtd53hSMIf2xJiSRKGp/1X9hga/phScud7URBADveDh1oEmwl3gc\n"
-        + "gorhABLYV7cPrARteQRV13tYWcuAZ6WjqNlbbW2mzBE7KTh4bgTzIX0uQ6SZ7bPl\n"
-        + "RmuKQHrdOO9vFGiSf3zDnIg8fhqSyy2SNrC/e7teuaguGCrg5GrP5izBAsiwvXbt\n"
-        + "ST3OG7c8Ky717JGTiUeTJoe4IaET+QP/SB4uQzVTrbXjBNtq1KqL/CT7l2ABnXsn\n"
-        + "psaVwHOMmY/wP+PiazMEDvLInDAu7R8oLNGqYR+7UYmYeAGmWgrc0L3yFVC01tTG\n"
-        + "bk7Yt/V5KRKVO2I9x+2CP0v0EqW4BNOJzbx5TJ5lBFLMTvbviOdsoDXw0S98HIHB\n"
-        + "T1bFFmhVeulCDLQeVGVzdHVzZXIgRyA8dGVzdGdAZXhhbXBsZS5jb20+iQE4BBMB\n"
-        + "AgAiBQJMfmkWAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRCKm1KT4tMr\n"
-        + "pVLBB/9achiQtmgBFkhR8frUQOWRQ+/z7obVhjdiAUUyN44rJu7jt5TdC0BMRooe\n"
-        + "mG8qoW3F03IS2a2LVnHsKrYvNF5jUqhBDkQ6Up7LFWZcC4CqhFZ6zG4bfXytVOuj\n"
-        + "MKpJ0o8IctwLbozmw74Z3awpsDgOOhLJ+sTMQ5VLU0D121ntYExevyCMf/+hvt2+\n"
-        + "6m1ICUANothhom6ByZvOREZFt+IJAeq/fQj7zV3tEB5LHxdLfxgRabYi8EumJL93\n"
-        + "sD/YBDblIfF/5Awxa3zf6464RVr2sQ6tqJ1kLpPy+dJG7VMqRf/rQucm8DD22KCZ\n"
-        + "52LbU7oanPcfe4bKO6T2jXz6bx0mnQOYBEx+aRYBCADU7Qj299k1JG5hPH75KQlx\n"
-        + "xdeovid5VYSdDhaz23N9IbRFyHcZv935yQO0mGVA51prJNVfuHmTy1MbHSbKDo6M\n"
-        + "bZQzqL5pOAUDPAacGH83enm1NomFEIXHUhqlfQ3uKOrqtA88IhJzcJRmEJUFOenl\n"
-        + "2TdzT+E3GzJheUjUDC6gxpvKDNMU39xvVtenRRNNWt035KPmEdUO+nHJqWMtrvsj\n"
-        + "RX6UjdsKKX1WFD2H9M/gLaIrdl1c4xu5bsA2F34j0ta6WYTFkiIP1Eiwe8HdsO7f\n"
-        + "aDXzju5P4NTKcoRNtlaVlLRD3cCXLU2UEiS84JUIG35Vq3NnygFXy9uTlHjVbUfL\n"
-        + "ABEBAAEAB/48KLaaNJ+xhJgNMA797crF0uyiOAumG/PqfeMLMQs5xQ6OktuXsl6Q\n"
-        + "pus9mLsu8c7Zq9//efsbt1xFMmDVwPQkmAdB60DVMKc16T1C2CcFcTy25vBG4Mqz\n"
-        + "bK6rqCAJ9JSe+H2/cy78X8gF6FR6VAkSUGN62IxcyfnbkW1yv/hiowZ5pQpGVjBH\n"
-        + "sjfu+6HGZhdJIyzrjnVjTJhXNCodtKq1lQGuL2t3ZB6osOXEsFtsI6lQF2s6QZZd\n"
-        + "MUOpSO+X1Rb5TCpWpR/Yj43sH6Tq7LZWEml9fV4wKe2PQWmFW+L8eZCwbYEz6GgZ\n"
-        + "w2pMoMxxOZJsOMOq4LFs4r9qaNQI+sU1BADZhx42JjqBIUsq0OhQcCizjCbPURNw\n"
-        + "7HRfPV8SQkldzmccVzGwFIKQqAVglNdT9AQefUQzx84CRqmWaROXaypkulOB79gM\n"
-        + "R/C/aXOdWz9/dGJ9fT/gcgq1vg9zt7dPE5QIYlhmNdfQPt6R50bUTXe22N2UYL98\n"
-        + "n1pQrhAdlsbT3QQA+pWPXQE4k3Hm7pwCycM2d4TmOIfB6YiaxjMNsZiepV4bqWPX\n"
-        + "iaHh0gw1f8Av6zmMncQELKRspA8Zrj3ZzB/OvNwfpgpqmjS0LyH4u8fGttm7y3In\n"
-        + "/NxZO33omf5vdB2yptzE6DegtsvS94ux6zp01SuzgCXjQbiSjb/VDL0/A8cD/1sQ\n"
-        + "PQGP1yrhn8aX/HAxgJv8cdI6ZnrSUW+G8RnhX281dl5a9so8APchhqeXspYFX6DJ\n"
-        + "Br6MqNkX69a7jthdLZCxaa3hGInr+A/nPVkNEHhjQ8a/kI+28ChRWndofme10hje\n"
-        + "QISFfGuMf6ULK9uo4d1MzGlstfcNRecizfniKby3SBmJAR8EGAECAAkFAkx+aRYC\n"
-        + "GwwACgkQiptSk+LTK6X92gf7BH6jpQJ0Po5nEYzHt3RrvIMmpAWQV/YNGwm9jay6\n"
-        + "6DQIwllFaID6EiKfk/oGuf95vmdN1aDOWsfGS9vrxiR68cAt06+atg1AVdtv25Ya\n"
-        + "AR/eoRF0jxrImxJOpixOrK6H1hXPbWQAPFMc3cmQnh6+MenLPRdu2r6gMAOD66zC\n"
-        + "PVuKEe2EWJ/ni80wQhb2tf1Pu5+Fcpqavgu4bApM9PFXyAQ9biHhmhDAUpXao6NH\n"
-        + "6y60ee5dUvbiROe/wIddRXMldVM3PuLt8AlOBZz12sfoq2ndcd8sMHDNPn/lmHLE\n"
-        + "YmRCrYmb9DyY2ReLfgfYN5DdU+2O0wuv0lH2l/qK+a9RqA==\n"
-        + "=T1WV\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOXBEx+aRYBCAC77YjBScTjRFHtZvk0yyAy8KAXopbCdMBQs7S7iidFMMxhs0Uu\n"
+            + "D7GeleyVusLFJfEM0Ul0b0pLgfJx9j3cot4BTl71OqnawHp4ktuqFyTjhhYy8kBe\n"
+            + "4mliNP36WW7fYXh+f5SZqDQ6rgyoJCOmiUlosb6CM2yUPH3oDtOKg/9Z0iMUcXfQ\n"
+            + "y+bxRKSQmDtiSIS7hwUZmQoo30iAZNygMBLnYyVau3YFan+xyBMCFLa2/pfE0qaU\n"
+            + "QMy67XP8uP7DXlepfc4Lk/qa/2WnAqmuTT2ty9MG+X8M8LuiPuMWfOEx8ICUWB9s\n"
+            + "kCCMWCagS7EUIPhp6AOqjMqEWGOyLmclkGCJABEBAAEAB/QJiwZmylg1MkL2y0Pc\n"
+            + "anQ4If//M0J0nXkmn/mNjHZyDQhT7caVkDZ01ygsck9xs3uKKxaP0xbyvqaRIvAB\n"
+            + "REQBzPkFevUlJqERfmOpP4OgCi8WZzbdmqG/WvGKxP/cWBbGVbQ2GVSNpkj+QNeO\n"
+            + "nWoc5unFstbQsEG0hww2/Hz7EppYoBvDrDLY1EPKzr0r6sk1O5gk3VWOqMEJVCh+\n"
+            + "K7EV4pPGmzMrfZQ0jSwRpr0HhzzhDYR7+QUbxr4OS5PoSJDFh0+A5kqFagyupe7A\n"
+            + "96L3Lh7wJBQJsOe5xjOu3lkFp+3vU+Mq7VzO9Fnp9BCwjb4mEjI39bJdGeeOVCWR\n"
+            + "sYEEAMjmftMhIHrjGRlbZVrLcZY8Du4CFQqImb2Tluo/6siIEurVp4F2swZFm7fw\n"
+            + "B2v09GGJ6zKpauJuxlbwo3CFnxbk24W39F/SixZLggLPtNOXdSrLIQrQ1AXu5ucQ\n"
+            + "oCnXS5FaVkD3Rtd53hSMIf2xJiSRKGp/1X9hga/phScud7URBADveDh1oEmwl3gc\n"
+            + "gorhABLYV7cPrARteQRV13tYWcuAZ6WjqNlbbW2mzBE7KTh4bgTzIX0uQ6SZ7bPl\n"
+            + "RmuKQHrdOO9vFGiSf3zDnIg8fhqSyy2SNrC/e7teuaguGCrg5GrP5izBAsiwvXbt\n"
+            + "ST3OG7c8Ky717JGTiUeTJoe4IaET+QP/SB4uQzVTrbXjBNtq1KqL/CT7l2ABnXsn\n"
+            + "psaVwHOMmY/wP+PiazMEDvLInDAu7R8oLNGqYR+7UYmYeAGmWgrc0L3yFVC01tTG\n"
+            + "bk7Yt/V5KRKVO2I9x+2CP0v0EqW4BNOJzbx5TJ5lBFLMTvbviOdsoDXw0S98HIHB\n"
+            + "T1bFFmhVeulCDLQeVGVzdHVzZXIgRyA8dGVzdGdAZXhhbXBsZS5jb20+iQE4BBMB\n"
+            + "AgAiBQJMfmkWAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRCKm1KT4tMr\n"
+            + "pVLBB/9achiQtmgBFkhR8frUQOWRQ+/z7obVhjdiAUUyN44rJu7jt5TdC0BMRooe\n"
+            + "mG8qoW3F03IS2a2LVnHsKrYvNF5jUqhBDkQ6Up7LFWZcC4CqhFZ6zG4bfXytVOuj\n"
+            + "MKpJ0o8IctwLbozmw74Z3awpsDgOOhLJ+sTMQ5VLU0D121ntYExevyCMf/+hvt2+\n"
+            + "6m1ICUANothhom6ByZvOREZFt+IJAeq/fQj7zV3tEB5LHxdLfxgRabYi8EumJL93\n"
+            + "sD/YBDblIfF/5Awxa3zf6464RVr2sQ6tqJ1kLpPy+dJG7VMqRf/rQucm8DD22KCZ\n"
+            + "52LbU7oanPcfe4bKO6T2jXz6bx0mnQOYBEx+aRYBCADU7Qj299k1JG5hPH75KQlx\n"
+            + "xdeovid5VYSdDhaz23N9IbRFyHcZv935yQO0mGVA51prJNVfuHmTy1MbHSbKDo6M\n"
+            + "bZQzqL5pOAUDPAacGH83enm1NomFEIXHUhqlfQ3uKOrqtA88IhJzcJRmEJUFOenl\n"
+            + "2TdzT+E3GzJheUjUDC6gxpvKDNMU39xvVtenRRNNWt035KPmEdUO+nHJqWMtrvsj\n"
+            + "RX6UjdsKKX1WFD2H9M/gLaIrdl1c4xu5bsA2F34j0ta6WYTFkiIP1Eiwe8HdsO7f\n"
+            + "aDXzju5P4NTKcoRNtlaVlLRD3cCXLU2UEiS84JUIG35Vq3NnygFXy9uTlHjVbUfL\n"
+            + "ABEBAAEAB/48KLaaNJ+xhJgNMA797crF0uyiOAumG/PqfeMLMQs5xQ6OktuXsl6Q\n"
+            + "pus9mLsu8c7Zq9//efsbt1xFMmDVwPQkmAdB60DVMKc16T1C2CcFcTy25vBG4Mqz\n"
+            + "bK6rqCAJ9JSe+H2/cy78X8gF6FR6VAkSUGN62IxcyfnbkW1yv/hiowZ5pQpGVjBH\n"
+            + "sjfu+6HGZhdJIyzrjnVjTJhXNCodtKq1lQGuL2t3ZB6osOXEsFtsI6lQF2s6QZZd\n"
+            + "MUOpSO+X1Rb5TCpWpR/Yj43sH6Tq7LZWEml9fV4wKe2PQWmFW+L8eZCwbYEz6GgZ\n"
+            + "w2pMoMxxOZJsOMOq4LFs4r9qaNQI+sU1BADZhx42JjqBIUsq0OhQcCizjCbPURNw\n"
+            + "7HRfPV8SQkldzmccVzGwFIKQqAVglNdT9AQefUQzx84CRqmWaROXaypkulOB79gM\n"
+            + "R/C/aXOdWz9/dGJ9fT/gcgq1vg9zt7dPE5QIYlhmNdfQPt6R50bUTXe22N2UYL98\n"
+            + "n1pQrhAdlsbT3QQA+pWPXQE4k3Hm7pwCycM2d4TmOIfB6YiaxjMNsZiepV4bqWPX\n"
+            + "iaHh0gw1f8Av6zmMncQELKRspA8Zrj3ZzB/OvNwfpgpqmjS0LyH4u8fGttm7y3In\n"
+            + "/NxZO33omf5vdB2yptzE6DegtsvS94ux6zp01SuzgCXjQbiSjb/VDL0/A8cD/1sQ\n"
+            + "PQGP1yrhn8aX/HAxgJv8cdI6ZnrSUW+G8RnhX281dl5a9so8APchhqeXspYFX6DJ\n"
+            + "Br6MqNkX69a7jthdLZCxaa3hGInr+A/nPVkNEHhjQ8a/kI+28ChRWndofme10hje\n"
+            + "QISFfGuMf6ULK9uo4d1MzGlstfcNRecizfniKby3SBmJAR8EGAECAAkFAkx+aRYC\n"
+            + "GwwACgkQiptSk+LTK6X92gf7BH6jpQJ0Po5nEYzHt3RrvIMmpAWQV/YNGwm9jay6\n"
+            + "6DQIwllFaID6EiKfk/oGuf95vmdN1aDOWsfGS9vrxiR68cAt06+atg1AVdtv25Ya\n"
+            + "AR/eoRF0jxrImxJOpixOrK6H1hXPbWQAPFMc3cmQnh6+MenLPRdu2r6gMAOD66zC\n"
+            + "PVuKEe2EWJ/ni80wQhb2tf1Pu5+Fcpqavgu4bApM9PFXyAQ9biHhmhDAUpXao6NH\n"
+            + "6y60ee5dUvbiROe/wIddRXMldVM3PuLt8AlOBZz12sfoq2ndcd8sMHDNPn/lmHLE\n"
+            + "YmRCrYmb9DyY2ReLfgfYN5DdU+2O0wuv0lH2l/qK+a9RqA==\n"
+            + "=T1WV\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/080E5723 2010-09-01
-   *       Key fingerprint = 2957 ABE4 937D A84A 2E5D  31DB 65C4 33C4 080E 5723
-   * uid                  Testuser H &lt;testh@example.com&gt;
-   * sub   2048R/68C7C262 2010-09-01
+   * pub 2048R/080E5723 2010-09-01 Key fingerprint = 2957 ABE4 937D A84A 2E5D 31DB 65C4 33C4 080E
+   * 5723 uid Testuser H &lt;testh@example.com&gt; sub 2048R/68C7C262 2010-09-01
    */
   public static TestKey keyH() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx+aSUBCADzpZ1h9awUQR1ChzrMhtoE1ltyTlJpS1G5HFEov9QNxVDTjpB8\n"
-        + "PMdb20NNdk/7g6E+ETpCBJGPoC4/TPFDiqe+UI7cRrRZJVbInkCbflYycLTUt9qW\n"
-        + "5c7IuyZA1+cSYaKp3jYccFZfIWvfTWDLWyUozTs9t1TsI28s3r5fBPvrZ+F1nYv/\n"
-        + "xpSkx3Zsxnn7QJTnd63rZdp0RdfJmF2rXERwR6XVtuLj5CqrFoLxy6OrSOl4am4J\n"
-        + "C2HRWhskB21LpdRtloY8bz0DOn6W6JUFRmSxQ1kbPClOXaiNhzMI0fD/KFnHImgR\n"
-        + "IKbbQyHHsKHBjNyn+zTIm5zUL6JMZMf9PoSZABEBAAG0HlRlc3R1c2VyIEggPHRl\n"
-        + "c3RoQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pJQIbAwYLCQgHAwIGFQgCCQoL\n"
-        + "BBYCAwECHgECF4AACgkQZcQzxAgOVyORcAf/QaHVlyhlBnU4edujW2uG/PFrZvwK\n"
-        + "fqOKW0QqQ7kVN8okKhnFv4y11IwLIzL9mOLYe2+Zyv3I3bz4X8Xw+MsBF6sMWLLf\n"
-        + "9ieu4Wz/5ScVu0PxY36kgV0AQRiLXk802Vk4t9jElCp9qx/dDln7f3879LLb3wNt\n"
-        + "fajne8EH0hjR4E3joPoG+IXSvSzWcPoZTmAZOKHPcRS8iqy0Ao8/UuQWYCedI/4R\n"
-        + "S1IJaByk8mmkMkqqV0kuPyDkvGpqhfh9zFYh97LuKcJktRTEBp3YMvuGcBDBwofG\n"
-        + "vYIVEMr7Ci5rowRQO/sxJfI1zNSWterWC46v6tOb9IvenOgP0/dQxlU82YkBIAQQ\n"
-        + "AQIACgUCTH5xmAMFAXgACgkQ0CLaOl6a7dCYuQf/V2i3Ih5Dqze0Rz5zoTD56/J7\n"
-        + "0SA4/SFm5eDUirY5B9BohkyxoMVG04uyjUmVs62ree7N0IASmeiF/wkBUZ/r/rr/\n"
-        + "0ntGj43y+1JpuSEohZOfgZJryDKRqyVWhRbeBj0g/SzxIQ1lEt2iHFvdSlfFVd+a\n"
-        + "SH1uDDjT/ZATKfAXcgeajUirWorJRaldue7O4oFe67fMLy36ewvpaMVZ+SpxH4CC\n"
-        + "Owq4Ls3dIAg2C5GQK8G0G7FwT1M26EPg66C79EGYkaxprgrilWE6l7QHc484TY1L\n"
-        + "ys04qKoPRnBinmrRxgRyyimvDN/+nd1jdM6nMe1gVLL3s5Vgo0fJMwNhDZMtdrkB\n"
-        + "DQRMfmklAQgAyajPVMt+OXO1ow7xzb0aZYNa5Xdv+w50JzVeWI0boPOuOmq6RCc1\n"
-        + "3NhOmBzx3CKH6zbSRoLBCZWM3cs1EQbl+8noaxq6YQVWiaROX8U7CThYA50jONP/\n"
-        + "qEk655QFsP8Bq96Z5AT/MflxEMayOtQywUFREF4/olhXvJOdurZfQPGnIis35NUc\n"
-        + "IaubI+gGVsluqWBohLOgqzyF7GMlv+Y2JZE5JKGSTO7ZosyI+OCNdZ6X2CJdDPZ1\n"
-        + "325QHYkmqiMJtb73AYTXurL7NNTxdxQVOnfvwXXW4mgHwPEHr8PU30+2xgo1ktrr\n"
-        + "rpFsd0o2UFhybTe7w1z2sAO1gP5s1bbGlwARAQABiQEfBBgBAgAJBQJMfmklAhsM\n"
-        + "AAoJEGXEM8QIDlcjqkQIAI78nwAgO5EgrUDoFikH6d36Kie9SHleaYcSX2c95Vqc\n"
-        + "umuiSAhaulGX0gM/jwvZkoawSyWIq+O2sPSc9F7VzdYdEnWVj2J5BpVx83TRPrTu\n"
-        + "72tsJ97op6JZz+Q8HwTLYJBmyW3/TEKh+iRL9CBtfTVywodZa58j41vCkx37NFPw\n"
-        + "plglT/Se1/US1rWYTH3Kfqo5zNARLUYzAdcxEpjwXWOvqnybn86KfMwqiOunz8eq\n"
-        + "MnTQYECfUrhX2WrbEAjCSc6/LfrTv/S+cO0rvulO/R97gG99pZdWSUjZypU5KLbp\n"
-        + "MBh0qq2wQxO2iagNXE6ms3kV/XihvCpXo9RArmldmW0=\n"
-        + "=lddL\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aSUBCADzpZ1h9awUQR1ChzrMhtoE1ltyTlJpS1G5HFEov9QNxVDTjpB8\n"
+            + "PMdb20NNdk/7g6E+ETpCBJGPoC4/TPFDiqe+UI7cRrRZJVbInkCbflYycLTUt9qW\n"
+            + "5c7IuyZA1+cSYaKp3jYccFZfIWvfTWDLWyUozTs9t1TsI28s3r5fBPvrZ+F1nYv/\n"
+            + "xpSkx3Zsxnn7QJTnd63rZdp0RdfJmF2rXERwR6XVtuLj5CqrFoLxy6OrSOl4am4J\n"
+            + "C2HRWhskB21LpdRtloY8bz0DOn6W6JUFRmSxQ1kbPClOXaiNhzMI0fD/KFnHImgR\n"
+            + "IKbbQyHHsKHBjNyn+zTIm5zUL6JMZMf9PoSZABEBAAG0HlRlc3R1c2VyIEggPHRl\n"
+            + "c3RoQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pJQIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQZcQzxAgOVyORcAf/QaHVlyhlBnU4edujW2uG/PFrZvwK\n"
+            + "fqOKW0QqQ7kVN8okKhnFv4y11IwLIzL9mOLYe2+Zyv3I3bz4X8Xw+MsBF6sMWLLf\n"
+            + "9ieu4Wz/5ScVu0PxY36kgV0AQRiLXk802Vk4t9jElCp9qx/dDln7f3879LLb3wNt\n"
+            + "fajne8EH0hjR4E3joPoG+IXSvSzWcPoZTmAZOKHPcRS8iqy0Ao8/UuQWYCedI/4R\n"
+            + "S1IJaByk8mmkMkqqV0kuPyDkvGpqhfh9zFYh97LuKcJktRTEBp3YMvuGcBDBwofG\n"
+            + "vYIVEMr7Ci5rowRQO/sxJfI1zNSWterWC46v6tOb9IvenOgP0/dQxlU82YkBIAQQ\n"
+            + "AQIACgUCTH5xmAMFAXgACgkQ0CLaOl6a7dCYuQf/V2i3Ih5Dqze0Rz5zoTD56/J7\n"
+            + "0SA4/SFm5eDUirY5B9BohkyxoMVG04uyjUmVs62ree7N0IASmeiF/wkBUZ/r/rr/\n"
+            + "0ntGj43y+1JpuSEohZOfgZJryDKRqyVWhRbeBj0g/SzxIQ1lEt2iHFvdSlfFVd+a\n"
+            + "SH1uDDjT/ZATKfAXcgeajUirWorJRaldue7O4oFe67fMLy36ewvpaMVZ+SpxH4CC\n"
+            + "Owq4Ls3dIAg2C5GQK8G0G7FwT1M26EPg66C79EGYkaxprgrilWE6l7QHc484TY1L\n"
+            + "ys04qKoPRnBinmrRxgRyyimvDN/+nd1jdM6nMe1gVLL3s5Vgo0fJMwNhDZMtdrkB\n"
+            + "DQRMfmklAQgAyajPVMt+OXO1ow7xzb0aZYNa5Xdv+w50JzVeWI0boPOuOmq6RCc1\n"
+            + "3NhOmBzx3CKH6zbSRoLBCZWM3cs1EQbl+8noaxq6YQVWiaROX8U7CThYA50jONP/\n"
+            + "qEk655QFsP8Bq96Z5AT/MflxEMayOtQywUFREF4/olhXvJOdurZfQPGnIis35NUc\n"
+            + "IaubI+gGVsluqWBohLOgqzyF7GMlv+Y2JZE5JKGSTO7ZosyI+OCNdZ6X2CJdDPZ1\n"
+            + "325QHYkmqiMJtb73AYTXurL7NNTxdxQVOnfvwXXW4mgHwPEHr8PU30+2xgo1ktrr\n"
+            + "rpFsd0o2UFhybTe7w1z2sAO1gP5s1bbGlwARAQABiQEfBBgBAgAJBQJMfmklAhsM\n"
+            + "AAoJEGXEM8QIDlcjqkQIAI78nwAgO5EgrUDoFikH6d36Kie9SHleaYcSX2c95Vqc\n"
+            + "umuiSAhaulGX0gM/jwvZkoawSyWIq+O2sPSc9F7VzdYdEnWVj2J5BpVx83TRPrTu\n"
+            + "72tsJ97op6JZz+Q8HwTLYJBmyW3/TEKh+iRL9CBtfTVywodZa58j41vCkx37NFPw\n"
+            + "plglT/Se1/US1rWYTH3Kfqo5zNARLUYzAdcxEpjwXWOvqnybn86KfMwqiOunz8eq\n"
+            + "MnTQYECfUrhX2WrbEAjCSc6/LfrTv/S+cO0rvulO/R97gG99pZdWSUjZypU5KLbp\n"
+            + "MBh0qq2wQxO2iagNXE6ms3kV/XihvCpXo9RArmldmW0=\n"
+            + "=lddL\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx+aSUBCADzpZ1h9awUQR1ChzrMhtoE1ltyTlJpS1G5HFEov9QNxVDTjpB8\n"
-        + "PMdb20NNdk/7g6E+ETpCBJGPoC4/TPFDiqe+UI7cRrRZJVbInkCbflYycLTUt9qW\n"
-        + "5c7IuyZA1+cSYaKp3jYccFZfIWvfTWDLWyUozTs9t1TsI28s3r5fBPvrZ+F1nYv/\n"
-        + "xpSkx3Zsxnn7QJTnd63rZdp0RdfJmF2rXERwR6XVtuLj5CqrFoLxy6OrSOl4am4J\n"
-        + "C2HRWhskB21LpdRtloY8bz0DOn6W6JUFRmSxQ1kbPClOXaiNhzMI0fD/KFnHImgR\n"
-        + "IKbbQyHHsKHBjNyn+zTIm5zUL6JMZMf9PoSZABEBAAEAB/wPPOigp4d9VcwxbLkz\n"
-        + "8OwiONDLz5OuY6hHCjsWMBcgTFqffI9TQc7bExW8ur1KVuNm+RdaaSQ8ZhF2YobF\n"
-        + "SV7v02R36NEfMStiDSmvv+E+stdQZXY9kT5TRgcgr5ATUXllo9DhCvKP7Qxs0Q9Q\n"
-        + "cJEcoedGVxiv0xCBLyYbVbm2sW+GJYjq0R5loaOy/Swbt5vOKQsajU8iyA4czSE8\n"
-        + "Ryr63OtwZ1TZsxekj//HKcngnptYY/FT5TPe4uzw8g1tJTIg/OZXrm8CahWzpfE3\n"
-        + "q8lGafhd0GjLftA9ffIHF0cAUs7HklMrgIKGdVPXfQmPzqDpmH5FO2y6QmqTG0v6\n"
-        + "JYW9BAD4Iobwh80MT3JZhJ0jGYMdi07cRyFN+hRwVKgNcBTdx3QGpGJatcyumD0C\n"
-        + "Yn/aXAn+XUkewSgYhdj9sSRodnWGoavdWELxUQkktsdiFg2/rnqmpqRXTGfR/tDh\n"
-        + "ohD2JaPrsavmUF6ShT3stGp8nUN+n6Bhd+QosaCZm5TC1CtA7QQA+16rrNNdP8XN\n"
-        + "MvpQRqJM5ljH0haqR/yD8vdCCZjk23hBk3YsXwSrhSbPzMeZC2FcDqkQTraTxrSG\n"
-        + "U0+xK3NjKKtbzCjQFH4cy4zdNMUX04OWopLGOEnnvTYukGtXT4lZQ9qm8ZBPh5a4\n"
-        + "cXfWy3ovjvRbxUuFOWm0gOfIoRcuWN0D/isTjqPmjihCuWkKTfa3xoq+dD7ynYhg\n"
-        + "Yu3UKfCqbNVor59ZrB4AkQiaVIDLKim3E1XDMS+IukmTuNVXpJeqK32tAYbEduHM\n"
-        + "7kwEq7SgVh34QvryKjCC/EUkDcjSQ+xlUaKl8QKYOdwtH97zZYK6QixB4uNQ6CuM\n"
-        + "75dqTZ6iQw7jQA+0HlRlc3R1c2VyIEggPHRlc3RoQGV4YW1wbGUuY29tPokBOAQT\n"
-        + "AQIAIgUCTH5pJQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQZcQzxAgO\n"
-        + "VyORcAf/QaHVlyhlBnU4edujW2uG/PFrZvwKfqOKW0QqQ7kVN8okKhnFv4y11IwL\n"
-        + "IzL9mOLYe2+Zyv3I3bz4X8Xw+MsBF6sMWLLf9ieu4Wz/5ScVu0PxY36kgV0AQRiL\n"
-        + "Xk802Vk4t9jElCp9qx/dDln7f3879LLb3wNtfajne8EH0hjR4E3joPoG+IXSvSzW\n"
-        + "cPoZTmAZOKHPcRS8iqy0Ao8/UuQWYCedI/4RS1IJaByk8mmkMkqqV0kuPyDkvGpq\n"
-        + "hfh9zFYh97LuKcJktRTEBp3YMvuGcBDBwofGvYIVEMr7Ci5rowRQO/sxJfI1zNSW\n"
-        + "terWC46v6tOb9IvenOgP0/dQxlU82Z0DmARMfmklAQgAyajPVMt+OXO1ow7xzb0a\n"
-        + "ZYNa5Xdv+w50JzVeWI0boPOuOmq6RCc13NhOmBzx3CKH6zbSRoLBCZWM3cs1EQbl\n"
-        + "+8noaxq6YQVWiaROX8U7CThYA50jONP/qEk655QFsP8Bq96Z5AT/MflxEMayOtQy\n"
-        + "wUFREF4/olhXvJOdurZfQPGnIis35NUcIaubI+gGVsluqWBohLOgqzyF7GMlv+Y2\n"
-        + "JZE5JKGSTO7ZosyI+OCNdZ6X2CJdDPZ1325QHYkmqiMJtb73AYTXurL7NNTxdxQV\n"
-        + "OnfvwXXW4mgHwPEHr8PU30+2xgo1ktrrrpFsd0o2UFhybTe7w1z2sAO1gP5s1bbG\n"
-        + "lwARAQABAAf8C3vFcrqz0Wm5ajOrqV+fZTB5uJ94jP9htengGYLPk/bMcR8qxD7H\n"
-        + "XnAi6Z6cV0DQJKDWkJVZkMYnY2ny96lA53mz9oVrH6NCLkxg+istFXVT7cDBBLdt\n"
-        + "05N3+z/+ovmiirr+YHG4Zowh2Ca4d4kl6sNhbmEvlnsZY++0B7Hi8ru2KgFBag2g\n"
-        + "wDmeVt2+ANJNfJ4uIHUEG+sDSDL4+rxQlBTMhxfVY5+zjbvzPlTf2jyAgDa5zGN2\n"
-        + "vRjB33Z0lbdZTeW7HsJcDsXaS77lKnQeWMmHSvpOXvFSIjnrWpxcMpg8hGY5e5UC\n"
-        + "zLCk+nucY/Od1NbtFYu/e7fl9/n3YnT7AQQA0v/t43Ut3go9vRlb47NN/KpJYL1N\n"
-        + "hh9F/SRzFwWxS+79CiZkf/bgmdJe4XkkS7QJMv+nXhtcko/gfzoaCrvIWIAyvhYa\n"
-        + "7tEbqH+iZ0eaLrQf7bu89Jmp2UNRT1EHLzm38eJ8gg7eNu+SjIhs3wART1KB7GvT\n"
-        + "YmpN5caJA2t2OaEEAPSq7CbvlPDc0qomQSs+NrDnhAv89mQEeksZRmhVa0o4Z7EO\n"
-        + "84DzM+Vxho5fn9h0LtxthhuKWKT8uYN/Qu4Y42cKQuRgMx09+GGwc4GWSC6gJPeP\n"
-        + "oKVJCdZx0l9u8fWQb37gnyH34WDxPvdQx3e4iw/dvruNzu17zmPndkdcyEU3BACD\n"
-        + "yXo21SEflFcfrO16VsITXWc9yweKTSD8Mq7wg2GG6eJPopgtwCLZSlYjnehxD2w2\n"
-        + "38lyr6jGPyITvalVwH6R//676Q2osbQ948Dv2ZcxaTlyla4RyY6E33hsnV9m8ZmM\n"
-        + "PUoNJvFSkKCuPy1N5zaYgUAPKwbEkc3qG+bZm+x2WU2biQEfBBgBAgAJBQJMfmkl\n"
-        + "AhsMAAoJEGXEM8QIDlcjqkQIAI78nwAgO5EgrUDoFikH6d36Kie9SHleaYcSX2c9\n"
-        + "5VqcumuiSAhaulGX0gM/jwvZkoawSyWIq+O2sPSc9F7VzdYdEnWVj2J5BpVx83TR\n"
-        + "PrTu72tsJ97op6JZz+Q8HwTLYJBmyW3/TEKh+iRL9CBtfTVywodZa58j41vCkx37\n"
-        + "NFPwplglT/Se1/US1rWYTH3Kfqo5zNARLUYzAdcxEpjwXWOvqnybn86KfMwqiOun\n"
-        + "z8eqMnTQYECfUrhX2WrbEAjCSc6/LfrTv/S+cO0rvulO/R97gG99pZdWSUjZypU5\n"
-        + "KLbpMBh0qq2wQxO2iagNXE6ms3kV/XihvCpXo9RArmldmW0=\n"
-        + "=voB9\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx+aSUBCADzpZ1h9awUQR1ChzrMhtoE1ltyTlJpS1G5HFEov9QNxVDTjpB8\n"
+            + "PMdb20NNdk/7g6E+ETpCBJGPoC4/TPFDiqe+UI7cRrRZJVbInkCbflYycLTUt9qW\n"
+            + "5c7IuyZA1+cSYaKp3jYccFZfIWvfTWDLWyUozTs9t1TsI28s3r5fBPvrZ+F1nYv/\n"
+            + "xpSkx3Zsxnn7QJTnd63rZdp0RdfJmF2rXERwR6XVtuLj5CqrFoLxy6OrSOl4am4J\n"
+            + "C2HRWhskB21LpdRtloY8bz0DOn6W6JUFRmSxQ1kbPClOXaiNhzMI0fD/KFnHImgR\n"
+            + "IKbbQyHHsKHBjNyn+zTIm5zUL6JMZMf9PoSZABEBAAEAB/wPPOigp4d9VcwxbLkz\n"
+            + "8OwiONDLz5OuY6hHCjsWMBcgTFqffI9TQc7bExW8ur1KVuNm+RdaaSQ8ZhF2YobF\n"
+            + "SV7v02R36NEfMStiDSmvv+E+stdQZXY9kT5TRgcgr5ATUXllo9DhCvKP7Qxs0Q9Q\n"
+            + "cJEcoedGVxiv0xCBLyYbVbm2sW+GJYjq0R5loaOy/Swbt5vOKQsajU8iyA4czSE8\n"
+            + "Ryr63OtwZ1TZsxekj//HKcngnptYY/FT5TPe4uzw8g1tJTIg/OZXrm8CahWzpfE3\n"
+            + "q8lGafhd0GjLftA9ffIHF0cAUs7HklMrgIKGdVPXfQmPzqDpmH5FO2y6QmqTG0v6\n"
+            + "JYW9BAD4Iobwh80MT3JZhJ0jGYMdi07cRyFN+hRwVKgNcBTdx3QGpGJatcyumD0C\n"
+            + "Yn/aXAn+XUkewSgYhdj9sSRodnWGoavdWELxUQkktsdiFg2/rnqmpqRXTGfR/tDh\n"
+            + "ohD2JaPrsavmUF6ShT3stGp8nUN+n6Bhd+QosaCZm5TC1CtA7QQA+16rrNNdP8XN\n"
+            + "MvpQRqJM5ljH0haqR/yD8vdCCZjk23hBk3YsXwSrhSbPzMeZC2FcDqkQTraTxrSG\n"
+            + "U0+xK3NjKKtbzCjQFH4cy4zdNMUX04OWopLGOEnnvTYukGtXT4lZQ9qm8ZBPh5a4\n"
+            + "cXfWy3ovjvRbxUuFOWm0gOfIoRcuWN0D/isTjqPmjihCuWkKTfa3xoq+dD7ynYhg\n"
+            + "Yu3UKfCqbNVor59ZrB4AkQiaVIDLKim3E1XDMS+IukmTuNVXpJeqK32tAYbEduHM\n"
+            + "7kwEq7SgVh34QvryKjCC/EUkDcjSQ+xlUaKl8QKYOdwtH97zZYK6QixB4uNQ6CuM\n"
+            + "75dqTZ6iQw7jQA+0HlRlc3R1c2VyIEggPHRlc3RoQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTH5pJQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQZcQzxAgO\n"
+            + "VyORcAf/QaHVlyhlBnU4edujW2uG/PFrZvwKfqOKW0QqQ7kVN8okKhnFv4y11IwL\n"
+            + "IzL9mOLYe2+Zyv3I3bz4X8Xw+MsBF6sMWLLf9ieu4Wz/5ScVu0PxY36kgV0AQRiL\n"
+            + "Xk802Vk4t9jElCp9qx/dDln7f3879LLb3wNtfajne8EH0hjR4E3joPoG+IXSvSzW\n"
+            + "cPoZTmAZOKHPcRS8iqy0Ao8/UuQWYCedI/4RS1IJaByk8mmkMkqqV0kuPyDkvGpq\n"
+            + "hfh9zFYh97LuKcJktRTEBp3YMvuGcBDBwofGvYIVEMr7Ci5rowRQO/sxJfI1zNSW\n"
+            + "terWC46v6tOb9IvenOgP0/dQxlU82Z0DmARMfmklAQgAyajPVMt+OXO1ow7xzb0a\n"
+            + "ZYNa5Xdv+w50JzVeWI0boPOuOmq6RCc13NhOmBzx3CKH6zbSRoLBCZWM3cs1EQbl\n"
+            + "+8noaxq6YQVWiaROX8U7CThYA50jONP/qEk655QFsP8Bq96Z5AT/MflxEMayOtQy\n"
+            + "wUFREF4/olhXvJOdurZfQPGnIis35NUcIaubI+gGVsluqWBohLOgqzyF7GMlv+Y2\n"
+            + "JZE5JKGSTO7ZosyI+OCNdZ6X2CJdDPZ1325QHYkmqiMJtb73AYTXurL7NNTxdxQV\n"
+            + "OnfvwXXW4mgHwPEHr8PU30+2xgo1ktrrrpFsd0o2UFhybTe7w1z2sAO1gP5s1bbG\n"
+            + "lwARAQABAAf8C3vFcrqz0Wm5ajOrqV+fZTB5uJ94jP9htengGYLPk/bMcR8qxD7H\n"
+            + "XnAi6Z6cV0DQJKDWkJVZkMYnY2ny96lA53mz9oVrH6NCLkxg+istFXVT7cDBBLdt\n"
+            + "05N3+z/+ovmiirr+YHG4Zowh2Ca4d4kl6sNhbmEvlnsZY++0B7Hi8ru2KgFBag2g\n"
+            + "wDmeVt2+ANJNfJ4uIHUEG+sDSDL4+rxQlBTMhxfVY5+zjbvzPlTf2jyAgDa5zGN2\n"
+            + "vRjB33Z0lbdZTeW7HsJcDsXaS77lKnQeWMmHSvpOXvFSIjnrWpxcMpg8hGY5e5UC\n"
+            + "zLCk+nucY/Od1NbtFYu/e7fl9/n3YnT7AQQA0v/t43Ut3go9vRlb47NN/KpJYL1N\n"
+            + "hh9F/SRzFwWxS+79CiZkf/bgmdJe4XkkS7QJMv+nXhtcko/gfzoaCrvIWIAyvhYa\n"
+            + "7tEbqH+iZ0eaLrQf7bu89Jmp2UNRT1EHLzm38eJ8gg7eNu+SjIhs3wART1KB7GvT\n"
+            + "YmpN5caJA2t2OaEEAPSq7CbvlPDc0qomQSs+NrDnhAv89mQEeksZRmhVa0o4Z7EO\n"
+            + "84DzM+Vxho5fn9h0LtxthhuKWKT8uYN/Qu4Y42cKQuRgMx09+GGwc4GWSC6gJPeP\n"
+            + "oKVJCdZx0l9u8fWQb37gnyH34WDxPvdQx3e4iw/dvruNzu17zmPndkdcyEU3BACD\n"
+            + "yXo21SEflFcfrO16VsITXWc9yweKTSD8Mq7wg2GG6eJPopgtwCLZSlYjnehxD2w2\n"
+            + "38lyr6jGPyITvalVwH6R//676Q2osbQ948Dv2ZcxaTlyla4RyY6E33hsnV9m8ZmM\n"
+            + "PUoNJvFSkKCuPy1N5zaYgUAPKwbEkc3qG+bZm+x2WU2biQEfBBgBAgAJBQJMfmkl\n"
+            + "AhsMAAoJEGXEM8QIDlcjqkQIAI78nwAgO5EgrUDoFikH6d36Kie9SHleaYcSX2c9\n"
+            + "5VqcumuiSAhaulGX0gM/jwvZkoawSyWIq+O2sPSc9F7VzdYdEnWVj2J5BpVx83TR\n"
+            + "PrTu72tsJ97op6JZz+Q8HwTLYJBmyW3/TEKh+iRL9CBtfTVywodZa58j41vCkx37\n"
+            + "NFPwplglT/Se1/US1rWYTH3Kfqo5zNARLUYzAdcxEpjwXWOvqnybn86KfMwqiOun\n"
+            + "z8eqMnTQYECfUrhX2WrbEAjCSc6/LfrTv/S+cO0rvulO/R97gG99pZdWSUjZypU5\n"
+            + "KLbpMBh0qq2wQxO2iagNXE6ms3kV/XihvCpXo9RArmldmW0=\n"
+            + "=voB9\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/5E9AEDD0 2010-09-01
-   *       Key fingerprint = 818D 5D0B 4AE2 A4FE A4C3  C44D D022 DA3A 5E9A EDD0
-   * uid                  Testuser I &lt;testi@example.com&gt;
-   * sub   2048R/0884E452 2010-09-01
+   * pub 2048R/5E9AEDD0 2010-09-01 Key fingerprint = 818D 5D0B 4AE2 A4FE A4C3 C44D D022 DA3A 5E9A
+   * EDD0 uid Testuser I &lt;testi@example.com&gt; sub 2048R/0884E452 2010-09-01
    */
   public static TestKey keyI() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx+aTEBCAC6dFperkew4ZowIfEyAjScjPBggcbw5XUXxLCF0nBRjWH+HvuI\n"
-        + "CGwznRyeuTiy5yyB9/CcvTLTkEs8qIyJUJoikm7QpaVVL6imVq1HD1xcOJpV1FV1\n"
-        + "eFu562xCRDUqD6KQf54N04V9TMDyubhPkQYbx1H2gq+uBEo9d1w6AsSMgaUn3xH/\n"
-        + "xYe+INxcP6jFT2OKc36x+8ipP6pc8Hba1X90JwadOcJlwEyJfJKs7hYHTaYn+I6+\n"
-        + "4w0Y//WebhT4ocsYIiOYrENQUcic+vL3fkwwJCloyDBCGxr7w7Gn4Pe3peTCl4Sp\n"
-        + "vIIoYnHPW4h3Nyh8qAlBNDw7dCPS9LP7wRdNABEBAAG0HlRlc3R1c2VyIEkgPHRl\n"
-        + "c3RpQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pMQIbAwYLCQgHAwIGFQgCCQoL\n"
-        + "BBYCAwECHgECF4AACgkQ0CLaOl6a7dAjNQf/fLmGeKgaesawP53UeioQ8hgDEFKP\n"
-        + "BddNQP248NpReZ1rg3h8Q21PQJVKrtDYn94WJi5NTqUtk9rtx9SiqKaEc3wzIpLc\n"
-        + "nIYrgGLWot5nq+5V1nY9t9QAiJJDrmm2/3tX+jTWW6CpuLih7IsD+gJmpZkY6PfM\n"
-        + "T+teKEeh5E1XBbu10fwDwMJta+043/TiljInjER1f/b41EnSjI6YXFnxnyiLeDgD\n"
-        + "A1QIIzB/W2ccGqphzJriDETDJhKFZIeqvjylZofgCLyMRSyZtsu+b4pfBK3hMpu5\n"
-        + "aaYylaM1BWOpAiqUmGUKqxN/o9EGx4wvsMxK6xgiZe5UdQPaoDcFCsEMg4kBIAQQ\n"
-        + "AQIACgUCTH5xrAMFAXgACgkQoTk8RsLmoZiu2Af8D4PnyWkosYYkcmU4T7CvIHGW\n"
-        + "Qnx4KsnYWaAqYrYrorL6R+f8SZ5caGwj05UOvHnqx/Ij0a1Zv4MpEuzB0se1XkyQ\n"
-        + "eCLdAIKVodfiepsCHyqW6/mc9LV2qKS1HF5x5LwDkI1atOuPt/O14fch4E0beTbl\n"
-        + "FXzGo7YdpH8RunV8l+i3FxxTcUtUkij3Ro4EMwVF/6YG8gBOd08GxWspEQWBH3GK\n"
-        + "k7Repj4IPwXCoEfU1H+XJNPaM5cnt+L87QfbhNOWmHmWhhrOmZg160joODON8w8x\n"
-        + "j3gma9Cp6luPDEQC3XnsEup3BdCdIciG5JS6JA/2GDeulg+eS4x9Xkmmp6nzObkB\n"
-        + "DQRMfmkxAQgAxeT+bUBbADga+lYtkmtYVbuG7uWjwdg9TR6qWKD7n37mcu6OgNNl\n"
-        + "rPaHoClvOL20fcArZ8wT/FbjvDI6ZHn22YA19OvAR+Eqmf3D7qTmebchnCu955Pk\n"
-        + "X7AOOpKfX48qoYq8BoskZDnbFidm5YKfIin3CNDdlQbd3na+ihGCuv0KoGzefuAH\n"
-        + "cITeYEUESh7HLzQ9/pMES9eCgdTEkwYD5NJjfkLnj2kZtDsSiNnENZ0TIlyKOBMn\n"
-        + "ixgsARDjLrkqyTg79thWALiqVBXUKn2NBtMkK5xTDc/7q3nIw4InYMIrLtntSu1w\n"
-        + "pn1gXbdg1HFl5BgqEB9Fp0k02YvrSiiVswARAQABiQEfBBgBAgAJBQJMfmkxAhsM\n"
-        + "AAoJENAi2jpemu3QFPoH/1ynX1j1QWL8TfJFPoB3vXivwGURs3J7LsywHTRjpQVQ\n"
-        + "vxQvKTzB1+woUxtEbdjKgMbvY/ShHSlEZKVV9l3ZihrNewHA1GMHrDtBGXcNRP9B\n"
-        + "RfJHTrDzjUxrEEwu4QIq71o4tS89NvQmlYYi7O4ThtVB4hYSwl436+vAT9ybIQkU\n"
-        + "OjCkYrKye6JHs1K4BnVuWcOVujQwW4H8QFbddcWF49uN6DSqrwDFsjFog6wL7u6V\n"
-        + "UL5upRBP/RZWA4HKJVF2tS0Ptr6xLTmf4Tp5n10CGFYkPcRp9biVyeVRJBW4uZf0\n"
-        + "EDsn9J5rNG0pWtgnhAEi6smoT4fADTOzpOovUiTSQhQ=\n"
-        + "=SiG3\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aTEBCAC6dFperkew4ZowIfEyAjScjPBggcbw5XUXxLCF0nBRjWH+HvuI\n"
+            + "CGwznRyeuTiy5yyB9/CcvTLTkEs8qIyJUJoikm7QpaVVL6imVq1HD1xcOJpV1FV1\n"
+            + "eFu562xCRDUqD6KQf54N04V9TMDyubhPkQYbx1H2gq+uBEo9d1w6AsSMgaUn3xH/\n"
+            + "xYe+INxcP6jFT2OKc36x+8ipP6pc8Hba1X90JwadOcJlwEyJfJKs7hYHTaYn+I6+\n"
+            + "4w0Y//WebhT4ocsYIiOYrENQUcic+vL3fkwwJCloyDBCGxr7w7Gn4Pe3peTCl4Sp\n"
+            + "vIIoYnHPW4h3Nyh8qAlBNDw7dCPS9LP7wRdNABEBAAG0HlRlc3R1c2VyIEkgPHRl\n"
+            + "c3RpQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pMQIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQ0CLaOl6a7dAjNQf/fLmGeKgaesawP53UeioQ8hgDEFKP\n"
+            + "BddNQP248NpReZ1rg3h8Q21PQJVKrtDYn94WJi5NTqUtk9rtx9SiqKaEc3wzIpLc\n"
+            + "nIYrgGLWot5nq+5V1nY9t9QAiJJDrmm2/3tX+jTWW6CpuLih7IsD+gJmpZkY6PfM\n"
+            + "T+teKEeh5E1XBbu10fwDwMJta+043/TiljInjER1f/b41EnSjI6YXFnxnyiLeDgD\n"
+            + "A1QIIzB/W2ccGqphzJriDETDJhKFZIeqvjylZofgCLyMRSyZtsu+b4pfBK3hMpu5\n"
+            + "aaYylaM1BWOpAiqUmGUKqxN/o9EGx4wvsMxK6xgiZe5UdQPaoDcFCsEMg4kBIAQQ\n"
+            + "AQIACgUCTH5xrAMFAXgACgkQoTk8RsLmoZiu2Af8D4PnyWkosYYkcmU4T7CvIHGW\n"
+            + "Qnx4KsnYWaAqYrYrorL6R+f8SZ5caGwj05UOvHnqx/Ij0a1Zv4MpEuzB0se1XkyQ\n"
+            + "eCLdAIKVodfiepsCHyqW6/mc9LV2qKS1HF5x5LwDkI1atOuPt/O14fch4E0beTbl\n"
+            + "FXzGo7YdpH8RunV8l+i3FxxTcUtUkij3Ro4EMwVF/6YG8gBOd08GxWspEQWBH3GK\n"
+            + "k7Repj4IPwXCoEfU1H+XJNPaM5cnt+L87QfbhNOWmHmWhhrOmZg160joODON8w8x\n"
+            + "j3gma9Cp6luPDEQC3XnsEup3BdCdIciG5JS6JA/2GDeulg+eS4x9Xkmmp6nzObkB\n"
+            + "DQRMfmkxAQgAxeT+bUBbADga+lYtkmtYVbuG7uWjwdg9TR6qWKD7n37mcu6OgNNl\n"
+            + "rPaHoClvOL20fcArZ8wT/FbjvDI6ZHn22YA19OvAR+Eqmf3D7qTmebchnCu955Pk\n"
+            + "X7AOOpKfX48qoYq8BoskZDnbFidm5YKfIin3CNDdlQbd3na+ihGCuv0KoGzefuAH\n"
+            + "cITeYEUESh7HLzQ9/pMES9eCgdTEkwYD5NJjfkLnj2kZtDsSiNnENZ0TIlyKOBMn\n"
+            + "ixgsARDjLrkqyTg79thWALiqVBXUKn2NBtMkK5xTDc/7q3nIw4InYMIrLtntSu1w\n"
+            + "pn1gXbdg1HFl5BgqEB9Fp0k02YvrSiiVswARAQABiQEfBBgBAgAJBQJMfmkxAhsM\n"
+            + "AAoJENAi2jpemu3QFPoH/1ynX1j1QWL8TfJFPoB3vXivwGURs3J7LsywHTRjpQVQ\n"
+            + "vxQvKTzB1+woUxtEbdjKgMbvY/ShHSlEZKVV9l3ZihrNewHA1GMHrDtBGXcNRP9B\n"
+            + "RfJHTrDzjUxrEEwu4QIq71o4tS89NvQmlYYi7O4ThtVB4hYSwl436+vAT9ybIQkU\n"
+            + "OjCkYrKye6JHs1K4BnVuWcOVujQwW4H8QFbddcWF49uN6DSqrwDFsjFog6wL7u6V\n"
+            + "UL5upRBP/RZWA4HKJVF2tS0Ptr6xLTmf4Tp5n10CGFYkPcRp9biVyeVRJBW4uZf0\n"
+            + "EDsn9J5rNG0pWtgnhAEi6smoT4fADTOzpOovUiTSQhQ=\n"
+            + "=SiG3\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx+aTEBCAC6dFperkew4ZowIfEyAjScjPBggcbw5XUXxLCF0nBRjWH+HvuI\n"
-        + "CGwznRyeuTiy5yyB9/CcvTLTkEs8qIyJUJoikm7QpaVVL6imVq1HD1xcOJpV1FV1\n"
-        + "eFu562xCRDUqD6KQf54N04V9TMDyubhPkQYbx1H2gq+uBEo9d1w6AsSMgaUn3xH/\n"
-        + "xYe+INxcP6jFT2OKc36x+8ipP6pc8Hba1X90JwadOcJlwEyJfJKs7hYHTaYn+I6+\n"
-        + "4w0Y//WebhT4ocsYIiOYrENQUcic+vL3fkwwJCloyDBCGxr7w7Gn4Pe3peTCl4Sp\n"
-        + "vIIoYnHPW4h3Nyh8qAlBNDw7dCPS9LP7wRdNABEBAAEAB/oCD6EKLvjXgItlqdm/\n"
-        + "X+OWMYHDCtuRCMW7+2gEw/TxfLeGJaOHWxAouwUIArEEb/hjdaRfIg4wdJUxmyPX\n"
-        + "WyNqUdupkjdXNa7RNaesIi0ilrdZOn7NlHWJCCXwKt2R0jd2p8PDED6CWaE1+76I\n"
-        + "/IuwOHDTD8MABke3KvHDXMxjzdeuRbm670Aqz6zTVY+BZG1GH63Ef5JEyezMgAU5\n"
-        + "42+v+OgD0W0/jCxF7jt2ddP9QiOzu0q65mI4qlOuSebxjH8P7ye0LU9EuWVgAcwc\n"
-        + "YJh2lk3eH8bCWTwlIHj4+8MYgY5i510I5xfY3sWuylw/qtFP9vYjisrysadcUExc\n"
-        + "QUxFBADXQSCmvtgRoSLiGfQv2y2qInx67eJw8pUXFEIJKdOFOhX4vogT9qPWQAms\n"
-        + "/vSshcsAPgpZJZ8MNeGpMGLAGm8y4D2zWWd9YLNmVXsPu7EyrDpXlKHCFnsQfOGN\n"
-        + "c5j8u4CHBn1cS/Yk53S+6Yge2jvnOjVNFmxB0ocs0Y5zbdTJYwQA3b+hQebH7NNr\n"
-        + "FlPwthRZS0TiX5+qkE9tE/0mpRrUN3iS9bnF0IXRmHFp7Hz+EsVbA2Re2A5HIHnQ\n"
-        + "/BSpAsSHRhjU3MH4gzwfg9W43eZGVfofSY6IlUCIcd1bGjSAjJgmfhjU7ofS59i/\n"
-        + "DjzP1jBfXdjOEUQULTkXjHPqO7j4048D/jqMwZNY3AawTMjqKr9nGK49aWv/OVdy\n"
-        + "6xGn4dRJNk3gnnIvjAEFy5+HHbUCJ2lA3X2AssQ9tvbuyDnoSL5/G+zEYtyRuAC5\n"
-        + "9TLQQRmy4qjsYC5TwfoUwFbgqRsmGUcjj2wtE+gb1S8P/zudYrEqOD3K60Y5qXcn\n"
-        + "S3PHgJ++5TzFQba0HlRlc3R1c2VyIEkgPHRlc3RpQGV4YW1wbGUuY29tPokBOAQT\n"
-        + "AQIAIgUCTH5pMQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ0CLaOl6a\n"
-        + "7dAjNQf/fLmGeKgaesawP53UeioQ8hgDEFKPBddNQP248NpReZ1rg3h8Q21PQJVK\n"
-        + "rtDYn94WJi5NTqUtk9rtx9SiqKaEc3wzIpLcnIYrgGLWot5nq+5V1nY9t9QAiJJD\n"
-        + "rmm2/3tX+jTWW6CpuLih7IsD+gJmpZkY6PfMT+teKEeh5E1XBbu10fwDwMJta+04\n"
-        + "3/TiljInjER1f/b41EnSjI6YXFnxnyiLeDgDA1QIIzB/W2ccGqphzJriDETDJhKF\n"
-        + "ZIeqvjylZofgCLyMRSyZtsu+b4pfBK3hMpu5aaYylaM1BWOpAiqUmGUKqxN/o9EG\n"
-        + "x4wvsMxK6xgiZe5UdQPaoDcFCsEMg50DmARMfmkxAQgAxeT+bUBbADga+lYtkmtY\n"
-        + "VbuG7uWjwdg9TR6qWKD7n37mcu6OgNNlrPaHoClvOL20fcArZ8wT/FbjvDI6ZHn2\n"
-        + "2YA19OvAR+Eqmf3D7qTmebchnCu955PkX7AOOpKfX48qoYq8BoskZDnbFidm5YKf\n"
-        + "Iin3CNDdlQbd3na+ihGCuv0KoGzefuAHcITeYEUESh7HLzQ9/pMES9eCgdTEkwYD\n"
-        + "5NJjfkLnj2kZtDsSiNnENZ0TIlyKOBMnixgsARDjLrkqyTg79thWALiqVBXUKn2N\n"
-        + "BtMkK5xTDc/7q3nIw4InYMIrLtntSu1wpn1gXbdg1HFl5BgqEB9Fp0k02YvrSiiV\n"
-        + "swARAQABAAf/VXp4O5CUvh9956vZu2kKmt2Jhx9CALT6pZkdU3MVvOr/d517iEHH\n"
-        + "pVJHevLqy8OFdtvO4+LOryyI6f14I3ZbHc+3frdmMqYb1LA8NZScyO5FYkOyn5jO\n"
-        + "CFbvjnVOyeP5MhXO6bSoX3JuI7+ZPoGRYxxlTDWLwJdatoDsBI9TvJhVekyAchTH\n"
-        + "Tyt3NQIvLXqHvKU/8WAgclBKeL/y/idep1BrJ4cIJ+EFp0agEG0WpRRUAYjwfE3P\n"
-        + "aSEV0NOoB8rapPW3XuEjO+ZTht+NYvqgPIdTjwXZGFPYnwvEuz772Th4pO3o/PdF\n"
-        + "2cljvRn3qo+lSVnJ0Ki2pb+LukJSIdfHgQQA1DBdm29a/3dBla2y6wxlSXW/3WBp\n"
-        + "51Vpd8SBuwdVrNNQMwPmf1L93YskJnUKSTo7MwgrYZFWf7QzgfD/cHXr8QK2C1TP\n"
-        + "czUC0/uFCm8pPQoOt/osp3PjDAzGgUAMFXCgLtb04P2JqbFvtse5oTFWrKqmscTG\n"
-        + "KnEBkzfgy37U0iMEAO7BEgXCYvqyztHmQATqJfbpxgQGqk738UW6qWwG8mK6aT5V\n"
-        + "OidZvrWqJ3WeIKmEhoJlY2Ky1ZTuJfeQuVucqzNWlZy2yzDijs+t3v4pFGajv4nV\n"
-        + "ivGvlb/O/QoHBuF/9K36lIIqcZstfa2UIYRqkkdEz2JHWJsr81VvCw2Gb38xA/sG\n"
-        + "hqErrIgSBPRCJObM/gb9rJ6dbA5SNY5trc778EjS1myhyPhGOaOmYbdQMONUqLo2\n"
-        + "q1UZo1G7oaI1Um9v5MXN1yZNX/kvx1TMldZEEixrhCIob81eXSpEUfs+Mz2RqvqT\n"
-        + "YsYquYQNPrPXWZQwTJV6fpsBQUMeE/pmlisaSAijHkXPiQEfBBgBAgAJBQJMfmkx\n"
-        + "AhsMAAoJENAi2jpemu3QFPoH/1ynX1j1QWL8TfJFPoB3vXivwGURs3J7LsywHTRj\n"
-        + "pQVQvxQvKTzB1+woUxtEbdjKgMbvY/ShHSlEZKVV9l3ZihrNewHA1GMHrDtBGXcN\n"
-        + "RP9BRfJHTrDzjUxrEEwu4QIq71o4tS89NvQmlYYi7O4ThtVB4hYSwl436+vAT9yb\n"
-        + "IQkUOjCkYrKye6JHs1K4BnVuWcOVujQwW4H8QFbddcWF49uN6DSqrwDFsjFog6wL\n"
-        + "7u6VUL5upRBP/RZWA4HKJVF2tS0Ptr6xLTmf4Tp5n10CGFYkPcRp9biVyeVRJBW4\n"
-        + "uZf0EDsn9J5rNG0pWtgnhAEi6smoT4fADTOzpOovUiTSQhQ=\n"
-        + "=RcWw\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx+aTEBCAC6dFperkew4ZowIfEyAjScjPBggcbw5XUXxLCF0nBRjWH+HvuI\n"
+            + "CGwznRyeuTiy5yyB9/CcvTLTkEs8qIyJUJoikm7QpaVVL6imVq1HD1xcOJpV1FV1\n"
+            + "eFu562xCRDUqD6KQf54N04V9TMDyubhPkQYbx1H2gq+uBEo9d1w6AsSMgaUn3xH/\n"
+            + "xYe+INxcP6jFT2OKc36x+8ipP6pc8Hba1X90JwadOcJlwEyJfJKs7hYHTaYn+I6+\n"
+            + "4w0Y//WebhT4ocsYIiOYrENQUcic+vL3fkwwJCloyDBCGxr7w7Gn4Pe3peTCl4Sp\n"
+            + "vIIoYnHPW4h3Nyh8qAlBNDw7dCPS9LP7wRdNABEBAAEAB/oCD6EKLvjXgItlqdm/\n"
+            + "X+OWMYHDCtuRCMW7+2gEw/TxfLeGJaOHWxAouwUIArEEb/hjdaRfIg4wdJUxmyPX\n"
+            + "WyNqUdupkjdXNa7RNaesIi0ilrdZOn7NlHWJCCXwKt2R0jd2p8PDED6CWaE1+76I\n"
+            + "/IuwOHDTD8MABke3KvHDXMxjzdeuRbm670Aqz6zTVY+BZG1GH63Ef5JEyezMgAU5\n"
+            + "42+v+OgD0W0/jCxF7jt2ddP9QiOzu0q65mI4qlOuSebxjH8P7ye0LU9EuWVgAcwc\n"
+            + "YJh2lk3eH8bCWTwlIHj4+8MYgY5i510I5xfY3sWuylw/qtFP9vYjisrysadcUExc\n"
+            + "QUxFBADXQSCmvtgRoSLiGfQv2y2qInx67eJw8pUXFEIJKdOFOhX4vogT9qPWQAms\n"
+            + "/vSshcsAPgpZJZ8MNeGpMGLAGm8y4D2zWWd9YLNmVXsPu7EyrDpXlKHCFnsQfOGN\n"
+            + "c5j8u4CHBn1cS/Yk53S+6Yge2jvnOjVNFmxB0ocs0Y5zbdTJYwQA3b+hQebH7NNr\n"
+            + "FlPwthRZS0TiX5+qkE9tE/0mpRrUN3iS9bnF0IXRmHFp7Hz+EsVbA2Re2A5HIHnQ\n"
+            + "/BSpAsSHRhjU3MH4gzwfg9W43eZGVfofSY6IlUCIcd1bGjSAjJgmfhjU7ofS59i/\n"
+            + "DjzP1jBfXdjOEUQULTkXjHPqO7j4048D/jqMwZNY3AawTMjqKr9nGK49aWv/OVdy\n"
+            + "6xGn4dRJNk3gnnIvjAEFy5+HHbUCJ2lA3X2AssQ9tvbuyDnoSL5/G+zEYtyRuAC5\n"
+            + "9TLQQRmy4qjsYC5TwfoUwFbgqRsmGUcjj2wtE+gb1S8P/zudYrEqOD3K60Y5qXcn\n"
+            + "S3PHgJ++5TzFQba0HlRlc3R1c2VyIEkgPHRlc3RpQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTH5pMQIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ0CLaOl6a\n"
+            + "7dAjNQf/fLmGeKgaesawP53UeioQ8hgDEFKPBddNQP248NpReZ1rg3h8Q21PQJVK\n"
+            + "rtDYn94WJi5NTqUtk9rtx9SiqKaEc3wzIpLcnIYrgGLWot5nq+5V1nY9t9QAiJJD\n"
+            + "rmm2/3tX+jTWW6CpuLih7IsD+gJmpZkY6PfMT+teKEeh5E1XBbu10fwDwMJta+04\n"
+            + "3/TiljInjER1f/b41EnSjI6YXFnxnyiLeDgDA1QIIzB/W2ccGqphzJriDETDJhKF\n"
+            + "ZIeqvjylZofgCLyMRSyZtsu+b4pfBK3hMpu5aaYylaM1BWOpAiqUmGUKqxN/o9EG\n"
+            + "x4wvsMxK6xgiZe5UdQPaoDcFCsEMg50DmARMfmkxAQgAxeT+bUBbADga+lYtkmtY\n"
+            + "VbuG7uWjwdg9TR6qWKD7n37mcu6OgNNlrPaHoClvOL20fcArZ8wT/FbjvDI6ZHn2\n"
+            + "2YA19OvAR+Eqmf3D7qTmebchnCu955PkX7AOOpKfX48qoYq8BoskZDnbFidm5YKf\n"
+            + "Iin3CNDdlQbd3na+ihGCuv0KoGzefuAHcITeYEUESh7HLzQ9/pMES9eCgdTEkwYD\n"
+            + "5NJjfkLnj2kZtDsSiNnENZ0TIlyKOBMnixgsARDjLrkqyTg79thWALiqVBXUKn2N\n"
+            + "BtMkK5xTDc/7q3nIw4InYMIrLtntSu1wpn1gXbdg1HFl5BgqEB9Fp0k02YvrSiiV\n"
+            + "swARAQABAAf/VXp4O5CUvh9956vZu2kKmt2Jhx9CALT6pZkdU3MVvOr/d517iEHH\n"
+            + "pVJHevLqy8OFdtvO4+LOryyI6f14I3ZbHc+3frdmMqYb1LA8NZScyO5FYkOyn5jO\n"
+            + "CFbvjnVOyeP5MhXO6bSoX3JuI7+ZPoGRYxxlTDWLwJdatoDsBI9TvJhVekyAchTH\n"
+            + "Tyt3NQIvLXqHvKU/8WAgclBKeL/y/idep1BrJ4cIJ+EFp0agEG0WpRRUAYjwfE3P\n"
+            + "aSEV0NOoB8rapPW3XuEjO+ZTht+NYvqgPIdTjwXZGFPYnwvEuz772Th4pO3o/PdF\n"
+            + "2cljvRn3qo+lSVnJ0Ki2pb+LukJSIdfHgQQA1DBdm29a/3dBla2y6wxlSXW/3WBp\n"
+            + "51Vpd8SBuwdVrNNQMwPmf1L93YskJnUKSTo7MwgrYZFWf7QzgfD/cHXr8QK2C1TP\n"
+            + "czUC0/uFCm8pPQoOt/osp3PjDAzGgUAMFXCgLtb04P2JqbFvtse5oTFWrKqmscTG\n"
+            + "KnEBkzfgy37U0iMEAO7BEgXCYvqyztHmQATqJfbpxgQGqk738UW6qWwG8mK6aT5V\n"
+            + "OidZvrWqJ3WeIKmEhoJlY2Ky1ZTuJfeQuVucqzNWlZy2yzDijs+t3v4pFGajv4nV\n"
+            + "ivGvlb/O/QoHBuF/9K36lIIqcZstfa2UIYRqkkdEz2JHWJsr81VvCw2Gb38xA/sG\n"
+            + "hqErrIgSBPRCJObM/gb9rJ6dbA5SNY5trc778EjS1myhyPhGOaOmYbdQMONUqLo2\n"
+            + "q1UZo1G7oaI1Um9v5MXN1yZNX/kvx1TMldZEEixrhCIob81eXSpEUfs+Mz2RqvqT\n"
+            + "YsYquYQNPrPXWZQwTJV6fpsBQUMeE/pmlisaSAijHkXPiQEfBBgBAgAJBQJMfmkx\n"
+            + "AhsMAAoJENAi2jpemu3QFPoH/1ynX1j1QWL8TfJFPoB3vXivwGURs3J7LsywHTRj\n"
+            + "pQVQvxQvKTzB1+woUxtEbdjKgMbvY/ShHSlEZKVV9l3ZihrNewHA1GMHrDtBGXcN\n"
+            + "RP9BRfJHTrDzjUxrEEwu4QIq71o4tS89NvQmlYYi7O4ThtVB4hYSwl436+vAT9yb\n"
+            + "IQkUOjCkYrKye6JHs1K4BnVuWcOVujQwW4H8QFbddcWF49uN6DSqrwDFsjFog6wL\n"
+            + "7u6VUL5upRBP/RZWA4HKJVF2tS0Ptr6xLTmf4Tp5n10CGFYkPcRp9biVyeVRJBW4\n"
+            + "uZf0EDsn9J5rNG0pWtgnhAEi6smoT4fADTOzpOovUiTSQhQ=\n"
+            + "=RcWw\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
   /**
-   * pub   2048R/C2E6A198 2010-09-01
-   *       Key fingerprint = 83AB CE4D 6845 D6DA F7FB  AA47 A139 3C46 C2E6 A198
-   * uid                  Testuser J &lt;testj@example.com&gt;
-   * sub   2048R/863E8ABF 2010-09-01
+   * pub 2048R/C2E6A198 2010-09-01 Key fingerprint = 83AB CE4D 6845 D6DA F7FB AA47 A139 3C46 C2E6
+   * A198 uid Testuser J &lt;testj@example.com&gt; sub 2048R/863E8ABF 2010-09-01
    */
   public static TestKey keyJ() throws Exception {
-    return new TestKey("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "mQENBEx+aUgBCADczNio9UWnUggUZkdqJye57497oD9vNo9rmtR+i1TkMpVeWaMH\n"
-        + "UWrm1twzIPV9D4lAWLJoG2cYF6nXG1JxsKc9mOIZ6O1WfsopMUU0p+EfU8H/cvdM\n"
-        + "/iccYS6OnNL4/xR1R7hlA4+b/jaOZfzdS3i5jwOf+TtCk7c5qOuFhraiVQ9H1G86\n"
-        + "+LsiWVeXEFc/FXxnESRmbaZFNJrAdJl23eKXRC6az0S5FwMVBvUhRpLwIGDbVT/0\n"
-        + "/QwaNUOq3bYwudPREFLg/1HtBuxNhRdV6mCrit+tsPan9o0/WtsHuq8n4/pqOKBc\n"
-        + "RRmOIQR9SEohE2TuVT3XVFpMXa4a4CBuNXjTABEBAAG0HlRlc3R1c2VyIEogPHRl\n"
-        + "c3RqQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pSAIbAwYLCQgHAwIGFQgCCQoL\n"
-        + "BBYCAwECHgECF4AACgkQoTk8RsLmoZi0BggAlnbCwmwaLwcpU9YcOE9/8KF56dIs\n"
-        + "XhdxzqdP91UmhVT0df1OBhgTqFkKprBLCT+B9yBClsnyXMatkvuhQG6C7lw9toMO\n"
-        + "TITRPZoFJe3Ezi+HRRPqAPubIcSgeILuilvFhkoUOgoC1ubmVPgcGBLb8tdvI3bA\n"
-        + "svq+n2jaYUlgL5N6ZNRNakc07e8vH5SeKiD8ZntJlTU49fkxzlawtDaI3+GhyUiB\n"
-        + "0Ah8pl143DFNAq8CfvQCPKwX4WFPkEflh0LlgaEPJUZ/H6KxKXXF8SC9cD2VIii8\n"
-        + "Yrue8y9T+j5y699A0GCptb1IKrgxbfhgD//3g3l1eXsEwn2cwFNCt7pZFLkBDQRM\n"
-        + "fmlIAQgA3E2pM6oDJGgfxbqSfykuRtTbiAi7JEd1DNvEe6gJ7qkBLM4ipILBD0qR\n"
-        + "qCwL37E4/3nMsZjA7GIFLQj2DrFW3aEEKwR/zdh7R67lo9CunCY+FPWTuOkCG8Sh\n"
-        + "3RLpbAV6I61NG/wDznW30vmKNJDgPpkzYj8u0T4MtpywEgxTxCqWZKCufWDRfNAy\n"
-        + "IBLt+piG+bcYKfw9pS8PvXPQMNIi4U2pu3hb/BHC3Y1A8FVpEe4CFV7rWb/K2Ydx\n"
-        + "eBxwwxm9sBxF+vhlI+ZEeb9JxGH6jYlc6twD4e6p3KqusAKLKiLsS5uLQnpMGGZ8\n"
-        + "vcpTSfyHjG2QHc3qG9S/yDCZjhhe2QARAQABiQEfBBgBAgAJBQJMfmlIAhsMAAoJ\n"
-        + "EKE5PEbC5qGYClMIANTdZ+/g/FPl1Lm0tO1CSnHVHekeGNA9n3L6SGiSZQJjEDo0\n"
-        + "gsye5xgxh5JGKf7CqbEFfeLC9Jx5W5EN4dVFudncIlC/SutfRzdt5W8CLXMl0c41\n"
-        + "5FmtpWNStk3MglkcjE5PrRRrSiRc45S0e2kIPb8eiVKg98/rCToC9+Qn3pMi/fcM\n"
-        + "LVpYE+dhvB5EhOSwBWWgvWXzeLhv5CnBKxH0ItHhNwvt8qPOHgQAJKPc6dV888xn\n"
-        + "Sew62LFefHPnGTHLP8RRgVIvZBG5IoovxSz89QGHQZiC4xv00I7zNwmtr6eEcl+y\n"
-        + "BkUK9QWITEBHUDqR+cbVa2dRr3fUHwRP7G2G+ow=\n"
-        + "=ucAX\n"
-        + "-----END PGP PUBLIC KEY BLOCK-----\n",
+    return new TestKey(
+        "-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "mQENBEx+aUgBCADczNio9UWnUggUZkdqJye57497oD9vNo9rmtR+i1TkMpVeWaMH\n"
+            + "UWrm1twzIPV9D4lAWLJoG2cYF6nXG1JxsKc9mOIZ6O1WfsopMUU0p+EfU8H/cvdM\n"
+            + "/iccYS6OnNL4/xR1R7hlA4+b/jaOZfzdS3i5jwOf+TtCk7c5qOuFhraiVQ9H1G86\n"
+            + "+LsiWVeXEFc/FXxnESRmbaZFNJrAdJl23eKXRC6az0S5FwMVBvUhRpLwIGDbVT/0\n"
+            + "/QwaNUOq3bYwudPREFLg/1HtBuxNhRdV6mCrit+tsPan9o0/WtsHuq8n4/pqOKBc\n"
+            + "RRmOIQR9SEohE2TuVT3XVFpMXa4a4CBuNXjTABEBAAG0HlRlc3R1c2VyIEogPHRl\n"
+            + "c3RqQGV4YW1wbGUuY29tPokBOAQTAQIAIgUCTH5pSAIbAwYLCQgHAwIGFQgCCQoL\n"
+            + "BBYCAwECHgECF4AACgkQoTk8RsLmoZi0BggAlnbCwmwaLwcpU9YcOE9/8KF56dIs\n"
+            + "XhdxzqdP91UmhVT0df1OBhgTqFkKprBLCT+B9yBClsnyXMatkvuhQG6C7lw9toMO\n"
+            + "TITRPZoFJe3Ezi+HRRPqAPubIcSgeILuilvFhkoUOgoC1ubmVPgcGBLb8tdvI3bA\n"
+            + "svq+n2jaYUlgL5N6ZNRNakc07e8vH5SeKiD8ZntJlTU49fkxzlawtDaI3+GhyUiB\n"
+            + "0Ah8pl143DFNAq8CfvQCPKwX4WFPkEflh0LlgaEPJUZ/H6KxKXXF8SC9cD2VIii8\n"
+            + "Yrue8y9T+j5y699A0GCptb1IKrgxbfhgD//3g3l1eXsEwn2cwFNCt7pZFLkBDQRM\n"
+            + "fmlIAQgA3E2pM6oDJGgfxbqSfykuRtTbiAi7JEd1DNvEe6gJ7qkBLM4ipILBD0qR\n"
+            + "qCwL37E4/3nMsZjA7GIFLQj2DrFW3aEEKwR/zdh7R67lo9CunCY+FPWTuOkCG8Sh\n"
+            + "3RLpbAV6I61NG/wDznW30vmKNJDgPpkzYj8u0T4MtpywEgxTxCqWZKCufWDRfNAy\n"
+            + "IBLt+piG+bcYKfw9pS8PvXPQMNIi4U2pu3hb/BHC3Y1A8FVpEe4CFV7rWb/K2Ydx\n"
+            + "eBxwwxm9sBxF+vhlI+ZEeb9JxGH6jYlc6twD4e6p3KqusAKLKiLsS5uLQnpMGGZ8\n"
+            + "vcpTSfyHjG2QHc3qG9S/yDCZjhhe2QARAQABiQEfBBgBAgAJBQJMfmlIAhsMAAoJ\n"
+            + "EKE5PEbC5qGYClMIANTdZ+/g/FPl1Lm0tO1CSnHVHekeGNA9n3L6SGiSZQJjEDo0\n"
+            + "gsye5xgxh5JGKf7CqbEFfeLC9Jx5W5EN4dVFudncIlC/SutfRzdt5W8CLXMl0c41\n"
+            + "5FmtpWNStk3MglkcjE5PrRRrSiRc45S0e2kIPb8eiVKg98/rCToC9+Qn3pMi/fcM\n"
+            + "LVpYE+dhvB5EhOSwBWWgvWXzeLhv5CnBKxH0ItHhNwvt8qPOHgQAJKPc6dV888xn\n"
+            + "Sew62LFefHPnGTHLP8RRgVIvZBG5IoovxSz89QGHQZiC4xv00I7zNwmtr6eEcl+y\n"
+            + "BkUK9QWITEBHUDqR+cbVa2dRr3fUHwRP7G2G+ow=\n"
+            + "=ucAX\n"
+            + "-----END PGP PUBLIC KEY BLOCK-----\n",
         "-----BEGIN PGP PRIVATE KEY BLOCK-----\n"
-        + "Version: GnuPG v1\n"
-        + "\n"
-        + "lQOYBEx+aUgBCADczNio9UWnUggUZkdqJye57497oD9vNo9rmtR+i1TkMpVeWaMH\n"
-        + "UWrm1twzIPV9D4lAWLJoG2cYF6nXG1JxsKc9mOIZ6O1WfsopMUU0p+EfU8H/cvdM\n"
-        + "/iccYS6OnNL4/xR1R7hlA4+b/jaOZfzdS3i5jwOf+TtCk7c5qOuFhraiVQ9H1G86\n"
-        + "+LsiWVeXEFc/FXxnESRmbaZFNJrAdJl23eKXRC6az0S5FwMVBvUhRpLwIGDbVT/0\n"
-        + "/QwaNUOq3bYwudPREFLg/1HtBuxNhRdV6mCrit+tsPan9o0/WtsHuq8n4/pqOKBc\n"
-        + "RRmOIQR9SEohE2TuVT3XVFpMXa4a4CBuNXjTABEBAAEAB/9sW1MQR53xKP6yFCeD\n"
-        + "3sdOJlSB1PiMeXgU1JznpTT58CEBdnfdRYVy14qkxM30m8U9gMm88YW8exBscgoZ\n"
-        + "pRnNztNW58phokNPx9AwsRp3p0ETPbZDYI6NDNwuPKQEchn2HEZPvFmjsjPP2hkn\n"
-        + "+Lu8RIUA4uzEFX3bnBxJIP1L2AztqyTgHDfXS4/nqerO/cheXhN7j1TUyRO4hinp\n"
-        + "C3WXaxm2kpQXFP2ktq2eu7YPFoW6I6HzHVDN2Z7fD/NzfmR2h4gcIaSDEjIs893N\n"
-        + "b3hsYiOTYwVFX9TBWLr9rSWyrjR4sWelFuMZpjQ53qq+rBm/+8knoNtoWgZFhbR0\n"
-        + "WJyRBADlBuX8kveqLl31QShgw+6TwTHXI40GiCA6DHwZiTstOO6d2KDNq2nHdtuo\n"
-        + "HBvSKYP4a2na39JKb7YfuSMg16QvxQNd7BQWz+NzbGLQEGuX455OD3TE74ZfVElo\n"
-        + "2H/i51hSjOdWihJVNBGlcDYPgb7oLLTbPdKXxptRM1+wrk2//QQA9s3pw2O3lSbV\n"
-        + "U8JyL/FhdyhDvRDuiNBPnB4O/Ynnzz8YSFwSdSE/u8FpguFWdh+UdSrdwE+Ux8kj\n"
-        + "W/miXaqTxUeKnpzOkiO5O2fLvAeriO3rU9KfBER03+NJo4weSorLXzeU4SWkw63N\n"
-        + "OiY3fc67Nj+l8qi1tmoEJyHUomuy7Q8EAOfBvMzGsQQJ12k+4gOSXN9DTWUa85P6\n"
-        + "IphFHC2cpTDy30IRR55sI6Mf3GpC+KzxEyw7WXjlTensEJAHMpyVVRhv6uF0eMaY\n"
-        + "+QGS+vyCgtUfGIwM5Teu6NjeqyShJDTC8qnM+75JgCNu6gZ2F2iTeY+tM3zE1auq\n"
-        + "po1pUACVm7qwR6u0HlRlc3R1c2VyIEogPHRlc3RqQGV4YW1wbGUuY29tPokBOAQT\n"
-        + "AQIAIgUCTH5pSAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQoTk8RsLm\n"
-        + "oZi0BggAlnbCwmwaLwcpU9YcOE9/8KF56dIsXhdxzqdP91UmhVT0df1OBhgTqFkK\n"
-        + "prBLCT+B9yBClsnyXMatkvuhQG6C7lw9toMOTITRPZoFJe3Ezi+HRRPqAPubIcSg\n"
-        + "eILuilvFhkoUOgoC1ubmVPgcGBLb8tdvI3bAsvq+n2jaYUlgL5N6ZNRNakc07e8v\n"
-        + "H5SeKiD8ZntJlTU49fkxzlawtDaI3+GhyUiB0Ah8pl143DFNAq8CfvQCPKwX4WFP\n"
-        + "kEflh0LlgaEPJUZ/H6KxKXXF8SC9cD2VIii8Yrue8y9T+j5y699A0GCptb1IKrgx\n"
-        + "bfhgD//3g3l1eXsEwn2cwFNCt7pZFJ0DmARMfmlIAQgA3E2pM6oDJGgfxbqSfyku\n"
-        + "RtTbiAi7JEd1DNvEe6gJ7qkBLM4ipILBD0qRqCwL37E4/3nMsZjA7GIFLQj2DrFW\n"
-        + "3aEEKwR/zdh7R67lo9CunCY+FPWTuOkCG8Sh3RLpbAV6I61NG/wDznW30vmKNJDg\n"
-        + "PpkzYj8u0T4MtpywEgxTxCqWZKCufWDRfNAyIBLt+piG+bcYKfw9pS8PvXPQMNIi\n"
-        + "4U2pu3hb/BHC3Y1A8FVpEe4CFV7rWb/K2YdxeBxwwxm9sBxF+vhlI+ZEeb9JxGH6\n"
-        + "jYlc6twD4e6p3KqusAKLKiLsS5uLQnpMGGZ8vcpTSfyHjG2QHc3qG9S/yDCZjhhe\n"
-        + "2QARAQABAAf7BUTPxk/u/vi935DpBXoXRKHZnLM3bFuIexCGQ74rQqR2qazUMH8o\n"
-        + "SFEsaBJpm2WyR47J5WqSHNi5SxPT2AUdNFeh/39hxY61Q6SuBFED+WMRbHrKbURR\n"
-        + "WjPiFuwus02eAkAYFWfBFY0n9/BcAhicQa90MTRj+RZb/EHa+GDdbgDatpwEK22z\n"
-        + "pPb3t/D2TC7ModizelngBN7bdp4Vqna/vMLhsiE+FqL+Ob0KiLkDxtcjZljc9xLK\n"
-        + "B7ZuGH/AZfhF08OAxUcsJdu5cF3viBT+HeSI4OUvdfxPFX98U/SFfuW4mPdHPEI9\n"
-        + "438pdjDUIpJFtcnROtZdS2o6C9ohHa5BUwQA52P8AKKRfg7LpaFMvtKkNORnscac\n"
-        + "1qvXLqAXaMeSsvyU5o1GNvSgbhFzDcXbAFJcXdOo2XgT7JzW/6v1uW9AuQPAkYhr\n"
-        + "ep0uE3mewlzWHZR41MQRaMGN4l80RN6ju4c/Ei+OMHYp2DUfZFDBXbxwWpN8tNoR\n"
-        + "S1X+rOL5RsQgkrcEAPO7zthR+GQnIgJC3c9Las9JkPywCxddjoWZoyt6yITVjIso\n"
-        + "IGD0SJppAkOS3Vdb+raydLuN7HmbpPFnvzyc+RdSt+YCGUObrHb/z9MfahzDNG3S\n"
-        + "VwUQEIl+L6glhwscQOCz80MCcYMFMk4TiankvChRFF5Wil//8QnaonH4bcrvA/46\n"
-        + "VB+ZaEdR+Z8IkYIf7oHLJNEwaH+kRTBQ2x5F9Gnwr9SL6AXAkNkvYD4in/+Bw35r\n"
-        + "o9zGirQQvNrvH3JlZ5PWp1/9rRl2Tefaaf8P2ij/Ky2poBLAhPwK56JXHLt5v+BZ\n"
-        + "mQwhY+teJnbfCwiiS0OeWtpVY/tDVU7wYOd2RIhVfkUziQEfBBgBAgAJBQJMfmlI\n"
-        + "AhsMAAoJEKE5PEbC5qGYClMIANTdZ+/g/FPl1Lm0tO1CSnHVHekeGNA9n3L6SGiS\n"
-        + "ZQJjEDo0gsye5xgxh5JGKf7CqbEFfeLC9Jx5W5EN4dVFudncIlC/SutfRzdt5W8C\n"
-        + "LXMl0c415FmtpWNStk3MglkcjE5PrRRrSiRc45S0e2kIPb8eiVKg98/rCToC9+Qn\n"
-        + "3pMi/fcMLVpYE+dhvB5EhOSwBWWgvWXzeLhv5CnBKxH0ItHhNwvt8qPOHgQAJKPc\n"
-        + "6dV888xnSew62LFefHPnGTHLP8RRgVIvZBG5IoovxSz89QGHQZiC4xv00I7zNwmt\n"
-        + "r6eEcl+yBkUK9QWITEBHUDqR+cbVa2dRr3fUHwRP7G2G+ow=\n"
-        + "=NiQI\n"
-        + "-----END PGP PRIVATE KEY BLOCK-----\n");
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "lQOYBEx+aUgBCADczNio9UWnUggUZkdqJye57497oD9vNo9rmtR+i1TkMpVeWaMH\n"
+            + "UWrm1twzIPV9D4lAWLJoG2cYF6nXG1JxsKc9mOIZ6O1WfsopMUU0p+EfU8H/cvdM\n"
+            + "/iccYS6OnNL4/xR1R7hlA4+b/jaOZfzdS3i5jwOf+TtCk7c5qOuFhraiVQ9H1G86\n"
+            + "+LsiWVeXEFc/FXxnESRmbaZFNJrAdJl23eKXRC6az0S5FwMVBvUhRpLwIGDbVT/0\n"
+            + "/QwaNUOq3bYwudPREFLg/1HtBuxNhRdV6mCrit+tsPan9o0/WtsHuq8n4/pqOKBc\n"
+            + "RRmOIQR9SEohE2TuVT3XVFpMXa4a4CBuNXjTABEBAAEAB/9sW1MQR53xKP6yFCeD\n"
+            + "3sdOJlSB1PiMeXgU1JznpTT58CEBdnfdRYVy14qkxM30m8U9gMm88YW8exBscgoZ\n"
+            + "pRnNztNW58phokNPx9AwsRp3p0ETPbZDYI6NDNwuPKQEchn2HEZPvFmjsjPP2hkn\n"
+            + "+Lu8RIUA4uzEFX3bnBxJIP1L2AztqyTgHDfXS4/nqerO/cheXhN7j1TUyRO4hinp\n"
+            + "C3WXaxm2kpQXFP2ktq2eu7YPFoW6I6HzHVDN2Z7fD/NzfmR2h4gcIaSDEjIs893N\n"
+            + "b3hsYiOTYwVFX9TBWLr9rSWyrjR4sWelFuMZpjQ53qq+rBm/+8knoNtoWgZFhbR0\n"
+            + "WJyRBADlBuX8kveqLl31QShgw+6TwTHXI40GiCA6DHwZiTstOO6d2KDNq2nHdtuo\n"
+            + "HBvSKYP4a2na39JKb7YfuSMg16QvxQNd7BQWz+NzbGLQEGuX455OD3TE74ZfVElo\n"
+            + "2H/i51hSjOdWihJVNBGlcDYPgb7oLLTbPdKXxptRM1+wrk2//QQA9s3pw2O3lSbV\n"
+            + "U8JyL/FhdyhDvRDuiNBPnB4O/Ynnzz8YSFwSdSE/u8FpguFWdh+UdSrdwE+Ux8kj\n"
+            + "W/miXaqTxUeKnpzOkiO5O2fLvAeriO3rU9KfBER03+NJo4weSorLXzeU4SWkw63N\n"
+            + "OiY3fc67Nj+l8qi1tmoEJyHUomuy7Q8EAOfBvMzGsQQJ12k+4gOSXN9DTWUa85P6\n"
+            + "IphFHC2cpTDy30IRR55sI6Mf3GpC+KzxEyw7WXjlTensEJAHMpyVVRhv6uF0eMaY\n"
+            + "+QGS+vyCgtUfGIwM5Teu6NjeqyShJDTC8qnM+75JgCNu6gZ2F2iTeY+tM3zE1auq\n"
+            + "po1pUACVm7qwR6u0HlRlc3R1c2VyIEogPHRlc3RqQGV4YW1wbGUuY29tPokBOAQT\n"
+            + "AQIAIgUCTH5pSAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQoTk8RsLm\n"
+            + "oZi0BggAlnbCwmwaLwcpU9YcOE9/8KF56dIsXhdxzqdP91UmhVT0df1OBhgTqFkK\n"
+            + "prBLCT+B9yBClsnyXMatkvuhQG6C7lw9toMOTITRPZoFJe3Ezi+HRRPqAPubIcSg\n"
+            + "eILuilvFhkoUOgoC1ubmVPgcGBLb8tdvI3bAsvq+n2jaYUlgL5N6ZNRNakc07e8v\n"
+            + "H5SeKiD8ZntJlTU49fkxzlawtDaI3+GhyUiB0Ah8pl143DFNAq8CfvQCPKwX4WFP\n"
+            + "kEflh0LlgaEPJUZ/H6KxKXXF8SC9cD2VIii8Yrue8y9T+j5y699A0GCptb1IKrgx\n"
+            + "bfhgD//3g3l1eXsEwn2cwFNCt7pZFJ0DmARMfmlIAQgA3E2pM6oDJGgfxbqSfyku\n"
+            + "RtTbiAi7JEd1DNvEe6gJ7qkBLM4ipILBD0qRqCwL37E4/3nMsZjA7GIFLQj2DrFW\n"
+            + "3aEEKwR/zdh7R67lo9CunCY+FPWTuOkCG8Sh3RLpbAV6I61NG/wDznW30vmKNJDg\n"
+            + "PpkzYj8u0T4MtpywEgxTxCqWZKCufWDRfNAyIBLt+piG+bcYKfw9pS8PvXPQMNIi\n"
+            + "4U2pu3hb/BHC3Y1A8FVpEe4CFV7rWb/K2YdxeBxwwxm9sBxF+vhlI+ZEeb9JxGH6\n"
+            + "jYlc6twD4e6p3KqusAKLKiLsS5uLQnpMGGZ8vcpTSfyHjG2QHc3qG9S/yDCZjhhe\n"
+            + "2QARAQABAAf7BUTPxk/u/vi935DpBXoXRKHZnLM3bFuIexCGQ74rQqR2qazUMH8o\n"
+            + "SFEsaBJpm2WyR47J5WqSHNi5SxPT2AUdNFeh/39hxY61Q6SuBFED+WMRbHrKbURR\n"
+            + "WjPiFuwus02eAkAYFWfBFY0n9/BcAhicQa90MTRj+RZb/EHa+GDdbgDatpwEK22z\n"
+            + "pPb3t/D2TC7ModizelngBN7bdp4Vqna/vMLhsiE+FqL+Ob0KiLkDxtcjZljc9xLK\n"
+            + "B7ZuGH/AZfhF08OAxUcsJdu5cF3viBT+HeSI4OUvdfxPFX98U/SFfuW4mPdHPEI9\n"
+            + "438pdjDUIpJFtcnROtZdS2o6C9ohHa5BUwQA52P8AKKRfg7LpaFMvtKkNORnscac\n"
+            + "1qvXLqAXaMeSsvyU5o1GNvSgbhFzDcXbAFJcXdOo2XgT7JzW/6v1uW9AuQPAkYhr\n"
+            + "ep0uE3mewlzWHZR41MQRaMGN4l80RN6ju4c/Ei+OMHYp2DUfZFDBXbxwWpN8tNoR\n"
+            + "S1X+rOL5RsQgkrcEAPO7zthR+GQnIgJC3c9Las9JkPywCxddjoWZoyt6yITVjIso\n"
+            + "IGD0SJppAkOS3Vdb+raydLuN7HmbpPFnvzyc+RdSt+YCGUObrHb/z9MfahzDNG3S\n"
+            + "VwUQEIl+L6glhwscQOCz80MCcYMFMk4TiankvChRFF5Wil//8QnaonH4bcrvA/46\n"
+            + "VB+ZaEdR+Z8IkYIf7oHLJNEwaH+kRTBQ2x5F9Gnwr9SL6AXAkNkvYD4in/+Bw35r\n"
+            + "o9zGirQQvNrvH3JlZ5PWp1/9rRl2Tefaaf8P2ij/Ky2poBLAhPwK56JXHLt5v+BZ\n"
+            + "mQwhY+teJnbfCwiiS0OeWtpVY/tDVU7wYOd2RIhVfkUziQEfBBgBAgAJBQJMfmlI\n"
+            + "AhsMAAoJEKE5PEbC5qGYClMIANTdZ+/g/FPl1Lm0tO1CSnHVHekeGNA9n3L6SGiS\n"
+            + "ZQJjEDo0gsye5xgxh5JGKf7CqbEFfeLC9Jx5W5EN4dVFudncIlC/SutfRzdt5W8C\n"
+            + "LXMl0c415FmtpWNStk3MglkcjE5PrRRrSiRc45S0e2kIPb8eiVKg98/rCToC9+Qn\n"
+            + "3pMi/fcMLVpYE+dhvB5EhOSwBWWgvWXzeLhv5CnBKxH0ItHhNwvt8qPOHgQAJKPc\n"
+            + "6dV888xnSew62LFefHPnGTHLP8RRgVIvZBG5IoovxSz89QGHQZiC4xv00I7zNwmt\n"
+            + "r6eEcl+yBkUK9QWITEBHUDqR+cbVa2dRr3fUHwRP7G2G+ow=\n"
+            + "=NiQI\n"
+            + "-----END PGP PRIVATE KEY BLOCK-----\n");
   }
 
-  private TestTrustKeys() {
-  }
+  private TestTrustKeys() {}
 }
diff --git a/gerrit-gwtdebug/BUCK b/gerrit-gwtdebug/BUCK
deleted file mode 100644
index 3670916..0000000
--- a/gerrit-gwtdebug/BUCK
+++ /dev/null
@@ -1,17 +0,0 @@
-java_library(
-  name = 'gwtdebug',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    '//gerrit-pgm:daemon',
-    '//gerrit-pgm:pgm',
-    '//gerrit-pgm:util',
-    '//gerrit-util-cli:cli',
-    '//lib/gwt:dev',
-    '//lib/jetty:server',
-    '//lib/jetty:servlet',
-    '//lib/jetty:servlets',
-    '//lib/log:api',
-    '//lib/log:log4j',
-  ],
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-gwtdebug/BUILD b/gerrit-gwtdebug/BUILD
new file mode 100644
index 0000000..232570a
--- /dev/null
+++ b/gerrit-gwtdebug/BUILD
@@ -0,0 +1,19 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "gwtdebug",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-pgm:daemon",
+        "//gerrit-pgm:pgm",
+        "//gerrit-pgm:util",
+        "//gerrit-util-cli:cli",
+        "//lib/gwt:dev",
+        "//lib/jetty:server",
+        "//lib/jetty:servlet",
+        "//lib/jetty:servlets",
+        "//lib/log:api",
+        "//lib/log:log4j",
+    ],
+)
diff --git a/gerrit-gwtdebug/src/main/java/com/google/gerrit/gwtdebug/GerritGwtDebugLauncher.java b/gerrit-gwtdebug/src/main/java/com/google/gerrit/gwtdebug/GerritGwtDebugLauncher.java
index 4282534..4edff0e 100644
--- a/gerrit-gwtdebug/src/main/java/com/google/gerrit/gwtdebug/GerritGwtDebugLauncher.java
+++ b/gerrit-gwtdebug/src/main/java/com/google/gerrit/gwtdebug/GerritGwtDebugLauncher.java
@@ -17,12 +17,10 @@
 import com.google.gerrit.pgm.Daemon;
 import com.google.gwt.dev.codeserver.CodeServer;
 import com.google.gwt.dev.codeserver.Options;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class GerritGwtDebugLauncher {
   private static final Logger log = LoggerFactory.getLogger(GerritGwtDebugLauncher.class);
@@ -55,8 +53,7 @@
     }
 
     Options options = new Options();
-    if (!options.parseArgs(sdmLauncherOptions.toArray(
-        new String[sdmLauncherOptions.size()]))) {
+    if (!options.parseArgs(sdmLauncherOptions.toArray(new String[sdmLauncherOptions.size()]))) {
       log.error("Failed to parse codeserver arguments");
       return 1;
     }
@@ -64,8 +61,9 @@
     CodeServer.main(options);
 
     try {
-      int r = new Daemon().main(daemonLauncherOptions.toArray(
-          new String[daemonLauncherOptions.size()]));
+      int r =
+          new Daemon()
+              .main(daemonLauncherOptions.toArray(new String[daemonLauncherOptions.size()]));
       if (r != 0) {
         log.error("Daemon exited with return code: " + r);
         return 1;
diff --git a/gerrit-gwtexpui/BUCK b/gerrit-gwtexpui/BUCK
deleted file mode 100644
index 79a97a9..0000000
--- a/gerrit-gwtexpui/BUCK
+++ /dev/null
@@ -1,114 +0,0 @@
-SRC = 'src/main/java/com/google/gwtexpui/'
-
-gwt_module(
-  name = 'Clippy',
-  srcs = glob([SRC + 'clippy/client/*.java']),
-  gwt_xml = SRC + 'clippy/Clippy.gwt.xml',
-  resources = [
-    SRC + 'clippy/client/clippy.css',
-    SRC + 'clippy/client/clippy.swf',
-    SRC + 'clippy/client/page_white_copy.png',
-    SRC + 'clippy/client/CopyableLabelText.properties',
-  ],
-  provided_deps = ['//lib/gwt:user'],
-  deps = [
-    ':SafeHtml',
-    ':UserAgent',
-    '//lib:LICENSE-clippy',
-    '//lib:LICENSE-silk_icons',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'CSS',
-  srcs = glob([SRC + 'css/rebind/*.java']),
-  resources = [SRC + 'css/CSS.gwt.xml'],
-  provided_deps = ['//lib/gwt:dev'],
-  visibility = ['PUBLIC'],
-)
-
-gwt_module(
-  name = 'GlobalKey',
-  srcs = glob([SRC + 'globalkey/client/*.java']),
-  gwt_xml = SRC + 'globalkey/GlobalKey.gwt.xml',
-  resources = [
-    SRC + 'globalkey/client/KeyConstants.properties',
-    SRC + 'globalkey/client/key.css',
-  ],
-  provided_deps = ['//lib/gwt:user'],
-  deps = [
-    ':SafeHtml',
-    ':UserAgent',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'linker_server',
-  srcs = glob([SRC + 'linker/server/*.java']),
-  provided_deps = ['//lib:servlet-api-3_1'],
-  visibility = ['PUBLIC'],
-)
-
-gwt_module(
-  name = 'Progress',
-  srcs = glob([SRC + 'progress/client/*.java']),
-  gwt_xml = SRC + 'progress/Progress.gwt.xml',
-  resources = [SRC + 'progress/client/progress.css'],
-  provided_deps = ['//lib/gwt:user'],
-  visibility = ['PUBLIC'],
-)
-
-gwt_module(
-  name = 'SafeHtml',
-  srcs = glob([SRC + 'safehtml/client/*.java']),
-  gwt_xml = SRC + 'safehtml/SafeHtml.gwt.xml',
-  resources = [SRC + 'safehtml/client/safehtml.css'],
-  provided_deps = ['//lib/gwt:user'],
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'SafeHtml_tests',
-  srcs = glob([
-    'src/test/java/com/google/gwtexpui/safehtml/client/**/*.java',
-  ]),
-  deps = [
-    ':SafeHtml',
-    '//lib:truth',
-    '//lib/gwt:user',
-    '//lib/gwt:dev',
-  ],
-  source_under_test = [':SafeHtml'],
-)
-
-gwt_module(
-  name = 'UserAgent',
-  srcs = glob([SRC + 'user/client/*.java']),
-  gwt_xml = SRC + 'user/User.gwt.xml',
-  resources = [SRC + 'user/client/tooltip.css'],
-  provided_deps = ['//lib/gwt:user'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'server',
-  srcs = glob([SRC + 'server/*.java']),
-  provided_deps = ['//lib:servlet-api-3_1'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'client-src-lib',
-  srcs = [],
-  resources = glob(
-    [SRC + n for n in [
-      'clippy/**/*',
-      'globalkey/**/*',
-      'safehtml/**/*',
-      'user/**/*',
-    ]]
-  ),
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-gwtexpui/BUILD b/gerrit-gwtexpui/BUILD
index d3b03ef..186f5d2 100644
--- a/gerrit-gwtexpui/BUILD
+++ b/gerrit-gwtexpui/BUILD
@@ -1,114 +1,119 @@
-load('//tools/bzl:gwt.bzl', 'gwt_module')
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:gwt.bzl", "gwt_module")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
-SRC = 'src/main/java/com/google/gwtexpui/'
+SRC = "src/main/java/com/google/gwtexpui/"
 
 gwt_module(
-  name = 'Clippy',
-  srcs = glob([SRC + 'clippy/client/*.java']),
-  gwt_xml = SRC + 'clippy/Clippy.gwt.xml',
-  resources = [
-    SRC + 'clippy/client/clippy.css',
-    SRC + 'clippy/client/clippy.swf',
-    SRC + 'clippy/client/page_white_copy.png',
-    SRC + 'clippy/client/CopyableLabelText.properties',
-  ],
-  deps = [
-    ':SafeHtml',
-    ':UserAgent',
-    '//lib/gwt:user',
-  ],
-  visibility = ['//visibility:public'],
+    name = "Clippy",
+    srcs = glob([SRC + "clippy/client/*.java"]),
+    data = [
+        "//lib:LICENSE-clippy",
+        "//lib:LICENSE-silk_icons",
+    ],
+    gwt_xml = SRC + "clippy/Clippy.gwt.xml",
+    resources = [
+        SRC + "clippy/client/clippy.css",
+        SRC + "clippy/client/clippy.swf",
+        SRC + "clippy/client/page_white_copy.png",
+        SRC + "clippy/client/CopyableLabelText.properties",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":SafeHtml",
+        ":UserAgent",
+        "//lib/gwt:user-neverlink",
+    ],
 )
 
 java_library(
-  name = 'CSS',
-  srcs = glob([SRC + 'css/rebind/*.java']),
-  resources = [SRC + 'css/CSS.gwt.xml'],
-  deps = ['//lib/gwt:dev'],
-  visibility = ['//visibility:public'],
+    name = "CSS",
+    srcs = glob([SRC + "css/rebind/*.java"]),
+    resources = [SRC + "css/CSS.gwt.xml"],
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:dev"],
 )
 
 gwt_module(
-  name = 'GlobalKey',
-  srcs = glob([SRC + 'globalkey/client/*.java']),
-  gwt_xml = SRC + 'globalkey/GlobalKey.gwt.xml',
-  resources = [
-    SRC + 'globalkey/client/KeyConstants.properties',
-    SRC + 'globalkey/client/key.css',
-  ],
-  deps = [
-    ':SafeHtml',
-    ':UserAgent',
-    '//lib/gwt:user',
-  ],
-  visibility = ['//visibility:public'],
+    name = "GlobalKey",
+    srcs = glob([SRC + "globalkey/client/*.java"]),
+    gwt_xml = SRC + "globalkey/GlobalKey.gwt.xml",
+    resources = [
+        SRC + "globalkey/client/KeyConstants.properties",
+        SRC + "globalkey/client/key.css",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":SafeHtml",
+        ":UserAgent",
+        "//lib/gwt:user",
+    ],
 )
 
 java_library(
-  name = 'linker_server',
-  srcs = glob([SRC + 'linker/server/*.java']),
-  deps = ['//lib:servlet-api-3_1'],
-  visibility = ['//visibility:public'],
+    name = "linker_server",
+    srcs = glob([SRC + "linker/server/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = ["//lib:servlet-api-3_1"],
 )
 
 gwt_module(
-  name = 'Progress',
-  srcs = glob([SRC + 'progress/client/*.java']),
-  gwt_xml = SRC + 'progress/Progress.gwt.xml',
-  resources = [SRC + 'progress/client/progress.css'],
-  deps = ['//lib/gwt:user'],
-  visibility = ['//visibility:public'],
+    name = "Progress",
+    srcs = glob([SRC + "progress/client/*.java"]),
+    gwt_xml = SRC + "progress/Progress.gwt.xml",
+    resources = [SRC + "progress/client/progress.css"],
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:user"],
 )
 
 gwt_module(
-  name = 'SafeHtml',
-  srcs = glob([SRC + 'safehtml/client/*.java']),
-  gwt_xml = SRC + 'safehtml/SafeHtml.gwt.xml',
-  resources = [SRC + 'safehtml/client/safehtml.css'],
-  deps = ['//lib/gwt:user'],
-  visibility = ['//visibility:public'],
+    name = "SafeHtml",
+    srcs = glob([SRC + "safehtml/client/*.java"]),
+    gwt_xml = SRC + "safehtml/SafeHtml.gwt.xml",
+    resources = [SRC + "safehtml/client/safehtml.css"],
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:user"],
 )
 
 junit_tests(
-  name = 'SafeHtml_tests',
-  srcs = glob([
-    'src/test/java/com/google/gwtexpui/safehtml/client/**/*.java',
-  ]),
-  deps = [
-    ':SafeHtml',
-    '//lib:truth',
-    '//lib/gwt:user',
-    '//lib/gwt:dev',
-  ],
+    name = "SafeHtml_tests",
+    srcs = glob([
+        "src/test/java/com/google/gwtexpui/safehtml/client/**/*.java",
+    ]),
+    deps = [
+        ":SafeHtml",
+        "//lib:truth",
+        "//lib/gwt:dev",
+        "//lib/gwt:user",
+    ],
 )
 
 gwt_module(
-  name = 'UserAgent',
-  srcs = glob([SRC + 'user/client/*.java']),
-  gwt_xml = SRC + 'user/User.gwt.xml',
-  resources = [SRC + 'user/client/tooltip.css'],
-  deps = ['//lib/gwt:user'],
-  visibility = ['//visibility:public'],
+    name = "UserAgent",
+    srcs = glob([SRC + "user/client/*.java"]),
+    gwt_xml = SRC + "user/User.gwt.xml",
+    resources = [SRC + "user/client/tooltip.css"],
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:user"],
 )
 
 java_library(
-  name = 'server',
-  srcs = glob([SRC + 'server/*.java']),
-  deps = ['//lib:servlet-api-3_1'],
-  visibility = ['//visibility:public'],
+    name = "server",
+    srcs = glob([SRC + "server/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = ["//lib:servlet-api-3_1"],
 )
 
 java_library(
-  name = 'client-src-lib',
-  srcs = [],
-  resources = glob(
-    [SRC + n for n in [
-      'clippy/**/*',
-      'globalkey/**/*',
-      'safehtml/**/*',
-      'user/**/*',
-    ]]
-  ),
-  visibility = ['//visibility:public'],
+    name = "client-src-lib",
+    srcs = [],
+    resources = glob(
+        [SRC + n for n in [
+            "clippy/**/*",
+            "globalkey/**/*",
+            "safehtml/**/*",
+            "user/**/*",
+        ]],
+    ),
+    visibility = ["//visibility:public"],
 )
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyCss.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyCss.java
index 05a1861..0d340ff 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyCss.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyCss.java
@@ -18,6 +18,8 @@
 
 public interface ClippyCss extends CssResource {
   String label();
+
   String copier();
+
   String swf();
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
index 8d54b2f..2d0f833 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
@@ -44,11 +44,10 @@
 
 /**
  * Label which permits the user to easily copy the complete content.
- * <p>
- * If the Flash plugin is available a "movie" is embedded that provides
- * one-click copying of the content onto the system clipboard. The label (if
- * visible) can also be clicked, switching from a label to an input box,
- * allowing the user to copy the text with a keyboard shortcut.
+ *
+ * <p>If the Flash plugin is available a "movie" is embedded that provides one-click copying of the
+ * content onto the system clipboard. The label (if visible) can also be clicked, switching from a
+ * label to an input box, allowing the user to copy the text with a keyboard shortcut.
  */
 public class CopyableLabel extends Composite implements HasText {
   private static final int SWF_WIDTH = 110;
@@ -96,8 +95,8 @@
    * Create a new label
    *
    * @param str initial content
-   * @param showLabel if true, the content is shown, if false it is hidden from
-   *        view and only the copy icon is displayed.
+   * @param showLabel if true, the content is shown, if false it is hidden from view and only the
+   *     copy icon is displayed.
    */
   public CopyableLabel(final String str, final boolean showLabel) {
     content = new FlowPanel();
@@ -109,37 +108,42 @@
     if (showLabel) {
       textLabel = new InlineLabel(getText());
       textLabel.setStyleName(ClippyResources.I.css().label());
-      textLabel.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(final ClickEvent event) {
-          showTextBox();
-        }
-      });
+      textLabel.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(final ClickEvent event) {
+              showTextBox();
+            }
+          });
       content.add(textLabel);
     }
 
     if (UserAgent.hasJavaScriptClipboard()) {
-      copier = new Button(new SafeHtmlBuilder()
-          .openElement("img")
-          .setAttribute("src", ClippyResources.I.clipboard().getSafeUri().asString())
-          .setWidth(14)
-          .setHeight(14)
-          .closeSelf());
+      copier =
+          new Button(
+              new SafeHtmlBuilder()
+                  .openElement("img")
+                  .setAttribute("src", ClippyResources.I.clipboard().getSafeUri().asString())
+                  .setWidth(14)
+                  .setHeight(14)
+                  .closeSelf());
       copier.setStyleName(ClippyResources.I.css().copier());
       Tooltip.addStyle(copier);
       Tooltip.setLabel(copier, CopyableLabelText.I.tooltip());
-      copier.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          copy();
-        }
-      });
-      copier.addMouseOutHandler(new MouseOutHandler() {
-        @Override
-        public void onMouseOut(MouseOutEvent event) {
-          Tooltip.setLabel(copier, CopyableLabelText.I.tooltip());
-        }
-      });
+      copier.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(ClickEvent event) {
+              copy();
+            }
+          });
+      copier.addMouseOutHandler(
+          new MouseOutHandler() {
+            @Override
+            public void onMouseOut(MouseOutEvent event) {
+              Tooltip.setLabel(copier, CopyableLabelText.I.tooltip());
+            }
+          });
 
       FlowPanel p = new FlowPanel();
       p.getElement().getStyle().setDisplay(Display.INLINE_BLOCK);
@@ -153,8 +157,8 @@
   /**
    * Change the text which is displayed in the clickable label.
    *
-   * @param text the new preview text, should be shorter than the original text
-   *        which would be copied to the clipboard.
+   * @param text the new preview text, should be shorter than the original text which would be
+   *     copied to the clipboard.
    */
   public void setPreviewText(final String text) {
     if (textLabel != null) {
@@ -163,8 +167,7 @@
   }
 
   private void embedMovie() {
-    if (copier == null && flashEnabled && !text.isEmpty()
-        && UserAgent.Flash.isInstalled()) {
+    if (copier == null && flashEnabled && !text.isEmpty() && UserAgent.Flash.isInstalled()) {
       final String flashVars = "text=" + URL.encodeQueryString(getText());
       final SafeHtmlBuilder h = new SafeHtmlBuilder();
 
@@ -223,47 +226,54 @@
       textBox.setText(getText());
       textBox.setVisibleLength(visibleLen);
       textBox.setReadOnly(true);
-      textBox.addKeyPressHandler(new KeyPressHandler() {
-        @Override
-        public void onKeyPress(final KeyPressEvent event) {
-          if (event.isControlKeyDown() || event.isMetaKeyDown()) {
-            switch (event.getCharCode()) {
-              case 'c':
-              case 'x':
-                textBox.addKeyUpHandler(new KeyUpHandler() {
-                  @Override
-                  public void onKeyUp(final KeyUpEvent event) {
-                    Scheduler.get().scheduleDeferred(new Command() {
-                      @Override
-                      public void execute() {
-                        hideTextBox();
-                      }
-                    });
-                  }
-                });
-                break;
+      textBox.addKeyPressHandler(
+          new KeyPressHandler() {
+            @Override
+            public void onKeyPress(final KeyPressEvent event) {
+              if (event.isControlKeyDown() || event.isMetaKeyDown()) {
+                switch (event.getCharCode()) {
+                  case 'c':
+                  case 'x':
+                    textBox.addKeyUpHandler(
+                        new KeyUpHandler() {
+                          @Override
+                          public void onKeyUp(final KeyUpEvent event) {
+                            Scheduler.get()
+                                .scheduleDeferred(
+                                    new Command() {
+                                      @Override
+                                      public void execute() {
+                                        hideTextBox();
+                                      }
+                                    });
+                          }
+                        });
+                    break;
+                }
+              }
             }
-          }
-        }
-      });
-      textBox.addBlurHandler(new BlurHandler() {
-        @Override
-        public void onBlur(final BlurEvent event) {
-          hideTextBox();
-        }
-      });
+          });
+      textBox.addBlurHandler(
+          new BlurHandler() {
+            @Override
+            public void onBlur(final BlurEvent event) {
+              hideTextBox();
+            }
+          });
       content.insert(textBox, 1);
     }
 
     textLabel.setVisible(false);
     textBox.setVisible(true);
-    Scheduler.get().scheduleDeferred(new Command() {
-      @Override
-      public void execute() {
-        textBox.selectAll();
-        textBox.setFocus(true);
-      }
-    });
+    Scheduler.get()
+        .scheduleDeferred(
+            new Command() {
+              @Override
+              public void execute() {
+                textBox.selectAll();
+                textBox.setFocus(true);
+              }
+            });
   }
 
   private void hideTextBox() {
@@ -283,9 +293,7 @@
       t.selectAll();
 
       boolean ok = execCommand("copy");
-      Tooltip.setLabel(copier, ok
-          ? CopyableLabelText.I.copied()
-          : CopyableLabelText.I.failed());
+      Tooltip.setLabel(copier, ok ? CopyableLabelText.I.copied() : CopyableLabelText.I.failed());
       if (!ok) {
         // Disable JavaScript clipboard and try flash movie in another instance.
         UserAgent.disableJavaScriptClipboard();
@@ -303,6 +311,5 @@
     }
   }
 
-  private static native boolean nativeExec(String c)
-  /*-{ return !! $doc.execCommand(c) }-*/;
+  private static native boolean nativeExec(String c) /*-{ return !! $doc.execCommand(c) }-*/;
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
index 8e4d090..ff36541 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabelText.java
@@ -21,6 +21,8 @@
   CopyableLabelText I = GWT.create(CopyableLabelText.class);
 
   String tooltip();
+
   String copied();
+
   String failed();
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java
index 753d25f..1066dd4 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java
@@ -25,7 +25,6 @@
 import com.google.gwt.core.ext.linker.impl.StandardLinkerContext;
 import com.google.gwt.core.ext.linker.impl.StandardStylesheetReference;
 import com.google.gwt.dev.util.Util;
-
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -39,19 +38,19 @@
   }
 
   @Override
-  public ArtifactSet link(final TreeLogger logger, final LinkerContext context,
-      final ArtifactSet artifacts) throws UnableToCompleteException {
+  public ArtifactSet link(
+      final TreeLogger logger, final LinkerContext context, final ArtifactSet artifacts)
+      throws UnableToCompleteException {
     final ArtifactSet returnTo = new ArtifactSet();
     int index = 0;
 
     final HashMap<String, PublicResource> css = new HashMap<>();
 
-    for (final StandardStylesheetReference ssr : artifacts
-        .<StandardStylesheetReference> find(StandardStylesheetReference.class)) {
+    for (final StandardStylesheetReference ssr :
+        artifacts.<StandardStylesheetReference>find(StandardStylesheetReference.class)) {
       css.put(ssr.getSrc(), null);
     }
-    for (final PublicResource pr : artifacts
-        .<PublicResource> find(PublicResource.class)) {
+    for (final PublicResource pr : artifacts.<PublicResource>find(PublicResource.class)) {
       if (css.containsKey(pr.getPartialPath())) {
         css.put(pr.getPartialPath(), new CssPubRsrc(name(logger, pr), pr));
       }
@@ -112,8 +111,7 @@
     }
 
     @Override
-    public InputStream getContents(final TreeLogger logger)
-        throws UnableToCompleteException {
+    public InputStream getContents(final TreeLogger logger) throws UnableToCompleteException {
       return src.getContents(logger);
     }
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/DocWidget.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/DocWidget.java
index c59a4ea..320010e 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/DocWidget.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/DocWidget.java
@@ -27,8 +27,7 @@
 import com.google.gwt.user.client.ui.RootPanel;
 import com.google.gwt.user.client.ui.Widget;
 
-public class DocWidget extends Widget
-    implements HasKeyPressHandlers, HasMouseMoveHandlers {
+public class DocWidget extends Widget implements HasKeyPressHandlers, HasMouseMoveHandlers {
   private static DocWidget me;
 
   public static DocWidget get() {
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
index daf5d61..3961313 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
@@ -26,14 +26,14 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 import com.google.gwt.user.client.ui.Widget;
 
-
 public class GlobalKey {
-  public static final KeyPressHandler STOP_PROPAGATION = new KeyPressHandler() {
-    @Override
-    public void onKeyPress(final KeyPressEvent event) {
-      event.stopPropagation();
-    }
-  };
+  public static final KeyPressHandler STOP_PROPAGATION =
+      new KeyPressHandler() {
+        @Override
+        public void onKeyPress(final KeyPressEvent event) {
+          event.stopPropagation();
+        }
+      };
 
   private static State global;
   static State active;
@@ -46,24 +46,27 @@
 
   private static void initEvents() {
     if (active == null) {
-      DocWidget.get().addKeyPressHandler(new KeyPressHandler() {
-        @Override
-        public void onKeyPress(final KeyPressEvent event) {
-          final KeyCommandSet s = active.live;
-          if (s != active.all) {
-            active.live = active.all;
-            restoreTimer.cancel();
-          }
-          s.onKeyPress(event);
-        }
-      });
+      DocWidget.get()
+          .addKeyPressHandler(
+              new KeyPressHandler() {
+                @Override
+                public void onKeyPress(final KeyPressEvent event) {
+                  final KeyCommandSet s = active.live;
+                  if (s != active.all) {
+                    active.live = active.all;
+                    restoreTimer.cancel();
+                  }
+                  s.onKeyPress(event);
+                }
+              });
 
-      restoreTimer = new Timer() {
-        @Override
-        public void run() {
-          active.live = active.all;
-        }
-      };
+      restoreTimer =
+          new Timer() {
+            @Override
+            public void run() {
+              active.live = active.all;
+            }
+          };
 
       global = new State(null);
       active = global;
@@ -72,12 +75,13 @@
 
   private static void initDialog() {
     if (restoreGlobal == null) {
-      restoreGlobal = new CloseHandler<PopupPanel>() {
-        @Override
-        public void onClose(final CloseEvent<PopupPanel> event) {
-          active = global;
-        }
-      };
+      restoreGlobal =
+          new CloseHandler<PopupPanel>() {
+            @Override
+            public void onClose(final CloseEvent<PopupPanel> event) {
+              active = global;
+            }
+          };
     }
   }
 
@@ -94,18 +98,19 @@
     active = new State(panel);
     active.add(new HidePopupPanelCommand(0, KeyCodes.KEY_ESCAPE, panel));
     panel.addCloseHandler(restoreGlobal);
-    panel.addDomHandler(new KeyDownHandler() {
-      @Override
-      public void onKeyDown(KeyDownEvent event) {
-        if (event.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
-          panel.hide();
-        }
-      }
-    }, KeyDownEvent.getType());
+    panel.addDomHandler(
+        new KeyDownHandler() {
+          @Override
+          public void onKeyDown(KeyDownEvent event) {
+            if (event.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
+              panel.hide();
+            }
+          }
+        },
+        KeyDownEvent.getType());
   }
 
-  public static HandlerRegistration addApplication(final Widget widget,
-      final KeyCommand appKey) {
+  public static HandlerRegistration addApplication(final Widget widget, final KeyCommand appKey) {
     initEvents();
     final State state = stateFor(widget);
     state.add(appKey);
@@ -117,8 +122,7 @@
     };
   }
 
-  public static HandlerRegistration add(final Widget widget,
-      final KeyCommandSet cmdSet) {
+  public static HandlerRegistration add(final Widget widget, final KeyCommandSet cmdSet) {
     initEvents();
     final State state = stateFor(widget);
     state.add(cmdSet);
@@ -147,8 +151,7 @@
     }
   }
 
-  private GlobalKey() {
-  }
+  private GlobalKey() {}
 
   static class State {
     final Widget root;
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
index a0fee2b..2e9b652 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
@@ -19,7 +19,6 @@
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
 
-
 public abstract class KeyCommand implements KeyPressHandler {
   public static final int M_CTRL = 1 << 16;
   public static final int M_ALT = 2 << 16;
@@ -27,9 +26,7 @@
   public static final int M_SHIFT = 8 << 16;
 
   public static boolean same(final KeyCommand a, final KeyCommand b) {
-    return a.getClass() == b.getClass()
-        && a.helpText.equals(b.helpText)
-        && a.sibling == b.sibling;
+    return a.getClass() == b.getClass() && a.helpText.equals(b.helpText) && a.sibling == b.sibling;
   }
 
   final int keyMask;
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
index e0a18aa..734dd4e 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
@@ -16,7 +16,6 @@
 
 import com.google.gwt.event.dom.client.KeyPressEvent;
 import com.google.gwt.event.dom.client.KeyPressHandler;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -65,8 +64,7 @@
 
   public void add(final KeyCommand k) {
     assert !map.containsKey(k.keyMask)
-         : "Key " + k.describeKeyStroke().asString()
-         + " already registered";
+        : "Key " + k.describeKeyStroke().asString() + " already registered";
     if (!map.containsKey(k.keyMask)) {
       map.put(k.keyMask, k);
     }
@@ -103,7 +101,7 @@
         s.filter(filter);
       }
     }
-    for (final Iterator<KeyCommand> i = map.values().iterator(); i.hasNext();) {
+    for (final Iterator<KeyCommand> i = map.values().iterator(); i.hasNext(); ) {
       final KeyCommand kc = i.next();
       if (!filter.include(kc)) {
         i.remove();
@@ -118,7 +116,7 @@
   }
 
   public Collection<KeyCommandSet> getSets() {
-    return sets != null ? sets : Collections.<KeyCommandSet> emptyList();
+    return sets != null ? sets : Collections.<KeyCommandSet>emptyList();
   }
 
   @Override
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
index 2b5984d..209b170 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyConstants.java
@@ -21,20 +21,32 @@
   KeyConstants I = GWT.create(KeyConstants.class);
 
   String applicationSection();
+
   String showHelp();
+
   String closeCurrentDialog();
 
   String keyboardShortcuts();
+
   String closeButton();
+
   String orOtherKey();
+
   String thenOtherKey();
 
   String keyCtrl();
+
   String keyAlt();
+
   String keyMeta();
+
   String keyShift();
+
   String keyEnter();
+
   String keyEsc();
+
   String keyLeft();
+
   String keyRight();
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCss.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCss.java
index d19018d..658af57 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCss.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCss.java
@@ -18,12 +18,20 @@
 
 public interface KeyCss extends CssResource {
   String helpPopup();
+
   String helpHeader();
+
   String helpHeaderGlue();
+
   String helpTable();
+
   String helpTableGlue();
+
   String helpGroup();
+
   String helpKeyStroke();
+
   String helpSeparator();
+
   String helpKey();
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
index a85d704..0ec9d10 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
@@ -31,7 +31,6 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -41,22 +40,21 @@
 import java.util.List;
 import java.util.Map;
 
-
-public class KeyHelpPopup extends PopupPanel implements
-    KeyPressHandler, KeyDownHandler {
+public class KeyHelpPopup extends PopupPanel implements KeyPressHandler, KeyDownHandler {
   private final FocusPanel focus;
 
   public KeyHelpPopup() {
-    super(true/* autohide */, true/* modal */);
+    super(true /* autohide */, true /* modal */);
     setStyleName(KeyResources.I.css().helpPopup());
 
     final Anchor closer = new Anchor(KeyConstants.I.closeButton());
-    closer.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        hide();
-      }
-    });
+    closer.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            hide();
+          }
+        });
 
     final Grid header = new Grid(1, 3);
     header.setStyleName(KeyResources.I.css().helpHeader());
@@ -70,8 +68,7 @@
     final Grid lists = new Grid(0, 7);
     lists.setStyleName(KeyResources.I.css().helpTable());
     populate(lists);
-    lists.getCellFormatter().addStyleName(0, 3,
-        KeyResources.I.css().helpTableGlue());
+    lists.getCellFormatter().addStyleName(0, 3, KeyResources.I.css().helpTableGlue());
 
     final FlowPanel body = new FlowPanel();
     body.add(header);
@@ -129,8 +126,8 @@
   }
 
   /**
-   * @return an ordered collection of KeyCommandSet, combining sets which share
-   *         the same name, so that each set name appears at most once.
+   * @return an ordered collection of KeyCommandSet, combining sets which share the same name, so
+   *     that each set name appears at most once.
    */
   private static Collection<KeyCommandSet> combinedSetsByName() {
     LinkedHashMap<String, KeyCommandSet> byName = new LinkedHashMap<>();
@@ -145,8 +142,7 @@
     return byName.values();
   }
 
-  private int formatGroup(final Grid lists, int row, final int col,
-      final KeyCommandSet set) {
+  private int formatGroup(final Grid lists, int row, final int col, final KeyCommandSet set) {
     if (set.isEmpty()) {
       return row;
     }
@@ -155,15 +151,14 @@
       lists.resizeRows(row + 1);
     }
     lists.setText(row, col + 2, set.getName());
-    lists.getCellFormatter().addStyleName(row, col + 2,
-        KeyResources.I.css().helpGroup());
+    lists.getCellFormatter().addStyleName(row, col + 2, KeyResources.I.css().helpGroup());
     row++;
 
     return formatKeys(lists, row, col, set, null);
   }
 
-  private int formatKeys(final Grid lists, int row, final int col,
-      final KeyCommandSet set, final SafeHtml prefix) {
+  private int formatKeys(
+      final Grid lists, int row, final int col, final KeyCommandSet set, final SafeHtml prefix) {
     final CellFormatter fmt = lists.getCellFormatter();
     final List<KeyCommand> keys = sort(set);
     if (lists.getRowCount() < row + keys.size()) {
@@ -171,7 +166,8 @@
     }
 
     Map<KeyCommand, Integer> rows = new HashMap<>();
-    FORMAT_KEYS: for (int i = 0; i < keys.size(); i++) {
+    FORMAT_KEYS:
+    for (int i = 0; i < keys.size(); i++) {
       final KeyCommand k = keys.get(i);
       if (rows.containsKey(k)) {
         continue;
@@ -234,12 +230,14 @@
 
   private List<KeyCommand> sort(final KeyCommandSet set) {
     final List<KeyCommand> keys = new ArrayList<>(set.getKeys());
-    Collections.sort(keys, new Comparator<KeyCommand>() {
-      @Override
-      public int compare(KeyCommand arg0, KeyCommand arg1) {
-        return arg0.getHelpText().compareTo(arg1.getHelpText());
-      }
-    });
+    Collections.sort(
+        keys,
+        new Comparator<KeyCommand>() {
+          @Override
+          public int compare(KeyCommand arg0, KeyCommand arg1) {
+            return arg0.getHelpText().compareTo(arg1.getHelpText());
+          }
+        });
     return keys;
   }
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
index b7ea69f..c2272c5 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
@@ -49,22 +49,24 @@
     }
 
     final KeyHelpPopup help = new KeyHelpPopup();
-    help.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(final CloseEvent<PopupPanel> event) {
-        current = null;
-        BUS.fireEvent(new FocusEvent() {});
-      }
-    });
+    help.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(final CloseEvent<PopupPanel> event) {
+            current = null;
+            BUS.fireEvent(new FocusEvent() {});
+          }
+        });
     current = help;
-    help.setPopupPositionAndShow(new PositionCallback() {
-      @Override
-      public void setPosition(final int pWidth, final int pHeight) {
-        final int left = (Window.getClientWidth() - pWidth) >> 1;
-        final int wLeft = Window.getScrollLeft();
-        final int wTop = Window.getScrollTop();
-        help.setPopupPosition(wLeft + left, wTop + 50);
-      }
-    });
+    help.setPopupPositionAndShow(
+        new PositionCallback() {
+          @Override
+          public void setPosition(final int pWidth, final int pHeight) {
+            final int left = (Window.getClientWidth() - pWidth) >> 1;
+            final int wLeft = Window.getScrollLeft();
+            final int wTop = Window.getScrollTop();
+            help.setPopupPosition(wLeft + left, wTop + 50);
+          }
+        });
   }
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
index a33f605..8f7bede 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
@@ -18,16 +18,15 @@
 
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-
 import javax.servlet.http.HttpServletRequest;
 
 /**
  * Selects the value for the {@code user.agent} property.
- * <p>
- * Examines the {@code User-Agent} HTTP request header, and tries to match it to
- * known {@code user.agent} values.
- * <p>
- * Ported from JavaScript in {@code com.google.gwt.user.UserAgent.gwt.xml}.
+ *
+ * <p>Examines the {@code User-Agent} HTTP request header, and tries to match it to known {@code
+ * user.agent} values.
+ *
+ * <p>Ported from JavaScript in {@code com.google.gwt.user.UserAgent.gwt.xml}.
  */
 public class UserAgentRule {
   private static final Pattern msie = compile(".*msie ([0-11]+)\\.([0-11]+).*");
@@ -89,7 +88,6 @@
   }
 
   private int makeVersion(Matcher result) {
-    return (Integer.parseInt(result.group(1)) * 1000)
-        + Integer.parseInt(result.group(2));
+    return (Integer.parseInt(result.group(1)) * 1000) + Integer.parseInt(result.group(2));
   }
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java
index 931e84e..bc18323 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java
@@ -20,10 +20,10 @@
 
 /**
  * A simple progress bar with a text label.
- * <p>
- * The bar is 200 pixels wide and 20 pixels high. To keep the implementation
- * simple and lightweight this dimensions are fixed and shouldn't be modified by
- * style overrides in client code or CSS.
+ *
+ * <p>The bar is 200 pixels wide and 20 pixels high. To keep the implementation simple and
+ * lightweight this dimensions are fixed and shouldn't be modified by style overrides in client code
+ * or CSS.
  */
 public class ProgressBar extends Composite {
   static {
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressCss.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressCss.java
index 9de2748..ec27490 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressCss.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressCss.java
@@ -18,6 +18,8 @@
 
 public interface ProgressCss extends CssResource {
   String container();
+
   String text();
+
   String bar();
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java
index a84835e..eb141f15 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/AttMap.java
@@ -21,6 +21,7 @@
 class AttMap {
   private static final Tag ANY = new AnyTag();
   private static final HashMap<String, Tag> TAGS;
+
   static {
     final Tag src = new SrcTag();
     TAGS = new HashMap<>();
@@ -108,8 +109,7 @@
 
   private static class AnyTag implements Tag {
     @Override
-    public void assertSafe(String name, String value) {
-    }
+    public void assertSafe(String name, String value) {}
   }
 
   private static class AnchorTag implements Tag {
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
index 487e613..4fb3246 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/FindReplace.java
@@ -18,19 +18,15 @@
 
 /** A Find/Replace pair used against the {@link SafeHtml} block of text. */
 public interface FindReplace {
-  /**
-   * @return regular expression to match substrings with; should be treated as
-   *     immutable.
-   */
+  /** @return regular expression to match substrings with; should be treated as immutable. */
   RegExp pattern();
 
   /**
    * Find and replace a single instance of this pattern in an input.
-   * <p>
-   * <b>WARNING:</b> No XSS sanitization is done on the return value of this
-   * method, e.g. this value may be passed directly to
-   * {@link SafeHtml#replaceAll(String, String)}. Implementations must sanitize output
-   * appropriately.
+   *
+   * <p><b>WARNING:</b> No XSS sanitization is done on the return value of this method, e.g. this
+   * value may be passed directly to {@link SafeHtml#replaceAll(String, String)}. Implementations
+   * must sanitize output appropriately.
    *
    * @param input input string.
    * @return result of regular expression replacement.
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
index cf5a445..25cad1d 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
@@ -15,7 +15,6 @@
 package com.google.gwtexpui.safehtml.client;
 
 import com.google.gwt.user.client.ui.SuggestOracle;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -24,13 +23,11 @@
 
 /**
  * A suggestion oracle that tries to highlight the matched text.
- * <p>
- * Suggestions supplied by the implementation of
- * {@link #onRequestSuggestions(Request, Callback)} are modified to wrap all
- * occurrences of the
- * {@link com.google.gwt.user.client.ui.SuggestOracle.Request#getQuery()}
- * substring in HTML {@code &lt;strong&gt;} tags, so they can be emphasized to
- * the user.
+ *
+ * <p>Suggestions supplied by the implementation of {@link #onRequestSuggestions(Request, Callback)}
+ * are modified to wrap all occurrences of the {@link
+ * com.google.gwt.user.client.ui.SuggestOracle.Request#getQuery()} substring in HTML {@code
+ * &lt;strong&gt;} tags, so they can be emphasized to the user.
  */
 public abstract class HighlightSuggestOracle extends SuggestOracle {
   private static String escape(String ds) {
@@ -43,20 +40,21 @@
   }
 
   @Override
-  public final void requestSuggestions(final Request request, final Callback cb) {
-    onRequestSuggestions(request, new Callback() {
-      @Override
-      public void onSuggestionsReady(final Request request,
-          final Response response) {
-        final String qpat = getQueryPattern(request.getQuery());
-        final boolean html = isHTML();
-        final ArrayList<Suggestion> r = new ArrayList<>();
-        for (final Suggestion s : response.getSuggestions()) {
-          r.add(new BoldSuggestion(qpat, s, html));
-        }
-        cb.onSuggestionsReady(request, new Response(r));
-      }
-    });
+  public final void requestSuggestions(Request request, Callback cb) {
+    onRequestSuggestions(
+        request,
+        new Callback() {
+          @Override
+          public void onSuggestionsReady(final Request request, final Response response) {
+            final String qpat = getQueryPattern(request.getQuery());
+            final boolean html = isHTML();
+            final ArrayList<Suggestion> r = new ArrayList<>();
+            for (final Suggestion s : response.getSuggestions()) {
+              r.add(new BoldSuggestion(qpat, s, html));
+            }
+            cb.onSuggestionsReady(request, new Response(r));
+          }
+        });
   }
 
   protected String getQueryPattern(final String query) {
@@ -64,10 +62,9 @@
   }
 
   /**
-   * @return true if
-   *         {@link com.google.gwt.user.client.ui.SuggestOracle.Suggestion#getDisplayString()}
-   *         returns HTML; false if the text must be escaped before evaluating
-   *         in an HTML like context.
+   * @return true if {@link
+   *     com.google.gwt.user.client.ui.SuggestOracle.Suggestion#getDisplayString()} returns HTML;
+   *     false if the text must be escaped before evaluating in an HTML like context.
    */
   protected boolean isHTML() {
     return false;
@@ -88,42 +85,45 @@
         ds = escape(ds);
       }
 
-      StringBuilder pattern = new StringBuilder();
-      for (String qterm : splitQuery(qstr)) {
-        qterm = escape(qterm);
-        // We now surround qstr by <strong>. But the chosen approach is not too
-        // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
-        // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
-        // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
-        // as repairing those mangled escapes is easier than not mangling them in
-        // the first place, we repair them afterwards.
-
-        if (pattern.length() > 0) {
-          pattern.append("|");
+      if (qstr != null && !qstr.isEmpty()) {
+        StringBuilder pattern = new StringBuilder();
+        for (String qterm : splitQuery(qstr)) {
+          qterm = escape(qterm);
+          // We now surround qstr by <strong>. But the chosen approach is not too
+          // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
+          // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
+          // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
+          // as repairing those mangled escapes is easier than not mangling them in
+          // the first place, we repair them afterwards.
+          if (pattern.length() > 0) {
+            pattern.append("|");
+          }
+          pattern.append(qterm);
         }
-        pattern.append(qterm);
+
+        ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
+
+        // Repairing <strong>-ed escapes.
+        ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
       }
 
-      ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
-
-      // Repairing <strong>-ed escapes.
-      ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
-
       displayString = ds;
     }
 
     /**
-     * Split the query by whitespace and filter out query terms which are
-     * substrings of other query terms.
+     * Split the query by whitespace and filter out query terms which are substrings of other query
+     * terms.
      */
     private static List<String> splitQuery(String query) {
       List<String> queryTerms = Arrays.asList(query.split("\\s+"));
-      Collections.sort(queryTerms, new Comparator<String>() {
-        @Override
-        public int compare(String s1, String s2) {
-          return Integer.compare(s2.length(), s1.length());
-        }
-      });
+      Collections.sort(
+          queryTerms,
+          new Comparator<String>() {
+            @Override
+            public int compare(String s1, String s2) {
+              return Integer.compare(s2.length(), s1.length());
+            }
+          });
 
       List<String> result = new ArrayList<>();
       for (String s : queryTerms) {
@@ -142,7 +142,7 @@
     }
 
     private static native String sgi(String inString, String pat, String newHtml)
-    /*-{ return inString.replace(RegExp(pat, 'gi'), newHtml); }-*/;
+        /*-{ return inString.replace(RegExp(pat, 'gi'), newHtml); }-*/ ;
 
     @Override
     public String getDisplayString() {
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java
index 464cbe9..cf0e51d 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/LinkFindReplace.java
@@ -18,14 +18,15 @@
 
 /**
  * A Find/Replace pair whose replacement string is a link.
- * <p>
- * It is safe to pass arbitrary user-provided links to this class. Links are
- * sanitized as follows:
+ *
+ * <p>It is safe to pass arbitrary user-provided links to this class. Links are sanitized as
+ * follows:
+ *
  * <ul>
- * <li>Only http(s) and mailto links are supported; any other scheme results in
- * an {@link IllegalArgumentException} from {@link #replace(String)}.
- * <li>Special characters in the link after regex replacement are escaped with
- * {@link SafeHtmlBuilder}.</li>
+ *   <li>Only http(s) and mailto links are supported; any other scheme results in an {@link
+ *       IllegalArgumentException} from {@link #replace(String)}.
+ *   <li>Special characters in the link after regex replacement are escaped with {@link
+ *       SafeHtmlBuilder}.
  * </ul>
  */
 public class LinkFindReplace implements FindReplace {
@@ -43,13 +44,12 @@
   private RegExp pat;
   private String link;
 
-  protected LinkFindReplace() {
-  }
+  protected LinkFindReplace() {}
 
   /**
    * @param find regular expression pattern to match substrings with.
-   * @param link replacement link href. Capture groups within
-   *        {@code find} can be referenced with {@code $<i>n</i>}.
+   * @param link replacement link href. Capture groups within {@code find} can be referenced with
+   *     {@code $<i>n</i>}.
    */
   public LinkFindReplace(String find, String link) {
     this.pat = RegExp.compile(find);
@@ -65,8 +65,7 @@
   public String replace(String input) {
     String href = pat.replace(input, link);
     if (!hasValidScheme(href)) {
-      throw new IllegalArgumentException(
-          "Invalid scheme (" + toString() + "): " + href);
+      throw new IllegalArgumentException("Invalid scheme (" + toString() + "): " + href);
     }
     return new SafeHtmlBuilder()
         .openAnchor()
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java
index 96026ad..dc39af6 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/RawFindReplace.java
@@ -18,20 +18,19 @@
 
 /**
  * A Find/Replace pair whose replacement string is arbitrary HTML.
- * <p>
- * <b>WARNING:</b> This class is not safe used with user-provided patterns.
+ *
+ * <p><b>WARNING:</b> This class is not safe used with user-provided patterns.
  */
 public class RawFindReplace implements FindReplace {
   private RegExp pat;
   private String replace;
 
-  protected RawFindReplace() {
-  }
+  protected RawFindReplace() {}
 
   /**
    * @param find regular expression pattern to match substrings with.
-   * @param replace replacement expression. Capture groups within
-   *        {@code find} can be referenced with {@code $<i>n</i>}.
+   * @param replace replacement expression. Capture groups within {@code find} can be referenced
+   *     with {@code $<i>n</i>}.
    */
   public RawFindReplace(String find, String replace) {
     this.pat = RegExp.compile(find);
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
index 10c2a78..9161652a 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtml.java
@@ -24,14 +24,12 @@
 import com.google.gwt.user.client.ui.HasHTML;
 import com.google.gwt.user.client.ui.InlineHTML;
 import com.google.gwt.user.client.ui.Widget;
-
 import java.util.Iterator;
 import java.util.List;
 
 /** Immutable string safely placed as HTML without further escaping. */
 @SuppressWarnings("serial")
-public abstract class SafeHtml
-    implements com.google.gwt.safehtml.shared.SafeHtml {
+public abstract class SafeHtml implements com.google.gwt.safehtml.shared.SafeHtml {
   public static final SafeHtmlResources RESOURCES;
 
   static {
@@ -40,42 +38,43 @@
       RESOURCES.css().ensureInjected();
 
     } else {
-      RESOURCES = new SafeHtmlResources() {
-        @Override
-        public SafeHtmlCss css() {
-          return new SafeHtmlCss() {
+      RESOURCES =
+          new SafeHtmlResources() {
             @Override
-            public String wikiList() {
-              return "wikiList";
-            }
+            public SafeHtmlCss css() {
+              return new SafeHtmlCss() {
+                @Override
+                public String wikiList() {
+                  return "wikiList";
+                }
 
-            @Override
-            public String wikiPreFormat() {
-              return "wikiPreFormat";
-            }
+                @Override
+                public String wikiPreFormat() {
+                  return "wikiPreFormat";
+                }
 
-            @Override
-            public String wikiQuote() {
-              return "wikiQuote";
-            }
+                @Override
+                public String wikiQuote() {
+                  return "wikiQuote";
+                }
 
-            @Override
-            public boolean ensureInjected() {
-              return false;
-            }
+                @Override
+                public boolean ensureInjected() {
+                  return false;
+                }
 
-            @Override
-            public String getName() {
-              return null;
-            }
+                @Override
+                public String getName() {
+                  return null;
+                }
 
-            @Override
-            public String getText() {
-              return null;
+                @Override
+                public String getText() {
+                  return null;
+                }
+              };
             }
           };
-        }
-      };
     }
   }
 
@@ -112,8 +111,8 @@
   }
 
   /** Set the inner HTML of a table cell. */
-  public static <T extends HTMLTable> T set(final T t, final int row,
-      final int col, final SafeHtml str) {
+  public static <T extends HTMLTable> T set(
+      final T t, final int row, final int col, final SafeHtml str) {
     t.setHTML(row, col, str.asString());
     return t;
   }
@@ -127,25 +126,17 @@
 
   /** Convert bare http:// and https:// URLs into &lt;a href&gt; tags. */
   public SafeHtml linkify() {
-    final String part = "(?:" +
-    "[a-zA-Z0-9$_+!*'%;:@=?#/~-]" +
-    "|&(?!lt;|gt;)" +
-    "|[.,](?!(?:\\s|$))" +
-    ")";
+    final String part = "(?:[a-zA-Z0-9$_+!*'%;:@=?#/~-]|&(?!lt;|gt;)|[.,](?!(?:\\s|$)))";
     return replaceAll(
-        "(https?://" +
-          part + "{2,}" +
-          "(?:[(]" + part + "*" + "[)])*" +
-          part + "*" +
-        ")",
+        "(https?://" + part + "{2,}(?:[(]" + part + "*[)])*" + part + "*)",
         "<a href=\"$1\" target=\"_blank\" rel=\"nofollow\">$1</a>");
   }
 
   /**
    * Apply {@link #linkify()}, and "\n\n" to &lt;p&gt;.
-   * <p>
-   * Lines that start with whitespace are assumed to be preformatted, and are
-   * formatted by the {@link SafeHtmlCss#wikiPreFormat()} CSS class.
+   *
+   * <p>Lines that start with whitespace are assumed to be preformatted, and are formatted by the
+   * {@link SafeHtmlCss#wikiPreFormat()} CSS class.
    */
   public SafeHtml wikify() {
     final SafeHtmlBuilder r = new SafeHtmlBuilder();
@@ -242,25 +233,23 @@
   }
 
   private static boolean isPreFormat(final String p) {
-    return p.contains("\n ") || p.contains("\n\t") || p.startsWith(" ")
-        || p.startsWith("\t");
+    return p.contains("\n ") || p.contains("\n\t") || p.startsWith(" ") || p.startsWith("\t");
   }
 
   private static boolean isList(final String p) {
-    return p.contains("\n- ") || p.contains("\n* ") || p.startsWith("- ")
-        || p.startsWith("* ");
+    return p.contains("\n- ") || p.contains("\n* ") || p.startsWith("- ") || p.startsWith("* ");
   }
 
   /**
    * Replace first occurrence of {@code regex} with {@code repl} .
-   * <p>
-   * <b>WARNING:</b> This replacement is being performed against an otherwise
-   * safe HTML string. The caller must ensure that the replacement does not
-   * introduce cross-site scripting attack entry points.
+   *
+   * <p><b>WARNING:</b> This replacement is being performed against an otherwise safe HTML string.
+   * The caller must ensure that the replacement does not introduce cross-site scripting attack
+   * entry points.
    *
    * @param regex regular expression pattern to match the substring with.
-   * @param repl replacement expression. Capture groups within
-   *        {@code regex} can be referenced with {@code $<i>n</i>}.
+   * @param repl replacement expression. Capture groups within {@code regex} can be referenced with
+   *     {@code $<i>n</i>}.
    * @return a new string, after the replacement has been made.
    */
   public SafeHtml replaceFirst(final String regex, final String repl) {
@@ -269,14 +258,14 @@
 
   /**
    * Replace each occurrence of {@code regex} with {@code repl} .
-   * <p>
-   * <b>WARNING:</b> This replacement is being performed against an otherwise
-   * safe HTML string. The caller must ensure that the replacement does not
-   * introduce cross-site scripting attack entry points.
+   *
+   * <p><b>WARNING:</b> This replacement is being performed against an otherwise safe HTML string.
+   * The caller must ensure that the replacement does not introduce cross-site scripting attack
+   * entry points.
    *
    * @param regex regular expression pattern to match substrings with.
-   * @param repl replacement expression. Capture groups within
-   *        {@code regex} can be referenced with {@code $<i>n</i>}.
+   * @param repl replacement expression. Capture groups within {@code regex} can be referenced with
+   *     {@code $<i>n</i>}.
    * @return a new string, after the replacements have been made.
    */
   public SafeHtml replaceAll(final String regex, final String repl) {
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
index 0e7f7eb..f54149b 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
@@ -16,9 +16,7 @@
 
 import com.google.gwt.core.client.GWT;
 
-/**
- * Safely constructs a {@link SafeHtml}, escaping user provided content.
- */
+/** Safely constructs a {@link SafeHtml}, escaping user provided content. */
 @SuppressWarnings("serial")
 public class SafeHtmlBuilder extends SafeHtml {
   private static final Impl impl;
@@ -163,10 +161,9 @@
 
   /**
    * Open an element, appending "{@code <tagName>}" to the buffer.
-   * <p>
-   * After the element is open the attributes may be manipulated until the next
-   * {@code append}, {@code openElement}, {@code closeSelf} or
-   * {@code closeElement} call.
+   *
+   * <p>After the element is open the attributes may be manipulated until the next {@code append},
+   * {@code openElement}, {@code closeSelf} or {@code closeElement} call.
    *
    * @param tagName name of the HTML element to open.
    */
@@ -187,9 +184,8 @@
    * Get an attribute of the last opened element.
    *
    * @param name name of the attribute to read.
-   * @return the attribute value, as a string. The empty string if the attribute
-   *         has not been assigned a value. The returned string is the raw
-   *         (unescaped) value.
+   * @return the attribute value, as a string. The empty string if the attribute has not been
+   *     assigned a value. The returned string is the raw (unescaped) value.
    */
   public String getAttribute(final String name) {
     assert isAttributeName(name);
@@ -201,8 +197,8 @@
    * Set an attribute of the last opened element.
    *
    * @param name name of the attribute to set.
-   * @param value value to assign; any existing value is replaced. The value is
-   *        escaped (if necessary) during the assignment.
+   * @param value value to assign; any existing value is replaced. The value is escaped (if
+   *     necessary) during the assignment.
    */
   public SafeHtmlBuilder setAttribute(final String name, final String value) {
     assert isAttributeName(name);
@@ -223,10 +219,10 @@
 
   /**
    * Append a new value into a whitespace delimited attribute.
-   * <p>
-   * If the attribute is not yet assigned, this method sets the attribute. If
-   * the attribute is already assigned, the new value is appended onto the end,
-   * after appending a single space to delimit the values.
+   *
+   * <p>If the attribute is not yet assigned, this method sets the attribute. If the attribute is
+   * already assigned, the new value is appended onto the end, after appending a single space to
+   * delimit the values.
    *
    * @param name name of the attribute to append onto.
    * @param value additional value to append.
@@ -257,9 +253,8 @@
 
   /**
    * Add an additional CSS class name to this element.
-   *<p>
-   * If no CSS class name has been specified yet, this method initializes it to
-   * the single name.
+   *
+   * <p>If no CSS class name has been specified yet, this method initializes it to the single name.
    */
   public SafeHtmlBuilder addStyleName(final String style) {
     assert isCssName(style);
@@ -419,8 +414,7 @@
       b.cb.append(escape(in));
     }
 
-    private static native String escape(String src)
-    /*-{ return src.replace(/&/g,'&amp;')
+    private static native String escape(String src) /*-{ return src.replace(/&/g,'&amp;')
                    .replace(/>/g,'&gt;')
                    .replace(/</g,'&lt;')
                    .replace(/"/g,'&quot;')
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java
index 651ebaf..f4b1c77 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlCss.java
@@ -18,6 +18,8 @@
 
 public interface SafeHtmlCss extends CssResource {
   String wikiList();
+
   String wikiPreFormat();
+
   String wikiQuote();
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java
index a438caf..4e39c1f 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java
@@ -16,7 +16,6 @@
 
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -28,14 +27,13 @@
 
 /**
  * Forces GWT resources to cache for a very long time.
- * <p>
- * GWT compiled JavaScript and ImageBundles can be cached indefinitely by a
- * browser and/or an edge proxy, as they never contain user-specific data and
- * are named by a unique checksum. If their content is ever modified then the
- * URL changes, so user agents would request a different resource. We force
- * these resources to have very long expiration times.
- * <p>
- * To use, add the following block to your {@code web.xml}:
+ *
+ * <p>GWT compiled JavaScript and ImageBundles can be cached indefinitely by a browser and/or an
+ * edge proxy, as they never contain user-specific data and are named by a unique checksum. If their
+ * content is ever modified then the URL changes, so user agents would request a different resource.
+ * We force these resources to have very long expiration times.
+ *
+ * <p>To use, add the following block to your {@code web.xml}:
  *
  * <pre>
  * &lt;filter&gt;
@@ -50,16 +48,15 @@
  */
 public class CacheControlFilter implements Filter {
   @Override
-  public void init(final FilterConfig config) {
-  }
+  public void init(final FilterConfig config) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(final ServletRequest sreq, final ServletResponse srsp,
-      final FilterChain chain) throws IOException, ServletException {
+  public void doFilter(
+      final ServletRequest sreq, final ServletResponse srsp, final FilterChain chain)
+      throws IOException, ServletException {
     final HttpServletRequest req = (HttpServletRequest) sreq;
     final HttpServletResponse rsp = (HttpServletResponse) srsp;
     final String pathInfo = pathInfo(req);
@@ -73,8 +70,7 @@
     chain.doFilter(req, rsp);
   }
 
-  private static boolean cacheForever(final String pathInfo,
-      final HttpServletRequest req) {
+  private static boolean cacheForever(final String pathInfo, final HttpServletRequest req) {
     if (pathInfo.endsWith(".cache.html")
         || pathInfo.endsWith(".cache.gif")
         || pathInfo.endsWith(".cache.png")
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
index d94f243..0e5e425 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheHeaders.java
@@ -18,7 +18,6 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import java.util.concurrent.TimeUnit;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
@@ -41,15 +40,14 @@
 
   /**
    * Permit caching the response for up to the age specified.
-   * <p>
-   * If the request is on a secure connection (e.g. SSL) private caching is
-   * used. This allows the user-agent to cache the response, but requests
-   * intermediate proxies to not cache. This may offer better protection for
-   * Set-Cookie headers.
-   * <p>
-   * If the request is on plaintext (insecure), public caching is used. This may
-   * allow an intermediate proxy to cache the response, including any Set-Cookie
-   * header that may have also been included.
+   *
+   * <p>If the request is on a secure connection (e.g. SSL) private caching is used. This allows the
+   * user-agent to cache the response, but requests intermediate proxies to not cache. This may
+   * offer better protection for Set-Cookie headers.
+   *
+   * <p>If the request is on plaintext (insecure), public caching is used. This may allow an
+   * intermediate proxy to cache the response, including any Set-Cookie header that may have also
+   * been included.
    *
    * @param req current request.
    * @param res response being returned.
@@ -57,22 +55,20 @@
    * @param unit time unit for age, usually {@link TimeUnit#SECONDS}.
    */
   public static void setCacheable(
-      HttpServletRequest req, HttpServletResponse res,
-      long age, TimeUnit unit) {
+      HttpServletRequest req, HttpServletResponse res, long age, TimeUnit unit) {
     setCacheable(req, res, age, unit, false);
   }
 
   /**
    * Permit caching the response for up to the age specified.
-   * <p>
-   * If the request is on a secure connection (e.g. SSL) private caching is
-   * used. This allows the user-agent to cache the response, but requests
-   * intermediate proxies to not cache. This may offer better protection for
-   * Set-Cookie headers.
-   * <p>
-   * If the request is on plaintext (insecure), public caching is used. This may
-   * allow an intermediate proxy to cache the response, including any Set-Cookie
-   * header that may have also been included.
+   *
+   * <p>If the request is on a secure connection (e.g. SSL) private caching is used. This allows the
+   * user-agent to cache the response, but requests intermediate proxies to not cache. This may
+   * offer better protection for Set-Cookie headers.
+   *
+   * <p>If the request is on plaintext (insecure), public caching is used. This may allow an
+   * intermediate proxy to cache the response, including any Set-Cookie header that may have also
+   * been included.
    *
    * @param req current request.
    * @param res response being returned.
@@ -81,8 +77,11 @@
    * @param mustRevalidate true if the client must validate the cached entity.
    */
   public static void setCacheable(
-      HttpServletRequest req, HttpServletResponse res,
-      long age, TimeUnit unit, boolean mustRevalidate) {
+      HttpServletRequest req,
+      HttpServletResponse res,
+      long age,
+      TimeUnit unit,
+      boolean mustRevalidate) {
     if (req.isSecure()) {
       setCacheablePrivate(res, age, unit, mustRevalidate);
     } else {
@@ -92,18 +91,18 @@
 
   /**
    * Allow the response to be cached by proxies and user-agents.
-   * <p>
-   * If the response includes a Set-Cookie header the cookie may be cached by a
-   * proxy and returned to multiple browsers behind the same proxy. This is
-   * insecure for authenticated connections.
+   *
+   * <p>If the response includes a Set-Cookie header the cookie may be cached by a proxy and
+   * returned to multiple browsers behind the same proxy. This is insecure for authenticated
+   * connections.
    *
    * @param res response being returned.
    * @param age how long the response can be cached.
    * @param unit time unit for age, usually {@link TimeUnit#SECONDS}.
    * @param mustRevalidate true if the client must validate the cached entity.
    */
-  public static void setCacheablePublic(HttpServletResponse res,
-      long age, TimeUnit unit, boolean mustRevalidate) {
+  public static void setCacheablePublic(
+      HttpServletResponse res, long age, TimeUnit unit, boolean mustRevalidate) {
     long now = System.currentTimeMillis();
     long sec = maxAgeSeconds(age, unit);
 
@@ -120,8 +119,8 @@
    * @param unit time unit for age, usually {@link TimeUnit#SECONDS}.
    * @param mustRevalidate true if the client must validate the cached entity.
    */
-  public static void setCacheablePrivate(HttpServletResponse res,
-      long age, TimeUnit unit, boolean mustRevalidate) {
+  public static void setCacheablePrivate(
+      HttpServletResponse res, long age, TimeUnit unit, boolean mustRevalidate) {
     long now = System.currentTimeMillis();
     res.setDateHeader("Expires", now);
     res.setDateHeader("Date", now);
@@ -129,22 +128,21 @@
   }
 
   public static boolean hasCacheHeader(HttpServletResponse res) {
-    return res.containsHeader("Cache-Control")
-        || res.containsHeader("Expires");
+    return res.containsHeader("Cache-Control") || res.containsHeader("Expires");
   }
 
-  private static void cache(HttpServletResponse res,
-      String type, long age, TimeUnit unit, boolean revalidate) {
-    res.setHeader("Cache-Control", String.format(
-        "%s, max-age=%d%s",
-        type, maxAgeSeconds(age, unit),
-        revalidate ? ", must-revalidate" : ""));
+  private static void cache(
+      HttpServletResponse res, String type, long age, TimeUnit unit, boolean revalidate) {
+    res.setHeader(
+        "Cache-Control",
+        String.format(
+            "%s, max-age=%d%s",
+            type, maxAgeSeconds(age, unit), revalidate ? ", must-revalidate" : ""));
   }
 
   private static long maxAgeSeconds(long age, TimeUnit unit) {
     return Math.min(unit.toSeconds(age), MAX_CACHE_DURATION);
   }
 
-  private CacheHeaders() {
-  }
+  private CacheHeaders() {}
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
index 54d8eca..7c165e5 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
@@ -39,14 +39,16 @@
   @Override
   public void show() {
     if (recenter == null) {
-      recenter = Window.addResizeHandler(new ResizeHandler() {
-        @Override
-        public void onResize(final ResizeEvent event) {
-          final int w = event.getWidth();
-          final int h = event.getHeight();
-          AutoCenterDialogBox.this.onResize(w, h);
-        }
-      });
+      recenter =
+          Window.addResizeHandler(
+              new ResizeHandler() {
+                @Override
+                public void onResize(final ResizeEvent event) {
+                  final int w = event.getWidth();
+                  final int h = event.getHeight();
+                  AutoCenterDialogBox.this.onResize(w, h);
+                }
+              });
     }
     super.show();
   }
@@ -62,9 +64,9 @@
 
   /**
    * Invoked when the outer browser window resizes.
-   * <p>
-   * Subclasses may override (but should ensure they still call super.onResize)
-   * to implement custom logic when a window resize occurs.
+   *
+   * <p>Subclasses may override (but should ensure they still call super.onResize) to implement
+   * custom logic when a window resize occurs.
    *
    * @param width new browser window width
    * @param height new browser window height
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java
index 7939697..b5ce046 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/Tooltip.java
@@ -62,7 +62,7 @@
    * @param text message to display on hover.
    */
   public static void setLabel(UIObject o, String text) {
-   setLabel(o.getElement(), text);
+    setLabel(o.getElement(), text);
   }
 
   /**
@@ -75,6 +75,5 @@
     e.setAttribute("aria-label", text != null ? text : "");
   }
 
-  private Tooltip() {
-  }
+  private Tooltip() {}
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java
index bbef449..1660a62 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/UserAgent.java
@@ -19,12 +19,12 @@
 
 /**
  * User agent feature tests we don't create permutations for.
- * <p>
- * Some features aren't worth creating full permutations in GWT for, as each new
- * boolean permutation (only two settings) doubles the compile time required. If
- * the setting only affects a couple of lines of JavaScript code, the slightly
- * larger cache files for user agents that lack the functionality requested is
- * trivial compared to the time developers lose building their application.
+ *
+ * <p>Some features aren't worth creating full permutations in GWT for, as each new boolean
+ * permutation (only two settings) doubles the compile time required. If the setting only affects a
+ * couple of lines of JavaScript code, the slightly larger cache files for user agents that lack the
+ * functionality requested is trivial compared to the time developers lose building their
+ * application.
  */
 public class UserAgent {
   private static boolean jsClip = guessJavaScriptClipboard();
@@ -38,7 +38,7 @@
   }
 
   private static native boolean nativeHasCopy()
-  /*-{ return $doc['queryCommandSupported'] && $doc.queryCommandSupported('copy') }-*/;
+      /*-{ return $doc['queryCommandSupported'] && $doc.queryCommandSupported('copy') }-*/ ;
 
   private static boolean guessJavaScriptClipboard() {
     String ua = Window.Navigator.getUserAgent();
@@ -93,9 +93,9 @@
 
     /**
      * Does the browser have ShockwaveFlash plugin installed?
-     * <p>
-     * This method may still return true if the user has disabled Flash or set
-     * the plugin to "click to run".
+     *
+     * <p>This method may still return true if the user has disabled Flash or set the plugin to
+     * "click to run".
      */
     public static boolean isInstalled() {
       if (!checked) {
@@ -105,8 +105,7 @@
       return installed;
     }
 
-    private static native boolean hasFlash()
-    /*-{
+    private static native boolean hasFlash() /*-{
       if (navigator.plugins && navigator.plugins.length) {
         if (navigator.plugins['Shockwave Flash'])     return true;
         if (navigator.plugins['Shockwave Flash 2.0']) return true;
@@ -126,18 +125,17 @@
 
   /**
    * Test for and disallow running this application in an &lt;iframe&gt;.
-   * <p>
-   * If the application is running within an iframe this method requests a
-   * browser generated redirect to pop the application out of the iframe into
-   * the top level window, and then aborts execution by throwing an exception.
-   * This is call should be placed early within the module's onLoad() method,
-   * before any real UI can be initialized that an attacking site could try to
-   * snip out and present in a confusing context.
-   * <p>
-   * If the break out works, execution will restart automatically in a proper
-   * top level window, where the script has full control over the display. If
-   * the break out fails, execution will abort and stop immediately, preventing
-   * UI widgets from being created, leaving the user with an empty frame.
+   *
+   * <p>If the application is running within an iframe this method requests a browser generated
+   * redirect to pop the application out of the iframe into the top level window, and then aborts
+   * execution by throwing an exception. This is call should be placed early within the module's
+   * onLoad() method, before any real UI can be initialized that an attacking site could try to snip
+   * out and present in a confusing context.
+   *
+   * <p>If the break out works, execution will restart automatically in a proper top level window,
+   * where the script has full control over the display. If the break out fails, execution will
+   * abort and stop immediately, preventing UI widgets from being created, leaving the user with an
+   * empty frame.
    */
   public static void assertNotInIFrame() {
     if (GWT.isScript() && amInsideIFrame()) {
@@ -146,16 +144,13 @@
     }
   }
 
-  private static native boolean amInsideIFrame()
-  /*-{ return top.location != $wnd.location; }-*/;
+  private static native boolean amInsideIFrame() /*-{ return top.location != $wnd.location; }-*/;
 
-  private static native void bustOutOfIFrame(String newloc)
-  /*-{ top.location.href = newloc }-*/;
+  private static native void bustOutOfIFrame(String newloc) /*-{ top.location.href = newloc }-*/;
 
   /**
-   * Test if Gerrit is running on a mobile browser. This check could be
-   * incomplete, but should cover most cases. Regexes shamelessly borrowed from
-   * CodeMirror.
+   * Test if Gerrit is running on a mobile browser. This check could be incomplete, but should cover
+   * most cases. Regexes shamelessly borrowed from CodeMirror.
    */
   public static native boolean isMobile() /*-{
     var ua = $wnd.navigator.userAgent;
@@ -164,13 +159,10 @@
         || /Android|webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(ua);
   }-*/;
 
-  /**
-   * Check if the height of the browser view is greater than its width.
-   */
+  /** Check if the height of the browser view is greater than its width. */
   public static boolean isPortrait() {
     return Window.getClientHeight() > Window.getClientWidth();
   }
 
-  private UserAgent() {
-  }
+  private UserAgent() {}
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/View.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/View.java
index f826fd0..b15d2fd 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/View.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/View.java
@@ -19,13 +19,11 @@
 
 /**
  * Widget to display within a {@link ViewSite}.
- *<p>
- * Implementations must override {@code protected void onLoad()} and
- * arrange for {@link #display()} to be invoked once the DOM within the view is
- * consistent for presentation to the user. Typically this means that the
- * subclass can start RPCs within {@code onLoad()} and then invoke
- * {@code display()} from within the AsyncCallback's
- * {@code onSuccess(Object)} method.
+ *
+ * <p>Implementations must override {@code protected void onLoad()} and arrange for {@link
+ * #display()} to be invoked once the DOM within the view is consistent for presentation to the
+ * user. Typically this means that the subclass can start RPCs within {@code onLoad()} and then
+ * invoke {@code display()} from within the AsyncCallback's {@code onSuccess(Object)} method.
  */
 public abstract class View extends Composite {
   ViewSite<? extends View> site;
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java
index 629b6b3..ca712c3 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java
@@ -20,13 +20,12 @@
 
 /**
  * Hosts a single {@link View}.
- * <p>
- * View instances are attached inside of an invisible DOM node, permitting their
- * {@code onLoad()} method to be invoked and to update the DOM prior to the
- * elements being made visible in the UI.
- * <p>
- * Complaint View instances must invoke {@link View#display()} once the DOM is
- * ready for presentation.
+ *
+ * <p>View instances are attached inside of an invisible DOM node, permitting their {@code onLoad()}
+ * method to be invoked and to update the DOM prior to the elements being made visible in the UI.
+ *
+ * <p>Complaint View instances must invoke {@link View#display()} once the DOM is ready for
+ * presentation.
  */
 public class ViewSite<V extends View> extends Composite {
   private final FlowPanel main;
@@ -46,10 +45,9 @@
 
   /**
    * Set the next view to display.
-   * <p>
-   * The view will be attached to the DOM tree within a hidden container,
-   * permitting its {@code onLoad()} method to execute and update the DOM
-   * without the user seeing the result.
+   *
+   * <p>The view will be attached to the DOM tree within a hidden container, permitting its {@code
+   * onLoad()} method to execute and update the DOM without the user seeing the result.
    *
    * @param view the next view to display.
    */
@@ -69,8 +67,7 @@
    *
    * @param view the view being displayed.
    */
-  protected void onShowView(final V view) {
-  }
+  protected void onShowView(final V view) {}
 
   @SuppressWarnings("unchecked")
   final void swap(final View v) {
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java
index 554315e..a77c5b4 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/LinkFindReplaceTest.java
@@ -15,18 +15,16 @@
 package com.google.gwtexpui.safehtml.client;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gwtexpui.safehtml.client.LinkFindReplace.hasValidScheme;
 
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
 
 public class LinkFindReplaceTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+  @Rule public ExpectedException exception = ExpectedException.none();
 
   @Test
-  public void testNoEscaping() {
+  public void noEscaping() {
     String find = "find";
     String link = "link";
     LinkFindReplace a = new LinkFindReplace(find, link);
@@ -36,51 +34,47 @@
   }
 
   @Test
-  public void testBackreference() {
-    LinkFindReplace l = new LinkFindReplace(
-        "(bug|issue)\\s*([0-9]+)", "/bug?id=$2");
-    assertThat(l.replace("issue 123"))
-      .isEqualTo("<a href=\"/bug?id=123\">issue 123</a>");
+  public void backreference() {
+    LinkFindReplace l = new LinkFindReplace("(bug|issue)\\s*([0-9]+)", "/bug?id=$2");
+    assertThat(l.replace("issue 123")).isEqualTo("<a href=\"/bug?id=123\">issue 123</a>");
   }
 
   @Test
-  public void testHasValidScheme() {
-    assertThat(hasValidScheme("/absolute/path")).isTrue();
-    assertThat(hasValidScheme("relative/path")).isTrue();
-    assertThat(hasValidScheme("http://url/")).isTrue();
-    assertThat(hasValidScheme("HTTP://url/")).isTrue();
-    assertThat(hasValidScheme("https://url/")).isTrue();
-    assertThat(hasValidScheme("mailto://url/")).isTrue();
-    assertThat(hasValidScheme("ftp://url/")).isFalse();
-    assertThat(hasValidScheme("data:evil")).isFalse();
-    assertThat(hasValidScheme("javascript:alert(1)")).isFalse();
+  public void hasValidScheme() {
+    assertThat(LinkFindReplace.hasValidScheme("/absolute/path")).isTrue();
+    assertThat(LinkFindReplace.hasValidScheme("relative/path")).isTrue();
+    assertThat(LinkFindReplace.hasValidScheme("http://url/")).isTrue();
+    assertThat(LinkFindReplace.hasValidScheme("HTTP://url/")).isTrue();
+    assertThat(LinkFindReplace.hasValidScheme("https://url/")).isTrue();
+    assertThat(LinkFindReplace.hasValidScheme("mailto://url/")).isTrue();
+    assertThat(LinkFindReplace.hasValidScheme("ftp://url/")).isFalse();
+    assertThat(LinkFindReplace.hasValidScheme("data:evil")).isFalse();
+    assertThat(LinkFindReplace.hasValidScheme("javascript:alert(1)")).isFalse();
   }
 
   @Test
-  public void testInvalidSchemeInReplace() {
+  public void invalidSchemeInReplace() {
     exception.expect(IllegalArgumentException.class);
     new LinkFindReplace("find", "javascript:alert(1)").replace("find");
   }
 
   @Test
-  public void testInvalidSchemeWithBackreference() {
+  public void invalidSchemeWithBackreference() {
     exception.expect(IllegalArgumentException.class);
-    new LinkFindReplace(".*(script:[^;]*)", "java$1")
-        .replace("Look at this script: alert(1);");
+    new LinkFindReplace(".*(script:[^;]*)", "java$1").replace("Look at this script: alert(1);");
   }
 
   @Test
-  public void testReplaceEscaping() {
+  public void replaceEscaping() {
     assertThat(new LinkFindReplace("find", "a\"&'<>b").replace("find"))
-      .isEqualTo("<a href=\"a&quot;&amp;&#39;&lt;&gt;b\">find</a>");
+        .isEqualTo("<a href=\"a&quot;&amp;&#39;&lt;&gt;b\">find</a>");
   }
 
   @Test
-  public void testHtmlInFind() {
+  public void htmlInFind() {
     String rawFind = "<b>&quot;bold&quot;</b>";
     LinkFindReplace a = new LinkFindReplace(rawFind, "/bold");
     assertThat(a.pattern().getSource()).isEqualTo(rawFind);
-    assertThat(a.replace(rawFind))
-      .isEqualTo("<a href=\"/bold\">" + rawFind + "</a>");
+    assertThat(a.replace(rawFind)).isEqualTo("<a href=\"/bold\">" + rawFind + "</a>");
   }
 }
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java
index 0f124c0..3b5e769 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/RawFindReplaceTest.java
@@ -20,7 +20,7 @@
 
 public class RawFindReplaceTest {
   @Test
-  public void testFindReplace() {
+  public void findReplace() {
     final String find = "find";
     final String replace = "replace";
     final RawFindReplace a = new RawFindReplace(find, replace);
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
index 0862711..17b0a4d 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
@@ -21,11 +21,10 @@
 import org.junit.rules.ExpectedException;
 
 public class SafeHtmlBuilderTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+  @Rule public ExpectedException exception = ExpectedException.none();
 
   @Test
-  public void testEmpty() {
+  public void empty() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b.isEmpty()).isTrue();
     assertThat(b.hasContent()).isFalse();
@@ -37,7 +36,7 @@
   }
 
   @Test
-  public void testToSafeHtml() {
+  public void toSafeHtml() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     b.append(1);
 
@@ -49,7 +48,7 @@
   }
 
   @Test
-  public void testAppend_boolean() {
+  public void append_boolean() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append(true));
     assertThat(b).isSameAs(b.append(false));
@@ -57,7 +56,7 @@
   }
 
   @Test
-  public void testAppend_char() {
+  public void append_char() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append('a'));
     assertThat(b).isSameAs(b.append('b'));
@@ -65,7 +64,7 @@
   }
 
   @Test
-  public void testAppend_int() {
+  public void append_int() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append(4));
     assertThat(b).isSameAs(b.append(2));
@@ -74,7 +73,7 @@
   }
 
   @Test
-  public void testAppend_long() {
+  public void append_long() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append(4L));
     assertThat(b).isSameAs(b.append(2L));
@@ -82,21 +81,21 @@
   }
 
   @Test
-  public void testAppend_float() {
+  public void append_float() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append(0.0f));
     assertThat(b.asString()).isEqualTo("0.0");
   }
 
   @Test
-  public void testAppend_double() {
+  public void append_double() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append(0.0));
     assertThat(b.asString()).isEqualTo("0.0");
   }
 
   @Test
-  public void testAppend_String() {
+  public void append_String() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append((String) null));
     assertThat(b.asString()).isEmpty();
@@ -106,7 +105,7 @@
   }
 
   @Test
-  public void testAppend_StringBuilder() {
+  public void append_StringBuilder() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append((StringBuilder) null));
     assertThat(b.asString()).isEmpty();
@@ -116,7 +115,7 @@
   }
 
   @Test
-  public void testAppend_StringBuffer() {
+  public void append_StringBuffer() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append((StringBuffer) null));
     assertThat(b.asString()).isEmpty();
@@ -126,21 +125,24 @@
   }
 
   @Test
-  public void testAppend_Object() {
+  public void append_Object() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append((Object) null));
     assertThat(b.asString()).isEmpty();
-    assertThat(b).isSameAs(b.append(new Object() {
-      @Override
-      public String toString() {
-        return "foobar";
-      }
-    }));
+    assertThat(b)
+        .isSameAs(
+            b.append(
+                new Object() {
+                  @Override
+                  public String toString() {
+                    return "foobar";
+                  }
+                }));
     assertThat(b.asString()).isEqualTo("foobar");
   }
 
   @Test
-  public void testAppend_CharSequence() {
+  public void append_CharSequence() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append((CharSequence) null));
     assertThat(b.asString()).isEmpty();
@@ -150,7 +152,7 @@
   }
 
   @Test
-  public void testAppend_SafeHtml() {
+  public void append_SafeHtml() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.append((SafeHtml) null));
     assertThat(b.asString()).isEmpty();
@@ -160,7 +162,7 @@
   }
 
   @Test
-  public void testHtmlSpecialCharacters() {
+  public void htmlSpecialCharacters() {
     assertThat(escape("&")).isEqualTo("&amp;");
     assertThat(escape("<")).isEqualTo("&lt;");
     assertThat(escape(">")).isEqualTo("&gt;");
@@ -178,21 +180,21 @@
   }
 
   @Test
-  public void testEntityNbsp() {
+  public void entityNbsp() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.nbsp());
     assertThat(b.asString()).isEqualTo("&nbsp;");
   }
 
   @Test
-  public void testTagBr() {
+  public void tagBr() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.br());
     assertThat(b.asString()).isEqualTo("<br />");
   }
 
   @Test
-  public void testTagTableTrTd() {
+  public void tagTableTrTd() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.openElement("table"));
     assertThat(b).isSameAs(b.openTr());
@@ -205,7 +207,7 @@
   }
 
   @Test
-  public void testTagDiv() {
+  public void tagDiv() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.openDiv());
     assertThat(b).isSameAs(b.append("d<a>ta"));
@@ -214,7 +216,7 @@
   }
 
   @Test
-  public void testTagAnchor() {
+  public void tagAnchor() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.openAnchor());
 
@@ -234,7 +236,7 @@
   }
 
   @Test
-  public void testTagHeightWidth() {
+  public void tagHeightWidth() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.openElement("img"));
     assertThat(b).isSameAs(b.setHeight(100));
@@ -244,7 +246,7 @@
   }
 
   @Test
-  public void testStyleName() {
+  public void styleName() {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     assertThat(b).isSameAs(b.openSpan());
     assertThat(b).isSameAs(b.setStyleName("foo"));
@@ -255,7 +257,7 @@
   }
 
   @Test
-  public void testRejectJavaScript_AnchorHref() {
+  public void rejectJavaScript_AnchorHref() {
     final String href = "javascript:window.close();";
     exception.expect(RuntimeException.class);
     exception.expectMessage("javascript unsafe in href: " + href);
@@ -263,7 +265,7 @@
   }
 
   @Test
-  public void testRejectJavaScript_ImgSrc() {
+  public void rejectJavaScript_ImgSrc() {
     final String href = "javascript:window.close();";
     exception.expect(RuntimeException.class);
     exception.expectMessage("javascript unsafe in href: " + href);
@@ -271,7 +273,7 @@
   }
 
   @Test
-  public void testRejectJavaScript_FormAction() {
+  public void rejectJavaScript_FormAction() {
     final String href = "javascript:window.close();";
     exception.expect(RuntimeException.class);
     exception.expectMessage("javascript unsafe in href: " + href);
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
index 8fe743e..b42878b 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_LinkifyTest.java
@@ -20,93 +20,102 @@
 
 public class SafeHtml_LinkifyTest {
   @Test
-  public void testLinkify_SimpleHttp1() {
+  public void linkify_SimpleHttp1() {
     final SafeHtml o = html("A http://go.here/ B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/</a> B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/</a> B");
   }
 
   @Test
-  public void testLinkify_SimpleHttps2() {
+  public void linkify_SimpleHttps2() {
     final SafeHtml o = html("A https://go.here/ B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A <a href=\"https://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">https://go.here/</a> B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A <a href=\"https://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">https://go.here/</a> B");
   }
 
   @Test
-  public void testLinkify_Parens1() {
+  public void linkify_Parens1() {
     final SafeHtml o = html("A (http://go.here/) B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A (<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/</a>) B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A (<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/</a>) B");
   }
 
   @Test
-  public void testLinkify_Parens() {
+  public void linkify_Parens() {
     final SafeHtml o = html("A http://go.here/#m() B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/#m()\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/#m()</a> B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A <a href=\"http://go.here/#m()\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/#m()</a> B");
   }
 
   @Test
-  public void testLinkify_AngleBrackets1() {
+  public void linkify_AngleBrackets1() {
     final SafeHtml o = html("A <http://go.here/> B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A &lt;<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/</a>&gt; B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A &lt;<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/</a>&gt; B");
   }
 
   @Test
-  public void testLinkify_TrailingPlainLetter() {
+  public void linkify_TrailingPlainLetter() {
     final SafeHtml o = html("A http://go.here/foo B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/foo\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/foo</a> B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A <a href=\"http://go.here/foo\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/foo</a> B");
   }
 
   @Test
-  public void testLinkify_TrailingDot() {
+  public void linkify_TrailingDot() {
     final SafeHtml o = html("A http://go.here/. B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/</a>. B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/</a>. B");
   }
 
   @Test
-  public void testLinkify_TrailingComma() {
+  public void linkify_TrailingComma() {
     final SafeHtml o = html("A http://go.here/, B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/</a>, B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/</a>, B");
   }
 
   @Test
-  public void testLinkify_TrailingDotDot() {
+  public void linkify_TrailingDotDot() {
     final SafeHtml o = html("A http://go.here/.. B");
     final SafeHtml n = o.linkify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A <a href=\"http://go.here/.\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/.</a>. B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A <a href=\"http://go.here/.\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/.</a>. B");
   }
 
   private static SafeHtml html(String text) {
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java
index 0401c9e..ac0f6fd6 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_ReplaceTest.java
@@ -16,71 +16,66 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import org.junit.Test;
-
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import org.junit.Test;
 
 public class SafeHtml_ReplaceTest {
   @Test
-  public void testReplaceEmpty() {
+  public void replaceEmpty() {
     SafeHtml o = html("A\nissue42\nB");
     assertThat(o.replaceAll(null)).isSameAs(o);
-    assertThat(o.replaceAll(Collections.<FindReplace> emptyList())).isSameAs(o);
+    assertThat(o.replaceAll(Collections.<FindReplace>emptyList())).isSameAs(o);
   }
 
   @Test
-  public void testReplaceOneLink() {
+  public void replaceOneLink() {
     SafeHtml o = html("A\nissue 42\nB");
-    SafeHtml n = o.replaceAll(repls(
-        new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
+    SafeHtml n =
+        o.replaceAll(repls(new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A\n<a href=\"?42\">issue 42</a>\nB");
+    assertThat(n.asString()).isEqualTo("A\n<a href=\"?42\">issue 42</a>\nB");
   }
 
   @Test
-  public void testReplaceNoLeadingOrTrailingText() {
+  public void replaceNoLeadingOrTrailingText() {
     SafeHtml o = html("issue 42");
-    SafeHtml n = o.replaceAll(repls(
-        new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
+    SafeHtml n =
+        o.replaceAll(repls(new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<a href=\"?42\">issue 42</a>");
+    assertThat(n.asString()).isEqualTo("<a href=\"?42\">issue 42</a>");
   }
 
   @Test
-  public void testReplaceTwoLinks() {
+  public void replaceTwoLinks() {
     SafeHtml o = html("A\nissue 42\nissue 9918\nB");
-    SafeHtml n = o.replaceAll(repls(
-        new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
+    SafeHtml n =
+        o.replaceAll(repls(new RawFindReplace("(issue\\s(\\d+))", "<a href=\"?$2\">$1</a>")));
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A\n"
-        + "<a href=\"?42\">issue 42</a>\n"
-        + "<a href=\"?9918\">issue 9918</a>\n"
-        + "B");
+    assertThat(n.asString())
+        .isEqualTo("A\n<a href=\"?42\">issue 42</a>\n<a href=\"?9918\">issue 9918</a>\nB");
   }
 
   @Test
-  public void testReplaceInOrder() {
+  public void replaceInOrder() {
     SafeHtml o = html("A\nissue 42\nReally GWTEXPUI-9918 is better\nB");
-    SafeHtml n = o.replaceAll(repls(
-        new RawFindReplace("(GWTEXPUI-(\\d+))",
-            "<a href=\"gwtexpui-bug?$2\">$1</a>"),
-        new RawFindReplace("(issue\\s+(\\d+))",
-            "<a href=\"generic-bug?$2\">$1</a>")));
+    SafeHtml n =
+        o.replaceAll(
+            repls(
+                new RawFindReplace("(GWTEXPUI-(\\d+))", "<a href=\"gwtexpui-bug?$2\">$1</a>"),
+                new RawFindReplace("(issue\\s+(\\d+))", "<a href=\"generic-bug?$2\">$1</a>")));
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "A\n"
-        + "<a href=\"generic-bug?42\">issue 42</a>\n"
-        + "Really <a href=\"gwtexpui-bug?9918\">GWTEXPUI-9918</a> is better\n"
-        + "B");
+    assertThat(n.asString())
+        .isEqualTo(
+            "A\n"
+                + "<a href=\"generic-bug?42\">issue 42</a>\n"
+                + "Really <a href=\"gwtexpui-bug?9918\">GWTEXPUI-9918</a> is better\n"
+                + "B");
   }
 
   @Test
-  public void testReplaceOverlappingAfterFirstChar() {
+  public void replaceOverlappingAfterFirstChar() {
     SafeHtml o = html("abcd");
     RawFindReplace ab = new RawFindReplace("ab", "AB");
     RawFindReplace bc = new RawFindReplace("bc", "23");
@@ -92,7 +87,7 @@
   }
 
   @Test
-  public void testReplaceOverlappingAtFirstCharLongestMatch() {
+  public void replaceOverlappingAtFirstCharLongestMatch() {
     SafeHtml o = html("abcd");
     RawFindReplace ab = new RawFindReplace("ab", "AB");
     RawFindReplace abc = new RawFindReplace("[^d][^d][^d]", "234");
@@ -102,7 +97,7 @@
   }
 
   @Test
-  public void testReplaceOverlappingAtFirstCharFirstMatch() {
+  public void replaceOverlappingAtFirstCharFirstMatch() {
     SafeHtml o = html("abcd");
     RawFindReplace ab1 = new RawFindReplace("ab", "AB");
     RawFindReplace ab2 = new RawFindReplace("[^cd][^cd]", "12");
@@ -112,7 +107,7 @@
   }
 
   @Test
-  public void testFailedSanitization() {
+  public void failedSanitization() {
     SafeHtml o = html("abcd");
     LinkFindReplace evil = new LinkFindReplace("(b)", "javascript:alert('$1')");
     LinkFindReplace ok = new LinkFindReplace("(b)", "/$1");
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java
index 9a7108d..d69b36c 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyListTest.java
@@ -27,114 +27,96 @@
   }
 
   @Test
-  public void testBulletList1() {
+  public void bulletList1() {
     final SafeHtml o = html("A\n\n* line 1\n* 2nd line");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A</p>"
-        + BEGIN_LIST
-        + item("line 1")
-        + item("2nd line")
-        + END_LIST);
+    assertThat(n.asString())
+        .isEqualTo("<p>A</p>" + BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST);
   }
 
   @Test
-  public void testBulletList2() {
+  public void bulletList2() {
     final SafeHtml o = html("A\n\n* line 1\n* 2nd line\n\nB");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A</p>"
-        + BEGIN_LIST
-        + item("line 1")
-        + item("2nd line")
-        + END_LIST
-        + "<p>B</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A</p>" + BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST + "<p>B</p>");
   }
 
   @Test
-  public void testBulletList3() {
+  public void bulletList3() {
     final SafeHtml o = html("* line 1\n* 2nd line\n\nB");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        BEGIN_LIST
-        + item("line 1")
-        + item("2nd line")
-        + END_LIST
-        + "<p>B</p>");
+    assertThat(n.asString())
+        .isEqualTo(BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST + "<p>B</p>");
   }
 
   @Test
-  public void testBulletList4() {
-    final SafeHtml o = html("To see this bug, you have to:\n" //
-        + "* Be on IMAP or EAS (not on POP)\n"//
-        + "* Be very unlucky\n");
+  public void bulletList4() {
+    final SafeHtml o =
+        html(
+            "To see this bug, you have to:\n" //
+                + "* Be on IMAP or EAS (not on POP)\n" //
+                + "* Be very unlucky\n");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>To see this bug, you have to:</p>"
-        + BEGIN_LIST
-        + item("Be on IMAP or EAS (not on POP)")
-        + item("Be very unlucky")
-        + END_LIST);
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>To see this bug, you have to:</p>"
+                + BEGIN_LIST
+                + item("Be on IMAP or EAS (not on POP)")
+                + item("Be very unlucky")
+                + END_LIST);
   }
 
   @Test
-  public void testBulletList5() {
-    final SafeHtml o = html("To see this bug,\n" //
-        + "you have to:\n" //
-        + "* Be on IMAP or EAS (not on POP)\n"//
-        + "* Be very unlucky\n");
+  public void bulletList5() {
+    final SafeHtml o =
+        html(
+            "To see this bug,\n" //
+                + "you have to:\n" //
+                + "* Be on IMAP or EAS (not on POP)\n" //
+                + "* Be very unlucky\n");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>To see this bug, you have to:</p>"
-        + BEGIN_LIST
-        + item("Be on IMAP or EAS (not on POP)")
-        + item("Be very unlucky")
-        + END_LIST);
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>To see this bug, you have to:</p>"
+                + BEGIN_LIST
+                + item("Be on IMAP or EAS (not on POP)")
+                + item("Be very unlucky")
+                + END_LIST);
   }
 
   @Test
-  public void testDashList1() {
+  public void dashList1() {
     final SafeHtml o = html("A\n\n- line 1\n- 2nd line");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A</p>"
-        + BEGIN_LIST
-        + item("line 1")
-        + item("2nd line")
-        + END_LIST);
+    assertThat(n.asString())
+        .isEqualTo("<p>A</p>" + BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST);
   }
 
   @Test
-  public void testDashList2() {
+  public void dashList2() {
     final SafeHtml o = html("A\n\n- line 1\n- 2nd line\n\nB");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A</p>"
-        + BEGIN_LIST
-        + item("line 1")
-        + item("2nd line")
-        + END_LIST
-        + "<p>B</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A</p>" + BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST + "<p>B</p>");
   }
 
   @Test
-  public void testDashList3() {
+  public void dashList3() {
     final SafeHtml o = html("- line 1\n- 2nd line\n\nB");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        BEGIN_LIST
-        + item("line 1")
-        + item("2nd line")
-        + END_LIST
-        + "<p>B</p>");
+    assertThat(n.asString())
+        .isEqualTo(BEGIN_LIST + item("line 1") + item("2nd line") + END_LIST + "<p>B</p>");
   }
 
   private static SafeHtml html(String text) {
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java
index 8085cac..1346cda 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyPreformatTest.java
@@ -27,59 +27,52 @@
   }
 
   @Test
-  public void testPreformat1() {
+  public void preformat1() {
     final SafeHtml o = html("A\n\n  This is pre\n  formatted");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A</p>"
-        + "<p>"
-        + pre("  This is pre")
-        + pre("  formatted")
-        + "</p>");
+    assertThat(n.asString())
+        .isEqualTo("<p>A</p><p>" + pre("  This is pre") + pre("  formatted") + "</p>");
   }
 
   @Test
-  public void testPreformat2() {
+  public void preformat2() {
     final SafeHtml o = html("A\n\n  This is pre\n  formatted\n\nbut this is not");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A</p>"
-        + "<p>"
-        + pre("  This is pre")
-        + pre("  formatted")
-        + "</p>"
-        + "<p>but this is not</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A</p>"
+                + "<p>"
+                + pre("  This is pre")
+                + pre("  formatted")
+                + "</p>"
+                + "<p>but this is not</p>");
   }
 
   @Test
-  public void testPreformat3() {
+  public void preformat3() {
     final SafeHtml o = html("A\n\n  Q\n    <R>\n  S\n\nB");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A</p>"
-        + "<p>"
-        + pre("  Q")
-        + pre("    &lt;R&gt;")
-        + pre("  S")
-        + "</p>"
-        + "<p>B</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A</p>"
+                + "<p>"
+                + pre("  Q")
+                + pre("    &lt;R&gt;")
+                + pre("  S")
+                + "</p>"
+                + "<p>B</p>");
   }
 
   @Test
-  public void testPreformat4() {
+  public void preformat4() {
     final SafeHtml o = html("  Q\n    <R>\n  S\n\nB");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>"
-        + pre("  Q")
-        + pre("    &lt;R&gt;")
-        + pre("  S")
-        + "</p>"
-        + "<p>B</p>");
+    assertThat(n.asString())
+        .isEqualTo("<p>" + pre("  Q") + pre("    &lt;R&gt;") + pre("  S") + "</p><p>B</p>");
   }
 
   private static SafeHtml html(String text) {
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java
index 766760f..2008447 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyQuoteTest.java
@@ -27,28 +27,24 @@
   }
 
   @Test
-  public void testQuote1() {
+  public void quote1() {
     final SafeHtml o = html("> I'm happy\n > with quotes!\n\nSee above.");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        quote("I&#39;m happy\nwith quotes!")
-        + "<p>See above.</p>");
+    assertThat(n.asString()).isEqualTo(quote("I&#39;m happy\nwith quotes!") + "<p>See above.</p>");
   }
 
   @Test
-  public void testQuote2() {
+  public void quote2() {
     final SafeHtml o = html("See this said:\n\n > a quoted\n > string block\n\nOK?");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>See this said:</p>"
-        + quote("a quoted\nstring block")
-        + "<p>OK?</p>");
+    assertThat(n.asString())
+        .isEqualTo("<p>See this said:</p>" + quote("a quoted\nstring block") + "<p>OK?</p>");
   }
 
   @Test
-  public void testNestedQuotes1() {
+  public void nestedQuotes1() {
     final SafeHtml o = html(" > > prior\n > \n > next\n");
     final SafeHtml n = o.wikify();
     assertThat(n.asString()).isEqualTo(quote(quote("prior") + "next\n"));
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
index 8f6ff8d..166af97 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtml_WikifyTest.java
@@ -20,7 +20,7 @@
 
 public class SafeHtml_WikifyTest {
   @Test
-  public void testWikify_OneLine1() {
+  public void wikify_OneLine1() {
     final SafeHtml o = html("A  B");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
@@ -28,7 +28,7 @@
   }
 
   @Test
-  public void testWikify_OneLine2() {
+  public void wikify_OneLine2() {
     final SafeHtml o = html("A  B\n");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
@@ -36,7 +36,7 @@
   }
 
   @Test
-  public void testWikify_OneParagraph1() {
+  public void wikify_OneParagraph1() {
     final SafeHtml o = html("A\nB");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
@@ -44,7 +44,7 @@
   }
 
   @Test
-  public void testWikify_OneParagraph2() {
+  public void wikify_OneParagraph2() {
     final SafeHtml o = html("A\nB\n");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
@@ -52,7 +52,7 @@
   }
 
   @Test
-  public void testWikify_TwoParagraphs() {
+  public void wikify_TwoParagraphs() {
     final SafeHtml o = html("A\nB\n\nC\nD");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
@@ -60,53 +60,58 @@
   }
 
   @Test
-  public void testLinkify_SimpleHttp1() {
+  public void linkify_SimpleHttp1() {
     final SafeHtml o = html("A http://go.here/ B");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/</a> B</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A <a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/</a> B</p>");
   }
 
   @Test
-  public void testLinkify_SimpleHttps2() {
+  public void linkify_SimpleHttps2() {
     final SafeHtml o = html("A https://go.here/ B");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A <a href=\"https://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">https://go.here/</a> B</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A <a href=\"https://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">https://go.here/</a> B</p>");
   }
 
   @Test
-  public void testLinkify_Parens1() {
+  public void linkify_Parens1() {
     final SafeHtml o = html("A (http://go.here/) B");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A (<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/</a>) B</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A (<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/</a>) B</p>");
   }
 
   @Test
-  public void testLinkify_Parens() {
+  public void linkify_Parens() {
     final SafeHtml o = html("A http://go.here/#m() B");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A <a href=\"http://go.here/#m()\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/#m()</a> B</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A <a href=\"http://go.here/#m()\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/#m()</a> B</p>");
   }
 
   @Test
-  public void testLinkify_AngleBrackets1() {
+  public void linkify_AngleBrackets1() {
     final SafeHtml o = html("A <http://go.here/> B");
     final SafeHtml n = o.wikify();
     assertThat(o).isNotSameAs(n);
-    assertThat(n.asString()).isEqualTo(
-        "<p>A &lt;<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
-        + ">http://go.here/</a>&gt; B</p>");
+    assertThat(n.asString())
+        .isEqualTo(
+            "<p>A &lt;<a href=\"http://go.here/\" target=\"_blank\" rel=\"nofollow\""
+                + ">http://go.here/</a>&gt; B</p>");
   }
 
   private static SafeHtml html(String text) {
diff --git a/gerrit-gwtui-common/BUCK b/gerrit-gwtui-common/BUCK
deleted file mode 100644
index ef78d98..0000000
--- a/gerrit-gwtui-common/BUCK
+++ /dev/null
@@ -1,72 +0,0 @@
-EXPORTED_DEPS = [
-  '//gerrit-common:client',
-  '//gerrit-gwtexpui:Clippy',
-  '//gerrit-gwtexpui:GlobalKey',
-  '//gerrit-gwtexpui:Progress',
-  '//gerrit-gwtexpui:SafeHtml',
-  '//gerrit-gwtexpui:UserAgent',
-]
-DEPS = ['//lib/gwt:user']
-SRC = 'src/main/java/com/google/gerrit/'
-DIFFY = glob(['src/main/resources/com/google/gerrit/client/diffy*.png'])
-
-gwt_module(
-  name = 'client',
-  srcs = glob([SRC + 'client/**/*.java']),
-  gwt_xml = SRC + 'GerritGwtUICommon.gwt.xml',
-  resources = glob(['src/main/**/*']),
-  exported_deps = EXPORTED_DEPS,
-  provided_deps = DEPS,
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'client-lib',
-  srcs = glob(['src/main/**/*.java']),
-  resources = glob(['src/main/**/*']),
-  exported_deps = EXPORTED_DEPS,
-  provided_deps = DEPS,
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'client-src-lib',
-  srcs = [],
-  resources = glob(['src/main/**/*']),
-  visibility = ['PUBLIC'],
-)
-
-prebuilt_jar(
-  name = 'diffy_logo',
-  binary_jar = ':diffy_image_files_ln',
-  deps = [
-    '//lib:LICENSE-diffy',
-    '//lib:LICENSE-CC-BY3.0-unported',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-genrule(
-  name = 'diffy_image_files_ln',
-  cmd = 'ln -s $(location :diffy_image_files) $OUT',
-  out = 'diffy_images.jar',
-)
-
-java_library(
-  name = 'diffy_image_files',
-  resources = DIFFY,
-)
-
-java_test(
-  name = 'client_tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':client',
-    '//lib:junit',
-    '//lib/gwt:user',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-  ],
-  source_under_test = [':client'],
-  vm_args = ['-Xmx512m'],
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-gwtui-common/BUILD b/gerrit-gwtui-common/BUILD
new file mode 100644
index 0000000..c2c9235
--- /dev/null
+++ b/gerrit-gwtui-common/BUILD
@@ -0,0 +1,63 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:gwt.bzl", "gwt_module")
+load("//tools/bzl:java.bzl", "java_library2")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+EXPORTED_DEPS = [
+    "//gerrit-common:client",
+    "//gerrit-gwtexpui:Clippy",
+    "//gerrit-gwtexpui:GlobalKey",
+    "//gerrit-gwtexpui:Progress",
+    "//gerrit-gwtexpui:SafeHtml",
+    "//gerrit-gwtexpui:UserAgent",
+]
+
+DEPS = ["//lib/gwt:user-neverlink"]
+
+SRC = "src/main/java/com/google/gerrit/"
+
+gwt_module(
+    name = "client",
+    srcs = glob(["src/main/**/*.java"]),
+    exported_deps = EXPORTED_DEPS,
+    gwt_xml = SRC + "GerritGwtUICommon.gwt.xml",
+    resources = glob(
+        ["src/main/**/*"],
+        exclude = [SRC + "client/**/*.java"] + [
+            SRC + "GerritGwtUICommon.gwt.xml",
+        ],
+    ),
+    visibility = ["//visibility:public"],
+    deps = DEPS,
+)
+
+java_library2(
+    name = "client-lib",
+    srcs = glob(["src/main/**/*.java"]),
+    exported_deps = EXPORTED_DEPS,
+    resources = glob(["src/main/**/*"]),
+    visibility = ["//visibility:public"],
+    deps = DEPS,
+)
+
+java_library(
+    name = "diffy_logo",
+    data = [
+        "//lib:LICENSE-CC-BY3.0-unported",
+        "//lib:LICENSE-diffy",
+    ],
+    resources = glob(["src/main/resources/com/google/gerrit/client/diffy*.png"]),
+    visibility = ["//visibility:public"],
+)
+
+junit_tests(
+    name = "client_tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":client",
+        "//lib:junit",
+        "//lib/gwt:dev",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/AccountFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/AccountFormatter.java
index 1b41f62..3058971 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/AccountFormatter.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/AccountFormatter.java
@@ -25,13 +25,14 @@
 
   /**
    * Formats an account as a name and an email address.
-   * <p>
-   * Example output:
+   *
+   * <p>Example output:
+   *
    * <ul>
-   * <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated</li>
-   * <li>{@code A U. Thor (12)}: missing email address</li>
-   * <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name</li>
-   * <li>{@code Anonymous Coward (12)}: missing name and email address</li>
+   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
+   *   <li>{@code A U. Thor (12)}: missing email address
+   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
+   *   <li>{@code Anonymous Coward (12)}: missing name and email address
    * </ul>
    */
   public String nameEmail(AccountInfo info) {
@@ -51,9 +52,9 @@
 
   /**
    * Formats an account name.
-   * <p>
-   * If the account has a full name, it returns only the full name. Otherwise it
-   * returns a longer form that includes the email address.
+   *
+   * <p>If the account has a full name, it returns only the full name. Otherwise it returns a longer
+   * form that includes the email address.
    */
   public String name(AccountInfo ai) {
     if (ai.name() != null && !ai.name().trim().isEmpty()) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java
index 5d0b93f..e769730 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonConstants.java
@@ -21,16 +21,26 @@
   CommonConstants C = GWT.create(CommonConstants.class);
 
   String inTheFuture();
+
   String month();
+
   String months();
+
   String year();
+
   String years();
 
   String oneSecondAgo();
+
   String oneMinuteAgo();
+
   String oneHourAgo();
+
   String oneDayAgo();
+
   String oneWeekAgo();
+
   String oneMonthAgo();
+
   String oneYearAgo();
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java
index 5a5b4a3..5314254 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/CommonMessages.java
@@ -21,13 +21,20 @@
   CommonMessages M = GWT.create(CommonMessages.class);
 
   String secondsAgo(long seconds);
+
   String minutesAgo(long minutes);
+
   String hoursAgo(long hours);
+
   String daysAgo(long days);
+
   String weeksAgo(long weeks);
+
   String monthsAgo(long months);
+
   String yearsAgo(long years);
+
   String years0MonthsAgo(long years, String yearLabel);
-  String yearsMonthsAgo(long years, String yearLabel, long months,
-      String monthLabel);
+
+  String yearsMonthsAgo(long years, String yearLabel, long months, String monthLabel);
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java
index b357737..32f79d7 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/DateFormatter.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.client.info.GeneralPreferences;
 import com.google.gwt.i18n.client.DateTimeFormat;
-
 import java.util.Date;
 
 public class DateFormatter {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
index 0a339a1..66a3b6b 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/GerritUiExtensionPoint.java
@@ -22,6 +22,7 @@
   CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
   CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
   CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK,
+  CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS,
 
   /* MyPasswordScreen */
   PASSWORD_SCREEN_BOTTOM,
@@ -33,9 +34,13 @@
   PROFILE_SCREEN_BOTTOM,
 
   /* ProjectInfoScreen */
-  PROJECT_INFO_SCREEN_TOP, PROJECT_INFO_SCREEN_BOTTOM;
+  PROJECT_INFO_SCREEN_TOP,
+  PROJECT_INFO_SCREEN_BOTTOM;
 
   public enum Key {
-    ACCOUNT_INFO, CHANGE_INFO, PROJECT_NAME, REVISION_INFO
+    ACCOUNT_INFO,
+    CHANGE_INFO,
+    PROJECT_NAME,
+    REVISION_INFO
   }
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
index 2165db2..e0cc9ca 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.client;
 
-import static com.google.gerrit.client.CommonConstants.C;
-import static com.google.gerrit.client.CommonMessages.M;
-
 import java.util.Date;
 
 /**
- * Formatter to format timestamps relative to the current time using time units
- * in the format defined by {@code git log --relative-date}.
+ * Formatter to format timestamps relative to the current time using time units in the format
+ * defined by {@code git log --relative-date}.
  */
 public class RelativeDateFormatter {
+  private static CommonConstants constants;
+  private static CommonMessages messages;
+
   static final long SECOND_IN_MILLIS = 1000;
   static final long MINUTE_IN_MILLIS = 60 * SECOND_IN_MILLIS;
   static final long HOUR_IN_MILLIS = 60 * MINUTE_IN_MILLIS;
@@ -32,92 +32,104 @@
   static final long MONTH_IN_MILLIS = 30 * DAY_IN_MILLIS;
   static final long YEAR_IN_MILLIS = 365 * DAY_IN_MILLIS;
 
+  static void setConstants(CommonConstants c, CommonMessages m) {
+    constants = c;
+    messages = m;
+  }
+
+  private static CommonConstants c() {
+    return constants != null ? constants : CommonConstants.C;
+  }
+
+  private static CommonMessages m() {
+    return messages != null ? messages : CommonMessages.M;
+  }
+
   /**
    * @param when {@link Date} to format
-   * @return age of given {@link Date} compared to now formatted in the same
-   *         relative format as returned by {@code git log --relative-date}
+   * @return age of given {@link Date} compared to now formatted in the same relative format as
+   *     returned by {@code git log --relative-date}
    */
-  @SuppressWarnings("boxing")
   public static String format(Date when) {
     long ageMillis = (new Date()).getTime() - when.getTime();
 
     // shouldn't happen in a perfect world
     if (ageMillis < 0) {
-      return C.inTheFuture();
+      return c().inTheFuture();
     }
 
     // seconds
     if (ageMillis < upperLimit(MINUTE_IN_MILLIS)) {
       long seconds = round(ageMillis, SECOND_IN_MILLIS);
       if (seconds == 1) {
-        return C.oneSecondAgo();
+        return c().oneSecondAgo();
       }
-      return M.secondsAgo(seconds);
+      return m().secondsAgo(seconds);
     }
 
     // minutes
     if (ageMillis < upperLimit(HOUR_IN_MILLIS)) {
       long minutes = round(ageMillis, MINUTE_IN_MILLIS);
       if (minutes == 1) {
-        return C.oneMinuteAgo();
+        return c().oneMinuteAgo();
       }
-      return M.minutesAgo(minutes);
+      return m().minutesAgo(minutes);
     }
 
     // hours
     if (ageMillis < upperLimit(DAY_IN_MILLIS)) {
       long hours = round(ageMillis, HOUR_IN_MILLIS);
       if (hours == 1) {
-        return C.oneHourAgo();
+        return c().oneHourAgo();
       }
-      return M.hoursAgo(hours);
+      return m().hoursAgo(hours);
     }
 
     // up to 14 days use days
     if (ageMillis < 14 * DAY_IN_MILLIS) {
       long days = round(ageMillis, DAY_IN_MILLIS);
       if (days == 1) {
-        return C.oneDayAgo();
+        return c().oneDayAgo();
       }
-      return M.daysAgo(days);
+      return m().daysAgo(days);
     }
 
     // up to 10 weeks use weeks
     if (ageMillis < 10 * WEEK_IN_MILLIS) {
       long weeks = round(ageMillis, WEEK_IN_MILLIS);
       if (weeks == 1) {
-        return C.oneWeekAgo();
+        return c().oneWeekAgo();
       }
-      return M.weeksAgo(weeks);
+      return m().weeksAgo(weeks);
     }
 
     // months
     if (ageMillis < YEAR_IN_MILLIS) {
       long months = round(ageMillis, MONTH_IN_MILLIS);
       if (months == 1) {
-        return C.oneMonthAgo();
+        return c().oneMonthAgo();
       }
-      return M.monthsAgo(months);
+      return m().monthsAgo(months);
     }
 
     // up to 5 years use "year, months" rounded to months
     if (ageMillis < 5 * YEAR_IN_MILLIS) {
-      long years = ageMillis / YEAR_IN_MILLIS;
-      String yearLabel = (years > 1) ? C.years() : C.year();
-      long months = round(ageMillis % YEAR_IN_MILLIS, MONTH_IN_MILLIS);
-      String monthLabel = (months > 1) ? C.months() : (months == 1 ? C.month() : "");
+      long years = round(ageMillis, MONTH_IN_MILLIS) / 12;
+      String yearLabel = (years > 1) ? c().years() : c().year();
+      long months = round(ageMillis - years * YEAR_IN_MILLIS, MONTH_IN_MILLIS);
+      String monthLabel = (months > 1) ? c().months() : (months == 1 ? c().month() : "");
       if (months == 0) {
-        return M.years0MonthsAgo(years, yearLabel);
+        return m().years0MonthsAgo(years, yearLabel);
       }
-      return M.yearsMonthsAgo(years, yearLabel, months, monthLabel);
+      return m().yearsMonthsAgo(years, yearLabel, months, monthLabel);
     }
 
     // years
     long years = round(ageMillis, YEAR_IN_MILLIS);
     if (years == 1) {
-      return C.oneYearAgo();
+      return c().oneYearAgo();
     }
-    return M.yearsAgo(years);
+    return m().yearsAgo(years);
   }
 
   private static long upperLimit(long unit) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
index 95751fa..67627c3 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/Resources.java
@@ -18,9 +18,7 @@
 import com.google.gwt.resources.client.ImageResource;
 
 public interface Resources extends ClientBundle {
-  /**
-   * silk icons (CC-BY3.0): http://famfamfam.com/lab/icons/silk/
-   */
+  /** silk icons (CC-BY3.0): http://famfamfam.com/lab/icons/silk/ */
   @Source("note_add.png")
   ImageResource addFileComment();
 
@@ -30,6 +28,9 @@
   @Source("user_add.png")
   ImageResource addUser();
 
+  @Source("user_edit.png")
+  ImageResource editUser();
+
   // derived from resultset_next.png
   @Source("resultset_down_gray.png")
   ImageResource arrowDown();
@@ -95,10 +96,7 @@
   @Source("help.png")
   ImageResource question();
 
-  /**
-   * tango icon library (public domain):
-   * http://tango.freedesktop.org/Tango_Icon_Library
-   */
+  /** tango icon library (public domain): http://tango.freedesktop.org/Tango_Icon_Library */
   @Source("goNext.png")
   ImageResource goNext();
 
@@ -115,18 +113,14 @@
   @Source("merge.png")
   ImageResource merge();
 
-  /**
-   * contributed by the artist under Apache2.0
-   */
+  /** contributed by the artist under Apache2.0 */
   @Source("sideBySideDiff.png")
   ImageResource sideBySideDiff();
 
   @Source("unifiedDiff.png")
   ImageResource unifiedDiff();
 
-  /**
-   * contributed by the artist under CC-BY3.0
-   */
+  /** contributed by the artist under CC-BY3.0 */
   @Source("diffy26.png")
   ImageResource gerritAvatar26();
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java
index 7679799..dc37285 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AccountInfo.java
@@ -19,7 +19,6 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
-
 import java.sql.Timestamp;
 
 public class AccountInfo extends JavaScriptObject {
@@ -28,10 +27,13 @@
   }
 
   public final native int _accountId() /*-{ return this._account_id || 0; }-*/;
+
   public final native String name() /*-{ return this.name; }-*/;
+
   public final native String email() /*-{ return this.email; }-*/;
-  public final native JsArrayString secondaryEmails()
-      /*-{ return this.secondary_emails; }-*/;
+
+  public final native JsArrayString secondaryEmails() /*-{ return this.secondary_emails; }-*/;
+
   public final native String username() /*-{ return this.username; }-*/;
 
   public final Timestamp registeredOn() {
@@ -44,17 +46,17 @@
   }
 
   private native String registeredOnRaw() /*-{ return this.registered_on; }-*/;
+
   private native Timestamp _getRegisteredOn() /*-{ return this._cts; }-*/;
+
   private native void _setRegisteredOn(Timestamp ts) /*-{ this._cts = ts; }-*/;
 
   /**
-   * @return true if the server supplied avatar information about this account.
-   *         The information may be an empty list, indicating no avatars are
-   *         available, such as when no plugin is installed. This method returns
-   *         false if the server did not check on avatars for the account.
+   * @return true if the server supplied avatar information about this account. The information may
+   *     be an empty list, indicating no avatars are available, such as when no plugin is installed.
+   *     This method returns false if the server did not check on avatars for the account.
    */
-  public final native boolean hasAvatarInfo()
-  /*-{ return this.hasOwnProperty('avatars') }-*/;
+  public final native boolean hasAvatarInfo() /*-{ return this.hasOwnProperty('avatars') }-*/;
 
   public final AvatarInfo avatar(int sz) {
     JsArray<AvatarInfo> a = avatars();
@@ -67,29 +69,30 @@
     return null;
   }
 
-  private native JsArray<AvatarInfo> avatars()
-  /*-{ return this.avatars }-*/;
+  private native JsArray<AvatarInfo> avatars() /*-{ return this.avatars }-*/;
 
   public final native void name(String n) /*-{ this.name = n }-*/;
+
   public final native void email(String e) /*-{ this.email = e }-*/;
+
   public final native void username(String n) /*-{ this.username = n }-*/;
 
-  public static native AccountInfo create(int id, String name,
-      String email, String username) /*-{
+  public static native AccountInfo create(int id, String name, String email, String username) /*-{
     return {'_account_id': id, 'name': name, 'email': email,
         'username': username};
   }-*/;
 
-  protected AccountInfo() {
-  }
+  protected AccountInfo() {}
 
   public static class AvatarInfo extends JavaScriptObject {
     public static final int DEFAULT_SIZE = 26;
+
     public final native String url() /*-{ return this.url }-*/;
+
     public final native int height() /*-{ return this.height || 0 }-*/;
+
     public final native int width() /*-{ return this.width || 0 }-*/;
 
-    protected AvatarInfo() {
-    }
+    protected AvatarInfo() {}
   }
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ActionInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ActionInfo.java
index 3b283ec..d09d5b7 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ActionInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ActionInfo.java
@@ -19,11 +19,14 @@
 public class ActionInfo extends JavaScriptObject {
 
   public final native String id() /*-{ return this.id; }-*/;
+
   public final native String method() /*-{ return this.method; }-*/;
+
   public final native String label() /*-{ return this.label; }-*/;
+
   public final native String title() /*-{ return this.title; }-*/;
+
   public final native boolean enabled() /*-{ return this.enabled || false; }-*/;
 
-  protected ActionInfo() {
-  }
+  protected ActionInfo() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java
new file mode 100644
index 0000000..04fba4f
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AgreementInfo.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.info;
+
+import com.google.gwt.core.client.JavaScriptObject;
+
+public class AgreementInfo extends JavaScriptObject {
+  public final native String name() /*-{ return this.name; }-*/;
+
+  public final native String description() /*-{ return this.description; }-*/;
+
+  public final native String url() /*-{ return this.url; }-*/;
+
+  public final native GroupInfo autoVerifyGroup() /*-{ return this.auto_verify_group; }-*/;
+
+  protected AgreementInfo() {}
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
index 345e1e3..d2e5d49 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/AuthInfo.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.client.info;
 
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -33,6 +32,7 @@
   public final boolean isLdap() {
     return authType() == AuthType.LDAP || authType() == AuthType.LDAP_BIND;
   }
+
   public final boolean isOpenId() {
     return authType() == AuthType.OPENID;
   }
@@ -53,32 +53,39 @@
     return authType() == AuthType.CUSTOM_EXTENSION;
   }
 
-  public final boolean canEdit(Account.FieldName f) {
+  public final boolean canEdit(AccountFieldName f) {
     return editableAccountFields().contains(f);
   }
 
-  public final List<Account.FieldName> editableAccountFields() {
-    List<Account.FieldName> fields = new ArrayList<>();
+  public final List<AccountFieldName> editableAccountFields() {
+    List<AccountFieldName> fields = new ArrayList<>();
     for (String f : Natives.asList(_editableAccountFields())) {
-      fields.add(Account.FieldName.valueOf(f));
+      fields.add(AccountFieldName.valueOf(f));
     }
     return fields;
   }
 
+  public final List<AgreementInfo> contributorAgreements() {
+    List<AgreementInfo> agreements = new ArrayList<>();
+    JsArray<AgreementInfo> contributorAgreements = _contributorAgreements();
+    if (contributorAgreements != null) {
+      for (AgreementInfo a : Natives.asList(contributorAgreements)) {
+        agreements.add(a);
+      }
+    }
+    return agreements;
+  }
+
   public final boolean siteHasUsernames() {
-    if (isCustomExtension()
-        && httpPasswordUrl() != null
-        && !canEdit(FieldName.USER_NAME)) {
+    if (isCustomExtension() && httpPasswordUrl() != null && !canEdit(AccountFieldName.USER_NAME)) {
       return false;
     }
     return true;
   }
 
   public final boolean isHttpPasswordSettingsEnabled() {
-    if (isGitBasicAuth() && gitBasicAuthPolicy() == GitBasicAuthPolicy.LDAP) {
-      return false;
-    }
-    return true;
+    return gitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP
+        || gitBasicAuthPolicy() == GitBasicAuthPolicy.HTTP_LDAP;
   }
 
   public final GitBasicAuthPolicy gitBasicAuthPolicy() {
@@ -86,21 +93,31 @@
   }
 
   public final native boolean useContributorAgreements()
-  /*-{ return this.use_contributor_agreements || false; }-*/;
-  public final native String loginUrl() /*-{ return this.login_url; }-*/;
-  public final native String loginText() /*-{ return this.login_text; }-*/;
-  public final native String switchAccountUrl() /*-{ return this.switch_account_url; }-*/;
-  public final native String registerUrl() /*-{ return this.register_url; }-*/;
-  public final native String registerText() /*-{ return this.register_text; }-*/;
-  public final native String editFullNameUrl() /*-{ return this.edit_full_name_url; }-*/;
-  public final native String httpPasswordUrl() /*-{ return this.http_password_url; }-*/;
-  public final native boolean isGitBasicAuth() /*-{ return this.is_git_basic_auth || false; }-*/;
-  private native String gitBasicAuthPolicyRaw()
-  /*-{ return this.git_basic_auth_policy; }-*/;
-  private native String authTypeRaw() /*-{ return this.auth_type; }-*/;
-  private native JsArrayString _editableAccountFields()
-  /*-{ return this.editable_account_fields; }-*/;
+      /*-{ return this.use_contributor_agreements || false; }-*/ ;
 
-  protected AuthInfo() {
-  }
+  public final native String loginUrl() /*-{ return this.login_url; }-*/;
+
+  public final native String loginText() /*-{ return this.login_text; }-*/;
+
+  public final native String switchAccountUrl() /*-{ return this.switch_account_url; }-*/;
+
+  public final native String registerUrl() /*-{ return this.register_url; }-*/;
+
+  public final native String registerText() /*-{ return this.register_text; }-*/;
+
+  public final native String editFullNameUrl() /*-{ return this.edit_full_name_url; }-*/;
+
+  public final native String httpPasswordUrl() /*-{ return this.http_password_url; }-*/;
+
+  private native String gitBasicAuthPolicyRaw() /*-{ return this.git_basic_auth_policy; }-*/;
+
+  private native String authTypeRaw() /*-{ return this.auth_type; }-*/;
+
+  private native JsArrayString _editableAccountFields()
+      /*-{ return this.editable_account_fields; }-*/ ;
+
+  private native JsArray<AgreementInfo> _contributorAgreements()
+      /*-{ return this.contributor_agreements; }-*/ ;
+
+  protected AuthInfo() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index 9eea93e..0de8b68 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -28,7 +28,6 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
-
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -72,6 +71,7 @@
   }
 
   private native Timestamp _getCts() /*-{ return this._cts; }-*/;
+
   private native void _setCts(Timestamp ts) /*-{ this._cts = ts; }-*/;
 
   public final Timestamp updated() {
@@ -105,45 +105,75 @@
   }
 
   public final native String id() /*-{ return this.id; }-*/;
+
   public final native String project() /*-{ return this.project; }-*/;
+
   public final native String branch() /*-{ return this.branch; }-*/;
+
   public final native String topic() /*-{ return this.topic; }-*/;
+
   public final native String changeId() /*-{ return this.change_id; }-*/;
+
   public final native boolean mergeable() /*-{ return this.mergeable ? true : false; }-*/;
+
   public final native int insertions() /*-{ return this.insertions; }-*/;
+
   public final native int deletions() /*-{ return this.deletions; }-*/;
+
   private native String statusRaw() /*-{ return this.status; }-*/;
+
   public final native String subject() /*-{ return this.subject; }-*/;
+
   public final native AccountInfo owner() /*-{ return this.owner; }-*/;
+
+  public final native AccountInfo assignee() /*-{ return this.assignee; }-*/;
+
   private native String createdRaw() /*-{ return this.created; }-*/;
+
   private native String updatedRaw() /*-{ return this.updated; }-*/;
+
   private native String submittedRaw() /*-{ return this.submitted; }-*/;
+
   public final native boolean starred() /*-{ return this.starred ? true : false; }-*/;
+
   public final native boolean reviewed() /*-{ return this.reviewed ? true : false; }-*/;
+
   public final native NativeMap<LabelInfo> allLabels() /*-{ return this.labels; }-*/;
+
   public final native LabelInfo label(String n) /*-{ return this.labels[n]; }-*/;
+
   public final native String currentRevision() /*-{ return this.current_revision; }-*/;
+
   public final native void setCurrentRevision(String r) /*-{ this.current_revision = r; }-*/;
+
   public final native NativeMap<RevisionInfo> revisions() /*-{ return this.revisions; }-*/;
+
   public final native RevisionInfo revision(String n) /*-{ return this.revisions[n]; }-*/;
+
   public final native JsArray<MessageInfo> messages() /*-{ return this.messages; }-*/;
+
   public final native void setEdit(EditInfo edit) /*-{ this.edit = edit; }-*/;
+
   public final native EditInfo edit() /*-{ return this.edit; }-*/;
+
   public final native boolean hasEdit() /*-{ return this.hasOwnProperty('edit') }-*/;
+
   public final native JsArrayString hashtags() /*-{ return this.hashtags; }-*/;
 
   public final native boolean hasPermittedLabels()
-  /*-{ return this.hasOwnProperty('permitted_labels') }-*/;
+      /*-{ return this.hasOwnProperty('permitted_labels') }-*/ ;
+
   public final native NativeMap<JsArrayString> permittedLabels()
-  /*-{ return this.permitted_labels; }-*/;
+      /*-{ return this.permitted_labels; }-*/ ;
+
   public final native JsArrayString permittedValues(String n)
-  /*-{ return this.permitted_labels[n]; }-*/;
+      /*-{ return this.permitted_labels[n]; }-*/ ;
 
   public final native JsArray<AccountInfo> removableReviewers()
-  /*-{ return this.removable_reviewers; }-*/;
+      /*-{ return this.removable_reviewers; }-*/ ;
 
-  private native NativeMap<JsArray<AccountInfo>> _reviewers()
-  /*-{ return this.reviewers; }-*/;
+  private native NativeMap<JsArray<AccountInfo>> _reviewers() /*-{ return this.reviewers; }-*/;
+
   public final Map<ReviewerState, List<AccountInfo>> reviewers() {
     NativeMap<JsArray<AccountInfo>> reviewers = _reviewers();
     Map<ReviewerState, List<AccountInfo>> result = new HashMap<>();
@@ -160,11 +190,12 @@
   }
 
   public final native boolean hasActions() /*-{ return this.hasOwnProperty('actions') }-*/;
+
   public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
 
   public final native int _number() /*-{ return this._number; }-*/;
-  public final native boolean _more_changes()
-  /*-{ return this._more_changes ? true : false; }-*/;
+
+  public final native boolean _more_changes() /*-{ return this._more_changes ? true : false; }-*/;
 
   public final SubmitType submitType() {
     String submitType = _submitType();
@@ -173,6 +204,7 @@
     }
     return SubmitType.valueOf(submitType);
   }
+
   private native String _submitType() /*-{ return this.submit_type; }-*/;
 
   public final boolean submittable() {
@@ -180,12 +212,11 @@
     return _submittable();
   }
 
-  private native boolean _submittable()
-  /*-{ return this.submittable ? true : false; }-*/;
+  private native boolean _submittable() /*-{ return this.submittable ? true : false; }-*/;
 
   /**
-   * @return the index of the missing label or -1
-   *         if no label is missing, or if more than one label is missing.
+   * @return the index of the missing label or -1 if no label is missing, or if more than one label
+   *     is missing.
    */
   public final int getMissingLabelIndex() {
     int i = -1;
@@ -224,8 +255,7 @@
     return ret;
   }
 
-  protected ChangeInfo() {
-  }
+  protected ChangeInfo() {}
 
   public static class LabelInfo extends JavaScriptObject {
     public final SubmitRecord.Label.Status status() {
@@ -241,13 +271,17 @@
     }
 
     public final native String name() /*-{ return this._name; }-*/;
+
     public final native AccountInfo approved() /*-{ return this.approved; }-*/;
+
     public final native AccountInfo rejected() /*-{ return this.rejected; }-*/;
 
     public final native AccountInfo recommended() /*-{ return this.recommended; }-*/;
+
     public final native AccountInfo disliked() /*-{ return this.disliked; }-*/;
 
     public final native JsArray<ApprovalInfo> all() /*-{ return this.all; }-*/;
+
     public final ApprovalInfo forUser(int user) {
       JsArray<ApprovalInfo> all = all();
       for (int i = 0; all != null && i < all.length(); i++) {
@@ -259,16 +293,20 @@
     }
 
     private native NativeMap<NativeString> _values() /*-{ return this.values; }-*/;
+
     public final Set<String> values() {
       return Natives.keys(_values());
     }
+
     public final native String valueText(String n) /*-{ return this.values[n]; }-*/;
 
     public final native boolean optional() /*-{ return this.optional ? true : false; }-*/;
+
     public final native boolean blocking() /*-{ return this.blocking ? true : false; }-*/;
+
     public final native short defaultValue() /*-{ return this.default_value; }-*/;
-    public final native short _value()
-    /*-{
+
+    public final native short _value() /*-{
       if (this.value) return this.value;
       if (this.disliked) return -1;
       if (this.recommended) return 1;
@@ -296,35 +334,50 @@
       return Short.parseShort(formatted);
     }
 
-    protected LabelInfo() {
-    }
+    protected LabelInfo() {}
   }
 
   public static class ApprovalInfo extends AccountInfo {
     public final native boolean hasValue() /*-{ return this.hasOwnProperty('value'); }-*/;
+
     public final native short value() /*-{ return this.value || 0; }-*/;
 
-    protected ApprovalInfo() {
-    }
+    public final native VotingRangeInfo
+        permittedVotingRange() /*-{ return this.permitted_voting_range; }-*/;
+
+    protected ApprovalInfo() {}
+  }
+
+  public static class VotingRangeInfo extends AccountInfo {
+    public final native short min() /*-{ return this.min || 0; }-*/;
+
+    public final native short max() /*-{ return this.max || 0; }-*/;
+
+    protected VotingRangeInfo() {}
   }
 
   public static class EditInfo extends JavaScriptObject {
     public final native String name() /*-{ return this.name; }-*/;
+
     public final native String setName(String n) /*-{ this.name = n; }-*/;
+
     public final native String baseRevision() /*-{ return this.base_revision; }-*/;
+
     public final native CommitInfo commit() /*-{ return this.commit; }-*/;
 
     public final native boolean hasActions() /*-{ return this.hasOwnProperty('actions') }-*/;
+
     public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
 
     public final native boolean hasFetch() /*-{ return this.hasOwnProperty('fetch') }-*/;
+
     public final native NativeMap<FetchInfo> fetch() /*-{ return this.fetch; }-*/;
 
     public final native boolean hasFiles() /*-{ return this.hasOwnProperty('files') }-*/;
+
     public final native NativeMap<FileInfo> files() /*-{ return this.files; }-*/;
 
-    protected EditInfo() {
-    }
+    protected EditInfo() {}
   }
 
   public static class RevisionInfo extends JavaScriptObject {
@@ -333,55 +386,74 @@
       revisionInfo.takeFromEdit(edit);
       return revisionInfo;
     }
+
     public static RevisionInfo forParent(int number, CommitInfo commit) {
       RevisionInfo revisionInfo = createObject().cast();
       revisionInfo.takeFromParent(number, commit);
       return revisionInfo;
     }
+
     private native void takeFromEdit(EditInfo edit) /*-{
       this._number = 0;
       this.name = edit.name;
       this.commit = edit.commit;
       this.edit_base = edit.base_revision;
     }-*/;
+
     private native void takeFromParent(int number, CommitInfo commit) /*-{
       this._number = number;
       this.commit = commit;
       this.name = this._number;
     }-*/;
+
     public final native int _number() /*-{ return this._number; }-*/;
+
     public final native String name() /*-{ return this.name; }-*/;
+
     public final native boolean draft() /*-{ return this.draft || false; }-*/;
+
     public final native AccountInfo uploader() /*-{ return this.uploader; }-*/;
+
     public final native boolean isEdit() /*-{ return this._number == 0; }-*/;
+
     public final native CommitInfo commit() /*-{ return this.commit; }-*/;
+
     public final native void setCommit(CommitInfo c) /*-{ this.commit = c; }-*/;
+
     public final native String editBase() /*-{ return this.edit_base; }-*/;
 
     public final native boolean hasFiles() /*-{ return this.hasOwnProperty('files') }-*/;
+
     public final native NativeMap<FileInfo> files() /*-{ return this.files; }-*/;
 
     public final native boolean hasActions() /*-{ return this.hasOwnProperty('actions') }-*/;
+
     public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
 
     public final native boolean hasFetch() /*-{ return this.hasOwnProperty('fetch') }-*/;
+
     public final native NativeMap<FetchInfo> fetch() /*-{ return this.fetch; }-*/;
 
-    public final native boolean hasPushCertificate() /*-{ return this.hasOwnProperty('push_certificate'); }-*/;
-    public final native PushCertificateInfo pushCertificate() /*-{ return this.push_certificate; }-*/;
+    public final native boolean
+        hasPushCertificate() /*-{ return this.hasOwnProperty('push_certificate'); }-*/;
+
+    public final native PushCertificateInfo
+        pushCertificate() /*-{ return this.push_certificate; }-*/;
 
     public static void sortRevisionInfoByNumber(JsArray<RevisionInfo> list) {
       final int editParent = findEditParent(list);
-      Collections.sort(Natives.asList(list), new Comparator<RevisionInfo>() {
-        @Override
-        public int compare(RevisionInfo a, RevisionInfo b) {
-          return num(a) - num(b);
-        }
+      Collections.sort(
+          Natives.asList(list),
+          new Comparator<RevisionInfo>() {
+            @Override
+            public int compare(RevisionInfo a, RevisionInfo b) {
+              return num(a) - num(b);
+            }
 
-        private int num(RevisionInfo r) {
-          return !r.isEdit() ? 2 * (r._number() - 1) + 1 : 2 * editParent;
-        }
-      });
+            private int num(RevisionInfo r) {
+              return !r.isEdit() ? 2 * (r._number() - 1) + 1 : 2 * editParent;
+            }
+          });
     }
 
     public static int findEditParent(JsArray<RevisionInfo> list) {
@@ -389,8 +461,7 @@
       return r == null ? -1 : r._number();
     }
 
-    public static RevisionInfo findEditParentRevision(
-        JsArray<RevisionInfo> list) {
+    public static RevisionInfo findEditParentRevision(JsArray<RevisionInfo> list) {
       for (int i = 0; i < list.length(); i++) {
         // edit under revisions?
         RevisionInfo editInfo = list.get(i);
@@ -414,66 +485,81 @@
       return PatchSet.Id.toId(_number());
     }
 
-    protected RevisionInfo () {
+    public final boolean isMerge() {
+      return commit().parents().length() > 1;
     }
+
+    protected RevisionInfo() {}
   }
 
   public static class FetchInfo extends JavaScriptObject {
     public final native String url() /*-{ return this.url }-*/;
+
     public final native String ref() /*-{ return this.ref }-*/;
+
     public final native NativeMap<NativeString> commands() /*-{ return this.commands }-*/;
+
     public final native String command(String n) /*-{ return this.commands[n]; }-*/;
 
-    protected FetchInfo () {
-    }
+    protected FetchInfo() {}
   }
 
   public static class CommitInfo extends JavaScriptObject {
     public final native String commit() /*-{ return this.commit; }-*/;
+
     public final native JsArray<CommitInfo> parents() /*-{ return this.parents; }-*/;
+
     public final native GitPerson author() /*-{ return this.author; }-*/;
+
     public final native GitPerson committer() /*-{ return this.committer; }-*/;
+
     public final native String subject() /*-{ return this.subject; }-*/;
+
     public final native String message() /*-{ return this.message; }-*/;
+
     public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
 
-    protected CommitInfo() {
-    }
+    protected CommitInfo() {}
   }
 
   public static class GitPerson extends JavaScriptObject {
     public final native String name() /*-{ return this.name; }-*/;
+
     public final native String email() /*-{ return this.email; }-*/;
+
     private native String dateRaw() /*-{ return this.date; }-*/;
 
     public final Timestamp date() {
       return JavaSqlTimestamp_JsonSerializer.parseTimestamp(dateRaw());
     }
 
-    protected GitPerson() {
-    }
+    protected GitPerson() {}
   }
 
   public static class MessageInfo extends JavaScriptObject {
     public final native AccountInfo author() /*-{ return this.author; }-*/;
+
     public final native String message() /*-{ return this.message; }-*/;
+
     public final native int _revisionNumber() /*-{ return this._revision_number || 0; }-*/;
+
+    public final native String tag() /*-{ return this.tag; }-*/;
+
     private native String dateRaw() /*-{ return this.date; }-*/;
 
     public final Timestamp date() {
       return JavaSqlTimestamp_JsonSerializer.parseTimestamp(dateRaw());
     }
 
-    protected MessageInfo() {
-    }
+    protected MessageInfo() {}
   }
 
   public static class MergeableInfo extends JavaScriptObject {
     public final native String submitType() /*-{ return this.submit_type }-*/;
+
     public final native boolean mergeable() /*-{ return this.mergeable }-*/;
 
-    protected MergeableInfo() {
-    }
+    protected MergeableInfo() {}
   }
 
   public static class IncludedInInfo extends JavaScriptObject {
@@ -482,11 +568,13 @@
     }
 
     public final native JsArrayString branches() /*-{ return this.branches; }-*/;
+
     public final native JsArrayString tags() /*-{ return this.tags; }-*/;
+
     public final native JsArrayString external(String n) /*-{ return this.external[n]; }-*/;
+
     private native NativeMap<JsArrayString> external() /*-{ return this.external; }-*/;
 
-    protected IncludedInInfo() {
-    }
+    protected IncludedInInfo() {}
   }
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
index 183e6af..8d56a1c 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/DownloadInfo.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
-
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -39,11 +38,12 @@
   }
 
   public final native DownloadSchemeInfo scheme(String n) /*-{ return this.schemes[n]; }-*/;
+
   private native NativeMap<DownloadSchemeInfo> _schemes() /*-{ return this.schemes; }-*/;
+
   private native JsArrayString _archives() /*-{ return this.archives; }-*/;
 
-  protected DownloadInfo() {
-  }
+  protected DownloadInfo() {}
 
   public static class DownloadSchemeInfo extends JavaScriptObject {
     public final List<String> commandNames() {
@@ -53,8 +53,7 @@
     public final Set<DownloadCommandInfo> commands(String project) {
       Set<DownloadCommandInfo> commands = new HashSet<>();
       for (String commandName : commandNames()) {
-        commands.add(new DownloadCommandInfo(commandName, command(commandName,
-            project)));
+        commands.add(new DownloadCommandInfo(commandName, command(commandName, project)));
       }
       return commands;
     }
@@ -75,14 +74,14 @@
       List<String> commandNames = cloneCommandNames();
       List<DownloadCommandInfo> commands = new ArrayList<>(commandNames.size());
       for (String commandName : commandNames) {
-        commands.add(new DownloadCommandInfo(commandName, cloneCommand(
-            commandName, project)));
+        commands.add(new DownloadCommandInfo(commandName, cloneCommand(commandName, project)));
       }
       return commands;
     }
 
     public final String cloneCommand(String commandName, String project) {
-      return cloneCommand(commandName).replaceAll("\\$\\{project\\}", project)
+      return cloneCommand(commandName)
+          .replaceAll("\\$\\{project\\}", project)
           .replaceAll("\\$\\{project-base-name\\}", projectBaseName(project));
     }
 
@@ -91,16 +90,22 @@
     }
 
     public final native String name() /*-{ return this.name; }-*/;
+
     public final native String url() /*-{ return this.url; }-*/;
+
     public final native boolean isAuthRequired() /*-{ return this.is_auth_required || false; }-*/;
+
     public final native boolean isAuthSupported() /*-{ return this.is_auth_supported || false; }-*/;
+
     public final native String command(String n) /*-{ return this.commands[n]; }-*/;
+
     public final native String cloneCommand(String n) /*-{ return this.clone_commands[n]; }-*/;
+
     private native NativeMap<NativeString> _commands() /*-{ return this.commands; }-*/;
+
     private native NativeMap<NativeString> _cloneCommands() /*-{ return this.clone_commands; }-*/;
 
-    protected DownloadSchemeInfo() {
-    }
+    protected DownloadSchemeInfo() {}
   }
 
   public static class DownloadCommandInfo {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
index 9b290a5..345a260 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/FileInfo.java
@@ -15,74 +15,63 @@
 package com.google.gerrit.client.info;
 
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.common.data.FilenameComparator;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
-
 import java.util.Collections;
 import java.util.Comparator;
 
 public class FileInfo extends JavaScriptObject {
   public final native String path() /*-{ return this.path; }-*/;
-  public final native String oldPath() /*-{ return this.old_path; }-*/;
-  public final native int linesInserted() /*-{ return this.lines_inserted || 0; }-*/;
-  public final native int linesDeleted() /*-{ return this.lines_deleted || 0; }-*/;
-  public final native boolean binary() /*-{ return this.binary || false; }-*/;
-  public final native String status() /*-{ return this.status; }-*/;
 
+  public final native String oldPath() /*-{ return this.old_path; }-*/;
+
+  public final native int linesInserted() /*-{ return this.lines_inserted || 0; }-*/;
+
+  public final native int linesDeleted() /*-{ return this.lines_deleted || 0; }-*/;
+
+  public final native boolean binary() /*-{ return this.binary || false; }-*/;
+
+  public final native String status() /*-{ return this.status; }-*/;
 
   // JSNI methods cannot have 'long' as a parameter type or a return type and
   // it's suggested to use double in this case:
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html#important
   public final long size() {
-    return (long)_size();
+    return (long) _size();
   }
+
   private native double _size() /*-{ return this.size || 0; }-*/;
 
   public final long sizeDelta() {
-    return (long)_sizeDelta();
+    return (long) _sizeDelta();
   }
+
   private native double _sizeDelta() /*-{ return this.size_delta || 0; }-*/;
 
   public final native int _row() /*-{ return this._row }-*/;
+
   public final native void _row(int r) /*-{ this._row = r }-*/;
 
   public static void sortFileInfoByPath(JsArray<FileInfo> list) {
-    Collections.sort(Natives.asList(list), new Comparator<FileInfo>() {
-      @Override
-      public int compare(FileInfo a, FileInfo b) {
-        if (Patch.COMMIT_MSG.equals(a.path())) {
-          return -1;
-        } else if (Patch.COMMIT_MSG.equals(b.path())) {
-          return 1;
-        }
-        // Look at file suffixes to check if it makes sense to use a different order
-        int s1 = a.path().lastIndexOf('.');
-        int s2 = b.path().lastIndexOf('.');
-        if (s1 > 0 && s2 > 0 &&
-            a.path().substring(0, s1).equals(b.path().substring(0, s2))) {
-            String suffixA = a.path().substring(s1);
-            String suffixB = b.path().substring(s2);
-            // C++ and C: give priority to header files (.h/.hpp/...)
-            if (suffixA.indexOf(".h") == 0) {
-                return -1;
-            } else if (suffixB.indexOf(".h") == 0) {
-                return 1;
-            }
-        }
-        return a.path().compareTo(b.path());
-      }
-    });
+    Collections.sort(
+        Natives.asList(list), Comparator.comparing(FileInfo::path, FilenameComparator.INSTANCE));
   }
 
   public static String getFileName(String path) {
-    String fileName = Patch.COMMIT_MSG.equals(path)
-        ? "Commit Message"
-        : path;
+    String fileName;
+    if (Patch.COMMIT_MSG.equals(path)) {
+      fileName = "Commit Message";
+    } else if (Patch.MERGE_LIST.equals(path)) {
+      fileName = "Merge List";
+    } else {
+      fileName = path;
+    }
+
     int s = fileName.lastIndexOf('/');
     return s >= 0 ? fileName.substring(s + 1) : fileName;
   }
 
-  protected FileInfo() {
-  }
+  protected FileInfo() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
index 45953cb..23e1a93 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
@@ -19,14 +19,15 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.ReviewCategoryStrategy;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
-
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -37,8 +38,7 @@
   }
 
   public static GeneralPreferences createDefault() {
-    GeneralPreferencesInfo d =
-        GeneralPreferencesInfo.defaults();
+    GeneralPreferencesInfo d = GeneralPreferencesInfo.defaults();
     GeneralPreferences p = createObject().cast();
     p.changesPerPage(d.changesPerPage);
     p.showSiteHeader(d.showSiteHeader);
@@ -47,159 +47,182 @@
     p.downloadCommand(d.downloadCommand);
     p.dateFormat(d.getDateFormat());
     p.timeFormat(d.getTimeFormat());
+    p.highlightAssigneeInChangeTable(d.highlightAssigneeInChangeTable);
     p.relativeDateInChangeTable(d.relativeDateInChangeTable);
     p.sizeBarInChangeTable(d.sizeBarInChangeTable);
     p.legacycidInChangeTable(d.legacycidInChangeTable);
     p.muteCommonPathPrefixes(d.muteCommonPathPrefixes);
     p.signedOffBy(d.signedOffBy);
+    p.emailFormat(d.emailFormat);
     p.reviewCategoryStrategy(d.getReviewCategoryStrategy());
     p.diffView(d.getDiffView());
     p.emailStrategy(d.emailStrategy);
+    p.defaultBaseForMerges(d.defaultBaseForMerges);
     return p;
   }
 
   public final int changesPerPage() {
-    int changesPerPage =
-        get("changes_per_page", GeneralPreferencesInfo.DEFAULT_PAGESIZE);
-    return 0 < changesPerPage
-        ? changesPerPage
-        : GeneralPreferencesInfo.DEFAULT_PAGESIZE;
+    int changesPerPage = get("changes_per_page", GeneralPreferencesInfo.DEFAULT_PAGESIZE);
+    return 0 < changesPerPage ? changesPerPage : GeneralPreferencesInfo.DEFAULT_PAGESIZE;
   }
-  private native short get(String n, int d)
-  /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
 
-  public final native boolean showSiteHeader()
-  /*-{ return this.show_site_header || false }-*/;
+  private native short get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
+
+  public final native boolean showSiteHeader() /*-{ return this.show_site_header || false }-*/;
 
   public final native boolean useFlashClipboard()
-  /*-{ return this.use_flash_clipboard || false }-*/;
+      /*-{ return this.use_flash_clipboard || false }-*/ ;
 
-  public final native String downloadScheme()
-  /*-{ return this.download_scheme }-*/;
+  public final native String downloadScheme() /*-{ return this.download_scheme }-*/;
 
   public final DownloadCommand downloadCommand() {
     String s = downloadCommandRaw();
     return s != null ? DownloadCommand.valueOf(s) : null;
   }
-  private native String downloadCommandRaw()
-  /*-{ return this.download_command }-*/;
+
+  private native String downloadCommandRaw() /*-{ return this.download_command }-*/;
 
   public final DateFormat dateFormat() {
     String s = dateFormatRaw();
     return s != null ? DateFormat.valueOf(s) : null;
   }
-  private native String dateFormatRaw()
-  /*-{ return this.date_format }-*/;
+
+  private native String dateFormatRaw() /*-{ return this.date_format }-*/;
 
   public final TimeFormat timeFormat() {
     String s = timeFormatRaw();
     return s != null ? TimeFormat.valueOf(s) : null;
   }
-  private native String timeFormatRaw()
-  /*-{ return this.time_format }-*/;
+
+  private native String timeFormatRaw() /*-{ return this.time_format }-*/;
+
+  public final native boolean highlightAssigneeInChangeTable()
+      /*-{ return this.highlight_assignee_in_change_table || false }-*/ ;
 
   public final native boolean relativeDateInChangeTable()
-  /*-{ return this.relative_date_in_change_table || false }-*/;
+      /*-{ return this.relative_date_in_change_table || false }-*/ ;
 
   public final native boolean sizeBarInChangeTable()
-  /*-{ return this.size_bar_in_change_table || false }-*/;
+      /*-{ return this.size_bar_in_change_table || false }-*/ ;
 
   public final native boolean legacycidInChangeTable()
-  /*-{ return this.legacycid_in_change_table || false }-*/;
+      /*-{ return this.legacycid_in_change_table || false }-*/ ;
 
   public final native boolean muteCommonPathPrefixes()
-  /*-{ return this.mute_common_path_prefixes || false }-*/;
+      /*-{ return this.mute_common_path_prefixes || false }-*/ ;
 
-  public final native boolean signedOffBy()
-  /*-{ return this.signed_off_by || false }-*/;
+  public final native boolean signedOffBy() /*-{ return this.signed_off_by || false }-*/;
 
   public final ReviewCategoryStrategy reviewCategoryStrategy() {
     String s = reviewCategeoryStrategyRaw();
     return s != null ? ReviewCategoryStrategy.valueOf(s) : ReviewCategoryStrategy.NONE;
   }
-  private native String reviewCategeoryStrategyRaw()
-  /*-{ return this.review_category_strategy }-*/;
+
+  private native String reviewCategeoryStrategyRaw() /*-{ return this.review_category_strategy }-*/;
 
   public final DiffView diffView() {
     String s = diffViewRaw();
     return s != null ? DiffView.valueOf(s) : null;
   }
-  private native String diffViewRaw()
-  /*-{ return this.diff_view }-*/;
+
+  private native String diffViewRaw() /*-{ return this.diff_view }-*/;
 
   public final EmailStrategy emailStrategy() {
     String s = emailStrategyRaw();
     return s != null ? EmailStrategy.valueOf(s) : null;
   }
 
-  private native String emailStrategyRaw()
-  /*-{ return this.email_strategy }-*/;
+  private native String emailStrategyRaw() /*-{ return this.email_strategy }-*/;
 
-  public final native JsArray<TopMenuItem> my()
-  /*-{ return this.my; }-*/;
+  public final EmailFormat emailFormat() {
+    String s = emailFormatRaw();
+    return s != null ? EmailFormat.valueOf(s) : null;
+  }
 
-  public final native void changesPerPage(int n)
-  /*-{ this.changes_per_page = n }-*/;
+  private native String emailFormatRaw() /*-{ return this.email_format }-*/;
 
-  public final native void showSiteHeader(boolean s)
-  /*-{ this.show_site_header = s }-*/;
+  public final DefaultBase defaultBaseForMerges() {
+    String s = defaultBaseForMergesRaw();
+    return s != null ? DefaultBase.valueOf(s) : null;
+  }
 
-  public final native void useFlashClipboard(boolean u)
-  /*-{ this.use_flash_clipboard = u }-*/;
+  private native String defaultBaseForMergesRaw() /*-{ return this.default_base_for_merges }-*/;
 
-  public final native void downloadScheme(String d)
-  /*-{ this.download_scheme = d }-*/;
+  public final native JsArray<TopMenuItem> my() /*-{ return this.my; }-*/;
+
+  public final native void changesPerPage(int n) /*-{ this.changes_per_page = n }-*/;
+
+  public final native void showSiteHeader(boolean s) /*-{ this.show_site_header = s }-*/;
+
+  public final native void useFlashClipboard(boolean u) /*-{ this.use_flash_clipboard = u }-*/;
+
+  public final native void downloadScheme(String d) /*-{ this.download_scheme = d }-*/;
 
   public final void downloadCommand(DownloadCommand d) {
     downloadCommandRaw(d != null ? d.toString() : null);
   }
-  public final native void downloadCommandRaw(String d)
-  /*-{ this.download_command = d }-*/;
+
+  public final native void downloadCommandRaw(String d) /*-{ this.download_command = d }-*/;
 
   public final void dateFormat(DateFormat f) {
     dateFormatRaw(f != null ? f.toString() : null);
   }
-  private native void dateFormatRaw(String f)
-  /*-{ this.date_format = f }-*/;
+
+  private native void dateFormatRaw(String f) /*-{ this.date_format = f }-*/;
 
   public final void timeFormat(TimeFormat f) {
     timeFormatRaw(f != null ? f.toString() : null);
   }
-  private native void timeFormatRaw(String f)
-  /*-{ this.time_format = f }-*/;
+
+  private native void timeFormatRaw(String f) /*-{ this.time_format = f }-*/;
+
+  public final native void highlightAssigneeInChangeTable(boolean d)
+      /*-{ this.highlight_assignee_in_change_table = d }-*/ ;
 
   public final native void relativeDateInChangeTable(boolean d)
-  /*-{ this.relative_date_in_change_table = d }-*/;
+      /*-{ this.relative_date_in_change_table = d }-*/ ;
 
   public final native void sizeBarInChangeTable(boolean s)
-  /*-{ this.size_bar_in_change_table = s }-*/;
+      /*-{ this.size_bar_in_change_table = s }-*/ ;
 
   public final native void legacycidInChangeTable(boolean s)
-  /*-{ this.legacycid_in_change_table = s }-*/;
+      /*-{ this.legacycid_in_change_table = s }-*/ ;
 
   public final native void muteCommonPathPrefixes(boolean s)
-  /*-{ this.mute_common_path_prefixes = s }-*/;
+      /*-{ this.mute_common_path_prefixes = s }-*/ ;
 
-  public final native void signedOffBy(boolean s)
-  /*-{ this.signed_off_by = s }-*/;
+  public final native void signedOffBy(boolean s) /*-{ this.signed_off_by = s }-*/;
 
   public final void reviewCategoryStrategy(ReviewCategoryStrategy s) {
     reviewCategoryStrategyRaw(s != null ? s.toString() : null);
   }
+
   private native void reviewCategoryStrategyRaw(String s)
-  /*-{ this.review_category_strategy = s }-*/;
+      /*-{ this.review_category_strategy = s }-*/ ;
 
   public final void diffView(DiffView d) {
     diffViewRaw(d != null ? d.toString() : null);
   }
-  private native void diffViewRaw(String d)
-  /*-{ this.diff_view = d }-*/;
+
+  private native void diffViewRaw(String d) /*-{ this.diff_view = d }-*/;
 
   public final void emailStrategy(EmailStrategy s) {
     emailStrategyRaw(s != null ? s.toString() : null);
   }
-  private native void emailStrategyRaw(String s)
-  /*-{ this.email_strategy = s }-*/;
+
+  private native void emailStrategyRaw(String s) /*-{ this.email_strategy = s }-*/;
+
+  public final void emailFormat(EmailFormat f) {
+    emailFormatRaw(f != null ? f.toString() : null);
+  }
+
+  private native void emailFormatRaw(String s) /*-{ this.email_format = s }-*/;
+
+  public final void defaultBaseForMerges(DefaultBase b) {
+    defaultBaseForMergesRaw(b != null ? b.toString() : null);
+  }
+
+  private native void defaultBaseForMergesRaw(String b) /*-{ this.default_base_for_merges = b }-*/;
 
   public final void setMyMenus(List<TopMenuItem> myMenus) {
     initMy();
@@ -207,7 +230,9 @@
       addMy(n);
     }
   }
+
   final native void initMy() /*-{ this.my = []; }-*/;
+
   final native void addMy(TopMenuItem m) /*-{ this.my.push(m); }-*/;
 
   public final Map<String, String> urlAliases() {
@@ -219,6 +244,7 @@
   }
 
   private native String urlAliasToken(String m) /*-{ return this.url_aliases[m]; }-*/;
+
   private native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
 
   public final void setUrlAliases(Map<String, String> urlAliases) {
@@ -227,9 +253,10 @@
       putUrlAlias(e.getKey(), e.getValue());
     }
   }
+
   private native void putUrlAlias(String m, String t) /*-{ this.url_aliases[m] = t; }-*/;
+
   private native void initUrlAliases() /*-{ this.url_aliases = {}; }-*/;
 
-  protected GeneralPreferences() {
-  }
+  protected GeneralPreferences() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
index 750412d..78ca417 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GerritInfo.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.client.info;
 
+import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArrayString;
+import java.util.ArrayList;
+import java.util.List;
 
 public class GerritInfo extends JavaScriptObject {
   public final Project.NameKey allProjectsNameKey() {
@@ -35,13 +39,32 @@
   }
 
   public final native String allProjects() /*-{ return this.all_projects; }-*/;
+
   public final native String allUsers() /*-{ return this.all_users; }-*/;
+
   public final native boolean docSearch() /*-{ return this.doc_search; }-*/;
+
   public final native String docUrl() /*-{ return this.doc_url; }-*/;
+
   public final native boolean editGpgKeys() /*-{ return this.edit_gpg_keys || false; }-*/;
+
   public final native String reportBugUrl() /*-{ return this.report_bug_url; }-*/;
+
   public final native String reportBugText() /*-{ return this.report_bug_text; }-*/;
 
-  protected GerritInfo() {
+  private native JsArrayString _webUis() /*-{ return this.web_uis; }-*/;
+
+  public final List<UiType> webUis() {
+    JsArrayString webUis = _webUis();
+    List<UiType> result = new ArrayList<>(webUis.length());
+    for (int i = 0; i < webUis.length(); i++) {
+      UiType t = UiType.parse(webUis.get(i));
+      if (t != null) {
+        result.add(t);
+      }
+    }
+    return result;
   }
+
+  protected GerritInfo() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java
index 7955347..fd4fde7 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GpgKeyInfo.java
@@ -19,15 +19,21 @@
 
 public class GpgKeyInfo extends JavaScriptObject {
   public enum Status {
-    BAD, OK, TRUSTED;
+    BAD,
+    OK,
+    TRUSTED;
   }
 
   public final native String id() /*-{ return this.id; }-*/;
+
   public final native String fingerprint() /*-{ return this.fingerprint; }-*/;
+
   public final native JsArrayString userIds() /*-{ return this.user_ids; }-*/;
+
   public final native String key() /*-{ return this.key; }-*/;
 
   private native String statusRaw() /*-{ return this.status; }-*/;
+
   public final Status status() {
     String s = statusRaw();
     if (s == null) {
@@ -36,10 +42,9 @@
     return Status.valueOf(s);
   }
 
-  public final native boolean hasProblems()
-  /*-{ return this.hasOwnProperty('problems'); }-*/;
+  public final native boolean hasProblems() /*-{ return this.hasOwnProperty('problems'); }-*/;
+
   public final native JsArrayString problems() /*-{ return this.problems; }-*/;
 
-  protected GpgKeyInfo() {
-  }
+  protected GpgKeyInfo() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java
new file mode 100644
index 0000000..94905c0
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupBaseInfo.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.info;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.http.client.URL;
+
+public class GroupBaseInfo extends JavaScriptObject {
+  public final AccountGroup.UUID getGroupUUID() {
+    return new AccountGroup.UUID(URL.decodeQueryString(id()));
+  }
+
+  public final native String id() /*-{ return this.id; }-*/;
+
+  public final native String name() /*-{ return this.name; }-*/;
+
+  protected GroupBaseInfo() {}
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java
new file mode 100644
index 0000000..9bf3411a
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GroupInfo.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.info;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwt.http.client.URL;
+
+public class GroupInfo extends GroupBaseInfo {
+  public final AccountGroup.Id getGroupId() {
+    return new AccountGroup.Id(group_id());
+  }
+
+  public final native GroupOptionsInfo options() /*-{ return this.options; }-*/;
+
+  public final native String description() /*-{ return this.description; }-*/;
+
+  public final native String url() /*-{ return this.url; }-*/;
+
+  public final native String owner() /*-{ return this.owner; }-*/;
+
+  public final native void owner(String o) /*-{ if(o)this.owner=o; }-*/;
+
+  public final native JsArray<AccountInfo> members() /*-{ return this.members; }-*/;
+
+  public final native JsArray<GroupInfo> includes() /*-{ return this.includes; }-*/;
+
+  private native int group_id() /*-{ return this.group_id; }-*/;
+
+  private native String owner_id() /*-{ return this.owner_id; }-*/;
+
+  private native void owner_id(String o) /*-{ if(o)this.owner_id=o; }-*/;
+
+  public final AccountGroup.UUID getOwnerUUID() {
+    String owner = owner_id();
+    if (owner != null) {
+      return new AccountGroup.UUID(URL.decodeQueryString(owner));
+    }
+    return null;
+  }
+
+  public final void setOwnerUUID(AccountGroup.UUID uuid) {
+    owner_id(URL.encodeQueryString(uuid.get()));
+  }
+
+  protected GroupInfo() {}
+
+  public static class GroupOptionsInfo extends JavaScriptObject {
+    public final native boolean
+        isVisibleToAll() /*-{ return this['visible_to_all'] ? true : false; }-*/;
+
+    protected GroupOptionsInfo() {}
+  }
+}
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
index 08fd130..d96adaa 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/OAuthTokenInfo.java
@@ -16,16 +16,19 @@
 
 import com.google.gwt.core.client.JavaScriptObject;
 
-
 public class OAuthTokenInfo extends JavaScriptObject {
 
-  protected OAuthTokenInfo() {
-  }
+  protected OAuthTokenInfo() {}
 
   public final native String username() /*-{ return this.username; }-*/;
+
   public final native String resourceHost() /*-{ return this.resource_host; }-*/;
+
   public final native String accessToken() /*-{ return this.access_token; }-*/;
+
   public final native String providerId() /*-{ return this.provider_id; }-*/;
+
   public final native String expiresAt() /*-{ return this.expires_at; }-*/;
+
   public final native String type() /*-{ return this.type; }-*/;
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/PushCertificateInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/PushCertificateInfo.java
index ebfec1a..fb5d932 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/PushCertificateInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/PushCertificateInfo.java
@@ -18,8 +18,8 @@
 
 public class PushCertificateInfo extends JavaScriptObject {
   public final native String certificate() /*-{ return this.certificate; }-*/;
+
   public final native GpgKeyInfo key() /*-{ return this.key; }-*/;
 
-  protected PushCertificateInfo() {
-  }
+  protected PushCertificateInfo() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
index 112c4db..dcd1cf1 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
@@ -19,19 +19,26 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
-
 import java.util.HashMap;
 import java.util.Map;
 
 public class ServerInfo extends JavaScriptObject {
   public final native AuthInfo auth() /*-{ return this.auth; }-*/;
+
   public final native ChangeConfigInfo change() /*-{ return this.change; }-*/;
+
   public final native DownloadInfo download() /*-{ return this.download; }-*/;
+
   public final native GerritInfo gerrit() /*-{ return this.gerrit; }-*/;
+
   public final native PluginConfigInfo plugin() /*-{ return this.plugin; }-*/;
+
   public final native SshdInfo sshd() /*-{ return this.sshd; }-*/;
+
   public final native SuggestInfo suggest() /*-{ return this.suggest; }-*/;
+
   public final native UserConfigInfo user() /*-{ return this.user; }-*/;
+
   public final native ReceiveInfo receive() /*-{ return this.receive; }-*/;
 
   public final Map<String, String> urlAliases() {
@@ -43,63 +50,66 @@
   }
 
   public final native String urlAliasToken(String n) /*-{ return this.url_aliases[n]; }-*/;
-  private native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
 
+  private native NativeMap<NativeString> _urlAliases() /*-{ return this.url_aliases; }-*/;
 
   public final boolean hasSshd() {
     return sshd() != null;
   }
 
-  protected ServerInfo() {
-  }
+  protected ServerInfo() {}
 
   public static class ChangeConfigInfo extends JavaScriptObject {
     public final native boolean allowDrafts() /*-{ return this.allow_drafts || false; }-*/;
+
     public final native boolean allowBlame() /*-{ return this.allow_blame || false; }-*/;
+
     public final native int largeChange() /*-{ return this.large_change || 0; }-*/;
+
     public final native String replyLabel() /*-{ return this.reply_label; }-*/;
+
     public final native String replyTooltip() /*-{ return this.reply_tooltip; }-*/;
+
+    public final native boolean
+        showAssigneeInChangesTable() /*-{ return this.show_assignee_in_changes_table || false; }-*/;
+
     public final native int updateDelay() /*-{ return this.update_delay || 0; }-*/;
+
     public final native boolean isSubmitWholeTopicEnabled() /*-{
         return this.submit_whole_topic; }-*/;
 
-    protected ChangeConfigInfo() {
-    }
+    protected ChangeConfigInfo() {}
   }
 
   public static class PluginConfigInfo extends JavaScriptObject {
     public final native boolean hasAvatars() /*-{ return this.has_avatars || false; }-*/;
+
     public final native JsArrayString jsResourcePaths() /*-{
         return this.js_resource_paths || []; }-*/;
 
-    protected PluginConfigInfo() {
-    }
+    protected PluginConfigInfo() {}
   }
 
   public static class SshdInfo extends JavaScriptObject {
-    protected SshdInfo() {
-    }
+    protected SshdInfo() {}
   }
 
   public static class SuggestInfo extends JavaScriptObject {
     public final native int from() /*-{ return this.from || 0; }-*/;
 
-    protected SuggestInfo() {
-    }
+    protected SuggestInfo() {}
   }
 
   public static class UserConfigInfo extends JavaScriptObject {
     public final native String anonymousCowardName() /*-{ return this.anonymous_coward_name; }-*/;
 
-    protected UserConfigInfo() {
-    }
+    protected UserConfigInfo() {}
   }
 
   public static class ReceiveInfo extends JavaScriptObject {
     public final native boolean enableSignedPush()
-    /*-{ return this.enable_signed_push || false; }-*/;
+        /*-{ return this.enable_signed_push || false; }-*/ ;
 
-    protected ReceiveInfo() {
-    }
+    protected ReceiveInfo() {}
   }
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenu.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenu.java
index bad0475..7e25af2 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenu.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenu.java
@@ -19,8 +19,7 @@
 
 public class TopMenu extends JavaScriptObject {
 
-  protected TopMenu() {
-  }
+  protected TopMenu() {}
 
   public final native String getName() /*-{ return this.name; }-*/;
 
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuItem.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuItem.java
index 54cde9c..3a286a2 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuItem.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuItem.java
@@ -25,13 +25,16 @@
   }
 
   public final native String getName() /*-{ return this.name; }-*/;
+
   public final native String getUrl() /*-{ return this.url; }-*/;
+
   public final native String getTarget() /*-{ return this.target; }-*/;
+
   public final native String getId() /*-{ return this.id; }-*/;
 
   public final native void name(String n) /*-{ this.name = n }-*/;
+
   public final native void url(String u) /*-{ this.url = u }-*/;
 
-  protected TopMenuItem() {
-  }
+  protected TopMenuItem() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuList.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuList.java
index d51e778..d7df1f7 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuList.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/TopMenuList.java
@@ -18,6 +18,5 @@
 
 public class TopMenuList extends JsArray<TopMenu> {
 
-  protected TopMenuList() {
-  }
+  protected TopMenuList() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/WebLinkInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/WebLinkInfo.java
index 367486b..bcf3dde 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/WebLinkInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/WebLinkInfo.java
@@ -21,12 +21,14 @@
 public class WebLinkInfo extends JavaScriptObject {
 
   public final native String name() /*-{ return this.name; }-*/;
+
   public final native String imageUrl() /*-{ return this.image_url; }-*/;
+
   public final native String url() /*-{ return this.url; }-*/;
+
   public final native String target() /*-{ return this.target; }-*/;
 
-  protected WebLinkInfo() {
-  }
+  protected WebLinkInfo() {}
 
   public final Anchor toAnchor() {
     Anchor a = new Anchor();
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
index 1f003de..43ff60c 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
@@ -17,7 +17,6 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -30,21 +29,18 @@
   }
 
   /**
-   * Loop through the result map's entries and copy the key strings into the
-   * "name" property of the corresponding child object. This only runs on the
-   * top level map of the result, and requires the children to be JSON objects
-   * and not a JSON primitive (e.g. boolean or string).
+   * Loop through the result map's entries and copy the key strings into the "name" property of the
+   * corresponding child object. This only runs on the top level map of the result, and requires the
+   * children to be JSON objects and not a JSON primitive (e.g. boolean or string).
    */
-  public static <T extends JavaScriptObject,
-      M extends NativeMap<T>> AsyncCallback<M> copyKeysIntoChildren(
-      AsyncCallback<M> callback) {
+  public static <T extends JavaScriptObject, M extends NativeMap<T>>
+      AsyncCallback<M> copyKeysIntoChildren(AsyncCallback<M> callback) {
     return copyKeysIntoChildren("name", callback);
   }
 
   /** Loop through the result map and set asProperty on the children. */
-  public static <T extends JavaScriptObject,
-      M extends NativeMap<T>> AsyncCallback<M> copyKeysIntoChildren(
-      final String asProperty, AsyncCallback<M> callback) {
+  public static <T extends JavaScriptObject, M extends NativeMap<T>>
+      AsyncCallback<M> copyKeysIntoChildren(final String asProperty, AsyncCallback<M> callback) {
     return new TransformCallback<M, M>(callback) {
       @Override
       protected M transform(M result) {
@@ -54,8 +50,7 @@
     };
   }
 
-  protected NativeMap() {
-  }
+  protected NativeMap() {}
 
   public final Set<String> keySet() {
     return Natives.keys(this);
@@ -68,8 +63,7 @@
     return sorted;
   }
 
-  public final native JsArray<T> values()
-  /*-{
+  public final native JsArray<T> values() /*-{
     var s = this;
     var v = [];
     var i = 0;
@@ -94,10 +88,10 @@
   }
 
   public final native T get(String n) /*-{ return this[n]; }-*/;
+
   public final native void put(String n, T v) /*-{ this[n] = v; }-*/;
 
-  public final native void copyKeysIntoChildren(String p)
-  /*-{
+  public final native void copyKeysIntoChildren(String p) /*-{
     var s = this;
     for (var k in s) {
       if (s.hasOwnProperty(k)) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java
index 572d454..a4b90c3 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeString.java
@@ -34,13 +34,11 @@
     return wrap0(TYPE, s);
   }
 
-  private static native NativeString wrap0(JavaScriptObject T, String s)
-  /*-{ return new T(s) }-*/;
+  private static native NativeString wrap0(JavaScriptObject T, String s) /*-{ return new T(s) }-*/;
 
   public native String asString() /*-{ return this.s; }-*/;
 
-  public static AsyncCallback<NativeString>
-  unwrap(final AsyncCallback<String> cb) {
+  public static AsyncCallback<NativeString> unwrap(final AsyncCallback<String> cb) {
     return new AsyncCallback<NativeString>() {
       @Override
       public void onSuccess(NativeString result) {
@@ -59,8 +57,7 @@
   }
 
   private static native boolean is(JavaScriptObject T, JavaScriptObject o)
-  /*-{ return o instanceof T }-*/;
+      /*-{ return o instanceof T }-*/ ;
 
-  protected NativeString() {
-  }
+  protected NativeString() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
index dcd96da..ebaa63b 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
@@ -18,7 +18,6 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.json.client.JSONObject;
-
 import java.util.AbstractList;
 import java.util.Collections;
 import java.util.List;
@@ -26,8 +25,8 @@
 
 public class Natives {
   /**
-   * Get the names of defined properties on the object. The returned set
-   * iterates in the native iteration order, which may match the source order.
+   * Get the names of defined properties on the object. The returned set iterates in the native
+   * iteration order, which may match the source order.
    */
   public static Set<String> keys(JavaScriptObject obj) {
     if (obj != null) {
@@ -60,8 +59,7 @@
     };
   }
 
-  public static <T extends JavaScriptObject> List<T> asList(
-      final JsArray<T> arr) {
+  public static <T extends JavaScriptObject> List<T> asList(final JsArray<T> arr) {
     if (arr == null) {
       return null;
     }
@@ -105,6 +103,5 @@
     return arr;
   }
 
-  private Natives() {
-  }
+  private Natives() {}
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/HighlightSuggestion.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/HighlightSuggestion.java
index 10e20bf..513f570 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/HighlightSuggestion.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/HighlightSuggestion.java
@@ -33,7 +33,7 @@
     int start = 0;
     int keyLen = keyword.length();
     SafeHtmlBuilder builder = new SafeHtmlBuilder();
-    for (;;) {
+    for (; ; ) {
       int index = value.indexOf(keyword, start);
       if (index == -1) {
         builder.appendEscaped(value.substring(start));
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
index cf7e1d8..d66d6a6 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
@@ -14,23 +14,27 @@
 
 package com.google.gerrit.client.ui;
 
+import com.google.gwt.user.client.Timer;
 import com.google.gwt.user.client.ui.SuggestOracle;
 
 /**
  * Delegates to a slow SuggestOracle, such as a remote server API.
- * <p>
- * A response is only supplied to the UI if no requests were made after the
- * oracle begin that request.
- * <p>
- * When a request is made while the delegate is still processing a prior request
- * all intermediate requests are discarded and the most recent request is
- * queued. The pending request's response is discarded and the most recent
- * request is started.
+ *
+ * <p>A response is only supplied to the UI if no requests were made after the oracle begin that
+ * request.
+ *
+ * <p>When a request is made while the delegate is still processing a prior request all intermediate
+ * requests are discarded and the most recent request is queued. The pending request's response is
+ * discarded and the most recent request is started.
  */
 public class RemoteSuggestOracle extends SuggestOracle {
   private final SuggestOracle oracle;
   private Query query;
   private String last;
+  private Timer requestRetentionTimer;
+  private boolean cancelOutstandingRequest;
+
+  private boolean serveSuggestions;
 
   public RemoteSuggestOracle(SuggestOracle src) {
     oracle = src;
@@ -42,13 +46,34 @@
 
   @Override
   public void requestSuggestions(Request req, Callback cb) {
-    Query q = new Query(req, cb);
-    if (query == null) {
-      query = q;
-      q.start();
-    } else {
-      query = q;
+    if (!serveSuggestions) {
+      return;
     }
+
+    // Use a timer for key stroke retention, such that we don't query the
+    // backend for each and every keystroke we receive.
+    if (requestRetentionTimer != null) {
+      requestRetentionTimer.cancel();
+    }
+    requestRetentionTimer =
+        new Timer() {
+          @Override
+          public void run() {
+            Query q = new Query(req, cb);
+            if (query == null) {
+              query = q;
+              q.start();
+            } else {
+              query = q;
+            }
+          }
+        };
+    requestRetentionTimer.schedule(200);
+  }
+
+  @Override
+  public void requestDefaultSuggestions(Request req, Callback cb) {
+    requestSuggestions(req, cb);
   }
 
   @Override
@@ -56,6 +81,19 @@
     return oracle.isDisplayStringHTML();
   }
 
+  public void cancelOutstandingRequest() {
+    if (requestRetentionTimer != null) {
+      requestRetentionTimer.cancel();
+    }
+    if (query != null) {
+      cancelOutstandingRequest = true;
+    }
+  }
+
+  public void setServeSuggestions(boolean serveSuggestions) {
+    this.serveSuggestions = serveSuggestions;
+  }
+
   private class Query implements Callback {
     final Request request;
     final Callback callback;
@@ -71,7 +109,11 @@
 
     @Override
     public void onSuggestionsReady(Request req, Response res) {
-      if (query == this) {
+      if (cancelOutstandingRequest || !serveSuggestions) {
+        // If cancelOutstandingRequest() was called, we ignore this response
+        cancelOutstandingRequest = false;
+        query = null;
+      } else if (query == this) {
         // No new request was started while this query was running.
         // Propose this request's response as the suggestions.
         query = null;
diff --git a/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png
new file mode 100644
index 0000000..c1974cd
--- /dev/null
+++ b/gerrit-gwtui-common/src/main/resources/com/google/gerrit/client/user_edit.png
Binary files differ
diff --git a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java b/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
index 6705e51..6915ba7 100644
--- a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
+++ b/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
@@ -21,34 +21,44 @@
 import static com.google.gerrit.client.RelativeDateFormatter.YEAR_IN_MILLIS;
 import static org.junit.Assert.assertEquals;
 
-import org.eclipse.jgit.util.RelativeDateFormatter;
-import org.junit.Test;
-
 import java.util.Date;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
 
 public class RelativeDateFormatterTest {
 
-  private static void assertFormat(long ageFromNow, long timeUnit,
-      String expectedFormat) {
+  @BeforeClass
+  public static void setConstants() {
+    Constants c = new Constants();
+    RelativeDateFormatter.setConstants(c, c);
+  }
+
+  @AfterClass
+  public static void unsetConstants() {
+    RelativeDateFormatter.setConstants(null, null);
+  }
+
+  private static void assertFormat(long ageFromNow, long timeUnit, String expectedFormat) {
     Date d = new Date(System.currentTimeMillis() - ageFromNow * timeUnit);
     String s = RelativeDateFormatter.format(d);
     assertEquals(expectedFormat, s);
   }
 
   @Test
-  public void testFuture() {
+  public void future() {
     assertFormat(-100, YEAR_IN_MILLIS, "in the future");
     assertFormat(-1, SECOND_IN_MILLIS, "in the future");
   }
 
   @Test
-  public void testFormatSeconds() {
-    assertFormat(1, SECOND_IN_MILLIS, "1 seconds ago");
+  public void formatSeconds() {
+    assertFormat(1, SECOND_IN_MILLIS, "1 second ago");
     assertFormat(89, SECOND_IN_MILLIS, "89 seconds ago");
   }
 
   @Test
-  public void testFormatMinutes() {
+  public void formatMinutes() {
     assertFormat(90, SECOND_IN_MILLIS, "2 minutes ago");
     assertFormat(3, MINUTE_IN_MILLIS, "3 minutes ago");
     assertFormat(60, MINUTE_IN_MILLIS, "60 minutes ago");
@@ -56,43 +66,151 @@
   }
 
   @Test
-  public void testFormatHours() {
+  public void formatHours() {
     assertFormat(90, MINUTE_IN_MILLIS, "2 hours ago");
     assertFormat(149, MINUTE_IN_MILLIS, "2 hours ago");
     assertFormat(35, HOUR_IN_MILLIS, "35 hours ago");
   }
 
   @Test
-  public void testFormatDays() {
+  public void formatDays() {
     assertFormat(36, HOUR_IN_MILLIS, "2 days ago");
     assertFormat(13, DAY_IN_MILLIS, "13 days ago");
   }
 
   @Test
-  public void testFormatWeeks() {
+  public void formatWeeks() {
     assertFormat(14, DAY_IN_MILLIS, "2 weeks ago");
     assertFormat(69, DAY_IN_MILLIS, "10 weeks ago");
   }
 
   @Test
-  public void testFormatMonths() {
+  public void formatMonths() {
     assertFormat(70, DAY_IN_MILLIS, "2 months ago");
     assertFormat(75, DAY_IN_MILLIS, "3 months ago");
     assertFormat(364, DAY_IN_MILLIS, "12 months ago");
   }
 
   @Test
-  public void testFormatYearsMonths() {
+  public void formatYearsMonths() {
     assertFormat(366, DAY_IN_MILLIS, "1 year ago");
     assertFormat(380, DAY_IN_MILLIS, "1 year, 1 month ago");
     assertFormat(410, DAY_IN_MILLIS, "1 year, 2 months ago");
     assertFormat(2, YEAR_IN_MILLIS, "2 years ago");
-    assertFormat(1824, DAY_IN_MILLIS, "4 years, 12 months ago");
+    assertFormat(1824, DAY_IN_MILLIS, "5 years ago");
+    assertFormat(2 * 365 - 10, DAY_IN_MILLIS, "2 years ago");
   }
 
   @Test
-  public void testFormatYears() {
+  public void formatYears() {
     assertFormat(5, YEAR_IN_MILLIS, "5 years ago");
     assertFormat(60, YEAR_IN_MILLIS, "60 years ago");
   }
+
+  private static class Constants implements CommonConstants, CommonMessages {
+    @Override
+    public String inTheFuture() {
+      return "in the future";
+    }
+
+    @Override
+    public String month() {
+      return "month";
+    }
+
+    @Override
+    public String months() {
+      return "months";
+    }
+
+    @Override
+    public String year() {
+      return "year";
+    }
+
+    @Override
+    public String years() {
+      return "years";
+    }
+
+    @Override
+    public String oneSecondAgo() {
+      return "1 second ago";
+    }
+
+    @Override
+    public String oneMinuteAgo() {
+      return "1 minute ago";
+    }
+
+    @Override
+    public String oneHourAgo() {
+      return "1 hour ago";
+    }
+
+    @Override
+    public String oneDayAgo() {
+      return "1 day ago";
+    }
+
+    @Override
+    public String oneWeekAgo() {
+      return "1 week ago";
+    }
+
+    @Override
+    public String oneMonthAgo() {
+      return "1 month ago";
+    }
+
+    @Override
+    public String oneYearAgo() {
+      return "1 year ago";
+    }
+
+    @Override
+    public String secondsAgo(long seconds) {
+      return seconds + " seconds ago";
+    }
+
+    @Override
+    public String minutesAgo(long minutes) {
+      return minutes + " minutes ago";
+    }
+
+    @Override
+    public String hoursAgo(long hours) {
+      return hours + " hours ago";
+    }
+
+    @Override
+    public String daysAgo(long days) {
+      return days + " days ago";
+    }
+
+    @Override
+    public String weeksAgo(long weeks) {
+      return weeks + " weeks ago";
+    }
+
+    @Override
+    public String monthsAgo(long months) {
+      return months + " months ago";
+    }
+
+    @Override
+    public String yearsAgo(long years) {
+      return years + " years ago";
+    }
+
+    @Override
+    public String years0MonthsAgo(long years, String yearLabel) {
+      return years + " " + yearLabel + " ago";
+    }
+
+    @Override
+    public String yearsMonthsAgo(long years, String yearLabel, long months, String monthLabel) {
+      return years + " " + yearLabel + ", " + months + " " + monthLabel + " ago";
+    }
+  }
 }
diff --git a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/ui/HighlightSuggestionTest.java b/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/ui/HighlightSuggestionTest.java
index 44ed50b..9bf2d95 100644
--- a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/ui/HighlightSuggestionTest.java
+++ b/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/ui/HighlightSuggestionTest.java
@@ -25,9 +25,7 @@
     String keyword = "key";
     String value = "somethingkeysomething";
     HighlightSuggestion suggestion = new HighlightSuggestion(keyword, value);
-    assertEquals(
-        "something<strong>key</strong>something",
-        suggestion.getDisplayString());
+    assertEquals("something<strong>key</strong>something", suggestion.getDisplayString());
     assertEquals(value, suggestion.getReplacementString());
   }
 
diff --git a/gerrit-gwtui/BUCK b/gerrit-gwtui/BUCK
deleted file mode 100644
index 1e39831..0000000
--- a/gerrit-gwtui/BUCK
+++ /dev/null
@@ -1,67 +0,0 @@
-include_defs('//gerrit-gwtui/gwt.defs')
-include_defs('//tools/gwt-constants.defs')
-
-DEPS = GWT_TRANSITIVE_DEPS + [
-  '//gerrit-gwtexpui:CSS',
-  '//lib:gwtjsonrpc',
-  '//lib/gwt:dev',
-]
-
-gwt_genrule(MODULE, DEPS)
-gwt_genrule(MODULE, DEPS, '_r')
-
-gwt_user_agent_permutations(
-  name = 'ui',
-  module_name = 'gerrit_ui',
-  modules = [MODULE],
-  module_deps = [':ui_module'],
-  deps = DEPS,
-  visibility = ['//:'],
-)
-
-def gen_ui_module(name, suffix = ""):
-  gwt_module(
-    name = name + suffix,
-    srcs = glob(['src/main/java/**/*.java']),
-    gwt_xml = 'src/main/java/%s.gwt.xml' % MODULE.replace('.', '/'),
-    resources = glob(['src/main/java/**/*']),
-    deps = [
-      ':silk_icons',
-      '//gerrit-gwtui-common:diffy_logo',
-      '//gerrit-gwtui-common:client',
-      '//gerrit-gwtexpui:CSS',
-      '//lib/codemirror:codemirror' + suffix,
-      '//lib/gwt:user',
-    ],
-    visibility = [
-      '//tools/eclipse:classpath',
-      '//Documentation:licenses.txt',
-      '//Documentation:js_licenses.txt',
-    ],
-  )
-
-gen_ui_module(name = 'ui_module')
-gen_ui_module(name = 'ui_module', suffix = '_r')
-
-java_library(
-  name = 'silk_icons',
-  deps = [
-    '//lib:LICENSE-silk_icons',
-  ],
-)
-
-java_test(
-  name = 'ui_tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':ui_module',
-    '//gerrit-common:client',
-    '//gerrit-extension-api:client',
-    '//lib:junit',
-    '//lib/gwt:dev',
-    '//lib/gwt:user',
-  ],
-  source_under_test = [':ui_module'],
-  vm_args = ['-Xmx512m'],
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-gwtui/BUILD b/gerrit-gwtui/BUILD
new file mode 100644
index 0000000..27d1fc3
--- /dev/null
+++ b/gerrit-gwtui/BUILD
@@ -0,0 +1,40 @@
+load(
+    "//tools/bzl:gwt.bzl",
+    "gen_ui_module",
+    "gwt_genrule",
+    "gwt_user_agent_permutations",
+)
+load("//tools/bzl:junit.bzl", "junit_tests")
+load("//tools/bzl:license.bzl", "license_test")
+
+gwt_genrule()
+
+gwt_genrule(suffix = "_r")
+
+gen_ui_module(name = "ui_module")
+
+gen_ui_module(
+    name = "ui_module",
+    suffix = "_r",
+)
+
+gwt_user_agent_permutations()
+
+license_test(
+    name = "ui_module_license_test",
+    target = ":ui_module",
+)
+
+junit_tests(
+    name = "ui_tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":ui_module",
+        "//gerrit-common:client",
+        "//gerrit-extension-api:client",
+        "//lib:junit",
+        "//lib/gwt:dev",
+        "//lib/gwt:user",
+    ],
+)
diff --git a/gerrit-gwtui/gwt.defs b/gerrit-gwtui/gwt.defs
deleted file mode 100644
index cd8fa74..0000000
--- a/gerrit-gwtui/gwt.defs
+++ /dev/null
@@ -1,142 +0,0 @@
-# Copyright (C) 2013 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.
-from multiprocessing import cpu_count
-
-BROWSERS = [
-  'chrome',
-  'firefox',
-  'gecko1_8',
-  'safari',
-  'msie', 'ie8', 'ie9', 'ie10', 'ie11',
-  'edge',
-]
-ALIASES = {
-  'chrome': 'safari',
-  'firefox': 'gecko1_8',
-  'msie': 'ie11',
-  'edge': 'edge',
-}
-MODULE = 'com.google.gerrit.GerritGwtUI'
-CPU_COUNT = cpu_count()
-
-def gwt_genrule(module, deps, suffix = ""):
-  dbg = 'ui_dbg' + suffix
-  opt = 'ui_opt' + suffix
-  soyc = 'ui_soyc' + suffix
-  module_dep = ':ui_module' + suffix
-  args = GWT_COMPILER_ARGS_RELEASE_MODE if suffix == "_r" else GWT_COMPILER_ARGS
-
-  genrule(
-    name = 'ui_optdbg' + suffix,
-    cmd = 'cd $TMP;' +
-      'unzip -q $(location :%s);' % dbg +
-      'mv' +
-      ' gerrit_ui/gerrit_ui.nocache.js' +
-      ' gerrit_ui/dbg_gerrit_ui.nocache.js;' +
-      'unzip -qo $(location :%s);' % opt +
-      'mkdir -p \$(dirname $OUT);' +
-      'zip -qr $OUT .',
-    out = 'ui_optdbg' + suffix + '.zip',
-    visibility = ['PUBLIC'],
-  )
-
-  gwt_binary(
-    name = opt,
-    modules = [module],
-    module_deps = [module_dep],
-    deps = deps + ([':' + dbg] if CPU_COUNT < 8 else []),
-    local_workers = CPU_COUNT,
-    strict = True,
-    experimental_args = args,
-    vm_args = GWT_JVM_ARGS,
-  )
-
-  gwt_binary(
-    name = dbg,
-    modules = [module],
-    style = 'PRETTY',
-    optimize = 0,
-    module_deps = [module_dep],
-    deps = deps,
-    local_workers = CPU_COUNT,
-    strict = True,
-    experimental_args = args,
-    vm_args = GWT_JVM_ARGS,
-    visibility = ['PUBLIC'],
-  )
-
-  gwt_binary(
-    name = soyc,
-    modules = [module],
-    module_deps = [module_dep],
-    deps = deps + [':' + dbg],
-    local_workers = CPU_COUNT,
-    strict = True,
-    experimental_args = args + ['-compileReport'],
-    vm_args = GWT_JVM_ARGS,
-   )
-
-def gwt_user_agent_permutations(
-    name,
-    module_name,
-    modules,
-    style = 'PRETTY',
-    optimize = 0,
-    draft_compile = True,
-    module_deps = [],
-    deps = [],
-    browsers = BROWSERS,
-    visibility = []):
-  for ua in browsers:
-    impl = ua
-    if ua in ALIASES:
-      impl = ALIASES[ua]
-    xml = ''.join([
-      "<module rename-to='%s'>" % module_name,
-      "<inherits name='%s'/>" % modules[0],
-      "<set-property name='user.agent' value='%s'/>" % impl,
-      "<set-property name='locale' value='default'/>",
-      "</module>",
-    ])
-    gwt = '%s_%s.gwt.xml' % (modules[0].replace('.', '/'), ua)
-    gwt_name = '%s_%s' % (name, ua)
-    jar = '%s.gwtxml.jar' % (gwt_name)
-
-    genrule(
-      name = '%s_gwtxml_gen' % gwt_name,
-      cmd = 'cd $TMP;' +
-        ('mkdir -p \$(dirname %s);' % gwt) +
-        ('echo "%s">%s;' % (xml, gwt)) +
-        'zip -qr $OUT .',
-      out = jar,
-    )
-    prebuilt_jar(
-      name = '%s_gwtxml_lib' % gwt_name,
-      binary_jar = ':%s_gwtxml_gen' % gwt_name,
-      gwt_jar = ':%s_gwtxml_gen' % gwt_name,
-    )
-    gwt_binary(
-      name = gwt_name,
-      modules = [modules[0] + '_' + ua],
-      style = style,
-      optimize = optimize,
-      draft_compile = draft_compile,
-      module_deps = module_deps + [':%s_gwtxml_lib' % gwt_name],
-      deps = deps,
-      local_workers = CPU_COUNT,
-      strict = True,
-      experimental_args = GWT_COMPILER_ARGS,
-      vm_args = GWT_JVM_ARGS,
-      visibility = visibility,
-    )
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
index 4ca1721..107d663 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/AvatarImage.java
@@ -43,11 +43,9 @@
    * An avatar image for the given account using the requested size.
    *
    * @param account The account in which we are interested
-   * @param size A requested size. Note that the size can be ignored depending
-   *        on the avatar provider. A size <= 0 indicates to let the provider
-   *        decide a default size.
-   * @param addPopup show avatar popup with user info on hovering over the
-   *        avatar image
+   * @param size A requested size. Note that the size can be ignored depending on the avatar
+   *     provider. A size <= 0 indicates to let the provider decide a default size.
+   * @param addPopup show avatar popup with user info on hovering over the avatar image
    */
   public AvatarImage(AccountInfo account, int size, boolean addPopup) {
     addLoadHandler(this);
@@ -84,10 +82,9 @@
       return;
     }
 
-     // TODO Kill /accounts/*/avatar URL.
+    // TODO Kill /accounts/*/avatar URL.
     String u = account.email();
-    if (Gerrit.isSignedIn()
-        && u.equals(Gerrit.getUserAccount().email())) {
+    if (Gerrit.isSignedIn() && u.equals(Gerrit.getUserAccount().email())) {
       u = "self";
     }
     RestApi api = new RestApi("/accounts/").id(u).view("avatar");
@@ -114,8 +111,7 @@
   }
 
   private static boolean isGerritServer(AccountInfo account) {
-    return account._accountId() == 0
-        && Util.C.messageNoAuthor().equals(account.name());
+    return account._accountId() == 0 && Util.C.messageNoAuthor().equals(account.name());
   }
 
   private static class PopupHandler implements MouseOverHandler, MouseOutHandler {
@@ -133,18 +129,22 @@
 
     private UserPopupPanel createPopupPanel(AccountInfo account) {
       UserPopupPanel popup = new UserPopupPanel(account, false, false);
-      popup.addDomHandler(new MouseOverHandler() {
-        @Override
-        public void onMouseOver(MouseOverEvent event) {
-          scheduleShow();
-        }
-      }, MouseOverEvent.getType());
-      popup.addDomHandler(new MouseOutHandler() {
-        @Override
-        public void onMouseOut(MouseOutEvent event) {
-          scheduleHide();
-        }
-      }, MouseOutEvent.getType());
+      popup.addDomHandler(
+          new MouseOverHandler() {
+            @Override
+            public void onMouseOver(MouseOverEvent event) {
+              scheduleShow();
+            }
+          },
+          MouseOverEvent.getType());
+      popup.addDomHandler(
+          new MouseOutHandler() {
+            @Override
+            public void onMouseOut(MouseOutEvent event) {
+              scheduleHide();
+            }
+          },
+          MouseOutEvent.getType());
       return popup;
     }
 
@@ -163,22 +163,21 @@
         hideTimer.cancel();
         hideTimer = null;
       }
-      if ((popup != null && popup.isShowing() && popup.isVisible())
-          || showTimer != null) {
+      if ((popup != null && popup.isShowing() && popup.isVisible()) || showTimer != null) {
         return;
       }
-      showTimer = new Timer() {
-        @Override
-        public void run() {
-          if (popup == null) {
-            popup = createPopupPanel(account);
-          }
-          if (!popup.isShowing() || !popup.isVisible()) {
-            popup.showRelativeTo(target);
-          }
-
-        }
-      };
+      showTimer =
+          new Timer() {
+            @Override
+            public void run() {
+              if (popup == null) {
+                popup = createPopupPanel(account);
+              }
+              if (!popup.isShowing() || !popup.isVisible()) {
+                popup.showRelativeTo(target);
+              }
+            }
+          };
       showTimer.schedule(600);
     }
 
@@ -187,16 +186,16 @@
         showTimer.cancel();
         showTimer = null;
       }
-      if (popup == null || !popup.isShowing() || !popup.isVisible()
-              || hideTimer != null) {
+      if (popup == null || !popup.isShowing() || !popup.isVisible() || hideTimer != null) {
         return;
       }
-      hideTimer = new Timer() {
-        @Override
-        public void run() {
-          popup.hide();
-        }
-      };
+      hideTimer =
+          new Timer() {
+            @Override
+            public void run() {
+              popup.hide();
+            }
+          };
       hideTimer.schedule(50);
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationCallback.java
index 8fef111..cc30873 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationCallback.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationCallback.java
@@ -15,21 +15,19 @@
 package com.google.gerrit.client;
 
 /**
- * Interface that a caller must implement to react on the result of a
- * {@link ConfirmationDialog}.
+ * Interface that a caller must implement to react on the result of a {@link ConfirmationDialog}.
  */
 public abstract class ConfirmationCallback {
 
   /**
-   * Called when the {@link ConfirmationDialog} is finished with OK.
-   * To be overwritten by subclasses.
+   * Called when the {@link ConfirmationDialog} is finished with OK. To be overwritten by
+   * subclasses.
    */
   public abstract void onOk();
 
   /**
-   * Called when the {@link ConfirmationDialog} is finished with Cancel.
-   * To be overwritten by subclasses.
+   * Called when the {@link ConfirmationDialog} is finished with Cancel. To be overwritten by
+   * subclasses.
    */
-  public void onCancel() {
-  }
+  public void onCancel() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java
index ab3ff5d..58865fa 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java
@@ -28,9 +28,9 @@
   private Button cancelButton;
   private Button okButton;
 
-  public ConfirmationDialog(final String dialogTitle, final SafeHtml message,
-      final ConfirmationCallback callback) {
-    super(/* auto hide */false, /* modal */true);
+  public ConfirmationDialog(
+      final String dialogTitle, final SafeHtml message, final ConfirmationCallback callback) {
+    super(/* auto hide */ false, /* modal */ true);
     setGlassEnabled(true);
     setText(dialogTitle);
 
@@ -38,25 +38,27 @@
 
     okButton = new Button();
     okButton.setText(Gerrit.C.confirmationDialogOk());
-    okButton.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        hide();
-        callback.onOk();
-      }
-    });
+    okButton.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            hide();
+            callback.onOk();
+          }
+        });
     buttons.add(okButton);
 
     cancelButton = new Button();
     cancelButton.getElement().getStyle().setProperty("marginLeft", "300px");
     cancelButton.setText(Gerrit.C.confirmationDialogCancel());
-    cancelButton.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        hide();
-        callback.onCancel();
-      }
-    });
+    cancelButton.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            hide();
+            callback.onCancel();
+          }
+        });
     buttons.add(cancelButton);
 
     final FlowPanel center = new FlowPanel();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java
new file mode 100644
index 0000000..21ced4c
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffObject.java
@@ -0,0 +1,183 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client;
+
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DefaultBase;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+
+/**
+ * Represent an object that can be diffed. This can be either a regular patch set, the base of a
+ * patch set, the parent of a merge, the auto-merge of a merge or an edit patch set.
+ */
+public class DiffObject {
+  public static final String AUTO_MERGE = "AutoMerge";
+
+  /**
+   * Parses a string that represents a diff object.
+   *
+   * <p>The following string representations are supported:
+   *
+   * <ul>
+   *   <li>a positive integer: represents a patch set
+   *   <li>a negative integer: represents a parent of a merge patch set
+   *   <li>'0': represents the edit patch set
+   *   <li>empty string or null: represents the parent of a 1-parent patch set, also called base
+   *   <li>'AutoMerge': represents the auto-merge of a merge patch set
+   * </ul>
+   *
+   * @param changeId the ID of the change to which the diff object belongs
+   * @param str the string representation of the diff object
+   * @return the parsed diff object, {@code null} if str cannot be parsed as diff object
+   */
+  public static DiffObject parse(Change.Id changeId, String str) {
+    if (str == null || str.isEmpty()) {
+      return new DiffObject(false);
+    }
+
+    if (AUTO_MERGE.equals(str)) {
+      return new DiffObject(true);
+    }
+
+    return new DiffObject(Dispatcher.toPsId(changeId, str));
+  }
+
+  /** Create a DiffObject that represents the parent of a 1-parent patch set. */
+  public static DiffObject base() {
+    return new DiffObject(false);
+  }
+
+  /** Create a DiffObject that represents the auto-merge for a merge patch set. */
+  public static DiffObject autoMerge() {
+    return new DiffObject(true);
+  }
+
+  /** Create a DiffObject that represents a patch set. */
+  public static DiffObject patchSet(PatchSet.Id psId) {
+    return new DiffObject(psId);
+  }
+
+  private final PatchSet.Id psId;
+  private final boolean autoMerge;
+
+  private DiffObject(PatchSet.Id psId) {
+    this.psId = psId;
+    this.autoMerge = false;
+  }
+
+  private DiffObject(boolean autoMerge) {
+    this.psId = null;
+    this.autoMerge = autoMerge;
+  }
+
+  public boolean isBase() {
+    return psId == null && !autoMerge;
+  }
+
+  public boolean isAutoMerge() {
+    return psId == null && autoMerge;
+  }
+
+  public boolean isBaseOrAutoMerge() {
+    return psId == null;
+  }
+
+  public boolean isPatchSet() {
+    return psId != null && psId.get() > 0;
+  }
+
+  public boolean isParent() {
+    return psId != null && psId.get() < 0;
+  }
+
+  public boolean isEdit() {
+    return psId != null && psId.get() == 0;
+  }
+
+  /**
+   * Returns the DiffObject as PatchSet.Id.
+   *
+   * @return PatchSet.Id with an id > 0 for a regular patch set; PatchSet.Id with an id < 0 for a
+   *     parent of a merge; PatchSet.Id with id == 0 for an edit patch set; {@code null} for the
+   *     base of a 1-parent patch set and for the auto-merge of a merge patch set
+   */
+  public PatchSet.Id asPatchSetId() {
+    return psId;
+  }
+
+  /**
+   * Returns the parent number for a parent of a merge.
+   *
+   * @return 1-based parent number, 0 if this DiffObject is not a parent of a merge
+   */
+  public int getParentNum() {
+    if (!isParent()) {
+      return 0;
+    }
+
+    return -psId.get();
+  }
+
+  /**
+   * Returns a string representation of this DiffObject that can be used in URLs.
+   *
+   * <p>The following string representations are returned:
+   *
+   * <ul>
+   *   <li>a positive integer for a patch set
+   *   <li>a negative integer for a parent of a merge patch set
+   *   <li>'0' for the edit patch set
+   *   <li>{@code null} for the parent of a 1-parent patch set, also called base
+   *   <li>'AutoMerge' for the auto-merge of a merge patch set
+   * </ul>
+   *
+   * @return string representation of this DiffObject
+   */
+  public String asString() {
+    if (autoMerge) {
+      if (Gerrit.getUserPreferences().defaultBaseForMerges() != DefaultBase.AUTO_MERGE) {
+        return AUTO_MERGE;
+      }
+      return null;
+    }
+
+    if (psId != null) {
+      return psId.getId();
+    }
+
+    return null;
+  }
+
+  @Override
+  public String toString() {
+    if (isPatchSet()) {
+      return "Patch Set " + psId.getId();
+    }
+
+    if (isParent()) {
+      return "Parent " + psId.getId();
+    }
+
+    if (isEdit()) {
+      return "Edit Patch Set";
+    }
+
+    if (isAutoMerge()) {
+      return "Auto Merge";
+    }
+
+    return "Base";
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffWebLinkInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffWebLinkInfo.java
index d52838d..fe7016f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffWebLinkInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/DiffWebLinkInfo.java
@@ -18,11 +18,10 @@
 
 public class DiffWebLinkInfo extends WebLinkInfo {
   public final native boolean showOnSideBySideDiffView()
-  /*-{ return this.show_on_side_by_side_diff_view || false; }-*/;
+      /*-{ return this.show_on_side_by_side_diff_view || false; }-*/ ;
 
   public final native boolean showOnUnifiedDiffView()
-  /*-{ return this.show_on_unified_diff_view || false; }-*/;
+      /*-{ return this.show_on_unified_diff_view || false; }-*/ ;
 
-  protected DiffWebLinkInfo() {
-  }
+  protected DiffWebLinkInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
index ba4b202..f077b20 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
@@ -89,7 +89,7 @@
 import com.google.gerrit.client.documentation.DocScreen;
 import com.google.gerrit.client.editor.EditScreen;
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.Screen;
@@ -108,35 +108,32 @@
 import com.google.gwtorm.client.KeyUtil;
 
 public class Dispatcher {
-  public static String toPatch(PatchSet.Id diffBase,
-      PatchSet.Id revision, String fileName) {
+  public static String toPatch(DiffObject diffBase, PatchSet.Id revision, String fileName) {
     return toPatch("", diffBase, revision, fileName, null, 0);
   }
 
-  public static String toPatch(PatchSet.Id diffBase,
-      PatchSet.Id revision, String fileName, DisplaySide side, int line) {
+  public static String toPatch(
+      DiffObject diffBase, PatchSet.Id revision, String fileName, DisplaySide side, int line) {
     return toPatch("", diffBase, revision, fileName, side, line);
   }
 
-  public static String toSideBySide(PatchSet.Id diffBase, Patch.Key id) {
+  public static String toSideBySide(DiffObject diffBase, Patch.Key id) {
     return toPatch("sidebyside", diffBase, id);
   }
 
-  public static String toSideBySide(PatchSet.Id diffBase,
-      PatchSet.Id revision, String fileName) {
+  public static String toSideBySide(DiffObject diffBase, PatchSet.Id revision, String fileName) {
     return toPatch("sidebyside", diffBase, revision, fileName, null, 0);
   }
 
-  public static String toUnified(PatchSet.Id diffBase,
-      PatchSet.Id revision, String fileName) {
+  public static String toUnified(DiffObject diffBase, PatchSet.Id revision, String fileName) {
     return toPatch("unified", diffBase, revision, fileName, null, 0);
   }
 
-  public static String toUnified(PatchSet.Id diffBase, Patch.Key id) {
+  public static String toUnified(DiffObject diffBase, Patch.Key id) {
     return toPatch("unified", diffBase, id);
   }
 
-  public static String toPatch(String type, PatchSet.Id diffBase, Patch.Key id) {
+  public static String toPatch(String type, DiffObject diffBase, Patch.Key id) {
     return toPatch(type, diffBase, id.getParentKey(), id.get(), null, 0);
   }
 
@@ -145,20 +142,24 @@
   }
 
   public static String toEditScreen(PatchSet.Id revision, String fileName, int line) {
-    return toPatch("edit", null, revision, fileName, null, line);
+    return toPatch("edit", DiffObject.base(), revision, fileName, null, line);
   }
 
-  private static String toPatch(String type, PatchSet.Id diffBase,
-      PatchSet.Id revision, String fileName, DisplaySide side, int line) {
+  private static String toPatch(
+      String type,
+      DiffObject diffBase,
+      PatchSet.Id revision,
+      String fileName,
+      DisplaySide side,
+      int line) {
     Change.Id c = revision.getParentKey();
     StringBuilder p = new StringBuilder();
     p.append("/c/").append(c).append("/");
-    if (diffBase != null) {
-      p.append(diffBase.get()).append("..");
+    if (diffBase != null && diffBase.asString() != null) {
+      p.append(diffBase.asString()).append("..");
     }
     p.append(revision.getId()).append("/").append(KeyUtil.encode(fileName));
-    if (type != null && !type.isEmpty()
-        && (!"sidebyside".equals(type) || preferUnified())) {
+    if (type != null && !type.isEmpty() && (!"sidebyside".equals(type) || preferUnified())) {
       p.append(",").append(type);
     }
     if (side == DisplaySide.A && line > 0) {
@@ -252,7 +253,8 @@
         || matchExact("register", token)
         || matchExact(REGISTER, token)
         || matchPrefix("/register/", token)
-        || matchPrefix("/VE/", token) || matchPrefix("VE,", token)
+        || matchPrefix("/VE/", token)
+        || matchPrefix("VE,", token)
         || matchPrefix("/SignInFailure,", token)) {
       settings(token);
 
@@ -325,27 +327,28 @@
       rest = rest.substring(c);
       if (matchPrefix(DASHBOARDS, rest)) {
         final String dashboardId = skip(rest);
-        GerritCallback<DashboardInfo> cb = new GerritCallback<DashboardInfo>() {
-          @Override
-          public void onSuccess(DashboardInfo result) {
-            if (matchPrefix("/dashboard/", result.url())) {
-              String params = skip(result.url()).substring(1);
-              ProjectDashboardScreen dash = new ProjectDashboardScreen(
-                  new Project.NameKey(project), params);
-              Gerrit.display(token, dash);
-            }
-          }
+        GerritCallback<DashboardInfo> cb =
+            new GerritCallback<DashboardInfo>() {
+              @Override
+              public void onSuccess(DashboardInfo result) {
+                if (matchPrefix("/dashboard/", result.url())) {
+                  String params = skip(result.url()).substring(1);
+                  ProjectDashboardScreen dash =
+                      new ProjectDashboardScreen(new Project.NameKey(project), params);
+                  Gerrit.display(token, dash);
+                }
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-            if ("default".equals(dashboardId) && RestApi.isNotFound(caught)) {
-              Gerrit.display(toChangeQuery(
-                  PageLinks.projectQuery(new Project.NameKey(project))));
-            } else {
-              super.onFailure(caught);
-            }
-          }
-        };
+              @Override
+              public void onFailure(Throwable caught) {
+                if ("default".equals(dashboardId) && RestApi.isNotFound(caught)) {
+                  Gerrit.display(
+                      toChangeQuery(PageLinks.projectQuery(new Project.NameKey(project))));
+                } else {
+                  super.onFailure(caught);
+                }
+              }
+            };
         if ("default".equals(dashboardId)) {
           DashboardList.getDefault(new Project.NameKey(project), cb);
           return;
@@ -389,14 +392,15 @@
 
     if (rest.isEmpty()) {
       FileTable.Mode mode = FileTable.Mode.REVIEW;
-      if (panel != null
-          && (panel.equals("edit") || panel.startsWith("edit/"))) {
+      if (panel != null && (panel.equals("edit") || panel.startsWith("edit/"))) {
         mode = FileTable.Mode.EDIT;
         panel = null;
       }
-      Gerrit.display(token, panel == null
-          ? new ChangeScreen(id, null, null, false, mode)
-          : new NotFoundScreen());
+      Gerrit.display(
+          token,
+          panel == null
+              ? new ChangeScreen(id, DiffObject.base(), null, false, mode)
+              : new NotFoundScreen());
       return;
     }
 
@@ -410,11 +414,14 @@
       rest = "";
     }
 
-    PatchSet.Id base = null;
+    DiffObject base = DiffObject.base();
     PatchSet.Id ps;
     int dotdot = psIdStr.indexOf("..");
     if (1 <= dotdot) {
-      base = new PatchSet.Id(id, Integer.parseInt(psIdStr.substring(0, dotdot)));
+      base = DiffObject.parse(id, psIdStr.substring(0, dotdot));
+      if (base == null) {
+        Gerrit.display(token, new NotFoundScreen());
+      }
       psIdStr = psIdStr.substring(dotdot + 2);
     }
     ps = toPsId(id, psIdStr);
@@ -436,22 +443,17 @@
       patch(token, base, p, side, line, panel);
     } else {
       if (panel == null) {
-        Gerrit.display(token,
-            new ChangeScreen(id,
-                base != null
-                    ? String.valueOf(base.get())
-                    : null,
-                String.valueOf(ps.get()), false, FileTable.Mode.REVIEW));
+        Gerrit.display(
+            token,
+            new ChangeScreen(id, base, String.valueOf(ps.get()), false, FileTable.Mode.REVIEW));
       } else {
         Gerrit.display(token, new NotFoundScreen());
       }
     }
   }
 
-  private static PatchSet.Id toPsId(Change.Id id, String psIdStr) {
-    return new PatchSet.Id(id, psIdStr.equals("edit")
-        ? 0
-        : Integer.parseInt(psIdStr));
+  public static PatchSet.Id toPsId(Change.Id id, String psIdStr) {
+    return new PatchSet.Id(id, psIdStr.equals("edit") ? 0 : Integer.parseInt(psIdStr));
   }
 
   private static void extension(final String token) {
@@ -463,30 +465,30 @@
     }
   }
 
-  private static void patch(String token,
-      PatchSet.Id baseId,
-      Patch.Key id,
-      DisplaySide side,
-      int line,
-      String panelType) {
+  private static void patch(
+      String token, DiffObject base, Patch.Key id, DisplaySide side, int line, String panelType) {
     String panel = panelType;
     if (panel == null) {
       int c = token.lastIndexOf(',');
       panel = 0 <= c ? token.substring(c + 1) : "";
     }
 
-    if ("".equals(panel) || /* DEPRECATED URL */"cm".equals(panel)) {
+    if ("".equals(panel) || /* DEPRECATED URL */ "cm".equals(panel)) {
       if (preferUnified()) {
-        unified(token, baseId, id, side, line);
+        unified(token, base, id, side, line);
       } else {
-        codemirror(token, baseId, id, side, line, false);
+        codemirror(token, base, id, side, line);
       }
     } else if ("sidebyside".equals(panel)) {
-      codemirror(token, baseId, id, side, line, false);
+      codemirror(token, base, id, side, line);
     } else if ("unified".equals(panel)) {
-      unified(token, baseId, id, side, line);
+      unified(token, base, id, side, line);
     } else if ("edit".equals(panel)) {
-      codemirror(token, null, id, side, line, true);
+      if (!Patch.isMagic(id.get()) || Patch.COMMIT_MSG.equals(id.get())) {
+        codemirrorForEdit(token, id, line);
+      } else {
+        Gerrit.display(token, new NotFoundScreen());
+      }
     } else {
       Gerrit.display(token, new NotFoundScreen());
     }
@@ -497,292 +499,303 @@
         || (UserAgent.isPortrait() && UserAgent.isMobile());
   }
 
-  private static void unified(final String token, final PatchSet.Id baseId,
-      final Patch.Key id, final DisplaySide side, final int line) {
-    GWT.runAsync(new AsyncSplit(token) {
-      @Override
-      public void onSuccess() {
-        Gerrit.display(token,
-            new Unified(baseId, id.getParentKey(), id.get(), side, line));
-      }
-    });
+  private static void unified(
+      final String token,
+      final DiffObject base,
+      final Patch.Key id,
+      final DisplaySide side,
+      final int line) {
+    GWT.runAsync(
+        new AsyncSplit(token) {
+          @Override
+          public void onSuccess() {
+            Gerrit.display(
+                token,
+                new Unified(base, DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
+          }
+        });
   }
 
-  private static void codemirror(final String token, final PatchSet.Id baseId,
-      final Patch.Key id, final DisplaySide side, final int line,
-      final boolean edit) {
-    GWT.runAsync(new AsyncSplit(token) {
-      @Override
-      public void onSuccess() {
-        Gerrit.display(token, edit
-            ? new EditScreen(baseId, id, line)
-            : new SideBySide(baseId, id.getParentKey(), id.get(), side, line));
-      }
-    });
+  private static void codemirror(
+      final String token,
+      final DiffObject base,
+      final Patch.Key id,
+      final DisplaySide side,
+      final int line) {
+    GWT.runAsync(
+        new AsyncSplit(token) {
+          @Override
+          public void onSuccess() {
+            Gerrit.display(
+                token,
+                new SideBySide(base, DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
+          }
+        });
+  }
+
+  private static void codemirrorForEdit(final String token, final Patch.Key id, final int line) {
+    GWT.runAsync(
+        new AsyncSplit(token) {
+          @Override
+          public void onSuccess() {
+            Gerrit.display(token, new EditScreen(id, line));
+          }
+        });
   }
 
   private static void settings(String token) {
-    GWT.runAsync(new AsyncSplit(token) {
-      @Override
-      public void onSuccess() {
-        Gerrit.display(token, select());
-      }
-
-      private Screen select() {
-        if (matchExact(SETTINGS, token)) {
-          return new MyProfileScreen();
-        }
-
-        if (matchExact(SETTINGS_PREFERENCES, token)) {
-          return new MyPreferencesScreen();
-        }
-
-        if (matchExact(SETTINGS_DIFF_PREFERENCES, token)) {
-          return new MyDiffPreferencesScreen();
-        }
-
-        if (matchExact(SETTINGS_EDIT_PREFERENCES, token)) {
-          return new MyEditPreferencesScreen();
-        }
-
-        if (matchExact(SETTINGS_PROJECTS, token)) {
-          return new MyWatchedProjectsScreen();
-        }
-
-        if (matchExact(SETTINGS_CONTACT, token)) {
-          return new MyContactInformationScreen();
-        }
-
-        if (matchExact(SETTINGS_SSHKEYS, token)) {
-          return new MySshKeysScreen();
-        }
-
-        if (matchExact(SETTINGS_GPGKEYS, token)
-            && Gerrit.info().gerrit().editGpgKeys()) {
-          return new MyGpgKeysScreen();
-        }
-
-        if (matchExact(SETTINGS_WEBIDENT, token)) {
-          return new MyIdentitiesScreen();
-        }
-
-        if (matchExact(SETTINGS_HTTP_PASSWORD, token)) {
-          return new MyPasswordScreen();
-        }
-
-        if (matchExact(SETTINGS_OAUTH_TOKEN, token)
-            && Gerrit.info().auth().isOAuth()
-            && Gerrit.info().auth().isGitBasicAuth()) {
-          return new MyOAuthTokenScreen();
-        }
-
-        if (matchExact(MY_GROUPS, token)
-            || matchExact(SETTINGS_MYGROUPS, token)) {
-          return new MyGroupsScreen();
-        }
-
-        if (matchExact(SETTINGS_AGREEMENTS, token)
-            && Gerrit.info().auth().useContributorAgreements()) {
-          return new MyAgreementsScreen();
-        }
-
-        if (matchExact(REGISTER, token)
-            || matchExact("/register/", token)
-            || matchExact("register", token)) {
-          return new RegisterScreen(MINE);
-        } else if (matchPrefix("/register/", token)) {
-          return new RegisterScreen("/" + skip(token));
-        }
-
-        if (matchPrefix("/VE/", token) || matchPrefix("VE,", token)) {
-          return new ValidateEmailScreen(skip(token));
-        }
-
-        if (matchExact(SETTINGS_NEW_AGREEMENT, token)) {
-          return new NewAgreementScreen();
-        }
-
-        if (matchPrefix(SETTINGS_NEW_AGREEMENT + "/", token)) {
-          return new NewAgreementScreen(skip(token));
-        }
-
-        if (matchPrefix(SETTINGS_EXTENSION, token)) {
-          ExtensionSettingsScreen view =
-              new ExtensionSettingsScreen(skip(token));
-          if (view.isFound()) {
-            return view;
+    GWT.runAsync(
+        new AsyncSplit(token) {
+          @Override
+          public void onSuccess() {
+            Gerrit.display(token, select());
           }
-          return new NotFoundScreen();
-        }
 
-        return new NotFoundScreen();
-      }
-    });
+          private Screen select() {
+            if (matchExact(SETTINGS, token)) {
+              return new MyProfileScreen();
+            }
+
+            if (matchExact(SETTINGS_PREFERENCES, token)) {
+              return new MyPreferencesScreen();
+            }
+
+            if (matchExact(SETTINGS_DIFF_PREFERENCES, token)) {
+              return new MyDiffPreferencesScreen();
+            }
+
+            if (matchExact(SETTINGS_EDIT_PREFERENCES, token)) {
+              return new MyEditPreferencesScreen();
+            }
+
+            if (matchExact(SETTINGS_PROJECTS, token)) {
+              return new MyWatchedProjectsScreen();
+            }
+
+            if (matchExact(SETTINGS_CONTACT, token)) {
+              return new MyContactInformationScreen();
+            }
+
+            if (matchExact(SETTINGS_SSHKEYS, token)) {
+              return new MySshKeysScreen();
+            }
+
+            if (matchExact(SETTINGS_GPGKEYS, token) && Gerrit.info().gerrit().editGpgKeys()) {
+              return new MyGpgKeysScreen();
+            }
+
+            if (matchExact(SETTINGS_WEBIDENT, token)) {
+              return new MyIdentitiesScreen();
+            }
+
+            if (matchExact(SETTINGS_HTTP_PASSWORD, token)) {
+              return new MyPasswordScreen();
+            }
+
+            if (matchExact(SETTINGS_OAUTH_TOKEN, token) && Gerrit.info().auth().isOAuth()) {
+              return new MyOAuthTokenScreen();
+            }
+
+            if (matchExact(MY_GROUPS, token) || matchExact(SETTINGS_MYGROUPS, token)) {
+              return new MyGroupsScreen();
+            }
+
+            if (matchExact(SETTINGS_AGREEMENTS, token)
+                && Gerrit.info().auth().useContributorAgreements()) {
+              return new MyAgreementsScreen();
+            }
+
+            if (matchExact(REGISTER, token)
+                || matchExact("/register/", token)
+                || matchExact("register", token)) {
+              return new RegisterScreen(MINE);
+            } else if (matchPrefix("/register/", token)) {
+              return new RegisterScreen("/" + skip(token));
+            }
+
+            if (matchPrefix("/VE/", token) || matchPrefix("VE,", token)) {
+              return new ValidateEmailScreen(skip(token));
+            }
+
+            if (matchExact(SETTINGS_NEW_AGREEMENT, token)) {
+              return new NewAgreementScreen();
+            }
+
+            if (matchPrefix(SETTINGS_NEW_AGREEMENT + "/", token)) {
+              return new NewAgreementScreen(skip(token));
+            }
+
+            if (matchPrefix(SETTINGS_EXTENSION, token)) {
+              ExtensionSettingsScreen view = new ExtensionSettingsScreen(skip(token));
+              if (view.isFound()) {
+                return view;
+              }
+              return new NotFoundScreen();
+            }
+
+            return new NotFoundScreen();
+          }
+        });
   }
 
   private static void admin(String token) {
-    GWT.runAsync(new AsyncSplit(token) {
-      @Override
-      public void onSuccess() {
-        if (matchExact(ADMIN_GROUPS, token)
-            || matchExact("/admin/groups", token)) {
-          Gerrit.display(token, new GroupListScreen());
-
-        } else if (matchPrefix(ADMIN_GROUPS, token)) {
-          String rest = skip(token);
-          if (rest.startsWith("?")) {
-            Gerrit.display(token, new GroupListScreen(rest.substring(1)));
-          } else {
-            group();
-          }
-
-        } else if (matchPrefix("/admin/groups", token)) {
-          String rest = skip(token);
-          if (rest.startsWith("?")) {
-            Gerrit.display(token, new GroupListScreen(rest.substring(1)));
-          }
-
-        } else if (matchExact(ADMIN_PROJECTS, token)
-            || matchExact("/admin/projects", token)) {
-          Gerrit.display(token, new ProjectListScreen());
-
-        } else if (matchPrefix(ADMIN_PROJECTS, token)) {
-            String rest = skip(token);
-            if (rest.startsWith("?")) {
-              Gerrit.display(token, new ProjectListScreen(rest.substring(1)));
-            } else {
-              Gerrit.display(token, selectProject());
-            }
-
-        } else if (matchPrefix("/admin/projects", token)) {
-          String rest = skip(token);
-          if (rest.startsWith("?")) {
-            Gerrit.display(token, new ProjectListScreen(rest.substring(1)));
-          }
-
-        } else if (matchPrefix(ADMIN_PLUGINS, token)
-            || matchExact("/admin/plugins", token)) {
-          Gerrit.display(token, new PluginListScreen());
-
-        } else if (matchExact(ADMIN_CREATE_PROJECT, token)
-            || matchExact("/admin/create-project", token)) {
-          Gerrit.display(token, new CreateProjectScreen());
-
-        } else if (matchExact(ADMIN_CREATE_GROUP, token)
-            || matchExact("/admin/create-group", token)) {
-          Gerrit.display(token, new CreateGroupScreen());
-
-        } else {
-          Gerrit.display(token, new NotFoundScreen());
-        }
-      }
-
-      private void group() {
-        final String panel;
-        final String group;
-
-        if (matchPrefix("/admin/groups/uuid-", token)) {
-          String p = skip(token);
-          int c = p.indexOf(',');
-          if (c < 0) {
-            group = p;
-            panel = null;
-          } else {
-            group = p.substring(0, c);
-            panel = p.substring(c + 1);
-          }
-        } else if (matchPrefix(ADMIN_GROUPS, token)) {
-          String p = skip(token);
-          int c = p.indexOf(',');
-          if (c < 0) {
-            group = p;
-            panel = null;
-          } else {
-            group = p.substring(0, c);
-            panel = p.substring(c + 1);
-          }
-        } else {
-          Gerrit.display(token, new NotFoundScreen());
-          return;
-        }
-
-        GroupApi.getGroupDetail(group, new GerritCallback<GroupInfo>() {
+    GWT.runAsync(
+        new AsyncSplit(token) {
           @Override
-          public void onSuccess(GroupInfo group) {
-            if (panel == null || panel.isEmpty()) {
-              // The token does not say which group screen should be shown,
-              // as default for internal groups show the members, as default
-              // for external and system groups show the info screen (since
-              // for external and system groups the members cannot be
-              // shown in the web UI).
-              //
-              if (AccountGroup.isInternalGroup(group.getGroupUUID())) {
-                String newToken =
-                    toGroup(group.getGroupId(), AccountGroupScreen.MEMBERS);
-                Gerrit.display(newToken,
-                    new AccountGroupMembersScreen(group, newToken));
+          public void onSuccess() {
+            if (matchExact(ADMIN_GROUPS, token) || matchExact("/admin/groups", token)) {
+              Gerrit.display(token, new GroupListScreen());
+
+            } else if (matchPrefix(ADMIN_GROUPS, token)) {
+              String rest = skip(token);
+              if (rest.startsWith("?")) {
+                Gerrit.display(token, new GroupListScreen(rest.substring(1)));
               } else {
-                String newToken =
-                    toGroup(group.getGroupId(), AccountGroupScreen.INFO);
-                Gerrit.display(newToken,
-                    new AccountGroupInfoScreen(group, newToken));
+                group();
               }
-            } else if (AccountGroupScreen.INFO.equals(panel)) {
-              Gerrit.display(token, new AccountGroupInfoScreen(group, token));
-            } else if (AccountGroupScreen.MEMBERS.equals(panel)) {
-              Gerrit.display(token, new AccountGroupMembersScreen(group, token));
-            } else if (AccountGroupScreen.AUDIT_LOG.equals(panel)) {
-              Gerrit.display(token, new AccountGroupAuditLogScreen(group, token));
+
+            } else if (matchPrefix("/admin/groups", token)) {
+              String rest = skip(token);
+              if (rest.startsWith("?")) {
+                Gerrit.display(token, new GroupListScreen(rest.substring(1)));
+              }
+
+            } else if (matchExact(ADMIN_PROJECTS, token) || matchExact("/admin/projects", token)) {
+              Gerrit.display(token, new ProjectListScreen());
+
+            } else if (matchPrefix(ADMIN_PROJECTS, token)) {
+              String rest = skip(token);
+              if (rest.startsWith("?")) {
+                Gerrit.display(token, new ProjectListScreen(rest.substring(1)));
+              } else {
+                Gerrit.display(token, selectProject());
+              }
+
+            } else if (matchPrefix("/admin/projects", token)) {
+              String rest = skip(token);
+              if (rest.startsWith("?")) {
+                Gerrit.display(token, new ProjectListScreen(rest.substring(1)));
+              }
+
+            } else if (matchPrefix(ADMIN_PLUGINS, token) || matchExact("/admin/plugins", token)) {
+              Gerrit.display(token, new PluginListScreen());
+
+            } else if (matchExact(ADMIN_CREATE_PROJECT, token)
+                || matchExact("/admin/create-project", token)) {
+              Gerrit.display(token, new CreateProjectScreen());
+
+            } else if (matchExact(ADMIN_CREATE_GROUP, token)
+                || matchExact("/admin/create-group", token)) {
+              Gerrit.display(token, new CreateGroupScreen());
+
             } else {
               Gerrit.display(token, new NotFoundScreen());
             }
           }
-        });
-      }
 
-      private Screen selectProject() {
-        if (matchPrefix(ADMIN_PROJECTS, token)) {
-          String rest = skip(token);
-          int c = rest.lastIndexOf(',');
-          if (c < 0) {
-            return new ProjectInfoScreen(Project.NameKey.parse(rest));
-          } else if (c == 0) {
+          private void group() {
+            final String panel;
+            final String group;
+
+            if (matchPrefix("/admin/groups/uuid-", token)) {
+              String p = skip(token);
+              int c = p.indexOf(',');
+              if (c < 0) {
+                group = p;
+                panel = null;
+              } else {
+                group = p.substring(0, c);
+                panel = p.substring(c + 1);
+              }
+            } else if (matchPrefix(ADMIN_GROUPS, token)) {
+              String p = skip(token);
+              int c = p.indexOf(',');
+              if (c < 0) {
+                group = p;
+                panel = null;
+              } else {
+                group = p.substring(0, c);
+                panel = p.substring(c + 1);
+              }
+            } else {
+              Gerrit.display(token, new NotFoundScreen());
+              return;
+            }
+
+            GroupApi.getGroupDetail(
+                group,
+                new GerritCallback<GroupInfo>() {
+                  @Override
+                  public void onSuccess(GroupInfo group) {
+                    if (panel == null || panel.isEmpty()) {
+                      // The token does not say which group screen should be shown,
+                      // as default for internal groups show the members, as default
+                      // for external and system groups show the info screen (since
+                      // for external and system groups the members cannot be
+                      // shown in the web UI).
+                      //
+                      if (AccountGroup.isInternalGroup(group.getGroupUUID())) {
+                        String newToken = toGroup(group.getGroupId(), AccountGroupScreen.MEMBERS);
+                        Gerrit.display(newToken, new AccountGroupMembersScreen(group, newToken));
+                      } else {
+                        String newToken = toGroup(group.getGroupId(), AccountGroupScreen.INFO);
+                        Gerrit.display(newToken, new AccountGroupInfoScreen(group, newToken));
+                      }
+                    } else if (AccountGroupScreen.INFO.equals(panel)) {
+                      Gerrit.display(token, new AccountGroupInfoScreen(group, token));
+                    } else if (AccountGroupScreen.MEMBERS.equals(panel)) {
+                      Gerrit.display(token, new AccountGroupMembersScreen(group, token));
+                    } else if (AccountGroupScreen.AUDIT_LOG.equals(panel)) {
+                      Gerrit.display(token, new AccountGroupAuditLogScreen(group, token));
+                    } else {
+                      Gerrit.display(token, new NotFoundScreen());
+                    }
+                  }
+                });
+          }
+
+          private Screen selectProject() {
+            if (matchPrefix(ADMIN_PROJECTS, token)) {
+              String rest = skip(token);
+              int c = rest.lastIndexOf(',');
+              if (c < 0) {
+                return new ProjectInfoScreen(Project.NameKey.parse(rest));
+              } else if (c == 0) {
+                return new NotFoundScreen();
+              }
+
+              int q = rest.lastIndexOf('?');
+              if (q > 0 && rest.lastIndexOf(',', q) > 0) {
+                c = rest.substring(0, q - 1).lastIndexOf(',');
+              }
+
+              Project.NameKey k = Project.NameKey.parse(rest.substring(0, c));
+              String panel = rest.substring(c + 1);
+
+              if (ProjectScreen.INFO.equals(panel)) {
+                return new ProjectInfoScreen(k);
+              }
+
+              if (ProjectScreen.BRANCHES.equals(panel)
+                  || matchPrefix(ProjectScreen.BRANCHES, panel)) {
+                return new ProjectBranchesScreen(k);
+              }
+
+              if (ProjectScreen.TAGS.equals(panel) || matchPrefix(ProjectScreen.TAGS, panel)) {
+                return new ProjectTagsScreen(k);
+              }
+
+              if (ProjectScreen.ACCESS.equals(panel)) {
+                return new ProjectAccessScreen(k);
+              }
+
+              if (ProjectScreen.DASHBOARDS.equals(panel)) {
+                return new ProjectDashboardsScreen(k);
+              }
+            }
             return new NotFoundScreen();
           }
-
-          int q = rest.lastIndexOf('?');
-          if (q > 0 && rest.lastIndexOf(',', q) > 0) {
-            c = rest.substring(0, q - 1).lastIndexOf(',');
-          }
-
-          Project.NameKey k = Project.NameKey.parse(rest.substring(0, c));
-          String panel = rest.substring(c + 1);
-
-          if (ProjectScreen.INFO.equals(panel)) {
-            return new ProjectInfoScreen(k);
-          }
-
-          if (ProjectScreen.BRANCHES.equals(panel)
-              || matchPrefix(ProjectScreen.BRANCHES, panel)) {
-            return new ProjectBranchesScreen(k);
-          }
-
-          if (ProjectScreen.TAGS.equals(panel)
-              || matchPrefix(ProjectScreen.TAGS, panel)) {
-            return new ProjectTagsScreen(k);
-          }
-
-          if (ProjectScreen.ACCESS.equals(panel)) {
-            return new ProjectAccessScreen(k);
-          }
-
-          if (ProjectScreen.DASHBOARDS.equals(panel)) {
-            return new ProjectDashboardsScreen(k);
-          }
-        }
-        return new NotFoundScreen();
-      }
-    });
+        });
   }
 
   private static boolean matchExact(String want, String token) {
@@ -814,8 +827,7 @@
 
     @Override
     public final void onFailure(Throwable reason) {
-      if (!isReloadUi
-          && "HTTP download failed with status 404".equals(reason.getMessage())) {
+      if (!isReloadUi && "HTTP download failed with status 404".equals(reason.getMessage())) {
         // The server was upgraded since we last download the main script,
         // so the pointers to the splits aren't valid anymore.  Force the
         // page to reload itself and pick up the new code.
@@ -828,11 +840,12 @@
   }
 
   private static void docSearch(final String token) {
-    GWT.runAsync(new AsyncSplit(token) {
-      @Override
-      public void onSuccess() {
-        Gerrit.display(token, new DocScreen(skip(token)));
-      }
-    });
+    GWT.runAsync(
+        new AsyncSplit(token) {
+          @Override
+          public void onSuccess() {
+            Gerrit.display(token, new DocScreen(skip(token)));
+          }
+        });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
index 87cb4b3..8e12575 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
@@ -37,7 +37,7 @@
   private final Button closey;
 
   protected ErrorDialog() {
-    super(/* auto hide */false, /* modal */true);
+    super(/* auto hide */ false, /* modal */ true);
     setGlassEnabled(true);
     getGlassElement().addClassName(Gerrit.RESOURCES.css().errorDialogGlass());
 
@@ -51,22 +51,24 @@
 
     closey = new Button();
     closey.setText(Gerrit.C.errorDialogContinue());
-    closey.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        hide();
-      }
-    });
-    closey.addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        // if the close button is triggered by a key we need to consume the key
-        // event, otherwise the key event would be propagated to the parent
-        // screen and eventually trigger some unwanted action there after the
-        // error dialog was closed
-        event.stopPropagation();
-      }
-    });
+    closey.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            hide();
+          }
+        });
+    closey.addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            // if the close button is triggered by a key we need to consume the key
+            // event, otherwise the key event would be propagated to the parent
+            // screen and eventually trigger some unwanted action there after the
+            // error dialog was closed
+            event.stopPropagation();
+          }
+        });
     buttons.add(closey);
 
     final FlowPanel center = new FlowPanel();
@@ -86,7 +88,7 @@
   /** Create a dialog box to show a single message string. */
   public ErrorDialog(final String message) {
     this();
-    body.add(new Label(message));
+    body.add(createErrorMsgLabel(message));
   }
 
   /** Create a dialog box to show a single message string. */
@@ -143,12 +145,16 @@
     }
 
     if (msg != null) {
-      final Label m = new Label(msg);
-      m.getElement().getStyle().setProperty("whiteSpace", "pre");
-      body.add(m);
+      body.add(createErrorMsgLabel(msg));
     }
   }
 
+  private Label createErrorMsgLabel(String message) {
+    Label m = new Label(message);
+    m.getElement().getStyle().setProperty("white-space", "pre");
+    return m;
+  }
+
   public ErrorDialog setText(final String t) {
     text.setText(t);
     return this;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
index dd1505c..b30b3ec 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
@@ -17,9 +17,7 @@
 import com.google.gerrit.client.change.Resources;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.info.GeneralPreferences;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gwt.i18n.client.NumberFormat;
-
 import java.util.Date;
 
 /** Misc. formatting functions. */
@@ -61,13 +59,14 @@
 
   /**
    * Formats an account as a name and an email address.
-   * <p>
-   * Example output:
+   *
+   * <p>Example output:
+   *
    * <ul>
-   * <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated</li>
-   * <li>{@code A U. Thor (12)}: missing email address</li>
-   * <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name</li>
-   * <li>{@code Anonymous Coward (12)}: missing name and email address</li>
+   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
+   *   <li>{@code A U. Thor (12)}: missing email address
+   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
+   *   <li>{@code Anonymous Coward (12)}: missing name and email address
    * </ul>
    */
   public static String nameEmail(AccountInfo info) {
@@ -76,25 +75,14 @@
 
   /**
    * Formats an account name.
-   * <p>
-   * If the account has a full name, it returns only the full name. Otherwise it
-   * returns a longer form that includes the email address.
+   *
+   * <p>If the account has a full name, it returns only the full name. Otherwise it returns a longer
+   * form that includes the email address.
    */
   public static String name(AccountInfo info) {
     return createAccountFormatter().name(info);
   }
 
-  public static AccountInfo asInfo(Account acct) {
-    if (acct == null) {
-      return AccountInfo.create(0, null, null, null);
-    }
-    return AccountInfo.create(
-        acct.getId() != null ? acct.getId().get() : 0,
-        acct.getFullName(),
-        acct.getPreferredEmail(),
-        acct.getUserName());
-  }
-
   public static AccountInfo asInfo(com.google.gerrit.common.data.AccountInfo acct) {
     if (acct == null) {
       return AccountInfo.create(0, null, null, null);
@@ -133,7 +121,9 @@
     int exp = (int) (Math.log(Math.abs(bytes)) / Math.log(1024));
     return (bytes > 0 && !abs ? "+" : "")
         + NumberFormat.getFormat("#.0").format(bytes / Math.pow(1024, exp))
-        + " " + "KMGTPE".charAt(exp - 1) + "iB";
+        + " "
+        + "KMGTPE".charAt(exp - 1)
+        + "iB";
   }
 
   public static String formatPercentage(long size, long delta) {
@@ -147,7 +137,7 @@
     if (size == 0) {
       return Resources.C.notAvailable();
     }
-    int p = Math.abs(Math.round(delta * 100 / size));
-    return p + "%";
+    long percentage = Math.abs(Math.round(delta * 100.0 / size));
+    return percentage + "%";
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
index d280e07..751302e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Gerrit.java
@@ -44,12 +44,14 @@
 import com.google.gerrit.client.ui.MorphingTabPanel;
 import com.google.gerrit.client.ui.ProjectLinkMenuItem;
 import com.google.gerrit.client.ui.Screen;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.common.data.SystemInfoService;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GerritTopMenu;
+import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.aria.client.Roles;
 import com.google.gwt.core.client.EntryPoint;
@@ -92,7 +94,6 @@
 import com.google.gwtjsonrpc.client.JsonUtil;
 import com.google.gwtjsonrpc.client.XsrfManager;
 import com.google.gwtorm.client.KeyUtil;
-
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -100,8 +101,7 @@
 public class Gerrit implements EntryPoint {
   public static final GerritConstants C = GWT.create(GerritConstants.class);
   public static final GerritMessages M = GWT.create(GerritMessages.class);
-  public static final GerritResources RESOURCES =
-      GWT.create(GerritResources.class);
+  public static final GerritResources RESOURCES = GWT.create(GerritResources.class);
   public static final SystemInfoService SYSTEM_SVC;
   public static final EventBus EVENT_BUS = GWT.create(SimpleEventBus.class);
   public static final Themer THEMER = GWT.create(Themer.class);
@@ -136,6 +136,7 @@
   private static ViewSite<Screen> body;
   private static String lastChangeListToken;
   private static String lastViewToken;
+  private static Anchor uiSwitcherLink;
 
   static {
     SYSTEM_SVC = GWT.create(SystemInfoService.class);
@@ -163,29 +164,29 @@
 
   /**
    * Load the screen at the given location, displaying when ready.
-   * <p>
-   * If the URL is not already pointing at this location, a new item will be
-   * added to the browser's history when the screen is fully loaded and
-   * displayed on the UI.
+   *
+   * <p>If the URL is not already pointing at this location, a new item will be added to the
+   * browser's history when the screen is fully loaded and displayed on the UI.
    *
    * @param token location to parse, load, and render.
    */
   public static void display(final String token) {
     if (body.getView() == null || !body.getView().displayToken(token)) {
       dispatcher.display(token);
+      updateUiLink(token);
     }
   }
 
   /**
    * Load the screen passed, assuming token can be used to locate it.
-   * <p>
-   * The screen is loaded in the background. When it is ready to be visible a
-   * new item will be added to the browser's history, the screen will be made
-   * visible, and the window title may be updated.
-   * <p>
-   * If {@link Screen#isRequiresSignIn()} is true and the user is not signed in
-   * yet the screen instance will be discarded, sign-in will take place, and
-   * will redirect to this location upon success.
+   *
+   * <p>The screen is loaded in the background. When it is ready to be visible a new item will be
+   * added to the browser's history, the screen will be made visible, and the window title may be
+   * updated.
+   *
+   * <p>If {@link Screen#isRequiresSignIn()} is true and the user is not signed in yet the screen
+   * instance will be discarded, sign-in will take place, and will redirect to this location upon
+   * success.
    *
    * @param token location that refers to {@code view}.
    * @param view the view to load.
@@ -199,6 +200,7 @@
         LocalComments.saveInlineComments();
       }
       body.setView(view);
+      updateUiLink(token);
     }
   }
 
@@ -208,11 +210,10 @@
 
   /**
    * Update the current history token after a screen change.
-   * <p>
-   * The caller has already updated the UI, but wants to publish a different
-   * history token for the current browser state. This really only makes sense
-   * if the caller is a {@code TabPanel} and is firing an event when the tab
-   * changed to a different part.
+   *
+   * <p>The caller has already updated the UI, but wants to publish a different history token for
+   * the current browser state. This really only makes sense if the caller is a {@code TabPanel} and
+   * is firing an event when the tab changed to a different part.
    *
    * @param token new location that is already visible.
    */
@@ -286,11 +287,15 @@
   }
 
   /** @return access token to prove user identity during REST API calls. */
+  @Nullable
   public static String getXGerritAuth() {
     return xGerritAuth;
   }
 
-  /** @return the preferences of the currently signed in user, the default preferences if not signed in */
+  /**
+   * @return the preferences of the currently signed in user, the default preferences if not signed
+   *     in
+   */
   public static GeneralPreferences getUserPreferences() {
     return myPrefs;
   }
@@ -390,17 +395,18 @@
 
   private void setXsrfToken() {
     xGerritAuth = Cookies.getCookie(XSRF_COOKIE_NAME);
-    JsonUtil.setDefaultXsrfManager(new XsrfManager() {
-      @Override
-      public String getToken(JsonDefTarget proxy) {
-        return xGerritAuth;
-      }
+    JsonUtil.setDefaultXsrfManager(
+        new XsrfManager() {
+          @Override
+          public String getToken(JsonDefTarget proxy) {
+            return xGerritAuth;
+          }
 
-      @Override
-      public void setToken(JsonDefTarget proxy, String token) {
-        // Ignore the request, we always rely upon the cookie.
-      }
-    });
+          @Override
+          public void setToken(JsonDefTarget proxy, String token) {
+            // Ignore the request, we always rely upon the cookie.
+          }
+        });
   }
 
   @Override
@@ -410,103 +416,117 @@
     }
     setXsrfToken();
 
-    KeyUtil.setEncoderImpl(new KeyUtil.Encoder() {
-      @Override
-      public String encode(String e) {
-        e = URL.encodeQueryString(e);
-        e = fixPathImpl(e);
-        e = fixColonImpl(e);
-        e = fixDoubleQuote(e);
-        return e;
-      }
+    KeyUtil.setEncoderImpl(
+        new KeyUtil.Encoder() {
+          @Override
+          public String encode(String e) {
+            e = URL.encodeQueryString(e);
+            e = fixPathImpl(e);
+            e = fixColonImpl(e);
+            e = fixDoubleQuote(e);
+            return e;
+          }
 
-      @Override
-      public String decode(final String e) {
-        return URL.decodeQueryString(e);
-      }
+          @Override
+          public String decode(final String e) {
+            return URL.decodeQueryString(e);
+          }
 
-      private native String fixPathImpl(String path)
-      /*-{ return path.replace(/%2F/g, "/"); }-*/;
+          private native String fixPathImpl(String path)
+              /*-{ return path.replace(/%2F/g, "/"); }-*/ ;
 
-      private native String fixColonImpl(String path)
-      /*-{ return path.replace(/%3A/g, ":"); }-*/;
+          private native String fixColonImpl(String path)
+              /*-{ return path.replace(/%3A/g, ":"); }-*/ ;
 
-      private native String fixDoubleQuote(String path)
-      /*-{ return path.replace(/%22/g, '"'); }-*/;
-    });
+          private native String fixDoubleQuote(String path)
+              /*-{ return path.replace(/%22/g, '"'); }-*/ ;
+        });
 
     initHostname();
     Window.setTitle(M.windowTitle1(myHost));
 
     RpcStatus.INSTANCE = new RpcStatus();
     CallbackGroup cbg = new CallbackGroup();
-    getDocIndex(cbg.add(new GerritCallback<DocInfo>() {
-      @Override
-      public void onSuccess(DocInfo indexInfo) {
-        hasDocumentation = indexInfo != null;
-        docUrl = selfRedirect("/Documentation/");
-      }
-    }));
-    ConfigServerApi.serverInfo(cbg.add(new GerritCallback<ServerInfo>() {
-      @Override
-      public void onSuccess(ServerInfo info) {
-        myServerInfo = info;
-        urlAliasMatcher = new UrlAliasMatcher(info.urlAliases());
-        String du = info.gerrit().docUrl();
-        if (du != null && !du.isEmpty()) {
-          hasDocumentation = true;
-          docUrl = du;
-        }
-        docSearch = info.gerrit().docSearch();
-      }
-    }));
-    HostPageDataService hpd = GWT.create(HostPageDataService.class);
-    hpd.load(cbg.addFinal(new GerritCallback<HostPageData>() {
-      @Override
-      public void onSuccess(final HostPageData result) {
-        Document.get().getElementById("gerrit_hostpagedata").removeFromParent();
-        myTheme = result.theme;
-        isNoteDbEnabled = result.isNoteDbEnabled;
-        if (result.accountDiffPref != null) {
-          myAccountDiffPref = result.accountDiffPref;
-        }
-        if (result.accountDiffPref != null) {
-          // TODO: Support options on the GetDetail REST endpoint so that it can
-          // also return the preferences. Then we can fetch everything with a
-          // single request and we don't need the callback group anymore.
-          CallbackGroup cbg = new CallbackGroup();
-          AccountApi.self().view("detail")
-              .get(cbg.add(new GerritCallback<AccountInfo>() {
-                @Override
-                public void onSuccess(AccountInfo result) {
-                  myAccount = result;
+    getDocIndex(
+        cbg.add(
+            new GerritCallback<DocInfo>() {
+              @Override
+              public void onSuccess(DocInfo indexInfo) {
+                hasDocumentation = indexInfo != null;
+                docUrl = selfRedirect("/Documentation/");
+              }
+            }));
+    ConfigServerApi.serverInfo(
+        cbg.add(
+            new GerritCallback<ServerInfo>() {
+              @Override
+              public void onSuccess(ServerInfo info) {
+                myServerInfo = info;
+                urlAliasMatcher = new UrlAliasMatcher(info.urlAliases());
+                String du = info.gerrit().docUrl();
+                if (du != null && !du.isEmpty()) {
+                  hasDocumentation = true;
+                  docUrl = du;
                 }
-          }));
-          AccountApi.self().view("preferences")
-              .get(cbg.add(new GerritCallback<GeneralPreferences>() {
-            @Override
-            public void onSuccess(GeneralPreferences prefs) {
-              myPrefs = prefs;
-              onModuleLoad2(result);
-            }
-          }));
-          AccountApi.getEditPreferences(
-              cbg.addFinal(new GerritCallback<EditPreferences>() {
-            @Override
-            public void onSuccess(EditPreferences prefs) {
-              EditPreferencesInfo prefsInfo = new EditPreferencesInfo();
-              prefs.copyTo(prefsInfo);
-              editPrefs = prefsInfo;
-            }
-          }));
-        } else {
-          myAccount = AccountInfo.create(0, null, null, null);
-          myPrefs = GeneralPreferences.createDefault();
-          editPrefs = null;
-          onModuleLoad2(result);
-        }
-      }
-    }));
+                docSearch = info.gerrit().docSearch();
+              }
+            }));
+    HostPageDataService hpd = GWT.create(HostPageDataService.class);
+    hpd.load(
+        cbg.addFinal(
+            new GerritCallback<HostPageData>() {
+              @Override
+              public void onSuccess(final HostPageData result) {
+                Document.get().getElementById("gerrit_hostpagedata").removeFromParent();
+                myTheme = result.theme;
+                isNoteDbEnabled = result.isNoteDbEnabled;
+                if (result.accountDiffPref != null) {
+                  myAccountDiffPref = result.accountDiffPref;
+                }
+                if (result.accountDiffPref != null) {
+                  // TODO: Support options on the GetDetail REST endpoint so that it can
+                  // also return the preferences. Then we can fetch everything with a
+                  // single request and we don't need the callback group anymore.
+                  CallbackGroup cbg = new CallbackGroup();
+                  AccountApi.self()
+                      .view("detail")
+                      .get(
+                          cbg.add(
+                              new GerritCallback<AccountInfo>() {
+                                @Override
+                                public void onSuccess(AccountInfo result) {
+                                  myAccount = result;
+                                }
+                              }));
+                  AccountApi.self()
+                      .view("preferences")
+                      .get(
+                          cbg.add(
+                              new GerritCallback<GeneralPreferences>() {
+                                @Override
+                                public void onSuccess(GeneralPreferences prefs) {
+                                  myPrefs = prefs;
+                                  onModuleLoad2(result);
+                                }
+                              }));
+                  AccountApi.getEditPreferences(
+                      cbg.addFinal(
+                          new GerritCallback<EditPreferences>() {
+                            @Override
+                            public void onSuccess(EditPreferences prefs) {
+                              EditPreferencesInfo prefsInfo = new EditPreferencesInfo();
+                              prefs.copyTo(prefsInfo);
+                              editPrefs = prefsInfo;
+                            }
+                          }));
+                } else {
+                  myAccount = AccountInfo.create(0, null, null, null);
+                  myPrefs = GeneralPreferences.createDefault();
+                  editPrefs = null;
+                  onModuleLoad2(result);
+                }
+              }
+            }));
   }
 
   private native boolean canLoadInIFrame() /*-{
@@ -529,6 +549,26 @@
     ApiGlue.fireEvent("history", token);
   }
 
+  private static String getUiSwitcherUrl(String token) {
+    UrlBuilder builder = new UrlBuilder();
+    builder.setProtocol(Location.getProtocol());
+    builder.setHost(Location.getHost());
+    String port = Location.getPort();
+    if (port != null && !port.isEmpty()) {
+      builder.setPort(Integer.parseInt(port));
+    }
+    String[] tokens = token.split("@", 2);
+    if (Location.getPath().endsWith("/") && tokens[0].startsWith("/")) {
+      tokens[0] = tokens[0].substring(1);
+    }
+    builder.setPath(Location.getPath() + tokens[0]);
+    if (tokens.length == 2) {
+      builder.setHash(tokens[1]);
+    }
+    builder.setParameter("polygerrit", "1");
+    return builder.buildString();
+  }
+
   private static void populateBottomMenu(RootPanel btmmenu, HostPageData hpd) {
     String vs = hpd.version;
     if (vs == null || vs.isEmpty()) {
@@ -537,12 +577,17 @@
 
     btmmenu.add(new InlineHTML(M.poweredBy(vs)));
 
+    if (info().gerrit().webUis().contains(UiType.POLYGERRIT)) {
+      btmmenu.add(new InlineLabel(" | "));
+      uiSwitcherLink = new Anchor(C.newUi(), getUiSwitcherUrl(History.getToken()));
+      uiSwitcherLink.setStyleName("");
+      btmmenu.add(uiSwitcherLink);
+    }
+
     String reportBugUrl = info().gerrit().reportBugUrl();
     if (reportBugUrl != null) {
       String reportBugText = info().gerrit().reportBugText();
-      Anchor a = new Anchor(
-          reportBugText == null ? C.reportBug() : reportBugText,
-          reportBugUrl);
+      Anchor a = new Anchor(reportBugText == null ? C.reportBug() : reportBugText, reportBugUrl);
       a.setTarget("_blank");
       a.setStyleName("");
       btmmenu.add(new InlineLabel(" | "));
@@ -552,6 +597,12 @@
     btmmenu.add(new InlineLabel(C.keyHelp()));
   }
 
+  private static void updateUiLink(String token) {
+    if (uiSwitcherLink != null) {
+      uiSwitcherLink.setHref(getUiSwitcherUrl(token));
+    }
+  }
+
   private void onModuleLoad2(HostPageData hpd) {
     RESOURCES.gwt_override().ensureInjected();
     RESOURCES.css().ensureInjected();
@@ -586,29 +637,29 @@
     siteHeader = RootPanel.get("gerrit_header");
     siteFooter = RootPanel.get("gerrit_footer");
 
-    body = new ViewSite<Screen>() {
-      @Override
-      protected void onShowView(Screen view) {
-        String token = view.getToken();
-        History.newItem(token, false);
-        dispatchHistoryHooks(token);
+    body =
+        new ViewSite<Screen>() {
+          @Override
+          protected void onShowView(Screen view) {
+            String token = view.getToken();
+            History.newItem(token, false);
+            dispatchHistoryHooks(token);
 
-        if (view instanceof ChangeListScreen) {
-          lastChangeListToken = token;
-        }
+            if (view instanceof ChangeListScreen) {
+              lastChangeListToken = token;
+            }
 
-        super.onShowView(view);
-        view.onShowView();
-        lastViewToken = token;
-      }
-    };
+            super.onShowView(view);
+            view.onShowView();
+            lastViewToken = token;
+          }
+        };
     gBody.add(body);
 
     JsonUtil.addRpcStartHandler(RpcStatus.INSTANCE);
     JsonUtil.addRpcCompleteHandler(RpcStatus.INSTANCE);
 
-    gStarting.getElement().getParentElement().removeChild(
-        gStarting.getElement());
+    gStarting.getElement().getParentElement().removeChild(gStarting.getElement());
     RootPanel.detachNow(gStarting);
     ApiGlue.init();
 
@@ -616,28 +667,28 @@
     populateBottomMenu(bottomMenu, hpd);
     refreshMenuBar();
 
-    History.addValueChangeHandler(new ValueChangeHandler<String>() {
-      @Override
-      public void onValueChange(ValueChangeEvent<String> event) {
-        display(event.getValue());
-      }
-    });
+    History.addValueChangeHandler(
+        new ValueChangeHandler<String>() {
+          @Override
+          public void onValueChange(ValueChangeEvent<String> event) {
+            display(event.getValue());
+          }
+        });
     JumpKeys.register(body);
 
     saveDefaultTheme();
     if (hpd.messages != null) {
       new MessageOfTheDayBar(hpd.messages).show();
     }
-    PluginLoader.load(hpd.plugins,
+    PluginLoader.load(
+        hpd.plugins,
         hpd.pluginsLoadTimeout,
         new GerritCallback<VoidResult>() {
           @Override
           public void onSuccess(VoidResult result) {
             String token = History.getToken();
             if (token.isEmpty()) {
-              token = isSignedIn()
-                  ? PageLinks.MINE
-                  : PageLinks.toChangeQuery("status:open");
+              token = isSignedIn() ? PageLinks.MINE : PageLinks.toChangeQuery("status:open");
             }
             display(token);
           }
@@ -645,7 +696,8 @@
   }
 
   private void saveDefaultTheme() {
-    THEMER.init(Document.get().getElementById("gerrit_sitecss"),
+    THEMER.init(
+        Document.get().getElementById("gerrit_sitecss"),
         Document.get().getElementById("gerrit_header"),
         Document.get().getElementById("gerrit_footer"));
   }
@@ -700,14 +752,14 @@
     projectsBar.addItem(new ProjectLinkMenuItem(C.menuProjectsTags(), ProjectScreen.TAGS));
     projectsBar.addItem(new ProjectLinkMenuItem(C.menuProjectsAccess(), ProjectScreen.ACCESS));
     final LinkMenuItem dashboardsMenuItem =
-        new ProjectLinkMenuItem(C.menuProjectsDashboards(),
-            ProjectScreen.DASHBOARDS) {
-      @Override
-      protected boolean match(String token) {
-        return super.match(token) ||
-            (!getTargetHistoryToken().isEmpty() && ("/admin" + token).startsWith(getTargetHistoryToken()));
-      }
-    };
+        new ProjectLinkMenuItem(C.menuProjectsDashboards(), ProjectScreen.DASHBOARDS) {
+          @Override
+          protected boolean match(String token) {
+            return super.match(token)
+                || (!getTargetHistoryToken().isEmpty()
+                    && ("/admin" + token).startsWith(getTargetHistoryToken()));
+          }
+        };
     projectsBar.addItem(dashboardsMenuItem);
     menuLeft.add(projectsBar, C.menuProjects());
 
@@ -720,27 +772,34 @@
 
       final LinkMenuBar pluginsBar = new LinkMenuBar();
       menuBars.put(GerritTopMenu.PLUGINS.menuName, pluginsBar);
-      AccountCapabilities.all(new GerritCallback<AccountCapabilities>() {
-        @Override
-        public void onSuccess(AccountCapabilities result) {
-          if (result.canPerform(CREATE_PROJECT)) {
-            insertLink(projectsBar, C.menuProjectsCreate(),
-                PageLinks.ADMIN_CREATE_PROJECT,
-                projectsBar.getWidgetIndex(dashboardsMenuItem) + 1);
-          }
-          if (result.canPerform(CREATE_GROUP)) {
-            insertLink(peopleBar, C.menuPeopleGroupsCreate(),
-                PageLinks.ADMIN_CREATE_GROUP,
-                peopleBar.getWidgetIndex(groupsListMenuItem) + 1);
-          }
-          if (result.canPerform(VIEW_PLUGINS)) {
-            insertLink(pluginsBar, C.menuPluginsInstalled(),
-                PageLinks.ADMIN_PLUGINS, 0);
-            menuLeft.insert(pluginsBar, C.menuPlugins(),
-                menuLeft.getWidgetIndex(peopleBar) + 1);
-          }
-        }
-      }, CREATE_PROJECT, CREATE_GROUP, VIEW_PLUGINS);
+      AccountCapabilities.all(
+          new GerritCallback<AccountCapabilities>() {
+            @Override
+            public void onSuccess(AccountCapabilities result) {
+              if (result.canPerform(CREATE_PROJECT)) {
+                insertLink(
+                    projectsBar,
+                    C.menuProjectsCreate(),
+                    PageLinks.ADMIN_CREATE_PROJECT,
+                    projectsBar.getWidgetIndex(dashboardsMenuItem) + 1);
+              }
+              if (result.canPerform(CREATE_GROUP)) {
+                insertLink(
+                    peopleBar,
+                    C.menuPeopleGroupsCreate(),
+                    PageLinks.ADMIN_CREATE_GROUP,
+                    peopleBar.getWidgetIndex(groupsListMenuItem) + 1);
+              }
+              if (result.canPerform(VIEW_PLUGINS)) {
+                insertLink(pluginsBar, C.menuPluginsInstalled(), PageLinks.ADMIN_PLUGINS, 0);
+                menuLeft.insert(
+                    pluginsBar, C.menuPlugins(), menuLeft.getWidgetIndex(peopleBar) + 1);
+              }
+            }
+          },
+          CREATE_PROJECT,
+          CREATE_GROUP,
+          VIEW_PLUGINS);
     }
 
     if (hasDocumentation) {
@@ -763,48 +822,55 @@
           break;
 
         case OPENID:
-          menuRight.addItem(C.menuRegister(), new Command() {
-            @Override
-            public void execute() {
-              String t = History.getToken();
-              if (t == null) {
-                t = "";
-              }
-              doSignIn(PageLinks.REGISTER + t);
-            }
-          });
-          menuRight.addItem(C.menuSignIn(), new Command() {
-            @Override
-            public void execute() {
-              doSignIn(History.getToken());
-            }
-          });
+          menuRight.addItem(
+              C.menuRegister(),
+              new Command() {
+                @Override
+                public void execute() {
+                  String t = History.getToken();
+                  if (t == null) {
+                    t = "";
+                  }
+                  doSignIn(PageLinks.REGISTER + t);
+                }
+              });
+          menuRight.addItem(
+              C.menuSignIn(),
+              new Command() {
+                @Override
+                public void execute() {
+                  doSignIn(History.getToken());
+                }
+              });
           break;
 
         case OAUTH:
-          menuRight.addItem(C.menuSignIn(), new Command() {
-            @Override
-            public void execute() {
-              doSignIn(History.getToken());
-            }
-          });
+          menuRight.addItem(
+              C.menuSignIn(),
+              new Command() {
+                @Override
+                public void execute() {
+                  doSignIn(History.getToken());
+                }
+              });
           break;
 
         case OPENID_SSO:
-          menuRight.addItem(C.menuSignIn(), new Command() {
-            @Override
-            public void execute() {
-              doSignIn(History.getToken());
-            }
-          });
+          menuRight.addItem(
+              C.menuSignIn(),
+              new Command() {
+                @Override
+                public void execute() {
+                  doSignIn(History.getToken());
+                }
+              });
           break;
 
         case HTTP:
         case HTTP_LDAP:
           if (authInfo.loginUrl() != null) {
-            String signinText = authInfo.loginText() == null
-                ? C.menuSignIn()
-                : authInfo.loginText();
+            String signinText =
+                authInfo.loginText() == null ? C.menuSignIn() : authInfo.loginText();
             menuRight.add(anchor(signinText, authInfo.loginUrl()));
           }
           break;
@@ -813,17 +879,18 @@
         case LDAP_BIND:
         case CUSTOM_EXTENSION:
           if (authInfo.registerUrl() != null) {
-            String registerText = authInfo.registerText() == null
-                ? C.menuRegister()
-                : authInfo.registerText();
+            String registerText =
+                authInfo.registerText() == null ? C.menuRegister() : authInfo.registerText();
             menuRight.add(anchor(registerText, authInfo.registerUrl()));
           }
-          menuRight.addItem(C.menuSignIn(), new Command() {
-            @Override
-            public void execute() {
-              doSignIn(History.getToken());
-            }
-          });
+          menuRight.addItem(
+              C.menuSignIn(),
+              new Command() {
+                @Override
+                public void execute() {
+                  doSignIn(History.getToken());
+                }
+              });
           break;
 
         case DEVELOPMENT_BECOME_ANY_ACCOUNT:
@@ -831,36 +898,38 @@
           break;
       }
     }
-    ConfigServerApi.topMenus(new GerritCallback<TopMenuList>() {
-      @Override
-      public void onSuccess(TopMenuList result) {
-        List<TopMenu> topMenuExtensions = Natives.asList(result);
-        for (TopMenu menu : topMenuExtensions) {
-          String name = menu.getName();
-          LinkMenuBar existingBar = menuBars.get(name);
-          LinkMenuBar bar =
-              existingBar != null ? existingBar : new LinkMenuBar();
-          for (TopMenuItem item : Natives.asList(menu.getItems())) {
-            addMenuLink(bar, item);
+    ConfigServerApi.topMenus(
+        new GerritCallback<TopMenuList>() {
+          @Override
+          public void onSuccess(TopMenuList result) {
+            List<TopMenu> topMenuExtensions = Natives.asList(result);
+            for (TopMenu menu : topMenuExtensions) {
+              String name = menu.getName();
+              LinkMenuBar existingBar = menuBars.get(name);
+              LinkMenuBar bar = existingBar != null ? existingBar : new LinkMenuBar();
+              for (TopMenuItem item : Natives.asList(menu.getItems())) {
+                addMenuLink(bar, item);
+              }
+              if (existingBar == null) {
+                menuBars.put(name, bar);
+                menuLeft.add(bar, name);
+              }
+            }
           }
-          if (existingBar == null) {
-            menuBars.put(name, bar);
-            menuLeft.add(bar, name);
-          }
-        }
-      }
-    });
+        });
   }
 
   public static void refreshUserPreferences() {
     if (isSignedIn()) {
-      AccountApi.self().view("preferences")
-          .get(new GerritCallback<GeneralPreferences>() {
-            @Override
-            public void onSuccess(GeneralPreferences prefs) {
-              setUserPreferences(prefs);
-            }
-          });
+      AccountApi.self()
+          .view("preferences")
+          .get(
+              new GerritCallback<GeneralPreferences>() {
+                @Override
+                public void onSuccess(GeneralPreferences prefs) {
+                  setUserPreferences(prefs);
+                }
+              });
     } else {
       setUserPreferences(GeneralPreferences.createDefault());
     }
@@ -889,29 +958,28 @@
   }
 
   private static void getDocIndex(final AsyncCallback<DocInfo> cb) {
-    RequestBuilder req =
-        new RequestBuilder(RequestBuilder.HEAD, GWT.getHostPageBaseURL()
-            + INDEX);
-    req.setCallback(new RequestCallback() {
-      @Override
-      public void onResponseReceived(Request req, Response resp) {
-        switch (resp.getStatusCode()) {
-          case Response.SC_OK:
-          case Response.SC_MOVED_PERMANENTLY:
-          case Response.SC_MOVED_TEMPORARILY:
-            cb.onSuccess(DocInfo.create());
-            break;
-          default:
-            cb.onSuccess(null);
-            break;
-        }
-      }
+    RequestBuilder req = new RequestBuilder(RequestBuilder.HEAD, GWT.getHostPageBaseURL() + INDEX);
+    req.setCallback(
+        new RequestCallback() {
+          @Override
+          public void onResponseReceived(Request req, Response resp) {
+            switch (resp.getStatusCode()) {
+              case Response.SC_OK:
+              case Response.SC_MOVED_PERMANENTLY:
+              case Response.SC_MOVED_TEMPORARILY:
+                cb.onSuccess(DocInfo.create());
+                break;
+              default:
+                cb.onSuccess(null);
+                break;
+            }
+          }
 
-      @Override
-      public void onError(Request request, Throwable e) {
-        cb.onFailure(e);
-      }
-    });
+          @Override
+          public void onError(Request request, Throwable e) {
+            cb.onFailure(e);
+          }
+        });
     try {
       req.send();
     } catch (RequestException e) {
@@ -921,8 +989,7 @@
 
   private static void whoAmI(boolean canLogOut) {
     AccountInfo account = getUserAccount();
-    final UserPopupPanel userPopup =
-        new UserPopupPanel(account, canLogOut, true);
+    final UserPopupPanel userPopup = new UserPopupPanel(account, canLogOut, true);
     final FlowPanel userSummaryPanel = new FlowPanel();
     class PopupHandler implements KeyDownHandler, ClickHandler {
       private void showHidePopup() {
@@ -971,49 +1038,48 @@
     return a;
   }
 
-  private static LinkMenuItem addLink(final LinkMenuBar m, final String text,
-      final String historyToken) {
+  private static LinkMenuItem addLink(
+      final LinkMenuBar m, final String text, final String historyToken) {
     LinkMenuItem i = new LinkMenuItem(text, historyToken);
     m.addItem(i);
     return i;
   }
 
-  private static void insertLink(final LinkMenuBar m, final String text,
-      final String historyToken, final int beforeIndex) {
+  private static void insertLink(
+      final LinkMenuBar m, final String text, final String historyToken, final int beforeIndex) {
     m.insertItem(new LinkMenuItem(text, historyToken), beforeIndex);
   }
 
   private static LinkMenuItem addProjectLink(LinkMenuBar m, TopMenuItem item) {
-    LinkMenuItem i = new ProjectLinkMenuItem(item.getName(), item.getUrl()) {
-        @Override
-        protected void onScreenLoad(Project.NameKey project) {
-        String p =
-            panel.replace(PROJECT_NAME_MENU_VAR,
-                URL.encodeQueryString(project.get()));
-          if (!panel.startsWith("/x/") && !isAbsolute(panel)) {
-            UrlBuilder builder = new UrlBuilder();
-            builder.setProtocol(Location.getProtocol());
-            builder.setHost(Location.getHost());
-            String port = Location.getPort();
-            if (port != null && !port.isEmpty()) {
-              builder.setPort(Integer.parseInt(port));
+    LinkMenuItem i =
+        new ProjectLinkMenuItem(item.getName(), item.getUrl()) {
+          @Override
+          protected void onScreenLoad(Project.NameKey project) {
+            String p = panel.replace(PROJECT_NAME_MENU_VAR, URL.encodeQueryString(project.get()));
+            if (!panel.startsWith("/x/") && !isAbsolute(panel)) {
+              UrlBuilder builder = new UrlBuilder();
+              builder.setProtocol(Location.getProtocol());
+              builder.setHost(Location.getHost());
+              String port = Location.getPort();
+              if (port != null && !port.isEmpty()) {
+                builder.setPort(Integer.parseInt(port));
+              }
+              builder.setPath(Location.getPath());
+              p = builder.buildString() + p;
             }
-            builder.setPath(Location.getPath());
-            p = builder.buildString() + p;
+            getElement().setPropertyString("href", p);
           }
-          getElement().setPropertyString("href", p);
-        }
 
-        @Override
-        public void go() {
-          String href = getElement().getPropertyString("href");
-          if (href.startsWith("#")) {
-            super.go();
-          } else {
-            Window.open(href, getElement().getPropertyString("target"), "");
+          @Override
+          public void go() {
+            String href = getElement().getPropertyString("href");
+            if (href.startsWith("#")) {
+              super.go();
+            } else {
+              Window.open(href, getElement().getPropertyString("target"), "");
+            }
           }
-        }
-      };
+        };
     if (item.getTarget() != null && !item.getTarget().isEmpty()) {
       i.getElement().setAttribute("target", item.getTarget());
     }
@@ -1024,8 +1090,7 @@
     return i;
   }
 
-  private static void addDocLink(final LinkMenuBar m, final String text,
-      final String href) {
+  private static void addDocLink(final LinkMenuBar m, final String text, final String href) {
     final Anchor atag = anchor(text, docUrl + href);
     atag.setTarget("_blank");
     m.add(atag);
@@ -1040,18 +1105,17 @@
   }
 
   private static void addExtensionLink(LinkMenuBar m, TopMenuItem item) {
-    if (item.getUrl().startsWith("#")
-        && (item.getTarget() == null || item.getTarget().isEmpty())) {
-      LinkMenuItem a =
-          new LinkMenuItem(item.getName(), item.getUrl().substring(1));
+    if (item.getUrl().startsWith("#") && (item.getTarget() == null || item.getTarget().isEmpty())) {
+      LinkMenuItem a = new LinkMenuItem(item.getName(), item.getUrl().substring(1));
       if (item.getId() != null) {
         a.getElement().setAttribute("id", item.getId());
       }
       m.addItem(a);
     } else {
-      Anchor atag = anchor(item.getName(), isAbsolute(item.getUrl())
-          ? item.getUrl()
-          : selfRedirect(item.getUrl()));
+      Anchor atag =
+          anchor(
+              item.getName(),
+              isAbsolute(item.getUrl()) ? item.getUrl() : selfRedirect(item.getUrl()));
       if (item.getTarget() != null && !item.getTarget().isEmpty()) {
         atag.setTarget(item.getTarget());
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
index 4c8c58d..b44cd1c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.java
@@ -18,35 +18,59 @@
 
 public interface GerritConstants extends Constants {
   String menuSignIn();
+
   String menuRegister();
+
   String reportBug();
+
   String loadingPlugins();
 
   String signInDialogTitle();
+
   String signInDialogGoAnonymous();
 
   String linkIdentityDialogTitle();
+
   String registerDialogTitle();
+
   String loginTypeUnsupported();
 
   String errorTitle();
+
   String errorDialogContinue();
+
   String warnTitle();
 
   String confirmationDialogOk();
+
   String confirmationDialogCancel();
 
   String branchCreationDialogTitle();
+
   String branchCreationConfirmationMessage();
 
+  String tagCreationDialogTitle();
+
+  String tagCreationConfirmationMessage();
+
   String branchDeletionDialogTitle();
+
   String branchDeletionConfirmationMessage();
 
+  String tagDeletionDialogTitle();
+
+  String tagDeletionConfirmationMessage();
+
+  String newUi();
+
   String notSignedInTitle();
+
   String notSignedInBody();
 
   String notFoundTitle();
+
   String notFoundBody();
+
   String noSuchAccountTitle();
 
   String noSuchGroupTitle();
@@ -56,78 +80,124 @@
   String labelNotApplicable();
 
   String menuAll();
+
   String menuAllOpen();
+
   String menuAllMerged();
+
   String menuAllAbandoned();
 
   String menuMine();
+
   String menuMyChanges();
+
   String menuMyDrafts();
+
   String menuMyWatchedChanges();
+
   String menuMyStarredChanges();
+
   String menuMyDraftComments();
 
   String menuDiff();
+
   String menuDiffCommit();
+
   String menuDiffPreferences();
+
   String menuDiffPatchSets();
+
   String menuDiffFiles();
 
   String menuProjects();
+
   String menuProjectsList();
+
   String menuProjectsInfo();
+
   String menuProjectsBranches();
+
   String menuProjectsTags();
+
   String menuProjectsAccess();
+
   String menuProjectsDashboards();
+
   String menuProjectsCreate();
 
   String menuPeople();
+
   String menuPeopleGroupsList();
+
   String menuPeopleGroupsCreate();
 
   String menuPlugins();
+
   String menuPluginsInstalled();
 
   String menuDocumentation();
+
   String menuDocumentationTOC();
+
   String menuDocumentationSearch();
+
   String menuDocumentationUpload();
+
   String menuDocumentationAccess();
+
   String menuDocumentationAPI();
+
   String menuDocumentationProjectOwnerGuide();
 
   String searchHint();
+
   String searchButton();
 
   String rpcStatusWorking();
 
   String sectionNavigation();
+
   String sectionActions();
+
   String keySearch();
+
   String keyEditor();
+
   String keyHelp();
 
   String sectionJumping();
+
   String jumpAllOpen();
+
   String jumpAllMerged();
+
   String jumpAllAbandoned();
+
   String jumpMine();
+
   String jumpMineDrafts();
+
   String jumpMineWatched();
+
   String jumpMineStarred();
+
   String jumpMineDraftComments();
 
   String projectAccessError();
+
   String projectAccessProposeForReviewHint();
 
   String userCannotVoteToolTip();
 
   String stringListPanelAdd();
+
   String stringListPanelDelete();
+
   String stringListPanelUp();
+
   String stringListPanelDown();
 
   String searchDropdownChanges();
+
   String searchDropdownDoc();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
index 10d7e1d..9aa4388 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritConstants.properties
@@ -20,9 +20,17 @@
 branchCreationDialogTitle = Branch Creation
 branchCreationConfirmationMessage = The following branch was successfully created:
 
+tagCreationDialogTitle = Tag Creation
+tagCreationConfirmationMessage = The following tag was successfully created:
+
 branchDeletionDialogTitle = Branch Deletion
 branchDeletionConfirmationMessage = Do you really want to delete the following branches?
 
+tagDeletionDialogTitle = Tag Deletion
+tagDeletionConfirmationMessage = Do you really want to delete the following tags?
+
+newUi = New UI
+
 notSignedInTitle = Code Review - Session Expired
 notSignedInBody = <b>Session Expired</b>\
 <p>You are no longer signed in to Gerrit Code Review.</p>\
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
index 32e30d4..968104c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
@@ -18,141 +18,282 @@
 
 public interface GerritCss extends CssResource {
   String accountDashboard();
+
   String accountInfoBlock();
+
   String accountLinkPanel();
+
   String accountPassword();
+
   String accountUsername();
+
   String activeRow();
+
   String addBranch();
+
   String addMemberTextBox();
+
   String addSshKeyPanel();
+
   String addWatchPanel();
+
   String avatarInfoPanel();
+
   String bottomheader();
+
   String branchTableDeleteButton();
+
   String branchTablePrevNextLinks();
+
   String cAPPROVAL();
+
+  String cASSIGNEE();
+
+  String cASSIGNEDTOME();
+
   String cLastUpdate();
+
   String cOWNER();
+
   String cSIZE();
+
   String cSUBJECT();
+
   String cSTATUS();
+
   String changeSize();
+
   String changeTable();
+
   String changeTablePrevNextLinks();
+
   String commentedActionDialog();
+
   String commentedActionMessage();
+
   String contributorAgreementAlreadySubmitted();
+
   String contributorAgreementButton();
+
   String contributorAgreementLegal();
+
   String contributorAgreementShortDescription();
+
   String createProjectPanel();
+
   String dataCell();
+
   String dataCellHidden();
+
   String dataHeader();
+
   String dataHeaderHidden();
+
   String downloadBox();
+
   String downloadBoxTable();
+
   String downloadBoxTableCommandColumn();
+
   String downloadBoxSpacer();
+
   String downloadBoxScheme();
+
   String downloadBoxCopyLabel();
+
   String downloadLink();
+
   String downloadLinkCopyLabel();
+
   String downloadLinkHeader();
+
   String downloadLinkHeaderGap();
+
   String downloadLinkList();
+
   String downloadLink_Active();
+
   String editHeadButton();
+
   String emptySection();
+
   String errorDialog();
+
   String errorDialogButtons();
+
   String errorDialogErrorType();
+
   String errorDialogGlass();
+
   String errorDialogTitle();
+
   String extensionPanel();
+
   String loadingPluginsDialog();
+
   String gerritBody();
+
   String gerritTopMenu();
+
   String greenCheckClass();
+
   String groupDescriptionPanel();
+
   String groupIncludesTable();
+
   String groupMembersTable();
+
   String groupName();
+
   String groupNamePanel();
+
   String groupNameTextBox();
+
   String groupOptionsPanel();
+
   String groupOwnerPanel();
+
   String groupOwnerTextBox();
+
   String groupUUIDPanel();
+
   String header();
+
   String iconCell();
+
   String iconHeader();
+
   String identityUntrustedExternalId();
+
   String infoBlock();
+
   String inputFieldTypeHint();
+
   String labelNotApplicable();
+
   String leftMostCell();
+
   String link();
+
   String linkMenuBar();
+
   String linkMenuItemNotLast();
+
   String maxObjectSizeLimitEffectiveLabel();
+
   String menuBarUserName();
+
   String menuBarUserNameAvatar();
+
   String menuBarUserNameFocusPanel();
+
   String menuBarUserNamePanel();
+
   String menuItem();
+
   String menuScreenMenuBar();
+
   String needsReview();
+
   String negscore();
+
   String oauthExpires();
+
   String oauthInfoBlock();
+
   String oauthPanel();
+
   String oauthPanelCookieEntry();
+
   String oauthPanelCookieHeading();
+
   String oauthPanelNetRCEntry();
+
   String oauthPanelNetRCHeading();
+
   String oauthToken();
+
   String pagingLink();
+
   String patchSetActions();
+
   String pluginProjectConfigInheritedValue();
+
   String pluginsTable();
+
   String posscore();
+
   String projectActions();
+
   String projectFilterLabel();
+
   String projectFilterPanel();
+
   String projectNameColumn();
+
   String queryIcon();
+
   String rebaseContentPanel();
+
   String rebaseSuggestBox();
+
   String registerScreenExplain();
+
   String registerScreenNextLinks();
+
   String registerScreenSection();
+
   String rpcStatus();
+
   String screen();
+
   String screenHeader();
+
   String searchPanel();
+
   String suggestBoxPopup();
+
   String sectionHeader();
+
   String singleLine();
+
   String smallHeading();
+
   String specialBranchDataCell();
+
   String specialBranchIconCell();
+
   String sshHostKeyPanel();
+
   String sshHostKeyPanelFingerprintData();
+
   String sshHostKeyPanelHeading();
+
   String sshHostKeyPanelKnownHostEntry();
+
   String sshKeyPanelEncodedKey();
+
   String sshKeyPanelInvalid();
+
   String sshKeyTable();
+
   String stringListPanelButtons();
+
   String topmenu();
+
   String topmenuMenuLeft();
+
   String topmenuMenuRight();
+
   String topmenuTDglue();
+
   String topmenuTDmenu();
+
   String topmost();
+
   String userInfoPopup();
+
   String usernameField();
+
   String watchedProjectFilter();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
index 58442b8..980529a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritMessages.java
@@ -18,21 +18,31 @@
 
 public interface GerritMessages extends Messages {
   String windowTitle1(String hostname);
+
   String windowTitle2(String section, String hostname);
+
   String poweredBy(String version);
 
   String noSuchAccountMessage(String who);
+
   String noSuchGroupMessage(String who);
+
   String nameAlreadyUsedBody(String alreadyUsedName);
 
   String branchCreationFailed(String branchName, String error);
+
   String invalidBranchName(String branchName);
+
   String invalidRevision(String revision);
+
   String branchCreationNotAllowedUnderRefnamePrefix(String refnamePrefix);
+
   String branchAlreadyExists(String branchName);
+
   String branchCreationConflict(String branchName, String existingBranchName);
 
   String pluginFailed(String scriptPath);
+
   String cannotDownloadPlugin(String scriptPath);
 
   String parentUpdateFailed(String message);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java
index e38cf7d..b819580 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java
@@ -40,56 +40,64 @@
   static void register(final Widget body) {
     final KeyCommandSet jumps = new KeyCommandSet();
 
-    jumps.add(new KeyCommand(0, 'o', Gerrit.C.jumpAllOpen()) {
-      @Override
-      public void onKeyPress(final KeyPressEvent event) {
-        Gerrit.display(PageLinks.toChangeQuery("status:open"));
-      }
-    });
-    jumps.add(new KeyCommand(0, 'm', Gerrit.C.jumpAllMerged()) {
-      @Override
-      public void onKeyPress(final KeyPressEvent event) {
-        Gerrit.display(PageLinks.toChangeQuery("status:merged"));
-      }
-    });
-    jumps.add(new KeyCommand(0, 'a', Gerrit.C.jumpAllAbandoned()) {
-      @Override
-      public void onKeyPress(final KeyPressEvent event) {
-        Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
-      }
-    });
+    jumps.add(
+        new KeyCommand(0, 'o', Gerrit.C.jumpAllOpen()) {
+          @Override
+          public void onKeyPress(final KeyPressEvent event) {
+            Gerrit.display(PageLinks.toChangeQuery("status:open"));
+          }
+        });
+    jumps.add(
+        new KeyCommand(0, 'm', Gerrit.C.jumpAllMerged()) {
+          @Override
+          public void onKeyPress(final KeyPressEvent event) {
+            Gerrit.display(PageLinks.toChangeQuery("status:merged"));
+          }
+        });
+    jumps.add(
+        new KeyCommand(0, 'a', Gerrit.C.jumpAllAbandoned()) {
+          @Override
+          public void onKeyPress(final KeyPressEvent event) {
+            Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
+          }
+        });
 
     if (Gerrit.isSignedIn()) {
-      jumps.add(new KeyCommand(0, 'i', Gerrit.C.jumpMine()) {
-        @Override
-        public void onKeyPress(final KeyPressEvent event) {
-          Gerrit.display(PageLinks.MINE);
-        }
-      });
-      jumps.add(new KeyCommand(0, 'd', Gerrit.C.jumpMineDrafts()) {
-        @Override
-        public void onKeyPress(final KeyPressEvent event) {
-          Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
-        }
-      });
-      jumps.add(new KeyCommand(0, 'c', Gerrit.C.jumpMineDraftComments()) {
-        @Override
-        public void onKeyPress(final KeyPressEvent event) {
-          Gerrit.display(PageLinks.toChangeQuery("has:draft"));
-        }
-      });
-      jumps.add(new KeyCommand(0, 'w', Gerrit.C.jumpMineWatched()) {
-        @Override
-        public void onKeyPress(final KeyPressEvent event) {
-          Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
-        }
-      });
-      jumps.add(new KeyCommand(0, 's', Gerrit.C.jumpMineStarred()) {
-        @Override
-        public void onKeyPress(final KeyPressEvent event) {
-          Gerrit.display(PageLinks.toChangeQuery("is:starred"));
-        }
-      });
+      jumps.add(
+          new KeyCommand(0, 'i', Gerrit.C.jumpMine()) {
+            @Override
+            public void onKeyPress(final KeyPressEvent event) {
+              Gerrit.display(PageLinks.MINE);
+            }
+          });
+      jumps.add(
+          new KeyCommand(0, 'd', Gerrit.C.jumpMineDrafts()) {
+            @Override
+            public void onKeyPress(final KeyPressEvent event) {
+              Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
+            }
+          });
+      jumps.add(
+          new KeyCommand(0, 'c', Gerrit.C.jumpMineDraftComments()) {
+            @Override
+            public void onKeyPress(final KeyPressEvent event) {
+              Gerrit.display(PageLinks.toChangeQuery("has:draft"));
+            }
+          });
+      jumps.add(
+          new KeyCommand(0, 'w', Gerrit.C.jumpMineWatched()) {
+            @Override
+            public void onKeyPress(final KeyPressEvent event) {
+              Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
+            }
+          });
+      jumps.add(
+          new KeyCommand(0, 's', Gerrit.C.jumpMineStarred()) {
+            @Override
+            public void onKeyPress(final KeyPressEvent event) {
+              Gerrit.display(PageLinks.toChangeQuery("is:starred"));
+            }
+          });
     }
 
     keys = new KeyCommandSet(Gerrit.C.sectionJumping());
@@ -98,6 +106,5 @@
     activeHandler = GlobalKey.add(body, keys);
   }
 
-  private JumpKeys() {
-  }
+  private JumpKeys() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java
index 054cdb3..36fa6e8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/MessageOfTheDayBar.java
@@ -28,13 +28,13 @@
 import com.google.gwt.user.client.ui.RootPanel;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.ArrayList;
 import java.util.List;
 
 /** Displays pending messages from the server. */
 class MessageOfTheDayBar extends Composite {
   interface Binder extends UiBinder<HTMLPanel, MessageOfTheDayBar> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private final List<HostPageData.Message> motd;
@@ -46,10 +46,10 @@
     initWidget(uiBinder.createAndBindUi(this));
 
     SafeHtmlBuilder b = new SafeHtmlBuilder();
-    if (motd.size() == 1) {
-      b.append(SafeHtml.asis(motd.get(0).html));
+    if (this.motd.size() == 1) {
+      b.append(SafeHtml.asis(this.motd.get(0).html));
     } else {
-      for (HostPageData.Message m : motd) {
+      for (HostPageData.Message m : this.motd) {
         b.openDiv();
         b.append(SafeHtml.asis(m.html));
         b.openElement("hr");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotSignedInDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotSignedInDialog.java
index a372f03..cd5197a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotSignedInDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/NotSignedInDialog.java
@@ -33,7 +33,7 @@
   private boolean buttonClicked;
 
   public NotSignedInDialog() {
-    super(/* auto hide */false, /* modal */true);
+    super(/* auto hide */ false, /* modal */ true);
     setGlassEnabled(true);
     getGlassElement().addClassName(Gerrit.RESOURCES.css().errorDialogGlass());
     addStyleName(Gerrit.RESOURCES.css().errorDialog());
@@ -41,27 +41,29 @@
     final FlowPanel buttons = new FlowPanel();
     signin = new Button();
     signin.setText(Gerrit.C.menuSignIn());
-    signin.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        buttonClicked = true;
-        hide();
-        Gerrit.doSignIn(History.getToken());
-      }
-    });
+    signin.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            buttonClicked = true;
+            hide();
+            Gerrit.doSignIn(History.getToken());
+          }
+        });
     buttons.add(signin);
 
     final Button close = new Button();
     close.getElement().getStyle().setProperty("marginLeft", "200px");
     close.setText(Gerrit.C.signInDialogGoAnonymous());
-    close.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        buttonClicked = true;
-        Gerrit.deleteSessionCookie();
-        hide();
-      }
-    });
+    close.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            buttonClicked = true;
+            Gerrit.deleteSessionCookie();
+            hide();
+          }
+        });
     buttons.add(close);
 
     Label title = new Label(Gerrit.C.notSignedInTitle());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java
index f86d8f4..42454a3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RangeInfo.java
@@ -21,6 +21,5 @@
 
   public final native int end() /*-{ return this.end; }-*/;
 
-  protected RangeInfo() {
-  }
+  protected RangeInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
index 83a187b..37c6a0b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchPanel.java
@@ -45,16 +45,17 @@
 
     searchBox = new HintTextBox();
     final MySuggestionDisplay suggestionDisplay = new MySuggestionDisplay();
-    searchBox.addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(final KeyPressEvent event) {
-        if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-          if (!suggestionDisplay.isSuggestionSelected) {
-            doSearch();
+    searchBox.addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(final KeyPressEvent event) {
+            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+              if (!suggestionDisplay.isSuggestionSelected) {
+                doSearch();
+              }
+            }
           }
-        }
-      }
-    });
+        });
 
     if (Gerrit.hasDocSearch()) {
       dropdown = new ListBox();
@@ -76,12 +77,13 @@
 
     final Button searchButton = new Button(Gerrit.C.searchButton());
     searchButton.setStyleName("searchButton");
-    searchButton.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        doSearch();
-      }
-    });
+    searchButton.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            doSearch();
+          }
+        });
 
     body.add(suggestBox);
     if (dropdown != null) {
@@ -99,15 +101,16 @@
     super.onLoad();
     if (regFocus == null) {
       regFocus =
-          GlobalKey.addApplication(this, new KeyCommand(0, '/', Gerrit.C
-              .keySearch()) {
-            @Override
-            public void onKeyPress(final KeyPressEvent event) {
-              event.preventDefault();
-              searchBox.setFocus(true);
-              searchBox.selectAll();
-            }
-          });
+          GlobalKey.addApplication(
+              this,
+              new KeyCommand(0, '/', Gerrit.C.keySearch()) {
+                @Override
+                public void onKeyPress(final KeyPressEvent event) {
+                  event.preventDefault();
+                  searchBox.setFocus(true);
+                  searchBox.selectAll();
+                }
+              });
     }
   }
 
@@ -127,8 +130,7 @@
 
     searchBox.setFocus(false);
 
-    if (dropdown != null
-        && dropdown.getSelectedValue().equals(Gerrit.C.searchDropdownDoc())) {
+    if (dropdown != null && dropdown.getSelectedValue().equals(Gerrit.C.searchDropdownDoc())) {
       // doc
       Gerrit.display(PageLinks.toDocumentationQuery(query));
     } else {
@@ -136,14 +138,12 @@
       if (query.matches("^[1-9][0-9]*$")) {
         Gerrit.display(PageLinks.toChange(Change.Id.parse(query)));
       } else {
-        Gerrit.display(
-            PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
+        Gerrit.display(PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
       }
     }
   }
 
-  private static class MySuggestionDisplay
-      extends SuggestBox.DefaultSuggestionDisplay {
+  private static class MySuggestionDisplay extends SuggestBox.DefaultSuggestionDisplay {
     private boolean isSuggestionSelected;
 
     private MySuggestionDisplay() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
index 54c5b92..20bc2746 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
@@ -19,49 +19,63 @@
 import com.google.gerrit.client.ui.ProjectNameSuggestOracle;
 import com.google.gwt.user.client.ui.SuggestOracle;
 import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.TreeSet;
 
 public class SearchSuggestOracle extends HighlightSuggestOracle {
-  private static final List<ParamSuggester> paramSuggester = Arrays.asList(
-      new ParamSuggester(Arrays.asList("project:", "parentproject:"),
-          new ProjectNameSuggestOracle()),
-      new ParamSuggester(Arrays.asList(
-          "owner:", "reviewer:", "commentby:", "reviewedby:", "author:",
-          "committer:", "from:"),
-          new AccountSuggestOracle() {
-            @Override
-            public void onRequestSuggestions(final Request request, final Callback done) {
-              super.onRequestSuggestions(request, new Callback() {
+  private static final List<ParamSuggester> paramSuggester =
+      Arrays.asList(
+          new ParamSuggester(
+              Arrays.asList("project:", "p:", "parentproject:"), new ProjectNameSuggestOracle()),
+          new ParamSuggester(
+              Arrays.asList(
+                  "owner:",
+                  "o:",
+                  "reviewer:",
+                  "r:",
+                  "commentby:",
+                  "reviewedby:",
+                  "author:",
+                  "committer:",
+                  "from:",
+                  "assignee:",
+                  "cc:"),
+              new AccountSuggestOracle() {
                 @Override
-                public void onSuggestionsReady(final Request request,
-                    final Response response) {
-                  if ("self".startsWith(request.getQuery())) {
-                    final ArrayList<SuggestOracle.Suggestion> r =
-                        new ArrayList<>(response.getSuggestions().size() + 1);
-                    r.addAll(response.getSuggestions());
-                    r.add(new SuggestOracle.Suggestion() {
-                      @Override
-                      public String getDisplayString() {
-                        return getReplacementString();
-                      }
-                      @Override
-                      public String getReplacementString() {
-                        return "self";
-                      }
-                    });
-                    response.setSuggestions(r);
-                  }
-                  done.onSuggestionsReady(request, response);
+                public void onRequestSuggestions(final Request request, final Callback done) {
+                  super.onRequestSuggestions(
+                      request,
+                      new Callback() {
+                        @Override
+                        public void onSuggestionsReady(
+                            final Request request, final Response response) {
+                          if ("self".startsWith(request.getQuery())) {
+                            final ArrayList<SuggestOracle.Suggestion> r =
+                                new ArrayList<>(response.getSuggestions().size() + 1);
+                            r.add(
+                                new SuggestOracle.Suggestion() {
+                                  @Override
+                                  public String getDisplayString() {
+                                    return getReplacementString();
+                                  }
+
+                                  @Override
+                                  public String getReplacementString() {
+                                    return "self";
+                                  }
+                                });
+                            r.addAll(response.getSuggestions());
+                            response.setSuggestions(r);
+                          }
+                          done.onSuggestionsReady(request, response);
+                        }
+                      });
                 }
-              });
-            }
-          }),
-      new ParamSuggester(Arrays.asList("ownerin:", "reviewerin:"),
-          new AccountGroupSuggestOracle()));
+              }),
+          new ParamSuggester(
+              Arrays.asList("ownerin:", "reviewerin:"), new AccountGroupSuggestOracle()));
 
   private static final TreeSet<String> suggestions = new TreeSet<>();
 
@@ -105,6 +119,7 @@
     suggestions.add("has:edit");
     suggestions.add("has:star");
     suggestions.add("has:stars");
+    suggestions.add("has:unresolved");
     suggestions.add("star:");
 
     suggestions.add("is:");
@@ -135,10 +150,17 @@
     suggestions.add("delta:");
     suggestions.add("size:");
 
+    suggestions.add("unresolved:");
+
     if (Gerrit.isNoteDbEnabled()) {
+      suggestions.add("cc:");
       suggestions.add("hashtag:");
     }
 
+    suggestions.add("is:assigned");
+    suggestions.add("is:unassigned");
+    suggestions.add("assignee:");
+
     suggestions.add("AND");
     suggestions.add("OR");
     suggestions.add("NOT");
@@ -210,12 +232,14 @@
   private static class SearchSuggestion implements SuggestOracle.Suggestion {
     private final String suggestion;
     private final String fullQuery;
+
     SearchSuggestion(String suggestion, String fullQuery) {
       this.suggestion = suggestion;
       // Add a space to the query if it is a complete operation (e.g.
       // "status:open") so the user can keep on typing.
       this.fullQuery = fullQuery.endsWith(":") ? fullQuery : fullQuery + " ";
     }
+
     @Override
     public String getDisplayString() {
       return suggestion;
@@ -231,8 +255,7 @@
     private final List<String> operators;
     private final SuggestOracle parameterSuggestionOracle;
 
-    ParamSuggester(final List<String> operators,
-        final SuggestOracle parameterSuggestionOracle) {
+    ParamSuggester(final List<String> operators, final SuggestOracle parameterSuggestionOracle) {
       this.operators = operators;
       this.parameterSuggestionOracle = parameterSuggestionOracle;
     }
@@ -242,8 +265,7 @@
       return operator != null && query.length() > operator.length();
     }
 
-    private String getApplicableOperator(final String lastWord,
-        final List<String> operators) {
+    private String getApplicableOperator(final String lastWord, final List<String> operators) {
       for (final String operator : operators) {
         if (lastWord.startsWith(operator)) {
           return operator;
@@ -258,16 +280,17 @@
           new Request(lastWord.substring(operator.length()), request.getLimit()),
           new Callback() {
             @Override
-            public void onSuggestionsReady(final Request req,
-                final Response response) {
+            public void onSuggestionsReady(final Request req, final Response response) {
               final String query = request.getQuery();
               final List<SearchSuggestOracle.Suggestion> r =
                   new ArrayList<>(response.getSuggestions().size());
-              for (final SearchSuggestOracle.Suggestion s : response
-                  .getSuggestions()) {
-                r.add(new SearchSuggestion(s.getDisplayString(),
-                    query.substring(0, query.length() - lastWord.length()) +
-                    operator + quoteIfNeeded(s.getReplacementString())));
+              for (final SearchSuggestOracle.Suggestion s : response.getSuggestions()) {
+                r.add(
+                    new SearchSuggestion(
+                        s.getDisplayString(),
+                        query.substring(0, query.length() - lastWord.length())
+                            + operator
+                            + quoteIfNeeded(s.getReplacementString())));
               }
               done.onSuggestionsReady(request, new Response(r));
             }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java
index bc36654..1a1f7bd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java
@@ -35,7 +35,6 @@
 import com.google.gwt.user.client.ui.Image;
 import com.google.gwt.user.client.ui.ImageResourceRenderer;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -47,8 +46,7 @@
   private Image info;
   protected FocusWidget widget;
 
-  public StringListPanel(String title, List<String> fieldNames, FocusWidget w,
-      boolean autoSort) {
+  public StringListPanel(String title, List<String> fieldNames, FocusWidget w, boolean autoSort) {
     widget = w;
     if (title != null) {
       titlePanel = new HorizontalPanel();
@@ -64,13 +62,14 @@
     buttonPanel.setStyleName(Gerrit.RESOURCES.css().stringListPanelButtons());
     deleteButton = new Button(Gerrit.C.stringListPanelDelete());
     deleteButton.setEnabled(false);
-    deleteButton.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        widget.setEnabled(true);
-        t.deleteChecked();
-      }
-    });
+    deleteButton.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            widget.setEnabled(true);
+            t.deleteChecked();
+          }
+        });
     buttonPanel.add(deleteButton);
     add(buttonPanel);
   }
@@ -110,8 +109,7 @@
     StringListTable(List<String> names, boolean autoSort) {
       this.autoSort = autoSort;
 
-      addButton =
-          new Button(new ImageResourceRenderer().render(Gerrit.RESOURCES.listAdd()));
+      addButton = new Button(new ImageResourceRenderer().render(Gerrit.RESOURCES.listAdd()));
       addButton.setTitle(Gerrit.C.stringListPanelAdd());
       OnEditEnabler e = new OnEditEnabler(addButton);
       inputs = new ArrayList<>();
@@ -125,15 +123,16 @@
 
         NpTextBox input = new NpTextBox();
         input.setVisibleLength(35);
-        input.addKeyPressHandler(new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              widget.setEnabled(true);
-              add();
-            }
-          }
-        });
+        input.addKeyPressHandler(
+            new KeyPressHandler() {
+              @Override
+              public void onKeyPress(KeyPressEvent event) {
+                if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+                  widget.setEnabled(true);
+                  add();
+                }
+              }
+            });
         inputs.add(input);
         fmt.addStyleName(1, i + 1, Gerrit.RESOURCES.css().dataHeader());
         table.setWidget(1, i + 1, input);
@@ -141,13 +140,14 @@
       }
       addButton.setEnabled(false);
 
-      addButton.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          widget.setEnabled(true);
-          add();
-        }
-      });
+      addButton.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(ClickEvent event) {
+              widget.setEnabled(true);
+              add();
+            }
+          });
       fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().iconHeader());
       fmt.addStyleName(1, 0, Gerrit.RESOURCES.css().leftMostCell());
       table.setWidget(1, 0, addButton);
@@ -194,12 +194,13 @@
       fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().iconCell());
       fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().leftMostCell());
       CheckBox checkBox = new CheckBox();
-      checkBox.addValueChangeHandler(new ValueChangeHandler<Boolean>() {
-        @Override
-        public void onValueChange(ValueChangeEvent<Boolean> event) {
-          enableDelete();
-        }
-      });
+      checkBox.addValueChangeHandler(
+          new ValueChangeHandler<Boolean>() {
+            @Override
+            public void onValueChange(ValueChangeEvent<Boolean> event) {
+              enableDelete();
+            }
+          });
       table.setWidget(row, 0, checkBox);
       for (int i = 0; i < values.size(); i++) {
         fmt.addStyleName(row, i + 1, Gerrit.RESOURCES.css().dataCell());
@@ -211,30 +212,31 @@
 
         Image down = new Image(Gerrit.RESOURCES.arrowDown());
         down.setTitle(Gerrit.C.stringListPanelDown());
-        down.addClickHandler(new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            moveDown(row);
-          }
-        });
+        down.addClickHandler(
+            new ClickHandler() {
+              @Override
+              public void onClick(ClickEvent event) {
+                moveDown(row);
+              }
+            });
         table.setWidget(row, values.size() + 1, down);
 
         Image up = new Image(Gerrit.RESOURCES.arrowUp());
         up.setTitle(Gerrit.C.stringListPanelUp());
-        up.addClickHandler(new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            moveUp(row);
-          }
-        });
+        up.addClickHandler(
+            new ClickHandler() {
+              @Override
+              public void onClick(ClickEvent event) {
+                moveUp(row);
+              }
+            });
         table.setWidget(row, values.size() + 2, up);
       }
     }
 
     @Override
     protected void onCellSingleClick(Event event, int row, int column) {
-      if (column == inputs.size() + 1 && row >= 2
-          && row < table.getRowCount() - 2) {
+      if (column == inputs.size() + 1 && row >= 2 && row < table.getRowCount() - 2) {
         moveDown(row);
       } else if (column == inputs.size() + 2 && row > 2) {
         moveUp(row);
@@ -265,8 +267,7 @@
     private void updateNavigationLinks() {
       if (!autoSort) {
         for (int row = 2; row < table.getRowCount(); row++) {
-          table.getWidget(row, inputs.size() + 1).setVisible(
-              row < table.getRowCount() - 1);
+          table.getWidget(row, inputs.size() + 1).setVisible(row < table.getRowCount() - 1);
           table.getWidget(row, inputs.size() + 2).setVisible(row > 2);
         }
       }
@@ -286,7 +287,7 @@
       if (autoSort) {
         for (int row = 1; row < table.getRowCount(); row++) {
           int compareResult = v.get(0).compareTo(table.getText(row, 1));
-          if (compareResult < 0)  {
+          if (compareResult < 0) {
             insertPos = row;
             break;
           } else if (compareResult == 0) {
@@ -320,8 +321,7 @@
     }
 
     @Override
-    protected void onOpenRow(int row) {
-    }
+    protected void onOpenRow(int row) {}
 
     @Override
     protected Object getRowItemKey(List<String> item) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Themer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Themer.java
index ceb50a8..d87c0b8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Themer.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Themer.java
@@ -20,8 +20,7 @@
 
 public class Themer {
   public static class ThemerIE extends Themer {
-    protected ThemerIE() {
-    }
+    protected ThemerIE() {}
 
     @Override
     protected String getCssText(StyleElement el) {
@@ -41,12 +40,12 @@
   protected String headerHtml;
   protected String footerHtml;
 
-  protected Themer() {
-  }
+  protected Themer() {}
 
   public void set(ThemeInfo theme) {
     if (theme != null) {
-      set(theme.css() != null ? theme.css() : cssText,
+      set(
+          theme.css() != null ? theme.css() : cssText,
           theme.header() != null ? theme.header() : headerHtml,
           theme.footer() != null ? theme.footer() : footerHtml);
     } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UrlAliasMatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UrlAliasMatcher.java
index f4ba870..acaaf46 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UrlAliasMatcher.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UrlAliasMatcher.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client;
 
 import com.google.gwt.regexp.shared.RegExp;
-
 import java.util.HashMap;
 import java.util.Map;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
index 0924796..40116af 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
@@ -27,9 +27,12 @@
 
 public class UserPopupPanel extends PopupPanel {
   interface Binder extends UiBinder<Widget, UserPopupPanel> {}
+
   private static final Binder binder = GWT.create(Binder.class);
 
-  @UiField(provided = true) AvatarImage avatar;
+  @UiField(provided = true)
+  AvatarImage avatar;
+
   @UiField Label userName;
   @UiField Label userEmail;
   @UiField Element userLinks;
@@ -37,9 +40,8 @@
   @UiField AnchorElement logout;
   @UiField InlineHyperlink settings;
 
-  public UserPopupPanel(AccountInfo account, boolean canLogOut,
-      boolean showSettingsLink) {
-    super(/* auto hide */true, /* modal */false);
+  public UserPopupPanel(AccountInfo account, boolean canLogOut, boolean showSettingsLink) {
+    super(/* auto hide */ true, /* modal */ false);
     avatar = new AvatarImage(account, 100, false);
     setWidget(binder.createAndBindUi(this));
     setStyleName(Gerrit.RESOURCES.css().userInfoPopup());
@@ -52,8 +54,7 @@
     if (showSettingsLink) {
       if (Gerrit.info().auth().switchAccountUrl() != null) {
         switchAccount.setHref(Gerrit.info().auth().switchAccountUrl());
-      } else if (Gerrit.info().auth().isDev()
-          || Gerrit.info().auth().isOpenId()) {
+      } else if (Gerrit.info().auth().isDev() || Gerrit.info().auth().isOpenId()) {
         switchAccount.setHref(Gerrit.selfRedirect("/login"));
       } else {
         switchAccount.removeFromParent();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/VoidResult.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/VoidResult.java
index d7bcd0c..810ebe7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/VoidResult.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/VoidResult.java
@@ -17,8 +17,7 @@
 import com.google.gwt.core.client.JavaScriptObject;
 
 public final class VoidResult extends JavaScriptObject {
-  protected VoidResult() {
-  }
+  protected VoidResult() {}
 
   public static VoidResult create() {
     return createObject().cast();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java
index 629f725..39a52e3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java
@@ -18,14 +18,12 @@
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import java.util.Collections;
 import java.util.Set;
 
 /** Access rights available from {@code /access/}. */
 public class AccessMap extends NativeMap<ProjectAccessInfo> {
-  public static void get(Set<Project.NameKey> projects,
-      AsyncCallback<AccessMap> callback) {
+  public static void get(Set<Project.NameKey> projects, AsyncCallback<AccessMap> callback) {
     RestApi api = new RestApi("/access/");
     for (Project.NameKey p : projects) {
       api.addParameter("project", p.get());
@@ -33,21 +31,21 @@
     api.get(NativeMap.copyKeysIntoChildren(callback));
   }
 
-  public static void get(final Project.NameKey project,
-      final AsyncCallback<ProjectAccessInfo> cb) {
-    get(Collections.singleton(project), new AsyncCallback<AccessMap>() {
-      @Override
-      public void onSuccess(AccessMap result) {
-        cb.onSuccess(result.get(project.get()));
-      }
+  public static void get(final Project.NameKey project, final AsyncCallback<ProjectAccessInfo> cb) {
+    get(
+        Collections.singleton(project),
+        new AsyncCallback<AccessMap>() {
+          @Override
+          public void onSuccess(AccessMap result) {
+            cb.onSuccess(result.get(project.get()));
+          }
 
-      @Override
-      public void onFailure(Throwable caught) {
-        cb.onFailure(caught);
-      }
-    });
+          @Override
+          public void onFailure(Throwable caught) {
+            cb.onFailure(caught);
+          }
+        });
   }
 
-  protected AccessMap() {
-  }
+  protected AccessMap() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
index 7cfb1fc..88635df 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/ProjectAccessInfo.java
@@ -18,8 +18,12 @@
 
 public class ProjectAccessInfo extends JavaScriptObject {
   public final native boolean canAddRefs() /*-{ return this.can_add ? true : false; }-*/;
+
+  public final native boolean canAddTagRefs() /*-{ return this.can_add_tags ? true : false; }-*/;
+
   public final native boolean isOwner() /*-{ return this.is_owner ? true : false; }-*/;
 
-  protected ProjectAccessInfo() {
-  }
+  public final native boolean configVisible() /*-{ return this.config_visible ? true : false; }-*/;
+
+  protected ProjectAccessInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
index acd2e78..7f4522f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountApi.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.AgreementInfo;
 import com.google.gerrit.client.info.GpgKeyInfo;
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.NativeMap;
@@ -26,14 +27,11 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
+import com.google.gwtorm.client.KeyUtil;
 import java.util.HashSet;
 import java.util.Set;
 
-/**
- * A collection of static methods which work on the Gerrit REST API for specific
- * accounts.
- */
+/** A collection of static methods which work on the Gerrit REST API for specific accounts. */
 public class AccountApi {
   public static RestApi self() {
     return new RestApi("/accounts/").view("self");
@@ -45,23 +43,20 @@
   }
 
   /** Put the account edit preferences */
-  public static void putEditPreferences(EditPreferences in,
-      AsyncCallback<EditPreferences> cb) {
+  public static void putEditPreferences(EditPreferences in, AsyncCallback<EditPreferences> cb) {
     self().view("preferences.edit").put(in, cb);
   }
 
-  public static void suggest(String query, int limit,
-      AsyncCallback<JsArray<AccountInfo>> cb) {
+  public static void suggest(String query, int limit, AsyncCallback<JsArray<AccountInfo>> cb) {
     new RestApi("/accounts/")
-      .addParameterTrue("suggest")
-      .addParameter("q", query)
-      .addParameter("n", limit)
-      .background()
-      .get(cb);
+        .addParameterTrue("suggest")
+        .addParameterRaw("q", KeyUtil.encode(query))
+        .addParameter("n", limit)
+        .background()
+        .get(cb);
   }
 
-  public static void putDiffPreferences(DiffPreferences in,
-      AsyncCallback<DiffPreferences> cb) {
+  public static void putDiffPreferences(DiffPreferences in, AsyncCallback<DiffPreferences> cb) {
     self().view("preferences.diff").put(in, cb);
   }
 
@@ -71,8 +66,7 @@
   }
 
   /** Set the username */
-  public static void setUsername(String account, String username,
-      AsyncCallback<NativeString> cb) {
+  public static void setUsername(String account, String username, AsyncCallback<NativeString> cb) {
     UsernameInput input = UsernameInput.create();
     input.username(username);
     new RestApi("/accounts/").id(account).view("username").put(input, cb);
@@ -83,36 +77,43 @@
     new RestApi("/accounts/").id(account).view("name").get(cb);
   }
 
+  /** Set the account name */
+  public static void setName(String account, String name, AsyncCallback<NativeString> cb) {
+    AccountNameInput input = AccountNameInput.create();
+    input.name(name);
+    new RestApi("/accounts/").id(account).view("name").put(input, cb);
+  }
+
   /** Retrieve email addresses */
-  public static void getEmails(String account,
-      AsyncCallback<JsArray<EmailInfo>> cb) {
+  public static void getEmails(String account, AsyncCallback<JsArray<EmailInfo>> cb) {
     new RestApi("/accounts/").id(account).view("emails").get(cb);
   }
 
   /** Register a new email address */
-  public static void registerEmail(String account, String email,
-      AsyncCallback<EmailInfo> cb) {
+  public static void registerEmail(String account, String email, AsyncCallback<EmailInfo> cb) {
     JavaScriptObject in = JavaScriptObject.createObject();
-    new RestApi("/accounts/").id(account).view("emails").id(email)
-        .ifNoneMatch().put(in, cb);
+    new RestApi("/accounts/").id(account).view("emails").id(email).ifNoneMatch().put(in, cb);
+  }
+
+  /** Set preferred email address */
+  public static void setPreferredEmail(
+      String account, String email, AsyncCallback<NativeString> cb) {
+    new RestApi("/accounts/").id(account).view("emails").id(email).view("preferred").put(cb);
   }
 
   /** Retrieve SSH keys */
-  public static void getSshKeys(String account,
-      AsyncCallback<JsArray<SshKeyInfo>> cb) {
+  public static void getSshKeys(String account, AsyncCallback<JsArray<SshKeyInfo>> cb) {
     new RestApi("/accounts/").id(account).view("sshkeys").get(cb);
   }
 
   /** Add a new SSH keys */
-  public static void addSshKey(String account, String sshPublicKey,
-      AsyncCallback<SshKeyInfo> cb) {
-    new RestApi("/accounts/").id(account).view("sshkeys")
-        .post(sshPublicKey, cb);
+  public static void addSshKey(String account, String sshPublicKey, AsyncCallback<SshKeyInfo> cb) {
+    new RestApi("/accounts/").id(account).view("sshkeys").post(sshPublicKey, cb);
   }
 
   /** Retrieve Watched Projects */
-  public static void getWatchedProjects(String account,
-      AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
+  public static void getWatchedProjects(
+      String account, AsyncCallback<JsArray<ProjectWatchInfo>> cb) {
     new RestApi("/accounts/").id(account).view("watched.projects").get(cb);
   }
 
@@ -159,45 +160,46 @@
   }
 
   /**
-   * Delete SSH keys. For each key to be deleted a separate DELETE request is
-   * fired to the server. The {@code onSuccess} method of the provided callback
-   * is invoked once after all requests succeeded. If any request fails the
-   * callbacks' {@code onFailure} method is invoked. In a failure case it can be
-   * that still some of the keys were successfully deleted.
+   * Delete SSH keys. For each key to be deleted a separate DELETE request is fired to the server.
+   * The {@code onSuccess} method of the provided callback is invoked once after all requests
+   * succeeded. If any request fails the callbacks' {@code onFailure} method is invoked. In a
+   * failure case it can be that still some of the keys were successfully deleted.
    */
-  public static void deleteSshKeys(String account,
-      Set<Integer> sequenceNumbers, AsyncCallback<VoidResult> cb) {
+  public static void deleteSshKeys(
+      String account, Set<Integer> sequenceNumbers, AsyncCallback<VoidResult> cb) {
     CallbackGroup group = new CallbackGroup();
     for (int seq : sequenceNumbers) {
-      new RestApi("/accounts/").id(account).view("sshkeys").id(seq)
-          .delete(group.add(cb));
+      new RestApi("/accounts/").id(account).view("sshkeys").id(seq).delete(group.add(cb));
       cb = CallbackGroup.emptyCallback();
     }
     group.done();
   }
 
-  /** Retrieve the HTTP password */
-  public static void getHttpPassword(String account,
-      AsyncCallback<NativeString> cb) {
-    new RestApi("/accounts/").id(account).view("password.http").get(cb);
-  }
-
   /** Generate a new HTTP password */
-  public static void generateHttpPassword(String account,
-      AsyncCallback<NativeString> cb) {
+  public static void generateHttpPassword(String account, AsyncCallback<NativeString> cb) {
     HttpPasswordInput in = HttpPasswordInput.create();
     in.generate(true);
     new RestApi("/accounts/").id(account).view("password.http").put(in, cb);
   }
 
-  /** Clear HTTP password */
-  public static void clearHttpPassword(String account,
-      AsyncCallback<VoidResult> cb) {
-    new RestApi("/accounts/").id(account).view("password.http").delete(cb);
+  /** Retrieve account external ids */
+  public static void getExternalIds(AsyncCallback<JsArray<ExternalIdInfo>> cb) {
+    self().view("external.ids").get(cb);
   }
 
-  private static JsArray<ProjectWatchInfo> projectWatchArrayFromSet(
-      Set<ProjectWatchInfo> set) {
+  /** Delete account external ids */
+  public static void deleteExternalIds(Set<String> ids, AsyncCallback<VoidResult> cb) {
+    self().view("external.ids:delete").post(Natives.arrayOf(ids), cb);
+  }
+
+  /** Enter a contributor agreement */
+  public static void enterAgreement(String account, String name, AsyncCallback<NativeString> cb) {
+    AgreementInput in = AgreementInput.create();
+    in.name(name);
+    new RestApi("/accounts/").id(account).view("agreements").put(in, cb);
+  }
+
+  private static JsArray<ProjectWatchInfo> projectWatchArrayFromSet(Set<ProjectWatchInfo> set) {
     JsArray<ProjectWatchInfo> jsArray = JsArray.createArray().cast();
     for (ProjectWatchInfo p : set) {
       jsArray.push(p);
@@ -205,6 +207,16 @@
     return jsArray;
   }
 
+  private static class AgreementInput extends JavaScriptObject {
+    final native void name(String n) /*-{ if(n)this.name=n; }-*/;
+
+    static AgreementInput create() {
+      return createObject().cast();
+    }
+
+    protected AgreementInput() {}
+  }
+
   private static class HttpPasswordInput extends JavaScriptObject {
     final native void generate(boolean g) /*-{ if(g)this.generate=g; }-*/;
 
@@ -212,8 +224,7 @@
       return createObject().cast();
     }
 
-    protected HttpPasswordInput() {
-    }
+    protected HttpPasswordInput() {}
   }
 
   private static class UsernameInput extends JavaScriptObject {
@@ -223,24 +234,35 @@
       return createObject().cast();
     }
 
-    protected UsernameInput() {
+    protected UsernameInput() {}
+  }
+
+  private static class AccountNameInput extends JavaScriptObject {
+    final native void name(String n) /*-{ if(n)this.name=n; }-*/;
+
+    static AccountNameInput create() {
+      return createObject().cast();
     }
+
+    protected AccountNameInput() {}
   }
 
-  public static void addGpgKey(String account, String armored,
-      AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
-    new RestApi("/accounts/")
-      .id(account)
-      .view("gpgkeys")
-      .post(GpgKeysInput.add(armored), cb);
+  public static void addGpgKey(
+      String account, String armored, AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
+    new RestApi("/accounts/").id(account).view("gpgkeys").post(GpgKeysInput.add(armored), cb);
   }
 
-  public static void deleteGpgKeys(String account,
-      Iterable<String> fingerprints, AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
+  public static void deleteGpgKeys(
+      String account, Iterable<String> fingerprints, AsyncCallback<NativeMap<GpgKeyInfo>> cb) {
     new RestApi("/accounts/")
-      .id(account)
-      .view("gpgkeys")
-      .post(GpgKeysInput.delete(fingerprints), cb);
+        .id(account)
+        .view("gpgkeys")
+        .post(GpgKeysInput.delete(fingerprints), cb);
+  }
+
+  /** List contributor agreements */
+  public static void getAgreements(String account, AsyncCallback<JsArray<AgreementInfo>> cb) {
+    new RestApi("/accounts/").id(account).view("agreements").get(cb);
   }
 
   private static class GpgKeysInput extends JavaScriptObject {
@@ -256,12 +278,10 @@
       return {'add': keys};
     }-*/;
 
-    private static native GpgKeysInput createWithDelete(
-        JsArrayString fingerprints) /*-{
+    private static native GpgKeysInput createWithDelete(JsArrayString fingerprints) /*-{
       return {'delete': fingerprints};
     }-*/;
 
-    protected GpgKeysInput() {
-    }
+    protected GpgKeysInput() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountCapabilities.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountCapabilities.java
index 42399ee..d317881 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountCapabilities.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountCapabilities.java
@@ -18,17 +18,13 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
-/** Capabilities the caller has from {@code /accounts/self/capabilities}.  */
+/** Capabilities the caller has from {@code /accounts/self/capabilities}. */
 public class AccountCapabilities extends JavaScriptObject {
   public static void all(AsyncCallback<AccountCapabilities> cb, String... filter) {
-    new RestApi("/accounts/self/capabilities")
-      .addParameter("q", filter)
-      .get(cb);
+    new RestApi("/accounts/self/capabilities").addParameter("q", filter).get(cb);
   }
 
-  protected AccountCapabilities() {
-  }
+  protected AccountCapabilities() {}
 
-  public final native boolean canPerform(String name)
-  /*-{ return this[name] ? true : false; }-*/;
+  public final native boolean canPerform(String name) /*-{ return this[name] ? true : false; }-*/;
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
index a084612..87694f9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
@@ -20,155 +20,284 @@
   String settingsHeading();
 
   String changeAvatar();
+
   String fullName();
+
   String preferredEmail();
+
   String registeredOn();
+
   String accountId();
 
   String diffViewLabel();
+
   String maximumPageSizeFieldLabel();
+
   String dateFormatLabel();
+
   String contextWholeFile();
+
   String showSiteHeader();
+
   String useFlashClipboard();
+
   String reviewCategoryLabel();
+
   String messageShowInReviewCategoryNone();
+
   String messageShowInReviewCategoryName();
+
   String messageShowInReviewCategoryEmail();
+
   String messageShowInReviewCategoryUsername();
+
   String messageShowInReviewCategoryAbbrev();
+
   String buttonSaveChanges();
+
+  String highlightAssigneeInChangeTable();
+
   String showRelativeDateInChangeTable();
+
   String showSizeBarInChangeTable();
+
   String showLegacycidInChangeTable();
+
   String muteCommonPathPrefixes();
+
   String signedOffBy();
+
   String myMenu();
+
   String myMenuInfo();
+
   String myMenuName();
+
   String myMenuUrl();
+
   String myMenuReset();
 
   String tabAccountSummary();
+
   String tabAgreements();
+
   String tabContactInformation();
+
   String tabDiffPreferences();
+
   String tabEditPreferences();
+
   String tabGpgKeys();
+
   String tabHttpAccess();
+
   String tabMyGroups();
+
   String tabOAuthToken();
+
   String tabPreferences();
+
   String tabSshKeys();
+
   String tabWatchedProjects();
+
   String tabWebIdentities();
 
   String buttonShowAddSshKey();
+
   String buttonCloseAddSshKey();
+
   String buttonDeleteSshKey();
+
   String buttonClearSshKeyInput();
+
   String buttonAddSshKey();
 
   String userName();
+
   String password();
+
   String buttonSetUserName();
+
   String confirmSetUserNameTitle();
+
   String confirmSetUserName();
+
   String buttonClearPassword();
+
   String buttonGeneratePassword();
+
+  String revokePassword();
+
   String linkObtainPassword();
+
   String linkEditFullName();
+
   String linkReloadContact();
+
   String invalidUserName();
+
   String invalidUserEmail();
 
   String labelOAuthToken();
+
   String labelOAuthExpires();
+
   String labelOAuthNetRCEntry();
+
   String labelOAuthGitCookie();
+
   String labelOAuthExpired();
 
   String sshKeyInvalid();
+
   String sshKeyAlgorithm();
+
   String sshKeyKey();
+
   String sshKeyComment();
+
   String sshKeyStatus();
 
   String addSshKeyPanelHeader();
+
   String addSshKeyHelpTitle();
+
   String addSshKeyHelp();
+
   String sshJavaAppletNotAvailable();
+
   String invalidSshKeyError();
 
   String sshHostKeyTitle();
+
   String sshHostKeyFingerprint();
+
   String sshHostKeyKnownHostEntry();
 
   String gpgKeyId();
+
   String gpgKeyFingerprint();
+
   String gpgKeyUserIds();
 
   String webIdStatus();
+
   String webIdEmail();
+
   String webIdIdentity();
+
   String untrustedProvider();
+
   String buttonDeleteIdentity();
+
   String buttonLinkIdentity();
 
   String buttonWatchProject();
+
   String defaultProjectName();
+
   String defaultFilter();
+
   String buttonBrowseProjects();
+
   String projects();
+
   String projectsClose();
+
   String projectListOpen();
+
   String watchedProjectName();
+
   String watchedProjectFilter();
+
   String watchedProjectColumnEmailNotifications();
+
   String watchedProjectColumnNewChanges();
+
   String watchedProjectColumnNewPatchSets();
+
   String watchedProjectColumnAllComments();
+
   String watchedProjectColumnSubmittedChanges();
+
   String watchedProjectColumnAbandonedChanges();
 
   String contactFieldFullName();
+
   String contactFieldEmail();
+
   String buttonOpenRegisterNewEmail();
+
   String buttonSendRegisterNewEmail();
+
   String buttonCancel();
+
   String titleRegisterNewEmail();
+
   String descRegisterNewEmail();
+
   String errorDialogTitleRegisterNewEmail();
 
   String newAgreement();
-  String agreementStatus();
+
   String agreementName();
+
   String agreementDescription();
-  String agreementStatus_EXPIRED();
-  String agreementStatus_VERIFIED();
 
   String newAgreementSelectTypeHeading();
+
   String newAgreementNoneAvailable();
+
   String newAgreementReviewLegalHeading();
+
   String newAgreementReviewContactHeading();
+
   String newAgreementCompleteHeading();
+
   String newAgreementIAGREE();
+
   String newAgreementAlreadySubmitted();
+
   String buttonSubmitNewAgreement();
 
   String welcomeToGerritCodeReview();
+
   String welcomeReviewContact();
+
   String welcomeContactFrom();
+
   String welcomeUsernameHeading();
+
   String welcomeSshKeyHeading();
+
   String welcomeSshKeyText();
+
   String welcomeAgreementHeading();
+
   String welcomeAgreementText();
+
   String welcomeAgreementLater();
+
   String welcomeContinue();
 
   String messageEnabled();
+
   String messageCCMeOnMyComments();
+
   String messageDisabled();
+
   String emailFieldLabel();
+
+  String emailFormatFieldLabel();
+
+  String messagePlaintextOnly();
+
+  String messageHtmlPlaintext();
+
+  String defaultBaseForMerges();
+
+  String autoMerge();
+
+  String firstParent();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
index ca2d316..481a9a7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
@@ -15,15 +15,24 @@
 messageShowInReviewCategoryAbbrev = Show Abbreviated Name
 
 emailFieldLabel = Email Notifications:
-messageEnabled = Enabled
-messageCCMeOnMyComments = CC Me On Comments I Write
-messageDisabled = Disabled
+messageCCMeOnMyComments = Every Comment
+messageEnabled = Only Comments Left By Others
+messageDisabled = None
+
+emailFormatFieldLabel = Email Format:
+messagePlaintextOnly = Plaintext Only
+messageHtmlPlaintext = HTML and Plaintext
+
+defaultBaseForMerges = Default Base For Merges:
+autoMerge = Auto Merge
+firstParent = First Parent
 
 maximumPageSizeFieldLabel = Maximum Page Size:
 diffViewLabel = Diff View:
 dateFormatLabel = Date/Time Format:
 contextWholeFile = Whole File
 buttonSaveChanges = Save Changes
+highlightAssigneeInChangeTable = Highlight Changes Assigned To Me In Changes Table
 showRelativeDateInChangeTable = Show Relative Dates In Changes Table
 showSizeBarInChangeTable = Show Change Sizes As Colored Bars
 showLegacycidInChangeTable = Show Change Number In Changes Table
@@ -64,6 +73,7 @@
 confirmSetUserName = Setting the Username is permanent.  Are you sure?
 buttonClearPassword = Clear Password
 buttonGeneratePassword = Generate Password
+revokePassword = (click 'Generate Password' to revoke an old password)
 linkObtainPassword = Obtain Password
 linkEditFullName = Edit
 linkReloadContact = Reload
@@ -151,10 +161,7 @@
 
 
 newAgreement = New Contributor Agreement
-agreementStatus = Status
 agreementName = Name
-agreementStatus_EXPIRED = Expired
-agreementStatus_VERIFIED = Verified
 agreementDescription = Description
 
 newAgreementSelectTypeHeading = Select an agreement type:
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java
index b398c0f..4cff1e2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountMessages.java
@@ -18,7 +18,10 @@
 
 public interface AccountMessages extends Messages {
   String lines(short cnt);
+
   String rowsPerPage(int cnt);
+
   String changeScreenServerDefault(String d);
+
   String enterIAGREE(String iagree);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
index ae3599d..da0357f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ContactPanelShort.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.account;
 
 import com.google.gerrit.client.ErrorDialog;
-import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.rpc.CallbackGroup;
@@ -25,8 +24,7 @@
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ChangeEvent;
 import com.google.gwt.event.dom.client.ChangeHandler;
@@ -46,7 +44,6 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
 import com.google.gwtexpui.user.client.AutoCenterDialogBox;
-import com.google.gwtjsonrpc.common.AsyncCallback;
 
 class ContactPanelShort extends Composite {
   protected final FlowPanel body;
@@ -91,12 +88,13 @@
 
     registerNewEmail = new Button(Util.C.buttonOpenRegisterNewEmail());
     registerNewEmail.setEnabled(false);
-    registerNewEmail.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doRegisterNewEmail();
-      }
-    });
+    registerNewEmail.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doRegisterNewEmail();
+          }
+        });
     final FlowPanel emailLine = new FlowPanel();
     emailLine.add(emailPick);
     if (canRegisterNewEmail()) {
@@ -104,7 +102,7 @@
     }
 
     int row = 0;
-    if (!Gerrit.info().auth().canEdit(FieldName.USER_NAME)
+    if (!Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME)
         && Gerrit.info().auth().siteHasUsernames()) {
       infoPlainText.resizeRows(infoPlainText.getRowCount() + 1);
       row(infoPlainText, row++, Util.C.userName(), new UsernameField());
@@ -115,21 +113,23 @@
       nameLine.add(nameTxt);
       if (Gerrit.info().auth().editFullNameUrl() != null) {
         Button edit = new Button(Util.C.linkEditFullName());
-        edit.addClickHandler(new ClickHandler() {
-          @Override
-          public void onClick(ClickEvent event) {
-            Window.open(Gerrit.info().auth().editFullNameUrl(), "_blank", null);
-          }
-        });
+        edit.addClickHandler(
+            new ClickHandler() {
+              @Override
+              public void onClick(ClickEvent event) {
+                Window.open(Gerrit.info().auth().editFullNameUrl(), "_blank", null);
+              }
+            });
         nameLine.add(edit);
       }
       Button reload = new Button(Util.C.linkReloadContact());
-      reload.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          Window.Location.replace(Gerrit.loginRedirect(PageLinks.SETTINGS_CONTACT));
-        }
-      });
+      reload.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(ClickEvent event) {
+              Window.Location.replace(Gerrit.loginRedirect(PageLinks.SETTINGS_CONTACT));
+            }
+          });
       nameLine.add(reload);
       row(infoPlainText, row++, Util.C.contactFieldFullName(), nameLine);
     } else {
@@ -139,45 +139,49 @@
 
     infoPlainText.getCellFormatter().addStyleName(0, 0, Gerrit.RESOURCES.css().topmost());
     infoPlainText.getCellFormatter().addStyleName(0, 1, Gerrit.RESOURCES.css().topmost());
-    infoPlainText.getCellFormatter().addStyleName(row - 1, 0, Gerrit.RESOURCES.css().bottomheader());
+    infoPlainText
+        .getCellFormatter()
+        .addStyleName(row - 1, 0, Gerrit.RESOURCES.css().bottomheader());
 
     save = new Button(Util.C.buttonSaveChanges());
     save.setEnabled(false);
-    save.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doSave(null);
-      }
-    });
+    save.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doSave();
+          }
+        });
 
-    emailPick.addChangeHandler(new ChangeHandler() {
-      @Override
-      public void onChange(final ChangeEvent event) {
-        final int idx = emailPick.getSelectedIndex();
-        final String v = 0 <= idx ? emailPick.getValue(idx) : null;
-        if (Util.C.buttonOpenRegisterNewEmail().equals(v)) {
-          for (int i = 0; i < emailPick.getItemCount(); i++) {
-            if (currentEmail.equals(emailPick.getValue(i))) {
-              emailPick.setSelectedIndex(i);
-              break;
+    emailPick.addChangeHandler(
+        new ChangeHandler() {
+          @Override
+          public void onChange(final ChangeEvent event) {
+            final int idx = emailPick.getSelectedIndex();
+            final String v = 0 <= idx ? emailPick.getValue(idx) : null;
+            if (Util.C.buttonOpenRegisterNewEmail().equals(v)) {
+              for (int i = 0; i < emailPick.getItemCount(); i++) {
+                if (currentEmail.equals(emailPick.getValue(i))) {
+                  emailPick.setSelectedIndex(i);
+                  break;
+                }
+              }
+              doRegisterNewEmail();
+            } else {
+              save.setEnabled(true);
             }
           }
-          doRegisterNewEmail();
-        } else {
-          save.setEnabled(true);
-        }
-      }
-    });
+        });
 
     onEditEnabler = new OnEditEnabler(save, nameTxt);
   }
 
   private boolean canEditFullName() {
-    return Gerrit.info().auth().canEdit(Account.FieldName.FULL_NAME);
+    return Gerrit.info().auth().canEdit(AccountFieldName.FULL_NAME);
   }
 
   private boolean canRegisterNewEmail() {
-    return Gerrit.info().auth().canEdit(Account.FieldName.REGISTER_NEW_EMAIL);
+    return Gerrit.info().auth().canEdit(AccountFieldName.REGISTER_NEW_EMAIL);
   }
 
   void hideSaveButton() {
@@ -200,32 +204,37 @@
     haveEmails = false;
 
     CallbackGroup group = new CallbackGroup();
-    AccountApi.getName("self", group.add(new GerritCallback<NativeString>() {
+    AccountApi.getName(
+        "self",
+        group.add(
+            new GerritCallback<NativeString>() {
 
-      @Override
-      public void onSuccess(NativeString result) {
-        nameTxt.setText(result.asString());
-        haveAccount = true;
-      }
+              @Override
+              public void onSuccess(NativeString result) {
+                nameTxt.setText(result.asString());
+                haveAccount = true;
+              }
 
-      @Override
-      public void onFailure(Throwable caught) {
-      }
-    }));
+              @Override
+              public void onFailure(Throwable caught) {}
+            }));
 
-    AccountApi.getEmails("self", group.addFinal(new GerritCallback<JsArray<EmailInfo>>() {
-      @Override
-      public void onSuccess(JsArray<EmailInfo> result) {
-        for (EmailInfo i : Natives.asList(result)) {
-          emailPick.addItem(i.email());
-          if (i.isPreferred()) {
-            currentEmail = i.email();
-          }
-        }
-        haveEmails = true;
-        postLoad();
-      }
-    }));
+    AccountApi.getEmails(
+        "self",
+        group.addFinal(
+            new GerritCallback<JsArray<EmailInfo>>() {
+              @Override
+              public void onSuccess(JsArray<EmailInfo> result) {
+                for (EmailInfo i : Natives.asList(result)) {
+                  emailPick.addItem(i.email());
+                  if (i.isPreferred()) {
+                    currentEmail = i.email();
+                  }
+                }
+                haveEmails = true;
+                postLoad();
+              }
+            }));
   }
 
   private void postLoad() {
@@ -238,11 +247,9 @@
     display();
   }
 
-  void display() {
-  }
+  void display() {}
 
-  protected void row(final Grid info, final int row, final String name,
-      final Widget field) {
+  protected void row(final Grid info, final int row, final String name, final Widget field) {
     info.setText(row, labelIdx, name);
     info.setWidget(row, fieldIdx, field);
     info.getCellFormatter().addStyleName(row, 0, Gerrit.RESOURCES.css().header());
@@ -269,66 +276,72 @@
     final Button register = new Button(Util.C.buttonSendRegisterNewEmail());
     final Button cancel = new Button(Util.C.buttonCancel());
     final FormPanel form = new FormPanel();
-    form.addSubmitHandler(new FormPanel.SubmitHandler() {
-      @Override
-      public void onSubmit(final SubmitEvent event) {
-        event.cancel();
-        final String addr = inEmail.getText().trim();
-        if (!addr.contains("@")) {
-          new ErrorDialog(Util.C.invalidUserEmail()).center();
-          return;
-        }
-
-        inEmail.setEnabled(false);
-        register.setEnabled(false);
-        AccountApi.registerEmail("self", addr, new GerritCallback<EmailInfo>() {
+    form.addSubmitHandler(
+        new FormPanel.SubmitHandler() {
           @Override
-          public void onSuccess(EmailInfo result) {
-            box.hide();
-            if (Gerrit.info().auth().isDev()) {
-              currentEmail = addr;
-              if (emailPick.getItemCount() == 0) {
-                AccountInfo me = Gerrit.getUserAccount();
-                me.email(addr);
-                onSaveSuccess(me);
-              } else {
-                save.setEnabled(true);
-              }
-              updateEmailList();
+          public void onSubmit(final SubmitEvent event) {
+            event.cancel();
+            final String addr = inEmail.getText().trim();
+            if (!addr.contains("@")) {
+              new ErrorDialog(Util.C.invalidUserEmail()).center();
+              return;
             }
-          }
 
-          @Override
-          public void onFailure(final Throwable caught) {
-            inEmail.setEnabled(true);
-            register.setEnabled(true);
-            if (caught.getMessage().startsWith(EmailException.MESSAGE)) {
-              final ErrorDialog d =
-                  new ErrorDialog(caught.getMessage().substring(
-                      EmailException.MESSAGE.length()));
-              d.setText(Util.C.errorDialogTitleRegisterNewEmail());
-              d.center();
-            } else {
-              super.onFailure(caught);
-            }
+            inEmail.setEnabled(false);
+            register.setEnabled(false);
+            AccountApi.registerEmail(
+                "self",
+                addr,
+                new GerritCallback<EmailInfo>() {
+                  @Override
+                  public void onSuccess(EmailInfo result) {
+                    box.hide();
+                    if (Gerrit.info().auth().isDev()) {
+                      currentEmail = addr;
+                      if (emailPick.getItemCount() == 0) {
+                        AccountInfo me = Gerrit.getUserAccount();
+                        me.email(addr);
+                        onSaveSuccess(me);
+                      } else {
+                        save.setEnabled(true);
+                      }
+                      updateEmailList();
+                    }
+                  }
+
+                  @Override
+                  public void onFailure(final Throwable caught) {
+                    inEmail.setEnabled(true);
+                    register.setEnabled(true);
+                    if (caught.getMessage().startsWith(EmailException.MESSAGE)) {
+                      final ErrorDialog d =
+                          new ErrorDialog(
+                              caught.getMessage().substring(EmailException.MESSAGE.length()));
+                      d.setText(Util.C.errorDialogTitleRegisterNewEmail());
+                      d.center();
+                    } else {
+                      super.onFailure(caught);
+                    }
+                  }
+                });
           }
         });
-      }
-    });
     form.setWidget(body);
 
-    register.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        form.submit();
-      }
-    });
-    cancel.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        box.hide();
-      }
-    });
+    register.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            form.submit();
+          }
+        });
+    cancel.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            box.hide();
+          }
+        });
 
     final FlowPanel buttons = new FlowPanel();
     buttons.setStyleName(Gerrit.RESOURCES.css().patchSetActions());
@@ -347,10 +360,13 @@
     inEmail.setFocus(true);
   }
 
-  void doSave(final AsyncCallback<Account> onSave) {
-    String newName = canEditFullName() ? nameTxt.getText() : null;
-    if (newName != null && newName.trim().isEmpty()) {
+  void doSave() {
+    final String newName;
+    String name = canEditFullName() ? nameTxt.getText() : null;
+    if (name != null && name.trim().isEmpty()) {
       newName = null;
+    } else {
+      newName = name;
     }
 
     final String newEmail;
@@ -368,22 +384,43 @@
     save.setEnabled(false);
     registerNewEmail.setEnabled(false);
 
-    Util.ACCOUNT_SEC.updateContact(newName, newEmail,
-        new GerritCallback<Account>() {
-          @Override
-          public void onSuccess(Account result) {
-            registerNewEmail.setEnabled(true);
-            onSaveSuccess(FormatUtil.asInfo(result));
-            if (onSave != null) {
-              onSave.onSuccess(result);
-            }
-          }
+    CallbackGroup group = new CallbackGroup();
+    if (currentEmail != null && !newEmail.equals(currentEmail)) {
+      AccountApi.setPreferredEmail(
+          "self",
+          newEmail,
+          group.add(
+              new GerritCallback<NativeString>() {
+                @Override
+                public void onSuccess(NativeString result) {}
+              }));
+    }
+    AccountApi.setName(
+        "self",
+        newName,
+        group.add(
+            new GerritCallback<NativeString>() {
+              @Override
+              public void onSuccess(NativeString result) {}
 
+              @Override
+              public void onFailure(Throwable caught) {
+                save.setEnabled(true);
+                registerNewEmail.setEnabled(true);
+                super.onFailure(caught);
+              }
+            }));
+    group.done();
+    group.addListener(
+        new GerritCallback<Void>() {
           @Override
-          public void onFailure(final Throwable caught) {
-            save.setEnabled(true);
+          public void onSuccess(Void result) {
+            currentEmail = newEmail;
+            AccountInfo me = Gerrit.getUserAccount();
+            me.email(currentEmail);
+            me.name(newName);
+            onSaveSuccess(me);
             registerNewEmail.setEnabled(true);
-            super.onFailure(caught);
           }
         });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
index 423d05f..286d29a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/DiffPreferences.java
@@ -126,52 +126,101 @@
   }
 
   public final native void tabSize(int t) /*-{ this.tab_size = t }-*/;
+
   public final native void lineLength(int c) /*-{ this.line_length = c }-*/;
+
   public final native void context(int c) /*-{ this.context = c }-*/;
+
   public final native void cursorBlinkRate(int r) /*-{ this.cursor_blink_rate = r }-*/;
+
   public final native void intralineDifference(Boolean i) /*-{ this.intraline_difference = i }-*/;
+
   public final native void showLineEndings(Boolean s) /*-{ this.show_line_endings = s }-*/;
+
   public final native void showTabs(Boolean s) /*-{ this.show_tabs = s }-*/;
-  public final native void showWhitespaceErrors(Boolean s) /*-{ this.show_whitespace_errors = s }-*/;
+
+  public final native void showWhitespaceErrors(
+      Boolean s) /*-{ this.show_whitespace_errors = s }-*/;
+
   public final native void syntaxHighlighting(Boolean s) /*-{ this.syntax_highlighting = s }-*/;
+
   public final native void hideTopMenu(Boolean s) /*-{ this.hide_top_menu = s }-*/;
-  public final native void autoHideDiffTableHeader(Boolean s) /*-{ this.auto_hide_diff_table_header = s }-*/;
+
+  public final native void autoHideDiffTableHeader(
+      Boolean s) /*-{ this.auto_hide_diff_table_header = s }-*/;
+
   public final native void hideLineNumbers(Boolean s) /*-{ this.hide_line_numbers = s }-*/;
+
   public final native void expandAllComments(Boolean e) /*-{ this.expand_all_comments = e }-*/;
+
   public final native void manualReview(Boolean r) /*-{ this.manual_review = r }-*/;
+
   public final native void renderEntireFile(Boolean r) /*-{ this.render_entire_file = r }-*/;
+
   public final native void retainHeader(Boolean r) /*-{ this.retain_header = r }-*/;
+
   public final native void hideEmptyPane(Boolean s) /*-{ this.hide_empty_pane = s }-*/;
+
   public final native void skipUnchanged(Boolean s) /*-{ this.skip_unchanged = s }-*/;
+
   public final native void skipUncommented(Boolean s) /*-{ this.skip_uncommented = s }-*/;
+
   public final native void skipDeleted(Boolean s) /*-{ this.skip_deleted = s }-*/;
+
   public final native void matchBrackets(Boolean m) /*-{ this.match_brackets = m }-*/;
+
   public final native void lineWrapping(Boolean w) /*-{ this.line_wrapping = w }-*/;
-  public final native boolean intralineDifference() /*-{ return this.intraline_difference || false }-*/;
+
+  public final native boolean
+      intralineDifference() /*-{ return this.intraline_difference || false }-*/;
+
   public final native boolean showLineEndings() /*-{ return this.show_line_endings || false }-*/;
+
   public final native boolean showTabs() /*-{ return this.show_tabs || false }-*/;
-  public final native boolean showWhitespaceErrors() /*-{ return this.show_whitespace_errors || false }-*/;
-  public final native boolean syntaxHighlighting() /*-{ return this.syntax_highlighting || false }-*/;
+
+  public final native boolean
+      showWhitespaceErrors() /*-{ return this.show_whitespace_errors || false }-*/;
+
+  public final native boolean
+      syntaxHighlighting() /*-{ return this.syntax_highlighting || false }-*/;
+
   public final native boolean hideTopMenu() /*-{ return this.hide_top_menu || false }-*/;
-  public final native boolean autoHideDiffTableHeader() /*-{ return this.auto_hide_diff_table_header || false }-*/;
+
+  public final native boolean
+      autoHideDiffTableHeader() /*-{ return this.auto_hide_diff_table_header || false }-*/;
+
   public final native boolean hideLineNumbers() /*-{ return this.hide_line_numbers || false }-*/;
-  public final native boolean expandAllComments() /*-{ return this.expand_all_comments || false }-*/;
+
+  public final native boolean
+      expandAllComments() /*-{ return this.expand_all_comments || false }-*/;
+
   public final native boolean manualReview() /*-{ return this.manual_review || false }-*/;
+
   public final native boolean renderEntireFile() /*-{ return this.render_entire_file || false }-*/;
+
   public final native boolean hideEmptyPane() /*-{ return this.hide_empty_pane || false }-*/;
+
   public final native boolean retainHeader() /*-{ return this.retain_header || false }-*/;
+
   public final native boolean skipUnchanged() /*-{ return this.skip_unchanged || false }-*/;
+
   public final native boolean skipUncommented() /*-{ return this.skip_uncommented || false }-*/;
+
   public final native boolean skipDeleted() /*-{ return this.skip_deleted || false }-*/;
+
   public final native boolean matchBrackets() /*-{ return this.match_brackets || false }-*/;
+
   public final native boolean lineWrapping() /*-{ return this.line_wrapping || false }-*/;
 
   private native void setThemeRaw(String i) /*-{ this.theme = i }-*/;
+
   private native void setIgnoreWhitespaceRaw(String i) /*-{ this.ignore_whitespace = i }-*/;
+
   private native String ignoreWhitespaceRaw() /*-{ return this.ignore_whitespace }-*/;
+
   private native String themeRaw() /*-{ return this.theme }-*/;
+
   private native int get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
 
-  protected DiffPreferences() {
-  }
+  protected DiffPreferences() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java
index ae89607..9cd2f17 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EditPreferences.java
@@ -33,6 +33,7 @@
     p.hideLineNumbers(in.hideLineNumbers);
     p.matchBrackets(in.matchBrackets);
     p.lineWrapping(in.lineWrapping);
+    p.indentWithTabs(in.indentWithTabs);
     p.autoCloseBrackets(in.autoCloseBrackets);
     p.showBase(in.showBase);
     p.theme(in.theme);
@@ -52,6 +53,7 @@
     p.hideLineNumbers = hideLineNumbers();
     p.matchBrackets = matchBrackets();
     p.lineWrapping = lineWrapping();
+    p.indentWithTabs = indentWithTabs();
     p.autoCloseBrackets = autoCloseBrackets();
     p.showBase = showBase();
     p.theme = theme();
@@ -62,37 +64,56 @@
   public final void theme(Theme i) {
     setThemeRaw(i != null ? i.toString() : Theme.DEFAULT.toString());
   }
+
   private native void setThemeRaw(String i) /*-{ this.theme = i }-*/;
 
   public final void keyMapType(KeyMapType i) {
     setkeyMapTypeRaw(i != null ? i.toString() : KeyMapType.DEFAULT.toString());
   }
+
   private native void setkeyMapTypeRaw(String i) /*-{ this.key_map_type = i }-*/;
 
   public final native void tabSize(int t) /*-{ this.tab_size = t }-*/;
+
   public final native void lineLength(int c) /*-{ this.line_length = c }-*/;
+
   public final native void indentUnit(int c) /*-{ this.indent_unit = c }-*/;
+
   public final native void cursorBlinkRate(int r) /*-{ this.cursor_blink_rate = r }-*/;
+
   public final native void hideTopMenu(boolean s) /*-{ this.hide_top_menu = s }-*/;
+
   public final native void showTabs(boolean s) /*-{ this.show_tabs = s }-*/;
-  public final native void showWhitespaceErrors(boolean s) /*-{ this.show_whitespace_errors = s }-*/;
+
+  public final native void showWhitespaceErrors(
+      boolean s) /*-{ this.show_whitespace_errors = s }-*/;
+
   public final native void syntaxHighlighting(boolean s) /*-{ this.syntax_highlighting = s }-*/;
+
   public final native void hideLineNumbers(boolean s) /*-{ this.hide_line_numbers = s }-*/;
+
   public final native void matchBrackets(boolean m) /*-{ this.match_brackets = m }-*/;
+
   public final native void lineWrapping(boolean w) /*-{ this.line_wrapping = w }-*/;
+
+  public final native void indentWithTabs(boolean w) /*-{ this.indent_with_tabs = w }-*/;
+
   public final native void autoCloseBrackets(boolean c) /*-{ this.auto_close_brackets = c }-*/;
+
   public final native void showBase(boolean s) /*-{ this.show_base = s }-*/;
 
   public final Theme theme() {
     String s = themeRaw();
     return s != null ? Theme.valueOf(s) : Theme.DEFAULT;
   }
+
   private native String themeRaw() /*-{ return this.theme }-*/;
 
   public final KeyMapType keyMapType() {
     String s = keyMapTypeRaw();
     return s != null ? KeyMapType.valueOf(s) : KeyMapType.DEFAULT;
   }
+
   private native String keyMapTypeRaw() /*-{ return this.key_map_type }-*/;
 
   public final int tabSize() {
@@ -112,16 +133,29 @@
   }
 
   public final native boolean hideTopMenu() /*-{ return this.hide_top_menu || false }-*/;
+
   public final native boolean showTabs() /*-{ return this.show_tabs || false }-*/;
-  public final native boolean showWhitespaceErrors() /*-{ return this.show_whitespace_errors || false }-*/;
-  public final native boolean syntaxHighlighting() /*-{ return this.syntax_highlighting || false }-*/;
+
+  public final native boolean
+      showWhitespaceErrors() /*-{ return this.show_whitespace_errors || false }-*/;
+
+  public final native boolean
+      syntaxHighlighting() /*-{ return this.syntax_highlighting || false }-*/;
+
   public final native boolean hideLineNumbers() /*-{ return this.hide_line_numbers || false }-*/;
+
   public final native boolean matchBrackets() /*-{ return this.match_brackets || false }-*/;
+
   public final native boolean lineWrapping() /*-{ return this.line_wrapping || false }-*/;
-  public final native boolean autoCloseBrackets() /*-{ return this.auto_close_brackets || false }-*/;
+
+  public final native boolean indentWithTabs() /*-{ return this.indent_with_tabs || false }-*/;
+
+  public final native boolean
+      autoCloseBrackets() /*-{ return this.auto_close_brackets || false }-*/;
+
   public final native boolean showBase() /*-{ return this.show_base || false }-*/;
+
   private native int get(String n, int d) /*-{ return this.hasOwnProperty(n) ? this[n] : d }-*/;
 
-  protected EditPreferences() {
-  }
+  protected EditPreferences() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EmailInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EmailInfo.java
index d0bbd8c..9c324be 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EmailInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/EmailInfo.java
@@ -18,9 +18,11 @@
 
 public class EmailInfo extends JavaScriptObject {
   public final native String email() /*-{ return this.email; }-*/;
-  public final native boolean isPreferred() /*-{ return this['preferred'] ? true : false; }-*/;
-  public final native boolean isConfirmationPending() /*-{ return this['pending_confirmation'] ? true : false; }-*/;
 
-  protected EmailInfo() {
-  }
+  public final native boolean isPreferred() /*-{ return this['preferred'] ? true : false; }-*/;
+
+  public final native boolean
+      isConfirmationPending() /*-{ return this['pending_confirmation'] ? true : false; }-*/;
+
+  protected EmailInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ExternalIdInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ExternalIdInfo.java
new file mode 100644
index 0000000..4ac0716
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ExternalIdInfo.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.account;
+
+import com.google.gerrit.client.auth.openid.OpenIdUtil;
+import com.google.gerrit.common.auth.openid.OpenIdUrls;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gwt.core.client.JavaScriptObject;
+
+public class ExternalIdInfo extends JavaScriptObject implements Comparable<ExternalIdInfo> {
+  /**
+   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
+   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   *
+   * <p>The name {@code gerrit:} was a very poor choice.
+   */
+  private static final String SCHEME_GERRIT = "gerrit:";
+
+  /** Scheme used to represent only an email address. */
+  private static final String SCHEME_MAILTO = "mailto:";
+
+  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
+  private static final String SCHEME_USERNAME = "username:";
+
+  public final native String identity() /*-{ return this.identity; }-*/;
+
+  public final native String emailAddress() /*-{ return this.email_address; }-*/;
+
+  public final native boolean isTrusted() /*-{ return this['trusted'] ? true : false; }-*/;
+
+  public final native boolean canDelete() /*-{ return this['can_delete'] ? true : false; }-*/;
+
+  public final boolean isUsername() {
+    return isScheme(SCHEME_USERNAME);
+  }
+
+  public final String describe() {
+    if (isScheme(SCHEME_GERRIT)) {
+      // A local user identity should just be itself.
+      return getSchemeRest();
+    } else if (isScheme(SCHEME_USERNAME)) {
+      // A local user identity should just be itself.
+      return getSchemeRest();
+    } else if (isScheme(SCHEME_MAILTO)) {
+      // Describe a mailto address as just its email address,
+      // which is already shown in the email address field.
+      return "";
+    } else if (isScheme(OpenIdUrls.URL_LAUNCHPAD)) {
+      return OpenIdUtil.C.nameLaunchpad();
+    } else if (isScheme(OpenIdUrls.URL_YAHOO)) {
+      return OpenIdUtil.C.nameYahoo();
+    }
+
+    return identity();
+  }
+
+  @Override
+  public final int compareTo(ExternalIdInfo a) {
+    return emailOf(this).compareTo(emailOf(a));
+  }
+
+  private boolean isScheme(String scheme) {
+    return identity() != null && identity().startsWith(scheme);
+  }
+
+  private String getSchemeRest() {
+    int colonIdx = identity().indexOf(':');
+    String scheme = (colonIdx > 0) ? identity().substring(0, colonIdx) : null;
+    return scheme != null ? identity().substring(scheme.length() + 1) : null;
+  }
+
+  private String emailOf(ExternalIdInfo a) {
+    return a.emailAddress() != null ? a.emailAddress() : "";
+  }
+
+  protected ExternalIdInfo() {}
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java
index 308cf30..4592b62 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyAgreementsScreen.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.client.account;
 
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.info.AgreementInfo;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
 import com.google.gerrit.client.ui.FancyFlexTable;
 import com.google.gerrit.client.ui.Hyperlink;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.AgreementInfo;
 import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
+import java.util.List;
 
 public class MyAgreementsScreen extends SettingsScreen {
   private AgreementTable agreements;
@@ -39,71 +42,56 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    Util.ACCOUNT_SVC.myAgreements(new ScreenLoadCallback<AgreementInfo>(this) {
-      @Override
-      public void preDisplay(final AgreementInfo result) {
-        agreements.display(result);
-      }
-    });
+    AccountApi.getAgreements(
+        "self",
+        new ScreenLoadCallback<JsArray<AgreementInfo>>(this) {
+          @Override
+          public void preDisplay(JsArray<AgreementInfo> result) {
+            agreements.display(Natives.asList(result));
+          }
+        });
   }
 
   private static class AgreementTable extends FancyFlexTable<ContributorAgreement> {
     AgreementTable() {
       table.setWidth("");
-      table.setText(0, 1, Util.C.agreementStatus());
-      table.setText(0, 2, Util.C.agreementName());
-      table.setText(0, 3, Util.C.agreementDescription());
+      table.setText(0, 1, Util.C.agreementName());
+      table.setText(0, 2, Util.C.agreementDescription());
 
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      for (int c = 1; c < 4; c++) {
+      FlexCellFormatter fmt = table.getFlexCellFormatter();
+      for (int c = 1; c < 3; c++) {
         fmt.addStyleName(0, c, Gerrit.RESOURCES.css().dataHeader());
       }
     }
 
-    void display(final AgreementInfo result) {
+    void display(List<AgreementInfo> result) {
       while (1 < table.getRowCount()) {
         table.removeRow(table.getRowCount() - 1);
       }
 
-      for (final String k : result.accepted) {
-        addOne(result, k);
+      for (AgreementInfo info : result) {
+        addOne(info);
       }
     }
 
-    void addOne(final AgreementInfo info, final String k) {
-      final int row = table.getRowCount();
+    void addOne(AgreementInfo info) {
+      int row = table.getRowCount();
       table.insertRow(row);
       applyDataRowStyle(row);
 
-      final ContributorAgreement cla = info.agreements.get(k);
-      final String statusName;
-      if (cla == null) {
-        statusName = Util.C.agreementStatus_EXPIRED();
+      String url = info.url();
+      if (url != null && url.length() > 0) {
+        Anchor a = new Anchor(info.name(), url);
+        a.setTarget("_blank");
+        table.setWidget(row, 1, a);
       } else {
-        statusName = Util.C.agreementStatus_VERIFIED();
+        table.setText(row, 1, info.name());
       }
-      table.setText(row, 1, statusName);
-
-      if (cla == null) {
-        table.setText(row, 2, "");
-        table.setText(row, 3, "");
-      } else {
-        final String url = cla.getAgreementUrl();
-        if (url != null && url.length() > 0) {
-          final Anchor a = new Anchor(cla.getName(), url);
-          a.setTarget("_blank");
-          table.setWidget(row, 2, a);
-        } else {
-          table.setText(row, 2, cla.getName());
-        }
-        table.setText(row, 3, cla.getDescription());
-      }
-      final FlexCellFormatter fmt = table.getFlexCellFormatter();
-      for (int c = 1; c < 4; c++) {
+      table.setText(row, 2, info.description());
+      FlexCellFormatter fmt = table.getFlexCellFormatter();
+      for (int c = 1; c < 3; c++) {
         fmt.addStyleName(row, c, Gerrit.RESOURCES.css().dataCell());
       }
-
-      setRowItem(row, cla);
     }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyContactInformationScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyContactInformationScreen.java
index 4c1016a..d5884f4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyContactInformationScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyContactInformationScreen.java
@@ -20,12 +20,13 @@
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    panel = new ContactPanelShort() {
-      @Override
-      void display() {
-        MyContactInformationScreen.this.display();
-      }
-    };
+    panel =
+        new ContactPanelShort() {
+          @Override
+          void display() {
+            MyContactInformationScreen.this.display();
+          }
+        };
     add(panel);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
index 99d791b..0dc1dab 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGpgKeysScreen.java
@@ -39,7 +39,6 @@
 import com.google.gwt.user.client.ui.VerticalPanel;
 import com.google.gwtexpui.clippy.client.CopyableLabel;
 import com.google.gwtexpui.globalkey.client.NpTextArea;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -47,9 +46,12 @@
 
 public class MyGpgKeysScreen extends SettingsScreen {
   interface Binder extends UiBinder<HTMLPanel, MyGpgKeysScreen> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
-  @UiField(provided = true) GpgKeyTable keys;
+  @UiField(provided = true)
+  GpgKeyTable keys;
+
   @UiField Button deleteKey;
   @UiField Button addKey;
 
@@ -105,39 +107,45 @@
   }
 
   private void refreshKeys() {
-    AccountApi.self().view("gpgkeys").get(NativeMap.copyKeysIntoChildren("id",
-        new GerritCallback<NativeMap<GpgKeyInfo>>() {
-          @Override
-          public void onSuccess(NativeMap<GpgKeyInfo> result) {
-            List<GpgKeyInfo> list = Natives.asList(result.values());
-            // TODO(dborowitz): Sort on something more meaningful, like
-            // created date?
-            Collections.sort(list, new Comparator<GpgKeyInfo>() {
-              @Override
-              public int compare(GpgKeyInfo a, GpgKeyInfo b) {
-                return a.id().compareTo(b.id());
-              }
-            });
-            keys.clear();
-            keyText.setText("");
-            errorPanel.setVisible(false);
-            addButton.setEnabled(true);
-            if (!list.isEmpty()) {
-              keys.setVisible(true);
-              for (GpgKeyInfo k : list) {
-                keys.addOneKey(k);
-              }
-              showKeyTable(true);
-              showAddKeyBlock(false);
-            } else {
-              keys.setVisible(false);
-              showAddKeyBlock(true);
-              showKeyTable(false);
-            }
+    AccountApi.self()
+        .view("gpgkeys")
+        .get(
+            NativeMap.copyKeysIntoChildren(
+                "id",
+                new GerritCallback<NativeMap<GpgKeyInfo>>() {
+                  @Override
+                  public void onSuccess(NativeMap<GpgKeyInfo> result) {
+                    List<GpgKeyInfo> list = Natives.asList(result.values());
+                    // TODO(dborowitz): Sort on something more meaningful, like
+                    // created date?
+                    Collections.sort(
+                        list,
+                        new Comparator<GpgKeyInfo>() {
+                          @Override
+                          public int compare(GpgKeyInfo a, GpgKeyInfo b) {
+                            return a.id().compareTo(b.id());
+                          }
+                        });
+                    keys.clear();
+                    keyText.setText("");
+                    errorPanel.setVisible(false);
+                    addButton.setEnabled(true);
+                    if (!list.isEmpty()) {
+                      keys.setVisible(true);
+                      for (GpgKeyInfo k : list) {
+                        keys.addOneKey(k);
+                      }
+                      showKeyTable(true);
+                      showAddKeyBlock(false);
+                    } else {
+                      keys.setVisible(false);
+                      showAddKeyBlock(true);
+                      showKeyTable(false);
+                    }
 
-            display();
-          }
-        }));
+                    display();
+                  }
+                }));
   }
 
   private void showAddKeyBlock(boolean show) {
@@ -157,7 +165,9 @@
     }
     addButton.setEnabled(false);
     keyText.setEnabled(false);
-    AccountApi.addGpgKey("self", keyText.getText(),
+    AccountApi.addGpgKey(
+        "self",
+        keyText.getText(),
         new AsyncCallback<NativeMap<GpgKeyInfo>>() {
           @Override
           public void onSuccess(NativeMap<GpgKeyInfo> result) {
@@ -178,8 +188,7 @@
                 errorText.setText(sce.getMessage());
               }
             } else {
-              errorText.setText(
-                  "Unexpected error saving key: " + caught.getMessage());
+              errorText.setText("Unexpected error saving key: " + caught.getMessage());
             }
             errorPanel.setVisible(true);
           }
@@ -201,12 +210,13 @@
       fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
       fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
 
-      updateDeleteHandler = new ValueChangeHandler<Boolean>() {
-        @Override
-        public void onValueChange(ValueChangeEvent<Boolean> event) {
-          updateDeleteButton();
-        }
-      };
+      updateDeleteHandler =
+          new ValueChangeHandler<Boolean>() {
+            @Override
+            public void onValueChange(ValueChangeEvent<Boolean> event) {
+              updateDeleteButton();
+            }
+          };
     }
 
     private void addOneKey(GpgKeyInfo k) {
@@ -253,7 +263,9 @@
           toDelete.add(getRowItem(row).fingerprint());
         }
       }
-      AccountApi.deleteGpgKeys("self", toDelete,
+      AccountApi.deleteGpgKeys(
+          "self",
+          toDelete,
           new GerritCallback<NativeMap<GpgKeyInfo>>() {
             @Override
             public void onSuccess(NativeMap<GpgKeyInfo> result) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java
index f6ac36a..e9112de 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyGroupsScreen.java
@@ -31,12 +31,13 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    GroupList.my(new ScreenLoadCallback<GroupList>(this) {
-      @Override
-      protected void preDisplay(GroupList result) {
-        groups.display(result);
-        groups.finishDisplay();
-      }
-    });
+    GroupList.my(
+        new ScreenLoadCallback<GroupList>(this) {
+          @Override
+          protected void preDisplay(GroupList result) {
+            groups.display(result);
+            groups.finishDisplay();
+          }
+        });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
index b638575..dfbd5c7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.client.account;
 
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.auth.openid.OpenIdUtil;
+import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.FancyFlexTable;
-import com.google.gerrit.common.auth.openid.OpenIdUrls;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.logical.shared.ValueChangeEvent;
@@ -30,12 +29,9 @@
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
 
 public class MyIdentitiesScreen extends SettingsScreen {
   private IdTable identites;
@@ -50,23 +46,24 @@
 
     deleteIdentity = new Button(Util.C.buttonDeleteIdentity());
     deleteIdentity.setEnabled(false);
-    deleteIdentity.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        identites.deleteChecked();
-      }
-    });
+    deleteIdentity.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            identites.deleteChecked();
+          }
+        });
     add(deleteIdentity);
 
-    if (Gerrit.info().auth().isOpenId()
-        || Gerrit.info().auth().isOAuth()) {
+    if (Gerrit.info().auth().isOpenId() || Gerrit.info().auth().isOAuth()) {
       Button linkIdentity = new Button(Util.C.buttonLinkIdentity());
-      linkIdentity.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(final ClickEvent event) {
-          Location.assign(Gerrit.loginRedirect(History.getToken()) + "?link");
-        }
-      });
+      linkIdentity.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(final ClickEvent event) {
+              Location.assign(Gerrit.loginRedirect(History.getToken()) + "?link");
+            }
+          });
       add(linkIdentity);
     }
   }
@@ -74,16 +71,17 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    Util.ACCOUNT_SEC
-        .myExternalIds(new ScreenLoadCallback<List<AccountExternalId>>(this) {
+    AccountApi.getExternalIds(
+        new GerritCallback<JsArray<ExternalIdInfo>>() {
           @Override
-          public void preDisplay(final List<AccountExternalId> result) {
-            identites.display(result);
+          public void onSuccess(JsArray<ExternalIdInfo> results) {
+            identites.display(results);
+            display();
           }
         });
   }
 
-  private class IdTable extends FancyFlexTable<AccountExternalId> {
+  private class IdTable extends FancyFlexTable<ExternalIdInfo> {
     private ValueChangeHandler<Boolean> updateDeleteHandler;
 
     IdTable() {
@@ -98,18 +96,19 @@
       fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
       fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
 
-      updateDeleteHandler = new ValueChangeHandler<Boolean>() {
-        @Override
-        public void onValueChange(ValueChangeEvent<Boolean> event) {
-          updateDeleteButton();
-        }
-      };
+      updateDeleteHandler =
+          new ValueChangeHandler<Boolean>() {
+            @Override
+            public void onValueChange(ValueChangeEvent<Boolean> event) {
+              updateDeleteButton();
+            }
+          };
     }
 
     void deleteChecked() {
-      final HashSet<AccountExternalId.Key> keys = new HashSet<>();
+      final HashSet<String> keys = new HashSet<>();
       for (int row = 1; row < table.getRowCount(); row++) {
-        final AccountExternalId k = getRowItem(row);
+        final ExternalIdInfo k = getRowItem(row);
         if (k == null) {
           continue;
         }
@@ -118,20 +117,21 @@
           continue;
         }
         if (cb.getValue()) {
-          keys.add(k.getKey());
+          keys.add(k.identity());
         }
       }
       if (keys.isEmpty()) {
         updateDeleteButton();
       } else {
         deleteIdentity.setEnabled(false);
-        Util.ACCOUNT_SEC.deleteExternalIds(keys,
-            new GerritCallback<Set<AccountExternalId.Key>>() {
+        AccountApi.deleteExternalIds(
+            keys,
+            new GerritCallback<VoidResult>() {
               @Override
-              public void onSuccess(final Set<AccountExternalId.Key> removed) {
-                for (int row = 1; row < table.getRowCount();) {
-                  final AccountExternalId k = getRowItem(row);
-                  if (k != null && removed.contains(k.getKey())) {
+              public void onSuccess(VoidResult result) {
+                for (int row = 1; row < table.getRowCount(); ) {
+                  final ExternalIdInfo k = getRowItem(row);
+                  if (k != null && keys.contains(k.identity())) {
                     table.removeRow(row);
                   } else {
                     row++;
@@ -167,30 +167,22 @@
       deleteIdentity.setEnabled(on);
     }
 
-    void display(final List<AccountExternalId> result) {
-      Collections.sort(result, new Comparator<AccountExternalId>() {
-        @Override
-        public int compare(AccountExternalId a, AccountExternalId b) {
-          return emailOf(a).compareTo(emailOf(b));
-        }
-
-        private String emailOf(final AccountExternalId a) {
-          return a.getEmailAddress() != null ? a.getEmailAddress() : "";
-        }
-      });
+    void display(final JsArray<ExternalIdInfo> results) {
+      List<ExternalIdInfo> idList = Natives.asList(results);
+      Collections.sort(idList);
 
       while (1 < table.getRowCount()) {
         table.removeRow(table.getRowCount() - 1);
       }
 
-      for (final AccountExternalId k : result) {
+      for (final ExternalIdInfo k : idList) {
         addOneId(k);
       }
       updateDeleteButton();
     }
 
-    void addOneId(final AccountExternalId k) {
-      if (k.isScheme(AccountExternalId.SCHEME_USERNAME)) {
+    void addOneId(final ExternalIdInfo k) {
+      if (k.isUsername()) {
         // Don't display the username as an identity here.
         return;
       }
@@ -211,15 +203,14 @@
         table.setText(row, 2, "");
       } else {
         table.setText(row, 2, Util.C.untrustedProvider());
-        fmt.addStyleName(row, 2, Gerrit.RESOURCES.css()
-            .identityUntrustedExternalId());
+        fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().identityUntrustedExternalId());
       }
-      if (k.getEmailAddress() != null && k.getEmailAddress().length() > 0) {
-        table.setText(row, 3, k.getEmailAddress());
+      if (k.emailAddress() != null && k.emailAddress().length() > 0) {
+        table.setText(row, 3, k.emailAddress());
       } else {
         table.setText(row, 3, "");
       }
-      table.setText(row, 4, describe(k));
+      table.setText(row, 4, k.describe());
 
       fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().iconCell());
       fmt.addStyleName(row, 2, Gerrit.RESOURCES.css().dataCell());
@@ -228,36 +219,5 @@
 
       setRowItem(row, k);
     }
-
-    private String describe(final AccountExternalId k) {
-      if (k.isScheme(AccountExternalId.SCHEME_GERRIT)) {
-        // A local user identity should just be itself.
-        //
-        return k.getSchemeRest();
-
-      } else if (k.isScheme(AccountExternalId.SCHEME_USERNAME)) {
-        // A local user identity should just be itself.
-        //
-        return k.getSchemeRest();
-
-      } else if (k.isScheme(AccountExternalId.SCHEME_MAILTO)) {
-        // Describe a mailto address as just its email address, which
-        // is already shown in the email address field.
-        //
-        return "";
-
-      } else if (k.isScheme("https://www.google.com/accounts/o8/id")) {
-        return OpenIdUtil.C.nameGoogle();
-
-      } else if (k.isScheme(OpenIdUrls.URL_LAUNCHPAD)) {
-        return OpenIdUtil.C.nameLaunchpad();
-
-      } else if (k.isScheme(OpenIdUrls.URL_YAHOO)) {
-        return OpenIdUtil.C.nameYahoo();
-
-      } else {
-        return k.getExternalId();
-      }
-    }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
index a4c92fe..5836763 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyOAuthTokenScreen.java
@@ -27,7 +27,6 @@
 import com.google.gwt.user.client.ui.Label;
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.clippy.client.CopyableLabel;
-
 import java.util.Date;
 
 public class MyOAuthTokenScreen extends SettingsScreen {
@@ -73,7 +72,7 @@
     Label netrcLabel = new Label(Util.C.labelOAuthNetRCEntry());
     netrcLabel.setStyleName(Gerrit.RESOURCES.css().oauthPanelNetRCHeading());
     flow.add(netrcLabel);
-    netrcValue= new CopyableLabel("");
+    netrcValue = new CopyableLabel("");
     netrcValue.setStyleName(Gerrit.RESOURCES.css().oauthPanelNetRCEntry());
     flow.add(netrcValue);
 
@@ -101,41 +100,46 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    AccountApi.self().view("preferences")
-    .get(new ScreenLoadCallback<GeneralPreferences>(this) {
-      @Override
-      protected void preDisplay(GeneralPreferences prefs) {
-        display(prefs);
-      }
-    });
+    AccountApi.self()
+        .view("preferences")
+        .get(
+            new ScreenLoadCallback<GeneralPreferences>(this) {
+              @Override
+              protected void preDisplay(GeneralPreferences prefs) {
+                display(prefs);
+              }
+            });
   }
 
   private void display(final GeneralPreferences prefs) {
-    AccountApi.self().view("oauthtoken")
-    .get(new GerritCallback<OAuthTokenInfo>() {
-      @Override
-      public void onSuccess(OAuthTokenInfo tokenInfo) {
-        tokenLabel.setText(tokenInfo.accessToken());
-        expiresLabel.setText(getExpiresAt(tokenInfo, prefs));
-        netrcValue.setText(getNetRC(tokenInfo));
-        cookieValue.setText(getCookie(tokenInfo));
-        flow.setVisible(true);
-        expiredNote.setVisible(false);
-      }
-      @Override
-      public void onFailure(Throwable caught) {
-        if (isNoSuchEntity(caught) || isSigninFailure(caught)) {
-          tokenLabel.setText("");
-          expiresLabel.setText("");
-          netrcValue.setText("");
-          cookieValue.setText("");
-          flow.setVisible(false);
-          expiredNote.setVisible(true);
-        } else {
-          showFailure(caught);
-        }
-      }
-    });
+    AccountApi.self()
+        .view("oauthtoken")
+        .get(
+            new GerritCallback<OAuthTokenInfo>() {
+              @Override
+              public void onSuccess(OAuthTokenInfo tokenInfo) {
+                tokenLabel.setText(tokenInfo.accessToken());
+                expiresLabel.setText(getExpiresAt(tokenInfo, prefs));
+                netrcValue.setText(getNetRC(tokenInfo));
+                cookieValue.setText(getCookie(tokenInfo));
+                flow.setVisible(true);
+                expiredNote.setVisible(false);
+              }
+
+              @Override
+              public void onFailure(Throwable caught) {
+                if (isNoSuchEntity(caught) || isSigninFailure(caught)) {
+                  tokenLabel.setText("");
+                  expiresLabel.setText("");
+                  netrcValue.setText("");
+                  cookieValue.setText("");
+                  flow.setVisible(false);
+                  expiredNote.setVisible(true);
+                } else {
+                  showFailure(caught);
+                }
+              }
+            });
   }
 
   private static long getExpiresAt(OAuthTokenInfo tokenInfo) {
@@ -155,16 +159,14 @@
     return getExpiresAt(tokenInfo) / 1000L;
   }
 
-  private static String getExpiresAt(OAuthTokenInfo tokenInfo,
-      GeneralPreferences prefs) {
+  private static String getExpiresAt(OAuthTokenInfo tokenInfo, GeneralPreferences prefs) {
     long expiresAt = getExpiresAt(tokenInfo);
     if (expiresAt == Long.MAX_VALUE) {
       return "";
     }
     String dateFormat = prefs.dateFormat().getLongFormat();
     String timeFormat = prefs.timeFormat().getFormat();
-    DateTimeFormat formatter = DateTimeFormat.getFormat(
-        dateFormat + " " + timeFormat);
+    DateTimeFormat formatter = DateTimeFormat.getFormat(dateFormat + " " + timeFormat);
     return formatter.format(new Date(expiresAt));
   }
 
@@ -193,5 +195,4 @@
     }
     return sb.toString();
   }
-
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
index 01772f8..e1d9ef0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
@@ -16,12 +16,10 @@
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.GerritUiExtensionPoint;
-import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.api.ExtensionPanel;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.client.rpc.ScreenLoadCallback;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.i18n.client.LocaleInfo;
@@ -36,7 +34,6 @@
 public class MyPasswordScreen extends SettingsScreen {
   private CopyableLabel password;
   private Button generatePassword;
-  private Button clearPassword;
 
   @Override
   protected void onInitUI() {
@@ -52,24 +49,17 @@
       return;
     }
 
-    password = new CopyableLabel("");
+    password = new CopyableLabel(Util.C.revokePassword());
     password.addStyleName(Gerrit.RESOURCES.css().accountPassword());
 
     generatePassword = new Button(Util.C.buttonGeneratePassword());
-    generatePassword.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        doGeneratePassword();
-      }
-    });
-
-    clearPassword = new Button(Util.C.buttonClearPassword());
-    clearPassword.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        doClearPassword();
-      }
-    });
+    generatePassword.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            doGeneratePassword();
+          }
+        });
 
     final Grid userInfo = new Grid(2, 2);
     final CellFormatter fmt = userInfo.getCellFormatter();
@@ -86,7 +76,6 @@
 
     final FlowPanel buttons = new FlowPanel();
     buttons.add(generatePassword);
-    buttons.add(clearPassword);
     add(buttons);
   }
 
@@ -104,43 +93,26 @@
     }
 
     enableUI(false);
-    AccountApi.getUsername("self", new GerritCallback<NativeString>() {
-      @Override
-      public void onSuccess(NativeString user) {
-        Gerrit.getUserAccount().username(user.asString());
-        refreshHttpPassword();
-      }
+    AccountApi.getUsername(
+        "self",
+        new GerritCallback<NativeString>() {
+          @Override
+          public void onSuccess(NativeString user) {
+            Gerrit.getUserAccount().username(user.asString());
+            enableUI(true);
+            display();
+          }
 
-      @Override
-      public void onFailure(final Throwable caught) {
-        if (RestApi.isNotFound(caught)) {
-          Gerrit.getUserAccount().username(null);
-          display();
-        } else {
-          super.onFailure(caught);
-        }
-      }
-    });
-  }
-
-  private void refreshHttpPassword() {
-    AccountApi.getHttpPassword("self", new ScreenLoadCallback<NativeString>(
-        this) {
-      @Override
-      protected void preDisplay(NativeString httpPassword) {
-        display(httpPassword.asString());
-      }
-
-      @Override
-      public void onFailure(final Throwable caught) {
-        if (RestApi.isNotFound(caught)) {
-          display(null);
-          display();
-        } else {
-          super.onFailure(caught);
-        }
-      }
-    });
+          @Override
+          public void onFailure(final Throwable caught) {
+            if (RestApi.isNotFound(caught)) {
+              Gerrit.getUserAccount().username(null);
+              display();
+            } else {
+              super.onFailure(caught);
+            }
+          }
+        });
   }
 
   private void display(String pass) {
@@ -149,8 +121,7 @@
     enableUI(true);
   }
 
-  private void row(final Grid info, final int row, final String name,
-      final Widget field) {
+  private void row(final Grid info, final int row, final String name, final Widget field) {
     final CellFormatter fmt = info.getCellFormatter();
     if (LocaleInfo.getCurrentLocale().isRTL()) {
       info.setText(row, 1, name);
@@ -166,7 +137,8 @@
   private void doGeneratePassword() {
     if (Gerrit.getUserAccount().username() != null) {
       enableUI(false);
-      AccountApi.generateHttpPassword("self",
+      AccountApi.generateHttpPassword(
+          "self",
           new GerritCallback<NativeString>() {
             @Override
             public void onSuccess(NativeString newPassword) {
@@ -181,28 +153,9 @@
     }
   }
 
-  private void doClearPassword() {
-    if (Gerrit.getUserAccount().username() != null) {
-      enableUI(false);
-      AccountApi.clearHttpPassword("self",
-          new GerritCallback<VoidResult>() {
-            @Override
-            public void onSuccess(VoidResult result) {
-              display(null);
-            }
-
-            @Override
-            public void onFailure(final Throwable caught) {
-              enableUI(true);
-            }
-          });
-    }
-  }
-
   private void enableUI(boolean on) {
     on &= Gerrit.getUserAccount().username() != null;
 
     generatePassword.setEnabled(on);
-    clearPassword.setVisible(on && !"".equals(password.getText()));
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
index 2b01b59..2edc137 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
@@ -41,7 +41,6 @@
 import com.google.gwt.user.client.ui.Grid;
 import com.google.gwt.user.client.ui.ListBox;
 import com.google.gwtexpui.user.client.UserAgent;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
@@ -50,6 +49,7 @@
 public class MyPreferencesScreen extends SettingsScreen {
   private CheckBox showSiteHeader;
   private CheckBox useFlashClipboard;
+  private CheckBox highlightAssigneeInChangeTable;
   private CheckBox relativeDateInChangeTable;
   private CheckBox sizeBarInChangeTable;
   private CheckBox legacycidInChangeTable;
@@ -61,6 +61,8 @@
   private ListBox reviewCategoryStrategy;
   private ListBox diffView;
   private ListBox emailStrategy;
+  private ListBox emailFormat;
+  private ListBox defaultBaseForMerges;
   private StringListPanel myMenus;
   private Button save;
 
@@ -93,18 +95,25 @@
         GeneralPreferencesInfo.ReviewCategoryStrategy.ABBREV.name());
 
     emailStrategy = new ListBox();
-    emailStrategy.addItem(Util.C.messageEnabled(),
-        GeneralPreferencesInfo.EmailStrategy.ENABLED.name());
-    emailStrategy
-        .addItem(
-            Util.C.messageCCMeOnMyComments(),
-            GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS
-                .name());
-    emailStrategy
-        .addItem(
-            Util.C.messageDisabled(),
-            GeneralPreferencesInfo.EmailStrategy.DISABLED
-                .name());
+    emailStrategy.addItem(
+        Util.C.messageCCMeOnMyComments(),
+        GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS.name());
+    emailStrategy.addItem(
+        Util.C.messageEnabled(), GeneralPreferencesInfo.EmailStrategy.ENABLED.name());
+    emailStrategy.addItem(
+        Util.C.messageDisabled(), GeneralPreferencesInfo.EmailStrategy.DISABLED.name());
+
+    emailFormat = new ListBox();
+    emailFormat.addItem(
+        Util.C.messagePlaintextOnly(), GeneralPreferencesInfo.EmailFormat.PLAINTEXT.name());
+    emailFormat.addItem(
+        Util.C.messageHtmlPlaintext(), GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT.name());
+
+    defaultBaseForMerges = new ListBox();
+    defaultBaseForMerges.addItem(
+        Util.C.autoMerge(), GeneralPreferencesInfo.DefaultBase.AUTO_MERGE.name());
+    defaultBaseForMerges.addItem(
+        Util.C.firstParent(), GeneralPreferencesInfo.DefaultBase.FIRST_PARENT.name());
 
     diffView = new ListBox();
     diffView.addItem(
@@ -116,8 +125,7 @@
 
     Date now = new Date();
     dateFormat = new ListBox();
-    for (GeneralPreferencesInfo.DateFormat fmt
-        : GeneralPreferencesInfo.DateFormat.values()) {
+    for (GeneralPreferencesInfo.DateFormat fmt : GeneralPreferencesInfo.DateFormat.values()) {
       StringBuilder r = new StringBuilder();
       r.append(DateTimeFormat.getFormat(fmt.getShortFormat()).format(now));
       r.append(" ; ");
@@ -126,8 +134,7 @@
     }
 
     timeFormat = new ListBox();
-    for (GeneralPreferencesInfo.TimeFormat fmt
-        : GeneralPreferencesInfo.TimeFormat.values()) {
+    for (GeneralPreferencesInfo.TimeFormat fmt : GeneralPreferencesInfo.TimeFormat.values()) {
       StringBuilder r = new StringBuilder();
       r.append(DateTimeFormat.getFormat(fmt.getFormat()).format(now));
       timeFormat.addItem(r.toString(), fmt.name());
@@ -148,7 +155,7 @@
       dateTimePanel.add(dateFormat);
       dateTimePanel.add(timeFormat);
     }
-
+    highlightAssigneeInChangeTable = new CheckBox(Util.C.highlightAssigneeInChangeTable());
     relativeDateInChangeTable = new CheckBox(Util.C.showRelativeDateInChangeTable());
     sizeBarInChangeTable = new CheckBox(Util.C.showSizeBarInChangeTable());
     legacycidInChangeTable = new CheckBox(Util.C.showLegacycidInChangeTable());
@@ -156,7 +163,7 @@
     signedOffBy = new CheckBox(Util.C.signedOffBy());
 
     boolean flashClippy = !UserAgent.hasJavaScriptClipboard() && UserAgent.Flash.isInstalled();
-    final Grid formGrid = new Grid(12 + (flashClippy ? 1 : 0), 2);
+    final Grid formGrid = new Grid(14 + (flashClippy ? 1 : 0), 2);
 
     int row = 0;
 
@@ -176,6 +183,14 @@
     formGrid.setWidget(row, fieldIdx, emailStrategy);
     row++;
 
+    formGrid.setText(row, labelIdx, Util.C.emailFormatFieldLabel());
+    formGrid.setWidget(row, fieldIdx, emailFormat);
+    row++;
+
+    formGrid.setText(row, labelIdx, Util.C.defaultBaseForMerges());
+    formGrid.setWidget(row, fieldIdx, defaultBaseForMerges);
+    row++;
+
     formGrid.setText(row, labelIdx, Util.C.diffViewLabel());
     formGrid.setWidget(row, fieldIdx, diffView);
     row++;
@@ -185,6 +200,10 @@
     row++;
 
     formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, highlightAssigneeInChangeTable);
+    row++;
+
+    formGrid.setText(row, labelIdx, "");
     formGrid.setWidget(row, fieldIdx, relativeDateInChangeTable);
     row++;
 
@@ -213,12 +232,13 @@
 
     save = new Button(Util.C.buttonSaveChanges());
     save.setEnabled(false);
-    save.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doSave();
-      }
-    });
+    save.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doSave();
+          }
+        });
 
     myMenus = new MyMenuPanel(save);
     add(myMenus);
@@ -231,6 +251,7 @@
     e.listenTo(maximumPageSize);
     e.listenTo(dateFormat);
     e.listenTo(timeFormat);
+    e.listenTo(highlightAssigneeInChangeTable);
     e.listenTo(relativeDateInChangeTable);
     e.listenTo(sizeBarInChangeTable);
     e.listenTo(legacycidInChangeTable);
@@ -239,6 +260,8 @@
     e.listenTo(diffView);
     e.listenTo(reviewCategoryStrategy);
     e.listenTo(emailStrategy);
+    e.listenTo(emailFormat);
+    e.listenTo(defaultBaseForMerges);
   }
 
   @Override
@@ -249,13 +272,15 @@
     extensionPanel.addStyleName(Gerrit.RESOURCES.css().extensionPanel());
     add(extensionPanel);
 
-    AccountApi.self().view("preferences")
-        .get(new ScreenLoadCallback<GeneralPreferences>(this) {
-      @Override
-      public void preDisplay(GeneralPreferences prefs) {
-        display(prefs);
-      }
-    });
+    AccountApi.self()
+        .view("preferences")
+        .get(
+            new ScreenLoadCallback<GeneralPreferences>(this) {
+              @Override
+              public void preDisplay(GeneralPreferences prefs) {
+                display(prefs);
+              }
+            });
   }
 
   private void enable(final boolean on) {
@@ -264,6 +289,7 @@
     maximumPageSize.setEnabled(on);
     dateFormat.setEnabled(on);
     timeFormat.setEnabled(on);
+    highlightAssigneeInChangeTable.setEnabled(on);
     relativeDateInChangeTable.setEnabled(on);
     sizeBarInChangeTable.setEnabled(on);
     legacycidInChangeTable.setEnabled(on);
@@ -272,30 +298,39 @@
     reviewCategoryStrategy.setEnabled(on);
     diffView.setEnabled(on);
     emailStrategy.setEnabled(on);
+    emailFormat.setEnabled(on);
+    defaultBaseForMerges.setEnabled(on);
   }
 
   private void display(GeneralPreferences p) {
     showSiteHeader.setValue(p.showSiteHeader());
     useFlashClipboard.setValue(p.useFlashClipboard());
     setListBox(maximumPageSize, DEFAULT_PAGESIZE, p.changesPerPage());
-    setListBox(dateFormat, GeneralPreferencesInfo.DateFormat.STD, //
+    setListBox(
+        dateFormat,
+        GeneralPreferencesInfo.DateFormat.STD, //
         p.dateFormat());
-    setListBox(timeFormat, GeneralPreferencesInfo.TimeFormat.HHMM_12, //
+    setListBox(
+        timeFormat,
+        GeneralPreferencesInfo.TimeFormat.HHMM_12, //
         p.timeFormat());
+    highlightAssigneeInChangeTable.setValue(p.highlightAssigneeInChangeTable());
     relativeDateInChangeTable.setValue(p.relativeDateInChangeTable());
     sizeBarInChangeTable.setValue(p.sizeBarInChangeTable());
     legacycidInChangeTable.setValue(p.legacycidInChangeTable());
     muteCommonPathPrefixes.setValue(p.muteCommonPathPrefixes());
     signedOffBy.setValue(p.signedOffBy());
-    setListBox(reviewCategoryStrategy,
+    setListBox(
+        reviewCategoryStrategy,
         GeneralPreferencesInfo.ReviewCategoryStrategy.NONE,
         p.reviewCategoryStrategy());
-    setListBox(diffView,
-        GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE,
-        p.diffView());
-    setListBox(emailStrategy,
-        GeneralPreferencesInfo.EmailStrategy.ENABLED,
-        p.emailStrategy());
+    setListBox(diffView, GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE, p.diffView());
+    setListBox(emailStrategy, GeneralPreferencesInfo.EmailStrategy.ENABLED, p.emailStrategy());
+    setListBox(emailFormat, GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT, p.emailFormat());
+    setListBox(
+        defaultBaseForMerges,
+        GeneralPreferencesInfo.DefaultBase.FIRST_PARENT,
+        p.defaultBaseForMerges());
     display(p.my());
   }
 
@@ -307,20 +342,19 @@
     myMenus.display(values);
   }
 
-  private void setListBox(final ListBox f, final int defaultValue,
-      final int currentValue) {
+  private void setListBox(final ListBox f, final int defaultValue, final int currentValue) {
     setListBox(f, String.valueOf(defaultValue), String.valueOf(currentValue));
   }
 
-  private <T extends Enum<?>> void setListBox(final ListBox f,
-      final T defaultValue, final T currentValue) {
-    setListBox(f,
+  private <T extends Enum<?>> void setListBox(
+      final ListBox f, final T defaultValue, final T currentValue) {
+    setListBox(
+        f,
         defaultValue != null ? defaultValue.name() : "",
         currentValue != null ? currentValue.name() : "");
   }
 
-  private void setListBox(final ListBox f, final String defaultValue,
-      final String currentValue) {
+  private void setListBox(final ListBox f, final String defaultValue, final String currentValue) {
     final int n = f.getItemCount();
     for (int i = 0; i < n; i++) {
       if (f.getValue(i).equals(currentValue)) {
@@ -341,8 +375,7 @@
     return defaultValue;
   }
 
-  private <T extends Enum<?>> T getListBox(final ListBox f,
-      final T defaultValue, T[] all) {
+  private <T extends Enum<?>> T getListBox(final ListBox f, final T defaultValue, T[] all) {
     final int idx = f.getSelectedIndex();
     if (0 <= idx) {
       String v = f.getValue(idx);
@@ -363,27 +396,48 @@
     p.showSiteHeader(showSiteHeader.getValue());
     p.useFlashClipboard(useFlashClipboard.getValue());
     p.changesPerPage(getListBox(maximumPageSize, DEFAULT_PAGESIZE));
-    p.dateFormat(getListBox(dateFormat,
-        GeneralPreferencesInfo.DateFormat.STD,
-        GeneralPreferencesInfo.DateFormat.values()));
-    p.timeFormat(getListBox(timeFormat,
-        GeneralPreferencesInfo.TimeFormat.HHMM_12,
-        GeneralPreferencesInfo.TimeFormat.values()));
+    p.dateFormat(
+        getListBox(
+            dateFormat,
+            GeneralPreferencesInfo.DateFormat.STD,
+            GeneralPreferencesInfo.DateFormat.values()));
+    p.timeFormat(
+        getListBox(
+            timeFormat,
+            GeneralPreferencesInfo.TimeFormat.HHMM_12,
+            GeneralPreferencesInfo.TimeFormat.values()));
+    p.highlightAssigneeInChangeTable(highlightAssigneeInChangeTable.getValue());
     p.relativeDateInChangeTable(relativeDateInChangeTable.getValue());
     p.sizeBarInChangeTable(sizeBarInChangeTable.getValue());
     p.legacycidInChangeTable(legacycidInChangeTable.getValue());
     p.muteCommonPathPrefixes(muteCommonPathPrefixes.getValue());
     p.signedOffBy(signedOffBy.getValue());
-    p.reviewCategoryStrategy(getListBox(reviewCategoryStrategy,
-        ReviewCategoryStrategy.NONE,
-        ReviewCategoryStrategy.values()));
-    p.diffView(getListBox(diffView,
-        GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE,
-        GeneralPreferencesInfo.DiffView.values()));
+    p.reviewCategoryStrategy(
+        getListBox(
+            reviewCategoryStrategy, ReviewCategoryStrategy.NONE, ReviewCategoryStrategy.values()));
+    p.diffView(
+        getListBox(
+            diffView,
+            GeneralPreferencesInfo.DiffView.SIDE_BY_SIDE,
+            GeneralPreferencesInfo.DiffView.values()));
 
-    p.emailStrategy(getListBox(emailStrategy,
-        GeneralPreferencesInfo.EmailStrategy.ENABLED,
-        GeneralPreferencesInfo.EmailStrategy.values()));
+    p.emailStrategy(
+        getListBox(
+            emailStrategy,
+            GeneralPreferencesInfo.EmailStrategy.ENABLED,
+            GeneralPreferencesInfo.EmailStrategy.values()));
+
+    p.emailFormat(
+        getListBox(
+            emailFormat,
+            GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT,
+            GeneralPreferencesInfo.EmailFormat.values()));
+
+    p.defaultBaseForMerges(
+        getListBox(
+            defaultBaseForMerges,
+            GeneralPreferencesInfo.DefaultBase.FIRST_PARENT,
+            GeneralPreferencesInfo.DefaultBase.values()));
 
     List<TopMenuItem> items = new ArrayList<>();
     for (List<String> v : myMenus.getValues()) {
@@ -394,45 +448,48 @@
     enable(false);
     save.setEnabled(false);
 
-    AccountApi.self().view("preferences")
-        .put(p, new GerritCallback<GeneralPreferences>() {
-          @Override
-          public void onSuccess(GeneralPreferences prefs) {
-            Gerrit.setUserPreferences(prefs);
-            enable(true);
-            display(prefs);
-          }
+    AccountApi.self()
+        .view("preferences")
+        .put(
+            p,
+            new GerritCallback<GeneralPreferences>() {
+              @Override
+              public void onSuccess(GeneralPreferences prefs) {
+                Gerrit.setUserPreferences(prefs);
+                enable(true);
+                display(prefs);
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-            enable(true);
-            save.setEnabled(true);
-            super.onFailure(caught);
-          }
-        });
+              @Override
+              public void onFailure(Throwable caught) {
+                enable(true);
+                save.setEnabled(true);
+                super.onFailure(caught);
+              }
+            });
   }
 
   private class MyMenuPanel extends StringListPanel {
     MyMenuPanel(Button save) {
-      super(Util.C.myMenu(), Arrays.asList(Util.C.myMenuName(),
-          Util.C.myMenuUrl()), save, false);
+      super(Util.C.myMenu(), Arrays.asList(Util.C.myMenuName(), Util.C.myMenuUrl()), save, false);
 
       setInfo(Util.C.myMenuInfo());
 
       Button resetButton = new Button(Util.C.myMenuReset());
-      resetButton.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          ConfigServerApi.defaultPreferences(
-              new GerritCallback<GeneralPreferences>() {
-                @Override
-                public void onSuccess(GeneralPreferences p) {
-                  MyPreferencesScreen.this.display(p.my());
-                  widget.setEnabled(true);
-                }
-              });
-        }
-      });
+      resetButton.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(ClickEvent event) {
+              ConfigServerApi.defaultPreferences(
+                  new GerritCallback<GeneralPreferences>() {
+                    @Override
+                    public void onSuccess(GeneralPreferences p) {
+                      MyPreferencesScreen.this.display(p.my());
+                      widget.setEnabled(true);
+                    }
+                  });
+            }
+          });
       buttonPanel.add(resetButton);
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java
index 0dfea4f..9d67663 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java
@@ -93,25 +93,26 @@
 
   private void infoRow(final int row, final String name) {
     info.setText(row, labelIdx, name);
-    info.getCellFormatter().addStyleName(row, 0,
-        Gerrit.RESOURCES.css().header());
+    info.getCellFormatter().addStyleName(row, 0, Gerrit.RESOURCES.css().header());
   }
 
   void display(AccountInfo account) {
     if (Gerrit.info().plugin().hasAvatars()) {
       avatar.setAccount(account, 93, false);
-      new RestApi("/accounts/").id("self").view("avatar.change.url")
-          .get(new AsyncCallback<NativeString>() {
-            @Override
-            public void onSuccess(NativeString changeUrl) {
-              changeAvatar.setHref(changeUrl.asString());
-              changeAvatar.setVisible(true);
-            }
+      new RestApi("/accounts/")
+          .id("self")
+          .view("avatar.change.url")
+          .get(
+              new AsyncCallback<NativeString>() {
+                @Override
+                public void onSuccess(NativeString changeUrl) {
+                  changeAvatar.setHref(changeUrl.asString());
+                  changeAvatar.setVisible(true);
+                }
 
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          });
+                @Override
+                public void onFailure(Throwable caught) {}
+              });
     }
 
     int row = 0;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MySshKeysScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MySshKeysScreen.java
index b1d6d82..6ba63aa 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MySshKeysScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MySshKeysScreen.java
@@ -20,12 +20,13 @@
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    panel = new SshPanel() {
-      @Override
-      void display() {
-        MySshKeysScreen.this.display();
-      }
-    };
+    panel =
+        new SshPanel() {
+          @Override
+          void display() {
+            MySshKeysScreen.this.display();
+          }
+        };
     add(panel);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java
index ce5cfd3..d3ac463 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchedProjectsScreen.java
@@ -76,28 +76,27 @@
     fp.add(addNew);
     add(fp);
 
-
     /* bottom table */
     add(watchesTab);
     add(delSel);
 
-
     /* popup */
-    projectsPopup = new ProjectListPopup() {
-      @Override
-      protected void onMovePointerTo(String projectName) {
-        // prevent user input from being overwritten by simply poping up
-        if (!projectsPopup.isPoppingUp() || "".equals(nameBox.getText())) {
-          nameBox.setText(projectName);
-        }
-      }
+    projectsPopup =
+        new ProjectListPopup() {
+          @Override
+          protected void onMovePointerTo(String projectName) {
+            // prevent user input from being overwritten by simply poping up
+            if (!projectsPopup.isPoppingUp() || "".equals(nameBox.getText())) {
+              nameBox.setText(projectName);
+            }
+          }
 
-      @Override
-      protected void openRow(String projectName) {
-        nameBox.setText(projectName);
-        doAddNew();
-      }
-    };
+          @Override
+          protected void openRow(String projectName) {
+            nameBox.setText(projectName);
+            doAddNew();
+          }
+        };
     projectsPopup.initPopup(Util.C.projects(), PageLinks.SETTINGS_PROJECTS);
   }
 
@@ -105,57 +104,64 @@
     nameBox = new RemoteSuggestBox(new ProjectNameSuggestOracle());
     nameBox.setVisibleLength(50);
     nameBox.setHintText(Util.C.defaultProjectName());
-    nameBox.addSelectionHandler(new SelectionHandler<String>() {
-      @Override
-      public void onSelection(SelectionEvent<String> event) {
-        doAddNew();
-      }
-    });
+    nameBox.addSelectionHandler(
+        new SelectionHandler<String>() {
+          @Override
+          public void onSelection(SelectionEvent<String> event) {
+            doAddNew();
+          }
+        });
 
     filterTxt = new HintTextBox();
     filterTxt.setVisibleLength(50);
     filterTxt.setHintText(Util.C.defaultFilter());
-    filterTxt.addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-          doAddNew();
-        }
-      }
-    });
+    filterTxt.addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+              doAddNew();
+            }
+          }
+        });
 
     addNew = new Button(Util.C.buttonWatchProject());
-    addNew.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doAddNew();
-      }
-    });
+    addNew.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doAddNew();
+          }
+        });
 
     browse = new Button(Util.C.buttonBrowseProjects());
-    browse.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        int top = grid.getAbsoluteTop() - 50; // under page header
-        // Try to place it to the right of everything else, but not
-        // right justified
-        int left =
-            5 + Math.max(grid.getAbsoluteLeft() + grid.getOffsetWidth(),
-                watchesTab.getAbsoluteLeft() + watchesTab.getOffsetWidth());
-        projectsPopup.setPreferredCoordinates(top, left);
-        projectsPopup.displayPopup();
-      }
-    });
+    browse.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            int top = grid.getAbsoluteTop() - 50; // under page header
+            // Try to place it to the right of everything else, but not
+            // right justified
+            int left =
+                5
+                    + Math.max(
+                        grid.getAbsoluteLeft() + grid.getOffsetWidth(),
+                        watchesTab.getAbsoluteLeft() + watchesTab.getOffsetWidth());
+            projectsPopup.setPreferredCoordinates(top, left);
+            projectsPopup.displayPopup();
+          }
+        });
 
     watchesTab = new MyWatchesTable();
 
     delSel = new Button(Util.C.buttonDeleteSshKey());
-    delSel.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        watchesTab.deleteChecked();
-      }
-    });
+    delSel.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            watchesTab.deleteChecked();
+          }
+        });
   }
 
   @Override
@@ -177,8 +183,7 @@
     }
 
     String filter = filterTxt.getText();
-    if (filter == null || filter.isEmpty()
-        || filter.equals(Util.C.defaultFilter())) {
+    if (filter == null || filter.isEmpty() || filter.equals(Util.C.defaultFilter())) {
       filter = null;
     }
 
@@ -186,12 +191,13 @@
     nameBox.setEnabled(false);
     filterTxt.setEnabled(false);
 
-    final ProjectWatchInfo projectWatchInfo = JavaScriptObject
-        .createObject().cast();
+    final ProjectWatchInfo projectWatchInfo = JavaScriptObject.createObject().cast();
     projectWatchInfo.project(projectName);
     projectWatchInfo.filter(filterTxt.getText());
 
-    AccountApi.updateWatchedProject("self", projectWatchInfo,
+    AccountApi.updateWatchedProject(
+        "self",
+        projectWatchInfo,
         new GerritCallback<JsArray<ProjectWatchInfo>>() {
           @Override
           public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
@@ -214,13 +220,14 @@
   }
 
   protected void populateWatches() {
-    AccountApi.getWatchedProjects("self",
+    AccountApi.getWatchedProjects(
+        "self",
         new GerritCallback<JsArray<ProjectWatchInfo>>() {
-      @Override
-      public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
-        display();
-        watchesTab.display(watchedProjects);
-      }
-    });
+          @Override
+          public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
+            watchesTab.display(watchedProjects);
+            display();
+          }
+        });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java
index 3647baf..5e45b68 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyWatchesTable.java
@@ -27,7 +27,6 @@
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.Label;
-
 import java.util.HashSet;
 import java.util.Set;
 
@@ -64,7 +63,9 @@
   public void deleteChecked() {
     final Set<ProjectWatchInfo> infos = getCheckedProjectWatchInfos();
     if (!infos.isEmpty()) {
-      AccountApi.deleteWatchedProjects("self", infos,
+      AccountApi.deleteWatchedProjects(
+          "self",
+          infos,
           new GerritCallback<JsArray<ProjectWatchInfo>>() {
             @Override
             public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
@@ -75,7 +76,7 @@
   }
 
   protected void remove(Set<ProjectWatchInfo> infos) {
-    for (int row = 1; row < table.getRowCount();) {
+    for (int row = 1; row < table.getRowCount(); ) {
       final ProjectWatchInfo k = getRowItem(row);
       if (k != null && infos.contains(k)) {
         table.removeRow(row);
@@ -126,8 +127,7 @@
 
   protected void populate(final int row, final ProjectWatchInfo info) {
     final FlowPanel fp = new FlowPanel();
-    fp.add(new ProjectLink(info.project(),
-        new Project.NameKey(info.project())));
+    fp.add(new ProjectLink(info.project(), new Project.NameKey(info.project())));
     if (info.filter() != null) {
       Label filter = new Label(info.filter());
       filter.setStyleName(Gerrit.RESOURCES.css().watchedProjectFilter());
@@ -155,34 +155,37 @@
     setRowItem(row, info);
   }
 
-  protected void addNotifyButton(final ProjectWatchInfo.Type type,
-      final ProjectWatchInfo info, final int row, final int col) {
+  protected void addNotifyButton(
+      final ProjectWatchInfo.Type type, final ProjectWatchInfo info, final int row, final int col) {
     final CheckBox cbox = new CheckBox();
 
-    cbox.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        final Boolean oldVal = info.notify(type);
-        info.notify(type, cbox.getValue());
-        cbox.setEnabled(false);
+    cbox.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            final Boolean oldVal = info.notify(type);
+            info.notify(type, cbox.getValue());
+            cbox.setEnabled(false);
 
-        AccountApi.updateWatchedProject("self", info,
-            new GerritCallback<JsArray<ProjectWatchInfo>>() {
-              @Override
-              public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
-                cbox.setEnabled(true);
-              }
+            AccountApi.updateWatchedProject(
+                "self",
+                info,
+                new GerritCallback<JsArray<ProjectWatchInfo>>() {
+                  @Override
+                  public void onSuccess(JsArray<ProjectWatchInfo> watchedProjects) {
+                    cbox.setEnabled(true);
+                  }
 
-              @Override
-              public void onFailure(Throwable caught) {
-                cbox.setEnabled(true);
-                info.notify(type, oldVal);
-                cbox.setValue(oldVal);
-                super.onFailure(caught);
-              }
-            });
-      }
-    });
+                  @Override
+                  public void onFailure(Throwable caught) {
+                    cbox.setEnabled(true);
+                    info.notify(type, oldVal);
+                    cbox.setValue(oldVal);
+                    super.onFailure(caught);
+                  }
+                });
+          }
+        });
 
     cbox.setValue(info.notify(type));
     table.setWidget(row, col, cbox);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
index 14f8e2f..afba2e2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
@@ -16,14 +16,16 @@
 
 import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.info.AgreementInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.NativeString;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountScreen;
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.client.ui.SmallHeading;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.AgreementInfo;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gwt.core.client.GWT;
+import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.http.client.Request;
@@ -41,8 +43,6 @@
 import com.google.gwt.user.client.ui.RadioButton;
 import com.google.gwt.user.client.ui.VerticalPanel;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
-import com.google.gwtjsonrpc.common.VoidResult;
-
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -50,8 +50,8 @@
 public class NewAgreementScreen extends AccountScreen {
   private final String nextToken;
   private Set<String> mySigned;
-  private List<ContributorAgreement> available;
-  private ContributorAgreement current;
+  private List<AgreementInfo> available;
+  private AgreementInfo current;
 
   private VerticalPanel radios;
 
@@ -73,25 +73,23 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    Util.ACCOUNT_SVC.myAgreements(new GerritCallback<AgreementInfo>() {
-      @Override
-      public void onSuccess(AgreementInfo result) {
-        if (isAttached()) {
-          mySigned = new HashSet<>(result.accepted);
-          postRPC();
-        }
-      }
-    });
-    Gerrit.SYSTEM_SVC
-        .contributorAgreements(new GerritCallback<List<ContributorAgreement>>() {
+    AccountApi.getAgreements(
+        "self",
+        new GerritCallback<JsArray<AgreementInfo>>() {
           @Override
-          public void onSuccess(final List<ContributorAgreement> result) {
+          public void onSuccess(JsArray<AgreementInfo> result) {
             if (isAttached()) {
-              available = result;
+              mySigned = new HashSet<>();
+              for (AgreementInfo info : Natives.asList(result)) {
+                mySigned.add(info.name());
+              }
               postRPC();
             }
           }
         });
+
+    available = Gerrit.info().auth().contributorAgreements();
+    postRPC();
   }
 
   @Override
@@ -104,8 +102,7 @@
     formBody.add(radios);
 
     agreementGroup = new FlowPanel();
-    agreementGroup
-        .add(new SmallHeading(Util.C.newAgreementReviewLegalHeading()));
+    agreementGroup.add(new SmallHeading(Util.C.newAgreementReviewLegalHeading()));
 
     agreementHtml = new HTML();
     agreementHtml.setStyleName(Gerrit.RESOURCES.css().contributorAgreementLegal());
@@ -122,12 +119,13 @@
     fp.add(new InlineLabel(Util.M.enterIAGREE(Util.C.newAgreementIAGREE())));
     finalGroup.add(fp);
     submit = new Button(Util.C.buttonSubmitNewAgreement());
-    submit.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doSign();
-      }
-    });
+    submit.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doSign();
+          }
+        });
     finalGroup.add(submit);
     formBody.add(finalGroup);
     new OnEditEnabler(submit, yesIAgreeBox);
@@ -158,27 +156,28 @@
     }
     radios.add(hdr);
 
-    for (final ContributorAgreement cla : available) {
-      final RadioButton r = new RadioButton("cla_id", cla.getName());
+    for (final AgreementInfo cla : available) {
+      final RadioButton r = new RadioButton("cla_id", cla.name());
       r.addStyleName(Gerrit.RESOURCES.css().contributorAgreementButton());
       radios.add(r);
 
-      if (mySigned.contains(cla.getName())) {
+      if (mySigned.contains(cla.name())) {
         r.setEnabled(false);
         final Label l = new Label(Util.C.newAgreementAlreadySubmitted());
         l.setStyleName(Gerrit.RESOURCES.css().contributorAgreementAlreadySubmitted());
         radios.add(l);
       } else {
-        r.addClickHandler(new ClickHandler() {
-          @Override
-          public void onClick(final ClickEvent event) {
-            showCLA(cla);
-          }
-        });
+        r.addClickHandler(
+            new ClickHandler() {
+              @Override
+              public void onClick(final ClickEvent event) {
+                showCLA(cla);
+              }
+            });
       }
 
-      if (cla.getDescription() != null && !cla.getDescription().equals("")) {
-        final Label l = new Label(cla.getDescription());
+      if (cla.description() != null && !cla.description().equals("")) {
+        final Label l = new Label(cla.description());
         l.setStyleName(Gerrit.RESOURCES.css().contributorAgreementShortDescription());
         radios.add(l);
       }
@@ -188,9 +187,7 @@
   private void doSign() {
     submit.setEnabled(false);
 
-    if (current == null
-        || !Util.C.newAgreementIAGREE()
-            .equalsIgnoreCase(yesIAgreeBox.getText())) {
+    if (current == null || !Util.C.newAgreementIAGREE().equalsIgnoreCase(yesIAgreeBox.getText())) {
       yesIAgreeBox.setText("");
       yesIAgreeBox.setFocus(true);
       return;
@@ -199,24 +196,26 @@
   }
 
   private void doEnterAgreement() {
-    Util.ACCOUNT_SEC.enterAgreement(current.getName(),
-        new GerritCallback<VoidResult>() {
+    AccountApi.enterAgreement(
+        "self",
+        current.name(),
+        new GerritCallback<NativeString>() {
           @Override
-          public void onSuccess(final VoidResult result) {
+          public void onSuccess(NativeString result) {
             Gerrit.display(nextToken);
           }
 
           @Override
-          public void onFailure(final Throwable caught) {
+          public void onFailure(Throwable caught) {
             yesIAgreeBox.setText("");
             super.onFailure(caught);
           }
         });
   }
 
-  private void showCLA(final ContributorAgreement cla) {
+  private void showCLA(AgreementInfo cla) {
     current = cla;
-    String url = cla.getAgreementUrl();
+    String url = cla.url();
     if (url != null && url.length() > 0) {
       agreementGroup.setVisible(true);
       agreementHtml.setText(Gerrit.C.rpcStatusWorking());
@@ -224,23 +223,25 @@
         url = GWT.getHostPageBaseURL() + url;
       }
       final RequestBuilder rb = new RequestBuilder(RequestBuilder.GET, url);
-      rb.setCallback(new RequestCallback() {
-        @Override
-        public void onError(Request request, Throwable exception) {
-          new ErrorDialog(exception).center();
-        }
+      rb.setCallback(
+          new RequestCallback() {
+            @Override
+            public void onError(Request request, Throwable exception) {
+              new ErrorDialog(exception).center();
+            }
 
-        @Override
-        public void onResponseReceived(Request request, Response response) {
-          final String ct = response.getHeader("Content-Type");
-          if (response.getStatusCode() == 200 && ct != null
-              && (ct.equals("text/html") || ct.startsWith("text/html;"))) {
-            agreementHtml.setHTML(response.getText());
-          } else {
-            new ErrorDialog(response.getStatusText()).center();
-          }
-        }
-      });
+            @Override
+            public void onResponseReceived(Request request, Response response) {
+              final String ct = response.getHeader("Content-Type");
+              if (response.getStatusCode() == 200
+                  && ct != null
+                  && (ct.equals("text/html") || ct.startsWith("text/html;"))) {
+                agreementHtml.setHTML(response.getText());
+              } else {
+                new ErrorDialog(response.getStatusText()).center();
+              }
+            }
+          });
       try {
         rb.send();
       } catch (RequestException e) {
@@ -250,7 +251,7 @@
       agreementGroup.setVisible(false);
     }
 
-    finalGroup.setVisible(cla.getAutoVerify() != null);
+    finalGroup.setVisible(cla.autoVerifyGroup() != null);
     yesIAgreeBox.setText("");
     submit.setEnabled(false);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java
index e43ec0c..fab25ae 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ProjectWatchInfo.java
@@ -26,9 +26,11 @@
   }
 
   public final native String project() /*-{ return this.project; }-*/;
+
   public final native String filter() /*-{ return this.filter; }-*/;
 
   public final native void project(String s) /*-{ this.project = s; }-*/;
+
   public final native void filter(String s) /*-{ this.filter = s; }-*/;
 
   public final void notify(ProjectWatchInfo.Type t, Boolean b) {
@@ -61,19 +63,35 @@
     return Boolean.valueOf(b);
   }
 
-  private native boolean notifyNewChanges() /*-{ return this['notify_new_changes'] ? true : false; }-*/;
-  private native boolean notifyNewPatchSets() /*-{ return this['notify_new_patch_sets'] ? true : false; }-*/;
-  private native boolean notifyAllComments() /*-{ return this['notify_all_comments'] ? true : false; }-*/;
-  private native boolean notifySubmittedChanges() /*-{ return this['notify_submitted_changes'] ? true : false; }-*/;
-  private native boolean notifyAbandonedChanges() /*-{ return this['notify_abandoned_changes'] ? true : false; }-*/;
+  private native boolean
+      notifyNewChanges() /*-{ return this['notify_new_changes'] ? true : false; }-*/;
 
-  private native void notifyNewChanges(boolean b) /*-{ this['notify_new_changes'] = b ? true : null; }-*/;
-  private native void notifyNewPatchSets(boolean b) /*-{ this['notify_new_patch_sets'] = b ? true : null; }-*/;
-  private native void notifyAllComments(boolean b) /*-{ this['notify_all_comments'] = b ? true : null; }-*/;
-  private native void notifySubmittedChanges(boolean b) /*-{ this['notify_submitted_changes'] = b ? true : null; }-*/;
-  private native void notifyAbandonedChanges(boolean b) /*-{ this['notify_abandoned_changes'] = b ? true : null; }-*/;
+  private native boolean
+      notifyNewPatchSets() /*-{ return this['notify_new_patch_sets'] ? true : false; }-*/;
 
-  protected ProjectWatchInfo() {
+  private native boolean
+      notifyAllComments() /*-{ return this['notify_all_comments'] ? true : false; }-*/;
 
-  }
+  private native boolean
+      notifySubmittedChanges() /*-{ return this['notify_submitted_changes'] ? true : false; }-*/;
+
+  private native boolean
+      notifyAbandonedChanges() /*-{ return this['notify_abandoned_changes'] ? true : false; }-*/;
+
+  private native void notifyNewChanges(
+      boolean b) /*-{ this['notify_new_changes'] = b ? true : null; }-*/;
+
+  private native void notifyNewPatchSets(
+      boolean b) /*-{ this['notify_new_patch_sets'] = b ? true : null; }-*/;
+
+  private native void notifyAllComments(
+      boolean b) /*-{ this['notify_all_comments'] = b ? true : null; }-*/;
+
+  private native void notifySubmittedChanges(
+      boolean b) /*-{ this['notify_submitted_changes'] = b ? true : null; }-*/;
+
+  private native void notifyAbandonedChanges(
+      boolean b) /*-{ this['notify_abandoned_changes'] = b ? true : null; }-*/;
+
+  protected ProjectWatchInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
index c32a846..d3d217c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.client.ui.SmallHeading;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gwt.i18n.client.LocaleInfo;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.FormPanel;
@@ -54,23 +54,24 @@
     final HTML whereFrom = new HTML(Util.C.welcomeContactFrom());
     whereFrom.setStyleName(Gerrit.RESOURCES.css().registerScreenExplain());
     contactGroup.add(whereFrom);
-    contactGroup.add(new ContactPanelShort() {
-      @Override
-      protected void display(AccountInfo account) {
-        super.display(account);
+    contactGroup.add(
+        new ContactPanelShort() {
+          @Override
+          protected void display(AccountInfo account) {
+            super.display(account);
 
-        if ("".equals(nameTxt.getText())) {
-          // No name? Encourage the user to provide us something.
-          //
-          nameTxt.setFocus(true);
-          save.setEnabled(true);
-        }
-      }
-    });
+            if ("".equals(nameTxt.getText())) {
+              // No name? Encourage the user to provide us something.
+              //
+              nameTxt.setFocus(true);
+              save.setEnabled(true);
+            }
+          }
+        });
     formBody.add(contactGroup);
 
     if (Gerrit.getUserAccount().username() == null
-        && Gerrit.info().auth().canEdit(FieldName.USER_NAME)) {
+        && Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME)) {
       final FlowPanel fp = new FlowPanel();
       fp.setStyleName(Gerrit.RESOURCES.css().registerScreenSection());
       fp.add(new SmallHeading(Util.C.welcomeUsernameHeading()));
@@ -106,11 +107,12 @@
       final HTML whySshKey = new HTML(Util.C.welcomeSshKeyText());
       whySshKey.setStyleName(Gerrit.RESOURCES.css().registerScreenExplain());
       sshKeyGroup.add(whySshKey);
-      sshKeyGroup.add(new SshPanel() {
-        {
-          setKeyTableVisible(false);
-        }
-      });
+      sshKeyGroup.add(
+          new SshPanel() {
+            {
+              setKeyTableVisible(false);
+            }
+          });
       formBody.add(sshKeyGroup);
     }
 
@@ -124,10 +126,8 @@
       whyAgreement.setStyleName(Gerrit.RESOURCES.css().registerScreenExplain());
       agreementGroup.add(whyAgreement);
 
-      choices.add(new InlineHyperlink(Util.C.newAgreement(),
-          PageLinks.SETTINGS_NEW_AGREEMENT));
-      choices
-          .add(new InlineHyperlink(Util.C.welcomeAgreementLater(), nextToken));
+      choices.add(new InlineHyperlink(Util.C.newAgreement(), PageLinks.SETTINGS_NEW_AGREEMENT));
+      choices.add(new InlineHyperlink(Util.C.welcomeAgreementLater(), nextToken));
       formBody.add(agreementGroup);
     } else {
       choices.add(new InlineHyperlink(Util.C.welcomeContinue(), nextToken));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
index ee7407e..a948595 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SettingsScreen.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.MenuScreen;
 import com.google.gerrit.common.PageLinks;
-
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -48,7 +48,7 @@
       linkByGerrit(Util.C.tabHttpAccess(), PageLinks.SETTINGS_HTTP_PASSWORD);
     }
     if (Gerrit.info().auth().isOAuth()
-        && Gerrit.info().auth().isGitBasicAuth()) {
+        && Gerrit.info().auth().gitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH) {
       linkByGerrit(Util.C.tabOAuthToken(), PageLinks.SETTINGS_OAUTH_TOKEN);
     }
     if (Gerrit.info().gerrit().editGpgKeys()) {
@@ -72,8 +72,7 @@
     for (String pluginName : ExtensionSettingsScreen.Definition.plugins()) {
       for (ExtensionSettingsScreen.Definition def :
           Natives.asList(ExtensionSettingsScreen.Definition.get(pluginName))) {
-        linkByPlugin(pluginName, def.getMenu(),
-            PageLinks.toSettings(pluginName, def.getPath()));
+        linkByPlugin(pluginName, def.getMenu(), PageLinks.toSettings(pluginName, def.getPath()));
       }
     }
   }
@@ -96,11 +95,9 @@
     setPageTitle(Util.C.settingsHeading());
   }
 
-  protected ExtensionPanel createExtensionPoint(
-      GerritUiExtensionPoint extensionPoint) {
+  protected ExtensionPanel createExtensionPoint(GerritUiExtensionPoint extensionPoint) {
     ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint);
-    extensionPanel.putObject(GerritUiExtensionPoint.Key.ACCOUNT_INFO,
-        Gerrit.getUserAccount());
+    extensionPanel.putObject(GerritUiExtensionPoint.Key.ACCOUNT_INFO, Gerrit.getUserAccount());
     return extensionPanel;
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshKeyInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshKeyInfo.java
index a4fed8b..23b3d2d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshKeyInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshKeyInfo.java
@@ -18,12 +18,16 @@
 
 public class SshKeyInfo extends JavaScriptObject {
   public final native int seq() /*-{ return this.seq || 0; }-*/;
+
   public final native String sshPublicKey() /*-{ return this.ssh_public_key; }-*/;
+
   public final native String encodedKey() /*-{ return this.encoded_key; }-*/;
+
   public final native String algorithm() /*-{ return this.algorithm; }-*/;
+
   public final native String comment() /*-{ return this.comment; }-*/;
+
   public final native boolean isValid() /*-{ return this['valid'] ? true : false; }-*/;
 
-  protected SshKeyInfo() {
-  }
+  protected SshKeyInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java
index 37ad764..0cf30de 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java
@@ -42,7 +42,6 @@
 import com.google.gwtexpui.clippy.client.CopyableLabel;
 import com.google.gwtexpui.globalkey.client.NpTextArea;
 import com.google.gwtjsonrpc.client.RemoteJsonException;
-
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -66,12 +65,13 @@
     final FlowPanel body = new FlowPanel();
 
     showAddKeyBlock = new Button(Util.C.buttonShowAddSshKey());
-    showAddKeyBlock.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        showAddKeyBlock(true);
-      }
-    });
+    showAddKeyBlock.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            showAddKeyBlock(true);
+          }
+        });
 
     keys = new SshKeyTable();
     body.add(keys);
@@ -79,12 +79,13 @@
       final FlowPanel fp = new FlowPanel();
       deleteKey = new Button(Util.C.buttonDeleteSshKey());
       deleteKey.setEnabled(false);
-      deleteKey.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(final ClickEvent event) {
-          keys.deleteChecked();
-        }
-      });
+      deleteKey.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(final ClickEvent event) {
+              keys.deleteChecked();
+            }
+          });
       fp.add(deleteKey);
       fp.add(showAddKeyBlock);
       body.add(fp);
@@ -110,35 +111,37 @@
     addKeyBlock.add(buttons);
 
     clearNew = new Button(Util.C.buttonClearSshKeyInput());
-    clearNew.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        addTxt.setText("");
-        addTxt.setFocus(true);
-      }
-    });
+    clearNew.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            addTxt.setText("");
+            addTxt.setFocus(true);
+          }
+        });
     buttons.add(clearNew);
 
     addNew = new Button(Util.C.buttonAddSshKey());
-    addNew.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doAddNew();
-      }
-    });
+    addNew.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doAddNew();
+          }
+        });
     buttons.add(addNew);
 
     closeAddKeyBlock = new Button(Util.C.buttonCloseAddSshKey());
-    closeAddKeyBlock.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        showAddKeyBlock(false);
-      }
-    });
+    closeAddKeyBlock.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            showAddKeyBlock(false);
+          }
+        });
     buttons.add(closeAddKeyBlock);
     buttons.setCellWidth(closeAddKeyBlock, "100%");
-    buttons.setCellHorizontalAlignment(closeAddKeyBlock,
-        HasHorizontalAlignment.ALIGN_RIGHT);
+    buttons.setCellHorizontalAlignment(closeAddKeyBlock, HasHorizontalAlignment.ALIGN_RIGHT);
 
     body.add(addKeyBlock);
 
@@ -158,39 +161,42 @@
     final String txt = addTxt.getText();
     if (txt != null && txt.length() > 0) {
       addNew.setEnabled(false);
-      AccountApi.addSshKey("self", txt, new GerritCallback<SshKeyInfo>() {
-        @Override
-        public void onSuccess(final SshKeyInfo k) {
-          addNew.setEnabled(true);
-          addTxt.setText("");
-          keys.addOneKey(k);
-          if (!keys.isVisible()) {
-            showAddKeyBlock(false);
-            setKeyTableVisible(true);
-            keys.updateDeleteButton();
-          }
-        }
+      AccountApi.addSshKey(
+          "self",
+          txt,
+          new GerritCallback<SshKeyInfo>() {
+            @Override
+            public void onSuccess(final SshKeyInfo k) {
+              addNew.setEnabled(true);
+              addTxt.setText("");
+              keys.addOneKey(k);
+              if (!keys.isVisible()) {
+                showAddKeyBlock(false);
+                setKeyTableVisible(true);
+                keys.updateDeleteButton();
+              }
+            }
 
-        @Override
-        public void onFailure(final Throwable caught) {
-          addNew.setEnabled(true);
+            @Override
+            public void onFailure(final Throwable caught) {
+              addNew.setEnabled(true);
 
-          if (isInvalidSshKey(caught)) {
-            new ErrorDialog(Util.C.invalidSshKeyError()).center();
+              if (isInvalidSshKey(caught)) {
+                new ErrorDialog(Util.C.invalidSshKeyError()).center();
 
-          } else {
-            super.onFailure(caught);
-          }
-        }
+              } else {
+                super.onFailure(caught);
+              }
+            }
 
-        private boolean isInvalidSshKey(final Throwable caught) {
-          if (caught instanceof InvalidSshKeyException) {
-            return true;
-          }
-          return caught instanceof RemoteJsonException
-              && InvalidSshKeyException.MESSAGE.equals(caught.getMessage());
-        }
-      });
+            private boolean isInvalidSshKey(final Throwable caught) {
+              if (caught instanceof InvalidSshKeyException) {
+                return true;
+              }
+              return caught instanceof RemoteJsonException
+                  && InvalidSshKeyException.MESSAGE.equals(caught.getMessage());
+            }
+          });
     }
   }
 
@@ -198,37 +204,39 @@
   protected void onLoad() {
     super.onLoad();
     refreshSshKeys();
-    Gerrit.SYSTEM_SVC.daemonHostKeys(new GerritCallback<List<SshHostKey>>() {
-      @Override
-      public void onSuccess(final List<SshHostKey> result) {
-        serverKeys.clear();
-        for (final SshHostKey keyInfo : result) {
-          serverKeys.add(new SshHostKeyPanel(keyInfo));
-        }
-        if (++loadCount == 2) {
-          display();
-        }
-      }
-    });
+    Gerrit.SYSTEM_SVC.daemonHostKeys(
+        new GerritCallback<List<SshHostKey>>() {
+          @Override
+          public void onSuccess(final List<SshHostKey> result) {
+            serverKeys.clear();
+            for (final SshHostKey keyInfo : result) {
+              serverKeys.add(new SshHostKeyPanel(keyInfo));
+            }
+            if (++loadCount == 2) {
+              display();
+            }
+          }
+        });
   }
 
   private void refreshSshKeys() {
-    AccountApi.getSshKeys("self", new GerritCallback<JsArray<SshKeyInfo>>() {
-      @Override
-      public void onSuccess(JsArray<SshKeyInfo> result) {
-        keys.display(Natives.asList(result));
-        if (result.length() == 0 && keys.isVisible()) {
-          showAddKeyBlock(true);
-        }
-        if (++loadCount == 2) {
-          display();
-        }
-      }
-    });
+    AccountApi.getSshKeys(
+        "self",
+        new GerritCallback<JsArray<SshKeyInfo>>() {
+          @Override
+          public void onSuccess(JsArray<SshKeyInfo> result) {
+            keys.display(Natives.asList(result));
+            if (result.length() == 0 && keys.isVisible()) {
+              showAddKeyBlock(true);
+            }
+            if (++loadCount == 2) {
+              display();
+            }
+          }
+        });
   }
 
-  void display() {
-  }
+  void display() {}
 
   private void showAddKeyBlock(final boolean show) {
     showAddKeyBlock.setVisible(!show);
@@ -252,12 +260,13 @@
       fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
       fmt.addStyleName(0, 5, Gerrit.RESOURCES.css().dataHeader());
 
-      updateDeleteHandler = new ValueChangeHandler<Boolean>() {
-        @Override
-        public void onValueChange(ValueChangeEvent<Boolean> event) {
-          updateDeleteButton();
-        }
-      };
+      updateDeleteHandler =
+          new ValueChangeHandler<Boolean>() {
+            @Override
+            public void onValueChange(ValueChangeEvent<Boolean> event) {
+              updateDeleteButton();
+            }
+          };
     }
 
     void deleteChecked() {
@@ -272,11 +281,13 @@
         updateDeleteButton();
       } else {
         deleteKey.setEnabled(false);
-        AccountApi.deleteSshKeys("self", sequenceNumbers,
+        AccountApi.deleteSshKeys(
+            "self",
+            sequenceNumbers,
             new GerritCallback<VoidResult>() {
               @Override
               public void onSuccess(VoidResult result) {
-                for (int row = 1; row < table.getRowCount();) {
+                for (int row = 1; row < table.getRowCount(); ) {
                   final SshKeyInfo k = getRowItem(row);
                   if (k != null && sequenceNumbers.contains(k.seq())) {
                     table.removeRow(row);
@@ -285,7 +296,7 @@
                   }
                 }
                 if (table.getRowCount() == 1) {
-                  display(Collections.<SshKeyInfo> emptyList());
+                  display(Collections.<SshKeyInfo>emptyList());
                 } else {
                   updateDeleteButton();
                 }
@@ -329,7 +340,9 @@
       table.setWidget(row, 1, sel);
       if (k.isValid()) {
         table.setText(row, 2, "");
-        fmt.removeStyleName(row, 2, //
+        fmt.removeStyleName(
+            row,
+            2, //
             Gerrit.RESOURCES.css().sshKeyPanelInvalid());
       } else {
         table.setText(row, 2, Util.C.sshKeyInvalid());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
index f388436..e201a8f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.OnEditEnabler;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
@@ -37,6 +37,12 @@
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
 
 class UsernameField extends Composite {
+  // If these regular expressions are modified the same modifications should be done to the
+  // corresponding regular expressions in the
+  // com.google.gerrit.server.account.ExternalId class.
+  private static final String USER_NAME_PATTERN_FIRST_REGEX = "[a-zA-Z0-9]";
+  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9.!#$%&’*+=?^_`\\{|\\}~@-]";
+
   private CopyableLabel userNameLbl;
   private NpTextBox userNameTxt;
   private Button setUserName;
@@ -58,24 +64,26 @@
       userNameTxt.addKeyPressHandler(new UserNameValidator());
       userNameTxt.addStyleName(Gerrit.RESOURCES.css().accountUsername());
       userNameTxt.setVisibleLength(16);
-      userNameTxt.addKeyPressHandler(new KeyPressHandler() {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-            confirmSetUserName();
-          }
-        }
-      });
+      userNameTxt.addKeyPressHandler(
+          new KeyPressHandler() {
+            @Override
+            public void onKeyPress(KeyPressEvent event) {
+              if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+                confirmSetUserName();
+              }
+            }
+          });
 
       setUserName = new Button(Util.C.buttonSetUserName());
       setUserName.setVisible(canEditUserName());
       setUserName.setEnabled(false);
-      setUserName.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(final ClickEvent event) {
-          confirmSetUserName();
-        }
-      });
+      setUserName.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(final ClickEvent event) {
+              confirmSetUserName();
+            }
+          });
       new OnEditEnabler(setUserName, userNameTxt);
 
       userNameLbl.setVisible(false);
@@ -86,19 +94,20 @@
   }
 
   private boolean canEditUserName() {
-    return Gerrit.info().auth().canEdit(Account.FieldName.USER_NAME);
+    return Gerrit.info().auth().canEdit(AccountFieldName.USER_NAME);
   }
 
   private void confirmSetUserName() {
     new ConfirmationDialog(
-        Util.C.confirmSetUserNameTitle(),
-        new SafeHtmlBuilder().append(Util.C.confirmSetUserName()),
-        new ConfirmationCallback() {
-          @Override
-          public void onOk() {
-            doSetUserName();
-          }
-        }).center();
+            Util.C.confirmSetUserNameTitle(),
+            new SafeHtmlBuilder().append(Util.C.confirmSetUserName()),
+            new ConfirmationCallback() {
+              @Override
+              public void onOk() {
+                doSetUserName();
+              }
+            })
+        .center();
   }
 
   private void doSetUserName() {
@@ -114,27 +123,29 @@
     }
     final String newUserName = newName;
 
-    AccountApi.setUsername("self", newUserName,
+    AccountApi.setUsername(
+        "self",
+        newUserName,
         new GerritCallback<NativeString>() {
-      @Override
-      public void onSuccess(NativeString result) {
-        Gerrit.getUserAccount().username(newUserName);
-        userNameLbl.setText(newUserName);
-        userNameLbl.setVisible(true);
-        userNameTxt.setVisible(false);
-        setUserName.setVisible(false);
-      }
+          @Override
+          public void onSuccess(NativeString result) {
+            Gerrit.getUserAccount().username(newUserName);
+            userNameLbl.setText(newUserName);
+            userNameLbl.setVisible(true);
+            userNameTxt.setVisible(false);
+            setUserName.setVisible(false);
+          }
 
-      @Override
-      public void onFailure(Throwable caught) {
-        enableUI(true);
-        if (RestApi.isExpected(422 /* Unprocessable Entity */)) {
-          new ErrorDialog(Util.C.invalidUserName()).center();
-        } else {
-          super.onFailure(caught);
-        }
-      }
-    });
+          @Override
+          public void onFailure(Throwable caught) {
+            enableUI(true);
+            if (RestApi.isExpected(422 /* Unprocessable Entity */)) {
+              new ErrorDialog(Util.C.invalidUserName()).center();
+            } else {
+              super.onFailure(caught);
+            }
+          }
+        });
   }
 
   private void enableUI(final boolean on) {
@@ -179,9 +190,9 @@
           final TextBox box = (TextBox) event.getSource();
           final String re;
           if (box.getCursorPos() == 0) {
-            re = Account.USER_NAME_PATTERN_FIRST;
+            re = USER_NAME_PATTERN_FIRST_REGEX;
           } else {
-            re = Account.USER_NAME_PATTERN_REST;
+            re = USER_NAME_PATTERN_REST_REGEX;
           }
           if (!String.valueOf(code).matches("^" + re + "$")) {
             event.preventDefault();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java
index a0f36b9..1c4870c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/Util.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.client.account;
 
-import com.google.gerrit.common.data.AccountSecurity;
-import com.google.gerrit.common.data.AccountService;
 import com.google.gerrit.common.data.ProjectAdminService;
 import com.google.gwt.core.client.GWT;
 import com.google.gwtjsonrpc.client.JsonUtil;
@@ -23,17 +21,9 @@
 public class Util {
   public static final AccountConstants C = GWT.create(AccountConstants.class);
   public static final AccountMessages M = GWT.create(AccountMessages.class);
-  public static final AccountService ACCOUNT_SVC;
-  public static final AccountSecurity ACCOUNT_SEC;
   public static final ProjectAdminService PROJECT_SVC;
 
   static {
-    ACCOUNT_SVC = GWT.create(AccountService.class);
-    JsonUtil.bind(ACCOUNT_SVC, "rpc/AccountService");
-
-    ACCOUNT_SEC = GWT.create(AccountSecurity.class);
-    JsonUtil.bind(ACCOUNT_SEC, "rpc/AccountSecurity");
-
     PROJECT_SVC = GWT.create(ProjectAdminService.class);
     JsonUtil.bind(PROJECT_SVC, "rpc/ProjectAdminService");
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java
index b79723b..990798c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java
@@ -37,11 +37,11 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    ConfigServerApi.confirmEmail(magicToken,
+    ConfigServerApi.confirmEmail(
+        magicToken,
         new ScreenLoadCallback<VoidResult>(this) {
           @Override
-          protected void preDisplay(final VoidResult result) {
-          }
+          protected void preDisplay(final VoidResult result) {}
 
           @Override
           protected void postDisplay() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java
index 39849c4..85937db 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/actions/ActionButton.java
@@ -43,8 +43,7 @@
     this(project, null, null, null, null, action);
   }
 
-  public ActionButton(Project.NameKey project, BranchInfo branch,
-      ActionInfo action) {
+  public ActionButton(Project.NameKey project, BranchInfo branch, ActionInfo action) {
     this(project, branch, null, null, null, action);
   }
 
@@ -52,18 +51,18 @@
     this(null, null, change, null, null, action);
   }
 
-  public ActionButton(ChangeInfo change, RevisionInfo revision,
-      ActionInfo action) {
+  public ActionButton(ChangeInfo change, RevisionInfo revision, ActionInfo action) {
     this(null, null, change, null, revision, action);
   }
 
-  private ActionButton(Project.NameKey project, BranchInfo branch,
-      ChangeInfo change, EditInfo edit, RevisionInfo revision,
+  private ActionButton(
+      Project.NameKey project,
+      BranchInfo branch,
+      ChangeInfo change,
+      EditInfo edit,
+      RevisionInfo revision,
       ActionInfo action) {
-    super(new SafeHtmlBuilder()
-      .openDiv()
-      .append(action.label())
-      .closeDiv());
+    super(new SafeHtmlBuilder().openDiv().append(action.label()).closeDiv());
     setStyleName("");
     setTitle(action.title());
     setEnabled(action.enabled());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
index af0b1f5..37813af 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
@@ -44,46 +44,36 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.ValueListBox;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
-public class AccessSectionEditor extends Composite implements
-    Editor<AccessSection>, ValueAwareEditor<AccessSection> {
-  interface Binder extends UiBinder<HTMLPanel, AccessSectionEditor> {
-  }
+public class AccessSectionEditor extends Composite
+    implements Editor<AccessSection>, ValueAwareEditor<AccessSection> {
+  interface Binder extends UiBinder<HTMLPanel, AccessSectionEditor> {}
 
   private static final Binder uiBinder = GWT.create(Binder.class);
 
-  @UiField
-  ValueEditor<String> name;
+  @UiField ValueEditor<String> name;
 
-  @UiField
-  FlowPanel permissionContainer;
+  @UiField FlowPanel permissionContainer;
   ListEditor<Permission, PermissionEditor> permissions;
 
-  @UiField
-  DivElement addContainer;
+  @UiField DivElement addContainer;
+
   @UiField(provided = true)
   @Editor.Ignore
   ValueListBox<String> permissionSelector;
 
-  @UiField
-  SpanElement deletedName;
+  @UiField SpanElement deletedName;
 
-  @UiField
-  Anchor deleteSection;
+  @UiField Anchor deleteSection;
 
-  @UiField
-  DivElement normal;
-  @UiField
-  DivElement deleted;
+  @UiField DivElement normal;
+  @UiField DivElement deleted;
 
-  @UiField
-  SpanElement sectionType;
-  @UiField
-  SpanElement sectionName;
+  @UiField SpanElement sectionType;
+  @UiField SpanElement sectionName;
 
   private final ProjectAccess projectAccess;
   private AccessSection value;
@@ -93,16 +83,16 @@
 
   public AccessSectionEditor(ProjectAccess access) {
     projectAccess = access;
-    permissionSelector = new ValueListBox<>(
-        new PermissionNameRenderer(access.getCapabilities()));
-    permissionSelector.addValueChangeHandler(new ValueChangeHandler<String>() {
-      @Override
-      public void onValueChange(ValueChangeEvent<String> event) {
-        if (!Util.C.addPermission().equals(event.getValue())) {
-          onAddPermission(event.getValue());
-        }
-      }
-    });
+    permissionSelector = new ValueListBox<>(new PermissionNameRenderer(access.getCapabilities()));
+    permissionSelector.addValueChangeHandler(
+        new ValueChangeHandler<String>() {
+          @Override
+          public void onValueChange(ValueChangeEvent<String> event) {
+            if (!AdminConstants.I.addPermission().equals(event.getValue())) {
+              onAddPermission(event.getValue());
+            }
+          }
+        });
 
     initWidget(uiBinder.createAndBindUi(this));
     permissions = ListEditor.of(new PermissionEditorSource());
@@ -122,16 +112,14 @@
   void onDeleteSection(@SuppressWarnings("unused") ClickEvent event) {
     isDeleted = true;
 
-    if (name.isVisible()
-        && RefConfigSection.isValid(name.getValue())) {
-      deletedName.setInnerText(Util.M.deletedReference(name.getValue()));
-
+    if (name.isVisible() && RefConfigSection.isValid(name.getValue())) {
+      deletedName.setInnerText(AdminMessages.I.deletedReference(name.getValue()));
     } else {
-      String name = Util.C.sectionNames().get(value.getName());
+      String name = AdminConstants.I.sectionNames().get(value.getName());
       if (name == null) {
         name = value.getName();
       }
-      deletedName.setInnerText(Util.M.deletedSection(name));
+      deletedName.setInnerText(AdminMessages.I.deletedSection(name));
     }
 
     normal.getStyle().setDisplay(Display.NONE);
@@ -159,12 +147,14 @@
 
   void editRefPattern() {
     name.edit();
-    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-      @Override
-      public void execute() {
-        name.setFocus(true);
-      }
-    });
+    Scheduler.get()
+        .scheduleDeferred(
+            new ScheduledCommand() {
+              @Override
+              public void execute() {
+                name.setFocus(true);
+              }
+            });
   }
 
   void enableEditing() {
@@ -190,18 +180,18 @@
     if (RefConfigSection.isValid(value.getName())) {
       name.setVisible(true);
       name.setIgnoreEditorValue(false);
-      sectionType.setInnerText(Util.C.sectionTypeReference());
+      sectionType.setInnerText(AdminConstants.I.sectionTypeReference());
 
     } else {
       name.setVisible(false);
       name.setIgnoreEditorValue(true);
 
-      String name = Util.C.sectionNames().get(value.getName());
+      String name = AdminConstants.I.sectionNames().get(value.getName());
       if (name != null) {
         sectionType.setInnerText(name);
         sectionName.getStyle().setDisplay(Display.NONE);
       } else {
-        sectionType.setInnerText(Util.C.sectionTypeSection());
+        sectionType.setInnerText(AdminConstants.I.sectionTypeSection());
         sectionName.setInnerText(value.getName());
         sectionName.getStyle().clearDisplay();
       }
@@ -232,7 +222,7 @@
       for (LabelType t : projectAccess.getLabelTypes().getLabelTypes()) {
         addPermission(Permission.forLabelAs(t.getName()), perms);
       }
-      for (String varName : Util.C.permissionNames().keySet()) {
+      for (String varName : AdminConstants.I.permissionNames().keySet()) {
         addPermission(varName, perms);
       }
     }
@@ -240,14 +230,13 @@
       addContainer.getStyle().setDisplay(Display.NONE);
     } else {
       addContainer.getStyle().setDisplay(Display.BLOCK);
-      perms.add(0, Util.C.addPermission());
-      permissionSelector.setValue(Util.C.addPermission());
+      perms.add(0, AdminConstants.I.addPermission());
+      permissionSelector.setValue(AdminConstants.I.addPermission());
       permissionSelector.setAcceptableValues(perms);
     }
   }
 
-  private void addPermission(final String permissionName,
-      final List<String> permissionList) {
+  private void addPermission(final String permissionName, final List<String> permissionList) {
     if (value.getPermission(permissionName) != null) {
       return;
     }
@@ -273,19 +262,16 @@
   }
 
   @Override
-  public void onPropertyChange(String... paths) {
-  }
+  public void onPropertyChange(String... paths) {}
 
   @Override
-  public void setDelegate(EditorDelegate<AccessSection> delegate) {
-  }
+  public void setDelegate(EditorDelegate<AccessSection> delegate) {}
 
   private class PermissionEditorSource extends EditorSource<PermissionEditor> {
     @Override
     public PermissionEditor create(int index) {
       PermissionEditor subEditor =
-          new PermissionEditor(projectAccess, readOnly, value,
-              projectAccess.getLabelTypes());
+          new PermissionEditor(projectAccess, readOnly, value, projectAccess.getLabelTypes());
       permissionContainer.insert(subEditor, index);
       return subEditor;
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java
index 254d3e6..5e38a14 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupAuditLogScreen.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.groups.GroupApi;
 import com.google.gerrit.client.groups.GroupAuditEventInfo;
-import com.google.gerrit.client.groups.GroupInfo;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.FancyFlexTable;
@@ -32,7 +32,6 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-
 import java.util.List;
 
 public class AccountGroupAuditLogScreen extends AccountGroupScreen {
@@ -45,14 +44,15 @@
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    add(new SmallHeading(Util.C.headingAuditLog()));
+    add(new SmallHeading(AdminConstants.I.headingAuditLog()));
     auditEventTable = new AuditEventTable();
     add(auditEventTable);
   }
 
   @Override
   protected void display(GroupInfo group, boolean canModify) {
-    GroupApi.getAuditLog(group.getGroupUUID(),
+    GroupApi.getAuditLog(
+        group.getGroupUUID(),
         new GerritCallback<JsArray<GroupAuditEventInfo>>() {
           @Override
           public void onSuccess(JsArray<GroupAuditEventInfo> result) {
@@ -61,12 +61,12 @@
         });
   }
 
-  private class AuditEventTable extends FancyFlexTable<GroupAuditEventInfo> {
+  private static class AuditEventTable extends FancyFlexTable<GroupAuditEventInfo> {
     AuditEventTable() {
-      table.setText(0, 1, Util.C.columnDate());
-      table.setText(0, 2, Util.C.columnType());
-      table.setText(0, 3, Util.C.columnMember());
-      table.setText(0, 4, Util.C.columnByUser());
+      table.setText(0, 1, AdminConstants.I.columnDate());
+      table.setText(0, 2, AdminConstants.I.columnType());
+      table.setText(0, 3, AdminConstants.I.columnMember());
+      table.setText(0, 4, AdminConstants.I.columnByUser());
 
       FlexCellFormatter fmt = table.getFlexCellFormatter();
       fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
@@ -95,11 +95,11 @@
       switch (auditEvent.type()) {
         case ADD_USER:
         case ADD_GROUP:
-          table.setText(row, 2, Util.C.typeAdded());
+          table.setText(row, 2, AdminConstants.I.typeAdded());
           break;
         case REMOVE_USER:
         case REMOVE_GROUP:
-          table.setText(row, 2, Util.C.typeRemoved());
+          table.setText(row, 2, AdminConstants.I.typeRemoved());
           break;
       }
 
@@ -112,21 +112,21 @@
         case REMOVE_GROUP:
           GroupInfo member = auditEvent.memberAsGroup();
           if (AccountGroup.isInternalGroup(member.getGroupUUID())) {
-            table.setWidget(row, 3,
-                new Hyperlink(member.name(),
-                    Dispatcher.toGroup(member.getGroupUUID())));
+            table.setWidget(
+                row,
+                3,
+                new Hyperlink(formatGroup(member), Dispatcher.toGroup(member.getGroupUUID())));
             fmt.getElement(row, 3).setTitle(null);
           } else if (member.url() != null) {
             Anchor a = new Anchor();
-            a.setText(member.name());
+            a.setText(formatGroup(member));
             a.setHref(member.url());
             a.setTitle("UUID " + member.getGroupUUID().get());
             table.setWidget(row, 3, a);
             fmt.getElement(row, 3).setTitle(null);
           } else {
-            table.setText(row, 3, member.name());
-            fmt.getElement(row, 3).setTitle(
-                "UUID " + member.getGroupUUID().get());
+            table.setText(row, 3, formatGroup(member));
+            fmt.getElement(row, 3).setTitle("UUID " + member.getGroupUUID().get());
           }
           break;
       }
@@ -150,4 +150,10 @@
     b.append(")");
     return b.toString();
   }
+
+  private static String formatGroup(GroupInfo group) {
+    return group.name() != null && !group.name().isEmpty()
+        ? group.name()
+        : group.getGroupUUID().get();
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
index a71dffe..4d1ad22 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupInfoScreen.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
 import com.google.gerrit.client.ui.OnEditEnabler;
@@ -72,7 +72,7 @@
   private void initUUID() {
     final VerticalPanel groupUUIDPanel = new VerticalPanel();
     groupUUIDPanel.setStyleName(Gerrit.RESOURCES.css().groupUUIDPanel());
-    groupUUIDPanel.add(new SmallHeading(Util.C.headingGroupUUID()));
+    groupUUIDPanel.add(new SmallHeading(AdminConstants.I.headingGroupUUID()));
     groupUUIDLabel = new CopyableLabel("");
     groupUUIDPanel.add(groupUUIDLabel);
     add(groupUUIDPanel);
@@ -86,26 +86,29 @@
     groupNameTxt.setVisibleLength(60);
     groupNamePanel.add(groupNameTxt);
 
-    saveName = new Button(Util.C.buttonRenameGroup());
+    saveName = new Button(AdminConstants.I.buttonRenameGroup());
     saveName.setEnabled(false);
-    saveName.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        final String newName = groupNameTxt.getText().trim();
-        GroupApi.renameGroup(getGroupUUID(), newName,
-            new GerritCallback<com.google.gerrit.client.VoidResult>() {
-              @Override
-              public void onSuccess(final com.google.gerrit.client.VoidResult result) {
-                saveName.setEnabled(false);
-                setPageTitle(Util.M.group(newName));
-                groupNameTxt.setText(newName);
-                if (getGroupUUID().equals(getOwnerGroupUUID())) {
-                  ownerTxt.setText(newName);
-                }
-              }
-            });
-      }
-    });
+    saveName.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            final String newName = groupNameTxt.getText().trim();
+            GroupApi.renameGroup(
+                getGroupUUID(),
+                newName,
+                new GerritCallback<com.google.gerrit.client.VoidResult>() {
+                  @Override
+                  public void onSuccess(final com.google.gerrit.client.VoidResult result) {
+                    saveName.setEnabled(false);
+                    setPageTitle(AdminMessages.I.group(newName));
+                    groupNameTxt.setText(newName);
+                    if (getGroupUUID().equals(getOwnerGroupUUID())) {
+                      ownerTxt.setText(newName);
+                    }
+                  }
+                });
+          }
+        });
     groupNamePanel.add(saveName);
     add(groupNamePanel);
   }
@@ -113,7 +116,7 @@
   private void initOwner() {
     final VerticalPanel ownerPanel = new VerticalPanel();
     ownerPanel.setStyleName(Gerrit.RESOURCES.css().groupOwnerPanel());
-    ownerPanel.add(new SmallHeading(Util.C.headingOwner()));
+    ownerPanel.add(new SmallHeading(AdminConstants.I.headingOwner()));
 
     final AccountGroupSuggestOracle accountGroupOracle = new AccountGroupSuggestOracle();
     ownerTxt = new RemoteSuggestBox(accountGroupOracle);
@@ -121,26 +124,29 @@
     ownerTxt.setVisibleLength(60);
     ownerPanel.add(ownerTxt);
 
-    saveOwner = new Button(Util.C.buttonChangeGroupOwner());
+    saveOwner = new Button(AdminConstants.I.buttonChangeGroupOwner());
     saveOwner.setEnabled(false);
-    saveOwner.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        final String newOwner = ownerTxt.getText().trim();
-        if (newOwner.length() > 0) {
-          AccountGroup.UUID ownerUuid = accountGroupOracle.getUUID(newOwner);
-          String ownerId = ownerUuid != null ? ownerUuid.get() : newOwner;
-          GroupApi.setGroupOwner(getGroupUUID(), ownerId,
-              new GerritCallback<GroupInfo>() {
-                @Override
-                public void onSuccess(final GroupInfo result) {
-                  updateOwnerGroup(result);
-                  saveOwner.setEnabled(false);
-                }
-              });
-        }
-      }
-    });
+    saveOwner.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            final String newOwner = ownerTxt.getText().trim();
+            if (newOwner.length() > 0) {
+              AccountGroup.UUID ownerUuid = accountGroupOracle.getUUID(newOwner);
+              String ownerId = ownerUuid != null ? ownerUuid.get() : newOwner;
+              GroupApi.setGroupOwner(
+                  getGroupUUID(),
+                  ownerId,
+                  new GerritCallback<GroupInfo>() {
+                    @Override
+                    public void onSuccess(final GroupInfo result) {
+                      updateOwnerGroup(result);
+                      saveOwner.setEnabled(false);
+                    }
+                  });
+            }
+          }
+        });
     ownerPanel.add(saveOwner);
     add(ownerPanel);
   }
@@ -148,28 +154,31 @@
   private void initDescription() {
     final VerticalPanel vp = new VerticalPanel();
     vp.setStyleName(Gerrit.RESOURCES.css().groupDescriptionPanel());
-    vp.add(new SmallHeading(Util.C.headingDescription()));
+    vp.add(new SmallHeading(AdminConstants.I.headingDescription()));
 
     descTxt = new NpTextArea();
     descTxt.setVisibleLines(6);
     descTxt.setCharacterWidth(60);
     vp.add(descTxt);
 
-    saveDesc = new Button(Util.C.buttonSaveDescription());
+    saveDesc = new Button(AdminConstants.I.buttonSaveDescription());
     saveDesc.setEnabled(false);
-    saveDesc.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        final String txt = descTxt.getText().trim();
-        GroupApi.setGroupDescription(getGroupUUID(), txt,
-            new GerritCallback<VoidResult>() {
-              @Override
-              public void onSuccess(final VoidResult result) {
-                saveDesc.setEnabled(false);
-              }
-            });
-      }
-    });
+    saveDesc.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            final String txt = descTxt.getText().trim();
+            GroupApi.setGroupDescription(
+                getGroupUUID(),
+                txt,
+                new GerritCallback<VoidResult>() {
+                  @Override
+                  public void onSuccess(final VoidResult result) {
+                    saveDesc.setEnabled(false);
+                  }
+                });
+          }
+        });
     vp.add(saveDesc);
     add(vp);
   }
@@ -179,26 +188,29 @@
 
     final VerticalPanel vp = new VerticalPanel();
     vp.setStyleName(Gerrit.RESOURCES.css().groupOptionsPanel());
-    vp.add(new SmallHeading(Util.C.headingGroupOptions()));
+    vp.add(new SmallHeading(AdminConstants.I.headingGroupOptions()));
 
-    visibleToAllCheckBox = new CheckBox(Util.C.isVisibleToAll());
+    visibleToAllCheckBox = new CheckBox(AdminConstants.I.isVisibleToAll());
     vp.add(visibleToAllCheckBox);
     groupOptionsPanel.add(vp);
 
-    saveGroupOptions = new Button(Util.C.buttonSaveGroupOptions());
+    saveGroupOptions = new Button(AdminConstants.I.buttonSaveGroupOptions());
     saveGroupOptions.setEnabled(false);
-    saveGroupOptions.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        GroupApi.setGroupOptions(getGroupUUID(),
-            visibleToAllCheckBox.getValue(), new GerritCallback<VoidResult>() {
-              @Override
-              public void onSuccess(final VoidResult result) {
-                saveGroupOptions.setEnabled(false);
-              }
-            });
-      }
-    });
+    saveGroupOptions.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            GroupApi.setGroupOptions(
+                getGroupUUID(),
+                visibleToAllCheckBox.getValue(),
+                new GerritCallback<VoidResult>() {
+                  @Override
+                  public void onSuccess(final VoidResult result) {
+                    saveGroupOptions.setEnabled(false);
+                  }
+                });
+          }
+        });
     groupOptionsPanel.add(saveGroupOptions);
 
     add(groupOptionsPanel);
@@ -211,9 +223,10 @@
   protected void display(final GroupInfo group, final boolean canModify) {
     groupUUIDLabel.setText(group.getGroupUUID().get());
     groupNameTxt.setText(group.name());
-    ownerTxt.setText(group.owner() != null
-        ? group.owner()
-        : Util.M.deletedReference(group.getOwnerUUID().get()));
+    ownerTxt.setText(
+        group.owner() != null
+            ? group.owner()
+            : AdminMessages.I.deletedReference(group.getOwnerUUID().get()));
     descTxt.setText(group.description());
     visibleToAllCheckBox.setValue(group.options().isVisibleToAll());
     setMembersTabVisible(AccountGroup.isInternalGroup(group.getGroupUUID()));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
index 7c0c8f6..51b4979 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountGroupSuggestOracle;
@@ -39,7 +39,6 @@
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.Panel;
-
 import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
@@ -79,33 +78,35 @@
     includes.setEnabled(canModify);
   }
 
-
   private void initMemberList() {
-    addMemberBox = new AddMemberBox(
-        Util.C.buttonAddGroupMember(),
-        Util.C.defaultAccountName(),
-        new AccountSuggestOracle());
+    addMemberBox =
+        new AddMemberBox(
+            AdminConstants.I.buttonAddGroupMember(),
+            AdminConstants.I.defaultAccountName(),
+            new AccountSuggestOracle());
 
-    addMemberBox.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doAddNewMember();
-      }
-    });
+    addMemberBox.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doAddNewMember();
+          }
+        });
 
     members = new MemberTable();
     members.addStyleName(Gerrit.RESOURCES.css().groupMembersTable());
 
-    delMember = new Button(Util.C.buttonDeleteGroupMembers());
-    delMember.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        members.deleteChecked();
-      }
-    });
+    delMember = new Button(AdminConstants.I.buttonDeleteGroupMembers());
+    delMember.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            members.deleteChecked();
+          }
+        });
 
     memberPanel = new FlowPanel();
-    memberPanel.add(new SmallHeading(Util.C.headingMembers()));
+    memberPanel.add(new SmallHeading(AdminConstants.I.headingMembers()));
     memberPanel.add(addMemberBox);
     memberPanel.add(members);
     memberPanel.add(delMember);
@@ -115,29 +116,33 @@
   private void initIncludeList() {
     accountGroupSuggestOracle = new AccountGroupSuggestOracle();
     addIncludeBox =
-        new AddMemberBox(Util.C.buttonAddIncludedGroup(),
-            Util.C.defaultAccountGroupName(), accountGroupSuggestOracle);
+        new AddMemberBox(
+            AdminConstants.I.buttonAddIncludedGroup(),
+            AdminConstants.I.defaultAccountGroupName(),
+            accountGroupSuggestOracle);
 
-    addIncludeBox.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doAddNewInclude();
-      }
-    });
+    addIncludeBox.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doAddNewInclude();
+          }
+        });
 
     includes = new IncludeTable();
     includes.addStyleName(Gerrit.RESOURCES.css().groupIncludesTable());
 
-    delInclude = new Button(Util.C.buttonDeleteIncludedGroup());
-    delInclude.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        includes.deleteChecked();
-      }
-    });
+    delInclude = new Button(AdminConstants.I.buttonDeleteIncludedGroup());
+    delInclude.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            includes.deleteChecked();
+          }
+        });
 
     includePanel = new FlowPanel();
-    includePanel.add(new SmallHeading(Util.C.headingIncludedGroups()));
+    includePanel.add(new SmallHeading(AdminConstants.I.headingIncludedGroups()));
     includePanel.add(addIncludeBox);
     includePanel.add(includes);
     includePanel.add(delInclude);
@@ -147,7 +152,7 @@
   private void initNoMembersInfo() {
     noMembersInfo = new FlowPanel();
     noMembersInfo.setVisible(false);
-    noMembersInfo.add(new SmallHeading(Util.C.noMembersInfo()));
+    noMembersInfo.add(new SmallHeading(AdminConstants.I.noMembersInfo()));
     add(noMembersInfo);
   }
 
@@ -174,7 +179,9 @@
     }
 
     addMemberBox.setEnabled(false);
-    GroupApi.addMember(getGroupUUID(), nameEmail,
+    GroupApi.addMember(
+        getGroupUUID(),
+        nameEmail,
         new GerritCallback<AccountInfo>() {
           @Override
           public void onSuccess(final AccountInfo memberInfo) {
@@ -203,7 +210,9 @@
     }
 
     addIncludeBox.setEnabled(false);
-    GroupApi.addIncludedGroup(getGroupUUID(), uuid.get(),
+    GroupApi.addIncludedGroup(
+        getGroupUUID(),
+        uuid.get(),
         new GerritCallback<GroupInfo>() {
           @Override
           public void onSuccess(final GroupInfo result) {
@@ -224,8 +233,8 @@
     private boolean enabled = true;
 
     MemberTable() {
-      table.setText(0, 2, Util.C.columnMember());
-      table.setText(0, 3, Util.C.columnEmailAddress());
+      table.setText(0, 2, AdminConstants.I.columnMember());
+      table.setText(0, 3, AdminConstants.I.columnEmailAddress());
 
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
       fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
@@ -252,11 +261,13 @@
         }
       }
       if (!ids.isEmpty()) {
-        GroupApi.removeMembers(getGroupUUID(), ids,
+        GroupApi.removeMembers(
+            getGroupUUID(),
+            ids,
             new GerritCallback<VoidResult>() {
               @Override
               public void onSuccess(final VoidResult result) {
-                for (int row = 1; row < table.getRowCount();) {
+                for (int row = 1; row < table.getRowCount(); ) {
                   final AccountInfo i = getRowItem(row);
                   if (i != null && ids.contains(i._accountId())) {
                     table.removeRow(row);
@@ -283,26 +294,27 @@
     }
 
     void insert(AccountInfo info) {
-      Comparator<AccountInfo> c = new Comparator<AccountInfo>() {
-        @Override
-        public int compare(AccountInfo a, AccountInfo b) {
-          int cmp = nullToEmpty(a.name()).compareTo(nullToEmpty(b.name()));
-          if (cmp != 0) {
-            return cmp;
-          }
+      Comparator<AccountInfo> c =
+          new Comparator<AccountInfo>() {
+            @Override
+            public int compare(AccountInfo a, AccountInfo b) {
+              int cmp = nullToEmpty(a.name()).compareTo(nullToEmpty(b.name()));
+              if (cmp != 0) {
+                return cmp;
+              }
 
-          cmp = nullToEmpty(a.email()).compareTo(nullToEmpty(b.email()));
-          if (cmp != 0) {
-            return cmp;
-          }
+              cmp = nullToEmpty(a.email()).compareTo(nullToEmpty(b.email()));
+              if (cmp != 0) {
+                return cmp;
+              }
 
-          return a._accountId() - b._accountId();
-        }
+              return a._accountId() - b._accountId();
+            }
 
-        public String nullToEmpty(String str) {
-          return str == null ? "" : str;
-        }
-      };
+            public String nullToEmpty(String str) {
+              return str == null ? "" : str;
+            }
+          };
       int insertPos = getInsertRow(c, info);
       if (insertPos >= 0) {
         table.insertRow(insertPos);
@@ -315,7 +327,7 @@
       CheckBox checkBox = new CheckBox();
       table.setWidget(row, 1, checkBox);
       checkBox.setEnabled(enabled);
-      table.setWidget(row, 2, new AccountLinkPanel(i));
+      table.setWidget(row, 2, AccountLinkPanel.create(i));
       table.setText(row, 3, i.email());
 
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
@@ -331,8 +343,8 @@
     private boolean enabled = true;
 
     IncludeTable() {
-      table.setText(0, 2, Util.C.columnGroupName());
-      table.setText(0, 3, Util.C.columnGroupDescription());
+      table.setText(0, 2, AdminConstants.I.columnGroupName());
+      table.setText(0, 3, AdminConstants.I.columnGroupDescription());
 
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
       fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
@@ -359,11 +371,13 @@
         }
       }
       if (!ids.isEmpty()) {
-        GroupApi.removeIncludedGroups(getGroupUUID(), ids,
+        GroupApi.removeIncludedGroups(
+            getGroupUUID(),
+            ids,
             new GerritCallback<VoidResult>() {
               @Override
               public void onSuccess(final VoidResult result) {
-                for (int row = 1; row < table.getRowCount();) {
+                for (int row = 1; row < table.getRowCount(); ) {
                   final GroupInfo i = getRowItem(row);
                   if (i != null && ids.contains(i.getGroupUUID())) {
                     table.removeRow(row);
@@ -390,20 +404,21 @@
     }
 
     void insert(GroupInfo info) {
-      Comparator<GroupInfo> c = new Comparator<GroupInfo>() {
-        @Override
-        public int compare(GroupInfo a, GroupInfo b) {
-          int cmp = nullToEmpty(a.name()).compareTo(nullToEmpty(b.name()));
-          if (cmp != 0) {
-            return cmp;
-          }
-          return a.getGroupUUID().compareTo(b.getGroupUUID());
-        }
+      Comparator<GroupInfo> c =
+          new Comparator<GroupInfo>() {
+            @Override
+            public int compare(GroupInfo a, GroupInfo b) {
+              int cmp = nullToEmpty(a.name()).compareTo(nullToEmpty(b.name()));
+              if (cmp != 0) {
+                return cmp;
+              }
+              return a.getGroupUUID().compareTo(b.getGroupUUID());
+            }
 
-        private String nullToEmpty(@Nullable String str) {
-          return (str == null) ? "" : str;
-        }
-      };
+            private String nullToEmpty(@Nullable String str) {
+              return (str == null) ? "" : str;
+            }
+          };
       int insertPos = getInsertRow(c, info);
       if (insertPos >= 0) {
         table.insertRow(insertPos);
@@ -420,8 +435,7 @@
       table.setWidget(row, 1, checkBox);
       checkBox.setEnabled(enabled);
       if (AccountGroup.isInternalGroup(uuid)) {
-        table.setWidget(row, 2,
-            new Hyperlink(i.name(), Dispatcher.toGroup(uuid)));
+        table.setWidget(row, 2, new Hyperlink(i.name(), Dispatcher.toGroup(uuid)));
         fmt.getElement(row, 2).setTitle(null);
         table.setText(row, 3, i.description());
       } else if (i.url() != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
index 8c00ba7..29b7677 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupScreen.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.client.Dispatcher.toGroup;
 
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.MenuScreen;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -40,8 +40,10 @@
     this.membersTabToken = getTabToken(token, MEMBERS);
     this.auditLogTabToken = getTabToken(token, AUDIT_LOG);
 
-    link(Util.C.groupTabGeneral(), getTabToken(token, INFO));
-    link(Util.C.groupTabMembers(), membersTabToken,
+    link(AdminConstants.I.groupTabGeneral(), getTabToken(token, INFO));
+    link(
+        AdminConstants.I.groupTabMembers(),
+        membersTabToken,
         AccountGroup.isInternalGroup(group.getGroupUUID()));
   }
 
@@ -55,22 +57,26 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    setPageTitle(Util.M.group(group.name()));
+    setPageTitle(AdminMessages.I.group(group.name()));
     display();
-    GroupApi.isGroupOwner(group.name(), new GerritCallback<Boolean>() {
-      @Override
-      public void onSuccess(Boolean result) {
-        if (result) {
-          link(Util.C.groupTabAuditLog(), auditLogTabToken,
-              AccountGroup.isInternalGroup(group.getGroupUUID()));
-          setToken(token);
-        }
-        display(group, result);
-      }
-    });
+    GroupApi.isGroupOwner(
+        group.name(),
+        new GerritCallback<Boolean>() {
+          @Override
+          public void onSuccess(Boolean result) {
+            if (result) {
+              link(
+                  AdminConstants.I.groupTabAuditLog(),
+                  auditLogTabToken,
+                  AccountGroup.isInternalGroup(group.getGroupUUID()));
+              setToken(token);
+            }
+            display(group, result);
+          }
+        });
   }
 
-  protected abstract void display(final GroupInfo group, final boolean canModify);
+  protected abstract void display(GroupInfo group, boolean canModify);
 
   protected AccountGroup.UUID getGroupUUID() {
     return group.getGroupUUID();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
index 984c5a3..14e7abc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
@@ -14,149 +14,270 @@
 
 package com.google.gerrit.client.admin;
 
+import com.google.gwt.core.client.GWT;
 import com.google.gwt.i18n.client.Constants;
-
 import java.util.Map;
 
 public interface AdminConstants extends Constants {
+  AdminConstants I = GWT.create(AdminConstants.class);
+
   String defaultAccountName();
+
   String defaultAccountGroupName();
+
   String defaultBranchName();
+
+  String defaultTagName();
+
   String defaultRevisionSpec();
 
+  String annotation();
+
   String buttonDeleteIncludedGroup();
+
   String buttonAddIncludedGroup();
+
   String buttonDeleteGroupMembers();
+
   String buttonAddGroupMember();
+
   String buttonSaveDescription();
+
   String buttonRenameGroup();
+
   String buttonCreateGroup();
+
   String buttonCreateProject();
+
   String buttonChangeGroupOwner();
+
   String buttonChangeGroupType();
+
   String buttonSelectGroup();
+
   String buttonSaveChanges();
+
   String checkBoxEmptyCommit();
+
   String checkBoxPermissionsOnly();
+
   String useContentMerge();
+
   String useContributorAgreements();
+
   String useSignedOffBy();
+
   String createNewChangeForAllNotInTarget();
+
   String enableSignedPush();
+
   String requireSignedPush();
+
   String requireChangeID();
+
   String rejectImplicitMerges();
+
   String headingMaxObjectSizeLimit();
+
   String headingGroupOptions();
+
   String isVisibleToAll();
+
   String buttonSaveGroupOptions();
+
   String suggestedGroupLabel();
+
   String parentSuggestions();
 
   String buttonBrowseProjects();
+
   String projects();
+
   String projectRepoBrowser();
+
   String headingGroupUUID();
+
   String headingOwner();
+
   String headingDescription();
+
   String headingProjectOptions();
+
   String headingProjectCommands();
+
   String headingCommands();
+
   String headingMembers();
+
   String headingIncludedGroups();
+
   String noMembersInfo();
+
   String headingExternalGroup();
+
   String headingCreateGroup();
+
   String headingParentProjectName();
+
   String columnProjectName();
+
   String headingAgreements();
+
   String headingAuditLog();
 
   String headingProjectSubmitType();
+
   String projectSubmitType_FAST_FORWARD_ONLY();
+
   String projectSubmitType_MERGE_ALWAYS();
+
   String projectSubmitType_MERGE_IF_NECESSARY();
+
   String projectSubmitType_REBASE_IF_NECESSARY();
+
+  String projectSubmitType_REBASE_ALWAYS();
+
   String projectSubmitType_CHERRY_PICK();
 
   String headingProjectState();
+
   String projectState_ACTIVE();
+
   String projectState_READ_ONLY();
+
   String projectState_HIDDEN();
 
   String columnMember();
+
   String columnEmailAddress();
+
   String columnGroupName();
+
   String columnGroupDescription();
+
   String columnGroupType();
+
   String columnGroupNotifications();
+
   String columnGroupVisibleToAll();
 
   String columnDate();
+
   String columnType();
+
   String columnByUser();
 
   String typeAdded();
+
   String typeRemoved();
 
   String columnBranchName();
+
   String columnBranchRevision();
-  String initialRevision();
-  String buttonAddBranch();
-  String buttonDeleteBranch();
-  String saveHeadButton();
-  String cancelHeadButton();
+
   String columnTagName();
 
+  String columnTagRevision();
+
+  String columnTagAnnotation();
+
+  String initialRevision();
+
+  String revision();
+
+  String buttonAddBranch();
+
+  String buttonDeleteBranch();
+
+  String buttonAddTag();
+
+  String buttonDeleteTag();
+
+  String saveHeadButton();
+
+  String cancelHeadButton();
+
   String groupItemHelp();
 
   String groupListTitle();
+
   String groupFilter();
+
   String createGroupTitle();
+
   String groupTabGeneral();
+
   String groupTabMembers();
+
   String groupTabAuditLog();
+
   String projectListTitle();
+
   String projectFilter();
+
   String createProjectTitle();
+
   String projectListQueryLink();
 
   String plugins();
+
   String pluginEnabled();
+
   String pluginDisabled();
+
   String pluginSettingsToolTip();
 
   String columnPluginName();
+
   String columnPluginSettings();
+
   String columnPluginVersion();
+
   String columnPluginStatus();
 
   String noGroupSelected();
+
   String errorNoMatchingGroups();
+
   String errorNoGitRepository();
 
   String addPermission();
-  Map<String,String> permissionNames();
+
+  Map<String, String> permissionNames();
 
   String refErrorEmpty();
+
   String refErrorBeginSlash();
+
   String refErrorDoubleSlash();
+
   String refErrorNoSpace();
+
   String refErrorPrintable();
+
   String errorsMustBeFixed();
 
   String sectionTypeReference();
+
   String sectionTypeSection();
+
   Map<String, String> sectionNames();
 
   String pagedListPrev();
+
   String pagedListNext();
 
   String buttonCreate();
+
   String buttonCreateDescription();
+
   String buttonCreateChange();
+
   String buttonCreateChangeDescription();
+
   String buttonEditConfig();
+
   String buttonEditConfigDescription();
+
   String editConfigMessage();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 2fe5978..32203bf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -1,7 +1,9 @@
 defaultAccountName = Name or Email
 defaultAccountGroupName = Group Name
 defaultBranchName = Branch Name
+defaultTagName = Tag Name
 defaultRevisionSpec = Revision (Branch or SHA-1)
+annotation = Annotation (optional)
 
 buttonDeleteIncludedGroup = Delete
 buttonAddIncludedGroup = Add
@@ -54,6 +56,7 @@
 headingProjectSubmitType = Submit Type
 projectSubmitType_FAST_FORWARD_ONLY = Fast Forward Only
 projectSubmitType_MERGE_IF_NECESSARY = Merge if Necessary
+projectSubmitType_REBASE_ALWAYS = Rebase Always
 projectSubmitType_REBASE_IF_NECESSARY = Rebase if Necessary
 projectSubmitType_MERGE_ALWAYS = Always Merge
 projectSubmitType_CHERRY_PICK = Cherry Pick
@@ -80,12 +83,17 @@
 
 columnBranchName = Branch Name
 columnBranchRevision = Revision
+columnTagName = Tag Name
+columnTagRevision = Revision
+columnTagAnnotation = Annotation
 initialRevision = Initial Revision
+revision = Revision
 buttonAddBranch = Create Branch
+buttonAddTag = Create Tag
 buttonDeleteBranch = Delete
+buttonDeleteTag = Delete
 saveHeadButton = Save
 cancelHeadButton = Cancel
-columnTagName = Tag Name
 
 groupItemHelp = group
 
@@ -123,7 +131,13 @@
 	abandon, \
 	addPatchSet, \
 	create, \
+	createTag, \
+	createSignedTag, \
+	delete, \
+	deleteChanges, \
 	deleteDrafts, \
+	deleteOwnChanges, \
+	editAssignee, \
 	editHashtags, \
 	editTopicName, \
 	forgeAuthor, \
@@ -133,8 +147,6 @@
 	publishDrafts, \
 	push, \
 	pushMerge, \
-	pushTag, \
-	pushSignedTag, \
 	read, \
 	rebase, \
 	removeReviewer, \
@@ -145,7 +157,13 @@
 abandon = Abandon
 addPatchSet = Add Patch Set
 create = Create Reference
+createTag = Create Annotated Tag
+createSignedTag = Create Signed Tag
+delete = Delete Reference
+deleteChanges = Delete Changes
 deleteDrafts = Delete Drafts
+deleteOwnChanges = Delete Own Changes
+editAssignee = Edit Assignee
 editHashtags = Edit Hashtags
 editTopicName = Edit Topic Name
 forgeAuthor = Forge Author Identity
@@ -155,8 +173,6 @@
 publishDrafts = Publish Drafts
 push = Push
 pushMerge = Push Merge Commit
-pushTag = Push Annotated Tag
-pushSignedTag = Push Signed Tag
 read = Read
 rebase = Rebase
 removeReviewer = Remove Reviewer
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminCss.java
index 55a9bf3..53b2e72 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminCss.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminCss.java
@@ -18,9 +18,11 @@
 
 public interface AdminCss extends CssResource {
   String deleteIcon();
+
   String undoIcon();
 
   String deleted();
+
   String deletedBorder();
 
   String deleteSectionHover();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
index 05ffa9a..7b18a39 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.java
@@ -14,21 +14,33 @@
 
 package com.google.gerrit.client.admin;
 
+import com.google.gwt.core.client.GWT;
 import com.google.gwt.i18n.client.Messages;
 
 public interface AdminMessages extends Messages {
+  AdminMessages I = GWT.create(AdminMessages.class);
+
   String group(String name);
+
   String label(String name);
+
   String labelAs(String name);
+
   String project(String name);
+
   String deletedGroup(int id);
 
   String deletedReference(String name);
+
   String deletedSection(String name);
 
   String effectiveMaxObjectSizeLimit(String effectiveMaxObjectSizeLimit);
-  String globalMaxObjectSizeLimit(String globalMaxObjectSizeLimit);
+
+  String noMaxObjectSizeLimit();
+
   String pluginProjectOptionsTitle(String pluginName);
+
   String pluginProjectInheritedValue(String value);
+
   String pluginProjectInheritedListValue(String value);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
index 6338920..c9aa987 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminMessages.properties
@@ -5,8 +5,8 @@
 deletedGroup = Deleted Group {0}
 deletedReference = Reference {0} was deleted
 deletedSection = Section {0} was deleted
-effectiveMaxObjectSizeLimit = effective: {0}
-globalMaxObjectSizeLimit = The global max object size limit is set to {0}. The limit cannot be increased on project level.
+effectiveMaxObjectSizeLimit = effective: {0} bytes
+noMaxObjectSizeLimit = No max object size limit is set.
 pluginProjectOptionsTitle = {0} Plugin Options
 pluginProjectOptionsTitle = {0} Plugin
 pluginProjectInheritedValue = inherited: {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
index 688a59f..dfbfbb6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminResources.java
@@ -24,19 +24,14 @@
   @Source("admin.css")
   AdminCss css();
 
-  /**
-   * unknown origin
-   * TODO replace icons
-   */
+  /** unknown origin TODO replace icons */
   @Source("deleteNormal.png")
   ImageResource deleteNormal();
 
   @Source("deleteHover.png")
   ImageResource deleteHover();
 
-  /**
-   * silk icons (CC-BY3.0): http://famfamfam.com/lab/icons/silk/
-   */
+  /** silk icons (CC-BY3.0): http://famfamfam.com/lab/icons/silk/ */
   @Source("arrow_undo.png")
   ImageResource undoNormal();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java
index ad8a595..2e5bbb5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java
@@ -31,29 +31,32 @@
     b.setEnabled(false);
     new CreateChangeDialog(new Project.NameKey(project)) {
       {
-        sendButton.setText(Util.C.buttonCreate());
-        message.setText(Util.C.buttonCreateDescription());
+        sendButton.setText(AdminConstants.I.buttonCreate());
+        message.setText(AdminConstants.I.buttonCreateDescription());
       }
 
       @Override
       public void onSend() {
-        ChangeApi.createChange(project, getDestinationBranch(),
-          getDestinationTopic(),
-          message.getText(), null,
-          new GerritCallback<ChangeInfo>() {
-            @Override
-            public void onSuccess(ChangeInfo result) {
-              sent = true;
-              hide();
-              Gerrit.display(PageLinks.toChange(result.legacyId()));
-            }
+        ChangeApi.createChange(
+            project,
+            getDestinationBranch(),
+            getDestinationTopic(),
+            message.getText(),
+            null,
+            new GerritCallback<ChangeInfo>() {
+              @Override
+              public void onSuccess(ChangeInfo result) {
+                sent = true;
+                hide();
+                Gerrit.display(PageLinks.toChange(result.legacyId()));
+              }
 
-            @Override
-            public void onFailure(Throwable caught) {
-              enableButtons(true);
-              super.onFailure(caught);
-            }
-        });
+              @Override
+              public void onFailure(Throwable caught) {
+                enableButtons(true);
+                super.onFailure(caught);
+              }
+            });
       }
 
       @Override
@@ -61,7 +64,6 @@
         super.onClose(event);
         b.setEnabled(true);
       }
-
     }.center();
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
index a2ba5cd..457e179 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.client.NotFoundScreen;
 import com.google.gerrit.client.account.AccountCapabilities;
 import com.google.gerrit.client.groups.GroupApi;
-import com.google.gerrit.client.groups.GroupInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.client.ui.Screen;
@@ -53,67 +53,74 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    AccountCapabilities.all(new GerritCallback<AccountCapabilities>() {
-      @Override
-      public void onSuccess(AccountCapabilities ac) {
-        if (ac.canPerform(CREATE_GROUP)) {
-          display();
-        } else {
-          Gerrit.display(PageLinks.ADMIN_CREATE_GROUP, new NotFoundScreen());
-        }
-      }
-    }, CREATE_GROUP);
+    AccountCapabilities.all(
+        new GerritCallback<AccountCapabilities>() {
+          @Override
+          public void onSuccess(AccountCapabilities ac) {
+            if (ac.canPerform(CREATE_GROUP)) {
+              display();
+            } else {
+              Gerrit.display(PageLinks.ADMIN_CREATE_GROUP, new NotFoundScreen());
+            }
+          }
+        },
+        CREATE_GROUP);
   }
 
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    setPageTitle(Util.C.createGroupTitle());
+    setPageTitle(AdminConstants.I.createGroupTitle());
     addCreateGroupPanel();
   }
 
   private void addCreateGroupPanel() {
     VerticalPanel addPanel = new VerticalPanel();
     addPanel.setStyleName(Gerrit.RESOURCES.css().addSshKeyPanel());
-    addPanel.add(new SmallHeading(Util.C.headingCreateGroup()));
+    addPanel.add(new SmallHeading(AdminConstants.I.headingCreateGroup()));
 
-    addTxt = new NpTextBox() {
-      @Override
-      public void onBrowserEvent(Event event) {
-        super.onBrowserEvent(event);
-        if (event.getTypeInt() == Event.ONPASTE) {
-          Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-            @Override
-            public void execute() {
-              if (addTxt.getValue().trim().length() != 0) {
-                addNew.setEnabled(true);
-              }
+    addTxt =
+        new NpTextBox() {
+          @Override
+          public void onBrowserEvent(Event event) {
+            super.onBrowserEvent(event);
+            if (event.getTypeInt() == Event.ONPASTE) {
+              Scheduler.get()
+                  .scheduleDeferred(
+                      new ScheduledCommand() {
+                        @Override
+                        public void execute() {
+                          if (addTxt.getValue().trim().length() != 0) {
+                            addNew.setEnabled(true);
+                          }
+                        }
+                      });
             }
-          });
-        }
-      }
-    };
+          }
+        };
     addTxt.sinkEvents(Event.ONPASTE);
 
     addTxt.setVisibleLength(60);
-    addTxt.addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-          doCreateGroup();
-        }
-      }
-    });
+    addTxt.addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+              doCreateGroup();
+            }
+          }
+        });
     addPanel.add(addTxt);
 
-    addNew = new Button(Util.C.buttonCreateGroup());
+    addNew = new Button(AdminConstants.I.buttonCreateGroup());
     addNew.setEnabled(false);
-    addNew.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doCreateGroup();
-      }
-    });
+    addNew.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doCreateGroup();
+          }
+        });
     addPanel.add(addNew);
     add(addPanel);
 
@@ -127,18 +134,19 @@
     }
 
     addNew.setEnabled(false);
-    GroupApi.createGroup(newName, new GerritCallback<GroupInfo>() {
-      @Override
-      public void onSuccess(final GroupInfo result) {
-        History.newItem(Dispatcher.toGroup(result.getGroupId(),
-            AccountGroupScreen.MEMBERS));
-      }
+    GroupApi.createGroup(
+        newName,
+        new GerritCallback<GroupInfo>() {
+          @Override
+          public void onSuccess(final GroupInfo result) {
+            History.newItem(Dispatcher.toGroup(result.getGroupId(), AccountGroupScreen.MEMBERS));
+          }
 
-      @Override
-      public void onFailure(Throwable caught) {
-        super.onFailure(caught);
-        addNew.setEnabled(true);
-      }
-    });
+          @Override
+          public void onFailure(Throwable caught) {
+            super.onFailure(caught);
+            addNew.setEnabled(true);
+          }
+        });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
index 3132531..092c6e1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
@@ -71,16 +71,18 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    AccountCapabilities.all(new GerritCallback<AccountCapabilities>() {
-      @Override
-      public void onSuccess(AccountCapabilities ac) {
-        if (ac.canPerform(CREATE_PROJECT)) {
-          display();
-        } else {
-          Gerrit.display(PageLinks.ADMIN_CREATE_PROJECT, new NotFoundScreen());
-        }
-      }
-    }, CREATE_PROJECT);
+    AccountCapabilities.all(
+        new GerritCallback<AccountCapabilities>() {
+          @Override
+          public void onSuccess(AccountCapabilities ac) {
+            if (ac.canPerform(CREATE_PROJECT)) {
+              display();
+            } else {
+              Gerrit.display(PageLinks.ADMIN_CREATE_PROJECT, new NotFoundScreen());
+            }
+          }
+        },
+        CREATE_PROJECT);
   }
 
   @Override
@@ -92,20 +94,21 @@
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    setPageTitle(Util.C.createProjectTitle());
+    setPageTitle(AdminConstants.I.createProjectTitle());
     addCreateProjectPanel();
 
     /* popup */
-    projectsPopup = new ProjectListPopup() {
-      @Override
-      protected void onMovePointerTo(String projectName) {
-        // prevent user input from being overwritten by simply poping up
-        if (!projectsPopup.isPoppingUp() || "".equals(parent.getText())) {
-          parent.setText(projectName);
-        }
-      }
-    };
-    projectsPopup.initPopup(Util.C.projects(), PageLinks.ADMIN_PROJECTS);
+    projectsPopup =
+        new ProjectListPopup() {
+          @Override
+          protected void onMovePointerTo(String projectName) {
+            // prevent user input from being overwritten by simply poping up
+            if (!projectsPopup.isPoppingUp() || "".equals(parent.getText())) {
+              parent.setText(projectName);
+            }
+          }
+        };
+    projectsPopup.initPopup(AdminConstants.I.projects(), PageLinks.ADMIN_PROJECTS);
   }
 
   private void addCreateProjectPanel() {
@@ -118,8 +121,8 @@
 
     addGrid(fp);
 
-    emptyCommit = new CheckBox(Util.C.checkBoxEmptyCommit());
-    permissionsOnly = new CheckBox(Util.C.checkBoxPermissionsOnly());
+    emptyCommit = new CheckBox(AdminConstants.I.checkBoxEmptyCommit());
+    permissionsOnly = new CheckBox(AdminConstants.I.checkBoxPermissionsOnly());
     fp.add(emptyCommit);
     fp.add(permissionsOnly);
     fp.add(create);
@@ -131,61 +134,68 @@
   }
 
   private void initCreateTxt() {
-    project = new NpTextBox() {
-      @Override
-      public void onBrowserEvent(Event event) {
-        super.onBrowserEvent(event);
-        if (event.getTypeInt() == Event.ONPASTE) {
-          Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-            @Override
-            public void execute() {
-              if (project.getValue().trim().length() != 0) {
-                create.setEnabled(true);
-              }
+    project =
+        new NpTextBox() {
+          @Override
+          public void onBrowserEvent(Event event) {
+            super.onBrowserEvent(event);
+            if (event.getTypeInt() == Event.ONPASTE) {
+              Scheduler.get()
+                  .scheduleDeferred(
+                      new ScheduledCommand() {
+                        @Override
+                        public void execute() {
+                          if (project.getValue().trim().length() != 0) {
+                            create.setEnabled(true);
+                          }
+                        }
+                      });
             }
-          });
-        }
-      }
-    };
+          }
+        };
     project.sinkEvents(Event.ONPASTE);
     project.setVisibleLength(50);
-    project.addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-          doCreateProject();
-        }
-      }
-    });
+    project.addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+              doCreateProject();
+            }
+          }
+        });
     new OnEditEnabler(create, project);
   }
 
   private void initCreateButton() {
-    create = new Button(Util.C.buttonCreateProject());
+    create = new Button(AdminConstants.I.buttonCreateProject());
     create.setEnabled(false);
-    create.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doCreateProject();
-      }
-    });
+    create.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doCreateProject();
+          }
+        });
 
-    browse = new Button(Util.C.buttonBrowseProjects());
-    browse.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        int top = grid.getAbsoluteTop() - 50; // under page header
-        // Try to place it to the right of everything else, but not
-        // right justified
-        int left =
-            5 + Math.max(
-                grid.getAbsoluteLeft() + grid.getOffsetWidth(),
-                suggestedParentsTab.getAbsoluteLeft()
-                    + suggestedParentsTab.getOffsetWidth());
-        projectsPopup.setPreferredCoordinates(top, left);
-        projectsPopup.displayPopup();
-      }
-    });
+    browse = new Button(AdminConstants.I.buttonBrowseProjects());
+    browse.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            int top = grid.getAbsoluteTop() - 50; // under page header
+            // Try to place it to the right of everything else, but not
+            // right justified
+            int left =
+                5
+                    + Math.max(
+                        grid.getAbsoluteLeft() + grid.getOffsetWidth(),
+                        suggestedParentsTab.getAbsoluteLeft()
+                            + suggestedParentsTab.getOffsetWidth());
+            projectsPopup.setPreferredCoordinates(top, left);
+            projectsPopup.displayPopup();
+          }
+        });
   }
 
   private void initParentBox() {
@@ -194,48 +204,52 @@
   }
 
   private void initSuggestedParents() {
-    suggestedParentsTab = new ProjectsTable() {
-      {
-        table.setText(0, 1, Util.C.parentSuggestions());
-      }
-
-      @Override
-      protected void populate(final int row, final ProjectInfo k) {
-        final Anchor projectLink = new Anchor(k.name());
-        projectLink.addClickHandler(new ClickHandler() {
+    suggestedParentsTab =
+        new ProjectsTable() {
+          {
+            table.setText(0, 1, AdminConstants.I.parentSuggestions());
+          }
 
           @Override
-          public void onClick(ClickEvent event) {
-            parent.setText(getRowItem(row).name());
+          protected void populate(final int row, final ProjectInfo k) {
+            populateState(row, k);
+            final Anchor projectLink = new Anchor(k.name());
+            projectLink.addClickHandler(
+                new ClickHandler() {
+
+                  @Override
+                  public void onClick(ClickEvent event) {
+                    parent.setText(getRowItem(row).name());
+                  }
+                });
+
+            table.setWidget(row, ProjectsTable.C_NAME, projectLink);
+            table.setText(row, ProjectsTable.C_DESCRIPTION, k.description());
+
+            setRowItem(row, k);
           }
-        });
-
-        table.setWidget(row, 2, projectLink);
-        table.setText(row, 3, k.description());
-
-        setRowItem(row, k);
-      }
-    };
+        };
     suggestedParentsTab.setVisible(false);
 
-    ProjectMap.parentCandidates(new GerritCallback<ProjectMap>() {
-      @Override
-      public void onSuccess(ProjectMap list) {
-        if (!list.isEmpty()) {
-          suggestedParentsTab.setVisible(true);
-          suggestedParentsTab.display(list);
-          suggestedParentsTab.finishDisplay();
-        }
-      }
-    });
+    ProjectMap.parentCandidates(
+        new GerritCallback<ProjectMap>() {
+          @Override
+          public void onSuccess(ProjectMap list) {
+            if (!list.isEmpty()) {
+              suggestedParentsTab.setVisible(true);
+              suggestedParentsTab.display(list);
+              suggestedParentsTab.finishDisplay();
+            }
+          }
+        });
   }
 
   private void addGrid(final VerticalPanel fp) {
     grid = new Grid(2, 3);
     grid.setStyleName(Gerrit.RESOURCES.css().infoBlock());
-    grid.setText(0, 0, Util.C.columnProjectName() + ":");
+    grid.setText(0, 0, AdminConstants.I.columnProjectName() + ":");
     grid.setWidget(0, 1, project);
-    grid.setText(1, 0, Util.C.headingParentProjectName() + ":");
+    grid.setText(1, 0, AdminConstants.I.headingParentProjectName() + ":");
     grid.setWidget(1, 1, parent);
     grid.setWidget(1, 2, browse);
     fp.add(grid);
@@ -251,13 +265,18 @@
     }
 
     enableForm(false);
-    ProjectApi.createProject(projectName, parentName, emptyCommit.getValue(),
-        permissionsOnly.getValue(), new AsyncCallback<VoidResult>() {
+    ProjectApi.createProject(
+        projectName,
+        parentName,
+        emptyCommit.getValue(),
+        permissionsOnly.getValue(),
+        new AsyncCallback<VoidResult>() {
           @Override
           public void onSuccess(VoidResult result) {
             String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
-            History.newItem(Dispatcher.toProjectAdmin(new Project.NameKey(
-                nameWithoutSuffix), ProjectScreen.INFO));
+            History.newItem(
+                Dispatcher.toProjectAdmin(
+                    new Project.NameKey(nameWithoutSuffix), ProjectScreen.INFO));
           }
 
           @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java
index 3e6086b..d28e9bb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java
@@ -27,12 +27,17 @@
   static void call(final Button b, final String project) {
     b.setEnabled(false);
 
-    ChangeApi.createChange(project, RefNames.REFS_CONFIG, null,
-        Util.C.editConfigMessage(), null, new GerritCallback<ChangeInfo>() {
+    ChangeApi.createChange(
+        project,
+        RefNames.REFS_CONFIG,
+        null,
+        AdminConstants.I.editConfigMessage(),
+        null,
+        new GerritCallback<ChangeInfo>() {
           @Override
           public void onSuccess(ChangeInfo result) {
-            Gerrit.display(Dispatcher.toEditScreen(
-                new PatchSet.Id(result.legacyId(), 1), "project.config"));
+            Gerrit.display(
+                Dispatcher.toEditScreen(new PatchSet.Id(result.legacyId(), 1), "project.config"));
           }
 
           @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
index 86222ce..b37a680 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupListScreen.java
@@ -95,7 +95,7 @@
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    setPageTitle(Util.C.groupListTitle());
+    setPageTitle(AdminConstants.I.groupListTitle());
     initPageHeader();
 
     prev = PagingHyperlink.createPrev();
@@ -117,27 +117,29 @@
   private void initPageHeader() {
     final HorizontalPanel hp = new HorizontalPanel();
     hp.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    final Label filterLabel = new Label(Util.C.projectFilter());
+    final Label filterLabel = new Label(AdminConstants.I.projectFilter());
     filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
     hp.add(filterLabel);
     filterTxt = new NpTextBox();
     filterTxt.setValue(match);
-    filterTxt.addKeyUpHandler(new KeyUpHandler() {
-      @Override
-      public void onKeyUp(KeyUpEvent event) {
-        Query q = new Query(filterTxt.getValue())
-          .open(event.getNativeKeyCode() == KeyCodes.KEY_ENTER);
-        if (match.equals(q.qMatch)) {
-          q.start(start);
-        }
-        if (q.open || !match.equals(q.qMatch)) {
-          if (query == null) {
-            q.run();
+    filterTxt.addKeyUpHandler(
+        new KeyUpHandler() {
+          @Override
+          public void onKeyUp(KeyUpEvent event) {
+            Query q =
+                new Query(filterTxt.getValue())
+                    .open(event.getNativeKeyCode() == KeyCodes.KEY_ENTER);
+            if (match.equals(q.qMatch)) {
+              q.start(start);
+            }
+            if (q.open || !match.equals(q.qMatch)) {
+              if (query == null) {
+                q.run();
+              }
+              query = q;
+            }
           }
-          query = q;
-        }
-      }
-    });
+        });
     hp.add(filterTxt);
     add(hp);
   }
@@ -178,7 +180,10 @@
 
     Query run() {
       int limit = open ? 1 : pageSize + 1;
-      GroupMap.match(qMatch, limit, qStart,
+      GroupMap.match(
+          qMatch,
+          limit,
+          qStart,
           new GerritCallback<GroupMap>() {
             @Override
             public void onSuccess(GroupMap result) {
@@ -197,8 +202,7 @@
 
     private void showMap(GroupMap result) {
       if (open && !result.isEmpty()) {
-        Gerrit.display(PageLinks.toGroup(
-            result.values().get(0).getGroupUUID()));
+        Gerrit.display(PageLinks.toGroup(result.values().get(0).getGroupUUID()));
         return;
       }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java
index d6d0fe3..db138a9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupReferenceBox.java
@@ -29,9 +29,11 @@
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.Focusable;
 
-public class GroupReferenceBox extends Composite implements
-    LeafValueEditor<GroupReference>, HasSelectionHandlers<GroupReference>,
-    HasCloseHandlers<GroupReferenceBox>, Focusable {
+public class GroupReferenceBox extends Composite
+    implements LeafValueEditor<GroupReference>,
+        HasSelectionHandlers<GroupReference>,
+        HasCloseHandlers<GroupReferenceBox>,
+        Focusable {
   private final AccountGroupSuggestOracle oracle;
   private final RemoteSuggestBox suggestBox;
 
@@ -40,20 +42,21 @@
     suggestBox = new RemoteSuggestBox(oracle);
     initWidget(suggestBox);
 
-    suggestBox.addSelectionHandler(new SelectionHandler<String>() {
-      @Override
-      public void onSelection(SelectionEvent<String> event) {
-        SelectionEvent.fire(GroupReferenceBox.this,
-            toValue(event.getSelectedItem()));
-      }
-    });
-    suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
-      @Override
-      public void onClose(CloseEvent<RemoteSuggestBox> event) {
-        suggestBox.setText("");
-        CloseEvent.fire(GroupReferenceBox.this, GroupReferenceBox.this);
-      }
-    });
+    suggestBox.addSelectionHandler(
+        new SelectionHandler<String>() {
+          @Override
+          public void onSelection(SelectionEvent<String> event) {
+            SelectionEvent.fire(GroupReferenceBox.this, toValue(event.getSelectedItem()));
+          }
+        });
+    suggestBox.addCloseHandler(
+        new CloseHandler<RemoteSuggestBox>() {
+          @Override
+          public void onClose(CloseEvent<RemoteSuggestBox> event) {
+            suggestBox.setText("");
+            CloseEvent.fire(GroupReferenceBox.this, GroupReferenceBox.this);
+          }
+        });
   }
 
   public void setVisibleLength(int len) {
@@ -61,14 +64,12 @@
   }
 
   @Override
-  public HandlerRegistration addSelectionHandler(
-      SelectionHandler<GroupReference> handler) {
+  public HandlerRegistration addSelectionHandler(SelectionHandler<GroupReference> handler) {
     return addHandler(handler, SelectionEvent.getType());
   }
 
   @Override
-  public HandlerRegistration addCloseHandler(
-      CloseHandler<GroupReferenceBox> handler) {
+  public HandlerRegistration addCloseHandler(CloseHandler<GroupReferenceBox> handler) {
     return addHandler(handler, CloseEvent.getType());
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
index 64fc0e5..0f5bf22 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/GroupTable.java
@@ -14,13 +14,11 @@
 
 package com.google.gerrit.client.admin;
 
-import static com.google.gerrit.client.admin.Util.C;
-
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.groups.GroupInfo;
 import com.google.gerrit.client.groups.GroupList;
 import com.google.gerrit.client.groups.GroupMap;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.HighlightingInlineHyperlink;
 import com.google.gerrit.client.ui.NavigationTable;
@@ -34,12 +32,10 @@
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.HTMLTable.Cell;
 import com.google.gwt.user.client.ui.Image;
-
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 
-
 public class GroupTable extends NavigationTable<GroupInfo> {
   private static final int NUM_COLS = 3;
 
@@ -48,22 +44,24 @@
   }
 
   public GroupTable(final String pointerId) {
-    super(C.groupItemHelp());
+    super(AdminConstants.I.groupItemHelp());
     setSavePointerId(pointerId);
 
-    table.setText(0, 1, C.columnGroupName());
-    table.setText(0, 2, C.columnGroupDescription());
-    table.setText(0, 3, C.columnGroupVisibleToAll());
-    table.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        final Cell cell = table.getCellForEvent(event);
-        if (cell != null && cell.getCellIndex() != 1
-            && getRowItem(cell.getRowIndex()) != null) {
-          movePointerTo(cell.getRowIndex());
-        }
-      }
-    });
+    table.setText(0, 1, AdminConstants.I.columnGroupName());
+    table.setText(0, 2, AdminConstants.I.columnGroupDescription());
+    table.setText(0, 3, AdminConstants.I.columnGroupVisibleToAll());
+    table.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            final Cell cell = table.getCellForEvent(event);
+            if (cell != null
+                && cell.getCellIndex() != 1
+                && getRowItem(cell.getRowIndex()) != null) {
+              movePointerTo(cell.getRowIndex());
+            }
+          }
+        });
 
     final FlexCellFormatter fmt = table.getFlexCellFormatter();
     for (int i = 1; i <= NUM_COLS; i++) {
@@ -107,12 +105,14 @@
       table.removeRow(table.getRowCount() - 1);
     }
 
-    Collections.sort(list, new Comparator<GroupInfo>() {
-      @Override
-      public int compare(GroupInfo a, GroupInfo b) {
-        return a.name().compareTo(b.name());
-      }
-    });
+    Collections.sort(
+        list,
+        new Comparator<GroupInfo>() {
+          @Override
+          public int compare(GroupInfo a, GroupInfo b) {
+            return a.name().compareTo(b.name());
+          }
+        });
     for (GroupInfo group : list.subList(fromIndex, toIndex)) {
       final int row = table.getRowCount();
       table.insertRow(row);
@@ -124,8 +124,11 @@
   void populate(final int row, final GroupInfo k, final String toHighlight) {
     if (k.url() != null) {
       if (isInteralGroup(k)) {
-        table.setWidget(row, 1, new HighlightingInlineHyperlink(k.name(),
-            Dispatcher.toGroup(k.getGroupId()), toHighlight));
+        table.setWidget(
+            row,
+            1,
+            new HighlightingInlineHyperlink(
+                k.name(), Dispatcher.toGroup(k.getGroupId()), toHighlight));
       } else {
         Anchor link = new Anchor();
         link.setHTML(Util.highlight(k.name(), toHighlight));
@@ -150,7 +153,6 @@
   }
 
   private boolean isInteralGroup(final GroupInfo groupInfo) {
-    return groupInfo != null
-        && groupInfo.url().startsWith("#" + PageLinks.ADMIN_GROUPS);
+    return groupInfo != null && groupInfo.url().startsWith("#" + PageLinks.ADMIN_GROUPS);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java
index 66738c0..75c3cb6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PaginatedProjectScreen.java
@@ -40,8 +40,7 @@
         match = URL.decodeQueryString(kv[1]);
       }
 
-      if ("skip".equals(kv[0])
-          && URL.decodeQueryString(kv[1]).matches("^[\\d]+")) {
+      if ("skip".equals(kv[0]) && URL.decodeQueryString(kv[1]).matches("^[\\d]+")) {
         start = Integer.parseInt(URL.decodeQueryString(kv[1]));
       }
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
index 025176c..d254c7d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionEditor.java
@@ -56,15 +56,13 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.ValueLabel;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 
-public class PermissionEditor extends Composite implements Editor<Permission>,
-    ValueAwareEditor<Permission> {
-  interface Binder extends UiBinder<HTMLPanel, PermissionEditor> {
-  }
+public class PermissionEditor extends Composite
+    implements Editor<Permission>, ValueAwareEditor<Permission> {
+  interface Binder extends UiBinder<HTMLPanel, PermissionEditor> {}
 
   private static final Binder uiBinder = GWT.create(Binder.class);
 
@@ -76,34 +74,22 @@
   @Path("name")
   ValueLabel<String> deletedName;
 
-  @UiField
-  CheckBox exclusiveGroup;
+  @UiField CheckBox exclusiveGroup;
 
-  @UiField
-  FlowPanel ruleContainer;
+  @UiField FlowPanel ruleContainer;
   ListEditor<PermissionRule, PermissionRuleEditor> rules;
 
-  @UiField
-  DivElement addContainer;
-  @UiField
-  DivElement addStage1;
-  @UiField
-  DivElement addStage2;
-  @UiField
-  Anchor beginAddRule;
-  @UiField
-  @Editor.Ignore
-  GroupReferenceBox groupToAdd;
-  @UiField
-  Button addRule;
+  @UiField DivElement addContainer;
+  @UiField DivElement addStage1;
+  @UiField DivElement addStage2;
+  @UiField Anchor beginAddRule;
+  @UiField @Editor.Ignore GroupReferenceBox groupToAdd;
+  @UiField Button addRule;
 
-  @UiField
-  Anchor deletePermission;
+  @UiField Anchor deletePermission;
 
-  @UiField
-  DivElement normal;
-  @UiField
-  DivElement deleted;
+  @UiField DivElement normal;
+  @UiField DivElement deleted;
 
   private final Project.NameKey projectName;
   private final Map<AccountGroup.UUID, GroupInfo> groupInfo;
@@ -114,10 +100,8 @@
   private PermissionRange.WithDefaults validRange;
   private boolean isDeleted;
 
-  public PermissionEditor(ProjectAccess projectAccess,
-      boolean readOnly,
-      AccessSection section,
-      LabelTypes labelTypes) {
+  public PermissionEditor(
+      ProjectAccess projectAccess, boolean readOnly, AccessSection section, LabelTypes labelTypes) {
     this.readOnly = readOnly;
     this.section = section;
     this.projectName = projectAccess.getProjectName();
@@ -134,8 +118,7 @@
     rules = ListEditor.of(new RuleEditorSource());
 
     exclusiveGroup.setEnabled(!readOnly);
-    exclusiveGroup.setVisible(RefConfigSection
-        .isValid(section.getName()));
+    exclusiveGroup.setVisible(RefConfigSection.isValid(section.getName()));
 
     if (readOnly) {
       addContainer.removeFromParent();
@@ -179,12 +162,14 @@
     addStage1.getStyle().setDisplay(Display.NONE);
     addStage2.getStyle().setDisplay(Display.BLOCK);
 
-    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-      @Override
-      public void execute() {
-        groupToAdd.setFocus(true);
-      }
-    });
+    Scheduler.get()
+        .scheduleDeferred(
+            new ScheduledCommand() {
+              @Override
+              public void execute() {
+                groupToAdd.setFocus(true);
+              }
+            });
   }
 
   @UiHandler("addRule")
@@ -206,8 +191,7 @@
   }
 
   @UiHandler("groupToAdd")
-  void onAbortAddGroup(
-      @SuppressWarnings("unused") CloseEvent<GroupReferenceBox> event) {
+  void onAbortAddGroup(@SuppressWarnings("unused") CloseEvent<GroupReferenceBox> event) {
     hideAddGroup();
   }
 
@@ -244,19 +228,20 @@
       //
       addRule.setEnabled(false);
       GroupMap.suggestAccountGroupForProject(
-          projectName.get(), ref.getName(), 1,
+          projectName.get(),
+          ref.getName(),
+          1,
           new GerritCallback<GroupMap>() {
             @Override
             public void onSuccess(GroupMap result) {
               addRule.setEnabled(true);
               if (result.values().length() == 1) {
-                addGroup(new GroupReference(
-                    result.values().get(0).getGroupUUID(),
-                    result.values().get(0).name()));
+                addGroup(
+                    new GroupReference(
+                        result.values().get(0).getGroupUUID(), result.values().get(0).name()));
               } else {
                 groupToAdd.setFocus(true);
-                new ErrorDialog(Gerrit.M.noSuchGroupMessage(ref.getName()))
-                    .center();
+                new ErrorDialog(Gerrit.M.noSuchGroupMessage(ref.getName())).center();
               }
             }
 
@@ -280,10 +265,13 @@
     if (Permission.hasRange(value.getName())) {
       LabelType lt = labelTypes.byLabel(value.getLabel());
       if (lt != null) {
-        validRange = new PermissionRange.WithDefaults(
-            value.getName(),
-            lt.getMin().getValue(), lt.getMax().getValue(),
-            lt.getMin().getValue(), lt.getMax().getValue());
+        validRange =
+            new PermissionRange.WithDefaults(
+                value.getName(),
+                lt.getMin().getValue(),
+                lt.getMax().getValue(),
+                lt.getMin().getValue(),
+                lt.getMax().getValue());
       }
     } else if (GlobalCapability.isCapability(value.getName())) {
       validRange = GlobalCapability.getRange(value.getName());
@@ -305,8 +293,7 @@
     List<PermissionRule> keep = new ArrayList<>(src.size());
 
     for (int i = 0; i < src.size(); i++) {
-      PermissionRuleEditor e =
-          (PermissionRuleEditor) ruleContainer.getWidget(i);
+      PermissionRuleEditor e = (PermissionRuleEditor) ruleContainer.getWidget(i);
       if (!e.isDeleted()) {
         keep.add(src.get(i));
       }
@@ -315,12 +302,10 @@
   }
 
   @Override
-  public void onPropertyChange(String... paths) {
-  }
+  public void onPropertyChange(String... paths) {}
 
   @Override
-  public void setDelegate(EditorDelegate<Permission> delegate) {
-  }
+  public void setDelegate(EditorDelegate<Permission> delegate) {}
 
   private class RuleEditorSource extends EditorSource<PermissionRuleEditor> {
     @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionNameRenderer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionNameRenderer.java
index 7ca8f0f..ef02bd0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionNameRenderer.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionNameRenderer.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.Permission;
 import com.google.gwt.text.shared.Renderer;
-
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
@@ -26,7 +25,7 @@
 
   static {
     permissions = new HashMap<>();
-    for (Map.Entry<String, String> e : Util.C.permissionNames().entrySet()) {
+    for (Map.Entry<String, String> e : AdminConstants.I.permissionNames().entrySet()) {
       permissions.put(e.getKey(), e.getValue());
       permissions.put(e.getKey().toLowerCase(), e.getValue());
     }
@@ -41,9 +40,9 @@
   @Override
   public String render(String varName) {
     if (Permission.isLabelAs(varName)) {
-      return Util.M.labelAs(Permission.extractLabel(varName));
+      return AdminMessages.I.labelAs(Permission.extractLabel(varName));
     } else if (Permission.isLabel(varName)) {
-      return Util.M.label(Permission.extractLabel(varName));
+      return AdminMessages.I.label(Permission.extractLabel(varName));
     }
 
     String desc = permissions.get(varName);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java
index f66307c..16dd167 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PermissionRuleEditor.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
 import static com.google.gerrit.common.data.Permission.PUSH;
-import static com.google.gerrit.common.data.Permission.PUSH_TAG;
 
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
@@ -48,16 +47,14 @@
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.ValueListBox;
-
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 
-public class PermissionRuleEditor extends Composite implements
-    Editor<PermissionRule>, ValueAwareEditor<PermissionRule> {
-  interface Binder extends UiBinder<HTMLPanel, PermissionRuleEditor> {
-  }
+public class PermissionRuleEditor extends Composite
+    implements Editor<PermissionRule>, ValueAwareEditor<PermissionRule> {
+  interface Binder extends UiBinder<HTMLPanel, PermissionRuleEditor> {}
 
   private static final Binder uiBinder = GWT.create(Binder.class);
 
@@ -70,32 +67,25 @@
   @UiField(provided = true)
   RangeBox max;
 
-  @UiField
-  CheckBox force;
+  @UiField CheckBox force;
 
-  @UiField
-  Anchor groupNameLink;
-  @UiField
-  SpanElement groupNameSpan;
-  @UiField
-  SpanElement deletedGroupName;
+  @UiField Anchor groupNameLink;
+  @UiField SpanElement groupNameSpan;
+  @UiField SpanElement deletedGroupName;
 
-  @UiField
-  Anchor deleteRule;
+  @UiField Anchor deleteRule;
 
-  @UiField
-  DivElement normal;
-  @UiField
-  DivElement deleted;
+  @UiField DivElement normal;
+  @UiField DivElement deleted;
 
-  @UiField
-  SpanElement rangeEditor;
+  @UiField SpanElement rangeEditor;
 
   private Map<AccountGroup.UUID, GroupInfo> groupInfo;
   private boolean isDeleted;
   private HandlerRegistration clickHandler;
 
-  public PermissionRuleEditor(boolean readOnly,
+  public PermissionRuleEditor(
+      boolean readOnly,
       Map<AccountGroup.UUID, GroupInfo> groupInfo,
       AccessSection section,
       Permission permission,
@@ -104,8 +94,8 @@
     action = new ValueListBox<>(actionRenderer);
 
     if (validRange != null && 10 < validRange.getRangeSize()) {
-        min = new RangeBox.Box();
-        max = new RangeBox.Box();
+      min = new RangeBox.Box();
+      max = new RangeBox.Box();
 
     } else if (validRange != null) {
       RangeBox.List minList = new RangeBox.List();
@@ -121,29 +111,35 @@
       min = minList;
       max = maxList;
 
+      action.setAcceptableValues(
+          Arrays.asList(
+              PermissionRule.Action.ALLOW,
+              PermissionRule.Action.DENY,
+              PermissionRule.Action.BLOCK));
+
     } else {
       min = new RangeBox.Box();
       max = new RangeBox.Box();
 
       if (GlobalCapability.PRIORITY.equals(permission.getName())) {
         action.setValue(PermissionRule.Action.INTERACTIVE);
-        action.setAcceptableValues(Arrays.asList(
-            PermissionRule.Action.INTERACTIVE,
-            PermissionRule.Action.BATCH));
+        action.setAcceptableValues(
+            Arrays.asList(PermissionRule.Action.INTERACTIVE, PermissionRule.Action.BATCH));
 
       } else {
         action.setValue(PermissionRule.Action.ALLOW);
-        action.setAcceptableValues(Arrays.asList(
-            PermissionRule.Action.ALLOW,
-            PermissionRule.Action.DENY,
-            PermissionRule.Action.BLOCK));
+        action.setAcceptableValues(
+            Arrays.asList(
+                PermissionRule.Action.ALLOW,
+                PermissionRule.Action.DENY,
+                PermissionRule.Action.BLOCK));
       }
     }
 
     initWidget(uiBinder.createAndBindUi(this));
 
     String name = permission.getName();
-    boolean canForce = PUSH.equals(name) || PUSH_TAG.equals(name);
+    boolean canForce = PUSH.equals(name);
     if (canForce) {
       String ref = section.getName();
       canForce = !ref.startsWith("refs/for/") && !ref.startsWith("^refs/for/");
@@ -154,15 +150,13 @@
     }
     force.setVisible(canForce);
     force.setEnabled(!readOnly);
+    action.getElement().setPropertyBoolean("disabled", readOnly);
 
     if (validRange != null) {
       min.setEnabled(!readOnly);
       max.setEnabled(!readOnly);
-      action.getElement().getStyle().setDisplay(Display.NONE);
-
     } else {
       rangeEditor.getStyle().setDisplay(Display.NONE);
-      action.getElement().setPropertyBoolean("disabled", readOnly);
     }
 
     if (readOnly) {
@@ -201,9 +195,8 @@
     }
 
     GroupReference ref = value.getGroup();
-    GroupInfo info = groupInfo != null && ref.getUUID() != null
-        ? groupInfo.get(ref.getUUID())
-        : null;
+    GroupInfo info =
+        groupInfo != null && ref.getUUID() != null ? groupInfo.get(ref.getUUID()) : null;
 
     boolean link;
     if (ref.getUUID() != null && AccountGroup.isInternalGroup(ref.getUUID())) {
@@ -212,14 +205,16 @@
       groupNameLink.setHref("#" + token);
       groupNameLink.setTitle(info != null ? info.getDescription() : null);
       groupNameLink.setTarget(null);
-      clickHandler = groupNameLink.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          event.preventDefault();
-          event.stopPropagation();
-          Gerrit.display(token);
-        }
-      });
+      clickHandler =
+          groupNameLink.addClickHandler(
+              new ClickHandler() {
+                @Override
+                public void onClick(ClickEvent event) {
+                  event.preventDefault();
+                  event.stopPropagation();
+                  Gerrit.display(token);
+                }
+              });
       link = true;
     } else if (info != null && info.getUrl() != null) {
       groupNameLink.setText(ref.getName());
@@ -239,27 +234,22 @@
   }
 
   @Override
-  public void setDelegate(EditorDelegate<PermissionRule> delegate) {
-  }
+  public void setDelegate(EditorDelegate<PermissionRule> delegate) {}
 
   @Override
-  public void flush() {
-  }
+  public void flush() {}
 
   @Override
-  public void onPropertyChange(String... paths) {
-  }
+  public void onPropertyChange(String... paths) {}
 
-  private static class ActionRenderer implements
-      Renderer<PermissionRule.Action> {
+  private static class ActionRenderer implements Renderer<PermissionRule.Action> {
     @Override
     public String render(PermissionRule.Action object) {
       return object != null ? object.toString() : "";
     }
 
     @Override
-    public void render(PermissionRule.Action object, Appendable appendable)
-        throws IOException {
+    public void render(PermissionRule.Action object, Appendable appendable) throws IOException {
       appendable.append(render(object));
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java
index 4dcb52f..8a70f2e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java
@@ -42,12 +42,13 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    PluginMap.all(new ScreenLoadCallback<PluginMap>(this) {
-      @Override
-      protected void preDisplay(final PluginMap result) {
-        pluginTable.display(result);
-      }
-    });
+    PluginMap.all(
+        new ScreenLoadCallback<PluginMap>(this) {
+          @Override
+          protected void preDisplay(final PluginMap result) {
+            pluginTable.display(result);
+          }
+        });
   }
 
   private void initPluginList() {
@@ -62,10 +63,10 @@
 
   private static class PluginTable extends FancyFlexTable<PluginInfo> {
     PluginTable() {
-      table.setText(0, 1, Util.C.columnPluginName());
-      table.setText(0, 2, Util.C.columnPluginSettings());
-      table.setText(0, 3, Util.C.columnPluginVersion());
-      table.setText(0, 4, Util.C.columnPluginStatus());
+      table.setText(0, 1, AdminConstants.I.columnPluginName());
+      table.setText(0, 2, AdminConstants.I.columnPluginSettings());
+      table.setText(0, 3, AdminConstants.I.columnPluginVersion());
+      table.setText(0, 4, AdminConstants.I.columnPluginStatus());
 
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
       fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
@@ -91,22 +92,23 @@
       if (plugin.disabled() || plugin.indexUrl() == null) {
         table.setText(row, 1, plugin.name());
       } else {
-        table.setWidget(row, 1, new Anchor(plugin.name(),
-            Gerrit.selfRedirect(plugin.indexUrl()), "_blank"));
+        table.setWidget(
+            row, 1, new Anchor(plugin.name(), Gerrit.selfRedirect(plugin.indexUrl()), "_blank"));
 
         if (new ExtensionScreen(plugin.name() + "/settings").isFound()) {
           InlineHyperlink adminScreenLink = new InlineHyperlink();
           adminScreenLink.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.gear()));
           adminScreenLink.setTargetHistoryToken("/x/" + plugin.name() + "/settings");
-          adminScreenLink.setTitle(Util.C.pluginSettingsToolTip());
+          adminScreenLink.setTitle(AdminConstants.I.pluginSettingsToolTip());
           table.setWidget(row, 2, adminScreenLink);
         }
       }
 
       table.setText(row, 3, plugin.version());
-      table.setText(row, 4, plugin.disabled()
-          ? Util.C.pluginDisabled()
-          : Util.C.pluginEnabled());
+      table.setText(
+          row,
+          4,
+          plugin.disabled() ? AdminConstants.I.pluginDisabled() : AdminConstants.I.pluginEnabled());
 
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
       fmt.addStyleName(row, 1, Gerrit.RESOURCES.css().dataCell());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginScreen.java
index dabcb45..0909fe1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginScreen.java
@@ -25,7 +25,7 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    setPageTitle(Util.C.plugins());
+    setPageTitle(AdminConstants.I.plugins());
     display();
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
index f29619a..05142c4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessEditor.java
@@ -38,39 +38,29 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.Image;
-
 import java.util.ArrayList;
 import java.util.List;
 
-public class ProjectAccessEditor extends Composite implements
-    Editor<ProjectAccess>, ValueAwareEditor<ProjectAccess> {
-  interface Binder extends UiBinder<HTMLPanel, ProjectAccessEditor> {
-  }
+public class ProjectAccessEditor extends Composite
+    implements Editor<ProjectAccess>, ValueAwareEditor<ProjectAccess> {
+  interface Binder extends UiBinder<HTMLPanel, ProjectAccessEditor> {}
 
   private static final Binder uiBinder = GWT.create(Binder.class);
 
-  @UiField
-  DivElement inheritsFrom;
+  @UiField DivElement inheritsFrom;
 
-  @UiField
-  Hyperlink parentProject;
+  @UiField Hyperlink parentProject;
 
-  @UiField
-  @Editor.Ignore
-  ParentProjectBox parentProjectBox;
+  @UiField @Editor.Ignore ParentProjectBox parentProjectBox;
 
-  @UiField
-  DivElement history;
+  @UiField DivElement history;
 
-  @UiField
-  FlowPanel webLinkPanel;
+  @UiField FlowPanel webLinkPanel;
 
-  @UiField
-  FlowPanel localContainer;
+  @UiField FlowPanel localContainer;
   ListEditor<AccessSection, AccessSectionEditor> local;
 
-  @UiField
-  Anchor addSection;
+  @UiField Anchor addSection;
 
   private ProjectAccess value;
 
@@ -139,12 +129,10 @@
   }
 
   @Override
-  public void onPropertyChange(String... paths) {
-  }
+  public void onPropertyChange(String... paths) {}
 
   @Override
-  public void setDelegate(EditorDelegate<ProjectAccess> delegate) {
-  }
+  public void setDelegate(EditorDelegate<ProjectAccess> delegate) {}
 
   void setEditing(final boolean editing) {
     this.editing = editing;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
index 6345207..0398e9d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectAccessScreen.java
@@ -48,7 +48,6 @@
 import com.google.gwt.user.client.ui.VerticalPanel;
 import com.google.gwtexpui.globalkey.client.NpTextArea;
 import com.google.gwtjsonrpc.client.RemoteJsonException;
-
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -57,45 +56,34 @@
 import java.util.Set;
 
 public class ProjectAccessScreen extends ProjectScreen {
-  interface Binder extends UiBinder<HTMLPanel, ProjectAccessScreen> {
-  }
+  interface Binder extends UiBinder<HTMLPanel, ProjectAccessScreen> {}
 
   private static final Binder uiBinder = GWT.create(Binder.class);
 
-  interface Driver extends SimpleBeanEditorDriver< //
-      ProjectAccess, //
-      ProjectAccessEditor> {
-  }
+  interface Driver
+      extends SimpleBeanEditorDriver< //
+          ProjectAccess, //
+          ProjectAccessEditor> {}
 
-  @UiField
-  DivElement editTools;
+  @UiField DivElement editTools;
 
-  @UiField
-  Button edit;
+  @UiField Button edit;
 
-  @UiField
-  Button cancel1;
+  @UiField Button cancel1;
 
-  @UiField
-  Button cancel2;
+  @UiField Button cancel2;
 
-  @UiField
-  VerticalPanel error;
+  @UiField VerticalPanel error;
 
-  @UiField
-  ProjectAccessEditor accessEditor;
+  @UiField ProjectAccessEditor accessEditor;
 
-  @UiField
-  DivElement commitTools;
+  @UiField DivElement commitTools;
 
-  @UiField
-  NpTextArea commitMessage;
+  @UiField NpTextArea commitMessage;
 
-  @UiField
-  Button commit;
+  @UiField Button commit;
 
-  @UiField
-  Button review;
+  @UiField Button review;
 
   private Driver driver;
 
@@ -122,24 +110,27 @@
     super.onLoad();
     CallbackGroup cbs = new CallbackGroup();
     ConfigServerApi.capabilities(
-        cbs.add(new AsyncCallback<NativeMap<CapabilityInfo>>() {
-          @Override
-          public void onSuccess(NativeMap<CapabilityInfo> result) {
-            capabilityMap = result;
-          }
+        cbs.add(
+            new AsyncCallback<NativeMap<CapabilityInfo>>() {
+              @Override
+              public void onSuccess(NativeMap<CapabilityInfo> result) {
+                capabilityMap = result;
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-            // Handled by ScreenLoadCallback.onFailure().
-          }
-        }));
-    Util.PROJECT_SVC.projectAccess(getProjectKey(),
-        cbs.addFinal(new ScreenLoadCallback<ProjectAccess>(this) {
-          @Override
-          public void preDisplay(ProjectAccess access) {
-            displayReadOnly(access);
-          }
-        }));
+              @Override
+              public void onFailure(Throwable caught) {
+                // Handled by ScreenLoadCallback.onFailure().
+              }
+            }));
+    Util.PROJECT_SVC.projectAccess(
+        getProjectKey(),
+        cbs.addFinal(
+            new ScreenLoadCallback<ProjectAccess>(this) {
+              @Override
+              public void preDisplay(ProjectAccess access) {
+                displayReadOnly(access);
+              }
+            }));
     savedPanel = ACCESS;
   }
 
@@ -178,8 +169,8 @@
     ProjectAccess mock = new ProjectAccess();
     mock.setProjectName(access.getProjectName());
     mock.setRevision(access.getRevision());
-    mock.setLocal(Collections.<AccessSection> emptyList());
-    mock.setOwnerOf(Collections.<String> emptySet());
+    mock.setLocal(Collections.<AccessSection>emptyList());
+    mock.setOwnerOf(Collections.<String>emptySet());
     driver.edit(mock);
   }
 
@@ -193,7 +184,7 @@
     final ProjectAccess access = driver.flush();
 
     if (driver.hasErrors()) {
-      Window.alert(Util.C.errorsMustBeFixed());
+      Window.alert(AdminConstants.I.errorsMustBeFixed());
       return;
     }
 
@@ -229,8 +220,7 @@
             }
           }
 
-          private Set<String> getDiffs(ProjectAccess wantedAccess,
-              ProjectAccess newAccess) {
+          private Set<String> getDiffs(ProjectAccess wantedAccess, ProjectAccess newAccess) {
             List<AccessSection> wantedSections =
                 mergeSections(removeEmptyPermissionsAndSections(wantedAccess.getLocal()));
             List<AccessSection> newSections =
@@ -255,10 +245,12 @@
             error.clear();
             enable(true);
             if (caught instanceof RemoteJsonException
-                && caught.getMessage().startsWith(
-                    UpdateParentFailedException.MESSAGE)) {
-              new ErrorDialog(Gerrit.M.parentUpdateFailed(caught.getMessage()
-                  .substring(UpdateParentFailedException.MESSAGE.length() + 1)))
+                && caught.getMessage().startsWith(UpdateParentFailedException.MESSAGE)) {
+              new ErrorDialog(
+                      Gerrit.M.parentUpdateFailed(
+                          caught
+                              .getMessage()
+                              .substring(UpdateParentFailedException.MESSAGE.length() + 1)))
                   .center();
             } else {
               super.onFailure(caught);
@@ -272,7 +264,7 @@
     final ProjectAccess access = driver.flush();
 
     if (driver.hasErrors()) {
-      Window.alert(Util.C.errorsMustBeFixed());
+      Window.alert(AdminConstants.I.errorsMustBeFixed());
       return;
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java
index 31edefc..8ff1164 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectBranchesScreen.java
@@ -66,7 +66,6 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -96,7 +95,8 @@
   protected void onLoad() {
     super.onLoad();
     addPanel.setVisible(false);
-    AccessMap.get(getProjectKey(),
+    AccessMap.get(
+        getProjectKey(),
         new GerritCallback<ProjectAccessInfo>() {
           @Override
           public void onSuccess(ProjectAccessInfo result) {
@@ -133,52 +133,56 @@
 
     nameTxtBox = new HintTextBox();
     nameTxtBox.setVisibleLength(texBoxLength);
-    nameTxtBox.setHintText(Util.C.defaultBranchName());
-    nameTxtBox.addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-          doAddNewBranch();
-        }
-      }
-    });
-    addGrid.setText(0, 0, Util.C.columnBranchName() + ":");
+    nameTxtBox.setHintText(AdminConstants.I.defaultBranchName());
+    nameTxtBox.addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+              doAddNewBranch();
+            }
+          }
+        });
+    addGrid.setText(0, 0, AdminConstants.I.columnBranchName() + ":");
     addGrid.setWidget(0, 1, nameTxtBox);
 
     irevTxtBox = new HintTextBox();
     irevTxtBox.setVisibleLength(texBoxLength);
-    irevTxtBox.setHintText(Util.C.defaultRevisionSpec());
-    irevTxtBox.addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-          doAddNewBranch();
-        }
-      }
-    });
-    addGrid.setText(1, 0, Util.C.initialRevision() + ":");
+    irevTxtBox.setHintText(AdminConstants.I.defaultRevisionSpec());
+    irevTxtBox.addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+              doAddNewBranch();
+            }
+          }
+        });
+    addGrid.setText(1, 0, AdminConstants.I.initialRevision() + ":");
     addGrid.setWidget(1, 1, irevTxtBox);
 
-    addBranch = new Button(Util.C.buttonAddBranch());
-    addBranch.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        doAddNewBranch();
-      }
-    });
+    addBranch = new Button(AdminConstants.I.buttonAddBranch());
+    addBranch.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            doAddNewBranch();
+          }
+        });
     addPanel.add(addGrid);
     addPanel.add(addBranch);
 
     branchTable = new BranchesTable();
 
-    delBranch = new Button(Util.C.buttonDeleteBranch());
+    delBranch = new Button(AdminConstants.I.buttonDeleteBranch());
     delBranch.setStyleName(Gerrit.RESOURCES.css().branchTableDeleteButton());
-    delBranch.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        branchTable.deleteChecked();
-      }
-    });
+    delBranch.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            branchTable.deleteChecked();
+          }
+        });
     HorizontalPanel buttons = new HorizontalPanel();
     buttons.setStyleName(Gerrit.RESOURCES.css().branchTablePrevNextLinks());
     buttons.add(delBranch);
@@ -193,25 +197,26 @@
     parseToken();
     HorizontalPanel hp = new HorizontalPanel();
     hp.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    Label filterLabel = new Label(Util.C.projectFilter());
+    Label filterLabel = new Label(AdminConstants.I.projectFilter());
     filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
     hp.add(filterLabel);
     filterTxt = new NpTextBox();
     filterTxt.setValue(match);
-    filterTxt.addKeyUpHandler(new KeyUpHandler() {
-      @Override
-      public void onKeyUp(KeyUpEvent event) {
-        Query q = new Query(filterTxt.getValue());
-        if (match.equals(q.qMatch)) {
-          q.start(start);
-        } else {
-          if (query == null) {
-            q.run();
+    filterTxt.addKeyUpHandler(
+        new KeyUpHandler() {
+          @Override
+          public void onKeyUp(KeyUpEvent event) {
+            Query q = new Query(filterTxt.getValue());
+            if (match.equals(q.qMatch)) {
+              q.start(start);
+            } else {
+              if (query == null) {
+                q.run();
+              }
+              query = q;
+            }
           }
-          query = q;
-        }
-      }
-    });
+        });
     hp.add(filterTxt);
     add(hp);
   }
@@ -226,18 +231,23 @@
     final String rev = irevTxtBox.getText().trim();
     if ("".equals(rev)) {
       irevTxtBox.setText("HEAD");
-      Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-        @Override
-        public void execute() {
-          irevTxtBox.selectAll();
-          irevTxtBox.setFocus(true);
-        }
-      });
+      Scheduler.get()
+          .scheduleDeferred(
+              new ScheduledCommand() {
+                @Override
+                public void execute() {
+                  irevTxtBox.selectAll();
+                  irevTxtBox.setFocus(true);
+                }
+              });
       return;
     }
 
     addBranch.setEnabled(false);
-    ProjectApi.createBranch(getProjectKey(), branchName, rev,
+    ProjectApi.createBranch(
+        getProjectKey(),
+        branchName,
+        rev,
         new GerritCallback<BranchInfo>() {
           @Override
           public void onSuccess(BranchInfo branch) {
@@ -267,13 +277,15 @@
     b.closeElement("p");
 
     ConfirmationDialog confirmationDialog =
-        new ConfirmationDialog(Gerrit.C.branchCreationDialogTitle(),
-            b.toSafeHtml(), new ConfirmationCallback() {
-      @Override
-      public void onOk() {
-        //do nothing
-      }
-    });
+        new ConfirmationDialog(
+            Gerrit.C.branchCreationDialogTitle(),
+            b.toSafeHtml(),
+            new ConfirmationCallback() {
+              @Override
+              public void onOk() {
+                // do nothing
+              }
+            });
     confirmationDialog.center();
     confirmationDialog.setCancelVisible(false);
   }
@@ -289,8 +301,8 @@
 
     BranchesTable() {
       table.setWidth("");
-      table.setText(0, 2, Util.C.columnBranchName());
-      table.setText(0, 3, Util.C.columnBranchRevision());
+      table.setText(0, 2, AdminConstants.I.columnBranchName());
+      table.setText(0, 3, AdminConstants.I.columnBranchRevision());
 
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
       fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
@@ -298,19 +310,21 @@
       fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
       fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
 
-      updateDeleteHandler = new ValueChangeHandler<Boolean>() {
-        @Override
-        public void onValueChange(ValueChangeEvent<Boolean> event) {
-          updateDeleteButton();
-        }
-      };
+      updateDeleteHandler =
+          new ValueChangeHandler<Boolean>() {
+            @Override
+            public void onValueChange(ValueChangeEvent<Boolean> event) {
+              updateDeleteButton();
+            }
+          };
     }
 
     Set<String> getCheckedRefs() {
       Set<String> refs = new HashSet<>();
       for (int row = 1; row < table.getRowCount(); row++) {
         final BranchInfo k = getRowItem(row);
-        if (k != null && table.getWidget(row, 1) instanceof CheckBox
+        if (k != null
+            && table.getWidget(row, 1) instanceof CheckBox
             && ((CheckBox) table.getWidget(row, 1)).getValue()) {
           refs.add(k.ref());
         }
@@ -321,8 +335,7 @@
     void setChecked(Set<String> refs) {
       for (int row = 1; row < table.getRowCount(); row++) {
         final BranchInfo k = getRowItem(row);
-        if (k != null && refs.contains(k.ref()) &&
-            table.getWidget(row, 1) instanceof CheckBox) {
+        if (k != null && refs.contains(k.ref()) && table.getWidget(row, 1) instanceof CheckBox) {
           ((CheckBox) table.getWidget(row, 1)).setValue(true);
         }
       }
@@ -354,23 +367,27 @@
 
       delBranch.setEnabled(false);
       ConfirmationDialog confirmationDialog =
-          new ConfirmationDialog(Gerrit.C.branchDeletionDialogTitle(),
-              b.toSafeHtml(), new ConfirmationCallback() {
-        @Override
-        public void onOk() {
-          deleteBranches(refs);
-        }
+          new ConfirmationDialog(
+              Gerrit.C.branchDeletionDialogTitle(),
+              b.toSafeHtml(),
+              new ConfirmationCallback() {
+                @Override
+                public void onOk() {
+                  deleteBranches(refs);
+                }
 
-        @Override
-        public void onCancel() {
-          branchTable.updateDeleteButton();
-        }
-      });
+                @Override
+                public void onCancel() {
+                  branchTable.updateDeleteButton();
+                }
+              });
       confirmationDialog.center();
     }
 
     private void deleteBranches(final Set<String> branches) {
-      ProjectApi.deleteBranches(getProjectKey(), branches,
+      ProjectApi.deleteBranches(
+          getProjectKey(),
+          branches,
           new GerritCallback<VoidResult>() {
             @Override
             public void onSuccess(VoidResult result) {
@@ -443,8 +460,7 @@
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
       String iconCellStyle = Gerrit.RESOURCES.css().iconCell();
       String dataCellStyle = Gerrit.RESOURCES.css().dataCell();
-      if (RefNames.REFS_CONFIG.equals(k.getShortName())
-          || "HEAD".equals(k.getShortName())) {
+      if (RefNames.REFS_CONFIG.equals(k.getShortName()) || "HEAD".equals(k.getShortName())) {
         iconCellStyle = Gerrit.RESOURCES.css().specialBranchIconCell();
         dataCellStyle = Gerrit.RESOURCES.css().specialBranchDataCell();
         fmt.setStyleName(row, 0, iconCellStyle);
@@ -457,9 +473,9 @@
       setRowItem(row, k);
     }
 
-    private void setHeadRevision(final int row, final int column,
-        final String rev) {
-      AccessMap.get(getProjectKey(),
+    private void setHeadRevision(final int row, final int column, final String rev) {
+      AccessMap.get(
+          getProjectKey(),
           new GerritCallback<ProjectAccessInfo>() {
             @Override
             public void onSuccess(ProjectAccessInfo result) {
@@ -483,57 +499,62 @@
       input.setValue(headRevision);
       input.setVisible(false);
       final Button save = new Button();
-      save.setText(Util.C.saveHeadButton());
+      save.setText(AdminConstants.I.saveHeadButton());
       save.setVisible(false);
       save.setEnabled(false);
       final Button cancel = new Button();
-      cancel.setText(Util.C.cancelHeadButton());
+      cancel.setText(AdminConstants.I.cancelHeadButton());
       cancel.setVisible(false);
 
       OnEditEnabler e = new OnEditEnabler(save);
       e.listenTo(input);
 
-      edit.addClickHandler(new  ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          l.setVisible(false);
-          edit.setVisible(false);
-          input.setVisible(true);
-          save.setVisible(true);
-          cancel.setVisible(true);
-        }
-      });
-      save.addClickHandler(new  ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          save.setEnabled(false);
-          ProjectApi.setHead(getProjectKey(), input.getValue().trim(),
-              new GerritCallback<NativeString>() {
+      edit.addClickHandler(
+          new ClickHandler() {
             @Override
-            public void onSuccess(NativeString result) {
-              Gerrit.display(PageLinks.toProjectBranches(getProjectKey()));
-            }
-
-            @Override
-            public void onFailure(Throwable caught) {
-              super.onFailure(caught);
-              save.setEnabled(true);
+            public void onClick(ClickEvent event) {
+              l.setVisible(false);
+              edit.setVisible(false);
+              input.setVisible(true);
+              save.setVisible(true);
+              cancel.setVisible(true);
             }
           });
-        }
-      });
-      cancel.addClickHandler(new  ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          l.setVisible(true);
-          edit.setVisible(true);
-          input.setVisible(false);
-          input.setValue(headRevision);
-          save.setVisible(false);
-          save.setEnabled(false);
-          cancel.setVisible(false);
-        }
-      });
+      save.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(ClickEvent event) {
+              save.setEnabled(false);
+              ProjectApi.setHead(
+                  getProjectKey(),
+                  input.getValue().trim(),
+                  new GerritCallback<NativeString>() {
+                    @Override
+                    public void onSuccess(NativeString result) {
+                      Gerrit.display(PageLinks.toProjectBranches(getProjectKey()));
+                    }
+
+                    @Override
+                    public void onFailure(Throwable caught) {
+                      super.onFailure(caught);
+                      save.setEnabled(true);
+                    }
+                  });
+            }
+          });
+      cancel.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(ClickEvent event) {
+              l.setVisible(true);
+              edit.setVisible(true);
+              input.setVisible(false);
+              input.setValue(headRevision);
+              save.setVisible(false);
+              save.setEnabled(false);
+              cancel.setVisible(false);
+            }
+          });
 
       p.add(l);
       p.add(edit);
@@ -600,19 +621,23 @@
     Query run() {
       // Retrieve one more branch than page size to determine if there are more
       // branches to display
-      ProjectApi.getBranches(getProjectKey(), pageSize + 1, qStart, qMatch,
-              new ScreenLoadCallback<JsArray<BranchInfo>>(ProjectBranchesScreen.this) {
-                @Override
-                public void preDisplay(JsArray<BranchInfo> result) {
-                  if (!isAttached()) {
-                    // View has been disposed.
-                  } else if (query == Query.this) {
-                    query = null;
-                    showList(result);
-                  } else {
-                    query.run();
-                  }
-                }
+      ProjectApi.getBranches(
+          getProjectKey(),
+          pageSize + 1,
+          qStart,
+          qMatch,
+          new ScreenLoadCallback<JsArray<BranchInfo>>(ProjectBranchesScreen.this) {
+            @Override
+            public void preDisplay(JsArray<BranchInfo> result) {
+              if (!isAttached()) {
+                // View has been disposed.
+              } else if (query == Query.this) {
+                query = null;
+                showList(result);
+              } else {
+                query.run();
+              }
+            }
           });
       return this;
     }
@@ -626,8 +651,7 @@
         branchTable.display(Natives.asList(result));
         next.setVisible(false);
       } else {
-        branchTable.displaySubset(Natives.asList(result), 0,
-            result.length() - 1);
+        branchTable.displaySubset(Natives.asList(result), 0, result.length() - 1);
         setupNavigationLink(next, qMatch, qStart + pageSize);
       }
       if (qStart > 0) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java
index f779861..52fe3399 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java
@@ -33,13 +33,14 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    DashboardList.all(getProjectKey(),
+    DashboardList.all(
+        getProjectKey(),
         new ScreenLoadCallback<JsArray<DashboardList>>(this) {
-      @Override
-      protected void preDisplay(JsArray<DashboardList> result) {
-        dashes.display(result);
-      }
-    });
+          @Override
+          protected void preDisplay(JsArray<DashboardList> result) {
+            dashes.display(result);
+          }
+        });
     savedPanel = DASHBOARDS;
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index e1cfa90..2e4054e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -62,7 +62,6 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.globalkey.client.NpTextArea;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -72,6 +71,7 @@
 
 public class ProjectInfoScreen extends ProjectScreen {
   private boolean isOwner;
+  private boolean configVisible;
 
   private LabeledWidgetsGrid grid;
   private Panel pluginOptionsPanel;
@@ -108,19 +108,19 @@
     super.onInitUI();
 
     Resources.I.style().ensureInjected();
-    saveProject = new Button(Util.C.buttonSaveChanges());
+    saveProject = new Button(AdminConstants.I.buttonSaveChanges());
     saveProject.setStyleName("");
-    saveProject.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        doSave();
-      }
-    });
+    saveProject.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            doSave();
+          }
+        });
 
     ExtensionPanel extensionPanelTop =
         new ExtensionPanel(GerritUiExtensionPoint.PROJECT_INFO_SCREEN_TOP);
-    extensionPanelTop.put(GerritUiExtensionPoint.Key.PROJECT_NAME,
-        getProjectKey().get());
+    extensionPanelTop.put(GerritUiExtensionPoint.Key.PROJECT_NAME, getProjectKey().get());
     add(extensionPanelTop);
 
     add(new ProjectDownloadPanel(getProjectKey().get(), true));
@@ -138,8 +138,7 @@
 
     ExtensionPanel extensionPanelBottom =
         new ExtensionPanel(GerritUiExtensionPoint.PROJECT_INFO_SCREEN_BOTTOM);
-    extensionPanelBottom.put(GerritUiExtensionPoint.Key.PROJECT_NAME,
-        getProjectKey().get());
+    extensionPanelBottom.put(GerritUiExtensionPoint.Key.PROJECT_NAME, getProjectKey().get());
     add(extensionPanelBottom);
   }
 
@@ -149,22 +148,27 @@
 
     Project.NameKey project = getProjectKey();
     CallbackGroup cbg = new CallbackGroup();
-    AccessMap.get(project,
-        cbg.add(new GerritCallback<ProjectAccessInfo>() {
-          @Override
-          public void onSuccess(ProjectAccessInfo result) {
-            isOwner = result.isOwner();
-            enableForm();
-            saveProject.setVisible(isOwner);
-          }
-        }));
-    ProjectApi.getConfig(project,
-        cbg.addFinal(new ScreenLoadCallback<ConfigInfo>(this) {
-          @Override
-          public void preDisplay(ConfigInfo result) {
-            display(result);
-          }
-        }));
+    AccessMap.get(
+        project,
+        cbg.add(
+            new GerritCallback<ProjectAccessInfo>() {
+              @Override
+              public void onSuccess(ProjectAccessInfo result) {
+                isOwner = result.isOwner();
+                configVisible = result.configVisible();
+                enableForm();
+                saveProject.setVisible(isOwner);
+              }
+            }));
+    ProjectApi.getConfig(
+        project,
+        cbg.addFinal(
+            new ScreenLoadCallback<ConfigInfo>(this) {
+              @Override
+              public void preDisplay(ConfigInfo result) {
+                display(result);
+              }
+            }));
 
     savedPanel = INFO;
   }
@@ -181,6 +185,9 @@
     if (enableSignedPush != null) {
       enableSignedPush.setEnabled(isOwner);
     }
+    if (requireSignedPush != null) {
+      requireSignedPush.setEnabled(isOwner);
+    }
     descTxt.setEnabled(isOwner);
     contributorAgreements.setEnabled(isOwner);
     signedOffBy.setEnabled(isOwner);
@@ -199,7 +206,7 @@
 
   private void initDescription() {
     final VerticalPanel vp = new VerticalPanel();
-    vp.add(new SmallHeading(Util.C.headingDescription()));
+    vp.add(new SmallHeading(AdminConstants.I.headingDescription()));
 
     descTxt = new NpTextArea();
     descTxt.setVisibleLines(6);
@@ -212,52 +219,53 @@
   }
 
   private void initProjectOptions() {
-    grid.addHeader(new SmallHeading(Util.C.headingProjectOptions()));
+    grid.addHeader(new SmallHeading(AdminConstants.I.headingProjectOptions()));
 
     state = new ListBox();
     for (ProjectState stateValue : ProjectState.values()) {
       state.addItem(Util.toLongString(stateValue), stateValue.name());
     }
     saveEnabler.listenTo(state);
-    grid.add(Util.C.headingProjectState(), state);
+    grid.add(AdminConstants.I.headingProjectState(), state);
 
     submitType = new ListBox();
     for (final SubmitType type : SubmitType.values()) {
       submitType.addItem(Util.toLongString(type), type.name());
     }
-    submitType.addChangeHandler(new ChangeHandler() {
-      @Override
-      public void onChange(ChangeEvent event) {
-        setEnabledForUseContentMerge();
-      }
-    });
+    submitType.addChangeHandler(
+        new ChangeHandler() {
+          @Override
+          public void onChange(ChangeEvent event) {
+            setEnabledForUseContentMerge();
+          }
+        });
     saveEnabler.listenTo(submitType);
-    grid.add(Util.C.headingProjectSubmitType(), submitType);
+    grid.add(AdminConstants.I.headingProjectSubmitType(), submitType);
 
     contentMerge = newInheritedBooleanBox();
     saveEnabler.listenTo(contentMerge);
-    grid.add(Util.C.useContentMerge(), contentMerge);
+    grid.add(AdminConstants.I.useContentMerge(), contentMerge);
 
     newChangeForAllNotInTarget = newInheritedBooleanBox();
     saveEnabler.listenTo(newChangeForAllNotInTarget);
-    grid.add(Util.C.createNewChangeForAllNotInTarget(), newChangeForAllNotInTarget);
+    grid.add(AdminConstants.I.createNewChangeForAllNotInTarget(), newChangeForAllNotInTarget);
 
     requireChangeID = newInheritedBooleanBox();
     saveEnabler.listenTo(requireChangeID);
-    grid.addHtml(Util.C.requireChangeID(), requireChangeID);
+    grid.addHtml(AdminConstants.I.requireChangeID(), requireChangeID);
 
     if (Gerrit.info().receive().enableSignedPush()) {
       enableSignedPush = newInheritedBooleanBox();
       saveEnabler.listenTo(enableSignedPush);
-      grid.add(Util.C.enableSignedPush(), enableSignedPush);
+      grid.add(AdminConstants.I.enableSignedPush(), enableSignedPush);
       requireSignedPush = newInheritedBooleanBox();
       saveEnabler.listenTo(requireSignedPush);
-      grid.add(Util.C.requireSignedPush(), requireSignedPush);
+      grid.add(AdminConstants.I.requireSignedPush(), requireSignedPush);
     }
 
     rejectImplicitMerges = newInheritedBooleanBox();
     saveEnabler.listenTo(rejectImplicitMerges);
-    grid.addHtml(Util.C.rejectImplicitMerges(), rejectImplicitMerges);
+    grid.addHtml(AdminConstants.I.rejectImplicitMerges(), rejectImplicitMerges);
 
     maxObjectSizeLimit = new NpTextBox();
     saveEnabler.listenTo(maxObjectSizeLimit);
@@ -267,7 +275,7 @@
     HorizontalPanel p = new HorizontalPanel();
     p.add(maxObjectSizeLimit);
     p.add(effectiveMaxObjectSizeLimit);
-    grid.addHtml(Util.C.headingMaxObjectSizeLimit(), p);
+    grid.addHtml(AdminConstants.I.headingMaxObjectSizeLimit(), p);
   }
 
   private static ListBox newInheritedBooleanBox() {
@@ -279,14 +287,13 @@
   }
 
   /**
-   * Enables the {@link #contentMerge} checkbox if the selected submit type
-   * allows the usage of content merge.
-   * If the submit type (currently only 'Fast Forward Only') does not allow
-   * content merge the useContentMerge checkbox gets disabled.
+   * Enables the {@link #contentMerge} checkbox if the selected submit type allows the usage of
+   * content merge. If the submit type (currently only 'Fast Forward Only') does not allow content
+   * merge the useContentMerge checkbox gets disabled.
    */
   private void setEnabledForUseContentMerge() {
-    if (SubmitType.FAST_FORWARD_ONLY.equals(SubmitType
-        .valueOf(submitType.getValue(submitType.getSelectedIndex())))) {
+    if (SubmitType.FAST_FORWARD_ONLY.equals(
+        SubmitType.valueOf(submitType.getValue(submitType.getSelectedIndex())))) {
       contentMerge.setEnabled(false);
       InheritedBooleanInfo b = InheritedBooleanInfo.create();
       b.setConfiguredValue(InheritableBoolean.FALSE);
@@ -297,17 +304,17 @@
   }
 
   private void initAgreements() {
-    grid.addHeader(new SmallHeading(Util.C.headingAgreements()));
+    grid.addHeader(new SmallHeading(AdminConstants.I.headingAgreements()));
 
     contributorAgreements = newInheritedBooleanBox();
     if (Gerrit.info().auth().useContributorAgreements()) {
       saveEnabler.listenTo(contributorAgreements);
-      grid.add(Util.C.useContributorAgreements(), contributorAgreements);
+      grid.add(AdminConstants.I.useContributorAgreements(), contributorAgreements);
     }
 
     signedOffBy = newInheritedBooleanBox();
     saveEnabler.listenTo(signedOffBy);
-    grid.addHtml(Util.C.useSignedOffBy(), signedOffBy);
+    grid.addHtml(AdminConstants.I.useSignedOffBy(), signedOffBy);
   }
 
   private void setSubmitType(final SubmitType newSubmitType) {
@@ -360,8 +367,9 @@
         }
         box.removeItem(inheritedIndex);
       } else {
-        box.setItemText(inheritedIndex, InheritableBoolean.INHERIT.name() + " ("
-            + inheritedBoolean.inheritedValue() + ")");
+        box.setItemText(
+            inheritedIndex,
+            InheritableBoolean.INHERIT.name() + " (" + inheritedBoolean.inheritedValue() + ")");
       }
     }
   }
@@ -393,14 +401,14 @@
     setSubmitType(result.submitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
-    if (result.maxObjectSizeLimit().inheritedValue() != null) {
-      effectiveMaxObjectSizeLimit.setVisible(true);
+    if (result.maxObjectSizeLimit().value() != null) {
       effectiveMaxObjectSizeLimit.setText(
-          Util.M.effectiveMaxObjectSizeLimit(result.maxObjectSizeLimit().value()));
-      effectiveMaxObjectSizeLimit.setTitle(
-          Util.M.globalMaxObjectSizeLimit(result.maxObjectSizeLimit().inheritedValue()));
+          AdminMessages.I.effectiveMaxObjectSizeLimit(result.maxObjectSizeLimit().value()));
+      if (result.maxObjectSizeLimit().summary() != null) {
+        effectiveMaxObjectSizeLimit.setTitle(result.maxObjectSizeLimit().summary());
+      }
     } else {
-      effectiveMaxObjectSizeLimit.setVisible(false);
+      effectiveMaxObjectSizeLimit.setText(AdminMessages.I.noMaxObjectSizeLimit());
     }
 
     saveProject.setEnabled(false);
@@ -416,10 +424,9 @@
       Map<String, HasEnabled> widgetMap = new HashMap<>();
       pluginConfigWidgets.put(pluginName, widgetMap);
       LabeledWidgetsGrid g = new LabeledWidgetsGrid();
-      g.addHeader(new SmallHeading(Util.M.pluginProjectOptionsTitle(pluginName)));
+      g.addHeader(new SmallHeading(AdminMessages.I.pluginProjectOptionsTitle(pluginName)));
       pluginOptionsPanel.add(g);
-      NativeMap<ConfigParameterInfo> pluginConfig =
-          info.pluginConfig(pluginName);
+      NativeMap<ConfigParameterInfo> pluginConfig = info.pluginConfig(pluginName);
       pluginConfig.copyKeysIntoChildren("name");
       for (ConfigParameterInfo param : Natives.asList(pluginConfig.values())) {
         HasEnabled w;
@@ -452,18 +459,13 @@
     enableForm();
   }
 
-  private TextBox renderTextBox(LabeledWidgetsGrid g,
-      ConfigParameterInfo param) {
-    NpTextBox textBox = param.type().equals("STRING")
-        ? new NpTextBox()
-        : new NpIntTextBox();
+  private TextBox renderTextBox(LabeledWidgetsGrid g, ConfigParameterInfo param) {
+    NpTextBox textBox = param.type().equals("STRING") ? new NpTextBox() : new NpIntTextBox();
     if (param.inheritable()) {
       textBox.setValue(param.configuredValue());
       Label inheritedLabel =
-          new Label(Util.M.pluginProjectInheritedValue(param
-              .inheritedValue()));
-      inheritedLabel.setStyleName(Gerrit.RESOURCES.css()
-          .pluginProjectConfigInheritedValue());
+          new Label(AdminMessages.I.pluginProjectInheritedValue(param.inheritedValue()));
+      inheritedLabel.setStyleName(Gerrit.RESOURCES.css().pluginProjectConfigInheritedValue());
       HorizontalPanel p = new HorizontalPanel();
       p.add(textBox);
       p.add(inheritedLabel);
@@ -472,12 +474,14 @@
       textBox.setValue(param.value());
       addWidget(g, textBox, param);
     }
+    if (textBox.getValue().length() > textBox.getVisibleLength()) {
+      textBox.setVisibleLength(textBox.getValue().length());
+    }
     saveEnabler.listenTo(textBox);
     return textBox;
   }
 
-  private CheckBox renderCheckBox(LabeledWidgetsGrid g,
-      ConfigParameterInfo param) {
+  private CheckBox renderCheckBox(LabeledWidgetsGrid g, ConfigParameterInfo param) {
     CheckBox checkBox = new CheckBox(getDisplayName(param));
     checkBox.setValue(Boolean.parseBoolean(param.value()));
     HorizontalPanel p = new HorizontalPanel();
@@ -492,20 +496,18 @@
       warningImg.setTitle(param.warning());
       p.add(warningImg);
     }
-    g.add((String)null, p);
+    g.add((String) null, p);
     saveEnabler.listenTo(checkBox);
     return checkBox;
   }
 
-  private ListBox renderListBox(LabeledWidgetsGrid g,
-      ConfigParameterInfo param) {
+  private ListBox renderListBox(LabeledWidgetsGrid g, ConfigParameterInfo param) {
     if (param.permittedValues() == null) {
       return null;
     }
     ListBox listBox = new ListBox();
     if (param.inheritable()) {
-      listBox.addItem(
-          Util.M.pluginProjectInheritedListValue(param.inheritedValue()));
+      listBox.addItem(AdminMessages.I.pluginProjectInheritedListValue(param.inheritedValue()));
       if (param.configuredValue() == null) {
         listBox.setSelectedIndex(0);
       }
@@ -537,10 +539,8 @@
         // since the listBox is disabled the inherited value cannot be
         // seen and we have to display it explicitly
         Label inheritedLabel =
-            new Label(Util.M.pluginProjectInheritedValue(param
-                .inheritedValue()));
-        inheritedLabel.setStyleName(Gerrit.RESOURCES.css()
-            .pluginProjectConfigInheritedValue());
+            new Label(AdminMessages.I.pluginProjectInheritedValue(param.inheritedValue()));
+        inheritedLabel.setStyleName(Gerrit.RESOURCES.css().pluginProjectConfigInheritedValue());
         HorizontalPanel p = new HorizontalPanel();
         p.add(listBox);
         p.add(inheritedLabel);
@@ -553,11 +553,9 @@
     return listBox;
   }
 
-  private StringListPanel renderStringListPanel(LabeledWidgetsGrid g,
-      ConfigParameterInfo param) {
+  private StringListPanel renderStringListPanel(LabeledWidgetsGrid g, ConfigParameterInfo param) {
     StringListPanel p =
-        new StringListPanel(null, Arrays.asList(getDisplayName(param)),
-            saveProject, false);
+        new StringListPanel(null, Arrays.asList(getDisplayName(param)), saveProject, false);
     List<List<String>> values = new ArrayList<>();
     for (String v : Natives.asList(param.values())) {
       values.add(Arrays.asList(v));
@@ -608,15 +606,14 @@
       return;
     }
     actions.copyKeysIntoChildren("id");
-    actionsGrid.addHeader(new SmallHeading(Util.C.headingProjectCommands()));
+    actionsGrid.addHeader(new SmallHeading(AdminConstants.I.headingProjectCommands()));
     FlowPanel actionsPanel = new FlowPanel();
     actionsPanel.setStyleName(Gerrit.RESOURCES.css().projectActions());
     actionsPanel.setVisible(true);
-    actionsGrid.add(Util.C.headingCommands(), actionsPanel);
+    actionsGrid.add(AdminConstants.I.headingCommands(), actionsPanel);
 
     for (String id : actions.keySet()) {
-      actionsPanel.add(new ActionButton(getProjectKey(),
-          actions.get(id)));
+      actionsPanel.add(new ActionButton(getProjectKey(), actions.get(id)));
     }
 
     // TODO: The user should have create permission on the branch referred to by
@@ -625,52 +622,60 @@
       actionsPanel.add(createChangeAction());
     }
 
-    if (isOwner) {
+    if (isOwner && configVisible) {
       actionsPanel.add(createEditConfigAction());
     }
   }
 
   private Button createChangeAction() {
-    final Button createChange = new Button(Util.C.buttonCreateChange());
+    final Button createChange = new Button(AdminConstants.I.buttonCreateChange());
     createChange.setStyleName("");
-    createChange.setTitle(Util.C.buttonCreateChangeDescription());
-    createChange.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        CreateChangeAction.call(createChange, getProjectKey().get());
-      }
-    });
+    createChange.setTitle(AdminConstants.I.buttonCreateChangeDescription());
+    createChange.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            CreateChangeAction.call(createChange, getProjectKey().get());
+          }
+        });
     return createChange;
   }
 
   private Button createEditConfigAction() {
-    final Button editConfig = new Button(Util.C.buttonEditConfig());
+    final Button editConfig = new Button(AdminConstants.I.buttonEditConfig());
     editConfig.setStyleName("");
-    editConfig.setTitle(Util.C.buttonEditConfigDescription());
-    editConfig.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        EditConfigAction.call(editConfig, getProjectKey().get());
-      }
-    });
+    editConfig.setTitle(AdminConstants.I.buttonEditConfigDescription());
+    editConfig.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            EditConfigAction.call(editConfig, getProjectKey().get());
+          }
+        });
     return editConfig;
   }
 
   private void doSave() {
     enableForm(false);
     saveProject.setEnabled(false);
-    InheritableBoolean esp = enableSignedPush != null
-        ? getBool(enableSignedPush) : null;
-    InheritableBoolean rsp = requireSignedPush != null
-        ? getBool(requireSignedPush) : null;
-    ProjectApi.setConfig(getProjectKey(), descTxt.getText().trim(),
-        getBool(contributorAgreements), getBool(contentMerge),
-        getBool(signedOffBy), getBool(newChangeForAllNotInTarget), getBool(requireChangeID),
-        esp, rsp, getBool(rejectImplicitMerges),
+    InheritableBoolean esp = enableSignedPush != null ? getBool(enableSignedPush) : null;
+    InheritableBoolean rsp = requireSignedPush != null ? getBool(requireSignedPush) : null;
+    ProjectApi.setConfig(
+        getProjectKey(),
+        descTxt.getText().trim(),
+        getBool(contributorAgreements),
+        getBool(contentMerge),
+        getBool(signedOffBy),
+        getBool(newChangeForAllNotInTarget),
+        getBool(requireChangeID),
+        esp,
+        rsp,
+        getBool(rejectImplicitMerges),
         maxObjectSizeLimit.getText().trim(),
         SubmitType.valueOf(submitType.getValue(submitType.getSelectedIndex())),
         ProjectState.valueOf(state.getValue(state.getSelectedIndex())),
-        getPluginConfigValues(), new GerritCallback<ConfigInfo>() {
+        getPluginConfigValues(),
+        new GerritCallback<ConfigInfo>() {
           @Override
           public void onSuccess(ConfigInfo result) {
             enableForm();
@@ -694,24 +699,26 @@
       for (Entry<String, HasEnabled> e2 : e.getValue().entrySet()) {
         HasEnabled widget = e2.getValue();
         if (widget instanceof TextBox) {
-          values.put(e2.getKey(), ConfigParameterValue.create()
-              .value(((TextBox) widget).getValue().trim()));
+          values.put(
+              e2.getKey(),
+              ConfigParameterValue.create().value(((TextBox) widget).getValue().trim()));
         } else if (widget instanceof CheckBox) {
-          values.put(e2.getKey(), ConfigParameterValue.create()
-              .value(Boolean.toString(((CheckBox) widget).getValue())));
+          values.put(
+              e2.getKey(),
+              ConfigParameterValue.create()
+                  .value(Boolean.toString(((CheckBox) widget).getValue())));
         } else if (widget instanceof ListBox) {
           ListBox listBox = (ListBox) widget;
           // the inherited value is at index 0,
           // if it is selected no value should be set on this project
-          String value = listBox.getSelectedIndex() > 0
-              ? listBox.getValue(listBox.getSelectedIndex()) : null;
-          values.put(e2.getKey(), ConfigParameterValue.create()
-              .value(value));
+          String value =
+              listBox.getSelectedIndex() > 0 ? listBox.getValue(listBox.getSelectedIndex()) : null;
+          values.put(e2.getKey(), ConfigParameterValue.create().value(value));
         } else if (widget instanceof StringListPanel) {
-          values.put(e2.getKey(),
-              ConfigParameterValue.create().values(
-                  ((StringListPanel) widget).getValues(0)
-                      .toArray(new String[] {})));
+          values.put(
+              e2.getKey(),
+              ConfigParameterValue.create()
+                  .values(((StringListPanel) widget).getValues(0).toArray(new String[] {})));
         } else {
           throw new UnsupportedOperationException("unsupported widget type");
         }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java
index 98eeb02..9166c56 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectListScreen.java
@@ -35,10 +35,8 @@
 import com.google.gwt.user.client.History;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HorizontalPanel;
-import com.google.gwt.user.client.ui.Image;
 import com.google.gwt.user.client.ui.Label;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
-
 import java.util.List;
 
 public class ProjectListScreen extends PaginatedProjectScreen {
@@ -72,7 +70,7 @@
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    setPageTitle(Util.C.projectListTitle());
+    setPageTitle(AdminConstants.I.projectListTitle());
     initPageHeader();
 
     prev = PagingHyperlink.createPrev();
@@ -81,74 +79,58 @@
     next = PagingHyperlink.createNext();
     next.setVisible(false);
 
-    projects = new ProjectsTable() {
-      @Override
-      protected void initColumnHeaders() {
-        super.initColumnHeaders();
-        table.setText(0, ProjectsTable.C_REPO_BROWSER,
-            Util.C.projectRepoBrowser());
-        table.getFlexCellFormatter().
-          addStyleName(0, ProjectsTable.C_REPO_BROWSER,
-              Gerrit.RESOURCES.css().dataHeader());
-      }
-
-      @Override
-      protected void onOpenRow(final int row) {
-        History.newItem(link(getRowItem(row)));
-      }
-
-      private String link(final ProjectInfo item) {
-        return Dispatcher.toProject(item.name_key());
-      }
-
-      @Override
-      protected void insert(int row, ProjectInfo k) {
-        super.insert(row, k);
-        table.getFlexCellFormatter().addStyleName(row,
-            ProjectsTable.C_REPO_BROWSER, Gerrit.RESOURCES.css().dataCell());
-      }
-
-      @Override
-      protected void populate(final int row, final ProjectInfo k) {
-        Image state = new Image();
-        switch (k.state()) {
-          case HIDDEN:
-            state.setResource(Gerrit.RESOURCES.redNot());
-            state.setTitle(Util.toLongString(k.state()));
-            table.setWidget(row, ProjectsTable.C_STATE, state);
-            break;
-          case READ_ONLY:
-            state.setResource(Gerrit.RESOURCES.readOnly());
-            state.setTitle(Util.toLongString(k.state()));
-            table.setWidget(row, ProjectsTable.C_STATE, state);
-            break;
-          case ACTIVE:
-          default:
-            // Intentionally left blank, do not show an icon when active.
-            break;
-        }
-
-        FlowPanel fp = new FlowPanel();
-        fp.add(new ProjectSearchLink(k.name_key()));
-        fp.add(new HighlightingInlineHyperlink(k.name(), link(k), match));
-        table.setWidget(row, ProjectsTable.C_NAME, fp);
-        table.setText(row, ProjectsTable.C_DESCRIPTION, k.description());
-        addWebLinks(row, k);
-
-        setRowItem(row, k);
-      }
-
-      private void addWebLinks(int row, ProjectInfo k) {
-        List<WebLinkInfo> webLinks = Natives.asList(k.webLinks());
-        if (webLinks != null && !webLinks.isEmpty()) {
-          FlowPanel p = new FlowPanel();
-          table.setWidget(row, ProjectsTable.C_REPO_BROWSER, p);
-          for (WebLinkInfo weblink : webLinks) {
-            p.add(weblink.toAnchor());
+    projects =
+        new ProjectsTable() {
+          @Override
+          protected void initColumnHeaders() {
+            super.initColumnHeaders();
+            table.setText(0, ProjectsTable.C_REPO_BROWSER, AdminConstants.I.projectRepoBrowser());
+            table
+                .getFlexCellFormatter()
+                .addStyleName(0, ProjectsTable.C_REPO_BROWSER, Gerrit.RESOURCES.css().dataHeader());
           }
-        }
-      }
-    };
+
+          @Override
+          protected void onOpenRow(final int row) {
+            History.newItem(link(getRowItem(row)));
+          }
+
+          private String link(final ProjectInfo item) {
+            return Dispatcher.toProject(item.name_key());
+          }
+
+          @Override
+          protected void insert(int row, ProjectInfo k) {
+            super.insert(row, k);
+            table
+                .getFlexCellFormatter()
+                .addStyleName(row, ProjectsTable.C_REPO_BROWSER, Gerrit.RESOURCES.css().dataCell());
+          }
+
+          @Override
+          protected void populate(int row, ProjectInfo k) {
+            populateState(row, k);
+            FlowPanel fp = new FlowPanel();
+            fp.add(new ProjectSearchLink(k.name_key()));
+            fp.add(new HighlightingInlineHyperlink(k.name(), link(k), match));
+            table.setWidget(row, ProjectsTable.C_NAME, fp);
+            table.setText(row, ProjectsTable.C_DESCRIPTION, k.description());
+            addWebLinks(row, k);
+
+            setRowItem(row, k);
+          }
+
+          private void addWebLinks(int row, ProjectInfo k) {
+            List<WebLinkInfo> webLinks = Natives.asList(k.webLinks());
+            if (webLinks != null && !webLinks.isEmpty()) {
+              FlowPanel p = new FlowPanel();
+              table.setWidget(row, ProjectsTable.C_REPO_BROWSER, p);
+              for (WebLinkInfo weblink : webLinks) {
+                p.add(weblink.toAnchor());
+              }
+            }
+          }
+        };
     projects.setSavePointerId(PageLinks.ADMIN_PROJECTS);
 
     add(projects);
@@ -162,27 +144,29 @@
   private void initPageHeader() {
     final HorizontalPanel hp = new HorizontalPanel();
     hp.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    final Label filterLabel = new Label(Util.C.projectFilter());
+    final Label filterLabel = new Label(AdminConstants.I.projectFilter());
     filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
     hp.add(filterLabel);
     filterTxt = new NpTextBox();
     filterTxt.setValue(match);
-    filterTxt.addKeyUpHandler(new KeyUpHandler() {
-      @Override
-      public void onKeyUp(KeyUpEvent event) {
-        Query q = new Query(filterTxt.getValue())
-          .open(event.getNativeKeyCode() == KeyCodes.KEY_ENTER);
-        if (match.equals(q.qMatch)) {
-          q.start(start);
-        }
-        if (q.open || !match.equals(q.qMatch)) {
-          if (query == null) {
-            q.run();
+    filterTxt.addKeyUpHandler(
+        new KeyUpHandler() {
+          @Override
+          public void onKeyUp(KeyUpEvent event) {
+            Query q =
+                new Query(filterTxt.getValue())
+                    .open(event.getNativeKeyCode() == KeyCodes.KEY_ENTER);
+            if (match.equals(q.qMatch)) {
+              q.start(start);
+            }
+            if (q.open || !match.equals(q.qMatch)) {
+              if (query == null) {
+                q.run();
+              }
+              query = q;
+            }
           }
-          query = q;
-        }
-      }
-    });
+        });
     hp.add(filterTxt);
     add(hp);
   }
@@ -223,7 +207,10 @@
 
     Query run() {
       int limit = open ? 1 : pageSize + 1;
-      ProjectMap.match(qMatch, limit, qStart,
+      ProjectMap.match(
+          qMatch,
+          limit,
+          qStart,
           new GerritCallback<ProjectMap>() {
             @Override
             public void onSuccess(ProjectMap result) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java
index a63dae4..3328163 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java
@@ -49,7 +49,7 @@
   protected void onInitUI() {
     super.onInitUI();
     if (name != null) {
-      setPageTitle(Util.M.project(name.get()));
+      setPageTitle(AdminMessages.I.project(name.get()));
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
index 2db0eff..f66f42b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
@@ -16,33 +16,65 @@
 
 import static com.google.gerrit.client.ui.Util.highlight;
 
+import com.google.gerrit.client.ConfirmationCallback;
+import com.google.gerrit.client.ConfirmationDialog;
+import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.VoidResult;
+import com.google.gerrit.client.access.AccessMap;
+import com.google.gerrit.client.access.ProjectAccessInfo;
+import com.google.gerrit.client.info.WebLinkInfo;
 import com.google.gerrit.client.projects.ProjectApi;
 import com.google.gerrit.client.projects.TagInfo;
+import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
+import com.google.gerrit.client.ui.HintTextBox;
 import com.google.gerrit.client.ui.Hyperlink;
 import com.google.gerrit.client.ui.NavigationTable;
 import com.google.gerrit.client.ui.PagingHyperlink;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArray;
+import com.google.gwt.core.client.Scheduler;
+import com.google.gwt.core.client.Scheduler.ScheduledCommand;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.dom.client.KeyCodes;
+import com.google.gwt.event.dom.client.KeyPressEvent;
+import com.google.gwt.event.dom.client.KeyPressHandler;
 import com.google.gwt.event.dom.client.KeyUpEvent;
 import com.google.gwt.event.dom.client.KeyUpHandler;
+import com.google.gwt.event.logical.shared.ValueChangeEvent;
+import com.google.gwt.event.logical.shared.ValueChangeHandler;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.Grid;
 import com.google.gwt.user.client.ui.HorizontalPanel;
 import com.google.gwt.user.client.ui.InlineHTML;
 import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.TextBox;
+import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
-
+import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 public class ProjectTagsScreen extends PaginatedProjectScreen {
-  private NpTextBox filterTxt;
-  private Query query;
   private Hyperlink prev;
   private Hyperlink next;
-  private TagsTable tagsTable;
+  private TagsTable tagTable;
+  private Button delTag;
+  private Button addTag;
+  private HintTextBox nameTxtBox;
+  private HintTextBox irevTxtBox;
+  private HintTextBox annotationTxtBox;
+  private FlowPanel addPanel;
+  private NpTextBox filterTxt;
+  private Query query;
 
   public ProjectTagsScreen(Project.NameKey toShow) {
     super(toShow);
@@ -54,67 +86,324 @@
   }
 
   @Override
+  protected void onLoad() {
+    super.onLoad();
+    addPanel.setVisible(false);
+    AccessMap.get(
+        getProjectKey(),
+        new GerritCallback<ProjectAccessInfo>() {
+          @Override
+          public void onSuccess(ProjectAccessInfo result) {
+            addPanel.setVisible(result.canAddTagRefs());
+          }
+        });
+    query = new Query(match).start(start).run();
+    savedPanel = TAGS;
+  }
+
+  private void updateForm() {
+    tagTable.updateDeleteButton();
+    addTag.setEnabled(true);
+    nameTxtBox.setEnabled(true);
+    irevTxtBox.setEnabled(true);
+    annotationTxtBox.setEnabled(true);
+  }
+
+  @Override
   protected void onInitUI() {
     super.onInitUI();
     initPageHeader();
+
     prev = PagingHyperlink.createPrev();
     prev.setVisible(false);
 
     next = PagingHyperlink.createNext();
     next.setVisible(false);
 
-    tagsTable = new TagsTable();
+    addPanel = new FlowPanel();
+
+    Grid addGrid = new Grid(3, 2);
+    addGrid.setStyleName(Gerrit.RESOURCES.css().addBranch());
+    int texBoxLength = 50;
+
+    KeyPressHandler onKeyPress =
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
+              doAddNewTag();
+            }
+          }
+        };
+
+    nameTxtBox = new HintTextBox();
+    nameTxtBox.setVisibleLength(texBoxLength);
+    nameTxtBox.setHintText(AdminConstants.I.defaultTagName());
+    nameTxtBox.addKeyPressHandler(onKeyPress);
+    addGrid.setText(0, 0, AdminConstants.I.columnTagName() + ":");
+    addGrid.setWidget(0, 1, nameTxtBox);
+
+    irevTxtBox = new HintTextBox();
+    irevTxtBox.setVisibleLength(texBoxLength);
+    irevTxtBox.setHintText(AdminConstants.I.defaultRevisionSpec());
+    irevTxtBox.addKeyPressHandler(onKeyPress);
+    addGrid.setText(1, 0, AdminConstants.I.revision() + ":");
+    addGrid.setWidget(1, 1, irevTxtBox);
+
+    annotationTxtBox = new HintTextBox();
+    annotationTxtBox.setVisibleLength(texBoxLength);
+    annotationTxtBox.setHintText(AdminConstants.I.annotation());
+    annotationTxtBox.addKeyPressHandler(onKeyPress);
+    addGrid.setText(2, 0, AdminConstants.I.columnTagAnnotation() + ":");
+    addGrid.setWidget(2, 1, annotationTxtBox);
+
+    addTag = new Button(AdminConstants.I.buttonAddTag());
+    addTag.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            doAddNewTag();
+          }
+        });
+    addPanel.add(addGrid);
+    addPanel.add(addTag);
+
+    tagTable = new TagsTable();
+
+    delTag = new Button(AdminConstants.I.buttonDeleteTag());
+    delTag.setStyleName(Gerrit.RESOURCES.css().branchTableDeleteButton());
+    delTag.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            tagTable.deleteChecked();
+          }
+        });
 
     HorizontalPanel buttons = new HorizontalPanel();
     buttons.setStyleName(Gerrit.RESOURCES.css().branchTablePrevNextLinks());
+    buttons.add(delTag);
     buttons.add(prev);
     buttons.add(next);
-    add(tagsTable);
+    add(tagTable);
     add(buttons);
-  }
-
-  @Override
-  protected void onLoad() {
-    super.onLoad();
-    query = new Query(match).start(start).run();
-    savedPanel = TAGS;
+    add(addPanel);
   }
 
   private void initPageHeader() {
     parseToken();
     HorizontalPanel hp = new HorizontalPanel();
     hp.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
-    Label filterLabel = new Label(Util.C.projectFilter());
+    Label filterLabel = new Label(AdminConstants.I.projectFilter());
     filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
     hp.add(filterLabel);
     filterTxt = new NpTextBox();
     filterTxt.setValue(match);
-    filterTxt.addKeyUpHandler(new KeyUpHandler() {
-      @Override
-      public void onKeyUp(KeyUpEvent event) {
-        Query q = new Query(filterTxt.getValue());
-        if (match.equals(q.qMatch)) {
-          q.start(start);
-        } else if (query == null) {
-          q.run();
-          query = q;
-        }
-      }
-    });
+    filterTxt.addKeyUpHandler(
+        new KeyUpHandler() {
+          @Override
+          public void onKeyUp(KeyUpEvent event) {
+            Query q = new Query(filterTxt.getValue());
+            if (match.equals(q.qMatch)) {
+              q.start(start);
+            } else {
+              if (query == null) {
+                q.run();
+              }
+              query = q;
+            }
+          }
+        });
     hp.add(filterTxt);
     add(hp);
   }
 
+  private void doAddNewTag() {
+    String tagName = nameTxtBox.getText().trim();
+    if (tagName.isEmpty()) {
+      nameTxtBox.setFocus(true);
+      return;
+    }
+
+    String rev = irevTxtBox.getText().trim();
+    if (rev.isEmpty()) {
+      irevTxtBox.setText("HEAD");
+      Scheduler.get()
+          .scheduleDeferred(
+              new ScheduledCommand() {
+                @Override
+                public void execute() {
+                  irevTxtBox.selectAll();
+                  irevTxtBox.setFocus(true);
+                }
+              });
+      return;
+    }
+
+    String annotation = annotationTxtBox.getText().trim();
+    if (annotation.isEmpty()) {
+      annotation = null;
+    }
+
+    addTag.setEnabled(false);
+    ProjectApi.createTag(
+        getProjectKey(),
+        tagName,
+        rev,
+        annotation,
+        new GerritCallback<TagInfo>() {
+          @Override
+          public void onSuccess(TagInfo tag) {
+            showAddedTag(tag);
+            nameTxtBox.setText("");
+            irevTxtBox.setText("");
+            annotationTxtBox.setText("");
+            query = new Query(match).start(start).run();
+          }
+
+          @Override
+          public void onFailure(Throwable caught) {
+            addTag.setEnabled(true);
+            selectAllAndFocus(nameTxtBox);
+            new ErrorDialog(caught.getMessage()).center();
+          }
+        });
+  }
+
+  void showAddedTag(TagInfo tag) {
+    SafeHtmlBuilder b = new SafeHtmlBuilder();
+    b.openElement("b");
+    b.append(Gerrit.C.tagCreationConfirmationMessage());
+    b.closeElement("b");
+
+    b.openElement("p");
+    b.append(tag.ref());
+    b.closeElement("p");
+
+    ConfirmationDialog confirmationDialog =
+        new ConfirmationDialog(
+            Gerrit.C.tagCreationDialogTitle(),
+            b.toSafeHtml(),
+            new ConfirmationCallback() {
+              @Override
+              public void onOk() {
+                // do nothing
+              }
+            });
+    confirmationDialog.center();
+    confirmationDialog.setCancelVisible(false);
+  }
+
+  private static void selectAllAndFocus(TextBox textBox) {
+    textBox.selectAll();
+    textBox.setFocus(true);
+  }
+
   private class TagsTable extends NavigationTable<TagInfo> {
+    private ValueChangeHandler<Boolean> updateDeleteHandler;
+    boolean canDelete;
 
     TagsTable() {
       table.setWidth("");
-      table.setText(0, 1, Util.C.columnTagName());
-      table.setText(0, 2, Util.C.columnBranchRevision());
+      table.setText(0, 2, AdminConstants.I.columnTagName());
+      table.setText(0, 3, AdminConstants.I.columnTagRevision());
 
       FlexCellFormatter fmt = table.getFlexCellFormatter();
-      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().dataHeader());
+      fmt.addStyleName(0, 1, Gerrit.RESOURCES.css().iconHeader());
       fmt.addStyleName(0, 2, Gerrit.RESOURCES.css().dataHeader());
+      fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
+      fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
+
+      updateDeleteHandler =
+          new ValueChangeHandler<Boolean>() {
+            @Override
+            public void onValueChange(ValueChangeEvent<Boolean> event) {
+              updateDeleteButton();
+            }
+          };
+    }
+
+    Set<String> getCheckedRefs() {
+      Set<String> refs = new HashSet<>();
+      for (int row = 1; row < table.getRowCount(); row++) {
+        TagInfo k = getRowItem(row);
+        if (k != null
+            && table.getWidget(row, 1) instanceof CheckBox
+            && ((CheckBox) table.getWidget(row, 1)).getValue()) {
+          refs.add(k.ref());
+        }
+      }
+      return refs;
+    }
+
+    void setChecked(Set<String> refs) {
+      for (int row = 1; row < table.getRowCount(); row++) {
+        TagInfo k = getRowItem(row);
+        if (k != null && refs.contains(k.ref()) && table.getWidget(row, 1) instanceof CheckBox) {
+          ((CheckBox) table.getWidget(row, 1)).setValue(true);
+        }
+      }
+    }
+
+    void deleteChecked() {
+      final Set<String> refs = getCheckedRefs();
+
+      SafeHtmlBuilder b = new SafeHtmlBuilder();
+      b.openElement("b");
+      b.append(Gerrit.C.tagDeletionConfirmationMessage());
+      b.closeElement("b");
+
+      b.openElement("p");
+      boolean first = true;
+      for (String ref : refs) {
+        if (!first) {
+          b.append(",").br();
+        }
+        b.append(ref);
+        first = false;
+      }
+      b.closeElement("p");
+
+      if (refs.isEmpty()) {
+        updateDeleteButton();
+        return;
+      }
+
+      delTag.setEnabled(false);
+      ConfirmationDialog confirmationDialog =
+          new ConfirmationDialog(
+              Gerrit.C.tagDeletionDialogTitle(),
+              b.toSafeHtml(),
+              new ConfirmationCallback() {
+                @Override
+                public void onOk() {
+                  deleteTags(refs);
+                }
+
+                @Override
+                public void onCancel() {
+                  tagTable.updateDeleteButton();
+                }
+              });
+      confirmationDialog.center();
+    }
+
+    private void deleteTags(final Set<String> tags) {
+      ProjectApi.deleteTags(
+          getProjectKey(),
+          tags,
+          new GerritCallback<VoidResult>() {
+            @Override
+            public void onSuccess(VoidResult result) {
+              query = new Query(match).start(start).run();
+            }
+
+            @Override
+            public void onFailure(Throwable caught) {
+              query = new Query(match).start(start).run();
+              super.onFailure(caught);
+            }
+          });
     }
 
     void display(List<TagInfo> tags) {
@@ -122,6 +411,8 @@
     }
 
     void displaySubset(List<TagInfo> tags, int fromIndex, int toIndex) {
+      canDelete = false;
+
       while (1 < table.getRowCount()) {
         table.removeRow(table.getRowCount() - 1);
       }
@@ -135,22 +426,61 @@
     }
 
     void populate(int row, TagInfo k) {
-      table.setWidget(row, 1, new InlineHTML(highlight(k.getShortName(), match)));
-
-      if (k.revision() != null) {
-        table.setText(row, 2, k.revision());
+      if (k.canDelete()) {
+        CheckBox sel = new CheckBox();
+        sel.addValueChangeHandler(updateDeleteHandler);
+        table.setWidget(row, 1, sel);
+        canDelete = true;
       } else {
-        table.setText(row, 2, "");
+        table.setText(row, 1, "");
       }
 
+      table.setWidget(row, 2, new InlineHTML(highlight(k.getShortName(), match)));
+
+      if (k.revision() != null) {
+        table.setText(row, 3, k.revision());
+      } else {
+        table.setText(row, 3, "");
+      }
+
+      FlowPanel actionsPanel = new FlowPanel();
+      if (k.webLinks() != null) {
+        for (WebLinkInfo webLink : Natives.asList(k.webLinks())) {
+          actionsPanel.add(webLink.toAnchor());
+        }
+      }
+      table.setWidget(row, 4, actionsPanel);
+
       FlexCellFormatter fmt = table.getFlexCellFormatter();
+      String iconCellStyle = Gerrit.RESOURCES.css().iconCell();
       String dataCellStyle = Gerrit.RESOURCES.css().dataCell();
-      fmt.addStyleName(row, 1, dataCellStyle);
+      fmt.addStyleName(row, 1, iconCellStyle);
       fmt.addStyleName(row, 2, dataCellStyle);
+      fmt.addStyleName(row, 3, dataCellStyle);
+      fmt.addStyleName(row, 4, dataCellStyle);
 
       setRowItem(row, k);
     }
 
+    boolean hasTagCanDelete() {
+      return canDelete;
+    }
+
+    void updateDeleteButton() {
+      boolean on = false;
+      for (int row = 1; row < table.getRowCount(); row++) {
+        Widget w = table.getWidget(row, 1);
+        if (w != null && w instanceof CheckBox) {
+          CheckBox sel = (CheckBox) w;
+          if (sel.getValue()) {
+            on = true;
+            break;
+          }
+        }
+      }
+      delTag.setEnabled(on);
+    }
+
     @Override
     protected void onOpenRow(int row) {
       if (row > 0) {
@@ -189,7 +519,11 @@
     Query run() {
       // Retrieve one more tag than page size to determine if there are more
       // tags to display
-      ProjectApi.getTags(getProjectKey(), pageSize + 1, qStart, qMatch,
+      ProjectApi.getTags(
+          getProjectKey(),
+          pageSize + 1,
+          qStart,
+          qMatch,
           new ScreenLoadCallback<JsArray<TagInfo>>(ProjectTagsScreen.this) {
             @Override
             public void preDisplay(JsArray<TagInfo> result) {
@@ -212,11 +546,10 @@
       ProjectTagsScreen.this.start = qStart;
 
       if (result.length() <= pageSize) {
-        tagsTable.display(Natives.asList(result));
+        tagTable.display(Natives.asList(result));
         next.setVisible(false);
       } else {
-        tagsTable.displaySubset(Natives.asList(result), 0,
-            result.length() - 1);
+        tagTable.displaySubset(Natives.asList(result), 0, result.length() - 1);
         setupNavigationLink(next, qMatch, qStart + pageSize);
       }
       if (qStart > 0) {
@@ -225,6 +558,11 @@
         prev.setVisible(false);
       }
 
+      delTag.setVisible(tagTable.hasTagCanDelete());
+      Set<String> checkedRefs = tagTable.getCheckedRefs();
+      tagTable.setChecked(checkedRefs);
+      updateForm();
+
       if (!isCurrentView()) {
         display();
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java
index b549528..063a60c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RangeBox.java
@@ -21,11 +21,9 @@
 import com.google.gwt.user.client.ui.IntegerBox;
 import com.google.gwt.user.client.ui.ValueBoxBase.TextAlignment;
 import com.google.gwt.user.client.ui.ValueListBox;
-
 import java.io.IOException;
 
-abstract class RangeBox extends Composite implements
-    IsEditor<TakesValueEditor<Integer>> {
+abstract class RangeBox extends Composite implements IsEditor<TakesValueEditor<Integer>> {
   static final RangeRenderer rangeRenderer = new RangeRenderer();
 
   private static class RangeRenderer implements Renderer<Integer> {
@@ -38,8 +36,7 @@
     }
 
     @Override
-    public void render(Integer object, Appendable appendable)
-        throws IOException {
+    public void render(Integer object, Appendable appendable) throws IOException {
       appendable.append(render(object));
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RefPatternBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RefPatternBox.java
index e8e88cc..f1180cc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RefPatternBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/RefPatternBox.java
@@ -21,73 +21,75 @@
 import com.google.gwt.text.shared.Renderer;
 import com.google.gwt.user.client.ui.ValueBox;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
-
 import java.io.IOException;
 import java.text.ParseException;
 
 public class RefPatternBox extends ValueBox<String> {
-  private static final Renderer<String> RENDERER = new Renderer<String>() {
-    @Override
-    public String render(String ref) {
-      return ref;
-    }
-
-    @Override
-    public void render(String ref, Appendable dst) throws IOException {
-      dst.append(render(ref));
-    }
-  };
-
-  private static final Parser<String> PARSER = new Parser<String>() {
-    @Override
-    public String parse(CharSequence text) throws ParseException {
-      String ref = text.toString();
-
-      if (ref.isEmpty()) {
-        throw new ParseException(Util.C.refErrorEmpty(), 0);
-      }
-
-      if (ref.charAt(0) == '/') {
-        throw new ParseException(Util.C.refErrorBeginSlash(), 0);
-      }
-
-      if (ref.charAt(0) == '^') {
-        if (!ref.startsWith("^refs/")) {
-          ref = "^refs/heads/" + ref.substring(1);
-        }
-      } else if (!ref.startsWith("refs/")) {
-        ref = "refs/heads/" + ref;
-      }
-
-      for (int i = 0; i < ref.length(); i++) {
-        final char c = ref.charAt(i);
-
-        if (c == '/' && 0 < i && ref.charAt(i - 1) == '/') {
-          throw new ParseException(Util.C.refErrorDoubleSlash(), i);
+  private static final Renderer<String> RENDERER =
+      new Renderer<String>() {
+        @Override
+        public String render(String ref) {
+          return ref;
         }
 
-        if (c == ' ') {
-          throw new ParseException(Util.C.refErrorNoSpace(), i);
+        @Override
+        public void render(String ref, Appendable dst) throws IOException {
+          dst.append(render(ref));
         }
+      };
 
-        if (c < ' ') {
-          throw new ParseException(Util.C.refErrorPrintable(), i);
+  private static final Parser<String> PARSER =
+      new Parser<String>() {
+        @Override
+        public String parse(CharSequence text) throws ParseException {
+          String ref = text.toString();
+
+          if (ref.isEmpty()) {
+            throw new ParseException(AdminConstants.I.refErrorEmpty(), 0);
+          }
+
+          if (ref.charAt(0) == '/') {
+            throw new ParseException(AdminConstants.I.refErrorBeginSlash(), 0);
+          }
+
+          if (ref.charAt(0) == '^') {
+            if (!ref.startsWith("^refs/")) {
+              ref = "^refs/heads/" + ref.substring(1);
+            }
+          } else if (!ref.startsWith("refs/")) {
+            ref = "refs/heads/" + ref;
+          }
+
+          for (int i = 0; i < ref.length(); i++) {
+            final char c = ref.charAt(i);
+
+            if (c == '/' && 0 < i && ref.charAt(i - 1) == '/') {
+              throw new ParseException(AdminConstants.I.refErrorDoubleSlash(), i);
+            }
+
+            if (c == ' ') {
+              throw new ParseException(AdminConstants.I.refErrorNoSpace(), i);
+            }
+
+            if (c < ' ') {
+              throw new ParseException(AdminConstants.I.refErrorPrintable(), i);
+            }
+          }
+          return ref;
         }
-      }
-      return ref;
-    }
-  };
+      };
 
   public RefPatternBox() {
     super(Document.get().createTextInputElement(), RENDERER, PARSER);
     addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
-    addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (event.getCharCode() == ' ') {
-          event.preventDefault();
-        }
-      }
-    });
+    addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (event.getCharCode() == ' ') {
+              event.preventDefault();
+            }
+          }
+        });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
index 81286ea..f08cdd8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
@@ -21,8 +21,6 @@
 import com.google.gwtjsonrpc.client.JsonUtil;
 
 public class Util {
-  public static final AdminConstants C = GWT.create(AdminConstants.class);
-  public static final AdminMessages M = GWT.create(AdminMessages.class);
   public static final ProjectAdminService PROJECT_SVC;
 
   static {
@@ -38,15 +36,17 @@
     }
     switch (type) {
       case FAST_FORWARD_ONLY:
-        return C.projectSubmitType_FAST_FORWARD_ONLY();
+        return AdminConstants.I.projectSubmitType_FAST_FORWARD_ONLY();
       case MERGE_IF_NECESSARY:
-        return C.projectSubmitType_MERGE_IF_NECESSARY();
+        return AdminConstants.I.projectSubmitType_MERGE_IF_NECESSARY();
       case REBASE_IF_NECESSARY:
-        return C.projectSubmitType_REBASE_IF_NECESSARY();
+        return AdminConstants.I.projectSubmitType_REBASE_IF_NECESSARY();
+      case REBASE_ALWAYS:
+        return AdminConstants.I.projectSubmitType_REBASE_ALWAYS();
       case MERGE_ALWAYS:
-        return C.projectSubmitType_MERGE_ALWAYS();
+        return AdminConstants.I.projectSubmitType_MERGE_ALWAYS();
       case CHERRY_PICK:
-        return C.projectSubmitType_CHERRY_PICK();
+        return AdminConstants.I.projectSubmitType_CHERRY_PICK();
       default:
         return type.name();
     }
@@ -58,11 +58,11 @@
     }
     switch (type) {
       case ACTIVE:
-        return C.projectState_ACTIVE();
+        return AdminConstants.I.projectState_ACTIVE();
       case READ_ONLY:
-        return C.projectState_READ_ONLY();
+        return AdminConstants.I.projectState_READ_ONLY();
       case HIDDEN:
-        return C.projectState_HIDDEN();
+        return AdminConstants.I.projectState_HIDDEN();
       default:
         return type.name();
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.java
index 60b11c2..ad614e5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ValueEditor.java
@@ -37,30 +37,24 @@
 import com.google.gwt.user.client.ui.SimplePanel;
 import com.google.gwt.user.client.ui.ValueBoxBase;
 import com.google.gwt.user.client.ui.Widget;
-
 import java.text.ParseException;
 import java.util.List;
 
-public class ValueEditor<T> extends Composite implements HasEditorErrors<T>,
-    IsEditor<ValueBoxEditor<T>>, LeafValueEditor<T>, Focusable {
-  interface Binder extends UiBinder<Widget, ValueEditor<?>> {
-  }
+public class ValueEditor<T> extends Composite
+    implements HasEditorErrors<T>, IsEditor<ValueBoxEditor<T>>, LeafValueEditor<T>, Focusable {
+  interface Binder extends UiBinder<Widget, ValueEditor<?>> {}
 
   static final Binder uiBinder = GWT.create(Binder.class);
 
-  @UiField
-  SimplePanel textPanel;
+  @UiField SimplePanel textPanel;
   private Label textLabel;
   private StartEditHandlers startHandlers;
 
-  @UiField
-  Image editIcon;
+  @UiField Image editIcon;
 
-  @UiField
-  SimplePanel editPanel;
+  @UiField SimplePanel editPanel;
 
-  @UiField
-  DivElement errorLabel;
+  @UiField DivElement errorLabel;
 
   private ValueBoxBase<T> editChild;
   private ValueBoxEditor<T> editProxy;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java
index a4fbb85..7e1db46 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java
@@ -142,33 +142,39 @@
     };
   }-*/;
 
-  static final native ActionContext create(RestApi f)/*-{
+  static final native ActionContext create(RestApi f) /*-{
     return new $wnd.Gerrit.ActionContext(f);
   }-*/;
 
   final native void set(ActionInfo a) /*-{ this.action=a; }-*/;
+
   final native void set(ChangeInfo c) /*-{ this.change=c; }-*/;
+
   final native void set(EditInfo e) /*-{ this.edit=e; }-*/;
+
   final native void set(Project.NameKey p) /*-{ this.project=p; }-*/;
+
   final native void set(BranchInfo b) /*-{ this.branch=b }-*/;
+
   final native void set(RevisionInfo r) /*-{ this.revision=r; }-*/;
 
   final native void button(ActionButton b) /*-{ this._b=b; }-*/;
+
   final native ActionButton button() /*-{ return this._b; }-*/;
 
   public final native boolean has_popup() /*-{ return this.hasOwnProperty('_p') }-*/;
+
   public final native void hide() /*-{ this.hide(); }-*/;
 
-  protected ActionContext() {
-  }
+  protected ActionContext() {}
 
   static final void get(RestApi api, JavaScriptObject cb) {
     api.get(wrap(cb));
   }
 
   /**
-   * The same as {@link #get(RestApi, JavaScriptObject)} but without converting
-   * a {@link NativeString} result to String.
+   * The same as {@link #get(RestApi, JavaScriptObject)} but without converting a {@link
+   * NativeString} result to String.
    */
   static final void getRaw(RestApi api, final JavaScriptObject cb) {
     api.get(wrapRaw(cb));
@@ -183,8 +189,8 @@
   }
 
   /**
-   * The same as {@link #post(RestApi, JavaScriptObject, JavaScriptObject)} but
-   * without converting a {@link NativeString} result to String.
+   * The same as {@link #post(RestApi, JavaScriptObject, JavaScriptObject)} but without converting a
+   * {@link NativeString} result to String.
    */
   static final void postRaw(RestApi api, JavaScriptObject in, JavaScriptObject cb) {
     if (NativeString.is(in)) {
@@ -199,8 +205,8 @@
   }
 
   /**
-   * The same as {@link #post(RestApi, String, JavaScriptObject)} but without
-   * converting a {@link NativeString} result to String.
+   * The same as {@link #post(RestApi, String, JavaScriptObject)} but without converting a {@link
+   * NativeString} result to String.
    */
   static final void postRaw(RestApi api, String in, JavaScriptObject cb) {
     api.post(in, wrapRaw(cb));
@@ -211,8 +217,8 @@
   }
 
   /**
-   * The same as {@link #put(RestApi, JavaScriptObject)} but without converting
-   * a {@link NativeString} result to String.
+   * The same as {@link #put(RestApi, JavaScriptObject)} but without converting a {@link
+   * NativeString} result to String.
    */
   static final void putRaw(RestApi api, JavaScriptObject cb) {
     api.put(wrapRaw(cb));
@@ -227,8 +233,8 @@
   }
 
   /**
-   * The same as {@link #put(RestApi, JavaScriptObject, JavaScriptObject)} but
-   * without converting a {@link NativeString} result to String.
+   * The same as {@link #put(RestApi, JavaScriptObject, JavaScriptObject)} but without converting a
+   * {@link NativeString} result to String.
    */
   static final void putRaw(RestApi api, JavaScriptObject in, JavaScriptObject cb) {
     if (NativeString.is(in)) {
@@ -243,8 +249,8 @@
   }
 
   /**
-   * The same as {@link #put(RestApi, String, JavaScriptObject)} but without
-   * converting a {@link NativeString} result to String.
+   * The same as {@link #put(RestApi, String, JavaScriptObject)} but without converting a {@link
+   * NativeString} result to String.
    */
   static final void putRaw(RestApi api, String in, JavaScriptObject cb) {
     api.put(in, wrapRaw(cb));
@@ -255,8 +261,8 @@
   }
 
   /**
-   * The same as {@link #delete(RestApi, JavaScriptObject)} but without
-   * converting a {@link NativeString} result to String.
+   * The same as {@link #delete(RestApi, JavaScriptObject)} but without converting a {@link
+   * NativeString} result to String.
    */
   static final void deleteRaw(RestApi api, JavaScriptObject cb) {
     api.delete(wrapRaw(cb));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
index 626252a..1555f56 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
@@ -286,8 +286,12 @@
   }
 
   static final native void invoke(JavaScriptObject f) /*-{ f(); }-*/;
+
   static final native void invoke(JavaScriptObject f, JavaScriptObject a) /*-{ f(a); }-*/;
-  static final native void invoke(JavaScriptObject f, JavaScriptObject a, JavaScriptObject b) /*-{ f(a,b) }-*/;
+
+  static final native void invoke(
+      JavaScriptObject f, JavaScriptObject a, JavaScriptObject b) /*-{ f(a,b) }-*/;
+
   static final native void invoke(JavaScriptObject f, String a) /*-{ f(a); }-*/;
 
   public static final void fireEvent(String event, String a) {
@@ -312,8 +316,7 @@
   }
 
   static final native JsArray<JavaScriptObject> getEventHandlers(String e)
-  /*-{ return $wnd.Gerrit.events[e] || [] }-*/;
+      /*-{ return $wnd.Gerrit.events[e] || [] }-*/ ;
 
-  private ApiGlue() {
-  }
+  private ApiGlue() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java
index 1e77773..6bba958 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ChangeGlue.java
@@ -38,10 +38,7 @@
     return true;
   }
 
-  public static void onAction(
-      ChangeInfo change,
-      ActionInfo action,
-      ActionButton button) {
+  public static void onAction(ChangeInfo change, ActionInfo action, ActionButton button) {
     RestApi api = ChangeApi.change(change.legacyId().get()).view(action.id());
     JavaScriptObject f = get(action.id());
     if (f != null) {
@@ -59,10 +56,8 @@
     return $wnd.Gerrit.change_actions[id];
   }-*/;
 
-  private static native boolean invoke(JavaScriptObject h,
-      ChangeInfo a, RevisionInfo r)
-  /*-{ return h(a,r) }-*/;
+  private static native boolean invoke(JavaScriptObject h, ChangeInfo a, RevisionInfo r)
+      /*-{ return h(a,r) }-*/ ;
 
-  private ChangeGlue() {
-  }
+  private ChangeGlue() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
index 55c40f8..74668c1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
@@ -64,8 +64,7 @@
     };
   }
 
-  private static void invoke(ActionInfo action, RestApi api,
-      AsyncCallback<JavaScriptObject> cb) {
+  private static void invoke(ActionInfo action, RestApi api, AsyncCallback<JavaScriptObject> cb) {
     if ("GET".equalsIgnoreCase(action.method())) {
       api.get(cb);
     } else if ("PUT".equalsIgnoreCase(action.method())) {
@@ -77,18 +76,19 @@
     }
   }
 
-  private DefaultActions() {
-  }
+  private DefaultActions() {}
 
   private static class UiResult extends JavaScriptObject {
     static native UiResult alert(String m) /*-{ return {'alert':m} }-*/;
+
     static native UiResult none() /*-{ return {} }-*/;
 
     final native String alert() /*-{ return this.alert }-*/;
+
     final native String redirectUrl() /*-{ return this.url }-*/;
+
     final native boolean openWindow() /*-{ return this.open_window || false }-*/;
 
-    protected UiResult() {
-    }
+    protected UiResult() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java
index b7e3df3..2d9a76a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/EditGlue.java
@@ -24,13 +24,8 @@
 
 public class EditGlue {
   public static void onAction(
-      ChangeInfo change,
-      EditInfo edit,
-      ActionInfo action,
-      ActionButton button) {
-    RestApi api = ChangeApi.edit(
-          change.legacyId().get())
-      .view(action.id());
+      ChangeInfo change, EditInfo edit, ActionInfo action, ActionButton button) {
+    RestApi api = ChangeApi.edit(change.legacyId().get()).view(action.id());
 
     JavaScriptObject f = get(action.id());
     if (f != null) {
@@ -49,6 +44,5 @@
     return $wnd.Gerrit.edit_actions[id];
   }-*/;
 
-  private EditGlue() {
-  }
+  private EditGlue() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
index e20c577..0873363 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
@@ -21,15 +21,13 @@
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.SimplePanel;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
 public class ExtensionPanel extends FlowPanel {
-  private static final Logger logger =
-      Logger.getLogger(ExtensionPanel.class.getName());
+  private static final Logger logger = Logger.getLogger(ExtensionPanel.class.getName());
   private final GerritUiExtensionPoint extensionPoint;
   private final List<Context> contexts;
 
@@ -79,10 +77,14 @@
       try {
         ctx.onLoad();
       } catch (RuntimeException e) {
-        logger.log(Level.SEVERE,
+        logger.log(
+            Level.SEVERE,
             "Failed to load extension panel for extension point "
-                + extensionPoint.name() + " from plugin " + ctx.getPluginName()
-                + ": " + e.getMessage());
+                + extensionPoint.name()
+                + " from plugin "
+                + ctx.getPluginName()
+                + ": "
+                + e.getMessage());
       }
     }
   }
@@ -99,6 +101,7 @@
 
   static class Definition extends JavaScriptObject {
     static final JavaScriptObject TYPE = init();
+
     private static native JavaScriptObject init() /*-{
       function PanelDefinition(n, c) {
         this.pluginName = n;
@@ -107,36 +110,35 @@
       return PanelDefinition;
     }-*/;
 
-    static native JsArray<Definition> get(String i)
-    /*-{ return $wnd.Gerrit.panels[i] || [] }-*/;
+    static native JsArray<Definition> get(String i) /*-{ return $wnd.Gerrit.panels[i] || [] }-*/;
 
-    protected Definition() {
-    }
+    protected Definition() {}
   }
 
   static class Context extends JavaScriptObject {
-    static final Context create(
-        Definition def,
-        SimplePanel panel) {
+    static final Context create(Definition def, SimplePanel panel) {
       return create(TYPE, def, panel.getElement());
     }
 
     final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
+
     final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
+
     final native String getPluginName() /*-{ return this._d.pluginName; }-*/;
 
     final native void put(String k, String v) /*-{ this.p[k] = v; }-*/;
+
     final native void putInt(String k, int v) /*-{ this.p[k] = v; }-*/;
+
     final native void putBoolean(String k, boolean v) /*-{ this.p[k] = v; }-*/;
+
     final native void putObject(String k, JavaScriptObject v) /*-{ this.p[k] = v; }-*/;
 
-    private static native Context create(
-        JavaScriptObject T,
-        Definition d,
-        Element e)
-    /*-{ return new T(d,e) }-*/;
+    private static native Context create(JavaScriptObject T, Definition d, Element e)
+        /*-{ return new T(d,e) }-*/ ;
 
     private static final JavaScriptObject TYPE = init();
+
     private static native JavaScriptObject init() /*-{
       var T = function(d,e) {
         this._d = d;
@@ -150,7 +152,6 @@
       return T;
     }-*/;
 
-    protected Context() {
-    }
+    protected Context() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java
index 19dbe06..ff495b9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionScreen.java
@@ -73,6 +73,7 @@
 
   static class Definition extends JavaScriptObject {
     static final JavaScriptObject TYPE = init();
+
     private static native JavaScriptObject init() /*-{
       function ScreenDefinition(r, c) {
         this.pattern = r;
@@ -81,11 +82,9 @@
       return ScreenDefinition;
     }-*/;
 
-    static native JsArray<Definition> get(String n)
-    /*-{ return $wnd.Gerrit.screens[n] || [] }-*/;
+    static native JsArray<Definition> get(String n) /*-{ return $wnd.Gerrit.screens[n] || [] }-*/;
 
-    final native JsArrayString match(String t)
-    /*-{
+    final native JsArrayString match(String t) /*-{
       var p = this.pattern;
       if (p instanceof $wnd.RegExp) {
         var m = p.exec(t);
@@ -94,30 +93,24 @@
       return p == t ? [t] : null;
     }-*/;
 
-    protected Definition() {
-    }
+    protected Definition() {}
   }
 
   static class Context extends JavaScriptObject {
-    static final Context create(
-        Definition def,
-        ExtensionScreen view,
-        JsArrayString match) {
+    static final Context create(Definition def, ExtensionScreen view, JsArrayString match) {
       return create(TYPE, def, view, view.getBody().getElement(), match);
     }
 
     final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
+
     final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
 
     private static native Context create(
-        JavaScriptObject T,
-        Definition d,
-        ExtensionScreen s,
-        Element e,
-        JsArrayString m)
-    /*-{ return new T(d,s,e,m) }-*/;
+        JavaScriptObject T, Definition d, ExtensionScreen s, Element e, JsArrayString m)
+        /*-{ return new T(d,s,e,m) }-*/ ;
 
     private static final JavaScriptObject TYPE = init();
+
     private static native JavaScriptObject init() /*-{
       var T = function(d,s,e,m) {
         this._d = d;
@@ -136,7 +129,6 @@
       return T;
     }-*/;
 
-    protected Context() {
-    }
+    protected Context() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java
index 1d7259b..e7d1ed1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionSettingsScreen.java
@@ -21,7 +21,6 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.dom.client.Element;
-
 import java.util.Set;
 
 /** SettingsScreen contributed by a plugin. */
@@ -75,6 +74,7 @@
 
   public static class Definition extends JavaScriptObject {
     static final JavaScriptObject TYPE = init();
+
     private static native JavaScriptObject init() /*-{
       function SettingsScreenDefinition(p, m, c) {
         this.path = p;
@@ -85,43 +85,39 @@
     }-*/;
 
     public static native JsArray<Definition> get(String n)
-    /*-{ return $wnd.Gerrit.settingsScreens[n] || [] }-*/;
+        /*-{ return $wnd.Gerrit.settingsScreens[n] || [] }-*/ ;
 
     public static final Set<String> plugins() {
       return Natives.keys(settingsScreens());
     }
 
     private static native NativeMap<NativeString> settingsScreens()
-    /*-{ return $wnd.Gerrit.settingsScreens; }-*/;
+        /*-{ return $wnd.Gerrit.settingsScreens; }-*/ ;
 
     public final native String getPath() /*-{ return this.path; }-*/;
+
     public final native String getMenu() /*-{ return this.menu; }-*/;
 
-    final native boolean matches(String t)
-    /*-{ return this.path == t; }-*/;
+    final native boolean matches(String t) /*-{ return this.path == t; }-*/;
 
-    protected Definition() {
-    }
+    protected Definition() {}
   }
 
   static class Context extends JavaScriptObject {
-    static final Context create(
-        Definition def,
-        ExtensionSettingsScreen view) {
+    static final Context create(Definition def, ExtensionSettingsScreen view) {
       return create(TYPE, def, view, view.getBody().getElement());
     }
 
     final native void onLoad() /*-{ this._d.onLoad(this) }-*/;
+
     final native JsArray<JavaScriptObject> unload() /*-{ return this._u }-*/;
 
     private static native Context create(
-        JavaScriptObject T,
-        Definition d,
-        ExtensionSettingsScreen s,
-        Element e)
-    /*-{ return new T(d,s,e) }-*/;
+        JavaScriptObject T, Definition d, ExtensionSettingsScreen s, Element e)
+        /*-{ return new T(d,s,e) }-*/ ;
 
     private static final JavaScriptObject TYPE = init();
+
     private static native JavaScriptObject init() /*-{
       var T = function(d,s,e) {
         this._d = d;
@@ -138,7 +134,6 @@
       return T;
     }-*/;
 
-    protected Context() {
-    }
+    protected Context() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java
index 6af244a..ba9c659 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/HtmlTemplate.java
@@ -84,11 +84,7 @@
     return new SafeHtmlBuilder().append(opts.str(id)).asString();
   }
 
-  private static Node parseHtml(
-      String html,
-      IdMap ids,
-      ReplacementMap opts,
-      boolean wantElements) {
+  private static Node parseHtml(String html, IdMap ids, ReplacementMap opts, boolean wantElements) {
     Element div = Document.get().createDivElement();
     div.setInnerHTML(html);
     if (!ids.isEmpty()) {
@@ -101,10 +97,7 @@
   }
 
   private static void attachHandlers(
-      Element e,
-      IdMap ids,
-      ReplacementMap opts,
-      boolean wantElements) {
+      Element e, IdMap ids, ReplacementMap opts, boolean wantElements) {
     if (e.getId() != null) {
       String key = ids.get(e.getId());
       if (key != null) {
@@ -116,7 +109,7 @@
         opts.map(key).attachHandlers(e);
       }
     }
-    for (Element c = e.getFirstChildElement(); c != null;) {
+    for (Element c = e.getFirstChildElement(); c != null; ) {
       attachHandlers(c, ids, opts, wantElements);
       c = c.getNextSiblingElement();
     }
@@ -124,7 +117,9 @@
 
   private static class ReplacementMap extends JavaScriptObject {
     final native ReplacementMap map(String n) /*-{ return this[n] }-*/;
+
     final native String str(String n) /*-{ return ''+this[n] }-*/;
+
     final native void attachHandlers(Element e) /*-{
       for (var k in this) {
         var f = this[k];
@@ -133,25 +128,26 @@
       }
     }-*/;
 
-    protected ReplacementMap() {
-    }
+    protected ReplacementMap() {}
   }
 
   private static class IdMap extends JavaScriptObject {
     final native String get(String i) /*-{ return this[i] }-*/;
+
     final native void remove(String i) /*-{ delete this[i] }-*/;
+
     final native void put(String i, String k) /*-{ this[i] = k }-*/;
+
     final native void put(String k, Element e) /*-{ this[k] = e }-*/;
+
     final native boolean isEmpty() /*-{
       for (var i in this)
         return false;
       return true;
     }-*/;
 
-    protected IdMap() {
-    }
+    protected IdMap() {}
   }
 
-  private HtmlTemplate() {
-  }
+  private HtmlTemplate() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
index fb549ee..29787b8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
@@ -27,16 +27,21 @@
   }
 
   native String url() /*-{ return this._scriptUrl }-*/;
+
   native String name() /*-{ return this.name }-*/;
 
   native boolean loaded() /*-{ return this._success || this._failure != null }-*/;
+
   native Exception failure() /*-{ return this._failure }-*/;
+
   native void failure(Exception e) /*-{ this._failure = e }-*/;
+
   native boolean success() /*-{ return this._success || false }-*/;
+
   native void _initialized() /*-{ this._success = true }-*/;
 
   private static native Plugin create(JavaScriptObject T, String u, String n)
-  /*-{ return new T(u,n) }-*/;
+      /*-{ return new T(u,n) }-*/ ;
 
   private static native JavaScriptObject createType() /*-{
     function Plugin(u, n) {
@@ -90,6 +95,5 @@
     };
   }-*/;
 
-  protected Plugin() {
-  }
+  protected Plugin() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
index ceb0eee..1c59dac 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
@@ -27,15 +27,14 @@
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.DialogBox;
 import com.google.gwtexpui.progress.client.ProgressBar;
-
 import java.util.List;
 
 /** Loads JavaScript plugins with a progress meter visible. */
 public class PluginLoader extends DialogBox {
   private static PluginLoader self;
 
-  public static void load(List<String> plugins,
-      int loadTimeout, AsyncCallback<VoidResult> callback) {
+  public static void load(
+      List<String> plugins, int loadTimeout, AsyncCallback<VoidResult> callback) {
     if (plugins == null || plugins.isEmpty()) {
       callback.onSuccess(VoidResult.create());
     } else {
@@ -59,7 +58,7 @@
   private boolean visible;
 
   private PluginLoader(int loadTimeout, AsyncCallback<VoidResult> cb) {
-    super(/* auto hide */false, /* modal */true);
+    super(/* auto hide */ false, /* modal */ true);
     callback = cb;
     this.loadTimeout = loadTimeout;
     progress = new ProgressBar(Gerrit.C.loadingPlugins());
@@ -73,43 +72,46 @@
       Plugin plugin = Plugin.create(url);
       plugins().put(url, plugin);
       ScriptInjector.fromUrl(url)
-        .setWindow(ScriptInjector.TOP_WINDOW)
-        .setCallback(new LoadCallback(plugin))
-        .inject();
+          .setWindow(ScriptInjector.TOP_WINDOW)
+          .setCallback(new LoadCallback(plugin))
+          .inject();
     }
   }
 
   private void startTimers() {
-    show = new Timer() {
-      @Override
-      public void run() {
-        setText(Window.getTitle());
-        setWidget(progress);
-        setGlassEnabled(true);
-        getGlassElement().addClassName(Gerrit.RESOURCES.css().errorDialogGlass());
-        hide(true);
-        center();
-        visible = true;
-      }
-    };
+    show =
+        new Timer() {
+          @Override
+          public void run() {
+            setText(Window.getTitle());
+            setWidget(progress);
+            setGlassEnabled(true);
+            getGlassElement().addClassName(Gerrit.RESOURCES.css().errorDialogGlass());
+            hide(true);
+            center();
+            visible = true;
+          }
+        };
     show.schedule(500);
 
-    update = new Timer() {
-      private int cycle;
+    update =
+        new Timer() {
+          private int cycle;
 
-      @Override
-      public void run() {
-        progress.setValue(100 * ++cycle * 250 / loadTimeout);
-      }
-    };
+          @Override
+          public void run() {
+            progress.setValue(100 * ++cycle * 250 / loadTimeout);
+          }
+        };
     update.scheduleRepeating(250);
 
-    timeout = new Timer() {
-      @Override
-      public void run() {
-        finish();
-      }
-    };
+    timeout =
+        new Timer() {
+          @Override
+          public void run() {
+            finish();
+          }
+        };
     timeout.schedule(loadTimeout);
   }
 
@@ -166,8 +168,7 @@
     return failed;
   }
 
-  private static native NativeMap<Plugin> plugins()
-  /*-{ return $wnd.Gerrit.plugins }-*/;
+  private static native NativeMap<Plugin> plugins() /*-{ return $wnd.Gerrit.plugins }-*/;
 
   private class LoadCallback implements Callback<Void, Exception> {
     private final Plugin plugin;
@@ -177,8 +178,7 @@
     }
 
     @Override
-    public void onSuccess(Void result) {
-    }
+    public void onSuccess(Void result) {}
 
     @Override
     public void onFailure(Exception reason) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java
index 330ec15..7cf4fbb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginName.java
@@ -21,11 +21,10 @@
 /**
  * Determines the name a plugin has been installed under.
  *
- * This implementation guesses the name a plugin runs under by looking at the
- * JavaScript call stack and identifying the URL of the script file calling
- * {@code Gerrit.install()}. The simple approach applied here is looking at
- * the source URLs and extracting the name out of the string, e.g.:
- * {@code "http://localhost:8080/plugins/[name]/static/foo.js"}.
+ * <p>This implementation guesses the name a plugin runs under by looking at the JavaScript call
+ * stack and identifying the URL of the script file calling {@code Gerrit.install()}. The simple
+ * approach applied here is looking at the source URLs and extracting the name out of the string,
+ * e.g.: {@code "http://localhost:8080/plugins/[name]/static/foo.js"}.
  */
 class PluginName {
   private static final String UNKNOWN = "<unknown>";
@@ -35,7 +34,7 @@
   }
 
   static String getCallerUrl() {
-    return GWT.<PluginName> create(PluginName.class).findCallerUrl();
+    return GWT.<PluginName>create(PluginName.class).findCallerUrl();
   }
 
   static String fromUrl(String url) {
@@ -74,10 +73,9 @@
   }
 
   protected static final native JavaScriptException makeException()
-  /*-{ try { null.a() } catch (e) { return e } }-*/;
+      /*-{ try { null.a() } catch (e) { return e } }-*/ ;
 
-  private static native boolean hasStack(JavaScriptException e)
-  /*-{ return !!e.stack }-*/;
+  private static native boolean hasStack(JavaScriptException e) /*-{ return !!e.stack }-*/;
 
   /** Extracts URL from the stack frame. */
   static class PluginNameMoz extends PluginName {
@@ -104,6 +102,6 @@
     }
 
     private static native JsArrayString getStack(JavaScriptException e)
-    /*-{ return e.stack ? e.stack.split('\n') : [] }-*/;
+        /*-{ return e.stack ? e.stack.split('\n') : [] }-*/ ;
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PopupHelper.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PopupHelper.java
index 5f28e14..173b369 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PopupHelper.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PopupHelper.java
@@ -46,15 +46,16 @@
     final PopupPanel p = new PopupPanel(true);
     p.setStyleName(Resources.I.style().popup());
     p.addAutoHidePartner(activatingButton.getElement());
-    p.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        activatingButton.unlink();
-        if (popup == p) {
-          popup = null;
-        }
-      }
-    });
+    p.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            activatingButton.unlink();
+            if (popup == p) {
+              popup = null;
+            }
+          }
+        });
     p.add(panel);
     p.showRelativeTo(activatingButton);
     GlobalKey.dialog(p);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java
index f9084d9..92070f8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ProjectGlue.java
@@ -24,13 +24,8 @@
 
 public class ProjectGlue {
   public static void onAction(
-      Project.NameKey project,
-      BranchInfo branch,
-      ActionInfo action,
-      ActionButton button) {
-    RestApi api = ProjectApi.project(project)
-        .view("branches").id(branch.ref())
-        .view(action.id());
+      Project.NameKey project, BranchInfo branch, ActionInfo action, ActionButton button) {
+    RestApi api = ProjectApi.project(project).view("branches").id(branch.ref()).view(action.id());
     JavaScriptObject f = branchAction(action.id());
     if (f != null) {
       ActionContext c = ActionContext.create(api);
@@ -44,10 +39,7 @@
     }
   }
 
-  public static void onAction(
-      Project.NameKey project,
-      ActionInfo action,
-      ActionButton button) {
+  public static void onAction(Project.NameKey project, ActionInfo action, ActionButton button) {
     RestApi api = ProjectApi.project(project).view(action.id());
     JavaScriptObject f = projectAction(action.id());
     if (f != null) {
@@ -69,6 +61,5 @@
     return $wnd.Gerrit.branch_actions[id];
   }-*/;
 
-  private ProjectGlue() {
-  }
+  private ProjectGlue() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java
index 50ebce7..2d3b393 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/RevisionGlue.java
@@ -24,14 +24,8 @@
 
 public class RevisionGlue {
   public static void onAction(
-      ChangeInfo change,
-      RevisionInfo revision,
-      ActionInfo action,
-      ActionButton button) {
-    RestApi api = ChangeApi.revision(
-          change.legacyId().get(),
-          revision.name())
-      .view(action.id());
+      ChangeInfo change, RevisionInfo revision, ActionInfo action, ActionButton button) {
+    RestApi api = ChangeApi.revision(change.legacyId().get(), revision.name()).view(action.id());
 
     JavaScriptObject f = get(action.id());
     if (f != null) {
@@ -50,6 +44,5 @@
     return $wnd.Gerrit.revision_actions[id];
   }-*/;
 
-  private RevisionGlue() {
-  }
+  private RevisionGlue() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.java
index 3bfb297..a0eaef7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.java
@@ -17,7 +17,7 @@
 import com.google.gwt.i18n.client.Constants;
 
 public interface OpenIdConstants extends Constants {
-  String nameGoogle();
   String nameLaunchpad();
+
   String nameYahoo();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.properties
index 08ddf38..d6e8de6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/auth/openid/OpenIdConstants.properties
@@ -1,3 +1,2 @@
-nameGoogle = Google Account
 nameLaunchpad = Launchpad ID
 nameYahoo = Yahoo! ID
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java
index bbd939a..77fddeb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/blame/BlameInfo.java
@@ -20,12 +20,14 @@
 
 public class BlameInfo extends JavaScriptObject {
   public final native String author() /*-{ return this.author; }-*/;
+
   public final native String id() /*-{ return this.id; }-*/;
+
   public final native String commitMsg() /*-{ return this.commit_msg; }-*/;
+
   public final native int time() /*-{ return this.time; }-*/;
+
   public final native JsArray<RangeInfo> ranges() /*-{ return this.ranges; }-*/;
 
-  protected BlameInfo() {
-  }
-
+  protected BlameInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java
index e93bcd9..b445b75 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java
@@ -32,12 +32,15 @@
 
   @Override
   void send(String message) {
-    ChangeApi.abandon(id.get(), message, new GerritCallback<ChangeInfo>() {
-      @Override
-      public void onSuccess(ChangeInfo result) {
-        Gerrit.display(PageLinks.toChange(id));
-        hide();
-      }
-    });
+    ChangeApi.abandon(
+        id.get(),
+        message,
+        new GerritCallback<ChangeInfo>() {
+          @Override
+          public void onSuccess(ChangeInfo result) {
+            Gerrit.display(PageLinks.toChange(id));
+            hide();
+          }
+        });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java
index 396bc8a..5b3ee29 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ActionMessageBox.java
@@ -33,6 +33,7 @@
 
 abstract class ActionMessageBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, ActionMessageBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface Style extends CssResource {
@@ -64,14 +65,15 @@
     final PopupPanel p = new PopupPanel(true);
     p.setStyleName(style.popup());
     p.addAutoHidePartner(activatingButton.getElement());
-    p.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        if (popup == p) {
-          popup = null;
-        }
-      }
-    });
+    p.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            if (popup == p) {
+              popup = null;
+            }
+          }
+        });
     p.add(this);
     p.showRelativeTo(activatingButton);
     GlobalKey.dialog(p);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
index 36107ee..ada28af 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
@@ -27,19 +27,32 @@
 import com.google.gwt.uibinder.client.UiBinder;
 import com.google.gwt.uibinder.client.UiField;
 import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.user.client.Window;
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.TreeSet;
 
 class Actions extends Composite {
   private static final String[] CORE = {
-    "abandon", "cherrypick", "followup", "hashtags", "publish",
-    "rebase", "restore", "revert", "submit", "topic", "/",};
+    "abandon",
+    "assignee",
+    "cherrypick",
+    "description",
+    "followup",
+    "hashtags",
+    "publish",
+    "rebase",
+    "restore",
+    "revert",
+    "submit",
+    "topic",
+    "/",
+  };
 
   interface Binder extends UiBinder<FlowPanel, Actions> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   @UiField Button cherrypick;
@@ -50,6 +63,8 @@
   @UiField Button abandon;
   private AbandonAction abandonAction;
 
+  @UiField Button deleteChange;
+
   @UiField Button restore;
   private RestoreAction restoreAction;
 
@@ -90,21 +105,20 @@
 
     initChangeActions(info, hasUser);
 
-    NativeMap<ActionInfo> actionMap = revInfo.hasActions()
-        ? revInfo.actions()
-        : NativeMap.<ActionInfo> create();
+    NativeMap<ActionInfo> actionMap =
+        revInfo.hasActions() ? revInfo.actions() : NativeMap.<ActionInfo>create();
     actionMap.copyKeysIntoChildren("id");
     reloadRevisionActions(actionMap);
   }
 
   private void initChangeActions(ChangeInfo info, boolean hasUser) {
-    NativeMap<ActionInfo> actions = info.hasActions()
-        ? info.actions()
-        : NativeMap.<ActionInfo> create();
+    NativeMap<ActionInfo> actions =
+        info.hasActions() ? info.actions() : NativeMap.<ActionInfo>create();
     actions.copyKeysIntoChildren("id");
 
     if (hasUser) {
       a2b(actions, "abandon", abandon);
+      a2b(actions, "/", deleteChange);
       a2b(actions, "restore", restore);
       a2b(actions, "revert", revert);
       a2b(actions, "followup", followUp);
@@ -123,10 +137,7 @@
       ActionInfo action = actions.get("submit");
       submit.setTitle(action.title());
       submit.setEnabled(action.enabled());
-      submit.setHTML(new SafeHtmlBuilder()
-          .openDiv()
-          .append(action.label())
-          .closeDiv());
+      submit.setHTML(new SafeHtmlBuilder().openDiv().append(action.label()).closeDiv());
       submit.setEnabled(action.enabled());
     }
     submit.setVisible(canSubmit);
@@ -161,8 +172,7 @@
   @UiHandler("followUp")
   void onFollowUp(@SuppressWarnings("unused") ClickEvent e) {
     if (followUpAction == null) {
-      followUpAction = new FollowUpAction(followUp, project,
-          branch, topic, key);
+      followUpAction = new FollowUpAction(followUp, project, branch, topic, key);
     }
     followUpAction.show();
   }
@@ -175,6 +185,13 @@
     abandonAction.show();
   }
 
+  @UiHandler("deleteChange")
+  void onDeleteChange(@SuppressWarnings("unused") ClickEvent e) {
+    if (Window.confirm(Resources.C.deleteChange())) {
+      ChangeActions.delete(changeId, deleteChange);
+    }
+  }
+
   @UiHandler("restore")
   void onRestore(@SuppressWarnings("unused") ClickEvent e) {
     if (restoreAction == null) {
@@ -185,8 +202,8 @@
 
   @UiHandler("rebase")
   void onRebase(@SuppressWarnings("unused") ClickEvent e) {
-    RebaseAction.call(rebase, project, changeInfo.branch(), changeId, revision,
-        rebaseParentNotCurrent);
+    RebaseAction.call(
+        rebase, project, changeInfo.branch(), changeId, revision, rebaseParentNotCurrent);
   }
 
   @UiHandler("submit")
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
index bc5a321..d0e5c3e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
@@ -72,6 +72,9 @@
     <g:Button ui:field='abandon' styleName='' visible='false'>
       <div><ui:msg>Abandon</ui:msg></div>
     </g:Button>
+    <g:Button ui:field='deleteChange' styleName='' visible='false'>
+      <div><ui:msg>Delete Change</ui:msg></div>
+    </g:Button>
     <g:Button ui:field='restore' styleName='' visible='false'>
       <div><ui:msg>Restore</ui:msg></div>
     </g:Button>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java
index 195623a..514b4ad 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java
@@ -32,8 +32,12 @@
   private AddFileBox addBox;
   private PopupPanel popup;
 
-  AddFileAction(Change.Id changeId, RevisionInfo revision,
-      ChangeScreen.Style style, Widget addButton, FileTable files) {
+  AddFileAction(
+      Change.Id changeId,
+      RevisionInfo revision,
+      ChangeScreen.Style style,
+      Widget addButton,
+      FileTable files) {
     this.changeId = changeId;
     this.revision = revision;
     this.style = style;
@@ -56,14 +60,15 @@
     final PopupPanel p = new PopupPanel(true);
     p.setStyleName(style.replyBox());
     p.addAutoHidePartner(addButton.getElement());
-    p.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        if (popup == p) {
-          popup = null;
-        }
-      }
-    });
+    p.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            if (popup == p) {
+              popup = null;
+            }
+          }
+        });
     p.add(addBox);
     p.showRelativeTo(addButton);
     GlobalKey.dialog(p);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java
index f747e0d..21bb590 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java
@@ -37,6 +37,7 @@
 
 class AddFileBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, AddFileBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private final Change.Id changeId;
@@ -55,19 +56,21 @@
     this.fileTable = files;
 
     path = new RemoteSuggestBox(new PathSuggestOracle(changeId, revision));
-    path.addSelectionHandler(new SelectionHandler<String>() {
-      @Override
-      public void onSelection(SelectionEvent<String> event) {
-        open(event.getSelectedItem());
-      }
-    });
-    path.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
-      @Override
-      public void onClose(CloseEvent<RemoteSuggestBox> event) {
-        hide();
-        fileTable.registerKeys();
-      }
-    });
+    path.addSelectionHandler(
+        new SelectionHandler<String>() {
+          @Override
+          public void onSelection(SelectionEvent<String> event) {
+            open(event.getSelectedItem());
+          }
+        });
+    path.addCloseHandler(
+        new CloseHandler<RemoteSuggestBox>() {
+          @Override
+          public void onClose(CloseEvent<RemoteSuggestBox> event) {
+            hide();
+            fileTable.registerKeys();
+          }
+        });
 
     initWidget(uiBinder.createAndBindUi(this));
   }
@@ -87,9 +90,7 @@
 
   private void open(String path) {
     hide();
-    Gerrit.display(Dispatcher.toEditScreen(
-        new PatchSet.Id(changeId, revision._number()),
-        path));
+    Gerrit.display(Dispatcher.toEditScreen(new PatchSet.Id(changeId, revision._number()), path));
   }
 
   @UiHandler("cancel")
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
new file mode 100644
index 0000000..7256497
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
@@ -0,0 +1,234 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.change;
+
+import com.google.gerrit.client.FormatUtil;
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.NotSignedInDialog;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.Util;
+import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.ui.InlineHyperlink;
+import com.google.gerrit.client.ui.RemoteSuggestBox;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwt.core.client.GWT;
+import com.google.gwt.dom.client.Element;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.logical.shared.CloseEvent;
+import com.google.gwt.event.logical.shared.CloseHandler;
+import com.google.gwt.event.logical.shared.SelectionEvent;
+import com.google.gwt.event.logical.shared.SelectionHandler;
+import com.google.gwt.uibinder.client.UiBinder;
+import com.google.gwt.uibinder.client.UiField;
+import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.EventListener;
+import com.google.gwt.user.client.rpc.StatusCodeException;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.Image;
+import com.google.gwt.user.client.ui.UIObject;
+
+/** Edit assignee using auto-completion. */
+public class Assignee extends Composite {
+  interface Binder extends UiBinder<HTMLPanel, Assignee> {}
+
+  private static final Binder uiBinder = GWT.create(Binder.class);
+
+  @UiField Element show;
+  @UiField InlineHyperlink assigneeLink;
+  @UiField Image editAssigneeIcon;
+  @UiField Element form;
+  @UiField Element error;
+
+  @UiField(provided = true)
+  RemoteSuggestBox suggestBox;
+
+  private AssigneeSuggestOracle assigneeSuggestOracle;
+  private Change.Id changeId;
+  private boolean canEdit;
+  private AccountInfo currentAssignee;
+
+  Assignee() {
+    assigneeSuggestOracle = new AssigneeSuggestOracle();
+    suggestBox = new RemoteSuggestBox(assigneeSuggestOracle);
+    suggestBox.setVisibleLength(55);
+    suggestBox.setHintText(Util.C.approvalTableEditAssigneeHint());
+    suggestBox.addCloseHandler(
+        new CloseHandler<RemoteSuggestBox>() {
+          @Override
+          public void onClose(CloseEvent<RemoteSuggestBox> event) {
+            Assignee.this.onCancel(null);
+          }
+        });
+    suggestBox.addSelectionHandler(
+        new SelectionHandler<String>() {
+          @Override
+          public void onSelection(SelectionEvent<String> event) {
+            editAssignee(event.getSelectedItem());
+          }
+        });
+
+    initWidget(uiBinder.createAndBindUi(this));
+    editAssigneeIcon.addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            onOpenForm();
+          }
+        },
+        ClickEvent.getType());
+  }
+
+  void set(ChangeInfo info) {
+    this.changeId = info.legacyId();
+    this.canEdit = info.hasActions() && info.actions().containsKey("assignee");
+    assigneeSuggestOracle.setChange(info);
+    setAssignee(info.assignee());
+    editAssigneeIcon.setVisible(canEdit);
+    if (!canEdit) {
+      show.setTitle(null);
+    }
+  }
+
+  void onOpenForm() {
+    UIObject.setVisible(form, true);
+    UIObject.setVisible(show, false);
+    UIObject.setVisible(error, false);
+    editAssigneeIcon.setVisible(false);
+    suggestBox.setFocus(true);
+    if (currentAssignee != null) {
+      suggestBox.setText(FormatUtil.nameEmail(currentAssignee));
+      suggestBox.selectAll();
+    } else {
+      suggestBox.setText("");
+    }
+  }
+
+  void onCloseForm() {
+    UIObject.setVisible(form, false);
+    UIObject.setVisible(show, true);
+    UIObject.setVisible(error, false);
+    editAssigneeIcon.setVisible(true);
+    suggestBox.setFocus(false);
+  }
+
+  @UiHandler("assign")
+  void onEditAssignee(@SuppressWarnings("unused") ClickEvent e) {
+    if (canEdit) {
+      editAssignee(suggestBox.getText());
+    }
+  }
+
+  @UiHandler("cancel")
+  void onCancel(@SuppressWarnings("unused") ClickEvent e) {
+    onCloseForm();
+  }
+
+  private void editAssignee(final String assignee) {
+    if (assignee.trim().isEmpty()) {
+      ChangeApi.deleteAssignee(
+          changeId.get(),
+          new GerritCallback<AccountInfo>() {
+            @Override
+            public void onSuccess(AccountInfo result) {
+              onCloseForm();
+              setAssignee(null);
+            }
+
+            @Override
+            public void onFailure(Throwable err) {
+              if (isSigninFailure(err)) {
+                new NotSignedInDialog().center();
+              } else {
+                UIObject.setVisible(error, true);
+                error.setInnerText(
+                    err instanceof StatusCodeException
+                        ? ((StatusCodeException) err).getEncodedResponse()
+                        : err.getMessage());
+              }
+            }
+          });
+    } else {
+      ChangeApi.setAssignee(
+          changeId.get(),
+          assignee,
+          new GerritCallback<AccountInfo>() {
+            @Override
+            public void onSuccess(AccountInfo result) {
+              onCloseForm();
+              setAssignee(result);
+              Reviewers reviewers = getReviewers();
+              if (reviewers != null) {
+                reviewers.updateReviewerList();
+              }
+            }
+
+            @Override
+            public void onFailure(Throwable err) {
+              if (isSigninFailure(err)) {
+                new NotSignedInDialog().center();
+              } else {
+                UIObject.setVisible(error, true);
+                error.setInnerText(
+                    err instanceof StatusCodeException
+                        ? ((StatusCodeException) err).getEncodedResponse()
+                        : err.getMessage());
+              }
+            }
+          });
+    }
+  }
+
+  private void setAssignee(AccountInfo assignee) {
+    currentAssignee = assignee;
+    assigneeLink.setText(assignee != null ? getName(assignee) : null);
+    assigneeLink.setTargetHistoryToken(
+        assignee != null
+            ? PageLinks.toAssigneeQuery(
+                assignee.name() != null
+                    ? assignee.name()
+                    : assignee.email() != null
+                        ? assignee.email()
+                        : String.valueOf(assignee._accountId()))
+            : "");
+  }
+
+  private Reviewers getReviewers() {
+    Element e = DOM.getParent(getElement());
+    for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
+      EventListener l = DOM.getEventListener(e);
+      if (l instanceof ChangeScreen) {
+        ChangeScreen screen = (ChangeScreen) l;
+        return screen.reviewers;
+      }
+    }
+    return null;
+  }
+
+  private String getName(AccountInfo info) {
+    if (info.name() != null) {
+      return info.name();
+    }
+    if (info.email() != null) {
+      return info.email();
+    }
+    return Gerrit.info().user().anonymousCowardName();
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml
new file mode 100644
index 0000000..d5a7239
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.ui.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<ui:UiBinder
+    xmlns:ui='urn:ui:com.google.gwt.uibinder'
+    xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
+    xmlns:g='urn:import:com.google.gwt.user.client.ui'
+    xmlns:u='urn:import:com.google.gerrit.client.ui'>
+  <ui:with field='ico' type='com.google.gerrit.client.GerritResources'/>
+  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
+  <ui:style gss='false'>
+    .suggestBox {
+      margin-bottom: 2px;
+    }
+
+    .error {
+      color: #D33D3D;
+      font-weight: bold;
+    }
+
+    .editAssignee,
+    .cancel {
+      cursor: pointer;
+      float: right;
+    }
+  </ui:style>
+  <g:HTMLPanel>
+    <div ui:field='show'>
+      <u:InlineHyperlink ui:field='assigneeLink'
+          title='Search for changes assigned to this user'/>
+      <g:Image ui:field='editAssigneeIcon'
+          resource='{ico.editUser}'
+          styleName='{style.editAssignee}'
+          title='Assign User to Change'/>
+    </div>
+    <div ui:field='form' style='display: none' aria-hidden='true'>
+      <u:RemoteSuggestBox ui:field='suggestBox' styleName='{style.suggestBox}'/>
+      <div ui:field='error'
+           class='{style.error}'
+           style='display: none' aria-hidden='true'/>
+      <div>
+        <g:Button ui:field='assign' styleName='{res.style.button}'>
+          <div>Assign</div>
+        </g:Button>
+        <g:Button ui:field='cancel'
+            styleName='{res.style.button}'
+            addStyleNames='{style.cancel}'>
+          <div>Cancel</div>
+        </g:Button>
+      </div>
+    </div>
+   </g:HTMLPanel>
+  </ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java
new file mode 100644
index 0000000..c8bbfc3
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AssigneeSuggestOracle.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.change;
+
+import com.google.gerrit.client.account.AccountApi;
+import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.ui.AccountSuggestOracle.AccountSuggestion;
+import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
+import com.google.gwt.core.client.JsArray;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** REST API based suggestion Oracle for assignee */
+public class AssigneeSuggestOracle extends SuggestAfterTypingNCharsOracle {
+
+  private ChangeInfo change;
+
+  public void setChange(ChangeInfo change) {
+    this.change = change;
+  }
+
+  @Override
+  protected void _onRequestSuggestions(Request req, Callback cb) {
+    AccountApi.suggest(
+        getQuery(req),
+        req.getLimit(),
+        new GerritCallback<JsArray<AccountInfo>>() {
+          @Override
+          public void onSuccess(JsArray<AccountInfo> result) {
+            List<AccountSuggestion> r = new ArrayList<>(result.length());
+            for (AccountInfo reviewer : Natives.asList(result)) {
+              r.add(new AccountSuggestion(reviewer, req.getQuery()));
+            }
+            cb.onSuggestionsReady(req, new Response(r));
+          }
+
+          @Override
+          public void onFailure(Throwable err) {
+            List<Suggestion> r = Collections.emptyList();
+            cb.onSuggestionsReady(req, new Response(r));
+          }
+        });
+  }
+
+  private String getQuery(Request req) {
+    StringBuilder query = new StringBuilder();
+    query.append(req.getQuery());
+    if (change != null) {
+      query.append(" cansee:").append(change._number());
+    }
+    return query.toString();
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
new file mode 100644
index 0000000..1be60cc
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client.change;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.user.client.rpc.AsyncCallback;
+import com.google.gwt.user.client.ui.Button;
+
+public class ChangeActions {
+
+  static void publish(Change.Id id, String revision, Button... draftButtons) {
+    ChangeApi.publish(id.get(), revision, cs(id, draftButtons));
+  }
+
+  static void delete(Change.Id id, String revision, Button... draftButtons) {
+    ChangeApi.deleteRevision(id.get(), revision, cs(id, draftButtons));
+  }
+
+  static void delete(Change.Id id, Button... draftButtons) {
+    ChangeApi.deleteChange(id.get(), mine(draftButtons));
+  }
+
+  public static GerritCallback<JavaScriptObject> cs(
+      final Change.Id id, final Button... draftButtons) {
+    setEnabled(false, draftButtons);
+    return new GerritCallback<JavaScriptObject>() {
+      @Override
+      public void onSuccess(JavaScriptObject result) {
+        Gerrit.display(PageLinks.toChange(id));
+      }
+
+      @Override
+      public void onFailure(Throwable err) {
+        setEnabled(true, draftButtons);
+        if (SubmitFailureDialog.isConflict(err)) {
+          new SubmitFailureDialog(err.getMessage()).center();
+          Gerrit.display(PageLinks.toChange(id));
+        } else {
+          super.onFailure(err);
+        }
+      }
+    };
+  }
+
+  private static AsyncCallback<JavaScriptObject> mine(final Button... draftButtons) {
+    setEnabled(false, draftButtons);
+    return new GerritCallback<JavaScriptObject>() {
+      @Override
+      public void onSuccess(JavaScriptObject result) {
+        Gerrit.display(PageLinks.MINE);
+      }
+
+      @Override
+      public void onFailure(Throwable err) {
+        setEnabled(true, draftButtons);
+        if (SubmitFailureDialog.isConflict(err)) {
+          new SubmitFailureDialog(err.getMessage()).center();
+          Gerrit.display(PageLinks.MINE);
+        } else {
+          super.onFailure(err);
+        }
+      }
+    };
+  }
+
+  private static void setEnabled(boolean enabled, Button... draftButtons) {
+    if (draftButtons != null) {
+      for (Button b : draftButtons) {
+        b.setEnabled(enabled);
+      }
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
index 63de389..bd211b7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.java
@@ -18,39 +18,66 @@
 
 public interface ChangeConstants extends Constants {
   String previousChange();
+
   String nextChange();
+
   String openChange();
+
   String reviewedFileTitle();
+
   String editFileInline();
+
   String removeFileInline();
+
   String restoreFileInline();
 
   String openLastFile();
+
   String openCommitMessage();
 
   String patchSet();
+
   String commit();
+
   String date();
+
   String author();
+
   String draft();
 
   String notAvailable();
+
   String relatedChanges();
+
   String relatedChangesTooltip();
+
   String conflictingChanges();
+
   String conflictingChangesTooltip();
+
   String cherryPicks();
+
   String cherryPicksTooltip();
+
   String sameTopic();
+
   String sameTopicTooltip();
+
   String submittedTogether();
+
   String submittedTogetherTooltip();
+
   String noChanges();
+
   String indirectAncestor();
+
   String merged();
+
   String abandoned();
 
   String deleteChangeEdit();
-  String deleteDraftChange();
+
+  String deleteChange();
+
   String deleteDraftRevision();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
index 5b4f18f..dd4760d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
@@ -34,5 +34,5 @@
 deleteChangeEdit = Delete Change Edit?\n\
   \n\
   All changes made in the edit revision will be lost.
-deleteDraftChange = Delete Draft Change?
+deleteChange = Delete Change?
 deleteDraftRevision = Delete Draft Revision?
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java
index 62c3636..4eead56 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.java
@@ -18,16 +18,30 @@
 
 public interface ChangeMessages extends Messages {
   String patchSets(String currentlyViewedPatchSet, int currentPatchSet);
+
   String changeWithNoRevisions(int changeId);
+
   String relatedChanges(int count);
+
   String relatedChanges(String count);
+
   String conflictingChanges(int count);
+
   String conflictingChanges(String count);
+
   String cherryPicks(int count);
+
   String cherryPicks(String count);
+
   String sameTopic(int count);
+
   String sameTopic(String count);
+
   String submittedTogether(int count);
+
   String submittedTogether(String count);
+
   String editPatchSet(int patchSet);
+
+  String failedToLoadFileList(String error);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties
index 6461899..743945d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeMessages.properties
@@ -6,3 +6,4 @@
 sameTopic = Same Topic ({0})
 submittedTogether = Submitted Together ({0})
 editPatchSet = edit:{0}
+failedToLoadFileList = Failed to load file list: {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index 87997d1..8b699da 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.client.change;
 
 import com.google.gerrit.client.AvatarImage;
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.GerritUiExtensionPoint;
+import com.google.gerrit.client.NotFoundScreen;
 import com.google.gerrit.client.api.ChangeGlue;
 import com.google.gerrit.client.api.ExtensionPanel;
 import com.google.gerrit.client.changes.ChangeApi;
@@ -99,33 +101,49 @@
 import com.google.gwtexpui.globalkey.client.KeyCommandSet;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
 import com.google.gwtorm.client.KeyUtil;
-
-import net.codemirror.lib.CodeMirror;
-
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import net.codemirror.lib.CodeMirror;
 
 public class ChangeScreen extends Screen {
+  private static final Logger logger = Logger.getLogger(ChangeScreen.class.getName());
+
   interface Binder extends UiBinder<HTMLPanel, ChangeScreen> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface Style extends CssResource {
     String avatar();
+
     String hashtagName();
+
     String hashtagIcon();
+
     String highlight();
+
     String labelName();
+
     String label_may();
+
     String label_need();
+
     String label_ok();
+
     String label_reject();
+
     String label_user();
+
     String pushCertStatus();
+
     String replyBox();
+
     String selected();
+
     String notCurrentPatchSet();
   }
 
@@ -141,7 +159,7 @@
   }
 
   private final Change.Id changeId;
-  private String base;
+  private DiffObject base;
   private String revision;
   private ChangeInfo changeInfo;
   private boolean hasDraftComments;
@@ -165,6 +183,8 @@
   @UiField ToggleButton star;
   @UiField Anchor permalink;
 
+  @UiField Assignee assignee;
+  @UiField Element assigneeRow;
   @UiField Element ccText;
   @UiField Reviewers reviewers;
   @UiField Hashtags hashtags;
@@ -199,6 +219,7 @@
   @UiField FileTable files;
   @UiField ListBox diffBase;
   @UiField History history;
+  @UiField SimplePanel historyExtensionRight;
 
   @UiField Button includedIn;
   @UiField Button patchSets;
@@ -209,7 +230,6 @@
   @UiField Button rebaseEdit;
   @UiField Button deleteEdit;
   @UiField Button publish;
-  @UiField Button deleteChange;
   @UiField Button deleteRevision;
   @UiField Button openAll;
   @UiField Button editMode;
@@ -219,6 +239,8 @@
   @UiField Button renameFile;
   @UiField Button expandAll;
   @UiField Button collapseAll;
+  @UiField Button hideTaggedComments;
+  @UiField Button showTaggedComments;
   @UiField QuickApprove quickApprove;
 
   private ReplyAction replyAction;
@@ -229,10 +251,14 @@
   private DeleteFileAction deleteFileAction;
   private RenameFileAction renameFileAction;
 
-  public ChangeScreen(Change.Id changeId, String base, String revision,
-      boolean openReplyBox, FileTable.Mode mode) {
+  public ChangeScreen(
+      Change.Id changeId,
+      DiffObject base,
+      String revision,
+      boolean openReplyBox,
+      FileTable.Mode mode) {
     this.changeId = changeId;
-    this.base = normalize(base);
+    this.base = base;
     this.revision = normalize(revision);
     this.openReplyBox = openReplyBox;
     this.fileTableMode = mode;
@@ -241,9 +267,7 @@
   }
 
   PatchSet.Id getPatchSetId() {
-    return new PatchSet.Id(
-        changeInfo.legacyId(),
-        changeInfo.revisions().get(revision)._number());
+    return new PatchSet.Id(changeInfo.legacyId(), changeInfo.revisions().get(revision)._number());
   }
 
   @Override
@@ -251,50 +275,68 @@
     super.onLoad();
     CallbackGroup group = new CallbackGroup();
     if (Gerrit.isSignedIn()) {
-      ChangeList.query("change:" + changeId.get() + " has:draft",
-          Collections.<ListChangesOption> emptySet(),
-          group.add(new AsyncCallback<ChangeList>() {
-            @Override
-            public void onSuccess(ChangeList result) {
-              hasDraftComments = result.length() > 0;
-            }
+      ChangeList.query(
+          "change:" + changeId.get() + " has:draft",
+          Collections.<ListChangesOption>emptySet(),
+          group.add(
+              new AsyncCallback<ChangeList>() {
+                @Override
+                public void onSuccess(ChangeList result) {
+                  hasDraftComments = result.length() > 0;
+                }
 
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          }));
-      ChangeApi.editWithFiles(changeId.get(), group.add(
-          new AsyncCallback<EditInfo>() {
-            @Override
-            public void onSuccess(EditInfo result) {
-              edit = result;
-            }
+                @Override
+                public void onFailure(Throwable caught) {}
+              }));
+      ChangeApi.editWithFiles(
+          changeId.get(),
+          group.add(
+              new AsyncCallback<EditInfo>() {
+                @Override
+                public void onSuccess(EditInfo result) {
+                  edit = result;
+                }
 
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          }));
+                @Override
+                public void onFailure(Throwable caught) {}
+              }));
     }
-    loadChangeInfo(true, group.addFinal(
-        new GerritCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(final ChangeInfo info) {
-            info.init();
-            addExtensionPoints(info, initCurrentRevision(info));
-
-            RevisionInfo rev = info.revision(revision);
-            CallbackGroup group = new CallbackGroup();
-            loadCommit(rev, group);
-
-            group.addListener(new GerritCallback<Void>() {
+    loadChangeInfo(
+        true,
+        group.addFinal(
+            new GerritCallback<ChangeInfo>() {
               @Override
-              public void onSuccess(Void result) {
-                loadConfigInfo(info, base);
+              public void onSuccess(final ChangeInfo info) {
+                info.init();
+                addExtensionPoints(info, initCurrentRevision(info));
+
+                final RevisionInfo rev = info.revision(revision);
+                CallbackGroup group = new CallbackGroup();
+                loadCommit(rev, group);
+
+                group.addListener(
+                    new GerritCallback<Void>() {
+                      @Override
+                      public void onSuccess(Void result) {
+                        if (base.isBase() && rev.isMerge()) {
+                          base =
+                              DiffObject.parse(
+                                  info.legacyId(),
+                                  Gerrit.getUserPreferences().defaultBaseForMerges().getBase());
+                        }
+                        loadConfigInfo(info, base);
+                        JsArray<MessageInfo> mAr = info.messages();
+                        for (int i = 0; i < mAr.length(); i++) {
+                          if (mAr.get(i).tag() != null) {
+                            hideTaggedComments.setVisible(true);
+                            break;
+                          }
+                        }
+                      }
+                    });
+                group.done();
               }
-            });
-            group.done();
-          }
-        }));
+            }));
   }
 
   private RevisionInfo initCurrentRevision(ChangeInfo info) {
@@ -337,25 +379,35 @@
   }
 
   private void addExtensionPoints(ChangeInfo change, RevisionInfo rev) {
-    addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER,
-        headerExtension, change, rev);
-    addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
-        headerExtensionMiddle, change, rev);
-    addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_POP_DOWNS,
-        headerExtensionRight, change, rev);
+    addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER, headerExtension, change, rev);
     addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
-        changeExtension, change, rev);
+        GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
+        headerExtensionMiddle,
+        change,
+        rev);
+    addExtensionPoint(
+        GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_POP_DOWNS,
+        headerExtensionRight,
+        change,
+        rev);
+    addExtensionPoint(
+        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK, changeExtension, change, rev);
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
-        relatedExtension, change, rev);
+        relatedExtension,
+        change,
+        rev);
     addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK,
-        commitExtension, change, rev);
+        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK, commitExtension, change, rev);
+    addExtensionPoint(
+        GerritUiExtensionPoint.CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS,
+        historyExtensionRight,
+        change,
+        rev);
   }
 
-  private void addExtensionPoint(GerritUiExtensionPoint extensionPoint,
-      Panel p, ChangeInfo change, RevisionInfo rev) {
+  private void addExtensionPoint(
+      GerritUiExtensionPoint extensionPoint, Panel p, ChangeInfo change, RevisionInfo rev) {
     ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.CHANGE_INFO, change);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.REVISION_INFO, rev);
@@ -368,9 +420,8 @@
 
   void loadChangeInfo(boolean fg, AsyncCallback<ChangeInfo> cb) {
     RestApi call = ChangeApi.detail(changeId.get());
-    EnumSet<ListChangesOption> opts = EnumSet.of(
-      ListChangesOption.ALL_REVISIONS,
-      ListChangesOption.CHANGE_ACTIONS);
+    EnumSet<ListChangesOption> opts =
+        EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.CHANGE_ACTIONS);
     if (enableSignedPush()) {
       opts.add(ListChangesOption.PUSH_CERTIFICATES);
     }
@@ -384,13 +435,14 @@
   void loadRevisionInfo() {
     RestApi call = ChangeApi.actions(changeId.get(), revision);
     call.background();
-    call.get(new GerritCallback<NativeMap<ActionInfo>>() {
-      @Override
-      public void onSuccess(NativeMap<ActionInfo> actionMap) {
-        actionMap.copyKeysIntoChildren("id");
-        renderRevisionInfo(changeInfo, actionMap);
-      }
-    });
+    call.get(
+        new GerritCallback<NativeMap<ActionInfo>>() {
+          @Override
+          public void onSuccess(NativeMap<ActionInfo> actionMap) {
+            actionMap.copyKeysIntoChildren("id");
+            renderRevisionInfo(changeInfo, actionMap);
+          }
+        });
   }
 
   @Override
@@ -423,10 +475,8 @@
   private void initReplyButton(ChangeInfo info, String revision) {
     if (!info.revision(revision).isEdit()) {
       reply.setTitle(Gerrit.info().change().replyLabel());
-      reply.setHTML(new SafeHtmlBuilder()
-        .openDiv()
-        .append(Gerrit.info().change().replyLabel())
-        .closeDiv());
+      reply.setHTML(
+          new SafeHtmlBuilder().openDiv().append(Gerrit.info().change().replyLabel()).closeDiv());
       if (hasDraftComments || lc.hasReplyComment()) {
         reply.setStyleName(style.highlight());
       }
@@ -435,7 +485,8 @@
   }
 
   private void gotoSibling(int offset) {
-    if (offset > 0 && changeInfo.currentRevision() != null
+    if (offset > 0
+        && changeInfo.currentRevision() != null
         && changeInfo.currentRevision().equals(revision)) {
       return;
     }
@@ -449,9 +500,9 @@
     for (int i = 0; i < revisions.length(); i++) {
       if (revision.equals(revisions.get(i).name())) {
         if (0 <= i + offset && i + offset < revisions.length()) {
-          Gerrit.display(PageLinks.toChange(
-              new PatchSet.Id(changeInfo.legacyId(),
-              revisions.get(i + offset)._number())));
+          Gerrit.display(
+              PageLinks.toChange(
+                  new PatchSet.Id(changeInfo.legacyId(), revisions.get(i + offset)._number())));
           return;
         }
         return;
@@ -461,26 +512,11 @@
 
   private void initIncludedInAction(ChangeInfo info) {
     if (info.status() == Status.MERGED) {
-      includedInAction = new IncludedInAction(
-          info.legacyId(),
-          style, headerLine, includedIn);
+      includedInAction = new IncludedInAction(info.legacyId(), style, headerLine, includedIn);
       includedIn.setVisible(true);
     }
   }
 
-  private void initChangeAction(ChangeInfo info) {
-    if (info.status() == Status.DRAFT) {
-      NativeMap<ActionInfo> actions = info.hasActions()
-          ? info.actions()
-          : NativeMap.<ActionInfo> create();
-      actions.copyKeysIntoChildren("id");
-      if (actions.containsKey("/")) {
-        deleteChange.setVisible(true);
-        deleteChange.setTitle(actions.get("/").title());
-      }
-    }
-  }
-
   private void updatePatchSetsTextStyle(boolean isPatchSetCurrent) {
     if (isPatchSetCurrent) {
       patchSetsText.removeClassName(style.notCurrentPatchSet());
@@ -489,11 +525,10 @@
     }
   }
 
-  private void initRevisionsAction(ChangeInfo info, String revision,
-      NativeMap<ActionInfo> actions) {
+  private void initRevisionsAction(
+      ChangeInfo info, String revision, NativeMap<ActionInfo> actions) {
     int currentPatchSet;
-    if (info.currentRevision() != null
-        && info.revisions().containsKey(info.currentRevision())) {
+    if (info.currentRevision() != null && info.revisions().containsKey(info.currentRevision())) {
       currentPatchSet = info.revision(info.currentRevision())._number();
     } else {
       JsArray<RevisionInfo> revList = info.revisions().values();
@@ -506,8 +541,7 @@
     String revisionId = info.revision(revision).id();
     if (revisionId.equals("edit")) {
       currentlyViewedPatchSet =
-          Resources.M.editPatchSet(RevisionInfo.findEditParent(info.revisions()
-              .values()));
+          Resources.M.editPatchSet(RevisionInfo.findEditParent(info.revisions().values()));
       currentPatchSet = info.revisions().values().length() - 1;
     } else {
       currentlyViewedPatchSet = revisionId;
@@ -515,12 +549,10 @@
         isPatchSetCurrent = false;
       }
     }
-    patchSetsText.setInnerText(Resources.M.patchSets(
-        currentlyViewedPatchSet, currentPatchSet));
+    patchSetsText.setInnerText(Resources.M.patchSets(currentlyViewedPatchSet, currentPatchSet));
     updatePatchSetsTextStyle(isPatchSetCurrent);
-    patchSetsAction = new PatchSetsAction(
-        info.legacyId(), revision, edit,
-        style, headerLine, patchSets);
+    patchSetsAction =
+        new PatchSetsAction(info.legacyId(), revision, edit, style, headerLine, patchSets);
 
     RevisionInfo revInfo = info.revision(revision);
     if (revInfo.draft()) {
@@ -536,23 +568,23 @@
   }
 
   private void initDownloadAction(ChangeInfo info, String revision) {
-    downloadAction =
-        new DownloadAction(info, revision, style, headerLine, download);
+    downloadAction = new DownloadAction(info, revision, style, headerLine, download);
   }
 
   private void initProjectLinks(final ChangeInfo info) {
-    projectSettingsLink.setHref(
-        "#" + PageLinks.toProject(info.projectNameKey()));
-    projectSettings.addDomHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        if (Hyperlink.impl.handleAsClick((Event) event.getNativeEvent())) {
-          event.stopPropagation();
-          event.preventDefault();
-          Gerrit.display(PageLinks.toProject(info.projectNameKey()));
-        }
-      }
-    }, ClickEvent.getType());
+    projectSettingsLink.setHref("#" + PageLinks.toProject(info.projectNameKey()));
+    projectSettings.addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            if (Hyperlink.impl.handleAsClick((Event) event.getNativeEvent())) {
+              event.stopPropagation();
+              event.preventDefault();
+              Gerrit.display(PageLinks.toProject(info.projectNameKey()));
+            }
+          }
+        },
+        ClickEvent.getType());
     projectDashboard.setText(info.project());
     projectDashboard.setTargetHistoryToken(
         PageLinks.toProjectDefaultDashboard(info.projectNameKey()));
@@ -562,46 +594,41 @@
     branchLink.setText(info.branch());
     branchLink.setTargetHistoryToken(
         PageLinks.toChangeQuery(
-            BranchLink.query(
-                info.projectNameKey(),
-                info.status(),
-                info.branch(),
-                null)));
+            BranchLink.query(info.projectNameKey(), info.status(), info.branch(), null)));
   }
 
   private void initEditMode(ChangeInfo info, String revision) {
     if (Gerrit.isSignedIn()) {
       RevisionInfo rev = info.revision(revision);
-      boolean isOpen = info.status().isOpen();
-      if (isOpen && isEditModeEnabled(info, rev)) {
-        editMode.setVisible(fileTableMode == FileTable.Mode.REVIEW);
-        addFile.setVisible(!editMode.isVisible());
-        deleteFile.setVisible(!editMode.isVisible());
-        renameFile.setVisible(!editMode.isVisible());
-        reviewMode.setVisible(!editMode.isVisible());
-        addFileAction = new AddFileAction(
-            changeId, info.revision(revision),
-            style, addFile, files);
-        deleteFileAction = new DeleteFileAction(
-            changeId, info.revision(revision),
-            style, addFile);
-        renameFileAction = new RenameFileAction(
-            changeId, info.revision(revision),
-            style, addFile);
-      } else {
-        editMode.setVisible(false);
-        addFile.setVisible(false);
-        reviewMode.setVisible(false);
-      }
+      if (info.status().isOpen()) {
+        if (isEditModeEnabled(info, rev)) {
+          editMode.setVisible(fileTableMode == FileTable.Mode.REVIEW);
+          addFile.setVisible(!editMode.isVisible());
+          deleteFile.setVisible(!editMode.isVisible());
+          renameFile.setVisible(!editMode.isVisible());
+          reviewMode.setVisible(!editMode.isVisible());
+          addFileAction =
+              new AddFileAction(changeId, info.revision(revision), style, addFile, files);
+          deleteFileAction =
+              new DeleteFileAction(changeId, info.revision(revision), style, addFile);
+          renameFileAction =
+              new RenameFileAction(changeId, info.revision(revision), style, addFile);
+        } else {
+          editMode.setVisible(false);
+          addFile.setVisible(false);
+          reviewMode.setVisible(false);
+        }
 
-      if (rev.isEdit()) {
-        if (isOpen) {
+        if (rev.isEdit()) {
           if (info.hasEditBasedOnCurrentPatchSet()) {
             publishEdit.setVisible(true);
           } else {
             rebaseEdit.setVisible(true);
           }
+          deleteEdit.setVisible(true);
         }
+      } else if (rev.isEdit()) {
+        deleteEdit.setStyleName(style.highlight());
         deleteEdit.setVisible(true);
       }
     }
@@ -614,43 +641,35 @@
     if (edit == null) {
       return revision.equals(info.currentRevision());
     }
-    return rev._number() == RevisionInfo.findEditParent(
-        info.revisions().values());
+    return rev._number() == RevisionInfo.findEditParent(info.revisions().values());
   }
 
   @UiHandler("publishEdit")
   void onPublishEdit(@SuppressWarnings("unused") ClickEvent e) {
-    EditActions.publishEdit(changeId);
+    EditActions.publishEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
   }
 
   @UiHandler("rebaseEdit")
   void onRebaseEdit(@SuppressWarnings("unused") ClickEvent e) {
-    EditActions.rebaseEdit(changeId);
+    EditActions.rebaseEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
   }
 
   @UiHandler("deleteEdit")
   void onDeleteEdit(@SuppressWarnings("unused") ClickEvent e) {
     if (Window.confirm(Resources.C.deleteChangeEdit())) {
-      EditActions.deleteEdit(changeId);
+      EditActions.deleteEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
     }
   }
 
   @UiHandler("publish")
   void onPublish(@SuppressWarnings("unused") ClickEvent e) {
-    DraftActions.publish(changeId, revision);
+    ChangeActions.publish(changeId, revision, publish, deleteRevision);
   }
 
   @UiHandler("deleteRevision")
   void onDeleteRevision(@SuppressWarnings("unused") ClickEvent e) {
     if (Window.confirm(Resources.C.deleteDraftRevision())) {
-      DraftActions.delete(changeId, revision);
-    }
-  }
-
-  @UiHandler("deleteChange")
-  void onDeleteChange(@SuppressWarnings("unused") ClickEvent e) {
-    if (Window.confirm(Resources.C.deleteDraftChange())) {
-      DraftActions.delete(changeId);
+      ChangeActions.delete(changeId, revision, publish, deleteRevision);
     }
   }
 
@@ -659,92 +678,102 @@
     super.registerKeys();
 
     KeyCommandSet keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    keysNavigation.add(new KeyCommand(0, 'u', Util.C.upToChangeList()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        Gerrit.displayLastChangeList();
-      }
-    });
-    keysNavigation.add(new KeyCommand(0, 'R', Util.C.keyReloadChange()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        Gerrit.display(PageLinks.toChange(changeId));
-      }
-    });
-    keysNavigation.add(new KeyCommand(0, 'n', Util.C.keyNextPatchSet()) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          gotoSibling(1);
-        }
-      }, new KeyCommand(0, 'p', Util.C.keyPreviousPatchSet()) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          gotoSibling(-1);
-        }
-      });
+    keysNavigation.add(
+        new KeyCommand(0, 'u', Util.C.upToChangeList()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            Gerrit.displayLastChangeList();
+          }
+        });
+    keysNavigation.add(
+        new KeyCommand(0, 'R', Util.C.keyReloadChange()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            Gerrit.display(PageLinks.toChange(changeId));
+          }
+        });
+    keysNavigation.add(
+        new KeyCommand(0, 'n', Util.C.keyNextPatchSet()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            gotoSibling(1);
+          }
+        },
+        new KeyCommand(0, 'p', Util.C.keyPreviousPatchSet()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            gotoSibling(-1);
+          }
+        });
     handlers.add(GlobalKey.add(this, keysNavigation));
 
     KeyCommandSet keysAction = new KeyCommandSet(Gerrit.C.sectionActions());
-    keysAction.add(new KeyCommand(0, 'a', Util.C.keyPublishComments()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (Gerrit.isSignedIn()) {
-          onReply(null);
-        } else {
-          Gerrit.doSignIn(getToken());
-        }
-      }
-    });
-    keysAction.add(new KeyCommand(0, 'x', Util.C.keyExpandAllMessages()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        onExpandAll(null);
-      }
-    });
-    keysAction.add(new KeyCommand(0, 'z', Util.C.keyCollapseAllMessages()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        onCollapseAll(null);
-      }
-    });
-    keysAction.add(new KeyCommand(0, 's', Util.C.changeTableStar()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (Gerrit.isSignedIn()) {
-          star.setValue(!star.getValue(), true);
-        } else {
-          Gerrit.doSignIn(getToken());
-        }
-      }
-    });
-    keysAction.add(new KeyCommand(0, 'c', Util.C.keyAddReviewers()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (Gerrit.isSignedIn()) {
-          reviewers.onOpenForm();
-        } else {
-          Gerrit.doSignIn(getToken());
-        }
-      }
-    });
-    keysAction.add(new KeyCommand(0, 't', Util.C.keyEditTopic()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        if (Gerrit.isSignedIn()) {
-          // In Firefox this event is mistakenly called when F5 is pressed so
-          // differentiate F5 from 't' by checking the charCode(F5=0, t=116).
-          if (event.getNativeEvent().getCharCode() == 0) {
-            Window.Location.reload();
-            return;
+    keysAction.add(
+        new KeyCommand(0, 'a', Util.C.keyPublishComments()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (Gerrit.isSignedIn()) {
+              onReply(null);
+            } else {
+              Gerrit.doSignIn(getToken());
+            }
           }
-          if (topic.canEdit()) {
-            topic.onEdit();
+        });
+    keysAction.add(
+        new KeyCommand(0, 'x', Util.C.keyExpandAllMessages()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            onExpandAll(null);
           }
-        } else {
-          Gerrit.doSignIn(getToken());
-        }
-      }
-    });
+        });
+    keysAction.add(
+        new KeyCommand(0, 'z', Util.C.keyCollapseAllMessages()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            onCollapseAll(null);
+          }
+        });
+    keysAction.add(
+        new KeyCommand(0, 's', Util.C.changeTableStar()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (Gerrit.isSignedIn()) {
+              star.setValue(!star.getValue(), true);
+            } else {
+              Gerrit.doSignIn(getToken());
+            }
+          }
+        });
+    keysAction.add(
+        new KeyCommand(0, 'c', Util.C.keyAddReviewers()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (Gerrit.isSignedIn()) {
+              reviewers.onOpenForm();
+            } else {
+              Gerrit.doSignIn(getToken());
+            }
+          }
+        });
+    keysAction.add(
+        new KeyCommand(0, 't', Util.C.keyEditTopic()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            if (Gerrit.isSignedIn()) {
+              // In Firefox this event is mistakenly called when F5 is pressed so
+              // differentiate F5 from 't' by checking the charCode(F5=0, t=116).
+              if (event.getNativeEvent().getCharCode() == 0) {
+                Window.Location.reload();
+                return;
+              }
+              if (topic.canEdit()) {
+                topic.onEdit();
+              }
+            } else {
+              Gerrit.doSignIn(getToken());
+            }
+          }
+        });
     handlers.add(GlobalKey.add(this, keysAction));
     files.registerKeys();
   }
@@ -753,9 +782,7 @@
   public void onShowView() {
     super.onShowView();
     commit.onShowView();
-    related.setMaxHeight(commit.getElement()
-        .getParentElement()
-        .getOffsetHeight());
+    related.setMaxHeight(commit.getElement().getParentElement().getOffsetHeight());
 
     if (openReplyBox) {
       onReply();
@@ -888,7 +915,31 @@
     int idx = diffBase.getSelectedIndex();
     if (0 <= idx) {
       String n = diffBase.getValue(idx);
-      loadConfigInfo(changeInfo, !n.isEmpty() ? n : null);
+      loadConfigInfo(changeInfo, DiffObject.parse(changeInfo.legacyId(), n));
+    }
+  }
+
+  @UiHandler("showTaggedComments")
+  void onShowTaggedComments(@SuppressWarnings("unused") ClickEvent e) {
+    showTaggedComments.setVisible(false);
+    hideTaggedComments.setVisible(true);
+    int n = history.getWidgetCount();
+    for (int i = 0; i < n; i++) {
+      Message m = ((Message) history.getWidget(i));
+      m.setVisible(true);
+    }
+  }
+
+  @UiHandler("hideTaggedComments")
+  void onHideTaggedComments(@SuppressWarnings("unused") ClickEvent e) {
+    hideTaggedComments.setVisible(false);
+    showTaggedComments.setVisible(true);
+    int n = history.getWidgetCount();
+    for (int i = 0; i < n; i++) {
+      Message m = ((Message) history.getWidget(i));
+      if (m.getMessageInfo().tag() != null) {
+        m.setVisible(false);
+      }
     }
   }
 
@@ -917,45 +968,79 @@
     int idx = diffBase.getSelectedIndex();
     if (0 <= idx) {
       String n = diffBase.getValue(idx);
-      loadConfigInfo(changeInfo, !n.isEmpty() ? n : null);
+      loadConfigInfo(changeInfo, DiffObject.parse(changeInfo.legacyId(), n));
     }
   }
 
-  private void loadConfigInfo(final ChangeInfo info, String base) {
-    RevisionInfo rev = info.revision(revision);
-    RevisionInfo b = resolveRevisionOrPatchSetId(info, base, null);
+  private void loadConfigInfo(final ChangeInfo info, DiffObject base) {
+    final RevisionInfo rev = info.revision(revision);
+    if (base.isAutoMerge() && !initCurrentRevision(info).isMerge()) {
+      Gerrit.display(getToken(), new NotFoundScreen());
+    }
+
+    updateToken(info, base, rev);
+
+    RevisionInfo baseRev = resolveRevisionOrPatchSetId(info, base.asString(), null);
 
     CallbackGroup group = new CallbackGroup();
     Timestamp lastReply = myLastReply(info);
     if (rev.isEdit()) {
       // Comments are filtered for the current revision. Use parent
       // patch set for edits, as edits themself can never have comments.
-      RevisionInfo p = RevisionInfo.findEditParentRevision(
-          info.revisions().values());
+      RevisionInfo p = RevisionInfo.findEditParentRevision(info.revisions().values());
       List<NativeMap<JsArray<CommentInfo>>> comments = loadComments(p, group);
-      loadFileList(b, rev, lastReply, group, comments, null);
+      loadFileList(base, baseRev, rev, lastReply, group, comments, null);
     } else {
-      loadDiff(b, rev, lastReply, group);
+      loadDiff(base, baseRev, rev, lastReply, group);
     }
+    group.addListener(
+        new AsyncCallback<Void>() {
+          @Override
+          public void onSuccess(Void result) {
+            loadConfigInfo(info, rev);
+          }
 
+          @Override
+          public void onFailure(Throwable caught) {
+            logger.log(
+                Level.SEVERE,
+                "Loading file list and inline comments failed: " + caught.getMessage());
+            loadConfigInfo(info, rev);
+          }
+        });
+    group.done();
+  }
+
+  private void loadConfigInfo(final ChangeInfo info, RevisionInfo rev) {
     if (loaded) {
-      group.done();
       return;
     }
 
     RevisionInfoCache.add(changeId, rev);
     ConfigInfoCache.add(info);
-    ConfigInfoCache.get(info.projectNameKey(),
-      group.addFinal(new ScreenLoadCallback<ConfigInfoCache.Entry>(this) {
-        @Override
-        protected void preDisplay(Entry result) {
-          loaded = true;
-          commentLinkProcessor = result.getCommentLinkProcessor();
-          setTheme(result.getTheme());
-          renderChangeInfo(info);
-          loadRevisionInfo();
-        }
-      }));
+    ConfigInfoCache.get(
+        info.projectNameKey(),
+        new ScreenLoadCallback<ConfigInfoCache.Entry>(this) {
+          @Override
+          protected void preDisplay(Entry result) {
+            loaded = true;
+            commentLinkProcessor = result.getCommentLinkProcessor();
+            setTheme(result.getTheme());
+            renderChangeInfo(info);
+            loadRevisionInfo();
+          }
+        });
+  }
+
+  private void updateToken(ChangeInfo info, DiffObject base, RevisionInfo rev) {
+    StringBuilder token = new StringBuilder("/c/").append(info._number()).append("/");
+    if (base.asString() != null) {
+      token.append(base.asString()).append("..");
+    }
+    if (base.asString() != null || !rev.name().equals(info.currentRevision())) {
+      token.append(rev._number());
+    }
+    setToken(token.toString());
   }
 
   static Timestamp myLastReply(ChangeInfo info) {
@@ -971,51 +1056,67 @@
     return null;
   }
 
-  private void loadDiff(RevisionInfo base, RevisionInfo rev,
-      Timestamp myLastReply, CallbackGroup group) {
+  private void loadDiff(
+      DiffObject base,
+      RevisionInfo baseRev,
+      RevisionInfo rev,
+      Timestamp myLastReply,
+      CallbackGroup group) {
     List<NativeMap<JsArray<CommentInfo>>> comments = loadComments(rev, group);
     List<NativeMap<JsArray<CommentInfo>>> drafts = loadDrafts(rev, group);
-    loadFileList(base, rev, myLastReply, group, comments, drafts);
+    loadFileList(base, baseRev, rev, myLastReply, group, comments, drafts);
 
     if (Gerrit.isSignedIn() && fileTableMode == FileTable.Mode.REVIEW) {
       ChangeApi.revision(changeId.get(), rev.name())
-        .view("files")
-        .addParameterTrue("reviewed")
-        .get(group.add(new AsyncCallback<JsArrayString>() {
-            @Override
-            public void onSuccess(JsArrayString result) {
-              files.markReviewed(result);
-            }
+          .view("files")
+          .addParameterTrue("reviewed")
+          .get(
+              group.add(
+                  new AsyncCallback<JsArrayString>() {
+                    @Override
+                    public void onSuccess(JsArrayString result) {
+                      files.markReviewed(result);
+                    }
 
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          }));
+                    @Override
+                    public void onFailure(Throwable caught) {}
+                  }));
     }
   }
 
-  private void loadFileList(final RevisionInfo base, final RevisionInfo rev,
-      final Timestamp myLastReply, CallbackGroup group,
+  private void loadFileList(
+      final DiffObject base,
+      final RevisionInfo baseRev,
+      final RevisionInfo rev,
+      final Timestamp myLastReply,
+      CallbackGroup group,
       final List<NativeMap<JsArray<CommentInfo>>> comments,
       final List<NativeMap<JsArray<CommentInfo>>> drafts) {
-    DiffApi.list(changeId.get(),
+    DiffApi.list(
+        changeId.get(),
         rev.name(),
-        base,
+        baseRev,
         group.add(
             new AsyncCallback<NativeMap<FileInfo>>() {
               @Override
               public void onSuccess(NativeMap<FileInfo> m) {
                 files.set(
-                    base != null ? new PatchSet.Id(changeId, base._number()) : null,
+                    base,
                     new PatchSet.Id(changeId, rev._number()),
-                    style, reply, fileTableMode, edit != null);
-                files.setValue(m, myLastReply,
+                    style,
+                    reply,
+                    fileTableMode,
+                    edit != null);
+                files.setValue(
+                    m,
+                    myLastReply,
                     comments != null ? comments.get(0) : null,
                     drafts != null ? drafts.get(0) : null);
               }
 
               @Override
               public void onFailure(Throwable caught) {
+                files.showError(caught);
               }
             }));
   }
@@ -1026,20 +1127,21 @@
     // TODO(dborowitz): Could eliminate this call by adding an option to include
     // inline comments in the change detail.
     ChangeApi.comments(changeId.get())
-      .get(group.add(new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-        @Override
-        public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-          // Return value is used for populating the file table, so only count
-          // comments for the current revision. Still include all comments in
-          // the history table.
-          r.add(filterForRevision(result, rev._number()));
-          history.addComments(result);
-        }
+        .get(
+            group.add(
+                new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
+                  @Override
+                  public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
+                    // Return value is used for populating the file table, so only count
+                    // comments for the current revision. Still include all comments in
+                    // the history table.
+                    r.add(filterForRevision(result, rev._number()));
+                    history.addComments(result);
+                  }
 
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      }));
+                  @Override
+                  public void onFailure(Throwable caught) {}
+                }));
     return r;
   }
 
@@ -1060,24 +1162,24 @@
     return filtered;
   }
 
-  private List<NativeMap<JsArray<CommentInfo>>> loadDrafts(
-      RevisionInfo rev, CallbackGroup group) {
+  private List<NativeMap<JsArray<CommentInfo>>> loadDrafts(RevisionInfo rev, CallbackGroup group) {
     final List<NativeMap<JsArray<CommentInfo>>> r = new ArrayList<>(1);
     if (Gerrit.isSignedIn()) {
       ChangeApi.revision(changeId.get(), rev.name())
-        .view("drafts")
-        .get(group.add(new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-          @Override
-          public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-            r.add(result);
-          }
+          .view("drafts")
+          .get(
+              group.add(
+                  new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
+                    @Override
+                    public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
+                      r.add(result);
+                    }
 
-          @Override
-          public void onFailure(Throwable caught) {
-          }
-        }));
+                    @Override
+                    public void onFailure(Throwable caught) {}
+                  }));
     } else {
-      r.add(NativeMap.<JsArray<CommentInfo>> create());
+      r.add(NativeMap.<JsArray<CommentInfo>>create());
     }
     return r;
   }
@@ -1087,34 +1189,32 @@
       return;
     }
 
-    ChangeApi.commitWithLinks(changeId.get(), rev.name(),
-        group.add(new AsyncCallback<CommitInfo>() {
-          @Override
-          public void onSuccess(CommitInfo info) {
-            rev.setCommit(info);
-          }
+    ChangeApi.commitWithLinks(
+        changeId.get(),
+        rev.name(),
+        group.add(
+            new AsyncCallback<CommitInfo>() {
+              @Override
+              public void onSuccess(CommitInfo info) {
+                rev.setCommit(info);
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-          }
-        }));
+              @Override
+              public void onFailure(Throwable caught) {}
+            }));
   }
 
-  private void renderSubmitType(Change.Status status, boolean canSubmit,
-      SubmitType submitType) {
+  private void renderSubmitType(Change.Status status, boolean canSubmit, SubmitType submitType) {
     if (canSubmit && status == Change.Status.NEW) {
-      statusText.setInnerText(changeInfo.mergeable()
-          ? Util.C.readyToSubmit()
-          : Util.C.mergeConflict());
+      statusText.setInnerText(
+          changeInfo.mergeable() ? Util.C.readyToSubmit() : Util.C.mergeConflict());
     }
     setVisible(notMergeable, !changeInfo.mergeable());
-    submitActionText.setInnerText(
-        com.google.gerrit.client.admin.Util.toLongString(submitType));
+    submitActionText.setInnerText(com.google.gerrit.client.admin.Util.toLongString(submitType));
   }
 
   private RevisionInfo resolveRevisionToDisplay(ChangeInfo info) {
-    RevisionInfo rev = resolveRevisionOrPatchSetId(info, revision,
-        info.currentRevision());
+    RevisionInfo rev = resolveRevisionOrPatchSetId(info, revision, info.currentRevision());
     if (rev != null) {
       revision = rev.name();
       return rev;
@@ -1130,25 +1230,22 @@
       revision = rev.name();
       return rev;
     }
-    new ErrorDialog(
-        Resources.M.changeWithNoRevisions(info.legacyId().get())).center();
+    new ErrorDialog(Resources.M.changeWithNoRevisions(info.legacyId().get())).center();
     throw new IllegalStateException("no revision, cannot proceed");
   }
 
   /**
-   * Resolve a revision or patch set id string to RevisionInfo.
-   * When this view is created from the changes table, revision
-   * is passed as a real revision.
-   * When this view is created from side by side (by closing it with 'u')
-   * patch set id is passed.
+   * Resolve a revision or patch set id string to RevisionInfo. When this view is created from the
+   * changes table, revision is passed as a real revision. When this view is created from side by
+   * side (by closing it with 'u') patch set id is passed.
    *
    * @param info change info
    * @param revOrId revision or patch set id
    * @param defaultValue value returned when revOrId is null
    * @return resolved revision or default value
    */
-  private RevisionInfo resolveRevisionOrPatchSetId(ChangeInfo info,
-      String revOrId, String defaultValue) {
+  private RevisionInfo resolveRevisionOrPatchSetId(
+      ChangeInfo info, String revOrId, String defaultValue) {
     int parentNum;
     if (revOrId == null) {
       revOrId = defaultValue;
@@ -1173,9 +1270,9 @@
 
   private boolean isSubmittable(ChangeInfo info) {
     boolean canSubmit =
-        info.status().isOpen() &&
-        revision.equals(info.currentRevision()) &&
-        !info.revision(revision).draft();
+        info.status().isOpen()
+            && revision.equals(info.currentRevision())
+            && !info.revision(revision).draft();
     if (canSubmit && info.status() == Change.Status.NEW) {
       for (String name : info.labels()) {
         LabelInfo label = info.label(name);
@@ -1195,7 +1292,7 @@
           case OK:
           default:
             break;
-          }
+        }
       }
     }
     return canSubmit;
@@ -1214,7 +1311,6 @@
     renderDiffBaseListBox(info);
     initReplyButton(info, revision);
     initIncludedInAction(info);
-    initChangeAction(info);
     initDownloadAction(info, revision);
     initProjectLinks(info);
     initBranchLink(info);
@@ -1228,6 +1324,7 @@
     commit.set(commentLinkProcessor, info, revision);
     related.set(info, revision);
     reviewers.set(info);
+    assignee.set(info);
     if (Gerrit.isNoteDbEnabled()) {
       hashtags.set(info, revision);
     } else {
@@ -1252,13 +1349,12 @@
 
     // Properly render revision actions initially while waiting for
     // the callback to populate them correctly.
-    NativeMap<ActionInfo> emptyMap = NativeMap.<ActionInfo> create();
+    NativeMap<ActionInfo> emptyMap = NativeMap.<ActionInfo>create();
     initRevisionsAction(info, revision, emptyMap);
     quickApprove.setVisible(false);
     actions.reloadRevisionActions(emptyMap);
 
-    boolean current = revision.equals(info.currentRevision())
-        && !revisionInfo.isEdit();
+    boolean current = revision.equals(info.currentRevision()) && !revisionInfo.isEdit();
 
     if (revisionInfo.isEdit()) {
       statusText.setInnerText(Util.C.changeEdit());
@@ -1271,8 +1367,9 @@
     }
 
     if (Gerrit.isSignedIn()) {
-      replyAction = new ReplyAction(info, revision, hasDraftComments,
-          style, commentLinkProcessor, reply, quickApprove);
+      replyAction =
+          new ReplyAction(
+              info, revision, hasDraftComments, style, commentLinkProcessor, reply, quickApprove);
     }
     history.set(commentLinkProcessor, replyAction, changeId, info);
 
@@ -1284,11 +1381,10 @@
     }
   }
 
-  private void renderRevisionInfo(ChangeInfo info,
-      NativeMap<ActionInfo> actionMap) {
+  private void renderRevisionInfo(ChangeInfo info, NativeMap<ActionInfo> actionMap) {
     initRevisionsAction(info, revision, actionMap);
-    commit.setParentNotCurrent(actionMap.containsKey("rebase")
-        && actionMap.get("rebase").enabled());
+    commit.setParentNotCurrent(
+        actionMap.containsKey("rebase") && actionMap.get("rebase").enabled());
     actions.reloadRevisionActions(actionMap);
   }
 
@@ -1300,18 +1396,19 @@
     }
     ownerLink.setText(name);
     ownerLink.setTitle(email(info.owner(), name));
-    ownerLink.setTargetHistoryToken(PageLinks.toAccountQuery(
-        info.owner().name() != null
-        ? info.owner().name()
-        : info.owner().email() != null
-        ? info.owner().email()
-        : String.valueOf(info.owner()._accountId()), Change.Status.NEW));
+    ownerLink.setTargetHistoryToken(
+        PageLinks.toAccountQuery(
+            info.owner().name() != null
+                ? info.owner().name()
+                : info.owner().email() != null
+                    ? info.owner().email()
+                    : String.valueOf(info.owner()._accountId()),
+            Change.Status.NEW));
   }
 
   private void renderUploader(ChangeInfo changeInfo, RevisionInfo revInfo) {
     AccountInfo uploader = revInfo.uploader();
-    boolean isOwner = uploader == null
-        || uploader._accountId() == changeInfo.owner()._accountId();
+    boolean isOwner = uploader == null || uploader._accountId() == changeInfo.owner()._accountId();
     renderPushCertificate(revInfo, isOwner ? ownerPanel : uploaderPanel);
     if (isOwner) {
       uploaderRow.getStyle().setDisplay(Display.NONE);
@@ -1334,8 +1431,7 @@
     Image status = new Image();
     panel.add(status);
     status.setStyleName(style.pushCertStatus());
-    if (!revInfo.hasPushCertificate()
-        || revInfo.pushCertificate().key() == null) {
+    if (!revInfo.hasPushCertificate() || revInfo.pushCertificate().key() == null) {
       status.setResource(Gerrit.RESOURCES.question());
       status.setTitle(Util.C.pushCertMissing());
       return;
@@ -1359,9 +1455,7 @@
   }
 
   private static String name(AccountInfo info) {
-    return info.name() != null
-        ? info.name()
-        : Gerrit.info().user().anonymousCowardName();
+    return info.name() != null ? info.name() : Gerrit.info().user().anonymousCowardName();
   }
 
   private static String email(AccountInfo info, String name) {
@@ -1369,9 +1463,7 @@
   }
 
   private static String problems(String msg, PushCertificateInfo info) {
-    if (info.key() == null
-        || !info.key().hasProblems()
-        || info.key().problems().length() == 0) {
+    if (info.key() == null || !info.key().hasProblems() || info.key().problems().length() == 0) {
       return msg;
     }
 
@@ -1400,14 +1492,14 @@
     int selectedIdx = list.length();
     for (int i = list.length() - 1; i >= 0; i--) {
       RevisionInfo r = list.get(i);
-      diffBase.addItem(
-        r.id() + ": " + r.name().substring(0, 6),
-        r.name());
+      diffBase.addItem(r.id() + ": " + r.name().substring(0, 6), r.id());
       if (r.name().equals(revision)) {
-        SelectElement.as(diffBase.getElement()).getOptions()
-            .getItem(diffBase.getItemCount() - 1).setDisabled(true);
+        SelectElement.as(diffBase.getElement())
+            .getOptions()
+            .getItem(diffBase.getItemCount() - 1)
+            .setDisabled(true);
       }
-      if (base != null && base.equals(String.valueOf(r._number()))) {
+      if (base.isPatchSet() && base.asPatchSetId().get() == r._number()) {
         selectedIdx = diffBase.getItemCount() - 1;
       }
     }
@@ -1415,15 +1507,14 @@
     RevisionInfo rev = info.revisions().get(revision);
     JsArray<CommitInfo> parents = rev.commit().parents();
     if (parents.length() > 1) {
-      diffBase.addItem(Util.C.autoMerge(), "");
+      diffBase.addItem(Util.C.autoMerge(), DiffObject.AUTO_MERGE);
       for (int i = 0; i < parents.length(); i++) {
         int parentNum = i + 1;
-        diffBase.addItem(Util.M.diffBaseParent(parentNum),
-            String.valueOf(-parentNum));
+        diffBase.addItem(Util.M.diffBaseParent(parentNum), String.valueOf(-parentNum));
       }
-      int parentNum = toParentNum(base);
-      if (parentNum > 0) {
-        selectedIdx = list.length() + parentNum;
+
+      if (base.isParent()) {
+        selectedIdx = list.length() + base.getParentNum();
       }
     } else {
       diffBase.addItem(Util.C.baseDiffItem(), "");
@@ -1452,21 +1543,20 @@
     }
 
     if (updateAvailable == null) {
-      updateAvailable = new UpdateAvailableBar() {
-        @Override
-        void onShow() {
-          Gerrit.display(PageLinks.toChange(changeId));
-        }
+      updateAvailable =
+          new UpdateAvailableBar() {
+            @Override
+            void onShow() {
+              Gerrit.display(PageLinks.toChange(changeId));
+            }
 
-        @Override
-        void onIgnore(Timestamp newTime) {
-          lastDisplayedUpdate = newTime;
-        }
-      };
+            @Override
+            void onIgnore(Timestamp newTime) {
+              lastDisplayedUpdate = newTime;
+            }
+          };
     }
-    updateAvailable.set(
-        Natives.asList(nm).subList(om.length(), nm.length()),
-        newInfo.updated());
+    updateAvailable.set(Natives.asList(nm).subList(om.length(), nm.length()), newInfo.updated());
     if (!updateAvailable.isAttached()) {
       add(updateAvailable);
     }
@@ -1486,9 +1576,8 @@
 
   /**
    * @param parentToken
-   * @return 1-based parentNum if parentToken is a String which can be parsed as
-   *     a negative integer i.e. "-1", "-2", etc. If parentToken cannot be
-   *     parsed as a negative integer, return zero.
+   * @return 1-based parentNum if parentToken is a String which can be parsed as a negative integer
+   *     i.e. "-1", "-2", etc. If parentToken cannot be parsed as a negative integer, return zero.
    */
   private static int toParentNum(String parentToken) {
     try {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
index a0d5405..152b157 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
@@ -355,6 +355,11 @@
       padding-top: 5px;
     }
 
+    .historyExtension {
+      display: inline-block;
+      float: right;
+    }
+
     .pushCertStatus {
       padding-left: 5px;
     }
@@ -405,9 +410,6 @@
               styleName='{style.highlight}' visible='false'>
             <div><ui:msg>Publish</ui:msg></div>
           </g:Button>
-          <g:Button ui:field='deleteChange' styleName='' visible='false'>
-            <div><ui:msg>Delete Change</ui:msg></div>
-          </g:Button>
           <g:Button ui:field='deleteRevision' styleName='' visible='false'>
             <div><ui:msg>Delete Revision</ui:msg></div>
           </g:Button>
@@ -458,6 +460,12 @@
                 </g:FlowPanel>
               </td>
             </tr>
+            <tr ui:field='assigneeRow'>
+              <th><ui:msg>Assignee</ui:msg></th>
+              <td>
+                <c:Assignee ui:field='assignee'/>
+              </td>
+            </tr>
             <tr>
               <th><ui:msg>Reviewers</ui:msg></th>
               <td>
@@ -601,6 +609,21 @@
           <ui:attribute name='title'/>
           <div><ui:msg>Collapse All</ui:msg></div>
         </g:Button>
+        <g:Button ui:field='hideTaggedComments'
+            styleName=''
+            visible='false'
+            title='Hide tagged comments'>
+          <ui:attribute name='title'/>
+          <div><ui:msg>Hide tagged comments</ui:msg></div>
+        </g:Button>
+        <g:Button ui:field='showTaggedComments'
+            styleName=''
+            visible='false'
+            title='Show tagged comments'>
+          <ui:attribute name='title'/>
+          <div><ui:msg>Show tagged comments</ui:msg></div>
+        </g:Button>
+        <g:SimplePanel ui:field='historyExtensionRight' styleName='{style.historyExtension}'/>
       </div>
     </div>
     <c:History ui:field='history'/>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java
index 3b72581..5fb0e7b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java
@@ -27,19 +27,20 @@
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.PopupPanel;
 
-
 class CherryPickAction {
-  static void call(final Button b, final ChangeInfo info, final String revision,
-      String project, final String commitMessage) {
+  static void call(
+      final Button b,
+      final ChangeInfo info,
+      final String revision,
+      String project,
+      final String commitMessage) {
     // TODO Replace CherryPickDialog with a nicer looking display.
     b.setEnabled(false);
     new CherryPickDialog(new Project.NameKey(project)) {
       {
         sendButton.setText(Util.C.buttonCherryPickChangeSend());
         if (info.status() == Change.Status.MERGED) {
-          message.setText(Util.M.cherryPickedChangeDefaultMessage(
-              commitMessage.trim(),
-              revision));
+          message.setText(Util.M.cherryPickedChangeDefaultMessage(commitMessage.trim(), revision));
         } else {
           message.setText(commitMessage.trim());
         }
@@ -47,7 +48,9 @@
 
       @Override
       public void onSend() {
-        ChangeApi.cherrypick(info.legacyId().get(), revision,
+        ChangeApi.cherrypick(
+            info.legacyId().get(),
+            revision,
             getDestinationBranch(),
             getMessageText(),
             new GerritCallback<ChangeInfo>() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
index 4eacc8c..0112579 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CommitBox.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.client.ui.InlineHyperlink;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
@@ -49,12 +50,16 @@
 
 class CommitBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, CommitBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface Style extends CssResource {
     String collapsed();
+
     String expanded();
+
     String clippy();
+
     String parentWebLink();
   }
 
@@ -99,9 +104,7 @@
     expanded = !expanded;
   }
 
-  void set(CommentLinkProcessor commentLinkProcessor,
-      ChangeInfo change,
-      String revision) {
+  void set(CommentLinkProcessor commentLinkProcessor, ChangeInfo change, String revision) {
     RevisionInfo revInfo = change.revision(revision);
     CommitInfo commit = revInfo.commit();
 
@@ -109,12 +112,10 @@
     idText.setText("Change-Id: " + change.changeId());
     idText.setPreviewText(change.changeId());
 
-    formatLink(commit.author(), authorPanel, authorNameEmail, authorDate,
-        change);
-    formatLink(commit.committer(), committerPanel, committerNameEmail,
-        committerDate, change);
-    text.setHTML(commentLinkProcessor.apply(
-        new SafeHtmlBuilder().append(commit.message()).linkify()));
+    formatLink(commit.author(), authorPanel, authorNameEmail, authorDate, change);
+    formatLink(commit.committer(), committerPanel, committerNameEmail, committerDate, change);
+    text.setHTML(
+        commentLinkProcessor.apply(new SafeHtmlBuilder().append(commit.message()).linkify()));
     setWebLinks(webLinkPanel, revInfo.commit());
 
     if (revInfo.commit().parents().length() > 1) {
@@ -174,25 +175,23 @@
     return copyLabel;
   }
 
-  private static void formatLink(GitPerson person, FlowPanel p,
-      InlineHyperlink name, Element date, ChangeInfo change) {
+  private static void formatLink(
+      GitPerson person, FlowPanel p, InlineHyperlink name, Element date, ChangeInfo change) {
     // only try to fetch the avatar image for author and committer if an avatar
     // plugin is installed, if the change owner has no avatar info assume that
     // no avatar plugin is installed
     if (change.owner().hasAvatarInfo()) {
       AvatarImage avatar;
-      if (change.owner().email().equals(person.email())) {
+      if (sameEmail(change.owner(), person)) {
         avatar = new AvatarImage(change.owner());
       } else {
-        avatar = new AvatarImage(
-            AccountInfo.create(0, person.name(), person.email(), null));
+        avatar = new AvatarImage(AccountInfo.create(0, person.name(), person.email(), null));
       }
       p.insert(avatar, 0);
     }
 
     name.setText(renderName(person));
-    name.setTargetHistoryToken(PageLinks
-        .toAccountQuery(owner(person), change.status()));
+    name.setTargetHistoryToken(PageLinks.toAccountQuery(owner(person), change.status()));
     date.setInnerText(FormatUtil.mediumFormat(person.date()));
   }
 
@@ -209,4 +208,12 @@
       return "";
     }
   }
+
+  private static boolean sameEmail(@Nullable AccountInfo p1, @Nullable GitPerson p2) {
+    return p1 != null
+        && p2 != null
+        && p1.email() != null
+        && p2.email() != null
+        && p1.email().equals(p2.email());
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java
index 3a7977a..4dcdc6e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java
@@ -31,8 +31,8 @@
   private DeleteFileBox deleteBox;
   private PopupPanel popup;
 
-  DeleteFileAction(Change.Id changeId, RevisionInfo revision,
-      ChangeScreen.Style style, Widget deleteButton) {
+  DeleteFileAction(
+      Change.Id changeId, RevisionInfo revision, ChangeScreen.Style style, Widget deleteButton) {
     this.changeId = changeId;
     this.revision = revision;
     this.style = style;
@@ -53,14 +53,15 @@
     final PopupPanel p = new PopupPanel(true);
     p.setStyleName(style.replyBox());
     p.addAutoHidePartner(deleteButton.getElement());
-    p.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        if (popup == p) {
-          popup = null;
-        }
-      }
-    });
+    p.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            if (popup == p) {
+              popup = null;
+            }
+          }
+        });
     p.add(deleteBox);
     p.showRelativeTo(deleteButton);
     GlobalKey.dialog(p);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java
index 3d889d0..3edfca2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java
@@ -39,6 +39,7 @@
 
 class DeleteFileBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, DeleteFileBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private final Change.Id changeId;
@@ -53,18 +54,20 @@
     this.changeId = changeId;
 
     path = new RemoteSuggestBox(new PathSuggestOracle(changeId, revision));
-    path.addSelectionHandler(new SelectionHandler<String>() {
-      @Override
-      public void onSelection(SelectionEvent<String> event) {
-        delete(event.getSelectedItem());
-      }
-    });
-    path.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
-      @Override
-      public void onClose(CloseEvent<RemoteSuggestBox> event) {
-        hide();
-      }
-    });
+    path.addSelectionHandler(
+        new SelectionHandler<String>() {
+          @Override
+          public void onSelection(SelectionEvent<String> event) {
+            delete(event.getSelectedItem());
+          }
+        });
+    path.addCloseHandler(
+        new CloseHandler<RemoteSuggestBox>() {
+          @Override
+          public void onClose(CloseEvent<RemoteSuggestBox> event) {
+            hide();
+          }
+        });
 
     initWidget(uiBinder.createAndBindUi(this));
   }
@@ -84,7 +87,9 @@
 
   private void delete(String path) {
     hide();
-    ChangeEditApi.delete(changeId.get(), path,
+    ChangeEditApi.delete(
+        changeId.get(),
+        path,
         new AsyncCallback<VoidResult>() {
           @Override
           public void onSuccess(VoidResult result) {
@@ -92,8 +97,7 @@
           }
 
           @Override
-          public void onFailure(Throwable caught) {
-          }
+          public void onFailure(Throwable caught) {}
         });
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java
index 9698aef..8e4ea84 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java
@@ -29,9 +29,9 @@
       UIObject relativeTo,
       Widget downloadButton) {
     super(style, relativeTo, downloadButton);
-    this.downloadBox = new DownloadBox(info, revision,
-        new PatchSet.Id(info.legacyId(),
-            info.revision(revision)._number()));
+    this.downloadBox =
+        new DownloadBox(
+            info, revision, new PatchSet.Id(info.legacyId(), info.revision(revision)._number()));
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
index e8707f4..6c2964d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
@@ -38,7 +38,6 @@
 import com.google.gwt.user.client.ui.VerticalPanel;
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.clippy.client.CopyableLabel;
-
 import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.Iterator;
@@ -58,15 +57,16 @@
     this.psId = psId;
     this.commandTable = new FlexTable();
     this.scheme = new ListBox();
-    this.scheme.addChangeHandler(new ChangeHandler() {
-      @Override
-      public void onChange(ChangeEvent event) {
-        renderCommands();
-        if (Gerrit.isSignedIn()) {
-          saveScheme();
-        }
-      }
-    });
+    this.scheme.addChangeHandler(
+        new ChangeHandler() {
+          @Override
+          public void onChange(ChangeEvent event) {
+            renderCommands();
+            if (Gerrit.isSignedIn()) {
+              saveScheme();
+            }
+          }
+        });
 
     setStyleName(Gerrit.RESOURCES.css().downloadBox());
     commandTable.setStyleName(Gerrit.RESOURCES.css().downloadBoxTable());
@@ -78,36 +78,38 @@
   protected void onLoad() {
     if (fetch == null) {
       if (psId.get() == 0) {
-        ChangeApi.editWithCommands(change.legacyId().get()).get(
-            new AsyncCallback<EditInfo>() {
-          @Override
-          public void onSuccess(EditInfo result) {
-            fetch = result.fetch();
-            renderScheme();
-          }
+        ChangeApi.editWithCommands(change.legacyId().get())
+            .get(
+                new AsyncCallback<EditInfo>() {
+                  @Override
+                  public void onSuccess(EditInfo result) {
+                    fetch = result.fetch();
+                    renderScheme();
+                  }
 
-          @Override
-          public void onFailure(Throwable caught) {
-          }
-        });
+                  @Override
+                  public void onFailure(Throwable caught) {}
+                });
       } else {
         RestApi call = ChangeApi.detail(change.legacyId().get());
-        ChangeList.addOptions(call, EnumSet.of(
-            revision.equals(change.currentRevision())
-               ? ListChangesOption.CURRENT_REVISION
-               : ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.DOWNLOAD_COMMANDS));
-        call.get(new AsyncCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo result) {
-            fetch = result.revision(revision).fetch();
-            renderScheme();
-          }
+        ChangeList.addOptions(
+            call,
+            EnumSet.of(
+                revision.equals(change.currentRevision())
+                    ? ListChangesOption.CURRENT_REVISION
+                    : ListChangesOption.ALL_REVISIONS,
+                ListChangesOption.DOWNLOAD_COMMANDS));
+        call.get(
+            new AsyncCallback<ChangeInfo>() {
+              @Override
+              public void onSuccess(ChangeInfo result) {
+                fetch = result.revision(revision).fetch();
+                renderScheme();
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-          }
-        });
+              @Override
+              public void onFailure(Throwable caught) {}
+            });
       }
     }
   }
@@ -116,11 +118,9 @@
     commandTable.removeAllRows();
 
     if (scheme.getItemCount() > 0) {
-      FetchInfo fetchInfo =
-          fetch.get(scheme.getValue(scheme.getSelectedIndex()));
+      FetchInfo fetchInfo = fetch.get(scheme.getValue(scheme.getSelectedIndex()));
       for (String commandName : fetchInfo.commands().sortedKeys()) {
-        CopyableLabel copyLabel =
-            new CopyableLabel(fetchInfo.command(commandName));
+        CopyableLabel copyLabel = new CopyableLabel(fetchInfo.command(commandName));
         copyLabel.setStyleName(Gerrit.RESOURCES.css().downloadBoxCopyLabel());
         insertCommand(commandName, copyLabel);
       }
@@ -135,22 +135,24 @@
   private void insertPatch() {
     String id = revision.substring(0, 7);
     Anchor patchBase64 = new Anchor(id + ".diff.base64");
-    patchBase64.setHref(new RestApi("/changes/")
-      .id(psId.getParentKey().get())
-      .view("revisions")
-      .id(revision)
-      .view("patch")
-      .addParameterTrue("download")
-      .url());
+    patchBase64.setHref(
+        new RestApi("/changes/")
+            .id(psId.getParentKey().get())
+            .view("revisions")
+            .id(revision)
+            .view("patch")
+            .addParameterTrue("download")
+            .url());
 
     Anchor patchZip = new Anchor(id + ".diff.zip");
-    patchZip.setHref(new RestApi("/changes/")
-      .id(psId.getParentKey().get())
-      .view("revisions")
-      .id(revision)
-      .view("patch")
-      .addParameterTrue("zip")
-      .url());
+    patchZip.setHref(
+        new RestApi("/changes/")
+            .id(psId.getParentKey().get())
+            .view("revisions")
+            .id(revision)
+            .view("patch")
+            .addParameterTrue("zip")
+            .url());
 
     HorizontalPanel p = new HorizontalPanel();
     p.add(patchBase64);
@@ -170,13 +172,14 @@
     List<Anchor> anchors = new ArrayList<>(activated.size());
     for (String f : activated) {
       Anchor archive = new Anchor(f);
-      archive.setHref(new RestApi("/changes/")
-          .id(psId.getParentKey().get())
-          .view("revisions")
-          .id(revision)
-          .view("archive")
-          .addParameter("format", f)
-          .url());
+      archive.setHref(
+          new RestApi("/changes/")
+              .id(psId.getParentKey().get())
+              .view("revisions")
+              .id(revision)
+              .view("archive")
+              .addParameter("format", f)
+              .url());
       anchors.add(archive);
     }
 
@@ -197,8 +200,9 @@
   private void insertCommand(String commandName, Widget w) {
     int row = commandTable.getRowCount();
     commandTable.insertRow(row);
-    commandTable.getCellFormatter().addStyleName(row, 0,
-        Gerrit.RESOURCES.css().downloadBoxTableCommandColumn());
+    commandTable
+        .getCellFormatter()
+        .addStyleName(row, 0, Gerrit.RESOURCES.css().downloadBoxTableCommandColumn());
     if (commandName != null) {
       commandTable.setText(row, 0, commandName);
     }
@@ -241,16 +245,17 @@
       prefs.downloadScheme(schemeStr);
       GeneralPreferences in = GeneralPreferences.create();
       in.downloadScheme(schemeStr);
-      AccountApi.self().view("preferences")
-          .put(in, new AsyncCallback<JavaScriptObject>() {
-            @Override
-            public void onSuccess(JavaScriptObject result) {
-            }
+      AccountApi.self()
+          .view("preferences")
+          .put(
+              in,
+              new AsyncCallback<JavaScriptObject>() {
+                @Override
+                public void onSuccess(JavaScriptObject result) {}
 
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          });
+                @Override
+                public void onFailure(Throwable caught) {}
+              });
     }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DraftActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DraftActions.java
deleted file mode 100644
index 634190a2..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DraftActions.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.change;
-
-import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.user.client.rpc.AsyncCallback;
-
-public class DraftActions {
-
-  static void publish(Change.Id id, String revision) {
-    ChangeApi.publish(id.get(), revision, cs(id));
-  }
-
-  static void delete(Change.Id id, String revision) {
-    ChangeApi.deleteRevision(id.get(), revision, cs(id));
-  }
-
-  static void delete(Change.Id id) {
-    ChangeApi.deleteChange(id.get(), mine());
-  }
-
-  public static GerritCallback<JavaScriptObject> cs(
-      final Change.Id id) {
-    return new GerritCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject result) {
-        Gerrit.display(PageLinks.toChange(id));
-      }
-
-      @Override
-      public void onFailure(Throwable err) {
-        if (SubmitFailureDialog.isConflict(err)) {
-          new SubmitFailureDialog(err.getMessage()).center();
-          Gerrit.display(PageLinks.toChange(id));
-        } else {
-          super.onFailure(err);
-        }
-      }
-    };
-  }
-
-  private static AsyncCallback<JavaScriptObject> mine() {
-    return new GerritCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject result) {
-        Gerrit.display(PageLinks.MINE);
-      }
-
-      @Override
-      public void onFailure(Throwable err) {
-        if (SubmitFailureDialog.isConflict(err)) {
-          new SubmitFailureDialog(err.getMessage()).center();
-          Gerrit.display(PageLinks.MINE);
-        } else {
-          super.onFailure(err);
-        }
-      }
-    };
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
index d11cf7e..97abddb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
@@ -20,23 +20,25 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.user.client.ui.Button;
 
 public class EditActions {
 
-  static void deleteEdit(Change.Id id) {
-    ChangeApi.deleteEdit(id.get(), cs(id));
+  static void deleteEdit(Change.Id id, Button... editButtons) {
+    ChangeApi.deleteEdit(id.get(), cs(id, editButtons));
   }
 
-  static void publishEdit(Change.Id id) {
-    ChangeApi.publishEdit(id.get(), cs(id));
+  static void publishEdit(Change.Id id, Button... editButtons) {
+    ChangeApi.publishEdit(id.get(), cs(id, editButtons));
   }
 
-  static void rebaseEdit(Change.Id id) {
-    ChangeApi.rebaseEdit(id.get(), cs(id));
+  static void rebaseEdit(Change.Id id, Button... editButtons) {
+    ChangeApi.rebaseEdit(id.get(), cs(id, editButtons));
   }
 
   public static GerritCallback<JavaScriptObject> cs(
-      final Change.Id id) {
+      final Change.Id id, final Button... editButtons) {
+    setEnabled(false, editButtons);
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject result) {
@@ -45,6 +47,7 @@
 
       @Override
       public void onFailure(Throwable err) {
+        setEnabled(true, editButtons);
         if (SubmitFailureDialog.isConflict(err)) {
           new SubmitFailureDialog(err.getMessage()).center();
           Gerrit.display(PageLinks.toChange(id));
@@ -54,4 +57,12 @@
       }
     };
   }
+
+  private static void setEnabled(boolean enabled, Button... editButtons) {
+    if (editButtons != null) {
+      for (Button b : editButtons) {
+        b.setEnabled(enabled);
+      }
+    }
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java
index 9afcd4f..0e30a8c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileComments.java
@@ -25,20 +25,18 @@
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HTMLPanel;
-
 import java.util.List;
 
 class FileComments extends Composite {
   interface Binder extends UiBinder<HTMLPanel, FileComments> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   @UiField InlineHyperlink path;
   @UiField FlowPanel comments;
 
-  FileComments(CommentLinkProcessor clp,
-      PatchSet.Id defaultPs,
-      String title,
-      List<CommentInfo> list) {
+  FileComments(
+      CommentLinkProcessor clp, PatchSet.Id defaultPs, String title, List<CommentInfo> list) {
     initWidget(uiBinder.createAndBindUi(this));
 
     path.setTargetHistoryToken(url(defaultPs, list.get(0)));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
index f0a7ce3..65e3dc0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.client.FormatUtil.formatBytes;
 import static com.google.gerrit.client.FormatUtil.formatPercentage;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
@@ -60,17 +61,16 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
 import com.google.gwt.user.client.ui.ImageResourceRenderer;
+import com.google.gwt.user.client.ui.Label;
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwt.user.client.ui.impl.HyperlinkImpl;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
 import com.google.gwtexpui.progress.client.ProgressBar;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.sql.Timestamp;
 
 public class FileTable extends FlowPanel {
-  private static final FileTableResources R = GWT
-      .create(FileTableResources.class);
+  private static final FileTableResources R = GWT.create(FileTableResources.class);
 
   interface FileTableResources extends ClientBundle {
     @Source("file_table.css")
@@ -79,21 +79,38 @@
 
   interface FileTableCss extends CssResource {
     String table();
+
     String nohover();
+
     String pointer();
+
     String reviewed();
+
     String status();
+
     String pathColumn();
+
     String commonPrefix();
+
     String renameCopySource();
+
     String draftColumn();
+
     String newColumn();
+
     String commentColumn();
+
     String deltaColumn1();
+
     String deltaColumn2();
+
     String inserted();
+
     String deleted();
+
     String restoreDelete();
+
+    String error();
   }
 
   public enum Mode {
@@ -157,7 +174,7 @@
   }
 
   private static boolean onOpen(NativeEvent e, int idx) {
-    if (link.handleAsClick(e.<Event> cast())) {
+    if (link.handleAsClick(e.<Event>cast())) {
       MyTable t = getMyTable(e);
       if (t != null) {
         t.onOpenRow(1 + idx);
@@ -180,7 +197,7 @@
     return null;
   }
 
-  private PatchSet.Id base;
+  private DiffObject base;
   private PatchSet.Id curr;
   private MyTable table;
   private boolean register;
@@ -197,8 +214,13 @@
     R.css().ensureInjected();
   }
 
-  public void set(PatchSet.Id base, PatchSet.Id curr, ChangeScreen.Style style,
-      Widget replyButton, Mode mode, boolean editExists) {
+  public void set(
+      DiffObject base,
+      PatchSet.Id curr,
+      ChangeScreen.Style style,
+      Widget replyButton,
+      Mode mode,
+      boolean editExists) {
     this.base = base;
     this.curr = curr;
     this.style = style;
@@ -207,21 +229,28 @@
     this.editExists = editExists;
   }
 
-  void setValue(NativeMap<FileInfo> fileMap,
+  void setValue(
+      NativeMap<FileInfo> fileMap,
       Timestamp myLastReply,
       @Nullable NativeMap<JsArray<CommentInfo>> comments,
       @Nullable NativeMap<JsArray<CommentInfo>> drafts) {
     JsArray<FileInfo> list = fileMap.values();
     FileInfo.sortFileInfoByPath(list);
 
-    DisplayCommand cmd = new DisplayCommand(fileMap, list,
-        myLastReply, comments, drafts);
+    DisplayCommand cmd = new DisplayCommand(fileMap, list, myLastReply, comments, drafts);
     if (cmd.execute()) {
       cmd.showProgressBar();
       Scheduler.get().scheduleIncremental(cmd);
     }
   }
 
+  void showError(Throwable t) {
+    clear();
+    Label l = new Label(Resources.M.failedToLoadFileList(t.getMessage()));
+    add(l);
+    l.setStyleName(R.css().error());
+  }
+
   void markReviewed(JsArrayString reviewed) {
     if (table != null) {
       table.markReviewed(reviewed);
@@ -258,11 +287,17 @@
     if (table != null) {
       String self = Gerrit.selfRedirect(null);
       for (FileInfo info : Natives.asList(table.list)) {
-        Window.open(self + "#" + url(info), "_blank", null);
+        if (canOpen(info.path())) {
+          Window.open(self + "#" + url(info), "_blank", null);
+        }
       }
     }
   }
 
+  private boolean canOpen(String path) {
+    return mode != Mode.EDIT || !Patch.isMagic(path) || Patch.COMMIT_MSG.equals(path);
+  }
+
   private void setTable(MyTable table) {
     clear();
     add(table);
@@ -283,8 +318,8 @@
 
   private String url(FileInfo info) {
     return info.binary()
-      ? Dispatcher.toUnified(base, curr, info.path())
-      : mode == Mode.REVIEW
+        ? Dispatcher.toUnified(base, curr, info.path())
+        : mode == Mode.REVIEW
             ? Dispatcher.toPatch(base, curr, info.path())
             : Dispatcher.toEditScreen(curr, info.path());
   }
@@ -302,61 +337,59 @@
           new PrevKeyCommand(0, 'k', Util.C.patchTablePrev()),
           new NextKeyCommand(0, 'j', Util.C.patchTableNext()));
       keysNavigation.add(new OpenKeyCommand(0, 'o', Util.C.patchTableOpenDiff()));
-      keysNavigation.add(new OpenKeyCommand(0, KeyCodes.KEY_ENTER,
-          Util.C.patchTableOpenDiff()));
+      keysNavigation.add(new OpenKeyCommand(0, KeyCodes.KEY_ENTER, Util.C.patchTableOpenDiff()));
 
       keysNavigation.add(
           new OpenFileCommand(list.length() - 1, 0, '[', Resources.C.openLastFile()),
           new OpenFileCommand(0, 0, ']', Resources.C.openCommitMessage()));
 
-      keysAction.add(new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          int row = getCurrentRow();
-          if (1 <= row && row <= MyTable.this.list.length()) {
-            FileInfo info = MyTable.this.list.get(row - 1);
-            InputElement b = getReviewed(info);
-            boolean c = !b.isChecked();
-            setReviewed(info, c);
-            b.setChecked(c);
-          }
-        }
-      });
+      keysAction.add(
+          new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
+            @Override
+            public void onKeyPress(KeyPressEvent event) {
+              int row = getCurrentRow();
+              if (1 <= row && row <= MyTable.this.list.length()) {
+                FileInfo info = MyTable.this.list.get(row - 1);
+                InputElement b = getReviewed(info);
+                boolean c = !b.isChecked();
+                setReviewed(info, c);
+                b.setChecked(c);
+              }
+            }
+          });
 
-      setSavePointerId(
-          (base != null ? base.toString() + ".." : "")
-          + curr.toString());
+      setSavePointerId((!base.isBase() ? base.asString() + ".." : "") + curr.toString());
     }
 
     void onDelete(int idx) {
       String path = list.get(idx).path();
-      ChangeEditApi.delete(curr.getParentKey().get(), path,
+      ChangeEditApi.delete(
+          curr.getParentKey().get(),
+          path,
           new AsyncCallback<VoidResult>() {
             @Override
             public void onSuccess(VoidResult result) {
-              Gerrit.display(PageLinks.toChangeInEditMode(
-                  curr.getParentKey()));
+              Gerrit.display(PageLinks.toChangeInEditMode(curr.getParentKey()));
             }
 
             @Override
-            public void onFailure(Throwable caught) {
-            }
+            public void onFailure(Throwable caught) {}
           });
     }
 
     void onRestore(int idx) {
       String path = list.get(idx).path();
-      ChangeEditApi.restore(curr.getParentKey().get(), path,
+      ChangeEditApi.restore(
+          curr.getParentKey().get(),
+          path,
           new AsyncCallback<VoidResult>() {
             @Override
             public void onSuccess(VoidResult result) {
-              Gerrit.display(PageLinks.toChangeInEditMode(
-                  curr.getParentKey()));
+              Gerrit.display(PageLinks.toChangeInEditMode(curr.getParentKey()));
             }
 
             @Override
-            public void onFailure(Throwable caught) {
-            }
+            public void onFailure(Throwable caught) {}
           });
     }
 
@@ -365,10 +398,7 @@
     }
 
     private void setReviewed(FileInfo info, boolean r) {
-      RestApi api = ChangeApi.revision(curr)
-          .view("files")
-          .id(info.path())
-          .view("reviewed");
+      RestApi api = ChangeApi.revision(curr).view("files").id(info.path()).view("reviewed");
       if (r) {
         api.put(CallbackGroup.<ReviewInfo>emptyCallback());
       } else {
@@ -420,7 +450,10 @@
     @Override
     protected void onOpenRow(int row) {
       if (1 <= row && row <= list.length()) {
-        Gerrit.display(url(list.get(row - 1)));
+        FileInfo info = list.get(row - 1);
+        if (canOpen(info.path())) {
+          Gerrit.display(url(info));
+        }
       }
     }
 
@@ -443,7 +476,10 @@
 
       @Override
       public void onKeyPress(KeyPressEvent event) {
-        Gerrit.display(url(list.get(index)));
+        FileInfo info = list.get(index);
+        if (canOpen(info.path())) {
+          Gerrit.display(url(info));
+        }
       }
     }
   }
@@ -471,7 +507,8 @@
     private long bytesInserted;
     private long bytesDeleted;
 
-    private DisplayCommand(NativeMap<FileInfo> map,
+    private DisplayCommand(
+        NativeMap<FileInfo> map,
         JsArray<FileInfo> list,
         Timestamp myLastReply,
         @Nullable NativeMap<JsArray<CommentInfo>> comments,
@@ -482,8 +519,7 @@
       this.comments = comments;
       this.drafts = drafts;
       this.hasUser = Gerrit.isSignedIn();
-      this.showChangeSizeBars =
-          Gerrit.getUserPreferences().sizeBarInChangeTable();
+      this.showChangeSizeBars = Gerrit.getUserPreferences().sizeBarInChangeTable();
       myTable.addStyleName(R.css().table());
     }
 
@@ -529,7 +565,7 @@
       bytesDeleted = 0;
       for (int i = 0; i < list.length(); i++) {
         FileInfo info = list.get(i);
-        if (!Patch.COMMIT_MSG.equals(info.path())) {
+        if (!Patch.isMagic(info.path())) {
           if (!info.binary()) {
             hasNonBinaryFile = true;
             inserted += info.linesInserted();
@@ -577,14 +613,8 @@
       }
       sb.openTh().setStyleName(R.css().status()).closeTh();
       sb.openTh().append(Util.C.patchTableColumnName()).closeTh();
-      sb.openTh()
-        .setAttribute("colspan", 3)
-        .append(Util.C.patchTableColumnComments())
-        .closeTh();
-      sb.openTh()
-        .setAttribute("colspan", 2)
-        .append(Util.C.patchTableColumnSize())
-        .closeTh();
+      sb.openTh().setAttribute("colspan", 3).append(Util.C.patchTableColumnComments()).closeTh();
+      sb.openTh().setAttribute("colspan", 2).append(Util.C.patchTableColumnSize()).closeTh();
       sb.closeTr();
     }
 
@@ -608,10 +638,10 @@
       sb.openTd().setStyleName(R.css().reviewed());
       if (hasUser) {
         sb.openElement("input")
-          .setAttribute("title", Resources.C.reviewedFileTitle())
-          .setAttribute("type", "checkbox")
-          .setAttribute("onclick", REVIEWED + "(event," + info._row() + ")")
-          .closeSelf();
+            .setAttribute("title", Resources.C.reviewedFileTitle())
+            .setAttribute("type", "checkbox")
+            .setAttribute("onclick", REVIEWED + "(event," + info._row() + ")")
+            .closeSelf();
       }
       sb.closeTd();
     }
@@ -619,22 +649,20 @@
     private void columnDeleteRestore(SafeHtmlBuilder sb, FileInfo info) {
       sb.openTd().setStyleName(R.css().restoreDelete());
       if (hasUser) {
-        if (!Patch.COMMIT_MSG.equals(info.path())) {
+        if (!Patch.isMagic(info.path())) {
           boolean editable = isEditable(info);
           sb.openDiv()
-            .openElement("button")
-            .setAttribute("title", Resources.C.restoreFileInline())
-            .setAttribute("onclick", RESTORE + "(event," + info._row() + ")")
-            .append(new ImageResourceRenderer().render(
-                Gerrit.RESOURCES.editUndo()))
-            .closeElement("button");
+              .openElement("button")
+              .setAttribute("title", Resources.C.restoreFileInline())
+              .setAttribute("onclick", RESTORE + "(event," + info._row() + ")")
+              .append(new ImageResourceRenderer().render(Gerrit.RESOURCES.editUndo()))
+              .closeElement("button");
           if (editable) {
             sb.openElement("button")
-              .setAttribute("title", Resources.C.removeFileInline())
-              .setAttribute("onclick", DELETE + "(event," + info._row() + ")")
-              .append(new ImageResourceRenderer().render(
-                  Gerrit.RESOURCES.redNot()))
-              .closeElement("button");
+                .setAttribute("title", Resources.C.removeFileInline())
+                .setAttribute("onclick", DELETE + "(event," + info._row() + ")")
+                .append(new ImageResourceRenderer().render(Gerrit.RESOURCES.redNot()))
+                .closeElement("button");
           }
           sb.closeDiv();
         }
@@ -644,13 +672,12 @@
 
     private boolean isEditable(FileInfo info) {
       String status = info.status();
-      return status == null
-          || !ChangeType.DELETED.matches(status);
+      return status == null || !ChangeType.DELETED.matches(status);
     }
 
     private void columnStatus(SafeHtmlBuilder sb, FileInfo info) {
       sb.openTd().setStyleName(R.css().status());
-      if (!Patch.COMMIT_MSG.equals(info.path())
+      if (!Patch.isMagic(info.path())
           && info.status() != null
           && !ChangeType.MODIFIED.matches(info.status())) {
         sb.append(info.status());
@@ -659,45 +686,57 @@
     }
 
     private void columnPath(SafeHtmlBuilder sb, FileInfo info) {
-      sb.openTd()
-        .setStyleName(R.css().pathColumn())
-        .openAnchor();
-
       String path = info.path();
+
+      sb.openTd().setStyleName(R.css().pathColumn());
+
+      if (!canOpen(path)) {
+        sb.openDiv();
+        appendPath(path);
+        sb.closeDiv();
+        sb.closeTd();
+        return;
+      }
+
+      sb.openAnchor();
+
       if (mode == Mode.EDIT && !isEditable(info)) {
         sb.setAttribute("onclick", RESTORE + "(event," + info._row() + ")");
       } else {
         sb.setAttribute("href", "#" + url(info))
-          .setAttribute("onclick", OPEN + "(event," + info._row() + ")");
+            .setAttribute("onclick", OPEN + "(event," + info._row() + ")");
       }
+      appendPath(path);
+      sb.closeAnchor();
+      if (info.oldPath() != null) {
+        sb.br();
+        sb.openSpan().setStyleName(R.css().renameCopySource()).append(info.oldPath()).closeSpan();
+      }
+      sb.closeTd();
+    }
 
+    private void appendPath(String path) {
       if (Patch.COMMIT_MSG.equals(path)) {
         sb.append(Util.C.commitMessage());
+      } else if (Patch.MERGE_LIST.equals(path)) {
+        sb.append(Util.C.mergeList());
       } else if (Gerrit.getUserPreferences().muteCommonPathPrefixes()) {
         int commonPrefixLen = commonPrefix(path);
         if (commonPrefixLen > 0) {
-          sb.openSpan().setStyleName(R.css().commonPrefix())
-            .append(path.substring(0, commonPrefixLen))
-            .closeSpan();
+          sb.openSpan()
+              .setStyleName(R.css().commonPrefix())
+              .append(path.substring(0, commonPrefixLen))
+              .closeSpan();
         }
         sb.append(path.substring(commonPrefixLen));
         lastPath = path;
       } else {
         sb.append(path);
       }
-
-      sb.closeAnchor();
-      if (info.oldPath() != null) {
-        sb.br();
-        sb.openSpan().setStyleName(R.css().renameCopySource())
-          .append(info.oldPath())
-          .closeSpan();
-      }
-      sb.closeTd();
     }
 
     private int commonPrefix(String path) {
-      for (int n = path.length(); n > 0;) {
+      for (int n = path.length(); n > 0; ) {
         int s = path.lastIndexOf('/', n);
         if (s < 0) {
           return 0;
@@ -753,9 +792,9 @@
       for (CommentInfo c : Natives.asList(list)) {
         if (c.side() == Side.REVISION) {
           result.push(c);
-        } else if (base == null && !c.hasParent()) {
+        } else if (base.isBaseOrAutoMerge() && !c.hasParent()) {
           result.push(c);
-        } else if (base != null && c.parent() == -base.get()) {
+        } else if (base.isParent() && c.parent() == base.getParentNum()) {
           result.push(c);
         }
       }
@@ -775,27 +814,21 @@
 
     private void columnDelta1(SafeHtmlBuilder sb, FileInfo info) {
       sb.openTd().setStyleName(R.css().deltaColumn1());
-      if (!Patch.COMMIT_MSG.equals(info.path()) && !info.binary()) {
+      if (!Patch.isMagic(info.path()) && !info.binary()) {
         if (showChangeSizeBars) {
           sb.append(info.linesInserted() + info.linesDeleted());
         } else if (!ChangeType.DELETED.matches(info.status())) {
           if (ChangeType.ADDED.matches(info.status())) {
-            sb.append(info.linesInserted())
-              .append(" lines");
+            sb.append(info.linesInserted()).append(" lines");
           } else {
-            sb.append("+")
-              .append(info.linesInserted())
-              .append(", -")
-              .append(info.linesDeleted());
+            sb.append("+").append(info.linesInserted()).append(", -").append(info.linesDeleted());
           }
         }
       } else if (info.binary()) {
         sb.append(formatBytes(info.sizeDelta()));
         long oldSize = info.size() - info.sizeDelta();
         if (oldSize != 0) {
-          sb.append(" (")
-            .append(formatPercentage(oldSize, info.sizeDelta()))
-            .append(")");
+          sb.append(" (").append(formatPercentage(oldSize, info.sizeDelta())).append(")");
         }
       }
       sb.closeTd();
@@ -804,7 +837,8 @@
     private void columnDelta2(SafeHtmlBuilder sb, FileInfo info) {
       sb.openTd().setStyleName(R.css().deltaColumn2());
       if (showChangeSizeBars
-          && !Patch.COMMIT_MSG.equals(info.path()) && !info.binary()
+          && !Patch.isMagic(info.path())
+          && !info.binary()
           && (info.linesInserted() != 0 || info.linesDeleted() != 0)) {
         int w = 80;
         int t = inserted + deleted;
@@ -812,21 +846,19 @@
         int d = Math.max(5, (int) (((double) w) * info.linesDeleted() / t));
 
         sb.setAttribute(
-            "title",
-            Util.M.patchTableSize_LongModify(info.linesInserted(),
-                info.linesDeleted()));
+            "title", Util.M.patchTableSize_LongModify(info.linesInserted(), info.linesDeleted()));
 
         if (0 < info.linesInserted()) {
           sb.openDiv()
-            .setStyleName(R.css().inserted())
-            .setAttribute("style", "width:" + i + "px")
-            .closeDiv();
+              .setStyleName(R.css().inserted())
+              .setAttribute("style", "width:" + i + "px")
+              .closeDiv();
         }
         if (0 < info.linesDeleted()) {
           sb.openDiv()
-            .setStyleName(R.css().deleted())
-            .setAttribute("style", "width:" + d + "px")
-            .closeDiv();
+              .setStyleName(R.css().deleted())
+              .setAttribute("style", "width:" + d + "px")
+              .closeDiv();
         }
       }
       sb.closeTd();
@@ -854,15 +886,16 @@
           sb.br();
         }
         if (binOldSize != 0) {
-          sb.append(Util.M.patchTableSize_ModifyBinaryFilesWithPercentages(
-              formatAbsBytes(bytesInserted),
-              formatAbsPercentage(binOldSize, bytesInserted),
-              formatAbsBytes(bytesDeleted),
-              formatAbsPercentage(binOldSize, bytesDeleted)));
+          sb.append(
+              Util.M.patchTableSize_ModifyBinaryFilesWithPercentages(
+                  formatAbsBytes(bytesInserted),
+                  formatAbsPercentage(binOldSize, bytesInserted),
+                  formatAbsBytes(bytesDeleted),
+                  formatAbsPercentage(binOldSize, bytesDeleted)));
         } else {
-          sb.append(Util.M.patchTableSize_ModifyBinaryFiles(
-              formatAbsBytes(bytesInserted),
-              formatAbsBytes(bytesDeleted)));
+          sb.append(
+              Util.M.patchTableSize_ModifyBinaryFiles(
+                  formatAbsBytes(bytesInserted), formatAbsBytes(bytesDeleted)));
         }
       }
       sb.closeTh();
@@ -881,15 +914,15 @@
         }
         if (0 < inserted) {
           sb.openDiv()
-          .setStyleName(R.css().inserted())
-          .setAttribute("style", "width:" + i + "px")
-          .closeDiv();
+              .setStyleName(R.css().inserted())
+              .setAttribute("style", "width:" + i + "px")
+              .closeDiv();
         }
         if (0 < deleted) {
           sb.openDiv()
-            .setStyleName(R.css().deleted())
-            .setAttribute("style", "width:" + d + "px")
-            .closeDiv();
+              .setStyleName(R.css().deleted())
+              .setAttribute("style", "width:" + d + "px")
+              .closeDiv();
         }
       }
       sb.closeTh();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java
index 29283b8..5c7472c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java
@@ -37,7 +37,12 @@
 
   @Override
   void send(String message) {
-    ChangeApi.createChange(project, branch, topic, message, base,
+    ChangeApi.createChange(
+        project,
+        branch,
+        topic,
+        message,
+        base,
         new GerritCallback<ChangeInfo>() {
           @Override
           public void onSuccess(ChangeInfo result) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java
index 44aae25..192be34 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Hashtags.java
@@ -43,12 +43,12 @@
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.Iterator;
 
 public class Hashtags extends Composite {
 
   interface Binder extends UiBinder<HTMLPanel, Hashtags> {}
+
   private static final int VISIBLE_LENGTH = 55;
   private static final Binder uiBinder = GWT.create(Binder.class);
   private static final String REMOVE;
@@ -73,15 +73,17 @@
     if (hashtags != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
       final PatchSet.Id psId = screen.getPatchSetId();
-      ChangeApi.hashtags(psId.getParentKey().get()).post(
-          PostInput.create(null, hashtags), new GerritCallback<JavaScriptObject>() {
-            @Override
-            public void onSuccess(JavaScriptObject result) {
-              if (screen.isCurrentView()) {
-                Gerrit.display(PageLinks.toChange(psId));
-              }
-            }
-          });
+      ChangeApi.hashtags(psId.getParentKey().get())
+          .post(
+              PostInput.create(null, hashtags),
+              new GerritCallback<JavaScriptObject>() {
+                @Override
+                public void onSuccess(JavaScriptObject result) {
+                  if (screen.isCurrentView()) {
+                    Gerrit.display(PageLinks.toChange(psId));
+                  }
+                }
+              });
     }
   }
 
@@ -111,16 +113,17 @@
     initWidget(uiBinder.createAndBindUi(this));
 
     hashtagTextBox.setVisibleLength(VISIBLE_LENGTH);
-    hashtagTextBox.addKeyDownHandler(new KeyDownHandler() {
-      @Override
-      public void onKeyDown(KeyDownEvent e) {
-        if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
-          onCancel(null);
-        } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
-          onAdd(null);
-        }
-      }
-    });
+    hashtagTextBox.addKeyDownHandler(
+        new KeyDownHandler() {
+          @Override
+          public void onKeyDown(KeyDownEvent e) {
+            if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
+              onCancel(null);
+            } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
+              onAdd(null);
+            }
+          }
+        });
 
     addHashtagIcon.addDomHandler(
         new ClickHandler() {
@@ -137,9 +140,7 @@
   }
 
   void set(ChangeInfo info, String revision) {
-    psId = new PatchSet.Id(
-        info.legacyId(),
-        info.revisions().get(revision)._number());
+    psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
 
     canEdit = info.hasActions() && info.actions().containsKey("hashtags");
     this.changeId = info.legacyId();
@@ -157,6 +158,7 @@
   private void display(ChangeInfo info) {
     hashtagsText.setInnerSafeHtml(formatHashtags(info));
   }
+
   private void display(JsArrayString hashtags) {
     hashtagsText.setInnerSafeHtml(formatHashtags(hashtags));
   }
@@ -177,13 +179,11 @@
           .setAttribute(DATA_ID, hashtagName)
           .setStyleName(style.hashtagName())
           .openAnchor()
-          .setAttribute("href",
-              "#" + PageLinks.toChangeQuery("hashtag:\"" + hashtagName + "\""))
+          .setAttribute("href", "#" + PageLinks.toChangeQuery("hashtag:\"" + hashtagName + "\""))
           .setAttribute("role", "listitem")
           .openSpan()
-            .setStyleName(style.hashtagIcon())
-            .append(new ImageResourceRenderer().render(
-                Gerrit.RESOURCES.hashtag()))
+          .setStyleName(style.hashtagIcon())
+          .append(new ImageResourceRenderer().render(Gerrit.RESOURCES.hashtag()))
           .closeSpan()
           .append(" ")
           .append(hashtagName)
@@ -219,25 +219,25 @@
   }
 
   private void addHashtag(final String hashtags) {
-    ChangeApi.hashtags(changeId.get()).post(
-        PostInput.create(hashtags, null),
-        new GerritCallback<JsArrayString>() {
-          @Override
-          public void onSuccess(JsArrayString result) {
-            Gerrit.display(PageLinks.toChange(
-                psId.getParentKey(),
-                String.valueOf(psId.get())));
-          }
+    ChangeApi.hashtags(changeId.get())
+        .post(
+            PostInput.create(hashtags, null),
+            new GerritCallback<JsArrayString>() {
+              @Override
+              public void onSuccess(JsArrayString result) {
+                Gerrit.display(PageLinks.toChange(psId.getParentKey(), String.valueOf(psId.get())));
+              }
 
-          @Override
-          public void onFailure(Throwable err) {
-            UIObject.setVisible(error, true);
-            error.setInnerText(err instanceof StatusCodeException
-                ? ((StatusCodeException) err).getEncodedResponse()
-                : err.getMessage());
-            hashtagTextBox.setEnabled(true);
-          }
-        });
+              @Override
+              public void onFailure(Throwable err) {
+                UIObject.setVisible(error, true);
+                error.setInnerText(
+                    err instanceof StatusCodeException
+                        ? ((StatusCodeException) err).getEncodedResponse()
+                        : err.getMessage());
+                hashtagTextBox.setEnabled(true);
+              }
+            });
   }
 
   public static class PostInput extends JavaScriptObject {
@@ -246,6 +246,7 @@
       input.init(toJsArrayString(add), toJsArrayString(remove));
       return input;
     }
+
     private static JsArrayString toJsArrayString(String commaSeparated) {
       if (commaSeparated == null || commaSeparated.equals("")) {
         return null;
@@ -262,7 +263,6 @@
       this.remove = remove;
     }-*/;
 
-    protected PostInput() {
-    }
+    protected PostInput() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java
index 92a0fc8..e221f54 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java
@@ -24,7 +24,6 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.Widget;
-
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -39,8 +38,7 @@
 
   private final Map<Integer, List<CommentInfo>> byAuthor = new HashMap<>();
 
-  void set(CommentLinkProcessor clp, ReplyAction ra,
-      Change.Id id, ChangeInfo info) {
+  void set(CommentLinkProcessor clp, ReplyAction ra, Change.Id id, ChangeInfo info) {
     this.clp = clp;
     this.replyAction = ra;
     this.changeId = id;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java
index 479670f..00b6c3c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java
@@ -22,10 +22,7 @@
   private final IncludedInBox includedInBox;
 
   IncludedInAction(
-      Change.Id changeId,
-      ChangeScreen.Style style,
-      UIObject relativeTo,
-      Widget includedInButton) {
+      Change.Id changeId, ChangeScreen.Style style, UIObject relativeTo, Widget includedInButton) {
     super(style, relativeTo, includedInButton);
     this.includedInBox = new IncludedInBox(changeId);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java
index 6493f11..0f121cc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java
@@ -35,6 +35,7 @@
 
 class IncludedInBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, IncludedInBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface Style extends CssResource {
@@ -57,25 +58,25 @@
   @Override
   protected void onLoad() {
     if (!loaded) {
-      ChangeApi.includedIn(changeId.get(),
+      ChangeApi.includedIn(
+          changeId.get(),
           new AsyncCallback<IncludedInInfo>() {
-        @Override
-        public void onSuccess(IncludedInInfo r) {
-          branches.setInnerSafeHtml(formatList(r.branches()));
-          tags.setInnerSafeHtml(formatList(r.tags()));
-          for (String n : r.externalNames()) {
-            JsArrayString external = r.external(n);
-            if (external.length() > 0) {
-              appendRow(n, external);
+            @Override
+            public void onSuccess(IncludedInInfo r) {
+              branches.setInnerSafeHtml(formatList(r.branches()));
+              tags.setInnerSafeHtml(formatList(r.tags()));
+              for (String n : r.externalNames()) {
+                JsArrayString external = r.external(n);
+                if (external.length() > 0) {
+                  appendRow(n, external);
+                }
+              }
+              loaded = true;
             }
-          }
-          loaded = true;
-        }
 
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      });
+            @Override
+            public void onFailure(Throwable caught) {}
+          });
     }
   }
 
@@ -83,10 +84,7 @@
     SafeHtmlBuilder html = new SafeHtmlBuilder();
     int size = l.length();
     for (int i = 0; i < size; i++) {
-      html.openSpan()
-          .addStyleName(style.includedInElement())
-          .append(l.get(i))
-          .closeSpan();
+      html.openSpan().addStyleName(style.includedInElement()).append(l.get(i)).closeSpan();
       if (i < size - 1) {
         html.append(", ");
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
index 89550c6..fc34aeb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Labels.java
@@ -35,7 +35,6 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -73,15 +72,16 @@
     if (user != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
       final Change.Id changeId = screen.getPatchSetId().getParentKey();
-      ChangeApi.reviewer(changeId.get(), user).delete(
-          new GerritCallback<JavaScriptObject>() {
-            @Override
-            public void onSuccess(JavaScriptObject result) {
-              if (screen.isCurrentView()) {
-                Gerrit.display(PageLinks.toChange(changeId));
-              }
-            }
-          });
+      ChangeApi.reviewer(changeId.get(), user)
+          .delete(
+              new GerritCallback<JavaScriptObject>() {
+                @Override
+                public void onSuccess(JavaScriptObject result) {
+                  if (screen.isCurrentView()) {
+                    Gerrit.display(PageLinks.toChange(changeId));
+                  }
+                }
+              });
     }
   }
 
@@ -91,15 +91,16 @@
     if (user != null && vote != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
       final Change.Id changeId = screen.getPatchSetId().getParentKey();
-      ChangeApi.vote(changeId.get(), user, vote).delete(
-          new GerritCallback<JavaScriptObject>() {
-            @Override
-            public void onSuccess(JavaScriptObject result) {
-              if (screen.isCurrentView()) {
-                Gerrit.display(PageLinks.toChange(changeId));
-              }
-            }
-          });
+      ChangeApi.vote(changeId.get(), user, vote)
+          .delete(
+              new GerritCallback<JavaScriptObject>() {
+                @Override
+                public void onSuccess(JavaScriptObject result) {
+                  if (screen.isCurrentView()) {
+                    Gerrit.display(PageLinks.toChange(changeId));
+                  }
+                }
+              });
     }
   }
 
@@ -189,8 +190,7 @@
         html.setStyleName(style.label_reject());
       }
       html.append(val).append(" ");
-      html.append(formatUserList(style, m.get(v), removable,
-          label.name(), null));
+      html.append(formatUserList(style, m.get(v), removable, label.name(), null));
       html.closeSpan();
     }
     return html.toBlockWidget();
@@ -210,13 +210,11 @@
   }
 
   private static boolean isApproved(LabelInfo label, ApprovalInfo ai) {
-    return label.approved() != null
-        && label.approved()._accountId() == ai._accountId();
+    return label.approved() != null && label.approved()._accountId() == ai._accountId();
   }
 
   private static boolean isRejected(LabelInfo label, ApprovalInfo ai) {
-    return label.rejected() != null
-        && label.rejected()._accountId() == ai._accountId();
+    return label.rejected() != null && label.rejected()._accountId() == ai._accountId();
   }
 
   private String getStyleForLabel(LabelInfo label) {
@@ -234,34 +232,37 @@
     }
   }
 
-  static SafeHtml formatUserList(ChangeScreen.Style style,
+  static SafeHtml formatUserList(
+      ChangeScreen.Style style,
       Collection<? extends AccountInfo> in,
       Set<Integer> removable,
       String label,
       Map<Integer, VotableInfo> votable) {
     List<AccountInfo> users = new ArrayList<>(in);
-    Collections.sort(users, new Comparator<AccountInfo>() {
-      @Override
-      public int compare(AccountInfo a, AccountInfo b) {
-        String as = name(a);
-        String bs = name(b);
-        if (as.isEmpty()) {
-          return 1;
-        } else if (bs.isEmpty()) {
-          return -1;
-        }
-        return as.compareTo(bs);
-      }
+    Collections.sort(
+        users,
+        new Comparator<AccountInfo>() {
+          @Override
+          public int compare(AccountInfo a, AccountInfo b) {
+            String as = name(a);
+            String bs = name(b);
+            if (as.isEmpty()) {
+              return 1;
+            } else if (bs.isEmpty()) {
+              return -1;
+            }
+            return as.compareTo(bs);
+          }
 
-      private String name(AccountInfo a) {
-        if (a.name() != null) {
-          return a.name();
-        } else if (a.email() != null) {
-          return a.email();
-        }
-        return "";
-      }
-    });
+          private String name(AccountInfo a) {
+            if (a.name() != null) {
+              return a.name();
+            } else if (a.email() != null) {
+              return a.email();
+            }
+            return "";
+          }
+        });
 
     SafeHtmlBuilder html = new SafeHtmlBuilder();
     Iterator<? extends AccountInfo> itr = users.iterator();
@@ -285,8 +286,7 @@
           if (!s.isEmpty()) {
             StringBuilder sb = new StringBuilder(Util.C.votable());
             sb.append(" ");
-            for (Iterator<String> it = vi.votableLabels().iterator();
-                it.hasNext();) {
+            for (Iterator<String> it = vi.votableLabels().iterator(); it.hasNext(); ) {
               sb.append(it.next());
               if (it.hasNext()) {
                 sb.append(", ");
@@ -305,9 +305,7 @@
         html.setAttribute(DATA_VOTE, label);
       }
       if (img != null) {
-        html.openElement("img")
-            .setStyleName(style.avatar())
-            .setAttribute("src", img.url());
+        html.openElement("img").setStyleName(style.avatar()).setAttribute("src", img.url());
         if (img.width() > 0) {
           html.setAttribute("width", img.width());
         }
@@ -326,8 +324,7 @@
           html.setAttribute("title", Util.M.removeReviewer(name))
               .setAttribute("onclick", REMOVE_REVIEWER + "(event)");
         }
-        html.append("×")
-            .closeElement("button");
+        html.append("×").closeElement("button");
       }
       html.closeSpan();
       if (itr.hasNext()) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java
index a0f8858..a1ad7c2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LineComment.java
@@ -32,6 +32,7 @@
 
 class LineComment extends Composite {
   interface Binder extends UiBinder<HTMLPanel, LineComment> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   @UiField Element sideLoc;
@@ -42,9 +43,7 @@
   @UiField InlineHyperlink line;
   @UiField Element message;
 
-  LineComment(CommentLinkProcessor clp,
-      PatchSet.Id defaultPs,
-      CommentInfo info) {
+  LineComment(CommentLinkProcessor clp, PatchSet.Id defaultPs, CommentInfo info) {
     initWidget(uiBinder.createAndBindUi(this));
 
     PatchSet.Id ps;
@@ -81,14 +80,17 @@
     }
 
     if (info.message() != null) {
-      message.setInnerSafeHtml(clp.apply(new SafeHtmlBuilder()
-          .append(info.message().trim()).wikify()));
+      message.setInnerSafeHtml(
+          clp.apply(new SafeHtmlBuilder().append(info.message().trim()).wikify()));
       ApiGlue.fireEvent("comment", message);
     }
   }
 
   private static String url(PatchSet.Id ps, CommentInfo info) {
-    return Dispatcher.toPatch(null, ps, info.path(),
+    return Dispatcher.toPatch(
+        null,
+        ps,
+        info.path(),
         info.side() == Side.PARENT ? DisplaySide.A : DisplaySide.B,
         info.line());
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
index f6022f9..689aa2a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/LocalComments.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.storage.client.Storage;
 import com.google.gwt.user.client.Cookies;
-
 import java.util.ArrayList;
 import java.util.Collection;
 
@@ -47,9 +46,10 @@
     private final Storage storageBackend;
 
     StorageBackend() {
-      storageBackend = (Storage.isLocalStorageSupported())
-          ? Storage.getLocalStorageIfSupported()
-          : Storage.getSessionStorageIfSupported();
+      storageBackend =
+          (Storage.isLocalStorageSupported())
+              ? Storage.getLocalStorageIfSupported()
+              : Storage.getSessionStorageIfSupported();
     }
 
     String getItem(String key) {
@@ -129,7 +129,9 @@
       if (isInlineComment(cookie)) {
         InlineComment input = getInlineComment(cookie);
         if (input.commentInfo.id() == null) {
-          CommentApi.createDraft(input.psId, input.commentInfo,
+          CommentApi.createDraft(
+              input.psId,
+              input.commentInfo,
               new GerritCallback<CommentInfo>() {
                 @Override
                 public void onSuccess(CommentInfo result) {
@@ -137,8 +139,11 @@
                 }
               });
         } else {
-          CommentApi.updateDraft(input.psId, input.commentInfo.id(),
-              input.commentInfo, new GerritCallback<CommentInfo>() {
+          CommentApi.updateDraft(
+              input.psId,
+              input.commentInfo.id(),
+              input.commentInfo,
+              new GerritCallback<CommentInfo>() {
                 @Override
                 public void onSuccess(CommentInfo result) {
                   storage.removeItem(cookie);
@@ -179,7 +184,8 @@
   }
 
   private static boolean isInlineComment(String key) {
-    return key.startsWith("patchCommentEdit-") || key.startsWith("patchReply-")
+    return key.startsWith("patchCommentEdit-")
+        || key.startsWith("patchReply-")
         || key.startsWith("patchComment-");
   }
 
@@ -196,11 +202,9 @@
       offset = 2;
     }
     Change.Id changeId = new Change.Id(Integer.parseInt(elements[offset + 0]));
-    PatchSet.Id psId =
-        new PatchSet.Id(changeId, Integer.parseInt(elements[offset + 1]));
+    PatchSet.Id psId = new PatchSet.Id(changeId, Integer.parseInt(elements[offset + 1]));
     path = atob(elements[offset + 2]);
-    side = (Side.PARENT.toString().equals(elements[offset + 3])) ? Side.PARENT
-        : Side.REVISION;
+    side = (Side.PARENT.toString().equals(elements[offset + 3])) ? Side.PARENT : Side.REVISION;
     range = null;
     if (elements[offset + 4].startsWith("R")) {
       String rangeStart = elements[offset + 4].substring(1);
@@ -216,7 +220,7 @@
     } else {
       line = Integer.parseInt(elements[offset + 4]);
     }
-    CommentInfo info = CommentInfo.create(path, side, line, range);
+    CommentInfo info = CommentInfo.create(path, side, line, range, false);
     info.message(storage.getItem(key));
     if (key.startsWith("patchReply-")) {
       info.inReplyTo(elements[1]);
@@ -237,12 +241,18 @@
     } else if (comment.inReplyTo() != null) {
       result = "patchReply-" + comment.inReplyTo() + "-";
     }
-    result += changeId + "-" + psId.getId() + "-" + btoa(comment.path()) + "-"
-        + comment.side() + "-";
+    result +=
+        changeId + "-" + psId.getId() + "-" + btoa(comment.path()) + "-" + comment.side() + "-";
     if (comment.hasRange()) {
-      result += "R" + comment.range().startLine() + ","
-          + comment.range().startCharacter() + "-" + comment.range().endLine()
-          + "," + comment.range().endCharacter();
+      result +=
+          "R"
+              + comment.range().startLine()
+              + ","
+              + comment.range().startCharacter()
+              + "-"
+              + comment.range().endLine()
+              + ","
+              + comment.range().endCharacter();
     } else {
       result += comment.line();
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
index 6c27ed9..a8fe2f0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Message.java
@@ -39,7 +39,6 @@
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -48,6 +47,7 @@
 
 class Message extends Composite {
   interface Binder extends UiBinder<HTMLPanel, Message> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface Style extends CssResource {
@@ -80,12 +80,14 @@
     }
 
     initWidget(uiBinder.createAndBindUi(this));
-    header.addDomHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        setOpen(!isOpen());
-      }
-    }, ClickEvent.getType());
+    header.addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            setOpen(!isOpen());
+          }
+        },
+        ClickEvent.getType());
 
     this.history = parent;
     this.info = info;
@@ -95,8 +97,8 @@
     if (info.message() != null) {
       String msg = info.message().trim();
       summary.setInnerText(msg);
-      message.setInnerSafeHtml(history.getCommentLinkProcessor()
-        .apply(new SafeHtmlBuilder().append(msg).wikify()));
+      message.setInnerSafeHtml(
+          history.getCommentLinkProcessor().apply(new SafeHtmlBuilder().append(msg).wikify()));
       ApiGlue.fireEvent("comment", message);
     } else {
       reply.getElement().getStyle().setVisibility(Visibility.HIDDEN);
@@ -140,9 +142,8 @@
   }
 
   private void setName(boolean open) {
-    name.setInnerText(open
-        ? authorName(info)
-        : com.google.gerrit.common.FormatUtil.elide(authorName(info), 20));
+    name.setInnerText(
+        open ? authorName(info) : com.google.gerrit.common.FormatUtil.elide(authorName(info), 20));
   }
 
   void autoOpen() {
@@ -168,21 +169,22 @@
 
   private void renderComments(List<CommentInfo> list) {
     CommentLinkProcessor clp = history.getCommentLinkProcessor();
-    PatchSet.Id ps = new PatchSet.Id(
-        history.getChangeId(),
-        info._revisionNumber());
+    PatchSet.Id ps = new PatchSet.Id(history.getChangeId(), info._revisionNumber());
     TreeMap<String, List<CommentInfo>> m = byPath(list);
     List<CommentInfo> l = m.remove(Patch.COMMIT_MSG);
     if (l != null) {
       comments.add(new FileComments(clp, ps, Util.C.commitMessage(), l));
     }
+    l = m.remove(Patch.MERGE_LIST);
+    if (l != null) {
+      comments.add(new FileComments(clp, ps, Util.C.mergeList(), l));
+    }
     for (Map.Entry<String, List<CommentInfo>> e : m.entrySet()) {
       comments.add(new FileComments(clp, ps, e.getKey(), e.getValue()));
     }
   }
 
-  private static TreeMap<String, List<CommentInfo>>
-  byPath(List<CommentInfo> list) {
+  private static TreeMap<String, List<CommentInfo>> byPath(List<CommentInfo> list) {
     TreeMap<String, List<CommentInfo>> m = new TreeMap<>();
     for (CommentInfo c : list) {
       List<CommentInfo> l = m.get(c.path());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java
index 1753c7b..189df08 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsBox.java
@@ -47,12 +47,12 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwt.user.client.ui.impl.HyperlinkImpl;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.Collections;
 import java.util.EnumSet;
 
 class PatchSetsBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, PatchSetsBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private static final String OPEN;
@@ -70,7 +70,7 @@
   }-*/;
 
   private static boolean onOpen(NativeEvent e, int idx) {
-    if (link.handleAsClick(e.<Event> cast())) {
+    if (link.handleAsClick(e.<Event>cast())) {
       PatchSetsBox t = getRevisionBox(e);
       if (t != null) {
         t.onOpenRow(idx);
@@ -94,8 +94,11 @@
 
   interface Style extends CssResource {
     String current();
+
     String legacy_id();
+
     String commit();
+
     String draft_comment();
   }
 
@@ -119,24 +122,23 @@
   protected void onLoad() {
     if (!loaded) {
       RestApi call = ChangeApi.detail(changeId.get());
-      ChangeList.addOptions(call, EnumSet.of(
-          ListChangesOption.ALL_COMMITS,
-          ListChangesOption.ALL_REVISIONS));
-      call.get(new AsyncCallback<ChangeInfo>() {
-        @Override
-        public void onSuccess(ChangeInfo result) {
-          if (edit != null) {
-            edit.setName(edit.commit().commit());
-            result.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
-          }
-          render(result.revisions());
-          loaded = true;
-        }
+      ChangeList.addOptions(
+          call, EnumSet.of(ListChangesOption.ALL_COMMITS, ListChangesOption.ALL_REVISIONS));
+      call.get(
+          new AsyncCallback<ChangeInfo>() {
+            @Override
+            public void onSuccess(ChangeInfo result) {
+              if (edit != null) {
+                edit.setName(edit.commit().commit());
+                result.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
+              }
+              render(result.revisions());
+              loaded = true;
+            }
 
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      });
+            @Override
+            public void onFailure(Throwable caught) {}
+          });
     }
   }
 
@@ -158,20 +160,25 @@
       revision(sb, i, revisions.get(i));
     }
 
-    GWT.<FancyFlexTableImpl> create(FancyFlexTableImpl.class)
-      .resetHtml(table, sb);
+    GWT.<FancyFlexTableImpl>create(FancyFlexTableImpl.class).resetHtml(table, sb);
   }
 
   private void header(SafeHtmlBuilder sb) {
     sb.openTr()
-      .openTh()
-          .setStyleName(style.legacy_id())
-          .append(Resources.C.patchSet())
-          .closeTh()
-      .openTh().append(Resources.C.commit()).closeTh()
-      .openTh().append(Resources.C.date()).closeTh()
-      .openTh().append(Resources.C.author()).closeTh()
-      .closeTr();
+        .openTh()
+        .setStyleName(style.legacy_id())
+        .append(Resources.C.patchSet())
+        .closeTh()
+        .openTh()
+        .append(Resources.C.commit())
+        .closeTh()
+        .openTh()
+        .append(Resources.C.date())
+        .closeTh()
+        .openTh()
+        .append(Resources.C.author())
+        .closeTh()
+        .closeTr();
   }
 
   private void revision(SafeHtmlBuilder sb, int index, RevisionInfo r) {
@@ -189,17 +196,15 @@
     sb.closeTd();
 
     sb.openTd()
-      .setStyleName(style.commit())
-      .openAnchor()
-      .setAttribute("href", "#" + url(r))
-      .setAttribute("onclick", OPEN + "(event," + index + ")")
-      .append(r.name().substring(0, 10))
-      .closeAnchor()
-      .closeTd();
+        .setStyleName(style.commit())
+        .openAnchor()
+        .setAttribute("href", "#" + url(r))
+        .setAttribute("onclick", OPEN + "(event," + index + ")")
+        .append(r.name().substring(0, 10))
+        .closeAnchor()
+        .closeTd();
 
-    sb.openTd()
-      .append(FormatUtil.shortFormatDayTime(c.committer().date()))
-      .closeTd();
+    sb.openTd().append(FormatUtil.shortFormatDayTime(c.committer().date())).closeTd();
 
     String an = c.author() != null ? c.author().name() : "";
     String cn = c.committer() != null ? c.committer().name() : "";
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java
index 2ca26ce..3b96a12 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java
@@ -17,11 +17,11 @@
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -38,26 +38,28 @@
 
   @Override
   protected void onRequestSuggestions(final Request req, final Callback cb) {
-    ChangeApi.revision(changeId.get(), revision.name())
-      .view("files")
-      .addParameter("q", req.getQuery())
-      .background()
-      .get(new AsyncCallback<JsArrayString>() {
-          @Override
-          public void onSuccess(JsArrayString result) {
-            List<Suggestion> r = new ArrayList<>();
-            for (String path : Natives.asList(result)) {
-              r.add(new PathSuggestion(path));
-            }
-            cb.onSuggestionsReady(req, new Response(r));
-          }
+    RestApi api = ChangeApi.revision(changeId.get(), revision.name()).view("files");
+    if (req.getQuery() != null) {
+      api.addParameter("q", req.getQuery() == null ? "" : req.getQuery());
+    }
+    api.background()
+        .get(
+            new AsyncCallback<JsArrayString>() {
+              @Override
+              public void onSuccess(JsArrayString result) {
+                List<Suggestion> r = new ArrayList<>();
+                for (String path : Natives.asList(result)) {
+                  r.add(new PathSuggestion(path));
+                }
+                cb.onSuggestionsReady(req, new Response(r));
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-            List<Suggestion> none = Collections.emptyList();
-            cb.onSuggestionsReady(req, new Response(none));
-          }
-        });
+              @Override
+              public void onFailure(Throwable caught) {
+                List<Suggestion> none = Collections.emptyList();
+                cb.onSuggestionsReady(req, new Response(none));
+              }
+            });
   }
 
   private static class PathSuggestion implements Suggestion {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java
index ebeb574..c4a74f5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java
@@ -94,13 +94,15 @@
       replyAction.quickApprove(input);
     } else {
       ChangeApi.revision(changeId.get(), revision)
-        .view("review")
-        .post(input, new GerritCallback<ReviewInput>() {
-          @Override
-          public void onSuccess(ReviewInput result) {
-            Gerrit.display(PageLinks.toChange(changeId));
-          }
-        });
+          .view("review")
+          .post(
+              input,
+              new GerritCallback<ReviewInput>() {
+                @Override
+                public void onSuccess(ReviewInput result) {
+                  Gerrit.display(PageLinks.toChange(changeId));
+                }
+              });
     }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java
index 986bd78..147f2bc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java
@@ -26,27 +26,36 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 
 class RebaseAction {
-  static void call(final Button b, final String project, final String branch,
-      final Change.Id id, final String revision, final boolean enabled) {
+  static void call(
+      final Button b,
+      final String project,
+      final String branch,
+      final Change.Id id,
+      final String revision,
+      final boolean enabled) {
     b.setEnabled(false);
 
     new RebaseDialog(project, branch, id, enabled) {
       @Override
       public void onSend() {
-        ChangeApi.rebase(id.get(), revision, getBase(), new GerritCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo result) {
-            sent = true;
-            hide();
-            Gerrit.display(PageLinks.toChange(id));
-          }
+        ChangeApi.rebase(
+            id.get(),
+            revision,
+            getBase(),
+            new GerritCallback<ChangeInfo>() {
+              @Override
+              public void onSuccess(ChangeInfo result) {
+                sent = true;
+                hide();
+                Gerrit.display(PageLinks.toChange(id));
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-            enableButtons(true);
-            super.onFailure(caught);
-          }
-        });
+              @Override
+              public void onFailure(Throwable caught) {
+                enableButtons(true);
+                super.onFailure(caught);
+              }
+            });
       }
 
       @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
index 0d0dba7..d5d5f36 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChanges.java
@@ -37,14 +37,12 @@
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.TabBar;
 import com.google.gwt.user.client.ui.TabPanel;
-
 import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.List;
 
 public class RelatedChanges extends TabPanel {
-  static final RelatedChangesResources R = GWT
-      .create(RelatedChangesResources.class);
+  static final RelatedChangesResources R = GWT.create(RelatedChangesResources.class);
 
   interface RelatedChangesResources extends ClientBundle {
     @Source("related_changes.css")
@@ -53,21 +51,30 @@
 
   interface RelatedChangesCss extends CssResource {
     String activeRow();
+
     String current();
+
     String gitweb();
+
     String indirect();
+
     String notCurrent();
+
     String pointer();
+
     String row();
+
     String subject();
+
     String strikedSubject();
+
     String submittable();
+
     String tabPanel();
   }
 
   enum Tab {
-    RELATED_CHANGES(Resources.C.relatedChanges(),
-        Resources.C.relatedChangesTooltip()) {
+    RELATED_CHANGES(Resources.C.relatedChanges(), Resources.C.relatedChangesTooltip()) {
       @Override
       String getTitle(int count) {
         return Resources.M.relatedChanges(count);
@@ -79,8 +86,7 @@
       }
     },
 
-    SUBMITTED_TOGETHER(Resources.C.submittedTogether(),
-        Resources.C.submittedTogether()) {
+    SUBMITTED_TOGETHER(Resources.C.submittedTogether(), Resources.C.submittedTogether()) {
       @Override
       String getTitle(int count) {
         return Resources.M.submittedTogether(count);
@@ -92,8 +98,7 @@
       }
     },
 
-    SAME_TOPIC(Resources.C.sameTopic(),
-        Resources.C.sameTopicTooltip()) {
+    SAME_TOPIC(Resources.C.sameTopic(), Resources.C.sameTopicTooltip()) {
       @Override
       String getTitle(int count) {
         return Resources.M.sameTopic(count);
@@ -105,8 +110,7 @@
       }
     },
 
-    CONFLICTING_CHANGES(Resources.C.conflictingChanges(),
-        Resources.C.conflictingChangesTooltip()) {
+    CONFLICTING_CHANGES(Resources.C.conflictingChanges(), Resources.C.conflictingChangesTooltip()) {
       @Override
       String getTitle(int count) {
         return Resources.M.conflictingChanges(count);
@@ -118,8 +122,7 @@
       }
     },
 
-    CHERRY_PICKS(Resources.C.cherryPicks(),
-        Resources.C.cherryPicksTooltip()) {
+    CHERRY_PICKS(Resources.C.cherryPicks(), Resources.C.cherryPicksTooltip()) {
       @Override
       String getTitle(int count) {
         return Resources.M.cherryPicks(count);
@@ -135,6 +138,7 @@
     final String tooltip;
 
     abstract String getTitle(int count);
+
     abstract String getTitle(String count);
 
     Tab(String defaultTitle, String tooltip) {
@@ -161,16 +165,17 @@
 
   private void initTabBar() {
     TabBar tabBar = getTabBar();
-    tabBar.addSelectionHandler(new SelectionHandler<Integer>() {
-      @Override
-      public void onSelection(SelectionEvent<Integer> event) {
-        if (selectedTab >= 0) {
-          tabs.get(selectedTab).registerKeys(false);
-        }
-        selectedTab = event.getSelectedItem();
-        tabs.get(selectedTab).registerKeys(true);
-      }
-    });
+    tabBar.addSelectionHandler(
+        new SelectionHandler<Integer>() {
+          @Override
+          public void onSelection(SelectionEvent<Integer> event) {
+            if (selectedTab >= 0) {
+              tabs.get(selectedTab).registerKeys(false);
+            }
+            selectedTab = event.getSelectedItem();
+            tabs.get(selectedTab).registerKeys(true);
+          }
+        });
 
     for (Tab tabInfo : Tab.values()) {
       RelatedChangesTab panel = new RelatedChangesTab(tabInfo);
@@ -198,8 +203,10 @@
       setForOpenChange(info, revision);
     }
 
-    ChangeApi.revision(info.legacyId().get(), revision).view("related")
-        .get(new TabCallback<RelatedInfo>(Tab.RELATED_CHANGES, info.project(), revision) {
+    ChangeApi.revision(info.legacyId().get(), revision)
+        .view("related")
+        .get(
+            new TabCallback<RelatedInfo>(Tab.RELATED_CHANGES, info.project(), revision) {
               @Override
               public JsArray<ChangeAndCommit> convert(RelatedInfo result) {
                 return result.changes();
@@ -211,27 +218,30 @@
     cherryPicksQuery.append(" ").append(op("change", info.changeId()));
     cherryPicksQuery.append(" ").append(op("-change", info.legacyId().get()));
     cherryPicksQuery.append(" -is:abandoned");
-    ChangeList.query(cherryPicksQuery.toString(),
+    ChangeList.query(
+        cherryPicksQuery.toString(),
         EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT),
         new TabChangeListCallback(Tab.CHERRY_PICKS, info.project(), revision));
 
-    if (info.currentRevision() != null
-        && info.currentRevision().equals(revision)) {
-      ChangeApi.change(info.legacyId().get()).view("submitted_together")
-          .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER,
-              info.project(), revision));
+    if (info.currentRevision() != null && info.currentRevision().equals(revision)) {
+      ChangeApi.change(info.legacyId().get())
+          .view("submitted_together")
+          .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER, info.project(), revision));
     }
 
     if (!Gerrit.info().change().isSubmitWholeTopicEnabled()
-        && info.topic() != null && !"".equals(info.topic())) {
+        && info.topic() != null
+        && !"".equals(info.topic())) {
       StringBuilder topicQuery = new StringBuilder();
       topicQuery.append("status:open");
       topicQuery.append(" ").append(op("topic", info.topic()));
-      ChangeList.query(topicQuery.toString(),
-          EnumSet.of(ListChangesOption.CURRENT_REVISION,
-                     ListChangesOption.CURRENT_COMMIT,
-                     ListChangesOption.DETAILED_LABELS,
-                     ListChangesOption.LABELS),
+      ChangeList.query(
+          topicQuery.toString(),
+          EnumSet.of(
+              ListChangesOption.CURRENT_REVISION,
+              ListChangesOption.CURRENT_COMMIT,
+              ListChangesOption.DETAILED_LABELS,
+              ListChangesOption.LABELS),
           new TabChangeListCallback(Tab.SAME_TOPIC, info.project(), revision));
     }
   }
@@ -242,7 +252,8 @@
       conflictsQuery.append("status:open");
       conflictsQuery.append(" is:mergeable");
       conflictsQuery.append(" ").append(op("conflicts", info.legacyId().get()));
-      ChangeList.query(conflictsQuery.toString(),
+      ChangeList.query(
+          conflictsQuery.toString(),
           EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT),
           new TabChangeListCallback(Tab.CONFLICTING_CHANGES, info.project(), revision));
     }
@@ -323,7 +334,7 @@
       setTabEnabled(tabInfo, enabled);
       outstandingCallbacks--;
       if (outstandingCallbacks == 0 || (enabled && tabInfo == Tab.RELATED_CHANGES)) {
-        outstandingCallbacks = 0;  // Only execute this block once
+        outstandingCallbacks = 0; // Only execute this block once
         for (int i = 0; i < getTabBar().getTabCount(); i++) {
           if (getTabBar().isTabEnabled(i)) {
             selectTab(i);
@@ -369,8 +380,8 @@
 
   public static class RelatedInfo extends JavaScriptObject {
     public final native JsArray<ChangeAndCommit> changes() /*-{ return this.changes }-*/;
-    protected RelatedInfo() {
-    }
+
+    protected RelatedInfo() {}
   }
 
   public static class ChangeAndCommit extends JavaScriptObject {
@@ -379,27 +390,29 @@
     }
 
     public final native String id() /*-{ return this.change_id }-*/;
+
     public final native CommitInfo commit() /*-{ return this.commit }-*/;
+
     final native String branch() /*-{ return this.branch }-*/;
+
     final native String project() /*-{ return this.project }-*/;
+
     final native boolean submittable() /*-{ return this._submittable ? true : false; }-*/;
+
     final Change.Status status() {
       String s = statusRaw();
       return s != null ? Change.Status.valueOf(s) : null;
     }
+
     private native String statusRaw() /*-{ return this.status; }-*/;
 
-    final native void setId(String i)
-    /*-{ if(i)this.change_id=i; }-*/;
+    final native void setId(String i) /*-{ if(i)this.change_id=i; }-*/;
 
-    final native void setCommit(CommitInfo c)
-    /*-{ if(c)this.commit=c; }-*/;
+    final native void setCommit(CommitInfo c) /*-{ if(c)this.commit=c; }-*/;
 
-    final native void setBranch(String b)
-    /*-{ if(b)this.branch=b; }-*/;
+    final native void setBranch(String b) /*-{ if(b)this.branch=b; }-*/;
 
-    final native void setProject(String b)
-    /*-{ if(b)this.project=b; }-*/;
+    final native void setProject(String b) /*-{ if(b)this.project=b; }-*/;
 
     public final Change.Id legacyId() {
       return hasChangeNumber() ? new Change.Id(_changeNumber()) : null;
@@ -412,39 +425,30 @@
     }
 
     public final native boolean hasChangeNumber()
-    /*-{ return this.hasOwnProperty('_change_number') }-*/;
+        /*-{ return this.hasOwnProperty('_change_number') }-*/ ;
 
     final native boolean hasRevisionNumber()
-    /*-{ return this.hasOwnProperty('_revision_number') }-*/;
+        /*-{ return this.hasOwnProperty('_revision_number') }-*/ ;
 
     final native boolean hasCurrentRevisionNumber()
-    /*-{ return this.hasOwnProperty('_current_revision_number') }-*/;
+        /*-{ return this.hasOwnProperty('_current_revision_number') }-*/ ;
 
-    final native int _changeNumber()
-    /*-{ return this._change_number }-*/;
+    final native int _changeNumber() /*-{ return this._change_number }-*/;
 
-    final native int _revisionNumber()
-    /*-{ return this._revision_number }-*/;
+    final native int _revisionNumber() /*-{ return this._revision_number }-*/;
 
-    final native int _currentRevisionNumber()
-    /*-{ return this._current_revision_number }-*/;
+    final native int _currentRevisionNumber() /*-{ return this._current_revision_number }-*/;
 
-    final native void setChangeNumber(int n)
-    /*-{ this._change_number=n; }-*/;
+    final native void setChangeNumber(int n) /*-{ this._change_number=n; }-*/;
 
-    final native void setRevisionNumber(int n)
-    /*-{ this._revision_number=n; }-*/;
+    final native void setRevisionNumber(int n) /*-{ this._revision_number=n; }-*/;
 
-    final native void setCurrentRevisionNumber(int n)
-    /*-{ this._current_revision_number=n; }-*/;
+    final native void setCurrentRevisionNumber(int n) /*-{ this._current_revision_number=n; }-*/;
 
-    final native void setSubmittable(boolean s)
-    /*-{ this._submittable=s; }-*/;
+    final native void setSubmittable(boolean s) /*-{ this._submittable=s; }-*/;
 
-    final native void setStatus(String s)
-    /*-{ if(s)this.status=s; }-*/;
+    final native void setStatus(String s) /*-{ if(s)this.status=s; }-*/;
 
-    protected ChangeAndCommit() {
-    }
+    protected ChangeAndCommit() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
index 791effc..9ffbad8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RelatedChangesTab.java
@@ -54,7 +54,6 @@
 import com.google.gwtexpui.globalkey.client.KeyCommand;
 import com.google.gwtexpui.globalkey.client.KeyCommandSet;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -170,16 +169,15 @@
     private int connectedPos;
     private int selected;
 
-    private DisplayCommand(String revision, JsArray<ChangeAndCommit> changes,
-        NavigationList navList) {
+    private DisplayCommand(
+        String revision, JsArray<ChangeAndCommit> changes, NavigationList navList) {
       this.revision = revision;
       this.changes = changes;
       this.navList = navList;
       rows = new ArrayList<>(changes.length());
       connectedPos = changes.length() - 1;
-      connected = showIndirectAncestors
-          ? new HashSet<String>(Math.max(changes.length() * 4 / 3, 16))
-          : null;
+      connected =
+          showIndirectAncestors ? new HashSet<>(Math.max(changes.length() * 4 / 3, 16)) : null;
     }
 
     private boolean computeConnected() {
@@ -229,8 +227,7 @@
       while (row < changes.length()) {
         ChangeAndCommit info = changes.get(row);
         String commit = info.commit().commit();
-        rows.add(new RowSafeHtml(
-            info, connected != null && !connected.contains(commit)));
+        rows.add(new RowSafeHtml(info, connected != null && !connected.contains(commit)));
         if (revision.equals(commit)) {
           selected = row;
         }
@@ -315,7 +312,8 @@
         sb.setStyleName(RelatedChanges.R.css().indirect());
         sb.setAttribute("title", Resources.C.indirectAncestor());
         sb.append('~');
-      } else if (info.hasCurrentRevisionNumber() && info.hasRevisionNumber()
+      } else if (info.hasCurrentRevisionNumber()
+          && info.hasRevisionNumber()
           && info._currentRevisionNumber() != info._revisionNumber()) {
         sb.setStyleName(RelatedChanges.R.css().notCurrent());
         sb.setAttribute("title", Util.C.notCurrent());
@@ -373,12 +371,13 @@
               movePointerTo(selectedRow + 1, true);
             }
           });
-      keysNavigation.add(new KeyCommand(0, 'O', Resources.C.openChange()) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          onOpenRow(getRow(selectedRow));
-        }
-      });
+      keysNavigation.add(
+          new KeyCommand(0, 'O', Resources.C.openChange()) {
+            @Override
+            public void onKeyPress(KeyPressEvent event) {
+              onOpenRow(getRow(selectedRow));
+            }
+          });
 
       if (maxHeight > 0) {
         setHeight(maxHeight + "px");
@@ -400,8 +399,7 @@
         rowHeight = surrogate.getOffsetHeight();
         rowWidth = surrogate.getOffsetWidth();
         getContainerElement().removeChild(surrogate);
-        getContainerElement().getStyle()
-            .setHeight(rowHeight * rows.size(), Style.Unit.PX);
+        getContainerElement().getStyle().setHeight(rowHeight * rows.size(), Style.Unit.PX);
         return true;
       }
       return false;
@@ -414,8 +412,7 @@
 
         if (scroll && rowHeight != 0) {
           // Position the selected row in the middle.
-          setVerticalScrollPosition(
-              Math.max(rowHeight * selectedRow - maxHeight / 2, 0));
+          setVerticalScrollPosition(Math.max(rowHeight * selectedRow - maxHeight / 2, 0));
           render();
         }
         renderSelected(selectedRow, true);
@@ -541,8 +538,7 @@
     private void onOpenRow(Element row) {
       // Find the first HREF of the anchor of the select row (if any)
       if (row != null) {
-        NodeList<Element> nodes =
-            row.getElementsByTagName(AnchorElement.TAG);
+        NodeList<Element> nodes = row.getElementsByTagName(AnchorElement.TAG);
         for (int i = 0; i < nodes.getLength(); i++) {
           String url = nodes.getItem(i).getAttribute("href");
           if (!url.isEmpty()) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java
index 9783cf4..cc24fe6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java
@@ -31,8 +31,8 @@
   private RenameFileBox renameBox;
   private PopupPanel popup;
 
-  RenameFileAction(Change.Id changeId, RevisionInfo revision,
-      ChangeScreen.Style style, Widget renameButton) {
+  RenameFileAction(
+      Change.Id changeId, RevisionInfo revision, ChangeScreen.Style style, Widget renameButton) {
     this.changeId = changeId;
     this.revision = revision;
     this.style = style;
@@ -53,14 +53,15 @@
     final PopupPanel p = new PopupPanel(true);
     p.setStyleName(style.replyBox());
     p.addAutoHidePartner(renameButton.getElement());
-    p.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        if (popup == p) {
-          popup = null;
-        }
-      }
-    });
+    p.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            if (popup == p) {
+              popup = null;
+            }
+          }
+        });
     p.add(renameBox);
     p.showRelativeTo(renameButton);
     GlobalKey.dialog(p);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java
index d0d8b488..a36b8ef 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java
@@ -38,6 +38,7 @@
 
 class RenameFileBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, RenameFileBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private final Change.Id changeId;
@@ -47,18 +48,20 @@
 
   @UiField(provided = true)
   RemoteSuggestBox path;
+
   @UiField NpTextBox newPath;
 
   RenameFileBox(Change.Id changeId, RevisionInfo revision) {
     this.changeId = changeId;
 
     path = new RemoteSuggestBox(new PathSuggestOracle(changeId, revision));
-    path.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
-      @Override
-      public void onClose(CloseEvent<RemoteSuggestBox> event) {
-        hide();
-      }
-    });
+    path.addCloseHandler(
+        new CloseHandler<RemoteSuggestBox>() {
+          @Override
+          public void onClose(CloseEvent<RemoteSuggestBox> event) {
+            hide();
+          }
+        });
 
     initWidget(uiBinder.createAndBindUi(this));
   }
@@ -78,7 +81,10 @@
 
   private void rename(String path, String newPath) {
     hide();
-    ChangeEditApi.rename(changeId.get(), path, newPath,
+    ChangeEditApi.rename(
+        changeId.get(),
+        path,
+        newPath,
         new AsyncCallback<VoidResult>() {
           @Override
           public void onSuccess(VoidResult result) {
@@ -86,8 +92,7 @@
           }
 
           @Override
-          public void onFailure(Throwable caught) {
-          }
+          public void onFailure(Throwable caught) {}
         });
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java
index 7882eec..1c21cbf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java
@@ -52,9 +52,7 @@
       CommentLinkProcessor clp,
       Widget replyButton,
       Widget quickApproveButton) {
-    this.psId = new PatchSet.Id(
-        info.legacyId(),
-        info.revisions().get(revision)._number());
+    this.psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
     this.revision = revision;
     this.hasDraftComments = hasDraftComments;
     this.style = style;
@@ -64,9 +62,10 @@
 
     boolean current = revision.equals(info.currentRevision());
     allLabels = info.allLabels();
-    permittedLabels = current && info.hasPermittedLabels()
-        ? info.permittedLabels()
-        : NativeMap.<JsArrayString> create();
+    permittedLabels =
+        current && info.hasPermittedLabels()
+            ? info.permittedLabels()
+            : NativeMap.<JsArrayString>create();
   }
 
   boolean isVisible() {
@@ -91,12 +90,7 @@
     }
 
     if (replyBox == null) {
-      replyBox = new ReplyBox(
-          clp,
-          psId,
-          revision,
-          allLabels,
-          permittedLabels);
+      replyBox = new ReplyBox(clp, psId, revision, allLabels, permittedLabels);
       allLabels = null;
       permittedLabels = null;
     }
@@ -108,17 +102,18 @@
     p.setStyleName(style.replyBox());
     p.addAutoHidePartner(replyButton.getElement());
     p.addAutoHidePartner(quickApproveButton.getElement());
-    p.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        if (popup == p) {
-          popup = null;
-          if (hasDraftComments || replyBox.hasMessage()) {
-            replyButton.setStyleName(style.highlight());
+    p.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            if (popup == p) {
+              popup = null;
+              if (hasDraftComments || replyBox.hasMessage()) {
+                replyButton.setStyleName(style.highlight());
+              }
+            }
           }
-        }
-      }
-    });
+        });
     p.add(replyBox);
     Window.scrollTo(0, 0);
     replyButton.removeStyleName(style.highlight());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
index e29048a..2a926b6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyBox.java
@@ -72,7 +72,6 @@
 import com.google.gwt.user.client.ui.TextArea;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -81,11 +80,14 @@
 
 public class ReplyBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, ReplyBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface Styles extends CssResource {
     String label_name();
+
     String label_value();
+
     String label_help();
   }
 
@@ -126,29 +128,28 @@
     }
 
     addDomHandler(
-      new KeyDownHandler() {
-        @Override
-        public void onKeyDown(KeyDownEvent e) {
-          e.stopPropagation();
-          if ((e.getNativeKeyCode() == KEY_ENTER
-              || e.getNativeKeyCode() == KEY_MAC_ENTER)
-              && (e.isControlKeyDown() || e.isMetaKeyDown())) {
-            e.preventDefault();
-            if (post.isEnabled()) {
-              onPost(null);
+        new KeyDownHandler() {
+          @Override
+          public void onKeyDown(KeyDownEvent e) {
+            e.stopPropagation();
+            if ((e.getNativeKeyCode() == KEY_ENTER || e.getNativeKeyCode() == KEY_MAC_ENTER)
+                && (e.isControlKeyDown() || e.isMetaKeyDown())) {
+              e.preventDefault();
+              if (post.isEnabled()) {
+                onPost(null);
+              }
             }
           }
-        }
-      },
-      KeyDownEvent.getType());
+        },
+        KeyDownEvent.getType());
     addDomHandler(
-      new KeyPressHandler() {
-        @Override
-        public void onKeyPress(KeyPressEvent e) {
-          e.stopPropagation();
-        }
-      },
-      KeyPressEvent.getType());
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent e) {
+            e.stopPropagation();
+          }
+        },
+        KeyPressEvent.getType());
   }
 
   @Override
@@ -160,35 +161,41 @@
       lc.removeReplyComment();
     }
     ChangeApi.drafts(psId.getParentKey().get())
-        .get(new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
-          @Override
-          public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
-            displayComments(result);
-            post.setEnabled(true);
-          }
+        .get(
+            new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
+              @Override
+              public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
+                displayComments(result);
+                post.setEnabled(true);
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-            post.setEnabled(true);
-          }
-        });
+              @Override
+              public void onFailure(Throwable caught) {
+                post.setEnabled(true);
+              }
+            });
 
-    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-      @Override
-      public void execute() {
-        message.setFocus(true);
-      }
-    });
-    Scheduler.get().scheduleFixedDelay(new RepeatingCommand() {
-      @Override
-      public boolean execute() {
-        String t = message.getText();
-        if (t != null) {
-          message.setCursorPos(t.length());
-        }
-        return false;
-      }
-    }, 0);
+    Scheduler.get()
+        .scheduleDeferred(
+            new ScheduledCommand() {
+              @Override
+              public void execute() {
+                message.setFocus(true);
+              }
+            });
+    Scheduler.get()
+        .scheduleFixedDelay(
+            new RepeatingCommand() {
+              @Override
+              public boolean execute() {
+                String t = message.getText();
+                if (t != null) {
+                  message.setCursorPos(t.length());
+                }
+                return false;
+              }
+            },
+            0);
   }
 
   @UiHandler("post")
@@ -212,20 +219,23 @@
     in.drafts(DraftHandling.PUBLISH_ALL_REVISIONS);
     in.prePost();
     ChangeApi.revision(psId.getParentKey().get(), revision)
-      .view("review")
-      .post(in, new GerritCallback<ReviewInput>() {
-        @Override
-        public void onSuccess(ReviewInput result) {
-          Gerrit.display(PageLinks.toChange(psId));
-        }
-        @Override
-        public void onFailure(final Throwable caught) {
-          if (RestApi.isNotSignedIn(caught)) {
-            lc.setReplyComment(message.getText());
-          }
-          super.onFailure(caught);
-        }
-      });
+        .view("review")
+        .post(
+            in,
+            new GerritCallback<ReviewInput>() {
+              @Override
+              public void onSuccess(ReviewInput result) {
+                Gerrit.display(PageLinks.toChange(psId));
+              }
+
+              @Override
+              public void onFailure(final Throwable caught) {
+                if (RestApi.isNotSignedIn(caught)) {
+                  lc.setReplyComment(message.getText());
+                }
+                super.onFailure(caught);
+              }
+            });
     hide();
   }
 
@@ -296,14 +306,15 @@
   }
 
   private void renderLabels(
-      List<String> names,
-      NativeMap<LabelInfo> all,
-      NativeMap<JsArrayString> permitted) {
+      List<String> names, NativeMap<LabelInfo> all, NativeMap<JsArrayString> permitted) {
     TreeSet<Short> values = new TreeSet<>();
     List<LabelAndValues> labels = new ArrayList<>(permitted.size());
     for (String id : names) {
       JsArrayString p = permitted.get(id);
       if (p != null) {
+        if (!all.containsKey(id)) {
+          continue;
+        }
         Set<Short> a = new TreeSet<>();
         for (int i = 0; i < p.length(); i++) {
           a.add(LabelInfo.parseValue(p.get(i)));
@@ -346,9 +357,7 @@
     return dv;
   }
 
-  private void renderRadio(int row,
-      List<Short> columns,
-      LabelAndValues lv) {
+  private void renderRadio(int row, List<Short> columns, LabelAndValues lv) {
     String id = lv.info.name();
     Short dv = normalizeDefaultValue(lv.info.defaultValue(), lv.permitted);
 
@@ -359,12 +368,10 @@
     fmt.setStyleName(row, 0, style.label_name());
     fmt.setStyleName(row, labelHelpColumn, style.label_help());
 
-    ApprovalInfo self = Gerrit.isSignedIn()
-        ? lv.info.forUser(Gerrit.getUserAccount().getId().get())
-        : null;
+    ApprovalInfo self =
+        Gerrit.isSignedIn() ? lv.info.forUser(Gerrit.getUserAccount().getId().get()) : null;
 
-    final LabelRadioGroup group =
-        new LabelRadioGroup(row, id, lv.permitted.size());
+    final LabelRadioGroup group = new LabelRadioGroup(row, id, lv.permitted.size());
     for (int i = 0; i < columns.size(); i++) {
       Short v = columns.get(i);
       if (lv.permitted.contains(v)) {
@@ -383,9 +390,8 @@
   }
 
   private void renderCheckBox(int row, LabelAndValues lv) {
-    ApprovalInfo self = Gerrit.isSignedIn()
-        ? lv.info.forUser(Gerrit.getUserAccount().getId().get())
-        : null;
+    ApprovalInfo self =
+        Gerrit.isSignedIn() ? lv.info.forUser(Gerrit.getUserAccount().getId().get()) : null;
 
     final String id = lv.info.name();
     final CheckBox b = new CheckBox();
@@ -394,12 +400,13 @@
     if (self != null && self.value() == 1) {
       b.setValue(true);
     }
-    b.addValueChangeHandler(new ValueChangeHandler<Boolean>() {
-      @Override
-      public void onValueChange(ValueChangeEvent<Boolean> event) {
-        in.label(id, event.getValue() ? (short) 1 : (short) 0);
-      }
-    });
+    b.addValueChangeHandler(
+        new ValueChangeHandler<Boolean>() {
+          @Override
+          public void onValueChange(ValueChangeEvent<Boolean> event) {
+            in.label(id, event.getValue() ? (short) 1 : (short) 0);
+          }
+        });
     b.setStyleName(style.label_name());
     labelsTable.setWidget(row, 0, b);
 
@@ -409,9 +416,7 @@
   }
 
   private static boolean isCheckBox(Set<Short> values) {
-    return values.size() == 2
-        && values.contains((short) 0)
-        && values.contains((short) 1);
+    return values.size() == 2 && values.contains((short) 0) && values.contains((short) 1);
   }
 
   private void displayComments(NativeMap<JsArray<CommentInfo>> m) {
@@ -419,17 +424,21 @@
 
     JsArray<CommentInfo> l = m.get(Patch.COMMIT_MSG);
     if (l != null) {
-      comments.add(new FileComments(clp, psId,
-          Util.C.commitMessage(), copyPath(Patch.COMMIT_MSG, l)));
+      comments.add(
+          new FileComments(clp, psId, Util.C.commitMessage(), copyPath(Patch.COMMIT_MSG, l)));
+    }
+    l = m.get(Patch.MERGE_LIST);
+    if (l != null) {
+      comments.add(
+          new FileComments(clp, psId, Util.C.commitMessage(), copyPath(Patch.MERGE_LIST, l)));
     }
 
     List<String> paths = new ArrayList<>(m.keySet());
     Collections.sort(paths);
 
     for (String path : paths) {
-      if (!path.equals(Patch.COMMIT_MSG)) {
-        comments.add(new FileComments(clp, psId,
-            path, copyPath(path, m.get(path))));
+      if (!Patch.isMagic(path)) {
+        comments.add(new FileComments(clp, psId, path, copyPath(path, m.get(path))));
       }
     }
 
@@ -471,9 +480,8 @@
     }
   }
 
-  private class LabelRadioButton extends RadioButton implements
-      ValueChangeHandler<Boolean>, ClickHandler, MouseOverHandler,
-      MouseOutHandler {
+  private class LabelRadioButton extends RadioButton
+      implements ValueChangeHandler<Boolean>, ClickHandler, MouseOverHandler, MouseOutHandler {
     private final LabelRadioGroup group;
     private final String text;
     private final short value;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
index fdfaf61..cfc4e23 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Resources.java
@@ -23,12 +23,16 @@
   ChangeConstants C = GWT.create(ChangeConstants.class);
   ChangeMessages M = GWT.create(ChangeMessages.class);
 
-  @Source("common.css") Style style();
+  @Source("common.css")
+  Style style();
 
   public interface Style extends CssResource {
     String button();
+
     String popup();
+
     String popupContent();
+
     String section();
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java
index 253c9a7..ebc3d68 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java
@@ -32,12 +32,15 @@
 
   @Override
   void send(String message) {
-    ChangeApi.restore(id.get(), message, new GerritCallback<ChangeInfo>() {
-      @Override
-      public void onSuccess(ChangeInfo result) {
-        Gerrit.display(PageLinks.toChange(id));
-        hide();
-      }
-    });
+    ChangeApi.restore(
+        id.get(),
+        message,
+        new GerritCallback<ChangeInfo>() {
+          @Override
+          public void onSuccess(ChangeInfo result) {
+            Gerrit.display(PageLinks.toChange(id));
+            hide();
+          }
+        });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java
index ae1b5d1..f216af8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java
@@ -27,23 +27,22 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 
 class RevertAction {
-  static void call(final Button b, final Change.Id id, final String revision,
-      final String commitSubject) {
+  static void call(
+      final Button b, final Change.Id id, final String revision, final String commitSubject) {
     // TODO Replace ActionDialog with a nicer looking display.
     b.setEnabled(false);
-    new TextAreaActionDialog(
-        Util.C.revertChangeTitle(),
-        Util.C.headingRevertMessage()) {
+    new TextAreaActionDialog(Util.C.revertChangeTitle(), Util.C.headingRevertMessage()) {
       {
         sendButton.setText(Util.C.buttonRevertChangeSend());
-        message.setText(Util.M.revertChangeDefaultMessage(
-            commitSubject, revision));
+        message.setText(Util.M.revertChangeDefaultMessage(commitSubject, revision));
       }
 
       @Override
       public void onSend() {
-        ChangeApi.revert(id.get(),
-            getMessageText(), new GerritCallback<ChangeInfo>() {
+        ChangeApi.revert(
+            id.get(),
+            getMessageText(),
+            new GerritCallback<ChangeInfo>() {
               @Override
               public void onSuccess(ChangeInfo result) {
                 sent = true;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
index a852fa0..8609774 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
@@ -14,64 +14,69 @@
 
 package com.google.gerrit.client.change;
 
-import com.google.gerrit.client.admin.Util;
+import com.google.gerrit.client.admin.AdminConstants;
 import com.google.gerrit.client.changes.ChangeApi;
-import com.google.gerrit.client.groups.GroupBaseInfo;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupBaseInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountSuggestOracle;
-import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
-
+import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
 /** REST API based suggestion Oracle for reviewers. */
-public class ReviewerSuggestOracle extends SuggestAfterTypingNCharsOracle {
+public class ReviewerSuggestOracle extends HighlightSuggestOracle {
   private Change.Id changeId;
 
   @Override
-  protected void _onRequestSuggestions(final Request req, final Callback cb) {
-    ChangeApi.suggestReviewers(changeId.get(), req.getQuery(), req.getLimit())
-        .get(new GerritCallback<JsArray<SuggestReviewerInfo>>() {
-          @Override
-          public void onSuccess(JsArray<SuggestReviewerInfo> result) {
-            List<RestReviewerSuggestion> r = new ArrayList<>(result.length());
-            for (SuggestReviewerInfo reviewer : Natives.asList(result)) {
-              r.add(new RestReviewerSuggestion(reviewer, req.getQuery()));
-            }
-            cb.onSuggestionsReady(req, new Response(r));
-          }
+  protected void onRequestSuggestions(final Request req, final Callback cb) {
+    ChangeApi.suggestReviewers(changeId.get(), req.getQuery(), req.getLimit(), false)
+        .get(
+            new GerritCallback<JsArray<SuggestReviewerInfo>>() {
+              @Override
+              public void onSuccess(JsArray<SuggestReviewerInfo> result) {
+                List<RestReviewerSuggestion> r = new ArrayList<>(result.length());
+                for (SuggestReviewerInfo reviewer : Natives.asList(result)) {
+                  r.add(new RestReviewerSuggestion(reviewer, req.getQuery()));
+                }
+                cb.onSuggestionsReady(req, new Response(r));
+              }
 
-          @Override
-          public void onFailure(Throwable err) {
-            List<Suggestion> r = Collections.emptyList();
-            cb.onSuggestionsReady(req, new Response(r));
-          }
-        });
+              @Override
+              public void onFailure(Throwable err) {
+                List<Suggestion> r = Collections.emptyList();
+                cb.onSuggestionsReady(req, new Response(r));
+              }
+            });
+  }
+
+  @Override
+  public void requestDefaultSuggestions(final Request req, final Callback cb) {
+    requestSuggestions(req, cb);
   }
 
   public void setChange(Change.Id changeId) {
     this.changeId = changeId;
   }
 
-  private static class RestReviewerSuggestion implements Suggestion {
+  public static class RestReviewerSuggestion implements Suggestion {
     private final String displayString;
     private final String replacementString;
 
     RestReviewerSuggestion(SuggestReviewerInfo reviewer, String query) {
       if (reviewer.account() != null) {
-        this.replacementString = AccountSuggestOracle.AccountSuggestion
-            .format(reviewer.account(), query);
+        this.replacementString =
+            AccountSuggestOracle.AccountSuggestion.format(reviewer.account(), query);
         this.displayString = replacementString;
       } else {
         this.replacementString = reviewer.group().name();
         this.displayString =
-            replacementString + " (" + Util.C.suggestedGroupLabel() + ")";
+            replacementString + " (" + AdminConstants.I.suggestedGroupLabel() + ")";
       }
     }
 
@@ -88,8 +93,9 @@
 
   public static class SuggestReviewerInfo extends JavaScriptObject {
     public final native AccountInfo account() /*-{ return this.account; }-*/;
+
     public final native GroupBaseInfo group() /*-{ return this.group; }-*/;
-    protected SuggestReviewerInfo() {
-    }
+
+    protected SuggestReviewerInfo() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
index b69d1c0..cd880a3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
@@ -52,7 +52,6 @@
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -62,6 +61,7 @@
 /** Add reviewers. */
 public class Reviewers extends Composite {
   interface Binder extends UiBinder<HTMLPanel, Reviewers> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   @UiField Element reviewersText;
@@ -69,6 +69,7 @@
   @UiField Button addMe;
   @UiField Element form;
   @UiField Element error;
+
   @UiField(provided = true)
   RemoteSuggestBox suggestBox;
 
@@ -81,20 +82,23 @@
   Reviewers() {
     reviewerSuggestOracle = new ReviewerSuggestOracle();
     suggestBox = new RemoteSuggestBox(reviewerSuggestOracle);
+    suggestBox.enableDefaultSuggestions();
     suggestBox.setVisibleLength(55);
     suggestBox.setHintText(Util.C.approvalTableAddReviewerHint());
-    suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
-      @Override
-      public void onClose(CloseEvent<RemoteSuggestBox> event) {
-        Reviewers.this.onCancel(null);
-      }
-    });
-    suggestBox.addSelectionHandler(new SelectionHandler<String>() {
-      @Override
-      public void onSelection(SelectionEvent<String> event) {
-        addReviewer(event.getSelectedItem(), false);
-      }
-    });
+    suggestBox.addCloseHandler(
+        new CloseHandler<RemoteSuggestBox>() {
+          @Override
+          public void onClose(CloseEvent<RemoteSuggestBox> event) {
+            Reviewers.this.onCancel(null);
+          }
+        });
+    suggestBox.addSelectionHandler(
+        new SelectionHandler<String>() {
+          @Override
+          public void onSelection(SelectionEvent<String> event) {
+            addReviewer(event.getSelectedItem(), false);
+          }
+        });
 
     initWidget(uiBinder.createAndBindUi(this));
     addReviewerIcon.addDomHandler(
@@ -123,6 +127,7 @@
     UIObject.setVisible(form, true);
     UIObject.setVisible(error, false);
     addReviewerIcon.setVisible(false);
+    suggestBox.setServeSuggestionsOnOracle(true);
     suggestBox.setFocus(true);
   }
 
@@ -143,6 +148,7 @@
     UIObject.setVisible(form, false);
     suggestBox.setFocus(false);
     suggestBox.setText("");
+    suggestBox.setServeSuggestionsOnOracle(false);
   }
 
   private void addReviewer(final String reviewer, boolean confirmed) {
@@ -150,56 +156,59 @@
       return;
     }
 
-    ChangeApi.reviewers(changeId.get()).post(
-        PostInput.create(reviewer, confirmed),
-        new GerritCallback<PostResult>() {
-          @Override
-          public void onSuccess(PostResult result) {
-            if (result.confirm()) {
-              askForConfirmation(result.error());
-            } else if (result.error() != null) {
-              UIObject.setVisible(error, true);
-              error.setInnerText(result.error());
-            } else {
-              UIObject.setVisible(error, false);
-              error.setInnerText("");
-              suggestBox.setText("");
+    ChangeApi.reviewers(changeId.get())
+        .post(
+            PostInput.create(reviewer, confirmed),
+            new GerritCallback<PostResult>() {
+              @Override
+              public void onSuccess(PostResult result) {
+                if (result.confirm()) {
+                  askForConfirmation(result.error());
+                } else if (result.error() != null) {
+                  UIObject.setVisible(error, true);
+                  error.setInnerText(result.error());
+                } else {
+                  UIObject.setVisible(error, false);
+                  error.setInnerText("");
+                  suggestBox.setText("");
 
-              if (result.reviewers() != null
-                  && result.reviewers().length() > 0) {
-                updateReviewerList();
-              }
-            }
-          }
-
-          private void askForConfirmation(String text) {
-            new ConfirmationDialog(
-                Util.C.approvalTableAddManyReviewersConfirmationDialogTitle(),
-                new SafeHtmlBuilder().append(text),
-                new ConfirmationCallback() {
-                  @Override
-                  public void onOk() {
-                    addReviewer(reviewer, true);
+                  if (result.reviewers() != null && result.reviewers().length() > 0) {
+                    updateReviewerList();
                   }
-                }).center();
-          }
+                }
+              }
 
-          @Override
-          public void onFailure(Throwable err) {
-            if (isSigninFailure(err)) {
-              new NotSignedInDialog().center();
-            } else {
-              UIObject.setVisible(error, true);
-              error.setInnerText(err instanceof StatusCodeException
-                  ? ((StatusCodeException) err).getEncodedResponse()
-                  : err.getMessage());
-            }
-          }
-        });
+              private void askForConfirmation(String text) {
+                new ConfirmationDialog(
+                        Util.C.approvalTableAddManyReviewersConfirmationDialogTitle(),
+                        new SafeHtmlBuilder().append(text),
+                        new ConfirmationCallback() {
+                          @Override
+                          public void onOk() {
+                            addReviewer(reviewer, true);
+                          }
+                        })
+                    .center();
+              }
+
+              @Override
+              public void onFailure(Throwable err) {
+                if (isSigninFailure(err)) {
+                  new NotSignedInDialog().center();
+                } else {
+                  UIObject.setVisible(error, true);
+                  error.setInnerText(
+                      err instanceof StatusCodeException
+                          ? ((StatusCodeException) err).getEncodedResponse()
+                          : err.getMessage());
+                }
+              }
+            });
   }
 
-  private void updateReviewerList() {
-    ChangeApi.detail(changeId.get(),
+  void updateReviewerList() {
+    ChangeApi.detail(
+        changeId.get(),
         new GerritCallback<ChangeInfo>() {
           @Override
           public void onSuccess(ChangeInfo result) {
@@ -219,18 +228,17 @@
     Set<Integer> removable = info.removableReviewerIds();
     Map<Integer, VotableInfo> votable = votable(info);
 
-    SafeHtml rHtml = Labels.formatUserList(style,
-        r.values(), removable, null, votable);
-    SafeHtml ccHtml = Labels.formatUserList(style,
-        cc.values(), removable, null, votable);
+    SafeHtml rHtml = Labels.formatUserList(style, r.values(), removable, null, votable);
+    SafeHtml ccHtml = Labels.formatUserList(style, cc.values(), removable, null, votable);
 
     reviewersText.setInnerSafeHtml(rHtml);
     ccText.setInnerSafeHtml(ccHtml);
     if (Gerrit.isSignedIn()) {
       int currentUser = Gerrit.getUserAccount()._accountId();
-      boolean showAddMeButton = info.owner()._accountId() != currentUser
-          && !cc.containsKey(currentUser)
-          && !r.containsKey(currentUser);
+      boolean showAddMeButton =
+          info.owner()._accountId() != currentUser
+              && !cc.containsKey(currentUser)
+              && !r.containsKey(currentUser);
       addMe.setVisible(showAddMeButton);
     }
   }
@@ -252,6 +260,8 @@
     Map<Integer, VotableInfo> d = new HashMap<>();
     for (String name : change.labels()) {
       LabelInfo label = change.label(name);
+      Short labelMaxValue =
+          label.valueSet().isEmpty() ? null : LabelInfo.parseValue(label.maxValue());
       if (label.all() != null) {
         for (ApprovalInfo ai : Natives.asList(label.all())) {
           int id = ai._accountId();
@@ -260,7 +270,11 @@
             ad = new VotableInfo();
             d.put(id, ad);
           }
-          if (ai.hasValue()) {
+          if (labelMaxValue != null
+              && ai.permittedVotingRange() != null
+              && ai.permittedVotingRange().max() == labelMaxValue) {
+            ad.votable(name + " (" + label.maxValue() + ") ");
+          } else if (ai.hasValue()) {
             ad.votable(name);
           }
         }
@@ -269,7 +283,6 @@
     return d;
   }
 
-
   public static class PostInput extends JavaScriptObject {
     public static PostInput create(String reviewer, boolean confirmed) {
       PostInput input = createObject().cast();
@@ -284,27 +297,28 @@
       }
     }-*/;
 
-    protected PostInput() {
-    }
+    protected PostInput() {}
   }
 
   public static class ReviewerInfo extends AccountInfo {
     final Set<String> approvals() {
       return Natives.keys(_approvals());
     }
+
     final native String approval(String l) /*-{ return this.approvals[l]; }-*/;
+
     private native NativeMap<NativeString> _approvals() /*-{ return this.approvals; }-*/;
 
-    protected ReviewerInfo() {
-    }
+    protected ReviewerInfo() {}
   }
 
   public static class PostResult extends JavaScriptObject {
     public final native JsArray<ReviewerInfo> reviewers() /*-{ return this.reviewers; }-*/;
+
     public final native boolean confirm() /*-{ return this.confirm || false; }-*/;
+
     public final native String error() /*-{ return this.error; }-*/;
 
-    protected PostResult() {
-    }
+    protected PostResult() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RightSidePopdownAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RightSidePopdownAction.java
index a3b16f9..1383c5d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RightSidePopdownAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RightSidePopdownAction.java
@@ -30,10 +30,7 @@
   private final UIObject relativeTo;
   private PopupPanel popup;
 
-  RightSidePopdownAction(
-      ChangeScreen.Style style,
-      UIObject relativeTo,
-      Widget button) {
+  RightSidePopdownAction(ChangeScreen.Style style, UIObject relativeTo, Widget button) {
     this.style = style;
     this.relativeTo = relativeTo;
     this.button = button;
@@ -48,31 +45,33 @@
       return;
     }
 
-    final PopupPanel p = new PopupPanel(true) {
-      @Override
-      public void setPopupPosition(int left, int top) {
-        top -= Document.get().getBodyOffsetTop();
+    final PopupPanel p =
+        new PopupPanel(true) {
+          @Override
+          public void setPopupPosition(int left, int top) {
+            top -= Document.get().getBodyOffsetTop();
 
-        int w = Window.getScrollLeft() + Window.getClientWidth();
-        int r = relativeTo.getAbsoluteLeft() + relativeTo.getOffsetWidth();
-        int right = w - r;
-        Style style = getElement().getStyle();
-        style.clearProperty("left");
-        style.setPropertyPx("right", right);
-        style.setPropertyPx("top", top);
-      }
-    };
+            int w = Window.getScrollLeft() + Window.getClientWidth();
+            int r = relativeTo.getAbsoluteLeft() + relativeTo.getOffsetWidth();
+            int right = w - r;
+            Style style = getElement().getStyle();
+            style.clearProperty("left");
+            style.setPropertyPx("right", right);
+            style.setPropertyPx("top", top);
+          }
+        };
     p.setStyleName(style.replyBox());
     p.addAutoHidePartner(button.getElement());
-    p.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        if (popup == p) {
-          button.removeStyleName(style.selected());
-          popup = null;
-        }
-      }
-    });
+    p.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            if (popup == p) {
+              button.removeStyleName(style.selected());
+              popup = null;
+            }
+          }
+        });
     p.add(getWidget());
     p.showRelativeTo(relativeTo);
     GlobalKey.dialog(p);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/StarIcon.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/StarIcon.java
index 0f19406..b7bf8de 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/StarIcon.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/StarIcon.java
@@ -20,8 +20,6 @@
 
 class StarIcon extends ToggleButton {
   StarIcon() {
-    super(
-      new Image(Gerrit.RESOURCES.starOpen()),
-      new Image(Gerrit.RESOURCES.starFilled()));
+    super(new Image(Gerrit.RESOURCES.starOpen()), new Image(Gerrit.RESOURCES.starFilled()));
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java
index ad1043a..69a7ca5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java
@@ -29,27 +29,28 @@
     if (ChangeGlue.onSubmitChange(changeInfo, revisionInfo)) {
       final Change.Id changeId = changeInfo.legacyId();
       ChangeApi.submit(
-        changeId.get(), revisionInfo.name(),
-        new GerritCallback<SubmitInfo>() {
-          @Override
-          public void onSuccess(SubmitInfo result) {
-            redisplay();
-          }
-
-          @Override
-          public void onFailure(Throwable err) {
-            if (SubmitFailureDialog.isConflict(err)) {
-              new SubmitFailureDialog(err.getMessage()).center();
-            } else {
-              super.onFailure(err);
+          changeId.get(),
+          revisionInfo.name(),
+          new GerritCallback<SubmitInfo>() {
+            @Override
+            public void onSuccess(SubmitInfo result) {
+              redisplay();
             }
-            redisplay();
-          }
 
-          private void redisplay() {
-            Gerrit.display(PageLinks.toChange(changeId));
-          }
-        });
+            @Override
+            public void onFailure(Throwable err) {
+              if (SubmitFailureDialog.isConflict(err)) {
+                new SubmitFailureDialog(err.getMessage()).center();
+              } else {
+                super.onFailure(err);
+              }
+              redisplay();
+            }
+
+            private void redisplay() {
+              Gerrit.display(PageLinks.toChange(changeId));
+            }
+          });
     }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitFailureDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitFailureDialog.java
index c9ac2cb..77bf217 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitFailureDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitFailureDialog.java
@@ -21,8 +21,7 @@
 
 class SubmitFailureDialog extends ErrorDialog {
   static boolean isConflict(Throwable err) {
-    return err instanceof RemoteJsonException
-        && 409 == ((RemoteJsonException) err).getCode();
+    return err instanceof RemoteJsonException && 409 == ((RemoteJsonException) err).getCode();
   }
 
   SubmitFailureDialog(String msg) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
index 025668f..f08414a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Topic.java
@@ -41,6 +41,7 @@
 /** Displays (and edits) the change topic string. */
 class Topic extends Composite {
   interface Binder extends UiBinder<HTMLPanel, Topic> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private PatchSet.Id psId;
@@ -58,21 +59,19 @@
   Topic() {
     initWidget(uiBinder.createAndBindUi(this));
     editIcon.addDomHandler(
-      new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent event) {
-          onEdit();
-        }
-      },
-      ClickEvent.getType());
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            onEdit();
+          }
+        },
+        ClickEvent.getType());
   }
 
   void set(ChangeInfo info, String revision) {
     canEdit = info.hasActions() && info.actions().containsKey("topic");
 
-    psId = new PatchSet.Id(
-        info.legacyId(),
-        info.revisions().get(revision)._number());
+    psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
 
     initTopicLink(info);
     editIcon.setVisible(canEdit);
@@ -100,6 +99,7 @@
 
       input.setText(text.getText());
       input.setFocus(true);
+      input.selectAll();
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.java
index c14e628..520dc69 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateAvailableBar.java
@@ -24,7 +24,6 @@
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.HTMLPanel;
-
 import java.sql.Timestamp;
 import java.util.HashSet;
 import java.util.List;
@@ -32,6 +31,7 @@
 /** Displays the "New Message From ..." panel in bottom right on updates. */
 abstract class UpdateAvailableBar extends Composite {
   interface Binder extends UiBinder<HTMLPanel, UpdateAvailableBar> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private Timestamp updated;
@@ -72,5 +72,6 @@
   }
 
   abstract void onShow();
+
   abstract void onIgnore(Timestamp newTime);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateCheckTimer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateCheckTimer.java
index b1c9761..4a5af0551 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateCheckTimer.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/UpdateCheckTimer.java
@@ -25,8 +25,7 @@
 class UpdateCheckTimer extends Timer implements ValueChangeHandler<Boolean> {
   private static final int MAX_PERIOD = 3 * 60 * 1000;
   private static final int IDLE_PERIOD = 2 * 3600 * 1000;
-  private static final int POLL_PERIOD =
-      Gerrit.info().change().updateDelay() * 1000;
+  private static final int POLL_PERIOD = Gerrit.info().change().updateDelay() * 1000;
 
   private final ChangeScreen screen;
   private int delay;
@@ -52,34 +51,34 @@
     }
 
     running = true;
-    screen.loadChangeInfo(false, new AsyncCallback<ChangeInfo>() {
-      @Override
-      public void onSuccess(ChangeInfo info) {
-        running = false;
-        screen.showUpdates(info);
+    screen.loadChangeInfo(
+        false,
+        new AsyncCallback<ChangeInfo>() {
+          @Override
+          public void onSuccess(ChangeInfo info) {
+            running = false;
+            screen.showUpdates(info);
 
-        int d = UserActivityMonitor.isActive()
-            ? POLL_PERIOD
-            : IDLE_PERIOD;
-        if (d != delay) {
-          delay = d;
-          schedule();
-        }
-      }
+            int d = UserActivityMonitor.isActive() ? POLL_PERIOD : IDLE_PERIOD;
+            if (d != delay) {
+              delay = d;
+              schedule();
+            }
+          }
 
-      @Override
-      public void onFailure(Throwable caught) {
-        // On failures increase the delay time and try again,
-        // but place an upper bound on the delay.
-        running = false;
-        delay = (int) Math.max(
-            delay * (1.5 + Math.random()),
-            UserActivityMonitor.isActive()
-              ? MAX_PERIOD
-              : IDLE_PERIOD + MAX_PERIOD);
-        schedule();
-      }
-    });
+          @Override
+          public void onFailure(Throwable caught) {
+            // On failures increase the delay time and try again,
+            // but place an upper bound on the delay.
+            running = false;
+            delay =
+                (int)
+                    Math.max(
+                        delay * (1.5 + Math.random()),
+                        UserActivityMonitor.isActive() ? MAX_PERIOD : IDLE_PERIOD + MAX_PERIOD);
+            schedule();
+          }
+        });
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/VotableInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/VotableInfo.java
index 056c9a2..33d8d12 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/VotableInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/VotableInfo.java
@@ -35,4 +35,3 @@
     return s;
   }
 }
-
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css
index bde9755..6f514df 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/file_table.css
@@ -109,3 +109,7 @@
   white-space: nowrap;
 }
 
+.error {
+  color: #D33D3D;
+  font-weight: bold;
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
index 62c14cb..f37cbc2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
@@ -27,7 +27,6 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.KeyPressEvent;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
-
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.EnumSet;
@@ -37,6 +36,7 @@
   // If changing default options, also update in
   // ChangeIT#defaultSearchDoesNotTouchDatabase().
   private static final Set<ListChangesOption> MY_DASHBOARD_OPTIONS;
+
   static {
     EnumSet<ListChangesOption> options = EnumSet.copyOf(ChangeTable.OPTIONS);
     options.add(ListChangesOption.REVIEWED);
@@ -58,16 +58,18 @@
   @Override
   protected void onInitUI() {
     super.onInitUI();
-    table = new ChangeTable() {
-      {
-        keysNavigation.add(new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
-          @Override
-          public void onKeyPress(final KeyPressEvent event) {
-            Gerrit.display(getToken());
+    table =
+        new ChangeTable() {
+          {
+            keysNavigation.add(
+                new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
+                  @Override
+                  public void onKeyPress(final KeyPressEvent event) {
+                    Gerrit.display(getToken());
+                  }
+                });
           }
-        });
-      }
-    };
+        };
     table.addStyleName(Gerrit.RESOURCES.css().accountDashboard());
 
     outgoing = new ChangeTable.Section();
@@ -75,13 +77,13 @@
     closed = new ChangeTable.Section();
 
     String who = mine ? "self" : ownerId.toString();
-    outgoing.setTitleWidget(new InlineHyperlink(Util.C.outgoingReviews(),
-        PageLinks.toChangeQuery(queryOutgoing(who))));
-    incoming.setTitleWidget(new InlineHyperlink(Util.C.incomingReviews(),
-        PageLinks.toChangeQuery(queryIncoming(who))));
+    outgoing.setTitleWidget(
+        new InlineHyperlink(Util.C.outgoingReviews(), PageLinks.toChangeQuery(queryOutgoing(who))));
+    incoming.setTitleWidget(
+        new InlineHyperlink(Util.C.incomingReviews(), PageLinks.toChangeQuery(queryIncoming(who))));
     incoming.setHighlightUnreviewed(mine);
-    closed.setTitleWidget(new InlineHyperlink(Util.C.recentlyClosed(),
-        PageLinks.toChangeQuery(queryClosed(who))));
+    closed.setTitleWidget(
+        new InlineHyperlink(Util.C.recentlyClosed(), PageLinks.toChangeQuery(queryClosed(who))));
 
     table.addSection(outgoing);
     table.addSection(incoming);
@@ -95,11 +97,17 @@
   }
 
   private static String queryIncoming(String who) {
-    return "is:open reviewer:" + who + " -owner:" + who + " -star:ignore";
+    return "is:open ((reviewer:"
+        + who
+        + " -owner:"
+        + who
+        + " -star:ignore) OR assignee:"
+        + who
+        + ")";
   }
 
   private static String queryClosed(String who) {
-    return "is:closed (owner:" + who + " OR reviewer:" + who + ")";
+    return "is:closed (owner:" + who + " OR reviewer:" + who + " OR assignee:" + who + ")";
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index b181341..f8a9ba1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.changes;
 
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.info.AccountInfo;
 import com.google.gerrit.client.info.ChangeInfo;
 import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
 import com.google.gerrit.client.info.ChangeInfo.EditInfo;
@@ -27,27 +28,29 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
-/**
- * A collection of static methods which work on the Gerrit REST API for specific
- * changes.
- */
+/** A collection of static methods which work on the Gerrit REST API for specific changes. */
 public class ChangeApi {
   /** Abandon the change, ending its review. */
   public static void abandon(int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    Input input = Input.create();
+    MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
     call(id, "abandon").post(input, cb);
   }
 
-  /** Create a new change.
+  /**
+   * Create a new change.
    *
-   * The new change is created as DRAFT unless the draft workflow is disabled
-   * by `change.allowDrafts = false` in the configuration, in which case the
-   * new change is created as NEW.
-   *
+   * <p>The new change is created as DRAFT unless the draft workflow is disabled by
+   * `change.allowDrafts = false` in the configuration, in which case the new change is created as
+   * NEW.
    */
-  public static void createChange(String project, String branch, String topic,
-      String subject, String base, AsyncCallback<ChangeInfo> cb) {
+  public static void createChange(
+      String project,
+      String branch,
+      String topic,
+      String subject,
+      String base,
+      AsyncCallback<ChangeInfo> cb) {
     CreateChangeInput input = CreateChangeInput.create();
     input.project(emptyToNull(project));
     input.branch(emptyToNull(branch));
@@ -64,14 +67,14 @@
 
   /** Restore a previously abandoned change to be open again. */
   public static void restore(int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    Input input = Input.create();
+    MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
     call(id, "restore").post(input, cb);
   }
 
   /** Create a new change that reverts the delta caused by this change. */
   public static void revert(int id, String msg, AsyncCallback<ChangeInfo> cb) {
-    Input input = Input.create();
+    MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
     call(id, "revert").post(input, cb);
   }
@@ -81,7 +84,7 @@
     RestApi call = call(id, "topic");
     topic = emptyToNull(topic);
     if (topic != null) {
-      Input input = Input.create();
+      TopicInput input = TopicInput.create();
       input.topic(topic);
       call.put(input, NativeString.unwrap(cb));
     } else {
@@ -98,11 +101,7 @@
   }
 
   public static RestApi blame(PatchSet.Id id, String path, boolean base) {
-    return revision(id)
-        .view("files")
-        .id(path)
-        .view("blame")
-        .addParameter("base", base);
+    return revision(id).view("files").id(path).view("blame").addParameter("base", base);
   }
 
   public static RestApi actions(int id, String revision) {
@@ -112,6 +111,16 @@
     return call(id, revision, "actions");
   }
 
+  public static void deleteAssignee(int id, AsyncCallback<AccountInfo> cb) {
+    change(id).view("assignee").delete(cb);
+  }
+
+  public static void setAssignee(int id, String user, AsyncCallback<AccountInfo> cb) {
+    AssigneeInput input = AssigneeInput.create();
+    input.assignee(user);
+    change(id).view("assignee").put(input, cb);
+  }
+
   public static RestApi comments(int id) {
     return call(id, "comments");
   }
@@ -157,10 +166,12 @@
     return change(id).view("reviewers");
   }
 
-  public static RestApi suggestReviewers(int id, String q, int n) {
-    return change(id).view("suggest_reviewers")
-        .addParameter("q", q)
-        .addParameter("n", n);
+  public static RestApi suggestReviewers(int id, String q, int n, boolean e) {
+    RestApi api = change(id).view("suggest_reviewers").addParameter("n", n).addParameter("e", e);
+    if (q != null) {
+      api.addParameter("q", q);
+    }
+    return api;
   }
 
   public static RestApi vote(int id, int reviewer, String vote) {
@@ -178,12 +189,14 @@
   public static RestApi hashtags(int changeId) {
     return change(changeId).view("hashtags");
   }
+
   public static RestApi hashtag(int changeId, String hashtag) {
     return change(changeId).view("hashtags").id(hashtag);
   }
 
   /** Submit a specific revision of a change. */
-  public static void cherrypick(int id, String commit, String destination, String message, AsyncCallback<ChangeInfo> cb) {
+  public static void cherrypick(
+      int id, String commit, String destination, String message, AsyncCallback<ChangeInfo> cb) {
     CherryPickInput cherryPickInput = CherryPickInput.create();
     cherryPickInput.setMessage(message);
     cherryPickInput.setDestination(destination);
@@ -191,8 +204,8 @@
   }
 
   /** Edit commit message for specific revision of a change. */
-  public static void message(int id, String commit, String message,
-      AsyncCallback<JavaScriptObject> cb) {
+  public static void message(
+      int id, String commit, String message, AsyncCallback<JavaScriptObject> cb) {
     CherryPickInput input = CherryPickInput.create();
     input.setMessage(message);
     call(id, commit, "message").post(input, cb);
@@ -244,16 +257,34 @@
     call(id, commit, "rebase").post(rebaseInput, cb);
   }
 
-  private static class Input extends JavaScriptObject {
-    final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
+  private static class MessageInput extends JavaScriptObject {
     final native void message(String m) /*-{ if(m)this.message=m; }-*/;
 
-    static Input create() {
-      return (Input) createObject();
+    static MessageInput create() {
+      return (MessageInput) createObject();
     }
 
-    protected Input() {
+    protected MessageInput() {}
+  }
+
+  private static class AssigneeInput extends JavaScriptObject {
+    final native void assignee(String a) /*-{ if(a)this.assignee=a; }-*/;
+
+    static AssigneeInput create() {
+      return (AssigneeInput) createObject();
     }
+
+    protected AssigneeInput() {}
+  }
+
+  private static class TopicInput extends JavaScriptObject {
+    final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
+
+    static TopicInput create() {
+      return (TopicInput) createObject();
+    }
+
+    protected TopicInput() {}
   }
 
   private static class CreateChangeInput extends JavaScriptObject {
@@ -262,25 +293,30 @@
     }
 
     public final native void branch(String b) /*-{ if(b)this.branch=b; }-*/;
-    public final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
-    public final native void project(String p) /*-{ if(p)this.project=p; }-*/;
-    public final native void subject(String s) /*-{ if(s)this.subject=s; }-*/;
-    public final native void baseChange(String b) /*-{ if(b)this.base_change=b; }-*/;
-    public final native void status(String s)  /*-{ if(s)this.status=s; }-*/;
 
-    protected CreateChangeInput() {
-    }
+    public final native void topic(String t) /*-{ if(t)this.topic=t; }-*/;
+
+    public final native void project(String p) /*-{ if(p)this.project=p; }-*/;
+
+    public final native void subject(String s) /*-{ if(s)this.subject=s; }-*/;
+
+    public final native void status(String s) /*-{ if(s)this.status=s; }-*/;
+
+    public final native void baseChange(String b) /*-{ if(b)this.base_change=b; }-*/;
+
+    protected CreateChangeInput() {}
   }
 
   private static class CherryPickInput extends JavaScriptObject {
     static CherryPickInput create() {
       return (CherryPickInput) createObject();
     }
+
     final native void setDestination(String d) /*-{ this.destination = d; }-*/;
+
     final native void setMessage(String m) /*-{ this.message = m; }-*/;
 
-    protected CherryPickInput() {
-    }
+    protected CherryPickInput() {}
   }
 
   private static class RebaseInput extends JavaScriptObject {
@@ -290,8 +326,7 @@
       return (RebaseInput) createObject();
     }
 
-    protected RebaseInput() {
-    }
+    protected RebaseInput() {}
   }
 
   private static RestApi call(int id, String action) {
@@ -311,11 +346,7 @@
     return str == null || str.isEmpty() ? null : str;
   }
 
-  public static void commitWithLinks(int changeId, String revision,
-      Callback<CommitInfo> callback) {
-    revision(changeId, revision)
-        .view("commit")
-        .addParameterTrue("links")
-        .get(callback);
+  public static void commitWithLinks(int changeId, String revision, Callback<CommitInfo> callback) {
+    revision(changeId, revision).view("commit").addParameterTrue("links").get(callback);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index b2334d1d..ae64ac0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -18,86 +18,145 @@
 
 public interface ChangeConstants extends Constants {
   String statusLongNew();
+
   String statusLongMerged();
+
   String statusLongAbandoned();
+
   String statusLongDraft();
+
   String submittable();
+
   String readyToSubmit();
+
   String mergeConflict();
+
   String notCurrent();
+
   String changeEdit();
 
   String myDashboardTitle();
+
   String unknownDashboardTitle();
+
   String incomingReviews();
+
   String outgoingReviews();
+
   String recentlyClosed();
 
   String changeTableColumnSubject();
+
   String changeTableColumnSize();
+
   String changeTableColumnStatus();
+
   String changeTableColumnOwner();
+
+  String changeTableColumnAssignee();
+
   String changeTableColumnProject();
+
   String changeTableColumnBranch();
+
   String changeTableColumnLastUpdate();
+
   String changeTableColumnID();
+
   String changeTableNone();
+
   String changeTableNotMergeable();
 
   String changeItemHelp();
+
   String changeTableStar();
+
   String changeTablePagePrev();
+
   String changeTablePageNext();
+
   String upToChangeList();
+
   String keyReloadChange();
+
   String keyNextPatchSet();
+
   String keyPreviousPatchSet();
+
   String keyReloadSearch();
+
   String keyPublishComments();
+
   String keyEditTopic();
+
   String keyAddReviewers();
+
   String keyExpandAllMessages();
+
   String keyCollapseAllMessages();
 
   String patchTableColumnName();
+
   String patchTableColumnComments();
+
   String patchTableColumnSize();
+
   String commitMessage();
 
+  String mergeList();
+
   String patchTablePrev();
+
   String patchTableNext();
+
   String patchTableOpenDiff();
 
+  String approvalTableEditAssigneeHint();
+
   String approvalTableAddReviewerHint();
+
   String approvalTableAddManyReviewersConfirmationDialogTitle();
 
   String changeInfoBlockUploaded();
+
   String changeInfoBlockUpdated();
 
   String messageNoAuthor();
 
   String sideBySide();
+
   String unifiedDiff();
 
   String buttonRevertChangeSend();
+
   String headingRevertMessage();
+
   String revertChangeTitle();
 
   String buttonCherryPickChangeSend();
+
   String headingCherryPickBranch();
+
   String cherryPickCommitMessage();
+
   String cherryPickTitle();
 
   String buttonRebaseChangeSend();
+
   String rebaseConfirmMessage();
+
   String rebaseNotPossibleMessage();
+
   String rebasePlaceholderMessage();
+
   String rebaseTitle();
 
   String baseDiffItem();
+
   String autoMerge();
 
   String pagedChangeListPrev();
+
   String pagedChangeListNext();
 
   String submitFailed();
@@ -105,7 +164,10 @@
   String votable();
 
   String pushCertMissing();
+
   String pushCertBad();
+
   String pushCertOk();
+
   String pushCertTrusted();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index b7e2677..01921de 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -18,6 +18,7 @@
 changeTableColumnSize = Size
 changeTableColumnStatus = Status
 changeTableColumnOwner = Owner
+changeTableColumnAssignee = Assignee
 changeTableColumnProject = Project
 changeTableColumnBranch = Branch
 changeTableColumnLastUpdate = Updated
@@ -40,16 +41,18 @@
 keyExpandAllMessages = Expand all messages
 keyCollapseAllMessages = Collapse all messages
 
-
 patchTableColumnName = File Path
 patchTableColumnComments = Comments
 patchTableColumnSize = Size
 commitMessage = Commit Message
+mergeList = Merge List
 
 patchTablePrev = Previous file
 patchTableNext = Next file
 patchTableOpenDiff = Open diff
 
+approvalTableEditAssigneeHint = Name or Email
+
 approvalTableAddReviewerHint = Name or Email or Group
 approvalTableAddManyReviewersConfirmationDialogTitle = Adding Group Members as Reviewers
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java
index f86ddf7..0a7fd08 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeEditApi.java
@@ -28,8 +28,7 @@
 /** REST API helpers to remotely edit a change. */
 public class ChangeEditApi {
   /** Get file (or commit message) contents. */
-  public static void get(PatchSet.Id id, String path, boolean base,
-      HttpCallback<NativeString> cb) {
+  public static void get(PatchSet.Id id, String path, boolean base, HttpCallback<NativeString> cb) {
     RestApi api;
     if (id.get() != 0) {
       // Read from a published revision, when change edit doesn't
@@ -44,14 +43,12 @@
   }
 
   /** Get file (or commit message) contents of the edit. */
-  public static void get(PatchSet.Id id, String path,
-      HttpCallback<NativeString> cb) {
+  public static void get(PatchSet.Id id, String path, HttpCallback<NativeString> cb) {
     get(id, path, false, cb);
   }
 
   /** Get meta info for change edit. */
-  public static void getMeta(PatchSet.Id id, String path,
-      AsyncCallback<EditFileInfo> cb) {
+  public static void getMeta(PatchSet.Id id, String path, AsyncCallback<EditFileInfo> cb) {
     if (id.get() != 0) {
       throw new IllegalStateException("only supported for edits");
     }
@@ -64,8 +61,7 @@
   }
 
   /** Put contents into a file or commit message in a change edit. */
-  public static void put(int id, String path, String content,
-      GerritCallback<VoidResult> cb) {
+  public static void put(int id, String path, String content, GerritCallback<VoidResult> cb) {
     if (Patch.COMMIT_MSG.equals(path)) {
       putMessage(id, content, cb);
     } else {
@@ -79,8 +75,7 @@
   }
 
   /** Rename a file in the pending edit. */
-  public static void rename(int id, String path, String newPath,
-      AsyncCallback<VoidResult> cb) {
+  public static void rename(int id, String path, String newPath, AsyncCallback<VoidResult> cb) {
     Input in = Input.create();
     in.oldPath(path);
     in.newPath(newPath);
@@ -108,10 +103,11 @@
     }
 
     final native void restorePath(String p) /*-{ this.restore_path=p }-*/;
+
     final native void oldPath(String p) /*-{ this.old_path=p }-*/;
+
     final native void newPath(String p) /*-{ this.new_path=p }-*/;
 
-    protected Input() {
-    }
+    protected Input() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java
index cea9142..5d525b6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeList.java
@@ -20,7 +20,6 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwtorm.client.KeyUtil;
-
 import java.util.Set;
 
 /** List of changes available from {@code /changes/}. */
@@ -43,34 +42,36 @@
     if (queries.length == 1) {
       // Server unwraps a single query, so wrap it back in an array for the
       // callback.
-      call.get(new AsyncCallback<ChangeList>() {
-        @Override
-        public void onSuccess(ChangeList result) {
-          JsArray<ChangeList> wrapped = JsArray.createArray(1).cast();
-          wrapped.set(0, result);
-          callback.onSuccess(wrapped);
-        }
+      call.get(
+          new AsyncCallback<ChangeList>() {
+            @Override
+            public void onSuccess(ChangeList result) {
+              JsArray<ChangeList> wrapped = JsArray.createArray(1).cast();
+              wrapped.set(0, result);
+              callback.onSuccess(wrapped);
+            }
 
-        @Override
-        public void onFailure(Throwable caught) {
-          callback.onFailure(caught);
-        }
-      });
+            @Override
+            public void onFailure(Throwable caught) {
+              callback.onFailure(caught);
+            }
+          });
     } else {
       call.get(callback);
     }
   }
 
-  public static void query(String query,
-      Set<ListChangesOption> options,
-      AsyncCallback<ChangeList> callback) {
+  public static void query(
+      String query, Set<ListChangesOption> options, AsyncCallback<ChangeList> callback) {
     query(query, options, callback, 0, 0);
   }
 
-  public static void query(String query,
+  public static void query(
+      String query,
       Set<ListChangesOption> options,
       AsyncCallback<ChangeList> callback,
-      int start, int limit) {
+      int start,
+      int limit) {
     RestApi call = newQuery(query);
     if (limit > 0) {
       call.addParameter("n", limit);
@@ -95,6 +96,5 @@
     return call;
   }
 
-  protected ChangeList() {
-  }
+  protected ChangeList() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeListScreen.java
index 8871ee0..ed5a6f2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeListScreen.java
@@ -14,5 +14,4 @@
 
 package com.google.gerrit.client.changes;
 
-public interface ChangeListScreen {
-}
+public interface ChangeListScreen {}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
index b192bd5..c64fe91 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
@@ -22,23 +22,33 @@
   String revertChangeDefaultMessage(String commitMsg, String commitId);
 
   String cherryPickedChangeDefaultMessage(String commitMsg, String commitId);
+
   String changeScreenTitleId(String changeId);
+
   String loadingPatchSet(int id);
 
   String patchTableSize_Modify(int insertions, int deletions);
-  String patchTableSize_ModifyBinaryFiles(String bytesInserted,
-      String bytesDeleted);
-  String patchTableSize_ModifyBinaryFilesWithPercentages(String bytesInserted,
-      String percentageInserted, String bytesDeleted, String percentageDeleted);
+
+  String patchTableSize_ModifyBinaryFiles(String bytesInserted, String bytesDeleted);
+
+  String patchTableSize_ModifyBinaryFilesWithPercentages(
+      String bytesInserted,
+      String percentageInserted,
+      String bytesDeleted,
+      String percentageDeleted);
+
   String patchTableSize_LongModify(int insertions, int deletions);
 
   String removeReviewer(String fullName);
+
   String removeVote(String label);
 
   String blockedOn(String labelName);
+
   String needs(String labelName);
 
   String changeQueryWindowTitle(String query);
+
   String changeQueryPageTitle(String query);
 
   String insertionsAndDeletions(int insertions, int deletions);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
index 9c78955..adf7cff 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
@@ -44,33 +44,35 @@
 import com.google.gwt.user.client.ui.SimplePanel;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 
 public class ChangeTable extends NavigationTable<ChangeInfo> {
   // If changing default options, also update in
   // ChangeIT#defaultSearchDoesNotTouchDatabase().
   static final Set<ListChangesOption> OPTIONS =
-      Collections.unmodifiableSet(EnumSet.of(
-          ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS));
+      Collections.unmodifiableSet(
+          EnumSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS));
 
   private static final int C_STAR = 1;
   private static final int C_ID = 2;
   private static final int C_SUBJECT = 3;
   private static final int C_STATUS = 4;
   private static final int C_OWNER = 5;
-  private static final int C_PROJECT = 6;
-  private static final int C_BRANCH = 7;
-  private static final int C_LAST_UPDATE = 8;
-  private static final int C_SIZE = 9;
-  private static final int BASE_COLUMNS = 10;
+  private static final int C_ASSIGNEE = 6;
+  private static final int C_PROJECT = 7;
+  private static final int C_BRANCH = 8;
+  private static final int C_LAST_UPDATE = 9;
+  private static final int C_SIZE = 10;
+  private static final int BASE_COLUMNS = 11;
 
   private final List<Section> sections;
   private int columns;
+  private final boolean showAssignee;
   private final boolean showLegacyId;
   private List<String> labelNames;
 
@@ -78,6 +80,7 @@
     super(Util.C.changeItemHelp());
     columns = BASE_COLUMNS;
     labelNames = Collections.emptyList();
+    showAssignee = Gerrit.info().change().showAssigneeInChangesTable();
     showLegacyId = Gerrit.getUserPreferences().legacycidInChangeTable();
 
     if (Gerrit.isSignedIn()) {
@@ -90,6 +93,7 @@
     table.setText(0, C_SUBJECT, Util.C.changeTableColumnSubject());
     table.setText(0, C_STATUS, Util.C.changeTableColumnStatus());
     table.setText(0, C_OWNER, Util.C.changeTableColumnOwner());
+    table.setText(0, C_ASSIGNEE, Util.C.changeTableColumnAssignee());
     table.setText(0, C_PROJECT, Util.C.changeTableColumnProject());
     table.setText(0, C_BRANCH, Util.C.changeTableColumnBranch());
     table.setText(0, C_LAST_UPDATE, Util.C.changeTableColumnLastUpdate());
@@ -103,25 +107,29 @@
     if (!showLegacyId) {
       fmt.addStyleName(0, C_ID, Gerrit.RESOURCES.css().dataHeaderHidden());
     }
+    if (!showAssignee) {
+      fmt.addStyleName(0, C_ASSIGNEE, Gerrit.RESOURCES.css().dataHeaderHidden());
+    }
 
-    table.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        final Cell cell = table.getCellForEvent(event);
-        if (cell == null) {
-          return;
-        }
-        if (cell.getCellIndex() == C_STAR) {
-          // Don't do anything (handled by star itself).
-        } else if (cell.getCellIndex() == C_STATUS) {
-          // Don't do anything.
-        } else if (cell.getCellIndex() == C_OWNER) {
-          // Don't do anything.
-        } else if (getRowItem(cell.getRowIndex()) != null) {
-          movePointerTo(cell.getRowIndex());
-        }
-      }
-    });
+    table.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            final Cell cell = table.getCellForEvent(event);
+            if (cell == null) {
+              return;
+            }
+            if (cell.getCellIndex() == C_STAR) {
+              // Don't do anything (handled by star itself).
+            } else if (cell.getCellIndex() == C_STATUS) {
+              // Don't do anything.
+            } else if (cell.getCellIndex() == C_OWNER) {
+              // Don't do anything.
+            } else if (getRowItem(cell.getRowIndex()) != null) {
+              movePointerTo(cell.getRowIndex());
+            }
+          }
+        });
   }
 
   @Override
@@ -163,6 +171,12 @@
     fmt.addStyleName(row, C_SUBJECT, Gerrit.RESOURCES.css().cSUBJECT());
     fmt.addStyleName(row, C_STATUS, Gerrit.RESOURCES.css().cSTATUS());
     fmt.addStyleName(row, C_OWNER, Gerrit.RESOURCES.css().cOWNER());
+    fmt.addStyleName(
+        row,
+        C_ASSIGNEE,
+        showAssignee
+            ? Gerrit.RESOURCES.css().cASSIGNEE()
+            : Gerrit.RESOURCES.css().dataCellHidden());
     fmt.addStyleName(row, C_LAST_UPDATE, Gerrit.RESOURCES.css().cLastUpdate());
     fmt.addStyleName(row, C_SIZE, Gerrit.RESOURCES.css().cSIZE());
 
@@ -211,13 +225,10 @@
     }
   }
 
-  private void populateChangeRow(final int row, final ChangeInfo c,
-      boolean highlightUnreviewed) {
+  private void populateChangeRow(final int row, final ChangeInfo c, boolean highlightUnreviewed) {
     CellFormatter fmt = table.getCellFormatter();
     if (Gerrit.isSignedIn()) {
-      table.setWidget(row, C_STAR, StarredChanges.createIcon(
-          c.legacyId(),
-          c.starred()));
+      table.setWidget(row, C_STAR, StarredChanges.createIcon(c.legacyId(), c.starred()));
     }
     table.setWidget(row, C_ID, new TableChangeLink(String.valueOf(c.legacyId()), c));
 
@@ -232,14 +243,26 @@
     }
 
     if (c.owner() != null) {
-      table.setWidget(row, C_OWNER, new AccountLinkPanel(c.owner(), status));
+      table.setWidget(row, C_OWNER, AccountLinkPanel.withStatus(c.owner(), status));
     } else {
       table.setText(row, C_OWNER, "");
     }
 
+    if (showAssignee) {
+      if (c.assignee() != null) {
+        table.setWidget(row, C_ASSIGNEE, AccountLinkPanel.forAssignee(c.assignee()));
+        if (Gerrit.getUserPreferences().highlightAssigneeInChangeTable()
+            && Objects.equals(c.assignee().getId(), Gerrit.getUserAccount().getId())) {
+          table.getRowFormatter().addStyleName(row, Gerrit.RESOURCES.css().cASSIGNEDTOME());
+        }
+      } else {
+        table.setText(row, C_ASSIGNEE, "");
+      }
+    }
+
     table.setWidget(row, C_PROJECT, new ProjectLink(c.projectNameKey()));
-    table.setWidget(row, C_BRANCH, new BranchLink(c.projectNameKey(), c
-        .status(), c.branch(), c.topic()));
+    table.setWidget(
+        row, C_BRANCH, new BranchLink(c.projectNameKey(), c.status(), c.branch(), c.topic()));
     if (Gerrit.getUserPreferences().relativeDateInChangeTable()) {
       table.setText(row, C_LAST_UPDATE, relativeFormat(c.updated()));
     } else {
@@ -248,12 +271,11 @@
 
     int col = C_SIZE;
     if (!Gerrit.getUserPreferences().sizeBarInChangeTable()) {
-      table.setText(row, col,
-          Util.M.insertionsAndDeletions(c.insertions(), c.deletions()));
+      table.setText(row, col, Util.M.insertionsAndDeletions(c.insertions(), c.deletions()));
     } else {
       table.setWidget(row, col, getSizeWidget(c));
-      fmt.getElement(row, col).setTitle(
-          Util.M.insertionsAndDeletions(c.insertions(), c.deletions()));
+      fmt.getElement(row, col)
+          .setTitle(Util.M.insertionsAndDeletions(c.insertions(), c.deletions()));
     }
     col++;
 
@@ -273,8 +295,7 @@
           Gerrit.getUserPreferences().reviewCategoryStrategy();
       if (label.rejected() != null) {
         user = label.rejected().name();
-        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy,
-            label.rejected());
+        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy, label.rejected());
         if (info != null) {
           FlowPanel panel = new FlowPanel();
           panel.add(new Image(Gerrit.RESOURCES.redNot()));
@@ -285,8 +306,7 @@
         }
       } else if (label.approved() != null) {
         user = label.approved().name();
-        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy,
-            label.approved());
+        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy, label.approved());
         if (info != null) {
           FlowPanel panel = new FlowPanel();
           panel.add(new Image(Gerrit.RESOURCES.greenCheck()));
@@ -297,8 +317,7 @@
         }
       } else if (label.disliked() != null) {
         user = label.disliked().name();
-        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy,
-            label.disliked());
+        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy, label.disliked());
         String vstr = String.valueOf(label._value());
         if (info != null) {
           vstr = vstr + " " + info;
@@ -307,8 +326,7 @@
         table.setText(row, col, vstr);
       } else if (label.recommended() != null) {
         user = label.recommended().name();
-        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy,
-            label.recommended());
+        info = getReviewCategoryDisplayInfo(reviewCategoryStrategy, label.recommended());
         String vstr = "+" + label._value();
         if (info != null) {
           vstr = vstr + " " + info;
@@ -333,8 +351,7 @@
       needHighlight = true;
     }
     final Element tr = fmt.getElement(row, 0).getParentElement();
-    UIObject.setStyleName(tr, Gerrit.RESOURCES.css().needsReview(),
-        needHighlight);
+    UIObject.setStyleName(tr, Gerrit.RESOURCES.css().needsReview(), needHighlight);
 
     setRowItem(row, c);
   }
@@ -523,8 +540,7 @@
         rows++;
       }
       for (int i = 0; i < sz; i++) {
-        parent.populateChangeRow(dataBegin + i, changeList.get(i),
-            highlightUnreviewed);
+        parent.populateChangeRow(dataBegin + i, changeList.get(i), highlightUnreviewed);
       }
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
index 66cd485..0950fa5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
@@ -23,38 +23,34 @@
 
 public class CommentApi {
 
-  public static void comments(PatchSet.Id id,
-      AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+  public static void comments(PatchSet.Id id, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
     revision(id, "comments").get(cb);
   }
 
-  public static void comment(PatchSet.Id id, String commentId,
-      AsyncCallback<CommentInfo> cb) {
+  public static void comment(PatchSet.Id id, String commentId, AsyncCallback<CommentInfo> cb) {
     revision(id, "comments").id(commentId).get(cb);
   }
 
-  public static void drafts(PatchSet.Id id,
-      AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+  public static void drafts(PatchSet.Id id, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
     revision(id, "drafts").get(cb);
   }
 
-  public static void draft(PatchSet.Id id, String draftId,
-      AsyncCallback<CommentInfo> cb) {
+  public static void draft(PatchSet.Id id, String draftId, AsyncCallback<CommentInfo> cb) {
     revision(id, "drafts").id(draftId).get(cb);
   }
 
-  public static void createDraft(PatchSet.Id id, CommentInfo content,
-      AsyncCallback<CommentInfo> cb) {
+  public static void createDraft(
+      PatchSet.Id id, CommentInfo content, AsyncCallback<CommentInfo> cb) {
     revision(id, "drafts").put(content, cb);
   }
 
-  public static void updateDraft(PatchSet.Id id, String draftId,
-      CommentInfo content, AsyncCallback<CommentInfo> cb) {
+  public static void updateDraft(
+      PatchSet.Id id, String draftId, CommentInfo content, AsyncCallback<CommentInfo> cb) {
     revision(id, "drafts").id(draftId).put(content, cb);
   }
 
-  public static void deleteDraft(PatchSet.Id id, String draftId,
-      AsyncCallback<JavaScriptObject> cb) {
+  public static void deleteDraft(
+      PatchSet.Id id, String draftId, AsyncCallback<JavaScriptObject> cb) {
     revision(id, "drafts").id(draftId).delete(cb);
   }
 
@@ -62,6 +58,5 @@
     return ChangeApi.revision(id).view(type);
   }
 
-  private CommentApi() {
-  }
+  private CommentApi() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
index d42c344..a111860 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
@@ -19,17 +19,16 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
-
 import java.sql.Timestamp;
 
 public class CommentInfo extends JavaScriptObject {
-  public static CommentInfo create(String path, Side side,
-      int line, CommentRange range) {
-    return create(path, side, 0, line, range);
+  public static CommentInfo create(
+      String path, Side side, int line, CommentRange range, Boolean unresolved) {
+    return create(path, side, 0, line, range, unresolved);
   }
 
-  public static CommentInfo create(String path, Side side, int parent,
-      int line, CommentRange range) {
+  public static CommentInfo create(
+      String path, Side side, int parent, int line, CommentRange range, boolean unresolved) {
     CommentInfo n = createObject().cast();
     n.path(path);
     n.side(side);
@@ -40,6 +39,7 @@
     } else if (line > 0) {
       n.line(line);
     }
+    n.unresolved(unresolved);
     return n;
   }
 
@@ -55,6 +55,7 @@
     } else if (r.hasLine()) {
       n.line(r.line());
     }
+    n.unresolved(r.unresolved());
     return n;
   }
 
@@ -72,35 +73,51 @@
     } else if (s.hasLine()) {
       n.line(s.line());
     }
+    n.unresolved(s.unresolved());
     return n;
   }
 
   public final native void path(String p) /*-{ this.path = p }-*/;
+
   public final native void id(String i) /*-{ this.id = i }-*/;
+
   public final native void line(int n) /*-{ this.line = n }-*/;
+
   public final native void range(CommentRange r) /*-{ this.range = r }-*/;
+
   public final native void inReplyTo(String i) /*-{ this.in_reply_to = i }-*/;
+
   public final native void message(String m) /*-{ this.message = m }-*/;
 
+  public final native void unresolved(boolean b) /*-{ this.unresolved = b }-*/;
+
   public final void side(Side side) {
     sideRaw(side.toString());
   }
+
   private native void sideRaw(String s) /*-{ this.side = s }-*/;
+
   public final native void parent(int n) /*-{ this.parent = n }-*/;
+
   public final native boolean hasParent() /*-{ return this.hasOwnProperty('parent') }-*/;
 
   public final native String path() /*-{ return this.path }-*/;
+
   public final native String id() /*-{ return this.id }-*/;
+
   public final native String inReplyTo() /*-{ return this.in_reply_to }-*/;
+
   public final native int patchSet() /*-{ return this.patch_set }-*/;
 
+  public final native boolean unresolved() /*-{ return this.unresolved }-*/;
+
   public final Side side() {
     String s = sideRaw();
-    return s != null
-        ? Side.valueOf(s)
-        : Side.REVISION;
+    return s != null ? Side.valueOf(s) : Side.REVISION;
   }
+
   private native String sideRaw() /*-{ return this.side }-*/;
+
   public final native int parent() /*-{ return this.parent }-*/;
 
   public final Timestamp updated() {
@@ -114,17 +131,24 @@
     }
     return r;
   }
+
   private native String updatedRaw() /*-{ return this.updated }-*/;
+
   private native Timestamp updatedTimestamp() /*-{ return this._ts }-*/;
+
   private native void updatedTimestamp(Timestamp t) /*-{ this._ts = t }-*/;
 
   public final native AccountInfo author() /*-{ return this.author }-*/;
+
   public final native int line() /*-{ return this.line || 0 }-*/;
+
   public final native boolean hasLine() /*-{ return this.hasOwnProperty('line') }-*/;
+
   public final native boolean hasRange() /*-{ return this.hasOwnProperty('range') }-*/;
+
   public final native CommentRange range() /*-{ return this.range }-*/;
+
   public final native String message() /*-{ return this.message }-*/;
 
-  protected CommentInfo() {
-  }
+  protected CommentInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CustomDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CustomDashboardScreen.java
index 320976e..802e56c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CustomDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CustomDashboardScreen.java
@@ -26,13 +26,14 @@
 
   @Override
   protected void onInitUI() {
-    table = new DashboardTable(this, params) {
-      @Override
-      public void finishDisplay() {
-        super.finishDisplay();
-        display();
-      }
-    };
+    table =
+        new DashboardTable(this, params) {
+          @Override
+          public void finishDisplay() {
+            super.finishDisplay();
+            display();
+          }
+        };
 
     super.onInitUI();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java
index 26e11ae..3cfe63d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java
@@ -24,7 +24,6 @@
 import com.google.gwt.event.dom.client.KeyPressEvent;
 import com.google.gwt.http.client.URL;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.ListIterator;
@@ -74,20 +73,20 @@
       sections.add(s);
     }
 
-    keysNavigation.add(new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        Gerrit.display(screen.getToken());
-      }
-    });
+    keysNavigation.add(
+        new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            Gerrit.display(screen.getToken());
+          }
+        });
   }
 
   private String removeLimitAndAge(String query) {
     StringBuilder unlimitedQuery = new StringBuilder();
     String[] operators = query.split(" ");
     for (String o : operators) {
-      if (!o.startsWith("limit:")
-          && !o.startsWith("age:") && !o.startsWith("-age:")) {
+      if (!o.startsWith("limit:") && !o.startsWith("age:") && !o.startsWith("-age:")) {
         unlimitedQuery.append(o).append(" ");
       }
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java
index b7b9b07..370d942 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java
@@ -50,24 +50,26 @@
     next = new Hyperlink(Util.C.pagedChangeListNext(), true, "");
     next.setVisible(false);
 
-    table = new ChangeTable() {
-      {
-        keysNavigation.add(
-            new DoLinkCommand(0, 'p', Util.C.changeTablePagePrev(), prev),
-            new DoLinkCommand(0, 'n', Util.C.changeTablePageNext(), next));
+    table =
+        new ChangeTable() {
+          {
+            keysNavigation.add(
+                new DoLinkCommand(0, 'p', Util.C.changeTablePagePrev(), prev),
+                new DoLinkCommand(0, 'n', Util.C.changeTablePageNext(), next));
 
-        keysNavigation.add(
-            new DoLinkCommand(0, '[', Util.C.changeTablePagePrev(), prev),
-            new DoLinkCommand(0, ']', Util.C.changeTablePageNext(), next));
+            keysNavigation.add(
+                new DoLinkCommand(0, '[', Util.C.changeTablePagePrev(), prev),
+                new DoLinkCommand(0, ']', Util.C.changeTablePageNext(), next));
 
-        keysNavigation.add(new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
-          @Override
-          public void onKeyPress(final KeyPressEvent event) {
-            Gerrit.display(getToken());
+            keysNavigation.add(
+                new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
+                  @Override
+                  public void onKeyPress(final KeyPressEvent event) {
+                    Gerrit.display(getToken());
+                  }
+                });
           }
-        });
-      }
-    };
+        };
     section = new ChangeTable.Section();
     table.addSection(section);
     table.setSavePointerId(anchorPrefix);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java
index d2eec27..12638d7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java
@@ -32,13 +32,14 @@
 
   @Override
   protected void onInitUI() {
-    table = new DashboardTable(this, params) {
-      @Override
-      public void finishDisplay() {
-        super.finishDisplay();
-        display();
-      }
-    };
+    table =
+        new DashboardTable(this, params) {
+          @Override
+          public void finishDisplay() {
+            super.finishDisplay();
+            display();
+          }
+        };
 
     super.onInitUI();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java
index 80117be..696fe8b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/QueryScreen.java
@@ -24,18 +24,15 @@
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwtorm.client.KeyUtil;
 
-public class QueryScreen extends PagedSingleListScreen implements
-    ChangeListScreen {
+public class QueryScreen extends PagedSingleListScreen implements ChangeListScreen {
   // Legacy numeric identifier.
   private static final RegExp NUMERIC_ID = RegExp.compile("^[1-9][0-9]*$");
   // Commit SHA1 hash
-  private static final RegExp COMMIT_SHA1 =
-      RegExp.compile("^([0-9a-fA-F]{4," + RevId.LEN + "})$");
+  private static final RegExp COMMIT_SHA1 = RegExp.compile("^([0-9a-fA-F]{4," + RevId.LEN + "})$");
   // Change-Id
   private static final String ID_PATTERN = "[iI][0-9a-f]{4,}$";
   private static final RegExp CHANGE_ID = RegExp.compile("^" + ID_PATTERN);
-  private static final RegExp CHANGE_ID_TRIPLET =
-      RegExp.compile("^(.)+~(.)+~" + ID_PATTERN);
+  private static final RegExp CHANGE_ID_TRIPLET = RegExp.compile("^(.)+~(.)+~" + ID_PATTERN);
 
   public static QueryScreen forQuery(String query) {
     return forQuery(query, 0);
@@ -87,8 +84,7 @@
   @Override
   protected void onLoad() {
     super.onLoad();
-    ChangeList.query(
-        query, ChangeTable.OPTIONS, loadCallback(), start, pageSize);
+    ChangeList.query(query, ChangeTable.OPTIONS, loadCallback(), start, pageSize);
   }
 
   private static boolean isSingleQuery(String query) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInfo.java
index 3508c3d..06d2484 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInfo.java
@@ -21,6 +21,5 @@
 
   public final native NativeMap<?> labels() /*-{ return this.labels }-*/;
 
-  protected ReviewInfo() {
-  }
+  protected ReviewInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
index a127e7f..113651b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ReviewInput.java
@@ -20,11 +20,17 @@
 
 public class ReviewInput extends JavaScriptObject {
   public enum NotifyHandling {
-    NONE, OWNER, OWNER_REVIEWERS, ALL
+    NONE,
+    OWNER,
+    OWNER_REVIEWERS,
+    ALL
   }
 
   public enum DraftHandling {
-    DELETE, PUBLISH, KEEP, PUBLISH_ALL_REVISIONS
+    DELETE,
+    PUBLISH,
+    KEEP,
+    PUBLISH_ALL_REVISIONS
   }
 
   public static ReviewInput create() {
@@ -35,18 +41,21 @@
   }
 
   public final native void message(String m) /*-{ if(m)this.message=m; }-*/;
+
   public final native void label(String n, short v) /*-{ this.labels[n]=v; }-*/;
-  public final native void comments(NativeMap<JsArray<CommentInfo>> m)
-  /*-{ this.comments=m }-*/;
+
+  public final native void comments(NativeMap<JsArray<CommentInfo>> m) /*-{ this.comments=m }-*/;
 
   public final void notify(NotifyHandling e) {
     _notify(e.name());
   }
+
   private native void _notify(String n) /*-{ this.notify=n; }-*/;
 
   public final void drafts(DraftHandling e) {
     _drafts(e.name());
   }
+
   private native void _drafts(String n) /*-{ this.drafts=n; }-*/;
 
   private native void init() /*-{
@@ -76,6 +85,5 @@
     }
   }-*/;
 
-  protected ReviewInput() {
-  }
+  protected ReviewInput() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java
index 48e3052..0b83119 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -27,9 +26,7 @@
   private static final RevisionInfoCache IMPL = new RevisionInfoCache();
 
   public static void add(Change.Id change, RevisionInfo info) {
-    IMPL.psToCommit.put(
-        new PatchSet.Id(change, info._number()),
-        info.name());
+    IMPL.psToCommit.put(new PatchSet.Id(change, info._number()), info.name());
   }
 
   static String get(PatchSet.Id id) {
@@ -40,11 +37,12 @@
 
   @SuppressWarnings("serial")
   private RevisionInfoCache() {
-    psToCommit = new LinkedHashMap<PatchSet.Id, String>(LIMIT) {
-      @Override
-      protected boolean removeEldestEntry(Map.Entry<PatchSet.Id, String> e) {
-        return size() > LIMIT;
-      }
-    };
+    psToCommit =
+        new LinkedHashMap<PatchSet.Id, String>(LIMIT) {
+          @Override
+          protected boolean removeEldestEntry(Map.Entry<PatchSet.Id, String> e) {
+            return size() > LIMIT;
+          }
+        };
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
index 43b3b80..b4499ac 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
@@ -28,7 +28,6 @@
 import com.google.gwtexpui.globalkey.client.KeyCommand;
 import com.google.web.bindery.event.shared.Event;
 import com.google.web.bindery.event.shared.HandlerRegistration;
-
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -66,8 +65,8 @@
   }
 
   /**
-   * Create a star icon for the given change, and current status. Returns null
-   * if the user is not signed in and cannot support starred changes.
+   * Create a star icon for the given change, and current status. Returns null if the user is not
+   * signed in and cannot support starred changes.
    */
   public static Icon createIcon(Change.Id source, boolean starred) {
     return Gerrit.isSignedIn() ? new Icon(source, starred) : null;
@@ -84,30 +83,23 @@
   }
 
   /** Add a handler to listen for starred status to change. */
-  public static HandlerRegistration addHandler(
-      Change.Id source,
-      ChangeStarHandler handler) {
+  public static HandlerRegistration addHandler(Change.Id source, ChangeStarHandler handler) {
     return Gerrit.EVENT_BUS.addHandlerToSource(TYPE, source, handler);
   }
 
   /**
-   * Broadcast the current starred value of a change to UI widgets. This does
-   * not RPC to the server and does not alter the starred status of a change.
+   * Broadcast the current starred value of a change to UI widgets. This does not RPC to the server
+   * and does not alter the starred status of a change.
    */
   public static void fireChangeStarEvent(Change.Id id, boolean starred) {
-    Gerrit.EVENT_BUS.fireEventFromSource(
-        new ChangeStarEvent(id, starred),
-        id);
+    Gerrit.EVENT_BUS.fireEventFromSource(new ChangeStarEvent(id, starred), id);
   }
 
   /**
-   * Set the starred status of a change. This method broadcasts to all
-   * interested UI widgets and sends an RPC to the server to record the
-   * updated status.
+   * Set the starred status of a change. This method broadcasts to all interested UI widgets and
+   * sends an RPC to the server to record the updated status.
    */
-  public static void toggleStar(
-      final Change.Id changeId,
-      final boolean newValue) {
+  public static void toggleStar(final Change.Id changeId, final boolean newValue) {
     pending.put(changeId, newValue);
     fireChangeStarEvent(changeId, newValue);
     if (!busy) {
@@ -116,8 +108,7 @@
   }
 
   private static boolean busy;
-  private static final Map<Change.Id, Boolean> pending =
-      new LinkedHashMap<>(4);
+  private static final Map<Change.Id, Boolean> pending = new LinkedHashMap<>(4);
 
   private static void startRequest() {
     busy = true;
@@ -125,31 +116,32 @@
     final Change.Id id = pending.keySet().iterator().next();
     final boolean starred = pending.remove(id);
     RestApi call = AccountApi.self().view("starred.changes").id(id.get());
-    AsyncCallback<JavaScriptObject> cb = new AsyncCallback<JavaScriptObject>() {
-      @Override
-      public void onSuccess(JavaScriptObject none) {
-        if (pending.isEmpty()) {
-          busy = false;
-        } else {
-          startRequest();
-        }
-      }
+    AsyncCallback<JavaScriptObject> cb =
+        new AsyncCallback<JavaScriptObject>() {
+          @Override
+          public void onSuccess(JavaScriptObject none) {
+            if (pending.isEmpty()) {
+              busy = false;
+            } else {
+              startRequest();
+            }
+          }
 
-      @Override
-      public void onFailure(Throwable caught) {
-        if (!starred && RestApi.isStatus(caught, 404)) {
-          onSuccess(null);
-          return;
-        }
+          @Override
+          public void onFailure(Throwable caught) {
+            if (!starred && RestApi.isStatus(caught, 404)) {
+              onSuccess(null);
+              return;
+            }
 
-        fireChangeStarEvent(id, !starred);
-        for (Map.Entry<Change.Id, Boolean> e : pending.entrySet()) {
-          fireChangeStarEvent(e.getKey(), !e.getValue());
-        }
-        pending.clear();
-        busy = false;
-      }
-    };
+            fireChangeStarEvent(id, !starred);
+            for (Map.Entry<Change.Id, Boolean> e : pending.entrySet()) {
+              fireChangeStarEvent(e.getKey(), !e.getValue());
+            }
+            pending.clear();
+            busy = false;
+          }
+        };
     if (starred) {
       call.put(cb);
     } else {
@@ -157,8 +149,7 @@
     }
   }
 
-  public static class Icon extends Image
-      implements ChangeStarHandler, ClickHandler {
+  public static class Icon extends Image implements ChangeStarHandler, ClickHandler {
     private final Change.Id changeId;
     private boolean starred;
     private HandlerRegistration handler;
@@ -171,9 +162,9 @@
     }
 
     /**
-     * Toggles the state of the star, as if the user clicked on the image. This
-     * will broadcast the new star status to all interested UI widgets, and RPC
-     * to the server to store the changed value.
+     * Toggles the state of the star, as if the user clicked on the image. This will broadcast the
+     * new star status to all interested UI widgets, and RPC to the server to store the changed
+     * value.
      */
     public void toggleStar() {
       StarredChanges.toggleStar(changeId, !starred);
@@ -206,6 +197,5 @@
     }
   }
 
-  private StarredChanges() {
-  }
+  private StarredChanges() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java
index 7a24774..9027c5b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/SubmitInfo.java
@@ -24,6 +24,5 @@
 
   private native String statusRaw() /*-{ return this.status; }-*/;
 
-  protected SubmitInfo() {
-  }
+  protected SubmitInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
index e6d3dbe..b2efcdb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
@@ -44,22 +44,20 @@
   }
 
   /**
-   * Crops the given change subject if needed so that it has at most
-   * {@link #SUBJECT_MAX_LENGTH} characters.
+   * Crops the given change subject if needed so that it has at most {@link #SUBJECT_MAX_LENGTH}
+   * characters.
    *
-   * If the given subject is not longer than {@link #SUBJECT_MAX_LENGTH}
-   * characters it is returned unchanged.
+   * <p>If the given subject is not longer than {@link #SUBJECT_MAX_LENGTH} characters it is
+   * returned unchanged.
    *
-   * If the length of the given subject exceeds {@link #SUBJECT_MAX_LENGTH}
-   * characters it is cropped. In this case {@link #SUBJECT_CROP_APPENDIX} is
-   * appended to the cropped subject, the cropped subject including the appendix
-   * has at most {@link #SUBJECT_MAX_LENGTH} characters.
+   * <p>If the length of the given subject exceeds {@link #SUBJECT_MAX_LENGTH} characters it is
+   * cropped. In this case {@link #SUBJECT_CROP_APPENDIX} is appended to the cropped subject, the
+   * cropped subject including the appendix has at most {@link #SUBJECT_MAX_LENGTH} characters.
    *
-   * If cropping is needed, the subject will be cropped after the last space
-   * character that is found within the last {@link #SUBJECT_CROP_RANGE}
-   * characters of the potentially visible characters. If no such space is
-   * found, the subject will be cropped so that the cropped subject including
-   * the appendix has exactly {@link #SUBJECT_MAX_LENGTH} characters.
+   * <p>If cropping is needed, the subject will be cropped after the last space character that is
+   * found within the last {@link #SUBJECT_CROP_RANGE} characters of the potentially visible
+   * characters. If no such space is found, the subject will be cropped so that the cropped subject
+   * including the appendix has exactly {@link #SUBJECT_MAX_LENGTH} characters.
    *
    * @return the subject, cropped if needed
    */
@@ -67,7 +65,9 @@
   public static String cropSubject(final String subject) {
     if (subject.length() > SUBJECT_MAX_LENGTH) {
       final int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
-      for (int cropPosition = maxLength; cropPosition > maxLength - SUBJECT_CROP_RANGE; cropPosition--) {
+      for (int cropPosition = maxLength;
+          cropPosition > maxLength - SUBJECT_CROP_RANGE;
+          cropPosition--) {
         // Character.isWhitespace(char) can't be used because this method is not supported by GWT,
         // see https://developers.google.com/web-toolkit/doc/1.6/RefJreEmulation#Package_java_lang
         if (Character.isSpace(subject.charAt(cropPosition - 1))) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/CapabilityInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/CapabilityInfo.java
index 45abbd6..1d7f4ab 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/CapabilityInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/CapabilityInfo.java
@@ -18,8 +18,8 @@
 
 public class CapabilityInfo extends JavaScriptObject {
   public final native String id() /*-{ return this.id; }-*/;
+
   public final native String name() /*-{ return this.name; }-*/;
 
-  protected CapabilityInfo() {
-  }
+  protected CapabilityInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java
index 47393a7..e71929c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/config/ConfigServerApi.java
@@ -23,10 +23,7 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
-/**
- * A collection of static methods which work on the Gerrit REST API for server
- * configuration.
- */
+/** A collection of static methods which work on the Gerrit REST API for server configuration. */
 public class ConfigServerApi {
   /** map of the server wide capabilities (core & plugins). */
   public static void capabilities(AsyncCallback<NativeMap<CapabilityInfo>> cb) {
@@ -58,7 +55,6 @@
       return createObject().cast();
     }
 
-    protected EmailConfirmationInput() {
-    }
+    protected EmailConfirmationInput() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.java
index b02f0474..ecb2938 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardConstants.java
@@ -18,9 +18,14 @@
 
 public interface DashboardConstants extends Constants {
   String dashboardName();
+
   String dashboardTitle();
+
   String dashboardDescription();
+
   String dashboardInherited();
+
   String dashboardItem();
+
   String dashboardDefaultToolTip();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java
index 44d74ab..0d49677 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java
@@ -18,16 +18,24 @@
 
 public class DashboardInfo extends JavaScriptObject {
   public final native String id() /*-{ return this.id; }-*/;
+
   public final native String title() /*-{ return this.title; }-*/;
+
   public final native String project() /*-{ return this.project; }-*/;
+
   public final native String definingProject() /*-{ return this.defining_project; }-*/;
+
   public final native String ref() /*-{ return this.ref; }-*/;
+
   public final native String path() /*-{ return this.path; }-*/;
+
   public final native String description() /*-{ return this.description; }-*/;
+
   public final native String foreach() /*-{ return this.foreach; }-*/;
+
   public final native String url() /*-{ return this.url; }-*/;
+
   public final native boolean isDefault() /*-{ return this['default'] ? true : false; }-*/;
 
-  protected DashboardInfo() {
-  }
+  protected DashboardInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardList.java
index 1190f9c..7ba3580 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardList.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardList.java
@@ -22,18 +22,16 @@
 
 /** Project dashboards from {@code /projects/<name>/dashboards/}. */
 public class DashboardList extends JsArray<DashboardInfo> {
-  public static void all(Project.NameKey project,
-      AsyncCallback<JsArray<DashboardList>> callback) {
+  public static void all(Project.NameKey project, AsyncCallback<JsArray<DashboardList>> callback) {
     base(project).addParameterTrue("inherited").get(callback);
   }
 
-  public static void getDefault(Project.NameKey project,
-      AsyncCallback<DashboardInfo> callback) {
+  public static void getDefault(Project.NameKey project, AsyncCallback<DashboardInfo> callback) {
     base(project).view("default").addParameterTrue("inherited").get(callback);
   }
 
-  public static void get(Project.NameKey project, String id,
-      AsyncCallback<DashboardInfo> callback) {
+  public static void get(
+      Project.NameKey project, String id, AsyncCallback<DashboardInfo> callback) {
     base(project).idRaw(encodeDashboardId(id)).get(callback);
   }
 
@@ -51,6 +49,5 @@
     return URL.encodeQueryString(id);
   }
 
-  protected DashboardList() {
-  }
+  protected DashboardList() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
index 47c0359..6215854 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardsTable.java
@@ -24,7 +24,6 @@
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
 import com.google.gwt.user.client.ui.Image;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -76,12 +75,14 @@
       table.removeRow(table.getRowCount() - 1);
     }
 
-    Collections.sort(list, new Comparator<DashboardInfo>() {
-      @Override
-      public int compare(DashboardInfo a, DashboardInfo b) {
-        return a.id().compareTo(b.id());
-      }
-    });
+    Collections.sort(
+        list,
+        new Comparator<DashboardInfo>() {
+          @Override
+          public int compare(DashboardInfo a, DashboardInfo b) {
+            return a.id().compareTo(b.id());
+          }
+        });
 
     String ref = null;
     for (DashboardInfo d : list) {
@@ -126,13 +127,21 @@
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
       fmt.getElement(row, 1).setTitle(Util.C.dashboardDefaultToolTip());
     }
-    table.setWidget(row, 2, new Anchor(k.path(), "#"
-            + PageLinks.toProjectDashboard(new Project.NameKey(k.project()), k.id())));
+    table.setWidget(
+        row,
+        2,
+        new Anchor(
+            k.path(),
+            "#" + PageLinks.toProjectDashboard(new Project.NameKey(k.project()), k.id())));
     table.setText(row, 3, k.title() != null ? k.title() : k.path());
     table.setText(row, 4, k.description());
     if (k.definingProject() != null && !k.definingProject().equals(k.project())) {
-      table.setWidget(row, 5, new Anchor(k.definingProject(), "#"
-          + PageLinks.toProjectDashboards(new Project.NameKey(k.definingProject()))));
+      table.setWidget(
+          row,
+          5,
+          new Anchor(
+              k.definingProject(),
+              "#" + PageLinks.toProjectDashboards(new Project.NameKey(k.definingProject()))));
     }
     setRowItem(row, k);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
index 5257ae0..953bc87 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
@@ -18,20 +18,17 @@
 
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.dom.client.Element;
-
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.LineClassWhere;
 import net.codemirror.lib.Pos;
 import net.codemirror.lib.TextMarker;
 
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.List;
-
 /** Colors modified regions for {@link SideBySide} and {@link Unified}. */
 abstract class ChunkManager {
-  static final native void onClick(Element e, JavaScriptObject f)
-  /*-{ e.onclick = f }-*/;
+  static final native void onClick(Element e, JavaScriptObject f) /*-{ e.onclick = f }-*/;
 
   final Scrollbar scrollbar;
   final LineMapper lineMapper;
@@ -71,27 +68,32 @@
     colorLines(cm, LineClassWhere.WRAP, color, line, line + cnt);
   }
 
-  void colorLines(final CodeMirror cm, final LineClassWhere where,
-      final String className, final int start, final int end) {
+  void colorLines(
+      final CodeMirror cm,
+      final LineClassWhere where,
+      final String className,
+      final int start,
+      final int end) {
     if (start < end) {
       for (int line = start; line < end; line++) {
         cm.addLineClass(line, where, className);
       }
-      undo.add(new Runnable() {
-        @Override
-        public void run() {
-          for (int line = start; line < end; line++) {
-            cm.removeLineClass(line, where, className);
-          }
-        }
-      });
+      undo.add(
+          new Runnable() {
+            @Override
+            public void run() {
+              for (int line = start; line < end; line++) {
+                cm.removeLineClass(line, where, className);
+              }
+            }
+          });
     }
   }
 
-  abstract Runnable diffChunkNav(final CodeMirror cm, final Direction dir);
+  abstract Runnable diffChunkNav(CodeMirror cm, Direction dir);
 
-  void diffChunkNavHelper(List<? extends DiffChunkInfo> chunks,
-      DiffScreen host, int res, Direction dir) {
+  void diffChunkNavHelper(
+      List<? extends DiffChunkInfo> chunks, DiffScreen host, int res, Direction dir) {
     if (res < 0) {
       res = -res - (dir == Direction.PREV ? 1 : 2);
     }
@@ -115,8 +117,7 @@
     targetCm.setCursor(Pos.create(cmLine));
     targetCm.focus();
     targetCm.scrollToY(
-        targetCm.heightAtLine(cmLine, "local")
-        - 0.5 * targetCm.scrollbarV().getClientHeight());
+        targetCm.heightAtLine(cmLine, "local") - 0.5 * targetCm.scrollbarV().getClientHeight());
   }
 
   Comparator<DiffChunkInfo> getDiffChunkComparator() {
@@ -130,12 +131,10 @@
         if (a.getSide() == b.getSide()) {
           return a.getStart() - b.getStart();
         } else if (a.getSide() == A) {
-          int comp = lineMapper.lineOnOther(a.getSide(), a.getStart())
-              .getLine() - b.getStart();
+          int comp = lineMapper.lineOnOther(a.getSide(), a.getStart()).getLine() - b.getStart();
           return comp == 0 ? -1 : comp;
         } else {
-          int comp = a.getStart() -
-              lineMapper.lineOnOther(b.getSide(), b.getStart()).getLine();
+          int comp = a.getStart() - lineMapper.lineOnOther(b.getSide(), b.getStart()).getLine();
           return comp == 0 ? 1 : comp;
         }
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css
index f4f1e83..b4216eb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css
@@ -60,11 +60,11 @@
   margin-right: 5px;
 
   -webkit-touch-callout: initial;
-  -webkit-user-select: initial;
-  -khtml-user-select: initial;
+  -webkit-user-select: text;
+  -khtml-user-select: text;
   -moz-user-select: text;
   -ms-user-select: text;
-  user-select: initial;
+  user-select: text;
 }
 
 .commentBox {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
index 70ef947..6f9e694 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.java
@@ -21,7 +21,6 @@
 import com.google.gwt.event.dom.client.MouseOverHandler;
 import com.google.gwt.resources.client.CssResource;
 import com.google.gwt.user.client.ui.Composite;
-
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.Pos;
@@ -36,15 +35,23 @@
 
   interface Style extends CssResource {
     String commentWidgets();
+
     String commentBox();
+
     String contents();
+
     String message();
+
     String header();
+
     String summary();
+
     String date();
 
     String goPrev();
+
     String goNext();
+
     String goUp();
   }
 
@@ -58,33 +65,40 @@
     this.group = group;
     if (range != null) {
       DiffScreen screen = group.getManager().host;
-      int startCmLine =
-          screen.getCmLine(range.startLine() - 1, group.getSide());
+      int startCmLine = screen.getCmLine(range.startLine() - 1, group.getSide());
       int endCmLine = screen.getCmLine(range.endLine() - 1, group.getSide());
-      fromTo = FromTo.create(
-          Pos.create(startCmLine, range.startCharacter()),
-          Pos.create(endCmLine, range.endCharacter()));
-      rangeMarker = group.getCm().markText(
-          fromTo.from(),
-          fromTo.to(),
-          Configuration.create()
-              .set("className", Resources.I.diffTableStyle().range()));
+      fromTo =
+          FromTo.create(
+              Pos.create(startCmLine, range.startCharacter()),
+              Pos.create(endCmLine, range.endCharacter()));
+      rangeMarker =
+          group
+              .getCm()
+              .markText(
+                  fromTo.from(),
+                  fromTo.to(),
+                  Configuration.create().set("className", Resources.I.diffTableStyle().range()));
     }
-    addDomHandler(new MouseOverHandler() {
-      @Override
-      public void onMouseOver(MouseOverEvent event) {
-        setRangeHighlight(true);
-      }
-    }, MouseOverEvent.getType());
-    addDomHandler(new MouseOutHandler() {
-      @Override
-      public void onMouseOut(MouseOutEvent event) {
-        setRangeHighlight(isOpen());
-      }
-    }, MouseOutEvent.getType());
+    addDomHandler(
+        new MouseOverHandler() {
+          @Override
+          public void onMouseOver(MouseOverEvent event) {
+            setRangeHighlight(true);
+          }
+        },
+        MouseOverEvent.getType());
+    addDomHandler(
+        new MouseOutHandler() {
+          @Override
+          public void onMouseOut(MouseOutEvent event) {
+            setRangeHighlight(isOpen());
+          }
+        },
+        MouseOutEvent.getType());
   }
 
   abstract CommentInfo getCommentInfo();
+
   abstract boolean isOpen();
 
   void setOpen(boolean open) {
@@ -112,11 +126,14 @@
   void setRangeHighlight(boolean highlight) {
     if (fromTo != null) {
       if (highlight && rangeHighlightMarker == null) {
-        rangeHighlightMarker = group.getCm().markText(
-            fromTo.from(),
-            fromTo.to(),
-            Configuration.create()
-                .set("className", Resources.I.diffTableStyle().rangeHighlight()));
+        rangeHighlightMarker =
+            group
+                .getCm()
+                .markText(
+                    fromTo.from(),
+                    fromTo.to(),
+                    Configuration.create()
+                        .set("className", Resources.I.diffTableStyle().rangeHighlight()));
       } else if (!highlight && rangeHighlightMarker != null) {
         rangeHighlightMarker.clear();
         rangeHighlightMarker = null;
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 1d198ec..414e82e 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
@@ -18,7 +18,6 @@
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.SimplePanel;
-
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.LineWidget;
@@ -27,7 +26,7 @@
 /**
  * LineWidget attached to a CodeMirror container.
  *
- * When a comment is placed on a line a CommentWidget is created.
+ * <p>When a comment is placed on a line a CommentWidget is created.
  */
 abstract class CommentGroup extends Composite {
 
@@ -139,12 +138,15 @@
 
   void attach(DiffTable parent) {
     parent.add(this);
-    lineWidget = cm.addLineWidget(Math.max(0, line - 1), getElement(),
-        Configuration.create()
-          .set("coverGutter", true)
-          .set("noHScroll", true)
-          .set("above", line <= 0)
-          .set("insertAt", 0));
+    lineWidget =
+        cm.addLineWidget(
+            Math.max(0, line - 1),
+            getElement(),
+            Configuration.create()
+                .set("coverGutter", true)
+                .set("noHScroll", true)
+                .set("above", line <= 0)
+                .set("insertAt", 0));
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
index 2f3ead3..587dacc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.patches.SkippedLine;
@@ -23,11 +24,6 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.JsArray;
-
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.TextMarker.FromTo;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -37,10 +33,13 @@
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.Pos;
+import net.codemirror.lib.TextMarker.FromTo;
 
 /** Tracks comment widgets for {@link DiffScreen}. */
 abstract class CommentManager {
-  private final PatchSet.Id base;
+  private final DiffObject base;
   private final PatchSet.Id revision;
   private final String path;
   private final CommentLinkProcessor commentLinkProcessor;
@@ -55,7 +54,7 @@
 
   CommentManager(
       DiffScreen host,
-      PatchSet.Id base,
+      DiffObject base,
       PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
@@ -129,29 +128,29 @@
   }
 
   Side getStoredSideFromDisplaySide(DisplaySide side) {
-    if (side == DisplaySide.A && (base == null || base.get() < 0)) {
+    if (side == DisplaySide.A && (base.isBaseOrAutoMerge() || base.isParent())) {
       return Side.PARENT;
     }
     return Side.REVISION;
   }
 
   int getParentNumFromDisplaySide(DisplaySide side) {
-    if (side == DisplaySide.A && base != null && base.get() < 0) {
-      return -base.get();
+    if (side == DisplaySide.A) {
+      return base.getParentNum();
     }
     return 0;
   }
 
   PatchSet.Id getPatchSetIdFromSide(DisplaySide side) {
-    if (side == DisplaySide.A && base != null && base.get() >= 0) {
-      return base;
+    if (side == DisplaySide.A && (base.isPatchSet() || base.isEdit())) {
+      return base.asPatchSetId();
     }
     return revision;
   }
 
   DisplaySide displaySide(CommentInfo info, DisplaySide forSide) {
     if (info.side() == Side.PARENT) {
-      return (base == null || base.get() < 0) ? DisplaySide.A : null;
+      return (base.isBaseOrAutoMerge() || base.isParent()) ? DisplaySide.A : null;
     }
     return forSide;
   }
@@ -172,8 +171,8 @@
    * Create a new {@link DraftBox} at the specified line and focus it.
    *
    * @param side which side the draft will appear on.
-   * @param line the line the draft will be at. Lines are 1-based. Line 0 is a
-   *        special case creating a file level comment.
+   * @param line the line the draft will be at. Lines are 1-based. Line 0 is a special case creating
+   *     a file level comment.
    */
   void insertNewDraft(DisplaySide side, int line) {
     if (line == 0) {
@@ -184,17 +183,21 @@
     if (0 < group.getBoxCount()) {
       CommentBox last = group.getCommentBox(group.getBoxCount() - 1);
       if (last instanceof DraftBox) {
-        ((DraftBox)last).setEdit(true);
+        ((DraftBox) last).setEdit(true);
       } else {
-        ((PublishedBox)last).doReply();
+        ((PublishedBox) last).doReply();
       }
     } else {
-      addDraftBox(side, CommentInfo.create(
-          getPath(),
-          getStoredSideFromDisplaySide(side),
-          getParentNumFromDisplaySide(side),
-          line,
-          null)).setEdit(true);
+      addDraftBox(
+              side,
+              CommentInfo.create(
+                  getPath(),
+                  getStoredSideFromDisplaySide(side),
+                  getParentNumFromDisplaySide(side),
+                  line,
+                  null,
+                  false))
+          .setEdit(true);
     }
   }
 
@@ -233,12 +236,9 @@
   DraftBox addDraftBox(DisplaySide side, CommentInfo info) {
     int cmLinePlusOne = host.getCmLine(info.line() - 1, side) + 1;
     CommentGroup group = group(side, cmLinePlusOne);
-    DraftBox box = new DraftBox(
-        group,
-        getCommentLinkProcessor(),
-        getPatchSetIdFromSide(side),
-        info,
-        isExpandAll());
+    DraftBox box =
+        new DraftBox(
+            group, getCommentLinkProcessor(), getPatchSetIdFromSide(side), info, isExpandAll());
 
     if (info.inReplyTo() != null) {
       PublishedBox r = getPublished().get(info.inReplyTo());
@@ -248,9 +248,10 @@
     }
 
     group.add(box);
-    box.setAnnotation(host.getDiffTable().scrollbar.draft(
-        host.getCmFromSide(side),
-        Math.max(0, cmLinePlusOne - 1)));
+    box.setAnnotation(
+        host.getDiffTable()
+            .scrollbar
+            .draft(host.getCmFromSide(side), Math.max(0, cmLinePlusOne - 1)));
     return box;
   }
 
@@ -274,9 +275,8 @@
         // It is only necessary to search one side to find a comment
         // on either side of the editor pair.
         SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
-        int line = src.extras().hasActiveLine()
-            ? src.getLineNumber(src.extras().activeLine()) + 1
-            : 0;
+        int line =
+            src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
 
         CommentGroup g;
         if (dir == Direction.NEXT) {
@@ -355,17 +355,17 @@
       if (side != null) {
         int cmLinePlusOne = host.getCmLine(info.line() - 1, side) + 1;
         CommentGroup group = group(side, cmLinePlusOne);
-        PublishedBox box = new PublishedBox(
-            group,
-            getCommentLinkProcessor(),
-            getPatchSetIdFromSide(side),
-            info,
-            side,
-            isOpen());
+        PublishedBox box =
+            new PublishedBox(
+                group,
+                getCommentLinkProcessor(),
+                getPatchSetIdFromSide(side),
+                info,
+                side,
+                isOpen());
         group.add(box);
-        box.setAnnotation(host.getDiffTable().scrollbar.comment(
-            host.getCmFromSide(side),
-            cmLinePlusOne - 1));
+        box.setAnnotation(
+            host.getDiffTable().scrollbar.comment(host.getCmFromSide(side), cmLinePlusOne - 1));
         getPublished().put(info.id(), box);
       }
     }
@@ -399,9 +399,9 @@
         if (deltaBefore < -context || deltaAfter < -context) {
           temp.add(skip); // Size guaranteed to be greater than 1
         } else if (deltaBefore > context && deltaAfter > context) {
-          SkippedLine before = new SkippedLine(
-              skip.getStartA(), skip.getStartB(),
-              skip.getSize() - deltaAfter - context);
+          SkippedLine before =
+              new SkippedLine(
+                  skip.getStartA(), skip.getStartB(), skip.getSize() - deltaAfter - context);
           skip.incrementStart(deltaBefore + context);
           checkAndAddSkip(temp, before);
           checkAndAddSkip(temp, skip);
@@ -421,8 +421,7 @@
     return skips;
   }
 
-  abstract void newDraftOnGutterClick(CodeMirror cm, String gutterClass,
-      int line);
+  abstract void newDraftOnGutterClick(CodeMirror cm, String gutterClass, int line);
 
   abstract CommentGroup getCommentGroupOnActiveLine(CodeMirror cm);
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java
index d3c150d..0f357d5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentRange.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.diff;
 
 import com.google.gwt.core.client.JavaScriptObject;
-
 import net.codemirror.lib.Pos;
 import net.codemirror.lib.TextMarker.FromTo;
 
@@ -39,8 +38,11 @@
   }
 
   public final native int startLine() /*-{ return this.start_line; }-*/;
+
   public final native int startCharacter() /*-{ return this.start_character; }-*/;
+
   public final native int endLine() /*-{ return this.end_line; }-*/;
+
   public final native int endCharacter() /*-{ return this.end_character; }-*/;
 
   private native void set(int sl, int sc, int el, int ec) /*-{
@@ -50,6 +52,5 @@
     this.end_character = ec;
   }-*/;
 
-  protected CommentRange() {
-  }
+  protected CommentRange() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
index ce1d294..3ed0c50 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentApi;
 import com.google.gerrit.client.changes.CommentInfo;
@@ -24,14 +25,13 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import java.util.Collections;
 import java.util.Comparator;
 
 /** Collection of published and draft comments loaded from the server. */
 class CommentsCollections {
   private final String path;
-  private final PatchSet.Id base;
+  private final DiffObject base;
   private final PatchSet.Id revision;
   private NativeMap<JsArray<CommentInfo>> publishedBaseAll;
   private NativeMap<JsArray<CommentInfo>> publishedRevisionAll;
@@ -40,28 +40,28 @@
   JsArray<CommentInfo> draftsBase;
   JsArray<CommentInfo> draftsRevision;
 
-  CommentsCollections(PatchSet.Id base, PatchSet.Id revision, String path) {
+  CommentsCollections(DiffObject base, PatchSet.Id revision, String path) {
     this.path = path;
     this.base = base;
     this.revision = revision;
   }
 
   void load(CallbackGroup group) {
-    if (base != null && base.get() > 0) {
-      CommentApi.comments(base, group.add(publishedBase()));
+    if (base.isPatchSet()) {
+      CommentApi.comments(base.asPatchSetId(), group.add(publishedBase()));
     }
     CommentApi.comments(revision, group.add(publishedRevision()));
 
     if (Gerrit.isSignedIn()) {
-      if (base != null && base.get() > 0) {
-        CommentApi.drafts(base, group.add(draftsBase()));
+      if (base.isPatchSet()) {
+        CommentApi.drafts(base.asPatchSetId(), group.add(draftsBase()));
       }
       CommentApi.drafts(revision, group.add(draftsRevision()));
     }
   }
 
   boolean hasCommentForPath(String filePath) {
-    if (base != null && base.get() > 0) {
+    if (base.isPatchSet()) {
       JsArray<CommentInfo> forBase = publishedBaseAll.get(filePath);
       if (forBase != null && forBase.length() > 0) {
         return true;
@@ -83,8 +83,7 @@
       }
 
       @Override
-      public void onFailure(Throwable caught) {
-      }
+      public void onFailure(Throwable caught) {}
     };
   }
 
@@ -100,24 +99,23 @@
       }
 
       @Override
-      public void onFailure(Throwable caught) {
-      }
+      public void onFailure(Throwable caught) {}
     };
   }
 
-    private JsArray<CommentInfo> filterForParent(JsArray<CommentInfo> list) {
-      JsArray<CommentInfo> result = JsArray.createArray().cast();
-      for (CommentInfo c : Natives.asList(list)) {
-        if (c.side() == Side.REVISION) {
-          result.push(c);
-        } else if (base == null && !c.hasParent()) {
-          result.push(c);
-        } else if (base != null && c.parent() == -base.get()) {
-          result.push(c);
-        }
+  private JsArray<CommentInfo> filterForParent(JsArray<CommentInfo> list) {
+    JsArray<CommentInfo> result = JsArray.createArray().cast();
+    for (CommentInfo c : Natives.asList(list)) {
+      if (c.side() == Side.REVISION) {
+        result.push(c);
+      } else if (base.isBaseOrAutoMerge() && !c.hasParent()) {
+        result.push(c);
+      } else if (base.isParent() && c.parent() == base.getParentNum()) {
+        result.push(c);
       }
-      return result;
     }
+    return result;
+  }
 
   private AsyncCallback<NativeMap<JsArray<CommentInfo>>> draftsBase() {
     return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
@@ -127,8 +125,7 @@
       }
 
       @Override
-      public void onFailure(Throwable caught) {
-      }
+      public void onFailure(Throwable caught) {}
     };
   }
 
@@ -143,8 +140,7 @@
       }
 
       @Override
-      public void onFailure(Throwable caught) {
-      }
+      public void onFailure(Throwable caught) {}
     };
   }
 
@@ -153,12 +149,14 @@
       for (CommentInfo c : Natives.asList(in)) {
         c.path(path);
       }
-      Collections.sort(Natives.asList(in), new Comparator<CommentInfo>() {
-        @Override
-        public int compare(CommentInfo a, CommentInfo b) {
-          return a.updated().compareTo(b.updated());
-        }
-      });
+      Collections.sort(
+          Natives.asList(in),
+          new Comparator<CommentInfo>() {
+            @Override
+            public int compare(CommentInfo a, CommentInfo b) {
+              return a.updated().compareTo(b.updated());
+            }
+          });
     }
     return in;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
index e3720cc..3f64066 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
@@ -26,8 +26,8 @@
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
 public class DiffApi {
-  public static void list(int id, String revision, RevisionInfo base,
-      AsyncCallback<NativeMap<FileInfo>> cb) {
+  public static void list(
+      int id, String revision, RevisionInfo base, AsyncCallback<NativeMap<FileInfo>> cb) {
     RestApi api = ChangeApi.revision(id, revision).view("files");
     if (base != null) {
       if (base._number() < 0) {
@@ -39,8 +39,7 @@
     api.get(NativeMap.copyKeysIntoChildren("path", cb));
   }
 
-  public static void list(PatchSet.Id id, PatchSet.Id base,
-      AsyncCallback<NativeMap<FileInfo>> cb) {
+  public static void list(PatchSet.Id id, PatchSet.Id base, AsyncCallback<NativeMap<FileInfo>> cb) {
     RestApi api = ChangeApi.revision(id).view("files");
     if (base != null) {
       if (base.get() < 0) {
@@ -53,9 +52,7 @@
   }
 
   public static DiffApi diff(PatchSet.Id id, String path) {
-    return new DiffApi(ChangeApi.revision(id)
-        .view("files").id(path)
-        .view("diff"));
+    return new DiffApi(ChangeApi.revision(id).view("files").id(path).view("diff"));
   }
 
   private final RestApi call;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
index b7910f5..cf40762 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffInfo.java
@@ -22,16 +22,20 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
-
-import java.util.LinkedList;
+import java.util.ArrayList;
 import java.util.List;
 
 public class DiffInfo extends JavaScriptObject {
   public final native FileMeta metaA() /*-{ return this.meta_a; }-*/;
+
   public final native FileMeta metaB() /*-{ return this.meta_b; }-*/;
+
   public final native JsArrayString diffHeader() /*-{ return this.diff_header; }-*/;
+
   public final native JsArray<Region> content() /*-{ return this.content; }-*/;
+
   public final native JsArray<DiffWebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
+
   public final native boolean binary() /*-{ return this.binary || false; }-*/;
 
   public final List<WebLinkInfo> sideBySideWebLinks() {
@@ -43,16 +47,14 @@
   }
 
   private List<WebLinkInfo> filterWebLinks(DiffView diffView) {
-    List<WebLinkInfo> filteredDiffWebLinks = new LinkedList<>();
+    List<WebLinkInfo> filteredDiffWebLinks = new ArrayList<>();
     List<DiffWebLinkInfo> allDiffWebLinks = Natives.asList(webLinks());
     if (allDiffWebLinks != null) {
       for (DiffWebLinkInfo webLink : allDiffWebLinks) {
-        if (diffView == DiffView.SIDE_BY_SIDE
-            && webLink.showOnSideBySideDiffView()) {
+        if (diffView == DiffView.SIDE_BY_SIDE && webLink.showOnSideBySideDiffView()) {
           filteredDiffWebLinks.add(webLink);
         }
-        if (diffView == DiffView.UNIFIED_DIFF
-            && webLink.showOnUnifiedDiffView()) {
+        if (diffView == DiffView.UNIFIED_DIFF && webLink.showOnUnifiedDiffView()) {
           filteredDiffWebLinks.add(webLink);
         }
       }
@@ -63,17 +65,15 @@
   public final ChangeType changeType() {
     return ChangeType.valueOf(changeTypeRaw());
   }
-  private native String changeTypeRaw()
-  /*-{ return this.change_type }-*/;
+
+  private native String changeTypeRaw() /*-{ return this.change_type }-*/;
 
   public final IntraLineStatus intralineStatus() {
     String s = intralineStatusRaw();
-    return s != null
-        ? IntraLineStatus.valueOf(s)
-        : IntraLineStatus.OFF;
+    return s != null ? IntraLineStatus.valueOf(s) : IntraLineStatus.OFF;
   }
-  private native String intralineStatusRaw()
-  /*-{ return this.intraline_status }-*/;
+
+  private native String intralineStatusRaw() /*-{ return this.intraline_status }-*/;
 
   public final boolean hasSkip() {
     JsArray<Region> c = content();
@@ -141,42 +141,50 @@
     }
   }
 
-  protected DiffInfo() {
-  }
+  protected DiffInfo() {}
 
   public enum IntraLineStatus {
-    OFF, OK, TIMEOUT, FAILURE
+    OFF,
+    OK,
+    TIMEOUT,
+    FAILURE
   }
 
   public static class FileMeta extends JavaScriptObject {
     public final native String name() /*-{ return this.name; }-*/;
+
     public final native String contentType() /*-{ return this.content_type; }-*/;
+
     public final native int lines() /*-{ return this.lines || 0 }-*/;
+
     public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
 
-    protected FileMeta() {
-    }
+    protected FileMeta() {}
   }
 
   public static class Region extends JavaScriptObject {
     public final native JsArrayString ab() /*-{ return this.ab; }-*/;
+
     public final native JsArrayString a() /*-{ return this.a; }-*/;
+
     public final native JsArrayString b() /*-{ return this.b; }-*/;
+
     public final native int skip() /*-{ return this.skip || 0; }-*/;
+
     public final native boolean common() /*-{ return this.common || false; }-*/;
 
     public final native JsArray<Span> editA() /*-{ return this.edit_a }-*/;
+
     public final native JsArray<Span> editB() /*-{ return this.edit_b }-*/;
 
-    protected Region() {
-    }
+    protected Region() {}
   }
 
   public static class Span extends JavaScriptObject {
     public final native int skip() /*-{ return this[0]; }-*/;
+
     public final native int mark() /*-{ return this[1]; }-*/;
 
-    protected Span() {
-    }
+    protected Span() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
index de19b35..60a75eb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.extensions.client.DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
 import static java.lang.Double.POSITIVE_INFINITY;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.account.DiffPreferences;
@@ -63,7 +64,9 @@
 import com.google.gwtexpui.globalkey.client.KeyCommand;
 import com.google.gwtexpui.globalkey.client.KeyCommandSet;
 import com.google.gwtexpui.globalkey.client.ShowHelpCommand;
-
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.BeforeSelectionChangeHandler;
 import net.codemirror.lib.CodeMirror.GutterClickHandler;
@@ -74,14 +77,10 @@
 import net.codemirror.mode.ModeInjector;
 import net.codemirror.theme.ThemeLoader;
 
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.List;
-
 /** Base class for SideBySide and Unified */
 abstract class DiffScreen extends Screen {
-  private static final KeyMap RENDER_ENTIRE_FILE_KEYMAP = KeyMap.create()
-      .propagate("Ctrl-F").propagate("Ctrl-G").propagate("Shift-Ctrl-G");
+  private static final KeyMap RENDER_ENTIRE_FILE_KEYMAP =
+      KeyMap.create().propagate("Ctrl-F").propagate("Ctrl-G").propagate("Shift-Ctrl-G");
 
   enum FileSize {
     SMALL(0),
@@ -96,7 +95,7 @@
   }
 
   private final Change.Id changeId;
-  final PatchSet.Id base;
+  final DiffObject base;
   final PatchSet.Id revision;
   final String path;
   final DiffPreferences prefs;
@@ -123,15 +122,15 @@
   Header header;
 
   DiffScreen(
-      PatchSet.Id base,
-      PatchSet.Id revision,
+      DiffObject base,
+      DiffObject revision,
       String path,
       DisplaySide startSide,
       int startLine,
       DiffView diffScreenType) {
     this.base = base;
-    this.revision = revision;
-    this.changeId = revision.getParentKey();
+    this.revision = revision.asPatchSetId();
+    this.changeId = revision.asPatchSetId().getParentKey();
     this.path = path;
     this.startSide = startSide;
     this.startLine = startLine;
@@ -139,8 +138,7 @@
     prefs = DiffPreferences.create(Gerrit.getDiffPreferences());
     handlers = new ArrayList<>(6);
     keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    header = new Header(
-        keysNavigation, base, revision, path, diffScreenType, prefs);
+    header = new Header(keysNavigation, base, revision, path, diffScreenType, prefs);
     skipManager = new SkipManager(this);
   }
 
@@ -158,111 +156,124 @@
     CallbackGroup group1 = new CallbackGroup();
     final CallbackGroup group2 = new CallbackGroup();
 
-    CodeMirror.initLibrary(group1.add(new AsyncCallback<Void>() {
-      final AsyncCallback<Void> themeCallback = group2.addEmpty();
+    CodeMirror.initLibrary(
+        group1.add(
+            new AsyncCallback<Void>() {
+              final AsyncCallback<Void> themeCallback = group2.addEmpty();
 
-      @Override
-      public void onSuccess(Void result) {
-        // Load theme after CM library to ensure theme can override CSS.
-        ThemeLoader.loadTheme(prefs.theme(), themeCallback);
-      }
+              @Override
+              public void onSuccess(Void result) {
+                // Load theme after CM library to ensure theme can override CSS.
+                ThemeLoader.loadTheme(prefs.theme(), themeCallback);
+              }
 
-      @Override
-      public void onFailure(Throwable caught) {
-      }
-    }));
+              @Override
+              public void onFailure(Throwable caught) {}
+            }));
 
     DiffApi.diff(revision, path)
-      .base(base)
-      .wholeFile()
-      .intraline(prefs.intralineDifference())
-      .ignoreWhitespace(prefs.ignoreWhitespace())
-      .get(group1.addFinal(new GerritCallback<DiffInfo>() {
-        final AsyncCallback<Void> modeInjectorCb = group2.addEmpty();
+        .base(base.asPatchSetId())
+        .wholeFile()
+        .intraline(prefs.intralineDifference())
+        .ignoreWhitespace(prefs.ignoreWhitespace())
+        .get(
+            group1.addFinal(
+                new GerritCallback<DiffInfo>() {
+                  final AsyncCallback<Void> modeInjectorCb = group2.addEmpty();
 
-        @Override
-        public void onSuccess(DiffInfo diffInfo) {
-          diff = diffInfo;
-          fileSize = bucketFileSize(diffInfo);
+                  @Override
+                  public void onSuccess(DiffInfo diffInfo) {
+                    diff = diffInfo;
+                    fileSize = bucketFileSize(diffInfo);
 
-          if (prefs.syntaxHighlighting()) {
-            if (fileSize.compareTo(FileSize.SMALL) > 0) {
-              modeInjectorCb.onSuccess(null);
-            } else {
-              injectMode(diffInfo, modeInjectorCb);
-            }
-          } else {
-            modeInjectorCb.onSuccess(null);
-          }
-        }
-      }));
+                    if (prefs.syntaxHighlighting()) {
+                      if (fileSize.compareTo(FileSize.SMALL) > 0) {
+                        modeInjectorCb.onSuccess(null);
+                      } else {
+                        injectMode(diffInfo, modeInjectorCb);
+                      }
+                    } else {
+                      modeInjectorCb.onSuccess(null);
+                    }
+                  }
+                }));
 
     if (Gerrit.isSignedIn()) {
-      ChangeApi.edit(changeId.get(), group2.add(
-          new AsyncCallback<EditInfo>() {
-            @Override
-            public void onSuccess(EditInfo result) {
-              edit = result;
-            }
+      ChangeApi.edit(
+          changeId.get(),
+          group2.add(
+              new AsyncCallback<EditInfo>() {
+                @Override
+                public void onSuccess(EditInfo result) {
+                  edit = result;
+                }
 
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          }));
+                @Override
+                public void onFailure(Throwable caught) {}
+              }));
     }
 
-    final CommentsCollections comments =
-        new CommentsCollections(base, revision, path);
+    final CommentsCollections comments = new CommentsCollections(base, revision, path);
     comments.load(group2);
 
     countParents(group2);
 
     RestApi call = ChangeApi.detail(changeId.get());
-    ChangeList.addOptions(call, EnumSet.of(
-        ListChangesOption.ALL_REVISIONS));
-    call.get(group2.add(new AsyncCallback<ChangeInfo>() {
-      @Override
-      public void onSuccess(ChangeInfo info) {
-        changeStatus = info.status();
-        info.revisions().copyKeysIntoChildren("name");
-        if (edit != null) {
-          edit.setName(edit.commit().commit());
-          info.setEdit(edit);
-          info.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
-        }
-        String currentRevision = info.currentRevision();
-        boolean current = currentRevision != null &&
-            revision.get() == info.revision(currentRevision)._number();
-        JsArray<RevisionInfo> list = info.revisions().values();
-        RevisionInfo.sortRevisionInfoByNumber(list);
-        getDiffTable().set(prefs, list, parents, diff, edit != null, current,
-            changeStatus.isOpen(), diff.binary());
-        header.setChangeInfo(info);
-      }
+    ChangeList.addOptions(call, EnumSet.of(ListChangesOption.ALL_REVISIONS));
+    call.get(
+        group2.add(
+            new AsyncCallback<ChangeInfo>() {
+              @Override
+              public void onSuccess(ChangeInfo info) {
+                changeStatus = info.status();
+                info.revisions().copyKeysIntoChildren("name");
+                if (edit != null) {
+                  edit.setName(edit.commit().commit());
+                  info.setEdit(edit);
+                  info.revisions().put(edit.name(), RevisionInfo.fromEdit(edit));
+                }
+                String currentRevision = info.currentRevision();
+                boolean current =
+                    currentRevision != null
+                        && revision.get() == info.revision(currentRevision)._number();
+                JsArray<RevisionInfo> list = info.revisions().values();
+                RevisionInfo.sortRevisionInfoByNumber(list);
+                getDiffTable()
+                    .set(
+                        prefs,
+                        list,
+                        parents,
+                        diff,
+                        edit != null,
+                        current,
+                        changeStatus.isOpen(),
+                        diff.binary());
+                header.setChangeInfo(info);
+              }
 
-      @Override
-      public void onFailure(Throwable caught) {
-      }
-    }));
+              @Override
+              public void onFailure(Throwable caught) {}
+            }));
 
-    ConfigInfoCache.get(changeId, group2.addFinal(
-        getScreenLoadCallback(comments)));
+    ConfigInfoCache.get(changeId, group2.addFinal(getScreenLoadCallback(comments)));
   }
 
   private void countParents(CallbackGroup cbg) {
     ChangeApi.revision(changeId.get(), revision.getId())
         .view("commit")
-        .get(cbg.add(new AsyncCallback<CommitInfo>() {
-          @Override
-          public void onSuccess(CommitInfo info) {
-            parents = info.parents().length();
-          }
+        .get(
+            cbg.add(
+                new AsyncCallback<CommitInfo>() {
+                  @Override
+                  public void onSuccess(CommitInfo info) {
+                    parents = info.parents().length();
+                  }
 
-          @Override
-          public void onFailure(Throwable caught) {
-            parents = 0;
-          }
-        }));
+                  @Override
+                  public void onFailure(Throwable caught) {
+                    parents = 0;
+                  }
+                }));
   }
 
   @Override
@@ -273,12 +284,14 @@
     if (prefs.hideTopMenu()) {
       Gerrit.setHeaderVisible(false);
     }
-    resizeHandler = Window.addResizeHandler(new ResizeHandler() {
-      @Override
-      public void onResize(ResizeEvent event) {
-        resizeCodeMirror();
-      }
-    });
+    resizeHandler =
+        Window.addResizeHandler(
+            new ResizeHandler() {
+              @Override
+              public void onResize(ResizeEvent event) {
+                resizeCodeMirror();
+              }
+            });
   }
 
   KeyCommandSet getKeysNavigation() {
@@ -326,149 +339,188 @@
   void registerCmEvents(final CodeMirror cm) {
     cm.on("cursorActivity", updateActiveLine(cm));
     cm.on("focus", updateActiveLine(cm));
-    KeyMap keyMap = KeyMap.create()
-        .on("A", upToChange(true))
-        .on("U", upToChange(false))
-        .on("'['", header.navigate(Direction.PREV))
-        .on("']'", header.navigate(Direction.NEXT))
-        .on("R", header.toggleReviewed())
-        .on("O", getCommentManager().toggleOpenBox(cm))
-        .on("N", maybeNextVimSearch(cm))
-        .on("Ctrl-Alt-E", openEditScreen(cm))
-        .on("P", getChunkManager().diffChunkNav(cm, Direction.PREV))
-        .on("Shift-M", header.reviewedAndNext())
-        .on("Shift-N", maybePrevVimSearch(cm))
-        .on("Shift-P", getCommentManager().commentNav(cm, Direction.PREV))
-        .on("Shift-O", getCommentManager().openCloseAll(cm))
-        .on("I", new Runnable() {
-          @Override
-          public void run() {
-            switch (getIntraLineStatus()) {
-              case OFF:
-              case OK:
-                toggleShowIntraline();
-                break;
-              case FAILURE:
-              case TIMEOUT:
-              default:
-                break;
-            }
-          }
-        })
-        .on("','", new Runnable() {
-          @Override
-          public void run() {
-            prefsAction.show();
-          }
-        })
-        .on("Shift-/", new Runnable() {
-          @Override
-          public void run() {
-            new ShowHelpCommand().onKeyPress(null);
-          }
-        })
-        .on("Space", new Runnable() {
-          @Override
-          public void run() {
-            cm.vim().handleKey("<C-d>");
-          }
-        })
-        .on("Shift-Space", new Runnable() {
-          @Override
-          public void run() {
-            cm.vim().handleKey("<C-u>");
-          }
-        })
-        .on("Ctrl-F", new Runnable() {
-          @Override
-          public void run() {
-            cm.execCommand("find");
-          }
-        })
-        .on("Ctrl-G", new Runnable() {
-          @Override
-          public void run() {
-            cm.execCommand("findNext");
-          }
-        })
-        .on("Enter", maybeNextCmSearch(cm))
-        .on("Shift-Ctrl-G", new Runnable() {
-          @Override
-          public void run() {
-            cm.execCommand("findPrev");
-          }
-        })
-        .on("Shift-Enter", new Runnable() {
-          @Override
-          public void run() {
-            cm.execCommand("findPrev");
-          }
-        })
-        .on("Esc", new Runnable() {
-          @Override
-          public void run() {
-            cm.setCursor(cm.getCursor());
-            cm.execCommand("clearSearch");
-            cm.vim().handleEx("nohlsearch");
-          }
-        })
-        .on("Ctrl-A", new Runnable() {
-          @Override
-          public void run() {
-            cm.execCommand("selectAll");
-          }
-        })
-        .on("G O", new Runnable() {
-          @Override
-          public void run() {
-            Gerrit.display(PageLinks.toChangeQuery("status:open"));
-          }
-        })
-        .on("G M", new Runnable() {
-          @Override
-          public void run() {
-            Gerrit.display(PageLinks.toChangeQuery("status:merged"));
-          }
-        })
-        .on("G A", new Runnable() {
-          @Override
-          public void run() {
-            Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
-          }
-        });
-        if (Gerrit.isSignedIn()) {
-          keyMap.on("G I", new Runnable() {
-            @Override
-            public void run() {
-              Gerrit.display(PageLinks.MINE);
-            }
-          })
-          .on("G D", new Runnable() {
-            @Override
-            public void run() {
-              Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
-            }
-          })
-          .on("G C", new Runnable() {
-            @Override
-            public void run() {
-              Gerrit.display(PageLinks.toChangeQuery("has:draft"));
-            }
-          })
-          .on("G W", new Runnable() {
-            @Override
-            public void run() {
-              Gerrit.display(
-                  PageLinks.toChangeQuery("is:watched status:open"));
-            }
-          })
-          .on("G S", new Runnable() {
-            @Override
-            public void run() {
-              Gerrit.display(PageLinks.toChangeQuery("is:starred"));
-            }
-          });
-        }
+    KeyMap keyMap =
+        KeyMap.create()
+            .on("A", upToChange(true))
+            .on("U", upToChange(false))
+            .on("'['", header.navigate(Direction.PREV))
+            .on("']'", header.navigate(Direction.NEXT))
+            .on("R", header.toggleReviewed())
+            .on("O", getCommentManager().toggleOpenBox(cm))
+            .on("N", maybeNextVimSearch(cm))
+            .on("Ctrl-Alt-E", openEditScreen(cm))
+            .on("P", getChunkManager().diffChunkNav(cm, Direction.PREV))
+            .on("Shift-M", header.reviewedAndNext())
+            .on("Shift-N", maybePrevVimSearch(cm))
+            .on("Shift-P", getCommentManager().commentNav(cm, Direction.PREV))
+            .on("Shift-O", getCommentManager().openCloseAll(cm))
+            .on(
+                "I",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    switch (getIntraLineStatus()) {
+                      case OFF:
+                      case OK:
+                        toggleShowIntraline();
+                        break;
+                      case FAILURE:
+                      case TIMEOUT:
+                      default:
+                        break;
+                    }
+                  }
+                })
+            .on(
+                "','",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    prefsAction.show();
+                  }
+                })
+            .on(
+                "Shift-/",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    new ShowHelpCommand().onKeyPress(null);
+                  }
+                })
+            .on(
+                "Space",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    cm.vim().handleKey("<C-d>");
+                  }
+                })
+            .on(
+                "Shift-Space",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    cm.vim().handleKey("<C-u>");
+                  }
+                })
+            .on(
+                "Ctrl-F",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    cm.execCommand("find");
+                  }
+                })
+            .on(
+                "Ctrl-G",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    cm.execCommand("findNext");
+                  }
+                })
+            .on("Enter", maybeNextCmSearch(cm))
+            .on(
+                "Shift-Ctrl-G",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    cm.execCommand("findPrev");
+                  }
+                })
+            .on(
+                "Shift-Enter",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    cm.execCommand("findPrev");
+                  }
+                })
+            .on(
+                "Esc",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    cm.setCursor(cm.getCursor());
+                    cm.execCommand("clearSearch");
+                    cm.vim().handleEx("nohlsearch");
+                  }
+                })
+            .on(
+                "Ctrl-A",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    cm.execCommand("selectAll");
+                  }
+                })
+            .on(
+                "G O",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    Gerrit.display(PageLinks.toChangeQuery("status:open"));
+                  }
+                })
+            .on(
+                "G M",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    Gerrit.display(PageLinks.toChangeQuery("status:merged"));
+                  }
+                })
+            .on(
+                "G A",
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
+                  }
+                });
+    if (Gerrit.isSignedIn()) {
+      keyMap
+          .on(
+              "G I",
+              new Runnable() {
+                @Override
+                public void run() {
+                  Gerrit.display(PageLinks.MINE);
+                }
+              })
+          .on(
+              "G D",
+              new Runnable() {
+                @Override
+                public void run() {
+                  Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
+                }
+              })
+          .on(
+              "G C",
+              new Runnable() {
+                @Override
+                public void run() {
+                  Gerrit.display(PageLinks.toChangeQuery("has:draft"));
+                }
+              })
+          .on(
+              "G W",
+              new Runnable() {
+                @Override
+                public void run() {
+                  Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
+                }
+              })
+          .on(
+              "G S",
+              new Runnable() {
+                @Override
+                public void run() {
+                  Gerrit.display(PageLinks.toChangeQuery("is:starred"));
+                }
+              });
+    }
 
     if (revision.get() != 0) {
       cm.on("beforeSelectionChange", onSelectionChange(cm));
@@ -526,61 +578,61 @@
     keysNavigation.add(
         new NoOpKeyCommand(KeyCommand.M_SHIFT, 'n', PatchUtil.C.commentNext()),
         new NoOpKeyCommand(KeyCommand.M_SHIFT, 'p', PatchUtil.C.commentPrev()));
-    keysNavigation.add(
-        new NoOpKeyCommand(KeyCommand.M_CTRL, 'f', Gerrit.C.keySearch()));
+    keysNavigation.add(new NoOpKeyCommand(KeyCommand.M_CTRL, 'f', Gerrit.C.keySearch()));
 
     keysAction = new KeyCommandSet(Gerrit.C.sectionActions());
-    keysAction.add(new NoOpKeyCommand(0, KeyCodes.KEY_ENTER,
-        PatchUtil.C.expandComment()));
+    keysAction.add(new NoOpKeyCommand(0, KeyCodes.KEY_ENTER, PatchUtil.C.expandComment()));
     keysAction.add(new NoOpKeyCommand(0, 'o', PatchUtil.C.expandComment()));
-    keysAction.add(new NoOpKeyCommand(
-        KeyCommand.M_SHIFT, 'o', PatchUtil.C.expandAllCommentsOnCurrentLine()));
+    keysAction.add(
+        new NoOpKeyCommand(KeyCommand.M_SHIFT, 'o', PatchUtil.C.expandAllCommentsOnCurrentLine()));
     if (Gerrit.isSignedIn()) {
-      keysAction.add(new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          header.toggleReviewed().run();
-        }
-      });
-      keysAction.add(new NoOpKeyCommand(KeyCommand.M_CTRL | KeyCommand.M_ALT,
-          'e', Gerrit.C.keyEditor()));
+      keysAction.add(
+          new KeyCommand(0, 'r', PatchUtil.C.toggleReviewed()) {
+            @Override
+            public void onKeyPress(KeyPressEvent event) {
+              header.toggleReviewed().run();
+            }
+          });
+      keysAction.add(
+          new NoOpKeyCommand(KeyCommand.M_CTRL | KeyCommand.M_ALT, 'e', Gerrit.C.keyEditor()));
     }
-    keysAction.add(new KeyCommand(
-        KeyCommand.M_SHIFT, 'm', PatchUtil.C.markAsReviewedAndGoToNext()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        header.reviewedAndNext().run();
-      }
-    });
-    keysAction.add(new KeyCommand(0, 'a', PatchUtil.C.openReply()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        upToChange(true).run();
-      }
-    });
-    keysAction.add(new KeyCommand(0, ',', PatchUtil.C.showPreferences()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        prefsAction.show();
-      }
-    });
+    keysAction.add(
+        new KeyCommand(KeyCommand.M_SHIFT, 'm', PatchUtil.C.markAsReviewedAndGoToNext()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            header.reviewedAndNext().run();
+          }
+        });
+    keysAction.add(
+        new KeyCommand(0, 'a', PatchUtil.C.openReply()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            upToChange(true).run();
+          }
+        });
+    keysAction.add(
+        new KeyCommand(0, ',', PatchUtil.C.showPreferences()) {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            prefsAction.show();
+          }
+        });
     if (getIntraLineStatus() == DiffInfo.IntraLineStatus.OFF
         || getIntraLineStatus() == DiffInfo.IntraLineStatus.OK) {
-      keysAction.add(new KeyCommand(0, 'i', PatchUtil.C.toggleIntraline()) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          toggleShowIntraline();
-        }
-      });
+      keysAction.add(
+          new KeyCommand(0, 'i', PatchUtil.C.toggleIntraline()) {
+            @Override
+            public void onKeyPress(KeyPressEvent event) {
+              toggleShowIntraline();
+            }
+          });
     }
 
     if (Gerrit.isSignedIn()) {
       keysAction.add(new NoOpKeyCommand(0, 'c', PatchUtil.C.commentInsert()));
       keysComment = new KeyCommandSet(PatchUtil.C.commentEditorSet());
-      keysComment.add(new NoOpKeyCommand(KeyCommand.M_CTRL, 's',
-          PatchUtil.C.commentSaveDraft()));
-      keysComment.add(new NoOpKeyCommand(0, KeyCodes.KEY_ESCAPE,
-          PatchUtil.C.commentCancelEdit()));
+      keysComment.add(new NoOpKeyCommand(KeyCommand.M_CTRL, 's', PatchUtil.C.commentSaveDraft()));
+      keysComment.add(new NoOpKeyCommand(0, KeyCodes.KEY_ESCAPE, PatchUtil.C.commentCancelEdit()));
     } else {
       keysComment = null;
     }
@@ -598,20 +650,22 @@
 
   void setupSyntaxHighlighting() {
     if (prefs.syntaxHighlighting() && fileSize.compareTo(FileSize.SMALL) > 0) {
-      Scheduler.get().scheduleFixedDelay(new RepeatingCommand() {
-        @Override
-        public boolean execute() {
-          if (prefs.syntaxHighlighting() && isAttached()) {
-            setSyntaxHighlighting(prefs.syntaxHighlighting());
-          }
-          return false;
-        }
-      }, 250);
+      Scheduler.get()
+          .scheduleFixedDelay(
+              new RepeatingCommand() {
+                @Override
+                public boolean execute() {
+                  if (prefs.syntaxHighlighting() && isAttached()) {
+                    setSyntaxHighlighting(prefs.syntaxHighlighting());
+                  }
+                  return false;
+                }
+              },
+              250);
     }
   }
 
-  abstract CodeMirror newCm(
-      DiffInfo.FileMeta meta, String contents, Element parent);
+  abstract CodeMirror newCm(DiffInfo.FileMeta meta, String contents, Element parent);
 
   void render(DiffInfo diff) {
     header.setNoDiff(diff);
@@ -620,11 +674,9 @@
 
   void setShowLineNumbers(boolean b) {
     if (b) {
-      getDiffTable().addStyleName(
-          Resources.I.diffTableStyle().showLineNumbers());
+      getDiffTable().addStyleName(Resources.I.diffTableStyle().showLineNumbers());
     } else {
-      getDiffTable().removeStyleName(
-          Resources.I.diffTableStyle().showLineNumbers());
+      getDiffTable().removeStyleName(Resources.I.diffTableStyle().showLineNumbers());
     }
   }
 
@@ -647,14 +699,15 @@
   abstract void setSyntaxHighlighting(boolean b);
 
   void setContext(final int context) {
-    operation(new Runnable() {
-      @Override
-      public void run() {
-        skipManager.removeAll();
-        skipManager.render(context, diff);
-        updateRenderEntireFile();
-      }
-    });
+    operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            skipManager.removeAll();
+            skipManager.render(context, diff);
+            updateRenderEntireFile();
+          }
+        });
   }
 
   private int adjustCommitMessageLine(int line) {
@@ -777,7 +830,7 @@
     this.prefsAction = prefsAction;
   }
 
-  abstract void operation(final Runnable apply);
+  abstract void operation(Runnable apply);
 
   private Runnable upToChange(final boolean openReplyBox) {
     return new Runnable() {
@@ -786,17 +839,16 @@
         CallbackGroup group = new CallbackGroup();
         getCommentManager().saveAllDrafts(group);
         group.done();
-        group.addListener(new GerritCallback<Void>() {
-          @Override
-          public void onSuccess(Void result) {
-            String b = base != null ? String.valueOf(base.get()) : null;
-            String rev = String.valueOf(revision.get());
-            Gerrit.display(
-              PageLinks.toChange(changeId, b, rev),
-              new ChangeScreen(changeId, b, rev, openReplyBox,
-                  FileTable.Mode.REVIEW));
-          }
-        });
+        group.addListener(
+            new GerritCallback<Void>() {
+              @Override
+              public void onSuccess(Void result) {
+                String rev = String.valueOf(revision.get());
+                Gerrit.display(
+                    PageLinks.toChange(changeId, base.asString(), rev),
+                    new ChangeScreen(changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
+              }
+            });
       }
     };
   }
@@ -876,12 +928,11 @@
   }
 
   String getContentType(DiffInfo.FileMeta meta) {
-    if (prefs.syntaxHighlighting() && meta != null
-        && meta.contentType() != null) {
-     ModeInfo m = ModeInfo.findMode(meta.contentType(), path);
-     return m != null ? m.mime() : null;
-   }
-   return null;
+    if (prefs.syntaxHighlighting() && meta != null && meta.contentType() != null) {
+      ModeInfo m = ModeInfo.findMode(meta.contentType(), path);
+      return m != null ? m.mime() : null;
+    }
+    return null;
   }
 
   String getContentType() {
@@ -901,52 +952,54 @@
     String nextPath = header.getNextPath();
     if (nextPath != null) {
       DiffApi.diff(revision, nextPath)
-        .base(base)
-        .wholeFile()
-        .intraline(prefs.intralineDifference())
-        .ignoreWhitespace(prefs.ignoreWhitespace())
-        .get(new AsyncCallback<DiffInfo>() {
-          @Override
-          public void onSuccess(DiffInfo info) {
-            new ModeInjector()
-              .add(getContentType(info.metaA()))
-              .add(getContentType(info.metaB()))
-              .inject(CallbackGroup.<Void> emptyCallback());
-          }
+          .base(base.asPatchSetId())
+          .wholeFile()
+          .intraline(prefs.intralineDifference())
+          .ignoreWhitespace(prefs.ignoreWhitespace())
+          .get(
+              new AsyncCallback<DiffInfo>() {
+                @Override
+                public void onSuccess(DiffInfo info) {
+                  new ModeInjector()
+                      .add(getContentType(info.metaA()))
+                      .add(getContentType(info.metaB()))
+                      .inject(CallbackGroup.<Void>emptyCallback());
+                }
 
-          @Override
-          public void onFailure(Throwable caught) {
-          }
-        });
+                @Override
+                public void onFailure(Throwable caught) {}
+              });
     }
   }
 
   void reloadDiffInfo() {
     final int id = ++reloadVersionId;
     DiffApi.diff(revision, path)
-      .base(base)
-      .wholeFile()
-      .intraline(prefs.intralineDifference())
-      .ignoreWhitespace(prefs.ignoreWhitespace())
-      .get(new GerritCallback<DiffInfo>() {
-        @Override
-        public void onSuccess(DiffInfo diffInfo) {
-          if (id == reloadVersionId && isAttached()) {
-            diff = diffInfo;
-            operation(new Runnable() {
+        .base(base.asPatchSetId())
+        .wholeFile()
+        .intraline(prefs.intralineDifference())
+        .ignoreWhitespace(prefs.ignoreWhitespace())
+        .get(
+            new GerritCallback<DiffInfo>() {
               @Override
-              public void run() {
-                skipManager.removeAll();
-                getChunkManager().reset();
-                getDiffTable().scrollbar.removeDiffAnnotations();
-                setShowIntraline(prefs.intralineDifference());
-                render(diff);
-                skipManager.render(prefs.context(), diff);
+              public void onSuccess(DiffInfo diffInfo) {
+                if (id == reloadVersionId && isAttached()) {
+                  diff = diffInfo;
+                  operation(
+                      new Runnable() {
+                        @Override
+                        public void run() {
+                          skipManager.removeAll();
+                          getChunkManager().reset();
+                          getDiffTable().scrollbar.removeDiffAnnotations();
+                          setShowIntraline(prefs.intralineDifference());
+                          render(diff);
+                          skipManager.render(prefs.context(), diff);
+                        }
+                      });
+                }
               }
             });
-          }
-        }
-      });
   }
 
   private static FileSize bucketFileSize(DiffInfo diff) {
@@ -955,8 +1008,7 @@
     FileSize[] sizes = FileSize.values();
     for (int i = sizes.length - 1; 0 <= i; i--) {
       FileSize s = sizes[i];
-      if ((a != null && s.lines <= a.lines())
-          || (b != null && s.lines <= b.lines())) {
+      if ((a != null && s.lines <= a.lines()) || (b != null && s.lines <= b.lines())) {
         return s;
       }
     }
@@ -968,23 +1020,23 @@
   private GutterClickHandler onGutterClick(final CodeMirror cm) {
     return new GutterClickHandler() {
       @Override
-      public void handle(CodeMirror instance, final int line,
-          final String gutterClass, NativeEvent clickEvent) {
-        if (Element.as(clickEvent.getEventTarget())
-                .hasClassName(getLineNumberClassName())
+      public void handle(
+          CodeMirror instance, final int line, final String gutterClass, NativeEvent clickEvent) {
+        if (Element.as(clickEvent.getEventTarget()).hasClassName(getLineNumberClassName())
             && clickEvent.getButton() == NativeEvent.BUTTON_LEFT
             && !clickEvent.getMetaKey()
             && !clickEvent.getAltKey()
             && !clickEvent.getCtrlKey()
             && !clickEvent.getShiftKey()) {
           cm.setCursor(Pos.create(line));
-          Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-            @Override
-            public void execute() {
-              getCommentManager().newDraftOnGutterClick(
-                  cm, gutterClass, line + 1);
-            }
-          });
+          Scheduler.get()
+              .scheduleDeferred(
+                  new ScheduledCommand() {
+                    @Override
+                    public void execute() {
+                      getCommentManager().newDraftOnGutterClick(cm, gutterClass, line + 1);
+                    }
+                  });
         }
       }
     };
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
index 392ad2f..4650acf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.account.DiffPreferences;
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
@@ -28,12 +29,9 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
-
 import net.codemirror.lib.CodeMirror;
 
-/**
- * Base class for SideBySideTable2 and UnifiedTable2
- */
+/** Base class for SideBySideTable2 and UnifiedTable2 */
 abstract class DiffTable extends Composite {
   static {
     Resources.I.diffTableStyle().ensureInjected();
@@ -41,11 +39,17 @@
 
   interface Style extends CssResource {
     String fullscreen();
+
     String dark();
+
     String noIntraline();
+
     String range();
+
     String rangeHighlight();
+
     String diffHeader();
+
     String showLineNumbers();
   }
 
@@ -66,11 +70,13 @@
   private ChangeType changeType;
   Scrollbar scrollbar;
 
-  DiffTable(DiffScreen parent, PatchSet.Id base, PatchSet.Id revision, String path) {
-    patchSetSelectBoxA = new PatchSetSelectBox(
-        parent, DisplaySide.A, revision.getParentKey(), base, path);
-    patchSetSelectBoxB = new PatchSetSelectBox(
-        parent, DisplaySide.B, revision.getParentKey(), revision, path);
+  DiffTable(DiffScreen parent, DiffObject base, DiffObject revision, String path) {
+    patchSetSelectBoxA =
+        new PatchSetSelectBox(
+            parent, DisplaySide.A, revision.asPatchSetId().getParentKey(), base, path);
+    patchSetSelectBoxB =
+        new PatchSetSelectBox(
+            parent, DisplaySide.B, revision.asPatchSetId().getParentKey(), revision, path);
     PatchSetSelectBox.link(patchSetSelectBoxA, patchSetSelectBoxB);
 
     this.scrollbar = new Scrollbar(this);
@@ -98,23 +104,28 @@
     return changeType;
   }
 
-  void setUpBlameIconA(CodeMirror cm, boolean isBase, PatchSet.Id rev,
-      String path) {
+  void setUpBlameIconA(CodeMirror cm, boolean isBase, PatchSet.Id rev, String path) {
     patchSetSelectBoxA.setUpBlame(cm, isBase, rev, path);
   }
 
-  void setUpBlameIconB(CodeMirror cm, PatchSet.Id rev,
-      String path) {
+  void setUpBlameIconB(CodeMirror cm, PatchSet.Id rev, String path) {
     patchSetSelectBoxB.setUpBlame(cm, false, rev, path);
   }
 
-  void set(DiffPreferences prefs, JsArray<RevisionInfo> list, int parents, DiffInfo info,
-      boolean editExists, boolean current, boolean open, boolean binary) {
+  void set(
+      DiffPreferences prefs,
+      JsArray<RevisionInfo> list,
+      int parents,
+      DiffInfo info,
+      boolean editExists,
+      boolean current,
+      boolean open,
+      boolean binary) {
     this.changeType = info.changeType();
-    patchSetSelectBoxA.setUpPatchSetNav(list, parents, info.metaA(), editExists,
-        current, open, binary);
-    patchSetSelectBoxB.setUpPatchSetNav(list, parents, info.metaB(), editExists,
-        current, open, binary);
+    patchSetSelectBoxA.setUpPatchSetNav(
+        list, parents, info.metaA(), editExists, current, open, binary);
+    patchSetSelectBoxB.setUpPatchSetNav(
+        list, parents, info.metaB(), editExists, current, open, binary);
 
     JsArrayString hdr = info.diffHeader();
     if (hdr != null) {
@@ -123,9 +134,9 @@
         String s = hdr.get(i);
         if (!info.binary()
             && (s.startsWith("diff --git ")
-            || s.startsWith("index ")
-            || s.startsWith("+++ ")
-            || s.startsWith("--- "))) {
+                || s.startsWith("index ")
+                || s.startsWith("+++ ")
+                || s.startsWith("--- "))) {
           continue;
         }
         b.append(s).append('\n');
@@ -147,9 +158,7 @@
   void refresh() {
     if (header) {
       CodeMirror cm = getDiffScreen().getCmFromSide(DisplaySide.A);
-      diffHeaderText.getStyle().setMarginLeft(
-          cm.getGutterElement().getOffsetWidth(),
-          Unit.PX);
+      diffHeaderText.getStyle().setMarginLeft(cm.getGutterElement().getOffsetWidth(), Unit.PX);
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Direction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Direction.java
index 9b0b1c4..b1dd87e1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Direction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Direction.java
@@ -16,5 +16,6 @@
 
 /** Direction of traversal in an ordered list. */
 public enum Direction {
-  PREV, NEXT
+  PREV,
+  NEXT
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java
index c7ee678..6cee174 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DisplaySide.java
@@ -16,7 +16,8 @@
 
 /** Enum representing the side on a side-by-side view */
 public enum DisplaySide {
-  A, B;
+  A,
+  B;
 
   DisplaySide otherSide() {
     return this == A ? B : A;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
index d814a57..b86df0b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DraftBox.java
@@ -50,12 +50,12 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.globalkey.client.NpTextArea;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import net.codemirror.lib.CodeMirror;
 
 /** An HtmlPanel for displaying and editing a draft */
 class DraftBox extends CommentBox {
   interface Binder extends UiBinder<HTMLPanel, DraftBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private static final int INITIAL_LINES = 5;
@@ -100,39 +100,44 @@
     expandAll = expandAllComments;
     initWidget(uiBinder.createAndBindUi(this));
 
-    expandTimer = new Timer() {
-      @Override
-      public void run() {
-        expandText();
-      }
-    };
+    expandTimer =
+        new Timer() {
+          @Override
+          public void run() {
+            expandText();
+          }
+        };
     set(info);
 
-    header.addDomHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        if (!isEdit()) {
-          if (autoClosed && !isOpen()) {
-            setOpen(true);
-            setEdit(true);
-          } else {
-            setOpen(!isOpen());
+    header.addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            if (!isEdit()) {
+              if (autoClosed && !isOpen()) {
+                setOpen(true);
+                setEdit(true);
+              } else {
+                setOpen(!isOpen());
+              }
+            }
           }
-        }
-      }
-    }, ClickEvent.getType());
+        },
+        ClickEvent.getType());
 
-    addDomHandler(new DoubleClickHandler() {
-      @Override
-      public void onDoubleClick(DoubleClickEvent event) {
-        if (isEdit()) {
-          editArea.setFocus(true);
-        } else {
-          setOpen(true);
-          setEdit(true);
-        }
-      }
-    }, DoubleClickEvent.getType());
+    addDomHandler(
+        new DoubleClickHandler() {
+          @Override
+          public void onDoubleClick(DoubleClickEvent event) {
+            if (isEdit()) {
+              editArea.setFocus(true);
+            } else {
+              setOpen(true);
+              setEdit(true);
+            }
+          }
+        },
+        DoubleClickEvent.getType());
 
     initResizeHandler();
   }
@@ -143,8 +148,7 @@
     if (info.message() != null) {
       String msg = info.message().trim();
       summary.setInnerText(msg);
-      message.setHTML(linkProcessor.apply(
-          new SafeHtmlBuilder().append(msg).wikify()));
+      message.setHTML(linkProcessor.apply(new SafeHtmlBuilder().append(msg).wikify()));
     }
     comment = info;
   }
@@ -191,24 +195,24 @@
 
     setRangeHighlight(edit);
     if (edit) {
-      String msg = comment.message() != null
-          ? comment.message()
-          : "";
+      String msg = comment.message() != null ? comment.message() : "";
       editArea.setValue(msg);
       cancel.setVisible(!isNew());
       expandText();
       editAreaHeight = editArea.getOffsetHeight();
 
       final int len = msg.length();
-      Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-        @Override
-        public void execute() {
-          editArea.setFocus(true);
-          if (len > 0) {
-            editArea.setCursorPos(len);
-          }
-        }
-      });
+      Scheduler.get()
+          .scheduleDeferred(
+              new ScheduledCommand() {
+                @Override
+                public void execute() {
+                  editArea.setFocus(true);
+                  if (len > 0) {
+                    editArea.setCursorPos(len);
+                  }
+                }
+              });
     } else {
       expandTimer.cancel();
       resizeTimer.cancel();
@@ -292,31 +296,32 @@
 
     pendingGroup = group;
     final LocalComments lc = new LocalComments(psId);
-    GerritCallback<CommentInfo> cb = new GerritCallback<CommentInfo>() {
-      @Override
-      public void onSuccess(CommentInfo result) {
-        enableEdit(true);
-        pendingGroup = null;
-        set(result);
-        setEdit(false);
-        if (autoClosed) {
-          setOpen(false);
-        }
-        getCommentManager().setUnsaved(DraftBox.this, false);
-      }
+    GerritCallback<CommentInfo> cb =
+        new GerritCallback<CommentInfo>() {
+          @Override
+          public void onSuccess(CommentInfo result) {
+            enableEdit(true);
+            pendingGroup = null;
+            set(result);
+            setEdit(false);
+            if (autoClosed) {
+              setOpen(false);
+            }
+            getCommentManager().setUnsaved(DraftBox.this, false);
+          }
 
-      @Override
-      public void onFailure(Throwable e) {
-        enableEdit(true);
-        pendingGroup = null;
-        if (RestApi.isNotSignedIn(e)) {
-          CommentInfo saved = CommentInfo.copy(comment);
-          saved.message(editArea.getValue().trim());
-          lc.setInlineComment(saved);
-        }
-        super.onFailure(e);
-      }
-    };
+          @Override
+          public void onFailure(Throwable e) {
+            enableEdit(true);
+            pendingGroup = null;
+            if (RestApi.isNotSignedIn(e)) {
+              CommentInfo saved = CommentInfo.copy(comment);
+              saved.message(editArea.getValue().trim());
+              lc.setInlineComment(saved);
+            }
+            super.onFailure(e);
+          }
+        };
     if (input.id() == null) {
       CommentApi.createDraft(psId, input, group.add(cb));
     } else {
@@ -358,22 +363,24 @@
     } else {
       setEdit(false);
       pendingGroup = new CallbackGroup();
-      CommentApi.deleteDraft(psId, comment.id(),
-          pendingGroup.addFinal(new GerritCallback<JavaScriptObject>() {
-        @Override
-        public void onSuccess(JavaScriptObject result) {
-          pendingGroup = null;
-          removeUI();
-        }
-      }));
+      CommentApi.deleteDraft(
+          psId,
+          comment.id(),
+          pendingGroup.addFinal(
+              new GerritCallback<JavaScriptObject>() {
+                @Override
+                public void onSuccess(JavaScriptObject result) {
+                  pendingGroup = null;
+                  removeUI();
+                }
+              }));
     }
   }
 
   @UiHandler("editArea")
   void onKeyDown(KeyDownEvent e) {
     resizeTimer.cancel();
-    if ((e.isControlKeyDown() || e.isMetaKeyDown())
-        && !e.isAltKeyDown() && !e.isShiftKeyDown()) {
+    if ((e.isControlKeyDown() || e.isMetaKeyDown()) && !e.isAltKeyDown() && !e.isShiftKeyDown()) {
       switch (e.getNativeKeyCode()) {
         case 's':
         case 'S':
@@ -405,32 +412,37 @@
   }
 
   private void initResizeHandler() {
-    resizeTimer = new Timer() {
-      @Override
-      public void run() {
-        getCommentGroup().resize();
-      }
-    };
+    resizeTimer =
+        new Timer() {
+          @Override
+          public void run() {
+            getCommentGroup().resize();
+          }
+        };
 
-    addDomHandler(new MouseMoveHandler() {
-      @Override
-      public void onMouseMove(MouseMoveEvent event) {
-        int h = editArea.getOffsetHeight();
-        if (isEdit() && h != editAreaHeight) {
-          getCommentGroup().resize();
-          resizeTimer.scheduleRepeating(50);
-          editAreaHeight = h;
-        }
-      }
-    }, MouseMoveEvent.getType());
+    addDomHandler(
+        new MouseMoveHandler() {
+          @Override
+          public void onMouseMove(MouseMoveEvent event) {
+            int h = editArea.getOffsetHeight();
+            if (isEdit() && h != editAreaHeight) {
+              getCommentGroup().resize();
+              resizeTimer.scheduleRepeating(50);
+              editAreaHeight = h;
+            }
+          }
+        },
+        MouseMoveEvent.getType());
 
-    addDomHandler(new MouseUpHandler() {
-      @Override
-      public void onMouseUp(MouseUpEvent event) {
-        resizeTimer.cancel();
-        getCommentGroup().resize();
-      }
-    }, MouseUpEvent.getType());
+    addDomHandler(
+        new MouseUpHandler() {
+          @Override
+          public void onMouseUp(MouseUpEvent event) {
+            resizeTimer.cancel();
+            getCommentGroup().resize();
+          }
+        },
+        MouseUpEvent.getType());
   }
 
   private boolean isNew() {
@@ -442,8 +454,6 @@
     if (isNew()) {
       return msg.length() > 0;
     }
-    return !msg.equals(comment.message() != null
-        ? comment.message().trim()
-        : "");
+    return !msg.equals(comment.message() != null ? comment.message().trim() : "");
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/EditIterator.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/EditIterator.java
index ce3a562..4cf78c7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/EditIterator.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/EditIterator.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.diff;
 
 import com.google.gwt.core.client.JsArrayString;
-
 import net.codemirror.lib.Pos;
 
 /** An iterator for intraline edits */
@@ -36,9 +35,7 @@
     while (line < lines.length()) {
       int len = lines.get(line).length() - pos + 1; // + 1 for LF
       if (numOfChar < len) {
-        Pos at = Pos.create(
-            startLine + line,
-            numOfChar + pos);
+        Pos at = Pos.create(startLine + line, numOfChar + pos);
         pos += numOfChar;
         return at;
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
index f377038..a2ffb03f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.account.DiffPreferences;
@@ -58,18 +59,20 @@
 import com.google.gwtexpui.globalkey.client.KeyCommandSet;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
-
 import java.util.List;
 
 public class Header extends Composite {
   interface Binder extends UiBinder<HTMLPanel, Header> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
+
   static {
     Resources.I.style().ensureInjected();
   }
 
   private enum ReviewedState {
-    AUTO_REVIEW, LOADED
+    AUTO_REVIEW,
+    LOADED
   }
 
   @UiField CheckBox reviewed;
@@ -87,7 +90,7 @@
   @UiField Image preferences;
 
   private final KeyCommandSet keys;
-  private final PatchSet.Id base;
+  private final DiffObject base;
   private final PatchSet.Id patchSetId;
   private final String path;
   private final DiffView diffScreenType;
@@ -99,12 +102,17 @@
   private PreferencesAction prefsAction;
   private ReviewedState reviewedState;
 
-  Header(KeyCommandSet keys, PatchSet.Id base, PatchSet.Id patchSetId,
-      String path, DiffView diffSreenType, DiffPreferences prefs) {
+  Header(
+      KeyCommandSet keys,
+      DiffObject base,
+      DiffObject patchSetId,
+      String path,
+      DiffView diffSreenType,
+      DiffPreferences prefs) {
     initWidget(uiBinder.createAndBindUi(this));
     this.keys = keys;
     this.base = base;
-    this.patchSetId = patchSetId;
+    this.patchSetId = patchSetId.asPatchSetId();
     this.path = path;
     this.diffScreenType = diffSreenType;
     this.prefs = prefs;
@@ -113,15 +121,19 @@
       reviewed.getElement().getStyle().setVisibility(Visibility.HIDDEN);
     }
     SafeHtml.setInnerHTML(filePath, formatPath(path));
-    up.setTargetHistoryToken(PageLinks.toChange(
-        patchSetId.getParentKey(),
-        base != null ? base.getId() : null, patchSetId.getId()));
+    up.setTargetHistoryToken(
+        PageLinks.toChange(
+            patchSetId.asPatchSetId().getParentKey(),
+            base.asString(),
+            patchSetId.asPatchSetId().getId()));
   }
 
   public static SafeHtml formatPath(String path) {
     SafeHtmlBuilder b = new SafeHtmlBuilder();
     if (Patch.COMMIT_MSG.equals(path)) {
       return b.append(Util.C.commitMessage());
+    } else if (Patch.MERGE_LIST.equals(path)) {
+      return b.append(Util.C.mergeList());
     }
 
     int s = path.lastIndexOf('/') + 1;
@@ -145,35 +157,39 @@
 
   @Override
   protected void onLoad() {
-    DiffApi.list(patchSetId, base, new GerritCallback<NativeMap<FileInfo>>() {
-      @Override
-      public void onSuccess(NativeMap<FileInfo> result) {
-        files = result.values();
-        FileInfo.sortFileInfoByPath(files);
-        fileNumber.setInnerText(
-            Integer.toString(Natives.asList(files).indexOf(result.get(path)) + 1));
-        fileCount.setInnerText(Integer.toString(files.length()));
-      }
-    });
+    DiffApi.list(
+        patchSetId,
+        base.asPatchSetId(),
+        new GerritCallback<NativeMap<FileInfo>>() {
+          @Override
+          public void onSuccess(NativeMap<FileInfo> result) {
+            files = result.values();
+            FileInfo.sortFileInfoByPath(files);
+            fileNumber.setInnerText(
+                Integer.toString(Natives.asList(files).indexOf(result.get(path)) + 1));
+            fileCount.setInnerText(Integer.toString(files.length()));
+          }
+        });
 
     if (Gerrit.isSignedIn()) {
-      ChangeApi.revision(patchSetId).view("files")
-        .addParameterTrue("reviewed")
-        .get(new AsyncCallback<JsArrayString>() {
-            @Override
-            public void onSuccess(JsArrayString result) {
-              boolean b = Natives.asList(result).contains(path);
-              reviewed.setValue(b, false);
-              if (!b && reviewedState == ReviewedState.AUTO_REVIEW) {
-                postAutoReviewed();
-              }
-              reviewedState = ReviewedState.LOADED;
-            }
+      ChangeApi.revision(patchSetId)
+          .view("files")
+          .addParameterTrue("reviewed")
+          .get(
+              new AsyncCallback<JsArrayString>() {
+                @Override
+                public void onSuccess(JsArrayString result) {
+                  boolean b = Natives.asList(result).contains(path);
+                  reviewed.setValue(b, false);
+                  if (!b && reviewedState == ReviewedState.AUTO_REVIEW) {
+                    postAutoReviewed();
+                  }
+                  reviewedState = ReviewedState.LOADED;
+                }
 
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          });
+                @Override
+                public void onFailure(Throwable caught) {}
+              });
     }
   }
 
@@ -189,8 +205,7 @@
     project.setInnerText(info.project());
   }
 
-  void init(PreferencesAction pa, List<InlineHyperlink> links,
-      List<WebLinkInfo> webLinks) {
+  void init(PreferencesAction pa, List<InlineHyperlink> links, List<WebLinkInfo> webLinks) {
     prefsAction = pa;
     prefsAction.setPartner(preferences);
 
@@ -205,30 +220,29 @@
   @UiHandler("reviewed")
   void onValueChange(ValueChangeEvent<Boolean> event) {
     if (event.getValue()) {
-      reviewed().put(CallbackGroup.<ReviewInfo> emptyCallback());
+      reviewed().put(CallbackGroup.<ReviewInfo>emptyCallback());
     } else {
-      reviewed().delete(CallbackGroup.<ReviewInfo> emptyCallback());
+      reviewed().delete(CallbackGroup.<ReviewInfo>emptyCallback());
     }
   }
 
   private void postAutoReviewed() {
-    reviewed().background().put(new AsyncCallback<ReviewInfo>() {
-        @Override
-        public void onSuccess(ReviewInfo result) {
-          reviewed.setValue(true, false);
-        }
+    reviewed()
+        .background()
+        .put(
+            new AsyncCallback<ReviewInfo>() {
+              @Override
+              public void onSuccess(ReviewInfo result) {
+                reviewed.setValue(true, false);
+              }
 
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      });
+              @Override
+              public void onFailure(Throwable caught) {}
+            });
   }
 
   private RestApi reviewed() {
-    return ChangeApi.revision(patchSetId)
-        .view("files")
-        .id(path)
-        .view("reviewed");
+    return ChangeApi.revision(patchSetId).view("files").id(path).view("reviewed");
   }
 
   @UiHandler("preferences")
@@ -246,15 +260,16 @@
     if (info != null) {
       final String url = url(info);
       link.setTargetHistoryToken(url);
-      link.setTitle(PatchUtil.M.fileNameWithShortcutKey(
-          FileInfo.getFileName(info.path()),
-          Character.toString(key)));
-      KeyCommand k = new KeyCommand(0, key, help) {
-        @Override
-        public void onKeyPress(KeyPressEvent event) {
-          Gerrit.display(url);
-        }
-      };
+      link.setTitle(
+          PatchUtil.M.fileNameWithShortcutKey(
+              FileInfo.getFileName(info.path()), Character.toString(key)));
+      KeyCommand k =
+          new KeyCommand(0, key, help) {
+            @Override
+            public void onKeyPress(KeyPressEvent event) {
+              Gerrit.display(url);
+            }
+          };
       keys.add(k);
       if (link == prev) {
         hasPrev = true;
@@ -294,10 +309,8 @@
       nextInfo = curr;
       break;
     }
-    KeyCommand p = setupNav(prev, '[', PatchUtil.C.previousFileHelp(),
-        prevInfo);
-    KeyCommand n = setupNav(next, ']', PatchUtil.C.nextFileHelp(),
-        nextInfo);
+    KeyCommand p = setupNav(prev, '[', PatchUtil.C.previousFileHelp(), prevInfo);
+    KeyCommand n = setupNav(next, ']', PatchUtil.C.nextFileHelp(), nextInfo);
     if (p != null && n != null) {
       keys.pair(p, n);
     }
@@ -332,8 +345,7 @@
       default:
         return new Runnable() {
           @Override
-          public void run() {
-          }
+          public void run() {}
         };
     }
   }
@@ -359,8 +371,7 @@
       UIObject.setVisible(noDiff, false); // Don't bother showing "No Differences"
     } else {
       JsArray<Region> regions = diff.content();
-      boolean b = regions.length() == 0
-          || (regions.length() == 1 && regions.get(0).ab() != null);
+      boolean b = regions.length() == 0 || (regions.length() == 1 && regions.get(0).ab() != null);
       UIObject.setVisible(noDiff, b);
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java
index ac295e3..b04973a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/InsertCommentBubble.java
@@ -24,28 +24,28 @@
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.HTMLPanel;
 import com.google.gwt.user.client.ui.Image;
-
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.Rect;
 
 /** Bubble displayed near a selected region to create a comment. */
 class InsertCommentBubble extends Composite {
   interface Binder extends UiBinder<HTMLPanel, InsertCommentBubble> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   @UiField Image icon;
 
-  InsertCommentBubble(
-      final CommentManager commentManager,
-      final CodeMirror cm) {
+  InsertCommentBubble(final CommentManager commentManager, final CodeMirror cm) {
     initWidget(uiBinder.createAndBindUi(this));
-    addDomHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        setVisible(false);
-        commentManager.newDraftCallback(cm).run();
-      }
-    }, ClickEvent.getType());
+    addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            setVisible(false);
+            commentManager.newDraftCallback(cm).run();
+          }
+        },
+        ClickEvent.getType());
   }
 
   void position(Rect r) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
index 96128f3..fc83a14 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/LineMapper.java
@@ -78,39 +78,28 @@
   /**
    * Helper method to retrieve the line number on the other side.
    *
-   * Given a line number on one side, performs a binary search in the lineMap
-   * to find the corresponding LineGap record.
+   * <p>Given a line number on one side, performs a binary search in the lineMap to find the
+   * corresponding LineGap record.
    *
-   * A LineGap records gap information from the start of an actual gap up to
-   * the start of the next gap. In the following example,
-   * lineMapAtoB will have LineGap: {start: 1, end: -1, delta: 3}
-   * (end set to -1 to represent a dummy gap of length zero. The binary search
-   * only looks at start so setting it to -1 has no effect here.)
-   * lineMapBtoA will have LineGap: {start: 1, end: 3, delta: -3}
-   * These LineGaps control lines between 1 and 5.
+   * <p>A LineGap records gap information from the start of an actual gap up to the start of the
+   * next gap. In the following example, lineMapAtoB will have LineGap: {start: 1, end: -1, delta:
+   * 3} (end set to -1 to represent a dummy gap of length zero. The binary search only looks at
+   * start so setting it to -1 has no effect here.) lineMapBtoA will have LineGap: {start: 1, end:
+   * 3, delta: -3} These LineGaps control lines between 1 and 5.
    *
-   * The "delta" is computed as the number to add on our side to get the line
-   * number on the other side given a line after the actual gap, so the result
-   * will be (line + delta). All lines within the actual gap (1 to 3) are
-   * considered corresponding to the last line above the region on the other
-   * side, which is 0 in this case. For these lines, we do (end + delta).
+   * <p>The "delta" is computed as the number to add on our side to get the line number on the other
+   * side given a line after the actual gap, so the result will be (line + delta). All lines within
+   * the actual gap (1 to 3) are considered corresponding to the last line above the region on the
+   * other side, which is 0 in this case. For these lines, we do (end + delta).
    *
-   * For example, to get the line number on the left corresponding to 1 on the
-   * right (lineOnOther(REVISION, 1)), the method looks up in lineMapBtoA,
-   * finds the "delta" to be -3, and returns 3 + (-3) = 0 since 1 falls in the
-   * actual gap. On the other hand, the line corresponding to 5 on the right
-   * will be 5 + (-3) = 2, since 5 is in the region after the gap (but still
+   * <p>For example, to get the line number on the left corresponding to 1 on the right
+   * (lineOnOther(REVISION, 1)), the method looks up in lineMapBtoA, finds the "delta" to be -3, and
+   * returns 3 + (-3) = 0 since 1 falls in the actual gap. On the other hand, the line corresponding
+   * to 5 on the right will be 5 + (-3) = 2, since 5 is in the region after the gap (but still
    * controlled by the current LineGap).
    *
-   * PARENT REVISION
-   *   0   |   0
-   *   -   |   1 \                      \
-   *   -   |   2 | Actual insertion gap |
-   *   -   |   3 /                      | Region controlled by one LineGap
-   *   1   |   4   <- delta = 4 - 1 = 3 |
-   *   2   |   5                        /
-   *   -   |   6
-   *      ...
+   * <p>PARENT REVISION 0 | 0 - | 1 \ \ - | 2 | Actual insertion gap | - | 3 / | Region controlled
+   * by one LineGap 1 | 4 <- delta = 4 - 1 = 3 | 2 | 5 / - | 6 ...
    */
   LineOnOtherInfo lineOnOther(DisplaySide mySide, int line) {
     List<LineGap> lineGaps = gapList(mySide);
@@ -204,14 +193,14 @@
   }
 
   /**
-   * Helper class to record line gap info and assist in calculation of line
-   * number on the other side.
+   * Helper class to record line gap info and assist in calculation of line number on the other
+   * side.
    *
-   * For a mapping from A to B, where A is the side with an insertion:
+   * <p>For a mapping from A to B, where A is the side with an insertion:
+   *
    * @field start The start line of the insertion in A.
    * @field end The exclusive end line of the insertion in A.
-   * @field delta The offset added to A to get the line number in B calculated
-   *              from end.
+   * @field delta The offset added to A to get the line number in B calculated from end.
    */
   private static class LineGap implements Comparable<LineGap> {
     private final int start;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java
index 969b861..584232d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/NoOpKeyCommand.java
@@ -24,6 +24,5 @@
   }
 
   @Override
-  public void onKeyPress(KeyPressEvent event) {
-  }
+  public void onKeyPress(KeyPressEvent event) {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
index bc37abb..822bc74 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.blame.BlameInfo;
@@ -43,14 +44,13 @@
 import com.google.gwt.user.client.ui.ImageResourceRenderer;
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtorm.client.KeyUtil;
-
-import net.codemirror.lib.CodeMirror;
-
 import java.util.List;
+import net.codemirror.lib.CodeMirror;
 
 /** HTMLPanel to select among patch sets */
 class PatchSetSelectBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, PatchSetSelectBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface BoxStyle extends CssResource {
@@ -67,14 +67,11 @@
   private String path;
   private Change.Id changeId;
   private PatchSet.Id revision;
-  private PatchSet.Id idActive;
+  private DiffObject idActive;
   private PatchSetSelectBox other;
 
-  PatchSetSelectBox(DiffScreen parent,
-      DisplaySide side,
-      Change.Id changeId,
-      PatchSet.Id revision,
-      String path) {
+  PatchSetSelectBox(
+      DiffScreen parent, DisplaySide side, Change.Id changeId, DiffObject diffObject, String path) {
     initWidget(uiBinder.createAndBindUi(this));
     icon.setTitle(PatchUtil.C.addFileCommentToolTip());
     icon.addStyleName(Gerrit.RESOURCES.css().link());
@@ -83,29 +80,35 @@
     this.side = side;
     this.sideA = side == DisplaySide.A;
     this.changeId = changeId;
-    this.revision = revision;
-    this.idActive = (sideA && revision == null) ? null : revision;
+    this.revision = diffObject.asPatchSetId();
+    this.idActive = diffObject;
     this.path = path;
   }
 
-  void setUpPatchSetNav(JsArray<RevisionInfo> list, int parents, DiffInfo.FileMeta meta,
-      boolean editExists, boolean current, boolean open, boolean binary) {
+  void setUpPatchSetNav(
+      JsArray<RevisionInfo> list,
+      int parents,
+      DiffInfo.FileMeta meta,
+      boolean editExists,
+      boolean current,
+      boolean open,
+      boolean binary) {
     InlineHyperlink selectedLink = null;
     if (sideA) {
       if (parents <= 1) {
-        InlineHyperlink link = createLink(PatchUtil.C.patchBase(), null);
+        InlineHyperlink link = createLink(PatchUtil.C.patchBase(), DiffObject.base());
         linkPanel.add(link);
         selectedLink = link;
       } else {
         for (int i = parents; i > 0; i--) {
           PatchSet.Id id = new PatchSet.Id(changeId, -i);
-          InlineHyperlink link = createLink(Util.M.diffBaseParent(i), id);
+          InlineHyperlink link = createLink(Util.M.diffBaseParent(i), DiffObject.patchSet(id));
           linkPanel.add(link);
           if (revision != null && id.equals(revision)) {
             selectedLink = link;
           }
         }
-        InlineHyperlink link = createLink(Util.C.autoMerge(), null);
+        InlineHyperlink link = createLink(Util.C.autoMerge(), DiffObject.autoMerge());
         linkPanel.add(link);
         if (selectedLink == null) {
           selectedLink = link;
@@ -114,8 +117,8 @@
     }
     for (int i = 0; i < list.length(); i++) {
       RevisionInfo r = list.get(i);
-      InlineHyperlink link = createLink(r.id(),
-          new PatchSet.Id(changeId, r._number()));
+      InlineHyperlink link =
+          createLink(r.id(), DiffObject.patchSet(new PatchSet.Id(changeId, r._number())));
       linkPanel.add(link);
       if (revision != null && r.id().equals(revision.getId())) {
         selectedLink = link;
@@ -128,12 +131,11 @@
     if (meta == null) {
       return;
     }
-    if (!Patch.COMMIT_MSG.equals(path)) {
+    if (!Patch.isMagic(path)) {
       linkPanel.add(createDownloadLink());
     }
-    if (!binary && open && idActive != null && Gerrit.isSignedIn()) {
-      if ((editExists && idActive.get() == 0)
-          || (!editExists && current)) {
+    if (!binary && open && !idActive.isBaseOrAutoMerge() && Gerrit.isSignedIn()) {
+      if ((editExists && idActive.isEdit()) || (!editExists && current)) {
         linkPanel.add(createEditIcon());
       }
     }
@@ -145,44 +147,46 @@
     }
   }
 
-  void setUpBlame(final CodeMirror cm, final boolean isBase,
-      final PatchSet.Id rev, final String path) {
-    if (!Patch.COMMIT_MSG.equals(path) && Gerrit.isSignedIn()
-        && Gerrit.info().change().allowBlame()) {
+  void setUpBlame(
+      final CodeMirror cm, final boolean isBase, final PatchSet.Id rev, final String path) {
+    if (!Patch.isMagic(path) && Gerrit.isSignedIn() && Gerrit.info().change().allowBlame()) {
       Anchor blameIcon = createBlameIcon();
-      blameIcon.addClickHandler(new ClickHandler() {
-        @Override
-        public void onClick(ClickEvent clickEvent) {
-          if (cm.extras().getBlameInfo() != null) {
-            cm.extras().toggleAnnotation();
-          } else {
-            ChangeApi.blame(rev, path, isBase)
-              .get(new GerritCallback<JsArray<BlameInfo>>() {
+      blameIcon.addClickHandler(
+          new ClickHandler() {
+            @Override
+            public void onClick(ClickEvent clickEvent) {
+              if (cm.extras().getBlameInfo() != null) {
+                cm.extras().toggleAnnotation();
+              } else {
+                ChangeApi.blame(rev, path, isBase)
+                    .get(
+                        new GerritCallback<JsArray<BlameInfo>>() {
 
-                @Override
-                public void onSuccess(JsArray<BlameInfo> lines) {
-                  cm.extras().toggleAnnotation(lines);
-                }
-              });
-          }
-        }
-      });
+                          @Override
+                          public void onSuccess(JsArray<BlameInfo> lines) {
+                            cm.extras().toggleAnnotation(lines);
+                          }
+                        });
+              }
+            }
+          });
       linkPanel.add(blameIcon);
     }
   }
 
   private Widget createEditIcon() {
-    PatchSet.Id id = (idActive == null) ? other.idActive : idActive;
-    Anchor anchor = new Anchor(
-        new ImageResourceRenderer().render(Gerrit.RESOURCES.edit()),
-        "#" + Dispatcher.toEditScreen(id, path));
+    PatchSet.Id id =
+        idActive.isBaseOrAutoMerge() ? other.idActive.asPatchSetId() : idActive.asPatchSetId();
+    Anchor anchor =
+        new Anchor(
+            new ImageResourceRenderer().render(Gerrit.RESOURCES.edit()),
+            "#" + Dispatcher.toEditScreen(id, path));
     anchor.setTitle(PatchUtil.C.edit());
     return anchor;
   }
 
   private Anchor createBlameIcon() {
-    Anchor anchor = new Anchor(
-        new ImageResourceRenderer().render(Gerrit.RESOURCES.blame()));
+    Anchor anchor = new Anchor(new ImageResourceRenderer().render(Gerrit.RESOURCES.blame()));
     anchor.setTitle(PatchUtil.C.blame());
     return anchor;
   }
@@ -192,27 +196,29 @@
     b.other = a;
   }
 
-  private InlineHyperlink createLink(String label, PatchSet.Id id) {
+  private InlineHyperlink createLink(String label, DiffObject id) {
     assert other != null;
     if (sideA) {
-      assert other.idActive != null;
+      assert !other.idActive.isBaseOrAutoMerge();
     }
-    PatchSet.Id diffBase = sideA ? id : other.idActive;
-    PatchSet.Id revision = sideA ? other.idActive : id;
+    DiffObject diffBase = sideA ? id : other.idActive;
+    DiffObject revision = sideA ? other.idActive : id;
 
-    return new InlineHyperlink(label,
+    return new InlineHyperlink(
+        label,
         parent.isSideBySide()
-            ? Dispatcher.toSideBySide(diffBase, revision, path)
-            : Dispatcher.toUnified(diffBase, revision, path));
+            ? Dispatcher.toSideBySide(diffBase, revision.asPatchSetId(), path)
+            : Dispatcher.toUnified(diffBase, revision.asPatchSetId(), path));
   }
 
   private Anchor createDownloadLink() {
-    PatchSet.Id id = (idActive == null) ? other.idActive : idActive;
-    String sideURL = (idActive == null) ? "1" : "0";
+    DiffObject diffObject = idActive.isBaseOrAutoMerge() ? other.idActive : idActive;
+    String sideURL = idActive.isBaseOrAutoMerge() ? "1" : "0";
     String base = GWT.getHostPageBaseURL() + "cat/";
-    Anchor anchor = new Anchor(
-        new ImageResourceRenderer().render(Gerrit.RESOURCES.downloadIcon()),
-        base + KeyUtil.encode(id + "," + path) + "^" + sideURL);
+    Anchor anchor =
+        new Anchor(
+            new ImageResourceRenderer().render(Gerrit.RESOURCES.downloadIcon()),
+            base + KeyUtil.encode(diffObject.asPatchSetId() + "," + path) + "^" + sideURL);
     anchor.setTitle(PatchUtil.C.download());
     return anchor;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java
index 3f7a0ab..2d4a4c4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesAction.java
@@ -53,20 +53,22 @@
     popup.setStyleName(current.style.dialog());
     popup.add(current);
     popup.addAutoHidePartner(partner.getElement());
-    popup.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        view.getCmFromSide(DisplaySide.B).focus();
-        popup = null;
-        current = null;
-      }
-    });
-    popup.setPopupPositionAndShow(new PositionCallback() {
-      @Override
-      public void setPosition(int offsetWidth, int offsetHeight) {
-        popup.setPopupPosition(300, 120);
-      }
-    });
+    popup.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            view.getCmFromSide(DisplaySide.B).focus();
+            popup = null;
+            current = null;
+          }
+        });
+    popup.setPopupPositionAndShow(
+        new PositionCallback() {
+          @Override
+          public void setPosition(int offsetWidth, int offsetHeight) {
+            popup.setPopupPosition(300, 120);
+          }
+        });
     current.setFocus(true);
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
index ef1d4bd..4d781ea 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
@@ -57,17 +57,16 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 import com.google.gwt.user.client.ui.ToggleButton;
 import com.google.gwt.user.client.ui.UIObject;
-
+import java.util.Objects;
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.mode.ModeInfo;
 import net.codemirror.mode.ModeInjector;
 import net.codemirror.theme.ThemeLoader;
 
-import java.util.Objects;
-
 /** Displays current diff preferences. */
 public class PreferencesBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, PreferencesBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   public interface Style extends CssResource {
@@ -134,32 +133,34 @@
     save.setVisible(Gerrit.isSignedIn());
 
     if (view != null) {
-      addDomHandler(new KeyDownHandler() {
-        @Override
-        public void onKeyDown(KeyDownEvent event) {
-          if (event.getNativeKeyCode() == KEY_ESCAPE
-              || event.getNativeKeyCode() == ',') {
-            close();
-          }
-        }
-      }, KeyDownEvent.getType());
+      addDomHandler(
+          new KeyDownHandler() {
+            @Override
+            public void onKeyDown(KeyDownEvent event) {
+              if (event.getNativeKeyCode() == KEY_ESCAPE || event.getNativeKeyCode() == ',') {
+                close();
+              }
+            }
+          },
+          KeyDownEvent.getType());
 
-      updateContextTimer = new Timer() {
-        @Override
-        public void run() {
-          if (prefs.context() == WHOLE_FILE_CONTEXT) {
-            contextEntireFile.setValue(true);
-          }
-          if (view.canRenderEntireFile(prefs)) {
-            renderEntireFile.setEnabled(true);
-            renderEntireFile.setValue(prefs.renderEntireFile());
-          } else {
-            renderEntireFile.setValue(false);
-            renderEntireFile.setEnabled(false);
-          }
-          view.setContext(prefs.context());
-        }
-      };
+      updateContextTimer =
+          new Timer() {
+            @Override
+            public void run() {
+              if (prefs.context() == WHOLE_FILE_CONTEXT) {
+                contextEntireFile.setValue(true);
+              }
+              if (view.canRenderEntireFile(prefs)) {
+                renderEntireFile.setEnabled(true);
+                renderEntireFile.setValue(prefs.renderEntireFile());
+              } else {
+                renderEntireFile.setValue(false);
+                renderEntireFile.setEnabled(false);
+              }
+              view.setContext(prefs.context());
+            }
+          };
     }
   }
 
@@ -187,8 +188,8 @@
     emptyPane.setValue(!prefs.hideEmptyPane());
     if (view != null) {
       leftSide.setValue(view.getDiffTable().isVisibleA());
-      leftSide.setEnabled(!(prefs.hideEmptyPane()
-          && view.getDiffTable().getChangeType() == ChangeType.ADDED));
+      leftSide.setEnabled(
+          !(prefs.hideEmptyPane() && view.getDiffTable().getChangeType() == ChangeType.ADDED));
     } else {
       UIObject.setVisible(leftSideLabel, false);
       leftSide.setVisible(false);
@@ -251,8 +252,8 @@
 
   @UiHandler("ignoreWhitespace")
   void onIgnoreWhitespace(@SuppressWarnings("unused") ChangeEvent e) {
-    prefs.ignoreWhitespace(Whitespace.valueOf(
-        ignoreWhitespace.getValue(ignoreWhitespace.getSelectedIndex())));
+    prefs.ignoreWhitespace(
+        Whitespace.valueOf(ignoreWhitespace.getValue(ignoreWhitespace.getSelectedIndex())));
     if (view != null) {
       view.reloadDiffInfo();
     }
@@ -320,15 +321,16 @@
     if (v != null && v.length() > 0) {
       prefs.tabSize(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
-        view.operation(new Runnable() {
-          @Override
-          public void run() {
-            int v = prefs.tabSize();
-            for (CodeMirror cm : view.getCms()) {
-              cm.setOption("tabSize", v);
-            }
-          }
-        });
+        view.operation(
+            new Runnable() {
+              @Override
+              public void run() {
+                int v = prefs.tabSize();
+                for (CodeMirror cm : view.getCms()) {
+                  cm.setOption("tabSize", v);
+                }
+              }
+            });
       }
     }
   }
@@ -339,15 +341,17 @@
     if (v != null && v.length() > 0) {
       prefs.lineLength(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
-        view.operation(new Runnable() {
-          @Override
-          public void run() {
-            view.setLineLength(prefs.lineLength());
-          }
-        });
+        view.operation(
+            new Runnable() {
+              @Override
+              public void run() {
+                view.setLineLength(prefs.lineLength());
+              }
+            });
       }
     }
   }
+
   @UiHandler("expandAllComments")
   void onExpandAllComments(ValueChangeEvent<Boolean> e) {
     prefs.expandAllComments(e.getValue());
@@ -363,10 +367,8 @@
       // A negative value hides the cursor entirely:
       // don't let user shoot himself in the foot.
       prefs.cursorBlinkRate(Math.max(0, Integer.parseInt(v)));
-      view.getCmFromSide(DisplaySide.A).setOption("cursorBlinkRate",
-          prefs.cursorBlinkRate());
-      view.getCmFromSide(DisplaySide.B).setOption("cursorBlinkRate",
-          prefs.cursorBlinkRate());
+      view.getCmFromSide(DisplaySide.A).setOption("cursorBlinkRate", prefs.cursorBlinkRate());
+      view.getCmFromSide(DisplaySide.B).setOption("cursorBlinkRate", prefs.cursorBlinkRate());
     }
   }
 
@@ -449,22 +451,26 @@
     final String mode = getSelectedMode();
     prefs.syntaxHighlighting(true);
     syntaxHighlighting.setValue(true, false);
-    new ModeInjector().add(mode).inject(new GerritCallback<Void>() {
-      @Override
-      public void onSuccess(Void result) {
-        if (prefs.syntaxHighlighting()
-            && Objects.equals(mode, getSelectedMode())
-            && view.isAttached()) {
-          view.operation(new Runnable() {
-            @Override
-            public void run() {
-              view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
-              view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
-            }
-          });
-        }
-      }
-    });
+    new ModeInjector()
+        .add(mode)
+        .inject(
+            new GerritCallback<Void>() {
+              @Override
+              public void onSuccess(Void result) {
+                if (prefs.syntaxHighlighting()
+                    && Objects.equals(mode, getSelectedMode())
+                    && view.isAttached()) {
+                  view.operation(
+                      new Runnable() {
+                        @Override
+                        public void run() {
+                          view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
+                          view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
+                        }
+                      });
+                }
+              }
+            });
   }
 
   private String getSelectedMode() {
@@ -476,15 +482,16 @@
   void onWhitespaceErrors(ValueChangeEvent<Boolean> e) {
     prefs.showWhitespaceErrors(e.getValue());
     if (view != null) {
-      view.operation(new Runnable() {
-        @Override
-        public void run() {
-          boolean s = prefs.showWhitespaceErrors();
-          for (CodeMirror cm : view.getCms()) {
-            cm.setOption("showTrailingSpace", s);
-          }
-        }
-      });
+      view.operation(
+          new Runnable() {
+            @Override
+            public void run() {
+              boolean s = prefs.showWhitespaceErrors();
+              for (CodeMirror cm : view.getCms()) {
+                cm.setOption("showTrailingSpace", s);
+              }
+            }
+          });
     }
   }
 
@@ -499,19 +506,15 @@
   @UiHandler("matchBrackets")
   void onMatchBrackets(ValueChangeEvent<Boolean> e) {
     prefs.matchBrackets(e.getValue());
-    view.getCmFromSide(DisplaySide.A).setOption("matchBrackets",
-        prefs.matchBrackets());
-    view.getCmFromSide(DisplaySide.B).setOption("matchBrackets",
-        prefs.matchBrackets());
+    view.getCmFromSide(DisplaySide.A).setOption("matchBrackets", prefs.matchBrackets());
+    view.getCmFromSide(DisplaySide.B).setOption("matchBrackets", prefs.matchBrackets());
   }
 
   @UiHandler("lineWrapping")
   void onLineWrapping(ValueChangeEvent<Boolean> e) {
     prefs.lineWrapping(e.getValue());
-    view.getCmFromSide(DisplaySide.A).setOption("lineWrapping",
-        prefs.lineWrapping());
-    view.getCmFromSide(DisplaySide.B).setOption("lineWrapping",
-        prefs.lineWrapping());
+    view.getCmFromSide(DisplaySide.A).setOption("lineWrapping", prefs.lineWrapping());
+    view.getCmFromSide(DisplaySide.B).setOption("lineWrapping", prefs.lineWrapping());
   }
 
   @UiHandler("skipDeleted")
@@ -537,22 +540,25 @@
     final Theme newTheme = getSelectedTheme();
     prefs.theme(newTheme);
     if (view != null) {
-      ThemeLoader.loadTheme(newTheme, new GerritCallback<Void>() {
-        @Override
-        public void onSuccess(Void result) {
-          view.operation(new Runnable() {
+      ThemeLoader.loadTheme(
+          newTheme,
+          new GerritCallback<Void>() {
             @Override
-            public void run() {
-              if (getSelectedTheme() == newTheme && isAttached()) {
-                String t = newTheme.name().toLowerCase();
-                view.getCmFromSide(DisplaySide.A).setOption("theme", t);
-                view.getCmFromSide(DisplaySide.B).setOption("theme", t);
-                view.setThemeStyles(newTheme.isDark());
-              }
+            public void onSuccess(Void result) {
+              view.operation(
+                  new Runnable() {
+                    @Override
+                    public void run() {
+                      if (getSelectedTheme() == newTheme && isAttached()) {
+                        String t = newTheme.name().toLowerCase();
+                        view.getCmFromSide(DisplaySide.A).setOption("theme", t);
+                        view.getCmFromSide(DisplaySide.B).setOption("theme", t);
+                        view.setThemeStyles(newTheme.isDark());
+                      }
+                    }
+                  });
             }
           });
-        }
-      });
     }
   }
 
@@ -567,14 +573,16 @@
 
   @UiHandler("save")
   void onSave(@SuppressWarnings("unused") ClickEvent e) {
-    AccountApi.putDiffPreferences(prefs, new GerritCallback<DiffPreferences>() {
-      @Override
-      public void onSuccess(DiffPreferences result) {
-        DiffPreferencesInfo p = new DiffPreferencesInfo();
-        result.copyTo(p);
-        Gerrit.setDiffPreferences(p);
-      }
-    });
+    AccountApi.putDiffPreferences(
+        prefs,
+        new GerritCallback<DiffPreferences>() {
+          @Override
+          public void onSuccess(DiffPreferences result) {
+            DiffPreferencesInfo p = new DiffPreferencesInfo();
+            result.copyTo(p);
+            Gerrit.setDiffPreferences(p);
+          }
+        });
     if (view != null) {
       close();
     }
@@ -606,18 +614,11 @@
   }
 
   private void initIgnoreWhitespace() {
+    ignoreWhitespace.addItem(PatchUtil.C.whitespaceIGNORE_NONE(), IGNORE_NONE.name());
+    ignoreWhitespace.addItem(PatchUtil.C.whitespaceIGNORE_TRAILING(), IGNORE_TRAILING.name());
     ignoreWhitespace.addItem(
-        PatchUtil.C.whitespaceIGNORE_NONE(),
-        IGNORE_NONE.name());
-    ignoreWhitespace.addItem(
-        PatchUtil.C.whitespaceIGNORE_TRAILING(),
-        IGNORE_TRAILING.name());
-    ignoreWhitespace.addItem(
-        PatchUtil.C.whitespaceIGNORE_LEADING_AND_TRAILING(),
-        IGNORE_LEADING_AND_TRAILING.name());
-    ignoreWhitespace.addItem(
-        PatchUtil.C.whitespaceIGNORE_ALL(),
-        IGNORE_ALL.name());
+        PatchUtil.C.whitespaceIGNORE_LEADING_AND_TRAILING(), IGNORE_LEADING_AND_TRAILING.name());
+    ignoreWhitespace.addItem(PatchUtil.C.whitespaceIGNORE_ALL(), IGNORE_ALL.name());
   }
 
   private void initMode() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
index dc2e3a2..ce698027 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PublishedBox.java
@@ -44,6 +44,7 @@
 /** An HtmlPanel for displaying a published comment */
 class PublishedBox extends CommentBox {
   interface Binder extends UiBinder<HTMLPanel, PublishedBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface Style extends CssResource {
@@ -90,20 +91,21 @@
     }
 
     initWidget(uiBinder.createAndBindUi(this));
-    header.addDomHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        setOpen(!isOpen());
-      }
-    }, ClickEvent.getType());
+    header.addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            setOpen(!isOpen());
+          }
+        },
+        ClickEvent.getType());
 
     name.setInnerText(authorName(info));
     date.setInnerText(FormatUtil.shortFormatDayTime(info.updated()));
     if (info.message() != null) {
       String msg = info.message().trim();
       summary.setInnerText(msg);
-      message.setInnerSafeHtml(clp.apply(
-          new SafeHtmlBuilder().append(msg).wikify()));
+      message.setInnerSafeHtml(clp.apply(new SafeHtmlBuilder().append(msg).wikify()));
       ApiGlue.fireEvent("comment", message);
     }
 
@@ -154,9 +156,7 @@
     if (quote) {
       commentReply.message(ReplyBox.quote(comment.message()));
     }
-    getCommentManager().addDraftBox(
-      displaySide,
-      commentReply).setEdit(true);
+    getCommentManager().addDraftBox(displaySide, commentReply).setEdit(true);
   }
 
   void doReply() {
@@ -193,7 +193,9 @@
       done.setEnabled(false);
       CommentInfo input = CommentInfo.createReply(comment);
       input.message(PatchUtil.C.cannedReplyDone());
-      CommentApi.createDraft(psId, input,
+      CommentApi.createDraft(
+          psId,
+          input,
           new GerritCallback<CommentInfo>() {
             @Override
             public void onSuccess(CommentInfo result) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java
index 2723374..e590333 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Resources.java
@@ -22,15 +22,22 @@
 interface Resources extends ClientBundle {
   Resources I = GWT.create(Resources.class);
 
-  @Source("CommentBox.css") CommentBox.Style style();
-  @Source("Scrollbar.css") Scrollbar.Style scrollbarStyle();
-  @Source("DiffTable.css") DiffTable.Style diffTableStyle();
+  @Source("CommentBox.css")
+  CommentBox.Style style();
 
-  /**
-   * tango icon library (public domain):
-   * http://tango.freedesktop.org/Tango_Icon_Library
-   */
-  @Source("goPrev.png") ImageResource goPrev();
-  @Source("goNext.png") ImageResource goNext();
-  @Source("goUp.png") ImageResource goUp();
+  @Source("Scrollbar.css")
+  Scrollbar.Style scrollbarStyle();
+
+  @Source("DiffTable.css")
+  DiffTable.Style diffTableStyle();
+
+  /** tango icon library (public domain): http://tango.freedesktop.org/Tango_Icon_Library */
+  @Source("goPrev.png")
+  ImageResource goPrev();
+
+  @Source("goNext.png")
+  ImageResource goNext();
+
+  @Source("goUp.png")
+  ImageResource goUp();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java
index 13f58db..35e3e7d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollSynchronizer.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.diff;
 
 import com.google.gwt.user.client.Timer;
-
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.ScrollInfo;
 
@@ -28,9 +27,7 @@
   private CodeMirror cmB;
   private boolean autoHideDiffTableHeader;
 
-  ScrollSynchronizer(SideBySideTable diffTable,
-      CodeMirror cmA, CodeMirror cmB,
-      LineMapper mapper) {
+  ScrollSynchronizer(SideBySideTable diffTable, CodeMirror cmA, CodeMirror cmB, LineMapper mapper) {
     this.diffTable = diffTable;
     this.mapper = mapper;
     this.cmB = cmB;
@@ -73,14 +70,15 @@
       this.src = src;
       this.dst = dst;
       this.srcSide = srcSide;
-      this.fixup = new Timer() {
-        @Override
-        public void run() {
-          if (active == ScrollCallback.this) {
-            fixup();
-          }
-        }
-      };
+      this.fixup =
+          new Timer() {
+            @Override
+            public void run() {
+              if (active == ScrollCallback.this) {
+                fixup();
+              }
+            }
+          };
     }
 
     void sync() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.java
index b72ab43..83ada90 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Scrollbar.java
@@ -15,12 +15,10 @@
 package com.google.gerrit.client.diff;
 
 import com.google.gwt.resources.client.CssResource;
-
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Pos;
-
 import java.util.ArrayList;
 import java.util.List;
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.Pos;
 
 /** Displays overview of all edits and comments in this file. */
 class Scrollbar {
@@ -30,9 +28,13 @@
 
   interface Style extends CssResource {
     String comment();
+
     String draft();
+
     String insert();
+
     String delete();
+
     String edit();
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
index c8f9911..6cb9b6a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
@@ -20,7 +20,6 @@
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.ui.Widget;
-
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.RegisteredHandler;
 import net.codemirror.lib.Pos;
@@ -62,14 +61,17 @@
   @Override
   protected void onLoad() {
     cmB.getWrapperElement().appendChild(getElement());
-    refresh = cmB.on("refresh", new Runnable() {
-      @Override
-      public void run() {
-        if (updateScale()) {
-          updatePosition();
-        }
-      }
-    });
+    refresh =
+        cmB.on(
+            "refresh",
+            new Runnable() {
+              @Override
+              public void run() {
+                if (updateScale()) {
+                  updatePosition();
+                }
+              }
+            });
     updateScale();
     updatePosition();
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
index dbe7e5d..1560597 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
@@ -16,6 +16,7 @@
 
 import static java.lang.Double.POSITIVE_INFINITY;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
@@ -25,7 +26,6 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.Scheduler;
 import com.google.gwt.core.client.Scheduler.ScheduledCommand;
@@ -42,18 +42,17 @@
 import com.google.gwt.user.client.ui.ImageResourceRenderer;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
-
+import java.util.Collections;
+import java.util.List;
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.LineHandle;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.KeyMap;
 import net.codemirror.lib.Pos;
 
-import java.util.Collections;
-import java.util.List;
-
 public class SideBySide extends DiffScreen {
   interface Binder extends UiBinder<FlowPanel, SideBySide> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
   private static final String LINE_NUMBER_CLASSNAME = "CodeMirror-linenumber";
 
@@ -69,11 +68,7 @@
   private SideBySideCommentManager commentManager;
 
   public SideBySide(
-      PatchSet.Id base,
-      PatchSet.Id revision,
-      String path,
-      DisplaySide startSide,
-      int startLine) {
+      DiffObject base, DiffObject revision, String path, DisplaySide startSide, int startLine) {
     super(base, revision, path, startSide, startLine, DiffView.SIDE_BY_SIDE);
 
     diffTable = new SideBySideTable(this, base, revision, path);
@@ -87,11 +82,14 @@
     return new ScreenLoadCallback<ConfigInfoCache.Entry>(SideBySide.this) {
       @Override
       protected void preDisplay(ConfigInfoCache.Entry result) {
-        commentManager = new SideBySideCommentManager(
-            SideBySide.this,
-            base, revision, path,
-            result.getCommentLinkProcessor(),
-            getChangeStatus().isOpen());
+        commentManager =
+            new SideBySideCommentManager(
+                SideBySide.this,
+                base,
+                revision,
+                path,
+                result.getCommentLinkProcessor(),
+                getChangeStatus().isOpen());
         setTheme(result.getTheme());
         display(comments);
         header.setupPrevNextFiles(comments);
@@ -103,15 +101,16 @@
   public void onShowView() {
     super.onShowView();
 
-    operation(new Runnable() {
-      @Override
-      public void run() {
-        resizeCodeMirror();
-        chunkManager.adjustPadding();
-        cmA.refresh();
-        cmB.refresh();
-      }
-    });
+    operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            resizeCodeMirror();
+            chunkManager.adjustPadding();
+            cmA.refresh();
+            cmB.refresh();
+          }
+        });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
 
@@ -145,10 +144,11 @@
   void registerCmEvents(final CodeMirror cm) {
     super.registerCmEvents(cm);
 
-    KeyMap keyMap = KeyMap.create()
-        .on("Shift-A", diffTable.toggleA())
-        .on("Shift-Left", moveCursorToSide(cm, DisplaySide.A))
-        .on("Shift-Right", moveCursorToSide(cm, DisplaySide.B));
+    KeyMap keyMap =
+        KeyMap.create()
+            .on("Shift-A", diffTable.toggleA())
+            .on("Shift-Left", moveCursorToSide(cm, DisplaySide.A))
+            .on("Shift-Right", moveCursorToSide(cm, DisplaySide.B));
     cm.addKeyMap(keyMap);
     maybeRegisterRenderEntireFileKeyMap(cm);
   }
@@ -157,16 +157,18 @@
   public void registerKeys() {
     super.registerKeys();
 
-    getKeysNavigation().add(
-        new NoOpKeyCommand(KeyCommand.M_SHIFT, KeyCodes.KEY_LEFT, PatchUtil.C.focusSideA()),
-        new NoOpKeyCommand(KeyCommand.M_SHIFT, KeyCodes.KEY_RIGHT, PatchUtil.C.focusSideB()));
-    getKeysAction().add(new KeyCommand(
-        KeyCommand.M_SHIFT, 'a', PatchUtil.C.toggleSideA()) {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        diffTable.toggleA().run();
-      }
-    });
+    getKeysNavigation()
+        .add(
+            new NoOpKeyCommand(KeyCommand.M_SHIFT, KeyCodes.KEY_LEFT, PatchUtil.C.focusSideA()),
+            new NoOpKeyCommand(KeyCommand.M_SHIFT, KeyCodes.KEY_RIGHT, PatchUtil.C.focusSideB()));
+    getKeysAction()
+        .add(
+            new KeyCommand(KeyCommand.M_SHIFT, 'a', PatchUtil.C.toggleSideA()) {
+              @Override
+              public void onKeyPress(KeyPressEvent event) {
+                diffTable.toggleA().run();
+              }
+            });
 
     registerHandlers();
   }
@@ -192,9 +194,12 @@
     cmA = newCm(diff.metaA(), diff.textA(), diffTable.cmA);
     cmB = newCm(diff.metaB(), diff.textB(), diffTable.cmB);
 
-    boolean reviewingBase = base == null;
-    getDiffTable().setUpBlameIconA(cmA, reviewingBase,
-        reviewingBase ? revision : base, path);
+    getDiffTable()
+        .setUpBlameIconA(
+            cmA,
+            base.isBaseOrAutoMerge(),
+            base.isBaseOrAutoMerge() ? revision : base.asPatchSetId(),
+            path);
     getDiffTable().setUpBlameIconB(cmB, revision, path);
 
     cmA.extras().side(DisplaySide.A);
@@ -203,25 +208,24 @@
 
     chunkManager = new SideBySideChunkManager(this, cmA, cmB, diffTable.scrollbar);
 
-    operation(new Runnable() {
-      @Override
-      public void run() {
-        // Estimate initial CodeMirror height, fixed up in onShowView.
-        int height = Window.getClientHeight()
-            - (Gerrit.getHeaderFooterHeight() + 18);
-        cmA.setHeight(height);
-        cmB.setHeight(height);
+    operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            // Estimate initial CodeMirror height, fixed up in onShowView.
+            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+            cmA.setHeight(height);
+            cmB.setHeight(height);
 
-        render(diff);
-        commentManager.render(comments, prefs.expandAllComments());
-        skipManager.render(prefs.context(), diff);
-      }
-    });
+            render(diff);
+            commentManager.render(comments, prefs.expandAllComments());
+            skipManager.render(prefs.context(), diff);
+          }
+        });
 
     registerCmEvents(cmA);
     registerCmEvents(cmB);
-    scrollSynchronizer = new ScrollSynchronizer(diffTable, cmA, cmB,
-            chunkManager.lineMapper);
+    scrollSynchronizer = new ScrollSynchronizer(diffTable, cmA, cmB, chunkManager.lineMapper);
 
     setPrefsAction(new PreferencesAction(this, prefs));
     header.init(getPrefsAction(), getUnifiedDiffLink(), diff.sideBySideWebLinks());
@@ -232,36 +236,33 @@
 
   private List<InlineHyperlink> getUnifiedDiffLink() {
     InlineHyperlink toUnifiedDiffLink = new InlineHyperlink();
-    toUnifiedDiffLink.setHTML(
-        new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
-    toUnifiedDiffLink.setTargetHistoryToken(
-        Dispatcher.toUnified(base, revision, path));
+    toUnifiedDiffLink.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
+    toUnifiedDiffLink.setTargetHistoryToken(Dispatcher.toUnified(base, revision, path));
     toUnifiedDiffLink.setTitle(PatchUtil.C.unifiedDiff());
     return Collections.singletonList(toUnifiedDiffLink);
   }
 
   @Override
-  CodeMirror newCm(
-      DiffInfo.FileMeta meta,
-      String contents,
-      Element parent) {
-    return CodeMirror.create(parent, Configuration.create()
-      .set("cursorBlinkRate", prefs.cursorBlinkRate())
-      .set("cursorHeight", 0.85)
-      .set("inputStyle", "textarea")
-      .set("keyMap", "vim_ro")
-      .set("lineNumbers", prefs.showLineNumbers())
-      .set("matchBrackets", prefs.matchBrackets())
-      .set("lineWrapping", prefs.lineWrapping())
-      .set("mode", getFileSize() == FileSize.SMALL ? getContentType(meta) : null)
-      .set("readOnly", true)
-      .set("scrollbarStyle", "overlay")
-      .set("showTrailingSpace", prefs.showWhitespaceErrors())
-      .set("styleSelectedText", true)
-      .set("tabSize", prefs.tabSize())
-      .set("theme", prefs.theme().name().toLowerCase())
-      .set("value", meta != null ? contents : "")
-      .set("viewportMargin", renderEntireFile() ? POSITIVE_INFINITY : 10));
+  CodeMirror newCm(DiffInfo.FileMeta meta, String contents, Element parent) {
+    return CodeMirror.create(
+        parent,
+        Configuration.create()
+            .set("cursorBlinkRate", prefs.cursorBlinkRate())
+            .set("cursorHeight", 0.85)
+            .set("inputStyle", "textarea")
+            .set("keyMap", "vim_ro")
+            .set("lineNumbers", prefs.showLineNumbers())
+            .set("matchBrackets", prefs.matchBrackets())
+            .set("lineWrapping", prefs.lineWrapping())
+            .set("mode", getFileSize() == FileSize.SMALL ? getContentType(meta) : null)
+            .set("readOnly", true)
+            .set("scrollbarStyle", "overlay")
+            .set("showTrailingSpace", prefs.showWhitespaceErrors())
+            .set("styleSelectedText", true)
+            .set("tabSize", prefs.tabSize())
+            .set("theme", prefs.theme().name().toLowerCase())
+            .set("value", meta != null ? contents : "")
+            .set("viewportMargin", renderEntireFile() ? POSITIVE_INFINITY : 10));
   }
 
   @Override
@@ -276,20 +277,22 @@
   void setSyntaxHighlighting(boolean b) {
     final DiffInfo diff = getDiff();
     if (b) {
-      injectMode(diff, new AsyncCallback<Void>() {
-        @Override
-        public void onSuccess(Void result) {
-          if (prefs.syntaxHighlighting()) {
-            cmA.setOption("mode", getContentType(diff.metaA()));
-            cmB.setOption("mode", getContentType(diff.metaB()));
-          }
-        }
+      injectMode(
+          diff,
+          new AsyncCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              if (prefs.syntaxHighlighting()) {
+                cmA.setOption("mode", getContentType(diff.metaA()));
+                cmB.setOption("mode", getContentType(diff.metaB()));
+              }
+            }
 
-        @Override
-        public void onFailure(Throwable caught) {
-          prefs.syntaxHighlighting(false);
-        }
-      });
+            @Override
+            public void onFailure(Throwable caught) {
+              prefs.syntaxHighlighting(false);
+            }
+          });
     } else {
       cmA.setOption("mode", (String) null);
       cmB.setOption("mode", (String) null);
@@ -326,29 +329,31 @@
         // key (or j/k) is held down. Performance on Chrome is fine
         // without the deferral.
         //
-        Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-          @Override
-          public void execute() {
-            operation(new Runnable() {
-              @Override
-              public void run() {
-                LineHandle handle =
-                    cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                if (!cm.extras().activeLine(handle)) {
-                  return;
-                }
+        Scheduler.get()
+            .scheduleDeferred(
+                new ScheduledCommand() {
+                  @Override
+                  public void execute() {
+                    operation(
+                        new Runnable() {
+                          @Override
+                          public void run() {
+                            LineHandle handle =
+                                cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                            if (!cm.extras().activeLine(handle)) {
+                              return;
+                            }
 
-                LineOnOtherInfo info =
-                    lineOnOther(cm.side(), cm.getLineNumber(handle));
-                if (info.isAligned()) {
-                  other.extras().activeLine(other.getLineHandle(info.getLine()));
-                } else {
-                  other.extras().clearActiveLine();
-                }
-              }
-            });
-          }
-        });
+                            LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
+                            if (info.isAligned()) {
+                              other.extras().activeLine(other.getLineHandle(info.getLine()));
+                            } else {
+                              other.extras().clearActiveLine();
+                            }
+                          }
+                        });
+                  }
+                });
       }
     };
   }
@@ -358,8 +363,7 @@
     if (cmDst == cmSrc) {
       return new Runnable() {
         @Override
-        public void run() {
-        }
+        public void run() {}
       };
     }
 
@@ -368,9 +372,10 @@
       @Override
       public void run() {
         if (cmSrc.extras().hasActiveLine()) {
-          cmDst.setCursor(Pos.create(lineOnOther(
-              sideSrc,
-              cmSrc.getLineNumber(cmSrc.extras().activeLine())).getLine()));
+          cmDst.setCursor(
+              Pos.create(
+                  lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine()))
+                      .getLine()));
         }
         cmDst.focus();
       }
@@ -385,22 +390,24 @@
 
   @Override
   void operation(final Runnable apply) {
-    cmA.operation(new Runnable() {
-      @Override
-      public void run() {
-        cmB.operation(new Runnable() {
+    cmA.operation(
+        new Runnable() {
           @Override
           public void run() {
-            apply.run();
+            cmB.operation(
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    apply.run();
+                  }
+                });
           }
         });
-      }
-    });
   }
 
   @Override
   CodeMirror[] getCms() {
-    return new CodeMirror[]{cmA, cmB};
+    return new CodeMirror[] {cmA, cmB};
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
index 11b7e7c..a78e59e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
@@ -28,23 +28,22 @@
 import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.EventListener;
-
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.LineClassWhere;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.LineWidget;
 import net.codemirror.lib.Pos;
 
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
 /** Colors modified regions for {@link SideBySide}. */
 class SideBySideChunkManager extends ChunkManager {
   private static final String DATA_LINES = "_cs2h";
   private static double guessedLineHeightPx = 15;
   private static final JavaScriptObject focusA = initOnClick(A);
   private static final JavaScriptObject focusB = initOnClick(B);
+
   private static native JavaScriptObject initOnClick(DisplaySide s) /*-{
     return $entry(function(e){
       @com.google.gerrit.client.diff.SideBySideChunkManager::focus(
@@ -76,10 +75,7 @@
   private List<LineWidget> padding;
   private List<Element> paddingDivs;
 
-  SideBySideChunkManager(SideBySide host,
-      CodeMirror cmA,
-      CodeMirror cmB,
-      Scrollbar scrollbar) {
+  SideBySideChunkManager(SideBySide host, CodeMirror cmA, CodeMirror cmB, Scrollbar scrollbar) {
     super(scrollbar);
 
     this.host = host;
@@ -109,9 +105,10 @@
     padding = new ArrayList<>();
     paddingDivs = new ArrayList<>();
 
-    String diffColor = diff.metaA() == null || diff.metaB() == null
-        ? SideBySideTable.style.intralineBg()
-        : SideBySideTable.style.diff();
+    String diffColor =
+        diff.metaA() == null || diff.metaB() == null
+            ? SideBySideTable.style.intralineBg()
+            : SideBySideTable.style.diff();
 
     for (Region current : Natives.asList(diff.content())) {
       if (current.ab() != null) {
@@ -154,9 +151,7 @@
     int aLen = a != null ? a.length() : 0;
     int bLen = b != null ? b.length() : 0;
 
-    String color = a == null || b == null
-        ? diffColor
-        : SideBySideTable.style.intralineBg();
+    String color = a == null || b == null ? diffColor : SideBySideTable.style.intralineBg();
 
     colorLines(cmA, color, startA, aLen);
     colorLines(cmB, color, startB, bLen);
@@ -187,20 +182,19 @@
     }
   }
 
-  private void markEdit(CodeMirror cm, int startLine,
-      JsArrayString lines, JsArray<Span> edits) {
+  private void markEdit(CodeMirror cm, int startLine, JsArrayString lines, JsArray<Span> edits) {
     if (lines == null || edits == null) {
       return;
     }
 
     EditIterator iter = new EditIterator(lines, startLine);
-    Configuration bg = Configuration.create()
-        .set("className", SideBySideTable.style.intralineBg())
-        .set("readOnly", true);
+    Configuration bg =
+        Configuration.create()
+            .set("className", SideBySideTable.style.intralineBg())
+            .set("readOnly", true);
 
-    Configuration diff = Configuration.create()
-        .set("className", SideBySideTable.style.diff())
-        .set("readOnly", true);
+    Configuration diff =
+        Configuration.create().set("className", SideBySideTable.style.diff()).set("readOnly", true);
 
     Pos last = Pos.create(0, 0);
     for (Span span : Natives.asList(edits)) {
@@ -213,9 +207,8 @@
       }
       getMarkers().add(cm.markText(from, to, diff));
       last = to;
-      colorLines(cm, LineClassWhere.BACKGROUND,
-          SideBySideTable.style.diff(),
-          from.line(), to.line());
+      colorLines(
+          cm, LineClassWhere.BACKGROUND, SideBySideTable.style.diff(), from.line(), to.line());
     }
   }
 
@@ -224,8 +217,7 @@
    *
    * @param cm parent CodeMirror to add extra space into.
    * @param line line to put the padding below.
-   * @param len number of lines to pad. Padding is inserted only if
-   *        {@code len >= 1}.
+   * @param len number of lines to pad. Padding is inserted only if {@code len >= 1}.
    */
   private void addPadding(CodeMirror cm, int line, final int len) {
     if (0 < len) {
@@ -235,20 +227,21 @@
       pad.getStyle().setHeight(guessedLineHeightPx * len, Unit.PX);
       focusOnClick(pad, cm.side());
       paddingDivs.add(pad);
-      padding.add(cm.addLineWidget(
-        line == -1 ? 0 : line,
-        pad,
-        Configuration.create()
-          .set("coverGutter", true)
-          .set("noHScroll", true)
-          .set("above", line == -1)));
+      padding.add(
+          cm.addLineWidget(
+              line == -1 ? 0 : line,
+              pad,
+              Configuration.create()
+                  .set("coverGutter", true)
+                  .set("noHScroll", true)
+                  .set("above", line == -1)));
     }
   }
 
-  private void addDiffChunk(CodeMirror cmToPad, int lineOnOther,
-      int chunkSize, boolean edit) {
-    chunks.add(new DiffChunkInfo(host.otherCm(cmToPad).side(),
-        lineOnOther - chunkSize + 1, lineOnOther, edit));
+  private void addDiffChunk(CodeMirror cmToPad, int lineOnOther, int chunkSize, boolean edit) {
+    chunks.add(
+        new DiffChunkInfo(
+            host.otherCm(cmToPad).side(), lineOnOther - chunkSize + 1, lineOnOther, edit));
   }
 
   @Override
@@ -256,13 +249,10 @@
     return new Runnable() {
       @Override
       public void run() {
-        int line = cm.extras().hasActiveLine()
-            ? cm.getLineNumber(cm.extras().activeLine())
-            : 0;
-        int res = Collections.binarySearch(
-                chunks,
-                new DiffChunkInfo(cm.side(), line, 0, false),
-                getDiffChunkComparator());
+        int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
+        int res =
+            Collections.binarySearch(
+                chunks, new DiffChunkInfo(cm.side(), line, 0, false), getDiffChunkComparator());
         diffChunkNavHelper(chunks, host, res, dir);
       }
     };
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 db72864..6fcd6c8 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
@@ -18,20 +18,17 @@
 import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.Timer;
-
-import net.codemirror.lib.CodeMirror;
-
 import java.util.PriorityQueue;
+import net.codemirror.lib.CodeMirror;
 
 /**
  * LineWidget attached to a CodeMirror container.
  *
- * When a comment is placed on a line a CommentWidget is created on both sides.
- * The group tracks all comment boxes on that same line, and also includes an
- * empty padding element to keep subsequent lines vertically aligned.
+ * <p>When a comment is placed on a line a CommentWidget is created on both sides. 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
-    implements Comparable<SideBySideCommentGroup> {
+class SideBySideCommentGroup extends CommentGroup implements Comparable<SideBySideCommentGroup> {
   static void pair(SideBySideCommentGroup a, SideBySideCommentGroup b) {
     a.peers.add(b);
     b.peers.add(a);
@@ -40,8 +37,8 @@
   private final Element padding;
   private final PriorityQueue<SideBySideCommentGroup> peers;
 
-  SideBySideCommentGroup(SideBySideCommentManager manager, CodeMirror cm, DisplaySide side,
-      int line) {
+  SideBySideCommentGroup(
+      SideBySideCommentManager manager, CodeMirror cm, DisplaySide side, int line) {
     super(manager, cm, side, line);
 
     padding = DOM.createDiv();
@@ -59,12 +56,12 @@
   void remove(DraftBox box) {
     super.remove(box);
 
-    if (getBoxCount() == 0 && peers.size() == 1
-        && peers.peek().peers.size() > 1) {
+    if (getBoxCount() == 0 && peers.size() == 1 && peers.peek().peers.size() > 1) {
       SideBySideCommentGroup peer = peers.peek();
       peer.peers.remove(this);
       detach();
-      if (peer.getBoxCount() == 0 && peer.peers.size() == 1
+      if (peer.getBoxCount() == 0
+          && peer.peers.size() == 1
           && peer.peers.peek().getBoxCount() == 0) {
         peer.detach();
       } else {
@@ -89,30 +86,33 @@
 
   @Override
   void handleRedraw() {
-    getLineWidget().onRedraw(new Runnable() {
-      @Override
-      public void run() {
-        if (canComputeHeight() && peers.peek().canComputeHeight()) {
-          if (getResizeTimer() != null) {
-            getResizeTimer().cancel();
-            setResizeTimer(null);
-          }
-          adjustPadding(SideBySideCommentGroup.this, peers.peek());
-        } else if (getResizeTimer() == null) {
-          setResizeTimer(new Timer() {
-            @Override
-            public void run() {
-              if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                cancel();
-                setResizeTimer(null);
-                adjustPadding(SideBySideCommentGroup.this, peers.peek());
+    getLineWidget()
+        .onRedraw(
+            new Runnable() {
+              @Override
+              public void run() {
+                if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                  if (getResizeTimer() != null) {
+                    getResizeTimer().cancel();
+                    setResizeTimer(null);
+                  }
+                  adjustPadding(SideBySideCommentGroup.this, peers.peek());
+                } else if (getResizeTimer() == null) {
+                  setResizeTimer(
+                      new Timer() {
+                        @Override
+                        public void run() {
+                          if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                            cancel();
+                            setResizeTimer(null);
+                            adjustPadding(SideBySideCommentGroup.this, peers.peek());
+                          }
+                        }
+                      });
+                  getResizeTimer().scheduleRepeating(5);
+                }
               }
-            }
-          });
-          getResizeTimer().scheduleRepeating(5);
-        }
-      }
-    });
+            });
   }
 
   @Override
@@ -157,7 +157,6 @@
     if (side == o.side) {
       return line - o.line;
     }
-    throw new IllegalStateException(
-        "Cannot compare SideBySideCommentGroup with different sides");
+    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 bcb7dac..7503711 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
@@ -14,22 +14,23 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.PatchSet;
-
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.TextMarker.FromTo;
-
 import java.util.Collection;
 import java.util.Map;
 import java.util.SortedMap;
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.TextMarker.FromTo;
 
 /** Tracks comment widgets for {@link SideBySide}. */
 class SideBySideCommentManager extends CommentManager {
-  SideBySideCommentManager(SideBySide host,
-      PatchSet.Id base, PatchSet.Id revision,
+  SideBySideCommentManager(
+      SideBySide host,
+      DiffObject base,
+      PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
       boolean open) {
@@ -59,8 +60,7 @@
   CommentGroup getCommentGroupOnActiveLine(CodeMirror cm) {
     CommentGroup group = null;
     if (cm.extras().hasActiveLine()) {
-      group = map(cm.side())
-          .get(cm.getLineNumber(cm.extras().activeLine()) + 1);
+      group = map(cm.side()).get(cm.getLineNumber(cm.extras().activeLine()) + 1);
     }
     return group;
   }
@@ -81,12 +81,16 @@
     int line = cm.getLineNumber(cm.extras().activeLine()) + 1;
     if (cm.somethingSelected()) {
       FromTo fromTo = adjustSelection(cm);
-      addDraftBox(cm.side(), CommentInfo.create(
-              getPath(),
-              getStoredSideFromDisplaySide(cm.side()),
-              getParentNumFromDisplaySide(cm.side()),
-              line,
-              CommentRange.create(fromTo))).setEdit(true);
+      addDraftBox(
+              cm.side(),
+              CommentInfo.create(
+                  getPath(),
+                  getStoredSideFromDisplaySide(cm.side()),
+                  getParentNumFromDisplaySide(cm.side()),
+                  line,
+                  CommentRange.create(fromTo),
+                  false))
+          .setEdit(true);
       cm.setCursor(fromTo.to());
       cm.setSelection(cm.getCursor());
     } else {
@@ -102,10 +106,8 @@
     }
 
     SideBySideCommentGroup newGroup = newGroup(side, line);
-    Map<Integer, CommentGroup> map =
-        side == DisplaySide.A ? sideA : sideB;
-    Map<Integer, CommentGroup> otherMap =
-        side == DisplaySide.A ? sideB : sideA;
+    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);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
index 2296796..7465c81 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.resources.client.CssResource;
@@ -24,18 +24,23 @@
 import com.google.gwt.user.client.ui.HTMLPanel;
 
 /**
- * A table with one row and two columns to hold the two CodeMirrors displaying
- * the files to be compared.
+ * A table with one row and two columns to hold the two CodeMirrors displaying the files to be
+ * compared.
  */
 class SideBySideTable extends DiffTable {
   interface Binder extends UiBinder<HTMLPanel, SideBySideTable> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface DiffTableStyle extends CssResource {
     String intralineBg();
+
     String diff();
+
     String hideA();
+
     String hideB();
+
     String padding();
   }
 
@@ -46,8 +51,7 @@
 
   private boolean visibleA;
 
-  SideBySideTable(SideBySide parent, PatchSet.Id base, PatchSet.Id revision,
-      String path) {
+  SideBySideTable(SideBySide parent, DiffObject base, DiffObject revision, String path) {
     super(parent, base, revision, path);
 
     initWidget(uiBinder.createAndBindUi(this));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
index 5f86955..03cfd60 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
@@ -26,7 +26,6 @@
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.HTMLPanel;
-
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.LineWidget;
@@ -36,6 +35,7 @@
 
 class SkipBar extends Composite {
   interface Binder extends UiBinder<HTMLPanel, SkipBar> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
   private static final int NUM_ROWS_TO_EXPAND = 10;
   private static final int UP_DOWN_THRESHOLD = 30;
@@ -44,9 +44,15 @@
     String noExpand();
   }
 
-  @UiField(provided = true) Anchor skipNum;
-  @UiField(provided = true) Anchor upArrow;
-  @UiField(provided = true) Anchor downArrow;
+  @UiField(provided = true)
+  Anchor skipNum;
+
+  @UiField(provided = true)
+  Anchor upArrow;
+
+  @UiField(provided = true)
+  Anchor downArrow;
+
   @UiField SkipBarStyle style;
 
   private final SkipManager manager;
@@ -64,50 +70,54 @@
     upArrow = new Anchor(true);
     downArrow = new Anchor(true);
     initWidget(uiBinder.createAndBindUi(this));
-    addDomHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        cm.focus();
-      }
-    }, ClickEvent.getType());
+    addDomHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            cm.focus();
+          }
+        },
+        ClickEvent.getType());
   }
 
   void collapse(int start, int end, boolean attach) {
     if (attach) {
       boolean isNew = lineWidget == null;
-      Configuration cfg = Configuration.create()
-          .set("coverGutter", true)
-          .set("noHScroll", true);
+      Configuration cfg = Configuration.create().set("coverGutter", true).set("noHScroll", true);
       if (start == 0) { // First line workaround
         lineWidget = cm.addLineWidget(end + 1, getElement(), cfg.set("above", true));
       } else {
         lineWidget = cm.addLineWidget(start - 1, getElement(), cfg);
       }
       if (isNew) {
-        lineWidget.onFirstRedraw(new Runnable() {
-          @Override
-          public void run() {
-            int w = cm.getGutterElement().getOffsetWidth();
-            getElement().getStyle().setPaddingLeft(w, Unit.PX);
-          }
-        });
+        lineWidget.onFirstRedraw(
+            new Runnable() {
+              @Override
+              public void run() {
+                int w = cm.getGutterElement().getOffsetWidth();
+                getElement().getStyle().setPaddingLeft(w, Unit.PX);
+              }
+            });
       }
     }
 
-    textMarker = cm.markText(
-        Pos.create(start, 0),
-        Pos.create(end),
-        Configuration.create()
-          .set("collapsed", true)
-          .set("inclusiveLeft", true)
-          .set("inclusiveRight", true));
+    textMarker =
+        cm.markText(
+            Pos.create(start, 0),
+            Pos.create(end),
+            Configuration.create()
+                .set("collapsed", true)
+                .set("inclusiveLeft", true)
+                .set("inclusiveRight", true));
 
-    textMarker.on("beforeCursorEnter", new Runnable() {
-      @Override
-      public void run() {
-        expandAll();
-      }
-    });
+    textMarker.on(
+        "beforeCursorEnter",
+        new Runnable() {
+          @Override
+          public void run() {
+            expandAll();
+          }
+        });
 
     int skipped = end - start + 1;
     if (skipped <= UP_DOWN_THRESHOLD) {
@@ -116,8 +126,7 @@
       upArrow.setHTML(PatchUtil.M.expandBefore(NUM_ROWS_TO_EXPAND));
       downArrow.setHTML(PatchUtil.M.expandAfter(NUM_ROWS_TO_EXPAND));
     }
-    skipNum.setText(PatchUtil.M.patchSkipRegion(Integer
-        .toString(skipped)));
+    skipNum.setText(PatchUtil.M.patchSkipRegion(Integer.toString(skipped)));
   }
 
   static void link(SkipBar barA, SkipBar barB) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java
index cf23694..533ba1f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipManager.java
@@ -18,16 +18,13 @@
 import com.google.gerrit.client.patches.SkippedLine;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gwt.core.client.JsArray;
-
-import net.codemirror.lib.CodeMirror;
-
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import net.codemirror.lib.CodeMirror;
 
-/** Collapses common regions with {@link SkipBar} for {@link SideBySide}
- *  and {@link Unified}. */
+/** Collapses common regions with {@link SkipBar} for {@link SideBySide} and {@link Unified}. */
 class SkipManager {
   private final Set<SkipBar> skipBars;
   private final DiffScreen host;
@@ -50,17 +47,16 @@
     for (int i = 0; i < regions.length(); i++) {
       Region current = regions.get(i);
       if (current.ab() != null || current.common() || current.skip() > 0) {
-        int len = current.skip() > 0
-            ? current.skip()
-            : (current.ab() != null ? current.ab() : current.b()).length();
+        int len =
+            current.skip() > 0
+                ? current.skip()
+                : (current.ab() != null ? current.ab() : current.b()).length();
         if (i == 0 && len > context + 1) {
           skips.add(new SkippedLine(0, 0, len - context));
         } else if (i == regions.length() - 1 && len > context + 1) {
-          skips.add(new SkippedLine(lineA + context, lineB + context,
-              len - context));
+          skips.add(new SkippedLine(lineA + context, lineB + context, len - context));
         } else if (len > 2 * context + 1) {
-          skips.add(new SkippedLine(lineA + context, lineB + context,
-              len - 2 * context));
+          skips.add(new SkippedLine(lineA + context, lineB + context, len - 2 * context));
         }
         lineA += len;
         lineB += len;
@@ -109,10 +105,8 @@
     }
   }
 
-  private SkipBar newSkipBar(CodeMirror cm, DisplaySide side,
-      SkippedLine skip) {
-    int start = host.getCmLine(
-        side == DisplaySide.A ? skip.getStartA() : skip.getStartB(), side);
+  private SkipBar newSkipBar(CodeMirror cm, DisplaySide side, SkippedLine skip) {
+    int start = host.getCmLine(side == DisplaySide.A ? skip.getStartA() : skip.getStartB(), side);
     int end = start + skip.getSize() - 1;
 
     SkipBar bar = new SkipBar(this, cm);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
index a231580..0f0ba41 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
@@ -16,6 +16,7 @@
 
 import static java.lang.Double.POSITIVE_INFINITY;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Dispatcher;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.diff.UnifiedChunkManager.LineRegionInfo;
@@ -25,7 +26,6 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
@@ -44,18 +44,17 @@
 import com.google.gwt.user.client.ui.InlineHTML;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
-
+import java.util.Collections;
+import java.util.List;
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.LineHandle;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.Pos;
 import net.codemirror.lib.ScrollInfo;
 
-import java.util.Collections;
-import java.util.List;
-
 public class Unified extends DiffScreen {
   interface Binder extends UiBinder<FlowPanel, Unified> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   @UiField(provided = true)
@@ -69,11 +68,7 @@
   private boolean autoHideDiffTableHeader;
 
   public Unified(
-      PatchSet.Id base,
-      PatchSet.Id revision,
-      String path,
-      DisplaySide startSide,
-      int startLine) {
+      DiffObject base, DiffObject revision, String path, DisplaySide startSide, int startLine) {
     super(base, revision, path, startSide, startLine, DiffView.UNIFIED_DIFF);
 
     diffTable = new UnifiedTable(this, base, revision, path);
@@ -87,11 +82,14 @@
     return new ScreenLoadCallback<ConfigInfoCache.Entry>(Unified.this) {
       @Override
       protected void preDisplay(ConfigInfoCache.Entry result) {
-        commentManager = new UnifiedCommentManager(
-            Unified.this,
-            base, revision, path,
-            result.getCommentLinkProcessor(),
-            getChangeStatus().isOpen());
+        commentManager =
+            new UnifiedCommentManager(
+                Unified.this,
+                base,
+                revision,
+                path,
+                result.getCommentLinkProcessor(),
+                getChangeStatus().isOpen());
         setTheme(result.getTheme());
         display(comments);
         header.setupPrevNextFiles(comments);
@@ -103,13 +101,14 @@
   public void onShowView() {
     super.onShowView();
 
-    operation(new Runnable() {
-      @Override
-      public void run() {
-        resizeCodeMirror();
-        cm.refresh();
-      }
-    });
+    operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            resizeCodeMirror();
+            cm.refresh();
+          }
+        });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
 
@@ -125,8 +124,7 @@
       }
     }
     if (getStartSide() != null && getStartLine() > 0) {
-      cm.scrollToLine(
-          chunkManager.getCmLine(getStartLine() - 1, getStartSide()));
+      cm.scrollToLine(chunkManager.getCmLine(getStartLine() - 1, getStartSide()));
       cm.focus();
     } else {
       cm.setCursor(Pos.create(0));
@@ -142,15 +140,17 @@
   void registerCmEvents(final CodeMirror cm) {
     super.registerCmEvents(cm);
 
-    cm.on("scroll", new Runnable() {
-      @Override
-      public void run() {
-        ScrollInfo si = cm.getScrollInfo();
-        if (autoHideDiffTableHeader) {
-          updateDiffTableHeader(si);
-        }
-      }
-    });
+    cm.on(
+        "scroll",
+        new Runnable() {
+          @Override
+          public void run() {
+            ScrollInfo si = cm.getScrollInfo();
+            if (autoHideDiffTableHeader) {
+              updateDiffTableHeader(si);
+            }
+          }
+        });
     maybeRegisterRenderEntireFileKeyMap(cm);
   }
 
@@ -179,27 +179,25 @@
       diffTable.addStyleName(Resources.I.diffTableStyle().showLineNumbers());
     }
 
-    cm = newCm(
-        diff.metaA() == null ? diff.metaB() : diff.metaA(),
-        diff.textUnified(),
-        diffTable.cm);
+    cm =
+        newCm(diff.metaA() == null ? diff.metaB() : diff.metaA(), diff.textUnified(), diffTable.cm);
     setShowTabs(prefs.showTabs());
 
     chunkManager = new UnifiedChunkManager(this, cm, diffTable.scrollbar);
 
-    operation(new Runnable() {
-      @Override
-      public void run() {
-        // Estimate initial CodeMirror height, fixed up in onShowView.
-        int height = Window.getClientHeight()
-            - (Gerrit.getHeaderFooterHeight() + 18);
-        cm.setHeight(height);
+    operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            // Estimate initial CodeMirror height, fixed up in onShowView.
+            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+            cm.setHeight(height);
 
-        render(diff);
-        commentManager.render(comments, prefs.expandAllComments());
-        skipManager.render(prefs.context(), diff);
-      }
-    });
+            render(diff);
+            commentManager.render(comments, prefs.expandAllComments());
+            skipManager.render(prefs.context(), diff);
+          }
+        });
 
     registerCmEvents(cm);
 
@@ -214,39 +212,37 @@
     InlineHyperlink toSideBySideDiffLink = new InlineHyperlink();
     toSideBySideDiffLink.setHTML(
         new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
-    toSideBySideDiffLink.setTargetHistoryToken(
-        Dispatcher.toSideBySide(base, revision, path));
+    toSideBySideDiffLink.setTargetHistoryToken(Dispatcher.toSideBySide(base, revision, path));
     toSideBySideDiffLink.setTitle(PatchUtil.C.sideBySideDiff());
     return Collections.singletonList(toSideBySideDiffLink);
   }
 
   @Override
-  CodeMirror newCm(
-      DiffInfo.FileMeta meta,
-      String contents,
-      Element parent) {
+  CodeMirror newCm(DiffInfo.FileMeta meta, String contents, Element parent) {
     JsArrayString gutters = JavaScriptObject.createArray().cast();
     gutters.push(UnifiedTable.style.lineNumbersLeft());
     gutters.push(UnifiedTable.style.lineNumbersRight());
 
-    return CodeMirror.create(parent, Configuration.create()
-        .set("cursorBlinkRate", prefs.cursorBlinkRate())
-        .set("cursorHeight", 0.85)
-        .set("gutters", gutters)
-        .set("inputStyle", "textarea")
-        .set("keyMap", "vim_ro")
-        .set("lineNumbers", false)
-        .set("lineWrapping", prefs.lineWrapping())
-        .set("matchBrackets", prefs.matchBrackets())
-        .set("mode", getFileSize() == FileSize.SMALL ? getContentType(meta) : null)
-        .set("readOnly", true)
-        .set("scrollbarStyle", "overlay")
-        .set("styleSelectedText", true)
-        .set("showTrailingSpace", prefs.showWhitespaceErrors())
-        .set("tabSize", prefs.tabSize())
-        .set("theme", prefs.theme().name().toLowerCase())
-        .set("value", meta != null ? contents : "")
-        .set("viewportMargin", renderEntireFile() ? POSITIVE_INFINITY : 10));
+    return CodeMirror.create(
+        parent,
+        Configuration.create()
+            .set("cursorBlinkRate", prefs.cursorBlinkRate())
+            .set("cursorHeight", 0.85)
+            .set("gutters", gutters)
+            .set("inputStyle", "textarea")
+            .set("keyMap", "vim_ro")
+            .set("lineNumbers", false)
+            .set("lineWrapping", prefs.lineWrapping())
+            .set("matchBrackets", prefs.matchBrackets())
+            .set("mode", getFileSize() == FileSize.SMALL ? getContentType(meta) : null)
+            .set("readOnly", true)
+            .set("scrollbarStyle", "overlay")
+            .set("styleSelectedText", true)
+            .set("showTrailingSpace", prefs.showWhitespaceErrors())
+            .set("tabSize", prefs.tabSize())
+            .set("theme", prefs.theme().name().toLowerCase())
+            .set("value", meta != null ? contents : "")
+            .set("viewportMargin", renderEntireFile() ? POSITIVE_INFINITY : 10));
   }
 
   @Override
@@ -256,15 +252,17 @@
     cm.refresh();
   }
 
-  private void setLineNumber(DisplaySide side, int cmLine, Integer line,
-      String styleName) {
+  private void setLineNumber(DisplaySide side, int cmLine, Integer line, String styleName) {
     SafeHtml html = SafeHtml.asis(line != null ? line.toString() : "&nbsp;");
     InlineHTML gutter = new InlineHTML(html);
     diffTable.add(gutter);
     gutter.setStyleName(styleName);
-    cm.setGutterMarker(cmLine, side == DisplaySide.A
-        ? UnifiedTable.style.lineNumbersLeft()
-        : UnifiedTable.style.lineNumbersRight(), gutter.getElement());
+    cm.setGutterMarker(
+        cmLine,
+        side == DisplaySide.A
+            ? UnifiedTable.style.lineNumbersLeft()
+            : UnifiedTable.style.lineNumbersRight(),
+        gutter.getElement());
   }
 
   void setLineNumber(DisplaySide side, int cmLine, int line) {
@@ -272,29 +270,29 @@
   }
 
   void setLineNumberEmpty(DisplaySide side, int cmLine) {
-    setLineNumber(side, cmLine, null,
-        UnifiedTable.style.unifiedLineNumberEmpty());
+    setLineNumber(side, cmLine, null, UnifiedTable.style.unifiedLineNumberEmpty());
   }
 
   @Override
   void setSyntaxHighlighting(boolean b) {
     final DiffInfo diff = getDiff();
     if (b) {
-      injectMode(diff, new AsyncCallback<Void>() {
-        @Override
-        public void onSuccess(Void result) {
-          if (prefs.syntaxHighlighting()) {
-            cm.setOption("mode", getContentType(diff.metaA() == null
-                ? diff.metaB()
-                : diff.metaA()));
-          }
-        }
+      injectMode(
+          diff,
+          new AsyncCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              if (prefs.syntaxHighlighting()) {
+                cm.setOption(
+                    "mode", getContentType(diff.metaA() == null ? diff.metaB() : diff.metaA()));
+              }
+            }
 
-        @Override
-        public void onFailure(Throwable caught) {
-          prefs.syntaxHighlighting(false);
-        }
-      });
+            @Override
+            public void onFailure(Throwable caught) {
+              prefs.syntaxHighlighting(false);
+            }
+          });
     } else {
       cm.setOption("mode", (String) null);
     }
@@ -328,14 +326,15 @@
         // key (or j/k) is held down. Performance on Chrome is fine
         // without the deferral.
         //
-        Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-          @Override
-          public void execute() {
-            LineHandle handle =
-                cm.getLineHandleVisualStart(cm.getCursor("end").line());
-            cm.extras().activeLine(handle);
-          }
-        });
+        Scheduler.get()
+            .scheduleDeferred(
+                new ScheduledCommand() {
+                  @Override
+                  public void execute() {
+                    LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                    cm.extras().activeLine(handle);
+                  }
+                });
       }
     };
   }
@@ -356,12 +355,13 @@
 
   @Override
   void operation(final Runnable apply) {
-    cm.operation(new Runnable() {
-      @Override
-      public void run() {
-        apply.run();
-      }
-    });
+    cm.operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            apply.run();
+          }
+        });
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
index 74cf0df..3939f99 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
@@ -24,20 +24,19 @@
 import com.google.gwt.dom.client.NativeEvent;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.EventListener;
-
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
 import net.codemirror.lib.CodeMirror;
 import net.codemirror.lib.CodeMirror.LineClassWhere;
 import net.codemirror.lib.Configuration;
 import net.codemirror.lib.Pos;
 
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-
 /** Colors modified regions for {@link Unified}. */
 class UnifiedChunkManager extends ChunkManager {
   private static final JavaScriptObject focus = initOnClick();
+
   private static native JavaScriptObject initOnClick() /*-{
     return $entry(function(e){
       @com.google.gerrit.client.diff.UnifiedChunkManager::focus(
@@ -70,9 +69,7 @@
   private final Unified host;
   private final CodeMirror cm;
 
-  UnifiedChunkManager(Unified host,
-      CodeMirror cm,
-      Scrollbar scrollbar) {
+  UnifiedChunkManager(Unified host, CodeMirror cm, Scrollbar scrollbar) {
     super(scrollbar);
 
     this.host = host;
@@ -123,14 +120,19 @@
     int bLen = b != null ? b.length() : 0;
     boolean insertOrDelete = a == null || b == null;
 
-    colorLines(cm,
+    colorLines(
+        cm,
         insertOrDelete && !useIntralineBg
             ? UnifiedTable.style.diffDelete()
-            : UnifiedTable.style.intralineDelete(), cmLine, aLen);
-    colorLines(cm,
+            : UnifiedTable.style.intralineDelete(),
+        cmLine,
+        aLen);
+    colorLines(
+        cm,
         insertOrDelete && !useIntralineBg
             ? UnifiedTable.style.diffInsert()
-            : UnifiedTable.style.intralineInsert(), cmLine + aLen,
+            : UnifiedTable.style.intralineInsert(),
+        cmLine + aLen,
         bLen);
     markEdit(DisplaySide.A, cmLine, a, region.editA());
     markEdit(DisplaySide.B, cmLine + aLen, b, region.editB());
@@ -166,20 +168,17 @@
     }
   }
 
-  private void markEdit(DisplaySide side, int startLine,
-      JsArrayString lines, JsArray<Span> edits) {
+  private void markEdit(DisplaySide side, int startLine, JsArrayString lines, JsArray<Span> edits) {
     if (lines == null || edits == null) {
       return;
     }
 
     EditIterator iter = new EditIterator(lines, startLine);
-    Configuration bg = Configuration.create()
-        .set("className", getIntralineBgFromSide(side))
-        .set("readOnly", true);
+    Configuration bg =
+        Configuration.create().set("className", getIntralineBgFromSide(side)).set("readOnly", true);
 
-    Configuration diff = Configuration.create()
-        .set("className", getDiffColorFromSide(side))
-        .set("readOnly", true);
+    Configuration diff =
+        Configuration.create().set("className", getDiffColorFromSide(side)).set("readOnly", true);
 
     Pos last = Pos.create(0, 0);
     for (Span span : Natives.asList(edits)) {
@@ -192,26 +191,25 @@
       }
       getMarkers().add(cm.markText(from, to, diff));
       last = to;
-      colorLines(cm, LineClassWhere.BACKGROUND,
-          getDiffColorFromSide(side),
-          from.line(), to.line());
+      colorLines(cm, LineClassWhere.BACKGROUND, getDiffColorFromSide(side), from.line(), to.line());
     }
   }
 
   private String getIntralineBgFromSide(DisplaySide side) {
-    return side == DisplaySide.A ? UnifiedTable.style.intralineDelete()
+    return side == DisplaySide.A
+        ? UnifiedTable.style.intralineDelete()
         : UnifiedTable.style.intralineInsert();
   }
 
   private String getDiffColorFromSide(DisplaySide side) {
-    return side == DisplaySide.A ? UnifiedTable.style.diffDelete()
+    return side == DisplaySide.A
+        ? UnifiedTable.style.diffDelete()
         : UnifiedTable.style.diffInsert();
   }
 
-  private void addDiffChunk(DisplaySide side, int chunkEnd, int chunkSize,
-      int cmLine, boolean edit) {
-    chunks.add(new UnifiedDiffChunkInfo(side, chunkEnd - chunkSize + 1, chunkEnd,
-        cmLine, edit));
+  private void addDiffChunk(
+      DisplaySide side, int chunkEnd, int chunkSize, int cmLine, boolean edit) {
+    chunks.add(new UnifiedDiffChunkInfo(side, chunkEnd - chunkSize + 1, chunkEnd, cmLine, edit));
   }
 
   @Override
@@ -219,10 +217,9 @@
     return new Runnable() {
       @Override
       public void run() {
-        int line = cm.extras().hasActiveLine()
-            ? cm.getLineNumber(cm.extras().activeLine())
-            : 0;
-        int res = Collections.binarySearch(
+        int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
+        int res =
+            Collections.binarySearch(
                 chunks,
                 new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
                 getDiffChunkComparatorCmLine());
@@ -244,9 +241,9 @@
   @Override
   int getCmLine(int line, DisplaySide side) {
     int res =
-        Collections.binarySearch(chunks,
-            new UnifiedDiffChunkInfo(
-                side, line, 0, 0, false), // Dummy DiffChunkInfo
+        Collections.binarySearch(
+            chunks,
+            new UnifiedDiffChunkInfo(side, line, 0, 0, false), // Dummy DiffChunkInfo
             getDiffChunkComparator());
     if (res >= 0) {
       return chunks.get(res).getCmLine();
@@ -255,22 +252,18 @@
     res = -res - 1;
     if (res > 0) {
       UnifiedDiffChunkInfo info = chunks.get(res - 1);
-      if (side == DisplaySide.A && info.isEdit()
-          && info.getSide() == DisplaySide.B) {
+      if (side == DisplaySide.A && info.isEdit() && info.getSide() == DisplaySide.B) {
         // Need to use the start and cmLine of the deletion chunk
         UnifiedDiffChunkInfo delete = chunks.get(res - 2);
         if (line <= delete.getEnd()) {
           return delete.getCmLine() + line - delete.getStart();
         }
         // Need to add the length of the insertion chunk
-        return delete.getCmLine() + line - delete.getStart()
-            + info.getEnd() - info.getStart() + 1;
+        return delete.getCmLine() + line - delete.getStart() + info.getEnd() - info.getStart() + 1;
       } else if (side == info.getSide()) {
         return info.getCmLine() + line - info.getStart();
       } else {
-        return info.getCmLine()
-            + lineMapper.lineOnOther(side, line).getLine()
-            - info.getStart();
+        return info.getCmLine() + lineMapper.lineOnOther(side, line).getLine() - info.getStart();
       }
     }
     return line;
@@ -278,14 +271,13 @@
 
   LineRegionInfo getLineRegionInfoFromCmLine(int cmLine) {
     int res =
-        Collections.binarySearch(chunks,
-            new UnifiedDiffChunkInfo(
-                DisplaySide.A, 0, 0, cmLine, false), // Dummy DiffChunkInfo
+        Collections.binarySearch(
+            chunks,
+            new UnifiedDiffChunkInfo(DisplaySide.A, 0, 0, cmLine, false), // Dummy DiffChunkInfo
             getDiffChunkComparatorCmLine());
-    if (res >= 0) {  // The line is right at the start of a diff chunk.
+    if (res >= 0) { // The line is right at the start of a diff chunk.
       UnifiedDiffChunkInfo info = chunks.get(res);
-      return new LineRegionInfo(
-          info.getStart(), displaySideToRegionType(info.getSide()));
+      return new LineRegionInfo(info.getStart(), displaySideToRegionType(info.getSide()));
     }
     // The line might be within or after a diff chunk.
     res = -res - 1;
@@ -297,14 +289,12 @@
           // For the common region after a deletion chunk, associate the line
           // on side B with a common region.
           return new LineRegionInfo(
-              lineMapper.lineOnOther(DisplaySide.A, lineOnInfoSide)
-                  .getLine(), RegionType.COMMON);
+              lineMapper.lineOnOther(DisplaySide.A, lineOnInfoSide).getLine(), RegionType.COMMON);
         }
         return new LineRegionInfo(lineOnInfoSide, RegionType.COMMON);
       }
       // Within a diff chunk
-      return new LineRegionInfo(
-          lineOnInfoSide, displaySideToRegionType(info.getSide()));
+      return new LineRegionInfo(lineOnInfoSide, displaySideToRegionType(info.getSide()));
     }
     // The line is before any diff chunk, so it always equals cmLine and
     // belongs to a common region.
@@ -312,7 +302,9 @@
   }
 
   enum RegionType {
-    INSERT, DELETE, COMMON,
+    INSERT,
+    DELETE,
+    COMMON,
   }
 
   private static RegionType displaySideToRegionType(DisplaySide side) {
@@ -320,13 +312,12 @@
   }
 
   /**
-   * Helper class to associate a line in the original file with the type of the
-   * region it belongs to.
+   * Helper class to associate a line in the original file with the type of the region it belongs
+   * to.
    *
-   * @field line The 0-based line number in the original file. Note that this
-   *     might be different from the line number shown in CodeMirror.
-   * @field type The type of the region the line belongs to. Can be INSERT,
-   *     DELETE or COMMON.
+   * @field line The 0-based line number in the original file. Note that this might be different
+   *     from the line number shown in CodeMirror.
+   * @field type The type of the region the line belongs to. Can be INSERT, DELETE or COMMON.
    */
   static class LineRegionInfo {
     final int line;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
index 4effb46..a6912df 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.client.diff;
 
 import com.google.gwt.user.client.Timer;
-
 import net.codemirror.lib.CodeMirror;
 
 /**
  * LineWidget attached to a CodeMirror container.
  *
- * When a comment is placed on a line a CommentWidget is created.
- * The group tracks all comment boxes on a line in unified diff view.
+ * <p>When a comment is placed on a line a CommentWidget is created. The group tracks all comment
+ * boxes on a line in unified diff view.
  */
 class UnifiedCommentGroup extends CommentGroup {
   UnifiedCommentGroup(UnifiedCommentManager manager, CodeMirror cm, DisplaySide side, int line) {
@@ -49,30 +48,33 @@
 
   @Override
   void handleRedraw() {
-    getLineWidget().onRedraw(new Runnable() {
-      @Override
-      public void run() {
-        if (canComputeHeight()) {
-          if (getResizeTimer() != null) {
-            getResizeTimer().cancel();
-            setResizeTimer(null);
-          }
-          reportHeightChange();
-        } else if (getResizeTimer() == null) {
-          setResizeTimer(new Timer() {
-            @Override
-            public void run() {
-              if (canComputeHeight()) {
-                cancel();
-                setResizeTimer(null);
-                reportHeightChange();
+    getLineWidget()
+        .onRedraw(
+            new Runnable() {
+              @Override
+              public void run() {
+                if (canComputeHeight()) {
+                  if (getResizeTimer() != null) {
+                    getResizeTimer().cancel();
+                    setResizeTimer(null);
+                  }
+                  reportHeightChange();
+                } else if (getResizeTimer() == null) {
+                  setResizeTimer(
+                      new Timer() {
+                        @Override
+                        public void run() {
+                          if (canComputeHeight()) {
+                            cancel();
+                            setResizeTimer(null);
+                            reportHeightChange();
+                          }
+                        }
+                      });
+                  getResizeTimer().scheduleRepeating(5);
+                }
               }
-            }
-          });
-          getResizeTimer().scheduleRepeating(5);
-        }
-      }
-    });
+            });
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
index 8968bc7..1d9b55a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentManager.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
+import com.google.gerrit.client.DiffObject;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
@@ -21,16 +22,14 @@
 import com.google.gerrit.client.diff.UnifiedChunkManager.RegionType;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.PatchSet;
-
-import net.codemirror.lib.CodeMirror;
-import net.codemirror.lib.Pos;
-import net.codemirror.lib.TextMarker.FromTo;
-
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import net.codemirror.lib.CodeMirror;
+import net.codemirror.lib.Pos;
+import net.codemirror.lib.TextMarker.FromTo;
 
 /** Tracks comment widgets for {@link Unified}. */
 class UnifiedCommentManager extends CommentManager {
@@ -42,8 +41,10 @@
   // duplicates and replace the entries in mergedMap on draft removal.
   private final Map<Integer, CommentGroup> duplicates;
 
-  UnifiedCommentManager(Unified host,
-      PatchSet.Id base, PatchSet.Id revision,
+  UnifiedCommentManager(
+      Unified host,
+      DiffObject base,
+      PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
       boolean open) {
@@ -70,17 +71,13 @@
   }
 
   @Override
-  void newDraftOnGutterClick(CodeMirror cm, String gutterClass,
-      int cmLinePlusOne) {
+  void newDraftOnGutterClick(CodeMirror cm, String gutterClass, int cmLinePlusOne) {
     if (!Gerrit.isSignedIn()) {
       signInCallback(cm).run();
     } else {
-      LineRegionInfo info =
-          ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
+      LineRegionInfo info = ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
       DisplaySide side =
-          gutterClass.equals(UnifiedTable.style.lineNumbersLeft())
-              ? DisplaySide.A
-              : DisplaySide.B;
+          gutterClass.equals(UnifiedTable.style.lineNumbersLeft()) ? DisplaySide.A : DisplaySide.B;
       int line = info.line;
       if (info.getSide() != side) {
         line = host.lineOnOther(info.getSide(), line).getLine();
@@ -94,12 +91,9 @@
     CommentGroup group = null;
     if (cm.extras().hasActiveLine()) {
       int cmLinePlusOne = cm.getLineNumber(cm.extras().activeLine()) + 1;
-      LineRegionInfo info =
-          ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
+      LineRegionInfo info = ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
       CommentGroup forSide = map(info.getSide()).get(cmLinePlusOne);
-      group = forSide == null
-          ? map(info.getSide().otherSide()).get(cmLinePlusOne)
-          : forSide;
+      group = forSide == null ? map(info.getSide().otherSide()).get(cmLinePlusOne) : forSide;
     }
     return group;
   }
@@ -112,8 +106,7 @@
   @Override
   String getTokenSuffixForActiveLine(CodeMirror cm) {
     int cmLinePlusOne = cm.getLineNumber(cm.extras().activeLine()) + 1;
-    LineRegionInfo info =
-        ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
+    LineRegionInfo info = ((Unified) host).getLineRegionInfoFromCmLine(cmLinePlusOne - 1);
     return (info.getSide() == DisplaySide.A ? "a" : "") + cmLinePlusOne;
   }
 
@@ -125,19 +118,15 @@
       Pos to = fromTo.to();
       Unified unified = (Unified) host;
       UnifiedChunkManager manager = unified.getChunkManager();
-      LineRegionInfo fromInfo =
-          unified.getLineRegionInfoFromCmLine(from.line());
-      LineRegionInfo toInfo =
-          unified.getLineRegionInfoFromCmLine(to.line());
+      LineRegionInfo fromInfo = unified.getLineRegionInfoFromCmLine(from.line());
+      LineRegionInfo toInfo = unified.getLineRegionInfoFromCmLine(to.line());
       DisplaySide side = toInfo.getSide();
 
       // Handle special cases in selections that span multiple regions. Force
       // start line to be on the same side as the end line.
-      if ((fromInfo.type == RegionType.INSERT
-          || fromInfo.type == RegionType.COMMON)
+      if ((fromInfo.type == RegionType.INSERT || fromInfo.type == RegionType.COMMON)
           && toInfo.type == RegionType.DELETE) {
-        LineOnOtherInfo infoOnSideA = manager.lineMapper
-            .lineOnOther(DisplaySide.B, fromInfo.line);
+        LineOnOtherInfo infoOnSideA = manager.lineMapper.lineOnOther(DisplaySide.B, fromInfo.line);
         int startLineOnSideA = infoOnSideA.getLine();
         if (infoOnSideA.isAligned()) {
           from.line(startLineOnSideA);
@@ -146,10 +135,8 @@
         }
         from.ch(0);
         to.line(toInfo.line);
-      } else if (fromInfo.type == RegionType.DELETE
-          && toInfo.type == RegionType.INSERT) {
-        LineOnOtherInfo infoOnSideB = manager.lineMapper
-            .lineOnOther(DisplaySide.A, fromInfo.line);
+      } else if (fromInfo.type == RegionType.DELETE && toInfo.type == RegionType.INSERT) {
+        LineOnOtherInfo infoOnSideB = manager.lineMapper.lineOnOther(DisplaySide.A, fromInfo.line);
         int startLineOnSideB = infoOnSideB.getLine();
         if (infoOnSideB.isAligned()) {
           from.line(startLineOnSideB);
@@ -158,10 +145,8 @@
         }
         from.ch(0);
         to.line(toInfo.line);
-      } else if (fromInfo.type == RegionType.DELETE
-          && toInfo.type == RegionType.COMMON) {
-        int toLineOnSideA = manager.lineMapper
-            .lineOnOther(DisplaySide.B, toInfo.line).getLine();
+      } else if (fromInfo.type == RegionType.DELETE && toInfo.type == RegionType.COMMON) {
+        int toLineOnSideA = manager.lineMapper.lineOnOther(DisplaySide.B, toInfo.line).getLine();
         from.line(fromInfo.line);
         // Force the end line to be on the same side as the start line.
         to.line(toLineOnSideA);
@@ -171,17 +156,20 @@
         to.line(toInfo.line);
       }
 
-      addDraftBox(side, CommentInfo.create(
-              getPath(),
-              getStoredSideFromDisplaySide(side),
-              to.line() + 1,
-              CommentRange.create(fromTo))).setEdit(true);
+      addDraftBox(
+              side,
+              CommentInfo.create(
+                  getPath(),
+                  getStoredSideFromDisplaySide(side),
+                  to.line() + 1,
+                  CommentRange.create(fromTo),
+                  false))
+          .setEdit(true);
       cm.setCursor(Pos.create(host.getCmLine(to.line(), side), to.ch()));
       cm.setSelection(cm.getCursor());
     } else {
       int cmLine = cm.getLineNumber(cm.extras().activeLine());
-      LineRegionInfo info =
-          ((Unified) host).getLineRegionInfoFromCmLine(cmLine);
+      LineRegionInfo info = ((Unified) host).getLineRegionInfoFromCmLine(cmLine);
       insertNewDraft(info.getSide(), cmLine + 1);
     }
   }
@@ -194,8 +182,8 @@
       return existing;
     }
 
-    UnifiedCommentGroup g = new UnifiedCommentGroup(
-        this, host.getCmFromSide(side), side, cmLinePlusOne);
+    UnifiedCommentGroup g =
+        new UnifiedCommentGroup(this, host.getCmFromSide(side), side, cmLinePlusOne);
     map.put(cmLinePlusOne, g);
     if (mergedMap.containsKey(cmLinePlusOne)) {
       duplicates.put(cmLinePlusOne, mergedMap.remove(cmLinePlusOne));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java
index 844be78..dc827cb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedDiffChunkInfo.java
@@ -18,8 +18,7 @@
 
   private int cmLine;
 
-  UnifiedDiffChunkInfo(DisplaySide side,
-      int start, int end, int cmLine, boolean edit) {
+  UnifiedDiffChunkInfo(DisplaySide side, int start, int end, int cmLine, boolean edit) {
     super(side, start, end, edit);
     this.cmLine = cmLine;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
index 72b3e49..2d5df63 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedTable.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.client.diff;
 
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.client.DiffObject;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.resources.client.CssResource;
@@ -23,21 +23,29 @@
 import com.google.gwt.user.client.ui.HTMLPanel;
 
 /**
- * A table with one row and one column to hold a unified CodeMirror displaying
- * the files to be compared.
+ * A table with one row and one column to hold a unified CodeMirror displaying the files to be
+ * compared.
  */
 class UnifiedTable extends DiffTable {
   interface Binder extends UiBinder<HTMLPanel, UnifiedTable> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface DiffTableStyle extends CssResource {
     String intralineInsert();
+
     String intralineDelete();
+
     String diffInsert();
+
     String diffDelete();
+
     String unifiedLineNumber();
+
     String unifiedLineNumberEmpty();
+
     String lineNumbersLeft();
+
     String lineNumbersRight();
   }
 
@@ -45,8 +53,7 @@
   @UiField Element cm;
   @UiField static DiffTableStyle style;
 
-  UnifiedTable(Unified parent, PatchSet.Id base, PatchSet.Id revision,
-      String path) {
+  UnifiedTable(Unified parent, DiffObject base, DiffObject revision, String path) {
     super(parent, base, revision, path);
 
     initWidget(uiBinder.createAndBindUi(this));
@@ -54,8 +61,7 @@
   }
 
   @Override
-  void setHideEmptyPane(boolean hide) {
-  }
+  void setHideEmptyPane(boolean hide) {}
 
   @Override
   boolean isVisibleA() {
@@ -69,8 +75,7 @@
 
   @Override
   int getHeaderHeight() {
-    int h = patchSetSelectBoxA.getOffsetHeight()
-        + patchSetSelectBoxB.getOffsetHeight();
+    int h = patchSetSelectBoxA.getOffsetHeight() + patchSetSelectBoxB.getOffsetHeight();
     if (hasHeader()) {
       h += diffHeaderRow.getOffsetHeight();
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java
index 5d55494..ea2f2cf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java
@@ -31,8 +31,6 @@
 
   @Override
   public void onKeyPress(final KeyPressEvent event) {
-    Gerrit.display(PageLinks.toChange(
-        revision.getParentKey(),
-        revision.getId()));
+    Gerrit.display(PageLinks.toChange(revision.getParentKey(), revision.getId()));
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.java
index f6b7a9d..2958783 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocConstants.java
@@ -20,6 +20,8 @@
   String keyReloadSearch();
 
   String docItemHelp();
+
   String docTableColumnTitle();
+
   String docTableNone();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocInfo.java
index fd63ffc..5fcb6b0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocInfo.java
@@ -20,14 +20,14 @@
 public class DocInfo extends JavaScriptObject {
 
   public final native String title() /*-{ return this.title; }-*/;
+
   public final native String url() /*-{ return this.url; }-*/;
 
   public static DocInfo create() {
     return (DocInfo) createObject();
   }
 
-  protected DocInfo() {
-  }
+  protected DocInfo() {}
 
   public final String getFullUrl() {
     return GWT.getHostPageBaseURL() + url();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.java
index df62b92..7d76f7b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocMessages.java
@@ -18,5 +18,6 @@
 
 public interface DocMessages extends Messages {
   String docQueryWindowTitle(String query);
+
   String docQueryPageTitle(String query);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java
index 0c5be8e..677c2bf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/documentation/DocTable.java
@@ -39,18 +39,19 @@
     FlexCellFormatter fmt = table.getFlexCellFormatter();
     fmt.addStyleName(0, C_TITLE, Gerrit.RESOURCES.css().dataHeader());
 
-    table.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        Cell cell = table.getCellForEvent(event);
-        if (cell == null) {
-          return;
-        }
-        if (getRowItem(cell.getRowIndex()) != null) {
-          movePointerTo(cell.getRowIndex());
-        }
-      }
-    });
+    table.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            Cell cell = table.getCellForEvent(event);
+            if (cell == null) {
+              return;
+            }
+            if (getRowItem(cell.getRowIndex()) != null) {
+              movePointerTo(cell.getRowIndex());
+            }
+          }
+        });
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandLink.java
index 4a6ecab..2b09175 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandLink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandLink.java
@@ -27,8 +27,7 @@
   private final CopyableLabel copyLabel;
   private final String command;
 
-  public DownloadCommandLink(CopyableLabel copyLabel,
-      DownloadCommandInfo commandInfo) {
+  public DownloadCommandLink(CopyableLabel copyLabel, DownloadCommandInfo commandInfo) {
     super(commandInfo.name());
     this.copyLabel = copyLabel;
     this.command = commandInfo.command();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandPanel.java
index 21c33e2..20cf3f3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadCommandPanel.java
@@ -37,8 +37,7 @@
     for (Widget w : this) {
       if (w instanceof DownloadCommandLink) {
         DownloadCommandLink d = (DownloadCommandLink) w;
-        if (currentCommand != null
-            && d.getText().equals(currentCommand.getText())) {
+        if (currentCommand != null && d.getText().equals(currentCommand.getText())) {
           d.select();
           return;
         }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadPanel.java
index 7119bd2..b881505 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadPanel.java
@@ -20,7 +20,6 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.InlineLabel;
 import com.google.gwtexpui.clippy.client.CopyableLabel;
-
 import java.util.List;
 
 public abstract class DownloadPanel extends FlowPanel {
@@ -63,6 +62,5 @@
     commands.select();
   }
 
-  protected abstract List<DownloadCommandInfo> getCommands(
-      DownloadSchemeInfo schemeInfo);
+  protected abstract List<DownloadCommandInfo> getCommands(DownloadSchemeInfo schemeInfo);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java
index 086376a..76e7d7c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlLink.java
@@ -25,7 +25,6 @@
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.Anchor;
 import com.google.gwt.user.client.ui.Widget;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -47,8 +46,8 @@
   private final DownloadSchemeInfo schemeInfo;
   private final String schemeName;
 
-  public DownloadUrlLink(DownloadPanel downloadPanel,
-      DownloadSchemeInfo schemeInfo, String schemeName) {
+  public DownloadUrlLink(
+      DownloadPanel downloadPanel, DownloadSchemeInfo schemeInfo, String schemeName) {
     super(schemeName);
     setStyleName(Gerrit.RESOURCES.css().downloadLink());
     Roles.getTabRole().set(getElement());
@@ -75,16 +74,17 @@
       prefs.downloadScheme(schemeName);
       GeneralPreferences in = GeneralPreferences.create();
       in.downloadScheme(schemeName);
-      AccountApi.self().view("preferences")
-          .put(in, new AsyncCallback<JavaScriptObject>() {
-            @Override
-            public void onSuccess(JavaScriptObject result) {
-            }
+      AccountApi.self()
+          .view("preferences")
+          .put(
+              in,
+              new AsyncCallback<JavaScriptObject>() {
+                @Override
+                public void onSuccess(JavaScriptObject result) {}
 
-            @Override
-            public void onFailure(Throwable caught) {
-            }
-          });
+                @Override
+                public void onFailure(Throwable caught) {}
+              });
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlPanel.java
index 541c6f3..6a5fbe4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/download/DownloadUrlPanel.java
@@ -18,7 +18,6 @@
 import com.google.gwt.aria.client.Roles;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.Widget;
-
 import java.util.Collection;
 
 public class DownloadUrlPanel extends FlowPanel {
@@ -56,7 +55,7 @@
   }
 
   public void add(Collection<DownloadUrlLink> links) {
-    for (Widget link: links) {
+    for (Widget link : links) {
       add(link);
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java
index 8dc4137..b70c209 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditConstants.java
@@ -21,6 +21,7 @@
   EditConstants I = GWT.create(EditConstants.class);
 
   String closeUnsavedChanges();
+
   String cancelUnsavedChanges();
 
   String gotoLineNumber();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditFileInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditFileInfo.java
index dda5fc2..3f9d732 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditFileInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditFileInfo.java
@@ -21,6 +21,5 @@
 public class EditFileInfo extends JavaScriptObject {
   public final native JsArray<DiffWebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
 
-  protected EditFileInfo() {
-  }
+  protected EditFileInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesAction.java
index d297f4d..e11ded0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesAction.java
@@ -43,20 +43,22 @@
     popup = new PopupPanel(true, false);
     popup.setStyleName(current.style.dialog());
     popup.add(current);
-    popup.addCloseHandler(new CloseHandler<PopupPanel>() {
-      @Override
-      public void onClose(CloseEvent<PopupPanel> event) {
-        view.getEditor().focus();
-        popup = null;
-        current = null;
-      }
-    });
-    popup.setPopupPositionAndShow(new PositionCallback() {
-      @Override
-      public void setPosition(int offsetWidth, int offsetHeight) {
-        popup.setPopupPosition(300, 120);
-      }
-    });
+    popup.addCloseHandler(
+        new CloseHandler<PopupPanel>() {
+          @Override
+          public void onClose(CloseEvent<PopupPanel> event) {
+            view.getEditor().focus();
+            popup = null;
+            current = null;
+          }
+        });
+    popup.setPopupPositionAndShow(
+        new PositionCallback() {
+          @Override
+          public void setPosition(int offsetWidth, int offsetHeight) {
+            popup.setPopupPosition(300, 120);
+          }
+        });
   }
 
   void hide() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java
index 6694e7c..6ef71e6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.java
@@ -40,12 +40,12 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 import com.google.gwt.user.client.ui.ToggleButton;
 import com.google.gwt.user.client.ui.UIObject;
-
 import net.codemirror.theme.ThemeLoader;
 
 /** Displays current edit preferences. */
 public class EditPreferencesBox extends Composite {
   interface Binder extends UiBinder<HTMLPanel, EditPreferencesBox> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   public interface Style extends CssResource {
@@ -69,6 +69,7 @@
   @UiField ToggleButton lineNumbers;
   @UiField ToggleButton matchBrackets;
   @UiField ToggleButton lineWrapping;
+  @UiField ToggleButton indentWithTabs;
   @UiField ToggleButton autoCloseBrackets;
   @UiField ToggleButton showBase;
   @UiField ListBox theme;
@@ -106,6 +107,7 @@
     lineNumbers.setValue(prefs.hideLineNumbers());
     matchBrackets.setValue(prefs.matchBrackets());
     lineWrapping.setValue(prefs.lineWrapping());
+    indentWithTabs.setValue(prefs.indentWithTabs());
     autoCloseBrackets.setValue(prefs.autoCloseBrackets());
     showBase.setValue(prefs.showBase());
     setTheme(prefs.theme());
@@ -215,6 +217,14 @@
     }
   }
 
+  @UiHandler("indentWithTabs")
+  void onIndentWithTabs(ValueChangeEvent<Boolean> e) {
+    prefs.indentWithTabs(e.getValue());
+    if (view != null) {
+      view.getEditor().setOption("indentWithTabs", prefs.indentWithTabs());
+    }
+  }
+
   @UiHandler("autoCloseBrackets")
   void onCloseBrackets(ValueChangeEvent<Boolean> e) {
     prefs.autoCloseBrackets(e.getValue());
@@ -237,19 +247,20 @@
     final Theme newTheme = Theme.valueOf(theme.getValue(theme.getSelectedIndex()));
     prefs.theme(newTheme);
     if (view != null) {
-      ThemeLoader.loadTheme(newTheme, new GerritCallback<Void>() {
-        @Override
-        public void onSuccess(Void result) {
-          view.setTheme(newTheme);
-        }
-      });
+      ThemeLoader.loadTheme(
+          newTheme,
+          new GerritCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              view.setTheme(newTheme);
+            }
+          });
     }
   }
 
   @UiHandler("keyMap")
   void onKeyMap(@SuppressWarnings("unused") ChangeEvent e) {
-    KeyMapType keyMapType = KeyMapType.valueOf(
-        keyMap.getValue(keyMap.getSelectedIndex()));
+    KeyMapType keyMapType = KeyMapType.valueOf(keyMap.getValue(keyMap.getSelectedIndex()));
     prefs.keyMapType(keyMapType);
     if (view != null) {
       view.setOption("keyMap", keyMapType.name().toLowerCase());
@@ -263,7 +274,8 @@
 
   @UiHandler("save")
   void onSave(@SuppressWarnings("unused") ClickEvent e) {
-    AccountApi.putEditPreferences(prefs,
+    AccountApi.putEditPreferences(
+        prefs,
         new GerritCallback<EditPreferences>() {
           @Override
           public void onSuccess(EditPreferences p) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml
index 6379b67..f5ec71e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditPreferencesBox.ui.xml
@@ -246,6 +246,13 @@
         </g:ToggleButton></td>
       </tr>
       <tr>
+        <th><ui:msg>Indent With Tabs</ui:msg></th>
+        <td><g:ToggleButton ui:field='indentWithTabs'>
+          <g:upFace><ui:msg>Off</ui:msg></g:upFace>
+          <g:downFace><ui:msg>On</ui:msg></g:downFace>
+        </g:ToggleButton></td>
+      </tr>
+      <tr>
         <th><ui:msg>Auto Close Brackets</ui:msg></th>
         <td><g:ToggleButton ui:field='autoCloseBrackets'>
           <g:upFace><ui:msg>Off</ui:msg></g:upFace>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
index 8a141f1..027fb40 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
@@ -73,7 +73,7 @@
 import com.google.gwt.user.client.ui.ImageResourceRenderer;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
-
+import java.util.List;
 import net.codemirror.addon.AddonInjector;
 import net.codemirror.addon.Addons;
 import net.codemirror.lib.CodeMirror;
@@ -87,19 +87,19 @@
 import net.codemirror.mode.ModeInjector;
 import net.codemirror.theme.ThemeLoader;
 
-import java.util.List;
-
 public class EditScreen extends Screen {
   interface Binder extends UiBinder<HTMLPanel, EditScreen> {}
+
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   interface Style extends CssResource {
     String fullWidth();
+
     String base();
+
     String hideBase();
   }
 
-  private final PatchSet.Id base;
   private final PatchSet.Id revision;
   private final String path;
   private final int startLine;
@@ -130,8 +130,7 @@
   private HandlerRegistration closeHandler;
   private int generation;
 
-  public EditScreen(PatchSet.Id base, Patch.Key patch, int startLine) {
-    this.base = base;
+  public EditScreen(Patch.Key patch, int startLine) {
     this.revision = patch.getParentKey();
     this.path = patch.get();
     this.startLine = startLine - 1;
@@ -157,74 +156,82 @@
     final CallbackGroup group2 = new CallbackGroup();
     final CallbackGroup group3 = new CallbackGroup();
 
-    CodeMirror.initLibrary(group1.add(new AsyncCallback<Void>() {
-      final AsyncCallback<Void> themeCallback = group3.addEmpty();
-
-      @Override
-      public void onSuccess(Void result) {
-        // Load theme after CM library to ensure theme can override CSS.
-        ThemeLoader.loadTheme(prefs.theme(), themeCallback);
-        group2.done();
-
-        new AddonInjector().add(Addons.I.merge_bundled().getName()).inject(
+    CodeMirror.initLibrary(
+        group1.add(
             new AsyncCallback<Void>() {
-          @Override
-          public void onFailure(Throwable caught) {
-          }
+              final AsyncCallback<Void> themeCallback = group3.addEmpty();
 
-          @Override
-          public void onSuccess(Void result) {
-            if (!prefs.showBase() || revision.get() > 0) {
-              group3.done();
-            }
-          }
-        });
-      }
+              @Override
+              public void onSuccess(Void result) {
+                // Load theme after CM library to ensure theme can override CSS.
+                ThemeLoader.loadTheme(prefs.theme(), themeCallback);
+                group2.done();
 
-      @Override
-      public void onFailure(Throwable caught) {
-      }
-    }));
+                new AddonInjector()
+                    .add(Addons.I.merge_bundled().getName())
+                    .inject(
+                        new AsyncCallback<Void>() {
+                          @Override
+                          public void onFailure(Throwable caught) {}
 
-    ChangeApi.detail(revision.getParentKey().get(),
-        group1.add(new AsyncCallback<ChangeInfo>() {
-          @Override
-          public void onSuccess(ChangeInfo c) {
-            project.setInnerText(c.project());
-            SafeHtml.setInnerHTML(filePath, Header.formatPath(path));
-          }
+                          @Override
+                          public void onSuccess(Void result) {
+                            if (!prefs.showBase() || revision.get() > 0) {
+                              group3.done();
+                            }
+                          }
+                        });
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-          }
-        }));
+              @Override
+              public void onFailure(Throwable caught) {}
+            }));
+
+    ChangeApi.detail(
+        revision.getParentKey().get(),
+        group1.add(
+            new AsyncCallback<ChangeInfo>() {
+              @Override
+              public void onSuccess(ChangeInfo c) {
+                project.setInnerText(c.project());
+                SafeHtml.setInnerHTML(filePath, Header.formatPath(path));
+              }
+
+              @Override
+              public void onFailure(Throwable caught) {}
+            }));
 
     if (revision.get() == 0) {
-      ChangeEditApi.getMeta(revision, path,
-          group1.add(new AsyncCallback<EditFileInfo>() {
-            @Override
-            public void onSuccess(EditFileInfo editInfo) {
-              editFileInfo = editInfo;
-            }
+      ChangeEditApi.getMeta(
+          revision,
+          path,
+          group1.add(
+              new AsyncCallback<EditFileInfo>() {
+                @Override
+                public void onSuccess(EditFileInfo editInfo) {
+                  editFileInfo = editInfo;
+                }
 
-            @Override
-            public void onFailure(Throwable e) {
-            }
-          }));
+                @Override
+                public void onFailure(Throwable e) {}
+              }));
 
       if (prefs.showBase()) {
-        ChangeEditApi.get(revision, path, true /* base */,
-            group1.addFinal(new HttpCallback<NativeString>() {
-              @Override
-              public void onSuccess(HttpResponse<NativeString> fc) {
-                baseContent = fc;
-                group3.done();
-              }
+        ChangeEditApi.get(
+            revision,
+            path,
+            true /* base */,
+            group1.addFinal(
+                new HttpCallback<NativeString>() {
+                  @Override
+                  public void onSuccess(HttpResponse<NativeString> fc) {
+                    baseContent = fc;
+                    group3.done();
+                  }
 
-              @Override
-              public void onFailure(Throwable e) {
-              }
-            }));
+                  @Override
+                  public void onFailure(Throwable e) {}
+                }));
       } else {
         group1.done();
       }
@@ -232,73 +239,74 @@
       // TODO(davido): We probably want to create dedicated GET EditScreenMeta
       // REST endpoint. Abuse GET diff for now, as it retrieves links we need.
       DiffApi.diff(revision, path)
-        .base(base)
-        .webLinksOnly()
-        .get(group1.addFinal(new AsyncCallback<DiffInfo>() {
-          @Override
-          public void onSuccess(DiffInfo diffInfo) {
-            diffLinks = diffInfo.webLinks();
-          }
+          .webLinksOnly()
+          .get(
+              group1.addFinal(
+                  new AsyncCallback<DiffInfo>() {
+                    @Override
+                    public void onSuccess(DiffInfo diffInfo) {
+                      diffLinks = diffInfo.webLinks();
+                    }
 
-          @Override
-          public void onFailure(Throwable e) {
-          }
-      }));
+                    @Override
+                    public void onFailure(Throwable e) {}
+                  }));
     }
 
-    ChangeEditApi.get(revision, path,
-        group2.add(new HttpCallback<NativeString>() {
-          final AsyncCallback<Void> modeCallback = group3.addEmpty();
+    ChangeEditApi.get(
+        revision,
+        path,
+        group2.add(
+            new HttpCallback<NativeString>() {
+              final AsyncCallback<Void> modeCallback = group3.addEmpty();
 
+              @Override
+              public void onSuccess(HttpResponse<NativeString> fc) {
+                content = fc;
+                if (revision.get() > 0) {
+                  baseContent = fc;
+                }
+
+                if (prefs.syntaxHighlighting()) {
+                  injectMode(fc.getContentType(), modeCallback);
+                } else {
+                  modeCallback.onSuccess(null);
+                }
+              }
+
+              @Override
+              public void onFailure(Throwable e) {
+                // "Not Found" means it's a new file.
+                if (RestApi.isNotFound(e)) {
+                  content = null;
+                  modeCallback.onSuccess(null);
+                } else {
+                  GerritCallback.showFailure(e);
+                }
+              }
+            }));
+
+    group3.addListener(
+        new ScreenLoadCallback<Void>(this) {
           @Override
-          public void onSuccess(HttpResponse<NativeString> fc) {
-            content = fc;
-            if (revision.get() > 0) {
-              baseContent = fc;
-            }
+          protected void preDisplay(Void result) {
+            initEditor();
 
-            if (prefs.syntaxHighlighting()) {
-              injectMode(fc.getContentType(), modeCallback);
-            } else {
-              modeCallback.onSuccess(null);
-            }
+            renderLinks(editFileInfo, diffLinks);
+            editFileInfo = null;
+            diffLinks = null;
+
+            showBase.setValue(prefs.showBase(), true);
+            cmBase.refresh();
           }
-
-          @Override
-          public void onFailure(Throwable e) {
-            // "Not Found" means it's a new file.
-            if (RestApi.isNotFound(e)) {
-              content = null;
-              modeCallback.onSuccess(null);
-            } else {
-              GerritCallback.showFailure(e);
-            }
-          }
-        }));
-
-    group3.addListener(new ScreenLoadCallback<Void>(this) {
-      @Override
-      protected void preDisplay(Void result) {
-        initEditor();
-
-        renderLinks(editFileInfo, diffLinks);
-        editFileInfo = null;
-        diffLinks = null;
-
-        showBase.setValue(prefs.showBase(), true);
-        cmBase.refresh();
-      }
-    });
+        });
   }
 
   @Override
   public void registerKeys() {
     super.registerKeys();
     KeyMap localKeyMap = KeyMap.create();
-    localKeyMap
-        .on("Ctrl-L", gotoLine())
-        .on("Cmd-L", gotoLine())
-        .on("Cmd-S", save());
+    localKeyMap.on("Ctrl-L", gotoLine()).on("Cmd-L", gotoLine()).on("Cmd-S", save());
 
     // TODO(davido): Find a better way to prevent key maps collisions
     if (prefs.keyMapType() != KeyMapType.EMACS) {
@@ -326,29 +334,34 @@
     if (prefs.hideTopMenu()) {
       Gerrit.setHeaderVisible(false);
     }
-    resizeHandler = Window.addResizeHandler(new ResizeHandler() {
-      @Override
-      public void onResize(ResizeEvent event) {
-        adjustHeight();
-      }
-    });
-    closeHandler = Window.addWindowClosingHandler(new ClosingHandler() {
-      @Override
-      public void onWindowClosing(ClosingEvent event) {
-        if (!cmEdit.isClean(generation)) {
-          event.setMessage(EditConstants.I.closeUnsavedChanges());
-        }
-      }
-    });
+    resizeHandler =
+        Window.addResizeHandler(
+            new ResizeHandler() {
+              @Override
+              public void onResize(ResizeEvent event) {
+                adjustHeight();
+              }
+            });
+    closeHandler =
+        Window.addWindowClosingHandler(
+            new ClosingHandler() {
+              @Override
+              public void onWindowClosing(ClosingEvent event) {
+                if (!cmEdit.isClean(generation)) {
+                  event.setMessage(EditConstants.I.closeUnsavedChanges());
+                }
+              }
+            });
 
     generation = cmEdit.changeGeneration(true);
     setClean(true);
-    cmEdit.on(new ChangesHandler() {
-      @Override
-      public void handle(CodeMirror cm) {
-        setClean(cm.isClean(generation));
-      }
-    });
+    cmEdit.on(
+        new ChangesHandler() {
+          @Override
+          public void handle(CodeMirror cm) {
+            setClean(cm.isClean(generation));
+          }
+        });
 
     adjustHeight();
     cmEdit.on("cursorActivity", updateCursorPosition());
@@ -400,17 +413,14 @@
 
   @UiHandler("close")
   void onClose(@SuppressWarnings("unused") ClickEvent e) {
-    if (cmEdit.isClean(generation)
-        || Window.confirm(EditConstants.I.cancelUnsavedChanges())) {
+    if (cmEdit.isClean(generation) || Window.confirm(EditConstants.I.cancelUnsavedChanges())) {
       upToChange();
     }
   }
 
   private void displayBase() {
-    cmBase.getWrapperElement().getParentElement()
-        .removeClassName(style.hideBase());
-    cmEdit.getWrapperElement().getParentElement()
-        .removeClassName(style.fullWidth());
+    cmBase.getWrapperElement().getParentElement().removeClassName(style.hideBase());
+    cmEdit.getWrapperElement().getParentElement().removeClassName(style.fullWidth());
     mv.getGapElement().removeClassName(style.hideBase());
     setCmBaseValue();
     setLineLength(prefs.lineLength());
@@ -422,7 +432,10 @@
     boolean shouldShow = e.getValue();
     if (shouldShow) {
       if (baseContent == null) {
-        ChangeEditApi.get(revision, path, true /* base */,
+        ChangeEditApi.get(
+            revision,
+            path,
+            true /* base */,
             new HttpCallback<NativeString>() {
               @Override
               public void onSuccess(HttpResponse<NativeString> fc) {
@@ -431,17 +444,14 @@
               }
 
               @Override
-              public void onFailure(Throwable e) {
-              }
+              public void onFailure(Throwable e) {}
             });
       } else {
         displayBase();
       }
     } else {
-      cmBase.getWrapperElement().getParentElement()
-          .addClassName(style.hideBase());
-      cmEdit.getWrapperElement().getParentElement()
-          .addClassName(style.fullWidth());
+      cmBase.getWrapperElement().getParentElement().addClassName(style.hideBase());
+      cmEdit.getWrapperElement().getParentElement().addClassName(style.fullWidth());
       mv.getGapElement().addClassName(style.hideBase());
     }
     mv.setShowDifferences(shouldShow);
@@ -463,18 +473,20 @@
   }
 
   void setTheme(final Theme newTheme) {
-    cmBase.operation(new Runnable() {
-      @Override
-      public void run() {
-        cmBase.setOption("theme", newTheme.name().toLowerCase());
-      }
-    });
-    cmEdit.operation(new Runnable() {
-      @Override
-      public void run() {
-        cmEdit.setOption("theme", newTheme.name().toLowerCase());
-      }
-    });
+    cmBase.operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            cmBase.setOption("theme", newTheme.name().toLowerCase());
+          }
+        });
+    cmEdit.operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            cmEdit.setOption("theme", newTheme.name().toLowerCase());
+          }
+        });
   }
 
   void setLineLength(int length) {
@@ -493,18 +505,20 @@
   }
 
   void setShowWhitespaceErrors(final boolean show) {
-    cmBase.operation(new Runnable() {
-      @Override
-      public void run() {
-        cmBase.setOption("showTrailingSpace", show);
-      }
-    });
-    cmEdit.operation(new Runnable() {
-      @Override
-      public void run() {
-        cmEdit.setOption("showTrailingSpace", show);
-      }
-    });
+    cmBase.operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            cmBase.setOption("showTrailingSpace", show);
+          }
+        });
+    cmEdit.operation(
+        new Runnable() {
+          @Override
+          public void run() {
+            cmEdit.setOption("showTrailingSpace", show);
+          }
+        });
   }
 
   void setShowTabs(boolean show) {
@@ -514,11 +528,8 @@
 
   void adjustHeight() {
     int height = header.getOffsetHeight();
-    int rest = Gerrit.getHeaderFooterHeight()
-        + height
-        + 5; // Estimate
-    mv.getGapElement().getStyle().setHeight(
-        Window.getClientHeight() - rest, Unit.PX);
+    int rest = Gerrit.getHeaderFooterHeight() + height + 5; // Estimate
+    mv.getGapElement().getStyle().setHeight(Window.getClientHeight() - rest, Unit.PX);
     cmBase.adjustHeight(height);
     cmEdit.adjustHeight(height);
   }
@@ -527,18 +538,20 @@
     ModeInfo modeInfo = ModeInfo.findMode(content.getContentType(), path);
     final String mode = modeInfo != null ? modeInfo.mime() : null;
     if (b && mode != null && !mode.isEmpty()) {
-      injectMode(mode, new AsyncCallback<Void>() {
-        @Override
-        public void onSuccess(Void result) {
-          cmBase.setOption("mode", mode);
-          cmEdit.setOption("mode", mode);
-        }
+      injectMode(
+          mode,
+          new AsyncCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              cmBase.setOption("mode", mode);
+              cmEdit.setOption("mode", mode);
+            }
 
-        @Override
-        public void onFailure(Throwable caught) {
-          prefs.syntaxHighlighting(false);
-        }
-      });
+            @Override
+            public void onFailure(Throwable caught) {
+              prefs.syntaxHighlighting(false);
+            }
+          });
     } else {
       cmBase.setOption("mode", (String) null);
       cmEdit.setOption("mode", (String) null);
@@ -558,23 +571,26 @@
         mode = ModeInfo.findMode(content.getContentType(), path);
       }
     }
-    Configuration cfg = Configuration.create()
-        .set("autoCloseBrackets", prefs.autoCloseBrackets())
-        .set("cursorBlinkRate", prefs.cursorBlinkRate())
-        .set("cursorHeight", 0.85)
-        .set("indentUnit", prefs.indentUnit())
-        .set("keyMap", prefs.keyMapType().name().toLowerCase())
-        .set("lineNumbers", prefs.hideLineNumbers())
-        .set("lineWrapping", prefs.lineWrapping())
-        .set("matchBrackets", prefs.matchBrackets())
-        .set("mode", mode != null ? mode.mime() : null)
-        .set("origLeft", editContent)
-        .set("scrollbarStyle", "overlay")
-        .set("showTrailingSpace", prefs.showWhitespaceErrors())
-        .set("styleSelectedText", true)
-        .set("tabSize", prefs.tabSize())
-        .set("theme", prefs.theme().name().toLowerCase())
-        .set("value", "");
+
+    Configuration cfg =
+        Configuration.create()
+            .set("autoCloseBrackets", prefs.autoCloseBrackets())
+            .set("cursorBlinkRate", prefs.cursorBlinkRate())
+            .set("cursorHeight", 0.85)
+            .set("indentUnit", prefs.indentUnit())
+            .set("keyMap", prefs.keyMapType().name().toLowerCase())
+            .set("lineNumbers", prefs.hideLineNumbers())
+            .set("lineWrapping", prefs.lineWrapping())
+            .set("indentWithTabs", prefs.indentWithTabs())
+            .set("matchBrackets", prefs.matchBrackets())
+            .set("mode", mode != null ? mode.mime() : null)
+            .set("origLeft", editContent)
+            .set("scrollbarStyle", "overlay")
+            .set("showTrailingSpace", prefs.showWhitespaceErrors())
+            .set("styleSelectedText", true)
+            .set("tabSize", prefs.tabSize())
+            .set("theme", prefs.theme().name().toLowerCase())
+            .set("value", "");
 
     if (editContent.contains("\r\n")) {
       cfg.set("lineSeparator", "\r\n");
@@ -588,16 +604,17 @@
     setCmBaseValue();
     cmEdit.setValue(editContent);
 
-    CodeMirror.addCommand("save", new CommandRunner() {
-      @Override
-      public void run(CodeMirror instance) {
-        save().run();
-      }
-    });
+    CodeMirror.addCommand(
+        "save",
+        new CommandRunner() {
+          @Override
+          public void run(CodeMirror instance) {
+            save().run();
+          }
+        });
   }
 
-  private void renderLinks(EditFileInfo editInfo,
-      JsArray<DiffWebLinkInfo> diffLinks) {
+  private void renderLinks(EditFileInfo editInfo, JsArray<DiffWebLinkInfo> diffLinks) {
     renderLinksToDiff();
 
     if (editInfo != null) {
@@ -617,18 +634,16 @@
 
   private void renderLinksToDiff() {
     InlineHyperlink sbs = new InlineHyperlink();
-    sbs.setHTML(new ImageResourceRenderer()
-        .render(Gerrit.RESOURCES.sideBySideDiff()));
+    sbs.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
     sbs.setTargetHistoryToken(
-        Dispatcher.toPatch("sidebyside", base, new Patch.Key(revision, path)));
+        Dispatcher.toPatch("sidebyside", null, new Patch.Key(revision, path)));
     sbs.setTitle(PatchUtil.C.sideBySideDiff());
     linkPanel.add(sbs);
 
     InlineHyperlink unified = new InlineHyperlink();
-    unified.setHTML(new ImageResourceRenderer()
-        .render(Gerrit.RESOURCES.unifiedDiff()));
+    unified.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
     unified.setTargetHistoryToken(
-        Dispatcher.toPatch("unified", base, new Patch.Key(revision, path)));
+        Dispatcher.toPatch("unified", null, new Patch.Key(revision, path)));
     unified.setTitle(PatchUtil.C.unifiedDiff());
     linkPanel.add(unified);
   }
@@ -642,17 +657,20 @@
         // key (or j/k) is held down. Performance on Chrome is fine
         // without the deferral.
         //
-        Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-          @Override
-          public void execute() {
-            cmEdit.operation(new Runnable() {
-              @Override
-              public void run() {
-                updateActiveLine();
-              }
-            });
-          }
-        });
+        Scheduler.get()
+            .scheduleDeferred(
+                new ScheduledCommand() {
+                  @Override
+                  public void execute() {
+                    cmEdit.operation(
+                        new Runnable() {
+                          @Override
+                          public void run() {
+                            updateActiveLine();
+                          }
+                        });
+                  }
+                });
       }
     };
   }
@@ -685,13 +703,17 @@
             }
           }
           final int g = cmEdit.changeGeneration(false);
-          ChangeEditApi.put(revision.getParentKey().get(), path, text,
+          ChangeEditApi.put(
+              revision.getParentKey().get(),
+              path,
+              text,
               new GerritCallback<VoidResult>() {
                 @Override
                 public void onSuccess(VoidResult result) {
                   generation = g;
                   setClean(cmEdit.isClean(g));
                 }
+
                 @Override
                 public void onFailure(final Throwable caught) {
                   close.setEnabled(true);
@@ -707,8 +729,9 @@
   }
 
   private void setCmBaseValue() {
-    cmBase.setValue(baseContent != null && baseContent.getResult() != null
-        ? baseContent.getResult().asString()
-        : "");
+    cmBase.setValue(
+        baseContent != null && baseContent.getResult() != null
+            ? baseContent.getResult().asString()
+            : "");
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
index 4190672..4076296 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
@@ -449,6 +449,11 @@
   white-space: nowrap;
 }
 
+.changeTable .cASSIGNEDTOME {
+  background: #ffe9d6 !important;
+}
+
+.changeTable .cASSIGNEE,
 .changeTable .cOWNER,
 .changeTable .cSTATUS {
   white-space: nowrap;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
index 93be87b..74cfaf1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.RestApi;
@@ -23,13 +24,9 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import java.util.Set;
 
-/**
- * A collection of static methods which work on the Gerrit REST API for specific
- * groups.
- */
+/** A collection of static methods which work on the Gerrit REST API for specific groups. */
 public class GroupApi {
   /** Create a new group */
   public static void createGroup(String groupName, AsyncCallback<GroupInfo> cb) {
@@ -42,36 +39,38 @@
   }
 
   /** Get the name of a group */
-  public static void getGroupName(AccountGroup.UUID group,
-      AsyncCallback<NativeString> cb) {
+  public static void getGroupName(AccountGroup.UUID group, AsyncCallback<NativeString> cb) {
     group(group).view("name").get(cb);
   }
 
   /** Check if the current user is owner of a group */
   public static void isGroupOwner(String groupName, final AsyncCallback<Boolean> cb) {
-    GroupMap.myOwned(groupName, new AsyncCallback<GroupMap>() {
-      @Override
-      public void onSuccess(GroupMap result) {
-        cb.onSuccess(!result.isEmpty());
-      }
-      @Override
-      public void onFailure(Throwable caught) {
-        cb.onFailure(caught);
-      }
-    });
+    GroupMap.myOwned(
+        groupName,
+        new AsyncCallback<GroupMap>() {
+          @Override
+          public void onSuccess(GroupMap result) {
+            cb.onSuccess(!result.isEmpty());
+          }
+
+          @Override
+          public void onFailure(Throwable caught) {
+            cb.onFailure(caught);
+          }
+        });
   }
 
   /** Rename a group */
-  public static void renameGroup(AccountGroup.UUID group,
-      String newName, AsyncCallback<VoidResult> cb) {
+  public static void renameGroup(
+      AccountGroup.UUID group, String newName, AsyncCallback<VoidResult> cb) {
     GroupInput in = GroupInput.create();
     in.name(newName);
     group(group).view("name").put(in, cb);
   }
 
   /** Set description for a group */
-  public static void setGroupDescription(AccountGroup.UUID group,
-      String description, AsyncCallback<VoidResult> cb) {
+  public static void setGroupDescription(
+      AccountGroup.UUID group, String description, AsyncCallback<VoidResult> cb) {
     RestApi call = group(group).view("description");
     if (description != null && !description.isEmpty()) {
       GroupInput in = GroupInput.create();
@@ -83,33 +82,33 @@
   }
 
   /** Set owner for a group */
-  public static void setGroupOwner(AccountGroup.UUID group,
-      String owner, AsyncCallback<GroupInfo> cb) {
+  public static void setGroupOwner(
+      AccountGroup.UUID group, String owner, AsyncCallback<GroupInfo> cb) {
     GroupInput in = GroupInput.create();
     in.owner(owner);
     group(group).view("owner").put(in, cb);
   }
 
   /** Set the options for a group */
-  public static void setGroupOptions(AccountGroup.UUID group,
-      boolean isVisibleToAll, AsyncCallback<VoidResult> cb) {
+  public static void setGroupOptions(
+      AccountGroup.UUID group, boolean isVisibleToAll, AsyncCallback<VoidResult> cb) {
     GroupOptionsInput in = GroupOptionsInput.create();
     in.visibleToAll(isVisibleToAll);
     group(group).view("options").put(in, cb);
   }
 
   /** Add member to a group. */
-  public static void addMember(AccountGroup.UUID group, String member,
-      AsyncCallback<AccountInfo> cb) {
+  public static void addMember(
+      AccountGroup.UUID group, String member, AsyncCallback<AccountInfo> cb) {
     members(group).id(member).put(cb);
   }
 
   /** Add members to a group. */
-  public static void addMembers(AccountGroup.UUID group,
-      Set<String> members,
-      final AsyncCallback<JsArray<AccountInfo>> cb) {
+  public static void addMembers(
+      AccountGroup.UUID group, Set<String> members, final AsyncCallback<JsArray<AccountInfo>> cb) {
     if (members.size() == 1) {
-      addMember(group,
+      addMember(
+          group,
           members.iterator().next(),
           new AsyncCallback<AccountInfo>() {
             @Override
@@ -132,8 +131,8 @@
   }
 
   /** Remove members from a group. */
-  public static void removeMembers(AccountGroup.UUID group,
-      Set<Integer> ids, final AsyncCallback<VoidResult> cb) {
+  public static void removeMembers(
+      AccountGroup.UUID group, Set<Integer> ids, final AsyncCallback<VoidResult> cb) {
     if (ids.size() == 1) {
       members(group).id(ids.iterator().next().toString()).delete(cb);
     } else {
@@ -146,17 +145,19 @@
   }
 
   /** Include a group into a group. */
-  public static void addIncludedGroup(AccountGroup.UUID group, String include,
-      AsyncCallback<GroupInfo> cb) {
+  public static void addIncludedGroup(
+      AccountGroup.UUID group, String include, AsyncCallback<GroupInfo> cb) {
     groups(group).id(include).put(cb);
   }
 
   /** Include groups into a group. */
-  public static void addIncludedGroups(AccountGroup.UUID group,
+  public static void addIncludedGroups(
+      AccountGroup.UUID group,
       Set<String> includedGroups,
       final AsyncCallback<JsArray<GroupInfo>> cb) {
     if (includedGroups.size() == 1) {
-      addIncludedGroup(group,
+      addIncludedGroup(
+          group,
           includedGroups.iterator().next(),
           new AsyncCallback<GroupInfo>() {
             @Override
@@ -179,8 +180,8 @@
   }
 
   /** Remove included groups from a group. */
-  public static void removeIncludedGroups(AccountGroup.UUID group,
-      Set<AccountGroup.UUID> ids, final AsyncCallback<VoidResult> cb) {
+  public static void removeIncludedGroups(
+      AccountGroup.UUID group, Set<AccountGroup.UUID> ids, final AsyncCallback<VoidResult> cb) {
     if (ids.size() == 1) {
       AccountGroup.UUID g = ids.iterator().next();
       groups(group).id(g.get()).delete(cb);
@@ -194,8 +195,8 @@
   }
 
   /** Get audit log of a group. */
-  public static void getAuditLog(AccountGroup.UUID group,
-      AsyncCallback<JsArray<GroupAuditEventInfo>> cb) {
+  public static void getAuditLog(
+      AccountGroup.UUID group, AsyncCallback<JsArray<GroupAuditEventInfo>> cb) {
     group(group).view("log.audit").get(cb);
   }
 
@@ -217,15 +218,16 @@
 
   private static class GroupInput extends JavaScriptObject {
     final native void description(String d) /*-{ if(d)this.description=d; }-*/;
+
     final native void name(String n) /*-{ if(n)this.name=n; }-*/;
+
     final native void owner(String o) /*-{ if(o)this.owner=o; }-*/;
 
     static GroupInput create() {
       return (GroupInput) createObject();
     }
 
-    protected GroupInput() {
-    }
+    protected GroupInput() {}
   }
 
   private static class GroupOptionsInput extends JavaScriptObject {
@@ -235,12 +237,12 @@
       return (GroupOptionsInput) createObject();
     }
 
-    protected GroupOptionsInput() {
-    }
+    protected GroupOptionsInput() {}
   }
 
   private static class MemberInput extends JavaScriptObject {
     final native void init() /*-{ this.members = []; }-*/;
+
     final native void addMember(String n) /*-{ this.members.push(n); }-*/;
 
     static MemberInput create() {
@@ -249,12 +251,12 @@
       return m;
     }
 
-    protected MemberInput() {
-    }
+    protected MemberInput() {}
   }
 
   private static class IncludedGroupInput extends JavaScriptObject {
     final native void init() /*-{ this.groups = []; }-*/;
+
     final native void addGroup(String n) /*-{ this.groups.push(n); }-*/;
 
     static IncludedGroupInput create() {
@@ -263,7 +265,6 @@
       return g;
     }
 
-    protected IncludedGroupInput() {
-    }
+    protected IncludedGroupInput() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
index ed41b65..255c6e8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupAuditEventInfo.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.client.groups;
 
 import com.google.gerrit.client.info.AccountInfo;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwtjsonrpc.client.impl.ser.JavaSqlTimestamp_JsonSerializer;
-
 import java.sql.Timestamp;
 
 public class GroupAuditEventInfo extends JavaScriptObject {
   public enum Type {
-    ADD_USER, REMOVE_USER, ADD_GROUP, REMOVE_GROUP
+    ADD_USER,
+    REMOVE_USER,
+    ADD_GROUP,
+    REMOVE_GROUP
   }
 
   public final Timestamp date() {
@@ -34,12 +37,14 @@
   }
 
   public final native AccountInfo user() /*-{ return this.user; }-*/;
+
   public final native AccountInfo memberAsUser() /*-{ return this.member; }-*/;
+
   public final native GroupInfo memberAsGroup() /*-{ return this.member; }-*/;
 
   private native String dateRaw() /*-{ return this.date; }-*/;
+
   private native String typeRaw() /*-{ return this.type; }-*/;
 
-  protected GroupAuditEventInfo() {
-  }
+  protected GroupAuditEventInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java
deleted file mode 100644
index 4811e59..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupBaseInfo.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.groups;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.http.client.URL;
-
-public class GroupBaseInfo extends JavaScriptObject {
-  public final AccountGroup.UUID getGroupUUID() {
-    return new AccountGroup.UUID(URL.decodeQueryString(id()));
-  }
-
-  public final native String id() /*-{ return this.id; }-*/;
-  public final native String name() /*-{ return this.name; }-*/;
-
-  protected GroupBaseInfo() {
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
deleted file mode 100644
index c3fd4ed..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupInfo.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.client.groups;
-
-import com.google.gerrit.client.info.AccountInfo;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.core.client.JsArray;
-import com.google.gwt.http.client.URL;
-
-public class GroupInfo extends GroupBaseInfo {
-  public final AccountGroup.Id getGroupId() {
-    return new AccountGroup.Id(group_id());
-  }
-
-  public final native GroupOptionsInfo options() /*-{ return this.options; }-*/;
-  public final native String description() /*-{ return this.description; }-*/;
-  public final native String url() /*-{ return this.url; }-*/;
-  public final native String owner() /*-{ return this.owner; }-*/;
-  public final native void owner(String o) /*-{ if(o)this.owner=o; }-*/;
-  public final native JsArray<AccountInfo> members() /*-{ return this.members; }-*/;
-  public final native JsArray<GroupInfo> includes() /*-{ return this.includes; }-*/;
-
-  private native int group_id() /*-{ return this.group_id; }-*/;
-  private native String owner_id() /*-{ return this.owner_id; }-*/;
-  private native void owner_id(String o) /*-{ if(o)this.owner_id=o; }-*/;
-
-  public final AccountGroup.UUID getOwnerUUID() {
-    String owner = owner_id();
-    if (owner != null) {
-        return new AccountGroup.UUID(URL.decodeQueryString(owner));
-    }
-    return null;
-  }
-
-  public final void setOwnerUUID(AccountGroup.UUID uuid) {
-    owner_id(URL.encodeQueryString(uuid.get()));
-  }
-
-  protected GroupInfo() {
-  }
-
-  public static class GroupOptionsInfo extends JavaScriptObject {
-    public final native boolean isVisibleToAll() /*-{ return this['visible_to_all'] ? true : false; }-*/;
-
-    protected GroupOptionsInfo() {
-    }
-  }
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java
index a24e1dc..db966b1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupList.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.groups;
 
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gwt.core.client.JsArray;
@@ -25,11 +26,9 @@
     new RestApi("/accounts/self/groups").get(callback);
   }
 
-  public static void included(AccountGroup.UUID group,
-      AsyncCallback<GroupList> callback) {
+  public static void included(AccountGroup.UUID group, AsyncCallback<GroupList> callback) {
     new RestApi("/groups/").id(group.get()).view("groups").get(callback);
   }
 
-  protected GroupList() {
-  }
+  protected GroupList() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
index 5532285..76147f5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupMap.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.groups;
 
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gwt.user.client.rpc.AsyncCallback;
@@ -38,8 +39,8 @@
     call.get(NativeMap.copyKeysIntoChildren(cb));
   }
 
-  public static void suggestAccountGroupForProject(String project, String query,
-      int limit, AsyncCallback<GroupMap> cb) {
+  public static void suggestAccountGroupForProject(
+      String project, String query, int limit, AsyncCallback<GroupMap> cb) {
     RestApi call = groups();
     if (project != null) {
       call.addParameter("p", project);
@@ -58,8 +59,7 @@
   }
 
   public static void myOwned(String groupName, AsyncCallback<GroupMap> cb) {
-    myOwnedGroups().addParameter("q", groupName).get(
-        NativeMap.copyKeysIntoChildren(cb));
+    myOwnedGroups().addParameter("g", groupName).get(NativeMap.copyKeysIntoChildren(cb));
   }
 
   private static RestApi myOwnedGroups() {
@@ -70,6 +70,5 @@
     return new RestApi("/groups/");
   }
 
-  protected GroupMap() {
-  }
+  protected GroupMap() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java
index b199169..4c4c8da 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchConstants.java
@@ -18,47 +18,74 @@
 
 public interface PatchConstants extends Constants {
   String patchBase();
+
   String patchSet();
 
   String upToChange();
+
   String openReply();
+
   String linePrev();
+
   String lineNext();
+
   String chunkPrev();
+
   String chunkNext();
+
   String commentPrev();
+
   String commentNext();
+
   String focusSideA();
+
   String focusSideB();
+
   String expandComment();
+
   String expandAllCommentsOnCurrentLine();
+
   String toggleSideA();
+
   String toggleIntraline();
+
   String showPreferences();
 
   String toggleReviewed();
+
   String markAsReviewedAndGoToNext();
 
   String commentEditorSet();
+
   String commentInsert();
+
   String commentSaveDraft();
+
   String commentCancelEdit();
 
   String whitespaceIGNORE_NONE();
+
   String whitespaceIGNORE_TRAILING();
+
   String whitespaceIGNORE_LEADING_AND_TRAILING();
+
   String whitespaceIGNORE_ALL();
 
   String previousFileHelp();
+
   String nextFileHelp();
 
   String download();
+
   String edit();
+
   String blame();
+
   String addFileCommentToolTip();
 
   String cannedReplyDone();
 
   String sideBySideDiff();
+
   String unifiedDiff();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java
index aaab1c9..358ccd3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchMessages.java
@@ -18,7 +18,10 @@
 
 public interface PatchMessages extends Messages {
   String expandBefore(int cnt);
+
   String expandAfter(int cnt);
+
   String patchSkipRegion(String lineNumber);
+
   String fileNameWithShortcutKey(String file, String key);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginInfo.java
index ed175cb..ac073de 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginInfo.java
@@ -18,10 +18,12 @@
 
 public class PluginInfo extends JavaScriptObject {
   public final native String name() /*-{ return this.name }-*/;
+
   public final native String version() /*-{ return this.version }-*/;
+
   public final native String indexUrl() /*-{ return this.index_url }-*/;
+
   public final native boolean disabled() /*-{ return this.disabled || false }-*/;
 
-  protected PluginInfo() {
-  }
+  protected PluginInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginMap.java
index e4c5159..cea27b9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/plugins/PluginMap.java
@@ -21,11 +21,8 @@
 /** Plugins available from {@code /plugins/}. */
 public class PluginMap extends NativeMap<PluginInfo> {
   public static void all(AsyncCallback<PluginMap> callback) {
-    new RestApi("/plugins/")
-      .addParameterTrue("all")
-      .get(NativeMap.copyKeysIntoChildren(callback));
+    new RestApi("/plugins/").addParameterTrue("all").get(NativeMap.copyKeysIntoChildren(callback));
   }
 
-  protected PluginMap() {
-  }
+  protected PluginMap() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/BranchInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/BranchInfo.java
index 8d166a1..097f26a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/BranchInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/BranchInfo.java
@@ -21,9 +21,10 @@
 
 public class BranchInfo extends RefInfo {
   public final native boolean canDelete() /*-{ return this['can_delete'] ? true : false; }-*/;
+
   public final native NativeMap<ActionInfo> actions() /*-{ return this.actions }-*/;
+
   public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
 
-  protected BranchInfo() {
-  }
+  protected BranchInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
index 9751b3b..c96d331 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -26,54 +26,50 @@
 import com.google.gwtexpui.safehtml.client.FindReplace;
 import com.google.gwtexpui.safehtml.client.LinkFindReplace;
 import com.google.gwtexpui.safehtml.client.RawFindReplace;
-
 import java.util.ArrayList;
 import java.util.List;
 
 public class ConfigInfo extends JavaScriptObject {
 
-  public final native String description()
-  /*-{ return this.description }-*/;
+  public final native String description() /*-{ return this.description }-*/;
 
   public final native InheritedBooleanInfo requireChangeId()
-  /*-{ return this.require_change_id; }-*/;
+      /*-{ return this.require_change_id; }-*/ ;
 
   public final native InheritedBooleanInfo useContentMerge()
-  /*-{ return this.use_content_merge; }-*/;
+      /*-{ return this.use_content_merge; }-*/ ;
 
   public final native InheritedBooleanInfo useContributorAgreements()
-  /*-{ return this.use_contributor_agreements; }-*/;
+      /*-{ return this.use_contributor_agreements; }-*/ ;
 
   public final native InheritedBooleanInfo createNewChangeForAllNotInTarget()
-  /*-{ return this.create_new_change_for_all_not_in_target; }-*/;
+      /*-{ return this.create_new_change_for_all_not_in_target; }-*/ ;
 
   public final native InheritedBooleanInfo useSignedOffBy()
-  /*-{ return this.use_signed_off_by; }-*/;
+      /*-{ return this.use_signed_off_by; }-*/ ;
 
   public final native InheritedBooleanInfo enableSignedPush()
-  /*-{ return this.enable_signed_push; }-*/;
+      /*-{ return this.enable_signed_push; }-*/ ;
 
   public final native InheritedBooleanInfo requireSignedPush()
-  /*-{ return this.require_signed_push; }-*/;
+      /*-{ return this.require_signed_push; }-*/ ;
 
   public final native InheritedBooleanInfo rejectImplicitMerges()
-  /*-{ return this.reject_implicit_merges; }-*/;
+      /*-{ return this.reject_implicit_merges; }-*/ ;
 
   public final SubmitType submitType() {
     return SubmitType.valueOf(submitTypeRaw());
   }
 
   public final native NativeMap<NativeMap<ConfigParameterInfo>> pluginConfig()
-  /*-{ return this.plugin_config || {}; }-*/;
+      /*-{ return this.plugin_config || {}; }-*/ ;
 
   public final native NativeMap<ConfigParameterInfo> pluginConfig(String p)
-  /*-{ return this.plugin_config[p]; }-*/;
+      /*-{ return this.plugin_config[p]; }-*/ ;
 
-  public final native NativeMap<ActionInfo> actions()
-  /*-{ return this.actions; }-*/;
+  public final native NativeMap<ActionInfo> actions() /*-{ return this.actions; }-*/;
 
-  private native String submitTypeRaw()
-  /*-{ return this.submit_type }-*/;
+  private native String submitTypeRaw() /*-{ return this.submit_type }-*/;
 
   public final ProjectState state() {
     if (stateRaw() == null) {
@@ -81,14 +77,14 @@
     }
     return ProjectState.valueOf(stateRaw());
   }
-  private native String stateRaw()
-  /*-{ return this.state }-*/;
+
+  private native String stateRaw() /*-{ return this.state }-*/;
 
   public final native MaxObjectSizeLimitInfo maxObjectSizeLimit()
-  /*-{ return this.max_object_size_limit; }-*/;
+      /*-{ return this.max_object_size_limit; }-*/ ;
 
-  private native NativeMap<CommentLinkInfo> commentlinks0()
-  /*-{ return this.commentlinks; }-*/;
+  private native NativeMap<CommentLinkInfo> commentlinks0() /*-{ return this.commentlinks; }-*/;
+
   final List<FindReplace> commentlinks() {
     JsArray<CommentLinkInfo> cls = commentlinks0().values();
     List<FindReplace> commentLinks = new ArrayList<>(cls.length());
@@ -105,10 +101,10 @@
           commentLinks.add(fr);
         } catch (RuntimeException e) {
           int index = e.getMessage().indexOf("at Object");
-          new ErrorDialog("Invalid commentlink configuration: "
-              + (index == -1
-              ? e.getMessage()
-              : e.getMessage().substring(0, index))).center();
+          new ErrorDialog(
+                  "Invalid commentlink configuration: "
+                      + (index == -1 ? e.getMessage() : e.getMessage().substring(0, index)))
+              .center();
         }
       }
     }
@@ -117,19 +113,20 @@
 
   final native ThemeInfo theme() /*-{ return this.theme; }-*/;
 
-  protected ConfigInfo() {
-  }
+  protected ConfigInfo() {}
 
   static class CommentLinkInfo extends JavaScriptObject {
     final native String match() /*-{ return this.match; }-*/;
+
     final native String link() /*-{ return this.link; }-*/;
+
     final native String html() /*-{ return this.html; }-*/;
+
     final native boolean enabled() /*-{
       return !this.hasOwnProperty('enabled') || this.enabled;
     }-*/;
 
-    protected CommentLinkInfo() {
-    }
+    protected CommentLinkInfo() {}
   }
 
   public static class InheritedBooleanInfo extends JavaScriptObject {
@@ -137,59 +134,72 @@
       return (InheritedBooleanInfo) createObject();
     }
 
-    public final native boolean value()
-    /*-{ return this.value ? true : false; }-*/;
+    public final native boolean value() /*-{ return this.value ? true : false; }-*/;
 
     public final native boolean inheritedValue()
-    /*-{ return this.inherited_value ? true : false; }-*/;
+        /*-{ return this.inherited_value ? true : false; }-*/ ;
 
     public final InheritableBoolean configuredValue() {
       return InheritableBoolean.valueOf(configuredValueRaw());
     }
-    private native String configuredValueRaw()
-    /*-{ return this.configured_value }-*/;
+
+    private native String configuredValueRaw() /*-{ return this.configured_value }-*/;
 
     public final void setConfiguredValue(InheritableBoolean v) {
       setConfiguredValueRaw(v.name());
     }
-    public final native void setConfiguredValueRaw(String v)
-    /*-{ if(v)this.configured_value=v; }-*/;
 
-    protected InheritedBooleanInfo() {
-    }
+    public final native void setConfiguredValueRaw(String v)
+        /*-{ if(v)this.configured_value=v; }-*/ ;
+
+    protected InheritedBooleanInfo() {}
   }
 
   public static class MaxObjectSizeLimitInfo extends JavaScriptObject {
     public final native String value() /*-{ return this.value; }-*/;
-    public final native String inheritedValue() /*-{ return this.inherited_value; }-*/;
+
     public final native String configuredValue() /*-{ return this.configured_value }-*/;
 
-    protected MaxObjectSizeLimitInfo() {
-    }
+    public final native String summary() /*-{ return this.summary; }-*/;
+
+    protected MaxObjectSizeLimitInfo() {}
   }
 
   public static class ConfigParameterInfo extends JavaScriptObject {
     public final native String name() /*-{ return this.name; }-*/;
-    public final native String displayName() /*-{ return this.display_name; }-*/;
-    public final native String description() /*-{ return this.description; }-*/;
-    public final native String warning() /*-{ return this.warning; }-*/;
-    public final native String type() /*-{ return this.type; }-*/;
-    public final native String value() /*-{ return this.value; }-*/;
-    public final native boolean editable() /*-{ return this.editable ? true : false; }-*/;
-    public final native boolean inheritable() /*-{ return this.inheritable ? true : false; }-*/;
-    public final native String configuredValue() /*-{ return this.configured_value; }-*/;
-    public final native String inheritedValue() /*-{ return this.inherited_value; }-*/;
-    public final native JsArrayString permittedValues()  /*-{ return this.permitted_values; }-*/;
-    public final native JsArrayString values()  /*-{ return this.values; }-*/;
 
-    protected ConfigParameterInfo() {
-    }
+    public final native String displayName() /*-{ return this.display_name; }-*/;
+
+    public final native String description() /*-{ return this.description; }-*/;
+
+    public final native String warning() /*-{ return this.warning; }-*/;
+
+    public final native String type() /*-{ return this.type; }-*/;
+
+    public final native String value() /*-{ return this.value; }-*/;
+
+    public final native boolean editable() /*-{ return this.editable ? true : false; }-*/;
+
+    public final native boolean inheritable() /*-{ return this.inheritable ? true : false; }-*/;
+
+    public final native String configuredValue() /*-{ return this.configured_value; }-*/;
+
+    public final native String inheritedValue() /*-{ return this.inherited_value; }-*/;
+
+    public final native JsArrayString permittedValues() /*-{ return this.permitted_values; }-*/;
+
+    public final native JsArrayString values() /*-{ return this.values; }-*/;
+
+    protected ConfigParameterInfo() {}
   }
 
   public static class ConfigParameterValue extends JavaScriptObject {
     final native void init() /*-{ this.values = []; }-*/;
+
     final native void addValue(String v) /*-{ this.values.push(v); }-*/;
+
     final native void setValue(String v) /*-{ if(v)this.value = v; }-*/;
+
     public static ConfigParameterValue create() {
       ConfigParameterValue v = createObject().cast();
       return v;
@@ -208,7 +218,6 @@
       return this;
     }
 
-    protected ConfigParameterValue() {
-    }
+    protected ConfigParameterValue() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
index 6a1ba11..e41cf120 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -29,8 +28,7 @@
 public class ConfigInfoCache {
   private static final int PROJECT_LIMIT = 25;
   private static final int CHANGE_LIMIT = 100;
-  private static final ConfigInfoCache instance =
-      GWT.create(ConfigInfoCache.class);
+  private static final ConfigInfoCache instance = GWT.create(ConfigInfoCache.class);
 
   public static class Entry {
     private final ConfigInfo info;
@@ -68,24 +66,25 @@
   private final LinkedHashMap<Integer, String> changeToProject;
 
   protected ConfigInfoCache() {
-    cache = new LinkedHashMap<String, Entry>(PROJECT_LIMIT) {
-      private static final long serialVersionUID = 1L;
+    cache =
+        new LinkedHashMap<String, Entry>(PROJECT_LIMIT) {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected boolean removeEldestEntry(
-          Map.Entry<String, ConfigInfoCache.Entry> e) {
-        return size() > PROJECT_LIMIT;
-      }
-    };
+          @Override
+          protected boolean removeEldestEntry(Map.Entry<String, ConfigInfoCache.Entry> e) {
+            return size() > PROJECT_LIMIT;
+          }
+        };
 
-    changeToProject = new LinkedHashMap<Integer, String>(CHANGE_LIMIT) {
-      private static final long serialVersionUID = 1L;
+    changeToProject =
+        new LinkedHashMap<Integer, String>(CHANGE_LIMIT) {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected boolean removeEldestEntry(Map.Entry<Integer, String> e) {
-        return size() > CHANGE_LIMIT;
-      }
-    };
+          @Override
+          protected boolean removeEldestEntry(Map.Entry<Integer, String> e) {
+            return size() > CHANGE_LIMIT;
+          }
+        };
   }
 
   private void getImpl(final String name, final AsyncCallback<Entry> cb) {
@@ -94,7 +93,8 @@
       cb.onSuccess(e);
       return;
     }
-    ProjectApi.getConfig(new Project.NameKey(name),
+    ProjectApi.getConfig(
+        new Project.NameKey(name),
         new AsyncCallback<ConfigInfo>() {
           @Override
           public void onSuccess(ConfigInfo result) {
@@ -116,17 +116,19 @@
       getImpl(name, cb);
       return;
     }
-    ChangeApi.change(id).get(new AsyncCallback<ChangeInfo>() {
-      @Override
-      public void onSuccess(ChangeInfo result) {
-        changeToProject.put(id, result.project());
-        getImpl(result.project(), cb);
-      }
+    ChangeApi.change(id)
+        .get(
+            new AsyncCallback<ChangeInfo>() {
+              @Override
+              public void onSuccess(ChangeInfo result) {
+                changeToProject.put(id, result.project());
+                getImpl(result.project(), cb);
+              }
 
-      @Override
-      public void onFailure(Throwable caught) {
-        cb.onFailure(caught);
-      }
-    });
+              @Override
+              public void onFailure(Throwable caught) {
+                cb.onFailure(caught);
+              }
+            });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
index 10932bc..ff4c810 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
@@ -25,30 +25,31 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
 
 public class ProjectApi {
   /** Create a new project */
-  public static void createProject(String projectName, String parent,
-      Boolean createEmptyCcommit, Boolean permissionsOnly,
+  public static void createProject(
+      String projectName,
+      String parent,
+      Boolean createEmptyCcommit,
+      Boolean permissionsOnly,
       AsyncCallback<VoidResult> cb) {
     ProjectInput input = ProjectInput.create();
     input.setName(projectName);
     input.setParent(parent);
     input.setPermissionsOnly(permissionsOnly);
     input.setCreateEmptyCommit(createEmptyCcommit);
-    new RestApi("/projects/").id(projectName).ifNoneMatch()
-        .put(input, cb);
+    new RestApi("/projects/").id(projectName).ifNoneMatch().put(input, cb);
   }
 
-  private static RestApi getRestApi(Project.NameKey name, String viewName,
-      int limit, int start, String match) {
+  private static RestApi getRestApi(
+      Project.NameKey name, String viewName, int limit, int start, String match) {
     RestApi call = project(name).view(viewName);
     call.addParameter("n", limit);
-    call.addParameter("s", start);
+    call.addParameter("S", start);
     if (match != null) {
       if (match.startsWith("^")) {
         call.addParameter("r", match);
@@ -59,42 +60,72 @@
     return call;
   }
 
+  /** Create a new tag */
+  public static void createTag(
+      Project.NameKey name,
+      String ref,
+      String revision,
+      String annotation,
+      AsyncCallback<TagInfo> cb) {
+    TagInput input = TagInput.create();
+    input.setRevision(revision);
+    input.setMessage(annotation);
+    project(name).view("tags").id(ref).ifNoneMatch().put(input, cb);
+  }
+
   /** Retrieve all visible tags of the project */
-  public static void getTags(Project.NameKey name,
-      AsyncCallback<JsArray<TagInfo>> cb) {
+  public static void getTags(Project.NameKey name, AsyncCallback<JsArray<TagInfo>> cb) {
     project(name).view("tags").get(cb);
   }
 
-  public static void getTags(Project.NameKey name, int limit, int start,
-      String match, AsyncCallback<JsArray<TagInfo>> cb) {
+  public static void getTags(
+      Project.NameKey name,
+      int limit,
+      int start,
+      String match,
+      AsyncCallback<JsArray<TagInfo>> cb) {
     getRestApi(name, "tags", limit, start, match).get(cb);
   }
 
+  /** Delete tags. One call is fired to the server to delete all the tags. */
+  public static void deleteTags(
+      Project.NameKey name, Set<String> refs, AsyncCallback<VoidResult> cb) {
+    if (refs.size() == 1) {
+      project(name).view("tags").id(refs.iterator().next()).delete(cb);
+    } else {
+      DeleteTagsInput d = DeleteTagsInput.create();
+      for (String ref : refs) {
+        d.addTag(ref);
+      }
+      project(name).view("tags:delete").post(d, cb);
+    }
+  }
+
   /** Create a new branch */
-  public static void createBranch(Project.NameKey name, String ref,
-      String revision, AsyncCallback<BranchInfo> cb) {
+  public static void createBranch(
+      Project.NameKey name, String ref, String revision, AsyncCallback<BranchInfo> cb) {
     BranchInput input = BranchInput.create();
     input.setRevision(revision);
     project(name).view("branches").id(ref).ifNoneMatch().put(input, cb);
   }
 
   /** Retrieve all visible branches of the project */
-  public static void getBranches(Project.NameKey name,
-      AsyncCallback<JsArray<BranchInfo>> cb) {
+  public static void getBranches(Project.NameKey name, AsyncCallback<JsArray<BranchInfo>> cb) {
     project(name).view("branches").get(cb);
   }
 
-  public static void getBranches(Project.NameKey name, int limit, int start,
-       String match, AsyncCallback<JsArray<BranchInfo>> cb) {
+  public static void getBranches(
+      Project.NameKey name,
+      int limit,
+      int start,
+      String match,
+      AsyncCallback<JsArray<BranchInfo>> cb) {
     getRestApi(name, "branches", limit, start, match).get(cb);
   }
 
-  /**
-   * Delete branches. One call is fired to the server to delete all the
-   * branches.
-   */
-  public static void deleteBranches(Project.NameKey name,
-      Set<String> refs, AsyncCallback<VoidResult> cb) {
+  /** Delete branches. One call is fired to the server to delete all the branches. */
+  public static void deleteBranches(
+      Project.NameKey name, Set<String> refs, AsyncCallback<VoidResult> cb) {
     if (refs.size() == 1) {
       project(name).view("branches").id(refs.iterator().next()).delete(cb);
     } else {
@@ -106,21 +137,24 @@
     }
   }
 
-  public static void getConfig(Project.NameKey name,
-      AsyncCallback<ConfigInfo> cb) {
+  public static void getConfig(Project.NameKey name, AsyncCallback<ConfigInfo> cb) {
     project(name).view("config").get(cb);
   }
 
-  public static void setConfig(Project.NameKey name, String description,
+  public static void setConfig(
+      Project.NameKey name,
+      String description,
       InheritableBoolean useContributorAgreements,
-      InheritableBoolean useContentMerge, InheritableBoolean useSignedOffBy,
+      InheritableBoolean useContentMerge,
+      InheritableBoolean useSignedOffBy,
       InheritableBoolean createNewChangeForAllNotInTarget,
       InheritableBoolean requireChangeId,
       InheritableBoolean enableSignedPush,
       InheritableBoolean requireSignedPush,
       InheritableBoolean rejectImplicitMerges,
       String maxObjectSizeLimit,
-      SubmitType submitType, ProjectState state,
+      SubmitType submitType,
+      ProjectState state,
       Map<String, Map<String, ConfigParameterValue>> pluginConfigValues,
       AsyncCallback<ConfigInfo> cb) {
     ConfigInput in = ConfigInput.create();
@@ -145,24 +179,25 @@
     project(name).view("config").put(in, cb);
   }
 
-  public static void getParent(Project.NameKey name,
-      final AsyncCallback<Project.NameKey> cb) {
-    project(name).view("parent").get(
-        new AsyncCallback<NativeString>() {
-          @Override
-          public void onSuccess(NativeString result) {
-            cb.onSuccess(new Project.NameKey(result.asString()));
-          }
+  public static void getParent(Project.NameKey name, final AsyncCallback<Project.NameKey> cb) {
+    project(name)
+        .view("parent")
+        .get(
+            new AsyncCallback<NativeString>() {
+              @Override
+              public void onSuccess(NativeString result) {
+                cb.onSuccess(new Project.NameKey(result.asString()));
+              }
 
-          @Override
-          public void onFailure(Throwable caught) {
-            cb.onFailure(caught);
-          }
-        });
+              @Override
+              public void onFailure(Throwable caught) {
+                cb.onFailure(caught);
+              }
+            });
   }
 
-  public static void getChildren(Project.NameKey name, boolean recursive,
-      AsyncCallback<JsArray<ProjectInfo>> cb) {
+  public static void getChildren(
+      Project.NameKey name, boolean recursive, AsyncCallback<JsArray<ProjectInfo>> cb) {
     RestApi view = project(name).view("children");
     if (recursive) {
       view.addParameterTrue("recursive");
@@ -170,13 +205,12 @@
     view.get(cb);
   }
 
-  public static void getDescription(Project.NameKey name,
-      AsyncCallback<NativeString> cb) {
+  public static void getDescription(Project.NameKey name, AsyncCallback<NativeString> cb) {
     project(name).view("description").get(cb);
   }
 
-  public static void setDescription(Project.NameKey name, String description,
-      AsyncCallback<NativeString> cb) {
+  public static void setDescription(
+      Project.NameKey name, String description, AsyncCallback<NativeString> cb) {
     RestApi call = project(name).view("description");
     if (description != null && !description.isEmpty()) {
       DescriptionInput input = DescriptionInput.create();
@@ -187,8 +221,7 @@
     }
   }
 
-  public static void setHead(Project.NameKey name, String ref,
-      AsyncCallback<NativeString> cb) {
+  public static void setHead(Project.NameKey name, String ref, AsyncCallback<NativeString> cb) {
     RestApi call = project(name).view("HEAD");
     HeadInput input = HeadInput.create();
     input.setRef(ref);
@@ -204,8 +237,7 @@
       return (ProjectInput) createObject();
     }
 
-    protected ProjectInput() {
-    }
+    protected ProjectInput() {}
 
     final native void setName(String n) /*-{ if(n)this.name=n; }-*/;
 
@@ -221,76 +253,78 @@
       return (ConfigInput) createObject();
     }
 
-    protected ConfigInput() {
-    }
+    protected ConfigInput() {}
 
-    final native void setDescription(String d)
-    /*-{ if(d)this.description=d; }-*/;
+    final native void setDescription(String d) /*-{ if(d)this.description=d; }-*/;
 
     final void setUseContributorAgreements(InheritableBoolean v) {
       setUseContributorAgreementsRaw(v.name());
     }
+
     private native void setUseContributorAgreementsRaw(String v)
-    /*-{ if(v)this.use_contributor_agreements=v; }-*/;
+        /*-{ if(v)this.use_contributor_agreements=v; }-*/ ;
 
     final void setUseContentMerge(InheritableBoolean v) {
       setUseContentMergeRaw(v.name());
     }
-    private native void setUseContentMergeRaw(String v)
-    /*-{ if(v)this.use_content_merge=v; }-*/;
+
+    private native void setUseContentMergeRaw(String v) /*-{ if(v)this.use_content_merge=v; }-*/;
 
     final void setUseSignedOffBy(InheritableBoolean v) {
       setUseSignedOffByRaw(v.name());
     }
-    private native void setUseSignedOffByRaw(String v)
-    /*-{ if(v)this.use_signed_off_by=v; }-*/;
+
+    private native void setUseSignedOffByRaw(String v) /*-{ if(v)this.use_signed_off_by=v; }-*/;
 
     final void setRequireChangeId(InheritableBoolean v) {
       setRequireChangeIdRaw(v.name());
     }
-    private native void setRequireChangeIdRaw(String v)
-    /*-{ if(v)this.require_change_id=v; }-*/;
+
+    private native void setRequireChangeIdRaw(String v) /*-{ if(v)this.require_change_id=v; }-*/;
 
     final void setCreateNewChangeForAllNotInTarget(InheritableBoolean v) {
       setCreateNewChangeForAllNotInTargetRaw(v.name());
     }
+
     private native void setCreateNewChangeForAllNotInTargetRaw(String v)
-    /*-{ if(v)this.create_new_change_for_all_not_in_target=v; }-*/;
+        /*-{ if(v)this.create_new_change_for_all_not_in_target=v; }-*/ ;
 
     final void setEnableSignedPush(InheritableBoolean v) {
       setEnableSignedPushRaw(v.name());
     }
-    private native void setEnableSignedPushRaw(String v)
-    /*-{ if(v)this.enable_signed_push=v; }-*/;
+
+    private native void setEnableSignedPushRaw(String v) /*-{ if(v)this.enable_signed_push=v; }-*/;
 
     final void setRequireSignedPush(InheritableBoolean v) {
       setRequireSignedPushRaw(v.name());
     }
+
     private native void setRequireSignedPushRaw(String v)
-    /*-{ if(v)this.require_signed_push=v; }-*/;
+        /*-{ if(v)this.require_signed_push=v; }-*/ ;
 
     final void setRejectImplicitMerges(InheritableBoolean v) {
       setRejectImplicitMergesRaw(v.name());
     }
-    private native void setRejectImplicitMergesRaw(String v)
-    /*-{ if(v)this.reject_implicit_merges=v; }-*/;
 
-    final native void setMaxObjectSizeLimit(String l)
-    /*-{ if(l)this.max_object_size_limit=l; }-*/;
+    private native void setRejectImplicitMergesRaw(String v)
+        /*-{ if(v)this.reject_implicit_merges=v; }-*/ ;
+
+    final native void setMaxObjectSizeLimit(String l) /*-{ if(l)this.max_object_size_limit=l; }-*/;
 
     final void setSubmitType(SubmitType t) {
       setSubmitTypeRaw(t.name());
     }
-    private native void setSubmitTypeRaw(String t)
-    /*-{ if(t)this.submit_type=t; }-*/;
+
+    private native void setSubmitTypeRaw(String t) /*-{ if(t)this.submit_type=t; }-*/;
 
     final void setState(ProjectState s) {
       setStateRaw(s.name());
     }
-    private native void setStateRaw(String s)
-    /*-{ if(s)this.state=s; }-*/;
 
-    final void setPluginConfigValues(Map<String, Map<String, ConfigParameterValue>> pluginConfigValues) {
+    private native void setStateRaw(String s) /*-{ if(s)this.state=s; }-*/;
+
+    final void setPluginConfigValues(
+        Map<String, Map<String, ConfigParameterValue>> pluginConfigValues) {
       if (!pluginConfigValues.isEmpty()) {
         NativeMap<ConfigParameterValueMap> configValues = NativeMap.create().cast();
         for (Entry<String, Map<String, ConfigParameterValue>> e : pluginConfigValues.entrySet()) {
@@ -303,8 +337,9 @@
         setPluginConfigValuesRaw(configValues);
       }
     }
+
     private native void setPluginConfigValuesRaw(NativeMap<ConfigParameterValueMap> v)
-    /*-{ this.plugin_config_values=v; }-*/;
+        /*-{ this.plugin_config_values=v; }-*/ ;
   }
 
   private static class ConfigParameterValueMap extends JavaScriptObject {
@@ -312,19 +347,29 @@
       return createObject().cast();
     }
 
-    protected ConfigParameterValueMap() {
-    }
+    protected ConfigParameterValueMap() {}
 
     public final native void put(String n, ConfigParameterValue v) /*-{ this[n] = v; }-*/;
   }
 
+  private static class TagInput extends JavaScriptObject {
+    static TagInput create() {
+      return (TagInput) createObject();
+    }
+
+    protected TagInput() {}
+
+    final native void setRevision(String r) /*-{ if(r)this.revision=r; }-*/;
+
+    final native void setMessage(String m) /*-{ if(m)this.message=m; }-*/;
+  }
+
   private static class BranchInput extends JavaScriptObject {
     static BranchInput create() {
       return (BranchInput) createObject();
     }
 
-    protected BranchInput() {
-    }
+    protected BranchInput() {}
 
     final native void setRevision(String r) /*-{ if(r)this.revision=r; }-*/;
   }
@@ -334,8 +379,7 @@
       return (DescriptionInput) createObject();
     }
 
-    protected DescriptionInput() {
-    }
+    protected DescriptionInput() {}
 
     final native void setDescription(String d) /*-{ if(d)this.description=d; }-*/;
   }
@@ -345,12 +389,25 @@
       return createObject().cast();
     }
 
-    protected HeadInput() {
-    }
+    protected HeadInput() {}
 
     final native void setRef(String r) /*-{ if(r)this.ref=r; }-*/;
   }
 
+  private static class DeleteTagsInput extends JavaScriptObject {
+    static DeleteTagsInput create() {
+      DeleteTagsInput d = createObject().cast();
+      d.init();
+      return d;
+    }
+
+    protected DeleteTagsInput() {}
+
+    final native void init() /*-{ this.tags = []; }-*/;
+
+    final native void addTag(String b) /*-{ this.tags.push(b); }-*/;
+  }
+
   private static class DeleteBranchesInput extends JavaScriptObject {
     static DeleteBranchesInput create() {
       DeleteBranchesInput d = createObject().cast();
@@ -358,10 +415,10 @@
       return d;
     }
 
-    protected DeleteBranchesInput() {
-    }
+    protected DeleteBranchesInput() {}
 
     final native void init() /*-{ this.branches = []; }-*/;
+
     final native void addBranch(String b) /*-{ this.branches.push(b); }-*/;
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java
index eed9d1d..1ff568f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectInfo.java
@@ -21,15 +21,15 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.ui.SuggestOracle;
 
-public class ProjectInfo
-    extends JavaScriptObject
-    implements SuggestOracle.Suggestion {
+public class ProjectInfo extends JavaScriptObject implements SuggestOracle.Suggestion {
   public final Project.NameKey name_key() {
     return new Project.NameKey(name());
   }
 
   public final native String name() /*-{ return this.name; }-*/;
+
   public final native String description() /*-{ return this.description; }-*/;
+
   public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
 
   public final ProjectState state() {
@@ -51,6 +51,5 @@
     return name();
   }
 
-  protected ProjectInfo() {
-  }
+  protected ProjectInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
index 0f121c8..4327c07 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
@@ -78,6 +78,5 @@
     match(match, 0, 0, cb);
   }
 
-  protected ProjectMap() {
-  }
+  protected ProjectMap() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java
index 9801d60..90c862f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/RefInfo.java
@@ -23,8 +23,8 @@
   }
 
   public final native String ref() /*-{ return this.ref; }-*/;
+
   public final native String revision() /*-{ return this.revision; }-*/;
 
-  protected RefInfo() {
-  }
+  protected RefInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/TagInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/TagInfo.java
index ee1d1af..fc13fe1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/TagInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/TagInfo.java
@@ -14,9 +14,14 @@
 
 package com.google.gerrit.client.projects;
 
+import com.google.gerrit.client.info.WebLinkInfo;
+import com.google.gwt.core.client.JsArray;
+
 public class TagInfo extends RefInfo {
+  public final native boolean canDelete() /*-{ return this['can_delete'] ? true : false; }-*/;
+
+  public final native JsArray<WebLinkInfo> webLinks() /*-{ return this.web_links; }-*/;
 
   // TODO(dpursehouse) add extra tag-related fields (message, tagger, etc)
-  protected TagInfo() {
-  }
+  protected TagInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ThemeInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ThemeInfo.java
index 9a852a2..7584e14 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ThemeInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ThemeInfo.java
@@ -18,9 +18,10 @@
 
 public class ThemeInfo extends JavaScriptObject {
   public final native String css() /*-{ return this.css; }-*/;
+
   public final native String header() /*-{ return this.header; }-*/;
+
   public final native String footer() /*-{ return this.footer; }-*/;
 
-  protected ThemeInfo() {
-  }
+  protected ThemeInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
index 009deaf..90a820f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/CallbackGroup.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.rpc;
 
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -23,22 +22,19 @@
 
 /**
  * Class for grouping together callbacks and calling them in order.
- * <p>
- * Callbacks are added to the group with {@link #add(AsyncCallback)}, which
- * returns a wrapped callback suitable for passing to an asynchronous RPC call.
- * The last callback must be added using {@link #addFinal(AsyncCallback)} or
- * {@link #done()} must be invoked.
  *
- * The enclosing group buffers returned results and ensures that
- * {@code onSuccess} is called exactly once for each callback in the group, in
- * the same order that callbacks were added. This allows callers to, for
- * example, use a {@link ScreenLoadCallback} as the last callback in the list
+ * <p>Callbacks are added to the group with {@link #add(AsyncCallback)}, which returns a wrapped
+ * callback suitable for passing to an asynchronous RPC call. The last callback must be added using
+ * {@link #addFinal(AsyncCallback)} or {@link #done()} must be invoked.
+ *
+ * <p>The enclosing group buffers returned results and ensures that {@code onSuccess} is called
+ * exactly once for each callback in the group, in the same order that callbacks were added. This
+ * allows callers to, for example, use a {@link ScreenLoadCallback} as the last callback in the list
  * and only display the screen once all callbacks have succeeded.
- * <p>
- * In the event of a failure, the <em>first</em> caught exception is sent to
- * <em>all</em> callbacks' {@code onFailure} methods, in order; subsequent
- * successes or failures are all ignored. Note that this means
- * {@code onFailure} may be called with an exception unrelated to the callback
+ *
+ * <p>In the event of a failure, the <em>first</em> caught exception is sent to <em>all</em>
+ * callbacks' {@code onFailure} methods, in order; subsequent successes or failures are all ignored.
+ * Note that this means {@code onFailure} may be called with an exception unrelated to the callback
  * processing it.
  */
 public class CallbackGroup {
@@ -52,12 +48,10 @@
   public static <T> Callback<T> emptyCallback() {
     return new Callback<T>() {
       @Override
-      public void onSuccess(T result) {
-      }
+      public void onSuccess(T result) {}
 
       @Override
-      public void onFailure(Throwable err) {
-      }
+      public void onFailure(Throwable err) {}
     };
   }
 
@@ -107,7 +101,7 @@
   }
 
   public void addListener(CallbackGroup group) {
-    addListener(group.<Void> addEmpty());
+    addListener(group.<Void>addEmpty());
   }
 
   private void success(CallbackGlue cb) {
@@ -156,12 +150,10 @@
       cb.onFailure(failedThrowable);
       return new HttpCallback<T>() {
         @Override
-        public void onSuccess(HttpResponse<T> result) {
-        }
+        public void onSuccess(HttpResponse<T> result) {}
 
         @Override
-        public void onFailure(Throwable caught) {
-        }
+        public void onFailure(Throwable caught) {}
       };
     }
 
@@ -178,11 +170,11 @@
   }
 
   public interface Callback<T>
-      extends AsyncCallback<T>, com.google.gwtjsonrpc.common.AsyncCallback<T> {
-  }
+      extends AsyncCallback<T>, com.google.gwtjsonrpc.common.AsyncCallback<T> {}
 
   private interface CallbackGlue {
     void applySuccess();
+
     void applyFailed();
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
index cd44fac..5688a31 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/GerritCallback.java
@@ -28,9 +28,9 @@
 import com.google.gwtjsonrpc.common.JsonConstants;
 
 /** Abstract callback handling generic error conditions automatically */
-public abstract class GerritCallback<T> implements
-    com.google.gwtjsonrpc.common.AsyncCallback<T>,
-    com.google.gwt.user.client.rpc.AsyncCallback<T> {
+public abstract class GerritCallback<T>
+    implements com.google.gwtjsonrpc.common.AsyncCallback<T>,
+        com.google.gwt.user.client.rpc.AsyncCallback<T> {
   @Override
   public void onFailure(final Throwable caught) {
     showFailure(caught);
@@ -69,8 +69,9 @@
   }
 
   public static boolean isSigninFailure(Throwable caught) {
-    if (isNotSignedIn(caught) || isInvalidXSRF(caught) ||
-        (isNoSuchEntity(caught) && !Gerrit.isSignedIn())) {
+    if (isNotSignedIn(caught)
+        || isInvalidXSRF(caught)
+        || (isNoSuchEntity(caught) && !Gerrit.isSignedIn())) {
       return true;
     }
     return false;
@@ -84,7 +85,7 @@
   protected static boolean isNotSignedIn(Throwable caught) {
     return RestApi.isNotSignedIn(caught)
         || (caught instanceof RemoteJsonException
-           && caught.getMessage().equals(NotSignedInException.MESSAGE));
+            && caught.getMessage().equals(NotSignedInException.MESSAGE));
   }
 
   protected static boolean isNoSuchEntity(Throwable caught) {
@@ -105,6 +106,6 @@
 
   protected static boolean isNoSuchGroup(final Throwable caught) {
     return caught instanceof RemoteJsonException
-    && caught.getMessage().startsWith(NoSuchGroupException.MESSAGE);
+        && caught.getMessage().startsWith(NoSuchGroupException.MESSAGE);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpCallback.java
index a97642e..2de2980 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpCallback.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpCallback.java
@@ -17,5 +17,6 @@
 /** AsyncCallback supplied with HTTP response headers. */
 public interface HttpCallback<T> {
   void onSuccess(HttpResponse<T> result);
+
   void onFailure(Throwable caught);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpResponse.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpResponse.java
index 969dd30..22d62fb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpResponse.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/HttpResponse.java
@@ -36,7 +36,7 @@
   /**
    * Content type supplied by the server.
    *
-   * This helper simplifies the common {@code getHeader("Content-Type")} case.
+   * <p>This helper simplifies the common {@code getHeader("Content-Type")} case.
    */
   public String getContentType() {
     return contentType;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
index 111f19a..250bc6e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
@@ -50,12 +50,11 @@
 
   /**
    * Expected JSON content body prefix that prevents XSSI.
-   * <p>
-   * The server always includes this line as the first line of the response
-   * content body when the response body is formatted as JSON. It gets inserted
-   * by the server to prevent the resource from being imported into another
-   * domain's page using a &lt;script&gt; tag. This line must be removed before
-   * the JSON can be parsed.
+   *
+   * <p>The server always includes this line as the first line of the response content body when the
+   * response body is formatted as JSON. It gets inserted by the server to prevent the resource from
+   * being imported into another domain's page using a &lt;script&gt; tag. This line must be removed
+   * before the JSON can be parsed.
    */
   private static final String JSON_MAGIC = ")]}'\n";
 
@@ -107,8 +106,7 @@
     }
   }
 
-  private static class HttpImpl<T extends JavaScriptObject>
-      implements RequestCallback {
+  private static class HttpImpl<T extends JavaScriptObject> implements RequestCallback {
     private final boolean background;
     private final HttpCallback<T> cb;
 
@@ -137,15 +135,15 @@
               data = NativeString.wrap(val.isString().stringValue()).cast();
               type = simpleType(res.getHeader("X-FYI-Content-Type"));
             } else {
-              data = RestApi.<T> cast(val);
+              data = RestApi.<T>cast(val);
               type = JSON_TYPE;
             }
           } catch (JSONException e) {
             if (!background) {
               RpcStatus.INSTANCE.onRpcComplete();
             }
-            cb.onFailure(new StatusCodeException(SC_BAD_RESPONSE,
-                "Invalid JSON: " + e.getMessage()));
+            cb.onFailure(
+                new StatusCodeException(SC_BAD_RESPONSE, "Invalid JSON: " + e.getMessage()));
             return;
           }
         } else if (isTextBody(res)) {
@@ -155,24 +153,31 @@
           if (!background) {
             RpcStatus.INSTANCE.onRpcComplete();
           }
-          cb.onFailure(new StatusCodeException(SC_BAD_RESPONSE, "Expected "
-              + JSON_TYPE + " or " + TEXT_TYPE + "; received Content-Type: "
-              + res.getHeader("Content-Type")));
+          cb.onFailure(
+              new StatusCodeException(
+                  SC_BAD_RESPONSE,
+                  "Expected "
+                      + JSON_TYPE
+                      + " or "
+                      + TEXT_TYPE
+                      + "; received Content-Type: "
+                      + res.getHeader("Content-Type")));
           return;
         }
 
-        Scheduler.ScheduledCommand cmd = new Scheduler.ScheduledCommand() {
-          @Override
-          public void execute() {
-            try {
-              cb.onSuccess(new HttpResponse<>(res, type, data));
-            } finally {
-              if (!background) {
-                RpcStatus.INSTANCE.onRpcComplete();
+        Scheduler.ScheduledCommand cmd =
+            new Scheduler.ScheduledCommand() {
+              @Override
+              public void execute() {
+                try {
+                  cb.onSuccess(new HttpResponse<>(res, type, data));
+                } finally {
+                  if (!background) {
+                    RpcStatus.INSTANCE.onRpcComplete();
+                  }
+                }
               }
-            }
-          }
-        };
+            };
 
         // Defer handling the response if the parse took a while.
         if ((System.currentTimeMillis() - start) > 75) {
@@ -213,9 +218,8 @@
         RpcStatus.INSTANCE.onRpcComplete();
       }
       if (err.getMessage().contains("XmlHttpRequest.status")) {
-        cb.onFailure(new StatusCodeException(
-            SC_UNAVAILABLE,
-            RpcConstants.C.errorServerUnavailable()));
+        cb.onFailure(
+            new StatusCodeException(SC_UNAVAILABLE, RpcConstants.C.errorServerUnavailable()));
       } else {
         cb.onFailure(new StatusCodeException(SC_BAD_TRANSPORT, err.getMessage()));
       }
@@ -229,12 +233,12 @@
 
   /**
    * Initialize a new API call.
-   * <p>
-   * By default the JSON format will be selected by including an HTTP Accept
-   * header in the request.
    *
-   * @param name URL of the REST resource to access, e.g. {@code "/projects/"}
-   *        to list accessible projects from the server.
+   * <p>By default the JSON format will be selected by including an HTTP Accept header in the
+   * request.
+   *
+   * @param name URL of the REST resource to access, e.g. {@code "/projects/"} to list accessible
+   *     projects from the server.
    */
   public RestApi(String name) {
     if (name.startsWith("/")) {
@@ -344,8 +348,7 @@
     send(DELETE, cb);
   }
 
-  private <T extends JavaScriptObject> void send(Method method,
-      HttpCallback<T> cb) {
+  private <T extends JavaScriptObject> void send(Method method, HttpCallback<T> cb) {
     HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
     try {
       if (!background) {
@@ -357,25 +360,19 @@
     }
   }
 
-  public <T extends JavaScriptObject> void post(
-      JavaScriptObject content,
-      AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void post(JavaScriptObject content, AsyncCallback<T> cb) {
     post(content, wrap(cb));
   }
 
-  public <T extends JavaScriptObject> void post(
-      JavaScriptObject content,
-      HttpCallback<T> cb) {
+  public <T extends JavaScriptObject> void post(JavaScriptObject content, HttpCallback<T> cb) {
     sendJSON(POST, content, cb);
   }
 
-  public <T extends JavaScriptObject> void post(String content,
-      AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void post(String content, AsyncCallback<T> cb) {
     post(content, wrap(cb));
   }
 
-  public <T extends JavaScriptObject> void post(String content,
-      HttpCallback<T> cb) {
+  public <T extends JavaScriptObject> void post(String content, HttpCallback<T> cb) {
     sendText(POST, content, cb);
   }
 
@@ -387,31 +384,24 @@
     send(PUT, cb);
   }
 
-  public <T extends JavaScriptObject> void put(String content,
-      AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void put(String content, AsyncCallback<T> cb) {
     put(content, wrap(cb));
   }
 
-  public <T extends JavaScriptObject> void put(String content,
-      HttpCallback<T> cb) {
+  public <T extends JavaScriptObject> void put(String content, HttpCallback<T> cb) {
     sendText(PUT, content, cb);
   }
 
-  public <T extends JavaScriptObject> void put(
-      JavaScriptObject content,
-      AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void put(JavaScriptObject content, AsyncCallback<T> cb) {
     put(content, wrap(cb));
   }
 
-  public <T extends JavaScriptObject> void put(
-      JavaScriptObject content,
-      HttpCallback<T> cb) {
+  public <T extends JavaScriptObject> void put(JavaScriptObject content, HttpCallback<T> cb) {
     sendJSON(PUT, content, cb);
   }
 
   private <T extends JavaScriptObject> void sendJSON(
-      Method method, JavaScriptObject content,
-      HttpCallback<T> cb) {
+      Method method, JavaScriptObject content, HttpCallback<T> cb) {
     HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
     try {
       if (!background) {
@@ -425,11 +415,10 @@
     }
   }
 
-  private static native String str(JavaScriptObject jso)
-  /*-{ return JSON.stringify(jso) }-*/;
+  private static native String str(JavaScriptObject jso) /*-{ return JSON.stringify(jso) }-*/;
 
-  private <T extends JavaScriptObject> void sendText(Method method, String body,
-      HttpCallback<T> cb) {
+  private <T extends JavaScriptObject> void sendText(
+      Method method, String body, HttpCallback<T> cb) {
     HttpImpl<T> httpCallback = new HttpImpl<>(background, cb);
     try {
       if (!background) {
@@ -480,8 +469,7 @@
     return type;
   }
 
-  private static JSONValue parseJson(Response res)
-      throws JSONException {
+  private static JSONValue parseJson(Response res) throws JSONException {
     String json = trimJsonMagic(res.getText());
     if (json.isEmpty()) {
       throw new JSONException("response was empty");
@@ -511,8 +499,7 @@
     }
   }
 
-  private static <T extends JavaScriptObject> HttpCallback<T> wrap(
-      final AsyncCallback<T> cb) {
+  private static <T extends JavaScriptObject> HttpCallback<T> wrap(final AsyncCallback<T> cb) {
     return new HttpCallback<T>() {
       @Override
       public void onSuccess(HttpResponse<T> r) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java
index 620133d..56d536d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RpcConstants.java
@@ -21,5 +21,6 @@
   RpcConstants C = GWT.create(RpcConstants.class);
 
   String errorServerUnavailable();
+
   String errorRemoteJsonException();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
index 97ed559..74b45df 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/ScreenLoadCallback.java
@@ -39,8 +39,7 @@
 
   protected abstract void preDisplay(T result);
 
-  protected void postDisplay() {
-  }
+  protected void postDisplay() {}
 
   @Override
   public void onFailure(final Throwable caught) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
index a96624a..80b8c66 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountGroupSuggestOracle.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.client.ui;
 
-import com.google.gerrit.client.groups.GroupInfo;
 import com.google.gerrit.client.groups.GroupMap;
+import com.google.gerrit.client.info.GroupInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.user.client.ui.SuggestOracle;
-
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Map;
@@ -56,8 +55,7 @@
     this.projectName = projectName;
   }
 
-  private static class AccountGroupSuggestion implements
-      SuggestOracle.Suggestion {
+  private static class AccountGroupSuggestion implements SuggestOracle.Suggestion {
     private final GroupInfo info;
 
     AccountGroupSuggestion(final GroupInfo k) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
index eb3b1ff..c44f357 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountLinkPanel.java
@@ -21,31 +21,39 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.user.client.ui.FlowPanel;
+import java.util.function.Function;
 
 /** Link to any user's account dashboard. */
 public class AccountLinkPanel extends FlowPanel {
-  public AccountLinkPanel(AccountInfo info) {
-    this(info, Change.Status.NEW);
+  public static AccountLinkPanel create(AccountInfo ai) {
+    return withStatus(ai, Change.Status.NEW);
   }
 
-  public AccountLinkPanel(AccountInfo info, Change.Status status) {
+  public static AccountLinkPanel withStatus(AccountInfo ai, Change.Status status) {
+    return new AccountLinkPanel(ai, name -> PageLinks.toAccountQuery(name, status));
+  }
+
+  public static AccountLinkPanel forAssignee(AccountInfo ai) {
+    return new AccountLinkPanel(ai, PageLinks::toAssigneeQuery);
+  }
+
+  private AccountLinkPanel(AccountInfo ai, Function<String, String> nameToQuery) {
     addStyleName(Gerrit.RESOURCES.css().accountLinkPanel());
 
     InlineHyperlink l =
-        new InlineHyperlink(FormatUtil.name(info), PageLinks.toAccountQuery(
-            owner(info), status)) {
-      @Override
-      public void go() {
-        Gerrit.display(getTargetHistoryToken());
-      }
-    };
-    l.setTitle(FormatUtil.nameEmail(info));
+        new InlineHyperlink(FormatUtil.name(ai), nameToQuery.apply(name(ai))) {
+          @Override
+          public void go() {
+            Gerrit.display(getTargetHistoryToken());
+          }
+        };
+    l.setTitle(FormatUtil.nameEmail(ai));
 
-    add(new AvatarImage(info));
+    add(new AvatarImage(ai));
     add(l);
   }
 
-  public static String owner(AccountInfo ai) {
+  private static String name(AccountInfo ai) {
     if (ai.email() != null) {
       return ai.email();
     } else if (ai.name() != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
index 3702e68..78ae156 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.ui.SuggestOracle;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -29,7 +28,9 @@
 public class AccountSuggestOracle extends SuggestAfterTypingNCharsOracle {
   @Override
   public void _onRequestSuggestions(final Request req, final Callback cb) {
-    AccountApi.suggest(req.getQuery(), req.getLimit(),
+    AccountApi.suggest(
+        req.getQuery(),
+        req.getLimit(),
         new GerritCallback<JsArray<AccountInfo>>() {
           @Override
           public void onSuccess(JsArray<AccountInfo> in) {
@@ -45,7 +46,7 @@
   public static class AccountSuggestion implements SuggestOracle.Suggestion {
     private final String suggestion;
 
-    AccountSuggestion(AccountInfo info, String query) {
+    public AccountSuggestion(AccountInfo info, String query) {
       this.suggestion = format(info, query);
     }
 
@@ -61,10 +62,10 @@
 
     public static String format(AccountInfo info, String query) {
       String s = FormatUtil.nameEmail(info);
-      if (!containsQuery(s, query) && info.secondaryEmails() != null) {
+      if (query != null && !containsQuery(s, query) && info.secondaryEmails() != null) {
         for (String email : Natives.asList(info.secondaryEmails())) {
-          AccountInfo info2 = AccountInfo.create(info._accountId(), info.name(),
-              email, info.username());
+          AccountInfo info2 =
+              AccountInfo.create(info._accountId(), info.name(), email, info.username());
           String s2 = FormatUtil.nameEmail(info2);
           if (containsQuery(s2, query)) {
             s = s2;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
index 72233f5..5d8d56c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
@@ -29,8 +29,8 @@
   private final Button addMember;
   private final RemoteSuggestBox suggestBox;
 
-  public AddMemberBox(final String buttonLabel, final String hint,
-      final SuggestOracle suggestOracle) {
+  public AddMemberBox(
+      final String buttonLabel, final String hint, final SuggestOracle suggestOracle) {
     addPanel = new FlowPanel();
     addMember = new Button(buttonLabel);
 
@@ -38,12 +38,13 @@
     suggestBox.setStyleName(Gerrit.RESOURCES.css().addMemberTextBox());
     suggestBox.setVisibleLength(50);
     suggestBox.setHintText(hint);
-    suggestBox.addSelectionHandler(new SelectionHandler<String>() {
-      @Override
-      public void onSelection(SelectionEvent<String> event) {
-        addMember.fireEvent(new ClickEvent() {});
-      }
-    });
+    suggestBox.addSelectionHandler(
+        new SelectionHandler<String>() {
+          @Override
+          public void onSelection(SelectionEvent<String> event) {
+            addMember.fireEvent(new ClickEvent() {});
+          }
+        });
 
     addPanel.add(suggestBox);
     addPanel.add(addMember);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java
index 984653d..6e05f83 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/BranchLink.java
@@ -25,13 +25,12 @@
 public class BranchLink extends InlineHyperlink {
   private final String query;
 
-  public BranchLink(Project.NameKey project, Change.Status status,
-      String branch, String topic) {
+  public BranchLink(Project.NameKey project, Change.Status status, String branch, String topic) {
     this(text(branch, topic), query(project, status, branch, topic));
   }
 
-  public BranchLink(String text, Project.NameKey project, Change.Status status,
-      String branch, String topic) {
+  public BranchLink(
+      String text, Project.NameKey project, Change.Status status, String branch, String topic) {
     this(text, query(project, status, branch, topic));
   }
 
@@ -56,14 +55,17 @@
     return branch;
   }
 
-  public static String query(Project.NameKey project, Change.Status status,
-      String branch, String topic) {
+  public static String query(
+      Project.NameKey project, Change.Status status, String branch, String topic) {
     String query = PageLinks.projectQuery(project, status);
 
     if (branch.startsWith(RefNames.REFS)) {
       if (branch.startsWith(RefNames.REFS_HEADS)) {
-        query += " " + PageLinks.op("branch", //
-            branch.substring(RefNames.REFS_HEADS.length()));
+        query +=
+            " "
+                + PageLinks.op(
+                    "branch", //
+                    branch.substring(RefNames.REFS_HEADS.length()));
       } else {
         query += " " + PageLinks.op("ref", branch);
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java
index 4e6f500..85552c9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CherryPickDialog.java
@@ -27,8 +27,7 @@
 import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-
-import java.util.LinkedList;
+import java.util.ArrayList;
 import java.util.List;
 
 public abstract class CherryPickDialog extends TextAreaActionDialog {
@@ -36,9 +35,9 @@
   private List<BranchInfo> branches;
 
   public CherryPickDialog(Project.NameKey project) {
-    super(Util.C.cherryPickTitle(), Util.C
-        .cherryPickCommitMessage());
-    ProjectApi.getBranches(project,
+    super(Util.C.cherryPickTitle(), Util.C.cherryPickCommitMessage());
+    ProjectApi.getBranches(
+        project,
         new GerritCallback<JsArray<BranchInfo>>() {
           @Override
           public void onSuccess(JsArray<BranchInfo> result) {
@@ -46,18 +45,20 @@
           }
         });
 
-    newBranch = new SuggestBox(new HighlightSuggestOracle() {
-      @Override
-      protected void onRequestSuggestions(Request request, Callback done) {
-        LinkedList<BranchSuggestion> suggestions = new LinkedList<>();
-        for (final BranchInfo b : branches) {
-          if (b.ref().contains(request.getQuery())) {
-            suggestions.add(new BranchSuggestion(b));
-          }
-        }
-        done.onSuggestionsReady(request, new Response(suggestions));
-      }
-    });
+    newBranch =
+        new SuggestBox(
+            new HighlightSuggestOracle() {
+              @Override
+              protected void onRequestSuggestions(Request request, Callback done) {
+                List<BranchSuggestion> suggestions = new ArrayList<>();
+                for (final BranchInfo b : branches) {
+                  if (b.ref().contains(request.getQuery())) {
+                    suggestions.add(new BranchSuggestion(b));
+                  }
+                }
+                done.onSuggestionsReady(request, new Response(suggestions));
+              }
+            });
 
     newBranch.setWidth("100%");
     newBranch.getElement().getStyle().setProperty("boxSizing", "border-box");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java
index fd7d40c..1753ade 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java
@@ -19,7 +19,6 @@
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.VoidResult;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -60,15 +59,14 @@
           msg.append("\": ");
           msg.append(e.errorMessage);
         }
-        Gerrit.SYSTEM_SVC.clientError(msg.toString(),
+        Gerrit.SYSTEM_SVC.clientError(
+            msg.toString(),
             new AsyncCallback<VoidResult>() {
               @Override
-              public void onFailure(Throwable caught) {
-              }
+              public void onFailure(Throwable caught) {}
 
               @Override
-              public void onSuccess(VoidResult result) {
-              }
+              public void onSuccess(VoidResult result) {}
             });
       }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java
index c21d5dc..d497740 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentedActionDialog.java
@@ -38,29 +38,31 @@
   protected boolean sent;
 
   public CommentedActionDialog(final String title, final String heading) {
-    super(/* auto hide */false, /* modal */true);
+    super(/* auto hide */ false, /* modal */ true);
     setGlassEnabled(true);
     setText(title);
 
     addStyleName(Gerrit.RESOURCES.css().commentedActionDialog());
 
     sendButton = new Button(Util.C.commentedActionButtonSend());
-    sendButton.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        enableButtons(false);
-        onSend();
-      }
-    });
+    sendButton.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            enableButtons(false);
+            onSend();
+          }
+        });
 
     cancelButton = new Button(Util.C.commentedActionButtonCancel());
     cancelButton.getElement().getStyle().setProperty("float", "right");
-    cancelButton.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        hide();
-      }
-    });
+    cancelButton.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            hide();
+          }
+        });
 
     contentPanel = new FlowPanel();
     contentPanel.setStyleName(Gerrit.RESOURCES.css().commentedActionMessage());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java
index 4cc6a16..f65fb1b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java
@@ -27,8 +27,8 @@
 import com.google.gwt.user.client.ui.Panel;
 import com.google.gwt.user.client.ui.Widget;
 
-public class ComplexDisclosurePanel extends Composite implements
-    HasOpenHandlers<DisclosurePanel>, HasCloseHandlers<DisclosurePanel> {
+public class ComplexDisclosurePanel extends Composite
+    implements HasOpenHandlers<DisclosurePanel>, HasCloseHandlers<DisclosurePanel> {
   private final DisclosurePanel main;
   private final Panel header;
 
@@ -51,28 +51,30 @@
       headerParent = tr1;
     }
 
-    header = new ComplexPanel() {
-      {
-        setElement((Element)(DOM.createTD()));
-        getElement().setInnerHTML("&nbsp;");
-      }
+    header =
+        new ComplexPanel() {
+          {
+            setElement((Element) (DOM.createTD()));
+            getElement().setInnerHTML("&nbsp;");
+          }
 
-      @Override
-      public void add(Widget w) {
-        add(w, (Element)getElement());
-      }
-    };
+          @Override
+          public void add(Widget w) {
+            add(w, (Element) getElement());
+          }
+        };
 
-    initWidget(new ComplexPanel() {
-      {
-        final DisclosurePanel main = ComplexDisclosurePanel.this.main;
-        setElement((Element)(main.getElement()));
-        getChildren().add(main);
-        adopt(main);
+    initWidget(
+        new ComplexPanel() {
+          {
+            final DisclosurePanel main = ComplexDisclosurePanel.this.main;
+            setElement((Element) (main.getElement()));
+            getChildren().add(main);
+            adopt(main);
 
-        add(ComplexDisclosurePanel.this.header, headerParent);
-      }
-    });
+            add(ComplexDisclosurePanel.this.header, headerParent);
+          }
+        });
   }
 
   public Panel getHeader() {
@@ -93,8 +95,7 @@
   }
 
   @Override
-  public HandlerRegistration addCloseHandler(
-      final CloseHandler<DisclosurePanel> h) {
+  public HandlerRegistration addCloseHandler(final CloseHandler<DisclosurePanel> h) {
     return main.addCloseHandler(h);
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java
index 7cda8a3..2d00281 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CreateChangeDialog.java
@@ -27,7 +27,6 @@
 import com.google.gwt.user.client.ui.TextBox;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -37,9 +36,9 @@
   private TextBox topic;
 
   public CreateChangeDialog(Project.NameKey project) {
-    super(Util.C.dialogCreateChangeTitle(),
-        Util.C.dialogCreateChangeHeading());
-    ProjectApi.getBranches(project,
+    super(Util.C.dialogCreateChangeTitle(), Util.C.dialogCreateChangeHeading());
+    ProjectApi.getBranches(
+        project,
         new GerritCallback<JsArray<BranchInfo>>() {
           @Override
           public void onSuccess(JsArray<BranchInfo> result) {
@@ -56,18 +55,20 @@
     panel.insert(newTopicPanel, 0);
     panel.insert(new SmallHeading(Util.C.newChangeTopicSuggestion()), 0);
 
-    newChange = new SuggestBox(new HighlightSuggestOracle() {
-      @Override
-      protected void onRequestSuggestions(Request request, Callback done) {
-        List<BranchSuggestion> suggestions = new ArrayList<>();
-        for (BranchInfo b : branches) {
-          if (b.ref().contains(request.getQuery())) {
-            suggestions.add(new BranchSuggestion(b));
-          }
-        }
-        done.onSuggestionsReady(request, new Response(suggestions));
-      }
-    });
+    newChange =
+        new SuggestBox(
+            new HighlightSuggestOracle() {
+              @Override
+              protected void onRequestSuggestions(Request request, Callback done) {
+                List<BranchSuggestion> suggestions = new ArrayList<>();
+                for (BranchInfo b : branches) {
+                  if (b.ref().contains(request.getQuery())) {
+                    suggestions.add(new BranchSuggestion(b));
+                  }
+                }
+                done.onSuggestionsReady(request, new Response(suggestions));
+              }
+            });
 
     newChange.setWidth("100%");
     newChange.getElement().getStyle().setProperty("boxSizing", "border-box");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java
index e77bc10..a9a17210 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTable.java
@@ -24,13 +24,11 @@
 import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
-
 import java.util.Comparator;
 import java.util.Iterator;
 
 public abstract class FancyFlexTable<RowItem> extends Composite {
-  private static final FancyFlexTableImpl impl =
-      GWT.create(FancyFlexTableImpl.class);
+  private static final FancyFlexTableImpl impl = GWT.create(FancyFlexTableImpl.class);
 
   protected static final int C_ARROW = 0;
 
@@ -51,8 +49,7 @@
   }
 
   protected RowItem getRowItem(final int row) {
-    return FancyFlexTable.<RowItem> getRowItem(table.getCellFormatter()
-        .getElement(row, 0));
+    return FancyFlexTable.<RowItem>getRowItem(table.getCellFormatter().getElement(row, 0));
   }
 
   protected void setRowItem(final int row, final RowItem item) {
@@ -64,32 +61,28 @@
    *
    * @param comparator comparator by which the items in the table are sorted
    * @param item the item that should be found
-   * @return if the item is found the number of the row that contains the item;
-   *         if the item is not found {@code -1}
+   * @return if the item is found the number of the row that contains the item; if the item is not
+   *     found {@code -1}
    */
   protected int findRowItem(Comparator<RowItem> comparator, RowItem item) {
     int row = lookupRowItem(comparator, item);
-    if (row < table.getRowCount()
-        && comparator.compare(item, getRowItem(row)) == 0) {
+    if (row < table.getRowCount() && comparator.compare(item, getRowItem(row)) == 0) {
       return row;
     }
     return -1;
   }
 
   /**
-   * Finds the number of the row where a new item should be inserted into the
-   * table.
+   * Finds the number of the row where a new item should be inserted into the table.
    *
    * @param comparator comparator by which the items in the table are sorted
    * @param item the new item that should be inserted
-   * @return if the item is not yet contained in the table, the number of the
-   *         row where the new item should be inserted; if the item is already
-   *         contained in the table {@code -1}
+   * @return if the item is not yet contained in the table, the number of the row where the new item
+   *     should be inserted; if the item is already contained in the table {@code -1}
    */
   protected int getInsertRow(Comparator<RowItem> comparator, RowItem item) {
     int row = lookupRowItem(comparator, item);
-    if (row >= table.getRowCount()
-        || comparator.compare(item, getRowItem(row)) != 0) {
+    if (row >= table.getRowCount() || comparator.compare(item, getRowItem(row)) != 0) {
       return row;
     }
     return -1;
@@ -100,9 +93,9 @@
    *
    * @param comparator comparator by which the items in the table are sorted
    * @param item the item that should be looked up
-   * @return if the item is found the number of the row that contains the item;
-   *         if the item is not found the number of the row where the item
-   *         should be inserted according to the given comparator.
+   * @return if the item is found the number of the row that contains the item; if the item is not
+   *     found the number of the row where the item should be inserted according to the given
+   *     comparator.
    */
   private int lookupRowItem(Comparator<RowItem> comparator, RowItem item) {
     int left = 1;
@@ -125,7 +118,7 @@
   }
 
   protected void resetHtml(final SafeHtml body) {
-    for (final Iterator<Widget> i = table.iterator(); i.hasNext();) {
+    for (final Iterator<Widget> i = table.iterator(); i.hasNext(); ) {
       i.next();
       i.remove();
     }
@@ -180,8 +173,8 @@
    * Get the td element that contains another element.
    *
    * @param target the child element whose parent td is required.
-   * @return the td containing element {@code target}; null if {@code target} is
-   *         not a member of this table.
+   * @return the td containing element {@code target}; null if {@code target} is not a member of
+   *     this table.
    */
   protected Element getParentCell(final Element target) {
     final Element body = FancyFlexTableImpl.getBodyElement(table);
@@ -219,12 +212,11 @@
     return DOM.getChildIndex(tr, td);
   }
 
-  protected static class MyFlexTable extends FlexTable {
-  }
+  protected static class MyFlexTable extends FlexTable {}
 
   private static native <ItemType> void setRowItem(Element td, ItemType c)
-  /*-{ td['__gerritRowItem'] = c; }-*/;
+      /*-{ td['__gerritRowItem'] = c; }-*/ ;
 
   private static native <ItemType> ItemType getRowItem(Element td)
-  /*-{ return td['__gerritRowItem']; }-*/;
+      /*-{ return td['__gerritRowItem']; }-*/ ;
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java
index e6e5d8b..ded0140 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java
@@ -25,5 +25,5 @@
   }
 
   protected static native Element getBodyElement(HTMLTable myTable)
-  /*-{ return myTable.@com.google.gwt.user.client.ui.HTMLTable::bodyElem; }-*/;
+      /*-{ return myTable.@com.google.gwt.user.client.ui.HTMLTable::bodyElem; }-*/ ;
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java
index 76ad0e7..a648412 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java
@@ -41,8 +41,7 @@
     b.closeElement("table");
 
     final Element newTable = SafeHtml.parse(b);
-    for (Element e = DOM.getFirstChild(newTable); e != null; e =
-        DOM.getNextSibling(e)) {
+    for (Element e = DOM.getFirstChild(newTable); e != null; e = DOM.getNextSibling(e)) {
       if ("tbody".equals(e.getTagName().toLowerCase())) {
         return e;
       }
@@ -51,5 +50,5 @@
   }
 
   private static native void setBodyElement(HTMLTable myTable, Element newBody)
-  /*-{ myTable.@com.google.gwt.user.client.ui.HTMLTable::bodyElem = newBody; }-*/;
+      /*-{ myTable.@com.google.gwt.user.client.ui.HTMLTable::bodyElem = newBody; }-*/ ;
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java
index 00825a3..6e1fb09 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.client.ui;
 
-
 public class HighlightingInlineHyperlink extends InlineHyperlink {
 
   private String toHighlight;
 
-  public HighlightingInlineHyperlink(final String text, final String token,
-      final String toHighlight) {
+  public HighlightingInlineHyperlink(
+      final String text, final String token, final String toHighlight) {
     super(text, token);
     this.toHighlight = toHighlight;
     highlight(text, toHighlight);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java
index fc60360..643c766 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java
@@ -28,9 +28,10 @@
 
   @Override
   protected void populate(final int row, final ProjectInfo k) {
-    table.setWidget(row, 1,
-        new InlineHTML(Util.highlight(k.name(), toHighlight)));
-    table.setText(row, 2, k.description());
+    populateState(row, k);
+    table.setWidget(
+        row, ProjectsTable.C_NAME, new InlineHTML(Util.highlight(k.name(), toHighlight)));
+    table.setText(row, ProjectsTable.C_DESCRIPTION, k.description());
 
     setRowItem(row, k);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java
index 2ec6cd93..f8ad835 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java
@@ -28,7 +28,6 @@
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.globalkey.client.NpTextBox;
 
-
 public class HintTextBox extends NpTextBox {
   private HandlerRegistration hintFocusHandler;
   private HandlerRegistration hintBlurHandler;
@@ -42,7 +41,6 @@
   private boolean hintOn;
   private boolean isFocused;
 
-
   @Override
   public String getText() {
     if (hintOn) {
@@ -58,7 +56,7 @@
     super.setText(text);
     prevText = text;
 
-    if (! isFocused) {
+    if (!isFocused) {
       blurHint();
     }
   }
@@ -91,42 +89,48 @@
     if (hintText == null) { // first time (was not already set)
       hintText = text;
 
-      hintFocusHandler = addFocusHandler(new FocusHandler() {
-          @Override
-          public void onFocus(FocusEvent event) {
-            focusHint();
-            prevText = getText();
-            isFocused = true;
-          }
-        });
+      hintFocusHandler =
+          addFocusHandler(
+              new FocusHandler() {
+                @Override
+                public void onFocus(FocusEvent event) {
+                  focusHint();
+                  prevText = getText();
+                  isFocused = true;
+                }
+              });
 
-      hintBlurHandler = addBlurHandler(new BlurHandler() {
-          @Override
-          public void onBlur(BlurEvent event) {
-            blurHint();
-            isFocused = false;
-          }
-        });
+      hintBlurHandler =
+          addBlurHandler(
+              new BlurHandler() {
+                @Override
+                public void onBlur(BlurEvent event) {
+                  blurHint();
+                  isFocused = false;
+                }
+              });
 
       /*
-      * There seems to be a strange bug (at least on firefox 3.5.9 ubuntu) with
-      * the textbox under the following circumstances:
-      *  1) The field is not focused with BText in it.
-      *  2) The field receives focus and a focus listener changes the text to FText
-      *  3) The ESC key is pressed and the value of the field has not changed
-      *     (ever) from FText
-      *  4) BUG: The text value gets reset to BText!
-      *
-      *  A counter to this bug seems to be to force setFocus(false) on ESC.
-      */
+       * There seems to be a strange bug (at least on firefox 3.5.9 ubuntu) with
+       * the textbox under the following circumstances:
+       *  1) The field is not focused with BText in it.
+       *  2) The field receives focus and a focus listener changes the text to FText
+       *  3) The ESC key is pressed and the value of the field has not changed
+       *     (ever) from FText
+       *  4) BUG: The text value gets reset to BText!
+       *
+       *  A counter to this bug seems to be to force setFocus(false) on ESC.
+       */
 
       /* Chrome does not create a KeyPressEvent on ESC, so use KeyDownEvents */
-      keyDownHandler = addKeyDownHandler(new KeyDownHandler() {
-          @Override
-          public void onKeyDown(final KeyDownEvent event) {
-            onKey(event.getNativeKeyCode());
-          }
-        });
+      keyDownHandler =
+          addKeyDownHandler(
+              new KeyDownHandler() {
+                @Override
+                public void onKeyDown(final KeyDownEvent event) {
+                  onKey(event.getNativeKeyCode());
+                }
+              });
 
     } else { // Changing an already set Hint
 
@@ -134,7 +138,7 @@
       hintText = text;
     }
 
-    if (! isFocused) {
+    if (!isFocused) {
       blurHint();
     }
   }
@@ -151,11 +155,10 @@
         // recreates the same string as before ESC was pressed, the
         // SuggestBox will think that the string has not changed, and
         // it will not yet provide any Suggestions.
-        ((SuggestBox)p).showSuggestionList();
+        ((SuggestBox) p).showSuggestionList();
 
         // The suggestion list lingers if we don't hide it.
-        ((DefaultSuggestionDisplay) ((SuggestBox) p).getSuggestionDisplay())
-            .hideSuggestions();
+        ((DefaultSuggestionDisplay) ((SuggestBox) p).getSuggestionDisplay()).hideSuggestions();
       }
 
       setFocus(false);
@@ -179,7 +182,7 @@
   }
 
   protected void blurHint() {
-    if (! hintOn && getHintText() != null && "".equals(super.getText())) {
+    if (!hintOn && getHintText() != null && "".equals(super.getText())) {
       hintOn = true;
       super.setText(getHintText());
       if (getHintStyleName() != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java
index 2108ad4..6c28145 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java
@@ -26,15 +26,14 @@
   public static final HyperlinkImpl impl = GWT.create(HyperlinkImpl.class);
 
   /** Initialize a default hyperlink with no target and no text. */
-  public Hyperlink() {
-  }
+  public Hyperlink() {}
 
   /**
    * Creates a hyperlink with its text and target history token specified.
    *
    * @param text the hyperlink's text
-   * @param token the history token to which it will link, which may not be null
-   *        (use {@link Anchor} instead if you don't need history processing)
+   * @param token the history token to which it will link, which may not be null (use {@link Anchor}
+   *     instead if you don't need history processing)
    */
   public Hyperlink(final String text, final String token) {
     super(text, token);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java
index 9d41774..24f2887 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java
@@ -21,8 +21,7 @@
 import com.google.gwt.user.client.Event;
 
 /** Standard GWT hyperlink with late updating of the token. */
-public class InlineHyperlink extends
-    com.google.gwt.user.client.ui.InlineHyperlink {
+public class InlineHyperlink extends com.google.gwt.user.client.ui.InlineHyperlink {
   /**
    * Creates a link with its text and target history token specified.
    *
@@ -34,8 +33,7 @@
   }
 
   /** Creates an empty link. */
-  public InlineHyperlink() {
-  }
+  public InlineHyperlink() {}
 
   @Override
   public void onBrowserEvent(final Event event) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java
index 4f7d419..d08b6f9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java
@@ -87,6 +87,5 @@
   }
 
   @Override
-  public void onScreenLoad(ScreenLoadEvent event) {
-  }
+  public void onScreenLoad(ScreenLoadEvent event) {}
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java
index d944690..2c614b5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java
@@ -62,8 +62,7 @@
     link(text, target, true);
   }
 
-  protected void link(final String text, final String target,
-      final boolean visible) {
+  protected void link(final String text, final String target, final boolean visible) {
     final LinkMenuItem item = new LinkMenuItem(text, target);
     item.setStyleName(Gerrit.RESOURCES.css().menuItem());
     item.setVisible(visible);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java
index 1b99707..7fd6432 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MorphingTabPanel.java
@@ -18,14 +18,13 @@
 import com.google.gwt.event.logical.shared.SelectionHandler;
 import com.google.gwt.user.client.ui.TabPanel;
 import com.google.gwt.user.client.ui.Widget;
-
 import java.util.ArrayList;
 import java.util.List;
 
-/** A TabPanel which allows entries to be hidden.  This class is not yet
- *  designed to handle removes or any other add methods than the one
- *  overridden here.  It is also not designed to handle anything other
- *  than text for the tab.
+/**
+ * A TabPanel which allows entries to be hidden. This class is not yet designed to handle removes or
+ * any other add methods than the one overridden here. It is also not designed to handle anything
+ * other than text for the tab.
  */
 public class MorphingTabPanel extends TabPanel {
   // Keep track of the order the widgets/texts should be in when not hidden.
@@ -38,12 +37,13 @@
   private int selection;
 
   public MorphingTabPanel() {
-    addSelectionHandler(new SelectionHandler<Integer>() {
-        @Override
-        public void onSelection(SelectionEvent<Integer> ev) {
-          selection = ev.getSelectedItem();
-        }
-      });
+    addSelectionHandler(
+        new SelectionHandler<Integer>() {
+          @Override
+          public void onSelection(SelectionEvent<Integer> ev) {
+            selection = ev.getSelectedItem();
+          }
+        });
   }
 
   public int getSelectedIndex() {
@@ -80,8 +80,8 @@
         int origPos = widgets.indexOf(w);
 
         /* Re-insert the widget right after the first visible widget found
-           when scanning backwards from the current widget */
-        for (int pos = origPos - 1; pos >= 0 ; pos--) {
+        when scanning backwards from the current widget */
+        for (int pos = origPos - 1; pos >= 0; pos--) {
           int visiblePos = visibles.indexOf(widgets.get(pos));
           if (visiblePos != -1) {
             visibles.add(visiblePos + 1, w);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
index 91fedef..8975dda 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
@@ -32,7 +32,6 @@
 import com.google.gwtexpui.globalkey.client.KeyCommand;
 import com.google.gwtexpui.globalkey.client.KeyCommandSet;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
-
 import java.util.LinkedHashMap;
 import java.util.Map.Entry;
 
@@ -45,28 +44,30 @@
     @Override
     public void onBrowserEvent(final Event event) {
       switch (DOM.eventGetType(event)) {
-        case Event.ONCLICK: {
-          // Find out which cell was actually clicked.
-          final Element td = getEventTargetCell(event);
-          if (td == null) {
+        case Event.ONCLICK:
+          {
+            // Find out which cell was actually clicked.
+            final Element td = getEventTargetCell(event);
+            if (td == null) {
+              break;
+            }
+            final int row = rowOf(td);
+            if (getRowItem(row) != null) {
+              onCellSingleClick(event, rowOf(td), columnOf(td));
+              return;
+            }
             break;
           }
-          final int row = rowOf(td);
-          if (getRowItem(row) != null) {
-            onCellSingleClick(event, rowOf(td), columnOf(td));
+        case Event.ONDBLCLICK:
+          {
+            // Find out which cell was actually clicked.
+            Element td = getEventTargetCell(event);
+            if (td == null) {
+              return;
+            }
+            onCellDoubleClick(rowOf(td), columnOf(td));
             return;
           }
-          break;
-        }
-        case Event.ONDBLCLICK: {
-          // Find out which cell was actually clicked.
-          Element td = getEventTargetCell(event);
-          if (td == null) {
-            return;
-          }
-          onCellDoubleClick(rowOf(td), columnOf(td));
-          return;
-        }
       }
       super.onBrowserEvent(event);
     }
@@ -98,8 +99,8 @@
         new PrevKeyCommand(0, 'k', Util.M.helpListPrev(itemHelpName)),
         new NextKeyCommand(0, 'j', Util.M.helpListNext(itemHelpName)));
     keysNavigation.add(new OpenKeyCommand(0, 'o', Util.M.helpListOpen(itemHelpName)));
-    keysNavigation.add(new OpenKeyCommand(0, KeyCodes.KEY_ENTER,
-                                                  Util.M.helpListOpen(itemHelpName)));
+    keysNavigation.add(
+        new OpenKeyCommand(0, KeyCodes.KEY_ENTER, Util.M.helpListOpen(itemHelpName)));
   }
 
   protected NavigationTable() {
@@ -208,8 +209,7 @@
       final Element tr = fmt.getElement(currentRow, C_ARROW).getParentElement();
       UIObject.setStyleName(tr, Gerrit.RESOURCES.css().activeRow(), false);
     }
-    if (0 <= newRow && newRow < table.getRowCount()
-        && getRowItem(newRow) != null) {
+    if (0 <= newRow && newRow < table.getRowCount() && getRowItem(newRow) != null) {
       table.setWidget(newRow, C_ARROW, pointer);
       final Element tr = fmt.getElement(newRow, C_ARROW).getParentElement();
       UIObject.setStyleName(tr, Gerrit.RESOURCES.css().activeRow(), true);
@@ -238,11 +238,12 @@
     }
 
     if (parentScrollPanel != null) {
-      parentScrollPanel.ensureVisible(new UIObject() {
-        {
-          setElement(tr);
-        }
-      });
+      parentScrollPanel.ensureVisible(
+          new UIObject() {
+            {
+              setElement(tr);
+            }
+          });
     } else {
       int rt = tr.getAbsoluteTop();
       int rl = tr.getAbsoluteLeft();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java
index 3994381..8be6647 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NpIntTextBox.java
@@ -31,21 +31,23 @@
   }
 
   private void init() {
-    addKeyDownHandler(new KeyDownHandler() {
-      @Override
-      public void onKeyDown(KeyDownEvent event) {
-        int code = event.getNativeKeyCode();
-        onKey(event, code, code);
-      }
-    });
-    addKeyPressHandler(new KeyPressHandler() {
-      @Override
-      public void onKeyPress(KeyPressEvent event) {
-        int charCode = event.getCharCode();
-        int keyCode = event.getNativeEvent().getKeyCode();
-        onKey(event, charCode, keyCode);
-      }
-    });
+    addKeyDownHandler(
+        new KeyDownHandler() {
+          @Override
+          public void onKeyDown(KeyDownEvent event) {
+            int code = event.getNativeKeyCode();
+            onKey(event, code, code);
+          }
+        });
+    addKeyPressHandler(
+        new KeyPressHandler() {
+          @Override
+          public void onKeyPress(KeyPressEvent event) {
+            int charCode = event.getCharCode();
+            int keyCode = event.getNativeEvent().getKeyCode();
+            onKey(event, charCode, keyCode);
+          }
+        });
   }
 
   private void onKey(KeyEvent<?> event, int charCode, int keyCode) {
@@ -67,9 +69,7 @@
         default:
           // Allow copy and paste using ctl-c/ctrl-v,
           // or whatever the platform's convention is.
-          if (!(event.isControlKeyDown()
-              || event.isMetaKeyDown()
-              || event.isAltKeyDown())) {
+          if (!(event.isControlKeyDown() || event.isMetaKeyDown() || event.isAltKeyDown())) {
             event.preventDefault();
           }
           break;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java
index 4391477..87de3b7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/OnEditEnabler.java
@@ -34,22 +34,23 @@
 import com.google.gwt.user.client.ui.ListBox;
 import com.google.gwt.user.client.ui.TextBoxBase;
 import com.google.gwt.user.client.ui.ValueBoxBase;
-
 import java.util.HashMap;
 import java.util.Map;
 
-
-/** Enables a FocusWidget (e.g. a Button) if an edit is detected from any
- *  registered input widget.
+/**
+ * Enables a FocusWidget (e.g. a Button) if an edit is detected from any registered input widget.
  */
-public class OnEditEnabler implements KeyPressHandler, KeyDownHandler,
-   MouseUpHandler, ChangeHandler, ValueChangeHandler<Object> {
+public class OnEditEnabler
+    implements KeyPressHandler,
+        KeyDownHandler,
+        MouseUpHandler,
+        ChangeHandler,
+        ValueChangeHandler<Object> {
 
   private final FocusWidget widget;
   private Map<TextBoxBase, String> strings = new HashMap<>();
   private String originalValue;
 
-
   // The first parameter to the contructors must be the FocusWidget to enable,
   // subsequent parameters are widgets to listenTo.
 
@@ -91,12 +92,13 @@
     // up to date with non-user updates of the text (calls to
     // setText()...) and also up to date with user changes which
     // occurred after enabling "widget".
-    tb.addFocusHandler(new FocusHandler() {
-        @Override
-        public void onFocus(FocusEvent event) {
-          strings.put(tb, tb.getText().trim());
-        }
-      });
+    tb.addFocusHandler(
+        new FocusHandler() {
+          @Override
+          public void onFocus(FocusEvent event) {
+            strings.put(tb, tb.getText().trim());
+          }
+        });
 
     // CTRL-V Pastes in Chrome seem only detectable via BrowserEvents or
     // KeyDownEvents, the latter is better.
@@ -112,7 +114,6 @@
     cb.addValueChangeHandler((ValueChangeHandler) this);
   }
 
-
   // Handlers
 
   @Override
@@ -142,19 +143,21 @@
   }
 
   private void on(final GwtEvent<?> e) {
-    if (widget.isEnabled() ||
-        ! (e.getSource() instanceof FocusWidget) ||
-        ! ((FocusWidget) e.getSource()).isEnabled() ) {
+    if (widget.isEnabled()
+        || !(e.getSource() instanceof FocusWidget)
+        || !((FocusWidget) e.getSource()).isEnabled()) {
       if (e.getSource() instanceof ValueBoxBase) {
         final TextBoxBase box = ((TextBoxBase) e.getSource());
-        Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-          @Override
-          public void execute() {
-            if (box.getValue().trim().equals(originalValue)) {
-              widget.setEnabled(false);
-            }
-          }
-        });
+        Scheduler.get()
+            .scheduleDeferred(
+                new ScheduledCommand() {
+                  @Override
+                  public void execute() {
+                    if (box.getValue().trim().equals(originalValue)) {
+                      widget.setEnabled(false);
+                    }
+                  }
+                });
       }
       return;
     }
@@ -171,17 +174,19 @@
 
   private void onTextBoxBase(final TextBoxBase tb) {
     // The text appears to not get updated until the handlers complete.
-    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
-      @Override
-      public void execute() {
-        String orig = strings.get(tb);
-        if (orig == null) {
-          orig = "";
-        }
-        if (! orig.equals(tb.getText().trim())) {
-          widget.setEnabled(true);
-        }
-      }
-    });
+    Scheduler.get()
+        .scheduleDeferred(
+            new ScheduledCommand() {
+              @Override
+              public void execute() {
+                String orig = strings.get(tb);
+                if (orig == null) {
+                  orig = "";
+                }
+                if (!orig.equals(tb.getText().trim())) {
+                  widget.setEnabled(true);
+                }
+              }
+            });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PagingHyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PagingHyperlink.java
index e4ad903..7f0ef68 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PagingHyperlink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/PagingHyperlink.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.client.ui;
 
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.admin.Util;
+import com.google.gerrit.client.admin.AdminConstants;
 
 public class PagingHyperlink extends Hyperlink {
 
   public static PagingHyperlink createPrev() {
-    return new PagingHyperlink(Util.C.pagedListPrev());
+    return new PagingHyperlink(AdminConstants.I.pagedListPrev());
   }
 
   public static PagingHyperlink createNext() {
-    return new PagingHyperlink(Util.C.pagedListNext());
+    return new PagingHyperlink(AdminConstants.I.pagedListNext());
   }
 
   private PagingHyperlink(String text) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java
index e254f6e..fab0cf7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java
@@ -21,7 +21,6 @@
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.Composite;
-
 import java.util.HashSet;
 import java.util.Set;
 
@@ -61,38 +60,41 @@
     public void setProject(Project.NameKey project) {
       exclude.clear();
       exclude.add(project.get());
-      ProjectApi.getChildren(project, true, new AsyncCallback<JsArray<ProjectInfo>>() {
-        @Override
-        public void onSuccess(JsArray<ProjectInfo> result) {
-          for (ProjectInfo p : Natives.asList(result)) {
-            exclude.add(p.name());
-          }
-        }
+      ProjectApi.getChildren(
+          project,
+          true,
+          new AsyncCallback<JsArray<ProjectInfo>>() {
+            @Override
+            public void onSuccess(JsArray<ProjectInfo> result) {
+              for (ProjectInfo p : Natives.asList(result)) {
+                exclude.add(p.name());
+              }
+            }
 
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      });
+            @Override
+            public void onFailure(Throwable caught) {}
+          });
     }
 
     @Override
     public void _onRequestSuggestions(Request req, final Callback callback) {
-      super._onRequestSuggestions(req, new Callback() {
-        @Override
-        public void onSuggestionsReady(Request request, Response response) {
-          if (exclude.size() > 0) {
-            Set<Suggestion> filteredSuggestions =
-                new HashSet<>(response.getSuggestions());
-            for (Suggestion s : response.getSuggestions()) {
-              if (exclude.contains(s.getReplacementString())) {
-                filteredSuggestions.remove(s);
+      super._onRequestSuggestions(
+          req,
+          new Callback() {
+            @Override
+            public void onSuggestionsReady(Request request, Response response) {
+              if (exclude.size() > 0) {
+                Set<Suggestion> filteredSuggestions = new HashSet<>(response.getSuggestions());
+                for (Suggestion s : response.getSuggestions()) {
+                  if (exclude.contains(s.getReplacementString())) {
+                    filteredSuggestions.remove(s);
+                  }
+                }
+                response.setSuggestions(filteredSuggestions);
               }
+              callback.onSuggestionsReady(request, response);
             }
-            response.setSuggestions(filteredSuggestions);
-          }
-          callback.onSuggestionsReady(request, response);
-        }
-      });
+          });
     }
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLinkMenuItem.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLinkMenuItem.java
index 119f5ef..114f794 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLinkMenuItem.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectLinkMenuItem.java
@@ -31,7 +31,7 @@
     Screen screen = event.getScreen();
     Project.NameKey projectKey;
     if (screen instanceof ProjectScreen) {
-      projectKey = ((ProjectScreen)screen).getProjectKey();
+      projectKey = ((ProjectScreen) screen).getProjectKey();
     } else {
       projectKey = ProjectScreen.getSavedKey();
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
index 36708dc..cace84b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectListPopup.java
@@ -96,57 +96,56 @@
    *
    * @param projectName project name.
    */
-  protected void onMovePointerTo(String projectName) {
-  }
+  protected void onMovePointerTo(String projectName) {}
 
   /**
    * Invoked after opening a project row.
    *
    * @param projectName project name.
    */
-  protected void openRow(String projectName) {
-  }
+  protected void openRow(String projectName) {}
 
   public boolean isPoppingUp() {
     return poppingUp;
   }
 
-  private void createWidgets(final String popupText,
-      final String currentPageLink) {
+  private void createWidgets(final String popupText, final String currentPageLink) {
     filterPanel = new HorizontalPanel();
     filterPanel.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
     final Label filterLabel =
-        new Label(com.google.gerrit.client.admin.Util.C.projectFilter());
+        new Label(com.google.gerrit.client.admin.AdminConstants.I.projectFilter());
     filterLabel.setStyleName(Gerrit.RESOURCES.css().projectFilterLabel());
     filterPanel.add(filterLabel);
     filterTxt = new NpTextBox();
-    filterTxt.addKeyUpHandler(new KeyUpHandler() {
-      @Override
-      public void onKeyUp(KeyUpEvent event) {
-        Query q = new Query(filterTxt.getValue());
-        if (!match.equals(q.qMatch)) {
-          if (query == null) {
-            q.run();
+    filterTxt.addKeyUpHandler(
+        new KeyUpHandler() {
+          @Override
+          public void onKeyUp(KeyUpEvent event) {
+            Query q = new Query(filterTxt.getValue());
+            if (!match.equals(q.qMatch)) {
+              if (query == null) {
+                q.run();
+              }
+              query = q;
+            }
           }
-          query = q;
-        }
-      }
-    });
+        });
     filterPanel.add(filterTxt);
 
-    projectsTab = new HighlightingProjectsTable() {
-      @Override
-      protected void movePointerTo(final int row, final boolean scroll) {
-        super.movePointerTo(row, scroll);
-        onMovePointerTo(getRowItem(row).name());
-      }
+    projectsTab =
+        new HighlightingProjectsTable() {
+          @Override
+          protected void movePointerTo(final int row, final boolean scroll) {
+            super.movePointerTo(row, scroll);
+            onMovePointerTo(getRowItem(row).name());
+          }
 
-      @Override
-      protected void onOpenRow(final int row) {
-        super.onOpenRow(row);
-        openRow(getRowItem(row).name());
-      }
-    };
+          @Override
+          protected void onOpenRow(final int row) {
+            super.onOpenRow(row);
+            openRow(getRowItem(row).name());
+          }
+        };
     projectsTab.setSavePointerId(currentPageLink);
 
     closeTop = createCloseButton();
@@ -159,12 +158,13 @@
 
   private Button createCloseButton() {
     Button close = new Button(Util.C.projectsClose());
-    close.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(final ClickEvent event) {
-        closePopup();
-      }
-    });
+    close.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(final ClickEvent event) {
+            closePopup();
+          }
+        });
     return close;
   }
 
@@ -176,8 +176,7 @@
     } else {
       popup.setPopupPositionAndShow(popupPosition);
       GlobalKey.dialog(popup);
-      GlobalKey.addApplication(popup, new HidePopupPanelCommand(0,
-          KeyCodes.KEY_ESCAPE, popup));
+      GlobalKey.addApplication(popup, new HidePopupPanelCommand(0, KeyCodes.KEY_ESCAPE, popup));
       projectsTab.setRegisterKeys(true);
       projectsTab.finishDisplay();
       filterTxt.setFocus(true);
@@ -202,19 +201,21 @@
     }
 
     Query run() {
-      ProjectMap.match(qMatch, new GerritCallback<ProjectMap>() {
-          @Override
-          public void onSuccess(ProjectMap result) {
-            if (!firstPopupLoad && !popup.isShowing()) {
-              query = null;
-            } else if (query == Query.this) {
-              query = null;
-              showMap(result);
-            } else {
-              query.run();
+      ProjectMap.match(
+          qMatch,
+          new GerritCallback<ProjectMap>() {
+            @Override
+            public void onSuccess(ProjectMap result) {
+              if (!firstPopupLoad && !popup.isShowing()) {
+                query = null;
+              } else if (query == Query.this) {
+                query = null;
+                showMap(result);
+              } else {
+                query.run();
+              }
             }
-          }
-        });
+          });
       return this;
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java
index 49120f6..2767a05 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java
@@ -22,7 +22,9 @@
 public class ProjectNameSuggestOracle extends SuggestAfterTypingNCharsOracle {
   @Override
   public void _onRequestSuggestions(final Request req, final Callback callback) {
-    ProjectMap.suggest(req.getQuery(), req.getLimit(),
+    ProjectMap.suggest(
+        req.getQuery(),
+        req.getLimit(),
         new GerritCallback<ProjectMap>() {
           @Override
           public void onSuccess(ProjectMap map) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java
index ad418a6..f0e06a0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectSearchLink.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.client.ui;
 
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.admin.Util;
+import com.google.gerrit.client.admin.AdminConstants;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.user.client.DOM;
@@ -25,10 +25,9 @@
 
   public ProjectSearchLink(Project.NameKey projectName) {
     super(" ", PageLinks.toProjectDefaultDashboard(projectName));
-    setTitle(Util.C.projectListQueryLink());
+    setTitle(AdminConstants.I.projectListQueryLink());
     final Image image = new Image(Gerrit.RESOURCES.queryIcon());
     image.setStyleName(Gerrit.RESOURCES.css().queryIcon());
-    DOM.insertBefore(getElement(), image.getElement(),
-        DOM.getFirstChild(getElement()));
+    DOM.insertBefore(getElement(), image.getElement(), DOM.getFirstChild(getElement()));
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
index bb55b69..99d0e8e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.client.projects.ProjectMap;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
-
+import com.google.gwt.user.client.ui.Image;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
@@ -37,8 +37,7 @@
 
   protected void initColumnHeaders() {
     table.setText(0, C_STATE, Util.C.projectStateAbbrev());
-    table.getCellFormatter().getElement(0, C_STATE)
-        .setTitle(Util.C.projectStateHelp());
+    table.getCellFormatter().getElement(0, C_STATE).setTitle(Util.C.projectStateHelp());
     table.setText(0, C_NAME, Util.C.projectName());
     table.setText(0, C_DESCRIPTION, Util.C.projectDescription());
 
@@ -70,12 +69,14 @@
     }
 
     List<ProjectInfo> list = Natives.asList(projects.values());
-    Collections.sort(list, new Comparator<ProjectInfo>() {
-      @Override
-      public int compare(ProjectInfo a, ProjectInfo b) {
-        return a.name().compareTo(b.name());
-      }
-    });
+    Collections.sort(
+        list,
+        new Comparator<ProjectInfo>() {
+          @Override
+          public int compare(ProjectInfo a, ProjectInfo b) {
+            return a.name().compareTo(b.name());
+          }
+        });
     for (ProjectInfo p : list.subList(fromIndex, toIndex)) {
       insert(table.getRowCount(), p);
     }
@@ -98,10 +99,30 @@
   }
 
   protected void populate(final int row, final ProjectInfo k) {
-    table.setText(row, C_STATE, k.state().toString());
+    populateState(row, k);
     table.setText(row, C_NAME, k.name());
     table.setText(row, C_DESCRIPTION, k.description());
 
     setRowItem(row, k);
   }
+
+  protected void populateState(int row, ProjectInfo k) {
+    Image state = new Image();
+    switch (k.state()) {
+      case HIDDEN:
+        state.setResource(Gerrit.RESOURCES.redNot());
+        state.setTitle(com.google.gerrit.client.admin.Util.toLongString(k.state()));
+        table.setWidget(row, ProjectsTable.C_STATE, state);
+        break;
+      case READ_ONLY:
+        state.setResource(Gerrit.RESOURCES.readOnly());
+        state.setTitle(com.google.gerrit.client.admin.Util.toLongString(k.state()));
+        table.setWidget(row, ProjectsTable.C_STATE, state);
+        break;
+      case ACTIVE:
+      default:
+        // Intentionally left blank, do not show an icon when active.
+        break;
+    }
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java
index 77c4e56..f3dc6c3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RebaseDialog.java
@@ -31,7 +31,6 @@
 import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -42,8 +41,11 @@
   private List<ChangeInfo> candidateChanges;
   private final boolean sendEnabled;
 
-  public RebaseDialog(final String project, final String branch,
-      final Change.Id changeId, final boolean sendEnabled) {
+  public RebaseDialog(
+      final String project,
+      final String branch,
+      final Change.Id changeId,
+      final boolean sendEnabled) {
     super(Util.C.rebaseTitle(), null);
     this.sendEnabled = sendEnabled;
     sendButton.setText(Util.C.buttonRebaseChangeSend());
@@ -51,62 +53,65 @@
     // Create the suggestion box to filter over a list of recent changes
     // open on the same branch. The list of candidates is primed by the
     // changeParent CheckBox (below) getting enabled by the user.
-    base = new SuggestBox(new HighlightSuggestOracle() {
-      @Override
-      protected void onRequestSuggestions(Request request, Callback done) {
-        String query = request.getQuery().toLowerCase();
-        List<ChangeSuggestion> suggestions = new ArrayList<>();
-        for (ChangeInfo ci : candidateChanges) {
-          if (changeId.equals(ci.legacyId())) {
-            continue;  // do not suggest current change
-          }
-          String id = String.valueOf(ci.legacyId().get());
-          if (id.contains(query) || ci.subject().toLowerCase().contains(query)) {
-            suggestions.add(new ChangeSuggestion(ci));
-            if (suggestions.size() >= 50) { // limit to 50 suggestions
-              break;
-            }
-          }
-        }
-        done.onSuggestionsReady(request, new Response(suggestions));
-      }
-    });
-    base.getElement().setAttribute("placeholder",
-        Util.C.rebasePlaceholderMessage());
+    base =
+        new SuggestBox(
+            new HighlightSuggestOracle() {
+              @Override
+              protected void onRequestSuggestions(Request request, Callback done) {
+                String query = request.getQuery().toLowerCase();
+                List<ChangeSuggestion> suggestions = new ArrayList<>();
+                for (ChangeInfo ci : candidateChanges) {
+                  if (changeId.equals(ci.legacyId())) {
+                    continue; // do not suggest current change
+                  }
+                  String id = String.valueOf(ci.legacyId().get());
+                  if (id.contains(query) || ci.subject().toLowerCase().contains(query)) {
+                    suggestions.add(new ChangeSuggestion(ci));
+                    if (suggestions.size() >= 50) { // limit to 50 suggestions
+                      break;
+                    }
+                  }
+                }
+                done.onSuggestionsReady(request, new Response(suggestions));
+              }
+            });
+    base.getElement().setAttribute("placeholder", Util.C.rebasePlaceholderMessage());
     base.setStyleName(Gerrit.RESOURCES.css().rebaseSuggestBox());
 
     // The changeParent checkbox must be clicked to load into browser memory
     // a list of open changes from the same project and same branch that this
     // change may rebase onto.
     changeParent = new CheckBox(Util.C.rebaseConfirmMessage());
-    changeParent.addClickHandler(new ClickHandler() {
-      @Override
-      public void onClick(ClickEvent event) {
-        if (changeParent.getValue()) {
-          ChangeList.query(
-              PageLinks.projectQuery(new Project.NameKey(project))
-                  + " " + PageLinks.op("branch", branch)
-                  + " is:open -age:90d",
-              Collections.<ListChangesOption> emptySet(),
-              new GerritCallback<ChangeList>() {
-                @Override
-                public void onSuccess(ChangeList result) {
-                  candidateChanges = Natives.asList(result);
-                  updateControls(true);
-                }
+    changeParent.addClickHandler(
+        new ClickHandler() {
+          @Override
+          public void onClick(ClickEvent event) {
+            if (changeParent.getValue()) {
+              ChangeList.query(
+                  PageLinks.projectQuery(new Project.NameKey(project))
+                      + " "
+                      + PageLinks.op("branch", branch)
+                      + " is:open -age:90d",
+                  Collections.<ListChangesOption>emptySet(),
+                  new GerritCallback<ChangeList>() {
+                    @Override
+                    public void onSuccess(ChangeList result) {
+                      candidateChanges = Natives.asList(result);
+                      updateControls(true);
+                    }
 
-                @Override
-                public void onFailure(Throwable err) {
-                  updateControls(false);
-                  changeParent.setValue(false);
-                  super.onFailure(err);
-                }
-              });
-        } else {
-          updateControls(false);
-        }
-      }
-    });
+                    @Override
+                    public void onFailure(Throwable err) {
+                      updateControls(false);
+                      changeParent.setValue(false);
+                      super.onFailure(err);
+                    }
+                  });
+            } else {
+              updateControls(false);
+            }
+          }
+        });
 
     // add the checkbox and suggestbox widgets to the content panel
     contentPanel.add(changeParent);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
index 62b8f2e..5d741cf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
@@ -13,6 +13,8 @@
 // limitations under the License.
 package com.google.gerrit.client.ui;
 
+import com.google.gwt.event.dom.client.FocusEvent;
+import com.google.gwt.event.dom.client.FocusHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
 import com.google.gwt.event.dom.client.KeyDownEvent;
 import com.google.gwt.event.dom.client.KeyDownHandler;
@@ -32,8 +34,11 @@
 import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
 import com.google.gwt.user.client.ui.TextBoxBase;
 
-public class RemoteSuggestBox extends Composite implements Focusable, HasText,
-    HasSelectionHandlers<String>, HasCloseHandlers<RemoteSuggestBox> {
+public class RemoteSuggestBox extends Composite
+    implements Focusable,
+        HasText,
+        HasSelectionHandlers<String>,
+        HasCloseHandlers<RemoteSuggestBox> {
   private final RemoteSuggestOracle remoteSuggestOracle;
   private final DefaultSuggestionDisplay display;
   private final HintTextBox textBox;
@@ -42,40 +47,43 @@
 
   public RemoteSuggestBox(SuggestOracle oracle) {
     remoteSuggestOracle = new RemoteSuggestOracle(oracle);
+    remoteSuggestOracle.setServeSuggestions(true);
     display = new DefaultSuggestionDisplay();
 
     textBox = new HintTextBox();
-    textBox.addKeyDownHandler(new KeyDownHandler() {
-      @Override
-      public void onKeyDown(KeyDownEvent e) {
-        submitOnSelection = false;
-
-        if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
-          CloseEvent.fire(RemoteSuggestBox.this, RemoteSuggestBox.this);
-        } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
-          if (display.isSuggestionListShowing()) {
-            if (textBox.getValue().equals(remoteSuggestOracle.getLast())) {
-              submitOnSelection = true;
-            } else {
-              display.hideSuggestions();
+    textBox.addKeyDownHandler(
+        new KeyDownHandler() {
+          @Override
+          public void onKeyDown(KeyDownEvent e) {
+            submitOnSelection = false;
+            if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
+              CloseEvent.fire(RemoteSuggestBox.this, RemoteSuggestBox.this);
+            } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
+              if (display.isSuggestionListShowing()) {
+                if (textBox.getValue().equals(remoteSuggestOracle.getLast())) {
+                  submitOnSelection = true;
+                } else {
+                  display.hideSuggestions();
+                }
+              } else {
+                SelectionEvent.fire(RemoteSuggestBox.this, getText());
+              }
             }
-          } else {
-            SelectionEvent.fire(RemoteSuggestBox.this, getText());
           }
-        }
-      }
-    });
+        });
 
     suggestBox = new SuggestBox(remoteSuggestOracle, textBox, display);
-    suggestBox.addSelectionHandler(new SelectionHandler<Suggestion>() {
-      @Override
-      public void onSelection(SelectionEvent<Suggestion> event) {
-        textBox.setFocus(true);
-        if (submitOnSelection) {
-          SelectionEvent.fire(RemoteSuggestBox.this, getText());
-        }
-      }
-    });
+    suggestBox.addSelectionHandler(
+        new SelectionHandler<Suggestion>() {
+          @Override
+          public void onSelection(SelectionEvent<Suggestion> event) {
+            if (submitOnSelection) {
+              SelectionEvent.fire(RemoteSuggestBox.this, getText());
+            }
+            remoteSuggestOracle.cancelOutstandingRequest();
+            display.hideSuggestions();
+          }
+        });
     initWidget(suggestBox);
   }
 
@@ -134,4 +142,24 @@
   public HandlerRegistration addCloseHandler(CloseHandler<RemoteSuggestBox> h) {
     return addHandler(h, CloseEvent.getType());
   }
+
+  public void selectAll() {
+    suggestBox.getValueBox().selectAll();
+  }
+
+  public void enableDefaultSuggestions() {
+    textBox.addFocusHandler(
+        new FocusHandler() {
+          @Override
+          public void onFocus(FocusEvent focusEvent) {
+            if (textBox.getText().equals("")) {
+              suggestBox.showSuggestionList();
+            }
+          }
+        });
+  }
+
+  public void setServeSuggestionsOnOracle(boolean serveSuggestions) {
+    remoteSuggestOracle.setServeSuggestions(serveSuggestions);
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
index 1068b3e..b0ee915 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java
@@ -24,15 +24,13 @@
 import com.google.gwtexpui.user.client.View;
 
 /**
-  *  A Screen layout with a header and a body.
-  *
-  * The header is mainly a text title, but it can be decorated
-  * in the West, the East, and the FarEast by any Widget.  The
-  * West and East decorations will surround the text on the
-  * left and right respectively, and the FarEast will be right
-  * justified to the right edge of the screen.  The East
-  * decoration will expand to take up any extra space.
-  */
+ * A Screen layout with a header and a body.
+ *
+ * <p>The header is mainly a text title, but it can be decorated in the West, the East, and the
+ * FarEast by any Widget. The West and East decorations will surround the text on the left and right
+ * respectively, and the FarEast will be right justified to the right edge of the screen. The East
+ * decoration will expand to take up any extra space.
+ */
 public abstract class Screen extends View {
   private Grid header;
   private InlineLabel headerText;
@@ -67,11 +65,13 @@
     }
   }
 
-  public void registerKeys() {
-  }
+  public void registerKeys() {}
 
   private enum Cols {
-    West, Title, East, FarEast
+    West,
+    Title,
+    East,
+    FarEast
   }
 
   protected void onInitUI() {
@@ -89,8 +89,9 @@
     header.setWidget(0, Cols.Title.ordinal(), title);
 
     header.setStyleName(Gerrit.RESOURCES.css().screenHeader());
-    header.getCellFormatter().setHorizontalAlignment(0, Cols.FarEast.ordinal(),
-      HasHorizontalAlignment.ALIGN_RIGHT);
+    header
+        .getCellFormatter()
+        .setHorizontalAlignment(0, Cols.FarEast.ordinal(), HasHorizontalAlignment.ALIGN_RIGHT);
     // force FarEast all the way to the right
     header.getCellFormatter().setWidth(0, Cols.FarEast.ordinal(), "100%");
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestAfterTypingNCharsOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestAfterTypingNCharsOracle.java
index 2d7736b..e24f347 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestAfterTypingNCharsOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SuggestAfterTypingNCharsOracle.java
@@ -16,20 +16,19 @@
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
-
 import java.util.Collections;
 import java.util.List;
 
 /**
- * Suggest oracle that only provides suggestions if the user has typed at least
- * as many characters as configured by 'suggest.from'. If 'suggest.from' is set
- * to 0, suggestions will always be provided.
+ * Suggest oracle that only provides suggestions if the user has typed at least as many characters
+ * as configured by 'suggest.from'. If 'suggest.from' is set to 0, suggestions will always be
+ * provided.
  */
 public abstract class SuggestAfterTypingNCharsOracle extends HighlightSuggestOracle {
 
   @Override
   protected void onRequestSuggestions(Request req, Callback cb) {
-    if (req.getQuery().length() >= Gerrit.info().suggest().from()) {
+    if (req.getQuery() != null && req.getQuery().length() >= Gerrit.info().suggest().from()) {
       _onRequestSuggestions(req, cb);
     } else {
       List<Suggestion> none = Collections.emptyList();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.java
index a220ea0..32bb796 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIConstants.java
@@ -18,16 +18,24 @@
 
 public interface UIConstants extends Constants {
   String commentedActionButtonSend();
+
   String commentedActionButtonCancel();
 
   String projectName();
+
   String projectDescription();
+
   String projectItemHelp();
+
   String projectStateAbbrev();
+
   String projectStateHelp();
 
   String dialogCreateChangeTitle();
+
   String dialogCreateChangeHeading();
+
   String newChangeBranchSuggestion();
+
   String newChangeTopicSuggestion();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.java
index fcb846f4..af17390 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UIMessages.java
@@ -18,6 +18,8 @@
 
 public interface UIMessages extends Messages {
   String helpListOpen(String item);
+
   String helpListPrev(String item);
+
   String helpListNext(String item);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UserActivityMonitor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UserActivityMonitor.java
index 6e754a2..2b76b9b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UserActivityMonitor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/UserActivityMonitor.java
@@ -36,15 +36,14 @@
   private static final MonitorImpl impl;
 
   /**
-   * @return true if there has been keyboard and/or mouse activity in recent
-   *         enough history to believe a user is still controlling this session.
+   * @return true if there has been keyboard and/or mouse activity in recent enough history to
+   *     believe a user is still controlling this session.
    */
   public static boolean isActive() {
     return impl.active || impl.recent;
   }
 
-  public static HandlerRegistration addValueChangeHandler(
-      ValueChangeHandler<Boolean> handler) {
+  public static HandlerRegistration addValueChangeHandler(ValueChangeHandler<Boolean> handler) {
     return impl.addValueChangeHandler(handler);
   }
 
@@ -56,12 +55,14 @@
     Scheduler.get().scheduleFixedDelay(impl, 60 * 1000);
   }
 
-  private UserActivityMonitor() {
-  }
+  private UserActivityMonitor() {}
 
-  private static class MonitorImpl implements RepeatingCommand,
-      KeyPressHandler, MouseMoveHandler, ValueChangeHandler<String>,
-      HasValueChangeHandlers<Boolean> {
+  private static class MonitorImpl
+      implements RepeatingCommand,
+          KeyPressHandler,
+          MouseMoveHandler,
+          ValueChangeHandler<String>,
+          HasValueChangeHandlers<Boolean> {
     private final EventBus bus = new SimpleEventBus();
     private boolean recent = true;
     private boolean active = true;
@@ -100,8 +101,7 @@
     }
 
     @Override
-    public HandlerRegistration addValueChangeHandler(
-        ValueChangeHandler<Boolean> handler) {
+    public HandlerRegistration addValueChangeHandler(ValueChangeHandler<Boolean> handler) {
       return bus.addHandler(ValueChangeEvent.getType(), handler);
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java
index 804eee1..26026e1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java
@@ -30,8 +30,7 @@
 
     int pos = 0;
     int endPos = 0;
-    while ((pos = text.toLowerCase().indexOf(
-        toHighlight.toLowerCase(), pos)) > -1) {
+    while ((pos = text.toLowerCase().indexOf(toHighlight.toLowerCase(), pos)) > -1) {
       if (pos > endPos) {
         b.append(text.substring(endPos, pos));
       }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java b/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
index efed451..ce91a46 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
@@ -16,21 +16,19 @@
 
 import com.google.gwt.safehtml.shared.SafeUri;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
-import net.codemirror.lib.Loader;
-
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
+import net.codemirror.lib.Loader;
 
 public class AddonInjector {
   private static final Map<String, SafeUri> addonUris = new HashMap<>();
+
   static {
-    addonUris.put(Addons.I.merge_bundled().getName(),
-        Addons.I.merge_bundled().getSafeUri());
+    addonUris.put(Addons.I.merge_bundled().getName(), Addons.I.merge_bundled().getSafeUri());
   }
 
   public static SafeUri getAddonScriptUri(String addon) {
@@ -51,9 +49,8 @@
     }
 
     if (!canLoad(name)) {
-      Logger.getLogger("net.codemirror").log(
-        Level.WARNING,
-        "CodeMirror addon " + name + " not configured.");
+      Logger.getLogger("net.codemirror")
+          .log(Level.WARNING, "CodeMirror addon " + name + " not configured.");
       return this;
     }
 
@@ -74,22 +71,22 @@
   private void beginLoading(final String addon) {
     pending++;
     Loader.injectScript(
-      getAddonScriptUri(addon),
-      new AsyncCallback<Void>() {
-        @Override
-        public void onSuccess(Void result) {
-          pending--;
-          if (pending == 0) {
-            appCallback.onSuccess(null);
+        getAddonScriptUri(addon),
+        new AsyncCallback<Void>() {
+          @Override
+          public void onSuccess(Void result) {
+            pending--;
+            if (pending == 0) {
+              appCallback.onSuccess(null);
+            }
           }
-        }
 
-        @Override
-        public void onFailure(Throwable caught) {
-          if (--pending == 0) {
-            appCallback.onFailure(caught);
+          @Override
+          public void onFailure(Throwable caught) {
+            if (--pending == 0) {
+              appCallback.onFailure(caught);
+            }
           }
-        }
-      });
+        });
   }
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java b/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java
index 7c8b362..19a681c 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/addon/Addons.java
@@ -22,5 +22,7 @@
 public interface Addons extends ClientBundle {
   Addons I = GWT.create(Addons.class);
 
-  @Source("merge_bundled.js") @DoNotEmbed DataResource merge_bundled();
+  @Source("merge_bundled.js")
+  @DoNotEmbed
+  DataResource merge_bundled();
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java
index 7418795..1e81f83a 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/BlameConfig.java
@@ -18,6 +18,6 @@
 
 public interface BlameConfig extends Messages {
   String shortBlameMsg(String commitId, String date, String author);
-  String detailedBlameMsg(String commitId, String author, String time,
-      String msg);
+
+  String detailedBlameMsg(String commitId, String author, String time, String msg);
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
index be914f3..a84c464 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirror.java
@@ -23,7 +23,6 @@
 import com.google.gwt.resources.client.CssResource;
 import com.google.gwt.user.client.Window;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import net.codemirror.lib.TextMarker.FromTo;
 
 /**
@@ -33,7 +32,7 @@
  */
 public class CodeMirror extends JavaScriptObject {
   public static void preload() {
-    initLibrary(CallbackGroup.<Void> emptyCallback());
+    initLibrary(CallbackGroup.<Void>emptyCallback());
   }
 
   public static void initLibrary(AsyncCallback<Void> cb) {
@@ -42,7 +41,9 @@
 
   interface Style extends CssResource {
     String activeLine();
+
     String showTabs();
+
     String margin();
   }
 
@@ -79,14 +80,16 @@
   public final native String getStringOption(String o) /*-{ return this.getOption(o) }-*/;
 
   public final native String getValue() /*-{ return this.getValue() }-*/;
+
   public final native void setValue(String v) /*-{ this.setValue(v) }-*/;
 
   public final native int changeGeneration(boolean closeEvent)
-  /*-{ return this.changeGeneration(closeEvent) }-*/;
-  public final native boolean isClean(int generation)
-  /*-{ return this.isClean(generation) }-*/;
+      /*-{ return this.changeGeneration(closeEvent) }-*/ ;
+
+  public final native boolean isClean(int generation) /*-{ return this.isClean(generation) }-*/;
 
   public final native void setWidth(double w) /*-{ this.setSize(w, null) }-*/;
+
   public final native void setHeight(double h) /*-{ this.setSize(null, h) }-*/;
 
   public final int getHeight() {
@@ -94,20 +97,21 @@
   }
 
   public final void adjustHeight(int localHeader) {
-    int rest = Gerrit.getHeaderFooterHeight()
-        + localHeader
-        + 5; // Estimate
+    int rest = Gerrit.getHeaderFooterHeight() + localHeader + 5; // Estimate
     setHeight(Window.getClientHeight() - rest);
   }
 
   public final native String getLine(int n) /*-{ return this.getLine(n) }-*/;
+
   public final native double barHeight() /*-{ return this.display.barHeight }-*/;
+
   public final native double barWidth() /*-{ return this.display.barWidth }-*/;
+
   public final native int lastLine() /*-{ return this.lastLine() }-*/;
+
   public final native void refresh() /*-{ this.refresh() }-*/;
 
-  public final native TextMarker markText(Pos from, Pos to,
-      Configuration options) /*-{
+  public final native TextMarker markText(Pos from, Pos to, Configuration options) /*-{
     return this.markText(from, to, options)
   }-*/;
 
@@ -130,46 +134,39 @@
         return "wrap";
       }
     };
+
     abstract String value();
   }
 
-  public final void addLineClass(int line, LineClassWhere where,
-      String className) {
+  public final void addLineClass(int line, LineClassWhere where, String className) {
     addLineClassNative(line, where.value(), className);
   }
 
-  private native void addLineClassNative(int line, String where,
-      String lineClass) /*-{
+  private native void addLineClassNative(int line, String where, String lineClass) /*-{
     this.addLineClass(line, where, lineClass)
   }-*/;
 
-  public final void addLineClass(LineHandle line, LineClassWhere where,
-      String className) {
+  public final void addLineClass(LineHandle line, LineClassWhere where, String className) {
     addLineClassNative(line, where.value(), className);
   }
 
-  private native void addLineClassNative(LineHandle line, String where,
-      String lineClass) /*-{
+  private native void addLineClassNative(LineHandle line, String where, String lineClass) /*-{
     this.addLineClass(line, where, lineClass)
   }-*/;
 
-  public final void removeLineClass(int line, LineClassWhere where,
-      String className) {
+  public final void removeLineClass(int line, LineClassWhere where, String className) {
     removeLineClassNative(line, where.value(), className);
   }
 
-  private native void removeLineClassNative(int line, String where,
-      String lineClass) /*-{
+  private native void removeLineClassNative(int line, String where, String lineClass) /*-{
     this.removeLineClass(line, where, lineClass)
   }-*/;
 
-  public final void removeLineClass(LineHandle line, LineClassWhere where,
-      String className) {
+  public final void removeLineClass(LineHandle line, LineClassWhere where, String className) {
     removeLineClassNative(line, where.value(), className);
   }
 
-  private native void removeLineClassNative(LineHandle line, String where,
-      String lineClass) /*-{
+  private native void removeLineClassNative(LineHandle line, String where, String lineClass) /*-{
     this.removeLineClass(line, where, lineClass)
   }-*/;
 
@@ -177,8 +174,7 @@
     this.addWidget(pos, node, false)
   }-*/;
 
-  public final native LineWidget addLineWidget(int line, Element node,
-      Configuration options) /*-{
+  public final native LineWidget addLineWidget(int line, Element node, Configuration options) /*-{
     return this.addLineWidget(line, node, options)
   }-*/;
 
@@ -291,7 +287,9 @@
   }-*/;
 
   public final native void setCursor(Pos p) /*-{ this.setCursor(p) }-*/;
+
   public final native Pos getCursor() /*-{ return this.getCursor() }-*/;
+
   public final native Pos getCursor(String start) /*-{
     return this.getCursor(start)
   }-*/;
@@ -301,6 +299,7 @@
   }
 
   public final native void setSelection(Pos p) /*-{ this.setSelection(p) }-*/;
+
   public final native void setSelection(Pos anchor, Pos head) /*-{
     this.setSelection(anchor, head)
   }-*/;
@@ -310,6 +309,7 @@
   }-*/;
 
   public final native void addKeyMap(KeyMap map) /*-{ this.addKeyMap(map) }-*/;
+
   public final native void removeKeyMap(KeyMap map) /*-{ this.removeKeyMap(map) }-*/;
 
   public final native LineHandle getLineHandle(int line) /*-{
@@ -391,7 +391,8 @@
     return this.setGutterMarker(line, gutterId, value);
   }-*/;
 
-  public final native LineHandle setGutterMarker(LineHandle line, String gutterId, Element value) /*-{
+  public final native LineHandle setGutterMarker(
+      LineHandle line, String gutterId, Element value) /*-{
     return this.setGutterMarker(line, gutterId, value);
   }-*/;
 
@@ -399,28 +400,26 @@
     return this.state.search && !!this.state.search.query;
   }-*/;
 
-  protected CodeMirror() {
-  }
+  protected CodeMirror() {}
 
   public static class Viewport extends JavaScriptObject {
     public final native int from() /*-{ return this.from }-*/;
+
     public final native int to() /*-{ return this.to }-*/;
+
     public final boolean contains(int line) {
       return from() <= line && line < to();
     }
 
-    protected Viewport() {
-    }
+    protected Viewport() {}
   }
 
   public static class LineHandle extends JavaScriptObject {
-    protected LineHandle(){
-    }
+    protected LineHandle() {}
   }
 
   public static class RegisteredHandler extends JavaScriptObject {
-    protected RegisteredHandler() {
-    }
+    protected RegisteredHandler() {}
   }
 
   public interface EventHandler {
@@ -432,8 +431,7 @@
   }
 
   public interface GutterClickHandler {
-    void handle(CodeMirror instance, int line, String gutter,
-        NativeEvent clickEvent);
+    void handle(CodeMirror instance, int line, String gutter, NativeEvent clickEvent);
   }
 
   public interface BeforeSelectionChangeHandler {
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirrorDoc.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirrorDoc.java
index d04cc24..be1af05 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirrorDoc.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/CodeMirrorDoc.java
@@ -19,8 +19,7 @@
 /** The Doc object representing the content in a CodeMirror */
 public class CodeMirrorDoc extends JavaScriptObject {
 
-  public final native void replaceRange(String replacement,
-      Pos from, Pos to) /*-{
+  public final native void replaceRange(String replacement, Pos from, Pos to) /*-{
     this.replaceRange(replacement, from, to);
   }-*/;
 
@@ -28,6 +27,5 @@
     this.replaceRange(insertion, at);
   }-*/;
 
-  protected CodeMirrorDoc() {
-  }
+  protected CodeMirrorDoc() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Configuration.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Configuration.java
index 7a0bbea..d37b70b 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Configuration.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Configuration.java
@@ -28,20 +28,19 @@
   }
 
   public final native Configuration set(String name, String val)
-  /*-{ this[name] = val; return this; }-*/;
+      /*-{ this[name] = val; return this; }-*/ ;
 
   public final native Configuration set(String name, int val)
-  /*-{ this[name] = val; return this; }-*/;
+      /*-{ this[name] = val; return this; }-*/ ;
 
   public final native Configuration set(String name, double val)
-  /*-{ this[name] = val; return this; }-*/;
+      /*-{ this[name] = val; return this; }-*/ ;
 
   public final native Configuration set(String name, boolean val)
-  /*-{ this[name] = val; return this; }-*/;
+      /*-{ this[name] = val; return this; }-*/ ;
 
   public final native Configuration set(String name, JavaScriptObject val)
-  /*-{ this[name] = val; return this; }-*/;
+      /*-{ this[name] = val; return this; }-*/ ;
 
-  protected Configuration() {
-  }
+  protected Configuration() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java
index d727e24..a5af703 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Extras.java
@@ -16,8 +16,8 @@
 
 import static com.google.gwt.dom.client.Style.Display.INLINE_BLOCK;
 import static com.google.gwt.dom.client.Style.Unit.PX;
-import static net.codemirror.lib.CodeMirror.style;
 import static net.codemirror.lib.CodeMirror.LineClassWhere.WRAP;
+import static net.codemirror.lib.CodeMirror.style;
 
 import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.RangeInfo;
@@ -31,11 +31,9 @@
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.i18n.client.DateTimeFormat;
 import com.google.gwt.user.client.DOM;
-
-import net.codemirror.lib.CodeMirror.LineHandle;
-
 import java.util.Date;
 import java.util.Objects;
+import net.codemirror.lib.CodeMirror.LineHandle;
 
 /** Additional features added to CodeMirror by Gerrit Code Review. */
 public class Extras {
@@ -43,8 +41,8 @@
   private static final BlameConfig C = GWT.create(BlameConfig.class);
 
   static final native Extras get(CodeMirror c) /*-{ return c.gerritExtras }-*/;
-  private static native void set(CodeMirror c, Extras e)
-  /*-{ c.gerritExtras = e }-*/;
+
+  private static native void set(CodeMirror c, Extras e) /*-{ c.gerritExtras = e }-*/;
 
   static void attach(CodeMirror c) {
     set(c, new Extras(c));
@@ -172,21 +170,23 @@
       gutters.push(ANNOTATION_GUTTER_ID);
       cm.setOption("gutters", gutters);
       annotated = true;
-      DateTimeFormat format = DateTimeFormat.getFormat(
-          DateTimeFormat.PredefinedFormat.DATE_SHORT);
+      DateTimeFormat format = DateTimeFormat.getFormat(DateTimeFormat.PredefinedFormat.DATE_SHORT);
       JsArray<LintLine> annotations = JsArray.createArray().cast();
       for (BlameInfo blameInfo : Natives.asList(blameInfos)) {
         for (RangeInfo range : Natives.asList(blameInfo.ranges())) {
           Date commitTime = new Date(blameInfo.time() * 1000L);
           String shortId = blameInfo.id().substring(0, 8);
-          String shortBlame = C.shortBlameMsg(
-              shortId, format.format(commitTime), blameInfo.author());
-          String detailedBlame = C.detailedBlameMsg(blameInfo.id(),
-              blameInfo.author(), FormatUtil.mediumFormat(commitTime),
-              blameInfo.commitMsg());
+          String shortBlame =
+              C.shortBlameMsg(shortId, format.format(commitTime), blameInfo.author());
+          String detailedBlame =
+              C.detailedBlameMsg(
+                  blameInfo.id(),
+                  blameInfo.author(),
+                  FormatUtil.mediumFormat(commitTime),
+                  blameInfo.commitMsg());
 
-          annotations.push(LintLine.create(shortBlame, detailedBlame, shortId,
-              Pos.create(range.start() - 1)));
+          annotations.push(
+              LintLine.create(shortBlame, detailedBlame, shortId, Pos.create(range.start() - 1)));
         }
       }
       cm.setOption("lint", getAnnotation(annotations));
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java
index 946b44f..be1852f 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/KeyMap.java
@@ -39,6 +39,5 @@
     return this;
   }-*/;
 
-  protected KeyMap() {
-  }
+  protected KeyMap() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/LineWidget.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/LineWidget.java
index 62c219b..4a15fd3 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/LineWidget.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/LineWidget.java
@@ -19,6 +19,7 @@
 /** LineWidget objects used within CodeMirror. */
 public class LineWidget extends JavaScriptObject {
   public final native void clear() /*-{ this.clear() }-*/;
+
   public final native void changed() /*-{ this.changed() }-*/;
 
   public final native void onRedraw(Runnable thunk) /*-{
@@ -36,6 +37,5 @@
     w.on("redraw", h);
   }-*/;
 
-  protected LineWidget() {
-  }
+  protected LineWidget() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java
index b1e20c1..b0b2ae0 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/LintLine.java
@@ -18,19 +18,25 @@
 import com.google.gwt.dom.client.StyleInjector;
 
 public class LintLine extends JavaScriptObject {
-  public static LintLine create(String shortMsg, String msg, String sev,
-      Pos line) {
-    StyleInjector.inject(".CodeMirror-lint-marker-" + sev + " {\n"
-        + "  visibility: hidden;\n"
-        + "  text-overflow: ellipsis;\n"
-        + "  white-space: nowrap;\n"
-        + "  overflow: hidden;\n"
-        + "  position: relative;\n"
-        + "}\n"
-        + ".CodeMirror-lint-marker-" + sev + ":after {\n"
-        + "  content:'" + shortMsg + "';\n"
-        + "  visibility: visible;\n"
-        + "}");
+  public static LintLine create(String shortMsg, String msg, String sev, Pos line) {
+    StyleInjector.inject(
+        ".CodeMirror-lint-marker-"
+            + sev
+            + " {\n"
+            + "  visibility: hidden;\n"
+            + "  text-overflow: ellipsis;\n"
+            + "  white-space: nowrap;\n"
+            + "  overflow: hidden;\n"
+            + "  position: relative;\n"
+            + "}\n"
+            + ".CodeMirror-lint-marker-"
+            + sev
+            + ":after {\n"
+            + "  content:'"
+            + shortMsg
+            + "';\n"
+            + "  visibility: visible;\n"
+            + "}");
     return create(msg, sev, line, null);
   }
 
@@ -44,11 +50,14 @@
   }-*/;
 
   public final native String message() /*-{ return this.message; }-*/;
+
   public final native String detailedMessage() /*-{ return this.message; }-*/;
+
   public final native String severity() /*-{ return this.severity; }-*/;
+
   public final native Pos from() /*-{ return this.from; }-*/;
+
   public final native Pos to() /*-{ return this.to; }-*/;
 
-  protected LintLine() {
-  }
+  protected LintLine() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java
index 379cb3c..582a3109 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java
@@ -27,8 +27,7 @@
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
 public class Loader {
-  private static native boolean isLibLoaded()
-  /*-{ return $wnd.hasOwnProperty('CodeMirror'); }-*/;
+  private static native boolean isLibLoaded() /*-{ return $wnd.hasOwnProperty('CodeMirror'); }-*/;
 
   static void initLibrary(final AsyncCallback<Void> cb) {
     if (isLibLoaded()) {
@@ -37,37 +36,39 @@
     }
 
     CallbackGroup group = new CallbackGroup();
-    injectCss(Lib.I.css(), group.<Void> addEmpty());
-    injectScript(Lib.I.js().getSafeUri(), group.add(new AsyncCallback<Void>() {
-      @Override
-      public void onSuccess(Void result) {
-        Vim.initKeyMap();
-      }
+    injectCss(Lib.I.css(), group.<Void>addEmpty());
+    injectScript(
+        Lib.I.js().getSafeUri(),
+        group.add(
+            new AsyncCallback<Void>() {
+              @Override
+              public void onSuccess(Void result) {
+                Vim.initKeyMap();
+              }
 
-      @Override
-      public void onFailure(Throwable caught) {
-      }
-    }));
+              @Override
+              public void onFailure(Throwable caught) {}
+            }));
     group.addListener(cb);
     group.done();
   }
 
-  private static void injectCss(ExternalTextResource css,
-      final AsyncCallback<Void> cb) {
+  private static void injectCss(ExternalTextResource css, final AsyncCallback<Void> cb) {
     try {
-      css.getText(new ResourceCallback<TextResource>() {
-        @Override
-        public void onSuccess(TextResource resource) {
-          StyleInjector.inject(resource.getText());
-          Lib.I.style().ensureInjected();
-          cb.onSuccess(null);
-        }
+      css.getText(
+          new ResourceCallback<TextResource>() {
+            @Override
+            public void onSuccess(TextResource resource) {
+              StyleInjector.inject(resource.getText());
+              Lib.I.style().ensureInjected();
+              cb.onSuccess(null);
+            }
 
-        @Override
-        public void onError(ResourceException e) {
-          cb.onFailure(e);
-        }
-      });
+            @Override
+            public void onError(ResourceException e) {
+              cb.onFailure(e);
+            }
+          });
     } catch (ResourceException e) {
       cb.onFailure(e);
     }
@@ -75,24 +76,25 @@
 
   public static void injectScript(SafeUri js, final AsyncCallback<Void> callback) {
     final ScriptElement[] script = new ScriptElement[1];
-    script[0] = ScriptInjector.fromUrl(js.asString())
-      .setWindow(ScriptInjector.TOP_WINDOW)
-      .setCallback(new Callback<Void, Exception>() {
-        @Override
-        public void onSuccess(Void result) {
-          script[0].removeFromParent();
-          callback.onSuccess(result);
-        }
+    script[0] =
+        ScriptInjector.fromUrl(js.asString())
+            .setWindow(ScriptInjector.TOP_WINDOW)
+            .setCallback(
+                new Callback<Void, Exception>() {
+                  @Override
+                  public void onSuccess(Void result) {
+                    script[0].removeFromParent();
+                    callback.onSuccess(result);
+                  }
 
-        @Override
-        public void onFailure(Exception reason) {
-          callback.onFailure(reason);
-        }
-       })
-      .inject()
-      .cast();
+                  @Override
+                  public void onFailure(Exception reason) {
+                    callback.onFailure(reason);
+                  }
+                })
+            .inject()
+            .cast();
   }
 
-  private Loader() {
-  }
+  private Loader() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java
index d7e1430..38c3906 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/MergeView.java
@@ -46,6 +46,5 @@
     return $doc.getElementsByClassName("CodeMirror-merge-gap")[0];
   }-*/;
 
-  protected MergeView() {
-  }
+  protected MergeView() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Pos.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Pos.java
index 07ead43..7f83b75 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Pos.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Pos.java
@@ -27,15 +27,16 @@
   }-*/;
 
   public final native void line(int l) /*-{ this.line = l }-*/;
+
   public final native void ch(int c) /*-{ this.ch = c }-*/;
 
   public final native int line() /*-{ return this.line }-*/;
+
   public final native int ch() /*-{ return this.ch || 0 }-*/;
 
   public final boolean equals(Pos o) {
     return this == o || (line() == o.line() && ch() == o.ch());
   }
 
-  protected Pos() {
-  }
+  protected Pos() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Rect.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Rect.java
index 5691d9f..1114403 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Rect.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Rect.java
@@ -19,10 +19,12 @@
 /** {left, right, top, bottom} objects used within CodeMirror. */
 public class Rect extends JavaScriptObject {
   public final native double left() /*-{ return this.left }-*/;
+
   public final native double right() /*-{ return this.right }-*/;
+
   public final native double top() /*-{ return this.top }-*/;
+
   public final native double bottom() /*-{ return this.bottom }-*/;
 
-  protected Rect() {
-  }
+  protected Rect() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/ScrollInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/ScrollInfo.java
index 2623530..432a60f 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/ScrollInfo.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/ScrollInfo.java
@@ -19,20 +19,22 @@
 /** Returned by {@link CodeMirror#getScrollInfo()}. */
 public class ScrollInfo extends JavaScriptObject {
   public final native double left() /*-{ return this.left }-*/;
+
   public final native double top() /*-{ return this.top }-*/;
 
   /**
-   * Pixel height of the full content being scrolled. This may only be an
-   * estimate given by CodeMirror. Line widgets further down in the document may
-   * not be measured, so line heights can be incorrect until drawn.
+   * Pixel height of the full content being scrolled. This may only be an estimate given by
+   * CodeMirror. Line widgets further down in the document may not be measured, so line heights can
+   * be incorrect until drawn.
    */
   public final native double height() /*-{ return this.height }-*/;
+
   public final native double width() /*-{ return this.width }-*/;
 
   /** Visible height of the viewport, excluding scrollbars. */
   public final native double clientHeight() /*-{ return this.clientHeight }-*/;
+
   public final native double clientWidth() /*-{ return this.clientWidth }-*/;
 
-  protected ScrollInfo() {
-  }
+  protected ScrollInfo() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/TextMarker.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/TextMarker.java
index eac8510..c3d248a 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/TextMarker.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/TextMarker.java
@@ -20,13 +20,15 @@
 /** Object that represents a text marker within CodeMirror */
 public class TextMarker extends JavaScriptObject {
   public final native void clear() /*-{ this.clear(); }-*/;
-  public final native void changed() /*-{ this.changed(); }-*/;
-  public final native FromTo find() /*-{ return this.find(); }-*/;
-  public final native void on(String event, Runnable thunk)
-  /*-{ this.on(event, function(){$entry(thunk.@java.lang.Runnable::run()())}) }-*/;
 
-  protected TextMarker() {
-  }
+  public final native void changed() /*-{ this.changed(); }-*/;
+
+  public final native FromTo find() /*-{ return this.find(); }-*/;
+
+  public final native void on(String event, Runnable thunk)
+      /*-{ this.on(event, function(){$entry(thunk.@java.lang.Runnable::run()())}) }-*/ ;
+
+  protected TextMarker() {}
 
   public static class FromTo extends JavaScriptObject {
     public static final native FromTo create(Pos f, Pos t) /*-{
@@ -40,12 +42,13 @@
     }
 
     public final native Pos from() /*-{ return this.from }-*/;
+
     public final native Pos to() /*-{ return this.to }-*/;
 
     public final native void from(Pos f) /*-{ this.from = f }-*/;
+
     public final native void to(Pos t) /*-{ this.to = t }-*/;
 
-    protected FromTo() {
-    }
+    protected FromTo() {}
   }
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java
index 84b4a6a..06016ef 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Vim.java
@@ -19,9 +19,8 @@
 /**
  * Glue around the Vim emulation for {@link CodeMirror}.
  *
- * As an instance {@code this} is actually the {@link CodeMirror} object. Class
- * Vim is providing a new namespace for Vim related methods that are associated
- * with an editor.
+ * <p>As an instance {@code this} is actually the {@link CodeMirror} object. Class Vim is providing
+ * a new namespace for Vim related methods that are associated with an editor.
  */
 public class Vim extends JavaScriptObject {
   static void initKeyMap() {
@@ -30,9 +29,10 @@
       km.propagate(key);
       km.propagate("'" + key.toLowerCase() + "'");
     }
-    for (String key : new String[] {
-      "Ctrl-C", "Ctrl-O", "Ctrl-P", "Ctrl-S",
-      "Ctrl-F", "Ctrl-B", "Ctrl-R",}) {
+    for (String key :
+        new String[] {
+          "Ctrl-C", "Ctrl-O", "Ctrl-P", "Ctrl-S", "Ctrl-F", "Ctrl-B", "Ctrl-R",
+        }) {
       km.propagate(key);
     }
     for (int i = 0; i <= 9; i++) {
@@ -66,6 +66,5 @@
     return v && v.searchState_ && !!v.searchState_.getOverlay();
   }-*/;
 
-  protected Vim() {
-  }
+  protected Vim() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
index 943be7e..c6f113e 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
@@ -21,7 +21,6 @@
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.resources.client.DataResource;
 import com.google.gwt.safehtml.shared.SafeUri;
-
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
@@ -36,127 +35,128 @@
   private static final Map<String, SafeUri> modeUris = new HashMap<>();
 
   static {
-    indexModes(new DataResource[] {
-      Modes.I.apl(),
-      Modes.I.asciiarmor(),
-      Modes.I.asn_1(),
-      Modes.I.asterisk(),
-      Modes.I.brainfuck(),
-      Modes.I.clike(),
-      Modes.I.clojure(),
-      Modes.I.cmake(),
-      Modes.I.cobol(),
-      Modes.I.coffeescript(),
-      Modes.I.commonlisp(),
-      Modes.I.crystal(),
-      Modes.I.css(),
-      Modes.I.cypher(),
-      Modes.I.d(),
-      Modes.I.dart(),
-      Modes.I.diff(),
-      Modes.I.django(),
-      Modes.I.dockerfile(),
-      Modes.I.dtd(),
-      Modes.I.dylan(),
-      Modes.I.ebnf(),
-      Modes.I.ecl(),
-      Modes.I.eiffel(),
-      Modes.I.elm(),
-      Modes.I.erlang(),
-      Modes.I.factor(),
-      Modes.I.fcl(),
-      Modes.I.forth(),
-      Modes.I.fortran(),
-      Modes.I.gas(),
-      Modes.I.gerrit_commit(),
-      Modes.I.gfm(),
-      Modes.I.gherkin(),
-      Modes.I.go(),
-      Modes.I.groovy(),
-      Modes.I.haml(),
-      Modes.I.handlebars(),
-      Modes.I.haskell_literate(),
-      Modes.I.haskell(),
-      Modes.I.haxe(),
-      Modes.I.htmlembedded(),
-      Modes.I.htmlmixed(),
-      Modes.I.http(),
-      Modes.I.idl(),
-      Modes.I.jade(),
-      Modes.I.javascript(),
-      Modes.I.jinja2(),
-      Modes.I.jsx(),
-      Modes.I.julia(),
-      Modes.I.livescript(),
-      Modes.I.lua(),
-      Modes.I.markdown(),
-      Modes.I.mathematica(),
-      Modes.I.mbox(),
-      Modes.I.mirc(),
-      Modes.I.mllike(),
-      Modes.I.modelica(),
-      Modes.I.mscgen(),
-      Modes.I.mumps(),
-      Modes.I.nginx(),
-      Modes.I.nsis(),
-      Modes.I.ntriples(),
-      Modes.I.octave(),
-      Modes.I.oz(),
-      Modes.I.pascal(),
-      Modes.I.pegjs(),
-      Modes.I.perl(),
-      Modes.I.php(),
-      Modes.I.pig(),
-      Modes.I.powershell(),
-      Modes.I.properties(),
-      Modes.I.protobuf(),
-      Modes.I.puppet(),
-      Modes.I.python(),
-      Modes.I.q(),
-      Modes.I.r(),
-      Modes.I.rpm(),
-      Modes.I.rst(),
-      Modes.I.ruby(),
-      Modes.I.rust(),
-      Modes.I.sas(),
-      Modes.I.sass(),
-      Modes.I.scheme(),
-      Modes.I.shell(),
-      Modes.I.smalltalk(),
-      Modes.I.smarty(),
-      Modes.I.solr(),
-      Modes.I.soy(),
-      Modes.I.sparql(),
-      Modes.I.spreadsheet(),
-      Modes.I.sql(),
-      Modes.I.stex(),
-      Modes.I.stylus(),
-      Modes.I.swift(),
-      Modes.I.tcl(),
-      Modes.I.textile(),
-      Modes.I.tiddlywiki(),
-      Modes.I.tiki(),
-      Modes.I.toml(),
-      Modes.I.tornado(),
-      Modes.I.troff(),
-      Modes.I.ttcn_cfg(),
-      Modes.I.ttcn(),
-      Modes.I.turtle(),
-      Modes.I.twig(),
-      Modes.I.vb(),
-      Modes.I.vbscript(),
-      Modes.I.velocity(),
-      Modes.I.verilog(),
-      Modes.I.vhdl(),
-      Modes.I.vue(),
-      Modes.I.webidl(),
-      Modes.I.xml(),
-      Modes.I.xquery(),
-      Modes.I.yacas(),
-      Modes.I.yaml_frontmatter(),
-      Modes.I.yaml(),
-      Modes.I.z80(),
-    });
+    indexModes(
+        new DataResource[] {
+          Modes.I.apl(),
+          Modes.I.asciiarmor(),
+          Modes.I.asn_1(),
+          Modes.I.asterisk(),
+          Modes.I.brainfuck(),
+          Modes.I.clike(),
+          Modes.I.clojure(),
+          Modes.I.cmake(),
+          Modes.I.cobol(),
+          Modes.I.coffeescript(),
+          Modes.I.commonlisp(),
+          Modes.I.crystal(),
+          Modes.I.css(),
+          Modes.I.cypher(),
+          Modes.I.d(),
+          Modes.I.dart(),
+          Modes.I.diff(),
+          Modes.I.django(),
+          Modes.I.dockerfile(),
+          Modes.I.dtd(),
+          Modes.I.dylan(),
+          Modes.I.ebnf(),
+          Modes.I.ecl(),
+          Modes.I.eiffel(),
+          Modes.I.elm(),
+          Modes.I.erlang(),
+          Modes.I.factor(),
+          Modes.I.fcl(),
+          Modes.I.forth(),
+          Modes.I.fortran(),
+          Modes.I.gas(),
+          Modes.I.gerrit_commit(),
+          Modes.I.gfm(),
+          Modes.I.gherkin(),
+          Modes.I.go(),
+          Modes.I.groovy(),
+          Modes.I.haml(),
+          Modes.I.handlebars(),
+          Modes.I.haskell_literate(),
+          Modes.I.haskell(),
+          Modes.I.haxe(),
+          Modes.I.htmlembedded(),
+          Modes.I.htmlmixed(),
+          Modes.I.http(),
+          Modes.I.idl(),
+          Modes.I.javascript(),
+          Modes.I.jinja2(),
+          Modes.I.jsx(),
+          Modes.I.julia(),
+          Modes.I.livescript(),
+          Modes.I.lua(),
+          Modes.I.markdown(),
+          Modes.I.mathematica(),
+          Modes.I.mbox(),
+          Modes.I.mirc(),
+          Modes.I.mllike(),
+          Modes.I.modelica(),
+          Modes.I.mscgen(),
+          Modes.I.mumps(),
+          Modes.I.nginx(),
+          Modes.I.nsis(),
+          Modes.I.ntriples(),
+          Modes.I.octave(),
+          Modes.I.oz(),
+          Modes.I.pascal(),
+          Modes.I.pegjs(),
+          Modes.I.perl(),
+          Modes.I.php(),
+          Modes.I.pig(),
+          Modes.I.powershell(),
+          Modes.I.properties(),
+          Modes.I.protobuf(),
+          Modes.I.pug(),
+          Modes.I.puppet(),
+          Modes.I.python(),
+          Modes.I.q(),
+          Modes.I.r(),
+          Modes.I.rpm(),
+          Modes.I.rst(),
+          Modes.I.ruby(),
+          Modes.I.rust(),
+          Modes.I.sas(),
+          Modes.I.sass(),
+          Modes.I.scheme(),
+          Modes.I.shell(),
+          Modes.I.smalltalk(),
+          Modes.I.smarty(),
+          Modes.I.solr(),
+          Modes.I.soy(),
+          Modes.I.sparql(),
+          Modes.I.spreadsheet(),
+          Modes.I.sql(),
+          Modes.I.stex(),
+          Modes.I.stylus(),
+          Modes.I.swift(),
+          Modes.I.tcl(),
+          Modes.I.textile(),
+          Modes.I.tiddlywiki(),
+          Modes.I.tiki(),
+          Modes.I.toml(),
+          Modes.I.tornado(),
+          Modes.I.troff(),
+          Modes.I.ttcn_cfg(),
+          Modes.I.ttcn(),
+          Modes.I.turtle(),
+          Modes.I.twig(),
+          Modes.I.vb(),
+          Modes.I.vbscript(),
+          Modes.I.velocity(),
+          Modes.I.verilog(),
+          Modes.I.vhdl(),
+          Modes.I.vue(),
+          Modes.I.webidl(),
+          Modes.I.xml(),
+          Modes.I.xquery(),
+          Modes.I.yacas(),
+          Modes.I.yaml_frontmatter(),
+          Modes.I.yaml(),
+          Modes.I.z80(),
+        });
 
     alias("application/x-httpd-php-open", "application/x-httpd-php");
     alias("application/x-javascript", "application/javascript");
@@ -242,12 +242,14 @@
         byMime.put(m.mode(), m);
       }
     }
-    Collections.sort(Natives.asList(filtered), new Comparator<ModeInfo>() {
-      @Override
-      public int compare(ModeInfo a, ModeInfo b) {
-        return a.name().toLowerCase().compareTo(b.name().toLowerCase());
-      }
-    });
+    Collections.sort(
+        Natives.asList(filtered),
+        new Comparator<ModeInfo>() {
+          @Override
+          public int compare(ModeInfo a, ModeInfo b) {
+            return a.name().toLowerCase().compareTo(b.name().toLowerCase());
+          }
+        });
     setAll(filtered);
   }
 
@@ -261,14 +263,11 @@
   public final native String mime() /*-{ return this.mime }-*/;
 
   /** Primary and additional MIME types that activate this mode. */
-  public final native JsArrayString mimes()
-  /*-{ return this.mimes || [this.mime] }-*/;
+  public final native JsArrayString mimes() /*-{ return this.mimes || [this.mime] }-*/;
 
-  private native JsArrayString ext()
-  /*-{ return this.ext || [] }-*/;
+  private native JsArrayString ext() /*-{ return this.ext || [] }-*/;
 
-  protected ModeInfo() {
-  }
+  protected ModeInfo() {}
 
   private static native ModeInfo gerrit_commit() /*-{
     return {name: "Git Commit Message",
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java
index 2c171ac..7440102 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java
@@ -16,13 +16,11 @@
 
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
-import net.codemirror.lib.Loader;
-
 import java.util.HashSet;
 import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
+import net.codemirror.lib.Loader;
 
 public class ModeInjector {
   private static boolean canLoad(String mode) {
@@ -30,13 +28,13 @@
   }
 
   private static native boolean isModeLoaded(String n)
-  /*-{ return $wnd.CodeMirror.modes.hasOwnProperty(n); }-*/;
+      /*-{ return $wnd.CodeMirror.modes.hasOwnProperty(n); }-*/ ;
 
   private static native boolean isMimeLoaded(String n)
-  /*-{ return $wnd.CodeMirror.mimeModes.hasOwnProperty(n); }-*/;
+      /*-{ return $wnd.CodeMirror.mimeModes.hasOwnProperty(n); }-*/ ;
 
   private static native JsArrayString getDependencies(String n)
-  /*-{ return $wnd.CodeMirror.modes[n].dependencies || []; }-*/;
+      /*-{ return $wnd.CodeMirror.modes[n].dependencies || []; }-*/ ;
 
   private final Set<String> loading = new HashSet<>(4);
   private int pending;
@@ -53,9 +51,8 @@
     }
 
     if (!canLoad(name)) {
-      Logger.getLogger("net.codemirror").log(
-        Level.WARNING,
-        "CodeMirror mode " + name + " not configured.");
+      Logger.getLogger("net.codemirror")
+          .log(Level.WARNING, "CodeMirror mode " + name + " not configured.");
       return this;
     }
 
@@ -76,24 +73,24 @@
   private void beginLoading(final String mode) {
     pending++;
     Loader.injectScript(
-      ModeInfo.getModeScriptUri(mode),
-      new AsyncCallback<Void>() {
-        @Override
-        public void onSuccess(Void result) {
-          pending--;
-          ensureDependenciesAreLoaded(mode);
-          if (pending == 0) {
-            appCallback.onSuccess(null);
+        ModeInfo.getModeScriptUri(mode),
+        new AsyncCallback<Void>() {
+          @Override
+          public void onSuccess(Void result) {
+            pending--;
+            ensureDependenciesAreLoaded(mode);
+            if (pending == 0) {
+              appCallback.onSuccess(null);
+            }
           }
-        }
 
-        @Override
-        public void onFailure(Throwable caught) {
-          if (--pending == 0) {
-            appCallback.onFailure(caught);
+          @Override
+          public void onFailure(Throwable caught) {
+            if (--pending == 0) {
+              appCallback.onFailure(caught);
+            }
           }
-        }
-      });
+        });
   }
 
   private void ensureDependenciesAreLoaded(String mode) {
@@ -105,9 +102,8 @@
       }
 
       if (!canLoad(d)) {
-        Logger.getLogger("net.codemirror").log(
-          Level.SEVERE,
-          "CodeMirror mode " + d + " needs " + d);
+        Logger.getLogger("net.codemirror")
+            .log(Level.SEVERE, "CodeMirror mode " + d + " needs " + d);
         continue;
       }
 
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
index 668a57f..2b87a34 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
@@ -22,127 +22,489 @@
 public interface Modes extends ClientBundle {
   Modes I = GWT.create(Modes.class);
 
-  @Source("apl.js") @DoNotEmbed DataResource apl();
-  @Source("asciiarmor.js") @DoNotEmbed DataResource asciiarmor();
-  @Source("asn.1.js") @DoNotEmbed DataResource asn_1();
-  @Source("asterisk.js") @DoNotEmbed DataResource asterisk();
-  @Source("brainfuck.js") @DoNotEmbed DataResource brainfuck();
-  @Source("clike.js") @DoNotEmbed DataResource clike();
-  @Source("clojure.js") @DoNotEmbed DataResource clojure();
-  @Source("cmake.js") @DoNotEmbed DataResource cmake();
-  @Source("cobol.js") @DoNotEmbed DataResource cobol();
-  @Source("coffeescript.js") @DoNotEmbed DataResource coffeescript();
-  @Source("commonlisp.js") @DoNotEmbed DataResource commonlisp();
-  @Source("crystal.js") @DoNotEmbed DataResource crystal();
-  @Source("css.js") @DoNotEmbed DataResource css();
-  @Source("cypher.js") @DoNotEmbed DataResource cypher();
-  @Source("d.js") @DoNotEmbed DataResource d();
-  @Source("dart.js") @DoNotEmbed DataResource dart();
-  @Source("diff.js") @DoNotEmbed DataResource diff();
-  @Source("django.js") @DoNotEmbed DataResource django();
-  @Source("dockerfile.js") @DoNotEmbed DataResource dockerfile();
-  @Source("dtd.js") @DoNotEmbed DataResource dtd();
-  @Source("dylan.js") @DoNotEmbed DataResource dylan();
-  @Source("ebnf.js") @DoNotEmbed DataResource ebnf();
-  @Source("ecl.js") @DoNotEmbed DataResource ecl();
-  @Source("eiffel.js") @DoNotEmbed DataResource eiffel();
-  @Source("elm.js") @DoNotEmbed DataResource elm();
-  @Source("erlang.js") @DoNotEmbed DataResource erlang();
-  @Source("factor.js") @DoNotEmbed DataResource factor();
-  @Source("fcl.js") @DoNotEmbed DataResource fcl();
-  @Source("forth.js") @DoNotEmbed DataResource forth();
-  @Source("fortran.js") @DoNotEmbed DataResource fortran();
-  @Source("gas.js") @DoNotEmbed DataResource gas();
-  @Source("gerrit/commit.js") @DoNotEmbed DataResource gerrit_commit();
-  @Source("gfm.js") @DoNotEmbed DataResource gfm();
-  @Source("gherkin.js") @DoNotEmbed DataResource gherkin();
-  @Source("go.js") @DoNotEmbed DataResource go();
-  @Source("groovy.js") @DoNotEmbed DataResource groovy();
-  @Source("haml.js") @DoNotEmbed DataResource haml();
-  @Source("handlebars.js") @DoNotEmbed DataResource handlebars();
-  @Source("haskell-literate.js") @DoNotEmbed DataResource haskell_literate();
-  @Source("haskell.js") @DoNotEmbed DataResource haskell();
-  @Source("haxe.js") @DoNotEmbed DataResource haxe();
-  @Source("htmlembedded.js") @DoNotEmbed DataResource htmlembedded();
-  @Source("htmlmixed.js") @DoNotEmbed DataResource htmlmixed();
-  @Source("http.js") @DoNotEmbed DataResource http();
-  @Source("idl.js") @DoNotEmbed DataResource idl();
-  @Source("jade.js") @DoNotEmbed DataResource jade();
-  @Source("javascript.js") @DoNotEmbed DataResource javascript();
-  @Source("jinja2.js") @DoNotEmbed DataResource jinja2();
-  @Source("jsx.js") @DoNotEmbed DataResource jsx();
-  @Source("julia.js") @DoNotEmbed DataResource julia();
-  @Source("livescript.js") @DoNotEmbed DataResource livescript();
-  @Source("lua.js") @DoNotEmbed DataResource lua();
-  @Source("markdown.js") @DoNotEmbed DataResource markdown();
-  @Source("mathematica.js") @DoNotEmbed DataResource mathematica();
-  @Source("mbox.js") @DoNotEmbed DataResource mbox();
-  @Source("mirc.js") @DoNotEmbed DataResource mirc();
-  @Source("mllike.js") @DoNotEmbed DataResource mllike();
-  @Source("modelica.js") @DoNotEmbed DataResource modelica();
-  @Source("mscgen.js") @DoNotEmbed DataResource mscgen();
-  @Source("mumps.js") @DoNotEmbed DataResource mumps();
-  @Source("nginx.js") @DoNotEmbed DataResource nginx();
-  @Source("nsis.js") @DoNotEmbed DataResource nsis();
-  @Source("ntriples.js") @DoNotEmbed DataResource ntriples();
-  @Source("octave.js") @DoNotEmbed DataResource octave();
-  @Source("oz.js") @DoNotEmbed DataResource oz();
-  @Source("pascal.js") @DoNotEmbed DataResource pascal();
-  @Source("pegjs.js") @DoNotEmbed DataResource pegjs();
-  @Source("perl.js") @DoNotEmbed DataResource perl();
-  @Source("php.js") @DoNotEmbed DataResource php();
-  @Source("pig.js") @DoNotEmbed DataResource pig();
-  @Source("powershell.js") @DoNotEmbed DataResource powershell();
-  @Source("properties.js") @DoNotEmbed DataResource properties();
-  @Source("protobuf.js") @DoNotEmbed DataResource protobuf();
-  @Source("puppet.js") @DoNotEmbed DataResource puppet();
-  @Source("python.js") @DoNotEmbed DataResource python();
-  @Source("q.js") @DoNotEmbed DataResource q();
-  @Source("r.js") @DoNotEmbed DataResource r();
-  @Source("rpm.js") @DoNotEmbed DataResource rpm();
-  @Source("rst.js") @DoNotEmbed DataResource rst();
-  @Source("ruby.js") @DoNotEmbed DataResource ruby();
-  @Source("rust.js") @DoNotEmbed DataResource rust();
-  @Source("sas.js") @DoNotEmbed DataResource sas();
-  @Source("sass.js") @DoNotEmbed DataResource sass();
-  @Source("scheme.js") @DoNotEmbed DataResource scheme();
-  @Source("shell.js") @DoNotEmbed DataResource shell();
-  @Source("sieve.js") @DoNotEmbed DataResource sieve();
-  @Source("slim.js") @DoNotEmbed DataResource slim();
-  @Source("smalltalk.js") @DoNotEmbed DataResource smalltalk();
-  @Source("smarty.js") @DoNotEmbed DataResource smarty();
-  @Source("solr.js") @DoNotEmbed DataResource solr();
-  @Source("soy.js") @DoNotEmbed DataResource soy();
-  @Source("sparql.js") @DoNotEmbed DataResource sparql();
-  @Source("spreadsheet.js") @DoNotEmbed DataResource spreadsheet();
-  @Source("sql.js") @DoNotEmbed DataResource sql();
-  @Source("stex.js") @DoNotEmbed DataResource stex();
-  @Source("stylus.js") @DoNotEmbed DataResource stylus();
-  @Source("swift.js") @DoNotEmbed DataResource swift();
-  @Source("tcl.js") @DoNotEmbed DataResource tcl();
-  @Source("textile.js") @DoNotEmbed DataResource textile();
-  @Source("tiddlywiki.js") @DoNotEmbed DataResource tiddlywiki();
-  @Source("tiki.js") @DoNotEmbed DataResource tiki();
-  @Source("toml.js") @DoNotEmbed DataResource toml();
-  @Source("tornado.js") @DoNotEmbed DataResource tornado();
-  @Source("troff.js") @DoNotEmbed DataResource troff();
-  @Source("ttcn-cfg.js") @DoNotEmbed DataResource ttcn_cfg();
-  @Source("ttcn.js") @DoNotEmbed DataResource ttcn();
-  @Source("turtle.js") @DoNotEmbed DataResource turtle();
-  @Source("twig.js") @DoNotEmbed DataResource twig();
-  @Source("vb.js") @DoNotEmbed DataResource vb();
-  @Source("vbscript.js") @DoNotEmbed DataResource vbscript();
-  @Source("velocity.js") @DoNotEmbed DataResource velocity();
-  @Source("verilog.js") @DoNotEmbed DataResource verilog();
-  @Source("vhdl.js") @DoNotEmbed DataResource vhdl();
-  @Source("vue.js") @DoNotEmbed DataResource vue();
-  @Source("webidl.js") @DoNotEmbed DataResource webidl();
-  @Source("xml.js") @DoNotEmbed DataResource xml();
-  @Source("xquery.js") @DoNotEmbed DataResource xquery();
-  @Source("yacas.js") @DoNotEmbed DataResource yacas();
-  @Source("yaml-frontmatter.js") @DoNotEmbed DataResource yaml_frontmatter();
-  @Source("yaml.js") @DoNotEmbed DataResource yaml();
-  @Source("z80.js") @DoNotEmbed DataResource z80();
+  @Source("apl.js")
+  @DoNotEmbed
+  DataResource apl();
+
+  @Source("asciiarmor.js")
+  @DoNotEmbed
+  DataResource asciiarmor();
+
+  @Source("asn.1.js")
+  @DoNotEmbed
+  DataResource asn_1();
+
+  @Source("asterisk.js")
+  @DoNotEmbed
+  DataResource asterisk();
+
+  @Source("brainfuck.js")
+  @DoNotEmbed
+  DataResource brainfuck();
+
+  @Source("clike.js")
+  @DoNotEmbed
+  DataResource clike();
+
+  @Source("clojure.js")
+  @DoNotEmbed
+  DataResource clojure();
+
+  @Source("cmake.js")
+  @DoNotEmbed
+  DataResource cmake();
+
+  @Source("cobol.js")
+  @DoNotEmbed
+  DataResource cobol();
+
+  @Source("coffeescript.js")
+  @DoNotEmbed
+  DataResource coffeescript();
+
+  @Source("commonlisp.js")
+  @DoNotEmbed
+  DataResource commonlisp();
+
+  @Source("crystal.js")
+  @DoNotEmbed
+  DataResource crystal();
+
+  @Source("css.js")
+  @DoNotEmbed
+  DataResource css();
+
+  @Source("cypher.js")
+  @DoNotEmbed
+  DataResource cypher();
+
+  @Source("d.js")
+  @DoNotEmbed
+  DataResource d();
+
+  @Source("dart.js")
+  @DoNotEmbed
+  DataResource dart();
+
+  @Source("diff.js")
+  @DoNotEmbed
+  DataResource diff();
+
+  @Source("django.js")
+  @DoNotEmbed
+  DataResource django();
+
+  @Source("dockerfile.js")
+  @DoNotEmbed
+  DataResource dockerfile();
+
+  @Source("dtd.js")
+  @DoNotEmbed
+  DataResource dtd();
+
+  @Source("dylan.js")
+  @DoNotEmbed
+  DataResource dylan();
+
+  @Source("ebnf.js")
+  @DoNotEmbed
+  DataResource ebnf();
+
+  @Source("ecl.js")
+  @DoNotEmbed
+  DataResource ecl();
+
+  @Source("eiffel.js")
+  @DoNotEmbed
+  DataResource eiffel();
+
+  @Source("elm.js")
+  @DoNotEmbed
+  DataResource elm();
+
+  @Source("erlang.js")
+  @DoNotEmbed
+  DataResource erlang();
+
+  @Source("factor.js")
+  @DoNotEmbed
+  DataResource factor();
+
+  @Source("fcl.js")
+  @DoNotEmbed
+  DataResource fcl();
+
+  @Source("forth.js")
+  @DoNotEmbed
+  DataResource forth();
+
+  @Source("fortran.js")
+  @DoNotEmbed
+  DataResource fortran();
+
+  @Source("gas.js")
+  @DoNotEmbed
+  DataResource gas();
+
+  @Source("gerrit/commit.js")
+  @DoNotEmbed
+  DataResource gerrit_commit();
+
+  @Source("gfm.js")
+  @DoNotEmbed
+  DataResource gfm();
+
+  @Source("gherkin.js")
+  @DoNotEmbed
+  DataResource gherkin();
+
+  @Source("go.js")
+  @DoNotEmbed
+  DataResource go();
+
+  @Source("groovy.js")
+  @DoNotEmbed
+  DataResource groovy();
+
+  @Source("haml.js")
+  @DoNotEmbed
+  DataResource haml();
+
+  @Source("handlebars.js")
+  @DoNotEmbed
+  DataResource handlebars();
+
+  @Source("haskell-literate.js")
+  @DoNotEmbed
+  DataResource haskell_literate();
+
+  @Source("haskell.js")
+  @DoNotEmbed
+  DataResource haskell();
+
+  @Source("haxe.js")
+  @DoNotEmbed
+  DataResource haxe();
+
+  @Source("htmlembedded.js")
+  @DoNotEmbed
+  DataResource htmlembedded();
+
+  @Source("htmlmixed.js")
+  @DoNotEmbed
+  DataResource htmlmixed();
+
+  @Source("http.js")
+  @DoNotEmbed
+  DataResource http();
+
+  @Source("idl.js")
+  @DoNotEmbed
+  DataResource idl();
+
+  @Source("javascript.js")
+  @DoNotEmbed
+  DataResource javascript();
+
+  @Source("jinja2.js")
+  @DoNotEmbed
+  DataResource jinja2();
+
+  @Source("jsx.js")
+  @DoNotEmbed
+  DataResource jsx();
+
+  @Source("julia.js")
+  @DoNotEmbed
+  DataResource julia();
+
+  @Source("livescript.js")
+  @DoNotEmbed
+  DataResource livescript();
+
+  @Source("lua.js")
+  @DoNotEmbed
+  DataResource lua();
+
+  @Source("markdown.js")
+  @DoNotEmbed
+  DataResource markdown();
+
+  @Source("mathematica.js")
+  @DoNotEmbed
+  DataResource mathematica();
+
+  @Source("mbox.js")
+  @DoNotEmbed
+  DataResource mbox();
+
+  @Source("mirc.js")
+  @DoNotEmbed
+  DataResource mirc();
+
+  @Source("mllike.js")
+  @DoNotEmbed
+  DataResource mllike();
+
+  @Source("modelica.js")
+  @DoNotEmbed
+  DataResource modelica();
+
+  @Source("mscgen.js")
+  @DoNotEmbed
+  DataResource mscgen();
+
+  @Source("mumps.js")
+  @DoNotEmbed
+  DataResource mumps();
+
+  @Source("nginx.js")
+  @DoNotEmbed
+  DataResource nginx();
+
+  @Source("nsis.js")
+  @DoNotEmbed
+  DataResource nsis();
+
+  @Source("ntriples.js")
+  @DoNotEmbed
+  DataResource ntriples();
+
+  @Source("octave.js")
+  @DoNotEmbed
+  DataResource octave();
+
+  @Source("oz.js")
+  @DoNotEmbed
+  DataResource oz();
+
+  @Source("pascal.js")
+  @DoNotEmbed
+  DataResource pascal();
+
+  @Source("pegjs.js")
+  @DoNotEmbed
+  DataResource pegjs();
+
+  @Source("perl.js")
+  @DoNotEmbed
+  DataResource perl();
+
+  @Source("php.js")
+  @DoNotEmbed
+  DataResource php();
+
+  @Source("pig.js")
+  @DoNotEmbed
+  DataResource pig();
+
+  @Source("powershell.js")
+  @DoNotEmbed
+  DataResource powershell();
+
+  @Source("properties.js")
+  @DoNotEmbed
+  DataResource properties();
+
+  @Source("protobuf.js")
+  @DoNotEmbed
+  DataResource protobuf();
+
+  @Source("pug.js")
+  @DoNotEmbed
+  DataResource pug();
+
+  @Source("puppet.js")
+  @DoNotEmbed
+  DataResource puppet();
+
+  @Source("python.js")
+  @DoNotEmbed
+  DataResource python();
+
+  @Source("q.js")
+  @DoNotEmbed
+  DataResource q();
+
+  @Source("r.js")
+  @DoNotEmbed
+  DataResource r();
+
+  @Source("rpm.js")
+  @DoNotEmbed
+  DataResource rpm();
+
+  @Source("rst.js")
+  @DoNotEmbed
+  DataResource rst();
+
+  @Source("ruby.js")
+  @DoNotEmbed
+  DataResource ruby();
+
+  @Source("rust.js")
+  @DoNotEmbed
+  DataResource rust();
+
+  @Source("sas.js")
+  @DoNotEmbed
+  DataResource sas();
+
+  @Source("sass.js")
+  @DoNotEmbed
+  DataResource sass();
+
+  @Source("scheme.js")
+  @DoNotEmbed
+  DataResource scheme();
+
+  @Source("shell.js")
+  @DoNotEmbed
+  DataResource shell();
+
+  @Source("sieve.js")
+  @DoNotEmbed
+  DataResource sieve();
+
+  @Source("slim.js")
+  @DoNotEmbed
+  DataResource slim();
+
+  @Source("smalltalk.js")
+  @DoNotEmbed
+  DataResource smalltalk();
+
+  @Source("smarty.js")
+  @DoNotEmbed
+  DataResource smarty();
+
+  @Source("solr.js")
+  @DoNotEmbed
+  DataResource solr();
+
+  @Source("soy.js")
+  @DoNotEmbed
+  DataResource soy();
+
+  @Source("sparql.js")
+  @DoNotEmbed
+  DataResource sparql();
+
+  @Source("spreadsheet.js")
+  @DoNotEmbed
+  DataResource spreadsheet();
+
+  @Source("sql.js")
+  @DoNotEmbed
+  DataResource sql();
+
+  @Source("stex.js")
+  @DoNotEmbed
+  DataResource stex();
+
+  @Source("stylus.js")
+  @DoNotEmbed
+  DataResource stylus();
+
+  @Source("swift.js")
+  @DoNotEmbed
+  DataResource swift();
+
+  @Source("tcl.js")
+  @DoNotEmbed
+  DataResource tcl();
+
+  @Source("textile.js")
+  @DoNotEmbed
+  DataResource textile();
+
+  @Source("tiddlywiki.js")
+  @DoNotEmbed
+  DataResource tiddlywiki();
+
+  @Source("tiki.js")
+  @DoNotEmbed
+  DataResource tiki();
+
+  @Source("toml.js")
+  @DoNotEmbed
+  DataResource toml();
+
+  @Source("tornado.js")
+  @DoNotEmbed
+  DataResource tornado();
+
+  @Source("troff.js")
+  @DoNotEmbed
+  DataResource troff();
+
+  @Source("ttcn-cfg.js")
+  @DoNotEmbed
+  DataResource ttcn_cfg();
+
+  @Source("ttcn.js")
+  @DoNotEmbed
+  DataResource ttcn();
+
+  @Source("turtle.js")
+  @DoNotEmbed
+  DataResource turtle();
+
+  @Source("twig.js")
+  @DoNotEmbed
+  DataResource twig();
+
+  @Source("vb.js")
+  @DoNotEmbed
+  DataResource vb();
+
+  @Source("vbscript.js")
+  @DoNotEmbed
+  DataResource vbscript();
+
+  @Source("velocity.js")
+  @DoNotEmbed
+  DataResource velocity();
+
+  @Source("verilog.js")
+  @DoNotEmbed
+  DataResource verilog();
+
+  @Source("vhdl.js")
+  @DoNotEmbed
+  DataResource vhdl();
+
+  @Source("vue.js")
+  @DoNotEmbed
+  DataResource vue();
+
+  @Source("webidl.js")
+  @DoNotEmbed
+  DataResource webidl();
+
+  @Source("xml.js")
+  @DoNotEmbed
+  DataResource xml();
+
+  @Source("xquery.js")
+  @DoNotEmbed
+  DataResource xquery();
+
+  @Source("yacas.js")
+  @DoNotEmbed
+  DataResource yacas();
+
+  @Source("yaml-frontmatter.js")
+  @DoNotEmbed
+  DataResource yaml_frontmatter();
+
+  @Source("yaml.js")
+  @DoNotEmbed
+  DataResource yaml();
+
+  @Source("z80.js")
+  @DoNotEmbed
+  DataResource z80();
 
   // When adding a resource, update static initializer in ModeInfo.
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java b/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
index 20dd8c7..1dce708 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
@@ -21,62 +21,60 @@
 import com.google.gwt.resources.client.ResourceException;
 import com.google.gwt.resources.client.TextResource;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-
 import java.util.EnumSet;
 
 /** Dynamically loads a known CodeMirror theme's CSS */
 public class ThemeLoader {
   private static final ExternalTextResource[] THEMES = {
-      Themes.I.day_3024(),
-      Themes.I.night_3024(),
-      Themes.I.abcdef(),
-      Themes.I.ambiance(),
-      Themes.I.base16_dark(),
-      Themes.I.base16_light(),
-      Themes.I.bespin(),
-      Themes.I.blackboard(),
-      Themes.I.cobalt(),
-      Themes.I.colorforth(),
-      Themes.I.dracula(),
-      Themes.I.eclipse(),
-      Themes.I.elegant(),
-      Themes.I.erlang_dark(),
-      Themes.I.hopscotch(),
-      Themes.I.icecoder(),
-      Themes.I.isotope(),
-      Themes.I.lesser_dark(),
-      Themes.I.liquibyte(),
-      Themes.I.material(),
-      Themes.I.mbo(),
-      Themes.I.mdn_like(),
-      Themes.I.midnight(),
-      Themes.I.monokai(),
-      Themes.I.neat(),
-      Themes.I.neo(),
-      Themes.I.night(),
-      Themes.I.paraiso_dark(),
-      Themes.I.paraiso_light(),
-      Themes.I.pastel_on_dark(),
-      Themes.I.railscasts(),
-      Themes.I.rubyblue(),
-      Themes.I.seti(),
-      Themes.I.solarized(),
-      Themes.I.the_matrix(),
-      Themes.I.tomorrow_night_bright(),
-      Themes.I.tomorrow_night_eighties(),
-      Themes.I.ttcn(),
-      Themes.I.twilight(),
-      Themes.I.vibrant_ink(),
-      Themes.I.xq_dark(),
-      Themes.I.xq_light(),
-      Themes.I.yeti(),
-      Themes.I.zenburn(),
+    Themes.I.day_3024(),
+    Themes.I.night_3024(),
+    Themes.I.abcdef(),
+    Themes.I.ambiance(),
+    Themes.I.base16_dark(),
+    Themes.I.base16_light(),
+    Themes.I.bespin(),
+    Themes.I.blackboard(),
+    Themes.I.cobalt(),
+    Themes.I.colorforth(),
+    Themes.I.dracula(),
+    Themes.I.eclipse(),
+    Themes.I.elegant(),
+    Themes.I.erlang_dark(),
+    Themes.I.hopscotch(),
+    Themes.I.icecoder(),
+    Themes.I.isotope(),
+    Themes.I.lesser_dark(),
+    Themes.I.liquibyte(),
+    Themes.I.material(),
+    Themes.I.mbo(),
+    Themes.I.mdn_like(),
+    Themes.I.midnight(),
+    Themes.I.monokai(),
+    Themes.I.neat(),
+    Themes.I.neo(),
+    Themes.I.night(),
+    Themes.I.paraiso_dark(),
+    Themes.I.paraiso_light(),
+    Themes.I.pastel_on_dark(),
+    Themes.I.railscasts(),
+    Themes.I.rubyblue(),
+    Themes.I.seti(),
+    Themes.I.solarized(),
+    Themes.I.the_matrix(),
+    Themes.I.tomorrow_night_bright(),
+    Themes.I.tomorrow_night_eighties(),
+    Themes.I.ttcn(),
+    Themes.I.twilight(),
+    Themes.I.vibrant_ink(),
+    Themes.I.xq_dark(),
+    Themes.I.xq_light(),
+    Themes.I.yeti(),
+    Themes.I.zenburn(),
   };
 
   private static final EnumSet<Theme> loaded = EnumSet.of(Theme.DEFAULT);
 
-  public static final void loadTheme(final Theme theme,
-      final AsyncCallback<Void> cb) {
+  public static final void loadTheme(final Theme theme, final AsyncCallback<Void> cb) {
     if (loaded.contains(theme)) {
       cb.onSuccess(null);
       return;
@@ -89,19 +87,20 @@
     }
 
     try {
-      resource.getText(new ResourceCallback<TextResource>() {
-        @Override
-        public void onSuccess(TextResource resource) {
-          StyleInjector.inject(resource.getText());
-          loaded.add(theme);
-          cb.onSuccess(null);
-        }
+      resource.getText(
+          new ResourceCallback<TextResource>() {
+            @Override
+            public void onSuccess(TextResource resource) {
+              StyleInjector.inject(resource.getText());
+              loaded.add(theme);
+              cb.onSuccess(null);
+            }
 
-        @Override
-        public void onError(ResourceException e) {
-          cb.onFailure(e);
-        }
-      });
+            @Override
+            public void onError(ResourceException e) {
+              cb.onFailure(e);
+            }
+          });
     } catch (ResourceException e) {
       cb.onFailure(e);
     }
@@ -116,6 +115,5 @@
     return null;
   }
 
-  private ThemeLoader() {
-  }
+  private ThemeLoader() {}
 }
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java b/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
index 80304a3..cfe853f 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
@@ -21,50 +21,143 @@
 public interface Themes extends ClientBundle {
   Themes I = GWT.create(Themes.class);
 
-  @Source("3024-day.css") ExternalTextResource day_3024();
-  @Source("3024-night.css") ExternalTextResource night_3024();
-  @Source("abcdef.css") ExternalTextResource abcdef();
-  @Source("ambiance.css") ExternalTextResource ambiance();
-  @Source("base16-dark.css") ExternalTextResource base16_dark();
-  @Source("base16-light.css") ExternalTextResource base16_light();
-  @Source("bespin.css") ExternalTextResource bespin();
-  @Source("blackboard.css") ExternalTextResource blackboard();
-  @Source("cobalt.css") ExternalTextResource cobalt();
-  @Source("colorforth.css") ExternalTextResource colorforth();
-  @Source("dracula.css") ExternalTextResource dracula();
-  @Source("eclipse.css") ExternalTextResource eclipse();
-  @Source("elegant.css") ExternalTextResource elegant();
-  @Source("erlang-dark.css") ExternalTextResource erlang_dark();
-  @Source("hopscotch.css") ExternalTextResource hopscotch();
-  @Source("icecoder.css") ExternalTextResource icecoder();
-  @Source("isotope.css") ExternalTextResource isotope();
-  @Source("lesser-dark.css") ExternalTextResource lesser_dark();
-  @Source("liquibyte.css") ExternalTextResource liquibyte();
-  @Source("material.css") ExternalTextResource material();
-  @Source("mbo.css") ExternalTextResource mbo();
-  @Source("mdn-like.css") ExternalTextResource mdn_like();
-  @Source("midnight.css") ExternalTextResource midnight();
-  @Source("monokai.css") ExternalTextResource monokai();
-  @Source("neat.css") ExternalTextResource neat();
-  @Source("neo.css") ExternalTextResource neo();
-  @Source("night.css") ExternalTextResource night();
-  @Source("paraiso-dark.css") ExternalTextResource paraiso_dark();
-  @Source("paraiso-light.css") ExternalTextResource paraiso_light();
-  @Source("pastel-on-dark.css") ExternalTextResource pastel_on_dark();
-  @Source("railscasts.css") ExternalTextResource railscasts();
-  @Source("rubyblue.css") ExternalTextResource rubyblue();
-  @Source("seti.css") ExternalTextResource seti();
-  @Source("solarized.css") ExternalTextResource solarized();
-  @Source("the-matrix.css") ExternalTextResource the_matrix();
-  @Source("tomorrow-night-bright.css") ExternalTextResource tomorrow_night_bright();
-  @Source("tomorrow-night-eighties.css") ExternalTextResource tomorrow_night_eighties();
-  @Source("ttcn.css") ExternalTextResource ttcn();
-  @Source("twilight.css") ExternalTextResource twilight();
-  @Source("vibrant-ink.css") ExternalTextResource vibrant_ink();
-  @Source("xq-dark.css") ExternalTextResource xq_dark();
-  @Source("xq-light.css") ExternalTextResource xq_light();
-  @Source("yeti.css") ExternalTextResource yeti();
-  @Source("zenburn.css") ExternalTextResource zenburn();
+  @Source("3024-day.css")
+  ExternalTextResource day_3024();
+
+  @Source("3024-night.css")
+  ExternalTextResource night_3024();
+
+  @Source("abcdef.css")
+  ExternalTextResource abcdef();
+
+  @Source("ambiance.css")
+  ExternalTextResource ambiance();
+
+  @Source("base16-dark.css")
+  ExternalTextResource base16_dark();
+
+  @Source("base16-light.css")
+  ExternalTextResource base16_light();
+
+  @Source("bespin.css")
+  ExternalTextResource bespin();
+
+  @Source("blackboard.css")
+  ExternalTextResource blackboard();
+
+  @Source("cobalt.css")
+  ExternalTextResource cobalt();
+
+  @Source("colorforth.css")
+  ExternalTextResource colorforth();
+
+  @Source("dracula.css")
+  ExternalTextResource dracula();
+
+  @Source("duotone-dark.css")
+  ExternalTextResource duotone_dark();
+
+  @Source("duotone-light.css")
+  ExternalTextResource duotone_light();
+
+  @Source("eclipse.css")
+  ExternalTextResource eclipse();
+
+  @Source("elegant.css")
+  ExternalTextResource elegant();
+
+  @Source("erlang-dark.css")
+  ExternalTextResource erlang_dark();
+
+  @Source("hopscotch.css")
+  ExternalTextResource hopscotch();
+
+  @Source("icecoder.css")
+  ExternalTextResource icecoder();
+
+  @Source("isotope.css")
+  ExternalTextResource isotope();
+
+  @Source("lesser-dark.css")
+  ExternalTextResource lesser_dark();
+
+  @Source("liquibyte.css")
+  ExternalTextResource liquibyte();
+
+  @Source("material.css")
+  ExternalTextResource material();
+
+  @Source("mbo.css")
+  ExternalTextResource mbo();
+
+  @Source("mdn-like.css")
+  ExternalTextResource mdn_like();
+
+  @Source("midnight.css")
+  ExternalTextResource midnight();
+
+  @Source("monokai.css")
+  ExternalTextResource monokai();
+
+  @Source("neat.css")
+  ExternalTextResource neat();
+
+  @Source("neo.css")
+  ExternalTextResource neo();
+
+  @Source("night.css")
+  ExternalTextResource night();
+
+  @Source("paraiso-dark.css")
+  ExternalTextResource paraiso_dark();
+
+  @Source("paraiso-light.css")
+  ExternalTextResource paraiso_light();
+
+  @Source("pastel-on-dark.css")
+  ExternalTextResource pastel_on_dark();
+
+  @Source("railscasts.css")
+  ExternalTextResource railscasts();
+
+  @Source("rubyblue.css")
+  ExternalTextResource rubyblue();
+
+  @Source("seti.css")
+  ExternalTextResource seti();
+
+  @Source("solarized.css")
+  ExternalTextResource solarized();
+
+  @Source("the-matrix.css")
+  ExternalTextResource the_matrix();
+
+  @Source("tomorrow-night-bright.css")
+  ExternalTextResource tomorrow_night_bright();
+
+  @Source("tomorrow-night-eighties.css")
+  ExternalTextResource tomorrow_night_eighties();
+
+  @Source("ttcn.css")
+  ExternalTextResource ttcn();
+
+  @Source("twilight.css")
+  ExternalTextResource twilight();
+
+  @Source("vibrant-ink.css")
+  ExternalTextResource vibrant_ink();
+
+  @Source("xq-dark.css")
+  ExternalTextResource xq_dark();
+
+  @Source("xq-light.css")
+  ExternalTextResource xq_light();
+
+  @Source("yeti.css")
+  ExternalTextResource yeti();
+
+  @Source("zenburn.css")
+  ExternalTextResource zenburn();
 
   // When adding a resource, update:
   // - static initializer in ThemeLoader
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java
index 25c8270..fcc214e 100644
--- a/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java
+++ b/gerrit-gwtui/src/test/java/com/google/gerrit/client/diff/LineMapperTest.java
@@ -17,14 +17,13 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
-
 import org.junit.Test;
 
 /** Unit tests for LineMapper */
 public class LineMapperTest {
 
   @Test
-  public void testAppendCommon() {
+  public void appendCommon() {
     LineMapper mapper = new LineMapper();
     mapper.appendCommon(10);
     assertEquals(10, mapper.getLineA());
@@ -32,7 +31,7 @@
   }
 
   @Test
-  public void testAppendInsert() {
+  public void appendInsert() {
     LineMapper mapper = new LineMapper();
     mapper.appendInsert(10);
     assertEquals(0, mapper.getLineA());
@@ -40,7 +39,7 @@
   }
 
   @Test
-  public void testAppendDelete() {
+  public void appendDelete() {
     LineMapper mapper = new LineMapper();
     mapper.appendDelete(10);
     assertEquals(10, mapper.getLineA());
@@ -48,63 +47,53 @@
   }
 
   @Test
-  public void testFindInCommon() {
+  public void findInCommon() {
     LineMapper mapper = new LineMapper();
     mapper.appendCommon(10);
-    assertEquals(new LineOnOtherInfo(9, true),
-        mapper.lineOnOther(DisplaySide.A, 9));
-    assertEquals(new LineOnOtherInfo(9, true),
-        mapper.lineOnOther(DisplaySide.B, 9));
+    assertEquals(new LineOnOtherInfo(9, true), mapper.lineOnOther(DisplaySide.A, 9));
+    assertEquals(new LineOnOtherInfo(9, true), mapper.lineOnOther(DisplaySide.B, 9));
   }
 
   @Test
-  public void testFindAfterCommon() {
+  public void findAfterCommon() {
     LineMapper mapper = new LineMapper();
     mapper.appendCommon(10);
-    assertEquals(new LineOnOtherInfo(10, true),
-        mapper.lineOnOther(DisplaySide.A, 10));
-    assertEquals(new LineOnOtherInfo(10, true),
-        mapper.lineOnOther(DisplaySide.B, 10));
+    assertEquals(new LineOnOtherInfo(10, true), mapper.lineOnOther(DisplaySide.A, 10));
+    assertEquals(new LineOnOtherInfo(10, true), mapper.lineOnOther(DisplaySide.B, 10));
   }
 
   @Test
-  public void testFindInInsertGap() {
+  public void findInInsertGap() {
     LineMapper mapper = new LineMapper();
     mapper.appendInsert(10);
-    assertEquals(new LineOnOtherInfo(-1, false),
-        mapper.lineOnOther(DisplaySide.B, 9));
+    assertEquals(new LineOnOtherInfo(-1, false), mapper.lineOnOther(DisplaySide.B, 9));
   }
 
   @Test
-  public void testFindAfterInsertGap() {
+  public void findAfterInsertGap() {
     LineMapper mapper = new LineMapper();
     mapper.appendInsert(10);
-    assertEquals(new LineOnOtherInfo(0, true),
-        mapper.lineOnOther(DisplaySide.B, 10));
-    assertEquals(new LineOnOtherInfo(10, true),
-        mapper.lineOnOther(DisplaySide.A, 0));
+    assertEquals(new LineOnOtherInfo(0, true), mapper.lineOnOther(DisplaySide.B, 10));
+    assertEquals(new LineOnOtherInfo(10, true), mapper.lineOnOther(DisplaySide.A, 0));
   }
 
   @Test
-  public void testFindInDeleteGap() {
+  public void findInDeleteGap() {
     LineMapper mapper = new LineMapper();
     mapper.appendDelete(10);
-    assertEquals(new LineOnOtherInfo(-1, false),
-        mapper.lineOnOther(DisplaySide.A, 9));
+    assertEquals(new LineOnOtherInfo(-1, false), mapper.lineOnOther(DisplaySide.A, 9));
   }
 
   @Test
-  public void testFindAfterDeleteGap() {
+  public void findAfterDeleteGap() {
     LineMapper mapper = new LineMapper();
     mapper.appendDelete(10);
-    assertEquals(new LineOnOtherInfo(0, true),
-        mapper.lineOnOther(DisplaySide.A, 10));
-    assertEquals(new LineOnOtherInfo(10, true),
-        mapper.lineOnOther(DisplaySide.B, 0));
+    assertEquals(new LineOnOtherInfo(0, true), mapper.lineOnOther(DisplaySide.A, 10));
+    assertEquals(new LineOnOtherInfo(10, true), mapper.lineOnOther(DisplaySide.B, 0));
   }
 
   @Test
-  public void testReplaceWithInsertInB() {
+  public void replaceWithInsertInB() {
     // 0 c c
     // 1 a b
     // 2 a b
@@ -119,14 +108,10 @@
     assertEquals(4, mapper.getLineA());
     assertEquals(6, mapper.getLineB());
 
-    assertEquals(new LineOnOtherInfo(1, true),
-        mapper.lineOnOther(DisplaySide.B, 1));
-    assertEquals(new LineOnOtherInfo(3, true),
-        mapper.lineOnOther(DisplaySide.B, 5));
+    assertEquals(new LineOnOtherInfo(1, true), mapper.lineOnOther(DisplaySide.B, 1));
+    assertEquals(new LineOnOtherInfo(3, true), mapper.lineOnOther(DisplaySide.B, 5));
 
-    assertEquals(new LineOnOtherInfo(2, true),
-        mapper.lineOnOther(DisplaySide.B, 2));
-    assertEquals(new LineOnOtherInfo(2, false),
-        mapper.lineOnOther(DisplaySide.B, 3));
+    assertEquals(new LineOnOtherInfo(2, true), mapper.lineOnOther(DisplaySide.B, 2));
+    assertEquals(new LineOnOtherInfo(2, false), mapper.lineOnOther(DisplaySide.B, 3));
   }
 }
diff --git a/gerrit-httpd/BUCK b/gerrit-httpd/BUCK
deleted file mode 100644
index d52963a..0000000
--- a/gerrit-httpd/BUCK
+++ /dev/null
@@ -1,79 +0,0 @@
-SRCS = glob(
-  ['src/main/java/**/*.java'],
-)
-RESOURCES = glob(['src/main/resources/**/*'])
-
-java_library(
-  name = 'httpd',
-  srcs = SRCS,
-  resources = RESOURCES,
-  deps = [
-    '//gerrit-antlr:query_exception',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gwtexpui:linker_server',
-    '//gerrit-gwtexpui:server',
-    '//gerrit-launcher:launcher',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-prettify:server',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-util-cli:cli',
-    '//gerrit-util-http:http',
-    '//lib:args4j',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib:mime-util',
-    '//lib/auto:auto-value',
-    '//lib/commons:codec',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
-    '//lib/log:api',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-  ],
-  provided_deps = ['//lib:servlet-api-3_1'],
-  visibility = ['PUBLIC'],
-)
-
-java_sources(
-  name = 'httpd-src',
-  srcs = SRCS + RESOURCES,
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'httpd_tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':httpd',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-util-http:http',
-    '//gerrit-util-http:testutil',
-    '//lib:jimfs',
-    '//lib:junit',
-    '//lib:gson',
-    '//lib:gwtorm',
-    '//lib:guava',
-    '//lib:servlet-api-3_1',
-    '//lib:truth',
-    '//lib/easymock:easymock',
-    '//lib/guice:guice',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.junit:junit',
-    '//lib/joda:joda-time',
-  ],
-  source_under_test = [':httpd'],
-  # TODO(sop) Remove after Buck supports Eclipse
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-httpd/BUILD b/gerrit-httpd/BUILD
index 1341ad1..d39e8f3 100644
--- a/gerrit-httpd/BUILD
+++ b/gerrit-httpd/BUILD
@@ -1,72 +1,80 @@
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+package(default_visibility = ["//visibility:public"])
 
 SRCS = glob(
-  ['src/main/java/**/*.java'],
+    ["src/main/java/**/*.java"],
 )
-RESOURCES = glob(['src/main/resources/**/*'])
+
+RESOURCES = glob(["src/main/resources/**/*"])
 
 java_library(
-  name = 'httpd',
-  srcs = SRCS,
-  resources = RESOURCES,
-  deps = [
-    '//gerrit-antlr:query_exception',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gwtexpui:linker_server',
-    '//gerrit-gwtexpui:server',
-    '//gerrit-launcher:launcher',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-prettify:server',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-util-cli:cli',
-    '//gerrit-util-http:http',
-    '//lib:args4j',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib:mime-util',
-    '//lib:servlet-api-3_1',
-    '//lib/auto:auto-value',
-    '//lib/commons:codec',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
-    '//lib/log:api',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-  ],
-  visibility = ['//visibility:public'],
+    name = "httpd",
+    srcs = SRCS,
+    resources = RESOURCES,
+    deps = [
+        "//gerrit-antlr:query_exception",
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-gwtexpui:linker_server",
+        "//gerrit-gwtexpui:server",
+        "//gerrit-launcher:launcher",
+        "//gerrit-patch-jgit:server",
+        "//gerrit-prettify:server",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//gerrit-util-cli:cli",
+        "//gerrit-util-http:http",
+        "//lib:args4j",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:jsch",
+        "//lib:mime-util",
+        "//lib:servlet-api-3_1",
+        "//lib:soy",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/commons:codec",
+        "//lib/commons:lang",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+    ],
 )
 
 junit_tests(
-  name = 'httpd_tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':httpd',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-util-http:http',
-    '//gerrit-util-http:testutil',
-    '//lib:jimfs',
-    '//lib:junit',
-    '//lib:gson',
-    '//lib:gwtorm',
-    '//lib:guava',
-    '//lib:servlet-api-3_1-without-neverlink',
-    '//lib:truth',
-    '//lib/easymock:easymock',
-    '//lib/guice:guice',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.junit:junit',
-    '//lib/joda:joda-time',
-  ],
+    name = "httpd_tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    deps = [
+        ":httpd",
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//gerrit-util-http:http",
+        "//gerrit-util-http:testutil",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:jimfs",
+        "//lib:junit",
+        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib:soy",
+        "//lib:truth",
+        "//lib/easymock",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/joda:joda-time",
+    ],
 )
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
index 4bf3102..68bbd98 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AdvertisedObjectsCacheKey.java
@@ -25,5 +25,6 @@
   }
 
   public abstract Account.Id account();
+
   public abstract Project.NameKey project();
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java
index bcc5842..0cd4efb 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -21,10 +21,8 @@
 import com.google.inject.Singleton;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.ServletModule;
-
 import java.io.IOException;
 import java.util.Iterator;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -42,8 +40,8 @@
         filter("/*").through(FilterProxy.class);
 
         bind(StopPluginListener.class)
-          .annotatedWith(UniqueAnnotations.create())
-          .to(FilterProxy.class);
+            .annotatedWith(UniqueAnnotations.create())
+            .to(FilterProxy.class);
       }
     };
   }
@@ -87,7 +85,7 @@
     }
 
     private synchronized void cleanUpInitializedFilters() {
-      Iterable<AllRequestFilter> filtersToCleanUp  = initializedFilters;
+      Iterable<AllRequestFilter> filtersToCleanUp = initializedFilters;
       initializedFilters = new DynamicSet<>();
       for (AllRequestFilter filter : filtersToCleanUp) {
         if (filters.contains(filter)) {
@@ -99,8 +97,8 @@
     }
 
     @Override
-    public void doFilter(ServletRequest req, ServletResponse res,
-        final FilterChain last) throws IOException, ServletException {
+    public void doFilter(ServletRequest req, ServletResponse res, final FilterChain last)
+        throws IOException, ServletException {
       final Iterator<AllRequestFilter> itr = filters.iterator();
       new FilterChain() {
         @Override
@@ -108,7 +106,7 @@
             throws IOException, ServletException {
           while (itr.hasNext()) {
             AllRequestFilter filter = itr.next();
-            // To avoid {@code synchronized} on the the whole filtering (and
+            // To avoid {@code synchronized} on the whole filtering (and
             // thereby killing concurrency), we start the below disjunction
             // with an unsynchronized check for containment. This
             // unsynchronized check is always correct if no filters got
@@ -127,8 +125,7 @@
             // it, given that this is really both really improbable and also
             // the "proper" fix for it would basically kill concurrency of
             // webrequests.
-            if (initializedFilters.contains(filter)
-                || initFilterIfNeeded(filter)) {
+            if (initializedFilters.contains(filter) || initFilterIfNeeded(filter)) {
               filter.doFilter(req, res, this);
               return;
             }
@@ -146,16 +143,16 @@
       // FilterConfig around, and reuse it to lazy init the AllRequestFilters.
       filterConfig = config;
 
-      for (AllRequestFilter f: filters) {
+      for (AllRequestFilter f : filters) {
         initFilterIfNeeded(f);
       }
     }
 
     @Override
     public synchronized void destroy() {
-      Iterable<AllRequestFilter> filtersToDestroy  = initializedFilters;
+      Iterable<AllRequestFilter> filtersToDestroy = initializedFilters;
       initializedFilters = new DynamicSet<>();
-      for (AllRequestFilter filter: filtersToDestroy) {
+      for (AllRequestFilter filter : filtersToDestroy) {
         filter.destroy();
       }
     }
@@ -170,10 +167,8 @@
   }
 
   @Override
-  public void init(FilterConfig config) throws ServletException {
-  }
+  public void init(FilterConfig config) throws ServletException {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 }
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 fa2e0e3..5b5a3b0 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
@@ -16,32 +16,33 @@
 
 import static java.util.concurrent.TimeUnit.HOURS;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.httpd.WebSessionManager.Key;
 import com.google.gerrit.httpd.WebSessionManager.Val;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Provider;
 import com.google.inject.servlet.RequestScoped;
-
-import org.eclipse.jgit.http.server.GitSmartHttpTools;
-
 import java.util.EnumSet;
-
 import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.http.server.GitSmartHttpTools;
 
 @RequestScoped
 public abstract class CacheBasedWebSession implements WebSession {
-  private static final String ACCOUNT_COOKIE = "GerritAccount";
+  @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
   protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
 
   private final HttpServletRequest request;
@@ -51,27 +52,30 @@
   private final Provider<AnonymousUser> anonymousProvider;
   private final IdentifiedUser.RequestFactory identified;
   private final EnumSet<AccessPath> okPaths = EnumSet.of(AccessPath.UNKNOWN);
+  private final AccountCache byIdCache;
   private Cookie outCookie;
 
   private Key key;
   private Val val;
   private CurrentUser user;
 
-  protected CacheBasedWebSession(final HttpServletRequest request,
+  protected CacheBasedWebSession(
+      final HttpServletRequest request,
       final HttpServletResponse response,
       final WebSessionManager manager,
       final AuthConfig authConfig,
       final Provider<AnonymousUser> anonymousProvider,
-      final IdentifiedUser.RequestFactory identified) {
+      final IdentifiedUser.RequestFactory identified,
+      final AccountCache byIdCache) {
     this.request = request;
     this.response = response;
     this.manager = manager;
     this.authConfig = authConfig;
     this.anonymousProvider = anonymousProvider;
     this.identified = identified;
+    this.byIdCache = byIdCache;
 
-    if (request.getRequestURI() == null
-        || !GitSmartHttpTools.isGitClient(request)) {
+    if (request.getRequestURI() == null || !GitSmartHttpTools.isGitClient(request)) {
       String cookie = readCookie();
       if (cookie != null) {
         key = new Key(cookie);
@@ -82,11 +86,15 @@
           val = manager.createVal(key, val);
         }
 
-        String token = request.getHeader(HostPageData.XSRF_HEADER_NAME);
-        if (val != null && token != null && token.equals(val.getAuth())) {
-          okPaths.add(AccessPath.REST_API);
+        if (val != null && !checkAccountStatus(val.getAccountId())) {
+          val = null;
         }
       }
+
+      String token = request.getHeader(HostPageData.XSRF_HEADER_NAME);
+      if (val != null && token != null && token.equals(val.getAuth())) {
+        okPaths.add(AccessPath.REST_API);
+      }
     }
   }
 
@@ -109,6 +117,7 @@
   }
 
   @Override
+  @Nullable
   public String getXGerritAuth() {
     return isSignedIn() ? val.getAuth() : null;
   }
@@ -133,7 +142,7 @@
   }
 
   @Override
-  public AccountExternalId.Key getLastLoginExternalId() {
+  public ExternalId.Key getLastLoginExternalId() {
     return val != null ? val.getExternalId() : null;
   }
 
@@ -150,14 +159,19 @@
   }
 
   @Override
-  public void login(final AuthResult res, final boolean rememberMe) {
-    final Account.Id id = res.getAccountId();
-    final AccountExternalId.Key identity = res.getExternalId();
+  public void login(AuthResult res, boolean rememberMe) {
+    Account.Id id = res.getAccountId();
+    ExternalId.Key identity = res.getExternalId();
 
     if (val != null) {
       manager.destroy(key);
     }
 
+    if (!checkAccountStatus(id)) {
+      val = null;
+      return;
+    }
+
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
@@ -188,6 +202,11 @@
     return val != null ? val.getSessionId() : null;
   }
 
+  private boolean checkAccountStatus(Account.Id id) {
+    AccountState accountState = byIdCache.get(id);
+    return accountState != null && accountState.getAccount().isActive();
+  }
+
   private void saveCookie() {
     if (response == null) {
       return;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CanonicalWebUrl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CanonicalWebUrl.java
index 901b180..437ddf3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CanonicalWebUrl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CanonicalWebUrl.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.httpd;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
 import javax.servlet.http.HttpServletRequest;
 
 public class CanonicalWebUrl {
@@ -25,9 +28,7 @@
 
   @Inject
   CanonicalWebUrl(
-      @com.google.gerrit.server.config.CanonicalWebUrl
-      @Nullable
-      Provider<String> provider) {
+      @com.google.gerrit.server.config.CanonicalWebUrl @Nullable Provider<String> provider) {
     configured = provider;
   }
 
@@ -38,10 +39,15 @@
 
   static String computeFromRequest(HttpServletRequest req) {
     StringBuffer url = req.getRequestURL();
-    url.setLength(url.length() - req.getServletPath().length());
-    if (url.charAt(url.length() - 1) != '/') {
-      url.append('/');
+    try {
+      url = new StringBuffer(URLDecoder.decode(url.toString(), UTF_8.name()));
+      url.setLength(url.length() - req.getServletPath().length());
+      if (url.charAt(url.length() - 1) != '/') {
+        url.append('/');
+      }
+      return url.toString();
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException("Unsupported encoding for request URL " + url, e);
     }
-    return url.toString();
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java
index f291621..07893ba 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java
@@ -29,12 +29,8 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.util.Locale;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -43,17 +39,18 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Trust the authentication which is done by the container.
- * <p>
- * Check whether the container has already authenticated the user. If yes, then
- * lookup the account and set the account ID in our current session.
- * <p>
- * This filter should only be configured to run, when authentication is
- * configured to trust container authentication. This filter is intended to
- * protect the {@link GitOverHttpServlet} and its handled URLs, which provide remote
- * repository access over HTTP. It also protects {@link RestApiServlet}.
+ *
+ * <p>Check whether the container has already authenticated the user. If yes, then lookup the
+ * account and set the account ID in our current session.
+ *
+ * <p>This filter should only be configured to run, when authentication is configured to trust
+ * container authentication. This filter is intended to protect the {@link GitOverHttpServlet} and
+ * its handled URLs, which provide remote repository access over HTTP. It also protects {@link
+ * RestApiServlet}.
  */
 @Singleton
 class ContainerAuthFilter implements Filter {
@@ -63,7 +60,8 @@
   private final String loginHttpHeader;
 
   @Inject
-  ContainerAuthFilter(DynamicItem<WebSession> session,
+  ContainerAuthFilter(
+      DynamicItem<WebSession> session,
       AccountCache accountCache,
       AuthConfig authConfig,
       @GerritServerConfig Config config) {
@@ -71,22 +69,18 @@
     this.accountCache = accountCache;
     this.config = config;
 
-    loginHttpHeader = firstNonNull(
-        emptyToNull(authConfig.getLoginHttpHeader()),
-        AUTHORIZATION);
+    loginHttpHeader = firstNonNull(emptyToNull(authConfig.getLoginHttpHeader()), AUTHORIZATION);
   }
 
   @Override
-  public void init(FilterConfig config) {
-  }
+  public void init(FilterConfig config) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     HttpServletRequest req = (HttpServletRequest) request;
     HttpServletResponse rsp = (HttpServletResponse) response;
 
@@ -95,8 +89,7 @@
     }
   }
 
-  private boolean verify(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException {
+  private boolean verify(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     String username = RemoteUserUtil.getRemoteUser(req, loginHttpHeader);
     if (username == null) {
       rsp.sendError(SC_FORBIDDEN);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java
index 183e015..11342be 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java
@@ -52,8 +52,8 @@
     return out.toString();
   }
 
-  private static void encode3to4(final StringBuilder out, final byte[] in,
-      final int inOffset, final int numSigBytes) {
+  private static void encode3to4(
+      final StringBuilder out, final byte[] in, final int inOffset, final int numSigBytes) {
     //           1         2         3
     // 01234567890123456789012345678901 Bit position
     // --------000000001111111122222222 Array position from threeBytes
@@ -66,9 +66,10 @@
     // We have to shift left 24 in order to flush out the 1's that appear
     // when Java treats a value as negative that is cast from a byte to an int.
     //
-    int inBuff = ( numSigBytes > 0 ? ((in[ inOffset     ] << 24) >>>  8) : 0 )
-               | ( numSigBytes > 1 ? ((in[ inOffset + 1 ] << 24) >>> 16) : 0 )
-               | ( numSigBytes > 2 ? ((in[ inOffset + 2 ] << 24) >>> 24) : 0 );
+    int inBuff =
+        (numSigBytes > 0 ? ((in[inOffset] << 24) >>> 8) : 0)
+            | (numSigBytes > 1 ? ((in[inOffset + 1] << 24) >>> 16) : 0)
+            | (numSigBytes > 2 ? ((in[inOffset + 2] << 24) >>> 24) : 0);
 
     switch (numSigBytes) {
       case 3:
@@ -94,6 +95,5 @@
     }
   }
 
-  private CookieBase64() {
-  }
+  private CookieBase64() {}
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java
index 74dc56f..825505c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java
@@ -11,22 +11,18 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.List;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 class DirectChangeByCommit extends HttpServlet {
   private static final long serialVersionUID = 1L;
-  private static final Logger log =
-      LoggerFactory.getLogger(DirectChangeByCommit.class);
+  private static final Logger log = LoggerFactory.getLogger(DirectChangeByCommit.class);
 
   private final Changes changes;
 
@@ -36,8 +32,8 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     String query = CharMatcher.is('/').trimTrailingFrom(req.getPathInfo());
     List<ChangeInfo> results;
     try {
@@ -50,8 +46,7 @@
     if (results.size() == 1) {
       // If exactly one change matches, link to that change.
       // TODO Link to a specific patch set, if one matched.
-      token = PageLinks.toChange(
-          new Change.Id(results.iterator().next()._number));
+      token = PageLinks.toChange(new Change.Id(results.iterator().next()._number));
     } else {
       // Otherwise, link to the query page.
       token = PageLinks.toChangeQuery(query);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritAuthModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritAuthModule.java
new file mode 100644
index 0000000..c0ef207
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritAuthModule.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.LFS_URL_WO_AUTH_REGEX;
+
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.servlet.ServletModule;
+import javax.servlet.Filter;
+
+/** Configures filter for authenticating REST requests. */
+public class GerritAuthModule extends ServletModule {
+  private static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
+  private final AuthConfig authConfig;
+
+  @Inject
+  GerritAuthModule(AuthConfig authConfig) {
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  protected void configureServlets() {
+    Class<? extends Filter> authFilter = retreiveAuthFilterFromConfig(authConfig);
+
+    filterRegex(NOT_AUTHORIZED_LFS_URL_REGEX).through(authFilter);
+    filter("/a/*").through(authFilter);
+  }
+
+  static Class<? extends Filter> retreiveAuthFilterFromConfig(AuthConfig authConfig) {
+    Class<? extends Filter> authFilter;
+    if (authConfig.isTrustContainerAuth()) {
+      authFilter = ContainerAuthFilter.class;
+    } else {
+      authFilter =
+          authConfig.getGitBasicAuthPolicy() == GitBasicAuthPolicy.OAUTH
+              ? ProjectOAuthFilter.class
+              : ProjectBasicAuthFilter.class;
+    }
+    return authFilter;
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
deleted file mode 100644
index c1a0f44..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritOptions.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2013 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.httpd;
-
-import org.eclipse.jgit.lib.Config;
-
-public class GerritOptions {
-  private final boolean headless;
-  private final boolean slave;
-  private final boolean enablePolyGerrit;
-  private final boolean forcePolyGerritDev;
-
-  public GerritOptions(Config cfg, boolean headless, boolean slave,
-      boolean forcePolyGerritDev) {
-    this.headless = headless;
-    this.slave = slave;
-    this.enablePolyGerrit = forcePolyGerritDev
-        || cfg.getBoolean("gerrit", null, "enablePolyGerrit", false);
-    this.forcePolyGerritDev = forcePolyGerritDev;
-  }
-
-  public boolean enableDefaultUi() {
-    return !headless && !enablePolyGerrit;
-  }
-
-  public boolean enableMasterFeatures() {
-    return !slave;
-  }
-
-  public boolean enablePolyGerrit() {
-    return !headless && enablePolyGerrit;
-  }
-
-  public boolean forcePolyGerritDev() {
-    return !headless && forcePolyGerritDev;
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java
index 019efb4..bbcd977 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java
@@ -21,22 +21,16 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
 import javax.servlet.ServletResponse;
+import org.eclipse.jgit.lib.Config;
 
-/**
- * Stores user as a request attribute, so servlets can access it outside of the
- * request scope.
- */
+/** Stores user as a request attribute, so servlets can access it outside of the request scope. */
 @Singleton
 public class GetUserFilter implements Filter {
 
@@ -67,8 +61,7 @@
   }
 
   @Override
-  public void doFilter(
-      ServletRequest req, ServletResponse resp, FilterChain chain)
+  public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
       throws IOException, ServletException {
     CurrentUser user = userProvider.get();
     if (user != null && user.isIdentifiedUser()) {
@@ -83,10 +76,8 @@
   }
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void init(FilterConfig arg0) {
-  }
+  public void init(FilterConfig arg0) {}
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
index c85bb7a..8400d60 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java
@@ -14,8 +14,7 @@
 
 package com.google.gerrit.httpd;
 
-import static com.google.gerrit.reviewdb.client.AuthType.OAUTH;
-import static com.google.gerrit.httpd.plugins.LfsPluginServlet.LFS_REST;
+import static com.google.gerrit.httpd.GitOverHttpServlet.URL_REGEX;
 
 import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
 import com.google.gerrit.server.config.AuthConfig;
@@ -23,50 +22,23 @@
 import com.google.inject.Inject;
 import com.google.inject.servlet.ServletModule;
 
-import javax.servlet.Filter;
-
 /** Configures Git access over HTTP with authentication. */
 public class GitOverHttpModule extends ServletModule {
-  private static final String LFS_URL_REGEX =
-      "^(?:(?!/a/))" + LFS_REST;
-
   private final AuthConfig authConfig;
   private final DownloadConfig downloadConfig;
 
   @Inject
-  GitOverHttpModule(AuthConfig authConfig,
-      DownloadConfig downloadConfig) {
+  GitOverHttpModule(AuthConfig authConfig, DownloadConfig downloadConfig) {
     this.authConfig = authConfig;
     this.downloadConfig = downloadConfig;
   }
 
   @Override
   protected void configureServlets() {
-    Class<? extends Filter> authFilter;
-    if (authConfig.isTrustContainerAuth()) {
-      authFilter = ContainerAuthFilter.class;
-    } else if (authConfig.isGitBasicAuth()) {
-      if (authConfig.getAuthType() == OAUTH) {
-        authFilter = ProjectOAuthFilter.class;
-      } else {
-        authFilter = ProjectBasicAuthFilter.class;
-      }
-    } else {
-      authFilter = ProjectDigestFilter.class;
+    if (downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
+        || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP)) {
+      filterRegex(URL_REGEX).through(GerritAuthModule.retreiveAuthFilterFromConfig(authConfig));
+      serveRegex(URL_REGEX).with(GitOverHttpServlet.class);
     }
-
-    if (isHttpEnabled()) {
-      String git = GitOverHttpServlet.URL_REGEX;
-      filterRegex(git).through(authFilter);
-      serveRegex(git).with(GitOverHttpServlet.class);
-    }
-
-    filterRegex(LFS_URL_REGEX).through(authFilter);
-    filter("/a/*").through(authFilter);
-  }
-
-  private boolean isHttpEnabled() {
-    return downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.ANON_HTTP)
-        || downloadConfig.getDownloadSchemes().contains(CoreDownloadSchemes.HTTP);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index bae6d19..7a5956e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -42,7 +42,19 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
-
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.http.server.GitServlet;
 import org.eclipse.jgit.http.server.GitSmartHttpTools;
@@ -62,21 +74,6 @@
 import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
 import org.eclipse.jgit.transport.resolver.UploadPackFactory;
 
-import java.io.IOException;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
 /** Serves Git repositories over HTTP. */
 @Singleton
 public class GitOverHttpServlet extends GitServlet {
@@ -87,6 +84,7 @@
   private static final String ID_CACHE = "adv_bases";
 
   public static final String URL_REGEX;
+
   static {
     StringBuilder url = new StringBuilder();
     url.append("^(?:/a)?(?:/p/|/)(.*/(?:info/refs");
@@ -110,24 +108,25 @@
       bind(Resolver.class);
       bind(UploadFactory.class);
       bind(UploadFilter.class);
-      bind(new TypeLiteral<ReceivePackFactory<HttpServletRequest>>() {}).to(
-          enableReceive ? ReceiveFactory.class : DisabledReceiveFactory.class);
+      bind(new TypeLiteral<ReceivePackFactory<HttpServletRequest>>() {})
+          .to(enableReceive ? ReceiveFactory.class : DisabledReceiveFactory.class);
       bind(ReceiveFilter.class);
-      install(new CacheModule() {
-        @Override
-        protected void configure() {
-          cache(ID_CACHE,
-              AdvertisedObjectsCacheKey.class,
-              new TypeLiteral<Set<ObjectId>>() {})
-            .maximumWeight(4096)
-            .expireAfterWrite(10, TimeUnit.MINUTES);
-        }
-      });
+      install(
+          new CacheModule() {
+            @Override
+            protected void configure() {
+              cache(ID_CACHE, AdvertisedObjectsCacheKey.class, new TypeLiteral<Set<ObjectId>>() {})
+                  .maximumWeight(4096)
+                  .expireAfterWrite(10, TimeUnit.MINUTES);
+            }
+          });
     }
   }
 
   @Inject
-  GitOverHttpServlet(Resolver resolver, UploadFactory upload,
+  GitOverHttpServlet(
+      Resolver resolver,
+      UploadFactory upload,
       UploadFilter uploadFilter,
       ReceivePackFactory<HttpServletRequest> receive,
       ReceiveFilter receiveFilter) {
@@ -146,8 +145,7 @@
     private final ProjectControl.Factory projectControlFactory;
 
     @Inject
-    Resolver(GitRepositoryManager manager,
-        ProjectControl.Factory projectControlFactory) {
+    Resolver(GitRepositoryManager manager, ProjectControl.Factory projectControlFactory) {
       this.manager = manager;
       this.projectControlFactory = projectControlFactory;
     }
@@ -155,7 +153,7 @@
     @Override
     public Repository open(HttpServletRequest req, String projectName)
         throws RepositoryNotFoundException, ServiceNotAuthorizedException,
-        ServiceNotEnabledException {
+            ServiceNotEnabledException {
       while (projectName.endsWith("/")) {
         projectName = projectName.substring(0, projectName.length() - 1);
       }
@@ -191,8 +189,7 @@
       try {
         return manager.openRepository(pc.getProject().getNameKey());
       } catch (IOException e) {
-        throw new RepositoryNotFoundException(
-            pc.getProject().getNameKey().get(), e);
+        throw new RepositoryNotFoundException(pc.getProject().getNameKey().get(), e);
       }
     }
   }
@@ -203,7 +200,8 @@
     private final DynamicSet<PostUploadHook> postUploadHooks;
 
     @Inject
-    UploadFactory(TransferConfig tc,
+    UploadFactory(
+        TransferConfig tc,
         DynamicSet<PreUploadHook> preUploadHooks,
         DynamicSet<PostUploadHook> postUploadHooks) {
       this.config = tc;
@@ -216,10 +214,8 @@
       UploadPack up = new UploadPack(repo);
       up.setPackConfig(config.getPackConfig());
       up.setTimeout(config.getTimeout());
-      up.setPreUploadHook(PreUploadHookChain.newChain(
-          Lists.newArrayList(preUploadHooks)));
-      up.setPostUploadHook(
-          PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
+      up.setPreUploadHook(PreUploadHookChain.newChain(Lists.newArrayList(preUploadHooks)));
+      up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
       return up;
     }
   }
@@ -232,7 +228,9 @@
     private final UploadValidators.Factory uploadValidatorsFactory;
 
     @Inject
-    UploadFilter(Provider<ReviewDb> db, TagCache tagCache,
+    UploadFilter(
+        Provider<ReviewDb> db,
+        TagCache tagCache,
         ChangeNotes.Factory changeNotesFactory,
         @Nullable SearchingChangeCacheImpl changeCache,
         UploadValidators.Factory uploadValidatorsFactory) {
@@ -244,16 +242,18 @@
     }
 
     @Override
-    public void doFilter(ServletRequest request, ServletResponse response,
-        FilterChain next) throws IOException, ServletException {
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain next)
+        throws IOException, ServletException {
       // The Resolver above already checked READ access for us.
       Repository repo = ServletUtils.getRepository(request);
       ProjectControl pc = (ProjectControl) request.getAttribute(ATT_CONTROL);
       UploadPack up = (UploadPack) request.getAttribute(ServletUtils.ATTRIBUTE_HANDLER);
 
       if (!pc.canRunUploadPack()) {
-        GitSmartHttpTools.sendError((HttpServletRequest) request,
-            (HttpServletResponse) response, HttpServletResponse.SC_FORBIDDEN,
+        GitSmartHttpTools.sendError(
+            (HttpServletRequest) request,
+            (HttpServletResponse) response,
+            HttpServletResponse.SC_FORBIDDEN,
             "upload-pack not permitted on this server");
         return;
       }
@@ -261,21 +261,20 @@
       // may have been overridden by a proxy server -- we'll try to avoid this.
       UploadValidators uploadValidators =
           uploadValidatorsFactory.create(pc.getProject(), repo, request.getRemoteHost());
-      up.setPreUploadHook(PreUploadHookChain.newChain(
-          Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
-      up.setAdvertiseRefsHook(new VisibleRefFilter(tagCache, changeNotesFactory,
-          changeCache, repo, pc, db.get(), true));
+      up.setPreUploadHook(
+          PreUploadHookChain.newChain(Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
+      up.setAdvertiseRefsHook(
+          new VisibleRefFilter(
+              tagCache, changeNotesFactory, changeCache, repo, pc, db.get(), true));
 
       next.doFilter(request, response);
     }
 
     @Override
-    public void init(FilterConfig config) {
-    }
+    public void init(FilterConfig config) {}
 
     @Override
-    public void destroy() {
-    }
+    public void destroy() {}
   }
 
   static class ReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
@@ -305,8 +304,7 @@
     }
   }
 
-  static class DisabledReceiveFactory implements
-      ReceivePackFactory<HttpServletRequest> {
+  static class DisabledReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
     @Override
     public ReceivePack create(HttpServletRequest req, Repository db)
         throws ServiceNotEnabledException {
@@ -318,16 +316,14 @@
     private final Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache;
 
     @Inject
-    ReceiveFilter(
-        @Named(ID_CACHE) Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache) {
+    ReceiveFilter(@Named(ID_CACHE) Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache) {
       this.cache = cache;
     }
 
     @Override
-    public void doFilter(ServletRequest request, ServletResponse response,
-        FilterChain chain) throws IOException, ServletException {
-      boolean isGet =
-        "GET".equalsIgnoreCase(((HttpServletRequest) request).getMethod());
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+        throws IOException, ServletException {
+      boolean isGet = "GET".equalsIgnoreCase(((HttpServletRequest) request).getMethod());
 
       ReceiveCommits rc = (ReceiveCommits) request.getAttribute(ATT_RC);
       ReceivePack rp = rc.getReceivePack();
@@ -336,16 +332,20 @@
       Project.NameKey projectName = pc.getProject().getNameKey();
 
       if (!pc.canRunReceivePack()) {
-        GitSmartHttpTools.sendError((HttpServletRequest) request,
-            (HttpServletResponse) response, HttpServletResponse.SC_FORBIDDEN,
+        GitSmartHttpTools.sendError(
+            (HttpServletRequest) request,
+            (HttpServletResponse) response,
+            HttpServletResponse.SC_FORBIDDEN,
             "receive-pack not permitted on this server");
         return;
       }
 
       final Capable s = rc.canUpload();
       if (s != Capable.OK) {
-        GitSmartHttpTools.sendError((HttpServletRequest) request,
-            (HttpServletResponse) response, HttpServletResponse.SC_FORBIDDEN,
+        GitSmartHttpTools.sendError(
+            (HttpServletRequest) request,
+            (HttpServletResponse) response,
+            HttpServletResponse.SC_FORBIDDEN,
             "\n" + s.getMessage());
         return;
       }
@@ -360,8 +360,8 @@
         return;
       }
 
-      AdvertisedObjectsCacheKey cacheKey = AdvertisedObjectsCacheKey.create(
-          pc.getUser().getAccountId(), projectName);
+      AdvertisedObjectsCacheKey cacheKey =
+          AdvertisedObjectsCacheKey.create(pc.getUser().getAccountId(), projectName);
 
       if (isGet) {
         cache.invalidate(cacheKey);
@@ -376,17 +376,14 @@
       chain.doFilter(request, response);
 
       if (isGet) {
-        cache.put(cacheKey, Collections.unmodifiableSet(
-            new HashSet<>(rp.getAdvertisedObjects())));
+        cache.put(cacheKey, Collections.unmodifiableSet(new HashSet<>(rp.getAdvertisedObjects())));
       }
     }
 
     @Override
-    public void init(FilterConfig arg0) {
-    }
+    public void init(FilterConfig arg0) {}
 
     @Override
-    public void destroy() {
-    }
+    public void destroy() {}
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
index 4b68f02..c77f76f 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.httpd.WebSessionManager.Val;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser.RequestFactory;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
@@ -30,7 +31,6 @@
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.name.Named;
 import com.google.inject.servlet.RequestScoped;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
@@ -42,14 +42,15 @@
       protected void configure() {
         persist(WebSessionManager.CACHE_NAME, String.class, Val.class)
             .maximumWeight(1024) // reasonable default for many sites
-            .expireAfterWrite(CacheBasedWebSession.MAX_AGE_MINUTES, MINUTES) // expire sessions if they are inactive
+            .expireAfterWrite(
+                CacheBasedWebSession.MAX_AGE_MINUTES,
+                MINUTES) // expire sessions if they are inactive
         ;
-        install(new FactoryModuleBuilder()
-            .build(WebSessionManagerFactory.class));
+        install(new FactoryModuleBuilder().build(WebSessionManagerFactory.class));
         DynamicItem.itemOf(binder(), WebSession.class);
         DynamicItem.bind(binder(), WebSession.class)
-          .to(H2CacheBasedWebSession.class)
-          .in(RequestScoped.class);
+            .to(H2CacheBasedWebSession.class)
+            .in(RequestScoped.class);
       }
     };
   }
@@ -62,8 +63,15 @@
       @Named(WebSessionManager.CACHE_NAME) Cache<String, Val> cache,
       AuthConfig authConfig,
       Provider<AnonymousUser> anonymousProvider,
-      RequestFactory identified) {
-    super(request, response, managerFactory.create(cache), authConfig,
-        anonymousProvider, identified);
+      RequestFactory identified,
+      AccountCache byIdCache) {
+    super(
+        request,
+        response,
+        managerFactory.create(cache),
+        authConfig,
+        anonymousProvider,
+        identified,
+        byIdCache);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HtmlDomUtil.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HtmlDomUtil.java
index 95f4536..9acc754 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HtmlDomUtil.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HtmlDomUtil.java
@@ -17,13 +17,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.io.ByteStreams;
-
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-import org.w3c.dom.Node;
-import org.w3c.dom.NodeList;
-import org.xml.sax.SAXException;
-
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -33,7 +26,6 @@
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.util.zip.GZIPOutputStream;
-
 import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.ParserConfigurationException;
@@ -47,6 +39,11 @@
 import javax.xml.xpath.XPathExpression;
 import javax.xml.xpath.XPathExpressionException;
 import javax.xml.xpath.XPathFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
 
 /** Utility functions to deal with HTML using W3C DOM operations. */
 public class HtmlDomUtil {
@@ -83,8 +80,7 @@
       serializer.setOutputProperty(OutputKeys.ENCODING, ENC.name());
       serializer.setOutputProperty(OutputKeys.METHOD, "html");
       serializer.setOutputProperty(OutputKeys.INDENT, "no");
-      serializer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC,
-          HtmlDomUtil.HTML_STRICT);
+      serializer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC, HtmlDomUtil.HTML_STRICT);
       serializer.transform(domSource, streamResult);
       return out.toString();
     } catch (TransformerException e) {
@@ -145,8 +141,7 @@
   }
 
   /** Parse an XHTML file from our CLASSPATH and return the instance. */
-  public static Document parseFile(Class<?> context, String name)
-      throws IOException {
+  public static Document parseFile(Class<?> context, String name) throws IOException {
     try (InputStream in = context.getResourceAsStream(name)) {
       if (in == null) {
         return null;
@@ -175,8 +170,7 @@
   }
 
   /** Read a Read a UTF-8 text file from our CLASSPATH and return it. */
-  public static String readFile(Class<?> context, String name)
-      throws IOException {
+  public static String readFile(Class<?> context, String name) throws IOException {
     try (InputStream in = context.getResourceAsStream(name)) {
       if (in == null) {
         return null;
@@ -201,8 +195,7 @@
   }
 
   /** Read a UTF-8 text file from the local drive. */
-  public static String readFile(Path parentDir, String name)
-      throws IOException {
+  public static String readFile(Path parentDir, String name) throws IOException {
     if (parentDir == null) {
       return null;
     }
@@ -216,8 +209,7 @@
     }
   }
 
-  private static DocumentBuilder newBuilder()
-      throws ParserConfigurationException {
+  private static DocumentBuilder newBuilder() throws ParserConfigurationException {
     DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
     factory.setValidating(false);
     factory.setExpandEntityReferences(false);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
index 0492e86..6411ee5 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
@@ -20,10 +20,8 @@
 import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
-
-import org.eclipse.jgit.lib.Config;
-
 import javax.servlet.http.HttpServletRequest;
+import org.eclipse.jgit.lib.Config;
 
 /** Sets {@code CanonicalWebUrl} to current HTTP request if not configured. */
 public class HttpCanonicalWebUrlProvider extends CanonicalWebUrlProvider {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java
index 88584eb..00c18af 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java
@@ -26,9 +26,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -43,7 +41,8 @@
   private final AuditService audit;
 
   @Inject
-  protected HttpLogoutServlet(final AuthConfig authConfig,
+  protected HttpLogoutServlet(
+      final AuthConfig authConfig,
       final DynamicItem<WebSession> webSession,
       @CanonicalWebUrl @Nullable final Provider<String> urlProvider,
       final AuditService audit) {
@@ -53,8 +52,8 @@
     this.audit = audit;
   }
 
-  protected void doLogout(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doLogout(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     webSession.get().logout();
     if (logoutUrl != null) {
       rsp.sendRedirect(logoutUrl);
@@ -74,8 +73,8 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
 
     final String sid = webSession.get().getSessionId();
     final CurrentUser currentUser = webSession.get().getUser();
@@ -85,9 +84,7 @@
     try {
       doLogout(req, rsp);
     } finally {
-      audit.dispatch(new AuditEvent(sid, currentUser,
-          what, when, null, null));
+      audit.dispatch(new AuditEvent(sid, currentUser, what, when, null, null));
     }
   }
-
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
index 9e0b042..2dedd86 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
@@ -18,12 +18,10 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.servlet.RequestScoped;
-
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.net.UnknownHostException;
-
 import javax.servlet.http.HttpServletRequest;
 
 @RequestScoped
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRequestContext.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRequestContext.java
index adad03f..6cc7a17 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRequestContext.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRequestContext.java
@@ -27,8 +27,7 @@
   private final RequestScopedReviewDbProvider provider;
 
   @Inject
-  HttpRequestContext(DynamicItem<WebSession> session,
-      RequestScopedReviewDbProvider provider) {
+  HttpRequestContext(DynamicItem<WebSession> session, RequestScopedReviewDbProvider provider) {
     this.session = session;
     this.provider = provider;
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
new file mode 100644
index 0000000..3a43e24
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpServletResponseRecorder.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * HttpServletResponse wrapper to allow response status code override.
+ *
+ * <p>Differently from the normal HttpServletResponse, this class allows multiple filters to
+ * override the response http status code.
+ */
+public class HttpServletResponseRecorder extends HttpServletResponseWrapper {
+  private static final Logger log = LoggerFactory.getLogger(HttpServletResponseWrapper.class);
+  private static final String LOCATION_HEADER = "Location";
+
+  private int status;
+  private String statusMsg = "";
+  private Map<String, String> headers = new HashMap<>();
+
+  /**
+   * Constructs a response recorder wrapping the given response.
+   *
+   * @param response the response to be wrapped
+   */
+  public HttpServletResponseRecorder(HttpServletResponse response) {
+    super(response);
+  }
+
+  @Override
+  public void sendError(int sc) throws IOException {
+    this.status = sc;
+  }
+
+  @Override
+  public void sendError(int sc, String msg) throws IOException {
+    this.status = sc;
+    this.statusMsg = msg;
+  }
+
+  @Override
+  public void sendRedirect(String location) throws IOException {
+    this.status = SC_MOVED_TEMPORARILY;
+    setHeader(LOCATION_HEADER, location);
+  }
+
+  @Override
+  public void setHeader(String name, String value) {
+    super.setHeader(name, value);
+    headers.put(name, value);
+  }
+
+  @SuppressWarnings("all")
+  // @Override is omitted for backwards compatibility with servlet-api 2.5
+  // TODO: Remove @SuppressWarnings and add @Override when Google upgrades
+  //       to servlet-api 3.1
+  public int getStatus() {
+    return status;
+  }
+
+  void play() throws IOException {
+    if (status != 0) {
+      log.debug("Replaying {} {}", status, statusMsg);
+
+      if (status == SC_MOVED_TEMPORARILY) {
+        super.sendRedirect(headers.get(LOCATION_HEADER));
+      } else {
+        super.sendError(status, statusMsg);
+      }
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java
index a4874a9..87de003 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/LoginUrlToken.java
@@ -17,7 +17,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.PageLinks;
-
 import javax.servlet.http.HttpServletRequest;
 
 public class LoginUrlToken {
@@ -31,6 +30,5 @@
     return CharMatcher.is('/').trimLeadingFrom(token);
   }
 
-  private LoginUrlToken() {
-  }
+  private LoginUrlToken() {}
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index fab0aeb..3358976 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -34,14 +34,8 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.apache.commons.codec.binary.Base64;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.Locale;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -51,22 +45,23 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponseWrapper;
+import org.apache.commons.codec.binary.Base64;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Authenticates the current user by HTTP basic authentication.
- * <p>
- * The current HTTP request is authenticated by looking up the username and
- * password from the Base64 encoded Authorization header and validating them
- * against any username/password configured authentication system in Gerrit.
- * This filter is intended only to protect the {@link GitOverHttpServlet} and
- * its handled URLs, which provide remote repository access over HTTP.
+ *
+ * <p>The current HTTP request is authenticated by looking up the username and password from the
+ * Base64 encoded Authorization header and validating them against any username/password configured
+ * authentication system in Gerrit. This filter is intended only to protect the {@link
+ * GitOverHttpServlet} and its handled URLs, which provide remote repository access over HTTP.
  *
  * @see <a href="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a>
  */
 @Singleton
 class ProjectBasicAuthFilter implements Filter {
-  private static final Logger log = LoggerFactory
-      .getLogger(ProjectBasicAuthFilter.class);
+  private static final Logger log = LoggerFactory.getLogger(ProjectBasicAuthFilter.class);
 
   public static final String REALM_NAME = "Gerrit Code Review";
   private static final String AUTHORIZATION = "Authorization";
@@ -78,8 +73,10 @@
   private final AuthConfig authConfig;
 
   @Inject
-  ProjectBasicAuthFilter(DynamicItem<WebSession> session,
-      AccountCache accountCache, AccountManager accountManager,
+  ProjectBasicAuthFilter(
+      DynamicItem<WebSession> session,
+      AccountCache accountCache,
+      AccountManager accountManager,
       AuthConfig authConfig) {
     this.session = session;
     this.accountCache = accountCache;
@@ -88,16 +85,14 @@
   }
 
   @Override
-  public void init(FilterConfig config) {
-  }
+  public void init(FilterConfig config) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     HttpServletRequest req = (HttpServletRequest) request;
     Response rsp = new Response((HttpServletResponse) response);
 
@@ -106,8 +101,7 @@
     }
   }
 
-  private boolean verify(HttpServletRequest req, Response rsp)
-      throws IOException {
+  private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
     final String hdr = req.getHeader(AUTHORIZATION);
     if (hdr == null || !hdr.startsWith(LIT_BASIC)) {
       // Allow an anonymous connection through, or it might be using a
@@ -115,8 +109,7 @@
       return true;
     }
 
-    final byte[] decoded =
-        Base64.decodeBase64(hdr.substring(LIT_BASIC.length()));
+    final byte[] decoded = Base64.decodeBase64(hdr.substring(LIT_BASIC.length()));
     String usernamePassword = new String(decoded, encoding(req));
     int splitPos = usernamePassword.indexOf(':');
     if (splitPos < 1) {
@@ -136,8 +129,10 @@
 
     final AccountState who = accountCache.getByUsername(username);
     if (who == null || !who.getAccount().isActive()) {
-      log.warn("Authentication failed for " + username
-          + ": account inactive or not provisioned in Gerrit");
+      log.warn(
+          "Authentication failed for "
+              + username
+              + ": account inactive or not provisioned in Gerrit");
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -145,7 +140,7 @@
     GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
         || gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
-      if (passwordMatchesTheUserGeneratedOne(who, username, password)) {
+      if (who.checkPassword(password, username)) {
         return succeedAuthentication(who);
       }
     }
@@ -162,13 +157,15 @@
       setUserIdentified(whoAuthResult.getAccountId());
       return true;
     } catch (NoSuchUserException e) {
-      if (password.equals(who.getPassword(who.getUserName()))) {
+      if (who.checkPassword(password, who.getUserName())) {
         return succeedAuthentication(who);
       }
       log.warn("Authentication failed for " + username, e);
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AuthenticationFailedException e) {
+      // This exception is thrown if the user provided wrong credentials, we don't need to log a
+      // stacktrace for it.
       log.warn("Authentication failed for " + username + ": " + e.getMessage());
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
@@ -184,10 +181,9 @@
     return true;
   }
 
-  private boolean failAuthentication(Response rsp, String username)
-      throws IOException {
-    log.warn("Authentication failed for {}: password does not match the one"
-        + " stored in Gerrit", username);
+  private boolean failAuthentication(Response rsp, String username) throws IOException {
+    log.warn(
+        "Authentication failed for {}: password does not match the one stored in Gerrit", username);
     rsp.sendError(SC_UNAUTHORIZED);
     return false;
   }
@@ -199,13 +195,6 @@
     ws.setAccessPathOk(AccessPath.REST_API, true);
   }
 
-  private boolean passwordMatchesTheUserGeneratedOne(AccountState who,
-      String username, String password) {
-    String accountPassword = who.getPassword(username);
-    return accountPassword != null && password != null
-        && accountPassword.equals(password);
-  }
-
   private String encoding(HttpServletRequest req) {
     return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java
deleted file mode 100644
index f66f397..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectDigestFilter.java
+++ /dev/null
@@ -1,341 +0,0 @@
-// Copyright (C) 2010 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.httpd;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.server.AccessPath;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtjsonrpc.server.SignedToken;
-import com.google.gwtjsonrpc.server.XsrfException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
-import java.io.IOException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletContext;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpServletResponseWrapper;
-
-/**
- * Authenticates the current user by HTTP digest authentication.
- * <p>
- * The current HTTP request is authenticated by looking up the username from the
- * Authorization header and checking the digest response against the stored
- * password. This filter is intended only to protect the {@link GitOverHttpServlet}
- * and its handled URLs, which provide remote repository access over HTTP.
- *
- * @see <a href="http://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a>
- */
-@Singleton
-class ProjectDigestFilter implements Filter {
-  public static final String REALM_NAME = "Gerrit Code Review";
-  private static final String AUTHORIZATION = "Authorization";
-
-  private final Provider<String> urlProvider;
-  private final DynamicItem<WebSession> session;
-  private final AccountCache accountCache;
-  private final Config config;
-  private final SignedToken tokens;
-  private ServletContext context;
-
-  @Inject
-  ProjectDigestFilter(@CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      DynamicItem<WebSession> session, AccountCache accountCache,
-      @GerritServerConfig Config config) throws XsrfException {
-    this.urlProvider = urlProvider;
-    this.session = session;
-    this.accountCache = accountCache;
-    this.config = config;
-    this.tokens = new SignedToken((int) SECONDS.convert(1, HOURS));
-  }
-
-  @Override
-  public void init(FilterConfig config) {
-    context = config.getServletContext();
-  }
-
-  @Override
-  public void destroy() {
-  }
-
-  @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
-    HttpServletRequest req = (HttpServletRequest) request;
-    Response rsp = new Response(req, (HttpServletResponse) response);
-
-    if (verify(req, rsp)) {
-      chain.doFilter(req, rsp);
-    }
-  }
-
-  private boolean verify(HttpServletRequest req, Response rsp)
-      throws IOException {
-    final String hdr = req.getHeader(AUTHORIZATION);
-    if (hdr == null || !hdr.startsWith("Digest ")) {
-      // Allow an anonymous connection through, or it might be using a
-      // session cookie instead of digest authentication.
-      return true;
-    }
-
-    final Map<String, String> p = parseAuthorization(hdr);
-    final String user = p.get("username");
-    final String realm = p.get("realm");
-    final String nonce = p.get("nonce");
-    final String uri = p.get("uri");
-    final String response = p.get("response");
-    final String qop = p.get("qop");
-    final String nc = p.get("nc");
-    final String cnonce = p.get("cnonce");
-    final String method = req.getMethod();
-
-    if (user == null //
-        || realm == null //
-        || nonce == null //
-        || uri == null //
-        || response == null //
-        || !"auth".equals(qop) //
-        || !REALM_NAME.equals(realm)) {
-      context.log("Invalid header: " + AUTHORIZATION + ": " + hdr);
-      rsp.sendError(SC_FORBIDDEN);
-      return false;
-    }
-
-    String username = user;
-    if (config.getBoolean("auth", "userNameToLowerCase", false)) {
-      username = username.toLowerCase(Locale.US);
-    }
-
-    final AccountState who = accountCache.getByUsername(username);
-    if (who == null || ! who.getAccount().isActive()) {
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-
-    final String passwd = who.getPassword(username);
-    if (passwd == null) {
-      rsp.sendError(SC_UNAUTHORIZED);
-      return false;
-    }
-
-    final String A1 = user + ":" + realm + ":" + passwd;
-    final String A2 = method + ":" + uri;
-    final String expect =
-        KD(H(A1), nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + H(A2));
-
-    if (expect.equals(response)) {
-      try {
-        if (tokens.checkToken(nonce, "") != null) {
-          WebSession ws = session.get();
-          ws.setUserAccountId(who.getAccount().getId());
-          ws.setAccessPathOk(AccessPath.GIT, true);
-          ws.setAccessPathOk(AccessPath.REST_API, true);
-          return true;
-
-        }
-        rsp.stale = true;
-        rsp.sendError(SC_UNAUTHORIZED);
-        return false;
-      } catch (XsrfException e) {
-        context.log("Error validating nonce for digest authentication", e);
-        rsp.sendError(SC_INTERNAL_SERVER_ERROR);
-        return false;
-      }
-
-    }
-    rsp.sendError(SC_UNAUTHORIZED);
-    return false;
-  }
-
-  private static String H(String data) {
-    MessageDigest md = newMD5();
-    md.update(data.getBytes(UTF_8));
-    return LHEX(md.digest());
-  }
-
-  private static String KD(String secret, String data) {
-    MessageDigest md = newMD5();
-    md.update(secret.getBytes(UTF_8));
-    md.update((byte) ':');
-    md.update(data.getBytes(UTF_8));
-    return LHEX(md.digest());
-  }
-
-  private static MessageDigest newMD5() {
-    try {
-      return MessageDigest.getInstance("MD5");
-    } catch (NoSuchAlgorithmException e) {
-      throw new RuntimeException("No MD5 available", e);
-    }
-  }
-
-  private static final char[] LHEX =
-      {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', //
-          'a', 'b', 'c', 'd', 'e', 'f',};
-
-  private static String LHEX(byte[] bin) {
-    StringBuilder r = new StringBuilder(bin.length * 2);
-    for (byte b : bin) {
-      r.append(LHEX[(b >>> 4) & 0x0f]);
-      r.append(LHEX[b & 0x0f]);
-    }
-    return r.toString();
-  }
-
-  private Map<String, String> parseAuthorization(String auth) {
-    Map<String, String> p = new HashMap<>();
-    int next = "Digest ".length();
-    while (next < auth.length()) {
-      if (next < auth.length() && auth.charAt(next) == ',') {
-        next++;
-      }
-      while (next < auth.length() && Character.isWhitespace(auth.charAt(next))) {
-        next++;
-      }
-
-      int eq = auth.indexOf('=', next);
-      if (eq < 0 || eq + 1 == auth.length()) {
-        return Collections.emptyMap();
-      }
-
-      final String name = auth.substring(next, eq);
-      final String value;
-      if (auth.charAt(eq + 1) == '"') {
-        int dq = auth.indexOf('"', eq + 2);
-        if (dq < 0) {
-          return Collections.emptyMap();
-        }
-        value = auth.substring(eq + 2, dq);
-        next = dq + 1;
-
-      } else {
-        int space = auth.indexOf(' ', eq + 1);
-        int comma = auth.indexOf(',', eq + 1);
-        if (space < 0) {
-          space = auth.length();
-        }
-        if (comma < 0) {
-          comma = auth.length();
-        }
-
-        final int e = Math.min(space, comma);
-        value = auth.substring(eq + 1, e);
-        next = e + 1;
-      }
-      p.put(name, value);
-    }
-    return p;
-  }
-
-  private String newNonce() {
-    try {
-      return tokens.newToken("");
-    } catch (XsrfException e) {
-      throw new RuntimeException("Cannot generate new nonce", e);
-    }
-  }
-
-  class Response extends HttpServletResponseWrapper {
-    private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
-    private final HttpServletRequest req;
-    Boolean stale;
-
-    Response(HttpServletRequest req, HttpServletResponse rsp) {
-      super(rsp);
-      this.req = req;
-    }
-
-    private void status(int sc) {
-      if (sc == SC_UNAUTHORIZED) {
-        StringBuilder v = new StringBuilder();
-        v.append("Digest");
-        v.append(" realm=\"").append(REALM_NAME).append("\"");
-
-        String url = urlProvider.get();
-        if (url == null) {
-          url = req.getContextPath();
-          if (url != null && !url.isEmpty() && !url.endsWith("/")) {
-            url += "/";
-          }
-        }
-        if (url != null && !url.isEmpty()) {
-          v.append(", domain=\"").append(url).append("\"");
-        }
-
-        v.append(", qop=\"auth\"");
-        if (stale != null) {
-          v.append(", stale=").append(stale);
-        }
-        v.append(", nonce=\"").append(newNonce()).append("\"");
-        setHeader(WWW_AUTHENTICATE, v.toString());
-
-      } else if (containsHeader(WWW_AUTHENTICATE)) {
-        setHeader(WWW_AUTHENTICATE, null);
-      }
-    }
-
-    @Override
-    public void sendError(int sc, String msg) throws IOException {
-      status(sc);
-      super.sendError(sc, msg);
-    }
-
-    @Override
-    public void sendError(int sc) throws IOException {
-      status(sc);
-      super.sendError(sc);
-    }
-
-    @Override
-    @Deprecated
-    public void setStatus(int sc, String sm) {
-      status(sc);
-      super.setStatus(sc, sm);
-    }
-
-    @Override
-    public void setStatus(int sc) {
-      status(sc);
-      super.setStatus(sc);
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index 7cadbae37..1f21da2 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -34,18 +34,11 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.apache.commons.codec.binary.Base64;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.util.Locale;
 import java.util.NoSuchElementException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -56,6 +49,10 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponseWrapper;
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Authenticates the current user with an OAuth2 server.
@@ -65,8 +62,7 @@
 @Singleton
 class ProjectOAuthFilter implements Filter {
 
-  private static final Logger log = LoggerFactory
-      .getLogger(ProjectOAuthFilter.class);
+  private static final Logger log = LoggerFactory.getLogger(ProjectOAuthFilter.class);
 
   private static final String REALM_NAME = "Gerrit Code Review";
   private static final String AUTHORIZATION = "Authorization";
@@ -84,7 +80,8 @@
   private String defaultAuthProvider;
 
   @Inject
-  ProjectOAuthFilter(DynamicItem<WebSession> session,
+  ProjectOAuthFilter(
+      DynamicItem<WebSession> session,
       DynamicMap<OAuthLoginProvider> pluginsProvider,
       AccountCache accountCache,
       AccountManager accountManager,
@@ -93,10 +90,8 @@
     this.loginProviders = pluginsProvider;
     this.accountCache = accountCache;
     this.accountManager = accountManager;
-    this.gitOAuthProvider =
-        gerritConfig.getString("auth", null, "gitOAuthProvider");
-    this.userNameToLowerCase =
-        gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
+    this.gitOAuthProvider = gerritConfig.getString("auth", null, "gitOAuthProvider");
+    this.userNameToLowerCase = gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
   }
 
   @Override
@@ -109,12 +104,11 @@
   }
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     HttpServletRequest req = (HttpServletRequest) request;
     Response rsp = new Response((HttpServletResponse) response);
     if (verify(req, rsp)) {
@@ -122,8 +116,7 @@
     }
   }
 
-  private boolean verify(HttpServletRequest req, Response rsp)
-      throws IOException {
+  private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
     AuthInfo authInfo = null;
 
     // first check if there is a BASIC authentication header
@@ -159,14 +152,15 @@
 
     AccountState who = accountCache.getByUsername(authInfo.username);
     if (who == null || !who.getAccount().isActive()) {
-      log.warn("Authentication failed for " + authInfo.username
-          + ": account inactive or not provisioned in Gerrit");
+      log.warn(
+          "Authentication failed for "
+              + authInfo.username
+              + ": account inactive or not provisioned in Gerrit");
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
 
-    AuthRequest authRequest = AuthRequest.forExternalUser(
-        authInfo.username);
+    AuthRequest authRequest = AuthRequest.forExternalUser(authInfo.username);
     authRequest.setEmailAddress(who.getAccount().getPreferredEmail());
     authRequest.setDisplayName(who.getAccount().getFullName());
     authRequest.setPassword(authInfo.tokenOrSecret);
@@ -188,17 +182,14 @@
   }
 
   /**
-   * Picks the only installed OAuth provider. If there is a multiude
-   * of providers available, the actual provider must be determined
-   * from the authentication request.
+   * Picks the only installed OAuth provider. If there is a multiude of providers available, the
+   * actual provider must be determined from the authentication request.
    *
-   * @throws ServletException if there is no {@code OAuthLoginProvider}
-   * installed at all.
+   * @throws ServletException if there is no {@code OAuthLoginProvider} installed at all.
    */
   private void pickOnlyProvider() throws ServletException {
     try {
-      Entry<OAuthLoginProvider> loginProvider =
-          Iterables.getOnlyElement(loginProviders);
+      Entry<OAuthLoginProvider> loginProvider = Iterables.getOnlyElement(loginProviders);
       defaultAuthPlugin = loginProvider.getPluginName();
       defaultAuthProvider = loginProvider.getExportName();
     } catch (NoSuchElementException e) {
@@ -209,8 +200,7 @@
   }
 
   /**
-   * Picks the {@code OAuthLoginProvider} configured with
-   * <tt>auth.gitOAuthProvider</tt>.
+   * Picks the {@code OAuthLoginProvider} configured with <tt>auth.gitOAuthProvider</tt>.
    *
    * @throws ServletException if the configured provider was not found.
    */
@@ -218,16 +208,16 @@
     int splitPos = gitOAuthProvider.lastIndexOf(':');
     if (splitPos < 1 || splitPos == gitOAuthProvider.length() - 1) {
       // no colon at all or leading/trailing colon: malformed providerId
-      throw new ServletException("OAuth login provider configuration is"
-          + " invalid: Must be of the form pluginName:providerName");
+      throw new ServletException(
+          "OAuth login provider configuration is"
+              + " invalid: Must be of the form pluginName:providerName");
     }
     defaultAuthPlugin = gitOAuthProvider.substring(0, splitPos);
     defaultAuthProvider = gitOAuthProvider.substring(splitPos + 1);
-    OAuthLoginProvider provider = loginProviders.get(defaultAuthPlugin,
-        defaultAuthProvider);
+    OAuthLoginProvider provider = loginProviders.get(defaultAuthPlugin, defaultAuthProvider);
     if (provider == null) {
-      throw new ServletException("Configured OAuth login provider "
-          + gitOAuthProvider + " wasn't installed");
+      throw new ServletException(
+          "Configured OAuth login provider " + gitOAuthProvider + " wasn't installed");
     }
   }
 
@@ -239,23 +229,23 @@
     if (splitPos < 1 || splitPos == usernamePassword.length() - 1) {
       return null;
     }
-    return new AuthInfo(usernamePassword.substring(0, splitPos),
-        usernamePassword.substring(splitPos + 1), defaultAuthPlugin,
+    return new AuthInfo(
+        usernamePassword.substring(0, splitPos),
+        usernamePassword.substring(splitPos + 1),
+        defaultAuthPlugin,
         defaultAuthProvider);
   }
 
-  private AuthInfo extractAuthInfo(Cookie cookie)
-      throws UnsupportedEncodingException {
-    String username = URLDecoder.decode(cookie.getName()
-        .substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
+  private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
+    String username =
+        URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
     String value = cookie.getValue();
     int splitPos = value.lastIndexOf('@');
     if (splitPos < 1 || splitPos == value.length() - 1) {
       // no providerId in the cookie value => assume default provider
       // note: a leading/trailing at sign is considered to belong to
       // the access token rather than being a separator
-      return new AuthInfo(username, cookie.getValue(),
-          defaultAuthPlugin, defaultAuthProvider);
+      return new AuthInfo(username, cookie.getValue(), defaultAuthPlugin, defaultAuthProvider);
     }
     String token = value.substring(0, splitPos);
     String providerId = value.substring(splitPos + 1);
@@ -295,11 +285,8 @@
     private final String pluginName;
     private final String exportName;
 
-    private AuthInfo(String username, String tokenOrSecret,
-        String pluginName, String exportName) {
-      this.username = userNameToLowerCase
-          ? username.toLowerCase(Locale.US)
-          : username;
+    private AuthInfo(String username, String tokenOrSecret, String pluginName, String exportName) {
+      this.username = userNameToLowerCase ? username.toLowerCase(Locale.US) : username;
       this.tokenOrSecret = tokenOrSecret;
       this.pluginName = pluginName;
       this.exportName = exportName;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyProperties.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyProperties.java
index 67b97c4..5977398 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyProperties.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyProperties.java
@@ -18,6 +18,8 @@
 
 public interface ProxyProperties {
   URL getProxyUrl();
+
   String getUsername();
+
   String getPassword();
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyPropertiesProvider.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyPropertiesProvider.java
index 0e51cc2..b0a8013 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyPropertiesProvider.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProxyPropertiesProvider.java
@@ -19,11 +19,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.net.MalformedURLException;
 import java.net.URL;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 class ProxyPropertiesProvider implements Provider<ProxyProperties> {
@@ -33,8 +31,7 @@
   private String proxyPassword;
 
   @Inject
-  ProxyPropertiesProvider(@GerritServerConfig Config config)
-      throws MalformedURLException {
+  ProxyPropertiesProvider(@GerritServerConfig Config config) throws MalformedURLException {
     String proxyUrlStr = config.getString("http", null, "proxy");
     if (!Strings.isNullOrEmpty(proxyUrlStr)) {
       proxyUrl = new URL(proxyUrlStr);
@@ -60,10 +57,12 @@
       public URL getProxyUrl() {
         return proxyUrl;
       }
+
       @Override
       public String getUsername() {
         return proxyUser;
       }
+
       @Override
       public String getPassword() {
         return proxyPassword;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 479a5e5..a02b5a0 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -16,27 +16,26 @@
 
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-
-import org.eclipse.jgit.util.Base64;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import javax.servlet.http.HttpServletRequest;
+import org.eclipse.jgit.util.Base64;
 
 public class RemoteUserUtil {
   /**
    * Tries to get username from a request with following strategies:
+   *
    * <ul>
-   * <li>ServletRequest#getRemoteUser</li>
-   * <li>HTTP 'Authorization' header</li>
-   * <li>Custom HTTP header</li>
+   *   <li>ServletRequest#getRemoteUser
+   *   <li>HTTP 'Authorization' header
+   *   <li>Custom HTTP header
    * </ul>
    *
    * @param req request to extract username from.
-   * @param loginHeader name of header which is used for extracting
-   *    username.
+   * @param loginHeader name of header which is used for extracting username.
    * @return the extracted username or null.
    */
-  public static String getRemoteUser(HttpServletRequest req,
-      String loginHeader) {
+  public static String getRemoteUser(HttpServletRequest req, String loginHeader) {
     if (AUTHORIZATION.equals(loginHeader)) {
       String user = emptyToNull(req.getRemoteUser());
       if (user != null) {
@@ -58,8 +57,7 @@
   }
 
   /**
-   * Extracts username from an HTTP Basic or Digest authentication
-   * header.
+   * Extracts username from an HTTP Basic or Digest authentication header.
    *
    * @param auth header value which is used for extracting.
    * @return username if available or null.
@@ -72,7 +70,7 @@
 
     } else if (auth.startsWith("Basic ")) {
       auth = auth.substring("Basic ".length());
-      auth = new String(Base64.decode(auth));
+      auth = new String(Base64.decode(auth), UTF_8);
       final int c = auth.indexOf(':');
       return c > 0 ? auth.substring(0, c) : null;
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java
index b46505f..548db48 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java
@@ -22,9 +22,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
-
 import java.io.IOException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -49,7 +47,8 @@
   private final ThreadLocalRequestContext local;
 
   @Inject
-  RequestContextFilter(final Provider<RequestCleanup> r,
+  RequestContextFilter(
+      final Provider<RequestCleanup> r,
       final Provider<HttpRequestContext> c,
       final ThreadLocalRequestContext l) {
     cleanup = r;
@@ -58,16 +57,14 @@
   }
 
   @Override
-  public void init(FilterConfig filterConfig) {
-  }
+  public void init(FilterConfig filterConfig) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(final ServletRequest request,
-      final ServletResponse response, final FilterChain chain)
+  public void doFilter(
+      final ServletRequest request, final ServletResponse response, final FilterChain chain)
       throws IOException, ServletException {
     RequestContext old = local.setContext(requestContext.get());
     try {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java
index ec193c9..cab4a92 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetrics.java
@@ -28,17 +28,15 @@
 
   @Inject
   public RequestMetrics(MetricMaker metricMaker) {
-    errors = metricMaker.newCounter(
-        "http/server/error_count",
-        new Description("Rate of REST API error responses")
-          .setRate()
-          .setUnit("errors"),
-        Field.ofInteger("status", "HTTP status code"));
-    successes = metricMaker.newCounter(
-        "http/server/success_count",
-        new Description("Rate of REST API success responses")
-          .setRate()
-          .setUnit("successes"),
-        Field.ofInteger("status", "HTTP status code"));
+    errors =
+        metricMaker.newCounter(
+            "http/server/error_count",
+            new Description("Rate of REST API error responses").setRate().setUnit("errors"),
+            Field.ofInteger("status", "HTTP status code"));
+    successes =
+        metricMaker.newCounter(
+            "http/server/success_count",
+            new Description("Rate of REST API success responses").setRate().setUnit("successes"),
+            Field.ofInteger("status", "HTTP status code"));
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java
index 48b2a2f..c7a7540 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestMetricsFilter.java
@@ -18,9 +18,7 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
-
 import java.io.IOException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -49,20 +47,18 @@
   }
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     Response rsp = new Response((HttpServletResponse) response, metrics);
 
     chain.doFilter(request, rsp);
   }
 
   @Override
-  public void init(FilterConfig cfg) throws ServletException {
-  }
+  public void init(FilterConfig cfg) throws ServletException {}
 
   private static class Response extends HttpServletResponseWrapper {
     private final RequestMetrics metrics;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireIdentifiedUserFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireIdentifiedUserFilter.java
index 2448d3f..522d0b6 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireIdentifiedUserFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireIdentifiedUserFilter.java
@@ -18,9 +18,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -40,16 +38,13 @@
   }
 
   @Override
-  public void init(FilterConfig filterConfig) {
-  }
+  public void init(FilterConfig filterConfig) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(ServletRequest request,
-      ServletResponse response, FilterChain chain)
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
       throws IOException, ServletException {
     if (user.get().isIdentifiedUser()) {
       chain.doFilter(request, response);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
index 4cb8e92..4bdd1f0 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequireSslFilter.java
@@ -20,9 +20,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
-
 import java.io.IOException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -59,16 +57,14 @@
   }
 
   @Override
-  public void init(FilterConfig filterConfig) {
-  }
+  public void init(FilterConfig filterConfig) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(final ServletRequest request,
-      final ServletResponse response, final FilterChain chain)
+  public void doFilter(
+      final ServletRequest request, final ServletResponse response, final FilterChain chain)
       throws IOException, ServletException {
     final HttpServletRequest req = (HttpServletRequest) request;
     final HttpServletResponse rsp = (HttpServletResponse) response;
@@ -100,7 +96,6 @@
   }
 
   private static boolean isLocalHost(final HttpServletRequest req) {
-    return "localhost".equals(req.getServerName())
-        || "127.0.0.1".equals(req.getServerName());
+    return "localhost".equals(req.getServerName()) || "127.0.0.1".equals(req.getServerName());
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
index 210800d..4862a70 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -29,12 +29,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -43,6 +38,8 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Allows running a request as another user account. */
 @Singleton
@@ -63,7 +60,8 @@
   private final AccountResolver accountResolver;
 
   @Inject
-  RunAsFilter(Provider<ReviewDb> db,
+  RunAsFilter(
+      Provider<ReviewDb> db,
       AuthConfig config,
       DynamicItem<WebSession> session,
       AccountResolver accountResolver) {
@@ -74,27 +72,24 @@
   }
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     HttpServletRequest req = (HttpServletRequest) request;
     HttpServletResponse res = (HttpServletResponse) response;
 
     String runas = req.getHeader(RUN_AS);
     if (runas != null) {
       if (!enabled) {
-        replyError(req, res,
-            SC_FORBIDDEN,
-            RUN_AS + " disabled by auth.enableRunAs = false",
-            null);
+        replyError(req, res, SC_FORBIDDEN, RUN_AS + " disabled by auth.enableRunAs = false", null);
         return;
       }
 
       CurrentUser self = session.get().getUser();
-      if (!self.getCapabilities().canRunAs()) {
-        replyError(req, res,
-            SC_FORBIDDEN,
-            "not permitted to use " + RUN_AS,
-            null);
+      if (!self.getCapabilities().canRunAs()
+          // Always disallow for anonymous users, even if permitted by the ACL,
+          // because that would be crazy.
+          || !self.isIdentifiedUser()) {
+        replyError(req, res, SC_FORBIDDEN, "not permitted to use " + RUN_AS, null);
         return;
       }
 
@@ -103,17 +98,11 @@
         target = accountResolver.find(db.get(), runas);
       } catch (OrmException e) {
         log.warn("cannot resolve account for " + RUN_AS, e);
-        replyError(req, res,
-            SC_INTERNAL_SERVER_ERROR,
-            "cannot resolve " + RUN_AS,
-            e);
+        replyError(req, res, SC_INTERNAL_SERVER_ERROR, "cannot resolve " + RUN_AS, e);
         return;
       }
       if (target == null) {
-        replyError(req, res,
-            SC_FORBIDDEN,
-            "no account matches " + RUN_AS,
-            null);
+        replyError(req, res, SC_FORBIDDEN, "no account matches " + RUN_AS, null);
         return;
       }
       session.get().setUserAccountId(target.getId());
@@ -123,10 +112,8 @@
   }
 
   @Override
-  public void init(FilterConfig filterConfig) {
-  }
+  public void init(FilterConfig filterConfig) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
new file mode 100644
index 0000000..4878006
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UniversalWebLoginFilter.java
@@ -0,0 +1,98 @@
+// 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.httpd;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.ServletModule;
+import java.io.IOException;
+import java.util.Optional;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class UniversalWebLoginFilter implements Filter {
+  private final DynamicItem<WebSession> session;
+  private final DynamicSet<WebLoginListener> webLoginListeners;
+  private final Provider<CurrentUser> userProvider;
+
+  public static ServletModule module() {
+    return new ServletModule() {
+      @Override
+      protected void configureServlets() {
+        filter("/login*", "/logout*").through(UniversalWebLoginFilter.class);
+        bind(UniversalWebLoginFilter.class).in(Singleton.class);
+
+        DynamicSet.setOf(binder(), WebLoginListener.class);
+      }
+    };
+  }
+
+  @Inject
+  public UniversalWebLoginFilter(
+      DynamicItem<WebSession> session,
+      DynamicSet<WebLoginListener> webLoginListeners,
+      Provider<CurrentUser> userProvider) {
+    this.session = session;
+    this.webLoginListeners = webLoginListeners;
+    this.userProvider = userProvider;
+  }
+
+  @Override
+  public void init(FilterConfig filterConfig) throws ServletException {}
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    HttpServletRequest httpRequest = (HttpServletRequest) request;
+    HttpServletResponseRecorder wrappedResponse =
+        new HttpServletResponseRecorder((HttpServletResponse) response);
+
+    Optional<IdentifiedUser> loggedInUserBefore = loggedInUser();
+    chain.doFilter(request, wrappedResponse);
+    Optional<IdentifiedUser> loggedInUserAfter = loggedInUser();
+
+    if (!loggedInUserBefore.isPresent() && loggedInUserAfter.isPresent()) {
+      for (WebLoginListener loginListener : webLoginListeners) {
+        loginListener.onLogin(loggedInUserAfter.get(), httpRequest, wrappedResponse);
+      }
+    } else if (loggedInUserBefore.isPresent() && !loggedInUserAfter.isPresent()) {
+      for (WebLoginListener loginListener : webLoginListeners) {
+        loginListener.onLogout(loggedInUserBefore.get(), httpRequest, wrappedResponse);
+      }
+    }
+
+    wrappedResponse.play();
+  }
+
+  private Optional<IdentifiedUser> loggedInUser() {
+    return session.get().isSignedIn()
+        ? Optional.of(userProvider.get().asIdentifiedUser())
+        : Optional.empty();
+  }
+
+  @Override
+  public void destroy() {}
+}
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 2c67182..409e978 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
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.httpd.raw.CatServlet;
 import com.google.gerrit.httpd.raw.HostPageServlet;
 import com.google.gerrit.httpd.raw.LegacyGerritServlet;
@@ -30,23 +31,20 @@
 import com.google.gerrit.httpd.restapi.GroupsRestApiServlet;
 import com.google.gerrit.httpd.restapi.ProjectsRestApiServlet;
 import com.google.gerrit.httpd.rpc.doc.QueryDocumentationFilter;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gwtexpui.server.CacheControlFilter;
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.ServletModule;
-
-import org.eclipse.jgit.lib.Constants;
-
 import java.io.IOException;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Constants;
 
 class UrlModule extends ServletModule {
   private GerritOptions options;
@@ -62,8 +60,9 @@
     filter("/*").through(Key.get(CacheControlFilter.class));
     bind(Key.get(CacheControlFilter.class)).in(SINGLETON);
 
-    if (options.enableDefaultUi()) {
+    if (options.enableGwtUi()) {
       filter("/").through(XsrfCookieFilter.class);
+      filter("/accounts/self/detail").through(XsrfCookieFilter.class);
       serve("/").with(HostPageServlet.class);
       serve("/Gerrit").with(LegacyGerritServlet.class);
       serve("/Gerrit/*").with(legacyGerritScreen());
@@ -72,8 +71,7 @@
     }
     serve("/cat/*").with(CatServlet.class);
 
-    if (authConfig.getAuthType() != AuthType.OAUTH &&
-        authConfig.getAuthType() != AuthType.OPENID) {
+    if (authConfig.getAuthType() != AuthType.OAUTH && authConfig.getAuthType() != AuthType.OPENID) {
       serve("/logout").with(HttpLogoutServlet.class);
       serve("/signout").with(HttpLogoutServlet.class);
     }
@@ -90,13 +88,24 @@
     serve("/starred").with(query("is:starred"));
 
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
-    serveRegex("^/register/?$").with(screen(PageLinks.REGISTER + "/"));
+    serveRegex("^/register$").with(registerScreen(false));
+    serveRegex("^/register/(.+)$").with(registerScreen(true));
     serveRegex("^/([1-9][0-9]*)/?$").with(directChangeById());
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
     filter("/a/*").through(RequireIdentifiedUserFilter.class);
+
+    // Must be after RequireIdentifiedUserFilter so auth happens before checking
+    // for RunAs capability.
+    install(new RunAsFilter.Module());
+
     serveRegex("^/(?:a/)?tools/(.*)$").with(ToolServlet.class);
+
+    // Bind servlets for REST root collections.
+    // The '/plugins/' root collection is already handled by HttpPluginServlet
+    // which is bound in HttpPluginModule. We cannot bind it here again although
+    // this means that plugins can't add REST views on PLUGIN_KIND.
     serveRegex("^/(?:a/)?access/(.*)$").with(AccessRestApiServlet.class);
     serveRegex("^/(?:a/)?accounts/(.*)$").with(AccountsRestApiServlet.class);
     serveRegex("^/(?:a/)?changes/(.*)$").with(ChangesRestApiServlet.class);
@@ -108,131 +117,158 @@
   }
 
   private Key<HttpServlet> notFound() {
-    return key(new HttpServlet() {
-      private static final long serialVersionUID = 1L;
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected void doGet(final HttpServletRequest req,
-          final HttpServletResponse rsp) throws IOException {
-        rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
-      }
-    });
+          @Override
+          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+              throws IOException {
+            rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+          }
+        });
   }
 
   private Key<HttpServlet> gerritUrl() {
-    return key(new HttpServlet() {
-      private static final long serialVersionUID = 1L;
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected void doGet(final HttpServletRequest req,
-          final HttpServletResponse rsp) throws IOException {
-        toGerrit(req.getRequestURI(), req, rsp);
-      }
-    });
+          @Override
+          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+              throws IOException {
+            toGerrit(req.getRequestURI().substring(req.getContextPath().length()), req, rsp);
+          }
+        });
   }
 
   private Key<HttpServlet> screen(final String target) {
-    return key(new HttpServlet() {
-      private static final long serialVersionUID = 1L;
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected void doGet(final HttpServletRequest req,
-          final HttpServletResponse rsp) throws IOException {
-        toGerrit(target, req, rsp);
-      }
-    });
+          @Override
+          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+              throws IOException {
+            toGerrit(target, req, rsp);
+          }
+        });
   }
 
   private Key<HttpServlet> legacyGerritScreen() {
-    return key(new HttpServlet() {
-      private static final long serialVersionUID = 1L;
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected void doGet(final HttpServletRequest req,
-          final HttpServletResponse rsp) throws IOException {
-        final String token = req.getPathInfo().substring(1);
-        toGerrit(token, req, rsp);
-      }
-    });
+          @Override
+          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+              throws IOException {
+            final String token = req.getPathInfo().substring(1);
+            toGerrit(token, req, rsp);
+          }
+        });
   }
 
   private Key<HttpServlet> directChangeById() {
-    return key(new HttpServlet() {
-      private static final long serialVersionUID = 1L;
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected void doGet(final HttpServletRequest req,
-          final HttpServletResponse rsp) throws IOException {
-        try {
-          String idString = req.getPathInfo();
-          if (idString.endsWith("/")) {
-            idString = idString.substring(0, idString.length() - 1);
+          @Override
+          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+              throws IOException {
+            try {
+              String idString = req.getPathInfo();
+              if (idString.endsWith("/")) {
+                idString = idString.substring(0, idString.length() - 1);
+              }
+              Change.Id id = Change.Id.parse(idString);
+              toGerrit(PageLinks.toChange(id), req, rsp);
+            } catch (IllegalArgumentException err) {
+              rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+            }
           }
-          Change.Id id = Change.Id.parse(idString);
-          toGerrit(PageLinks.toChange(id), req, rsp);
-        } catch (IllegalArgumentException err) {
-          rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
-        }
-      }
-    });
+        });
   }
 
   private Key<HttpServlet> queryProjectNew() {
-    return key(new HttpServlet() {
-      private static final long serialVersionUID = 1L;
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
-          throws IOException {
-        String name = req.getPathInfo();
-        if (Strings.isNullOrEmpty(name)) {
-          toGerrit(PageLinks.ADMIN_PROJECTS, req, rsp);
-          return;
-        }
+          @Override
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+            String name = req.getPathInfo();
+            if (Strings.isNullOrEmpty(name)) {
+              toGerrit(PageLinks.ADMIN_PROJECTS, req, rsp);
+              return;
+            }
 
-        while (name.endsWith("/")) {
-          name = name.substring(0, name.length() - 1);
-        }
-        if (name.endsWith(Constants.DOT_GIT_EXT)) {
-          name = name.substring(0, //
-              name.length() - Constants.DOT_GIT_EXT.length());
-        }
-        while (name.endsWith("/")) {
-          name = name.substring(0, name.length() - 1);
-        }
-        Project.NameKey project = new Project.NameKey(name);
-        toGerrit(PageLinks.toChangeQuery(PageLinks.projectQuery(project,
-            Change.Status.NEW)), req, rsp);
-      }
-    });
+            while (name.endsWith("/")) {
+              name = name.substring(0, name.length() - 1);
+            }
+            if (name.endsWith(Constants.DOT_GIT_EXT)) {
+              name =
+                  name.substring(
+                      0, //
+                      name.length() - Constants.DOT_GIT_EXT.length());
+            }
+            while (name.endsWith("/")) {
+              name = name.substring(0, name.length() - 1);
+            }
+            Project.NameKey project = new Project.NameKey(name);
+            toGerrit(
+                PageLinks.toChangeQuery(PageLinks.projectQuery(project, Change.Status.NEW)),
+                req,
+                rsp);
+          }
+        });
   }
 
   private Key<HttpServlet> query(final String query) {
-    return key(new HttpServlet() {
-      private static final long serialVersionUID = 1L;
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
 
-      @Override
-      protected void doGet(final HttpServletRequest req,
-          final HttpServletResponse rsp) throws IOException {
-        toGerrit(PageLinks.toChangeQuery(query), req, rsp);
-      }
-    });
+          @Override
+          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+              throws IOException {
+            toGerrit(PageLinks.toChangeQuery(query), req, rsp);
+          }
+        });
   }
 
   private Key<HttpServlet> key(final HttpServlet servlet) {
-    final Key<HttpServlet> srv =
-        Key.get(HttpServlet.class, UniqueAnnotations.create());
-    bind(srv).toProvider(new Provider<HttpServlet>() {
-      @Override
-      public HttpServlet get() {
-        return servlet;
-      }
-    }).in(SINGLETON);
+    final Key<HttpServlet> srv = Key.get(HttpServlet.class, UniqueAnnotations.create());
+    bind(srv)
+        .toProvider(
+            new Provider<HttpServlet>() {
+              @Override
+              public HttpServlet get() {
+                return servlet;
+              }
+            })
+        .in(SINGLETON);
     return srv;
   }
 
-  static void toGerrit(final String target, final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  private Key<HttpServlet> registerScreen(final Boolean slash) {
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
+
+          @Override
+          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+              throws IOException {
+            String path = String.format("/register%s", slash ? req.getPathInfo() : "");
+            toGerrit(path, req, rsp);
+          }
+        });
+  }
+
+  static void toGerrit(
+      final String target, final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     final StringBuilder url = new StringBuilder();
     url.append(req.getContextPath());
     url.append('/');
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebLoginListener.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebLoginListener.java
new file mode 100644
index 0000000..5222f4c
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebLoginListener.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.IdentifiedUser;
+import java.io.IOException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Allows to listen and override the reponse to login/logout web actions.
+ *
+ * <p>Allows to intercept and act when a Gerrit user logs in or logs out of the Web interface to
+ * perform actions or to override the output response status code.
+ *
+ * <p>Typical use can be multi-factor authentication (on login) or global sign-out from SSO systems
+ * (on logout).
+ */
+@ExtensionPoint
+public interface WebLoginListener {
+
+  /**
+   * Invoked after a user's web login.
+   *
+   * @param userId logged in user
+   * @param request request of the latest login action
+   * @param response response of the latest login action
+   */
+  void onLogin(IdentifiedUser userId, HttpServletRequest request, HttpServletResponse response)
+      throws IOException;
+
+  /**
+   * Invoked after a user's web logout.
+   *
+   * @param userId logged out user
+   * @param request request of the latest logout action
+   * @param response response of the latest logout action
+   */
+  void onLogout(IdentifiedUser userId, HttpServletRequest request, HttpServletResponse response)
+      throws IOException;
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
index 3e3b7c4..9967af6 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.git.AsyncReceiveCommits;
@@ -33,7 +34,6 @@
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
 import com.google.inject.servlet.RequestScoped;
-
 import java.net.SocketAddress;
 
 public class WebModule extends LifecycleModule {
@@ -42,9 +42,7 @@
   private final GerritOptions options;
 
   @Inject
-  WebModule(AuthConfig authConfig,
-      GerritOptions options,
-      GitwebCgiConfig gitwebCgiConfig) {
+  WebModule(AuthConfig authConfig, GerritOptions options, GitwebCgiConfig gitwebCgiConfig) {
     this.authConfig = authConfig;
     this.options = options;
     this.gitwebCgiConfig = gitwebCgiConfig;
@@ -55,8 +53,6 @@
     bind(RequestScopePropagator.class).to(GuiceRequestScopePropagator.class);
     bind(HttpRequestContext.class);
 
-    install(new RunAsFilter.Module());
-
     installAuthModule();
     if (options.enableMasterFeatures()) {
       install(new UrlModule(options, authConfig));
@@ -71,12 +67,16 @@
 
     install(new AsyncReceiveCommits.Module());
 
-    bind(SocketAddress.class).annotatedWith(RemotePeer.class).toProvider(
-        HttpRemotePeerProvider.class).in(RequestScoped.class);
+    bind(SocketAddress.class)
+        .annotatedWith(RemotePeer.class)
+        .toProvider(HttpRemotePeerProvider.class)
+        .in(RequestScoped.class);
 
     bind(ProxyProperties.class).toProvider(ProxyPropertiesProvider.class);
 
     listener().toInstance(registerInParentInjectors());
+
+    install(UniversalWebLoginFilter.module());
   }
 
   private void installAuthModule() {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
index 327aaa3..f1600bc 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
@@ -14,25 +14,35 @@
 
 package com.google.gerrit.httpd;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
+
+  @Nullable
   String getXGerritAuth();
+
   boolean isValidXGerritAuth(String keyIn);
-  AccountExternalId.Key getLastLoginExternalId();
+
+  ExternalId.Key getLastLoginExternalId();
+
   CurrentUser getUser();
+
   void login(AuthResult res, boolean rememberMe);
 
   /** Set the user account for this current request only. */
   void setUserAccountId(Account.Id id);
+
   boolean isAccessPathOk(AccessPath path);
+
   void setAccessPathOk(AccessPath path, boolean ok);
 
   void logout();
+
   String getSessionId();
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
index 902bf00..bc01319 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,16 +30,11 @@
 
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.ObjectInputStream;
@@ -47,6 +42,9 @@
 import java.io.Serializable;
 import java.security.SecureRandom;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class WebSessionManager {
   private static final Logger log = LoggerFactory.getLogger(WebSessionManager.class);
@@ -57,19 +55,24 @@
   private final Cache<String, Val> self;
 
   @Inject
-  WebSessionManager(@GerritServerConfig Config cfg,
-      @Assisted final Cache<String, Val> cache) {
+  WebSessionManager(@GerritServerConfig Config cfg, @Assisted final Cache<String, Val> cache) {
     prng = new SecureRandom();
     self = cache;
 
-    sessionMaxAgeMillis = SECONDS.toMillis(ConfigUtil.getTimeUnit(cfg,
-        "cache", CACHE_NAME, "maxAge",
-        SECONDS.convert(MAX_AGE_MINUTES, MINUTES), SECONDS));
+    sessionMaxAgeMillis =
+        SECONDS.toMillis(
+            ConfigUtil.getTimeUnit(
+                cfg,
+                "cache",
+                CACHE_NAME,
+                "maxAge",
+                SECONDS.convert(MAX_AGE_MINUTES, MINUTES),
+                SECONDS));
     if (sessionMaxAgeMillis < MINUTES.toMillis(5)) {
-      log.warn(String.format(
-          "cache.%s.maxAge is set to %d milliseconds;" +
-          " it should be at least 5 minutes.",
-          CACHE_NAME, sessionMaxAgeMillis));
+      log.warn(
+          "cache.{}.maxAge is set to {} milliseconds; it should be at least 5 minutes.",
+          CACHE_NAME,
+          sessionMaxAgeMillis);
     }
   }
 
@@ -95,15 +98,20 @@
     }
   }
 
-  Val createVal(final Key key, final Val val) {
-    final Account.Id who = val.getAccountId();
-    final boolean remember = val.isPersistentCookie();
-    final AccountExternalId.Key lastLogin = val.getExternalId();
+  Val createVal(Key key, Val val) {
+    Account.Id who = val.getAccountId();
+    boolean remember = val.isPersistentCookie();
+    ExternalId.Key lastLogin = val.getExternalId();
     return createVal(key, who, remember, lastLogin, val.sessionId, val.auth);
   }
 
-  Val createVal(final Key key, final Account.Id who, final boolean remember,
-      final AccountExternalId.Key lastLogin, String sid, String auth) {
+  Val createVal(
+      Key key,
+      Account.Id who,
+      boolean remember,
+      ExternalId.Key lastLogin,
+      String sid,
+      String auth) {
     // Refresh the cookie every hour or when it is half-expired.
     // This reduces the odds that the user session will be kicked
     // early but also avoids us needing to refresh the cookie on
@@ -155,7 +163,7 @@
     self.invalidate(key.token);
   }
 
-  static final class Key  {
+  static final class Key {
     private transient String token;
 
     Key(final String t) {
@@ -183,14 +191,19 @@
     private transient Account.Id accountId;
     private transient long refreshCookieAt;
     private transient boolean persistentCookie;
-    private transient AccountExternalId.Key externalId;
+    private transient ExternalId.Key externalId;
     private transient long expiresAt;
     private transient String sessionId;
     private transient String auth;
 
-    Val(final Account.Id accountId, final long refreshCookieAt,
-        final boolean persistentCookie, final AccountExternalId.Key externalId,
-        final long expiresAt, final String sessionId, final String auth) {
+    Val(
+        Account.Id accountId,
+        long refreshCookieAt,
+        boolean persistentCookie,
+        ExternalId.Key externalId,
+        long expiresAt,
+        String sessionId,
+        String auth) {
       this.accountId = accountId;
       this.refreshCookieAt = refreshCookieAt;
       this.persistentCookie = persistentCookie;
@@ -208,7 +221,7 @@
       return accountId;
     }
 
-    AccountExternalId.Key getExternalId() {
+    ExternalId.Key getExternalId() {
       return externalId;
     }
 
@@ -240,7 +253,7 @@
 
       if (externalId != null) {
         writeVarInt32(out, 4);
-        writeString(out, externalId.get());
+        writeString(out, externalId.toString());
       }
 
       if (sessionId != null) {
@@ -260,7 +273,8 @@
     }
 
     private void readObject(final ObjectInputStream in) throws IOException {
-      PARSE: for (;;) {
+      PARSE:
+      for (; ; ) {
         final int tag = readVarInt32(in);
         switch (tag) {
           case 0:
@@ -275,7 +289,7 @@
             persistentCookie = readVarInt32(in) != 0;
             continue;
           case 4:
-            externalId = new AccountExternalId.Key(readString(in));
+            externalId = ExternalId.Key.parse(readString(in));
             continue;
           case 5:
             sessionId = readString(in);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSshGlueModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSshGlueModule.java
index eb06617..de75f6b 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSshGlueModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSshGlueModule.java
@@ -21,10 +21,10 @@
 
 /**
  * Pulls objects from the SSH injector over the HTTP injector.
- * <p>
- * This mess is only necessary because we build up two different injectors, in
- * order to have different request scopes. But some HTTP RPCs can cause changes
- * to the SSH side of the house, and thus needs access to it.
+ *
+ * <p>This mess is only necessary because we build up two different injectors, in order to have
+ * different request scopes. But some HTTP RPCs can cause changes to the SSH side of the house, and
+ * thus needs access to it.
  */
 public class WebSshGlueModule extends AbstractModule {
   private final Provider<SshInfo> sshInfoProvider;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
index 842b2b4..ff64c84 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/XsrfCookieFilter.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.httpd;
 
+import static com.google.common.base.Strings.nullToEmpty;
+
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.CurrentUser;
@@ -21,9 +23,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -42,32 +42,30 @@
 
   @Inject
   XsrfCookieFilter(
-      Provider<CurrentUser> user,
-      DynamicItem<WebSession> session,
-      AuthConfig authConfig) {
+      Provider<CurrentUser> user, DynamicItem<WebSession> session, AuthConfig authConfig) {
     this.user = user;
     this.session = session;
     this.authConfig = authConfig;
   }
 
   @Override
-  public void doFilter(ServletRequest req, ServletResponse rsp,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest req, ServletResponse rsp, FilterChain chain)
+      throws IOException, ServletException {
     WebSession s = user.get().isIdentifiedUser() ? session.get() : null;
-    setXsrfTokenCookie(
-        (HttpServletRequest) req, (HttpServletResponse) rsp, s);
+    setXsrfTokenCookie((HttpServletRequest) req, (HttpServletResponse) rsp, s);
     chain.doFilter(req, rsp);
   }
 
-  private void setXsrfTokenCookie(HttpServletRequest req,
-      HttpServletResponse rsp, WebSession session) {
-    String v = session != null ? session.getXGerritAuth() : "";
-    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, v);
+  private void setXsrfTokenCookie(
+      HttpServletRequest req, HttpServletResponse rsp, WebSession session) {
+    String v = session != null ? session.getXGerritAuth() : null;
+    Cookie c = new Cookie(HostPageData.XSRF_COOKIE_NAME, nullToEmpty(v));
     c.setPath("/");
     c.setSecure(authConfig.getCookieSecure() && isSecure(req));
-    c.setMaxAge(session != null
-        ? -1 // Set the cookie for this browser session.
-        : 0); // Remove the cookie (expire immediately).
+    c.setMaxAge(
+        v != null
+            ? -1 // Set the cookie for this browser session.
+            : 0); // Remove the cookie (expire immediately).
     rsp.addCookie(c);
   }
 
@@ -76,10 +74,8 @@
   }
 
   @Override
-  public void init(FilterConfig config) {
-  }
+  public void init(FilterConfig config) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 8d5aff6..b7c6be3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.httpd.auth.become;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -23,13 +24,13 @@
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.template.SiteHeaderFooter;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
@@ -37,21 +38,18 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.Writer;
 import java.util.List;
 import java.util.UUID;
-
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
 
 @SuppressWarnings("serial")
 @Singleton
@@ -63,7 +61,8 @@
   private final InternalAccountQuery accountQuery;
 
   @Inject
-  BecomeAnyAccountLoginServlet(DynamicItem<WebSession> ws,
+  BecomeAnyAccountLoginServlet(
+      DynamicItem<WebSession> ws,
       SchemaFactory<ReviewDb> sf,
       AccountManager am,
       SiteHeaderFooter shf,
@@ -76,14 +75,14 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException, ServletException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException, ServletException {
     doPost(req, rsp);
   }
 
   @Override
-  protected void doPost(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException, ServletException {
+  protected void doPost(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException, ServletException {
     CacheHeaders.setNotCacheable(rsp);
 
     final AuthResult res;
@@ -181,31 +180,25 @@
     return null;
   }
 
-  private AuthResult auth(final AccountExternalId account) {
+  private AuthResult auth(Account.Id account) {
     if (account != null) {
-      return new AuthResult(account.getAccountId(), null, false);
+      return new AuthResult(account, null, false);
     }
     return null;
   }
 
   private AuthResult byUserName(final String userName) {
     try {
-      AccountExternalId.Key extKey =
-          new AccountExternalId.Key(SCHEME_USERNAME, userName);
-      List<AccountState> accountStates =
-          accountQuery.byExternalId(extKey.get());
+      List<AccountState> accountStates = accountQuery.byExternalId(SCHEME_USERNAME, userName);
       if (accountStates.isEmpty()) {
-        getServletContext()
-            .log("No accounts with username " + userName + " found");
+        getServletContext().log("No accounts with username " + userName + " found");
         return null;
       }
       if (accountStates.size() > 1) {
-        getServletContext()
-            .log("Multiple accounts with username " + userName + " found");
+        getServletContext().log("Multiple accounts with username " + userName + " found");
         return null;
       }
-      return auth(new AccountExternalId(
-          accountStates.get(0).getAccount().getId(), extKey));
+      return auth(accountStates.get(0).getAccount().getId());
     } catch (OrmException e) {
       getServletContext().log("cannot query account index", e);
       return null;
@@ -238,9 +231,9 @@
   }
 
   private AuthResult create() throws IOException {
-    String fakeId = AccountExternalId.SCHEME_UUID + UUID.randomUUID();
     try {
-      return accountManager.authenticate(new AuthRequest(fakeId));
+      return accountManager.authenticate(
+          new AuthRequest(ExternalId.Key.create(SCHEME_UUID, UUID.randomUUID().toString())));
     } catch (AccountException e) {
       getServletContext().log("cannot create new account", e);
       return null;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 978f081..5a0ed71 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -26,18 +26,16 @@
 import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.raw.HostPageServlet;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Locale;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -49,12 +47,11 @@
 
 /**
  * Watches request for the host page and requires login if not yet signed in.
- * <p>
- * If HTTP authentication has been enabled on this server this filter is bound
- * in front of the {@link HostPageServlet} and redirects users who are not yet
- * signed in to visit {@code /login/}, so the web container can force login.
- * This redirect is performed with JavaScript, such that any existing anchor
- * token in the URL can be rewritten and preserved through the authentication
+ *
+ * <p>If HTTP authentication has been enabled on this server this filter is bound in front of the
+ * {@link HostPageServlet} and redirects users who are not yet signed in to visit {@code /login/},
+ * so the web container can force login. This redirect is performed with JavaScript, such that any
+ * existing anchor token in the URL can be rewritten and preserved through the authentication
  * process of any enterprise single sign-on solutions.
  */
 @Singleton
@@ -69,8 +66,8 @@
   private final boolean userNameToLowerCase;
 
   @Inject
-  HttpAuthFilter(final DynamicItem<WebSession> webSession,
-      final AuthConfig authConfig) throws IOException {
+  HttpAuthFilter(final DynamicItem<WebSession> webSession, final AuthConfig authConfig)
+      throws IOException {
     this.sessionProvider = webSession;
 
     final String pageName = "LoginRedirect.html";
@@ -81,9 +78,7 @@
 
     signInRaw = doc.getBytes(HtmlDomUtil.ENC);
     signInGzip = HtmlDomUtil.compress(signInRaw);
-    loginHeader = firstNonNull(
-        emptyToNull(authConfig.getLoginHttpHeader()),
-        AUTHORIZATION);
+    loginHeader = firstNonNull(emptyToNull(authConfig.getLoginHttpHeader()), AUTHORIZATION);
     displaynameHeader = emptyToNull(authConfig.getHttpDisplaynameHeader());
     emailHeader = emptyToNull(authConfig.getHttpEmailHeader());
     externalIdHeader = emptyToNull(authConfig.getHttpExternalIdHeader());
@@ -91,8 +86,8 @@
   }
 
   @Override
-  public void doFilter(final ServletRequest request,
-      final ServletResponse response, final FilterChain chain)
+  public void doFilter(
+      final ServletRequest request, final ServletResponse response, final FilterChain chain)
       throws IOException, ServletException {
     if (isSessionValid((HttpServletRequest) request)) {
       chain.doFilter(request, response);
@@ -132,15 +127,15 @@
   }
 
   private static boolean correctUser(String user, WebSession session) {
-    AccountExternalId.Key id = session.getLastLoginExternalId();
-    return id != null
-        && id.equals(new AccountExternalId.Key(SCHEME_GERRIT, user));
+    ExternalId.Key id = session.getLastLoginExternalId();
+    return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
   }
 
   String getRemoteUser(HttpServletRequest req) {
     String remoteUser = RemoteUserUtil.getRemoteUser(req, loginHeader);
-    return (userNameToLowerCase && remoteUser != null) ?
-        remoteUser.toLowerCase(Locale.US) : remoteUser;
+    return (userNameToLowerCase && remoteUser != null)
+        ? remoteUser.toLowerCase(Locale.US)
+        : remoteUser;
   }
 
   String getRemoteDisplayname(HttpServletRequest req) {
@@ -170,10 +165,8 @@
   }
 
   @Override
-  public void init(final FilterConfig filterConfig) {
-  }
+  public void init(final FilterConfig filterConfig) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index 40e0f60..a8224eb 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
@@ -23,17 +23,22 @@
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.LoginUrlToken;
 import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import javax.servlet.ServletException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.w3c.dom.Document;
@@ -41,26 +46,16 @@
 import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
 
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.ServletOutputStream;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
 /**
  * Initializes the user session if HTTP authentication is enabled.
- * <p>
- * If HTTP authentication has been enabled this servlet binds to {@code /login/}
- * and initializes the user session based on user information contained in the
- * HTTP request.
+ *
+ * <p>If HTTP authentication has been enabled this servlet binds to {@code /login/} and initializes
+ * the user session based on user information contained in the HTTP request.
  */
 @Singleton
 class HttpLoginServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
-  private static final Logger log =
-      LoggerFactory.getLogger(HttpLoginServlet.class);
+  private static final Logger log = LoggerFactory.getLogger(HttpLoginServlet.class);
 
   private final DynamicItem<WebSession> webSession;
   private final CanonicalWebUrl urlProvider;
@@ -69,7 +64,8 @@
   private final AuthConfig authConfig;
 
   @Inject
-  HttpLoginServlet(final DynamicItem<WebSession> webSession,
+  HttpLoginServlet(
+      final DynamicItem<WebSession> webSession,
       final CanonicalWebUrl urlProvider,
       final AccountManager accountManager,
       final HttpAuthFilter authFilter,
@@ -82,18 +78,21 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws ServletException, IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws ServletException, IOException {
     final String token = LoginUrlToken.getToken(req);
 
     CacheHeaders.setNotCacheable(rsp);
     final String user = authFilter.getRemoteUser(req);
     if (user == null || "".equals(user)) {
-      log.error("Unable to authenticate user by " + authFilter.getLoginHeader()
-          + " request header.  Check container or server configuration.");
+      log.error(
+          "Unable to authenticate user by "
+              + authFilter.getLoginHeader()
+              + " request header.  Check container or server configuration.");
 
-      final Document doc = HtmlDomUtil.parseFile( //
-          HttpLoginServlet.class, "ConfigurationError.html");
+      final Document doc =
+          HtmlDomUtil.parseFile( //
+              HttpLoginServlet.class, "ConfigurationError.html");
 
       replace(doc, "loginHeader", authFilter.getLoginHeader());
       replace(doc, "ServerName", req.getServerName());
@@ -126,12 +125,16 @@
     String remoteExternalId = authFilter.getRemoteExternalIdToken(req);
     if (remoteExternalId != null) {
       try {
-        log.debug("Associating external identity \"{}\" to user \"{}\"",
-            remoteExternalId, user);
+        log.debug("Associating external identity \"{}\" to user \"{}\"", remoteExternalId, user);
         updateRemoteExternalId(arsp, remoteExternalId);
       } catch (AccountException | OrmException e) {
-        log.error("Unable to associate external identity \"" + remoteExternalId
-            + "\" to user \"" + user + "\"", e);
+        log.error(
+            "Unable to associate external identity \""
+                + remoteExternalId
+                + "\" to user \""
+                + user
+                + "\"",
+            e);
         rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
         return;
       }
@@ -154,11 +157,9 @@
 
   private void updateRemoteExternalId(AuthResult arsp, String remoteAuthToken)
       throws AccountException, OrmException, IOException {
-    AccountExternalId remoteAuthExtId =
-        new AccountExternalId(arsp.getAccountId(), new AccountExternalId.Key(
-            SCHEME_EXTERNAL, remoteAuthToken));
-    accountManager.updateLink(arsp.getAccountId(),
-        new AuthRequest(remoteAuthExtId.getExternalId()));
+    accountManager.updateLink(
+        arsp.getAccountId(),
+        new AuthRequest(ExternalId.Key.create(SCHEME_EXTERNAL, remoteAuthToken)));
   }
 
   private void replace(Document doc, String name, String value) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
index ea9b1c0..bb3dc6a 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
@@ -22,47 +22,43 @@
 import com.google.gerrit.server.account.AuthResult;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.security.cert.X509Certificate;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
 import javax.servlet.ServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 class HttpsClientSslCertAuthFilter implements Filter {
 
   private static final Pattern REGEX_USERID = Pattern.compile("CN=([^,]*)");
-  private static final Logger log =
-    LoggerFactory.getLogger(HttpsClientSslCertAuthFilter.class);
+  private static final Logger log = LoggerFactory.getLogger(HttpsClientSslCertAuthFilter.class);
 
   private final DynamicItem<WebSession> webSession;
   private final AccountManager accountManager;
 
   @Inject
-  HttpsClientSslCertAuthFilter(final DynamicItem<WebSession> webSession,
-      final AccountManager accountManager) {
+  HttpsClientSslCertAuthFilter(
+      final DynamicItem<WebSession> webSession, final AccountManager accountManager) {
     this.webSession = webSession;
     this.accountManager = accountManager;
   }
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(ServletRequest req, ServletResponse rsp,
-      FilterChain chain) throws IOException, ServletException {
-    X509Certificate[] certs = (X509Certificate[]) req.getAttribute("javax.servlet.request.X509Certificate");
+  public void doFilter(ServletRequest req, ServletResponse rsp, FilterChain chain)
+      throws IOException, ServletException {
+    X509Certificate[] certs =
+        (X509Certificate[]) req.getAttribute("javax.servlet.request.X509Certificate");
     if (certs == null || certs.length == 0) {
       throw new ServletException(
           "Couldn't get the attribute javax.servlet.request.X509Certificate from the request");
@@ -89,6 +85,5 @@
   }
 
   @Override
-  public void init(FilterConfig arg0) throws ServletException {
-  }
+  public void init(FilterConfig arg0) throws ServletException {}
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
index 4c7ce7b..8b14af7 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
@@ -21,21 +21,18 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 /**
- * Servlet bound to {@code /login/*} to redirect after client SSL certificate
- * login.
- * <p>
- * When using client SSL certificate one should normally never see the sign in
- * dialog. However, this will happen if users session gets invalidated in some
- * way. Like in other authentication types, we need to force page to fully
- * reload in order to initialize a new session and create a valid xsrfKey.
+ * Servlet bound to {@code /login/*} to redirect after client SSL certificate login.
+ *
+ * <p>When using client SSL certificate one should normally never see the sign in dialog. However,
+ * this will happen if users session gets invalidated in some way. Like in other authentication
+ * types, we need to force page to fully reload in order to initialize a new session and create a
+ * valid xsrfKey.
  */
 @Singleton
 public class HttpsClientSslCertLoginServlet extends HttpServlet {
@@ -44,13 +41,14 @@
   private final Provider<String> urlProvider;
 
   @Inject
-  public HttpsClientSslCertLoginServlet(@CanonicalWebUrl @Nullable final Provider<String> urlProvider) {
+  public HttpsClientSslCertLoginServlet(
+      @CanonicalWebUrl @Nullable final Provider<String> urlProvider) {
     this.urlProvider = urlProvider;
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     final StringBuilder rdr = new StringBuilder();
     rdr.append(urlProvider.get());
     rdr.append(LoginUrlToken.getToken(req));
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
index a5b4f7a..4671475 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
@@ -30,30 +30,27 @@
 import com.google.gerrit.server.account.AccountUserNameException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.AuthenticationFailedException;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-
 import java.io.IOException;
-
 import javax.servlet.ServletException;
 import javax.servlet.ServletOutputStream;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
 
 /** Handles username/password based authentication against the directory. */
 @SuppressWarnings("serial")
 @Singleton
 class LdapLoginServlet extends HttpServlet {
-  private static final Logger log = LoggerFactory
-      .getLogger(LdapLoginServlet.class);
+  private static final Logger log = LoggerFactory.getLogger(LdapLoginServlet.class);
 
   private final AccountManager accountManager;
   private final DynamicItem<WebSession> webSession;
@@ -61,7 +58,8 @@
   private final SiteHeaderFooter headers;
 
   @Inject
-  LdapLoginServlet(AccountManager accountManager,
+  LdapLoginServlet(
+      AccountManager accountManager,
       DynamicItem<WebSession> webSession,
       CanonicalWebUrl urlProvider,
       SiteHeaderFooter headers) {
@@ -71,8 +69,9 @@
     this.headers = headers;
   }
 
-  private void sendForm(HttpServletRequest req, HttpServletResponse res,
-      @Nullable String errorMessage) throws IOException {
+  private void sendForm(
+      HttpServletRequest req, HttpServletResponse res, @Nullable String errorMessage)
+      throws IOException {
     String self = req.getRequestURI();
     String cancel = MoreObjects.firstNonNull(urlProvider.get(req), "/");
     cancel += LoginUrlToken.getToken(req);
@@ -100,8 +99,7 @@
   }
 
   @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse res)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
     sendForm(req, res, null);
   }
 
@@ -129,10 +127,16 @@
     } catch (AuthenticationUnavailableException e) {
       sendForm(req, res, "Authentication unavailable at this time.");
       return;
-    } catch (AccountException e) {
-      log.info(String.format("'%s' failed to sign in: %s", username, e.getMessage()));
+    } catch (AuthenticationFailedException e) {
+      // This exception is thrown if the user provided wrong credentials, we don't need to log a
+      // stacktrace for it.
+      log.warn("'{}' failed to sign in: {}", username, e.getMessage());
       sendForm(req, res, "Invalid username or password.");
       return;
+    } catch (AccountException e) {
+      log.warn("'{}' failed to sign in", username, e);
+      sendForm(req, res, "Authentication failed.");
+      return;
     } catch (RuntimeException e) {
       log.error("LDAP authentication failed", e);
       sendForm(req, res, "Authentication unavailable at this time.");
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
index b5365ad..67d36e4 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
@@ -21,14 +21,12 @@
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.util.concurrent.TimeUnit;
-
 import javax.servlet.ServletOutputStream;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -64,8 +62,8 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     if (raw != null) {
       rsp.setContentType("image/png");
       rsp.setContentLength(raw.length);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
index abcc4fe..c5a1f18 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
@@ -24,11 +24,9 @@
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.concurrent.TimeUnit;
-
 import javax.servlet.ServletOutputStream;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -56,8 +54,7 @@
   private final byte[] raw_css;
   private final byte[] gz_css;
 
-  GitwebCssServlet(final Path src)
-      throws IOException {
+  GitwebCssServlet(final Path src) throws IOException {
     if (src != null) {
       final Path dir = src.getParent();
       final String name = src.getFileName().toString();
@@ -84,8 +81,8 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     if (raw_css != null) {
       rsp.setContentType("text/css");
       rsp.setCharacterEncoding(UTF_8.name());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
index f30eb52..70f6e4c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
@@ -21,14 +21,12 @@
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.util.concurrent.TimeUnit;
-
 import javax.servlet.ServletOutputStream;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -64,8 +62,8 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     if (raw != null) {
       rsp.setContentType("text/javascript");
       rsp.setContentLength(raw.length);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 4b66023..48fbd6c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.config.GitwebConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -49,14 +50,8 @@
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedInputStream;
 import java.io.BufferedReader;
 import java.io.EOFException;
@@ -75,17 +70,21 @@
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
-
+import java.util.stream.Collectors;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Invokes {@code gitweb.cgi} for the project given in {@code p}. */
 @SuppressWarnings("serial")
 @Singleton
 class GitwebServlet extends HttpServlet {
-  private static final Logger log =
-      LoggerFactory.getLogger(GitwebServlet.class);
+  private static final Logger log = LoggerFactory.getLogger(GitwebServlet.class);
 
   private static final String PROJECT_LIST_ACTION = "project_list";
 
@@ -100,7 +99,8 @@
   private final EnvList _env;
 
   @Inject
-  GitwebServlet(LocalDiskRepositoryManager repoManager,
+  GitwebServlet(
+      GitRepositoryManager repoManager,
       ProjectControl.Factory projectControl,
       Provider<AnonymousUser> anonymousUserProvider,
       Provider<CurrentUser> userProvider,
@@ -110,7 +110,10 @@
       GitwebConfig gitwebConfig,
       GitwebCgiConfig gitwebCgiConfig)
       throws IOException {
-    this.repoManager = repoManager;
+    if (!(repoManager instanceof LocalDiskRepositoryManager)) {
+      throw new ProvisionException("Gitweb can only be used with LocalDiskRepositoryManager");
+    }
+    this.repoManager = (LocalDiskRepositoryManager) repoManager;
     this.projectControl = projectControl;
     this.anonymousUserProvider = anonymousUserProvider;
     this.userProvider = userProvider;
@@ -123,7 +126,7 @@
       try {
         uri = new URI(url);
       } catch (URISyntaxException e) {
-        log.error("Invalid gitweb.url: " + url);
+        log.error("Invalid gitweb.url: {}", url);
       }
       gitwebUrl = uri;
     } else {
@@ -153,8 +156,7 @@
     }
   }
 
-  private void makeSiteConfig(SitePaths site, Config cfg, SshInfo sshInfo)
-      throws IOException {
+  private void makeSiteConfig(SitePaths site, Config cfg, SshInfo sshInfo) throws IOException {
     if (!Files.exists(site.tmp_dir)) {
       Files.createDirectories(site.tmp_dir);
     }
@@ -177,8 +179,7 @@
     _env.set("GIT_DIR", ".");
     _env.set("GITWEB_CONFIG", myconf.toAbsolutePath().toString());
 
-    try (PrintWriter p =
-        new PrintWriter(Files.newBufferedWriter(myconf, UTF_8))) {
+    try (PrintWriter p = new PrintWriter(Files.newBufferedWriter(myconf, UTF_8))) {
       p.print("# Autogenerated by Gerrit Code Review \n");
       p.print("# DO NOT EDIT\n");
       p.print("\n");
@@ -300,9 +301,9 @@
       p.print("      ('review',$r,'commitdiff');\n");
       p.print("}\n");
       p.print("if ($cgi->param('hb')) {\n");
-      p.print("  add_review_link($cgi->param('hb'));\n");
+      p.print("  add_review_link(scalar $cgi->param('hb'));\n");
       p.print("} elsif ($cgi->param('h')) {\n");
-      p.print("  add_review_link($cgi->param('h'));\n");
+      p.print("  add_review_link(scalar $cgi->param('h'));\n");
       p.print("} else {\n");
       p.print("  add_review_link();\n");
       p.print("}\n");
@@ -364,8 +365,8 @@
   }
 
   @Override
-  protected void service(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void service(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     if (req.getQueryString() == null || req.getQueryString().isEmpty()) {
       // No query string? They want the project list, which we don't
       // currently support. Return to Gerrit's own web UI.
@@ -383,8 +384,12 @@
       }
 
       if (a.equals(PROJECT_LIST_ACTION)) {
-        rsp.sendRedirect(req.getContextPath() + "/#" + PageLinks.ADMIN_PROJECTS
-            + "?filter=" + Url.encode(params.get("pf") + "/"));
+        rsp.sendRedirect(
+            req.getContextPath()
+                + "/#"
+                + PageLinks.ADMIN_PROJECTS
+                + "?filter="
+                + Url.encode(params.get("pf") + "/"));
         return;
       }
     }
@@ -403,7 +408,7 @@
     try {
       project = projectControl.validateFor(nameKey);
       if (!project.allRefsAreVisible() && !project.isOwner()) {
-         // Pretend the project doesn't exist
+        // Pretend the project doesn't exist
         throw new NoSuchProjectException(nameKey);
       }
     } catch (NoSuchProjectException e) {
@@ -418,7 +423,6 @@
       return;
     }
 
-
     try (Repository repo = repoManager.openRepository(nameKey)) {
       CacheHeaders.setNotCacheable(rsp);
       exec(req, rsp, project);
@@ -459,13 +463,15 @@
     return params;
   }
 
-  private void exec(final HttpServletRequest req,
-      final HttpServletResponse rsp, final ProjectControl project) throws IOException {
+  private void exec(
+      final HttpServletRequest req, final HttpServletResponse rsp, final ProjectControl project)
+      throws IOException {
     final Process proc =
-        Runtime.getRuntime().exec(
-            new String[] {gitwebCgi.toAbsolutePath().toString()},
-            makeEnv(req, project),
-            gitwebCgi.toAbsolutePath().getParent().toFile());
+        Runtime.getRuntime()
+            .exec(
+                new String[] {gitwebCgi.toAbsolutePath().toString()},
+                makeEnv(req, project),
+                gitwebCgi.toAbsolutePath().getParent().toFile());
 
     copyStderrToLog(proc.getErrorStream());
     if (0 < req.getContentLength()) {
@@ -497,7 +503,7 @@
 
       final int status = proc.exitValue();
       if (0 != status) {
-        log.error("Non-zero exit status (" + status + ") from " + gitwebCgi);
+        log.error("Non-zero exit status ({}) from {}", status, gitwebCgi);
         if (!rsp.isCommitted()) {
           rsp.sendError(500);
         }
@@ -507,8 +513,7 @@
     }
   }
 
-  private String[] makeEnv(final HttpServletRequest req,
-      final ProjectControl project) {
+  private String[] makeEnv(final HttpServletRequest req, final ProjectControl project) {
     final EnvList env = new EnvList(_env);
     final int contentLength = Math.max(0, req.getContentLength());
 
@@ -549,9 +554,9 @@
     env.set("GERRIT_CONTEXT_PATH", req.getContextPath() + "/");
     env.set("GERRIT_PROJECT_NAME", project.getProject().getName());
 
-    env.set("GITWEB_PROJECTROOT",
-        repoManager.getBasePath(project.getProject().getNameKey())
-            .toAbsolutePath().toString());
+    env.set(
+        "GITWEB_PROJECTROOT",
+        repoManager.getBasePath(project.getProject().getNameKey()).toAbsolutePath().toString());
 
     if (project.forUser(anonymousUserProvider.get()).isVisible()) {
       env.set("GERRIT_ANONYMOUS_READ", "1");
@@ -601,67 +606,70 @@
       }
 
       if (gitwebUrl.getPath() != null) {
-        env.set("SCRIPT_NAME", gitwebUrl.getPath().isEmpty()
-                               ? "/" : gitwebUrl.getPath());
+        env.set("SCRIPT_NAME", gitwebUrl.getPath().isEmpty() ? "/" : gitwebUrl.getPath());
       }
     }
 
     return env.getEnvArray();
   }
 
-  private void copyContentToCGI(final HttpServletRequest req,
-      final OutputStream dst) throws IOException {
+  private void copyContentToCGI(final HttpServletRequest req, final OutputStream dst)
+      throws IOException {
     final int contentLength = req.getContentLength();
     final InputStream src = req.getInputStream();
-    new Thread(new Runnable() {
-      @Override
-      public void run() {
-        try {
-          try {
-            final byte[] buf = new byte[bufferSize];
-            int remaining = contentLength;
-            while (0 < remaining) {
-              final int max = Math.max(buf.length, remaining);
-              final int n = src.read(buf, 0, max);
-              if (n < 0) {
-                throw new EOFException("Expected " + remaining + " more bytes");
+    new Thread(
+            () -> {
+              try {
+                try {
+                  final byte[] buf = new byte[bufferSize];
+                  int remaining = contentLength;
+                  while (0 < remaining) {
+                    final int max = Math.max(buf.length, remaining);
+                    final int n = src.read(buf, 0, max);
+                    if (n < 0) {
+                      throw new EOFException("Expected " + remaining + " more bytes");
+                    }
+                    dst.write(buf, 0, n);
+                    remaining -= n;
+                  }
+                } finally {
+                  dst.close();
+                }
+              } catch (IOException e) {
+                log.error("Unexpected error copying input to CGI", e);
               }
-              dst.write(buf, 0, n);
-              remaining -= n;
-            }
-          } finally {
-            dst.close();
-          }
-        } catch (IOException e) {
-          log.debug("Unexpected error copying input to CGI", e);
-        }
-      }
-    }, "Gitweb-InputFeeder").start();
+            },
+            "Gitweb-InputFeeder")
+        .start();
   }
 
   private void copyStderrToLog(final InputStream in) {
-    new Thread(new Runnable() {
-      @Override
-      public void run() {
-        try (BufferedReader br =
-            new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
-          String line;
-          while ((line = br.readLine()) != null) {
-            log.error("CGI: " + line);
-          }
-        } catch (IOException e) {
-          log.debug("Unexpected error copying stderr from CGI", e);
-        }
-      }
-    }, "Gitweb-ErrorLogger").start();
+    new Thread(
+            () -> {
+              try (BufferedReader br =
+                  new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
+                String err =
+                    br.lines()
+                        .filter(s -> !s.isEmpty())
+                        .map(s -> "CGI: " + s)
+                        .collect(Collectors.joining("\n"))
+                        .trim();
+                if (!err.isEmpty()) {
+                  log.error(err);
+                }
+              } catch (IOException e) {
+                log.error("Unexpected error copying stderr from CGI", e);
+              }
+            },
+            "Gitweb-ErrorLogger")
+        .start();
   }
 
   private static Enumeration<String> enumerateHeaderNames(HttpServletRequest req) {
     return req.getHeaderNames();
   }
 
-  private void readCgiHeaders(HttpServletResponse res, final InputStream in)
-      throws IOException {
+  private void readCgiHeaders(HttpServletResponse res, final InputStream in) throws IOException {
     String line;
     while (!(line = readLine(in)).isEmpty()) {
       if (line.startsWith("HTTP")) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java
index b51bfb9..85dd5a5 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/ContextMapper.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.httpd.plugins;
 
 import com.google.common.base.Strings;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletRequestWrapper;
 
@@ -70,5 +69,4 @@
       return pathInfo;
     }
   }
-
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
index 8ae0e5c..9ab2d72 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.server.plugins.AutoRegisterUtil.calculateBindAnnotation;
 
 import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.server.plugins.InvalidPluginException;
 import com.google.gerrit.server.plugins.ModuleGenerator;
@@ -25,17 +25,14 @@
 import com.google.inject.Scopes;
 import com.google.inject.TypeLiteral;
 import com.google.inject.servlet.ServletModule;
-
 import java.lang.annotation.Annotation;
 import java.util.HashMap;
 import java.util.Map;
-
 import javax.servlet.http.HttpServlet;
 
-class HttpAutoRegisterModuleGenerator extends ServletModule
-    implements ModuleGenerator {
+class HttpAutoRegisterModuleGenerator extends ServletModule implements ModuleGenerator {
   private final Map<String, Class<HttpServlet>> serve = new HashMap<>();
-  private final Multimap<TypeLiteral<?>, Class<?>> listeners = LinkedListMultimap.create();
+  private final ListMultimap<TypeLiteral<?>, Class<?>> listeners = LinkedListMultimap.create();
 
   @Override
   protected void configureServlets() {
@@ -56,26 +53,25 @@
   }
 
   @Override
-  public void setPluginName(String name) {
-  }
+  public void setPluginName(String name) {}
 
   @SuppressWarnings("unchecked")
   @Override
-  public void export(Export export, Class<?> type)
-      throws InvalidPluginException {
+  public void export(Export export, Class<?> type) throws InvalidPluginException {
     if (HttpServlet.class.isAssignableFrom(type)) {
       Class<HttpServlet> old = serve.get(export.value());
       if (old != null) {
-        throw new InvalidPluginException(String.format(
-            "@Export(\"%s\") has duplicate bindings:\n  %s\n  %s",
-            export.value(), old.getName(), type.getName()));
+        throw new InvalidPluginException(
+            String.format(
+                "@Export(\"%s\") has duplicate bindings:\n  %s\n  %s",
+                export.value(), old.getName(), type.getName()));
       }
       serve.put(export.value(), (Class<HttpServlet>) type);
     } else {
-      throw new InvalidPluginException(String.format(
-          "Class %s with @Export(\"%s\") must extend %s",
-          type.getName(), export.value(),
-          HttpServlet.class.getName()));
+      throw new InvalidPluginException(
+          String.format(
+              "Class %s with @Export(\"%s\") must extend %s",
+              type.getName(), export.value(), HttpServlet.class.getName()));
     }
   }
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
index 3c72ec5..279903c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd.plugins;
 
+import com.google.gerrit.extensions.api.lfs.LfsDefinitions;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.ResourceWeigher;
@@ -33,34 +34,34 @@
     serveRegex("^/(?:a/)?plugins/(.*)?$").with(HttpPluginServlet.class);
 
     bind(LfsPluginServlet.class);
-    serveRegex(LfsPluginServlet.URL_REGEX).with(LfsPluginServlet.class);
+    serveRegex(LfsDefinitions.LFS_URL_REGEX).with(LfsPluginServlet.class);
 
     bind(StartPluginListener.class)
-      .annotatedWith(UniqueAnnotations.create())
-      .to(HttpPluginServlet.class);
+        .annotatedWith(UniqueAnnotations.create())
+        .to(HttpPluginServlet.class);
 
     bind(ReloadPluginListener.class)
-      .annotatedWith(UniqueAnnotations.create())
-      .to(HttpPluginServlet.class);
+        .annotatedWith(UniqueAnnotations.create())
+        .to(HttpPluginServlet.class);
 
     bind(StartPluginListener.class)
-      .annotatedWith(UniqueAnnotations.create())
-      .to(LfsPluginServlet.class);
+        .annotatedWith(UniqueAnnotations.create())
+        .to(LfsPluginServlet.class);
 
     bind(ReloadPluginListener.class)
-      .annotatedWith(UniqueAnnotations.create())
-      .to(LfsPluginServlet.class);
+        .annotatedWith(UniqueAnnotations.create())
+        .to(LfsPluginServlet.class);
 
-    bind(ModuleGenerator.class)
-      .to(HttpAutoRegisterModuleGenerator.class);
+    bind(ModuleGenerator.class).to(HttpAutoRegisterModuleGenerator.class);
 
-    install(new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(PLUGIN_RESOURCES, ResourceKey.class, Resource.class)
-          .maximumWeight(2 << 20)
-          .weigher(ResourceWeigher.class);
-      }
-    });
+    install(
+        new CacheModule() {
+          @Override
+          protected void configure() {
+            cache(PLUGIN_RESOURCES, ResourceKey.class, Resource.class)
+                .maximumWeight(2 << 20)
+                .weigher(ResourceWeigher.class);
+          }
+        });
   }
 }
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 8594e30..b64b3b3 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
@@ -14,18 +14,22 @@
 
 package com.google.gerrit.httpd.plugins;
 
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
 import static com.google.gerrit.common.FileUtil.lastModified;
 import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CHARACTER_ENCODING;
 import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CONTENT_TYPE;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
+import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.io.ByteStreams;
@@ -36,6 +40,7 @@
 import com.google.gerrit.httpd.resources.SmallResource;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.documentation.MarkdownFormatter;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.plugins.Plugin;
@@ -53,12 +58,6 @@
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import com.google.inject.servlet.GuiceFilter;
-
-import org.eclipse.jgit.util.IO;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
@@ -73,11 +72,12 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.ConcurrentMap;
+import java.util.function.Predicate;
 import java.util.jar.Attributes;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-
 import javax.servlet.FilterChain;
 import javax.servlet.ServletConfig;
 import javax.servlet.ServletContext;
@@ -87,14 +87,18 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.apache.commons.lang.StringUtils;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.IO;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
-class HttpPluginServlet extends HttpServlet
-    implements StartPluginListener, ReloadPluginListener {
+class HttpPluginServlet extends HttpServlet implements StartPluginListener, ReloadPluginListener {
   private static final int SMALL_RESOURCE = 128 * 1024;
   private static final long serialVersionUID = 1L;
-  private static final Logger log
-      = LoggerFactory.getLogger(HttpPluginServlet.class);
+  private static final Logger log = LoggerFactory.getLogger(HttpPluginServlet.class);
 
   private final MimeUtilFileTypeRegistry mimeUtil;
   private final Provider<String> webUrl;
@@ -105,8 +109,8 @@
 
   private List<Plugin> pending = new ArrayList<>();
   private ContextMapper wrapper;
-  private final ConcurrentMap<String, PluginHolder> plugins
-      = Maps.newConcurrentMap();
+  private final ConcurrentMap<String, PluginHolder> plugins = Maps.newConcurrentMap();
+  private final Pattern allowOrigin;
 
   @Inject
   HttpPluginServlet(
@@ -115,7 +119,8 @@
       @Named(HttpPluginModule.PLUGIN_RESOURCES) Cache<ResourceKey, Resource> cache,
       SshInfo sshInfo,
       RestApiServlet.Globals globals,
-      PluginsCollection plugins) {
+      PluginsCollection plugins,
+      @GerritServerConfig Config cfg) {
     this.mimeUtil = mimeUtil;
     this.webUrl = webUrl;
     this.resourceCache = cache;
@@ -136,6 +141,7 @@
     }
     this.sshHost = sshHost;
     this.sshPort = sshPort;
+    this.allowOrigin = makeAllowOrigin(cfg);
   }
 
   @Override
@@ -167,12 +173,13 @@
     GuiceFilter filter = load(plugin);
     final String name = plugin.getName();
     final PluginHolder holder = new PluginHolder(plugin, filter);
-    plugin.add(new RegistrationHandle() {
-      @Override
-      public void remove() {
-        plugins.remove(name, holder);
-      }
-    });
+    plugin.add(
+        new RegistrationHandle() {
+          @Override
+          public void remove() {
+            plugins.remove(name, holder);
+          }
+        });
     plugins.put(name, holder);
   }
 
@@ -183,25 +190,25 @@
       try {
         filter = plugin.getHttpInjector().getInstance(GuiceFilter.class);
       } catch (RuntimeException e) {
-        log.warn(String.format("Plugin %s cannot load GuiceFilter", name), e);
+        log.warn("Plugin {} cannot load GuiceFilter", name, e);
         return null;
       }
 
       try {
-        ServletContext ctx =
-            PluginServletContext.create(plugin, wrapper.getFullPath(name));
+        ServletContext ctx = PluginServletContext.create(plugin, wrapper.getFullPath(name));
         filter.init(new WrappedFilterConfig(ctx));
       } catch (ServletException e) {
-        log.warn(String.format("Plugin %s failed to initialize HTTP", name), e);
+        log.warn("Plugin {} failed to initialize HTTP", name, e);
         return null;
       }
 
-      plugin.add(new RegistrationHandle() {
-        @Override
-        public void remove() {
-          filter.destroy();
-        }
-      });
+      plugin.add(
+          new RegistrationHandle() {
+            @Override
+            public void remove() {
+              filter.destroy();
+            }
+          });
       return filter;
     }
     return null;
@@ -210,9 +217,12 @@
   @Override
   public void service(HttpServletRequest req, HttpServletResponse res)
       throws IOException, ServletException {
-    List<String> parts = Lists.newArrayList(
-      Splitter.on('/').limit(3).omitEmptyStrings()
-        .split(Strings.nullToEmpty(RequestUtil.getEncodedPathInfo(req))));
+    List<String> parts =
+        Lists.newArrayList(
+            Splitter.on('/')
+                .limit(3)
+                .omitEmptyStrings()
+                .split(Strings.nullToEmpty(RequestUtil.getEncodedPathInfo(req))));
 
     if (isApiCall(req, parts)) {
       managerApi.service(req, res);
@@ -228,13 +238,13 @@
     }
 
     HttpServletRequest wr = wrapper.create(req, name);
-    FilterChain chain = new FilterChain() {
-      @Override
-      public void doFilter(ServletRequest req, ServletResponse res)
-          throws IOException {
-        onDefault(holder, (HttpServletRequest) req, (HttpServletResponse) res);
-      }
-    };
+    FilterChain chain =
+        new FilterChain() {
+          @Override
+          public void doFilter(ServletRequest req, ServletResponse res) throws IOException {
+            onDefault(holder, (HttpServletRequest) req, (HttpServletResponse) res);
+          }
+        };
     if (holder.filter != null) {
       holder.filter.doFilter(wr, res, chain);
     } else {
@@ -250,9 +260,8 @@
         || (cnt == 2 && parts.get(1).startsWith("gerrit~"));
   }
 
-  private void onDefault(PluginHolder holder,
-      HttpServletRequest req,
-      HttpServletResponse res) throws IOException {
+  private void onDefault(PluginHolder holder, HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
     if (!"GET".equals(req.getMethod()) && !"HEAD".equals(req.getMethod())) {
       CacheHeaders.setNotCacheable(res);
       res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
@@ -265,6 +274,8 @@
       return;
     }
 
+    checkCors(req, res);
+
     String file = pathInfo.substring(1);
     PluginResourceKey key = PluginResourceKey.create(holder.plugin, file);
     Resource rsc = resourceCache.getIfPresent(key);
@@ -296,8 +307,7 @@
           Resource.NOT_FOUND.send(req, res);
         }
       }
-    } else if (file.equals(
-        holder.docPrefix.substring(0, holder.docPrefix.length() - 1))) {
+    } else if (file.equals(holder.docPrefix.substring(0, holder.docPrefix.length() - 1))) {
       res.sendRedirect(uri + "/index.html");
     } else if (file.startsWith(holder.docPrefix) && file.endsWith("/")) {
       res.sendRedirect(uri + "index.html");
@@ -313,15 +323,13 @@
         if (hasUpToDateCachedResource(rsc, pluginLastModified)) {
           rsc.send(req, res);
         } else {
-          sendAutoIndex(scanner, pfx, holder.plugin.getName(), key, res,
-              pluginLastModified);
+          sendAutoIndex(scanner, pfx, holder.plugin.getName(), key, res, pluginLastModified);
         }
       } else if (entry.isPresent() && entry.get().getName().endsWith(".md")) {
         if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
           rsc.send(req, res);
         } else {
-          sendMarkdownAsHtml(scanner, entry.get(), holder.plugin.getName(),
-              key, res);
+          sendMarkdownAsHtml(scanner, entry.get(), holder.plugin.getName(), key, res);
         }
       } else if (entry.isPresent()) {
         if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
@@ -339,14 +347,44 @@
     }
   }
 
-  private boolean hasUpToDateCachedResource(Resource cachedResource, long lastUpdateTime) {
-    return cachedResource != null
-        && cachedResource.isUnchanged(lastUpdateTime);
+  private static Pattern makeAllowOrigin(Config cfg) {
+    String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
+    if (allow.length > 0) {
+      return Pattern.compile(Joiner.on('|').join(allow));
+    }
+    return null;
   }
 
-  private void appendEntriesSection(PluginContentScanner scanner, List<PluginEntry> entries,
-      String sectionTitle, StringBuilder md, String prefix,
-      int nameOffset) throws IOException {
+  private void checkCors(HttpServletRequest req, HttpServletResponse res) {
+    String origin = req.getHeader(ORIGIN);
+    if (!Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) {
+      res.addHeader(VARY, ORIGIN);
+      setCorsHeaders(res, origin);
+    }
+  }
+
+  private void setCorsHeaders(HttpServletResponse res, String origin) {
+    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD");
+  }
+
+  private boolean isOriginAllowed(String origin) {
+    return allowOrigin == null || allowOrigin.matcher(origin).matches();
+  }
+
+  private boolean hasUpToDateCachedResource(Resource cachedResource, long lastUpdateTime) {
+    return cachedResource != null && cachedResource.isUnchanged(lastUpdateTime);
+  }
+
+  private void appendEntriesSection(
+      PluginContentScanner scanner,
+      List<PluginEntry> entries,
+      String sectionTitle,
+      StringBuilder md,
+      String prefix,
+      int nameOffset)
+      throws IOException {
     if (!entries.isEmpty()) {
       md.append("## ").append(sectionTitle).append(" ##\n");
       for (PluginEntry entry : entries) {
@@ -359,7 +397,6 @@
           if (Strings.isNullOrEmpty(entryTitle)) {
             entryTitle = rsrc.substring(nameOffset, rsrc.length() - 3).replace('-', ' ');
           }
-          rsrc = rsrc.substring(0, rsrc.length() - 3) + ".html";
         } else {
           entryTitle = rsrc.substring(nameOffset).replace('-', ' ');
         }
@@ -369,9 +406,13 @@
     }
   }
 
-  private void sendAutoIndex(PluginContentScanner scanner,
-      final String prefix, final String pluginName,
-      PluginResourceKey cacheKey, HttpServletResponse res,long lastModifiedTime)
+  private void sendAutoIndex(
+      PluginContentScanner scanner,
+      final String prefix,
+      final String pluginName,
+      PluginResourceKey cacheKey,
+      HttpServletResponse res,
+      long lastModifiedTime)
       throws IOException {
     List<PluginEntry> cmds = new ArrayList<>();
     List<PluginEntry> servlets = new ArrayList<>();
@@ -379,35 +420,30 @@
     List<PluginEntry> docs = new ArrayList<>();
     PluginEntry about = null;
 
-    Predicate<PluginEntry> filter = new Predicate<PluginEntry>() {
-      @Override
-      public boolean apply(PluginEntry entry) {
-        String name = entry.getName();
-        Optional<Long> size = entry.getSize();
-        if (name.startsWith(prefix)
-            && (name.endsWith(".md") || name.endsWith(".html"))
-            && size.isPresent()) {
-          if (size.get() <= 0 || size.get() > SMALL_RESOURCE) {
-            log.warn(String.format(
-                "Plugin %s: %s omitted from document index. "
-                  + "Size %d out of range (0,%d).",
-                pluginName,
-                name.substring(prefix.length()),
-                size.get(),
-                SMALL_RESOURCE));
-            return false;
+    Predicate<PluginEntry> filter =
+        entry -> {
+          String name = entry.getName();
+          Optional<Long> size = entry.getSize();
+          if (name.startsWith(prefix)
+              && (name.endsWith(".md") || name.endsWith(".html"))
+              && size.isPresent()) {
+            if (size.get() <= 0 || size.get() > SMALL_RESOURCE) {
+              log.warn(
+                  "Plugin {}: {} omitted from document index. Size {} out of range (0,{}).",
+                  pluginName,
+                  name.substring(prefix.length()),
+                  size.get(),
+                  SMALL_RESOURCE);
+              return false;
+            }
+            return true;
           }
-          return true;
-        }
-        return false;
-      }
-    };
+          return false;
+        };
 
-    List<PluginEntry> entries = FluentIterable
-        .from(Collections.list(scanner.entries()))
-        .filter(filter)
-        .toList();
-    for (PluginEntry entry: entries) {
+    List<PluginEntry> entries =
+        Collections.list(scanner.entries()).stream().filter(filter).collect(toList());
+    for (PluginEntry entry : entries) {
       String name = entry.getName().substring(prefix.length());
       if (name.startsWith("cmd-")) {
         cmds.add(entry);
@@ -419,10 +455,10 @@
         if (about == null) {
           about = entry;
         } else {
-          log.warn(String.format(
-              "Plugin %s: Multiple 'about' documents found; using %s",
+          log.warn(
+              "Plugin {}: Multiple 'about' documents found; using {}",
               pluginName,
-              about.getName().substring(prefix.length())));
+              about.getName().substring(prefix.length()));
         }
       } else {
         docs.add(entry);
@@ -438,12 +474,12 @@
     appendPluginInfoTable(md, scanner.getManifest().getMainAttributes());
 
     if (about != null) {
-      InputStreamReader isr = new InputStreamReader(scanner.getInputStream(about));
+      InputStreamReader isr = new InputStreamReader(scanner.getInputStream(about), UTF_8);
       StringBuilder aboutContent = new StringBuilder();
       try (BufferedReader reader = new BufferedReader(isr)) {
         String line;
         while ((line = reader.readLine()) != null) {
-          line = line.trim();
+          line = StringUtils.stripEnd(line, null);
           if (line.isEmpty()) {
             aboutContent.append("\n");
           } else {
@@ -467,8 +503,12 @@
     sendMarkdownAsHtml(md.toString(), pluginName, cacheKey, res, lastModifiedTime);
   }
 
-  private void sendMarkdownAsHtml(String md, String pluginName,
-      PluginResourceKey cacheKey, HttpServletResponse res, long lastModifiedTime)
+  private void sendMarkdownAsHtml(
+      String md,
+      String pluginName,
+      PluginResourceKey cacheKey,
+      HttpServletResponse res,
+      long lastModifiedTime)
       throws UnsupportedEncodingException, IOException {
     Map<String, String> macros = new HashMap<>();
     macros.put("PLUGIN", pluginName);
@@ -495,12 +535,13 @@
     }
     m.appendTail(sb);
 
-    byte[] html = new MarkdownFormatter()
-      .markdownToDocHtml(sb.toString(), UTF_8.name());
-    resourceCache.put(cacheKey, new SmallResource(html)
-        .setContentType("text/html")
-        .setCharacterEncoding(UTF_8.name())
-        .setLastModified(lastModifiedTime));
+    byte[] html = new MarkdownFormatter().markdownToDocHtml(sb.toString(), UTF_8.name());
+    resourceCache.put(
+        cacheKey,
+        new SmallResource(html)
+            .setContentType("text/html")
+            .setCharacterEncoding(UTF_8.name())
+            .setLastModified(lastModifiedTime));
     res.setContentType("text/html");
     res.setCharacterEncoding(UTF_8.name());
     res.setContentLength(html.length);
@@ -517,31 +558,23 @@
 
       html.append("<table class=\"plugin_info\">");
       if (!Strings.isNullOrEmpty(t)) {
-        html.append("<tr><th>Name</th><td>")
-            .append(t)
-            .append("</td></tr>\n");
+        html.append("<tr><th>Name</th><td>").append(t).append("</td></tr>\n");
       }
       if (!Strings.isNullOrEmpty(n)) {
-        html.append("<tr><th>Vendor</th><td>")
-            .append(n)
-            .append("</td></tr>\n");
+        html.append("<tr><th>Vendor</th><td>").append(n).append("</td></tr>\n");
       }
       if (!Strings.isNullOrEmpty(v)) {
-        html.append("<tr><th>Version</th><td>")
-            .append(v)
-            .append("</td></tr>\n");
+        html.append("<tr><th>Version</th><td>").append(v).append("</td></tr>\n");
       }
       if (!Strings.isNullOrEmpty(a)) {
-        html.append("<tr><th>API Version</th><td>")
-            .append(a)
-            .append("</td></tr>\n");
+        html.append("<tr><th>API Version</th><td>").append(a).append("</td></tr>\n");
       }
       html.append("</table>\n");
     }
   }
 
   private static String extractTitleFromMarkdown(PluginContentScanner scanner, PluginEntry entry)
-        throws IOException {
+      throws IOException {
     String charEnc = null;
     Map<Object, String> atts = entry.getAttrs();
     if (atts != null) {
@@ -550,22 +583,25 @@
     if (charEnc == null) {
       charEnc = UTF_8.name();
     }
-    return new MarkdownFormatter().extractTitleFromMarkdown(
-          readWholeEntry(scanner, entry),
-          charEnc);
+    return new MarkdownFormatter()
+        .extractTitleFromMarkdown(readWholeEntry(scanner, entry), charEnc);
   }
 
-  private static Optional<PluginEntry> findSource(
-      PluginContentScanner scanner, String file) throws IOException {
+  private static Optional<PluginEntry> findSource(PluginContentScanner scanner, String file)
+      throws IOException {
     if (file.endsWith(".html")) {
       int d = file.lastIndexOf('.');
       return scanner.getEntry(file.substring(0, d) + ".md");
     }
-    return Optional.absent();
+    return Optional.empty();
   }
 
-  private void sendMarkdownAsHtml(PluginContentScanner scanner, PluginEntry entry,
-      String pluginName, PluginResourceKey key, HttpServletResponse res)
+  private void sendMarkdownAsHtml(
+      PluginContentScanner scanner,
+      PluginEntry entry,
+      String pluginName,
+      PluginResourceKey key,
+      HttpServletResponse res)
       throws IOException {
     byte[] rawmd = readWholeEntry(scanner, entry);
     String encoding = null;
@@ -574,9 +610,8 @@
       encoding = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
     }
 
-    String txtmd = RawParseUtils.decode(
-        Charset.forName(encoding != null ? encoding : UTF_8.name()),
-        rawmd);
+    String txtmd =
+        RawParseUtils.decode(Charset.forName(encoding != null ? encoding : UTF_8.name()), rawmd);
     long time = entry.getTime();
     if (0 < time) {
       res.setDateHeader("Last-Modified", time);
@@ -584,8 +619,11 @@
     sendMarkdownAsHtml(txtmd, pluginName, key, res, time);
   }
 
-  private void sendResource(PluginContentScanner scanner, PluginEntry entry,
-      PluginResourceKey key, HttpServletResponse res)
+  private void sendResource(
+      PluginContentScanner scanner,
+      PluginEntry entry,
+      PluginResourceKey key,
+      HttpServletResponse res)
       throws IOException {
     byte[] data = null;
     Optional<Long> size = entry.getSize();
@@ -602,8 +640,7 @@
     }
     if (contentType == null) {
       contentType = mimeUtil.getMimeType(entry.getName(), data).toString();
-      if ("application/octet-stream".equals(contentType)
-          && entry.getName().endsWith(".js")) {
+      if ("application/octet-stream".equals(contentType) && entry.getName().endsWith(".js")) {
         contentType = "application/javascript";
       } else if ("application/x-pointplus".equals(contentType)
           && entry.getName().endsWith(".css")) {
@@ -623,21 +660,23 @@
       res.setCharacterEncoding(charEnc);
     }
     if (data != null) {
-      resourceCache.put(key, new SmallResource(data)
-          .setContentType(contentType)
-          .setCharacterEncoding(charEnc)
-          .setLastModified(time));
+      resourceCache.put(
+          key,
+          new SmallResource(data)
+              .setContentType(contentType)
+              .setCharacterEncoding(charEnc)
+              .setLastModified(time));
       res.getOutputStream().write(data);
     } else {
       writeToResponse(res, scanner.getInputStream(entry));
     }
   }
 
-  private void sendJsPlugin(Plugin plugin, PluginResourceKey key,
-      HttpServletRequest req, HttpServletResponse res) throws IOException {
+  private void sendJsPlugin(
+      Plugin plugin, PluginResourceKey key, HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
     Path path = plugin.getSrcFile();
-    if (req.getRequestURI().endsWith(getJsPluginPath(plugin))
-        && Files.exists(path)) {
+    if (req.getRequestURI().endsWith(getJsPluginPath(plugin)) && Files.exists(path)) {
       res.setHeader("Content-Length", Long.toString(Files.size(path)));
       res.setContentType("application/javascript");
       writeToResponse(res, Files.newInputStream(path));
@@ -648,8 +687,8 @@
   }
 
   private static String getJsPluginPath(Plugin plugin) {
-    return String.format("/plugins/%s/static/%s", plugin.getName(),
-        plugin.getSrcFile().getFileName());
+    return String.format(
+        "/plugins/%s/static/%s", plugin.getName(), plugin.getSrcFile().getFileName());
   }
 
   private void writeToResponse(HttpServletResponse res, InputStream inputStream)
@@ -676,10 +715,8 @@
     PluginHolder(Plugin plugin, GuiceFilter filter) {
       this.plugin = plugin;
       this.filter = filter;
-      this.staticPrefix =
-        getPrefix(plugin, "Gerrit-HttpStaticPrefix", "static/");
-      this.docPrefix =
-        getPrefix(plugin, "Gerrit-HttpDocumentationPrefix", "Documentation/");
+      this.staticPrefix = getPrefix(plugin, "Gerrit-HttpStaticPrefix", "static/");
+      this.docPrefix = getPrefix(plugin, "Gerrit-HttpDocumentationPrefix", "Documentation/");
     }
 
     private static String getPrefix(Plugin plugin, String attr, String def) {
@@ -689,15 +726,13 @@
         return def;
       }
       try {
-        String prefix =
-            scanner.getManifest().getMainAttributes().getValue(attr);
+        String prefix = scanner.getManifest().getMainAttributes().getValue(attr);
         if (prefix != null) {
           return CharMatcher.is('/').trimFrom(prefix) + "/";
         }
         return def;
       } catch (IOException e) {
-        log.warn(String.format("Error getting %s for plugin %s, using default",
-            attr, plugin.getName()), e);
+        log.warn("Error getting {} for plugin {}, using default", attr, plugin.getName(), e);
         return null;
       }
     }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
index d298026..e13ea95 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd.plugins;
 
+import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.CONTENTTYPE_VND_GIT_LFS_JSON;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
 
@@ -27,11 +28,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.GuiceFilter;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.OutputStreamWriter;
@@ -39,7 +35,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
-
 import javax.servlet.FilterChain;
 import javax.servlet.ServletConfig;
 import javax.servlet.ServletContext;
@@ -49,21 +44,15 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class LfsPluginServlet extends HttpServlet
     implements StartPluginListener, ReloadPluginListener {
   private static final long serialVersionUID = 1L;
-  private static final Logger log
-      = LoggerFactory.getLogger(LfsPluginServlet.class);
-
-  public static final String LFS_REST =
-      "(?:/p/|/)(.+)(?:/info/lfs/objects/batch)$";
-  public static final String URL_REGEX =
-      "^(?:/a)?" + LFS_REST;
-
-  private static final String CONTENTTYPE_VND_GIT_LFS_JSON =
-      "application/vnd.git-lfs+json; charset=utf-8";
+  private static final Logger log = LoggerFactory.getLogger(LfsPluginServlet.class);
   private static final String MESSAGE_LFS_NOT_CONFIGURED =
       "{\"message\":\"No LFS plugin is configured to handle LFS requests.\"}";
 
@@ -75,14 +64,13 @@
   @Inject
   LfsPluginServlet(@GerritServerConfig Config cfg) {
     this.pluginName = cfg.getString("lfs", null, "plugin");
-    this.chain = new FilterChain() {
-      @Override
-      public void doFilter(ServletRequest req, ServletResponse res)
-          throws IOException {
-        Resource.NOT_FOUND.send(
-            (HttpServletRequest) req, (HttpServletResponse) res);
-      }
-    };
+    this.chain =
+        new FilterChain() {
+          @Override
+          public void doFilter(ServletRequest req, ServletResponse res) throws IOException {
+            Resource.NOT_FOUND.send((HttpServletRequest) req, (HttpServletResponse) res);
+          }
+        };
     this.filter = new AtomicReference<>();
   }
 
@@ -120,13 +108,11 @@
     install(newPlugin);
   }
 
-  private void responseLfsNotConfigured(HttpServletResponse res)
-      throws IOException {
+  private void responseLfsNotConfigured(HttpServletResponse res) throws IOException {
     CacheHeaders.setNotCacheable(res);
     res.setContentType(CONTENTTYPE_VND_GIT_LFS_JSON);
     res.setStatus(SC_NOT_IMPLEMENTED);
-    Writer w = new BufferedWriter(
-        new OutputStreamWriter(res.getOutputStream(), UTF_8));
+    Writer w = new BufferedWriter(new OutputStreamWriter(res.getOutputStream(), UTF_8));
     w.write(MESSAGE_LFS_NOT_CONFIGURED);
     w.flush();
   }
@@ -136,12 +122,13 @@
       return;
     }
     final GuiceFilter guiceFilter = load(plugin);
-    plugin.add(new RegistrationHandle() {
-      @Override
-      public void remove() {
-        filter.compareAndSet(guiceFilter, null);
-      }
-    });
+    plugin.add(
+        new RegistrationHandle() {
+          @Override
+          public void remove() {
+            filter.compareAndSet(guiceFilter, null);
+          }
+        });
     filter.set(guiceFilter);
   }
 
@@ -152,25 +139,25 @@
       try {
         guiceFilter = plugin.getHttpInjector().getInstance(GuiceFilter.class);
       } catch (RuntimeException e) {
-        log.warn(String.format("Plugin %s cannot load GuiceFilter", name), e);
+        log.warn("Plugin {} cannot load GuiceFilter", name, e);
         return null;
       }
 
       try {
-        ServletContext ctx =
-            PluginServletContext.create(plugin, "/");
+        ServletContext ctx = PluginServletContext.create(plugin, "/");
         guiceFilter.init(new WrappedFilterConfig(ctx));
       } catch (ServletException e) {
-        log.warn(String.format("Plugin %s failed to initialize HTTP", name), e);
+        log.warn("Plugin {} failed to initialize HTTP", name, e);
         return null;
       }
 
-      plugin.add(new RegistrationHandle() {
-        @Override
-        public void remove() {
-          guiceFilter.destroy();
-        }
-      });
+      plugin.add(
+          new RegistrationHandle() {
+            @Override
+            public void remove() {
+              guiceFilter.destroy();
+            }
+          });
       return guiceFilter;
     }
     return null;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java
index 0a15a4f..a306aa6 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java
@@ -25,6 +25,7 @@
   }
 
   public abstract Plugin.CacheKey plugin();
+
   public abstract String resource();
 
   @Override
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
index 476dba8..8f64d9f 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
@@ -17,10 +17,6 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.server.plugins.Plugin;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.InputStream;
 import java.lang.reflect.InvocationHandler;
 import java.lang.reflect.Method;
@@ -30,24 +26,24 @@
 import java.util.Enumeration;
 import java.util.Set;
 import java.util.concurrent.ConcurrentMap;
-
 import javax.servlet.RequestDispatcher;
 import javax.servlet.Servlet;
 import javax.servlet.ServletContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class PluginServletContext {
-  private static final Logger log =
-      LoggerFactory.getLogger(PluginServletContext.class);
+  private static final Logger log = LoggerFactory.getLogger(PluginServletContext.class);
 
   static ServletContext create(Plugin plugin, String contextPath) {
-    return (ServletContext) Proxy.newProxyInstance(
-        PluginServletContext.class.getClassLoader(),
-        new Class[] {ServletContext.class, API.class},
-        new Handler(plugin, contextPath));
+    return (ServletContext)
+        Proxy.newProxyInstance(
+            PluginServletContext.class.getClassLoader(),
+            new Class<?>[] {ServletContext.class, API.class},
+            new Handler(plugin, contextPath));
   }
 
-  private PluginServletContext() {
-  }
+  private PluginServletContext() {}
 
   private static class Handler implements InvocationHandler, API {
     private final Plugin plugin;
@@ -61,18 +57,14 @@
     }
 
     @Override
-    public Object invoke(Object proxy, Method method, Object[] args)
-        throws Throwable {
+    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
       Method handler;
       try {
-        handler = API.class.getDeclaredMethod(
-            method.getName(),
-            method.getParameterTypes());
+        handler = API.class.getDeclaredMethod(method.getName(), method.getParameterTypes());
       } catch (NoSuchMethodException e) {
-        throw new NoSuchMethodError(String.format(
-            "%s does not implement %s",
-            PluginServletContext.class,
-            method.toGenericString()));
+        throw new NoSuchMethodError(
+            String.format(
+                "%s does not implement %s", PluginServletContext.class, method.toGenericString()));
       }
       return handler.invoke(this, args);
     }
@@ -163,7 +155,7 @@
 
     @Override
     public void log(String msg, Throwable reason) {
-      log.warn(String.format("[plugin %s] %s", plugin.getName(), msg), reason);
+      log.warn("[plugin {}] {}", plugin.getName(), msg, reason);
     }
 
     @Override
@@ -210,33 +202,57 @@
 
   interface API {
     String getContextPath();
+
     String getInitParameter(String name);
+
     @SuppressWarnings("rawtypes")
     Enumeration getInitParameterNames();
+
     ServletContext getContext(String name);
+
     RequestDispatcher getNamedDispatcher(String name);
+
     RequestDispatcher getRequestDispatcher(String name);
+
     URL getResource(String name);
+
     InputStream getResourceAsStream(String name);
+
     @SuppressWarnings("rawtypes")
     Set getResourcePaths(String name);
+
     Servlet getServlet(String name);
+
     String getRealPath(String name);
+
     String getServletContextName();
+
     @SuppressWarnings("rawtypes")
     Enumeration getServletNames();
+
     @SuppressWarnings("rawtypes")
     Enumeration getServlets();
+
     void log(Exception reason, String msg);
+
     void log(String msg);
+
     void log(String msg, Throwable reason);
+
     Object getAttribute(String name);
+
     Enumeration<String> getAttributeNames();
+
     void setAttribute(String name, Object value);
+
     void removeAttribute(String name);
+
     String getMimeType(String file);
+
     int getMajorVersion();
+
     int getMinorVersion();
+
     String getServerInfo();
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
index 04e49c9..b415241 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
@@ -15,10 +15,8 @@
 package com.google.gerrit.httpd.plugins;
 
 import com.google.inject.servlet.GuiceFilter;
-
 import java.util.Collections;
 import java.util.Enumeration;
-
 import javax.servlet.FilterConfig;
 import javax.servlet.ServletContext;
 
@@ -39,10 +37,9 @@
     return null;
   }
 
-  @SuppressWarnings({"rawtypes", "unchecked"})
   @Override
-  public Enumeration getInitParameterNames() {
-    return Collections.enumeration(Collections.emptyList());
+  public Enumeration<String> getInitParameterNames() {
+    return Collections.emptyEnumeration();
   }
 
   @Override
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java
new file mode 100644
index 0000000..f52792c
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java
@@ -0,0 +1,165 @@
+// 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.httpd.raw;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Joiner;
+import com.google.common.escape.Escaper;
+import com.google.common.html.HtmlEscapers;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gwtexpui.server.CacheHeaders;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.util.Properties;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class BazelBuild {
+  private static final Logger log = LoggerFactory.getLogger(BazelBuild.class);
+
+  private final Path sourceRoot;
+
+  public BazelBuild(Path sourceRoot) {
+    this.sourceRoot = sourceRoot;
+  }
+
+  // builds the given label.
+  public void build(Label label) throws IOException, BuildFailureException {
+    ProcessBuilder proc = newBuildProcess(label);
+    proc.directory(sourceRoot.toFile()).redirectErrorStream(true);
+    log.info("building " + label.fullName());
+    long start = TimeUtil.nowMs();
+    Process rebuild = proc.start();
+    byte[] out;
+    try (InputStream in = rebuild.getInputStream()) {
+      out = ByteStreams.toByteArray(in);
+    } finally {
+      rebuild.getOutputStream().close();
+    }
+
+    int status;
+    try {
+      status = rebuild.waitFor();
+    } catch (InterruptedException e) {
+      throw new InterruptedIOException(
+          "interrupted waiting for: " + Joiner.on(' ').join(proc.command()));
+    }
+    if (status != 0) {
+      log.warn("build failed: " + new String(out, UTF_8));
+      throw new BuildFailureException(out);
+    }
+
+    long time = TimeUtil.nowMs() - start;
+    log.info(String.format("UPDATED    %s in %.3fs", label.fullName(), time / 1000.0));
+  }
+
+  // Represents a label in bazel.
+  static class Label {
+    protected final String pkg;
+    protected final String name;
+
+    public String fullName() {
+      return "//" + pkg + ":" + name;
+    }
+
+    @Override
+    public String toString() {
+      return fullName();
+    }
+
+    // Label in Bazel style.
+    Label(String pkg, String name) {
+      this.name = name;
+      this.pkg = pkg;
+    }
+  }
+
+  static class BuildFailureException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    final byte[] why;
+
+    BuildFailureException(byte[] why) {
+      this.why = why;
+    }
+
+    public void display(String rule, HttpServletResponse res) throws IOException {
+      res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      res.setContentType("text/html");
+      res.setCharacterEncoding(UTF_8.name());
+      CacheHeaders.setNotCacheable(res);
+
+      Escaper html = HtmlEscapers.htmlEscaper();
+      try (PrintWriter w = res.getWriter()) {
+        w.write("<html><title>BUILD FAILED</title><body>");
+        w.format("<h1>%s FAILED</h1>", html.escape(rule));
+        w.write("<pre>");
+        w.write(html.escape(RawParseUtils.decode(why)));
+        w.write("</pre>");
+        w.write("</body></html>");
+      }
+    }
+  }
+
+  private Properties loadBuildProperties(Path propPath) throws IOException {
+    Properties properties = new Properties();
+    try (InputStream in = Files.newInputStream(propPath)) {
+      properties.load(in);
+    } catch (NoSuchFileException e) {
+      // Ignore; will be run from PATH, with a descriptive error if it fails.
+    }
+    return properties;
+  }
+
+  private ProcessBuilder newBuildProcess(Label label) throws IOException {
+    Properties properties = loadBuildProperties(sourceRoot.resolve(".bazel_path"));
+    String bazel = firstNonNull(properties.getProperty("bazel"), "bazel");
+    ProcessBuilder proc = new ProcessBuilder(bazel, "build", label.fullName());
+    if (properties.containsKey("PATH")) {
+      proc.environment().put("PATH", properties.getProperty("PATH"));
+    }
+    return proc;
+  }
+
+  /** returns the root relative path to the artifact for the given label */
+  public Path targetPath(Label l) {
+    return sourceRoot.resolve("bazel-bin").resolve(l.pkg).resolve(l.name);
+  }
+
+  /** Label for the agent specific GWT zip. */
+  public Label gwtZipLabel(String agent) {
+    return new Label("gerrit-gwtui", "ui_" + agent + ".zip");
+  }
+
+  /** Label for the polygerrit component zip. */
+  public Label polygerritComponents() {
+    return new Label("polygerrit-ui", "polygerrit_components.bower_components.zip");
+  }
+
+  /** Label for the fonts zip file. */
+  public Label fontZipLabel() {
+    return new Label("polygerrit-ui", "fonts.zip");
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
new file mode 100644
index 0000000..1be3045
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsDevServlet.java
@@ -0,0 +1,49 @@
+// 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.httpd.raw;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.launcher.GerritLauncher;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Objects;
+
+/* Bower component servlet only used in development mode */
+class BowerComponentsDevServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path bowerComponents;
+  private final Path zip;
+
+  BowerComponentsDevServlet(Cache<Path, Resource> cache, BazelBuild builder) throws IOException {
+    super(cache, true);
+
+    Objects.requireNonNull(builder);
+    BazelBuild.Label label = builder.polygerritComponents();
+    try {
+      builder.build(label);
+    } catch (BazelBuild.BuildFailureException e) {
+      throw new IOException(e);
+    }
+
+    zip = builder.targetPath(label);
+    bowerComponents = GerritLauncher.newZipFileSystem(zip).getPath("/");
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) throws IOException {
+    return bowerComponents.resolve(pathInfo);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
deleted file mode 100644
index ef55e34..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BowerComponentsServlet.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.launcher.GerritLauncher;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-class BowerComponentsServlet extends ResourceServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final Path zip;
-  private final Path bowerComponents;
-
-  BowerComponentsServlet(Cache<Path, Resource> cache, Path buckOut)
-      throws IOException {
-    super(cache, true);
-    zip = getZipPath(buckOut);
-    if (zip == null || !Files.exists(zip)) {
-      bowerComponents = null;
-    } else {
-      bowerComponents = GerritLauncher
-          .newZipFileSystem(zip)
-          .getPath("bower_components/");
-    }
-  }
-
-  @Override
-  protected Path getResourcePath(String pathInfo) throws IOException {
-    if (bowerComponents == null) {
-      throw new IOException("No polymer components found: " + zip
-          + ". Run `buck build //polygerrit-ui:polygerrit_components`?");
-    }
-    return bowerComponents.resolve(pathInfo);
-  }
-
-  private static Path getZipPath(Path buckOut) {
-    if (buckOut == null) {
-      return null;
-    }
-    return buckOut.resolve("gen")
-        .resolve("polygerrit-ui")
-        .resolve("polygerrit_components")
-        .resolve("polygerrit_components.bower_components.zip");
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
deleted file mode 100644
index 0b4a02e..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BuckUtils.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.escape.Escaper;
-import com.google.common.html.HtmlEscapers;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gwtexpui.server.CacheHeaders;
-
-import org.eclipse.jgit.util.RawParseUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InterruptedIOException;
-import java.io.PrintWriter;
-import java.nio.file.Files;
-import java.nio.file.NoSuchFileException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.Properties;
-
-import javax.servlet.http.HttpServletResponse;
-
-class BuckUtils {
-  private static final Logger log =
-      LoggerFactory.getLogger(BuckUtils.class);
-
-  static void build(Path root, Path gen, String target)
-      throws IOException, BuildFailureException {
-    log.info("buck build " + target);
-    Properties properties = loadBuckProperties(gen);
-    String buck = firstNonNull(properties.getProperty("buck"), "buck");
-    ProcessBuilder proc = new ProcessBuilder(buck, "build", target)
-        .directory(root.toFile())
-        .redirectErrorStream(true);
-    if (properties.containsKey("PATH")) {
-      proc.environment().put("PATH", properties.getProperty("PATH"));
-    }
-    long start = TimeUtil.nowMs();
-    Process rebuild = proc.start();
-    byte[] out;
-    try (InputStream in = rebuild.getInputStream()) {
-      out = ByteStreams.toByteArray(in);
-    } finally {
-      rebuild.getOutputStream().close();
-    }
-
-    int status;
-    try {
-      status = rebuild.waitFor();
-    } catch (InterruptedException e) {
-      throw new InterruptedIOException("interrupted waiting for " + buck);
-    }
-    if (status != 0) {
-      throw new BuildFailureException(out);
-    }
-
-    long time = TimeUtil.nowMs() - start;
-    log.info(String.format("UPDATED    %s in %.3fs", target, time / 1000.0));
-  }
-
-  private static Properties loadBuckProperties(Path gen) throws IOException {
-    Properties properties = new Properties();
-    Path p = gen.resolve(Paths.get("tools/buck/buck.properties"));
-    try (InputStream in = Files.newInputStream(p)) {
-      properties.load(in);
-    } catch (NoSuchFileException e) {
-      // Ignore; will be run from PATH, with a descriptive error if it fails.
-    }
-    return properties;
-  }
-
-  static void displayFailure(String rule, byte[] why, HttpServletResponse res)
-      throws IOException {
-    res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-    res.setContentType("text/html");
-    res.setCharacterEncoding(UTF_8.name());
-    CacheHeaders.setNotCacheable(res);
-
-    Escaper html = HtmlEscapers.htmlEscaper();
-    try (PrintWriter w = res.getWriter()) {
-      w.write("<html><title>BUILD FAILED</title><body>");
-      w.format("<h1>%s FAILED</h1>", html.escape(rule));
-      w.write("<pre>");
-      w.write(html.escape(RawParseUtils.decode(why)));
-      w.write("</pre>");
-      w.write("</body></html>");
-    }
-  }
-
-  static class BuildFailureException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    final byte[] why;
-
-    BuildFailureException(byte[] why) {
-      this.why = why;
-    }
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
index 4047279..7e298aa 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.raw;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Change;
@@ -31,21 +30,19 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
+import java.util.Optional;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 /**
  * Exports a single version of a patch as a normal file download.
- * <p>
- * This can be relatively unsafe with Microsoft Internet Explorer 6.0 as the
- * browser will (rather incorrectly) treat an HTML or JavaScript file its
- * supposed to download as though it was served by this site, and will execute
- * it with the site's own protection domain. This opens a massive security hole
- * so we package the content into a zip file.
+ *
+ * <p>This can be relatively unsafe with Microsoft Internet Explorer 6.0 as the browser will (rather
+ * incorrectly) treat an HTML or JavaScript file its supposed to download as though it was served by
+ * this site, and will execute it with the site's own protection domain. This opens a massive
+ * security hole so we package the content into a zip file.
  */
 @SuppressWarnings("serial")
 @Singleton
@@ -57,7 +54,8 @@
   private final PatchSetUtil psUtil;
 
   @Inject
-  CatServlet(Provider<ReviewDb> sf,
+  CatServlet(
+      Provider<ReviewDb> sf,
       ChangeControl.GenericFactory ccf,
       Provider<CurrentUser> usrprv,
       ChangeEditUtil ceu,
@@ -70,8 +68,8 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     String keyStr = req.getPathInfo();
 
     // We shouldn't have to do this extra decode pass, but somehow we
@@ -122,8 +120,7 @@
     String revision;
     try {
       final ReviewDb db = requestDb.get();
-      final ChangeControl control = changeControl.validateFor(db, changeId,
-          userProvider.get());
+      final ChangeControl control = changeControl.validateFor(db, changeId, userProvider.get());
       if (patchKey.getParentKey().get() == 0) {
         // change edit
         try {
@@ -139,8 +136,7 @@
           return;
         }
       } else {
-        PatchSet patchSet =
-            psUtil.get(db, control.getNotes(), patchKey.getParentKey());
+        PatchSet patchSet = psUtil.get(db, control.getNotes(), patchKey.getParentKey());
         if (patchSet == null) {
           rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
           return;
@@ -157,8 +153,10 @@
     }
 
     String path = patchKey.getFileName();
-    String restUrl = String.format("%s/changes/%d/revisions/%s/files/%s/download?parent=%d",
-        req.getContextPath(), changeId.get(), revision, Url.encode(path), side);
+    String restUrl =
+        String.format(
+            "%s/changes/%d/revisions/%s/files/%s/download?parent=%d",
+            req.getContextPath(), changeId.get(), revision, Url.encode(path), side);
     rsp.sendRedirect(restUrl);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
index 826ff95..c13286e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
-
 import java.nio.file.Path;
 
 class DirectoryDocServlet extends ResourceServlet {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java
index 336faad..0f3e342 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/DirectoryGwtUiServlet.java
@@ -16,7 +16,6 @@
 
 import com.google.common.cache.Cache;
 import com.google.gerrit.common.TimeUtil;
-
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -29,8 +28,8 @@
 
   private final Path ui;
 
-  DirectoryGwtUiServlet(Cache<Path, Resource> cache, Path unpackedWar,
-      boolean dev) throws IOException {
+  DirectoryGwtUiServlet(Cache<Path, Resource> cache, Path unpackedWar, boolean dev)
+      throws IOException {
     super(cache, false);
     ui = unpackedWar.resolve("gerrit_ui");
     if (!Files.exists(ui)) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsDevServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsDevServlet.java
new file mode 100644
index 0000000..68b0d8c
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsDevServlet.java
@@ -0,0 +1,50 @@
+// 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.httpd.raw;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.launcher.GerritLauncher;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Objects;
+
+/* Font servlet only used in development mode */
+class FontsDevServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final Path fonts;
+
+  FontsDevServlet(Cache<Path, Resource> cache, BazelBuild builder) throws IOException {
+    super(cache, true);
+    Objects.requireNonNull(builder);
+
+    BazelBuild.Label zipLabel = builder.fontZipLabel();
+    try {
+      builder.build(zipLabel);
+    } catch (BazelBuild.BuildFailureException e) {
+      throw new IOException(e);
+    }
+
+    Path zip = builder.targetPath(zipLabel);
+    Objects.requireNonNull(zip);
+
+    fonts = GerritLauncher.newZipFileSystem(zip).getPath("/");
+  }
+
+  @Override
+  protected Path getResourcePath(String pathInfo) throws IOException {
+    return fonts.resolve(pathInfo);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java
deleted file mode 100644
index 3a8c8cb..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/FontsServlet.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.raw;
-
-import com.google.common.cache.Cache;
-import com.google.gerrit.launcher.GerritLauncher;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-class FontsServlet extends ResourceServlet {
-  private static final long serialVersionUID = 1L;
-
-  private final Path zip;
-  private final Path fonts;
-
-  FontsServlet(Cache<Path, Resource> cache, Path buckOut)
-      throws IOException {
-    super(cache, true);
-    zip = getZipPath(buckOut);
-    if (zip == null || !Files.exists(zip)) {
-      fonts = null;
-    } else {
-      fonts = GerritLauncher
-          .newZipFileSystem(zip)
-          .getPath("/");
-    }
-  }
-
-  @Override
-  protected Path getResourcePath(String pathInfo) throws IOException {
-    if (fonts == null) {
-      throw new IOException("No fonts found: " + zip
-          + ". Run `buck build //polygerrit-ui:fonts`?");
-    }
-    return fonts.resolve(pathInfo);
-  }
-
-  private static Path getZipPath(Path buckOut) {
-    if (buckOut == null) {
-      return null;
-    }
-    return buckOut.resolve("gen")
-        .resolve("polygerrit-ui")
-        .resolve("fonts")
-        .resolve("fonts.zip");
-  }
-}
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 bb3eff7..0e1f6e2 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
@@ -43,15 +43,6 @@
 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.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-import org.w3c.dom.Node;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
@@ -61,19 +52,24 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
-
 import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
 
 /** Sends the Gerrit host page to clients. */
 @SuppressWarnings("serial")
 @Singleton
 public class HostPageServlet extends HttpServlet {
-  private static final Logger log =
-      LoggerFactory.getLogger(HostPageServlet.class);
+  private static final Logger log = LoggerFactory.getLogger(HostPageServlet.class);
 
   private static final String HPD_ID = "gerrit_hostpagedata";
   private static final int DEFAULT_JS_LOAD_TIMEOUT = 5000;
@@ -95,6 +91,7 @@
   private volatile Page page;
 
   @Inject
+  @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
   HostPageServlet(
       Provider<CurrentUser> cu,
       SitePaths sp,
@@ -156,8 +153,8 @@
 
   private static int getPluginsLoadTimeout(Config cfg) {
     long cfgValue =
-        ConfigUtil.getTimeUnit(cfg, "plugins", null, "jsLoadTimeout",
-            DEFAULT_JS_LOAD_TIMEOUT, TimeUnit.MILLISECONDS);
+        ConfigUtil.getTimeUnit(
+            cfg, "plugins", null, "jsLoadTimeout", DEFAULT_JS_LOAD_TIMEOUT, TimeUnit.MILLISECONDS);
     if (cfgValue < 0) {
       return 0;
     }
@@ -182,8 +179,7 @@
   }
 
   @Override
-  protected void doGet(HttpServletRequest req,
-      HttpServletResponse rsp) throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     Page.Content page = select(req);
     StringWriter w = new StringWriter();
     CurrentUser user = currentUser.get();
@@ -234,9 +230,7 @@
   private void plugins(StringWriter w) {
     List<String> urls = new ArrayList<>();
     for (WebUiPlugin u : plugins) {
-      urls.add(String.format("plugins/%s/%s",
-          u.getPluginName(),
-          u.getJavaScriptResourcePath()));
+      urls.add(String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath()));
     }
     if (!urls.isEmpty()) {
       w.write(HPD_ID + ".plugins=");
@@ -273,8 +267,7 @@
   }
 
   private void insertETags(Element e) {
-    if ("img".equalsIgnoreCase(e.getTagName())
-        || "script".equalsIgnoreCase(e.getTagName())) {
+    if ("img".equalsIgnoreCase(e.getTagName()) || "script".equalsIgnoreCase(e.getTagName())) {
       String src = e.getAttribute("src");
       if (src != null && src.startsWith("static/")) {
         String name = src.substring("static/".length());
@@ -370,8 +363,7 @@
       }
     }
 
-    private FileInfo injectCssFile(Document hostDoc, String id, Path src)
-        throws IOException {
+    private FileInfo injectCssFile(Document hostDoc, String id, Path src) throws IOException {
       FileInfo info = new FileInfo(src);
       Element banner = HtmlDomUtil.find(hostDoc, id);
       if (banner == null) {
@@ -382,8 +374,7 @@
         banner.removeChild(banner.getFirstChild());
       }
 
-      String css =
-          HtmlDomUtil.readFile(src.getParent(), src.getFileName().toString());
+      String css = HtmlDomUtil.readFile(src.getParent(), src.getFileName().toString());
       if (css == null) {
         return info;
       }
@@ -392,8 +383,7 @@
       return info;
     }
 
-    private FileInfo injectXmlFile(Document hostDoc, String id, Path src)
-        throws IOException {
+    private FileInfo injectXmlFile(Document hostDoc, String id, Path src) throws IOException {
       FileInfo info = new FileInfo(src);
       Element banner = HtmlDomUtil.find(hostDoc, id);
       if (banner == null) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java
new file mode 100644
index 0000000..706e177
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.base.Strings;
+import com.google.common.io.Resources;
+import com.google.gerrit.common.Nullable;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.data.SanitizedContent;
+import com.google.template.soy.data.SoyMapData;
+import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
+import com.google.template.soy.tofu.SoyTofu;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class IndexServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+  private final byte[] indexSource;
+
+  IndexServlet(String canonicalURL, @Nullable String cdnPath) throws URISyntaxException {
+    String resourcePath = "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
+    SoyFileSet.Builder builder = SoyFileSet.builder();
+    builder.add(Resources.getResource(resourcePath));
+    SoyTofu.Renderer renderer =
+        builder
+            .build()
+            .compileToTofu()
+            .newRenderer("com.google.gerrit.httpd.raw.Index")
+            .setContentKind(SanitizedContent.ContentKind.HTML)
+            .setData(getTemplateData(canonicalURL, cdnPath));
+    indexSource = renderer.render().getBytes(UTF_8);
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    rsp.setCharacterEncoding(UTF_8.name());
+    rsp.setContentType("text/html");
+    rsp.setStatus(SC_OK);
+    try (OutputStream w = rsp.getOutputStream()) {
+      w.write(indexSource);
+    }
+  }
+
+  static String computeCanonicalPath(String canonicalURL) throws URISyntaxException {
+    if (Strings.isNullOrEmpty(canonicalURL)) {
+      return "";
+    }
+
+    // If we serving from a sub-directory rather than root, determine the path
+    // from the cannonical web URL.
+    URI uri = new URI(canonicalURL);
+    return uri.getPath().replaceAll("/$", "");
+  }
+
+  static SoyMapData getTemplateData(String canonicalURL, String cdnPath) throws URISyntaxException {
+    String canonicalPath = computeCanonicalPath(canonicalURL);
+
+    String staticPath = "";
+    if (cdnPath != null) {
+      staticPath = cdnPath;
+    } else if (canonicalPath != null) {
+      staticPath = canonicalPath;
+    }
+
+    // The resource path must be typed as safe for use in a script src.
+    // TODO(wyatta): Upgrade this to use an appropriate safe URL type.
+    SanitizedContent sanitizedStaticPath =
+        UnsafeSanitizedContentOrdainer.ordainAsSafe(
+            staticPath, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
+
+    return new SoyMapData(
+        "canonicalPath", canonicalPath,
+        "staticResourcePath", sanitizedStaticPath);
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
index 95a247f..8ccf221 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
@@ -19,23 +19,20 @@
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.OutputStream;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 /**
  * Redirects from {@code /Gerrit#foo} to {@code /#foo} in JavaScript.
- * <p>
- * This redirect exists to convert the older /Gerrit URL into the more modern
- * URL format which does not use a servlet name for the host page. We cannot do
- * the redirect here in the server side, as it would lose any history token that
- * appears in the URL. Instead we send an HTML page which instructs the browser
- * to replace the URL, but preserve the history token.
+ *
+ * <p>This redirect exists to convert the older /Gerrit URL into the more modern URL format which
+ * does not use a servlet name for the host page. We cannot do the redirect here in the server side,
+ * as it would lose any history token that appears in the URL. Instead we send an HTML page which
+ * instructs the browser to replace the URL, but preserve the history token.
  */
 @SuppressWarnings("serial")
 @Singleton
@@ -56,8 +53,8 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     final byte[] tosend;
     if (RPCServletUtils.acceptsGzipEncoding(req)) {
       rsp.setHeader("Content-Encoding", "gzip");
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
index 4ca8b1c..c508b2d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/PolyGerritUiServlet.java
@@ -15,12 +15,17 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
-
+import com.google.gerrit.common.TimeUtil;
+import java.io.IOException;
+import java.nio.file.FileSystems;
 import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
 
 class PolyGerritUiServlet extends ResourceServlet {
   private static final long serialVersionUID = 1L;
 
+  private static final FileTime NOW = FileTime.fromMillis(TimeUtil.nowMs());
+
   private final Path ui;
 
   PolyGerritUiServlet(Cache<Path, Resource> cache, Path ui) {
@@ -32,4 +37,16 @@
   protected Path getResourcePath(String pathInfo) {
     return ui.resolve(pathInfo);
   }
+
+  @Override
+  protected FileTime getLastModifiedTime(Path p) throws IOException {
+    if (ui.getFileSystem().equals(FileSystems.getDefault())) {
+      // Assets are being served from disk, so we can trust the mtime.
+      return super.getLastModifiedTime(p);
+    }
+    // Assume this FileSystem is serving from a WAR. All WAR outputs from the build process have
+    // mtimes of 1980/1/1, so we can't trust it, and return the initialization time of this class
+    // instead.
+    return NOW;
+  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
index 1984cbb..90aedbe 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/RecompileGwtUiFilter.java
@@ -14,20 +14,18 @@
 
 package com.google.gerrit.httpd.raw;
 
-import com.google.gerrit.httpd.raw.BuckUtils.BuildFailureException;
 import com.google.gwtexpui.linker.server.UserAgentRule;
-
 import java.io.File;
-import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Enumeration;
 import java.util.HashSet;
 import java.util.Set;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -43,59 +41,48 @@
   private final UserAgentRule rule = new UserAgentRule();
   private final Set<String> uaInitialized = new HashSet<>();
   private final Path unpackedWar;
-  private final Path gen;
-  private final Path root;
+  private final BazelBuild builder;
 
-  private String lastTarget;
+  private String lastAgent;
   private long lastTime;
 
-  RecompileGwtUiFilter(Path buckOut, Path unpackedWar) {
+  RecompileGwtUiFilter(BazelBuild builder, Path unpackedWar) {
+    this.builder = builder;
     this.unpackedWar = unpackedWar;
-    gen = buckOut.resolve("gen");
-    root = buckOut.getParent();
   }
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse res,
-      FilterChain chain) throws IOException, ServletException {
-    String pkg = "gerrit-gwtui";
-    String target = "ui_" + rule.select((HttpServletRequest) request);
-    if (gwtuiRecompile || !uaInitialized.contains(target)) {
-      String rule = "//" + pkg + ":" + target;
-      // TODO(davido): instead of assuming specific Buck's internal
-      // target directory for gwt_binary() artifacts, ask Buck for
-      // the location of user agent permutation GWT zip, e. g.:
-      // $ buck targets --show_output //gerrit-gwtui:ui_safari \
-      //    | awk '{print $2}'
-      String child = String.format("%s/__gwt_binary_%s__", pkg, target);
-      File zip = gen.resolve(child).resolve(target + ".zip").toFile();
+  public void doFilter(ServletRequest request, ServletResponse res, FilterChain chain)
+      throws IOException, ServletException {
+    String agent = rule.select((HttpServletRequest) request);
+    if (unpackedWar != null && (gwtuiRecompile || !uaInitialized.contains(agent))) {
+      BazelBuild.Label label = builder.gwtZipLabel(agent);
+      File zip = builder.targetPath(label).toFile();
 
       synchronized (this) {
         try {
-          BuckUtils.build(root, gen, rule);
-        } catch (BuildFailureException e) {
-          BuckUtils.displayFailure(rule, e.why, (HttpServletResponse) res);
+          builder.build(label);
+        } catch (BazelBuild.BuildFailureException e) {
+          e.display(label.toString(), (HttpServletResponse) res);
           return;
         }
 
-        if (!target.equals(lastTarget) || lastTime != zip.lastModified()) {
-          lastTarget = target;
+        if (!agent.equals(lastAgent) || lastTime != zip.lastModified()) {
+          lastAgent = agent;
           lastTime = zip.lastModified();
           unpack(zip, unpackedWar.toFile());
         }
       }
-      uaInitialized.add(target);
+      uaInitialized.add(agent);
     }
     chain.doFilter(request, res);
   }
 
   @Override
-  public void init(FilterConfig config) {
-  }
+  public void init(FilterConfig config) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   private static void unpack(File srcwar, File dstwar) throws IOException {
     try (ZipFile zf = new ZipFile(srcwar)) {
@@ -105,10 +92,10 @@
         final String name = ze.getName();
 
         if (ze.isDirectory()
-          || name.startsWith("WEB-INF/")
-          || name.startsWith("META-INF/")
-          || name.startsWith("com/google/gerrit/launcher/")
-          || name.equals("Main.class")) {
+            || name.startsWith("WEB-INF/")
+            || name.startsWith("META-INF/")
+            || name.startsWith("com/google/gerrit/launcher/")
+            || name.equals("Main.class")) {
           continue;
         }
 
@@ -116,7 +103,7 @@
         mkdir(rawtmp.getParentFile());
         rawtmp.deleteOnExit();
 
-        try (FileOutputStream rawout = new FileOutputStream(rawtmp);
+        try (OutputStream rawout = Files.newOutputStream(rawtmp.toPath());
             InputStream in = zf.getInputStream(ze)) {
           final byte[] buf = new byte[4096];
           int n;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 4f07ac2..3ca1878 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -31,15 +31,10 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.hash.Hashing;
-import com.google.gerrit.common.FileUtil;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.OutputStream;
 import java.nio.file.Files;
@@ -49,45 +44,46 @@
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.zip.GZIPOutputStream;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Base class for serving static resources.
- * <p>
- * Supports caching, ETags, basic content type detection, and limited gzip
- * compression.
+ *
+ * <p>Supports caching, ETags, basic content type detection, and limited gzip compression.
  */
 public abstract class ResourceServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
 
-  private static final Logger log =
-      LoggerFactory.getLogger(ResourceServlet.class);
+  private static final Logger log = LoggerFactory.getLogger(ResourceServlet.class);
 
   private static final int CACHE_FILE_SIZE_LIMIT_BYTES = 100 << 10;
 
   private static final String JS = "application/x-javascript";
   private static final ImmutableMap<String, String> MIME_TYPES =
-      ImmutableMap.<String, String> builder()
-        .put("css", "text/css")
-        .put("gif", "image/gif")
-        .put("htm", "text/html")
-        .put("html", "text/html")
-        .put("ico", "image/x-icon")
-        .put("jpeg", "image/jpeg")
-        .put("jpg", "image/jpeg")
-        .put("js", JS)
-        .put("pdf", "application/pdf")
-        .put("png", "image/png")
-        .put("rtf", "text/rtf")
-        .put("svg", "image/svg+xml")
-        .put("text", "text/plain")
-        .put("tif", "image/tiff")
-        .put("tiff", "image/tiff")
-        .put("txt", "text/plain")
-        .build();
+      ImmutableMap.<String, String>builder()
+          .put("css", "text/css")
+          .put("gif", "image/gif")
+          .put("htm", "text/html")
+          .put("html", "text/html")
+          .put("ico", "image/x-icon")
+          .put("jpeg", "image/jpeg")
+          .put("jpg", "image/jpeg")
+          .put("js", JS)
+          .put("pdf", "application/pdf")
+          .put("png", "image/png")
+          .put("rtf", "text/rtf")
+          .put("svg", "image/svg+xml")
+          .put("text", "text/plain")
+          .put("tif", "image/tiff")
+          .put("tiff", "image/tiff")
+          .put("txt", "text/plain")
+          .put("woff", "font/woff")
+          .put("woff2", "font/woff2")
+          .build();
 
   protected static String contentType(String name) {
     int dot = name.lastIndexOf('.');
@@ -105,14 +101,16 @@
     this(cache, refresh, true, CACHE_FILE_SIZE_LIMIT_BYTES);
   }
 
-  protected ResourceServlet(Cache<Path, Resource> cache, boolean refresh,
-      boolean cacheOnClient) {
+  protected ResourceServlet(Cache<Path, Resource> cache, boolean refresh, boolean cacheOnClient) {
     this(cache, refresh, cacheOnClient, CACHE_FILE_SIZE_LIMIT_BYTES);
   }
 
   @VisibleForTesting
-  ResourceServlet(Cache<Path, Resource> cache, boolean refresh,
-      boolean cacheOnClient, int cacheFileSizeLimitBytes) {
+  ResourceServlet(
+      Cache<Path, Resource> cache,
+      boolean refresh,
+      boolean cacheOnClient,
+      int cacheFileSizeLimitBytes) {
     this.cache = checkNotNull(cache, "cache");
     this.refresh = refresh;
     this.cacheOnClient = cacheOnClient;
@@ -120,8 +118,7 @@
   }
 
   /**
-   * Get the resource path on the filesystem that should be served for this
-   * request.
+   * Get the resource path on the filesystem that should be served for this request.
    *
    * @param pathInfo result of {@link HttpServletRequest#getPathInfo()}.
    * @return path where static content can be found.
@@ -134,8 +131,7 @@
   }
 
   @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     String name;
     if (req.getPathInfo() == null) {
       name = "/";
@@ -165,7 +161,7 @@
         r = cache.get(p, newLoader(p));
       }
     } catch (ExecutionException e) {
-      log.warn("Cannot load static resource " + req.getPathInfo(), e);
+      log.warn("Cannot load static resource {}", req.getPathInfo(), e);
       CacheHeaders.setNotCacheable(rsp);
       rsp.setStatus(SC_INTERNAL_SERVER_ERROR);
       return;
@@ -218,12 +214,12 @@
     try {
       Path p = getResourcePath(name);
       if (p == null) {
-        log.warn(String.format("Path doesn't exist %s", name));
+        log.warn("Path doesn't exist {}", name);
         return null;
       }
       return cache.get(p, newLoader(p));
     } catch (ExecutionException | IOException e) {
-      log.warn(String.format("Cannot load static resource %s", name), e);
+      log.warn("Cannot load static resource {}", name, e);
       return null;
     }
   }
@@ -234,17 +230,17 @@
   }
 
   /**
-   * Maybe stream a path to the response, depending on the properties of the
-   * file and cache headers in the request.
+   * Maybe stream a path to the response, depending on the properties of the file and cache headers
+   * in the request.
    *
    * @param p path to stream
    * @param req HTTP request.
    * @param rsp HTTP response.
-   * @return true if the response was written (either the file contents or an
-   *     error); false if the path is too small to stream and should be cached.
+   * @return true if the response was written (either the file contents or an error); false if the
+   *     path is too small to stream and should be cached.
    */
-  private boolean maybeStream(Path p, HttpServletRequest req,
-      HttpServletResponse rsp) throws IOException {
+  private boolean maybeStream(Path p, HttpServletRequest req, HttpServletResponse rsp)
+      throws IOException {
     try {
       if (Files.size(p) < cacheFileSizeLimitBytes) {
         return false;
@@ -255,7 +251,7 @@
       return true;
     }
 
-    long lastModified = FileUtil.lastModified(p);
+    long lastModified = getLastModifiedTime(p).toMillis();
     if (req.getDateHeader(IF_MODIFIED_SINCE) >= lastModified) {
       rsp.setStatus(SC_NOT_MODIFIED);
       return true;
@@ -283,41 +279,35 @@
     return true;
   }
 
-
   private static boolean isUnreasonableName(String name) {
     return name.length() < 1
-      || name.contains("\\") // no windows/dos style paths
-      || name.startsWith("../") // no "../etc/passwd"
-      || name.contains("/../") // no "foo/../etc/passwd"
-      || name.contains("/./") // "foo/./foo" is insane to ask
-      || name.contains("//"); // windows UNC path can be "//..."
+        || name.contains("\\") // no windows/dos style paths
+        || name.startsWith("../") // no "../etc/passwd"
+        || name.contains("/../") // no "foo/../etc/passwd"
+        || name.contains("/./") // "foo/./foo" is insane to ask
+        || name.contains("//"); // windows UNC path can be "//..."
   }
 
   private Callable<Resource> newLoader(final Path p) {
-    return new Callable<Resource>() {
-      @Override
-      public Resource call() throws IOException {
-        try {
-          return new Resource(
-              getLastModifiedTime(p),
-              contentType(p.toString()),
-              Files.readAllBytes(p));
-        } catch (NoSuchFileException e) {
-          return Resource.NOT_FOUND;
-        }
+    return () -> {
+      try {
+        return new Resource(
+            getLastModifiedTime(p), contentType(p.toString()), Files.readAllBytes(p));
+      } catch (NoSuchFileException e) {
+        return Resource.NOT_FOUND;
       }
     };
   }
 
   public static class Resource {
-    static final Resource NOT_FOUND =
-        new Resource(FileTime.fromMillis(0), "", new byte[] {});
+    static final Resource NOT_FOUND = new Resource(FileTime.fromMillis(0), "", new byte[] {});
 
     final FileTime lastModified;
     final String contentType;
     final String etag;
     final byte[] raw;
 
+    @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
     Resource(FileTime lastModified, String contentType, byte[] raw) {
       this.lastModified = checkNotNull(lastModified, "lastModified");
       this.contentType = checkNotNull(contentType, "contentType");
@@ -332,14 +322,11 @@
       } catch (NoSuchFileException e) {
         return this != NOT_FOUND;
       }
-      return t.toMillis() == 0
-          || lastModified.toMillis() == 0
-          || !lastModified.equals(t);
+      return t.toMillis() == 0 || lastModified.toMillis() == 0 || !lastModified.equals(t);
     }
   }
 
-  public static class Weigher
-      implements com.google.common.cache.Weigher<Path, Resource> {
+  public static class Weigher implements com.google.common.cache.Weigher<Path, Resource> {
     @Override
     public int weigh(Path p, Resource r) {
       return 2 * p.toString().length() + r.raw.length;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java
index 64dd862..0ac3d10 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SingleFileServlet.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
-
 import java.nio.file.Path;
 
 /** Serve a single static file, regardless of path. */
@@ -29,8 +28,8 @@
     this.path = path;
   }
 
-  SingleFileServlet(Cache<Path, Resource> cache, Path path, boolean refresh,
-      boolean cacheOnClient) {
+  SingleFileServlet(
+      Cache<Path, Resource> cache, Path path, boolean refresh, boolean cacheOnClient) {
     super(cache, refresh, cacheOnClient);
     this.path = path;
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
index 6365306..594415a 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
@@ -20,12 +20,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.nio.file.Path;
-
+import org.eclipse.jgit.lib.Config;
 
 /** Sends static content from the site 's {@code static/} subdirectory. */
 @Singleton
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
index f570cb6..b20f990 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
@@ -20,37 +20,31 @@
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import com.jcraft.jsch.HostKey;
-
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.List;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 /**
- * Servlet hosting an SSH daemon on another port. During a standard HTTP GET
- * request the servlet returns the hostname and port number back to the client
- * in the form <code>${host} ${port}</code>.
- * <p>
- * Use a Git URL such as <code>ssh://${email}@${host}:${port}/${path}</code>,
- * e.g. {@code ssh://sop@google.com@gerrit.com:8010/tools/gerrit.git} to
- * access the SSH daemon itself.
- * <p>
- * Versions of Git before 1.5.3 may require setting the username and port
- * properties in the user's {@code ~/.ssh/config} file, and using a host
- * alias through a URL such as {@code gerrit-alias:/tools/gerrit.git}:
- * <pre>
- * {@code
+ * Servlet hosting an SSH daemon on another port. During a standard HTTP GET request the servlet
+ * returns the hostname and port number back to the client in the form <code>${host} ${port}</code>.
+ *
+ * <p>Use a Git URL such as <code>ssh://${email}@${host}:${port}/${path}</code>, e.g. {@code
+ * ssh://sop@google.com@gerrit.com:8010/tools/gerrit.git} to access the SSH daemon itself.
+ *
+ * <p>Versions of Git before 1.5.3 may require setting the username and port properties in the
+ * user's {@code ~/.ssh/config} file, and using a host alias through a URL such as {@code
+ * gerrit-alias:/tools/gerrit.git}:
+ *
+ * <pre>{@code
  * Host gerrit-alias
  *  User sop@google.com
  *  Hostname gerrit.com
  *  Port 8010
- * }
- * </pre>
+ * }</pre>
  */
 @SuppressWarnings("serial")
 @Singleton
@@ -63,8 +57,8 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     final List<HostKey> hostKeys = sshd.getHostKeys();
     final String out;
     if (!hostKeys.isEmpty()) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
index 7916ed0..a1dbbb8 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -15,16 +15,20 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.file.Files.exists;
 import static java.nio.file.Files.isReadable;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.httpd.GerritOptions;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.httpd.XsrfCookieFilter;
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
@@ -35,48 +39,68 @@
 import com.google.inject.name.Named;
 import com.google.inject.name.Names;
 import com.google.inject.servlet.ServletModule;
-
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.file.FileSystem;
+import java.nio.file.Path;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.nio.file.FileSystem;
-import java.nio.file.Path;
-
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
 public class StaticModule extends ServletModule {
-  private static final Logger log =
-      LoggerFactory.getLogger(StaticModule.class);
+  private static final Logger log = LoggerFactory.getLogger(StaticModule.class);
 
   public static final String CACHE = "static_content";
+  public static final String GERRIT_UI_COOKIE = "GERRIT_UI";
 
+  /**
+   * Paths at which we should serve the main PolyGerrit application {@code index.html}.
+   *
+   * <p>Supports {@code "/*"} as a trailing wildcard.
+   */
   public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
+      ImmutableList.of("/", "/c/*", "/q/*", "/x/*", "/admin/*", "/dashboard/*", "/settings/*");
+  // TODO(dborowitz): These fragments conflict with the REST API
+  // namespace, so they will need to use a different path.
+  // "/groups/*",
+  // "/projects/*");
+  //
+
+  /**
+   * Paths that should be treated as static assets when serving PolyGerrit.
+   *
+   * <p>Supports {@code "/*"} as a trailing wildcard.
+   */
+  private static final ImmutableList<String> POLYGERRIT_ASSET_PATHS =
       ImmutableList.of(
-        "/",
-        "/c/*",
-        "/q/*",
-        "/x/*",
-        "/admin/*",
-        "/dashboard/*",
-        "/settings/*",
-        // TODO(dborowitz): These fragments conflict with the REST API
-        // namespace, so they will need to use a different path.
-        "/groups/*",
-        "/projects/*");
+          "/behaviors/*",
+          "/bower_components/*",
+          "/elements/*",
+          "/fonts/*",
+          "/scripts/*",
+          "/styles/*");
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
   private static final String GWT_UI_SERVLET = "GwtUiServlet";
-  private static final String POLYGERRIT_INDEX_SERVLET =
-      "PolyGerritUiIndexServlet";
+  private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
+  private static final int GERRIT_UI_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
+
   private final GerritOptions options;
   private Paths paths;
 
@@ -85,9 +109,11 @@
     this.options = options;
   }
 
+  @Provides
+  @Singleton
   private Paths getPaths() {
     if (paths == null) {
-      paths = new Paths();
+      paths = new Paths(options);
     }
     return paths;
   }
@@ -96,19 +122,22 @@
   protected void configureServlets() {
     serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
     serve("/static/*").with(SiteStaticDirectoryServlet.class);
-    install(new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(CACHE, Path.class, Resource.class)
-            .maximumWeight(1 << 20)
-            .weigher(ResourceServlet.Weigher.class);
-      }
-    });
+    install(
+        new CacheModule() {
+          @Override
+          protected void configure() {
+            cache(CACHE, Path.class, Resource.class)
+                .maximumWeight(1 << 20)
+                .weigher(ResourceServlet.Weigher.class);
+          }
+        });
+    if (!options.headless()) {
+      install(new CoreStaticModule());
+    }
     if (options.enablePolyGerrit()) {
-      install(new CoreStaticModule());
-      install(new PolyGerritUiModule());
-    } else if (options.enableDefaultUi()) {
-      install(new CoreStaticModule());
+      install(new PolyGerritModule());
+    }
+    if (options.enableGwtUi()) {
       install(new GwtUiModule());
     }
   }
@@ -127,8 +156,8 @@
         private static final long serialVersionUID = 1L;
 
         @Override
-        protected void service(HttpServletRequest req,
-            HttpServletResponse resp) throws IOException {
+        protected void service(HttpServletRequest req, HttpServletResponse resp)
+            throws IOException {
           resp.sendError(HttpServletResponse.SC_NOT_FOUND);
         }
       };
@@ -145,10 +174,11 @@
     @Provides
     @Singleton
     @Named(ROBOTS_TXT_SERVLET)
-    HttpServlet getRobotsTxtServlet(@GerritServerConfig Config cfg,
-        SitePaths sitePaths, @Named(CACHE) Cache<Path, Resource> cache) {
-      Path configPath = sitePaths.resolve(
-          cfg.getString("httpd", null, "robotsFile"));
+    HttpServlet getRobotsTxtServlet(
+        @GerritServerConfig Config cfg,
+        SitePaths sitePaths,
+        @Named(CACHE) Cache<Path, Resource> cache) {
+      Path configPath = sitePaths.resolve(cfg.getString("httpd", null, "robotsFile"));
       if (configPath != null) {
         if (exists(configPath) && isReadable(configPath)) {
           return new SingleFileServlet(cache, configPath, true);
@@ -157,11 +187,9 @@
       }
       Paths p = getPaths();
       if (p.warFs != null) {
-        return new SingleFileServlet(
-            cache, p.warFs.getPath("/robots.txt"), false);
+        return new SingleFileServlet(cache, p.warFs.getPath("/robots.txt"), false);
       }
-      return new SingleFileServlet(
-          cache, webappSourcePath("robots.txt"), true);
+      return new SingleFileServlet(cache, webappSourcePath("robots.txt"), true);
     }
 
     @Provides
@@ -170,11 +198,9 @@
     HttpServlet getFaviconServlet(@Named(CACHE) Cache<Path, Resource> cache) {
       Paths p = getPaths();
       if (p.warFs != null) {
-        return new SingleFileServlet(
-            cache, p.warFs.getPath("/favicon.ico"), false);
+        return new SingleFileServlet(cache, p.warFs.getPath("/favicon.ico"), false);
       }
-      return new SingleFileServlet(
-          cache, webappSourcePath("favicon.ico"), true);
+      return new SingleFileServlet(cache, webappSourcePath("favicon.ico"), true);
     }
 
     private Path webappSourcePath(String name) {
@@ -182,8 +208,7 @@
       if (p.unpackedWar != null) {
         return p.unpackedWar.resolve(name);
       }
-      return p.buckOut.resolveSibling("gerrit-war").resolve("src")
-          .resolve("main").resolve("webapp").resolve(name);
+      return p.sourceRoot.resolve("gerrit-war/src/main/webapp/" + name);
     }
   }
 
@@ -194,15 +219,14 @@
           .with(Key.get(HttpServlet.class, Names.named(GWT_UI_SERVLET)));
       Paths p = getPaths();
       if (p.isDev()) {
-        filter("/").through(new RecompileGwtUiFilter(p.buckOut, p.unpackedWar));
+        filter("/").through(new RecompileGwtUiFilter(p.builder, p.unpackedWar));
       }
     }
 
     @Provides
     @Singleton
     @Named(GWT_UI_SERVLET)
-    HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache)
-        throws IOException {
+    HttpServlet getGwtUiServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
       Paths p = getPaths();
       if (p.warFs != null) {
         return new WarGwtUiServlet(cache, p.warFs);
@@ -211,68 +235,57 @@
     }
   }
 
-  private class PolyGerritUiModule extends ServletModule {
+  private class PolyGerritModule extends ServletModule {
     @Override
     public void configureServlets() {
-      Path buckOut = getPaths().buckOut;
-      if (buckOut != null) {
-        serve("/bower_components/*").with(BowerComponentsServlet.class);
-        serve("/fonts/*").with(FontsServlet.class);
-      } else {
-        // In the war case, bower_components and fonts are either inlined
-        // by vulcanize, or live under /polygerrit_ui in the war file,
-        // so we don't need a separate servlet.
-      }
-
-      Key<HttpServlet> indexKey = named(POLYGERRIT_INDEX_SERVLET);
       for (String p : POLYGERRIT_INDEX_PATHS) {
-        filter(p).through(XsrfCookieFilter.class);
-        serve(p).with(indexKey);
+        // Skip XsrfCookieFilter for /, since that is already done in the GWT UI
+        // path (UrlModule).
+        if (!p.equals("/")) {
+          filter(p).through(XsrfCookieFilter.class);
+        }
       }
-      serve("/*").with(PolyGerritUiServlet.class);
+      filter("/*").through(PolyGerritFilter.class);
     }
 
     @Provides
     @Singleton
     @Named(POLYGERRIT_INDEX_SERVLET)
     HttpServlet getPolyGerritUiIndexServlet(
-        @Named(CACHE) Cache<Path, Resource> cache) {
-      return new SingleFileServlet(cache,
-          polyGerritBasePath().resolve("index.html"),
-          getPaths().isDev(),
-          false);
+        @CanonicalWebUrl @Nullable String canonicalUrl, @GerritServerConfig Config cfg)
+        throws URISyntaxException {
+      String cdnPath = cfg.getString("gerrit", null, "cdnPath");
+      return new IndexServlet(canonicalUrl, cdnPath);
     }
 
     @Provides
     @Singleton
-    PolyGerritUiServlet getPolyGerritUiServlet(
-        @Named(CACHE) Cache<Path, Resource> cache) {
+    PolyGerritUiServlet getPolyGerritUiServlet(@Named(CACHE) Cache<Path, Resource> cache) {
       return new PolyGerritUiServlet(cache, polyGerritBasePath());
     }
 
     @Provides
     @Singleton
-    BowerComponentsServlet getBowerComponentsServlet(
-        @Named(CACHE) Cache<Path, Resource> cache) throws IOException {
-      return new BowerComponentsServlet(cache, getPaths().buckOut);
+    BowerComponentsDevServlet getBowerComponentsServlet(@Named(CACHE) Cache<Path, Resource> cache)
+        throws IOException {
+      return getPaths().isDev() ? new BowerComponentsDevServlet(cache, getPaths().builder) : null;
     }
 
     @Provides
     @Singleton
-    FontsServlet getFontsServlet(
-        @Named(CACHE) Cache<Path, Resource> cache) throws IOException {
-      return new FontsServlet(cache, getPaths().buckOut);
+    FontsDevServlet getFontsServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
+      return getPaths().isDev() ? new FontsDevServlet(cache, getPaths().builder) : null;
     }
 
     private Path polyGerritBasePath() {
       Paths p = getPaths();
       if (options.forcePolyGerritDev()) {
-        checkArgument(p.buckOut != null,
-            "no buck-out directory found for PolyGerrit developer mode");
+        checkArgument(
+            p.sourceRoot != null, "no source root directory found for PolyGerrit developer mode");
       }
 
       if (p.isDev()) {
-        return p.buckOut.getParent().resolve("polygerrit-ui").resolve("app");
+        return p.sourceRoot.resolve("polygerrit-ui").resolve("app");
       }
 
       return p.warFs != null
@@ -281,49 +294,58 @@
     }
   }
 
-  private class Paths {
+  private static class Paths {
     private final FileSystem warFs;
-    private final Path buckOut;
+    private final BazelBuild builder;
+    private final Path sourceRoot;
     private final Path unpackedWar;
     private final boolean development;
 
-    private Paths() {
+    private Paths(GerritOptions options) {
       try {
         File launcherLoadedFrom = getLauncherLoadedFrom();
-        if (launcherLoadedFrom != null
-            && launcherLoadedFrom.getName().endsWith(".jar")) {
+        if (launcherLoadedFrom != null && launcherLoadedFrom.getName().endsWith(".jar")) {
           // Special case: unpacked war archive deployed in container.
           // The path is something like:
           // <container>/<gerrit>/WEB-INF/lib/launcher.jar
           // Switch to exploded war case with <container>/webapp>/<gerrit>
           // root directory
           warFs = null;
-          unpackedWar = java.nio.file.Paths.get(launcherLoadedFrom
-              .getParentFile()
-              .getParentFile()
-              .getParentFile()
-              .toURI());
-          buckOut = null;
+          unpackedWar =
+              java.nio.file.Paths.get(
+                  launcherLoadedFrom.getParentFile().getParentFile().getParentFile().toURI());
+          sourceRoot = null;
           development = false;
+          builder = null;
           return;
         }
         warFs = getDistributionArchive(launcherLoadedFrom);
         if (warFs == null) {
-          buckOut = getDeveloperBuckOut();
           unpackedWar = makeWarTempDir();
           development = true;
         } else if (options.forcePolyGerritDev()) {
-          buckOut = getDeveloperBuckOut();
           unpackedWar = null;
           development = true;
         } else {
-          buckOut = null;
           unpackedWar = null;
           development = false;
+          sourceRoot = null;
+          builder = null;
+          return;
         }
       } catch (IOException e) {
-        throw new ProvisionException(
-            "Error initializing static content paths", e);
+        throw new ProvisionException("Error initializing static content paths", e);
+      }
+
+      sourceRoot = getSourceRootOrNull();
+      builder = new BazelBuild(sourceRoot);
+    }
+
+    private static Path getSourceRootOrNull() {
+      try {
+        return GerritLauncher.resolveInSourceRoot(".");
+      } catch (FileNotFoundException e) {
+        return null;
       }
     }
 
@@ -343,8 +365,7 @@
             && GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
           return null;
         }
-        ProvisionException pe =
-            new ProvisionException("Error reading gerrit.war");
+        ProvisionException pe = new ProvisionException("Error reading gerrit.war");
         pe.initCause(e);
         throw pe;
       }
@@ -355,14 +376,6 @@
       return development;
     }
 
-    private Path getDeveloperBuckOut() {
-      try {
-        return GerritLauncher.getDeveloperBuckOut();
-      } catch (FileNotFoundException e) {
-        return null;
-      }
-    }
-
     private Path makeWarTempDir() {
       // Obtain our local temporary directory, but it comes back as a file
       // so we have to switch it to be a directory post creation.
@@ -382,8 +395,7 @@
           return dstwar.getAbsoluteFile().toPath();
         }
       } catch (IOException e) {
-        ProvisionException pe =
-            new ProvisionException("Cannot create war tempdir");
+        ProvisionException pe = new ProvisionException("Cannot create war tempdir");
         pe.initCause(e);
         throw pe;
       }
@@ -393,4 +405,196 @@
   private static Key<HttpServlet> named(String name) {
     return Key.get(HttpServlet.class, Names.named(name));
   }
+
+  @Singleton
+  private static class PolyGerritFilter implements Filter {
+    private final GerritOptions options;
+    private final Paths paths;
+    private final HttpServlet polyGerritIndex;
+    private final PolyGerritUiServlet polygerritUI;
+    private final BowerComponentsDevServlet bowerComponentServlet;
+    private final FontsDevServlet fontServlet;
+
+    @Inject
+    PolyGerritFilter(
+        GerritOptions options,
+        Paths paths,
+        @Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
+        PolyGerritUiServlet polygerritUI,
+        @Nullable BowerComponentsDevServlet bowerComponentServlet,
+        @Nullable FontsDevServlet fontServlet) {
+      this.paths = paths;
+      this.options = options;
+      this.polyGerritIndex = polyGerritIndex;
+      this.polygerritUI = polygerritUI;
+      this.bowerComponentServlet = bowerComponentServlet;
+      this.fontServlet = fontServlet;
+      checkState(
+          options.enablePolyGerrit(), "can't install PolyGerritFilter when PolyGerrit is disabled");
+    }
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {}
+
+    @Override
+    public void destroy() {}
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+        throws IOException, ServletException {
+      HttpServletRequest req = (HttpServletRequest) request;
+      HttpServletResponse res = (HttpServletResponse) response;
+      if (handlePolyGerritParam(req, res)) {
+        return;
+      }
+      if (!isPolyGerritEnabled(req)) {
+        chain.doFilter(req, res);
+        return;
+      }
+
+      GuiceFilterRequestWrapper reqWrapper = new GuiceFilterRequestWrapper(req);
+      String path = pathInfo(req);
+
+      // Special case assets during development that are built by Buck and not
+      // served out of the source tree.
+      //
+      // In the war case, these are either inlined by vulcanize, or live under
+      // /polygerrit_ui in the war file, so we can just treat them as normal
+      // assets.
+      if (paths.isDev()) {
+        if (path.startsWith("/bower_components/")) {
+          bowerComponentServlet.service(reqWrapper, res);
+          return;
+        } else if (path.startsWith("/fonts/")) {
+          fontServlet.service(reqWrapper, res);
+          return;
+        }
+      }
+
+      if (isPolyGerritIndex(path)) {
+        polyGerritIndex.service(reqWrapper, res);
+        return;
+      }
+      if (isPolyGerritAsset(path)) {
+        polygerritUI.service(reqWrapper, res);
+        return;
+      }
+
+      chain.doFilter(req, res);
+    }
+
+    private static String pathInfo(HttpServletRequest req) {
+      String uri = req.getRequestURI();
+      String ctx = req.getContextPath();
+      return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
+    }
+
+    private boolean handlePolyGerritParam(HttpServletRequest req, HttpServletResponse res)
+        throws IOException {
+      if (!options.enableGwtUi() || !"GET".equals(req.getMethod())) {
+        return false;
+      }
+      boolean redirect = false;
+      String param = req.getParameter("polygerrit");
+      if ("1".equals(param)) {
+        setPolyGerritCookie(req, res, UiType.POLYGERRIT);
+        redirect = true;
+      } else if ("0".equals(param)) {
+        setPolyGerritCookie(req, res, UiType.GWT);
+        redirect = true;
+      }
+      if (redirect) {
+        // Strip polygerrit param from URL. This actually strips all params,
+        // which is a similar behavior to the JS PolyGerrit redirector code.
+        // Stripping just one param is frustratingly difficult without the use
+        // of Apache httpclient, which is a dep we don't want here:
+        // https://gerrit-review.googlesource.com/#/c/57570/57/gerrit-httpd/BUCK@32
+        res.sendRedirect(req.getRequestURL().toString());
+      }
+      return redirect;
+    }
+
+    private boolean isPolyGerritEnabled(HttpServletRequest req) {
+      return !options.enableGwtUi() || isPolyGerritCookie(req);
+    }
+
+    private boolean isPolyGerritCookie(HttpServletRequest req) {
+      UiType type = options.defaultUi();
+      Cookie[] all = req.getCookies();
+      if (all != null) {
+        for (Cookie c : all) {
+          if (GERRIT_UI_COOKIE.equals(c.getName())) {
+            UiType t = UiType.parse(c.getValue());
+            if (t != null) {
+              type = t;
+              break;
+            }
+          }
+        }
+      }
+      return type == UiType.POLYGERRIT;
+    }
+
+    private void setPolyGerritCookie(HttpServletRequest req, HttpServletResponse res, UiType pref) {
+      // Only actually set a cookie if both UIs are enabled in the server;
+      // otherwise clear it.
+      Cookie cookie = new Cookie(GERRIT_UI_COOKIE, pref.name());
+      if (options.enablePolyGerrit() && options.enableGwtUi()) {
+        cookie.setPath("/");
+        cookie.setSecure(isSecure(req));
+        cookie.setMaxAge(GERRIT_UI_COOKIE_MAX_AGE);
+      } else {
+        cookie.setValue("");
+        cookie.setMaxAge(0);
+      }
+      res.addCookie(cookie);
+    }
+
+    private static boolean isSecure(HttpServletRequest req) {
+      return req.isSecure() || "https".equals(req.getScheme());
+    }
+
+    private static boolean isPolyGerritAsset(String path) {
+      return matchPath(POLYGERRIT_ASSET_PATHS, path);
+    }
+
+    private static boolean isPolyGerritIndex(String path) {
+      return matchPath(POLYGERRIT_INDEX_PATHS, path);
+    }
+
+    private static boolean matchPath(Iterable<String> paths, String path) {
+      for (String p : paths) {
+        if (p.endsWith("/*")) {
+          if (path.regionMatches(0, p, 0, p.length() - 1)) {
+            return true;
+          }
+        } else if (p.equals(path)) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  private static class GuiceFilterRequestWrapper extends HttpServletRequestWrapper {
+    GuiceFilterRequestWrapper(HttpServletRequest req) {
+      super(req);
+    }
+
+    @Override
+    public String getPathInfo() {
+      String uri = getRequestURI();
+      String ctx = getContextPath();
+      // This is a workaround for long standing guice filter bug:
+      // https://github.com/google/guice/issues/807
+      String res = uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
+
+      // Match the logic in the ResourceServlet, that re-add "/"
+      // for null path info
+      if ("/".equals(res)) {
+        return null;
+      }
+      return res;
+    }
+  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ThemeFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ThemeFactory.java
index 9e89eee..6a75e07 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ThemeFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ThemeFactory.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.common.data.HostPageData;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.Config;
 
 class ThemeFactory {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ToolServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ToolServlet.java
index 5c6480a..fcdd21d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ToolServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/ToolServlet.java
@@ -29,16 +29,13 @@
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-
 import java.io.IOException;
 import java.io.OutputStream;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
 
 /** Sends the client side tools we keep within our software. */
 @Singleton
@@ -52,8 +49,7 @@
   }
 
   @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     Entry ent = toc.get(req.getPathInfo());
     if (ent == null) {
       rsp.sendError(SC_NOT_FOUND);
@@ -88,8 +84,8 @@
     }
   }
 
-  private void doGetDirectory(Entry ent, HttpServletRequest req,
-      HttpServletResponse rsp) throws IOException {
+  private void doGetDirectory(Entry ent, HttpServletRequest req, HttpServletResponse rsp)
+      throws IOException {
     String path = "/tools/" + ent.getPath();
     Document page = newDocument();
 
@@ -130,8 +126,7 @@
 
     Element footer = page.createElement("p");
     footer.setAttribute("style", "text-align: right; font-style: italic");
-    footer.setTextContent("Powered by Gerrit Code Review "
-        + Version.getVersion());
+    footer.setTextContent("Powered by Gerrit Code Review " + Version.getVersion());
     body.appendChild(footer);
 
     byte[] tosend = toUTF8(page);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarDocServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarDocServlet.java
index ad23314..3f6ff25 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarDocServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarDocServlet.java
@@ -15,13 +15,16 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
-
+import com.google.gerrit.common.TimeUtil;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
 
 class WarDocServlet extends ResourceServlet {
   private static final long serialVersionUID = 1L;
 
+  private static final FileTime NOW = FileTime.fromMillis(TimeUtil.nowMs());
+
   private final FileSystem warFs;
 
   WarDocServlet(Cache<Path, Resource> cache, FileSystem warFs) {
@@ -33,4 +36,11 @@
   protected Path getResourcePath(String pathInfo) {
     return warFs.getPath("/Documentation/" + pathInfo);
   }
+
+  @Override
+  protected FileTime getLastModifiedTime(Path p) {
+    // Return initialization time of this class, since the WAR outputs from the build process all
+    // have mtimes of 1980/1/1.
+    return NOW;
+  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java
index 45952cc..ff27965 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/WarGwtUiServlet.java
@@ -16,7 +16,6 @@
 
 import com.google.common.cache.Cache;
 import com.google.gerrit.common.TimeUtil;
-
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
 import java.nio.file.attribute.FileTime;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/Resource.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/Resource.java
index b6d9a75..bfa0b95 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/Resource.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/Resource.java
@@ -15,45 +15,42 @@
 package com.google.gerrit.httpd.resources;
 
 import com.google.gwtexpui.server.CacheHeaders;
-
 import java.io.IOException;
 import java.io.Serializable;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 public abstract class Resource implements Serializable {
   private static final long serialVersionUID = 1L;
 
-  public static final Resource NOT_FOUND = new Resource() {
-    private static final long serialVersionUID = 1L;
+  public static final Resource NOT_FOUND =
+      new Resource() {
+        private static final long serialVersionUID = 1L;
 
-    @Override
-    public int weigh() {
-      return 0;
-    }
+        @Override
+        public int weigh() {
+          return 0;
+        }
 
-    @Override
-    public void send(HttpServletRequest req, HttpServletResponse res)
-        throws IOException {
-      CacheHeaders.setNotCacheable(res);
-      res.sendError(HttpServletResponse.SC_NOT_FOUND);
-    }
+        @Override
+        public void send(HttpServletRequest req, HttpServletResponse res) throws IOException {
+          CacheHeaders.setNotCacheable(res);
+          res.sendError(HttpServletResponse.SC_NOT_FOUND);
+        }
 
-    @Override
-    public boolean isUnchanged(long latestModifiedDate) {
-      return false;
-    }
+        @Override
+        public boolean isUnchanged(long latestModifiedDate) {
+          return false;
+        }
 
-    protected Object readResolve() {
-      return NOT_FOUND;
-    }
-  };
+        protected Object readResolve() {
+          return NOT_FOUND;
+        }
+      };
 
   public abstract boolean isUnchanged(long latestModifiedDate);
 
   public abstract int weigh();
 
-  public abstract void send(HttpServletRequest req, HttpServletResponse res)
-      throws IOException;
+  public abstract void send(HttpServletRequest req, HttpServletResponse res) throws IOException;
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java
index 9d052fe..8d80111 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/resources/SmallResource.java
@@ -16,9 +16,7 @@
 
 import com.google.common.net.HttpHeaders;
 import com.google.gerrit.common.Nullable;
-
 import java.io.IOException;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
@@ -54,8 +52,7 @@
   }
 
   @Override
-  public void send(HttpServletRequest req, HttpServletResponse res)
-      throws IOException {
+  public void send(HttpServletRequest req, HttpServletResponse res) throws IOException {
     if (0 < lastModified) {
       long ifModifiedSince = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
       if (ifModifiedSince > 0 && ifModifiedSince == lastModified) {
@@ -66,7 +63,7 @@
     }
     res.setContentType(contentType);
     if (characterEncoding != null) {
-     res.setCharacterEncoding(characterEncoding);
+      res.setCharacterEncoding(characterEncoding);
     }
     res.setContentLength(data.length);
     res.getOutputStream().write(data);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
index a9bd85d..0d1e53c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccessRestApiServlet.java
@@ -24,8 +24,7 @@
   private static final long serialVersionUID = 1L;
 
   @Inject
-  AccessRestApiServlet(RestApiServlet.Globals globals,
-      Provider<AccessCollection> access) {
+  AccessRestApiServlet(RestApiServlet.Globals globals, Provider<AccessCollection> access) {
     super(globals, access);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
index 7f8b152..ee57000 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/AccountsRestApiServlet.java
@@ -24,8 +24,7 @@
   private static final long serialVersionUID = 1L;
 
   @Inject
-  AccountsRestApiServlet(RestApiServlet.Globals globals,
-      Provider<AccountsCollection> accounts) {
+  AccountsRestApiServlet(RestApiServlet.Globals globals, Provider<AccountsCollection> accounts) {
     super(globals, accounts);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
index f6f89a6..ccafc6d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ChangesRestApiServlet.java
@@ -24,8 +24,7 @@
   private static final long serialVersionUID = 1L;
 
   @Inject
-  ChangesRestApiServlet(RestApiServlet.Globals globals,
-      Provider<ChangesCollection> changes) {
+  ChangesRestApiServlet(RestApiServlet.Globals globals, Provider<ChangesCollection> changes) {
     super(globals, changes);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
index 48dcfd9..87df4cf 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
@@ -24,8 +24,8 @@
   private static final long serialVersionUID = 1L;
 
   @Inject
-  ConfigRestApiServlet(RestApiServlet.Globals globals,
-      Provider<ConfigCollection> configCollection) {
+  ConfigRestApiServlet(
+      RestApiServlet.Globals globals, Provider<ConfigCollection> configCollection) {
     super(globals, configCollection);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
index 4503bc5..5c7502f 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/GroupsRestApiServlet.java
@@ -24,8 +24,7 @@
   private static final long serialVersionUID = 1L;
 
   @Inject
-  GroupsRestApiServlet(RestApiServlet.Globals globals,
-      Provider<GroupsCollection> groups) {
+  GroupsRestApiServlet(RestApiServlet.Globals globals, Provider<GroupsCollection> groups) {
     super(globals, groups);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index f80cc49..ced3121 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -23,7 +23,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Url;
@@ -34,22 +34,19 @@
 import com.google.gson.JsonPrimitive;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.CmdLineException;
-
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.Set;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.kohsuke.args4j.CmdLineException;
 
 class ParameterParser {
-  private static final ImmutableSet<String> RESERVED_KEYS = ImmutableSet.of(
-      "pp", "prettyPrint", "strict", "callback", "alt", "fields");
+  private static final ImmutableSet<String> RESERVED_KEYS =
+      ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
 
   private final CmdLineParser.Factory parserFactory;
 
@@ -58,10 +55,8 @@
     this.parserFactory = pf;
   }
 
-  <T> boolean parse(T param,
-      Multimap<String, String> in,
-      HttpServletRequest req,
-      HttpServletResponse res)
+  <T> boolean parse(
+      T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
       throws IOException {
     CmdLineParser clp = parserFactory.create(param);
     try {
@@ -81,17 +76,17 @@
       clp.printUsage(msg, null);
       msg.write('\n');
       CacheHeaders.setNotCacheable(res);
-      replyBinaryResult(req, res,
-          BinaryResult.create(msg.toString()).setContentType("text/plain"));
+      replyBinaryResult(req, res, BinaryResult.create(msg.toString()).setContentType("text/plain"));
       return false;
     }
 
     return true;
   }
 
-  static void splitQueryString(String queryString,
-      Multimap<String, String> config,
-      Multimap<String, String> params) {
+  static void splitQueryString(
+      String queryString,
+      ListMultimap<String, String> config,
+      ListMultimap<String, String> params) {
     if (!Strings.isNullOrEmpty(queryString)) {
       for (String kvPair : Splitter.on('&').split(queryString)) {
         Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
@@ -110,9 +105,7 @@
     Set<String> params = new HashSet<>();
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
       for (String kvPair : Splitter.on('&').split(req.getQueryString())) {
-        params.add(Iterables.getFirst(
-            Splitter.on('=').limit(2).split(kvPair),
-            null));
+        params.add(Iterables.getFirst(Splitter.on('=').limit(2).split(kvPair), null));
       }
     }
     return params;
@@ -120,8 +113,8 @@
 
   /**
    * Convert a standard URL encoded form input into a parsed JSON tree.
-   * <p>
-   * Given an input such as:
+   *
+   * <p>Given an input such as:
    *
    * <pre>
    * message=Does+not+compile.&labels.Verified=-1
@@ -144,23 +137,21 @@
    * }
    * </pre>
    *
-   * This input can then be further processed into the Java input type expected
-   * by a view using Gson. Here we rely on Gson to perform implicit conversion
-   * of a string {@code "-1"} to a number type when the Java input type expects
-   * a number.
-   * <p>
-   * Conversion assumes any field name that does not contain {@code "."} will be
-   * a property of the top level input object. Any field with a dot will use the
-   * first segment as the top level property name naming an object, and the rest
-   * of the field name as a property in the nested object.
+   * This input can then be further processed into the Java input type expected by a view using
+   * Gson. Here we rely on Gson to perform implicit conversion of a string {@code "-1"} to a number
+   * type when the Java input type expects a number.
+   *
+   * <p>Conversion assumes any field name that does not contain {@code "."} will be a property of
+   * the top level input object. Any field with a dot will use the first segment as the top level
+   * property name naming an object, and the rest of the field name as a property in the nested
+   * object.
    *
    * @param req request to parse form input from and create JSON tree.
    * @return the converted JSON object tree.
-   * @throws BadRequestException the request cannot be cast, as there are
-   *         conflicting definitions for a nested object.
+   * @throws BadRequestException the request cannot be cast, as there are conflicting definitions
+   *     for a nested object.
    */
-  static JsonObject formToJson(HttpServletRequest req)
-      throws BadRequestException {
+  static JsonObject formToJson(HttpServletRequest req) throws BadRequestException {
     Map<String, String[]> map = req.getParameterMap();
     return formToJson(map, query(req));
   }
@@ -190,9 +181,7 @@
         } else if (e.isJsonObject()) {
           obj = e.getAsJsonObject();
         } else {
-          throw new BadRequestException(String.format(
-              "key %s conflicts with %s",
-              key, property));
+          throw new BadRequestException(String.format("key %s conflicts with %s", key, property));
         }
         key = key.substring(dot + 1);
       }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
index 87245ab..f34608a 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ProjectsRestApiServlet.java
@@ -24,8 +24,7 @@
   private static final long serialVersionUID = 1L;
 
   @Inject
-  ProjectsRestApiServlet(RestApiServlet.Globals globals,
-      Provider<ProjectsCollection> projects) {
+  ProjectsRestApiServlet(RestApiServlet.Globals globals, Provider<ProjectsCollection> projects) {
     super(globals, projects);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
index 5de0e0c..4af03a3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
@@ -30,8 +30,7 @@
 @Singleton
 public class RestApiMetrics {
   private static final String[] PKGS = {
-    "com.google.gerrit.server.",
-    "com.google.gerrit.",
+    "com.google.gerrit.server.", "com.google.gerrit.",
   };
 
   final Counter1<String> count;
@@ -42,32 +41,34 @@
   @Inject
   RestApiMetrics(MetricMaker metrics) {
     Field<String> view = Field.ofString("view", "view implementation class");
-    count = metrics.newCounter(
-        "http/server/rest_api/count",
-        new Description("REST API calls by view")
-          .setRate(),
-        view);
+    count =
+        metrics.newCounter(
+            "http/server/rest_api/count",
+            new Description("REST API calls by view").setRate(),
+            view);
 
-    errorCount = metrics.newCounter(
-        "http/server/rest_api/error_count",
-        new Description("REST API errors by view")
-          .setRate(),
-        view,
-        Field.ofInteger("error_code", "HTTP status code"));
+    errorCount =
+        metrics.newCounter(
+            "http/server/rest_api/error_count",
+            new Description("REST API errors by view").setRate(),
+            view,
+            Field.ofInteger("error_code", "HTTP status code"));
 
-    serverLatency = metrics.newTimer(
-        "http/server/rest_api/server_latency",
-        new Description("REST API call latency by view")
-          .setCumulative()
-          .setUnit(Units.MILLISECONDS),
-        view);
+    serverLatency =
+        metrics.newTimer(
+            "http/server/rest_api/server_latency",
+            new Description("REST API call latency by view")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            view);
 
-    responseBytes = metrics.newHistogram(
-        "http/server/rest_api/response_bytes",
-        new Description("Size of response on network (may be gzip compressed)")
-          .setCumulative()
-          .setUnit(Units.BYTES),
-        view);
+    responseBytes =
+        metrics.newHistogram(
+            "http/server/rest_api/response_bytes",
+            new Description("Size of response on network (may be gzip compressed)")
+                .setCumulative()
+                .setUnit(Units.BYTES),
+            view);
   }
 
   String view(ViewData viewData) {
@@ -78,8 +79,7 @@
         break;
       }
     }
-    if (!Strings.isNullOrEmpty(viewData.pluginName)
-        && !"gerrit".equals(viewData.pluginName)) {
+    if (!Strings.isNullOrEmpty(viewData.pluginName) && !"gerrit".equals(viewData.pluginName)) {
       impl = viewData.pluginName + '-' + impl;
     }
     return impl;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 943d824..bfa91d6 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -15,9 +15,18 @@
 package com.google.gerrit.httpd.restapi;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
@@ -33,15 +42,16 @@
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 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.Multimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.io.BaseEncoding;
 import com.google.common.io.CountingOutputStream;
 import com.google.common.math.IntMath;
@@ -64,6 +74,7 @@
 import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.NeedsParams;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.PreconditionFailedException;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -85,6 +96,7 @@
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.account.CapabilityUtils;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.gson.ExclusionStrategy;
 import com.google.gson.FieldAttributes;
@@ -102,12 +114,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.util.TemporaryBuffer;
-import org.eclipse.jgit.util.TemporaryBuffer.Heap;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
 import java.io.EOFException;
@@ -131,36 +137,44 @@
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.stream.StreamSupport;
 import java.util.zip.GZIPOutputStream;
-
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.TemporaryBuffer;
+import org.eclipse.jgit.util.TemporaryBuffer.Heap;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class RestApiServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
-  private static final Logger log = LoggerFactory
-      .getLogger(RestApiServlet.class);
+  private static final Logger log = LoggerFactory.getLogger(RestApiServlet.class);
 
   /** MIME type used for a JSON response body. */
   private static final String JSON_TYPE = "application/json";
+
   private static final String FORM_TYPE = "application/x-www-form-urlencoded";
 
   // HTTP 422 Unprocessable Entity.
   // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
   private static final int SC_UNPROCESSABLE_ENTITY = 422;
+  private static final String X_REQUESTED_WITH = "X-Requested-With";
+  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
+      ImmutableSet.of(X_REQUESTED_WITH);
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
 
   /**
    * Garbage prefix inserted before JSON output to prevent XSSI.
-   * <p>
-   * This prefix is ")]}'\n" and is designed to prevent a web browser from
-   * executing the response body if the resource URI were to be referenced using
-   * a &lt;script src="...&gt; HTML tag from another web site. Clients using the
-   * HTTP interface will need to always strip the first line of response data to
-   * remove this magic header.
+   *
+   * <p>This prefix is ")]}'\n" and is designed to prevent a web browser from executing the response
+   * body if the resource URI were to be referenced using a &lt;script src="...&gt; HTML tag from
+   * another web site. Clients using the HTTP interface will need to always strip the first line of
+   * response data to remove this magic header.
    */
   public static final byte[] JSON_MAGIC;
 
@@ -174,30 +188,43 @@
     final Provider<ParameterParser> paramParser;
     final AuditService auditService;
     final RestApiMetrics metrics;
+    final Pattern allowOrigin;
 
     @Inject
-    Globals(Provider<CurrentUser> currentUser,
+    Globals(
+        Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
         AuditService auditService,
-        RestApiMetrics metrics) {
+        RestApiMetrics metrics,
+        @GerritServerConfig Config cfg) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
       this.auditService = auditService;
       this.metrics = metrics;
+      allowOrigin = makeAllowOrigin(cfg);
+    }
+
+    private static Pattern makeAllowOrigin(Config cfg) {
+      String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
+      if (allow.length > 0) {
+        return Pattern.compile(Joiner.on('|').join(allow));
+      }
+      return null;
     }
   }
 
   private final Globals globals;
   private final Provider<RestCollection<RestResource, RestResource>> members;
 
-  public RestApiServlet(Globals globals,
-      RestCollection<? extends RestResource, ? extends RestResource> members) {
+  public RestApiServlet(
+      Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
     this(globals, Providers.of(members));
   }
 
-  public RestApiServlet(Globals globals,
+  public RestApiServlet(
+      Globals globals,
       Provider<? extends RestCollection<? extends RestResource, ? extends RestResource>> members) {
     @SuppressWarnings("unchecked")
     Provider<RestCollection<RestResource, RestResource>> n =
@@ -216,23 +243,34 @@
     int status = SC_OK;
     long responseBytes = -1;
     Object result = null;
-    Multimap<String, String> params = LinkedHashMultimap.create();
+    ListMultimap<String, String> params = MultimapBuilder.hashKeys().arrayListValues().build();
+    ListMultimap<String, String> config = MultimapBuilder.hashKeys().arrayListValues().build();
     Object inputRequestBody = null;
     RestResource rsrc = TopLevelResource.INSTANCE;
     ViewData viewData = null;
 
     try {
+      if (isCorsPreflight(req)) {
+        doCorsPreflight(req, res);
+        return;
+      }
+      checkCors(req, res);
       checkUserSession(req);
 
+      ParameterParser.splitQueryString(req.getQueryString(), config, params);
+
       List<IdString> path = splitPath(req);
       RestCollection<RestResource, RestResource> rc = members.get();
-      CapabilityUtils.checkRequiresCapability(globals.currentUser,
-          null, rc.getClass());
+      CapabilityUtils.checkRequiresCapability(globals.currentUser, null, rc.getClass());
 
       viewData = new ViewData(null, null);
 
       if (path.isEmpty()) {
-        if (isGetOrHead(req)) {
+        if (rc instanceof NeedsParams) {
+          ((NeedsParams) rc).setParams(params);
+        }
+
+        if (isRead(req)) {
           viewData = new ViewData(null, rc.list());
         } else if (rc instanceof AcceptsPost && "POST".equals(req.getMethod())) {
           @SuppressWarnings("unchecked")
@@ -251,8 +289,7 @@
         } catch (ResourceNotFoundException e) {
           if (rc instanceof AcceptsCreate
               && path.isEmpty()
-              && ("POST".equals(req.getMethod())
-                  || "PUT".equals(req.getMethod()))) {
+              && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
             @SuppressWarnings("unchecked")
             AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) rc;
             viewData = new ViewData(null, ac.create(rsrc, id));
@@ -267,13 +304,13 @@
       }
       checkRequiresCapability(viewData);
 
-      while (viewData.view instanceof RestCollection<?,?>) {
+      while (viewData.view instanceof RestCollection<?, ?>) {
         @SuppressWarnings("unchecked")
         RestCollection<RestResource, RestResource> c =
             (RestCollection<RestResource, RestResource>) viewData.view;
 
         if (path.isEmpty()) {
-          if (isGetOrHead(req)) {
+          if (isRead(req)) {
             viewData = new ViewData(null, c.list());
           } else if (c instanceof AcceptsPost && "POST".equals(req.getMethod())) {
             @SuppressWarnings("unchecked")
@@ -296,8 +333,7 @@
         } catch (ResourceNotFoundException e) {
           if (c instanceof AcceptsCreate
               && path.isEmpty()
-              && ("POST".equals(req.getMethod())
-                  || "PUT".equals(req.getMethod()))) {
+              && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
             @SuppressWarnings("unchecked")
             AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c;
             viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id));
@@ -324,13 +360,11 @@
         return;
       }
 
-      Multimap<String, String> config = LinkedHashMultimap.create();
-      ParameterParser.splitQueryString(req.getQueryString(), config, params);
       if (!globals.paramParser.get().parse(viewData.view, params, req, res)) {
         return;
       }
 
-      if (viewData.view instanceof RestReadView<?> && isGetOrHead(req)) {
+      if (viewData.view instanceof RestReadView<?> && isRead(req)) {
         result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
       } else if (viewData.view instanceof RestModifyView<?, ?>) {
         @SuppressWarnings("unchecked")
@@ -355,7 +389,7 @@
       } else if (result instanceof Response.Accepted) {
         CacheHeaders.setNotCacheable(res);
         res.setStatus(SC_ACCEPTED);
-        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted)result).location());
+        res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
         return;
       } else {
         CacheHeaders.setNotCacheable(res);
@@ -371,45 +405,62 @@
         }
       }
     } catch (MalformedJsonException e) {
-      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
-          "Invalid " + JSON_TYPE + " in request", e);
+      responseBytes =
+          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
     } catch (JsonParseException e) {
-      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
-          "Invalid " + JSON_TYPE + " in request", e);
+      responseBytes =
+          replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
     } catch (BadRequestException e) {
-      responseBytes = replyError(req, res, status = SC_BAD_REQUEST,
-          messageOr(e, "Bad Request"), e.caching(), e);
+      responseBytes =
+          replyError(
+              req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
     } catch (AuthException e) {
-      responseBytes = replyError(req, res, status = SC_FORBIDDEN,
-          messageOr(e, "Forbidden"), e.caching(), e);
+      responseBytes =
+          replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
     } catch (AmbiguousViewException e) {
-      responseBytes = replyError(req, res, status = SC_NOT_FOUND,
-          messageOr(e, "Ambiguous"), e);
+      responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
     } catch (ResourceNotFoundException e) {
-      responseBytes = replyError(req, res, status = SC_NOT_FOUND,
-          messageOr(e, "Not Found"), e.caching(), e);
+      responseBytes =
+          replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
     } catch (MethodNotAllowedException e) {
-      responseBytes = replyError(req, res, status = SC_METHOD_NOT_ALLOWED,
-          messageOr(e, "Method Not Allowed"), e.caching(), e);
+      responseBytes =
+          replyError(
+              req,
+              res,
+              status = SC_METHOD_NOT_ALLOWED,
+              messageOr(e, "Method Not Allowed"),
+              e.caching(),
+              e);
     } catch (ResourceConflictException e) {
-      responseBytes = replyError(req, res, status = SC_CONFLICT,
-          messageOr(e, "Conflict"), e.caching(), e);
+      responseBytes =
+          replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
     } catch (PreconditionFailedException e) {
-      responseBytes = replyError(req, res, status = SC_PRECONDITION_FAILED,
-          messageOr(e, "Precondition Failed"), e.caching(), e);
+      responseBytes =
+          replyError(
+              req,
+              res,
+              status = SC_PRECONDITION_FAILED,
+              messageOr(e, "Precondition Failed"),
+              e.caching(),
+              e);
     } catch (UnprocessableEntityException e) {
-      responseBytes = replyError(req, res, status = SC_UNPROCESSABLE_ENTITY,
-          messageOr(e, "Unprocessable Entity"), e.caching(), e);
+      responseBytes =
+          replyError(
+              req,
+              res,
+              status = SC_UNPROCESSABLE_ENTITY,
+              messageOr(e, "Unprocessable Entity"),
+              e.caching(),
+              e);
     } catch (NotImplementedException e) {
-      responseBytes = replyError(req, res, status = SC_NOT_IMPLEMENTED,
-          messageOr(e, "Not Implemented"), e);
+      responseBytes =
+          replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
     } catch (Exception e) {
       status = SC_INTERNAL_SERVER_ERROR;
       responseBytes = handleException(e, req, res);
     } finally {
-      String metric = viewData != null && viewData.view != null
-          ? globals.metrics.view(viewData)
-          : "_unknown";
+      String metric =
+          viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
       globals.metrics.count.increment(metric);
       if (status >= SC_BAD_REQUEST) {
         globals.metrics.errorCount.increment(metric, status);
@@ -418,16 +469,82 @@
         globals.metrics.responseBytes.record(metric, responseBytes);
       }
       globals.metrics.serverLatency.record(
-          metric,
-          System.nanoTime() - startNanos,
-          TimeUnit.NANOSECONDS);
-      globals.auditService.dispatch(new ExtendedHttpAuditEvent(globals.webSession.get()
-          .getSessionId(), globals.currentUser.get(), req,
-          auditStartTs, params, inputRequestBody, status,
-          result, rsrc, viewData == null ? null : viewData.view));
+          metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+      globals.auditService.dispatch(
+          new ExtendedHttpAuditEvent(
+              globals.webSession.get().getSessionId(),
+              globals.currentUser.get(),
+              req,
+              auditStartTs,
+              params,
+              inputRequestBody,
+              status,
+              result,
+              rsrc,
+              viewData == null ? null : viewData.view));
     }
   }
 
+  private void checkCors(HttpServletRequest req, HttpServletResponse res) {
+    String origin = req.getHeader(ORIGIN);
+    if (isRead(req) && !Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) {
+      res.addHeader(VARY, ORIGIN);
+      setCorsHeaders(res, origin);
+    }
+  }
+
+  private static boolean isCorsPreflight(HttpServletRequest req) {
+    return "OPTIONS".equals(req.getMethod())
+        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
+        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
+  }
+
+  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
+      throws BadRequestException {
+    CacheHeaders.setNotCacheable(res);
+    res.setHeader(
+        VARY, Joiner.on(", ").join(ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD)));
+
+    String origin = req.getHeader(ORIGIN);
+    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+      throw new BadRequestException("CORS not allowed");
+    }
+
+    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
+    if (!"GET".equals(method) && !"HEAD".equals(method)) {
+      throw new BadRequestException(method + " not allowed in CORS");
+    }
+
+    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
+    if (headers != null) {
+      res.addHeader(VARY, ACCESS_CONTROL_REQUEST_HEADERS);
+      String badHeader =
+          StreamSupport.stream(Splitter.on(',').trimResults().split(headers).spliterator(), false)
+              .filter(h -> !ALLOWED_CORS_REQUEST_HEADERS.contains(h))
+              .findFirst()
+              .orElse(null);
+      if (badHeader != null) {
+        throw new BadRequestException(badHeader + " not allowed in CORS");
+      }
+    }
+
+    res.setStatus(SC_OK);
+    setCorsHeaders(res, origin);
+    res.setContentType("text/plain");
+    res.setContentLength(0);
+  }
+
+  private void setCorsHeaders(HttpServletResponse res, String origin) {
+    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS");
+    res.setHeader(ACCESS_CONTROL_ALLOW_HEADERS, Joiner.on(", ").join(ALLOWED_CORS_REQUEST_HEADERS));
+  }
+
+  private boolean isOriginAllowed(String origin) {
+    return globals.allowOrigin != null && globals.allowOrigin.matcher(origin).matches();
+  }
+
   private static String messageOr(Throwable t, String defaultMessage) {
     if (!Strings.isNullOrEmpty(t.getMessage())) {
       return t.getMessage();
@@ -436,9 +553,9 @@
   }
 
   @SuppressWarnings({"unchecked", "rawtypes"})
-  private static boolean notModified(HttpServletRequest req, RestResource rsrc,
-      RestView<RestResource> view) {
-    if (!isGetOrHead(req)) {
+  private static boolean notModified(
+      HttpServletRequest req, RestResource rsrc, RestView<RestResource> view) {
+    if (!isRead(req)) {
       return false;
     }
 
@@ -467,9 +584,8 @@
   }
 
   private static <R extends RestResource> void configureCaching(
-      HttpServletRequest req, HttpServletResponse res, R rsrc,
-      RestView<R> view, CacheControl c) {
-    if (isGetOrHead(req)) {
+      HttpServletRequest req, HttpServletResponse res, R rsrc, RestView<R> view, CacheControl c) {
+    if (isRead(req)) {
       switch (c.getType()) {
         case NONE:
         default:
@@ -477,15 +593,11 @@
           break;
         case PRIVATE:
           addResourceStateHeaders(res, rsrc, view);
-          CacheHeaders.setCacheablePrivate(res,
-              c.getAge(), c.getUnit(),
-              c.isMustRevalidate());
+          CacheHeaders.setCacheablePrivate(res, c.getAge(), c.getUnit(), c.isMustRevalidate());
           break;
         case PUBLIC:
           addResourceStateHeaders(res, rsrc, view);
-          CacheHeaders.setCacheable(req, res,
-              c.getAge(), c.getUnit(),
-              c.isMustRevalidate());
+          CacheHeaders.setCacheable(req, res, c.getAge(), c.getUnit(), c.isMustRevalidate());
           break;
       }
     } else {
@@ -493,7 +605,7 @@
     }
   }
 
-  private static  <R extends RestResource> void addResourceStateHeaders(
+  private static <R extends RestResource> void addResourceStateHeaders(
       HttpServletResponse res, R rsrc, RestView<R> view) {
     if (view instanceof ETagView) {
       res.setHeader(HttpHeaders.ETAG, ((ETagView<R>) view).getETag(rsrc));
@@ -507,8 +619,7 @@
     }
   }
 
-  private void checkPreconditions(HttpServletRequest req)
-      throws PreconditionFailedException {
+  private void checkPreconditions(HttpServletRequest req) throws PreconditionFailedException {
     if ("*".equals(req.getHeader(HttpHeaders.IF_NONE_MATCH))) {
       throw new PreconditionFailedException("Resource already exists");
     }
@@ -517,9 +628,10 @@
   private static Type inputType(RestModifyView<RestResource, Object> m) {
     Type inputType = extractInputType(m.getClass());
     if (inputType == null) {
-      throw new IllegalStateException(String.format(
-          "View %s does not correctly implement %s",
-          m.getClass(), RestModifyView.class.getSimpleName()));
+      throw new IllegalStateException(
+          String.format(
+              "View %s does not correctly implement %s",
+              m.getClass(), RestModifyView.class.getSimpleName()));
     }
     return inputType;
   }
@@ -551,9 +663,9 @@
   }
 
   private Object parseRequest(HttpServletRequest req, Type type)
-      throws IOException, BadRequestException, SecurityException,
-      IllegalArgumentException, NoSuchMethodException, IllegalAccessException,
-      InstantiationException, InvocationTargetException, MethodNotAllowedException {
+      throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
+          NoSuchMethodException, IllegalAccessException, InstantiationException,
+          InvocationTargetException, MethodNotAllowedException {
     if (isType(JSON_TYPE, req.getContentType())) {
       try (BufferedReader br = req.getReader();
           JsonReader json = new JsonReader(br)) {
@@ -587,11 +699,8 @@
         }
         return parseString(sb.toString(), type);
       }
-    } else if ("POST".equals(req.getMethod())
-        && isType(FORM_TYPE, req.getContentType())) {
-      return OutputFormat.JSON.newGson().fromJson(
-          ParameterParser.formToJson(req),
-          type);
+    } else if ("POST".equals(req.getMethod()) && isType(FORM_TYPE, req.getContentType())) {
+      return OutputFormat.JSON.newGson().fromJson(ParameterParser.formToJson(req), type);
     } else {
       throw new BadRequestException("Expected Content-Type: " + JSON_TYPE);
     }
@@ -600,8 +709,7 @@
   private static boolean hasNoBody(HttpServletRequest req) {
     int len = req.getContentLength();
     String type = req.getContentType();
-    return (len <= 0 && type == null)
-        || (len == 0 && isType(FORM_TYPE, type));
+    return (len <= 0 && type == null) || (len == 0 && isType(FORM_TYPE, type));
   }
 
   @SuppressWarnings("rawtypes")
@@ -617,9 +725,9 @@
   }
 
   private Object parseRawInput(final HttpServletRequest req, Type type)
-      throws SecurityException, NoSuchMethodException,
-      IllegalArgumentException, InstantiationException, IllegalAccessException,
-      InvocationTargetException, MethodNotAllowedException {
+      throws SecurityException, NoSuchMethodException, IllegalArgumentException,
+          InstantiationException, IllegalAccessException, InvocationTargetException,
+          MethodNotAllowedException {
     Object obj = createInstance(type);
     for (Field f : obj.getClass().getDeclaredFields()) {
       if (f.getType() == RawInput.class) {
@@ -633,8 +741,8 @@
 
   private Object parseString(String value, Type type)
       throws BadRequestException, SecurityException, NoSuchMethodException,
-      IllegalArgumentException, IllegalAccessException, InstantiationException,
-      InvocationTargetException {
+          IllegalArgumentException, IllegalAccessException, InstantiationException,
+          InvocationTargetException {
     if (type == String.class) {
       return value;
     }
@@ -645,8 +753,7 @@
       return obj;
     }
     for (Field f : fields) {
-      if (f.getAnnotation(DefaultInput.class) != null
-          && f.getType() == String.class) {
+      if (f.getAnnotation(DefaultInput.class) != null && f.getType() == String.class) {
         f.setAccessible(true);
         f.set(obj, value);
         return obj;
@@ -656,8 +763,8 @@
   }
 
   private static Object createInstance(Type type)
-      throws NoSuchMethodException, InstantiationException,
-      IllegalAccessException, InvocationTargetException {
+      throws NoSuchMethodException, InstantiationException, IllegalAccessException,
+          InvocationTargetException {
     if (type instanceof Class) {
       @SuppressWarnings("unchecked")
       Class<Object> clazz = (Class<Object>) type;
@@ -668,9 +775,10 @@
     throw new InstantiationException("Cannot make " + type);
   }
 
-  public static long replyJson(@Nullable HttpServletRequest req,
+  public static long replyJson(
+      @Nullable HttpServletRequest req,
       HttpServletResponse res,
-      Multimap<String, String> config,
+      ListMultimap<String, String> config,
       Object result)
       throws IOException {
     TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
@@ -684,13 +792,12 @@
     }
     w.write('\n');
     w.flush();
-    return replyBinaryResult(req, res, asBinaryResult(buf)
-      .setContentType(JSON_TYPE)
-      .setCharacterEncoding(UTF_8));
+    return replyBinaryResult(
+        req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8));
   }
 
-  private static Gson newGson(Multimap<String, String> config,
-      @Nullable HttpServletRequest req) {
+  private static Gson newGson(
+      ListMultimap<String, String> config, @Nullable HttpServletRequest req) {
     GsonBuilder gb = OutputFormat.JSON_COMPACT.newGsonBuilder();
 
     enablePrettyPrint(gb, config, req);
@@ -699,9 +806,8 @@
     return gb.create();
   }
 
-  private static void enablePrettyPrint(GsonBuilder gb,
-      Multimap<String, String> config,
-      @Nullable HttpServletRequest req) {
+  private static void enablePrettyPrint(
+      GsonBuilder gb, ListMultimap<String, String> config, @Nullable HttpServletRequest req) {
     String pp = Iterables.getFirst(config.get("pp"), null);
     if (pp == null) {
       pp = Iterables.getFirst(config.get("prettyPrint"), null);
@@ -714,55 +820,53 @@
     }
   }
 
-  private static void enablePartialGetFields(GsonBuilder gb,
-      Multimap<String, String> config) {
+  private static void enablePartialGetFields(GsonBuilder gb, ListMultimap<String, String> config) {
     final Set<String> want = new HashSet<>();
     for (String p : config.get("fields")) {
       Iterables.addAll(want, OptionUtil.splitOptionValue(p));
     }
     if (!want.isEmpty()) {
-      gb.addSerializationExclusionStrategy(new ExclusionStrategy() {
-        private final Map<String, String> names = new HashMap<>();
+      gb.addSerializationExclusionStrategy(
+          new ExclusionStrategy() {
+            private final Map<String, String> names = new HashMap<>();
 
-        @Override
-        public boolean shouldSkipField(FieldAttributes field) {
-          String name = names.get(field.getName());
-          if (name == null) {
-            // Names are supplied by Gson in terms of Java source.
-            // Translate and cache the JSON lower_case_style used.
-            try {
-              name =
-                  FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES.translateName(//
-                      field.getDeclaringClass().getDeclaredField(field.getName()));
-              names.put(field.getName(), name);
-            } catch (SecurityException e) {
-              return true;
-            } catch (NoSuchFieldException e) {
-              return true;
+            @Override
+            public boolean shouldSkipField(FieldAttributes field) {
+              String name = names.get(field.getName());
+              if (name == null) {
+                // Names are supplied by Gson in terms of Java source.
+                // Translate and cache the JSON lower_case_style used.
+                try {
+                  name =
+                      FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES.translateName( //
+                          field.getDeclaringClass().getDeclaredField(field.getName()));
+                  names.put(field.getName(), name);
+                } catch (SecurityException e) {
+                  return true;
+                } catch (NoSuchFieldException e) {
+                  return true;
+                }
+              }
+              return !want.contains(name);
             }
-          }
-          return !want.contains(name);
-        }
 
-        @Override
-        public boolean shouldSkipClass(Class<?> clazz) {
-          return false;
-        }
-      });
+            @Override
+            public boolean shouldSkipClass(Class<?> clazz) {
+              return false;
+            }
+          });
     }
   }
 
   @SuppressWarnings("resource")
   static long replyBinaryResult(
-      @Nullable HttpServletRequest req,
-      HttpServletResponse res,
-      BinaryResult bin) throws IOException {
+      @Nullable HttpServletRequest req, HttpServletResponse res, BinaryResult bin)
+      throws IOException {
     final BinaryResult appResult = bin;
     try {
       if (bin.getAttachmentName() != null) {
         res.setHeader(
-            "Content-Disposition",
-            "attachment; filename=\"" + bin.getAttachmentName() + "\"");
+            "Content-Disposition", "attachment; filename=\"" + bin.getAttachmentName() + "\"");
       }
       if (bin.isBase64()) {
         if (req != null && JSON_TYPE.equals(req.getHeader(HttpHeaders.ACCEPT))) {
@@ -784,8 +888,7 @@
       }
 
       if (req == null || !"HEAD".equals(req.getMethod())) {
-        try (CountingOutputStream dst =
-            new CountingOutputStream(res.getOutputStream())) {
+        try (CountingOutputStream dst = new CountingOutputStream(res.getOutputStream())) {
           bin.writeTo(dst);
           return dst.getCount();
         }
@@ -796,8 +899,8 @@
     }
   }
 
-  private static BinaryResult stackJsonString(HttpServletResponse res,
-      final BinaryResult src) throws IOException {
+  private static BinaryResult stackJsonString(HttpServletResponse res, final BinaryResult src)
+      throws IOException {
     TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
     buf.write(JSON_MAGIC);
     try (Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8));
@@ -809,41 +912,42 @@
     }
     res.setHeader("X-FYI-Content-Encoding", "json");
     res.setHeader("X-FYI-Content-Type", src.getContentType());
-    return asBinaryResult(buf)
-      .setContentType(JSON_TYPE)
-      .setCharacterEncoding(UTF_8);
+    return asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8);
   }
 
-  private static BinaryResult stackBase64(HttpServletResponse res,
-      final BinaryResult src) throws IOException {
+  private static BinaryResult stackBase64(HttpServletResponse res, final BinaryResult src)
+      throws IOException {
     BinaryResult b64;
     long len = src.getContentLength();
     if (0 <= len && len <= (7 << 20)) {
       b64 = base64(src);
     } else {
-      b64 = new BinaryResult() {
-        @Override
-        public void writeTo(OutputStream out) throws IOException {
-          try (OutputStreamWriter w = new OutputStreamWriter(
-                new FilterOutputStream(out) {
-                  @Override
-                  public void close() {
-                    // Do not close out, but only w and e.
-                  }
-                }, ISO_8859_1);
-              OutputStream e = BaseEncoding.base64().encodingStream(w)) {
-            src.writeTo(e);
-          }
-        }
-      };
+      b64 =
+          new BinaryResult() {
+            @Override
+            public void writeTo(OutputStream out) throws IOException {
+              try (OutputStreamWriter w =
+                      new OutputStreamWriter(
+                          new FilterOutputStream(out) {
+                            @Override
+                            public void close() {
+                              // Do not close out, but only w and e.
+                            }
+                          },
+                          ISO_8859_1);
+                  OutputStream e = BaseEncoding.base64().encodingStream(w)) {
+                src.writeTo(e);
+              }
+            }
+          };
     }
     res.setHeader("X-FYI-Content-Encoding", "base64");
     res.setHeader("X-FYI-Content-Type", src.getContentType());
     return b64.setContentType("text/plain").setCharacterEncoding(ISO_8859_1);
   }
 
-  private static BinaryResult stackGzip(HttpServletResponse res,
-      final BinaryResult src) throws IOException {
+  private static BinaryResult stackGzip(HttpServletResponse res, final BinaryResult src)
+      throws IOException {
     BinaryResult gz;
     long len = src.getContentLength();
     if (len < 256) {
@@ -854,15 +958,16 @@
         return src;
       }
     } else {
-      gz = new BinaryResult() {
-        @Override
-        public void writeTo(OutputStream out) throws IOException {
-          GZIPOutputStream gz = new GZIPOutputStream(out);
-          src.writeTo(gz);
-          gz.finish();
-          gz.flush();
-        }
-      };
+      gz =
+          new BinaryResult() {
+            @Override
+            public void writeTo(OutputStream out) throws IOException {
+              GZIPOutputStream gz = new GZIPOutputStream(out);
+              src.writeTo(gz);
+              gz.finish();
+              gz.flush();
+            }
+          };
     }
     res.setHeader("Content-Encoding", "gzip");
     return gz.setContentType(src.getContentType());
@@ -871,12 +976,11 @@
   private ViewData view(
       RestResource rsrc,
       RestCollection<RestResource, RestResource> rc,
-      String method, List<IdString> path) throws AmbiguousViewException,
-      RestApiException {
+      String method,
+      List<IdString> path)
+      throws AmbiguousViewException, RestApiException {
     DynamicMap<RestView<RestResource>> views = rc.views();
-    final IdString projection = path.isEmpty()
-        ? IdString.fromUrl("/")
-        : path.remove(0);
+    final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0);
     if (!path.isEmpty()) {
       // If there are path components still remaining after this projection
       // is chosen, look for the projection based upon GET as the method as
@@ -892,8 +996,7 @@
       if (Strings.isNullOrEmpty(viewname)) {
         viewname = "/";
       }
-      RestView<RestResource> view =
-          views.get(p.get(0), method + "." + viewname);
+      RestView<RestResource> view = views.get(p.get(0), method + "." + viewname);
       if (view != null) {
         return new ViewData(p.get(0), view);
       }
@@ -929,22 +1032,15 @@
     }
 
     if (r.size() == 1) {
-      Map.Entry<String, RestView<RestResource>> entry =
-          Iterables.getOnlyElement(r.entrySet());
+      Map.Entry<String, RestView<RestResource>> entry = Iterables.getOnlyElement(r.entrySet());
       return new ViewData(entry.getKey(), entry.getValue());
     } else if (r.isEmpty()) {
       throw new ResourceNotFoundException(projection);
     } else {
-      throw new AmbiguousViewException(String.format(
-        "Projection %s is ambiguous: %s",
-        name,
-        Joiner.on(", ").join(
-          Iterables.transform(r.keySet(), new Function<String, String>() {
-            @Override
-            public String apply(String in) {
-              return in + "~" + projection;
-            }
-          }))));
+      throw new AmbiguousViewException(
+          String.format(
+              "Projection %s is ambiguous: %s",
+              name, r.keySet().stream().map(in -> in + "~" + projection).collect(joining(", "))));
     }
   }
 
@@ -969,40 +1065,36 @@
     return p;
   }
 
-  private void checkUserSession(HttpServletRequest req)
-      throws AuthException {
+  private void checkUserSession(HttpServletRequest req) throws AuthException {
     CurrentUser user = globals.currentUser.get();
-    if (isStateChange(req)) {
-      if (user instanceof AnonymousUser) {
-        throw new AuthException("Authentication required");
-      } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
-        throw new AuthException("Invalid authentication method. In order to authenticate, "
-            + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
-      }
+    if (isRead(req)) {
+      user.setAccessPath(AccessPath.REST_API);
+      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
+    } else if (user instanceof AnonymousUser) {
+      throw new AuthException("Authentication required");
+    } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
+      throw new AuthException(
+          "Invalid authentication method. In order to authenticate, "
+              + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
     }
-    user.setAccessPath(AccessPath.REST_API);
   }
 
-  private static boolean isGetOrHead(HttpServletRequest req) {
+  private static boolean isRead(HttpServletRequest req) {
     return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
   }
 
-  private static boolean isStateChange(HttpServletRequest req) {
-    return !isGetOrHead(req);
-  }
-
   private void checkRequiresCapability(ViewData viewData) throws AuthException {
-    CapabilityUtils.checkRequiresCapability(globals.currentUser,
-        viewData.pluginName, viewData.view.getClass());
+    CapabilityUtils.checkRequiresCapability(
+        globals.currentUser, viewData.pluginName, viewData.view.getClass());
   }
 
-  private static long handleException(Throwable err, HttpServletRequest req,
-      HttpServletResponse res) throws IOException {
+  private static long handleException(
+      Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException {
     String uri = req.getRequestURI();
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
       uri += "?" + req.getQueryString();
     }
-    log.error(String.format("Error in %s %s", req.getMethod(), uri), err);
+    log.error("Error in {} {}", req.getMethod(), uri, err);
 
     if (!res.isCommitted()) {
       res.reset();
@@ -1011,14 +1103,24 @@
     return 0;
   }
 
-  public static long replyError(HttpServletRequest req, HttpServletResponse res,
-      int statusCode, String msg, @Nullable Throwable err) throws IOException {
+  public static long replyError(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      int statusCode,
+      String msg,
+      @Nullable Throwable err)
+      throws IOException {
     return replyError(req, res, statusCode, msg, CacheControl.NONE, err);
   }
 
-  public static long replyError(HttpServletRequest req,
-      HttpServletResponse res, int statusCode, String msg,
-      CacheControl c, @Nullable Throwable err) throws IOException {
+  public static long replyError(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      int statusCode,
+      String msg,
+      CacheControl c,
+      @Nullable Throwable err)
+      throws IOException {
     if (err != null) {
       RequestUtil.setErrorTraceAttribute(req, err);
     }
@@ -1027,16 +1129,15 @@
     return replyText(req, res, msg);
   }
 
-  static long replyText(@Nullable HttpServletRequest req,
-      HttpServletResponse res, String text) throws IOException {
-    if ((req == null || isGetOrHead(req)) && isMaybeHTML(text)) {
-      return replyJson(req, res, ImmutableMultimap.of("pp", "0"), new JsonPrimitive(text));
+  static long replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text)
+      throws IOException {
+    if ((req == null || isRead(req)) && isMaybeHTML(text)) {
+      return replyJson(req, res, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text));
     }
     if (!text.endsWith("\n")) {
       text += "\n";
     }
-    return replyBinaryResult(req, res,
-        BinaryResult.create(text).setContentType("text/plain"));
+    return replyBinaryResult(req, res, BinaryResult.create(text).setContentType("text/plain"));
   }
 
   private static boolean isMaybeHTML(String text) {
@@ -1075,20 +1176,18 @@
     return 4 * IntMath.divide((int) n, 3, CEILING);
   }
 
-  private static BinaryResult base64(BinaryResult bin)
-      throws IOException {
+  private static BinaryResult base64(BinaryResult bin) throws IOException {
     int maxSize = base64MaxSize(bin.getContentLength());
     int estSize = Math.min(base64MaxSize(HEAP_EST_SIZE), maxSize);
     TemporaryBuffer.Heap buf = heap(estSize, maxSize);
-    try (OutputStream encoded = BaseEncoding.base64().encodingStream(
-        new OutputStreamWriter(buf, ISO_8859_1))) {
+    try (OutputStream encoded =
+        BaseEncoding.base64().encodingStream(new OutputStreamWriter(buf, ISO_8859_1))) {
       bin.writeTo(encoded);
     }
     return asBinaryResult(buf);
   }
 
-  private static BinaryResult compress(BinaryResult bin)
-      throws IOException {
+  private static BinaryResult compress(BinaryResult bin) throws IOException {
     TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, 20 << 20);
     try (GZIPOutputStream gz = new GZIPOutputStream(buf)) {
       bin.writeTo(gz);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java
index 4696e8d..31819e8 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/AuditedHttpServletResponse.java
@@ -15,13 +15,10 @@
 package com.google.gerrit.httpd.rpc;
 
 import java.io.IOException;
-
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponseWrapper;
 
-class AuditedHttpServletResponse
-    extends HttpServletResponseWrapper
-    implements HttpServletResponse {
+class AuditedHttpServletResponse extends HttpServletResponseWrapper implements HttpServletResponse {
   private int status;
 
   AuditedHttpServletResponse(HttpServletResponse response) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
index 9cf6504..e561c9b 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/BaseServiceImplementation.java
@@ -26,7 +26,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Provider;
-
 import java.io.IOException;
 
 /** Support for services which require a {@link ReviewDb} instance. */
@@ -34,8 +33,8 @@
   private final Provider<ReviewDb> schema;
   private final Provider<? extends CurrentUser> currentUser;
 
-  protected BaseServiceImplementation(final Provider<ReviewDb> schema,
-      final Provider<? extends CurrentUser> currentUser) {
+  protected BaseServiceImplementation(
+      final Provider<ReviewDb> schema, final Provider<? extends CurrentUser> currentUser) {
     this.schema = schema;
     this.currentUser = currentUser;
   }
@@ -55,10 +54,10 @@
 
   /**
    * Executes {@code action.run} with an active ReviewDb connection.
-   * <p>
-   * A database handle is automatically opened and closed around the action's
-   * {@link Action#run(ReviewDb)} method. OrmExceptions are caught and passed
-   * into the onFailure method of the callback.
+   *
+   * <p>A database handle is automatically opened and closed around the action's {@link
+   * Action#run(ReviewDb)} method. OrmExceptions are caught and passed into the onFailure method of
+   * the callback.
    *
    * @param <T> type of result the callback expects.
    * @param callback the callback that will receive the result.
@@ -101,8 +100,7 @@
     }
   }
 
-  private static <T> void handleOrmException(
-      final AsyncCallback<T> callback, Exception e) {
+  private static <T> void handleOrmException(final AsyncCallback<T> callback, Exception e) {
     if (e.getCause() instanceof Failure) {
       callback.onFailure(e.getCause().getCause());
     } else if (e.getCause() instanceof NoSuchEntityException) {
@@ -129,13 +127,13 @@
      * @param db an open database handle to be used by this connection.
      * @return he value to pass to {@link AsyncCallback#onSuccess(Object)}.
      * @throws OrmException any schema based action failed.
-     * @throws Failure cause is given to
-     *         {@link AsyncCallback#onFailure(Throwable)}.
+     * @throws Failure cause is given to {@link AsyncCallback#onFailure(Throwable)}.
      * @throws NoSuchProjectException
      * @throws NoSuchGroupException
      * @throws InvalidQueryException
      */
-    T run(ReviewDb db) throws OrmException, Failure, NoSuchProjectException,
-        NoSuchGroupException, InvalidQueryException, IOException;
+    T run(ReviewDb db)
+        throws OrmException, Failure, NoSuchProjectException, NoSuchGroupException,
+            InvalidQueryException, IOException;
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
index 319907b..cce87a8 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServlet.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.httpd.rpc;
 
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.audit.RpcAuditEvent;
 import com.google.gerrit.common.TimeUtil;
@@ -33,43 +33,35 @@
 import com.google.gwtjsonrpc.server.MethodHandle;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-/**
- * Base JSON servlet to ensure the current user is not forged.
- */
+/** Base JSON servlet to ensure the current user is not forged. */
 @SuppressWarnings("serial")
 final class GerritJsonServlet extends JsonServlet<GerritJsonServlet.GerritCall> {
   private static final Logger log = LoggerFactory.getLogger(GerritJsonServlet.class);
-  private static final ThreadLocal<GerritCall> currentCall =
-      new ThreadLocal<>();
-  private static final ThreadLocal<MethodHandle> currentMethod =
-      new ThreadLocal<>();
+  private static final ThreadLocal<GerritCall> currentCall = new ThreadLocal<>();
+  private static final ThreadLocal<MethodHandle> currentMethod = new ThreadLocal<>();
   private final DynamicItem<WebSession> session;
   private final RemoteJsonService service;
   private final AuditService audit;
 
-
   @Inject
-  GerritJsonServlet(final DynamicItem<WebSession> w, final RemoteJsonService s,
-      final AuditService a) {
+  GerritJsonServlet(
+      final DynamicItem<WebSession> w, final RemoteJsonService s, final AuditService a) {
     session = w;
     service = s;
     audit = a;
   }
 
   @Override
-  protected GerritCall createActiveCall(final HttpServletRequest req,
-      final HttpServletResponse rsp) {
+  protected GerritCall createActiveCall(
+      final HttpServletRequest req, final HttpServletResponse rsp) {
     final GerritCall call = new GerritCall(session.get(), req, new AuditedHttpServletResponse(rsp));
     currentCall.set(call);
     return call;
@@ -83,8 +75,8 @@
   private static GsonBuilder gerritDefaultGsonBuilder() {
     final GsonBuilder g = defaultGsonBuilder();
 
-    g.registerTypeAdapter(org.eclipse.jgit.diff.Edit.class,
-        new org.eclipse.jgit.diff.EditDeserializer());
+    g.registerTypeAdapter(
+        org.eclipse.jgit.diff.Edit.class, new org.eclipse.jgit.diff.EditDeserializer());
 
     return g;
   }
@@ -114,8 +106,8 @@
   }
 
   @Override
-  protected void service(final HttpServletRequest req,
-      final HttpServletResponse resp) throws IOException {
+  protected void service(final HttpServletRequest req, final HttpServletResponse resp)
+      throws IOException {
     try {
       super.service(req, resp);
     } finally {
@@ -133,25 +125,31 @@
       }
       Audit note = method.getAnnotation(Audit.class);
       if (note != null) {
-        final String sid = call.getWebSession().getSessionId();
-        final CurrentUser username = call.getWebSession().getUser();
-        final Multimap<String, ?> args =
-            extractParams(note, call);
-        final String what = extractWhat(note, call);
-        final Object result = call.getResult();
+        String sid = call.getWebSession().getSessionId();
+        CurrentUser username = call.getWebSession().getUser();
+        ListMultimap<String, ?> args = extractParams(note, call);
+        String what = extractWhat(note, call);
+        Object result = call.getResult();
 
-        audit.dispatch(new RpcAuditEvent(sid, username, what, call.getWhen(),
-            args, call.getHttpServletRequest().getMethod(), call.getHttpServletRequest().getMethod(),
-            ((AuditedHttpServletResponse) (call.getHttpServletResponse()))
-                .getStatus(), result));
+        audit.dispatch(
+            new RpcAuditEvent(
+                sid,
+                username,
+                what,
+                call.getWhen(),
+                args,
+                call.getHttpServletRequest().getMethod(),
+                call.getHttpServletRequest().getMethod(),
+                ((AuditedHttpServletResponse) (call.getHttpServletResponse())).getStatus(),
+                result));
       }
     } catch (Throwable all) {
       log.error("Unable to log the call", all);
     }
   }
 
-  private Multimap<String, ?> extractParams(final Audit note, final GerritCall call) {
-    Multimap<String, Object> args = ArrayListMultimap.create();
+  private ListMultimap<String, ?> extractParams(Audit note, GerritCall call) {
+    ListMultimap<String, Object> args = MultimapBuilder.hashKeys().arrayListValues().build();
 
     Object[] params = call.getParams();
     for (int i = 0; i < params.length; i++) {
@@ -167,11 +165,8 @@
 
   private String extractWhat(final Audit note, final GerritCall call) {
     Class<?> methodClass = call.getMethodClass();
-    String methodClassName = methodClass != null
-        ? methodClass.getName()
-        : "<UNKNOWN_CLASS>";
-    methodClassName =
-        methodClassName.substring(methodClassName.lastIndexOf(".") + 1);
+    String methodClassName = methodClass != null ? methodClass.getName() : "<UNKNOWN_CLASS>";
+    methodClassName = methodClassName.substring(methodClassName.lastIndexOf(".") + 1);
     String what = note.action();
     if (what.length() == 0) {
       what = call.getMethod().getName();
@@ -238,8 +233,7 @@
       return null;
     }
 
-    GerritCall(final WebSession session, final HttpServletRequest i,
-        final HttpServletResponse o) {
+    GerritCall(final WebSession session, final HttpServletRequest i, final HttpServletResponse o) {
       super(i, o);
       this.session = session;
       this.when = TimeUtil.nowMs();
@@ -255,11 +249,9 @@
 
     @Override
     public void onFailure(final Throwable error) {
-      if (error instanceof IllegalArgumentException
-          || error instanceof IllegalStateException) {
+      if (error instanceof IllegalArgumentException || error instanceof IllegalStateException) {
         super.onFailure(error);
-      } else if (error instanceof OrmException
-          || error instanceof RuntimeException) {
+      } else if (error instanceof OrmException || error instanceof RuntimeException) {
         onInternalFailure(error);
       } else {
         super.onFailure(error);
@@ -296,5 +288,4 @@
       return TimeUtil.nowMs() - when;
     }
   }
-
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
index 9361130..9fd9269 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
@@ -22,8 +22,7 @@
 
 /** Creates {@link GerritJsonServlet} with a {@link RemoteJsonService}. */
 class GerritJsonServletProvider implements Provider<GerritJsonServlet> {
-  @Inject
-  private Injector injector;
+  @Inject private Injector injector;
 
   private final Class<? extends RemoteJsonService> serviceClass;
 
@@ -35,11 +34,14 @@
   @Override
   public GerritJsonServlet get() {
     final RemoteJsonService srv = injector.getInstance(serviceClass);
-    return injector.createChildInjector(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(RemoteJsonService.class).toInstance(srv);
-      }
-    }).getInstance(GerritJsonServlet.class);
+    return injector
+        .createChildInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                bind(RemoteJsonService.class).toInstance(srv);
+              }
+            })
+        .getInstance(GerritJsonServlet.class);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java
index 9364764..a9d654c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/Handler.java
@@ -21,14 +21,13 @@
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.VoidResult;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.concurrent.Callable;
 
 /**
  * Base class for RPC service implementations.
- * <p>
- * Typically an RPC service implementation will extend this class and use Guice
- * injection to manage its state. For example:
+ *
+ * <p>Typically an RPC service implementation will extend this class and use Guice injection to
+ * manage its state. For example:
  *
  * <pre>
  *   class Foo extends Handler&lt;Result&gt; {
@@ -41,8 +40,8 @@
  *   }
  * </pre>
  *
- * @param <T> type of result for {@link AsyncCallback#onSuccess(Object)} if the
- *        operation completed successfully.
+ * @param <T> type of result for {@link AsyncCallback#onSuccess(Object)} if the operation completed
+ *     successfully.
  */
 public abstract class Handler<T> implements Callable<T> {
   public static <T> Handler<T> wrap(final Callable<T> r) {
@@ -65,8 +64,7 @@
       if (r != null) {
         callback.onSuccess(r);
       }
-    } catch (NoSuchProjectException | NoSuchChangeException
-        | NoSuchRefException e) {
+    } catch (NoSuchProjectException | NoSuchChangeException | NoSuchRefException e) {
       callback.onFailure(new NoSuchEntityException());
 
     } catch (OrmException e) {
@@ -89,10 +87,10 @@
   /**
    * Compute the operation result.
    *
-   * @return the result of the operation. Return {@link VoidResult#INSTANCE} if
-   *         there is no meaningful return value for the operation.
-   * @throws Exception the operation failed. The caller will log the exception
-   *         and the stack trace, if it is worth logging on the server side.
+   * @return the result of the operation. Return {@link VoidResult#INSTANCE} if there is no
+   *     meaningful return value for the operation.
+   * @throws Exception the operation failed. The caller will log the exception and the stack trace,
+   *     if it is worth logging on the server side.
    */
   @Override
   public abstract T call() throws Exception;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
index 953bc71..5315182 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
@@ -39,10 +39,8 @@
   }
 
   protected void rpc(final String name, Class<? extends RemoteJsonService> clazz) {
-    final Key<GerritJsonServlet> srv =
-        Key.get(GerritJsonServlet.class, UniqueAnnotations.create());
-    final GerritJsonServletProvider provider =
-        new GerritJsonServletProvider(clazz);
+    final Key<GerritJsonServlet> srv = Key.get(GerritJsonServlet.class, UniqueAnnotations.create());
+    final GerritJsonServletProvider provider = new GerritJsonServletProvider(clazz);
     bind(clazz);
     serve(prefix + name).with(srv);
     bind(srv).toProvider(provider).in(Scopes.SINGLETON);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
index c0fb86b..ec67661 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
@@ -14,59 +14,33 @@
 
 package com.google.gerrit.httpd.rpc;
 
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.SshHostKey;
 import com.google.gerrit.common.data.SystemInfoService;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.VoidResult;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import com.jcraft.jsch.HostKey;
 import com.jcraft.jsch.JSch;
-
+import java.util.ArrayList;
+import java.util.List;
+import javax.servlet.http.HttpServletRequest;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
-import javax.servlet.http.HttpServletRequest;
-
 class SystemInfoServiceImpl implements SystemInfoService {
-  private static final Logger log =
-      LoggerFactory.getLogger(SystemInfoServiceImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(SystemInfoServiceImpl.class);
 
   private static final JSch JSCH = new JSch();
 
   private final List<HostKey> hostKeys;
   private final Provider<HttpServletRequest> httpRequest;
-  private final ProjectCache projectCache;
 
   @Inject
-  SystemInfoServiceImpl(SshInfo daemon,
-      Provider<HttpServletRequest> hsr,
-      ProjectCache pc) {
+  SystemInfoServiceImpl(SshInfo daemon, Provider<HttpServletRequest> hsr) {
     hostKeys = daemon.getHostKeys();
     httpRequest = hsr;
-    projectCache = pc;
-  }
-
-  @Override
-  public void contributorAgreements(
-      final AsyncCallback<List<ContributorAgreement>> callback) {
-    Collection<ContributorAgreement> agreements =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
-    List<ContributorAgreement> cas =
-        Lists.newArrayListWithCapacity(agreements.size());
-    for (ContributorAgreement ca : agreements) {
-      cas.add(ca.forUi());
-    }
-    callback.onSuccess(cas);
   }
 
   @Override
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
index 4398c78..9aab920 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.rpc;
 
-import com.google.gerrit.httpd.rpc.account.AccountModule;
 import com.google.gerrit.httpd.rpc.project.ProjectModule;
 
 /** Registers servlets to answer RPCs from client UI. */
@@ -27,7 +26,6 @@
   protected void configureServlets() {
     rpc(SystemInfoServiceImpl.class);
 
-    install(new AccountModule());
     install(new ProjectModule());
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountModule.java
deleted file mode 100644
index 62778eb..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountModule.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2009 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.httpd.rpc.account;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.httpd.rpc.RpcServletModule;
-import com.google.gerrit.httpd.rpc.UiRpcModule;
-
-public class AccountModule extends RpcServletModule {
-  public AccountModule() {
-    super(UiRpcModule.PREFIX);
-  }
-
-  @Override
-  protected void configureServlets() {
-    install(new FactoryModule() {
-      @Override
-      protected void configure() {
-        factory(AgreementInfoFactory.Factory.class);
-        factory(DeleteExternalIds.Factory.class);
-        factory(ExternalIdDetailFactory.Factory.class);
-      }
-    });
-    rpc(AccountSecurityImpl.class);
-    rpc(AccountServiceImpl.class);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
deleted file mode 100644
index 8fcf9ea..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountSecurityImpl.java
+++ /dev/null
@@ -1,177 +0,0 @@
-// Copyright (C) 2008 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.httpd.rpc.account;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.data.AccountSecurity;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.errors.NoSuchEntityException;
-import com.google.gerrit.common.errors.PermissionDeniedException;
-import com.google.gerrit.httpd.rpc.BaseServiceImplementation;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountByEmailCache;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.Realm;
-import com.google.gerrit.server.extensions.events.AgreementSignup;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-
-class AccountSecurityImpl extends BaseServiceImplementation implements
-    AccountSecurity {
-  private final Realm realm;
-  private final ProjectCache projectCache;
-  private final Provider<IdentifiedUser> user;
-  private final AccountByEmailCache byEmailCache;
-  private final AccountCache accountCache;
-
-  private final DeleteExternalIds.Factory deleteExternalIdsFactory;
-  private final ExternalIdDetailFactory.Factory externalIdDetailFactory;
-
-  private final GroupCache groupCache;
-  private final AuditService auditService;
-  private final AgreementSignup agreementSignup;
-
-  @Inject
-  AccountSecurityImpl(final Provider<ReviewDb> schema,
-      final Provider<CurrentUser> currentUser,
-      final Realm r, final Provider<IdentifiedUser> u,
-      final ProjectCache pc,
-      final AccountByEmailCache abec, final AccountCache uac,
-      final DeleteExternalIds.Factory deleteExternalIdsFactory,
-      final ExternalIdDetailFactory.Factory externalIdDetailFactory,
-      final GroupCache groupCache,
-      final AuditService auditService,
-      AgreementSignup agreementSignup) {
-    super(schema, currentUser);
-    realm = r;
-    user = u;
-    projectCache = pc;
-    byEmailCache = abec;
-    accountCache = uac;
-    this.auditService = auditService;
-    this.deleteExternalIdsFactory = deleteExternalIdsFactory;
-    this.externalIdDetailFactory = externalIdDetailFactory;
-    this.groupCache = groupCache;
-    this.agreementSignup = agreementSignup;
-  }
-
-  @Override
-  public void myExternalIds(AsyncCallback<List<AccountExternalId>> callback) {
-    externalIdDetailFactory.create().to(callback);
-  }
-
-  @Override
-  public void deleteExternalIds(final Set<AccountExternalId.Key> keys,
-      final AsyncCallback<Set<AccountExternalId.Key>> callback) {
-    deleteExternalIdsFactory.create(keys).to(callback);
-  }
-
-  @Override
-  public void updateContact(final String name, final String emailAddr,
-      final AsyncCallback<Account> callback) {
-    run(callback, new Action<Account>() {
-      @Override
-      public Account run(ReviewDb db)
-          throws OrmException, Failure, IOException {
-        IdentifiedUser self = user.get();
-        final Account me = db.accounts().get(self.getAccountId());
-        final String oldEmail = me.getPreferredEmail();
-        if (realm.allowsEdit(Account.FieldName.FULL_NAME)) {
-          me.setFullName(Strings.emptyToNull(name));
-        }
-        if (!Strings.isNullOrEmpty(emailAddr)
-            && !self.hasEmailAddress(emailAddr)) {
-          throw new Failure(new PermissionDeniedException("Email address must be verified"));
-        }
-        me.setPreferredEmail(Strings.emptyToNull(emailAddr));
-        db.accounts().update(Collections.singleton(me));
-        if (!eq(oldEmail, me.getPreferredEmail())) {
-          byEmailCache.evict(oldEmail);
-          byEmailCache.evict(me.getPreferredEmail());
-        }
-        accountCache.evict(me.getId());
-        return me;
-      }
-    });
-  }
-
-  private static boolean eq(final String a, final String b) {
-    if (a == null && b == null) {
-      return true;
-    }
-    return a != null && a.equals(b);
-  }
-
-  @Override
-  public void enterAgreement(final String agreementName,
-      final AsyncCallback<VoidResult> callback) {
-    run(callback, new Action<VoidResult>() {
-      @Override
-      public VoidResult run(final ReviewDb db)
-          throws OrmException, Failure, IOException {
-        ContributorAgreement ca = projectCache.getAllProjects().getConfig()
-            .getContributorAgreement(agreementName);
-        if (ca == null) {
-          throw new Failure(new NoSuchEntityException());
-        }
-
-        if (ca.getAutoVerify() == null) {
-          throw new Failure(new IllegalStateException(
-              "cannot enter a non-autoVerify agreement"));
-        } else if (ca.getAutoVerify().getUUID() == null) {
-          throw new Failure(new NoSuchEntityException());
-        }
-
-        AccountGroup group = groupCache.get(ca.getAutoVerify().getUUID());
-        if (group == null) {
-          throw new Failure(new NoSuchEntityException());
-        }
-
-        Account account = user.get().getAccount();
-        agreementSignup.fire(account, ca.getName());
-
-        final AccountGroupMember.Key key =
-            new AccountGroupMember.Key(account.getId(), group.getId());
-        AccountGroupMember m = db.accountGroupMembers().get(key);
-        if (m == null) {
-          m = new AccountGroupMember(key);
-          auditService.dispatchAddAccountsToGroup(account.getId(), Collections
-              .singleton(m));
-          db.accountGroupMembers().insert(Collections.singleton(m));
-          accountCache.evict(m.getAccountId());
-        }
-
-        return VoidResult.INSTANCE;
-      }
-    });
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java
deleted file mode 100644
index 8fba47d..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AccountServiceImpl.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2008 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.httpd.rpc.account;
-
-import com.google.gerrit.common.data.AccountService;
-import com.google.gerrit.common.data.AgreementInfo;
-import com.google.gerrit.httpd.rpc.BaseServiceImplementation;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-class AccountServiceImpl extends BaseServiceImplementation implements
-    AccountService {
-  private final AgreementInfoFactory.Factory agreementInfoFactory;
-
-  @Inject
-  AccountServiceImpl(final Provider<ReviewDb> schema,
-      final Provider<IdentifiedUser> identifiedUser,
-      final AgreementInfoFactory.Factory agreementInfoFactory) {
-    super(schema, identifiedUser);
-    this.agreementInfoFactory = agreementInfoFactory;
-  }
-
-  @Override
-  public void myAgreements(final AsyncCallback<AgreementInfo> callback) {
-    agreementInfoFactory.create().to(callback);
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java
deleted file mode 100644
index 91afd97..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/AgreementInfoFactory.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2009 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.httpd.rpc.account;
-
-import com.google.gerrit.common.data.AgreementInfo;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-class AgreementInfoFactory extends Handler<AgreementInfo> {
-  private static final Logger log = LoggerFactory.getLogger(AgreementInfoFactory.class);
-
-  interface Factory {
-    AgreementInfoFactory create();
-  }
-
-  private final IdentifiedUser user;
-  private final ProjectCache projectCache;
-
-  private AgreementInfo info;
-
-  @Inject
-  AgreementInfoFactory(final IdentifiedUser user,
-      final ProjectCache projectCache) {
-    this.user = user;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  public AgreementInfo call() throws Exception {
-    List<String> accepted = new ArrayList<>();
-    Map<String, ContributorAgreement> agreements = new HashMap<>();
-    Collection<ContributorAgreement> cas =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
-    for (ContributorAgreement ca : cas) {
-      agreements.put(ca.getName(), ca.forUi());
-
-      List<AccountGroup.UUID> groupIds = new ArrayList<>();
-      for (PermissionRule rule : ca.getAccepted()) {
-        if ((rule.getAction() == Action.ALLOW) && (rule.getGroup() != null)) {
-          if (rule.getGroup().getUUID() == null) {
-            log.warn("group \"" + rule.getGroup().getName() + "\" does not " +
-                " exist, referenced in CLA \"" + ca.getName() + "\"");
-          } else {
-            groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
-          }
-        }
-      }
-      if (user.getEffectiveGroups().containsAnyOf(groupIds)) {
-        accepted.add(ca.getName());
-      }
-    }
-
-    info = new AgreementInfo();
-    info.setAccepted(accepted);
-    info.setAgreements(agreements);
-    return info;
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java
deleted file mode 100644
index 34b7a4b..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/DeleteExternalIds.java
+++ /dev/null
@@ -1,104 +0,0 @@
-// Copyright (C) 2009 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.httpd.rpc.account;
-
-import com.google.gerrit.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountByEmailCache;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-class DeleteExternalIds extends Handler<Set<AccountExternalId.Key>> {
-  interface Factory {
-    DeleteExternalIds create(Set<AccountExternalId.Key> keys);
-  }
-
-  private final ReviewDb db;
-  private final IdentifiedUser user;
-  private final ExternalIdDetailFactory detailFactory;
-  private final AccountByEmailCache byEmailCache;
-  private final AccountCache accountCache;
-
-  private final Set<AccountExternalId.Key> keys;
-
-  @Inject
-  DeleteExternalIds(final ReviewDb db, final IdentifiedUser user,
-      final ExternalIdDetailFactory detailFactory,
-      final AccountByEmailCache byEmailCache, final AccountCache accountCache,
-
-      @Assisted final Set<AccountExternalId.Key> keys) {
-    this.db = db;
-    this.user = user;
-    this.detailFactory = detailFactory;
-    this.byEmailCache = byEmailCache;
-    this.accountCache = accountCache;
-
-    this.keys = keys;
-  }
-
-  @Override
-  public Set<AccountExternalId.Key> call() throws OrmException, IOException {
-    final Map<AccountExternalId.Key, AccountExternalId> have = have();
-
-    List<AccountExternalId> toDelete = new ArrayList<>();
-    for (AccountExternalId.Key k : keys) {
-      final AccountExternalId id = have.get(k);
-      if (id != null && id.canDelete()) {
-        toDelete.add(id);
-      }
-    }
-
-    if (!toDelete.isEmpty()) {
-      db.accountExternalIds().delete(toDelete);
-      accountCache.evict(user.getAccountId());
-      for (AccountExternalId e : toDelete) {
-        byEmailCache.evict(e.getEmailAddress());
-      }
-    }
-
-    return toKeySet(toDelete);
-  }
-
-  private Map<AccountExternalId.Key, AccountExternalId> have()
-      throws OrmException {
-    Map<AccountExternalId.Key, AccountExternalId> r;
-
-    r = new HashMap<>();
-    for (AccountExternalId i : detailFactory.call()) {
-      r.put(i.getKey(), i);
-    }
-    return r;
-  }
-
-  private Set<AccountExternalId.Key> toKeySet(List<AccountExternalId> toDelete) {
-    Set<AccountExternalId.Key> r = new HashSet<>();
-    for (AccountExternalId i : toDelete) {
-      r.add(i.getKey());
-    }
-    return r;
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/ExternalIdDetailFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/ExternalIdDetailFactory.java
deleted file mode 100644
index b97d46a..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/account/ExternalIdDetailFactory.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2009 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.httpd.rpc.account;
-
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
-
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.httpd.WebSession;
-import com.google.gerrit.httpd.rpc.Handler;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AuthConfig;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-
-import java.util.Collections;
-import java.util.List;
-
-class ExternalIdDetailFactory extends Handler<List<AccountExternalId>> {
-  interface Factory {
-    ExternalIdDetailFactory create();
-  }
-
-  private final ReviewDb db;
-  private final IdentifiedUser user;
-  private final AuthConfig authConfig;
-  private final DynamicItem<WebSession> session;
-
-  @Inject
-  ExternalIdDetailFactory(final ReviewDb db, final IdentifiedUser user,
-      final AuthConfig authConfig, final DynamicItem<WebSession> session) {
-    this.db = db;
-    this.user = user;
-    this.authConfig = authConfig;
-    this.session = session;
-  }
-
-  @Override
-  public List<AccountExternalId> call() throws OrmException {
-    final AccountExternalId.Key last = session.get().getLastLoginExternalId();
-    final List<AccountExternalId> ids =
-        db.accountExternalIds().byAccount(user.getAccountId()).toList();
-
-    for (final AccountExternalId e : ids) {
-      e.setTrusted(authConfig.isIdentityTrustable(Collections.singleton(e)));
-
-      // The identity can be deleted only if its not the one used to
-      // establish this web session, and if only if an identity was
-      // actually used to establish this web session.
-      //
-      if (e.isScheme(SCHEME_USERNAME)) {
-        e.setCanDelete(false);
-      } else {
-        e.setCanDelete(last != null && !last.equals(e.getKey()));
-      }
-    }
-    return ids;
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java
index 973adbe..1604997 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java
@@ -15,21 +15,15 @@
 package com.google.gerrit.httpd.rpc.doc;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.LinkedHashMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor.DocQueryException;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor.DocResult;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.List;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -38,11 +32,12 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class QueryDocumentationFilter implements Filter {
-  private final Logger log =
-      LoggerFactory.getLogger(QueryDocumentationFilter.class);
+  private final Logger log = LoggerFactory.getLogger(QueryDocumentationFilter.class);
 
   private final QueryDocumentationExecutor searcher;
 
@@ -52,24 +47,20 @@
   }
 
   @Override
-  public void init(FilterConfig filterConfig) {
-  }
+  public void init(FilterConfig filterConfig) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
   public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
       throws IOException, ServletException {
     HttpServletRequest req = (HttpServletRequest) request;
-    if ("GET".equals(req.getMethod())
-        && !Strings.isNullOrEmpty(req.getParameter("q"))) {
+    if ("GET".equals(req.getMethod()) && !Strings.isNullOrEmpty(req.getParameter("q"))) {
       HttpServletResponse rsp = (HttpServletResponse) response;
       try {
         List<DocResult> result = searcher.doQuery(request.getParameter("q"));
-        Multimap<String, String> config = LinkedHashMultimap.create();
-        RestApiServlet.replyJson(req, rsp, config, result);
+        RestApiServlet.replyJson(req, rsp, ImmutableListMultimap.of(), result);
       } catch (DocQueryException e) {
         log.error("Doc search failed:", e);
         rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
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 3f471bf..75026d3 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
@@ -19,7 +19,6 @@
 import com.google.gerrit.common.data.ProjectAccess;
 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.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -32,14 +31,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import java.util.List;
 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.List;
-
 class ChangeProjectAccess extends ProjectAccessHandler<ProjectAccess> {
   interface Factory {
     ChangeProjectAccess create(
@@ -55,9 +52,11 @@
   private final ProjectCache projectCache;
 
   @Inject
-  ChangeProjectAccess(ProjectAccessFactory.Factory projectAccessFactory,
+  ChangeProjectAccess(
+      ProjectAccessFactory.Factory projectAccessFactory,
       ProjectControl.Factory projectControlFactory,
-      ProjectCache projectCache, GroupBackend groupBackend,
+      ProjectCache projectCache,
+      GroupBackend groupBackend,
       MetaDataUpdate.User metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
@@ -67,22 +66,38 @@
       @Assisted List<AccessSection> sectionList,
       @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
       @Nullable @Assisted String message) {
-    super(projectControlFactory, groupBackend, metaDataUpdateFactory,
-        allProjects, setParent, projectName, base, sectionList,
-        parentProjectName, message, true);
+    super(
+        projectControlFactory,
+        groupBackend,
+        metaDataUpdateFactory,
+        allProjects,
+        setParent,
+        projectName,
+        base,
+        sectionList,
+        parentProjectName,
+        message,
+        true);
     this.projectAccessFactory = projectAccessFactory;
     this.projectCache = projectCache;
     this.gitRefUpdated = gitRefUpdated;
   }
 
   @Override
-  protected ProjectAccess updateProjectConfig(CurrentUser user,
-      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
+  protected ProjectAccess updateProjectConfig(
+      ProjectControl projectControl,
+      ProjectConfig config,
+      MetaDataUpdate md,
+      boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException {
     RevCommit commit = config.commit(md);
 
-    gitRefUpdated.fire(config.getProject().getNameKey(), RefNames.REFS_CONFIG,
-        base, commit.getId(), user.asIdentifiedUser().getAccount());
+    gitRefUpdated.fire(
+        config.getProject().getNameKey(),
+        RefNames.REFS_CONFIG,
+        base,
+        commit.getId(),
+        projectControl.getUser().asIdentifiedUser().getAccount());
 
     projectCache.evict(config.getProject());
     return projectAccessFactory.create(projectName).call();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index ed2a4f9..ca23ec2 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupDescription;
@@ -42,9 +40,6 @@
 import com.google.gerrit.server.project.RefControl;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -52,6 +47,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 class ProjectAccessFactory extends Handler<ProjectAccess> {
   interface Factory {
@@ -70,14 +66,14 @@
   private WebLinks webLinks;
 
   @Inject
-  ProjectAccessFactory(final GroupBackend groupBackend,
+  ProjectAccessFactory(
+      final GroupBackend groupBackend,
       final ProjectCache projectCache,
       final ProjectControl.Factory projectControlFactory,
       final GroupControl.Factory groupControlFactory,
       final MetaDataUpdate.Server metaDataUpdateFactory,
       final AllProjectsName allProjectsName,
       final WebLinks webLinks,
-
       @Assisted final Project.NameKey name) {
     this.groupBackend = groupBackend;
     this.projectCache = projectCache;
@@ -91,8 +87,7 @@
   }
 
   @Override
-  public ProjectAccess call() throws NoSuchProjectException, IOException,
-      ConfigInvalidException {
+  public ProjectAccess call() throws NoSuchProjectException, IOException, ConfigInvalidException {
     pc = open();
 
     // Load the current configuration from the repository, ensuring its the most
@@ -109,8 +104,7 @@
         projectCache.evict(config.getProject());
         pc = open();
       } else if (config.getRevision() != null
-          && !config.getRevision().equals(
-              pc.getProjectState().getConfig().getRevision())) {
+          && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
         projectCache.evict(config.getProject());
         pc = open();
       }
@@ -207,8 +201,8 @@
 
     detail.setLocal(local);
     detail.setOwnerOf(ownerOf);
-    detail.setCanUpload(pc.isOwner()
-        || (metaConfigControl.isVisible() && metaConfigControl.canUpload()));
+    detail.setCanUpload(
+        metaConfigControl.isVisible() && (pc.isOwner() || metaConfigControl.canUpload()));
     detail.setConfigVisible(pc.isOwner() || metaConfigControl.isVisible());
     detail.setGroupInfo(buildGroupInfo(local));
     detail.setLabelTypes(pc.getLabelTypes());
@@ -217,10 +211,10 @@
   }
 
   private List<WebLinkInfoCommon> getConfigFileLogLinks(String projectName) {
-    FluentIterable<WebLinkInfoCommon> links =
-        webLinks.getFileHistoryLinksCommon(projectName, RefNames.REFS_CONFIG,
-            ProjectConfig.PROJECT_CONFIG);
-    return links.isEmpty() ? null : links.toList();
+    List<WebLinkInfoCommon> links =
+        webLinks.getFileHistoryLinks(
+            projectName, RefNames.REFS_CONFIG, ProjectConfig.PROJECT_CONFIG);
+    return links.isEmpty() ? null : links;
   }
 
   private Map<AccountGroup.UUID, GroupInfo> buildGroupInfo(List<AccessSection> local) {
@@ -238,14 +232,7 @@
         }
       }
     }
-    return Maps.filterEntries(
-      infos,
-      new Predicate<Map.Entry<AccountGroup.UUID, GroupInfo>>() {
-        @Override
-        public boolean apply(Map.Entry<AccountGroup.UUID, GroupInfo> in) {
-          return in.getValue() != null;
-        }
-      });
+    return Maps.filterEntries(infos, in -> in.getValue() != null);
   }
 
   private ProjectControl open() throws NoSuchProjectException {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index 111dfc9..0d90190 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.rpc.Handler;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -43,15 +42,13 @@
 import com.google.gerrit.server.project.SetParent;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.IOException;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
 
 public abstract class ProjectAccessHandler<T> extends Handler<T> {
 
@@ -68,12 +65,18 @@
   protected String message;
   private boolean checkIfOwner;
 
-  protected ProjectAccessHandler(ProjectControl.Factory projectControlFactory,
-      GroupBackend groupBackend, MetaDataUpdate.User metaDataUpdateFactory,
-      AllProjectsName allProjects, Provider<SetParent> setParent,
-      Project.NameKey projectName, ObjectId base,
-      List<AccessSection> sectionList, Project.NameKey parentProjectName,
-      String message, boolean checkIfOwner) {
+  protected ProjectAccessHandler(
+      ProjectControl.Factory projectControlFactory,
+      GroupBackend groupBackend,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      AllProjectsName allProjects,
+      Provider<SetParent> setParent,
+      Project.NameKey projectName,
+      ObjectId base,
+      List<AccessSection> sectionList,
+      Project.NameKey parentProjectName,
+      String message,
+      boolean checkIfOwner) {
     this.projectControlFactory = projectControlFactory;
     this.groupBackend = groupBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -89,11 +92,11 @@
   }
 
   @Override
-  public final T call() throws NoSuchProjectException, IOException,
-      ConfigInvalidException, InvalidNameException, NoSuchGroupException,
-      OrmException, UpdateParentFailedException, PermissionDeniedException {
-    final ProjectControl projectControl =
-        projectControlFactory.controlFor(projectName);
+  public final T call()
+      throws NoSuchProjectException, IOException, ConfigInvalidException, InvalidNameException,
+          NoSuchGroupException, OrmException, UpdateParentFailedException,
+          PermissionDeniedException {
+    final ProjectControl projectControl = projectControlFactory.controlFor(projectName);
 
     Capable r = projectControl.canPushToAtLeastOneRef();
     if (r != Capable.OK) {
@@ -130,24 +133,28 @@
             config.remove(config.getAccessSection(name));
           }
 
-        } else if (!checkIfOwner ||  projectControl.controlForRef(name).isOwner()) {
+        } else if (!checkIfOwner || projectControl.controlForRef(name).isOwner()) {
           config.remove(config.getAccessSection(name));
         }
       }
 
       boolean parentProjectUpdate = false;
-      if (!config.getProject().getNameKey().equals(allProjects) &&
-          !config.getProject().getParent(allProjects).equals(parentProjectName)) {
+      if (!config.getProject().getNameKey().equals(allProjects)
+          && !config.getProject().getParent(allProjects).equals(parentProjectName)) {
         parentProjectUpdate = true;
         try {
-          setParent.get().validateParentUpdate(projectControl,
-              MoreObjects.firstNonNull(parentProjectName, allProjects).get(),
-              checkIfOwner);
+          setParent
+              .get()
+              .validateParentUpdate(
+                  projectControl,
+                  MoreObjects.firstNonNull(parentProjectName, allProjects).get(),
+                  checkIfOwner);
         } catch (AuthException e) {
           throw new UpdateParentFailedException(
               "You are not allowed to change the parent project since you are "
-              + "not an administrator. You may save the modifications for review "
-              + "so that an administrator can approve them.", e);
+                  + "not an administrator. You may save the modifications for review "
+                  + "so that an administrator can approve them.",
+              e);
         } catch (ResourceConflictException | UnprocessableEntityException e) {
           throw new UpdateParentFailedException(e.getMessage(), e);
         }
@@ -163,20 +170,22 @@
         md.setMessage("Modify access rules\n");
       }
 
-      return updateProjectConfig(projectControl.getUser(), config, md,
-          parentProjectUpdate);
+      return updateProjectConfig(projectControl, config, md, parentProjectUpdate);
     } catch (RepositoryNotFoundException notFound) {
       throw new NoSuchProjectException(projectName);
     }
   }
 
-  protected abstract T updateProjectConfig(CurrentUser user,
-      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
-      throws IOException, NoSuchProjectException, ConfigInvalidException,
-      OrmException;
+  protected abstract T updateProjectConfig(
+      ProjectControl projectControl,
+      ProjectConfig config,
+      MetaDataUpdate md,
+      boolean parentProjectUpdate)
+      throws IOException, NoSuchProjectException, ConfigInvalidException, OrmException,
+          PermissionDeniedException;
 
-  private void replace(ProjectConfig config, Set<String> toDelete,
-      AccessSection section) throws NoSuchGroupException {
+  private void replace(ProjectConfig config, Set<String> toDelete, AccessSection section)
+      throws NoSuchGroupException {
     for (Permission permission : section.getPermissions()) {
       for (PermissionRule rule : permission.getRules()) {
         lookupGroup(rule);
@@ -197,8 +206,7 @@
   private void lookupGroup(PermissionRule rule) throws NoSuchGroupException {
     GroupReference ref = rule.getGroup();
     if (ref.getUUID() == null) {
-      final GroupReference group =
-          GroupBackends.findBestSuggestion(groupBackend, ref.getName());
+      final GroupReference group = GroupBackends.findBestSuggestion(groupBackend, ref.getName());
       if (group == null) {
         throw new NoSuchGroupException(ref.getName());
       }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
index c378701..bdb274d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
@@ -21,10 +21,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 class ProjectAdminServiceImpl implements ProjectAdminService {
   private final ChangeProjectAccess.Factory changeProjectAccessFactory;
@@ -32,7 +30,8 @@
   private final ProjectAccessFactory.Factory projectAccessFactory;
 
   @Inject
-  ProjectAdminServiceImpl(final ChangeProjectAccess.Factory changeProjectAccessFactory,
+  ProjectAdminServiceImpl(
+      final ChangeProjectAccess.Factory changeProjectAccessFactory,
       final ReviewProjectAccess.Factory reviewProjectAccessFactory,
       final ProjectAccessFactory.Factory projectAccessFactory) {
     this.changeProjectAccessFactory = changeProjectAccessFactory;
@@ -41,8 +40,8 @@
   }
 
   @Override
-  public void projectAccess(final Project.NameKey projectName,
-      final AsyncCallback<ProjectAccess> callback) {
+  public void projectAccess(
+      final Project.NameKey projectName, final AsyncCallback<ProjectAccess> callback) {
     projectAccessFactory.create(projectName).to(callback);
   }
 
@@ -54,18 +53,28 @@
   }
 
   @Override
-  public void changeProjectAccess(Project.NameKey projectName,
-      String baseRevision, String msg, List<AccessSection> sections,
-      Project.NameKey parentProjectName, AsyncCallback<ProjectAccess> cb) {
-    changeProjectAccessFactory.create(projectName, getBase(baseRevision),
-        sections, parentProjectName, msg).to(cb);
+  public void changeProjectAccess(
+      Project.NameKey projectName,
+      String baseRevision,
+      String msg,
+      List<AccessSection> sections,
+      Project.NameKey parentProjectName,
+      AsyncCallback<ProjectAccess> cb) {
+    changeProjectAccessFactory
+        .create(projectName, getBase(baseRevision), sections, parentProjectName, msg)
+        .to(cb);
   }
 
   @Override
-  public void reviewProjectAccess(Project.NameKey projectName,
-      String baseRevision, String msg, List<AccessSection> sections,
-      Project.NameKey parentProjectName, AsyncCallback<Change.Id> cb) {
-    reviewProjectAccessFactory.create(projectName, getBase(baseRevision),
-        sections, parentProjectName, msg).to(cb);
+  public void reviewProjectAccess(
+      Project.NameKey projectName,
+      String baseRevision,
+      String msg,
+      List<AccessSection> sections,
+      Project.NameKey parentProjectName,
+      AsyncCallback<Change.Id> cb) {
+    reviewProjectAccessFactory
+        .create(projectName, getBase(baseRevision), sections, parentProjectName, msg)
+        .to(cb);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java
index a89f52e..3d7d80f 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectModule.java
@@ -25,14 +25,15 @@
 
   @Override
   protected void configureServlets() {
-    install(new FactoryModule() {
-      @Override
-      protected void configure() {
-        factory(ChangeProjectAccess.Factory.class);
-        factory(ReviewProjectAccess.Factory.class);
-        factory(ProjectAccessFactory.Factory.class);
-      }
-    });
+    install(
+        new FactoryModule() {
+          @Override
+          protected void configure() {
+            factory(ChangeProjectAccess.Factory.class);
+            factory(ReviewProjectAccess.Factory.class);
+            factory(ProjectAccessFactory.Factory.class);
+          }
+        });
     rpc(ProjectAdminServiceImpl.class);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
index 9260e01..9ad1250 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeInserter;
@@ -35,28 +35,27 @@
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.project.SetParent;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.util.List;
-
 public class ReviewProjectAccess extends ProjectAccessHandler<Change.Id> {
   interface Factory {
     ReviewProjectAccess create(
@@ -76,9 +75,11 @@
   private final BatchUpdate.Factory updateFactory;
 
   @Inject
-  ReviewProjectAccess(final ProjectControl.Factory projectControlFactory,
+  ReviewProjectAccess(
+      final ProjectControl.Factory projectControlFactory,
       GroupBackend groupBackend,
-      MetaDataUpdate.User metaDataUpdateFactory, ReviewDb db,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      ReviewDb db,
       Provider<PostReviewers> reviewersProvider,
       ProjectCache projectCache,
       AllProjectsName allProjects,
@@ -87,15 +88,23 @@
       BatchUpdate.Factory updateFactory,
       Provider<SetParent> setParent,
       Sequences seq,
-
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
       @Assisted List<AccessSection> sectionList,
       @Nullable @Assisted("parentProjectName") Project.NameKey parentProjectName,
       @Nullable @Assisted String message) {
-    super(projectControlFactory, groupBackend, metaDataUpdateFactory,
-        allProjects, setParent, projectName, base, sectionList,
-        parentProjectName, message, false);
+    super(
+        projectControlFactory,
+        groupBackend,
+        metaDataUpdateFactory,
+        allProjects,
+        setParent,
+        projectName,
+        base,
+        sectionList,
+        parentProjectName,
+        message,
+        false);
     this.db = db;
     this.seq = seq;
     this.reviewersProvider = reviewersProvider;
@@ -106,26 +115,38 @@
   }
 
   @Override
-  protected Change.Id updateProjectConfig(CurrentUser user,
-      ProjectConfig config, MetaDataUpdate md, boolean parentProjectUpdate)
-      throws IOException, OrmException {
+  protected Change.Id updateProjectConfig(
+      ProjectControl projectControl,
+      ProjectConfig config,
+      MetaDataUpdate md,
+      boolean parentProjectUpdate)
+      throws IOException, OrmException, PermissionDeniedException {
+    RefControl refsMetaConfigControl = projectControl.controlForRef(RefNames.REFS_CONFIG);
+    if (!refsMetaConfigControl.isVisible()) {
+      throw new PermissionDeniedException(RefNames.REFS_CONFIG + " not visible");
+    }
+    if (!projectControl.isOwner() && !refsMetaConfigControl.canUpload()) {
+      throw new PermissionDeniedException("cannot upload to " + RefNames.REFS_CONFIG);
+    }
+
     md.setInsertChangeId(true);
     Change.Id changeId = new Change.Id(seq.nextChangeId());
     RevCommit commit =
-        config.commitToNewRef(md, new PatchSet.Id(changeId,
-            Change.INITIAL_PATCH_SET_ID).toRefName());
+        config.commitToNewRef(
+            md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
     if (commit.getId().equals(base)) {
       return null;
     }
 
     try (RevWalk rw = new RevWalk(md.getRepository());
         ObjectInserter objInserter = md.getRepository().newObjectInserter();
-        BatchUpdate bu = updateFactory.create(
-          db, config.getProject().getNameKey(), user,
-          TimeUtil.nowTs())) {
+        BatchUpdate bu =
+            updateFactory.create(
+                db, config.getProject().getNameKey(), projectControl.getUser(), TimeUtil.nowTs())) {
       bu.setRepository(md.getRepository(), rw, objInserter);
       bu.insertChange(
-          changeInserterFactory.create(changeId, commit, RefNames.REFS_CONFIG)
+          changeInserterFactory
+              .create(changeId, commit, RefNames.REFS_CONFIG)
               .setValidatePolicy(CommitValidators.Policy.NONE)
               .setUpdateRef(false)); // Created by commitToNewRef.
       bu.execute();
@@ -147,14 +168,12 @@
   }
 
   private void addProjectOwnersAsReviewers(ChangeResource rsrc) {
-    final String projectOwners =
-        groupBackend.get(SystemGroupBackend.PROJECT_OWNERS).getName();
+    final String projectOwners = groupBackend.get(SystemGroupBackend.PROJECT_OWNERS).getName();
     try {
       AddReviewerInput input = new AddReviewerInput();
       input.reviewer = projectOwners;
       reviewersProvider.get().apply(rsrc, input);
-    } catch (IOException | OrmException |
-        RestApiException | UpdateException e) {
+    } catch (IOException | OrmException | RestApiException | UpdateException e) {
       // one of the owner groups is not visible to the user and this it why it
       // can't be added as reviewer
     }
@@ -162,16 +181,18 @@
 
   private void addAdministratorsAsReviewers(ChangeResource rsrc) {
     List<PermissionRule> adminRules =
-        projectCache.getAllProjects().getConfig()
+        projectCache
+            .getAllProjects()
+            .getConfig()
             .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
-            .getPermission(GlobalCapability.ADMINISTRATE_SERVER).getRules();
+            .getPermission(GlobalCapability.ADMINISTRATE_SERVER)
+            .getRules();
     for (PermissionRule r : adminRules) {
       try {
         AddReviewerInput input = new AddReviewerInput();
         input.reviewer = r.getGroup().getUUID().get();
         reviewersProvider.get().apply(rsrc, input);
-      } catch (IOException | OrmException |
-          RestApiException | UpdateException e) {
+      } catch (IOException | OrmException | RestApiException | UpdateException e) {
         // ignore
       }
     }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
index 9c067de..dca4d0f 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
@@ -22,16 +22,14 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.nio.file.Path;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 
-import java.io.IOException;
-import java.nio.file.Path;
-
 @Singleton
 public class SiteHeaderFooter {
   private static final Logger log = LoggerFactory.getLogger(SiteHeaderFooter.class);
@@ -119,9 +117,7 @@
     }
 
     void load() throws IOException {
-      css = HtmlDomUtil.readFile(
-          cssFile.path.getParent(),
-          cssFile.path.getFileName().toString());
+      css = HtmlDomUtil.readFile(cssFile.path.getParent(), cssFile.path.getFileName().toString());
       header = readXml(headerFile);
       footer = readXml(footerFile);
     }
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
index 57bc7f4..e475fd1 100644
--- a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
+++ b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
@@ -21,7 +21,7 @@
     <div id="gerrit_body" class="gerritBody">
       <h1>Sign In to Gerrit Code Review at <span id="hostName">example.com</span></h1>
       <div id="error_message">Invalid username or password.</div>
-      <form method="POST" action="#" id="login_form">
+      <form method="POST" action="#" id="login_form" onsubmit="return shouldSubmit()">
         <table style="border: 0;">
         <tr>
           <th>Username</th>
@@ -50,7 +50,7 @@
         <tr>
           <td></td>
           <td>
-            <input type="submit" value="Sign In" tabindex="4"/>
+            <input id="b_signin" type="submit" value="Sign In" tabindex="4"/>
             <a href="../" id="cancel_link">Cancel</a>
           </td>
         </tr>
@@ -62,22 +62,37 @@
     </div>
 
     <script type="text/javascript">
+    <![CDATA[
+      var submitted = false;
+      function shouldSubmit() {
+        if(!submitted) {
+          submitted = true;
+          document.getElementById('b_signin').disabled=true;
+          return true;
+        }
+        return false;
+      }
+
       var login_form = document.getElementById('login_form');
       var f_user = document.getElementById('f_user');
       var f_pass = document.getElementById('f_pass');
-      f_user.onkeydown = function(e) {
+
+      // Keyup event must be used to avoid issue with Firefox autocomplete
+      // Issue #6083
+      f_user.onkeyup = function(e) {
         if (e.keyCode == 13) {
           f_pass.focus();
           return false;
         }
       }
-      f_pass.onkeydown = function(e) {
-        if (e.keyCode == 13) {
+      f_pass.onkeyup = function(e) {
+        if (e.keyCode == 13 && shouldSubmit()) {
           login_form.submit();
           return false;
         }
       }
       f_user.focus();
+    ]]>
     </script>
   </body>
 </html>
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
new file mode 100644
index 0000000..99c3454
--- /dev/null
+++ b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -0,0 +1,52 @@
+/**
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.httpd.raw}
+
+/**
+ * @param canonicalPath
+ * @param staticResourcePath
+ */
+{template .Index autoescape="strict" kind="html"}
+  <!DOCTYPE html>{\n}
+  <html lang="en">{\n}
+  <meta charset="utf-8">{\n}
+  <meta name="description" content="Gerrit Code Review">{\n}
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">{\n}
+
+  <noscript>
+    To use PolyGerrit, please enable JavaScript in your browser settings, and then refresh this page.
+  </noscript>
+
+  {if $canonicalPath != ''}
+    <script>window.CANONICAL_PATH = '{$canonicalPath}';</script>{\n}
+  {/if}
+
+  <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
+
+  // SourceCodePro fonts are used in styles/fonts.css
+  // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
+  <link rel="preload" href="{$staticResourcePath}/fonts/SourceCodePro-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/SourceCodePro-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
+  <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
+  <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
+  <link rel="preload" href="{$staticResourcePath}/elements/gr-app.js" as="script" crossorigin="anonymous">{\n}
+  <link rel="import" href="{$staticResourcePath}/elements/gr-app.html">{\n}
+
+  <body unresolved>{\n}
+  <gr-app id="app"></gr-app>{\n}
+{/template}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index 94f3768..86989dd 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -17,7 +17,6 @@
 import static org.easymock.EasyMock.anyObject;
 import static org.easymock.EasyMock.capture;
 import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.newCapture;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
@@ -26,25 +25,22 @@
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import com.google.inject.Key;
 import com.google.inject.util.Providers;
-
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import org.easymock.Capture;
 import org.easymock.EasyMockSupport;
 import org.easymock.IMocksControl;
 import org.junit.Before;
 import org.junit.Test;
 
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
 public class AllRequestFilterFilterProxyTest {
   /**
    * Set of filters for FilterProxy
-   * <p/>
-   * This set is used to as set of filters when fetching an
-   * {@link AllRequestFilter.FilterProxy} instance through
-   * {@link #getFilterProxy()}.
+   *
+   * <p>This set is used to as set of filters when fetching an {@link AllRequestFilter.FilterProxy}
+   * instance through {@link #getFilterProxy()}.
    */
   private DynamicSet<AllRequestFilter> filters;
 
@@ -64,9 +60,9 @@
 
   /**
    * Obtain a FilterProxy with a known DynamicSet of filters
-   * <p/>
-   * The returned {@link AllRequestFilter.FilterProxy} can have new filters
-   * added dynamically by calling {@link #addFilter(AllRequestFilter)}.
+   *
+   * <p>The returned {@link AllRequestFilter.FilterProxy} can have new filters added dynamically by
+   * calling {@link #addFilter(AllRequestFilter)}.
    */
   private AllRequestFilter.FilterProxy getFilterProxy() {
     return new AllRequestFilter.FilterProxy(filters);
@@ -74,19 +70,17 @@
 
   /**
    * Add a filter to created FilterProxy instances
-   * <p/>
-   * This method adds the given filter to all
-   * {@link AllRequestFilter.FilterProxy} instances created by
-   * {@link #getFilterProxy()}.
+   *
+   * <p>This method adds the given filter to all {@link AllRequestFilter.FilterProxy} instances
+   * created by {@link #getFilterProxy()}.
    */
-  private ReloadableRegistrationHandle<AllRequestFilter> addFilter(
-      final AllRequestFilter filter) {
+  private ReloadableRegistrationHandle<AllRequestFilter> addFilter(final AllRequestFilter filter) {
     Key<AllRequestFilter> key = Key.get(AllRequestFilter.class);
     return filters.add(key, Providers.of(filter));
   }
 
   @Test
-  public void testNoFilters() throws Exception {
+  public void noFilters() throws Exception {
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
@@ -108,7 +102,7 @@
   }
 
   @Test
-  public void testSingleFilterNoBubbling() throws Exception {
+  public void singleFilterNoBubbling() throws Exception {
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock("config", FilterConfig.class);
@@ -135,7 +129,7 @@
   }
 
   @Test
-  public void testSingleFilterBubbling() throws Exception {
+  public void singleFilterBubbling() throws Exception {
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
@@ -145,7 +139,7 @@
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock(FilterChain.class);
 
-    Capture<FilterChain> capturedChain = newCapture();
+    Capture<FilterChain> capturedChain = new Capture<>();
 
     AllRequestFilter filter = mockControl.createMock(AllRequestFilter.class);
     filter.init(config);
@@ -167,7 +161,7 @@
   }
 
   @Test
-  public void testTwoFiltersNoBubbling() throws Exception {
+  public void twoFiltersNoBubbling() throws Exception {
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
@@ -200,7 +194,7 @@
   }
 
   @Test
-  public void testTwoFiltersBubbling() throws Exception {
+  public void twoFiltersBubbling() throws Exception {
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
@@ -210,8 +204,8 @@
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock(FilterChain.class);
 
-    Capture<FilterChain> capturedChainA = newCapture();
-    Capture<FilterChain> capturedChainB = newCapture();
+    Capture<FilterChain> capturedChainA = new Capture<>();
+    Capture<FilterChain> capturedChainB = new Capture<>();
 
     AllRequestFilter filterA = mockControl.createMock(AllRequestFilter.class);
     AllRequestFilter filterB = mockControl.createMock(AllRequestFilter.class);
@@ -240,7 +234,7 @@
   }
 
   @Test
-  public void testPostponedLoading() throws Exception {
+  public void postponedLoading() throws Exception {
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
@@ -252,9 +246,9 @@
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock("chain", FilterChain.class);
 
-    Capture<FilterChain> capturedChainA1 = newCapture();
-    Capture<FilterChain> capturedChainA2 = newCapture();
-    Capture<FilterChain> capturedChainB = newCapture();
+    Capture<FilterChain> capturedChainA1 = new Capture<>();
+    Capture<FilterChain> capturedChainA2 = new Capture<>();
+    Capture<FilterChain> capturedChainB = new Capture<>();
 
     AllRequestFilter filterA = mockControl.createMock("filterA", AllRequestFilter.class);
     AllRequestFilter filterB = mockControl.createMock("filterB", AllRequestFilter.class);
@@ -292,7 +286,7 @@
   }
 
   @Test
-  public void testDynamicUnloading() throws Exception {
+  public void dynamicUnloading() throws Exception {
     EasyMockSupport ems = new EasyMockSupport();
 
     FilterConfig config = ems.createMock(FilterConfig.class);
@@ -308,9 +302,9 @@
     IMocksControl mockControl = ems.createStrictControl();
     FilterChain chain = mockControl.createMock("chain", FilterChain.class);
 
-    Capture<FilterChain> capturedChainA1 = newCapture();
-    Capture<FilterChain> capturedChainB1 = newCapture();
-    Capture<FilterChain> capturedChainB2 = newCapture();
+    Capture<FilterChain> capturedChainA1 = new Capture<>();
+    Capture<FilterChain> capturedChainB1 = new Capture<>();
+    Capture<FilterChain> capturedChainB2 = new Capture<>();
 
     AllRequestFilter filterA = mockControl.createMock("filterA", AllRequestFilter.class);
     AllRequestFilter filterB = mockControl.createMock("filterB", AllRequestFilter.class);
@@ -334,10 +328,8 @@
     ems.replayAll();
 
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
-    ReloadableRegistrationHandle<AllRequestFilter> handleFilterA =
-        addFilter(filterA);
-    ReloadableRegistrationHandle<AllRequestFilter> handleFilterB =
-        addFilter(filterB);
+    ReloadableRegistrationHandle<AllRequestFilter> handleFilterA = addFilter(filterA);
+    ReloadableRegistrationHandle<AllRequestFilter> handleFilterB = addFilter(filterB);
 
     filterProxy.init(config);
 
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/RemoteUserUtilTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/RemoteUserUtilTest.java
index b6d0b0b..f012ee3 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/RemoteUserUtilTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/RemoteUserUtilTest.java
@@ -24,9 +24,7 @@
   public void testExtractUsername() {
     assertThat(extractUsername(null)).isNull();
     assertThat(extractUsername("")).isNull();
-    assertThat(extractUsername("Basic dXNlcjpwYXNzd29yZA=="))
-        .isEqualTo("user");
-    assertThat(extractUsername("Digest username=\"user\", realm=\"test\""))
-        .isEqualTo("user");
+    assertThat(extractUsername("Basic dXNlcjpwYXNzd29yZA==")).isEqualTo("user");
+    assertThat(extractUsername("Digest username=\"user\", realm=\"test\"")).isEqualTo("user");
   }
 }
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java
index 9559e13..684a241 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/ContextMapperTest.java
@@ -17,10 +17,8 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
-
-import org.junit.Test;
-
 import javax.servlet.http.HttpServletRequest;
+import org.junit.Test;
 
 public class ContextMapperTest {
 
@@ -29,17 +27,15 @@
   private static final String RESOURCE = "my-resource";
 
   @Test
-  public void testUnauthorized() throws Exception {
+  public void unauthorized() throws Exception {
     ContextMapper classUnderTest = new ContextMapper(CONTEXT);
 
     HttpServletRequest originalRequest =
         createFakeRequest("/plugins/", PLUGIN_NAME + "/" + RESOURCE);
 
-    HttpServletRequest result =
-        classUnderTest.create(originalRequest, PLUGIN_NAME);
+    HttpServletRequest result = classUnderTest.create(originalRequest, PLUGIN_NAME);
 
-    assertThat(result.getContextPath())
-        .isEqualTo(CONTEXT + "/plugins/" + PLUGIN_NAME);
+    assertThat(result.getContextPath()).isEqualTo(CONTEXT + "/plugins/" + PLUGIN_NAME);
     assertThat(result.getServletPath()).isEqualTo("");
     assertThat(result.getPathInfo()).isEqualTo("/" + RESOURCE);
     assertThat(result.getRequestURI())
@@ -47,26 +43,23 @@
   }
 
   @Test
-  public void testAuthorized() throws Exception {
+  public void authorized() throws Exception {
     ContextMapper classUnderTest = new ContextMapper(CONTEXT);
 
     HttpServletRequest originalRequest =
         createFakeRequest("/a/plugins/", PLUGIN_NAME + "/" + RESOURCE);
 
-    HttpServletRequest result =
-        classUnderTest.create(originalRequest, PLUGIN_NAME);
+    HttpServletRequest result = classUnderTest.create(originalRequest, PLUGIN_NAME);
 
-    assertThat(result.getContextPath())
-        .isEqualTo(CONTEXT + "/a/plugins/" + PLUGIN_NAME);
+    assertThat(result.getContextPath()).isEqualTo(CONTEXT + "/a/plugins/" + PLUGIN_NAME);
     assertThat(result.getServletPath()).isEqualTo("");
     assertThat(result.getPathInfo()).isEqualTo("/" + RESOURCE);
     assertThat(result.getRequestURI())
         .isEqualTo(CONTEXT + "/a/plugins/" + PLUGIN_NAME + "/" + RESOURCE);
   }
 
-  private static FakeHttpServletRequest createFakeRequest(String servletPath,
-      String pathInfo) {
-    return new FakeHttpServletRequest(
-        "gerrit.example.com", 80, CONTEXT, servletPath).setPathInfo(pathInfo);
+  private static FakeHttpServletRequest createFakeRequest(String servletPath, String pathInfo) {
+    return new FakeHttpServletRequest("gerrit.example.com", 80, CONTEXT, servletPath)
+        .setPathInfo(pathInfo);
   }
 }
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/LfsPluginServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/LfsPluginServletTest.java
deleted file mode 100644
index 065fa5d..0000000
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/plugins/LfsPluginServletTest.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.plugins;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import org.junit.Test;
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class LfsPluginServletTest {
-
-  @Test
-  public void noLfsEndPoint_noMatch() {
-    Pattern p = Pattern.compile(LfsPluginServlet.URL_REGEX);
-    doesNotMatch(p, "/foo");
-    doesNotMatch(p, "/a/foo");
-    doesNotMatch(p, "/p/foo");
-    doesNotMatch(p, "/a/p/foo");
-
-    doesNotMatch(p, "/info/lfs/objects/batch");
-    doesNotMatch(p, "/info/lfs/objects/batch/foo");
-  }
-
-  @Test
-  public void matchingLfsEndpoint_projectNameCaptured() {
-    Pattern p = Pattern.compile(LfsPluginServlet.URL_REGEX);
-    matches(p, "/foo/bar/info/lfs/objects/batch", "foo/bar");
-    matches(p, "/a/foo/bar/info/lfs/objects/batch", "foo/bar");
-    matches(p, "/p/foo/bar/info/lfs/objects/batch", "foo/bar");
-    matches(p, "/a/p/foo/bar/info/lfs/objects/batch", "foo/bar");
-  }
-
-  private void doesNotMatch(Pattern p, String input) {
-    Matcher m = p.matcher(input);
-    assertThat(m.matches()).isFalse();
-  }
-
-  private void matches(Pattern p, String input, String expectedProjectName) {
-    Matcher m = p.matcher(input);
-    assertThat(m.matches()).isTrue();
-    assertThat(m.group(1)).isEqualTo(expectedProjectName);
-  }
-}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java
new file mode 100644
index 0000000..7133cf6
--- /dev/null
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.template.soy.data.SoyMapData;
+import java.net.URISyntaxException;
+import org.junit.Test;
+
+public class IndexServletTest {
+  @Test
+  public void noPathAndNoCDN() throws URISyntaxException {
+    SoyMapData data = IndexServlet.getTemplateData("http://example.com/", null);
+    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
+    assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("");
+  }
+
+  @Test
+  public void pathAndNoCDN() throws URISyntaxException {
+    SoyMapData data = IndexServlet.getTemplateData("http://example.com/gerrit/", null);
+    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
+    assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("/gerrit");
+  }
+
+  @Test
+  public void noPathAndCDN() throws URISyntaxException {
+    SoyMapData data =
+        IndexServlet.getTemplateData("http://example.com/", "http://my-cdn.com/foo/bar/");
+    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
+    assertThat(data.getSingle("staticResourcePath").stringValue())
+        .isEqualTo("http://my-cdn.com/foo/bar/");
+  }
+
+  @Test
+  public void pathAndCDN() throws URISyntaxException {
+    SoyMapData data =
+        IndexServlet.getTemplateData("http://example.com/gerrit", "http://my-cdn.com/foo/bar/");
+    assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
+    assertThat(data.getSingle("staticResourcePath").stringValue())
+        .isEqualTo("http://my-cdn.com/foo/bar/");
+  }
+}
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
index e8c835d..18256c6 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -30,11 +30,6 @@
 import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
-
-import org.joda.time.format.ISODateTimeFormat;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 import java.nio.file.FileSystem;
@@ -43,13 +38,13 @@
 import java.nio.file.attribute.FileTime;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.zip.GZIPInputStream;
+import org.joda.time.format.ISODateTimeFormat;
+import org.junit.Before;
+import org.junit.Test;
 
 public class ResourceServletTest {
   private static Cache<Path, Resource> newCache(int size) {
-    return CacheBuilder.newBuilder()
-      .maximumSize(size)
-      .recordStats()
-      .build();
+    return CacheBuilder.newBuilder().maximumSize(size).recordStats().build();
   }
 
   private static class Servlet extends ResourceServlet {
@@ -57,26 +52,29 @@
 
     private final FileSystem fs;
 
-    private Servlet(FileSystem fs, Cache<Path, Resource> cache,
-        boolean refresh) {
+    private Servlet(FileSystem fs, Cache<Path, Resource> cache, boolean refresh) {
       super(cache, refresh);
       this.fs = fs;
     }
 
-    private Servlet(FileSystem fs, Cache<Path, Resource> cache,
-        boolean refresh, boolean cacheOnClient) {
+    private Servlet(
+        FileSystem fs, Cache<Path, Resource> cache, boolean refresh, boolean cacheOnClient) {
       super(cache, refresh, cacheOnClient);
       this.fs = fs;
     }
 
-    private Servlet(FileSystem fs, Cache<Path, Resource> cache,
-        boolean refresh, int cacheFileSizeLimitBytes) {
+    private Servlet(
+        FileSystem fs, Cache<Path, Resource> cache, boolean refresh, int cacheFileSizeLimitBytes) {
       super(cache, refresh, true, cacheFileSizeLimitBytes);
       this.fs = fs;
     }
 
-    private Servlet(FileSystem fs, Cache<Path, Resource> cache,
-        boolean refresh, boolean cacheOnClient, int cacheFileSizeLimitBytes) {
+    private Servlet(
+        FileSystem fs,
+        Cache<Path, Resource> cache,
+        boolean refresh,
+        boolean cacheOnClient,
+        int cacheFileSizeLimitBytes) {
       super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
       this.fs = fs;
     }
@@ -93,8 +91,7 @@
   @Before
   public void setUp() {
     fs = Jimfs.newFileSystem(Configuration.unix());
-    ts = new AtomicLong(ISODateTimeFormat.dateTime().parseMillis(
-        "2010-01-30T12:00:00.000-08:00"));
+    ts = new AtomicLong(ISODateTimeFormat.dateTime().parseMillis("2010-01-30T12:00:00.000-08:00"));
   }
 
   @Test
@@ -237,8 +234,7 @@
     Servlet servlet = new Servlet(fs, cache, true);
     writeFile("/foo", "foo1");
 
-    FakeHttpServletRequest req = request("/foo")
-        .addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
     FakeHttpServletResponse res = new FakeHttpServletResponse();
     servlet.doGet(req, res);
     assertThat(res.getStatus()).isEqualTo(SC_OK);
@@ -255,8 +251,7 @@
     String content = Strings.repeat("a", 100);
     writeFile("/foo", content);
 
-    FakeHttpServletRequest req = request("/foo")
-        .addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
     FakeHttpServletResponse res = new FakeHttpServletResponse();
     servlet.doGet(req, res);
     assertThat(res.getStatus()).isEqualTo(SC_OK);
@@ -267,8 +262,7 @@
   }
 
   @Test
-  public void largeFileBypassesCacheRegardlessOfRefreshParamter()
-      throws Exception {
+  public void largeFileBypassesCacheRegardlessOfRefreshParamter() throws Exception {
     for (boolean refresh : Lists.newArrayList(true, false)) {
       Cache<Path, Resource> cache = newCache(1);
       Servlet servlet = new Servlet(fs, cache, refresh, 3);
@@ -312,8 +306,7 @@
     String content = Strings.repeat("a", 100);
     writeFile("/foo", content);
 
-    FakeHttpServletRequest req = request("/foo")
-        .addHeader("Accept-Encoding", "gzip");
+    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
     FakeHttpServletResponse res = new FakeHttpServletResponse();
     servlet.doGet(req, res);
     assertThat(res.getStatus()).isEqualTo(SC_OK);
@@ -331,8 +324,7 @@
 
   private void writeFile(String path, String content) throws Exception {
     Files.write(fs.getPath(path), content.getBytes(UTF_8));
-    Files.setLastModifiedTime(
-        fs.getPath(path), FileTime.fromMillis(ts.getAndIncrement()));
+    Files.setLastModifiedTime(fs.getPath(path), FileTime.fromMillis(ts.getAndIncrement()));
   }
 
   private static void assertCacheHits(Cache<?, ?> cache, int hits, int misses) {
@@ -340,8 +332,7 @@
     assertThat(cache.stats().missCount()).named("misses").isEqualTo(misses);
   }
 
-  private static void assertCacheable(FakeHttpServletResponse res,
-      boolean revalidate) {
+  private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
     String header = res.getHeader("Cache-Control").toLowerCase();
     assertThat(header).contains("public");
     if (revalidate) {
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java
index af90585..2b724e2 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java
@@ -22,19 +22,19 @@
 import com.google.gson.JsonArray;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
-
 import org.junit.Test;
 
 public class ParameterParserTest {
   @Test
-  public void testConvertFormToJson() throws BadRequestException {
-    JsonObject obj = ParameterParser.formToJson(
-        ImmutableMap.of(
-            "message", new String[]{"this.is.text"},
-            "labels.Verified", new String[]{"-1"},
-            "labels.Code-Review", new String[]{"2"},
-            "a_list", new String[]{"a", "b"}),
-        ImmutableSet.of("q"));
+  public void convertFormToJson() throws BadRequestException {
+    JsonObject obj =
+        ParameterParser.formToJson(
+            ImmutableMap.of(
+                "message", new String[] {"this.is.text"},
+                "labels.Verified", new String[] {"-1"},
+                "labels.Code-Review", new String[] {"2"},
+                "a_list", new String[] {"a", "b"}),
+            ImmutableSet.of("q"));
 
     JsonObject labels = new JsonObject();
     labels.addProperty("Verified", "-1");
diff --git a/gerrit-launcher/BUCK b/gerrit-launcher/BUCK
deleted file mode 100644
index 5be25fa..0000000
--- a/gerrit-launcher/BUCK
+++ /dev/null
@@ -1,13 +0,0 @@
-# NOTE: GerritLauncher must be a single, self-contained class. Do not add any
-# additional srcs or deps to this rule.
-java_library(
-  name = 'launcher',
-  srcs = ['src/main/java/com/google/gerrit/launcher/GerritLauncher.java'],
-  visibility = [
-    '//gerrit-acceptance-framework/...',
-    '//gerrit-acceptance-tests/...',
-    '//gerrit-httpd:',
-    '//gerrit-main:main_lib',
-    '//gerrit-pgm:',
-  ],
-)
diff --git a/gerrit-launcher/BUILD b/gerrit-launcher/BUILD
index ced3447..c8736a0 100644
--- a/gerrit-launcher/BUILD
+++ b/gerrit-launcher/BUILD
@@ -1,7 +1,9 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 # NOTE: GerritLauncher must be a single, self-contained class. Do not add any
 # additional srcs or deps to this rule.
 java_library(
-  name = 'launcher',
-  srcs = ['src/main/java/com/google/gerrit/launcher/GerritLauncher.java'],
-  visibility = ['//visibility:public'],
+    name = "launcher",
+    srcs = ["src/main/java/com/google/gerrit/launcher/GerritLauncher.java"],
+    visibility = ["//visibility:public"],
 )
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
index 7954146..2ebd8c1 100644
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -19,9 +19,9 @@
 
 import java.io.File;
 import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
@@ -52,9 +52,11 @@
 
 /** Main class for a JAR file to run code from "WEB-INF/lib". */
 public final class GerritLauncher {
-  private static final String pkg = "com.google.gerrit.pgm";
+  private static final String PKG = "com.google.gerrit.pgm";
   public static final String NOT_ARCHIVED = "NOT_ARCHIVED";
 
+  private static ClassLoader daemonClassLoader;
+
   public static void main(final String[] argv) throws Exception {
     System.exit(mainImpl(argv));
   }
@@ -104,6 +106,42 @@
     return invokeProgram(cl, argv);
   }
 
+  public static void daemonStart(final String[] argv) throws Exception {
+    if (daemonClassLoader != null) {
+      throw new IllegalStateException("daemonStart can be called only once per JVM instance");
+    }
+    final ClassLoader cl = libClassLoader(false);
+    Thread.currentThread().setContextClassLoader(cl);
+
+    daemonClassLoader = cl;
+
+    String[] daemonArgv = new String[argv.length + 1];
+    daemonArgv[0] = "daemon";
+    for (int i = 0; i < argv.length; i++) {
+      daemonArgv[i + 1] = argv[i];
+    }
+    int res = invokeProgram(cl, daemonArgv);
+    if (res != 0) {
+      throw new Exception("Unexpected return value: " + res);
+    }
+  }
+
+  public static void daemonStop(final String[] argv) throws Exception {
+    if (daemonClassLoader == null) {
+      throw new IllegalStateException("daemonStop can be called only after call to daemonStop");
+    }
+    String[] daemonArgv = new String[argv.length + 2];
+    daemonArgv[0] = "daemon";
+    daemonArgv[1] = "--stop-only";
+    for (int i = 0; i < argv.length; i++) {
+      daemonArgv[i + 2] = argv[i];
+    }
+    int res = invokeProgram(daemonClassLoader, daemonArgv);
+    if (res != 0) {
+      throw new Exception("Unexpected return value: " + res);
+    }
+  }
+
   private static boolean isProlog(String cn) {
     return "PrologShell".equals(cn) || "Rulec".equals(cn);
   }
@@ -123,8 +161,8 @@
     }
   }
 
-  private static int invokeProgram(final ClassLoader loader,
-      final String[] origArgv) throws Exception {
+  private static int invokeProgram(final ClassLoader loader, final String[] origArgv)
+      throws Exception {
     String name = origArgv[0];
     final String[] argv = new String[origArgv.length - 1];
     System.arraycopy(origArgv, 1, argv, 0, argv.length);
@@ -133,17 +171,17 @@
     try {
       try {
         String cn = programClassName(name);
-        clazz = Class.forName(pkg + "." + cn, true, loader);
+        clazz = Class.forName(PKG + "." + cn, true, loader);
       } catch (ClassNotFoundException cnfe) {
         if (name.equals(name.toLowerCase())) {
-          clazz = Class.forName(pkg + "." + name, true, loader);
+          clazz = Class.forName(PKG + "." + name, true, loader);
         } else {
           throw cnfe;
         }
       }
     } catch (ClassNotFoundException cnfe) {
       System.err.println("fatal: unknown command " + name);
-      System.err.println("      (no " + pkg + "." + name + ")");
+      System.err.println("      (no " + PKG + "." + name + ")");
       return 1;
     }
 
@@ -160,7 +198,8 @@
       if ((main.getModifiers() & Modifier.STATIC) == Modifier.STATIC) {
         res = main.invoke(null, new Object[] {argv});
       } else {
-        res = main.invoke(clazz.newInstance(), new Object[] {argv});
+        res =
+            main.invoke(clazz.getConstructor(new Class<?>[] {}).newInstance(), new Object[] {argv});
       }
     } catch (InvocationTargetException ite) {
       if (ite.getCause() instanceof Exception) {
@@ -194,8 +233,7 @@
     return cn;
   }
 
-  private static ClassLoader libClassLoader(boolean prologCompiler)
-      throws IOException {
+  private static ClassLoader libClassLoader(boolean prologCompiler) throws IOException {
     final File path;
     try {
       path = getDistributionArchive();
@@ -245,19 +283,15 @@
 
     ClassLoader parent = ClassLoader.getSystemClassLoader();
     if (!extapi.isEmpty()) {
-      parent = new URLClassLoader(
-          extapi.toArray(new URL[extapi.size()]),
-          parent);
+      parent = new URLClassLoader(extapi.toArray(new URL[extapi.size()]), parent);
     }
-    return new URLClassLoader(
-        jars.values().toArray(new URL[jars.size()]),
-        parent);
+    return new URLClassLoader(jars.values().toArray(new URL[jars.size()]), parent);
   }
 
-  private static void extractJar(ZipFile zf, ZipEntry ze,
-      SortedMap<String, URL> jars) throws IOException {
+  private static void extractJar(ZipFile zf, ZipEntry ze, SortedMap<String, URL> jars)
+      throws IOException {
     File tmp = createTempFile(safeName(ze), ".jar");
-    try (FileOutputStream out = new FileOutputStream(tmp);
+    try (OutputStream out = Files.newOutputStream(tmp.toPath());
         InputStream in = zf.getInputStream(ze)) {
       byte[] buf = new byte[4096];
       int n;
@@ -267,14 +301,10 @@
     }
 
     String name = ze.getName();
-    jars.put(
-        name.substring(name.lastIndexOf('/'), name.length()),
-        tmp.toURI().toURL());
+    jars.put(name.substring(name.lastIndexOf('/'), name.length()), tmp.toURI().toURL());
   }
 
-  private static void move(SortedMap<String, URL> jars,
-      String prefix,
-      List<URL> extapi) {
+  private static void move(SortedMap<String, URL> jars, String prefix, List<URL> extapi) {
     SortedMap<String, URL> matches = jars.tailMap(prefix);
     if (!matches.isEmpty()) {
       String first = matches.firstKey();
@@ -313,8 +343,7 @@
    * @return local path of the Gerrit WAR file.
    * @throws FileNotFoundException if the code cannot guess the location.
    */
-  public static File getDistributionArchive()
-      throws FileNotFoundException, IOException {
+  public static File getDistributionArchive() throws FileNotFoundException, IOException {
     File result = myArchive;
     if (result == null) {
       synchronized (GerritLauncher.class) {
@@ -329,8 +358,7 @@
     return result;
   }
 
-  public static synchronized FileSystem getZipFileSystem(Path zip)
-      throws IOException {
+  public static synchronized FileSystem getZipFileSystem(Path zip) throws IOException {
     // FileSystems canonicalizes the path, so we should too.
     zip = zip.toRealPath();
     FileSystem zipFs = zipFileSystems.get(zip);
@@ -343,14 +371,12 @@
 
   public static FileSystem newZipFileSystem(Path zip) throws IOException {
     return FileSystems.newFileSystem(
-        URI.create("jar:" + zip.toUri()),
-        Collections.<String, String> emptyMap());
+        URI.create("jar:" + zip.toUri()), Collections.<String, String>emptyMap());
   }
 
   private static File locateMyArchive() throws FileNotFoundException {
     final ClassLoader myCL = GerritLauncher.class.getClassLoader();
-    final String myName =
-        GerritLauncher.class.getName().replace('.', '/') + ".class";
+    final String myName = GerritLauncher.class.getName().replace('.', '/') + ".class";
 
     final URL myClazz = myCL.getResource(myName);
     if (myClazz == null) {
@@ -382,12 +408,11 @@
     // The CodeSource might be able to give us the source as a stream.
     // If so, copy it to a local file so we have random access to it.
     //
-    final CodeSource src =
-        GerritLauncher.class.getProtectionDomain().getCodeSource();
+    final CodeSource src = GerritLauncher.class.getProtectionDomain().getCodeSource();
     if (src != null) {
       try (InputStream in = src.getLocation().openStream()) {
         final File tmp = createTempFile("gerrit_", ".zip");
-        try (FileOutputStream out = new FileOutputStream(tmp)) {
+        try (OutputStream out = Files.newOutputStream(tmp.toPath())) {
           final byte[] buf = new byte[4096];
           int n;
           while ((n = in.read(buf, 0, buf.length)) > 0) {
@@ -409,35 +434,30 @@
 
   /**
    * Creates a temporary file within the application's unpack location.
-   * <p>
-   * The launcher unpacks the nested JAR files into a temporary directory,
-   * allowing the classes to be loaded from local disk with standard Java APIs.
-   * This method constructs a new temporary file in the same directory.
-   * <p>
-   * The method first tries to create {@code prefix + suffix} within the
-   * directory under the assumption that a given {@code prefix + suffix}
-   * combination is made at most once per JVM execution. If this fails (e.g. the
-   * named file already exists) a mangled unique name is used and returned
-   * instead, with the unique string appearing between the prefix and suffix.
-   * <p>
-   * Files created by this method will be automatically deleted by the JVM when
-   * it terminates. If the returned file is converted into a directory by the
-   * caller, the caller must arrange for the contents to be deleted before the
-   * directory is.
-   * <p>
-   * If supported by the underlying operating system, the temporary directory
-   * which contains these temporary files is accessible only by the user running
-   * the JVM.
+   *
+   * <p>The launcher unpacks the nested JAR files into a temporary directory, allowing the classes
+   * to be loaded from local disk with standard Java APIs. This method constructs a new temporary
+   * file in the same directory.
+   *
+   * <p>The method first tries to create {@code prefix + suffix} within the directory under the
+   * assumption that a given {@code prefix + suffix} combination is made at most once per JVM
+   * execution. If this fails (e.g. the named file already exists) a mangled unique name is used and
+   * returned instead, with the unique string appearing between the prefix and suffix.
+   *
+   * <p>Files created by this method will be automatically deleted by the JVM when it terminates. If
+   * the returned file is converted into a directory by the caller, the caller must arrange for the
+   * contents to be deleted before the directory is.
+   *
+   * <p>If supported by the underlying operating system, the temporary directory which contains
+   * these temporary files is accessible only by the user running the JVM.
    *
    * @param prefix prefix of the file name.
    * @param suffix suffix of the file name.
-   * @return the path of the temporary file. The returned object exists in the
-   *         filesystem as a file; caller may need to delete and recreate as a
-   *         directory if a directory was preferred.
+   * @return the path of the temporary file. The returned object exists in the filesystem as a file;
+   *     caller may need to delete and recreate as a directory if a directory was preferred.
    * @throws IOException the file could not be created.
    */
-  public static synchronized File createTempFile(String prefix, String suffix)
-      throws IOException {
+  public static synchronized File createTempFile(String prefix, String suffix) throws IOException {
     if (!temporaryDirectoryFound) {
       final File d = File.createTempFile("gerrit_", "_app", tmproot());
       if (d.delete() && d.mkdir()) {
@@ -491,7 +511,6 @@
     return myHome;
   }
 
-
   private static File tmproot() {
     File tmp;
     String gerritTemp = System.getenv("GERRIT_TMP");
@@ -577,6 +596,7 @@
   /**
    * Locate the path of the {@code eclipse-out} directory in a source tree.
    *
+   * @return local path of the {@code eclipse-out} directory in a source tree.
    * @throws FileNotFoundException if the directory cannot be found.
    */
   public static Path getDeveloperEclipseOut() throws FileNotFoundException {
@@ -584,57 +604,61 @@
   }
 
   /**
-   * Locate the path of the {@code buck-out} directory in a source tree.
+   * Locate a path in the source tree.
    *
+   * @return local path of the {@code name} directory in a source tree.
    * @throws FileNotFoundException if the directory cannot be found.
    */
-  public static Path getDeveloperBuckOut() throws FileNotFoundException {
-    return resolveInSourceRoot("buck-out");
-  }
+  public static Path resolveInSourceRoot(String name) throws FileNotFoundException {
 
-  private static Path resolveInSourceRoot(String name)
-      throws FileNotFoundException {
     // Find ourselves in the classpath, as a loose class file or jar.
     Class<GerritLauncher> self = GerritLauncher.class;
-    URL u = self.getResource(self.getSimpleName() + ".class");
-    if (u == null) {
-      throw new FileNotFoundException("Cannot find class " + self.getName());
-    } else if ("jar".equals(u.getProtocol())) {
-      String p = u.getPath();
-      try {
-        u = new URL(p.substring(0, p.indexOf('!')));
-      } catch (MalformedURLException e) {
-        FileNotFoundException fnfe =
-            new FileNotFoundException("Not a valid jar file: " + u);
-        fnfe.initCause(e);
-        throw fnfe;
-      }
-    }
-    if (!"file".equals(u.getProtocol())) {
-      throw new FileNotFoundException("Cannot extract path from " + u);
-    }
 
-    // Pop up to the top-level source folder by looking for .buckconfig.
-    Path dir = Paths.get(u.getPath());
-    while (!Files.isRegularFile(dir.resolve(".buckconfig"))) {
-      Path parent = dir.getParent();
-      if (parent == null) {
-        throw new FileNotFoundException("Cannot find source root from " + u);
+    Path dir;
+    String sourceRoot = System.getProperty("sourceRoot");
+    if (sourceRoot != null) {
+      dir = Paths.get(sourceRoot);
+      if (!Files.exists(dir)) {
+        throw new FileNotFoundException("source root not found: " + dir);
       }
-      dir = parent;
+    } else {
+      URL u = self.getResource(self.getSimpleName() + ".class");
+      if (u == null) {
+        throw new FileNotFoundException("Cannot find class " + self.getName());
+      } else if ("jar".equals(u.getProtocol())) {
+        String p = u.getPath();
+        try {
+          u = new URL(p.substring(0, p.indexOf('!')));
+        } catch (MalformedURLException e) {
+          FileNotFoundException fnfe = new FileNotFoundException("Not a valid jar file: " + u);
+          fnfe.initCause(e);
+          throw fnfe;
+        }
+      }
+      if (!"file".equals(u.getProtocol())) {
+        throw new FileNotFoundException("Cannot extract path from " + u);
+      }
+
+      // Pop up to the top-level source folder by looking for WORKSPACE.
+      dir = Paths.get(u.getPath());
+      while (!Files.isRegularFile(dir.resolve("WORKSPACE"))) {
+        Path parent = dir.getParent();
+        if (parent == null) {
+          throw new FileNotFoundException("Cannot find source root from " + u);
+        }
+        dir = parent;
+      }
     }
 
     Path ret = dir.resolve(name);
     if (!Files.exists(ret)) {
-      throw new FileNotFoundException(
-          name + " not found in source root " + dir);
+      throw new FileNotFoundException(name + " not found in source root " + dir);
     }
     return ret;
   }
 
-  private static ClassLoader useDevClasspath()
-      throws MalformedURLException, FileNotFoundException {
-    Path out = getDeveloperEclipseOut();
+  private static ClassLoader useDevClasspath() throws MalformedURLException, FileNotFoundException {
+    Path out = resolveInSourceRoot("eclipse-out");
     List<URL> dirs = new ArrayList<>();
     dirs.add(out.resolve("classes").toUri().toURL());
     ClassLoader cl = GerritLauncher.class.getClassLoader();
@@ -644,8 +668,7 @@
       }
     }
     return new URLClassLoader(
-        dirs.toArray(new URL[dirs.size()]),
-        ClassLoader.getSystemClassLoader().getParent());
+        dirs.toArray(new URL[dirs.size()]), ClassLoader.getSystemClassLoader().getParent());
   }
 
   private static boolean includeJar(URL u) {
@@ -655,6 +678,5 @@
         && !path.contains("/buck-out/gen/lib/gwt/");
   }
 
-  private GerritLauncher() {
-  }
+  private GerritLauncher() {}
 }
diff --git a/gerrit-lucene/BUCK b/gerrit-lucene/BUCK
deleted file mode 100644
index 771a021..0000000
--- a/gerrit-lucene/BUCK
+++ /dev/null
@@ -1,41 +0,0 @@
-QUERY_BUILDER = [
-  'src/main/java/com/google/gerrit/lucene/QueryBuilder.java',
-]
-
-java_library(
-  name = 'query_builder',
-  srcs = QUERY_BUILDER,
-  deps = [
-    '//gerrit-antlr:query_exception',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:gwtorm',
-    '//lib:guava',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'lucene',
-  srcs = glob(['src/main/java/**/*.java'], excludes = QUERY_BUILDER),
-  deps = [
-    ':query_builder',
-    '//gerrit-antlr:query_exception',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/log:api',
-    '//lib/lucene:lucene-analyzers-common',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-    '//lib/lucene:lucene-misc',
-  ],
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-lucene/BUILD b/gerrit-lucene/BUILD
index 2f1cba7..c2c6ba4 100644
--- a/gerrit-lucene/BUILD
+++ b/gerrit-lucene/BUILD
@@ -1,41 +1,46 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 QUERY_BUILDER = [
-  'src/main/java/com/google/gerrit/lucene/QueryBuilder.java',
+    "src/main/java/com/google/gerrit/lucene/QueryBuilder.java",
 ]
 
 java_library(
-  name = 'query_builder',
-  srcs = QUERY_BUILDER,
-  deps = [
-    '//gerrit-antlr:query_exception',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:gwtorm',
-    '//lib:guava',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-  ],
-  visibility = ['//visibility:public'],
+    name = "query_builder",
+    srcs = QUERY_BUILDER,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-antlr:query_exception",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+    ],
 )
 
 java_library(
-  name = 'lucene',
-  srcs = glob(['src/main/java/**/*.java'], exclude = QUERY_BUILDER),
-  deps = [
-    ':query_builder',
-    '//gerrit-antlr:query_exception',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/log:api',
-    '//lib/lucene:lucene-analyzers-common',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-    '//lib/lucene:lucene-misc',
-  ],
-  visibility = ['//visibility:public'],
+    name = "lucene",
+    srcs = glob(
+        ["src/main/java/**/*.java"],
+        exclude = QUERY_BUILDER,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":query_builder",
+        "//gerrit-antlr:query_exception",
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/lucene:lucene-analyzers-common",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-misc",
+    ],
 )
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index eb0dfaa..5c3183a 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.lucene;
 
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import com.google.common.base.Joiner;
@@ -30,9 +31,20 @@
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.Schema.Values;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
@@ -51,41 +63,17 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.store.AlreadyClosedException;
 import org.apache.lucene.store.Directory;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-
 /** Basic Lucene index implementation. */
 public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
-  private static final Logger log =
-      LoggerFactory.getLogger(AbstractLuceneIndex.class);
+  private static final Logger log = LoggerFactory.getLogger(AbstractLuceneIndex.class);
 
   static String sortFieldName(FieldDef<?, ?> f) {
     return f.getName() + "_SORT";
   }
 
-  public static void setReady(SitePaths sitePaths, String name, int version,
-      boolean ready) throws IOException {
-    try {
-      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
-      cfg.setReady(name, version, ready);
-      cfg.save();
-    } catch (ConfigInvalidException e) {
-      throw new IOException(e);
-    }
-  }
-
   private final Schema<V> schema;
   private final SitePaths sitePaths;
   private final Directory dir;
@@ -104,7 +92,8 @@
       String name,
       String subIndex,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory) throws IOException {
+      SearcherFactory searcherFactory)
+      throws IOException {
     this.schema = schema;
     this.sitePaths = sitePaths;
     this.dir = dir;
@@ -116,60 +105,74 @@
     if (commitPeriod < 0) {
       delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
     } else if (commitPeriod == 0) {
-      delegateWriter =
-          new AutoCommitWriter(dir, writerConfig.getLuceneConfig(), true);
+      delegateWriter = new AutoCommitWriter(dir, writerConfig.getLuceneConfig(), true);
     } else {
       final AutoCommitWriter autoCommitWriter =
           new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
       delegateWriter = autoCommitWriter;
 
-      autoCommitExecutor = new ScheduledThreadPoolExecutor(1, new ThreadFactoryBuilder()
-          .setNameFormat(index + " Commit-%d")
-          .setDaemon(true)
-          .build());
-      autoCommitExecutor.scheduleAtFixedRate(new Runnable() {
-            @Override
-            public void run() {
-              try {
-                if (autoCommitWriter.hasUncommittedChanges()) {
-                  autoCommitWriter.manualFlush();
-                  autoCommitWriter.commit();
+      autoCommitExecutor =
+          new ScheduledThreadPoolExecutor(
+              1,
+              new ThreadFactoryBuilder()
+                  .setNameFormat(index + " Commit-%d")
+                  .setDaemon(true)
+                  .build());
+      @SuppressWarnings("unused") // Error handling within Runnable.
+      Future<?> possiblyIgnoredError =
+          autoCommitExecutor.scheduleAtFixedRate(
+              new Runnable() {
+                @Override
+                public void run() {
+                  try {
+                    if (autoCommitWriter.hasUncommittedChanges()) {
+                      autoCommitWriter.manualFlush();
+                      autoCommitWriter.commit();
+                    }
+                  } catch (IOException e) {
+                    log.error("Error committing " + index + " Lucene index", e);
+                  } catch (OutOfMemoryError e) {
+                    log.error("Error committing " + index + " Lucene index", e);
+                    try {
+                      autoCommitWriter.close();
+                    } catch (IOException e2) {
+                      log.error(
+                          "SEVERE: Error closing "
+                              + index
+                              + " Lucene index after OOM;"
+                              + " index may be corrupted.",
+                          e);
+                    }
+                  }
                 }
-              } catch (IOException e) {
-                log.error("Error committing " + index + " Lucene index", e);
-              } catch (OutOfMemoryError e) {
-                log.error("Error committing " + index + " Lucene index", e);
-                try {
-                  autoCommitWriter.close();
-                } catch (IOException e2) {
-                  log.error("SEVERE: Error closing " + index
-                      + " Lucene index  after OOM; index may be corrupted.", e);
-                }
-              }
-            }
-          }, commitPeriod, commitPeriod, MILLISECONDS);
+              },
+              commitPeriod,
+              commitPeriod,
+              MILLISECONDS);
     }
     writer = new TrackingIndexWriter(delegateWriter);
-    searcherManager = new WrappableSearcherManager(
-        writer.getIndexWriter(), true, searcherFactory);
+    searcherManager = new WrappableSearcherManager(writer.getIndexWriter(), true, searcherFactory);
 
     notDoneNrtFutures = Sets.newConcurrentHashSet();
 
-    writerThread = MoreExecutors.listeningDecorator(
-        Executors.newFixedThreadPool(1,
-            new ThreadFactoryBuilder()
-              .setNameFormat(index + " Write-%d")
-              .setDaemon(true)
-              .build()));
+    writerThread =
+        MoreExecutors.listeningDecorator(
+            Executors.newFixedThreadPool(
+                1,
+                new ThreadFactoryBuilder()
+                    .setNameFormat(index + " Write-%d")
+                    .setDaemon(true)
+                    .build()));
 
-    reopenThread = new ControlledRealTimeReopenThread<>(
-        writer, searcherManager,
-        0.500 /* maximum stale age (seconds) */,
-        0.010 /* minimum stale age (seconds) */);
+    reopenThread =
+        new ControlledRealTimeReopenThread<>(
+            writer,
+            searcherManager,
+            0.500 /* maximum stale age (seconds) */,
+            0.010 /* minimum stale age (seconds) */);
     reopenThread.setName(index + " NRT");
-    reopenThread.setPriority(Math.min(
-        Thread.currentThread().getPriority() + 2,
-        Thread.MAX_PRIORITY));
+    reopenThread.setPriority(
+        Math.min(Thread.currentThread().getPriority() + 2, Thread.MAX_PRIORITY));
     reopenThread.setDaemon(true);
 
     // This must be added after the reopen thread is created. The reopen thread
@@ -180,25 +183,25 @@
     // internal listener needs to be called first.
     // TODO(dborowitz): This may have been fixed by
     // http://issues.apache.org/jira/browse/LUCENE-5461
-    searcherManager.addListener(new RefreshListener() {
-      @Override
-      public void beforeRefresh() throws IOException {
-      }
+    searcherManager.addListener(
+        new RefreshListener() {
+          @Override
+          public void beforeRefresh() throws IOException {}
 
-      @Override
-      public void afterRefresh(boolean didRefresh) throws IOException {
-        for (NrtFuture f : notDoneNrtFutures) {
-          f.removeIfDone();
-        }
-      }
-    });
+          @Override
+          public void afterRefresh(boolean didRefresh) throws IOException {
+            for (NrtFuture f : notDoneNrtFutures) {
+              f.removeIfDone();
+            }
+          }
+        });
 
     reopenThread.start();
   }
 
   @Override
   public void markReady(boolean ready) throws IOException {
-    setReady(sitePaths, name, schema.getVersion(), ready);
+    IndexUtils.setReady(sitePaths, name, schema.getVersion(), ready);
   }
 
   @Override
@@ -213,8 +216,7 @@
         log.warn("shutting down " + name + " index with pending Lucene writes");
       }
     } catch (InterruptedException e) {
-      log.warn("interrupted waiting for pending Lucene writes of " + name +
-          " index", e);
+      log.warn("interrupted waiting for pending Lucene writes of " + name + " index", e);
     }
     reopenThread.close();
 
@@ -246,44 +248,49 @@
   }
 
   ListenableFuture<?> insert(final Document doc) {
-    return submit(new Callable<Long>() {
-      @Override
-      public Long call() throws IOException, InterruptedException {
-        return writer.addDocument(doc);
-      }
-    });
+    return submit(
+        new Callable<Long>() {
+          @Override
+          public Long call() throws IOException, InterruptedException {
+            return writer.addDocument(doc);
+          }
+        });
   }
 
   ListenableFuture<?> replace(final Term term, final Document doc) {
-    return submit(new Callable<Long>() {
-      @Override
-      public Long call() throws IOException, InterruptedException {
-        return writer.updateDocument(term, doc);
-      }
-    });
+    return submit(
+        new Callable<Long>() {
+          @Override
+          public Long call() throws IOException, InterruptedException {
+            return writer.updateDocument(term, doc);
+          }
+        });
   }
 
   ListenableFuture<?> delete(final Term term) {
-    return submit(new Callable<Long>() {
-      @Override
-      public Long call() throws IOException, InterruptedException {
-        return writer.deleteDocuments(term);
-      }
-    });
+    return submit(
+        new Callable<Long>() {
+          @Override
+          public Long call() throws IOException, InterruptedException {
+            return writer.deleteDocuments(term);
+          }
+        });
   }
 
   private ListenableFuture<?> submit(Callable<Long> task) {
-    ListenableFuture<Long> future =
-        Futures.nonCancellationPropagating(writerThread.submit(task));
-    return Futures.transformAsync(future, new AsyncFunction<Long, Void>() {
-      @Override
-      public ListenableFuture<Void> apply(Long gen) throws InterruptedException {
-        // Tell the reopen thread a future is waiting on this
-        // generation so it uses the min stale time when refreshing.
-        reopenThread.waitForGeneration(gen, 0);
-        return new NrtFuture(gen);
-      }
-    });
+    ListenableFuture<Long> future = Futures.nonCancellationPropagating(writerThread.submit(task));
+    return Futures.transformAsync(
+        future,
+        new AsyncFunction<Long, Void>() {
+          @Override
+          public ListenableFuture<Void> apply(Long gen) throws InterruptedException {
+            // Tell the reopen thread a future is waiting on this
+            // generation so it uses the min stale time when refreshing.
+            reopenThread.waitForGeneration(gen, 0);
+            return new NrtFuture(gen);
+          }
+        },
+        directExecutor());
   }
 
   @Override
@@ -330,8 +337,7 @@
       for (Object value : values.getValues()) {
         doc.add(new LongField(name, ((Timestamp) value).getTime(), store));
       }
-    } else if (type == FieldType.EXACT
-        || type == FieldType.PREFIX) {
+    } else if (type == FieldType.EXACT || type == FieldType.PREFIX) {
       for (Object value : values.getValues()) {
         doc.add(new StringField(name, (String) value, store));
       }
@@ -369,8 +375,8 @@
     }
 
     @Override
-    public Void get(long timeout, TimeUnit unit) throws InterruptedException,
-        TimeoutException, ExecutionException {
+    public Void get(long timeout, TimeUnit unit)
+        throws InterruptedException, TimeoutException, ExecutionException {
       if (!isDone()) {
         if (!reopenThread.waitForGeneration(gen, (int) unit.toMillis(timeout))) {
           throw new TimeoutException();
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java
index 4a53fc6..7a418aa 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AutoCommitWriter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.lucene;
 
+import java.io.IOException;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexWriterConfig;
@@ -22,26 +23,22 @@
 import org.apache.lucene.search.Query;
 import org.apache.lucene.store.Directory;
 
-import java.io.IOException;
-
 /** Writer that optionally flushes/commits after every write. */
 public class AutoCommitWriter extends IndexWriter {
   private boolean autoCommit;
 
-  AutoCommitWriter(Directory dir, IndexWriterConfig config)
-      throws IOException {
+  AutoCommitWriter(Directory dir, IndexWriterConfig config) throws IOException {
     this(dir, config, false);
   }
 
-  AutoCommitWriter(Directory dir, IndexWriterConfig config, boolean autoCommit)
-      throws IOException {
+  AutoCommitWriter(Directory dir, IndexWriterConfig config, boolean autoCommit) throws IOException {
     super(dir, config);
     setAutoCommit(autoCommit);
   }
 
   /**
-   * This method will override Gerrit configuration index.name.commitWithin
-   * until next Gerrit restart (or reconfiguration through this method).
+   * This method will override Gerrit configuration index.name.commitWithin until next Gerrit
+   * restart (or reconfiguration through this method).
    *
    * @param enable auto commit
    */
@@ -50,23 +47,21 @@
   }
 
   @Override
-  public void addDocument(Iterable<? extends IndexableField> doc)
-      throws IOException {
+  public void addDocument(Iterable<? extends IndexableField> doc) throws IOException {
     super.addDocument(doc);
     autoFlush();
   }
 
   @Override
-  public void addDocuments(
-      Iterable<? extends Iterable<? extends IndexableField>> docs)
+  public void addDocuments(Iterable<? extends Iterable<? extends IndexableField>> docs)
       throws IOException {
     super.addDocuments(docs);
     autoFlush();
   }
 
   @Override
-  public void updateDocuments(Term delTerm,
-      Iterable<? extends Iterable<? extends IndexableField>> docs)
+  public void updateDocuments(
+      Term delTerm, Iterable<? extends Iterable<? extends IndexableField>> docs)
       throws IOException {
     super.updateDocuments(delTerm, docs);
     autoFlush();
@@ -95,8 +90,7 @@
   }
 
   @Override
-  public void updateDocument(Term term, Iterable<? extends IndexableField> doc)
-      throws IOException {
+  public void updateDocument(Term term, Iterable<? extends IndexableField> doc) throws IOException {
     super.updateDocument(term, doc);
     autoFlush();
   }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 9bec978..58117b8 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -31,17 +31,15 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
-
+import java.io.IOException;
+import java.nio.file.Path;
+import java.sql.Timestamp;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.NumericDocValuesField;
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
 
-import java.io.IOException;
-import java.nio.file.Path;
-import java.sql.Timestamp;
-
 public class ChangeSubIndex extends AbstractLuceneIndex<Change.Id, ChangeData>
     implements ChangeIndex {
   ChangeSubIndex(
@@ -49,9 +47,15 @@
       SitePaths sitePaths,
       Path path,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory) throws IOException {
-    this(schema, sitePaths, FSDirectory.open(path),
-        path.getFileName().toString(), writerConfig, searcherFactory);
+      SearcherFactory searcherFactory)
+      throws IOException {
+    this(
+        schema,
+        sitePaths,
+        FSDirectory.open(path),
+        path.getFileName().toString(),
+        writerConfig,
+        searcherFactory);
   }
 
   ChangeSubIndex(
@@ -60,28 +64,25 @@
       Directory dir,
       String subIndex,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory) throws IOException {
-    super(schema, sitePaths, dir, NAME, subIndex, writerConfig,
-        searcherFactory);
+      SearcherFactory searcherFactory)
+      throws IOException {
+    super(schema, sitePaths, dir, NAME, subIndex, writerConfig, searcherFactory);
   }
 
   @Override
   public void replace(ChangeData obj) throws IOException {
-    throw new UnsupportedOperationException(
-        "don't use ChangeSubIndex directly");
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
   }
 
   @Override
   public void delete(Change.Id key) throws IOException {
-    throw new UnsupportedOperationException(
-        "don't use ChangeSubIndex directly");
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
   }
 
   @Override
-  public DataSource<ChangeData> getSource(Predicate<ChangeData> p,
-      QueryOptions opts) throws QueryParseException {
-    throw new UnsupportedOperationException(
-        "don't use ChangeSubIndex directly");
+  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
   }
 
   @Override
@@ -97,8 +98,4 @@
     }
     super.add(doc, values);
   }
-
-  @Override
-  public void stop() {
-  }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/CustomMappingAnalyzer.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/CustomMappingAnalyzer.java
index 3d7faeb..acbedb1 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/CustomMappingAnalyzer.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/CustomMappingAnalyzer.java
@@ -14,36 +14,32 @@
 
 package com.google.gerrit.lucene;
 
+import java.io.Reader;
+import java.util.Map;
 import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.analysis.AnalyzerWrapper;
 import org.apache.lucene.analysis.charfilter.MappingCharFilter;
 import org.apache.lucene.analysis.charfilter.NormalizeCharMap;
 
-import java.io.Reader;
-import java.util.Map;
-
 /**
  * This analyzer can be used to provide custom char mappings.
  *
  * <p>Example usage:
  *
- * <pre class="prettyprint">
- * {@code
+ * <pre class="prettyprint">{@code
  * Map<String,String> customMapping = new HashMap<>();
  * customMapping.put("_", " ");
  * customMapping.put(".", " ");
  *
  * CustomMappingAnalyzer analyzer =
  *   new CustomMappingAnalyzer(new StandardAnalyzer(version), customMapping);
- * }
- * </pre>
+ * }</pre>
  */
 public class CustomMappingAnalyzer extends AnalyzerWrapper {
   private Analyzer delegate;
   private Map<String, String> customMappings;
 
-  public CustomMappingAnalyzer(Analyzer delegate,
-      Map<String, String> customMappings) {
+  public CustomMappingAnalyzer(Analyzer delegate, Map<String, String> customMappings) {
     super(delegate.getReuseStrategy());
     this.delegate = delegate;
     this.customMappings = customMappings;
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java
deleted file mode 100644
index f43e385..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-import com.google.common.primitives.Ints;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-
-import java.io.IOException;
-
-class GerritIndexStatus {
-  private static final String SECTION = "index";
-  private static final String KEY_READY = "ready";
-
-  private final FileBasedConfig cfg;
-
-  GerritIndexStatus(SitePaths sitePaths)
-      throws ConfigInvalidException, IOException {
-    cfg = new FileBasedConfig(
-        sitePaths.index_dir.resolve("gerrit_index.config").toFile(),
-        FS.detect());
-    cfg.load();
-    convertLegacyConfig();
-  }
-
-  void setReady(String indexName, int version, boolean ready) {
-    cfg.setBoolean(SECTION, indexDirName(indexName, version), KEY_READY, ready);
-  }
-
-  boolean getReady(String indexName, int version) {
-    return cfg.getBoolean(SECTION, indexDirName(indexName, version), KEY_READY,
-        false);
-  }
-
-  void save() throws IOException {
-    cfg.save();
-  }
-
-  private void convertLegacyConfig() throws IOException {
-    boolean dirty = false;
-    // Convert legacy [index "25"] to modern [index "changes_0025"].
-    for (String subsection : cfg.getSubsections(SECTION)) {
-      Integer v = Ints.tryParse(subsection);
-      if (v != null) {
-        String ready = cfg.getString(SECTION, subsection, KEY_READY);
-        if (ready != null) {
-          dirty = false;
-          cfg.unset(SECTION, subsection, KEY_READY);
-          cfg.setString(SECTION,
-              indexDirName(ChangeSchemaDefinitions.NAME, v), KEY_READY, ready);
-        }
-      }
-    }
-    if (dirty) {
-      cfg.save();
-    }
-  }
-
-  private static String indexDirName(String indexName, int version) {
-    return String.format("%s_%04d", indexName, version);
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
index 33966b8..a241f3d 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
@@ -19,7 +19,6 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.server.config.ConfigUtil;
-
 import org.apache.lucene.analysis.standard.StandardAnalyzer;
 import org.apache.lucene.analysis.util.CharArraySet;
 import org.apache.lucene.index.ConcurrentMergeScheduler;
@@ -27,14 +26,9 @@
 import org.apache.lucene.index.IndexWriterConfig.OpenMode;
 import org.eclipse.jgit.lib.Config;
 
-import java.util.Map;
-
-/**
- * Combination of Lucene {@link IndexWriterConfig} with additional
- * Gerrit-specific options.
- */
+/** Combination of Lucene {@link IndexWriterConfig} with additional Gerrit-specific options. */
 class GerritIndexWriterConfig {
-  private static final Map<String, String> CUSTOM_CHAR_MAPPING =
+  private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
       ImmutableMap.of("_", " ", ".", " ");
 
   private final IndexWriterConfig luceneConfig;
@@ -43,11 +37,12 @@
 
   GerritIndexWriterConfig(Config cfg, String name) {
     analyzer =
-        new CustomMappingAnalyzer(new StandardAnalyzer(
-            CharArraySet.EMPTY_SET), CUSTOM_CHAR_MAPPING);
-    luceneConfig = new IndexWriterConfig(analyzer)
-        .setOpenMode(OpenMode.CREATE_OR_APPEND)
-        .setCommitOnClose(true);
+        new CustomMappingAnalyzer(
+            new StandardAnalyzer(CharArraySet.EMPTY_SET), CUSTOM_CHAR_MAPPING);
+    luceneConfig =
+        new IndexWriterConfig(analyzer)
+            .setOpenMode(OpenMode.CREATE_OR_APPEND)
+            .setCommitOnClose(true);
 
     int maxMergeCount = cfg.getInt("index", name, "maxMergeCount", -1);
     int maxThreadCount = cfg.getInt("index", name, "maxThreadCount", -1);
@@ -64,16 +59,19 @@
     }
 
     double m = 1 << 20;
-    luceneConfig.setRAMBufferSizeMB(cfg.getLong(
-        "index", name, "ramBufferSize",
-        (long) (IndexWriterConfig.DEFAULT_RAM_BUFFER_SIZE_MB * m)) / m);
-    luceneConfig.setMaxBufferedDocs(cfg.getInt(
-        "index", name, "maxBufferedDocs",
-        IndexWriterConfig.DEFAULT_MAX_BUFFERED_DOCS));
+    luceneConfig.setRAMBufferSizeMB(
+        cfg.getLong(
+                "index",
+                name,
+                "ramBufferSize",
+                (long) (IndexWriterConfig.DEFAULT_RAM_BUFFER_SIZE_MB * m))
+            / m);
+    luceneConfig.setMaxBufferedDocs(
+        cfg.getInt("index", name, "maxBufferedDocs", IndexWriterConfig.DEFAULT_MAX_BUFFERED_DOCS));
     try {
       commitWithinMs =
-          ConfigUtil.getTimeUnit(cfg, "index", name, "commitWithin",
-              MILLISECONDS.convert(5, MINUTES), MILLISECONDS);
+          ConfigUtil.getTimeUnit(
+              cfg, "index", name, "commitWithin", MILLISECONDS.convert(5, MINUTES), MILLISECONDS);
     } catch (IllegalArgumentException e) {
       commitWithinMs = cfg.getLong("index", name, "commitWithin", 0);
     }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 59980e7..fbde7be 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -16,13 +16,12 @@
 
 import static com.google.gerrit.server.index.account.AccountField.ID;
 
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.account.AccountIndex;
@@ -32,8 +31,15 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.IndexSearcher;
@@ -50,20 +56,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-
-public class LuceneAccountIndex
-    extends AbstractLuceneIndex<Account.Id, AccountState>
+public class LuceneAccountIndex extends AbstractLuceneIndex<Account.Id, AccountState>
     implements AccountIndex {
-  private static final Logger log =
-      LoggerFactory.getLogger(LuceneAccountIndex.class);
+  private static final Logger log = LoggerFactory.getLogger(LuceneAccountIndex.class);
 
   private static final String ACCOUNTS = "accounts";
 
@@ -79,15 +74,14 @@
 
   private final GerritIndexWriterConfig indexWriterConfig;
   private final QueryBuilder<AccountState> queryBuilder;
-  private final AccountCache accountCache;
+  private final Provider<AccountCache> accountCache;
 
-  private static Directory dir(Schema<AccountState> schema, Config cfg,
-      SitePaths sitePaths) throws IOException {
+  private static Directory dir(Schema<AccountState> schema, Config cfg, SitePaths sitePaths)
+      throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       return new RAMDirectory();
     }
-    Path indexDir =
-        LuceneVersionManager.getDir(sitePaths, ACCOUNTS + "_", schema);
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS + "_", schema);
     return FSDirectory.open(indexDir);
   }
 
@@ -95,14 +89,20 @@
   LuceneAccountIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
-      AccountCache accountCache,
-      @Assisted Schema<AccountState> schema) throws IOException {
-    super(schema, sitePaths, dir(schema, cfg, sitePaths), ACCOUNTS, null,
-        new GerritIndexWriterConfig(cfg, ACCOUNTS), new SearcherFactory());
+      Provider<AccountCache> accountCache,
+      @Assisted Schema<AccountState> schema)
+      throws IOException {
+    super(
+        schema,
+        sitePaths,
+        dir(schema, cfg, sitePaths),
+        ACCOUNTS,
+        null,
+        new GerritIndexWriterConfig(cfg, ACCOUNTS),
+        new SearcherFactory());
     this.accountCache = accountCache;
 
-    indexWriterConfig =
-        new GerritIndexWriterConfig(cfg, ACCOUNTS);
+    indexWriterConfig = new GerritIndexWriterConfig(cfg, ACCOUNTS);
     queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
   }
 
@@ -126,13 +126,12 @@
   }
 
   @Override
-  public DataSource<AccountState> getSource(Predicate<AccountState> p,
-      QueryOptions opts) throws QueryParseException {
+  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
+      throws QueryParseException {
     return new QuerySource(
         opts,
         queryBuilder.toQuery(p),
-        new Sort(
-            new SortField(ID_SORT_FIELD, SortField.Type.LONG, true)));
+        new Sort(new SortField(ID_SORT_FIELD, SortField.Type.LONG, true)));
   }
 
   private class QuerySource implements DataSource<AccountState> {
@@ -164,7 +163,7 @@
         List<AccountState> result = new ArrayList<>(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
           ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searcher.doc(sd.doc, fields(opts));
+          Document doc = searcher.doc(sd.doc, IndexUtils.accountFields(opts));
           result.add(toAccountState(doc));
         }
         final List<AccountState> r = Collections.unmodifiableList(result);
@@ -198,24 +197,12 @@
     }
   }
 
-  private Set<String> fields(QueryOptions opts) {
-    Set<String> fs = opts.fields();
-    return fs.contains(ID.getName())
-        ? fs
-        : Sets.union(fs, ImmutableSet.of(ID.getName()));
-  }
-
   private AccountState toAccountState(Document doc) {
-    Account.Id id =
-        new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
+    Account.Id id = new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
     // Use the AccountCache rather than depending on any stored fields in the
-    // document (of which there shouldn't be any. The most expensive part to
+    // document (of which there shouldn't be any). The most expensive part to
     // compute anyway is the effective group IDs, and we don't have a good way
     // to reindex when those change.
-    return accountCache.get(id);
-  }
-
-  @Override
-  public void stop() {
+    return accountCache.get().get(id);
   }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 530566c..ce5ab71 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -16,25 +16,25 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
-import static com.google.gerrit.lucene.LuceneVersionManager.CHANGES_PREFIX;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
+import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
-import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -45,14 +45,13 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeField.ChangeProtoField;
-import com.google.gerrit.server.index.change.ChangeField.PatchSetApprovalProtoField;
-import com.google.gerrit.server.index.change.ChangeField.PatchSetProtoField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -64,7 +63,17 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexableField;
@@ -84,53 +93,40 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-
 /**
  * Secondary index implementation using Apache Lucene.
- * <p>
- * Writes are managed using a single {@link IndexWriter} per process, committed
- * aggressively. Reads use {@link SearcherManager} and periodically refresh,
- * though there may be some lag between a committed write and it showing up to
- * other threads' searchers.
+ *
+ * <p>Writes are managed using a single {@link IndexWriter} per process, committed aggressively.
+ * Reads use {@link SearcherManager} and periodically refresh, though there may be some lag between
+ * a committed write and it showing up to other threads' searchers.
  */
 public class LuceneChangeIndex implements ChangeIndex {
-  private static final Logger log =
-      LoggerFactory.getLogger(LuceneChangeIndex.class);
+  private static final Logger log = LoggerFactory.getLogger(LuceneChangeIndex.class);
 
-  public static final String CHANGES_OPEN = "open";
-  public static final String CHANGES_CLOSED = "closed";
+  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
+  static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
 
-  static final String UPDATED_SORT_FIELD =
-      sortFieldName(ChangeField.UPDATED);
-  static final String ID_SORT_FIELD =
-      sortFieldName(ChangeField.LEGACY_ID);
-
+  private static final String CHANGES_PREFIX = "changes_";
+  private static final String CHANGES_OPEN = "open";
+  private static final String CHANGES_CLOSED = "closed";
   private static final String ADDED_FIELD = ChangeField.ADDED.getName();
   private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
   private static final String DELETED_FIELD = ChangeField.DELETED.getName();
   private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
   private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
-  private static final String REVIEWEDBY_FIELD =
-      ChangeField.REVIEWEDBY.getName();
+  private static final String REF_STATE_FIELD = ChangeField.REF_STATE.getName();
+  private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
+  private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
   private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
-  private static final String HASHTAG_FIELD =
-      ChangeField.HASHTAG_CASE_AWARE.getName();
+  private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
   private static final String STAR_FIELD = ChangeField.STAR.getName();
-  @Deprecated
-  private static final String STARREDBY_FIELD = ChangeField.STARREDBY.getName();
+  private static final String SUBMIT_RECORD_LENIENT_FIELD =
+      ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
+  private static final String SUBMIT_RECORD_STRICT_FIELD =
+      ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
+  private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
+      ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
 
   static Term idTerm(ChangeData cd) {
     return QueryBuilder.intTerm(LEGACY_ID.getName(), cd.getId().get());
@@ -153,46 +149,43 @@
   LuceneChangeIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
-      @IndexExecutor(INTERACTIVE)  ListeningExecutorService executor,
+      @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       FillArgs fillArgs,
-      @Assisted Schema<ChangeData> schema) throws IOException {
+      @Assisted Schema<ChangeData> schema)
+      throws IOException {
     this.fillArgs = fillArgs;
     this.executor = executor;
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.schema = schema;
 
-    GerritIndexWriterConfig openConfig =
-        new GerritIndexWriterConfig(cfg, "changes_open");
-    GerritIndexWriterConfig closedConfig =
-        new GerritIndexWriterConfig(cfg, "changes_closed");
+    GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
+    GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
 
     queryBuilder = new QueryBuilder<>(schema, openConfig.getAnalyzer());
 
     SearcherFactory searcherFactory = new SearcherFactory();
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      openIndex = new ChangeSubIndex(schema, sitePaths, new RAMDirectory(),
-          "ramOpen", openConfig, searcherFactory);
-      closedIndex = new ChangeSubIndex(schema, sitePaths, new RAMDirectory(),
-          "ramClosed", closedConfig, searcherFactory);
+      openIndex =
+          new ChangeSubIndex(
+              schema, sitePaths, new RAMDirectory(), "ramOpen", openConfig, searcherFactory);
+      closedIndex =
+          new ChangeSubIndex(
+              schema, sitePaths, new RAMDirectory(), "ramClosed", closedConfig, searcherFactory);
     } else {
       Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES_PREFIX, schema);
-      openIndex = new ChangeSubIndex(schema, sitePaths,
-          dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
-      closedIndex = new ChangeSubIndex(schema, sitePaths,
-          dir.resolve(CHANGES_CLOSED), closedConfig, searcherFactory);
+      openIndex =
+          new ChangeSubIndex(
+              schema, sitePaths, dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
+      closedIndex =
+          new ChangeSubIndex(
+              schema, sitePaths, dir.resolve(CHANGES_CLOSED), closedConfig, searcherFactory);
     }
   }
 
   @Override
-  public void stop() {
-    MoreExecutors.shutdownAndAwaitTermination(
-        executor, Long.MAX_VALUE, TimeUnit.SECONDS);
-  }
-
-  @Override
   public void close() {
     try {
       openIndex.close();
@@ -214,13 +207,9 @@
     Document doc = openIndex.toDocument(cd, fillArgs);
     try {
       if (cd.change().getStatus().isOpen()) {
-        Futures.allAsList(
-            closedIndex.delete(id),
-            openIndex.replace(id, doc)).get();
+        Futures.allAsList(closedIndex.delete(id), openIndex.replace(id, doc)).get();
       } else {
-        Futures.allAsList(
-            openIndex.delete(id),
-            closedIndex.replace(id, doc)).get();
+        Futures.allAsList(openIndex.delete(id), closedIndex.replace(id, doc)).get();
       }
     } catch (OrmException | ExecutionException | InterruptedException e) {
       throw new IOException(e);
@@ -231,9 +220,7 @@
   public void delete(Change.Id id) throws IOException {
     Term idTerm = LuceneChangeIndex.idTerm(id);
     try {
-      Futures.allAsList(
-          openIndex.delete(idTerm),
-          closedIndex.delete(idTerm)).get();
+      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new IOException(e);
     }
@@ -283,14 +270,12 @@
     private final QueryOptions opts;
     private final Sort sort;
 
-
-    private QuerySource(List<ChangeSubIndex> indexes,
-        Predicate<ChangeData> predicate, QueryOptions opts, Sort sort)
+    private QuerySource(
+        List<ChangeSubIndex> indexes, Predicate<ChangeData> predicate, QueryOptions opts, Sort sort)
         throws QueryParseException {
       this.indexes = indexes;
       this.predicate = predicate;
-      this.query = checkNotNull(queryBuilder.toQuery(predicate),
-          "null query from Lucene");
+      this.query = checkNotNull(queryBuilder.toQuery(predicate), "null query from Lucene");
       this.opts = opts;
       this.sort = sort;
     }
@@ -317,19 +302,21 @@
         throw new OrmException("interrupted");
       }
 
-      final Set<String> fields = fields(opts);
+      final Set<String> fields = IndexUtils.changeFields(opts);
       return new ChangeDataResults(
-          executor.submit(new Callable<List<Document>>() {
-            @Override
-            public List<Document> call() throws IOException {
-              return doRead(fields);
-            }
+          executor.submit(
+              new Callable<List<Document>>() {
+                @Override
+                public List<Document> call() throws IOException {
+                  return doRead(fields);
+                }
 
-            @Override
-            public String toString() {
-              return predicate.toString();
-            }
-          }), fields);
+                @Override
+                public String toString() {
+                  return predicate.toString();
+                }
+              }),
+          fields);
     }
 
     private List<Document> doRead(Set<String> fields) throws IOException {
@@ -394,7 +381,7 @@
         close();
         throw new OrmRuntimeException(e);
       } catch (ExecutionException e) {
-        Throwables.propagateIfPossible(e.getCause());
+        Throwables.throwIfUnchecked(e.getCause());
         throw new OrmRuntimeException(e.getCause());
       }
     }
@@ -405,35 +392,9 @@
     }
   }
 
-  private Set<String> fields(QueryOptions opts) {
-    // Ensure we request enough fields to construct a ChangeData.
-    Set<String> fs = opts.fields();
-    if (fs.contains(CHANGE.getName())) {
-      // A Change is always sufficient.
-      return fs;
-    }
-
-    if (!schema.hasField(PROJECT)) {
-      // Schema is not new enough to have project field. Ensure we have ID
-      // field, and call createOnlyWhenNoteDbDisabled from toChangeData below.
-      if (fs.contains(LEGACY_ID.getName())) {
-        return fs;
-      }
-      return Sets.union(fs, ImmutableSet.of(LEGACY_ID.getName()));
-    }
-
-    // New enough schema to have project field, so ensure that is requested.
-    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
-      return fs;
-    }
-    return Sets.union(fs,
-        ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
-  }
-
-  private static Multimap<String, IndexableField> fields(Document doc,
-      Set<String> fields) {
-    Multimap<String, IndexableField> stored =
-        ArrayListMultimap.create(fields.size(), 4);
+  private static ListMultimap<String, IndexableField> fields(Document doc, Set<String> fields) {
+    ListMultimap<String, IndexableField> stored =
+        MultimapBuilder.hashKeys(fields.size()).arrayListValues(4).build();
     for (IndexableField f : doc) {
       String name = f.name();
       if (fields.contains(name)) {
@@ -443,16 +404,17 @@
     return stored;
   }
 
-  private ChangeData toChangeData(Multimap<String, IndexableField> doc,
-      Set<String> fields, String idFieldName) {
+  private ChangeData toChangeData(
+      ListMultimap<String, IndexableField> doc, Set<String> fields, String idFieldName) {
     ChangeData cd;
     // Either change or the ID field was guaranteed to be included in the call
     // to fields() above.
     IndexableField cb = Iterables.getFirst(doc.get(CHANGE_FIELD), null);
     if (cb != null) {
       BytesRef proto = cb.binaryValue();
-      cd = changeDataFactory.create(db.get(),
-          ChangeProtoField.CODEC.decode(proto.bytes, proto.offset, proto.length));
+      cd =
+          changeDataFactory.create(
+              db.get(), CHANGE_CODEC.decode(proto.bytes, proto.offset, proto.length));
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
       Change.Id id = new Change.Id(f.numericValue().intValue());
@@ -462,8 +424,7 @@
         // disabled.
         cd = changeDataFactory.createOnlyWhenNoteDbDisabled(db.get(), id);
       } else {
-        cd = changeDataFactory.create(
-            db.get(), new Project.NameKey(project.stringValue()), id);
+        cd = changeDataFactory.create(db.get(), new Project.NameKey(project.stringValue()), id);
       }
     }
 
@@ -485,21 +446,29 @@
     if (fields.contains(HASHTAG_FIELD)) {
       decodeHashtags(doc, cd);
     }
-    if (fields.contains(STARREDBY_FIELD)) {
-      decodeStarredBy(doc, cd);
-    }
     if (fields.contains(STAR_FIELD)) {
       decodeStar(doc, cd);
     }
     if (fields.contains(REVIEWER_FIELD)) {
       decodeReviewers(doc, cd);
     }
+    decodeSubmitRecords(
+        doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
+    decodeSubmitRecords(
+        doc, SUBMIT_RECORD_LENIENT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
+    if (fields.contains(REF_STATE_FIELD)) {
+      decodeRefStates(doc, cd);
+    }
+    if (fields.contains(REF_STATE_PATTERN_FIELD)) {
+      decodeRefStatePatterns(doc, cd);
+    }
+
+    decodeUnresolvedCommentCount(doc, cd);
     return cd;
   }
 
-  private void decodePatchSets(Multimap<String, IndexableField> doc, ChangeData cd) {
-    List<PatchSet> patchSets =
-        decodeProtos(doc, PATCH_SET_FIELD, PatchSetProtoField.CODEC);
+  private void decodePatchSets(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PATCH_SET_CODEC);
     if (!patchSets.isEmpty()) {
       // Will be an empty list for schemas prior to when this field was stored;
       // this cannot be valid since a change needs at least one patch set.
@@ -507,18 +476,15 @@
     }
   }
 
-  private void decodeApprovals(Multimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setCurrentApprovals(
-        decodeProtos(doc, APPROVAL_FIELD, PatchSetApprovalProtoField.CODEC));
+  private void decodeApprovals(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setCurrentApprovals(decodeProtos(doc, APPROVAL_FIELD, APPROVAL_CODEC));
   }
 
-  private void decodeChangedLines(Multimap<String, IndexableField> doc, ChangeData cd) {
+  private void decodeChangedLines(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     IndexableField added = Iterables.getFirst(doc.get(ADDED_FIELD), null);
     IndexableField deleted = Iterables.getFirst(doc.get(DELETED_FIELD), null);
     if (added != null && deleted != null) {
-      cd.setChangedLines(
-          added.numericValue().intValue(),
-          deleted.numericValue().intValue());
+      cd.setChangedLines(added.numericValue().intValue(), deleted.numericValue().intValue());
     } else {
       // No ChangedLines stored, likely due to failure during reindexing, for
       // example due to LargeObjectException. But we know the field was
@@ -528,7 +494,7 @@
     }
   }
 
-  private void decodeMergeable(Multimap<String, IndexableField> doc, ChangeData cd) {
+  private void decodeMergeable(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
     if (f != null) {
       String mergeable = f.stringValue();
@@ -540,11 +506,10 @@
     }
   }
 
-  private void decodeReviewedBy(Multimap<String, IndexableField> doc, ChangeData cd) {
+  private void decodeReviewedBy(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     Collection<IndexableField> reviewedBy = doc.get(REVIEWEDBY_FIELD);
     if (reviewedBy.size() > 0) {
-      Set<Account.Id> accounts =
-          Sets.newHashSetWithExpectedSize(reviewedBy.size());
+      Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
       for (IndexableField r : reviewedBy) {
         int id = r.numericValue().intValue();
         if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
@@ -556,7 +521,7 @@
     }
   }
 
-  private void decodeHashtags(Multimap<String, IndexableField> doc, ChangeData cd) {
+  private void decodeHashtags(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     Collection<IndexableField> hashtag = doc.get(HASHTAG_FIELD);
     Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtag.size());
     for (IndexableField r : hashtag) {
@@ -565,23 +530,11 @@
     cd.setHashtags(hashtags);
   }
 
-  @Deprecated
-  private void decodeStarredBy(Multimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> starredBy = doc.get(STARREDBY_FIELD);
-    Set<Account.Id> accounts =
-        Sets.newHashSetWithExpectedSize(starredBy.size());
-    for (IndexableField r : starredBy) {
-      accounts.add(new Account.Id(r.numericValue().intValue()));
-    }
-    cd.setStarredBy(accounts);
-  }
-
-  private void decodeStar(Multimap<String, IndexableField> doc, ChangeData cd) {
+  private void decodeStar(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     Collection<IndexableField> star = doc.get(STAR_FIELD);
-    Multimap<Account.Id, String> stars = ArrayListMultimap.create();
+    ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
     for (IndexableField r : star) {
-      StarredChangesUtil.StarField starField =
-          StarredChangesUtil.StarField.parse(r.stringValue());
+      StarredChangesUtil.StarField starField = StarredChangesUtil.StarField.parse(r.stringValue());
       if (starField != null) {
         stars.put(starField.accountId(), starField.label());
       }
@@ -589,21 +542,39 @@
     cd.setStars(stars);
   }
 
-  private void decodeReviewers(Multimap<String, IndexableField> doc, ChangeData cd) {
+  private void decodeReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     cd.setReviewers(
         ChangeField.parseReviewerFieldValues(
-            FluentIterable.from(doc.get(REVIEWER_FIELD))
-                .transform(
-                    new Function<IndexableField, String>() {
-                      @Override
-                      public String apply(IndexableField in) {
-                        return in.stringValue();
-                      }
-                    })));
+            FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
   }
 
-  private static <T> List<T> decodeProtos(Multimap<String, IndexableField> doc,
-      String fieldName, ProtobufCodec<T> codec) {
+  private void decodeSubmitRecords(
+      ListMultimap<String, IndexableField> doc,
+      String field,
+      SubmitRuleOptions opts,
+      ChangeData cd) {
+    ChangeField.parseSubmitRecords(
+        Collections2.transform(doc.get(field), f -> f.binaryValue().utf8ToString()), opts, cd);
+  }
+
+  private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setRefStates(copyAsBytes(doc.get(REF_STATE_FIELD)));
+  }
+
+  private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setRefStatePatterns(copyAsBytes(doc.get(REF_STATE_PATTERN_FIELD)));
+  }
+
+  private void decodeUnresolvedCommentCount(
+      ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField f = Iterables.getFirst(doc.get(UNRESOLVED_COMMENT_COUNT_FIELD), null);
+    if (f != null && f.numericValue() != null) {
+      cd.setUnresolvedCommentCount(f.numericValue().intValue());
+    }
+  }
+
+  private static <T> List<T> decodeProtos(
+      ListMultimap<String, IndexableField> doc, String fieldName, ProtobufCodec<T> codec) {
     Collection<IndexableField> fields = doc.get(fieldName);
     if (fields.isEmpty()) {
       return Collections.emptyList();
@@ -616,4 +587,16 @@
     }
     return result;
   }
+
+  private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
+    return fields.stream()
+        .map(
+            f -> {
+              BytesRef ref = f.binaryValue();
+              byte[] b = new byte[ref.length];
+              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
+              return b;
+            })
+        .collect(toList());
+  }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
new file mode 100644
index 0000000..c4f10ff
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.lucene;
+
+import static com.google.gerrit.server.index.group.GroupField.UUID;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.TopFieldDocs;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.RAMDirectory;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, AccountGroup>
+    implements GroupIndex {
+  private static final Logger log = LoggerFactory.getLogger(LuceneGroupIndex.class);
+
+  private static final String GROUPS = "groups";
+
+  private static final String UUID_SORT_FIELD = sortFieldName(UUID);
+
+  private static Term idTerm(AccountGroup group) {
+    return idTerm(group.getGroupUUID());
+  }
+
+  private static Term idTerm(AccountGroup.UUID uuid) {
+    return QueryBuilder.stringTerm(UUID.getName(), uuid.get());
+  }
+
+  private final GerritIndexWriterConfig indexWriterConfig;
+  private final QueryBuilder<AccountGroup> queryBuilder;
+  private final Provider<GroupCache> groupCache;
+
+  private static Directory dir(Schema<AccountGroup> schema, Config cfg, SitePaths sitePaths)
+      throws IOException {
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      return new RAMDirectory();
+    }
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS + "_", schema);
+    return FSDirectory.open(indexDir);
+  }
+
+  @Inject
+  LuceneGroupIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<GroupCache> groupCache,
+      @Assisted Schema<AccountGroup> schema)
+      throws IOException {
+    super(
+        schema,
+        sitePaths,
+        dir(schema, cfg, sitePaths),
+        GROUPS,
+        null,
+        new GerritIndexWriterConfig(cfg, GROUPS),
+        new SearcherFactory());
+    this.groupCache = groupCache;
+
+    indexWriterConfig = new GerritIndexWriterConfig(cfg, GROUPS);
+    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
+  }
+
+  @Override
+  public void replace(AccountGroup group) throws IOException {
+    try {
+      // No parts of FillArgs are currently required, just use null.
+      replace(idTerm(group), toDocument(group, null)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void delete(AccountGroup.UUID key) throws IOException {
+    try {
+      delete(idTerm(key)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public DataSource<AccountGroup> getSource(Predicate<AccountGroup> p, QueryOptions opts)
+      throws QueryParseException {
+    return new QuerySource(
+        opts,
+        queryBuilder.toQuery(p),
+        new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
+  }
+
+  private class QuerySource implements DataSource<AccountGroup> {
+    private final QueryOptions opts;
+    private final Query query;
+    private final Sort sort;
+
+    private QuerySource(QueryOptions opts, Query query, Sort sort) {
+      this.opts = opts;
+      this.query = query;
+      this.sort = sort;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<AccountGroup> read() throws OrmException {
+      IndexSearcher searcher = null;
+      try {
+        searcher = acquire();
+        int realLimit = opts.start() + opts.limit();
+        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        List<AccountGroup> result = new ArrayList<>(docs.scoreDocs.length);
+        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
+          ScoreDoc sd = docs.scoreDocs[i];
+          Document doc = searcher.doc(sd.doc, IndexUtils.groupFields(opts));
+          result.add(toAccountGroup(doc));
+        }
+        final List<AccountGroup> r = Collections.unmodifiableList(result);
+        return new ResultSet<AccountGroup>() {
+          @Override
+          public Iterator<AccountGroup> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<AccountGroup> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      } finally {
+        if (searcher != null) {
+          try {
+            release(searcher);
+          } catch (IOException e) {
+            log.warn("cannot release Lucene searcher", e);
+          }
+        }
+      }
+    }
+  }
+
+  private AccountGroup toAccountGroup(Document doc) {
+    AccountGroup.UUID uuid = new AccountGroup.UUID(doc.getField(UUID.getName()).stringValue());
+    // Use the GroupCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any).
+    return groupCache.get().get(uuid);
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index f5d5146..a78504f 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -15,39 +15,20 @@
 package com.google.gerrit.lucene;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.AbstractIndexModule;
+import com.google.gerrit.server.index.AbstractVersionManager;
 import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.inject.Inject;
-import com.google.inject.Provides;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
-import com.google.inject.name.Named;
-import com.google.inject.name.Names;
-
+import com.google.gerrit.server.index.group.GroupIndex;
+import java.util.Map;
 import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
-import java.util.Collection;
-import java.util.Map;
-import java.util.Set;
-
-public class LuceneIndexModule extends LifecycleModule {
-  private static final String SINGLE_VERSIONS =
-      "LuceneIndexModule/SingleVersions";
-
+public class LuceneIndexModule extends AbstractIndexModule {
   public static LuceneIndexModule singleVersionAllLatest(int threads) {
-    return new LuceneIndexModule(ImmutableMap.<String, Integer> of(), threads);
+    return new LuceneIndexModule(ImmutableMap.<String, Integer>of(), threads);
   }
 
   public static LuceneIndexModule singleVersionWithExplicitVersions(
@@ -63,107 +44,34 @@
     return cfg.getBoolean("index", "lucene", "testInmemory", false);
   }
 
-  private final int threads;
-  private final Map<String, Integer> singleVersions;
-
   private LuceneIndexModule(Map<String, Integer> singleVersions, int threads) {
-    this.singleVersions = singleVersions;
-    this.threads = threads;
+    super(singleVersions, threads);
   }
 
   @Override
-  protected void configure() {
-    install(
-        new FactoryModuleBuilder()
-            .implement(ChangeIndex.class, LuceneChangeIndex.class)
-            .build(ChangeIndex.Factory.class));
-    install(
-        new FactoryModuleBuilder()
-            .implement(AccountIndex.class, LuceneAccountIndex.class)
-            .build(AccountIndex.Factory.class));
-
-    install(new IndexModule(threads));
-    if (singleVersions == null) {
-      install(new MultiVersionModule());
-    } else {
-      install(new SingleVersionModule());
-    }
+  protected Class<? extends AccountIndex> getAccountIndex() {
+    return LuceneAccountIndex.class;
   }
 
-  @Provides
-  @Singleton
-  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
-    BooleanQuery.setMaxClauseCount(cfg.getInt("index", "maxTerms",
-        BooleanQuery.getMaxClauseCount()));
-    return IndexConfig.fromConfig(cfg);
+  @Override
+  protected Class<? extends ChangeIndex> getChangeIndex() {
+    return LuceneChangeIndex.class;
   }
 
-  private static class MultiVersionModule extends LifecycleModule {
-    @Override
-    public void configure() {
-      listener().to(LuceneVersionManager.class);
-    }
+  @Override
+  protected Class<? extends GroupIndex> getGroupIndex() {
+    return LuceneGroupIndex.class;
   }
 
-  private class SingleVersionModule extends LifecycleModule {
-    @Override
-    public void configure() {
-      listener().to(SingleVersionListener.class);
-      bind(new TypeLiteral<Map<String, Integer>>() {})
-          .annotatedWith(Names.named(SINGLE_VERSIONS))
-          .toInstance(singleVersions);
-    }
+  @Override
+  protected Class<? extends AbstractVersionManager> getVersionManager() {
+    return LuceneVersionManager.class;
   }
 
-  @Singleton
-  static class SingleVersionListener implements LifecycleListener {
-    private final Set<String> disabled;
-    private final Collection<IndexDefinition<?, ?, ?>> defs;
-    private final Map<String, Integer> singleVersions;
-
-    @Inject
-    SingleVersionListener(
-        @GerritServerConfig Config cfg,
-        Collection<IndexDefinition<?, ?, ?>> defs,
-        @Named(SINGLE_VERSIONS) Map<String, Integer> singleVersions) {
-      this.defs = defs;
-      this.singleVersions = singleVersions;
-
-      disabled = ImmutableSet.copyOf(
-          cfg.getStringList("index", null, "testDisable"));
-    }
-
-    @Override
-    public void start() {
-      for (IndexDefinition<?, ?, ?> def : defs) {
-        start(def);
-      }
-    }
-
-    private <K, V, I extends Index<K, V>> void start(
-        IndexDefinition<K, V, I> def) {
-      if (disabled.contains(def.getName())) {
-        return;
-      }
-      Schema<V> schema;
-      Integer v = singleVersions.get(def.getName());
-      if (v == null) {
-        schema = def.getLatest();
-      } else {
-        schema = def.getSchemas().get(v);
-        if (schema == null) {
-          throw new ProvisionException(String.format(
-                "Unrecognized %s schema version: %s", def.getName(), v));
-        }
-      }
-      I index = def.getIndexFactory().create(schema);
-      def.getIndexCollection().setSearchIndex(index);
-      def.getIndexCollection().addWriteIndex(index);
-    }
-
-    @Override
-    public void stop() {
-      // Do nothing; indexes are closed by IndexCollection.
-    }
+  @Override
+  protected IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+    BooleanQuery.setMaxClauseCount(
+        cfg.getInt("index", "maxTerms", BooleanQuery.getMaxClauseCount()));
+    return super.getIndexConfig(cfg);
   }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
index f05f879..441a4b7 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -14,235 +14,61 @@
 
 package com.google.gerrit.lucene;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.AbstractVersionManager;
+import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.index.IndexDefinition.IndexFactory;
-import com.google.gerrit.server.index.OnlineReindexer;
 import com.google.gerrit.server.index.Schema;
 import com.google.inject.Inject;
-import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Collection;
-import java.util.List;
-import java.util.Map;
 import java.util.TreeMap;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
-public class LuceneVersionManager implements LifecycleListener {
-  private static final Logger log = LoggerFactory
-      .getLogger(LuceneVersionManager.class);
-
-  static final String CHANGES_PREFIX = "changes_";
-
-  private static class Version<V> {
-    private final Schema<V> schema;
-    private final int version;
-    private final boolean exists;
-    private final boolean ready;
-
-    private Version(Schema<V> schema, int version, boolean exists,
-        boolean ready) {
-      checkArgument(schema == null || schema.getVersion() == version);
-      this.schema = schema;
-      this.version = version;
-      this.exists = exists;
-      this.ready = ready;
-    }
-  }
+public class LuceneVersionManager extends AbstractVersionManager implements LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(LuceneVersionManager.class);
 
   static Path getDir(SitePaths sitePaths, String prefix, Schema<?> schema) {
-    return sitePaths.index_dir.resolve(String.format("%s%04d",
-        prefix, schema.getVersion()));
+    return sitePaths.index_dir.resolve(String.format("%s%04d", prefix, schema.getVersion()));
   }
 
-  private final SitePaths sitePaths;
-  private final Map<String, IndexDefinition<?, ?, ?>> defs;
-  private final Map<String, OnlineReindexer<?, ?, ?>> reindexers;
-  private final boolean onlineUpgrade;
-  private final String runReindexMsg;
-
   @Inject
   LuceneVersionManager(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       Collection<IndexDefinition<?, ?, ?>> defs) {
-    this.sitePaths = sitePaths;
-    this.defs = Maps.newHashMapWithExpectedSize(defs.size());
-    for (IndexDefinition<?, ?, ?> def : defs) {
-      this.defs.put(def.getName(), def);
-    }
-
-    reindexers = Maps.newHashMapWithExpectedSize(defs.size());
-    onlineUpgrade = cfg.getBoolean("index", null, "onlineUpgrade", true);
-    runReindexMsg =
-        "No index versions ready; run java -jar " +
-        sitePaths.gerrit_war.toAbsolutePath() +
-        " reindex";
+    super(cfg, sitePaths, defs);
   }
 
   @Override
-  public void start() {
-    GerritIndexStatus cfg;
-    try {
-      cfg = new GerritIndexStatus(sitePaths);
-    } catch (ConfigInvalidException | IOException e) {
-      throw fail(e);
-    }
-
-    if (!Files.exists(sitePaths.index_dir)) {
-      throw new ProvisionException(runReindexMsg);
-    } else if (!Files.exists(sitePaths.index_dir)) {
-      log.warn("Not a directory: {}", sitePaths.index_dir.toAbsolutePath());
-      throw new ProvisionException(runReindexMsg);
-    }
-
-    for (IndexDefinition<?, ?, ?> def : defs.values()) {
-      initIndex(def, cfg);
-    }
-  }
-
-  private <K, V, I extends Index<K, V>> void initIndex(
-      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
-    TreeMap<Integer, Version<V>> versions = scanVersions(def, cfg);
-    // Search from the most recent ready version.
-    // Write to the most recent ready version and the most recent version.
-    Version<V> search = null;
-    List<Version<V>> write = Lists.newArrayListWithCapacity(2);
-    for (Version<V> v : versions.descendingMap().values()) {
-      if (v.schema == null) {
-        continue;
-      }
-      if (write.isEmpty() && onlineUpgrade) {
-        write.add(v);
-      }
-      if (v.ready) {
-        search = v;
-        if (!write.contains(v)) {
-          write.add(v);
-        }
-        break;
-      }
-    }
-    if (search == null) {
-      throw new ProvisionException(runReindexMsg);
-    }
-
-    IndexFactory<K, V, I> factory = def.getIndexFactory();
-    I searchIndex = factory.create(search.schema);
-    IndexCollection<K, V, I> indexes = def.getIndexCollection();
-    indexes.setSearchIndex(searchIndex);
-    for (Version<V> v : write) {
-      if (v.schema != null) {
-        if (v.version != search.version) {
-          indexes.addWriteIndex(factory.create(v.schema));
-        } else {
-          indexes.addWriteIndex(searchIndex);
-        }
-      }
-    }
-
-    markNotReady(cfg, def.getName(), versions.values(), write);
-
-    int latest = write.get(0).version;
-    OnlineReindexer<K, V, I> reindexer = new OnlineReindexer<>(def, latest);
-    synchronized (this) {
-      if (!reindexers.containsKey(def.getName())) {
-        reindexers.put(def.getName(), reindexer);
-        if (onlineUpgrade && latest != search.version) {
-          reindexer.start();
-        }
-      }
-    }
-  }
-
-  /**
-   * Start the online reindexer if the current index is not already the latest.
-   *
-   * @param  force start re-index
-   * @return true if started, otherwise false.
-   * @throws ReindexerAlreadyRunningException
-   */
-  public synchronized boolean startReindexer(String name, boolean force)
-      throws ReindexerAlreadyRunningException {
-    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
-    validateReindexerNotRunning(reindexer);
-    if (force || !isCurrentIndexVersionLatest(name, reindexer)) {
-      reindexer.start();
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * Activate the latest index if the current index is not already the latest.
-   *
-   * @return true if index was activate, otherwise false.
-   * @throws ReindexerAlreadyRunningException
-   */
-  public synchronized boolean activateLatestIndex(String name)
-      throws ReindexerAlreadyRunningException {
-    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
-    validateReindexerNotRunning(reindexer);
-    if (!isCurrentIndexVersionLatest(name, reindexer)) {
-      reindexer.activateIndex();
-      return true;
-    }
-    return false;
-  }
-
-  private boolean isCurrentIndexVersionLatest(
-      String name, OnlineReindexer<?, ?, ?> reindexer) {
-    int readVersion = defs.get(name).getIndexCollection().getSearchIndex()
-        .getSchema().getVersion();
-    return reindexer == null
-        || reindexer.getVersion() == readVersion;
-  }
-
-  private static void validateReindexerNotRunning(
-      OnlineReindexer<?, ?, ?> reindexer)
-      throws ReindexerAlreadyRunningException {
-    if (reindexer != null && reindexer.isRunning()) {
-      throw new ReindexerAlreadyRunningException();
-    }
-  }
-
-  private <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>>
-      scanVersions(IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
-    TreeMap<Integer, Version<V>> versions = new TreeMap<>();
+  protected <K, V, I extends Index<K, V>>
+      TreeMap<Integer, AbstractVersionManager.Version<V>> scanVersions(
+          IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, AbstractVersionManager.Version<V>> versions = new TreeMap<>();
     for (Schema<V> schema : def.getSchemas().values()) {
       // This part is Lucene-specific.
-      Path p = getDir(sitePaths, def.getName(), schema);
+      Path p = getDir(sitePaths, def.getName() + "_", schema);
       boolean isDir = Files.isDirectory(p);
       if (Files.exists(p) && !isDir) {
         log.warn("Not a directory: {}", p.toAbsolutePath());
       }
       int v = schema.getVersion();
-      versions.put(v, new Version<>(
-          schema, v, isDir, cfg.getReady(def.getName(), v)));
+      versions.put(v, new Version<>(schema, v, isDir, cfg.getReady(def.getName(), v)));
     }
 
     String prefix = def.getName() + "_";
-    try (DirectoryStream<Path> paths =
-        Files.newDirectoryStream(sitePaths.index_dir)) {
+    try (DirectoryStream<Path> paths = Files.newDirectoryStream(sitePaths.index_dir)) {
       for (Path p : paths) {
         String n = p.getFileName().toString();
         if (!n.startsWith(prefix)) {
@@ -251,13 +77,11 @@
         String versionStr = n.substring(prefix.length());
         Integer v = Ints.tryParse(versionStr);
         if (v == null || versionStr.length() != 4) {
-          log.warn("Unrecognized version in index directory: {}",
-              p.toAbsolutePath());
+          log.warn("Unrecognized version in index directory: {}", p.toAbsolutePath());
           continue;
         }
         if (!versions.containsKey(v)) {
-          versions.put(v, new Version<V>(
-              null, v, true, cfg.getReady(def.getName(), v)));
+          versions.put(v, new Version<V>(null, v, true, cfg.getReady(def.getName(), v)));
         }
       }
     } catch (IOException e) {
@@ -265,33 +89,4 @@
     }
     return versions;
   }
-
-  private <V> void markNotReady(GerritIndexStatus cfg, String name,
-      Iterable<Version<V>> versions, Collection<Version<V>> inUse) {
-    boolean dirty = false;
-    for (Version<V> v : versions) {
-      if (!inUse.contains(v) && v.exists) {
-        cfg.setReady(name, v.version, false);
-        dirty = true;
-      }
-    }
-    if (dirty) {
-      try {
-        cfg.save();
-      } catch (IOException e) {
-        throw fail(e);
-      }
-    }
-  }
-
-  private ProvisionException fail(Throwable t) {
-    ProvisionException e = new ProvisionException("Error scanning indexes");
-    e.initCause(t);
-    throw e;
-  }
-
-  @Override
-  public void stop() {
-    // Do nothing; indexes are closed on demand by IndexCollection.
-  }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
index a993b49..74111a0 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST_NOT;
 import static org.apache.lucene.search.BooleanClause.Occur.SHOULD;
@@ -29,9 +30,11 @@
 import com.google.gerrit.server.query.AndPredicate;
 import com.google.gerrit.server.query.NotPredicate;
 import com.google.gerrit.server.query.OrPredicate;
+import com.google.gerrit.server.query.PostFilterPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
-
+import java.util.Date;
+import java.util.List;
 import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.BooleanQuery;
@@ -44,9 +47,6 @@
 import org.apache.lucene.util.BytesRefBuilder;
 import org.apache.lucene.util.NumericUtils;
 
-import java.util.Date;
-import java.util.List;
-
 public class QueryBuilder<V> {
   static Term intTerm(String name, int value) {
     BytesRefBuilder builder = new BytesRefBuilder();
@@ -54,6 +54,12 @@
     return new Term(name, builder.get());
   }
 
+  static Term stringTerm(String name, String value) {
+    BytesRefBuilder builder = new BytesRefBuilder();
+    builder.append(value.getBytes(UTF_8), 0, value.length());
+    return new Term(name, builder.get());
+  }
+
   private final Schema<V> schema;
   private final org.apache.lucene.util.QueryBuilder queryBuilder;
 
@@ -71,13 +77,14 @@
       return not(p);
     } else if (p instanceof IndexPredicate) {
       return fieldQuery((IndexPredicate<V>) p);
+    } else if (p instanceof PostFilterPredicate) {
+      return new MatchAllDocsQuery();
     } else {
       throw new QueryParseException("cannot create query for index: " + p);
     }
   }
 
-  private Query or(Predicate<V> p)
-      throws QueryParseException {
+  private Query or(Predicate<V> p) throws QueryParseException {
     try {
       BooleanQuery.Builder q = new BooleanQuery.Builder();
       for (int i = 0; i < p.getChildCount(); i++) {
@@ -89,8 +96,7 @@
     }
   }
 
-  private Query and(Predicate<V> p)
-      throws QueryParseException {
+  private Query and(Predicate<V> p) throws QueryParseException {
     try {
       BooleanQuery.Builder b = new BooleanQuery.Builder();
       List<Query> not = Lists.newArrayListWithCapacity(p.getChildCount());
@@ -116,8 +122,7 @@
     }
   }
 
-  private Query not(Predicate<V> p)
-      throws QueryParseException {
+  private Query not(Predicate<V> p) throws QueryParseException {
     Predicate<V> n = p.getChild(0);
     if (n instanceof TimestampRangePredicate) {
       return notTimestamp((TimestampRangePredicate<V>) n);
@@ -125,35 +130,36 @@
 
     // Lucene does not support negation, start with all and subtract.
     return new BooleanQuery.Builder()
-      .add(new MatchAllDocsQuery(), MUST)
-      .add(toQuery(n), MUST_NOT)
-      .build();
+        .add(new MatchAllDocsQuery(), MUST)
+        .add(toQuery(n), MUST_NOT)
+        .build();
   }
 
-  private Query fieldQuery(IndexPredicate<V> p)
-      throws QueryParseException {
-    checkArgument(schema.hasField(p.getField()),
-        "field not in schema v%s: %s", schema.getVersion(),
+  private Query fieldQuery(IndexPredicate<V> p) throws QueryParseException {
+    checkArgument(
+        schema.hasField(p.getField()),
+        "field not in schema v%s: %s",
+        schema.getVersion(),
         p.getField().getName());
-    if (p.getType() == FieldType.INTEGER) {
+    FieldType<?> type = p.getType();
+    if (type == FieldType.INTEGER) {
       return intQuery(p);
-    } else if (p.getType() == FieldType.INTEGER_RANGE) {
+    } else if (type == FieldType.INTEGER_RANGE) {
       return intRangeQuery(p);
-    } else if (p.getType() == FieldType.TIMESTAMP) {
+    } else if (type == FieldType.TIMESTAMP) {
       return timestampQuery(p);
-    } else if (p.getType() == FieldType.EXACT) {
+    } else if (type == FieldType.EXACT) {
       return exactQuery(p);
-    } else if (p.getType() == FieldType.PREFIX) {
+    } else if (type == FieldType.PREFIX) {
       return prefixQuery(p);
-    } else if (p.getType() == FieldType.FULL_TEXT) {
+    } else if (type == FieldType.FULL_TEXT) {
       return fullTextQuery(p);
     } else {
-      throw FieldType.badFieldType(p.getType());
+      throw FieldType.badFieldType(type);
     }
   }
 
-  private Query intQuery(IndexPredicate<V> p)
-      throws QueryParseException {
+  private Query intQuery(IndexPredicate<V> p) throws QueryParseException {
     int value;
     try {
       // Can't use IntPredicate because it and IndexPredicate are different
@@ -165,49 +171,37 @@
     return new TermQuery(intTerm(p.getField().getName(), value));
   }
 
-  private Query intRangeQuery(IndexPredicate<V> p)
-      throws QueryParseException {
+  private Query intRangeQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof IntegerRangePredicate) {
-      IntegerRangePredicate<V> r =
-          (IntegerRangePredicate<V>) p;
+      IntegerRangePredicate<V> r = (IntegerRangePredicate<V>) p;
       int minimum = r.getMinimumValue();
       int maximum = r.getMaximumValue();
       if (minimum == maximum) {
         // Just fall back to a standard integer query.
         return new TermQuery(intTerm(p.getField().getName(), minimum));
       }
-      return NumericRangeQuery.newIntRange(
-          r.getField().getName(),
-          minimum,
-          maximum,
-          true,
-          true);
+      return NumericRangeQuery.newIntRange(r.getField().getName(), minimum, maximum, true, true);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
 
-  private Query timestampQuery(IndexPredicate<V> p)
-      throws QueryParseException {
+  private Query timestampQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
-      TimestampRangePredicate<V> r =
-          (TimestampRangePredicate<V>) p;
+      TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
       return NumericRangeQuery.newLongRange(
           r.getField().getName(),
           r.getMinTimestamp().getTime(),
           r.getMaxTimestamp().getTime(),
-          true, true);
+          true,
+          true);
     }
     throw new QueryParseException("not a timestamp: " + p);
   }
 
-  private Query notTimestamp(TimestampRangePredicate<V> r)
-      throws QueryParseException {
+  private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
     if (r.getMinTimestamp().getTime() == 0) {
       return NumericRangeQuery.newLongRange(
-          r.getField().getName(),
-          r.getMaxTimestamp().getTime(),
-          null,
-          true, true);
+          r.getField().getName(), r.getMaxTimestamp().getTime(), null, true, true);
     }
     throw new QueryParseException("cannot negate: " + r);
   }
@@ -234,17 +228,14 @@
     return new PrefixQuery(new Term(p.getField().getName(), p.getValue()));
   }
 
-  private Query fullTextQuery(IndexPredicate<V> p)
-      throws QueryParseException {
+  private Query fullTextQuery(IndexPredicate<V> p) throws QueryParseException {
     String value = p.getValue();
     if (value == null) {
-      throw new QueryParseException(
-          "Full-text search over empty string not supported");
+      throw new QueryParseException("Full-text search over empty string not supported");
     }
     Query query = queryBuilder.createPhraseQuery(p.getField().getName(), value);
     if (query == null) {
-      throw new QueryParseException(
-          "Cannot create full-text query with value: " + value);
+      throw new QueryParseException("Cannot create full-text query with value: " + value);
     }
     return query;
   }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ReindexerAlreadyRunningException.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ReindexerAlreadyRunningException.java
deleted file mode 100644
index 0ca632b..0000000
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ReindexerAlreadyRunningException.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.lucene;
-
-public class ReindexerAlreadyRunningException extends Exception {
-
-  private static final long serialVersionUID = 1L;
-
-  public ReindexerAlreadyRunningException() {
-    super("Reindexer is already running.");
-  }
-}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index 458cfda..f9ecac3 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -17,6 +17,7 @@
  * limitations under the License.
  */
 
+import java.io.IOException;
 import org.apache.lucene.index.DirectoryReader;
 import org.apache.lucene.index.FilterDirectoryReader;
 import org.apache.lucene.index.FilterLeafReader;
@@ -27,16 +28,13 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.store.Directory;
 
-import java.io.IOException;
-
 /**
- * Utility class to safely share {@link IndexSearcher} instances across multiple
- * threads, while periodically reopening. This class ensures each searcher is
- * closed only once all threads have finished using it.
+ * Utility class to safely share {@link IndexSearcher} instances across multiple threads, while
+ * periodically reopening. This class ensures each searcher is closed only once all threads have
+ * finished using it.
  *
- * <p>
- * Use {@link #acquire} to obtain the current searcher, and {@link #release} to
- * release it, like this:
+ * <p>Use {@link #acquire} to obtain the current searcher, and {@link #release} to release it, like
+ * this:
  *
  * <pre class="prettyprint">
  * IndexSearcher s = manager.acquire();
@@ -49,15 +47,12 @@
  * s = null;
  * </pre>
  *
- * <p>
- * In addition you should periodically call {@link #maybeRefresh}. While it's
- * possible to call this just before running each query, this is discouraged
- * since it penalizes the unlucky queries that need to refresh. It's better to use
- * a separate background thread, that periodically calls {@link #maybeRefresh}. Finally,
- * be sure to call {@link #close} once you are done.
+ * <p>In addition you should periodically call {@link #maybeRefresh}. While it's possible to call
+ * this just before running each query, this is discouraged since it penalizes the unlucky queries
+ * that need to refresh. It's better to use a separate background thread, that periodically calls
+ * {@link #maybeRefresh}. Finally, be sure to call {@link #close} once you are done.
  *
  * @see SearcherFactory
- *
  * @lucene.experimental
  */
 // This file was copied from:
@@ -70,28 +65,22 @@
   private final SearcherFactory searcherFactory;
 
   /**
-   * Creates and returns a new SearcherManager from the given
-   * {@link IndexWriter}.
+   * Creates and returns a new SearcherManager from the given {@link IndexWriter}.
    *
-   * @param writer
-   *          the IndexWriter to open the IndexReader from.
-   * @param applyAllDeletes
-   *          If <code>true</code>, all buffered deletes will be applied (made
-   *          visible) in the {@link IndexSearcher} / {@link DirectoryReader}.
-   *          If <code>false</code>, the deletes may or may not be applied, but
-   *          remain buffered (in IndexWriter) so that they will be applied in
-   *          the future. Applying deletes can be costly, so if your app can
-   *          tolerate deleted documents being returned you might gain some
-   *          performance by passing <code>false</code>. See
-   *          {@link DirectoryReader#openIfChanged(DirectoryReader, IndexWriter, boolean)}.
-   * @param searcherFactory
-   *          An optional {@link SearcherFactory}. Pass <code>null</code> if you
-   *          don't require the searcher to be warmed before going live or other
-   *          custom behavior.
-   *
+   * @param writer the IndexWriter to open the IndexReader from.
+   * @param applyAllDeletes If <code>true</code>, all buffered deletes will be applied (made
+   *     visible) in the {@link IndexSearcher} / {@link DirectoryReader}. If <code>false</code>, the
+   *     deletes may or may not be applied, but remain buffered (in IndexWriter) so that they will
+   *     be applied in the future. Applying deletes can be costly, so if your app can tolerate
+   *     deleted documents being returned you might gain some performance by passing <code>false
+   *     </code>. See {@link DirectoryReader#openIfChanged(DirectoryReader, IndexWriter, boolean)}.
+   * @param searcherFactory An optional {@link SearcherFactory}. Pass <code>null</code> if you don't
+   *     require the searcher to be warmed before going live or other custom behavior.
    * @throws IOException if there is a low-level I/O error
    */
-  WrappableSearcherManager(IndexWriter writer, boolean applyAllDeletes, SearcherFactory searcherFactory) throws IOException {
+  WrappableSearcherManager(
+      IndexWriter writer, boolean applyAllDeletes, SearcherFactory searcherFactory)
+      throws IOException {
     if (searcherFactory == null) {
       searcherFactory = new SearcherFactory();
     }
@@ -101,11 +90,10 @@
 
   /**
    * Creates and returns a new SearcherManager from the given {@link Directory}.
-   * @param dir the directory to open the DirectoryReader on.
-   * @param searcherFactory An optional {@link SearcherFactory}. Pass
-   *        <code>null</code> if you don't require the searcher to be warmed
-   *        before going live or other custom behavior.
    *
+   * @param dir the directory to open the DirectoryReader on.
+   * @param searcherFactory An optional {@link SearcherFactory}. Pass <code>null</code> if you don't
+   *     require the searcher to be warmed before going live or other custom behavior.
    * @throws IOException if there is a low-level I/O error
    */
   WrappableSearcherManager(Directory dir, SearcherFactory searcherFactory) throws IOException {
@@ -117,17 +105,16 @@
   }
 
   /**
-   * Creates and returns a new SearcherManager from an existing {@link DirectoryReader}.  Note that
+   * Creates and returns a new SearcherManager from an existing {@link DirectoryReader}. Note that
    * this steals the incoming reference.
    *
    * @param reader the DirectoryReader.
-   * @param searcherFactory An optional {@link SearcherFactory}. Pass
-   *        <code>null</code> if you don't require the searcher to be warmed
-   *        before going live or other custom behavior.
-   *
+   * @param searcherFactory An optional {@link SearcherFactory}. Pass <code>null</code> if you don't
+   *     require the searcher to be warmed before going live or other custom behavior.
    * @throws IOException if there is a low-level I/O error
    */
-  WrappableSearcherManager(DirectoryReader reader, SearcherFactory searcherFactory) throws IOException {
+  WrappableSearcherManager(DirectoryReader reader, SearcherFactory searcherFactory)
+      throws IOException {
     if (searcherFactory == null) {
       searcherFactory = new SearcherFactory();
     }
@@ -143,8 +130,8 @@
   @Override
   protected IndexSearcher refreshIfNeeded(IndexSearcher referenceToRefresh) throws IOException {
     final IndexReader r = referenceToRefresh.getIndexReader();
-    assert r instanceof DirectoryReader :
-      "searcher's IndexReader should be a DirectoryReader, but got " + r;
+    assert r instanceof DirectoryReader
+        : "searcher's IndexReader should be a DirectoryReader, but got " + r;
     final IndexReader newReader = DirectoryReader.openIfChanged((DirectoryReader) r);
     if (newReader == null) {
       return null;
@@ -163,28 +150,30 @@
   }
 
   /**
-   * Returns <code>true</code> if no changes have occured since this searcher
-   * ie. reader was opened, otherwise <code>false</code>.
+   * Returns <code>true</code> if no changes have occured since this searcher ie. reader was opened,
+   * otherwise <code>false</code>.
+   *
    * @see DirectoryReader#isCurrent()
    */
   public boolean isSearcherCurrent() throws IOException {
     final IndexSearcher searcher = acquire();
     try {
       final IndexReader r = searcher.getIndexReader();
-      assert r instanceof DirectoryReader :
-        "searcher's IndexReader should be a DirectoryReader, but got " + r;
+      assert r instanceof DirectoryReader
+          : "searcher's IndexReader should be a DirectoryReader, but got " + r;
       return ((DirectoryReader) r).isCurrent();
     } finally {
       release(searcher);
     }
   }
 
-  /** Expert: creates a searcher from the provided {@link
-   *  IndexReader} using the provided {@link
-   *  SearcherFactory}.  NOTE: this decRefs incoming reader
-   * on throwing an exception. */
+  /**
+   * Expert: creates a searcher from the provided {@link IndexReader} using the provided {@link
+   * SearcherFactory}. NOTE: this decRefs incoming reader on throwing an exception.
+   */
   @SuppressWarnings("resource")
-  public static IndexSearcher getSearcher(SearcherFactory searcherFactory, IndexReader reader) throws IOException {
+  public static IndexSearcher getSearcher(SearcherFactory searcherFactory, IndexReader reader)
+      throws IOException {
     boolean success = false;
     final IndexSearcher searcher;
     try {
@@ -206,9 +195,11 @@
 
       if (unwrapped != reader) {
         throw new IllegalStateException(
-            "SearcherFactory must wrap the provided reader (got " +
-            searcher.getIndexReader() +
-            " but expected " + reader + ")");
+            "SearcherFactory must wrap the provided reader (got "
+                + searcher.getIndexReader()
+                + " but expected "
+                + reader
+                + ")");
       }
       success = true;
     } finally {
diff --git a/gerrit-main/BUCK b/gerrit-main/BUCK
deleted file mode 100644
index 388126e..0000000
--- a/gerrit-main/BUCK
+++ /dev/null
@@ -1,15 +0,0 @@
-java_binary(
-  name = 'main_bin',
-  main_class = 'Main',
-  deps = [':main_lib'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'main_lib',
-  srcs = ['src/main/java/Main.java'],
-  deps = ['//gerrit-launcher:launcher'],
-  source = '1.2',
-  target = '1.2',
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-main/BUILD b/gerrit-main/BUILD
new file mode 100644
index 0000000..0b88b9a
--- /dev/null
+++ b/gerrit-main/BUILD
@@ -0,0 +1,15 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+
+java_binary(
+    name = "main_bin",
+    main_class = "Main",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":main_lib"],
+)
+
+java_library(
+    name = "main_lib",
+    srcs = ["src/main/java/Main.java"],
+    visibility = ["//visibility:public"],
+    deps = ["//gerrit-launcher:launcher"],
+)
diff --git a/gerrit-main/src/main/java/Main.java b/gerrit-main/src/main/java/Main.java
index 58de6a4..8c9deb1 100644
--- a/gerrit-main/src/main/java/Main.java
+++ b/gerrit-main/src/main/java/Main.java
@@ -12,7 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-
 public final class Main {
   // We don't do any real work here because we need to import
   // the archive lookup code and we cannot import a class in
@@ -33,7 +32,6 @@
     final String version = System.getProperty("java.specification.version");
     if (1.8 <= parse(version)) {
       return true;
-
     }
     System.err.println("fatal: Gerrit Code Review requires Java 8 or later");
     System.err.println("       (trying to run on Java " + version + ")");
@@ -57,6 +55,5 @@
     }
   }
 
-  private Main() {
-  }
+  private Main() {}
 }
diff --git a/gerrit-oauth/BUCK b/gerrit-oauth/BUCK
deleted file mode 100644
index fa5a8e2..0000000
--- a/gerrit-oauth/BUCK
+++ /dev/null
@@ -1,26 +0,0 @@
-SRCS = glob(
-  ['src/main/java/**/*.java'],
-)
-RESOURCES = glob(['src/main/resources/**/*'])
-
-java_library(
-  name = 'oauth',
-  srcs = SRCS,
-  resources = RESOURCES,
-  deps = [
-    '//gerrit-common:annotations',
-    '//gerrit-extension-api:api',
-    '//gerrit-httpd:httpd',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib/commons:codec',
-    '//lib/guice:guice',
-    '//lib/guice:guice-servlet',
-    '//lib/log:api',
-  ],
-  provided_deps = ['//lib:servlet-api-3_1'],
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-oauth/BUILD b/gerrit-oauth/BUILD
index b2cf17b..2da0662 100644
--- a/gerrit-oauth/BUILD
+++ b/gerrit-oauth/BUILD
@@ -1,26 +1,30 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 SRCS = glob(
-  ['src/main/java/**/*.java'],
+    ["src/main/java/**/*.java"],
 )
-RESOURCES = glob(['src/main/resources/**/*'])
+
+RESOURCES = glob(["src/main/resources/**/*"])
 
 java_library(
-  name = 'oauth',
-  srcs = SRCS,
-  resources = RESOURCES,
-  deps = [
-    '//gerrit-common:annotations',
-    '//gerrit-extension-api:api',
-    '//gerrit-httpd:httpd',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib/commons:codec',
-    '//lib/guice:guice',
-    '//lib/guice:guice-servlet',
-    '//lib/log:api',
-    '//lib:servlet-api-3_1',
-  ],
-  visibility = ['//visibility:public'],
+    name = "oauth",
+    srcs = SRCS,
+    resources = RESOURCES,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-common:annotations",
+        "//gerrit-extension-api:api",
+        "//gerrit-httpd:httpd",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
 )
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
index 36bca15..126a1d7 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthLogoutServlet.java
@@ -24,9 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
@@ -37,7 +35,8 @@
   private final Provider<OAuthSession> oauthSession;
 
   @Inject
-  OAuthLogoutServlet(AuthConfig authConfig,
+  OAuthLogoutServlet(
+      AuthConfig authConfig,
       DynamicItem<WebSession> webSession,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       AuditService audit,
@@ -47,12 +46,10 @@
   }
 
   @Override
-  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException {
+  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     super.doLogout(req, rsp);
     if (req.getSession(false) != null) {
       oauthSession.get().logout();
     }
   }
 }
-
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index 35f79c9..ab69dde 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -16,6 +16,7 @@
 
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -31,23 +32,22 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
-
-import org.apache.commons.codec.binary.Base64;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
-
+import java.util.Optional;
 import javax.servlet.ServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.apache.commons.codec.binary.Base64;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @SessionScoped
 /* OAuth protocol implementation */
@@ -67,7 +67,8 @@
   private boolean linkMode;
 
   @Inject
-  OAuthSession(DynamicItem<WebSession> webSession,
+  OAuthSession(
+      DynamicItem<WebSession> webSession,
       Provider<IdentifiedUser> identifiedUser,
       AccountManager accountManager,
       CanonicalWebUrl urlProvider,
@@ -88,8 +89,9 @@
     return Strings.emptyToNull(request.getParameter("code")) != null;
   }
 
-  boolean login(HttpServletRequest request, HttpServletResponse response,
-      OAuthServiceProvider oauth) throws IOException {
+  boolean login(
+      HttpServletRequest request, HttpServletResponse response, OAuthServiceProvider oauth)
+      throws IOException {
     log.debug("Login " + this);
 
     if (isOAuthFinal(request)) {
@@ -99,8 +101,7 @@
       }
 
       log.debug("Login-Retrieve-User " + this);
-      OAuthToken token = oauth.getAccessToken(
-          new OAuthVerifier(request.getParameter("code")));
+      OAuthToken token = oauth.getAccessToken(new OAuthVerifier(request.getParameter("code")));
       user = oauth.getUserInfo(token);
 
       if (isLoggedIn()) {
@@ -118,22 +119,19 @@
     // https://bz.apache.org/bugzilla/show_bug.cgi?id=28323
     // we cannot use LoginUrlToken.getToken() method,
     // because it relies on getPathInfo() and it is always null here.
-    redirectToken = redirectToken.substring(
-        request.getContextPath().length());
-    response.sendRedirect(oauth.getAuthorizationUrl() +
-        "&state=" + state);
+    redirectToken = redirectToken.substring(request.getContextPath().length());
+    response.sendRedirect(oauth.getAuthorizationUrl() + "&state=" + state);
     return false;
   }
 
-  private void authenticateAndRedirect(HttpServletRequest req,
-      HttpServletResponse rsp, OAuthToken token) throws IOException {
-    AuthRequest areq = new AuthRequest(user.getExternalId());
+  private void authenticateAndRedirect(
+      HttpServletRequest req, HttpServletResponse rsp, OAuthToken token) throws IOException {
+    AuthRequest areq = new AuthRequest(ExternalId.Key.parse(user.getExternalId()));
     AuthResult arsp;
     try {
       String claimedIdentifier = user.getClaimedIdentity();
       if (!Strings.isNullOrEmpty(claimedIdentifier)) {
-        if (!authenticateWithIdentityClaimedDuringHandshake(areq, rsp,
-            claimedIdentifier)) {
+        if (!authenticateWithIdentityClaimedDuringHandshake(areq, rsp, claimedIdentifier)) {
           return;
         }
       } else if (linkMode) {
@@ -155,44 +153,55 @@
     }
 
     webSession.get().login(arsp, true);
-    String suffix = redirectToken.substring(
-        OAuthWebFilter.GERRIT_LOGIN.length() + 1);
+    String suffix = redirectToken.substring(OAuthWebFilter.GERRIT_LOGIN.length() + 1);
+    suffix = CharMatcher.anyOf("/").trimLeadingFrom(Url.decode(suffix));
     StringBuilder rdr = new StringBuilder(urlProvider.get(req));
-    rdr.append(Url.decode(suffix));
+    rdr.append(suffix);
     rsp.sendRedirect(rdr.toString());
   }
 
   private boolean authenticateWithIdentityClaimedDuringHandshake(
       AuthRequest req, HttpServletResponse rsp, String claimedIdentifier)
       throws AccountException, IOException {
-    Account.Id claimedId = accountManager.lookup(claimedIdentifier);
-    Account.Id actualId = accountManager.lookup(user.getExternalId());
-    if (claimedId != null && actualId != null) {
-      if (claimedId.equals(actualId)) {
+    Optional<Account.Id> claimedId = accountManager.lookup(claimedIdentifier);
+    Optional<Account.Id> actualId = accountManager.lookup(user.getExternalId());
+    if (claimedId.isPresent() && actualId.isPresent()) {
+      if (claimedId.get().equals(actualId.get())) {
         // Both link to the same account, that's what we expected.
         log.debug("OAuth2: claimed identity equals current id");
       } else {
         // This is (for now) a fatal error. There are two records
         // for what might be the same user.
         //
-        log.error("OAuth accounts disagree over user identity:\n"
-            + "  Claimed ID: " + claimedId + " is " + claimedIdentifier
-            + "\n" + "  Delgate ID: " + actualId + " is "
-            + user.getExternalId());
+        log.error(
+            "OAuth accounts disagree over user identity:\n"
+                + "  Claimed ID: "
+                + claimedId.get()
+                + " is "
+                + claimedIdentifier
+                + "\n"
+                + "  Delgate ID: "
+                + actualId.get()
+                + " is "
+                + user.getExternalId());
         rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
         return false;
       }
-    } else if (claimedId != null && actualId == null) {
+    } else if (claimedId.isPresent() && !actualId.isPresent()) {
       // Claimed account already exists: link to it.
       //
-      log.info("OAuth2: linking claimed identity to {}",
-          claimedId.toString());
+      log.info("OAuth2: linking claimed identity to {}", claimedId.get().toString());
       try {
-        accountManager.link(claimedId, req);
+        accountManager.link(claimedId.get(), req);
       } catch (OrmException e) {
-        log.error("Cannot link: " +  user.getExternalId()
-            + " to user identity:\n"
-            + "  Claimed ID: " + claimedId + " is " + claimedIdentifier);
+        log.error(
+            "Cannot link: "
+                + user.getExternalId()
+                + " to user identity:\n"
+                + "  Claimed ID: "
+                + claimedId.get()
+                + " is "
+                + claimedIdentifier);
         rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
         return false;
       }
@@ -200,13 +209,16 @@
     return true;
   }
 
-  private boolean authenticateWithLinkedIdentity(AuthRequest areq,
-      HttpServletResponse rsp) throws AccountException, IOException {
+  private boolean authenticateWithLinkedIdentity(AuthRequest areq, HttpServletResponse rsp)
+      throws AccountException, IOException {
     try {
       accountManager.link(identifiedUser.get().getAccountId(), areq);
     } catch (OrmException e) {
-      log.error("Cannot link: " + user.getExternalId()
-          + " to user identity: " + identifiedUser.get().getAccountId());
+      log.error(
+          "Cannot link: "
+              + user.getExternalId()
+              + " to user identity: "
+              + identifiedUser.get().getAccountId());
       rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
       return false;
     } finally {
@@ -238,8 +250,7 @@
     try {
       return SecureRandom.getInstance("SHA1PRNG");
     } catch (NoSuchAlgorithmException e) {
-      throw new IllegalArgumentException(
-          "No SecureRandom available for GitHub authentication", e);
+      throw new IllegalArgumentException("No SecureRandom available for GitHub authentication", e);
     }
   }
 
@@ -251,8 +262,7 @@
 
   @Override
   public String toString() {
-    return "OAuthSession [token=" + tokenCache.get(accountId) + ", user="
-        + user + "]";
+    return "OAuthSession [token=" + tokenCache.get(accountId) + ", user=" + user + "]";
   }
 
   public void setServiceProvider(OAuthServiceProvider provider) {
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
index 4c6c0b0..6ec5f3b 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
@@ -29,16 +29,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-
 import java.io.IOException;
 import java.util.Map;
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.SortedSet;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -48,6 +43,8 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
 
 @Singleton
 /* OAuth web filter uses active OAuth session to perform OAuth requests */
@@ -61,7 +58,8 @@
   private OAuthServiceProvider ssoProvider;
 
   @Inject
-  OAuthWebFilter(@CanonicalWebUrl @Nullable Provider<String> urlProvider,
+  OAuthWebFilter(
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       DynamicMap<OAuthServiceProvider> oauthServiceProviders,
       Provider<OAuthSession> oauthSessionProvider,
       SiteHeaderFooter header) {
@@ -77,12 +75,11 @@
   }
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     HttpServletRequest httpRequest = (HttpServletRequest) request;
     HttpServletResponse httpResponse = (HttpServletResponse) response;
 
@@ -93,9 +90,8 @@
     }
 
     String provider = httpRequest.getParameter("provider");
-    OAuthServiceProvider service = ssoProvider == null
-        ? oauthSession.getServiceProvider()
-        : ssoProvider;
+    OAuthServiceProvider service =
+        ssoProvider == null ? oauthSession.getServiceProvider() : ssoProvider;
 
     if (isGerritLogin(httpRequest) || oauthSession.isOAuthFinal(httpRequest)) {
       if (service == null && Strings.isNullOrEmpty(provider)) {
@@ -112,29 +108,24 @@
     }
   }
 
-  private OAuthServiceProvider findService(String providerId)
-      throws ServletException {
+  private OAuthServiceProvider findService(String providerId) throws ServletException {
     Set<String> plugins = oauthServiceProviders.plugins();
     for (String pluginName : plugins) {
-      Map<String, Provider<OAuthServiceProvider>> m =
-          oauthServiceProviders.byPlugin(pluginName);
-        for (Map.Entry<String, Provider<OAuthServiceProvider>> e
-            : m.entrySet()) {
-          if (providerId.equals(
-              String.format("%s_%s", pluginName, e.getKey()))) {
-            return e.getValue().get();
-          }
+      Map<String, Provider<OAuthServiceProvider>> m = oauthServiceProviders.byPlugin(pluginName);
+      for (Map.Entry<String, Provider<OAuthServiceProvider>> e : m.entrySet()) {
+        if (providerId.equals(String.format("%s_%s", pluginName, e.getKey()))) {
+          return e.getValue().get();
         }
+      }
     }
     throw new ServletException("No provider found for: " + providerId);
   }
 
-  private void selectProvider(HttpServletRequest req, HttpServletResponse res,
-      @Nullable String errorMessage)
+  private void selectProvider(
+      HttpServletRequest req, HttpServletResponse res, @Nullable String errorMessage)
       throws IOException {
     String self = req.getRequestURI();
-    String cancel = MoreObjects.firstNonNull(
-        urlProvider != null ? urlProvider.get() : "/", "/");
+    String cancel = MoreObjects.firstNonNull(urlProvider != null ? urlProvider.get() : "/", "/");
     cancel += LoginUrlToken.getToken(req);
 
     Document doc = header.parse(OAuthWebFilter.class, "LoginForm.html");
@@ -153,33 +144,26 @@
 
     Set<String> plugins = oauthServiceProviders.plugins();
     for (String pluginName : plugins) {
-      Map<String, Provider<OAuthServiceProvider>> m =
-          oauthServiceProviders.byPlugin(pluginName);
-        for (Map.Entry<String, Provider<OAuthServiceProvider>> e
-            : m.entrySet()) {
-          addProvider(providers, pluginName, e.getKey(),
-              e.getValue().get().getName());
-        }
+      Map<String, Provider<OAuthServiceProvider>> m = oauthServiceProviders.byPlugin(pluginName);
+      for (Map.Entry<String, Provider<OAuthServiceProvider>> e : m.entrySet()) {
+        addProvider(providers, pluginName, e.getKey(), e.getValue().get().getName());
+      }
     }
 
     sendHtml(res, doc);
   }
 
-  private static void addProvider(Element form, String pluginName,
-      String id, String serviceName) {
+  private static void addProvider(Element form, String pluginName, String id, String serviceName) {
     Element div = form.getOwnerDocument().createElement("div");
     div.setAttribute("id", id);
     Element hyperlink = form.getOwnerDocument().createElement("a");
-    hyperlink.setAttribute("href", String.format("?provider=%s_%s",
-        pluginName, id));
-    hyperlink.setTextContent(serviceName +
-        " (" + pluginName + " plugin)");
+    hyperlink.setAttribute("href", String.format("?provider=%s_%s", pluginName, id));
+    hyperlink.setTextContent(serviceName + " (" + pluginName + " plugin)");
     div.appendChild(hyperlink);
     form.appendChild(div);
   }
 
-  private static void sendHtml(HttpServletResponse res, Document doc)
-      throws IOException {
+  private static void sendHtml(HttpServletResponse res, Document doc) throws IOException {
     byte[] bin = HtmlDomUtil.toUTF8(doc);
     res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
     res.setContentType("text/html");
@@ -190,12 +174,10 @@
     }
   }
 
-  private void pickSSOServiceProvider()
-      throws ServletException {
+  private void pickSSOServiceProvider() throws ServletException {
     SortedSet<String> plugins = oauthServiceProviders.plugins();
     if (plugins.isEmpty()) {
-      throw new ServletException(
-          "OAuth service provider wasn't installed");
+      throw new ServletException("OAuth service provider wasn't installed");
     }
     if (plugins.size() == 1) {
       SortedMap<String, Provider<OAuthServiceProvider>> services =
diff --git a/gerrit-openid/BUCK b/gerrit-openid/BUCK
deleted file mode 100644
index 5eace7b..0000000
--- a/gerrit-openid/BUCK
+++ /dev/null
@@ -1,26 +0,0 @@
-java_library(
-  name = 'openid',
-  srcs = glob(['src/main/java/**/*.java']),
-  resources = glob(['src/main/resources/**/*']),
-  deps = [
-    '//lib/openid:consumer',
-  ],
-  provided_deps = [
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gwtexpui:server',
-    '//gerrit-httpd:httpd',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib:servlet-api-3_1',
-    '//lib/commons:codec',
-    '//lib/guice:guice',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/log:api',
-  ],
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-openid/BUILD b/gerrit-openid/BUILD
index b5ae049..3eb8ceb 100644
--- a/gerrit-openid/BUILD
+++ b/gerrit-openid/BUILD
@@ -1,24 +1,27 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'openid',
-  srcs = glob(['src/main/java/**/*.java']),
-  resources = glob(['src/main/resources/**/*']),
-  deps = [ # We want all these deps to be provided_deps
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gwtexpui:server',
-    '//gerrit-httpd:httpd',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib:servlet-api-3_1',
-    '//lib/commons:codec',
-    '//lib/guice:guice',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/log:api',
-    '//lib/openid:consumer',
-  ],
-  visibility = ['//visibility:public'],
+    name = "openid",
+    srcs = glob(["src/main/java/**/*.java"]),
+    resources = glob(["src/main/resources/**/*"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        # We want all these deps to be provided_deps
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-gwtexpui:server",
+        "//gerrit-httpd:httpd",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/commons:codec",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/openid:consumer",
+    ],
 )
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
index 2ee3b6b..889e070 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/DiscoveryResult.java
@@ -32,8 +32,7 @@
   String providerUrl;
   Map<String, String> providerArgs;
 
-  DiscoveryResult() {
-  }
+  DiscoveryResult() {}
 
   DiscoveryResult(String redirect, Map<String, String> args) {
     status = Status.VALID;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index 3a40252..6202cfc 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -24,12 +24,12 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.auth.openid.OpenIdUrls;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.LoginUrlToken;
 import com.google.gerrit.httpd.template.SiteHeaderFooter;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
@@ -37,32 +37,30 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-
-import javax.servlet.ServletOutputStream;
-import javax.servlet.http.Cookie;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
 /** Handles OpenID based login flow. */
 @SuppressWarnings("serial")
 @Singleton
 class LoginForm extends HttpServlet {
   private static final Logger log = LoggerFactory.getLogger(LoginForm.class);
-  private static final ImmutableMap<String, String> ALL_PROVIDERS = ImmutableMap.of(
-      "launchpad", OpenIdUrls.URL_LAUNCHPAD,
-      "yahoo", OpenIdUrls.URL_YAHOO);
+  private static final ImmutableMap<String, String> ALL_PROVIDERS =
+      ImmutableMap.of(
+          "launchpad", OpenIdUrls.URL_LAUNCHPAD,
+          "yahoo", OpenIdUrls.URL_YAHOO);
 
   private final ImmutableSet<String> suggestProviders;
   private final Provider<String> urlProvider;
@@ -87,9 +85,7 @@
     this.urlProvider = urlProvider;
     this.impl = impl;
     this.header = header;
-    this.maxRedirectUrlLength = config.getInt(
-        "openid", "maxRedirectUrlLength",
-        10);
+    this.maxRedirectUrlLength = config.getInt("openid", "maxRedirectUrlLength", 10);
     this.oauthSessionProvider = oauthSessionProvider;
     this.currentUserProvider = currentUserProvider;
     this.oauthServiceProviders = oauthServiceProviders;
@@ -114,8 +110,7 @@
   }
 
   @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse res)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
     if (ssoUrl != null) {
       String token = LoginUrlToken.getToken(req);
       SignInMode mode;
@@ -138,8 +133,7 @@
   }
 
   @Override
-  protected void doPost(HttpServletRequest req, HttpServletResponse res)
-      throws IOException {
+  protected void doPost(HttpServletRequest req, HttpServletResponse res) throws IOException {
     boolean link = req.getParameter("link") != null;
     String id = Strings.nullToEmpty(req.getParameter("id")).trim();
     if (id.isEmpty()) {
@@ -175,12 +169,10 @@
     } else {
       log.debug("OAuth provider \"{}\"", id);
       OAuthSessionOverOpenID oauthSession = oauthSessionProvider.get();
-      if (!currentUserProvider.get().isIdentifiedUser()
-          && oauthSession.isLoggedIn()) {
+      if (!currentUserProvider.get().isIdentifiedUser() && oauthSession.isLoggedIn()) {
         oauthSession.logout();
       }
-      if ((isGerritLogin(req)
-          || oauthSession.isOAuthFinal(req))) {
+      if ((isGerritLogin(req) || oauthSession.isOAuthFinal(req))) {
         oauthSession.setServiceProvider(oauthProvider);
         oauthSession.setLinkMode(link);
         oauthSession.login(req, res, oauthProvider);
@@ -188,8 +180,14 @@
     }
   }
 
-  private void discover(HttpServletRequest req, HttpServletResponse res,
-      boolean link, String id, boolean remember, String token, SignInMode mode)
+  private void discover(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      boolean link,
+      String id,
+      boolean remember,
+      String token,
+      SignInMode mode)
       throws IOException {
     if (ssoUrl != null) {
       remember = false;
@@ -202,8 +200,7 @@
         break;
 
       case NO_PROVIDER:
-        sendForm(req, res, link,
-            "Provider is not supported, or was incorrectly entered.");
+        sendForm(req, res, link, "Provider is not supported, or was incorrectly entered.");
         break;
 
       case ERROR:
@@ -212,8 +209,7 @@
     }
   }
 
-  private void redirect(DiscoveryResult r, HttpServletResponse res)
-      throws IOException {
+  private void redirect(DiscoveryResult r, HttpServletResponse res) throws IOException {
     StringBuilder url = new StringBuilder();
     url.append(r.providerUrl);
     if (r.providerArgs != null && !r.providerArgs.isEmpty()) {
@@ -225,9 +221,7 @@
         } else {
           url.append('&');
         }
-        url.append(Url.encode(arg.getKey()))
-           .append('=')
-           .append(Url.encode(arg.getValue()));
+        url.append(Url.encode(arg.getKey())).append('=').append(Url.encode(arg.getValue()));
       }
     }
     if (url.length() <= maxRedirectUrlLength) {
@@ -250,11 +244,11 @@
     sendHtml(res, doc);
   }
 
-  private void sendForm(HttpServletRequest req, HttpServletResponse res,
-      boolean link, @Nullable String errorMessage) throws IOException {
+  private void sendForm(
+      HttpServletRequest req, HttpServletResponse res, boolean link, @Nullable String errorMessage)
+      throws IOException {
     String self = req.getRequestURI();
-    String cancel = MoreObjects.firstNonNull(
-        urlProvider != null ? urlProvider.get() : "/", "/");
+    String cancel = MoreObjects.firstNonNull(urlProvider != null ? urlProvider.get() : "/", "/");
     cancel += LoginUrlToken.getToken(req);
 
     Document doc = header.parse(LoginForm.class, "LoginForm.html");
@@ -305,20 +299,16 @@
     Element providers = HtmlDomUtil.find(doc, "providers");
     Set<String> plugins = oauthServiceProviders.plugins();
     for (String pluginName : plugins) {
-      Map<String, Provider<OAuthServiceProvider>> m =
-          oauthServiceProviders.byPlugin(pluginName);
-        for (Map.Entry<String, Provider<OAuthServiceProvider>> e
-            : m.entrySet()) {
-          addProvider(providers, link, pluginName, e.getKey(),
-              e.getValue().get().getName());
-        }
+      Map<String, Provider<OAuthServiceProvider>> m = oauthServiceProviders.byPlugin(pluginName);
+      for (Map.Entry<String, Provider<OAuthServiceProvider>> e : m.entrySet()) {
+        addProvider(providers, link, pluginName, e.getKey(), e.getValue().get().getName());
+      }
     }
 
     sendHtml(res, doc);
   }
 
-  private void sendHtml(HttpServletResponse res, Document doc)
-      throws IOException {
+  private void sendHtml(HttpServletResponse res, Document doc) throws IOException {
     byte[] bin = HtmlDomUtil.toUTF8(doc);
     res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
     res.setContentType("text/html");
@@ -329,20 +319,18 @@
     }
   }
 
-  private static void addProvider(Element form, boolean link,
-      String pluginName, String id, String serviceName) {
+  private static void addProvider(
+      Element form, boolean link, String pluginName, String id, String serviceName) {
     Element div = form.getOwnerDocument().createElement("div");
     div.setAttribute("id", id);
     Element hyperlink = form.getOwnerDocument().createElement("a");
-    StringBuilder u = new StringBuilder(String.format("?id=%s_%s",
-        pluginName, id));
+    StringBuilder u = new StringBuilder(String.format("?id=%s_%s", pluginName, id));
     if (link) {
       u.append("&link");
     }
     hyperlink.setAttribute("href", u.toString());
 
-    hyperlink.setTextContent(serviceName +
-        " (" + pluginName + " plugin)");
+    hyperlink.setTextContent(serviceName + " (" + pluginName + " plugin)");
     div.appendChild(hyperlink);
     form.appendChild(div);
   }
@@ -353,15 +341,12 @@
     }
     Set<String> plugins = oauthServiceProviders.plugins();
     for (String pluginName : plugins) {
-      Map<String, Provider<OAuthServiceProvider>> m =
-          oauthServiceProviders.byPlugin(pluginName);
-        for (Map.Entry<String, Provider<OAuthServiceProvider>> e
-            : m.entrySet()) {
-          if (providerId.equals(
-              String.format("%s_%s", pluginName, e.getKey()))) {
-            return e.getValue().get();
-          }
+      Map<String, Provider<OAuthServiceProvider>> m = oauthServiceProviders.byPlugin(pluginName);
+      for (Map.Entry<String, Provider<OAuthServiceProvider>> e : m.entrySet()) {
+        if (providerId.equals(String.format("%s_%s", pluginName, e.getKey()))) {
+          return e.getValue().get();
         }
+      }
     }
     return null;
   }
@@ -379,7 +364,6 @@
   }
 
   private static boolean isGerritLogin(HttpServletRequest request) {
-    return request.getRequestURI().indexOf(
-        OAuthSessionOverOpenID.GERRIT_LOGIN) >= 0;
+    return request.getRequestURI().indexOf(OAuthSessionOverOpenID.GERRIT_LOGIN) >= 0;
   }
 }
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
index 02f428e..eecfb7f 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthOverOpenIDLogoutServlet.java
@@ -24,9 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
@@ -37,7 +35,8 @@
   private final Provider<OAuthSessionOverOpenID> oauthSession;
 
   @Inject
-  OAuthOverOpenIDLogoutServlet(AuthConfig authConfig,
+  OAuthOverOpenIDLogoutServlet(
+      AuthConfig authConfig,
       DynamicItem<WebSession> webSession,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       AuditService audit,
@@ -47,8 +46,7 @@
   }
 
   @Override
-  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException {
+  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     super.doLogout(req, rsp);
     if (req.getSession(false) != null) {
       oauthSession.get().logout();
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index 67ac895..0fdd20a 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -31,29 +31,27 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
-
-import org.apache.commons.codec.binary.Base64;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
-
+import java.util.Optional;
 import javax.servlet.ServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.apache.commons.codec.binary.Base64;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** OAuth protocol implementation */
 @SessionScoped
 class OAuthSessionOverOpenID {
   static final String GERRIT_LOGIN = "/login";
-  private static final Logger log = LoggerFactory.getLogger(
-      OAuthSessionOverOpenID.class);
+  private static final Logger log = LoggerFactory.getLogger(OAuthSessionOverOpenID.class);
   private static final SecureRandom randomState = newRandomGenerator();
   private final String state;
   private final DynamicItem<WebSession> webSession;
@@ -67,7 +65,8 @@
   private boolean linkMode;
 
   @Inject
-  OAuthSessionOverOpenID(DynamicItem<WebSession> webSession,
+  OAuthSessionOverOpenID(
+      DynamicItem<WebSession> webSession,
       Provider<IdentifiedUser> identifiedUser,
       AccountManager accountManager,
       CanonicalWebUrl urlProvider) {
@@ -86,8 +85,9 @@
     return Strings.emptyToNull(request.getParameter("code")) != null;
   }
 
-  boolean login(HttpServletRequest request, HttpServletResponse response,
-      OAuthServiceProvider oauth) throws IOException {
+  boolean login(
+      HttpServletRequest request, HttpServletResponse response, OAuthServiceProvider oauth)
+      throws IOException {
     log.debug("Login " + this);
 
     if (isOAuthFinal(request)) {
@@ -110,20 +110,20 @@
     }
     log.debug("Login-PHASE1 " + this);
     redirectToken = LoginUrlToken.getToken(request);
-    response.sendRedirect(oauth.getAuthorizationUrl() +
-        "&state=" + state);
+    response.sendRedirect(oauth.getAuthorizationUrl() + "&state=" + state);
     return false;
   }
 
-  private void authenticateAndRedirect(HttpServletRequest req,
-      HttpServletResponse rsp) throws IOException {
+  private void authenticateAndRedirect(HttpServletRequest req, HttpServletResponse rsp)
+      throws IOException {
     com.google.gerrit.server.account.AuthRequest areq =
-        new com.google.gerrit.server.account.AuthRequest(user.getExternalId());
+        new com.google.gerrit.server.account.AuthRequest(
+            ExternalId.Key.parse(user.getExternalId()));
     AuthResult arsp = null;
     try {
       String claimedIdentifier = user.getClaimedIdentity();
-      Account.Id actualId = accountManager.lookup(user.getExternalId());
-      Account.Id claimedId = null;
+      Optional<Account.Id> actualId = accountManager.lookup(user.getExternalId());
+      Optional<Account.Id> claimedId = Optional.empty();
 
       // We try to retrieve claimed identity.
       // For some reason, for example staging instance
@@ -133,27 +133,34 @@
       // That why we query it here, not to lose linking mode.
       if (!Strings.isNullOrEmpty(claimedIdentifier)) {
         claimedId = accountManager.lookup(claimedIdentifier);
-        if (claimedId == null) {
+        if (!claimedId.isPresent()) {
           log.debug("Claimed identity is unknown");
         }
       }
 
       // Use case 1: claimed identity was provided during handshake phase
       // and user account exists for this identity
-      if (claimedId != null) {
+      if (claimedId.isPresent()) {
         log.debug("Claimed identity is set and is known");
-        if (actualId != null) {
-          if (claimedId.equals(actualId)) {
+        if (actualId.isPresent()) {
+          if (claimedId.get().equals(actualId.get())) {
             // Both link to the same account, that's what we expected.
             log.debug("Both link to the same account. All is fine.");
           } else {
             // This is (for now) a fatal error. There are two records
             // for what might be the same user. The admin would have to
             // link the accounts manually.
-            log.error("OAuth accounts disagree over user identity:\n"
-                + "  Claimed ID: " + claimedId + " is " + claimedIdentifier
-                + "\n" + "  Delgate ID: " + actualId + " is "
-                + user.getExternalId());
+            log.error(
+                "OAuth accounts disagree over user identity:\n"
+                    + "  Claimed ID: "
+                    + claimedId.get()
+                    + " is "
+                    + claimedIdentifier
+                    + "\n"
+                    + "  Delgate ID: "
+                    + actualId.get()
+                    + " is "
+                    + user.getExternalId());
             rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
             return;
           }
@@ -161,11 +168,16 @@
           // Claimed account already exists: link to it.
           log.debug("Claimed account already exists: link to it.");
           try {
-            accountManager.link(claimedId, areq);
+            accountManager.link(claimedId.get(), areq);
           } catch (OrmException e) {
-            log.error("Cannot link: " +  user.getExternalId()
-                + " to user identity:\n"
-                + "  Claimed ID: " + claimedId + " is " + claimedIdentifier);
+            log.error(
+                "Cannot link: "
+                    + user.getExternalId()
+                    + " to user identity:\n"
+                    + "  Claimed ID: "
+                    + claimedId.get()
+                    + " is "
+                    + claimedIdentifier);
             rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
             return;
           }
@@ -174,12 +186,10 @@
         // Use case 2: link mode activated from the UI
         Account.Id accountId = identifiedUser.get().getAccountId();
         try {
-          log.debug("Linking \"{}\" to \"{}\"", user.getExternalId(),
-              accountId);
+          log.debug("Linking \"{}\" to \"{}\"", user.getExternalId(), accountId);
           accountManager.link(accountId, areq);
         } catch (OrmException e) {
-          log.error("Cannot link: " + user.getExternalId()
-              + " to user identity: " + accountId);
+          log.error("Cannot link: " + user.getExternalId() + " to user identity: " + accountId);
           rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
           return;
         } finally {
@@ -222,8 +232,7 @@
     try {
       return SecureRandom.getInstance("SHA1PRNG");
     } catch (NoSuchAlgorithmException e) {
-      throw new IllegalArgumentException(
-          "No SecureRandom available for GitHub authentication", e);
+      throw new IllegalArgumentException("No SecureRandom available for GitHub authentication", e);
     }
   }
 
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
index c17079d..fe3d9ee 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
@@ -20,11 +20,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.util.SortedMap;
 import java.util.SortedSet;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -34,7 +32,6 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-
 /** OAuth web filter uses active OAuth session to perform OAuth requests */
 @Singleton
 class OAuthWebFilterOverOpenID implements Filter {
@@ -45,7 +42,8 @@
   private OAuthServiceProvider ssoProvider;
 
   @Inject
-  OAuthWebFilterOverOpenID(DynamicMap<OAuthServiceProvider> oauthServiceProviders,
+  OAuthWebFilterOverOpenID(
+      DynamicMap<OAuthServiceProvider> oauthServiceProviders,
       Provider<OAuthSessionOverOpenID> oauthSessionProvider) {
     this.oauthServiceProviders = oauthServiceProviders;
     this.oauthSessionProvider = oauthSessionProvider;
@@ -57,26 +55,24 @@
   }
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     HttpServletRequest httpRequest = (HttpServletRequest) request;
     HttpServletResponse httpResponse = (HttpServletResponse) response;
 
     OAuthSessionOverOpenID oauthSession = oauthSessionProvider.get();
-    OAuthServiceProvider service = ssoProvider == null
-        ? oauthSession.getServiceProvider()
-        : ssoProvider;
+    OAuthServiceProvider service =
+        ssoProvider == null ? oauthSession.getServiceProvider() : ssoProvider;
 
     if (isGerritLogin(httpRequest) || oauthSession.isOAuthFinal(httpRequest)) {
-        if (service == null) {
-          throw new IllegalStateException("service is unknown");
-        }
-        oauthSession.setServiceProvider(service);
-        oauthSession.login(httpRequest, httpResponse, service);
+      if (service == null) {
+        throw new IllegalStateException("service is unknown");
+      }
+      oauthSession.setServiceProvider(service);
+      oauthSession.login(httpRequest, httpResponse, service);
     } else {
       chain.doFilter(httpRequest, response);
     }
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
index 51342f7..1406267 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
@@ -17,9 +17,7 @@
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index 36947a9..c4db3c7 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
@@ -34,7 +35,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.lib.Config;
 import org.openid4java.consumer.ConsumerException;
 import org.openid4java.consumer.ConsumerManager;
@@ -59,19 +67,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.net.URL;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import javax.servlet.http.Cookie;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
 @Singleton
 class OpenIdServiceImpl {
-  private static final Logger log =
-      LoggerFactory.getLogger(OpenIdServiceImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(OpenIdServiceImpl.class);
 
   static final String RETURN_URL = "OpenID";
 
@@ -84,12 +82,9 @@
   private static final String OPENID_MODE = "openid.mode";
   private static final String OMODE_CANCEL = "cancel";
 
-  private static final String SCHEMA_EMAIL =
-      "http://schema.openid.net/contact/email";
-  private static final String SCHEMA_FIRSTNAME =
-      "http://schema.openid.net/namePerson/first";
-  private static final String SCHEMA_LASTNAME =
-      "http://schema.openid.net/namePerson/last";
+  private static final String SCHEMA_EMAIL = "http://schema.openid.net/contact/email";
+  private static final String SCHEMA_FIRSTNAME = "http://schema.openid.net/namePerson/first";
+  private static final String SCHEMA_LASTNAME = "http://schema.openid.net/namePerson/last";
 
   private final DynamicItem<WebSession> webSession;
   private final Provider<IdentifiedUser> identifiedUser;
@@ -103,16 +98,17 @@
   private final int papeMaxAuthAge;
 
   @Inject
-  OpenIdServiceImpl(final DynamicItem<WebSession> cf,
+  OpenIdServiceImpl(
+      final DynamicItem<WebSession> cf,
       final Provider<IdentifiedUser> iu,
       CanonicalWebUrl up,
-      @GerritServerConfig final Config config, final AuthConfig ac,
+      @GerritServerConfig final Config config,
+      final AuthConfig ac,
       final AccountManager am,
       ProxyProperties proxyProperties) {
 
     if (proxyProperties.getProxyUrl() != null) {
-      final org.openid4java.util.ProxyProperties proxy =
-          new org.openid4java.util.ProxyProperties();
+      final org.openid4java.util.ProxyProperties proxy = new org.openid4java.util.ProxyProperties();
       URL url = proxyProperties.getProxyUrl();
       proxy.setProxyHostName(url.getHost());
       proxy.setProxyPort(url.getPort());
@@ -128,13 +124,24 @@
     manager = new ConsumerManager();
     allowedOpenIDs = ac.getAllowedOpenIDs();
     openIdDomains = ac.getOpenIdDomains();
-    papeMaxAuthAge = (int) ConfigUtil.getTimeUnit(config, //
-        "auth", null, "maxOpenIdSessionAge", -1, TimeUnit.SECONDS);
+    papeMaxAuthAge =
+        (int)
+            ConfigUtil.getTimeUnit(
+                config, //
+                "auth",
+                null,
+                "maxOpenIdSessionAge",
+                -1,
+                TimeUnit.SECONDS);
   }
 
   @SuppressWarnings("unchecked")
-  DiscoveryResult discover(HttpServletRequest req, String openidIdentifier,
-      final SignInMode mode, final boolean remember, final String returnToken) {
+  DiscoveryResult discover(
+      HttpServletRequest req,
+      String openidIdentifier,
+      final SignInMode mode,
+      final boolean remember,
+      final String returnToken) {
     final State state;
     state = init(req, openidIdentifier, mode, remember, returnToken);
     if (state == null) {
@@ -173,9 +180,7 @@
       return new DiscoveryResult(DiscoveryResult.Status.ERROR);
     }
 
-    return new DiscoveryResult(
-        aReq.getDestinationUrl(false),
-        aReq.getParameterMap());
+    return new DiscoveryResult(aReq.getDestinationUrl(false), aReq.getParameterMap());
   }
 
   private boolean requestRegistration(final AuthRequest aReq) {
@@ -199,8 +204,7 @@
   }
 
   /** Called by {@link OpenIdLoginServlet} doGet, doPost */
-  void doAuth(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws Exception {
+  void doAuth(final HttpServletRequest req, final HttpServletResponse rsp) throws Exception {
     if (OMODE_CANCEL.equals(req.getParameter(OPENID_MODE))) {
       cancel(req, rsp);
       return;
@@ -243,15 +247,17 @@
     }
 
     final VerificationResult result =
-        manager.verify(state.retTo.toString(), new ParameterList(req
-            .getParameterMap()), state.discovered);
+        manager.verify(
+            state.retTo.toString(), new ParameterList(req.getParameterMap()), state.discovered);
     if (result.getVerifiedId() == null /* authentication failure */) {
       if ("Nonce verification failed.".equals(result.getStatusMsg())) {
         // We might be suffering from clock skew on this system.
         //
-        log.error("OpenID failure: " + result.getStatusMsg()
-            + "  Likely caused by clock skew on this server,"
-            + " install/configure NTP.");
+        log.error(
+            "OpenID failure: "
+                + result.getStatusMsg()
+                + "  Likely caused by clock skew on this server,"
+                + " install/configure NTP.");
         cancelWithError(req, rsp, result.getStatusMsg());
 
       } else if (result.getStatusMsg() != null) {
@@ -295,8 +301,7 @@
     }
 
     if (authRsp.hasExtension(SRegMessage.OPENID_NS_SREG)) {
-      final MessageExtension ext =
-          authRsp.getExtension(SRegMessage.OPENID_NS_SREG);
+      final MessageExtension ext = authRsp.getExtension(SRegMessage.OPENID_NS_SREG);
       if (ext instanceof SRegResponse) {
         sregRsp = (SRegResponse) ext;
       }
@@ -310,7 +315,7 @@
     }
 
     final com.google.gerrit.server.account.AuthRequest areq =
-        new com.google.gerrit.server.account.AuthRequest(openidIdentifier);
+        new com.google.gerrit.server.account.AuthRequest(ExternalId.Key.parse(openidIdentifier));
 
     if (sregRsp != null) {
       areq.setDisplayName(sregRsp.getAttributeValue("fullname"));
@@ -364,40 +369,48 @@
       // identity we have in our AuthRequest above. We still should have a
       // link between the two, so set one up if not present.
       //
-      Account.Id claimedId = accountManager.lookup(claimedIdentifier);
-      Account.Id actualId = accountManager.lookup(areq.getExternalId());
+      Optional<Account.Id> claimedId = accountManager.lookup(claimedIdentifier);
+      Optional<Account.Id> actualId = accountManager.lookup(areq.getExternalIdKey().get());
 
-      if (claimedId != null && actualId != null) {
-        if (claimedId.equals(actualId)) {
+      if (claimedId.isPresent() && actualId.isPresent()) {
+        if (claimedId.get().equals(actualId.get())) {
           // Both link to the same account, that's what we expected.
         } else {
           // This is (for now) a fatal error. There are two records
           // for what might be the same user.
           //
-          log.error("OpenID accounts disagree over user identity:\n"
-              + "  Claimed ID: " + claimedId + " is " + claimedIdentifier
-              + "\n" + "  Delgate ID: " + actualId + " is "
-              + areq.getExternalId());
+          log.error(
+              "OpenID accounts disagree over user identity:\n"
+                  + "  Claimed ID: "
+                  + claimedId.get()
+                  + " is "
+                  + claimedIdentifier
+                  + "\n"
+                  + "  Delgate ID: "
+                  + actualId.get()
+                  + " is "
+                  + areq.getExternalIdKey());
           cancelWithError(req, rsp, "Contact site administrator");
           return;
         }
 
-      } else if (claimedId == null && actualId != null) {
+      } else if (!claimedId.isPresent() && actualId.isPresent()) {
         // Older account, the actual was already created but the claimed
         // was missing due to a bug in Gerrit. Link the claimed.
         //
         final com.google.gerrit.server.account.AuthRequest linkReq =
-            new com.google.gerrit.server.account.AuthRequest(claimedIdentifier);
+            new com.google.gerrit.server.account.AuthRequest(
+                ExternalId.Key.parse(claimedIdentifier));
         linkReq.setDisplayName(areq.getDisplayName());
         linkReq.setEmailAddress(areq.getEmailAddress());
-        accountManager.link(actualId, linkReq);
+        accountManager.link(actualId.get(), linkReq);
 
-      } else if (claimedId != null && actualId == null) {
+      } else if (claimedId.isPresent() && !actualId.isPresent()) {
         // Claimed account already exists, but it smells like the user has
         // changed their delegate to point to a different provider. Link
         // the new provider.
         //
-        accountManager.link(claimedId, areq);
+        accountManager.link(claimedId.get(), areq);
 
       } else {
         // Both are null, we are going to create a new account below.
@@ -424,7 +437,7 @@
           if (arsp.isNew() && claimedIdentifier != null) {
             final com.google.gerrit.server.account.AuthRequest linkReq =
                 new com.google.gerrit.server.account.AuthRequest(
-                    claimedIdentifier);
+                    ExternalId.Key.parse(claimedIdentifier));
             linkReq.setDisplayName(areq.getDisplayName());
             linkReq.setEmailAddress(areq.getEmailAddress());
             accountManager.link(arsp.getAccountId(), linkReq);
@@ -432,12 +445,13 @@
           callback(arsp.isNew(), req, rsp);
           break;
 
-        case LINK_IDENTIY: {
-          arsp = accountManager.link(identifiedUser.get().getAccountId(), areq);
-          webSession.get().login(arsp, remember);
-          callback(false, req, rsp);
-          break;
-        }
+        case LINK_IDENTIY:
+          {
+            arsp = accountManager.link(identifiedUser.get().getAccountId(), areq);
+            webSession.get().login(arsp, remember);
+            callback(false, req, rsp);
+            break;
+          }
       }
     } catch (AccountException e) {
       log.error("OpenID authentication failure", e);
@@ -464,8 +478,9 @@
     }
   }
 
-  private void callback(final boolean isNew, final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  private void callback(
+      final boolean isNew, final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     String token = req.getParameter(P_TOKEN);
     if (token == null || token.isEmpty() || token.startsWith("/SignInFailure,")) {
       token = PageLinks.MINE;
@@ -473,23 +488,28 @@
 
     final StringBuilder rdr = new StringBuilder();
     rdr.append(urlProvider.get(req));
+    String nextToken = Url.decode(token);
     if (isNew && !token.startsWith(PageLinks.REGISTER + "/")) {
       rdr.append('#' + PageLinks.REGISTER);
+      if (nextToken.startsWith("#")) {
+        // Need to strip the leading # off the token to fix registration page redirect
+        nextToken = nextToken.substring(1);
+      }
     }
-    rdr.append(Url.decode(token));
+    rdr.append(nextToken);
     rsp.sendRedirect(rdr.toString());
   }
 
-  private void cancel(final HttpServletRequest req,
-      final HttpServletResponse rsp) throws IOException {
+  private void cancel(final HttpServletRequest req, final HttpServletResponse rsp)
+      throws IOException {
     if (isSignIn(signInMode(req))) {
       webSession.get().logout();
     }
     callback(false, req, rsp);
   }
 
-  private void cancelWithError(final HttpServletRequest req,
-      final HttpServletResponse rsp, final String errorDetail)
+  private void cancelWithError(
+      final HttpServletRequest req, final HttpServletResponse rsp, final String errorDetail)
       throws IOException {
     final SignInMode mode = signInMode(req);
     if (isSignIn(mode)) {
@@ -506,8 +526,12 @@
     rsp.sendRedirect(rdr.toString());
   }
 
-  private State init(HttpServletRequest req, final String openidIdentifier,
-      final SignInMode mode, final boolean remember, final String returnToken) {
+  private State init(
+      HttpServletRequest req,
+      final String openidIdentifier,
+      final SignInMode mode,
+      final boolean remember,
+      final String returnToken) {
     final List<?> list;
     try {
       list = manager.discover(openidIdentifier);
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/SignInMode.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/SignInMode.java
index 9aae6c5..eebb0e7 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/SignInMode.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/SignInMode.java
@@ -15,5 +15,7 @@
 package com.google.gerrit.httpd.auth.openid;
 
 enum SignInMode {
-  SIGN_IN, LINK_IDENTIY, REGISTER
+  SIGN_IN,
+  LINK_IDENTIY,
+  REGISTER
 }
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
index 92230f6..2f7f4bd 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsFilter.java
@@ -4,9 +4,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -25,18 +23,16 @@
   }
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     HttpServletResponse rsp = (HttpServletResponse) response;
     rsp.setHeader("X-XRDS-Location", url.get() + XrdsServlet.LOCATION);
     chain.doFilter(request, response);
   }
 
   @Override
-  public void init(FilterConfig config) {
-  }
+  public void init(FilterConfig config) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 }
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsServlet.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsServlet.java
index b48d3ed..5ff4057 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsServlet.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/XrdsServlet.java
@@ -20,9 +20,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-
 import javax.servlet.ServletOutputStream;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -41,11 +39,9 @@
   }
 
   @Override
-  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     final StringBuilder r = new StringBuilder();
-    r.append("<?xml version=\"1.0\" encoding=\"")
-     .append(UTF_8.name()).append("\"?>");
+    r.append("<?xml version=\"1.0\" encoding=\"").append(UTF_8.name()).append("\"?>");
     r.append("<xrds:XRDS");
     r.append(" xmlns:xrds=\"xri://$xrds\"");
     r.append(" xmlns:openid=\"http://openid.net/xmlns/1.0\"");
@@ -53,8 +49,7 @@
     r.append("<XRD>");
     r.append("<Service priority=\"1\">");
     r.append("<Type>http://specs.openid.net/auth/2.0/return_to</Type>");
-    r.append("<URI>").append(url.get()).append(OpenIdServiceImpl.RETURN_URL)
-     .append("</URI>");
+    r.append("<URI>").append(url.get()).append(OpenIdServiceImpl.RETURN_URL).append("</URI>");
     r.append("</Service>");
     r.append("</XRD>");
     r.append("</xrds:XRDS>");
diff --git a/gerrit-patch-commonsnet/BUCK b/gerrit-patch-commonsnet/BUCK
deleted file mode 100644
index 53b382f..0000000
--- a/gerrit-patch-commonsnet/BUCK
+++ /dev/null
@@ -1,11 +0,0 @@
-java_library(
-  name = 'commons-net',
-  srcs = glob(['src/main/java/org/apache/commons/net/**/*.java']),
-  deps = [
-    '//gerrit-util-ssl:ssl',
-    '//lib/commons:codec',
-    '//lib/commons:net',
-    '//lib/log:api',
-  ],
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-patch-commonsnet/BUILD b/gerrit-patch-commonsnet/BUILD
index c5e541d..67d39d9 100644
--- a/gerrit-patch-commonsnet/BUILD
+++ b/gerrit-patch-commonsnet/BUILD
@@ -1,11 +1,13 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'commons-net',
-  srcs = glob(['src/main/java/org/apache/commons/net/**/*.java']),
-  deps = [
-    '//gerrit-util-ssl:ssl',
-    '//lib/commons:codec',
-    '//lib/commons:net',
-    '//lib/log:api',
-  ],
-  visibility = ['//visibility:public'],
+    name = "commons-net",
+    srcs = glob(["src/main/java/org/apache/commons/net/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-util-ssl:ssl",
+        "//lib/commons:codec",
+        "//lib/commons:net",
+        "//lib/log:api",
+    ],
 )
diff --git a/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java b/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
index 63bb842..48890dd 100644
--- a/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
+++ b/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
@@ -17,9 +17,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.util.ssl.BlindSSLSocketFactory;
-
-import org.apache.commons.codec.binary.Base64;
-
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
 import java.io.IOException;
@@ -31,12 +28,12 @@
 import java.security.NoSuchAlgorithmException;
 import java.util.Arrays;
 import java.util.List;
-
 import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
 import javax.net.ssl.SSLParameters;
 import javax.net.ssl.SSLSocket;
 import javax.net.ssl.SSLSocketFactory;
+import org.apache.commons.codec.binary.Base64;
 
 public class AuthSMTPClient extends SMTPClient {
   private String authTypes;
@@ -60,7 +57,7 @@
     if (verify) {
       SSLParameters sslParams = new SSLParameters();
       sslParams.setEndpointIdentificationAlgorithm("HTTPS");
-      ((SSLSocket)_socket_).setSSLParameters(sslParams);
+      ((SSLSocket) _socket_).setSSLParameters(sslParams);
     }
 
     // XXX: Can't call _connectAction_() because SMTP server doesn't
@@ -72,10 +69,8 @@
     _socket_.setSoTimeout(_timeout_);
     _input_ = _socket_.getInputStream();
     _output_ = _socket_.getOutputStream();
-    _reader =
-        new BufferedReader(new InputStreamReader(_input_, UTF_8));
-    _writer =
-        new BufferedWriter(new OutputStreamWriter(_output_, UTF_8));
+    _reader = new BufferedReader(new InputStreamReader(_input_, UTF_8));
+    _writer = new BufferedWriter(new OutputStreamWriter(_output_, UTF_8));
     return true;
   }
 
@@ -154,8 +149,8 @@
     return SMTPReply.isPositiveCompletion(sendCommand(cmd));
   }
 
-  private boolean authLogin(String smtpUser, String smtpPass) throws UnsupportedEncodingException,
-      IOException {
+  private boolean authLogin(String smtpUser, String smtpPass)
+      throws UnsupportedEncodingException, IOException {
     if (sendCommand("AUTH", "LOGIN") != 334) {
       return false;
     }
@@ -169,8 +164,9 @@
     return SMTPReply.isPositiveCompletion(sendCommand(cmd));
   }
 
-  private static final char[] hexchar =
-      {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
+  private static final char[] hexchar = {
+    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
+  };
 
   private String toHex(final byte[] b) {
     final StringBuilder sec = new StringBuilder();
@@ -183,8 +179,8 @@
     return sec.toString();
   }
 
-  private boolean authPlain(String smtpUser, String smtpPass) throws UnsupportedEncodingException,
-      IOException {
+  private boolean authPlain(String smtpUser, String smtpPass)
+      throws UnsupportedEncodingException, IOException {
     String token = '\0' + smtpUser + '\0' + smtpPass;
     String cmd = "PLAIN " + encodeBase64(token.getBytes(UTF_8));
     return SMTPReply.isPositiveCompletion(sendCommand("AUTH", cmd));
diff --git a/gerrit-patch-jgit/BUCK b/gerrit-patch-jgit/BUCK
deleted file mode 100644
index 09ccf9c..0000000
--- a/gerrit-patch-jgit/BUCK
+++ /dev/null
@@ -1,66 +0,0 @@
-SRC = 'src/main/java/org/eclipse/jgit/'
-
-gwt_module(
-  name = 'client',
-  srcs = [
-    SRC + 'diff/Edit_JsonSerializer.java',
-    SRC + 'diff/ReplaceEdit.java',
-  ],
-  gwt_xml = SRC + 'JGit.gwt.xml',
-  deps = [
-    '//lib:gwtjsonrpc',
-    ':Edit',
-  ],
-  provided_deps = ['//lib/gwt:user'],
-  visibility = ['PUBLIC'],
-)
-
-gwt_module(
-  name = 'Edit',
-  srcs = [':jgit_edit_src'],
-  deps = [':edit_src'],
-  visibility = ['PUBLIC'],
-)
-
-prebuilt_jar(
-  name = 'edit_src',
-  binary_jar = ':jgit_edit_src',
-)
-
-genrule(
-  name = 'jgit_edit_src',
-  cmd = 'unzip -qd $TMP $(location //lib/jgit/org.eclipse.jgit:jgit_src) ' +
-    'org/eclipse/jgit/diff/Edit.java;' +
-    'cd $TMP;' +
-    'zip -Dq $OUT org/eclipse/jgit/diff/Edit.java',
-  out = 'edit.src.zip',
-)
-
-java_library(
-  name = 'server',
-  srcs = [
-    SRC + x for x in [
-      'diff/EditDeserializer.java',
-      'diff/ReplaceEdit.java',
-      'internal/storage/file/WindowCacheStatAccessor.java',
-      'lib/ObjectIdSerialization.java',
-    ]
-  ],
-  deps = [
-    '//lib:gson',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'jgit_patch_tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':server',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib:junit',
-  ],
-  source_under_test = [':server'],
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-patch-jgit/BUILD b/gerrit-patch-jgit/BUILD
index 13a2fe0..91b34f3 100644
--- a/gerrit-patch-jgit/BUILD
+++ b/gerrit-patch-jgit/BUILD
@@ -1,66 +1,68 @@
-load('//tools/bzl:genrule2.bzl', 'genrule2')
-load('//tools/bzl:gwt.bzl', 'gwt_module')
+load("@rules_java//java:defs.bzl", "java_library", "java_test")
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:gwt.bzl", "gwt_module")
 
-SRC = 'src/main/java/org/eclipse/jgit/'
+SRC = "src/main/java/org/eclipse/jgit/"
 
 gwt_module(
-  name = 'client',
-  srcs = [
-    SRC + 'diff/Edit_JsonSerializer.java',
-    SRC + 'diff/ReplaceEdit.java',
-  ],
-  gwt_xml = SRC + 'JGit.gwt.xml',
-  deps = [
-    ':Edit',
-    '//lib/gwt:user',
-    '//lib:gwtjsonrpc',
-  ],
-  visibility = ['//visibility:public'],
+    name = "client",
+    srcs = [
+        SRC + "diff/Edit_JsonSerializer.java",
+        SRC + "diff/ReplaceEdit.java",
+    ],
+    gwt_xml = SRC + "JGit.gwt.xml",
+    visibility = ["//visibility:public"],
+    deps = [
+        ":Edit",
+        "//lib:gwtjsonrpc",
+        "//lib/gwt:user",
+    ],
 )
 
 gwt_module(
-  name = 'Edit',
-  srcs = [':jgit_edit_src'],
-  visibility = ['//visibility:public'],
+    name = "Edit",
+    srcs = [":jgit_edit_src"],
+    visibility = ["//visibility:public"],
 )
 
 genrule2(
-  name = 'jgit_edit_src',
-  cmd = ' && '.join([
-    'unzip -qd $$TMP $(location @jgit_src//file) ' +
-      'org/eclipse/jgit/diff/Edit.java',
-    'cd $$TMP',
-    'zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java',
-  ]),
-  tools = ['@jgit_src//file'],
-  out = 'edit.srcjar',
+    name = "jgit_edit_src",
+    outs = ["edit.srcjar"],
+    cmd = " && ".join([
+        "unzip -qd $$TMP $(location //lib/jgit/org.eclipse.jgit:jgit-source) " +
+        "org/eclipse/jgit/diff/Edit.java",
+        "cd $$TMP",
+        "zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java",
+    ]),
+    tools = ["//lib/jgit/org.eclipse.jgit:jgit-source"],
 )
 
 java_library(
-  name = 'server',
-  srcs = [
-    SRC + x for x in [
-      'diff/EditDeserializer.java',
-      'diff/ReplaceEdit.java',
-      'internal/storage/file/WindowCacheStatAccessor.java',
-      'lib/ObjectIdSerialization.java',
-    ]
-  ],
-  deps = [
-    '//lib:gson',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "server",
+    srcs = [
+        SRC + x
+        for x in [
+            "diff/EditDeserializer.java",
+            "diff/ReplaceEdit.java",
+            "internal/storage/file/WindowCacheStatAccessor.java",
+            "lib/ObjectIdSerialization.java",
+        ]
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:gson",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
 )
 
 java_test(
-  name = 'jgit_patch_tests',
-  test_class = 'org.eclipse.jgit.diff.EditDeserializerTest',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':server',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib:junit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "jgit_patch_tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    test_class = "org.eclipse.jgit.diff.EditDeserializerTest",
+    visibility = ["//visibility:public"],
+    deps = [
+        ":server",
+        "//lib:junit",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
 )
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java
index a99b360..8090f60 100644
--- a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java
+++ b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java
@@ -23,16 +23,15 @@
 import com.google.gson.JsonPrimitive;
 import com.google.gson.JsonSerializationContext;
 import com.google.gson.JsonSerializer;
-
 import java.lang.reflect.Type;
 import java.util.ArrayList;
 import java.util.List;
 
-public class EditDeserializer implements JsonDeserializer<Edit>,
-    JsonSerializer<Edit> {
+public class EditDeserializer implements JsonDeserializer<Edit>, JsonSerializer<Edit> {
   @Override
-  public Edit deserialize(final JsonElement json, final Type typeOfT,
-      final JsonDeserializationContext context) throws JsonParseException {
+  public Edit deserialize(
+      final JsonElement json, final Type typeOfT, final JsonDeserializationContext context)
+      throws JsonParseException {
     if (json.isJsonNull()) {
       return null;
     }
@@ -51,7 +50,7 @@
     }
 
     List<Edit> l = new ArrayList<>((cnt / 4) - 1);
-    for (int i = 4; i < cnt;) {
+    for (int i = 4; i < cnt; ) {
       int as = get(o, i++);
       int ae = get(o, i++);
       int bs = get(o, i++);
@@ -61,8 +60,7 @@
     return new ReplaceEdit(get(o, 0), get(o, 1), get(o, 2), get(o, 3), l);
   }
 
-  private static int get(final JsonArray a, final int idx)
-      throws JsonParseException {
+  private static int get(final JsonArray a, final int idx) throws JsonParseException {
     final JsonElement v = a.get(idx);
     if (!v.isJsonPrimitive()) {
       throw new JsonParseException("Expected array of 4 for Edit type");
@@ -75,8 +73,8 @@
   }
 
   @Override
-  public JsonElement serialize(final Edit src, final Type typeOfSrc,
-      final JsonSerializationContext context) {
+  public JsonElement serialize(
+      final Edit src, final Type typeOfSrc, final JsonSerializationContext context) {
     if (src == null) {
       return JsonNull.INSTANCE;
     }
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
index 1a3714b..ce8a9f3 100644
--- a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
+++ b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
@@ -16,7 +16,6 @@
 
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwtjsonrpc.client.impl.JsonSerializer;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -36,7 +35,7 @@
     }
 
     List<Edit> l = new ArrayList<>((cnt / 4) - 1);
-    for (int i = 4; i < cnt;) {
+    for (int i = 4; i < cnt; ) {
       int as = get(o, i++);
       int ae = get(o, i++);
       int bs = get(o, i++);
@@ -69,9 +68,7 @@
     sb.append(o.getEndB());
   }
 
-  private static native int length(JavaScriptObject jso)
-  /*-{ return jso.length; }-*/;
+  private static native int length(JavaScriptObject jso) /*-{ return jso.length; }-*/;
 
-  private static native int get(JavaScriptObject jso, int idx)
-  /*-{ return jso[idx]; }-*/;
+  private static native int get(JavaScriptObject jso, int idx) /*-{ return jso[idx]; }-*/;
 }
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java
index 1e94050..f8c4340 100644
--- a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java
+++ b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/internal/storage/file/WindowCacheStatAccessor.java
@@ -26,6 +26,5 @@
     return WindowCache.getInstance().getOpenBytes();
   }
 
-  private WindowCacheStatAccessor() {
-  }
+  private WindowCacheStatAccessor() {}
 }
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java
index 1d9d9d5..6617793 100644
--- a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java
+++ b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java
@@ -14,25 +14,23 @@
 
 package org.eclipse.jgit.lib;
 
-import org.eclipse.jgit.util.IO;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import org.eclipse.jgit.util.IO;
 
 public class ObjectIdSerialization {
   public static void writeCanBeNull(final OutputStream out, final AnyObjectId id)
       throws IOException {
     if (id != null) {
-      out.write((byte)1);
+      out.write((byte) 1);
       writeNotNull(out, id);
     } else {
-      out.write((byte)0);
+      out.write((byte) 0);
     }
   }
 
-  public static void writeNotNull(final OutputStream out, final AnyObjectId id)
-      throws IOException {
+  public static void writeNotNull(final OutputStream out, final AnyObjectId id) throws IOException {
     id.copyRawTo(out);
   }
 
@@ -53,6 +51,5 @@
     return ObjectId.fromRaw(b);
   }
 
-  private ObjectIdSerialization() {
-  }
+  private ObjectIdSerialization() {}
 }
diff --git a/gerrit-patch-jgit/src/test/java/org/eclipse/jgit/diff/EditDeserializerTest.java b/gerrit-patch-jgit/src/test/java/org/eclipse/jgit/diff/EditDeserializerTest.java
index a2c3dae..c431715 100644
--- a/gerrit-patch-jgit/src/test/java/org/eclipse/jgit/diff/EditDeserializerTest.java
+++ b/gerrit-patch-jgit/src/test/java/org/eclipse/jgit/diff/EditDeserializerTest.java
@@ -20,7 +20,7 @@
 
 public class EditDeserializerTest {
   @Test
-  public void testDiffDeserializer() {
+  public void diffDeserializer() {
     assertNotNull("edit deserializer", new EditDeserializer());
   }
 }
diff --git a/gerrit-pgm/BUCK b/gerrit-pgm/BUCK
deleted file mode 100644
index 4be941c..0000000
--- a/gerrit-pgm/BUCK
+++ /dev/null
@@ -1,184 +0,0 @@
-SRCS = 'src/main/java/com/google/gerrit/pgm/'
-RSRCS = 'src/main/resources/com/google/gerrit/pgm/'
-
-INIT_API_SRCS = glob([SRCS + 'init/api/*.java'])
-
-BASE_JETTY_DEPS = [
-  '//gerrit-common:server',
-  '//gerrit-extension-api:api',
-  '//gerrit-gwtexpui:linker_server',
-  '//gerrit-gwtexpui:server',
-  '//gerrit-httpd:httpd',
-  '//gerrit-server:server',
-  '//gerrit-sshd:sshd',
-  '//lib:guava',
-  '//lib/guice:guice',
-  '//lib/guice:guice-assistedinject',
-  '//lib/guice:guice-servlet',
-  '//lib/jgit/org.eclipse.jgit:jgit',
-  '//lib/joda:joda-time',
-  '//lib/log:api',
-  '//lib/log:log4j',
-]
-
-DEPS = BASE_JETTY_DEPS + [
-  '//gerrit-reviewdb:server',
-  '//lib/log:jsonevent-layout',
-]
-
-java_library(
-  name = 'init-api',
-  srcs = INIT_API_SRCS,
-  deps = DEPS + ['//gerrit-common:annotations'],
-  visibility = ['PUBLIC'],
-)
-
-java_sources(
-  name = 'init-api-src',
-  srcs = INIT_API_SRCS,
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'init',
-  srcs = glob([SRCS + 'init/*.java']),
-  resources = glob([RSRCS + 'init/*']),
-  deps = DEPS + [
-    ':init-api',
-    ':util',
-    '//gerrit-common:annotations',
-    '//gerrit-lucene:lucene',
-    '//lib:args4j',
-    '//lib:derby',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:h2',
-    '//lib/commons:validator',
-    '//lib/mina:sshd',
-  ],
-  provided_deps = ['//gerrit-launcher:launcher'],
-  visibility = [
-    '//gerrit-acceptance-framework/...',
-    '//gerrit-acceptance-tests/...',
-    '//gerrit-war:',
-  ],
-)
-
-REST_UTIL_DEPS = [
-  '//gerrit-cache-h2:cache-h2',
-  '//gerrit-util-cli:cli',
-  '//lib:args4j',
-  '//lib:gwtorm',
-  '//lib/commons:dbcp',
-]
-
-java_library(
-  name = 'util',
-  deps = DEPS + REST_UTIL_DEPS,
-  exported_deps = [':util-nodep'],
-  visibility = [
-    '//gerrit-acceptance-tests/...',
-    '//gerrit-gwtdebug:gwtdebug',
-    '//gerrit-war:',
-  ],
-)
-
-java_library(
-  name = 'util-nodep',
-  srcs = glob([SRCS + 'util/*.java']),
-  provided_deps = DEPS + REST_UTIL_DEPS,
-  visibility = ['//gerrit-acceptance-framework/...'],
-)
-
-JETTY_DEPS = [
-  '//lib/jetty:jmx',
-  '//lib/jetty:server',
-  '//lib/jetty:servlet',
-]
-
-java_library(
-  name = 'http',
-  deps = DEPS + JETTY_DEPS,
-  exported_deps = [':http-jetty'],
-  visibility = ['//gerrit-war:'],
-)
-
-java_library(
-  name = 'http-jetty',
-  srcs = glob([SRCS + 'http/jetty/*.java']),
-  provided_deps = JETTY_DEPS + BASE_JETTY_DEPS + [
-    '//gerrit-launcher:launcher',
-    '//gerrit-reviewdb:client',
-    '//lib:servlet-api-3_1',
-  ],
-  visibility = ['//gerrit-acceptance-framework/...'],
-)
-
-REST_PGM_DEPS = [
-  ':http',
-  ':init',
-  ':init-api',
-  ':util',
-  '//gerrit-cache-h2:cache-h2',
-  '//gerrit-gpg:gpg',
-  '//gerrit-lucene:lucene',
-  '//gerrit-oauth:oauth',
-  '//gerrit-openid:openid',
-  '//lib:args4j',
-  '//lib:gwtorm',
-  '//lib:protobuf',
-  '//lib:servlet-api-3_1',
-  '//lib/auto:auto-value',
-  '//lib/prolog:cafeteria',
-  '//lib/prolog:compiler',
-  '//lib/prolog:runtime',
-]
-
-java_library(
-  name = 'pgm',
-  resources = glob([RSRCS + '*']),
-  deps = DEPS + REST_PGM_DEPS + [
-    ':daemon',
-  ],
-  visibility = [
-    '//:',
-    '//gerrit-acceptance-tests/...',
-    '//gerrit-gwtdebug:gwtdebug',
-    '//tools/eclipse:classpath',
-    '//Documentation:licenses.txt',
-  ],
-)
-
-# no transitive deps, used for gerrit-acceptance-framework
-java_library(
-  name = 'daemon',
-  srcs = glob([SRCS + '*.java', SRCS + 'rules/*.java']),
-  resources = glob([RSRCS + '*']),
-  deps = ['//lib/auto:auto-value'],
-  provided_deps = DEPS + REST_PGM_DEPS + [
-    '//gerrit-launcher:launcher',
-  ],
-  visibility = [
-    '//gerrit-acceptance-framework/...',
-    '//gerrit-gwtdebug:gwtdebug',
-  ],
-)
-
-java_test(
-  name = 'pgm_tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':init',
-    ':init-api',
-    ':pgm',
-    '//gerrit-common:server',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:junit',
-    '//lib/easymock:easymock',
-    '//lib/guice:guice',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.junit:junit',
-  ],
-  source_under_test = [':pgm'],
-)
diff --git a/gerrit-pgm/BUILD b/gerrit-pgm/BUILD
index 59b371a..54c8d7c 100644
--- a/gerrit-pgm/BUILD
+++ b/gerrit-pgm/BUILD
@@ -1,161 +1,179 @@
-load('//tools/bzl:java.bzl', 'java_library2')
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
+load("//tools/bzl:license.bzl", "license_test")
 
-SRCS = 'src/main/java/com/google/gerrit/pgm/'
-RSRCS = 'src/main/resources/com/google/gerrit/pgm/'
+SRCS = "src/main/java/com/google/gerrit/pgm/"
 
-INIT_API_SRCS = glob([SRCS + 'init/api/*.java'])
+RSRCS = "src/main/resources/com/google/gerrit/pgm/"
+
+INIT_API_SRCS = glob([SRCS + "init/api/*.java"])
 
 BASE_JETTY_DEPS = [
-  '//gerrit-common:server',
-  '//gerrit-extension-api:api',
-  '//gerrit-gwtexpui:linker_server',
-  '//gerrit-gwtexpui:server',
-  '//gerrit-httpd:httpd',
-  '//gerrit-server:server',
-  '//gerrit-sshd:sshd',
-  '//lib:guava',
-  '//lib/guice:guice',
-  '//lib/guice:guice-assistedinject',
-  '//lib/guice:guice-servlet',
-  '//lib/jgit/org.eclipse.jgit:jgit',
-  '//lib/log:api',
-  '//lib/log:log4j',
+    "//gerrit-common:server",
+    "//gerrit-extension-api:api",
+    "//gerrit-gwtexpui:linker_server",
+    "//gerrit-gwtexpui:server",
+    "//gerrit-httpd:httpd",
+    "//gerrit-server:server",
+    "//gerrit-sshd:sshd",
+    "//lib:guava",
+    "//lib/guice:guice",
+    "//lib/guice:guice-assistedinject",
+    "//lib/guice:guice-servlet",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib/joda:joda-time",
+    "//lib/log:api",
+    "//lib/log:log4j",
 ]
 
 DEPS = BASE_JETTY_DEPS + [
-  '//gerrit-reviewdb:server',
-  '//lib/log:jsonevent-layout',
+    "//gerrit-reviewdb:server",
+    "//lib/log:jsonevent-layout",
 ]
 
 java_library(
-  name = 'init-api',
-  srcs = INIT_API_SRCS,
-  deps = DEPS + ['//gerrit-common:annotations'],
-  visibility = ['//visibility:public'],
+    name = "init-api",
+    srcs = INIT_API_SRCS,
+    visibility = ["//visibility:public"],
+    deps = DEPS + ["//gerrit-common:annotations"],
 )
 
 java_library(
-  name = 'init',
-  srcs = glob([SRCS + 'init/*.java']),
-  resources = glob([RSRCS + 'init/*']),
-  deps = DEPS + [
-    ':init-api',
-    ':util',
-    '//gerrit-common:annotations',
-    '//gerrit-launcher:launcher', # We want this dep to be provided_deps
-    '//gerrit-lucene:lucene',
-    '//lib:args4j',
-    '//lib:derby',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:h2',
-    '//lib/commons:validator',
-    '//lib/mina:sshd',
-  ],
-  visibility = ['//visibility:public'],
+    name = "init",
+    srcs = glob([SRCS + "init/**/*.java"]),
+    resources = glob([RSRCS + "init/*"]),
+    visibility = ["//visibility:public"],
+    deps = DEPS + [
+        ":init-api",
+        ":util",
+        "//gerrit-common:annotations",
+        "//gerrit-elasticsearch:elasticsearch",
+        "//gerrit-launcher:launcher",  # We want this dep to be provided_deps
+        "//gerrit-lucene:lucene",
+        "//lib:args4j",
+        "//lib:derby",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:h2",
+        "//lib/commons:validator",
+        "//lib/mina:sshd",
+    ],
 )
 
 REST_UTIL_DEPS = [
-  '//gerrit-cache-h2:cache-h2',
-  '//gerrit-util-cli:cli',
-  '//lib:args4j',
-  '//lib:gwtorm',
-  '//lib/commons:dbcp',
+    "//gerrit-cache-h2:cache-h2",
+    "//gerrit-cache-mem:mem",
+    "//gerrit-util-cli:cli",
+    "//lib:args4j",
+    "//lib:gwtorm",
+    "//lib/commons:dbcp",
 ]
 
 java_library(
-  name = 'util',
-  exports = [':util-nodep'],
-  runtime_deps = DEPS + REST_UTIL_DEPS,
-  visibility = ['//visibility:public'],
+    name = "util",
+    visibility = ["//visibility:public"],
+    exports = [":util-nodep"],
+    runtime_deps = DEPS + REST_UTIL_DEPS,
 )
 
 java_library(
-  name = 'util-nodep',
-  srcs = glob([SRCS + 'util/*.java']),
-  deps = DEPS + REST_UTIL_DEPS, #  We want all these deps to be provided_deps
-  visibility = ['//visibility:public'],
+    name = "util-nodep",
+    srcs = glob([SRCS + "util/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = DEPS + REST_UTIL_DEPS,  #  We want all these deps to be provided_deps
 )
 
 JETTY_DEPS = [
-  '//lib/jetty:jmx',
-  '//lib/jetty:server',
-  '//lib/jetty:servlet',
+    "//lib/jetty:jmx",
+    "//lib/jetty:server",
+    "//lib/jetty:servlet",
 ]
 
 java_library(
-  name = 'http',
-  runtime_deps = DEPS + JETTY_DEPS,
-  exports = [':http-jetty'],
-  visibility = ['//visibility:public'],
+    name = "http",
+    visibility = ["//visibility:public"],
+    exports = [":http-jetty"],
+    runtime_deps = DEPS + JETTY_DEPS,
 )
 
 java_library(
-  name = 'http-jetty',
-  srcs = glob([SRCS + 'http/jetty/*.java']),
-  deps = JETTY_DEPS + BASE_JETTY_DEPS + [ # We want all these deps to be provided_deps
-    '//gerrit-launcher:launcher',
-    '//gerrit-reviewdb:client',
-    '//lib:servlet-api-3_1',
-  ],
-  visibility = ['//visibility:public'],
+    name = "http-jetty",
+    srcs = glob([SRCS + "http/jetty/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = JETTY_DEPS + BASE_JETTY_DEPS + [
+        # We want all these deps to be provided_deps
+        "//gerrit-launcher:launcher",
+        "//gerrit-reviewdb:client",
+        "//lib:servlet-api-3_1",
+    ],
 )
 
 REST_PGM_DEPS = [
-  ':http',
-  ':init',
-  ':init-api',
-  ':util',
-  '//gerrit-cache-h2:cache-h2',
-  '//gerrit-gpg:gpg',
-  '//gerrit-lucene:lucene',
-  '//gerrit-oauth:oauth',
-  '//gerrit-openid:openid',
-  '//lib:args4j',
-  '//lib:gwtorm',
-  '//lib:protobuf',
-  '//lib:servlet-api-3_1-without-neverlink',
-  '//lib/auto:auto-value',
-  '//lib/prolog:cafeteria',
-  '//lib/prolog:compiler',
-  '//lib/prolog:runtime',
+    ":http",
+    ":init",
+    ":init-api",
+    ":util",
+    "//gerrit-cache-h2:cache-h2",
+    "//gerrit-cache-mem:mem",
+    "//gerrit-elasticsearch:elasticsearch",
+    "//gerrit-gpg:gpg",
+    "//gerrit-lucene:lucene",
+    "//gerrit-oauth:oauth",
+    "//gerrit-openid:openid",
+    "//lib:args4j",
+    "//lib:gwtorm",
+    "//lib:protobuf",
+    "//lib:servlet-api-3_1-without-neverlink",
+    "//lib/prolog:cafeteria",
+    "//lib/prolog:compiler",
+    "//lib/prolog:runtime",
 ]
 
 java_library(
-  name = 'pgm',
-  resources = glob([RSRCS + '*']),
-  runtime_deps = DEPS + REST_PGM_DEPS + [
-    ':daemon',
-  ],
-  visibility = ['//visibility:public'],
+    name = "pgm",
+    resources = glob([RSRCS + "*"]),
+    visibility = ["//visibility:public"],
+    runtime_deps = DEPS + REST_PGM_DEPS + [
+        ":daemon",
+    ],
 )
 
 # no transitive deps, used for gerrit-acceptance-framework
 java_library(
-  name = 'daemon',
-  srcs = glob([SRCS + '*.java', SRCS + 'rules/*.java']),
-  resources = glob([RSRCS + '*']),
-  deps = DEPS + REST_PGM_DEPS + [ # We want all these deps to be provided_deps
-    '//gerrit-launcher:launcher',
-  ],
-  visibility = ['//visibility:public'],
+    name = "daemon",
+    srcs = glob([
+        SRCS + "*.java",
+        SRCS + "rules/*.java",
+    ]),
+    resources = glob([RSRCS + "*"]),
+    visibility = ["//visibility:public"],
+    deps = DEPS + REST_PGM_DEPS + [
+        # We want all these deps to be provided_deps
+        "//gerrit-launcher:launcher",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+    ],
 )
 
 junit_tests(
-  name = 'pgm_tests',
-  srcs = glob(['src/test/java/**/*.java']),
-  deps = [
-    ':init',
-    ':init-api',
-    ':pgm',
-    '//gerrit-common:server',
-    '//gerrit-server:server',
-    '//lib:guava',
-    '//lib:junit',
-    '//lib/easymock:easymock',
-    '//lib/guice:guice',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.junit:junit',
-  ],
+    name = "pgm_tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    deps = [
+        ":init",
+        ":init-api",
+        ":pgm",
+        "//gerrit-common:server",
+        "//gerrit-server:server",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib/easymock",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
+
+license_test(
+    name = "pgm_license_test",
+    target = ":pgm",
 )
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Cat.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Cat.java
index 2214587..940c9db 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Cat.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Cat.java
@@ -16,11 +16,9 @@
 
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.pgm.util.AbstractProgram;
-
-import org.kohsuke.args4j.Argument;
-
 import java.io.IOException;
 import java.io.InputStream;
+import org.kohsuke.args4j.Argument;
 
 /** Dump the contents of a file in our archive. */
 public class Cat extends AbstractProgram {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 3af1397..8327513 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -20,9 +20,11 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
-import com.google.gerrit.httpd.GerritOptions;
+import com.google.gerrit.httpd.GerritAuthModule;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
@@ -46,9 +48,12 @@
 import com.google.gerrit.pgm.util.LogFileCompressor;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.StartupChecks;
 import com.google.gerrit.server.account.InternalAccountDirectory;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -56,6 +61,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.RestCacheAdminModule;
 import com.google.gerrit.server.events.StreamEventsApiListener;
@@ -67,13 +73,15 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.mail.SmtpEmailSender;
+import com.google.gerrit.server.mail.receive.MailReceiver;
+import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
+import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
-import com.google.gerrit.server.schema.H2AccountPatchReviewStore;
+import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
@@ -96,20 +104,17 @@
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Stage;
-
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.lang.Thread.UncaughtExceptionHandler;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
-
 import javax.servlet.http.HttpServletRequest;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Run SSH daemon portions of Gerrit. */
 public class Daemon extends SiteProgram {
@@ -127,7 +132,7 @@
   private boolean sshd = true;
 
   @Option(name = "--disable-sshd", usage = "Disable the internal SSH daemon")
-  void setDisableSshd(@SuppressWarnings("unused")  boolean arg) {
+  void setDisableSshd(@SuppressWarnings("unused") boolean arg) {
     sshd = false;
   }
 
@@ -149,10 +154,15 @@
   @Option(name = "--polygerrit-dev", usage = "Force PolyGerrit UI for development")
   private boolean polyGerritDev;
 
-  @Option(name = "--init", aliases = {"-i"},
+  @Option(
+      name = "--init",
+      aliases = {"-i"},
       usage = "Init site before starting the daemon")
   private boolean doInit;
 
+  @Option(name = "--stop-only", usage = "Stop the daemon", hidden = true)
+  private boolean stopOnly;
+
   private final LifecycleManager manager = new LifecycleManager();
   private Injector dbInjector;
   private Injector cfgInjector;
@@ -169,12 +179,17 @@
   private Runnable serverStarted;
   private IndexType indexType;
 
-  public Daemon() {
+  public Daemon() {}
+
+  @VisibleForTesting
+  public Daemon(Runnable serverStarted, Path sitePath) {
+    super(sitePath);
+    this.serverStarted = serverStarted;
   }
 
   @VisibleForTesting
-  public Daemon(Runnable serverStarted) {
-    this.serverStarted = serverStarted;
+  public void setEnableSshd(boolean enable) {
+    sshd = enable;
   }
 
   public void setEnableHttpd(boolean enable) {
@@ -183,6 +198,10 @@
 
   @Override
   public int run() throws Exception {
+    if (stopOnly) {
+      RuntimeShutdown.manualShutdown();
+      return 0;
+    }
     if (doInit) {
       try {
         new Init(getSitePath()).run();
@@ -191,12 +210,13 @@
       }
     }
     mustHaveValidSite();
-    Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler() {
-      @Override
-      public void uncaughtException(Thread t, Throwable e) {
-        log.error("Thread " + t.getName() + " threw exception", e);
-      }
-    });
+    Thread.setDefaultUncaughtExceptionHandler(
+        new UncaughtExceptionHandler() {
+          @Override
+          public void uncaughtException(Thread t, Throwable e) {
+            log.error("Thread " + t.getName() + " threw exception", e);
+          }
+        });
 
     if (runId != null) {
       runFile = getSitePath().resolve("logs").resolve("gerrit.run");
@@ -212,20 +232,14 @@
 
     try {
       start();
-      RuntimeShutdown.add(new Runnable() {
-        @Override
-        public void run() {
-          log.info("caught shutdown, cleaning up");
-          if (runId != null) {
-            try {
-              Files.delete(runFile);
-            } catch (IOException err) {
-              log.warn("failed to delete " + runFile, err);
+      RuntimeShutdown.add(
+          new Runnable() {
+            @Override
+            public void run() {
+              log.info("caught shutdown, cleaning up");
+              stop();
             }
-          }
-          manager.stop();
-        }
-      });
+          });
 
       log.info("Gerrit Code Review " + myVersion() + " ready");
       if (runId != null) {
@@ -287,14 +301,12 @@
       dbInjector = createDbInjector(true /* enableMetrics */, MULTI_USER);
     }
     cfgInjector = createCfgInjector();
-    config = cfgInjector.getInstance(
-        Key.get(Config.class, GerritServerConfig.class));
+    config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     if (!slave) {
       initIndexType();
     }
     sysInjector = createSysInjector();
-    sysInjector.getInstance(PluginGuiceEnvironment.class)
-      .setDbCfgInjector(dbInjector, cfgInjector);
+    sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
     manager.add(dbInjector, cfgInjector, sysInjector);
 
     if (!consoleLog) {
@@ -315,6 +327,13 @@
 
   @VisibleForTesting
   public void stop() {
+    if (runId != null) {
+      try {
+        Files.delete(runFile);
+      } catch (IOException err) {
+        log.warn("failed to delete " + runFile, err);
+      }
+    }
     manager.stop();
   }
 
@@ -338,6 +357,19 @@
     modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new LogFileCompressor.Module());
 
+    // Plugin module needs to be inserted *before* the index module.
+    // There is the concept of LifecycleModule, in Gerrit's own extension
+    // to Guice, which has these:
+    //  listener().to(SomeClassImplementingLifecycleListener.class);
+    // and the start() methods of each such listener are executed in the
+    // order they are declared.
+    // Makes sure that PluginLoader.start() is executed before the
+    // LuceneIndexModule.start() so that plugins get loaded and the respective
+    // Guice modules installed so that the on-line reindexing will happen
+    // with the proper classes (e.g. group backends, custom Prolog
+    // predicates) and the associated rules ready to be evaluated.
+    modules.add(new PluginModule());
+
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
@@ -345,16 +377,19 @@
     modules.add(new WorkQueue.Module());
     modules.add(new StreamEventsApiListener.Module());
     modules.add(new EventBroker.Module());
-    modules.add(test
-        ? new H2AccountPatchReviewStore.InMemoryModule()
-        : new JdbcAccountPatchReviewStore.Module(config));
+    modules.add(
+        test
+            ? new InMemoryAccountPatchReviewStore.Module()
+            : new JdbcAccountPatchReviewStore.Module(config));
     modules.add(new ReceiveCommitsExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new SearchingChangeCacheImpl.Module(slave));
     modules.add(new InternalAccountDirectory.Module());
-    modules.add(new DefaultCacheFactory.Module());
+    modules.add(new DefaultMemoryCacheModule());
+    modules.add(new H2CacheModule());
+    modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     if (emailModule != null) {
       modules.add(emailModule);
     } else {
@@ -364,54 +399,63 @@
     modules.add(new PluginRestApiModule());
     modules.add(new RestCacheAdminModule());
     modules.add(new GpgModule(config));
+    modules.add(new StartupChecks.Module());
     if (MoreObjects.firstNonNull(httpd, true)) {
-      modules.add(new CanonicalWebUrlModule() {
-        @Override
-        protected Class<? extends Provider<String>> provider() {
-          return HttpCanonicalWebUrlProvider.class;
-        }
-      });
+      modules.add(
+          new CanonicalWebUrlModule() {
+            @Override
+            protected Class<? extends Provider<String>> provider() {
+              return HttpCanonicalWebUrlProvider.class;
+            }
+          });
     } else {
-      modules.add(new CanonicalWebUrlModule() {
-        @Override
-        protected Class<? extends Provider<String>> provider() {
-          return CanonicalWebUrlProvider.class;
-        }
-      });
+      modules.add(
+          new CanonicalWebUrlModule() {
+            @Override
+            protected Class<? extends Provider<String>> provider() {
+              return CanonicalWebUrlProvider.class;
+            }
+          });
     }
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
     } else {
       modules.add(NoSshKeyCache.module());
     }
-    modules.add(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(GerritOptions.class).toInstance(
-            new GerritOptions(config, headless, slave, polyGerritDev));
-        if (test) {
-          bind(String.class).annotatedWith(SecureStoreClassName.class)
-              .toInstance(DefaultSecureStore.class.getName());
-          bind(SecureStore.class).toProvider(SecureStoreProvider.class);
-        }
-      }
-    });
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(GerritOptions.class)
+                .toInstance(new GerritOptions(config, headless, slave, polyGerritDev));
+            if (test) {
+              bind(String.class)
+                  .annotatedWith(SecureStoreClassName.class)
+                  .toInstance(DefaultSecureStore.class.getName());
+              bind(SecureStore.class).toProvider(SecureStoreProvider.class);
+            }
+          }
+        });
     modules.add(new GarbageCollectionModule());
     if (!slave) {
       modules.add(new ChangeCleanupRunner.Module());
     }
-    return cfgInjector.createChildInjector(modules);
+    return cfgInjector.createChildInjector(
+        ModuleOverloader.override(modules, LibModuleLoader.loadModules(cfgInjector)));
   }
 
-  private AbstractModule createIndexModule() {
+  private Module createIndexModule() {
     if (slave) {
       return new DummyIndexModule();
     }
+    if (luceneModule != null) {
+      return luceneModule;
+    }
     switch (indexType) {
       case LUCENE:
-        return luceneModule != null
-            ? luceneModule
-            : LuceneIndexModule.latestVersionWithOnlineUpgrade();
+        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+      case ELASTICSEARCH:
+        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -421,6 +465,7 @@
     indexType = IndexModule.getIndexType(cfgInjector);
     switch (indexType) {
       case LUCENE:
+      case ELASTICSEARCH:
         break;
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
@@ -429,8 +474,7 @@
 
   private void initSshd() {
     sshInjector = createSshInjector();
-    sysInjector.getInstance(PluginGuiceEnvironment.class)
-        .setSshInjector(sshInjector);
+    sysInjector.getInstance(PluginGuiceEnvironment.class).setSshInjector(sshInjector);
     manager.add(sshInjector);
   }
 
@@ -440,11 +484,13 @@
     if (!test) {
       modules.add(new SshHostKeyModule());
     }
-    modules.add(new DefaultCommandModule(slave,
-        sysInjector.getInstance(DownloadConfig.class),
-        sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
-    if (!slave && indexType == IndexType.LUCENE) {
-      modules.add(new IndexCommandsModule());
+    modules.add(
+        new DefaultCommandModule(
+            slave,
+            sysInjector.getInstance(DownloadConfig.class),
+            sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+    if (!slave) {
+      modules.add(new IndexCommandsModule(sysInjector));
     }
     return sysInjector.createChildInjector(modules);
   }
@@ -452,12 +498,11 @@
   private void initHttpd() {
     webInjector = createWebInjector();
 
-    sysInjector.getInstance(PluginGuiceEnvironment.class)
-        .setHttpInjector(webInjector);
+    sysInjector.getInstance(PluginGuiceEnvironment.class).setHttpInjector(webInjector);
 
-    sysInjector.getInstance(HttpCanonicalWebUrlProvider.class)
-        .setHttpServletRequest(
-            webInjector.getProvider(HttpServletRequest.class));
+    sysInjector
+        .getInstance(HttpCanonicalWebUrlProvider.class)
+        .setHttpServletRequest(webInjector.getProvider(HttpServletRequest.class));
 
     httpdInjector = createHttpdInjector();
     manager.add(webInjector, httpdInjector);
@@ -469,10 +514,11 @@
       modules.add(new ProjectQoSFilter.Module());
     }
     modules.add(RequestContextFilter.module());
-    modules.add(AllRequestFilter.module());
     modules.add(RequestMetricsFilter.module());
     modules.add(H2CacheBasedWebSession.module());
+    modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(AllRequestFilter.module());
     modules.add(sysInjector.getInstance(WebModule.class));
     modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     modules.add(new HttpPluginModule());
@@ -483,8 +529,8 @@
     }
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
-    if (authConfig.getAuthType() == AuthType.OPENID ||
-        authConfig.getAuthType() == AuthType.OPENID_SSO) {
+    if (authConfig.getAuthType() == AuthType.OPENID
+        || authConfig.getAuthType() == AuthType.OPENID_SSO) {
       modules.add(new OpenIdModule());
     } else if (authConfig.getAuthType() == AuthType.OAUTH) {
       modules.add(new OAuthModule());
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
index 8e09578..b1a50d7 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
@@ -23,10 +23,8 @@
 import com.google.gerrit.sshd.commands.QueryShell;
 import com.google.gerrit.sshd.commands.QueryShell.Factory;
 import com.google.inject.Injector;
-
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
+import org.kohsuke.args4j.Option;
 
 /** Run Gerrit's SQL query tool */
 public class Gsql extends SiteProgram {
@@ -46,17 +44,18 @@
     dbInjector = createDbInjector(SINGLE_USER);
     manager.add(dbInjector);
     manager.start();
-    RuntimeShutdown.add(new Runnable() {
-      @Override
-      public void run() {
-        try {
-          System.in.close();
-        } catch (IOException e) {
-          // Ignored
-        }
-        manager.stop();
-      }
-    });
+    RuntimeShutdown.add(
+        new Runnable() {
+          @Override
+          public void run() {
+            try {
+              System.in.close();
+            } catch (IOException e) {
+              // Ignored
+            }
+            manager.stop();
+          }
+        });
     final QueryShell shell = shellFactory().create(System.in, System.out);
     shell.setOutputFormat(format);
     if (query != null) {
@@ -68,11 +67,14 @@
   }
 
   private Factory shellFactory() {
-    return dbInjector.createChildInjector(new FactoryModule() {
-      @Override
-      protected void configure() {
-        factory(QueryShell.Factory.class);
-      }
-    }).getInstance(QueryShell.Factory.class);
+    return dbInjector
+        .createChildInjector(
+            new FactoryModule() {
+              @Override
+              protected void configure() {
+                factory(QueryShell.Factory.class);
+              }
+            })
+        .getInstance(QueryShell.Factory.class);
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
index 05a0d70..b9c7068 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.pgm;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
-import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.IoUtil;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.PluginData;
@@ -34,23 +33,23 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.util.Providers;
-
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
+import org.kohsuke.args4j.Option;
 
 /** Initialize a new Gerrit installation. */
 public class Init extends BaseInit {
-  @Option(name = "--batch", aliases = {"-b"},
+  @Option(
+      name = "--batch",
+      aliases = {"-b"},
       usage = "Batch mode; skip interactive prompting")
   private boolean batchMode;
 
-  @Option(name = "--delete-caches",
-      usage = "Delete all persistent caches without asking")
+  @Option(name = "--delete-caches", usage = "Delete all persistent caches without asking")
   private boolean deleteCaches;
 
   @Option(name = "--no-auto-start", usage = "Don't automatically start daemon after init")
@@ -65,16 +64,15 @@
   @Option(name = "--install-plugin", usage = "Install given plugin without asking")
   private List<String> installPlugins;
 
-  @Option(name = "--install-all-plugins",
-      usage = "Install all plugins from war without asking")
+  @Option(name = "--install-all-plugins", usage = "Install all plugins from war without asking")
   private boolean installAllPlugins;
 
-  @Option(name = "--secure-store-lib",
+  @Option(
+      name = "--secure-store-lib",
       usage = "Path to jar providing SecureStore implementation class")
   private String secureStoreLib;
 
-  @Option(name = "--dev",
-      usage = "Setup site with default options suitable for developers")
+  @Option(name = "--dev", usage = "Setup site with default options suitable for developers")
   private boolean dev;
 
   @Option(name = "--skip-all-downloads", usage = "Don't download libraries")
@@ -83,8 +81,7 @@
   @Option(name = "--skip-download", usage = "Don't download given library")
   private List<String> skippedDownloads;
 
-  @Inject
-  Browser browser;
+  @Inject Browser browser;
 
   public Init() {
     super(new WarDistribution(), null);
@@ -102,12 +99,10 @@
 
     if (!skipPlugins) {
       final List<PluginData> plugins =
-          InitPlugins.listPluginsAndRemoveTempFiles(init.site,
-              pluginsDistribution);
+          InitPlugins.listPluginsAndRemoveTempFiles(init.site, pluginsDistribution);
       ConsoleUI ui = ConsoleUI.getInstance(false);
       if (installAllPlugins && !nullOrEmpty(installPlugins)) {
-        ui.message(
-            "Cannot use --install-plugin together with --install-all-plugins.\n");
+        ui.message("Cannot use --install-plugin together with --install-all-plugins.\n");
         return true;
       }
       verifyInstallPluginList(ui, plugins);
@@ -129,15 +124,17 @@
   @Override
   protected void afterInit(SiteRun run) throws Exception {
     List<Module> modules = new ArrayList<>();
-    modules.add(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
-        bind(Browser.class);
-        bind(String.class).annotatedWith(SecureStoreClassName.class)
-            .toProvider(Providers.of(getConfiguredSecureStoreClass()));
-      }
-    });
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
+            bind(Browser.class);
+            bind(String.class)
+                .annotatedWith(SecureStoreClassName.class)
+                .toProvider(Providers.of(getConfiguredSecureStoreClass()));
+          }
+        });
     modules.add(new GerritServerConfigModule());
     Guice.createInjector(modules).injectMembers(this);
     start(run);
@@ -168,7 +165,6 @@
     return deleteCaches;
   }
 
-
   @Override
   protected boolean skipPlugins() {
     return skipPlugins;
@@ -186,9 +182,7 @@
 
   @Override
   protected List<String> getSkippedDownloads() {
-    return skippedDownloads != null
-        ? skippedDownloads
-        : Collections.<String> emptyList();
+    return skippedDownloads != null ? skippedDownloads : Collections.<String>emptyList();
   }
 
   @Override
@@ -230,7 +224,7 @@
     IoUtil.copyWithThread(proc.getInputStream(), System.err);
     IoUtil.copyWithThread(proc.getErrorStream(), System.err);
 
-    for (;;) {
+    for (; ; ) {
       try {
         int rc = proc.waitFor();
         if (rc != 0) {
@@ -247,16 +241,10 @@
     if (nullOrEmpty(installPlugins) || nullOrEmpty(plugins)) {
       return;
     }
-    ArrayList<String> copy = Lists.newArrayList(installPlugins);
-    List<String> pluginNames = Lists.transform(plugins, new Function<PluginData, String>() {
-      @Override
-      public String apply(PluginData input) {
-        return input.name;
-      }
-    });
-    copy.removeAll(pluginNames);
-    if (!copy.isEmpty()) {
-      ui.message("Cannot find plugin(s): %s\n", Joiner.on(", ").join(copy));
+    Set<String> missing = Sets.newHashSet(installPlugins);
+    plugins.stream().forEach(p -> missing.remove(p.name));
+    if (!missing.isEmpty()) {
+      ui.message("Cannot find plugin(s): %s\n", Joiner.on(", ").join(missing));
       listPlugins = true;
     }
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
index b092784..e740ec8 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
@@ -15,10 +15,6 @@
 package com.google.gerrit.pgm;
 
 import com.google.gerrit.launcher.GerritLauncher;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -28,6 +24,8 @@
 import java.net.URLClassLoader;
 import java.util.ArrayList;
 import java.util.Properties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class JythonShell {
   private static final Logger log = LoggerFactory.getLogger(JythonShell.class);
@@ -38,7 +36,7 @@
   private Class<?> pyObject;
   private Class<?> pySystemState;
   private Object shell;
-  private ArrayList <String> injectedVariables;
+  private ArrayList<String> injectedVariables;
 
   public JythonShell() {
     Properties env = new Properties();
@@ -72,36 +70,45 @@
     pyObject = findClass("org.python.core.PyObject");
     pySystemState = findClass("org.python.core.PySystemState");
 
-    runMethod(pySystemState, pySystemState, "initialize",
-      new Class[]  { Properties.class, Properties.class },
-      new Object[] { null, env }
-    );
+    runMethod(
+        pySystemState,
+        pySystemState,
+        "initialize",
+        new Class<?>[] {Properties.class, Properties.class},
+        new Object[] {null, env});
 
     try {
-      shell = console.newInstance();
+      shell = console.getConstructor(new Class<?>[] {}).newInstance();
       log.info("Jython shell instance created.");
-    } catch (InstantiationException | IllegalAccessException e) {
+    } catch (InstantiationException
+        | IllegalAccessException
+        | IllegalArgumentException
+        | InvocationTargetException
+        | NoSuchMethodException
+        | SecurityException e) {
       throw noInterpreter(e);
     }
     injectedVariables = new ArrayList<>();
     set("Shell", this);
   }
 
-  protected Object runMethod0(Class<?> klazz, Object instance,
-    String name, Class<?>[] sig, Object[] args)
+  protected Object runMethod0(
+      Class<?> klazz, Object instance, String name, Class<?>[] sig, Object[] args)
       throws InvocationTargetException {
     try {
       Method m;
       m = klazz.getMethod(name, sig);
       return m.invoke(instance, args);
-    } catch (NoSuchMethodException | IllegalAccessException
-        | IllegalArgumentException | SecurityException e) {
+    } catch (NoSuchMethodException
+        | IllegalAccessException
+        | IllegalArgumentException
+        | SecurityException e) {
       throw cannotStart(e);
     }
   }
 
-  protected Object runMethod(Class<?> klazz, Object instance,
-    String name, Class<?>[] sig, Object[] args) {
+  protected Object runMethod(
+      Class<?> klazz, Object instance, String name, Class<?>[] sig, Object[] args) {
     try {
       return runMethod0(klazz, instance, name, sig, args);
     } catch (InvocationTargetException e) {
@@ -114,15 +121,14 @@
   }
 
   protected String getDefaultBanner() {
-    return (String)runInterpreter("getDefaultBanner",
-                  new Class[] { }, new Object[] { });
+    return (String) runInterpreter("getDefaultBanner", new Class<?>[] {}, new Object[] {});
   }
 
   protected void printInjectedVariable(String id) {
-    runInterpreter("exec",
-      new Class[]  { String.class },
-      new Object[] { "print '\"%s\" is \"%s\"' % (\"" + id + "\", " + id + ")" }
-    );
+    runInterpreter(
+        "exec",
+        new Class<?>[] {String.class},
+        new Object[] {"print '\"%s\" is \"%s\"' % (\"" + id + "\", " + id + ")"});
   }
 
   public void run() {
@@ -130,18 +136,19 @@
       printInjectedVariable(key);
     }
     reload();
-    runInterpreter("interact",
-      new Class[]  { String.class, pyObject },
-      new Object[] { getDefaultBanner() +
-        " running for Gerrit " + com.google.gerrit.common.Version.getVersion(),
-        null, });
+    runInterpreter(
+        "interact",
+        new Class<?>[] {String.class, pyObject},
+        new Object[] {
+          getDefaultBanner()
+              + " running for Gerrit "
+              + com.google.gerrit.common.Version.getVersion(),
+          null,
+        });
   }
 
   public void set(String key, Object content) {
-    runInterpreter("set",
-      new Class[]  { String.class, Object.class },
-      new Object[] { key, content }
-    );
+    runInterpreter("set", new Class<?>[] {String.class, Object.class}, new Object[] {key, content});
     injectedVariables.add(key);
   }
 
@@ -174,14 +181,17 @@
     try {
       File script = new File(parent, p);
       if (script.canExecute()) {
-        runMethod0(console, shell, "execfile",
-          new Class[] { String.class },
-          new Object[] { script.getAbsolutePath() }
-        );
+        runMethod0(
+            console,
+            shell,
+            "execfile",
+            new Class<?>[] {String.class},
+            new Object[] {script.getAbsolutePath()});
       } else {
-        log.info("User initialization file "
-          + script.getAbsolutePath()
-          + " is not found or not executable");
+        log.info(
+            "User initialization file "
+                + script.getAbsolutePath()
+                + " is not found or not executable");
       }
     } catch (InvocationTargetException e) {
       log.error("Exception occurred while loading file " + p + " : ", e);
@@ -192,10 +202,12 @@
 
   protected void execStream(final InputStream in, final String p) {
     try {
-      runMethod0(console, shell, "execfile",
-        new Class[] { InputStream.class, String.class },
-        new Object[] { in, p }
-      );
+      runMethod0(
+          console,
+          shell,
+          "execfile",
+          new Class<?>[] {InputStream.class, String.class},
+          new Object[] {in, p});
     } catch (InvocationTargetException e) {
       log.error("Exception occurred while loading " + p + " : ", e);
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index bd9ed8f..fecd57b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -14,122 +14,85 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.ExternalIdsBatchUpdate;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.kohsuke.args4j.Option;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
+import java.util.Collection;
 import java.util.Locale;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
 
 /** Converts the local username for all accounts to lower case */
 public class LocalUsernamesToLowerCase extends SiteProgram {
-  @Option(name = "--threads", usage = "Number of concurrent threads to run")
-  private int threads = 2;
-
   private final LifecycleManager manager = new LifecycleManager();
   private final TextProgressMonitor monitor = new TextProgressMonitor();
-  private List<AccountExternalId> todo;
 
-  private Injector dbInjector;
+  @Inject private SchemaFactory<ReviewDb> database;
 
-  @Inject
-  private SchemaFactory<ReviewDb> database;
+  @Inject private ExternalIdsBatchUpdate externalIdsBatchUpdate;
 
   @Override
   public int run() throws Exception {
-    if (threads <= 0) {
-      threads = 1;
-    }
-
-    dbInjector = createDbInjector(MULTI_USER);
-    manager.add(dbInjector,
-        dbInjector.createChildInjector(SchemaVersionCheck.module()));
+    Injector dbInjector = createDbInjector(MULTI_USER);
+    manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
     manager.start();
     dbInjector.injectMembers(this);
 
     try (ReviewDb db = database.open()) {
-      todo = db.accountExternalIds().all().toList();
-      synchronized (monitor) {
-        monitor.beginTask("Converting local usernames", todo.size());
-      }
-    }
+      Collection<ExternalId> todo = ExternalId.from(db.accountExternalIds().all().toList());
+      monitor.beginTask("Converting local usernames", todo.size());
 
-    final List<Worker> workers = new ArrayList<>(threads);
-    for (int tid = 0; tid < threads; tid++) {
-      Worker t = new Worker();
-      t.start();
-      workers.add(t);
+      for (ExternalId extId : todo) {
+        convertLocalUserToLowerCase(extId);
+        monitor.update(1);
+      }
+
+      externalIdsBatchUpdate.commit(db);
     }
-    for (Worker t : workers) {
-      t.join();
-    }
-    synchronized (monitor) {
-      monitor.endTask();
-    }
+    monitor.endTask();
+
+    int exitCode = reindexAccounts();
     manager.stop();
-    return 0;
+    return exitCode;
   }
 
-  private void convertLocalUserToLowerCase(final ReviewDb db,
-      final AccountExternalId extId) {
-    if (extId.isScheme(AccountExternalId.SCHEME_GERRIT)) {
-      final String localUser = extId.getSchemeRest();
-      final String localUserLowerCase = localUser.toLowerCase(Locale.US);
+  private void convertLocalUserToLowerCase(ExternalId extId) {
+    if (extId.isScheme(SCHEME_GERRIT)) {
+      String localUser = extId.key().id();
+      String localUserLowerCase = localUser.toLowerCase(Locale.US);
       if (!localUser.equals(localUserLowerCase)) {
-        final AccountExternalId.Key extIdKeyLowerCase =
-            new AccountExternalId.Key(AccountExternalId.SCHEME_GERRIT,
-                localUserLowerCase);
-        final AccountExternalId extIdLowerCase =
-            new AccountExternalId(extId.getAccountId(), extIdKeyLowerCase);
-        try {
-          db.accountExternalIds().insert(Collections.singleton(extIdLowerCase));
-          db.accountExternalIds().delete(Collections.singleton(extId));
-        } catch (OrmException error) {
-          System.err.println("ERR " + error.getMessage());
-        }
+        ExternalId extIdLowerCase =
+            ExternalId.create(
+                SCHEME_GERRIT,
+                localUserLowerCase,
+                extId.accountId(),
+                extId.email(),
+                extId.password());
+        externalIdsBatchUpdate.replace(extId, extIdLowerCase);
       }
     }
   }
 
-  private AccountExternalId next() {
-    synchronized (todo) {
-      if (todo.isEmpty()) {
-        return null;
-      }
-      return todo.remove(todo.size() - 1);
-    }
-  }
-
-  private class Worker extends Thread {
-    @Override
-    public void run() {
-      try (ReviewDb db = database.open()) {
-        for (;;) {
-          final AccountExternalId extId = next();
-          if (extId == null) {
-            break;
-          }
-          convertLocalUserToLowerCase(db, extId);
-          synchronized (monitor) {
-            monitor.update(1);
-          }
-        }
-      } catch (OrmException e) {
-        e.printStackTrace();
-      }
-    }
+  private int reindexAccounts() throws Exception {
+    monitor.beginTask("Reindex accounts", ProgressMonitor.UNKNOWN);
+    String[] reindexArgs = {
+      "--site-path", getSitePath().toString(), "--index", AccountSchemaDefinitions.NAME
+    };
+    System.out.println("Migration complete, reindexing accounts with:");
+    System.out.println("  reindex " + String.join(" ", reindexArgs));
+    Reindex reindexPgm = new Reindex();
+    int exitCode = reindexPgm.main(reindexArgs);
+    monitor.endTask();
+    return exitCode;
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Ls.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Ls.java
index 9f6436b..4211c17 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Ls.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Ls.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.pgm.util.AbstractProgram;
-
 import java.io.IOException;
 import java.util.Enumeration;
 import java.util.zip.ZipEntry;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
index 6e6be3a..4ace62b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateAccountPatchReviewDb.java
@@ -15,18 +15,16 @@
 package com.google.gerrit.pgm;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Stopwatch;
 import com.google.common.base.Strings;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.inject.Injector;
 import com.google.inject.Key;
-
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
@@ -34,6 +32,9 @@
 import java.sql.Statement;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
 
 /** Migrates AccountPatchReviewDb from one to another */
 public class MigrateAccountPatchReviewDb extends SiteProgram {
@@ -41,21 +42,24 @@
   @Option(name = "--sourceUrl", usage = "Url of source database")
   private String sourceUrl;
 
-  @Option(name = "--chunkSize", usage = "chunk size of fetching from source and push to target on each time")
+  @Option(
+      name = "--chunkSize",
+      usage = "chunk size of fetching from source and push to target on each time")
   private static long chunkSize = 100000;
 
   @Override
   public int run() throws Exception {
+    Injector dbInjector = createDbInjector(DataSourceProvider.Context.SINGLE_USER);
     SitePaths sitePaths = new SitePaths(getSitePath());
+    ThreadSettingsConfig threadSettingsConfig = dbInjector.getInstance(ThreadSettingsConfig.class);
     Config fakeCfg = new Config();
     if (!Strings.isNullOrEmpty(sourceUrl)) {
       fakeCfg.setString("accountPatchReviewDb", null, "url", sourceUrl);
     }
     JdbcAccountPatchReviewStore sourceJdbcAccountPatchReviewStore =
-        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(fakeCfg,
-            sitePaths);
+        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(
+            fakeCfg, sitePaths, threadSettingsConfig);
 
-    Injector dbInjector = createDbInjector(DataSourceProvider.Context.SINGLE_USER);
     Config cfg = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     String targetUrl = cfg.getString("accountPatchReviewDb", null, "url");
     if (targetUrl == null) {
@@ -64,7 +68,8 @@
     }
     System.out.println("target Url: " + targetUrl);
     JdbcAccountPatchReviewStore targetJdbcAccountPatchReviewStore =
-        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(cfg, sitePaths);
+        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(
+            cfg, sitePaths, threadSettingsConfig);
     targetJdbcAccountPatchReviewStore.createTableIfNotExists();
 
     if (!isTargetTableEmpty(targetJdbcAccountPatchReviewStore)) {
@@ -81,17 +86,22 @@
                     + "LIMIT ? "
                     + "OFFSET ?");
         PreparedStatement targetStmt =
-            targetCon.prepareStatement("INSERT INTO account_patch_reviews "
-                + "(account_id, change_id, patch_set_id, file_name) VALUES "
-                + "(?, ?, ?, ?)")) {
+            targetCon.prepareStatement(
+                "INSERT INTO account_patch_reviews "
+                    + "(account_id, change_id, patch_set_id, file_name) VALUES "
+                    + "(?, ?, ?, ?)")) {
       targetCon.setAutoCommit(false);
       long offset = 0;
+      Stopwatch sw = Stopwatch.createStarted();
       List<Row> rows = selectRows(sourceStmt, offset);
       while (!rows.isEmpty()) {
         insertRows(targetCon, targetStmt, rows);
         offset += rows.size();
+        System.out.printf("%8d rows migrated\n", offset);
         rows = selectRows(sourceStmt, offset);
       }
+      double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
+      System.out.printf("Migrated %d rows in %.01fs (%.01f/s)\n", offset, t, offset / t);
     }
     return 0;
   }
@@ -99,17 +109,18 @@
   @AutoValue
   abstract static class Row {
     abstract int accountId();
+
     abstract int changeId();
+
     abstract int patchSetId();
+
     abstract String fileName();
   }
 
-  private static boolean isTargetTableEmpty(JdbcAccountPatchReviewStore store)
-      throws SQLException {
+  private static boolean isTargetTableEmpty(JdbcAccountPatchReviewStore store) throws SQLException {
     try (Connection con = store.getConnection();
         Statement s = con.createStatement();
-        ResultSet r = s.executeQuery(
-            "SELECT COUNT(1) FROM account_patch_reviews")) {
+        ResultSet r = s.executeQuery("SELECT COUNT(1) FROM account_patch_reviews")) {
       if (r.next()) {
         return r.getInt(1) == 0;
       }
@@ -117,25 +128,25 @@
     }
   }
 
-  private static List<Row> selectRows(PreparedStatement stmt, long offset)
-      throws SQLException {
+  private static List<Row> selectRows(PreparedStatement stmt, long offset) throws SQLException {
     List<Row> results = new ArrayList<>();
     stmt.setLong(1, chunkSize);
     stmt.setLong(2, offset);
     try (ResultSet rs = stmt.executeQuery()) {
       while (rs.next()) {
-        results.add(new AutoValue_MigrateAccountPatchReviewDb_Row(
-            rs.getInt("account_id"),
-            rs.getInt("change_id"),
-            rs.getInt("patch_set_id"),
-            rs.getString("file_name")));
+        results.add(
+            new AutoValue_MigrateAccountPatchReviewDb_Row(
+                rs.getInt("account_id"),
+                rs.getInt("change_id"),
+                rs.getInt("patch_set_id"),
+                rs.getString("file_name")));
       }
     }
     return results;
   }
 
-  private static void insertRows(Connection con, PreparedStatement stmt,
-      List<Row> rows) throws SQLException {
+  private static void insertRows(Connection con, PreparedStatement stmt, List<Row> rows)
+      throws SQLException {
     for (Row r : rows) {
       stmt.setLong(1, r.accountId());
       stmt.setLong(2, r.changeId());
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java
index 643323f..457cae3 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Passwd.java
@@ -28,30 +28,30 @@
 import com.google.inject.Module;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
-
-import org.kohsuke.args4j.Argument;
-
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import org.kohsuke.args4j.Argument;
 
 public class Passwd extends SiteProgram {
   private String section;
   private String key;
 
-  @Argument(metaVar = "SECTION.KEY", index = 0, required = true,
+  @Argument(
+      metaVar = "SECTION.KEY",
+      index = 0,
+      required = true,
       usage = "Section and key separated by a dot of the password to set")
   private String sectionAndKey;
 
-  @Argument(metaVar = "PASSWORD", index = 1, required = false,
-      usage = "Password to set")
+  @Argument(metaVar = "PASSWORD", index = 1, required = false, usage = "Password to set")
   private String password;
 
   private void init() {
     String[] varParts = sectionAndKey.split("\\.");
     if (varParts.length != 2) {
-      throw new IllegalArgumentException("Invalid name '" + sectionAndKey
-          + "': expected section.key format");
+      throw new IllegalArgumentException(
+          "Invalid name '" + sectionAndKey + "': expected section.key format");
     }
     section = varParts[0];
     key = varParts[1];
@@ -67,22 +67,22 @@
 
   private Injector getSysInjector() {
     List<Module> modules = new ArrayList<>();
-    modules.add(new FactoryModule() {
-      @Override
-      protected void configure() {
-        bind(Path.class).annotatedWith(SitePath.class)
-            .toInstance(getSitePath());
-        bind(ConsoleUI.class).toInstance(
-            ConsoleUI.getInstance(password != null));
-        factory(Section.Factory.class);
-        bind(Boolean.class).annotatedWith(InstallAllPlugins.class).toInstance(
-            Boolean.FALSE);
-        bind(new TypeLiteral<List<String>>() {}).annotatedWith(
-            InstallPlugins.class).toInstance(new ArrayList<String>());
-        bind(String.class).annotatedWith(SecureStoreClassName.class)
-            .toProvider(Providers.of(getConfiguredSecureStoreClass()));
-      }
-    });
+    modules.add(
+        new FactoryModule() {
+          @Override
+          protected void configure() {
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
+            bind(ConsoleUI.class).toInstance(ConsoleUI.getInstance(password != null));
+            factory(Section.Factory.class);
+            bind(Boolean.class).annotatedWith(InstallAllPlugins.class).toInstance(Boolean.FALSE);
+            bind(new TypeLiteral<List<String>>() {})
+                .annotatedWith(InstallPlugins.class)
+                .toInstance(new ArrayList<String>());
+            bind(String.class)
+                .annotatedWith(SecureStoreClassName.class)
+                .toProvider(Providers.of(getConfiguredSecureStoreClass()));
+          }
+        });
     modules.add(new GerritServerConfigModule());
     return Guice.createInjector(modules);
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/PrologShell.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/PrologShell.java
index 58162a1..5decd68 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/PrologShell.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/PrologShell.java
@@ -15,19 +15,16 @@
 package com.google.gerrit.pgm;
 
 import com.google.gerrit.pgm.util.AbstractProgram;
-
 import com.googlecode.prolog_cafe.exceptions.HaltException;
 import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.PrologClassLoader;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
-
-import org.kohsuke.args4j.Option;
-
 import java.io.File;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import org.kohsuke.args4j.Option;
 
 public class PrologShell extends AbstractProgram {
   @Option(name = "-s", metaVar = "FILE.pl", usage = "file to load")
@@ -67,11 +64,12 @@
   }
 
   private void banner() {
-    System.err.format("Gerrit Code Review %s - Interactive Prolog Shell",
+    System.err.format(
+        "Gerrit Code Review %s - Interactive Prolog Shell",
         com.google.gerrit.common.Version.getVersion());
     System.err.println();
-    System.err.println("(type Ctrl-D or \"halt.\" to exit,"
-        + " \"['path/to/file.pl'].\" to load a file)");
+    System.err.println(
+        "(type Ctrl-D or \"halt.\" to exit, \"['path/to/file.pl'].\" to load a file)");
     System.err.println();
     System.err.flush();
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java
index 7dba8ed..4fe6cf6 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtoGen.java
@@ -19,11 +19,6 @@
 import com.google.gerrit.pgm.util.AbstractProgram;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gwtorm.schema.java.JavaSchemaModel;
-
-import org.eclipse.jgit.internal.storage.file.LockFile;
-import org.eclipse.jgit.util.IO;
-import org.kohsuke.args4j.Option;
-
 import java.io.BufferedWriter;
 import java.io.File;
 import java.io.InputStream;
@@ -31,9 +26,17 @@
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.nio.ByteBuffer;
+import org.eclipse.jgit.internal.storage.file.LockFile;
+import org.eclipse.jgit.util.IO;
+import org.kohsuke.args4j.Option;
 
 public class ProtoGen extends AbstractProgram {
-  @Option(name = "--output", aliases = {"-o"}, required = true, metaVar = "FILE", usage = "File to write .proto into")
+  @Option(
+      name = "--output",
+      aliases = {"-o"},
+      required = true,
+      metaVar = "FILE",
+      usage = "File to write .proto into")
   private File file;
 
   @Override
@@ -45,8 +48,7 @@
     try {
       JavaSchemaModel jsm = new JavaSchemaModel(ReviewDb.class);
       try (OutputStream o = lock.getOutputStream();
-          PrintWriter out = new PrintWriter(
-              new BufferedWriter(new OutputStreamWriter(o, UTF_8)))) {
+          PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, UTF_8)))) {
         String header;
         try (InputStream in = getClass().getResourceAsStream("ProtoGenHeader.txt")) {
           ByteBuffer buf = IO.readWholeStream(in, 1024);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
index 948182e..c11dae1 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
@@ -37,45 +37,45 @@
 import com.google.protobuf.ByteString;
 import com.google.protobuf.Parser;
 import com.google.protobuf.UnknownFieldSet;
-
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.kohsuke.args4j.Option;
-
 import java.io.BufferedInputStream;
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.InputStream;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.nio.file.Files;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.kohsuke.args4j.Option;
 
 /**
  * Import data from a protocol buffer dump into the database.
- * <p>
- * Takes as input a file containing protocol buffers concatenated together with
- * varint length encoding, as in {@link Parser#parseDelimitedFrom(InputStream)}.
- * Each message contains a single field with a tag corresponding to the relation
- * ID in the {@link com.google.gwtorm.server.Relation} annotation.
- * <p>
- * <strong>Warning</strong>: This method blindly upserts data into the database.
- * It should only be used to restore a protobuf-formatted backup into a new,
- * empty site.
+ *
+ * <p>Takes as input a file containing protocol buffers concatenated together with varint length
+ * encoding, as in {@link Parser#parseDelimitedFrom(InputStream)}. Each message contains a single
+ * field with a tag corresponding to the relation ID in the {@link
+ * com.google.gwtorm.server.Relation} annotation.
+ *
+ * <p><strong>Warning</strong>: This method blindly upserts data into the database. It should only
+ * be used to restore a protobuf-formatted backup into a new, empty site.
  */
 public class ProtobufImport extends SiteProgram {
-  @Option(name = "--file", aliases = {"-f"}, required = true, metaVar = "FILE",
+  @Option(
+      name = "--file",
+      aliases = {"-f"},
+      required = true,
+      metaVar = "FILE",
       usage = "File to import from")
   private File file;
 
   private final LifecycleManager manager = new LifecycleManager();
   private final Map<Integer, Relation> relations = new HashMap<>();
 
-  @Inject
-  private SchemaFactory<ReviewDb> schemaFactory;
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
 
   @Override
   public int run() throws Exception {
@@ -84,34 +84,36 @@
     Injector dbInjector = createDbInjector(SINGLE_USER);
     manager.add(dbInjector);
     manager.start();
-    RuntimeShutdown.add(new Runnable() {
-      @Override
-      public void run() {
-        manager.stop();
-      }
-    });
+    RuntimeShutdown.add(
+        new Runnable() {
+          @Override
+          public void run() {
+            manager.stop();
+          }
+        });
     dbInjector.injectMembers(this);
 
     ProgressMonitor progress = new TextProgressMonitor();
     progress.beginTask("Importing entities", ProgressMonitor.UNKNOWN);
     try (ReviewDb db = schemaFactory.open()) {
-      for (RelationModel model
-          : new JavaSchemaModel(ReviewDb.class).getRelations()) {
+      for (RelationModel model : new JavaSchemaModel(ReviewDb.class).getRelations()) {
         relations.put(model.getRelationID(), Relation.create(model, db));
       }
 
-      Parser<UnknownFieldSet> parser =
-          UnknownFieldSet.getDefaultInstance().getParserForType();
-      try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
+      Parser<UnknownFieldSet> parser = UnknownFieldSet.getDefaultInstance().getParserForType();
+      try (InputStream in = new BufferedInputStream(Files.newInputStream(file.toPath()))) {
         UnknownFieldSet msg;
         while ((msg = parser.parseDelimitedFrom(in)) != null) {
           Map.Entry<Integer, UnknownFieldSet.Field> e =
               Iterables.getOnlyElement(msg.asMap().entrySet());
-          Relation rel = checkNotNull(relations.get(e.getKey()),
-              "unknown relation ID %s in message: %s", e.getKey(), msg);
+          Relation rel =
+              checkNotNull(
+                  relations.get(e.getKey()),
+                  "unknown relation ID %s in message: %s",
+                  e.getKey(),
+                  msg);
           List<ByteString> values = e.getValue().getLengthDelimitedList();
-          checkState(values.size() == 1,
-            "expected one string field in message: %s", msg);
+          checkState(values.size() == 1, "expected one string field in message: %s", msg);
           upsert(rel, values.get(0));
           progress.update(1);
         }
@@ -123,8 +125,7 @@
   }
 
   @SuppressWarnings({"rawtypes", "unchecked"})
-  private static void upsert(Relation rel, ByteString s)
-      throws OrmException {
+  private static void upsert(Relation rel, ByteString s) throws OrmException {
     Collection ents = Collections.singleton(rel.codec().decode(s));
     try {
       // Not all relations support update; fall back manually.
@@ -138,16 +139,16 @@
   @AutoValue
   abstract static class Relation {
     private static Relation create(RelationModel model, ReviewDb db)
-        throws IllegalAccessException, InvocationTargetException,
-        NoSuchMethodException, ClassNotFoundException {
+        throws IllegalAccessException, InvocationTargetException, NoSuchMethodException,
+            ClassNotFoundException {
       Method m = db.getClass().getMethod(model.getMethodName());
       Class<?> clazz = Class.forName(model.getEntityTypeClassName());
       return new AutoValue_ProtobufImport_Relation(
-          (Access<?, ?>) m.invoke(db),
-          CodecFactory.encoder(clazz));
+          (Access<?, ?>) m.invoke(db), CodecFactory.encoder(clazz));
     }
 
     abstract Access<?, ?> access();
+
     abstract ProtobufCodec<?> codec();
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
index 0adb1af..de8d0cb 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
@@ -14,21 +14,23 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Function;
 import com.google.common.base.Predicates;
 import com.google.common.base.Stopwatch;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Ordering;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.common.FormatUtil;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -47,74 +49,77 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.DummyIndexModule;
 import com.google.gerrit.server.index.change.ReindexAfterUpdate;
-import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import java.io.BufferedWriter;
 import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class RebuildNoteDb extends SiteProgram {
-  private static final Logger log =
-      LoggerFactory.getLogger(RebuildNoteDb.class);
+  private static final Logger log = LoggerFactory.getLogger(RebuildNoteDb.class);
 
-  @Option(name = "--threads",
-      usage = "Number of threads to use for rebuilding NoteDb")
+  @Option(name = "--threads", usage = "Number of threads to use for rebuilding NoteDb")
   private int threads = Runtime.getRuntime().availableProcessors();
 
-  @Option(name = "--project",
-      usage = "Projects to rebuild; recommended for debugging only")
+  @Option(name = "--project", usage = "Projects to rebuild; recommended for debugging only")
   private List<String> projects = new ArrayList<>();
 
-  @Option(name = "--change",
+  @Option(
+      name = "--change",
       usage = "Individual change numbers to rebuild; recommended for debugging only")
   private List<Integer> changes = new ArrayList<>();
 
   private Injector dbInjector;
   private Injector sysInjector;
 
-  @Inject
-  private AllUsersName allUsersName;
+  @Inject private AllUsersName allUsersName;
 
-  @Inject
-  private ChangeRebuilder rebuilder;
+  @Inject private ChangeRebuilder rebuilder;
 
-  @Inject
-  @GerritServerConfig
-  private Config cfg;
+  @Inject @GerritServerConfig private Config cfg;
 
-  @Inject
-  private GitRepositoryManager repoManager;
+  @Inject private GitRepositoryManager repoManager;
 
-  @Inject
-  private NotesMigration notesMigration;
+  @Inject private NoteDbUpdateManager.Factory updateManagerFactory;
 
-  @Inject
-  private SchemaFactory<ReviewDb> schemaFactory;
+  @Inject private NotesMigration notesMigration;
 
-  @Inject
-  private WorkQueue workQueue;
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
+
+  @Inject private WorkQueue workQueue;
+
+  @Inject private ChangeBundleReader bundleReader;
 
   @Override
   public int run() throws Exception {
@@ -138,36 +143,34 @@
     ListeningExecutorService executor = newExecutor();
     System.out.println("Rebuilding the NoteDb");
 
-    final ImmutableMultimap<Project.NameKey, Change.Id> changesByProject =
-        getChangesByProject();
+    ImmutableListMultimap<Project.NameKey, Change.Id> changesByProject = getChangesByProject();
     boolean ok;
     Stopwatch sw = Stopwatch.createStarted();
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
       deleteRefs(RefNames.REFS_DRAFT_COMMENTS, allUsersRepo);
 
       List<ListenableFuture<Boolean>> futures = new ArrayList<>();
-      List<Project.NameKey> projectNames = Ordering.usingToString()
-          .sortedCopy(changesByProject.keySet());
+      List<Project.NameKey> projectNames =
+          Ordering.usingToString().sortedCopy(changesByProject.keySet());
       for (final Project.NameKey project : projectNames) {
-        ListenableFuture<Boolean> future = executor.submit(
-            new Callable<Boolean>() {
-              @Override
-              public Boolean call() {
-                try (ReviewDb db = unwrapDb(schemaFactory.open())) {
-                  return rebuilder.rebuildProject(
-                      db, changesByProject, project, allUsersRepo);
-                } catch (Exception e) {
-                  log.error("Error rebuilding project " + project, e);
-                  return false;
-                }
-              }
-            });
+        ListenableFuture<Boolean> future =
+            executor.submit(
+                new Callable<Boolean>() {
+                  @Override
+                  public Boolean call() {
+                    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
+                      return rebuildProject(db, changesByProject, project, allUsersRepo);
+                    } catch (Exception e) {
+                      log.error("Error rebuilding project " + project, e);
+                      return false;
+                    }
+                  }
+                });
         futures.add(future);
       }
 
       try {
-        ok = Iterables.all(
-            Futures.allAsList(futures).get(), Predicates.equalTo(true));
+        ok = Iterables.all(Futures.allAsList(futures).get(), Predicates.equalTo(true));
       } catch (InterruptedException | ExecutionException e) {
         log.error("Error rebuilding projects", e);
         ok = false;
@@ -175,72 +178,67 @@
     }
 
     double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
-    System.out.format("Rebuild %d changes in %.01fs (%.01f/s)\n",
+    System.out.format(
+        "Rebuild %d changes in %.01fs (%.01f/s)\n",
         changesByProject.size(), t, changesByProject.size() / t);
     return ok ? 0 : 1;
   }
 
-  private static void execute(BatchRefUpdate bru, Repository repo)
-      throws IOException {
+  private static void execute(BatchRefUpdate bru, Repository repo) throws IOException {
     try (RevWalk rw = new RevWalk(repo)) {
       bru.execute(rw, NullProgressMonitor.INSTANCE);
     }
     for (ReceiveCommand command : bru.getCommands()) {
       if (command.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException(String.format("Command %s failed: %s",
-            command.toString(), command.getResult()));
+        throw new IOException(
+            String.format("Command %s failed: %s", command.toString(), command.getResult()));
       }
     }
   }
 
-  private void deleteRefs(String prefix, Repository allUsersRepo)
-      throws IOException {
+  private void deleteRefs(String prefix, Repository allUsersRepo) throws IOException {
     RefDatabase refDb = allUsersRepo.getRefDatabase();
     Map<String, Ref> allRefs = refDb.getRefs(prefix);
     BatchRefUpdate bru = refDb.newBatchUpdate();
     for (Map.Entry<String, Ref> ref : allRefs.entrySet()) {
-      bru.addCommand(new ReceiveCommand(ref.getValue().getObjectId(),
-          ObjectId.zeroId(), prefix + ref.getKey()));
+      bru.addCommand(
+          new ReceiveCommand(
+              ref.getValue().getObjectId(), ObjectId.zeroId(), prefix + ref.getKey()));
     }
     execute(bru, allUsersRepo);
   }
 
   private Injector createSysInjector() {
-    return dbInjector.createChildInjector(new FactoryModule() {
-      @Override
-      public void configure() {
-        install(dbInjector.getInstance(BatchProgramModule.class));
-        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(
-            ReindexAfterUpdate.class);
-        install(new DummyIndexModule());
-        factory(ChangeResource.Factory.class);
-      }
-    });
+    return dbInjector.createChildInjector(
+        new FactoryModule() {
+          @Override
+          public void configure() {
+            install(dbInjector.getInstance(BatchProgramModule.class));
+            DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+                .to(ReindexAfterUpdate.class);
+            install(new DummyIndexModule());
+            factory(ChangeResource.Factory.class);
+          }
+        });
   }
 
   private ListeningExecutorService newExecutor() {
     if (threads > 0) {
-      return MoreExecutors.listeningDecorator(
-          workQueue.createQueue(threads, "RebuildChange"));
+      return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "RebuildChange"));
     }
     return MoreExecutors.newDirectExecutorService();
   }
 
-  private ImmutableMultimap<Project.NameKey, Change.Id> getChangesByProject()
+  private ImmutableListMultimap<Project.NameKey, Change.Id> getChangesByProject()
       throws OrmException {
     // Memorize all changes so we can close the db connection and allow
     // rebuilder threads to use the full connection pool.
-    Multimap<Project.NameKey, Change.Id> changesByProject =
-        ArrayListMultimap.create();
+    ListMultimap<Project.NameKey, Change.Id> changesByProject =
+        MultimapBuilder.hashKeys().arrayListValues().build();
     try (ReviewDb db = schemaFactory.open()) {
       if (projects.isEmpty() && !changes.isEmpty()) {
-        Iterable<Change> todo = unwrapDb(db).changes().get(
-            Iterables.transform(changes, new Function<Integer, Change.Id>() {
-              @Override
-              public Change.Id apply(Integer in) {
-                return new Change.Id(in);
-              }
-            }));
+        Iterable<Change> todo =
+            unwrapDb(db).changes().get(Iterables.transform(changes, Change.Id::new));
         for (Change c : todo) {
           changesByProject.put(c.getProject(), c.getId());
         }
@@ -249,8 +247,7 @@
           boolean include = false;
           if (projects.isEmpty() && changes.isEmpty()) {
             include = true;
-          } else if (!projects.isEmpty()
-              && projects.contains(c.getProject().get())) {
+          } else if (!projects.isEmpty() && projects.contains(c.getProject().get())) {
             include = true;
           } else if (!changes.isEmpty() && changes.contains(c.getId().get())) {
             include = true;
@@ -260,7 +257,43 @@
           }
         }
       }
-      return ImmutableMultimap.copyOf(changesByProject);
+      return ImmutableListMultimap.copyOf(changesByProject);
     }
   }
+
+  private boolean rebuildProject(
+      ReviewDb db,
+      ImmutableListMultimap<Project.NameKey, Change.Id> allChanges,
+      Project.NameKey project,
+      Repository allUsersRepo)
+      throws IOException, OrmException {
+    checkArgument(allChanges.containsKey(project));
+    boolean ok = true;
+    ProgressMonitor pm =
+        new TextProgressMonitor(
+            new PrintWriter(new BufferedWriter(new OutputStreamWriter(System.out, UTF_8))));
+    pm.beginTask(FormatUtil.elide(project.get(), 50), allChanges.get(project).size());
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(project);
+        ObjectInserter allUsersInserter = allUsersRepo.newObjectInserter();
+        ObjectReader reader = allUsersInserter.newReader();
+        RevWalk allUsersRw = new RevWalk(reader)) {
+      manager.setAllUsersRepo(
+          allUsersRepo, allUsersRw, allUsersInserter, new ChainedReceiveCommands(allUsersRepo));
+      for (Change.Id changeId : allChanges.get(project)) {
+        try {
+          rebuilder.buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+        } catch (NoPatchSetsException e) {
+          log.warn(e.getMessage());
+        } catch (Throwable t) {
+          log.error("Failed to rebuild change " + changeId, t);
+          ok = false;
+        }
+        pm.update(1);
+      }
+      manager.execute();
+    } finally {
+      pm.endTask();
+    }
+    return ok;
+  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
index 2e7d88a..d73207b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.util.stream.Collectors.toSet;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -40,11 +39,6 @@
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.util.io.NullOutputStream;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -54,12 +48,16 @@
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.io.NullOutputStream;
+import org.kohsuke.args4j.Option;
 
 public class Reindex extends SiteProgram {
   @Option(name = "--threads", usage = "Number of threads to use for indexing")
   private int threads = Runtime.getRuntime().availableProcessors();
 
-  @Option(name = "--changes-schema-version",
+  @Option(
+      name = "--changes-schema-version",
       usage = "Schema version to reindex, for changes; default is most recent version")
   private Integer changesVersion;
 
@@ -76,19 +74,16 @@
   private Injector sysInjector;
   private Config globalConfig;
 
-  @Inject
-  private Collection<IndexDefinition<?, ?, ?>> indexDefs;
+  @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
 
   @Override
   public int run() throws Exception {
     mustHaveValidSite();
     dbInjector = createDbInjector(MULTI_USER);
-    globalConfig =
-        dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+    globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     threads = ThreadLimiter.limitThreads(dbInjector, threads);
     checkNotSlaveMode();
-    disableLuceneAutomaticCommit();
-    disableChangeCache();
+    overrideConfig();
     LifecycleManager dbManager = new LifecycleManager();
     dbManager.add(dbInjector);
     dbManager.start();
@@ -134,21 +129,14 @@
     }
 
     checkNotNull(indexDefs, "Called this method before injectMembers?");
-    Set<String> valid = FluentIterable.from(indexDefs).transform(
-        new Function<IndexDefinition<?, ?, ?>, String>() {
-          @Override
-          public String apply(IndexDefinition<?, ?, ?> input) {
-            return input.getName();
-          }
-        }).toSortedSet(Ordering.natural());
-
+    Set<String> valid = indexDefs.stream().map(IndexDefinition::getName).sorted().collect(toSet());
     Set<String> invalid = Sets.difference(Sets.newHashSet(indices), valid);
     if (invalid.isEmpty()) {
       return;
     }
 
-    throw die("invalid index name(s): " + new TreeSet<>(invalid)
-        + " available indices are: " + valid);
+    throw die(
+        "invalid index name(s): " + new TreeSet<>(invalid) + " available indices are: " + valid);
   }
 
   private void checkNotSlaveMode() throws Die {
@@ -166,40 +154,45 @@
     Module indexModule;
     switch (IndexModule.getIndexType(dbInjector)) {
       case LUCENE:
-        indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(
-            versions, threads);
+        indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads);
+        break;
+      case ELASTICSEARCH:
+        indexModule = ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads);
         break;
       default:
         throw new IllegalStateException("unsupported index.type");
     }
     modules.add(indexModule);
     modules.add(dbInjector.getInstance(BatchProgramModule.class));
-    modules.add(new FactoryModule() {
-      @Override
-      protected void configure() {
-        factory(ChangeResource.Factory.class);
-      }
-    });
+    modules.add(
+        new FactoryModule() {
+          @Override
+          protected void configure() {
+            factory(ChangeResource.Factory.class);
+          }
+        });
 
     return dbInjector.createChildInjector(modules);
   }
 
-  private void disableLuceneAutomaticCommit() {
+  private void overrideConfig() {
+    // Disable auto-commit for speed; committing will happen at the end of the process.
     if (IndexModule.getIndexType(dbInjector) == IndexType.LUCENE) {
       globalConfig.setLong("index", "changes_open", "commitWithin", -1);
       globalConfig.setLong("index", "changes_closed", "commitWithin", -1);
     }
-  }
 
-  private void disableChangeCache() {
+    // Disable change cache.
     globalConfig.setLong("cache", "changes", "maximumWeight", 0);
+
+    // Disable auto-reindexing if stale, since there are no concurrent writes to race with.
+    globalConfig.setBoolean("index", null, "autoReindexIfStale", false);
   }
 
-  private <K, V, I extends Index<K, V>> boolean reindex(
-      IndexDefinition<K, V, I> def) throws IOException {
+  private <K, V, I extends Index<K, V>> boolean reindex(IndexDefinition<K, V, I> def)
+      throws IOException {
     I index = def.getIndexCollection().getSearchIndex();
-    checkNotNull(index,
-        "no active search index configured for %s", def.getName());
+    checkNotNull(index, "no active search index configured for %s", def.getName());
     index.markReady(false);
     index.deleteAll();
 
@@ -209,8 +202,8 @@
     SiteIndexer.Result result = siteIndexer.indexAll(index);
     int n = result.doneCount() + result.failedCount();
     double t = result.elapsed(TimeUnit.MILLISECONDS) / 1000d;
-    System.out.format("Reindexed %d documents in %s index in %.01fs (%.01f/s)\n",
-        n, def.getName(), t, n / t);
+    System.out.format(
+        "Reindexed %d documents in %s index in %.01fs (%.01f/s)\n", n, def.getName(), t, n / t);
     if (result.success()) {
       index.markReady(true);
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Rulec.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Rulec.java
index 7134f49..add06ef 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Rulec.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Rulec.java
@@ -24,20 +24,17 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-
 import com.googlecode.prolog_cafe.exceptions.CompileException;
-
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-import java.util.ArrayList;
-import java.util.LinkedHashSet;
-import java.util.List;
-
 /**
- * Gets rules.pl at refs/meta/config and compiles into jar file called
- * rules-(sha1 of rules.pl).jar in (site-path)/cache/rules
+ * Gets rules.pl at refs/meta/config and compiles into jar file called rules-(sha1 of rules.pl).jar
+ * in (site-path)/cache/rules
  */
 public class Rulec extends SiteProgram {
   @Option(name = "--all", usage = "recompile all rules")
@@ -46,30 +43,35 @@
   @Option(name = "--quiet", usage = "suppress some messages")
   private boolean quiet;
 
-  @Argument(index = 0, multiValued = true, metaVar = "PROJECT", usage = "project to compile rules for")
+  @Argument(
+      index = 0,
+      multiValued = true,
+      metaVar = "PROJECT",
+      usage = "project to compile rules for")
   private List<String> projectNames = new ArrayList<>();
 
   private Injector dbInjector;
 
   private final LifecycleManager manager = new LifecycleManager();
 
-  @Inject
-  private GitRepositoryManager gitManager;
+  @Inject private GitRepositoryManager gitManager;
 
-  @Inject
-  private PrologCompiler.Factory jarFactory;
+  @Inject private PrologCompiler.Factory jarFactory;
 
   @Override
   public int run() throws Exception {
     dbInjector = createDbInjector(SINGLE_USER);
     manager.add(dbInjector);
     manager.start();
-    dbInjector.createChildInjector(new FactoryModule() {
-      @Override
-      protected void configure() {
-        factory(PrologCompiler.Factory.class);
-      }
-    }).injectMembers(this);
+    dbInjector
+        .createChildInjector(
+            new FactoryModule() {
+              @Override
+              protected void configure() {
+                factory(PrologCompiler.Factory.class);
+              }
+            })
+        .injectMembers(this);
 
     LinkedHashSet<Project.NameKey> names = new LinkedHashSet<>();
     for (String name : projectNames) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SwitchSecureStore.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SwitchSecureStore.java
index ac84e82..1cdd8a8 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -28,14 +28,6 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.securestore.SecureStore.EntryKey;
 import com.google.inject.Injector;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -44,11 +36,16 @@
 import java.util.List;
 import java.util.jar.JarFile;
 import java.util.zip.ZipEntry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class SwitchSecureStore extends SiteProgram {
   private static String getSecureStoreClassFromGerritConfig(SitePaths sitePaths) {
-    FileBasedConfig cfg =
-        new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
+    FileBasedConfig cfg = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
     try {
       cfg.load();
     } catch (IOException | ConfigInvalidException e) {
@@ -57,10 +54,10 @@
     return cfg.getString("gerrit", null, "secureStoreClass");
   }
 
-  private static final Logger log = LoggerFactory
-      .getLogger(SwitchSecureStore.class);
+  private static final Logger log = LoggerFactory.getLogger(SwitchSecureStore.class);
 
-  @Option(name = "--new-secure-store-lib",
+  @Option(
+      name = "--new-secure-store-lib",
       usage = "Path to new SecureStore implementation",
       required = true)
   private String newSecureStoreLib;
@@ -70,8 +67,7 @@
     SitePaths sitePaths = new SitePaths(getSitePath());
     Path newSecureStorePath = Paths.get(newSecureStoreLib);
     if (!Files.exists(newSecureStorePath)) {
-      log.error(String.format("File %s doesn't exist",
-          newSecureStorePath.toAbsolutePath()));
+      log.error("File {} doesn't exist", newSecureStorePath.toAbsolutePath());
       return -1;
     }
 
@@ -79,19 +75,20 @@
     String currentSecureStoreName = getCurrentSecureStoreClassName(sitePaths);
 
     if (currentSecureStoreName.equals(newSecureStore)) {
-      log.error("Old and new SecureStore implementation names "
-          + "are the same. Migration will not work");
+      log.error(
+          "Old and new SecureStore implementation names are the same. Migration will not work");
       return -1;
     }
 
     IoUtil.loadJARs(newSecureStorePath);
     SiteLibraryLoaderUtil.loadSiteLib(sitePaths.lib_dir);
 
-    log.info("Current secureStoreClass property ({}) will be replaced with {}",
-        currentSecureStoreName, newSecureStore);
+    log.info(
+        "Current secureStoreClass property ({}) will be replaced with {}",
+        currentSecureStoreName,
+        newSecureStore);
     Injector dbInjector = createDbInjector(SINGLE_USER);
-    SecureStore currentStore =
-        getSecureStore(currentSecureStoreName, dbInjector);
+    SecureStore currentStore = getSecureStore(currentSecureStoreName, dbInjector);
     SecureStore newStore = getSecureStore(newSecureStore, dbInjector);
 
     migrateProperties(currentStore, newStore);
@@ -107,14 +104,11 @@
   private void migrateProperties(SecureStore currentStore, SecureStore newStore) {
     log.info("Migrate entries");
     for (EntryKey key : currentStore.list()) {
-      String[] value =
-          currentStore.getList(key.section, key.subsection, key.name);
+      String[] value = currentStore.getList(key.section, key.subsection, key.name);
       if (value != null) {
-        newStore.setList(key.section, key.subsection, key.name,
-            Arrays.asList(value));
+        newStore.setList(key.section, key.subsection, key.name, Arrays.asList(value));
       } else {
-        String msg =
-            String.format("Cannot migrate entry for %s", key.section);
+        String msg = String.format("Cannot migrate entry for %s", key.section);
         if (key.subsection != null) {
           msg = msg + String.format(".%s", key.subsection);
         }
@@ -124,59 +118,52 @@
     }
   }
 
-  private void removeOldLib(SitePaths sitePaths, String currentSecureStoreName)
-      throws IOException {
-    Path oldSecureStore =
-        findJarWithSecureStore(sitePaths, currentSecureStoreName);
+  private void removeOldLib(SitePaths sitePaths, String currentSecureStoreName) throws IOException {
+    Path oldSecureStore = findJarWithSecureStore(sitePaths, currentSecureStoreName);
     if (oldSecureStore != null) {
-      log.info("Removing old SecureStore ({}) from lib/ directory",
-          oldSecureStore.getFileName());
+      log.info("Removing old SecureStore ({}) from lib/ directory", oldSecureStore.getFileName());
       try {
         Files.delete(oldSecureStore);
       } catch (IOException e) {
         log.error("Cannot remove {}", oldSecureStore.toAbsolutePath(), e);
       }
     } else {
-      log.info("Cannot find jar with old SecureStore ({}) in lib/ directory",
-          currentSecureStoreName);
+      log.info(
+          "Cannot find jar with old SecureStore ({}) in lib/ directory", currentSecureStoreName);
     }
   }
 
-  private void copyNewLib(SitePaths sitePaths, Path newSecureStorePath)
-      throws IOException {
-    log.info("Copy new SecureStore ({}) into lib/ directory",
-        newSecureStorePath.getFileName());
-    Files.copy(newSecureStorePath,
-        sitePaths.lib_dir.resolve(newSecureStorePath.getFileName()));
+  private void copyNewLib(SitePaths sitePaths, Path newSecureStorePath) throws IOException {
+    log.info("Copy new SecureStore ({}) into lib/ directory", newSecureStorePath.getFileName());
+    Files.copy(newSecureStorePath, sitePaths.lib_dir.resolve(newSecureStorePath.getFileName()));
   }
 
   private void updateGerritConfig(SitePaths sitePaths, String newSecureStore)
       throws IOException, ConfigInvalidException {
-    log.info("Set gerrit.secureStoreClass property of gerrit.config to {}",
-        newSecureStore);
-    FileBasedConfig config =
-        new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
+    log.info("Set gerrit.secureStoreClass property of gerrit.config to {}", newSecureStore);
+    FileBasedConfig config = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
     config.load();
     config.setString("gerrit", null, "secureStoreClass", newSecureStore);
     config.save();
   }
 
-  private String getNewSecureStoreClassName(Path secureStore)
-      throws IOException {
-    JarScanner scanner = new JarScanner(secureStore);
-    List<String> newSecureStores =
-        scanner.findSubClassesOf(SecureStore.class);
-    if (newSecureStores.isEmpty()) {
-      throw new RuntimeException(String.format(
-          "Cannot find implementation of SecureStore interface in %s",
-          secureStore.toAbsolutePath()));
+  private String getNewSecureStoreClassName(Path secureStore) throws IOException {
+    try (JarScanner scanner = new JarScanner(secureStore)) {
+      List<String> newSecureStores = scanner.findSubClassesOf(SecureStore.class);
+      if (newSecureStores.isEmpty()) {
+        throw new RuntimeException(
+            String.format(
+                "Cannot find implementation of SecureStore interface in %s",
+                secureStore.toAbsolutePath()));
+      }
+      if (newSecureStores.size() > 1) {
+        throw new RuntimeException(
+            String.format(
+                "Found too many implementations of SecureStore:\n%s\nin %s",
+                Joiner.on("\n").join(newSecureStores), secureStore.toAbsolutePath()));
+      }
+      return Iterables.getOnlyElement(newSecureStores);
     }
-    if (newSecureStores.size() > 1) {
-      throw new RuntimeException(String.format(
-          "Found too many implementations of SecureStore:\n%s\nin %s", Joiner
-              .on("\n").join(newSecureStores), secureStore.toAbsolutePath()));
-    }
-    return Iterables.getOnlyElement(newSecureStores);
   }
 
   private String getCurrentSecureStoreClassName(SitePaths sitePaths) {
@@ -190,8 +177,7 @@
   private SecureStore getSecureStore(String className, Injector injector) {
     try {
       @SuppressWarnings("unchecked")
-      Class<? extends SecureStore> clazz =
-          (Class<? extends SecureStore>) Class.forName(className);
+      Class<? extends SecureStore> clazz = (Class<? extends SecureStore>) Class.forName(className);
       return injector.getInstance(clazz);
     } catch (ClassNotFoundException e) {
       throw new RuntimeException(
@@ -199,8 +185,8 @@
     }
   }
 
-  private Path findJarWithSecureStore(SitePaths sitePaths,
-      String secureStoreClass) throws IOException {
+  private Path findJarWithSecureStore(SitePaths sitePaths, String secureStoreClass)
+      throws IOException {
     List<Path> jars = SiteLibraryLoaderUtil.listJars(sitePaths.lib_dir);
     String secureStoreClassPath = secureStoreClass.replace('.', '/') + ".class";
     for (Path jar : jars) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/WarDistribution.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/WarDistribution.java
index 3328a54..d3df9d3 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/WarDistribution.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/WarDistribution.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.pgm.init.PluginsDistribution;
 import com.google.inject.Singleton;
-
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
@@ -29,12 +28,15 @@
 import java.util.List;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class WarDistribution implements PluginsDistribution {
+  private static final Logger log = LoggerFactory.getLogger(WarDistribution.class);
 
   @Override
-  public void foreach(Processor processor) throws FileNotFoundException, IOException {
+  public void foreach(Processor processor) throws IOException {
     File myWar = GerritLauncher.getDistributionArchive();
     if (myWar.isFile()) {
       try (ZipFile zf = new ZipFile(myWar)) {
@@ -47,10 +49,11 @@
 
           if (ze.getName().startsWith(PLUGIN_DIR) && ze.getName().endsWith(JAR)) {
             String pluginJarName = new File(ze.getName()).getName();
-            String pluginName = pluginJarName.substring(0,
-                pluginJarName.length() - JAR.length());
+            String pluginName = pluginJarName.substring(0, pluginJarName.length() - JAR.length());
             try (InputStream in = zf.getInputStream(ze)) {
               processor.process(pluginName, in);
+            } catch (IOException ioe) {
+              log.error("Error opening plugin {}: {}", ze.getName(), ioe.getMessage());
             }
           }
         }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
index 0c2ec78..2fbbb97 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HiddenErrorHandler.java
@@ -18,7 +18,10 @@
 
 import com.google.common.base.Strings;
 import com.google.gwtexpui.server.CacheHeaders;
-
+import java.io.IOException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jetty.http.HttpHeader;
 import org.eclipse.jetty.http.HttpStatus;
 import org.eclipse.jetty.server.HttpConnection;
@@ -27,18 +30,13 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
-import javax.servlet.ServletOutputStream;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
 class HiddenErrorHandler extends ErrorHandler {
   private static final Logger log = LoggerFactory.getLogger(HiddenErrorHandler.class);
 
   @Override
-  public void handle(String target, Request baseRequest,
-      HttpServletRequest req, HttpServletResponse res) throws IOException {
+  public void handle(
+      String target, Request baseRequest, HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
     HttpConnection conn = HttpConnection.getCurrentConnection();
     baseRequest.setHandled(true);
     try {
@@ -48,11 +46,9 @@
     }
   }
 
-  private void reply(HttpConnection conn, HttpServletResponse res)
-      throws IOException {
+  private void reply(HttpConnection conn, HttpServletResponse res) throws IOException {
     byte[] msg = message(conn);
-    res.setHeader(HttpHeader.CONTENT_TYPE.asString(),
-        "text/plain; charset=ISO-8859-1");
+    res.setHeader(HttpHeader.CONTENT_TYPE.asString(), "text/plain; charset=ISO-8859-1");
     res.setContentLength(msg.length);
     try {
       CacheHeaders.setNotCacheable(res);
@@ -70,21 +66,20 @@
     } else {
       msg = conn.getHttpChannel().getResponse().getReason();
       if (msg == null) {
-        msg = HttpStatus.getMessage(conn.getHttpChannel()
-            .getResponse().getStatus());
+        msg = HttpStatus.getMessage(conn.getHttpChannel().getResponse().getStatus());
       }
     }
     return msg.getBytes(ISO_8859_1);
   }
 
   private static void log(HttpServletRequest req) {
-    Throwable err = (Throwable)req.getAttribute("javax.servlet.error.exception");
+    Throwable err = (Throwable) req.getAttribute("javax.servlet.error.exception");
     if (err != null) {
       String uri = req.getRequestURI();
       if (!Strings.isNullOrEmpty(req.getQueryString())) {
         uri += "?" + req.getQueryString();
       }
-      log.error(String.format("Error in %s %s", req.getMethod(), uri), err);
+      log.error("Error in {} {}", req.getMethod(), uri, err);
     }
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
index a326919..9f54634 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.inject.Inject;
-
 import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
@@ -55,8 +54,7 @@
   }
 
   @Override
-  protected void doStart() throws Exception {
-  }
+  protected void doStart() throws Exception {}
 
   @Override
   protected void doStop() throws Exception {
@@ -65,18 +63,19 @@
 
   @Override
   public void log(final Request req, final Response rsp) {
-    final LoggingEvent event = new LoggingEvent( //
-        Logger.class.getName(), // fqnOfCategoryClass
-        log, // logger
-        TimeUtil.nowMs(), // when
-        Level.INFO, // level
-        "", // message text
-        "HTTPD", // thread name
-        null, // exception information
-        null, // current NDC string
-        null, // caller location
-        null // MDC properties
-        );
+    final LoggingEvent event =
+        new LoggingEvent( //
+            Logger.class.getName(), // fqnOfCategoryClass
+            log, // logger
+            TimeUtil.nowMs(), // when
+            Level.INFO, // level
+            "", // message text
+            "HTTPD", // thread name
+            null, // exception information
+            null, // current NDC string
+            null, // caller location
+            null // MDC properties
+            );
 
     String uri = req.getRequestURI();
     String qs = req.getQueryString();
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
index bab4de7..bfa4d64 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
-import org.apache.log4j.Layout;
-import org.apache.log4j.spi.LoggingEvent;
-
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.TimeZone;
+import org.apache.log4j.Layout;
+import org.apache.log4j.spi.LoggingEvent;
 
 public final class HttpLogLayout extends Layout {
   private final SimpleDateFormat dateFormat;
@@ -116,7 +115,5 @@
   }
 
   @Override
-  public void activateOptions() {
-  }
+  public void activateOptions() {}
 }
-
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index b065436..0103aae 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -18,9 +18,9 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.pgm.http.jetty.HttpLog.HttpLogFactory;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
@@ -29,7 +29,19 @@
 import com.google.inject.Singleton;
 import com.google.inject.servlet.GuiceFilter;
 import com.google.inject.servlet.GuiceServletContextListener;
-
+import java.lang.management.ManagementFactory;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import javax.servlet.DispatcherType;
+import javax.servlet.Filter;
 import org.eclipse.jetty.http.HttpScheme;
 import org.eclipse.jetty.jmx.MBeanContainer;
 import org.eclipse.jetty.server.Connector;
@@ -45,6 +57,7 @@
 import org.eclipse.jetty.server.handler.ContextHandler;
 import org.eclipse.jetty.server.handler.ContextHandlerCollection;
 import org.eclipse.jetty.server.handler.RequestLogHandler;
+import org.eclipse.jetty.server.handler.StatisticsHandler;
 import org.eclipse.jetty.server.session.SessionHandler;
 import org.eclipse.jetty.servlet.DefaultServlet;
 import org.eclipse.jetty.servlet.FilterHolder;
@@ -56,27 +69,9 @@
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
 import org.eclipse.jetty.util.thread.ThreadPool;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.lang.management.ManagementFactory;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-import javax.servlet.DispatcherType;
-import javax.servlet.Filter;
 
 @Singleton
 public class JettyServer {
-  private static final Logger log = LoggerFactory.getLogger(JettyServer.class);
-
   static class Lifecycle implements LifecycleListener {
     private final JettyServer server;
     private final Config cfg;
@@ -91,14 +86,13 @@
     public void start() {
       try {
         String origUrl = cfg.getString("httpd", null, "listenUrl");
-        boolean rewrite = !Strings.isNullOrEmpty(origUrl)
-            && origUrl.endsWith(":0/");
+        boolean rewrite = !Strings.isNullOrEmpty(origUrl) && origUrl.endsWith(":0/");
         server.httpd.start();
         if (rewrite) {
           Connector con = server.httpd.getConnectors()[0];
           if (con instanceof ServerConnector) {
             @SuppressWarnings("resource")
-            ServerConnector serverCon = (ServerConnector)con;
+            ServerConnector serverCon = (ServerConnector) con;
             String host = serverCon.getHost();
             int port = serverCon.getLocalPort();
             String url = String.format("http://%s:%d", host, port);
@@ -128,7 +122,8 @@
   private boolean reverseProxy;
 
   @Inject
-  JettyServer(@GerritServerConfig Config cfg,
+  JettyServer(
+      @GerritServerConfig Config cfg,
       ThreadSettingsConfig threadSettingsConfig,
       SitePaths site,
       JettyEnv env,
@@ -146,13 +141,21 @@
       app = handler;
     }
     if (cfg.getBoolean("httpd", "registerMBeans", false)) {
-      MBeanContainer mbean =
-          new MBeanContainer(ManagementFactory.getPlatformMBeanServer());
+      MBeanContainer mbean = new MBeanContainer(ManagementFactory.getPlatformMBeanServer());
       httpd.addEventListener(mbean);
       httpd.addBean(Log.getRootLogger());
       httpd.addBean(mbean);
     }
 
+    long gracefulStopTimeout =
+        cfg.getTimeUnit("httpd", null, "gracefulStopTimeout", 0L, TimeUnit.MILLISECONDS);
+    if (gracefulStopTimeout > 0) {
+      StatisticsHandler statsHandler = new StatisticsHandler();
+      statsHandler.setHandler(app);
+      app = statsHandler;
+      httpd.setStopTimeout(gracefulStopTimeout);
+    }
+
     httpd.setHandler(app);
     httpd.setStopAtShutdown(false);
   }
@@ -163,8 +166,7 @@
     // need to use a larger default header size to ensure we have
     // the space required.
     //
-    final int requestHeaderSize =
-        cfg.getInt("httpd", "requestheadersize", 16386);
+    final int requestHeaderSize = cfg.getInt("httpd", "requestheadersize", 16386);
     final URI[] listenUrls = listenURLs(cfg);
     final boolean reuseAddress = cfg.getBoolean("httpd", "reuseaddress", true);
     final int acceptors = cfg.getInt("httpd", "acceptorThreads", 2);
@@ -178,11 +180,16 @@
       final ServerConnector c;
       HttpConfiguration config = defaultConfig(requestHeaderSize);
 
-      if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType) && ! "https".equals(u.getScheme())) {
-        throw new IllegalArgumentException("Protocol '" + u.getScheme()
-            + "' " + " not supported in httpd.listenurl '" + u
-            + "' when auth.type = '" + AuthType.CLIENT_SSL_CERT_LDAP.name()
-            + "'; only 'https' is supported");
+      if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType) && !"https".equals(u.getScheme())) {
+        throw new IllegalArgumentException(
+            "Protocol '"
+                + u.getScheme()
+                + "' "
+                + " not supported in httpd.listenurl '"
+                + u
+                + "' when auth.type = '"
+                + AuthType.CLIENT_SSL_CERT_LDAP.name()
+                + "'; only 'https' is supported");
       }
 
       if ("http".equals(u.getScheme())) {
@@ -204,7 +211,7 @@
         if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType)) {
           ssl.setNeedClientAuth(true);
 
-          Path crl = getFile(cfg, "sslcrl", "etc/crl.pem");
+          Path crl = getFile(cfg, "sslCrl", "etc/crl.pem");
           if (Files.exists(crl)) {
             ssl.setCrlPath(crl.toAbsolutePath().toString());
             ssl.setValidatePeerCerts(true);
@@ -214,10 +221,16 @@
         defaultPort = 443;
 
         config.addCustomizer(new SecureRequestCustomizer());
-        c = new ServerConnector(server,
-            null, null, null, 0, acceptors,
-            new SslConnectionFactory(ssl, "http/1.1"),
-            new HttpConnectionFactory(config));
+        c =
+            new ServerConnector(
+                server,
+                null,
+                null,
+                null,
+                0,
+                acceptors,
+                new SslConnectionFactory(ssl, "http/1.1"),
+                new HttpConnectionFactory(config));
 
       } else if ("proxy-http".equals(u.getScheme())) {
         defaultPort = 8080;
@@ -227,32 +240,38 @@
       } else if ("proxy-https".equals(u.getScheme())) {
         defaultPort = 8080;
         config.addCustomizer(new ForwardedRequestCustomizer());
-        config.addCustomizer(new HttpConfiguration.Customizer() {
-          @Override
-          public void customize(Connector connector,
-              HttpConfiguration channelConfig, Request request) {
-            request.setScheme(HttpScheme.HTTPS.asString());
-            request.setSecure(true);
-          }
-        });
+        config.addCustomizer(
+            new HttpConfiguration.Customizer() {
+              @Override
+              public void customize(
+                  Connector connector, HttpConfiguration channelConfig, Request request) {
+                request.setScheme(HttpScheme.HTTPS.asString());
+                request.setSecure(true);
+              }
+            });
         c = newServerConnector(server, acceptors, config);
 
       } else {
-        throw new IllegalArgumentException("Protocol '" + u.getScheme() + "' "
-            + " not supported in httpd.listenurl '" + u + "';"
-            + " only 'http', 'https', 'proxy-http, 'proxy-https'"
-            + " are supported");
+        throw new IllegalArgumentException(
+            "Protocol '"
+                + u.getScheme()
+                + "' "
+                + " not supported in httpd.listenurl '"
+                + u
+                + "';"
+                + " only 'http', 'https', 'proxy-http, 'proxy-https'"
+                + " are supported");
       }
 
       try {
-        if (u.getHost() == null && (u.getAuthority().equals("*") //
-            || u.getAuthority().startsWith("*:"))) {
+        if (u.getHost() == null
+            && (u.getAuthority().equals("*") //
+                || u.getAuthority().startsWith("*:"))) {
           // Bind to all local addresses. Port wasn't parsed right by URI
           // due to the illegal host of "*" so replace with a legal name
           // and parse the URI.
           //
-          final URI r =
-              new URI(u.toString().replace('*', 'A')).parseServerAuthority();
+          final URI r = new URI(u.toString().replace('*', 'A')).parseServerAuthority();
           c.setHost(null);
           c.setPort(0 < r.getPort() ? r.getPort() : defaultPort);
         } else {
@@ -265,17 +284,16 @@
       }
       c.setInheritChannel(cfg.getBoolean("httpd", "inheritChannel", false));
       c.setReuseAddress(reuseAddress);
-      c.setIdleTimeout(
-          cfg.getTimeUnit("httpd", null, "idleTimeout", 30000L, MILLISECONDS));
+      c.setIdleTimeout(cfg.getTimeUnit("httpd", null, "idleTimeout", 30000L, MILLISECONDS));
       connectors[idx] = c;
     }
     return connectors;
   }
 
-  private static ServerConnector newServerConnector(Server server,
-      int acceptors, HttpConfiguration config) {
-    return new ServerConnector(server, null, null, null, 0, acceptors,
-        new HttpConnectionFactory(config));
+  private static ServerConnector newServerConnector(
+      Server server, int acceptors, HttpConfiguration config) {
+    return new ServerConnector(
+        server, null, null, null, 0, acceptors, new HttpConnectionFactory(config));
   }
 
   private HttpConfiguration defaultConfig(int requestHeaderSize) {
@@ -325,19 +343,18 @@
     int maxThreads = threadSettingsConfig.getHttpdMaxThreads();
     int minThreads = cfg.getInt("httpd", null, "minthreads", 5);
     int maxQueued = cfg.getInt("httpd", null, "maxqueued", 200);
-    int idleTimeout = (int)MILLISECONDS.convert(60, SECONDS);
-    int maxCapacity = maxQueued == 0
-        ? Integer.MAX_VALUE
-        : Math.max(minThreads, maxQueued);
-    QueuedThreadPool pool = new QueuedThreadPool(
-        maxThreads,
-        minThreads,
-        idleTimeout,
-        new BlockingArrayQueue<Runnable>(
-            minThreads, // capacity,
-            minThreads, // growBy,
-            maxCapacity // maxCapacity
-    ));
+    int idleTimeout = (int) MILLISECONDS.convert(60, SECONDS);
+    int maxCapacity = maxQueued == 0 ? Integer.MAX_VALUE : Math.max(minThreads, maxQueued);
+    QueuedThreadPool pool =
+        new QueuedThreadPool(
+            maxThreads,
+            minThreads,
+            idleTimeout,
+            new BlockingArrayQueue<Runnable>(
+                minThreads, // capacity,
+                minThreads, // growBy,
+                maxCapacity // maxCapacity
+                ));
     pool.setName("HTTP");
     return pool;
   }
@@ -375,8 +392,8 @@
     return r;
   }
 
-  private ContextHandler makeContext(final String contextPath,
-      final JettyEnv env, final Config cfg) {
+  private ContextHandler makeContext(
+      final String contextPath, final JettyEnv env, final Config cfg) {
     final ServletContextHandler app = new ServletContextHandler();
 
     // This enables the use of sessions in Jetty, feature available
@@ -389,24 +406,24 @@
     //
     app.setContextPath(contextPath);
 
-    // HTTP front-end filter to be used as surrogate of Apache HTTP
+    // HTTP front-end filters to be used as surrogate of Apache HTTP
     // reverse-proxy filtering.
     // It is meant to be used as simpler tiny deployment of custom-made
     // security enforcement (Security tokens, IP-based security filtering, others)
-    String filterClassName = cfg.getString("httpd", null, "filterClass");
-    if (filterClassName != null) {
+    String[] filterClassNames = cfg.getStringList("httpd", null, "filterClass");
+    for (String filterClassName : filterClassNames) {
       try {
         @SuppressWarnings("unchecked")
         Class<? extends Filter> filterClass =
             (Class<? extends Filter>) Class.forName(filterClassName);
         Filter filter = env.webInjector.getInstance(filterClass);
-        app.addFilter(new FilterHolder(filter), "/*",
+        app.addFilter(
+            new FilterHolder(filter),
+            "/*",
             EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
       } catch (Throwable e) {
-        String errorMessage =
-            "Unable to instantiate front-end HTTP Filter " + filterClassName;
-        log.error(errorMessage, e);
-        throw new IllegalArgumentException(errorMessage, e);
+        throw new IllegalArgumentException(
+            "Unable to instantiate front-end HTTP Filter " + filterClassName, e);
       }
     }
 
@@ -415,15 +432,15 @@
     // already have built.
     //
     GuiceFilter filter = env.webInjector.getInstance(GuiceFilter.class);
-    app.addFilter(new FilterHolder(filter), "/*", EnumSet.of(
-        DispatcherType.REQUEST,
-        DispatcherType.ASYNC));
-    app.addEventListener(new GuiceServletContextListener() {
-      @Override
-      protected Injector getInjector() {
-        return env.webInjector;
-      }
-    });
+    app.addFilter(
+        new FilterHolder(filter), "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
+    app.addEventListener(
+        new GuiceServletContextListener() {
+          @Override
+          protected Injector getInjector() {
+            return env.webInjector;
+          }
+        });
 
     // Jetty requires at least one servlet be bound before it will
     // bother running the filter above. Since the filter has all
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
index ccb4192..ebf3686 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.pgm.http.jetty;
 
 import static com.google.gerrit.server.config.ConfigUtil.getTimeUnit;
-import static com.google.inject.Scopes.SINGLETON;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
 
@@ -29,16 +28,9 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
-
-import org.eclipse.jetty.continuation.Continuation;
-import org.eclipse.jetty.continuation.ContinuationListener;
-import org.eclipse.jetty.continuation.ContinuationSupport;
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -48,20 +40,23 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.continuation.Continuation;
+import org.eclipse.jetty.continuation.ContinuationListener;
+import org.eclipse.jetty.continuation.ContinuationSupport;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Use Jetty continuations to defer execution until threads are available.
- * <p>
- * We actually schedule a task into the same execution queue as the SSH daemon
- * uses for command execution, and then park the web request in a continuation
- * until an execution thread is available. This ensures that the overall JVM
- * process doesn't exceed the configured limit on concurrent Git requests.
- * <p>
- * During Git request execution however we have to use the Jetty service thread,
- * not the thread from the SSH execution queue. Trying to complete the request
- * on the SSH execution queue caused Jetty's HTTP parser to crash, so we instead
- * block the SSH execution queue thread and ask Jetty to resume processing on
- * the web service thread.
+ *
+ * <p>We actually schedule a task into the same execution queue as the SSH daemon uses for command
+ * execution, and then park the web request in a continuation until an execution thread is
+ * available. This ensures that the overall JVM process doesn't exceed the configured limit on
+ * concurrent Git requests.
+ *
+ * <p>During Git request execution however we have to use the Jetty service thread, not the thread
+ * from the SSH execution queue. Trying to complete the request on the SSH execution queue caused
+ * Jetty's HTTP parser to crash, so we instead block the SSH execution queue thread and ask Jetty to
+ * resume processing on the web service thread.
  */
 @Singleton
 public class ProjectQoSFilter implements Filter {
@@ -69,16 +64,14 @@
   private static final String TASK = ATT_SPACE + "/TASK";
   private static final String CANCEL = ATT_SPACE + "/CANCEL";
 
-  private static final String FILTER_RE =
-      "^/(.*)/(git-upload-pack|git-receive-pack)$";
+  private static final String FILTER_RE = "^/(.*)/(git-upload-pack|git-receive-pack)$";
   private static final Pattern URI_PATTERN = Pattern.compile(FILTER_RE);
 
   public static class Module extends ServletModule {
 
     @Override
     protected void configureServlets() {
-      bind(QueueProvider.class).to(CommandExecutorQueueProvider.class)
-          .in(SINGLETON);
+      bind(QueueProvider.class).to(CommandExecutorQueueProvider.class);
       filterRegex(FILTER_RE).through(ProjectQoSFilter.class);
     }
   }
@@ -90,8 +83,10 @@
   private final long maxWait;
 
   @Inject
-  ProjectQoSFilter(final Provider<CurrentUser> user,
-      QueueProvider queue, final ServletContext context,
+  ProjectQoSFilter(
+      final Provider<CurrentUser> user,
+      QueueProvider queue,
+      final ServletContext context,
       @GerritServerConfig final Config cfg) {
     this.user = user;
     this.queue = queue;
@@ -100,8 +95,8 @@
   }
 
   @Override
-  public void doFilter(ServletRequest request, ServletResponse response,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
     final HttpServletRequest req = (HttpServletRequest) request;
     final HttpServletResponse rsp = (HttpServletResponse) response;
     final Continuation cont = ContinuationSupport.getContinuation(req);
@@ -145,15 +140,12 @@
   }
 
   @Override
-  public void init(FilterConfig config) {
-  }
+  public void init(FilterConfig config) {}
 
   @Override
-  public void destroy() {
-  }
+  public void destroy() {}
 
-  private final class TaskThunk implements CancelableRunnable,
-      ContinuationListener {
+  private final class TaskThunk implements CancelableRunnable, ContinuationListener {
 
     private final WorkQueue.Executor executor;
     private final Continuation cont;
@@ -162,8 +154,8 @@
     private boolean done;
     private Thread worker;
 
-    TaskThunk(final WorkQueue.Executor executor, final Continuation cont,
-        final HttpServletRequest req) {
+    TaskThunk(
+        final WorkQueue.Executor executor, final Continuation cont, final HttpServletRequest req) {
       this.executor = executor;
       this.cont = cont;
       this.name = generateName(req);
@@ -209,8 +201,7 @@
     }
 
     @Override
-    public void onComplete(Continuation self) {
-    }
+    public void onComplete(Continuation self) {}
 
     @Override
     public void onTimeout(Continuation self) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AllUsersNameOnInitProvider.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AllUsersNameOnInitProvider.java
index 29e6166..2ace787 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AllUsersNameOnInitProvider.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AllUsersNameOnInitProvider.java
@@ -27,8 +27,7 @@
   @Inject
   AllUsersNameOnInitProvider(Section.Factory sections) {
     String n = sections.get("gerrit", null).get("allUsers");
-    name = MoreObjects.firstNonNull(
-        Strings.emptyToNull(n), AllUsersNameProvider.DEFAULT);
+    name = MoreObjects.firstNonNull(Strings.emptyToNull(n), AllUsersNameProvider.DEFAULT);
   }
 
   @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
index f625f75..207bdfb 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -30,12 +30,16 @@
 import com.google.gerrit.pgm.init.api.InstallAllPlugins;
 import com.google.gerrit.pgm.init.api.InstallPlugins;
 import com.google.gerrit.pgm.init.api.LibraryDownload;
+import com.google.gerrit.pgm.init.index.IndexManagerOnInit;
+import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
+import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.plugins.JarScanner;
 import com.google.gerrit.server.schema.SchemaUpdater;
 import com.google.gerrit.server.schema.UpdateUI;
@@ -57,10 +61,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.spi.Message;
 import com.google.inject.util.Providers;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.file.FileVisitResult;
@@ -73,13 +73,13 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
-
 import javax.sql.DataSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Initialize a new Gerrit installation. */
 public class BaseInit extends SiteProgram {
-  private static final Logger log =
-      LoggerFactory.getLogger(BaseInit.class);
+  private static final Logger log = LoggerFactory.getLogger(BaseInit.class);
 
   private final boolean standalone;
   private final boolean initDb;
@@ -88,22 +88,29 @@
 
   private Injector sysInjector;
 
-  protected BaseInit(PluginsDistribution pluginsDistribution,
-      List<String> pluginsToInstall) {
+  protected BaseInit(PluginsDistribution pluginsDistribution, List<String> pluginsToInstall) {
     this.standalone = true;
     this.initDb = true;
     this.pluginsDistribution = pluginsDistribution;
     this.pluginsToInstall = pluginsToInstall;
   }
 
-  public BaseInit(Path sitePath, boolean standalone, boolean initDb,
-      PluginsDistribution pluginsDistribution, List<String> pluginsToInstall) {
+  public BaseInit(
+      Path sitePath,
+      boolean standalone,
+      boolean initDb,
+      PluginsDistribution pluginsDistribution,
+      List<String> pluginsToInstall) {
     this(sitePath, null, standalone, initDb, pluginsDistribution, pluginsToInstall);
   }
 
-  public BaseInit(Path sitePath, final Provider<DataSource> dsProvider,
-      boolean standalone, boolean initDb,
-      PluginsDistribution pluginsDistribution, List<String> pluginsToInstall) {
+  public BaseInit(
+      Path sitePath,
+      final Provider<DataSource> dsProvider,
+      boolean standalone,
+      boolean initDb,
+      PluginsDistribution pluginsDistribution,
+      List<String> pluginsToInstall) {
     super(sitePath, dsProvider);
     this.standalone = standalone;
     this.initDb = initDb;
@@ -123,16 +130,28 @@
     init.flags.skipPlugins = skipPlugins();
     init.flags.deleteCaches = getDeleteCaches();
 
-
     final SiteRun run;
     try {
       init.initializer.run();
       init.flags.deleteOnFailure = false;
 
-      run = createSiteRun(init);
-      run.upgradeSchema();
+      Injector sysInjector = createSysInjector(init);
+      IndexManagerOnInit indexManager = sysInjector.getInstance(IndexManagerOnInit.class);
+      try {
+        indexManager.start();
+        run = createSiteRun(init);
+        try {
+          run.upgradeSchema();
+        } catch (OrmException e) {
+          String msg = "Couldn't upgrade schema. Expected if slave and read-only database";
+          System.err.println(msg);
+          log.warn(msg, e);
+        }
 
-      init.initializer.postRun(createSysInjector(init));
+        init.initializer.postRun(sysInjector);
+      } finally {
+        indexManager.stop();
+      }
     } catch (Exception | Error failure) {
       if (init.flags.deleteOnFailure) {
         recursiveDelete(getSitePath());
@@ -177,8 +196,7 @@
    * @param run completed run instance.
    * @throws Exception
    */
-  protected void afterInit(SiteRun run) throws Exception {
-  }
+  protected void afterInit(SiteRun run) throws Exception {}
 
   protected List<String> getInstallPlugins() {
     try {
@@ -187,7 +205,7 @@
       }
       List<String> names = pluginsDistribution.listPluginNames();
       if (pluginsToInstall != null) {
-        for (Iterator<String> i = names.iterator(); i.hasNext();) {
+        for (Iterator<String> i = names.iterator(); i.hasNext(); ) {
           String n = i.next();
           if (!pluginsToInstall.contains(n)) {
             i.remove();
@@ -196,8 +214,7 @@
       }
       return names;
     } catch (FileNotFoundException e) {
-      log.warn("Couldn't find distribution archive location."
-          + " No plugin will be installed");
+      log.warn("Couldn't find distribution archive location. No plugin will be installed");
       return null;
     }
   }
@@ -217,7 +234,10 @@
     final SitePathInitializer initializer;
 
     @Inject
-    SiteInit(final SitePaths site, final InitFlags flags, final ConsoleUI ui,
+    SiteInit(
+        final SitePaths site,
+        final InitFlags flags,
+        final ConsoleUI ui,
         final SitePathInitializer initializer) {
       this.site = site;
       this.flags = flags;
@@ -233,53 +253,57 @@
     final SecureStoreInitData secureStoreInitData = discoverSecureStoreClass();
     final String currentSecureStoreClassName = getConfiguredSecureStoreClass();
 
-    if (secureStoreInitData != null && currentSecureStoreClassName != null
+    if (secureStoreInitData != null
+        && currentSecureStoreClassName != null
         && !currentSecureStoreClassName.equals(secureStoreInitData.className)) {
-      String err = String.format(
-          "Different secure store was previously configured: %s. "
-          + "Use SwitchSecureStore program to switch between implementations.",
-          currentSecureStoreClassName);
+      String err =
+          String.format(
+              "Different secure store was previously configured: %s. "
+                  + "Use SwitchSecureStore program to switch between implementations.",
+              currentSecureStoreClassName);
       throw die(err);
     }
 
     m.add(new GerritServerConfigModule());
     m.add(new InitModule(standalone, initDb));
-    m.add(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(ConsoleUI.class).toInstance(ui);
-        bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-        List<String> plugins =
-            MoreObjects.firstNonNull(
-                getInstallPlugins(), new ArrayList<String>());
-        bind(new TypeLiteral<List<String>>() {}).annotatedWith(
-            InstallPlugins.class).toInstance(plugins);
-        bind(new TypeLiteral<Boolean>() {}).annotatedWith(
-            InstallAllPlugins.class).toInstance(installAllPlugins());
-        bind(PluginsDistribution.class).toInstance(pluginsDistribution);
+    m.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(ConsoleUI.class).toInstance(ui);
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+            List<String> plugins =
+                MoreObjects.firstNonNull(getInstallPlugins(), new ArrayList<String>());
+            bind(new TypeLiteral<List<String>>() {})
+                .annotatedWith(InstallPlugins.class)
+                .toInstance(plugins);
+            bind(new TypeLiteral<Boolean>() {})
+                .annotatedWith(InstallAllPlugins.class)
+                .toInstance(installAllPlugins());
+            bind(PluginsDistribution.class).toInstance(pluginsDistribution);
 
-        String secureStoreClassName;
-        if (secureStoreInitData != null) {
-          secureStoreClassName = secureStoreInitData.className;
-        } else {
-          secureStoreClassName = currentSecureStoreClassName;
-        }
-        if (secureStoreClassName != null) {
-          ui.message("Using secure store: %s\n", secureStoreClassName);
-        }
-        bind(SecureStoreInitData.class).toProvider(
-            Providers.of(secureStoreInitData));
-        bind(String.class).annotatedWith(SecureStoreClassName.class)
-            .toProvider(Providers.of(secureStoreClassName));
-        bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
-        bind(new TypeLiteral<List<String>>() {}).annotatedWith(
-            LibraryDownload.class).toInstance(getSkippedDownloads());
-        bind(Boolean.class).annotatedWith(
-            LibraryDownload.class).toInstance(skipAllDownloads());
+            String secureStoreClassName;
+            if (secureStoreInitData != null) {
+              secureStoreClassName = secureStoreInitData.className;
+            } else {
+              secureStoreClassName = currentSecureStoreClassName;
+            }
+            if (secureStoreClassName != null) {
+              ui.message("Using secure store: %s\n", secureStoreClassName);
+            }
+            bind(SecureStoreInitData.class).toProvider(Providers.of(secureStoreInitData));
+            bind(String.class)
+                .annotatedWith(SecureStoreClassName.class)
+                .toProvider(Providers.of(secureStoreClassName));
+            bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
+            bind(new TypeLiteral<List<String>>() {})
+                .annotatedWith(LibraryDownload.class)
+                .toInstance(getSkippedDownloads());
+            bind(Boolean.class).annotatedWith(LibraryDownload.class).toInstance(skipAllDownloads());
 
-        bind(MetricMaker.class).to(DisabledMetricMaker.class);
-      }
-    });
+            bind(MetricMaker.class).to(DisabledMetricMaker.class);
+          }
+        });
 
     try {
       return Guice.createInjector(PRODUCTION, m).getInstance(SiteInit.class);
@@ -314,30 +338,28 @@
       return null;
     }
 
-    try {
-      Path secureStoreLib = Paths.get(secureStore);
-      if (!Files.exists(secureStoreLib)) {
-        throw new InvalidSecureStoreException(String.format(
-            "File %s doesn't exist", secureStore));
-      }
-      JarScanner scanner = new JarScanner(secureStoreLib);
-      List<String> secureStores =
-          scanner.findSubClassesOf(SecureStore.class);
+    Path secureStoreLib = Paths.get(secureStore);
+    if (!Files.exists(secureStoreLib)) {
+      throw new InvalidSecureStoreException(String.format("File %s doesn't exist", secureStore));
+    }
+    try (JarScanner scanner = new JarScanner(secureStoreLib)) {
+      List<String> secureStores = scanner.findSubClassesOf(SecureStore.class);
       if (secureStores.isEmpty()) {
-        throw new InvalidSecureStoreException(String.format(
-            "Cannot find class implementing %s interface in %s",
-            SecureStore.class.getName(), secureStore));
+        throw new InvalidSecureStoreException(
+            String.format(
+                "Cannot find class implementing %s interface in %s",
+                SecureStore.class.getName(), secureStore));
       }
       if (secureStores.size() > 1) {
-        throw new InvalidSecureStoreException(String.format(
-            "%s has more that one implementation of %s interface",
-            secureStore, SecureStore.class.getName()));
+        throw new InvalidSecureStoreException(
+            String.format(
+                "%s has more that one implementation of %s interface",
+                secureStore, SecureStore.class.getName()));
       }
       IoUtil.loadJARs(secureStoreLib);
       return new SecureStoreInitData(secureStoreLib, secureStores.get(0));
     } catch (IOException e) {
-      throw new InvalidSecureStoreException(String.format("%s is not a valid jar",
-          secureStore));
+      throw new InvalidSecureStoreException(String.format("%s is not a valid jar", secureStore));
     }
   }
 
@@ -350,10 +372,13 @@
     final GitRepositoryManager repositoryManager;
 
     @Inject
-    SiteRun(final ConsoleUI ui, final SitePaths site, final InitFlags flags,
-        final SchemaUpdater schemaUpdater,
-        final SchemaFactory<ReviewDb> schema,
-        final GitRepositoryManager repositoryManager) {
+    SiteRun(
+        ConsoleUI ui,
+        SitePaths site,
+        InitFlags flags,
+        SchemaUpdater schemaUpdater,
+        SchemaFactory<ReviewDb> schema,
+        GitRepositoryManager repositoryManager) {
       this.ui = ui;
       this.site = site;
       this.flags = flags;
@@ -364,32 +389,33 @@
 
     void upgradeSchema() throws OrmException {
       final List<String> pruneList = new ArrayList<>();
-      schemaUpdater.update(new UpdateUI() {
-        @Override
-        public void message(String msg) {
-          System.err.println(msg);
-          System.err.flush();
-        }
-
-        @Override
-        public boolean yesno(boolean def, String msg) {
-          return ui.yesno(def, msg);
-        }
-
-        @Override
-        public boolean isBatch() {
-          return ui.isBatch();
-        }
-
-        @Override
-        public void pruneSchema(StatementExecutor e, List<String> prune) {
-          for (String p : prune) {
-            if (!pruneList.contains(p)) {
-              pruneList.add(p);
+      schemaUpdater.update(
+          new UpdateUI() {
+            @Override
+            public void message(String msg) {
+              System.err.println(msg);
+              System.err.flush();
             }
-          }
-        }
-      });
+
+            @Override
+            public boolean yesno(boolean def, String msg) {
+              return ui.yesno(def, msg);
+            }
+
+            @Override
+            public boolean isBatch() {
+              return ui.isBatch();
+            }
+
+            @Override
+            public void pruneSchema(StatementExecutor e, List<String> prune) {
+              for (String p : prune) {
+                if (!pruneList.contains(p)) {
+                  pruneList.add(p);
+                }
+              }
+            }
+          });
 
       if (!pruneList.isEmpty()) {
         StringBuilder msg = new StringBuilder();
@@ -424,14 +450,27 @@
   private Injector createSysInjector(final SiteInit init) {
     if (sysInjector == null) {
       final List<Module> modules = new ArrayList<>();
-      modules.add(new AbstractModule() {
-        @Override
-        protected void configure() {
-          bind(ConsoleUI.class).toInstance(init.ui);
-          bind(InitFlags.class).toInstance(init.flags);
-        }
-      });
-      sysInjector = createDbInjector(SINGLE_USER).createChildInjector(modules);
+      modules.add(
+          new AbstractModule() {
+            @Override
+            protected void configure() {
+              bind(ConsoleUI.class).toInstance(init.ui);
+              bind(InitFlags.class).toInstance(init.flags);
+            }
+          });
+      Injector dbInjector = createDbInjector(SINGLE_USER);
+
+      switch (IndexModule.getIndexType(dbInjector)) {
+        case LUCENE:
+          modules.add(new LuceneIndexModuleOnInit());
+          break;
+        case ELASTICSEARCH:
+          modules.add(new ElasticIndexModuleOnInit());
+          break;
+        default:
+          throw new IllegalStateException("unsupported index.type");
+      }
+      sysInjector = dbInjector.createChildInjector(modules);
     }
     return sysInjector;
   }
@@ -439,36 +478,37 @@
   private static void recursiveDelete(Path path) {
     final String msg = "warn: Cannot remove ";
     try {
-      Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
-        @Override
-        public FileVisitResult visitFile(Path f, BasicFileAttributes attrs)
-            throws IOException {
-          try {
-            Files.delete(f);
-          } catch (IOException e) {
-            System.err.println(msg + f);
-          }
-          return FileVisitResult.CONTINUE;
-        }
+      Files.walkFileTree(
+          path,
+          new SimpleFileVisitor<Path>() {
+            @Override
+            public FileVisitResult visitFile(Path f, BasicFileAttributes attrs) throws IOException {
+              try {
+                Files.delete(f);
+              } catch (IOException e) {
+                System.err.println(msg + f);
+              }
+              return FileVisitResult.CONTINUE;
+            }
 
-        @Override
-        public FileVisitResult postVisitDirectory(Path dir, IOException err) {
-          try {
-            // Previously warned if err was not null; if dir is not empty as a
-            // result, will cause an error that will be logged below.
-            Files.delete(dir);
-          } catch (IOException e) {
-            System.err.println(msg + dir);
-          }
-          return FileVisitResult.CONTINUE;
-        }
+            @Override
+            public FileVisitResult postVisitDirectory(Path dir, IOException err) {
+              try {
+                // Previously warned if err was not null; if dir is not empty as a
+                // result, will cause an error that will be logged below.
+                Files.delete(dir);
+              } catch (IOException e) {
+                System.err.println(msg + dir);
+              }
+              return FileVisitResult.CONTINUE;
+            }
 
-        @Override
-        public FileVisitResult visitFileFailed(Path f, IOException e) {
-          System.err.println(msg + f);
-          return FileVisitResult.CONTINUE;
-        }
-      });
+            @Override
+            public FileVisitResult visitFileFailed(Path f, IOException e) {
+              System.err.println(msg + f);
+              return FileVisitResult.CONTINUE;
+            }
+          });
     } catch (IOException e) {
       System.err.println(msg + path);
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java
index 41cb87e..8868a31 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java
@@ -18,13 +18,11 @@
 import com.google.gerrit.pgm.init.api.InitUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.net.Socket;
 import java.net.URI;
 import java.net.URISyntaxException;
+import org.eclipse.jgit.lib.Config;
 
 /** Opens the user's web browser to the web UI. */
 public class Browser {
@@ -67,7 +65,7 @@
     int port = InitUtil.portOf(uri);
     System.err.format("Waiting for server on %s:%d ... ", host, port);
     System.err.flush();
-    for (;;) {
+    for (; ; ) {
       Socket s;
       try {
         s = new Socket(host, port);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DB2Initializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DB2Initializer.java
index 3f6abcf..9dc1088 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DB2Initializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DB2Initializer.java
@@ -18,7 +18,6 @@
 
 import com.google.gerrit.pgm.init.api.Section;
 
-
 public class DB2Initializer implements DatabaseConfigInitializer {
 
   @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
index bc9ce8c..2701957 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigInitializer.java
@@ -20,8 +20,8 @@
 interface DatabaseConfigInitializer {
 
   /**
-   * Performs database platform specific configuration steps and writes
-   * configuration parameters into the given database section
+   * Performs database platform specific configuration steps and writes configuration parameters
+   * into the given database section
    */
   void initConfig(Section databaseSection);
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
index 9dda276..b80bf35 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
@@ -29,23 +29,33 @@
   @Override
   protected void configure() {
     bind(SitePaths.class).toInstance(site);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("db2")).to(DB2Initializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("derby")).to(DerbyInitializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("h2")).to(H2Initializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("jdbc")).to(JDBCInitializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("mysql")).to(MySqlInitializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("oracle")).to(OracleInitializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("postgresql")).to(PostgreSQLInitializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("maxdb")).to(MaxDbInitializer.class);
-    bind(DatabaseConfigInitializer.class).annotatedWith(
-        Names.named("hana")).to(HANAInitializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("db2"))
+        .to(DB2Initializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("derby"))
+        .to(DerbyInitializer.class);
+    bind(DatabaseConfigInitializer.class).annotatedWith(Names.named("h2")).to(H2Initializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("jdbc"))
+        .to(JDBCInitializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("mariadb"))
+        .to(MariaDbInitializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("mysql"))
+        .to(MySqlInitializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("oracle"))
+        .to(OracleInitializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("postgresql"))
+        .to(PostgreSQLInitializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("maxdb"))
+        .to(MaxDbInitializer.class);
+    bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("hana"))
+        .to(HANAInitializer.class);
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java
index 4d710f1..5db4287 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-
 import java.nio.file.Path;
 
 class DerbyInitializer implements DatabaseConfigInitializer {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
new file mode 100644
index 0000000..5f992bf
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -0,0 +1,28 @@
+// 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.pgm.init;
+
+import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gwtorm.server.OrmException;
+import java.util.Collection;
+
+public class ExternalIdsOnInit {
+  public synchronized void insert(ReviewDb db, Collection<ExternalId> extIds) throws OrmException {
+    db.accountExternalIds().insert(toAccountExternalIds(extIds));
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java
index 6d60ad1..1f3fd0f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-
 import java.nio.file.Path;
 
 class H2Initializer implements DatabaseConfigInitializer {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
index fa0acbd..bc39799 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
@@ -26,8 +26,7 @@
   public void initConfig(Section databaseSection) {
     final String defInstanceNumber = "00";
     databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Instance number", "instance", defInstanceNumber,
-        false);
+    databaseSection.string("Instance number", "instance", defInstanceNumber, false);
     String instance = databaseSection.get("instance");
     Integer instanceNumber = Ints.tryParse(instance);
     if (instanceNumber == null || instanceNumber < 0 || instanceNumber > 99) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 2de71cc..0fadff4 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -18,57 +18,68 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
-
-import org.apache.commons.validator.routines.EmailValidator;
-
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import org.apache.commons.validator.routines.EmailValidator;
 
 public class InitAdminUser implements InitStep {
   private final ConsoleUI ui;
   private final InitFlags flags;
   private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
+  private final ExternalIdsOnInit externalIds;
   private SchemaFactory<ReviewDb> dbFactory;
+  private AccountIndexCollection indexCollection;
 
   @Inject
   InitAdminUser(
       InitFlags flags,
       ConsoleUI ui,
-      VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory) {
+      VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
+      ExternalIdsOnInit externalIds) {
     this.flags = flags;
     this.ui = ui;
     this.authorizedKeysFactory = authorizedKeysFactory;
+    this.externalIds = externalIds;
   }
 
   @Override
-  public void run() {
-  }
+  public void run() {}
 
   @Inject(optional = true)
   void set(SchemaFactory<ReviewDb> dbFactory) {
     this.dbFactory = dbFactory;
   }
 
+  @Inject(optional = true)
+  void set(AccountIndexCollection indexCollection) {
+    this.indexCollection = indexCollection;
+  }
+
   @Override
   public void postRun() throws Exception {
-    AuthType authType =
-        flags.cfg.getEnum(AuthType.values(), "auth", null, "type", null);
+    AuthType authType = flags.cfg.getEnum(AuthType.values(), "auth", null, "type", null);
     if (authType != AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT) {
       return;
     }
@@ -84,39 +95,37 @@
           AccountSshKey sshKey = readSshKey(id);
           String email = readEmail(sshKey);
 
-          AccountExternalId extUser =
-              new AccountExternalId(id, new AccountExternalId.Key(
-                  AccountExternalId.SCHEME_USERNAME, username));
-          if (!Strings.isNullOrEmpty(httpPassword)) {
-            extUser.setPassword(httpPassword);
-          }
-          db.accountExternalIds().insert(Collections.singleton(extUser));
+          List<ExternalId> extIds = new ArrayList<>(2);
+          extIds.add(ExternalId.createUsername(username, id, httpPassword));
 
           if (email != null) {
-            AccountExternalId extMailto =
-                new AccountExternalId(id, new AccountExternalId.Key(
-                    AccountExternalId.SCHEME_MAILTO, email));
-            extMailto.setEmailAddress(email);
-            db.accountExternalIds().insert(Collections.singleton(extMailto));
+            extIds.add(ExternalId.createEmail(id, email));
           }
+          externalIds.insert(db, extIds);
 
           Account a = new Account(id, TimeUtil.nowTs());
           a.setFullName(name);
           a.setPreferredEmail(email);
           db.accounts().insert(Collections.singleton(a));
 
-          AccountGroupName adminGroup = db.accountGroupNames().get(
-              new AccountGroup.NameKey("Administrators"));
+          AccountGroupName adminGroupName =
+              db.accountGroupNames().get(new AccountGroup.NameKey("Administrators"));
           AccountGroupMember m =
-              new AccountGroupMember(new AccountGroupMember.Key(id,
-                  adminGroup.getId()));
+              new AccountGroupMember(new AccountGroupMember.Key(id, adminGroupName.getId()));
           db.accountGroupMembers().insert(Collections.singleton(m));
 
           if (sshKey != null) {
-            VersionedAuthorizedKeysOnInit authorizedKeys =
-                authorizedKeysFactory.create(id).load();
+            VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
             authorizedKeys.addKey(sshKey.getSshPublicKey());
-            authorizedKeys.save("Added SSH key for initial admin user\n");
+            authorizedKeys.save("Add SSH key for initial admin user\n");
+          }
+
+          AccountGroup adminGroup = db.accountGroups().get(adminGroupName.getId());
+          AccountState as =
+              new AccountState(
+                  a, Collections.singleton(adminGroup.getGroupUUID()), extIds, new HashMap<>());
+          for (AccountIndex accountIndex : indexCollection.getWriteIndexes()) {
+            accountIndex.replace(as);
           }
         }
       }
@@ -126,10 +135,10 @@
   private String readEmail(AccountSshKey sshKey) {
     String defaultEmail = "admin@example.com";
     if (sshKey != null && sshKey.getComment() != null) {
-     String c = sshKey.getComment().trim();
-     if (EmailValidator.getInstance().isValid(c)) {
-       defaultEmail = c;
-     }
+      String c = sshKey.getComment().trim();
+      if (EmailValidator.getInstance().isValid(c)) {
+        defaultEmail = c;
+      }
     }
     return readEmail(defaultEmail);
   }
@@ -145,23 +154,18 @@
 
   private AccountSshKey readSshKey(Account.Id id) throws IOException {
     String defaultPublicSshKeyFile = "";
-    Path defaultPublicSshKeyPath =
-        Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
+    Path defaultPublicSshKeyPath = Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
     if (Files.exists(defaultPublicSshKeyPath)) {
       defaultPublicSshKeyFile = defaultPublicSshKeyPath.toString();
     }
-    String publicSshKeyFile =
-        ui.readString(defaultPublicSshKeyFile, "public SSH key file");
-    return !Strings.isNullOrEmpty(publicSshKeyFile)
-        ? createSshKey(id, publicSshKeyFile) : null;
+    String publicSshKeyFile = ui.readString(defaultPublicSshKeyFile, "public SSH key file");
+    return !Strings.isNullOrEmpty(publicSshKeyFile) ? createSshKey(id, publicSshKeyFile) : null;
   }
 
-  private AccountSshKey createSshKey(Account.Id id, String keyFile)
-      throws IOException {
+  private AccountSshKey createSshKey(Account.Id id, String keyFile) throws IOException {
     Path p = Paths.get(keyFile);
     if (!Files.exists(p)) {
-      throw new IOException(String.format(
-          "Cannot add public SSH key: %s is not a file", keyFile));
+      throw new IOException(String.format("Cannot add public SSH key: %s is not a file", keyFile));
     }
     String content = new String(Files.readAllBytes(p), UTF_8);
     return new AccountSshKey(new AccountSshKey.Id(id, 1), content);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
index 6b30f80..a52d8ba 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
@@ -14,16 +14,22 @@
 
 package com.google.gerrit.pgm.init;
 
+import static com.google.gerrit.extensions.client.GitBasicAuthPolicy.HTTP;
+import static com.google.gerrit.extensions.client.GitBasicAuthPolicy.HTTP_LDAP;
+import static com.google.gerrit.extensions.client.GitBasicAuthPolicy.LDAP;
+import static com.google.gerrit.extensions.client.GitBasicAuthPolicy.OAUTH;
 import static com.google.gerrit.pgm.init.api.InitUtil.dnOf;
 
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.EnumSet;
 
 /** Initialize the {@code auth} configuration section. */
 @Singleton
@@ -35,20 +41,15 @@
   private final Section auth;
   private final Section ldap;
   private final Section receive;
-  private final Libraries libraries;
   private final InitFlags flags;
 
   @Inject
-  InitAuth(InitFlags flags,
-      ConsoleUI ui,
-      Libraries libraries,
-      Section.Factory sections) {
+  InitAuth(InitFlags flags, ConsoleUI ui, Section.Factory sections) {
     this.flags = flags;
     this.ui = ui;
     this.auth = sections.get("auth", null);
     this.ldap = sections.get("ldap", null);
     this.receive = sections.get(RECEIVE, null);
-    this.libraries = libraries;
   }
 
   @Override
@@ -64,27 +65,51 @@
   }
 
   private void initAuthType() {
-    AuthType authType = auth.select("Authentication method", "type",
-        flags.dev ? AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT : AuthType.OPENID);
+    AuthType authType =
+        auth.select(
+            "Authentication method",
+            "type",
+            flags.dev ? AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT : AuthType.OPENID);
     switch (authType) {
       case HTTP:
-      case HTTP_LDAP: {
-        String hdr = auth.get("httpHeader");
-        if (ui.yesno(hdr != null, "Get username from custom HTTP header")) {
-          auth.string("Username HTTP header", "httpHeader", "SM_USER");
-        } else if (hdr != null) {
-          auth.unset("httpHeader");
+      case HTTP_LDAP:
+        {
+          String hdr = auth.get("httpHeader");
+          if (ui.yesno(hdr != null, "Get username from custom HTTP header")) {
+            auth.string("Username HTTP header", "httpHeader", "SM_USER");
+          } else if (hdr != null) {
+            auth.unset("httpHeader");
+          }
+          auth.string("SSO logout URL", "logoutUrl", null);
+          break;
         }
-        auth.string("SSO logout URL", "logoutUrl", null);
-        break;
-      }
 
+      case LDAP:
+        {
+          auth.select(
+              "Git/HTTP authentication",
+              "gitBasicAuthPolicy",
+              HTTP,
+              EnumSet.of(HTTP, HTTP_LDAP, LDAP));
+          break;
+        }
+      case OAUTH:
+        {
+          GitBasicAuthPolicy gitBasicAuth =
+              auth.select(
+                  "Git/HTTP authentication", "gitBasicAuthPolicy", HTTP, EnumSet.of(HTTP, OAUTH));
+
+          if (gitBasicAuth == OAUTH) {
+            ui.message(
+                "*WARNING* Please make sure that your chosen OAuth provider\n"
+                    + "supports Git token authentication.\n");
+          }
+          break;
+        }
       case CLIENT_SSL_CERT_LDAP:
       case CUSTOM_EXTENSION:
       case DEVELOPMENT_BECOME_ANY_ACCOUNT:
-      case LDAP:
       case LDAP_BIND:
-      case OAUTH:
       case OPENID:
       case OPENID_SSO:
         break;
@@ -93,28 +118,28 @@
     switch (authType) {
       case LDAP:
       case LDAP_BIND:
-      case HTTP_LDAP: {
-        String server =
-            ldap.string("LDAP server", "server", "ldap://localhost");
-        if (server != null //
-            && !server.startsWith("ldap://") //
-            && !server.startsWith("ldaps://")) {
-          if (ui.yesno(false, "Use SSL")) {
-            server = "ldaps://" + server;
-          } else {
-            server = "ldap://" + server;
+      case HTTP_LDAP:
+        {
+          String server = ldap.string("LDAP server", "server", "ldap://localhost");
+          if (server != null //
+              && !server.startsWith("ldap://") //
+              && !server.startsWith("ldaps://")) {
+            if (ui.yesno(false, "Use SSL")) {
+              server = "ldaps://" + server;
+            } else {
+              server = "ldap://" + server;
+            }
+            ldap.set("server", server);
           }
-          ldap.set("server", server);
+
+          ldap.string("LDAP username", "username", null);
+          ldap.password("username", "password");
+
+          String aBase = ldap.string("Account BaseDN", "accountBase", dnOf(server));
+          ldap.string("Group BaseDN", "groupBase", aBase);
+          break;
         }
 
-        ldap.string("LDAP username", "username", null);
-        ldap.password("username", "password");
-
-        String aBase = ldap.string("Account BaseDN", "accountBase", dnOf(server));
-        ldap.string("Group BaseDN", "groupBase", aBase);
-        break;
-      }
-
       case CLIENT_SSL_CERT_LDAP:
       case CUSTOM_EXTENSION:
       case DEVELOPMENT_BECOME_ANY_ACCOUNT:
@@ -130,13 +155,5 @@
     boolean def = flags.cfg.getBoolean(RECEIVE, ENABLE_SIGNED_PUSH, false);
     boolean enable = ui.yesno(def, "Enable signed push support");
     receive.set("enableSignedPush", Boolean.toString(enable));
-    if (enable) {
-      libraries.bouncyCastleProvider.downloadRequired();
-      libraries.bouncyCastlePGP.downloadRequired();
-    }
-  }
-
-  @Override
-  public void postRun() throws Exception {
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
index 33dc204..30627d5 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
@@ -39,8 +38,11 @@
   private final Section cache;
 
   @Inject
-  InitCache(final ConsoleUI ui, final InitFlags flags,
-      final SitePaths site, final Section.Factory sections) {
+  InitCache(
+      final ConsoleUI ui,
+      final InitFlags flags,
+      final SitePaths site,
+      final Section.Factory sections) {
     this.ui = ui;
     this.flags = flags;
     this.site = site;
@@ -67,8 +69,7 @@
     Path loc = site.resolve(path);
     FileUtil.mkdirsOrDie(loc, "cannot create cache.directory");
     List<Path> cacheFiles = new ArrayList<>();
-    try (DirectoryStream<Path> stream =
-        Files.newDirectoryStream(loc, "*.{lock,h2,trace}.db")) {
+    try (DirectoryStream<Path> stream = Files.newDirectoryStream(loc, "*.{lock,h2,trace}.db")) {
       for (Path entry : stream) {
         cacheFiles.add(entry);
       }
@@ -78,8 +79,7 @@
     }
     if (!cacheFiles.isEmpty()) {
       for (Path entry : cacheFiles) {
-        if (flags.deleteCaches ||
-            ui.yesno(false, "Delete cache file %s", entry)) {
+        if (flags.deleteCaches || ui.yesno(false, "Delete cache file %s", entry)) {
           try {
             Files.deleteIfExists(entry);
           } catch (IOException e) {
@@ -89,8 +89,4 @@
       }
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
index 36754a1..3958069 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
@@ -25,15 +25,13 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.internal.storage.file.LockFile;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.LockFile;
 
 /** Initialize the {@code container} configuration section. */
 @Singleton
@@ -43,8 +41,7 @@
   private final Section container;
 
   @Inject
-  InitContainer(final ConsoleUI ui, final SitePaths site,
-      final Section.Factory sections) {
+  InitContainer(final ConsoleUI ui, final SitePaths site, final Section.Factory sections) {
     this.ui = ui;
     this.site = site;
     this.container = sections.get("container", null);
@@ -67,8 +64,9 @@
 
     String path = container.get("war");
     if (path != null) {
-      path = container.string("Gerrit runtime", "war",
-          myWar != null ? myWar.toAbsolutePath().toString() : null);
+      path =
+          container.string(
+              "Gerrit runtime", "war", myWar != null ? myWar.toAbsolutePath().toString() : null);
       if (path == null || path.isEmpty()) {
         throw die("container.war is required");
       }
@@ -117,8 +115,4 @@
   private static String javaHome() {
     return System.getProperty("java.home");
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
index 7e4d3c1..349ab55 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
@@ -32,7 +32,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import com.google.inject.name.Names;
-
 import java.lang.annotation.Annotation;
 import java.util.List;
 import java.util.Set;
@@ -47,7 +46,10 @@
   private final Section idSection;
 
   @Inject
-  InitDatabase(final ConsoleUI ui, final SitePaths site, final Libraries libraries,
+  InitDatabase(
+      final ConsoleUI ui,
+      final SitePaths site,
+      final Libraries libraries,
       final Section.Factory sections) {
     this.ui = ui;
     this.site = site;
@@ -76,15 +78,15 @@
       database.set("type", "jdbc");
     }
 
-    String dbType =
-        database.select("Database server type", "type", "h2", allowedValues);
+    String dbType = database.select("Database server type", "type", "h2", allowedValues);
 
     DatabaseConfigInitializer dci =
-        i.getInstance(Key.get(DatabaseConfigInitializer.class,
-            Names.named(dbType.toLowerCase())));
+        i.getInstance(Key.get(DatabaseConfigInitializer.class, Names.named(dbType.toLowerCase())));
 
     if (dci instanceof MySqlInitializer) {
       libraries.mysqlDriver.downloadRequired();
+    } else if (dci instanceof MariaDbInitializer) {
+      libraries.mariadbDriver.downloadRequired();
     } else if (dci instanceof OracleInitializer) {
       libraries.oracleDriver.downloadRequired();
     } else if (dci instanceof DB2Initializer) {
@@ -98,12 +100,7 @@
     // Initialize UUID for NoteDb on first init.
     String id = idSection.get(GerritServerIdProvider.KEY);
     if (Strings.isNullOrEmpty(id)) {
-      idSection.set(
-          GerritServerIdProvider.KEY, GerritServerIdProvider.generate());
+      idSection.set(GerritServerIdProvider.KEY, GerritServerIdProvider.generate());
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java
new file mode 100644
index 0000000..4305877
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java
@@ -0,0 +1,41 @@
+// 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.pgm.init;
+
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class InitDev implements InitStep {
+  private final InitFlags flags;
+  private final Section plugins;
+
+  @Inject
+  InitDev(InitFlags flags, Section.Factory sections) {
+    this.flags = flags;
+    this.plugins = sections.get("plugins", null);
+  }
+
+  @Override
+  public void run() throws Exception {
+    if (!flags.dev) {
+      return;
+    }
+    plugins.set("allowRemoteAdmin", "true");
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
index d8fd509..fc42f9d 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.nio.file.Path;
 
 /** Initialize the GitRepositoryManager configuration section. */
@@ -47,8 +46,4 @@
     }
     FileUtil.mkdirsOrDie(d, "Cannot create");
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java
index a907d46..1a86106 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitHttpd.java
@@ -28,7 +28,6 @@
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -45,7 +44,10 @@
   private final Section gerrit;
 
   @Inject
-  InitHttpd(final ConsoleUI ui, final SitePaths site, final InitFlags flags,
+  InitHttpd(
+      final ConsoleUI ui,
+      final SitePaths site,
+      final InitFlags flags,
       final Section.Factory sections) {
     this.ui = ui;
     this.site = site;
@@ -129,8 +131,7 @@
     generateSslCertificate();
   }
 
-  private void generateSslCertificate() throws IOException,
-      InterruptedException {
+  private void generateSslCertificate() throws IOException, InterruptedException {
     final String listenUrl = httpd.get("listenUrl");
 
     if (!listenUrl.startsWith("https://")) {
@@ -152,8 +153,7 @@
     }
 
     Path store = site.ssl_keystore;
-    if (!ui.yesno(!Files.exists(store),
-        "Create new self-signed SSL certificate")) {
+    if (!ui.yesno(!Files.exists(store), "Create new self-signed SSL certificate")) {
       return;
     }
 
@@ -164,11 +164,9 @@
     }
 
     hostname = ui.readString(hostname, "Certificate server name");
-    final String validity =
-        ui.readString("365", "Certificate expires in (days)");
+    final String validity = ui.readString("365", "Certificate expires in (days)");
 
-    final String dname =
-        "CN=" + hostname + ",OU=Gerrit Code Review,O=" + domainOf(hostname);
+    final String dname = "CN=" + hostname + ",OU=Gerrit Code Review,O=" + domainOf(hostname);
 
     Path tmpdir = site.etc_dir.resolve("tmp.sslcertgen");
     try {
@@ -179,16 +177,27 @@
     chmod(0600, tmpdir);
 
     Path tmpstore = tmpdir.resolve("keystore");
-    Runtime.getRuntime().exec(new String[] {"keytool", //
-        "-keystore", tmpstore.toAbsolutePath().toString(), //
-        "-storepass", ssl_pass, //
-        "-genkeypair", //
-        "-alias", hostname, //
-        "-keyalg", "RSA", //
-        "-validity", validity, //
-        "-dname", dname, //
-        "-keypass", ssl_pass, //
-    }).waitFor();
+    Runtime.getRuntime()
+        .exec(
+            new String[] {
+              "keytool", //
+              "-keystore",
+              tmpstore.toAbsolutePath().toString(), //
+              "-storepass",
+              ssl_pass, //
+              "-genkeypair", //
+              "-alias",
+              hostname, //
+              "-keyalg",
+              "RSA", //
+              "-validity",
+              validity, //
+              "-dname",
+              dname, //
+              "-keypass",
+              ssl_pass, //
+            })
+        .waitFor();
     chmod(0600, tmpstore);
 
     try {
@@ -202,8 +211,4 @@
       throw die("Cannot delete " + tmpdir, e);
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
index 018211b..2e67bfb 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.lucene.AbstractLuceneIndex;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -23,10 +22,10 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.SchemaDefinitions;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
@@ -40,17 +39,16 @@
   private final SitePaths site;
   private final InitFlags initFlags;
   private final Section gerrit;
+  private final Section.Factory sections;
 
   @Inject
-  InitIndex(ConsoleUI ui,
-      Section.Factory sections,
-      SitePaths site,
-      InitFlags initFlags) {
+  InitIndex(ConsoleUI ui, Section.Factory sections, SitePaths site, InitFlags initFlags) {
     this.ui = ui;
     this.index = sections.get("index", null);
     this.gerrit = sections.get("gerrit", null);
     this.site = site;
     this.initFlags = initFlags;
+    this.sections = sections;
   }
 
   @Override
@@ -61,19 +59,26 @@
       type = index.select("Type", "type", type);
     }
 
+    if (type == IndexType.ELASTICSEARCH) {
+      Section elasticsearch = sections.get("elasticsearch", null);
+      elasticsearch.string("Index Prefix", "prefix", "gerrit_");
+      elasticsearch.string("Server", "server", "http://localhost:9200");
+      index.string("Result window size", "maxLimit", "10000");
+    }
+
     if ((site.isNew || isEmptySite()) && type == IndexType.LUCENE) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
-        AbstractLuceneIndex.setReady(
-            site, def.getName(), def.getLatest().getVersion(), true);
+        IndexUtils.setReady(site, def.getName(), def.getLatest().getVersion(), true);
       }
     } else {
       if (IndexType.values().length <= 1) {
         ui.header("Index");
       }
-      String message = String.format(
-        "\nThe index must be %sbuilt before starting Gerrit:\n"
-        + "  java -jar gerrit.war reindex -d site_path\n",
-        site.isNew ? "" : "re");
+      String message =
+          String.format(
+              "\nThe index must be %sbuilt before starting Gerrit:\n"
+                  + "  java -jar gerrit.war reindex -d site_path\n",
+              site.isNew ? "" : "re");
       ui.message(message);
       initFlags.autoStart = false;
     }
@@ -87,8 +92,4 @@
       return true;
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java
index b6b4cc1..60fd60f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -19,15 +19,12 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.Arrays;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class InitLabels implements InitStep {
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE =
-      "copyAllScoresIfNoCodeChange";
+  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
   private static final String KEY_LABEL = "label";
   private static final String KEY_FUNCTION = "function";
   private static final String KEY_VALUE = "value";
@@ -58,7 +55,10 @@
     Config cfg = allProjectsConfig.load().getConfig();
     if (installVerified) {
       cfg.setString(KEY_LABEL, LABEL_VERIFIED, KEY_FUNCTION, "MaxWithBlock");
-      cfg.setStringList(KEY_LABEL, LABEL_VERIFIED, KEY_VALUE,
+      cfg.setStringList(
+          KEY_LABEL,
+          LABEL_VERIFIED,
+          KEY_VALUE,
           Arrays.asList(new String[] {"-1 Fails", " 0 No score", "+1 Verified"}));
       cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
       allProjectsConfig.save("Configure 'Verified' label");
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
index b5aa625..f6b7e6a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.binder.LinkedBindingBuilder;
 import com.google.inject.internal.UniqueAnnotations;
-
 import java.lang.annotation.Annotation;
 
 /** Injection configuration for the site initialization process. */
@@ -64,6 +63,7 @@
     step().to(InitHttpd.class);
     step().to(InitCache.class);
     step().to(InitPlugins.class);
+    step().to(InitDev.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
index ee20d99..4526a87 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
@@ -26,7 +26,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.net.URL;
 import java.net.URLClassLoader;
@@ -44,8 +43,8 @@
   final ConsoleUI ui;
 
   @Inject
-  public InitPluginStepsLoader(final ConsoleUI ui, final SitePaths sitePaths,
-      final Injector initInjector) {
+  public InitPluginStepsLoader(
+      final ConsoleUI ui, final SitePaths sitePaths, final Injector initInjector) {
     this.pluginsDir = sitePaths.plugins_dir;
     this.initInjector = initInjector;
     this.ui = ui;
@@ -68,8 +67,8 @@
   private InitStep loadInitStep(Path jar) {
     try {
       URLClassLoader pluginLoader =
-          new URLClassLoader(new URL[] {jar.toUri().toURL()},
-             InitPluginStepsLoader.class.getClassLoader());
+          new URLClassLoader(
+              new URL[] {jar.toUri().toURL()}, InitPluginStepsLoader.class.getClassLoader());
       try (JarFile jarFile = new JarFile(jar.toFile())) {
         Attributes jarFileAttributes = jarFile.getManifest().getMainAttributes();
         String initClassName = jarFileAttributes.getValue("Gerrit-InitStep");
@@ -100,24 +99,23 @@
   }
 
   private Injector getPluginInjector(Path jarPath) throws IOException {
-    final String pluginName = MoreObjects.firstNonNull(
-        JarPluginProvider.getJarPluginName(jarPath),
-        PluginLoader.nameOf(jarPath));
-    return initInjector.createChildInjector(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(String.class).annotatedWith(PluginName.class).toInstance(
-            pluginName);
-      }
-    });
+    final String pluginName =
+        MoreObjects.firstNonNull(
+            JarPluginProvider.getJarPluginName(jarPath), PluginLoader.nameOf(jarPath));
+    return initInjector.createChildInjector(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(String.class).annotatedWith(PluginName.class).toInstance(pluginName);
+          }
+        });
   }
 
   private List<Path> scanJarsInPluginsDirectory() {
     try {
       return PluginLoader.listPlugins(pluginsDir, ".jar");
     } catch (IOException e) {
-      ui.message("WARN: Cannot list %s: %s", pluginsDir.toAbsolutePath(),
-          e.getMessage());
+      ui.message("WARN: Cannot list %s: %s", pluginsDir.toAbsolutePath(), e.getMessage());
       return ImmutableList.of();
     }
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java
index 43d7d3b..385d20c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPlugins.java
@@ -24,7 +24,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
@@ -42,37 +41,42 @@
   public static final String PLUGIN_DIR = "WEB-INF/plugins/";
   public static final String JAR = ".jar";
 
-  public static List<PluginData> listPlugins(SitePaths site,
-      PluginsDistribution pluginsDistribution) throws IOException {
+  public static List<PluginData> listPlugins(
+      SitePaths site, PluginsDistribution pluginsDistribution) throws IOException {
     return listPlugins(site, false, pluginsDistribution);
   }
 
-  public static List<PluginData> listPluginsAndRemoveTempFiles(SitePaths site,
-      PluginsDistribution pluginsDistribution) throws IOException {
+  public static List<PluginData> listPluginsAndRemoveTempFiles(
+      SitePaths site, PluginsDistribution pluginsDistribution) throws IOException {
     return listPlugins(site, true, pluginsDistribution);
   }
 
-  private static List<PluginData> listPlugins(final SitePaths site,
-      final boolean deleteTempPluginFile, PluginsDistribution pluginsDistribution)
-          throws IOException {
+  private static List<PluginData> listPlugins(
+      final SitePaths site,
+      final boolean deleteTempPluginFile,
+      PluginsDistribution pluginsDistribution)
+      throws IOException {
     final List<PluginData> result = new ArrayList<>();
-    pluginsDistribution.foreach(new PluginsDistribution.Processor() {
-      @Override
-      public void process(String pluginName, InputStream in) throws IOException {
-        Path tmpPlugin = JarPluginProvider.storeInTemp(pluginName, in, site);
-        String pluginVersion = getVersion(tmpPlugin);
-        if (deleteTempPluginFile) {
-          Files.delete(tmpPlugin);
-        }
-        result.add(new PluginData(pluginName, pluginVersion, tmpPlugin));
-      }
-    });
-    return FluentIterable.from(result).toSortedList(new Comparator<PluginData>() {
-        @Override
-        public int compare(PluginData a, PluginData b) {
-          return a.name.compareTo(b.name);
-        }
-      });
+    pluginsDistribution.foreach(
+        new PluginsDistribution.Processor() {
+          @Override
+          public void process(String pluginName, InputStream in) throws IOException {
+            Path tmpPlugin = JarPluginProvider.storeInTemp(pluginName, in, site);
+            String pluginVersion = getVersion(tmpPlugin);
+            if (deleteTempPluginFile) {
+              Files.delete(tmpPlugin);
+            }
+            result.add(new PluginData(pluginName, pluginVersion, tmpPlugin));
+          }
+        });
+    return FluentIterable.from(result)
+        .toSortedList(
+            new Comparator<PluginData>() {
+              @Override
+              public int compare(PluginData a, PluginData b) {
+                return a.name.compareTo(b.name);
+              }
+            });
   }
 
   private final ConsoleUI ui;
@@ -84,8 +88,11 @@
   private Injector postRunInjector;
 
   @Inject
-  InitPlugins(final ConsoleUI ui, final SitePaths site,
-      InitFlags initFlags, InitPluginStepsLoader pluginLoader,
+  InitPlugins(
+      final ConsoleUI ui,
+      final SitePaths site,
+      InitFlags initFlags,
+      InitPluginStepsLoader pluginLoader,
       PluginsDistribution pluginsDistribution) {
     this.ui = ui;
     this.site = site;
@@ -124,24 +131,26 @@
 
         if (!(initFlags.installPlugins.contains(pluginName)
             || initFlags.installAllPlugins
-            || ui.yesno(upgrade, "Install plugin %s version %s", pluginName,
-                plugin.version))) {
+            || ui.yesno(upgrade, "Install plugin %s version %s", pluginName, plugin.version))) {
           Files.deleteIfExists(tmpPlugin);
           continue;
         }
 
         if (upgrade) {
           final String installedPluginVersion = getVersion(p);
-          if (!ui.yesno(upgrade, "%s %s is already installed, overwrite it",
-              plugin.name, installedPluginVersion)) {
+          if (!ui.yesno(
+              upgrade,
+              "%s %s is already installed, overwrite it",
+              plugin.name,
+              installedPluginVersion)) {
             Files.deleteIfExists(tmpPlugin);
             continue;
           }
           try {
             Files.delete(p);
           } catch (IOException e) {
-            throw new IOException("Failed to delete plugin " + pluginName
-                + ": " + p.toAbsolutePath(), e);
+            throw new IOException(
+                "Failed to delete plugin " + pluginName + ": " + p.toAbsolutePath(), e);
           }
         }
         try {
@@ -153,9 +162,14 @@
             ui.message("Installed %s %s\n", plugin.name, plugin.version);
           }
         } catch (IOException e) {
-          throw new IOException("Failed to install plugin " + pluginName
-              + ": " + tmpPlugin.toAbsolutePath() + " -> "
-              + p.toAbsolutePath(), e);
+          throw new IOException(
+              "Failed to install plugin "
+                  + pluginName
+                  + ": "
+                  + tmpPlugin.toAbsolutePath()
+                  + " -> "
+                  + p.toAbsolutePath(),
+              e);
         }
       } finally {
         Files.deleteIfExists(plugin.pluginPath);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
index 5c7eefd..97359b3 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
@@ -21,10 +21,9 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.mail.SmtpEmailSender.Encryption;
+import com.google.gerrit.server.mail.Encryption;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.nio.file.Files;
 
 /** Initialize the {@code sendemail} configuration section. */
@@ -35,8 +34,7 @@
   private final SitePaths site;
 
   @Inject
-  InitSendEmail(final ConsoleUI ui, final SitePaths site,
-      final Section.Factory sections) {
+  InitSendEmail(final ConsoleUI ui, final SitePaths site, final Section.Factory sections) {
     this.ui = ui;
     this.sendemail = sections.get("sendemail", null);
     this.site = site;
@@ -46,14 +44,12 @@
   public void run() {
     ui.header("Email Delivery");
 
-    final String hostname =
-        sendemail.string("SMTP server hostname", "smtpServer", "localhost");
+    final String hostname = sendemail.string("SMTP server hostname", "smtpServer", "localhost");
 
     sendemail.string("SMTP server port", "smtpServerPort", "(default)", true);
 
     final Encryption enc =
-        sendemail.select("SMTP encryption", "smtpEncryption", Encryption.NONE,
-            true);
+        sendemail.select("SMTP encryption", "smtpEncryption", Encryption.NONE, true);
 
     String username = null;
     if (Files.exists(site.gerrit_config)) {
@@ -64,8 +60,4 @@
     sendemail.string("SMTP username", "smtpUser", username);
     sendemail.password("smtpUser", "smtpPass");
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
index 4cdf3aa..d963cbb 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.gerrit.common.FileUtil.chmod;
-import static com.google.gerrit.pgm.init.api.InitUtil.die;
 import static com.google.gerrit.pgm.init.api.InitUtil.hostname;
 import static java.nio.file.Files.exists;
 
@@ -23,33 +21,28 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.HostPlatform;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.apache.sshd.common.util.security.SecurityUtils;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-
 import java.io.IOException;
+import java.lang.ProcessBuilder.Redirect;
 import java.net.InetSocketAddress;
-import java.nio.file.Files;
-import java.nio.file.Path;
 
 /** Initialize the {@code sshd} configuration section. */
 @Singleton
 class InitSshd implements InitStep {
   private final ConsoleUI ui;
   private final SitePaths site;
-  private final Libraries libraries;
   private final Section sshd;
+  private final StaleLibraryRemover remover;
 
   @Inject
-  InitSshd(final ConsoleUI ui, final SitePaths site, final Libraries libraries,
-      final Section.Factory sections) {
+  InitSshd(ConsoleUI ui, SitePaths site, Section.Factory sections, StaleLibraryRemover remover) {
     this.ui = ui;
     this.site = site;
-    this.libraries = libraries;
     this.sshd = sections.get("sshd", null);
+    this.remover = remover;
   }
 
   @Override
@@ -76,13 +69,8 @@
     port = ui.readInt(port, "Listen on port");
     sshd.set("listenAddress", SocketUtil.format(hostname, port));
 
-    if (exists(site.ssh_rsa) || exists(site.ssh_dsa)) {
-      libraries.bouncyCastleSSL.downloadRequired();
-    } else if (!exists(site.ssh_key)) {
-      libraries.bouncyCastleSSL.downloadOptional();
-    }
-
     generateSshHostKeys();
+    remover.remove("bc(pg|pkix|prov)-.*[.]jar");
   }
 
   private static boolean isOff(String listenHostname) {
@@ -92,79 +80,172 @@
   }
 
   private void generateSshHostKeys() throws InterruptedException, IOException {
-    if (!exists(site.ssh_key) //
-        && !exists(site.ssh_rsa) //
-        && !exists(site.ssh_dsa)) {
+    if (!exists(site.ssh_key)
+        && (!exists(site.ssh_rsa)
+            || !exists(site.ssh_dsa)
+            || !exists(site.ssh_ed25519)
+            || !exists(site.ssh_ecdsa_256)
+            || !exists(site.ssh_ecdsa_384)
+            || !exists(site.ssh_ecdsa_521))) {
       System.err.print("Generating SSH host key ...");
       System.err.flush();
 
-      if (SecurityUtils.isBouncyCastleRegistered()) {
-        // Generate the SSH daemon host key using ssh-keygen.
-        //
-        final String comment = "gerrit-code-review@" + hostname();
+      // Generate the SSH daemon host key using ssh-keygen.
+      //
+      final String comment = "gerrit-code-review@" + hostname();
 
+      // Workaround for JDK-6518827 - zero-length argument ignored on Win32
+      String emptyPassphraseArg = HostPlatform.isWin32() ? "\"\"" : "";
+      if (!exists(site.ssh_rsa)) {
         System.err.print(" rsa...");
         System.err.flush();
-        Runtime.getRuntime().exec(new String[] {"ssh-keygen",
-            "-q" /* quiet */,
-            "-t", "rsa",
-            "-P", "",
-            "-C", comment,
-            "-f", site.ssh_rsa.toAbsolutePath().toString(),
-            }).waitFor();
+        new ProcessBuilder(
+                "ssh-keygen",
+                "-q" /* quiet */,
+                "-t",
+                "rsa",
+                "-N",
+                emptyPassphraseArg,
+                "-C",
+                comment,
+                "-f",
+                site.ssh_rsa.toAbsolutePath().toString())
+            .redirectError(Redirect.INHERIT)
+            .redirectOutput(Redirect.INHERIT)
+            .start()
+            .waitFor();
+      }
 
+      if (!exists(site.ssh_dsa)) {
         System.err.print(" dsa...");
         System.err.flush();
-        Runtime.getRuntime().exec(new String[] {"ssh-keygen",
-            "-q" /* quiet */,
-            "-t", "dsa",
-            "-P", "",
-            "-C", comment,
-            "-f", site.ssh_dsa.toAbsolutePath().toString(),
-            }).waitFor();
+        new ProcessBuilder(
+                "ssh-keygen",
+                "-q" /* quiet */,
+                "-t",
+                "dsa",
+                "-P",
+                emptyPassphraseArg,
+                "-C",
+                comment,
+                "-f",
+                site.ssh_dsa.toAbsolutePath().toString())
+            .redirectError(Redirect.INHERIT)
+            .redirectOutput(Redirect.INHERIT)
+            .start()
+            .waitFor();
+      }
 
-      } else {
-        // Generate the SSH daemon host key ourselves. This is complex
-        // because SimpleGeneratorHostKeyProvider doesn't mark the data
-        // file as only readable by us, exposing the private key for a
-        // short period of time. We try to reduce that risk by creating
-        // the key within a temporary directory.
-        //
-        Path tmpdir = site.etc_dir.resolve("tmp.sshkeygen");
-        try {
-          Files.createDirectory(tmpdir);
-        } catch (IOException e) {
-          throw die("Cannot create directory " + tmpdir, e);
-        }
-        chmod(0600, tmpdir);
-
-        Path tmpkey = tmpdir.resolve(site.ssh_key.getFileName().toString());
-        SimpleGeneratorHostKeyProvider p;
-
-        System.err.print(" rsa(simple)...");
+      if (!exists(site.ssh_ed25519)) {
+        System.err.print(" ed25519...");
         System.err.flush();
-        p = new SimpleGeneratorHostKeyProvider();
-        p.setPath(tmpkey.toAbsolutePath());
-        p.setAlgorithm("RSA");
-        p.loadKeys(); // forces the key to generate.
-        chmod(0600, tmpkey);
-
         try {
-          Files.move(tmpkey, site.ssh_key);
-        } catch (IOException e) {
-          throw die("Cannot rename " + tmpkey + " to " + site.ssh_key, e);
+          new ProcessBuilder(
+                  "ssh-keygen",
+                  "-q" /* quiet */,
+                  "-t",
+                  "ed25519",
+                  "-P",
+                  emptyPassphraseArg,
+                  "-C",
+                  comment,
+                  "-f",
+                  site.ssh_ed25519.toAbsolutePath().toString())
+              .redirectError(Redirect.INHERIT)
+              .redirectOutput(Redirect.INHERIT)
+              .start()
+              .waitFor();
+        } catch (Exception e) {
+          // continue since older hosts won't be able to generate ed25519 keys.
+          System.err.print(" Failed to generate ed25519 key, continuing...");
+          System.err.flush();
         }
+      }
+
+      if (!exists(site.ssh_ecdsa_256)) {
+        System.err.print(" ecdsa 256...");
+        System.err.flush();
         try {
-          Files.delete(tmpdir);
-        } catch (IOException e) {
-          throw die("Cannot delete " + tmpdir, e);
+          new ProcessBuilder(
+                  "ssh-keygen",
+                  "-q" /* quiet */,
+                  "-t",
+                  "ecdsa",
+                  "-b",
+                  "256",
+                  "-P",
+                  emptyPassphraseArg,
+                  "-C",
+                  comment,
+                  "-f",
+                  site.ssh_ecdsa_256.toAbsolutePath().toString())
+              .redirectError(Redirect.INHERIT)
+              .redirectOutput(Redirect.INHERIT)
+              .start()
+              .waitFor();
+        } catch (Exception e) {
+          // continue since older hosts won't be able to generate ecdsa keys.
+          System.err.print(" Failed to generate ecdsa 256 key, continuing...");
+          System.err.flush();
+        }
+      }
+
+      if (!exists(site.ssh_ecdsa_384)) {
+        System.err.print(" ecdsa 384...");
+        System.err.flush();
+        try {
+          new ProcessBuilder(
+                  "ssh-keygen",
+                  "-q" /* quiet */,
+                  "-t",
+                  "ecdsa",
+                  "-b",
+                  "384",
+                  "-P",
+                  emptyPassphraseArg,
+                  "-C",
+                  comment,
+                  "-f",
+                  site.ssh_ecdsa_384.toAbsolutePath().toString())
+              .redirectError(Redirect.INHERIT)
+              .redirectOutput(Redirect.INHERIT)
+              .start()
+              .waitFor();
+        } catch (Exception e) {
+          // continue since older hosts won't be able to generate ecdsa keys.
+          System.err.print(" Failed to generate ecdsa 384 key, continuing...");
+          System.err.flush();
+        }
+      }
+
+      if (!exists(site.ssh_ecdsa_521)) {
+        System.err.print(" ecdsa 521...");
+        System.err.flush();
+        try {
+          new ProcessBuilder(
+                  "ssh-keygen",
+                  "-q" /* quiet */,
+                  "-t",
+                  "ecdsa",
+                  "-b",
+                  "521",
+                  "-P",
+                  emptyPassphraseArg,
+                  "-C",
+                  comment,
+                  "-f",
+                  site.ssh_ecdsa_521.toAbsolutePath().toString())
+              .redirectError(Redirect.INHERIT)
+              .redirectOutput(Redirect.INHERIT)
+              .start()
+              .waitFor();
+        } catch (Exception e) {
+          // continue since older hosts won't be able to generate ecdsa keys.
+          System.err.print(" Failed to generate ecdsa 521 key, continuing...");
+          System.err.flush();
         }
       }
       System.err.println(" done");
     }
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java
index 4659ee3..e3a1d66 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java
@@ -37,6 +37,8 @@
         database.set("driver", "org.apache.derby.jdbc.EmbeddedDriver");
       } else if (url.startsWith("jdbc:h2:")) {
         database.set("driver", "org.h2.Driver");
+      } else if (url.startsWith("jdbc:mariadb:")) {
+        database.set("driver", "org.mariadb.jdbc.Driver");
       } else if (url.startsWith("jdbc:mysql:")) {
         database.set("driver", "com.mysql.jdbc.Driver");
       } else if (url.startsWith("jdbc:postgresql:")) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
index b4c672a..3259f96 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
@@ -20,10 +20,6 @@
 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.Config;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
@@ -32,28 +28,28 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 
 /** Standard {@link LibraryDownloader} instances derived from configuration. */
 @Singleton
 class Libraries {
-  private static final String RESOURCE_FILE =
-      "com/google/gerrit/pgm/init/libraries.config";
+  private static final String RESOURCE_FILE = "com/google/gerrit/pgm/init/libraries.config";
 
   private final Provider<LibraryDownloader> downloadProvider;
   private final List<String> skippedDownloads;
   private final boolean skipAllDownloads;
 
-  /* final */LibraryDownloader bouncyCastlePGP;
-  /* final */LibraryDownloader bouncyCastleProvider;
-  /* final */LibraryDownloader bouncyCastleSSL;
-  /* final */LibraryDownloader db2Driver;
-  /* final */LibraryDownloader db2DriverLicense;
-  /* final */LibraryDownloader hanaDriver;
-  /* final */LibraryDownloader mysqlDriver;
-  /* final */LibraryDownloader oracleDriver;
+  /* final */ LibraryDownloader db2Driver;
+  /* final */ LibraryDownloader db2DriverLicense;
+  /* final */ LibraryDownloader hanaDriver;
+  /* final */ LibraryDownloader mariadbDriver;
+  /* final */ LibraryDownloader mysqlDriver;
+  /* final */ LibraryDownloader oracleDriver;
 
   @Inject
-  Libraries(final Provider<LibraryDownloader> downloadProvider,
+  Libraries(
+      final Provider<LibraryDownloader> downloadProvider,
       @LibraryDownload List<String> skippedDownloads,
       @LibraryDownload Boolean skipAllDownloads) {
     this.downloadProvider = downloadProvider;
@@ -71,8 +67,7 @@
     }
 
     for (Field f : Libraries.class.getDeclaredFields()) {
-      if ((f.getModifiers() & Modifier.STATIC) == 0
-          && f.getType() == LibraryDownloader.class) {
+      if ((f.getModifiers() & Modifier.STATIC) == 0 && f.getType() == LibraryDownloader.class) {
         try {
           f.set(this, downloadProvider.get());
         } catch (IllegalArgumentException | IllegalAccessException e) {
@@ -82,20 +77,22 @@
     }
 
     for (Field f : Libraries.class.getDeclaredFields()) {
-      if ((f.getModifiers() & Modifier.STATIC) == 0
-          && f.getType() == LibraryDownloader.class) {
+      if ((f.getModifiers() & Modifier.STATIC) == 0 && f.getType() == LibraryDownloader.class) {
         try {
           init(f, cfg);
-        } catch (IllegalArgumentException | IllegalAccessException
-            | NoSuchFieldException | SecurityException e) {
+        } catch (IllegalArgumentException
+            | IllegalAccessException
+            | NoSuchFieldException
+            | SecurityException e) {
           throw new IllegalStateException("Cannot configure " + f.getName());
         }
       }
     }
   }
 
-  private void init(Field field, Config cfg) throws IllegalArgumentException,
-      IllegalAccessException, NoSuchFieldException, SecurityException {
+  private void init(Field field, Config cfg)
+      throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException,
+          SecurityException {
     String n = field.getName();
     LibraryDownloader dl = (LibraryDownloader) field.get(this);
     dl.setName(get(cfg, n, "name"));
@@ -116,12 +113,11 @@
     return doGet(cfg, name, key, true);
   }
 
-  private static String doGet(Config cfg, String name, String key,
-      boolean required) {
+  private static String doGet(Config cfg, String name, String key, boolean required) {
     String val = cfg.getString("library", name, key);
     if ((val == null || val.isEmpty()) && required) {
-      throw new IllegalStateException("Variable library." + name + "." + key
-          + " is required within " + RESOURCE_FILE);
+      throw new IllegalStateException(
+          "Variable library." + name + "." + key + " is required within " + RESOURCE_FILE);
     }
     return val;
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
index 653f2c5..c0b5c75 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.pgm.init;
 
-import com.google.common.base.Strings;
 import com.google.common.hash.Funnels;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
@@ -24,9 +23,6 @@
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.util.HttpSupport;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
@@ -36,17 +32,18 @@
 import java.net.ProxySelector;
 import java.net.URISyntaxException;
 import java.net.URL;
-import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.util.HttpSupport;
 
 /** Get optional or required 3rd party library files into $site_path/lib. */
 class LibraryDownloader {
   private final ConsoleUI ui;
   private final Path lib_dir;
+  private final StaleLibraryRemover remover;
 
   private boolean required;
   private String name;
@@ -61,9 +58,10 @@
   private boolean skipDownload;
 
   @Inject
-  LibraryDownloader(ConsoleUI ui, SitePaths site) {
+  LibraryDownloader(ConsoleUI ui, SitePaths site, StaleLibraryRemover remover) {
     this.ui = ui;
     this.lib_dir = site.lib_dir;
+    this.remover = remover;
     this.needs = new ArrayList<>(2);
   }
 
@@ -150,17 +148,14 @@
     msg.append("\n");
     msg.append("Gerrit Code Review is not shipped with %s\n");
     if (neededBy != null) {
-      msg.append(String.format(
-          "** This library is required by %s. **\n",
-          neededBy.name));
+      msg.append(String.format("** This library is required by %s. **\n", neededBy.name));
     } else if (required) {
       msg.append("**  This library is required for your configuration. **\n");
     } else {
       msg.append("  If available, Gerrit can take advantage of features\n");
       msg.append("  in the library, but will also function without it.\n");
     }
-    msg.append(String.format(
-        "%s and install it now", download ? "Download" : "Copy"));
+    msg.append(String.format("%s and install it now", download ? "Download" : "Copy"));
     return ui.yesno(true, msg.toString(), name);
   }
 
@@ -174,7 +169,7 @@
     }
 
     try {
-      removeStaleVersions();
+      remover.remove(remove);
       if (download) {
         doGetByHttp();
       } else {
@@ -221,41 +216,15 @@
     }
   }
 
-  private void removeStaleVersions() {
-    if (!Strings.isNullOrEmpty(remove)) {
-      DirectoryStream.Filter<Path> filter = new DirectoryStream.Filter<Path>() {
-        @Override
-        public boolean accept(Path entry) {
-          return entry.getFileName().toString()
-              .matches("^" + remove + "$");
-        }
-      };
-      try (DirectoryStream<Path> paths =
-          Files.newDirectoryStream(lib_dir, filter)) {
-        for (Path p : paths) {
-          String old = p.getFileName().toString();
-          String bak = "." + old + ".backup";
-          ui.message("Renaming %s to %s\n", old, bak);
-          try {
-            Files.move(p, p.resolveSibling(bak));
-          } catch (IOException e) {
-            throw new Die("cannot rename " + old, e);
-          }
-        }
-      } catch (IOException e) {
-        throw new Die("cannot remove stale library versions", e);
-      }
-    }
-  }
-
   private void doGetByLocalCopy() throws IOException {
     System.err.print("Copying " + jarUrl + " ...");
     Path p = url2file(jarUrl);
     if (!Files.exists(p)) {
-      StringBuilder msg = new StringBuilder()
-          .append("\n")
-          .append("Can not find the %s at this location: %s\n")
-          .append("Please provide alternative URL");
+      StringBuilder msg =
+          new StringBuilder()
+              .append("\n")
+              .append("Can not find the %s at this location: %s\n")
+              .append("Please provide alternative URL");
       p = url2file(ui.readString(null, msg.toString(), name, jarUrl));
     }
     Files.copy(p, dst);
@@ -300,11 +269,12 @@
         throw new FileNotFoundException(url.toString());
 
       default:
-        throw new IOException(url.toString() + ": " + HttpSupport.response(c)
-            + " " + c.getResponseMessage());
+        throw new IOException(
+            url.toString() + ": " + HttpSupport.response(c) + " " + c.getResponseMessage());
     }
   }
 
+  @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
   private void verifyFileChecksum() {
     if (sha1 == null) {
       System.err.println();
@@ -326,8 +296,9 @@
       deleteDst();
       throw new Die(dst + " SHA-1 checksum does not match");
 
-    } else if (!ui.yesno(null /* force an answer */,
-        "error: SHA-1 checksum does not match\n" + "Use %s anyway",//
+    } else if (!ui.yesno(
+        null /* force an answer */,
+        "error: SHA-1 checksum does not match\nUse %s anyway", //
         dst.getFileName())) {
       deleteDst();
       throw new Die("aborted by user");
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MariaDbInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MariaDbInitializer.java
new file mode 100644
index 0000000..db32113
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MariaDbInitializer.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.gerrit.pgm.init.api.InitUtil.username;
+
+import com.google.gerrit.pgm.init.api.Section;
+
+class MariaDbInitializer implements DatabaseConfigInitializer {
+
+  @Override
+  public void initConfig(Section databaseSection) {
+    final String defPort = "(mariadb default)";
+    databaseSection.string("Server hostname", "hostname", "localhost");
+    databaseSection.string("Server port", "port", defPort, true);
+    databaseSection.string("Database name", "database", "reviewdb");
+    databaseSection.string("Database username", "username", username());
+    databaseSection.password("username", "password");
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/OracleInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/OracleInitializer.java
index e58c6ad..ffbaf34 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/OracleInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/OracleInitializer.java
@@ -18,7 +18,6 @@
 
 import com.google.gerrit.pgm.init.api.Section;
 
-
 public class OracleInitializer implements DatabaseConfigInitializer {
 
   @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
index 68af83f..73720c4 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/PluginsDistribution.java
@@ -19,19 +19,15 @@
 import java.io.InputStream;
 import java.util.List;
 
-/**
- * Represents the plugins packaged in the Gerrit distribution
- */
+/** Represents the plugins packaged in the Gerrit distribution */
 public interface PluginsDistribution {
 
   public interface Processor {
     /**
      * @param pluginName the name of the plugin (without the .jar extension)
-     * @param in the content of the plugin .jar file. Implementors don't have to
-     *        close this stream.
-     * @throws IOException implementations will typically propagate any
-     *         IOException caused by dealing with the InputStream back to the
-     *         caller
+     * @param in the content of the plugin .jar file. Implementors don't have to close this stream.
+     * @throws IOException implementations will typically propagate any IOException caused by
+     *     dealing with the InputStream back to the caller
      */
     void process(String pluginName, InputStream in) throws IOException;
   }
@@ -40,18 +36,16 @@
    * Iterate over plugins package in the Gerrit distribution
    *
    * @param processor invoke for each plugin via its process method
-   * @throws FileNotFoundException if the location of the plugins couldn't be
-   *         determined
-   * @throws IOException in case of any other IO error caused by reading the
-   *         plugin input stream
+   * @throws FileNotFoundException if the location of the plugins couldn't be determined
+   * @throws IOException in case of any other IO error caused by reading the plugin input stream
    */
   void foreach(Processor processor) throws FileNotFoundException, IOException;
 
   /**
    * List plugins included in the Gerrit distribution
+   *
    * @return list of plugins names included in the Gerrit distribution
-   * @throws FileNotFoundException if the location of the plugins couldn't be
-   *         determined
+   * @throws FileNotFoundException if the location of the plugins couldn't be determined
    */
   List<String> listPluginNames() throws FileNotFoundException;
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index f16e2ec..243ea09 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -29,12 +29,11 @@
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.pgm.init.api.Section.Factory;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.mail.OutgoingEmail;
+import com.google.gerrit.server.mail.EmailModule;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.TypeLiteral;
-
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -51,8 +50,11 @@
   private final SecureStoreInitData secureStoreInitData;
 
   @Inject
-  public SitePathInitializer(final Injector injector, final ConsoleUI ui,
-      final InitFlags flags, final SitePaths site,
+  public SitePathInitializer(
+      final Injector injector,
+      final ConsoleUI ui,
+      final InitFlags flags,
+      final SitePaths site,
       final Section.Factory sectionFactory,
       @Nullable final SecureStoreInitData secureStoreInitData) {
     this.ui = ui;
@@ -85,8 +87,7 @@
     mkdir(site.data_dir);
 
     for (InitStep step : steps) {
-      if (step instanceof InitPlugins
-          && flags.skipPlugins) {
+      if (step instanceof InitPlugins && flags.skipPlugins) {
         continue;
       }
       step.run();
@@ -97,23 +98,42 @@
 
     extract(site.gerrit_sh, getClass(), "gerrit.sh");
     chmod(0755, site.gerrit_sh);
+    extract(site.gerrit_service, getClass(), "gerrit.service");
+    chmod(0755, site.gerrit_service);
+    extract(site.gerrit_socket, getClass(), "gerrit.socket");
+    chmod(0755, site.gerrit_socket);
     chmod(0700, site.tmp_dir);
 
-    extractMailExample("Abandoned.vm");
-    extractMailExample("AddKey.vm");
-    extractMailExample("ChangeFooter.vm");
-    extractMailExample("ChangeSubject.vm");
-    extractMailExample("Comment.vm");
-    extractMailExample("CommentFooter.vm");
-    extractMailExample("DeleteReviewer.vm");
-    extractMailExample("DeleteVote.vm");
-    extractMailExample("Footer.vm");
-    extractMailExample("Merged.vm");
-    extractMailExample("NewChange.vm");
-    extractMailExample("RegisterNewEmail.vm");
-    extractMailExample("ReplacePatchSet.vm");
-    extractMailExample("Restored.vm");
-    extractMailExample("Reverted.vm");
+    extractMailExample("Abandoned.soy");
+    extractMailExample("AbandonedHtml.soy");
+    extractMailExample("AddKey.soy");
+    extractMailExample("ChangeFooter.soy");
+    extractMailExample("ChangeFooterHtml.soy");
+    extractMailExample("ChangeSubject.soy");
+    extractMailExample("Comment.soy");
+    extractMailExample("CommentHtml.soy");
+    extractMailExample("CommentFooter.soy");
+    extractMailExample("CommentFooterHtml.soy");
+    extractMailExample("DeleteReviewer.soy");
+    extractMailExample("DeleteReviewerHtml.soy");
+    extractMailExample("DeleteVote.soy");
+    extractMailExample("DeleteVoteHtml.soy");
+    extractMailExample("Footer.soy");
+    extractMailExample("FooterHtml.soy");
+    extractMailExample("HeaderHtml.soy");
+    extractMailExample("Merged.soy");
+    extractMailExample("MergedHtml.soy");
+    extractMailExample("NewChange.soy");
+    extractMailExample("NewChangeHtml.soy");
+    extractMailExample("RegisterNewEmail.soy");
+    extractMailExample("ReplacePatchSet.soy");
+    extractMailExample("ReplacePatchSetHtml.soy");
+    extractMailExample("Restored.soy");
+    extractMailExample("RestoredHtml.soy");
+    extractMailExample("Reverted.soy");
+    extractMailExample("RevertedHtml.soy");
+    extractMailExample("SetAssignee.soy");
+    extractMailExample("SetAssigneeHtml.soy");
 
     if (!ui.isBatch()) {
       System.err.println();
@@ -122,8 +142,7 @@
 
   public void postRun(Injector injector) throws Exception {
     for (InitStep step : steps) {
-      if (step instanceof InitPlugins
-          && flags.skipPlugins) {
+      if (step instanceof InitPlugins && flags.skipPlugins) {
         continue;
       }
       injector.injectMembers(step);
@@ -133,8 +152,7 @@
 
   private void saveSecureStore() throws IOException {
     if (secureStoreInitData != null) {
-      Path dst =
-          site.lib_dir.resolve(secureStoreInitData.jarFile.getFileName());
+      Path dst = site.lib_dir.resolve(secureStoreInitData.jarFile.getFileName());
       Files.copy(secureStoreInitData.jarFile, dst);
       Section gerritSection = sectionFactory.get("gerrit", null);
       gerritSection.set("secureStoreClass", secureStoreInitData.className);
@@ -143,7 +161,7 @@
 
   private void extractMailExample(String orig) throws Exception {
     Path ex = site.mail_dir.resolve(orig + ".example");
-    extract(ex, OutgoingEmail.class, orig);
+    extract(ex, EmailModule.class, orig);
     chmod(0444, ex);
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java
new file mode 100644
index 0000000..c454cce
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/StaleLibraryRemover.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Die;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@Singleton
+public class StaleLibraryRemover {
+  private final ConsoleUI ui;
+  private final Path lib_dir;
+
+  @Inject
+  StaleLibraryRemover(ConsoleUI ui, SitePaths site) {
+    this.ui = ui;
+    this.lib_dir = site.lib_dir;
+  }
+
+  public void remove(String pattern) {
+    if (!Strings.isNullOrEmpty(pattern)) {
+      DirectoryStream.Filter<Path> filter =
+          new DirectoryStream.Filter<Path>() {
+            @Override
+            public boolean accept(Path entry) {
+              return entry.getFileName().toString().matches("^" + pattern + "$");
+            }
+          };
+      try (DirectoryStream<Path> paths = Files.newDirectoryStream(lib_dir, filter)) {
+        for (Path p : paths) {
+          String old = p.getFileName().toString();
+          String bak = "." + old + ".backup";
+          Path dest = p.resolveSibling(bak);
+          if (Files.exists(dest)) {
+            ui.message("WARNING: not renaming %s to %s: already exists\n", old, bak);
+            continue;
+          }
+          ui.message("Renaming %s to %s\n", old, bak);
+          try {
+            Files.move(p, dest);
+          } catch (IOException e) {
+            throw new Die("cannot rename " + old, e);
+          }
+        }
+      } catch (IOException e) {
+        throw new Die("cannot remove stale library versions", e);
+      }
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
index 52f9096..d5d7e78 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
@@ -27,10 +27,6 @@
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.UnsupportedEncodingException;
@@ -42,24 +38,27 @@
 import java.util.Arrays;
 import java.util.Map;
 import java.util.Properties;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
 
 /** Upgrade from a 2.0.x site to a 2.1 site. */
 @Singleton
 class UpgradeFrom2_0_x implements InitStep {
-  static final String[] etcFiles = {"gerrit.config", //
-      "secure.config", //
-      "replication.config", //
-      "ssh_host_rsa_key", //
-      "ssh_host_rsa_key.pub", //
-      "ssh_host_dsa_key", //
-      "ssh_host_dsa_key.pub", //
-      "ssh_host_key", //
-      "contact_information.pub", //
-      "gitweb_config.perl", //
-      "keystore", //
-      "GerritSite.css", //
-      "GerritSiteFooter.html", //
-      "GerritSiteHeader.html", //
+  static final String[] etcFiles = {
+    "gerrit.config", //
+    "secure.config", //
+    "replication.config", //
+    "ssh_host_rsa_key", //
+    "ssh_host_rsa_key.pub", //
+    "ssh_host_dsa_key", //
+    "ssh_host_dsa_key.pub", //
+    "ssh_host_key", //
+    "contact_information.pub", //
+    "gitweb_config.perl", //
+    "keystore", //
+    "GerritSite.css", //
+    "GerritSiteFooter.html", //
+    "GerritSiteHeader.html", //
   };
 
   private final ConsoleUI ui;
@@ -71,8 +70,11 @@
   private final Section.Factory sections;
 
   @Inject
-  UpgradeFrom2_0_x(final SitePaths site, final InitFlags flags,
-      final ConsoleUI ui, final Section.Factory sections) {
+  UpgradeFrom2_0_x(
+      final SitePaths site,
+      final InitFlags flags,
+      final ConsoleUI ui,
+      final Section.Factory sections) {
     this.ui = ui;
     this.sections = sections;
 
@@ -286,8 +288,4 @@
     }
     return null;
   }
-
-  @Override
-  public void postRun() throws Exception {
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
index 6739ce0..e0b0c1c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.VersionedMetaDataOnInit;
@@ -28,12 +27,11 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
 
 public class VersionedAuthorizedKeysOnInit extends VersionedMetaDataOnInit {
   public interface Factory {
@@ -54,8 +52,7 @@
   }
 
   @Override
-  public VersionedAuthorizedKeysOnInit load()
-      throws IOException, ConfigInvalidException {
+  public VersionedAuthorizedKeysOnInit load() throws IOException, ConfigInvalidException {
     super.load();
     return this;
   }
@@ -69,8 +66,7 @@
     checkState(keys != null, "SSH keys not loaded yet");
     int seq = keys.isEmpty() ? 1 : keys.size() + 1;
     AccountSshKey.Id keyId = new AccountSshKey.Id(accountId, seq);
-    AccountSshKey key =
-        new VersionedAuthorizedKeys.SimpleSshKeyCreator().create(keyId, pub);
+    AccountSshKey key = new VersionedAuthorizedKeys.SimpleSshKeyCreator().create(keyId, pub);
     keys.add(Optional.of(key));
     return key;
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index a7ebd33..8c013ed 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.pgm.init.api;
 
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GroupList;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.inject.Inject;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -28,20 +29,15 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 public class AllProjectsConfig extends VersionedMetaDataOnInit {
-
   private static final Logger log = LoggerFactory.getLogger(AllProjectsConfig.class);
 
   private Config cfg;
   private GroupList groupList;
 
   @Inject
-  AllProjectsConfig(AllProjectsNameOnInitProvider allProjects, SitePaths site,
-      InitFlags flags) {
+  AllProjectsConfig(AllProjectsNameOnInitProvider allProjects, SitePaths site, InitFlags flags) {
     super(flags, site, allProjects.get(), RefNames.REFS_CONFIG);
-
   }
 
   public Config getConfig() {
@@ -53,8 +49,7 @@
   }
 
   @Override
-  public AllProjectsConfig load()
-      throws IOException, ConfigInvalidException {
+  public AllProjectsConfig load() throws IOException, ConfigInvalidException {
     super.load();
     return this;
   }
@@ -66,19 +61,20 @@
   }
 
   private GroupList readGroupList() throws IOException {
-    return GroupList.parse(readUTF8(GroupList.FILE_NAME),
-        GroupList.createLoggerSink(GroupList.FILE_NAME, log));
+    return GroupList.parse(
+        new Project.NameKey(project),
+        readUTF8(GroupList.FILE_NAME),
+        error -> log.error("Error parsing file {}: {}", GroupList.FILE_NAME, error.getMessage()));
   }
 
-  public void save(String pluginName, String message)
-      throws IOException, ConfigInvalidException {
-    save(new PersonIdent(pluginName, pluginName + "@gerrit"),
+  public void save(String pluginName, String message) throws IOException, ConfigInvalidException {
+    save(
+        new PersonIdent(pluginName, pluginName + "@gerrit"),
         "Update from plugin " + pluginName + ": " + message);
   }
 
   @Override
-  protected void save(PersonIdent ident, String msg)
-      throws IOException, ConfigInvalidException {
+  protected void save(PersonIdent ident, String msg) throws IOException, ConfigInvalidException {
     super.save(ident, msg);
 
     // we need to invalidate the JGit cache if the group list is invalidated in
@@ -87,8 +83,7 @@
   }
 
   @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     saveConfig(ProjectConfig.PROJECT_CONFIG, cfg);
     saveGroupList();
     return true;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsNameOnInitProvider.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsNameOnInitProvider.java
index 9a6faea..fae2f4e 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsNameOnInitProvider.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsNameOnInitProvider.java
@@ -26,8 +26,7 @@
   @Inject
   AllProjectsNameOnInitProvider(Section.Factory sections) {
     String n = sections.get("gerrit", null).get("allProjects");
-    name = MoreObjects.firstNonNull(
-        Strings.emptyToNull(n), AllProjectsNameProvider.DEFAULT);
+    name = MoreObjects.firstNonNull(Strings.emptyToNull(n), AllProjectsNameProvider.DEFAULT);
   }
 
   @Override
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index e210d5b..7f723b7 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.gerrit.common.Die;
-
 import java.io.Console;
-import java.lang.reflect.InvocationTargetException;
+import java.util.EnumSet;
 import java.util.Set;
 
 /** Console based interaction with the invoking user. */
@@ -38,18 +37,6 @@
     return new Die("aborted by user");
   }
 
-  /** Obtain all values from an enumeration. */
-  @SuppressWarnings("unchecked")
-  protected static <T extends Enum<?>> T[] all(final T value) {
-    try {
-      return (T[]) value.getClass().getMethod("values").invoke(null);
-    } catch (IllegalArgumentException | NoSuchMethodException
-        | InvocationTargetException | IllegalAccessException
-        | SecurityException e) {
-      throw new IllegalArgumentException("Cannot obtain enumeration values", e);
-    }
-  }
-
   /** @return true if this is a batch UI that has no user interaction. */
   public abstract boolean isBatch();
 
@@ -69,12 +56,12 @@
   public abstract String readString(String def, String fmt, Object... args);
 
   /** Prompt the user to make a choice from an allowed list of values. */
-  public abstract String readString(String def, Set<String> allowedValues,
-      String fmt, Object... args);
+  public abstract String readString(
+      String def, Set<String> allowedValues, String fmt, Object... args);
 
   /** Prompt the user for an integer value, suggesting a default. */
   public int readInt(int def, String fmt, Object... args) {
-    for (;;) {
+    for (; ; ) {
       String p = readString(String.valueOf(def), fmt, args);
       try {
         return Integer.parseInt(p.trim(), 10);
@@ -87,9 +74,15 @@
   /** Prompt the user for a password, returning the string; null if blank. */
   public abstract String password(String fmt, Object... args);
 
+  /** Display an error message on the system stderr. */
+  public void error(String format, Object... args) {
+    System.err.println(String.format(format, args));
+    System.err.flush();
+  }
+
   /** Prompt the user to make a choice from an enumeration's values. */
-  public abstract <T extends Enum<?>> T readEnum(T def, String fmt,
-      Object... args);
+  public abstract <T extends Enum<?>, A extends EnumSet<? extends T>> T readEnum(
+      T def, A options, String fmt, Object... args);
 
   private static class Interactive extends ConsoleUI {
     private final Console console;
@@ -106,7 +99,7 @@
     @Override
     public boolean yesno(Boolean def, String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
-      for (;;) {
+      for (; ; ) {
         String y = "y";
         String n = "n";
         if (def != null) {
@@ -161,9 +154,8 @@
     }
 
     @Override
-    public String readString(String def, Set<String> allowedValues, String fmt,
-        Object... args) {
-      for (;;) {
+    public String readString(String def, Set<String> allowedValues, String fmt, Object... args) {
+      for (; ; ) {
         String r = readString(def, fmt, args);
         if (allowedValues.contains(r.toLowerCase())) {
           return r.toLowerCase();
@@ -181,7 +173,7 @@
     @Override
     public String password(String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
-      for (;;) {
+      for (; ; ) {
         final char[] a1 = console.readPassword("%-30s : ", prompt);
         if (a1 == null) {
           throw abort();
@@ -203,11 +195,11 @@
     }
 
     @Override
-    public <T extends Enum<?>> T readEnum(T def, String fmt, Object... args) {
+    public <T extends Enum<?>, A extends EnumSet<? extends T>> T readEnum(
+        T def, A options, String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
-      final T[] options = all(def);
-      for (;;) {
-        String r = console.readLine("%-30s [%s/?]: ", prompt, def.toString());
+      for (; ; ) {
+        String r = console.readLine("%-30s [%s/?]: ", prompt, def.toString().toLowerCase());
         if (r == null) {
           throw abort();
         }
@@ -259,14 +251,12 @@
     }
 
     @Override
-    public String readString(String def, Set<String> allowedValues, String fmt,
-        Object... args) {
+    public String readString(String def, Set<String> allowedValues, String fmt, Object... args) {
       return def;
     }
 
     @Override
-    public void waitForUser() {
-    }
+    public void waitForUser() {}
 
     @Override
     public String password(String fmt, Object... args) {
@@ -274,16 +264,15 @@
     }
 
     @Override
-    public <T extends Enum<?>> T readEnum(T def, String fmt, Object... args) {
+    public <T extends Enum<?>, A extends EnumSet<? extends T>> T readEnum(
+        T def, A options, String fmt, Object... args) {
       return def;
     }
 
     @Override
-    public void header(String fmt, Object... args) {
-    }
+    public void header(String fmt, Object... args) {}
 
     @Override
-    public void message(String fmt, Object... args) {
-    }
+    public void message(String fmt, Object... args) {}
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
index fa62d93..691243f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
@@ -19,14 +19,12 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 
-import java.io.IOException;
-import java.util.List;
-
 /** Global variables used by the 'init' command. */
 @Singleton
 public class InitFlags {
@@ -52,11 +50,12 @@
 
   @VisibleForTesting
   @Inject
-  public InitFlags(final SitePaths site,
+  public InitFlags(
+      final SitePaths site,
       final SecureStore secureStore,
       @InstallPlugins final List<String> installPlugins,
-      @InstallAllPlugins final Boolean installAllPlugins) throws IOException,
-          ConfigInvalidException {
+      @InstallAllPlugins final Boolean installAllPlugins)
+      throws IOException, ConfigInvalidException {
     sec = secureStore;
     this.installPlugins = installPlugins;
     this.installAllPlugins = installAllPlugins;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
index fd28399..9d4becc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitStep.java
@@ -19,5 +19,5 @@
   void run() throws Exception;
 
   /** Executed after the site has been initialized */
-  void postRun() throws Exception;
+  default void postRun() throws Exception {}
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
index 1e1ddd7..b80cb22 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
@@ -18,11 +18,6 @@
 
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.common.Die;
-
-import org.eclipse.jgit.internal.storage.file.LockFile;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.SystemReader;
-
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileNotFoundException;
@@ -37,6 +32,9 @@
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.util.Arrays;
+import org.eclipse.jgit.internal.storage.file.LockFile;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.SystemReader;
 
 /** Utility functions to help initialize a site. */
 public class InitUtil {
@@ -121,8 +119,7 @@
     return name;
   }
 
-  public static void extract(Path dst, Class<?> sibling, String name)
-      throws IOException {
+  public static void extract(Path dst, Class<?> sibling, String name) throws IOException {
     try (InputStream in = open(sibling, name)) {
       if (in != null) {
         copy(dst, ByteStreams.toByteArray(in));
@@ -147,8 +144,7 @@
     return in;
   }
 
-  public static void copy(Path dst, byte[] buf)
-      throws FileNotFoundException, IOException {
+  public static void copy(Path dst, byte[] buf) throws FileNotFoundException, IOException {
     // If the file already has the content we want to put there,
     // don't attempt to overwrite the file.
     //
@@ -203,6 +199,5 @@
     return port;
   }
 
-  private InitUtil() {
-  }
+  private InitUtil() {}
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java
index 809a197..7a41acc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallAllPlugins.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
 @BindingAnnotation
 @Retention(RetentionPolicy.RUNTIME)
-public @interface InstallAllPlugins {
-}
+public @interface InstallAllPlugins {}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallPlugins.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallPlugins.java
index 256892a..a88250e 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallPlugins.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InstallPlugins.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
 @BindingAnnotation
 @Retention(RetentionPolicy.RUNTIME)
-public @interface InstallPlugins {
-}
+public @interface InstallPlugins {}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java
index 7e46b21..3e3b67a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/LibraryDownload.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
 @BindingAnnotation
 @Retention(RetentionPolicy.RUNTIME)
-public @interface LibraryDownload {
-}
+public @interface LibraryDownload {}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
index 8cda882..d52005f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
@@ -19,17 +19,16 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.EnumSet;
 import java.util.Set;
 
 /** Helper to edit a section of the configuration files. */
 public class Section {
   public interface Factory {
-    Section get(@Assisted("section") String section,
-        @Assisted("subsection") String subsection);
+    Section get(@Assisted("section") String section, @Assisted("subsection") String subsection);
   }
 
   private final InitFlags flags;
@@ -40,8 +39,11 @@
   private final SecureStore secureStore;
 
   @Inject
-  public Section(final InitFlags flags, final SitePaths site,
-      final SecureStore secureStore, final ConsoleUI ui,
+  public Section(
+      final InitFlags flags,
+      final SitePaths site,
+      final SecureStore secureStore,
+      final ConsoleUI ui,
       @Assisted("section") final String section,
       @Assisted("subsection") @Nullable final String subsection) {
     this.flags = flags;
@@ -92,8 +94,8 @@
     return string(title, name, dv, false);
   }
 
-  public String string(final String title, final String name, final String dv,
-      final boolean nullIfDefault) {
+  public String string(
+      final String title, final String name, final String dv, final boolean nullIfDefault) {
     final String ov = get(name);
     String nv = ui.readString(ov != null ? ov : dv, "%s", title);
     if (nullIfDefault && nv.equals(dv)) {
@@ -109,16 +111,28 @@
     return site.resolve(string(title, name, defValue));
   }
 
-  public <T extends Enum<?>> T select(final String title, final String name,
-      final T defValue) {
+  public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
+      String title, String name, T defValue) {
     return select(title, name, defValue, false);
   }
 
-  public <T extends Enum<?>> T select(final String title, final String name,
-      final T defValue, final boolean nullIfDefault) {
+  public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
+      String title, String name, T defValue, boolean nullIfDefault) {
+    @SuppressWarnings("unchecked")
+    E allowedValues = (E) EnumSet.allOf(defValue.getClass());
+    return select(title, name, defValue, allowedValues, nullIfDefault);
+  }
+
+  public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
+      String title, String name, T defValue, E allowedValues) {
+    return select(title, name, defValue, allowedValues, false);
+  }
+
+  public <T extends Enum<?>, A extends EnumSet<? extends T>> T select(
+      String title, String name, T defValue, A allowedValues, final boolean nullIfDefault) {
     final boolean set = get(name) != null;
     T oldValue = flags.cfg.getEnum(section, subsection, name, defValue);
-    T newValue = ui.readEnum(oldValue, "%s", title);
+    T newValue = ui.readEnum(oldValue, allowedValues, "%s", title);
     if (nullIfDefault && newValue == defValue) {
       newValue = null;
     }
@@ -132,8 +146,8 @@
     return newValue;
   }
 
-  public String select(final String title, final String name, final String dv,
-      Set<String> allowedValues) {
+  public String select(
+      final String title, final String name, final String dv, Set<String> allowedValues) {
     final String ov = get(name);
     String nv = ui.readString(ov != null ? ov : dv, allowedValues, "%s", title);
     if (!eq(ov, nv)) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index b953a0b..fd825b8 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.pgm.init.api;
 
+import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.VersionedMetaData;
-
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -31,19 +34,14 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.FS;
 
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Path;
-
 public abstract class VersionedMetaDataOnInit extends VersionedMetaData {
 
+  protected final String project;
   private final InitFlags flags;
   private final SitePaths site;
-  private final String project;
   private final String ref;
 
-  protected VersionedMetaDataOnInit(InitFlags flags, SitePaths site,
-      String project, String ref) {
+  protected VersionedMetaDataOnInit(InitFlags flags, SitePaths site, String project, String ref) {
     this.flags = flags;
     this.site = site;
     this.project = project;
@@ -55,8 +53,7 @@
     return ref;
   }
 
-  public VersionedMetaDataOnInit load()
-      throws IOException, ConfigInvalidException {
+  public VersionedMetaDataOnInit load() throws IOException, ConfigInvalidException {
     File path = getPath();
     if (path != null) {
       try (Repository repo = new FileRepository(path)) {
@@ -67,11 +64,10 @@
   }
 
   public void save(String message) throws IOException, ConfigInvalidException {
-    save(new PersonIdent("Gerrit Initialization", "init@gerrit"), message);
+    save(new GerritPersonIdentProvider(flags.cfg).get(), message);
   }
 
-  protected void save(PersonIdent ident, String msg)
-      throws IOException, ConfigInvalidException {
+  protected void save(PersonIdent ident, String msg) throws IOException, ConfigInvalidException {
     File path = getPath();
     if (path == null) {
       throw new IOException(project + " does not exist.");
@@ -112,15 +108,15 @@
     }
   }
 
-  private void updateRef(Repository repo, PersonIdent ident,
-      ObjectId newRevision, String refLogMsg) throws IOException {
+  private void updateRef(Repository repo, PersonIdent ident, ObjectId newRevision, String refLogMsg)
+      throws IOException {
     RefUpdate ru = repo.updateRef(getRefName());
     ru.setRefLogIdent(ident);
     ru.setNewObjectId(newRevision);
     ru.setExpectedOldObjectId(revision);
     ru.setRefLogMessage(refLogMsg, false);
     RefUpdate.Result r = ru.update();
-    switch(r) {
+    switch (r) {
       case FAST_FORWARD:
       case NEW:
       case NO_CHANGE:
@@ -133,8 +129,8 @@
       case REJECTED_CURRENT_BRANCH:
       case RENAMED:
       default:
-        throw new IOException("Failed to update " + getRefName() + " of "
-            + project + ": " + r.name());
+        throw new IOException(
+            "Failed to update " + getRefName() + " of " + project + ": " + r.name());
     }
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java
new file mode 100644
index 0000000..5273dfb
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.index;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import java.util.Collection;
+
+/**
+ * This class starts/stops the indexes from the init program so that init can write updates the
+ * indexes.
+ */
+public class IndexManagerOnInit {
+  private final LifecycleListener indexManager;
+  private final Collection<IndexDefinition<?, ?, ?>> defs;
+
+  @Inject
+  IndexManagerOnInit(
+      @Named(IndexModuleOnInit.INDEX_MANAGER) LifecycleListener indexManager,
+      Collection<IndexDefinition<?, ?, ?>> defs) {
+    this.indexManager = indexManager;
+    this.defs = defs;
+  }
+
+  public void start() {
+    indexManager.start();
+
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      def.getIndexCollection().start();
+    }
+  }
+
+  public void stop() {
+    indexManager.stop();
+
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      def.getIndexCollection().stop();
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
new file mode 100644
index 0000000..0358f13
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.index;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.index.SchemaDefinitions;
+import com.google.gerrit.server.index.SingleVersionModule;
+import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexDefinition;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.account.AllAccountsIndexer;
+import com.google.gerrit.server.index.group.AllGroupsIndexer;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndexDefinition;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Names;
+import com.google.inject.util.Providers;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+public class IndexModuleOnInit extends AbstractModule {
+  static final String INDEX_MANAGER = "IndexModuleOnInit/IndexManager";
+
+  private static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
+      ImmutableList.<SchemaDefinitions<?>>of(
+          AccountSchemaDefinitions.INSTANCE, GroupSchemaDefinitions.INSTANCE);
+
+  @Override
+  protected void configure() {
+    // The AccountIndex implementations (LuceneAccountIndex and
+    // ElasticAccountIndex) need AccountCache only for reading from the index.
+    // On init we only want to write to the index, hence we don't need the
+    // account cache.
+    bind(AccountCache.class).toProvider(Providers.of(null));
+
+    // AccountIndexDefinition wants to have AllAccountsIndexer but it is only
+    // used by the Reindex program and the OnlineReindexer which are both not
+    // used during init, hence we don't need AllAccountsIndexer.
+    bind(AllAccountsIndexer.class).toProvider(Providers.of(null));
+
+    bind(AccountIndexCollection.class);
+
+    // The GroupIndex implementations (LuceneGroupIndex and ElasticGroupIndex)
+    // need GroupCache only for reading from the index. On init we only want to
+    // write to the index, hence we don't need the group cache.
+    bind(GroupCache.class).toProvider(Providers.of(null));
+
+    // GroupIndexDefinition wants to have AllGroupsIndexer but it is only used
+    // by the Reindex program and the OnlineReindexer which are both not used
+    // during init, hence we don't need AllGroupsIndexer.
+    bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
+
+    bind(GroupIndexCollection.class);
+
+    bind(new TypeLiteral<Map<String, Integer>>() {})
+        .annotatedWith(Names.named(SingleVersionModule.SINGLE_VERSIONS))
+        .toInstance(ImmutableMap.<String, Integer>of());
+    bind(LifecycleListener.class)
+        .annotatedWith(Names.named(INDEX_MANAGER))
+        .to(SingleVersionListener.class);
+  }
+
+  @Provides
+  Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
+      AccountIndexDefinition accounts, GroupIndexDefinition groups) {
+    Collection<IndexDefinition<?, ?, ?>> result =
+        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups);
+    Set<String> expected =
+        FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
+    Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
+    if (!expected.equals(actual)) {
+      throw new ProvisionException(
+          "need index definitions for all schemas: " + expected + " != " + actual);
+    }
+    return result;
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
new file mode 100644
index 0000000..f086ab1
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.index.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticAccountIndex;
+import com.google.gerrit.elasticsearch.ElasticGroupIndex;
+import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.inject.AbstractModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+
+public class ElasticIndexModuleOnInit extends AbstractModule {
+
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, ElasticAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+
+    install(
+        new FactoryModuleBuilder()
+            .implement(GroupIndex.class, ElasticGroupIndex.class)
+            .build(GroupIndex.Factory.class));
+
+    install(new IndexModuleOnInit());
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
new file mode 100644
index 0000000..12a44dc
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.index.lucene;
+
+import com.google.gerrit.lucene.LuceneAccountIndex;
+import com.google.gerrit.lucene.LuceneGroupIndex;
+import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.inject.AbstractModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+
+public class LuceneIndexModuleOnInit extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, LuceneAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+
+    install(
+        new FactoryModuleBuilder()
+            .implement(GroupIndex.class, LuceneGroupIndex.class)
+            .build(GroupIndex.Factory.class));
+
+    install(new IndexModuleOnInit());
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java
index 8d3c766..4ad7701 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/rules/PrologCompiler.java
@@ -21,20 +21,12 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import com.googlecode.prolog_cafe.compiler.Compiler;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
-
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.URL;
 import java.net.URLClassLoader;
@@ -48,18 +40,20 @@
 import java.util.jar.JarEntry;
 import java.util.jar.JarOutputStream;
 import java.util.jar.Manifest;
-
 import javax.tools.Diagnostic;
 import javax.tools.DiagnosticCollector;
 import javax.tools.JavaCompiler;
 import javax.tools.JavaFileObject;
 import javax.tools.StandardJavaFileManager;
 import javax.tools.ToolProvider;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 
 /**
- * Helper class for Rulec: does the actual prolog -> java src -> class -> jar work
- * Finds rules.pl in refs/meta/config branch
- * Creates rules-(sha1 of rules.pl).jar in (site-path)/cache/rules
+ * Helper class for Rulec: does the actual prolog -> java src -> class -> jar work Finds rules.pl in
+ * refs/meta/config branch Creates rules-(sha1 of rules.pl).jar in (site-path)/cache/rules
  */
 public class PrologCompiler implements Callable<PrologCompiler.Status> {
   public interface Factory {
@@ -67,15 +61,16 @@
   }
 
   public enum Status {
-    NO_RULES, COMPILED
+    NO_RULES,
+    COMPILED
   }
 
   private final Path ruleDir;
   private final Repository git;
 
   @Inject
-  PrologCompiler(@GerritServerConfig Config config, SitePaths site,
-      @Assisted Repository gitRepository) {
+  PrologCompiler(
+      @GerritServerConfig Config config, SitePaths site, @Assisted Repository gitRepository) {
     Path cacheDir = site.resolve(config.getString("cache", null, "directory"));
     ruleDir = cacheDir != null ? cacheDir.resolve("rules") : null;
     git = gitRepository;
@@ -123,8 +118,7 @@
   }
 
   /** Creates a copy of rules.pl and compiles it into Java sources. */
-  private void compileProlog(ObjectId prolog, File tempDir)
-      throws IOException, CompileException {
+  private void compileProlog(ObjectId prolog, File tempDir) throws IOException, CompileException {
     File tempRules = copyToTempFile(prolog, tempDir);
     try {
       Compiler comp = new Compiler();
@@ -139,7 +133,7 @@
     // Any leak of tmp caused by this method failing will be cleaned
     // up by our caller when tempDir is recursively deleted.
     File tmp = File.createTempFile("rules", ".pl", tempDir);
-    try (FileOutputStream out = new FileOutputStream(tmp)) {
+    try (OutputStream out = Files.newOutputStream(tmp.toPath())) {
       git.open(blobId).copyTo(out);
     }
     return tmp;
@@ -152,12 +146,11 @@
       throw new CompileException("JDK required (running inside of JRE)");
     }
 
-    DiagnosticCollector<JavaFileObject> diagnostics =
-        new DiagnosticCollector<>();
+    DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
     try (StandardJavaFileManager fileManager =
         compiler.getStandardFileManager(diagnostics, null, null)) {
-      Iterable<? extends JavaFileObject> compilationUnits = fileManager
-        .getJavaFileObjectsFromFiles(getAllFiles(tempDir, ".java"));
+      Iterable<? extends JavaFileObject> compilationUnits =
+          fileManager.getJavaFileObjectsFromFiles(getAllFiles(tempDir, ".java"));
       ArrayList<String> options = new ArrayList<>();
       String classpath = getMyClasspath();
       if (classpath != null) {
@@ -166,13 +159,8 @@
       }
       options.add("-d");
       options.add(tempDir.getPath());
-      JavaCompiler.CompilationTask task = compiler.getTask(
-          null,
-          fileManager,
-          diagnostics,
-          options,
-          null,
-          compilationUnits);
+      JavaCompiler.CompilationTask task =
+          compiler.getTask(null, fileManager, diagnostics, options, null, compilationUnits);
       if (!task.call()) {
         Locale myLocale = Locale.getDefault();
         StringBuilder msg = new StringBuilder();
@@ -217,8 +205,9 @@
   }
 
   /** Takes compiled prolog .class files, puts them into the jar file. */
-  private void createJar(Path archiveFile, List<String> toBeJared,
-      File tempDir, ObjectId metaConfig, ObjectId rulesId) throws IOException {
+  private void createJar(
+      Path archiveFile, List<String> toBeJared, File tempDir, ObjectId metaConfig, ObjectId rulesId)
+      throws IOException {
     long now = TimeUtil.nowMs();
     Manifest mf = new Manifest();
     mf.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
@@ -229,8 +218,7 @@
     mf.getMainAttributes().putValue("Source-Commit", metaConfig.name());
     mf.getMainAttributes().putValue("Source-Blob", rulesId.name());
 
-    Path tmpjar =
-        Files.createTempFile(archiveFile.getParent(), ".rulec_", ".jar");
+    Path tmpjar = Files.createTempFile(archiveFile.getParent(), ".rulec_", ".jar");
     try (OutputStream stream = Files.newOutputStream(tmpjar);
         JarOutputStream out = new JarOutputStream(stream, mf)) {
       byte[] buffer = new byte[10240];
@@ -241,7 +229,7 @@
         jarAdd.setTime(now);
         out.putNextEntry(jarAdd);
         if (f.isFile()) {
-          try (FileInputStream in = new FileInputStream(f)) {
+          try (InputStream in = Files.newInputStream(f.toPath())) {
             while (true) {
               int nRead = in.read(buffer, 0, buffer.length);
               if (nRead <= 0) {
@@ -262,15 +250,13 @@
     }
   }
 
-  private List<File> getAllFiles(File dir, String extension)
-      throws IOException {
+  private List<File> getAllFiles(File dir, String extension) throws IOException {
     ArrayList<File> fileList = new ArrayList<>();
     getAllFiles(dir, extension, fileList);
     return fileList;
   }
 
-  private void getAllFiles(File dir, String extension, List<File> fileList)
-      throws IOException {
+  private void getAllFiles(File dir, String extension, List<File> fileList) throws IOException {
     for (File f : listFiles(dir)) {
       if (f.getName().endsWith(extension)) {
         fileList.add(f);
@@ -281,15 +267,14 @@
     }
   }
 
-  private List<String> getRelativePaths(File dir, String extension)
-      throws IOException {
+  private List<String> getRelativePaths(File dir, String extension) throws IOException {
     ArrayList<String> pathList = new ArrayList<>();
     getRelativePaths(dir, extension, "", pathList);
     return pathList;
   }
 
-  private static void getRelativePaths(File dir, String extension, String path,
-      List<String> pathList) throws IOException {
+  private static void getRelativePaths(
+      File dir, String extension, String path, List<String> pathList) throws IOException {
     for (File f : listFiles(dir)) {
       if (f.getName().endsWith(extension)) {
         pathList.add(path + f.getName());
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java
index 31fa7dd..825bd70 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java
@@ -14,16 +14,13 @@
 
 package com.google.gerrit.pgm.util;
 
-
 import com.google.gerrit.common.Die;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.OptionHandlers;
-
+import java.io.StringWriter;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.Option;
 
-import java.io.StringWriter;
-
 /** Base class for command line invocations of Gerrit Code Review. */
 public abstract class AbstractProgram {
   private final Object sleepLock = new Object();
@@ -69,8 +66,7 @@
         final Throwable cause = err.getCause();
         final String diemsg = err.getMessage();
         if (cause != null && !cause.getMessage().equals(diemsg)) {
-          System.err.println("fatal: "
-              + cause.getMessage().replaceAll("\n", "\nfatal: "));
+          System.err.println("fatal: " + cause.getMessage().replaceAll("\n", "\nfatal: "));
         }
         System.err.println("fatal: " + diemsg.replaceAll("\n", "\nfatal: "));
       }
@@ -104,9 +100,9 @@
 
   /**
    * Run this program's logic, returning the command exit status.
-   * <p>
-   * When this method completes, the JVM is terminated. To keep the JVM running,
-   * use {@code return never()}.
+   *
+   * <p>When this method completes, the JVM is terminated. To keep the JVM running, use {@code
+   * return never()}.
    */
   public abstract int run() throws Exception;
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
index 0360cd6..d39c2fd 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchGitModule.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidators;
 
 /** Module for batch programs that need git access. */
 public class BatchGitModule extends FactoryModule {
@@ -27,7 +26,6 @@
   protected void configure() {
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
-    factory(CommitValidators.Factory.class);
     install(new GitModule());
   }
 }
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 f076e54..5cc0ca0 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
@@ -38,7 +38,8 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 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.cache.h2.H2CacheModule;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
@@ -52,13 +53,15 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
-import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.extensions.events.EventUtil;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.group.GroupModule;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
@@ -69,31 +72,28 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Module for programs that perform batch operations on a site.
- * <p>
- * Any program that requires this module likely also requires using
- * {@link ThreadLimiter} to limit the number of threads accessing the database
- * concurrently.
+ *
+ * <p>Any program that requires this module likely also requires using {@link ThreadLimiter} to
+ * limit the number of threads accessing the database concurrently.
  */
 public class BatchProgramModule extends FactoryModule {
   private final Config cfg;
   private final Module reviewDbModule;
 
   @Inject
-  BatchProgramModule(@GerritServerConfig Config cfg,
-      PerThreadReviewDbModule reviewDbModule) {
+  BatchProgramModule(@GerritServerConfig Config cfg, PerThreadReviewDbModule reviewDbModule) {
     this.cfg = cfg;
     this.reviewDbModule = reviewDbModule;
   }
@@ -104,55 +104,58 @@
     install(reviewDbModule);
     install(new DiffExecutorModule());
     install(new ReceiveCommitsExecutorModule());
+    install(BatchUpdate.module());
     install(PatchListCacheImpl.module());
 
     // Plugins are not loaded and we're just running through each change
     // once, so don't worry about cache removal.
     bind(new TypeLiteral<DynamicSet<CacheRemovalListener>>() {})
-        .toInstance(DynamicSet.<CacheRemovalListener> emptySet());
+        .toInstance(DynamicSet.<CacheRemovalListener>emptySet());
     bind(new TypeLiteral<DynamicMap<Cache<?, ?>>>() {})
-        .toInstance(DynamicMap.<Cache<?, ?>> emptyMap());
+        .toInstance(DynamicMap.<Cache<?, ?>>emptyMap());
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
-        .toProvider(CommentLinkProvider.class).in(SINGLETON);
-    bind(String.class).annotatedWith(CanonicalWebUrl.class)
+        .toProvider(CommentLinkProvider.class)
+        .in(SINGLETON);
+    bind(String.class)
+        .annotatedWith(CanonicalWebUrl.class)
         .toProvider(CanonicalWebUrlProvider.class);
-    bind(Boolean.class).annotatedWith(DisableReverseDnsLookup.class)
-        .toProvider(DisableReverseDnsLookupProvider.class).in(SINGLETON);
+    bind(Boolean.class)
+        .annotatedWith(DisableReverseDnsLookup.class)
+        .toProvider(DisableReverseDnsLookupProvider.class)
+        .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
-    bind(IdentifiedUser.class)
-      .toProvider(Providers.<IdentifiedUser> of(null));
-    bind(ReplacePatchSetSender.Factory.class).toProvider(
-        Providers.<ReplacePatchSetSender.Factory>of(null));
+    bind(IdentifiedUser.class).toProvider(Providers.<IdentifiedUser>of(null));
+    bind(ReplacePatchSetSender.Factory.class)
+        .toProvider(Providers.<ReplacePatchSetSender.Factory>of(null));
     bind(CurrentUser.class).to(IdentifiedUser.class);
-    factory(BatchUpdate.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
 
     // As Reindex is a batch program, don't assume the index is available for
     // the change cache.
-    bind(SearchingChangeCacheImpl.class).toProvider(
-        Providers.<SearchingChangeCacheImpl>of(null));
+    bind(SearchingChangeCacheImpl.class).toProvider(Providers.<SearchingChangeCacheImpl>of(null));
 
     bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
-      .annotatedWith(AdministrateServerGroups.class)
-      .toInstance(ImmutableSet.<GroupReference> of());
+        .annotatedWith(AdministrateServerGroups.class)
+        .toInstance(ImmutableSet.<GroupReference>of());
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
-      .annotatedWith(GitUploadPackGroups.class)
-      .toInstance(Collections.<AccountGroup.UUID> emptySet());
+        .annotatedWith(GitUploadPackGroups.class)
+        .toInstance(Collections.<AccountGroup.UUID>emptySet());
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
-      .annotatedWith(GitReceivePackGroups.class)
-      .toInstance(Collections.<AccountGroup.UUID> emptySet());
+        .annotatedWith(GitReceivePackGroups.class)
+        .toInstance(Collections.<AccountGroup.UUID>emptySet());
     bind(ChangeControl.Factory.class);
     factory(ProjectControl.AssistedFactory.class);
 
     install(new BatchGitModule());
-    install(new DefaultCacheFactory.Module());
+    install(new DefaultMemoryCacheModule());
+    install(new H2CacheModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
     install(new PrologModule());
     install(AccountByEmailCacheImpl.module());
-    install(AccountCacheImpl.module());
+    install(AccountCacheImpl.module(false));
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
     install(ProjectCacheImpl.module());
@@ -165,10 +168,10 @@
     factory(ChangeData.Factory.class);
     factory(ProjectState.Factory.class);
 
-    bind(ChangeJson.Factory.class).toProvider(
-        Providers.<ChangeJson.Factory>of(null));
-    bind(AccountVisibility.class)
-        .toProvider(AccountVisibilityProvider.class)
-        .in(SINGLETON);
+    bind(ChangeJson.Factory.class).toProvider(Providers.<ChangeJson.Factory>of(null));
+    bind(EventUtil.class).toProvider(Providers.<EventUtil>of(null));
+    bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+    bind(RevisionCreated.class).toInstance(RevisionCreated.DISABLED);
+    bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java
index 95caf25..e5076c9 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java
@@ -18,9 +18,9 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.util.SystemLog;
-
+import java.io.IOException;
+import java.nio.file.Path;
 import net.logstash.log4j.JSONEventLayoutV1;
-
 import org.apache.log4j.ConsoleAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.LogManager;
@@ -28,9 +28,6 @@
 import org.apache.log4j.PatternLayout;
 import org.eclipse.jgit.lib.Config;
 
-import java.io.IOException;
-import java.nio.file.Path;
-
 public class ErrorLogFile {
   static final String LOG_NAME = "error_log";
   static final String JSON_SUFFIX = ".json";
@@ -54,16 +51,15 @@
 
   public static LifecycleListener start(final Path sitePath, final Config config)
       throws IOException {
-    Path logdir = FileUtil.mkdirsOrDie(new SitePaths(sitePath).logs_dir,
-        "Cannot create log directory");
+    Path logdir =
+        FileUtil.mkdirsOrDie(new SitePaths(sitePath).logs_dir, "Cannot create log directory");
     if (SystemLog.shouldConfigure()) {
       initLogSystem(logdir, config);
     }
 
     return new LifecycleListener() {
       @Override
-      public void start() {
-      }
+      public void start() {}
 
       @Override
       public void stop() {
@@ -80,13 +76,14 @@
     boolean text = config.getBoolean("log", "textLogging", true) || !json;
 
     if (text) {
-      root.addAppender(SystemLog.createAppender(logdir, LOG_NAME,
-          new PatternLayout("[%d] [%t] %-5p %c %x: %m%n")));
+      root.addAppender(
+          SystemLog.createAppender(
+              logdir, LOG_NAME, new PatternLayout("[%d] [%t] %-5p %c %x: %m%n")));
     }
 
     if (json) {
-      root.addAppender(SystemLog.createAppender(logdir, LOG_NAME + JSON_SUFFIX,
-          new JSONEventLayoutV1()));
+      root.addAppender(
+          SystemLog.createAppender(logdir, LOG_NAME + JSON_SUFFIX, new JSONEventLayoutV1()));
     }
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/GuiceLogger.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/GuiceLogger.java
index 6776ca9..a25fc14 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/GuiceLogger.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/GuiceLogger.java
@@ -23,19 +23,22 @@
 
 public class GuiceLogger {
   private static final Handler HANDLER;
+
   static {
-    HANDLER = new StreamHandler(System.out, new Formatter() {
-      @Override
-      public String format(LogRecord record) {
-        return String.format("[Guice %s] %s%n", record.getLevel().getName(),
-            record.getMessage());
-      }
-    });
+    HANDLER =
+        new StreamHandler(
+            System.out,
+            new Formatter() {
+              @Override
+              public String format(LogRecord record) {
+                return String.format(
+                    "[Guice %s] %s%n", record.getLevel().getName(), record.getMessage());
+              }
+            });
     HANDLER.setLevel(Level.ALL);
   }
 
-  private GuiceLogger() {
-  }
+  private GuiceLogger() {}
 
   public static Logger getLogger() {
     return Logger.getLogger("com.google.inject");
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
index 262997b..853a43f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/LogFileCompressor.java
@@ -23,24 +23,23 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
-
-import org.joda.time.DateTime;
-import org.joda.time.Duration;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.Future;
 import java.util.zip.GZIPOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Compresses the old error logs. */
 public class LogFileCompressor implements Runnable {
-  private static final Logger log =
-      LoggerFactory.getLogger(LogFileCompressor.class);
+  private static final Logger log = LoggerFactory.getLogger(LogFileCompressor.class);
 
   public static class Module extends LifecycleModule {
     @Override
@@ -54,27 +53,29 @@
     private final LogFileCompressor compressor;
 
     @Inject
-    Lifecycle(WorkQueue queue,
-        LogFileCompressor compressor) {
+    Lifecycle(WorkQueue queue, LogFileCompressor compressor) {
       this.queue = queue;
       this.compressor = compressor;
     }
 
     @Override
     public void start() {
-      //compress log once and then schedule compression every day at 11:00pm
+      // compress log once and then schedule compression every day at 11:00pm
       queue.getDefaultQueue().execute(compressor);
-      DateTime now = DateTime.now();
-      long milliSecondsUntil11am =
-          new Duration(now, now.withTimeAtStartOfDay().plusHours(23))
-              .getMillis();
-      queue.getDefaultQueue().scheduleAtFixedRate(compressor,
-          milliSecondsUntil11am, HOURS.toMillis(24), MILLISECONDS);
+      ZoneId zone = ZoneId.systemDefault();
+      LocalDateTime now = LocalDateTime.now(zone);
+      long milliSecondsUntil11pm =
+          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError =
+          queue
+              .getDefaultQueue()
+              .scheduleAtFixedRate(
+                  compressor, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
     }
 
     @Override
-    public void stop() {
-    }
+    public void stop() {}
   }
 
   private final Path logs_dir;
@@ -94,17 +95,21 @@
 
   @Override
   public void run() {
-    if (!Files.isDirectory(logs_dir)) {
-      return;
-    }
-    try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
-      for (Path entry : list) {
-        if (!isLive(entry) && !isCompressed(entry) && isLogFile(entry)) {
-          compress(entry);
-        }
+    try {
+      if (!Files.isDirectory(logs_dir)) {
+        return;
       }
-    } catch (IOException e) {
-      log.error("Error listing logs to compress in " + logs_dir, e);
+      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
+        for (Path entry : list) {
+          if (!isLive(entry) && !isCompressed(entry) && isLogFile(entry)) {
+            compress(entry);
+          }
+        }
+      } catch (IOException e) {
+        log.error("Error listing logs to compress in " + logs_dir, e);
+      }
+    } catch (Exception e) {
+      log.error("Failed to compress log files: " + e.getMessage(), e);
     }
   }
 
@@ -113,7 +118,8 @@
     return name.endsWith("_log")
         || name.endsWith(".log")
         || name.endsWith(".run")
-        || name.endsWith(".pid");
+        || name.endsWith(".pid")
+        || name.endsWith(".json");
   }
 
   private boolean isCompressed(Path entry) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
index 9ef31ff..bdd939f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/PerThreadReviewDbModule.java
@@ -22,15 +22,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
 /**
  * Module to bind a single {@link ReviewDb} instance per thread.
- * <p>
- * New instances are opened on demand, but are closed only at shutdown.
+ *
+ * <p>New instances are opened on demand, but are closed only at shutdown.
  */
 class PerThreadReviewDbModule extends LifecycleModule {
   private final SchemaFactory<ReviewDb> schema;
@@ -42,38 +41,41 @@
 
   @Override
   protected void configure() {
-    final List<ReviewDb> dbs = Collections.synchronizedList(
-        new ArrayList<ReviewDb>());
+    final List<ReviewDb> dbs = Collections.synchronizedList(new ArrayList<ReviewDb>());
     final ThreadLocal<ReviewDb> localDb = new ThreadLocal<>();
 
-    bind(ReviewDb.class).toProvider(new Provider<ReviewDb>() {
-      @Override
-      public ReviewDb get() {
-        ReviewDb db = localDb.get();
-        if (db == null) {
-          try {
-            db = schema.open();
-            dbs.add(db);
-            localDb.set(db);
-          } catch (OrmException e) {
-            throw new ProvisionException("unable to open ReviewDb", e);
-          }
-        }
-        return db;
-      }
-    });
-    listener().toInstance(new LifecycleListener() {
-      @Override
-      public void start() {
-        // Do nothing.
-      }
+    bind(ReviewDb.class)
+        .toProvider(
+            new Provider<ReviewDb>() {
+              @Override
+              public ReviewDb get() {
+                ReviewDb db = localDb.get();
+                if (db == null) {
+                  try {
+                    db = schema.open();
+                    dbs.add(db);
+                    localDb.set(db);
+                  } catch (OrmException e) {
+                    throw new ProvisionException("unable to open ReviewDb", e);
+                  }
+                }
+                return db;
+              }
+            });
+    listener()
+        .toInstance(
+            new LifecycleListener() {
+              @Override
+              public void start() {
+                // Do nothing.
+              }
 
-      @Override
-      public void stop() {
-        for (ReviewDb db : dbs) {
-          db.close();
-        }
-      }
-    });
+              @Override
+              public void stop() {
+                for (ReviewDb db : dbs) {
+                  db.close();
+                }
+              }
+            });
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ProxyUtil.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ProxyUtil.java
index c2a52ec..3eb8187 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ProxyUtil.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ProxyUtil.java
@@ -37,34 +37,31 @@
 
 package com.google.gerrit.pgm.util;
 
-import org.eclipse.jgit.util.CachedAuthenticator;
-
+import com.google.common.base.Strings;
 import java.net.MalformedURLException;
 import java.net.URL;
+import org.eclipse.jgit.util.CachedAuthenticator;
 
 final class ProxyUtil {
   /**
    * Configure the JRE's standard HTTP based on {@code http_proxy}.
-   * <p>
-   * The popular libcurl library honors the {@code http_proxy} environment
-   * variable as a means of specifying an HTTP proxy for requests made behind a
-   * firewall. This is not natively recognized by the JRE, so this method can be
-   * used by command line utilities to configure the JRE before the first
-   * request is sent.
    *
-   * @throws MalformedURLException the value in {@code http_proxy} is
-   *         unsupportable.
+   * <p>The popular libcurl library honors the {@code http_proxy} environment variable as a means of
+   * specifying an HTTP proxy for requests made behind a firewall. This is not natively recognized
+   * by the JRE, so this method can be used by command line utilities to configure the JRE before
+   * the first request is sent.
+   *
+   * @throws MalformedURLException the value in {@code http_proxy} is unsupportable.
    */
   static void configureHttpProxy() throws MalformedURLException {
     final String s = System.getenv("http_proxy");
-    if (s == null || s.equals("")) {
+    if (Strings.isNullOrEmpty(s)) {
       return;
     }
 
     final URL u = new URL((!s.contains("://")) ? "http://" + s : s);
     if (!"http".equals(u.getProtocol())) {
-      throw new MalformedURLException("Invalid http_proxy: " + s
-          + ": Only http supported.");
+      throw new MalformedURLException("Invalid http_proxy: " + s + ": Only http supported.");
     }
 
     final String proxyHost = u.getHost();
@@ -80,11 +77,10 @@
       final int c = userpass.indexOf(':');
       final String user = userpass.substring(0, c);
       final String pass = userpass.substring(c + 1);
-      CachedAuthenticator.add(new CachedAuthenticator.CachedAuthentication(
-          proxyHost, proxyPort, user, pass));
+      CachedAuthenticator.add(
+          new CachedAuthenticator.CachedAuthentication(proxyHost, proxyPort, user, pass));
     }
   }
 
-  ProxyUtil() {
-  }
+  ProxyUtil() {}
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
index dc3a915..7eed2ef 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.pgm.util;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class RuntimeShutdown {
   private static final ShutdownCallback cb = new ShutdownCallback();
@@ -39,12 +38,14 @@
     cb.waitForShutdown();
   }
 
-  private RuntimeShutdown() {
+  public static void manualShutdown() {
+    cb.manualShutdown();
   }
 
+  private RuntimeShutdown() {}
+
   private static class ShutdownCallback extends Thread {
-    private static final Logger log =
-        LoggerFactory.getLogger(ShutdownCallback.class);
+    private static final Logger log = LoggerFactory.getLogger(ShutdownCallback.class);
 
     private final List<Runnable> tasks = new ArrayList<>();
     private boolean shutdownStarted;
@@ -62,7 +63,6 @@
           }
           tasks.add(newTask);
           return true;
-
         }
         // We don't permit adding a task once shutdown has started.
         //
@@ -96,6 +96,11 @@
       }
     }
 
+    void manualShutdown() {
+      Runtime.getRuntime().removeShutdownHook(this);
+      run();
+    }
+
     void waitForShutdown() {
       synchronized (this) {
         while (!shutdownComplete) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
index 6443e21..1c7dc52 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteLibraryBasedDataSourceProvider.java
@@ -23,12 +23,9 @@
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.nio.file.Path;
-
 import javax.sql.DataSource;
+import org.eclipse.jgit.lib.Config;
 
 /** Loads the site library if not yet loaded. */
 @Singleton
@@ -37,7 +34,8 @@
   private boolean init;
 
   @Inject
-  SiteLibraryBasedDataSourceProvider(SitePaths site,
+  SiteLibraryBasedDataSourceProvider(
+      SitePaths site,
       @GerritServerConfig Config cfg,
       MetricMaker metrics,
       ThreadSettingsConfig tsc,
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
index 9e2da5c..ebb04ac 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -50,10 +50,6 @@
 import com.google.inject.name.Names;
 import com.google.inject.spi.Message;
 import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-
 import java.lang.annotation.Annotation;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -62,11 +58,17 @@
 import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.List;
-
 import javax.sql.DataSource;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
 
 public abstract class SiteProgram extends AbstractProgram {
-  @Option(name = "--site-path", aliases = {"-d"}, usage = "Local directory containing site data")
+  private static final String CONNECTION_ERROR = "Cannot connect to SQL database";
+
+  @Option(
+      name = "--site-path",
+      aliases = {"-d"},
+      usage = "Local directory containing site data")
   private void setSitePath(String path) {
     sitePath = Paths.get(path);
   }
@@ -75,7 +77,10 @@
 
   private Path sitePath = Paths.get(".");
 
-  protected SiteProgram() {
+  protected SiteProgram() {}
+
+  protected SiteProgram(Path sitePath) {
+    this.sitePath = sitePath;
   }
 
   protected SiteProgram(Path sitePath, final Provider<DataSource> dsProvider) {
@@ -91,8 +96,7 @@
   /** Ensures we are running inside of a valid site, otherwise throws a Die. */
   protected void mustHaveValidSite() throws Die {
     if (!Files.exists(sitePath.resolve("etc").resolve("gerrit.config"))) {
-      throw die("not a Gerrit site: '" + getSitePath() + "'\n"
-          + "Perhaps you need to run init first?");
+      throw die("not a Gerrit site: '" + getSitePath() + "'\nPerhaps you need to run init first?");
     }
   }
 
@@ -102,51 +106,54 @@
   }
 
   /** @return provides database connectivity and site path. */
-  protected Injector createDbInjector(final boolean enableMetrics,
-      final DataSourceProvider.Context context) {
-    final Path sitePath = getSitePath();
+  protected Injector createDbInjector(
+      final boolean enableMetrics, final DataSourceProvider.Context context) {
     final List<Module> modules = new ArrayList<>();
 
-    Module sitePathModule = new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-        bind(String.class).annotatedWith(SecureStoreClassName.class)
-            .toProvider(Providers.of(getConfiguredSecureStoreClass()));
-      }
-    };
+    Module sitePathModule =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
+            bind(String.class)
+                .annotatedWith(SecureStoreClassName.class)
+                .toProvider(Providers.of(getConfiguredSecureStoreClass()));
+          }
+        };
     modules.add(sitePathModule);
 
     if (enableMetrics) {
       modules.add(new DropWizardMetricMaker.ApiModule());
     } else {
-      modules.add(new AbstractModule() {
-        @Override
-        protected void configure() {
-          bind(MetricMaker.class).to(DisabledMetricMaker.class);
-        }
-      });
+      modules.add(
+          new AbstractModule() {
+            @Override
+            protected void configure() {
+              bind(MetricMaker.class).to(DisabledMetricMaker.class);
+            }
+          });
     }
 
-    modules.add(new LifecycleModule() {
-      @Override
-      protected void configure() {
-        bind(DataSourceProvider.Context.class).toInstance(context);
-        if (dsProvider != null) {
-          bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-            .toProvider(dsProvider)
-            .in(SINGLETON);
-          if (LifecycleListener.class.isAssignableFrom(dsProvider.getClass())) {
-            listener().toInstance((LifecycleListener) dsProvider);
+    modules.add(
+        new LifecycleModule() {
+          @Override
+          protected void configure() {
+            bind(DataSourceProvider.Context.class).toInstance(context);
+            if (dsProvider != null) {
+              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
+                  .toProvider(dsProvider)
+                  .in(SINGLETON);
+              if (LifecycleListener.class.isAssignableFrom(dsProvider.getClass())) {
+                listener().toInstance((LifecycleListener) dsProvider);
+              }
+            } else {
+              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
+                  .toProvider(SiteLibraryBasedDataSourceProvider.class)
+                  .in(SINGLETON);
+              listener().to(SiteLibraryBasedDataSourceProvider.class);
+            }
           }
-        } else {
-          bind(Key.get(DataSource.class, Names.named("ReviewDb")))
-            .toProvider(SiteLibraryBasedDataSourceProvider.class)
-            .in(SINGLETON);
-          listener().to(SiteLibraryBasedDataSourceProvider.class);
-        }
-      }
-    });
+        });
     Module configModule = new GerritServerConfigModule();
     modules.add(configModule);
     Injector cfgInjector = Guice.createInjector(sitePathModule, configModule);
@@ -162,16 +169,17 @@
       throw new ProvisionException("database.type must be defined");
     }
 
-    final DataSourceType dst = Guice.createInjector(new DataSourceModule(), configModule,
-            sitePathModule).getInstance(
-            Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
+    final DataSourceType dst =
+        Guice.createInjector(new DataSourceModule(), configModule, sitePathModule)
+            .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
 
-    modules.add(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(DataSourceType.class).toInstance(dst);
-      }
-    });
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(DataSourceType.class).toInstance(dst);
+          }
+        });
     modules.add(new DatabaseModule());
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
@@ -184,15 +192,16 @@
       Throwable why = first.getCause();
 
       if (why instanceof SQLException) {
-        throw die("Cannot connect to SQL database", why);
+        throw die(CONNECTION_ERROR, why);
       }
-      if (why instanceof OrmException && why.getCause() != null
+      if (why instanceof OrmException
+          && why.getCause() != null
           && "Unable to determine driver URL".equals(why.getMessage())) {
         why = why.getCause();
         if (isCannotCreatePoolException(why)) {
-          throw die("Cannot connect to SQL database", why.getCause());
+          throw die(CONNECTION_ERROR, why.getCause());
         }
-        throw die("Cannot connect to SQL database", why);
+        throw die(CONNECTION_ERROR, why);
       }
 
       final StringBuilder buf = new StringBuilder();
@@ -224,12 +233,13 @@
     }
 
     List<Module> modules = new ArrayList<>();
-    modules.add(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
-      }
-    });
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(getSitePath());
+          }
+        });
     modules.add(new GerritServerConfigModule());
     modules.add(new DataSourceModule());
     Injector i = Guice.createInjector(modules);
@@ -238,21 +248,21 @@
     for (Binding<DataSourceType> binding : dsTypeBindings) {
       Annotation annotation = binding.getKey().getAnnotation();
       if (annotation instanceof Named) {
-        if (((Named) annotation).value().toLowerCase().contains(dbProductName)) {
-          return ((Named) annotation).value();
+        Named named = (Named) annotation;
+        if (named.value().toLowerCase().contains(dbProductName)) {
+          return named.value();
         }
       }
     }
-    throw new IllegalStateException(String.format(
-        "Cannot guess database type from the database product name '%s'",
-        dbProductName));
+    throw new IllegalStateException(
+        String.format(
+            "Cannot guess database type from the database product name '%s'", dbProductName));
   }
 
   @SuppressWarnings("deprecation")
   private static boolean isCannotCreatePoolException(Throwable why) {
     return why instanceof org.apache.commons.dbcp.SQLNestedException
         && why.getCause() != null
-        && why.getMessage().startsWith(
-            "Cannot create PoolableConnectionFactory");
+        && why.getMessage().startsWith("Cannot create PoolableConnectionFactory");
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
index 7b0e4da..d609c34 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ThreadLimiter.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.inject.Injector;
 import com.google.inject.Key;
-
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -27,8 +26,7 @@
 // TODO(dborowitz): Not necessary once we switch to NoteDb.
 /** Utility to limit threads used by a batch program. */
 public class ThreadLimiter {
-  private static final Logger log =
-      LoggerFactory.getLogger(ThreadLimiter.class);
+  private static final Logger log = LoggerFactory.getLogger(ThreadLimiter.class);
 
   public static int limitThreads(Injector dbInjector, int threads) {
     return limitThreads(
@@ -38,19 +36,16 @@
         threads);
   }
 
-  private static int limitThreads(Config cfg, DataSourceType dst,
-      ThreadSettingsConfig threadSettingsConfig, int threads) {
-    boolean usePool = cfg.getBoolean("database", "connectionpool",
-        dst.usePool());
+  private static int limitThreads(
+      Config cfg, DataSourceType dst, ThreadSettingsConfig threadSettingsConfig, int threads) {
+    boolean usePool = cfg.getBoolean("database", "connectionpool", dst.usePool());
     int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
     if (usePool && threads > poolLimit) {
-      log.warn("Limiting program to " + poolLimit
-          + " threads due to database.poolLimit");
+      log.warn("Limiting program to " + poolLimit + " threads due to database.poolLimit");
       return poolLimit;
     }
     return threads;
   }
 
-  private ThreadLimiter() {
-  }
+  private ThreadLimiter() {}
 }
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.service b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.service
similarity index 100%
rename from gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.service
rename to gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.service
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
index 5952880..a76d0186 100755
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -259,9 +259,9 @@
 fi
 
 if test -z "$JAVA" ; then
-  echo >&2 "Cannot find a JRE or JDK. Please set JAVA_HOME or"
-  echo >&2 "container.javaHome in $GERRIT_SITE/etc/gerrit.config"
-  echo >&2 "to a >=1.7 JRE"
+  echo >&2 "Cannot find a JRE or JDK. Please ensure that the JAVA_HOME environment"
+  echo >&2 "variable or container.javaHome in $GERRIT_SITE/etc/gerrit.config is"
+  echo >&2 "set to a valid >=1.7 JRE location"
   exit 1
 fi
 
@@ -285,7 +285,11 @@
 
 GERRIT_FDS=`get_config --int core.packedGitOpenFiles`
 test -z "$GERRIT_FDS" && GERRIT_FDS=128
-GERRIT_FDS=`expr $GERRIT_FDS + $GERRIT_FDS`
+FDS_MULTIPLIER=2
+USE_LFS=`get_config --get lfs.plugin`
+test -n "$USE_LFS" && FDS_MULTIPLIER=3
+
+GERRIT_FDS=`expr $FDS_MULTIPLIER \* $GERRIT_FDS`
 test $GERRIT_FDS -lt 1024 && GERRIT_FDS=1024
 
 GERRIT_STARTUP_TIMEOUT=`get_config --get container.startupTimeout`
@@ -435,6 +439,11 @@
                 echo -16 > "/proc/${PID}/oom_adj"
             fi
         fi
+    elif [ "$(uname -s)"=="Linux" ] && test -d "/proc/${PID}"; then
+        echo "WARNING: Could not adjust Gerrit's process for the kernel's out-of-memory killer."
+        echo "         This may be caused by ${0} not being run as root."
+        echo "         Consider changing the OOM score adjustment manually for Gerrit's PID=${PID} with e.g.:"
+        echo "         echo '-1000' | sudo tee /proc/${PID}/oom_score_adj"
     fi
 
     TIMEOUT="$GERRIT_STARTUP_TIMEOUT"
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.socket b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.socket
similarity index 100%
rename from gerrit-pgm/src/main/resources/com/google/gerrit/pgm/systemd/gerrit.socket
rename to gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.socket
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
index 4d9d0f0..26ac9d6 100644
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
@@ -12,36 +12,18 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-
-# Version should match lib/bouncycastle/BUCK
-[library "bouncyCastleProvider"]
-  name = Bouncy Castle Crypto Provider v152
-  url = https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.52/bcprov-jdk15on-1.52.jar
-  sha1 = 88a941faf9819d371e3174b5ed56a3f3f7d73269
-  remove = bcprov-.*[.]jar
-
-# Version should match lib/bouncycastle/BUCK
-[library "bouncyCastleSSL"]
-  name = Bouncy Castle Crypto SSL v152
-  url = https://repo1.maven.org/maven2/org/bouncycastle/bcpkix-jdk15on/1.52/bcpkix-jdk15on-1.52.jar
-  sha1 = b8ffac2bbc6626f86909589c8cc63637cc936504
-  needs = bouncyCastleProvider
-  remove = bcpkix-.*[.]jar
-
-# Version should match lib/bouncycastle/BUCK
-[library "bouncyCastlePGP"]
-  name = Bouncy Castle Crypto OpenPGP v152
-  url = https://repo1.maven.org/maven2/org/bouncycastle/bcpg-jdk15on/1.52/bcpg-jdk15on-1.52.jar
-  sha1 = ff4665a4b5633ff6894209d5dd10b7e612291858
-  needs = bouncyCastleProvider
-  remove = bcpg-.*[.]jar
-
 [library "mysqlDriver"]
-  name = MySQL Connector/J 5.1.21
-  url = https://repo1.maven.org/maven2/mysql/mysql-connector-java/5.1.21/mysql-connector-java-5.1.21.jar
-  sha1 = 7abbd19fc2e2d5b92c0895af8520f7fa30266be9
+  name = MySQL Connector/J 5.1.41
+  url = https://repo1.maven.org/maven2/mysql/mysql-connector-java/5.1.41/mysql-connector-java-5.1.41.jar
+  sha1 = b0878056f15616989144d6114d36d3942321d0d1
   remove = mysql-connector-java-.*[.]jar
 
+[library "mariadbDriver"]
+  name = MariaDB Connector/J 1.5.9
+  url = https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/1.5.9/mariadb-java-client-1.5.9.jar
+  sha1 = 75d4d6e4cdb9a551a102e92a14c640768174e214
+  remove = mariadb-java-client-.*[.]jar
+
 [library "oracleDriver"]
   name = Oracle JDBC driver 11g Release 2 (11.2.0)
   url = file:///u01/app/oracle/product/11.2.0/xe/jdbc/lib/ojdbc6.jar
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java
index 959ba69..35c0937 100644
--- a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java
+++ b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/InitTestCase.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.pgm.init;
 
-import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
-import org.junit.Ignore;
-
 import java.io.IOException;
 import java.nio.file.Path;
+import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
+import org.junit.Ignore;
 
 @Ignore
 public abstract class InitTestCase extends LocalDiskRepositoryTestCase {
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java
index 48754f1..af21ad0 100644
--- a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java
+++ b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/LibrariesTest.java
@@ -22,28 +22,30 @@
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Provider;
-
-import org.junit.Test;
-
 import java.nio.file.Paths;
 import java.util.Collections;
+import org.junit.Test;
 
 public class LibrariesTest {
   @Test
-  public void testCreate() throws Exception {
+  public void create() throws Exception {
     final SitePaths site = new SitePaths(Paths.get("."));
     final ConsoleUI ui = createStrictMock(ConsoleUI.class);
+    final StaleLibraryRemover remover = createStrictMock(StaleLibraryRemover.class);
 
     replay(ui);
 
-    Libraries lib = new Libraries(new Provider<LibraryDownloader>() {
-      @Override
-      public LibraryDownloader get() {
-        return new LibraryDownloader(ui, site);
-      }
-    }, Collections.<String> emptyList(), false);
+    Libraries lib =
+        new Libraries(
+            new Provider<LibraryDownloader>() {
+              @Override
+              public LibraryDownloader get() {
+                return new LibraryDownloader(ui, site, remover);
+              }
+            },
+            Collections.<String>emptyList(),
+            false);
 
-    assertNotNull(lib.bouncyCastleProvider);
     assertNotNull(lib.mysqlDriver);
 
     verify(ui);
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
index 89f61cc..7721fca 100644
--- a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
+++ b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_xTest.java
@@ -32,25 +32,22 @@
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.securestore.SecureStore;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-import org.junit.Test;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Collections;
 import java.util.List;
-
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Test;
 
 public class UpgradeFrom2_0_xTest extends InitTestCase {
 
   @Test
-  public void testUpgrade() throws IOException, ConfigInvalidException {
+  public void upgrade() throws IOException, ConfigInvalidException {
     final Path p = newSitePath();
     final SitePaths site = new SitePaths(p);
     assertTrue(site.isNew);
@@ -60,8 +57,7 @@
       Files.write(p.resolve(n), ("# " + n + "\n").getBytes(UTF_8));
     }
 
-    FileBasedConfig old =
-        new FileBasedConfig(p.resolve("gerrit.config").toFile(), FS.DETECTED);
+    FileBasedConfig old = new FileBasedConfig(p.resolve("gerrit.config").toFile(), FS.DETECTED);
 
     old.setString("ldap", null, "username", "ldap.user");
     old.setString("ldap", null, "password", "ldap.s3kr3t");
@@ -71,21 +67,19 @@
     old.save();
 
     final InMemorySecureStore secureStore = new InMemorySecureStore();
-    final InitFlags flags = new InitFlags(site, secureStore,
-        Collections.<String> emptyList(), false);
+    final InitFlags flags =
+        new InitFlags(site, secureStore, Collections.<String>emptyList(), false);
     final ConsoleUI ui = createStrictMock(ConsoleUI.class);
-    Section.Factory sections = new Section.Factory() {
-      @Override
-      public Section get(String name, String subsection) {
-        return new Section(flags, site, secureStore, ui, name, subsection);
-      }
-    };
+    Section.Factory sections =
+        new Section.Factory() {
+          @Override
+          public Section get(String name, String subsection) {
+            return new Section(flags, site, secureStore, ui, name, subsection);
+          }
+        };
 
-    expect(ui.yesno(
-        eq(true),
-        eq("Upgrade '%s'"),
-        eq(p.toAbsolutePath().normalize())))
-      .andReturn(true);
+    expect(ui.yesno(eq(true), eq("Upgrade '%s'"), eq(p.toAbsolutePath().normalize())))
+        .andReturn(true);
     replay(ui);
 
     UpgradeFrom2_0_x u = new UpgradeFrom2_0_x(site, flags, ui, sections);
@@ -99,13 +93,11 @@
         continue;
       }
       try (InputStream in = Files.newInputStream(site.etc_dir.resolve(n))) {
-        assertEquals("# " + n + "\n",
-            new String(ByteStreams.toByteArray(in), UTF_8));
+        assertEquals("# " + n + "\n", new String(ByteStreams.toByteArray(in), UTF_8));
       }
     }
 
-    FileBasedConfig cfg =
-        new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
+    FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
     cfg.load();
 
     assertEquals("email.user", cfg.getString("sendemail", null, "smtpUser"));
@@ -128,14 +120,13 @@
     }
 
     @Override
-    public String[] getListForPlugin(String pluginName, String section,
-        String subsection, String name) {
+    public String[] getListForPlugin(
+        String pluginName, String section, String subsection, String name) {
       throw new UnsupportedOperationException("not used by tests");
     }
 
     @Override
-    public void setList(String section, String subsection, String name,
-        List<String> values) {
+    public void setList(String section, String subsection, String name, List<String> values) {
       cfg.setStringList(section, subsection, name, values);
     }
 
@@ -148,5 +139,15 @@
     public Iterable<EntryKey> list() {
       throw new UnsupportedOperationException("not used by tests");
     }
+
+    @Override
+    public boolean isOutdated() {
+      throw new UnsupportedOperationException("not used by tests");
+    }
+
+    @Override
+    public void reload() {
+      throw new UnsupportedOperationException("not used by tests");
+    }
   }
 }
diff --git a/gerrit-plugin-api/BUCK b/gerrit-plugin-api/BUCK
deleted file mode 100644
index 8cbf1a1..0000000
--- a/gerrit-plugin-api/BUCK
+++ /dev/null
@@ -1,77 +0,0 @@
-SRCS = [
-  'gerrit-server/src/main/java/',
-  'gerrit-httpd/src/main/java/',
-  'gerrit-sshd/src/main/java/',
-]
-
-PLUGIN_API = [
-  '//gerrit-httpd:httpd',
-  '//gerrit-pgm:init-api',
-  '//gerrit-server:server',
-  '//gerrit-sshd:sshd',
-]
-
-java_binary(
-  name = 'plugin-api',
-  deps = [':lib'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'lib',
-  exported_deps = PLUGIN_API + [
-    '//gerrit-antlr:query_exception',
-    '//gerrit-antlr:query_parser',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gwtexpui:server',
-    '//gerrit-reviewdb:server',
-    '//lib:args4j',
-    '//lib:blame-cache',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib:mime-util',
-    '//lib:servlet-api-3_1',
-    '//lib:velocity',
-    '//lib/commons:lang',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-    '//lib/mina:sshd',
-    '//lib/prolog:compiler',
-    '//lib/prolog:runtime',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_binary(
-  name = 'plugin-api-src',
-  deps = [
-    '//gerrit-extension-api:extension-api-src',
-  ] + [d + '-src' for d in PLUGIN_API],
-  visibility = ['PUBLIC'],
-)
-
-java_doc(
-  name = 'plugin-api-javadoc',
-  title = 'Gerrit Review Plugin API Documentation',
-  pkgs = ['com.google.gerrit'],
-  paths = [n for n in SRCS],
-  srcs = glob([n + '**/*.java' for n in SRCS]),
-  deps = [
-    ':plugin-api',
-    '//lib/bouncycastle:bcprov',
-    '//lib/bouncycastle:bcpg',
-    '//lib/bouncycastle:bcpkix',
-  ],
-  visibility = ['PUBLIC'],
-  do_it_wrong = True,
-)
diff --git a/gerrit-plugin-api/BUILD b/gerrit-plugin-api/BUILD
index 2c18ca6..70136919 100644
--- a/gerrit-plugin-api/BUILD
+++ b/gerrit-plugin-api/BUILD
@@ -1,51 +1,105 @@
-SRCS = [
-  'gerrit-server/src/main/java/',
-  'gerrit-httpd/src/main/java/',
-  'gerrit-sshd/src/main/java/',
-]
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+load("//tools/bzl:javadoc.bzl", "java_doc")
 
 PLUGIN_API = [
-  '//gerrit-httpd:httpd',
-  '//gerrit-pgm:init-api',
-  '//gerrit-server:server',
-  '//gerrit-sshd:sshd',
+    "//gerrit-httpd:httpd",
+    "//gerrit-pgm:init-api",
+    "//gerrit-server:server",
+    "//gerrit-sshd:sshd",
+]
+
+EXPORTS = [
+    "//gerrit-antlr:query_exception",
+    "//gerrit-antlr:query_parser",
+    "//gerrit-common:annotations",
+    "//gerrit-common:server",
+    "//gerrit-extension-api:api",
+    "//gerrit-gwtexpui:server",
+    "//gerrit-reviewdb:server",
+    "//gerrit-server/src/main/prolog:common",
+    "//lib/commons:dbcp",
+    "//lib/commons:lang",
+    "//lib/dropwizard:dropwizard-core",
+    "//lib/guice:guice",
+    "//lib/guice:guice-assistedinject",
+    "//lib/guice:guice-servlet",
+    "//lib/guice:javax-inject",
+    "//lib/httpcomponents:httpclient",
+    "//lib/httpcomponents:httpcore",
+    "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib/joda:joda-time",
+    "//lib/log:api",
+    "//lib/log:log4j",
+    "//lib/mina:sshd",
+    "//lib/ow2:ow2-asm",
+    "//lib/ow2:ow2-asm-analysis",
+    "//lib/ow2:ow2-asm-commons",
+    "//lib/ow2:ow2-asm-util",
+    "//lib:args4j",
+    "//lib:blame-cache",
+    "//lib:guava",
+    "//lib:gson",
+    "//lib:gwtorm",
+    "//lib:icu4j",
+    "//lib:jsch",
+    "//lib:mime-util",
+    "//lib:protobuf",
+    "//lib:servlet-api-3_1-without-neverlink",
+    "//lib:soy",
+    "//lib:velocity",
 ]
 
 java_binary(
-  name = 'plugin-api',
-  main_class = 'Dummy',
-  runtime_deps = [':lib'],
-  visibility = ['//visibility:public'],
+    name = "plugin-api",
+    main_class = "Dummy",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":lib"],
 )
 
 java_library(
-  name = 'lib',
-  exports = PLUGIN_API + [
-    '//gerrit-antlr:query_exception',
-    '//gerrit-antlr:query_parser',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-gwtexpui:server',
-    '//gerrit-reviewdb:server',
-    '//lib:args4j',
-    '//lib:blame-cache',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib:mime-util',
-    '//lib:servlet-api-3_1',
-    '//lib:velocity',
-    '//lib/commons:lang',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-    '//lib/mina:sshd',
-  ],
-  visibility = ['//visibility:public'],
+    name = "lib",
+    visibility = ["//visibility:public"],
+    exports = PLUGIN_API + EXPORTS,
+)
+
+java_library(
+    name = "lib-neverlink",
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = PLUGIN_API + EXPORTS,
+)
+
+java_binary(
+    name = "plugin-api-sources",
+    main_class = "Dummy",
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//gerrit-antlr:libquery_exception-src.jar",
+        "//gerrit-antlr:libquery_parser-src.jar",
+        "//gerrit-common:libannotations-src.jar",
+        "//gerrit-extension-api:libapi-src.jar",
+        "//gerrit-gwtexpui:libserver-src.jar",
+        "//gerrit-httpd:libhttpd-src.jar",
+        "//gerrit-pgm:libinit-api-src.jar",
+        "//gerrit-reviewdb:libserver-src.jar",
+        "//gerrit-server:libserver-src.jar",
+        "//gerrit-sshd:libsshd-src.jar",
+    ],
+)
+
+java_doc(
+    name = "plugin-api-javadoc",
+    libs = PLUGIN_API + [
+        "//gerrit-antlr:query_exception",
+        "//gerrit-antlr:query_parser",
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-gwtexpui:server",
+        "//gerrit-reviewdb:server",
+    ],
+    pkgs = ["com.google.gerrit"],
+    title = "Gerrit Review Plugin API Documentation",
+    visibility = ["//visibility:public"],
 )
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index 02ada4a..1abf2d8 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/gerrit-plugin-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.13.14</version>
+  <version>2.14.23-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -23,7 +23,10 @@
 
   <developers>
     <developer>
-      <name>Andrew Bonventre</name>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -38,16 +41,28 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
-      <name>Saša Živkov</name>
+      <name>Ole Rehmsen</name>
     </developer>
     <developer>
-      <name>Shawn Pearce</name>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
     </developer>
   </developers>
 
diff --git a/gerrit-plugin-archetype/.gitignore b/gerrit-plugin-archetype/.gitignore
deleted file mode 100644
index 7075a2f..0000000
--- a/gerrit-plugin-archetype/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-/target
-/.classpath
-/.project
-/.settings
diff --git a/gerrit-plugin-archetype/pom.xml b/gerrit-plugin-archetype/pom.xml
deleted file mode 100644
index 59aa21a..0000000
--- a/gerrit-plugin-archetype/pom.xml
+++ /dev/null
@@ -1,108 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 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.
--->
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
-  <modelVersion>4.0.0</modelVersion>
-
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-plugin-archetype</artifactId>
-  <version>2.13.14</version>
-  <name>Gerrit Code Review - Plugin Archetype</name>
-  <description>Maven Archetype for Gerrit Plugins</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <properties>
-    <defaultGerritApiVersion>${project.version}</defaultGerritApiVersion>
-  </properties>
-
-  <build>
-    <resources>
-      <resource>
-        <directory>src/main/resources</directory>
-        <filtering>true</filtering>
-        <includes>
-          <include>META-INF/maven/archetype-metadata.xml</include>
-        </includes>
-      </resource>
-      <resource>
-        <directory>src/main/resources</directory>
-        <filtering>false</filtering>
-        <excludes>
-          <exclude>META-INF/maven/archetype-metadata.xml</exclude>
-        </excludes>
-      </resource>
-    </resources>
-  </build>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
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
deleted file mode 100644
index e32a0d6..0000000
--- a/gerrit-plugin-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ /dev/null
@@ -1,78 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 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.
--->
-<archetype-descriptor name="Gerrit Plugin">
-  <requiredProperties>
-    <requiredProperty key="pluginName"/>
-
-    <requiredProperty key="Gerrit-Module">
-      <defaultValue>Y</defaultValue>
-    </requiredProperty>
-    <requiredProperty key="Gerrit-SshModule">
-      <defaultValue>Y</defaultValue>
-    </requiredProperty>
-    <requiredProperty key="Gerrit-HttpModule">
-      <defaultValue>Y</defaultValue>
-    </requiredProperty>
-
-    <requiredProperty key="Implementation-Vendor">
-      <defaultValue>Gerrit Code Review</defaultValue>
-    </requiredProperty>
-
-    <requiredProperty key="gerritApiType">
-      <defaultValue>plugin</defaultValue>
-    </requiredProperty>
-    <requiredProperty key="gerritApiVersion">
-      <defaultValue>${defaultGerritApiVersion}</defaultValue>
-    </requiredProperty>
-  </requiredProperties>
-
-  <fileSets>
-    <fileSet filtered="true" packaged="true">
-      <directory>src/main/java</directory>
-      <includes>
-        <include>**/*.java</include>
-      </includes>
-    </fileSet>
-
-    <fileSet filtered="true">
-      <directory>src/main/resources/Documentation</directory>
-      <includes>
-        <include>**/*.md</include>
-      </includes>
-    </fileSet>
-
-    <fileSet filtered="true">
-      <directory></directory>
-      <include>.buckconfig</include>
-      <include>BUCK</include>
-      <include>VERSION</include>
-      <include>lib/gerrit/BUCK</include>
-      <excludes>
-        <exclude>**/*.java</exclude>
-      </excludes>
-    </fileSet>
-
-    <fileSet>
-      <directory></directory>
-      <includes>
-        <include>.gitignore</include>
-        <include>.settings/*</include>
-        <include>LICENSE</include>
-      </includes>
-    </fileSet>
-  </fileSets>
-</archetype-descriptor>
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.buckconfig b/gerrit-plugin-archetype/src/main/resources/archetype-resources/.buckconfig
deleted file mode 100644
index 1044c12..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.buckconfig
+++ /dev/null
@@ -1,14 +0,0 @@
-[alias]
-  ${pluginName} = //:${pluginName}
-  plugin = //:${pluginName}
-
-[java]
-  src_roots = java, resources
-
-[project]
-  ignore = .git
-
-[cache]
-  mode = dir
-  dir = buck-out/cache
-
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.gitignore b/gerrit-plugin-archetype/src/main/resources/archetype-resources/.gitignore
deleted file mode 100644
index 43838b0..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.gitignore
+++ /dev/null
@@ -1,9 +0,0 @@
-/.buckversion
-/.buckd
-/buck-out
-/bucklets
-/target
-/.classpath
-/.project
-/.settings/org.maven.ide.eclipse.prefs
-/.settings/org.eclipse.m2e.core.prefs
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs b/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs
deleted file mode 100644
index 29abf99..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs
+++ /dev/null
@@ -1,6 +0,0 @@
-eclipse.preferences.version=1
-encoding//src/main/java=UTF-8
-encoding//src/main/resources=UTF-8
-encoding//src/test/java=UTF-8
-encoding//src/test/resources=UTF-8
-encoding/<project>=UTF-8
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs b/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs
deleted file mode 100644
index 5a0ad22..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs
+++ /dev/null
@@ -1,2 +0,0 @@
-eclipse.preferences.version=1
-line.separator=\n
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs b/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
deleted file mode 100644
index 2a585e4..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
+++ /dev/null
@@ -1,346 +0,0 @@
-eclipse.preferences.version=1
-org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
-org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull
-org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault
-org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable
-org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
-org.eclipse.jdt.core.compiler.compliance=1.7
-org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
-org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
-org.eclipse.jdt.core.compiler.problem.comparingIdentical=warning
-org.eclipse.jdt.core.compiler.problem.deadCode=warning
-org.eclipse.jdt.core.compiler.problem.deprecation=warning
-org.eclipse.jdt.core.compiler.problem.deprecationInDeprecatedCode=disabled
-org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled
-org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
-org.eclipse.jdt.core.compiler.problem.emptyStatement=ignore
-org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=ignore
-org.eclipse.jdt.core.compiler.problem.fallthroughCase=ignore
-org.eclipse.jdt.core.compiler.problem.fatalOptionalError=disabled
-org.eclipse.jdt.core.compiler.problem.fieldHiding=ignore
-org.eclipse.jdt.core.compiler.problem.finalParameterBound=warning
-org.eclipse.jdt.core.compiler.problem.finallyBlockNotCompletingNormally=warning
-org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
-org.eclipse.jdt.core.compiler.problem.hiddenCatchBlock=warning
-org.eclipse.jdt.core.compiler.problem.includeNullInfoFromAsserts=disabled
-org.eclipse.jdt.core.compiler.problem.incompatibleNonInheritedInterfaceMethod=warning
-org.eclipse.jdt.core.compiler.problem.incompleteEnumSwitch=warning
-org.eclipse.jdt.core.compiler.problem.indirectStaticAccess=ignore
-org.eclipse.jdt.core.compiler.problem.localVariableHiding=ignore
-org.eclipse.jdt.core.compiler.problem.methodWithConstructorName=warning
-org.eclipse.jdt.core.compiler.problem.missingDefaultCase=ignore
-org.eclipse.jdt.core.compiler.problem.missingDeprecatedAnnotation=ignore
-org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=disabled
-org.eclipse.jdt.core.compiler.problem.missingHashCodeMethod=ignore
-org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotation=ignore
-org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotationForInterfaceMethodImplementation=enabled
-org.eclipse.jdt.core.compiler.problem.missingSerialVersion=warning
-org.eclipse.jdt.core.compiler.problem.missingSynchronizedOnInheritedMethod=ignore
-org.eclipse.jdt.core.compiler.problem.noEffectAssignment=warning
-org.eclipse.jdt.core.compiler.problem.noImplicitStringConversion=warning
-org.eclipse.jdt.core.compiler.problem.nonExternalizedStringLiteral=ignore
-org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error
-org.eclipse.jdt.core.compiler.problem.nullReference=warning
-org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error
-org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning
-org.eclipse.jdt.core.compiler.problem.overridingPackageDefaultMethod=warning
-org.eclipse.jdt.core.compiler.problem.parameterAssignment=ignore
-org.eclipse.jdt.core.compiler.problem.possibleAccidentalBooleanAssignment=ignore
-org.eclipse.jdt.core.compiler.problem.potentialNullReference=ignore
-org.eclipse.jdt.core.compiler.problem.potentiallyUnclosedCloseable=ignore
-org.eclipse.jdt.core.compiler.problem.rawTypeReference=warning
-org.eclipse.jdt.core.compiler.problem.redundantNullAnnotation=warning
-org.eclipse.jdt.core.compiler.problem.redundantNullCheck=ignore
-org.eclipse.jdt.core.compiler.problem.redundantSpecificationOfTypeArguments=ignore
-org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=ignore
-org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore
-org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore
-org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled
-org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=warning
-org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled
-org.eclipse.jdt.core.compiler.problem.suppressWarnings=enabled
-org.eclipse.jdt.core.compiler.problem.syntheticAccessEmulation=ignore
-org.eclipse.jdt.core.compiler.problem.typeParameterHiding=warning
-org.eclipse.jdt.core.compiler.problem.unavoidableGenericTypeProblems=enabled
-org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
-org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
-org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
-org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
-org.eclipse.jdt.core.compiler.problem.unnecessaryElse=ignore
-org.eclipse.jdt.core.compiler.problem.unnecessaryTypeCheck=ignore
-org.eclipse.jdt.core.compiler.problem.unqualifiedFieldAccess=ignore
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownException=ignore
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionExemptExceptionAndThrowable=enabled
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionIncludeDocCommentReference=enabled
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionWhenOverriding=disabled
-org.eclipse.jdt.core.compiler.problem.unusedImport=warning
-org.eclipse.jdt.core.compiler.problem.unusedLabel=warning
-org.eclipse.jdt.core.compiler.problem.unusedLocal=warning
-org.eclipse.jdt.core.compiler.problem.unusedObjectAllocation=ignore
-org.eclipse.jdt.core.compiler.problem.unusedParameter=ignore
-org.eclipse.jdt.core.compiler.problem.unusedParameterIncludeDocCommentReference=enabled
-org.eclipse.jdt.core.compiler.problem.unusedParameterWhenImplementingAbstract=disabled
-org.eclipse.jdt.core.compiler.problem.unusedParameterWhenOverridingConcrete=disabled
-org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=warning
-org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
-org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
-org.eclipse.jdt.core.compiler.source=1.7
-org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_assignment=16
-org.eclipse.jdt.core.formatter.alignment_for_binary_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_compact_if=16
-org.eclipse.jdt.core.formatter.alignment_for_conditional_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_enum_constants=16
-org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer=16
-org.eclipse.jdt.core.formatter.alignment_for_multiple_fields=16
-org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation=16
-org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration=16
-org.eclipse.jdt.core.formatter.blank_lines_after_imports=1
-org.eclipse.jdt.core.formatter.blank_lines_after_package=1
-org.eclipse.jdt.core.formatter.blank_lines_before_field=0
-org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration=0
-org.eclipse.jdt.core.formatter.blank_lines_before_imports=0
-org.eclipse.jdt.core.formatter.blank_lines_before_member_type=0
-org.eclipse.jdt.core.formatter.blank_lines_before_method=1
-org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk=1
-org.eclipse.jdt.core.formatter.blank_lines_before_package=0
-org.eclipse.jdt.core.formatter.blank_lines_between_import_groups=1
-org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations=2
-org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_array_initializer=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_block=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_block_in_case=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_enum_constant=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_method_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_switch=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment=false
-org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment=false
-org.eclipse.jdt.core.formatter.comment.format_block_comments=true
-org.eclipse.jdt.core.formatter.comment.format_header=true
-org.eclipse.jdt.core.formatter.comment.format_html=true
-org.eclipse.jdt.core.formatter.comment.format_javadoc_comments=true
-org.eclipse.jdt.core.formatter.comment.format_line_comments=true
-org.eclipse.jdt.core.formatter.comment.format_source_code=true
-org.eclipse.jdt.core.formatter.comment.indent_parameter_description=false
-org.eclipse.jdt.core.formatter.comment.indent_root_tags=true
-org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags=insert
-org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter=do not insert
-org.eclipse.jdt.core.formatter.comment.line_length=80
-org.eclipse.jdt.core.formatter.compact_else_if=true
-org.eclipse.jdt.core.formatter.continuation_indentation=2
-org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer=2
-org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line=false
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header=true
-org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases=true
-org.eclipse.jdt.core.formatter.indent_empty_lines=false
-org.eclipse.jdt.core.formatter.indent_statements_compare_to_block=true
-org.eclipse.jdt.core.formatter.indent_statements_compare_to_body=true
-org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases=true
-org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch=true
-org.eclipse.jdt.core.formatter.indentation.size=4
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_member=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing=insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_annotation_declaration=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_declaration=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter=insert
-org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_binary_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_ellipsis=insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_after_unary_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter=insert
-org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_binary_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_ellipsis=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while=insert
-org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return=insert
-org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw=insert
-org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_unary_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.join_lines_in_comments=true
-org.eclipse.jdt.core.formatter.join_wrapped_lines=true
-org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line=false
-org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line=false
-org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line=true
-org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line=false
-org.eclipse.jdt.core.formatter.lineSplit=80
-org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column=false
-org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column=false
-org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body=0
-org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve=3
-org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line=false
-org.eclipse.jdt.core.formatter.tabulation.char=space
-org.eclipse.jdt.core.formatter.tabulation.size=2
-org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations=false
-org.eclipse.jdt.core.formatter.wrap_before_binary_operator=true
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs b/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs
deleted file mode 100644
index 7397758..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs
+++ /dev/null
@@ -1,60 +0,0 @@
-eclipse.preferences.version=1
-editor_save_participant_org.eclipse.jdt.ui.postsavelistener.cleanup=true
-formatter_profile=_Google Format
-formatter_settings_version=11
-org.eclipse.jdt.ui.ignorelowercasenames=true
-org.eclipse.jdt.ui.importorder=\#;com.google;com;dk;eu;junit;net;org;java;javax;
-org.eclipse.jdt.ui.ondemandthreshold=99
-org.eclipse.jdt.ui.staticondemandthreshold=99
-org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates/>
-sp_cleanup.add_default_serial_version_id=true
-sp_cleanup.add_generated_serial_version_id=false
-sp_cleanup.add_missing_annotations=false
-sp_cleanup.add_missing_deprecated_annotations=true
-sp_cleanup.add_missing_methods=false
-sp_cleanup.add_missing_nls_tags=false
-sp_cleanup.add_missing_override_annotations=true
-sp_cleanup.add_serial_version_id=false
-sp_cleanup.always_use_blocks=true
-sp_cleanup.always_use_parentheses_in_expressions=false
-sp_cleanup.always_use_this_for_non_static_field_access=false
-sp_cleanup.always_use_this_for_non_static_method_access=false
-sp_cleanup.convert_to_enhanced_for_loop=false
-sp_cleanup.correct_indentation=false
-sp_cleanup.format_source_code=false
-sp_cleanup.format_source_code_changes_only=false
-sp_cleanup.make_local_variable_final=true
-sp_cleanup.make_parameters_final=true
-sp_cleanup.make_private_fields_final=true
-sp_cleanup.make_type_abstract_if_missing_method=false
-sp_cleanup.make_variable_declarations_final=false
-sp_cleanup.never_use_blocks=false
-sp_cleanup.never_use_parentheses_in_expressions=true
-sp_cleanup.on_save_use_additional_actions=true
-sp_cleanup.organize_imports=false
-sp_cleanup.qualify_static_field_accesses_with_declaring_class=false
-sp_cleanup.qualify_static_member_accesses_through_instances_with_declaring_class=true
-sp_cleanup.qualify_static_member_accesses_through_subtypes_with_declaring_class=true
-sp_cleanup.qualify_static_member_accesses_with_declaring_class=false
-sp_cleanup.qualify_static_method_accesses_with_declaring_class=false
-sp_cleanup.remove_private_constructors=true
-sp_cleanup.remove_trailing_whitespaces=true
-sp_cleanup.remove_trailing_whitespaces_all=true
-sp_cleanup.remove_trailing_whitespaces_ignore_empty=false
-sp_cleanup.remove_unnecessary_casts=false
-sp_cleanup.remove_unnecessary_nls_tags=false
-sp_cleanup.remove_unused_imports=false
-sp_cleanup.remove_unused_local_variables=false
-sp_cleanup.remove_unused_private_fields=true
-sp_cleanup.remove_unused_private_members=false
-sp_cleanup.remove_unused_private_methods=true
-sp_cleanup.remove_unused_private_types=true
-sp_cleanup.sort_members=false
-sp_cleanup.sort_members_all=false
-sp_cleanup.use_blocks=false
-sp_cleanup.use_blocks_only_for_return_and_throw=false
-sp_cleanup.use_parentheses_in_expressions=false
-sp_cleanup.use_this_for_non_static_field_access=false
-sp_cleanup.use_this_for_non_static_field_access_only_if_necessary=true
-sp_cleanup.use_this_for_non_static_method_access=false
-sp_cleanup.use_this_for_non_static_method_access_only_if_necessary=true
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/BUCK b/gerrit-plugin-archetype/src/main/resources/archetype-resources/BUCK
deleted file mode 100644
index 55a2a4a..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/BUCK
+++ /dev/null
@@ -1,22 +0,0 @@
-include_defs('//bucklets/gerrit_plugin.bucklet')
-
-gerrit_plugin(
-  name = '${pluginName}',
-  srcs = glob(['src/main/java/**/*.java']),
-  resources = glob(['src/main/resources/**/*']),
-  manifest_entries = [
-    'Gerrit-PluginName: ${pluginName}',
-    'Gerrit-ApiType: ${gerritApiType}',
-    'Gerrit-ApiVersion: ${gerritApiVersion}',
-    'Gerrit-Module: ${package}.Module',
-    'Gerrit-SshModule: ${package}.SshModule',
-    'Gerrit-HttpModule: ${package}.HttpModule',
-  ],
-)
-
-# this is required for bucklets/tools/eclipse/project.py to work
-java_library(
-  name = 'classpath',
-  deps = [':${pluginName}__plugin'],
-)
-
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/LICENSE b/gerrit-plugin-archetype/src/main/resources/archetype-resources/LICENSE
deleted file mode 100644
index 11069ed..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/LICENSE
+++ /dev/null
@@ -1,201 +0,0 @@
-                              Apache License
-                        Version 2.0, January 2004
-                     http://www.apache.org/licenses/
-
-TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-1. Definitions.
-
-   "License" shall mean the terms and conditions for use, reproduction,
-   and distribution as defined by Sections 1 through 9 of this document.
-
-   "Licensor" shall mean the copyright owner or entity authorized by
-   the copyright owner that is granting the License.
-
-   "Legal Entity" shall mean the union of the acting entity and all
-   other entities that control, are controlled by, or are under common
-   control with that entity. For the purposes of this definition,
-   "control" means (i) the power, direct or indirect, to cause the
-   direction or management of such entity, whether by contract or
-   otherwise, or (ii) ownership of fifty percent (50%) or more of the
-   outstanding shares, or (iii) beneficial ownership of such entity.
-
-   "You" (or "Your") shall mean an individual or Legal Entity
-   exercising permissions granted by this License.
-
-   "Source" form shall mean the preferred form for making modifications,
-   including but not limited to software source code, documentation
-   source, and configuration files.
-
-   "Object" form shall mean any form resulting from mechanical
-   transformation or translation of a Source form, including but
-   not limited to compiled object code, generated documentation,
-   and conversions to other media types.
-
-   "Work" shall mean the work of authorship, whether in Source or
-   Object form, made available under the License, as indicated by a
-   copyright notice that is included in or attached to the work
-   (an example is provided in the Appendix below).
-
-   "Derivative Works" shall mean any work, whether in Source or Object
-   form, that is based on (or derived from) the Work and for which the
-   editorial revisions, annotations, elaborations, or other modifications
-   represent, as a whole, an original work of authorship. For the purposes
-   of this License, Derivative Works shall not include works that remain
-   separable from, or merely link (or bind by name) to the interfaces of,
-   the Work and Derivative Works thereof.
-
-   "Contribution" shall mean any work of authorship, including
-   the original version of the Work and any modifications or additions
-   to that Work or Derivative Works thereof, that is intentionally
-   submitted to Licensor for inclusion in the Work by the copyright owner
-   or by an individual or Legal Entity authorized to submit on behalf of
-   the copyright owner. For the purposes of this definition, "submitted"
-   means any form of electronic, verbal, or written communication sent
-   to the Licensor or its representatives, including but not limited to
-   communication on electronic mailing lists, source code control systems,
-   and issue tracking systems that are managed by, or on behalf of, the
-   Licensor for the purpose of discussing and improving the Work, but
-   excluding communication that is conspicuously marked or otherwise
-   designated in writing by the copyright owner as "Not a Contribution."
-
-   "Contributor" shall mean Licensor and any individual or Legal Entity
-   on behalf of whom a Contribution has been received by Licensor and
-   subsequently incorporated within the Work.
-
-2. Grant of Copyright License. Subject to the terms and conditions of
-   this License, each Contributor hereby grants to You a perpetual,
-   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-   copyright license to reproduce, prepare Derivative Works of,
-   publicly display, publicly perform, sublicense, and distribute the
-   Work and such Derivative Works in Source or Object form.
-
-3. Grant of Patent License. Subject to the terms and conditions of
-   this License, each Contributor hereby grants to You a perpetual,
-   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-   (except as stated in this section) patent license to make, have made,
-   use, offer to sell, sell, import, and otherwise transfer the Work,
-   where such license applies only to those patent claims licensable
-   by such Contributor that are necessarily infringed by their
-   Contribution(s) alone or by combination of their Contribution(s)
-   with the Work to which such Contribution(s) was submitted. If You
-   institute patent litigation against any entity (including a
-   cross-claim or counterclaim in a lawsuit) alleging that the Work
-   or a Contribution incorporated within the Work constitutes direct
-   or contributory patent infringement, then any patent licenses
-   granted to You under this License for that Work shall terminate
-   as of the date such litigation is filed.
-
-4. Redistribution. You may reproduce and distribute copies of the
-   Work or Derivative Works thereof in any medium, with or without
-   modifications, and in Source or Object form, provided that You
-   meet the following conditions:
-
-   (a) You must give any other recipients of the Work or
-       Derivative Works a copy of this License; and
-
-   (b) You must cause any modified files to carry prominent notices
-       stating that You changed the files; and
-
-   (c) You must retain, in the Source form of any Derivative Works
-       that You distribute, all copyright, patent, trademark, and
-       attribution notices from the Source form of the Work,
-       excluding those notices that do not pertain to any part of
-       the Derivative Works; and
-
-   (d) If the Work includes a "NOTICE" text file as part of its
-       distribution, then any Derivative Works that You distribute must
-       include a readable copy of the attribution notices contained
-       within such NOTICE file, excluding those notices that do not
-       pertain to any part of the Derivative Works, in at least one
-       of the following places: within a NOTICE text file distributed
-       as part of the Derivative Works; within the Source form or
-       documentation, if provided along with the Derivative Works; or,
-       within a display generated by the Derivative Works, if and
-       wherever such third-party notices normally appear. The contents
-       of the NOTICE file are for informational purposes only and
-       do not modify the License. You may add Your own attribution
-       notices within Derivative Works that You distribute, alongside
-       or as an addendum to the NOTICE text from the Work, provided
-       that such additional attribution notices cannot be construed
-       as modifying the License.
-
-   You may add Your own copyright statement to Your modifications and
-   may provide additional or different license terms and conditions
-   for use, reproduction, or distribution of Your modifications, or
-   for any such Derivative Works as a whole, provided Your use,
-   reproduction, and distribution of the Work otherwise complies with
-   the conditions stated in this License.
-
-5. Submission of Contributions. Unless You explicitly state otherwise,
-   any Contribution intentionally submitted for inclusion in the Work
-   by You to the Licensor shall be under the terms and conditions of
-   this License, without any additional terms or conditions.
-   Notwithstanding the above, nothing herein shall supersede or modify
-   the terms of any separate license agreement you may have executed
-   with Licensor regarding such Contributions.
-
-6. Trademarks. This License does not grant permission to use the trade
-   names, trademarks, service marks, or product names of the Licensor,
-   except as required for reasonable and customary use in describing the
-   origin of the Work and reproducing the content of the NOTICE file.
-
-7. Disclaimer of Warranty. Unless required by applicable law or
-   agreed to in writing, Licensor provides the Work (and each
-   Contributor provides its Contributions) on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-   implied, including, without limitation, any warranties or conditions
-   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-   PARTICULAR PURPOSE. You are solely responsible for determining the
-   appropriateness of using or redistributing the Work and assume any
-   risks associated with Your exercise of permissions under this License.
-
-8. Limitation of Liability. In no event and under no legal theory,
-   whether in tort (including negligence), contract, or otherwise,
-   unless required by applicable law (such as deliberate and grossly
-   negligent acts) or agreed to in writing, shall any Contributor be
-   liable to You for damages, including any direct, indirect, special,
-   incidental, or consequential damages of any character arising as a
-   result of this License or out of the use or inability to use the
-   Work (including but not limited to damages for loss of goodwill,
-   work stoppage, computer failure or malfunction, or any and all
-   other commercial damages or losses), even if such Contributor
-   has been advised of the possibility of such damages.
-
-9. Accepting Warranty or Additional Liability. While redistributing
-   the Work or Derivative Works thereof, You may choose to offer,
-   and charge a fee for, acceptance of support, warranty, indemnity,
-   or other liability obligations and/or rights consistent with this
-   License. However, in accepting such obligations, You may act only
-   on Your own behalf and on Your sole responsibility, not on behalf
-   of any other Contributor, and only if You agree to indemnify,
-   defend, and hold each Contributor harmless for any liability
-   incurred by, or claims asserted against, such Contributor by reason
-   of your accepting any such warranty or additional liability.
-
-END OF TERMS AND CONDITIONS
-
-APPENDIX: How to apply the Apache License to your work.
-
-   To apply the Apache License to your work, attach the following
-   boilerplate notice, with the fields enclosed by brackets "[]"
-   replaced with your own identifying information. (Don't include
-   the brackets!)  The text should be enclosed in the appropriate
-   comment syntax for the file format. We also recommend that a
-   file or class name and description of purpose be included on the
-   same "printed page" as the copyright notice for easier
-   identification within third-party archives.
-
-Copyright [yyyy] [name of copyright owner]
-
-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.
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/VERSION b/gerrit-plugin-archetype/src/main/resources/archetype-resources/VERSION
deleted file mode 100644
index 8bbb460..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/VERSION
+++ /dev/null
@@ -1,5 +0,0 @@
-# Used by BUCK to include "Implementation-Version" in plugin Manifest.
-# If this file doesn't exist the output of 'git describe' is used
-# instead.
-PLUGIN_VERSION = '${version}'
-
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/lib/gerrit/BUCK b/gerrit-plugin-archetype/src/main/resources/archetype-resources/lib/gerrit/BUCK
deleted file mode 100644
index b1648d3..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/lib/gerrit/BUCK
+++ /dev/null
@@ -1,12 +0,0 @@
-include_defs('//bucklets/maven_jar.bucklet')
-
-VER = '${gerritApiVersion}'
-REPO = MAVEN_LOCAL
-
-maven_jar(
-  name = '${gerritApiType}-api',
-  id = 'com.google.gerrit:gerrit-${gerritApiType}-api:' + VER,
-  attach_source = False,
-  repository = REPO,
-  license = 'Apache2.0',
-)
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml b/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
deleted file mode 100644
index 026e21d..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/pom.xml
+++ /dev/null
@@ -1,94 +0,0 @@
-<!--
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
-  <modelVersion>4.0.0</modelVersion>
-
-  <groupId>${groupId}</groupId>
-  <artifactId>${artifactId}</artifactId>
-  <packaging>jar</packaging>
-  <version>${version}</version>
-  <name>${pluginName}</name>
-
-  <properties>
-    <Gerrit-ApiType>${gerritApiType}</Gerrit-ApiType>
-    <Gerrit-ApiVersion>${gerritApiVersion}</Gerrit-ApiVersion>
-  </properties>
-
-  <build>
-    <plugins>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-jar-plugin</artifactId>
-        <version>2.4</version>
-        <configuration>
-          <archive>
-            <manifestEntries>
-              <Gerrit-PluginName>${pluginName}</Gerrit-PluginName>
-#if ($Gerrit-Module.equalsIgnoreCase("Y"))
-              <Gerrit-Module>${package}.Module</Gerrit-Module>
-#end
-#if ($Gerrit-SshModule.equalsIgnoreCase("Y"))
-              <Gerrit-SshModule>${package}.SshModule</Gerrit-SshModule>
-#end
-#if ($Gerrit-HttpModule.equalsIgnoreCase("Y"))
-              <Gerrit-HttpModule>${package}.HttpModule</Gerrit-HttpModule>
-#end
-
-              <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-
-              <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
-              <Implementation-Version>${project.version}</Implementation-Version>
-
-              <Gerrit-ApiType>${Gerrit-ApiType}</Gerrit-ApiType>
-              <Gerrit-ApiVersion>${Gerrit-ApiVersion}</Gerrit-ApiVersion>
-            </manifestEntries>
-          </archive>
-        </configuration>
-      </plugin>
-
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-compiler-plugin</artifactId>
-        <version>2.3.2</version>
-        <configuration>
-          <source>1.7</source>
-          <target>1.7</target>
-          <encoding>UTF-8</encoding>
-        </configuration>
-      </plugin>
-    </plugins>
-  </build>
-
-  <dependencies>
-    <dependency>
-      <groupId>com.google.gerrit</groupId>
-      <artifactId>gerrit-${Gerrit-ApiType}-api</artifactId>
-      <version>${Gerrit-ApiVersion}</version>
-      <scope>provided</scope>
-    </dependency>
-  </dependencies>
-#if ($gerritApiVersion.endsWith("SNAPSHOT"))
-
-  <repositories>
-    <repository>
-      <id>snapshot-repository</id>
-      <url>https://oss.sonatype.org/content/repositories/snapshots/</url>
-    </repository>
-  </repositories>
-#end
-</project>
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/HttpModule.java b/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/HttpModule.java
deleted file mode 100644
index a0fed9e..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/HttpModule.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package};
-
-import com.google.inject.servlet.ServletModule;
-
-class HttpModule extends ServletModule {
-  @Override
-  protected void configureServlets() {
-    // TODO
-  }
-}
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/Module.java b/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/Module.java
deleted file mode 100644
index 39ce59b..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/Module.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package};
-
-import com.google.inject.AbstractModule;
-
-class Module extends AbstractModule {
-  @Override
-  protected void configure() {
-    // TODO
-  }
-}
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/SshModule.java b/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/SshModule.java
deleted file mode 100644
index 1ef7cc8..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/java/SshModule.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package};
-
-import com.google.gerrit.sshd.PluginCommandModule;
-
-class SshModule extends PluginCommandModule {
-  @Override
-  protected void configureCommands() {
-    // command("my-command").to(MyCommand.class);
-  }
-}
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/build.md b/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/build.md
deleted file mode 100644
index e4e944a..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/build.md
+++ /dev/null
@@ -1,84 +0,0 @@
-Build
-=====
-
-This plugin can be built with Buck or Maven.
-
-Buck
-----
-
-Two build modes are supported: Standalone and in Gerrit tree.
-The standalone build mode is recommended, as this mode doesn't require
-the Gerrit tree to exist locally.
-
-
-### Build standalone
-
-Clone bucklets library:
-
-```
-  git clone https://gerrit.googlesource.com/bucklets
-
-```
-and link it to @PLUGIN@ plugin directory:
-
-```
-  cd @PLUGIN@ && ln -s ../bucklets .
-```
-
-Add link to the .buckversion file:
-
-```
-  cd @PLUGIN@ && ln -s bucklets/buckversion .buckversion
-```
-
-Add link to the .watchmanconfig file:
-```
-  cd @PLUGIN@ && ln -s bucklets/watchmanconfig .watchmanconfig
-```
-
-To build the plugin, issue the following command:
-
-
-```
-  buck build plugin
-```
-
-The output is created in
-
-```
-  buck-out/gen/@PLUGIN@.jar
-```
-
-### Build in Gerrit tree
-
-Clone or link this plugin to the plugins directory of Gerrit's source
-tree, and issue the command:
-
-```
-  buck build plugins/@PLUGIN@
-```
-
-The output is created in
-
-```
-  buck-out/gen/plugins/@PLUGIN@/@PLUGIN@.jar
-```
-
-This project can be imported into the Eclipse IDE:
-
-```
-  ./tools/eclipse/project.py
-```
-
-Maven
------
-
-Note that the Maven build is provided for compatibility reasons, but
-it is considered to be deprecated and will be removed in a future
-version of this plugin.
-
-To build with Maven, run
-
-```
-mvn clean package
-```
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/cmd-start.md b/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/cmd-start.md
deleted file mode 100644
index beecb90..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/cmd-start.md
+++ /dev/null
@@ -1 +0,0 @@
-TODO: command documentation
diff --git a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/config.md b/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/config.md
deleted file mode 100644
index bde3084..0000000
--- a/gerrit-plugin-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/config.md
+++ /dev/null
@@ -1 +0,0 @@
-TODO: config documentation
diff --git a/gerrit-plugin-gwt-archetype/.gitignore b/gerrit-plugin-gwt-archetype/.gitignore
deleted file mode 100644
index 7075a2f..0000000
--- a/gerrit-plugin-gwt-archetype/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-/target
-/.classpath
-/.project
-/.settings
diff --git a/gerrit-plugin-gwt-archetype/pom.xml b/gerrit-plugin-gwt-archetype/pom.xml
deleted file mode 100644
index 28f2f80..0000000
--- a/gerrit-plugin-gwt-archetype/pom.xml
+++ /dev/null
@@ -1,108 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 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.
--->
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
-  <modelVersion>4.0.0</modelVersion>
-
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-plugin-gwt-archetype</artifactId>
-  <version>2.13.14</version>
-  <name>Gerrit Code Review - Web UI GWT Plugin Archetype</name>
-  <description>Maven Archetype for Gerrit Web UI GWT Plugins</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <properties>
-    <defaultGerritApiVersion>${project.version}</defaultGerritApiVersion>
-  </properties>
-
-  <build>
-    <resources>
-      <resource>
-        <directory>src/main/resources</directory>
-        <filtering>true</filtering>
-        <includes>
-          <include>META-INF/maven/archetype-metadata.xml</include>
-        </includes>
-      </resource>
-      <resource>
-        <directory>src/main/resources</directory>
-        <filtering>false</filtering>
-        <excludes>
-          <exclude>META-INF/maven/archetype-metadata.xml</exclude>
-        </excludes>
-      </resource>
-    </resources>
-  </build>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
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
deleted file mode 100644
index 32a603b..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ /dev/null
@@ -1,77 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 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.
--->
-<archetype-descriptor name="Gerrit Plugin">
-  <requiredProperties>
-    <requiredProperty key="pluginName"/>
-
-    <requiredProperty key="Implementation-Vendor">
-      <defaultValue>Gerrit Code Review</defaultValue>
-    </requiredProperty>
-    <requiredProperty key="Gwt-Version">
-      <defaultValue>2.7.0</defaultValue>
-    </requiredProperty>
-
-    <requiredProperty key="gerritApiVersion">
-      <defaultValue>${defaultGerritApiVersion}</defaultValue>
-    </requiredProperty>
-  </requiredProperties>
-
-  <fileSets>
-    <fileSet filtered="true" packaged="true">
-      <directory>src/main/java</directory>
-      <includes>
-        <include>**/*.css</include>
-        <include>**/*.png</include>
-        <include>**/*.java</include>
-        <include>**/*.gwt.xml</include>
-      </includes>
-    </fileSet>
-
-    <fileSet filtered="true">
-      <directory>src/main/resources/Documentation</directory>
-      <includes>
-        <include>**/*.md</include>
-      </includes>
-    </fileSet>
-
-    <fileSet filtered="true">
-      <directory></directory>
-      <include>.buckconfig</include>
-      <include>BUCK</include>
-      <include>VERSION</include>
-      <include>lib/gerrit/BUCK</include>
-      <include>lib/gwt/BUCK</include>
-      <excludes>
-        <exclude>**/client/</exclude>
-        <exclude>**/public/</exclude>
-        <exclude>**/*.css</exclude>
-        <exclude>**/*.png</exclude>
-        <exclude>**/*.java</exclude>
-        <exclude>**/*.gwt.xml</exclude>
-      </excludes>
-    </fileSet>
-
-    <fileSet>
-      <directory></directory>
-      <includes>
-        <include>.gitignore</include>
-        <include>.settings/*</include>
-        <include>LICENSE</include>
-      </includes>
-    </fileSet>
-  </fileSets>
-</archetype-descriptor>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.buckconfig b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.buckconfig
deleted file mode 100644
index 1044c12..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.buckconfig
+++ /dev/null
@@ -1,14 +0,0 @@
-[alias]
-  ${pluginName} = //:${pluginName}
-  plugin = //:${pluginName}
-
-[java]
-  src_roots = java, resources
-
-[project]
-  ignore = .git
-
-[cache]
-  mode = dir
-  dir = buck-out/cache
-
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.gitignore b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.gitignore
deleted file mode 100644
index 43838b0..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.gitignore
+++ /dev/null
@@ -1,9 +0,0 @@
-/.buckversion
-/.buckd
-/buck-out
-/bucklets
-/target
-/.classpath
-/.project
-/.settings/org.maven.ide.eclipse.prefs
-/.settings/org.eclipse.m2e.core.prefs
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs
deleted file mode 100644
index 29abf99..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs
+++ /dev/null
@@ -1,6 +0,0 @@
-eclipse.preferences.version=1
-encoding//src/main/java=UTF-8
-encoding//src/main/resources=UTF-8
-encoding//src/test/java=UTF-8
-encoding//src/test/resources=UTF-8
-encoding/<project>=UTF-8
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs
deleted file mode 100644
index 5a0ad22..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs
+++ /dev/null
@@ -1,2 +0,0 @@
-eclipse.preferences.version=1
-line.separator=\n
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
deleted file mode 100644
index 2a585e4..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
+++ /dev/null
@@ -1,346 +0,0 @@
-eclipse.preferences.version=1
-org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
-org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull
-org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault
-org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable
-org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
-org.eclipse.jdt.core.compiler.compliance=1.7
-org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
-org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
-org.eclipse.jdt.core.compiler.problem.comparingIdentical=warning
-org.eclipse.jdt.core.compiler.problem.deadCode=warning
-org.eclipse.jdt.core.compiler.problem.deprecation=warning
-org.eclipse.jdt.core.compiler.problem.deprecationInDeprecatedCode=disabled
-org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled
-org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
-org.eclipse.jdt.core.compiler.problem.emptyStatement=ignore
-org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=ignore
-org.eclipse.jdt.core.compiler.problem.fallthroughCase=ignore
-org.eclipse.jdt.core.compiler.problem.fatalOptionalError=disabled
-org.eclipse.jdt.core.compiler.problem.fieldHiding=ignore
-org.eclipse.jdt.core.compiler.problem.finalParameterBound=warning
-org.eclipse.jdt.core.compiler.problem.finallyBlockNotCompletingNormally=warning
-org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
-org.eclipse.jdt.core.compiler.problem.hiddenCatchBlock=warning
-org.eclipse.jdt.core.compiler.problem.includeNullInfoFromAsserts=disabled
-org.eclipse.jdt.core.compiler.problem.incompatibleNonInheritedInterfaceMethod=warning
-org.eclipse.jdt.core.compiler.problem.incompleteEnumSwitch=warning
-org.eclipse.jdt.core.compiler.problem.indirectStaticAccess=ignore
-org.eclipse.jdt.core.compiler.problem.localVariableHiding=ignore
-org.eclipse.jdt.core.compiler.problem.methodWithConstructorName=warning
-org.eclipse.jdt.core.compiler.problem.missingDefaultCase=ignore
-org.eclipse.jdt.core.compiler.problem.missingDeprecatedAnnotation=ignore
-org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=disabled
-org.eclipse.jdt.core.compiler.problem.missingHashCodeMethod=ignore
-org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotation=ignore
-org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotationForInterfaceMethodImplementation=enabled
-org.eclipse.jdt.core.compiler.problem.missingSerialVersion=warning
-org.eclipse.jdt.core.compiler.problem.missingSynchronizedOnInheritedMethod=ignore
-org.eclipse.jdt.core.compiler.problem.noEffectAssignment=warning
-org.eclipse.jdt.core.compiler.problem.noImplicitStringConversion=warning
-org.eclipse.jdt.core.compiler.problem.nonExternalizedStringLiteral=ignore
-org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error
-org.eclipse.jdt.core.compiler.problem.nullReference=warning
-org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error
-org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning
-org.eclipse.jdt.core.compiler.problem.overridingPackageDefaultMethod=warning
-org.eclipse.jdt.core.compiler.problem.parameterAssignment=ignore
-org.eclipse.jdt.core.compiler.problem.possibleAccidentalBooleanAssignment=ignore
-org.eclipse.jdt.core.compiler.problem.potentialNullReference=ignore
-org.eclipse.jdt.core.compiler.problem.potentiallyUnclosedCloseable=ignore
-org.eclipse.jdt.core.compiler.problem.rawTypeReference=warning
-org.eclipse.jdt.core.compiler.problem.redundantNullAnnotation=warning
-org.eclipse.jdt.core.compiler.problem.redundantNullCheck=ignore
-org.eclipse.jdt.core.compiler.problem.redundantSpecificationOfTypeArguments=ignore
-org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=ignore
-org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore
-org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore
-org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled
-org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=warning
-org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled
-org.eclipse.jdt.core.compiler.problem.suppressWarnings=enabled
-org.eclipse.jdt.core.compiler.problem.syntheticAccessEmulation=ignore
-org.eclipse.jdt.core.compiler.problem.typeParameterHiding=warning
-org.eclipse.jdt.core.compiler.problem.unavoidableGenericTypeProblems=enabled
-org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
-org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
-org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
-org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
-org.eclipse.jdt.core.compiler.problem.unnecessaryElse=ignore
-org.eclipse.jdt.core.compiler.problem.unnecessaryTypeCheck=ignore
-org.eclipse.jdt.core.compiler.problem.unqualifiedFieldAccess=ignore
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownException=ignore
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionExemptExceptionAndThrowable=enabled
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionIncludeDocCommentReference=enabled
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionWhenOverriding=disabled
-org.eclipse.jdt.core.compiler.problem.unusedImport=warning
-org.eclipse.jdt.core.compiler.problem.unusedLabel=warning
-org.eclipse.jdt.core.compiler.problem.unusedLocal=warning
-org.eclipse.jdt.core.compiler.problem.unusedObjectAllocation=ignore
-org.eclipse.jdt.core.compiler.problem.unusedParameter=ignore
-org.eclipse.jdt.core.compiler.problem.unusedParameterIncludeDocCommentReference=enabled
-org.eclipse.jdt.core.compiler.problem.unusedParameterWhenImplementingAbstract=disabled
-org.eclipse.jdt.core.compiler.problem.unusedParameterWhenOverridingConcrete=disabled
-org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=warning
-org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
-org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
-org.eclipse.jdt.core.compiler.source=1.7
-org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_assignment=16
-org.eclipse.jdt.core.formatter.alignment_for_binary_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_compact_if=16
-org.eclipse.jdt.core.formatter.alignment_for_conditional_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_enum_constants=16
-org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer=16
-org.eclipse.jdt.core.formatter.alignment_for_multiple_fields=16
-org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation=16
-org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration=16
-org.eclipse.jdt.core.formatter.blank_lines_after_imports=1
-org.eclipse.jdt.core.formatter.blank_lines_after_package=1
-org.eclipse.jdt.core.formatter.blank_lines_before_field=0
-org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration=0
-org.eclipse.jdt.core.formatter.blank_lines_before_imports=0
-org.eclipse.jdt.core.formatter.blank_lines_before_member_type=0
-org.eclipse.jdt.core.formatter.blank_lines_before_method=1
-org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk=1
-org.eclipse.jdt.core.formatter.blank_lines_before_package=0
-org.eclipse.jdt.core.formatter.blank_lines_between_import_groups=1
-org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations=2
-org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_array_initializer=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_block=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_block_in_case=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_enum_constant=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_method_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_switch=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment=false
-org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment=false
-org.eclipse.jdt.core.formatter.comment.format_block_comments=true
-org.eclipse.jdt.core.formatter.comment.format_header=true
-org.eclipse.jdt.core.formatter.comment.format_html=true
-org.eclipse.jdt.core.formatter.comment.format_javadoc_comments=true
-org.eclipse.jdt.core.formatter.comment.format_line_comments=true
-org.eclipse.jdt.core.formatter.comment.format_source_code=true
-org.eclipse.jdt.core.formatter.comment.indent_parameter_description=false
-org.eclipse.jdt.core.formatter.comment.indent_root_tags=true
-org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags=insert
-org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter=do not insert
-org.eclipse.jdt.core.formatter.comment.line_length=80
-org.eclipse.jdt.core.formatter.compact_else_if=true
-org.eclipse.jdt.core.formatter.continuation_indentation=2
-org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer=2
-org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line=false
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header=true
-org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases=true
-org.eclipse.jdt.core.formatter.indent_empty_lines=false
-org.eclipse.jdt.core.formatter.indent_statements_compare_to_block=true
-org.eclipse.jdt.core.formatter.indent_statements_compare_to_body=true
-org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases=true
-org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch=true
-org.eclipse.jdt.core.formatter.indentation.size=4
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_member=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing=insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_annotation_declaration=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_declaration=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter=insert
-org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_binary_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_ellipsis=insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_after_unary_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter=insert
-org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_binary_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_ellipsis=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while=insert
-org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return=insert
-org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw=insert
-org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_unary_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.join_lines_in_comments=true
-org.eclipse.jdt.core.formatter.join_wrapped_lines=true
-org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line=false
-org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line=false
-org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line=true
-org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line=false
-org.eclipse.jdt.core.formatter.lineSplit=80
-org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column=false
-org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column=false
-org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body=0
-org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve=3
-org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line=false
-org.eclipse.jdt.core.formatter.tabulation.char=space
-org.eclipse.jdt.core.formatter.tabulation.size=2
-org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations=false
-org.eclipse.jdt.core.formatter.wrap_before_binary_operator=true
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs
deleted file mode 100644
index 7397758..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs
+++ /dev/null
@@ -1,60 +0,0 @@
-eclipse.preferences.version=1
-editor_save_participant_org.eclipse.jdt.ui.postsavelistener.cleanup=true
-formatter_profile=_Google Format
-formatter_settings_version=11
-org.eclipse.jdt.ui.ignorelowercasenames=true
-org.eclipse.jdt.ui.importorder=\#;com.google;com;dk;eu;junit;net;org;java;javax;
-org.eclipse.jdt.ui.ondemandthreshold=99
-org.eclipse.jdt.ui.staticondemandthreshold=99
-org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates/>
-sp_cleanup.add_default_serial_version_id=true
-sp_cleanup.add_generated_serial_version_id=false
-sp_cleanup.add_missing_annotations=false
-sp_cleanup.add_missing_deprecated_annotations=true
-sp_cleanup.add_missing_methods=false
-sp_cleanup.add_missing_nls_tags=false
-sp_cleanup.add_missing_override_annotations=true
-sp_cleanup.add_serial_version_id=false
-sp_cleanup.always_use_blocks=true
-sp_cleanup.always_use_parentheses_in_expressions=false
-sp_cleanup.always_use_this_for_non_static_field_access=false
-sp_cleanup.always_use_this_for_non_static_method_access=false
-sp_cleanup.convert_to_enhanced_for_loop=false
-sp_cleanup.correct_indentation=false
-sp_cleanup.format_source_code=false
-sp_cleanup.format_source_code_changes_only=false
-sp_cleanup.make_local_variable_final=true
-sp_cleanup.make_parameters_final=true
-sp_cleanup.make_private_fields_final=true
-sp_cleanup.make_type_abstract_if_missing_method=false
-sp_cleanup.make_variable_declarations_final=false
-sp_cleanup.never_use_blocks=false
-sp_cleanup.never_use_parentheses_in_expressions=true
-sp_cleanup.on_save_use_additional_actions=true
-sp_cleanup.organize_imports=false
-sp_cleanup.qualify_static_field_accesses_with_declaring_class=false
-sp_cleanup.qualify_static_member_accesses_through_instances_with_declaring_class=true
-sp_cleanup.qualify_static_member_accesses_through_subtypes_with_declaring_class=true
-sp_cleanup.qualify_static_member_accesses_with_declaring_class=false
-sp_cleanup.qualify_static_method_accesses_with_declaring_class=false
-sp_cleanup.remove_private_constructors=true
-sp_cleanup.remove_trailing_whitespaces=true
-sp_cleanup.remove_trailing_whitespaces_all=true
-sp_cleanup.remove_trailing_whitespaces_ignore_empty=false
-sp_cleanup.remove_unnecessary_casts=false
-sp_cleanup.remove_unnecessary_nls_tags=false
-sp_cleanup.remove_unused_imports=false
-sp_cleanup.remove_unused_local_variables=false
-sp_cleanup.remove_unused_private_fields=true
-sp_cleanup.remove_unused_private_members=false
-sp_cleanup.remove_unused_private_methods=true
-sp_cleanup.remove_unused_private_types=true
-sp_cleanup.sort_members=false
-sp_cleanup.sort_members_all=false
-sp_cleanup.use_blocks=false
-sp_cleanup.use_blocks_only_for_return_and_throw=false
-sp_cleanup.use_parentheses_in_expressions=false
-sp_cleanup.use_this_for_non_static_field_access=false
-sp_cleanup.use_this_for_non_static_field_access_only_if_necessary=true
-sp_cleanup.use_this_for_non_static_method_access=false
-sp_cleanup.use_this_for_non_static_method_access_only_if_necessary=true
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/BUCK b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/BUCK
deleted file mode 100644
index f33929d..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/BUCK
+++ /dev/null
@@ -1,21 +0,0 @@
-include_defs('//bucklets/gerrit_plugin.bucklet')
-
-gerrit_plugin(
-  name = '${pluginName}',
-  srcs = glob(['src/main/java/**/*.java']),
-  resources = glob(['src/main/**/*']),
-  gwt_module = '${package}.HelloPlugin',
-  manifest_entries = [
-    'Gerrit-PluginName: ${pluginName}',
-    'Gerrit-ApiType: plugin',
-    'Gerrit-ApiVersion: ${gerritApiVersion}',
-    'Gerrit-Module: ${package}.Module',
-  ],
-)
-
-# this is required for bucklets/tools/eclipse/project.py to work
-java_library(
-  name = 'classpath',
-  deps = [':${pluginName}__plugin'],
-)
-
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/LICENSE b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/LICENSE
deleted file mode 100644
index 11069ed..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/LICENSE
+++ /dev/null
@@ -1,201 +0,0 @@
-                              Apache License
-                        Version 2.0, January 2004
-                     http://www.apache.org/licenses/
-
-TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-1. Definitions.
-
-   "License" shall mean the terms and conditions for use, reproduction,
-   and distribution as defined by Sections 1 through 9 of this document.
-
-   "Licensor" shall mean the copyright owner or entity authorized by
-   the copyright owner that is granting the License.
-
-   "Legal Entity" shall mean the union of the acting entity and all
-   other entities that control, are controlled by, or are under common
-   control with that entity. For the purposes of this definition,
-   "control" means (i) the power, direct or indirect, to cause the
-   direction or management of such entity, whether by contract or
-   otherwise, or (ii) ownership of fifty percent (50%) or more of the
-   outstanding shares, or (iii) beneficial ownership of such entity.
-
-   "You" (or "Your") shall mean an individual or Legal Entity
-   exercising permissions granted by this License.
-
-   "Source" form shall mean the preferred form for making modifications,
-   including but not limited to software source code, documentation
-   source, and configuration files.
-
-   "Object" form shall mean any form resulting from mechanical
-   transformation or translation of a Source form, including but
-   not limited to compiled object code, generated documentation,
-   and conversions to other media types.
-
-   "Work" shall mean the work of authorship, whether in Source or
-   Object form, made available under the License, as indicated by a
-   copyright notice that is included in or attached to the work
-   (an example is provided in the Appendix below).
-
-   "Derivative Works" shall mean any work, whether in Source or Object
-   form, that is based on (or derived from) the Work and for which the
-   editorial revisions, annotations, elaborations, or other modifications
-   represent, as a whole, an original work of authorship. For the purposes
-   of this License, Derivative Works shall not include works that remain
-   separable from, or merely link (or bind by name) to the interfaces of,
-   the Work and Derivative Works thereof.
-
-   "Contribution" shall mean any work of authorship, including
-   the original version of the Work and any modifications or additions
-   to that Work or Derivative Works thereof, that is intentionally
-   submitted to Licensor for inclusion in the Work by the copyright owner
-   or by an individual or Legal Entity authorized to submit on behalf of
-   the copyright owner. For the purposes of this definition, "submitted"
-   means any form of electronic, verbal, or written communication sent
-   to the Licensor or its representatives, including but not limited to
-   communication on electronic mailing lists, source code control systems,
-   and issue tracking systems that are managed by, or on behalf of, the
-   Licensor for the purpose of discussing and improving the Work, but
-   excluding communication that is conspicuously marked or otherwise
-   designated in writing by the copyright owner as "Not a Contribution."
-
-   "Contributor" shall mean Licensor and any individual or Legal Entity
-   on behalf of whom a Contribution has been received by Licensor and
-   subsequently incorporated within the Work.
-
-2. Grant of Copyright License. Subject to the terms and conditions of
-   this License, each Contributor hereby grants to You a perpetual,
-   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-   copyright license to reproduce, prepare Derivative Works of,
-   publicly display, publicly perform, sublicense, and distribute the
-   Work and such Derivative Works in Source or Object form.
-
-3. Grant of Patent License. Subject to the terms and conditions of
-   this License, each Contributor hereby grants to You a perpetual,
-   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-   (except as stated in this section) patent license to make, have made,
-   use, offer to sell, sell, import, and otherwise transfer the Work,
-   where such license applies only to those patent claims licensable
-   by such Contributor that are necessarily infringed by their
-   Contribution(s) alone or by combination of their Contribution(s)
-   with the Work to which such Contribution(s) was submitted. If You
-   institute patent litigation against any entity (including a
-   cross-claim or counterclaim in a lawsuit) alleging that the Work
-   or a Contribution incorporated within the Work constitutes direct
-   or contributory patent infringement, then any patent licenses
-   granted to You under this License for that Work shall terminate
-   as of the date such litigation is filed.
-
-4. Redistribution. You may reproduce and distribute copies of the
-   Work or Derivative Works thereof in any medium, with or without
-   modifications, and in Source or Object form, provided that You
-   meet the following conditions:
-
-   (a) You must give any other recipients of the Work or
-       Derivative Works a copy of this License; and
-
-   (b) You must cause any modified files to carry prominent notices
-       stating that You changed the files; and
-
-   (c) You must retain, in the Source form of any Derivative Works
-       that You distribute, all copyright, patent, trademark, and
-       attribution notices from the Source form of the Work,
-       excluding those notices that do not pertain to any part of
-       the Derivative Works; and
-
-   (d) If the Work includes a "NOTICE" text file as part of its
-       distribution, then any Derivative Works that You distribute must
-       include a readable copy of the attribution notices contained
-       within such NOTICE file, excluding those notices that do not
-       pertain to any part of the Derivative Works, in at least one
-       of the following places: within a NOTICE text file distributed
-       as part of the Derivative Works; within the Source form or
-       documentation, if provided along with the Derivative Works; or,
-       within a display generated by the Derivative Works, if and
-       wherever such third-party notices normally appear. The contents
-       of the NOTICE file are for informational purposes only and
-       do not modify the License. You may add Your own attribution
-       notices within Derivative Works that You distribute, alongside
-       or as an addendum to the NOTICE text from the Work, provided
-       that such additional attribution notices cannot be construed
-       as modifying the License.
-
-   You may add Your own copyright statement to Your modifications and
-   may provide additional or different license terms and conditions
-   for use, reproduction, or distribution of Your modifications, or
-   for any such Derivative Works as a whole, provided Your use,
-   reproduction, and distribution of the Work otherwise complies with
-   the conditions stated in this License.
-
-5. Submission of Contributions. Unless You explicitly state otherwise,
-   any Contribution intentionally submitted for inclusion in the Work
-   by You to the Licensor shall be under the terms and conditions of
-   this License, without any additional terms or conditions.
-   Notwithstanding the above, nothing herein shall supersede or modify
-   the terms of any separate license agreement you may have executed
-   with Licensor regarding such Contributions.
-
-6. Trademarks. This License does not grant permission to use the trade
-   names, trademarks, service marks, or product names of the Licensor,
-   except as required for reasonable and customary use in describing the
-   origin of the Work and reproducing the content of the NOTICE file.
-
-7. Disclaimer of Warranty. Unless required by applicable law or
-   agreed to in writing, Licensor provides the Work (and each
-   Contributor provides its Contributions) on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-   implied, including, without limitation, any warranties or conditions
-   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-   PARTICULAR PURPOSE. You are solely responsible for determining the
-   appropriateness of using or redistributing the Work and assume any
-   risks associated with Your exercise of permissions under this License.
-
-8. Limitation of Liability. In no event and under no legal theory,
-   whether in tort (including negligence), contract, or otherwise,
-   unless required by applicable law (such as deliberate and grossly
-   negligent acts) or agreed to in writing, shall any Contributor be
-   liable to You for damages, including any direct, indirect, special,
-   incidental, or consequential damages of any character arising as a
-   result of this License or out of the use or inability to use the
-   Work (including but not limited to damages for loss of goodwill,
-   work stoppage, computer failure or malfunction, or any and all
-   other commercial damages or losses), even if such Contributor
-   has been advised of the possibility of such damages.
-
-9. Accepting Warranty or Additional Liability. While redistributing
-   the Work or Derivative Works thereof, You may choose to offer,
-   and charge a fee for, acceptance of support, warranty, indemnity,
-   or other liability obligations and/or rights consistent with this
-   License. However, in accepting such obligations, You may act only
-   on Your own behalf and on Your sole responsibility, not on behalf
-   of any other Contributor, and only if You agree to indemnify,
-   defend, and hold each Contributor harmless for any liability
-   incurred by, or claims asserted against, such Contributor by reason
-   of your accepting any such warranty or additional liability.
-
-END OF TERMS AND CONDITIONS
-
-APPENDIX: How to apply the Apache License to your work.
-
-   To apply the Apache License to your work, attach the following
-   boilerplate notice, with the fields enclosed by brackets "[]"
-   replaced with your own identifying information. (Don't include
-   the brackets!)  The text should be enclosed in the appropriate
-   comment syntax for the file format. We also recommend that a
-   file or class name and description of purpose be included on the
-   same "printed page" as the copyright notice for easier
-   identification within third-party archives.
-
-Copyright [yyyy] [name of copyright owner]
-
-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.
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/VERSION b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/VERSION
deleted file mode 100644
index 8bbb460..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/VERSION
+++ /dev/null
@@ -1,5 +0,0 @@
-# Used by BUCK to include "Implementation-Version" in plugin Manifest.
-# If this file doesn't exist the output of 'git describe' is used
-# instead.
-PLUGIN_VERSION = '${version}'
-
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gerrit/BUCK b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gerrit/BUCK
deleted file mode 100644
index 0a0d8b9..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gerrit/BUCK
+++ /dev/null
@@ -1,20 +0,0 @@
-include_defs('//bucklets/maven_jar.bucklet')
-
-VER = '${gerritApiVersion}'
-REPO = MAVEN_LOCAL
-
-maven_jar(
-  name = 'plugin-api',
-  id = 'com.google.gerrit:gerrit-plugin-api:' + VER,
-  attach_source = False,
-  repository = REPO,
-  license = 'Apache2.0',
-)
-
-maven_jar(
-  name = 'gwtui-api',
-  id = 'com.google.gerrit:gerrit-plugin-gwtui:' + VER,
-  attach_source = False,
-  repository = REPO,
-  license = 'Apache2.0',
-)
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gwt/BUCK b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gwt/BUCK
deleted file mode 100644
index 21bc45c..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/gwt/BUCK
+++ /dev/null
@@ -1,17 +0,0 @@
-include_defs('//bucklets/maven_jar.bucklet')
-
-VERSION = '${Gwt-Version}'
-
-maven_jar(
-  name = 'user',
-  id = 'com.google.gwt:gwt-user:' + VERSION,
-  license = 'Apache2.0',
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'dev',
-  id = 'com.google.gwt:gwt-dev:' + VERSION,
-  license = 'Apache2.0',
-  attach_source = False,
-)
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/ow2/BUCK b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/ow2/BUCK
deleted file mode 100644
index db6c76c..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/lib/ow2/BUCK
+++ /dev/null
@@ -1,32 +0,0 @@
-include_defs('//bucklets/maven_jar.bucklet')
-
-VERSION = '5.0.3'
-
-maven_jar(
-  name = 'ow2-asm',
-  id = 'org.ow2.asm:asm:' + VERSION,
-  sha1 = 'dcc2193db20e19e1feca8b1240dbbc4e190824fa',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-analysis',
-  id = 'org.ow2.asm:asm-analysis:' + VERSION,
-  sha1 = 'c7126aded0e8e13fed5f913559a0dd7b770a10f3',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-tree',
-  id = 'org.ow2.asm:asm-tree:' + VERSION,
-  sha1 = '287749b48ba7162fb67c93a026d690b29f410bed',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-util',
-  id = 'org.ow2.asm:asm-util:' + VERSION,
-  sha1 = '1512e5571325854b05fb1efce1db75fcced54389',
-  license = 'ow2',
-)
-
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
deleted file mode 100644
index 2c7fe88..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/pom.xml
+++ /dev/null
@@ -1,122 +0,0 @@
-<!--
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
-  <modelVersion>4.0.0</modelVersion>
-
-  <groupId>${groupId}</groupId>
-  <artifactId>${artifactId}</artifactId>
-  <packaging>jar</packaging>
-  <version>${version}</version>
-  <name>${pluginName}</name>
-
-  <properties>
-    <Gerrit-ApiType>plugin</Gerrit-ApiType>
-    <Gerrit-ApiVersion>${gerritApiVersion}</Gerrit-ApiVersion>
-  </properties>
-
-  <build>
-    <plugins>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-jar-plugin</artifactId>
-        <version>2.4</version>
-        <configuration>
-          <includes>
-            <include>**/*.*</include>
-          </includes>
-          <archive>
-            <manifestEntries>
-              <Gerrit-PluginName>${pluginName}</Gerrit-PluginName>
-              <Gerrit-Module>${package}.Module</Gerrit-Module>
-              <Gerrit-HttpModule>${package}.HttpModule</Gerrit-HttpModule>
-              <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-
-              <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
-              <Implementation-Version>${project.version}</Implementation-Version>
-
-              <Gerrit-ApiType>${Gerrit-ApiType}</Gerrit-ApiType>
-              <Gerrit-ApiVersion>${Gerrit-ApiVersion}</Gerrit-ApiVersion>
-            </manifestEntries>
-          </archive>
-        </configuration>
-      </plugin>
-
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-compiler-plugin</artifactId>
-        <version>2.3.2</version>
-        <configuration>
-          <source>1.7</source>
-          <target>1.7</target>
-          <encoding>UTF-8</encoding>
-        </configuration>
-      </plugin>
-
-      <plugin>
-        <groupId>org.codehaus.mojo</groupId>
-        <artifactId>gwt-maven-plugin</artifactId>
-        <version>${Gwt-Version}</version>
-        <configuration>
-          <module>${package}.HelloPlugin</module>
-          <disableClassMetadata>true</disableClassMetadata>
-          <disableCastChecking>true</disableCastChecking>
-          <webappDirectory>${project.build.directory}/classes/static</webappDirectory>
-        </configuration>
-        <executions>
-          <execution>
-            <goals>
-              <goal>compile</goal>
-            </goals>
-          </execution>
-        </executions>
-      </plugin>
-
-    </plugins>
-  </build>
-
-  <dependencies>
-    <dependency>
-      <groupId>com.google.gerrit</groupId>
-      <artifactId>gerrit-${Gerrit-ApiType}-api</artifactId>
-      <version>${Gerrit-ApiVersion}</version>
-      <scope>provided</scope>
-    </dependency>
-
-    <dependency>
-      <groupId>com.google.gerrit</groupId>
-      <artifactId>gerrit-plugin-gwtui</artifactId>
-      <version>${Gerrit-ApiVersion}</version>
-    </dependency>
-
-    <dependency>
-      <groupId>com.google.gwt</groupId>
-      <artifactId>gwt-user</artifactId>
-      <version>${Gwt-Version}</version>
-      <scope>provided</scope>
-    </dependency>
-  </dependencies>
-#if ($gerritApiVersion.endsWith("SNAPSHOT"))
-
-  <repositories>
-    <repository>
-      <id>snapshot-repository</id>
-      <url>https://oss.sonatype.org/content/repositories/snapshots/</url>
-    </repository>
-  </repositories>
-#end
-</project>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HelloMenu.java b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HelloMenu.java
deleted file mode 100644
index d2d9d80..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HelloMenu.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package};
-
-import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.gerrit.extensions.webui.TopMenu;
-import com.google.inject.Inject;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-public class HelloMenu implements TopMenu {
-  private final List<MenuEntry> menuEntries;
-
-  @Inject
-  public HelloMenu(@PluginName String pluginName) {
-    menuEntries = new ArrayList<>();
-    menuEntries.add(new MenuEntry("Hello", Collections
-        .singletonList(new MenuItem("Hello Screen", "#/x/" + pluginName, ""))));
-  }
-
-  @Override
-  public List<MenuEntry> getEntries() {
-    return menuEntries;
-  }
-}
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HelloPlugin.gwt.xml b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HelloPlugin.gwt.xml
deleted file mode 100644
index 1f6f81e..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/HelloPlugin.gwt.xml
+++ /dev/null
@@ -1,29 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
- 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.
--->
-<module rename-to="hello_gwt_plugin">
-  <!-- Inherit the core Web Toolkit stuff.                        -->
-  <inherits name="com.google.gwt.user.User"/>
-  <!-- Other module inherits                                      -->
-  <inherits name="com.google.gerrit.Plugin"/>
-  <inherits name="com.google.gwt.http.HTTP"/>
-  <!-- Using GWT built-in themes adds a number of static          -->
-  <!-- resources to the plugin. No theme inherits lines were      -->
-  <!-- added in order to make this plugin as simple as possible   -->
-  <!-- Specify the app entry point class.                         -->
-  <entry-point class="${package}.client.HelloPlugin"/>
-  <stylesheet src="hello.css"/>
-</module>
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/Module.java b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/Module.java
deleted file mode 100644
index 73e5695..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/Module.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package};
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.webui.GwtPlugin;
-import com.google.gerrit.extensions.webui.TopMenu;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.inject.AbstractModule;
-
-public class Module extends AbstractModule {
-
-  @Override
-  protected void configure() {
-    DynamicSet.bind(binder(), TopMenu.class).to(HelloMenu.class);
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new GwtPlugin("hello_gwt_plugin"));
-  }
-}
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/client/HelloPlugin.java b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/client/HelloPlugin.java
deleted file mode 100644
index 4a7e149..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/client/HelloPlugin.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package}.client;
-
-import com.google.gerrit.plugin.client.Plugin;
-import com.google.gerrit.plugin.client.PluginEntryPoint;
-
-import ${package}.client.HelloScreen;
-
-/**
- * HelloWorld Plugin.
- */
-public class HelloPlugin extends PluginEntryPoint {
-
-  @Override
-  public void onPluginLoad() {
-    Plugin.get().screen("", new HelloScreen.Factory());
-  }
-}
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/client/HelloScreen.java b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/client/HelloScreen.java
deleted file mode 100644
index 09b8b92..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/client/HelloScreen.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package}.client;
-
-import com.google.gerrit.plugin.client.screen.Screen;
-import com.google.gwt.user.client.ui.Label;
-import com.google.gwt.user.client.ui.VerticalPanel;
-
-public class HelloScreen extends VerticalPanel {
-
-  static class Factory implements Screen.EntryPoint {
-    @Override
-    public void onLoad(Screen screen) {
-      screen.setPageTitle("Hello");
-      screen.show(new HelloScreen());
-    }
-  }
-
-  HelloScreen() {
-    setStyleName("hello-panel");
-    add(new Label("Hello World Screen"));
-  }
-}
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/public/hello.css b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/public/hello.css
deleted file mode 100644
index 72cf023..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/java/public/hello.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.hello-panel {
-  border-spacing: 0px 5px;
-}
diff --git a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/build.md b/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/build.md
deleted file mode 100644
index e225bab..0000000
--- a/gerrit-plugin-gwt-archetype/src/main/resources/archetype-resources/src/main/resources/Documentation/build.md
+++ /dev/null
@@ -1,81 +0,0 @@
-Build
-=====
-
-This plugin can be built with Buck or Maven.
-
-Buck
-----
-
-Two build modes are supported: Standalone and in Gerrit tree.
-The standalone build mode is recommended, as this mode doesn't require
-the Gerrit tree to exist locally.
-
-
-
-Clone bucklets library:
-
-```
-  git clone https://gerrit.googlesource.com/bucklets
-
-```
-and link it to @PLUGIN@ plugin directory:
-
-```
-  cd @PLUGIN@ && ln -s ../bucklets .
-```
-
-Add link to the .buckversion file:
-
-```
-  cd @PLUGIN@ && ln -s bucklets/buckversion .buckversion
-```
-
-Add link to the .watchmanconfig file:
-```
-  cd @PLUGIN@ && ln -s bucklets/watchmanconfig .watchmanconfig
-```
-
-To build the plugin, issue the following command:
-
-```
-  buck build plugin
-```
-
-The output is created in
-
-```
-  buck-out/gen/@PLUGIN@.jar
-```
-
-
-Clone or link this plugin to the plugins directory of Gerrit's source
-tree, and issue the command:
-
-```
-  buck build plugins/@PLUGIN@
-```
-
-The output is created in
-
-```
-  buck-out/gen/plugins/@PLUGIN@/@PLUGIN@.jar
-```
-
-This project can be imported into the Eclipse IDE:
-
-```
-  ./tools/eclipse/project.py
-```
-
-Maven
------
-
-Note that the Maven build is provided for compatibility reasons, but
-it is considered to be deprecated and will be removed in a future
-version of this plugin.
-
-To build with Maven, run
-
-```
-mvn clean package
-```
diff --git a/gerrit-plugin-gwtui/BUCK b/gerrit-plugin-gwtui/BUCK
deleted file mode 100644
index 2ee0e19..0000000
--- a/gerrit-plugin-gwtui/BUCK
+++ /dev/null
@@ -1,65 +0,0 @@
-COMMON = ['gerrit-gwtui-common/src/main/java/']
-GWTEXPUI = ['gerrit-gwtexpui/src/main/java/']
-SRC = 'src/main/java/com/google/gerrit/'
-SRCS = glob([SRC + '**/*.java'])
-
-DEPS = ['//lib/gwt:user']
-
-java_binary(
-  name = 'gwtui-api',
-  deps = [
-    ':gwtui-api-lib',
-    '//gerrit-gwtui-common:client-lib',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'gwtui-api-lib',
-  srcs = SRCS,
-  resources = glob(['src/main/**/*']),
-  exported_deps = ['//gerrit-gwtui-common:client-lib'],
-  provided_deps = DEPS + ['//lib/gwt:dev'],
-  visibility = ['PUBLIC'],
-)
-
-java_binary(
-  name = 'gwtui-api-src',
-  deps = [
-    ':gwtui-api-src-lib',
-    '//gerrit-gwtexpui:client-src-lib',
-    '//gerrit-gwtui-common:client-src-lib',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'gwtui-api-src-lib',
-  srcs = [],
-  resources = glob(['src/main/**/*']),
-  visibility = ['PUBLIC'],
-)
-
-java_doc(
-  name = 'gwtui-api-javadoc',
-  title = 'Gerrit Review GWT Extension API Documentation',
-  pkgs = [
-    'com.google.gerrit',
-    'com.google.gwtexpui.clippy',
-    'com.google.gwtexpui.globalkey',
-    'com.google.gwtexpui.safehtml',
-    'com.google.gwtexpui.user',
-  ],
-  paths = COMMON + GWTEXPUI,
-  srcs = SRCS,
-  deps = DEPS + [
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm_client',
-    '//lib/gwt:dev',
-    '//gerrit-gwtui-common:client-lib',
-    '//gerrit-common:client',
-    '//gerrit-reviewdb:client',
-  ],
-  visibility = ['PUBLIC'],
-  do_it_wrong = True,
-)
diff --git a/gerrit-plugin-gwtui/BUILD b/gerrit-plugin-gwtui/BUILD
new file mode 100644
index 0000000..990689f
--- /dev/null
+++ b/gerrit-plugin-gwtui/BUILD
@@ -0,0 +1,80 @@
+load("@rules_java//java:defs.bzl", "java_binary")
+load("//tools/bzl:java.bzl", "java_library2")
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+package(default_visibility = ["//visibility:public"])
+
+SRCS = glob(["src/main/java/com/google/gerrit/**/*.java"])
+
+DEPS = ["//lib/gwt:user-neverlink"]
+
+java_binary(
+    name = "gwtui-api",
+    main_class = "Dummy",
+    runtime_deps = [
+        ":gwtui-api-lib",
+        "//gerrit-gwtui-common:client-lib",
+    ],
+)
+
+java_library2(
+    name = "gwtui-api-lib",
+    srcs = SRCS,
+    exported_deps = ["//gerrit-gwtui-common:client-lib"],
+    resources = glob(["src/main/**/*"]),
+    deps = DEPS + [
+        "//gerrit-common:libclient-src.jar",
+        "//gerrit-extension-api:libclient-src.jar",
+        "//gerrit-gwtexpui:libClippy-src.jar",
+        "//gerrit-gwtexpui:libGlobalKey-src.jar",
+        "//gerrit-gwtexpui:libProgress-src.jar",
+        "//gerrit-gwtexpui:libSafeHtml-src.jar",
+        "//gerrit-gwtexpui:libUserAgent-src.jar",
+        "//gerrit-gwtui-common:libclient-src.jar",
+        "//gerrit-patch-jgit:libclient-src.jar",
+        "//gerrit-patch-jgit:libEdit-src.jar",
+        "//gerrit-prettify:libclient-src.jar",
+        "//gerrit-reviewdb:libclient-src.jar",
+        "//lib/gwt:dev-neverlink",
+    ],
+)
+
+java_library2(
+    name = "gwtui-api-lib-neverlink",
+    srcs = SRCS,
+    exported_deps = ["//gerrit-gwtui-common:client-lib"],
+    neverlink = 1,  # we want this to be exported deps
+    resources = glob(["src/main/**/*"]),
+    deps = DEPS + ["//lib/gwt:dev"],
+)
+
+java_binary(
+    name = "gwtui-api-source",
+    main_class = "Dummy",
+    runtime_deps = [
+        ":libgwtui-api-lib-src.jar",
+        "//gerrit-gwtexpui:client-src-lib",
+        "//gerrit-gwtui-common:libclient-lib-src.jar",
+    ],
+)
+
+java_doc(
+    name = "gwtui-api-javadoc",
+    libs = DEPS + [
+        ":gwtui-api-lib",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm-client",
+        "//lib/gwt:dev",
+        "//gerrit-gwtui-common:client-lib",
+        "//gerrit-common:client",
+        "//gerrit-reviewdb:client",
+    ],
+    pkgs = [
+        "com.google.gerrit.plugin",
+        "com.google.gwtexpui.clippy",
+        "com.google.gwtexpui.globalkey",
+        "com.google.gwtexpui.safehtml",
+        "com.google.gwtexpui.user",
+    ],
+    title = "Gerrit Review GWT Extension API Documentation",
+)
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index 5c412fd..93b74e9 100644
--- a/gerrit-plugin-gwtui/pom.xml
+++ b/gerrit-plugin-gwtui/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.13.14</version>
+  <version>2.14.23-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin GWT UI</name>
   <description>Common Classes for Gerrit GWT UI Plugins</description>
@@ -23,7 +23,10 @@
 
   <developers>
     <developer>
-      <name>Andrew Bonventre</name>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -38,16 +41,28 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
-      <name>Saša Živkov</name>
+      <name>Ole Rehmsen</name>
     </developer>
     <developer>
-      <name>Shawn Pearce</name>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
     </developer>
   </developers>
 
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/FormatUtil.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/FormatUtil.java
index e5e8c62..85bc9c3 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/FormatUtil.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/FormatUtil.java
@@ -18,13 +18,11 @@
 import com.google.gerrit.client.DateFormatter;
 import com.google.gerrit.client.RelativeDateFormatter;
 import com.google.gerrit.client.info.AccountInfo;
-
 import java.util.Date;
 
 public class FormatUtil {
   private static final AccountFormatter accountFormatter =
-      new AccountFormatter(Plugin.get().getServerInfo().user()
-          .anonymousCowardName());
+      new AccountFormatter(Plugin.get().getServerInfo().user().anonymousCowardName());
 
   /** Format a date using a really short format. */
   public static String shortFormat(Date dt) {
@@ -52,13 +50,14 @@
 
   /**
    * Formats an account as a name and an email address.
-   * <p>
-   * Example output:
+   *
+   * <p>Example output:
+   *
    * <ul>
-   * <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated</li>
-   * <li>{@code A U. Thor (12)}: missing email address</li>
-   * <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name</li>
-   * <li>{@code Anonymous Coward (12)}: missing name and email address</li>
+   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
+   *   <li>{@code A U. Thor (12)}: missing email address
+   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
+   *   <li>{@code Anonymous Coward (12)}: missing name and email address
    * </ul>
    */
   public static String nameEmail(AccountInfo info) {
@@ -67,9 +66,9 @@
 
   /**
    * Formats an account name.
-   * <p>
-   * If the account has a full name, it returns only the full name. Otherwise it
-   * returns a longer form that includes the email address.
+   *
+   * <p>If the account has a full name, it returns only the full name. Otherwise it returns a longer
+   * form that includes the email address.
    */
   public static String name(AccountInfo info) {
     return accountFormatter.name(info);
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
index 14e3155..7c478c1 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
@@ -26,12 +26,11 @@
 /**
  * Wrapper around the plugin instance exposed by Gerrit.
  *
- * Listeners for events generated by the main UI must be registered
- * through this instance.
+ * <p>Listeners for events generated by the main UI must be registered through this instance.
  */
 public final class Plugin extends JavaScriptObject {
-  private static final Plugin self = install(
-      GWT.getModuleBaseURL() + GWT.getModuleName() + ".nocache.js");
+  private static final Plugin self =
+      install(GWT.getModuleBaseURL() + GWT.getModuleName() + ".nocache.js");
 
   /** Obtain the plugin instance wrapper. */
   public static Plugin get() {
@@ -44,65 +43,57 @@
   }
 
   /** Installed name of the plugin. */
-  public native String getPluginName()
-  /*-{ return this.getPluginName() }-*/;
+  public native String getPluginName() /*-{ return this.getPluginName() }-*/;
 
   /** Navigate the UI to the screen identified by the token. */
-  public native void go(String token)
-  /*-{ return this.go(token) }-*/;
+  public native void go(String token) /*-{ return this.go(token) }-*/;
 
   /** Refresh the current UI. */
-  public native void refresh()
-  /*-{ return this.refresh() }-*/;
+  public native void refresh() /*-{ return this.refresh() }-*/;
 
   /** Refresh Gerrit's menu bar. */
-  public native void refreshMenuBar()
-  /*-{ return this.refreshMenuBar() }-*/;
+  public native void refreshMenuBar() /*-{ return this.refreshMenuBar() }-*/;
 
-  /** @return the preferences of the currently signed in user, the default preferences if not signed in */
-  public native GeneralPreferences getUserPreferences()
-  /*-{ return this.getUserPreferences() }-*/;
+  /**
+   * @return the preferences of the currently signed in user, the default preferences if not signed
+   *     in
+   */
+  public native GeneralPreferences getUserPreferences() /*-{ return this.getUserPreferences() }-*/;
 
   /** Refresh the user preferences of the current user. */
-  public native void refreshUserPreferences()
-  /*-{ return this.refreshUserPreferences() }-*/;
+  public native void refreshUserPreferences() /*-{ return this.refreshUserPreferences() }-*/;
 
   /** @return the server info */
-  public native ServerInfo getServerInfo()
-  /*-{ return this.getServerInfo() }-*/;
+  public native ServerInfo getServerInfo() /*-{ return this.getServerInfo() }-*/;
 
   /** @return the current user */
-  public native AccountInfo getCurrentUser()
-  /*-{ return this.getCurrentUser() }-*/;
+  public native AccountInfo getCurrentUser() /*-{ return this.getCurrentUser() }-*/;
 
   /** Check if user is signed in. */
-  public native boolean isSignedIn()
-  /*-{ return this.isSignedIn() }-*/;
+  public native boolean isSignedIn() /*-{ return this.isSignedIn() }-*/;
 
   /** Show message in Gerrit's ErrorDialog. */
-  public native void showError(String message)
-  /*-{ return this.showError(message) }-*/;
+  public native void showError(String message) /*-{ return this.showError(message) }-*/;
 
   /**
    * Register a screen displayed at {@code /#/x/plugin/token}.
    *
-   * @param token literal anchor token appearing after the plugin name. For
-   *        regular expression matching use {@code screenRegex()} .
+   * @param token literal anchor token appearing after the plugin name. For regular expression
+   *     matching use {@code screenRegex()} .
    * @param entry callback function invoked to create the screen widgets.
    */
   public void screen(String token, Screen.EntryPoint entry) {
     screen(token, wrap(entry));
   }
 
-  private native void screen(String t, JavaScriptObject e)
-  /*-{ this.screen(t, e) }-*/;
+  private native void screen(String t, JavaScriptObject e) /*-{ this.screen(t, e) }-*/;
 
   /**
    * Register a screen displayed at {@code /#/x/plugin/regex}.
    *
-   * @param regex JavaScript {@code RegExp} expression to match the anchor token
-   *        after the plugin name. Matching groups are exposed through the
-   *        {@code Screen} object passed into the {@code Screen.EntryPoint}.
+   * @param regex JavaScript {@code RegExp} expression to match the anchor token after the plugin
+   *     name. Matching groups are exposed through the {@code Screen} object passed into the {@code
+   *     Screen.EntryPoint}.
    * @param entry callback function invoked to create the screen widgets.
    */
   public void screenRegex(String regex, Screen.EntryPoint entry) {
@@ -110,7 +101,7 @@
   }
 
   private native void screenRegex(String p, JavaScriptObject e)
-  /*-{ this.screen(new $wnd.RegExp(p), e) }-*/;
+      /*-{ this.screen(new $wnd.RegExp(p), e) }-*/ ;
 
   /**
    * Register a settings screen displayed at {@code /#/settings/x/plugin/token}.
@@ -123,29 +114,27 @@
   }
 
   private native void settingsScreen(String t, String m, JavaScriptObject e)
-  /*-{ this.settingsScreen(t, m, e) }-*/;
+      /*-{ this.settingsScreen(t, m, e) }-*/ ;
 
   /**
    * Register a panel for a UI extension point.
    *
-   * @param extensionPoint the UI extension point for which the panel should be
-   *        registered.
+   * @param extensionPoint the UI extension point for which the panel should be registered.
    * @param entry callback function invoked to create the panel widgets.
    */
   public void panel(GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry) {
     panel(extensionPoint.name(), wrap(entry));
   }
 
-  private native void panel(String i, JavaScriptObject e)
-  /*-{ this.panel(i, e) }-*/;
+  private native void panel(String i, JavaScriptObject e) /*-{ this.panel(i, e) }-*/;
 
-  protected Plugin() {
-  }
+  protected Plugin() {}
 
   native void _initialized() /*-{ this._success = true }-*/;
+
   native void _loaded() /*-{ this._loadedGwt() }-*/;
-  private static native Plugin install(String u)
-  /*-{ return $wnd.Gerrit.installGwt(u) }-*/;
+
+  private static native Plugin install(String u) /*-{ return $wnd.Gerrit.installGwt(u) }-*/;
 
   private static native JavaScriptObject wrap(Screen.EntryPoint b) /*-{
     return $entry(function(c){
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/PluginEntryPoint.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/PluginEntryPoint.java
index 0f8a16a..808cda3 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/PluginEntryPoint.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/PluginEntryPoint.java
@@ -19,16 +19,17 @@
 /**
  * Base class for writing Gerrit Web UI plugins
  *
- * Writing a plugin:
+ * <p>Writing a plugin:
+ *
  * <ol>
- * <li>Declare subtype of Plugin</li>
- * <li>Bind WebUiPlugin to GwtPlugin implementation in Gerrit-Module</li>
+ *   <li>Declare subtype of Plugin
+ *   <li>Bind WebUiPlugin to GwtPlugin implementation in Gerrit-Module
  * </ol>
  */
 public abstract class PluginEntryPoint implements EntryPoint {
   /**
-   * The plugin entry point method, called automatically by loading
-   * a module that declares an implementing class as an entry point.
+   * The plugin entry point method, called automatically by loading a module that declares an
+   * implementing class as an entry point.
    */
   public abstract void onPluginLoad();
 
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
index 0200a14..8ee6d0e 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/extension/Panel.java
@@ -22,7 +22,7 @@
 /**
  * Panel that extends a Gerrit core screen contributed by this plugin.
  *
- * Panel should be registered early at module load:
+ * <p>Panel should be registered early at module load:
  *
  * <pre>
  * &#064;Override
@@ -42,17 +42,16 @@
   public interface EntryPoint {
     /**
      * Invoked when the panel has been created.
-     * <p>
-     * The implementation should create a single widget to define the content of
-     * this panel and add it to the passed panel instance.
-     * <p>
-     * To use multiple widgets, compose them in panels such as {@code FlowPanel}
-     * and add only the top level widget to the panel.
-     * <p>
-     * The panel is already attached to the browser DOM.
-     * Any widgets added to the screen will immediately receive {@code onLoad()}.
-     * GWT will fire {@code onUnload()} when the panel is removed from the UI,
-     * generally caused by the user navigating to another screen.
+     *
+     * <p>The implementation should create a single widget to define the content of this panel and
+     * add it to the passed panel instance.
+     *
+     * <p>To use multiple widgets, compose them in panels such as {@code FlowPanel} and add only the
+     * top level widget to the panel.
+     *
+     * <p>The panel is already attached to the browser DOM. Any widgets added to the screen will
+     * immediately receive {@code onLoad()}. GWT will fire {@code onUnload()} when the panel is
+     * removed from the UI, generally caused by the user navigating to another screen.
      *
      * @param panel panel that will contain the panel widget.
      */
@@ -63,15 +62,16 @@
     native Element body() /*-{ return this.body }-*/;
 
     native String get(String k) /*-{ return this.p[k]; }-*/;
+
     native int getInt(String k, int d) /*-{
       return this.p.hasOwnProperty(k) ? this.p[k] : d
     }-*/;
+
     native int getBoolean(String k, boolean d) /*-{
       return this.p.hasOwnProperty(k) ? this.p[k] : d
     }-*/;
-    native JavaScriptObject getObject(String k)
-    /*-{ return this.p[k]; }-*/;
 
+    native JavaScriptObject getObject(String k) /*-{ return this.p[k]; }-*/;
 
     native void detach(Panel p) /*-{
       this.onUnload($entry(function(){
@@ -79,8 +79,7 @@
       }));
     }-*/;
 
-    protected Context() {
-    }
+    protected Context() {}
   }
 
   private final Context ctx;
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/NoContent.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/NoContent.java
index 55744d5..9ff4fed 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/NoContent.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/NoContent.java
@@ -17,6 +17,5 @@
 import com.google.gwt.core.client.JavaScriptObject;
 
 public class NoContent extends JavaScriptObject {
-  protected NoContent() {
-  }
+  protected NoContent() {}
 }
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java
index d627959..86791f8 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/rpc/RestApi.java
@@ -100,8 +100,7 @@
     return path.toString();
   }
 
-  public <T extends JavaScriptObject>
-  void get(AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void get(AsyncCallback<T> cb) {
     get(path(), wrap(cb));
   }
 
@@ -109,62 +108,51 @@
     get(NativeString.unwrap(cb));
   }
 
-  private static native void get(String p, JavaScriptObject r)
-  /*-{ $wnd.Gerrit.get_raw(p, r) }-*/;
+  private static native void get(String p, JavaScriptObject r) /*-{ $wnd.Gerrit.get_raw(p, r) }-*/;
 
-  public <T extends JavaScriptObject>
-  void put(AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void put(AsyncCallback<T> cb) {
     put(path(), wrap(cb));
   }
 
-  private static native void put(String p, JavaScriptObject r)
-  /*-{ $wnd.Gerrit.put_raw(p, r) }-*/;
+  private static native void put(String p, JavaScriptObject r) /*-{ $wnd.Gerrit.put_raw(p, r) }-*/;
 
-  public <T extends JavaScriptObject>
-  void put(String content, AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void put(String content, AsyncCallback<T> cb) {
     put(path(), content, wrap(cb));
   }
 
-  private static native
-  void put(String p, String c, JavaScriptObject r)
-  /*-{ $wnd.Gerrit.put_raw(p, c, r) }-*/;
+  private static native void put(String p, String c, JavaScriptObject r)
+      /*-{ $wnd.Gerrit.put_raw(p, c, r) }-*/ ;
 
-  public <T extends JavaScriptObject>
-  void put(JavaScriptObject content, AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void put(JavaScriptObject content, AsyncCallback<T> cb) {
     put(path(), content, wrap(cb));
   }
 
-  private static native
-  void put(String p, JavaScriptObject c, JavaScriptObject r)
-  /*-{ $wnd.Gerrit.put_raw(p, c, r) }-*/;
+  private static native void put(String p, JavaScriptObject c, JavaScriptObject r)
+      /*-{ $wnd.Gerrit.put_raw(p, c, r) }-*/ ;
 
-  public <T extends JavaScriptObject>
-  void post(String content, AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void post(String content, AsyncCallback<T> cb) {
     post(path(), content, wrap(cb));
   }
 
-  private static native
-  void post(String p, String c, JavaScriptObject r)
-  /*-{ $wnd.Gerrit.post_raw(p, c, r) }-*/;
+  private static native void post(String p, String c, JavaScriptObject r)
+      /*-{ $wnd.Gerrit.post_raw(p, c, r) }-*/ ;
 
-  public <T extends JavaScriptObject>
-  void post(JavaScriptObject content, AsyncCallback<T> cb) {
+  public <T extends JavaScriptObject> void post(JavaScriptObject content, AsyncCallback<T> cb) {
     post(path(), content, wrap(cb));
   }
 
-  private static native
-  void post(String p, JavaScriptObject c, JavaScriptObject r)
-  /*-{ $wnd.Gerrit.post_raw(p, c, r) }-*/;
+  private static native void post(String p, JavaScriptObject c, JavaScriptObject r)
+      /*-{ $wnd.Gerrit.post_raw(p, c, r) }-*/ ;
 
   public void delete(AsyncCallback<NoContent> cb) {
     delete(path(), wrap(cb));
   }
 
   private static native void delete(String p, JavaScriptObject r)
-  /*-{ $wnd.Gerrit.del_raw(p, r) }-*/;
+      /*-{ $wnd.Gerrit.del_raw(p, r) }-*/ ;
 
-  private static native <T extends JavaScriptObject>
-  JavaScriptObject wrap(AsyncCallback<T> b) /*-{
+  private static native <T extends JavaScriptObject> JavaScriptObject wrap(
+      AsyncCallback<T> b) /*-{
     return function(r) {
       b.@com.google.gwt.user.client.rpc.AsyncCallback::onSuccess(Ljava/lang/Object;)(r)
     }
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
index 5e0ba4a..226ac48 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/screen/Screen.java
@@ -23,7 +23,7 @@
 /**
  * Screen contributed by this plugin.
  *
- * Screens should be registered early at module load:
+ * <p>Screens should be registered early at module load:
  *
  * <pre>
  * &#064;Override
@@ -43,18 +43,18 @@
   public interface EntryPoint {
     /**
      * Invoked when the screen has been created, but not yet displayed.
-     * <p>
-     * The implementation should create a single widget to define the content of
-     * this screen and added it to the passed screen instance. When the screen
-     * is ready to be displayed, call {@link Screen#show()}.
-     * <p>
-     * To use multiple widgets, compose them in panels such as {@code FlowPanel}
-     * and add only the top level widget to the screen.
-     * <p>
-     * The screen is already attached to the browser DOM in an invisible area.
-     * Any widgets added to the screen will immediately receive {@code onLoad()}.
-     * GWT will fire {@code onUnload()} when the screen is removed from the UI,
-     * generally caused by the user navigating to another screen.
+     *
+     * <p>The implementation should create a single widget to define the content of this screen and
+     * added it to the passed screen instance. When the screen is ready to be displayed, call {@link
+     * Screen#show()}.
+     *
+     * <p>To use multiple widgets, compose them in panels such as {@code FlowPanel} and add only the
+     * top level widget to the screen.
+     *
+     * <p>The screen is already attached to the browser DOM in an invisible area. Any widgets added
+     * to the screen will immediately receive {@code onLoad()}. GWT will fire {@code onUnload()}
+     * when the screen is removed from the UI, generally caused by the user navigating to another
+     * screen.
      *
      * @param screen panel that will contain the screen widget.
      */
@@ -63,18 +63,22 @@
 
   static final class Context extends JavaScriptObject {
     native Element body() /*-{ return this.body }-*/;
+
     native JsArrayString token_match() /*-{ return this.token_match }-*/;
+
     native void show() /*-{ this.show() }-*/;
+
     native void setTitle(String t) /*-{ this.setTitle(t) }-*/;
+
     native void setWindowTitle(String t) /*-{ this.setWindowTitle(t) }-*/;
+
     native void detach(Screen s) /*-{
       this.onUnload($entry(function(){
         s.@com.google.gwt.user.client.ui.Widget::onDetach()();
       }));
     }-*/;
 
-    protected Context() {
-    }
+    protected Context() {}
   }
 
   private final Context ctx;
@@ -92,8 +96,8 @@
   }
 
   /**
-   * @param group groups range from 1 to {@code getTokenGroups() - 1}. Token
-   *        group 0 is the entire token, see {@link #getToken()}.
+   * @param group groups range from 1 to {@code getTokenGroups() - 1}. Token group 0 is the entire
+   *     token, see {@link #getToken()}.
    * @return the token from the regex match group.
    */
   public String getToken(int group) {
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java
index 528b07a..13e19ae 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java
@@ -20,7 +20,6 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.SuggestOracle;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -30,9 +29,7 @@
 
   private final int chars;
 
-  /**
-   * @param chars minimum chars to start suggesting.
-   */
+  /** @param chars minimum chars to start suggesting. */
   public GroupSuggestOracle(int chars) {
     this.chars = chars;
   }
@@ -52,22 +49,23 @@
     if (req.getLimit() > 0) {
       rest.addParameter("n", req.getLimit());
     }
-    rest.get(new AsyncCallback<NativeMap<JavaScriptObject>>() {
-      @Override
-      public void onSuccess(NativeMap<JavaScriptObject> result) {
-        List<String> keys = result.sortedKeys();
-        List<Suggestion> suggestions = new ArrayList<>(keys.size());
-        for (String g : keys) {
-          suggestions.add(new HighlightSuggestion(req.getQuery(), g));
-        }
-        done.onSuggestionsReady(req, new Response(suggestions));
-      }
+    rest.get(
+        new AsyncCallback<NativeMap<JavaScriptObject>>() {
+          @Override
+          public void onSuccess(NativeMap<JavaScriptObject> result) {
+            List<String> keys = result.sortedKeys();
+            List<Suggestion> suggestions = new ArrayList<>(keys.size());
+            for (String g : keys) {
+              suggestions.add(new HighlightSuggestion(req.getQuery(), g));
+            }
+            done.onSuggestionsReady(req, new Response(suggestions));
+          }
 
-      @Override
-      public void onFailure(Throwable caught) {
-        responseEmptySuggestion(req, done);
-      }
-    });
+          @Override
+          public void onFailure(Throwable caught) {
+            responseEmptySuggestion(req, done);
+          }
+        });
   }
 
   private static void responseEmptySuggestion(Request req, Callback done) {
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/rebind/PluginGenerator.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/rebind/PluginGenerator.java
index 89bb026..ba14556 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/rebind/PluginGenerator.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/rebind/PluginGenerator.java
@@ -24,19 +24,18 @@
 import com.google.gwt.core.ext.typeinfo.TypeOracle;
 import com.google.gwt.user.rebind.ClassSourceFileComposerFactory;
 import com.google.gwt.user.rebind.SourceWriter;
-
 import java.io.PrintWriter;
 
 /**
- * Write the top layer in the Gadget bootstrap sandwich and generate a stub
- * manifest that will be completed by the linker.
+ * Write the top layer in the Gadget bootstrap sandwich and generate a stub manifest that will be
+ * completed by the linker.
  *
- * Based on gwt-gadgets GadgetGenerator class
+ * <p>Based on gwt-gadgets GadgetGenerator class
  */
 public class PluginGenerator extends Generator {
   @Override
-  public String generate(TreeLogger logger, GeneratorContext context,
-      String typeName) throws UnableToCompleteException {
+  public String generate(TreeLogger logger, GeneratorContext context, String typeName)
+      throws UnableToCompleteException {
 
     // The TypeOracle knows about all types in the type system
     TypeOracle typeOracle = context.getTypeOracle();
@@ -54,18 +53,18 @@
     validateType(logger, sourceType);
 
     // Pick a name for the generated class to not conflict.
-    String generatedSimpleSourceName = sourceType.getSimpleSourceName()
-        + "PluginImpl";
+    String generatedSimpleSourceName = sourceType.getSimpleSourceName() + "PluginImpl";
 
     // Begin writing the generated source.
-    ClassSourceFileComposerFactory f = new ClassSourceFileComposerFactory(
-        sourceType.getPackage().getName(), generatedSimpleSourceName);
+    ClassSourceFileComposerFactory f =
+        new ClassSourceFileComposerFactory(
+            sourceType.getPackage().getName(), generatedSimpleSourceName);
     f.addImport(GWT.class.getName());
     f.setSuperclass(typeName);
 
     // All source gets written through this Writer
-    PrintWriter out = context.tryCreate(logger,
-        sourceType.getPackage().getName(), generatedSimpleSourceName);
+    PrintWriter out =
+        context.tryCreate(logger, sourceType.getPackage().getName(), generatedSimpleSourceName);
 
     // If an implementation already exists, we don't need to do any work
     if (out != null) {
@@ -78,11 +77,9 @@
     return f.getCreatedClassName();
   }
 
-  protected void validateType(TreeLogger logger, JClassType type)
-      throws UnableToCompleteException {
+  protected void validateType(TreeLogger logger, JClassType type) throws UnableToCompleteException {
     if (!type.isDefaultInstantiable()) {
-      logger.log(TreeLogger.ERROR, "Plugin types must be default instantiable",
-          null);
+      logger.log(TreeLogger.ERROR, "Plugin types must be default instantiable", null);
       throw new UnableToCompleteException();
     }
   }
diff --git a/gerrit-plugin-js-archetype/.gitignore b/gerrit-plugin-js-archetype/.gitignore
deleted file mode 100644
index 7075a2f..0000000
--- a/gerrit-plugin-js-archetype/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-/target
-/.classpath
-/.project
-/.settings
diff --git a/gerrit-plugin-js-archetype/pom.xml b/gerrit-plugin-js-archetype/pom.xml
deleted file mode 100644
index de6e1d9..0000000
--- a/gerrit-plugin-js-archetype/pom.xml
+++ /dev/null
@@ -1,108 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 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.
--->
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
-  <modelVersion>4.0.0</modelVersion>
-
-  <groupId>com.google.gerrit</groupId>
-  <artifactId>gerrit-plugin-js-archetype</artifactId>
-  <version>2.13.14</version>
-  <name>Gerrit Code Review - Web UI JavaScript Plugin Archetype</name>
-  <description>Maven Archetype for Gerrit Web UI JavaScript Plugins</description>
-  <url>https://www.gerritcodereview.com/</url>
-
-  <properties>
-    <defaultGerritApiVersion>${project.version}</defaultGerritApiVersion>
-  </properties>
-
-  <build>
-    <resources>
-      <resource>
-        <directory>src/main/resources</directory>
-        <filtering>true</filtering>
-        <includes>
-          <include>META-INF/maven/archetype-metadata.xml</include>
-        </includes>
-      </resource>
-      <resource>
-        <directory>src/main/resources</directory>
-        <filtering>false</filtering>
-        <excludes>
-          <exclude>META-INF/maven/archetype-metadata.xml</exclude>
-        </excludes>
-      </resource>
-    </resources>
-  </build>
-
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-
-  <scm>
-    <url>https://gerrit.googlesource.com/gerrit</url>
-    <connection>https://gerrit.googlesource.com/gerrit</connection>
-  </scm>
-
-  <developers>
-    <developer>
-      <name>Andrew Bonventre</name>
-    </developer>
-    <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
-      <name>David Ostrovsky</name>
-    </developer>
-    <developer>
-      <name>David Pursehouse</name>
-    </developer>
-    <developer>
-      <name>Edwin Kempin</name>
-    </developer>
-    <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
-      <name>Martin Fick</name>
-    </developer>
-    <developer>
-      <name>Saša Živkov</name>
-    </developer>
-    <developer>
-      <name>Shawn Pearce</name>
-    </developer>
-  </developers>
-
-  <mailingLists>
-    <mailingList>
-      <name>Repo and Gerrit Discussion</name>
-      <post>repo-discuss@googlegroups.com</post>
-      <subscribe>https://groups.google.com/forum/#!forum/repo-discuss</subscribe>
-      <unsubscribe>https://groups.google.com/forum/#!forum/repo-discuss</unsubscribe>
-      <archive>https://groups.google.com/forum/#!forum/repo-discuss</archive>
-    </mailingList>
-  </mailingLists>
-
-  <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
-    <system>Gerrit Issue Tracker</system>
-  </issueManagement>
-</project>
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
deleted file mode 100644
index ef0e96c..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml
+++ /dev/null
@@ -1,64 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2012 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.
--->
-<archetype-descriptor name="Gerrit Plugin">
-  <requiredProperties>
-    <requiredProperty key="pluginName"/>
-
-    <requiredProperty key="Implementation-Vendor">
-      <defaultValue>Gerrit Code Review</defaultValue>
-    </requiredProperty>
-
-    <requiredProperty key="gerritApiType">
-      <defaultValue>js</defaultValue>
-    </requiredProperty>
-    <requiredProperty key="gerritApiVersion">
-      <defaultValue>${defaultGerritApiVersion}</defaultValue>
-    </requiredProperty>
-  </requiredProperties>
-
-  <fileSets>
-    <fileSet filtered="true" packaged="true">
-      <directory>src/main/java</directory>
-      <includes>
-        <include>**/*.java</include>
-      </includes>
-    </fileSet>
-
-    <fileSet>
-      <directory>src/main/js</directory>
-      <includes>
-        <include>**/*.js</include>
-      </includes>
-    </fileSet>
-
-    <fileSet filtered="true">
-      <directory>src/main/resources/Documentation</directory>
-      <includes>
-        <include>**/*.md</include>
-      </includes>
-    </fileSet>
-
-    <fileSet>
-      <directory></directory>
-      <includes>
-        <include>.gitignore</include>
-        <include>.settings/*</include>
-        <include>LICENSE</include>
-      </includes>
-    </fileSet>
-  </fileSets>
-</archetype-descriptor>
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.gitignore b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.gitignore
deleted file mode 100644
index 80d6257..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-/target
-/.classpath
-/.project
-/.settings/org.maven.ide.eclipse.prefs
-/.settings/org.eclipse.m2e.core.prefs
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs
deleted file mode 100644
index 29abf99..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.resources.prefs
+++ /dev/null
@@ -1,6 +0,0 @@
-eclipse.preferences.version=1
-encoding//src/main/java=UTF-8
-encoding//src/main/resources=UTF-8
-encoding//src/test/java=UTF-8
-encoding//src/test/resources=UTF-8
-encoding/<project>=UTF-8
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs
deleted file mode 100644
index 5a0ad22..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.core.runtime.prefs
+++ /dev/null
@@ -1,2 +0,0 @@
-eclipse.preferences.version=1
-line.separator=\n
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
deleted file mode 100644
index 2a585e4..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.core.prefs
+++ /dev/null
@@ -1,346 +0,0 @@
-eclipse.preferences.version=1
-org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
-org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull
-org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault
-org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable
-org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
-org.eclipse.jdt.core.compiler.compliance=1.7
-org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=ignore
-org.eclipse.jdt.core.compiler.problem.autoboxing=ignore
-org.eclipse.jdt.core.compiler.problem.comparingIdentical=warning
-org.eclipse.jdt.core.compiler.problem.deadCode=warning
-org.eclipse.jdt.core.compiler.problem.deprecation=warning
-org.eclipse.jdt.core.compiler.problem.deprecationInDeprecatedCode=disabled
-org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled
-org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
-org.eclipse.jdt.core.compiler.problem.emptyStatement=ignore
-org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=ignore
-org.eclipse.jdt.core.compiler.problem.fallthroughCase=ignore
-org.eclipse.jdt.core.compiler.problem.fatalOptionalError=disabled
-org.eclipse.jdt.core.compiler.problem.fieldHiding=ignore
-org.eclipse.jdt.core.compiler.problem.finalParameterBound=warning
-org.eclipse.jdt.core.compiler.problem.finallyBlockNotCompletingNormally=warning
-org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
-org.eclipse.jdt.core.compiler.problem.hiddenCatchBlock=warning
-org.eclipse.jdt.core.compiler.problem.includeNullInfoFromAsserts=disabled
-org.eclipse.jdt.core.compiler.problem.incompatibleNonInheritedInterfaceMethod=warning
-org.eclipse.jdt.core.compiler.problem.incompleteEnumSwitch=warning
-org.eclipse.jdt.core.compiler.problem.indirectStaticAccess=ignore
-org.eclipse.jdt.core.compiler.problem.localVariableHiding=ignore
-org.eclipse.jdt.core.compiler.problem.methodWithConstructorName=warning
-org.eclipse.jdt.core.compiler.problem.missingDefaultCase=ignore
-org.eclipse.jdt.core.compiler.problem.missingDeprecatedAnnotation=ignore
-org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=disabled
-org.eclipse.jdt.core.compiler.problem.missingHashCodeMethod=ignore
-org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotation=ignore
-org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotationForInterfaceMethodImplementation=enabled
-org.eclipse.jdt.core.compiler.problem.missingSerialVersion=warning
-org.eclipse.jdt.core.compiler.problem.missingSynchronizedOnInheritedMethod=ignore
-org.eclipse.jdt.core.compiler.problem.noEffectAssignment=warning
-org.eclipse.jdt.core.compiler.problem.noImplicitStringConversion=warning
-org.eclipse.jdt.core.compiler.problem.nonExternalizedStringLiteral=ignore
-org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error
-org.eclipse.jdt.core.compiler.problem.nullReference=warning
-org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error
-org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning
-org.eclipse.jdt.core.compiler.problem.overridingPackageDefaultMethod=warning
-org.eclipse.jdt.core.compiler.problem.parameterAssignment=ignore
-org.eclipse.jdt.core.compiler.problem.possibleAccidentalBooleanAssignment=ignore
-org.eclipse.jdt.core.compiler.problem.potentialNullReference=ignore
-org.eclipse.jdt.core.compiler.problem.potentiallyUnclosedCloseable=ignore
-org.eclipse.jdt.core.compiler.problem.rawTypeReference=warning
-org.eclipse.jdt.core.compiler.problem.redundantNullAnnotation=warning
-org.eclipse.jdt.core.compiler.problem.redundantNullCheck=ignore
-org.eclipse.jdt.core.compiler.problem.redundantSpecificationOfTypeArguments=ignore
-org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=ignore
-org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore
-org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore
-org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled
-org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=warning
-org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled
-org.eclipse.jdt.core.compiler.problem.suppressWarnings=enabled
-org.eclipse.jdt.core.compiler.problem.syntheticAccessEmulation=ignore
-org.eclipse.jdt.core.compiler.problem.typeParameterHiding=warning
-org.eclipse.jdt.core.compiler.problem.unavoidableGenericTypeProblems=enabled
-org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
-org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
-org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
-org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
-org.eclipse.jdt.core.compiler.problem.unnecessaryElse=ignore
-org.eclipse.jdt.core.compiler.problem.unnecessaryTypeCheck=ignore
-org.eclipse.jdt.core.compiler.problem.unqualifiedFieldAccess=ignore
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownException=ignore
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionExemptExceptionAndThrowable=enabled
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionIncludeDocCommentReference=enabled
-org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionWhenOverriding=disabled
-org.eclipse.jdt.core.compiler.problem.unusedImport=warning
-org.eclipse.jdt.core.compiler.problem.unusedLabel=warning
-org.eclipse.jdt.core.compiler.problem.unusedLocal=warning
-org.eclipse.jdt.core.compiler.problem.unusedObjectAllocation=ignore
-org.eclipse.jdt.core.compiler.problem.unusedParameter=ignore
-org.eclipse.jdt.core.compiler.problem.unusedParameterIncludeDocCommentReference=enabled
-org.eclipse.jdt.core.compiler.problem.unusedParameterWhenImplementingAbstract=disabled
-org.eclipse.jdt.core.compiler.problem.unusedParameterWhenOverridingConcrete=disabled
-org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=warning
-org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
-org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
-org.eclipse.jdt.core.compiler.source=1.7
-org.eclipse.jdt.core.formatter.align_type_members_on_columns=false
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation=16
-org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_assignment=16
-org.eclipse.jdt.core.formatter.alignment_for_binary_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_compact_if=16
-org.eclipse.jdt.core.formatter.alignment_for_conditional_expression=16
-org.eclipse.jdt.core.formatter.alignment_for_enum_constants=16
-org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer=16
-org.eclipse.jdt.core.formatter.alignment_for_multiple_fields=16
-org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation=16
-org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration=16
-org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration=16
-org.eclipse.jdt.core.formatter.blank_lines_after_imports=1
-org.eclipse.jdt.core.formatter.blank_lines_after_package=1
-org.eclipse.jdt.core.formatter.blank_lines_before_field=0
-org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration=0
-org.eclipse.jdt.core.formatter.blank_lines_before_imports=0
-org.eclipse.jdt.core.formatter.blank_lines_before_member_type=0
-org.eclipse.jdt.core.formatter.blank_lines_before_method=1
-org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk=1
-org.eclipse.jdt.core.formatter.blank_lines_before_package=0
-org.eclipse.jdt.core.formatter.blank_lines_between_import_groups=1
-org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations=2
-org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_array_initializer=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_block=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_block_in_case=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_enum_constant=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_method_declaration=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_switch=end_of_line
-org.eclipse.jdt.core.formatter.brace_position_for_type_declaration=end_of_line
-org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment=false
-org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment=false
-org.eclipse.jdt.core.formatter.comment.format_block_comments=true
-org.eclipse.jdt.core.formatter.comment.format_header=true
-org.eclipse.jdt.core.formatter.comment.format_html=true
-org.eclipse.jdt.core.formatter.comment.format_javadoc_comments=true
-org.eclipse.jdt.core.formatter.comment.format_line_comments=true
-org.eclipse.jdt.core.formatter.comment.format_source_code=true
-org.eclipse.jdt.core.formatter.comment.indent_parameter_description=false
-org.eclipse.jdt.core.formatter.comment.indent_root_tags=true
-org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags=insert
-org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter=do not insert
-org.eclipse.jdt.core.formatter.comment.line_length=80
-org.eclipse.jdt.core.formatter.compact_else_if=true
-org.eclipse.jdt.core.formatter.continuation_indentation=2
-org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer=2
-org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line=false
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header=true
-org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header=true
-org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases=true
-org.eclipse.jdt.core.formatter.indent_empty_lines=false
-org.eclipse.jdt.core.formatter.indent_statements_compare_to_block=true
-org.eclipse.jdt.core.formatter.indent_statements_compare_to_body=true
-org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases=true
-org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch=true
-org.eclipse.jdt.core.formatter.indentation.size=4
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_member=insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing=insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_annotation_declaration=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_declaration=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body=insert
-org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter=insert
-org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_binary_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block=insert
-org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments=insert
-org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters=insert
-org.eclipse.jdt.core.formatter.insert_space_after_ellipsis=insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard=do not insert
-org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_after_unary_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter=insert
-org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_binary_operator=insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_ellipsis=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized=insert
-org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while=insert
-org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return=insert
-org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw=insert
-org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional=insert
-org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for=do not insert
-org.eclipse.jdt.core.formatter.insert_space_before_unary_operator=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration=do not insert
-org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation=do not insert
-org.eclipse.jdt.core.formatter.join_lines_in_comments=true
-org.eclipse.jdt.core.formatter.join_wrapped_lines=true
-org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line=false
-org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line=false
-org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line=true
-org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line=false
-org.eclipse.jdt.core.formatter.lineSplit=80
-org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column=false
-org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column=false
-org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body=0
-org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve=3
-org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line=false
-org.eclipse.jdt.core.formatter.tabulation.char=space
-org.eclipse.jdt.core.formatter.tabulation.size=2
-org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations=false
-org.eclipse.jdt.core.formatter.wrap_before_binary_operator=true
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs
deleted file mode 100644
index 7397758..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/.settings/org.eclipse.jdt.ui.prefs
+++ /dev/null
@@ -1,60 +0,0 @@
-eclipse.preferences.version=1
-editor_save_participant_org.eclipse.jdt.ui.postsavelistener.cleanup=true
-formatter_profile=_Google Format
-formatter_settings_version=11
-org.eclipse.jdt.ui.ignorelowercasenames=true
-org.eclipse.jdt.ui.importorder=\#;com.google;com;dk;eu;junit;net;org;java;javax;
-org.eclipse.jdt.ui.ondemandthreshold=99
-org.eclipse.jdt.ui.staticondemandthreshold=99
-org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates/>
-sp_cleanup.add_default_serial_version_id=true
-sp_cleanup.add_generated_serial_version_id=false
-sp_cleanup.add_missing_annotations=false
-sp_cleanup.add_missing_deprecated_annotations=true
-sp_cleanup.add_missing_methods=false
-sp_cleanup.add_missing_nls_tags=false
-sp_cleanup.add_missing_override_annotations=true
-sp_cleanup.add_serial_version_id=false
-sp_cleanup.always_use_blocks=true
-sp_cleanup.always_use_parentheses_in_expressions=false
-sp_cleanup.always_use_this_for_non_static_field_access=false
-sp_cleanup.always_use_this_for_non_static_method_access=false
-sp_cleanup.convert_to_enhanced_for_loop=false
-sp_cleanup.correct_indentation=false
-sp_cleanup.format_source_code=false
-sp_cleanup.format_source_code_changes_only=false
-sp_cleanup.make_local_variable_final=true
-sp_cleanup.make_parameters_final=true
-sp_cleanup.make_private_fields_final=true
-sp_cleanup.make_type_abstract_if_missing_method=false
-sp_cleanup.make_variable_declarations_final=false
-sp_cleanup.never_use_blocks=false
-sp_cleanup.never_use_parentheses_in_expressions=true
-sp_cleanup.on_save_use_additional_actions=true
-sp_cleanup.organize_imports=false
-sp_cleanup.qualify_static_field_accesses_with_declaring_class=false
-sp_cleanup.qualify_static_member_accesses_through_instances_with_declaring_class=true
-sp_cleanup.qualify_static_member_accesses_through_subtypes_with_declaring_class=true
-sp_cleanup.qualify_static_member_accesses_with_declaring_class=false
-sp_cleanup.qualify_static_method_accesses_with_declaring_class=false
-sp_cleanup.remove_private_constructors=true
-sp_cleanup.remove_trailing_whitespaces=true
-sp_cleanup.remove_trailing_whitespaces_all=true
-sp_cleanup.remove_trailing_whitespaces_ignore_empty=false
-sp_cleanup.remove_unnecessary_casts=false
-sp_cleanup.remove_unnecessary_nls_tags=false
-sp_cleanup.remove_unused_imports=false
-sp_cleanup.remove_unused_local_variables=false
-sp_cleanup.remove_unused_private_fields=true
-sp_cleanup.remove_unused_private_members=false
-sp_cleanup.remove_unused_private_methods=true
-sp_cleanup.remove_unused_private_types=true
-sp_cleanup.sort_members=false
-sp_cleanup.sort_members_all=false
-sp_cleanup.use_blocks=false
-sp_cleanup.use_blocks_only_for_return_and_throw=false
-sp_cleanup.use_parentheses_in_expressions=false
-sp_cleanup.use_this_for_non_static_field_access=false
-sp_cleanup.use_this_for_non_static_field_access_only_if_necessary=true
-sp_cleanup.use_this_for_non_static_method_access=false
-sp_cleanup.use_this_for_non_static_method_access_only_if_necessary=true
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/LICENSE b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/LICENSE
deleted file mode 100644
index 11069ed..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/LICENSE
+++ /dev/null
@@ -1,201 +0,0 @@
-                              Apache License
-                        Version 2.0, January 2004
-                     http://www.apache.org/licenses/
-
-TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-1. Definitions.
-
-   "License" shall mean the terms and conditions for use, reproduction,
-   and distribution as defined by Sections 1 through 9 of this document.
-
-   "Licensor" shall mean the copyright owner or entity authorized by
-   the copyright owner that is granting the License.
-
-   "Legal Entity" shall mean the union of the acting entity and all
-   other entities that control, are controlled by, or are under common
-   control with that entity. For the purposes of this definition,
-   "control" means (i) the power, direct or indirect, to cause the
-   direction or management of such entity, whether by contract or
-   otherwise, or (ii) ownership of fifty percent (50%) or more of the
-   outstanding shares, or (iii) beneficial ownership of such entity.
-
-   "You" (or "Your") shall mean an individual or Legal Entity
-   exercising permissions granted by this License.
-
-   "Source" form shall mean the preferred form for making modifications,
-   including but not limited to software source code, documentation
-   source, and configuration files.
-
-   "Object" form shall mean any form resulting from mechanical
-   transformation or translation of a Source form, including but
-   not limited to compiled object code, generated documentation,
-   and conversions to other media types.
-
-   "Work" shall mean the work of authorship, whether in Source or
-   Object form, made available under the License, as indicated by a
-   copyright notice that is included in or attached to the work
-   (an example is provided in the Appendix below).
-
-   "Derivative Works" shall mean any work, whether in Source or Object
-   form, that is based on (or derived from) the Work and for which the
-   editorial revisions, annotations, elaborations, or other modifications
-   represent, as a whole, an original work of authorship. For the purposes
-   of this License, Derivative Works shall not include works that remain
-   separable from, or merely link (or bind by name) to the interfaces of,
-   the Work and Derivative Works thereof.
-
-   "Contribution" shall mean any work of authorship, including
-   the original version of the Work and any modifications or additions
-   to that Work or Derivative Works thereof, that is intentionally
-   submitted to Licensor for inclusion in the Work by the copyright owner
-   or by an individual or Legal Entity authorized to submit on behalf of
-   the copyright owner. For the purposes of this definition, "submitted"
-   means any form of electronic, verbal, or written communication sent
-   to the Licensor or its representatives, including but not limited to
-   communication on electronic mailing lists, source code control systems,
-   and issue tracking systems that are managed by, or on behalf of, the
-   Licensor for the purpose of discussing and improving the Work, but
-   excluding communication that is conspicuously marked or otherwise
-   designated in writing by the copyright owner as "Not a Contribution."
-
-   "Contributor" shall mean Licensor and any individual or Legal Entity
-   on behalf of whom a Contribution has been received by Licensor and
-   subsequently incorporated within the Work.
-
-2. Grant of Copyright License. Subject to the terms and conditions of
-   this License, each Contributor hereby grants to You a perpetual,
-   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-   copyright license to reproduce, prepare Derivative Works of,
-   publicly display, publicly perform, sublicense, and distribute the
-   Work and such Derivative Works in Source or Object form.
-
-3. Grant of Patent License. Subject to the terms and conditions of
-   this License, each Contributor hereby grants to You a perpetual,
-   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-   (except as stated in this section) patent license to make, have made,
-   use, offer to sell, sell, import, and otherwise transfer the Work,
-   where such license applies only to those patent claims licensable
-   by such Contributor that are necessarily infringed by their
-   Contribution(s) alone or by combination of their Contribution(s)
-   with the Work to which such Contribution(s) was submitted. If You
-   institute patent litigation against any entity (including a
-   cross-claim or counterclaim in a lawsuit) alleging that the Work
-   or a Contribution incorporated within the Work constitutes direct
-   or contributory patent infringement, then any patent licenses
-   granted to You under this License for that Work shall terminate
-   as of the date such litigation is filed.
-
-4. Redistribution. You may reproduce and distribute copies of the
-   Work or Derivative Works thereof in any medium, with or without
-   modifications, and in Source or Object form, provided that You
-   meet the following conditions:
-
-   (a) You must give any other recipients of the Work or
-       Derivative Works a copy of this License; and
-
-   (b) You must cause any modified files to carry prominent notices
-       stating that You changed the files; and
-
-   (c) You must retain, in the Source form of any Derivative Works
-       that You distribute, all copyright, patent, trademark, and
-       attribution notices from the Source form of the Work,
-       excluding those notices that do not pertain to any part of
-       the Derivative Works; and
-
-   (d) If the Work includes a "NOTICE" text file as part of its
-       distribution, then any Derivative Works that You distribute must
-       include a readable copy of the attribution notices contained
-       within such NOTICE file, excluding those notices that do not
-       pertain to any part of the Derivative Works, in at least one
-       of the following places: within a NOTICE text file distributed
-       as part of the Derivative Works; within the Source form or
-       documentation, if provided along with the Derivative Works; or,
-       within a display generated by the Derivative Works, if and
-       wherever such third-party notices normally appear. The contents
-       of the NOTICE file are for informational purposes only and
-       do not modify the License. You may add Your own attribution
-       notices within Derivative Works that You distribute, alongside
-       or as an addendum to the NOTICE text from the Work, provided
-       that such additional attribution notices cannot be construed
-       as modifying the License.
-
-   You may add Your own copyright statement to Your modifications and
-   may provide additional or different license terms and conditions
-   for use, reproduction, or distribution of Your modifications, or
-   for any such Derivative Works as a whole, provided Your use,
-   reproduction, and distribution of the Work otherwise complies with
-   the conditions stated in this License.
-
-5. Submission of Contributions. Unless You explicitly state otherwise,
-   any Contribution intentionally submitted for inclusion in the Work
-   by You to the Licensor shall be under the terms and conditions of
-   this License, without any additional terms or conditions.
-   Notwithstanding the above, nothing herein shall supersede or modify
-   the terms of any separate license agreement you may have executed
-   with Licensor regarding such Contributions.
-
-6. Trademarks. This License does not grant permission to use the trade
-   names, trademarks, service marks, or product names of the Licensor,
-   except as required for reasonable and customary use in describing the
-   origin of the Work and reproducing the content of the NOTICE file.
-
-7. Disclaimer of Warranty. Unless required by applicable law or
-   agreed to in writing, Licensor provides the Work (and each
-   Contributor provides its Contributions) on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-   implied, including, without limitation, any warranties or conditions
-   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-   PARTICULAR PURPOSE. You are solely responsible for determining the
-   appropriateness of using or redistributing the Work and assume any
-   risks associated with Your exercise of permissions under this License.
-
-8. Limitation of Liability. In no event and under no legal theory,
-   whether in tort (including negligence), contract, or otherwise,
-   unless required by applicable law (such as deliberate and grossly
-   negligent acts) or agreed to in writing, shall any Contributor be
-   liable to You for damages, including any direct, indirect, special,
-   incidental, or consequential damages of any character arising as a
-   result of this License or out of the use or inability to use the
-   Work (including but not limited to damages for loss of goodwill,
-   work stoppage, computer failure or malfunction, or any and all
-   other commercial damages or losses), even if such Contributor
-   has been advised of the possibility of such damages.
-
-9. Accepting Warranty or Additional Liability. While redistributing
-   the Work or Derivative Works thereof, You may choose to offer,
-   and charge a fee for, acceptance of support, warranty, indemnity,
-   or other liability obligations and/or rights consistent with this
-   License. However, in accepting such obligations, You may act only
-   on Your own behalf and on Your sole responsibility, not on behalf
-   of any other Contributor, and only if You agree to indemnify,
-   defend, and hold each Contributor harmless for any liability
-   incurred by, or claims asserted against, such Contributor by reason
-   of your accepting any such warranty or additional liability.
-
-END OF TERMS AND CONDITIONS
-
-APPENDIX: How to apply the Apache License to your work.
-
-   To apply the Apache License to your work, attach the following
-   boilerplate notice, with the fields enclosed by brackets "[]"
-   replaced with your own identifying information. (Don't include
-   the brackets!)  The text should be enclosed in the appropriate
-   comment syntax for the file format. We also recommend that a
-   file or class name and description of purpose be included on the
-   same "printed page" as the copyright notice for easier
-   identification within third-party archives.
-
-Copyright [yyyy] [name of copyright owner]
-
-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.
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
deleted file mode 100644
index 8f4aadd..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/pom.xml
+++ /dev/null
@@ -1,119 +0,0 @@
-<!--
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
-  <modelVersion>4.0.0</modelVersion>
-
-  <groupId>${groupId}</groupId>
-  <artifactId>${artifactId}</artifactId>
-  <packaging>jar</packaging>
-  <version>${version}</version>
-  <name>${pluginName}</name>
-
-  <properties>
-    <Gerrit-ApiType>${gerritApiType}</Gerrit-ApiType>
-    <Gerrit-ApiVersion>${gerritApiVersion}</Gerrit-ApiVersion>
-  </properties>
-
-  <build>
-    <plugins>
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-jar-plugin</artifactId>
-        <version>2.4</version>
-        <configuration>
-          <includes>
-            <include>**/*.js</include>
-            <include>**/*.class</include>
-          </includes>
-          <archive>
-            <manifestEntries>
-              <Gerrit-PluginName>${pluginName}</Gerrit-PluginName>
-              <Implementation-Vendor>${Implementation-Vendor}</Implementation-Vendor>
-
-              <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
-              <Implementation-Version>${project.version}</Implementation-Version>
-
-              <Gerrit-ApiType>${Gerrit-ApiType}</Gerrit-ApiType>
-              <Gerrit-ApiVersion>${Gerrit-ApiVersion}</Gerrit-ApiVersion>
-            </manifestEntries>
-          </archive>
-        </configuration>
-      </plugin>
-
-      <plugin>
-        <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-compiler-plugin</artifactId>
-        <version>2.3.2</version>
-        <configuration>
-          <source>1.7</source>
-          <target>1.7</target>
-          <encoding>UTF-8</encoding>
-        </configuration>
-      </plugin>
-
-      <plugin>
-        <artifactId>maven-resources-plugin</artifactId>
-        <version>2.6</version>
-        <executions>
-          <execution>
-            <id>copy-resources</id>
-            <phase>process-resources</phase>
-            <goals>
-              <goal>copy-resources</goal>
-            </goals>
-            <configuration>
-              <outputDirectory>${basedir}/target/classes/static</outputDirectory>
-              <resources>
-                <resource>
-                  <directory>src/main/js</directory>
-                  <filtering>true</filtering>
-                </resource>
-              </resources>
-            </configuration>
-          </execution>
-        </executions>
-      </plugin>
-
-    </plugins>
-  </build>
-
-  <dependencies>
-    <dependency>
-      <groupId>com.google.gerrit</groupId>
-      <artifactId>gerrit-extension-api</artifactId>
-      <version>${Gerrit-ApiVersion}</version>
-      <scope>provided</scope>
-    </dependency>
-
-    <dependency>
-      <groupId>junit</groupId>
-      <artifactId>junit</artifactId>
-      <version>4.8.1</version>
-      <scope>test</scope>
-    </dependency>
-  </dependencies>
-#if ($gerritApiVersion.endsWith("SNAPSHOT"))
-
-  <repositories>
-    <repository>
-      <id>snapshot-repository</id>
-      <url>https://oss.sonatype.org/content/repositories/snapshots/</url>
-    </repository>
-  </repositories>
-#end
-</project>
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/src/main/java/MyJsExtension.java b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/src/main/java/MyJsExtension.java
deleted file mode 100644
index 39d06e3..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/src/main/java/MyJsExtension.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package ${package};
-
-import com.google.gerrit.extensions.annotations.Listen;
-import com.google.gerrit.extensions.webui.JavaScriptPlugin;
-
-@Listen
-public class MyJsExtension extends JavaScriptPlugin {
-  public MyJsExtension() {
-    super("hello-js-plugins.js");
-  }
-}
diff --git a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/src/main/js/hello-js-plugins.js b/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/src/main/js/hello-js-plugins.js
deleted file mode 100644
index fd51a42..0000000
--- a/gerrit-plugin-js-archetype/src/main/resources/archetype-resources/src/main/js/hello-js-plugins.js
+++ /dev/null
@@ -1 +0,0 @@
-alert("Greeting from JavaScript Gerrit plugin!");
\ No newline at end of file
diff --git a/gerrit-prettify/BUCK b/gerrit-prettify/BUCK
deleted file mode 100644
index bf2e02a..0000000
--- a/gerrit-prettify/BUCK
+++ /dev/null
@@ -1,47 +0,0 @@
-SRC = 'src/main/java/com/google/gerrit/prettify/'
-
-gwt_module(
-  name = 'client',
-  srcs = glob([
-    SRC + 'common/**/*.java',
-  ]),
-  gwt_xml = SRC + 'PrettyFormatter.gwt.xml',
-  deps = [
-    '//gerrit-gwtexpui:SafeHtml',
-  ],
-  exported_deps = [
-    '//gerrit-extension-api:client',
-    '//gerrit-patch-jgit:client',
-    '//gerrit-patch-jgit:Edit',
-    '//gerrit-reviewdb:client',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtjsonrpc_src',
-  ],
-  provided_deps = ['//lib/gwt:user'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'server',
-  srcs = glob([SRC + 'common/**/*.java']),
-  deps = [
-    '//gerrit-patch-jgit:server',
-    '//gerrit-reviewdb:server',
-    '//lib:guava',
-    '//lib:gwtjsonrpc',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-export_file(
-  name = 'prettify.min.js',
-  src = 'src/main/resources/com/google/gerrit/prettify/client/prettify.js',
-  visibility = ['//Documentation:'],
-)
-
-export_file(
-  name = 'prettify.min.css',
-  src = 'src/main/resources/com/google/gerrit/prettify/client/prettify.css',
-  visibility = ['//Documentation:'],
-)
diff --git a/gerrit-prettify/BUILD b/gerrit-prettify/BUILD
index 063feee..efbe199 100644
--- a/gerrit-prettify/BUILD
+++ b/gerrit-prettify/BUILD
@@ -1,35 +1,41 @@
-load('//tools/bzl:gwt.bzl', 'gwt_module')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:gwt.bzl", "gwt_module")
 
-SRC = 'src/main/java/com/google/gerrit/prettify/'
+SRC = "src/main/java/com/google/gerrit/prettify/"
 
 gwt_module(
-  name = 'client',
-  srcs = glob([
-    SRC + 'common/**/*.java',
-  ]),
-  gwt_xml = SRC + 'PrettyFormatter.gwt.xml',
-  deps = ['//lib/gwt:user'],
-  exported_deps = [
-    '//gerrit-extension-api:client',
-    '//gerrit-gwtexpui:SafeHtml',
-    '//gerrit-patch-jgit:client',
-    '//gerrit-patch-jgit:Edit',
-    '//gerrit-reviewdb:client',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtjsonrpc_src',
-  ],
-  visibility = ['//visibility:public'],
+    name = "client",
+    srcs = glob([
+        SRC + "common/**/*.java",
+    ]),
+    exported_deps = [
+        "//gerrit-extension-api:client",
+        "//gerrit-gwtexpui:SafeHtml",
+        "//gerrit-patch-jgit:Edit",
+        "//gerrit-patch-jgit:client",
+        "//gerrit-reviewdb:client",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtjsonrpc_src",
+    ],
+    gwt_xml = SRC + "PrettyFormatter.gwt.xml",
+    visibility = ["//visibility:public"],
+    deps = ["//lib/gwt:user-neverlink"],
 )
 
 java_library(
-  name = 'server',
-  srcs = glob([SRC + 'common/**/*.java']),
-  deps = [
-    '//gerrit-patch-jgit:server',
-    '//gerrit-reviewdb:server',
-    '//lib:guava',
-    '//lib:gwtjsonrpc',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "server",
+    srcs = glob([SRC + "common/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-patch-jgit:server",
+        "//gerrit-reviewdb:server",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
 )
+
+exports_files([
+    "src/main/resources/com/google/gerrit/prettify/client/prettify.css",
+    "src/main/resources/com/google/gerrit/prettify/client/prettify.js",
+])
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java
index ec5c78d8..1b06f0f 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.prettify.common;
 
-import org.eclipse.jgit.diff.Edit;
-
 import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.diff.Edit;
 
 public class EditList {
   private final List<Edit> edits;
@@ -25,8 +24,8 @@
   private final int aSize;
   private final int bSize;
 
-  public EditList(final List<Edit> edits, final int contextLines,
-      final int aSize, final int bSize) {
+  public EditList(
+      final List<Edit> edits, final int contextLines, final int aSize, final int bSize) {
     this.edits = edits;
     this.context = contextLines;
     this.aSize = aSize;
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
index a57146f..1dce0a0 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
@@ -14,11 +14,9 @@
 
 package com.google.gerrit.prettify.common;
 
-
-import org.eclipse.jgit.diff.Edit;
-
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.diff.Edit;
 
 public class SparseFileContent {
   protected String path;
@@ -282,8 +280,7 @@
       lines = new ArrayList<>();
     }
 
-    protected Range() {
-    }
+    protected Range() {}
 
     private String get(final int i) {
       return lines.get(i - base);
diff --git a/gerrit-reviewdb/BUCK b/gerrit-reviewdb/BUCK
deleted file mode 100644
index 82e0135..0000000
--- a/gerrit-reviewdb/BUCK
+++ /dev/null
@@ -1,38 +0,0 @@
-SRC = 'src/main/java/com/google/gerrit/reviewdb/'
-TESTS = 'src/test/java/com/google/gerrit/reviewdb/'
-
-gwt_module(
-  name = 'client',
-  srcs = glob([SRC + 'client/**/*.java']),
-  gwt_xml = SRC + 'ReviewDB.gwt.xml',
-  deps = [
-    '//gerrit-extension-api:client',
-    '//lib:gwtorm_client',
-    '//lib:gwtorm_client_src'
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'server',
-  srcs = glob([SRC + '**/*.java']),
-  resources = glob(['src/main/resources/**/*']),
-  deps = [
-    '//gerrit-extension-api:api',
-    '//lib:guava',
-    '//lib:gwtorm',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'client_tests',
-  srcs = glob([TESTS + 'client/**/*.java']),
-  deps = [
-    ':client',
-    '//lib:gwtorm',
-    '//lib:truth',
-  ],
-  source_under_test = [':client'],
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-reviewdb/BUILD b/gerrit-reviewdb/BUILD
index a4144ec..334d375 100644
--- a/gerrit-reviewdb/BUILD
+++ b/gerrit-reviewdb/BUILD
@@ -1,39 +1,42 @@
-load('//tools/bzl:gwt.bzl', 'gwt_module')
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:gwt.bzl", "gwt_module")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
-SRC = 'src/main/java/com/google/gerrit/reviewdb/'
-TESTS = 'src/test/java/com/google/gerrit/reviewdb/'
+package(default_visibility = ["//visibility:public"])
+
+SRC = "src/main/java/com/google/gerrit/reviewdb/"
+
+TESTS = "src/test/java/com/google/gerrit/reviewdb/"
 
 gwt_module(
-  name = 'client',
-  srcs = glob([SRC + 'client/**/*.java']),
-  gwt_xml = SRC + 'ReviewDB.gwt.xml',
-  deps = [
-    '//gerrit-extension-api:client',
-    '//lib:gwtorm_client',
-    '//lib:gwtorm_client_src'
-  ],
-  visibility = ['//visibility:public'],
+    name = "client",
+    srcs = glob([SRC + "client/**/*.java"]),
+    gwt_xml = SRC + "ReviewDB.gwt.xml",
+    deps = [
+        "//gerrit-extension-api:client",
+        "//lib:gwtorm-client",
+        "//lib:gwtorm-client_src",
+    ],
 )
 
 java_library(
-  name = 'server',
-  srcs = glob([SRC + '**/*.java']),
-  resources = glob(['src/main/resources/**/*']),
-  deps = [
-    '//gerrit-extension-api:api',
-    '//lib:guava',
-    '//lib:gwtorm',
-  ],
-  visibility = ['//visibility:public'],
+    name = "server",
+    srcs = glob([SRC + "**/*.java"]),
+    resources = glob(["src/main/resources/**/*"]),
+    deps = [
+        "//gerrit-extension-api:api",
+        "//lib:guava",
+        "//lib:gwtorm",
+    ],
 )
 
 junit_tests(
-  name = 'client_tests',
-  srcs = glob([TESTS + 'client/**/*.java']),
-  deps = [
-    ':client',
-    '//lib:gwtorm',
-    '//lib:truth',
-  ],
+    name = "client_tests",
+    srcs = glob([TESTS + "client/**/*.java"]),
+    deps = [
+        ":client",
+        "//gerrit-server:testutil",
+        "//lib:gwtorm",
+        "//lib:truth",
+    ],
 )
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
index 1682195..d1fcbe0 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
@@ -20,60 +20,30 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.IntKey;
-
 import java.sql.Timestamp;
 
 /**
  * Information about a single user.
- * <p>
- * A user may have multiple identities they can use to login to Gerrit (see
- * {@link AccountExternalId}), but in such cases they always map back to a
- * single Account entity.
- * <p>
- * Entities "owned" by an Account (that is, their primary key contains the
- * {@link Account.Id} key as part of their key structure):
+ *
+ * <p>A user may have multiple identities they can use to login to Gerrit (see ExternalId), but in
+ * such cases they always map back to a single Account entity.
+ *
+ * <p>Entities "owned" by an Account (that is, their primary key contains the {@link Account.Id} key
+ * as part of their key structure):
+ *
  * <ul>
- *
- * <li>{@link AccountExternalId}: OpenID identities and email addresses known to
- * be registered to this user. Multiple records can exist when the user has more
- * than one public identity, such as a work and a personal email address.</li>
- *
- * <li>{@link AccountGroupMember}: membership of the user in a specific human
- * managed {@link AccountGroup}. Multiple records can exist when the user is a
- * member of more than one group.</li>
- *
- * <li>{@link AccountProjectWatch}: user's email settings related to a specific
- * {@link Project}. One record per project the user is interested in tracking.</li>
- *
- * <li>{@link AccountSshKey}: user's public SSH keys, for authentication through
- * the internal SSH daemon. One record per SSH key uploaded by the user, keys
- * are checked in random order until a match is found.</li>
- *
- * <li>{@link DiffPreferencesInfo}: user's preferences for rendering side-to-side
- * and unified diff</li>
- *
+ *   <li>ExternalId: OpenID identities and email addresses known to be registered to this user.
+ *       Multiple records can exist when the user has more than one public identity, such as a work
+ *       and a personal email address.
+ *   <li>{@link AccountGroupMember}: membership of the user in a specific human managed {@link
+ *       AccountGroup}. Multiple records can exist when the user is a member of more than one group.
+ *   <li>{@link AccountSshKey}: user's public SSH keys, for authentication through the internal SSH
+ *       daemon. One record per SSH key uploaded by the user, keys are checked in random order until
+ *       a match is found.
+ *   <li>{@link DiffPreferencesInfo}: user's preferences for rendering side-to-side and unified diff
  * </ul>
  */
 public final class Account {
-  public enum FieldName {
-    FULL_NAME, USER_NAME, REGISTER_NEW_EMAIL
-  }
-
-  public static final String USER_NAME_PATTERN_FIRST = "[a-zA-Z0-9]";
-  public static final String USER_NAME_PATTERN_REST = "[a-zA-Z0-9._@-]";
-  public static final String USER_NAME_PATTERN_LAST = "[a-zA-Z0-9]";
-
-  /** Regular expression that {@link #userName} must match. */
-  public static final String USER_NAME_PATTERN = "^" + //
-      "(" + //
-      USER_NAME_PATTERN_FIRST + //
-      USER_NAME_PATTERN_REST + "*" + //
-      USER_NAME_PATTERN_LAST + //
-      "|" + //
-      USER_NAME_PATTERN_FIRST + //
-      ")" + //
-      "$";
-
   /** Key local to Gerrit to identify a user. */
   public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
@@ -81,10 +51,9 @@
     @Column(id = 1)
     protected int id;
 
-    protected Id() {
-    }
+    protected Id() {}
 
-    public Id(final int id) {
+    public Id(int id) {
       this.id = id;
     }
 
@@ -99,8 +68,8 @@
     }
 
     /** Parse an Account.Id out of a string representation. */
-    public static Id parse(final String str) {
-      final Id r = new Id();
+    public static Id parse(String str) {
+      Id r = new Id();
       r.fromString(str);
       return r;
     }
@@ -118,8 +87,8 @@
     /**
      * Parse an Account.Id out of a part of a ref-name.
      *
-     * @param name  a ref name with the following syntax: {@code "34/1234..."}.
-     *              We assume that the caller has trimmed any prefix.
+     * @param name a ref name with the following syntax: {@code "34/1234..."}. We assume that the
+     *     caller has trimmed any prefix.
      */
     public static Id fromRefPart(String name) {
       Integer id = RefNames.parseShardedRefPart(name);
@@ -128,10 +97,10 @@
 
     /**
      * Parse an Account.Id out of the last part of a ref name.
-     * <p>
-     * The input is a ref name of the form {@code ".../1234"}, where the suffix
-     * is a non-sharded account ID. Ref names using a sharded ID should use
-     * {@link #fromRefPart(String)} instead for greater safety.
+     *
+     * <p>The input is a ref name of the form {@code ".../1234"}, where the suffix is a non-sharded
+     * account ID. Ref names using a sharded ID should use {@link #fromRefPart(String)} instead for
+     * greater safety.
      *
      * @param name ref name
      * @return account ID, or null if not numeric.
@@ -161,24 +130,29 @@
 
   // DELETED: id = 6 (generalPreferences)
 
-  /** Is this user active */
+  /**
+   * Is this user inactive? This is used to avoid showing some users (eg. former employees) in
+   * auto-suggest.
+   */
   @Column(id = 7)
   protected boolean inactive;
 
+  /** The user-settable status of this account (e.g. busy, OOO, available) */
+  @Column(id = 8, notNull = false)
+  protected String status;
+
   /** <i>computed</i> the username selected from the identities. */
   protected String userName;
 
   /** <i>stored in git, used for caching</i> the user's preferences. */
   private GeneralPreferencesInfo generalPreferences;
 
-  protected Account() {
-  }
+  protected Account() {}
 
   /**
    * Create a new account.
    *
-   * @param newId unique id, see
-   *        {@link com.google.gerrit.reviewdb.server.ReviewDb#nextAccountId()}.
+   * @param newId unique id, see {@link com.google.gerrit.reviewdb.server.ReviewDb#nextAccountId()}.
    * @param registeredOn when the account was registered.
    */
   public Account(Account.Id newId, Timestamp registeredOn) {
@@ -197,7 +171,7 @@
   }
 
   /** Set the full name of the user ("Given-name Surname" style). */
-  public void setFullName(final String name) {
+  public void setFullName(String name) {
     if (name != null && !name.trim().isEmpty()) {
       fullName = name.trim();
     } else {
@@ -211,15 +185,15 @@
   }
 
   /** Set the email address the user prefers to be contacted through. */
-  public void setPreferredEmail(final String addr) {
+  public void setPreferredEmail(String addr) {
     preferredEmail = addr;
   }
 
   /**
    * Formats an account name.
-   * <p>
-   * If the account has a full name, it returns only the full name. Otherwise it
-   * returns a longer form that includes the email address.
+   *
+   * <p>If the account has a full name, it returns only the full name. Otherwise it returns a longer
+   * form that includes the email address.
    */
   public String getName(String anonymousCowardName) {
     if (fullName != null) {
@@ -233,13 +207,14 @@
 
   /**
    * Get the name and email address.
-   * <p>
-   * Example output:
+   *
+   * <p>Example output:
+   *
    * <ul>
-   * <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated</li>
-   * <li>{@code A U. Thor (12)}: missing email address</li>
-   * <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name</li>
-   * <li>{@code Anonymous Coward (12)}: missing name and email address</li>
+   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
+   *   <li>{@code A U. Thor (12)}: missing email address
+   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
+   *   <li>{@code Anonymous Coward (12)}: missing name and email address
    * </ul>
    */
   public String getNameEmail(String anonymousCowardName) {
@@ -272,11 +247,19 @@
   }
 
   public boolean isActive() {
-    return ! inactive;
+    return !inactive;
   }
 
   public void setActive(boolean active) {
-    inactive = ! active;
+    inactive = !active;
+  }
+
+  public String getStatus() {
+    return status;
+  }
+
+  public void setStatus(String status) {
+    this.status = status;
   }
 
   /** @return the computed user name for this account */
@@ -285,7 +268,7 @@
   }
 
   /** Update the computed user name property. */
-  public void setUserName(final String userName) {
+  public void setUserName(String userName) {
     this.userName = userName;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
index 41336791..3c8f2fa 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
@@ -14,16 +14,18 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.StringKey;
+import java.util.Objects;
 
 /** Association of an external account identifier to a local {@link Account}. */
 public final class AccountExternalId {
   /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP},
-   * {@link AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
-   * <p>
-   * The name {@code gerrit:} was a very poor choice.
+   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
+   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   *
+   * <p>The name {@code gerrit:} was a very poor choice.
    */
   public static final String SCHEME_GERRIT = "gerrit:";
 
@@ -48,8 +50,7 @@
     @Column(id = 1)
     protected String externalId;
 
-    protected Key() {
-    }
+    protected Key() {}
 
     public Key(String scheme, final String identity) {
       if (!scheme.endsWith(":")) {
@@ -87,6 +88,8 @@
   @Column(id = 3, notNull = false)
   protected String emailAddress;
 
+  // Encoded version of the hashed and salted password, to be interpreted by the
+  // {@link HashedPassword} class.
   @Column(id = 4, notNull = false)
   protected String password;
 
@@ -96,8 +99,7 @@
   /** <i>computed value</i> can this identity be removed from the account? */
   protected boolean canDelete;
 
-  protected AccountExternalId() {
-  }
+  protected AccountExternalId() {}
 
   /**
    * Create a new binding to an external identity.
@@ -138,19 +140,17 @@
 
   public String getSchemeRest() {
     String scheme = key.getScheme();
-    return null != scheme
-        ? getExternalId().substring(scheme.length() + 1)
-        : null;
+    return null != scheme ? getExternalId().substring(scheme.length() + 1) : null;
+  }
+
+  public void setPassword(String hashed) {
+    password = hashed;
   }
 
   public String getPassword() {
     return password;
   }
 
-  public void setPassword(String p) {
-    password = p;
-  }
-
   public boolean isTrusted() {
     return trusted;
   }
@@ -166,4 +166,21 @@
   public void setCanDelete(final boolean t) {
     canDelete = t;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AccountExternalId) {
+      AccountExternalId extId = (AccountExternalId) o;
+      return Objects.equals(key, extId.key)
+          && Objects.equals(accountId, extId.accountId)
+          && Objects.equals(emailAddress, extId.emailAddress)
+          && Objects.equals(password, extId.password);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(key, accountId, emailAddress, password);
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java
index 284ae0a..c3b2908 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java
@@ -21,15 +21,13 @@
 /** Named group of one or more accounts, typically used for access controls. */
 public final class AccountGroup {
   /** Group name key */
-  public static class NameKey extends
-      StringKey<com.google.gwtorm.client.Key<?>> {
+  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
     @Column(id = 1)
     protected String name;
 
-    protected NameKey() {
-    }
+    protected NameKey() {}
 
     public NameKey(final String n) {
       name = n;
@@ -47,15 +45,13 @@
   }
 
   /** Globally unique identifier. */
-  public static class UUID extends
-      StringKey<com.google.gwtorm.client.Key<?>> {
+  public static class UUID extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
     @Column(id = 1)
     protected String uuid;
 
-    protected UUID() {
-    }
+    protected UUID() {}
 
     public UUID(final String n) {
       uuid = n;
@@ -91,8 +87,7 @@
     @Column(id = 1)
     protected int id;
 
-    protected Id() {
-    }
+    protected Id() {}
 
     public Id(final int id) {
       this.id = id;
@@ -144,17 +139,18 @@
 
   /**
    * Identity of the group whose members can manage this group.
-   * <p>
-   * This can be a self-reference to indicate the group's members manage itself.
+   *
+   * <p>This can be a self-reference to indicate the group's members manage itself.
    */
   @Column(id = 10)
   protected UUID ownerGroupUUID;
 
-  protected AccountGroup() {
-  }
+  protected AccountGroup() {}
 
-  public AccountGroup(final AccountGroup.NameKey newName,
-      final AccountGroup.Id newId, final AccountGroup.UUID uuid) {
+  public AccountGroup(
+      final AccountGroup.NameKey newName,
+      final AccountGroup.Id newId,
+      final AccountGroup.UUID uuid) {
     name = newName;
     groupId = newId;
     visibleToAll = false;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
index 3443f80..b4bf783 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
@@ -60,8 +60,7 @@
   @Column(id = 1, name = Column.NONE)
   protected Key key;
 
-  protected AccountGroupById() {
-  }
+  protected AccountGroupById() {}
 
   public AccountGroupById(final AccountGroupById.Key k) {
     key = k;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
index 161a66e..d1e72af 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
@@ -16,7 +16,6 @@
 
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.CompoundKey;
-
 import java.sql.Timestamp;
 
 /** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
@@ -75,11 +74,10 @@
   @Column(id = 4, notNull = false)
   protected Timestamp removedOn;
 
-  protected AccountGroupByIdAud() {
-  }
+  protected AccountGroupByIdAud() {}
 
-  public AccountGroupByIdAud(final AccountGroupById m,
-      final Account.Id adder, final Timestamp when) {
+  public AccountGroupByIdAud(
+      final AccountGroupById m, final Account.Id adder, final Timestamp when) {
     final AccountGroup.Id group = m.getGroupId();
     final AccountGroup.UUID include = m.getIncludeUUID();
     key = new AccountGroupByIdAud.Key(group, include, when);
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
index 4869cf8..ce6999f 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
@@ -56,8 +56,7 @@
   @Column(id = 1, name = Column.NONE)
   protected Key key;
 
-  protected AccountGroupMember() {
-  }
+  protected AccountGroupMember() {}
 
   public AccountGroupMember(final AccountGroupMember.Key k) {
     key = k;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
index c1b057a..4f3992d 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
@@ -16,7 +16,6 @@
 
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.CompoundKey;
-
 import java.sql.Timestamp;
 
 /** Membership of an {@link Account} in an {@link AccountGroup}. */
@@ -75,11 +74,10 @@
   @Column(id = 4, notNull = false)
   protected Timestamp removedOn;
 
-  protected AccountGroupMemberAudit() {
-  }
+  protected AccountGroupMemberAudit() {}
 
-  public AccountGroupMemberAudit(final AccountGroupMember m,
-      final Account.Id adder, Timestamp addedOn) {
+  public AccountGroupMemberAudit(
+      final AccountGroupMember m, final Account.Id adder, Timestamp addedOn) {
     final Account.Id who = m.getAccountId();
     final AccountGroup.Id group = m.getAccountGroupId();
     key = new AccountGroupMemberAudit.Key(who, group, addedOn);
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupName.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupName.java
index 701f2c3..924f457 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupName.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupName.java
@@ -24,8 +24,7 @@
   @Column(id = 2)
   protected AccountGroup.Id groupId;
 
-  protected AccountGroupName() {
-  }
+  protected AccountGroupName() {}
 
   public AccountGroupName(AccountGroup.NameKey name, AccountGroup.Id groupId) {
     this.name = name;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountPatchReview.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountPatchReview.java
deleted file mode 100644
index a1c3c3c..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountPatchReview.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2009 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-
-/**
- * An entity that keeps track of what user reviewed what patches.
- */
-public final class AccountPatchReview {
-
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Account.Id accountId;
-
-    @Column(id = 2, name = Column.NONE)
-    protected Patch.Key patchKey;
-
-    protected Key() {
-      accountId = new Account.Id();
-      patchKey = new Patch.Key();
-    }
-
-    public Key(final Patch.Key p, final Account.Id a) {
-      patchKey = p;
-      accountId = a;
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public Patch.Key getPatchKey() {
-      return patchKey;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {patchKey};
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected AccountPatchReview.Key key;
-
-  protected AccountPatchReview() {
-  }
-
-  public AccountPatchReview(final Patch.Key k, final Account.Id a) {
-    key = new AccountPatchReview.Key(k, a);
-  }
-
-  public AccountPatchReview.Key getKey() {
-    return key;
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountProjectWatch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountProjectWatch.java
deleted file mode 100644
index a6796e7..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountProjectWatch.java
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright (C) 2008 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.reviewdb.client;
-
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.CompoundKey;
-import com.google.gwtorm.client.StringKey;
-
-/** An {@link Account} interested in a {@link Project}. */
-public final class AccountProjectWatch {
-
-  public enum NotifyType {
-    // sort by name, except 'ALL' which should stay last
-    ABANDONED_CHANGES,
-    ALL_COMMENTS,
-    NEW_CHANGES,
-    NEW_PATCHSETS,
-    SUBMITTED_CHANGES,
-
-    ALL
-  }
-
-  public static final String FILTER_ALL = "*";
-
-  public static class Key extends CompoundKey<Account.Id> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected Account.Id accountId;
-
-    @Column(id = 2)
-    protected Project.NameKey projectName;
-
-    @Column(id = 3)
-    protected Filter filter;
-
-    protected Key() {
-      accountId = new Account.Id();
-      projectName = new Project.NameKey();
-      filter = new Filter();
-    }
-
-    public Key(Account.Id a, Project.NameKey g, String f) {
-      accountId = a;
-      projectName = g;
-      filter = new Filter(f);
-    }
-
-    @Override
-    public Account.Id getParentKey() {
-      return accountId;
-    }
-
-    public Project.NameKey getProjectName() {
-      return projectName;
-    }
-
-    public Filter getFilter() {
-      return filter;
-    }
-
-    @Override
-    public com.google.gwtorm.client.Key<?>[] members() {
-      return new com.google.gwtorm.client.Key<?>[] {projectName, filter};
-    }
-  }
-
-  public static class Filter extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected String filter;
-
-    protected Filter() {
-    }
-
-    public Filter(String f) {
-      filter = f != null && !f.isEmpty() ? f : FILTER_ALL;
-    }
-
-    @Override
-    public String get() {
-      return filter;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      filter = newValue;
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  /** Automatically send email notifications of new changes? */
-  @Column(id = 2)
-  protected boolean notifyNewChanges;
-
-  /** Automatically receive comments published to this project */
-  @Column(id = 3)
-  protected boolean notifyAllComments;
-
-  /** Automatically receive changes submitted to this project */
-  @Column(id = 4)
-  protected boolean notifySubmittedChanges;
-
-  @Column(id = 5)
-  protected boolean notifyNewPatchSets;
-
-  @Column(id = 6)
-  protected boolean notifyAbandonedChanges;
-
-  protected AccountProjectWatch() {
-  }
-
-  public AccountProjectWatch(final AccountProjectWatch.Key k) {
-    key = k;
-  }
-
-  public AccountProjectWatch.Key getKey() {
-    return key;
-  }
-
-  public Account.Id getAccountId() {
-    return key.accountId;
-  }
-
-  public Project.NameKey getProjectNameKey() {
-    return key.projectName;
-  }
-
-  public String getFilter() {
-    return FILTER_ALL.equals(key.filter.get()) ? null : key.filter.get();
-  }
-
-  public boolean isNotify(final NotifyType type) {
-    switch (type) {
-      case NEW_CHANGES:
-        return notifyNewChanges;
-
-      case NEW_PATCHSETS:
-        return notifyNewPatchSets;
-
-      case ALL_COMMENTS:
-        return notifyAllComments;
-
-      case SUBMITTED_CHANGES:
-        return notifySubmittedChanges;
-
-      case ABANDONED_CHANGES:
-        return notifyAbandonedChanges;
-
-      case ALL:
-        break;
-    }
-    return false;
-  }
-
-  public void setNotify(final NotifyType type, final boolean v) {
-    switch (type) {
-      case NEW_CHANGES:
-        notifyNewChanges = v;
-        break;
-
-      case NEW_PATCHSETS:
-        notifyNewPatchSets = v;
-        break;
-
-      case ALL_COMMENTS:
-        notifyAllComments = v;
-        break;
-
-      case SUBMITTED_CHANGES:
-        notifySubmittedChanges = v;
-        break;
-
-      case ABANDONED_CHANGES:
-        notifyAbandonedChanges = v;
-        break;
-
-      case ALL:
-        notifyNewChanges = v;
-        notifyNewPatchSets = v;
-        notifyAllComments = v;
-        notifySubmittedChanges = v;
-        notifyAbandonedChanges = v;
-        break;
-    }
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
index 78aef91..3645dac 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.reviewdb.client;
 
 import com.google.gwtorm.client.IntKey;
-
 import java.util.Objects;
 
 /** An SSH key approved for use by an {@link Account}. */
@@ -62,8 +61,7 @@
 
   protected boolean valid;
 
-  protected AccountSshKey() {
-  }
+  protected AccountSshKey() {}
 
   public AccountSshKey(final AccountSshKey.Id i, final String pub) {
     id = i;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AuthType.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AuthType.java
deleted file mode 100644
index 38a78ba..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AuthType.java
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (C) 2009 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.reviewdb.client;
-
-public enum AuthType {
-  /** Login relies upon the OpenID standard: {@link "http://openid.net/"} */
-  OPENID,
-
-  /** Login relies upon the OpenID standard: {@link "http://openid.net/"} in Single Sign On mode */
-  OPENID_SSO,
-
-  /**
-   * Login relies upon the container/web server security.
-   * <p>
-   * The container or web server must populate an HTTP header with a unique name
-   * for the current user. Gerrit will implicitly trust the value of this header
-   * to supply the unique identity.
-   */
-  HTTP,
-
-  /**
-   * Login relies upon the container/web server security, but also uses LDAP.
-   * <p>
-   * Like {@link #HTTP}, the container or web server must populate an HTTP
-   * header with a unique name for the current user. Gerrit will implicitly
-   * trust the value of this header to supply the unique identity.
-   * <p>
-   * In addition to trusting the HTTP headers, Gerrit will obtain basic user
-   * registration (name and email) from LDAP, and some group memberships.
-   */
-  HTTP_LDAP,
-
-  /**
-   * Login via client SSL certificate.
-   * <p>
-   * This authentication type is actually kind of SSO. Gerrit will configure
-   * Jetty's SSL channel to request client's SSL certificate. For this
-   * authentication to work a Gerrit administrator has to import the root
-   * certificate of the trust chain used to issue the client's certificate
-   * into the <review-site>/etc/keystore.
-   * <p>
-   * After the authentication is done Gerrit will obtain basic user
-   * registration (name and email) from LDAP, and some group memberships.
-   * Therefore, the "_LDAP" suffix in the name of this authentication type.
-   */
-  CLIENT_SSL_CERT_LDAP,
-
-  /**
-   * Login collects username and password through a web form, and binds to LDAP.
-   * <p>
-   * Unlike {@link #HTTP_LDAP}, Gerrit presents a sign-in dialog to the user and
-   * makes the connection to the LDAP server on their behalf.
-   */
-  LDAP,
-
-  /**
-   * Login collects username and password through a web form, and binds to LDAP.
-   * <p>
-   * Unlike {@link #HTTP_LDAP}, Gerrit presents a sign-in dialog to the user and
-   * makes the connection to the LDAP server on their behalf.
-   * <p>
-   * Unlike the more generic {@link #LDAP} mode, Gerrit can only query the
-   * directory via an actual authenticated user account.
-   */
-  LDAP_BIND,
-
-  /** Login is managed by additional, unspecified code. */
-  CUSTOM_EXTENSION,
-
-  /** Development mode to enable becoming anyone you want. */
-  DEVELOPMENT_BECOME_ANY_ACCOUNT,
-
-  /** Generic OAuth provider over HTTP. */
-  OAUTH
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
index 23685c5..d0df7c6 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
@@ -66,8 +66,7 @@
   protected RevId revision;
   protected boolean canDelete;
 
-  protected Branch() {
-  }
+  protected Branch() {}
 
   public Branch(final Branch.NameKey newName) {
     name = newName;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index 1864c56..9655edd 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -21,14 +21,13 @@
 import com.google.gwtorm.client.IntKey;
 import com.google.gwtorm.client.RowVersion;
 import com.google.gwtorm.client.StringKey;
-
 import java.sql.Timestamp;
 import java.util.Arrays;
 
 /**
  * A change proposed to be merged into a {@link Branch}.
- * <p>
- * The data graph rooted below a Change can be quite complex:
+ *
+ * <p>The data graph rooted below a Change can be quite complex:
  *
  * <pre>
  *   {@link Change}
@@ -41,58 +40,58 @@
  *          |
  *          +- {@link PatchLineComment}: comment about a specific line
  * </pre>
+ *
  * <p>
+ *
  * <h5>PatchSets</h5>
+ *
+ * <p>Every change has at least one PatchSet. A change starts out with one PatchSet, the initial
+ * proposal put forth by the change owner. This {@link Account} is usually also listed as the author
+ * and committer in the PatchSetInfo.
+ *
+ * <p>Each PatchSet contains zero or more Patch records, detailing the file paths impacted by the
+ * change (otherwise known as, the file paths the author added/deleted/modified). Sometimes a merge
+ * commit can contain zero patches, if the merge has no conflicts, or has no impact other than to
+ * cut off a line of development.
+ *
+ * <p>Each PatchLineComment is a draft or a published comment about a single line of the associated
+ * file. These are the inline comment entities created by users as they perform a review.
+ *
+ * <p>When additional PatchSets appear under a change, these PatchSets reference <i>replacement</i>
+ * commits; alternative commits that could be made to the project instead of the original commit
+ * referenced by the first PatchSet.
+ *
+ * <p>A change has at most one current PatchSet. The current PatchSet is updated when a new
+ * replacement PatchSet is uploaded. When a change is submitted, the current patch set is what is
+ * merged into the destination branch.
+ *
  * <p>
- * Every change has at least one PatchSet. A change starts out with one
- * PatchSet, the initial proposal put forth by the change owner. This
- * {@link Account} is usually also listed as the author and committer in the
- * PatchSetInfo.
- * <p>
- * Each PatchSet contains zero or more Patch records, detailing the file paths
- * impacted by the change (otherwise known as, the file paths the author
- * added/deleted/modified). Sometimes a merge commit can contain zero patches,
- * if the merge has no conflicts, or has no impact other than to cut off a line
- * of development.
- * <p>
- * Each PatchLineComment is a draft or a published comment about a single line
- * of the associated file. These are the inline comment entities created by
- * users as they perform a review.
- * <p>
- * When additional PatchSets appear under a change, these PatchSets reference
- * <i>replacement</i> commits; alternative commits that could be made to the
- * project instead of the original commit referenced by the first PatchSet.
- * <p>
- * A change has at most one current PatchSet. The current PatchSet is updated
- * when a new replacement PatchSet is uploaded. When a change is submitted, the
- * current patch set is what is merged into the destination branch.
- * <p>
+ *
  * <h5>ChangeMessage</h5>
- * <p>
- * The ChangeMessage entity is a general free-form comment about the whole
- * change, rather than PatchLineComment's file and line specific context. The
- * ChangeMessage appears at the start of any email generated by Gerrit, and is
- * shown on the change overview page, rather than in a file-specific context.
- * Users often use this entity to describe general remarks about the overall
+ *
+ * <p>The ChangeMessage entity is a general free-form comment about the whole change, rather than
+ * PatchLineComment's file and line specific context. The ChangeMessage appears at the start of any
+ * email generated by Gerrit, and is shown on the change overview page, rather than in a
+ * file-specific context. Users often use this entity to describe general remarks about the overall
  * concept proposed by the change.
+ *
  * <p>
+ *
  * <h5>PatchSetApproval</h5>
- * <p>
- * PatchSetApproval entities exist to fill in the <i>cells</i> of the approvals
- * table in the web UI. That is, a single PatchSetApproval record's key is the
- * tuple {@code (PatchSet,Account,ApprovalCategory)}. Each PatchSetApproval
- * carries with it a small score value, typically within the range -2..+2.
- * <p>
- * If an Account has created only PatchSetApprovals with a score value of 0, the
- * Change shows in their dashboard, and they are said to be CC'd (carbon copied)
- * on the Change, but are not a direct reviewer. This often happens when an
- * account was specified at upload time with the {@code --cc} command line flag,
- * or have published comments, but left the approval scores at 0 ("No Score").
- * <p>
- * If an Account has one or more PatchSetApprovals with a score != 0, the Change
- * shows in their dashboard, and they are said to be an active reviewer. Such
- * individuals are highlighted when notice of a replacement patch set is sent,
- * or when notice of the change submission occurs.
+ *
+ * <p>PatchSetApproval entities exist to fill in the <i>cells</i> of the approvals table in the web
+ * UI. That is, a single PatchSetApproval record's key is the tuple {@code
+ * (PatchSet,Account,ApprovalCategory)}. Each PatchSetApproval carries with it a small score value,
+ * typically within the range -2..+2.
+ *
+ * <p>If an Account has created only PatchSetApprovals with a score value of 0, the Change shows in
+ * their dashboard, and they are said to be CC'd (carbon copied) on the Change, but are not a direct
+ * reviewer. This often happens when an account was specified at upload time with the {@code --cc}
+ * command line flag, or have published comments, but left the approval scores at 0 ("No Score").
+ *
+ * <p>If an Account has one or more PatchSetApprovals with a score != 0, the Change shows in their
+ * dashboard, and they are said to be an active reviewer. Such individuals are highlighted when
+ * notice of a replacement patch set is sent, or when notice of the change submission occurs.
  */
 public final class Change {
   public static class Id extends IntKey<com.google.gwtorm.client.Key<?>> {
@@ -101,8 +100,7 @@
     @Column(id = 1)
     public int id;
 
-    protected Id() {
-    }
+    protected Id() {}
 
     public Id(final int id) {
       this.id = id;
@@ -123,16 +121,12 @@
     }
 
     StringBuilder refPrefixBuilder() {
-      StringBuilder r = new StringBuilder(32)
-         .append(REFS_CHANGES);
+      StringBuilder r = new StringBuilder(32).append(REFS_CHANGES);
       int m = id % 100;
       if (m < 10) {
         r.append('0');
       }
-      return r.append(m)
-          .append('/')
-          .append(id)
-          .append('/');
+      return r.append(m).append('/').append(id).append('/');
     }
 
     /** Parse a Change.Id out of a string representation. */
@@ -143,21 +137,59 @@
     }
 
     public static Id fromRef(String ref) {
+      if (RefNames.isRefsEdit(ref)) {
+        return fromEditRefPart(ref);
+      }
       int cs = startIndex(ref);
       if (cs < 0) {
         return null;
       }
       int ce = nextNonDigit(ref, cs);
       if (ref.substring(ce).equals(RefNames.META_SUFFIX)
+          || ref.substring(ce).equals(RefNames.ROBOT_COMMENTS_SUFFIX)
           || PatchSet.Id.fromRef(ref, ce) >= 0) {
         return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
       }
       return null;
     }
 
+    public static Id fromAllUsersRef(String ref) {
+      if (ref == null) {
+        return null;
+      }
+      String prefix;
+      if (ref.startsWith(RefNames.REFS_STARRED_CHANGES)) {
+        prefix = RefNames.REFS_STARRED_CHANGES;
+      } else if (ref.startsWith(RefNames.REFS_DRAFT_COMMENTS)) {
+        prefix = RefNames.REFS_DRAFT_COMMENTS;
+      } else {
+        return null;
+      }
+      int cs = startIndex(ref, prefix);
+      if (cs < 0) {
+        return null;
+      }
+      int ce = nextNonDigit(ref, cs);
+      if (ce < ref.length() && ref.charAt(ce) == '/' && isNumeric(ref, ce + 1)) {
+        return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
+      }
+      return null;
+    }
+
+    private static boolean isNumeric(String s, int off) {
+      if (off >= s.length()) {
+        return false;
+      }
+      for (int i = off; i < s.length(); i++) {
+        if (!Character.isDigit(s.charAt(i))) {
+          return false;
+        }
+      }
+      return true;
+    }
+
     public static Id fromEditRefPart(String ref) {
-      int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) +
-          RefNames.EDIT_PREFIX.length();
+      int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) + RefNames.EDIT_PREFIX.length();
       int endChangeId = nextNonDigit(ref, startChangeId);
       String id = ref.substring(startChangeId, endChangeId);
       if (id != null && !id.isEmpty()) {
@@ -172,12 +204,16 @@
     }
 
     static int startIndex(String ref) {
-      if (ref == null || !ref.startsWith(REFS_CHANGES)) {
+      return startIndex(ref, REFS_CHANGES);
+    }
+
+    static int startIndex(String ref, String expectedPrefix) {
+      if (ref == null || !ref.startsWith(expectedPrefix)) {
         return -1;
       }
 
       // Last 2 digits.
-      int ls = REFS_CHANGES.length();
+      int ls = expectedPrefix.length();
       int le = nextNonDigit(ref, ls);
       if (le - ls != 2 || le >= ref.length() || ref.charAt(le) != '/') {
         return -1;
@@ -196,14 +232,12 @@
         case 0:
           return -1;
         case 1:
-          if (ref.charAt(ls) != '0'
-              || ref.charAt(ls + 1) != ref.charAt(cs)) {
+          if (ref.charAt(ls) != '0' || ref.charAt(ls + 1) != ref.charAt(cs)) {
             return -1;
           }
           break;
         default:
-          if (ref.charAt(ls) != ref.charAt(ce - 2)
-              || ref.charAt(ls + 1) != ref.charAt(ce - 1)) {
+          if (ref.charAt(ls) != ref.charAt(ce - 2) || ref.charAt(ls + 1) != ref.charAt(ce - 1)) {
             return -1;
           }
           break;
@@ -226,8 +260,7 @@
     @Column(id = 1, length = 60)
     protected String id;
 
-    protected Key() {
-    }
+    protected Key() {}
 
     public Key(final String id) {
       this.id = id;
@@ -286,26 +319,23 @@
   /**
    * Current state within the basic workflow of the change.
    *
-   * <p>
-   * Within the database, lower case codes ('a'..'z') indicate a change that is
-   * still open, and that can be modified/refined further, while upper case
-   * codes ('A'..'Z') indicate a change that is closed and cannot be further
-   * modified.
-   * */
+   * <p>Within the database, lower case codes ('a'..'z') indicate a change that is still open, and
+   * that can be modified/refined further, while upper case codes ('A'..'Z') indicate a change that
+   * is closed and cannot be further modified.
+   */
   public enum Status {
     /**
      * Change is open and pending review, or review is in progress.
      *
-     * <p>
-     * This is the default state assigned to a change when it is first created
-     * in the database. A change stays in the NEW state throughout its review
-     * cycle, until the change is submitted or abandoned.
+     * <p>This is the default state assigned to a change when it is first created in the database. A
+     * change stays in the NEW state throughout its review cycle, until the change is submitted or
+     * abandoned.
      *
-     * <p>
-     * Changes in the NEW state can be moved to:
+     * <p>Changes in the NEW state can be moved to:
+     *
      * <ul>
-     * <li>{@link #MERGED} - when the Submit Patch Set action is used;
-     * <li>{@link #ABANDONED} - when the Abandon action is used.
+     *   <li>{@link #MERGED} - when the Submit Patch Set action is used;
+     *   <li>{@link #ABANDONED} - when the Abandon action is used.
      * </ul>
      */
     NEW(STATUS_NEW, ChangeStatus.NEW),
@@ -313,17 +343,15 @@
     /**
      * Change is a draft change that only consists of draft patchsets.
      *
-     * <p>
-     * This is a change that is not meant to be submitted or reviewed yet. If
-     * the uploader publishes the change, it becomes a NEW change.
-     * Publishing is a one-way action, a change cannot return to DRAFT status.
-     * Draft changes are only visible to the uploader and those explicitly
-     * added as reviewers.
+     * <p>This is a change that is not meant to be submitted or reviewed yet. If the uploader
+     * publishes the change, it becomes a NEW change. Publishing is a one-way action, a change
+     * cannot return to DRAFT status. Draft changes are only visible to the uploader and those
+     * explicitly added as reviewers.
      *
-     * <p>
-     * Changes in the DRAFT state can be moved to:
+     * <p>Changes in the DRAFT state can be moved to:
+     *
      * <ul>
-     * <li>{@link #NEW} - when the change is published, it becomes a new change;
+     *   <li>{@link #NEW} - when the change is published, it becomes a new change;
      * </ul>
      */
     DRAFT(STATUS_DRAFT, ChangeStatus.DRAFT),
@@ -331,20 +359,17 @@
     /**
      * Change is closed, and submitted to its destination branch.
      *
-     * <p>
-     * Once a change has been merged, it cannot be further modified by adding a
-     * replacement patch set. Draft comments however may be published,
-     * supporting a post-submit review.
+     * <p>Once a change has been merged, it cannot be further modified by adding a replacement patch
+     * set. Draft comments however may be published, supporting a post-submit review.
      */
     MERGED(STATUS_MERGED, ChangeStatus.MERGED),
 
     /**
      * Change is closed, but was not submitted to its destination branch.
      *
-     * <p>
-     * Once a change has been abandoned, it cannot be further modified by adding
-     * a replacement patch set, and it cannot be merged. Draft comments however
-     * may be published, permitting reviewers to send constructive feedback.
+     * <p>Once a change has been abandoned, it cannot be further modified by adding a replacement
+     * patch set, and it cannot be merged. Draft comments however may be published, permitting
+     * reviewers to send constructive feedback.
      */
     ABANDONED('A', ChangeStatus.ABANDONED);
 
@@ -357,9 +382,11 @@
         ok &= s.name().equals(s.changeStatus.name());
       }
       if (!ok) {
-        throw new IllegalStateException("Mismatched status mapping: "
-            + Arrays.asList(Status.values()) + " != "
-            + Arrays.asList(ChangeStatus.values()));
+        throw new IllegalStateException(
+            "Mismatched status mapping: "
+                + Arrays.asList(Status.values())
+                + " != "
+                + Arrays.asList(ChangeStatus.values()));
       }
     }
 
@@ -427,8 +454,8 @@
 
   /**
    * When was a meaningful modification last made to this record's data
-   * <p>
-   * Note, this update timestamp includes its children.
+   *
+   * <p>Note, this update timestamp includes its children.
    */
   @Column(id = 5)
   protected Timestamp lastUpdatedOn;
@@ -467,29 +494,36 @@
 
   /**
    * First line of first patch set's commit message.
-   * <p>
-   * Unlike {@link #subject}, this string does not change if future patch sets
-   * change the first line.
+   *
+   * <p>Unlike {@link #subject}, this string does not change if future patch sets change the first
+   * line.
    */
   @Column(id = 17, notNull = false)
   protected String originalSubject;
 
   /**
-   * Unique id for the changes submitted together assigned during merging.
-   * Only set if the status is MERGED.
+   * Unique id for the changes submitted together assigned during merging. Only set if the status is
+   * MERGED.
    */
   @Column(id = 18, notNull = false)
   protected String submissionId;
 
+  /** Allows assigning a change to a user. */
+  @Column(id = 19, notNull = false)
+  protected Account.Id assignee;
+
   /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
   @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
   protected String noteDbState;
 
-  protected Change() {
-  }
+  protected Change() {}
 
-  public Change(Change.Key newKey, Change.Id newId, Account.Id ownedBy,
-      Branch.NameKey forBranch, Timestamp ts) {
+  public Change(
+      Change.Key newKey,
+      Change.Id newId,
+      Account.Id ownedBy,
+      Branch.NameKey forBranch,
+      Timestamp ts) {
     changeKey = newKey;
     changeId = newId;
     createdOn = ts;
@@ -500,6 +534,7 @@
   }
 
   public Change(Change other) {
+    assignee = other.assignee;
     changeId = other.changeId;
     changeKey = other.changeKey;
     rowVersion = other.rowVersion;
@@ -535,6 +570,14 @@
     changeKey = k;
   }
 
+  public Account.Id getAssignee() {
+    return assignee;
+  }
+
+  public void setAssignee(Account.Id a) {
+    assignee = a;
+  }
+
   public Timestamp getCreatedOn() {
     return createdOn;
   }
@@ -612,11 +655,9 @@
     }
   }
 
-  public void setCurrentPatchSet(PatchSet.Id psId, String subject,
-      String originalSubject) {
+  public void setCurrentPatchSet(PatchSet.Id psId, String subject, String originalSubject) {
     if (!psId.getParentKey().equals(changeId)) {
-      throw new IllegalArgumentException(
-          "patch set ID " + psId + " is not for change " + changeId);
+      throw new IllegalArgumentException("patch set ID " + psId + " is not for change " + changeId);
     }
     currentPatchSetId = psId.get();
     this.subject = subject;
@@ -664,10 +705,17 @@
   @Override
   public String toString() {
     return new StringBuilder(getClass().getSimpleName())
-        .append('{').append(changeId)
-        .append(" (").append(changeKey).append("), ")
-        .append("dest=").append(dest).append(", ")
-        .append("status=").append(status).append('}')
+        .append('{')
+        .append(changeId)
+        .append(" (")
+        .append(changeKey)
+        .append("), ")
+        .append("dest=")
+        .append(dest)
+        .append(", ")
+        .append("status=")
+        .append(status)
+        .append('}')
         .toString();
   }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
index 898dc94..caf20c7 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
@@ -16,8 +16,8 @@
 
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.StringKey;
-
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /** A message attached to a {@link Change}. */
 public final class ChangeMessage {
@@ -78,11 +78,14 @@
   @Column(id = 6, notNull = false)
   protected String tag;
 
-  protected ChangeMessage() {
-  }
+  /** Real user that added this message on behalf of the user recorded in {@link #author}. */
+  @Column(id = 7, notNull = false)
+  protected Account.Id realAuthor;
 
-  public ChangeMessage(final ChangeMessage.Key k, final Account.Id a,
-      final Timestamp wo, final PatchSet.Id psid) {
+  protected ChangeMessage() {}
+
+  public ChangeMessage(
+      final ChangeMessage.Key k, final Account.Id a, final Timestamp wo, final PatchSet.Id psid) {
     key = k;
     author = a;
     writtenOn = wo;
@@ -105,6 +108,15 @@
     author = accountId;
   }
 
+  public Account.Id getRealAuthor() {
+    return realAuthor != null ? realAuthor : getAuthor();
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
+  }
+
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
@@ -140,12 +152,20 @@
   @Override
   public String toString() {
     return "ChangeMessage{"
-        + "key=" + key
-        + ", author=" + author
-        + ", writtenOn=" + writtenOn
-        + ", patchset=" + patchset
-        + ", tag=" + tag
-        + ", message=[" + message
+        + "key="
+        + key
+        + ", author="
+        + author
+        + ", realAuthor="
+        + realAuthor
+        + ", writtenOn="
+        + writtenOn
+        + ", patchset="
+        + patchset
+        + ", tag="
+        + tag
+        + ", message=["
+        + message
         + "]}";
   }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
new file mode 100644
index 0000000..cadd52c
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -0,0 +1,319 @@
+// 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.reviewdb.client;
+
+import java.sql.Timestamp;
+import java.util.Objects;
+
+/**
+ * This class represents inline comments in NoteDb. This means it determines the JSON format for
+ * inline comments in the revision notes that NoteDb uses to persist inline comments.
+ *
+ * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
+ * require a corresponding data migration (adding new optional fields is generally okay).
+ *
+ * <p>{@link PatchLineComment} also represents inline comments, but in ReviewDb. There are a few
+ * notable differences:
+ *
+ * <ul>
+ *   <li>PatchLineComment knows the comment status (published or draft). For comments in NoteDb the
+ *       status is determined by the branch in which they are stored (published comments are stored
+ *       in the change meta ref; draft comments are store in refs/draft-comments branches in
+ *       All-Users). Hence Comment doesn't need to contain the status, but the status is implicitly
+ *       known by where the comments are read from.
+ *   <li>PatchLineComment knows the change ID. For comments in NoteDb, the change ID is determined
+ *       by the branch in which they are stored (the ref name contains the change ID). Hence Comment
+ *       doesn't need to contain the change ID, but the change ID is implicitly known by where the
+ *       comments are read from.
+ * </ul>
+ *
+ * <p>For all utility classes and middle layer functionality using Comment over PatchLineComment is
+ * preferred, as PatchLineComment will go away together with ReviewDb. This means Comment should be
+ * used everywhere and only for storing inline comment in ReviewDb a conversion to PatchLineComment
+ * is done. Converting Comments to PatchLineComments and vice verse is done by
+ * CommentsUtil#toPatchLineComments(Change.Id, PatchLineComment.Status, Iterable) and
+ * CommentsUtil#toComments(String, Iterable).
+ */
+public class Comment {
+  public static class Key {
+    public String uuid;
+    public String filename;
+    public int patchSetId;
+
+    public Key(Key k) {
+      this(k.uuid, k.filename, k.patchSetId);
+    }
+
+    public Key(String uuid, String filename, int patchSetId) {
+      this.uuid = uuid;
+      this.filename = filename;
+      this.patchSetId = patchSetId;
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Key{")
+          .append("uuid=")
+          .append(uuid)
+          .append(',')
+          .append("filename=")
+          .append(filename)
+          .append(',')
+          .append("patchSetId=")
+          .append(patchSetId)
+          .append('}')
+          .toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Key) {
+        Key k = (Key) o;
+        return Objects.equals(uuid, k.uuid)
+            && Objects.equals(filename, k.filename)
+            && Objects.equals(patchSetId, k.patchSetId);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(uuid, filename, patchSetId);
+    }
+  }
+
+  public static class Identity {
+    int id;
+
+    public Identity(Account.Id id) {
+      this.id = id.get();
+    }
+
+    public Account.Id getId() {
+      return new Account.Id(id);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Identity) {
+        return Objects.equals(id, ((Identity) o).id);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(id);
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Identity{")
+          .append("id=")
+          .append(id)
+          .append('}')
+          .toString();
+    }
+  }
+
+  public static class Range {
+    public int startLine; // 1-based, inclusive
+    public int startChar; // 0-based, inclusive
+    public int endLine; // 1-based, exclusive
+    public int endChar; // 0-based, exclusive
+
+    public Range(Range r) {
+      this(r.startLine, r.startChar, r.endLine, r.endChar);
+    }
+
+    public Range(com.google.gerrit.extensions.client.Comment.Range r) {
+      this(r.startLine, r.startCharacter, r.endLine, r.endCharacter);
+    }
+
+    public Range(int startLine, int startChar, int endLine, int endChar) {
+      this.startLine = startLine;
+      this.startChar = startChar;
+      this.endLine = endLine;
+      this.endChar = endChar;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Range) {
+        Range r = (Range) o;
+        return Objects.equals(startLine, r.startLine)
+            && Objects.equals(startChar, r.startChar)
+            && Objects.equals(endLine, r.endLine)
+            && Objects.equals(endChar, r.endChar);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(startLine, startChar, endLine, endChar);
+    }
+
+    @Override
+    public String toString() {
+      return new StringBuilder()
+          .append("Comment.Range{")
+          .append("startLine=")
+          .append(startLine)
+          .append(',')
+          .append("startChar=")
+          .append(startChar)
+          .append(',')
+          .append("endLine=")
+          .append(endLine)
+          .append(',')
+          .append("endChar=")
+          .append(endChar)
+          .append('}')
+          .toString();
+    }
+  }
+
+  public Key key;
+  public int lineNbr;
+  public Identity author;
+  protected Identity realAuthor;
+  public Timestamp writtenOn;
+  public short side;
+  public String message;
+  public String parentUuid;
+  public Range range;
+  public String tag;
+  public String revId;
+  public String serverId;
+  public boolean unresolved;
+
+  public Comment(Comment c) {
+    this(
+        new Key(c.key),
+        c.author.getId(),
+        new Timestamp(c.writtenOn.getTime()),
+        c.side,
+        c.message,
+        c.serverId,
+        c.unresolved);
+    this.lineNbr = c.lineNbr;
+    this.realAuthor = c.realAuthor;
+    this.range = c.range != null ? new Range(c.range) : null;
+    this.tag = c.tag;
+    this.revId = c.revId;
+    this.unresolved = c.unresolved;
+  }
+
+  public Comment(
+      Key key,
+      Account.Id author,
+      Timestamp writtenOn,
+      short side,
+      String message,
+      String serverId,
+      boolean unresolved) {
+    this.key = key;
+    this.author = new Comment.Identity(author);
+    this.realAuthor = this.author;
+    this.writtenOn = writtenOn;
+    this.side = side;
+    this.message = message;
+    this.serverId = serverId;
+    this.unresolved = unresolved;
+  }
+
+  public void setLineNbrAndRange(
+      Integer lineNbr, com.google.gerrit.extensions.client.Comment.Range range) {
+    this.lineNbr = lineNbr != null ? lineNbr : range != null ? range.endLine : 0;
+    if (range != null) {
+      this.range = new Comment.Range(range);
+    }
+  }
+
+  public void setRange(CommentRange range) {
+    this.range = range != null ? range.asCommentRange() : null;
+  }
+
+  public void setRevId(RevId revId) {
+    this.revId = revId != null ? revId.get() : null;
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    realAuthor = id != null && id.get() != author.id ? new Comment.Identity(id) : null;
+  }
+
+  public Identity getRealAuthor() {
+    return realAuthor != null ? realAuthor : author;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof Comment) {
+      return Objects.equals(key, ((Comment) o).key);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return key.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return new StringBuilder()
+        .append("Comment{")
+        .append("key=")
+        .append(key)
+        .append(',')
+        .append("lineNbr=")
+        .append(lineNbr)
+        .append(',')
+        .append("author=")
+        .append(author.getId().get())
+        .append(',')
+        .append("realAuthor=")
+        .append(realAuthor != null ? realAuthor.getId().get() : "")
+        .append(',')
+        .append("writtenOn=")
+        .append(writtenOn.toString())
+        .append(',')
+        .append("side=")
+        .append(side)
+        .append(',')
+        .append("message=")
+        .append(Objects.toString(message, ""))
+        .append(',')
+        .append("parentUuid=")
+        .append(Objects.toString(parentUuid, ""))
+        .append(',')
+        .append("range=")
+        .append(Objects.toString(range, ""))
+        .append(',')
+        .append("revId=")
+        .append(revId != null ? revId : "")
+        .append(',')
+        .append("tag=")
+        .append(Objects.toString(tag, ""))
+        .append(',')
+        .append("unresolved=")
+        .append(unresolved)
+        .append('}')
+        .toString();
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
index 5a98d94..b9da8d5 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CommentRange.java
@@ -30,8 +30,7 @@
   @Column(id = 4)
   protected int endCharacter;
 
-  protected CommentRange() {
-  }
+  protected CommentRange() {}
 
   public CommentRange(int sl, int sc, int el, int ec) {
     startLine = sl;
@@ -72,12 +71,18 @@
     endCharacter = ec;
   }
 
+  public Comment.Range asCommentRange() {
+    return new Comment.Range(startLine, startCharacter, endLine, endCharacter);
+  }
+
   @Override
   public boolean equals(Object obj) {
     if (obj instanceof CommentRange) {
       CommentRange other = (CommentRange) obj;
-      return startLine == other.startLine && startCharacter == other.startCharacter &&
-          endLine == other.endLine && endCharacter == other.endCharacter;
+      return startLine == other.startLine
+          && startCharacter == other.startCharacter
+          && endLine == other.endLine
+          && endCharacter == other.endCharacter;
     }
     return false;
   }
@@ -93,7 +98,14 @@
 
   @Override
   public String toString() {
-    return "Range[startLine=" + startLine + ", startCharacter=" + startCharacter
-        + ", endLine=" + endLine + ", endCharacter=" + endCharacter + "]";
+    return "Range[startLine="
+        + startLine
+        + ", startCharacter="
+        + startCharacter
+        + ", endLine="
+        + endLine
+        + ", endCharacter="
+        + endCharacter
+        + "]";
   }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java
index 9303373..2ca89c8 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CoreDownloadSchemes.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.reviewdb.client;
 
-/**
- * Download scheme string constants supported by the download-commands core
- * plugin.
- */
+/** Download scheme string constants supported by the download-commands core plugin. */
 public class CoreDownloadSchemes {
   public static final String ANON_GIT = "git";
   public static final String ANON_HTTP = "anonymous http";
@@ -25,6 +22,5 @@
   public static final String SSH = "ssh";
   public static final String REPO_DOWNLOAD = "repo";
 
-  private CoreDownloadSchemes() {
-  }
+  private CoreDownloadSchemes() {}
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
index cba5d41..9d61186 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
@@ -19,8 +19,7 @@
 
 /** Current version of the database schema, to facilitate live upgrades. */
 public final class CurrentSchemaVersion {
-  public static final class Key extends
-      StringKey<com.google.gwtorm.client.Key<?>> {
+  public static final class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
     private static final String VALUE = "X";
@@ -28,8 +27,7 @@
     @Column(id = 1, length = 1)
     public String one = VALUE;
 
-    public Key() {
-    }
+    public Key() {}
 
     @Override
     public String get() {
@@ -56,6 +54,5 @@
   @Column(id = 2)
   public transient int versionNbr;
 
-  public CurrentSchemaVersion() {
-  }
+  public CurrentSchemaVersion() {}
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixReplacement.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixReplacement.java
new file mode 100644
index 0000000..66630e4
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixReplacement.java
@@ -0,0 +1,41 @@
+// 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.reviewdb.client;
+
+public class FixReplacement {
+  public String path;
+  public Comment.Range range;
+  public String replacement;
+
+  public FixReplacement(String path, Comment.Range range, String replacement) {
+    this.path = path;
+    this.range = range;
+    this.replacement = replacement;
+  }
+
+  @Override
+  public String toString() {
+    return "FixReplacement{"
+        + "path='"
+        + path
+        + '\''
+        + ", range="
+        + range
+        + ", replacement='"
+        + replacement
+        + '\''
+        + '}';
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixSuggestion.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixSuggestion.java
new file mode 100644
index 0000000..d766a3a
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/FixSuggestion.java
@@ -0,0 +1,43 @@
+// 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.reviewdb.client;
+
+import java.util.List;
+
+public class FixSuggestion {
+  public String fixId;
+  public String description;
+  public List<FixReplacement> replacements;
+
+  public FixSuggestion(String fixId, String description, List<FixReplacement> replacements) {
+    this.fixId = fixId;
+    this.description = description;
+    this.replacements = replacements;
+  }
+
+  @Override
+  public String toString() {
+    return "FixSuggestion{"
+        + "fixId='"
+        + fixId
+        + '\''
+        + ", description='"
+        + description
+        + '\''
+        + ", replacements="
+        + replacements
+        + '}';
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
index 5239447..c38078e 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
@@ -29,8 +29,7 @@
   @Column(id = 1)
   public String id;
 
-  public LabelId() {
-  }
+  public LabelId() {}
 
   public LabelId(final String n) {
     id = n;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
index 6a55965..269b6d4 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
@@ -22,6 +22,20 @@
   /** Magical file name which represents the commit message. */
   public static final String COMMIT_MSG = "/COMMIT_MSG";
 
+  /** Magical file name which represents the merge list of a merge commit. */
+  public static final String MERGE_LIST = "/MERGE_LIST";
+
+  /**
+   * Checks if the given path represents a magic file. A magic file is a generated file that is
+   * automatically included into changes. It does not exist in the commit of the patch set.
+   *
+   * @param path the file path
+   * @return {@code true} if the path represents a magic file, otherwise {@code false}.
+   */
+  public static boolean isMagic(String path) {
+    return COMMIT_MSG.equals(path) || MERGE_LIST.equals(path);
+  }
+
   public static class Key extends StringKey<PatchSet.Id> {
     private static final long serialVersionUID = 1L;
 
@@ -117,32 +131,26 @@
     /**
      * A textual difference between two versions.
      *
-     * <p>
-     * A UNIFIED patch can be rendered in multiple ways. Most commonly, it is
-     * rendered as a side by side display using two columns, left column for the
-     * old version, right column for the new version. A UNIFIED patch can also
-     * be formatted in a number of standard "patch script" styles, but typically
-     * is formatted in the POSIX standard unified diff format.
+     * <p>A UNIFIED patch can be rendered in multiple ways. Most commonly, it is rendered as a side
+     * by side display using two columns, left column for the old version, right column for the new
+     * version. A UNIFIED patch can also be formatted in a number of standard "patch script" styles,
+     * but typically is formatted in the POSIX standard unified diff format.
      *
-     * <p>
-     * Usually Gerrit renders a UNIFIED patch in a PatchScreen.SideBySide view,
-     * presenting the file in two columns. If the user chooses, a
-     * PatchScreen.Unified is also a valid display method.
-     * */
+     * <p>Usually Gerrit renders a UNIFIED patch in a PatchScreen.SideBySide view, presenting the
+     * file in two columns. If the user chooses, a PatchScreen.Unified is also a valid display
+     * method.
+     */
     UNIFIED('U'),
 
     /**
      * Difference of two (or more) binary contents.
      *
-     * <p>
-     * A BINARY patch cannot be viewed in a text display, as it represents a
-     * change in binary content at the associated path, for example, an image
-     * file has been replaced with a different image.
+     * <p>A BINARY patch cannot be viewed in a text display, as it represents a change in binary
+     * content at the associated path, for example, an image file has been replaced with a different
+     * image.
      *
-     * <p>
-     * Gerrit can only render a BINARY file in a PatchScreen.Unified view, as
-     * the only information it can display is the old and new file content
-     * hashes.
+     * <p>Gerrit can only render a BINARY file in a PatchScreen.Unified view, as the only
+     * information it can display is the old and new file content hashes.
      */
     BINARY('B');
 
@@ -187,17 +195,13 @@
   /** Number of lines deleted from the file. */
   protected int deletions;
 
-  /**
-   * Original if {@link #changeType} is {@link ChangeType#COPIED} or
-   * {@link ChangeType#RENAMED}.
-   */
+  /** Original if {@link #changeType} is {@link ChangeType#COPIED} or {@link ChangeType#RENAMED}. */
   protected String sourceFileName;
 
   /** True if this patch has been reviewed by the current logged in user */
   private boolean reviewedByCurrentUser;
 
-  protected Patch() {
-  }
+  protected Patch() {}
 
   public Patch(final Patch.Key newId) {
     key = newId;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
index 16b2d61..90552b8 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
@@ -17,15 +17,27 @@
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.StringKey;
-
 import java.sql.Timestamp;
 import java.util.Objects;
 
-/** A comment left by a user on a specific line of a {@link Patch}. */
+/**
+ * A comment left by a user on a specific line of a {@link Patch}.
+ *
+ * <p>This class represents an inline comment in ReviewDb. It should only be used for
+ * writing/reading inline comments to/from ReviewDb. For all other purposes inline comments should
+ * be represented by {@link Comment}.
+ *
+ * @see Comment
+ */
 public final class PatchLineComment {
   public static class Key extends StringKey<Patch.Key> {
     private static final long serialVersionUID = 1L;
 
+    public static Key from(Change.Id changeId, Comment.Key key) {
+      return new Key(
+          new Patch.Key(new PatchSet.Id(changeId, key.patchSetId), key.filename), key.uuid);
+    }
+
     @Column(id = 1, name = Column.NONE)
     protected Patch.Key patchKey;
 
@@ -55,6 +67,11 @@
     public void set(String newValue) {
       uuid = newValue;
     }
+
+    public Comment.Key asCommentKey() {
+      return new Comment.Key(
+          get(), getParentKey().getFileName(), getParentKey().getParentKey().get());
+    }
   }
 
   public static final char STATUS_DRAFT = 'd';
@@ -85,6 +102,28 @@
     }
   }
 
+  public static PatchLineComment from(
+      Change.Id changeId, PatchLineComment.Status status, Comment c) {
+    PatchLineComment.Key key =
+        new PatchLineComment.Key(
+            new Patch.Key(new PatchSet.Id(changeId, c.key.patchSetId), c.key.filename), c.key.uuid);
+
+    PatchLineComment plc =
+        new PatchLineComment(key, c.lineNbr, c.author.getId(), c.parentUuid, c.writtenOn);
+    plc.setSide(c.side);
+    plc.setMessage(c.message);
+    if (c.range != null) {
+      Comment.Range r = c.range;
+      plc.setRange(new CommentRange(r.startLine, r.startChar, r.endLine, r.endChar));
+    }
+    plc.setTag(c.tag);
+    plc.setRevId(new RevId(c.revId));
+    plc.setStatus(status);
+    plc.setRealAuthor(c.getRealAuthor().getId());
+    plc.setUnresolved(c.unresolved);
+    return plc;
+  }
+
   @Column(id = 1, name = Column.NONE)
   protected Key key;
 
@@ -112,10 +151,7 @@
   @Column(id = 7, notNull = false, length = Integer.MAX_VALUE)
   protected String message;
 
-  /**
-   * The parent of this comment, or null if this is the first comment on this
-   * line
-   */
+  /** The parent of this comment, or null if this is the first comment on this line */
   @Column(id = 8, length = 40, notNull = false)
   protected String parentUuid;
 
@@ -125,20 +161,26 @@
   @Column(id = 10, notNull = false)
   protected String tag;
 
+  /** Real user that added this comment on behalf of the user recorded in {@link #author}. */
+  @Column(id = 11, notNull = false)
+  protected Account.Id realAuthor;
+
+  /** True if this comment requires further action. */
+  @Column(id = 12)
+  protected boolean unresolved;
+
   /**
    * The RevId for the commit to which this comment is referring.
    *
-   * Note that this field is not stored in the database. It is just provided
-   * for users of this class to avoid a lookup when they don't have easy access
-   * to a ReviewDb.
+   * <p>Note that this field is not stored in the database. It is just provided for users of this
+   * class to avoid a lookup when they don't have easy access to a ReviewDb.
    */
   protected RevId revId;
 
-  protected PatchLineComment() {
-  }
+  protected PatchLineComment() {}
 
-  public PatchLineComment(PatchLineComment.Key id, int line, Account.Id a,
-      String parentUuid, Timestamp when) {
+  public PatchLineComment(
+      PatchLineComment.Key id, int line, Account.Id a, String parentUuid, Timestamp when) {
     key = id;
     lineNbr = line;
     author = a;
@@ -151,6 +193,7 @@
     key = o.key;
     lineNbr = o.lineNbr;
     author = o.author;
+    realAuthor = o.realAuthor;
     writtenOn = o.writtenOn;
     status = o.status;
     side = o.side;
@@ -158,11 +201,12 @@
     parentUuid = o.parentUuid;
     revId = o.revId;
     if (o.range != null) {
-      range = new CommentRange(
-          o.range.getStartLine(),
-          o.range.getStartCharacter(),
-          o.range.getEndLine(),
-          o.range.getEndCharacter());
+      range =
+          new CommentRange(
+              o.range.getStartLine(),
+              o.range.getStartCharacter(),
+              o.range.getEndLine(),
+              o.range.getEndCharacter());
     }
   }
 
@@ -186,6 +230,15 @@
     return author;
   }
 
+  public Account.Id getRealAuthor() {
+    return realAuthor != null ? realAuthor : getAuthor();
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
+  }
+
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
@@ -228,9 +281,10 @@
 
   public void setRange(Range r) {
     if (r != null) {
-      range = new CommentRange(
-          r.startLine, r.startCharacter,
-          r.endLine, r.endCharacter);
+      range =
+          new CommentRange(
+              r.startLine, r.startCharacter,
+              r.endLine, r.endCharacter);
     } else {
       range = null;
     }
@@ -260,6 +314,26 @@
     return tag;
   }
 
+  public void setUnresolved(Boolean unresolved) {
+    this.unresolved = unresolved;
+  }
+
+  public Boolean getUnresolved() {
+    return unresolved;
+  }
+
+  public Comment asComment(String serverId) {
+    Comment c =
+        new Comment(key.asCommentKey(), author, writtenOn, side, message, serverId, unresolved);
+    c.setRevId(revId);
+    c.setRange(range);
+    c.lineNbr = lineNbr;
+    c.parentUuid = parentUuid;
+    c.tag = tag;
+    c.setRealAuthor(getRealAuthor());
+    return c;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof PatchLineComment) {
@@ -274,7 +348,8 @@
           && Objects.equals(parentUuid, c.getParentUuid())
           && Objects.equals(range, c.getRange())
           && Objects.equals(revId, c.getRevId())
-          && Objects.equals(tag, c.getTag());
+          && Objects.equals(tag, c.getTag())
+          && Objects.equals(unresolved, c.getUnresolved());
     }
     return false;
   }
@@ -291,17 +366,16 @@
     builder.append("key=").append(key).append(',');
     builder.append("lineNbr=").append(lineNbr).append(',');
     builder.append("author=").append(author.get()).append(',');
+    builder.append("realAuthor=").append(realAuthor != null ? realAuthor.get() : "").append(',');
     builder.append("writtenOn=").append(writtenOn.toString()).append(',');
     builder.append("status=").append(status).append(',');
     builder.append("side=").append(side).append(',');
-    builder.append("message=").append(Objects.toString(message, ""))
-      .append(',');
-    builder.append("parentUuid=").append(Objects.toString(parentUuid, ""))
-      .append(',');
-    builder.append("range=").append(Objects.toString(range, ""))
-      .append(',');
-    builder.append("revId=").append(revId != null ? revId.get() : "");
-    builder.append("tag=").append(Objects.toString(tag, ""));
+    builder.append("message=").append(Objects.toString(message, "")).append(',');
+    builder.append("parentUuid=").append(Objects.toString(parentUuid, "")).append(',');
+    builder.append("range=").append(Objects.toString(range, "")).append(',');
+    builder.append("revId=").append(revId != null ? revId.get() : "").append(',');
+    builder.append("tag=").append(Objects.toString(tag, "")).append(',');
+    builder.append("unresolved=").append(unresolved);
     builder.append('}');
     return builder.toString();
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
index a8bf07b..aa61511 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -16,7 +16,6 @@
 
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.IntKey;
-
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -33,7 +32,7 @@
    * Is the reference name a change reference?
    *
    * @deprecated use isChangeRef instead.
-   **/
+   */
   @Deprecated
   public static boolean isRef(String name) {
     return isChangeRef(name);
@@ -108,9 +107,7 @@
     }
 
     public String toRefName() {
-      return changeId.refPrefixBuilder()
-          .append(patchSetId)
-          .toString();
+      return changeId.refPrefixBuilder().append(patchSetId).toString();
     }
 
     /** Parse a PatchSet.Id out of a string representation. */
@@ -154,9 +151,7 @@
     }
 
     public static String toId(int number) {
-      return number == 0
-          ? "edit"
-          : String.valueOf(number);
+      return number == 0 ? "edit" : String.valueOf(number);
     }
   }
 
@@ -178,29 +173,48 @@
 
   /**
    * Opaque group identifier, usually assigned during creation.
-   * <p>
-   * This field is actually a comma-separated list of values, as in rare cases
-   * involving merge commits a patch set may belong to multiple groups.
-   * <p>
-   * Changes on the same branch having patch sets with intersecting groups are
-   * considered related, as in the "Related Changes" tab.
+   *
+   * <p>This field is actually a comma-separated list of values, as in rare cases involving merge
+   * commits a patch set may belong to multiple groups.
+   *
+   * <p>Changes on the same branch having patch sets with intersecting groups are considered
+   * related, as in the "Related Changes" tab.
    */
   @Column(id = 6, notNull = false, length = Integer.MAX_VALUE)
   protected String groups;
 
-  //DELETED id = 7 (pushCertficate)
+  // DELETED id = 7 (pushCertficate)
 
   /** Certificate sent with a push that created this patch set. */
   @Column(id = 8, notNull = false, length = Integer.MAX_VALUE)
   protected String pushCertificate;
 
-  protected PatchSet() {
-  }
+  /**
+   * Optional user-supplied description for this patch set.
+   *
+   * <p>When this field is null, the description was never set on the patch set. When this field is
+   * an empty string, the description was set and later cleared.
+   */
+  @Column(id = 9, notNull = false, length = Integer.MAX_VALUE)
+  protected String description;
+
+  protected PatchSet() {}
 
   public PatchSet(final PatchSet.Id k) {
     id = k;
   }
 
+  public PatchSet(PatchSet src) {
+    this.id = src.id;
+    this.revision = src.revision;
+    this.uploader = src.uploader;
+    this.createdOn = src.createdOn;
+    this.draft = src.draft;
+    this.groups = src.groups;
+    this.pushCertificate = src.pushCertificate;
+    this.description = src.description;
+  }
+
   public PatchSet.Id getId() {
     return id;
   }
@@ -267,6 +281,14 @@
     pushCertificate = cert;
   }
 
+  public String getDescription() {
+    return description;
+  }
+
+  public void setDescription(String description) {
+    this.description = description;
+  }
+
   @Override
   public String toString() {
     return "[PatchSet " + getId().toString() + "]";
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
index b9cd813..ef2732b 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
@@ -16,7 +16,6 @@
 
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.CompoundKey;
-
 import java.sql.Timestamp;
 import java.util.Date;
 import java.util.Objects;
@@ -41,8 +40,7 @@
       categoryId = new LabelId();
     }
 
-    public Key(final PatchSet.Id ps, final Account.Id a,
-        final LabelId c) {
+    public Key(final PatchSet.Id ps, final Account.Id a, final LabelId c) {
       this.patchSetId = ps;
       this.accountId = a;
       this.categoryId = c;
@@ -72,17 +70,19 @@
 
   /**
    * Value assigned by the user.
-   * <p>
-   * The precise meaning of "value" is up to each category.
-   * <p>
-   * In general:
+   *
+   * <p>The precise meaning of "value" is up to each category.
+   *
+   * <p>In general:
+   *
    * <ul>
-   * <li><b>&lt; 0:</b> The approval is rejected/revoked.</li>
-   * <li><b>= 0:</b> No indication either way is provided.</li>
-   * <li><b>&gt; 0:</b> The approval is approved/positive.</li>
+   *   <li><b>&lt; 0:</b> The approval is rejected/revoked.
+   *   <li><b>= 0:</b> No indication either way is provided.
+   *   <li><b>&gt; 0:</b> The approval is approved/positive.
    * </ul>
-   * and in the negative and positive direction a magnitude can be assumed.The
-   * further from 0 the more assertive the approval.
+   *
+   * and in the negative and positive direction a magnitude can be assumed.The further from 0 the
+   * more assertive the approval.
    */
   @Column(id = 2)
   protected short value;
@@ -93,11 +93,17 @@
   @Column(id = 6, notNull = false)
   protected String tag;
 
+  /** Real user that made this approval on behalf of the user recorded in {@link Key#accountId}. */
+  @Column(id = 7, notNull = false)
+  protected Account.Id realAccountId;
+
+  @Column(id = 8)
+  protected boolean postSubmit;
+
   // DELETED: id = 4 (changeOpen)
   // DELETED: id = 5 (changeSortKey)
 
-  protected PatchSetApproval() {
-  }
+  protected PatchSetApproval() {}
 
   public PatchSetApproval(PatchSetApproval.Key k, short v, Date ts) {
     key = k;
@@ -106,11 +112,16 @@
   }
 
   public PatchSetApproval(final PatchSet.Id psId, final PatchSetApproval src) {
-    key =
-        new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
+    key = new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
     value = src.getValue();
     granted = src.granted;
+    realAccountId = src.realAccountId;
     tag = src.tag;
+    postSubmit = src.postSubmit;
+  }
+
+  public PatchSetApproval(PatchSetApproval src) {
+    this(src.getPatchSetId(), src);
   }
 
   public PatchSetApproval.Key getKey() {
@@ -125,6 +136,15 @@
     return key.accountId;
   }
 
+  public Account.Id getRealAccountId() {
+    return realAccountId != null ? realAccountId : getAccountId();
+  }
+
+  public void setRealAccountId(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAccountId = Objects.equals(getAccountId(), id) ? null : id;
+  }
+
   public LabelId getLabelId() {
     return key.categoryId;
   }
@@ -165,10 +185,29 @@
     return tag;
   }
 
+  public void setPostSubmit(boolean postSubmit) {
+    this.postSubmit = postSubmit;
+  }
+
+  public boolean isPostSubmit() {
+    return postSubmit;
+  }
+
   @Override
   public String toString() {
-    return new StringBuilder().append('[').append(key).append(": ")
-        .append(value).append(",tag:").append(tag).append(']').toString();
+    StringBuilder sb =
+        new StringBuilder("[")
+            .append(key)
+            .append(": ")
+            .append(value)
+            .append(",tag:")
+            .append(tag)
+            .append(",realAccountId:")
+            .append(realAccountId);
+    if (postSubmit) {
+      sb.append(",postSubmit");
+    }
+    return sb.append(']').toString();
   }
 
   @Override
@@ -178,7 +217,9 @@
       return Objects.equals(key, p.key)
           && Objects.equals(value, p.value)
           && Objects.equals(granted, p.granted)
-          && Objects.equals(tag, p.tag);
+          && Objects.equals(tag, p.tag)
+          && Objects.equals(realAccountId, p.realAccountId)
+          && postSubmit == p.postSubmit;
     }
     return false;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
index 1a00fae..4970db1 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
@@ -16,10 +16,7 @@
 
 import java.util.List;
 
-
-/**
- * Additional data about a {@link PatchSet} not normally loaded.
- */
+/** Additional data about a {@link PatchSet} not normally loaded. */
 public final class PatchSetInfo {
   public static class ParentInfo {
     public RevId id;
@@ -30,8 +27,7 @@
       this.shortMessage = shortMessage;
     }
 
-    protected ParentInfo() {
-    }
+    protected ParentInfo() {}
   }
 
   protected PatchSet.Id key;
@@ -54,8 +50,10 @@
   /** SHA-1 of commit */
   protected String revId;
 
-  protected PatchSetInfo() {
-  }
+  /** Optional user-supplied description for the patch set. */
+  protected String description;
+
+  protected PatchSetInfo() {}
 
   public PatchSetInfo(final PatchSet.Id k) {
     key = k;
@@ -116,4 +114,12 @@
   public String getRevId() {
     return revId;
   }
+
+  public void setDescription(String description) {
+    this.description = description;
+  }
+
+  public String getDescription() {
+    return description;
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
index 74ebb25..ba83c58 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
@@ -23,15 +23,13 @@
 /** Projects match a source code repository managed by Gerrit */
 public final class Project {
   /** Project name key */
-  public static class NameKey extends
-      StringKey<com.google.gwtorm.client.Key<?>> {
+  public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
     @Column(id = 1)
     protected String name;
 
-    protected NameKey() {
-    }
+    protected NameKey() {}
 
     public NameKey(final String n) {
       name = n;
@@ -101,8 +99,7 @@
 
   protected InheritableBoolean rejectImplicitMerges;
 
-  protected Project() {
-  }
+  protected Project() {}
 
   public Project(Project.NameKey nameKey) {
     name = nameKey;
@@ -177,8 +174,7 @@
     return createNewChangeForAllNotInTarget;
   }
 
-  public void setCreateNewChangeForAllNotInTarget(
-      InheritableBoolean useAllNotInTarget) {
+  public void setCreateNewChangeForAllNotInTarget(InheritableBoolean useAllNotInTarget) {
     this.createNewChangeForAllNotInTarget = useAllNotInTarget;
   }
 
@@ -261,9 +257,9 @@
   /**
    * Returns the name key of the parent project.
    *
-   * @return name key of the parent project, {@code null} if this project
-   *         is the wild project, {@code null} or the name key of the wild
-   *         project if this project is a direct child of the wild project
+   * @return name key of the parent project, {@code null} if this project is the wild project,
+   *     {@code null} or the name key of the wild project if this project is a direct child of the
+   *     wild project
    */
   public Project.NameKey getParent() {
     return parent;
@@ -273,8 +269,7 @@
    * Returns the name key of the parent project.
    *
    * @param allProjectsName name key of the wild project
-   * @return name key of the parent project, {@code null} if this project
-   *         is the wild project
+   * @return name key of the parent project, {@code null} if this project is the wild project
    */
   public Project.NameKey getParent(final Project.NameKey allProjectsName) {
     if (parent != null) {
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index 95f4f8e..b892e3d 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -34,6 +34,9 @@
   /** Configuration settings for a project {@code refs/meta/config} */
   public static final String REFS_CONFIG = "refs/meta/config";
 
+  /** Note tree listing external IDs */
+  public static final String REFS_EXTERNAL_IDS = "refs/meta/external-ids";
+
   /** Preference settings for a user {@code refs/users} */
   public static final String REFS_USERS = "refs/users/";
 
@@ -57,24 +60,24 @@
 
   /**
    * Prefix applied to merge commit base nodes.
-   * <p>
-   * References in this directory should take the form
-   * {@code refs/cache-automerge/xx/yyyy...} where xx is
-   * the first two digits of the merge commit's object
-   * name, and yyyyy... is the remaining 38. The reference
-   * should point to a treeish that is the automatic merge
-   * result of the merge commit's parents.
+   *
+   * <p>References in this directory should take the form {@code refs/cache-automerge/xx/yyyy...}
+   * where xx is the first two digits of the merge commit's object name, and yyyyy... is the
+   * remaining 38. The reference should point to a treeish that is the automatic merge result of the
+   * merge commit's parents.
    */
   public static final String REFS_CACHE_AUTOMERGE = "refs/cache-automerge/";
 
   /** Suffix of a meta ref in the NoteDb. */
   public static final String META_SUFFIX = "/meta";
 
+  /** Suffix of a ref that stores robot comments in the NoteDb. */
+  public static final String ROBOT_COMMENTS_SUFFIX = "/robot-comments";
+
   public static final String EDIT_PREFIX = "edit-";
 
   public static String fullName(String ref) {
-    return (ref.startsWith(REFS) || ref.equals(HEAD)) ?
-        ref : REFS_HEADS + ref;
+    return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
   }
 
   public static final String shortName(String ref) {
@@ -94,6 +97,14 @@
     return r.toString();
   }
 
+  public static String robotCommentsRef(Change.Id id) {
+    StringBuilder r = new StringBuilder();
+    r.append(REFS_CHANGES);
+    r.append(shard(id.get()));
+    r.append(ROBOT_COMMENTS_SUFFIX);
+    return r.toString();
+  }
+
   public static String refsUsers(Account.Id accountId) {
     StringBuilder r = new StringBuilder();
     r.append(REFS_USERS);
@@ -101,8 +112,7 @@
     return r.toString();
   }
 
-  public static String refsDraftComments(Change.Id changeId,
-      Account.Id accountId) {
+  public static String refsDraftComments(Change.Id changeId, Account.Id accountId) {
     StringBuilder r = buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get());
     r.append(accountId.get());
     return r.toString();
@@ -112,8 +122,7 @@
     return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).toString();
   }
 
-  public static String refsStarredChanges(Change.Id changeId,
-      Account.Id accountId) {
+  public static String refsStarredChanges(Change.Id changeId, Account.Id accountId) {
     StringBuilder r = buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get());
     r.append(accountId.get());
     return r.toString();
@@ -131,6 +140,10 @@
     return r;
   }
 
+  public static String refsCacheAutomerge(String hash) {
+    return REFS_CACHE_AUTOMERGE + hash.substring(0, 2) + '/' + hash.substring(2);
+  }
+
   public static String shard(int id) {
     if (id < 0) {
       return null;
@@ -155,14 +168,13 @@
    * @param psId patch set number
    * @return reference for this change edit
    */
-  public static String refsEdit(Account.Id accountId, Change.Id changeId,
-      PatchSet.Id psId) {
+  public static String refsEdit(Account.Id accountId, Change.Id changeId, PatchSet.Id psId) {
     return refsEditPrefix(accountId, changeId) + psId.get();
   }
 
   /**
-   * Returns reference prefix for this change edit with sharded user and
-   * change number: refs/users/UU/UUUU/edit-CCCC/.
+   * Returns reference prefix for this change edit with sharded user and change number:
+   * refs/users/UU/UUUU/edit-CCCC/.
    *
    * @param accountId account id
    * @param changeId change number
@@ -177,7 +189,7 @@
   }
 
   public static boolean isRefsEdit(String ref) {
-    return ref.startsWith(REFS_USERS) && ref.contains(EDIT_PREFIX);
+    return ref != null && ref.startsWith(REFS_USERS) && ref.contains(EDIT_PREFIX);
   }
 
   public static boolean isRefsUsers(String ref) {
@@ -246,6 +258,5 @@
     return Integer.valueOf(name.substring(i, name.length()));
   }
 
-  private RefNames() {
-  }
+  private RefNames() {}
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
index 42f3017..5474707 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
@@ -18,13 +18,13 @@
 
 /** A revision identifier for a file or a change. */
 public final class RevId {
+  public static final int ABBREV_LEN = 7;
   public static final int LEN = 40;
 
   @Column(id = 1, length = LEN)
   protected String id;
 
-  protected RevId() {
-  }
+  protected RevId() {}
 
   public RevId(final String str) {
     id = str;
@@ -41,8 +41,8 @@
   }
 
   /**
-   * @return if {@link #isComplete()}, {@code this}; otherwise a new RevId
-   *         with 'z' appended on the end.
+   * @return if {@link #isComplete()}, {@code this}; otherwise a new RevId with 'z' appended on the
+   *     end.
    */
   public RevId max() {
     if (isComplete()) {
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java
new file mode 100644
index 0000000..eceb0bf
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java
@@ -0,0 +1,99 @@
+// 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.reviewdb.client;
+
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public class RobotComment extends Comment {
+  public String robotId;
+  public String robotRunId;
+  public String url;
+  public Map<String, String> properties;
+  public List<FixSuggestion> fixSuggestions;
+
+  public RobotComment(
+      Key key,
+      Account.Id author,
+      Timestamp writtenOn,
+      short side,
+      String message,
+      String serverId,
+      String robotId,
+      String robotRunId) {
+    super(key, author, writtenOn, side, message, serverId, false);
+    this.robotId = robotId;
+    this.robotRunId = robotRunId;
+  }
+
+  @Override
+  public String toString() {
+    return new StringBuilder()
+        .append("RobotComment{")
+        .append("key=")
+        .append(key)
+        .append(',')
+        .append("robotId=")
+        .append(robotId)
+        .append(',')
+        .append("robotRunId=")
+        .append(robotRunId)
+        .append(',')
+        .append("lineNbr=")
+        .append(lineNbr)
+        .append(',')
+        .append("author=")
+        .append(author.getId().get())
+        .append(',')
+        .append("realAuthor=")
+        .append(realAuthor != null ? realAuthor.getId().get() : "")
+        .append(',')
+        .append("writtenOn=")
+        .append(writtenOn.toString())
+        .append(',')
+        .append("side=")
+        .append(side)
+        .append(',')
+        .append("message=")
+        .append(Objects.toString(message, ""))
+        .append(',')
+        .append("parentUuid=")
+        .append(Objects.toString(parentUuid, ""))
+        .append(',')
+        .append("range=")
+        .append(Objects.toString(range, ""))
+        .append(',')
+        .append("revId=")
+        .append(revId != null ? revId : "")
+        .append(',')
+        .append("tag=")
+        .append(Objects.toString(tag, ""))
+        .append(',')
+        .append("unresolved=")
+        .append(unresolved)
+        .append(',')
+        .append("url=")
+        .append(url)
+        .append(',')
+        .append("properties=")
+        .append(properties != null ? properties : "")
+        .append("fixSuggestions=")
+        .append(fixSuggestions != null ? fixSuggestions : "")
+        .append('}')
+        .toString();
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
index 32a9131..bd00d37 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SubmoduleSubscription.java
@@ -19,11 +19,11 @@
 
 /**
  * Defining a project/branch subscription to a project/branch project.
- * <p>
- * This means a class instance represents a repo/branch subscription to a
- * project/branch (the subscriber).
- * <p>
- * A subscriber operates a submodule in defined path.
+ *
+ * <p>This means a class instance represents a repo/branch subscription to a project/branch (the
+ * subscriber).
+ *
+ * <p>A subscriber operates a submodule in defined path.
  */
 public final class SubmoduleSubscription {
   /** Subscription key */
@@ -31,8 +31,8 @@
     private static final long serialVersionUID = 1L;
 
     /**
-     * Indicates the super project, aka subscriber: the project owner of the
-     * gitlinks to the submodules.
+     * Indicates the super project, aka subscriber: the project owner of the gitlinks to the
+     * submodules.
      */
     @Column(id = 1)
     protected Branch.NameKey superProject;
@@ -71,12 +71,9 @@
   @Column(id = 2)
   protected Branch.NameKey submodule;
 
-  protected SubmoduleSubscription() {
-  }
+  protected SubmoduleSubscription() {}
 
-  public SubmoduleSubscription(Branch.NameKey superProject,
-      Branch.NameKey submodule,
-      String path) {
+  public SubmoduleSubscription(Branch.NameKey superProject, Branch.NameKey submodule, String path) {
     this.key = new Key(superProject, path);
     this.submodule = submodule;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
index 0f8d005..9abc744 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
@@ -19,8 +19,7 @@
 
 /** Global configuration needed to serve web requests. */
 public final class SystemConfig {
-  public static final class Key extends
-      StringKey<com.google.gwtorm.client.Key<?>> {
+  public static final class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
 
     private static final String VALUE = "X";
@@ -28,8 +27,7 @@
     @Column(id = 1, length = 1)
     protected String one = VALUE;
 
-    public Key() {
-    }
+    public Key() {}
 
     @Override
     public String get() {
@@ -52,13 +50,10 @@
   @Column(id = 1)
   protected Key singleton;
 
-  /**
-   * Local filesystem location of header/footer/CSS configuration files
-   */
-  @Column(id = 3, notNull = false)
+  /** Local filesystem location of header/footer/CSS configuration files */
+  @Column(id = 3, notNull = false, length = Integer.MAX_VALUE)
   public transient String sitePath;
 
-
   // DO NOT LOOK BELOW THIS LINE. These fields have all been deleted,
   // but survive to support schema upgrade code.
 
@@ -90,6 +85,5 @@
   @Column(id = 11, notNull = false)
   public AccountGroup.UUID batchUsersGroupUUID;
 
-  protected SystemConfig() {
-  }
+  protected SystemConfig() {}
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java
index c664285..8cc9737 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java
@@ -30,8 +30,7 @@
     @Column(id = 1, length = TrackingId.TRACKING_ID_MAX_CHAR)
     protected String id;
 
-    protected Id() {
-    }
+    protected Id() {}
 
     public Id(final String id) {
       this.id = id;
@@ -55,8 +54,7 @@
     @Column(id = 1, length = TrackingId.TRACKING_SYSTEM_MAX_CHAR)
     protected String system;
 
-    protected System() {
-    }
+    protected System() {}
 
     public System(final String s) {
       this.system = s;
@@ -119,11 +117,9 @@
   @Column(id = 1, name = Column.NONE)
   protected Key key;
 
-  protected TrackingId() {
-  }
+  protected TrackingId() {}
 
-  public TrackingId(final Change.Id ch, final TrackingId.Id id,
-      final TrackingId.System s) {
+  public TrackingId(final Change.Id ch, final TrackingId.Id id, final TrackingId.System s) {
     key = new Key(ch, id, s);
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountAccess.java
index de81a90..b015af8 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountAccess.java
@@ -35,12 +35,11 @@
   ResultSet<Account> byFullName(String name) throws OrmException;
 
   @Query("WHERE fullName >= ? AND fullName <= ? ORDER BY fullName LIMIT ?")
-  ResultSet<Account> suggestByFullName(String nameA, String nameB, int limit)
-      throws OrmException;
+  ResultSet<Account> suggestByFullName(String nameA, String nameB, int limit) throws OrmException;
 
   @Query("WHERE preferredEmail >= ? AND preferredEmail <= ? ORDER BY preferredEmail LIMIT ?")
-  ResultSet<Account> suggestByPreferredEmail(String nameA, String nameB,
-      int limit) throws OrmException;
+  ResultSet<Account> suggestByPreferredEmail(String nameA, String nameB, int limit)
+      throws OrmException;
 
   @Query("LIMIT 1")
   ResultSet<Account> anyAccounts() throws OrmException;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
index 12bd80f..9124301 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
@@ -22,30 +22,14 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface AccountExternalIdAccess extends
-    Access<AccountExternalId, AccountExternalId.Key> {
+public interface AccountExternalIdAccess extends Access<AccountExternalId, AccountExternalId.Key> {
   @Override
   @PrimaryKey("key")
   AccountExternalId get(AccountExternalId.Key key) throws OrmException;
 
-  @Query("WHERE key >= ? AND key <= ? ORDER BY key LIMIT ?")
-  ResultSet<AccountExternalId> suggestByKey(AccountExternalId.Key keyA,
-      AccountExternalId.Key keyB, int limit) throws OrmException;
-
   @Query("WHERE accountId = ?")
   ResultSet<AccountExternalId> byAccount(Account.Id id) throws OrmException;
 
-  @Query("WHERE accountId = ? AND emailAddress = ?")
-  ResultSet<AccountExternalId> byAccountEmail(Account.Id id, String email)
-      throws OrmException;
-
-  @Query("WHERE emailAddress = ?")
-  ResultSet<AccountExternalId> byEmailAddress(String email) throws OrmException;
-
-  @Query("WHERE emailAddress >= ? AND emailAddress <= ? ORDER BY emailAddress LIMIT ?")
-  ResultSet<AccountExternalId> suggestByEmailAddress(String emailA,
-      String emailB, int limit) throws OrmException;
-
   @Query
   ResultSet<AccountExternalId> all() throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java
index 3b82773..c5a1caf 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupAccess.java
@@ -21,8 +21,7 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface AccountGroupAccess extends
-    Access<AccountGroup, AccountGroup.Id> {
+public interface AccountGroupAccess extends Access<AccountGroup, AccountGroup.Id> {
   @Override
   @PrimaryKey("groupId")
   AccountGroup get(AccountGroup.Id id) throws OrmException;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java
index 4fce0cd..1634fda 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAccess.java
@@ -22,8 +22,7 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface AccountGroupByIdAccess extends
-    Access<AccountGroupById, AccountGroupById.Key> {
+public interface AccountGroupByIdAccess extends Access<AccountGroupById, AccountGroupById.Key> {
   @Override
   @PrimaryKey("key")
   AccountGroupById get(AccountGroupById.Key key) throws OrmException;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java
index 480e8e4..08f9a0a 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupByIdAudAccess.java
@@ -22,18 +22,16 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface AccountGroupByIdAudAccess extends
-    Access<AccountGroupByIdAud, AccountGroupByIdAud.Key> {
+public interface AccountGroupByIdAudAccess
+    extends Access<AccountGroupByIdAud, AccountGroupByIdAud.Key> {
   @Override
   @PrimaryKey("key")
-  AccountGroupByIdAud get(AccountGroupByIdAud.Key key)
-      throws OrmException;
+  AccountGroupByIdAud get(AccountGroupByIdAud.Key key) throws OrmException;
 
   @Query("WHERE key.groupId = ? AND key.includeUUID = ?")
-  ResultSet<AccountGroupByIdAud> byGroupInclude(AccountGroup.Id groupId,
-      AccountGroup.UUID incGroupUUID) throws OrmException;
+  ResultSet<AccountGroupByIdAud> byGroupInclude(
+      AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) throws OrmException;
 
   @Query("WHERE key.groupId = ?")
-  ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId)
-      throws OrmException;
+  ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java
index b3296d9..0213f25a 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAccess.java
@@ -23,8 +23,8 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface AccountGroupMemberAccess extends
-    Access<AccountGroupMember, AccountGroupMember.Key> {
+public interface AccountGroupMemberAccess
+    extends Access<AccountGroupMember, AccountGroupMember.Key> {
   @Override
   @PrimaryKey("key")
   AccountGroupMember get(AccountGroupMember.Key key) throws OrmException;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java
index 041254a..aa2f7c4 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupMemberAuditAccess.java
@@ -23,18 +23,16 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface AccountGroupMemberAuditAccess extends
-    Access<AccountGroupMemberAudit, AccountGroupMemberAudit.Key> {
+public interface AccountGroupMemberAuditAccess
+    extends Access<AccountGroupMemberAudit, AccountGroupMemberAudit.Key> {
   @Override
   @PrimaryKey("key")
-  AccountGroupMemberAudit get(AccountGroupMemberAudit.Key key)
-      throws OrmException;
+  AccountGroupMemberAudit get(AccountGroupMemberAudit.Key key) throws OrmException;
 
   @Query("WHERE key.groupId = ? AND key.accountId = ?")
-  ResultSet<AccountGroupMemberAudit> byGroupAccount(AccountGroup.Id groupId,
-      Account.Id accountId) throws OrmException;
+  ResultSet<AccountGroupMemberAudit> byGroupAccount(AccountGroup.Id groupId, Account.Id accountId)
+      throws OrmException;
 
   @Query("WHERE key.groupId = ?")
-  ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId)
-      throws OrmException;
+  ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
index 0a06d07..82660cb 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
@@ -22,9 +22,7 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-
-public interface AccountGroupNameAccess extends
-    Access<AccountGroupName, AccountGroup.NameKey> {
+public interface AccountGroupNameAccess extends Access<AccountGroupName, AccountGroup.NameKey> {
   @Override
   @PrimaryKey("name")
   AccountGroupName get(AccountGroup.NameKey name) throws OrmException;
@@ -33,6 +31,6 @@
   ResultSet<AccountGroupName> all() throws OrmException;
 
   @Query("WHERE name.name >= ? AND name.name <= ? ORDER BY name LIMIT ?")
-  ResultSet<AccountGroupName> suggestByName(String nameA, String nameB,
-      int limit) throws OrmException;
+  ResultSet<AccountGroupName> suggestByName(String nameA, String nameB, int limit)
+      throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountProjectWatchAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountProjectWatchAccess.java
deleted file mode 100644
index c6f4775..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountProjectWatchAccess.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2008 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.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountProjectWatchAccess extends
-    Access<AccountProjectWatch, AccountProjectWatch.Key> {
-  @Override
-  @PrimaryKey("key")
-  AccountProjectWatch get(AccountProjectWatch.Key key) throws OrmException;
-
-  @Query("WHERE key.accountId = ?")
-  ResultSet<AccountProjectWatch> byAccount(Account.Id id) throws OrmException;
-
-  @Query("WHERE key.projectName = ?")
-  ResultSet<AccountProjectWatch> byProject(Project.NameKey name) throws OrmException;
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java
index d291fd5..fe87e59 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ChangeMessageAccess.java
@@ -23,8 +23,7 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface ChangeMessageAccess extends
-    Access<ChangeMessage, ChangeMessage.Key> {
+public interface ChangeMessageAccess extends Access<ChangeMessage, ChangeMessage.Key> {
   @Override
   @PrimaryKey("key")
   ChangeMessage get(ChangeMessage.Key id) throws OrmException;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java
deleted file mode 100644
index b70778e..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java
+++ /dev/null
@@ -1,289 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.common.util.concurrent.CheckedFuture;
-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.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-public class DisabledChangesReviewDbWrapper extends ReviewDbWrapper {
-  private static final String MSG = "This table has been migrated to NoteDb";
-
-  private final DisabledChangeAccess changes;
-  private final DisabledPatchSetApprovalAccess patchSetApprovals;
-  private final DisabledChangeMessageAccess changeMessages;
-  private final DisabledPatchSetAccess patchSets;
-  private final DisabledPatchLineCommentAccess patchComments;
-
-  public DisabledChangesReviewDbWrapper(ReviewDb db) {
-    super(db);
-    changes = new DisabledChangeAccess(delegate.changes());
-    patchSetApprovals =
-        new DisabledPatchSetApprovalAccess(delegate.patchSetApprovals());
-    changeMessages = new DisabledChangeMessageAccess(delegate.changeMessages());
-    patchSets = new DisabledPatchSetAccess(delegate.patchSets());
-    patchComments =
-        new DisabledPatchLineCommentAccess(delegate.patchComments());
-  }
-
-  public ReviewDb unsafeGetDelegate() {
-    return delegate;
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return changes;
-  }
-
-  @Override
-  public PatchSetApprovalAccess patchSetApprovals() {
-    return patchSetApprovals;
-  }
-
-  @Override
-  public ChangeMessageAccess changeMessages() {
-    return changeMessages;
-  }
-
-  @Override
-  public PatchSetAccess patchSets() {
-    return patchSets;
-  }
-
-  @Override
-  public PatchLineCommentAccess patchComments() {
-    return patchComments;
-  }
-
-  private static class DisabledChangeAccess extends ChangeAccessWrapper {
-
-    protected DisabledChangeAccess(ChangeAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<Change> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public CheckedFuture<Change, OrmException> getAsync(Change.Id key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<Change> get(Iterable<Change.Id> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public Change get(Change.Id id) throws OrmException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<Change> all() throws OrmException {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-
-  private static class DisabledPatchSetApprovalAccess
-      extends PatchSetApprovalAccessWrapper {
-    DisabledPatchSetApprovalAccess(PatchSetApprovalAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public CheckedFuture<PatchSetApproval, OrmException> getAsync(
-        PatchSetApproval.Key key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> get(
-        Iterable<PatchSetApproval.Key> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public PatchSetApproval get(PatchSetApproval.Key key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byChange(Change.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-
-  private static class DisabledChangeMessageAccess
-      extends ChangeMessageAccessWrapper {
-    DisabledChangeMessageAccess(ChangeMessageAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public CheckedFuture<ChangeMessage, OrmException> getAsync(
-        ChangeMessage.Key key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ChangeMessage get(ChangeMessage.Key id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byChange(Change.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<ChangeMessage> all() {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-
-  private static class DisabledPatchSetAccess extends PatchSetAccessWrapper {
-    DisabledPatchSetAccess(PatchSetAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<PatchSet> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public CheckedFuture<PatchSet, OrmException> getAsync(PatchSet.Id key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public PatchSet get(PatchSet.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchSet> byChange(Change.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-
-  private static class DisabledPatchLineCommentAccess
-      extends PatchLineCommentAccessWrapper {
-    DisabledPatchLineCommentAccess(PatchLineCommentAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> iterateAllEntities() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public CheckedFuture<PatchLineComment, OrmException> getAsync(
-        PatchLineComment.Key key) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> get(
-        Iterable<PatchLineComment.Key> keys) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public PatchLineComment get(PatchLineComment.Key id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byChange(Change.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id,
-        String file) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> publishedByPatchSet(
-        PatchSet.Id patchset) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
-        PatchSet.Id patchset, Account.Id author) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByChangeFileAuthor(Change.Id id,
-        String file, Account.Id author) {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) {
-      throw new UnsupportedOperationException(MSG);
-    }
-  }
-}
-
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
new file mode 100644
index 0000000..d4c6354
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
@@ -0,0 +1,285 @@
+// 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.reviewdb.server;
+
+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.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+public class DisallowReadFromChangesReviewDbWrapper extends ReviewDbWrapper {
+  private static final String MSG = "This table has been migrated to NoteDb";
+
+  private final Changes changes;
+  private final PatchSetApprovals patchSetApprovals;
+  private final ChangeMessages changeMessages;
+  private final PatchSets patchSets;
+  private final PatchLineComments patchComments;
+
+  public DisallowReadFromChangesReviewDbWrapper(ReviewDb db) {
+    super(db);
+    changes = new Changes(delegate.changes());
+    patchSetApprovals = new PatchSetApprovals(delegate.patchSetApprovals());
+    changeMessages = new ChangeMessages(delegate.changeMessages());
+    patchSets = new PatchSets(delegate.patchSets());
+    patchComments = new PatchLineComments(delegate.patchComments());
+  }
+
+  public ReviewDb unsafeGetDelegate() {
+    return delegate;
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return changes;
+  }
+
+  @Override
+  public PatchSetApprovalAccess patchSetApprovals() {
+    return patchSetApprovals;
+  }
+
+  @Override
+  public ChangeMessageAccess changeMessages() {
+    return changeMessages;
+  }
+
+  @Override
+  public PatchSetAccess patchSets() {
+    return patchSets;
+  }
+
+  @Override
+  public PatchLineCommentAccess patchComments() {
+    return patchComments;
+  }
+
+  private static class Changes extends ChangeAccessWrapper {
+
+    protected Changes(ChangeAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<Change> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<Change, OrmException> getAsync(
+        Change.Id key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<Change> get(Iterable<Change.Id> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public Change get(Change.Id id) throws OrmException {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<Change> all() throws OrmException {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class PatchSetApprovals extends PatchSetApprovalAccessWrapper {
+    PatchSetApprovals(PatchSetApprovalAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<PatchSetApproval, OrmException> getAsync(
+        PatchSetApproval.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> get(Iterable<PatchSetApproval.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public PatchSetApproval get(PatchSetApproval.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byChange(Change.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class ChangeMessages extends ChangeMessageAccessWrapper {
+    ChangeMessages(ChangeMessageAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<ChangeMessage, OrmException> getAsync(
+        ChangeMessage.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ChangeMessage get(ChangeMessage.Key id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byChange(Change.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> all() {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class PatchSets extends PatchSetAccessWrapper {
+    PatchSets(PatchSetAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<PatchSet> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<PatchSet, OrmException> getAsync(
+        PatchSet.Id key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public PatchSet get(PatchSet.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchSet> byChange(Change.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class PatchLineComments extends PatchLineCommentAccessWrapper {
+    PatchLineComments(PatchLineCommentAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<PatchLineComment, OrmException> getAsync(
+        PatchLineComment.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> get(Iterable<PatchLineComment.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public PatchLineComment get(PatchLineComment.Key id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byChange(Change.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
+        PatchSet.Id patchset, Account.Id author) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
+        Change.Id id, String file, Account.Id author) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java
index 81f3e57..08b8484 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchLineCommentAccess.java
@@ -24,8 +24,7 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface PatchLineCommentAccess extends
-    Access<PatchLineComment, PatchLineComment.Key> {
+public interface PatchLineCommentAccess extends Access<PatchLineComment, PatchLineComment.Key> {
   @Override
   @PrimaryKey("key")
   PatchLineComment get(PatchLineComment.Key id) throws OrmException;
@@ -36,33 +35,35 @@
   @Query("WHERE key.patchKey.patchSetId = ?")
   ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) throws OrmException;
 
-  @Query("WHERE key.patchKey.patchSetId.changeId = ?"
-      + " AND key.patchKey.fileName = ? AND status = '"
-      + PatchLineComment.STATUS_PUBLISHED + "' ORDER BY lineNbr,writtenOn")
-  ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file)
+  @Query(
+      "WHERE key.patchKey.patchSetId.changeId = ?"
+          + " AND key.patchKey.fileName = ? AND status = '"
+          + PatchLineComment.STATUS_PUBLISHED
+          + "' ORDER BY lineNbr,writtenOn")
+  ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file) throws OrmException;
+
+  @Query(
+      "WHERE key.patchKey.patchSetId = ? AND status = '" + PatchLineComment.STATUS_PUBLISHED + "'")
+  ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset) throws OrmException;
+
+  @Query(
+      "WHERE key.patchKey.patchSetId = ? AND status = '"
+          + PatchLineComment.STATUS_DRAFT
+          + "' AND author = ? ORDER BY key.patchKey,lineNbr,writtenOn")
+  ResultSet<PatchLineComment> draftByPatchSetAuthor(PatchSet.Id patchset, Account.Id author)
       throws OrmException;
 
-  @Query("WHERE key.patchKey.patchSetId = ? AND status = '"
-      + PatchLineComment.STATUS_PUBLISHED + "'")
-  ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset)
+  @Query(
+      "WHERE key.patchKey.patchSetId.changeId = ?"
+          + " AND key.patchKey.fileName = ? AND author = ? AND status = '"
+          + PatchLineComment.STATUS_DRAFT
+          + "' ORDER BY lineNbr,writtenOn")
+  ResultSet<PatchLineComment> draftByChangeFileAuthor(Change.Id id, String file, Account.Id author)
       throws OrmException;
 
-  @Query("WHERE key.patchKey.patchSetId = ? AND status = '"
-      + PatchLineComment.STATUS_DRAFT
-      + "' AND author = ? ORDER BY key.patchKey,lineNbr,writtenOn")
-  ResultSet<PatchLineComment> draftByPatchSetAuthor
-      (PatchSet.Id patchset, Account.Id author)
-      throws OrmException;
+  @Query("WHERE status = '" + PatchLineComment.STATUS_DRAFT + "' AND author = ?")
+  ResultSet<PatchLineComment> draftByAuthor(Account.Id author) throws OrmException;
 
-  @Query("WHERE key.patchKey.patchSetId.changeId = ?"
-      + " AND key.patchKey.fileName = ? AND author = ? AND status = '"
-      + PatchLineComment.STATUS_DRAFT + "' ORDER BY lineNbr,writtenOn")
-  ResultSet<PatchLineComment> draftByChangeFileAuthor
-      (Change.Id id, String file, Account.Id author)
-      throws OrmException;
-
-  @Query("WHERE status = '" + PatchLineComment.STATUS_DRAFT
-      + "' AND author = ?")
-  ResultSet<PatchLineComment> draftByAuthor(Account.Id author)
-      throws OrmException;
+  @Query
+  ResultSet<PatchLineComment> all() throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
index f7452c5..bf3c9e4 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetAccess.java
@@ -29,4 +29,7 @@
 
   @Query("WHERE id.changeId = ? ORDER BY id.patchSetId")
   ResultSet<PatchSet> byChange(Change.Id id) throws OrmException;
+
+  @Query
+  ResultSet<PatchSet> all() throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java
index cddee73..69357bc 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/PatchSetApprovalAccess.java
@@ -24,8 +24,7 @@
 import com.google.gwtorm.server.Query;
 import com.google.gwtorm.server.ResultSet;
 
-public interface PatchSetApprovalAccess extends
-    Access<PatchSetApproval, PatchSetApproval.Key> {
+public interface PatchSetApprovalAccess extends Access<PatchSetApproval, PatchSetApproval.Key> {
   @Override
   @PrimaryKey("key")
   PatchSetApproval get(PatchSetApproval.Key key) throws OrmException;
@@ -37,6 +36,9 @@
   ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) throws OrmException;
 
   @Query("WHERE key.patchSetId = ? AND key.accountId = ?")
-  ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet,
-      Account.Id account) throws OrmException;
+  ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account)
+      throws OrmException;
+
+  @Query
+  ResultSet<PatchSetApproval> all() throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
index c585ca5..9b4e1ed 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.SystemConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.Relation;
@@ -26,12 +25,13 @@
 
 /**
  * The review service database schema.
- * <p>
- * Root entities that are at the top level of some important data graph:
+ *
+ * <p>Root entities that are at the top level of some important data graph:
+ *
  * <ul>
- * <li>{@link Account}: Per-user account registration, preferences, identity.</li>
- * <li>{@link Change}: All review information about a single proposed change.</li>
- * <li>{@link SystemConfig}: Server-wide settings, managed by administrator.</li>
+ *   <li>{@link Account}: Per-user account registration, preferences, identity.
+ *   <li>{@link Change}: All review information about a single proposed change.
+ *   <li>{@link SystemConfig}: Server-wide settings, managed by administrator.
  * </ul>
  */
 public interface ReviewDb extends Schema {
@@ -71,8 +71,7 @@
 
   // Deleted @Relation(id = 18)
 
-  @Relation(id = 19)
-  AccountProjectWatchAccess accountProjectWatches();
+  // Deleted @Relation(id = 19)
 
   // Deleted @Relation(id = 20)
 
@@ -119,12 +118,4 @@
   @Sequence(startWith = FIRST_CHANGE_ID)
   @Deprecated
   int nextChangeId() throws OrmException;
-
-  /**
-   * Next id for a block of {@link ChangeMessage} records.
-   *
-   * @see com.google.gerrit.server.ChangeUtil#messageUUID(ReviewDb)
-   */
-  @Sequence
-  int nextChangeMessageId() throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
index 42d0993..bb31b1c 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
@@ -14,48 +14,39 @@
 
 package com.google.gerrit.reviewdb.server;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Ordering;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.client.IntKey;
 
 /** Static utilities for ReviewDb types. */
 public class ReviewDbUtil {
-  public static final Function<IntKey<?>, Integer> INT_KEY_FUNCTION =
-      new Function<IntKey<?>, Integer>() {
-        @Override
-        public Integer apply(IntKey<?> in) {
-          return in.get();
-        }
-      };
-
-  private static final Function<Change, Change.Id> CHANGE_ID_FUNCTION =
-      new Function<Change, Change.Id>() {
-        @Override
-        public Change.Id apply(Change in) {
-          return in.getId();
-        }
-      };
-
   private static final Ordering<? extends IntKey<?>> INT_KEY_ORDERING =
-      Ordering.natural().nullsFirst().onResultOf(INT_KEY_FUNCTION).nullsFirst();
+      Ordering.natural().nullsFirst().<IntKey<?>>onResultOf(IntKey::get).nullsFirst();
 
+  /**
+   * Null-safe ordering over arbitrary subclass of {@code IntKey}.
+   *
+   * <p>In some cases, {@code Comparator.comparing(Change.Id::get)} may be shorter and cleaner.
+   * However, this method may be preferable in some cases:
+   *
+   * <ul>
+   *   <li>This ordering is null-safe over both input and the result of {@link IntKey#get()}; {@code
+   *       comparing} is only a good idea if all inputs are obviously non-null.
+   *   <li>{@code intKeyOrdering().sortedCopy(iterable)} is shorter than the stream equivalent.
+   *   <li>Creating derived comparators may be more readable with {@link Ordering} method chaining
+   *       rather than static {@code Comparator} methods.
+   * </ul>
+   */
   @SuppressWarnings("unchecked")
   public static <K extends IntKey<?>> Ordering<K> intKeyOrdering() {
     return (Ordering<K>) INT_KEY_ORDERING;
   }
 
-  public static Function<Change, Change.Id> changeIdFunction() {
-    return CHANGE_ID_FUNCTION;
-  }
-
   public static ReviewDb unwrapDb(ReviewDb db) {
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      return ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
+    if (db instanceof DisallowReadFromChangesReviewDbWrapper) {
+      return ((DisallowReadFromChangesReviewDbWrapper) db).unsafeGetDelegate();
     }
     return db;
   }
 
-  private ReviewDbUtil() {
-  }
+  private ReviewDbUtil() {}
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
index 6b25378..4ad8e39 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.common.util.concurrent.CheckedFuture;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -28,7 +27,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.gwtorm.server.StatementExecutor;
-
 import java.util.Map;
 
 public class ReviewDbWrapper implements ReviewDb {
@@ -109,11 +107,6 @@
   }
 
   @Override
-  public AccountProjectWatchAccess accountProjectWatches() {
-    return delegate.accountProjectWatches();
-  }
-
-  @Override
   public ChangeAccess changes() {
     return delegate.changes();
   }
@@ -164,11 +157,6 @@
     return delegate.nextChangeId();
   }
 
-  @Override
-  public int nextChangeMessageId() throws OrmException {
-    return delegate.nextChangeMessageId();
-  }
-
   public static class ChangeAccessWrapper implements ChangeAccess {
     protected final ChangeAccess delegate;
 
@@ -201,8 +189,10 @@
       return delegate.toMap(c);
     }
 
+    @SuppressWarnings("deprecation")
     @Override
-    public CheckedFuture<Change, OrmException> getAsync(Change.Id key) {
+    public com.google.common.util.concurrent.CheckedFuture<Change, OrmException> getAsync(
+        Change.Id key) {
       return delegate.getAsync(key);
     }
 
@@ -257,8 +247,7 @@
     }
   }
 
-  public static class PatchSetApprovalAccessWrapper
-      implements PatchSetApprovalAccess {
+  public static class PatchSetApprovalAccessWrapper implements PatchSetApprovalAccess {
     protected final PatchSetApprovalAccess delegate;
 
     protected PatchSetApprovalAccessWrapper(PatchSetApprovalAccess delegate) {
@@ -276,8 +265,7 @@
     }
 
     @Override
-    public ResultSet<PatchSetApproval> iterateAllEntities()
-        throws OrmException {
+    public ResultSet<PatchSetApproval> iterateAllEntities() throws OrmException {
       return delegate.iterateAllEntities();
     }
 
@@ -287,13 +275,13 @@
     }
 
     @Override
-    public Map<PatchSetApproval.Key, PatchSetApproval> toMap(
-        Iterable<PatchSetApproval> c) {
+    public Map<PatchSetApproval.Key, PatchSetApproval> toMap(Iterable<PatchSetApproval> c) {
       return delegate.toMap(c);
     }
 
+    @SuppressWarnings("deprecation")
     @Override
-    public CheckedFuture<PatchSetApproval, OrmException> getAsync(
+    public com.google.common.util.concurrent.CheckedFuture<PatchSetApproval, OrmException> getAsync(
         PatchSetApproval.Key key) {
       return delegate.getAsync(key);
     }
@@ -305,32 +293,27 @@
     }
 
     @Override
-    public void insert(Iterable<PatchSetApproval> instances)
-        throws OrmException {
+    public void insert(Iterable<PatchSetApproval> instances) throws OrmException {
       delegate.insert(instances);
     }
 
     @Override
-    public void update(Iterable<PatchSetApproval> instances)
-        throws OrmException {
+    public void update(Iterable<PatchSetApproval> instances) throws OrmException {
       delegate.update(instances);
     }
 
     @Override
-    public void upsert(Iterable<PatchSetApproval> instances)
-        throws OrmException {
+    public void upsert(Iterable<PatchSetApproval> instances) throws OrmException {
       delegate.upsert(instances);
     }
 
     @Override
-    public void deleteKeys(Iterable<PatchSetApproval.Key> keys)
-        throws OrmException {
+    public void deleteKeys(Iterable<PatchSetApproval.Key> keys) throws OrmException {
       delegate.deleteKeys(keys);
     }
 
     @Override
-    public void delete(Iterable<PatchSetApproval> instances)
-        throws OrmException {
+    public void delete(Iterable<PatchSetApproval> instances) throws OrmException {
       delegate.delete(instances);
     }
 
@@ -340,8 +323,8 @@
     }
 
     @Override
-    public PatchSetApproval atomicUpdate(PatchSetApproval.Key key,
-        AtomicUpdate<PatchSetApproval> update) throws OrmException {
+    public PatchSetApproval atomicUpdate(
+        PatchSetApproval.Key key, AtomicUpdate<PatchSetApproval> update) throws OrmException {
       return delegate.atomicUpdate(key, update);
     }
 
@@ -351,26 +334,28 @@
     }
 
     @Override
-    public ResultSet<PatchSetApproval> byChange(Change.Id id)
-        throws OrmException {
+    public ResultSet<PatchSetApproval> byChange(Change.Id id) throws OrmException {
       return delegate.byChange(id);
     }
 
     @Override
-    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id)
-        throws OrmException {
+    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) throws OrmException {
       return delegate.byPatchSet(id);
     }
 
     @Override
-    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet,
-        Account.Id account) throws OrmException {
+    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account)
+        throws OrmException {
       return delegate.byPatchSetUser(patchSet, account);
     }
+
+    @Override
+    public ResultSet<PatchSetApproval> all() throws OrmException {
+      return delegate.all();
+    }
   }
 
-  public static class ChangeMessageAccessWrapper
-      implements ChangeMessageAccess {
+  public static class ChangeMessageAccessWrapper implements ChangeMessageAccess {
     protected final ChangeMessageAccess delegate;
 
     protected ChangeMessageAccessWrapper(ChangeMessageAccess delegate) {
@@ -398,20 +383,19 @@
     }
 
     @Override
-    public Map<ChangeMessage.Key, ChangeMessage> toMap(
-        Iterable<ChangeMessage> c) {
+    public Map<ChangeMessage.Key, ChangeMessage> toMap(Iterable<ChangeMessage> c) {
       return delegate.toMap(c);
     }
 
+    @SuppressWarnings("deprecation")
     @Override
-    public CheckedFuture<ChangeMessage, OrmException> getAsync(
+    public com.google.common.util.concurrent.CheckedFuture<ChangeMessage, OrmException> getAsync(
         ChangeMessage.Key key) {
       return delegate.getAsync(key);
     }
 
     @Override
-    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys)
-        throws OrmException {
+    public ResultSet<ChangeMessage> get(Iterable<ChangeMessage.Key> keys) throws OrmException {
       return delegate.get(keys);
     }
 
@@ -431,8 +415,7 @@
     }
 
     @Override
-    public void deleteKeys(Iterable<ChangeMessage.Key> keys)
-        throws OrmException {
+    public void deleteKeys(Iterable<ChangeMessage.Key> keys) throws OrmException {
       delegate.deleteKeys(keys);
     }
 
@@ -447,8 +430,8 @@
     }
 
     @Override
-    public ChangeMessage atomicUpdate(ChangeMessage.Key key,
-        AtomicUpdate<ChangeMessage> update) throws OrmException {
+    public ChangeMessage atomicUpdate(ChangeMessage.Key key, AtomicUpdate<ChangeMessage> update)
+        throws OrmException {
       return delegate.atomicUpdate(key, update);
     }
 
@@ -463,8 +446,7 @@
     }
 
     @Override
-    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id)
-        throws OrmException {
+    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException {
       return delegate.byPatchSet(id);
     }
 
@@ -472,7 +454,6 @@
     public ResultSet<ChangeMessage> all() throws OrmException {
       return delegate.all();
     }
-
   }
 
   public static class PatchSetAccessWrapper implements PatchSetAccess {
@@ -507,14 +488,15 @@
       return delegate.toMap(c);
     }
 
+    @SuppressWarnings("deprecation")
     @Override
-    public CheckedFuture<PatchSet, OrmException> getAsync(PatchSet.Id key) {
+    public com.google.common.util.concurrent.CheckedFuture<PatchSet, OrmException> getAsync(
+        PatchSet.Id key) {
       return delegate.getAsync(key);
     }
 
     @Override
-    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys)
-        throws OrmException {
+    public ResultSet<PatchSet> get(Iterable<PatchSet.Id> keys) throws OrmException {
       return delegate.get(keys);
     }
 
@@ -564,10 +546,13 @@
       return delegate.byChange(id);
     }
 
+    @Override
+    public ResultSet<PatchSet> all() throws OrmException {
+      return delegate.all();
+    }
   }
 
-  public static class PatchLineCommentAccessWrapper
-      implements PatchLineCommentAccess {
+  public static class PatchLineCommentAccessWrapper implements PatchLineCommentAccess {
     protected PatchLineCommentAccess delegate;
 
     protected PatchLineCommentAccessWrapper(PatchLineCommentAccess delegate) {
@@ -585,8 +570,7 @@
     }
 
     @Override
-    public ResultSet<PatchLineComment> iterateAllEntities()
-        throws OrmException {
+    public ResultSet<PatchLineComment> iterateAllEntities() throws OrmException {
       return delegate.iterateAllEntities();
     }
 
@@ -596,13 +580,13 @@
     }
 
     @Override
-    public Map<PatchLineComment.Key, PatchLineComment> toMap(
-        Iterable<PatchLineComment> c) {
+    public Map<PatchLineComment.Key, PatchLineComment> toMap(Iterable<PatchLineComment> c) {
       return delegate.toMap(c);
     }
 
+    @SuppressWarnings("deprecation")
     @Override
-    public CheckedFuture<PatchLineComment, OrmException> getAsync(
+    public com.google.common.util.concurrent.CheckedFuture<PatchLineComment, OrmException> getAsync(
         PatchLineComment.Key key) {
       return delegate.getAsync(key);
     }
@@ -614,32 +598,27 @@
     }
 
     @Override
-    public void insert(Iterable<PatchLineComment> instances)
-        throws OrmException {
+    public void insert(Iterable<PatchLineComment> instances) throws OrmException {
       delegate.insert(instances);
     }
 
     @Override
-    public void update(Iterable<PatchLineComment> instances)
-        throws OrmException {
+    public void update(Iterable<PatchLineComment> instances) throws OrmException {
       delegate.update(instances);
     }
 
     @Override
-    public void upsert(Iterable<PatchLineComment> instances)
-        throws OrmException {
+    public void upsert(Iterable<PatchLineComment> instances) throws OrmException {
       delegate.upsert(instances);
     }
 
     @Override
-    public void deleteKeys(Iterable<PatchLineComment.Key> keys)
-        throws OrmException {
+    public void deleteKeys(Iterable<PatchLineComment.Key> keys) throws OrmException {
       delegate.deleteKeys(keys);
     }
 
     @Override
-    public void delete(Iterable<PatchLineComment> instances)
-        throws OrmException {
+    public void delete(Iterable<PatchLineComment> instances) throws OrmException {
       delegate.delete(instances);
     }
 
@@ -649,8 +628,8 @@
     }
 
     @Override
-    public PatchLineComment atomicUpdate(PatchLineComment.Key key,
-        AtomicUpdate<PatchLineComment> update) throws OrmException {
+    public PatchLineComment atomicUpdate(
+        PatchLineComment.Key key, AtomicUpdate<PatchLineComment> update) throws OrmException {
       return delegate.atomicUpdate(key, update);
     }
 
@@ -660,20 +639,18 @@
     }
 
     @Override
-    public ResultSet<PatchLineComment> byChange(Change.Id id)
-        throws OrmException {
+    public ResultSet<PatchLineComment> byChange(Change.Id id) throws OrmException {
       return delegate.byChange(id);
     }
 
     @Override
-    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id)
-        throws OrmException {
+    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) throws OrmException {
       return delegate.byPatchSet(id);
     }
 
     @Override
-    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id,
-        String file) throws OrmException {
+    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file)
+        throws OrmException {
       return delegate.publishedByChangeFile(id, file);
     }
 
@@ -690,15 +667,19 @@
     }
 
     @Override
-    public ResultSet<PatchLineComment> draftByChangeFileAuthor(Change.Id id,
-        String file, Account.Id author) throws OrmException {
+    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
+        Change.Id id, String file, Account.Id author) throws OrmException {
       return delegate.draftByChangeFileAuthor(id, file, author);
     }
 
     @Override
-    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author)
-        throws OrmException {
+    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) throws OrmException {
       return delegate.draftByAuthor(author);
     }
+
+    @Override
+    public ResultSet<PatchLineComment> all() throws OrmException {
+      return delegate.all();
+    }
   }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java
index dda8d5c..8819a6c 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SchemaVersionAccess.java
@@ -20,8 +20,8 @@
 import com.google.gwtorm.server.PrimaryKey;
 
 /** Access interface for {@link CurrentSchemaVersion}. */
-public interface SchemaVersionAccess extends
-    Access<CurrentSchemaVersion, CurrentSchemaVersion.Key> {
+public interface SchemaVersionAccess
+    extends Access<CurrentSchemaVersion, CurrentSchemaVersion.Key> {
   @Override
   @PrimaryKey("singleton")
   CurrentSchemaVersion get(CurrentSchemaVersion.Key key) throws OrmException;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java
index 3bc49dd..a2177fd 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/SystemConfigAccess.java
@@ -22,8 +22,7 @@
 import com.google.gwtorm.server.ResultSet;
 
 /** Access interface for {@link SystemConfig}. */
-public interface SystemConfigAccess extends
-    Access<SystemConfig, SystemConfig.Key> {
+public interface SystemConfigAccess extends Access<SystemConfig, SystemConfig.Key> {
   @Override
   @PrimaryKey("singleton")
   SystemConfig get(SystemConfig.Key key) throws OrmException;
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
index 2110295..deceab9 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
@@ -21,10 +21,6 @@
 CREATE INDEX account_external_ids_byAccount
 ON account_external_ids (account_id);
 
---    covers:             byEmailAddress, suggestByEmailAddress
-CREATE INDEX account_external_ids_byEmail
-ON account_external_ids (email_address);
-
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
@@ -39,13 +35,6 @@
 CREATE INDEX account_group_id_byInclude
 ON account_group_by_id (include_uuid);
 
--- *********************************************************************
--- AccountProjectWatchAccess
---    @PrimaryKey covers: byAccount
---    covers:             byProject
-CREATE INDEX account_project_watches_byP
-ON account_project_watches (project_name);
-
 
 -- *********************************************************************
 -- ApprovalCategoryAccess
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
index 334b6c4..1ec8ea6 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
@@ -25,11 +25,6 @@
 ON account_external_ids (account_id)
 #
 
---    covers:             byEmailAddress, suggestByEmailAddress
-CREATE INDEX account_external_ids_byEmail
-ON account_external_ids (email_address)
-#
-
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
@@ -47,14 +42,6 @@
 
 
 -- *********************************************************************
--- AccountProjectWatchAccess
---    @PrimaryKey covers: byAccount
---    covers:             byProject
-CREATE INDEX acc_project_watches_byProject
-ON account_project_watches (project_name)
-#
-
--- *********************************************************************
 -- ApprovalCategoryAccess
 --    too small to bother indexing
 
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
index bdceb7b..a11c86b 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
@@ -68,10 +68,6 @@
 CREATE INDEX account_external_ids_byAccount
 ON account_external_ids (account_id);
 
---    covers:             byEmailAddress, suggestByEmailAddress
-CREATE INDEX account_external_ids_byEmail
-ON account_external_ids (email_address);
-
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
@@ -86,13 +82,6 @@
 CREATE INDEX account_group_id_byInclude
 ON account_group_by_id (include_uuid);
 
--- *********************************************************************
--- AccountProjectWatchAccess
---    @PrimaryKey covers: byAccount
---    covers:             byProject
-CREATE INDEX account_project_watches_byP
-ON account_project_watches (project_name);
-
 
 -- *********************************************************************
 -- ApprovalCategoryAccess
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
index 07c00b9..ed378e4 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
@@ -21,22 +21,21 @@
 public class AccountSshKeyTest {
   private static final String KEY =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
-      + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
-      + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
-      + "w== john.doe@example.com";
+          + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
+          + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
+          + "w== john.doe@example.com";
 
   private static final String KEY_WITH_NEWLINES =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS\n"
-      + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28\n"
-      + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T\n"
-      + "w== john.doe@example.com";
+          + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28\n"
+          + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T\n"
+          + "w== john.doe@example.com";
 
   private final Account.Id accountId = new Account.Id(1);
 
   @Test
-  public void testValidity() throws Exception {
-    AccountSshKey key = new AccountSshKey(
-        new AccountSshKey.Id(accountId, -1), KEY);
+  public void validity() throws Exception {
+    AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, -1), KEY);
     assertThat(key.isValid()).isFalse();
     key = new AccountSshKey(new AccountSshKey.Id(accountId, 0), KEY);
     assertThat(key.isValid()).isFalse();
@@ -45,9 +44,8 @@
   }
 
   @Test
-  public void testGetters() throws Exception {
-    AccountSshKey key = new AccountSshKey(
-        new AccountSshKey.Id(accountId, 1), KEY);
+  public void getters() throws Exception {
+    AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, 1), KEY);
     assertThat(key.getSshPublicKey()).isEqualTo(KEY);
     assertThat(key.getAlgorithm()).isEqualTo(KEY.split(" ")[0]);
     assertThat(key.getEncodedKey()).isEqualTo(KEY.split(" ")[1]);
@@ -55,9 +53,8 @@
   }
 
   @Test
-  public void testKeyWithNewLines() throws Exception {
-    AccountSshKey key = new AccountSshKey(
-        new AccountSshKey.Id(accountId, 1), KEY_WITH_NEWLINES);
+  public void keyWithNewLines() throws Exception {
+    AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, 1), KEY_WITH_NEWLINES);
     assertThat(key.getSshPublicKey()).isEqualTo(KEY);
     assertThat(key.getAlgorithm()).isEqualTo(KEY.split(" ")[0]);
     assertThat(key.getEncodedKey()).isEqualTo(KEY.split(" ")[1]);
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
index cf2d289..6d1d0a6 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/ChangeTest.java
@@ -64,6 +64,15 @@
   }
 
   @Test
+  public void parseEditRefNames() {
+    assertRef(5, "refs/users/34/1234/edit-5/1");
+    assertRef(5, "refs/users/34/1234/edit-5");
+    assertNotRef("refs/changes/34/1234/edit-5/1");
+    assertNotRef("refs/users/34/1234/EDIT-5/1");
+    assertNotRef("refs/users/34/1234");
+  }
+
+  @Test
   public void parseChangeMetaRefNames() {
     assertRef(1, "refs/changes/01/1/meta");
     assertRef(1234, "refs/changes/34/1234/meta");
@@ -74,11 +83,47 @@
   }
 
   @Test
+  public void parseRobotCommentRefNames() {
+    assertRef(1, "refs/changes/01/1/robot-comments");
+    assertRef(1234, "refs/changes/34/1234/robot-comments");
+
+    assertNotRef("refs/changes/01/1/robot-comment");
+    assertNotRef("refs/changes/01/1/ROBOT-COMMENTS");
+    assertNotRef("refs/changes/01/1/1/robot-comments");
+  }
+
+  @Test
+  public void parseStarredChangesRefNames() {
+    assertAllUsersRef(1, "refs/starred-changes/01/1/1001");
+    assertAllUsersRef(1234, "refs/starred-changes/34/1234/1001");
+
+    assertNotRef("refs/starred-changes/01/1/1001");
+    assertNotAllUsersRef(null);
+    assertNotAllUsersRef("refs/starred-changes/01/1/1xx1");
+    assertNotAllUsersRef("refs/starred-changes/01/1/");
+    assertNotAllUsersRef("refs/starred-changes/01/1");
+    assertNotAllUsersRef("refs/starred-changes/35/1234/1001");
+    assertNotAllUsersRef("refs/starred-changeS/01/1/1001");
+  }
+
+  @Test
+  public void parseDraftRefNames() {
+    assertAllUsersRef(1, "refs/draft-comments/01/1/1001");
+    assertAllUsersRef(1234, "refs/draft-comments/34/1234/1001");
+
+    assertNotRef("refs/draft-comments/01/1/1001");
+    assertNotAllUsersRef(null);
+    assertNotAllUsersRef("refs/draft-comments/01/1/1xx1");
+    assertNotAllUsersRef("refs/draft-comments/01/1/");
+    assertNotAllUsersRef("refs/draft-comments/01/1");
+    assertNotAllUsersRef("refs/draft-comments/35/1234/1001");
+    assertNotAllUsersRef("refs/draft-commentS/01/1/1001");
+  }
+
+  @Test
   public void toRefPrefix() {
-    assertThat(new Change.Id(1).toRefPrefix())
-        .isEqualTo("refs/changes/01/1/");
-    assertThat(new Change.Id(1234).toRefPrefix())
-        .isEqualTo("refs/changes/34/1234/");
+    assertThat(new Change.Id(1).toRefPrefix()).isEqualTo("refs/changes/01/1/");
+    assertThat(new Change.Id(1234).toRefPrefix()).isEqualTo("refs/changes/34/1234/");
   }
 
   @Test
@@ -110,6 +155,14 @@
     assertThat(Change.Id.fromRef(refName)).isNull();
   }
 
+  private static void assertAllUsersRef(int changeId, String refName) {
+    assertThat(Change.Id.fromAllUsersRef(refName)).isEqualTo(new Change.Id(changeId));
+  }
+
+  private static void assertNotAllUsersRef(String refName) {
+    assertThat(Change.Id.fromAllUsersRef(refName)).isNull();
+  }
+
   private static void assertRefPart(int changeId, String refName) {
     assertEquals(new Change.Id(changeId), Change.Id.fromRefPart(refName));
   }
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
index eba08c8..a0a806f 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetApprovalTest.java
@@ -16,33 +16,23 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-
-import org.junit.Test;
-
+import com.google.gerrit.testutil.GerritBaseTests;
 import java.util.HashMap;
 import java.util.Map;
+import org.junit.Test;
 
-public class PatchSetApprovalTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class PatchSetApprovalTest extends GerritBaseTests {
   @Test
   public void keyEquality() {
-    PatchSetApproval.Key k1 = new PatchSetApproval.Key(
-        new PatchSet.Id(new Change.Id(1), 2),
-        new Account.Id(3),
-        new LabelId("My-Label"));
-    PatchSetApproval.Key k2 = new PatchSetApproval.Key(
-        new PatchSet.Id(new Change.Id(1), 2),
-        new Account.Id(3),
-        new LabelId("My-Label"));
-    PatchSetApproval.Key k3 = new PatchSetApproval.Key(
-        new PatchSet.Id(new Change.Id(1), 2),
-        new Account.Id(3),
-        new LabelId("Other-Label"));
+    PatchSetApproval.Key k1 =
+        new PatchSetApproval.Key(
+            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
+    PatchSetApproval.Key k2 =
+        new PatchSetApproval.Key(
+            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("My-Label"));
+    PatchSetApproval.Key k3 =
+        new PatchSetApproval.Key(
+            new PatchSet.Id(new Change.Id(1), 2), new Account.Id(3), new LabelId("Other-Label"));
 
     assertThat(k2).isEqualTo(k1);
     assertThat(k3).isNotEqualTo(k1);
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
index 0f8aba6..51a405f 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/PatchSetTest.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.reviewdb.client.PatchSet.splitGroups;
 
 import com.google.common.collect.ImmutableList;
-
 import org.junit.Test;
 
 public class PatchSetTest {
@@ -82,9 +81,8 @@
   }
 
   @Test
-  public void testToRefName() {
-    assertThat(new PatchSet.Id(new Change.Id(1), 23).toRefName())
-        .isEqualTo("refs/changes/01/1/23");
+  public void toRefName() {
+    assertThat(new PatchSet.Id(new Change.Id(1), 23).toRefName()).isEqualTo("refs/changes/01/1/23");
     assertThat(new PatchSet.Id(new Change.Id(1234), 5).toRefName())
         .isEqualTo("refs/changes/34/1234/5");
   }
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
index 57cedd5..65a92a0 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -42,37 +42,36 @@
   @Test
   public void refsDraftComments() throws Exception {
     assertThat(RefNames.refsDraftComments(changeId, accountId))
-      .isEqualTo("refs/draft-comments/73/67473/1011123");
+        .isEqualTo("refs/draft-comments/73/67473/1011123");
   }
 
   @Test
   public void refsDraftCommentsPrefix() throws Exception {
     assertThat(RefNames.refsDraftCommentsPrefix(changeId))
-      .isEqualTo("refs/draft-comments/73/67473/");
+        .isEqualTo("refs/draft-comments/73/67473/");
   }
 
   @Test
   public void refsStarredChanges() throws Exception {
     assertThat(RefNames.refsStarredChanges(changeId, accountId))
-      .isEqualTo("refs/starred-changes/73/67473/1011123");
+        .isEqualTo("refs/starred-changes/73/67473/1011123");
   }
 
   @Test
   public void refsStarredChangesPrefix() throws Exception {
     assertThat(RefNames.refsStarredChangesPrefix(changeId))
-      .isEqualTo("refs/starred-changes/73/67473/");
+        .isEqualTo("refs/starred-changes/73/67473/");
   }
 
   @Test
   public void refsEdit() throws Exception {
     assertThat(RefNames.refsEdit(accountId, changeId, psId))
-      .isEqualTo("refs/users/23/1011123/edit-67473/42");
+        .isEqualTo("refs/users/23/1011123/edit-67473/42");
   }
 
   @Test
   public void isRefsEdit() throws Exception {
-    assertThat(RefNames.isRefsEdit("refs/users/23/1011123/edit-67473/42"))
-        .isTrue();
+    assertThat(RefNames.isRefsEdit("refs/users/23/1011123/edit-67473/42")).isTrue();
 
     // user ref, but no edit ref
     assertThat(RefNames.isRefsEdit("refs/users/23/1011123")).isFalse();
@@ -85,14 +84,13 @@
   public void isRefsUsers() throws Exception {
     assertThat(RefNames.isRefsUsers("refs/users/23/1011123")).isTrue();
     assertThat(RefNames.isRefsUsers("refs/users/default")).isTrue();
-    assertThat(RefNames.isRefsUsers("refs/users/23/1011123/edit-67473/42"))
-        .isTrue();
+    assertThat(RefNames.isRefsUsers("refs/users/23/1011123/edit-67473/42")).isTrue();
 
     assertThat(RefNames.isRefsUsers("refs/heads/master")).isFalse();
   }
 
   @Test
-  public void testParseShardedRefsPart() throws Exception {
+  public void testparseShardedRefsPart() throws Exception {
     assertThat(parseShardedRefPart("01/1")).isEqualTo(1);
     assertThat(parseShardedRefPart("01/1-drafts")).isEqualTo(1);
     assertThat(parseShardedRefPart("01/1-drafts/2")).isEqualTo(1);
diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK
deleted file mode 100644
index 4fc578c..0000000
--- a/gerrit-server/BUCK
+++ /dev/null
@@ -1,213 +0,0 @@
-CONSTANTS_SRC = [
-  'src/main/java/com/google/gerrit/server/documentation/Constants.java',
-]
-
-SRCS = glob(
-  ['src/main/java/**/*.java'],
-  excludes = CONSTANTS_SRC,
-)
-RESOURCES =  glob(['src/main/resources/**/*'])
-
-java_library(
-  name = 'constants',
-  srcs = CONSTANTS_SRC,
-  visibility = ['PUBLIC'],
-)
-
-# TODO(sop) break up gerrit-server java_library(), its too big
-java_library(
-  name = 'server',
-  srcs = SRCS,
-  resources = RESOURCES,
-  deps = [
-    ':constants',
-    '//gerrit-antlr:query_exception',
-    '//gerrit-antlr:query_parser',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-patch-commonsnet:commons-net',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-prettify:server',
-    '//gerrit-reviewdb:server',
-    '//gerrit-util-cli:cli',
-    '//gerrit-util-ssl:ssl',
-    '//lib:args4j',
-    '//lib:automaton',
-    '//lib:blame-cache',
-    '//lib:grappa',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:guava-retrying',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib:juniversalchardet',
-    '//lib:mime-util',
-    '//lib:pegdown',
-    '//lib:protobuf',
-    '//lib:tukaani-xz',
-    '//lib:velocity',
-    '//lib/antlr:java_runtime',
-    '//lib/auto:auto-value',
-    '//lib/commons:codec',
-    '//lib/commons:compress',
-    '//lib/commons:dbcp',
-    '//lib/commons:lang',
-    '//lib/commons:net',
-    '//lib/commons:validator',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-    '//lib/log:jsonevent-layout',
-    '//lib/log:log4j',
-    '//lib/lucene:lucene-analyzers-common',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-    '//lib/lucene:lucene-queryparser',
-    '//lib/ow2:ow2-asm',
-    '//lib/ow2:ow2-asm-tree',
-    '//lib/ow2:ow2-asm-util',
-    '//lib/prolog:runtime',
-  ],
-  provided_deps = [
-    '//lib:servlet-api-3_1',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_sources(
-  name = 'server-src',
-  srcs = SRCS + RESOURCES,
-  visibility = ['PUBLIC'],
-)
-
-TESTUTIL_DEPS = [
-  ':server',
-  '//gerrit-common:server',
-  '//gerrit-cache-h2:cache-h2',
-  '//gerrit-extension-api:api',
-  '//gerrit-gpg:gpg',
-  '//gerrit-lucene:lucene',
-  '//gerrit-reviewdb:server',
-  '//lib:gwtorm',
-  '//lib:h2',
-  '//lib:truth',
-  '//lib/guice:guice',
-  '//lib/guice:guice-servlet',
-  '//lib/jgit/org.eclipse.jgit:jgit',
-  '//lib/jgit/org.eclipse.jgit.junit:junit',
-  '//lib/joda:joda-time',
-  '//lib/log:api',
-  '//lib/log:impl_log4j',
-  '//lib/log:log4j',
-]
-
-TESTUTIL = glob([
-  'src/test/java/com/google/gerrit/testutil/**/*.java',
-  'src/test/java/com/google/gerrit/server/project/Util.java',
-  ])
-java_library(
-  name = 'testutil',
-  srcs = TESTUTIL,
-  deps = [
-    '//lib/auto:auto-value',
-  ],
-  provided_deps = TESTUTIL_DEPS,
-  exported_deps = [
-    '//lib/easymock:easymock',
-    '//lib/powermock:powermock-api-easymock',
-    '//lib/powermock:powermock-api-support',
-    '//lib/powermock:powermock-core',
-    '//lib/powermock:powermock-module-junit4',
-    '//lib/powermock:powermock-module-junit4-common',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-PROLOG_TEST_CASE = [
-  'src/test/java/com/google/gerrit/rules/PrologTestCase.java',
-]
-PROLOG_TESTS = glob(
-  ['src/test/java/com/google/gerrit/rules/**/*.java'],
-  excludes = PROLOG_TEST_CASE,
-)
-
-java_library(
-  name = 'prolog_test_case',
-  srcs = PROLOG_TEST_CASE,
-  deps = [
-    ':server',
-    ':testutil',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//lib:guava',
-    '//lib:junit',
-    '//lib:truth',
-    '//lib/guice:guice',
-    '//lib/prolog:runtime',
-  ],
-)
-
-java_test(
-  name = 'prolog_tests',
-  srcs = PROLOG_TESTS,
-  resources = glob(['src/test/resources/com/google/gerrit/rules/**/*']),
-  deps = TESTUTIL_DEPS + [
-    ':prolog_test_case',
-    ':testutil',
-    '//gerrit-server/src/main/prolog:common',
-    '//lib/prolog:runtime',
-  ],
-)
-
-QUERY_TESTS = glob(
-  ['src/test/java/com/google/gerrit/server/query/**/*.java'],
-)
-
-java_test(
-  name = 'query_tests',
-  srcs = QUERY_TESTS,
-  deps = TESTUTIL_DEPS + [
-    ':testutil',
-    '//gerrit-antlr:query_exception',
-    '//gerrit-antlr:query_parser',
-    '//gerrit-common:annotations',
-    '//gerrit-server/src/main/prolog:common',
-    '//lib/antlr:java_runtime',
-  ],
-  source_under_test = [':server'],
-)
-
-java_test(
-  name = 'server_tests',
-  labels = ['server'],
-  srcs = glob(
-    ['src/test/java/**/*.java'],
-    excludes = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS
-  ),
-  resources = glob(['src/test/resources/com/google/gerrit/server/mail/*']),
-  deps = TESTUTIL_DEPS + [
-    ':testutil',
-    '//gerrit-antlr:query_exception',
-    '//gerrit-common:annotations',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-server/src/main/prolog:common',
-    '//lib:args4j',
-    '//lib:grappa',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:guava-retrying',
-    '//lib:protobuf',
-    '//lib/commons:validator',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib/guice:guice-assistedinject',
-    '//lib/prolog:runtime',
-  ],
-  source_under_test = [':server'],
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
index 5a6b50f..d1ac723 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -1,208 +1,252 @@
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:javadoc.bzl", "java_doc")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
 CONSTANTS_SRC = [
-  'src/main/java/com/google/gerrit/server/documentation/Constants.java',
+    "src/main/java/com/google/gerrit/server/documentation/Constants.java",
 ]
 
 SRCS = glob(
-  ['src/main/java/**/*.java'],
-  exclude = CONSTANTS_SRC,
+    ["src/main/java/**/*.java"],
+    exclude = CONSTANTS_SRC,
 )
-RESOURCES =  glob(['src/main/resources/**/*'])
+
+RESOURCES = glob(["src/main/resources/**/*"])
 
 java_library(
-  name = 'constants',
-  srcs = CONSTANTS_SRC,
-  visibility = ['//visibility:public'],
+    name = "constants",
+    srcs = CONSTANTS_SRC,
+    visibility = ["//visibility:public"],
 )
 
 java_library(
-  name = 'server',
-  srcs = SRCS,
-  resources = RESOURCES,
-  deps = [
-    ':constants',
-    '//gerrit-antlr:query_exception',
-    '//gerrit-antlr:query_parser',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//gerrit-patch-commonsnet:commons-net',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-prettify:server',
-    '//gerrit-reviewdb:server',
-    '//gerrit-util-cli:cli',
-    '//gerrit-util-ssl:ssl',
-    '//lib:args4j',
-    '//lib:automaton',
-    '//lib:blame-cache',
-    '//lib:grappa',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:guava-retrying',
-    '//lib:gwtjsonrpc',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib:juniversalchardet',
-    '//lib:mime-util',
-    '//lib:pegdown',
-    '//lib:protobuf',
-    '//lib:servlet-api-3_1',
-    '//lib:tukaani-xz',
-    '//lib:velocity',
-    '//lib/antlr:java_runtime',
-    '//lib/auto:auto-value',
-    '//lib/commons:codec',
-    '//lib/commons:compress',
-    '//lib/commons:dbcp',
-    '//lib/commons:lang',
-    '//lib/commons:net',
-    '//lib/commons:validator',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
-    '//lib/joda:joda-time',
-    '//lib/log:api',
-    '//lib/log:jsonevent-layout',
-    '//lib/log:log4j',
-    '//lib/lucene:lucene-analyzers-common',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-    '//lib/lucene:lucene-queryparser',
-    '//lib/ow2:ow2-asm',
-    '//lib/ow2:ow2-asm-tree',
-    '//lib/ow2:ow2-asm-util',
-    '//lib/prolog:runtime',
-  ],
-  visibility = ['//visibility:public'],
+    name = "server",
+    srcs = SRCS,
+    resources = RESOURCES,
+    visibility = ["//visibility:public"],
+    deps = [
+        ":constants",
+        "//gerrit-antlr:query_exception",
+        "//gerrit-antlr:query_parser",
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-patch-commonsnet:commons-net",
+        "//gerrit-patch-jgit:server",
+        "//gerrit-prettify:server",
+        "//gerrit-reviewdb:server",
+        "//gerrit-util-cli:cli",
+        "//gerrit-util-ssl:ssl",
+        "//lib:args4j",
+        "//lib:automaton",
+        "//lib:blame-cache",
+        "//lib:grappa",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib:jsch",
+        "//lib:juniversalchardet",
+        "//lib:mime-util",
+        "//lib:pegdown",
+        "//lib:protobuf",
+        "//lib:servlet-api-3_1",
+        "//lib:soy",
+        "//lib:tukaani-xz",
+        "//lib:velocity",
+        "//lib/antlr:java-runtime",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/bouncycastle:bcpkix-neverlink",
+        "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/commons:codec",
+        "//lib/commons:compress",
+        "//lib/commons:dbcp",
+        "//lib/commons:lang",
+        "//lib/commons:net",
+        "//lib/commons:validator",
+        "//lib/dropwizard:dropwizard-core",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/joda:joda-time",
+        "//lib/jsoup",
+        "//lib/log:api",
+        "//lib/log:jsonevent-layout",
+        "//lib/log:log4j",
+        "//lib/lucene:lucene-analyzers-common",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-queryparser",
+        "//lib/mime4j:core",
+        "//lib/mime4j:dom",
+        "//lib/ow2:ow2-asm",
+        "//lib/ow2:ow2-asm-tree",
+        "//lib/ow2:ow2-asm-util",
+        "//lib/prolog:runtime",
+    ],
 )
 
 TESTUTIL_DEPS = [
-  ':server',
-  '//gerrit-common:server',
-  '//gerrit-cache-h2:cache-h2',
-  '//gerrit-extension-api:api',
-  '//gerrit-gpg:gpg',
-  '//gerrit-lucene:lucene',
-  '//gerrit-reviewdb:server',
-  '//lib:gwtorm',
-  '//lib:h2',
-  '//lib:truth',
-  '//lib/guice:guice',
-  '//lib/guice:guice-servlet',
-  '//lib/jgit/org.eclipse.jgit:jgit',
-  '//lib/jgit/org.eclipse.jgit.junit:junit',
-  '//lib/joda:joda-time',
-  '//lib/log:api',
-  '//lib/log:impl_log4j',
-  '//lib/log:log4j',
+    ":server",
+    "//gerrit-common:annotations",
+    "//gerrit-common:server",
+    "//gerrit-cache-h2:cache-h2",
+    "//gerrit-cache-mem:mem",
+    "//gerrit-extension-api:api",
+    "//gerrit-gpg:gpg",
+    "//gerrit-lucene:lucene",
+    "//gerrit-reviewdb:server",
+    "//lib:gwtorm",
+    "//lib:h2",
+    "//lib:truth",
+    "//lib/guice:guice",
+    "//lib/guice:guice-servlet",
+    "//lib/jgit/org.eclipse.jgit:jgit",
+    "//lib/jgit/org.eclipse.jgit.junit:junit",
+    "//lib/joda:joda-time",
+    "//lib/log:api",
+    "//lib/log:impl-log4j",
+    "//lib/log:log4j",
 ]
 
 TESTUTIL = glob([
-  'src/test/java/com/google/gerrit/testutil/**/*.java',
-  'src/test/java/com/google/gerrit/server/project/Util.java',
-])
+    "src/test/java/com/google/gerrit/testutil/**/*.java",
+]) + [
+    "src/test/java/com/google/gerrit/server/project/Util.java",
+]
 
 java_library(
-  name = 'testutil',
-  srcs = TESTUTIL,
-  deps = TESTUTIL_DEPS + [
-    '//lib/auto:auto-value',
-    '//lib/easymock:easymock',
-    '//lib/powermock:powermock-api-easymock',
-    '//lib/powermock:powermock-api-support',
-    '//lib/powermock:powermock-core',
-    '//lib/powermock:powermock-module-junit4',
-    '//lib/powermock:powermock-module-junit4-common',
-  ],
-  exports = [
-    '//lib/easymock:easymock',
-    '//lib/powermock:powermock-api-easymock',
-    '//lib/powermock:powermock-api-support',
-    '//lib/powermock:powermock-core',
-    '//lib/powermock:powermock-module-junit4',
-    '//lib/powermock:powermock-module-junit4-common',
-  ],
-  visibility = ['//visibility:public'],
+    name = "testutil",
+    testonly = 1,
+    srcs = TESTUTIL,
+    visibility = ["//visibility:public"],
+    exports = [
+        "//lib/easymock",
+        "//lib/powermock:powermock-api-easymock",
+        "//lib/powermock:powermock-api-support",
+        "//lib/powermock:powermock-core",
+        "//lib/powermock:powermock-module-junit4",
+        "//lib/powermock:powermock-module-junit4-common",
+    ],
+    deps = TESTUTIL_DEPS + [
+        "//gerrit-pgm:init",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/easymock:easymock",
+        "//lib/powermock:powermock-api-easymock",
+        "//lib/powermock:powermock-api-support",
+        "//lib/powermock:powermock-core",
+        "//lib/powermock:powermock-module-junit4",
+        "//lib/powermock:powermock-module-junit4-common",
+    ],
 )
 
 PROLOG_TEST_CASE = [
-  'src/test/java/com/google/gerrit/rules/PrologTestCase.java',
+    "src/test/java/com/google/gerrit/rules/PrologTestCase.java",
 ]
+
 PROLOG_TESTS = glob(
-  ['src/test/java/com/google/gerrit/rules/**/*.java'],
-  exclude = PROLOG_TEST_CASE,
+    ["src/test/java/com/google/gerrit/rules/**/*.java"],
+    exclude = PROLOG_TEST_CASE,
 )
 
 java_library(
-  name = 'prolog_test_case',
-  srcs = PROLOG_TEST_CASE,
-  deps = [
-    ':server',
-    ':testutil',
-    '//gerrit-common:server',
-    '//gerrit-extension-api:api',
-    '//lib:guava',
-    '//lib:junit',
-    '//lib:truth',
-    '//lib/guice:guice',
-    '//lib/prolog:runtime',
-  ],
+    name = "prolog_test_case",
+    testonly = 1,
+    srcs = PROLOG_TEST_CASE,
+    deps = [
+        ":server",
+        ":testutil",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//lib:guava",
+        "//lib:junit",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/prolog:runtime",
+    ],
 )
 
 junit_tests(
-  name = 'prolog_tests',
-  srcs = PROLOG_TESTS,
-  resources = glob(['src/test/resources/com/google/gerrit/rules/**/*']),
-  deps = TESTUTIL_DEPS + [
-    ':prolog_test_case',
-    ':testutil',
-    '//gerrit-server/src/main/prolog:common',
-    '//lib/prolog:runtime',
-  ],
+    name = "prolog_tests",
+    srcs = PROLOG_TESTS,
+    resources = glob(["src/test/resources/com/google/gerrit/rules/**/*"]),
+    deps = TESTUTIL_DEPS + [
+        ":prolog_test_case",
+        ":testutil",
+        "//gerrit-server/src/main/prolog:common",
+        "//lib/prolog:runtime",
+    ],
 )
 
 QUERY_TESTS = glob(
-  ['src/test/java/com/google/gerrit/server/query/**/*.java'],
+    ["src/test/java/com/google/gerrit/server/query/**/*.java"],
+)
+
+java_library(
+    name = "query_tests_code",
+    testonly = 1,
+    srcs = QUERY_TESTS,
+    visibility = ["//visibility:public"],
+    deps = TESTUTIL_DEPS + [
+        ":testutil",
+        "//gerrit-antlr:query_exception",
+        "//gerrit-antlr:query_parser",
+        "//gerrit-server/src/main/prolog:common",
+        "//lib/antlr:java-runtime",
+    ],
 )
 
 junit_tests(
-  name = 'query_tests',
-  srcs = QUERY_TESTS,
-  deps = TESTUTIL_DEPS + [
-    ':testutil',
-    '//gerrit-antlr:query_exception',
-    '//gerrit-antlr:query_parser',
-    '//gerrit-common:annotations',
-    '//gerrit-server/src/main/prolog:common',
-    '//lib/antlr:java_runtime',
-  ],
-  visibility = ['//visibility:public'],
+    name = "query_tests",
+    size = "large",
+    srcs = QUERY_TESTS,
+    visibility = ["//visibility:public"],
+    deps = TESTUTIL_DEPS + [
+        ":testutil",
+        "//gerrit-antlr:query_exception",
+        "//gerrit-antlr:query_parser",
+        "//gerrit-server/src/main/prolog:common",
+        "//lib/antlr:java-runtime",
+    ],
 )
 
 junit_tests(
-  name = 'server_tests',
-  srcs = glob(
-    ['src/test/java/**/*.java'],
-    exclude = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS
-  ),
-  deps = TESTUTIL_DEPS + [
-    ':testutil',
-    '//gerrit-antlr:query_exception',
-    '//gerrit-common:annotations',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-server/src/main/prolog:common',
-    '//lib:args4j',
-    '//lib:grappa',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:guava-retrying',
-    '//lib:protobuf',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib/guice:guice-assistedinject',
-    '//lib/prolog:runtime',
-  ],
-  visibility = ['//visibility:public'],
+    name = "server_tests",
+    size = "large",
+    srcs = glob(
+        ["src/test/java/**/*.java"],
+        exclude = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS,
+    ),
+    resources = glob(["src/test/resources/com/google/gerrit/server/**/*"]),
+    visibility = ["//visibility:public"],
+    deps = TESTUTIL_DEPS + [
+        ":testutil",
+        "//gerrit-antlr:query_exception",
+        "//gerrit-patch-jgit:server",
+        "//gerrit-server/src/main/prolog:common",
+        "//gerrit-test-util:test_util",
+        "//lib:args4j",
+        "//lib:grappa",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:protobuf",
+        "//lib/bouncycastle:bcprov",
+        "//lib/bouncycastle:bcpkix",
+        "//lib/dropwizard:dropwizard-core",
+        "//lib/guice:guice-assistedinject",
+        "//lib/prolog:runtime",
+        "//lib/commons:codec",
+    ],
+)
+
+java_doc(
+    name = "doc",
+    libs = [":server"],
+    pkgs = ["com.google.gerrit"],
+    title = "Gerrit Review Server Documentation",
 )
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditEvent.java
index 14f12b8..7a4e683 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditEvent.java
@@ -17,21 +17,21 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Preconditions;
-import com.google.common.collect.HashMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.server.CurrentUser;
 
 public class AuditEvent {
 
   public static final String UNKNOWN_SESSION_ID = "000000000000000000000000000";
-  protected static final Multimap<String, ?> EMPTY_PARAMS = HashMultimap.create();
+  protected static final ImmutableListMultimap<String, ?> EMPTY_PARAMS = ImmutableListMultimap.of();
 
   public final String sessionId;
   public final CurrentUser who;
   public final long when;
   public final String what;
-  public final Multimap<String, ?> params;
+  public final ListMultimap<String, ?> params;
   public final Object result;
   public final long timeAtStart;
   public final long elapsed;
@@ -57,8 +57,13 @@
    * @param params parameters of the event
    * @param result result of the event
    */
-  public AuditEvent(String sessionId, CurrentUser who, String what, long when,
-      Multimap<String, ?> params, Object result) {
+  public AuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      Object result) {
     Preconditions.checkNotNull(what, "what is a mandatory not null param !");
 
     this.sessionId = MoreObjects.firstNonNull(sessionId, UNKNOWN_SESSION_ID);
@@ -95,7 +100,8 @@
 
   @Override
   public String toString() {
-    return String.format("AuditEvent UUID:%s, SID:%s, TS:%d, who:%s, what:%s",
+    return String.format(
+        "AuditEvent UUID:%s, SID:%s, TS:%d, who:%s, what:%s",
         uuid.uuid(), sessionId, when, who, what);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditListener.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditListener.java
index 0aab248..8eb8ed4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditListener.java
@@ -20,5 +20,4 @@
 public interface AuditListener {
 
   void onAuditableAction(AuditEvent action);
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java
index 89b51f8..aedb8a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java
@@ -25,5 +25,4 @@
     DynamicSet.setOf(binder(), GroupMemberAuditListener.class);
     bind(AuditService.class);
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java
index 4844045..cc29559 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java
@@ -20,12 +20,10 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.util.Collection;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collection;
-
 @Singleton
 public class AuditService {
   private static final Logger log = LoggerFactory.getLogger(AuditService.class);
@@ -34,7 +32,8 @@
   private final DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners;
 
   @Inject
-  public AuditService(DynamicSet<AuditListener> auditListeners,
+  public AuditService(
+      DynamicSet<AuditListener> auditListeners,
       DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners) {
     this.auditListeners = auditListeners;
     this.groupMemberAuditListeners = groupMemberAuditListeners;
@@ -46,8 +45,7 @@
     }
   }
 
-  public void dispatchAddAccountsToGroup(Account.Id actor,
-      Collection<AccountGroupMember> added) {
+  public void dispatchAddAccountsToGroup(Account.Id actor, Collection<AccountGroupMember> added) {
     for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
       try {
         auditListener.onAddAccountsToGroup(actor, added);
@@ -57,8 +55,8 @@
     }
   }
 
-  public void dispatchDeleteAccountsFromGroup(Account.Id actor,
-      Collection<AccountGroupMember> removed) {
+  public void dispatchDeleteAccountsFromGroup(
+      Account.Id actor, Collection<AccountGroupMember> removed) {
     for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
       try {
         auditListener.onDeleteAccountsFromGroup(actor, removed);
@@ -68,8 +66,7 @@
     }
   }
 
-  public void dispatchAddGroupsToGroup(Account.Id actor,
-      Collection<AccountGroupById> added) {
+  public void dispatchAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added) {
     for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
       try {
         auditListener.onAddGroupsToGroup(actor, added);
@@ -79,8 +76,8 @@
     }
   }
 
-  public void dispatchDeleteGroupsFromGroup(Account.Id actor,
-      Collection<AccountGroupById> removed) {
+  public void dispatchDeleteGroupsFromGroup(
+      Account.Id actor, Collection<AccountGroupById> removed) {
     for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
       try {
         auditListener.onDeleteGroupsFromGroup(actor, removed);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java
index 90cddeeb..4db8a51 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/ExtendedHttpAuditEvent.java
@@ -15,16 +15,13 @@
 package com.google.gerrit.audit;
 
 import com.google.common.base.Preconditions;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
-
 import javax.servlet.http.HttpServletRequest;
 
-/**
- * Extended audit event. Adds request, resource and view data to HttpAuditEvent.
- */
+/** Extended audit event. Adds request, resource and view data to HttpAuditEvent. */
 public class ExtendedHttpAuditEvent extends HttpAuditEvent {
   public final HttpServletRequest httpRequest;
   public final RestResource resource;
@@ -44,12 +41,27 @@
    * @param resource REST resource data
    * @param view view rendering object
    */
-  public ExtendedHttpAuditEvent(String sessionId, CurrentUser who,
-      HttpServletRequest httpRequest, long when, Multimap<String, ?> params,
-      Object input, int status, Object result, RestResource resource,
+  public ExtendedHttpAuditEvent(
+      String sessionId,
+      CurrentUser who,
+      HttpServletRequest httpRequest,
+      long when,
+      ListMultimap<String, ?> params,
+      Object input,
+      int status,
+      Object result,
+      RestResource resource,
       RestView<RestResource> view) {
-    super(sessionId, who, httpRequest.getRequestURI(), when, params, httpRequest.getMethod(),
-        input, status, result);
+    super(
+        sessionId,
+        who,
+        httpRequest.getRequestURI(),
+        when,
+        params,
+        httpRequest.getMethod(),
+        input,
+        status,
+        result);
     this.httpRequest = Preconditions.checkNotNull(httpRequest);
     this.resource = resource;
     this.view = view;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/GroupMemberAuditListener.java b/gerrit-server/src/main/java/com/google/gerrit/audit/GroupMemberAuditListener.java
index 1269f4a..0878499 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/GroupMemberAuditListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/GroupMemberAuditListener.java
@@ -18,20 +18,16 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroupById;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
-
 import java.util.Collection;
 
 @ExtensionPoint
 public interface GroupMemberAuditListener {
 
-  void onAddAccountsToGroup(Account.Id actor,
-      Collection<AccountGroupMember> added);
+  void onAddAccountsToGroup(Account.Id actor, Collection<AccountGroupMember> added);
 
-  void onDeleteAccountsFromGroup(Account.Id actor,
-      Collection<AccountGroupMember> removed);
+  void onDeleteAccountsFromGroup(Account.Id actor, Collection<AccountGroupMember> removed);
 
   void onAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added);
 
-  void onDeleteGroupsFromGroup(Account.Id actor,
-      Collection<AccountGroupById> deleted);
+  void onDeleteGroupsFromGroup(Account.Id actor, Collection<AccountGroupById> deleted);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java
index 805e050..cd19606 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/HttpAuditEvent.java
@@ -13,7 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.audit;
 
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.server.CurrentUser;
 
 public class HttpAuditEvent extends AuditEvent {
@@ -34,8 +34,16 @@
    * @param status HTTP status
    * @param result result of the event
    */
-  public HttpAuditEvent(String sessionId, CurrentUser who, String what, long when,
-      Multimap<String, ?> params, String httpMethod, Object input, int status, Object result) {
+  public HttpAuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      String httpMethod,
+      Object input,
+      int status,
+      Object result) {
     super(sessionId, who, what, when, params, result);
     this.httpMethod = httpMethod;
     this.input = input;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java
index 157b72d..f6b955c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/RpcAuditEvent.java
@@ -11,9 +11,10 @@
 // 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.audit;
 
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.server.CurrentUser;
 
 public class RpcAuditEvent extends HttpAuditEvent {
@@ -31,9 +32,16 @@
    * @param status HTTP status
    * @param result result of the event
    */
-  public RpcAuditEvent(String sessionId, CurrentUser who, String what,
-      long when, Multimap<String, ?> params, String httpMethod, Object input,
-      int status, Object result) {
+  public RpcAuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      String httpMethod,
+      Object input,
+      int status,
+      Object result) {
     super(sessionId, who, what, when, params, httpMethod, input, status, result);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/SshAuditEvent.java b/gerrit-server/src/main/java/com/google/gerrit/audit/SshAuditEvent.java
index 58864c8..98cba09 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/SshAuditEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/SshAuditEvent.java
@@ -11,15 +11,21 @@
 // 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.audit;
 
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.server.CurrentUser;
 
 public class SshAuditEvent extends AuditEvent {
 
-  public SshAuditEvent(String sessionId, CurrentUser who, String what,
-      long when, Multimap<String, ?> params, Object result) {
+  public SshAuditEvent(
+      String sessionId,
+      CurrentUser who,
+      String what,
+      long when,
+      ListMultimap<String, ?> params,
+      Object result) {
     super(sessionId, who, what, when, params, result);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
index 0029768..4603141 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.events.ProjectEvent;
 import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
@@ -35,10 +36,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Distributes Events to listeners if they are allowed to see them */
 @Singleton
 public class EventBroker implements EventDispatcher {
+  private static final Logger log = LoggerFactory.getLogger(EventBroker.class);
 
   public static class Module extends LifecycleModule {
     @Override
@@ -48,10 +52,7 @@
     }
   }
 
-  /**
-   * Listeners to receive changes as they happen (limited by visibility of
-   * user).
-   */
+  /** Listeners to receive changes as they happen (limited by visibility of user). */
   protected final DynamicSet<UserScopedEventListener> listeners;
 
   /** Listeners to receive all changes as they happen. */
@@ -64,7 +65,8 @@
   protected final Provider<ReviewDb> dbProvider;
 
   @Inject
-  public EventBroker(DynamicSet<UserScopedEventListener> listeners,
+  public EventBroker(
+      DynamicSet<UserScopedEventListener> listeners,
       DynamicSet<EventListener> unrestrictedListeners,
       ProjectCache projectCache,
       ChangeNotes.Factory notesFactory,
@@ -77,8 +79,7 @@
   }
 
   @Override
-  public void postEvent(Change change, ChangeEvent event)
-      throws OrmException {
+  public void postEvent(Change change, ChangeEvent event) throws OrmException {
     fireEvent(change, event);
   }
 
@@ -103,8 +104,7 @@
     }
   }
 
-  protected void fireEvent(Change change, ChangeEvent event)
-      throws OrmException {
+  protected void fireEvent(Change change, ChangeEvent event) throws OrmException {
     for (UserScopedEventListener listener : listeners) {
       if (isVisibleTo(change, listener.getUser())) {
         listener.onEvent(event);
@@ -148,8 +148,7 @@
     return pe.controlFor(user).isVisible();
   }
 
-  protected boolean isVisibleTo(Change change, CurrentUser user)
-      throws OrmException {
+  protected boolean isVisibleTo(Change change, CurrentUser user) throws OrmException {
     if (change == null) {
       return false;
     }
@@ -171,16 +170,21 @@
     return pc.controlForRef(branchName).isVisible();
   }
 
-  protected boolean isVisibleTo(Event event, CurrentUser user)
-      throws OrmException {
+  protected boolean isVisibleTo(Event event, CurrentUser user) throws OrmException {
     if (event instanceof RefEvent) {
       RefEvent refEvent = (RefEvent) event;
       String ref = refEvent.getRefName();
       if (PatchSet.isChangeRef(ref)) {
         Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
-        Change change = notesFactory.create(
-            dbProvider.get(), refEvent.getProjectNameKey(), cid).getChange();
-        return isVisibleTo(change, user);
+        try {
+          Change change =
+              notesFactory
+                  .createChecked(dbProvider.get(), refEvent.getProjectNameKey(), cid)
+                  .getChange();
+          return isVisibleTo(change, user);
+        } catch (NoSuchChangeException e) {
+          log.debug("Change {} cannot be found, falling back on ref visibility check", cid.id);
+        }
       }
       return isVisibleTo(refEvent.getBranchNameKey(), user);
     } else if (event instanceof ProjectEvent) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
index 09fa581..20d55d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.server.events.RefEvent;
 import com.google.gwtorm.server.OrmException;
 
-
 /** Interface for posting (dispatching) Events */
 public interface EventDispatcher {
   /**
@@ -53,10 +52,9 @@
 
   /**
    * Post a stream event generically.
-   * <p>
-   * If you are creating a RefEvent or ChangeEvent from scratch,
-   * it is more efficient to use the specific postEvent methods
-   * for those use cases.
+   *
+   * <p>If you are creating a RefEvent or ChangeEvent from scratch, it is more efficient to use the
+   * specific postEvent methods for those use cases.
    *
    * @param event The event to post.
    * @throws OrmException on failure to post the event due to DB error
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
index b2d5680..6cfc5eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventListener.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.events.Event;
 
 /**
- * Allows to listen to events without user visibility restrictions. To listen to
- * events visible to a specific user, use {@link UserScopedEventListener}.
+ * Allows to listen to events without user visibility restrictions. To listen to events visible to a
+ * specific user, use {@link UserScopedEventListener}.
  */
 @ExtensionPoint
 public interface EventListener {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java
index 22435ba..3216bac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/UserScopedEventListener.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.server.CurrentUser;
 
 /**
- * Allows to listen to events visible to the specified user. To listen to events
- * without user visibility restrictions, use {@link EventListener}.
+ * Allows to listen to events visible to the specified user. To listen to events without user
+ * visibility restrictions, use {@link EventListener}.
  */
 @ExtensionPoint
 public interface UserScopedEventListener extends EventListener {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/Version.java b/gerrit-server/src/main/java/com/google/gerrit/common/Version.java
index 57a2946..f5a63af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/Version.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/Version.java
@@ -16,13 +16,12 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class Version {
   private static final Logger log = LoggerFactory.getLogger(Version.class);
@@ -57,6 +56,5 @@
     }
   }
 
-  private Version() {
-  }
+  private Version() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java b/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java
index db6faa2..bbffd49 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleManager.java
@@ -23,10 +23,8 @@
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
-
-import org.slf4j.LoggerFactory;
-
 import java.util.List;
+import org.slf4j.LoggerFactory;
 
 /** Tracks and executes registered {@link LifecycleListener}s. */
 public class LifecycleManager {
@@ -36,34 +34,38 @@
   /** Index of the last listener to start successfully; -1 when not started. */
   private int startedIndex = -1;
 
-  /** Add a handle that must be cleared during stop.
+  /**
+   * Add a handle that must be cleared during stop.
    *
    * @param handle the handle to add.
-   **/
+   */
   public void add(RegistrationHandle handle) {
     handles.add(handle);
   }
 
-  /** Add a single listener.
+  /**
+   * Add a single listener.
    *
    * @param listener the listener to add.
-   **/
+   */
   public void add(LifecycleListener listener) {
     listeners.add(Providers.of(listener));
   }
 
-  /** Add a single listener.
+  /**
+   * Add a single listener.
    *
    * @param listener the listener to add.
-   **/
+   */
   public void add(Provider<LifecycleListener> listener) {
     listeners.add(listener);
   }
 
-  /** Add all {@link LifecycleListener}s registered in the Injector.
+  /**
+   * Add all {@link LifecycleListener}s registered in the Injector.
    *
    * @param injector the injector to add.
-   **/
+   */
   public void add(Injector injector) {
     Preconditions.checkState(startedIndex < 0, "Already started");
     for (Binding<LifecycleListener> binding : get(injector)) {
@@ -71,10 +73,11 @@
     }
   }
 
-  /** Add all {@link LifecycleListener}s registered in the Injectors.
+  /**
+   * Add all {@link LifecycleListener}s registered in the Injectors.
    *
    * @param injectors the injectors to add.
-   **/
+   */
   public void add(Injector... injectors) {
     for (Injector i : injectors) {
       add(i);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java b/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java
index c22f2ee..bfb61d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/lifecycle/LifecycleModule.java
@@ -5,22 +5,18 @@
 import com.google.inject.Singleton;
 import com.google.inject.binder.LinkedBindingBuilder;
 import com.google.inject.internal.UniqueAnnotations;
-
 import java.lang.annotation.Annotation;
 
 /** Module to support registering a unique LifecyleListener. */
 public abstract class LifecycleModule extends FactoryModule {
   /**
    * @return a unique listener binding.
-   * <p>
-   * To create a listener binding use:
-   *
-   * <pre>
+   *     <p>To create a listener binding use:
+   *     <pre>
    * listener().to(MyListener.class);
    * </pre>
-   *
-   * where {@code MyListener} is a {@link Singleton} implementing the
-   * {@link LifecycleListener} interface.
+   *     where {@code MyListener} is a {@link Singleton} implementing the {@link LifecycleListener}
+   *     interface.
    */
   protected LinkedBindingBuilder<LifecycleListener> listener() {
     final Annotation id = UniqueAnnotations.create();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java
index 1714c7a..1264645 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 
-
 /**
  * Metric whose value is supplied when the trigger is invoked.
  *
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java
index 864a0ea..1df88a6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/CallbackMetric1.java
@@ -38,6 +38,5 @@
 
   /** Prune any submetrics that were not assigned during this trigger. */
   @Override
-  public void prune() {
-  }
+  public void prune() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java
index a2af7e4..d684cd2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter0.java
@@ -18,13 +18,13 @@
 
 /**
  * Metric whose value increments during the life of the process.
- * <p>
- * Suitable uses are "total requests handled", "bytes sent", etc.
- * Use {@link Description#setRate()} to suggest the monitoring system
- * should also track the rate of increments if this is of interest.
- * <p>
- * For an instantaneous read of a value that can change over time
- * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * <p>Suitable uses are "total requests handled", "bytes sent", etc. Use {@link
+ * Description#setRate()} to suggest the monitoring system should also track the rate of increments
+ * if this is of interest.
+ *
+ * <p>For an instantaneous read of a value that can change over time (e.g. "memory in use") use a
+ * {@link CallbackMetric}.
  */
 public abstract class Counter0 implements RegistrationHandle {
   /** Increment the counter by one event. */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java
index 1b8c833..78b2496 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter1.java
@@ -18,13 +18,13 @@
 
 /**
  * Metric whose value increments during the life of the process.
- * <p>
- * Suitable uses are "total requests handled", "bytes sent", etc.
- * Use {@link Description#setRate()} to suggest the monitoring system
- * should also track the rate of increments if this is of interest.
- * <p>
- * For an instantaneous read of a value that can change over time
- * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * <p>Suitable uses are "total requests handled", "bytes sent", etc. Use {@link
+ * Description#setRate()} to suggest the monitoring system should also track the rate of increments
+ * if this is of interest.
+ *
+ * <p>For an instantaneous read of a value that can change over time (e.g. "memory in use") use a
+ * {@link CallbackMetric}.
  *
  * @param <F1> type of the field.
  */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java
index a24b46d..5f2ae55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter2.java
@@ -18,13 +18,13 @@
 
 /**
  * Metric whose value increments during the life of the process.
- * <p>
- * Suitable uses are "total requests handled", "bytes sent", etc.
- * Use {@link Description#setRate()} to suggest the monitoring system
- * should also track the rate of increments if this is of interest.
- * <p>
- * For an instantaneous read of a value that can change over time
- * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * <p>Suitable uses are "total requests handled", "bytes sent", etc. Use {@link
+ * Description#setRate()} to suggest the monitoring system should also track the rate of increments
+ * if this is of interest.
+ *
+ * <p>For an instantaneous read of a value that can change over time (e.g. "memory in use") use a
+ * {@link CallbackMetric}.
  *
  * @param <F1> type of the field.
  * @param <F2> type of the field.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java
index e0ac5be..7a51bdb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Counter3.java
@@ -18,13 +18,13 @@
 
 /**
  * Metric whose value increments during the life of the process.
- * <p>
- * Suitable uses are "total requests handled", "bytes sent", etc.
- * Use {@link Description#setRate()} to suggest the monitoring system
- * should also track the rate of increments if this is of interest.
- * <p>
- * For an instantaneous read of a value that can change over time
- * (e.g. "memory in use") use a {@link CallbackMetric}.
+ *
+ * <p>Suitable uses are "total requests handled", "bytes sent", etc. Use {@link
+ * Description#setRate()} to suggest the monitoring system should also track the rate of increments
+ * if this is of interest.
+ *
+ * <p>For an instantaneous read of a value that can change over time (e.g. "memory in use") use a
+ * {@link CallbackMetric}.
  *
  * @param <F1> type of the field.
  * @param <F2> type of the field.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java
index b1579f8..10568bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Description.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
-
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
@@ -40,8 +39,7 @@
 
     public static final String BYTES = "bytes";
 
-    private Units() {
-    }
+    private Units() {}
   }
 
   public enum FieldOrdering {
@@ -49,10 +47,9 @@
     AT_END,
 
     /**
-     * Splits the metric name by inserting field values before the last '/' in
-     * the metric name. For example {@code "plugins/replication/push_latency"}
-     * with a {@code Field.ofString("remote")} will create submetrics named
-     * {@code "plugins/replication/some-server/push_latency"}.
+     * Splits the metric name by inserting field values before the last '/' in the metric name. For
+     * example {@code "plugins/replication/push_latency"} with a {@code Field.ofString("remote")}
+     * will create submetrics named {@code "plugins/replication/some-server/push_latency"}.
      */
     PREFIX_FIELDS_BASENAME;
   }
@@ -62,16 +59,16 @@
   /**
    * Describe a metric.
    *
-   * @param helpText a short one-sentence string explaining the values captured
-   *        by the metric. This may be made available to administrators as
-   *        documentation in the reporting tools.
+   * @param helpText a short one-sentence string explaining the values captured by the metric. This
+   *     may be made available to administrators as documentation in the reporting tools.
    */
   public Description(String helpText) {
     annotations = Maps.newLinkedHashMapWithExpectedSize(4);
     annotations.put(DESCRIPTION, helpText);
   }
 
-  /** Set unit used to describe the value.
+  /**
+   * Set unit used to describe the value.
    *
    * @param unitName name of the unit, e.g. "requests", "seconds", etc.
    * @return this
@@ -82,9 +79,8 @@
   }
 
   /**
-   * Mark the value as constant for the life of this process. Typically used for
-   * software versions, command line arguments, etc. that cannot change without
-   * a process restart.
+   * Mark the value as constant for the life of this process. Typically used for software versions,
+   * command line arguments, etc. that cannot change without a process restart.
    *
    * @return this
    */
@@ -94,9 +90,8 @@
   }
 
   /**
-   * Indicates the metric may be usefully interpreted as a count over short
-   * periods of time, such as request arrival rate. May only be applied to a
-   * {@link Counter0}.
+   * Indicates the metric may be usefully interpreted as a count over short periods of time, such as
+   * request arrival rate. May only be applied to a {@link Counter0}.
    *
    * @return this
    */
@@ -106,8 +101,8 @@
   }
 
   /**
-   * Instantaneously sampled value that may increase or decrease at a later
-   * time. Memory allocated or open network connections are examples of gauges.
+   * Instantaneously sampled value that may increase or decrease at a later time. Memory allocated
+   * or open network connections are examples of gauges.
    *
    * @return this
    */
@@ -117,9 +112,8 @@
   }
 
   /**
-   * Indicates the metric accumulates over the lifespan of the process. A
-   * {@link Counter0} like total requests handled accumulates over the process
-   * and should be {@code setCumulative()}.
+   * Indicates the metric accumulates over the lifespan of the process. A {@link Counter0} like
+   * total requests handled accumulates over the process and should be {@code setCumulative()}.
    *
    * @return this
    */
@@ -175,11 +169,12 @@
     return getTimeUnit(annotations.get(UNIT));
   }
 
-  private static final ImmutableMap<String, TimeUnit> TIME_UNITS = ImmutableMap.of(
-      Units.NANOSECONDS, TimeUnit.NANOSECONDS,
-      Units.MICROSECONDS, TimeUnit.MICROSECONDS,
-      Units.MILLISECONDS, TimeUnit.MILLISECONDS,
-      Units.SECONDS, TimeUnit.SECONDS);
+  private static final ImmutableMap<String, TimeUnit> TIME_UNITS =
+      ImmutableMap.of(
+          Units.NANOSECONDS, TimeUnit.NANOSECONDS,
+          Units.MICROSECONDS, TimeUnit.MICROSECONDS,
+          Units.MILLISECONDS, TimeUnit.MILLISECONDS,
+          Units.SECONDS, TimeUnit.SECONDS);
 
   public static TimeUnit getTimeUnit(String unit) {
     if (Strings.isNullOrEmpty(unit)) {
@@ -187,8 +182,7 @@
     }
     TimeUnit u = TIME_UNITS.get(unit);
     if (u == null) {
-      throw new IllegalArgumentException(String.format(
-          "unit %s not TimeUnit", unit));
+      throw new IllegalArgumentException(String.format("unit %s not TimeUnit", unit));
     }
     return u;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java
index 1b05e2c..ea408e2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.metrics;
 
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
@@ -24,132 +23,173 @@
   @Override
   public Counter0 newCounter(String name, Description desc) {
     return new Counter0() {
-      @Override public void incrementBy(long value) {}
-      @Override public void remove() {}
+      @Override
+      public void incrementBy(long value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1> Counter1<F1> newCounter(String name, Description desc,
-      Field<F1> field1) {
+  public <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1) {
     return new Counter1<F1>() {
-      @Override public void incrementBy(F1 field1, long value) {}
-      @Override public void remove() {}
+      @Override
+      public void incrementBy(F1 field1, long value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1, F2> Counter2<F1, F2> newCounter(String name, Description desc,
-      Field<F1> field1, Field<F2> field2) {
+  public <F1, F2> Counter2<F1, F2> newCounter(
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     return new Counter2<F1, F2>() {
-      @Override public void incrementBy(F1 field1, F2 field2, long value) {}
-      @Override public void remove() {}
+      @Override
+      public void incrementBy(F1 field1, F2 field2, long value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(String name,
-      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+  public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
     return new Counter3<F1, F2, F3>() {
-      @Override public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {}
-      @Override public void remove() {}
+      @Override
+      public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
   public Timer0 newTimer(String name, Description desc) {
     return new Timer0() {
-      @Override public void record(long value, TimeUnit unit) {}
-      @Override public void remove() {}
+      @Override
+      public void record(long value, TimeUnit unit) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1> Timer1<F1> newTimer(String name, Description desc,
-      Field<F1> field1) {
+  public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
     return new Timer1<F1>() {
-      @Override public void record(F1 field1, long value, TimeUnit unit) {}
-      @Override public void remove() {}
+      @Override
+      public void record(F1 field1, long value, TimeUnit unit) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1, F2> Timer2<F1, F2> newTimer(String name, Description desc,
-      Field<F1> field1, Field<F2> field2) {
+  public <F1, F2> Timer2<F1, F2> newTimer(
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     return new Timer2<F1, F2>() {
-      @Override public void record(F1 field1, F2 field2, long value, TimeUnit unit) {}
-      @Override public void remove() {}
+      @Override
+      public void record(F1 field1, F2 field2, long value, TimeUnit unit) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(String name,
-      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+  public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
     return new Timer3<F1, F2, F3>() {
-      @Override public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
-      @Override public void remove() {}
+      @Override
+      public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
   public Histogram0 newHistogram(String name, Description desc) {
     return new Histogram0() {
-      @Override public void record(long value) {}
-      @Override public void remove() {}
+      @Override
+      public void record(long value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1> Histogram1<F1> newHistogram(String name, Description desc,
-      Field<F1> field1) {
+  public <F1> Histogram1<F1> newHistogram(String name, Description desc, Field<F1> field1) {
     return new Histogram1<F1>() {
-      @Override public void record(F1 field1, long value) {}
-      @Override public void remove() {}
+      @Override
+      public void record(F1 field1, long value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1, F2> Histogram2<F1, F2> newHistogram(String name, Description desc,
-      Field<F1> field1, Field<F2> field2) {
+  public <F1, F2> Histogram2<F1, F2> newHistogram(
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     return new Histogram2<F1, F2>() {
-      @Override public void record(F1 field1, F2 field2, long value) {}
-      @Override public void remove() {}
+      @Override
+      public void record(F1 field1, F2 field2, long value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(String name,
-      Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+  public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
     return new Histogram3<F1, F2, F3>() {
-      @Override public void record(F1 field1, F2 field2, F3 field3, long value) {}
-      @Override public void remove() {}
+      @Override
+      public void record(F1 field1, F2 field2, F3 field3, long value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <V> CallbackMetric0<V> newCallbackMetric(String name,
-      Class<V> valueClass, Description desc) {
+  public <V> CallbackMetric0<V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc) {
     return new CallbackMetric0<V>() {
-      @Override public void set(V value) {}
-      @Override public void remove() {}
+      @Override
+      public void set(V value) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(String name,
-      Class<V> valueClass, Description desc, Field<F1> field1) {
+  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc, Field<F1> field1) {
     return new CallbackMetric1<F1, V>() {
-      @Override public void set(F1 field1, V value) {}
-      @Override public void forceCreate(F1 field1) {}
-      @Override public void remove() {}
+      @Override
+      public void set(F1 field1, V value) {}
+
+      @Override
+      public void forceCreate(F1 field1) {}
+
+      @Override
+      public void remove() {}
     };
   }
 
   @Override
-  public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
-      Runnable trigger) {
+  public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics, Runnable trigger) {
     return new RegistrationHandle() {
-      @Override public void remove() {}
+      @Override
+      public void remove() {}
     };
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
index 364f4f8..95eb9cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Field.java
@@ -53,8 +53,7 @@
    * @param name field name
    * @return enum field
    */
-  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType,
-      String name) {
+  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType, String name) {
     return ofEnum(enumType, name, null);
   }
 
@@ -66,16 +65,16 @@
    * @param description field description
    * @return enum field
    */
-  public static <E extends Enum<E>> Field<E> ofEnum(Class<E> enumType,
-      String name, String description) {
+  public static <E extends Enum<E>> Field<E> ofEnum(
+      Class<E> enumType, String name, String description) {
     return new Field<>(name, enumType, description);
   }
 
   /**
    * Break down metrics by string.
-   * <p>
-   * Each unique string will allocate a new submetric. <b>Do not use user
-   * content as a field value</b> as field values are never reclaimed.
+   *
+   * <p>Each unique string will allocate a new submetric. <b>Do not use user content as a field
+   * value</b> as field values are never reclaimed.
    *
    * @param name field name
    * @return string field
@@ -86,9 +85,9 @@
 
   /**
    * Break down metrics by string.
-   * <p>
-   * Each unique string will allocate a new submetric. <b>Do not use user
-   * content as a field value</b> as field values are never reclaimed.
+   *
+   * <p>Each unique string will allocate a new submetric. <b>Do not use user content as a field
+   * value</b> as field values are never reclaimed.
    *
    * @param name field name
    * @param description field description
@@ -100,9 +99,9 @@
 
   /**
    * Break down metrics by integer.
-   * <p>
-   * Each unique integer will allocate a new submetric. <b>Do not use user
-   * content as a field value</b> as field values are never reclaimed.
+   *
+   * <p>Each unique integer will allocate a new submetric. <b>Do not use user content as a field
+   * value</b> as field values are never reclaimed.
    *
    * @param name field name
    * @return integer field
@@ -113,9 +112,9 @@
 
   /**
    * Break down metrics by integer.
-   * <p>
-   * Each unique integer will allocate a new submetric. <b>Do not use user
-   * content as a field value</b> as field values are never reclaimed.
+   *
+   * <p>Each unique integer will allocate a new submetric. <b>Do not use user content as a field
+   * value</b> as field values are never reclaimed.
    *
    * @param name field name
    * @param description field description
@@ -160,18 +159,11 @@
   @SuppressWarnings("unchecked")
   private static <T> Function<T, String> initFormatter(Class<T> keyType) {
     if (keyType == String.class) {
-      return (Function<T, String>) Functions.<String> identity();
-
+      return (Function<T, String>) Functions.<String>identity();
     } else if (keyType == Integer.class || keyType == Boolean.class) {
       return (Function<T, String>) Functions.toStringFunction();
-
     } else if (Enum.class.isAssignableFrom(keyType)) {
-      return new Function<T, String>() {
-        @Override
-        public String apply(T in) {
-          return ((Enum<?>) in).name();
-        }
-      };
+      return in -> ((Enum<?>) in).name();
     }
     throw new IllegalStateException("unsupported type " + keyType.getName());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java
index fa614d5..5aad8fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram0.java
@@ -18,8 +18,8 @@
 
 /**
  * Measures the statistical distribution of values in a stream of data.
- * <p>
- * Suitable uses are "response size in bytes", etc.
+ *
+ * <p>Suitable uses are "response size in bytes", etc.
  */
 public abstract class Histogram0 implements RegistrationHandle {
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java
index dd1ed0a..3b9307f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram1.java
@@ -18,8 +18,8 @@
 
 /**
  * Measures the statistical distribution of values in a stream of data.
- * <p>
- * Suitable uses are "response size in bytes", etc.
+ *
+ * <p>Suitable uses are "response size in bytes", etc.
  *
  * @param <F1> type of the field.
  */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java
index b1c4482..939fe25 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram2.java
@@ -18,8 +18,8 @@
 
 /**
  * Measures the statistical distribution of values in a stream of data.
- * <p>
- * Suitable uses are "response size in bytes", etc.
+ *
+ * <p>Suitable uses are "response size in bytes", etc.
  *
  * @param <F1> type of the field.
  * @param <F2> type of the field.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java
index 0c50e118..ed709e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Histogram3.java
@@ -18,8 +18,8 @@
 
 /**
  * Measures the statistical distribution of values in a stream of data.
- * <p>
- * Suitable uses are "response size in bytes", etc.
+ *
+ * <p>Suitable uses are "response size in bytes", etc.
  *
  * @param <F1> type of the field.
  * @param <F2> type of the field.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
index 461c6b6..eee76fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Supplier;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-
 import java.util.Set;
 
 /** Factory to create metrics for monitoring. */
@@ -30,15 +29,14 @@
    * @return counter
    */
   public abstract Counter0 newCounter(String name, Description desc);
-  public abstract <F1> Counter1<F1> newCounter(
-      String name, Description desc,
-      Field<F1> field1);
+
+  public abstract <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1);
+
   public abstract <F1, F2> Counter2<F1, F2> newCounter(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2);
+      String name, Description desc, Field<F1> field1, Field<F2> field2);
+
   public abstract <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3);
 
   /**
    * Metric recording time spent on an operation.
@@ -48,15 +46,14 @@
    * @return timer
    */
   public abstract Timer0 newTimer(String name, Description desc);
-  public abstract <F1> Timer1<F1> newTimer(
-      String name, Description desc,
-      Field<F1> field1);
+
+  public abstract <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1);
+
   public abstract <F1, F2> Timer2<F1, F2> newTimer(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2);
+      String name, Description desc, Field<F1> field1, Field<F2> field2);
+
   public abstract <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3);
 
   /**
    * Metric recording statistical distribution of values.
@@ -66,15 +63,14 @@
    * @return histogram
    */
   public abstract Histogram0 newHistogram(String name, Description desc);
-  public abstract <F1> Histogram1<F1> newHistogram(
-      String name, Description desc,
-      Field<F1> field1);
+
+  public abstract <F1> Histogram1<F1> newHistogram(String name, Description desc, Field<F1> field1);
+
   public abstract <F1, F2> Histogram2<F1, F2> newHistogram(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2);
+      String name, Description desc, Field<F1> field1, Field<F2> field2);
+
   public abstract <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3);
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3);
 
   /**
    * Constant value that does not change.
@@ -89,12 +85,14 @@
     @SuppressWarnings("unchecked")
     Class<V> type = (Class<V>) value.getClass();
     final CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
-    newTrigger(metric, new Runnable() {
-      @Override
-      public void run() {
-        metric.set(value);
-      }
-    });
+    newTrigger(
+        metric,
+        new Runnable() {
+          @Override
+          public void run() {
+            metric.set(value);
+          }
+        });
   }
 
   /**
@@ -117,19 +115,21 @@
    * @param desc description of the metric.
    * @param trigger function to compute the value of the metric.
    */
-  public <V> void newCallbackMetric(String name,
-      Class<V> valueClass, Description desc, final Supplier<V> trigger) {
+  public <V> void newCallbackMetric(
+      String name, Class<V> valueClass, Description desc, final Supplier<V> trigger) {
     final CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
-    newTrigger(metric, new Runnable() {
-      @Override
-      public void run() {
-        metric.set(trigger.get());
-      }
-    });
+    newTrigger(
+        metric,
+        new Runnable() {
+          @Override
+          public void run() {
+            metric.set(trigger.get());
+          }
+        });
   }
 
   /**
-   *  Instantaneous reading of a single value.
+   * Instantaneous reading of a single value.
    *
    * @param name field name
    * @param valueClass field type
@@ -138,9 +138,9 @@
    */
   public abstract <V> CallbackMetric0<V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc);
+
   public abstract <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
-      String name, Class<V> valueClass, Description desc,
-      Field<F1> field1);
+      String name, Class<V> valueClass, Description desc, Field<F1> field1);
 
   /**
    * Connect logic to populate a previously created {@link CallbackMetric}.
@@ -153,16 +153,28 @@
     return newTrigger(ImmutableSet.<CallbackMetric<?>>of(metric1), trigger);
   }
 
-  public RegistrationHandle newTrigger(CallbackMetric<?> metric1,
-      CallbackMetric<?> metric2, Runnable trigger) {
+  public RegistrationHandle newTrigger(
+      CallbackMetric<?> metric1, CallbackMetric<?> metric2, Runnable trigger) {
     return newTrigger(ImmutableSet.of(metric1, metric2), trigger);
   }
 
-  public RegistrationHandle newTrigger(CallbackMetric<?> metric1,
-      CallbackMetric<?> metric2, CallbackMetric<?> metric3, Runnable trigger) {
+  public RegistrationHandle newTrigger(
+      CallbackMetric<?> metric1,
+      CallbackMetric<?> metric2,
+      CallbackMetric<?> metric3,
+      Runnable trigger) {
     return newTrigger(ImmutableSet.of(metric1, metric2, metric3), trigger);
   }
 
-  public abstract RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
-      Runnable trigger);
+  public abstract RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics, Runnable trigger);
+
+  /**
+   * Sanitize the given metric name.
+   *
+   * @param name the name to sanitize.
+   * @return sanitized version of the name.
+   */
+  public String sanitizeMetricName(String name) {
+    return name;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java
index d2c9a52..55d1ddf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer0.java
@@ -17,13 +17,12 @@
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-
 import java.util.concurrent.TimeUnit;
 
 /**
  * Records elapsed time for an operation or span.
- * <p>
- * Typical usage in a try-with-resources block:
+ *
+ * <p>Typical usage in a try-with-resources block:
  *
  * <pre>
  * try (Timer0.Context ctx = timer.start()) {
@@ -53,7 +52,8 @@
     return new Context(this);
   }
 
-  /** Record a value in the distribution.
+  /**
+   * Record a value in the distribution.
    *
    * @param value value to record
    * @param unit time unit of the value
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java
index be6931d..f623841 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer1.java
@@ -17,13 +17,12 @@
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-
 import java.util.concurrent.TimeUnit;
 
 /**
  * Records elapsed time for an operation or span.
- * <p>
- * Typical usage in a try-with-resources block:
+ *
+ * <p>Typical usage in a try-with-resources block:
  *
  * <pre>
  * try (Timer1.Context ctx = timer.start(field)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java
index 0ace4c3..b03ff83 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer2.java
@@ -17,13 +17,12 @@
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-
 import java.util.concurrent.TimeUnit;
 
 /**
  * Records elapsed time for an operation or span.
- * <p>
- * Typical usage in a try-with-resources block:
+ *
+ * <p>Typical usage in a try-with-resources block:
  *
  * <pre>
  * try (Timer2.Context ctx = timer.start(field)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java
index 09e899d..91af42c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/Timer3.java
@@ -17,13 +17,12 @@
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.gerrit.extensions.registration.RegistrationHandle;
-
 import java.util.concurrent.TimeUnit;
 
 /**
  * Records elapsed time for an operation or span.
- * <p>
- * Typical usage in a try-with-resources block:
+ *
+ * <p>Typical usage in a try-with-resources block:
  *
  * <pre>
  * try (Timer3.Context ctx = timer.start(field)) {
@@ -76,6 +75,5 @@
    * @param value value to record
    * @param unit time unit of the value
    */
-  public abstract void record(F1 field1, F2 field2, F3 field3,
-      long value, TimeUnit unit);
+  public abstract void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
index e7ab75c..c7a92a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
@@ -14,16 +14,13 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
-
-import com.codahale.metrics.Gauge;
-import com.codahale.metrics.Metric;
-import com.codahale.metrics.MetricRegistry;
-
 import java.util.Iterator;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
@@ -40,8 +37,13 @@
   protected volatile Runnable trigger;
   private final Object lock = new Object();
 
-  BucketedCallback(DropWizardMetricMaker metrics, MetricRegistry registry,
-      String name, Class<V> valueType, Description desc, Field<?>... fields) {
+  BucketedCallback(
+      DropWizardMetricMaker metrics,
+      MetricRegistry registry,
+      String name,
+      Class<V> valueType,
+      Description desc,
+      Field<?>... fields) {
     this.metrics = metrics;
     this.registry = registry;
     this.name = name;
@@ -124,14 +126,7 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(
-        cells,
-        new Function<ValueGauge, Metric> () {
-          @Override
-          public Metric apply(ValueGauge in) {
-            return in;
-          }
-        });
+    return Maps.transformValues(cells, in -> (Metric) in);
   }
 
   final class ValueGauge implements Gauge<V> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
index 10b92e6..7706297 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedCounter.java
@@ -14,15 +14,12 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
+import com.codahale.metrics.Metric;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.CounterImpl;
-
-import com.codahale.metrics.Metric;
-
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -37,8 +34,8 @@
   private final Map<Object, CounterImpl> cells;
   private final Object lock = new Object();
 
-  BucketedCounter(DropWizardMetricMaker metrics,
-      String name, Description desc, Field<?>... fields) {
+  BucketedCounter(
+      DropWizardMetricMaker metrics, String name, Description desc, Field<?>... fields) {
     this.metrics = metrics;
     this.name = name;
     this.isRate = desc.isRate();
@@ -98,13 +95,6 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(
-        cells,
-        new Function<CounterImpl, Metric> () {
-          @Override
-          public Metric apply(CounterImpl in) {
-            return in.metric;
-          }
-        });
+    return Maps.transformValues(cells, c -> c.metric);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
index 071c678..35eb180 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedHistogram.java
@@ -14,15 +14,12 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
+import com.codahale.metrics.Metric;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.HistogramImpl;
-
-import com.codahale.metrics.Metric;
-
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -36,8 +33,8 @@
   private final Map<Object, HistogramImpl> cells;
   private final Object lock = new Object();
 
-  BucketedHistogram(DropWizardMetricMaker metrics, String name,
-      Description desc, Field<?>... fields) {
+  BucketedHistogram(
+      DropWizardMetricMaker metrics, String name, Description desc, Field<?>... fields) {
     this.metrics = metrics;
     this.name = name;
     this.ordering = desc.getFieldOrdering();
@@ -96,13 +93,6 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(
-        cells,
-        new Function<HistogramImpl, Metric> () {
-          @Override
-          public Metric apply(HistogramImpl in) {
-            return in.metric;
-          }
-        });
+    return Maps.transformValues(cells, h -> h.metric);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
index 799e594..9b0b37f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedMetric.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
+import com.codahale.metrics.Metric;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Field;
-
-import com.codahale.metrics.Metric;
-
 import java.util.Map;
 
 /** Metric broken down into buckets by {@link Field} values. */
 interface BucketedMetric extends Metric {
-  @Nullable Metric getTotal();
+  @Nullable
+  Metric getTotal();
+
   Field<?>[] getFields();
+
   Map<?, Metric> getCells();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
index 6981ef1..3b19a62 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/BucketedTimer.java
@@ -14,15 +14,12 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
+import com.codahale.metrics.Metric;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker.TimerImpl;
-
-import com.codahale.metrics.Metric;
-
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -36,8 +33,7 @@
   private final Map<Object, TimerImpl> cells;
   private final Object lock = new Object();
 
-  BucketedTimer(DropWizardMetricMaker metrics, String name,
-      Description desc, Field<?>... fields) {
+  BucketedTimer(DropWizardMetricMaker metrics, String name, Description desc, Field<?>... fields) {
     this.metrics = metrics;
     this.name = name;
     this.ordering = desc.getFieldOrdering();
@@ -96,13 +92,6 @@
 
   @Override
   public Map<Object, Metric> getCells() {
-    return Maps.transformValues(
-        cells,
-        new Function<TimerImpl, Metric> () {
-          @Override
-          public Metric apply(TimerImpl in) {
-            return in.metric;
-          }
-        });
+    return Maps.transformValues(cells, t -> t.metric);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
index 372bdcb..f153e7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackGroup.java
@@ -15,17 +15,15 @@
 package com.google.gerrit.metrics.dropwizard;
 
 import com.google.common.collect.ImmutableSet;
-
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * Run a user specified trigger only once every 2 seconds.
- * <p>
- * This allows the same Runnable trigger to be applied to several metrics. When
- * a recorder is sampling the related metrics only the first access will perform
- * recomputation. Reading other related metrics will rely on the already set
- * values for the next several seconds.
+ *
+ * <p>This allows the same Runnable trigger to be applied to several metrics. When a recorder is
+ * sampling the related metrics only the first access will perform recomputation. Reading other
+ * related metrics will rely on the already set values for the next several seconds.
  */
 class CallbackGroup implements Runnable {
   private static final long PERIOD = TimeUnit.SECONDS.toNanos(2);
@@ -57,7 +55,7 @@
   }
 
   private boolean reload() {
-    for (;;) {
+    for (; ; ) {
       long now = System.nanoTime();
       long next = reloadAt.get();
       if (next > now) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
index 4f5b7ad..9fd4fc9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricGlue.java
@@ -16,7 +16,10 @@
 
 interface CallbackMetricGlue {
   void beginSet();
+
   void endSet();
+
   void register(Runnable trigger);
+
   void remove();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
index dcab692..6910d22 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
+import com.codahale.metrics.MetricRegistry;
 import com.google.gerrit.metrics.CallbackMetric0;
 
-import com.codahale.metrics.MetricRegistry;
-
-class CallbackMetricImpl0<V>
-    extends CallbackMetric0<V>
-    implements CallbackMetricGlue {
+class CallbackMetricImpl0<V> extends CallbackMetric0<V> implements CallbackMetricGlue {
   @SuppressWarnings("unchecked")
   static <V> V zeroFor(Class<V> valueClass) {
     if (valueClass == Integer.class) {
@@ -36,8 +33,7 @@
     } else if (valueClass == Boolean.class) {
       return (V) Boolean.FALSE;
     } else {
-      throw new IllegalArgumentException("unsupported value type "
-          + valueClass.getName());
+      throw new IllegalArgumentException("unsupported value type " + valueClass.getName());
     }
   }
 
@@ -46,8 +42,8 @@
   private final String name;
   private volatile V value;
 
-  CallbackMetricImpl0(DropWizardMetricMaker metrics, MetricRegistry registry,
-      String name, Class<V> valueType) {
+  CallbackMetricImpl0(
+      DropWizardMetricMaker metrics, MetricRegistry registry, String name, Class<V> valueType) {
     this.metrics = metrics;
     this.registry = registry;
     this.name = name;
@@ -55,12 +51,10 @@
   }
 
   @Override
-  public void beginSet() {
-  }
+  public void beginSet() {}
 
   @Override
-  public void endSet() {
-  }
+  public void endSet() {}
 
   @Override
   public void set(V value) {
@@ -75,12 +69,14 @@
 
   @Override
   public void register(final Runnable trigger) {
-    registry.register(name, new com.codahale.metrics.Gauge<V>() {
-      @Override
-      public V getValue() {
-        trigger.run();
-        return value;
-      }
-    });
+    registry.register(
+        name,
+        new com.codahale.metrics.Gauge<V>() {
+          @Override
+          public V getValue() {
+            trigger.run();
+            return value;
+          }
+        });
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
index 81d5ff5..6d1daf4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
@@ -14,17 +14,21 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
+import com.codahale.metrics.MetricRegistry;
 import com.google.common.base.Function;
 import com.google.gerrit.metrics.CallbackMetric1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 
-import com.codahale.metrics.MetricRegistry;
-
 /** Optimized version of {@link BucketedCallback} for single dimension. */
 class CallbackMetricImpl1<F1, V> extends BucketedCallback<V> {
-  CallbackMetricImpl1(DropWizardMetricMaker metrics, MetricRegistry registry,
-      String name, Class<V> valueClass, Description desc, Field<F1> field1) {
+  CallbackMetricImpl1(
+      DropWizardMetricMaker metrics,
+      MetricRegistry registry,
+      String name,
+      Class<V> valueClass,
+      Description desc,
+      Field<F1> field1) {
     super(metrics, registry, name, valueClass, desc, field1);
   }
 
@@ -32,9 +36,7 @@
     return new Impl1();
   }
 
-  private final class Impl1
-      extends CallbackMetric1<F1, V>
-      implements CallbackMetricGlue {
+  private final class Impl1 extends CallbackMetric1<F1, V> implements CallbackMetricGlue {
     @Override
     public void beginSet() {
       doBeginSet();
@@ -76,8 +78,7 @@
   @Override
   String name(Object field1) {
     @SuppressWarnings("unchecked")
-    Function<Object, String> fmt =
-        (Function<Object, String>) fields[0].formatter();
+    Function<Object, String> fmt = (Function<Object, String>) fields[0].formatter();
 
     return fmt.apply(field1).replace('/', '-');
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
index 25647ef..46434ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -21,8 +21,7 @@
 
 /** Optimized version of {@link BucketedCounter} for single dimension. */
 class CounterImpl1<F1> extends BucketedCounter {
-  CounterImpl1(DropWizardMetricMaker metrics, String name, Description desc,
-      Field<F1> field1) {
+  CounterImpl1(DropWizardMetricMaker metrics, String name, Description desc, Field<F1> field1) {
     super(metrics, name, desc, field1);
   }
 
@@ -44,8 +43,7 @@
   @Override
   String name(Object field1) {
     @SuppressWarnings("unchecked")
-    Function<Object, String> fmt =
-        (Function<Object, String>) fields[0].formatter();
+    Function<Object, String> fmt = (Function<Object, String>) fields[0].formatter();
 
     return fmt.apply(field1).replace('/', '-');
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
index a2f1f84..38c31a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -24,8 +24,7 @@
 
 /** Generalized implementation of N-dimensional counter metrics. */
 class CounterImplN extends BucketedCounter implements BucketedMetric {
-  CounterImplN(DropWizardMetricMaker metrics, String name, Description desc,
-      Field<?>... fields) {
+  CounterImplN(DropWizardMetricMaker metrics, String name, Description desc, Field<?>... fields) {
     super(metrics, name, desc, fields);
   }
 
@@ -65,8 +64,7 @@
     ImmutableList<Object> keyList = (ImmutableList<Object>) key;
     String[] parts = new String[fields.length];
     for (int i = 0; i < fields.length; i++) {
-      Function<Object, String> fmt =
-          (Function<Object, String>) fields[i].formatter();
+      Function<Object, String> fmt = (Function<Object, String>) fields[i].formatter();
 
       parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index 6359bb5..fc53ee7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -18,7 +18,8 @@
 import static com.google.gerrit.metrics.dropwizard.MetricResource.METRIC_KIND;
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
 
-import com.google.common.base.Function;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -50,10 +51,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Scopes;
 import com.google.inject.Singleton;
-
-import com.codahale.metrics.Metric;
-import com.codahale.metrics.MetricRegistry;
-
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
@@ -124,8 +121,7 @@
 
   @Override
   public synchronized <F1> Counter1<F1> newCounter(
-      String name, Description desc,
-      Field<F1> field1) {
+      String name, Description desc, Field<F1> field1) {
     checkCounterDescription(name, desc);
     CounterImpl1<F1> m = new CounterImpl1<>(this, name, desc, field1);
     define(name, desc);
@@ -135,8 +131,7 @@
 
   @Override
   public synchronized <F1, F2> Counter2<F1, F2> newCounter(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2) {
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     checkCounterDescription(name, desc);
     CounterImplN m = new CounterImplN(this, name, desc, field1, field2);
     define(name, desc);
@@ -146,8 +141,7 @@
 
   @Override
   public synchronized <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
     checkCounterDescription(name, desc);
     CounterImplN m = new CounterImplN(this, name, desc, field1, field2, field3);
     define(name, desc);
@@ -157,8 +151,8 @@
 
   private static void checkCounterDescription(String name, Description desc) {
     checkMetricName(name);
-    checkArgument(!desc.isConstant(), "counters must not be constant");
-    checkArgument(!desc.isGauge(), "counters must not be gauge");
+    checkArgument(!desc.isConstant(), "counter must not be constant");
+    checkArgument(!desc.isGauge(), "counter must not be gauge");
   }
 
   CounterImpl newCounterImpl(String name, boolean isRate) {
@@ -199,8 +193,8 @@
   }
 
   @Override
-  public synchronized <F1, F2> Timer2<F1, F2> newTimer(String name, Description desc,
-      Field<F1> field1, Field<F2> field2) {
+  public synchronized <F1, F2> Timer2<F1, F2> newTimer(
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     checkTimerDescription(name, desc);
     TimerImplN m = new TimerImplN(this, name, desc, field1, field2);
     define(name, desc);
@@ -210,8 +204,7 @@
 
   @Override
   public synchronized <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
     checkTimerDescription(name, desc);
     TimerImplN m = new TimerImplN(this, name, desc, field1, field2, field3);
     define(name, desc);
@@ -240,8 +233,8 @@
   }
 
   @Override
-  public synchronized <F1> Histogram1<F1> newHistogram(String name,
-      Description desc, Field<F1> field1) {
+  public synchronized <F1> Histogram1<F1> newHistogram(
+      String name, Description desc, Field<F1> field1) {
     checkHistogramDescription(name, desc);
     HistogramImpl1<F1> m = new HistogramImpl1<>(this, name, desc, field1);
     define(name, desc);
@@ -250,8 +243,8 @@
   }
 
   @Override
-  public synchronized <F1, F2> Histogram2<F1, F2> newHistogram(String name,
-      Description desc, Field<F1> field1, Field<F2> field2) {
+  public synchronized <F1, F2> Histogram2<F1, F2> newHistogram(
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     checkHistogramDescription(name, desc);
     HistogramImplN m = new HistogramImplN(this, name, desc, field1, field2);
     define(name, desc);
@@ -261,8 +254,7 @@
 
   @Override
   public synchronized <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
     checkHistogramDescription(name, desc);
     HistogramImplN m = new HistogramImplN(this, name, desc, field1, field2, field3);
     define(name, desc);
@@ -294,8 +286,8 @@
   public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc, Field<F1> field1) {
     checkMetricName(name);
-    CallbackMetricImpl1<F1, V> m = new CallbackMetricImpl1<>(this, registry,
-        name, valueClass, desc, field1);
+    CallbackMetricImpl1<F1, V> m =
+        new CallbackMetricImpl1<>(this, registry, name, valueClass, desc, field1);
     define(name, desc);
     bucketed.put(name, m);
     return m.create();
@@ -304,15 +296,8 @@
   @Override
   public synchronized RegistrationHandle newTrigger(
       Set<CallbackMetric<?>> metrics, Runnable trigger) {
-    final ImmutableSet<CallbackMetricGlue> all = FluentIterable.from(metrics)
-        .transform(
-          new Function<CallbackMetric<?>, CallbackMetricGlue>() {
-            @Override
-            public CallbackMetricGlue apply(CallbackMetric<?> input) {
-              return (CallbackMetricGlue) input;
-            }
-          })
-        .toSet();
+    ImmutableSet<CallbackMetricGlue> all =
+        FluentIterable.from(metrics).transform(m -> (CallbackMetricGlue) m).toSet();
 
     trigger = new CallbackGroup(trigger, all);
     for (CallbackMetricGlue m : all) {
@@ -338,28 +323,48 @@
   private synchronized void define(String name, Description desc) {
     if (descriptions.containsKey(name)) {
       ImmutableMap<String, String> annotations = descriptions.get(name);
-      if (!desc.getAnnotations().get(Description.DESCRIPTION).equals(
-          annotations.get(Description.DESCRIPTION))) {
-        throw new IllegalStateException(String.format(
-            "metric %s already defined", name));
+      if (!desc.getAnnotations()
+          .get(Description.DESCRIPTION)
+          .equals(annotations.get(Description.DESCRIPTION))) {
+        throw new IllegalStateException(String.format("metric '%s' already defined", name));
       }
     } else {
       descriptions.put(name, desc.getAnnotations());
     }
   }
 
-  private static final Pattern METRIC_NAME_PATTERN = Pattern
-      .compile("[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*");
+  private static final Pattern METRIC_NAME_PATTERN =
+      Pattern.compile("[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*");
 
   private static void checkMetricName(String name) {
     checkArgument(
         METRIC_NAME_PATTERN.matcher(name).matches(),
-        "metric name must match %s", METRIC_NAME_PATTERN.pattern());
+        "invalid metric name '%s': must match pattern '%s'",
+        name,
+        METRIC_NAME_PATTERN.pattern());
   }
 
-  static String name(Description.FieldOrdering ordering,
-      String codeName,
-      String fieldValues) {
+  @Override
+  public String sanitizeMetricName(String name) {
+    if (METRIC_NAME_PATTERN.matcher(name).matches()) {
+      return name;
+    }
+
+    String first = name.substring(0, 1).replaceFirst("[^\\w-]", "_");
+    if (name.length() == 1) {
+      return first;
+    }
+
+    String result = first + name.substring(1).replaceAll("/[/]+", "/").replaceAll("[^\\w-/]", "_");
+
+    if (result.endsWith("/")) {
+      result = result.substring(0, result.length() - 1);
+    }
+
+    return result;
+  }
+
+  static String name(Description.FieldOrdering ordering, String codeName, String fieldValues) {
     if (ordering == FieldOrdering.PREFIX_FIELDS_BASENAME) {
       int s = codeName.lastIndexOf('/');
       if (s > 0) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
index 47064df..52e35c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
-
 import org.kohsuke.args4j.Option;
 
 class GetMetric implements RestReadView<MetricResource> {
@@ -40,8 +39,6 @@
       throw new AuthException("restricted to viewCaches");
     }
     return new MetricJson(
-        resource.getMetric(),
-        metrics.getAnnotations(resource.getName()),
-        dataOnly);
+        resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
index e3f9e1c..3eb12fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -21,8 +21,7 @@
 
 /** Optimized version of {@link BucketedHistogram} for single dimension. */
 class HistogramImpl1<F1> extends BucketedHistogram implements BucketedMetric {
-  HistogramImpl1(DropWizardMetricMaker metrics, String name,
-      Description desc, Field<F1> field1) {
+  HistogramImpl1(DropWizardMetricMaker metrics, String name, Description desc, Field<F1> field1) {
     super(metrics, name, desc, field1);
   }
 
@@ -44,8 +43,7 @@
   @Override
   String name(Object field1) {
     @SuppressWarnings("unchecked")
-    Function<Object, String> fmt =
-        (Function<Object, String>) fields[0].formatter();
+    Function<Object, String> fmt = (Function<Object, String>) fields[0].formatter();
 
     return fmt.apply(field1).replace('/', '-');
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
index d832c60..3561c55a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -24,8 +24,7 @@
 
 /** Generalized implementation of N-dimensional Histogram metrics. */
 class HistogramImplN extends BucketedHistogram implements BucketedMetric {
-  HistogramImplN(DropWizardMetricMaker metrics, String name,
-      Description desc, Field<?>... fields) {
+  HistogramImplN(DropWizardMetricMaker metrics, String name, Description desc, Field<?>... fields) {
     super(metrics, name, desc, fields);
   }
 
@@ -65,8 +64,7 @@
     ImmutableList<Object> keyList = (ImmutableList<Object>) key;
     String[] parts = new String[fields.length];
     for (int i = 0; i < fields.length; i++) {
-      Function<Object, String> fmt =
-          (Function<Object, String>) fields[i].formatter();
+      Function<Object, String> fmt = (Function<Object, String>) fields[i].formatter();
 
       parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 04d10a2..8ef1614 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -14,21 +14,18 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
+import com.codahale.metrics.Metric;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
-
-import com.codahale.metrics.Metric;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import org.kohsuke.args4j.Option;
 
 class ListMetrics implements RestReadView<ConfigResource> {
   private final CurrentUser user;
@@ -37,7 +34,10 @@
   @Option(name = "--data-only", usage = "return only values")
   boolean dataOnly;
 
-  @Option(name = "--prefix", aliases = {"-p"}, metaVar = "PREFIX",
+  @Option(
+      name = "--prefix",
+      aliases = {"-p"},
+      metaVar = "PREFIX",
       usage = "match metric by exact match or prefix")
   List<String> query = new ArrayList<>();
 
@@ -48,8 +48,7 @@
   }
 
   @Override
-  public Map<String, MetricJson> apply(ConfigResource resource)
-      throws AuthException {
+  public Map<String, MetricJson> apply(ConfigResource resource) throws AuthException {
     if (!user.getCapabilities().canViewCaches()) {
       throw new AuthException("restricted to viewCaches");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
index b332262..2080623 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -14,12 +14,6 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
-import com.google.common.base.Function;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-
 import com.codahale.metrics.Counter;
 import com.codahale.metrics.Gauge;
 import com.codahale.metrics.Histogram;
@@ -27,7 +21,11 @@
 import com.codahale.metrics.Metric;
 import com.codahale.metrics.Snapshot;
 import com.codahale.metrics.Timer;
-
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -114,8 +112,7 @@
       rate_5m = m.getFiveMinuteRate();
       rate_15m = m.getFifteenMinuteRate();
 
-      double div =
-          Description.getTimeUnit(atts.get(Description.UNIT)).toNanos(1);
+      double div = Description.getTimeUnit(atts.get(Description.UNIT)).toNanos(1);
       p50 = s.getMedian() / div;
       p75 = s.get75thPercentile() / div;
       p95 = s.get95thPercentile() / div;
@@ -153,17 +150,12 @@
 
   @SuppressWarnings("unchecked")
   private static Map<String, Object> makeBuckets(
-      Field<?>[] fields,
-      Map<?, Metric> metrics,
-      ImmutableMap<String, String> atts) {
+      Field<?>[] fields, Map<?, Metric> metrics, ImmutableMap<String, String> atts) {
     if (fields.length == 1) {
-      Function<Object, String> fmt =
-          (Function<Object, String>) fields[0].formatter();
+      Function<Object, String> fmt = (Function<Object, String>) fields[0].formatter();
       Map<String, Object> out = new TreeMap<>();
       for (Map.Entry<?, Metric> e : metrics.entrySet()) {
-        out.put(
-            fmt.apply(e.getKey()),
-            new MetricJson(e.getValue(), atts, true));
+        out.put(fmt.apply(e.getKey()), new MetricJson(e.getValue(), atts, true));
       }
       return out;
     }
@@ -174,8 +166,7 @@
       Map<String, Object> dst = out;
 
       for (int i = 0; i < fields.length - 1; i++) {
-        Function<Object, String> fmt =
-            (Function<Object, String>) fields[i].formatter();
+        Function<Object, String> fmt = (Function<Object, String>) fields[i].formatter();
         String key = fmt.apply(keys.get(i));
         Map<String, Object> t = (Map<String, Object>) dst.get(key);
         if (t == null) {
@@ -187,9 +178,7 @@
 
       Function<Object, String> fmt =
           (Function<Object, String>) fields[fields.length - 1].formatter();
-      dst.put(
-          fmt.apply(keys.get(fields.length - 1)),
-          new MetricJson(e.getValue(), atts, true));
+      dst.put(fmt.apply(keys.get(fields.length - 1)), new MetricJson(e.getValue(), atts, true));
     }
     return out;
   }
@@ -202,9 +191,8 @@
     FieldJson(Field<?> field) {
       this.name = field.getName();
       this.description = field.getDescription();
-      this.type = Enum.class.isAssignableFrom(field.getType())
-          ? field.getType().getSimpleName()
-          : null;
+      this.type =
+          Enum.class.isAssignableFrom(field.getType()) ? field.getType().getSimpleName() : null;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
index d073f37..226edc7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
+import com.codahale.metrics.Metric;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.TypeLiteral;
 
-import com.codahale.metrics.Metric;
-
 class MetricResource extends ConfigResource {
   static final TypeLiteral<RestView<MetricResource>> METRIC_KIND =
       new TypeLiteral<RestView<MetricResource>>() {};
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
index 81945f1..2686f1f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.metrics.dropwizard;
 
+import com.codahale.metrics.Metric;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -26,19 +27,18 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import com.codahale.metrics.Metric;
-
 @Singleton
-class MetricsCollection implements
-    ChildCollection<ConfigResource, MetricResource> {
+class MetricsCollection implements ChildCollection<ConfigResource, MetricResource> {
   private final DynamicMap<RestView<MetricResource>> views;
   private final Provider<ListMetrics> list;
   private final Provider<CurrentUser> user;
   private final DropWizardMetricMaker metrics;
 
   @Inject
-  MetricsCollection(DynamicMap<RestView<MetricResource>> views,
-      Provider<ListMetrics> list, Provider<CurrentUser> user,
+  MetricsCollection(
+      DynamicMap<RestView<MetricResource>> views,
+      Provider<ListMetrics> list,
+      Provider<CurrentUser> user,
       DropWizardMetricMaker metrics) {
     this.views = views;
     this.list = list;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index 0164f6f..fe6f70e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -18,13 +18,11 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Timer1;
-
 import java.util.concurrent.TimeUnit;
 
 /** Optimized version of {@link BucketedTimer} for single dimension. */
 class TimerImpl1<F1> extends BucketedTimer implements BucketedMetric {
-  TimerImpl1(DropWizardMetricMaker metrics, String name,
-      Description desc, Field<F1> field1) {
+  TimerImpl1(DropWizardMetricMaker metrics, String name, Description desc, Field<F1> field1) {
     super(metrics, name, desc, field1);
   }
 
@@ -46,8 +44,7 @@
   @Override
   String name(Object field1) {
     @SuppressWarnings("unchecked")
-    Function<Object, String> fmt =
-        (Function<Object, String>) fields[0].formatter();
+    Function<Object, String> fmt = (Function<Object, String>) fields[0].formatter();
 
     return fmt.apply(field1).replace('/', '-');
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
index 49c9f14..43cc290 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -21,13 +21,11 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.Timer2;
 import com.google.gerrit.metrics.Timer3;
-
 import java.util.concurrent.TimeUnit;
 
 /** Generalized implementation of N-dimensional timer metrics. */
 class TimerImplN extends BucketedTimer implements BucketedMetric {
-  TimerImplN(DropWizardMetricMaker metrics, String name,
-      Description desc, Field<?>... fields) {
+  TimerImplN(DropWizardMetricMaker metrics, String name, Description desc, Field<?>... fields) {
     super(metrics, name, desc, fields);
   }
 
@@ -49,8 +47,7 @@
   <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
     return new Timer3<F1, F2, F3>() {
       @Override
-      public void record(F1 field1, F2 field2, F3 field3,
-          long value, TimeUnit unit) {
+      public void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
         total.record(value, unit);
         forceCreate(field1, field2, field3).record(value, unit);
       }
@@ -68,8 +65,7 @@
     ImmutableList<Object> keyList = (ImmutableList<Object>) key;
     String[] parts = new String[fields.length];
     for (int i = 0; i < fields.length; i++) {
-      Function<Object, String> fmt =
-          (Function<Object, String>) fields[i].formatter();
+      Function<Object, String> fmt = (Function<Object, String>) fields[i].formatter();
 
       parts[i] = fmt.apply(keyList.get(i)).replace('/', '-');
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
index b5a2fcc8..c3eb39f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
@@ -18,31 +18,28 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
-
 import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
 
 public class JGitMetricModule extends MetricModule {
   @Override
   protected void configure(MetricMaker metrics) {
     metrics.newCallbackMetric(
-      "jgit/block_cache/cache_used",
-      Long.class,
-      new Description("Bytes of memory retained in JGit block cache.")
-        .setGauge()
-        .setUnit(Units.BYTES),
-      new Supplier<Long>() {
-        @Override
-        public Long get() {
-          return WindowCacheStatAccessor.getOpenBytes();
-        }
-      });
+        "jgit/block_cache/cache_used",
+        Long.class,
+        new Description("Bytes of memory retained in JGit block cache.")
+            .setGauge()
+            .setUnit(Units.BYTES),
+        new Supplier<Long>() {
+          @Override
+          public Long get() {
+            return WindowCacheStatAccessor.getOpenBytes();
+          }
+        });
 
     metrics.newCallbackMetric(
         "jgit/block_cache/open_files",
         Integer.class,
-        new Description("File handles held open by JGit block cache.")
-          .setGauge()
-          .setUnit("fds"),
+        new Description("File handles held open by JGit block cache.").setGauge().setUnit("fds"),
         new Supplier<Integer>() {
           @Override
           public Integer get() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java
index c556ee4..200a29d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/MetricModule.java
@@ -26,18 +26,18 @@
 
   @Override
   protected void configure() {
-    listener().toInstance(new LifecycleListener() {
-      @Inject
-      MetricMaker metrics;
+    listener()
+        .toInstance(
+            new LifecycleListener() {
+              @Inject MetricMaker metrics;
 
-      @Override
-      public void start() {
-        configure(metrics);
-      }
+              @Override
+              public void start() {
+                configure(metrics);
+              }
 
-      @Override
-      public void stop() {
-      }
-    });
+              @Override
+              public void stop() {}
+            });
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
index 4eccf53..bc2846a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
@@ -18,13 +18,11 @@
 import java.lang.management.OperatingSystemMXBean;
 import java.lang.reflect.Method;
 import java.util.Arrays;
-
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 class OperatingSystemMXBeanProvider {
-  private static final Logger log =
-      LoggerFactory.getLogger(OperatingSystemMXBeanProvider.class);
+  private static final Logger log = LoggerFactory.getLogger(OperatingSystemMXBeanProvider.class);
 
   private final OperatingSystemMXBean sys;
   private final Method getProcessCpuTime;
@@ -43,8 +41,7 @@
             return new OperatingSystemMXBeanProvider(sys);
           }
         } catch (ReflectiveOperationException e) {
-          log.debug(String.format(
-              "No implementation for %s: %s", name, e.getMessage()));
+          log.debug("No implementation for {}", name, e);
         }
       }
       log.warn("No implementation of UnixOperatingSystemMXBean found");
@@ -55,11 +52,10 @@
   private OperatingSystemMXBeanProvider(OperatingSystemMXBean sys)
       throws ReflectiveOperationException {
     this.sys = sys;
-    getProcessCpuTime =
-        sys.getClass().getMethod("getProcessCpuTime", new Class[] {});
+    getProcessCpuTime = sys.getClass().getMethod("getProcessCpuTime", new Class<?>[] {});
     getProcessCpuTime.setAccessible(true);
     getOpenFileDescriptorCount =
-        sys.getClass().getMethod("getOpenFileDescriptorCount", new Class[] {});
+        sys.getClass().getMethod("getOpenFileDescriptorCount", new Class<?>[] {});
     getOpenFileDescriptorCount.setAccessible(true);
   }
 
@@ -78,4 +74,4 @@
       return -1;
     }
   }
-}
\ No newline at end of file
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index e05afd1..11f8e50 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -53,16 +53,13 @@
   private void procUptime(MetricMaker metrics) {
     metrics.newConstantMetric(
         "proc/birth_timestamp",
-        Long.valueOf(TimeUnit.MILLISECONDS.toMicros(
-            System.currentTimeMillis())),
-        new Description("Time at which the process started")
-          .setUnit(Units.MICROSECONDS));
+        Long.valueOf(TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis())),
+        new Description("Time at which the process started").setUnit(Units.MICROSECONDS));
 
     metrics.newCallbackMetric(
         "proc/uptime",
         Long.class,
-        new Description("Uptime of this process")
-          .setUnit(Units.MILLISECONDS),
+        new Description("Uptime of this process").setUnit(Units.MILLISECONDS),
         new Supplier<Long>() {
           @Override
           public Long get() {
@@ -72,8 +69,7 @@
   }
 
   private void procCpuUsage(MetricMaker metrics) {
-    final OperatingSystemMXBeanProvider provider =
-        OperatingSystemMXBeanProvider.Factory.create();
+    final OperatingSystemMXBeanProvider provider = OperatingSystemMXBeanProvider.Factory.create();
 
     if (provider == null) {
       return;
@@ -83,9 +79,7 @@
       metrics.newCallbackMetric(
           "proc/cpu/usage",
           Double.class,
-          new Description("CPU time used by the process")
-            .setCumulative()
-            .setUnit(Units.SECONDS),
+          new Description("CPU time used by the process").setCumulative().setUnit(Units.SECONDS),
           new Supplier<Double>() {
             @Override
             public Double get() {
@@ -98,9 +92,7 @@
       metrics.newCallbackMetric(
           "proc/num_open_fds",
           Long.class,
-          new Description("Number of open file descriptors")
-            .setGauge()
-            .setUnit("fds"),
+          new Description("Number of open file descriptors").setGauge().setUnit("fds"),
           new Supplier<Long>() {
             @Override
             public Long get() {
@@ -111,99 +103,105 @@
   }
 
   private void procJvmMemory(MetricMaker metrics) {
-    final CallbackMetric0<Long> heapCommitted = metrics.newCallbackMetric(
-        "proc/jvm/memory/heap_committed",
-        Long.class,
-        new Description("Amount of memory guaranteed for user objects.")
-          .setGauge()
-          .setUnit(Units.BYTES));
+    final CallbackMetric0<Long> heapCommitted =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/heap_committed",
+            Long.class,
+            new Description("Amount of memory guaranteed for user objects.")
+                .setGauge()
+                .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> heapUsed = metrics.newCallbackMetric(
-        "proc/jvm/memory/heap_used",
-        Long.class,
-        new Description("Amount of memory holding user objects.")
-          .setGauge()
-          .setUnit(Units.BYTES));
+    final CallbackMetric0<Long> heapUsed =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/heap_used",
+            Long.class,
+            new Description("Amount of memory holding user objects.")
+                .setGauge()
+                .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> nonHeapCommitted = metrics.newCallbackMetric(
-        "proc/jvm/memory/non_heap_committed",
-        Long.class,
-        new Description("Amount of memory guaranteed for classes, etc.")
-          .setGauge()
-          .setUnit(Units.BYTES));
+    final CallbackMetric0<Long> nonHeapCommitted =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/non_heap_committed",
+            Long.class,
+            new Description("Amount of memory guaranteed for classes, etc.")
+                .setGauge()
+                .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> nonHeapUsed = metrics.newCallbackMetric(
-        "proc/jvm/memory/non_heap_used",
-        Long.class,
-        new Description("Amount of memory holding classes, etc.")
-          .setGauge()
-          .setUnit(Units.BYTES));
+    final CallbackMetric0<Long> nonHeapUsed =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/non_heap_used",
+            Long.class,
+            new Description("Amount of memory holding classes, etc.")
+                .setGauge()
+                .setUnit(Units.BYTES));
 
     final CallbackMetric0<Integer> objectPendingFinalizationCount =
         metrics.newCallbackMetric(
-        "proc/jvm/memory/object_pending_finalization_count",
-        Integer.class,
-        new Description("Approximate number of objects needing finalization.")
-          .setGauge()
-          .setUnit("objects"));
+            "proc/jvm/memory/object_pending_finalization_count",
+            Integer.class,
+            new Description("Approximate number of objects needing finalization.")
+                .setGauge()
+                .setUnit("objects"));
 
     final MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
     metrics.newTrigger(
-      ImmutableSet.<CallbackMetric<?>> of(
-          heapCommitted, heapUsed, nonHeapCommitted,
-          nonHeapUsed, objectPendingFinalizationCount),
-      new Runnable() {
-        @Override
-        public void run() {
-          try {
-            MemoryUsage stats = memory.getHeapMemoryUsage();
-            heapCommitted.set(stats.getCommitted());
-            heapUsed.set(stats.getUsed());
-          } catch (IllegalArgumentException e) {
-            // MXBean may throw due to a bug in Java 7; ignore.
+        ImmutableSet.<CallbackMetric<?>>of(
+            heapCommitted, heapUsed, nonHeapCommitted, nonHeapUsed, objectPendingFinalizationCount),
+        new Runnable() {
+          @Override
+          public void run() {
+            try {
+              MemoryUsage stats = memory.getHeapMemoryUsage();
+              heapCommitted.set(stats.getCommitted());
+              heapUsed.set(stats.getUsed());
+            } catch (IllegalArgumentException e) {
+              // MXBean may throw due to a bug in Java 7; ignore.
+            }
+
+            MemoryUsage stats = memory.getNonHeapMemoryUsage();
+            nonHeapCommitted.set(stats.getCommitted());
+            nonHeapUsed.set(stats.getUsed());
+
+            objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
           }
-
-          MemoryUsage stats = memory.getNonHeapMemoryUsage();
-          nonHeapCommitted.set(stats.getCommitted());
-          nonHeapUsed.set(stats.getUsed());
-
-          objectPendingFinalizationCount.set(
-              memory.getObjectPendingFinalizationCount());
-        }
-      });
+        });
   }
 
   private void procJvmGc(MetricMaker metrics) {
-    final CallbackMetric1<String, Long> gcCount = metrics.newCallbackMetric(
-        "proc/jvm/gc/count",
-        Long.class,
-        new Description("Number of GCs").setCumulative(),
-        Field.ofString("gc_name", "The name of the garbage collector"));
+    final CallbackMetric1<String, Long> gcCount =
+        metrics.newCallbackMetric(
+            "proc/jvm/gc/count",
+            Long.class,
+            new Description("Number of GCs").setCumulative(),
+            Field.ofString("gc_name", "The name of the garbage collector"));
 
-    final CallbackMetric1<String, Long> gcTime = metrics.newCallbackMetric(
-        "proc/jvm/gc/time",
-        Long.class,
-        new Description("Approximate accumulated GC elapsed time")
-          .setCumulative()
-          .setUnit(Units.MILLISECONDS),
-        Field.ofString("gc_name", "The name of the garbage collector"));
+    final CallbackMetric1<String, Long> gcTime =
+        metrics.newCallbackMetric(
+            "proc/jvm/gc/time",
+            Long.class,
+            new Description("Approximate accumulated GC elapsed time")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            Field.ofString("gc_name", "The name of the garbage collector"));
 
-    metrics.newTrigger(gcCount, gcTime, new Runnable() {
-      @Override
-      public void run() {
-        for (GarbageCollectorMXBean gc : ManagementFactory
-            .getGarbageCollectorMXBeans()) {
-          long count = gc.getCollectionCount();
-          if (count != -1) {
-            gcCount.set(gc.getName(), count);
+    metrics.newTrigger(
+        gcCount,
+        gcTime,
+        new Runnable() {
+          @Override
+          public void run() {
+            for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
+              long count = gc.getCollectionCount();
+              if (count != -1) {
+                gcCount.set(gc.getName(), count);
+              }
+              long time = gc.getCollectionTime();
+              if (time != -1) {
+                gcTime.set(gc.getName(), time);
+              }
+            }
           }
-          long time = gc.getCollectionTime();
-          if (time != -1) {
-            gcTime.set(gc.getName(), time);
-          }
-        }
-      }
-    });
+        });
   }
 
   private void procJvmThread(MetricMaker metrics) {
@@ -211,9 +209,7 @@
     metrics.newCallbackMetric(
         "proc/jvm/thread/num_live",
         Integer.class,
-        new Description("Current live thread count")
-          .setGauge()
-          .setUnit("threads"),
+        new Description("Current live thread count").setGauge().setUnit("threads"),
         new Supplier<Integer>() {
           @Override
           public Integer get() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java
index eb2d264..c2643de 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java
@@ -11,38 +11,33 @@
 // 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.rules;
 
 import com.google.common.collect.LinkedHashMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.SetMultimap;
 import com.google.gerrit.extensions.registration.DynamicSet;
-
 import java.util.Collection;
 
-/**
- * Loads the classes for Prolog predicates.
- */
+/** Loads the classes for Prolog predicates. */
 public class PredicateClassLoader extends ClassLoader {
 
-  private final Multimap<String, ClassLoader> packageClassLoaderMap =
+  private final SetMultimap<String, ClassLoader> packageClassLoaderMap =
       LinkedHashMultimap.create();
 
   public PredicateClassLoader(
-      final DynamicSet<PredicateProvider> predicateProviders,
-      final ClassLoader parent) {
+      final DynamicSet<PredicateProvider> predicateProviders, final ClassLoader parent) {
     super(parent);
 
     for (PredicateProvider predicateProvider : predicateProviders) {
       for (String pkg : predicateProvider.getPackages()) {
-        packageClassLoaderMap.put(pkg, predicateProvider.getClass()
-            .getClassLoader());
+        packageClassLoaderMap.put(pkg, predicateProvider.getClass().getClassLoader());
       }
     }
   }
 
   @Override
-  protected Class<?> findClass(final String className)
-      throws ClassNotFoundException {
+  protected Class<?> findClass(final String className) throws ClassNotFoundException {
     final Collection<ClassLoader> classLoaders =
         packageClassLoaderMap.get(getPackageName(className));
     for (final ClassLoader cl : classLoaders) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
index 9d32e38..c64bc92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateProvider.java
@@ -15,17 +15,15 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
 import com.googlecode.prolog_cafe.lang.Predicate;
 
 /**
- * Provides additional packages that contain Prolog predicates that should be
- * made available in the Prolog environment. The predicates can e.g. be used in
- * the project submit rules.
+ * Provides additional packages that contain Prolog predicates that should be made available in the
+ * Prolog environment. The predicates can e.g. be used in the project submit rules.
  *
- * Each Java class defining a Prolog predicate must be in one of the provided
- * packages and its name must apply to the 'PRED_[functor]_[arity]' format. In
- * addition it must extend {@link Predicate}.
+ * <p>Each Java class defining a Prolog predicate must be in one of the provided packages and its
+ * name must apply to the 'PRED_[functor]_[arity]' format. In addition it must extend {@link
+ * Predicate}.
  */
 @ExtensionPoint
 public interface PredicateProvider {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
index de54a0b..a234317 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
@@ -25,34 +25,30 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-
 import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
 import com.googlecode.prolog_cafe.lang.Predicate;
 import com.googlecode.prolog_cafe.lang.PredicateEncoder;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Per-thread Prolog interpreter.
- * <p>
- * This class is not thread safe.
- * <p>
- * A single copy of the Prolog interpreter, for the current thread.
+ *
+ * <p>This class is not thread safe.
+ *
+ * <p>A single copy of the Prolog interpreter, for the current thread.
  */
 public class PrologEnvironment extends BufferingPrologControl {
-  private static final Logger log =
-      LoggerFactory.getLogger(PrologEnvironment.class);
+  private static final Logger log = LoggerFactory.getLogger(PrologEnvironment.class);
 
   public interface Factory {
     /**
@@ -84,7 +80,9 @@
   @Override
   public void setPredicate(Predicate goal) {
     super.setPredicate(goal);
-    setReductionLimit(args.reductionLimit(goal));
+    int reductionLimit = args.reductionLimit(goal);
+    log.debug("setting reductionLimit {}", reductionLimit);
+    setReductionLimit(reductionLimit);
   }
 
   /**
@@ -112,8 +110,8 @@
   }
 
   /**
-   * Copy the stored values from another interpreter to this one.
-   * Also gets the cleanup from the child interpreter
+   * Copy the stored values from another interpreter to this one. Also gets the cleanup from the
+   * child interpreter
    */
   public void copyStoredValues(PrologEnvironment child) {
     storedValues.putAll(child.storedValues);
@@ -121,9 +119,8 @@
   }
 
   /**
-   * Assign the environment a cleanup list (in order to use a centralized list)
-   * If this enivronment's list is non-empty, append its cleanup tasks to the
-   * assigning list.
+   * Assign the environment a cleanup list (in order to use a centralized list) If this
+   * enivronment's list is non-empty, append its cleanup tasks to the assigning list.
    */
   public void setCleanup(List<Runnable> newCleanupList) {
     newCleanupList.addAll(cleanup);
@@ -132,17 +129,16 @@
 
   /**
    * Adds cleanup task to run when close() is called
+   *
    * @param task is run when close() is called
    */
   public void addToCleanup(Runnable task) {
     cleanup.add(task);
   }
 
-  /**
-   * Release resources stored in interpreter's hash manager.
-   */
+  /** Release resources stored in interpreter's hash manager. */
   public void close() {
-    for (final Iterator<Runnable> i = cleanup.iterator(); i.hasNext();) {
+    for (final Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
       try {
         i.next().run();
       } catch (Throwable err) {
@@ -155,12 +151,16 @@
   @Singleton
   public static class Args {
     private static final Class<Predicate> CONSULT_STREAM_2;
+
     static {
       try {
         @SuppressWarnings("unchecked")
-        Class<Predicate> c = (Class<Predicate>) Class.forName(
-            PredicateEncoder.encode(Prolog.BUILTIN, "consult_stream", 2),
-            false, RulesCache.class.getClassLoader());
+        Class<Predicate> c =
+            (Class<Predicate>)
+                Class.forName(
+                    PredicateEncoder.encode(Prolog.BUILTIN, "consult_stream", 2),
+                    false,
+                    RulesCache.class.getClassLoader());
         CONSULT_STREAM_2 = c;
       } catch (ClassNotFoundException e) {
         throw new LinkageError("cannot find predicate consult_stream", e);
@@ -177,7 +177,8 @@
     private final int compileLimit;
 
     @Inject
-    Args(ProjectCache projectCache,
+    Args(
+        ProjectCache projectCache,
         GitRepositoryManager repositoryManager,
         PatchListCache patchListCache,
         PatchSetInfoFactory patchSetInfoFactory,
@@ -194,9 +195,15 @@
       int limit = config.getInt("rules", null, "reductionLimit", 100000);
       reductionLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
 
-      limit = config.getInt("rules", null, "compileReductionLimit",
-          (int) Math.min(10L * limit, Integer.MAX_VALUE));
+      limit =
+          config.getInt(
+              "rules",
+              null,
+              "compileReductionLimit",
+              (int) Math.min(10L * limit, Integer.MAX_VALUE));
       compileLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
+
+      log.info("reductionLimit: {}, compileLimit: {}", reductionLimit, compileLimit);
     }
 
     private int reductionLimit(Predicate goal) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java b/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
index 068b70d..9ab0dd6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/RulesCache.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.exceptions.SyntaxException;
 import com.googlecode.prolog_cafe.exceptions.TermException;
@@ -39,15 +38,6 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
-
-import org.eclipse.jgit.errors.LargeObjectException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.util.RawParseUtils;
-
 import java.io.IOException;
 import java.io.PushbackReader;
 import java.io.Reader;
@@ -65,24 +55,29 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.errors.LargeObjectException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.RawParseUtils;
 
 /**
  * Manages a cache of compiled Prolog rules.
- * <p>
- * Rules are loaded from the {@code site_path/cache/rules/rules-SHA1.jar}, where
- * {@code SHA1} is the SHA1 of the Prolog {@code rules.pl} in a project's
- * {@link RefNames#REFS_CONFIG} branch.
+ *
+ * <p>Rules are loaded from the {@code site_path/cache/rules/rules-SHA1.jar}, where {@code SHA1} is
+ * the SHA1 of the Prolog {@code rules.pl} in a project's {@link RefNames#REFS_CONFIG} branch.
  */
 @Singleton
 public class RulesCache {
-  private static final List<String> PACKAGE_LIST = ImmutableList.of(
-      Prolog.BUILTIN, "gerrit");
+  private static final ImmutableList<String> PACKAGE_LIST =
+      ImmutableList.of(Prolog.BUILTIN, "gerrit");
 
   private static final class MachineRef extends WeakReference<PrologMachineCopy> {
     final ObjectId key;
 
-    MachineRef(ObjectId key, PrologMachineCopy pcm,
-        ReferenceQueue<PrologMachineCopy> queue) {
+    MachineRef(ObjectId key, PrologMachineCopy pcm, ReferenceQueue<PrologMachineCopy> queue) {
       super(pcm, queue);
       this.key = key;
     }
@@ -98,16 +93,17 @@
   private final ClassLoader systemLoader;
   private final PrologMachineCopy defaultMachine;
   private final Map<ObjectId, MachineRef> machineCache = new HashMap<>();
-  private final ReferenceQueue<PrologMachineCopy> dead =
-      new ReferenceQueue<>();
+  private final ReferenceQueue<PrologMachineCopy> dead = new ReferenceQueue<>();
 
   @Inject
-  protected RulesCache(@GerritServerConfig Config config, SitePaths site,
-      GitRepositoryManager gm, DynamicSet<PredicateProvider> predicateProviders) {
+  protected RulesCache(
+      @GerritServerConfig Config config,
+      SitePaths site,
+      GitRepositoryManager gm,
+      DynamicSet<PredicateProvider> predicateProviders) {
     maxDbSize = config.getInt("rules", null, "maxPrologDatabaseSize", 256);
     maxSrcBytes = config.getInt("rules", null, "maxSourceBytes", 128 << 10);
-    enableProjectRules = config.getBoolean("rules", null, "enable", true)
-        && maxSrcBytes > 0;
+    enableProjectRules = config.getBoolean("rules", null, "enable", true) && maxSrcBytes > 0;
     cacheDir = site.resolve(config.getString("cache", null, "directory"));
     rulesDir = cacheDir != null ? cacheDir.resolve("rules") : null;
     gitMgr = gm;
@@ -127,9 +123,7 @@
    * @return a Prolog machine, after loading the specified rules.
    * @throws CompileException the machine cannot be created.
    */
-  public synchronized PrologMachineCopy loadMachine(
-      Project.NameKey project,
-      ObjectId rulesId)
+  public synchronized PrologMachineCopy loadMachine(Project.NameKey project, ObjectId rulesId)
       throws CompileException {
     if (!enableProjectRules || project == null || rulesId == null) {
       return defaultMachine;
@@ -154,8 +148,7 @@
     return pcm;
   }
 
-  public PrologMachineCopy loadMachine(String name, Reader in)
-      throws CompileException {
+  public PrologMachineCopy loadMachine(String name, Reader in) throws CompileException {
     PrologMachineCopy pmc = consultRules(name, in);
     if (pmc == null) {
       throw new CompileException("Cannot consult rules from the stream " + name);
@@ -173,8 +166,8 @@
     }
   }
 
-  private PrologMachineCopy createMachine(Project.NameKey project,
-      ObjectId rulesId) throws CompileException {
+  private PrologMachineCopy createMachine(Project.NameKey project, ObjectId rulesId)
+      throws CompileException {
     // If the rules are available as a complied JAR on local disk, prefer
     // that over dynamic consult as the bytecode will be faster.
     //
@@ -196,29 +189,26 @@
     return pmc;
   }
 
-  private PrologMachineCopy consultRules(String name, Reader rules)
-      throws CompileException {
+  private PrologMachineCopy consultRules(String name, Reader rules) throws CompileException {
     BufferingPrologControl ctl = newEmptyMachine(systemLoader);
     PushbackReader in = new PushbackReader(rules, Prolog.PUSHBACK_SIZE);
     try {
-      if (!ctl.execute(Prolog.BUILTIN, "consult_stream",
-          SymbolTerm.intern(name), new JavaObjectTerm(in))) {
+      if (!ctl.execute(
+          Prolog.BUILTIN, "consult_stream", SymbolTerm.intern(name), new JavaObjectTerm(in))) {
         return null;
       }
     } catch (SyntaxException e) {
       throw new CompileException(e.toString(), e);
     } catch (TermException e) {
       Term m = e.getMessageTerm();
-      if (m instanceof StructureTerm && "syntax_error".equals(m.name())
-          && m.arity() >= 1) {
+      if (m instanceof StructureTerm && "syntax_error".equals(m.name()) && m.arity() >= 1) {
         StringBuilder msg = new StringBuilder();
         if (m.arg(0) instanceof ListTerm) {
           msg.append(Joiner.on(' ').join(((ListTerm) m.arg(0)).toJava()));
         } else {
           msg.append(m.arg(0).toString());
         }
-        if (m.arity() == 2 && m.arg(1) instanceof StructureTerm
-            && "at".equals(m.arg(1).name())) {
+        if (m.arity() == 2 && m.arg(1) instanceof StructureTerm && "at".equals(m.arg(1).name())) {
           Term at = m.arg(1).arg(0).dereference();
           if (at instanceof ListTerm) {
             msg.append(" at: ");
@@ -259,8 +249,7 @@
     return b.toString().trim();
   }
 
-  private String read(Project.NameKey project, ObjectId rulesId)
-      throws CompileException {
+  private String read(Project.NameKey project, ObjectId rulesId) throws CompileException {
     try (Repository git = gitMgr.openRepository(project)) {
       try {
         ObjectLoader ldr = git.open(rulesId, Constants.OBJ_BLOB);
@@ -279,8 +268,8 @@
   private BufferingPrologControl newEmptyMachine(ClassLoader cl) {
     BufferingPrologControl ctl = new BufferingPrologControl();
     ctl.setMaxDatabaseSize(maxDbSize);
-    ctl.setPrologClassLoader(new PrologClassLoader(new PredicateClassLoader(
-        predicateProviders, cl)));
+    ctl.setPrologClassLoader(
+        new PrologClassLoader(new PredicateClassLoader(predicateProviders, cl)));
     ctl.setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
 
     List<String> packages = new ArrayList<>();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java
index 206e840..4892fc1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValue.java
@@ -38,16 +38,14 @@
   /**
    * Initialize a stored value key using any Java Object.
    *
-   * @param key unique identity of the stored value. This will be the hash key
-   *        in the Prolog Environments's hash map.
+   * @param key unique identity of the stored value. This will be the hash key in the Prolog
+   *     Environments's hash map.
    */
   public StoredValue(Object key) {
     this.key = key;
   }
 
-  /**
-   * Initializes a stored value key with a new unique key.
-   */
+  /** Initializes a stored value key with a new unique key. */
   public StoredValue() {
     key = this;
   }
@@ -60,7 +58,7 @@
   public T get(Prolog engine) {
     T obj = getOrNull(engine);
     if (obj == null) {
-      //unless createValue() is overridden, will return null
+      // unless createValue() is overridden, will return null
       obj = createValue(engine);
       if (obj == null) {
         throw new SystemException("No " + key + " available");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
index c493ccd..34fcb52 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
@@ -36,16 +36,13 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
-
 import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.Prolog;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 
 public final class StoredValues {
   public static final StoredValue<ReviewDb> REVIEW_DB = create(ReviewDb.class);
@@ -76,65 +73,68 @@
     }
   }
 
-  public static final StoredValue<PatchSetInfo> PATCH_SET_INFO = new StoredValue<PatchSetInfo>() {
-    @Override
-    public PatchSetInfo createValue(Prolog engine) {
-      Change change = getChange(engine);
-      PatchSet ps = getPatchSet(engine);
-      PrologEnvironment env = (PrologEnvironment) engine.control;
-      PatchSetInfoFactory patchInfoFactory =
-              env.getArgs().getPatchSetInfoFactory();
-      try {
-        return patchInfoFactory.get(change.getProject(), ps);
-      } catch (PatchSetInfoNotAvailableException e) {
-        throw new SystemException(e.getMessage());
-      }
-    }
-  };
-
-  public static final StoredValue<PatchList> PATCH_LIST = new StoredValue<PatchList>() {
-    @Override
-    public PatchList createValue(Prolog engine) {
-      PrologEnvironment env = (PrologEnvironment) engine.control;
-      PatchSet ps = getPatchSet(engine);
-      PatchListCache plCache = env.getArgs().getPatchListCache();
-      Change change = getChange(engine);
-      Project.NameKey project = change.getProject();
-      ObjectId b = ObjectId.fromString(ps.getRevision().get());
-      Whitespace ws = Whitespace.IGNORE_NONE;
-      PatchListKey plKey = PatchListKey.againstDefaultBase(b, ws);
-      PatchList patchList;
-      try {
-        patchList = plCache.get(plKey, project);
-      } catch (PatchListNotAvailableException e) {
-        throw new SystemException("Cannot create " + plKey);
-      }
-      return patchList;
-    }
-  };
-
-  public static final StoredValue<Repository> REPOSITORY = new StoredValue<Repository>() {
-    @Override
-    public Repository createValue(Prolog engine) {
-      PrologEnvironment env = (PrologEnvironment) engine.control;
-      GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
-      Change change = getChange(engine);
-      Project.NameKey projectKey = change.getProject();
-      final Repository repo;
-      try {
-        repo = gitMgr.openRepository(projectKey);
-      } catch (IOException e) {
-        throw new SystemException(e.getMessage());
-      }
-      env.addToCleanup(new Runnable() {
+  public static final StoredValue<PatchSetInfo> PATCH_SET_INFO =
+      new StoredValue<PatchSetInfo>() {
         @Override
-        public void run() {
-          repo.close();
+        public PatchSetInfo createValue(Prolog engine) {
+          Change change = getChange(engine);
+          PatchSet ps = getPatchSet(engine);
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          PatchSetInfoFactory patchInfoFactory = env.getArgs().getPatchSetInfoFactory();
+          try {
+            return patchInfoFactory.get(change.getProject(), ps);
+          } catch (PatchSetInfoNotAvailableException e) {
+            throw new SystemException(e.getMessage());
+          }
         }
-      });
-      return repo;
-    }
-  };
+      };
+
+  public static final StoredValue<PatchList> PATCH_LIST =
+      new StoredValue<PatchList>() {
+        @Override
+        public PatchList createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          PatchSet ps = getPatchSet(engine);
+          PatchListCache plCache = env.getArgs().getPatchListCache();
+          Change change = getChange(engine);
+          Project.NameKey project = change.getProject();
+          ObjectId b = ObjectId.fromString(ps.getRevision().get());
+          Whitespace ws = Whitespace.IGNORE_NONE;
+          PatchListKey plKey = PatchListKey.againstDefaultBase(b, ws);
+          PatchList patchList;
+          try {
+            patchList = plCache.get(plKey, project);
+          } catch (PatchListNotAvailableException e) {
+            throw new SystemException("Cannot create " + plKey);
+          }
+          return patchList;
+        }
+      };
+
+  public static final StoredValue<Repository> REPOSITORY =
+      new StoredValue<Repository>() {
+        @Override
+        public Repository createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
+          Change change = getChange(engine);
+          Project.NameKey projectKey = change.getProject();
+          final Repository repo;
+          try {
+            repo = gitMgr.openRepository(projectKey);
+          } catch (IOException e) {
+            throw new SystemException(e.getMessage());
+          }
+          env.addToCleanup(
+              new Runnable() {
+                @Override
+                public void run() {
+                  repo.close();
+                }
+              });
+          return repo;
+        }
+      };
 
   public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
       new StoredValue<AnonymousUser>() {
@@ -153,6 +153,5 @@
         }
       };
 
-  private StoredValues() {
-  }
+  private StoredValues() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
index 36888e3..de8e9a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
-
 import java.util.Collections;
 
 /** An anonymous user who has not yet authenticated. */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
index bc2ec06..cb65ed3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
@@ -36,22 +36,19 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.TreeMap;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 
 /**
  * Copies approvals between patch sets.
- * <p>
- * The result of a copy may either be stored, as when stamping approvals in the
- * database at submit time, or refreshed on demand, as when reading approvals
- * from the NoteDb.
+ *
+ * <p>The result of a copy may either be stored, as when stamping approvals in the database at
+ * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
  */
 @Singleton
 public class ApprovalCopier {
@@ -63,7 +60,8 @@
   private final PatchSetUtil psUtil;
 
   @Inject
-  ApprovalCopier(GitRepositoryManager repoManager,
+  ApprovalCopier(
+      GitRepositoryManager repoManager,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
       LabelNormalizer labelNormalizer,
@@ -77,55 +75,87 @@
     this.psUtil = psUtil;
   }
 
-  public void copy(ReviewDb db, ChangeControl ctl, PatchSet ps)
-      throws OrmException {
-    db.patchSetApprovals().insert(getForPatchSet(db, ctl, ps));
+  /**
+   * Apply approval copy settings from prior PatchSets to a new PatchSet.
+   *
+   * @param db review database.
+   * @param ctl change control for user uploading PatchSet
+   * @param ps new PatchSet
+   * @throws OrmException
+   */
+  public void copy(ReviewDb db, ChangeControl ctl, PatchSet ps) throws OrmException {
+    copy(db, ctl, ps, Collections.<PatchSetApproval>emptyList());
   }
 
-  Iterable<PatchSetApproval> getForPatchSet(ReviewDb db,
-      ChangeControl ctl, PatchSet.Id psId) throws OrmException {
+  /**
+   * Apply approval copy settings from prior PatchSets to a new PatchSet.
+   *
+   * @param db review database.
+   * @param ctl change control for user uploading PatchSet
+   * @param ps new PatchSet
+   * @param dontCopy PatchSetApprovals indicating which (account, label) pairs should not be copied
+   * @throws OrmException
+   */
+  public void copy(ReviewDb db, ChangeControl ctl, PatchSet ps, Iterable<PatchSetApproval> dontCopy)
+      throws OrmException {
+    db.patchSetApprovals().insert(getForPatchSet(db, ctl, ps, dontCopy));
+  }
+
+  Iterable<PatchSetApproval> getForPatchSet(ReviewDb db, ChangeControl ctl, PatchSet.Id psId)
+      throws OrmException {
+    return getForPatchSet(db, ctl, psId, Collections.<PatchSetApproval>emptyList());
+  }
+
+  Iterable<PatchSetApproval> getForPatchSet(
+      ReviewDb db, ChangeControl ctl, PatchSet.Id psId, Iterable<PatchSetApproval> dontCopy)
+      throws OrmException {
     PatchSet ps = psUtil.get(db, ctl.getNotes(), psId);
     if (ps == null) {
       return Collections.emptyList();
     }
-    return getForPatchSet(db, ctl, ps);
+    return getForPatchSet(db, ctl, ps, dontCopy);
   }
 
-  private Iterable<PatchSetApproval> getForPatchSet(ReviewDb db,
-      ChangeControl ctl, PatchSet ps) throws OrmException {
+  private Iterable<PatchSetApproval> getForPatchSet(
+      ReviewDb db, ChangeControl ctl, PatchSet ps, Iterable<PatchSetApproval> dontCopy)
+      throws OrmException {
     checkNotNull(ps, "ps should not be null");
     ChangeData cd = changeDataFactory.create(db, ctl);
     try {
-      ProjectState project =
-          projectCache.checkedGet(cd.change().getDest().getParentKey());
+      ProjectState project = projectCache.checkedGet(cd.change().getDest().getParentKey());
       ListMultimap<PatchSet.Id, PatchSetApproval> all = cd.approvals();
       checkNotNull(all, "all should not be null");
 
-      Table<String, Account.Id, PatchSetApproval> wontCopy =
-          HashBasedTable.create();
-      Table<String, Account.Id, PatchSetApproval> byUser =
-          HashBasedTable.create();
+      Table<String, Account.Id, PatchSetApproval> wontCopy = HashBasedTable.create();
+      for (PatchSetApproval psa : dontCopy) {
+        wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+      }
+
+      Table<String, Account.Id, PatchSetApproval> byUser = HashBasedTable.create();
       for (PatchSetApproval psa : all.get(ps.getId())) {
-        byUser.put(psa.getLabel(), psa.getAccountId(), psa);
+        if (!wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
+          byUser.put(psa.getLabel(), psa.getAccountId(), psa);
+        }
       }
 
       TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
 
-      try (Repository repo =
-          repoManager.openRepository(project.getProject().getNameKey())) {
+      try (Repository repo = repoManager.openRepository(project.getProject().getNameKey())) {
         // Walk patch sets strictly less than current in descending order.
-        Collection<PatchSet> allPrior = patchSets.descendingMap()
-            .tailMap(ps.getId().get(), false)
-            .values();
+        Collection<PatchSet> allPrior =
+            patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
         for (PatchSet priorPs : allPrior) {
           List<PatchSetApproval> priorApprovals = all.get(priorPs.getId());
           if (priorApprovals.isEmpty()) {
             continue;
           }
 
-          ChangeKind kind = changeKindCache.getChangeKind(project, repo,
-              ObjectId.fromString(priorPs.getRevision().get()),
-              ObjectId.fromString(ps.getRevision().get()));
+          ChangeKind kind =
+              changeKindCache.getChangeKind(
+                  project.getProject().getNameKey(),
+                  repo,
+                  ObjectId.fromString(priorPs.getRevision().get()),
+                  ObjectId.fromString(ps.getRevision().get()));
 
           for (PatchSetApproval psa : priorApprovals) {
             if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
@@ -138,8 +168,7 @@
               wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
               continue;
             }
-            byUser.put(psa.getLabel(), psa.getAccountId(),
-                copy(psa, ps.getId()));
+            byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
           }
         }
         return labelNormalizer.normalize(ctl, byUser.values()).getNormalized();
@@ -149,8 +178,7 @@
     }
   }
 
-  private static TreeMap<Integer, PatchSet> getPatchSets(ChangeData cd)
-      throws OrmException {
+  private static TreeMap<Integer, PatchSet> getPatchSets(ChangeData cd) throws OrmException {
     Collection<PatchSet> patchSets = cd.patchSets();
     TreeMap<Integer, PatchSet> result = new TreeMap<>();
     for (PatchSet ps : patchSets) {
@@ -159,15 +187,14 @@
     return result;
   }
 
-  private static boolean canCopy(ProjectState project, PatchSetApproval psa,
-      PatchSet.Id psId, ChangeKind kind) {
+  private static boolean canCopy(
+      ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
     int n = psa.getKey().getParentKey().get();
     checkArgument(n != psId.get());
     LabelType type = project.getLabelTypes().byLabel(psa.getLabelId());
     if (type == null) {
       return false;
-    } else if (
-        (type.isCopyMinScore() && type.isMaxNegative(psa))
+    } else if ((type.isCopyMinScore() && type.isMaxNegative(psa))
         || (type.isCopyMaxScore() && type.isMaxPositive(psa))) {
       return true;
     }
@@ -195,4 +222,4 @@
     }
     return new PatchSetApproval(psId, src);
   }
-}
\ No newline at end of file
+}
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 e0526e4..57615c4 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,12 +14,12 @@
 
 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 static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
@@ -27,10 +27,14 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
+import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.LabelId;
@@ -47,8 +51,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -58,54 +60,62 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Utility functions to manipulate patchset approvals.
- * <p>
- * Approvals are overloaded, they represent both approvals and reviewers
- * which should be CCed on a change.  To ensure that reviewers are not lost
- * there must always be an approval on each patchset for each reviewer,
- * even if the reviewer hasn't actually given a score to the change.  To
- * mark the "no score" case, a dummy approval, which may live in any of
- * the available categories, with a score of 0 is used.
- * <p>
- * The methods in this class only modify the gwtorm database.
+ *
+ * <p>Approvals are overloaded, they represent both approvals and reviewers which should be CCed on
+ * a change. To ensure that reviewers are not lost there must always be an approval on each patchset
+ * for each reviewer, even if the reviewer hasn't actually given a score to the change. To mark the
+ * "no score" case, a dummy approval, which may live in any of the available categories, with a
+ * score of 0 is used.
+ *
+ * <p>The methods in this class only modify the gwtorm database.
  */
 @Singleton
 public class ApprovalsUtil {
-  private static final Ordering<PatchSetApproval> SORT_APPROVALS =
-      Ordering.natural()
-          .onResultOf(
-              new Function<PatchSetApproval, Timestamp>() {
-                @Override
-                public Timestamp apply(PatchSetApproval a) {
-                  return a.getGranted();
-                }
-              });
+  private static final Logger log = LoggerFactory.getLogger(ApprovalsUtil.class);
 
-  public static List<PatchSetApproval> sortApprovals(
-      Iterable<PatchSetApproval> approvals) {
+  private static final Ordering<PatchSetApproval> SORT_APPROVALS =
+      Ordering.from(comparing(PatchSetApproval::getGranted));
+
+  public static List<PatchSetApproval> sortApprovals(Iterable<PatchSetApproval> approvals) {
     return SORT_APPROVALS.sortedCopy(approvals);
   }
 
+  public static PatchSetApproval newApproval(
+      PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Date when) {
+    PatchSetApproval psa =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(psId, user.getAccountId(), labelId),
+            Shorts.checkedCast(value),
+            when);
+    user.updateRealAccountId(psa::setRealAccountId);
+    return psa;
+  }
+
   private static Iterable<PatchSetApproval> filterApprovals(
       Iterable<PatchSetApproval> psas, final Account.Id accountId) {
-    return Iterables.filter(psas, new Predicate<PatchSetApproval>() {
-      @Override
-      public boolean apply(PatchSetApproval input) {
-        return Objects.equals(input.getAccountId(), accountId);
-      }
-    });
+    return Iterables.filter(psas, a -> Objects.equals(a.getAccountId(), accountId));
   }
 
   private final NotesMigration migration;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
   private final ApprovalCopier copier;
 
   @VisibleForTesting
   @Inject
-  public ApprovalsUtil(NotesMigration migration,
+  public ApprovalsUtil(
+      NotesMigration migration,
+      IdentifiedUser.GenericFactory userFactory,
+      ChangeControl.GenericFactory changeControlFactory,
       ApprovalCopier copier) {
     this.migration = migration;
+    this.userFactory = userFactory;
+    this.changeControlFactory = changeControlFactory;
     this.copier = copier;
   }
 
@@ -117,11 +127,9 @@
    * @return reviewers for the change.
    * @throws OrmException if reviewers for the change could not be read.
    */
-  public ReviewerSet getReviewers(ReviewDb db, ChangeNotes notes)
-      throws OrmException {
+  public ReviewerSet getReviewers(ReviewDb db, ChangeNotes notes) throws OrmException {
     if (!migration.readChanges()) {
-      return ReviewerSet.fromApprovals(
-          db.patchSetApprovals().byChange(notes.getChangeId()));
+      return ReviewerSet.fromApprovals(db.patchSetApprovals().byChange(notes.getChangeId()));
     }
     return notes.load().getReviewers();
   }
@@ -129,13 +137,11 @@
   /**
    * Get all reviewers and CCed accounts for a change.
    *
-   * @param allApprovals all approvals to consider; must all belong to the same
-   *     change.
+   * @param allApprovals all approvals to consider; must all belong to the same change.
    * @return reviewers for the change.
    * @throws OrmException if reviewers for the change could not be read.
    */
-  public ReviewerSet getReviewers(ChangeNotes notes,
-      Iterable<PatchSetApproval> allApprovals)
+  public ReviewerSet getReviewers(ChangeNotes notes, Iterable<PatchSetApproval> allApprovals)
       throws OrmException {
     if (!migration.readChanges()) {
       return ReviewerSet.fromApprovals(allApprovals);
@@ -144,33 +150,49 @@
   }
 
   /**
-   * Get updates to reviewer set.
-   * Always returns empty list for ReviewDb.
+   * Get updates to reviewer set. Always returns empty list for ReviewDb.
    *
    * @param notes change notes.
    * @return reviewer updates for the change.
    * @throws OrmException if reviewer updates for the change could not be read.
    */
-  public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes)
-      throws OrmException {
+  public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes) throws OrmException {
     if (!migration.readChanges()) {
       return ImmutableList.of();
     }
     return notes.load().getReviewerUpdates();
   }
 
-  public List<PatchSetApproval> addReviewers(ReviewDb db,
-      ChangeUpdate update, LabelTypes labelTypes, Change change, PatchSet ps,
-      PatchSetInfo info, Iterable<Account.Id> wantReviewers,
-      Collection<Account.Id> existingReviewers) throws OrmException {
-    return addReviewers(db, update, labelTypes, change, ps.getId(),
-        ps.isDraft(), info.getAuthor().getAccount(),
-        info.getCommitter().getAccount(), wantReviewers, existingReviewers);
+  public List<PatchSetApproval> addReviewers(
+      ReviewDb db,
+      ChangeUpdate update,
+      LabelTypes labelTypes,
+      Change change,
+      PatchSet ps,
+      PatchSetInfo info,
+      Iterable<Account.Id> wantReviewers,
+      Collection<Account.Id> existingReviewers)
+      throws OrmException {
+    return addReviewers(
+        db,
+        update,
+        labelTypes,
+        change,
+        ps.getId(),
+        info.getAuthor().getAccount(),
+        info.getCommitter().getAccount(),
+        wantReviewers,
+        existingReviewers);
   }
 
-  public List<PatchSetApproval> addReviewers(ReviewDb db, ChangeNotes notes,
-      ChangeUpdate update, LabelTypes labelTypes, Change change,
-      Iterable<Account.Id> wantReviewers) throws OrmException {
+  public List<PatchSetApproval> addReviewers(
+      ReviewDb db,
+      ChangeNotes notes,
+      ChangeUpdate update,
+      LabelTypes labelTypes,
+      Change change,
+      Iterable<Account.Id> wantReviewers)
+      throws OrmException {
     PatchSet.Id psId = change.currentPatchSetId();
     Collection<Account.Id> existingReviewers;
     if (migration.readChanges()) {
@@ -183,32 +205,37 @@
     // Existing reviewers should include pending additions in the REVIEWER
     // state, taken from ChangeUpdate.
     existingReviewers = Lists.newArrayList(existingReviewers);
-    for (Map.Entry<Account.Id, ReviewerStateInternal> entry :
-        update.getReviewers().entrySet()) {
+    for (Map.Entry<Account.Id, ReviewerStateInternal> entry : update.getReviewers().entrySet()) {
       if (entry.getValue() == REVIEWER) {
         existingReviewers.add(entry.getKey());
       }
     }
-    return addReviewers(db, update, labelTypes, change, psId, false, null, null,
-        wantReviewers, existingReviewers);
+    return addReviewers(
+        db, update, labelTypes, change, psId, null, null, wantReviewers, existingReviewers);
   }
 
-  private List<PatchSetApproval> addReviewers(ReviewDb db, ChangeUpdate update,
-      LabelTypes labelTypes, Change change, PatchSet.Id psId, boolean isDraft,
-      Account.Id authorId, Account.Id committerId,
+  private List<PatchSetApproval> addReviewers(
+      ReviewDb db,
+      ChangeUpdate update,
+      LabelTypes labelTypes,
+      Change change,
+      PatchSet.Id psId,
+      Account.Id authorId,
+      Account.Id committerId,
       Iterable<Account.Id> wantReviewers,
-      Collection<Account.Id> existingReviewers) throws OrmException {
+      Collection<Account.Id> existingReviewers)
+      throws OrmException {
     List<LabelType> allTypes = labelTypes.getLabelTypes();
     if (allTypes.isEmpty()) {
       return ImmutableList.of();
     }
 
     Set<Account.Id> need = Sets.newLinkedHashSet(wantReviewers);
-    if (authorId != null && !isDraft) {
+    if (authorId != null && canSee(db, update.getNotes(), authorId)) {
       need.add(authorId);
     }
 
-    if (committerId != null && !isDraft) {
+    if (committerId != null && canSee(db, update.getNotes(), committerId)) {
       need.add(committerId);
     }
     need.remove(change.getOwner());
@@ -220,15 +247,29 @@
     List<PatchSetApproval> cells = Lists.newArrayListWithCapacity(need.size());
     LabelId labelId = Iterables.getLast(allTypes).getLabelId();
     for (Account.Id account : need) {
-      cells.add(new PatchSetApproval(
-          new PatchSetApproval.Key(psId, account, labelId),
-          (short) 0, update.getWhen()));
+      cells.add(
+          new PatchSetApproval(
+              new PatchSetApproval.Key(psId, account, labelId), (short) 0, update.getWhen()));
       update.putReviewer(account, REVIEWER);
     }
-    db.patchSetApprovals().insert(cells);
+    db.patchSetApprovals().upsert(cells);
     return Collections.unmodifiableList(cells);
   }
 
+  private boolean canSee(ReviewDb db, ChangeNotes notes, Account.Id accountId) {
+    try {
+      IdentifiedUser user = userFactory.create(accountId);
+      return changeControlFactory.controlFor(notes, user).isVisible(db);
+    } catch (OrmException e) {
+      log.warn(
+          "Failed to check if account {} can see change {}",
+          accountId.get(),
+          notes.getChangeId().get(),
+          e);
+      return false;
+    }
+  }
+
   /**
    * Adds accounts to a change as reviewers in the CC state.
    *
@@ -238,13 +279,13 @@
    * @return whether a change was made.
    * @throws OrmException
    */
-  public Collection<Account.Id> addCcs(ChangeNotes notes, ChangeUpdate update,
-      Collection<Account.Id> wantCCs) throws OrmException {
+  public Collection<Account.Id> addCcs(
+      ChangeNotes notes, ChangeUpdate update, Collection<Account.Id> wantCCs) throws OrmException {
     return addCcs(update, wantCCs, notes.load().getReviewers());
   }
 
-  private Collection<Account.Id> addCcs(ChangeUpdate update,
-      Collection<Account.Id> wantCCs, ReviewerSet existingReviewers) {
+  private Collection<Account.Id> addCcs(
+      ChangeUpdate update, Collection<Account.Id> wantCCs, ReviewerSet existingReviewers) {
     Set<Account.Id> need = new LinkedHashSet<>(wantCCs);
     need.removeAll(existingReviewers.all());
     need.removeAll(update.getReviewers().keySet());
@@ -254,59 +295,80 @@
     return need;
   }
 
-  public void addApprovals(ReviewDb db, ChangeUpdate update,
-      LabelTypes labelTypes, PatchSet ps, ChangeControl changeCtl,
-      Map<String, Short> approvals) throws OrmException {
-    if (!approvals.isEmpty()) {
-      checkApprovals(approvals, changeCtl);
-      List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
-      Date ts = update.getWhen();
-      for (Map.Entry<String, Short> vote : approvals.entrySet()) {
-        LabelType lt = labelTypes.byLabel(vote.getKey());
-        cells.add(new PatchSetApproval(new PatchSetApproval.Key(
-            ps.getId(),
-            ps.getUploader(),
-            lt.getLabelId()),
-            vote.getValue(),
-            ts));
-        update.putApproval(vote.getKey(), vote.getValue());
-      }
-      db.patchSetApprovals().insert(cells);
+  /**
+   * Adds approvals to ChangeUpdate for a new patch set, and writes to ReviewDb.
+   *
+   * @param db review database.
+   * @param update change update.
+   * @param labelTypes label types for the containing project.
+   * @param ps patch set being approved.
+   * @param changeCtl change control for user adding approvals.
+   * @param approvals approvals to add.
+   * @throws RestApiException
+   * @throws OrmException
+   */
+  public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(
+      ReviewDb db,
+      ChangeUpdate update,
+      LabelTypes labelTypes,
+      PatchSet ps,
+      ChangeControl changeCtl,
+      Map<String, Short> approvals)
+      throws RestApiException, OrmException {
+    Account.Id accountId = changeCtl.getUser().getAccountId();
+    checkArgument(
+        accountId.equals(ps.getUploader()),
+        "expected user %s to match patch set uploader %s",
+        accountId,
+        ps.getUploader());
+    if (approvals.isEmpty()) {
+      return Collections.emptyList();
     }
+    checkApprovals(approvals, changeCtl);
+    List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
+    Date ts = update.getWhen();
+    for (Map.Entry<String, Short> vote : approvals.entrySet()) {
+      LabelType lt = labelTypes.byLabel(vote.getKey());
+      cells.add(newApproval(ps.getId(), changeCtl.getUser(), lt.getLabelId(), vote.getValue(), ts));
+    }
+    for (PatchSetApproval psa : cells) {
+      update.putApproval(psa.getLabel(), psa.getValue());
+    }
+    db.patchSetApprovals().insert(cells);
+    return cells;
   }
 
-  public static void checkLabel(LabelTypes labelTypes, String name, Short value) {
+  public static void checkLabel(LabelTypes labelTypes, String name, Short value)
+      throws BadRequestException {
     LabelType label = labelTypes.byLabel(name);
     if (label == null) {
-      throw new IllegalArgumentException(String.format(
-          "label \"%s\" is not a configured label", name));
+      throw new BadRequestException(String.format("label \"%s\" is not a configured label", name));
     }
     if (label.getValue(value) == null) {
-      throw new IllegalArgumentException(String.format(
-          "label \"%s\": %d is not a valid value", name, value));
+      throw new BadRequestException(
+          String.format("label \"%s\": %d is not a valid value", name, value));
     }
   }
 
-  private static void checkApprovals(Map<String, Short> approvals,
-      ChangeControl changeCtl) {
+  private static void checkApprovals(Map<String, Short> approvals, ChangeControl changeCtl)
+      throws AuthException {
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       String name = vote.getKey();
       Short value = vote.getValue();
       PermissionRange range = changeCtl.getRange(Permission.forLabel(name));
       if (range == null || !range.contains(value)) {
-        throw new IllegalArgumentException(String.format(
-            "applying label \"%s\": %d is restricted", name, value));
+        throw new AuthException(
+            String.format("applying label \"%s\": %d is restricted", name, value));
       }
     }
   }
 
-  public ListMultimap<PatchSet.Id, PatchSetApproval> byChange(ReviewDb db,
-      ChangeNotes notes) throws OrmException {
+  public ListMultimap<PatchSet.Id, PatchSetApproval> byChange(ReviewDb db, ChangeNotes notes)
+      throws OrmException {
     if (!migration.readChanges()) {
       ImmutableListMultimap.Builder<PatchSet.Id, PatchSetApproval> result =
           ImmutableListMultimap.builder();
-      for (PatchSetApproval psa
-          : db.patchSetApprovals().byChange(notes.getChangeId())) {
+      for (PatchSetApproval psa : db.patchSetApprovals().byChange(notes.getChangeId())) {
         result.put(psa.getPatchSetId(), psa);
       }
       return result.build();
@@ -314,26 +376,23 @@
     return notes.load().getApprovals();
   }
 
-  public Iterable<PatchSetApproval> byPatchSet(ReviewDb db, ChangeControl ctl,
-      PatchSet.Id psId) throws OrmException {
+  public Iterable<PatchSetApproval> byPatchSet(ReviewDb db, ChangeControl ctl, PatchSet.Id psId)
+      throws OrmException {
     if (!migration.readChanges()) {
       return sortApprovals(db.patchSetApprovals().byPatchSet(psId));
     }
     return copier.getForPatchSet(db, ctl, psId);
   }
 
-  public Iterable<PatchSetApproval> byPatchSetUser(ReviewDb db,
-      ChangeControl ctl, PatchSet.Id psId, Account.Id accountId)
-      throws OrmException {
+  public Iterable<PatchSetApproval> byPatchSetUser(
+      ReviewDb db, ChangeControl ctl, PatchSet.Id psId, Account.Id accountId) throws OrmException {
     if (!migration.readChanges()) {
-      return sortApprovals(
-          db.patchSetApprovals().byPatchSetUser(psId, accountId));
+      return sortApprovals(db.patchSetApprovals().byPatchSetUser(psId, accountId));
     }
     return filterApprovals(byPatchSet(db, ctl, psId), accountId);
   }
 
-  public PatchSetApproval getSubmitter(ReviewDb db, ChangeNotes notes,
-      PatchSet.Id c) {
+  public PatchSetApproval getSubmitter(ReviewDb db, ChangeNotes notes, PatchSet.Id c) {
     if (c == null) {
       return null;
     }
@@ -345,16 +404,14 @@
     }
   }
 
-  public static PatchSetApproval getSubmitter(PatchSet.Id c,
-      Iterable<PatchSetApproval> approvals) {
+  public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) {
     if (c == null) {
       return null;
     }
     PatchSetApproval submitter = null;
     for (PatchSetApproval a : approvals) {
       if (a.getPatchSetId().equals(c) && a.getValue() > 0 && a.isLegacySubmit()) {
-        if (submitter == null
-            || a.getGranted().compareTo(submitter.getGranted()) > 0) {
+        if (submitter == null || a.getGranted().compareTo(submitter.getGranted()) > 0) {
           submitter = a;
         }
       }
@@ -362,22 +419,20 @@
     return submitter;
   }
 
-  public static String renderMessageWithApprovals(int patchSetId,
-      Map<String, Short> n, Map<String, PatchSetApproval> c) {
+  public static String renderMessageWithApprovals(
+      int patchSetId, Map<String, Short> n, Map<String, PatchSetApproval> c) {
     StringBuilder msgs = new StringBuilder("Uploaded patch set " + patchSetId);
     if (!n.isEmpty()) {
       boolean first = true;
       for (Map.Entry<String, Short> e : n.entrySet()) {
-        if (c.containsKey(e.getKey())
-            && c.get(e.getKey()).getValue() == e.getValue()) {
+        if (c.containsKey(e.getKey()) && c.get(e.getKey()).getValue() == e.getValue()) {
           continue;
         }
         if (first) {
           msgs.append(":");
           first = false;
         }
-        msgs.append(" ")
-            .append(LabelVote.create(e.getKey(), e.getValue()).format());
+        msgs.append(" ").append(LabelVote.create(e.getKey(), e.getValue()).format());
       }
     }
     return msgs.toString();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
index bc6f732..0d5c61c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Optional;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -26,10 +26,10 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class ChangeFinder {
@@ -40,18 +40,24 @@
     this.queryProvider = queryProvider;
   }
 
+  public ChangeControl findOne(String id, CurrentUser user) throws OrmException {
+    List<ChangeControl> ctls = find(id, user);
+    if (ctls.size() != 1) {
+      return null;
+    }
+    return ctls.get(0);
+  }
+
   /**
    * Find changes matching the given identifier.
    *
-   * @param id change identifier, either a numeric ID, a Change-Id, or
-   *     project~branch~id triplet.
+   * @param id change identifier, either a numeric ID, a Change-Id, or project~branch~id triplet.
    * @param user user to wrap in controls.
-   * @return possibly-empty list of controls for all matching changes,
-   *     corresponding to the given user; may or may not be visible.
+   * @return possibly-empty list of controls for all matching changes, corresponding to the given
+   *     user; may or may not be visible.
    * @throws OrmException if an error occurred querying the database.
    */
-  public List<ChangeControl> find(String id, CurrentUser user)
-      throws OrmException {
+  public List<ChangeControl> find(String id, CurrentUser user) throws OrmException {
     // Use the index to search for changes, but don't return any stored fields,
     // to force rereading in case the index is stale.
     InternalChangeQuery query = queryProvider.get().noFields();
@@ -64,6 +70,11 @@
       }
     }
 
+    // Try commit hash
+    if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
+      return asChangeControls(query.byCommit(id), user);
+    }
+
     // Try isolated changeId
     if (!id.contains("~")) {
       return asChangeControls(query.byKeyPrefix(id), user);
@@ -72,17 +83,13 @@
     // Try change triplet
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id);
     if (triplet.isPresent()) {
-      return asChangeControls(query.byBranchKey(
-          triplet.get().branch(),
-          triplet.get().id()),
-          user);
+      return asChangeControls(query.byBranchKey(triplet.get().branch(), triplet.get().id()), user);
     }
 
     return Collections.emptyList();
   }
 
-  public ChangeControl findOne(Change.Id id, CurrentUser user)
-      throws OrmException, NoSuchChangeException {
+  public ChangeControl findOne(Change.Id id, CurrentUser user) throws OrmException {
     List<ChangeControl> ctls = find(id, user);
     if (ctls.size() != 1) {
       throw new NoSuchChangeException(id);
@@ -90,16 +97,15 @@
     return ctls.get(0);
   }
 
-  public List<ChangeControl> find(Change.Id id, CurrentUser user)
-      throws OrmException {
+  public List<ChangeControl> find(Change.Id id, CurrentUser user) throws OrmException {
     // Use the index to search for changes, but don't return any stored fields,
     // to force rereading in case the index is stale.
     InternalChangeQuery query = queryProvider.get().noFields();
     return asChangeControls(query.byLegacyChangeId(id), user);
   }
 
-  private List<ChangeControl> asChangeControls(List<ChangeData> cds,
-      CurrentUser user) throws OrmException {
+  private List<ChangeControl> asChangeControls(List<ChangeData> cds, CurrentUser user)
+      throws OrmException {
     List<ChangeControl> ctls = new ArrayList<>(cds.size());
     for (ChangeData cd : cds) {
       ctls.add(cd.changeControl(user));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
index f3fdbcb..d277bf9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -14,33 +14,71 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 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.update.ChangeContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 
 /**
  * Utility functions to manipulate ChangeMessages.
- * <p>
- * These methods either query for and update ChangeMessages in the NoteDb or
- * ReviewDb, depending on the state of the NotesMigration.
+ *
+ * <p>These methods either query for and update ChangeMessages in the NoteDb or ReviewDb, depending
+ * on the state of the NotesMigration.
  */
 @Singleton
 public class ChangeMessagesUtil {
-  private static List<ChangeMessage> sortChangeMessages(
-      Iterable<ChangeMessage> changeMessage) {
+  public static final String TAG_ABANDON = "autogenerated:gerrit:abandon";
+  public static final String TAG_CHERRY_PICK_CHANGE = "autogenerated:gerrit:cherryPickChange";
+  public static final String TAG_DELETE_ASSIGNEE = "autogenerated:gerrit:deleteAssignee";
+  public static final String TAG_DELETE_REVIEWER = "autogenerated:gerrit:deleteReviewer";
+  public static final String TAG_DELETE_VOTE = "autogenerated:gerrit:deleteVote";
+  public static final String TAG_MERGED = "autogenerated:gerrit:merged";
+  public static final String TAG_MOVE = "autogenerated:gerrit:move";
+  public static final String TAG_RESTORE = "autogenerated:gerrit:restore";
+  public static final String TAG_REVERT = "autogenerated:gerrit:revert";
+  public static final String TAG_SET_ASSIGNEE = "autogenerated:gerrit:setAssignee";
+  public static final String TAG_SET_DESCRIPTION = "autogenerated:gerrit:setPsDescription";
+  public static final String TAG_SET_HASHTAGS = "autogenerated:gerrit:setHashtag";
+  public static final String TAG_SET_TOPIC = "autogenerated:gerrit:setTopic";
+  public static final String TAG_UPLOADED_PATCH_SET = "autogenerated:gerrit:newPatchSet";
+
+  public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
+    return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
+  }
+
+  public static ChangeMessage newMessage(
+      PatchSet.Id psId, CurrentUser user, Timestamp when, String body, @Nullable String tag) {
+    checkNotNull(psId);
+    Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
+    ChangeMessage m =
+        new ChangeMessage(
+            new ChangeMessage.Key(psId.getParentKey(), ChangeUtil.messageUuid()),
+            accountId,
+            when,
+            psId);
+    m.setMessage(body);
+    m.setTag(tag);
+    user.updateRealAccountId(m::setRealAuthor);
+    return m;
+  }
+
+  private static List<ChangeMessage> sortChangeMessages(Iterable<ChangeMessage> changeMessage) {
     return ChangeNotes.MESSAGE_BY_TIME.sortedCopy(changeMessage);
   }
 
@@ -54,26 +92,26 @@
 
   public List<ChangeMessage> byChange(ReviewDb db, ChangeNotes notes) throws OrmException {
     if (!migration.readChanges()) {
-      return
-          sortChangeMessages(db.changeMessages().byChange(notes.getChangeId()));
+      return sortChangeMessages(db.changeMessages().byChange(notes.getChangeId()));
     }
     return notes.load().getChangeMessages();
   }
 
-  public Iterable<ChangeMessage> byPatchSet(ReviewDb db, ChangeNotes notes,
-      PatchSet.Id psId) throws OrmException {
+  public Iterable<ChangeMessage> byPatchSet(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
+      throws OrmException {
     if (!migration.readChanges()) {
       return db.changeMessages().byPatchSet(psId);
     }
     return notes.load().getChangeMessagesByPatchSet().get(psId);
   }
 
-  public void addChangeMessage(ReviewDb db, ChangeUpdate update,
-      ChangeMessage changeMessage) throws OrmException {
+  public void addChangeMessage(ReviewDb db, ChangeUpdate update, ChangeMessage changeMessage)
+      throws OrmException {
     checkState(
         Objects.equals(changeMessage.getAuthor(), update.getNullableAccountId()),
         "cannot store change message by %s in update by %s",
-        changeMessage.getAuthor(), update.getNullableAccountId());
+        changeMessage.getAuthor(),
+        update.getNullableAccountId());
     update.setChangeMessage(changeMessage.getMessage());
     update.setTag(changeMessage.getTag());
     db.changeMessages().insert(Collections.singleton(changeMessage));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
index 11a3d81..2859949 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
@@ -14,69 +14,45 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.util.IdGenerator;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Singleton;
+import static java.util.Comparator.comparingInt;
 
+import com.google.common.collect.Ordering;
+import com.google.common.io.BaseEncoding;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.Map;
+import java.util.Random;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-import java.util.Map;
-
 @Singleton
 public class ChangeUtil {
-  private static final Object uuidLock = new Object();
-  private static final int SEED = 0x2418e6f9;
-  private static int uuidPrefix;
-  private static int uuidSeq;
+  private static final Random UUID_RANDOM = new SecureRandom();
+  private static final BaseEncoding UUID_ENCODING = BaseEncoding.base16().lowerCase();
 
   private static final int SUBJECT_MAX_LENGTH = 80;
   private static final String SUBJECT_CROP_APPENDIX = "...";
   private static final int SUBJECT_CROP_RANGE = 10;
 
-  public static final Function<PatchSet, Integer> TO_PS_ID =
-      new Function<PatchSet, Integer>() {
-        @Override
-        public Integer apply(PatchSet in) {
-          return in.getId().get();
-        }
-      };
+  public static final Ordering<PatchSet> PS_ID_ORDER =
+      Ordering.from(comparingInt(PatchSet::getPatchSetId));
 
-  public static final Ordering<PatchSet> PS_ID_ORDER = Ordering.natural()
-    .onResultOf(TO_PS_ID);
-
-  /**
-   * Generate a new unique identifier for change message entities.
-   *
-   * @param db the database connection, used to increment the change message
-   *        allocation sequence.
-   * @return the new unique identifier.
-   * @throws OrmException the database couldn't be incremented.
-   */
-  public static String messageUUID(ReviewDb db) throws OrmException {
-    int p;
-    int s;
-    synchronized (uuidLock) {
-      if (uuidSeq == 0) {
-        uuidPrefix = db.nextChangeMessageId();
-        uuidSeq = Integer.MAX_VALUE;
-      }
-      p = uuidPrefix;
-      s = uuidSeq--;
-    }
-    String u = IdGenerator.format(IdGenerator.mix(SEED, p));
-    String l = IdGenerator.format(IdGenerator.mix(p, s));
-    return u + '_' + l;
+  public static String formatChangeUrl(String canonicalWebUrl, Change change) {
+    return canonicalWebUrl + change.getChangeId();
   }
 
-  public static PatchSet.Id nextPatchSetId(Map<String, Ref> allRefs,
-      PatchSet.Id id) {
+  /** @return a new unique identifier for change message entities. */
+  public static String messageUuid() {
+    byte[] buf = new byte[8];
+    UUID_RANDOM.nextBytes(buf);
+    return UUID_ENCODING.encode(buf, 0, 4) + '_' + UUID_ENCODING.encode(buf, 4, 4);
+  }
+
+  public static PatchSet.Id nextPatchSetId(Map<String, Ref> allRefs, PatchSet.Id id) {
     PatchSet.Id next = nextPatchSetId(id);
     while (allRefs.containsKey(next.toRefName())) {
       next = nextPatchSetId(next);
@@ -88,8 +64,7 @@
     return new PatchSet.Id(id.getParentKey(), id.get() + 1);
   }
 
-  public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id)
-      throws IOException {
+  public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id) throws IOException {
     return nextPatchSetId(git.getRefDatabase().getRefs(RefDatabase.ALL), id);
   }
 
@@ -97,7 +72,8 @@
     if (subject.length() > SUBJECT_MAX_LENGTH) {
       int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
       for (int cropPosition = maxLength;
-          cropPosition > maxLength - SUBJECT_CROP_RANGE; cropPosition--) {
+          cropPosition > maxLength - SUBJECT_CROP_RANGE;
+          cropPosition--) {
         if (Character.isWhitespace(subject.charAt(cropPosition - 1))) {
           return subject.substring(0, cropPosition) + SUBJECT_CROP_APPENDIX;
         }
@@ -107,6 +83,5 @@
     return subject;
   }
 
-  private ChangeUtil() {
-  }
+  private ChangeUtil() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java
index 282d51e..63f7202 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -32,16 +32,13 @@
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.OptionHandlerUtil;
 import com.google.gerrit.util.cli.OptionHandlers;
-
+import java.net.SocketAddress;
+import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.spi.OptionHandler;
 
-import java.net.SocketAddress;
-import java.sql.Timestamp;
-
 public class CmdLineParserModule extends FactoryModule {
-  public CmdLineParserModule() {
-  }
+  public CmdLineParserModule() {}
 
   @Override
   protected void configure() {
@@ -59,8 +56,7 @@
     registerOptionHandler(Timestamp.class, TimestampHandler.class);
   }
 
-  private <T> void registerOptionHandler(Class<T> type,
-      Class<? extends OptionHandler<T>> impl) {
+  private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
     install(OptionHandlerUtil.moduleFor(type, impl));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
new file mode 100644
index 0000000..8d2289a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
@@ -0,0 +1,503 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.StreamSupport;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Utility functions to manipulate Comments.
+ *
+ * <p>These methods either query for and update Comments in the NoteDb or ReviewDb, depending on the
+ * state of the NotesMigration.
+ */
+@Singleton
+public class CommentsUtil {
+  public static final Ordering<Comment> COMMENT_ORDER =
+      new Ordering<Comment>() {
+        @Override
+        public int compare(Comment c1, Comment c2) {
+          return ComparisonChain.start()
+              .compare(c1.key.filename, c2.key.filename)
+              .compare(c1.key.patchSetId, c2.key.patchSetId)
+              .compare(c1.side, c2.side)
+              .compare(c1.lineNbr, c2.lineNbr)
+              .compare(c1.writtenOn, c2.writtenOn)
+              .result();
+        }
+      };
+
+  public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
+      new Ordering<CommentInfo>() {
+        @Override
+        public int compare(CommentInfo a, CommentInfo b) {
+          return ComparisonChain.start()
+              .compare(a.path, b.path, NULLS_FIRST)
+              .compare(a.patchSet, b.patchSet, NULLS_FIRST)
+              .compare(side(a), side(b))
+              .compare(a.line, b.line, NULLS_FIRST)
+              .compare(a.inReplyTo, b.inReplyTo, NULLS_FIRST)
+              .compare(a.message, b.message)
+              .compare(a.id, b.id)
+              .result();
+        }
+
+        private int side(CommentInfo c) {
+          return firstNonNull(c.side, Side.REVISION).ordinal();
+        }
+      };
+
+  public static PatchSet.Id getCommentPsId(Change.Id changeId, Comment comment) {
+    return new PatchSet.Id(changeId, comment.key.patchSetId);
+  }
+
+  public static String extractMessageId(@Nullable String tag) {
+    if (tag == null || !tag.startsWith("mailMessageId=")) {
+      return null;
+    }
+    return tag.substring("mailMessageId=".length());
+  }
+
+  private static final Ordering<Comparable<?>> NULLS_FIRST = Ordering.natural().nullsFirst();
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final NotesMigration migration;
+  private final String serverId;
+
+  @Inject
+  CommentsUtil(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      NotesMigration migration,
+      @GerritServerId String serverId) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.migration = migration;
+    this.serverId = serverId;
+  }
+
+  public Comment newComment(
+      ChangeContext ctx,
+      String path,
+      PatchSet.Id psId,
+      short side,
+      String message,
+      @Nullable Boolean unresolved,
+      @Nullable String parentUuid)
+      throws OrmException, UnprocessableEntityException {
+    if (unresolved == null) {
+      if (parentUuid == null) {
+        // Default to false if comment is not descended from another.
+        unresolved = false;
+      } else {
+        // Inherit unresolved value from inReplyTo comment if not specified.
+        Comment.Key key = new Comment.Key(parentUuid, path, psId.patchSetId);
+        Optional<Comment> parent = get(ctx.getDb(), ctx.getNotes(), key);
+        if (!parent.isPresent()) {
+          throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
+        }
+        unresolved = parent.get().unresolved;
+      }
+    }
+    Comment c =
+        new Comment(
+            new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
+            ctx.getUser().getAccountId(),
+            ctx.getWhen(),
+            side,
+            message,
+            serverId,
+            unresolved);
+    c.parentUuid = parentUuid;
+    ctx.getUser().updateRealAccountId(c::setRealAuthor);
+    return c;
+  }
+
+  public RobotComment newRobotComment(
+      ChangeContext ctx,
+      String path,
+      PatchSet.Id psId,
+      short side,
+      String message,
+      String robotId,
+      String robotRunId) {
+    RobotComment c =
+        new RobotComment(
+            new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
+            ctx.getUser().getAccountId(),
+            ctx.getWhen(),
+            side,
+            message,
+            serverId,
+            robotId,
+            robotRunId);
+    ctx.getUser().updateRealAccountId(c::setRealAuthor);
+    return c;
+  }
+
+  public Optional<Comment> get(ReviewDb db, ChangeNotes notes, Comment.Key key)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return Optional.ofNullable(
+              db.patchComments().get(PatchLineComment.Key.from(notes.getChangeId(), key)))
+          .map(plc -> plc.asComment(serverId));
+    }
+    Predicate<Comment> p = c -> key.equals(c.key);
+    Optional<Comment> c = publishedByChange(db, notes).stream().filter(p).findFirst();
+    if (c.isPresent()) {
+      return c;
+    }
+    return draftByChange(db, notes).stream().filter(p).findFirst();
+  }
+
+  public List<Comment> publishedByChange(ReviewDb db, ChangeNotes notes) throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(
+          byCommentStatus(db.patchComments().byChange(notes.getChangeId()), Status.PUBLISHED));
+    }
+
+    notes.load();
+    return sort(Lists.newArrayList(notes.getComments().values()));
+  }
+
+  public List<RobotComment> robotCommentsByChange(ChangeNotes notes) throws OrmException {
+    if (!migration.readChanges()) {
+      return ImmutableList.of();
+    }
+
+    notes.load();
+    return sort(Lists.newArrayList(notes.getRobotComments().values()));
+  }
+
+  public List<Comment> draftByChange(ReviewDb db, ChangeNotes notes) throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(byCommentStatus(db.patchComments().byChange(notes.getChangeId()), Status.DRAFT));
+    }
+
+    List<Comment> comments = new ArrayList<>();
+    for (Ref ref : getDraftRefs(notes.getChangeId())) {
+      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+      if (account != null) {
+        comments.addAll(draftByChangeAuthor(db, notes, account));
+      }
+    }
+    return sort(comments);
+  }
+
+  private List<Comment> byCommentStatus(
+      ResultSet<PatchLineComment> comments, final PatchLineComment.Status status) {
+    return toComments(
+        serverId, Lists.newArrayList(Iterables.filter(comments, c -> c.getStatus() == status)));
+  }
+
+  public List<Comment> byPatchSet(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(toComments(serverId, db.patchComments().byPatchSet(psId).toList()));
+    }
+    List<Comment> comments = new ArrayList<>();
+    comments.addAll(publishedByPatchSet(db, notes, psId));
+
+    for (Ref ref : getDraftRefs(notes.getChangeId())) {
+      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+      if (account != null) {
+        comments.addAll(draftByPatchSetAuthor(db, psId, account, notes));
+      }
+    }
+    return sort(comments);
+  }
+
+  public List<Comment> publishedByChangeFile(
+      ReviewDb db, ChangeNotes notes, Change.Id changeId, String file) throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(
+          toComments(serverId, db.patchComments().publishedByChangeFile(changeId, file).toList()));
+    }
+    return commentsOnFile(notes.load().getComments().values(), file);
+  }
+
+  public List<Comment> publishedByPatchSet(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return removeCommentsOnAncestorOfCommitMessage(
+          sort(toComments(serverId, db.patchComments().publishedByPatchSet(psId).toList())));
+    }
+    return removeCommentsOnAncestorOfCommitMessage(
+        commentsOnPatchSet(notes.load().getComments().values(), psId));
+  }
+
+  public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return ImmutableList.of();
+    }
+    return commentsOnPatchSet(notes.load().getRobotComments().values(), psId);
+  }
+
+  /**
+   * For the commit message the A side in a diff view is always empty when a comparison against an
+   * ancestor is done, so there can't be any comments on this ancestor. However earlier we showed
+   * the auto-merge commit message on side A when for a merge commit a comparison against the
+   * auto-merge was done. From that time there may still be comments on the auto-merge commit
+   * message and those we want to filter out.
+   */
+  private List<Comment> removeCommentsOnAncestorOfCommitMessage(List<Comment> list) {
+    return list.stream()
+        .filter(c -> c.side != 0 || !Patch.COMMIT_MSG.equals(c.key.filename))
+        .collect(toList());
+  }
+
+  public List<Comment> draftByPatchSetAuthor(
+      ReviewDb db, PatchSet.Id psId, Account.Id author, ChangeNotes notes) throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(
+          toComments(serverId, db.patchComments().draftByPatchSetAuthor(psId, author).toList()));
+    }
+    return commentsOnPatchSet(notes.load().getDraftComments(author).values(), psId);
+  }
+
+  public List<Comment> draftByChangeFileAuthor(
+      ReviewDb db, ChangeNotes notes, String file, Account.Id author) throws OrmException {
+    if (!migration.readChanges()) {
+      return sort(
+          toComments(
+              serverId,
+              db.patchComments()
+                  .draftByChangeFileAuthor(notes.getChangeId(), file, author)
+                  .toList()));
+    }
+    return commentsOnFile(notes.load().getDraftComments(author).values(), file);
+  }
+
+  public List<Comment> draftByChangeAuthor(ReviewDb db, ChangeNotes notes, Account.Id author)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return StreamSupport.stream(db.patchComments().draftByAuthor(author).spliterator(), false)
+          .filter(c -> c.getPatchSetId().getParentKey().equals(notes.getChangeId()))
+          .map(plc -> plc.asComment(serverId))
+          .sorted(COMMENT_ORDER)
+          .collect(toList());
+    }
+    List<Comment> comments = new ArrayList<>();
+    comments.addAll(notes.getDraftComments(author).values());
+    return sort(comments);
+  }
+
+  @Deprecated // To be used only by HasDraftByLegacyPredicate.
+  public List<Change.Id> changesWithDraftsByAuthor(ReviewDb db, Account.Id author)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return FluentIterable.from(db.patchComments().draftByAuthor(author))
+          .transform(plc -> plc.getPatchSetId().getParentKey())
+          .toList();
+    }
+
+    List<Change.Id> changes = new ArrayList<>();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      for (String refName : repo.getRefDatabase().getRefs(RefNames.REFS_DRAFT_COMMENTS).keySet()) {
+        Account.Id accountId = Account.Id.fromRefSuffix(refName);
+        Change.Id changeId = Change.Id.fromRefPart(refName);
+        if (accountId == null || changeId == null) {
+          continue;
+        }
+        changes.add(changeId);
+      }
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return changes;
+  }
+
+  public void putComments(
+      ReviewDb db, ChangeUpdate update, PatchLineComment.Status status, Iterable<Comment> comments)
+      throws OrmException {
+    for (Comment c : comments) {
+      update.putComment(status, c);
+    }
+    db.patchComments().upsert(toPatchLineComments(update.getId(), status, comments));
+  }
+
+  public void putRobotComments(ChangeUpdate update, Iterable<RobotComment> comments) {
+    for (RobotComment c : comments) {
+      update.putRobotComment(c);
+    }
+  }
+
+  public void deleteComments(ReviewDb db, ChangeUpdate update, Iterable<Comment> comments)
+      throws OrmException {
+    for (Comment c : comments) {
+      update.deleteComment(c);
+    }
+    db.patchComments()
+        .delete(toPatchLineComments(update.getId(), PatchLineComment.Status.DRAFT, comments));
+  }
+
+  public void deleteAllDraftsFromAllUsers(Change.Id changeId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      for (Ref ref : getDraftRefs(repo, changeId)) {
+        bru.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+      }
+      bru.setRefLogMessage("Delete drafts from NoteDb", false);
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+      for (ReceiveCommand cmd : bru.getCommands()) {
+        if (cmd.getResult() != ReceiveCommand.Result.OK) {
+          throw new IOException(
+              String.format(
+                  "Failed to delete draft comment ref %s at %s: %s (%s)",
+                  cmd.getRefName(), cmd.getOldId(), cmd.getResult(), cmd.getMessage()));
+        }
+      }
+    }
+  }
+
+  private static List<Comment> commentsOnFile(Collection<Comment> allComments, String file) {
+    List<Comment> result = new ArrayList<>(allComments.size());
+    for (Comment c : allComments) {
+      String currentFilename = c.key.filename;
+      if (currentFilename.equals(file)) {
+        result.add(c);
+      }
+    }
+    return sort(result);
+  }
+
+  private static <T extends Comment> List<T> commentsOnPatchSet(
+      Collection<T> allComments, PatchSet.Id psId) {
+    List<T> result = new ArrayList<>(allComments.size());
+    for (T c : allComments) {
+      if (c.key.patchSetId == psId.get()) {
+        result.add(c);
+      }
+    }
+    return sort(result);
+  }
+
+  public static void setCommentRevId(Comment c, PatchListCache cache, Change change, PatchSet ps)
+      throws OrmException {
+    checkArgument(
+        c.key.patchSetId == ps.getId().get(),
+        "cannot set RevId for patch set %s on comment %s",
+        ps.getId(),
+        c);
+    if (c.revId == null) {
+      try {
+        if (Side.fromShort(c.side) == Side.PARENT) {
+          if (c.side < 0) {
+            c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
+          } else {
+            c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
+          }
+        } else {
+          c.revId = ps.getRevision().get();
+        }
+      } catch (PatchListNotAvailableException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
+
+  /**
+   * Get NoteDb draft refs for a change.
+   *
+   * <p>Works if NoteDb is not enabled, but the results are not meaningful.
+   *
+   * <p>This is just a simple ref scan, so the results may potentially include refs for zombie draft
+   * comments. A zombie draft is one which has been published but the write to delete the draft ref
+   * from All-Users failed.
+   *
+   * @param changeId change ID.
+   * @return raw refs from All-Users repo.
+   */
+  public Collection<Ref> getDraftRefs(Change.Id changeId) throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getDraftRefs(repo, changeId);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId) throws IOException {
+    return repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(changeId)).values();
+  }
+
+  private static <T extends Comment> List<T> sort(List<T> comments) {
+    Collections.sort(comments, COMMENT_ORDER);
+    return comments;
+  }
+
+  public static Iterable<PatchLineComment> toPatchLineComments(
+      Change.Id changeId, PatchLineComment.Status status, Iterable<Comment> comments) {
+    return FluentIterable.from(comments).transform(c -> PatchLineComment.from(changeId, status, c));
+  }
+
+  public static List<Comment> toComments(
+      final String serverId, Iterable<PatchLineComment> comments) {
+    return COMMENT_ORDER.sortedCopy(
+        FluentIterable.from(comments).transform(plc -> plc.asComment(serverId)));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommonConverters.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommonConverters.java
index be07bde..2b48169 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CommonConverters.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommonConverters.java
@@ -15,17 +15,15 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.extensions.common.GitPerson;
-
-import org.eclipse.jgit.lib.PersonIdent;
-
 import java.sql.Timestamp;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /**
  * Converters to classes in {@code com.google.gerrit.extensions.common}.
- * <p>
- * The server frequently needs to convert internal types to types exposed in the
- * extension API, but the converters themselves are not part of this API. This
- * class contains such converters as static utility methods.
+ *
+ * <p>The server frequently needs to convert internal types to types exposed in the extension API,
+ * but the converters themselves are not part of this API. This class contains such converters as
+ * static utility methods.
  */
 public class CommonConverters {
   public static GitPerson toGitPerson(PersonIdent ident) {
@@ -37,6 +35,5 @@
     return result;
   }
 
-  private CommonConverters() {
-  }
+  private CommonConverters() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
index 34a2d02..c6f10d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
@@ -17,13 +17,15 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.CapabilityControl;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.servlet.RequestScoped;
+import java.util.function.Consumer;
 
 /**
  * Information about the currently logged in user.
- * <p>
- * This is a {@link RequestScoped} property managed by Guice.
+ *
+ * <p>This is a {@link RequestScoped} property managed by Guice.
  *
  * @see AnonymousUser
  * @see IdentifiedUser
@@ -35,14 +37,14 @@
       return new PropertyKey<>();
     }
 
-    private PropertyKey() {
-    }
+    private PropertyKey() {}
   }
 
   private final CapabilityControl.Factory capabilityControlFactory;
   private AccessPath accessPath = AccessPath.UNKNOWN;
 
   private CapabilityControl capabilities;
+  private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
 
   protected CurrentUser(CapabilityControl.Factory capabilityControlFactory) {
     this.capabilityControlFactory = capabilityControlFactory;
@@ -59,26 +61,33 @@
 
   /**
    * Identity of the authenticated user.
-   * <p>
-   * In the normal case where a user authenticates as themselves
-   * {@code getRealUser() == this}.
-   * <p>
-   * If {@code X-Gerrit-RunAs} or {@code suexec} was used this method returns
-   * the identity of the account that has permission to act on behalf of this
-   * user.
+   *
+   * <p>In the normal case where a user authenticates as themselves {@code getRealUser() == this}.
+   *
+   * <p>If {@code X-Gerrit-RunAs} or {@code suexec} was used this method returns the identity of the
+   * account that has permission to act on behalf of this user.
    */
   public CurrentUser getRealUser() {
     return this;
   }
 
   /**
+   * If the {@link #getRealUser()} has an account ID associated with it, call the given setter with
+   * that ID.
+   */
+  public void updateRealAccountId(Consumer<Account.Id> setter) {
+    if (getRealUser().isIdentifiedUser()) {
+      setter.accept(getRealUser().getAccountId());
+    }
+  }
+
+  /**
    * Get the set of groups the user is currently a member of.
-   * <p>
-   * The returned set may be a subset of the user's actual groups; if the user's
-   * account is currently deemed to be untrusted then the effective group set is
-   * only the anonymous and registered user groups. To enable additional groups
-   * (and gain their granted permissions) the user must update their account to
-   * use only trusted authentication providers.
+   *
+   * <p>The returned set may be a subset of the user's actual groups; if the user's account is
+   * currently deemed to be untrusted then the effective group set is only the anonymous and
+   * registered user groups. To enable additional groups (and gain their granted permissions) the
+   * user must update their account to use only trusted authentication providers.
    *
    * @return active groups for this user.
    */
@@ -108,7 +117,11 @@
         getClass().getSimpleName() + " is not an IdentifiedUser");
   }
 
-  /** Return account ID if {@link #isIdentifiedUser} is true. */
+  /**
+   * Return account ID if {@link #isIdentifiedUser} is true.
+   *
+   * @throws UnsupportedOperationException if the user is not logged in.
+   */
   public Account.Id getAccountId() {
     throw new UnsupportedOperationException(
         getClass().getSimpleName() + " is not an IdentifiedUser");
@@ -136,6 +149,26 @@
    * @param key unique property key.
    * @param value value to store; or {@code null} to clear the value.
    */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {
+  public <T> void put(PropertyKey<T> key, @Nullable T value) {}
+
+  public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
+    put(lastLoginExternalIdPropertyKey, externalIdKey);
+  }
+
+  public ExternalId.Key getLastLoginExternalIdKey() {
+    return get(lastLoginExternalIdPropertyKey);
+  }
+
+  /**
+   * Checks if the current user has the same account id of another.
+   *
+   * <p>Provide a generic interface for allowing subclasses to define whether two accounts represent
+   * the same account id.
+   *
+   * @param other user to compare
+   * @return true if the two users have the same account id
+   */
+  public boolean hasSameAccountId(CurrentUser other) {
+    return false;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/EnableSignedPush.java b/gerrit-server/src/main/java/com/google/gerrit/server/EnableSignedPush.java
index 13942a67..154a783 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/EnableSignedPush.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/EnableSignedPush.java
@@ -17,13 +17,9 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
-/**
- * Marker on a boolean indicating whether signed push is enabled on the server.
- */
+/** Marker on a boolean indicating whether signed push is enabled on the server. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface EnableSignedPush {
-}
+public @interface EnableSignedPush {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdent.java b/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdent.java
index 07b49ee..5d259b3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdent.java
@@ -17,7 +17,6 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
@@ -26,5 +25,4 @@
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface GerritPersonIdent {
-}
+public @interface GerritPersonIdent {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java
index 2d9bbb9..95e9813c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.UserConfig;
@@ -31,17 +32,18 @@
 
   @Inject
   public GerritPersonIdentProvider(@GerritServerConfig final Config cfg) {
-    String name = cfg.getString("user", null, "name");
-    if (name == null) {
-      name = "Gerrit Code Review";
-    }
-    this.name = name;
-    email = cfg.get(UserConfig.KEY).getCommitterEmail();
+    StringBuilder name = new StringBuilder();
+    PersonIdent.appendSanitized(
+        name, firstNonNull(cfg.getString("user", null, "name"), "Gerrit Code Review"));
+    this.name = name.toString();
+
+    StringBuilder email = new StringBuilder();
+    PersonIdent.appendSanitized(email, cfg.get(UserConfig.KEY).getCommitterEmail());
+    this.email = email.toString();
   }
 
   @Override
   public PersonIdent get() {
     return new PersonIdent(name, email);
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/GpgException.java b/gerrit-server/src/main/java/com/google/gerrit/server/GpgException.java
index 5ed27b5..a34b75c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/GpgException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/GpgException.java
@@ -30,4 +30,3 @@
     super(message, cause);
   }
 }
-
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 168dbf7..41b7c67 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
@@ -31,13 +32,11 @@
 import com.google.gerrit.server.config.DisableReverseDnsLookup;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
+import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.util.SystemReader;
-
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.MalformedURLException;
@@ -48,6 +47,8 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.TimeZone;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.util.SystemReader;
 
 /** An authenticated user. */
 public class IdentifiedUser extends CurrentUser {
@@ -84,9 +85,17 @@
     }
 
     public IdentifiedUser create(AccountState state) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, Providers.of((SocketAddress) null), state,
+      return new IdentifiedUser(
+          capabilityControlFactory,
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          disableReverseDnsLookup,
+          Providers.of((SocketAddress) null),
+          state,
           null);
     }
 
@@ -98,19 +107,28 @@
       return runAs(remotePeer, id, null);
     }
 
-    public IdentifiedUser runAs(SocketAddress remotePeer, Account.Id id,
-        @Nullable CurrentUser caller) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, Providers.of(remotePeer), id, caller);
+    public IdentifiedUser runAs(
+        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+      return new IdentifiedUser(
+          capabilityControlFactory,
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          disableReverseDnsLookup,
+          Providers.of(remotePeer),
+          id,
+          caller);
     }
   }
 
   /**
    * Create an IdentifiedUser, relying on current request state.
-   * <p>
-   * Can only be used from within a module that has defined request scoped
-   * {@code @RemotePeer SocketAddress} and {@code ReviewDb} providers.
+   *
+   * <p>Can only be used from within a module that has defined request scoped {@code @RemotePeer
+   * SocketAddress} and {@code ReviewDb} providers.
    */
   @Singleton
   public static class RequestFactory {
@@ -147,22 +165,39 @@
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, remotePeerProvider, id, null);
+      return new IdentifiedUser(
+          capabilityControlFactory,
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          disableReverseDnsLookup,
+          remotePeerProvider,
+          id,
+          null);
     }
 
     public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
-      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
-          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
-          disableReverseDnsLookup, remotePeerProvider, id, caller);
+      return new IdentifiedUser(
+          capabilityControlFactory,
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          disableReverseDnsLookup,
+          remotePeerProvider,
+          id,
+          caller);
     }
   }
 
   private static final GroupMembership registeredGroups =
-      new ListGroupMembership(ImmutableSet.of(
-          SystemGroupBackend.ANONYMOUS_USERS,
-          SystemGroupBackend.REGISTERED_USERS));
+      new ListGroupMembership(
+          ImmutableSet.of(SystemGroupBackend.ANONYMOUS_USERS, SystemGroupBackend.REGISTERED_USERS));
 
   private final Provider<String> canonicalUrl;
   private final AccountCache accountCache;
@@ -171,8 +206,7 @@
   private final GroupBackend groupBackend;
   private final String anonymousCowardName;
   private final Boolean disableReverseDnsLookup;
-  private final Set<String> validEmails =
-      Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
+  private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
 
   private final Provider<SocketAddress> remotePeerProvider;
   private final Account.Id accountId;
@@ -196,9 +230,18 @@
       @Nullable Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
-    this(capabilityControlFactory, authConfig, realm, anonymousCowardName,
-        canonicalUrl, accountCache, groupBackend, disableReverseDnsLookup,
-        remotePeerProvider, state.getAccount().getId(), realUser);
+    this(
+        capabilityControlFactory,
+        authConfig,
+        realm,
+        anonymousCowardName,
+        canonicalUrl,
+        accountCache,
+        groupBackend,
+        disableReverseDnsLookup,
+        remotePeerProvider,
+        state.getAccount().getId(),
+        realUser);
     this.state = state;
   }
 
@@ -321,20 +364,9 @@
     if (user == null) {
       user = "";
     }
-    user = user + "|" + "account-" + ua.getId().toString();
+    user = user + "|account-" + ua.getId().toString();
 
-    String host = null;
-    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";
-    }
-
-    return new PersonIdent(name, user + "@" + host, when, tz);
+    return new PersonIdent(name, user + "@" + guessHost(), when, tz);
   }
 
   public PersonIdent newCommitterIdent(final Date when, final TimeZone tz) {
@@ -424,6 +456,72 @@
     }
   }
 
+  /**
+   * Returns a materialized copy of the user with all dependencies.
+   *
+   * <p>Invoke all providers and factories of dependent objects and store the references to a copy
+   * of the current identified user.
+   *
+   * @return copy of the identified user
+   */
+  public IdentifiedUser materializedCopy() {
+    CapabilityControl capabilities = getCapabilities();
+    Provider<SocketAddress> remotePeer;
+    try {
+      remotePeer = Providers.of(remotePeerProvider.get());
+    } catch (OutOfScopeException | ProvisionException e) {
+      remotePeer =
+          new Provider<SocketAddress>() {
+            @Override
+            public SocketAddress get() {
+              throw e;
+            }
+          };
+    }
+    return new IdentifiedUser(
+        new CapabilityControl.Factory() {
+
+          @Override
+          public CapabilityControl create(CurrentUser user) {
+            return capabilities;
+          }
+        },
+        authConfig,
+        realm,
+        anonymousCowardName,
+        Providers.of(canonicalUrl.get()),
+        accountCache,
+        groupBackend,
+        disableReverseDnsLookup,
+        remotePeer,
+        state,
+        realUser);
+  }
+
+  @Override
+  public boolean hasSameAccountId(CurrentUser other) {
+    return getAccountId().get() == other.getAccountId().get();
+  }
+
+  private String guessHost() {
+    String host = null;
+    SocketAddress remotePeer = null;
+    try {
+      remotePeer = remotePeerProvider.get();
+    } catch (OutOfScopeException | ProvisionException e) {
+      // Leave null.
+    }
+    if (remotePeer instanceof InetSocketAddress) {
+      InetSocketAddress sa = (InetSocketAddress) remotePeer;
+      InetAddress in = sa.getAddress();
+      host = in != null ? getHost(in) : sa.getHostName();
+    }
+    if (Strings.isNullOrEmpty(host)) {
+      return "unknown";
+    }
+    return host;
+  }
+
   private String getHost(final InetAddress in) {
     if (Boolean.FALSE.equals(disableReverseDnsLookup)) {
       return in.getCanonicalHostName();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
index 02d41f4..bc99ec1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
@@ -21,11 +21,10 @@
 
 /**
  * User identity for plugin code that needs an identity.
- * <p>
- * An InternalUser has no real identity, it acts as the server and can access
- * anything it wants, anytime it wants, given the JVM's own direct access to
- * data. Plugins may use this when they need to have a CurrentUser with read
- * permission on anything.
+ *
+ * <p>An InternalUser has no real identity, it acts as the server and can access anything it wants,
+ * anytime it wants, given the JVM's own direct access to data. Plugins may use this when they need
+ * to have a CurrentUser with read permission on anything.
  *
  * @see PluginUser
  */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/LibModuleLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/LibModuleLoader.java
new file mode 100644
index 0000000..4ec7d2d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/LibModuleLoader.java
@@ -0,0 +1,61 @@
+// 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 java.util.stream.Collectors.toList;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import com.google.inject.ProvisionException;
+import java.util.Arrays;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Loads configured Guice modules from {@code gerrit.installModule}. */
+public class LibModuleLoader {
+  private static final Logger log = LoggerFactory.getLogger(LibModuleLoader.class);
+
+  public static List<Module> loadModules(Injector parent) {
+    Config cfg = getConfig(parent);
+    return Arrays.stream(cfg.getStringList("gerrit", null, "installModule"))
+        .map(m -> createModule(parent, m))
+        .collect(toList());
+  }
+
+  private static Config getConfig(Injector i) {
+    return i.getInstance(Key.get(Config.class, GerritServerConfig.class));
+  }
+
+  private static Module createModule(Injector injector, String className) {
+    Module m = injector.getInstance(loadModule(className));
+    log.info("Installed module {}", className);
+    return m;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Class<Module> loadModule(String className) {
+    try {
+      return (Class<Module>) Class.forName(className);
+    } catch (ClassNotFoundException | LinkageError e) {
+      String msg = "Cannot load LibModule " + className;
+      log.error(msg, e);
+      throw new ProvisionException(msg, e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ModuleImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/ModuleImpl.java
new file mode 100644
index 0000000..1614755
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ModuleImpl.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Retention(RUNTIME)
+@Target(TYPE)
+@Inherited
+/**
+ * Use this annotation to mark module as being swappable with implementation from {@code
+ * gerrit.installModule}. Note that module with this annotation shouldn't be part of circular
+ * dependency with any existing module.
+ */
+public @interface ModuleImpl {
+  String name();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ModuleOverloader.java b/gerrit-server/src/main/java/com/google/gerrit/server/ModuleOverloader.java
new file mode 100644
index 0000000..9a8fe84
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ModuleOverloader.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.inject.Module;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class ModuleOverloader {
+  public static List<Module> override(List<Module> modules, List<Module> overrideCandidates) {
+    if (overrideCandidates == null || overrideCandidates.isEmpty()) {
+      return modules;
+    }
+
+    // group candidates by annotation existence
+    Map<Boolean, List<Module>> grouped =
+        overrideCandidates.stream()
+            .collect(
+                Collectors.groupingBy(m -> m.getClass().getAnnotation(ModuleImpl.class) != null));
+
+    // add all non annotated libs to modules list
+    List<Module> libs = grouped.get(Boolean.FALSE);
+    if (libs != null) {
+      modules.addAll(libs);
+    }
+
+    List<Module> overrides = grouped.get(Boolean.TRUE);
+    if (overrides == null) {
+      return modules;
+    }
+
+    // swipe cache implementation with alternative provided in lib
+    return modules.stream()
+        .map(
+            m -> {
+              ModuleImpl a = m.getClass().getAnnotation(ModuleImpl.class);
+              if (a == null) {
+                return m;
+              }
+              return overrides.stream()
+                  .filter(
+                      o ->
+                          o.getClass()
+                              .getAnnotation(ModuleImpl.class)
+                              .name()
+                              .equalsIgnoreCase(a.name()))
+                  .findFirst()
+                  .orElse(m);
+            })
+        .collect(Collectors.toList());
+  }
+
+  private ModuleOverloader() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java
index 24d10f7..7b317cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/OptionUtil.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 
@@ -24,18 +23,9 @@
   private static final Splitter COMMA_OR_SPACE =
       Splitter.on(CharMatcher.anyOf(", ")).omitEmptyStrings().trimResults();
 
-  private static final Function<String, String> TO_LOWER_CASE =
-      new Function<String, String>() {
-        @Override
-        public String apply(String input) {
-          return input.toLowerCase();
-        }
-      };
-
   public static Iterable<String> splitOptionValue(String value) {
-    return Iterables.transform(COMMA_OR_SPACE.split(value), TO_LOWER_CASE);
+    return Iterables.transform(COMMA_OR_SPACE.split(value), String::toLowerCase);
   }
 
-  private OptionUtil() {
-  }
+  private OptionUtil() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/OutputFormat.java b/gerrit-server/src/main/java/com/google/gerrit/server/OutputFormat.java
index 7e1ec4b..e555845 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/OutputFormat.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/OutputFormat.java
@@ -18,30 +18,28 @@
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gwtjsonrpc.server.SqlTimestampDeserializer;
-
 import java.sql.Timestamp;
 
 /** Standard output format used by an API call. */
 public enum OutputFormat {
   /**
-   * The output is a human readable text format. It may also be regular enough
-   * to be machine readable. Whether or not the text format is machine readable
-   * and will be committed to as a long term format that tools can build upon is
-   * specific to each API call.
+   * The output is a human readable text format. It may also be regular enough to be machine
+   * readable. Whether or not the text format is machine readable and will be committed to as a long
+   * term format that tools can build upon is specific to each API call.
    */
   TEXT,
 
   /**
-   * Pretty-printed JSON format. This format uses whitespace to make the output
-   * readable by a human, but is also machine readable with a JSON library. The
-   * structure of the output is a long term format that tools can rely upon.
+   * Pretty-printed JSON format. This format uses whitespace to make the output readable by a human,
+   * but is also machine readable with a JSON library. The structure of the output is a long term
+   * format that tools can rely upon.
    */
   JSON,
 
   /**
-   * Same as {@link #JSON}, but with unnecessary whitespace removed to save
-   * generation time and copy costs. Typically JSON_COMPACT format is used by a
-   * browser based HTML client running over the network.
+   * Same as {@link #JSON}, but with unnecessary whitespace removed to save generation time and copy
+   * costs. Typically JSON_COMPACT format is used by a browser based HTML client running over the
+   * network.
    */
   JSON_COMPACT;
 
@@ -55,9 +53,10 @@
     if (!isJson()) {
       throw new IllegalStateException(String.format("%s is not JSON", this));
     }
-    GsonBuilder gb = new GsonBuilder()
-      .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
-      .registerTypeAdapter(Timestamp.class, new SqlTimestampDeserializer());
+    GsonBuilder gb =
+        new GsonBuilder()
+            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+            .registerTypeAdapter(Timestamp.class, new SqlTimestampDeserializer());
     if (this == OutputFormat.JSON) {
       gb.setPrettyPrinting();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
deleted file mode 100644
index 603f528..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
+++ /dev/null
@@ -1,405 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-import com.google.gerrit.reviewdb.client.PatchSet;
-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.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.DraftCommentNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Utility functions to manipulate PatchLineComments.
- * <p>
- * These methods either query for and update PatchLineComments in the NoteDb or
- * ReviewDb, depending on the state of the NotesMigration.
- */
-@Singleton
-public class PatchLineCommentsUtil {
-  public static final Ordering<PatchLineComment> PLC_ORDER =
-      new Ordering<PatchLineComment>() {
-    @Override
-    public int compare(PatchLineComment c1, PatchLineComment c2) {
-      String filename1 = c1.getKey().getParentKey().get();
-      String filename2 = c2.getKey().getParentKey().get();
-      return ComparisonChain.start()
-          .compare(filename1, filename2)
-          .compare(getCommentPsId(c1).get(), getCommentPsId(c2).get())
-          .compare(c1.getSide(), c2.getSide())
-          .compare(c1.getLine(), c2.getLine())
-          .compare(c1.getWrittenOn(), c2.getWrittenOn())
-          .result();
-    }
-  };
-
-  public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
-      new Ordering<CommentInfo>() {
-        @Override
-        public int compare(CommentInfo a, CommentInfo b) {
-          return ComparisonChain.start()
-              .compare(a.path, b.path, NULLS_FIRST)
-              .compare(a.patchSet, b.patchSet, NULLS_FIRST)
-              .compare(side(a), side(b))
-              .compare(a.line, b.line, NULLS_FIRST)
-              .compare(a.id, b.id)
-              .result();
-        }
-
-        private int side(CommentInfo c) {
-          return firstNonNull(c.side, Side.REVISION).ordinal();
-        }
-      };
-
-  public static PatchSet.Id getCommentPsId(PatchLineComment plc) {
-    return plc.getKey().getParentKey().getParentKey();
-  }
-
-  private static final Ordering<Comparable<?>> NULLS_FIRST =
-      Ordering.natural().nullsFirst();
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsers;
-  private final DraftCommentNotes.Factory draftFactory;
-  private final NotesMigration migration;
-
-  @Inject
-  PatchLineCommentsUtil(GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      DraftCommentNotes.Factory draftFactory,
-      NotesMigration migration) {
-    this.repoManager = repoManager;
-    this.allUsers = allUsers;
-    this.draftFactory = draftFactory;
-    this.migration = migration;
-  }
-
-  public Optional<PatchLineComment> get(ReviewDb db, ChangeNotes notes,
-      PatchLineComment.Key key) throws OrmException {
-    if (!migration.readChanges()) {
-      return Optional.fromNullable(db.patchComments().get(key));
-    }
-    for (PatchLineComment c : publishedByChange(db, notes)) {
-      if (key.equals(c.getKey())) {
-        return Optional.of(c);
-      }
-    }
-    for (PatchLineComment c : draftByChange(db, notes)) {
-      if (key.equals(c.getKey())) {
-        return Optional.of(c);
-      }
-    }
-    return Optional.absent();
-  }
-
-  public List<PatchLineComment> publishedByChange(ReviewDb db,
-      ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(byCommentStatus(
-          db.patchComments().byChange(notes.getChangeId()), Status.PUBLISHED));
-    }
-
-    notes.load();
-    List<PatchLineComment> comments = new ArrayList<>();
-    comments.addAll(notes.getComments().values());
-    return sort(comments);
-  }
-
-  public List<PatchLineComment> draftByChange(ReviewDb db,
-      ChangeNotes notes) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(byCommentStatus(
-          db.patchComments().byChange(notes.getChangeId()), Status.DRAFT));
-    }
-
-    List<PatchLineComment> comments = new ArrayList<>();
-    for (Ref ref : getDraftRefs(notes.getChangeId())) {
-      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-      if (account != null) {
-        comments.addAll(draftByChangeAuthor(db, notes, account));
-      }
-    }
-    return sort(comments);
-  }
-
-  private static List<PatchLineComment> byCommentStatus(
-      ResultSet<PatchLineComment> comments,
-      final PatchLineComment.Status status) {
-    return Lists.newArrayList(
-      Iterables.filter(comments, new Predicate<PatchLineComment>() {
-        @Override
-        public boolean apply(PatchLineComment input) {
-          return (input.getStatus() == status);
-        }
-      })
-    );
-  }
-
-  public List<PatchLineComment> byPatchSet(ReviewDb db,
-      ChangeNotes notes, PatchSet.Id psId) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(db.patchComments().byPatchSet(psId).toList());
-    }
-    List<PatchLineComment> comments = new ArrayList<>();
-    comments.addAll(publishedByPatchSet(db, notes, psId));
-
-    for (Ref ref : getDraftRefs(notes.getChangeId())) {
-      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-      if (account != null) {
-        comments.addAll(draftByPatchSetAuthor(db, psId, account, notes));
-      }
-    }
-    return sort(comments);
-  }
-
-  public List<PatchLineComment> publishedByChangeFile(ReviewDb db,
-      ChangeNotes notes, Change.Id changeId, String file) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          db.patchComments().publishedByChangeFile(changeId, file).toList());
-    }
-    return commentsOnFile(notes.load().getComments().values(), file);
-  }
-
-  public List<PatchLineComment> publishedByPatchSet(ReviewDb db,
-      ChangeNotes notes, PatchSet.Id psId) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          db.patchComments().publishedByPatchSet(psId).toList());
-    }
-    return commentsOnPatchSet(notes.load().getComments().values(), psId);
-  }
-
-  public List<PatchLineComment> draftByPatchSetAuthor(ReviewDb db,
-      PatchSet.Id psId, Account.Id author, ChangeNotes notes)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          db.patchComments().draftByPatchSetAuthor(psId, author).toList());
-    }
-    return commentsOnPatchSet(
-        notes.load().getDraftComments(author).values(), psId);
-  }
-
-  public List<PatchLineComment> draftByChangeFileAuthor(ReviewDb db,
-      ChangeNotes notes, String file, Account.Id author)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(
-          db.patchComments()
-            .draftByChangeFileAuthor(notes.getChangeId(), file, author)
-            .toList());
-    }
-    return commentsOnFile(
-        notes.load().getDraftComments(author).values(), file);
-  }
-
-  public List<PatchLineComment> draftByChangeAuthor(ReviewDb db,
-      ChangeNotes notes, Account.Id author)
-      throws OrmException {
-    if (!migration.readChanges()) {
-      final Change.Id matchId = notes.getChangeId();
-      return FluentIterable
-          .from(db.patchComments().draftByAuthor(author))
-          .filter(new Predicate<PatchLineComment>() {
-            @Override
-            public boolean apply(PatchLineComment in) {
-              Change.Id changeId =
-                  in.getKey().getParentKey().getParentKey().getParentKey();
-              return changeId.equals(matchId);
-            }
-          }).toSortedList(PLC_ORDER);
-    }
-    List<PatchLineComment> comments = new ArrayList<>();
-    comments.addAll(notes.getDraftComments(author).values());
-    return sort(comments);
-  }
-
-  @Deprecated // To be used only by HasDraftByLegacyPredicate.
-  public List<PatchLineComment> draftByAuthor(ReviewDb db,
-      Account.Id author) throws OrmException {
-    if (!migration.readChanges()) {
-      return sort(db.patchComments().draftByAuthor(author).toList());
-    }
-
-    List<PatchLineComment> comments = new ArrayList<>();
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      for (String refName : repo.getRefDatabase()
-          .getRefs(RefNames.REFS_DRAFT_COMMENTS).keySet()) {
-        Account.Id accountId = Account.Id.fromRefSuffix(refName);
-        Change.Id changeId = Change.Id.fromRefPart(refName);
-        if (accountId == null || changeId == null) {
-          continue;
-        }
-        // Avoid loading notes for all affected changes just to be able to auto-
-        // rebuild. This is only used in a corner case in the search codepath,
-        // so returning slightly stale values is ok.
-        DraftCommentNotes notes =
-            draftFactory.createWithAutoRebuildingDisabled(changeId, author);
-        comments.addAll(notes.load().getComments().values());
-      }
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-    return sort(comments);
-  }
-
-  public void putComments(ReviewDb db, ChangeUpdate update,
-      Iterable<PatchLineComment> comments) throws OrmException {
-    for (PatchLineComment c : comments) {
-      update.putComment(c);
-    }
-    db.patchComments().upsert(comments);
-  }
-
-  public void deleteComments(ReviewDb db, ChangeUpdate update,
-      Iterable<PatchLineComment> comments) throws OrmException {
-    for (PatchLineComment c : comments) {
-      update.deleteComment(c);
-    }
-    db.patchComments().delete(comments);
-  }
-
-  public void deleteAllDraftsFromAllUsers(Change.Id changeId)
-      throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
-      for (Ref ref : getDraftRefs(repo, changeId)) {
-        bru.addCommand(new ReceiveCommand(
-            ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
-      }
-      bru.setRefLogMessage("Delete drafts from NoteDb", false);
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-      for (ReceiveCommand cmd : bru.getCommands()) {
-        if (cmd.getResult() != ReceiveCommand.Result.OK) {
-          throw new IOException(String.format(
-              "Failed to delete draft comment ref %s at %s: %s (%s)",
-              cmd.getRefName(), cmd.getOldId(), cmd.getResult(),
-              cmd.getMessage()));
-        }
-      }
-    }
-  }
-
-  private static List<PatchLineComment> commentsOnFile(
-      Collection<PatchLineComment> allComments,
-      String file) {
-    List<PatchLineComment> result = new ArrayList<>(allComments.size());
-    for (PatchLineComment c : allComments) {
-      String currentFilename = c.getKey().getParentKey().getFileName();
-      if (currentFilename.equals(file)) {
-        result.add(c);
-      }
-    }
-    return sort(result);
-  }
-
-  private static List<PatchLineComment> commentsOnPatchSet(
-      Collection<PatchLineComment> allComments,
-      PatchSet.Id psId) {
-    List<PatchLineComment> result = new ArrayList<>(allComments.size());
-    for (PatchLineComment c : allComments) {
-      if (getCommentPsId(c).equals(psId)) {
-        result.add(c);
-      }
-    }
-    return sort(result);
-  }
-
-  public static RevId setCommentRevId(PatchLineComment c,
-      PatchListCache cache, Change change, PatchSet ps) throws OrmException {
-    checkArgument(c.getPatchSetId().equals(ps.getId()),
-        "cannot set RevId for patch set %s on comment %s", ps.getId(), c);
-    if (c.getRevId() == null) {
-      try {
-        if (Side.fromShort(c.getSide()) == Side.PARENT) {
-          if (c.getSide() < 0) {
-            c.setRevId(new RevId(ObjectId.toString(
-                cache.getOldId(change, ps, -c.getSide()))));
-          } else {
-            c.setRevId(new RevId(ObjectId.toString(
-                cache.getOldId(change, ps, null))));
-          }
-        } else {
-          c.setRevId(ps.getRevision());
-        }
-      } catch (PatchListNotAvailableException e) {
-        throw new OrmException(e);
-      }
-    }
-    return c.getRevId();
-  }
-
-  public Collection<Ref> getDraftRefs(Change.Id changeId)
-      throws OrmException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getDraftRefs(repo, changeId);
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId)
-      throws IOException {
-    return repo.getRefDatabase().getRefs(
-        RefNames.refsDraftCommentsPrefix(changeId)).values();
-  }
-
-  private static List<PatchLineComment> sort(List<PatchLineComment> comments) {
-    Collections.sort(comments, PLC_ORDER);
-    return comments;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
index 0dcf3bf..ab942ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
@@ -32,14 +32,12 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Utilities for manipulating patch sets. */
 @Singleton
@@ -51,13 +49,11 @@
     this.migration = migration;
   }
 
-  public PatchSet current(ReviewDb db, ChangeNotes notes)
-      throws OrmException {
+  public PatchSet current(ReviewDb db, ChangeNotes notes) throws OrmException {
     return get(db, notes, notes.getChange().currentPatchSetId());
   }
 
-  public PatchSet get(ReviewDb db, ChangeNotes notes, PatchSet.Id psId)
-      throws OrmException {
+  public PatchSet get(ReviewDb db, ChangeNotes notes, PatchSet.Id psId) throws OrmException {
     if (!migration.readChanges()) {
       return db.patchSets().get(psId);
     }
@@ -73,13 +69,12 @@
     return notes.load().getPatchSets().values();
   }
 
-  public ImmutableMap<PatchSet.Id, PatchSet> byChangeAsMap(ReviewDb db,
-        ChangeNotes notes) throws OrmException {
+  public ImmutableMap<PatchSet.Id, PatchSet> byChangeAsMap(ReviewDb db, ChangeNotes notes)
+      throws OrmException {
     if (!migration.readChanges()) {
-      ImmutableMap.Builder<PatchSet.Id, PatchSet> result =
-          ImmutableMap.builder();
-      for (PatchSet ps : ChangeUtil.PS_ID_ORDER.sortedCopy(
-          db.patchSets().byChange(notes.getChangeId()))) {
+      ImmutableMap.Builder<PatchSet.Id, PatchSet> result = ImmutableMap.builder();
+      for (PatchSet ps :
+          ChangeUtil.PS_ID_ORDER.sortedCopy(db.patchSets().byChange(notes.getChangeId()))) {
         result.put(ps.getId(), ps);
       }
       return result.build();
@@ -87,9 +82,16 @@
     return notes.load().getPatchSets();
   }
 
-  public PatchSet insert(ReviewDb db, RevWalk rw, ChangeUpdate update,
-      PatchSet.Id psId, ObjectId commit, boolean draft,
-      List<String> groups, String pushCertificate)
+  public PatchSet insert(
+      ReviewDb db,
+      RevWalk rw,
+      ChangeUpdate update,
+      PatchSet.Id psId,
+      ObjectId commit,
+      boolean draft,
+      List<String> groups,
+      String pushCertificate,
+      String description)
       throws OrmException, IOException {
     checkNotNull(groups, "groups may not be null");
     ensurePatchSetMatches(psId, update);
@@ -101,9 +103,11 @@
     ps.setDraft(draft);
     ps.setGroups(groups);
     ps.setPushCertificate(pushCertificate);
+    ps.setDescription(description);
     db.patchSets().insert(Collections.singleton(ps));
 
     update.setCommit(rw, commit, pushCertificate);
+    update.setPsDescription(description);
     update.setGroups(groups);
     if (draft) {
       update.setPatchSetState(DRAFT);
@@ -112,38 +116,40 @@
     return ps;
   }
 
-  public void publish(ReviewDb db, ChangeUpdate update, PatchSet ps)
-      throws OrmException {
+  public void publish(ReviewDb db, ChangeUpdate update, PatchSet ps) throws OrmException {
     ensurePatchSetMatches(ps.getId(), update);
     ps.setDraft(false);
     update.setPatchSetState(PUBLISHED);
     db.patchSets().update(Collections.singleton(ps));
   }
 
-  public void delete(ReviewDb db, ChangeUpdate update, PatchSet ps)
-      throws OrmException {
+  public void delete(ReviewDb db, ChangeUpdate update, PatchSet ps) throws OrmException {
     ensurePatchSetMatches(ps.getId(), update);
-    checkArgument(ps.isDraft(),
-        "cannot delete non-draft patch set %s", ps.getId());
+    checkArgument(ps.isDraft(), "cannot delete non-draft patch set %s", ps.getId());
     update.setPatchSetState(PatchSetState.DELETED);
     db.patchSets().delete(Collections.singleton(ps));
   }
 
   private void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
     Change.Id changeId = update.getChange().getId();
-    checkArgument(psId.getParentKey().equals(changeId),
-        "cannot modify patch set %s on update for change %s", psId, changeId);
+    checkArgument(
+        psId.getParentKey().equals(changeId),
+        "cannot modify patch set %s on update for change %s",
+        psId,
+        changeId);
     if (update.getPatchSetId() != null) {
-      checkArgument(update.getPatchSetId().equals(psId),
+      checkArgument(
+          update.getPatchSetId().equals(psId),
           "cannot modify patch set %s on update for %s",
-          psId, update.getPatchSetId());
+          psId,
+          update.getPatchSetId());
     } else {
       update.setPatchSetId(psId);
     }
   }
 
-  public void setGroups(ReviewDb db, ChangeUpdate update, PatchSet ps,
-      List<String> groups) throws OrmException {
+  public void setGroups(ReviewDb db, ChangeUpdate update, PatchSet ps, List<String> groups)
+      throws OrmException {
     ps.setGroups(groups);
     update.setGroups(groups);
     db.patchSets().update(Collections.singleton(ps));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
index 6616a66..263bb50 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.net.SocketAddress;
 
 /** Identity of a peer daemon process that isn't this JVM. */
@@ -33,8 +32,8 @@
   private final SocketAddress peer;
 
   @Inject
-  protected PeerDaemonUser(CapabilityControl.Factory capabilityControlFactory,
-      @Assisted SocketAddress peer) {
+  protected PeerDaemonUser(
+      CapabilityControl.Factory capabilityControlFactory, @Assisted SocketAddress peer) {
     super(capabilityControlFactory);
     this.peer = peer;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java
index 490ab07..13e04c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java
@@ -28,8 +28,7 @@
 
   @Inject
   protected PluginUser(
-      CapabilityControl.Factory capabilityControlFactory,
-      @Assisted String pluginName) {
+      CapabilityControl.Factory capabilityControlFactory, @Assisted String pluginName) {
     super(capabilityControlFactory);
     this.pluginName = pluginName;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java
index 40c5242..f3ab21d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java
@@ -16,12 +16,10 @@
 
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.git.GitRepositoryManager;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-
 public class ProjectUtil {
 
   /**
@@ -29,16 +27,14 @@
    *
    * @param repoManager Git repository manager to open the git repository
    * @param branch the branch for which it should be checked if it exists
-   * @return {@code true} if the specified branch exists or if
-   *         {@code HEAD} points to this branch, otherwise
-   *         {@code false}
-   * @throws RepositoryNotFoundException the repository of the branch's project
-   *         does not exist.
+   * @return {@code true} if the specified branch exists or if {@code HEAD} points to this branch,
+   *     otherwise {@code false}
+   * @throws RepositoryNotFoundException the repository of the branch's project does not exist.
    * @throws IOException error while retrieving the branch from the repository.
    */
-  public static boolean branchExists(final GitRepositoryManager repoManager,
-      final Branch.NameKey branch) throws RepositoryNotFoundException,
-      IOException {
+  public static boolean branchExists(
+      final GitRepositoryManager repoManager, final Branch.NameKey branch)
+      throws RepositoryNotFoundException, IOException {
     try (Repository repo = repoManager.openRepository(branch.getParentKey())) {
       boolean exists = repo.getRefDatabase().exactRef(branch.get()) != null;
       if (!exists) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/RemotePeer.java b/gerrit-server/src/main/java/com/google/gerrit/server/RemotePeer.java
index 77e6d43..4e7e04a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/RemotePeer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/RemotePeer.java
@@ -17,12 +17,10 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 import java.net.SocketAddress;
 
 /** Marker on a {@link SocketAddress} pointing to the remote client. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface RemotePeer {
-}
+public @interface RemotePeer {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java b/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java
index 6e12346..72b361c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java
@@ -15,22 +15,16 @@
 package com.google.gerrit.server;
 
 import com.google.inject.servlet.RequestScoped;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-
-/**
- * Registers cleanup activities to be completed when a scope ends.
- */
+/** Registers cleanup activities to be completed when a scope ends. */
 @RequestScoped
 public class RequestCleanup implements Runnable {
-  private static final Logger log =
-      LoggerFactory.getLogger(RequestCleanup.class);
+  private static final Logger log = LoggerFactory.getLogger(RequestCleanup.class);
 
   private final List<Runnable> cleanup = new LinkedList<>();
   private boolean ran;
@@ -49,7 +43,7 @@
   public void run() {
     synchronized (cleanup) {
       ran = true;
-      for (final Iterator<Runnable> i = cleanup.iterator(); i.hasNext();) {
+      for (final Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
         try {
           i.next().run();
         } catch (Throwable err) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
new file mode 100644
index 0000000..1d28e05
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
@@ -0,0 +1,280 @@
+// 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.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.change.SuggestReviewers;
+import com.google.gerrit.server.change.SuggestedReviewer;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+import org.apache.commons.lang.mutable.MutableDouble;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ReviewerRecommender {
+  private static final Logger log = LoggerFactory.getLogger(ReviewerRecommender.class);
+  private static final double BASE_REVIEWER_WEIGHT = 10;
+  private static final double BASE_OWNER_WEIGHT = 1;
+  private static final double BASE_COMMENT_WEIGHT = 0.5;
+  private static final double[] WEIGHTS =
+      new double[] {
+        BASE_REVIEWER_WEIGHT, BASE_OWNER_WEIGHT, BASE_COMMENT_WEIGHT,
+      };
+  private static final long PLUGIN_QUERY_TIMEOUT = 500; // ms
+
+  private final ChangeQueryBuilder changeQueryBuilder;
+  private final Config config;
+  private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
+  private final InternalChangeQuery internalChangeQuery;
+  private final WorkQueue workQueue;
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+
+  @Inject
+  ReviewerRecommender(
+      ChangeQueryBuilder changeQueryBuilder,
+      DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
+      InternalChangeQuery internalChangeQuery,
+      WorkQueue workQueue,
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      @GerritServerConfig Config config) {
+    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
+    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+    this.changeQueryBuilder = changeQueryBuilder;
+    this.config = config;
+    this.internalChangeQuery = internalChangeQuery;
+    this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
+    this.workQueue = workQueue;
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+  }
+
+  public List<Account.Id> suggestReviewers(
+      ChangeNotes changeNotes,
+      SuggestReviewers suggestReviewers,
+      ProjectControl projectControl,
+      List<Account.Id> candidateList)
+      throws OrmException {
+    String query = suggestReviewers.getQuery();
+    double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
+
+    Map<Account.Id, MutableDouble> reviewerScores;
+    if (Strings.isNullOrEmpty(query)) {
+      reviewerScores = baseRankingForEmptyQuery(baseWeight);
+    } else {
+      reviewerScores = baseRankingForCandidateList(candidateList, projectControl, baseWeight);
+    }
+
+    // Send the query along with a candidate list to all plugins and merge the
+    // results. Plugins don't necessarily need to use the candidates list, they
+    // can also return non-candidate account ids.
+    List<Callable<Set<SuggestedReviewer>>> tasks =
+        new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
+    List<Double> weights = new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
+
+    for (DynamicMap.Entry<ReviewerSuggestion> plugin : reviewerSuggestionPluginMap) {
+      tasks.add(
+          () ->
+              plugin
+                  .getProvider()
+                  .get()
+                  .suggestReviewers(
+                      projectControl.getProject().getNameKey(),
+                      changeNotes != null ? changeNotes.getChangeId() : null,
+                      query,
+                      reviewerScores.keySet()));
+      String key = plugin.getPluginName() + "-" + plugin.getExportName();
+      String pluginWeight = config.getString("addReviewer", key, "weight");
+      if (Strings.isNullOrEmpty(pluginWeight)) {
+        pluginWeight = "1";
+      }
+      log.debug("weight for {}: {}", key, pluginWeight);
+      try {
+        weights.add(Double.parseDouble(pluginWeight));
+      } catch (NumberFormatException e) {
+        log.error("Exception while parsing weight for {}", key, e);
+        weights.add(1d);
+      }
+    }
+
+    try {
+      List<Future<Set<SuggestedReviewer>>> futures =
+          workQueue.getDefaultQueue().invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
+      Iterator<Double> weightIterator = weights.iterator();
+      for (Future<Set<SuggestedReviewer>> f : futures) {
+        double weight = weightIterator.next();
+        for (SuggestedReviewer s : f.get()) {
+          if (reviewerScores.containsKey(s.account)) {
+            reviewerScores.get(s.account).add(s.score * weight);
+          } else {
+            reviewerScores.put(s.account, new MutableDouble(s.score * weight));
+          }
+        }
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      log.error("Exception while suggesting reviewers", e);
+      return ImmutableList.of();
+    }
+
+    if (changeNotes != null) {
+      // Remove change owner
+      reviewerScores.remove(changeNotes.getChange().getOwner());
+
+      // Remove existing reviewers
+      reviewerScores
+          .keySet()
+          .removeAll(approvalsUtil.getReviewers(dbProvider.get(), changeNotes).byState(REVIEWER));
+    }
+
+    // Sort results
+    Stream<Entry<Account.Id, MutableDouble>> sorted =
+        reviewerScores.entrySet().stream()
+            .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
+    List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
+    return sortedSuggestions;
+  }
+
+  private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(double baseWeight)
+      throws OrmException {
+    // Get the user's last 25 changes, check approvals
+    try {
+      List<ChangeData> result =
+          internalChangeQuery
+              .setLimit(25)
+              .setRequestedFields(ImmutableSet.of(ChangeField.REVIEWER.getName()))
+              .query(changeQueryBuilder.owner("self"));
+      Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
+      for (ChangeData cd : result) {
+        for (PatchSetApproval approval : cd.currentApprovals()) {
+          Account.Id id = approval.getAccountId();
+          if (suggestions.containsKey(id)) {
+            suggestions.get(id).add(baseWeight);
+          } else {
+            suggestions.put(id, new MutableDouble(baseWeight));
+          }
+        }
+      }
+      return suggestions;
+    } catch (QueryParseException e) {
+      // Unhandled, because owner:self will never provoke a QueryParseException
+      log.error("Exception while suggesting reviewers", e);
+      return ImmutableMap.of();
+    }
+  }
+
+  private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
+      List<Account.Id> candidates, ProjectControl projectControl, double baseWeight)
+      throws OrmException {
+    // Get each reviewer's activity based on number of applied labels
+    // (weighted 10d), number of comments (weighted 0.5d) and number of owned
+    // changes (weighted 1d).
+    Map<Account.Id, MutableDouble> reviewers = new LinkedHashMap<>();
+    if (candidates.size() == 0) {
+      return reviewers;
+    }
+    List<Predicate<ChangeData>> predicates = new ArrayList<>();
+    for (Account.Id id : candidates) {
+      try {
+        Predicate<ChangeData> projectQuery =
+            changeQueryBuilder.project(projectControl.getProject().getName());
+
+        // Get all labels for this project and create a compound OR query to
+        // fetch all changes where users have applied one of these labels
+        List<LabelType> labelTypes = projectControl.getLabelTypes().getLabelTypes();
+        List<Predicate<ChangeData>> labelPredicates = new ArrayList<>(labelTypes.size());
+        for (LabelType type : labelTypes) {
+          labelPredicates.add(changeQueryBuilder.label(type.getName() + ",user=" + id));
+        }
+        Predicate<ChangeData> reviewerQuery =
+            Predicate.and(projectQuery, Predicate.or(labelPredicates));
+
+        Predicate<ChangeData> ownerQuery =
+            Predicate.and(projectQuery, changeQueryBuilder.owner(id.toString()));
+        Predicate<ChangeData> commentedByQuery =
+            Predicate.and(projectQuery, changeQueryBuilder.commentby(id.toString()));
+
+        predicates.add(reviewerQuery);
+        predicates.add(ownerQuery);
+        predicates.add(commentedByQuery);
+        reviewers.put(id, new MutableDouble());
+      } catch (QueryParseException e) {
+        // Unhandled: If an exception is thrown, we won't increase the
+        // candidates's score
+        log.error("Exception while suggesting reviewers", e);
+      }
+    }
+
+    List<List<ChangeData>> result =
+        internalChangeQuery.setLimit(25).setRequestedFields(ImmutableSet.of()).query(predicates);
+
+    Iterator<List<ChangeData>> queryResultIterator = result.iterator();
+    Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
+
+    int i = 0;
+    Account.Id currentId = null;
+    while (queryResultIterator.hasNext()) {
+      List<ChangeData> currentResult = queryResultIterator.next();
+      if (i % WEIGHTS.length == 0) {
+        currentId = reviewersIterator.next();
+      }
+
+      reviewers.get(currentId).add(WEIGHTS[i % WEIGHTS.length] * baseWeight * currentResult.size());
+      i++;
+    }
+    return reviewers;
+  }
+}
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
index 515cef7..67b1d9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerSet.java
@@ -25,47 +25,45 @@
 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.
+ *
+ * <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());
+  private static final ReviewerSet EMPTY =
+      new ReviewerSet(ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>of());
 
-  public static ReviewerSet fromApprovals(
-      Iterable<PatchSetApproval> approvals) {
+  public static ReviewerSet fromApprovals(Iterable<PatchSetApproval> approvals) {
     PatchSetApproval first = null;
-    Table<ReviewerStateInternal, Account.Id, Timestamp> reviewers =
-        HashBasedTable.create();
+    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());
+            first
+                .getKey()
+                .getParentKey()
+                .getParentKey()
+                .equals(psa.getKey().getParentKey().getParentKey()),
+            "multiple change IDs: %s, %s",
+            first.getKey(),
+            psa.getKey());
       }
       Account.Id id = psa.getAccountId();
+      reviewers.put(REVIEWER, id, psa.getGranted());
       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) {
+  public static ReviewerSet fromTable(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
     return new ReviewerSet(table);
   }
 
@@ -73,8 +71,7 @@
     return EMPTY;
   }
 
-  private final ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>
-      table;
+  private final ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp> table;
   private ImmutableSet<Account.Id> accounts;
 
   private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
@@ -93,8 +90,7 @@
     return table.row(state).keySet();
   }
 
-  public ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>
-      asTable() {
+  public ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp> asTable() {
     return table;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java
index bbe4013..c4f3e2a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerStatusUpdate.java
@@ -17,20 +17,21 @@
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-
 import java.sql.Timestamp;
 
 /** Change to a reviewer's status. */
 @AutoValue
 public abstract class ReviewerStatusUpdate {
   public static ReviewerStatusUpdate create(
-      Timestamp ts, Account.Id updatedBy, Account.Id reviewer,
-      ReviewerStateInternal state) {
+      Timestamp ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) {
     return new AutoValue_ReviewerStatusUpdate(ts, updatedBy, reviewer, state);
   }
 
   public abstract Timestamp date();
+
   public abstract Account.Id updatedBy();
+
   public abstract Account.Id reviewer();
+
   public abstract ReviewerStateInternal state();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
index 3b43162..251390a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
@@ -14,31 +14,23 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.base.Function;
-import com.google.common.base.MoreObjects;
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountState;
@@ -46,8 +38,7 @@
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.SuggestReviewers;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.query.QueryParseException;
@@ -58,28 +49,22 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 public class ReviewersUtil {
-  private static final Logger log = LoggerFactory.getLogger(ReviewersUtil.class);
-
   @Singleton
   private static class Metrics {
     final Timer0 queryAccountsLatency;
+    final Timer0 recommendAccountsLatency;
+    final Timer0 loadAccountsLatency;
     final Timer0 queryGroupsLatency;
+    final Timer0 filterVisibility;
 
     @Inject
     Metrics(MetricMaker metricMaker) {
@@ -89,66 +74,65 @@
               new Description("Latency for querying accounts for reviewer suggestion")
                   .setCumulative()
                   .setUnit(Units.MILLISECONDS));
+      recommendAccountsLatency =
+          metricMaker.newTimer(
+              "reviewer_suggestion/recommend_accounts",
+              new Description("Latency for recommending accounts for reviewer suggestion")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      loadAccountsLatency =
+          metricMaker.newTimer(
+              "reviewer_suggestion/load_accounts",
+              new Description("Latency for loading accounts for reviewer suggestion")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
       queryGroupsLatency =
           metricMaker.newTimer(
               "reviewer_suggestion/query_groups",
               new Description("Latency for querying groups for reviewer suggestion")
                   .setCumulative()
                   .setUnit(Units.MILLISECONDS));
+      filterVisibility =
+          metricMaker.newTimer(
+              "reviewer_suggestion/filter_visibility",
+              new Description("Latency for removing users that can't see the change")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
     }
   }
 
-  private static final String MAX_SUFFIX = "\u9fa5";
-  private static final Ordering<SuggestedReviewerInfo> ORDERING =
-      Ordering.natural().onResultOf(new Function<SuggestedReviewerInfo, String>() {
-        @Nullable
-        @Override
-        public String apply(@Nullable SuggestedReviewerInfo suggestedReviewerInfo) {
-          if (suggestedReviewerInfo == null) {
-            return null;
-          }
-          return suggestedReviewerInfo.account != null
-              ? MoreObjects.firstNonNull(suggestedReviewerInfo.account.email,
-              Strings.nullToEmpty(suggestedReviewerInfo.account.name))
-              : Strings.nullToEmpty(suggestedReviewerInfo.group.name);
-        }
-      });
+  // Generate a candidate list at 2x the size of what the user wants to see to
+  // give the ranking algorithm a good set of candidates it can work with
+  private static final int CANDIDATE_LIST_MULTIPLIER = 2;
+
   private final AccountLoader accountLoader;
-  private final AccountCache accountCache;
-  private final AccountIndexCollection indexes;
-  private final AccountQueryBuilder queryBuilder;
-  private final AccountQueryProcessor queryProcessor;
-  private final AccountControl accountControl;
-  private final Provider<ReviewDb> dbProvider;
+  private final AccountQueryBuilder accountQueryBuilder;
+  private final AccountQueryProcessor accountQueryProcessor;
   private final GroupBackend groupBackend;
   private final GroupMembers.Factory groupMembersFactory;
   private final Provider<CurrentUser> currentUser;
+  private final ReviewerRecommender reviewerRecommender;
   private final Metrics metrics;
 
   @Inject
-  ReviewersUtil(AccountLoader.Factory accountLoaderFactory,
-      AccountCache accountCache,
-      AccountIndexCollection indexes,
-      AccountQueryBuilder queryBuilder,
-      AccountQueryProcessor queryProcessor,
-      AccountControl.Factory accountControlFactory,
-      Provider<ReviewDb> dbProvider,
+  ReviewersUtil(
+      AccountLoader.Factory accountLoaderFactory,
+      AccountQueryBuilder accountQueryBuilder,
+      AccountQueryProcessor accountQueryProcessor,
       GroupBackend groupBackend,
       GroupMembers.Factory groupMembersFactory,
       Provider<CurrentUser> currentUser,
+      ReviewerRecommender reviewerRecommender,
       Metrics metrics) {
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
     fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
     this.accountLoader = accountLoaderFactory.create(fillOptions);
-    this.accountCache = accountCache;
-    this.indexes = indexes;
-    this.queryBuilder = queryBuilder;
-    this.queryProcessor = queryProcessor;
-    this.accountControl = accountControlFactory.get();
-    this.dbProvider = dbProvider;
+    this.accountQueryBuilder = accountQueryBuilder;
+    this.accountQueryProcessor = accountQueryProcessor;
+    this.currentUser = currentUser;
     this.groupBackend = groupBackend;
     this.groupMembersFactory = groupMembersFactory;
-    this.currentUser = currentUser;
+    this.reviewerRecommender = reviewerRecommender;
     this.metrics = metrics;
   }
 
@@ -157,37 +141,116 @@
   }
 
   public List<SuggestedReviewerInfo> suggestReviewers(
-      SuggestReviewers suggestReviewers, ProjectControl projectControl,
-      VisibilityControl visibilityControl)
-      throws IOException, OrmException, BadRequestException {
+      ChangeNotes changeNotes,
+      SuggestReviewers suggestReviewers,
+      ProjectControl projectControl,
+      VisibilityControl visibilityControl,
+      boolean excludeGroups)
+      throws IOException, OrmException {
     String query = suggestReviewers.getQuery();
-    boolean suggestAccounts = suggestReviewers.getSuggestAccounts();
-    int suggestFrom = suggestReviewers.getSuggestFrom();
     int limit = suggestReviewers.getLimit();
 
-    if (Strings.isNullOrEmpty(query)) {
-      throw new BadRequestException("missing query field");
-    }
-
-    if (!suggestAccounts || query.length() < suggestFrom) {
+    if (!suggestReviewers.getSuggestAccounts()) {
       return Collections.emptyList();
     }
 
-    Collection<AccountInfo> suggestedAccounts =
-        suggestAccounts(suggestReviewers, visibilityControl);
-
-    List<SuggestedReviewerInfo> reviewer = new ArrayList<>();
-    for (AccountInfo a : suggestedAccounts) {
-      SuggestedReviewerInfo info = new SuggestedReviewerInfo();
-      info.account = a;
-      info.count = 1;
-      reviewer.add(info);
+    List<Account.Id> candidateList = new ArrayList<>();
+    if (!Strings.isNullOrEmpty(query)) {
+      candidateList = suggestAccounts(suggestReviewers);
     }
 
+    List<Account.Id> sortedRecommendations =
+        recommendAccounts(changeNotes, suggestReviewers, projectControl, candidateList);
+
+    // Filter accounts by visibility and enforce limit
+    List<Account.Id> filteredRecommendations = new ArrayList<>();
+    try (Timer0.Context ctx = metrics.filterVisibility.start()) {
+      for (Account.Id reviewer : sortedRecommendations) {
+        if (filteredRecommendations.size() >= limit) {
+          break;
+        }
+        if (visibilityControl.isVisibleTo(reviewer)) {
+          filteredRecommendations.add(reviewer);
+        }
+      }
+    }
+
+    List<SuggestedReviewerInfo> suggestedReviewer = loadAccounts(filteredRecommendations);
+    if (!excludeGroups && suggestedReviewer.size() < limit && !Strings.isNullOrEmpty(query)) {
+      // Add groups at the end as individual accounts are usually more
+      // important.
+      suggestedReviewer.addAll(
+          suggestAccountGroups(
+              suggestReviewers,
+              projectControl,
+              visibilityControl,
+              limit - suggestedReviewer.size()));
+    }
+
+    if (suggestedReviewer.size() <= limit) {
+      return suggestedReviewer;
+    }
+    return suggestedReviewer.subList(0, limit);
+  }
+
+  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) throws OrmException {
+    try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
+      try {
+        QueryResult<AccountState> result =
+            accountQueryProcessor
+                .setLimit(suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER)
+                .query(accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
+        return result.entities().stream().map(a -> a.getAccount().getId()).collect(toList());
+      } catch (QueryParseException e) {
+        return ImmutableList.of();
+      }
+    }
+  }
+
+  private List<Account.Id> recommendAccounts(
+      ChangeNotes changeNotes,
+      SuggestReviewers suggestReviewers,
+      ProjectControl projectControl,
+      List<Account.Id> candidateList)
+      throws OrmException {
+    try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
+      return reviewerRecommender.suggestReviewers(
+          changeNotes, suggestReviewers, projectControl, candidateList);
+    }
+  }
+
+  private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds)
+      throws OrmException {
+    try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
+      List<SuggestedReviewerInfo> reviewer =
+          accountIds.stream()
+              .map(accountLoader::get)
+              .filter(Objects::nonNull)
+              .map(
+                  a -> {
+                    SuggestedReviewerInfo info = new SuggestedReviewerInfo();
+                    info.account = a;
+                    info.count = 1;
+                    return info;
+                  })
+              .collect(toList());
+      accountLoader.fill();
+      return reviewer;
+    }
+  }
+
+  private List<SuggestedReviewerInfo> suggestAccountGroups(
+      SuggestReviewers suggestReviewers,
+      ProjectControl projectControl,
+      VisibilityControl visibilityControl,
+      int limit)
+      throws OrmException, IOException {
     try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
-      for (GroupReference g : suggestAccountGroup(suggestReviewers, projectControl)) {
-        GroupAsReviewer result = suggestGroupAsReviewer(
-            suggestReviewers, projectControl.getProject(), g, visibilityControl);
+      List<SuggestedReviewerInfo> groups = new ArrayList<>();
+      for (GroupReference g : suggestAccountGroups(suggestReviewers, projectControl)) {
+        GroupAsReviewer result =
+            suggestGroupAsReviewer(
+                suggestReviewers, projectControl.getProject(), g, visibilityControl);
         if (result.allowed || result.allowedWithConfirmation) {
           GroupBaseInfo info = new GroupBaseInfo();
           info.id = Url.encode(g.getUUID().get());
@@ -198,124 +261,21 @@
           if (result.allowedWithConfirmation) {
             suggestedReviewerInfo.confirm = true;
           }
-          reviewer.add(suggestedReviewerInfo);
-        }
-      }
-    }
-
-    reviewer = ORDERING.immutableSortedCopy(reviewer);
-    if (reviewer.size() <= limit) {
-      return reviewer;
-    }
-    return reviewer.subList(0, limit);
-  }
-
-  private Collection<AccountInfo> suggestAccounts(SuggestReviewers suggestReviewers,
-      VisibilityControl visibilityControl) throws OrmException {
-    try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
-      AccountIndex searchIndex = indexes.getSearchIndex();
-      if (searchIndex != null) {
-        return suggestAccountsFromIndex(suggestReviewers, visibilityControl);
-      }
-      log.warn("Account index not available; suggesting reviewers from DB");
-      return suggestAccountsFromDb(suggestReviewers, visibilityControl);
-    }
-  }
-
-  private Collection<AccountInfo> suggestAccountsFromIndex(
-      SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
-      throws OrmException {
-    try {
-      Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
-      QueryResult<AccountState> result = queryProcessor
-          .setLimit(suggestReviewers.getLimit())
-          .query(queryBuilder.defaultQuery(suggestReviewers.getQuery()));
-      for (AccountState accountState : result.entities()) {
-        Account.Id id = accountState.getAccount().getId();
-        if (visibilityControl.isVisibleTo(id)) {
-          matches.put(id, accountLoader.get(id));
-        }
-      }
-
-      accountLoader.fill();
-
-      return matches.values();
-    } catch (QueryParseException e) {
-      return ImmutableList.of();
-    }
-  }
-
-  private Collection<AccountInfo> suggestAccountsFromDb(
-      SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
-          throws OrmException {
-    String query = suggestReviewers.getQuery();
-    int limit = suggestReviewers.getLimit();
-
-    String a = query;
-    String b = a + MAX_SUFFIX;
-
-    Map<Account.Id, AccountInfo> r = new LinkedHashMap<>();
-    Map<Account.Id, String> queryEmail = new HashMap<>();
-
-    for (Account p : dbProvider.get().accounts()
-        .suggestByFullName(a, b, limit)) {
-      if (p.isActive()) {
-        addSuggestion(r, p.getId(), visibilityControl);
-      }
-    }
-
-    if (r.size() < limit) {
-      for (Account p : dbProvider.get().accounts()
-          .suggestByPreferredEmail(a, b, limit - r.size())) {
-        if (p.isActive()) {
-          addSuggestion(r, p.getId(), visibilityControl);
-        }
-      }
-    }
-
-    if (r.size() < limit) {
-      for (AccountExternalId e : dbProvider.get().accountExternalIds()
-          .suggestByEmailAddress(a, b, limit - r.size())) {
-        if (!r.containsKey(e.getAccountId())) {
-          Account p = accountCache.get(e.getAccountId()).getAccount();
-          if (p.isActive()) {
-            if (addSuggestion(r, p.getId(), visibilityControl)) {
-              queryEmail.put(e.getAccountId(), e.getEmailAddress());
-            }
+          groups.add(suggestedReviewerInfo);
+          if (groups.size() >= limit) {
+            break;
           }
         }
       }
+      return groups;
     }
-
-    accountLoader.fill();
-    for (Map.Entry<Account.Id, String> p : queryEmail.entrySet()) {
-      AccountInfo info = r.get(p.getKey());
-      if (info != null) {
-        info.email = p.getValue();
-      }
-    }
-    return new ArrayList<>(r.values());
   }
 
-  private boolean addSuggestion(Map<Account.Id, AccountInfo> map,
-      Account.Id account, VisibilityControl visibilityControl)
-      throws OrmException {
-    if (!map.containsKey(account)
-        // Can the suggestion see the change?
-        && visibilityControl.isVisibleTo(account)
-        // Can the current user see the account?
-        && accountControl.canSee(account)) {
-      map.put(account, accountLoader.get(account));
-      return true;
-    }
-    return false;
-  }
-
-  private List<GroupReference> suggestAccountGroup(
+  private List<GroupReference> suggestAccountGroups(
       SuggestReviewers suggestReviewers, ProjectControl ctl) {
     return Lists.newArrayList(
-        Iterables.limit(groupBackend.suggest(suggestReviewers.getQuery(), ctl),
-            suggestReviewers.getLimit()));
+        Iterables.limit(
+            groupBackend.suggest(suggestReviewers.getQuery(), ctl), suggestReviewers.getLimit()));
   }
 
   private static class GroupAsReviewer {
@@ -324,22 +284,25 @@
     int size;
   }
 
-  private GroupAsReviewer suggestGroupAsReviewer(SuggestReviewers suggestReviewers,
-      Project project, GroupReference group,
-      VisibilityControl visibilityControl) throws OrmException, IOException {
+  private GroupAsReviewer suggestGroupAsReviewer(
+      SuggestReviewers suggestReviewers,
+      Project project,
+      GroupReference group,
+      VisibilityControl visibilityControl)
+      throws OrmException, IOException {
     GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
-    int maxAllowedWithoutConfirmation =
-        suggestReviewers.getMaxAllowedWithoutConfirmation();
+    int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
 
     if (!PostReviewers.isLegalReviewerGroup(group.getUUID())) {
       return result;
     }
 
     try {
-      Set<Account> members = groupMembersFactory
-          .create(currentUser.get())
-          .listAccounts(group.getUUID(), project.getNameKey());
+      Set<Account> members =
+          groupMembersFactory
+              .create(currentUser.get())
+              .listAccounts(group.getUUID(), project.getNameKey());
 
       if (members.isEmpty()) {
         return result;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
index 9448ceb..4ab42f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
@@ -28,21 +28,22 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.Config;
 
 @SuppressWarnings("deprecation")
 @Singleton
 public class Sequences {
+  public static final String CHANGES = "changes";
+
   private final Provider<ReviewDb> db;
   private final NotesMigration migration;
   private final RepoSequence changeSeq;
 
   @Inject
-  Sequences(@GerritServerConfig Config cfg,
+  Sequences(
+      @GerritServerConfig Config cfg,
       final Provider<ReviewDb> db,
       NotesMigration migration,
       GitRepositoryManager repoManager,
@@ -51,17 +52,18 @@
     this.migration = migration;
 
     final int gap = cfg.getInt("noteDb", "changes", "initialSequenceGap", 0);
-    changeSeq = new RepoSequence(
-        repoManager,
-        allProjects,
-        "changes",
-        new RepoSequence.Seed() {
-          @Override
-          public int get() throws OrmException {
-            return db.get().nextChangeId() + gap;
-          }
-        },
-        cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20));
+    changeSeq =
+        new RepoSequence(
+            repoManager,
+            allProjects,
+            CHANGES,
+            new RepoSequence.Seed() {
+              @Override
+              public int get() throws OrmException {
+                return db.get().nextChangeId() + gap;
+              }
+            },
+            cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20));
   }
 
   public int nextChangeId() throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index 5a89afa..ae7488c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,20 +14,21 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
-import com.google.common.base.Predicate;
 import com.google.common.base.Splitter;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
+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.client.Project;
@@ -44,7 +45,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.NullProgressMonitor;
@@ -62,12 +69,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.List;
-import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
 @Singleton
 public class StarredChangesUtil {
   @AutoValue
@@ -93,6 +94,7 @@
     }
 
     public abstract Account.Id accountId();
+
     public abstract String label();
 
     @Override
@@ -101,20 +103,39 @@
     }
   }
 
+  @AutoValue
+  public abstract static class StarRef {
+    private static final StarRef MISSING =
+        new AutoValue_StarredChangesUtil_StarRef(null, ImmutableSortedSet.of());
+
+    private static StarRef create(Ref ref, Iterable<String> labels) {
+      return new AutoValue_StarredChangesUtil_StarRef(
+          checkNotNull(ref), ImmutableSortedSet.copyOf(labels));
+    }
+
+    @Nullable
+    public abstract Ref ref();
+
+    public abstract ImmutableSortedSet<String> labels();
+
+    public ObjectId objectId() {
+      return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
+    }
+  }
+
   public static class IllegalLabelException extends IllegalArgumentException {
     private static final long serialVersionUID = 1L;
 
     static IllegalLabelException invalidLabels(Set<String> invalidLabels) {
       return new IllegalLabelException(
-          String.format("invalid labels: %s",
-              Joiner.on(", ").join(invalidLabels)));
+          String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
     }
 
-    static IllegalLabelException mutuallyExclusiveLabels(String label1,
-        String label2) {
+    static IllegalLabelException mutuallyExclusiveLabels(String label1, String label2) {
       return new IllegalLabelException(
-          String.format("The labels %s and %s are mutually exclusive."
-              + " Only one of them can be set.", label1, label2));
+          String.format(
+              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
+              label1, label2));
     }
 
     IllegalLabelException(String message) {
@@ -122,8 +143,7 @@
     }
   }
 
-  private static final Logger log =
-      LoggerFactory.getLogger(StarredChangesUtil.class);
+  private static final Logger log = LoggerFactory.getLogger(StarredChangesUtil.class);
 
   public static final String DEFAULT_LABEL = "star";
   public static final String IGNORE_LABEL = "ignore";
@@ -138,7 +158,8 @@
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
-  StarredChangesUtil(GitRepositoryManager repoManager,
+  StarredChangesUtil(
+      GitRepositoryManager repoManager,
       AllUsersName allUsers,
       Provider<ReviewDb> dbProvider,
       @GerritPersonIdent PersonIdent serverIdent,
@@ -152,26 +173,31 @@
     this.queryProvider = queryProvider;
   }
 
-  public ImmutableSortedSet<String> getLabels(Account.Id accountId,
-      Change.Id changeId) throws OrmException {
+  public ImmutableSortedSet<String> getLabels(Account.Id accountId, Change.Id changeId)
+      throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return ImmutableSortedSet.copyOf(
-          readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
+      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)).labels();
     } catch (IOException e) {
       throw new OrmException(
-          String.format("Reading stars from change %d for account %d failed",
-              changeId.get(), accountId.get()), e);
+          String.format(
+              "Reading stars from change %d for account %d failed",
+              changeId.get(), accountId.get()),
+          e);
     }
   }
 
-  public ImmutableSortedSet<String> star(Account.Id accountId,
-      Project.NameKey project, Change.Id changeId, Set<String> labelsToAdd,
-      Set<String> labelsToRemove) throws OrmException {
+  public ImmutableSortedSet<String> star(
+      Account.Id accountId,
+      Project.NameKey project,
+      Change.Id changeId,
+      Set<String> labelsToAdd,
+      Set<String> labelsToRemove)
+      throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
-      ObjectId oldObjectId = getObjectId(repo, refName);
+      StarRef old = readLabels(repo, refName);
 
-      SortedSet<String> labels = readLabels(repo, oldObjectId);
+      Set<String> labels = new HashSet<>(old.labels());
       if (labelsToAdd != null) {
         labels.addAll(labelsToAdd);
       }
@@ -180,23 +206,22 @@
       }
 
       if (labels.isEmpty()) {
-        deleteRef(repo, refName, oldObjectId);
+        deleteRef(repo, refName, old.objectId());
       } else {
         checkMutuallyExclusiveLabels(labels);
-        updateLabels(repo, refName, oldObjectId, labels);
+        updateLabels(repo, refName, old.objectId(), labels);
       }
 
       indexer.index(dbProvider.get(), project, changeId);
       return ImmutableSortedSet.copyOf(labels);
     } catch (IOException e) {
       throw new OrmException(
-          String.format("Star change %d for account %d failed",
-              changeId.get(), accountId.get()), e);
+          String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
+          e);
     }
   }
 
-  public void unstarAll(Project.NameKey project, Change.Id changeId)
-      throws OrmException, NoSuchChangeException {
+  public void unstarAll(Project.NameKey project, Change.Id changeId) throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
       BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
@@ -206,184 +231,136 @@
       for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
         String refName = RefNames.refsStarredChanges(changeId, accountId);
         Ref ref = repo.getRefDatabase().getRef(refName);
-        batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(),
-            ObjectId.zeroId(), refName));
+        batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
       }
       batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
       for (ReceiveCommand command : batchUpdate.getCommands()) {
         if (command.getResult() != ReceiveCommand.Result.OK) {
-          throw new IOException(String.format(
-              "Unstar change %d failed, ref %s could not be deleted: %s",
-              changeId.get(), command.getRefName(), command.getResult()));
+          throw new IOException(
+              String.format(
+                  "Unstar change %d failed, ref %s could not be deleted: %s",
+                  changeId.get(), command.getRefName(), command.getResult()));
         }
       }
       indexer.index(dbProvider.get(), project, changeId);
     } catch (IOException e) {
-      throw new OrmException(
-          String.format("Unstar change %d failed", changeId.get()), e);
+      throw new OrmException(String.format("Unstar change %d failed", changeId.get()), e);
     }
   }
 
-  public ImmutableMultimap<Account.Id, String> byChange(Change.Id changeId)
-      throws OrmException {
+  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableMultimap.Builder<Account.Id, String> builder =
-          new ImmutableMultimap.Builder<>();
-      for (String refPart : getRefNames(repo,
-          RefNames.refsStarredChangesPrefix(changeId))) {
+      ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
+      for (String refPart : getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))) {
         Integer id = Ints.tryParse(refPart);
         if (id == null) {
           continue;
         }
         Account.Id accountId = new Account.Id(id);
-        builder.putAll(accountId,
-            readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
+        builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
     } catch (IOException e) {
-      throw new OrmException(String.format(
-          "Get accounts that starred change %d failed", changeId.get()), e);
+      throw new OrmException(
+          String.format("Get accounts that starred change %d failed", changeId.get()), e);
     }
   }
 
-  public Set<Account.Id> byChange(final Change.Id changeId,
-      final String label) throws OrmException {
-    try (final Repository repo = repoManager.openRepository(allUsers)) {
-      return FluentIterable
-          .from(getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId)))
-          .transform(new Function<String, Account.Id>() {
-            @Override
-            public Account.Id apply(String refPart) {
-              return Account.Id.parse(refPart);
-            }
-          })
-          .filter(new Predicate<Account.Id>() {
-            @Override
-            public boolean apply(Account.Id accountId) {
-              try {
-                return readLabels(repo,
-                    RefNames.refsStarredChanges(changeId, accountId))
-                        .contains(label);
-              } catch (IOException e) {
-                log.error(String.format(
-                    "Cannot query stars by account %d on change %d",
-                    accountId.get(), changeId.get()), e);
-                return false;
-              }
-            }
-          }).toSet();
+  public Set<Account.Id> byChange(final Change.Id changeId, final String label)
+      throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId)).stream()
+          .map(Account.Id::parse)
+          .filter(accountId -> hasStar(repo, changeId, accountId, label))
+          .collect(toSet());
     } catch (IOException e) {
       throw new OrmException(
-          String.format("Get accounts that starred change %d failed",
-              changeId.get()), e);
+          String.format("Get accounts that starred change %d failed", changeId.get()), e);
     }
   }
 
   @Deprecated
   // To be used only for IsStarredByLegacyPredicate.
-  public Set<Change.Id> byAccount(final Account.Id accountId,
-      final String label) throws OrmException {
-    try (final Repository repo = repoManager.openRepository(allUsers)) {
-      return FluentIterable
-          .from(getRefNames(repo, RefNames.REFS_STARRED_CHANGES))
-          .filter(new Predicate<String>() {
-            @Override
-            public boolean apply(String refPart) {
-              return refPart.endsWith("/" + accountId.get());
-            }
-          })
-          .transform(new Function<String, Change.Id>() {
-            @Override
-            public Change.Id apply(String refPart) {
-              return Change.Id.fromRefPart(refPart);
-            }
-          })
-          .filter(new Predicate<Change.Id>() {
-            @Override
-            public boolean apply(Change.Id changeId) {
-              try {
-                return readLabels(repo,
-                    RefNames.refsStarredChanges(changeId, accountId))
-                        .contains(label);
-              } catch (IOException e) {
-                log.error(String.format(
-                    "Cannot query stars by account %d on change %d",
-                    accountId.get(), changeId.get()), e);
-                return false;
-              }
-            }
-          }).toSet();
+  public Set<Change.Id> byAccount(final Account.Id accountId, final String label)
+      throws OrmException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getRefNames(repo, RefNames.REFS_STARRED_CHANGES).stream()
+          .filter(refPart -> refPart.endsWith("/" + accountId.get()))
+          .map(Change.Id::fromRefPart)
+          .filter(changeId -> hasStar(repo, changeId, accountId, label))
+          .collect(toSet());
     } catch (IOException e) {
       throw new OrmException(
-          String.format("Get changes that were starred by %d failed",
-              accountId.get()), e);
+          String.format("Get changes that were starred by %d failed", accountId.get()), e);
     }
   }
 
-  public ImmutableMultimap<Account.Id, String> byChangeFromIndex(
-      Change.Id changeId) throws OrmException, NoSuchChangeException {
-    Set<String> fields = ImmutableSet.of(
-        ChangeField.ID.getName(),
-        ChangeField.STAR.getName());
-    List<ChangeData> changeData = queryProvider.get().setRequestedFields(fields)
-        .byLegacyChangeId(changeId);
+  private boolean hasStar(Repository repo, Change.Id changeId, Account.Id accountId, String label) {
+    try {
+      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId))
+          .labels()
+          .contains(label);
+    } catch (IOException e) {
+      log.error(
+          "Cannot query stars by account {} on change {}", accountId.get(), changeId.get(), e);
+      return false;
+    }
+  }
+
+  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId)
+      throws OrmException {
+    Set<String> fields = ImmutableSet.of(ChangeField.ID.getName(), ChangeField.STAR.getName());
+    List<ChangeData> changeData =
+        queryProvider.get().setRequestedFields(fields).byLegacyChangeId(changeId);
     if (changeData.size() != 1) {
       throw new NoSuchChangeException(changeId);
     }
     return changeData.get(0).stars();
   }
 
-  private static Set<String> getRefNames(Repository repo, String prefix)
-      throws IOException {
+  private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
     RefDatabase refDb = repo.getRefDatabase();
     return refDb.getRefs(prefix).keySet();
   }
 
   public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getObjectId(repo,
-          RefNames.refsStarredChanges(changeId, accountId));
+      Ref ref = repo.exactRef(RefNames.refsStarredChanges(changeId, accountId));
+      return ref != null ? ref.getObjectId() : ObjectId.zeroId();
     } catch (IOException e) {
-      log.error(String.format(
-          "Getting star object ID for account %d on change %d failed",
-          accountId.get(), changeId.get()), e);
+      log.error(
+          "Getting star object ID for account {} on change {} failed",
+          accountId.get(),
+          changeId.get(),
+          e);
       return ObjectId.zeroId();
     }
   }
 
-  private static ObjectId getObjectId(Repository repo, String refName)
-      throws IOException {
+  private static StarRef readLabels(Repository repo, String refName) throws IOException {
     Ref ref = repo.exactRef(refName);
-    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  private static SortedSet<String> readLabels(Repository repo, String refName)
-      throws IOException {
-    return readLabels(repo, getObjectId(repo, refName));
-  }
-
-  private static TreeSet<String> readLabels(Repository repo, ObjectId id)
-      throws IOException {
-    if (ObjectId.zeroId().equals(id)) {
-      return new TreeSet<>();
+    if (ref == null) {
+      return StarRef.MISSING;
     }
 
     try (ObjectReader reader = repo.newObjectReader()) {
-      ObjectLoader obj = reader.open(id, Constants.OBJ_BLOB);
-      TreeSet<String> labels = new TreeSet<>();
-      Iterables.addAll(labels,
-          Splitter.on(CharMatcher.whitespace()).omitEmptyStrings()
+      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
+      return StarRef.create(
+          ref,
+          Splitter.on(CharMatcher.whitespace())
+              .omitEmptyStrings()
               .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
-      return labels;
     }
   }
 
-  public static ObjectId writeLabels(Repository repo, SortedSet<String> labels)
+  public static ObjectId writeLabels(Repository repo, Collection<String> labels)
       throws IOException {
     validateLabels(labels);
     try (ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId id = oi.insert(Constants.OBJ_BLOB,
-          Joiner.on("\n").join(labels).getBytes(UTF_8));
+      ObjectId id =
+          oi.insert(
+              Constants.OBJ_BLOB,
+              labels.stream().sorted().distinct().collect(joining("\n")).getBytes(UTF_8));
       oi.flush();
       return id;
     }
@@ -391,12 +368,11 @@
 
   private static void checkMutuallyExclusiveLabels(Set<String> labels) {
     if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
-      throw IllegalLabelException.mutuallyExclusiveLabels(DEFAULT_LABEL,
-          IGNORE_LABEL);
+      throw IllegalLabelException.mutuallyExclusiveLabels(DEFAULT_LABEL, IGNORE_LABEL);
     }
   }
 
-  private static void validateLabels(Set<String> labels) {
+  private static void validateLabels(Collection<String> labels) {
     if (labels == null) {
       return;
     }
@@ -412,9 +388,9 @@
     }
   }
 
-  private void updateLabels(Repository repo, String refName,
-      ObjectId oldObjectId, SortedSet<String> labels)
-          throws IOException, OrmException {
+  private void updateLabels(
+      Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
+      throws IOException, OrmException {
     try (RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
@@ -436,8 +412,7 @@
         case REJECTED_CURRENT_BRANCH:
         case RENAMED:
           throw new OrmException(
-              String.format("Update star labels on ref %s failed: %s", refName,
-                  result.name()));
+              String.format("Update star labels on ref %s failed: %s", refName, result.name()));
       }
     }
   }
@@ -462,8 +437,8 @@
       case REJECTED:
       case REJECTED_CURRENT_BRANCH:
       case RENAMED:
-        throw new OrmException(String.format("Delete star ref %s failed: %s",
-            refName, result.name()));
+        throw new OrmException(
+            String.format("Delete star ref %s failed: %s", refName, result.name()));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StartupCheck.java b/gerrit-server/src/main/java/com/google/gerrit/server/StartupCheck.java
new file mode 100644
index 0000000..196ca5b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StartupCheck.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+
+/** Check executed on Gerrit startup. */
+public interface StartupCheck {
+  /**
+   * Performs Gerrit startup check, can abort startup by throwing {@link StartupException}.
+   *
+   * <p>Called on Gerrit startup after all {@link LifecycleListener} have been invoked.
+   *
+   * @throws StartupException thrown if Gerrit startup should be aborted
+   */
+  void check() throws StartupException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StartupChecks.java b/gerrit-server/src/main/java/com/google/gerrit/server/StartupChecks.java
new file mode 100644
index 0000000..9df2604
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StartupChecks.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.account.UniversalGroupBackend;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class StartupChecks implements LifecycleListener {
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      DynamicSet.setOf(binder(), StartupCheck.class);
+      listener().to(StartupChecks.class);
+      DynamicSet.bind(binder(), StartupCheck.class).to(UniversalGroupBackend.ConfigCheck.class);
+      DynamicSet.bind(binder(), StartupCheck.class).to(SystemGroupBackend.NameCheck.class);
+    }
+  }
+
+  private final DynamicSet<StartupCheck> startupChecks;
+
+  @Inject
+  StartupChecks(DynamicSet<StartupCheck> startupChecks) {
+    this.startupChecks = startupChecks;
+  }
+
+  @Override
+  public void start() throws StartupException {
+    for (StartupCheck startupCheck : startupChecks) {
+      startupCheck.check();
+    }
+  }
+
+  @Override
+  public void stop() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StartupException.java b/gerrit-server/src/main/java/com/google/gerrit/server/StartupException.java
new file mode 100644
index 0000000..f84594b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StartupException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+public class StartupException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public StartupException(String message) {
+    super(message);
+  }
+
+  public StartupException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
index 0aef9e9..83b6ec6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
@@ -16,22 +16,21 @@
 
 public class StringUtil {
   /**
-   * An array of the string representations that should be used in place
-   * of the non-printable characters in the beginning of the ASCII table
-   * when escaping a string. The index of each element in the array
-   * corresponds to its ASCII value, i.e. the string representation of
-   * ASCII 0 is found in the first element of this array.
+   * An array of the string representations that should be used in place of the non-printable
+   * characters in the beginning of the ASCII table when escaping a string. The index of each
+   * element in the array corresponds to its ASCII value, i.e. the string representation of ASCII 0
+   * is found in the first element of this array.
    */
-  private static final String[] NON_PRINTABLE_CHARS =
-    { "\\x00", "\\x01", "\\x02", "\\x03", "\\x04", "\\x05", "\\x06", "\\a",
-      "\\b",   "\\t",   "\\n",   "\\v",   "\\f",   "\\r",   "\\x0e", "\\x0f",
-      "\\x10", "\\x11", "\\x12", "\\x13", "\\x14", "\\x15", "\\x16", "\\x17",
-      "\\x18", "\\x19", "\\x1a", "\\x1b", "\\x1c", "\\x1d", "\\x1e", "\\x1f", };
+  private static final String[] NON_PRINTABLE_CHARS = {
+    "\\x00", "\\x01", "\\x02", "\\x03", "\\x04", "\\x05", "\\x06", "\\a",
+    "\\b", "\\t", "\\n", "\\v", "\\f", "\\r", "\\x0e", "\\x0f",
+    "\\x10", "\\x11", "\\x12", "\\x13", "\\x14", "\\x15", "\\x16", "\\x17",
+    "\\x18", "\\x19", "\\x1a", "\\x1b", "\\x1c", "\\x1d", "\\x1e", "\\x1f",
+  };
 
   /**
-   * Escapes the input string so that all non-printable characters
-   * (0x00-0x1f) are represented as a hex escape (\x00, \x01, ...)
-   * or as a C-style escape sequence (\a, \b, \t, \n, \v, \f, or \r).
+   * Escapes the input string so that all non-printable characters (0x00-0x1f) are represented as a
+   * hex escape (\x00, \x01, ...) or as a C-style escape sequence (\a, \b, \t, \n, \v, \f, or \r).
    * Backslashes in the input string are doubled (\\).
    */
   public static String escapeString(final String str) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java b/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java
index 3ac6c98..adad11c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java
@@ -12,11 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-
 package com.google.gerrit.server;
 
 import com.google.gerrit.extensions.restapi.Url;
-
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -25,8 +23,7 @@
 
   private String url;
 
-  public UrlEncoded() {
-  }
+  public UrlEncoded() {}
 
   public UrlEncoded(final String url) {
     this.url = url;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
index a8345e5..4ca77bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
@@ -29,48 +29,41 @@
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.WebLink;
+import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.util.Collections;
+import java.util.List;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @Singleton
 public class WebLinks {
   private static final Logger log = LoggerFactory.getLogger(WebLinks.class);
+
   private static final Predicate<WebLinkInfo> INVALID_WEBLINK =
-      new Predicate<WebLinkInfo>() {
-
-        @Override
-        public boolean apply(WebLinkInfo link) {
-          if (link == null) {
-            return false;
-          } else if (Strings.isNullOrEmpty(link.name)
-              || Strings.isNullOrEmpty(link.url)) {
-            log.warn(String.format("%s is missing name and/or url",
-                link.getClass().getName()));
-            return false;
-          }
-          return true;
+      link -> {
+        if (link == null) {
+          return false;
+        } else if (Strings.isNullOrEmpty(link.name) || Strings.isNullOrEmpty(link.url)) {
+          log.warn("{} is missing name and/or url", link.getClass().getName());
+          return false;
         }
+        return true;
       };
-  private static final Predicate<WebLinkInfoCommon> INVALID_WEBLINK_COMMON =
-      new Predicate<WebLinkInfoCommon>() {
 
-        @Override
-        public boolean apply(WebLinkInfoCommon link) {
-          if (link == null) {
-            return false;
-          } else if (Strings.isNullOrEmpty(link.name)
-              || Strings.isNullOrEmpty(link.url)) {
-            log.warn(String.format("%s is missing name and/or url", link
-                .getClass().getName()));
-            return false;
-          }
-          return true;
+  private static final Predicate<WebLinkInfoCommon> INVALID_WEBLINK_COMMON =
+      link -> {
+        if (link == null) {
+          return false;
+        } else if (Strings.isNullOrEmpty(link.name) || Strings.isNullOrEmpty(link.url)) {
+          log.warn("{} is missing name and/or url", link.getClass().getName());
+          return false;
         }
+        return true;
       };
 
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
@@ -80,16 +73,18 @@
   private final DynamicSet<DiffWebLink> diffLinks;
   private final DynamicSet<ProjectWebLink> projectLinks;
   private final DynamicSet<BranchWebLink> branchLinks;
+  private final DynamicSet<TagWebLink> tagLinks;
 
   @Inject
-  public WebLinks(DynamicSet<PatchSetWebLink> patchSetLinks,
+  public WebLinks(
+      DynamicSet<PatchSetWebLink> patchSetLinks,
       DynamicSet<ParentWebLink> parentLinks,
       DynamicSet<FileWebLink> fileLinks,
       DynamicSet<FileHistoryWebLink> fileLogLinks,
       DynamicSet<DiffWebLink> diffLinks,
       DynamicSet<ProjectWebLink> projectLinks,
-      DynamicSet<BranchWebLink> branchLinks
-      ) {
+      DynamicSet<BranchWebLink> branchLinks,
+      DynamicSet<TagWebLink> tagLinks) {
     this.patchSetLinks = patchSetLinks;
     this.parentLinks = parentLinks;
     this.fileLinks = fileLinks;
@@ -97,23 +92,16 @@
     this.diffLinks = diffLinks;
     this.projectLinks = projectLinks;
     this.branchLinks = branchLinks;
+    this.tagLinks = tagLinks;
   }
 
   /**
-   *
    * @param project Project name.
    * @param commit SHA1 of commit.
    * @return Links for patch sets.
    */
-  public FluentIterable<WebLinkInfo> getPatchSetLinks(final Project.NameKey project,
-      final String commit) {
-    return filterLinks(patchSetLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((PatchSetWebLink)webLink).getPatchSetWebLink(project.get(), commit);
-      }
-    });
+  public List<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
+    return filterLinks(patchSetLinks, webLink -> webLink.getPatchSetWebLink(project.get(), commit));
   }
 
   /**
@@ -121,83 +109,53 @@
    * @param revision SHA1 of the parent revision.
    * @return Links for patch sets.
    */
-  public FluentIterable<WebLinkInfo> getParentLinks(final Project.NameKey project,
-      final String revision) {
-    return filterLinks(parentLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((ParentWebLink)webLink).getParentWebLink(project.get(), revision);
-      }
-    });
+  public List<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
+    return filterLinks(parentLinks, webLink -> webLink.getParentWebLink(project.get(), revision));
   }
 
   /**
-   *
    * @param project Project name.
    * @param revision SHA1 of revision.
    * @param file File name.
    * @return Links for files.
    */
-  public FluentIterable<WebLinkInfo> getFileLinks(final String project, final String revision,
-      final String file) {
-    return filterLinks(fileLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((FileWebLink)webLink).getFileWebLink(project, revision, file);
-      }
-    });
+  public List<WebLinkInfo> getFileLinks(String project, String revision, String file) {
+    return Patch.isMagic(file)
+        ? Collections.emptyList()
+        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, file));
   }
 
   /**
-   *
    * @param project Project name.
    * @param revision SHA1 of revision.
    * @param file File name.
    * @return Links for file history
    */
-  public FluentIterable<WebLinkInfo> getFileHistoryLinks(final String project,
-      final String revision, final String file) {
-    return filterLinks(fileHistoryLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((FileHistoryWebLink) webLink).getFileHistoryWebLink(project,
-            revision, file);
-      }
-    });
-  }
-
-  public FluentIterable<WebLinkInfoCommon> getFileHistoryLinksCommon(
-      final String project, final String revision, final String file) {
-    return FluentIterable
-        .from(fileHistoryLinks)
-        .transform(new Function<WebLink, WebLinkInfoCommon>() {
-          @Override
-          public WebLinkInfoCommon apply(WebLink webLink) {
-            WebLinkInfo info =
-                ((FileHistoryWebLink) webLink).getFileHistoryWebLink(project,
-                    revision, file);
-            if (info == null) {
-              return null;
-            }
-            WebLinkInfoCommon commonInfo = new WebLinkInfoCommon();
-            commonInfo.name = info.name;
-            commonInfo.imageUrl = info.imageUrl;
-            commonInfo.url = info.url;
-            commonInfo.target = info.target;
-            return commonInfo;
-          }
-        })
-        .filter(INVALID_WEBLINK_COMMON);
+  public List<WebLinkInfoCommon> getFileHistoryLinks(String project, String revision, String file) {
+    if (Patch.isMagic(file)) {
+      return Collections.emptyList();
+    }
+    return FluentIterable.from(fileHistoryLinks)
+        .transform(
+            webLink -> {
+              WebLinkInfo info = webLink.getFileHistoryWebLink(project, revision, file);
+              if (info == null) {
+                return null;
+              }
+              WebLinkInfoCommon commonInfo = new WebLinkInfoCommon();
+              commonInfo.name = info.name;
+              commonInfo.imageUrl = info.imageUrl;
+              commonInfo.url = info.url;
+              commonInfo.target = info.target;
+              return commonInfo;
+            })
+        .filter(INVALID_WEBLINK_COMMON)
+        .toList();
   }
 
   /**
-   *
    * @param project Project name.
-   * @param patchSetIdA Patch set ID of side A, <code>null</code> if no base
-   *        patch set was selected.
+   * @param patchSetIdA Patch set ID of side A, <code>null</code> if no base patch set was selected.
    * @param revisionA SHA1 of revision of side A.
    * @param fileA File name of side A.
    * @param patchSetIdB Patch set ID of side B.
@@ -205,58 +163,62 @@
    * @param fileB File name of side B.
    * @return Links for file diffs.
    */
-  public FluentIterable<DiffWebLinkInfo> getDiffLinks(final String project, final int changeId,
-      final Integer patchSetIdA, final String revisionA, final String fileA,
-      final int patchSetIdB, final String revisionB, final String fileB) {
-   return FluentIterable
-       .from(diffLinks)
-       .transform(new Function<WebLink, DiffWebLinkInfo>() {
-         @Override
-         public DiffWebLinkInfo apply(WebLink webLink) {
-            return ((DiffWebLink) webLink).getDiffLink(project, changeId,
-                patchSetIdA, revisionA, fileA,
-                patchSetIdB, revisionB, fileB);
-          }
-       })
-       .filter(INVALID_WEBLINK);
- }
-
-  /**
-   *
-   * @param project Project name.
-   * @return Links for projects.
-   */
-  public FluentIterable<WebLinkInfo> getProjectLinks(final String project) {
-    return filterLinks(projectLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((ProjectWebLink)webLink).getProjectWeblink(project);
-      }
-    });
+  public List<DiffWebLinkInfo> getDiffLinks(
+      final String project,
+      final int changeId,
+      final Integer patchSetIdA,
+      final String revisionA,
+      final String fileA,
+      final int patchSetIdB,
+      final String revisionB,
+      final String fileB) {
+    if (Patch.isMagic(fileA) || Patch.isMagic(fileB)) {
+      return Collections.emptyList();
+    }
+    return FluentIterable.from(diffLinks)
+        .transform(
+            webLink ->
+                webLink.getDiffLink(
+                    project,
+                    changeId,
+                    patchSetIdA,
+                    revisionA,
+                    fileA,
+                    patchSetIdB,
+                    revisionB,
+                    fileB))
+        .filter(INVALID_WEBLINK)
+        .toList();
   }
 
   /**
-   *
+   * @param project Project name.
+   * @return Links for projects.
+   */
+  public List<WebLinkInfo> getProjectLinks(final String project) {
+    return filterLinks(projectLinks, webLink -> webLink.getProjectWeblink(project));
+  }
+
+  /**
    * @param project Project name
    * @param branch Branch name
    * @return Links for branches.
    */
-  public FluentIterable<WebLinkInfo> getBranchLinks(final String project, final String branch) {
-    return filterLinks(branchLinks, new Function<WebLink, WebLinkInfo>() {
-
-      @Override
-      public WebLinkInfo apply(WebLink webLink) {
-        return ((BranchWebLink)webLink).getBranchWebLink(project, branch);
-      }
-    });
+  public List<WebLinkInfo> getBranchLinks(final String project, final String branch) {
+    return filterLinks(branchLinks, webLink -> webLink.getBranchWebLink(project, branch));
   }
 
-  private FluentIterable<WebLinkInfo> filterLinks(DynamicSet<? extends WebLink> links,
-      Function<WebLink, WebLinkInfo> transformer) {
-    return FluentIterable
-        .from(links)
-        .transform(transformer)
-        .filter(INVALID_WEBLINK);
+  /**
+   * @param project Project name
+   * @param tag Tag name
+   * @return Links for tags.
+   */
+  public List<WebLinkInfo> getTagLinks(String project, String tag) {
+    return filterLinks(tagLinks, webLink -> webLink.getTagWebLink(project, tag));
+  }
+
+  private <T extends WebLink> List<WebLinkInfo> filterLinks(
+      DynamicSet<T> links, Function<T, WebLinkInfo> transformer) {
+    return FluentIterable.from(links).transform(transformer).filter(INVALID_WEBLINK).toList();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessCollection.java
index aa04b33..2e90889 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/access/AccessCollection.java
@@ -25,14 +25,12 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class AccessCollection implements
-    RestCollection<TopLevelResource, AccessResource> {
+public class AccessCollection implements RestCollection<TopLevelResource, AccessResource> {
   private final Provider<ListAccess> list;
   private final DynamicMap<RestView<AccessResource>> views;
 
   @Inject
-  AccessCollection(Provider<ListAccess> list,
-      DynamicMap<RestView<AccessResource>> views) {
+  AccessCollection(Provider<ListAccess> list, DynamicMap<RestView<AccessResource>> views) {
     this.list = list;
     this.views = views;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
index aeff017..68e787d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
@@ -22,18 +22,19 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.GetAccess;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import org.kohsuke.args4j.Option;
 
 public class ListAccess implements RestReadView<TopLevelResource> {
 
-  @Option(name = "--project", aliases = {"-p"}, metaVar = "PROJECT",
+  @Option(
+      name = "--project",
+      aliases = {"-p"},
+      metaVar = "PROJECT",
       usage = "projects for which the access rights should be returned")
   private List<String> projects = new ArrayList<>();
 
@@ -54,5 +55,4 @@
     }
     return access;
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
index 30420e0..0f9ec8d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -16,18 +16,15 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.EmailSender;
+import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.Inject;
-
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
 
-/** Basic implementation of {@link Realm}.  */
+/** Basic implementation of {@link Realm}. */
 public abstract class AbstractRealm implements Realm {
   private EmailSender emailSender;
 
@@ -37,11 +34,11 @@
   }
 
   @Override
-  public Set<FieldName> getEditableFields() {
-    Set<Account.FieldName> fields = new  HashSet<>();
-    for (Account.FieldName n : Account.FieldName.values()) {
+  public Set<AccountFieldName> getEditableFields() {
+    Set<AccountFieldName> fields = new HashSet<>();
+    for (AccountFieldName n : AccountFieldName.values()) {
       if (allowsEdit(n)) {
-        if (n == Account.FieldName.REGISTER_NEW_EMAIL) {
+        if (n == AccountFieldName.REGISTER_NEW_EMAIL) {
           if (emailSender != null && emailSender.isEnabled()) {
             fields.add(n);
           }
@@ -55,8 +52,8 @@
 
   @Override
   public boolean hasEmailAddress(IdentifiedUser user, String email) {
-    for (AccountExternalId ext : user.state().getExternalIds()) {
-      if (email != null && email.equalsIgnoreCase(ext.getEmailAddress())) {
+    for (ExternalId ext : user.state().getExternalIds()) {
+      if (email != null && email.equalsIgnoreCase(ext.email())) {
         return true;
       }
     }
@@ -65,11 +62,11 @@
 
   @Override
   public Set<String> getEmailAddresses(IdentifiedUser user) {
-    Collection<AccountExternalId> ids = user.state().getExternalIds();
+    Collection<ExternalId> ids = user.state().getExternalIds();
     Set<String> emails = Sets.newHashSetWithExpectedSize(ids.size());
-    for (AccountExternalId ext : ids) {
-      if (!Strings.isNullOrEmpty(ext.getEmailAddress())) {
-        emails.add(ext.getEmailAddress());
+    for (ExternalId ext : ids) {
+      if (!Strings.isNullOrEmpty(ext.email())) {
+        emails.add(ext.email());
       }
     }
     return emails;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
index 9001ea5..e73d82b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
-
 import java.util.Set;
 
 /** Translates an email address to a set of matching accounts. */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
index 0856616..0a9d32d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
@@ -18,10 +18,8 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -30,30 +28,24 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Translates an email address to a set of matching accounts. */
 @Singleton
 public class AccountByEmailCacheImpl implements AccountByEmailCache {
-  private static final Logger log = LoggerFactory
-      .getLogger(AccountByEmailCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(AccountByEmailCacheImpl.class);
   private static final String CACHE_NAME = "accounts_byemail";
 
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(CACHE_NAME,
-            String.class,
-            new TypeLiteral<Set<Account.Id>>() {})
-          .loader(Loader.class);
+        cache(CACHE_NAME, String.class, new TypeLiteral<Set<Account.Id>>() {}).loader(Loader.class);
         bind(AccountByEmailCacheImpl.class);
         bind(AccountByEmailCache.class).to(AccountByEmailCacheImpl.class);
       }
@@ -63,8 +55,7 @@
   private final LoadingCache<String, Set<Account.Id>> cache;
 
   @Inject
-  AccountByEmailCacheImpl(
-      @Named(CACHE_NAME) LoadingCache<String, Set<Account.Id>> cache) {
+  AccountByEmailCacheImpl(@Named(CACHE_NAME) LoadingCache<String, Set<Account.Id>> cache) {
     this.cache = cache;
   }
 
@@ -87,15 +78,11 @@
 
   static class Loader extends CacheLoader<String, Set<Account.Id>> {
     private final SchemaFactory<ReviewDb> schema;
-    private final AccountIndexCollection accountIndexes;
     private final Provider<InternalAccountQuery> accountQueryProvider;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema,
-        AccountIndexCollection accountIndexes,
-        Provider<InternalAccountQuery> accountQueryProvider) {
+    Loader(SchemaFactory<ReviewDb> schema, Provider<InternalAccountQuery> accountQueryProvider) {
       this.schema = schema;
-      this.accountIndexes = accountIndexes;
       this.accountQueryProvider = accountQueryProvider;
     }
 
@@ -106,18 +93,13 @@
         for (Account a : db.accounts().byPreferredEmail(email)) {
           r.add(a.getId());
         }
-        if (accountIndexes.getSearchIndex() != null) {
-          for (AccountState accountState : accountQueryProvider.get()
-              .byExternalId(
-                  (new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO,
-                      email)).get())) {
+        for (AccountState accountState : accountQueryProvider.get().byEmailPrefix(email)) {
+          if (accountState.getExternalIds().stream()
+              .filter(e -> email.equals(e.email()))
+              .findAny()
+              .isPresent()) {
             r.add(accountState.getAccount().getId());
           }
-        } else {
-          for (AccountExternalId a : db.accountExternalIds()
-              .byEmailAddress(email)) {
-            r.add(a.getAccountId());
-          }
         }
         return ImmutableSet.copyOf(r);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
index 3a4566a..df6b122 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
@@ -14,14 +14,42 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-
 import java.io.IOException;
 
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
+  /**
+   * Returns an {@code AccountState} instance for the given account ID. If not cached yet the
+   * account is loaded. Returns an empty {@code AccountState} instance to represent a missing
+   * account.
+   *
+   * @param accountId ID of the account that should be retrieved
+   * @return {@code AccountState} instance for the given account ID, if no account with this ID
+   *     exists an empty {@code AccountState} instance is returned to represent the missing account
+   */
   AccountState get(Account.Id accountId);
 
+  /**
+   * Returns an {@code AccountState} instance for the given account ID. If not cached yet the
+   * account is loaded. Returns {@code null} if the account is missing.
+   *
+   * @param accountId ID of the account that should be retrieved
+   * @return {@code AccountState} instance for the given account ID, if no account with this ID
+   *     exists {@code null} is returned
+   */
+  @Nullable
+  AccountState getOrNull(Account.Id accountId);
+
+  /**
+   * Returns an {@code AccountState} instance for the given account ID if it is present in the
+   * cache.
+   *
+   * @param accountId ID of the account that should be retrieved
+   * @return {@code AccountState} instance for the given account ID if it is present in the cache,
+   *     otherwise {@code null}
+   */
   AccountState getIfPresent(Account.Id accountId);
 
   AccountState getByUsername(String username);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 149931d..1828cca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,22 +14,22 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Optional;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -40,41 +40,34 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Caches important (but small) account state to avoid database hits. */
 @Singleton
 public class AccountCacheImpl implements AccountCache {
-  private static final Logger log = LoggerFactory
-      .getLogger(AccountCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(AccountCacheImpl.class);
 
   private static final String BYID_NAME = "accounts";
   private static final String BYUSER_NAME = "accounts_byname";
 
-  public static Module module() {
+  public static Module module(boolean useReviewdb) {
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(BYID_NAME, Account.Id.class, AccountState.class)
-          .loader(ByIdLoader.class);
+        cache(BYID_NAME, Account.Id.class, new TypeLiteral<Optional<AccountState>>() {})
+            .loader(ByIdLoader.class);
 
-        cache(BYUSER_NAME,
-            String.class,
-            new TypeLiteral<Optional<Account.Id>>() {})
-          .loader(ByNameLoader.class);
+        cache(BYUSER_NAME, String.class, new TypeLiteral<Optional<Account.Id>>() {})
+            .loader(useReviewdb ? ByNameReviewDbLoader.class : ByNameLoader.class);
 
         bind(AccountCacheImpl.class);
         bind(AccountCache.class).to(AccountCacheImpl.class);
@@ -82,12 +75,13 @@
     };
   }
 
-  private final LoadingCache<Account.Id, AccountState> byId;
+  private final LoadingCache<Account.Id, Optional<AccountState>> byId;
   private final LoadingCache<String, Optional<Account.Id>> byName;
   private final Provider<AccountIndexer> indexer;
 
   @Inject
-  AccountCacheImpl(@Named(BYID_NAME) LoadingCache<Account.Id, AccountState> byId,
+  AccountCacheImpl(
+      @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
       @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
       Provider<AccountIndexer> indexer) {
     this.byId = byId;
@@ -98,7 +92,7 @@
   @Override
   public AccountState get(Account.Id accountId) {
     try {
-      return byId.get(accountId);
+      return byId.get(accountId).orElse(missing(accountId));
     } catch (ExecutionException e) {
       log.warn("Cannot load AccountState for " + accountId, e);
       return missing(accountId);
@@ -106,15 +100,27 @@
   }
 
   @Override
+  @Nullable
+  public AccountState getOrNull(Account.Id accountId) {
+    try {
+      return byId.get(accountId).orElse(null);
+    } catch (ExecutionException e) {
+      log.warn("Cannot load AccountState for " + accountId, e);
+      return null;
+    }
+  }
+
+  @Override
   public AccountState getIfPresent(Account.Id accountId) {
-    return byId.getIfPresent(accountId);
+    Optional<AccountState> state = byId.getIfPresent(accountId);
+    return state != null ? state.orElse(missing(accountId)) : null;
   }
 
   @Override
   public AccountState getByUsername(String username) {
     try {
       Optional<Account.Id> id = byName.get(username);
-      return id != null && id.isPresent() ? byId.get(id.get()) : null;
+      return id != null && id.isPresent() ? getOrNull(id.get()) : null;
     } catch (ExecutionException e) {
       log.warn("Cannot load AccountState for " + username, e);
       return null;
@@ -147,60 +153,56 @@
   private static AccountState missing(Account.Id accountId) {
     Account account = new Account(accountId, TimeUtil.nowTs());
     account.setActive(false);
-    Collection<AccountExternalId> ids = Collections.emptySet();
     Set<AccountGroup.UUID> anon = ImmutableSet.of();
-    return new AccountState(account, anon, ids,
-        new HashMap<ProjectWatchKey, Set<NotifyType>>());
+    return new AccountState(
+        account, anon, Collections.emptySet(), new HashMap<ProjectWatchKey, Set<NotifyType>>());
   }
 
-  static class ByIdLoader extends CacheLoader<Account.Id, AccountState> {
+  static class ByIdLoader extends CacheLoader<Account.Id, Optional<AccountState>> {
     private final SchemaFactory<ReviewDb> schema;
     private final GroupCache groupCache;
     private final GeneralPreferencesLoader loader;
     private final LoadingCache<String, Optional<Account.Id>> byName;
-    private final boolean readFromGit;
     private final Provider<WatchConfig.Accessor> watchConfig;
 
     @Inject
-    ByIdLoader(SchemaFactory<ReviewDb> sf,
+    ByIdLoader(
+        SchemaFactory<ReviewDb> sf,
         GroupCache groupCache,
         GeneralPreferencesLoader loader,
-        @Named(BYUSER_NAME) LoadingCache<String,
-            Optional<Account.Id>> byUsername,
-        @GerritServerConfig Config cfg,
+        @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
         Provider<WatchConfig.Accessor> watchConfig) {
       this.schema = sf;
       this.groupCache = groupCache;
       this.loader = loader;
       this.byName = byUsername;
-      this.readFromGit =
-          cfg.getBoolean("user", null, "readProjectWatchesFromGit", false);
       this.watchConfig = watchConfig;
     }
 
     @Override
-    public AccountState load(Account.Id key) throws Exception {
+    public Optional<AccountState> load(Account.Id key) throws Exception {
       try (ReviewDb db = schema.open()) {
-        final AccountState state = load(db, key);
-        String user = state.getUserName();
+        Optional<AccountState> state = load(db, key);
+        if (!state.isPresent()) {
+          return state;
+        }
+        String user = state.get().getUserName();
         if (user != null) {
-          byName.put(user, Optional.of(state.getAccount().getId()));
+          byName.put(user, Optional.of(state.get().getAccount().getId()));
         }
         return state;
       }
     }
 
-    private AccountState load(final ReviewDb db, final Account.Id who)
+    private Optional<AccountState> load(final ReviewDb db, final Account.Id who)
         throws OrmException, IOException, ConfigInvalidException {
       Account account = db.accounts().get(who);
       if (account == null) {
-        // Account no longer exists? They are anonymous.
-        return missing(who);
+        return Optional.empty();
       }
 
-      Collection<AccountExternalId> externalIds =
-          Collections.unmodifiableCollection(
-              db.accountExternalIds().byAccount(who).toList());
+      Set<ExternalId> externalIds =
+          ExternalId.from(db.accountExternalIds().byAccount(who).toList());
 
       Set<AccountGroup.UUID> internalGroups = new HashSet<>();
       for (AccountGroupMember g : db.accountGroupMembers().byAccount(who)) {
@@ -215,55 +217,48 @@
       try {
         account.setGeneralPreferences(loader.load(who));
       } catch (IOException | ConfigInvalidException e) {
-        log.warn("Cannot load GeneralPreferences for " + who +
-            " (using default)", e);
+        log.warn("Cannot load GeneralPreferences for " + who + " (using default)", e);
         account.setGeneralPreferences(GeneralPreferencesInfo.defaults());
       }
 
-      Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
-          readFromGit
-              ? watchConfig.get().getProjectWatches(who)
-              : GetWatchedProjects.readProjectWatchesFromDb(db, who);
+      return Optional.of(
+          new AccountState(
+              account, internalGroups, externalIds, watchConfig.get().getProjectWatches(who)));
+    }
+  }
 
-      return new AccountState(account, internalGroups, externalIds,
-          projectWatches);
+  static class ByNameReviewDbLoader extends CacheLoader<String, Optional<Account.Id>> {
+    private final SchemaFactory<ReviewDb> dbProvider;
+
+    @Inject
+    public ByNameReviewDbLoader(SchemaFactory<ReviewDb> dbProvider) {
+      this.dbProvider = dbProvider;
+    }
+
+    @Override
+    public Optional<Account.Id> load(String username) throws Exception {
+      try (ReviewDb db = dbProvider.open()) {
+        return Optional.ofNullable(
+                db.accountExternalIds()
+                    .get(new AccountExternalId.Key(SCHEME_USERNAME + ":" + username)))
+            .map(AccountExternalId::getAccountId);
+      }
     }
   }
 
   static class ByNameLoader extends CacheLoader<String, Optional<Account.Id>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final AccountIndexCollection accountIndexes;
     private final Provider<InternalAccountQuery> accountQueryProvider;
 
     @Inject
-    ByNameLoader(SchemaFactory<ReviewDb> sf,
-        AccountIndexCollection accountIndexes,
-        Provider<InternalAccountQuery> accountQueryProvider) {
-      this.schema = sf;
-      this.accountIndexes = accountIndexes;
+    ByNameLoader(Provider<InternalAccountQuery> accountQueryProvider) {
       this.accountQueryProvider = accountQueryProvider;
     }
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-        AccountExternalId.Key key = new AccountExternalId.Key( //
-            AccountExternalId.SCHEME_USERNAME, //
-            username);
-      if (accountIndexes.getSearchIndex() != null) {
-        AccountState accountState =
-            accountQueryProvider.get().oneByExternalId(key.get());
-        return accountState != null
-            ? Optional.of(accountState.getAccount().getId())
-            : Optional.<Account.Id>absent();
-      }
-
-      try (ReviewDb db = schema.open()) {
-        AccountExternalId id = db.accountExternalIds().get(key);
-        if (id != null) {
-          return Optional.of(id.getAccountId());
-        }
-        return Optional.absent();
-      }
+      AccountState accountState =
+          accountQueryProvider.get().oneByExternalId(SCHEME_USERNAME, username);
+      return Optional.ofNullable(accountState).map(s -> s.getAccount().getId());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
index c5b0699..d682909 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Predicate;
-import com.google.common.collect.Sets;
+import static java.util.stream.Collectors.toSet;
+
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -27,8 +27,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import java.util.HashSet;
 import java.util.Set;
 
 /** Access control management for one account's access to other accounts. */
@@ -41,7 +39,8 @@
     private final AccountVisibility accountVisibility;
 
     @Inject
-    Factory(final ProjectCache projectCache,
+    Factory(
+        final ProjectCache projectCache,
         final GroupControl.Factory groupControlFactory,
         final Provider<CurrentUser> user,
         final IdentifiedUser.GenericFactory userFactory,
@@ -54,8 +53,8 @@
     }
 
     public AccountControl get() {
-      return new AccountControl(projectCache, groupControlFactory, user.get(),
-          userFactory, accountVisibility);
+      return new AccountControl(
+          projectCache, groupControlFactory, user.get(), userFactory, accountVisibility);
     }
   }
 
@@ -65,13 +64,13 @@
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountVisibility accountVisibility;
 
-  AccountControl(final ProjectCache projectCache,
-        final GroupControl.Factory groupControlFactory,
-        final CurrentUser user,
-        final IdentifiedUser.GenericFactory userFactory,
-        final AccountVisibility accountVisibility) {
-    this.accountsSection =
-        projectCache.getAllProjects().getConfig().getAccountsSection();
+  AccountControl(
+      final ProjectCache projectCache,
+      final GroupControl.Factory groupControlFactory,
+      final CurrentUser user,
+      final IdentifiedUser.GenericFactory userFactory,
+      final AccountVisibility accountVisibility) {
+    this.accountsSection = projectCache.getAllProjects().getConfig().getAccountsSection();
     this.groupControlFactory = groupControlFactory;
     this.user = user;
     this.userFactory = userFactory;
@@ -83,65 +82,63 @@
   }
 
   /**
-   * Returns true if the current user is allowed to see the otherUser, based
-   * on the account visibility policy. Depending on the group membership
-   * realms supported, this may not be able to determine SAME_GROUP or
-   * VISIBLE_GROUP correctly (defaulting to not being visible). This is because
-   * {@link GroupMembership#getKnownGroups()} may only return a subset of the
-   * effective groups.
+   * Returns true if the current user is allowed to see the otherUser, based on the account
+   * visibility policy. Depending on the group membership realms supported, this may not be able to
+   * determine SAME_GROUP or VISIBLE_GROUP correctly (defaulting to not being visible). This is
+   * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective
+   * groups.
    */
   public boolean canSee(Account otherUser) {
     return canSee(otherUser.getId());
   }
 
   /**
-   * Returns true if the current user is allowed to see the otherUser, based
-   * on the account visibility policy. Depending on the group membership
-   * realms supported, this may not be able to determine SAME_GROUP or
-   * VISIBLE_GROUP correctly (defaulting to not being visible). This is because
-   * {@link GroupMembership#getKnownGroups()} may only return a subset of the
-   * effective groups.
+   * Returns true if the current user is allowed to see the otherUser, based on the account
+   * visibility policy. Depending on the group membership realms supported, this may not be able to
+   * determine SAME_GROUP or VISIBLE_GROUP correctly (defaulting to not being visible). This is
+   * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective
+   * groups.
    */
   public boolean canSee(final Account.Id otherUser) {
-    return canSee(new OtherUser() {
-      @Override
-      Account.Id getId() {
-        return otherUser;
-      }
+    return canSee(
+        new OtherUser() {
+          @Override
+          Account.Id getId() {
+            return otherUser;
+          }
 
-      @Override
-      IdentifiedUser createUser() {
-        return userFactory.create(otherUser);
-      }
-    });
+          @Override
+          IdentifiedUser createUser() {
+            return userFactory.create(otherUser);
+          }
+        });
   }
 
   /**
-   * Returns true if the current user is allowed to see the otherUser, based
-   * on the account visibility policy. Depending on the group membership
-   * realms supported, this may not be able to determine SAME_GROUP or
-   * VISIBLE_GROUP correctly (defaulting to not being visible). This is because
-   * {@link GroupMembership#getKnownGroups()} may only return a subset of the
-   * effective groups.
+   * Returns true if the current user is allowed to see the otherUser, based on the account
+   * visibility policy. Depending on the group membership realms supported, this may not be able to
+   * determine SAME_GROUP or VISIBLE_GROUP correctly (defaulting to not being visible). This is
+   * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective
+   * groups.
    */
   public boolean canSee(final AccountState otherUser) {
-    return canSee(new OtherUser() {
-      @Override
-      Account.Id getId() {
-        return otherUser.getAccount().getId();
-      }
+    return canSee(
+        new OtherUser() {
+          @Override
+          Account.Id getId() {
+            return otherUser.getAccount().getId();
+          }
 
-      @Override
-      IdentifiedUser createUser() {
-        return userFactory.create(otherUser);
-      }
-    });
+          @Override
+          IdentifiedUser createUser() {
+            return userFactory.create(otherUser);
+          }
+        });
   }
 
   private boolean canSee(OtherUser otherUser) {
     // Special case: I can always see myself.
-    if (user.isIdentifiedUser()
-        && user.getAccountId().equals(otherUser.getId())) {
+    if (user.isIdentifiedUser() && user.getAccountId().equals(otherUser.getId())) {
       return true;
     }
     if (user.getCapabilities().canViewAllAccounts()) {
@@ -151,32 +148,34 @@
     switch (accountVisibility) {
       case ALL:
         return true;
-      case SAME_GROUP: {
-        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
-        for (PermissionRule rule : accountsSection.getSameGroupVisibility()) {
-          if (rule.isBlock() || rule.isDeny()) {
-            usersGroups.remove(rule.getGroup().getUUID());
-          }
-        }
-
-        if (user.getEffectiveGroups().containsAnyOf(usersGroups)) {
-          return true;
-        }
-        break;
-      }
-      case VISIBLE_GROUP: {
-        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
-        for (AccountGroup.UUID usersGroup : usersGroups) {
-          try {
-            if (groupControlFactory.controlFor(usersGroup).isVisible()) {
-              return true;
+      case SAME_GROUP:
+        {
+          Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
+          for (PermissionRule rule : accountsSection.getSameGroupVisibility()) {
+            if (rule.isBlock() || rule.isDeny()) {
+              usersGroups.remove(rule.getGroup().getUUID());
             }
-          } catch (NoSuchGroupException e) {
-            continue;
           }
+
+          if (user.getEffectiveGroups().containsAnyOf(usersGroups)) {
+            return true;
+          }
+          break;
         }
-        break;
-      }
+      case VISIBLE_GROUP:
+        {
+          Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
+          for (AccountGroup.UUID usersGroup : usersGroups) {
+            try {
+              if (groupControlFactory.controlFor(usersGroup).isVisible()) {
+                return true;
+              }
+            } catch (NoSuchGroupException e) {
+              continue;
+            }
+          }
+          break;
+        }
       case NONE:
         break;
       default:
@@ -186,14 +185,9 @@
   }
 
   private Set<AccountGroup.UUID> groupsOf(IdentifiedUser user) {
-    return new HashSet<>(Sets.filter(
-      user.getEffectiveGroups().getKnownGroups(),
-      new Predicate<AccountGroup.UUID>() {
-        @Override
-        public boolean apply(AccountGroup.UUID in) {
-          return !SystemGroupBackend.isSystemGroup(in);
-        }
-      }));
+    return user.getEffectiveGroups().getKnownGroups().stream()
+        .filter(a -> !SystemGroupBackend.isSystemGroup(a))
+        .collect(toSet());
   }
 
   private abstract static class OtherUser {
@@ -207,6 +201,7 @@
     }
 
     abstract IdentifiedUser createUser();
+
     abstract Account.Id getId();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
index 63d2fc6..5c14c94 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.common.AccountInfo;
-
 import java.util.Set;
 
 /**
  * Directory of user account information.
  *
- * Implementations supply data to Gerrit about user accounts.
+ * <p>Implementations supply data to Gerrit about user accounts.
  */
 public abstract class AccountDirectory {
   /** Fields to be populated for a REST API response. */
@@ -42,20 +41,17 @@
     USERNAME,
 
     /** Numeric account ID, may be deprecated. */
-    ID
+    ID,
+
+    /** The user-settable status of this account (e.g. busy, OOO, available) */
+    STATUS
   }
 
-  public abstract void fillAccountInfo(
-      Iterable<? extends AccountInfo> in,
-      Set<FillOptions> options)
+  public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
       throws DirectoryException;
 
   @SuppressWarnings("serial")
   public static class DirectoryException extends Exception {
-    public DirectoryException(String message) {
-      super(message);
-    }
-
     public DirectoryException(String message, Throwable why) {
       super(message, why);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountInfoCacheFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountInfoCacheFactory.java
deleted file mode 100644
index 32781f0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountInfoCacheFactory.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.common.data.AccountInfo;
-import com.google.gerrit.common.data.AccountInfoCache;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.inject.Inject;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-/** Efficiently builds an {@link AccountInfoCache}. */
-public class AccountInfoCacheFactory {
-  public interface Factory {
-    AccountInfoCacheFactory create();
-  }
-
-  private final AccountCache accountCache;
-  private final Map<Account.Id, Account> out;
-
-  @Inject
-  AccountInfoCacheFactory(final AccountCache accountCache) {
-    this.accountCache = accountCache;
-    this.out = new HashMap<>();
-  }
-
-  /**
-   * Indicate an account will be needed later on.
-   *
-   * @param id identity that will be needed in the future; may be null.
-   */
-  public void want(final Account.Id id) {
-    if (id != null && !out.containsKey(id)) {
-      out.put(id, accountCache.get(id).getAccount());
-    }
-  }
-
-  /** Indicate one or more accounts will be needed later on. */
-  public void want(final Iterable<Account.Id> ids) {
-    for (final Account.Id id : ids) {
-      want(id);
-    }
-  }
-
-  public Account get(final Account.Id id) {
-    want(id);
-    return out.get(id);
-  }
-
-  /**
-   * Create an AccountInfoCache with the currently loaded Account entities.
-   * */
-  public AccountInfoCache create() {
-    final List<AccountInfo> r = new ArrayList<>(out.size());
-    for (final Account a : out.values()) {
-      r.add(new AccountInfo(a));
-    }
-    return new AccountInfoCache(r);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
index f84d399..a137256b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLoader.java
@@ -25,7 +25,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -37,15 +36,18 @@
 
 public class AccountLoader {
   public static final Set<FillOptions> DETAILED_OPTIONS =
-      Collections.unmodifiableSet(EnumSet.of(
-          FillOptions.ID,
-          FillOptions.NAME,
-          FillOptions.EMAIL,
-          FillOptions.USERNAME,
-          FillOptions.AVATARS));
+      Collections.unmodifiableSet(
+          EnumSet.of(
+              FillOptions.ID,
+              FillOptions.NAME,
+              FillOptions.EMAIL,
+              FillOptions.USERNAME,
+              FillOptions.STATUS,
+              FillOptions.AVATARS));
 
   public interface Factory {
     AccountLoader create(boolean detailed);
+
     AccountLoader create(Set<FillOptions> options);
   }
 
@@ -55,17 +57,12 @@
   private final List<AccountInfo> provided;
 
   @AssistedInject
-  AccountLoader(InternalAccountDirectory directory,
-      @Assisted boolean detailed) {
-    this(directory,
-        detailed
-            ? DETAILED_OPTIONS
-            : InternalAccountDirectory.ID_ONLY);
+  AccountLoader(InternalAccountDirectory directory, @Assisted boolean detailed) {
+    this(directory, detailed ? DETAILED_OPTIONS : InternalAccountDirectory.ID_ONLY);
   }
 
   @AssistedInject
-  AccountLoader(InternalAccountDirectory directory,
-      @Assisted Set<FillOptions> options) {
+  AccountLoader(InternalAccountDirectory directory, @Assisted Set<FillOptions> options) {
     this.directory = directory;
     this.options = options;
     created = new HashMap<>();
@@ -91,19 +88,23 @@
 
   public void fill() throws OrmException {
     try {
-      directory.fillAccountInfo(
-          Iterables.concat(created.values(), provided), options);
+      directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
     } catch (DirectoryException e) {
-      Throwables.propagateIfPossible(e.getCause(), OrmException.class);
+      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
       throw new OrmException(e);
     }
   }
 
-  public void fill(Collection<? extends AccountInfo> infos)
-      throws OrmException {
+  public void fill(Collection<? extends AccountInfo> infos) throws OrmException {
     for (AccountInfo info : infos) {
       put(info);
     }
     fill();
   }
+
+  public AccountInfo fillOne(Account.Id id) throws OrmException {
+    AccountInfo info = get(id);
+    fill();
+    return info;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index 1e46409..21fb280 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import static java.util.stream.Collectors.toSet;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
@@ -21,33 +23,31 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
-
 /** Tracks authentication related details for user accounts. */
 @Singleton
 public class AccountManager {
-  private static final Logger log =
-      LoggerFactory.getLogger(AccountManager.class);
+  private static final Logger log = LoggerFactory.getLogger(AccountManager.class);
 
   private final SchemaFactory<ReviewDb> schema;
   private final AccountCache byIdCache;
@@ -58,16 +58,19 @@
   private final ProjectCache projectCache;
   private final AtomicBoolean awaitsFirstAccountCheck;
   private final AuditService auditService;
+  private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   @Inject
-  AccountManager(SchemaFactory<ReviewDb> schema,
+  AccountManager(
+      SchemaFactory<ReviewDb> schema,
       AccountCache byIdCache,
       AccountByEmailCache byEmailCache,
       Realm accountMapper,
       IdentifiedUser.GenericFactory userFactory,
       ChangeUserName.Factory changeUserNameFactory,
       ProjectCache projectCache,
-      AuditService auditService) {
+      AuditService auditService,
+      ExternalIdsUpdate.Server externalIdsUpdateFactory) {
     this.schema = schema;
     this.byIdCache = byIdCache;
     this.byEmailCache = byEmailCache;
@@ -77,18 +80,14 @@
     this.projectCache = projectCache;
     this.awaitsFirstAccountCheck = new AtomicBoolean(true);
     this.auditService = auditService;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
-  /**
-   * @return user identified by this external identity string, or null.
-   */
-  public Account.Id lookup(String externalId) throws AccountException {
-    try {
-      try (ReviewDb db = schema.open()) {
-        AccountExternalId ext =
-            db.accountExternalIds().get(new AccountExternalId.Key(externalId));
-        return ext != null ? ext.getAccountId() : null;
-      }
+  /** @return user identified by this external identity string */
+  public Optional<Account.Id> lookup(String externalId) throws AccountException {
+    try (ReviewDb db = schema.open()) {
+      ExternalId extId = findExternalId(db, ExternalId.Key.parse(externalId));
+      return extId != null ? Optional.of(extId.accountId()) : Optional.empty();
     } catch (OrmException e) {
       throw new AccountException("Cannot lookup account " + externalId, e);
     }
@@ -99,16 +98,14 @@
    *
    * @param who identity of the user, with any details we received about them.
    * @return the result of authenticating the user.
-   * @throws AccountException the account does not exist, and cannot be created,
-   *         or exists, but cannot be located, or is inactive.
+   * @throws AccountException the account does not exist, and cannot be created, or exists, but
+   *     cannot be located, or is inactive.
    */
-  public AuthResult authenticate(AuthRequest who)
-      throws AccountException, IOException {
+  public AuthResult authenticate(AuthRequest who) throws AccountException, IOException {
     who = realm.authenticate(who);
     try {
       try (ReviewDb db = schema.open()) {
-        AccountExternalId.Key key = id(who);
-        AccountExternalId id = getAccountExternalId(db, key);
+        ExternalId id = findExternalId(db, who.getExternalIdKey());
         if (id == null) {
           // New account, automatically create and return.
           //
@@ -116,73 +113,59 @@
         }
 
         // Account exists
-        Account act = byIdCache.get(id.getAccountId()).getAccount();
+        Account act = byIdCache.get(id.accountId()).getAccount();
         if (!act.isActive()) {
           throw new AccountException("Authentication error, account inactive");
         }
 
         // return the identity to the caller.
         update(db, who, id);
-        return new AuthResult(id.getAccountId(), key, false);
+        return new AuthResult(id.accountId(), who.getExternalIdKey(), false);
       }
-    } catch (OrmException e) {
+    } catch (OrmException | ConfigInvalidException e) {
       throw new AccountException("Authentication error", e);
     }
   }
 
-  private AccountExternalId getAccountExternalId(ReviewDb db,
-      AccountExternalId.Key key) throws OrmException {
-    // We don't have at the moment an account_by_external_id cache
-    // but by using the accounts cache we get the list of external_ids
-    // without having to query the DB every time
-    if (key.getScheme().equals(AccountExternalId.SCHEME_GERRIT)
-        || key.getScheme().equals(AccountExternalId.SCHEME_USERNAME)) {
-      AccountState state = byIdCache.getByUsername(
-          key.get().substring(key.getScheme().length()));
-      if (state != null) {
-        for (AccountExternalId accountExternalId : state.getExternalIds()) {
-          if (accountExternalId.getKey().equals(key)) {
-            return accountExternalId;
-          }
-        }
-      }
-    }
-    return db.accountExternalIds().get(key);
+  private ExternalId findExternalId(ReviewDb db, ExternalId.Key key) throws OrmException {
+    return ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
   }
 
-  private void update(ReviewDb db, AuthRequest who, AccountExternalId extId)
+  private void update(ReviewDb db, AuthRequest who, ExternalId extId)
       throws OrmException, IOException {
-    IdentifiedUser user = userFactory.create(extId.getAccountId());
+    IdentifiedUser user = userFactory.create(extId.accountId());
     Account toUpdate = null;
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
     //
     String newEmail = who.getEmailAddress();
-    String oldEmail = extId.getEmailAddress();
+    String oldEmail = extId.email();
     if (newEmail != null && !newEmail.equals(oldEmail)) {
-      if (oldEmail != null
-          && oldEmail.equals(user.getAccount().getPreferredEmail())) {
+      if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
         toUpdate = load(toUpdate, user.getAccountId(), db);
         toUpdate.setPreferredEmail(newEmail);
       }
 
-      extId.setEmailAddress(newEmail);
-      db.accountExternalIds().update(Collections.singleton(extId));
+      externalIdsUpdateFactory
+          .create()
+          .replace(
+              db,
+              extId,
+              ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password()));
     }
 
-    if (!realm.allowsEdit(Account.FieldName.FULL_NAME)
+    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
         && !Strings.isNullOrEmpty(who.getDisplayName())
         && !eq(user.getAccount().getFullName(), who.getDisplayName())) {
       toUpdate = load(toUpdate, user.getAccountId(), db);
       toUpdate.setFullName(who.getDisplayName());
     }
 
-    if (!realm.allowsEdit(Account.FieldName.USER_NAME)
+    if (!realm.allowsEdit(AccountFieldName.USER_NAME)
         && who.getUserName() != null
         && !eq(user.getUserName(), who.getUserName())) {
-      log.warn(String.format("Not changing already set username %s to %s",
-          user.getUserName(), who.getUserName()));
+      log.warn("Not changing already set username {} to {}", user.getUserName(), who.getUserName());
     }
 
     if (toUpdate != null) {
@@ -198,8 +181,7 @@
     }
   }
 
-  private Account load(Account toUpdate, Account.Id accountId, ReviewDb db)
-      throws OrmException {
+  private Account load(Account toUpdate, Account.Id accountId, ReviewDb db) throws OrmException {
     if (toUpdate == null) {
       toUpdate = db.accounts().get(accountId);
       if (toUpdate == null) {
@@ -214,32 +196,36 @@
   }
 
   private AuthResult create(ReviewDb db, AuthRequest who)
-      throws OrmException, AccountException, IOException {
+      throws OrmException, AccountException, IOException, ConfigInvalidException {
     Account.Id newId = new Account.Id(db.nextAccountId());
+    log.debug("Assigning new Id {} to account", newId);
     Account account = new Account(newId, TimeUtil.nowTs());
-    AccountExternalId extId = createId(newId, who);
 
-    extId.setEmailAddress(who.getEmailAddress());
+    ExternalId extId =
+        ExternalId.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
+    log.debug("Created external Id: {}", extId);
     account.setFullName(who.getDisplayName());
-    account.setPreferredEmail(extId.getEmailAddress());
+    account.setPreferredEmail(extId.email());
 
-    boolean isFirstAccount = awaitsFirstAccountCheck.getAndSet(false)
-      && db.accounts().anyAccounts().toList().isEmpty();
+    boolean isFirstAccount =
+        awaitsFirstAccountCheck.getAndSet(false) && db.accounts().anyAccounts().toList().isEmpty();
 
     try {
       db.accounts().upsert(Collections.singleton(account));
 
-      AccountExternalId existingExtId =
-          db.accountExternalIds().get(extId.getKey());
-      if (existingExtId != null
-          && !existingExtId.getAccountId().equals(extId.getAccountId())) {
+      ExternalId existingExtId =
+          ExternalId.from(db.accountExternalIds().get(extId.key().asAccountExternalIdKey()));
+      if (existingExtId != null && !existingExtId.accountId().equals(extId.accountId())) {
         // external ID is assigned to another account, do not overwrite
         db.accounts().delete(Collections.singleton(account));
         throw new AccountException(
-            "Cannot assign external ID \"" + extId.getExternalId()
-                + "\" to account " + newId + "; external ID already in use.");
+            "Cannot assign external ID \""
+                + extId.key().get()
+                + "\" to account "
+                + newId
+                + "; external ID already in use.");
       }
-      db.accountExternalIds().upsert(Collections.singleton(extId));
+      externalIdsUpdateFactory.create().upsert(db, extId);
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
@@ -252,35 +238,50 @@
       // is going to be the site's administrator and just make them that
       // to bootstrap the authentication database.
       //
-      Permission admin = projectCache.getAllProjects()
-          .getConfig()
-          .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
-          .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
+      Permission admin =
+          projectCache
+              .getAllProjects()
+              .getConfig()
+              .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+              .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
       AccountGroup.UUID uuid = admin.getRules().get(0).getGroup().getUUID();
-      AccountGroup g = db.accountGroups().byUUID(uuid).iterator().next();
+      Iterator<AccountGroup> adminGroupIt = db.accountGroups().byUUID(uuid).iterator();
+      if (!adminGroupIt.hasNext()) {
+        throw new OrmException(
+            "Administrator group's UUID is misaligned in backend and All-Projects repository");
+      }
+      AccountGroup g = adminGroupIt.next();
       AccountGroup.Id adminId = g.getId();
-      AccountGroupMember m =
-          new AccountGroupMember(new AccountGroupMember.Key(newId, adminId));
+      AccountGroupMember m = new AccountGroupMember(new AccountGroupMember.Key(newId, adminId));
       auditService.dispatchAddAccountsToGroup(newId, Collections.singleton(m));
       db.accountGroupMembers().insert(Collections.singleton(m));
     }
 
+    log.debug("Username from AuthRequest: {}", who.getUserName());
     if (who.getUserName() != null) {
+      log.debug("Setting username for: {}", who.getUserName());
       // Only set if the name hasn't been used yet, but was given to us.
       //
       IdentifiedUser user = userFactory.create(newId);
+      log.debug("Identified user {} was created from {}", user, who.getUserName());
       try {
         changeUserNameFactory.create(db, user, who.getUserName()).call();
       } catch (NameAlreadyUsedException e) {
         String message =
-            "Cannot assign user name \"" + who.getUserName() + "\" to account "
-                + newId + "; name already in use.";
+            "Cannot assign user name \""
+                + who.getUserName()
+                + "\" to account "
+                + newId
+                + "; name already in use.";
         handleSettingUserNameFailure(db, account, extId, message, e, false);
       } catch (InvalidUserNameException e) {
         String message =
-            "Cannot assign user name \"" + who.getUserName() + "\" to account "
-                + newId + "; name does not conform.";
+            "Cannot assign user name \""
+                + who.getUserName()
+                + "\" to account "
+                + newId
+                + "; name does not conform.";
         handleSettingUserNameFailure(db, account, extId, message, e, false);
       } catch (OrmException e) {
         String message = "Cannot assign user name";
@@ -291,38 +292,39 @@
     byEmailCache.evict(account.getPreferredEmail());
     byIdCache.evict(account.getId());
     realm.onCreateAccount(who, account);
-    return new AuthResult(newId, extId.getKey(), true);
+    return new AuthResult(newId, extId.key(), true);
   }
 
   /**
-   * This method handles an exception that occurred during the setting of the
-   * user name for a newly created account. If the realm does not allow the user
-   * to set a user name manually this method deletes the newly created account
-   * and throws an {@link AccountUserNameException}. In any case the error
-   * message is logged.
+   * This method handles an exception that occurred during the setting of the user name for a newly
+   * created account. If the realm does not allow the user to set a user name manually this method
+   * deletes the newly created account and throws an {@link AccountUserNameException}. In any case
+   * the error message is logged.
    *
    * @param db the database
    * @param account the newly created account
    * @param extId the newly created external id
    * @param errorMessage the error message
-   * @param e the exception that occurred during the setting of the user name
-   *        for the new account
-   * @param logException flag that decides whether the exception should be
-   *        included into the log
-   * @throws AccountUserNameException thrown if the realm does not allow the
-   *         user to manually set the user name
+   * @param e the exception that occurred during the setting of the user name for the new account
+   * @param logException flag that decides whether the exception should be included into the log
+   * @throws AccountUserNameException thrown if the realm does not allow the user to manually set
+   *     the user name
    * @throws OrmException thrown if cleaning the database failed
    */
-  private void handleSettingUserNameFailure(ReviewDb db, Account account,
-      AccountExternalId extId, String errorMessage, Exception e,
+  private void handleSettingUserNameFailure(
+      ReviewDb db,
+      Account account,
+      ExternalId extId,
+      String errorMessage,
+      Exception e,
       boolean logException)
-      throws AccountUserNameException, OrmException {
+      throws AccountUserNameException, OrmException, IOException {
     if (logException) {
       log.error(errorMessage, e);
     } else {
       log.error(errorMessage);
     }
-    if (!realm.allowsEdit(Account.FieldName.USER_NAME)) {
+    if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
       // setting the given user name has failed, but the realm does not
       // allow the user to manually set a user name,
       // this means we would end with an account without user name
@@ -332,120 +334,103 @@
       // this is why the best we can do here is to fail early and cleanup
       // the database
       db.accounts().delete(Collections.singleton(account));
-      db.accountExternalIds().delete(Collections.singleton(extId));
+      externalIdsUpdateFactory.create().delete(db, extId);
       throw new AccountUserNameException(errorMessage, e);
     }
   }
 
-  private static AccountExternalId createId(Account.Id newId, AuthRequest who) {
-    String ext = who.getExternalId();
-    return new AccountExternalId(newId, new AccountExternalId.Key(ext));
-  }
-
   /**
    * Link another authentication identity to an existing account.
    *
    * @param to account to link the identity onto.
    * @param who the additional identity.
    * @return the result of linking the identity to the user.
-   * @throws AccountException the identity belongs to a different account, or it
-   *         cannot be linked at this time.
+   * @throws AccountException the identity belongs to a different account, or it cannot be linked at
+   *     this time.
    */
   public AuthResult link(Account.Id to, AuthRequest who)
       throws AccountException, OrmException, IOException {
     try (ReviewDb db = schema.open()) {
-      AccountExternalId.Key key = id(who);
-      AccountExternalId extId = getAccountExternalId(db, key);
+      ExternalId extId = findExternalId(db, who.getExternalIdKey());
       if (extId != null) {
-        if (!extId.getAccountId().equals(to)) {
+        if (!extId.accountId().equals(to)) {
           throw new AccountException("Identity in use by another account");
         }
         update(db, who, extId);
       } else {
-        extId = createId(to, who);
-        extId.setEmailAddress(who.getEmailAddress());
-        db.accountExternalIds().insert(Collections.singleton(extId));
+        externalIdsUpdateFactory
+            .create()
+            .insert(
+                db, ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
 
         if (who.getEmailAddress() != null) {
           Account a = db.accounts().get(to);
           if (a.getPreferredEmail() == null) {
             a.setPreferredEmail(who.getEmailAddress());
             db.accounts().update(Collections.singleton(a));
+            byIdCache.evict(to);
           }
-        }
-
-        if (who.getEmailAddress() != null) {
           byEmailCache.evict(who.getEmailAddress());
         }
-        byIdCache.evict(to);
       }
 
-      return new AuthResult(to, key, false);
-
+      return new AuthResult(to, who.getExternalIdKey(), false);
     }
   }
 
   /**
    * Update the link to another unique authentication identity to an existing account.
    *
-   * Existing external identities with the same scheme will be removed and replaced
-   * with the new one.
+   * <p>Existing external identities with the same scheme will be removed and replaced with the new
+   * one.
    *
    * @param to account to link the identity onto.
    * @param who the additional identity.
    * @return the result of linking the identity to the user.
    * @throws OrmException
-   * @throws AccountException the identity belongs to a different account, or it
-   *         cannot be linked at this time.
+   * @throws AccountException the identity belongs to a different account, or it cannot be linked at
+   *     this time.
    */
-  public AuthResult updateLink(Account.Id to, AuthRequest who) throws OrmException,
-      AccountException, IOException {
+  public AuthResult updateLink(Account.Id to, AuthRequest who)
+      throws OrmException, AccountException, IOException {
     try (ReviewDb db = schema.open()) {
-      AccountExternalId.Key key = id(who);
-      List<AccountExternalId.Key> filteredKeysByScheme =
-          filterKeysByScheme(key.getScheme(), db.accountExternalIds()
-              .byAccount(to));
-      if (!filteredKeysByScheme.isEmpty()
-          && (filteredKeysByScheme.size() > 1 || !filteredKeysByScheme
-              .contains(key))) {
-        db.accountExternalIds().deleteKeys(filteredKeysByScheme);
+      Collection<ExternalId> filteredExtIdsByScheme =
+          ExternalId.from(db.accountExternalIds().byAccount(to).toList()).stream()
+              .filter(e -> e.isScheme(who.getExternalIdKey().scheme()))
+              .collect(toSet());
+
+      if (!filteredExtIdsByScheme.isEmpty()
+          && (filteredExtIdsByScheme.size() > 1
+              || !filteredExtIdsByScheme.stream()
+                  .filter(e -> e.key().equals(who.getExternalIdKey()))
+                  .findAny()
+                  .isPresent())) {
+        externalIdsUpdateFactory.create().delete(db, filteredExtIdsByScheme);
       }
       byIdCache.evict(to);
       return link(to, who);
     }
   }
 
-  private List<AccountExternalId.Key> filterKeysByScheme(
-      String keyScheme, ResultSet<AccountExternalId> externalIds) {
-    List<AccountExternalId.Key> filteredExternalIds = new ArrayList<>();
-    for (AccountExternalId accountExternalId : externalIds) {
-      if (accountExternalId.isScheme(keyScheme)) {
-        filteredExternalIds.add(accountExternalId.getKey());
-      }
-    }
-    return filteredExternalIds;
-  }
-
   /**
    * Unlink an authentication identity from an existing account.
    *
    * @param from account to unlink the identity from.
    * @param who the identity to delete
    * @return the result of unlinking the identity from the user.
-   * @throws AccountException the identity belongs to a different account, or it
-   *         cannot be unlinked at this time.
+   * @throws AccountException the identity belongs to a different account, or it cannot be unlinked
+   *     at this time.
    */
   public AuthResult unlink(Account.Id from, AuthRequest who)
       throws AccountException, OrmException, IOException {
     try (ReviewDb db = schema.open()) {
-      AccountExternalId.Key key = id(who);
-      AccountExternalId extId = getAccountExternalId(db, key);
+      ExternalId extId = findExternalId(db, who.getExternalIdKey());
       if (extId != null) {
-        if (!extId.getAccountId().equals(from)) {
+        if (!extId.accountId().equals(from)) {
           throw new AccountException(
-              "Identity '" + key.get() + "' in use by another account");
+              "Identity '" + who.getExternalIdKey().get() + "' in use by another account");
         }
-        db.accountExternalIds().delete(Collections.singleton(extId));
+        externalIdsUpdateFactory.create().delete(db, extId);
 
         if (who.getEmailAddress() != null) {
           Account a = db.accounts().get(from);
@@ -453,22 +438,16 @@
               && a.getPreferredEmail().equals(who.getEmailAddress())) {
             a.setPreferredEmail(null);
             db.accounts().update(Collections.singleton(a));
+            byIdCache.evict(from);
           }
           byEmailCache.evict(who.getEmailAddress());
-          byIdCache.evict(from);
         }
 
       } else {
-        throw new AccountException("Identity '" + key.get() + "' not found");
+        throw new AccountException("Identity '" + who.getExternalIdKey().get() + "' not found");
       }
 
-      return new AuthResult(from, key, false);
-
+      return new AuthResult(from, who.getExternalIdKey(), false);
     }
   }
-
-
-  private static AccountExternalId.Key id(AuthRequest who) {
-    return new AccountExternalId.Key(who.getExternalId());
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
index 5a18269..8691f86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
@@ -14,18 +14,15 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toSet;
+
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -38,31 +35,28 @@
   private final Realm realm;
   private final AccountByEmailCache byEmail;
   private final AccountCache byId;
-  private final AccountIndexCollection accountIndexes;
   private final Provider<InternalAccountQuery> accountQueryProvider;
 
   @Inject
-  AccountResolver(Realm realm,
+  AccountResolver(
+      Realm realm,
       AccountByEmailCache byEmail,
       AccountCache byId,
-      AccountIndexCollection accountIndexes,
       Provider<InternalAccountQuery> accountQueryProvider) {
     this.realm = realm;
     this.byEmail = byEmail;
     this.byId = byId;
-    this.accountIndexes = accountIndexes;
     this.accountQueryProvider = accountQueryProvider;
   }
 
   /**
    * Locate exactly one account matching the name or name/email string.
    *
-   * @param nameOrEmail a string of the format
-   *        "Full Name &lt;email@example&gt;", just the email address
-   *        ("email@example"), a full name ("Full Name"), an account id
-   *        ("18419") or an user name ("username").
-   * @return the single account that matches; null if no account matches or
-   *         there are multiple candidates.
+   * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
+   *     address ("email@example"), a full name ("Full Name"), an account id ("18419") or an user
+   *     name ("username").
+   * @return the single account that matches; null if no account matches or there are multiple
+   *     candidates.
    */
   public Account find(ReviewDb db, String nameOrEmail) throws OrmException {
     Set<Account.Id> r = findAll(db, nameOrEmail);
@@ -88,14 +82,12 @@
    * Find all accounts matching the name or name/email string.
    *
    * @param db open database handle.
-   * @param nameOrEmail a string of the format
-   *        "Full Name &lt;email@example&gt;", just the email address
-   *        ("email@example"), a full name ("Full Name"), an account id
-   *        ("18419") or an user name ("username").
-   * @return the accounts that match, empty collection if none.  Never null.
+   * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
+   *     address ("email@example"), a full name ("Full Name"), an account id ("18419") or an user
+   *     name ("username").
+   * @return the accounts that match, empty collection if none. Never null.
    */
-  public Set<Account.Id> findAll(ReviewDb db, String nameOrEmail)
-      throws OrmException {
+  public Set<Account.Id> findAll(ReviewDb db, String nameOrEmail) throws OrmException {
     Matcher m = Pattern.compile("^.* \\(([1-9][0-9]*)\\)$").matcher(nameOrEmail);
     if (m.matches()) {
       Account.Id id = Account.Id.parse(m.group(1));
@@ -113,7 +105,7 @@
       return Collections.emptySet();
     }
 
-    if (nameOrEmail.matches(Account.USER_NAME_PATTERN)) {
+    if (ExternalId.isValidUsername(nameOrEmail)) {
       AccountState who = byId.getByUsername(nameOrEmail);
       if (who != null) {
         return Collections.singleton(who.getAccount().getId());
@@ -131,14 +123,12 @@
    * Locate exactly one account matching the name or name/email string.
    *
    * @param db open database handle.
-   * @param nameOrEmail a string of the format
-   *        "Full Name &lt;email@example&gt;", just the email address
-   *        ("email@example"), a full name ("Full Name").
-   * @return the single account that matches; null if no account matches or
-   *         there are multiple candidates.
+   * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
+   *     address ("email@example"), a full name ("Full Name").
+   * @return the single account that matches; null if no account matches or there are multiple
+   *     candidates.
    */
-  public Account findByNameOrEmail(ReviewDb db, String nameOrEmail)
-      throws OrmException {
+  public Account findByNameOrEmail(ReviewDb db, String nameOrEmail) throws OrmException {
     Set<Account.Id> r = findAllByNameOrEmail(db, nameOrEmail);
     return r.size() == 1 ? byId.get(r.iterator().next()).getAccount() : null;
   }
@@ -147,13 +137,11 @@
    * Locate exactly one account matching the name or name/email string.
    *
    * @param db open database handle.
-   * @param nameOrEmail a string of the format
-   *        "Full Name &lt;email@example&gt;", just the email address
-   *        ("email@example"), a full name ("Full Name").
+   * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
+   *     address ("email@example"), a full name ("Full Name").
    * @return the accounts that match, empty collection if none. Never null.
    */
-  public Set<Account.Id> findAllByNameOrEmail(ReviewDb db, String nameOrEmail)
-      throws OrmException {
+  public Set<Account.Id> findAllByNameOrEmail(ReviewDb db, String nameOrEmail) throws OrmException {
     int lt = nameOrEmail.indexOf('<');
     int gt = nameOrEmail.indexOf('>');
     if (lt >= 0 && gt > lt && nameOrEmail.contains("@")) {
@@ -183,47 +171,15 @@
       return Collections.singleton(id);
     }
 
-    if (accountIndexes.getSearchIndex() != null) {
-      List<AccountState> m = accountQueryProvider.get().byFullName(nameOrEmail);
-      if (m.size() == 1) {
-        return Collections.singleton(m.get(0).getAccount().getId());
-      }
-
-      // At this point we have no clue. Just perform a whole bunch of suggestions
-      // and pray we come up with a reasonable result list.
-      return FluentIterable
-          .from(accountQueryProvider.get().byDefault(nameOrEmail))
-          .transform(new Function<AccountState, Account.Id>() {
-            @Override
-            public Account.Id apply(AccountState accountState) {
-              return accountState.getAccount().getId();
-            }
-          }).toSet();
-    }
-
-    List<Account> m = db.accounts().byFullName(nameOrEmail).toList();
+    List<AccountState> m = accountQueryProvider.get().byFullName(nameOrEmail);
     if (m.size() == 1) {
-      return Collections.singleton(m.get(0).getId());
+      return Collections.singleton(m.get(0).getAccount().getId());
     }
 
     // At this point we have no clue. Just perform a whole bunch of suggestions
     // and pray we come up with a reasonable result list.
-    Set<Account.Id> result = new HashSet<>();
-    String a = nameOrEmail;
-    String b = nameOrEmail + "\u9fa5";
-    for (Account act : db.accounts().suggestByFullName(a, b, 10)) {
-      result.add(act.getId());
-    }
-    for (AccountExternalId extId : db.accountExternalIds()
-        .suggestByKey(
-            new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, a),
-            new AccountExternalId.Key(AccountExternalId.SCHEME_USERNAME, b), 10)) {
-      result.add(extId.getAccountId());
-    }
-    for (AccountExternalId extId : db.accountExternalIds()
-        .suggestByEmailAddress(a, b, 10)) {
-      result.add(extId.getAccountId());
-    }
-    return result;
+    return accountQueryProvider.get().byDefault(nameOrEmail).stream()
+        .map(a -> a.getAccount().getId())
+        .collect(toSet());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
index 8bebf52..27e713f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.inject.TypeLiteral;
-
 import java.util.Set;
 
 public class AccountResource implements RestResource {
@@ -66,10 +65,6 @@
     public String getCapability() {
       return capability;
     }
-
-    public boolean has() {
-      return user.getCapabilities().canPerform(getCapability());
-    }
   }
 
   public static class Email extends AccountResource {
@@ -119,8 +114,7 @@
     private final ChangeResource change;
     private final Set<String> labels;
 
-    public Star(IdentifiedUser user, ChangeResource change,
-        Set<String> labels) {
+    public Star(IdentifiedUser user, ChangeResource change, Set<String> labels) {
       this.user = user;
       this.change = change;
       this.labels = labels;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index 05a7179..ad3b634 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -14,44 +14,44 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
+import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.server.CurrentUser.PropertyKey;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import org.apache.commons.codec.DecoderException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class AccountState {
+  private static final Logger logger = LoggerFactory.getLogger(AccountState.class);
+
   public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
-      new Function<AccountState, Account.Id>() {
-        @Override
-        public Account.Id apply(AccountState in) {
-          return in.getAccount().getId();
-        }
-      };
+      a -> a.getAccount().getId();
 
   private final Account account;
   private final Set<AccountGroup.UUID> internalGroups;
-  private final Collection<AccountExternalId> externalIds;
+  private final Collection<ExternalId> externalIds;
   private final Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
   private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
 
-  public AccountState(Account account,
+  public AccountState(
+      Account account,
       Set<AccountGroup.UUID> actualGroups,
-      Collection<AccountExternalId> externalIds,
+      Collection<ExternalId> externalIds,
       Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
     this.account = account;
     this.internalGroups = actualGroups;
@@ -67,27 +67,38 @@
 
   /**
    * Get the username, if one has been declared for this user.
-   * <p>
-   * The username is the {@link AccountExternalId} using the scheme
-   * {@link AccountExternalId#SCHEME_USERNAME}.
+   *
+   * <p>The username is the {@link ExternalId} using the scheme {@link ExternalId#SCHEME_USERNAME}.
    */
   public String getUserName() {
     return account.getUserName();
   }
 
-  /** @return the password matching the requested username; or null. */
-  public String getPassword(String username) {
-    for (AccountExternalId id : getExternalIds()) {
-      if (id.isScheme(AccountExternalId.SCHEME_USERNAME)
-          && username.equals(id.getSchemeRest())) {
-        return id.getPassword();
+  public boolean checkPassword(String password, String username) {
+    if (password == null) {
+      return false;
+    }
+    for (ExternalId id : getExternalIds()) {
+      // Only process the "username:$USER" entry, which is unique.
+      if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
+        continue;
+      }
+
+      String hashedStr = id.password();
+      if (!Strings.isNullOrEmpty(hashedStr)) {
+        try {
+          return HashedPassword.decode(hashedStr).checkPassword(password);
+        } catch (DecoderException e) {
+          logger.error("DecoderException for user {}: {}", username, e.getMessage());
+          return false;
+        }
       }
     }
-    return null;
+    return false;
   }
 
   /** The external identities that identify the account holder. */
-  public Collection<AccountExternalId> getExternalIds() {
+  public Collection<ExternalId> getExternalIds() {
     return externalIds;
   }
 
@@ -101,20 +112,20 @@
     return internalGroups;
   }
 
-  public static String getUserName(Collection<AccountExternalId> ids) {
-    for (AccountExternalId id : ids) {
-      if (id.isScheme(SCHEME_USERNAME)) {
-        return id.getSchemeRest();
+  public static String getUserName(Collection<ExternalId> ids) {
+    for (ExternalId extId : ids) {
+      if (extId.isScheme(SCHEME_USERNAME)) {
+        return extId.key().id();
       }
     }
     return null;
   }
 
-  public static Set<String> getEmails(Collection<AccountExternalId> ids) {
+  public static Set<String> getEmails(Collection<ExternalId> ids) {
     Set<String> emails = new HashSet<>();
-    for (AccountExternalId id : ids) {
-      if (id.isScheme(SCHEME_MAILTO)) {
-        emails.add(id.getSchemeRest());
+    for (ExternalId extId : ids) {
+      if (extId.isScheme(SCHEME_MAILTO)) {
+        emails.add(extId.key().id());
       }
     }
     return emails;
@@ -122,9 +133,9 @@
 
   /**
    * Lookup a previously stored property.
-   * <p>
-   * All properties are automatically cleared when the account cache invalidates
-   * the {@code AccountState}. This method is thread-safe.
+   *
+   * <p>All properties are automatically cleared when the account cache invalidates the {@code
+   * AccountState}. This method is thread-safe.
    *
    * @param key unique property key.
    * @return previously stored value, or {@code null}.
@@ -142,8 +153,8 @@
 
   /**
    * Store a property for later retrieval.
-   * <p>
-   * This method is thread-safe.
+   *
+   * <p>This method is thread-safe.
    *
    * @param key unique property key.
    * @param value value to store; or {@code null} to clear the value.
@@ -161,16 +172,16 @@
     }
   }
 
-  private synchronized Cache<PropertyKey<Object>, Object> properties(
-      boolean allocate) {
+  private synchronized Cache<PropertyKey<Object>, Object> properties(boolean allocate) {
     if (properties == null && allocate) {
-      properties = CacheBuilder.newBuilder()
-          .concurrencyLevel(1)
-          .initialCapacity(16)
-          // Use weakKeys to ensure plugins that garbage collect will also
-          // eventually release data held in any still live AccountState.
-          .weakKeys()
-          .build();
+      properties =
+          CacheBuilder.newBuilder()
+              .concurrencyLevel(1)
+              .initialCapacity(16)
+              // Use weakKeys to ensure plugins that garbage collect will also
+              // eventually release data held in any still live AccountState.
+              .weakKeys()
+              .build();
     }
     return properties;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java
index 1cf8be8..19fd34d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.account;
 
 /**
- * Thrown by {@link AccountManager} if the user name for a newly created account
- * could not be set and the realm does not allow the user to set a user name
- * manually.
+ * Thrown by {@link AccountManager} if the user name for a newly created account could not be set
+ * and the realm does not allow the user to set a user name manually.
  */
 public class AccountUserNameException extends AccountException {
   private static final long serialVersionUID = 1L;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibility.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibility.java
index 7ee8db6..9957134 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibility.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibility.java
@@ -26,8 +26,8 @@
   VISIBLE_GROUP,
 
   /**
-   * Other accounts are not visible to the given user unless they are explicitly
-   * collaborating on a change.
+   * Other accounts are not visible to the given user unless they are explicitly collaborating on a
+   * change.
    */
   NONE
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
index 25f0a7d..4521cd5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.eclipse.jgit.lib.Config;
 
 public class AccountVisibilityProvider implements Provider<AccountVisibility> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
index 04ebc87..081ea26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -34,9 +35,8 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class AccountsCollection implements
-    RestCollection<TopLevelResource, AccountResource>,
-    AcceptsCreate<TopLevelResource> {
+public class AccountsCollection
+    implements RestCollection<TopLevelResource, AccountResource>, AcceptsCreate<TopLevelResource> {
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> self;
   private final AccountResolver resolver;
@@ -47,7 +47,8 @@
   private final CreateAccount.Factory createAccountFactory;
 
   @Inject
-  AccountsCollection(Provider<ReviewDb> db,
+  AccountsCollection(
+      Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       AccountResolver resolver,
       AccountControl.Factory accountControlFactory,
@@ -80,41 +81,50 @@
   /**
    * Parses a account ID from a request body and returns the user.
    *
-   * @param id ID of the account, can be a string of the format
-   *        "{@code Full Name <email@example.com>}", just the email address,
-   *        a full name if it is unique, an account ID, a user name or
-   *        "{@code self}" for the calling user
+   * @param id ID of the account, can be a string of the format "{@code Full Name
+   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
+   *     a user name or "{@code self}" for the calling user
    * @return the user, never null.
-   * @throws UnprocessableEntityException thrown if the account ID cannot be
-   *         resolved or if the account is not visible to the calling user
+   * @throws UnprocessableEntityException thrown if the account ID cannot be resolved or if the
+   *     account is not visible to the calling user
    */
-  public IdentifiedUser parse(String id) throws AuthException,
-      UnprocessableEntityException, OrmException {
-    IdentifiedUser user = parseId(id);
+  public IdentifiedUser parse(String id)
+      throws AuthException, UnprocessableEntityException, OrmException {
+    return parseOnBehalfOf(null, id);
+  }
+
+  /**
+   * Parses an account ID and returns the user without making any permission check whether the
+   * current user can see the account.
+   *
+   * @param id ID of the account, can be a string of the format "{@code Full Name
+   *     <email@example.com>}", just the email address, a full name if it is unique, an account ID,
+   *     a user name or "{@code self}" for the calling user
+   * @return the user, null if no user is found for the given account ID
+   * @throws AuthException thrown if 'self' is used as account ID and the current user is not
+   *     authenticated
+   * @throws OrmException
+   */
+  public IdentifiedUser parseId(String id) throws AuthException, OrmException {
+    return parseIdOnBehalfOf(null, id);
+  }
+
+  /**
+   * Like {@link #parse(String)}, but also sets the {@link CurrentUser#getRealUser()} on the result.
+   */
+  public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller, String id)
+      throws AuthException, UnprocessableEntityException, OrmException {
+    IdentifiedUser user = parseIdOnBehalfOf(caller, id);
     if (user == null) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", id));
+      throw new UnprocessableEntityException(String.format("Account Not Found: %s", id));
     } else if (!accountControlFactory.get().canSee(user.getAccount())) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", id));
+      throw new UnprocessableEntityException(String.format("Account Not Found: %s", id));
     }
     return user;
   }
 
-  /**
-   * Parses an account ID and returns the user without making any permission
-   * check whether the current user can see the account.
-   *
-   * @param id ID of the account, can be a string of the format
-   *        "{@code Full Name <email@example.com>}", just the email address,
-   *        a full name if it is unique, an account ID, a user name or
-   *        "{@code self}" for the calling user
-   * @return the user, null if no user is found for the given account ID
-   * @throws AuthException thrown if 'self' is used as account ID and the
-   *         current user is not authenticated
-   * @throws OrmException
-   */
-  public IdentifiedUser parseId(String id) throws AuthException, OrmException {
+  private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller, String id)
+      throws AuthException, OrmException {
     if (id.equals("self")) {
       CurrentUser user = self.get();
       if (user.isIdentifiedUser()) {
@@ -130,7 +140,8 @@
     if (match == null) {
       return null;
     }
-    return userFactory.create(match.getId());
+    CurrentUser realUser = caller != null ? caller.getRealUser() : null;
+    return userFactory.runAs(null, match.getId(), realUser);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
index 216672c..44b632a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
@@ -29,20 +29,18 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AddSshKey.Input;
-import com.google.gerrit.server.mail.AddKeySender;
+import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.io.InputStream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.io.InputStream;
-
 @Singleton
 public class AddSshKey implements RestModifyView<AccountResource, Input> {
   private static final Logger log = LoggerFactory.getLogger(AddSshKey.class);
@@ -57,7 +55,8 @@
   private final AddKeySender.Factory addKeyFactory;
 
   @Inject
-  AddSshKey(Provider<CurrentUser> self,
+  AddSshKey(
+      Provider<CurrentUser> self,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       AddKeySender.Factory addKeyFactory) {
@@ -69,9 +68,8 @@
 
   @Override
   public Response<SshKeyInfo> apply(AccountResource rsrc, Input input)
-      throws AuthException, BadRequestException, OrmException, IOException,
-      ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to add SSH keys");
     }
@@ -79,8 +77,7 @@
   }
 
   public Response<SshKeyInfo> apply(IdentifiedUser user, Input input)
-      throws BadRequestException, IOException,
-      ConfigInvalidException {
+      throws BadRequestException, IOException, ConfigInvalidException {
     if (input == null) {
       input = new Input();
     }
@@ -89,22 +86,22 @@
     }
 
     final RawInput rawKey = input.raw;
-    String sshPublicKey = new ByteSource() {
-      @Override
-      public InputStream openStream() throws IOException {
-        return rawKey.getInputStream();
-      }
-    }.asCharSource(UTF_8).read();
+    String sshPublicKey =
+        new ByteSource() {
+          @Override
+          public InputStream openStream() throws IOException {
+            return rawKey.getInputStream();
+          }
+        }.asCharSource(UTF_8).read();
 
     try {
-      AccountSshKey sshKey =
-          authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
+      AccountSshKey sshKey = authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
 
       try {
         addKeyFactory.create(user, sshKey).send();
       } catch (EmailException e) {
-        log.error("Cannot send SSH key added message to "
-            + user.getAccount().getPreferredEmail(), e);
+        log.error(
+            "Cannot send SSH key added message to " + user.getAccount().getPreferredEmail(), e);
       }
 
       sshKeyCache.evict(user.getUserName());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
index c585f97..d1dd4b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
@@ -14,55 +14,46 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_EXTERNAL;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
-
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
 
 /**
  * Information for {@link AccountManager#authenticate(AuthRequest)}.
- * <p>
- * Callers should populate this object with as much information as possible
- * about the user account. For example, OpenID authentication might return
- * registration information including a display name for the user, and an email
- * address for them. These fields however are optional, as not all OpenID
- * providers return them, and not all non-OpenID systems can use them.
+ *
+ * <p>Callers should populate this object with as much information as possible about the user
+ * account. For example, OpenID authentication might return registration information including a
+ * display name for the user, and an email address for them. These fields however are optional, as
+ * not all OpenID providers return them, and not all non-OpenID systems can use them.
  */
 public class AuthRequest {
   /** Create a request for a local username, such as from LDAP. */
-  public static AuthRequest forUser(final String username) {
-    final AccountExternalId.Key i =
-        new AccountExternalId.Key(SCHEME_GERRIT, username);
-    final AuthRequest r = new AuthRequest(i.get());
+  public static AuthRequest forUser(String username) {
+    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_GERRIT, username));
     r.setUserName(username);
     return r;
   }
 
   /** Create a request for an external username. */
   public static AuthRequest forExternalUser(String username) {
-    AccountExternalId.Key i =
-        new AccountExternalId.Key(SCHEME_EXTERNAL, username);
-    AuthRequest r = new AuthRequest(i.get());
+    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_EXTERNAL, username));
     r.setUserName(username);
     return r;
   }
 
   /**
    * Create a request for an email address registration.
-   * <p>
-   * This type of request should be used only to attach a new email address to
-   * an existing user account.
+   *
+   * <p>This type of request should be used only to attach a new email address to an existing user
+   * account.
    */
-  public static AuthRequest forEmail(final String email) {
-    final AccountExternalId.Key i =
-        new AccountExternalId.Key(SCHEME_MAILTO, email);
-    final AuthRequest r = new AuthRequest(i.get());
+  public static AuthRequest forEmail(String email) {
+    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_MAILTO, email));
     r.setEmailAddress(email);
     return r;
   }
 
-  private String externalId;
+  private ExternalId.Key externalId;
   private String password;
   private String displayName;
   private String emailAddress;
@@ -71,30 +62,24 @@
   private String authPlugin;
   private String authProvider;
 
-  public AuthRequest(final String externalId) {
+  public AuthRequest(ExternalId.Key externalId) {
     this.externalId = externalId;
   }
 
-  public String getExternalId() {
+  public ExternalId.Key getExternalIdKey() {
     return externalId;
   }
 
-  public boolean isScheme(final String scheme) {
-    return getExternalId().startsWith(scheme);
-  }
-
   public String getLocalUser() {
-    if (isScheme(SCHEME_GERRIT)) {
-      return getExternalId().substring(SCHEME_GERRIT.length());
+    if (externalId.isScheme(SCHEME_GERRIT)) {
+      return externalId.id();
     }
     return null;
   }
 
-  public void setLocalUser(final String localUser) {
-    if (isScheme(SCHEME_GERRIT)) {
-      final AccountExternalId.Key key =
-          new AccountExternalId.Key(SCHEME_GERRIT, localUser);
-      externalId = key.get();
+  public void setLocalUser(String localUser) {
+    if (externalId.isScheme(SCHEME_GERRIT)) {
+      externalId = ExternalId.Key.create(SCHEME_GERRIT, localUser);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
index b94e41a..4aced52 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
@@ -15,16 +15,14 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 
 /** Result from {@link AccountManager#authenticate(AuthRequest)}. */
 public class AuthResult {
   private final Account.Id accountId;
-  private final AccountExternalId.Key externalId;
+  private final ExternalId.Key externalId;
   private final boolean isNew;
 
-  public AuthResult(final Account.Id accountId,
-      final AccountExternalId.Key externalId, final boolean isNew) {
+  public AuthResult(Account.Id accountId, ExternalId.Key externalId, boolean isNew) {
     this.accountId = accountId;
     this.externalId = externalId;
     this.isNew = isNew;
@@ -36,15 +34,15 @@
   }
 
   /** External identity used to authenticate the user. */
-  public AccountExternalId.Key getExternalId() {
+  public ExternalId.Key getExternalId() {
     return externalId;
   }
 
   /**
    * True if this account was recently created for the user.
-   * <p>
-   * New users should be redirected to the registration screen, so they can
-   * configure their new user account.
+   *
+   * <p>New users should be redirected to the registration screen, so they can configure their new
+   * user account.
    */
   public boolean isNew() {
     return isNew;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
index 0e8c051..4d86ab2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthorizedKeys.java
@@ -15,25 +15,21 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Optional;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 public class AuthorizedKeys {
   public static final String FILE_NAME = "authorized_keys";
 
-  @VisibleForTesting
-  public static final String INVALID_KEY_COMMENT_PREFIX = "# INVALID ";
+  @VisibleForTesting public static final String INVALID_KEY_COMMENT_PREFIX = "# INVALID ";
 
-  @VisibleForTesting
-  public static final String DELETED_KEY_COMMENT = "# DELETED";
+  @VisibleForTesting public static final String DELETED_KEY_COMMENT = "# DELETED";
 
-  public static List<Optional<AccountSshKey>> parse(
-      Account.Id accountId, String s) {
+  public static List<Optional<AccountSshKey>> parse(Account.Id accountId, String s) {
     List<Optional<AccountSshKey>> keys = new ArrayList<>();
     int seq = 1;
     for (String line : s.split("\\r?\\n")) {
@@ -42,18 +38,16 @@
         continue;
       } else if (line.startsWith(INVALID_KEY_COMMENT_PREFIX)) {
         String pub = line.substring(INVALID_KEY_COMMENT_PREFIX.length());
-        AccountSshKey key =
-            new AccountSshKey(new AccountSshKey.Id(accountId, seq++), pub);
+        AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, seq++), pub);
         key.setInvalid();
         keys.add(Optional.of(key));
       } else if (line.startsWith(DELETED_KEY_COMMENT)) {
-        keys.add(Optional.<AccountSshKey> absent());
+        keys.add(Optional.empty());
         seq++;
       } else if (line.startsWith("#")) {
         continue;
       } else {
-        AccountSshKey key =
-            new AccountSshKey(new AccountSshKey.Id(accountId, seq++), line);
+        AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, seq++), line);
         keys.add(Optional.of(key));
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
index 95338fe..e53b7d0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
@@ -28,8 +28,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-class Capabilities implements
-    ChildCollection<AccountResource, AccountResource.Capability> {
+class Capabilities implements ChildCollection<AccountResource, AccountResource.Capability> {
   private final Provider<CurrentUser> self;
   private final DynamicMap<RestView<AccountResource.Capability>> views;
   private final Provider<GetCapabilities> get;
@@ -52,7 +51,7 @@
   @Override
   public Capability parse(AccountResource parent, IdString id)
       throws ResourceNotFoundException, AuthException {
-    if (self.get() != parent.getUser()
+    if (!self.get().hasSameAccountId(parent.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("restricted to administrator");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java
index 4bf4214..05d771e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -42,6 +41,7 @@
     CapabilityCollection create(@Nullable AccessSection section);
   }
 
+  private final SystemGroupBackend systemGroupBackend;
   private final ImmutableMap<String, ImmutableList<PermissionRule>> permissions;
 
   public final ImmutableList<PermissionRule> administrateServer;
@@ -52,8 +52,11 @@
 
   @Inject
   CapabilityCollection(
+      SystemGroupBackend systemGroupBackend,
       @AdministrateServerGroups ImmutableSet<GroupReference> admins,
       @Assisted @Nullable AccessSection section) {
+    this.systemGroupBackend = systemGroupBackend;
+
     if (section == null) {
       section = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
     }
@@ -96,8 +99,8 @@
     queryLimit = getPermission(GlobalCapability.QUERY_LIMIT);
   }
 
-  private static List<PermissionRule> mergeAdmin(Set<GroupReference> admins,
-      List<PermissionRule> rules) {
+  private static List<PermissionRule> mergeAdmin(
+      Set<GroupReference> admins, List<PermissionRule> rules) {
     if (admins.isEmpty()) {
       return rules;
     }
@@ -116,19 +119,22 @@
 
   public ImmutableList<PermissionRule> getPermission(String permissionName) {
     ImmutableList<PermissionRule> r = permissions.get(permissionName);
-    return r != null ? r : ImmutableList.<PermissionRule> of();
+    return r != null ? r : ImmutableList.<PermissionRule>of();
   }
 
-  private static final GroupReference anonymous = SystemGroupBackend
-      .getGroup(SystemGroupBackend.ANONYMOUS_USERS);
-
-  private static void configureDefaults(Map<String, List<PermissionRule>> out,
-      AccessSection section) {
-    configureDefault(out, section, GlobalCapability.QUERY_LIMIT, anonymous);
+  private void configureDefaults(Map<String, List<PermissionRule>> out, AccessSection section) {
+    configureDefault(
+        out,
+        section,
+        GlobalCapability.QUERY_LIMIT,
+        systemGroupBackend.getGroup(SystemGroupBackend.ANONYMOUS_USERS));
   }
 
-  private static void configureDefault(Map<String, List<PermissionRule>> out,
-      AccessSection section, String capName, GroupReference group) {
+  private static void configureDefault(
+      Map<String, List<PermissionRule>> out,
+      AccessSection section,
+      String capName,
+      GroupReference group) {
     if (doesNotDeclare(section, capName)) {
       PermissionRange.WithDefaults range = GlobalCapability.getRange(capName);
       if (range != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
index e348e73..66d0bf9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
@@ -14,15 +14,14 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
+import static com.google.common.base.Predicates.not;
+
 import com.google.common.base.Predicate;
-import com.google.common.base.Predicates;
-import com.google.common.collect.Iterables;
+import com.google.common.collect.FluentIterable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.git.QueueProvider;
@@ -30,8 +29,8 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -68,8 +67,9 @@
       if (user.getRealUser() != user) {
         canAdministrateServer = false;
       } else {
-        canAdministrateServer = user instanceof PeerDaemonUser
-            || matchAny(capabilities.administrateServer, ALLOWED_RULE);
+        canAdministrateServer =
+            user instanceof PeerDaemonUser
+                || matchAny(capabilities.administrateServer, ALLOWED_RULE);
       }
     }
     return canAdministrateServer;
@@ -77,20 +77,17 @@
 
   /** @return true if the user can create an account for another user. */
   public boolean canCreateAccount() {
-    return canPerform(GlobalCapability.CREATE_ACCOUNT)
-      || canAdministrateServer();
+    return canPerform(GlobalCapability.CREATE_ACCOUNT) || canAdministrateServer();
   }
 
   /** @return true if the user can create a group. */
   public boolean canCreateGroup() {
-    return canPerform(GlobalCapability.CREATE_GROUP)
-      || canAdministrateServer();
+    return canPerform(GlobalCapability.CREATE_GROUP) || canAdministrateServer();
   }
 
   /** @return true if the user can create a project. */
   public boolean canCreateProject() {
-    return canPerform(GlobalCapability.CREATE_PROJECT)
-      || canAdministrateServer();
+    return canPerform(GlobalCapability.CREATE_PROJECT) || canAdministrateServer();
   }
 
   /** @return true if the user can email reviewers. */
@@ -98,64 +95,54 @@
     if (canEmailReviewers == null) {
       canEmailReviewers =
           matchAny(capabilities.emailReviewers, ALLOWED_RULE)
-          || !matchAny(capabilities.emailReviewers, Predicates.not(ALLOWED_RULE));
-
+              || !matchAny(capabilities.emailReviewers, not(ALLOWED_RULE));
     }
     return canEmailReviewers;
   }
 
   /** @return true if the user can kill any running task. */
   public boolean canKillTask() {
-    return canPerform(GlobalCapability.KILL_TASK)
-      || canMaintainServer();
+    return canPerform(GlobalCapability.KILL_TASK) || canMaintainServer();
   }
 
   /** @return true if the user can modify an account for another user. */
   public boolean canModifyAccount() {
-    return canPerform(GlobalCapability.MODIFY_ACCOUNT)
-      || canAdministrateServer();
+    return canPerform(GlobalCapability.MODIFY_ACCOUNT) || canAdministrateServer();
   }
 
   /** @return true if the user can view all accounts. */
   public boolean canViewAllAccounts() {
-    return canPerform(GlobalCapability.VIEW_ALL_ACCOUNTS)
-      || canAdministrateServer();
+    return canPerform(GlobalCapability.VIEW_ALL_ACCOUNTS) || canAdministrateServer();
   }
 
   /** @return true if the user can view the server caches. */
   public boolean canViewCaches() {
-    return canPerform(GlobalCapability.VIEW_CACHES)
-      || canMaintainServer();
+    return canPerform(GlobalCapability.VIEW_CACHES) || canMaintainServer();
   }
 
   /** @return true if the user can flush the server's caches. */
   public boolean canFlushCaches() {
-    return canPerform(GlobalCapability.FLUSH_CACHES)
-      || canMaintainServer();
+    return canPerform(GlobalCapability.FLUSH_CACHES) || canMaintainServer();
   }
 
   /** @return true if the user can perform basic server maintenance. */
   public boolean canMaintainServer() {
-    return canPerform(GlobalCapability.MAINTAIN_SERVER)
-      || canAdministrateServer();
+    return canPerform(GlobalCapability.MAINTAIN_SERVER) || canAdministrateServer();
   }
 
   /** @return true if the user can view open connections. */
   public boolean canViewConnections() {
-    return canPerform(GlobalCapability.VIEW_CONNECTIONS)
-        || canAdministrateServer();
+    return canPerform(GlobalCapability.VIEW_CONNECTIONS) || canAdministrateServer();
   }
 
   /** @return true if the user can view the installed plugins. */
   public boolean canViewPlugins() {
-    return canPerform(GlobalCapability.VIEW_PLUGINS)
-      || canAdministrateServer();
+    return canPerform(GlobalCapability.VIEW_PLUGINS) || canAdministrateServer();
   }
 
   /** @return true if the user can view the entire queue. */
   public boolean canViewQueue() {
-    return canPerform(GlobalCapability.VIEW_QUEUE)
-      || canMaintainServer();
+    return canPerform(GlobalCapability.VIEW_QUEUE) || canMaintainServer();
   }
 
   /** @return true if the user can access the database (with gsql). */
@@ -165,14 +152,12 @@
 
   /** @return true if the user can stream Gerrit events. */
   public boolean canStreamEvents() {
-    return canPerform(GlobalCapability.STREAM_EVENTS)
-        || canAdministrateServer();
+    return canPerform(GlobalCapability.STREAM_EVENTS) || canAdministrateServer();
   }
 
   /** @return true if the user can run the Git garbage collection. */
   public boolean canRunGC() {
-    return canPerform(GlobalCapability.RUN_GC)
-        || canMaintainServer();
+    return canPerform(GlobalCapability.RUN_GC) || canMaintainServer();
   }
 
   /** @return true if the user can impersonate another user. */
@@ -235,13 +220,11 @@
     return null;
   }
 
-  private static PermissionRange toRange(String permissionName,
-      List<PermissionRule> ruleList) {
+  private static PermissionRange toRange(String permissionName, List<PermissionRule> ruleList) {
     int min = 0;
     int max = 0;
     if (ruleList.isEmpty()) {
-      PermissionRange.WithDefaults defaultRange =
-          GlobalCapability.getRange(permissionName);
+      PermissionRange.WithDefaults defaultRange = GlobalCapability.getRange(permissionName);
       if (defaultRange != null) {
         min = defaultRange.getDefaultMin();
         max = defaultRange.getDefaultMax();
@@ -279,27 +262,15 @@
     return mine;
   }
 
-  private static final Predicate<PermissionRule> ALLOWED_RULE = new Predicate<PermissionRule>() {
-    @Override
-    public boolean apply(PermissionRule rule) {
-      return rule.getAction() == Action.ALLOW;
-    }
-  };
+  private static final Predicate<PermissionRule> ALLOWED_RULE = r -> r.getAction() == Action.ALLOW;
 
-  private boolean matchAny(Iterable<PermissionRule> rules, Predicate<PermissionRule> predicate) {
-    Iterable<AccountGroup.UUID> ids = Iterables.transform(
-        Iterables.filter(rules, predicate),
-        new Function<PermissionRule, AccountGroup.UUID>() {
-          @Override
-          public AccountGroup.UUID apply(PermissionRule rule) {
-            return rule.getGroup().getUUID();
-          }
-        });
-    return user.getEffectiveGroups().containsAnyOf(ids);
+  private boolean matchAny(Collection<PermissionRule> rules, Predicate<PermissionRule> predicate) {
+    return user.getEffectiveGroups()
+        .containsAnyOf(
+            FluentIterable.from(rules).filter(predicate).transform(r -> r.getGroup().getUUID()));
   }
 
-  private static boolean match(GroupMembership groups,
-      PermissionRule rule) {
+  private static boolean match(GroupMembership groups, PermissionRule rule) {
     return groups.contains(rule.getGroup().getUUID());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
index 2bf147d..27a196c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
@@ -20,34 +20,29 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Provider;
-
+import java.lang.annotation.Annotation;
+import java.util.Arrays;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.lang.annotation.Annotation;
-import java.util.Arrays;
-
 public class CapabilityUtils {
-  private static final Logger log = LoggerFactory
-      .getLogger(CapabilityUtils.class);
+  private static final Logger log = LoggerFactory.getLogger(CapabilityUtils.class);
 
-  public static void checkRequiresCapability(Provider<CurrentUser> userProvider,
-      String pluginName, Class<?> clazz) throws AuthException {
+  public static void checkRequiresCapability(
+      Provider<CurrentUser> userProvider, String pluginName, Class<?> clazz) throws AuthException {
     checkRequiresCapability(userProvider.get(), pluginName, clazz);
   }
 
-  public static void checkRequiresCapability(CurrentUser user,
-      String pluginName, Class<?> clazz)
+  public static void checkRequiresCapability(CurrentUser user, String pluginName, Class<?> clazz)
       throws AuthException {
     RequiresCapability rc = getClassAnnotation(clazz, RequiresCapability.class);
-    RequiresAnyCapability rac =
-        getClassAnnotation(clazz, RequiresAnyCapability.class);
+    RequiresAnyCapability rac = getClassAnnotation(clazz, RequiresAnyCapability.class);
     if (rc != null && rac != null) {
-      log.error(String.format(
-          "Class %s uses both @%s and @%s",
+      log.error(
+          "Class {} uses both @{} and @{}",
           clazz.getName(),
           RequiresCapability.class.getSimpleName(),
-          RequiresAnyCapability.class.getSimpleName()));
+          RequiresAnyCapability.class.getSimpleName());
       throw new AuthException("cannot check capability");
     }
     CapabilityControl ctl = user.getCapabilities();
@@ -58,74 +53,72 @@
     checkRequiresAnyCapability(ctl, pluginName, clazz, rac);
   }
 
-  private static void checkRequiresCapability(CapabilityControl ctl,
-      String pluginName, Class<?> clazz, RequiresCapability rc)
+  private static void checkRequiresCapability(
+      CapabilityControl ctl, String pluginName, Class<?> clazz, RequiresCapability rc)
       throws AuthException {
     if (rc == null) {
       return;
     }
-    String capability =
-        resolveCapability(pluginName, rc.value(), rc.scope(), clazz);
+    String capability = resolveCapability(pluginName, rc.value(), rc.scope(), clazz);
     if (!ctl.canPerform(capability)) {
-      throw new AuthException(String.format(
-          "Capability %s is required to access this resource",
-          capability));
+      throw new AuthException(
+          String.format("Capability %s is required to access this resource", capability));
     }
   }
 
-  private static void checkRequiresAnyCapability(CapabilityControl ctl,
-      String pluginName, Class<?> clazz, RequiresAnyCapability rac)
+  private static void checkRequiresAnyCapability(
+      CapabilityControl ctl, String pluginName, Class<?> clazz, RequiresAnyCapability rac)
       throws AuthException {
     if (rac == null) {
       return;
     }
     if (rac.value().length == 0) {
-      log.error(String.format(
-          "Class %s uses @%s with no capabilities listed",
+      log.error(
+          "Class {} uses @{} with no capabilities listed",
           clazz.getName(),
-          RequiresAnyCapability.class.getSimpleName()));
+          RequiresAnyCapability.class.getSimpleName());
       throw new AuthException("cannot check capability");
     }
     for (String capability : rac.value()) {
-      capability =
-          resolveCapability(pluginName, capability, rac.scope(), clazz);
+      capability = resolveCapability(pluginName, capability, rac.scope(), clazz);
       if (ctl.canPerform(capability)) {
         return;
       }
     }
     throw new AuthException(
         "One of the following capabilities is required to access this"
-        + " resource: " + Arrays.asList(rac.value()));
+            + " resource: "
+            + Arrays.asList(rac.value()));
   }
 
-  private static String resolveCapability(String pluginName, String capability,
-      CapabilityScope scope, Class<?> clazz) throws AuthException {
-    if (pluginName != null && !"gerrit".equals(pluginName)
-       && (scope == CapabilityScope.PLUGIN
-        || scope == CapabilityScope.CONTEXT)) {
+  private static String resolveCapability(
+      String pluginName, String capability, CapabilityScope scope, Class<?> clazz)
+      throws AuthException {
+    if (pluginName != null
+        && !"gerrit".equals(pluginName)
+        && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
       capability = String.format("%s-%s", pluginName, capability);
     } else if (scope == CapabilityScope.PLUGIN) {
-      log.error(String.format(
-          "Class %s uses @%s(scope=%s), but is not within a plugin",
+      log.error(
+          "Class {} uses @{}(scope={}), but is not within a plugin",
           clazz.getName(),
           RequiresCapability.class.getSimpleName(),
-          CapabilityScope.PLUGIN.name()));
+          CapabilityScope.PLUGIN.name());
       throw new AuthException("cannot check capability");
     }
     return capability;
   }
 
   /**
-   * Find an instance of the specified annotation, walking up the inheritance
-   * tree if necessary.
+   * Find an instance of the specified annotation, walking up the inheritance tree if necessary.
    *
    * @param <T> Annotation type to search for
    * @param clazz root class to search, may be null
    * @param annotationClass class object of Annotation subclass to search for
    * @return the requested annotation or null if none
    */
-  private static <T extends Annotation> T getClassAnnotation(Class<?> clazz,
-      Class<T> annotationClass) {
+  private static <T extends Annotation> T getClassAnnotation(
+      Class<?> clazz, Class<T> annotationClass) {
     for (; clazz != null; clazz = clazz.getSuperclass()) {
       T t = clazz.getAnnotation(annotationClass);
       if (t != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
index c1ecafd..9b076f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -28,21 +27,18 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.concurrent.Callable;
-import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Operation to change the username of an account. */
 public class ChangeUserName implements Callable<VoidResult> {
-  public static final String USERNAME_CANNOT_BE_CHANGED =
-      "Username cannot be changed.";
+  private static final Logger log = LoggerFactory.getLogger(ChangeUserName.class);
 
-  private static final Pattern USER_NAME_PATTERN =
-      Pattern.compile(Account.USER_NAME_PATTERN);
+  public static final String USERNAME_CANNOT_BE_CHANGED = "Username cannot be changed.";
 
   /** Generic factory to change any user's username. */
   public interface Factory {
@@ -51,56 +47,65 @@
 
   private final AccountCache accountCache;
   private final SshKeyCache sshKeyCache;
+  private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   private final ReviewDb db;
   private final IdentifiedUser user;
   private final String newUsername;
 
   @Inject
-  ChangeUserName(final AccountCache accountCache,
-      final SshKeyCache sshKeyCache,
-
-      @Assisted final ReviewDb db, @Assisted final IdentifiedUser user,
-      @Nullable @Assisted final String newUsername) {
+  ChangeUserName(
+      AccountCache accountCache,
+      SshKeyCache sshKeyCache,
+      ExternalIdsUpdate.Server externalIdsUpdateFactory,
+      @Assisted ReviewDb db,
+      @Assisted IdentifiedUser user,
+      @Nullable @Assisted String newUsername) {
     this.accountCache = accountCache;
     this.sshKeyCache = sshKeyCache;
-
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.db = db;
     this.user = user;
     this.newUsername = newUsername;
   }
 
   @Override
-  public VoidResult call() throws OrmException, NameAlreadyUsedException,
-      InvalidUserNameException, IOException {
-    final Collection<AccountExternalId> old = old();
+  public VoidResult call()
+      throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException,
+          ConfigInvalidException {
+    Collection<ExternalId> old =
+        ExternalId.from(db.accountExternalIds().byAccount(user.getAccountId()).toList()).stream()
+            .filter(e -> e.isScheme(SCHEME_USERNAME))
+            .collect(toSet());
     if (!old.isEmpty()) {
+      log.error(
+          "External id with scheme \"username:\" already exists for the user {}",
+          user.getAccountId());
       throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
     }
 
+    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
     if (newUsername != null && !newUsername.isEmpty()) {
-      if (!USER_NAME_PATTERN.matcher(newUsername).matches()) {
+      if (!ExternalId.isValidUsername(newUsername)) {
         throw new InvalidUserNameException();
       }
 
-      final AccountExternalId.Key key =
-          new AccountExternalId.Key(SCHEME_USERNAME, newUsername);
+      ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, newUsername);
       try {
-        final AccountExternalId id =
-            new AccountExternalId(user.getAccountId(), key);
-
-        for (AccountExternalId i : old) {
-          if (i.getPassword() != null) {
-            id.setPassword(i.getPassword());
+        String password = null;
+        for (ExternalId i : old) {
+          if (i.password() != null) {
+            password = i.password();
           }
         }
-
-        db.accountExternalIds().insert(Collections.singleton(id));
+        externalIdsUpdate.insert(db, ExternalId.create(key, user.getAccountId(), null, password));
+        log.info("Created the new external Id with key: {}", key);
       } catch (OrmDuplicateKeyException dupeErr) {
         // If we are using this identity, don't report the exception.
         //
-        AccountExternalId other = db.accountExternalIds().get(key);
-        if (other != null && other.getAccountId().equals(user.getAccountId())) {
+        ExternalId other =
+            ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+        if (other != null && other.accountId().equals(user.getAccountId())) {
           return VoidResult.INSTANCE;
         }
 
@@ -112,26 +117,14 @@
 
     // If we have any older user names, remove them.
     //
-    db.accountExternalIds().delete(old);
-    for (AccountExternalId i : old) {
-      sshKeyCache.evict(i.getSchemeRest());
-      accountCache.evictByUsername(i.getSchemeRest());
+    externalIdsUpdate.delete(db, old);
+    for (ExternalId extId : old) {
+      sshKeyCache.evict(extId.key().id());
+      accountCache.evictByUsername(extId.key().id());
     }
 
-    accountCache.evict(user.getAccountId());
     accountCache.evictByUsername(newUsername);
     sshKeyCache.evict(newUsername);
     return VoidResult.INSTANCE;
   }
-
-  private Collection<AccountExternalId> old() throws OrmException {
-    final Collection<AccountExternalId> r = new ArrayList<>(1);
-    for (AccountExternalId i : db.accountExternalIds().byAccount(
-        user.getAccountId())) {
-      if (i.isScheme(SCHEME_USERNAME)) {
-        r.add(i);
-      }
-    }
-    return r;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
index 8d121c2..e7d6994 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -30,34 +32,29 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.group.GroupsCollection;
-import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.mail.OutgoingEmailValidator;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
-public class CreateAccount
-    implements RestModifyView<TopLevelResource, AccountInput> {
+public class CreateAccount implements RestModifyView<TopLevelResource, AccountInput> {
   public interface Factory {
     CreateAccount create(String username);
   }
@@ -68,25 +65,26 @@
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final AccountCache accountCache;
-  private final AccountIndexer indexer;
   private final AccountByEmailCache byEmailCache;
   private final AccountLoader.Factory infoLoader;
   private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
   private final AuditService auditService;
+  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
   private final String username;
 
   @Inject
-  CreateAccount(ReviewDb db,
+  CreateAccount(
+      ReviewDb db,
       Provider<IdentifiedUser> currentUser,
       GroupsCollection groupsCollection,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       AccountCache accountCache,
-      AccountIndexer indexer,
       AccountByEmailCache byEmailCache,
       AccountLoader.Factory infoLoader,
       DynamicSet<AccountExternalIdCreator> externalIdCreators,
       AuditService auditService,
+      ExternalIdsUpdate.User externalIdsUpdateFactory,
       @Assisted String username) {
     this.db = db;
     this.currentUser = currentUser;
@@ -94,19 +92,18 @@
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.accountCache = accountCache;
-    this.indexer = indexer;
     this.byEmailCache = byEmailCache;
     this.infoLoader = infoLoader;
     this.externalIdCreators = externalIdCreators;
     this.auditService = auditService;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.username = username;
   }
 
   @Override
   public Response<AccountInfo> apply(TopLevelResource rsrc, AccountInput input)
-      throws BadRequestException, ResourceConflictException,
-      UnprocessableEntityException, OrmException, IOException,
-      ConfigInvalidException {
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
+          OrmException, IOException, ConfigInvalidException {
     if (input == null) {
       input = new AccountInput();
     }
@@ -114,64 +111,52 @@
       throw new BadRequestException("username must match URL");
     }
 
-    if (!username.matches(Account.USER_NAME_PATTERN)) {
-      throw new BadRequestException("Username '" + username + "'"
-          + " must contain only letters, numbers, _, - or .");
+    if (!ExternalId.isValidUsername(username)) {
+      throw new BadRequestException("Invalid username '" + username + "'");
     }
 
     Set<AccountGroup.Id> groups = parseGroups(input.groups);
 
     Account.Id id = new Account.Id(db.nextAccountId());
 
-    AccountExternalId extUser =
-        new AccountExternalId(id, new AccountExternalId.Key(
-            AccountExternalId.SCHEME_USERNAME, username));
-
-    if (input.httpPassword != null) {
-      extUser.setPassword(input.httpPassword);
-    }
-
-    if (db.accountExternalIds().get(extUser.getKey()) != null) {
-      throw new ResourceConflictException(
-          "username '" + username + "' already exists");
+    ExternalId extUser = ExternalId.createUsername(username, id, input.httpPassword);
+    if (db.accountExternalIds().get(extUser.key().asAccountExternalIdKey()) != null) {
+      throw new ResourceConflictException("username '" + username + "' already exists");
     }
     if (input.email != null) {
-      if (db.accountExternalIds().get(getEmailKey(input.email)) != null) {
-        throw new UnprocessableEntityException(
-            "email '" + input.email + "' already exists");
+      if (db.accountExternalIds()
+              .get(ExternalId.Key.create(SCHEME_MAILTO, input.email).asAccountExternalIdKey())
+          != null) {
+        throw new UnprocessableEntityException("email '" + input.email + "' already exists");
       }
       if (!OutgoingEmailValidator.isValid(input.email)) {
         throw new BadRequestException("invalid email address");
       }
     }
 
-    LinkedList<AccountExternalId> externalIds = new LinkedList<>();
-    externalIds.add(extUser);
+    List<ExternalId> extIds = new ArrayList<>();
+    extIds.add(extUser);
     for (AccountExternalIdCreator c : externalIdCreators) {
-      externalIds.addAll(c.create(id, username, input.email));
+      extIds.addAll(c.create(id, username, input.email));
     }
 
+    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
     try {
-      db.accountExternalIds().insert(externalIds);
+      externalIdsUpdate.insert(db, extIds);
     } catch (OrmDuplicateKeyException duplicateKey) {
-      throw new ResourceConflictException(
-          "username '" + username + "' already exists");
+      throw new ResourceConflictException("username '" + username + "' already exists");
     }
 
     if (input.email != null) {
-      AccountExternalId extMailto =
-          new AccountExternalId(id, getEmailKey(input.email));
-      extMailto.setEmailAddress(input.email);
       try {
-        db.accountExternalIds().insert(Collections.singleton(extMailto));
+        externalIdsUpdate.insert(db, ExternalId.createEmail(id, input.email));
       } catch (OrmDuplicateKeyException duplicateKey) {
         try {
-          db.accountExternalIds().delete(Collections.singleton(extUser));
-        } catch (OrmException cleanupError) {
+          externalIdsUpdate.delete(db, extUser);
+        } catch (IOException | OrmException cleanupError) {
           // Ignored
         }
-        throw new UnprocessableEntityException(
-            "email '" + input.email + "' already exists");
+        throw new UnprocessableEntityException("email '" + input.email + "' already exists");
       }
     }
 
@@ -181,10 +166,9 @@
     db.accounts().insert(Collections.singleton(a));
 
     for (AccountGroup.Id groupId : groups) {
-      AccountGroupMember m =
-          new AccountGroupMember(new AccountGroupMember.Key(id, groupId));
-      auditService.dispatchAddAccountsToGroup(currentUser.get().getAccountId(),
-          Collections.singleton(m));
+      AccountGroupMember m = new AccountGroupMember(new AccountGroupMember.Key(id, groupId));
+      auditService.dispatchAddAccountsToGroup(
+          currentUser.get().getAccountId(), Collections.singleton(m));
       db.accountGroupMembers().insert(Collections.singleton(m));
     }
 
@@ -197,9 +181,9 @@
       }
     }
 
+    accountCache.evict(id); // triggers reindex
     accountCache.evictByUsername(username);
     byEmailCache.evict(input.email);
-    indexer.index(id);
 
     AccountLoader loader = infoLoader.create(true);
     AccountInfo info = loader.get(id);
@@ -212,14 +196,9 @@
     Set<AccountGroup.Id> groupIds = new HashSet<>();
     if (groups != null) {
       for (String g : groups) {
-        groupIds.add(GroupDescriptions.toAccountGroup(
-            groupsCollection.parseInternal(g)).getId());
+        groupIds.add(GroupDescriptions.toAccountGroup(groupsCollection.parseInternal(g)).getId());
       }
     }
     return groupIds;
   }
-
-  private AccountExternalId.Key getEmailKey(String email) {
-    return new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
index 713154c..00cf4e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.extensions.client.AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT;
+
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -23,24 +27,20 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GetEmails.EmailInfo;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.mail.OutgoingEmailValidator;
-import com.google.gerrit.server.mail.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 public class CreateEmail implements RestModifyView<AccountResource, EmailInput> {
   private static final Logger log = LoggerFactory.getLogger(CreateEmail.class);
 
@@ -50,14 +50,15 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
-  private final AuthConfig authConfig;
   private final AccountManager accountManager;
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
   private final String email;
+  private final boolean isDevMode;
 
   @Inject
-  CreateEmail(Provider<CurrentUser> self,
+  CreateEmail(
+      Provider<CurrentUser> self,
       Realm realm,
       AuthConfig authConfig,
       AccountManager accountManager,
@@ -66,19 +67,19 @@
       @Assisted String email) {
     this.self = self;
     this.realm = realm;
-    this.authConfig = authConfig;
     this.accountManager = accountManager;
     this.registerNewEmailFactory = registerNewEmailFactory;
     this.putPreferred = putPreferred;
-    this.email = email;
+    this.email = email != null ? email.trim() : null;
+    this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
   }
 
   @Override
   public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
-      ResourceNotFoundException, OrmException, EmailException,
-      MethodNotAllowedException, IOException {
-    if (self.get() != rsrc.getUser()
+          ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
+          IOException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to add email address");
     }
@@ -87,44 +88,47 @@
       input = new EmailInput();
     }
 
-    if (!OutgoingEmailValidator.isValid(email)) {
-      throw new BadRequestException("invalid email address");
-    }
-
-    if (input.noConfirmation
-        && !self.get().getCapabilities().canModifyAccount()) {
+    if (input.noConfirmation && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to use no_confirmation");
     }
 
-    if (!realm.allowsEdit(FieldName.REGISTER_NEW_EMAIL)) {
+    if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow adding emails");
     }
 
     return apply(rsrc.getUser(), input);
   }
 
+  /** To be used from plugins that want to create emails without permission checks. */
   public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
-      ResourceNotFoundException, OrmException, EmailException,
-      MethodNotAllowedException, IOException {
+          ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
+          IOException {
+    if (input == null) {
+      input = new EmailInput();
+    }
+
     if (input.email != null && !email.equals(input.email)) {
       throw new BadRequestException("email address must match URL");
     }
 
+    if (!OutgoingEmailValidator.isValid(email)) {
+      throw new BadRequestException("invalid email address");
+    }
+
     EmailInfo info = new EmailInfo();
     info.email = email;
-    if (input.noConfirmation
-        || authConfig.getAuthType() == AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT) {
+    if (input.noConfirmation || isDevMode) {
+      if (isDevMode) {
+        log.warn("skipping email validation in developer mode");
+      }
       try {
-        accountManager.link(user.getAccountId(),
-            AuthRequest.forEmail(email));
+        accountManager.link(user.getAccountId(), AuthRequest.forEmail(email));
       } catch (AccountException e) {
         throw new ResourceConflictException(e.getMessage());
       }
       if (input.preferred) {
-        putPreferred.apply(
-            new AccountResource.Email(user, email),
-            null);
+        putPreferred.apply(new AccountResource.Email(user, email), null);
         info.preferred = true;
       }
     } else {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateGroupArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateGroupArgs.java
index 9ddef3a..0c0778c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
 import java.util.Collection;
 
 public class CreateGroupArgs {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
index eb3c9a0..795f1c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.Set;
 
 @Singleton
@@ -30,16 +30,14 @@
   private final AuthConfig authConfig;
 
   @Inject
-  DefaultRealm(EmailExpander emailExpander,
-      AccountByEmailCache byEmail,
-      AuthConfig authConfig) {
+  DefaultRealm(EmailExpander emailExpander, AccountByEmailCache byEmail, AuthConfig authConfig) {
     this.emailExpander = emailExpander;
     this.byEmail = byEmail;
     this.authConfig = authConfig;
   }
 
   @Override
-  public boolean allowsEdit(final Account.FieldName field) {
+  public boolean allowsEdit(final AccountFieldName field) {
     if (authConfig.getAuthType() == AuthType.HTTP) {
       switch (field) {
         case USER_NAME:
@@ -65,7 +63,8 @@
 
   @Override
   public AuthRequest authenticate(final AuthRequest who) {
-    if (who.getEmailAddress() == null && who.getLocalUser() != null
+    if (who.getEmailAddress() == null
+        && who.getLocalUser() != null
         && emailExpander.canExpand(who.getLocalUser())) {
       who.setEmailAddress(emailExpander.expand(who.getLocalUser()));
     }
@@ -73,8 +72,7 @@
   }
 
   @Override
-  public void onCreateAccount(final AuthRequest who, final Account account) {
-  }
+  public void onCreateAccount(final AuthRequest who, final Account account) {}
 
   @Override
   public Account.Id lookup(final String accountName) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
index f6c48af..8b713ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
@@ -16,47 +16,71 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.DeleteActive.Input;
+import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-import java.util.Collections;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class DeleteActive implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   private final Provider<ReviewDb> dbProvider;
   private final AccountCache byIdCache;
+  private final Provider<IdentifiedUser> self;
 
   @Inject
-  DeleteActive(Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  DeleteActive(
+      Provider<ReviewDb> dbProvider, AccountCache byIdCache, Provider<IdentifiedUser> self) {
     this.dbProvider = dbProvider;
     this.byIdCache = byIdCache;
+    this.self = self;
   }
 
   @Override
   public Response<?> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, OrmException, IOException {
-    Account a = dbProvider.get().accounts().get(rsrc.getUser().getAccountId());
+      throws RestApiException, OrmException, IOException {
+    if (self.get().hasSameAccountId(rsrc.getUser())) {
+      throw new ResourceConflictException("cannot deactivate own account");
+    }
+
+    AtomicBoolean alreadyInactive = new AtomicBoolean(false);
+    Account a =
+        dbProvider
+            .get()
+            .accounts()
+            .atomicUpdate(
+                rsrc.getUser().getAccountId(),
+                new AtomicUpdate<Account>() {
+                  @Override
+                  public Account update(Account a) {
+                    if (!a.isActive()) {
+                      alreadyInactive.set(true);
+                    } else {
+                      a.setActive(false);
+                    }
+                    return a;
+                  }
+                });
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    if (!a.isActive()) {
-      throw new ResourceNotFoundException();
+    if (alreadyInactive.get()) {
+      throw new ResourceConflictException("account not active");
     }
-    a.setActive(false);
-    dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index 76f63b7..ecddc8c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -14,14 +14,15 @@
 
 package com.google.gerrit.server.account;
 
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -30,13 +31,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteEmail implements RestModifyView<AccountResource.Email, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
@@ -44,8 +45,11 @@
   private final AccountManager accountManager;
 
   @Inject
-  DeleteEmail(Provider<CurrentUser> self, Realm realm,
-      Provider<ReviewDb> dbProvider, AccountManager accountManager) {
+  DeleteEmail(
+      Provider<CurrentUser> self,
+      Realm realm,
+      Provider<ReviewDb> dbProvider,
+      AccountManager accountManager) {
     this.self = self;
     this.realm = realm;
     this.dbProvider = dbProvider;
@@ -54,10 +58,9 @@
 
   @Override
   public Response<?> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException,
-      ResourceConflictException, MethodNotAllowedException, OrmException,
-      IOException {
-    if (self.get() != rsrc.getUser()
+      throws AuthException, ResourceNotFoundException, ResourceConflictException,
+          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to delete email address");
     }
@@ -65,20 +68,27 @@
   }
 
   public Response<?> apply(IdentifiedUser user, String email)
-      throws ResourceNotFoundException, ResourceConflictException,
-      MethodNotAllowedException, OrmException, IOException {
-    if (!realm.allowsEdit(FieldName.REGISTER_NEW_EMAIL)) {
+      throws ResourceNotFoundException, ResourceConflictException, MethodNotAllowedException,
+          OrmException, IOException {
+    if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow deleting emails");
     }
-    AccountExternalId.Key key = new AccountExternalId.Key(
-        AccountExternalId.SCHEME_MAILTO, email);
-    AccountExternalId extId = dbProvider.get().accountExternalIds().get(key);
-    if (extId == null) {
+
+    Set<ExternalId> extIds =
+        dbProvider.get().accountExternalIds().byAccount(user.getAccountId()).toList().stream()
+            .map(ExternalId::from)
+            .filter(e -> email.equals(e.email()))
+            .collect(toSet());
+    if (extIds.isEmpty()) {
       throw new ResourceNotFoundException(email);
     }
+
     try {
-      accountManager.unlink(user.getAccountId(),
-          AuthRequest.forEmail(email));
+      for (ExternalId extId : extIds) {
+        AuthRequest authRequest = new AuthRequest(extId.key());
+        authRequest.setEmailAddress(email);
+        accountManager.unlink(user.getAccountId(), authRequest);
+      }
     } catch (AccountException e) {
       throw new ResourceConflictException(e.getMessage());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
new file mode 100644
index 0000000..a0e084c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.toMap;
+
+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.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+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.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
+  private final AccountByEmailCache accountByEmailCache;
+  private final AccountCache accountCache;
+  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
+  private final Provider<CurrentUser> self;
+  private final Provider<ReviewDb> dbProvider;
+
+  @Inject
+  DeleteExternalIds(
+      AccountByEmailCache accountByEmailCache,
+      AccountCache accountCache,
+      ExternalIdsUpdate.User externalIdsUpdateFactory,
+      Provider<CurrentUser> self,
+      Provider<ReviewDb> dbProvider) {
+    this.accountByEmailCache = accountByEmailCache;
+    this.accountCache = accountCache;
+    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+    this.self = self;
+    this.dbProvider = dbProvider;
+  }
+
+  @Override
+  public Response<?> apply(AccountResource resource, List<String> externalIds)
+      throws RestApiException, IOException, OrmException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(resource.getUser())) {
+      throw new AuthException("not allowed to delete external IDs");
+    }
+
+    if (externalIds == null || externalIds.size() == 0) {
+      throw new BadRequestException("external IDs are required");
+    }
+
+    Account.Id accountId = resource.getUser().getAccountId();
+    Map<ExternalId.Key, ExternalId> externalIdMap =
+        dbProvider.get().accountExternalIds().byAccount(resource.getUser().getAccountId()).toList()
+            .stream()
+            .map(ExternalId::from)
+            .collect(toMap(i -> i.key(), i -> i));
+
+    List<ExternalId> toDelete = new ArrayList<>();
+    ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
+    for (String externalIdStr : externalIds) {
+      ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
+
+      if (id == null) {
+        throw new UnprocessableEntityException(
+            String.format("External id %s does not exist", externalIdStr));
+      }
+
+      if ((!id.isScheme(SCHEME_USERNAME))
+          && ((last == null) || (!last.get().equals(id.key().get())))) {
+        toDelete.add(id);
+      } else {
+        throw new ResourceConflictException(
+            String.format("External id %s cannot be deleted", externalIdStr));
+      }
+    }
+
+    if (!toDelete.isEmpty()) {
+      externalIdsUpdateFactory.create().delete(dbProvider.get(), toDelete);
+      accountCache.evict(accountId);
+      for (ExternalId e : toDelete) {
+        accountByEmailCache.evict(e.email());
+      }
+    }
+
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
index 9212002..abb0118 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
@@ -24,24 +24,21 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
-import java.io.IOException;
-
 @Singleton
-public class DeleteSshKey implements
-    RestModifyView<AccountResource.SshKey, Input> {
-  public static class Input {
-  }
+public class DeleteSshKey implements RestModifyView<AccountResource.SshKey, Input> {
+  public static class Input {}
 
   private final Provider<CurrentUser> self;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
 
   @Inject
-  DeleteSshKey(Provider<CurrentUser> self,
+  DeleteSshKey(
+      Provider<CurrentUser> self,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache) {
     this.self = self;
@@ -51,15 +48,14 @@
 
   @Override
   public Response<?> apply(AccountResource.SshKey rsrc, Input input)
-      throws AuthException, OrmException, RepositoryNotFoundException,
-      IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to delete SSH keys");
     }
 
-    authorizedKeys.deleteKey(rsrc.getUser().getAccountId(),
-        rsrc.getSshKey().getKey().get());
+    authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().getKey().get());
     sshKeyCache.evict(rsrc.getUser().getUserName());
 
     return Response.none();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
index e2fbc3c..95ef384 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
@@ -14,46 +14,35 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
-import com.google.common.collect.Lists;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 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.AccountProjectWatch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
-import java.util.HashMap;
-import java.util.LinkedList;
 import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
-  private final Provider<ReviewDb> dbProvider;
   private final Provider<IdentifiedUser> self;
   private final AccountCache accountCache;
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
-  DeleteWatchedProjects(Provider<ReviewDb> dbProvider,
-      Provider<IdentifiedUser> self,
-      AccountCache accountCache,
-      WatchConfig.Accessor watchConfig) {
-    this.dbProvider = dbProvider;
+  DeleteWatchedProjects(
+      Provider<IdentifiedUser> self, AccountCache accountCache, WatchConfig.Accessor watchConfig) {
     this.self = self;
     this.accountCache = accountCache;
     this.watchConfig = watchConfig;
@@ -61,57 +50,23 @@
 
   @Override
   public Response<?> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
-      throws AuthException, UnprocessableEntityException, OrmException,
-      IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("It is not allowed to edit project watches "
-          + "of other users");
+      throw new AuthException("It is not allowed to edit project watches of other users");
     }
     if (input == null) {
       return Response.none();
     }
 
     Account.Id accountId = rsrc.getUser().getAccountId();
-    deleteFromDb(accountId, input);
-    deleteFromGit(accountId, input);
+    watchConfig.deleteProjectWatches(
+        accountId,
+        input.stream()
+            .map(w -> ProjectWatchKey.create(new Project.NameKey(w.project), w.filter))
+            .collect(toList()));
     accountCache.evict(accountId);
     return Response.none();
   }
-
-  private void deleteFromDb(Account.Id accountId, List<ProjectWatchInfo> input)
-      throws OrmException, IOException {
-    ResultSet<AccountProjectWatch> watchedProjects =
-        dbProvider.get().accountProjectWatches().byAccount(accountId);
-    HashMap<AccountProjectWatch.Key, AccountProjectWatch> watchedProjectsMap =
-        new HashMap<>();
-    for (AccountProjectWatch watchedProject : watchedProjects) {
-      watchedProjectsMap.put(watchedProject.getKey(), watchedProject);
-    }
-
-    List<AccountProjectWatch> watchesToDelete = new LinkedList<>();
-    for (ProjectWatchInfo projectInfo : input) {
-      AccountProjectWatch.Key key = new AccountProjectWatch.Key(accountId,
-          new Project.NameKey(projectInfo.project), projectInfo.filter);
-      if (watchedProjectsMap.containsKey(key)) {
-        watchesToDelete.add(watchedProjectsMap.get(key));
-      }
-    }
-    if (!watchesToDelete.isEmpty()) {
-      dbProvider.get().accountProjectWatches().delete(watchesToDelete);
-      accountCache.evict(accountId);
-    }
-  }
-
-  private void deleteFromGit(Account.Id accountId, List<ProjectWatchInfo> input)
-      throws IOException, ConfigInvalidException {
-    watchConfig.deleteProjectWatches(accountId, Lists.transform(input,
-        new Function<ProjectWatchInfo, ProjectWatchKey>() {
-          @Override
-          public ProjectWatchKey apply(ProjectWatchInfo info) {
-            return ProjectWatchKey.create(new Project.NameKey(info.project),
-                info.filter);
-          }
-        }));
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
index 75408c8..3c501e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-
-/**
- * Expands user name to a local email address, usually by adding a domain.
- */
+/** Expands user name to a local email address, usually by adding a domain. */
 public interface EmailExpander {
   boolean canExpand(String user);
 
@@ -30,8 +27,7 @@
       return fmt == null || fmt.isEmpty();
     }
 
-    private None() {
-    }
+    private None() {}
 
     @Override
     public boolean canExpand(String user) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
index 733cf5b..8cfb66c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
@@ -28,16 +28,17 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class Emails implements
-    ChildCollection<AccountResource, AccountResource.Email>,
-    AcceptsCreate<AccountResource> {
+public class Emails
+    implements ChildCollection<AccountResource, AccountResource.Email>,
+        AcceptsCreate<AccountResource> {
   private final DynamicMap<RestView<AccountResource.Email>> views;
   private final GetEmails list;
   private final Provider<CurrentUser> self;
   private final CreateEmail.Factory createEmailFactory;
 
   @Inject
-  Emails(DynamicMap<RestView<AccountResource.Email>> views,
+  Emails(
+      DynamicMap<RestView<AccountResource.Email>> views,
       GetEmails list,
       Provider<CurrentUser> self,
       CreateEmail.Factory createEmailFactory) {
@@ -55,7 +56,7 @@
   @Override
   public AccountResource.Email parse(AccountResource rsrc, IdString id)
       throws ResourceNotFoundException {
-    if (self.get() != rsrc.getUser()
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new ResourceNotFoundException();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
new file mode 100644
index 0000000..0a8a028
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
@@ -0,0 +1,364 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.hash.Hashing;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class ExternalId implements Serializable {
+  // If these regular expressions are modified the same modifications should be done to the
+  // corresponding regular expressions in the
+  // com.google.gerrit.client.account.UsernameField class.
+  private static final String USER_NAME_PATTERN_FIRST_REGEX = "[a-zA-Z0-9]";
+  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9.!#$%&’*+=?^_`\\{|\\}~@-]";
+  private static final String USER_NAME_PATTERN_LAST_REGEX = "[a-zA-Z0-9]";
+
+  /** Regular expression that a username must match. */
+  private static final String USER_NAME_PATTERN_REGEX =
+      "^("
+          + //
+          USER_NAME_PATTERN_FIRST_REGEX
+          + //
+          USER_NAME_PATTERN_REST_REGEX
+          + "*"
+          + //
+          USER_NAME_PATTERN_LAST_REGEX
+          + //
+          "|"
+          + //
+          USER_NAME_PATTERN_FIRST_REGEX
+          + //
+          ")$";
+
+  private static final Pattern USER_NAME_PATTERN = Pattern.compile(USER_NAME_PATTERN_REGEX);
+
+  public static boolean isValidUsername(String username) {
+    return USER_NAME_PATTERN.matcher(username).matches();
+  }
+
+  private static final long serialVersionUID = 1L;
+
+  private static final String EXTERNAL_ID_SECTION = "externalId";
+  private static final String ACCOUNT_ID_KEY = "accountId";
+  private static final String EMAIL_KEY = "email";
+  private static final String PASSWORD_KEY = "password";
+
+  /**
+   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
+   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   *
+   * <p>The name {@code gerrit:} was a very poor choice.
+   */
+  public static final String SCHEME_GERRIT = "gerrit";
+
+  /** Scheme used for randomly created identities constructed by a UUID. */
+  public static final String SCHEME_UUID = "uuid";
+
+  /** Scheme used to represent only an email address. */
+  public static final String SCHEME_MAILTO = "mailto";
+
+  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
+  public static final String SCHEME_USERNAME = "username";
+
+  /** Scheme used for GPG public keys. */
+  public static final String SCHEME_GPGKEY = "gpgkey";
+
+  /** Scheme for external auth used during authentication, e.g. OAuth Token */
+  public static final String SCHEME_EXTERNAL = "external";
+
+  @AutoValue
+  public abstract static class Key implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    public static Key create(@Nullable String scheme, String id) {
+      return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
+    }
+
+    public static ExternalId.Key from(AccountExternalId.Key externalIdKey) {
+      return parse(externalIdKey.get());
+    }
+
+    /**
+     * Parses an external ID key from a string in the format "scheme:id" or "id".
+     *
+     * @return the parsed external ID key
+     */
+    public static Key parse(String externalId) {
+      int c = externalId.indexOf(':');
+      if (c < 1 || c >= externalId.length() - 1) {
+        return create(null, externalId);
+      }
+      return create(externalId.substring(0, c), externalId.substring(c + 1));
+    }
+
+    public static Set<AccountExternalId.Key> toAccountExternalIdKeys(
+        Collection<ExternalId.Key> extIdKeys) {
+      return extIdKeys.stream().map(k -> k.asAccountExternalIdKey()).collect(toSet());
+    }
+
+    public abstract @Nullable String scheme();
+
+    public abstract String id();
+
+    public boolean isScheme(String scheme) {
+      return scheme.equals(scheme());
+    }
+
+    public AccountExternalId.Key asAccountExternalIdKey() {
+      if (scheme() != null) {
+        return new AccountExternalId.Key(scheme(), id());
+      }
+      return new AccountExternalId.Key(id());
+    }
+
+    /**
+     * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
+     * notes branch.
+     */
+    @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
+    public ObjectId sha1() {
+      return ObjectId.fromRaw(Hashing.sha1().hashString(get(), UTF_8).asBytes());
+    }
+
+    /**
+     * Exports this external ID key as string with the format "scheme:id", or "id" id scheme is
+     * null.
+     *
+     * <p>This string representation is used as subsection name in the Git config file that stores
+     * the external ID.
+     */
+    public String get() {
+      if (scheme() != null) {
+        return scheme() + ":" + id();
+      }
+      return id();
+    }
+
+    @Override
+    public String toString() {
+      return get();
+    }
+  }
+
+  public static ExternalId create(String scheme, String id, Account.Id accountId) {
+    return new AutoValue_ExternalId(Key.create(scheme, id), accountId, null, null);
+  }
+
+  public static ExternalId create(
+      String scheme,
+      String id,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword) {
+    return create(Key.create(scheme, id), accountId, email, hashedPassword);
+  }
+
+  public static ExternalId create(Key key, Account.Id accountId) {
+    return create(key, accountId, null, null);
+  }
+
+  public static ExternalId create(
+      Key key, Account.Id accountId, @Nullable String email, @Nullable String hashedPassword) {
+    return new AutoValue_ExternalId(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword));
+  }
+
+  public static ExternalId createWithPassword(
+      Key key, Account.Id accountId, @Nullable String email, String plainPassword) {
+    plainPassword = Strings.emptyToNull(plainPassword);
+    String hashedPassword =
+        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
+    return create(key, accountId, email, hashedPassword);
+  }
+
+  public static ExternalId createUsername(String id, Account.Id accountId, String plainPassword) {
+    return createWithPassword(Key.create(SCHEME_USERNAME, id), accountId, null, plainPassword);
+  }
+
+  public static ExternalId createWithEmail(
+      String scheme, String id, Account.Id accountId, @Nullable String email) {
+    return createWithEmail(Key.create(scheme, id), accountId, email);
+  }
+
+  public static ExternalId createWithEmail(Key key, Account.Id accountId, @Nullable String email) {
+    return new AutoValue_ExternalId(key, accountId, Strings.emptyToNull(email), null);
+  }
+
+  public static ExternalId createEmail(Account.Id accountId, String email) {
+    return createWithEmail(SCHEME_MAILTO, email, accountId, checkNotNull(email));
+  }
+
+  /**
+   * Parses an external ID from a byte array that contain the external ID as an Git config file
+   * text.
+   *
+   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
+   * email and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   */
+  public static ExternalId parse(String noteId, byte[] raw) throws ConfigInvalidException {
+    Config externalIdConfig = new Config();
+    try {
+      externalIdConfig.fromText(new String(raw, UTF_8));
+    } catch (ConfigInvalidException e) {
+      throw invalidConfig(noteId, e.getMessage());
+    }
+
+    Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
+    if (externalIdKeys.size() != 1) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Expected exactly 1 %s section, found %d",
+              EXTERNAL_ID_SECTION, externalIdKeys.size()));
+    }
+
+    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
+    Key externalIdKey = Key.parse(externalIdKeyStr);
+    if (externalIdKey == null) {
+      throw invalidConfig(noteId, String.format("Invalid external id: %s", externalIdKeyStr));
+    }
+
+    String accountIdStr =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
+    String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
+    String password =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
+    if (accountIdStr == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Missing value for %s.%s.%s", EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+    Integer accountId = Ints.tryParse(accountIdStr);
+    if (accountId == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value %s for %s.%s.%s is invalid, expected account ID",
+              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+
+    return new AutoValue_ExternalId(
+        externalIdKey,
+        new Account.Id(accountId),
+        Strings.emptyToNull(email),
+        Strings.emptyToNull(password));
+  }
+
+  private static ConfigInvalidException invalidConfig(String noteId, String message) {
+    return new ConfigInvalidException(
+        String.format("Invalid external id config for note %s: %s", noteId, message));
+  }
+
+  public static ExternalId from(AccountExternalId externalId) {
+    if (externalId == null) {
+      return null;
+    }
+
+    return new AutoValue_ExternalId(
+        ExternalId.Key.parse(externalId.getExternalId()),
+        externalId.getAccountId(),
+        Strings.emptyToNull(externalId.getEmailAddress()),
+        Strings.emptyToNull(externalId.getPassword()));
+  }
+
+  public static Set<ExternalId> from(Collection<AccountExternalId> externalIds) {
+    if (externalIds == null) {
+      return ImmutableSet.of();
+    }
+    return externalIds.stream().map(ExternalId::from).collect(toSet());
+  }
+
+  public static Set<AccountExternalId> toAccountExternalIds(Collection<ExternalId> extIds) {
+    return extIds.stream().map(e -> e.asAccountExternalId()).collect(toSet());
+  }
+
+  public abstract Key key();
+
+  public abstract Account.Id accountId();
+
+  public abstract @Nullable String email();
+
+  public abstract @Nullable String password();
+
+  public boolean isScheme(String scheme) {
+    return key().isScheme(scheme);
+  }
+
+  public AccountExternalId asAccountExternalId() {
+    AccountExternalId extId = new AccountExternalId(accountId(), key().asAccountExternalIdKey());
+    extId.setEmailAddress(email());
+    extId.setPassword(password());
+    return extId;
+  }
+
+  /**
+   * Exports this external ID as Git config file text.
+   *
+   * <p>The Git config has exactly one externalId subsection with an accountId and optionally email
+   * and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   */
+  @Override
+  public String toString() {
+    Config c = new Config();
+    writeToConfig(c);
+    return c.toText();
+  }
+
+  public void writeToConfig(Config c) {
+    String externalIdKey = key().get();
+    c.setInt(EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, accountId().get());
+    if (email() != null) {
+      c.setString(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY, email());
+    }
+    if (password() != null) {
+      c.setString(EXTERNAL_ID_SECTION, externalIdKey, PASSWORD_KEY, password());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
new file mode 100644
index 0000000..c937935
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Class to read external IDs from NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has exactly one externalId subsection with
+ * an accountId and optionally email and password:
+ *
+ * <pre>
+ * [externalId "username:jdoe"]
+ *   accountId = 1003407
+ *   email = jdoe@example.com
+ *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+ * </pre>
+ */
+@Singleton
+public class ExternalIds {
+  public static final int MAX_NOTE_SZ = 1 << 19;
+
+  public static ObjectId readRevision(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
+    if (!rev.equals(ObjectId.zeroId())) {
+      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
+    }
+    return NoteMap.newEmptyMap();
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public ExternalIds(GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  public ObjectId readRevision() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readRevision(repo);
+    }
+  }
+
+  /** Reads and returns the specified external ID. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev = readRevision(repo);
+      if (rev.equals(ObjectId.zeroId())) {
+        return null;
+      }
+
+      return parse(key, rw, rev);
+    }
+  }
+
+  private ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    NoteMap noteMap = readNoteMap(rw, rev);
+    ObjectId noteId = key.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    byte[] raw =
+        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    return ExternalId.parse(noteId.name(), raw);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
new file mode 100644
index 0000000..68d3d0b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmException;
+import java.util.HashSet;
+import java.util.Set;
+
+/** This class allows to do batch updates to external IDs. */
+public class ExternalIdsBatchUpdate {
+  private final Set<ExternalId> toAdd = new HashSet<>();
+  private final Set<ExternalId> toDelete = new HashSet<>();
+
+  /** Adds an external ID replacement to the batch. */
+  public void replace(ExternalId extIdToDelete, ExternalId extIdToAdd) {
+    ExternalIdsUpdate.checkSameAccount(ImmutableSet.of(extIdToDelete, extIdToAdd));
+    toAdd.add(extIdToAdd);
+    toDelete.add(extIdToDelete);
+  }
+
+  /**
+   * Commits this batch.
+   *
+   * <p>This means external ID replacements which were prepared by invoking {@link
+   * #replace(ExternalId, ExternalId)} are now executed. Deletion of external IDs is done before
+   * adding the new external IDs. This means if an external ID is specified for deletion and an
+   * external ID with the same key is specified to be added, the old external ID with that key is
+   * deleted first and then the new external ID is added (so the external ID for that key is
+   * replaced).
+   */
+  public void commit(ReviewDb db) throws OrmException {
+    if (toDelete.isEmpty() && toAdd.isEmpty()) {
+      return;
+    }
+
+    db.accountExternalIds().delete(toAccountExternalIds(toDelete));
+    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
+    toAdd.clear();
+    toDelete.clear();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
new file mode 100644
index 0000000..ca9bfaa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
@@ -0,0 +1,274 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.account.ExternalId.Key.toAccountExternalIdKeys;
+import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+import static java.util.stream.Collectors.toSet;
+
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.TimeUnit;
+
+// Updates externalIds in ReviewDb.
+public class ExternalIdsUpdate {
+  /**
+   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the Gerrit server.
+   */
+  @Singleton
+  public static class Server {
+    private final AccountCache accountCache;
+
+    @Inject
+    public Server(AccountCache accountCache) {
+      this.accountCache = accountCache;
+    }
+
+    public ExternalIdsUpdate create() {
+      return new ExternalIdsUpdate(accountCache);
+    }
+  }
+
+  @Singleton
+  public static class User {
+    private final AccountCache accountCache;
+
+    @Inject
+    public User(AccountCache accountCache) {
+      this.accountCache = accountCache;
+    }
+
+    public ExternalIdsUpdate create() {
+      return new ExternalIdsUpdate(accountCache);
+    }
+  }
+
+  @VisibleForTesting
+  public static RetryerBuilder<Void> retryerBuilder() {
+    return RetryerBuilder.<Void>newBuilder()
+        .retryIfException(e -> e instanceof LockFailureException)
+        .withWaitStrategy(
+            WaitStrategies.join(
+                WaitStrategies.exponentialWait(2, TimeUnit.SECONDS),
+                WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
+        .withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS));
+  }
+
+  private final AccountCache accountCache;
+
+  @VisibleForTesting
+  public ExternalIdsUpdate(AccountCache accountCache) {
+    this.accountCache = accountCache;
+  }
+
+  /**
+   * Inserts a new external ID.
+   *
+   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
+   */
+  public void insert(ReviewDb db, ExternalId extId) throws IOException, OrmException {
+    insert(db, Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts new external IDs.
+   *
+   * <p>If any of the external ID already exists, the insert fails with {@link
+   * OrmDuplicateKeyException}.
+   */
+  public void insert(ReviewDb db, Collection<ExternalId> extIds) throws IOException, OrmException {
+    db.accountExternalIds().insert(toAccountExternalIds(extIds));
+    evictAccounts(extIds);
+  }
+
+  /**
+   * Inserts or updates an external ID.
+   *
+   * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
+   */
+  public void upsert(ReviewDb db, ExternalId extId) throws IOException, OrmException {
+    upsert(db, Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts or updates external IDs.
+   *
+   * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
+   */
+  public void upsert(ReviewDb db, Collection<ExternalId> extIds) throws IOException, OrmException {
+    db.accountExternalIds().upsert(toAccountExternalIds(extIds));
+    evictAccounts(extIds);
+  }
+
+  /**
+   * Deletes an external ID.
+   *
+   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
+   * that has the same key, but otherwise doesn't match the specified external ID.
+   */
+  public void delete(ReviewDb db, ExternalId extId) throws IOException, OrmException {
+    delete(db, Collections.singleton(extId));
+  }
+
+  /**
+   * Deletes external IDs.
+   *
+   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
+   * that has the same key as any of the external IDs that should be deleted, but otherwise doesn't
+   * match the that external ID.
+   */
+  public void delete(ReviewDb db, Collection<ExternalId> extIds) throws IOException, OrmException {
+    db.accountExternalIds().delete(toAccountExternalIds(extIds));
+    evictAccounts(extIds);
+  }
+
+  /**
+   * Delete an external ID by key.
+   *
+   * <p>The external ID is only deleted if it belongs to the specified account. If it belongs to
+   * another account the deletion fails with {@link IllegalStateException}.
+   */
+  public void delete(ReviewDb db, Account.Id accountId, ExternalId.Key extIdKey)
+      throws IOException, OrmException {
+    delete(db, accountId, Collections.singleton(extIdKey));
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * <p>The external IDs are only deleted if they belongs to the specified account. If any of the
+   * external IDs belongs to another account the deletion fails with {@link IllegalStateException}.
+   */
+  public void delete(ReviewDb db, Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
+      throws IOException, OrmException {
+    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(extIdKeys));
+    accountCache.evict(accountId);
+  }
+
+  /** Deletes all external IDs of the specified account. */
+  public void deleteAll(ReviewDb db, Account.Id accountId) throws IOException, OrmException {
+    delete(db, ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()));
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * <p>If any of the specified external IDs belongs to another account the replacement fails with
+   * {@link IllegalStateException}.
+   */
+  public void replace(
+      ReviewDb db,
+      Account.Id accountId,
+      Collection<ExternalId.Key> toDelete,
+      Collection<ExternalId> toAdd)
+      throws IOException, OrmException {
+    checkSameAccount(toAdd, accountId);
+
+    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(toDelete));
+    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
+    accountCache.evict(accountId);
+  }
+
+  /**
+   * Replaces an external ID.
+   *
+   * <p>If the specified external IDs belongs to different accounts the replacement fails with
+   * {@link IllegalStateException}.
+   */
+  public void replace(ReviewDb db, ExternalId toDelete, ExternalId toAdd)
+      throws IOException, OrmException {
+    replace(db, Collections.singleton(toDelete), Collections.singleton(toAdd));
+  }
+
+  /**
+   * Replaces external IDs.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID is specified for deletion and an external ID with the same key is specified to be
+   * added, the old external ID with that key is deleted first and then the new external ID is added
+   * (so the external ID for that key is replaced).
+   *
+   * <p>If the specified external IDs belong to different accounts the replacement fails with {@link
+   * IllegalStateException}.
+   */
+  public void replace(ReviewDb db, Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, OrmException {
+    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+    if (accountId == null) {
+      // toDelete and toAdd are empty -> nothing to do
+      return;
+    }
+
+    replace(db, accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
+  }
+
+  /**
+   * Checks that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  public static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
+    return checkSameAccount(extIds, null);
+  }
+
+  /**
+   * Checks that all specified external IDs belong to specified account. If no account is specified
+   * it is checked that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  public static Account.Id checkSameAccount(
+      Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
+    for (ExternalId extId : extIds) {
+      if (accountId == null) {
+        accountId = extId.accountId();
+        continue;
+      }
+      checkState(
+          accountId.equals(extId.accountId()),
+          "external id %s belongs to account %s, expected account %s",
+          extId.key().get(),
+          extId.accountId().get(),
+          accountId.get());
+    }
+    return accountId;
+  }
+
+  private void evictAccounts(Collection<ExternalId> extIds) throws IOException {
+    for (Account.Id id : extIds.stream().map(ExternalId::accountId).collect(toSet())) {
+      accountCache.evict(id);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
index d3b938f..a53f64e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/FakeRealm.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
 
 /** Fake implementation of {@link Realm} that does not communicate. */
 public class FakeRealm extends AbstractRealm {
   @Override
-  public boolean allowsEdit(FieldName field) {
+  public boolean allowsEdit(AccountFieldName field) {
     return false;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
index 8339baf..df64c0b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
@@ -24,6 +26,7 @@
 import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.reviewdb.client.Account;
@@ -32,7 +35,12 @@
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -40,24 +48,15 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.lang.reflect.Field;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
 @Singleton
 public class GeneralPreferencesLoader {
-  private static final Logger log =
-      LoggerFactory.getLogger(GeneralPreferencesLoader.class);
+  private static final Logger log = LoggerFactory.getLogger(GeneralPreferencesLoader.class);
 
   private final GitRepositoryManager gitMgr;
   private final AllUsersName allUsersName;
 
   @Inject
-  public GeneralPreferencesLoader(GitRepositoryManager gitMgr,
-      AllUsersName allUsersName) {
+  public GeneralPreferencesLoader(GitRepositoryManager gitMgr, AllUsersName allUsersName) {
     this.gitMgr = gitMgr;
     this.allUsersName = allUsersName;
   }
@@ -67,35 +66,54 @@
     return read(id, null);
   }
 
-  public GeneralPreferencesInfo merge(Account.Id id,
-      GeneralPreferencesInfo in) throws IOException,
-          ConfigInvalidException, RepositoryNotFoundException {
+  public GeneralPreferencesInfo merge(Account.Id id, GeneralPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
     return read(id, in);
   }
 
-  private GeneralPreferencesInfo read(Account.Id id,
-      GeneralPreferencesInfo in) throws IOException,
-          ConfigInvalidException, RepositoryNotFoundException {
+  private GeneralPreferencesInfo read(Account.Id id, GeneralPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
     try (Repository allUsers = gitMgr.openRepository(allUsersName)) {
       // Load all users default prefs
       VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
       dp.load(allUsers);
-      GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
-      loadSection(dp.getConfig(), UserConfigSections.GENERAL, null, allUserPrefs,
-          GeneralPreferencesInfo.defaults(), in);
 
       // Load user prefs
       VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
       p.load(allUsers);
       GeneralPreferencesInfo r =
-          loadSection(p.getConfig(), UserConfigSections.GENERAL, null,
-          new GeneralPreferencesInfo(),
-          updateDefaults(allUserPrefs), in);
-
+          loadSection(
+              p.getConfig(),
+              UserConfigSections.GENERAL,
+              null,
+              new GeneralPreferencesInfo(),
+              readDefaultsFromGit(dp.getConfig(), in),
+              in);
+      loadChangeTableColumns(r, p, dp);
       return loadMyMenusAndUrlAliases(r, p, dp);
     }
   }
 
+  public GeneralPreferencesInfo readDefaultsFromGit(Repository git, GeneralPreferencesInfo in)
+      throws ConfigInvalidException, IOException {
+    VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
+    dp.load(git);
+    return readDefaultsFromGit(dp.getConfig(), in);
+  }
+
+  private GeneralPreferencesInfo readDefaultsFromGit(Config config, GeneralPreferencesInfo in)
+      throws ConfigInvalidException {
+    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
+    loadSection(
+        config,
+        UserConfigSections.GENERAL,
+        null,
+        allUserPrefs,
+        GeneralPreferencesInfo.defaults(),
+        in);
+    return updateDefaults(allUserPrefs);
+  }
+
   private GeneralPreferencesInfo updateDefaults(GeneralPreferencesInfo input) {
     GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
     try {
@@ -109,9 +127,7 @@
         }
       }
     } catch (IllegalAccessException e) {
-      log.error(
-          "Cannot get default general preferences from " + allUsersName.get(),
-          e);
+      log.error("Cannot get default general preferences from " + allUsersName.get(), e);
       return GeneralPreferencesInfo.defaults();
     }
     return result;
@@ -128,8 +144,7 @@
       r.my.add(new MenuItem("Drafts", "#/q/owner:self+is:draft", null));
       r.my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
       r.my.add(new MenuItem("Edits", "#/q/has:edit", null));
-      r.my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open",
-          null));
+      r.my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
       r.my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
       r.my.add(new MenuItem("Groups", "#/groups/self", null));
     }
@@ -146,27 +161,38 @@
     Config cfg = v.getConfig();
     for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
       String url = my(cfg, subsection, KEY_URL, "#/");
-      String target = my(cfg, subsection, KEY_TARGET,
-          url.startsWith("#") ? null : "_blank");
-      my.add(new MenuItem(
-          subsection, url, target,
-          my(cfg, subsection, KEY_ID, null)));
+      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
+      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
     }
     return my;
   }
 
-  private static String my(Config cfg, String subsection, String key,
-      String defaultValue) {
+  private static String my(Config cfg, String subsection, String key, String defaultValue) {
     String val = cfg.getString(UserConfigSections.MY, subsection, key);
     return !Strings.isNullOrEmpty(val) ? val : defaultValue;
   }
 
+  public GeneralPreferencesInfo loadChangeTableColumns(
+      GeneralPreferencesInfo r, VersionedAccountPreferences v, VersionedAccountPreferences d) {
+    r.changeTable = changeTable(v);
+
+    if (r.changeTable.isEmpty() && !v.isDefaults()) {
+      r.changeTable = changeTable(d);
+    }
+    return r;
+  }
+
+  private static List<String> changeTable(VersionedAccountPreferences v) {
+    return Lists.newArrayList(v.getConfig().getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
+  }
+
   private static Map<String, String> urlAliases(VersionedAccountPreferences v) {
     HashMap<String, String> urlAliases = new HashMap<>();
     Config cfg = v.getConfig();
     for (String subsection : cfg.getSubsections(URL_ALIAS)) {
-      urlAliases.put(cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
-         cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
+      urlAliases.put(
+          cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
+          cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
     }
     return !urlAliases.isEmpty() ? urlAliases : null;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java
index 10b6df9..9864b45 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetActive.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.inject.Singleton;
@@ -22,9 +21,9 @@
 @Singleton
 public class GetActive implements RestReadView<AccountResource> {
   @Override
-  public Object apply(AccountResource rsrc) {
+  public Response<String> apply(AccountResource rsrc) {
     if (rsrc.getUser().getAccount().isActive()) {
-      return BinaryResult.create("ok\n");
+      return Response.ok("ok");
     }
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
index 9e1201a..dfbde96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAgreements.java
@@ -25,42 +25,42 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AgreementJson;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
 @Singleton
 public class GetAgreements implements RestReadView<AccountResource> {
-  private static final Logger log =
-      LoggerFactory.getLogger(GetAgreements.class);
+  private static final Logger log = LoggerFactory.getLogger(GetAgreements.class);
 
   private final Provider<CurrentUser> self;
   private final ProjectCache projectCache;
+  private final AgreementJson agreementJson;
   private final boolean agreementsEnabled;
 
   @Inject
-  GetAgreements(Provider<CurrentUser> self,
+  GetAgreements(
+      Provider<CurrentUser> self,
       ProjectCache projectCache,
+      AgreementJson agreementJson,
       @GerritServerConfig Config config) {
     this.self = self;
     this.projectCache = projectCache;
-    this.agreementsEnabled =
-        config.getBoolean("auth", "contributorAgreements", false);
+    this.agreementJson = agreementJson;
+    this.agreementsEnabled = config.getBoolean("auth", "contributorAgreements", false);
   }
 
   @Override
-  public List<AgreementInfo> apply(AccountResource resource)
-      throws RestApiException {
+  public List<AgreementInfo> apply(AccountResource resource) throws RestApiException {
     if (!agreementsEnabled) {
       throw new MethodNotAllowedException("contributor agreements disabled");
     }
@@ -84,18 +84,19 @@
           if (rule.getGroup().getUUID() != null) {
             groupIds.add(rule.getGroup().getUUID());
           } else {
-            log.warn("group \"" + rule.getGroup().getName() + "\" does not " +
-                " exist, referenced in CLA \"" + ca.getName() + "\"");
+            log.warn(
+                "group \""
+                    + rule.getGroup().getName()
+                    + "\" does not "
+                    + "exist, referenced in CLA \""
+                    + ca.getName()
+                    + "\"");
           }
         }
       }
 
       if (user.getEffectiveGroups().containsAnyOf(groupIds)) {
-        AgreementInfo info = new AgreementInfo();
-        info.name = ca.getName();
-        info.description = ca.getDescription();
-        info.url = ca.getAgreementUrl();
-        results.add(info);
+        results.add(agreementJson.format(ca));
       }
     }
     return results;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
index 1953c63..7ee9112 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatar.java
@@ -22,17 +22,17 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.concurrent.TimeUnit;
+import org.kohsuke.args4j.Option;
 
 public class GetAvatar implements RestReadView<AccountResource> {
   private final DynamicItem<AvatarProvider> avatarProvider;
 
   private int size;
 
-  @Option(name = "--size", aliases = {"-s"},
+  @Option(
+      name = "--size",
+      aliases = {"-s"},
       usage = "recommended size in pixels, height and width")
   public void setSize(int s) {
     size = s;
@@ -44,18 +44,15 @@
   }
 
   @Override
-  public Response.Redirect apply(AccountResource rsrc)
-      throws ResourceNotFoundException {
+  public Response.Redirect apply(AccountResource rsrc) throws ResourceNotFoundException {
     AvatarProvider impl = avatarProvider.get();
     if (impl == null) {
-      throw (new ResourceNotFoundException())
-          .caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
+      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
     }
 
     String url = impl.getUrl(rsrc.getUser(), size);
     if (Strings.isNullOrEmpty(url)) {
-      throw (new ResourceNotFoundException())
-          .caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
+      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
     }
     return Response.redirect(url);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java
index ec020fb..d340772 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetAvatarChangeUrl.java
@@ -32,8 +32,7 @@
   }
 
   @Override
-  public String apply(AccountResource rsrc)
-      throws ResourceNotFoundException {
+  public String apply(AccountResource rsrc) throws ResourceNotFoundException {
     AvatarProvider impl = avatarProvider.get();
     if (impl == null) {
       throw new ResourceNotFoundException();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
index cbd0e32..fa36d1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
@@ -49,14 +49,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
+import org.kohsuke.args4j.Option;
 
 class GetCapabilities implements RestReadView<AccountResource> {
   @Option(name = "-q", metaVar = "CAP", usage = "Capability to inspect")
@@ -66,21 +64,21 @@
     }
     Iterables.addAll(query, OptionUtil.splitOptionValue(name));
   }
+
   private Set<String> query;
 
   private final Provider<CurrentUser> self;
   private final DynamicMap<CapabilityDefinition> pluginCapabilities;
 
   @Inject
-  GetCapabilities(Provider<CurrentUser> self,
-      DynamicMap<CapabilityDefinition> pluginCapabilities) {
+  GetCapabilities(Provider<CurrentUser> self, DynamicMap<CapabilityDefinition> pluginCapabilities) {
     this.self = self;
     this.pluginCapabilities = pluginCapabilities;
   }
 
   @Override
   public Object apply(AccountResource resource) throws AuthException {
-    if (self.get() != resource.getUser()
+    if (!self.get().hasSameAccountId(resource.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("restricted to administrator");
     }
@@ -138,9 +136,9 @@
       }
     }
 
-    return OutputFormat.JSON.newGson().toJsonTree(
-      have,
-      new TypeToken<Map<String, Object>>() {}.getType());
+    return OutputFormat.JSON
+        .newGson()
+        .toJsonTree(have, new TypeToken<Map<String, Object>>() {}.getType());
   }
 
   private boolean want(String name) {
@@ -149,8 +147,10 @@
 
   private static class Range {
     private transient PermissionRange range;
+
     @SuppressWarnings("unused")
     private int min;
+
     @SuppressWarnings("unused")
     private int max;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
index 81c860e..9eafec0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
@@ -23,7 +23,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.EnumSet;
@@ -44,10 +43,9 @@
     AccountDetailInfo info = new AccountDetailInfo(a.getId().get());
     info.registeredOn = a.getRegisteredOn();
     try {
-      directory.fillAccountInfo(Collections.singleton(info),
-          EnumSet.allOf(FillOptions.class));
+      directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
     } catch (DirectoryException e) {
-      Throwables.propagateIfPossible(e.getCause(), OrmException.class);
+      Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
       throw new OrmException(e);
     }
     return info;
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 2c4a840..c2f7b8f 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
@@ -28,27 +28,25 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.lang.reflect.Field;
 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 static final Logger log = LoggerFactory.getLogger(GetDiffPreferences.class);
 
   private final Provider<CurrentUser> self;
   private final Provider<AllUsersName> allUsersName;
   private final GitRepositoryManager gitMgr;
 
   @Inject
-  GetDiffPreferences(Provider<CurrentUser> self,
+  GetDiffPreferences(
+      Provider<CurrentUser> self,
       Provider<AllUsersName> allUsersName,
       GitRepositoryManager gitMgr) {
     this.self = self;
@@ -59,7 +57,7 @@
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc)
       throws AuthException, ConfigInvalidException, IOException {
-    if (self.get() != rsrc.getUser()
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("restricted to administrator");
     }
@@ -68,30 +66,34 @@
     return readFromGit(id, gitMgr, allUsersName.get(), null);
   }
 
-  static DiffPreferencesInfo readFromGit(Account.Id id,
-      GitRepositoryManager gitMgr, AllUsersName allUsersName,
-      DiffPreferencesInfo in)
+  static DiffPreferencesInfo readFromGit(
+      Account.Id id, 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);
-
-      // Load user prefs
-      VersionedAccountPreferences p =
-          VersionedAccountPreferences.forUser(id);
+      VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
       p.load(git);
       DiffPreferencesInfo prefs = new DiffPreferencesInfo();
-      loadSection(p.getConfig(), UserConfigSections.DIFF, null, prefs,
-          updateDefaults(allUserPrefs), in);
+      loadSection(
+          p.getConfig(), UserConfigSections.DIFF, null, prefs, readDefaultsFromGit(git, in), in);
       return prefs;
     }
   }
 
+  static DiffPreferencesInfo readDefaultsFromGit(Repository git, DiffPreferencesInfo in)
+      throws ConfigInvalidException, IOException {
+    VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
+    dp.load(git);
+    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+    loadSection(
+        dp.getConfig(),
+        UserConfigSections.DIFF,
+        null,
+        allUserPrefs,
+        DiffPreferencesInfo.defaults(),
+        in);
+    return updateDefaults(allUserPrefs);
+  }
+
   private static DiffPreferencesInfo updateDefaults(DiffPreferencesInfo input) {
     DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
index 02cfaa0..e795f83 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
@@ -27,13 +27,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 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 GetEditPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
@@ -41,37 +39,38 @@
   private final GitRepositoryManager gitMgr;
 
   @Inject
-  GetEditPreferences(Provider<CurrentUser> self,
-      AllUsersName allUsersName,
-      GitRepositoryManager gitMgr) {
+  GetEditPreferences(
+      Provider<CurrentUser> self, AllUsersName allUsersName, GitRepositoryManager gitMgr) {
     this.self = self;
     this.allUsersName = allUsersName;
     this.gitMgr = gitMgr;
   }
 
   @Override
-  public EditPreferencesInfo apply(AccountResource rsrc) throws AuthException,
-      IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+  public EditPreferencesInfo apply(AccountResource rsrc)
+      throws AuthException, IOException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("requires Modify Account capability");
     }
 
-    return readFromGit(
-        rsrc.getUser().getAccountId(), gitMgr, allUsersName, null);
+    return readFromGit(rsrc.getUser().getAccountId(), gitMgr, allUsersName, null);
   }
 
-  static EditPreferencesInfo readFromGit(Account.Id id,
-      GitRepositoryManager gitMgr, AllUsersName allUsersName,
-      EditPreferencesInfo in) throws IOException, ConfigInvalidException,
-          RepositoryNotFoundException {
+  static EditPreferencesInfo readFromGit(
+      Account.Id id, GitRepositoryManager gitMgr, AllUsersName allUsersName, EditPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
-      VersionedAccountPreferences p =
-          VersionedAccountPreferences.forUser(id);
+      VersionedAccountPreferences p = VersionedAccountPreferences.forUser(id);
       p.load(git);
 
-      return loadSection(p.getConfig(), UserConfigSections.EDIT, null,
-          new EditPreferencesInfo(), EditPreferencesInfo.defaults(), in);
+      return loadSection(
+          p.getConfig(),
+          UserConfigSections.EDIT,
+          null,
+          new EditPreferencesInfo(),
+          EditPreferencesInfo.defaults(),
+          in);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java
index 6763578b..82e0944 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmail.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.GetEmails.EmailInfo;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetEmail implements RestReadView<AccountResource.Email> {
   @Inject
-  public GetEmail() {
-  }
+  public GetEmail() {}
 
   @Override
   public EmailInfo apply(AccountResource.Email rsrc) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java
index 14cc74e..184780f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEmails.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -36,22 +36,14 @@
         emails.add(e);
       }
     }
-    Collections.sort(emails, new Comparator<EmailInfo>() {
-      @Override
-      public int compare(EmailInfo a, EmailInfo b) {
-        return a.email.compareTo(b.email);
-      }
-    });
+    Collections.sort(
+        emails,
+        new Comparator<EmailInfo>() {
+          @Override
+          public int compare(EmailInfo a, EmailInfo b) {
+            return a.email.compareTo(b.email);
+          }
+        });
     return emails;
   }
-
-  public static class EmailInfo {
-    public String email;
-    public Boolean preferred;
-    public Boolean pendingConfirmation;
-
-    void preferred(String e) {
-      this.preferred = e != null && e.equals(email) ? true : null;
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
new file mode 100644
index 0000000..c926cff
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+@Singleton
+public class GetExternalIds implements RestReadView<AccountResource> {
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> self;
+  private final AuthConfig authConfig;
+
+  @Inject
+  GetExternalIds(Provider<ReviewDb> db, Provider<CurrentUser> self, AuthConfig authConfig) {
+    this.db = db;
+    this.self = self;
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  public List<AccountExternalIdInfo> apply(AccountResource resource)
+      throws RestApiException, OrmException {
+    if (!self.get().hasSameAccountId(resource.getUser())) {
+      throw new AuthException("not allowed to get external IDs");
+    }
+
+    Collection<ExternalId> ids =
+        ExternalId.from(
+            db.get().accountExternalIds().byAccount(resource.getUser().getAccountId()).toList());
+    if (ids.isEmpty()) {
+      return ImmutableList.of();
+    }
+    List<AccountExternalIdInfo> result = Lists.newArrayListWithCapacity(ids.size());
+    for (ExternalId id : ids) {
+      AccountExternalIdInfo info = new AccountExternalIdInfo();
+      info.identity = id.key().get();
+      info.emailAddress = id.email();
+      info.trusted = toBoolean(authConfig.isIdentityTrustable(Collections.singleton(id)));
+      // The identity can be deleted only if its not the one used to
+      // establish this web session, and if only if an identity was
+      // actually used to establish this web session.
+      if (!id.isScheme(SCHEME_USERNAME)) {
+        ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
+        info.canDelete = toBoolean(last == null || !last.get().equals(info.identity));
+      }
+      result.add(info);
+    }
+    return result;
+  }
+
+  private static Boolean toBoolean(boolean v) {
+    return v ? v : null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java
index 5b71e0b..757cb44d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetGroups.java
@@ -24,7 +24,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.List;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetHttpPassword.java
deleted file mode 100644
index c49ab98..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetHttpPassword.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetHttpPassword implements RestReadView<AccountResource> {
-
-  private final Provider<CurrentUser> self;
-
-  @Inject
-  GetHttpPassword(Provider<CurrentUser> self) {
-    this.self = self;
-  }
-
-  @Override
-  public String apply(AccountResource rsrc) throws AuthException,
-      ResourceNotFoundException {
-    if (self.get() != rsrc.getUser()
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to get http password");
-    }
-    AccountState s = rsrc.getUser().state();
-    if (s.getUserName() == null) {
-      throw new ResourceNotFoundException();
-    }
-    String p = s.getPassword(s.getUserName());
-    if (p == null) {
-      throw new ResourceNotFoundException();
-    }
-    return p;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
index 5d343c4..61f5b84 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
@@ -25,32 +25,32 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.net.URI;
 import java.net.URISyntaxException;
 
 @Singleton
-class GetOAuthToken implements RestReadView<AccountResource>{
+class GetOAuthToken implements RestReadView<AccountResource> {
 
   private static final String BEARER_TYPE = "bearer";
 
   private final Provider<CurrentUser> self;
   private final OAuthTokenCache tokenCache;
-  private final String hostName;
+  private final Provider<String> canonicalWebUrlProvider;
 
   @Inject
-  GetOAuthToken(Provider<CurrentUser> self,
+  GetOAuthToken(
+      Provider<CurrentUser> self,
       OAuthTokenCache tokenCache,
       @CanonicalWebUrl Provider<String> urlProvider) {
     this.self = self;
     this.tokenCache = tokenCache;
-    this.hostName = getHostName(urlProvider.get());
+    this.canonicalWebUrlProvider = urlProvider;
   }
 
   @Override
-  public OAuthTokenInfo apply(AccountResource rsrc) throws AuthException,
-      ResourceNotFoundException {
-    if (self.get() != rsrc.getUser()) {
+  public OAuthTokenInfo apply(AccountResource rsrc)
+      throws AuthException, ResourceNotFoundException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
       throw new AuthException("not allowed to get access token");
     }
     Account a = rsrc.getUser().getAccount();
@@ -60,7 +60,7 @@
     }
     OAuthTokenInfo accessTokenInfo = new OAuthTokenInfo();
     accessTokenInfo.username = a.getUserName();
-    accessTokenInfo.resourceHost = hostName;
+    accessTokenInfo.resourceHost = getHostName(canonicalWebUrlProvider.get());
     accessTokenInfo.accessToken = accessToken.getToken();
     accessTokenInfo.providerId = accessToken.getProviderId();
     accessTokenInfo.expiresAt = Long.toString(accessToken.getExpiresAt());
@@ -84,5 +84,4 @@
     public String expiresAt;
     public String type;
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
index 3e83f4c..95b115f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
@@ -29,16 +29,14 @@
   private final AccountCache accountCache;
 
   @Inject
-  GetPreferences(Provider<CurrentUser> self,
-      AccountCache accountCache) {
+  GetPreferences(Provider<CurrentUser> self, AccountCache accountCache) {
     this.self = self;
     this.accountCache = accountCache;
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException {
-    if (self.get() != rsrc.getUser()
+  public GeneralPreferencesInfo apply(AccountResource rsrc) throws AuthException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("requires Modify Account capability");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
index bf1a3af..a169f6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.SshKeyInfo;
@@ -27,12 +26,10 @@
 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 java.io.IOException;
 import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
 @Singleton
 public class GetSshKeys implements RestReadView<AccountResource> {
@@ -41,17 +38,16 @@
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
-  GetSshKeys(Provider<CurrentUser> self,
-      VersionedAuthorizedKeys.Accessor authorizedKeys) {
+  GetSshKeys(Provider<CurrentUser> self, VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.self = self;
     this.authorizedKeys = authorizedKeys;
   }
 
   @Override
   public List<SshKeyInfo> apply(AccountResource rsrc)
-      throws AuthException, OrmException, RepositoryNotFoundException,
-      IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to get SSH keys");
     }
@@ -60,13 +56,7 @@
 
   public List<SshKeyInfo> apply(IdentifiedUser user)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    return Lists.transform(authorizedKeys.getKeys(user.getAccountId()),
-        new Function<AccountSshKey, SshKeyInfo>() {
-          @Override
-          public SshKeyInfo apply(AccountSshKey key) {
-            return newSshKeyInfo(key);
-          }
-        });
+    return Lists.transform(authorizedKeys.getKeys(user.getAccountId()), GetSshKeys::newSshKeyInfo);
   }
 
   public static SshKeyInfo newSshKeyInfo(AccountSshKey sshKey) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetStatus.java
new file mode 100644
index 0000000..5d57c4c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetStatus.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetStatus implements RestReadView<AccountResource> {
+  @Override
+  public String apply(AccountResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getUser().getAccount().getStatus());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java
index a5f271d..6541f55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetUsername.java
@@ -23,12 +23,10 @@
 @Singleton
 public class GetUsername implements RestReadView<AccountResource> {
   @Inject
-  public GetUsername() {
-  }
+  public GetUsername() {}
 
   @Override
-  public String apply(AccountResource rsrc) throws AuthException,
-      ResourceNotFoundException {
+  public String apply(AccountResource rsrc) throws AuthException, ResourceNotFoundException {
     String username = rsrc.getUser().getAccount().getUserName();
     if (username == null) {
       throw new ResourceNotFoundException();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
index 3748e17..d8580eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
@@ -20,114 +20,70 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class GetWatchedProjects implements RestReadView<AccountResource> {
 
-  private final Provider<ReviewDb> dbProvider;
   private final Provider<IdentifiedUser> self;
-  private final boolean readFromGit;
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
-  public GetWatchedProjects(Provider<ReviewDb> dbProvider,
-      Provider<IdentifiedUser> self,
-      @GerritServerConfig Config cfg,
-      WatchConfig.Accessor watchConfig) {
-    this.dbProvider = dbProvider;
+  public GetWatchedProjects(Provider<IdentifiedUser> self, WatchConfig.Accessor watchConfig) {
     this.self = self;
-    this.readFromGit =
-        cfg.getBoolean("user", null, "readProjectWatchesFromGit", false);
     this.watchConfig = watchConfig;
   }
 
   @Override
   public List<ProjectWatchInfo> apply(AccountResource rsrc)
       throws OrmException, AuthException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("It is not allowed to list project watches "
-          + "of other users");
+      throw new AuthException("It is not allowed to list project watches of other users");
     }
     Account.Id accountId = rsrc.getUser().getAccountId();
-    Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
-        readFromGit
-            ? watchConfig.getProjectWatches(accountId)
-            : readProjectWatchesFromDb(dbProvider.get(), accountId);
-
-    List<ProjectWatchInfo> projectWatchInfos = new LinkedList<>();
-    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches
-        .entrySet()) {
+    List<ProjectWatchInfo> projectWatchInfos = new ArrayList<>();
+    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
+        watchConfig.getProjectWatches(accountId).entrySet()) {
       ProjectWatchInfo pwi = new ProjectWatchInfo();
       pwi.filter = e.getKey().filter();
       pwi.project = e.getKey().project().get();
-      pwi.notifyAbandonedChanges =
-          toBoolean(e.getValue().contains(NotifyType.ABANDONED_CHANGES));
-      pwi.notifyNewChanges =
-          toBoolean(e.getValue().contains(NotifyType.NEW_CHANGES));
-      pwi.notifyNewPatchSets =
-          toBoolean(e.getValue().contains(NotifyType.NEW_PATCHSETS));
-      pwi.notifySubmittedChanges =
-          toBoolean(e.getValue().contains(NotifyType.SUBMITTED_CHANGES));
-      pwi.notifyAllComments =
-          toBoolean(e.getValue().contains(NotifyType.ALL_COMMENTS));
+      pwi.notifyAbandonedChanges = toBoolean(e.getValue().contains(NotifyType.ABANDONED_CHANGES));
+      pwi.notifyNewChanges = toBoolean(e.getValue().contains(NotifyType.NEW_CHANGES));
+      pwi.notifyNewPatchSets = toBoolean(e.getValue().contains(NotifyType.NEW_PATCHSETS));
+      pwi.notifySubmittedChanges = toBoolean(e.getValue().contains(NotifyType.SUBMITTED_CHANGES));
+      pwi.notifyAllComments = toBoolean(e.getValue().contains(NotifyType.ALL_COMMENTS));
       projectWatchInfos.add(pwi);
     }
-    Collections.sort(projectWatchInfos, new Comparator<ProjectWatchInfo>() {
-      @Override
-      public int compare(ProjectWatchInfo pwi1, ProjectWatchInfo pwi2) {
-        return ComparisonChain.start()
-            .compare(pwi1.project, pwi2.project)
-            .compare(Strings.nullToEmpty(pwi1.filter),
-                Strings.nullToEmpty(pwi2.filter))
-            .result();
-      }
-    });
+    Collections.sort(
+        projectWatchInfos,
+        new Comparator<ProjectWatchInfo>() {
+          @Override
+          public int compare(ProjectWatchInfo pwi1, ProjectWatchInfo pwi2) {
+            return ComparisonChain.start()
+                .compare(pwi1.project, pwi2.project)
+                .compare(Strings.nullToEmpty(pwi1.filter), Strings.nullToEmpty(pwi2.filter))
+                .result();
+          }
+        });
     return projectWatchInfos;
   }
 
   private static Boolean toBoolean(boolean value) {
     return value ? true : null;
   }
-
-  public static Map<ProjectWatchKey, Set<NotifyType>> readProjectWatchesFromDb(
-      ReviewDb db, Account.Id who) throws OrmException {
-    Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
-        new HashMap<>();
-    for (AccountProjectWatch apw : db.accountProjectWatches().byAccount(who)) {
-      ProjectWatchKey key =
-          ProjectWatchKey.create(apw.getProjectNameKey(), apw.getFilter());
-      Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
-      for (NotifyType notifyType : NotifyType.values()) {
-        if (apw.isNotify(notifyType)) {
-          notifyValues.add(notifyType);
-        }
-      }
-      projectWatches.put(key, notifyValues);
-    }
-    return projectWatches;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java
index c65f6d6..bf71732 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java
@@ -21,21 +21,16 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ProjectControl;
-
 import java.util.Collection;
 
-/**
- * Implementations of GroupBackend provide lookup and membership accessors
- * to a group system.
- */
+/** Implementations of GroupBackend provide lookup and membership accessors to a group system. */
 @ExtensionPoint
 public interface GroupBackend {
   /** @return {@code true} if the backend can operate on the UUID. */
   boolean handles(AccountGroup.UUID uuid);
 
   /**
-   * Looks up a group in the backend. If the group does not exist, null is
-   * returned.
+   * Looks up a group in the backend. If the group does not exist, null is returned.
    *
    * @param uuid the group identifier
    * @return the group
@@ -44,16 +39,11 @@
   GroupDescription.Basic get(AccountGroup.UUID uuid);
 
   /** @return suggestions for the group name sorted by name. */
-  Collection<GroupReference> suggest(
-      String name,
-      @Nullable ProjectControl project);
+  Collection<GroupReference> suggest(String name, @Nullable ProjectControl project);
 
   /** @return the group membership checker for the backend. */
   GroupMembership membershipsOf(IdentifiedUser user);
 
-  /**
-   * @return {@code true} if the group with the given UUID is visible to all
-   *         registered users.
-   */
+  /** @return {@code true} if the group with the given UUID is visible to all registered users. */
   boolean isVisibleToAll(AccountGroup.UUID uuid);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java
index 69ca1e9..e029954 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java
@@ -18,40 +18,36 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.server.project.ProjectControl;
-
 import java.util.Collection;
 import java.util.Comparator;
 
-/**
- * Utility class for dealing with a GroupBackend.
- */
+/** Utility class for dealing with a GroupBackend. */
 public class GroupBackends {
 
   public static final Comparator<GroupReference> GROUP_REF_NAME_COMPARATOR =
       new Comparator<GroupReference>() {
-    @Override
-    public int compare(GroupReference a, GroupReference b) {
-      return a.getName().compareTo(b.getName());
-    }
-  };
+        @Override
+        public int compare(GroupReference a, GroupReference b) {
+          return a.getName().compareTo(b.getName());
+        }
+      };
 
   /**
-   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the
-   * result to return the best suggestion, or null if one does not exist.
+   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the result to return the
+   * best suggestion, or null if one does not exist.
    *
    * @param groupBackend the group backend
    * @param name the name for which to suggest groups
    * @return the best single GroupReference suggestion
    */
   @Nullable
-  public static GroupReference findBestSuggestion(GroupBackend groupBackend,
-      String name) {
+  public static GroupReference findBestSuggestion(GroupBackend groupBackend, String name) {
     return findBestSuggestion(groupBackend, name, null);
   }
 
   /**
-   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the
-   * result to return the best suggestion, or null if one does not exist.
+   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the result to return the
+   * best suggestion, or null if one does not exist.
    *
    * @param groupBackend the group backend
    * @param name the name for which to suggest groups
@@ -59,8 +55,8 @@
    * @return the best single GroupReference suggestion
    */
   @Nullable
-  public static GroupReference findBestSuggestion(GroupBackend groupBackend,
-      String name, @Nullable ProjectControl project) {
+  public static GroupReference findBestSuggestion(
+      GroupBackend groupBackend, String name, @Nullable ProjectControl project) {
     Collection<GroupReference> refs = groupBackend.suggest(name, project);
     if (refs.size() == 1) {
       return Iterables.getOnlyElement(refs);
@@ -75,22 +71,21 @@
   }
 
   /**
-   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the
-   * result to return the exact suggestion, or null if one does not exist.
+   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the result to return the
+   * exact suggestion, or null if one does not exist.
    *
    * @param groupBackend the group backend
    * @param name the name for which to suggest groups
    * @return the exact single GroupReference suggestion
    */
   @Nullable
-  public static GroupReference findExactSuggestion(
-      GroupBackend groupBackend, String name) {
+  public static GroupReference findExactSuggestion(GroupBackend groupBackend, String name) {
     return findExactSuggestion(groupBackend, name, null);
   }
 
   /**
-   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the
-   * result to return the exact suggestion, or null if one does not exist.
+   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the result to return the
+   * exact suggestion, or null if one does not exist.
    *
    * @param groupBackend the group backend
    * @param name the name for which to suggest groups
@@ -114,6 +109,5 @@
     return ref.getName().equalsIgnoreCase(name) || ref.getUUID().get().equals(name);
   }
 
-  private GroupBackends() {
-  }
+  private GroupBackends() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
index c7a2241..8e30a24 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.io.IOException;
 
 /** Tracks group objects in memory for efficient access. */
 public interface GroupCache {
@@ -24,21 +26,20 @@
   AccountGroup get(AccountGroup.NameKey name);
 
   /**
-   * Lookup a group definition by its UUID. The returned definition may be null
-   * if the group has been deleted and the UUID reference is stale, or was
-   * copied from another server.
+   * Lookup a group definition by its UUID. The returned definition may be null if the group has
+   * been deleted and the UUID reference is stale, or was copied from another server.
    */
   @Nullable
   AccountGroup get(AccountGroup.UUID uuid);
 
-  /** @return sorted iteration of groups. */
-  Iterable<AccountGroup> all();
+  /** @return sorted list of groups. */
+  ImmutableList<AccountGroup> all();
 
   /** Notify the cache that a new group was constructed. */
-  void onCreateGroup(AccountGroup.NameKey newGroupName);
+  void onCreateGroup(AccountGroup.NameKey newGroupName) throws IOException;
 
-  void evict(AccountGroup group);
+  void evict(AccountGroup group) throws IOException;
 
-  void evictAfterRename(final AccountGroup.NameKey oldName,
-      final AccountGroup.NameKey newName);
+  void evictAfterRename(AccountGroup.NameKey oldName, AccountGroup.NameKey newName)
+      throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
index e5e2f99..06a4680 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -14,34 +14,34 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Optional;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
-
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.ExecutionException;
-
 /** Tracks group objects in memory for efficient access. */
 @Singleton
 public class GroupCacheImpl implements GroupCache {
-  private static final Logger log = LoggerFactory
-      .getLogger(GroupCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(GroupCacheImpl.class);
 
   private static final String BYID_NAME = "groups";
   private static final String BYNAME_NAME = "groups_byname";
@@ -51,20 +51,14 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(BYID_NAME,
-            AccountGroup.Id.class,
-            new TypeLiteral<Optional<AccountGroup>>() {})
-          .loader(ByIdLoader.class);
+        cache(BYID_NAME, AccountGroup.Id.class, new TypeLiteral<Optional<AccountGroup>>() {})
+            .loader(ByIdLoader.class);
 
-        cache(BYNAME_NAME,
-            String.class,
-            new TypeLiteral<Optional<AccountGroup>>() {})
-          .loader(ByNameLoader.class);
+        cache(BYNAME_NAME, String.class, new TypeLiteral<Optional<AccountGroup>>() {})
+            .loader(ByNameLoader.class);
 
-        cache(BYUUID_NAME,
-            String.class,
-            new TypeLiteral<Optional<AccountGroup>>() {})
-          .loader(ByUUIDLoader.class);
+        cache(BYUUID_NAME, String.class, new TypeLiteral<Optional<AccountGroup>>() {})
+            .loader(ByUUIDLoader.class);
 
         bind(GroupCacheImpl.class);
         bind(GroupCache.class).to(GroupCacheImpl.class);
@@ -76,17 +70,20 @@
   private final LoadingCache<String, Optional<AccountGroup>> byName;
   private final LoadingCache<String, Optional<AccountGroup>> byUUID;
   private final SchemaFactory<ReviewDb> schema;
+  private final Provider<GroupIndexer> indexer;
 
   @Inject
   GroupCacheImpl(
       @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<AccountGroup>> byId,
       @Named(BYNAME_NAME) LoadingCache<String, Optional<AccountGroup>> byName,
       @Named(BYUUID_NAME) LoadingCache<String, Optional<AccountGroup>> byUUID,
-      SchemaFactory<ReviewDb> schema) {
+      SchemaFactory<ReviewDb> schema,
+      Provider<GroupIndexer> indexer) {
     this.byId = byId;
     this.byName = byName;
     this.byUUID = byUUID;
     this.schema = schema;
+    this.indexer = indexer;
   }
 
   @Override
@@ -95,13 +92,13 @@
       Optional<AccountGroup> g = byId.get(groupId);
       return g.isPresent() ? g.get() : missing(groupId);
     } catch (ExecutionException e) {
-      log.warn("Cannot load group " + groupId, e);
+      log.warn("Cannot load group {}", groupId, e);
       return missing(groupId);
     }
   }
 
   @Override
-  public void evict(final AccountGroup group) {
+  public void evict(final AccountGroup group) throws IOException {
     if (group.getId() != null) {
       byId.invalidate(group.getId());
     }
@@ -111,17 +108,19 @@
     if (group.getGroupUUID() != null) {
       byUUID.invalidate(group.getGroupUUID().get());
     }
+    indexer.get().index(group.getGroupUUID());
   }
 
   @Override
-  public void evictAfterRename(final AccountGroup.NameKey oldName,
-      final AccountGroup.NameKey newName) {
+  public void evictAfterRename(
+      final AccountGroup.NameKey oldName, final AccountGroup.NameKey newName) throws IOException {
     if (oldName != null) {
       byName.invalidate(oldName.get());
     }
     if (newName != null) {
       byName.invalidate(newName.get());
     }
+    indexer.get().index(get(newName).getGroupUUID());
   }
 
   @Override
@@ -130,9 +129,9 @@
       return null;
     }
     try {
-      return byName.get(name.get()).orNull();
+      return byName.get(name.get()).orElse(null);
     } catch (ExecutionException e) {
-      log.warn(String.format("Cannot lookup group %s by name", name.get()), e);
+      log.warn("Cannot lookup group {} by name", name.get(), e);
       return null;
     }
   }
@@ -143,26 +142,27 @@
       return null;
     }
     try {
-      return byUUID.get(uuid.get()).orNull();
+      return byUUID.get(uuid.get()).orElse(null);
     } catch (ExecutionException e) {
-      log.warn(String.format("Cannot lookup group %s by name", uuid.get()), e);
+      log.warn("Cannot lookup group {} by uuid", uuid.get(), e);
       return null;
     }
   }
 
   @Override
-  public Iterable<AccountGroup> all() {
+  public ImmutableList<AccountGroup> all() {
     try (ReviewDb db = schema.open()) {
-      return Collections.unmodifiableList(db.accountGroups().all().toList());
+      return ImmutableList.copyOf(db.accountGroups().all());
     } catch (OrmException e) {
       log.warn("Cannot list internal groups", e);
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
   }
 
   @Override
-  public void onCreateGroup(AccountGroup.NameKey newGroupName) {
+  public void onCreateGroup(AccountGroup.NameKey newGroupName) throws IOException {
     byName.invalidate(newGroupName.get());
+    indexer.get().index(get(newGroupName).getGroupUUID());
   }
 
   private static AccountGroup missing(AccountGroup.Id key) {
@@ -170,8 +170,7 @@
     return new AccountGroup(name, key, null);
   }
 
-  static class ByIdLoader extends
-      CacheLoader<AccountGroup.Id, Optional<AccountGroup>> {
+  static class ByIdLoader extends CacheLoader<AccountGroup.Id, Optional<AccountGroup>> {
     private final SchemaFactory<ReviewDb> schema;
 
     @Inject
@@ -180,10 +179,9 @@
     }
 
     @Override
-    public Optional<AccountGroup> load(final AccountGroup.Id key)
-        throws Exception {
+    public Optional<AccountGroup> load(final AccountGroup.Id key) throws Exception {
       try (ReviewDb db = schema.open()) {
-        return Optional.fromNullable(db.accountGroups().get(key));
+        return Optional.ofNullable(db.accountGroups().get(key));
       }
     }
   }
@@ -197,15 +195,14 @@
     }
 
     @Override
-    public Optional<AccountGroup> load(String name)
-        throws Exception {
+    public Optional<AccountGroup> load(String name) throws Exception {
       try (ReviewDb db = schema.open()) {
         AccountGroup.NameKey key = new AccountGroup.NameKey(name);
         AccountGroupName r = db.accountGroupNames().get(key);
         if (r != null) {
-          return Optional.fromNullable(db.accountGroups().get(r.getId()));
+          return Optional.ofNullable(db.accountGroups().get(r.getId()));
         }
-        return Optional.absent();
+        return Optional.empty();
       }
     }
   }
@@ -219,8 +216,7 @@
     }
 
     @Override
-    public Optional<AccountGroup> load(String uuid)
-        throws Exception {
+    public Optional<AccountGroup> load(String uuid) throws Exception {
       try (ReviewDb db = schema.open()) {
         List<AccountGroup> r;
 
@@ -228,7 +224,7 @@
         if (r.size() == 1) {
           return Optional.of(r.get(0));
         } else if (r.size() == 0) {
-          return Optional.absent();
+          return Optional.empty();
         } else {
           throw new OrmDuplicateKeyException("Duplicate group UUID " + uuid);
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupComparator.java
index 13800b5..4bab3a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupComparator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupComparator.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
 import java.util.Comparator;
 
 public class GroupComparator implements Comparator<AccountGroup> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
index 2e03913..ee788ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
@@ -36,8 +36,7 @@
       groupBackend = gb;
     }
 
-    public GroupControl controlFor(final CurrentUser who,
-        final AccountGroup.UUID groupId)
+    public GroupControl controlFor(final CurrentUser who, final AccountGroup.UUID groupId)
         throws NoSuchGroupException {
       final GroupDescription.Basic group = groupBackend.get(groupId);
       if (group == null) {
@@ -53,15 +52,13 @@
     private final GroupBackend groupBackend;
 
     @Inject
-    Factory(final GroupCache gc, final Provider<CurrentUser> cu,
-        final GroupBackend gb) {
+    Factory(final GroupCache gc, final Provider<CurrentUser> cu, final GroupBackend gb) {
       groupCache = gc;
       user = cu;
       groupBackend = gb;
     }
 
-    public GroupControl controlFor(final AccountGroup.Id groupId)
-        throws NoSuchGroupException {
+    public GroupControl controlFor(final AccountGroup.Id groupId) throws NoSuchGroupException {
       final AccountGroup group = groupCache.get(groupId);
       if (group == null) {
         throw new NoSuchGroupException(groupId);
@@ -69,8 +66,7 @@
       return controlFor(GroupDescriptions.forAccountGroup(group));
     }
 
-    public GroupControl controlFor(final AccountGroup.UUID groupId)
-        throws NoSuchGroupException {
+    public GroupControl controlFor(final AccountGroup.UUID groupId) throws NoSuchGroupException {
       final GroupDescription.Basic group = groupBackend.get(groupId);
       if (group == null) {
         throw new NoSuchGroupException(groupId);
@@ -86,8 +82,7 @@
       return new GroupControl(user.get(), group, groupBackend);
     }
 
-    public GroupControl validateFor(final AccountGroup.Id groupId)
-        throws NoSuchGroupException {
+    public GroupControl validateFor(final AccountGroup.Id groupId) throws NoSuchGroupException {
       final GroupControl c = controlFor(groupId);
       if (!c.isVisible()) {
         throw new NoSuchGroupException(groupId);
@@ -95,8 +90,7 @@
       return c;
     }
 
-    public GroupControl validateFor(final AccountGroup.UUID groupUUID)
-        throws NoSuchGroupException {
+    public GroupControl validateFor(final AccountGroup.UUID groupUUID) throws NoSuchGroupException {
       final GroupControl c = controlFor(groupUUID);
       if (!c.isVisible()) {
         throw new NoSuchGroupException(groupUUID);
@@ -112,7 +106,7 @@
 
   GroupControl(CurrentUser who, GroupDescription.Basic gd, GroupBackend gb) {
     user = who;
-    group =  gd;
+    group = gd;
     groupBackend = gb;
   }
 
@@ -131,10 +125,10 @@
      * server administrators.
      */
     return user.isInternalUser()
-      || groupBackend.isVisibleToAll(group.getGroupUUID())
-      || user.getEffectiveGroups().contains(group.getGroupUUID())
-      || user.getCapabilities().canAdministrateServer()
-      || isOwner();
+        || groupBackend.isVisibleToAll(group.getGroupUUID())
+        || user.getEffectiveGroups().contains(group.getGroupUUID())
+        || user.getCapabilities().canAdministrateServer()
+        || isOwner();
   }
 
   public boolean isOwner() {
@@ -143,8 +137,9 @@
       isOwner = false;
     } else if (isOwner == null) {
       AccountGroup.UUID ownerUUID = accountGroup.getOwnerGroupUUID();
-      isOwner = getUser().getEffectiveGroups().contains(ownerUUID)
-             || getUser().getCapabilities().canAdministrateServer();
+      isOwner =
+          getUser().getEffectiveGroups().contains(ownerUUID)
+              || getUser().getCapabilities().canAdministrateServer();
     }
     return isOwner;
   }
@@ -178,7 +173,6 @@
 
   private boolean canSeeMembers() {
     AccountGroup accountGroup = GroupDescriptions.toAccountGroup(group);
-    return (accountGroup != null && accountGroup.isVisibleToAll())
-        || isOwner();
+    return (accountGroup != null && accountGroup.isVisibleToAll()) || isOwner();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java
index 94feb7d..fb7d7e7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java
@@ -14,23 +14,16 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDetail;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupById;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.GroupInfoCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.List;
 import java.util.concurrent.Callable;
 
@@ -42,27 +35,19 @@
   private final ReviewDb db;
   private final GroupControl.Factory groupControl;
   private final GroupCache groupCache;
-  private final GroupBackend groupBackend;
-  private final AccountInfoCacheFactory aic;
-  private final GroupInfoCache gic;
 
   private final AccountGroup.Id groupId;
   private GroupControl control;
 
   @Inject
-  GroupDetailFactory(ReviewDb db,
+  GroupDetailFactory(
+      ReviewDb db,
       GroupControl.Factory groupControl,
       GroupCache groupCache,
-      GroupBackend groupBackend,
-      AccountInfoCacheFactory.Factory accountInfoCacheFactory,
-      GroupInfoCache.Factory groupInfoCacheFactory,
       @Assisted AccountGroup.Id groupId) {
     this.db = db;
     this.groupControl = groupControl;
     this.groupCache = groupCache;
-    this.groupBackend = groupBackend;
-    this.aic = accountInfoCacheFactory.create();
-    this.gic = groupInfoCacheFactory.create();
 
     this.groupId = groupId;
   }
@@ -73,14 +58,8 @@
     AccountGroup group = groupCache.get(groupId);
     GroupDetail detail = new GroupDetail();
     detail.setGroup(group);
-    GroupDescription.Basic ownerGroup = groupBackend.get(group.getOwnerGroupUUID());
-    if (ownerGroup != null) {
-      detail.setOwnerGroup(GroupReference.forGroup(ownerGroup));
-    }
     detail.setMembers(loadMembers());
     detail.setIncludes(loadIncludes());
-    detail.setAccounts(aic.create());
-    detail.setCanModify(control.isOwner());
     return detail;
   }
 
@@ -88,33 +67,9 @@
     List<AccountGroupMember> members = new ArrayList<>();
     for (AccountGroupMember m : db.accountGroupMembers().byGroup(groupId)) {
       if (control.canSeeMember(m.getAccountId())) {
-        aic.want(m.getAccountId());
         members.add(m);
       }
     }
-
-    Collections.sort(members, new Comparator<AccountGroupMember>() {
-      @Override
-      public int compare(AccountGroupMember o1, AccountGroupMember o2) {
-        Account a = aic.get(o1.getAccountId());
-        Account b = aic.get(o2.getAccountId());
-        return n(a).compareTo(n(b));
-      }
-
-      private String n(final Account a) {
-        String n = a.getFullName();
-        if (n != null && n.length() > 0) {
-          return n;
-        }
-
-        n = a.getPreferredEmail();
-        if (n != null && n.length() > 0) {
-          return n;
-        }
-
-        return a.getId().toString();
-      }
-    });
     return members;
   }
 
@@ -123,32 +78,10 @@
 
     for (AccountGroupById m : db.accountGroupById().byGroup(groupId)) {
       if (control.canSeeGroup()) {
-        gic.want(m.getIncludeUUID());
         groups.add(m);
       }
     }
 
-    Collections.sort(groups, new Comparator<AccountGroupById>() {
-      @Override
-      public int compare(AccountGroupById o1, AccountGroupById o2) {
-        GroupDescription.Basic a = gic.get(o1.getIncludeUUID());
-        GroupDescription.Basic b = gic.get(o2.getIncludeUUID());
-        return n(a).compareTo(n(b));
-      }
-
-      private String n (GroupDescription.Basic a) {
-        if (a == null) {
-          return "";
-        }
-
-        String n = a.getName();
-        if (n != null && n.length() > 0) {
-          return n;
-        }
-        return a.getGroupUUID().get();
-      }
-    });
-
     return groups;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
index 9971301..c702aef 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -15,20 +15,20 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
-import java.util.Set;
+import java.util.Collection;
 
 /** Tracks group inclusions in memory for efficient access. */
 public interface GroupIncludeCache {
   /** @return groups directly a member of the passed group. */
-  Set<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
+  Collection<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
 
   /** @return any groups the passed group belongs to. */
-  Set<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
+  Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
 
   /** @return set of any UUIDs that are not internal groups. */
-  Set<AccountGroup.UUID> allExternalMembers();
+  Collection<AccountGroup.UUID> allExternalMembers();
 
   void evictSubgroupsOf(AccountGroup.UUID groupId);
+
   void evictParentGroupsOf(AccountGroup.UUID groupId);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 9bd6b30..1c9baf8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -16,32 +16,31 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupById;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Tracks group inclusions in memory for efficient access. */
 @Singleton
 public class GroupIncludeCacheImpl implements GroupIncludeCache {
-  private static final Logger log = LoggerFactory
-      .getLogger(GroupIncludeCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(GroupIncludeCacheImpl.class);
   private static final String PARENT_GROUPS_NAME = "groups_byinclude";
   private static final String SUBGROUPS_NAME = "groups_members";
   private static final String EXTERNAL_NAME = "groups_external";
@@ -50,20 +49,20 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(PARENT_GROUPS_NAME,
-            AccountGroup.UUID.class,
-            new TypeLiteral<Set<AccountGroup.UUID>>() {})
-          .loader(ParentGroupsLoader.class);
+        cache(
+                PARENT_GROUPS_NAME,
+                AccountGroup.UUID.class,
+                new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
+            .loader(ParentGroupsLoader.class);
 
-        cache(SUBGROUPS_NAME,
-            AccountGroup.UUID.class,
-            new TypeLiteral<Set<AccountGroup.UUID>>() {})
-          .loader(SubgroupsLoader.class);
+        cache(
+                SUBGROUPS_NAME,
+                AccountGroup.UUID.class,
+                new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
+            .loader(SubgroupsLoader.class);
 
-        cache(EXTERNAL_NAME,
-            String.class,
-            new TypeLiteral<Set<AccountGroup.UUID>>() {})
-          .loader(AllExternalLoader.class);
+        cache(EXTERNAL_NAME, String.class, new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
+            .loader(AllExternalLoader.class);
 
         bind(GroupIncludeCacheImpl.class);
         bind(GroupIncludeCache.class).to(GroupIncludeCacheImpl.class);
@@ -71,22 +70,24 @@
     };
   }
 
-  private final LoadingCache<AccountGroup.UUID, Set<AccountGroup.UUID>> subgroups;
-  private final LoadingCache<AccountGroup.UUID, Set<AccountGroup.UUID>> parentGroups;
-  private final LoadingCache<String, Set<AccountGroup.UUID>> external;
+  private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> subgroups;
+  private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups;
+  private final LoadingCache<String, ImmutableList<AccountGroup.UUID>> external;
 
   @Inject
   GroupIncludeCacheImpl(
-      @Named(SUBGROUPS_NAME) LoadingCache<AccountGroup.UUID, Set<AccountGroup.UUID>> subgroups,
-      @Named(PARENT_GROUPS_NAME) LoadingCache<AccountGroup.UUID, Set<AccountGroup.UUID>> parentGroups,
-      @Named(EXTERNAL_NAME) LoadingCache<String, Set<AccountGroup.UUID>> external) {
+      @Named(SUBGROUPS_NAME)
+          LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> subgroups,
+      @Named(PARENT_GROUPS_NAME)
+          LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups,
+      @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
     this.subgroups = subgroups;
     this.parentGroups = parentGroups;
     this.external = external;
   }
 
   @Override
-  public Set<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID groupId) {
+  public Collection<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID groupId) {
     try {
       return subgroups.get(groupId);
     } catch (ExecutionException e) {
@@ -96,7 +97,7 @@
   }
 
   @Override
-  public Set<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId) {
+  public Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId) {
     try {
       return parentGroups.get(groupId);
     } catch (ExecutionException e) {
@@ -124,17 +125,17 @@
   }
 
   @Override
-  public Set<AccountGroup.UUID> allExternalMembers() {
+  public Collection<AccountGroup.UUID> allExternalMembers() {
     try {
       return external.get(EXTERNAL_NAME);
     } catch (ExecutionException e) {
       log.warn("Cannot load set of non-internal groups", e);
-      return Collections.emptySet();
+      return ImmutableList.of();
     }
   }
 
-  static class SubgroupsLoader extends
-      CacheLoader<AccountGroup.UUID, Set<AccountGroup.UUID>> {
+  static class SubgroupsLoader
+      extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
 
     @Inject
@@ -143,25 +144,24 @@
     }
 
     @Override
-    public Set<AccountGroup.UUID> load(AccountGroup.UUID key) throws Exception {
+    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
       try (ReviewDb db = schema.open()) {
         List<AccountGroup> group = db.accountGroups().byUUID(key).toList();
         if (group.size() != 1) {
-          return Collections.emptySet();
+          return ImmutableList.of();
         }
 
         Set<AccountGroup.UUID> ids = new HashSet<>();
-        for (AccountGroupById agi : db.accountGroupById()
-            .byGroup(group.get(0).getId())) {
+        for (AccountGroupById agi : db.accountGroupById().byGroup(group.get(0).getId())) {
           ids.add(agi.getIncludeUUID());
         }
-        return ImmutableSet.copyOf(ids);
+        return ImmutableList.copyOf(ids);
       }
     }
   }
 
-  static class ParentGroupsLoader extends
-      CacheLoader<AccountGroup.UUID, Set<AccountGroup.UUID>> {
+  static class ParentGroupsLoader
+      extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
 
     @Inject
@@ -170,11 +170,10 @@
     }
 
     @Override
-    public Set<AccountGroup.UUID> load(AccountGroup.UUID key) throws Exception {
+    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
       try (ReviewDb db = schema.open()) {
         Set<AccountGroup.Id> ids = new HashSet<>();
-        for (AccountGroupById agi : db.accountGroupById()
-            .byIncludeUUID(key)) {
+        for (AccountGroupById agi : db.accountGroupById().byIncludeUUID(key)) {
           ids.add(agi.getGroupId());
         }
 
@@ -182,13 +181,12 @@
         for (AccountGroup g : db.accountGroups().get(ids)) {
           groupArray.add(g.getGroupUUID());
         }
-        return ImmutableSet.copyOf(groupArray);
+        return ImmutableList.copyOf(groupArray);
       }
     }
   }
 
-  static class AllExternalLoader extends
-      CacheLoader<String, Set<AccountGroup.UUID>> {
+  static class AllExternalLoader extends CacheLoader<String, ImmutableList<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
 
     @Inject
@@ -197,7 +195,7 @@
     }
 
     @Override
-    public Set<AccountGroup.UUID> load(String key) throws Exception {
+    public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
       try (ReviewDb db = schema.open()) {
         Set<AccountGroup.UUID> ids = new HashSet<>();
         for (AccountGroupById agi : db.accountGroupById().all()) {
@@ -205,7 +203,7 @@
             ids.add(agi.getIncludeUUID());
           }
         }
-        return ImmutableSet.copyOf(ids);
+        return ImmutableList.copyOf(ids);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
index 61f13d6..ea99b9b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
@@ -28,7 +28,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashSet;
@@ -46,7 +45,8 @@
   private final CurrentUser currentUser;
 
   @Inject
-  GroupMembers(final GroupCache groupCache,
+  GroupMembers(
+      final GroupCache groupCache,
       final GroupDetailFactory.Factory groupDetailFactory,
       final AccountCache accountCache,
       final ProjectControl.GenericFactory projectControl,
@@ -58,14 +58,15 @@
     this.currentUser = currentUser;
   }
 
-  public Set<Account> listAccounts(final AccountGroup.UUID groupUUID,
-      final Project.NameKey project) throws NoSuchGroupException,
-      NoSuchProjectException, OrmException, IOException {
+  public Set<Account> listAccounts(final AccountGroup.UUID groupUUID, final Project.NameKey project)
+      throws NoSuchGroupException, NoSuchProjectException, OrmException, IOException {
     return listAccounts(groupUUID, project, new HashSet<AccountGroup.UUID>());
   }
 
-  private Set<Account> listAccounts(final AccountGroup.UUID groupUUID,
-      final Project.NameKey project, final Set<AccountGroup.UUID> seen)
+  private Set<Account> listAccounts(
+      final AccountGroup.UUID groupUUID,
+      final Project.NameKey project,
+      final Set<AccountGroup.UUID> seen)
       throws NoSuchGroupException, OrmException, NoSuchProjectException, IOException {
     if (SystemGroupBackend.PROJECT_OWNERS.equals(groupUUID)) {
       return getProjectOwners(project, seen);
@@ -77,17 +78,16 @@
     return Collections.emptySet();
   }
 
-  private Set<Account> getProjectOwners(final Project.NameKey project,
-      final Set<AccountGroup.UUID> seen) throws NoSuchProjectException,
-      NoSuchGroupException, OrmException, IOException {
+  private Set<Account> getProjectOwners(
+      final Project.NameKey project, final Set<AccountGroup.UUID> seen)
+      throws NoSuchProjectException, NoSuchGroupException, OrmException, IOException {
     seen.add(SystemGroupBackend.PROJECT_OWNERS);
     if (project == null) {
       return Collections.emptySet();
     }
 
     final Iterable<AccountGroup.UUID> ownerGroups =
-        projectControl.controlFor(project, currentUser).getProjectState()
-            .getAllOwners();
+        projectControl.controlFor(project, currentUser).getProjectState().getAllOwners();
 
     final HashSet<Account> projectOwners = new HashSet<>();
     for (final AccountGroup.UUID ownerGroup : ownerGroups) {
@@ -98,12 +98,11 @@
     return projectOwners;
   }
 
-  private Set<Account> getGroupMembers(final AccountGroup group,
-      final Project.NameKey project, final Set<AccountGroup.UUID> seen)
+  private Set<Account> getGroupMembers(
+      final AccountGroup group, final Project.NameKey project, final Set<AccountGroup.UUID> seen)
       throws NoSuchGroupException, OrmException, NoSuchProjectException, IOException {
     seen.add(group.getGroupUUID());
-    final GroupDetail groupDetail =
-        groupDetailFactory.create(group.getId()).call();
+    final GroupDetail groupDetail = groupDetailFactory.create(group.getId()).call();
 
     final Set<Account> members = new HashSet<>();
     if (groupDetail.members != null) {
@@ -113,8 +112,7 @@
     }
     if (groupDetail.includes != null) {
       for (final AccountGroupById groupInclude : groupDetail.includes) {
-        final AccountGroup includedGroup =
-            groupCache.get(groupInclude.getIncludeUUID());
+        final AccountGroup includedGroup = groupCache.get(groupInclude.getIncludeUUID());
         if (includedGroup != null && !seen.contains(includedGroup.getGroupUUID())) {
           members.addAll(listAccounts(includedGroup.getGroupUUID(), project, seen));
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java
index c45b7b7..59b992a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembership.java
@@ -15,35 +15,33 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
 import java.util.Collections;
 import java.util.Set;
 
 /**
- * Implementations of GroupMembership provide methods to test
- * the presence of a user in a particular group.
+ * Implementations of GroupMembership provide methods to test the presence of a user in a particular
+ * group.
  */
 public interface GroupMembership {
-  GroupMembership EMPTY =
-      new ListGroupMembership(Collections.<AccountGroup.UUID>emptySet());
+  GroupMembership EMPTY = new ListGroupMembership(Collections.<AccountGroup.UUID>emptySet());
 
   /**
-   * Returns {@code true} when the user this object was created for is a member
-   * of the specified group.
+   * Returns {@code true} when the user this object was created for is a member of the specified
+   * group.
    */
   boolean contains(AccountGroup.UUID groupId);
 
   /**
-   * Returns {@code true} when the user this object was created for is a member
-   * of any of the specified group.
+   * Returns {@code true} when the user this object was created for is a member of any of the
+   * specified group.
    */
   boolean containsAnyOf(Iterable<AccountGroup.UUID> groupIds);
 
   /**
    * Returns a set containing an input member of {@code contains(id)} is true.
-   * <p>
-   * This is batch form of contains that returns specific group information.
-   * Implementors may implement the method as:
+   *
+   * <p>This is batch form of contains that returns specific group information. Implementors may
+   * implement the method as:
    *
    * <pre>
    * Set&lt;AccountGroup.UUID&gt; r = new HashSet&lt;&gt;();
@@ -54,11 +52,10 @@
   Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupIds);
 
   /**
-   * Returns the set of groups that can be determined by the implementation.
-   * This may not return all groups the {@link #contains(AccountGroup.UUID)}
-   * would return {@code true} for, but will at least contain all top level
-   * groups. This restriction stems from the API of some group systems, which
-   * make it expensive to enumerate the members of a group.
+   * Returns the set of groups that can be determined by the implementation. This may not return all
+   * groups the {@link #contains(AccountGroup.UUID)} would return {@code true} for, but will at
+   * least contain all top level groups. This restriction stems from the API of some group systems,
+   * which make it expensive to enumerate the members of a group.
    */
   Set<AccountGroup.UUID> getKnownGroups();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupUUID.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupUUID.java
index b871c68..45c7052 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupUUID.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupUUID.java
@@ -15,13 +15,11 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
+import java.security.MessageDigest;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 
-import java.security.MessageDigest;
-
 public class GroupUUID {
   public static AccountGroup.UUID make(String groupName, PersonIdent creator) {
     MessageDigest md = Constants.newMessageDigest();
@@ -30,6 +28,5 @@
     return new AccountGroup.UUID(ObjectId.fromRaw(md.digest()).name());
   }
 
-  private GroupUUID() {
-  }
+  private GroupUUID() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/HashedPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/HashedPassword.java
new file mode 100644
index 0000000..0323f4e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/HashedPassword.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Preconditions;
+import com.google.common.io.BaseEncoding;
+import com.google.common.primitives.Ints;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import org.apache.commons.codec.DecoderException;
+import org.bouncycastle.crypto.generators.BCrypt;
+import org.bouncycastle.util.Arrays;
+
+/**
+ * Holds logic for salted, hashed passwords. It uses BCrypt from BouncyCastle, which truncates
+ * passwords at 72 bytes.
+ */
+public class HashedPassword {
+  private static final String ALGORITHM_PREFIX = "bcrypt:";
+  private static final SecureRandom secureRandom = new SecureRandom();
+  private static final BaseEncoding codec = BaseEncoding.base64();
+
+  // bcrypt uses 2^cost rounds. Since we use a generated random password, no need
+  // for a high cost.
+  private static final int DEFAULT_COST = 4;
+
+  /**
+   * decodes a hashed password encoded with {@link #encode}.
+   *
+   * @throws DecoderException if input is malformed.
+   */
+  public static HashedPassword decode(String encoded) throws DecoderException {
+    if (!encoded.startsWith(ALGORITHM_PREFIX)) {
+      throw new DecoderException("unrecognized algorithm");
+    }
+
+    String[] fields = encoded.split(":");
+    if (fields.length != 4) {
+      throw new DecoderException("want 4 fields");
+    }
+
+    Integer cost = Ints.tryParse(fields[1]);
+    if (cost == null) {
+      throw new DecoderException("cost parse failed");
+    }
+
+    if (!(cost >= 4 && cost < 32)) {
+      throw new DecoderException("cost should be 4..31 inclusive, got " + cost);
+    }
+
+    byte[] salt = codec.decode(fields[2]);
+    if (salt.length != 16) {
+      throw new DecoderException("salt should be 16 bytes, got " + salt.length);
+    }
+    return new HashedPassword(codec.decode(fields[3]), salt, cost);
+  }
+
+  private static byte[] hashPassword(String password, byte[] salt, int cost) {
+    byte[] pwBytes = password.getBytes(StandardCharsets.UTF_8);
+
+    return BCrypt.generate(pwBytes, salt, cost);
+  }
+
+  public static HashedPassword fromPassword(String password) {
+    byte[] salt = newSalt();
+
+    return new HashedPassword(hashPassword(password, salt, DEFAULT_COST), salt, DEFAULT_COST);
+  }
+
+  private static byte[] newSalt() {
+    byte[] bytes = new byte[16];
+    secureRandom.nextBytes(bytes);
+    return bytes;
+  }
+
+  private byte[] salt;
+  private byte[] hashed;
+  private int cost;
+
+  private HashedPassword(byte[] hashed, byte[] salt, int cost) {
+    this.salt = salt;
+    this.hashed = hashed;
+    this.cost = cost;
+
+    Preconditions.checkState(cost >= 4 && cost < 32);
+
+    // salt must be 128 bit.
+    Preconditions.checkState(salt.length == 16);
+  }
+
+  /**
+   * Serialize the hashed password and its parameters for persistent storage.
+   *
+   * @return one-line string encoding the hash and salt.
+   */
+  public String encode() {
+    return ALGORITHM_PREFIX + cost + ":" + codec.encode(salt) + ":" + codec.encode(hashed);
+  }
+
+  public boolean checkPassword(String password) {
+    // Constant-time comparison, because we're paranoid.
+    return Arrays.areEqual(hashPassword(password, salt, cost), hashed);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index 3eaeebe..70801c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -30,12 +29,11 @@
 
 /**
  * Group membership checker for the internal group system.
- * <p>
- * Groups the user is directly a member of are pulled from the in-memory
- * AccountCache by way of the IdentifiedUser. Transitive group memberhips are
- * resolved on demand starting from the requested group and looking for a path
- * to a group the user is a member of. Other group backends are supported by
- * recursively invoking the universal GroupMembership.
+ *
+ * <p>Groups the user is directly a member of are pulled from the in-memory AccountCache by way of
+ * the IdentifiedUser. Transitive group memberhips are resolved on demand starting from the
+ * requested group and looking for a path to a group the user is a member of. Other group backends
+ * are supported by recursively invoking the universal GroupMembership.
  */
 public class IncludingGroupMembership implements GroupMembership {
   public interface Factory {
@@ -48,8 +46,7 @@
   private Set<AccountGroup.UUID> knownGroups;
 
   @Inject
-  IncludingGroupMembership(GroupIncludeCache includeCache,
-      @Assisted IdentifiedUser user) {
+  IncludingGroupMembership(GroupIncludeCache includeCache, @Assisted IdentifiedUser user) {
     this.includeCache = includeCache;
     this.user = user;
 
@@ -112,7 +109,7 @@
     return r;
   }
 
-  private boolean search(Set<AccountGroup.UUID> ids) {
+  private boolean search(Iterable<AccountGroup.UUID> ids) {
     return user.getEffectiveGroups().containsAnyOf(ids);
   }
 
@@ -123,8 +120,7 @@
     r.remove(null);
 
     List<AccountGroup.UUID> q = Lists.newArrayList(r);
-    for (AccountGroup.UUID g : membership.intersection(
-        includeCache.allExternalMembers())) {
+    for (AccountGroup.UUID g : membership.intersection(includeCache.allExternalMembers())) {
       if (g != null && r.add(g)) {
         q.add(g);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
index b8cdf76..238241c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
@@ -1,16 +1,16 @@
-//Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
-//Licensed under the Apache License, Version 2.0 (the "License");
-//you may not use this file except in compliance with the License.
-//You may obtain a copy of the License at
+// 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
+// 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.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.account;
 
@@ -19,32 +19,27 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.Index.Input;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 
 @Singleton
 public class Index implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   private final AccountCache accountCache;
   private final Provider<CurrentUser> self;
 
   @Inject
-  Index(AccountCache accountCache,
-      Provider<CurrentUser> self) {
+  Index(AccountCache accountCache, Provider<CurrentUser> self) {
     this.accountCache = accountCache;
     this.self = self;
   }
 
   @Override
-  public Response<?> apply(AccountResource rsrc, Input input)
-      throws IOException, AuthException, OrmException {
-    if (self.get() != rsrc.getUser()
+  public Response<?> apply(AccountResource rsrc, Input input) throws IOException, AuthException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to index account");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 78a801e..7791a2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -20,13 +20,11 @@
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -36,8 +34,7 @@
 
 @Singleton
 public class InternalAccountDirectory extends AccountDirectory {
-  static final Set<FillOptions> ID_ONLY =
-      Collections.unmodifiableSet(EnumSet.of(FillOptions.ID));
+  static final Set<FillOptions> ID_ONLY = Collections.unmodifiableSet(EnumSet.of(FillOptions.ID));
 
   public static class Module extends AbstractModule {
     @Override
@@ -51,7 +48,8 @@
   private final IdentifiedUser.GenericFactory userFactory;
 
   @Inject
-  InternalAccountDirectory(AccountCache accountCache,
+  InternalAccountDirectory(
+      AccountCache accountCache,
       DynamicItem<AvatarProvider> avatar,
       IdentifiedUser.GenericFactory userFactory) {
     this.accountCache = accountCache;
@@ -60,9 +58,7 @@
   }
 
   @Override
-  public void fillAccountInfo(
-      Iterable<? extends AccountInfo> in,
-      Set<FillOptions> options)
+  public void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
       throws DirectoryException {
     if (options.equals(ID_ONLY)) {
       return;
@@ -74,9 +70,10 @@
     }
   }
 
-  private void fill(AccountInfo info,
+  private void fill(
+      AccountInfo info,
       Account account,
-      @Nullable Collection<AccountExternalId> externalIds,
+      @Nullable Collection<ExternalId> externalIds,
       Set<FillOptions> options) {
     if (options.contains(FillOptions.ID)) {
       info._accountId = account.getId().get();
@@ -94,15 +91,16 @@
       info.email = account.getPreferredEmail();
     }
     if (options.contains(FillOptions.SECONDARY_EMAILS)) {
-      info.secondaryEmails = externalIds != null
-          ? getSecondaryEmails(account, externalIds)
-          : null;
+      info.secondaryEmails = externalIds != null ? getSecondaryEmails(account, externalIds) : null;
     }
     if (options.contains(FillOptions.USERNAME)) {
-      info.username = externalIds != null
-          ? AccountState.getUserName(externalIds)
-          : null;
+      info.username = externalIds != null ? AccountState.getUserName(externalIds) : null;
     }
+
+    if (options.contains(FillOptions.STATUS)) {
+      info.status = account.getStatus();
+    }
+
     if (options.contains(FillOptions.AVATARS)) {
       AvatarProvider ap = avatar.get();
       if (ap != null) {
@@ -125,8 +123,7 @@
     }
   }
 
-  public List<String> getSecondaryEmails(Account account,
-      Collection<AccountExternalId> externalIds) {
+  public List<String> getSecondaryEmails(Account account, Collection<ExternalId> externalIds) {
     List<String> emails = new ArrayList<>(AccountState.getEmails(externalIds));
     if (account.getPreferredEmail() != null) {
       emails.remove(account.getPreferredEmail());
@@ -136,10 +133,7 @@
   }
 
   private static void addAvatar(
-      AvatarProvider provider,
-      AccountInfo account,
-      IdentifiedUser user,
-      int size) {
+      AvatarProvider provider, AccountInfo account, IdentifiedUser user, int size) {
     String url = provider.getUrl(user, size);
     if (url != null) {
       AvatarInfo avatar = new AvatarInfo();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
index c47d6f8..38efbbf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
@@ -26,28 +24,19 @@
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.util.Collection;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Implementation of GroupBackend for the internal group system. */
 @Singleton
 public class InternalGroupBackend implements GroupBackend {
-  private static final Function<AccountGroup, GroupReference> ACT_GROUP_TO_GROUP_REF =
-      new Function<AccountGroup, GroupReference>() {
-        @Override
-        public GroupReference apply(AccountGroup group) {
-          return GroupReference.forGroup(group);
-        }
-      };
-
   private final GroupControl.Factory groupControlFactory;
   private final GroupCache groupCache;
   private final IncludingGroupMembership.Factory groupMembershipFactory;
 
   @Inject
-  InternalGroupBackend(GroupControl.Factory groupControlFactory,
+  InternalGroupBackend(
+      GroupControl.Factory groupControlFactory,
       GroupCache groupCache,
       IncludingGroupMembership.Factory groupMembershipFactory) {
     this.groupControlFactory = groupControlFactory;
@@ -75,18 +64,15 @@
   }
 
   @Override
-  public Collection<GroupReference> suggest(final String name,
-      final ProjectControl project) {
-    Iterable<AccountGroup> filtered = Iterables.filter(groupCache.all(),
-        new Predicate<AccountGroup>() {
-          @Override
-          public boolean apply(AccountGroup group) {
-            // startsWithIgnoreCase && isVisible
-            return group.getName().regionMatches(true, 0, name, 0, name.length())
-                && groupControlFactory.controlFor(group).isVisible();
-          }
-        });
-    return Lists.newArrayList(Iterables.transform(filtered, ACT_GROUP_TO_GROUP_REF));
+  public Collection<GroupReference> suggest(final String name, final ProjectControl project) {
+    return groupCache.all().stream()
+        .filter(
+            group ->
+                // startsWithIgnoreCase && isVisible
+                group.getName().regionMatches(true, 0, name, 0, name.length())
+                    && groupControlFactory.controlFor(group).isVisible())
+        .map(GroupReference::forGroup)
+        .collect(toList());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java
index d60b7af..e8d0df7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InvalidUserNameException.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Error indicating the SSH user name does not match {@link Account#USER_NAME_PATTERN} pattern. */
+/** Error indicating the SSH user name does not match the expected pattern. */
 public class InvalidUserNameException extends Exception {
 
   private static final long serialVersionUID = 1L;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ListGroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ListGroupMembership.java
index 118940f..60e7345 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ListGroupMembership.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ListGroupMembership.java
@@ -17,12 +17,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
 import java.util.Set;
 
-/**
- * GroupMembership over an explicit list.
- */
+/** GroupMembership over an explicit list. */
 public class ListGroupMembership implements GroupMembership {
   private final Set<AccountGroup.UUID> groups;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
index 8c5228f..775ce6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
@@ -44,6 +44,8 @@
     get(ACCOUNT_KIND, "name").to(GetName.class);
     put(ACCOUNT_KIND, "name").to(PutName.class);
     delete(ACCOUNT_KIND, "name").to(PutName.class);
+    get(ACCOUNT_KIND, "status").to(GetStatus.class);
+    put(ACCOUNT_KIND, "status").to(PutStatus.class);
     get(ACCOUNT_KIND, "username").to(GetUsername.class);
     put(ACCOUNT_KIND, "username").to(PutUsername.class);
     get(ACCOUNT_KIND, "active").to(GetActive.class);
@@ -54,15 +56,13 @@
     put(EMAIL_KIND).to(PutEmail.class);
     delete(EMAIL_KIND).to(DeleteEmail.class);
     put(EMAIL_KIND, "preferred").to(PutPreferred.class);
-    get(ACCOUNT_KIND, "password.http").to(GetHttpPassword.class);
     put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
     delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
     child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
     post(ACCOUNT_KIND, "sshkeys").to(AddSshKey.class);
     get(ACCOUNT_KIND, "watched.projects").to(GetWatchedProjects.class);
     post(ACCOUNT_KIND, "watched.projects").to(PostWatchedProjects.class);
-    post(ACCOUNT_KIND, "watched.projects:delete")
-        .to(DeleteWatchedProjects.class);
+    post(ACCOUNT_KIND, "watched.projects:delete").to(DeleteWatchedProjects.class);
 
     get(SSH_KEY_KIND).to(GetSshKey.class);
     delete(SSH_KEY_KIND).to(DeleteSshKey.class);
@@ -95,6 +95,9 @@
     get(STAR_KIND).to(Stars.Get.class);
     post(STAR_KIND).to(Stars.Post.class);
 
+    get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
+    post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
+
     factory(CreateAccount.Factory.class);
     factory(CreateEmail.Factory.class);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
index d54ec50..7a4e0ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
@@ -21,33 +21,25 @@
 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.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.util.EnumSet;
 import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class PostWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
-  private final Provider<ReviewDb> dbProvider;
   private final Provider<IdentifiedUser> self;
   private final GetWatchedProjects getWatchedProjects;
   private final ProjectsCollection projectsCollection;
@@ -55,13 +47,12 @@
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
-  public PostWatchedProjects(Provider<ReviewDb> dbProvider,
+  public PostWatchedProjects(
       Provider<IdentifiedUser> self,
       GetWatchedProjects getWatchedProjects,
       ProjectsCollection projectsCollection,
       AccountCache accountCache,
       WatchConfig.Accessor watchConfig) {
-    this.dbProvider = dbProvider;
     this.self = self;
     this.getWatchedProjects = getWatchedProjects;
     this.projectsCollection = projectsCollection;
@@ -70,71 +61,28 @@
   }
 
   @Override
-  public List<ProjectWatchInfo> apply(AccountResource rsrc,
-      List<ProjectWatchInfo> input) throws OrmException, RestApiException,
-          IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+  public List<ProjectWatchInfo> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
+      throws OrmException, RestApiException, IOException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to edit project watches");
     }
     Account.Id accountId = rsrc.getUser().getAccountId();
-    updateInDb(accountId, input);
-    updateInGit(accountId, input);
+    watchConfig.upsertProjectWatches(accountId, asMap(input));
     accountCache.evict(accountId);
     return getWatchedProjects.apply(rsrc);
   }
 
-  private void updateInDb(Account.Id accountId, List<ProjectWatchInfo> input)
-      throws BadRequestException, UnprocessableEntityException, IOException,
-      OrmException {
-    Set<AccountProjectWatch.Key> keys = new HashSet<>();
-    List<AccountProjectWatch> watchedProjects = new LinkedList<>();
-    for (ProjectWatchInfo a : input) {
-      if (a.project == null) {
-        throw new BadRequestException("project name must be specified");
-      }
-
-      Project.NameKey projectKey =
-          projectsCollection.parse(a.project).getNameKey();
-      AccountProjectWatch.Key key =
-          new AccountProjectWatch.Key(accountId, projectKey, a.filter);
-      if (!keys.add(key)) {
-        throw new BadRequestException("duplicate entry for project "
-            + format(key.getProjectName().get(), key.getFilter().get()));
-      }
-      AccountProjectWatch apw = new AccountProjectWatch(key);
-      apw.setNotify(AccountProjectWatch.NotifyType.ABANDONED_CHANGES,
-          toBoolean(a.notifyAbandonedChanges));
-      apw.setNotify(AccountProjectWatch.NotifyType.ALL_COMMENTS,
-          toBoolean(a.notifyAllComments));
-      apw.setNotify(AccountProjectWatch.NotifyType.NEW_CHANGES,
-          toBoolean(a.notifyNewChanges));
-      apw.setNotify(AccountProjectWatch.NotifyType.NEW_PATCHSETS,
-          toBoolean(a.notifyNewPatchSets));
-      apw.setNotify(AccountProjectWatch.NotifyType.SUBMITTED_CHANGES,
-          toBoolean(a.notifySubmittedChanges));
-      watchedProjects.add(apw);
-    }
-    dbProvider.get().accountProjectWatches().upsert(watchedProjects);
-  }
-
-  private void updateInGit(Account.Id accountId, List<ProjectWatchInfo> input)
-      throws BadRequestException, UnprocessableEntityException, IOException,
-      ConfigInvalidException {
-    watchConfig.upsertProjectWatches(accountId, asMap(input));
-  }
-
-  private Map<ProjectWatchKey, Set<NotifyType>> asMap(
-      List<ProjectWatchInfo> input) throws BadRequestException,
-          UnprocessableEntityException, IOException {
+  private Map<ProjectWatchKey, Set<NotifyType>> asMap(List<ProjectWatchInfo> input)
+      throws BadRequestException, UnprocessableEntityException, IOException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
       if (info.project == null) {
         throw new BadRequestException("project name must be specified");
       }
 
-      ProjectWatchKey key = ProjectWatchKey.create(
-          projectsCollection.parse(info.project).getNameKey(), info.filter);
+      ProjectWatchKey key =
+          ProjectWatchKey.create(projectsCollection.parse(info.project).getNameKey(), info.filter);
       if (m.containsKey(key)) {
         throw new BadRequestException(
             "duplicate entry for project " + format(info.project, info.filter));
@@ -168,8 +116,6 @@
 
   private static String format(String project, String filter) {
     return project
-        + (filter != null && !AccountProjectWatch.FILTER_ALL.equals(filter)
-            ? " and filter " + filter
-            : "");
+        + (filter != null && !WatchConfig.FILTER_ALL.equals(filter) ? " and filter " + filter : "");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
index 9197011..da5a58f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutAccount
-    implements RestModifyView<AccountResource, AccountInput> {
+public class PutAccount implements RestModifyView<AccountResource, AccountInput> {
   @Override
-  public Object apply(AccountResource resource, AccountInput input)
+  public Response<AccountInfo> apply(AccountResource resource, AccountInput input)
       throws ResourceConflictException {
     throw new ResourceConflictException("account exists");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
index 8cc134f..32c5345 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
@@ -22,19 +22,19 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.PutActive.Input;
+import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.util.Collections;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class PutActive implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   private final Provider<ReviewDb> dbProvider;
   private final AccountCache byIdCache;
@@ -48,16 +48,29 @@
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws ResourceNotFoundException, OrmException, IOException {
-    Account a = dbProvider.get().accounts().get(rsrc.getUser().getAccountId());
+    AtomicBoolean alreadyActive = new AtomicBoolean(false);
+    Account a =
+        dbProvider
+            .get()
+            .accounts()
+            .atomicUpdate(
+                rsrc.getUser().getAccountId(),
+                new AtomicUpdate<Account>() {
+                  @Override
+                  public Account update(Account a) {
+                    if (a.isActive()) {
+                      alreadyActive.set(true);
+                    } else {
+                      a.setActive(true);
+                    }
+                    return a;
+                  }
+                });
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    if (a.isActive()) {
-      return Response.ok("");
-    }
-    a.setActive(true);
     dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
-    return Response.created("");
+    return alreadyActive.get() ? Response.ok("") : Response.created("");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
index 2fdf666..e622575 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -36,14 +37,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.Config;
 
-import java.io.IOException;
-
 @Singleton
-public class PutAgreement
-    implements RestModifyView<AccountResource, AgreementInput> {
+public class PutAgreement implements RestModifyView<AccountResource, AgreementInput> {
   private final ProjectCache projectCache;
   private final GroupCache groupCache;
   private final Provider<IdentifiedUser> self;
@@ -52,7 +50,8 @@
   private final boolean agreementsEnabled;
 
   @Inject
-  PutAgreement(ProjectCache projectCache,
+  PutAgreement(
+      ProjectCache projectCache,
       GroupCache groupCache,
       Provider<IdentifiedUser> self,
       AgreementSignup agreementSignup,
@@ -63,24 +62,23 @@
     this.self = self;
     this.agreementSignup = agreementSignup;
     this.addMembers = addMembers;
-    this.agreementsEnabled =
-        config.getBoolean("auth", "contributorAgreements", false);
+    this.agreementsEnabled = config.getBoolean("auth", "contributorAgreements", false);
   }
 
   @Override
-  public Object apply(AccountResource resource, AgreementInput input)
+  public Response<String> apply(AccountResource resource, AgreementInput input)
       throws IOException, OrmException, RestApiException {
     if (!agreementsEnabled) {
       throw new MethodNotAllowedException("contributor agreements disabled");
     }
 
-    if (self.get() != resource.getUser()) {
+    if (!self.get().hasSameAccountId(resource.getUser())) {
       throw new AuthException("not allowed to enter contributor agreement");
     }
 
     String agreementName = Strings.nullToEmpty(input.name);
-    ContributorAgreement ca = projectCache.getAllProjects().getConfig()
-        .getContributorAgreement(agreementName);
+    ContributorAgreement ca =
+        projectCache.getAllProjects().getConfig().getContributorAgreement(agreementName);
     if (ca == null) {
       throw new UnprocessableEntityException("contributor agreement not found");
     }
@@ -103,7 +101,6 @@
     addMembers.addMembers(group.getId(), ImmutableList.of(account.getId()));
     agreementSignup.fire(account, agreementName);
 
-    return agreementName;
+    return Response.ok(agreementName);
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index 0cd93f1..0174ff1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -30,16 +29,12 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-import org.apache.commons.codec.binary.Base64;
-
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
-import java.util.Collections;
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
-@Singleton
 public class PutHttpPassword implements RestModifyView<AccountResource, Input> {
   public static class Input {
     public String httpPassword;
@@ -59,20 +54,22 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
-  private final AccountCache accountCache;
+  private final ExternalIdsUpdate.User externalIdsUpdate;
 
   @Inject
-  PutHttpPassword(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider,
-      AccountCache accountCache) {
+  PutHttpPassword(
+      Provider<CurrentUser> self,
+      Provider<ReviewDb> dbProvider,
+      ExternalIdsUpdate.User externalIdsUpdate) {
     this.self = self;
     this.dbProvider = dbProvider;
-    this.accountCache = accountCache;
+    this.externalIdsUpdate = externalIdsUpdate;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException,
-      ResourceConflictException, OrmException, IOException {
+      throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
+          IOException, ConfigInvalidException {
     if (input == null) {
       input = new Input();
     }
@@ -80,22 +77,23 @@
 
     String newPassword;
     if (input.generate) {
-      if (self.get() != rsrc.getUser()
+      if (!self.get().hasSameAccountId(rsrc.getUser())
           && !self.get().getCapabilities().canAdministrateServer()) {
         throw new AuthException("not allowed to generate HTTP password");
       }
       newPassword = generate();
 
     } else if (input.httpPassword == null) {
-      if (self.get() != rsrc.getUser()
+      if (!self.get().hasSameAccountId(rsrc.getUser())
           && !self.get().getCapabilities().canAdministrateServer()) {
         throw new AuthException("not allowed to clear HTTP password");
       }
       newPassword = null;
     } else {
       if (!self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException("not allowed to set HTTP password directly, "
-            + "requires the Administrate Server permission");
+        throw new AuthException(
+            "not allowed to set HTTP password directly, "
+                + "requires the Administrate Server permission");
       }
       newPassword = input.httpPassword;
     }
@@ -103,25 +101,27 @@
   }
 
   public Response<String> apply(IdentifiedUser user, String newPassword)
-      throws ResourceNotFoundException, ResourceConflictException, OrmException,
-      IOException {
+      throws ResourceNotFoundException, ResourceConflictException, OrmException, IOException {
     if (user.getUserName() == null) {
       throw new ResourceConflictException("username must be set");
     }
 
-    AccountExternalId id = dbProvider.get().accountExternalIds()
-        .get(new AccountExternalId.Key(
-            SCHEME_USERNAME, user.getUserName()));
-    if (id == null) {
+    ExternalId extId =
+        ExternalId.from(
+            dbProvider
+                .get()
+                .accountExternalIds()
+                .get(
+                    ExternalId.Key.create(SCHEME_USERNAME, user.getUserName())
+                        .asAccountExternalIdKey()));
+    if (extId == null) {
       throw new ResourceNotFoundException();
     }
-    id.setPassword(newPassword);
-    dbProvider.get().accountExternalIds().update(Collections.singleton(id));
-    accountCache.evict(user.getAccountId());
+    ExternalId newExtId =
+        ExternalId.createWithPassword(extId.key(), extId.accountId(), extId.email(), newPassword);
+    externalIdsUpdate.create().upsert(dbProvider.get(), newExtId);
 
-    return Strings.isNullOrEmpty(newPassword)
-        ? Response.<String>none()
-        : Response.ok(newPassword);
+    return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
   }
 
   public static String generate() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
index e0b69a6..a00e2ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -22,24 +23,21 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutName.Input;
+import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
-import java.util.Collections;
 
 @Singleton
 public class PutName implements RestModifyView<AccountResource, Input> {
   public static class Input {
-    @DefaultInput
-    public String name;
+    @DefaultInput public String name;
   }
 
   private final Provider<CurrentUser> self;
@@ -48,8 +46,11 @@
   private final AccountCache byIdCache;
 
   @Inject
-  PutName(Provider<CurrentUser> self, Realm realm,
-      Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  PutName(
+      Provider<CurrentUser> self,
+      Realm realm,
+      Provider<ReviewDb> dbProvider,
+      AccountCache byIdCache) {
     this.self = self;
     this.realm = realm;
     this.dbProvider = dbProvider;
@@ -58,9 +59,9 @@
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws AuthException, MethodNotAllowedException,
-      ResourceNotFoundException, OrmException, IOException {
-    if (self.get() != rsrc.getUser()
+      throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
+          IOException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to change name");
     }
@@ -68,25 +69,35 @@
   }
 
   public Response<String> apply(IdentifiedUser user, Input input)
-      throws MethodNotAllowedException, ResourceNotFoundException, OrmException,
-      IOException {
+      throws MethodNotAllowedException, ResourceNotFoundException, OrmException, IOException {
     if (input == null) {
       input = new Input();
     }
 
-    if (!realm.allowsEdit(FieldName.FULL_NAME)) {
+    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)) {
       throw new MethodNotAllowedException("realm does not allow editing name");
     }
 
-    Account a = dbProvider.get().accounts().get(user.getAccountId());
+    String newName = input.name;
+    Account a =
+        dbProvider
+            .get()
+            .accounts()
+            .atomicUpdate(
+                user.getAccountId(),
+                new AtomicUpdate<Account>() {
+                  @Override
+                  public Account update(Account a) {
+                    a.setFullName(newName);
+                    return a;
+                  }
+                });
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    a.setFullName(input.name);
-    dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
     return Strings.isNullOrEmpty(a.getFullName())
-        ? Response.<String> none()
+        ? Response.<String>none()
         : Response.ok(a.getFullName());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
index 92357b5..d86a312 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
@@ -23,27 +23,25 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutPreferred.Input;
+import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.util.Collections;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 @Singleton
-public class PutPreferred implements
-    RestModifyView<AccountResource.Email, Input> {
-  static class Input {
-  }
+public class PutPreferred implements RestModifyView<AccountResource.Email, Input> {
+  static class Input {}
 
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
   private final AccountCache byIdCache;
 
   @Inject
-  PutPreferred(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider,
-      AccountCache byIdCache) {
+  PutPreferred(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
     this.self = self;
     this.dbProvider = dbProvider;
     this.byIdCache = byIdCache;
@@ -51,9 +49,8 @@
 
   @Override
   public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException,
-      IOException {
-    if (self.get() != rsrc.getUser()
+      throws AuthException, ResourceNotFoundException, OrmException, IOException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("not allowed to set preferred email address");
     }
@@ -62,16 +59,29 @@
 
   public Response<String> apply(IdentifiedUser user, String email)
       throws ResourceNotFoundException, OrmException, IOException {
-    Account a = dbProvider.get().accounts().get(user.getAccountId());
+    AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
+    Account a =
+        dbProvider
+            .get()
+            .accounts()
+            .atomicUpdate(
+                user.getAccountId(),
+                new AtomicUpdate<Account>() {
+                  @Override
+                  public Account update(Account a) {
+                    if (email.equals(a.getPreferredEmail())) {
+                      alreadyPreferred.set(true);
+                    } else {
+                      a.setPreferredEmail(email);
+                    }
+                    return a;
+                  }
+                });
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    if (email.equals(a.getPreferredEmail())) {
-      return Response.ok("");
-    }
-    a.setPreferredEmail(email);
     dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
-    return Response.created("");
+    return alreadyPreferred.get() ? Response.ok("") : Response.created("");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
new file mode 100644
index 0000000..c16d8da
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.PutStatus.Input;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class PutStatus implements RestModifyView<AccountResource, Input> {
+  public static class Input {
+    @DefaultInput String status;
+
+    public Input(String status) {
+      this.status = status;
+    }
+
+    public Input() {}
+  }
+
+  private final Provider<CurrentUser> self;
+  private final Provider<ReviewDb> dbProvider;
+  private final AccountCache byIdCache;
+
+  @Inject
+  PutStatus(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+    this.self = self;
+    this.dbProvider = dbProvider;
+    this.byIdCache = byIdCache;
+  }
+
+  @Override
+  public Response<String> apply(AccountResource rsrc, Input input)
+      throws AuthException, ResourceNotFoundException, OrmException, IOException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
+        && !self.get().getCapabilities().canModifyAccount()) {
+      throw new AuthException("not allowed to set status");
+    }
+    return apply(rsrc.getUser(), input);
+  }
+
+  public Response<String> apply(IdentifiedUser user, Input input)
+      throws ResourceNotFoundException, OrmException, IOException {
+    if (input == null) {
+      input = new Input();
+    }
+
+    String newStatus = input.status;
+    Account a =
+        dbProvider
+            .get()
+            .accounts()
+            .atomicUpdate(
+                user.getAccountId(),
+                new AtomicUpdate<Account>() {
+                  @Override
+                  public Account update(Account a) {
+                    a.setStatus(Strings.nullToEmpty(newStatus));
+                    return a;
+                  }
+                });
+    if (a == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
+    byIdCache.evict(a.getId());
+    return Strings.isNullOrEmpty(a.getStatus()) ? Response.none() : Response.ok(a.getStatus());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index e9dc393..21b1720 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.PutUsername.Input;
@@ -29,14 +29,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class PutUsername implements RestModifyView<AccountResource, Input> {
   public static class Input {
-    @DefaultInput
-    public String username;
+    @DefaultInput public String username;
   }
 
   private final Provider<CurrentUser> self;
@@ -45,7 +44,8 @@
   private final Provider<ReviewDb> db;
 
   @Inject
-  PutUsername(Provider<CurrentUser> self,
+  PutUsername(
+      Provider<CurrentUser> self,
       ChangeUserName.Factory changeUserNameFactory,
       Realm realm,
       Provider<ReviewDb> db) {
@@ -56,15 +56,15 @@
   }
 
   @Override
-  public String apply(AccountResource rsrc, Input input) throws AuthException,
-      MethodNotAllowedException, UnprocessableEntityException,
-      ResourceConflictException, OrmException, IOException {
-    if (self.get() != rsrc.getUser()
+  public String apply(AccountResource rsrc, Input input)
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
+          ResourceConflictException, OrmException, IOException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canAdministrateServer()) {
       throw new AuthException("not allowed to set username");
     }
 
-    if (!realm.allowsEdit(Account.FieldName.USER_NAME)) {
+    if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
       throw new MethodNotAllowedException("realm does not allow editing username");
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
index 000637a..a2de481 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
@@ -23,13 +23,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.QueryResult;
@@ -37,29 +33,21 @@
 import com.google.gerrit.server.query.account.AccountQueryProcessor;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-import org.kohsuke.args4j.Option;
-
 import java.util.Collections;
 import java.util.EnumSet;
-import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
 
 public class QueryAccounts implements RestReadView<TopLevelResource> {
   private static final int MAX_SUGGEST_RESULTS = 100;
-  private static final String MAX_SUFFIX = "\u9fa5";
 
-  private final AccountControl accountControl;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final AccountCache accountCache;
-  private final AccountIndexCollection indexes;
   private final AccountQueryBuilder queryBuilder;
   private final AccountQueryProcessor queryProcessor;
-  private final ReviewDb db;
   private final boolean suggestConfig;
   private final int suggestFrom;
 
@@ -75,7 +63,11 @@
     this.suggest = suggest;
   }
 
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of users to return")
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of users to return")
   public void setLimit(int n) {
     queryProcessor.setLimit(n);
 
@@ -98,33 +90,33 @@
     options.addAll(ListAccountsOption.fromBits(Integer.parseInt(hex, 16)));
   }
 
-  @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", usage = "match users")
+  @Option(
+      name = "--query",
+      aliases = {"-q"},
+      metaVar = "QUERY",
+      usage = "match users")
   public void setQuery(String query) {
     this.query = query;
   }
 
-  @Option(name = "--start", aliases = {"-S"}, metaVar = "CNT",
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
       usage = "Number of accounts to skip")
   public void setStart(int start) {
     this.start = start;
   }
 
   @Inject
-  QueryAccounts(AccountControl.Factory accountControlFactory,
+  QueryAccounts(
       AccountLoader.Factory accountLoaderFactory,
-      AccountCache accountCache,
-      AccountIndexCollection indexes,
       AccountQueryBuilder queryBuilder,
       AccountQueryProcessor queryProcessor,
-      ReviewDb db,
       @GerritServerConfig Config cfg) {
-    this.accountControl = accountControlFactory.get();
     this.accountLoaderFactory = accountLoaderFactory;
-    this.accountCache = accountCache;
-    this.indexes = indexes;
     this.queryBuilder = queryBuilder;
     this.queryProcessor = queryProcessor;
-    this.db = db;
     this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
     this.options = EnumSet.noneOf(ListAccountsOption.class);
 
@@ -133,8 +125,7 @@
     } else {
       boolean suggest;
       try {
-        AccountVisibility av =
-            cfg.getEnum("suggest", null, "accounts", AccountVisibility.ALL);
+        AccountVisibility av = cfg.getEnum("suggest", null, "accounts", AccountVisibility.ALL);
         suggest = (av != AccountVisibility.NONE);
       } catch (IllegalArgumentException err) {
         suggest = cfg.getBoolean("suggest", null, "accounts", true);
@@ -169,22 +160,6 @@
     }
     accountLoader = accountLoaderFactory.create(fillOptions);
 
-    AccountIndex searchIndex = indexes.getSearchIndex();
-    if (searchIndex != null) {
-      return queryFromIndex();
-    }
-
-    if (!suggest) {
-      throw new MethodNotAllowedException();
-    }
-    if (start != null) {
-      throw new MethodNotAllowedException("option start not allowed");
-    }
-    return queryFromDb();
-  }
-
-  public List<AccountInfo> queryFromIndex()
-      throws BadRequestException, MethodNotAllowedException, OrmException {
     if (queryProcessor.isDisabled()) {
       throw new MethodNotAllowedException("query disabled");
     }
@@ -223,57 +198,4 @@
       throw new BadRequestException(e.getMessage());
     }
   }
-
-  public List<AccountInfo> queryFromDb() throws OrmException {
-    String a = query;
-    String b = a + MAX_SUFFIX;
-
-    Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
-    Map<Account.Id, String> queryEmail = new HashMap<>();
-
-    for (Account p : db.accounts().suggestByFullName(a, b, suggestLimit)) {
-      addSuggestion(matches, p);
-    }
-    if (matches.size() < suggestLimit) {
-      for (Account p : db.accounts()
-          .suggestByPreferredEmail(a, b, suggestLimit - matches.size())) {
-        addSuggestion(matches, p);
-      }
-    }
-    if (matches.size() < suggestLimit) {
-      for (AccountExternalId e : db.accountExternalIds()
-          .suggestByEmailAddress(a, b, suggestLimit - matches.size())) {
-        if (addSuggestion(matches, e.getAccountId())) {
-          queryEmail.put(e.getAccountId(), e.getEmailAddress());
-        }
-      }
-    }
-
-    accountLoader.fill();
-    for (Map.Entry<Account.Id, String> p : queryEmail.entrySet()) {
-      AccountInfo info = matches.get(p.getKey());
-      if (info != null) {
-        info.email = p.getValue();
-      }
-    }
-
-    return AccountInfoComparator.ORDER_NULLS_LAST.sortedCopy(matches.values());
-  }
-
-  private boolean addSuggestion(Map<Account.Id, AccountInfo> map, Account a) {
-    if (!a.isActive()) {
-      return false;
-    }
-    Account.Id id = a.getId();
-    if (!map.containsKey(id) && accountControl.canSee(id)) {
-      map.put(id, accountLoader.get(id));
-      return true;
-    }
-    return false;
-  }
-
-  private boolean addSuggestion(Map<Account.Id, AccountInfo> map, Account.Id id) {
-    Account a = accountCache.get(id).getAccount();
-    return addSuggestion(map, a);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
index 85fde4e..5d551bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
-
 import java.util.Set;
 
 public interface Realm {
   /** Can the end-user modify this field of their own account? */
-  boolean allowsEdit(Account.FieldName field);
+  boolean allowsEdit(AccountFieldName field);
 
   /** Returns the account fields that the end-user can modify. */
-  Set<Account.FieldName> getEditableFields();
+  Set<AccountFieldName> getEditableFields();
 
   AuthRequest authenticate(AuthRequest who) throws AccountException;
 
@@ -38,11 +38,10 @@
 
   /**
    * Locate an account whose local username is the given account name.
-   * <p>
-   * Generally this only works for local realms, such as one backed by an LDAP
-   * directory, or where there is an {@link EmailExpander} configured that knows
-   * how to convert the accountName into an email address, and then locate the
-   * user by that email address.
+   *
+   * <p>Generally this only works for local realms, such as one backed by an LDAP directory, or
+   * where there is an {@link EmailExpander} configured that knows how to convert the accountName
+   * into an email address, and then locate the user by that email address.
    */
   Account.Id lookup(String accountName);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
index c408155..c72ff02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.server.account.GetDiffPreferences.readDefaultsFromGit;
 import static com.google.gerrit.server.account.GetDiffPreferences.readFromGit;
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 import static com.google.gerrit.server.config.ConfigUtil.storeSection;
@@ -31,22 +32,20 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
-import java.io.IOException;
-
 @Singleton
-public class SetDiffPreferences implements
-    RestModifyView<AccountResource, DiffPreferencesInfo> {
+public class SetDiffPreferences implements RestModifyView<AccountResource, DiffPreferencesInfo> {
   private final Provider<CurrentUser> self;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
   private final GitRepositoryManager gitMgr;
 
   @Inject
-  SetDiffPreferences(Provider<CurrentUser> self,
+  SetDiffPreferences(
+      Provider<CurrentUser> self,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
       GitRepositoryManager gitMgr) {
@@ -59,8 +58,8 @@
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo in)
       throws AuthException, BadRequestException, ConfigInvalidException,
-      RepositoryNotFoundException, IOException {
-    if (self.get() != rsrc.getUser()
+          RepositoryNotFoundException, IOException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("requires Modify Account capability");
     }
@@ -73,20 +72,16 @@
     return writeToGit(readFromGit(id, gitMgr, allUsersName, in), id);
   }
 
-  private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in,
-      Account.Id userId) throws RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
+  private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in, Account.Id userId)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     DiffPreferencesInfo out = new DiffPreferencesInfo();
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      VersionedAccountPreferences prefs = VersionedAccountPreferences.forUser(
-          userId);
+      DiffPreferencesInfo allUserPrefs = readDefaultsFromGit(md.getRepository(), null);
+      VersionedAccountPreferences prefs = VersionedAccountPreferences.forUser(userId);
       prefs.load(md);
-      DiffPreferencesInfo defaults = DiffPreferencesInfo.defaults();
-      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in,
-          defaults);
+      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in, allUserPrefs);
       prefs.commit(md);
-      loadSection(prefs.getConfig(), UserConfigSections.DIFF, null, out,
-          DiffPreferencesInfo.defaults(), null);
+      loadSection(prefs.getConfig(), UserConfigSections.DIFF, null, out, allUserPrefs, null);
     }
     return out;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
index 7bff42b..e2a2912 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
@@ -31,15 +31,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
-import java.io.IOException;
-
 @Singleton
-public class SetEditPreferences implements
-    RestModifyView<AccountResource, EditPreferencesInfo> {
+public class SetEditPreferences implements RestModifyView<AccountResource, EditPreferencesInfo> {
 
   private final Provider<CurrentUser> self;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
@@ -47,7 +44,8 @@
   private final AllUsersName allUsersName;
 
   @Inject
-  SetEditPreferences(Provider<CurrentUser> self,
+  SetEditPreferences(
+      Provider<CurrentUser> self,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       GitRepositoryManager gitMgr,
       AllUsersName allUsersName) {
@@ -59,9 +57,9 @@
 
   @Override
   public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo in)
-      throws AuthException, BadRequestException, RepositoryNotFoundException,
-      IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+      throws AuthException, BadRequestException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("requires Modify Account capability");
     }
@@ -77,12 +75,21 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       prefs = VersionedAccountPreferences.forUser(accountId);
       prefs.load(md);
-      storeSection(prefs.getConfig(), UserConfigSections.EDIT, null,
+      storeSection(
+          prefs.getConfig(),
+          UserConfigSections.EDIT,
+          null,
           readFromGit(accountId, gitMgr, allUsersName, in),
           EditPreferencesInfo.defaults());
       prefs.commit(md);
-      out = loadSection(prefs.getConfig(), UserConfigSections.EDIT, null, out,
-          EditPreferencesInfo.defaults(), null);
+      out =
+          loadSection(
+              prefs.getConfig(),
+              UserConfigSections.EDIT,
+              null,
+              out,
+              EditPreferencesInfo.defaults(),
+              null);
     }
 
     return out;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
index b70cabd..d2164f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.gerrit.server.config.ConfigUtil.storeSection;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_MATCH;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
@@ -38,19 +39,16 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class SetPreferences implements
-    RestModifyView<AccountResource, GeneralPreferencesInfo> {
+public class SetPreferences implements RestModifyView<AccountResource, GeneralPreferencesInfo> {
   private final Provider<CurrentUser> self;
   private final AccountCache cache;
   private final GeneralPreferencesLoader loader;
@@ -59,7 +57,8 @@
   private final DynamicMap<DownloadScheme> downloadSchemes;
 
   @Inject
-  SetPreferences(Provider<CurrentUser> self,
+  SetPreferences(
+      Provider<CurrentUser> self,
       AccountCache cache,
       GeneralPreferencesLoader loader,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
@@ -74,11 +73,9 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc,
-      GeneralPreferencesInfo i)
-          throws AuthException, BadRequestException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+  public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo i)
+      throws AuthException, BadRequestException, IOException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new AuthException("requires Modify Account capability");
     }
@@ -87,6 +84,7 @@
     Account.Id id = rsrc.getUser().getAccountId();
     GeneralPreferencesInfo n = loader.merge(id, i);
 
+    n.changeTable = i.changeTable;
     n.my = i.my;
     n.urlAliases = i.urlAliases;
 
@@ -102,9 +100,14 @@
       prefs = VersionedAccountPreferences.forUser(id);
       prefs.load(md);
 
-      storeSection(prefs.getConfig(), UserConfigSections.GENERAL, null, i,
-          GeneralPreferencesInfo.defaults());
+      storeSection(
+          prefs.getConfig(),
+          UserConfigSections.GENERAL,
+          null,
+          i,
+          loader.readDefaultsFromGit(md.getRepository(), null));
 
+      storeMyChangeTableColumns(prefs, i.changeTable);
       storeMyMenus(prefs, i.my);
       storeUrlAliases(prefs, i.urlAliases);
       prefs.commit(md);
@@ -112,8 +115,7 @@
     }
   }
 
-  public static void storeMyMenus(VersionedAccountPreferences prefs,
-      List<MenuItem> my) {
+  public static void storeMyMenus(VersionedAccountPreferences prefs, List<MenuItem> my) {
     Config cfg = prefs.getConfig();
     if (my != null) {
       unsetSection(cfg, UserConfigSections.MY);
@@ -125,6 +127,15 @@
     }
   }
 
+  public static void storeMyChangeTableColumns(
+      VersionedAccountPreferences prefs, List<String> changeTable) {
+    Config cfg = prefs.getConfig();
+    if (changeTable != null) {
+      unsetSection(cfg, UserConfigSections.CHANGE_TABLE);
+      cfg.setStringList(UserConfigSections.CHANGE_TABLE, null, CHANGE_TABLE_COLUMN, changeTable);
+    }
+  }
+
   private static void set(Config cfg, String section, String key, String val) {
     if (Strings.isNullOrEmpty(val)) {
       cfg.unset(UserConfigSections.MY, section, key);
@@ -135,13 +146,13 @@
 
   private static void unsetSection(Config cfg, String section) {
     cfg.unsetSection(section, null);
-    for (String subsection: cfg.getSubsections(section)) {
+    for (String subsection : cfg.getSubsections(section)) {
       cfg.unsetSection(section, subsection);
     }
   }
 
-  public static void storeUrlAliases(VersionedAccountPreferences prefs,
-      Map<String, String> urlAliases) {
+  public static void storeUrlAliases(
+      VersionedAccountPreferences prefs, Map<String, String> urlAliases) {
     if (urlAliases != null) {
       Config cfg = prefs.getConfig();
       for (String subsection : cfg.getSubsections(URL_ALIAS)) {
@@ -157,19 +168,16 @@
     }
   }
 
-  private void checkDownloadScheme(String downloadScheme)
-      throws BadRequestException {
+  private void checkDownloadScheme(String downloadScheme) throws BadRequestException {
     if (Strings.isNullOrEmpty(downloadScheme)) {
       return;
     }
 
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
-      if (e.getExportName().equals(downloadScheme)
-          && e.getProvider().get().isEnabled()) {
+      if (e.getExportName().equals(downloadScheme) && e.getProvider().get().isEnabled()) {
         return;
       }
     }
-    throw new BadRequestException(
-        "Unsupported download scheme: " + downloadScheme);
+    throw new BadRequestException("Unsupported download scheme: " + downloadScheme);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
index 44a3192..4f00e1a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
@@ -26,22 +26,21 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
-import java.io.IOException;
-
 @Singleton
-public class SshKeys implements
-    ChildCollection<AccountResource, AccountResource.SshKey> {
+public class SshKeys implements ChildCollection<AccountResource, AccountResource.SshKey> {
   private final DynamicMap<RestView<AccountResource.SshKey>> views;
   private final GetSshKeys list;
   private final Provider<CurrentUser> self;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
-  SshKeys(DynamicMap<RestView<AccountResource.SshKey>> views,
-      GetSshKeys list, Provider<CurrentUser> self,
+  SshKeys(
+      DynamicMap<RestView<AccountResource.SshKey>> views,
+      GetSshKeys list,
+      Provider<CurrentUser> self,
       VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.views = views;
     this.list = list;
@@ -56,9 +55,8 @@
 
   @Override
   public AccountResource.SshKey parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException, OrmException, IOException,
-      ConfigInvalidException {
-    if (self.get() != rsrc.getUser()
+      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())
         && !self.get().getCapabilities().canModifyAccount()) {
       throw new ResourceNotFoundException();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
index b71fc68..868d378 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
@@ -38,16 +38,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 @Singleton
-public class StarredChanges implements
-    ChildCollection<AccountResource, AccountResource.StarredChange>,
-    AcceptsCreate<AccountResource> {
+public class StarredChanges
+    implements ChildCollection<AccountResource, AccountResource.StarredChange>,
+        AcceptsCreate<AccountResource> {
   private static final Logger log = LoggerFactory.getLogger(StarredChanges.class);
 
   private final ChangesCollection changes;
@@ -56,7 +54,8 @@
   private final StarredChangesUtil starredChangesUtil;
 
   @Inject
-  StarredChanges(ChangesCollection changes,
+  StarredChanges(
+      ChangesCollection changes,
       DynamicMap<RestView<AccountResource.StarredChange>> views,
       Provider<Create> createProvider,
       StarredChangesUtil starredChangesUtil) {
@@ -71,7 +70,8 @@
       throws ResourceNotFoundException, OrmException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-    if (starredChangesUtil.getLabels(user.getAccountId(), change.getId())
+    if (starredChangesUtil
+        .getLabels(user.getAccountId(), change.getId())
         .contains(StarredChangesUtil.DEFAULT_LABEL)) {
       return new AccountResource.StarredChange(user, change);
     }
@@ -87,8 +87,8 @@
   public RestView<AccountResource> list() throws ResourceNotFoundException {
     return new RestReadView<AccountResource>() {
       @Override
-      public Object apply(AccountResource self) throws BadRequestException,
-          AuthException, OrmException {
+      public Object apply(AccountResource self)
+          throws BadRequestException, AuthException, OrmException {
         QueryChanges query = changes.list();
         query.addQuery("starredby:" + self.getUser().getAccountId().get());
         return query.apply(TopLevelResource.INSTANCE);
@@ -98,11 +98,10 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public RestModifyView<AccountResource, EmptyInput> create(
-      AccountResource parent, IdString id) throws UnprocessableEntityException {
+  public RestModifyView<AccountResource, EmptyInput> create(AccountResource parent, IdString id)
+      throws UnprocessableEntityException {
     try {
-      return createProvider.get()
-          .setChange(changes.parse(TopLevelResource.INSTANCE, id));
+      return createProvider.get().setChange(changes.parse(TopLevelResource.INSTANCE, id));
     } catch (ResourceNotFoundException e) {
       throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
     } catch (OrmException e) {
@@ -131,12 +130,16 @@
     @Override
     public Response<?> apply(AccountResource rsrc, EmptyInput in)
         throws AuthException, OrmException, IOException {
-      if (self.get() != rsrc.getUser()) {
+      if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to add starred change");
       }
       try {
-        starredChangesUtil.star(self.get().getAccountId(), change.getProject(),
-            change.getId(), StarredChangesUtil.DEFAULT_LABELS, null);
+        starredChangesUtil.star(
+            self.get().getAccountId(),
+            change.getProject(),
+            change.getId(),
+            StarredChangesUtil.DEFAULT_LABELS,
+            null);
       } catch (OrmDuplicateKeyException e) {
         return Response.none();
       }
@@ -145,8 +148,7 @@
   }
 
   @Singleton
-  static class Put implements
-      RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  static class Put implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
     private final Provider<CurrentUser> self;
 
     @Inject
@@ -157,7 +159,7 @@
     @Override
     public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
         throws AuthException {
-      if (self.get() != rsrc.getUser()) {
+      if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed update starred changes");
       }
       return Response.none();
@@ -165,8 +167,7 @@
   }
 
   @Singleton
-  public static class Delete implements
-      RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Delete implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -177,18 +178,20 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc,
-        EmptyInput in) throws AuthException, OrmException, IOException {
-      if (self.get() != rsrc.getUser()) {
+    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
+        throws AuthException, OrmException, IOException {
+      if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
       }
-      starredChangesUtil.star(self.get().getAccountId(),
-          rsrc.getChange().getProject(), rsrc.getChange().getId(), null,
+      starredChangesUtil.star(
+          self.get().getAccountId(),
+          rsrc.getChange().getProject(),
+          rsrc.getChange().getId(),
+          null,
           StarredChangesUtil.DEFAULT_LABELS);
       return Response.none();
     }
   }
 
-  public static class EmptyInput {
-  }
+  public static class EmptyInput {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
index fddbb6a..cf43a21 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
@@ -38,15 +38,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.Collection;
 import java.util.List;
 import java.util.Set;
 import java.util.SortedSet;
 
 @Singleton
-public class Stars implements
-    ChildCollection<AccountResource, AccountResource.Star> {
+public class Stars implements ChildCollection<AccountResource, AccountResource.Star> {
 
   private final ChangesCollection changes;
   private final ListStarredChanges listStarredChanges;
@@ -54,7 +52,8 @@
   private final DynamicMap<RestView<AccountResource.Star>> views;
 
   @Inject
-  Stars(ChangesCollection changes,
+  Stars(
+      ChangesCollection changes,
       ListStarredChanges listStarredChanges,
       StarredChangesUtil starredChangesUtil,
       DynamicMap<RestView<AccountResource.Star>> views) {
@@ -69,8 +68,7 @@
       throws ResourceNotFoundException, OrmException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-    Set<String> labels =
-        starredChangesUtil.getLabels(user.getAccountId(), change.getId());
+    Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
     return new AccountResource.Star(user, change, labels);
   }
 
@@ -85,14 +83,12 @@
   }
 
   @Singleton
-  public static class ListStarredChanges
-      implements RestReadView<AccountResource> {
+  public static class ListStarredChanges implements RestReadView<AccountResource> {
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
 
     @Inject
-    ListStarredChanges(Provider<CurrentUser> self,
-        ChangesCollection changes) {
+    ListStarredChanges(Provider<CurrentUser> self, ChangesCollection changes) {
       this.self = self;
       this.changes = changes;
     }
@@ -101,9 +97,8 @@
     @SuppressWarnings("unchecked")
     public List<ChangeInfo> apply(AccountResource rsrc)
         throws BadRequestException, AuthException, OrmException {
-      if (self.get() != rsrc.getUser()) {
-        throw new AuthException(
-            "not allowed to list stars of another account");
+      if (!self.get().hasSameAccountId(rsrc.getUser())) {
+        throw new AuthException("not allowed to list stars of another account");
       }
       QueryChanges query = changes.list();
       query.addQuery("has:stars");
@@ -112,38 +107,32 @@
   }
 
   @Singleton
-  public static class Get implements
-      RestReadView<AccountResource.Star> {
+  public static class Get implements RestReadView<AccountResource.Star> {
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
     @Inject
-    Get(Provider<CurrentUser> self,
-        StarredChangesUtil starredChangesUtil) {
+    Get(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
       this.self = self;
       this.starredChangesUtil = starredChangesUtil;
     }
 
     @Override
-    public SortedSet<String> apply(AccountResource.Star rsrc)
-        throws AuthException, OrmException {
-      if (self.get() != rsrc.getUser()) {
+    public SortedSet<String> apply(AccountResource.Star rsrc) throws AuthException, OrmException {
+      if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to get stars of another account");
       }
-      return starredChangesUtil.getLabels(self.get().getAccountId(),
-          rsrc.getChange().getId());
+      return starredChangesUtil.getLabels(self.get().getAccountId(), rsrc.getChange().getId());
     }
   }
 
   @Singleton
-  public static class Post implements
-      RestModifyView<AccountResource.Star, StarsInput> {
+  public static class Post implements RestModifyView<AccountResource.Star, StarsInput> {
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
     @Inject
-    Post(Provider<CurrentUser> self,
-        StarredChangesUtil starredChangesUtil) {
+    Post(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
       this.self = self;
       this.starredChangesUtil = starredChangesUtil;
     }
@@ -151,13 +140,15 @@
     @Override
     public Collection<String> apply(AccountResource.Star rsrc, StarsInput in)
         throws AuthException, BadRequestException, OrmException {
-      if (self.get() != rsrc.getUser()) {
-        throw new AuthException(
-            "not allowed to update stars of another account");
+      if (!self.get().hasSameAccountId(rsrc.getUser())) {
+        throw new AuthException("not allowed to update stars of another account");
       }
       try {
-        return starredChangesUtil.star(self.get().getAccountId(),
-            rsrc.getChange().getProject(), rsrc.getChange().getId(), in.add,
+        return starredChangesUtil.star(
+            self.get().getAccountId(),
+            rsrc.getChange().getProject(),
+            rsrc.getChange().getId(),
+            in.add,
             in.remove);
       } catch (IllegalLabelException e) {
         throw new BadRequestException(e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 3fccacce..4c66e1b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.server.account;
 
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
+import static java.util.stream.Collectors.joining;
 
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupDescription;
@@ -27,26 +28,26 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StartupCheck;
+import com.google.gerrit.server.StartupException;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
- * Universal implementation of the GroupBackend that works with the injected
- * set of GroupBackends.
+ * Universal implementation of the GroupBackend that works with the injected set of GroupBackends.
  */
 @Singleton
 public class UniversalGroupBackend implements GroupBackend {
-  private static final Logger log =
-      LoggerFactory.getLogger(UniversalGroupBackend.class);
+  private static final Logger log = LoggerFactory.getLogger(UniversalGroupBackend.class);
 
   private final DynamicSet<GroupBackend> backends;
 
@@ -100,46 +101,45 @@
   }
 
   private class UniversalGroupMembership implements GroupMembership {
-   private final Map<GroupBackend, GroupMembership> memberships;
+    private final Map<GroupBackend, GroupMembership> memberships;
 
-   private UniversalGroupMembership(IdentifiedUser user) {
-     ImmutableMap.Builder<GroupBackend, GroupMembership> builder =
-         ImmutableMap.builder();
-     for (GroupBackend g : backends) {
-       builder.put(g, g.membershipsOf(user));
-     }
-     this.memberships = builder.build();
-   }
+    private UniversalGroupMembership(IdentifiedUser user) {
+      ImmutableMap.Builder<GroupBackend, GroupMembership> builder = ImmutableMap.builder();
+      for (GroupBackend g : backends) {
+        builder.put(g, g.membershipsOf(user));
+      }
+      this.memberships = builder.build();
+    }
 
-   @Nullable
-   private GroupMembership membership(AccountGroup.UUID uuid) {
-     if (uuid != null) {
-       for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
-         if (m.getKey().handles(uuid)) {
-           return m.getValue();
-         }
-       }
-     }
-     return null;
-   }
+    @Nullable
+    private GroupMembership membership(AccountGroup.UUID uuid) {
+      if (uuid != null) {
+        for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
+          if (m.getKey().handles(uuid)) {
+            return m.getValue();
+          }
+        }
+      }
+      return null;
+    }
 
-   @Override
-   public boolean contains(AccountGroup.UUID uuid) {
-     if (uuid == null) {
-       return false;
-     }
-     GroupMembership m = membership(uuid);
-     if (m == null) {
-       log.debug("Unknown GroupMembership for UUID: " + uuid);
-       return false;
-     }
-     return m.contains(uuid);
-   }
+    @Override
+    public boolean contains(AccountGroup.UUID uuid) {
+      if (uuid == null) {
+        return false;
+      }
+      GroupMembership m = membership(uuid);
+      if (m == null) {
+        log.debug("Unknown GroupMembership for UUID: " + uuid);
+        return false;
+      }
+      return m.contains(uuid);
+    }
 
     @Override
     public boolean containsAnyOf(Iterable<AccountGroup.UUID> uuids) {
-      Multimap<GroupMembership, AccountGroup.UUID> lookups =
-          ArrayListMultimap.create();
+      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+          MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
@@ -151,8 +151,8 @@
         }
         lookups.put(m, uuid);
       }
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry
-          : lookups .asMap().entrySet()) {
+      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
+          lookups.asMap().entrySet()) {
         GroupMembership m = entry.getKey();
         Collection<AccountGroup.UUID> ids = entry.getValue();
         if (ids.size() == 1) {
@@ -168,8 +168,8 @@
 
     @Override
     public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> uuids) {
-      Multimap<GroupMembership, AccountGroup.UUID> lookups =
-          ArrayListMultimap.create();
+      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+          MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
@@ -182,8 +182,8 @@
         lookups.put(m, uuid);
       }
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry
-          : lookups.asMap().entrySet()) {
+      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
+          lookups.asMap().entrySet()) {
         groups.addAll(entry.getKey().intersection(entry.getValue()));
       }
       return groups;
@@ -208,4 +208,39 @@
     }
     return false;
   }
+
+  public static class ConfigCheck implements StartupCheck {
+    private final Config cfg;
+    private final UniversalGroupBackend universalGroupBackend;
+
+    @Inject
+    ConfigCheck(@GerritServerConfig Config cfg, UniversalGroupBackend groupBackend) {
+      this.cfg = cfg;
+      this.universalGroupBackend = groupBackend;
+    }
+
+    @Override
+    public void check() throws StartupException {
+      String invalid =
+          cfg.getSubsections("groups").stream()
+              .filter(
+                  sub -> {
+                    AccountGroup.UUID uuid = new AccountGroup.UUID(sub);
+                    GroupBackend groupBackend = universalGroupBackend.backend(uuid);
+                    return groupBackend == null || groupBackend.get(uuid) == null;
+                  })
+              .map(u -> "'" + u + "'")
+              .collect(joining(","));
+
+      if (!invalid.isEmpty()) {
+        throw new StartupException(
+            String.format(
+                "Subsections for 'groups' in gerrit.config must be valid group"
+                    + " UUIDs. The following group UUIDs could not be resolved: "
+                    + invalid
+                    + " Please remove/fix these 'groups' subsections in"
+                    + " gerrit.config."));
+      }
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
index 426c6f6..7808edd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -17,19 +17,15 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.DestinationList;
-import com.google.gerrit.server.git.TabFile;
-import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.VersionedMetaData;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.FileMode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
-/** Preferences for user accounts. */
+/** User configured named destinations. */
 public class VersionedAccountDestinations extends VersionedMetaData {
   private static final Logger log = LoggerFactory.getLogger(VersionedAccountDestinations.class);
 
@@ -55,26 +51,26 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
+    if (revision == null) {
+      return;
+    }
     String prefix = DestinationList.DIR_NAME + "/";
     for (PathInfo p : getPathInfos(true)) {
       if (p.fileMode == FileMode.REGULAR_FILE) {
         String path = p.path;
         if (path.startsWith(prefix)) {
           String label = path.substring(prefix.length());
-          ValidationError.Sink errors = TabFile.createLoggerSink(path, log);
-          destinations.parseLabel(label, readUTF8(path), errors);
+          destinations.parseLabel(
+              label,
+              readUTF8(path),
+              error -> log.error("Error parsing file {}: {}", path, error.getMessage()));
         }
       }
     }
   }
 
-  public ValidationError.Sink createSink(String file) {
-    return ValidationError.createLoggerSink(file, log);
-  }
-
   @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     throw new UnsupportedOperationException("Cannot yet save destinations");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
index 2273426..2eb0b54 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
@@ -18,13 +18,11 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.VersionedMetaData;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 
-import java.io.IOException;
-
 /** Preferences for user accounts. */
 public class VersionedAccountPreferences extends VersionedMetaData {
   public static final String PREFERENCES = "preferences.config";
@@ -63,8 +61,7 @@
   }
 
   @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     if (Strings.isNullOrEmpty(commit.getMessage())) {
       commit.setMessage("Updated preferences\n");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountQueries.java
index 2ea6c53..af0463a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountQueries.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountQueries.java
@@ -18,14 +18,12 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.QueryList;
 import com.google.gerrit.server.git.VersionedMetaData;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 /** Named Queries for user accounts. */
 public class VersionedAccountQueries extends VersionedMetaData {
   private static final Logger log = LoggerFactory.getLogger(VersionedAccountQueries.class);
@@ -52,13 +50,15 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
-    queryList = QueryList.parse(readUTF8(QueryList.FILE_NAME),
-        QueryList.createLoggerSink(QueryList.FILE_NAME, log));
+    queryList =
+        QueryList.parse(
+            readUTF8(QueryList.FILE_NAME),
+            error ->
+                log.error("Error parsing file {}: {}", QueryList.FILE_NAME, error.getMessage()));
   }
 
   @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     throw new UnsupportedOperationException("Cannot yet save named queries");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index bb744ce..8cffe92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.errors.InvalidSshKeyException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -36,37 +35,32 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Repository;
 
 /**
- * 'authorized_keys' file in the refs/users/CD/ABCD branches of the All-Users
- * repository.
+ * 'authorized_keys' file in the refs/users/CD/ABCD branches of the All-Users repository.
  *
- * The `authorized_keys' files stores the public SSH keys of the user. The file
- * format matches the standard SSH file format, which means that each key is
- * stored on a separate line (see
+ * <p>The `authorized_keys' files stores the public SSH keys of the user. The file format matches
+ * the standard SSH file format, which means that each key is stored on a separate line (see
  * https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys).
  *
- * The order of the keys in the file determines the sequence numbers of the
- * keys. The first line corresponds to sequence number 1.
+ * <p>The order of the keys in the file determines the sequence numbers of the keys. The first line
+ * corresponds to sequence number 1.
  *
- * Invalid keys are marked with the prefix <code># INVALID</code>.
+ * <p>Invalid keys are marked with the prefix <code># INVALID</code>.
  *
- * To keep the sequence numbers intact when a key is deleted, a
- * <code># DELETED</code> line is inserted at the position where the key was
- * deleted.
+ * <p>To keep the sequence numbers intact when a key is deleted, a <code># DELETED</code> line is
+ * inserted at the position where the key was deleted.
  *
- * Other comment lines are ignored on read, and are not written back when the
- * file is modified.
+ * <p>Other comment lines are ignored on read, and are not written back when the file is modified.
  */
 public class VersionedAuthorizedKeys extends VersionedMetaData {
   @Singleton
@@ -128,17 +122,17 @@
     private VersionedAuthorizedKeys read(Account.Id accountId)
         throws IOException, ConfigInvalidException {
       try (Repository git = repoManager.openRepository(allUsersName)) {
-        VersionedAuthorizedKeys authorizedKeys =
-            authorizedKeysFactory.create(accountId);
+        VersionedAuthorizedKeys authorizedKeys = authorizedKeysFactory.create(accountId);
         authorizedKeys.load(git);
         return authorizedKeys;
       }
     }
 
-    private void commit(VersionedAuthorizedKeys authorizedKeys)
-        throws IOException {
-      try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName,
-          userFactory.create(authorizedKeys.accountId))) {
+    private void commit(VersionedAuthorizedKeys authorizedKeys) throws IOException {
+      try (MetaDataUpdate md =
+          metaDataUpdateFactory
+              .get()
+              .create(allUsersName, userFactory.create(authorizedKeys.accountId))) {
         authorizedKeys.commit(md);
       }
     }
@@ -161,9 +155,7 @@
   private List<Optional<AccountSshKey>> keys;
 
   @Inject
-  public VersionedAuthorizedKeys(
-      SshKeyCreator sshKeyCreator,
-      @Assisted Account.Id accountId) {
+  public VersionedAuthorizedKeys(SshKeyCreator sshKeyCreator, @Assisted Account.Id accountId) {
     this.sshKeyCreator = sshKeyCreator;
     this.accountId = accountId;
     this.ref = RefNames.refsUsers(accountId);
@@ -192,27 +184,25 @@
   /** Returns all SSH keys. */
   private List<AccountSshKey> getKeys() {
     checkLoaded();
-    return Lists.newArrayList(Optional.presentInstances(keys));
+    return keys.stream().filter(Optional::isPresent).map(Optional::get).collect(toList());
   }
 
   /**
    * Returns the SSH key with the given sequence number.
    *
    * @param seq sequence number
-   * @return the SSH key, <code>null</code> if there is no SSH key with this
-   *         sequence number, or if the SSH key with this sequence number has
-   *         been deleted
+   * @return the SSH key, <code>null</code> if there is no SSH key with this sequence number, or if
+   *     the SSH key with this sequence number has been deleted
    */
   private AccountSshKey getKey(int seq) {
     checkLoaded();
-    Optional<AccountSshKey> key = keys.get(seq - 1);
-    return key.orNull();
+    return keys.get(seq - 1).orElse(null);
   }
 
   /**
    * Adds a new public SSH key.
    *
-   * If the specified public key exists already, the existing key is returned.
+   * <p>If the specified public key exists already, the existing key is returned.
    *
    * @param pub the public SSH key to be added
    * @return the new SSH key
@@ -222,8 +212,7 @@
     checkLoaded();
 
     for (Optional<AccountSshKey> key : keys) {
-      if (key.isPresent()
-          && key.get().getSshPublicKey().trim().equals(pub.trim())) {
+      if (key.isPresent() && key.get().getSshPublicKey().trim().equals(pub.trim())) {
         return key.get();
       }
     }
@@ -239,14 +228,13 @@
    * Deletes the SSH key with the given sequence number.
    *
    * @param seq the sequence number
-   * @return <code>true</code> if a key with this sequence number was found and
-   *         deleted, <code>false</code> if no key with the given sequence
-   *         number exists
+   * @return <code>true</code> if a key with this sequence number was found and deleted, <code>false
+   *     </code> if no key with the given sequence number exists
    */
   private boolean deleteKey(int seq) {
     checkLoaded();
     if (seq <= keys.size() && keys.get(seq - 1).isPresent()) {
-      keys.set(seq - 1, Optional.<AccountSshKey> absent());
+      keys.set(seq - 1, Optional.empty());
       return true;
     }
     return false;
@@ -256,9 +244,9 @@
    * Marks the SSH key with the given sequence number as invalid.
    *
    * @param seq the sequence number
-   * @return <code>true</code> if a key with this sequence number was found and
-   *         marked as invalid, <code>false</code> if no key with the given
-   *         sequence number exists or if the key was already marked as invalid
+   * @return <code>true</code> if a key with this sequence number was found and marked as invalid,
+   *     <code>false</code> if no key with the given sequence number exists or if the key was
+   *     already marked as invalid
    */
   private boolean markKeyInvalid(int seq) {
     checkLoaded();
@@ -273,20 +261,13 @@
   /**
    * Sets new SSH keys.
    *
-   * The existing SSH keys are overwritten.
+   * <p>The existing SSH keys are overwritten.
    *
    * @param newKeys the new public SSH keys
    */
   public void setKeys(Collection<AccountSshKey> newKeys) {
-    Ordering<AccountSshKey> o =
-        Ordering.natural().onResultOf(new Function<AccountSshKey, Integer>() {
-          @Override
-          public Integer apply(AccountSshKey sshKey) {
-            return sshKey.getKey().get();
-          }
-        });
-    keys = new ArrayList<>(Collections.nCopies(o.max(newKeys).getKey().get(),
-        Optional.<AccountSshKey> absent()));
+    Ordering<AccountSshKey> o = Ordering.from(comparing(k -> k.getKey().get()));
+    keys = new ArrayList<>(Collections.nCopies(o.max(newKeys).getKey().get(), Optional.empty()));
     for (AccountSshKey key : newKeys) {
       keys.set(key.getKey().get() - 1, Optional.of(key));
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
index f24ef2e..667ca37 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
@@ -21,18 +21,15 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Enums;
 import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
@@ -44,12 +41,6 @@
 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.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -58,18 +49,22 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
 
 /**
- * ‘watch.config’ file in the user branch in the All-Users repository that
- * contains the watch configuration of the user.
- * <p>
- * The 'watch.config' file is a git config file that has one 'project' section
- * for all project watches of a project.
- * <p>
- * The project name is used as subsection name and the filters with the notify
- * types that decide for which events email notifications should be sent are
- * represented as 'notify' values in the subsection. A 'notify' value is
- * formatted as {@code <filter> [<comma-separated-list-of-notify-types>]}:
+ * ‘watch.config’ file in the user branch in the All-Users repository that contains the watch
+ * configuration of the user.
+ *
+ * <p>The 'watch.config' file is a git config file that has one 'project' section for all project
+ * watches of a project.
+ *
+ * <p>The project name is used as subsection name and the filters with the notify types that decide
+ * for which events email notifications should be sent are represented as 'notify' values in the
+ * subsection. A 'notify' value is formatted as {@code <filter>
+ * [<comma-separated-list-of-notify-types>]}:
  *
  * <pre>
  *   [project "foo"]
@@ -77,22 +72,20 @@
  *     notify = branch:master [ALL_COMMENTS, NEW_PATCHSETS]
  *     notify = branch:master owner:self [SUBMITTED_CHANGES]
  * </pre>
- * <p>
- * If two notify values in the same subsection have the same filter they are
- * merged on the next save, taking the union of the notify types.
- * <p>
- * For watch configurations that notify on no event the list of notify types is
- * empty:
+ *
+ * <p>If two notify values in the same subsection have the same filter they are merged on the next
+ * save, taking the union of the notify types.
+ *
+ * <p>For watch configurations that notify on no event the list of notify types is empty:
  *
  * <pre>
  *   [project "foo"]
  *     notify = branch:master []
  * </pre>
- * <p>
- * Unknown notify types are ignored and removed on save.
+ *
+ * <p>Unknown notify types are ignored and removed on save.
  */
-public class WatchConfig extends VersionedMetaData
-    implements ValidationError.Sink {
+public class WatchConfig extends VersionedMetaData implements ValidationError.Sink {
   @Singleton
   public static class Accessor {
     private final GitRepositoryManager repoManager;
@@ -112,8 +105,8 @@
       this.userFactory = userFactory;
     }
 
-    public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches(
-        Account.Id accountId) throws IOException, ConfigInvalidException {
+    public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches(Account.Id accountId)
+        throws IOException, ConfigInvalidException {
       try (Repository git = repoManager.openRepository(allUsersName)) {
         WatchConfig watchConfig = new WatchConfig(accountId);
         watchConfig.load(git);
@@ -121,22 +114,20 @@
       }
     }
 
-    public synchronized void upsertProjectWatches(Account.Id accountId,
-        Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches)
+    public synchronized void upsertProjectWatches(
+        Account.Id accountId, Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches)
         throws IOException, ConfigInvalidException {
       WatchConfig watchConfig = read(accountId);
-      Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
-          watchConfig.getProjectWatches();
+      Map<ProjectWatchKey, Set<NotifyType>> projectWatches = watchConfig.getProjectWatches();
       projectWatches.putAll(newProjectWatches);
       commit(watchConfig);
     }
 
-    public synchronized void deleteProjectWatches(Account.Id accountId,
-        Collection<ProjectWatchKey> projectWatchKeys)
-            throws IOException, ConfigInvalidException {
+    public synchronized void deleteProjectWatches(
+        Account.Id accountId, Collection<ProjectWatchKey> projectWatchKeys)
+        throws IOException, ConfigInvalidException {
       WatchConfig watchConfig = read(accountId);
-      Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
-          watchConfig.getProjectWatches();
+      Map<ProjectWatchKey, Set<NotifyType>> projectWatches = watchConfig.getProjectWatches();
       boolean commit = false;
       for (ProjectWatchKey key : projectWatchKeys) {
         if (projectWatches.remove(key) != null) {
@@ -161,8 +152,7 @@
       }
     }
 
-    private WatchConfig read(Account.Id accountId)
-        throws IOException, ConfigInvalidException {
+    private WatchConfig read(Account.Id accountId) throws IOException, ConfigInvalidException {
       try (Repository git = repoManager.openRepository(allUsersName)) {
         WatchConfig watchConfig = new WatchConfig(accountId);
         watchConfig.load(git);
@@ -170,10 +160,11 @@
       }
     }
 
-    private void commit(WatchConfig watchConfig)
-        throws IOException {
-      try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName,
-          userFactory.create(watchConfig.accountId))) {
+    private void commit(WatchConfig watchConfig) throws IOException {
+      try (MetaDataUpdate md =
+          metaDataUpdateFactory
+              .get()
+              .create(allUsersName, userFactory.create(watchConfig.accountId))) {
         watchConfig.commit(md);
       }
     }
@@ -181,16 +172,28 @@
 
   @AutoValue
   public abstract static class ProjectWatchKey {
-    public static ProjectWatchKey create(Project.NameKey project,
-        @Nullable String filter) {
-      return new AutoValue_WatchConfig_ProjectWatchKey(project,
-          Strings.emptyToNull(filter));
+    public static ProjectWatchKey create(Project.NameKey project, @Nullable String filter) {
+      return new AutoValue_WatchConfig_ProjectWatchKey(project, Strings.emptyToNull(filter));
     }
 
     public abstract Project.NameKey project();
+
     public abstract @Nullable String filter();
   }
 
+  public enum NotifyType {
+    // sort by name, except 'ALL' which should stay last
+    ABANDONED_CHANGES,
+    ALL_COMMENTS,
+    NEW_CHANGES,
+    NEW_PATCHSETS,
+    SUBMITTED_CHANGES,
+
+    ALL
+  }
+
+  public static final String FILTER_ALL = "*";
+
   public static final String WATCH_CONFIG = "watch.config";
   public static final String PROJECT = "project";
   public static final String KEY_NOTIFY = "notify";
@@ -219,12 +222,10 @@
 
   @VisibleForTesting
   public static Map<ProjectWatchKey, Set<NotifyType>> parse(
-      Account.Id accountId, Config cfg,
-      ValidationError.Sink validationErrorSink) {
+      Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
     Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
     for (String projectName : cfg.getSubsections(PROJECT)) {
-      String[] notifyValues =
-          cfg.getStringList(PROJECT, projectName, KEY_NOTIFY);
+      String[] notifyValues = cfg.getStringList(PROJECT, projectName, KEY_NOTIFY);
       for (String nv : notifyValues) {
         if (Strings.isNullOrEmpty(nv)) {
           continue;
@@ -236,8 +237,8 @@
           continue;
         }
 
-        ProjectWatchKey key = ProjectWatchKey
-            .create(new Project.NameKey(projectName), notifyValue.filter());
+        ProjectWatchKey key =
+            ProjectWatchKey.create(new Project.NameKey(projectName), notifyValue.filter());
         if (!projectWatches.containsKey(key)) {
           projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
         }
@@ -252,9 +253,12 @@
     return projectWatches;
   }
 
+  public void setProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+    this.projectWatches = projectWatches;
+  }
+
   @Override
-  protected boolean onSave(CommitBuilder commit)
-      throws IOException, ConfigInvalidException {
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
 
     if (Strings.isNullOrEmpty(commit.getMessage())) {
@@ -264,22 +268,18 @@
     Config cfg = readConfig(WATCH_CONFIG);
 
     for (String projectName : cfg.getSubsections(PROJECT)) {
-      cfg.unset(PROJECT, projectName, KEY_NOTIFY);
+      cfg.unsetSection(PROJECT, projectName);
     }
 
-    Multimap<String, String> notifyValuesByProject = ArrayListMultimap.create();
-    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches
-        .entrySet()) {
-      NotifyValue notifyValue =
-          NotifyValue.create(e.getKey().filter(), e.getValue());
-      notifyValuesByProject.put(e.getKey().project().get(),
-          notifyValue.toString());
+    ListMultimap<String, String> notifyValuesByProject =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches.entrySet()) {
+      NotifyValue notifyValue = NotifyValue.create(e.getKey().filter(), e.getValue());
+      notifyValuesByProject.put(e.getKey().project().get(), notifyValue.toString());
     }
 
-    for (Map.Entry<String, Collection<String>> e : notifyValuesByProject.asMap()
-        .entrySet()) {
-      cfg.setStringList(PROJECT, e.getKey(), KEY_NOTIFY,
-          new ArrayList<>(e.getValue()));
+    for (Map.Entry<String, Collection<String>> e : notifyValuesByProject.asMap().entrySet()) {
+      cfg.setStringList(PROJECT, e.getKey(), KEY_NOTIFY, new ArrayList<>(e.getValue()));
     }
 
     saveConfig(WATCH_CONFIG, cfg);
@@ -312,57 +312,64 @@
 
   @AutoValue
   public abstract static class NotifyValue {
-    public static NotifyValue parse(Account.Id accountId, String project,
-        String notifyValue, ValidationError.Sink validationErrorSink) {
+    public static NotifyValue parse(
+        Account.Id accountId,
+        String project,
+        String notifyValue,
+        ValidationError.Sink validationErrorSink) {
       notifyValue = notifyValue.trim();
       int i = notifyValue.lastIndexOf('[');
       if (i < 0 || notifyValue.charAt(notifyValue.length() - 1) != ']') {
-        validationErrorSink.error(new ValidationError(WATCH_CONFIG,
-            String.format(
-                "Invalid project watch of account %d for project %s: %s",
-                accountId.get(), project, notifyValue)));
+        validationErrorSink.error(
+            new ValidationError(
+                WATCH_CONFIG,
+                String.format(
+                    "Invalid project watch of account %d for project %s: %s",
+                    accountId.get(), project, notifyValue)));
         return null;
       }
       String filter = notifyValue.substring(0, i).trim();
-      if (filter.isEmpty() || AccountProjectWatch.FILTER_ALL.equals(filter)) {
+      if (filter.isEmpty() || FILTER_ALL.equals(filter)) {
         filter = null;
       }
 
       Set<NotifyType> notifyTypes = EnumSet.noneOf(NotifyType.class);
       if (i + 1 < notifyValue.length() - 2) {
-        for (String nt : Splitter.on(',').trimResults().splitToList(
-            notifyValue.substring(i + 1, notifyValue.length() - 1))) {
-          Optional<NotifyType> notifyType =
-              Enums.getIfPresent(NotifyType.class, nt);
-          if (!notifyType.isPresent()) {
-            validationErrorSink.error(new ValidationError(WATCH_CONFIG,
-                String.format(
-                    "Invalid notify type %s in project watch "
-                        + "of account %d for project %s: %s",
-                    nt, accountId.get(), project, notifyValue)));
+        for (String nt :
+            Splitter.on(',')
+                .trimResults()
+                .splitToList(notifyValue.substring(i + 1, notifyValue.length() - 1))) {
+          NotifyType notifyType = Enums.getIfPresent(NotifyType.class, nt).orNull();
+          if (notifyType == null) {
+            validationErrorSink.error(
+                new ValidationError(
+                    WATCH_CONFIG,
+                    String.format(
+                        "Invalid notify type %s in project watch "
+                            + "of account %d for project %s: %s",
+                        nt, accountId.get(), project, notifyValue)));
             continue;
           }
-          notifyTypes.add(notifyType.get());
+          notifyTypes.add(notifyType);
         }
       }
       return create(filter, notifyTypes);
     }
 
-    public static NotifyValue create(@Nullable String filter,
-        Set<NotifyType> notifyTypes) {
-      return new AutoValue_WatchConfig_NotifyValue(Strings.emptyToNull(filter),
-          Sets.immutableEnumSet(notifyTypes));
+    public static NotifyValue create(@Nullable String filter, Set<NotifyType> notifyTypes) {
+      return new AutoValue_WatchConfig_NotifyValue(
+          Strings.emptyToNull(filter), Sets.immutableEnumSet(notifyTypes));
     }
 
     public abstract @Nullable String filter();
+
     public abstract ImmutableSet<NotifyType> notifyTypes();
 
     @Override
     public String toString() {
       List<NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
       StringBuilder notifyValue = new StringBuilder();
-      notifyValue.append(firstNonNull(filter(), AccountProjectWatch.FILTER_ALL))
-          .append(" [");
+      notifyValue.append(firstNonNull(filter(), FILTER_ALL)).append(" [");
       Joiner.on(", ").appendTo(notifyValue, notifyTypes);
       notifyValue.append("]");
       return notifyValue.toString();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java
index 577abe1..6241276 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java
@@ -32,11 +32,8 @@
   private final Projects projects;
 
   @Inject
-  GerritApiImpl(Accounts accounts,
-      Changes changes,
-      Config config,
-      Groups groups,
-      Projects projects) {
+  GerritApiImpl(
+      Accounts accounts, Changes changes, Config config, Groups groups, Projects projects) {
     this.accounts = accounts;
     this.changes = changes;
     this.config = config;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 1bca929..430b6b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.accounts;
 
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
@@ -24,13 +26,16 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.common.AgreementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.GpgException;
@@ -38,18 +43,26 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AddSshKey;
 import com.google.gerrit.server.account.CreateEmail;
+import com.google.gerrit.server.account.DeleteActive;
+import com.google.gerrit.server.account.DeleteEmail;
+import com.google.gerrit.server.account.DeleteExternalIds;
 import com.google.gerrit.server.account.DeleteSshKey;
 import com.google.gerrit.server.account.DeleteWatchedProjects;
+import com.google.gerrit.server.account.GetActive;
 import com.google.gerrit.server.account.GetAgreements;
 import com.google.gerrit.server.account.GetAvatar;
 import com.google.gerrit.server.account.GetDiffPreferences;
 import com.google.gerrit.server.account.GetEditPreferences;
+import com.google.gerrit.server.account.GetEmails;
+import com.google.gerrit.server.account.GetExternalIds;
 import com.google.gerrit.server.account.GetPreferences;
 import com.google.gerrit.server.account.GetSshKeys;
 import com.google.gerrit.server.account.GetWatchedProjects;
 import com.google.gerrit.server.account.Index;
 import com.google.gerrit.server.account.PostWatchedProjects;
+import com.google.gerrit.server.account.PutActive;
 import com.google.gerrit.server.account.PutAgreement;
+import com.google.gerrit.server.account.PutStatus;
 import com.google.gerrit.server.account.SetDiffPreferences;
 import com.google.gerrit.server.account.SetEditPreferences;
 import com.google.gerrit.server.account.SetPreferences;
@@ -61,13 +74,11 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.SortedSet;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class AccountApiImpl implements AccountApi {
   interface Factory {
@@ -92,7 +103,9 @@
   private final Stars stars;
   private final Stars.Get starsGet;
   private final Stars.Post starsPost;
+  private final GetEmails getEmails;
   private final CreateEmail.Factory createEmailFactory;
+  private final DeleteEmail deleteEmail;
   private final GpgApiAdapter gpgApiAdapter;
   private final GetSshKeys getSshKeys;
   private final AddSshKey addSshKey;
@@ -100,10 +113,17 @@
   private final SshKeys sshKeys;
   private final GetAgreements getAgreements;
   private final PutAgreement putAgreement;
+  private final GetActive getActive;
+  private final PutActive putActive;
+  private final DeleteActive deleteActive;
   private final Index index;
+  private final GetExternalIds getExternalIds;
+  private final DeleteExternalIds deleteExternalIds;
+  private final PutStatus putStatus;
 
   @Inject
-  AccountApiImpl(AccountLoader.Factory ailf,
+  AccountApiImpl(
+      AccountLoader.Factory ailf,
       ChangesCollection changes,
       GetAvatar getAvatar,
       GetPreferences getPreferences,
@@ -120,7 +140,9 @@
       Stars stars,
       Stars.Get starsGet,
       Stars.Post starsPost,
+      GetEmails getEmails,
       CreateEmail.Factory createEmailFactory,
+      DeleteEmail deleteEmail,
       GpgApiAdapter gpgApiAdapter,
       GetSshKeys getSshKeys,
       AddSshKey addSshKey,
@@ -128,7 +150,13 @@
       SshKeys sshKeys,
       GetAgreements getAgreements,
       PutAgreement putAgreement,
+      GetActive getActive,
+      PutActive putActive,
+      DeleteActive deleteActive,
       Index index,
+      GetExternalIds getExternalIds,
+      DeleteExternalIds deleteExternalIds,
+      PutStatus putStatus,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
@@ -148,7 +176,9 @@
     this.stars = stars;
     this.starsGet = starsGet;
     this.starsPost = starsPost;
+    this.getEmails = getEmails;
     this.createEmailFactory = createEmailFactory;
+    this.deleteEmail = deleteEmail;
     this.getSshKeys = getSshKeys;
     this.addSshKey = addSshKey;
     this.deleteSshKey = deleteSshKey;
@@ -156,12 +186,17 @@
     this.gpgApiAdapter = gpgApiAdapter;
     this.getAgreements = getAgreements;
     this.putAgreement = putAgreement;
+    this.getActive = getActive;
+    this.putActive = putActive;
+    this.deleteActive = deleteActive;
     this.index = index;
+    this.getExternalIds = getExternalIds;
+    this.deleteExternalIds = deleteExternalIds;
+    this.putStatus = putStatus;
   }
 
   @Override
-  public com.google.gerrit.extensions.common.AccountInfo get()
-      throws RestApiException {
+  public com.google.gerrit.extensions.common.AccountInfo get() throws RestApiException {
     AccountLoader accountLoader = accountLoaderFactory.create(true);
     try {
       AccountInfo ai = accountLoader.get(account.getUser().getAccountId());
@@ -173,6 +208,25 @@
   }
 
   @Override
+  public boolean getActive() throws RestApiException {
+    Response<String> result = getActive.apply(account);
+    return result.statusCode() == SC_OK && result.value().equals("ok");
+  }
+
+  @Override
+  public void setActive(boolean active) throws RestApiException {
+    try {
+      if (active) {
+        putActive.apply(account, new PutActive.Input());
+      } else {
+        deleteActive.apply(account, new DeleteActive.Input());
+      }
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot set active", e);
+    }
+  }
+
+  @Override
   public String getAvatarUrl(int size) throws RestApiException {
     getAvatar.setSize(size);
     return getAvatar.apply(account).location();
@@ -184,8 +238,7 @@
   }
 
   @Override
-  public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in)
-      throws RestApiException {
+  public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException {
     try {
       return setPreferences.apply(account, in);
     } catch (IOException | ConfigInvalidException e) {
@@ -203,8 +256,7 @@
   }
 
   @Override
-  public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in)
-      throws RestApiException {
+  public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
     try {
       return setDiffPreferences.apply(account, in);
     } catch (IOException | ConfigInvalidException e) {
@@ -222,8 +274,7 @@
   }
 
   @Override
-  public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in)
-      throws RestApiException {
+  public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
     try {
       return setEditPreferences.apply(account, in);
     } catch (IOException | ConfigInvalidException e) {
@@ -241,8 +292,8 @@
   }
 
   @Override
-  public List<ProjectWatchInfo> setWatchedProjects(
-      List<ProjectWatchInfo> in) throws RestApiException {
+  public List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in)
+      throws RestApiException {
     try {
       return postWatchedProjects.apply(account, in);
     } catch (OrmException | IOException | ConfigInvalidException e) {
@@ -251,8 +302,7 @@
   }
 
   @Override
-  public void deleteWatchedProjects(List<ProjectWatchInfo> in)
-      throws RestApiException {
+  public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException {
     try {
       deleteWatchedProjects.apply(account, in);
     } catch (OrmException | IOException | ConfigInvalidException e) {
@@ -263,9 +313,7 @@
   @Override
   public void starChange(String changeId) throws RestApiException {
     try {
-      ChangeResource rsrc = changes.parse(
-        TopLevelResource.INSTANCE,
-        IdString.fromUrl(changeId));
+      ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       starredChangesCreate.setChange(rsrc);
       starredChangesCreate.apply(account, new StarredChanges.EmptyInput());
     } catch (OrmException | IOException e) {
@@ -276,23 +324,19 @@
   @Override
   public void unstarChange(String changeId) throws RestApiException {
     try {
-      ChangeResource rsrc =
-          changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
+      ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
-      starredChangesDelete.apply(starredChange,
-          new StarredChanges.EmptyInput());
+      starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot unstar change", e);
     }
   }
 
   @Override
-  public void setStars(String changeId, StarsInput input)
-      throws RestApiException {
+  public void setStars(String changeId, StarsInput input) throws RestApiException {
     try {
-      AccountResource.Star rsrc =
-          stars.parse(account, IdString.fromUrl(changeId));
+      AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
       starsPost.apply(rsrc, input);
     } catch (OrmException e) {
       throw new RestApiException("Cannot post stars", e);
@@ -302,8 +346,7 @@
   @Override
   public SortedSet<String> getStars(String changeId) throws RestApiException {
     try {
-      AccountResource.Star rsrc =
-          stars.parse(account, IdString.fromUrl(changeId));
+      AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
       return starsGet.apply(rsrc);
     } catch (OrmException e) {
       throw new RestApiException("Cannot get stars", e);
@@ -320,17 +363,41 @@
   }
 
   @Override
+  public List<EmailInfo> getEmails() {
+    return getEmails.apply(account);
+  }
+
+  @Override
   public void addEmail(EmailInput input) throws RestApiException {
-    AccountResource.Email rsrc =
-        new AccountResource.Email(account.getUser(), input.email);
+    AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
       createEmailFactory.create(input.email).apply(rsrc, input);
-    } catch (EmailException | OrmException | IOException e) {
+    } catch (EmailException | OrmException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot add email", e);
     }
   }
 
   @Override
+  public void deleteEmail(String email) throws RestApiException {
+    AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), email);
+    try {
+      deleteEmail.apply(rsrc, null);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot delete email", e);
+    }
+  }
+
+  @Override
+  public void setStatus(String status) throws RestApiException {
+    PutStatus.Input in = new PutStatus.Input(status);
+    try {
+      putStatus.apply(account, in);
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot set status", e);
+    }
+  }
+
+  @Override
   public List<SshKeyInfo> listSshKeys() throws RestApiException {
     try {
       return getSshKeys.apply(account);
@@ -371,8 +438,8 @@
   }
 
   @Override
-  public Map<String, GpgKeyInfo> putGpgKeys(List<String> add,
-      List<String> delete) throws RestApiException {
+  public Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> delete)
+      throws RestApiException {
     try {
       return gpgApiAdapter.putGpgKeys(account, add, delete);
     } catch (GpgException e) {
@@ -409,8 +476,26 @@
   public void index() throws RestApiException {
     try {
       index.apply(account, new Index.Input());
-    } catch (IOException | OrmException e) {
+    } catch (IOException e) {
       throw new RestApiException("Cannot index account", e);
     }
   }
+
+  @Override
+  public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
+    try {
+      return getExternalIds.apply(account);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get external IDs", e);
+    }
+  }
+
+  @Override
+  public void deleteExternalIds(List<String> externalIds) throws RestApiException {
+    try {
+      deleteExternalIds.apply(account, externalIds);
+    } catch (IOException | OrmException | ConfigInvalidException e) {
+      throw new RestApiException("Cannot delete external IDs", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
index a0b9b4e..2d90853 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
@@ -15,23 +15,18 @@
 package com.google.gerrit.server.api.accounts;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-
+import com.google.gerrit.server.account.ExternalId;
 import java.util.List;
 
 public interface AccountExternalIdCreator {
 
   /**
-   * Returns additional external identifiers to assign to a given
-   * user when creating an account.
+   * Returns additional external identifiers to assign to a given user when creating an account.
    *
    * @param id the identifier of the account.
    * @param username the name of the user.
-   * @param email an optional email address to assign to the external
-   * identifiers, or {@code null}.
-   *
+   * @param email an optional email address to assign to the external identifiers, or {@code null}.
    * @return a list of external identifiers, or an empty list.
    */
-  List<AccountExternalId> create(Account.Id id, String username,
-      String email);
+  List<ExternalId> create(Account.Id id, String username, String email);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
index f89e5ca..7c468fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
@@ -17,20 +17,17 @@
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.extensions.common.AccountInfo;
-
 import java.util.Comparator;
 
 public class AccountInfoComparator extends Ordering<AccountInfo>
     implements Comparator<AccountInfo> {
-  public static final AccountInfoComparator ORDER_NULLS_FIRST =
-      new AccountInfoComparator();
+  public static final AccountInfoComparator ORDER_NULLS_FIRST = new AccountInfoComparator();
   public static final AccountInfoComparator ORDER_NULLS_LAST =
       new AccountInfoComparator().setNullsLast();
 
   private boolean nullsLast;
 
-  private AccountInfoComparator() {
-  }
+  private AccountInfoComparator() {}
 
   private AccountInfoComparator setNullsLast() {
     this.nullsLast = true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 6a248f7..498b720 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -36,11 +36,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class AccountsImpl implements Accounts {
@@ -51,7 +49,8 @@
   private final Provider<QueryAccounts> queryAccountsProvider;
 
   @Inject
-  AccountsImpl(AccountsCollection accounts,
+  AccountsImpl(
+      AccountsCollection accounts,
       AccountApiImpl.Factory api,
       Provider<CurrentUser> self,
       CreateAccount.Factory createAccount,
@@ -66,8 +65,7 @@
   @Override
   public AccountApi id(String id) throws RestApiException {
     try {
-      return api.create(accounts.parse(TopLevelResource.INSTANCE,
-          IdString.fromDecoded(id)));
+      return api.create(accounts.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
     } catch (OrmException e) {
       throw new RestApiException("Cannot parse change", e);
     }
@@ -100,8 +98,8 @@
     }
     checkRequiresCapability(self, null, CreateAccount.class);
     try {
-      AccountInfo info = createAccount.create(in.username)
-          .apply(TopLevelResource.INSTANCE, in).value();
+      AccountInfo info =
+          createAccount.create(in.username).apply(TopLevelResource.INSTANCE, in).value();
       return id(info._accountId);
     } catch (OrmException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot create account " + in.username, e);
@@ -119,13 +117,11 @@
   }
 
   @Override
-  public SuggestAccountsRequest suggestAccounts(String query)
-    throws RestApiException {
+  public SuggestAccountsRequest suggestAccounts(String query) throws RestApiException {
     return suggestAccounts().withQuery(query);
   }
 
-  private List<AccountInfo> suggestAccounts(SuggestAccountsRequest r)
-    throws RestApiException {
+  private List<AccountInfo> suggestAccounts(SuggestAccountsRequest r) throws RestApiException {
     try {
       QueryAccounts myQueryAccounts = queryAccountsProvider.get();
       myQueryAccounts.setSuggest(true);
@@ -152,8 +148,7 @@
     return query().withQuery(query);
   }
 
-  private List<AccountInfo> query(QueryRequest r)
-    throws RestApiException {
+  private List<AccountInfo> query(QueryRequest r) throws RestApiException {
     try {
       QueryAccounts myQueryAccounts = queryAccountsProvider.get();
       myQueryAccounts.setQuery(r.getQuery());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
index a83110c..7def6fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
-
 import java.util.List;
 import java.util.Map;
 
@@ -32,12 +31,11 @@
   Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
       throws RestApiException, GpgException;
 
-  Map<String, GpgKeyInfo> putGpgKeys(AccountResource account, List<String> add,
-      List<String> delete) throws RestApiException, GpgException;
-
-  GpgKeyApi gpgKey(AccountResource account, IdString idStr)
+  Map<String, GpgKeyInfo> putGpgKeys(AccountResource account, List<String> add, List<String> delete)
       throws RestApiException, GpgException;
 
-  PushCertificateInfo checkPushCertificate(String certStr,
-      IdentifiedUser expectedUser) throws GpgException;
+  GpgKeyApi gpgKey(AccountResource account, IdString idStr) throws RestApiException, GpgException;
+
+  PushCertificateInfo checkPushCertificate(String certStr, IdentifiedUser expectedUser)
+      throws GpgException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 9bfb342..a0babe1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -14,13 +14,18 @@
 
 package com.google.gerrit.server.api.changes;
 
+import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.changes.MoveInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
@@ -28,41 +33,51 @@
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.Abandon;
-import com.google.gerrit.server.change.ChangeEdits;
+import com.google.gerrit.server.change.ChangeIncludedIn;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.Check;
-import com.google.gerrit.server.change.DeleteDraftChange;
+import com.google.gerrit.server.change.CreateMergePatchSet;
+import com.google.gerrit.server.change.DeleteAssignee;
+import com.google.gerrit.server.change.DeleteChange;
+import com.google.gerrit.server.change.GetAssignee;
 import com.google.gerrit.server.change.GetHashtags;
+import com.google.gerrit.server.change.GetPastAssignees;
 import com.google.gerrit.server.change.GetTopic;
 import com.google.gerrit.server.change.Index;
 import com.google.gerrit.server.change.ListChangeComments;
 import com.google.gerrit.server.change.ListChangeDrafts;
+import com.google.gerrit.server.change.ListChangeRobotComments;
 import com.google.gerrit.server.change.Move;
 import com.google.gerrit.server.change.PostHashtags;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.PublishDraftPatchSet;
+import com.google.gerrit.server.change.PutAssignee;
 import com.google.gerrit.server.change.PutTopic;
+import com.google.gerrit.server.change.Rebase;
 import com.google.gerrit.server.change.Restore;
 import com.google.gerrit.server.change.Revert;
 import com.google.gerrit.server.change.Reviewers;
 import com.google.gerrit.server.change.Revisions;
 import com.google.gerrit.server.change.SubmittedTogether;
 import com.google.gerrit.server.change.SuggestChangeReviewers;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
 import java.io.IOException;
 import java.util.EnumSet;
 import java.util.List;
@@ -84,25 +99,33 @@
   private final Abandon abandon;
   private final Revert revert;
   private final Restore restore;
-  private final SubmittedTogether submittedTogether;
-  private final PublishDraftPatchSet.CurrentRevision
-    publishDraftChange;
-  private final DeleteDraftChange deleteDraftChange;
+  private final CreateMergePatchSet updateByMerge;
+  private final Provider<SubmittedTogether> submittedTogether;
+  private final PublishDraftPatchSet.CurrentRevision publishDraftChange;
+  private final Rebase.CurrentRevision rebase;
+  private final DeleteChange deleteChange;
   private final GetTopic getTopic;
   private final PutTopic putTopic;
+  private final ChangeIncludedIn includedIn;
   private final PostReviewers postReviewers;
   private final ChangeJson.Factory changeJson;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
+  private final PutAssignee putAssignee;
+  private final GetAssignee getAssignee;
+  private final GetPastAssignees getPastAssignees;
+  private final DeleteAssignee deleteAssignee;
   private final ListChangeComments listComments;
+  private final ListChangeRobotComments listChangeRobotComments;
   private final ListChangeDrafts listDrafts;
+  private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
   private final Index index;
-  private final ChangeEdits.Detail editDetail;
   private final Move move;
 
   @Inject
-  ChangeApiImpl(Changes changeApi,
+  ChangeApiImpl(
+      Changes changeApi,
       Reviewers reviewers,
       Revisions revisions,
       ReviewerApiImpl.Factory reviewerApi,
@@ -111,20 +134,28 @@
       Abandon abandon,
       Revert revert,
       Restore restore,
-      SubmittedTogether submittedTogether,
+      CreateMergePatchSet updateByMerge,
+      Provider<SubmittedTogether> submittedTogether,
       PublishDraftPatchSet.CurrentRevision publishDraftChange,
-      DeleteDraftChange deleteDraftChange,
+      Rebase.CurrentRevision rebase,
+      DeleteChange deleteChange,
       GetTopic getTopic,
       PutTopic putTopic,
+      ChangeIncludedIn includedIn,
       PostReviewers postReviewers,
       ChangeJson.Factory changeJson,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
+      PutAssignee putAssignee,
+      GetAssignee getAssignee,
+      GetPastAssignees getPastAssignees,
+      DeleteAssignee deleteAssignee,
       ListChangeComments listComments,
+      ListChangeRobotComments listChangeRobotComments,
       ListChangeDrafts listDrafts,
+      ChangeEditApiImpl.Factory changeEditApi,
       Check check,
       Index index,
-      ChangeEdits.Detail editDetail,
       Move move,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
@@ -136,20 +167,28 @@
     this.suggestReviewers = suggestReviewers;
     this.abandon = abandon;
     this.restore = restore;
+    this.updateByMerge = updateByMerge;
     this.submittedTogether = submittedTogether;
     this.publishDraftChange = publishDraftChange;
-    this.deleteDraftChange = deleteDraftChange;
+    this.rebase = rebase;
+    this.deleteChange = deleteChange;
     this.getTopic = getTopic;
     this.putTopic = putTopic;
+    this.includedIn = includedIn;
     this.postReviewers = postReviewers;
     this.changeJson = changeJson;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
+    this.putAssignee = putAssignee;
+    this.getAssignee = getAssignee;
+    this.getPastAssignees = getPastAssignees;
+    this.deleteAssignee = deleteAssignee;
     this.listComments = listComments;
+    this.listChangeRobotComments = listChangeRobotComments;
     this.listDrafts = listDrafts;
+    this.changeEditApi = changeEditApi;
     this.check = check;
     this.index = index;
-    this.editDetail = editDetail;
     this.move = move;
     this.change = change;
   }
@@ -172,8 +211,7 @@
   @Override
   public RevisionApi revision(String id) throws RestApiException {
     try {
-      return revisionApi.create(
-          revisions.parse(change, IdString.fromDecoded(id)));
+      return revisionApi.create(revisions.parse(change, IdString.fromDecoded(id)));
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot parse revision", e);
     }
@@ -182,8 +220,7 @@
   @Override
   public ReviewerApi reviewer(String id) throws RestApiException {
     try {
-      return reviewerApi.create(
-          reviewers.parse(change, IdString.fromDecoded(id)));
+      return reviewerApi.create(reviewers.parse(change, IdString.fromDecoded(id)));
     } catch (OrmException e) {
       throw new RestApiException("Cannot parse reviewer", e);
     }
@@ -242,27 +279,44 @@
   public ChangeApi revert(RevertInput in) throws RestApiException {
     try {
       return changeApi.id(revert.apply(change, in)._number);
-    } catch (OrmException | IOException | UpdateException
-        | NoSuchChangeException e) {
+    } catch (OrmException | IOException | UpdateException e) {
       throw new RestApiException("Cannot revert change", e);
     }
   }
 
-  @SuppressWarnings("unchecked")
   @Override
-  public List<ChangeInfo> submittedTogether() throws RestApiException {
+  public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
     try {
-      return (List<ChangeInfo>) submittedTogether.apply(change);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot query submittedTogether", e);
+      return updateByMerge.apply(change, in).value();
+    } catch (IOException | UpdateException | InvalidChangeOperationException | OrmException e) {
+      throw new RestApiException("Cannot update change by merge", e);
     }
   }
 
   @Override
+  public List<ChangeInfo> submittedTogether() throws RestApiException {
+    SubmittedTogetherInfo info =
+        submittedTogether(
+            EnumSet.noneOf(ListChangesOption.class), EnumSet.noneOf(SubmittedTogetherOption.class));
+    return info.changes;
+  }
+
+  @Override
+  public SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options)
+      throws RestApiException {
+    return submittedTogether(EnumSet.noneOf(ListChangesOption.class), options);
+  }
+
+  @Override
   public SubmittedTogetherInfo submittedTogether(
-      EnumSet<SubmittedTogetherOption> options) throws RestApiException {
+      EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
+      throws RestApiException {
     try {
-      return submittedTogether.apply(change, options);
+      return submittedTogether
+          .get()
+          .addListChangesOption(listOptions)
+          .addSubmittedTogetherOption(submitOptions)
+          .applyInfo(change);
     } catch (IOException | OrmException e) {
       throw new RestApiException("Cannot query submittedTogether", e);
     }
@@ -278,9 +332,23 @@
   }
 
   @Override
+  public void rebase() throws RestApiException {
+    rebase(new RebaseInput());
+  }
+
+  @Override
+  public void rebase(RebaseInput in) throws RestApiException {
+    try {
+      rebase.apply(change, in);
+    } catch (EmailException | OrmException | UpdateException | IOException e) {
+      throw new RestApiException("Cannot rebase change", e);
+    }
+  }
+
+  @Override
   public void delete() throws RestApiException {
     try {
-      deleteDraftChange.apply(change, null);
+      deleteChange.apply(change, null);
     } catch (UpdateException e) {
       throw new RestApiException("Cannot delete change", e);
     }
@@ -303,6 +371,15 @@
   }
 
   @Override
+  public IncludedInInfo includedIn() throws RestApiException {
+    try {
+      return includedIn.apply(change);
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Could not extract IncludedIn data", e);
+    }
+  }
+
+  @Override
   public void addReviewer(String reviewer) throws RestApiException {
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = reviewer;
@@ -329,8 +406,7 @@
   }
 
   @Override
-  public SuggestedReviewersRequest suggestReviewers(String query)
-      throws RestApiException {
+  public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
     return suggestReviewers().withQuery(query);
   }
 
@@ -346,8 +422,7 @@
   }
 
   @Override
-  public ChangeInfo get(EnumSet<ListChangesOption> s)
-      throws RestApiException {
+  public ChangeInfo get(EnumSet<ListChangesOption> s) throws RestApiException {
     try {
       return changeJson.create(s).format(change);
     } catch (OrmException e) {
@@ -362,12 +437,12 @@
 
   @Override
   public EditInfo getEdit() throws RestApiException {
-    try {
-      Response<EditInfo> edit = editDetail.apply(change);
-      return edit.isNone() ? null : edit.value();
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot retrieve change edit", e);
-    }
+    return edit().get().orElse(null);
+  }
+
+  @Override
+  public ChangeEditApi edit() throws RestApiException {
+    return changeEditApi.create(change);
   }
 
   @Override
@@ -379,7 +454,7 @@
   public void setHashtags(HashtagsInput input) throws RestApiException {
     try {
       postHashtags.apply(change, input);
-    } catch (RestApiException | UpdateException e) {
+    } catch (UpdateException e) {
       throw new RestApiException("Cannot post hashtags", e);
     }
   }
@@ -394,6 +469,44 @@
   }
 
   @Override
+  public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
+    try {
+      return putAssignee.apply(change, input).value();
+    } catch (UpdateException | IOException | OrmException e) {
+      throw new RestApiException("Cannot set assignee", e);
+    }
+  }
+
+  @Override
+  public AccountInfo getAssignee() throws RestApiException {
+    try {
+      Response<AccountInfo> r = getAssignee.apply(change);
+      return r.isNone() ? null : r.value();
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get assignee", e);
+    }
+  }
+
+  @Override
+  public List<AccountInfo> getPastAssignees() throws RestApiException {
+    try {
+      return getPastAssignees.apply(change).value();
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get past assignees", e);
+    }
+  }
+
+  @Override
+  public AccountInfo deleteAssignee() throws RestApiException {
+    try {
+      Response<AccountInfo> r = deleteAssignee.apply(change, null);
+      return r.isNone() ? null : r.value();
+    } catch (UpdateException | OrmException e) {
+      throw new RestApiException("Cannot delete assignee", e);
+    }
+  }
+
+  @Override
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
       return listComments.apply(change);
@@ -403,6 +516,15 @@
   }
 
   @Override
+  public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
+    try {
+      return listChangeRobotComments.apply(change);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get robot comments", e);
+    }
+  }
+
+  @Override
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
       return listDrafts.apply(change);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
new file mode 100644
index 0000000..80d5071
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -0,0 +1,215 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import com.google.gerrit.extensions.api.changes.ChangeEditApi;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.ChangeEditResource;
+import com.google.gerrit.server.change.ChangeEdits;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.DeleteChangeEdit;
+import com.google.gerrit.server.change.PublishChangeEdit;
+import com.google.gerrit.server.change.RebaseChangeEdit;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Optional;
+
+public class ChangeEditApiImpl implements ChangeEditApi {
+  interface Factory {
+    ChangeEditApiImpl create(ChangeResource changeResource);
+  }
+
+  private final ChangeEdits.Detail editDetail;
+  private final ChangeEdits.Post changeEditsPost;
+  private final DeleteChangeEdit deleteChangeEdit;
+  private final RebaseChangeEdit.Rebase rebaseChangeEdit;
+  private final PublishChangeEdit.Publish publishChangeEdit;
+  private final ChangeEdits.Get changeEditsGet;
+  private final ChangeEdits.Put changeEditsPut;
+  private final ChangeEdits.DeleteContent changeEditDeleteContent;
+  private final ChangeEdits.GetMessage getChangeEditCommitMessage;
+  private final ChangeEdits.EditMessage modifyChangeEditCommitMessage;
+  private final ChangeEdits changeEdits;
+  private final ChangeResource changeResource;
+
+  @Inject
+  public ChangeEditApiImpl(
+      ChangeEdits.Detail editDetail,
+      ChangeEdits.Post changeEditsPost,
+      DeleteChangeEdit deleteChangeEdit,
+      RebaseChangeEdit.Rebase rebaseChangeEdit,
+      PublishChangeEdit.Publish publishChangeEdit,
+      ChangeEdits.Get changeEditsGet,
+      ChangeEdits.Put changeEditsPut,
+      ChangeEdits.DeleteContent changeEditDeleteContent,
+      ChangeEdits.GetMessage getChangeEditCommitMessage,
+      ChangeEdits.EditMessage modifyChangeEditCommitMessage,
+      ChangeEdits changeEdits,
+      @Assisted ChangeResource changeResource) {
+    this.editDetail = editDetail;
+    this.changeEditsPost = changeEditsPost;
+    this.deleteChangeEdit = deleteChangeEdit;
+    this.rebaseChangeEdit = rebaseChangeEdit;
+    this.publishChangeEdit = publishChangeEdit;
+    this.changeEditsGet = changeEditsGet;
+    this.changeEditsPut = changeEditsPut;
+    this.changeEditDeleteContent = changeEditDeleteContent;
+    this.getChangeEditCommitMessage = getChangeEditCommitMessage;
+    this.modifyChangeEditCommitMessage = modifyChangeEditCommitMessage;
+    this.changeEdits = changeEdits;
+    this.changeResource = changeResource;
+  }
+
+  @Override
+  public Optional<EditInfo> get() throws RestApiException {
+    try {
+      Response<EditInfo> edit = editDetail.apply(changeResource);
+      return edit.isNone() ? Optional.empty() : Optional.of(edit.value());
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot retrieve change edit", e);
+    }
+  }
+
+  @Override
+  public void create() throws RestApiException {
+    try {
+      changeEditsPost.apply(changeResource, null);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot create change edit", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteChangeEdit.apply(changeResource, new DeleteChangeEdit.Input());
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot delete change edit", e);
+    }
+  }
+
+  @Override
+  public void rebase() throws RestApiException {
+    try {
+      rebaseChangeEdit.apply(changeResource, null);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot rebase change edit", e);
+    }
+  }
+
+  @Override
+  public void publish() throws RestApiException {
+    publish(null);
+  }
+
+  @Override
+  public void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException {
+    try {
+      publishChangeEdit.apply(changeResource, publishChangeEditInput);
+    } catch (IOException | OrmException | UpdateException e) {
+      throw new RestApiException("Cannot publish change edit", e);
+    }
+  }
+
+  @Override
+  public Optional<BinaryResult> getFile(String filePath) throws RestApiException {
+    try {
+      ChangeEditResource changeEditResource = getChangeEditResource(filePath);
+      Response<BinaryResult> fileResponse = changeEditsGet.apply(changeEditResource);
+      return fileResponse.isNone() ? Optional.empty() : Optional.of(fileResponse.value());
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot retrieve file of change edit", e);
+    }
+  }
+
+  @Override
+  public void renameFile(String oldFilePath, String newFilePath) throws RestApiException {
+    try {
+      ChangeEdits.Post.Input renameInput = new ChangeEdits.Post.Input();
+      renameInput.oldPath = oldFilePath;
+      renameInput.newPath = newFilePath;
+      changeEditsPost.apply(changeResource, renameInput);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot rename file of change edit", e);
+    }
+  }
+
+  @Override
+  public void restoreFile(String filePath) throws RestApiException {
+    try {
+      ChangeEdits.Post.Input restoreInput = new ChangeEdits.Post.Input();
+      restoreInput.restorePath = filePath;
+      changeEditsPost.apply(changeResource, restoreInput);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot restore file of change edit", e);
+    }
+  }
+
+  @Override
+  public void modifyFile(String filePath, RawInput newContent) throws RestApiException {
+    try {
+      changeEditsPut.apply(changeResource.getControl(), filePath, newContent);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot modify file of change edit", e);
+    }
+  }
+
+  @Override
+  public void deleteFile(String filePath) throws RestApiException {
+    try {
+      changeEditDeleteContent.apply(changeResource.getControl(), filePath);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot delete file of change edit", e);
+    }
+  }
+
+  @Override
+  public String getCommitMessage() throws RestApiException {
+    try {
+      try (BinaryResult binaryResult = getChangeEditCommitMessage.apply(changeResource)) {
+        return binaryResult.asString();
+      }
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot get commit message of change edit", e);
+    }
+  }
+
+  @Override
+  public void modifyCommitMessage(String newCommitMessage) throws RestApiException {
+    ChangeEdits.EditMessage.Input input = new ChangeEdits.EditMessage.Input();
+    input.message = newCommitMessage;
+    try {
+      modifyChangeEditCommitMessage.apply(changeResource, input);
+    } catch (IOException | OrmException e) {
+      throw new RestApiException("Cannot modify commit message of change edit", e);
+    }
+  }
+
+  private ChangeEditResource getChangeEditResource(String filePath)
+      throws ResourceNotFoundException, AuthException, IOException, OrmException {
+    return changeEdits.parse(changeResource, IdString.fromDecoded(filePath));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index bb2eea7..c77f86f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -32,14 +32,13 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.CreateChange;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.query.change.QueryChanges;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.util.List;
 
@@ -51,7 +50,8 @@
   private final Provider<QueryChanges> queryProvider;
 
   @Inject
-  ChangesImpl(ChangesCollection changes,
+  ChangesImpl(
+      ChangesCollection changes,
       ChangeApiImpl.Factory api,
       CreateChange createChange,
       Provider<QueryChanges> queryProvider) {
@@ -67,20 +67,16 @@
   }
 
   @Override
-  public ChangeApi id(String project, String branch, String id)
-      throws RestApiException {
-    return id(Joiner.on('~').join(ImmutableList.of(
-        Url.encode(project),
-        Url.encode(branch),
-        Url.encode(id))));
+  public ChangeApi id(String project, String branch, String id) throws RestApiException {
+    return id(
+        Joiner.on('~')
+            .join(ImmutableList.of(Url.encode(project), Url.encode(branch), Url.encode(id))));
   }
 
   @Override
   public ChangeApi id(String id) throws RestApiException {
     try {
-      return api.create(changes.parse(
-          TopLevelResource.INSTANCE,
-          IdString.fromUrl(id)));
+      return api.create(changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(id)));
     } catch (OrmException e) {
       throw new RestApiException("Cannot parse change", e);
     }
@@ -89,11 +85,9 @@
   @Override
   public ChangeApi create(ChangeInput in) throws RestApiException {
     try {
-      ChangeInfo out = createChange.apply(
-          TopLevelResource.INSTANCE, in).value();
+      ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
       return api.create(changes.parse(new Change.Id(out._number)));
-    } catch (OrmException | IOException | InvalidChangeOperationException
-        | UpdateException e) {
+    } catch (OrmException | IOException | InvalidChangeOperationException | UpdateException e) {
       throw new RestApiException("Cannot create change", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
index 0352aff..5c61e23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -32,8 +32,7 @@
   private final CommentResource comment;
 
   @Inject
-  CommentApiImpl(GetComment getComment,
-      @Assisted CommentResource comment) {
+  CommentApiImpl(GetComment getComment, @Assisted CommentResource comment) {
     this.getComment = getComment;
     this.comment = comment;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
index 2e2dfcc..1bd9216 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.change.GetDraftComment;
 import com.google.gerrit.server.change.PutDraftComment;
-import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -38,7 +38,8 @@
   private final DraftCommentResource draft;
 
   @Inject
-  DraftApiImpl(DeleteDraftComment deleteDraft,
+  DraftApiImpl(
+      DeleteDraftComment deleteDraft,
       GetDraftComment getDraft,
       PutDraftComment putDraft,
       @Assisted DraftCommentResource draft) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index e6ca18df..aa66e7b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -22,11 +22,9 @@
 import com.google.gerrit.server.change.GetContent;
 import com.google.gerrit.server.change.GetDiff;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.io.IOException;
 
 class FileApiImpl implements FileApi {
@@ -39,9 +37,7 @@
   private final FileResource file;
 
   @Inject
-  FileApiImpl(GetContent getContent,
-      GetDiff getDiff,
-      @Assisted FileResource file) {
+  FileApiImpl(GetContent getContent, GetDiff getDiff, @Assisted FileResource file) {
     this.getContent = getContent;
     this.getDiff = getDiff;
     this.file = file;
@@ -51,7 +47,7 @@
   public BinaryResult content() throws RestApiException {
     try {
       return getContent.apply(file);
-    } catch (NoSuchChangeException | IOException | OrmException e) {
+    } catch (IOException | OrmException e) {
       throw new RestApiException("Cannot retrieve file content", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
index 228dad6..e91d64a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
@@ -24,9 +24,12 @@
 
     factory(ChangeApiImpl.Factory.class);
     factory(CommentApiImpl.Factory.class);
+    factory(RobotCommentApiImpl.Factory.class);
     factory(DraftApiImpl.Factory.class);
     factory(RevisionApiImpl.Factory.class);
     factory(FileApiImpl.Factory.class);
     factory(ReviewerApiImpl.Factory.class);
+    factory(RevisionReviewerApiImpl.Factory.class);
+    factory(ChangeEditApiImpl.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
index a18c575..8ac874a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.api.changes;
 
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -22,11 +23,10 @@
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.change.Votes;
-import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.util.Map;
 
 public class ReviewerApiImpl implements ReviewerApi {
@@ -40,7 +40,8 @@
   private final DeleteReviewer deleteReviewer;
 
   @Inject
-  ReviewerApiImpl(Votes.List listVotes,
+  ReviewerApiImpl(
+      Votes.List listVotes,
       DeleteVote deleteVote,
       DeleteReviewer deleteReviewer,
       @Assisted ReviewerResource reviewer) {
@@ -79,8 +80,13 @@
 
   @Override
   public void remove() throws RestApiException {
+    remove(new DeleteReviewerInput());
+  }
+
+  @Override
+  public void remove(DeleteReviewerInput input) throws RestApiException {
     try {
-      deleteReviewer.apply(reviewer, null);
+      deleteReviewer.apply(reviewer, input);
     } catch (UpdateException e) {
       throw new RestApiException("Cannot remove reviewer", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 6b5e83c..43be8df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -26,12 +26,16 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
+import com.google.gerrit.extensions.api.changes.RobotCommentApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -44,33 +48,39 @@
 import com.google.gerrit.server.change.DraftComments;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.Files;
+import com.google.gerrit.server.change.GetDescription;
+import com.google.gerrit.server.change.GetMergeList;
 import com.google.gerrit.server.change.GetPatch;
 import com.google.gerrit.server.change.GetRevisionActions;
 import com.google.gerrit.server.change.ListRevisionComments;
 import com.google.gerrit.server.change.ListRevisionDrafts;
+import com.google.gerrit.server.change.ListRobotComments;
 import com.google.gerrit.server.change.Mergeable;
 import com.google.gerrit.server.change.PostReview;
+import com.google.gerrit.server.change.PreviewSubmit;
 import com.google.gerrit.server.change.PublishDraftPatchSet;
+import com.google.gerrit.server.change.PutDescription;
 import com.google.gerrit.server.change.Rebase;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.Reviewed;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.RevisionReviewers;
+import com.google.gerrit.server.change.RobotComments;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.change.TestSubmitType;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 class RevisionApiImpl implements RevisionApi {
   interface Factory {
@@ -79,11 +89,14 @@
 
   private final GitRepositoryManager repoManager;
   private final Changes changes;
+  private final RevisionReviewers revisionReviewers;
+  private final RevisionReviewerApiImpl.Factory revisionReviewerApi;
   private final CherryPick cherryPick;
   private final DeleteDraftPatchSet deleteDraft;
   private final Rebase rebase;
   private final RebaseUtil rebaseUtil;
   private final Submit submit;
+  private final PreviewSubmit submitPreview;
   private final PublishDraftPatchSet publish;
   private final Reviewed.PutReviewed putReviewed;
   private final Reviewed.DeleteReviewed deleteReviewed;
@@ -95,24 +108,34 @@
   private final Mergeable mergeable;
   private final FileApiImpl.Factory fileApi;
   private final ListRevisionComments listComments;
+  private final ListRobotComments listRobotComments;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
   private final DraftComments drafts;
   private final DraftApiImpl.Factory draftFactory;
   private final Comments comments;
   private final CommentApiImpl.Factory commentFactory;
+  private final RobotComments robotComments;
+  private final RobotCommentApiImpl.Factory robotCommentFactory;
   private final GetRevisionActions revisionActions;
   private final TestSubmitType testSubmitType;
   private final TestSubmitType.Get getSubmitType;
+  private final Provider<GetMergeList> getMergeList;
+  private final PutDescription putDescription;
+  private final GetDescription getDescription;
 
   @Inject
-  RevisionApiImpl(GitRepositoryManager repoManager,
+  RevisionApiImpl(
+      GitRepositoryManager repoManager,
       Changes changes,
+      RevisionReviewers revisionReviewers,
+      RevisionReviewerApiImpl.Factory revisionReviewerApi,
       CherryPick cherryPick,
       DeleteDraftPatchSet deleteDraft,
       Rebase rebase,
       RebaseUtil rebaseUtil,
       Submit submit,
+      PreviewSubmit submitPreview,
       PublishDraftPatchSet publish,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
@@ -123,24 +146,33 @@
       Mergeable mergeable,
       FileApiImpl.Factory fileApi,
       ListRevisionComments listComments,
+      ListRobotComments listRobotComments,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
       DraftComments drafts,
       DraftApiImpl.Factory draftFactory,
       Comments comments,
       CommentApiImpl.Factory commentFactory,
+      RobotComments robotComments,
+      RobotCommentApiImpl.Factory robotCommentFactory,
       GetRevisionActions revisionActions,
       TestSubmitType testSubmitType,
       TestSubmitType.Get getSubmitType,
+      Provider<GetMergeList> getMergeList,
+      PutDescription putDescription,
+      GetDescription getDescription,
       @Assisted RevisionResource r) {
     this.repoManager = repoManager;
     this.changes = changes;
+    this.revisionReviewers = revisionReviewers;
+    this.revisionReviewerApi = revisionReviewerApi;
     this.cherryPick = cherryPick;
     this.deleteDraft = deleteDraft;
     this.rebase = rebase;
     this.rebaseUtil = rebaseUtil;
     this.review = review;
     this.submit = submit;
+    this.submitPreview = submitPreview;
     this.publish = publish;
     this.files = files;
     this.putReviewed = putReviewed;
@@ -150,15 +182,21 @@
     this.mergeable = mergeable;
     this.fileApi = fileApi;
     this.listComments = listComments;
+    this.robotComments = robotComments;
+    this.listRobotComments = listRobotComments;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
     this.drafts = drafts;
     this.draftFactory = draftFactory;
     this.comments = comments;
     this.commentFactory = commentFactory;
+    this.robotCommentFactory = robotCommentFactory;
     this.revisionActions = revisionActions;
     this.testSubmitType = testSubmitType;
     this.getSubmitType = getSubmitType;
+    this.getMergeList = getMergeList;
+    this.putDescription = putDescription;
+    this.getDescription = getDescription;
     this.revision = r;
   }
 
@@ -187,6 +225,21 @@
   }
 
   @Override
+  public BinaryResult submitPreview() throws RestApiException {
+    return submitPreview("zip");
+  }
+
+  @Override
+  public BinaryResult submitPreview(String format) throws RestApiException {
+    try {
+      submitPreview.setFormat(format);
+      return submitPreview.apply(revision);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get submit preview", e);
+    }
+  }
+
+  @Override
   public void publish() throws RestApiException {
     try {
       publish.apply(revision, new PublishDraftPatchSet.Input());
@@ -214,8 +267,7 @@
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebase.apply(revision, in)._number);
-    } catch (OrmException | EmailException | UpdateException | IOException
-        | NoSuchChangeException e) {
+    } catch (OrmException | EmailException | UpdateException | IOException e) {
       throw new RestApiException("Cannot rebase ps", e);
     }
   }
@@ -224,8 +276,7 @@
   public boolean canRebase() throws RestApiException {
     try (Repository repo = repoManager.openRepository(revision.getProject());
         RevWalk rw = new RevWalk(repo)) {
-      return rebaseUtil.canRebase(
-          revision.getPatchSet(), revision.getChange().getDest(), repo, rw);
+      return rebaseUtil.canRebase(revision.getPatchSet(), revision.getChange().getDest(), repo, rw);
     } catch (IOException e) {
       throw new RestApiException("Cannot check if rebase is possible", e);
     }
@@ -241,6 +292,16 @@
   }
 
   @Override
+  public RevisionReviewerApi reviewer(String id) throws RestApiException {
+    try {
+      return revisionReviewerApi.create(
+          revisionReviewers.parse(revision, IdString.fromDecoded(id)));
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot parse reviewer", e);
+    }
+  }
+
+  @Override
   public void setReviewed(String path, boolean reviewed) throws RestApiException {
     try {
       RestModifyView<FileResource, Reviewed.Input> view;
@@ -249,9 +310,7 @@
       } else {
         view = deleteReviewed;
       }
-      view.apply(
-          files.parse(revision, IdString.fromDecoded(path)),
-          new Reviewed.Input());
+      view.apply(files.parse(revision, IdString.fromDecoded(path)), new Reviewed.Input());
     } catch (Exception e) {
       throw new RestApiException("Cannot update reviewed flag", e);
     }
@@ -261,10 +320,9 @@
   @Override
   public Set<String> reviewed() throws RestApiException {
     try {
-      return ImmutableSet.copyOf((Iterable<String>) listFiles
-          .setReviewed(true)
-          .apply(revision).value());
-    } catch (OrmException | IOException e) {
+      return ImmutableSet.copyOf(
+          (Iterable<String>) listFiles.setReviewed(true).apply(revision).value());
+    } catch (OrmException | IOException | PatchListNotAvailableException e) {
       throw new RestApiException("Cannot list reviewed files", e);
     }
   }
@@ -292,8 +350,8 @@
   @Override
   public Map<String, FileInfo> files() throws RestApiException {
     try {
-      return (Map<String, FileInfo>)listFiles.apply(revision).value();
-    } catch (OrmException | IOException e) {
+      return (Map<String, FileInfo>) listFiles.apply(revision).value();
+    } catch (OrmException | IOException | PatchListNotAvailableException e) {
       throw new RestApiException("Cannot retrieve files", e);
     }
   }
@@ -302,9 +360,8 @@
   @Override
   public Map<String, FileInfo> files(String base) throws RestApiException {
     try {
-      return (Map<String, FileInfo>) listFiles.setBase(base)
-          .apply(revision).value();
-    } catch (OrmException | IOException e) {
+      return (Map<String, FileInfo>) listFiles.setBase(base).apply(revision).value();
+    } catch (OrmException | IOException | PatchListNotAvailableException e) {
       throw new RestApiException("Cannot retrieve files", e);
     }
   }
@@ -313,17 +370,15 @@
   @Override
   public Map<String, FileInfo> files(int parentNum) throws RestApiException {
     try {
-      return (Map<String, FileInfo>) listFiles.setParent(parentNum)
-          .apply(revision).value();
-    } catch (OrmException | IOException e) {
+      return (Map<String, FileInfo>) listFiles.setParent(parentNum).apply(revision).value();
+    } catch (OrmException | IOException | PatchListNotAvailableException e) {
       throw new RestApiException("Cannot retrieve files", e);
     }
   }
 
   @Override
   public FileApi file(String path) {
-    return fileApi.create(files.parse(revision,
-        IdString.fromDecoded(path)));
+    return fileApi.create(files.parse(revision, IdString.fromDecoded(path)));
   }
 
   @Override
@@ -336,6 +391,15 @@
   }
 
   @Override
+  public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
+    try {
+      return listRobotComments.apply(revision);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
   public List<CommentInfo> commentsAsList() throws RestApiException {
     try {
       return listComments.getComments(revision);
@@ -354,6 +418,15 @@
   }
 
   @Override
+  public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException {
+    try {
+      return listRobotComments.getComments(revision);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
   public List<CommentInfo> draftsAsList() throws RestApiException {
     try {
       return listDrafts.getComments(revision);
@@ -365,8 +438,7 @@
   @Override
   public DraftApi draft(String id) throws RestApiException {
     try {
-      return draftFactory.create(drafts.parse(revision,
-          IdString.fromDecoded(id)));
+      return draftFactory.create(drafts.parse(revision, IdString.fromDecoded(id)));
     } catch (OrmException e) {
       throw new RestApiException("Cannot retrieve draft", e);
     }
@@ -377,7 +449,8 @@
     try {
       String id = createDraft.apply(revision, in).value().id;
       // Reread change to pick up new notes refs.
-      return changes.id(revision.getChange().getId().get())
+      return changes
+          .id(revision.getChange().getId().get())
           .revision(revision.getPatchSet().getId().get())
           .draft(id);
     } catch (UpdateException | OrmException e) {
@@ -388,14 +461,22 @@
   @Override
   public CommentApi comment(String id) throws RestApiException {
     try {
-      return commentFactory.create(comments.parse(revision,
-          IdString.fromDecoded(id)));
+      return commentFactory.create(comments.parse(revision, IdString.fromDecoded(id)));
     } catch (OrmException e) {
       throw new RestApiException("Cannot retrieve comment", e);
     }
   }
 
   @Override
+  public RobotCommentApi robotComment(String id) throws RestApiException {
+    try {
+      return robotCommentFactory.create(robotComments.parse(revision, IdString.fromDecoded(id)));
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comment", e);
+    }
+  }
+
+  @Override
   public BinaryResult patch() throws RestApiException {
     try {
       return getPatch.apply(revision);
@@ -405,8 +486,21 @@
   }
 
   @Override
+  public BinaryResult patch(String path) throws RestApiException {
+    try {
+      return getPatch.setPath(path).apply(revision);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot get patch", e);
+    }
+  }
+
+  @Override
   public Map<String, ActionInfo> actions() throws RestApiException {
-    return revisionActions.apply(revision).value();
+    try {
+      return revisionActions.apply(revision).value();
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot get actions", e);
+    }
   }
 
   @Override
@@ -419,12 +513,49 @@
   }
 
   @Override
-  public SubmitType testSubmitType(TestSubmitRuleInput in)
-      throws RestApiException {
+  public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
     try {
       return testSubmitType.apply(revision, in);
     } catch (OrmException e) {
       throw new RestApiException("Cannot test submit type", e);
     }
   }
+
+  @Override
+  public MergeListRequest getMergeList() throws RestApiException {
+    return new MergeListRequest() {
+      @Override
+      public List<CommitInfo> get() throws RestApiException {
+        try {
+          GetMergeList gml = getMergeList.get();
+          gml.setUninterestingParent(getUninterestingParent());
+          gml.setAddLinks(getAddLinks());
+          return gml.apply(revision).value();
+        } catch (IOException e) {
+          throw new RestApiException("Cannot get merge list", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public void description(String description) throws RestApiException {
+    PutDescription.Input in = new PutDescription.Input();
+    in.description = description;
+    try {
+      putDescription.apply(revision, in);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot set description", e);
+    }
+  }
+
+  @Override
+  public String description() throws RestApiException {
+    return getDescription.apply(revision);
+  }
+
+  @Override
+  public String etag() throws RestApiException {
+    return revisionActions.getETag(revision);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
new file mode 100644
index 0000000..5c56321
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.changes;
+
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.DeleteVote;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.VoteResource;
+import com.google.gerrit.server.change.Votes;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
+
+public class RevisionReviewerApiImpl implements RevisionReviewerApi {
+  interface Factory {
+    RevisionReviewerApiImpl create(ReviewerResource r);
+  }
+
+  private final ReviewerResource reviewer;
+  private final Votes.List listVotes;
+  private final DeleteVote deleteVote;
+
+  @Inject
+  RevisionReviewerApiImpl(
+      Votes.List listVotes, DeleteVote deleteVote, @Assisted ReviewerResource reviewer) {
+    this.listVotes = listVotes;
+    this.deleteVote = deleteVote;
+    this.reviewer = reviewer;
+  }
+
+  @Override
+  public Map<String, Short> votes() throws RestApiException {
+    try {
+      return listVotes.apply(reviewer);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot list votes", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(String label) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, label), null);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot delete vote", e);
+    }
+  }
+
+  @Override
+  public void deleteVote(DeleteVoteInput input) throws RestApiException {
+    try {
+      deleteVote.apply(new VoteResource(reviewer, input.label), input);
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot delete vote", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
new file mode 100644
index 0000000..ded98cb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
@@ -0,0 +1,48 @@
+// 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.api.changes;
+
+import com.google.gerrit.extensions.api.changes.RobotCommentApi;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.GetRobotComment;
+import com.google.gerrit.server.change.RobotCommentResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class RobotCommentApiImpl implements RobotCommentApi {
+  interface Factory {
+    RobotCommentApiImpl create(RobotCommentResource c);
+  }
+
+  private final GetRobotComment getComment;
+  private final RobotCommentResource comment;
+
+  @Inject
+  RobotCommentApiImpl(GetRobotComment getComment, @Assisted RobotCommentResource comment) {
+    this.getComment = getComment;
+    this.comment = comment;
+  }
+
+  @Override
+  public RobotCommentInfo get() throws RestApiException {
+    try {
+      return getComment.apply(comment);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comment", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
index 8339ecf..9b6ead0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -18,18 +18,18 @@
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetDiffPreferences;
 import com.google.gerrit.server.config.GetPreferences;
+import com.google.gerrit.server.config.GetServerInfo;
 import com.google.gerrit.server.config.SetDiffPreferences;
 import com.google.gerrit.server.config.SetPreferences;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class ServerImpl implements Server {
@@ -37,16 +37,20 @@
   private final SetPreferences setPreferences;
   private final GetDiffPreferences getDiffPreferences;
   private final SetDiffPreferences setDiffPreferences;
+  private final GetServerInfo getServerInfo;
 
   @Inject
-  ServerImpl(GetPreferences getPreferences,
+  ServerImpl(
+      GetPreferences getPreferences,
       SetPreferences setPreferences,
       GetDiffPreferences getDiffPreferences,
-      SetDiffPreferences setDiffPreferences) {
+      SetDiffPreferences setDiffPreferences,
+      GetServerInfo getServerInfo) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
     this.setDiffPreferences = setDiffPreferences;
+    this.getServerInfo = getServerInfo;
   }
 
   @Override
@@ -55,8 +59,16 @@
   }
 
   @Override
-  public GeneralPreferencesInfo getDefaultPreferences()
-      throws RestApiException {
+  public ServerInfo getInfo() throws RestApiException {
+    try {
+      return getServerInfo.apply(new ConfigResource());
+    } catch (IOException e) {
+      throw new RestApiException("Cannot get server info", e);
+    }
+  }
+
+  @Override
+  public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException {
     try {
       return getPreferences.apply(new ConfigResource());
     } catch (IOException | ConfigInvalidException e) {
@@ -65,8 +77,8 @@
   }
 
   @Override
-  public GeneralPreferencesInfo setDefaultPreferences(
-      GeneralPreferencesInfo in) throws RestApiException {
+  public GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in)
+      throws RestApiException {
     try {
       return setPreferences.apply(new ConfigResource(), in);
     } catch (IOException | ConfigInvalidException e) {
@@ -75,8 +87,7 @@
   }
 
   @Override
-  public DiffPreferencesInfo getDefaultDiffPreferences()
-      throws RestApiException {
+  public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException {
     try {
       return getDiffPreferences.apply(new ConfigResource());
     } catch (IOException | ConfigInvalidException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 5660176..15120d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.group.GetOptions;
 import com.google.gerrit.server.group.GetOwner;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.Index;
 import com.google.gerrit.server.group.ListIncludedGroups;
 import com.google.gerrit.server.group.ListMembers;
 import com.google.gerrit.server.group.PutDescription;
@@ -43,7 +44,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
@@ -71,6 +71,7 @@
   private final DeleteIncludedGroups deleteGroups;
   private final GetAuditLog getAuditLog;
   private final GroupResource rsrc;
+  private final Index index;
 
   @AssistedInject
   GroupApiImpl(
@@ -91,6 +92,7 @@
       AddIncludedGroups addGroups,
       DeleteIncludedGroups deleteGroups,
       GetAuditLog getAuditLog,
+      Index index,
       @Assisted GroupResource rsrc) {
     this.getGroup = getGroup;
     this.getDetail = getDetail;
@@ -109,6 +111,7 @@
     this.addGroups = addGroups;
     this.deleteGroups = deleteGroups;
     this.getAuditLog = getAuditLog;
+    this.index = index;
     this.rsrc = rsrc;
   }
 
@@ -143,7 +146,7 @@
       putName.apply(rsrc, in);
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(name, e);
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot put group name", e);
     }
   }
@@ -163,7 +166,7 @@
     in.owner = owner;
     try {
       putOwner.apply(rsrc, in);
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot put group owner", e);
     }
   }
@@ -179,7 +182,7 @@
     in.description = description;
     try {
       putDescription.apply(rsrc, in);
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot put group description", e);
     }
   }
@@ -193,7 +196,7 @@
   public void options(GroupOptionsInfo options) throws RestApiException {
     try {
       putOptions.apply(rsrc, options);
-    } catch (OrmException e) {
+    } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot put group options", e);
     }
   }
@@ -216,8 +219,7 @@
   @Override
   public void addMembers(String... members) throws RestApiException {
     try {
-      addMembers.apply(
-          rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
+      addMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot add group members", e);
     }
@@ -226,8 +228,7 @@
   @Override
   public void removeMembers(String... members) throws RestApiException {
     try {
-      deleteMembers.apply(
-          rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
+      deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot remove group members", e);
     }
@@ -245,8 +246,7 @@
   @Override
   public void addGroups(String... groups) throws RestApiException {
     try {
-      addGroups.apply(
-          rsrc, AddIncludedGroups.Input.fromGroups(Arrays.asList(groups)));
+      addGroups.apply(rsrc, AddIncludedGroups.Input.fromGroups(Arrays.asList(groups)));
     } catch (OrmException e) {
       throw new RestApiException("Cannot add group members", e);
     }
@@ -255,8 +255,7 @@
   @Override
   public void removeGroups(String... groups) throws RestApiException {
     try {
-      deleteGroups.apply(
-          rsrc, AddIncludedGroups.Input.fromGroups(Arrays.asList(groups)));
+      deleteGroups.apply(rsrc, AddIncludedGroups.Input.fromGroups(Arrays.asList(groups)));
     } catch (OrmException e) {
       throw new RestApiException("Cannot remove group members", e);
     }
@@ -270,4 +269,13 @@
       throw new RestApiException("Cannot get audit log", e);
     }
   }
+
+  @Override
+  public void index() throws RestApiException {
+    try {
+      index.apply(rsrc, new Index.Input());
+    } catch (IOException e) {
+      throw new RestApiException("Cannot index group", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index b509c55..1d725a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.groups.Groups;
+import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -30,13 +31,14 @@
 import com.google.gerrit.server.group.CreateGroup;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.ListGroups;
+import com.google.gerrit.server.group.QueryGroups;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import java.util.List;
 import java.util.SortedMap;
 
 @Singleton
@@ -45,6 +47,7 @@
   private final GroupsCollection groups;
   private final ProjectsCollection projects;
   private final Provider<ListGroups> listGroups;
+  private final Provider<QueryGroups> queryGroups;
   private final Provider<CurrentUser> user;
   private final CreateGroup.Factory createGroup;
   private final GroupApiImpl.Factory api;
@@ -55,6 +58,7 @@
       GroupsCollection groups,
       ProjectsCollection projects,
       Provider<ListGroups> listGroups,
+      Provider<QueryGroups> queryGroups,
       Provider<CurrentUser> user,
       CreateGroup.Factory createGroup,
       GroupApiImpl.Factory api) {
@@ -62,6 +66,7 @@
     this.groups = groups;
     this.projects = projects;
     this.listGroups = listGroups;
+    this.queryGroups = queryGroups;
     this.user = user;
     this.createGroup = createGroup;
     this.api = api;
@@ -69,8 +74,7 @@
 
   @Override
   public GroupApi id(String id) throws RestApiException {
-    return api.create(
-        groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
+    return api.create(groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
   }
 
   @Override
@@ -87,8 +91,7 @@
     }
     checkRequiresCapability(user, null, CreateGroup.class);
     try {
-      GroupInfo info = createGroup.create(in.name)
-          .apply(TopLevelResource.INSTANCE, in);
+      GroupInfo info = createGroup.create(in.name).apply(TopLevelResource.INSTANCE, in);
       return id(info.id);
     } catch (OrmException | IOException e) {
       throw new RestApiException("Cannot create group " + in.name, e);
@@ -105,16 +108,14 @@
     };
   }
 
-  private SortedMap<String, GroupInfo> list(ListRequest req)
-      throws RestApiException {
+  private SortedMap<String, GroupInfo> list(ListRequest req) throws RestApiException {
     TopLevelResource tlr = TopLevelResource.INSTANCE;
     ListGroups list = listGroups.get();
     list.setOptions(req.getOptions());
 
     for (String project : req.getProjects()) {
       try {
-        list.addProject(
-            projects.parse(tlr, IdString.fromDecoded(project)).getControl());
+        list.addProject(projects.parse(tlr, IdString.fromDecoded(project)).getControl());
       } catch (IOException e) {
         throw new RestApiException("Error looking up project " + project, e);
       }
@@ -145,4 +146,34 @@
       throw new RestApiException("Cannot list groups", e);
     }
   }
+
+  @Override
+  public QueryRequest query() {
+    return new QueryRequest() {
+      @Override
+      public List<GroupInfo> get() throws RestApiException {
+        return GroupsImpl.this.query(this);
+      }
+    };
+  }
+
+  @Override
+  public QueryRequest query(String query) {
+    return query().withQuery(query);
+  }
+
+  private List<GroupInfo> query(QueryRequest r) throws RestApiException {
+    try {
+      QueryGroups myQueryGroups = queryGroups.get();
+      myQueryGroups.setQuery(r.getQuery());
+      myQueryGroups.setLimit(r.getLimit());
+      myQueryGroups.setStart(r.getStart());
+      for (ListGroupsOption option : r.getOptions()) {
+        myQueryGroups.addOption(option);
+      }
+      return myQueryGroups.apply(TopLevelResource.INSTANCE);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot query groups", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 7fbb1f6..348b4e8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,12 +28,13 @@
 import com.google.gerrit.server.project.FileResource;
 import com.google.gerrit.server.project.FilesCollection;
 import com.google.gerrit.server.project.GetContent;
+import com.google.gerrit.server.project.GetReflog;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.io.IOException;
+import java.util.List;
 
 public class BranchApiImpl implements BranchApi {
   interface Factory {
@@ -44,15 +46,18 @@
   private final DeleteBranch deleteBranch;
   private final FilesCollection filesCollection;
   private final GetContent getContent;
+  private final GetReflog getReflog;
   private final String ref;
   private final ProjectResource project;
 
   @Inject
-  BranchApiImpl(BranchesCollection branches,
+  BranchApiImpl(
+      BranchesCollection branches,
       CreateBranch.Factory createBranchFactory,
       DeleteBranch deleteBranch,
       FilesCollection filesCollection,
       GetContent getContent,
+      GetReflog getReflog,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.branches = branches;
@@ -60,6 +65,7 @@
     this.deleteBranch = deleteBranch;
     this.filesCollection = filesCollection;
     this.getContent = getContent;
+    this.getReflog = getReflog;
     this.project = project;
     this.ref = ref;
   }
@@ -95,14 +101,22 @@
   @Override
   public BinaryResult file(String path) throws RestApiException {
     try {
-      FileResource resource = filesCollection.parse(resource(),
-        IdString.fromDecoded(path));
+      FileResource resource = filesCollection.parse(resource(), IdString.fromDecoded(path));
       return getContent.apply(resource);
     } catch (IOException e) {
       throw new RestApiException("Cannot retrieve file", e);
     }
   }
 
+  @Override
+  public List<ReflogEntryInfo> reflog() throws RestApiException {
+    try {
+      return getReflog.apply(resource());
+    } catch (IOException e) {
+      throw new RestApiException("Cannot retrieve reflog", e);
+    }
+  }
+
   private BranchResource resource() throws RestApiException, IOException {
     return branches.parse(project, IdString.fromDecoded(ref));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index b972f5e..925b647 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -31,9 +31,7 @@
   private final ChildProjectResource rsrc;
 
   @AssistedInject
-  ChildProjectApiImpl(
-      GetChildProject getChildProject,
-      @Assisted ChildProjectResource rsrc) {
+  ChildProjectApiImpl(GetChildProject getChildProject, @Assisted ChildProjectResource rsrc) {
     this.getChildProject = getChildProject;
     this.rsrc = rsrc;
   }
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 b28258c..e29d633 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
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
+import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.project.ChildProjectsCollection;
 import com.google.gerrit.server.project.CreateProject;
 import com.google.gerrit.server.project.DeleteBranches;
+import com.google.gerrit.server.project.DeleteTags;
 import com.google.gerrit.server.project.GetAccess;
 import com.google.gerrit.server.project.GetConfig;
 import com.google.gerrit.server.project.GetDescription;
@@ -55,15 +57,14 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class ProjectApiImpl implements ProjectApi {
   interface Factory {
     ProjectApiImpl create(ProjectResource project);
+
     ProjectApiImpl create(String name);
   }
 
@@ -87,9 +88,11 @@
   private final ListBranches listBranches;
   private final ListTags listTags;
   private final DeleteBranches deleteBranches;
+  private final DeleteTags deleteTags;
 
   @AssistedInject
-  ProjectApiImpl(CurrentUser user,
+  ProjectApiImpl(
+      CurrentUser user,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -107,15 +110,35 @@
       ListBranches listBranches,
       ListTags listTags,
       DeleteBranches deleteBranches,
+      DeleteTags deleteTags,
       @Assisted ProjectResource project) {
-    this(user, createProjectFactory, projectApi, projects, getDescription,
-        putDescription, childApi, children, projectJson, branchApiFactory,
-        tagApiFactory, getAccess, setAccess, getConfig, putConfig, listBranches,
-        listTags, deleteBranches, project, null);
+    this(
+        user,
+        createProjectFactory,
+        projectApi,
+        projects,
+        getDescription,
+        putDescription,
+        childApi,
+        children,
+        projectJson,
+        branchApiFactory,
+        tagApiFactory,
+        getAccess,
+        setAccess,
+        getConfig,
+        putConfig,
+        listBranches,
+        listTags,
+        deleteBranches,
+        deleteTags,
+        project,
+        null);
   }
 
   @AssistedInject
-  ProjectApiImpl(CurrentUser user,
+  ProjectApiImpl(
+      CurrentUser user,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -133,14 +156,34 @@
       ListBranches listBranches,
       ListTags listTags,
       DeleteBranches deleteBranches,
+      DeleteTags deleteTags,
       @Assisted String name) {
-    this(user, createProjectFactory, projectApi, projects, getDescription,
-        putDescription, childApi, children, projectJson, branchApiFactory,
-        tagApiFactory, getAccess, setAccess, getConfig, putConfig, listBranches,
-        listTags, deleteBranches, null, name);
+    this(
+        user,
+        createProjectFactory,
+        projectApi,
+        projects,
+        getDescription,
+        putDescription,
+        childApi,
+        children,
+        projectJson,
+        branchApiFactory,
+        tagApiFactory,
+        getAccess,
+        setAccess,
+        getConfig,
+        putConfig,
+        listBranches,
+        listTags,
+        deleteBranches,
+        deleteTags,
+        null,
+        name);
   }
 
-  private ProjectApiImpl(CurrentUser user,
+  private ProjectApiImpl(
+      CurrentUser user,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -158,6 +201,7 @@
       ListBranches listBranches,
       ListTags listTags,
       DeleteBranches deleteBranches,
+      DeleteTags deleteTags,
       ProjectResource project,
       String name) {
     this.user = user;
@@ -180,6 +224,7 @@
     this.listBranches = listBranches;
     this.listTags = listTags;
     this.deleteBranches = deleteBranches;
+    this.deleteTags = deleteTags;
   }
 
   @Override
@@ -197,8 +242,7 @@
         throw new BadRequestException("name must match input.name");
       }
       checkRequiresCapability(user, null, CreateProject.class);
-      createProjectFactory.create(name)
-          .apply(TopLevelResource.INSTANCE, in);
+      createProjectFactory.create(name).apply(TopLevelResource.INSTANCE, in);
       return projectApi.create(projects.parse(name));
     } catch (IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot create project: " + e.getMessage(), e);
@@ -228,8 +272,7 @@
   }
 
   @Override
-  public ProjectAccessInfo access(ProjectAccessInput p)
-      throws RestApiException {
+  public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
     try {
       return setAccess.apply(checkExists(), p);
     } catch (IOException e) {
@@ -238,8 +281,7 @@
   }
 
   @Override
-  public void description(DescriptionInput in)
-      throws RestApiException {
+  public void description(DescriptionInput in) throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
     } catch (IOException e) {
@@ -290,8 +332,7 @@
     };
   }
 
-  private List<TagInfo> listTags(ListRefsRequest<TagInfo> request)
-      throws RestApiException {
+  private List<TagInfo> listTags(ListRefsRequest<TagInfo> request) throws RestApiException {
     listTags.setLimit(request.getLimit());
     listTags.setStart(request.getStart());
     listTags.setMatchSubstring(request.getSubstring());
@@ -309,8 +350,7 @@
   }
 
   @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());
@@ -319,8 +359,7 @@
   @Override
   public ChildProjectApi child(String name) throws RestApiException {
     try {
-      return childApi.create(
-          children.parse(checkExists(), IdString.fromDecoded(name)));
+      return childApi.create(children.parse(checkExists(), IdString.fromDecoded(name)));
     } catch (IOException e) {
       throw new RestApiException("Cannot parse child project", e);
     }
@@ -345,6 +384,15 @@
     }
   }
 
+  @Override
+  public void deleteTags(DeleteTagsInput in) throws RestApiException {
+    try {
+      deleteTags.apply(checkExists(), in);
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot delete tags", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 5d21c45..9483508 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -27,7 +27,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.util.SortedMap;
 
@@ -38,7 +37,8 @@
   private final Provider<ListProjects> listProvider;
 
   @Inject
-  ProjectsImpl(ProjectsCollection projects,
+  ProjectsImpl(
+      ProjectsCollection projects,
       ProjectApiImpl.Factory api,
       Provider<ListProjects> listProvider) {
     this.projects = projects;
@@ -82,8 +82,7 @@
     };
   }
 
-  private SortedMap<String, ProjectInfo> list(ListRequest request)
-      throws RestApiException {
+  private SortedMap<String, ProjectInfo> list(ListRequest request) throws RestApiException {
     ListProjects lp = listProvider.get();
     lp.setShowDescription(request.getDescription());
     lp.setLimit(request.getLimit());
@@ -112,8 +111,7 @@
         type = FilterType.PERMISSIONS;
         break;
       default:
-        throw new BadRequestException(
-            "Unknown filter type: " + request.getFilterType());
+        throw new BadRequestException("Unknown filter type: " + request.getFilterType());
     }
     lp.setFilterType(type);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 3adfd00..4e81407 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -20,11 +20,14 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.CreateTag;
+import com.google.gerrit.server.project.DeleteTag;
 import com.google.gerrit.server.project.ListTags;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.TagResource;
+import com.google.gerrit.server.project.TagsCollection;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.io.IOException;
 
 public class TagApiImpl implements TagApi {
@@ -34,16 +37,23 @@
 
   private final ListTags listTags;
   private final CreateTag.Factory createTagFactory;
+  private final DeleteTag deleteTag;
+  private final TagsCollection tags;
   private final String ref;
   private final ProjectResource project;
 
   @Inject
-  TagApiImpl(ListTags listTags,
+  TagApiImpl(
+      ListTags listTags,
       CreateTag.Factory createTagFactory,
+      DeleteTag deleteTag,
+      TagsCollection tags,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.listTags = listTags;
     this.createTagFactory = createTagFactory;
+    this.deleteTag = deleteTag;
+    this.tags = tags;
     this.project = project;
     this.ref = ref;
   }
@@ -66,4 +76,17 @@
       throw new RestApiException(e.getMessage());
     }
   }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteTag.apply(resource(), new DeleteTag.Input());
+    } catch (OrmException | IOException e) {
+      throw new RestApiException(e.getMessage());
+    }
+  }
+
+  private TagResource resource() throws RestApiException, IOException {
+    return tags.parse(project, IdString.fromDecoded(ref));
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
index bf74a4a..4d135b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -30,16 +29,17 @@
   private final GroupCache groupCache;
 
   @Inject
-  public AccountGroupIdHandler(final GroupCache groupCache,
-      @Assisted final CmdLineParser parser, @Assisted final OptionDef option,
+  public AccountGroupIdHandler(
+      final GroupCache groupCache,
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
       @Assisted final Setter<AccountGroup.Id> setter) {
     super(parser, option, setter);
     this.groupCache = groupCache;
   }
 
   @Override
-  public final int parseArguments(final Parameters params)
-      throws CmdLineException {
+  public final int parseArguments(final Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
     final AccountGroup group = groupCache.get(new AccountGroup.NameKey(n));
     if (group == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 674fe08..5644668 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.args4j;
 
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.GroupControl;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -30,19 +32,44 @@
 
 public class AccountGroupUUIDHandler extends OptionHandler<AccountGroup.UUID> {
   private final GroupBackend groupBackend;
+  private final GroupControl.Factory groupControlFactory;
 
   @Inject
-  public AccountGroupUUIDHandler(final GroupBackend groupBackend,
-      @Assisted final CmdLineParser parser, @Assisted final OptionDef option,
+  public AccountGroupUUIDHandler(
+      final GroupBackend groupBackend,
+      final GroupControl.Factory groupControlFactory,
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
       @Assisted final Setter<AccountGroup.UUID> setter) {
     super(parser, option, setter);
     this.groupBackend = groupBackend;
+    this.groupControlFactory = groupControlFactory;
   }
 
   @Override
-  public final int parseArguments(final Parameters params)
-      throws CmdLineException {
+  public final int parseArguments(final Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
+    AccountGroup.UUID uuid = new AccountGroup.UUID(n);
+    if (groupBackend.handles(uuid)) {
+      GroupDescription.Basic d = groupBackend.get(uuid);
+      if (d != null) {
+        setter.addValue(uuid);
+        return 1;
+      }
+    }
+
+    // Might be a legacy AccountGroup.Id.
+    if (n.matches("^[1-9][0-9]*$")) {
+      try {
+        AccountGroup.Id legacyId = AccountGroup.Id.parse(n);
+        uuid = groupControlFactory.controlFor(legacyId).getGroup().getGroupUUID();
+        setter.addValue(uuid);
+        return 1;
+      } catch (IllegalArgumentException | NoSuchGroupException e) {
+        // Ignored
+      }
+    }
+
     GroupReference group = GroupBackends.findExactSuggestion(groupBackend, n);
     if (group == null) {
       throw new CmdLineException(owner, "Group \"" + n + "\" does not exist");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 7f5f2d2..9ee6901 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -14,19 +14,20 @@
 
 package com.google.gerrit.server.args4j;
 
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -34,8 +35,6 @@
 import org.kohsuke.args4j.spi.Parameters;
 import org.kohsuke.args4j.spi.Setter;
 
-import java.io.IOException;
-
 public class AccountIdHandler extends OptionHandler<Account.Id> {
   private final Provider<ReviewDb> db;
   private final AccountResolver accountResolver;
@@ -59,8 +58,7 @@
   }
 
   @Override
-  public int parseArguments(Parameters params)
-      throws CmdLineException {
+  public int parseArguments(Parameters params) throws CmdLineException {
     String token = params.getParameter(0);
     Account.Id accountId;
     try {
@@ -92,9 +90,8 @@
     return 1;
   }
 
-  private Account.Id createAccountByLdap(String user)
-      throws CmdLineException, IOException {
-    if (!user.matches(Account.USER_NAME_PATTERN)) {
+  private Account.Id createAccountByLdap(String user) throws CmdLineException, IOException {
+    if (!ExternalId.isValidUsername(user)) {
       throw new CmdLineException(owner, "user \"" + user + "\" not found");
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
index 00eaf94..bdf0c91 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
@@ -23,7 +23,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -38,20 +37,20 @@
   public ChangeIdHandler(
       // TODO(dborowitz): Not sure whether this is injectable here.
       Provider<InternalChangeQuery> queryProvider,
-      @Assisted final CmdLineParser parser, @Assisted final OptionDef option,
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
       @Assisted final Setter<Change.Id> setter) {
     super(parser, option, setter);
     this.queryProvider = queryProvider;
   }
 
   @Override
-  public final int parseArguments(final Parameters params)
-      throws CmdLineException {
+  public final int parseArguments(final Parameters params) throws CmdLineException {
     final String token = params.getParameter(0);
     final String[] tokens = token.split(",");
     if (tokens.length != 3) {
-      throw new CmdLineException(owner, "change should be specified as "
-                                 + "<project>,<branch>,<change-id>");
+      throw new CmdLineException(
+          owner, "change should be specified as <project>,<branch>,<change-id>");
     }
 
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ObjectIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
index b7f2fb9..aa8a958 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
@@ -16,7 +16,6 @@
 
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
@@ -28,8 +27,10 @@
 public class ObjectIdHandler extends OptionHandler<ObjectId> {
 
   @Inject
-  public ObjectIdHandler(@Assisted final CmdLineParser parser,
-      @Assisted final OptionDef option, @Assisted final Setter<ObjectId> setter) {
+  public ObjectIdHandler(
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
+      @Assisted final Setter<ObjectId> setter) {
     super(parser, option, setter);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
index a48568f..e8283be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -28,21 +27,21 @@
 public class PatchSetIdHandler extends OptionHandler<PatchSet.Id> {
 
   @Inject
-  public PatchSetIdHandler(@Assisted final CmdLineParser parser,
-      @Assisted final OptionDef option, @Assisted final Setter<PatchSet.Id> setter) {
+  public PatchSetIdHandler(
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
+      @Assisted final Setter<PatchSet.Id> setter) {
     super(parser, option, setter);
   }
 
   @Override
-  public final int parseArguments(final Parameters params)
-      throws CmdLineException {
+  public final int parseArguments(final Parameters params) throws CmdLineException {
     final String token = params.getParameter(0);
     final PatchSet.Id id;
     try {
       id = PatchSet.Id.parse(token);
     } catch (IllegalArgumentException e) {
-      throw new CmdLineException(owner, "\"" + token
-          + "\" is not a valid patch set");
+      throw new CmdLineException(owner, "\"" + token + "\" is not a valid patch set");
     }
 
     setter.addValue(id);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
index 8771c23d..02e907f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
@@ -22,7 +22,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -32,11 +32,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 public class ProjectControlHandler extends OptionHandler<ProjectControl> {
-  private static final Logger log = LoggerFactory
-      .getLogger(ProjectControlHandler.class);
+  private static final Logger log = LoggerFactory.getLogger(ProjectControlHandler.class);
   private final ProjectControl.GenericFactory projectControlFactory;
   private final Provider<CurrentUser> user;
 
@@ -44,7 +41,8 @@
   public ProjectControlHandler(
       final ProjectControl.GenericFactory projectControlFactory,
       Provider<CurrentUser> user,
-      @Assisted final CmdLineParser parser, @Assisted final OptionDef option,
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
       @Assisted final Setter<ProjectControl> setter) {
     super(parser, option, setter);
     this.projectControlFactory = projectControlFactory;
@@ -52,8 +50,7 @@
   }
 
   @Override
-  public final int parseArguments(final Parameters params)
-      throws CmdLineException {
+  public final int parseArguments(final Parameters params) throws CmdLineException {
     String projectName = params.getParameter(0);
 
     while (projectName.endsWith("/")) {
@@ -74,17 +71,14 @@
 
     final ProjectControl control;
     try {
-      control = projectControlFactory.validateFor(
-          nameKey,
-          ProjectControl.OWNER | ProjectControl.VISIBLE,
-          user.get());
+      control =
+          projectControlFactory.validateFor(
+              nameKey, ProjectControl.OWNER | ProjectControl.VISIBLE, user.get());
     } catch (NoSuchProjectException e) {
       throw new CmdLineException(owner, e.getMessage());
     } catch (IOException e) {
       log.warn("Cannot load project " + nameWithoutSuffix, e);
-      throw new CmdLineException(
-          owner,
-          new NoSuchProjectException(nameKey).getMessage());
+      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
     }
 
     setter.addValue(control);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
index 0c20b2d..e0193c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
+import java.net.SocketAddress;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -25,19 +25,18 @@
 import org.kohsuke.args4j.spi.Parameters;
 import org.kohsuke.args4j.spi.Setter;
 
-import java.net.SocketAddress;
-
 public class SocketAddressHandler extends OptionHandler<SocketAddress> {
 
   @Inject
-  public SocketAddressHandler(@Assisted final CmdLineParser parser,
-      @Assisted final OptionDef option, @Assisted final Setter<SocketAddress> setter) {
+  public SocketAddressHandler(
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
+      @Assisted final Setter<SocketAddress> setter) {
     super(parser, option, setter);
   }
 
   @Override
-  public final int parseArguments(final Parameters params)
-      throws CmdLineException {
+  public final int parseArguments(final Parameters params) throws CmdLineException {
     final String token = params.getParameter(0);
     try {
       setter.addValue(SocketUtil.parse(token, 0));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java
index 619ec1f..b7af2e7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java
@@ -16,7 +16,6 @@
 
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -27,14 +26,15 @@
 public class SubcommandHandler extends OptionHandler<String> {
 
   @Inject
-  public SubcommandHandler(@Assisted final CmdLineParser parser,
-      @Assisted final OptionDef option, @Assisted final Setter<String> setter) {
+  public SubcommandHandler(
+      @Assisted final CmdLineParser parser,
+      @Assisted final OptionDef option,
+      @Assisted final Setter<String> setter) {
     super(parser, option, setter);
   }
 
   @Override
-  public final int parseArguments(final Parameters params)
-      throws CmdLineException {
+  public final int parseArguments(final Parameters params) throws CmdLineException {
     setter.addValue(params.getParameter(0));
     owner.stopOptionParsing();
     return 1;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/TimestampHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/TimestampHandler.java
index 21ef31a..eddfbcd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/TimestampHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/TimestampHandler.java
@@ -16,7 +16,11 @@
 
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
+import java.sql.Timestamp;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.TimeZone;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -24,18 +28,14 @@
 import org.kohsuke.args4j.spi.Parameters;
 import org.kohsuke.args4j.spi.Setter;
 
-import java.sql.Timestamp;
-import java.text.DateFormat;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.TimeZone;
-
 public class TimestampHandler extends OptionHandler<Timestamp> {
   public static final String TIMESTAMP_FORMAT = "yyyyMMdd_HHmm";
 
   @Inject
-  public TimestampHandler(@Assisted CmdLineParser parser,
-      @Assisted OptionDef option, @Assisted Setter<Timestamp> setter) {
+  public TimestampHandler(
+      @Assisted CmdLineParser parser,
+      @Assisted OptionDef option,
+      @Assisted Setter<Timestamp> setter) {
     super(parser, option, setter);
   }
 
@@ -48,9 +48,10 @@
       setter.addValue(new Timestamp(fmt.parse(timestamp).getTime()));
       return 1;
     } catch (ParseException e) {
-      throw new CmdLineException(owner,
-          String.format("Invalid timestamp: %s; expected format: %s",
-              timestamp, TIMESTAMP_FORMAT), e);
+      throw new CmdLineException(
+          owner,
+          String.format("Invalid timestamp: %s; expected format: %s", timestamp, TIMESTAMP_FORMAT),
+          e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthBackend.java
index 1050926..9ec3366 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthBackend.java
@@ -16,9 +16,7 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 
-/**
- * Implementations of AuthBackend authenticate users for incoming request.
- */
+/** Implementations of AuthBackend authenticate users for incoming request. */
 @ExtensionPoint
 public interface AuthBackend {
 
@@ -26,24 +24,20 @@
   String getDomain();
 
   /**
-   * Authenticate inspects the AuthRequest and returns authenticated user. If
-   * the request is unable to be authenticated, an exception will be thrown. The
-   * {@link MissingCredentialsException} must be thrown when there are no
-   * credentials for the request. It is expected that at most one AuthBackend
-   * will either return an AuthUser or throw a non-MissingCredentialsException.
+   * Authenticate inspects the AuthRequest and returns authenticated user. If the request is unable
+   * to be authenticated, an exception will be thrown. The {@link MissingCredentialsException} must
+   * be thrown when there are no credentials for the request. It is expected that at most one
+   * AuthBackend will either return an AuthUser or throw a non-MissingCredentialsException.
    *
    * @param req the object describing the request.
    * @return the successfully authenticated user.
    * @throws MissingCredentialsException when there are no credentials.
-   * @throws InvalidCredentialsException when the credentials are present and
-   *         invalid.
-   * @throws UnknownUserException when the credentials are valid but there is
-   *         no matching user.
-   * @throws UserNotAllowedException when the credentials are valid but the user
-   *         is not allowed.
+   * @throws InvalidCredentialsException when the credentials are present and invalid.
+   * @throws UnknownUserException when the credentials are valid but there is no matching user.
+   * @throws UserNotAllowedException when the credentials are valid but the user is not allowed.
    * @throws AuthException when any other error occurs.
    */
-  AuthUser authenticate(AuthRequest req) throws MissingCredentialsException,
-      InvalidCredentialsException, UnknownUserException,
-      UserNotAllowedException, AuthException;
+  AuthUser authenticate(AuthRequest req)
+      throws MissingCredentialsException, InvalidCredentialsException, UnknownUserException,
+          UserNotAllowedException, AuthException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthException.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthException.java
index 8cf4a0a..0c41f5b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthException.java
@@ -14,14 +14,11 @@
 
 package com.google.gerrit.server.auth;
 
-/**
- * Base type for authentication exceptions.
- */
+/** Base type for authentication exceptions. */
 public class AuthException extends Exception {
   private static final long serialVersionUID = -8946302676525580372L;
 
-  public AuthException() {
-  }
+  public AuthException() {}
 
   public AuthException(String msg) {
     super(msg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java
index e194eb7..71c5d26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthRequest.java
@@ -16,11 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 
-import java.util.Objects;
-
-/**
- * Defines an abstract request for user authentication to Gerrit.
- */
+/** Defines an abstract request for user authentication to Gerrit. */
 public abstract class AuthRequest {
   private final String username;
   private final String password;
@@ -49,10 +45,4 @@
   public final String getPassword() {
     return password;
   }
-
-  public void checkPassword(String pwd) throws AuthException {
-    if (!Objects.equals(getPassword(), pwd)) {
-      throw new InvalidCredentialsException();
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthUser.java
index f2c8222..71f29a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthUser.java
@@ -19,14 +19,10 @@
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
 
-/**
- * An authenticated user as specified by the AuthBackend.
- */
+/** An authenticated user as specified by the AuthBackend. */
 public class AuthUser {
 
-  /**
-   * Globally unique identifier for the user.
-   */
+  /** Globally unique identifier for the user. */
   @AutoValue
   public abstract static class UUID {
     /**
@@ -87,7 +83,6 @@
 
   @Override
   public String toString() {
-    return String.format("AuthUser[uuid=%s, username=%s]", getUUID(),
-        getUsername());
+    return String.format("AuthUser[uuid=%s, username=%s]", getUUID(), getUsername());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java
index 6ecea5e..508bf31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/InternalAuthBackend.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.Locale;
 
 @Singleton
@@ -39,12 +38,12 @@
     return "gerrit";
   }
 
+  // TODO(gerritcodereview-team): This function has no coverage.
   @Override
   public AuthUser authenticate(AuthRequest req)
-      throws MissingCredentialsException, InvalidCredentialsException,
-      UnknownUserException, UserNotAllowedException, AuthException {
-    if (Strings.isNullOrEmpty(req.getUsername())
-        || Strings.isNullOrEmpty(req.getPassword())) {
+      throws MissingCredentialsException, InvalidCredentialsException, UnknownUserException,
+          UserNotAllowedException, AuthException {
+    if (Strings.isNullOrEmpty(req.getUsername()) || Strings.isNullOrEmpty(req.getPassword())) {
       throw new MissingCredentialsException();
     }
 
@@ -59,11 +58,15 @@
     if (who == null) {
       throw new UnknownUserException();
     } else if (!who.getAccount().isActive()) {
-      throw new UserNotAllowedException("Authentication failed for " + username
-          + ": account inactive or not provisioned in Gerrit");
+      throw new UserNotAllowedException(
+          "Authentication failed for "
+              + username
+              + ": account inactive or not provisioned in Gerrit");
     }
 
-    req.checkPassword(who.getPassword(username));
+    if (!who.checkPassword(req.getPassword(), username)) {
+      throw new InvalidCredentialsException();
+    }
     return new AuthUser(AuthUser.UUID.create(username), username);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/InvalidCredentialsException.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/InvalidCredentialsException.java
index bca8586..c96a429 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/InvalidCredentialsException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/InvalidCredentialsException.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.auth;
 
 /**
- * An authentication exception that is thrown when the credentials are present
- * and are unable to be verified.
+ * An authentication exception that is thrown when the credentials are present and are unable to be
+ * verified.
  */
 public class InvalidCredentialsException extends AuthException {
   private static final long serialVersionUID = 3709201042080444276L;
 
-  public InvalidCredentialsException() {
-  }
+  public InvalidCredentialsException() {}
 
   public InvalidCredentialsException(String msg) {
     super(msg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/MissingCredentialsException.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/MissingCredentialsException.java
index 062728a..d30f6da 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/MissingCredentialsException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/MissingCredentialsException.java
@@ -15,17 +15,15 @@
 package com.google.gerrit.server.auth;
 
 /**
- * An authentication exception that is thrown when the credentials are not
- * present. This indicates that the AuthBackend has none of the needed
- * information in the request to perform authentication. If parts of the
- * authentication information is available to the backend, then a different
+ * An authentication exception that is thrown when the credentials are not present. This indicates
+ * that the AuthBackend has none of the needed information in the request to perform authentication.
+ * If parts of the authentication information are available to the backend, then a different
  * AuthException should be used.
  */
 public class MissingCredentialsException extends AuthException {
   private static final long serialVersionUID = -6499866977513508051L;
 
-  public MissingCredentialsException() {
-  }
+  public MissingCredentialsException() {}
 
   public MissingCredentialsException(String msg) {
     super(msg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
index 76a9fd6..3ad97b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
@@ -19,14 +19,10 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.List;
 
-/**
- * Universal implementation of the AuthBackend that works with the injected
- * set of AuthBackends.
- */
+/** Universal implementation of the AuthBackend that works with the injected set of AuthBackends. */
 @Singleton
 public final class UniversalAuthBackend implements AuthBackend {
   private final DynamicSet<AuthBackend> authBackends;
@@ -59,14 +55,15 @@
       throw new MissingCredentialsException();
     }
 
-    String msg = String.format("Multiple AuthBackends attempted to handle request:"
-        + " authUsers=%s authExs=%s", authUsers, authExs);
+    String msg =
+        String.format(
+            "Multiple AuthBackends attempted to handle request: authUsers=%s authExs=%s",
+            authUsers, authExs);
     throw new AuthException(msg);
   }
 
   @Override
   public String getDomain() {
-    throw new UnsupportedOperationException(
-        "UniversalAuthBackend doesn't support domain.");
+    throw new UnsupportedOperationException("UniversalAuthBackend doesn't support domain.");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UnknownUserException.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UnknownUserException.java
index 124912a..289d7d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UnknownUserException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UnknownUserException.java
@@ -15,14 +15,12 @@
 package com.google.gerrit.server.auth;
 
 /**
- * An authentication exception that is thrown when credentials are presented for
- * an unknown user.
+ * An authentication exception that is thrown when credentials are presented for an unknown user.
  */
 public class UnknownUserException extends AuthException {
   private static final long serialVersionUID = 1626186166924670754L;
 
-  public UnknownUserException() {
-  }
+  public UnknownUserException() {}
 
   public UnknownUserException(String msg) {
     super(msg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UserNotAllowedException.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UserNotAllowedException.java
index 2420330..1b23cb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UserNotAllowedException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UserNotAllowedException.java
@@ -15,15 +15,13 @@
 package com.google.gerrit.server.auth;
 
 /**
- * An authentication exception that is thrown when the user credentials are
- * valid, but not allowed to authenticate for other reasons i.e. account
- * disabled.
+ * An authentication exception that is thrown when the user credentials are valid, but not allowed
+ * to authenticate for other reasons i.e. account disabled.
  */
 public class UserNotAllowedException extends AuthException {
   private static final long serialVersionUID = -1531411999932922558L;
 
-  public UserNotAllowedException() {
-  }
+  public UserNotAllowedException() {}
 
   public UserNotAllowedException(String msg) {
     super(msg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
index 3567811..d2499c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -28,9 +28,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.security.PrivilegedActionException;
 import java.security.PrivilegedExceptionAction;
 import java.util.ArrayList;
@@ -41,7 +38,6 @@
 import java.util.Properties;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
-
 import javax.naming.CompositeName;
 import javax.naming.Context;
 import javax.naming.Name;
@@ -55,8 +51,14 @@
 import javax.security.auth.Subject;
 import javax.security.auth.login.LoginContext;
 import javax.security.auth.login.LoginException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-@Singleton class Helper {
+@Singleton
+class Helper {
+  private static final Logger log = LoggerFactory.getLogger(Helper.class);
+
   static final String LDAP_UUID = "ldap:";
 
   private final Cache<String, ImmutableSet<String>> parentGroups;
@@ -74,9 +76,9 @@
   private final boolean groupsVisibleToAll;
 
   @Inject
-  Helper(@GerritServerConfig final Config config,
-      @Named(LdapModule.PARENT_GROUPS_CACHE)
-      Cache<String, ImmutableSet<String>> parentGroups) {
+  Helper(
+      @GerritServerConfig final Config config,
+      @Named(LdapModule.PARENT_GROUPS_CACHE) Cache<String, ImmutableSet<String>> parentGroups) {
     this.config = config;
     this.server = LdapRealm.optional(config, "server");
     this.username = LdapRealm.optional(config, "username");
@@ -84,27 +86,23 @@
     this.referral = LdapRealm.optional(config, "referral", "ignore");
     this.sslVerify = config.getBoolean("ldap", "sslverify", true);
     this.groupsVisibleToAll = config.getBoolean("ldap", "groupsVisibleToAll", false);
-    this.authentication =
-        LdapRealm.optional(config, "authentication", "simple");
+    this.authentication = LdapRealm.optional(config, "authentication", "simple");
     String readTimeout = LdapRealm.optional(config, "readTimeout");
     if (readTimeout != null) {
       readTimeoutMillis =
-          Long.toString(ConfigUtil.getTimeUnit(readTimeout, 0,
-              TimeUnit.MILLISECONDS));
+          Long.toString(ConfigUtil.getTimeUnit(readTimeout, 0, TimeUnit.MILLISECONDS));
     } else {
       readTimeoutMillis = null;
     }
     String connectTimeout = LdapRealm.optional(config, "connectTimeout");
     if (connectTimeout != null) {
       connectTimeoutMillis =
-          Long.toString(ConfigUtil.getTimeUnit(connectTimeout, 0,
-              TimeUnit.MILLISECONDS));
+          Long.toString(ConfigUtil.getTimeUnit(connectTimeout, 0, TimeUnit.MILLISECONDS));
     } else {
       connectTimeoutMillis = null;
     }
     this.parentGroups = parentGroups;
-    this.useConnectionPooling =
-        LdapRealm.optional(config, "useConnectionPooling", false);
+    this.useConnectionPooling = LdapRealm.optional(config, "useConnectionPooling", false);
   }
 
   private Properties createContextProperties() {
@@ -141,22 +139,23 @@
     return new InitialDirContext(env);
   }
 
-  private DirContext kerberosOpen(final Properties env) throws LoginException,
-      NamingException {
+  private DirContext kerberosOpen(final Properties env) throws LoginException, NamingException {
     LoginContext ctx = new LoginContext("KerberosLogin");
     ctx.login();
     Subject subject = ctx.getSubject();
     try {
-      return Subject.doAs(subject, new PrivilegedExceptionAction<DirContext>() {
-          @Override
-          public DirContext run() throws NamingException {
-            return new InitialDirContext(env);
-          }
-        });
+      return Subject.doAs(
+          subject,
+          new PrivilegedExceptionAction<DirContext>() {
+            @Override
+            public DirContext run() throws NamingException {
+              return new InitialDirContext(env);
+            }
+          });
     } catch (PrivilegedActionException e) {
-      Throwables.propagateIfPossible(e.getException(), NamingException.class);
-      Throwables.propagateIfPossible(e.getException(), RuntimeException.class);
-      LdapRealm.log.warn("Internal error", e.getException());
+      Throwables.throwIfInstanceOf(e.getException(), NamingException.class);
+      Throwables.throwIfInstanceOf(e.getException(), RuntimeException.class);
+      log.warn("Internal error", e.getException());
       return null;
     } finally {
       ctx.logout();
@@ -187,8 +186,8 @@
     return ldapSchema;
   }
 
-  LdapQuery.Result findAccount(Helper.LdapSchema schema,
-      DirContext ctx, String username, boolean fetchMemberOf)
+  LdapQuery.Result findAccount(
+      Helper.LdapSchema schema, DirContext ctx, String username, boolean fetchMemberOf)
       throws NamingException, AccountException {
     final HashMap<String, String> params = new HashMap<>();
     params.put(LdapRealm.USERNAME, username);
@@ -211,8 +210,8 @@
     throw new NoSuchUserException(username);
   }
 
-  Set<AccountGroup.UUID> queryForGroups(final DirContext ctx,
-      final String username, LdapQuery.Result account)
+  Set<AccountGroup.UUID> queryForGroups(
+      final DirContext ctx, final String username, LdapQuery.Result account)
       throws NamingException {
     final LdapSchema schema = getSchema(ctx);
     final Set<String> groupDNs = new HashSet<>();
@@ -274,9 +273,14 @@
     return ImmutableSet.copyOf(actual);
   }
 
-  private void recursivelyExpandGroups(final Set<String> groupDNs,
-      final LdapSchema schema, final DirContext ctx, final String groupDN) {
-    if (groupDNs.add(groupDN) && schema.accountMemberField != null) {
+  private void recursivelyExpandGroups(
+      final Set<String> groupDNs,
+      final LdapSchema schema,
+      final DirContext ctx,
+      final String groupDN) {
+    if (groupDNs.add(groupDN)
+        && schema.accountMemberField != null
+        && schema.accountMemberExpandGroups) {
       ImmutableSet<String> cachedParentsDNs = parentGroups.getIfPresent(groupDN);
       if (cachedParentsDNs == null) {
         // Recursively identify the groups it is a member of.
@@ -285,7 +289,7 @@
           final Name compositeGroupName = new CompositeName().add(groupDN);
           final Attribute in =
               ctx.getAttributes(compositeGroupName, schema.accountMemberFieldArray)
-                .get(schema.accountMemberField);
+                  .get(schema.accountMemberField);
           if (in != null) {
             final NamingEnumeration<?> groups = in.getAll();
             try {
@@ -297,7 +301,7 @@
             }
           }
         } catch (NamingException e) {
-          LdapRealm.log.warn("Could not find group " + groupDN, e);
+          log.warn("Could not find group {}", groupDN, e);
         }
         cachedParentsDNs = dns.build();
         parentGroups.put(groupDN, cachedParentsDNs);
@@ -319,6 +323,7 @@
     final ParameterizedString accountEmailAddress;
     final ParameterizedString accountSshUserName;
     final String accountMemberField;
+    final boolean accountMemberExpandGroups;
     final String[] accountMemberFieldArray;
     final List<LdapQuery> accountQueryList;
     final List<LdapQuery> accountWithMemberOfQueryList;
@@ -350,11 +355,13 @@
       for (String groupBase : groupBases) {
         if (groupMemberPattern != null) {
           final LdapQuery groupMemberQuery =
-              new LdapQuery(groupBase, groupScope, new ParameterizedString(
-                  groupMemberPattern), Collections.<String> emptySet());
+              new LdapQuery(
+                  groupBase,
+                  groupScope,
+                  new ParameterizedString(groupMemberPattern),
+                  Collections.<String>emptySet());
           if (groupMemberQuery.getParameters().isEmpty()) {
-            throw new IllegalArgumentException(
-                "No variables in ldap.groupMemberPattern");
+            throw new IllegalArgumentException("No variables in ldap.groupMemberPattern");
           }
 
           for (final String name : groupMemberQuery.getParameters()) {
@@ -367,14 +374,12 @@
 
       // Account query
       //
-      accountFullName =
-          LdapRealm.paramString(config, "accountFullName", type.accountFullName());
+      accountFullName = LdapRealm.paramString(config, "accountFullName", type.accountFullName());
       if (accountFullName != null) {
         accountAtts.addAll(accountFullName.getParameterNames());
       }
       accountEmailAddress =
-          LdapRealm.paramString(config, "accountEmailAddress", type
-              .accountEmailAddress());
+          LdapRealm.paramString(config, "accountEmailAddress", type.accountEmailAddress());
       if (accountEmailAddress != null) {
         accountAtts.addAll(accountEmailAddress.getParameterNames());
       }
@@ -390,6 +395,8 @@
       } else {
         accountMemberFieldArray = null;
       }
+      accountMemberExpandGroups =
+          LdapRealm.optional(config, "accountMemberExpandGroups", type.accountMemberExpandGroups());
 
       final SearchScope accountScope = LdapRealm.scope(config, "accountScope");
       final String accountPattern =
@@ -404,18 +411,20 @@
       }
       for (String accountBase : LdapRealm.requiredList(config, "accountBase")) {
         LdapQuery accountQuery =
-            new LdapQuery(accountBase, accountScope, new ParameterizedString(
-                accountPattern), accountAtts);
+            new LdapQuery(
+                accountBase, accountScope, new ParameterizedString(accountPattern), accountAtts);
         if (accountQuery.getParameters().isEmpty()) {
-          throw new IllegalArgumentException(
-              "No variables in ldap.accountPattern");
+          throw new IllegalArgumentException("No variables in ldap.accountPattern");
         }
         accountQueryList.add(accountQuery);
 
         if (accountWithMemberOfAtts != null) {
           LdapQuery accountWithMemberOfQuery =
-              new LdapQuery(accountBase, accountScope, new ParameterizedString(
-                  accountPattern), accountWithMemberOfAtts);
+              new LdapQuery(
+                  accountBase,
+                  accountScope,
+                  new ParameterizedString(accountPattern),
+                  accountWithMemberOfAtts);
           accountWithMemberOfQueryList.add(accountWithMemberOfQuery);
         }
       }
@@ -425,8 +434,11 @@
       try {
         return LdapType.guessType(ctx);
       } catch (NamingException e) {
-        LdapRealm.log.warn("Cannot discover type of LDAP server at " + server
-            + ", assuming the server is RFC 2307 compliant.", e);
+        log.warn(
+            "Cannot discover type of LDAP server at {},"
+                + " assuming the server is RFC 2307 compliant.",
+            server,
+            e);
         return LdapType.RFC_2307;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
index 8dc7177..2854294 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.AuthException;
@@ -27,20 +27,15 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
+import java.util.Locale;
+import javax.naming.NamingException;
+import javax.naming.directory.DirContext;
+import javax.security.auth.login.LoginException;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Locale;
-
-import javax.naming.NamingException;
-import javax.naming.directory.DirContext;
-import javax.security.auth.login.LoginException;
-
-/**
- * Implementation of AuthBackend for the LDAP authentication system.
- */
+/** Implementation of AuthBackend for the LDAP authentication system. */
 public class LdapAuthBackend implements AuthBackend {
   private static final Logger log = LoggerFactory.getLogger(LdapAuthBackend.class);
 
@@ -49,13 +44,10 @@
   private final boolean lowerCaseUsername;
 
   @Inject
-  public LdapAuthBackend(Helper helper,
-      AuthConfig authConfig,
-      @GerritServerConfig Config config) {
+  public LdapAuthBackend(Helper helper, AuthConfig authConfig, @GerritServerConfig Config config) {
     this.helper = helper;
     this.authConfig = authConfig;
-    this.lowerCaseUsername =
-        config.getBoolean("ldap", "localUsernameToLowerCase", false);
+    this.lowerCaseUsername = config.getBoolean("ldap", "localUsernameToLowerCase", false);
   }
 
   @Override
@@ -65,15 +57,14 @@
 
   @Override
   public AuthUser authenticate(AuthRequest req)
-      throws MissingCredentialsException, InvalidCredentialsException,
-      UnknownUserException, UserNotAllowedException, AuthException {
+      throws MissingCredentialsException, InvalidCredentialsException, UnknownUserException,
+          UserNotAllowedException, AuthException {
     if (req.getUsername() == null) {
       throw new MissingCredentialsException();
     }
 
-    final String username = lowerCaseUsername
-        ? req.getUsername().toLowerCase(Locale.US)
-        : req.getUsername();
+    final String username =
+        lowerCaseUsername ? req.getUsername().toLowerCase(Locale.US) : req.getUsername();
     try {
       final DirContext ctx;
       if (authConfig.getAuthType() == AuthType.LDAP_BIND) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index bd4a3b0..9efad24 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
 import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
@@ -25,39 +26,36 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.name.Named;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
-
 import javax.naming.InvalidNameException;
 import javax.naming.NamingException;
 import javax.naming.directory.DirContext;
 import javax.naming.ldap.LdapName;
 import javax.naming.ldap.Rdn;
 import javax.security.auth.login.LoginException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-/**
- * Implementation of GroupBackend for the LDAP group system.
- */
+/** Implementation of GroupBackend for the LDAP group system. */
 public class LdapGroupBackend implements GroupBackend {
   static final Logger log = LoggerFactory.getLogger(LdapGroupBackend.class);
 
@@ -69,6 +67,7 @@
   private final LoadingCache<String, Boolean> existsCache;
   private final ProjectCache projectCache;
   private final Provider<CurrentUser> userProvider;
+  private final Config gerritConfig;
 
   @Inject
   LdapGroupBackend(
@@ -76,23 +75,24 @@
       @Named(GROUP_CACHE) LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
       @Named(GROUP_EXIST_CACHE) LoadingCache<String, Boolean> existsCache,
       ProjectCache projectCache,
-      Provider<CurrentUser> userProvider) {
+      Provider<CurrentUser> userProvider,
+      @GerritServerConfig Config gerritConfig) {
     this.helper = helper;
     this.membershipCache = membershipCache;
     this.projectCache = projectCache;
     this.existsCache = existsCache;
     this.userProvider = userProvider;
+    this.gerritConfig = gerritConfig;
   }
 
   private boolean isLdapUUID(AccountGroup.UUID uuid) {
     return uuid.get().startsWith(LDAP_UUID);
   }
 
-  private static GroupReference groupReference(ParameterizedString p,
-      LdapQuery.Result res) throws NamingException {
+  private static GroupReference groupReference(ParameterizedString p, LdapQuery.Result res)
+      throws NamingException {
     return new GroupReference(
-        new AccountGroup.UUID(LDAP_UUID + res.getDN()),
-        LDAP_NAME + LdapRealm.apply(p, res));
+        new AccountGroup.UUID(LDAP_UUID + res.getDN()), LDAP_NAME + LdapRealm.apply(p, res));
   }
 
   private static String cnFor(String dn) {
@@ -125,14 +125,13 @@
 
     String groupDn = uuid.get().substring(LDAP_UUID.length());
     CurrentUser user = userProvider.get();
-    if (!(user.isIdentifiedUser())
-        || !membershipsOf(user.asIdentifiedUser()).contains(uuid)) {
+    if (!(user.isIdentifiedUser()) || !membershipsOf(user.asIdentifiedUser()).contains(uuid)) {
       try {
         if (!existsCache.get(groupDn)) {
           return null;
         }
       } catch (ExecutionException e) {
-        log.warn(String.format("Cannot lookup group %s in LDAP", groupDn), e);
+        log.warn("Cannot lookup group {} in LDAP", groupDn, e);
         return null;
       }
     }
@@ -184,19 +183,18 @@
     if (id == null) {
       return GroupMembership.EMPTY;
     }
-    return new LdapGroupMembership(membershipCache, projectCache, id);
+    return new LdapGroupMembership(membershipCache, projectCache, id, gerritConfig);
   }
 
-  private static String findId(final Collection<AccountExternalId> ids) {
-    for (final AccountExternalId i : ids) {
-      if (i.isScheme(AccountExternalId.SCHEME_GERRIT)) {
-        return i.getSchemeRest();
+  private static String findId(Collection<ExternalId> extIds) {
+    for (ExternalId extId : extIds) {
+      if (extId.isScheme(SCHEME_GERRIT)) {
+        return extId.key().id();
       }
     }
     return null;
   }
 
-
   private Set<GroupReference> suggestLdap(String name) {
     if (name.isEmpty()) {
       return Collections.emptySet();
@@ -209,14 +207,12 @@
         // Do exact lookups until there are at least 3 characters.
         name = Rdn.escapeValue(name) + ((name.length() >= 3) ? "*" : "");
         LdapSchema schema = helper.getSchema(ctx);
-        ParameterizedString filter = ParameterizedString.asis(
-            schema.groupPattern.replace(GROUPNAME, name).toString());
-        Set<String> returnAttrs =
-            new HashSet<>(schema.groupName.getParameterNames());
+        ParameterizedString filter =
+            ParameterizedString.asis(schema.groupPattern.replace(GROUPNAME, name).toString());
+        Set<String> returnAttrs = new HashSet<>(schema.groupName.getParameterNames());
         Map<String, String> params = Collections.emptyMap();
         for (String groupBase : schema.groupBases) {
-          LdapQuery query = new LdapQuery(
-              groupBase, schema.groupScope, filter, returnAttrs);
+          LdapQuery query = new LdapQuery(groupBase, schema.groupScope, filter, returnAttrs);
           for (LdapQuery.Result res : query.query(ctx, params)) {
             out.add(groupReference(schema.groupName, res));
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
index 7853320..a12be7c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
@@ -19,24 +19,27 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.project.ProjectCache;
-
 import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
 
 class LdapGroupMembership implements GroupMembership {
   private final LoadingCache<String, Set<AccountGroup.UUID>> membershipCache;
   private final ProjectCache projectCache;
   private final String id;
+  private final boolean guessRelevantGroups;
   private GroupMembership membership;
 
   LdapGroupMembership(
       LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
       ProjectCache projectCache,
-      String id) {
+      String id,
+      Config gerritConfig) {
     this.membershipCache = membershipCache;
     this.projectCache = projectCache;
     this.id = id;
+    this.guessRelevantGroups = gerritConfig.getBoolean("ldap", "guessRelevantGroups", true);
   }
 
   @Override
@@ -57,7 +60,9 @@
   @Override
   public Set<AccountGroup.UUID> getKnownGroups() {
     Set<AccountGroup.UUID> g = new HashSet<>(get().getKnownGroups());
-    g.retainAll(projectCache.guessRelevantGroupUUIDs());
+    if (guessRelevantGroups) {
+      g.retainAll(projectCache.guessRelevantGroupUUIDs());
+    }
     return g;
   }
 
@@ -66,8 +71,7 @@
       try {
         membership = new ListGroupMembership(membershipCache.get(id));
       } catch (ExecutionException e) {
-        LdapGroupBackend.log.warn(String.format(
-            "Cannot lookup membershipsOf %s in LDAP", id), e);
+        LdapGroupBackend.log.warn("Cannot lookup membershipsOf {} in LDAP", id, e);
         membership = GroupMembership.EMPTY;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java
index eaaafd6..05228b4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapModule.java
@@ -16,7 +16,6 @@
 
 import static java.util.concurrent.TimeUnit.HOURS;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
@@ -26,7 +25,7 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.inject.Scopes;
 import com.google.inject.TypeLiteral;
-
+import java.util.Optional;
 import java.util.Set;
 
 public class LdapModule extends CacheModule {
@@ -35,30 +34,21 @@
   static final String GROUP_EXIST_CACHE = "ldap_group_existence";
   static final String PARENT_GROUPS_CACHE = "ldap_groups_byinclude";
 
-
   @Override
   protected void configure() {
-    cache(GROUP_CACHE,
-        String.class,
-        new TypeLiteral<Set<AccountGroup.UUID>>() {})
-      .expireAfterWrite(1, HOURS)
-      .loader(LdapRealm.MemberLoader.class);
+    cache(GROUP_CACHE, String.class, new TypeLiteral<Set<AccountGroup.UUID>>() {})
+        .expireAfterWrite(1, HOURS)
+        .loader(LdapRealm.MemberLoader.class);
 
-    cache(USERNAME_CACHE,
-        String.class,
-        new TypeLiteral<Optional<Account.Id>>() {})
-      .loader(LdapRealm.UserLoader.class);
+    cache(USERNAME_CACHE, String.class, new TypeLiteral<Optional<Account.Id>>() {})
+        .loader(LdapRealm.UserLoader.class);
 
-    cache(GROUP_EXIST_CACHE,
-        String.class,
-        new TypeLiteral<Boolean>() {})
-      .expireAfterWrite(1, HOURS)
-      .loader(LdapRealm.ExistenceLoader.class);
+    cache(GROUP_EXIST_CACHE, String.class, new TypeLiteral<Boolean>() {})
+        .expireAfterWrite(1, HOURS)
+        .loader(LdapRealm.ExistenceLoader.class);
 
-    cache(PARENT_GROUPS_CACHE,
-        String.class,
-        new TypeLiteral<ImmutableSet<String>>() {})
-      .expireAfterWrite(1, HOURS);
+    cache(PARENT_GROUPS_CACHE, String.class, new TypeLiteral<ImmutableSet<String>>() {})
+        .expireAfterWrite(1, HOURS);
 
     bind(Helper.class);
     bind(Realm.class).to(LdapRealm.class).in(Scopes.SINGLETON);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
index e9ae8f5..28eb05d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
@@ -15,14 +15,12 @@
 package com.google.gerrit.server.auth.ldap;
 
 import com.google.gerrit.common.data.ParameterizedString;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-
 import javax.naming.NamingEnumeration;
 import javax.naming.NamingException;
 import javax.naming.PartialResultException;
@@ -41,8 +39,11 @@
   private final ParameterizedString pattern;
   private final String[] returnAttributes;
 
-  LdapQuery(final String base, final SearchScope searchScope,
-      final ParameterizedString pattern, final Set<String> returnAttributes) {
+  LdapQuery(
+      final String base,
+      final SearchScope searchScope,
+      final ParameterizedString pattern,
+      final Set<String> returnAttributes) {
     this.base = base;
     this.searchScope = searchScope;
 
@@ -126,9 +127,9 @@
     @Override
     public String toString() {
       try {
-          return getDN();
+        return getDN();
       } catch (NamingException e) {
-          return "";
+        return "";
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 30b08a6..122d4bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -14,22 +14,24 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.EmailExpander;
+import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -37,29 +39,28 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
-
 import javax.naming.CompositeName;
 import javax.naming.Name;
 import javax.naming.NamingException;
 import javax.naming.directory.DirContext;
 import javax.security.auth.login.LoginException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 class LdapRealm extends AbstractRealm {
-  static final Logger log = LoggerFactory.getLogger(LdapRealm.class);
+  private static final Logger log = LoggerFactory.getLogger(LdapRealm.class);
+
   static final String LDAP = "com.sun.jndi.ldap.LdapCtxFactory";
   static final String USERNAME = "username";
 
@@ -67,8 +68,11 @@
   private final AuthConfig authConfig;
   private final EmailExpander emailExpander;
   private final LoadingCache<String, Optional<Account.Id>> usernameCache;
-  private final Set<Account.FieldName> readOnlyAccountFields;
+  private final Set<AccountFieldName> readOnlyAccountFields;
   private final boolean fetchMemberOfEagerly;
+  private final String mandatoryGroup;
+  private final LdapGroupBackend groupBackend;
+
   private final Config config;
 
   private final LoadingCache<String, Set<AccountGroup.UUID>> membershipCache;
@@ -78,12 +82,16 @@
       Helper helper,
       AuthConfig authConfig,
       EmailExpander emailExpander,
-      @Named(LdapModule.GROUP_CACHE) final LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
-      @Named(LdapModule.USERNAME_CACHE) final LoadingCache<String, Optional<Account.Id>> usernameCache,
+      LdapGroupBackend groupBackend,
+      @Named(LdapModule.GROUP_CACHE)
+          final LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
+      @Named(LdapModule.USERNAME_CACHE)
+          final LoadingCache<String, Optional<Account.Id>> usernameCache,
       @GerritServerConfig final Config config) {
     this.helper = helper;
     this.authConfig = authConfig;
     this.emailExpander = emailExpander;
+    this.groupBackend = groupBackend;
     this.usernameCache = usernameCache;
     this.membershipCache = membershipCache;
     this.config = config;
@@ -91,16 +99,17 @@
     this.readOnlyAccountFields = new HashSet<>();
 
     if (optdef(config, "accountFullName", "DEFAULT") != null) {
-      readOnlyAccountFields.add(Account.FieldName.FULL_NAME);
+      readOnlyAccountFields.add(AccountFieldName.FULL_NAME);
     }
     if (optdef(config, "accountSshUserName", "DEFAULT") != null) {
-      readOnlyAccountFields.add(Account.FieldName.USER_NAME);
+      readOnlyAccountFields.add(AccountFieldName.USER_NAME);
     }
     if (!authConfig.isAllowRegisterNewEmail()) {
-      readOnlyAccountFields.add(Account.FieldName.REGISTER_NEW_EMAIL);
+      readOnlyAccountFields.add(AccountFieldName.REGISTER_NEW_EMAIL);
     }
 
     fetchMemberOfEagerly = optional(config, "fetchMemberOfEagerly", true);
+    mandatoryGroup = optional(config, "mandatoryGroup");
   }
 
   static SearchScope scope(final Config c, final String setting) {
@@ -135,14 +144,12 @@
     return v;
   }
 
-  static List<String> optionalList(final Config config,
-      final String name) {
+  static List<String> optionalList(final Config config, final String name) {
     String[] s = config.getStringList("ldap", null, name);
     return Arrays.asList(s);
   }
 
-  static List<String> requiredList(final Config config,
-      final String name) {
+  static List<String> requiredList(final Config config, final String name) {
     List<String> vlist = optionalList(config, name);
 
     if (vlist.isEmpty()) {
@@ -185,23 +192,21 @@
     }
   }
 
-  private static void checkBackendCompliance(String configOption,
-      String suppliedValue, boolean disabledByBackend) {
+  private static void checkBackendCompliance(
+      String configOption, String suppliedValue, boolean disabledByBackend) {
     if (disabledByBackend && !Strings.isNullOrEmpty(suppliedValue)) {
-      String msg = String.format("LDAP backend doesn't support: ldap.%s",
-          configOption);
+      String msg = String.format("LDAP backend doesn't support: ldap.%s", configOption);
       log.error(msg);
       throw new IllegalArgumentException(msg);
     }
   }
 
   @Override
-  public boolean allowsEdit(final Account.FieldName field) {
+  public boolean allowsEdit(final AccountFieldName field) {
     return !readOnlyAccountFields.contains(field);
   }
 
-  static String apply(ParameterizedString p, LdapQuery.Result m)
-      throws NamingException {
+  static String apply(ParameterizedString p, LdapQuery.Result m) throws NamingException {
     if (p == null) {
       return null;
     }
@@ -216,8 +221,7 @@
   }
 
   @Override
-  public AuthRequest authenticate(final AuthRequest who)
-      throws AccountException {
+  public AuthRequest authenticate(final AuthRequest who) throws AccountException {
     if (config.getBoolean("ldap", "localUsernameToLowerCase", false)) {
       who.setLocalUser(who.getLocalUser().toLowerCase(Locale.US));
     }
@@ -232,8 +236,7 @@
       }
       try {
         final Helper.LdapSchema schema = helper.getSchema(ctx);
-        final LdapQuery.Result m = helper.findAccount(schema, ctx, username,
-            fetchMemberOfEagerly);
+        final LdapQuery.Result m = helper.findAccount(schema, ctx, username, fetchMemberOfEagerly);
 
         if (authConfig.getAuthType() == AuthType.LDAP && !who.isSkipAuthentication()) {
           // We found the user account, but we need to verify
@@ -262,8 +265,22 @@
         // in the middle of authenticating the user, its likely we will
         // need to know what access rights they have soon.
         //
-        if (fetchMemberOfEagerly) {
-          membershipCache.put(username, helper.queryForGroups(ctx, username, m));
+        if (fetchMemberOfEagerly || mandatoryGroup != null) {
+          Set<AccountGroup.UUID> groups = helper.queryForGroups(ctx, username, m);
+          if (mandatoryGroup != null) {
+            GroupReference mandatoryGroupRef =
+                GroupBackends.findExactSuggestion(groupBackend, mandatoryGroup);
+            if (mandatoryGroupRef == null) {
+              throw new AccountException("Could not identify mandatory group: " + mandatoryGroup);
+            }
+            if (!groups.contains(mandatoryGroupRef.getUUID())) {
+              throw new AccountException(
+                  "Not member of mandatory LDAP group: " + mandatoryGroupRef.getName());
+            }
+          }
+          // Regardless if we enabled fetchMemberOfEagerly, we already have the
+          // groups and it would be a waste not to cache them.
+          membershipCache.put(username, groups);
         }
         return who;
       } finally {
@@ -294,9 +311,9 @@
     }
     try {
       Optional<Account.Id> id = usernameCache.get(accountName);
-      return id != null ? id.orNull() : null;
+      return id != null ? id.orElse(null) : null;
     } catch (ExecutionException e) {
-      log.warn(String.format("Cannot lookup account %s in LDAP", accountName), e);
+      log.warn("Cannot lookup account {} in LDAP", accountName, e);
       return null;
     }
   }
@@ -312,13 +329,13 @@
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
-        final AccountExternalId extId =
-            db.accountExternalIds().get(
-                new AccountExternalId.Key(SCHEME_GERRIT, username));
-        if (extId != null) {
-          return Optional.of(extId.getAccountId());
-        }
-        return Optional.absent();
+        return Optional.ofNullable(
+                ExternalId.from(
+                    db.accountExternalIds()
+                        .get(
+                            ExternalId.Key.create(SCHEME_GERRIT, username)
+                                .asAccountExternalIdKey())))
+            .map(ExternalId::accountId);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java
index 3c1b0d2..5df13f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java
@@ -25,11 +25,17 @@
   static LdapType guessType(final DirContext ctx) throws NamingException {
     final Attributes rootAtts = ctx.getAttributes("");
     Attribute supported = rootAtts.get("supportedCapabilities");
-    if (supported != null && (supported.contains("1.2.840.113556.1.4.800")
-          || supported.contains("1.2.840.113556.1.4.1851"))) {
+    if (supported != null
+        && (supported.contains("1.2.840.113556.1.4.800")
+            || supported.contains("1.2.840.113556.1.4.1851"))) {
       return new ActiveDirectory();
     }
 
+    supported = rootAtts.get("supportedExtension");
+    if (supported != null && supported.contains("2.16.840.1.113730.3.8.10.1")) {
+      return new FreeIPA();
+    }
+
     return RFC_2307;
   }
 
@@ -47,6 +53,8 @@
 
   abstract String accountMemberField();
 
+  abstract boolean accountMemberExpandGroups();
+
   abstract String accountPattern();
 
   private static class Rfc2307 extends LdapType {
@@ -89,6 +97,11 @@
     String accountPattern() {
       return "(uid=${username})";
     }
+
+    @Override
+    boolean accountMemberExpandGroups() {
+      return true;
+    }
   }
 
   private static class ActiveDirectory extends LdapType {
@@ -131,5 +144,58 @@
     String accountPattern() {
       return "(&(objectClass=user)(sAMAccountName=${username}))";
     }
+
+    @Override
+    boolean accountMemberExpandGroups() {
+      return true;
+    }
+  }
+
+  private static class FreeIPA extends LdapType {
+
+    @Override
+    String groupPattern() {
+      return "(cn=${groupname})";
+    }
+
+    @Override
+    String groupName() {
+      return "cn";
+    }
+
+    @Override
+    String groupMemberPattern() {
+      return null; // FreeIPA uses memberOf in the account
+    }
+
+    @Override
+    String accountFullName() {
+      return "displayName";
+    }
+
+    @Override
+    String accountEmailAddress() {
+      return "mail";
+    }
+
+    @Override
+    String accountSshUserName() {
+      return "uid";
+    }
+
+    @Override
+    String accountMemberField() {
+      return "memberOf";
+    }
+
+    @Override
+    String accountPattern() {
+      return "(uid=${username})";
+    }
+
+    @Override
+    boolean accountMemberExpandGroups() {
+      return false;
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
index c07b4c8..a1d9350 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
@@ -17,9 +17,9 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
+import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Account.FieldName;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -27,57 +27,48 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.util.HashSet;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class OAuthRealm extends AbstractRealm {
   private final DynamicMap<OAuthLoginProvider> loginProviders;
-  private final Set<FieldName> editableAccountFields;
+  private final Set<AccountFieldName> editableAccountFields;
 
   @Inject
-  OAuthRealm(DynamicMap<OAuthLoginProvider> loginProviders,
-      @GerritServerConfig Config config) {
+  OAuthRealm(DynamicMap<OAuthLoginProvider> loginProviders, @GerritServerConfig Config config) {
     this.loginProviders = loginProviders;
     this.editableAccountFields = new HashSet<>();
     // User name should be always editable, because not all OAuth providers
     // expose them
-    editableAccountFields.add(FieldName.USER_NAME);
+    editableAccountFields.add(AccountFieldName.USER_NAME);
     if (config.getBoolean("oauth", null, "allowEditFullName", false)) {
-      editableAccountFields.add(FieldName.FULL_NAME);
+      editableAccountFields.add(AccountFieldName.FULL_NAME);
     }
     if (config.getBoolean("oauth", null, "allowRegisterNewEmail", false)) {
-      editableAccountFields.add(FieldName.REGISTER_NEW_EMAIL);
+      editableAccountFields.add(AccountFieldName.REGISTER_NEW_EMAIL);
     }
   }
 
   @Override
-  public boolean allowsEdit(FieldName field) {
+  public boolean allowsEdit(AccountFieldName field) {
     return editableAccountFields.contains(field);
   }
 
   /**
-   * Authenticates with the {@link OAuthLoginProvider} specified
-   * in the authentication request.
+   * Authenticates with the {@link OAuthLoginProvider} specified in the authentication request.
    *
-   * {@link AccountManager} calls this method without password
-   * if authenticity of the user has already been established.
-   * In that case we can skip the authentication request to the
-   * {@code OAuthLoginService}.
+   * <p>{@link AccountManager} calls this method without password if authenticity of the user has
+   * already been established. In that case we can skip the authentication request to the {@code
+   * OAuthLoginService}.
    *
    * @param who the authentication request.
-   *
-   * @return the authentication request with resolved email address
-   * and display name in case the authenticity of the user could
-   * be established; otherwise {@code who} is returned unchanged.
-   *
-   * @throws AccountException if the authentication request with
-   * the OAuth2 server failed or no {@code OAuthLoginProvider} was
-   * available to handle the request.
+   * @return the authentication request with resolved email address and display name in case the
+   *     authenticity of the user could be established; otherwise {@code who} is returned unchanged.
+   * @throws AccountException if the authentication request with the OAuth2 server failed or no
+   *     {@code OAuthLoginProvider} was available to handle the request.
    */
   @Override
   public AuthRequest authenticate(AuthRequest who) throws AccountException {
@@ -106,20 +97,19 @@
     }
     if (!Strings.isNullOrEmpty(userInfo.getEmailAddress())
         && (Strings.isNullOrEmpty(who.getUserName())
-            || !allowsEdit(FieldName.REGISTER_NEW_EMAIL))) {
+            || !allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL))) {
       who.setEmailAddress(userInfo.getEmailAddress());
     }
     if (!Strings.isNullOrEmpty(userInfo.getDisplayName())
         && (Strings.isNullOrEmpty(who.getDisplayName())
-            || !allowsEdit(FieldName.FULL_NAME))) {
+            || !allowsEdit(AccountFieldName.FULL_NAME))) {
       who.setDisplayName(userInfo.getDisplayName());
     }
     return who;
   }
 
   @Override
-  public void onCreateAccount(AuthRequest who, Account account) {
-  }
+  public void onCreateAccount(AuthRequest who, Account account) {}
 
   @Override
   public Account.Id lookup(String accountName) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 94bdb06..1ac3bca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -45,7 +45,8 @@
   private final Cache<Account.Id, OAuthToken> cache;
 
   @Inject
-  OAuthTokenCache(@Named(OAUTH_TOKENS) Cache<Account.Id, OAuthToken> cache,
+  OAuthTokenCache(
+      @Named(OAUTH_TOKENS) Cache<Account.Id, OAuthToken> cache,
       DynamicItem<OAuthTokenEncrypter> encrypter) {
     this.cache = cache;
     this.encrypter = encrypter;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
index 7e15f52..d30e667 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.openid;
 
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.ExternalId;
 
 public class OpenIdProviderPattern {
   public static OpenIdProviderPattern create(String pattern) {
@@ -27,15 +27,14 @@
   protected boolean regex;
   protected String pattern;
 
-  protected OpenIdProviderPattern() {
-  }
+  protected OpenIdProviderPattern() {}
 
   public boolean matches(String id) {
     return regex ? id.matches(pattern) : id.startsWith(pattern);
   }
 
-  public boolean matches(AccountExternalId id) {
-    return matches(id.getExternalId());
+  public boolean matches(ExternalId extId) {
+    return matches(extId.key().get());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
index 571a7e5..72134c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
@@ -20,7 +20,7 @@
 /**
  * Provide avatar URLs for specified user.
  *
- * Invoked by Gerrit when Avatar image requests are made.
+ * <p>Invoked by Gerrit when Avatar image requests are made.
  */
 @ExtensionPoint
 public interface AvatarProvider {
@@ -28,14 +28,13 @@
    * Get avatar URL.
    *
    * @param forUser The user for which to load an avatar image
-   * @param imageSize A requested image size, in pixels. An imageSize of 0
-   *        indicates to use whatever default size the provider determines.
-   *        AvatarProviders may ignore the requested image size. The web
-   *        interface will resize any image to match imageSize, so ideally the
-   *        provider should return an image sized correctly.
-   * @return a URL of an avatar image for the specified user. A return value of
-   *         {@code null} is acceptable, and results in the server responding
-   *         with a 404. This will hide the avatar image in the web UI.
+   * @param imageSize A requested image size, in pixels. An imageSize of 0 indicates to use whatever
+   *     default size the provider determines. AvatarProviders may ignore the requested image size.
+   *     The web interface will resize any image to match imageSize, so ideally the provider should
+   *     return an image sized correctly.
+   * @return a URL of an avatar image for the specified user. A return value of {@code null} is
+   *     acceptable, and results in the server responding with a 404. This will hide the avatar
+   *     image in the web UI.
    */
   String getUrl(IdentifiedUser forUser, int imageSize);
 
@@ -43,8 +42,8 @@
    * Gets a URL for a user to modify their avatar image.
    *
    * @param forUser The user wishing to change their avatar image
-   * @return a URL the user should visit to modify their avatar, or null if
-   *         modification is not possible.
+   * @return a URL the user should visit to modify their avatar, or null if modification is not
+   *     possible.
    */
   String getChangeAvatarUrl(IdentifiedUser forUser);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java
index 7062871..abb0f32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheBinding.java
@@ -18,7 +18,6 @@
 import com.google.common.cache.Weigher;
 import com.google.gerrit.common.Nullable;
 import com.google.inject.TypeLiteral;
-
 import java.util.concurrent.TimeUnit;
 
 /** Configure a cache declared within a {@link CacheModule} instance. */
@@ -26,6 +25,9 @@
   /** Set the total size of the cache. */
   CacheBinding<K, V> maximumWeight(long weight);
 
+  /** Set the total on-disk limit of the cache */
+  CacheBinding<K, V> diskLimit(long limit);
+
   /** Set the time an element lives before being expired. */
   CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit durationUnits);
 
@@ -36,10 +38,21 @@
   CacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz);
 
   String name();
+
   TypeLiteral<K> keyType();
+
   TypeLiteral<V> valueType();
+
   long maximumWeight();
-  @Nullable Long expireAfterWrite(TimeUnit unit);
-  @Nullable Weigher<K, V> weigher();
-  @Nullable CacheLoader<K, V> loader();
+
+  long diskLimit();
+
+  @Nullable
+  Long expireAfterWrite(TimeUnit unit);
+
+  @Nullable
+  Weigher<K, V> weigher();
+
+  @Nullable
+  CacheLoader<K, V> loader();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
index ebf8259..862f4e8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -25,65 +25,71 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.Set;
 
 @Singleton
 public class CacheMetrics {
   @Inject
-  public CacheMetrics(MetricMaker metrics,
-      final DynamicMap<Cache<?, ?>> cacheMap) {
+  public CacheMetrics(MetricMaker metrics, final DynamicMap<Cache<?, ?>> cacheMap) {
     Field<String> F_NAME = Field.ofString("cache_name");
 
     final CallbackMetric1<String, Long> memEnt =
-        metrics.newCallbackMetric("caches/memory_cached", Long.class,
+        metrics.newCallbackMetric(
+            "caches/memory_cached",
+            Long.class,
             new Description("Memory entries").setGauge().setUnit("entries"),
             F_NAME);
     final CallbackMetric1<String, Double> memHit =
-        metrics.newCallbackMetric("caches/memory_hit_ratio", Double.class,
+        metrics.newCallbackMetric(
+            "caches/memory_hit_ratio",
+            Double.class,
             new Description("Memory hit ratio").setGauge().setUnit("percent"),
             F_NAME);
     final CallbackMetric1<String, Long> memEvict =
-        metrics.newCallbackMetric("caches/memory_eviction_count", Long.class,
-            new Description("Memory eviction count").setGauge()
-                .setUnit("evicted entries"),
+        metrics.newCallbackMetric(
+            "caches/memory_eviction_count",
+            Long.class,
+            new Description("Memory eviction count").setGauge().setUnit("evicted entries"),
             F_NAME);
     final CallbackMetric1<String, Long> perDiskEnt =
-        metrics.newCallbackMetric("caches/disk_cached", Long.class,
-            new Description("Disk entries used by persistent cache").setGauge()
-                .setUnit("entries"),
+        metrics.newCallbackMetric(
+            "caches/disk_cached",
+            Long.class,
+            new Description("Disk entries used by persistent cache").setGauge().setUnit("entries"),
             F_NAME);
     final CallbackMetric1<String, Double> perDiskHit =
-        metrics.newCallbackMetric("caches/disk_hit_ratio", Double.class,
-            new Description("Disk hit ratio for persistent cache").setGauge()
-                .setUnit("percent"),
+        metrics.newCallbackMetric(
+            "caches/disk_hit_ratio",
+            Double.class,
+            new Description("Disk hit ratio for persistent cache").setGauge().setUnit("percent"),
             F_NAME);
 
     final Set<CallbackMetric<?>> cacheMetrics =
-        ImmutableSet.<CallbackMetric<?>> of(memEnt, memHit, memEvict,
-            perDiskEnt, perDiskHit);
+        ImmutableSet.<CallbackMetric<?>>of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit);
 
-    metrics.newTrigger(cacheMetrics, new Runnable() {
-      @Override
-      public void run() {
-        for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-          Cache<?, ?> c = e.getProvider().get();
-          String name = metricNameOf(e);
-          CacheStats cstats = c.stats();
-          memEnt.set(name, c.size());
-          memHit.set(name, cstats.hitRate() * 100);
-          memEvict.set(name, cstats.evictionCount());
-          if (c instanceof PersistentCache) {
-            PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
-            perDiskEnt.set(name, d.size());
-            perDiskHit.set(name, hitRatio(d));
+    metrics.newTrigger(
+        cacheMetrics,
+        new Runnable() {
+          @Override
+          public void run() {
+            for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+              Cache<?, ?> c = e.getProvider().get();
+              String name = metricNameOf(e);
+              CacheStats cstats = c.stats();
+              memEnt.set(name, c.size());
+              memHit.set(name, cstats.hitRate() * 100);
+              memEvict.set(name, cstats.evictionCount());
+              if (c instanceof PersistentCache) {
+                PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
+                perDiskEnt.set(name, d.size());
+                perDiskHit.set(name, hitRatio(d));
+              }
+            }
+            for (CallbackMetric<?> cbm : cacheMetrics) {
+              cbm.prune();
+            }
           }
-        }
-        for (CallbackMetric<?> cbm : cacheMetrics) {
-          cbm.prune();
-        }
-      }
-    });
+        });
   }
 
   private static double hitRatio(PersistentCache.DiskStats d) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheModule.java
index 9f51de7..0e0c16f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheModule.java
@@ -26,16 +26,15 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 import com.google.inject.util.Types;
-
 import java.io.Serializable;
 import java.lang.reflect.Type;
 
-/**
- * Miniature DSL to support binding {@link Cache} instances in Guice.
- */
+/** Miniature DSL to support binding {@link Cache} instances in Guice. */
 public abstract class CacheModule extends FactoryModule {
-  private static final TypeLiteral<Cache<?, ?>> ANY_CACHE =
-      new TypeLiteral<Cache<?, ?>>() {};
+  public static final String MEMORY_MODULE = "cache-memory";
+  public static final String PERSISTENT_MODULE = "cache-persistent";
+
+  private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<Cache<?, ?>>() {};
 
   /**
    * Declare a named in-memory cache.
@@ -44,10 +43,7 @@
    * @param <V> type of value stored by the cache.
    * @return binding to describe the cache.
    */
-  protected <K, V> CacheBinding<K, V> cache(
-      String name,
-      Class<K> keyType,
-      Class<V> valType) {
+  protected <K, V> CacheBinding<K, V> cache(String name, Class<K> keyType, Class<V> valType) {
     return cache(name, TypeLiteral.get(keyType), TypeLiteral.get(valType));
   }
 
@@ -58,10 +54,7 @@
    * @param <V> type of value stored by the cache.
    * @return binding to describe the cache.
    */
-  protected <K, V> CacheBinding<K, V> cache(
-      String name,
-      Class<K> keyType,
-      TypeLiteral<V> valType) {
+  protected <K, V> CacheBinding<K, V> cache(String name, Class<K> keyType, TypeLiteral<V> valType) {
     return cache(name, TypeLiteral.get(keyType), valType);
   }
 
@@ -73,12 +66,8 @@
    * @return binding to describe the cache.
    */
   protected <K, V> CacheBinding<K, V> cache(
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType) {
-    Type type = Types.newParameterizedType(
-        Cache.class,
-        keyType.getType(), valType.getType());
+      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
+    Type type = Types.newParameterizedType(Cache.class, keyType.getType(), valType.getType());
 
     @SuppressWarnings("unchecked")
     Key<Cache<K, V>> key = (Key<Cache<K, V>>) Key.get(type, Names.named(name));
@@ -89,24 +78,21 @@
     return m.maximumWeight(1024);
   }
 
-  <K,V> Provider<CacheLoader<K,V>> bindCacheLoader(
-      CacheProvider<K, V> m,
-      Class<? extends CacheLoader<K,V>> impl) {
-    Type type = Types.newParameterizedType(
-        Cache.class,
-        m.keyType().getType(), m.valueType().getType());
+  <K, V> Provider<CacheLoader<K, V>> bindCacheLoader(
+      CacheProvider<K, V> m, Class<? extends CacheLoader<K, V>> impl) {
+    Type type =
+        Types.newParameterizedType(Cache.class, m.keyType().getType(), m.valueType().getType());
 
-    Type loadingType = Types.newParameterizedType(
-        LoadingCache.class,
-        m.keyType().getType(), m.valueType().getType());
+    Type loadingType =
+        Types.newParameterizedType(
+            LoadingCache.class, m.keyType().getType(), m.valueType().getType());
 
-    Type loaderType = Types.newParameterizedType(
-        CacheLoader.class,
-        m.keyType().getType(), m.valueType().getType());
+    Type loaderType =
+        Types.newParameterizedType(
+            CacheLoader.class, m.keyType().getType(), m.valueType().getType());
 
     @SuppressWarnings("unchecked")
-    Key<LoadingCache<K, V>> key =
-        (Key<LoadingCache<K, V>>) Key.get(type, Names.named(m.name));
+    Key<LoadingCache<K, V>> key = (Key<LoadingCache<K, V>>) Key.get(type, Names.named(m.name));
 
     @SuppressWarnings("unchecked")
     Key<LoadingCache<K, V>> loadingKey =
@@ -121,16 +107,13 @@
     return getProvider(loaderKey);
   }
 
-  <K,V> Provider<Weigher<K,V>> bindWeigher(
-      CacheProvider<K, V> m,
-      Class<? extends Weigher<K,V>> impl) {
-    Type weigherType = Types.newParameterizedType(
-        Weigher.class,
-        m.keyType().getType(), m.valueType().getType());
+  <K, V> Provider<Weigher<K, V>> bindWeigher(
+      CacheProvider<K, V> m, Class<? extends Weigher<K, V>> impl) {
+    Type weigherType =
+        Types.newParameterizedType(Weigher.class, m.keyType().getType(), m.valueType().getType());
 
     @SuppressWarnings("unchecked")
-    Key<Weigher<K, V>> key =
-        (Key<Weigher<K, V>>) Key.get(weigherType, Names.named(m.name));
+    Key<Weigher<K, V>> key = (Key<Weigher<K, V>>) Key.get(weigherType, Names.named(m.name));
 
     bind(key).to(impl).in(Scopes.SINGLETON);
     return getProvider(key);
@@ -144,9 +127,7 @@
    * @return binding to describe the cache.
    */
   protected <K extends Serializable, V extends Serializable> CacheBinding<K, V> persist(
-      String name,
-      Class<K> keyType,
-      Class<V> valType) {
+      String name, Class<K> keyType, Class<V> valType) {
     return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType));
   }
 
@@ -158,9 +139,7 @@
    * @return binding to describe the cache.
    */
   protected <K extends Serializable, V extends Serializable> CacheBinding<K, V> persist(
-      String name,
-      Class<K> keyType,
-      TypeLiteral<V> valType) {
+      String name, Class<K> keyType, TypeLiteral<V> valType) {
     return persist(name, TypeLiteral.get(keyType), valType);
   }
 
@@ -172,10 +151,7 @@
    * @return binding to describe the cache.
    */
   protected <K extends Serializable, V extends Serializable> CacheBinding<K, V> persist(
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType) {
-    return ((CacheProvider<K, V>) cache(name, keyType, valType))
-        .persist(true);
+      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
+    return ((CacheProvider<K, V>) cache(name, keyType, valType)).persist(true);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java
index 6d9ae0f..86df104 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -26,18 +26,16 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
-
 import java.util.concurrent.TimeUnit;
 
-class CacheProvider<K, V>
-    implements Provider<Cache<K, V>>,
-    CacheBinding<K, V> {
+class CacheProvider<K, V> implements Provider<Cache<K, V>>, CacheBinding<K, V> {
   private final CacheModule module;
   final String name;
   private final TypeLiteral<K> keyType;
   private final TypeLiteral<V> valType;
   private boolean persist;
   private long maximumWeight;
+  private long diskLimit;
   private Long expireAfterWrite;
   private Provider<CacheLoader<K, V>> loader;
   private Provider<Weigher<K, V>> weigher;
@@ -47,10 +45,7 @@
   private PersistentCacheFactory persistentCacheFactory;
   private boolean frozen;
 
-  CacheProvider(CacheModule module,
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType) {
+  CacheProvider(CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
     this.module = module;
     this.name = name;
     this.keyType = keyType;
@@ -86,6 +81,14 @@
   }
 
   @Override
+  public CacheBinding<K, V> diskLimit(long limit) {
+    Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
+    Preconditions.checkState(persist, "diskLimit supported for persistent caches only");
+    diskLimit = limit;
+    return this;
+  }
+
+  @Override
   public CacheBinding<K, V> expireAfterWrite(long duration, TimeUnit unit) {
     Preconditions.checkState(!frozen, "binding frozen, cannot be modified");
     expireAfterWrite = SECONDS.convert(duration, unit);
@@ -130,11 +133,17 @@
   }
 
   @Override
+  public long diskLimit() {
+    if (diskLimit > 0) {
+      return diskLimit;
+    }
+    return 128 << 20;
+  }
+
+  @Override
   @Nullable
   public Long expireAfterWrite(TimeUnit unit) {
-   return expireAfterWrite != null
-       ? unit.convert(expireAfterWrite, SECONDS)
-       : null;
+    return expireAfterWrite != null ? unit.convert(expireAfterWrite, SECONDS) : null;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
index bdc1220..2f9ed59 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheRemovalListener.java
@@ -16,8 +16,6 @@
 
 import com.google.common.cache.RemovalNotification;
 
-public interface CacheRemovalListener<K,V> {
-  void onRemoval(String pluginName,
-    String cacheName,
-    RemovalNotification<K, V> notification);
+public interface CacheRemovalListener<K, V> {
+  void onRemoval(String pluginName, String cacheName, RemovalNotification<K, V> notification);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
index 96b437d..be06601 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
@@ -22,8 +22,8 @@
 import com.google.inject.assistedinject.Assisted;
 
 /**
- * This listener dispatches removal events to all other RemovalListeners
- * attached via the DynamicSet API.
+ * This listener dispatches removal events to all other RemovalListeners attached via the DynamicSet
+ * API.
  *
  * @param <K>
  * @param <V>
@@ -39,8 +39,8 @@
   private String pluginName = "gerrit";
 
   @Inject
-  ForwardingRemovalListener(DynamicSet<CacheRemovalListener> listeners,
-      @Assisted String cacheName) {
+  ForwardingRemovalListener(
+      DynamicSet<CacheRemovalListener> listeners, @Assisted String cacheName) {
     this.listeners = listeners;
     this.cacheName = cacheName;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/MemoryCacheFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
index 6b8b489..49fcd5b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
@@ -21,7 +21,5 @@
 public interface MemoryCacheFactory {
   <K, V> Cache<K, V> build(CacheBinding<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(
-      CacheBinding<K, V> def,
-      CacheLoader<K, V> loader);
+  <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCacheFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
index 0769b2a..c52c232 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
@@ -22,9 +22,7 @@
 public interface PersistentCacheFactory {
   <K, V> Cache<K, V> build(CacheBinding<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(
-      CacheBinding<K, V> def,
-      CacheLoader<K, V> loader);
+  <K, V> LoadingCache<K, V> build(CacheBinding<K, V> def, CacheLoader<K, V> loader);
 
   void onStop(Plugin plugin);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
index adbcf22..0cafe6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -26,58 +28,44 @@
 import com.google.gerrit.extensions.webui.UiAction;
 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;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ChangeAbandoned;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.AbandonedSender;
-import com.google.gerrit.server.mail.ReplyToChangeSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.git.AbandonOp;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.util.Collection;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Abandon implements RestModifyView<ChangeResource, AbandonInput>,
-    UiAction<ChangeResource> {
+public class Abandon
+    implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Abandon.class);
 
-  private final AbandonedSender.Factory abandonedSenderFactory;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
-  private final ChangeMessagesUtil cmUtil;
-  private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeAbandoned changeAbandoned;
+  private final AbandonOp.Factory abandonOpFactory;
+  private final NotifyUtil notifyUtil;
 
   @Inject
-  Abandon(AbandonedSender.Factory abandonedSenderFactory,
+  Abandon(
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
-      ChangeMessagesUtil cmUtil,
-      PatchSetUtil psUtil,
       BatchUpdate.Factory batchUpdateFactory,
-      ChangeAbandoned changeAbandoned) {
-    this.abandonedSenderFactory = abandonedSenderFactory;
+      AbandonOp.Factory abandonOpFactory,
+      NotifyUtil notifyUtil) {
     this.dbProvider = dbProvider;
     this.json = json;
-    this.cmUtil = cmUtil;
-    this.psUtil = psUtil;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.changeAbandoned = changeAbandoned;
+    this.abandonOpFactory = abandonOpFactory;
+    this.notifyUtil = notifyUtil;
   }
 
   @Override
@@ -87,104 +75,89 @@
     if (!control.canAbandon(dbProvider.get())) {
       throw new AuthException("abandon not permitted");
     }
-    Change change = abandon(control, input.message, input.notify);
-    return json.create(ChangeJson.NO_OPTIONS).format(change);
+    Change change =
+        abandon(
+            control, input.message, input.notify, notifyUtil.resolveAccounts(input.notifyDetails));
+    return json.noOptions().format(change);
+  }
+
+  public Change abandon(ChangeControl control) throws RestApiException, UpdateException {
+    return abandon(control, "", NotifyHandling.ALL, ImmutableListMultimap.of());
   }
 
   public Change abandon(ChangeControl control, String msgTxt)
       throws RestApiException, UpdateException {
-    return abandon(control, msgTxt, NotifyHandling.ALL);
+    return abandon(control, msgTxt, NotifyHandling.ALL, ImmutableListMultimap.of());
   }
 
-  public Change abandon(ChangeControl control, String msgTxt,
-      NotifyHandling notifyHandling) throws RestApiException, UpdateException {
+  public Change abandon(
+      ChangeControl control,
+      String msgTxt,
+      NotifyHandling notifyHandling,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws RestApiException, UpdateException {
     CurrentUser user = control.getUser();
-    Account account = user.isIdentifiedUser()
-        ? user.asIdentifiedUser().getAccount()
-        : null;
-    Op op = new Op(msgTxt, account, notifyHandling);
-    try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
-        control.getProject().getNameKey(), user, TimeUtil.nowTs())) {
+    Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
+    AbandonOp op = abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify);
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(),
+            control.getProject().getNameKey(),
+            control.getUser(),
+            TimeUtil.nowTs())) {
       u.addOp(control.getId(), op).execute();
     }
-    return op.change;
+    return op.getChange();
   }
 
-  private class Op extends BatchUpdate.Op {
-    private final Account account;
-    private final String msgTxt;
-
-    private Change change;
-    private PatchSet patchSet;
-    private ChangeMessage message;
-    private NotifyHandling notifyHandling;
-
-    private Op(String msgTxt, Account account, NotifyHandling notifyHandling) {
-      this.account = account;
-      this.msgTxt = msgTxt;
-      this.notifyHandling = notifyHandling;
+  /**
+   * If an extension has more than one changes to abandon that belong to the same project, they
+   * should use the batch instead of abandoning one by one.
+   *
+   * <p>It's the caller's responsibility to ensure that all jobs inside the same batch have the
+   * matching project from its ChangeControl. Violations will result in a ResourceConflictException.
+   */
+  public void batchAbandon(
+      Project.NameKey project,
+      CurrentUser user,
+      Collection<ChangeControl> controls,
+      String msgTxt,
+      NotifyHandling notifyHandling,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws RestApiException, UpdateException {
+    if (controls.isEmpty()) {
+      return;
     }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException,
-        ResourceConflictException {
-      change = ctx.getChange();
-      PatchSet.Id psId = change.currentPatchSetId();
-      ChangeUpdate update = ctx.getUpdate(psId);
-      if (!change.getStatus().isOpen()) {
-        throw new ResourceConflictException("change is " + status(change));
-      } else if (change.getStatus() == Change.Status.DRAFT) {
-        throw new ResourceConflictException(
-            "draft changes cannot be abandoned");
-      }
-      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-      change.setStatus(Change.Status.ABANDONED);
-      change.setLastUpdatedOn(ctx.getWhen());
-
-      update.setStatus(change.getStatus());
-      message = newMessage(ctx);
-      cmUtil.addChangeMessage(ctx.getDb(), update, message);
-      return true;
-    }
-
-    private ChangeMessage newMessage(ChangeContext ctx) throws OrmException {
-      StringBuilder msg = new StringBuilder();
-      msg.append("Abandoned");
-      if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
-        msg.append("\n\n");
-        msg.append(msgTxt.trim());
-      }
-
-      ChangeMessage message = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          account != null ? account.getId() : null,
-          ctx.getWhen(),
-          change.currentPatchSetId());
-      message.setMessage(msg.toString());
-      return message;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws OrmException {
-      try {
-        ReplyToChangeSender cm =
-            abandonedSenderFactory.create(ctx.getProject(), change.getId());
-        if (account != null) {
-          cm.setFrom(account.getId());
+    Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
+    try (BatchUpdate u =
+        batchUpdateFactory.create(dbProvider.get(), project, user, TimeUtil.nowTs())) {
+      for (ChangeControl control : controls) {
+        if (!project.equals(control.getProject().getNameKey())) {
+          throw new ResourceConflictException(
+              String.format(
+                  "Project name \"%s\" doesn't match \"%s\"",
+                  control.getProject().getNameKey().get(), project.get()));
         }
-        cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-        cm.setNotify(notifyHandling);
-        cm.send();
-      } catch (Exception e) {
-        log.error("Cannot email update for change " + change.getId(), e);
+        u.addOp(
+            control.getId(),
+            abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify));
       }
-      changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(),
-          notifyHandling);
+      u.execute();
     }
   }
 
+  public void batchAbandon(
+      Project.NameKey project, CurrentUser user, Collection<ChangeControl> controls, String msgTxt)
+      throws RestApiException, UpdateException {
+    batchAbandon(project, user, controls, msgTxt, NotifyHandling.ALL, ImmutableListMultimap.of());
+  }
+
+  public void batchAbandon(
+      Project.NameKey project, CurrentUser user, Collection<ChangeControl> controls)
+      throws RestApiException, UpdateException {
+    batchAbandon(project, user, controls, "", NotifyHandling.ALL, ImmutableListMultimap.of());
+  }
+
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
     boolean canAbandon = false;
@@ -194,14 +167,11 @@
       log.error("Cannot check canAbandon status. Assuming false.", e);
     }
     return new UiAction.Description()
-      .setLabel("Abandon")
-      .setTitle("Abandon the change")
-      .setVisible(resource.getChange().getStatus().isOpen()
-          && resource.getChange().getStatus() != Change.Status.DRAFT
-          && canAbandon);
-  }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+        .setLabel("Abandon")
+        .setTitle("Abandon the change")
+        .setVisible(
+            resource.getChange().getStatus().isOpen()
+                && resource.getChange().getStatus() != Change.Status.DRAFT
+                && canAbandon);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
index 60d9c08..9ab96f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.project.ChangeControl;
@@ -25,22 +27,22 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class AbandonUtil {
   private static final Logger log = LoggerFactory.getLogger(AbandonUtil.class);
 
   private final ChangeCleanupConfig cfg;
-  private final InternalUser.Factory internalUserFactory;
   private final ChangeQueryProcessor queryProcessor;
   private final ChangeQueryBuilder queryBuilder;
   private final Abandon abandon;
+  private final InternalUser internalUser;
 
   @Inject
   AbandonUtil(
@@ -50,10 +52,10 @@
       ChangeQueryBuilder queryBuilder,
       Abandon abandon) {
     this.cfg = cfg;
-    this.internalUserFactory = internalUserFactory;
     this.queryProcessor = queryProcessor;
     this.queryBuilder = queryBuilder;
     this.abandon = abandon;
+    internalUser = internalUserFactory.create();
   }
 
   public void abandonInactiveOpenChanges() {
@@ -62,48 +64,66 @@
     }
 
     try {
-      String query = "status:new age:"
-          + TimeUnit.MILLISECONDS.toMinutes(cfg.getAbandonAfter())
-          + "m";
+      String query =
+          "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(cfg.getAbandonAfter()) + "m";
       if (!cfg.getAbandonIfMergeable()) {
         query += " -is:mergeable";
       }
-      List<ChangeData> changesToAbandon = queryProcessor.enforceVisibility(false)
-          .query(queryBuilder.parse(query)).entities();
-      int count = 0;
+
+      List<ChangeData> changesToAbandon =
+          queryProcessor.enforceVisibility(false).query(queryBuilder.parse(query)).entities();
+      ImmutableListMultimap.Builder<Project.NameKey, ChangeControl> builder =
+          ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
         try {
-          if (noNeedToAbandon(cd, query)){
-            log.debug("Change data \"{}\" does not satisfy the query \"{}\" any"
-                + " more, hence skipping it in clean up", cd, query);
-            continue;
-          }
-          abandon.abandon(changeControl(cd), cfg.getAbandonMessage());
-          count++;
-        } catch (ResourceConflictException e) {
-          // Change was already merged or abandoned.
-        } catch (Throwable e) {
-          log.error(String.format(
-              "Failed to auto-abandon inactive open change %d.",
-                  cd.getId().get()), e);
+          ChangeControl control = cd.changeControl(internalUser);
+          builder.put(control.getProject().getNameKey(), control);
+        } catch (OrmException e) {
+          log.warn("Failed to query inactive open change for auto-abandoning.", e);
         }
       }
-      log.info(String.format("Auto-Abandoned %d of %d changes.",
-          count, changesToAbandon.size()));
+
+      int count = 0;
+      ListMultimap<Project.NameKey, ChangeControl> abandons = builder.build();
+      String message = cfg.getAbandonMessage();
+      for (Project.NameKey project : abandons.keySet()) {
+        Collection<ChangeControl> changes = getValidChanges(abandons.get(project), query);
+        try {
+          abandon.batchAbandon(project, internalUser, changes, message);
+          count += changes.size();
+        } catch (Throwable e) {
+          StringBuilder msg = new StringBuilder("Failed to auto-abandon inactive change(s):");
+          for (ChangeControl change : changes) {
+            msg.append(" ").append(change.getId().get());
+          }
+          msg.append(".");
+          log.error(msg.toString(), e);
+        }
+      }
+      log.info("Auto-Abandoned {} of {} changes.", count, changesToAbandon.size());
     } catch (QueryParseException | OrmException e) {
       log.error("Failed to query inactive open changes for auto-abandoning.", e);
     }
   }
 
-  private boolean noNeedToAbandon(ChangeData cd, String query)
+  private Collection<ChangeControl> getValidChanges(
+      Collection<ChangeControl> changeControls, String query)
       throws OrmException, QueryParseException {
-    String newQuery = query + " change:" + cd.getId();
-    List<ChangeData> changesToAbandon = queryProcessor.enforceVisibility(false)
-        .query(queryBuilder.parse(newQuery)).entities();
-    return changesToAbandon.isEmpty();
-  }
-
-  private ChangeControl changeControl(ChangeData cd) throws OrmException {
-    return cd.changeControl(internalUserFactory.create());
+    Collection<ChangeControl> validChanges = new ArrayList<>();
+    for (ChangeControl cc : changeControls) {
+      String newQuery = query + " change:" + cc.getId();
+      List<ChangeData> changesToAbandon =
+          queryProcessor.enforceVisibility(false).query(queryBuilder.parse(newQuery)).entities();
+      if (!changesToAbandon.isEmpty()) {
+        validChanges.add(cc);
+      } else {
+        log.debug(
+            "Change data with id \"{}\" does not satisfy the query \"{}\""
+                + " any more, hence skipping it in clean up",
+            cc.getId(),
+            query);
+      }
+    }
+    return validChanges;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
index 3bb98ff..b7a6e82 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -15,39 +15,34 @@
 package com.google.gerrit.server.change;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.Collection;
+import java.util.Optional;
 
 /**
  * Store for reviewed flags on changes.
  *
- * A reviewed flag is a tuple of (patch set ID, file, account ID) and records
- * whether the user has reviewed a file in a patch set. Each user can easily
- * have thousands of reviewed flags and the number of reviewed flags is growing
- * without bound. The store must be able handle this data volume efficiently.
+ * <p>A reviewed flag is a tuple of (patch set ID, file, account ID) and records whether the user
+ * has reviewed a file in a patch set. Each user can easily have thousands of reviewed flags and the
+ * number of reviewed flags is growing without bound. The store must be able handle this data volume
+ * efficiently.
  *
- * For a multi-master setup the store must replicate the data between the
- * masters.
+ * <p>For a multi-master setup the store must replicate the data between the masters.
  */
 public interface AccountPatchReviewStore {
 
-  /**
-   * Represents patch set id with reviewed files.
-   */
+  /** Represents patch set id with reviewed files. */
   @AutoValue
   abstract class PatchSetWithReviewedFiles {
     abstract PatchSet.Id patchSetId();
+
     abstract ImmutableSet<String> files();
 
-    public static PatchSetWithReviewedFiles create(
-        PatchSet.Id id, ImmutableSet<String> files) {
-      return new AutoValue_AccountPatchReviewStore_PatchSetWithReviewedFiles(
-          id, files);
+    public static PatchSetWithReviewedFiles create(PatchSet.Id id, ImmutableSet<String> files) {
+      return new AutoValue_AccountPatchReviewStore_PatchSetWithReviewedFiles(id, files);
     }
   }
 
@@ -57,12 +52,11 @@
    * @param psId patch set ID
    * @param accountId account ID of the user
    * @param path file path
-   * @return {@code true} if the reviewed flag was updated, {@code false} if the
-   *         reviewed flag was already set
+   * @return {@code true} if the reviewed flag was updated, {@code false} if the reviewed flag was
+   *     already set
    * @throws OrmException thrown if updating the reviewed flag failed
    */
-  boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path)
-      throws OrmException;
+  boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path) throws OrmException;
 
   /**
    * Marks the given files in the given patch set as reviewed by the given user.
@@ -72,24 +66,21 @@
    * @param paths file paths
    * @throws OrmException thrown if updating the reviewed flag failed
    */
-  void markReviewed(PatchSet.Id psId, Account.Id accountId,
-      Collection<String> paths) throws OrmException;
+  void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths)
+      throws OrmException;
 
   /**
-   * Clears the reviewed flag for the given file in the given patch set for the
-   * given user.
+   * Clears the reviewed flag for the given file in the given patch set for the given user.
    *
    * @param psId patch set ID
    * @param accountId account ID of the user
    * @param path file path
    * @throws OrmException thrown if clearing the reviewed flag failed
    */
-  void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path)
-      throws OrmException;
+  void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path) throws OrmException;
 
   /**
-   * Clears the reviewed flags for all files in the given patch set for all
-   * users.
+   * Clears the reviewed flags for all files in the given patch set for all users.
    *
    * @param psId patch set ID
    * @throws OrmException thrown if clearing the reviewed flags failed
@@ -97,15 +88,15 @@
   void clearReviewed(PatchSet.Id psId) throws OrmException;
 
   /**
-   * Find the latest patch set, that is smaller or equals to the given patch set,
-   * where at least, one file has been reviewed by the given user.
+   * Find the latest patch set, that is smaller or equals to the given patch set, where at least,
+   * one file has been reviewed by the given user.
    *
    * @param psId patch set ID
    * @param accountId account ID of the user
-   * @return optionally, all files the have been reviewed by the given user
-   * that belong to the patch set that is smaller or equals to the given patch set
+   * @return optionally, all files the have been reviewed by the given user that belong to the patch
+   *     set that is smaller or equals to the given patch set
    * @throws OrmException thrown if accessing the reviewed flags failed
    */
-  Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId,
-      Account.Id accountId) throws OrmException;
+  Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId)
+      throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
index 4992c8e..519a4bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
@@ -14,69 +14,156 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.util.Providers;
-
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 @Singleton
 public class ActionJson {
   private final Revisions revisions;
+  private final ChangeJson.Factory changeJsonFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final DynamicMap<RestView<ChangeResource>> changeViews;
+  private final DynamicSet<ActionVisitor> visitorSet;
 
   @Inject
   ActionJson(
       Revisions revisions,
+      ChangeJson.Factory changeJsonFactory,
       ChangeResource.Factory changeResourceFactory,
-      DynamicMap<RestView<ChangeResource>> changeViews) {
+      DynamicMap<RestView<ChangeResource>> changeViews,
+      DynamicSet<ActionVisitor> visitorSet) {
     this.revisions = revisions;
+    this.changeJsonFactory = changeJsonFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeViews = changeViews;
+    this.visitorSet = visitorSet;
   }
 
-  public Map<String, ActionInfo> format(RevisionResource rsrc) {
-    return toActionMap(rsrc);
+  public Map<String, ActionInfo> format(RevisionResource rsrc) throws OrmException {
+    ChangeInfo changeInfo = null;
+    RevisionInfo revisionInfo = null;
+    List<ActionVisitor> visitors = visitors();
+    if (!visitors.isEmpty()) {
+      changeInfo = changeJson().format(rsrc);
+      revisionInfo = checkNotNull(Iterables.getOnlyElement(changeInfo.revisions.values()));
+      changeInfo.revisions = null;
+    }
+    return toActionMap(rsrc, visitors, changeInfo, revisionInfo);
+  }
+
+  private ChangeJson changeJson() {
+    return changeJsonFactory.noOptions();
+  }
+
+  private ArrayList<ActionVisitor> visitors() {
+    return Lists.newArrayList(visitorSet);
   }
 
   public ChangeInfo addChangeActions(ChangeInfo to, ChangeControl ctl) {
-    to.actions = toActionMap(ctl);
+    List<ActionVisitor> visitors = visitors();
+    to.actions = toActionMap(ctl, visitors, copy(visitors, to));
     return to;
   }
 
-  public RevisionInfo addRevisionActions(RevisionInfo to,
-      RevisionResource rsrc) {
-    to.actions = toActionMap(rsrc);
+  public RevisionInfo addRevisionActions(
+      @Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) throws OrmException {
+    List<ActionVisitor> visitors = visitors();
+    if (!visitors.isEmpty()) {
+      if (changeInfo != null) {
+        changeInfo = copy(visitors, changeInfo);
+      } else {
+        changeInfo = changeJson().format(rsrc);
+      }
+    }
+    to.actions = toActionMap(rsrc, visitors, changeInfo, copy(visitors, to));
     return to;
   }
 
-  private Map<String, ActionInfo> toActionMap(ChangeControl ctl) {
+  private ChangeInfo copy(List<ActionVisitor> visitors, ChangeInfo changeInfo) {
+    if (visitors.isEmpty()) {
+      return null;
+    }
+    // Include all fields from ChangeJson#toChangeInfo that are not protected by
+    // any ListChangesOptions.
+    ChangeInfo copy = new ChangeInfo();
+    copy.project = changeInfo.project;
+    copy.branch = changeInfo.branch;
+    copy.topic = changeInfo.topic;
+    copy.assignee = changeInfo.assignee;
+    copy.hashtags = changeInfo.hashtags;
+    copy.changeId = changeInfo.changeId;
+    copy.submitType = changeInfo.submitType;
+    copy.mergeable = changeInfo.mergeable;
+    copy.insertions = changeInfo.insertions;
+    copy.deletions = changeInfo.deletions;
+    copy.subject = changeInfo.subject;
+    copy.status = changeInfo.status;
+    copy.owner = changeInfo.owner;
+    copy.created = changeInfo.created;
+    copy.updated = changeInfo.updated;
+    copy._number = changeInfo._number;
+    copy.starred = changeInfo.starred;
+    copy.stars = changeInfo.stars;
+    copy.submitted = changeInfo.submitted;
+    copy.id = changeInfo.id;
+    return copy;
+  }
+
+  private RevisionInfo copy(List<ActionVisitor> visitors, RevisionInfo revisionInfo) {
+    if (visitors.isEmpty()) {
+      return null;
+    }
+    // Include all fields from ChangeJson#toRevisionInfo that are not protected
+    // by any ListChangesOptions.
+    RevisionInfo copy = new RevisionInfo();
+    copy.isCurrent = revisionInfo.isCurrent;
+    copy._number = revisionInfo._number;
+    copy.ref = revisionInfo.ref;
+    copy.created = revisionInfo.created;
+    copy.uploader = revisionInfo.uploader;
+    copy.draft = revisionInfo.draft;
+    copy.fetch = revisionInfo.fetch;
+    copy.kind = revisionInfo.kind;
+    copy.description = revisionInfo.description;
+    return copy;
+  }
+
+  private Map<String, ActionInfo> toActionMap(
+      ChangeControl ctl, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
     Map<String, ActionInfo> out = new LinkedHashMap<>();
     if (!ctl.getUser().isIdentifiedUser()) {
       return out;
     }
 
     Provider<CurrentUser> userProvider = Providers.of(ctl.getUser());
-    for (UiAction.Description d : UiActions.from(
-        changeViews,
-        changeResourceFactory.create(ctl),
-        userProvider)) {
-      out.put(d.getId(), new ActionInfo(d));
-    }
-
+    FluentIterable<UiAction.Description> descs =
+        UiActions.from(changeViews, changeResourceFactory.create(ctl), userProvider);
     // The followup action is a client-side only operation that does not
     // have a server side handler. It must be manually registered into the
     // resulting action map.
@@ -86,20 +173,41 @@
       PrivateInternals_UiActionDescription.setMethod(descr, "POST");
       descr.setTitle("Create follow-up change");
       descr.setLabel("Follow-Up");
-      out.put(descr.getId(), new ActionInfo(descr));
+      descs = descs.append(descr);
+    }
+
+    ACTION:
+    for (UiAction.Description d : descs) {
+      ActionInfo actionInfo = new ActionInfo(d);
+      for (ActionVisitor visitor : visitors) {
+        if (!visitor.visit(d.getId(), actionInfo, changeInfo)) {
+          continue ACTION;
+        }
+      }
+      out.put(d.getId(), actionInfo);
     }
     return out;
   }
 
-  private Map<String, ActionInfo> toActionMap(RevisionResource rsrc) {
+  private Map<String, ActionInfo> toActionMap(
+      RevisionResource rsrc,
+      List<ActionVisitor> visitors,
+      ChangeInfo changeInfo,
+      RevisionInfo revisionInfo) {
+    if (!rsrc.getControl().getUser().isIdentifiedUser()) {
+      return ImmutableMap.of();
+    }
     Map<String, ActionInfo> out = new LinkedHashMap<>();
-    if (rsrc.getControl().getUser().isIdentifiedUser()) {
-      Provider<CurrentUser> userProvider = Providers.of(
-          rsrc.getControl().getUser());
-      for (UiAction.Description d : UiActions.from(
-          revisions, rsrc, userProvider)) {
-        out.put(d.getId(), new ActionInfo(d));
+    Provider<CurrentUser> userProvider = Providers.of(rsrc.getControl().getUser());
+    ACTION:
+    for (UiAction.Description d : UiActions.from(revisions, rsrc, userProvider)) {
+      ActionInfo actionInfo = new ActionInfo(d);
+      for (ActionVisitor visitor : visitors) {
+        if (!visitor.visit(d.getId(), actionInfo, changeInfo, revisionInfo)) {
+          continue ACTION;
+        }
       }
+      out.put(d.getId(), actionInfo);
     }
     return out;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java
new file mode 100644
index 0000000..20e586f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AllowedFormats.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.config.DownloadConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class AllowedFormats {
+  final ImmutableMap<String, ArchiveFormat> extensions;
+  final ImmutableSet<ArchiveFormat> allowed;
+
+  @Inject
+  AllowedFormats(DownloadConfig cfg) {
+    Map<String, ArchiveFormat> exts = new HashMap<>();
+    for (ArchiveFormat format : cfg.getArchiveFormats()) {
+      for (String ext : format.getSuffixes()) {
+        exts.put(ext, format);
+      }
+      exts.put(format.name().toLowerCase(), format);
+    }
+    extensions = ImmutableMap.copyOf(exts);
+
+    // Zip is not supported because it may be interpreted by a Java plugin as a
+    // valid JAR file, whose code would have access to cookies on the domain.
+    allowed =
+        Sets.immutableEnumSet(
+            Iterables.filter(cfg.getArchiveFormats(), f -> f != ArchiveFormat.ZIP));
+  }
+
+  public Set<ArchiveFormat> getAllowed() {
+    return allowed;
+  }
+
+  public ImmutableMap<String, ArchiveFormat> getExtensions() {
+    return extensions;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
index 335f201..3fefcd4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
@@ -14,12 +14,19 @@
 
 package com.google.gerrit.server.change;
 
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.OutputStream;
+import org.apache.commons.compress.archivers.ArchiveOutputStream;
 import org.eclipse.jgit.api.ArchiveCommand;
+import org.eclipse.jgit.api.ArchiveCommand.Format;
 import org.eclipse.jgit.archive.TarFormat;
 import org.eclipse.jgit.archive.Tbz2Format;
 import org.eclipse.jgit.archive.TgzFormat;
 import org.eclipse.jgit.archive.TxzFormat;
 import org.eclipse.jgit.archive.ZipFormat;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectLoader;
 
 public enum ArchiveFormat {
   TGZ("application/x-gzip", new TgzFormat()),
@@ -52,4 +59,19 @@
   Iterable<String> getSuffixes() {
     return format.suffixes();
   }
+
+  public ArchiveOutputStream createArchiveOutputStream(OutputStream o) throws IOException {
+    return (ArchiveOutputStream) this.format.createArchiveOutputStream(o);
+  }
+
+  public <T extends Closeable> void putEntry(T out, String path, byte[] data) throws IOException {
+    @SuppressWarnings("unchecked")
+    ArchiveCommand.Format<T> fmt = (Format<T>) format;
+    fmt.putEntry(
+        out,
+        null,
+        path,
+        FileMode.REGULAR_FILE,
+        new ObjectLoader.SmallObject(FileMode.TYPE_FILE, data));
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 310c8cb..d1cd238 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -25,16 +25,14 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.concurrent.TimeUnit;
-
 /** Runnable to enable scheduling change cleanups to run periodically */
 public class ChangeCleanupRunner implements Runnable {
-  private static final Logger log = LoggerFactory
-      .getLogger(ChangeCleanupRunner.class);
+  private static final Logger log = LoggerFactory.getLogger(ChangeCleanupRunner.class);
 
   public static class Module extends LifecycleModule {
     @Override
@@ -49,9 +47,7 @@
     private final ChangeCleanupConfig cfg;
 
     @Inject
-    Lifecycle(WorkQueue queue,
-        ChangeCleanupRunner runner,
-        ChangeCleanupConfig cfg) {
+    Lifecycle(WorkQueue queue, ChangeCleanupRunner runner, ChangeCleanupConfig cfg) {
       this.queue = queue;
       this.runner = runner;
       this.cfg = cfg;
@@ -65,12 +61,13 @@
       if (delay == MISSING_CONFIG && interval == MISSING_CONFIG) {
         log.info("Ignoring missing changeCleanup schedule configuration");
       } else if (delay < 0 || interval <= 0) {
-        log.warn(String.format(
-            "Ignoring invalid changeCleanup schedule configuration: %s",
-            scheduleConfig));
+        log.warn("Ignoring invalid changeCleanup schedule configuration: {}", scheduleConfig);
       } else {
-        queue.getDefaultQueue().scheduleAtFixedRate(runner, delay,
-            interval, TimeUnit.MILLISECONDS);
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError =
+            queue
+                .getDefaultQueue()
+                .scheduleAtFixedRate(runner, delay, interval, TimeUnit.MILLISECONDS);
       }
     }
 
@@ -84,9 +81,7 @@
   private final AbandonUtil abandonUtil;
 
   @Inject
-  ChangeCleanupRunner(
-      OneOffRequestContext oneOffRequestContext,
-      AbandonUtil abandonUtil) {
+  ChangeCleanupRunner(OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil) {
     this.oneOffRequestContext = oneOffRequestContext;
     this.abandonUtil = abandonUtil;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
index c57f5a0..108e180 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.project.ChangeControl;
@@ -25,10 +24,12 @@
 
 /**
  * Represents change edit resource, that is actualy two kinds of resources:
+ *
  * <ul>
- * <li>the change edit itself</li>
- * <li>a path within the edit</li>
+ *   <li>the change edit itself
+ *   <li>a path within the edit
  * </ul>
+ *
  * distinguished by whether path is null or not.
  */
 public class ChangeEditResource implements RestResource {
@@ -39,8 +40,7 @@
   private final ChangeEdit edit;
   private final String path;
 
-  public ChangeEditResource(ChangeResource change, ChangeEdit edit,
-      String path) {
+  public ChangeEditResource(ChangeResource change, ChangeEdit edit, String path) {
     this.change = change;
     this.edit = edit;
     this.path = path;
@@ -60,10 +60,6 @@
     return getChangeResource().getControl();
   }
 
-  public Change getChange() {
-    return edit.getChange();
-  }
-
   public ChangeEdit getChangeEdit() {
     return edit;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
index 878cc81..da92964 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -40,8 +37,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditJson;
@@ -50,28 +46,28 @@
 import com.google.gerrit.server.edit.UnchangedCommitMessageException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-import java.util.List;
-
 @Singleton
-public class ChangeEdits implements
-    ChildCollection<ChangeResource, ChangeEditResource>,
-    AcceptsCreate<ChangeResource>,
-    AcceptsPost<ChangeResource>,
-    AcceptsDelete<ChangeResource> {
+public class ChangeEdits
+    implements ChildCollection<ChangeResource, ChangeEditResource>,
+        AcceptsCreate<ChangeResource>,
+        AcceptsPost<ChangeResource>,
+        AcceptsDelete<ChangeResource> {
   private final DynamicMap<RestView<ChangeEditResource>> views;
   private final Create.Factory createFactory;
   private final DeleteFile.Factory deleteFileFactory;
@@ -80,7 +76,8 @@
   private final Post post;
 
   @Inject
-  ChangeEdits(DynamicMap<RestView<ChangeEditResource>> views,
+  ChangeEdits(
+      DynamicMap<RestView<ChangeEditResource>> views,
       Create.Factory createFactory,
       Provider<Detail> detail,
       ChangeEditUtil editUtil,
@@ -106,8 +103,7 @@
 
   @Override
   public ChangeEditResource parse(ChangeResource rsrc, IdString id)
-      throws ResourceNotFoundException, AuthException, IOException,
-      InvalidChangeOperationException, OrmException {
+      throws ResourceNotFoundException, AuthException, IOException, OrmException {
     Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
     if (!edit.isPresent()) {
       throw new ResourceNotFoundException(id);
@@ -117,12 +113,10 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public Create create(ChangeResource parent, IdString id)
-      throws RestApiException {
-    return createFactory.create(parent.getChange(), id.get());
+  public Create create(ChangeResource parent, IdString id) throws RestApiException {
+    return createFactory.create(id.get());
   }
 
-
   @SuppressWarnings("unchecked")
   @Override
   public Post post(ChangeResource parent) throws RestApiException {
@@ -130,127 +124,62 @@
   }
 
   /**
-  * Create handler that is activated when collection element is accessed
-  * but doesn't exist, e. g. PUT request with a path was called but
-  * change edit wasn't created yet. Change edit is created and PUT
-  * handler is called.
-  */
+   * Create handler that is activated when collection element is accessed but doesn't exist, e. g.
+   * PUT request with a path was called but change edit wasn't created yet. Change edit is created
+   * and PUT handler is called.
+   */
   @SuppressWarnings("unchecked")
   @Override
-  public DeleteFile delete(ChangeResource parent, IdString id)
-      throws RestApiException {
+  public DeleteFile delete(ChangeResource parent, IdString id) throws RestApiException {
     // It's safe to assume that id can never be null, because
     // otherwise we would end up in dedicated endpoint for
     // deleting of change edits and not a file in change edit
     return deleteFileFactory.create(id.get());
   }
 
-  public static class Create implements
-      RestModifyView<ChangeResource, Put.Input> {
+  public static class Create implements RestModifyView<ChangeResource, Put.Input> {
 
     interface Factory {
-      Create create(Change change, String path);
+      Create create(String path);
     }
 
-    private final Provider<ReviewDb> db;
-    private final ChangeEditUtil editUtil;
-    private final ChangeEditModifier editModifier;
-    private final PatchSetUtil psUtil;
     private final Put putEdit;
-    private final Change change;
     private final String path;
 
     @Inject
-    Create(Provider<ReviewDb> db,
-        ChangeEditUtil editUtil,
-        ChangeEditModifier editModifier,
-        PatchSetUtil psUtil,
-        Put putEdit,
-        @Assisted Change change,
-        @Assisted @Nullable String path) {
-      this.db = db;
-      this.editUtil = editUtil;
-      this.editModifier = editModifier;
-      this.psUtil = psUtil;
+    Create(Put putEdit, @Assisted String path) {
       this.putEdit = putEdit;
-      this.change = change;
       this.path = path;
     }
 
     @Override
     public Response<?> apply(ChangeResource resource, Put.Input input)
-        throws AuthException, IOException, ResourceConflictException,
-        OrmException, InvalidChangeOperationException {
-      Optional<ChangeEdit> edit = editUtil.byChange(change);
-      if (edit.isPresent()) {
-        throw new ResourceConflictException(String.format(
-            "edit already exists for the change %s",
-            resource.getId()));
-      }
-      edit = createEdit(resource);
-      if (!Strings.isNullOrEmpty(path)) {
-        putEdit.apply(new ChangeEditResource(resource, edit.get(), path),
-            input);
-      }
+        throws AuthException, ResourceConflictException, IOException, OrmException {
+      putEdit.apply(resource.getControl(), path, input.content);
       return Response.none();
     }
-
-    private Optional<ChangeEdit> createEdit(ChangeResource resource)
-        throws AuthException, IOException, ResourceConflictException,
-        OrmException {
-      editModifier.createEdit(change,
-          psUtil.current(db.get(), resource.getNotes()));
-      return editUtil.byChange(change);
-    }
   }
 
-  public static class DeleteFile implements
-      RestModifyView<ChangeResource, DeleteFile.Input> {
-    public static class Input {
-    }
+  public static class DeleteFile implements RestModifyView<ChangeResource, DeleteFile.Input> {
+    public static class Input {}
 
     interface Factory {
       DeleteFile create(String path);
     }
 
-    private final ChangeEditUtil editUtil;
-    private final ChangeEditModifier editModifier;
-    private final PatchSetUtil psUtil;
-    private final Provider<ReviewDb> db;
+    private final DeleteContent deleteContent;
     private final String path;
 
     @Inject
-    DeleteFile(ChangeEditUtil editUtil,
-        ChangeEditModifier editModifier,
-        PatchSetUtil psUtil,
-        Provider<ReviewDb> db,
-        @Assisted String path) {
-      this.editUtil = editUtil;
-      this.editModifier = editModifier;
-      this.psUtil = psUtil;
-      this.db = db;
+    DeleteFile(DeleteContent deleteContent, @Assisted String path) {
+      this.deleteContent = deleteContent;
       this.path = path;
     }
 
     @Override
     public Response<?> apply(ChangeResource rsrc, DeleteFile.Input in)
-        throws IOException, AuthException, ResourceConflictException,
-        OrmException, InvalidChangeOperationException, BadRequestException {
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
-      if (edit.isPresent()) {
-        // Edit is wiped out
-        editUtil.delete(edit.get());
-      } else {
-        // Edit is created on top of current patch set by deleting path.
-        // Even if the latest patch set changed since the user triggered
-        // the operation, deleting the whole file is probably still what
-        // they intended.
-        editModifier.createEdit(rsrc.getChange(),
-            psUtil.current(db.get(), rsrc.getNotes()));
-        edit = editUtil.byChange(rsrc.getChange());
-        editModifier.deleteFile(edit.get(), path);
-      }
-      return Response.none();
+        throws IOException, AuthException, ResourceConflictException, OrmException {
+      return deleteContent.apply(rsrc.getControl(), path);
     }
   }
 
@@ -272,7 +201,8 @@
     boolean downloadCommands;
 
     @Inject
-    Detail(ChangeEditUtil editUtil,
+    Detail(
+        ChangeEditUtil editUtil,
         ChangeEditJson editJson,
         FileInfoJson fileInfoJson,
         Revisions revisions) {
@@ -283,8 +213,8 @@
     }
 
     @Override
-    public Response<EditInfo> apply(ChangeResource rsrc) throws AuthException,
-        IOException, ResourceNotFoundException, OrmException {
+    public Response<EditInfo> apply(ChangeResource rsrc)
+        throws AuthException, IOException, ResourceNotFoundException, OrmException {
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
       if (!edit.isPresent()) {
         return Response.none();
@@ -294,16 +224,12 @@
       if (list) {
         PatchSet basePatchSet = null;
         if (base != null) {
-          RevisionResource baseResource = revisions.parse(
-              rsrc, IdString.fromDecoded(base));
+          RevisionResource baseResource = revisions.parse(rsrc, IdString.fromDecoded(base));
           basePatchSet = baseResource.getPatchSet();
         }
         try {
           editInfo.files =
-              fileInfoJson.toFileInfoMap(
-                  rsrc.getChange(),
-                  edit.get().getRevision(),
-                  basePatchSet);
+              fileInfoJson.toFileInfoMap(rsrc.getChange(), edit.get().getRevision(), basePatchSet);
         } catch (PatchListNotAvailableException e) {
           throw new ResourceNotFoundException(e.getMessage());
         }
@@ -313,101 +239,94 @@
   }
 
   /**
-   * Post to edit collection resource. Two different operations are
-   * supported:
+   * Post to edit collection resource. Two different operations are supported:
+   *
    * <ul>
-   * <li>Create non existing change edit</li>
-   * <li>Restore path in existing change edit</li>
+   *   <li>Create non existing change edit
+   *   <li>Restore path in existing change edit
    * </ul>
+   *
    * The combination of two operations in one request is supported.
    */
   @Singleton
-  public static class Post implements
-      RestModifyView<ChangeResource, Post.Input> {
+  public static class Post implements RestModifyView<ChangeResource, Post.Input> {
     public static class Input {
       public String restorePath;
       public String oldPath;
       public String newPath;
     }
 
-    private final Provider<ReviewDb> db;
-    private final ChangeEditUtil editUtil;
     private final ChangeEditModifier editModifier;
-    private final PatchSetUtil psUtil;
+    private final GitRepositoryManager repositoryManager;
 
     @Inject
-    Post(Provider<ReviewDb> db,
-        ChangeEditUtil editUtil,
-        ChangeEditModifier editModifier,
-        PatchSetUtil psUtil) {
-      this.db = db;
-      this.editUtil = editUtil;
+    Post(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
       this.editModifier = editModifier;
-      this.psUtil = psUtil;
+      this.repositoryManager = repositoryManager;
     }
 
     @Override
     public Response<?> apply(ChangeResource resource, Post.Input input)
-        throws AuthException, InvalidChangeOperationException, IOException,
-        ResourceConflictException, OrmException {
-      Optional<ChangeEdit> edit = editUtil.byChange(resource.getChange());
-      if (!edit.isPresent()) {
-        edit = createEdit(resource);
-      }
-
-      if (input != null) {
-        if (!Strings.isNullOrEmpty(input.restorePath)) {
-          editModifier.restoreFile(edit.get(), input.restorePath);
-        } else if (!Strings.isNullOrEmpty(input.oldPath)
-            && !Strings.isNullOrEmpty(input.newPath)) {
-          editModifier.renameFile(edit.get(), input.oldPath, input.newPath);
+        throws AuthException, IOException, ResourceConflictException, OrmException {
+      Project.NameKey project = resource.getProject();
+      try (Repository repository = repositoryManager.openRepository(project)) {
+        ChangeControl changeControl = resource.getControl();
+        if (isRestoreFile(input)) {
+          editModifier.restoreFile(repository, changeControl, input.restorePath);
+        } else if (isRenameFile(input)) {
+          editModifier.renameFile(repository, changeControl, input.oldPath, input.newPath);
+        } else {
+          editModifier.createEdit(repository, changeControl);
         }
+      } catch (InvalidChangeOperationException e) {
+        throw new ResourceConflictException(e.getMessage());
       }
       return Response.none();
     }
 
-    private Optional<ChangeEdit> createEdit(ChangeResource resource)
-        throws AuthException, IOException, ResourceConflictException,
-        OrmException {
-      editModifier.createEdit(resource.getChange(),
-          psUtil.current(db.get(), resource.getNotes()));
-      return editUtil.byChange(resource.getChange());
+    private static boolean isRestoreFile(Input input) {
+      return input != null && !Strings.isNullOrEmpty(input.restorePath);
+    }
+
+    private static boolean isRenameFile(Input input) {
+      return input != null
+          && !Strings.isNullOrEmpty(input.oldPath)
+          && !Strings.isNullOrEmpty(input.newPath);
     }
   }
 
-  /**
-  * Put handler that is activated when PUT request is called on
-  * collection element.
-  */
+  /** Put handler that is activated when PUT request is called on collection element. */
   @Singleton
-  public static class Put implements
-      RestModifyView<ChangeEditResource, Put.Input> {
+  public static class Put implements RestModifyView<ChangeEditResource, Put.Input> {
     public static class Input {
-      @DefaultInput
-      public RawInput content;
+      @DefaultInput public RawInput content;
     }
 
     private final ChangeEditModifier editModifier;
+    private final GitRepositoryManager repositoryManager;
 
     @Inject
-    Put(ChangeEditModifier editModifier) {
+    Put(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
       this.editModifier = editModifier;
+      this.repositoryManager = repositoryManager;
     }
 
     @Override
     public Response<?> apply(ChangeEditResource rsrc, Input input)
-        throws AuthException, ResourceConflictException {
-      String path = rsrc.getPath();
+        throws AuthException, ResourceConflictException, IOException, OrmException {
+      return apply(rsrc.getControl(), rsrc.getPath(), input.content);
+    }
+
+    public Response<?> apply(ChangeControl changeControl, String path, RawInput newContent)
+        throws ResourceConflictException, AuthException, IOException, OrmException {
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
         throw new ResourceConflictException("Invalid path: " + path);
       }
 
-      try {
-        editModifier.modifyFile(
-            rsrc.getChangeEdit(),
-            rsrc.getPath(),
-            input.content);
-      } catch (InvalidChangeOperationException | IOException e) {
+      Project.NameKey project = changeControl.getChange().getProject();
+      try (Repository repository = repositoryManager.openRepository(project)) {
+        editModifier.modifyFile(repository, changeControl, path, newContent);
+      } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
       return Response.none();
@@ -416,29 +335,36 @@
 
   /**
    * Handler to delete a file.
-   * <p>
-   * This deletes the file from the repository completely. This is not the same
-   * as reverting or restoring a file to its previous contents.
+   *
+   * <p>This deletes the file from the repository completely. This is not the same as reverting or
+   * restoring a file to its previous contents.
    */
   @Singleton
-  public static class DeleteContent implements
-      RestModifyView<ChangeEditResource, DeleteContent.Input> {
-    public static class Input {
-    }
+  public static class DeleteContent
+      implements RestModifyView<ChangeEditResource, DeleteContent.Input> {
+    public static class Input {}
 
     private final ChangeEditModifier editModifier;
+    private final GitRepositoryManager repositoryManager;
 
     @Inject
-    DeleteContent(ChangeEditModifier editModifier) {
+    DeleteContent(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
       this.editModifier = editModifier;
+      this.repositoryManager = repositoryManager;
     }
 
     @Override
     public Response<?> apply(ChangeEditResource rsrc, DeleteContent.Input input)
-        throws AuthException, ResourceConflictException {
-      try {
-        editModifier.deleteFile(rsrc.getChangeEdit(), rsrc.getPath());
-      } catch (InvalidChangeOperationException | IOException e) {
+        throws AuthException, ResourceConflictException, OrmException, IOException {
+      return apply(rsrc.getControl(), rsrc.getPath());
+    }
+
+    public Response<?> apply(ChangeControl changeControl, String filePath)
+        throws AuthException, IOException, OrmException, ResourceConflictException {
+      Project.NameKey project = changeControl.getChange().getProject();
+      try (Repository repository = repositoryManager.openRepository(project)) {
+        editModifier.deleteFile(repository, changeControl, filePath);
+      } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
       return Response.none();
@@ -448,9 +374,10 @@
   public static class Get implements RestReadView<ChangeEditResource> {
     private final FileContentUtil fileContentUtil;
 
-    @Option(name = "--base", aliases = {"-b"},
-      usage = "whether to load the content on the base revision instead of the"
-        + " change edit")
+    @Option(
+        name = "--base",
+        aliases = {"-b"},
+        usage = "whether to load the content on the base revision instead of the change edit")
     private boolean base;
 
     @Inject
@@ -459,17 +386,16 @@
     }
 
     @Override
-    public Response<?> apply(ChangeEditResource rsrc)
-        throws IOException {
+    public Response<BinaryResult> apply(ChangeEditResource rsrc) throws IOException {
       try {
         ChangeEdit edit = rsrc.getChangeEdit();
-        return Response.ok(fileContentUtil.getContent(
-              rsrc.getControl().getProjectControl().getProjectState(),
-              base
-                  ? ObjectId.fromString(
-                      edit.getBasePatchSet().getRevision().get())
-                  : ObjectId.fromString(edit.getRevision().get()),
-              rsrc.getPath()));
+        return Response.ok(
+            fileContentUtil.getContent(
+                rsrc.getControl().getProjectControl().getProjectState(),
+                base
+                    ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
+                    : ObjectId.fromString(edit.getRevision().get()),
+                rsrc.getPath()));
       } catch (ResourceNotFoundException rnfe) {
         return Response.none();
       }
@@ -490,8 +416,9 @@
       FileInfo r = new FileInfo();
       ChangeEdit edit = rsrc.getChangeEdit();
       Change change = edit.getChange();
-      FluentIterable<DiffWebLinkInfo> links =
-          webLinks.getDiffLinks(change.getProject().get(),
+      List<DiffWebLinkInfo> links =
+          webLinks.getDiffLinks(
+              change.getProject().get(),
               change.getChangeId(),
               edit.getBasePatchSet().getPatchSetId(),
               edit.getBasePatchSet().getRefName(),
@@ -499,7 +426,7 @@
               0,
               edit.getRefName(),
               rsrc.getPath());
-      r.webLinks = links.isEmpty() ? null : links.toList();
+      r.webLinks = links.isEmpty() ? null : links;
       return r;
     }
 
@@ -509,48 +436,34 @@
   }
 
   @Singleton
-  public static class EditMessage implements
-      RestModifyView<ChangeResource, EditMessage.Input> {
+  public static class EditMessage implements RestModifyView<ChangeResource, EditMessage.Input> {
     public static class Input {
-      @DefaultInput
-      public String message;
+      @DefaultInput public String message;
     }
 
-    private final Provider<ReviewDb> db;
     private final ChangeEditModifier editModifier;
-    private final ChangeEditUtil editUtil;
-    private final PatchSetUtil psUtil;
+    private final GitRepositoryManager repositoryManager;
 
     @Inject
-    EditMessage(Provider<ReviewDb> db,
-        ChangeEditModifier editModifier,
-        ChangeEditUtil editUtil,
-        PatchSetUtil psUtil) {
-      this.db = db;
+    EditMessage(ChangeEditModifier editModifier, GitRepositoryManager repositoryManager) {
       this.editModifier = editModifier;
-      this.editUtil = editUtil;
-      this.psUtil = psUtil;
+      this.repositoryManager = repositoryManager;
     }
 
     @Override
-    public Object apply(ChangeResource rsrc, Input input) throws AuthException,
-        IOException, InvalidChangeOperationException, BadRequestException,
-        ResourceConflictException, OrmException {
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
-      if (!edit.isPresent()) {
-        editModifier.createEdit(rsrc.getChange(),
-            psUtil.current(db.get(), rsrc.getNotes()));
-        edit = editUtil.byChange(rsrc.getChange());
-      }
-
+    public Object apply(ChangeResource rsrc, Input input)
+        throws AuthException, IOException, BadRequestException, ResourceConflictException,
+            OrmException {
       if (input == null || Strings.isNullOrEmpty(input.message)) {
         throw new BadRequestException("commit message must be provided");
       }
 
-      try {
-        editModifier.modifyMessage(edit.get(), input.message);
-      } catch (UnchangedCommitMessageException ucm) {
-        throw new ResourceConflictException(ucm.getMessage());
+      Project.NameKey project = rsrc.getProject();
+      try (Repository repository = repositoryManager.openRepository(project)) {
+        ChangeControl changeControl = rsrc.getControl();
+        editModifier.modifyMessage(repository, changeControl, input.message);
+      } catch (UnchangedCommitMessageException e) {
+        throw new ResourceConflictException(e.getMessage());
       }
 
       return Response.none();
@@ -561,29 +474,30 @@
     private final GitRepositoryManager repoManager;
     private final ChangeEditUtil editUtil;
 
-    @Option(name = "--base", aliases = {"-b"},
-        usage = "whether to load the message on the base revision instead"
-        + " of the change edit")
+    @Option(
+        name = "--base",
+        aliases = {"-b"},
+        usage = "whether to load the message on the base revision instead of the change edit")
     private boolean base;
 
     @Inject
-    GetMessage(GitRepositoryManager repoManager,
-        ChangeEditUtil editUtil) {
+    GetMessage(GitRepositoryManager repoManager, ChangeEditUtil editUtil) {
       this.repoManager = repoManager;
       this.editUtil = editUtil;
     }
 
     @Override
-    public BinaryResult apply(ChangeResource rsrc) throws AuthException,
-        IOException, ResourceNotFoundException, OrmException {
+    public BinaryResult apply(ChangeResource rsrc)
+        throws AuthException, IOException, ResourceNotFoundException, OrmException {
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
       String msg;
       if (edit.isPresent()) {
         if (base) {
           try (Repository repo = repoManager.openRepository(rsrc.getProject());
               RevWalk rw = new RevWalk(repo)) {
-            RevCommit commit = rw.parseCommit(ObjectId.fromString(
-                edit.get().getBasePatchSet().getRevision().get()));
+            RevCommit commit =
+                rw.parseCommit(
+                    ObjectId.fromString(edit.get().getBasePatchSet().getRevision().get()));
             msg = commit.getFullMessage();
           }
         } else {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java
new file mode 100644
index 0000000..f852a97
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class ChangeIncludedIn implements RestReadView<ChangeResource> {
+  private Provider<ReviewDb> db;
+  private PatchSetUtil psUtil;
+  private IncludedIn includedIn;
+
+  @Inject
+  ChangeIncludedIn(Provider<ReviewDb> db, PatchSetUtil psUtil, IncludedIn includedIn) {
+    this.db = db;
+    this.psUtil = psUtil;
+    this.includedIn = includedIn;
+  }
+
+  @Override
+  public IncludedInInfo apply(ChangeResource rsrc)
+      throws RestApiException, OrmException, IOException {
+    ChangeControl ctl = rsrc.getControl();
+    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
+    Project.NameKey project = ctl.getProject().getNameKey();
+    return includedIn.apply(project, ps.getRevision().get());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index 0d7a1bf..1908583 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -17,13 +17,19 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.Change.INITIAL_PATCH_SET_ID;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -33,42 +39,32 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
-import com.google.gerrit.server.git.BanCommit;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.SendEmailExecutor;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.CreateChangeSender;
+import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.InsertChangeOp;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.util.ChangeIdUtil;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
@@ -76,16 +72,23 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.util.ChangeIdUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-public class ChangeInserter extends BatchUpdate.InsertChangeOp {
+public class ChangeInserter implements InsertChangeOp {
   public interface Factory {
     ChangeInserter create(Change.Id cid, RevCommit rc, String refName);
   }
 
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeInserter.class);
+  private static final Logger log = LoggerFactory.getLogger(ChangeInserter.class);
 
   private final ProjectControl.GenericFactory projectControlFactory;
+  private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
@@ -106,10 +109,11 @@
   private Change.Status status;
   private String topic;
   private String message;
+  private String patchSetDescription;
   private List<String> groups = Collections.emptyList();
-  private CommitValidators.Policy validatePolicy =
-      CommitValidators.Policy.GERRIT;
+  private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
   private NotifyHandling notify = NotifyHandling.ALL;
+  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
   private Set<Account.Id> reviewers;
   private Set<Account.Id> extraCC;
   private Map<String, Short> approvals;
@@ -127,7 +131,9 @@
   private String pushCert;
 
   @Inject
-  ChangeInserter(ProjectControl.GenericFactory projectControlFactory,
+  ChangeInserter(
+      ProjectControl.GenericFactory projectControlFactory,
+      IdentifiedUser.GenericFactory userFactory,
       ChangeControl.GenericFactory changeControlFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
@@ -142,6 +148,7 @@
       @Assisted RevCommit commit,
       @Assisted String refName) {
     this.projectControlFactory = projectControlFactory;
+    this.userFactory = userFactory;
     this.changeControlFactory = changeControlFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
@@ -168,12 +175,13 @@
 
   @Override
   public Change createChange(Context ctx) {
-    change = new Change(
-        getChangeKey(commit),
-        changeId,
-        ctx.getAccountId(),
-        new Branch.NameKey(ctx.getProject(), refName),
-        ctx.getWhen());
+    change =
+        new Change(
+            getChangeKey(commit),
+            changeId,
+            ctx.getAccountId(),
+            new Branch.NameKey(ctx.getProject(), refName),
+            ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
     return change;
@@ -184,9 +192,13 @@
     if (!idList.isEmpty()) {
       return new Change.Key(idList.get(idList.size() - 1).trim());
     }
-    ObjectId id = ChangeIdUtil.computeChangeId(commit.getTree(), commit,
-        commit.getAuthorIdent(), commit.getCommitterIdent(),
-        commit.getShortMessage());
+    ObjectId id =
+        ChangeIdUtil.computeChangeId(
+            commit.getTree(),
+            commit,
+            commit.getAuthorIdent(),
+            commit.getCommitterIdent(),
+            commit.getShortMessage());
     StringBuilder changeId = new StringBuilder();
     changeId.append("I").append(ObjectId.toString(id));
     return new Change.Key(changeId.toString());
@@ -216,6 +228,11 @@
     return this;
   }
 
+  public ChangeInserter setPatchSetDescription(String patchSetDescription) {
+    this.patchSetDescription = patchSetDescription;
+    return this;
+  }
+
   public ChangeInserter setValidatePolicy(CommitValidators.Policy validate) {
     this.validatePolicy = checkNotNull(validate);
     return this;
@@ -226,6 +243,12 @@
     return this;
   }
 
+  public ChangeInserter setAccountsToNotify(
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.accountsToNotify = checkNotNull(accountsToNotify);
+    return this;
+  }
+
   public ChangeInserter setReviewers(Set<Account.Id> reviewers) {
     this.reviewers = reviewers;
     return this;
@@ -237,22 +260,19 @@
   }
 
   public ChangeInserter setDraft(boolean draft) {
-    checkState(change == null,
-        "setDraft(boolean) only valid before creating change");
+    checkState(change == null, "setDraft(boolean) only valid before creating change");
     return setStatus(draft ? Change.Status.DRAFT : Change.Status.NEW);
   }
 
   public ChangeInserter setStatus(Change.Status status) {
-    checkState(change == null,
-        "setStatus(Change.Status) only valid before creating change");
+    checkState(change == null, "setStatus(Change.Status) only valid before creating change");
     this.status = status;
     return this;
   }
 
   public ChangeInserter setGroups(List<String> groups) {
     checkNotNull(groups, "groups may not be empty");
-    checkState(patchSet == null,
-        "setGroups(Iterable<String>) only valid before creating change");
+    checkState(patchSet == null, "setGroups(Iterable<String>) only valid before creating change");
     this.groups = groups;
     return this;
   }
@@ -281,8 +301,7 @@
   }
 
   public PatchSet getPatchSet() {
-    checkState(patchSet != null,
-        "getPatchSet() only valid after creating change");
+    checkState(patchSet != null, "getPatchSet() only valid after creating change");
     return patchSet;
   }
 
@@ -300,28 +319,26 @@
     if (message == null) {
       return null;
     }
-    checkState(changeMessage != null,
-        "getChangeMessage() only valid after inserting change");
+    checkState(changeMessage != null, "getChangeMessage() only valid after inserting change");
     return changeMessage;
   }
 
   @Override
-  public void updateRepo(RepoContext ctx)
-      throws ResourceConflictException, IOException {
+  public void updateRepo(RepoContext ctx) throws ResourceConflictException, IOException {
     validate(ctx);
     if (!updateRef) {
       return;
     }
     if (updateRefCommand == null) {
-      ctx.addRefUpdate(
-          new ReceiveCommand(ObjectId.zeroId(), commit, psId.toRefName()));
+      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), commit, psId.toRefName()));
     } else {
       ctx.addRefUpdate(updateRefCommand);
     }
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx) throws OrmException, IOException {
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException {
     change = ctx.getChange(); // Use defensive copy created by ChangeControl.
     ReviewDb db = ctx.getDb();
     ChangeControl ctl = ctx.getControl();
@@ -333,14 +350,24 @@
     update.setSubjectForCommit("Create change");
     update.setBranch(change.getDest().get());
     update.setTopic(change.getTopic());
+    update.setPsDescription(patchSetDescription);
 
     boolean draft = status == Change.Status.DRAFT;
     List<String> newGroups = groups;
     if (newGroups.isEmpty()) {
       newGroups = GroupCollector.getDefaultGroups(commit);
     }
-    patchSet = psUtil.insert(ctx.getDb(), ctx.getRevWalk(), update, psId,
-        commit, draft, newGroups, pushCert);
+    patchSet =
+        psUtil.insert(
+            ctx.getDb(),
+            ctx.getRevWalk(),
+            update,
+            psId,
+            commit,
+            draft,
+            newGroups,
+            pushCert,
+            patchSetDescription);
 
     /* TODO: fixStatus is used here because the tests
      * (byStatusClosed() in AbstractQueryChangesTest)
@@ -353,48 +380,87 @@
     update.fixStatus(change.getStatus());
 
     LabelTypes labelTypes = ctl.getProjectControl().getLabelTypes();
-    approvalsUtil.addReviewers(db, update, labelTypes, change,
-        patchSet, patchSetInfo, reviewers, Collections.<Account.Id> emptySet());
-    approvalsUtil.addApprovals(db, update, labelTypes, patchSet,
-        ctx.getControl(), approvals);
+    approvalsUtil.addReviewers(
+        db,
+        update,
+        labelTypes,
+        change,
+        patchSet,
+        patchSetInfo,
+        filterOnChangeVisibility(db, ctx.getNotes(), reviewers),
+        Collections.<Account.Id>emptySet());
+    approvalsUtil.addApprovalsForNewPatchSet(
+        db, update, labelTypes, patchSet, ctx.getControl(), approvals);
+    // Check if approvals are changing in with this update. If so, add current user to reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // reviewer which is needed in several other code paths.
+    if (!approvals.isEmpty()) {
+      update.putReviewer(ctx.getAccountId(), REVIEWER);
+    }
     if (message != null) {
       changeMessage =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(db)), ctx.getAccountId(),
-              patchSet.getCreatedOn(), patchSet.getId());
-      changeMessage.setMessage(message);
+          ChangeMessagesUtil.newMessage(
+              patchSet.getId(),
+              ctx.getUser(),
+              patchSet.getCreatedOn(),
+              message,
+              ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
       cmUtil.addChangeMessage(db, update, changeMessage);
     }
     return true;
   }
 
-  @Override
-  public void postUpdate(Context ctx) throws OrmException, NoSuchChangeException {
-    if (sendMail) {
-      Runnable sender = new Runnable() {
-        @Override
-        public void run() {
-          try {
-            CreateChangeSender cm = createChangeSenderFactory
-                .create(change.getProject(), change.getId());
-            cm.setFrom(change.getOwner());
-            cm.setPatchSet(patchSet, patchSetInfo);
-            cm.setNotify(notify);
-            cm.addReviewers(reviewers);
-            cm.addExtraCC(extraCC);
-            cm.send();
-          } catch (Exception e) {
-            log.error("Cannot send email for new change " + change.getId(), e);
-          }
-        }
+  private Set<Account.Id> filterOnChangeVisibility(
+      final ReviewDb db, final ChangeNotes notes, Set<Account.Id> accounts) {
+    return accounts.stream()
+        .filter(
+            accountId -> {
+              try {
+                IdentifiedUser user = userFactory.create(accountId);
+                return changeControlFactory.controlFor(notes, user).isVisible(db);
+              } catch (OrmException e) {
+                log.warn(
+                    "Failed to check if account {} can see change {}",
+                    accountId.get(),
+                    notes.getChangeId().get(),
+                    e);
+                return false;
+              }
+            })
+        .collect(toSet());
+  }
 
-        @Override
-        public String toString() {
-          return "send-email newchange";
-        }
-      };
+  @Override
+  public void postUpdate(Context ctx) throws OrmException {
+    if (sendMail && (notify != NotifyHandling.NONE || !accountsToNotify.isEmpty())) {
+      Runnable sender =
+          new Runnable() {
+            @Override
+            public void run() {
+              try {
+                CreateChangeSender cm =
+                    createChangeSenderFactory.create(change.getProject(), change.getId());
+                cm.setFrom(change.getOwner());
+                cm.setPatchSet(patchSet, patchSetInfo);
+                cm.setNotify(notify);
+                cm.setAccountsToNotify(accountsToNotify);
+                cm.addReviewers(reviewers);
+                cm.addExtraCC(extraCC);
+                cm.send();
+              } catch (Exception e) {
+                log.error("Cannot send email for new change {}", change.getId(), e);
+              }
+            }
+
+            @Override
+            public String toString() {
+              return "send-email newchange";
+            }
+          };
       if (requestScopePropagator != null) {
-        sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError =
+            sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
       } else {
         sender.run();
       }
@@ -406,11 +472,10 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(),
-          ctx.getWhen(), notify);
+      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
-        ChangeControl changeControl = changeControlFactory.controlFor(
-            ctx.getDb(), change, ctx.getUser());
+        ChangeControl changeControl =
+            changeControlFactory.controlFor(ctx.getDb(), change, ctx.getUser());
         List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
         Map<String, Short> allApprovals = new HashMap<>();
         Map<String, Short> oldApprovals = new HashMap<>();
@@ -424,48 +489,31 @@
             oldApprovals.put(entry.getKey(), (short) 0);
           }
         }
-        commentAdded.fire(change, patchSet,
-            ctx.getAccount(), null,
-            allApprovals, oldApprovals, ctx.getWhen());
+        commentAdded.fire(
+            change, patchSet, ctx.getAccount(), null, allApprovals, oldApprovals, ctx.getWhen());
       }
     }
   }
 
-  private void validate(RepoContext ctx)
-      throws IOException, ResourceConflictException {
+  private void validate(RepoContext ctx) throws IOException, ResourceConflictException {
     if (validatePolicy == CommitValidators.Policy.NONE) {
       return;
     }
 
     try {
-      RefControl refControl = projectControlFactory
-          .controlFor(ctx.getProject(), ctx.getUser()).controlForRef(refName);
-      CommitValidators cv = commitValidatorsFactory.create(
-          refControl, new NoSshInfo(), ctx.getRepository());
-
+      RefControl refControl =
+          projectControlFactory.controlFor(ctx.getProject(), ctx.getUser()).controlForRef(refName);
       String refName = psId.toRefName();
-      CommitReceivedEvent event = new CommitReceivedEvent(
-          new ReceiveCommand(
-              ObjectId.zeroId(),
-              commit.getId(),
-              refName),
-          refControl.getProjectControl().getProject(),
-          change.getDest().get(),
-          commit,
-          ctx.getIdentifiedUser());
-
-      switch (validatePolicy) {
-      case RECEIVE_COMMITS:
-        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(
-            ctx.getRepository(), ctx.getRevWalk());
-        cv.validateForReceiveCommits(event, rejectCommits);
-        break;
-      case GERRIT:
-        cv.validateForGerritCommits(event);
-        break;
-      case NONE:
-        break;
-      }
+      CommitReceivedEvent event =
+          new CommitReceivedEvent(
+              new ReceiveCommand(ObjectId.zeroId(), commit.getId(), refName),
+              refControl.getProjectControl().getProject(),
+              change.getDest().get(),
+              commit,
+              ctx.getIdentifiedUser());
+      commitValidatorsFactory
+          .create(validatePolicy, refControl, new NoSshInfo(), ctx.getRepository())
+          .validate(event);
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
     } catch (NoSuchProjectException e) {
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 6d81319..d0d56e1 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
@@ -32,26 +33,28 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
+import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Optional;
 import com.google.common.base.Throwables;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -62,6 +65,7 @@
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -73,6 +77,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.VotingRangeInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -87,6 +92,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GpgException;
@@ -100,49 +106,83 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.QueryResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeMap;
-
 public class ChangeJson {
   private static final Logger log = LoggerFactory.getLogger(ChangeJson.class);
-  public static final Set<ListChangesOption> NO_OPTIONS =
-      Collections.emptySet();
 
-  public interface Factory {
-    ChangeJson create(Set<ListChangesOption> options);
+  // Submit rule options in this class should always use fastEvalLabels for
+  // efficiency reasons. Callers that care about submittability after taking
+  // vote squashing into account should be looking at the submit action.
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
+      ChangeField.SUBMIT_RULE_OPTIONS_LENIENT.toBuilder().fastEvalLabels(true).build();
+
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
+      ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().fastEvalLabels(true).build();
+
+  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+      ImmutableSet.of(ALL_REVISIONS, MESSAGES);
+
+  @Singleton
+  public static class Factory {
+    private final AssistedFactory factory;
+
+    @Inject
+    Factory(AssistedFactory factory) {
+      this.factory = factory;
+    }
+
+    public ChangeJson noOptions() {
+      return create(ImmutableSet.of());
+    }
+
+    public ChangeJson create(Iterable<ListChangesOption> options) {
+      return factory.create(options);
+    }
+
+    public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
+      return create(Sets.immutableEnumSet(first, rest));
+    }
+  }
+
+  public interface AssistedFactory {
+    ChangeJson create(Iterable<ListChangesOption> options);
   }
 
   private final Provider<ReviewDb> db;
@@ -159,7 +199,7 @@
   private final DynamicMap<DownloadScheme> downloadSchemes;
   private final DynamicMap<DownloadCommand> downloadCommands;
   private final WebLinks webLinks;
-  private final EnumSet<ListChangesOption> options;
+  private final ImmutableSet<ListChangesOption> options;
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ConsistencyChecker> checkerProvider;
   private final ActionJson actionJson;
@@ -167,9 +207,11 @@
   private final ChangeNotes.Factory notesFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeKindCache changeKindCache;
+  private final ChangeIndexCollection indexes;
+  private final ApprovalsUtil approvalsUtil;
 
+  private boolean lazyLoad = true;
   private AccountLoader accountLoader;
-  private Map<Change.Id, List<SubmitRecord>> submitRecords;
   private FixInput fix;
 
   @AssistedInject
@@ -195,7 +237,9 @@
       ChangeNotes.Factory notesFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeKindCache changeKindCache,
-      @Assisted Set<ListChangesOption> options) {
+      ChangeIndexCollection indexes,
+      ApprovalsUtil approvalsUtil,
+      @Assisted Iterable<ListChangesOption> options) {
     this.db = db;
     this.labelNormalizer = ln;
     this.userProvider = user;
@@ -217,9 +261,14 @@
     this.notesFactory = notesFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeKindCache = changeKindCache;
-    this.options = options.isEmpty()
-        ? EnumSet.noneOf(ListChangesOption.class)
-        : EnumSet.copyOf(options);
+    this.indexes = indexes;
+    this.approvalsUtil = approvalsUtil;
+    this.options = Sets.immutableEnumSet(options);
+  }
+
+  public ChangeJson lazyLoad(boolean load) {
+    lazyLoad = load;
+    return this;
   }
 
   public ChangeJson fix(FixInput fix) {
@@ -235,12 +284,11 @@
     return format(changeDataFactory.create(db.get(), change));
   }
 
-  public ChangeInfo format(Project.NameKey project, Change.Id id)
-      throws OrmException, NoSuchChangeException {
+  public ChangeInfo format(Project.NameKey project, Change.Id id) throws OrmException {
     ChangeNotes notes;
     try {
       notes = notesFactory.createChecked(db.get(), project, id);
-    } catch (OrmException | NoSuchChangeException e) {
+    } catch (OrmException e) {
       if (!has(CHECK)) {
         throw e;
       }
@@ -250,11 +298,11 @@
   }
 
   public ChangeInfo format(ChangeData cd) throws OrmException {
-    return format(cd, Optional.<PatchSet.Id> absent(), true);
+    return format(cd, Optional.empty(), true);
   }
 
-  private ChangeInfo format(ChangeData cd, Optional<PatchSet.Id> limitToPsId,
-      boolean fillAccountLoader)
+  private ChangeInfo format(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader)
       throws OrmException {
     try {
       if (fillAccountLoader) {
@@ -264,10 +312,13 @@
         return res;
       }
       return toChangeInfo(cd, limitToPsId);
-    } catch (PatchListNotAvailableException | GpgException | OrmException
-        | IOException | RuntimeException e) {
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | OrmException
+        | IOException
+        | RuntimeException e) {
       if (!has(CHECK)) {
-        Throwables.propagateIfPossible(e, OrmException.class);
+        Throwables.throwIfInstanceOf(e, OrmException.class);
         throw new OrmException(e);
       }
       return checkOnly(cd);
@@ -279,16 +330,10 @@
     return format(cd, Optional.of(rsrc.getPatchSet().getId()), true);
   }
 
-  public List<List<ChangeInfo>> formatQueryResults(
-      List<QueryResult<ChangeData>> in) throws OrmException {
+  public List<List<ChangeInfo>> formatQueryResults(List<QueryResult<ChangeData>> in)
+      throws OrmException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    ensureLoaded(FluentIterable.from(in).transformAndConcat(
-        new Function<QueryResult<ChangeData>, List<ChangeData>>() {
-          @Override
-          public List<ChangeData> apply(QueryResult<ChangeData> in) {
-            return in.entities();
-          }
-        }));
+    ensureLoaded(FluentIterable.from(in).transformAndConcat(QueryResult::entities));
 
     List<List<ChangeInfo>> res = Lists.newArrayListWithCapacity(in.size());
     Map<Change.Id, ChangeInfo> out = new HashMap<>();
@@ -303,8 +348,7 @@
     return res;
   }
 
-  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in)
-      throws OrmException {
+  public List<ChangeInfo> formatChangeDatas(Collection<ChangeData> in) throws OrmException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
@@ -316,37 +360,44 @@
   }
 
   private void ensureLoaded(Iterable<ChangeData> all) throws OrmException {
-    ChangeData.ensureChangeLoaded(all);
-    if (has(ALL_REVISIONS)) {
-      ChangeData.ensureAllPatchSetsLoaded(all);
-    } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
-      ChangeData.ensureCurrentPatchSetLoaded(all);
+    if (lazyLoad) {
+      ChangeData.ensureChangeLoaded(all);
+      if (has(ALL_REVISIONS)) {
+        ChangeData.ensureAllPatchSetsLoaded(all);
+      } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
+        ChangeData.ensureCurrentPatchSetLoaded(all);
+      }
+      if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
+        ChangeData.ensureReviewedByLoadedForOpenChanges(all);
+      }
+      ChangeData.ensureCurrentApprovalsLoaded(all);
+    } else {
+      for (ChangeData cd : all) {
+        cd.setLazyLoad(false);
+      }
     }
-    if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
-      ChangeData.ensureReviewedByLoadedForOpenChanges(all);
-    }
-    ChangeData.ensureCurrentApprovalsLoaded(all);
   }
 
   private boolean has(ListChangesOption option) {
     return options.contains(option);
   }
 
-  private List<ChangeInfo> toChangeInfo(Map<Change.Id, ChangeInfo> out,
-      List<ChangeData> changes) {
+  private List<ChangeInfo> toChangeInfo(Map<Change.Id, ChangeInfo> out, List<ChangeData> changes) {
     List<ChangeInfo> info = Lists.newArrayListWithCapacity(changes.size());
     for (ChangeData cd : changes) {
       ChangeInfo i = out.get(cd.getId());
       if (i == null) {
         try {
-          i = toChangeInfo(cd, Optional.<PatchSet.Id> absent());
-        } catch (PatchListNotAvailableException | GpgException | OrmException
-            | IOException | RuntimeException e) {
+          i = toChangeInfo(cd, Optional.empty());
+        } catch (PatchListNotAvailableException
+            | GpgException
+            | OrmException
+            | IOException
+            | RuntimeException e) {
           if (has(CHECK)) {
             i = checkOnly(cd);
           } else {
-            log.warn(
-                "Omitting corrupt change " + cd.getId() + " from results", e);
+            log.warn("Omitting corrupt change " + cd.getId() + " from results", e);
             continue;
           }
         }
@@ -397,9 +448,8 @@
     return info;
   }
 
-  private ChangeInfo toChangeInfo(ChangeData cd,
-      Optional<PatchSet.Id> limitToPsId) throws PatchListNotAvailableException,
-      GpgException, OrmException, IOException {
+  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
     ChangeControl ctl = cd.changeControl().forUser(user);
@@ -419,16 +469,23 @@
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
     out.topic = in.getTopic();
+    if (indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE)) {
+      if (in.getAssignee() != null) {
+        out.assignee = accountLoader.get(in.getAssignee());
+      }
+    }
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
-    if (in.getStatus() != Change.Status.MERGED) {
+    if (in.getStatus().isOpen()) {
       SubmitTypeRecord str = cd.submitTypeRecord();
       if (str.isOk()) {
         out.submitType = str.type;
       }
       out.mergeable = cd.isMergeable();
+      if (has(SUBMITTABLE)) {
+        out.submittable = submittable(cd);
+      }
     }
-    out.submittable = Submit.submittable(cd);
     Optional<ChangedLines> changedLines = cd.changedLines();
     if (changedLines.isPresent()) {
       out.insertions = changedLines.get().insertions;
@@ -440,12 +497,11 @@
     out.created = in.getCreatedOn();
     out.updated = in.getLastUpdatedOn();
     out._number = in.getId().get();
+    out.unresolvedCommentCount = cd.unresolvedCommentCount();
 
     if (user.isIdentifiedUser()) {
-      Collection<String> stars = cd.stars().get(user.getAccountId());
-      out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL)
-          ? true
-          : null;
+      Collection<String> stars = cd.stars(user.getAccountId());
+      out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
       if (!stars.isEmpty()) {
         out.stars = stars;
       }
@@ -462,18 +518,20 @@
     if (out.labels != null && has(DETAILED_LABELS)) {
       // If limited to specific patch sets but not the current patch set, don't
       // list permitted labels, since users can't vote on those patch sets.
-      if (!limitToPsId.isPresent()
-          || limitToPsId.get().equals(in.currentPatchSetId())) {
-        out.permittedLabels = permittedLabels(ctl, cd);
+      if (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId())) {
+        out.permittedLabels =
+            cd.change().getStatus() != Change.Status.ABANDONED
+                ? permittedLabels(ctl, cd)
+                : ImmutableMap.of();
       }
-      out.removableReviewers = removableReviewers(ctl, out.labels.values());
 
       out.reviewers = new HashMap<>();
-      for (Map.Entry<ReviewerStateInternal, Map<Account.Id, Timestamp>> e
-          : cd.reviewers().asTable().rowMap().entrySet()) {
-        out.reviewers.put(e.getKey().asReviewerState(),
-            toAccountInfo(e.getValue().keySet()));
+      for (Map.Entry<ReviewerStateInternal, Map<Account.Id, Timestamp>> e :
+          cd.reviewers().asTable().rowMap().entrySet()) {
+        out.reviewers.put(e.getKey().asReviewerState(), toAccountInfo(e.getValue().keySet()));
       }
+
+      out.removableReviewers = removableReviewers(ctl, out);
     }
 
     if (has(REVIEWER_UPDATES)) {
@@ -481,9 +539,7 @@
     }
 
     boolean needMessages = has(MESSAGES);
-    boolean needRevisions = has(ALL_REVISIONS)
-        || has(CURRENT_REVISION)
-        || limitToPsId.isPresent();
+    boolean needRevisions = has(ALL_REVISIONS) || has(CURRENT_REVISION) || limitToPsId.isPresent();
     Map<PatchSet.Id, PatchSet> src;
     if (needMessages || needRevisions) {
       src = loadPatchSets(cd, limitToPsId);
@@ -495,8 +551,10 @@
     }
     finish(out);
 
+    // This block must come after the ChangeInfo is mostly populated, since
+    // it will be passed to ActionVisitors as-is.
     if (needRevisions) {
-      out.revisions = revisions(ctl, cd, src);
+      out.revisions = revisions(ctl, cd, src, out);
       if (out.revisions != null) {
         for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
           if (entry.getValue().isCurrent) {
@@ -514,8 +572,7 @@
     return out;
   }
 
-  private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd)
-      throws OrmException {
+  private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) throws OrmException {
     List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
     List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
     for (ReviewerStatusUpdate c : reviewerUpdates) {
@@ -529,27 +586,16 @@
     return result;
   }
 
-  private List<SubmitRecord> submitRecords(ChangeData cd) throws OrmException {
-    // Maintain our own cache rather than using cd.getSubmitRecords(),
-    // since the latter may not have used the same values for
-    // fastEvalLabels/allowDraft/etc.
-    // TODO(dborowitz): Handle this better at the ChangeData level.
-    if (submitRecords == null) {
-      submitRecords = new HashMap<>();
-    }
-    List<SubmitRecord> records = submitRecords.get(cd.getId());
-    if (records == null) {
-      records = new SubmitRuleEvaluator(cd)
-        .setFastEvalLabels(true)
-        .setAllowDraft(true)
-        .evaluate();
-      submitRecords.put(cd.getId(), records);
-    }
-    return records;
+  private boolean submittable(ChangeData cd) throws OrmException {
+    return SubmitRecord.findOkRecord(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)).isPresent();
   }
 
-  private Map<String, LabelInfo> labelsFor(ChangeControl ctl,
-      ChangeData cd, boolean standard, boolean detailed) throws OrmException {
+  private List<SubmitRecord> submitRecords(ChangeData cd) throws OrmException {
+    return cd.submitRecords(SUBMIT_RULE_OPTIONS_LENIENT);
+  }
+
+  private Map<String, LabelInfo> labelsFor(
+      ChangeControl ctl, ChangeData cd, boolean standard, boolean detailed) throws OrmException {
     if (!standard && !detailed) {
       return null;
     }
@@ -559,15 +605,15 @@
     }
 
     LabelTypes labelTypes = ctl.getLabelTypes();
-    Map<String, LabelWithStatus> withStatus = cd.change().getStatus().isOpen()
-      ? labelsForOpenChange(ctl, cd, labelTypes, standard, detailed)
-      : labelsForClosedChange(cd, labelTypes, standard, detailed);
-    return ImmutableMap.copyOf(
-        Maps.transformValues(withStatus, LabelWithStatus.TO_LABEL_INFO));
+    Map<String, LabelWithStatus> withStatus =
+        cd.change().getStatus().isOpen()
+            ? labelsForOpenChange(ctl, cd, labelTypes, standard, detailed)
+            : labelsForClosedChange(ctl, cd, labelTypes, standard, detailed);
+    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
-  private Map<String, LabelWithStatus> labelsForOpenChange(ChangeControl ctl,
-      ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
+  private Map<String, LabelWithStatus> labelsForOpenChange(
+      ChangeControl ctl, ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
       throws OrmException {
     Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
     if (detailed) {
@@ -594,8 +640,8 @@
     return labels;
   }
 
-  private Map<String, LabelWithStatus> initLabels(ChangeData cd,
-      LabelTypes labelTypes, boolean standard) throws OrmException {
+  private Map<String, LabelWithStatus> initLabels(
+      ChangeData cd, LabelTypes labelTypes, boolean standard) throws OrmException {
     // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
     Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
     for (SubmitRecord rec : submitRecords(cd)) {
@@ -631,8 +677,8 @@
     return labels;
   }
 
-  private void setLabelScores(LabelType type,
-      LabelWithStatus l, short score, Account.Id accountId) {
+  private void setLabelScores(
+      LabelType type, LabelWithStatus l, short score, Account.Id accountId) {
     if (l.label().approved != null || l.label().rejected != null) {
       return;
     }
@@ -657,19 +703,23 @@
     }
   }
 
-  private void setAllApprovals(ChangeControl baseCtrl, ChangeData cd,
-      Map<String, LabelWithStatus> labels) throws OrmException {
+  private void setAllApprovals(
+      ChangeControl baseCtrl, ChangeData cd, Map<String, LabelWithStatus> labels)
+      throws OrmException {
+    Change.Status status = cd.change().getStatus();
+    checkState(status.isOpen(), "should not call setAllApprovals on %s change", status);
+
     // Include a user in the output for this label if either:
     //  - They are an explicit reviewer.
     //  - They ever voted on this change.
     Set<Account.Id> allUsers = new HashSet<>();
-    allUsers.addAll(cd.reviewers().all());
+    allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
     for (PatchSetApproval psa : cd.approvals().values()) {
       allUsers.add(psa.getAccountId());
     }
 
-    Table<Account.Id, String, PatchSetApproval> current = HashBasedTable.create(
-        allUsers.size(), baseCtrl.getLabelTypes().getLabelTypes().size());
+    Table<Account.Id, String, PatchSetApproval> current =
+        HashBasedTable.create(allUsers.size(), baseCtrl.getLabelTypes().getLabelTypes().size());
     for (PatchSetApproval psa : cd.currentApprovals()) {
       current.put(psa.getAccountId(), psa.getLabel(), psa);
     }
@@ -677,6 +727,7 @@
     for (Account.Id accountId : allUsers) {
       IdentifiedUser user = userFactory.create(accountId);
       ChangeControl ctl = baseCtrl.forUser(user);
+      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         LabelType lt = ctl.getLabelTypes().byLabel(e.getKey());
         if (lt == null) {
@@ -685,6 +736,7 @@
           continue;
         }
         Integer value;
+        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
         String tag = null;
         Timestamp date = null;
         PatchSetApproval psa = current.get(accountId, lt.getName());
@@ -698,26 +750,64 @@
           }
           tag = psa.getTag();
           date = psa.getGranted();
+          if (psa.isPostSubmit()) {
+            log.warn("unexpected post-submit approval on open change: {}", psa);
+          }
         } else {
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
           value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
         }
-        addApproval(e.getValue().label(),
-            approvalInfo(accountId, value, tag, date));
+        addApproval(
+            e.getValue().label(), approvalInfo(accountId, value, permittedVotingRange, tag, date));
       }
     }
   }
 
-  private Timestamp getSubmittedOn(ChangeData cd)
-      throws OrmException {
+  private Map<String, VotingRangeInfo> getPermittedVotingRanges(
+      Map<String, Collection<String>> permittedLabels) {
+    Map<String, VotingRangeInfo> permittedVotingRanges =
+        Maps.newHashMapWithExpectedSize(permittedLabels.size());
+    for (String label : permittedLabels.keySet()) {
+      List<Integer> permittedVotingRange =
+          permittedLabels.get(label).stream()
+              .map(this::parseRangeValue)
+              .filter(java.util.Objects::nonNull)
+              .sorted()
+              .collect(toList());
+
+      if (permittedVotingRange.isEmpty()) {
+        permittedVotingRanges.put(label, null);
+      } else {
+        int minPermittedValue = permittedVotingRange.get(0);
+        int maxPermittedValue = Iterables.getLast(permittedVotingRange);
+        permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
+      }
+    }
+    return permittedVotingRanges;
+  }
+
+  private Integer parseRangeValue(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    } else if (value.startsWith(" ")) {
+      value = value.trim();
+    }
+    return Ints.tryParse(value);
+  }
+
+  private Timestamp getSubmittedOn(ChangeData cd) throws OrmException {
     Optional<PatchSetApproval> s = cd.getSubmitApproval();
     return s.isPresent() ? s.get().getGranted() : null;
   }
 
-  private Map<String, LabelWithStatus> labelsForClosedChange(ChangeData cd,
-      LabelTypes labelTypes, boolean standard, boolean detailed)
+  private Map<String, LabelWithStatus> labelsForClosedChange(
+      ChangeControl baseCtrl,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
       throws OrmException {
     Set<Account.Id> allUsers = new HashSet<>();
     if (detailed) {
@@ -730,10 +820,9 @@
       }
     }
 
-    // We can only approximately reconstruct what the submit rule evaluator
-    // would have done. These should really come from a stored submit record.
     Set<String> labelNames = new HashSet<>();
-    Multimap<Account.Id, PatchSetApproval> current = HashMultimap.create();
+    SetMultimap<Account.Id, PatchSetApproval> current =
+        MultimapBuilder.hashKeys().hashSetValues().build();
     for (PatchSetApproval a : cd.currentApprovals()) {
       allUsers.add(a.getAccountId());
       LabelType type = labelTypes.byLabel(a.getLabelId());
@@ -745,25 +834,50 @@
       }
     }
 
-    // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
-    Map<String, LabelWithStatus> labels =
-        new TreeMap<>(labelTypes.nameComparator());
-    for (String name : labelNames) {
-      LabelType type = labelTypes.byLabel(name);
-      LabelWithStatus l = LabelWithStatus.create(new LabelInfo(), null);
-      if (detailed) {
-        setLabelValues(type, l);
+    Map<String, LabelWithStatus> labels;
+    if (cd.change().getStatus() == Change.Status.MERGED) {
+      // Since voting on merged changes is allowed all labels which apply to
+      // the change must be returned. All applying labels can be retrieved from
+      // the submit records, which is what initLabels does.
+      // It's not possible to only compute the labels based on the approvals
+      // since merged changes may not have approvals for all labels (e.g. if not
+      // all labels are required for submit or if the change was auto-closed due
+      // to direct push or if new labels were defined after the change was
+      // merged).
+      labels = initLabels(cd, labelTypes, standard);
+
+      // Also include all labels for which approvals exists. E.g. there can be
+      // approvals for labels that are ignored by a Prolog submit rule and hence
+      // it wouldn't be included in the submit records.
+      for (String name : labelNames) {
+        if (!labels.containsKey(name)) {
+          labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
+        }
       }
-      labels.put(type.getName(), l);
+    } else {
+      // For abandoned changes return only labels for which approvals exist.
+      // Other labels are not needed since voting on abandoned changes is not
+      // allowed.
+      labels = new TreeMap<>(labelTypes.nameComparator());
+      for (String name : labelNames) {
+        labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
+      }
+    }
+
+    if (detailed) {
+      labels.entrySet().stream()
+          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
     }
 
     for (Account.Id accountId : allUsers) {
-      Map<String, ApprovalInfo> byLabel =
-          Maps.newHashMapWithExpectedSize(labels.size());
-
+      Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
+      Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
+        ChangeControl ctl = baseCtrl.forUser(userFactory.create(accountId));
+        pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountId, 0, null, null);
+          ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
           byLabel.put(entry.getKey(), ai);
           addApproval(entry.getValue().label(), ai);
         }
@@ -778,8 +892,12 @@
         ApprovalInfo info = byLabel.get(type.getName());
         if (info != null) {
           info.value = Integer.valueOf(val);
+          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
           info.date = psa.getGranted();
           info.tag = psa.getTag();
+          if (psa.isPostSubmit()) {
+            info.postSubmit = true;
+          }
         }
         if (!standard) {
           continue;
@@ -791,17 +909,26 @@
     return labels;
   }
 
-  private ApprovalInfo approvalInfo(Account.Id id, Integer value, String tag,
+  private ApprovalInfo approvalInfo(
+      Account.Id id,
+      Integer value,
+      VotingRangeInfo permittedVotingRange,
+      String tag,
       Timestamp date) {
-    ApprovalInfo ai = getApprovalInfo(id, value, tag, date);
+    ApprovalInfo ai = getApprovalInfo(id, value, permittedVotingRange, tag, date);
     accountLoader.put(ai);
     return ai;
   }
 
   public static ApprovalInfo getApprovalInfo(
-      Account.Id id, Integer value, String tag, Timestamp date) {
+      Account.Id id,
+      Integer value,
+      VotingRangeInfo permittedVotingRange,
+      String tag,
+      Timestamp date) {
     ApprovalInfo ai = new ApprovalInfo(id.get());
     ai.value = value;
+    ai.permittedVotingRange = permittedVotingRange;
     ai.date = date;
     ai.tag = tag;
     return ai;
@@ -824,10 +951,12 @@
 
   private Map<String, Collection<String>> permittedLabels(ChangeControl ctl, ChangeData cd)
       throws OrmException {
-    if (ctl == null) {
+    if (ctl == null || !ctl.getUser().isIdentifiedUser()) {
       return null;
     }
 
+    Map<String, Short> labels = null;
+    boolean isMerged = ctl.getChange().getStatus() == Change.Status.MERGED;
     LabelTypes labelTypes = ctl.getLabelTypes();
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
     for (SubmitRecord rec : submitRecords(cd)) {
@@ -836,21 +965,27 @@
       }
       for (SubmitRecord.Label r : rec.labels) {
         LabelType type = labelTypes.byLabel(r.label);
-        if (type == null) {
+        if (type == null || (isMerged && !type.allowPostSubmit())) {
           continue;
         }
         PermissionRange range = ctl.getRange(Permission.forLabel(r.label));
         for (LabelValue v : type.getValues()) {
-          if (range.contains(v.getValue())) {
+          boolean ok = range.contains(v.getValue());
+          if (isMerged) {
+            if (labels == null) {
+              labels = currentLabels(ctl);
+            }
+            short prev = labels.getOrDefault(type.getName(), (short) 0);
+            ok &= v.getValue() >= prev;
+          }
+          if (ok) {
             permitted.put(r.label, v.formatValue());
           }
         }
       }
     }
-    List<String> toClear =
-      Lists.newArrayListWithCapacity(permitted.keySet().size());
-    for (Map.Entry<String, Collection<String>> e
-        : permitted.asMap().entrySet()) {
+    List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
+    for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
       if (isOnlyZero(e.getValue())) {
         toClear.add(e.getKey());
       }
@@ -861,16 +996,24 @@
     return permitted.asMap();
   }
 
-  private Collection<ChangeMessageInfo> messages(ChangeControl ctl, ChangeData cd,
-      Map<PatchSet.Id, PatchSet> map)
-      throws OrmException {
+  private Map<String, Short> currentLabels(ChangeControl ctl) throws OrmException {
+    Map<String, Short> result = new HashMap<>();
+    for (PatchSetApproval psa :
+        approvalsUtil.byPatchSetUser(
+            db.get(), ctl, ctl.getChange().currentPatchSetId(), ctl.getUser().getAccountId())) {
+      result.put(psa.getLabel(), psa.getValue());
+    }
+    return result;
+  }
+
+  private Collection<ChangeMessageInfo> messages(
+      ChangeControl ctl, ChangeData cd, Map<PatchSet.Id, PatchSet> map) throws OrmException {
     List<ChangeMessage> messages = cmUtil.byChange(db.get(), cd.notes());
     if (messages.isEmpty()) {
       return Collections.emptyList();
     }
 
-    List<ChangeMessageInfo> result =
-        Lists.newArrayListWithCapacity(messages.size());
+    List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
     for (ChangeMessage message : messages) {
       PatchSet.Id patchNum = message.getPatchSetId();
       PatchSet ps = patchNum != null ? map.get(patchNum) : null;
@@ -888,8 +1031,18 @@
     return result;
   }
 
-  private Collection<AccountInfo> removableReviewers(ChangeControl ctl,
-      Collection<LabelInfo> labels) {
+  private Collection<AccountInfo> removableReviewers(ChangeControl ctl, ChangeInfo out) {
+    // Although this is called removableReviewers, this method also determines
+    // which CCs are removable.
+    //
+    // For reviewers, we need to look at each approval, because the reviewer
+    // should only be considered removable if *all* of their approvals can be
+    // removed. First, add all reviewers with *any* removable approval to the
+    // "removable" set. Along the way, if we encounter a non-removable approval,
+    // add the reviewer to the "fixed" set. Before we return, remove all members
+    // of "fixed" from "removable", because not all of their approvals can be
+    // removed.
+    Collection<LabelInfo> labels = out.labels.values();
     Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
     Set<Account.Id> removable = Sets.newHashSetWithExpectedSize(labels.size());
     for (LabelInfo label : labels) {
@@ -905,6 +1058,24 @@
         }
       }
     }
+
+    // CCs are simpler than reviewers. They are removable if the ChangeControl
+    // would permit a non-negative approval by that account to be removed, in
+    // which case add them to removable. We don't need to add unremovable CCs to
+    // "fixed" because we only visit each CC once here.
+    Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
+    if (ccs != null) {
+      for (AccountInfo ai : ccs) {
+        Account.Id id = new Account.Id(ai._accountId);
+        if (ctl.canRemoveReviewer(id, 0)) {
+          removable.add(id);
+        }
+      }
+    }
+
+    // Subtract any reviewers with non-removable approvals from the "removable"
+    // set. This also subtracts any CCs that for some reason also hold
+    // unremovable approvals.
     removable.removeAll(fixed);
 
     List<AccountInfo> result = Lists.newArrayListWithCapacity(removable.size());
@@ -914,37 +1085,38 @@
     return result;
   }
 
-  private Collection<AccountInfo> toAccountInfo(
-      Collection<Account.Id> accounts) {
-    return FluentIterable.from(accounts)
-        .transform(new Function<Account.Id, AccountInfo>() {
-          @Override
-          public AccountInfo apply(Account.Id id) {
-            return accountLoader.get(id);
-          }
-        })
-        .toSortedList(AccountInfoComparator.ORDER_NULLS_FIRST);
+  private Collection<AccountInfo> toAccountInfo(Collection<Account.Id> accounts) {
+    return accounts.stream()
+        .map(accountLoader::get)
+        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
+        .collect(toList());
   }
 
-  private Map<String, RevisionInfo> revisions(ChangeControl ctl, ChangeData cd,
-      Map<PatchSet.Id, PatchSet> map) throws PatchListNotAvailableException,
-      GpgException, OrmException, IOException {
+  @Nullable
+  private Repository openRepoIfNecessary(ChangeControl ctl) throws IOException {
+    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
+      return repoManager.openRepository(ctl.getProject().getNameKey());
+    }
+    return null;
+  }
+
+  private Map<String, RevisionInfo> revisions(
+      ChangeControl ctl, ChangeData cd, Map<PatchSet.Id, PatchSet> map, ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo =
-        repoManager.openRepository(ctl.getProject().getNameKey())) {
+    try (Repository repo = openRepoIfNecessary(ctl)) {
       for (PatchSet in : map.values()) {
-        if ((has(ALL_REVISIONS)
-            || in.getId().equals(ctl.getChange().currentPatchSetId()))
+        if ((has(ALL_REVISIONS) || in.getId().equals(ctl.getChange().currentPatchSetId()))
             && ctl.isPatchVisible(in, db.get())) {
-          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, false));
+          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, false, changeInfo));
         }
       }
       return res;
     }
   }
 
-  private Map<PatchSet.Id, PatchSet> loadPatchSets(ChangeData cd,
-      Optional<PatchSet.Id> limitToPsId) throws OrmException {
+  private Map<PatchSet.Id, PatchSet> loadPatchSets(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+      throws OrmException {
     Collection<PatchSet> src;
     if (has(ALL_REVISIONS) || has(MESSAGES)) {
       src = cd.patchSets();
@@ -958,8 +1130,7 @@
       } else {
         ps = cd.currentPatchSet();
         if (ps == null) {
-          throw new OrmException(
-              "missing current patch set for change " + cd.getId());
+          throw new OrmException("missing current patch set for change " + cd.getId());
         }
       }
       src = Collections.singletonList(ps);
@@ -972,22 +1143,24 @@
   }
 
   public RevisionInfo getRevisionInfo(ChangeControl ctl, PatchSet in)
-      throws PatchListNotAvailableException, GpgException, OrmException,
-      IOException {
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    try (Repository repo =
-        repoManager.openRepository(ctl.getProject().getNameKey())) {
-      RevisionInfo rev = toRevisionInfo(
-          ctl, changeDataFactory.create(db.get(), ctl), in, repo, true);
+    try (Repository repo = openRepoIfNecessary(ctl)) {
+      RevisionInfo rev =
+          toRevisionInfo(ctl, changeDataFactory.create(db.get(), ctl), in, repo, true, null);
       accountLoader.fill();
       return rev;
     }
   }
 
-  private RevisionInfo toRevisionInfo(ChangeControl ctl, ChangeData cd,
-      PatchSet in, Repository repo, boolean fillCommit)
-      throws PatchListNotAvailableException, GpgException, OrmException,
-      IOException {
+  private RevisionInfo toRevisionInfo(
+      ChangeControl ctl,
+      ChangeData cd,
+      PatchSet in,
+      @Nullable Repository repo,
+      boolean fillCommit,
+      @Nullable ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     Change c = ctl.getChange();
     RevisionInfo out = new RevisionInfo();
     out.isCurrent = in.getId().equals(c.currentPatchSetId());
@@ -998,9 +1171,9 @@
     out.draft = in.isDraft() ? true : null;
     out.fetch = makeFetchMap(ctl, in);
     out.kind = changeKindCache.getChangeKind(repo, cd, in);
+    out.description = in.getDescription();
 
-    boolean setCommit = has(ALL_COMMITS)
-        || (out.isCurrent && has(CURRENT_COMMIT));
+    boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
     boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
     if (setCommit || addFooters) {
       Project.NameKey project = c.getProject();
@@ -1012,9 +1185,16 @@
           out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS), fillCommit);
         }
         if (addFooters) {
-          out.commitWithFooters = mergeUtilFactory
-              .create(projectCache.get(project))
-              .createCherryPickCommitMessage(commit, ctl, in.getId());
+          Ref ref = repo.exactRef(ctl.getChange().getDest().get());
+          RevCommit mergeTip = null;
+          if (ref != null) {
+            mergeTip = rw.parseCommit(ref.getObjectId());
+            rw.parseBody(mergeTip);
+          }
+          out.commitWithFooters =
+              mergeUtilFactory
+                  .create(projectCache.get(project))
+                  .createCommitMessageOnSubmit(commit, mergeTip, ctl, in.getId());
         }
       }
     }
@@ -1022,21 +1202,22 @@
     if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
       out.files = fileInfoJson.toFileInfoMap(c, in);
       out.files.remove(Patch.COMMIT_MSG);
+      out.files.remove(Patch.MERGE_LIST);
     }
 
     if ((out.isCurrent || (out.draft != null && out.draft))
         && has(CURRENT_ACTIONS)
         && userProvider.get().isIdentifiedUser()) {
 
-      actionJson.addRevisionActions(out,
-          new RevisionResource(changeResourceFactory.create(ctl), in));
+      actionJson.addRevisionActions(
+          changeInfo, out, new RevisionResource(changeResourceFactory.create(ctl), in));
     }
 
     if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
       if (in.getPushCertificate() != null) {
-        out.pushCertificate = gpgApi.checkPushCertificate(
-            in.getPushCertificate(),
-            userFactory.create(in.getUploader()));
+        out.pushCertificate =
+            gpgApi.checkPushCertificate(
+                in.getPushCertificate(), userFactory.create(in.getUploader()));
       } else {
         out.pushCertificate = new PushCertificateInfo();
       }
@@ -1045,8 +1226,9 @@
     return out;
   }
 
-  CommitInfo toCommit(ChangeControl ctl, RevWalk rw, RevCommit commit,
-      boolean addLinks, boolean fillCommit) throws IOException {
+  CommitInfo toCommit(
+      ChangeControl ctl, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      throws IOException {
     Project.NameKey project = ctl.getProject().getNameKey();
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -1059,9 +1241,8 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      FluentIterable<WebLinkInfo> links =
-          webLinks.getPatchSetLinks(project, commit.name());
-      info.webLinks = links.isEmpty() ? null : links.toList();
+      List<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      info.webLinks = links.isEmpty() ? null : links;
     }
 
     for (RevCommit parent : commit.getParents()) {
@@ -1070,17 +1251,15 @@
       i.commit = parent.name();
       i.subject = parent.getShortMessage();
       if (addLinks) {
-        FluentIterable<WebLinkInfo> parentLinks =
-            webLinks.getParentLinks(project, parent.name());
-        i.webLinks = parentLinks.isEmpty() ? null : parentLinks.toList();
+        List<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
       }
       info.parents.add(i);
     }
     return info;
   }
 
-  private Map<String, FetchInfo> makeFetchMap(ChangeControl ctl, PatchSet in)
-      throws OrmException {
+  private Map<String, FetchInfo> makeFetchMap(ChangeControl ctl, PatchSet in) throws OrmException {
     Map<String, FetchInfo> r = new LinkedHashMap<>();
 
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
@@ -1091,8 +1270,7 @@
         continue;
       }
 
-      if (!scheme.isAuthSupported()
-          && !ctl.forUser(anonymous).isPatchVisible(in, db.get())) {
+      if (!scheme.isAuthSupported() && !ctl.forUser(anonymous).isPatchVisible(in, db.get())) {
         continue;
       }
 
@@ -1103,17 +1281,19 @@
       r.put(schemeName, fetchInfo);
 
       if (has(DOWNLOAD_COMMANDS)) {
-        populateFetchMap(scheme, downloadCommands, projectName, refName,
-            fetchInfo);
+        populateFetchMap(scheme, downloadCommands, projectName, refName, fetchInfo);
       }
     }
 
     return r;
   }
 
-  public static void populateFetchMap(DownloadScheme scheme,
-      DynamicMap<DownloadCommand> commands, String projectName,
-      String refName, FetchInfo fetchInfo) {
+  public static void populateFetchMap(
+      DownloadScheme scheme,
+      DynamicMap<DownloadCommand> commands,
+      String projectName,
+      String refName,
+      FetchInfo fetchInfo) {
     for (DynamicMap.Entry<DownloadCommand> e2 : commands) {
       String commandName = e2.getExportName();
       DownloadCommand command = e2.getProvider().get();
@@ -1124,8 +1304,7 @@
     }
   }
 
-  private static void addCommand(FetchInfo fetchInfo, String commandName,
-      String c) {
+  private static void addCommand(FetchInfo fetchInfo, String commandName, String c) {
     if (fetchInfo.commands == null) {
       fetchInfo.commands = new TreeMap<>();
     }
@@ -1133,10 +1312,9 @@
   }
 
   static void finish(ChangeInfo info) {
-    info.id = Joiner.on('~').join(
-        Url.encode(info.project),
-        Url.encode(info.branch),
-        Url.encode(info.changeId));
+    info.id =
+        Joiner.on('~')
+            .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
   }
 
   private static void addApproval(LabelInfo label, ApprovalInfo approval) {
@@ -1148,20 +1326,13 @@
 
   @AutoValue
   abstract static class LabelWithStatus {
-    private static final Function<LabelWithStatus, LabelInfo> TO_LABEL_INFO =
-        new Function<LabelWithStatus, LabelInfo>() {
-          @Override
-          public LabelInfo apply(LabelWithStatus in) {
-            return in.label();
-          }
-        };
-
-    private static LabelWithStatus create(LabelInfo label,
-        SubmitRecord.Label.Status status) {
+    private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
       return new AutoValue_ChangeJson_LabelWithStatus(label, status);
     }
 
     abstract LabelInfo label();
-    @Nullable abstract SubmitRecord.Label.Status status();
+
+    @Nullable
+    abstract SubmitRecord.Label.Status status();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
index 2302b70..aa47827 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -14,27 +14,27 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 /**
  * Cache of {@link ChangeKind} per commit.
- * <p>
- * This is immutable conditioned on the merge strategy (unless the JGit strategy
- * implementation changes, which might invalidate old entries).
+ *
+ * <p>This is immutable conditioned on the merge strategy (unless the JGit strategy implementation
+ * changes, which might invalidate old entries).
  */
 public interface ChangeKindCache {
-  ChangeKind getChangeKind(ProjectState project, Repository repo,
-      ObjectId prior, ObjectId next);
+  ChangeKind getChangeKind(
+      Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next);
 
   ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
 
-  ChangeKind getChangeKind(Repository repo, ChangeData cd, PatchSet patch);
+  ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 1d1b27b..c75a413 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -22,23 +22,32 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.Weigher;
 import com.google.common.collect.FluentIterable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.name.Named;
-
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -50,20 +59,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import java.io.Serializable;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Objects;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-
 public class ChangeKindCacheImpl implements ChangeKindCache {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeKindCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(ChangeKindCacheImpl.class);
 
   private static final String ID_CACHE = "change_kind";
 
@@ -83,46 +80,39 @@
   public static class NoCache implements ChangeKindCache {
     private final boolean useRecursiveMerge;
     private final ChangeData.Factory changeDataFactory;
-    private final ProjectCache projectCache;
     private final GitRepositoryManager repoManager;
 
-
     @Inject
     NoCache(
         @GerritServerConfig Config serverConfig,
         ChangeData.Factory changeDataFactory,
-        ProjectCache projectCache,
         GitRepositoryManager repoManager) {
       this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig);
       this.changeDataFactory = changeDataFactory;
-      this.projectCache = projectCache;
       this.repoManager = repoManager;
     }
 
     @Override
-    public ChangeKind getChangeKind(ProjectState project, Repository repo,
-        ObjectId prior, ObjectId next) {
+    public ChangeKind getChangeKind(
+        Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next) {
       try {
         Key key = new Key(prior, next, useRecursiveMerge);
-        return new Loader(key, repo).call();
+        return new Loader(key, repoManager, project, repo).call();
       } catch (IOException e) {
-        log.warn("Cannot check trivial rebase of new patch set " + next.name()
-            + " in " + project.getProject().getName(), e);
+        log.warn(
+            "Cannot check trivial rebase of new patch set " + next.name() + " in " + project, e);
         return ChangeKind.REWORK;
       }
     }
 
     @Override
-    public ChangeKind getChangeKind(ReviewDb db, Change change,
-        PatchSet patch) {
-      return getChangeKindInternal(this, db, change, patch, changeDataFactory,
-          projectCache, repoManager);
+    public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch) {
+      return getChangeKindInternal(this, db, change, patch, changeDataFactory, repoManager);
     }
 
     @Override
-    public ChangeKind getChangeKind(Repository repo, ChangeData cd,
-        PatchSet patch) {
-      return getChangeKindInternal(this, repo, cd, patch, projectCache);
+    public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch) {
+      return getChangeKindInternal(this, repo, cd, patch);
     }
   }
 
@@ -135,8 +125,7 @@
 
     private Key(ObjectId prior, ObjectId next, boolean useRecursiveMerge) {
       checkNotNull(next, "next");
-      String strategyName = MergeUtil.mergeStrategyName(
-          true, useRecursiveMerge);
+      String strategyName = MergeUtil.mergeStrategyName(true, useRecursiveMerge);
       this.prior = prior.copy();
       this.next = next.copy();
       this.strategyName = strategyName;
@@ -191,11 +180,19 @@
 
   private static class Loader implements Callable<ChangeKind> {
     private final Key key;
-    private final Repository repo;
+    private final GitRepositoryManager repoManager;
+    private final Project.NameKey projectName;
+    private final Repository alreadyOpenRepo;
 
-    private Loader(Key key, Repository repo) {
+    private Loader(
+        Key key,
+        GitRepositoryManager repoManager,
+        Project.NameKey projectName,
+        @Nullable Repository alreadyOpenRepo) {
       this.key = key;
-      this.repo = repo;
+      this.repoManager = repoManager;
+      this.projectName = projectName;
+      this.alreadyOpenRepo = alreadyOpenRepo;
     }
 
     @Override
@@ -204,6 +201,12 @@
         return ChangeKind.NO_CODE_CHANGE;
       }
 
+      Repository repo = alreadyOpenRepo;
+      boolean close = false;
+      if (repo == null) {
+        repo = repoManager.openRepository(projectName);
+        close = true;
+      }
       try (RevWalk walk = new RevWalk(repo)) {
         RevCommit prior = walk.parseCommit(key.prior);
         walk.parseBody(prior);
@@ -221,9 +224,15 @@
           return ChangeKind.NO_CHANGE;
         }
 
-        if ((prior.getParentCount() != 1 || next.getParentCount() != 1)
-            && (!onlyFirstParentChanged(prior, next)
-                || prior.getParentCount() == 0)) {
+        if (prior.getParentCount() == 0 || next.getParentCount() == 0) {
+          // At this point we have considered all the kinds that could be applicable to root
+          // commits; the remainder of the checks in this method all assume that both commits have
+          // at least one parent.
+          return ChangeKind.REWORK;
+        }
+
+        if ((prior.getParentCount() > 1 || next.getParentCount() > 1)
+            && !onlyFirstParentChanged(prior, next)) {
           // Trivial rebases done by machine only work well on 1 parent.
           return ChangeKind.REWORK;
         }
@@ -232,8 +241,7 @@
         // having the same tree as would exist when the prior commit is
         // cherry-picked onto the next commit's new first parent.
         try (ObjectInserter ins = new InMemoryInserter(repo)) {
-          ThreeWayMerger merger =
-              MergeUtil.newThreeWayMerger(repo, ins, key.strategyName);
+          ThreeWayMerger merger = MergeUtil.newThreeWayMerger(repo, ins, key.strategyName);
           merger.setBase(prior.getParent(0));
           if (merger.merge(next.getParent(0), prior)
               && merger.getResultTreeId().equals(next.getTree())) {
@@ -247,6 +255,10 @@
           // it was a rework.
         }
         return ChangeKind.REWORK;
+      } finally {
+        if (close) {
+          repo.close();
+        }
       }
     }
 
@@ -296,7 +308,9 @@
   public static class ChangeKindWeigher implements Weigher<Key, ChangeKind> {
     @Override
     public int weigh(Key key, ChangeKind changeKind) {
-      return 16 + 2 * 36 + 2 * key.strategyName.length() // Size of Key, 64 bit JVM
+      return 16
+          + 2 * 36
+          + 2 * key.strategyName.length() // Size of Key, 64 bit JVM
           + 2 * changeKind.name().length(); // Size of ChangeKind, 64 bit JVM
     }
   }
@@ -304,7 +318,6 @@
   private final Cache<Key, ChangeKind> cache;
   private final boolean useRecursiveMerge;
   private final ChangeData.Factory changeDataFactory;
-  private final ProjectCache projectCache;
   private final GitRepositoryManager repoManager;
 
   @Inject
@@ -312,58 +325,47 @@
       @GerritServerConfig Config serverConfig,
       @Named(ID_CACHE) Cache<Key, ChangeKind> cache,
       ChangeData.Factory changeDataFactory,
-      ProjectCache projectCache,
       GitRepositoryManager repoManager) {
     this.cache = cache;
     this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig);
     this.changeDataFactory = changeDataFactory;
-    this.projectCache = projectCache;
     this.repoManager = repoManager;
   }
 
   @Override
-  public ChangeKind getChangeKind(ProjectState project, Repository repo,
-      ObjectId prior, ObjectId next) {
+  public ChangeKind getChangeKind(
+      Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next) {
     try {
       Key key = new Key(prior, next, useRecursiveMerge);
-      return cache.get(key, new Loader(key, repo));
+      return cache.get(key, new Loader(key, repoManager, project, repo));
     } catch (ExecutionException e) {
-      log.warn("Cannot check trivial rebase of new patch set " + next.name()
-          + " in " + project.getProject().getName(), e);
+      log.warn("Cannot check trivial rebase of new patch set " + next.name() + " in " + project, e);
       return ChangeKind.REWORK;
     }
   }
 
   @Override
   public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch) {
-    return getChangeKindInternal(this, db, change, patch, changeDataFactory,
-        projectCache, repoManager);
+    return getChangeKindInternal(this, db, change, patch, changeDataFactory, repoManager);
   }
 
   @Override
-  public ChangeKind getChangeKind(Repository repo, ChangeData cd,
-      PatchSet patch) {
-    return getChangeKindInternal(this, repo, cd, patch, projectCache);
+  public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch) {
+    return getChangeKindInternal(this, repo, cd, patch);
   }
 
   private static ChangeKind getChangeKindInternal(
-      ChangeKindCache cache,
-      Repository repo,
-      ChangeData change,
-      PatchSet patch,
-      ProjectCache projectCache) {
+      ChangeKindCache cache, @Nullable Repository repo, ChangeData change, PatchSet patch) {
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
     if (patch.getId().get() > 1) {
       try {
-        ProjectState projectState = projectCache.checkedGet(change.project());
         Collection<PatchSet> patchSetCollection = change.patchSets();
         PatchSet priorPs = patch;
         for (PatchSet ps : patchSetCollection) {
-          if (ps.getId().get() < patch.getId().get() &&
-              (ps.getId().get() > priorPs.getId().get() ||
-                  priorPs == patch)) {
+          if (ps.getId().get() < patch.getId().get()
+              && (ps.getId().get() > priorPs.getId().get() || priorPs == patch)) {
             // We only want the previous patch set, so walk until the last one
             priorPs = ps;
           }
@@ -375,14 +377,20 @@
         // and deletes the draft.
         if (priorPs != patch) {
           kind =
-              cache.getChangeKind(projectState, repo,
+              cache.getChangeKind(
+                  change.project(),
+                  repo,
                   ObjectId.fromString(priorPs.getRevision().get()),
                   ObjectId.fromString(patch.getRevision().get()));
         }
-      } catch (IOException | OrmException e) {
+      } catch (OrmException e) {
         // Do nothing; assume we have a complex change
-        log.warn("Unable to get change kind for patchSet " + patch.getPatchSetId() +
-            "of change " + change.getId(), e);
+        log.warn(
+            "Unable to get change kind for patchSet "
+                + patch.getPatchSetId()
+                + "of change "
+                + change.getId(),
+            e);
       }
     }
     return kind;
@@ -394,7 +402,6 @@
       Change change,
       PatchSet patch,
       ChangeData.Factory changeDataFactory,
-      ProjectCache projectCache,
       GitRepositoryManager repoManager) {
     // TODO - dborowitz: add NEW_CHANGE type for default.
     ChangeKind kind = ChangeKind.REWORK;
@@ -402,13 +409,15 @@
     // the repository.
     if (patch.getId().get() > 1) {
       try (Repository repo = repoManager.openRepository(change.getProject())) {
-        kind = getChangeKindInternal(cache, repo,
-            changeDataFactory.create(db, change), patch,
-            projectCache);
+        kind = getChangeKindInternal(cache, repo, changeDataFactory.create(db, change), patch);
       } catch (IOException e) {
         // Do nothing; assume we have a complex change
-        log.warn("Unable to get change kind for patchSet " + patch.getPatchSetId() +
-            "of change " + change.getChangeId(), e);
+        log.warn(
+            "Unable to get change kind for patchSet "
+                + patch.getPatchSetId()
+                + "of change "
+                + change.getChangeId(),
+            e);
       }
     }
     return kind;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
index 8236d3d..92b4150 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -23,7 +23,8 @@
   }
 
   public String revertChangeDefaultMessage;
-  public String reviewerNotFound;
+  public String reviewerNotFoundUser;
+  public String reviewerNotFoundUserOrGroup;
 
   public String groupIsNotAllowed;
   public String groupHasTooManyMembers;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
index 05d12b3..8422bba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
@@ -35,15 +35,14 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
 import org.eclipse.jgit.lib.ObjectId;
 
 public class ChangeResource implements RestResource, HasETag {
   /**
    * JSON format version number for ETag computations.
-   * <p>
-   * Should be bumped on any JSON format change (new fields, etc.) so that
-   * otherwise unmodified changes get new ETags.
+   *
+   * <p>Should be bumped on any JSON format change (new fields, etc.) so that otherwise unmodified
+   * changes get new ETags.
    */
   public static final int JSON_FORMAT_VERSION = 1;
 
@@ -58,8 +57,7 @@
   private final ChangeControl control;
 
   @AssistedInject
-  ChangeResource(StarredChangesUtil starredChangesUtil,
-      @Assisted ChangeControl control) {
+  ChangeResource(StarredChangesUtil starredChangesUtil, @Assisted ChangeControl control) {
     this.starredChangesUtil = starredChangesUtil;
     this.control = control;
   }
@@ -92,13 +90,13 @@
   // unrelated to the UI.
   public void prepareETag(Hasher h, CurrentUser user) {
     h.putInt(JSON_FORMAT_VERSION)
-      .putLong(getChange().getLastUpdatedOn().getTime())
-      .putInt(getChange().getRowVersion())
-      .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
+        .putLong(getChange().getLastUpdatedOn().getTime())
+        .putInt(getChange().getRowVersion())
+        .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
 
     if (user.isIdentifiedUser()) {
       for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
-        h.putBytes(uuid.get().getBytes());
+        h.putBytes(uuid.get().getBytes(UTF_8));
       }
     }
 
@@ -119,13 +117,12 @@
   }
 
   @Override
+  @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
   public String getETag() {
     CurrentUser user = control.getUser();
     Hasher h = Hashing.md5().newHasher();
     if (user.isIdentifiedUser()) {
-      h.putString(
-          starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(),
-          UTF_8);
+      h.putString(starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(), UTF_8);
     }
     prepareETag(h, user);
     return h.hash().toString();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
index 7069e6d..71a3db7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.change;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import java.util.Optional;
 
 @AutoValue
 public abstract class ChangeTriplet {
@@ -28,32 +28,29 @@
   }
 
   private static String format(Branch.NameKey branch, Change.Key change) {
-    return branch.getParentKey().get()
-        + "~" + branch.getShortName()
-        + "~" + change.get();
+    return branch.getParentKey().get() + "~" + branch.getShortName() + "~" + change.get();
   }
 
   /**
    * Parse a triplet out of a string.
    *
    * @param triplet string of the form "project~branch~id".
-   * @return the triplet if the input string has the proper format, or absent if
-   *     not.
+   * @return the triplet if the input string has the proper format, or absent if not.
    */
   public static Optional<ChangeTriplet> parse(String triplet) {
     int t2 = triplet.lastIndexOf('~');
     int t1 = triplet.lastIndexOf('~', t2 - 1);
     if (t1 < 0 || t2 < 0) {
-      return Optional.absent();
+      return Optional.empty();
     }
 
     String project = Url.decode(triplet.substring(0, t1));
     String branch = Url.decode(triplet.substring(t1 + 1, t2));
     String changeId = Url.decode(triplet.substring(t2 + 1));
 
-    ChangeTriplet result = new AutoValue_ChangeTriplet(
-        new Branch.NameKey(new Project.NameKey(project), branch),
-        new Change.Key(changeId));
+    ChangeTriplet result =
+        new AutoValue_ChangeTriplet(
+            new Branch.NameKey(new Project.NameKey(project), branch), new Change.Key(changeId));
     return Optional.of(result);
   }
 
@@ -62,6 +59,7 @@
   }
 
   public abstract Branch.NameKey branch();
+
   public abstract Change.Key id();
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
index b89691a..eeb1ab3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
@@ -32,13 +32,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.List;
 
 @Singleton
-public class ChangesCollection implements
-    RestCollection<TopLevelResource, ChangeResource>,
-    AcceptsPost<TopLevelResource> {
+public class ChangesCollection
+    implements RestCollection<TopLevelResource, ChangeResource>, AcceptsPost<TopLevelResource> {
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final Provider<QueryChanges> queryFactory;
@@ -92,8 +90,7 @@
     return changeResourceFactory.create(ctl);
   }
 
-  public ChangeResource parse(Change.Id id)
-      throws ResourceNotFoundException, OrmException {
+  public ChangeResource parse(Change.Id id) throws ResourceNotFoundException, OrmException {
     List<ChangeControl> ctls = changeFinder.find(id, user.get());
     if (ctls.isEmpty()) {
       throw new ResourceNotFoundException(toIdString(id));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
index f4869be..3b67930 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
@@ -26,10 +26,8 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
-import java.util.EnumSet;
-
-public class Check implements RestReadView<ChangeResource>,
-    RestModifyView<ChangeResource, FixInput> {
+public class Check
+    implements RestReadView<ChangeResource>, RestModifyView<ChangeResource, FixInput> {
   private final ChangeJson.Factory jsonFactory;
 
   @Inject
@@ -38,8 +36,7 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc)
-      throws RestApiException, OrmException {
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException, OrmException {
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
@@ -56,6 +53,6 @@
   }
 
   private ChangeJson newChangeJson() {
-    return jsonFactory.create(EnumSet.of(ListChangesOption.CHECK));
+    return jsonFactory.create(ListChangesOption.CHECK);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
index 1a063f4..e5a4d0f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
@@ -27,30 +27,28 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 
 @Singleton
-public class CherryPick implements RestModifyView<RevisionResource, CherryPickInput>,
-    UiAction<RevisionResource> {
+public class CherryPick
+    implements RestModifyView<RevisionResource, CherryPickInput>, UiAction<RevisionResource> {
   private final Provider<ReviewDb> dbProvider;
   private final CherryPickChange cherryPickChange;
   private final ChangeJson.Factory json;
 
   @Inject
-  CherryPick(Provider<ReviewDb> dbProvider,
-      CherryPickChange cherryPickChange,
-      ChangeJson.Factory json) {
+  CherryPick(
+      Provider<ReviewDb> dbProvider, CherryPickChange cherryPickChange, ChangeJson.Factory json) {
     this.dbProvider = dbProvider;
     this.cherryPickChange = cherryPickChange;
     this.json = json;
@@ -60,6 +58,7 @@
   public ChangeInfo apply(RevisionResource revision, CherryPickInput input)
       throws OrmException, IOException, UpdateException, RestApiException {
     final ChangeControl control = revision.getControl();
+    int parent = input.parent == null ? 1 : input.parent;
 
     if (input.message == null || input.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
@@ -82,18 +81,23 @@
     String refName = RefNames.fullName(input.destination);
     RefControl refControl = projectControl.controlForRef(refName);
     if (!refControl.canUpload()) {
-      throw new AuthException("Not allowed to cherry pick "
-          + revision.getChange().getId().toString() + " to "
-          + input.destination);
+      throw new AuthException(
+          "Not allowed to cherry pick "
+              + revision.getChange().getId().toString()
+              + " to "
+              + input.destination);
     }
 
     try {
       Change.Id cherryPickedChangeId =
-          cherryPickChange.cherryPick(revision.getChange(),
-              revision.getPatchSet(), input.message, refName,
-              refControl);
-      return json.create(ChangeJson.NO_OPTIONS).format(revision.getProject(),
-          cherryPickedChangeId);
+          cherryPickChange.cherryPick(
+              revision.getChange(),
+              revision.getPatchSet(),
+              input.message,
+              refName,
+              refControl,
+              parent);
+      return json.noOptions().format(revision.getProject(), cherryPickedChangeId);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException | NoSuchChangeException e) {
@@ -104,9 +108,8 @@
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
     return new UiAction.Description()
-      .setLabel("Cherry Pick")
-      .setTitle("Cherry pick change to a different branch")
-      .setVisible(resource.getControl().getProjectControl().canUpload()
-          && resource.isCurrent());
+        .setLabel("Cherry Pick")
+        .setTitle("Cherry pick change to a different branch")
+        .setVisible(resource.getControl().getProjectControl().canUpload() && resource.isCurrent());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index db18ba2..b2455f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -32,15 +33,12 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -49,26 +47,29 @@
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.TimeZone;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.util.ChangeIdUtil;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.TimeZone;
-
 @Singleton
 public class CherryPickChange {
 
@@ -86,7 +87,8 @@
   private final BatchUpdate.Factory batchUpdateFactory;
 
   @Inject
-  CherryPickChange(Provider<ReviewDb> db,
+  CherryPickChange(
+      Provider<ReviewDb> db,
       Sequences seq,
       Provider<InternalChangeQuery> queryProvider,
       @GerritPersonIdent PersonIdent myIdent,
@@ -112,13 +114,16 @@
     this.batchUpdateFactory = batchUpdateFactory;
   }
 
-  public Change.Id cherryPick(Change change, PatchSet patch,
-      final String message, final String ref,
-      final RefControl refControl) throws NoSuchChangeException,
-      OrmException, MissingObjectException,
-      IncorrectObjectTypeException, IOException,
-      InvalidChangeOperationException, IntegrationException, UpdateException,
-      RestApiException {
+  public Change.Id cherryPick(
+      Change change,
+      PatchSet patch,
+      final String message,
+      final String ref,
+      final RefControl refControl,
+      int parent)
+      throws NoSuchChangeException, OrmException, MissingObjectException,
+          IncorrectObjectTypeException, IOException, InvalidChangeOperationException,
+          IntegrationException, UpdateException, RestApiException {
 
     if (Strings.isNullOrEmpty(ref)) {
       throw new InvalidChangeOperationException(
@@ -133,12 +138,12 @@
         // created later on, to ensure the cherry-picked commit is flushed
         // before patch sets are updated.
         ObjectInserter oi = git.newObjectInserter();
-        CodeReviewRevWalk revWalk =
-          CodeReviewCommit.newRevWalk(oi.newReader())) {
+        ObjectReader reader = oi.newReader();
+        CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
       Ref destRef = git.getRefDatabase().exactRef(ref);
       if (destRef == null) {
-        throw new InvalidChangeOperationException(String.format(
-            "Branch %s does not exist.", destinationBranch));
+        throw new InvalidChangeOperationException(
+            String.format("Branch %s does not exist.", destinationBranch));
       }
 
       CodeReviewCommit mergeTip = revWalk.parseCommit(destRef.getObjectId());
@@ -146,27 +151,45 @@
       CodeReviewCommit commitToCherryPick =
           revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
 
+      if (parent <= 0 || parent > commitToCherryPick.getParentCount()) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "Cherry Pick: Parent %s does not exist. Please specify a parent in"
+                    + " range [1, %s].",
+                parent, commitToCherryPick.getParentCount()));
+      }
+
       Timestamp now = TimeUtil.nowTs();
-      PersonIdent committerIdent =
-          identifiedUser.newCommitterIdent(now, serverTimeZone);
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(now, serverTimeZone);
 
       final ObjectId computedChangeId =
-          ChangeIdUtil
-              .computeChangeId(commitToCherryPick.getTree(), mergeTip,
-                  commitToCherryPick.getAuthorIdent(), committerIdent, message);
-      String commitMessage =
-          ChangeIdUtil.insertId(message, computedChangeId).trim() + '\n';
+          ChangeIdUtil.computeChangeId(
+              commitToCherryPick.getTree(),
+              mergeTip,
+              commitToCherryPick.getAuthorIdent(),
+              committerIdent,
+              message);
+      String commitMessage = ChangeIdUtil.insertId(message, computedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
       try {
         ProjectState projectState = refControl.getProjectControl().getProjectState();
         cherryPickCommit =
-            mergeUtilFactory.create(projectState).createCherryPickFromCommit(git, oi, mergeTip,
-                commitToCherryPick, committerIdent, commitMessage, revWalk);
+            mergeUtilFactory
+                .create(projectState)
+                .createCherryPickFromCommit(
+                    git,
+                    oi,
+                    mergeTip,
+                    commitToCherryPick,
+                    committerIdent,
+                    commitMessage,
+                    revWalk,
+                    parent - 1,
+                    false);
 
         Change.Key changeKey;
-        final List<String> idList = cherryPickCommit.getFooterLines(
-            FooterConstants.CHANGE_ID);
+        final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
         if (!idList.isEmpty()) {
           final String idStr = idList.get(idList.size() - 1).trim();
           changeKey = new Change.Key(idStr);
@@ -174,27 +197,27 @@
           changeKey = new Change.Key("I" + computedChangeId.name());
         }
 
-        Branch.NameKey newDest =
-            new Branch.NameKey(change.getProject(), destRef.getName());
-        List<ChangeData> destChanges = queryProvider.get()
-            .setLimit(2)
-            .byBranchKey(newDest, changeKey);
+        Branch.NameKey newDest = new Branch.NameKey(change.getProject(), destRef.getName());
+        List<ChangeData> destChanges =
+            queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
         if (destChanges.size() > 1) {
-          throw new InvalidChangeOperationException("Several changes with key "
-              + changeKey + " reside on the same branch. "
-              + "Cannot create a new patch set.");
+          throw new InvalidChangeOperationException(
+              "Several changes with key "
+                  + changeKey
+                  + " reside on the same branch. "
+                  + "Cannot create a new patch set.");
         }
-        try (BatchUpdate bu = batchUpdateFactory.create(
-            db.get(), change.getDest().getParentKey(), identifiedUser, now)) {
+        try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db.get(), change.getDest().getParentKey(), identifiedUser, now)) {
           bu.setRepository(git, revWalk, oi);
           Change.Id result;
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
             // will be added as a new patch set.
-            ChangeControl destCtl = refControl.getProjectControl()
-                .controlFor(destChanges.get(0).notes());
-            result = insertPatchSet(
-                bu, git, destCtl, cherryPickCommit);
+            ChangeControl destCtl =
+                refControl.getProjectControl().controlFor(destChanges.get(0).notes());
+            result = insertPatchSet(bu, git, destCtl, cherryPickCommit);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
@@ -203,13 +226,13 @@
               newTopic = change.getTopic() + "-" + newDest.getShortName();
             }
             result =
-                createNewChange(bu, cherryPickCommit,
-                    refControl.getRefName(), newTopic, change.getDest());
+                createNewChange(
+                    bu, cherryPickCommit, refControl.getRefName(), newTopic, change.getDest());
 
-            bu.addOp(change.getId(),
+            bu.addOp(
+                change.getId(),
                 new AddMessageToSourceChangeOp(
-                    changeMessagesUtil, patch.getId(), destinationBranch,
-                    cherryPickCommit));
+                    changeMessagesUtil, patch.getId(), destinationBranch, cherryPickCommit));
           }
           bu.execute();
           return result;
@@ -222,47 +245,51 @@
     }
   }
 
-  private Change.Id insertPatchSet(BatchUpdate bu, Repository git,
-      ChangeControl destCtl, CodeReviewCommit cherryPickCommit)
+  private Change.Id insertPatchSet(
+      BatchUpdate bu, Repository git, ChangeControl destCtl, CodeReviewCommit cherryPickCommit)
       throws IOException, OrmException {
     Change destChange = destCtl.getChange();
-    PatchSet.Id psId =
-        ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
-    PatchSetInserter inserter = patchSetInserterFactory
-        .create(destCtl, psId, cherryPickCommit);
+    PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+    PatchSetInserter inserter = patchSetInserterFactory.create(destCtl, psId, cherryPickCommit);
     PatchSet.Id newPatchSetId = inserter.getPatchSetId();
     PatchSet current = psUtil.current(db.get(), destCtl.getNotes());
 
-    bu.addOp(destChange.getId(), inserter
-        .setMessage("Uploaded patch set " + newPatchSetId.get() + ".")
-        .setDraft(current.isDraft())
-        .setSendMail(false));
+    bu.addOp(
+        destChange.getId(),
+        inserter
+            .setMessage("Uploaded patch set " + newPatchSetId.get() + ".")
+            .setDraft(current.isDraft())
+            .setNotify(NotifyHandling.NONE));
     return destChange.getId();
   }
 
-  private Change.Id createNewChange(BatchUpdate bu,
-      CodeReviewCommit cherryPickCommit, String refName, String topic,
-      Branch.NameKey sourceBranch) throws OrmException {
+  private Change.Id createNewChange(
+      BatchUpdate bu,
+      CodeReviewCommit cherryPickCommit,
+      String refName,
+      String topic,
+      Branch.NameKey sourceBranch)
+      throws OrmException {
     Change.Id changeId = new Change.Id(seq.nextChangeId());
-    ChangeInserter ins = changeInserterFactory.create(
-          changeId, cherryPickCommit, refName)
-        .setValidatePolicy(CommitValidators.Policy.GERRIT)
-        .setTopic(topic);
+    ChangeInserter ins =
+        changeInserterFactory
+            .create(changeId, cherryPickCommit, refName)
+            .setValidatePolicy(CommitValidators.Policy.GERRIT)
+            .setTopic(topic);
 
-    ins.setMessage(
-        messageForDestinationChange(ins.getPatchSetId(), sourceBranch));
+    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch));
     bu.insertChange(ins);
     return changeId;
   }
 
-  private static class AddMessageToSourceChangeOp extends BatchUpdate.Op {
+  private static class AddMessageToSourceChangeOp implements BatchUpdateOp {
     private final ChangeMessagesUtil cmUtil;
     private final PatchSet.Id psId;
     private final String destBranch;
     private final ObjectId cherryPickCommit;
 
-    private AddMessageToSourceChangeOp(ChangeMessagesUtil cmUtil,
-        PatchSet.Id psId, String destBranch, ObjectId cherryPickCommit) {
+    private AddMessageToSourceChangeOp(
+        ChangeMessagesUtil cmUtil, PatchSet.Id psId, String destBranch, ObjectId cherryPickCommit) {
       this.cmUtil = cmUtil;
       this.psId = psId;
       this.destBranch = destBranch;
@@ -271,32 +298,33 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws OrmException {
-      ChangeMessage changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(
-              ctx.getChange().getId(), ChangeUtil.messageUUID(ctx.getDb())),
-              ctx.getAccountId(), ctx.getWhen(), psId);
-      StringBuilder sb = new StringBuilder("Patch Set ")
-          .append(psId.get())
-          .append(": Cherry Picked")
-          .append("\n\n")
-          .append("This patchset was cherry picked to branch ")
-          .append(destBranch)
-          .append(" as commit ")
-          .append(cherryPickCommit.name());
-      changeMessage.setMessage(sb.toString());
-
+      StringBuilder sb =
+          new StringBuilder("Patch Set ")
+              .append(psId.get())
+              .append(": Cherry Picked")
+              .append("\n\n")
+              .append("This patchset was cherry picked to branch ")
+              .append(destBranch)
+              .append(" as commit ")
+              .append(cherryPickCommit.name());
+      ChangeMessage changeMessage =
+          ChangeMessagesUtil.newMessage(
+              psId,
+              ctx.getUser(),
+              ctx.getWhen(),
+              sb.toString(),
+              ChangeMessagesUtil.TAG_CHERRY_PICK_CHANGE);
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
       return true;
     }
   }
 
-  private String messageForDestinationChange(PatchSet.Id patchSetId,
-      Branch.NameKey sourceBranch) {
+  private String messageForDestinationChange(PatchSet.Id patchSetId, Branch.NameKey sourceBranch) {
     return new StringBuilder("Patch Set ")
-      .append(patchSetId.get())
-      .append(": Cherry Picked from branch ")
-      .append(sourceBranch.getShortName())
-      .append(".")
-      .toString();
+        .append(patchSetId.get())
+        .append(": Cherry Picked from branch ")
+        .append(sourceBranch.getShortName())
+        .append(".")
+        .toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
index d1ce453..0ebd84b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
@@ -14,21 +14,26 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.COMMENT_INFO_ORDER;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.reviewdb.client.FixSuggestion;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -57,104 +62,155 @@
     return this;
   }
 
-  CommentInfo format(PatchLineComment c) throws OrmException {
-    AccountLoader loader = null;
-    if (fillAccounts) {
-      loader = accountLoaderFactory.create(true);
-    }
-    CommentInfo commentInfo = toCommentInfo(c, loader);
-    if (fillAccounts) {
-      loader.fill();
-    }
-    return commentInfo;
+  public CommentFormatter newCommentFormatter() {
+    return new CommentFormatter();
   }
 
-  Map<String, List<CommentInfo>> format(Iterable<PatchLineComment> l)
-      throws OrmException {
-    Map<String, List<CommentInfo>> out = new TreeMap<>();
-    AccountLoader accountLoader = fillAccounts
-        ? accountLoaderFactory.create(true)
-        : null;
+  public RobotCommentFormatter newRobotCommentFormatter() {
+    return new RobotCommentFormatter();
+  }
 
-    for (PatchLineComment c : l) {
-      CommentInfo o = toCommentInfo(c, accountLoader);
-      List<CommentInfo> list = out.get(o.path);
-      if (list == null) {
-        list = new ArrayList<>();
-        out.put(o.path, list);
+  private abstract class BaseCommentFormatter<F extends Comment, T extends CommentInfo> {
+    public T format(F comment) throws OrmException {
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+      T info = toInfo(comment, loader);
+      if (loader != null) {
+        loader.fill();
       }
-      o.path = null;
-      list.add(o);
+      return info;
     }
 
-    for (List<CommentInfo> list : out.values()) {
-      Collections.sort(list, COMMENT_INFO_ORDER);
+    public Map<String, List<T>> format(Iterable<F> comments) throws OrmException {
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+
+      Map<String, List<T>> out = new TreeMap<>();
+
+      for (F c : comments) {
+        T o = toInfo(c, loader);
+        List<T> list = out.get(o.path);
+        if (list == null) {
+          list = new ArrayList<>();
+          out.put(o.path, list);
+        }
+        o.path = null;
+        list.add(o);
+      }
+
+      for (List<T> list : out.values()) {
+        Collections.sort(list, COMMENT_INFO_ORDER);
+      }
+
+      if (loader != null) {
+        loader.fill();
+      }
+      return out;
     }
 
-    if (accountLoader != null) {
-      accountLoader.fill();
+    public List<T> formatAsList(Iterable<F> comments) throws OrmException {
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+
+      List<T> out =
+          FluentIterable.from(comments)
+              .transform(c -> toInfo(c, loader))
+              .toSortedList(COMMENT_INFO_ORDER);
+
+      if (loader != null) {
+        loader.fill();
+      }
+      return out;
     }
 
-    return out;
-  }
+    protected abstract T toInfo(F comment, AccountLoader loader);
 
-  List<CommentInfo> formatAsList(Iterable<PatchLineComment> l)
-      throws OrmException {
-    final AccountLoader accountLoader = fillAccounts
-        ? accountLoaderFactory.create(true)
-        : null;
-    List<CommentInfo> out = FluentIterable
-        .from(l)
-        .transform(new Function<PatchLineComment, CommentInfo>() {
-          @Override
-          public CommentInfo apply(PatchLineComment c) {
-            return toCommentInfo(c, accountLoader);
-          }
-        }).toSortedList(COMMENT_INFO_ORDER);
-
-    if (accountLoader != null) {
-      accountLoader.fill();
-    }
-
-    return out;
-  }
-
-  private CommentInfo toCommentInfo(PatchLineComment c, AccountLoader loader) {
-    CommentInfo r = new CommentInfo();
-    if (fillPatchSet) {
-      r.patchSet = c.getKey().getParentKey().getParentKey().get();
-    }
-    r.id = Url.encode(c.getKey().get());
-    r.path = c.getKey().getParentKey().getFileName();
-    if (c.getSide() <= 0) {
-      r.side = Side.PARENT;
-      if (c.getSide() < 0) {
-        r.parent = -c.getSide();
+    protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
+      if (fillPatchSet) {
+        r.patchSet = c.key.patchSetId;
+      }
+      r.id = Url.encode(c.key.uuid);
+      r.path = c.key.filename;
+      if (c.side <= 0) {
+        r.side = Side.PARENT;
+        if (c.side < 0) {
+          r.parent = -c.side;
+        }
+      }
+      if (c.lineNbr > 0) {
+        r.line = c.lineNbr;
+      }
+      r.inReplyTo = Url.encode(c.parentUuid);
+      r.message = Strings.emptyToNull(c.message);
+      r.updated = c.writtenOn;
+      r.range = toRange(c.range);
+      r.tag = c.tag;
+      r.unresolved = c.unresolved;
+      if (loader != null) {
+        r.author = loader.get(c.author.getId());
       }
     }
-    if (c.getLine() > 0) {
-      r.line = c.getLine();
+
+    protected Range toRange(Comment.Range commentRange) {
+      Range range = null;
+      if (commentRange != null) {
+        range = new Range();
+        range.startLine = commentRange.startLine;
+        range.startCharacter = commentRange.startChar;
+        range.endLine = commentRange.endLine;
+        range.endCharacter = commentRange.endChar;
+      }
+      return range;
     }
-    r.inReplyTo = Url.encode(c.getParentUuid());
-    r.message = Strings.emptyToNull(c.getMessage());
-    r.updated = c.getWrittenOn();
-    r.range = toRange(c.getRange());
-    r.tag = c.getTag();
-    if (loader != null) {
-      r.author = loader.get(c.getAuthor());
-    }
-    return r;
   }
 
-  private Range toRange(CommentRange commentRange) {
-    Range range = null;
-    if (commentRange != null) {
-      range = new Range();
-      range.startLine = commentRange.getStartLine();
-      range.startCharacter = commentRange.getStartCharacter();
-      range.endLine = commentRange.getEndLine();
-      range.endCharacter = commentRange.getEndCharacter();
+  class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+    @Override
+    protected CommentInfo toInfo(Comment c, AccountLoader loader) {
+      CommentInfo ci = new CommentInfo();
+      fillCommentInfo(c, ci, loader);
+      return ci;
     }
-    return range;
+
+    private CommentFormatter() {}
+  }
+
+  class RobotCommentFormatter extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
+    @Override
+    protected RobotCommentInfo toInfo(RobotComment c, AccountLoader loader) {
+      RobotCommentInfo rci = new RobotCommentInfo();
+      rci.robotId = c.robotId;
+      rci.robotRunId = c.robotRunId;
+      rci.url = c.url;
+      rci.properties = c.properties;
+      rci.fixSuggestions = toFixSuggestionInfos(c.fixSuggestions);
+      fillCommentInfo(c, rci, loader);
+      return rci;
+    }
+
+    private List<FixSuggestionInfo> toFixSuggestionInfos(
+        @Nullable List<FixSuggestion> fixSuggestions) {
+      if (fixSuggestions == null || fixSuggestions.isEmpty()) {
+        return null;
+      }
+
+      return fixSuggestions.stream().map(this::toFixSuggestionInfo).collect(toList());
+    }
+
+    private FixSuggestionInfo toFixSuggestionInfo(FixSuggestion fixSuggestion) {
+      FixSuggestionInfo fixSuggestionInfo = new FixSuggestionInfo();
+      fixSuggestionInfo.fixId = fixSuggestion.fixId;
+      fixSuggestionInfo.description = fixSuggestion.description;
+      fixSuggestionInfo.replacements =
+          fixSuggestion.replacements.stream().map(this::toFixReplacementInfo).collect(toList());
+      return fixSuggestionInfo;
+    }
+
+    private FixReplacementInfo toFixReplacementInfo(FixReplacement fixReplacement) {
+      FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+      fixReplacementInfo.path = fixReplacement.path;
+      fixReplacementInfo.range = toRange(fixReplacement.range);
+      fixReplacementInfo.replacement = fixReplacement.replacement;
+      return fixReplacementInfo;
+    }
+
+    private RobotCommentFormatter() {}
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
index c535e9e..40c8515 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.inject.TypeLiteral;
 
@@ -26,9 +26,9 @@
       new TypeLiteral<RestView<CommentResource>>() {};
 
   private final RevisionResource rev;
-  private final PatchLineComment comment;
+  private final Comment comment;
 
-  public CommentResource(RevisionResource rev, PatchLineComment c) {
+  public CommentResource(RevisionResource rev, Comment c) {
     this.rev = rev;
     this.comment = c;
   }
@@ -37,15 +37,15 @@
     return rev.getPatchSet();
   }
 
-  PatchLineComment getComment() {
+  Comment getComment() {
     return comment;
   }
 
   String getId() {
-    return comment.getKey().get();
+    return comment.key.uuid;
   }
 
   Account.Id getAuthorId() {
-    return comment.getAuthor();
+    return comment.author.getId();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java
index 8f78f0e..935aa4e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Comments.java
@@ -19,9 +19,9 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -33,16 +33,18 @@
   private final DynamicMap<RestView<CommentResource>> views;
   private final ListRevisionComments list;
   private final Provider<ReviewDb> dbProvider;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
-  Comments(DynamicMap<RestView<CommentResource>> views,
-      ListRevisionComments list, Provider<ReviewDb> dbProvider,
-      PatchLineCommentsUtil plcUtil) {
+  Comments(
+      DynamicMap<RestView<CommentResource>> views,
+      ListRevisionComments list,
+      Provider<ReviewDb> dbProvider,
+      CommentsUtil commentsUtil) {
     this.views = views;
     this.list = list;
     this.dbProvider = dbProvider;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
@@ -61,9 +63,9 @@
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (PatchLineComment c : plcUtil.publishedByPatchSet(dbProvider.get(),
-        notes, rev.getPatchSet().getId())) {
-      if (uuid.equals(c.getKey().get())) {
+    for (Comment c :
+        commentsUtil.publishedByPatchSet(dbProvider.get(), notes, rev.getPatchSet().getId())) {
+      if (uuid.equals(c.key.uuid)) {
         return new CommentResource(rev, c);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 287c3ed..41845e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -19,19 +19,17 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
-import static com.google.gerrit.server.ChangeUtil.TO_PS_ID;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -45,22 +43,29 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -76,31 +81,18 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 /**
  * Checks changes for various kinds of inconsistency and corruption.
- * <p>
- * A single instance may be reused for checking multiple changes, but not
- * concurrently.
+ *
+ * <p>A single instance may be reused for checking multiple changes, but not concurrently.
  */
 public class ConsistencyChecker {
-  private static final Logger log =
-      LoggerFactory.getLogger(ConsistencyChecker.class);
+  private static final Logger log = LoggerFactory.getLogger(ConsistencyChecker.class);
 
   @AutoValue
   public abstract static class Result {
-    private static Result create(ChangeControl ctl,
-        List<ProblemInfo> problems) {
-      return new AutoValue_ConsistencyChecker_Result(
-          ctl.getId(), ctl.getChange(), problems);
+    private static Result create(ChangeControl ctl, List<ProblemInfo> problems) {
+      return new AutoValue_ConsistencyChecker_Result(ctl.getId(), ctl.getChange(), problems);
     }
 
     public abstract Change.Id id();
@@ -129,7 +121,7 @@
   private RevWalk rw;
 
   private RevCommit tip;
-  private Multimap<ObjectId, PatchSet> patchSetsBySha;
+  private SetMultimap<ObjectId, PatchSet> patchSetsBySha;
   private PatchSet currPs;
   private RevCommit currPsCommit;
 
@@ -219,8 +211,8 @@
     try {
       currPs = psUtil.current(db.get(), ctl.getNotes());
       if (currPs == null) {
-        problem(String.format("Current patch set %d not found",
-              change().currentPatchSetId().get()));
+        problem(
+            String.format("Current patch set %d not found", change().currentPatchSetId().get()));
       }
     } catch (OrmException e) {
       error("Failed to look up current patch set", e);
@@ -248,19 +240,13 @@
     } catch (OrmException e) {
       return error("Failed to look up patch sets", e);
     }
-    patchSetsBySha = MultimapBuilder.hashKeys(all.size())
-        .treeSetValues(PS_ID_ORDER)
-        .build();
+    patchSetsBySha = MultimapBuilder.hashKeys(all.size()).treeSetValues(PS_ID_ORDER).build();
 
     Map<String, Ref> refs;
     try {
-    refs = repo.getRefDatabase().exactRef(
-        Lists.transform(all, new Function<PatchSet, String>() {
-          @Override
-          public String apply(PatchSet ps) {
-            return ps.getId().toRefName();
-          }
-        }).toArray(new String[all.size()]));
+      refs =
+          repo.getRefDatabase()
+              .exactRef(all.stream().map(ps -> ps.getId().toRefName()).toArray(String[]::new));
     } catch (IOException e) {
       error("error reading refs", e);
       refs = Collections.emptyMap();
@@ -271,8 +257,7 @@
       // Check revision format.
       int psNum = ps.getId().get();
       String refName = ps.getId().toRefName();
-      ObjectId objId =
-          parseObjectId(ps.getRevision().get(), "patch set " + psNum);
+      ObjectId objId = parseObjectId(ps.getRevision().get(), "patch set " + psNum);
       if (objId == null) {
         continue;
       }
@@ -284,21 +269,18 @@
       if (ref == null) {
         refProblem = problem("Ref missing: " + refName);
       } else if (!objId.equals(ref.getObjectId())) {
-        String actual = ref.getObjectId() != null
-            ? ref.getObjectId().name()
-            : "null";
-        refProblem = problem(String.format(
-            "Expected %s to point to %s, found %s",
-            ref.getName(), objId.name(), actual));
+        String actual = ref.getObjectId() != null ? ref.getObjectId().name() : "null";
+        refProblem =
+            problem(
+                String.format(
+                    "Expected %s to point to %s, found %s", ref.getName(), objId.name(), actual));
       }
 
       // Check object existence.
-      RevCommit psCommit = parseCommit(
-          objId, String.format("patch set %d", psNum));
+      RevCommit psCommit = parseCommit(objId, String.format("patch set %d", psNum));
       if (psCommit == null) {
         if (fix != null && fix.deletePatchSetIfCommitMissing) {
-          deletePatchSetOps.add(
-              new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
+          deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
         }
         continue;
       } else if (refProblem != null && fix != null) {
@@ -313,12 +295,12 @@
     deletePatchSets(deletePatchSetOps);
 
     // Check for duplicates.
-    for (Map.Entry<ObjectId, Collection<PatchSet>> e
-        : patchSetsBySha.asMap().entrySet()) {
+    for (Map.Entry<ObjectId, Collection<PatchSet>> e : patchSetsBySha.asMap().entrySet()) {
       if (e.getValue().size() > 1) {
-        problem(String.format("Multiple patch sets pointing to %s: %s",
-            e.getKey().name(),
-            Collections2.transform(e.getValue(), TO_PS_ID)));
+        problem(
+            String.format(
+                "Multiple patch sets pointing to %s: %s",
+                e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::getPatchSetId)));
       }
     }
 
@@ -338,8 +320,7 @@
       problem("Destination ref not found (may be new branch): " + refName);
       return;
     }
-    tip = parseCommit(dest.getObjectId(),
-        "destination ref " + refName);
+    tip = parseCommit(dest.getObjectId(), "destination ref " + refName);
     if (tip == null) {
       return;
     }
@@ -351,8 +332,7 @@
       try {
         merged = rw.isMergedInto(currPsCommit, tip);
       } catch (IOException e) {
-        problem("Error checking whether patch set " + currPs.getId().get()
-            + " is merged");
+        problem("Error checking whether patch set " + currPs.getId().get() + " is merged");
         return;
       }
       checkMergedBitMatchesStatus(currPs.getId(), currPsCommit, merged);
@@ -361,14 +341,14 @@
 
   private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
     String refName = change().getDest().get();
-    return problem(String.format(
-        "Patch set %d (%s) is merged into destination ref %s (%s), but change"
-        + " status is %s", psId.get(), commit.name(),
-        refName, tip.name(), change().getStatus()));
+    return problem(
+        String.format(
+            "Patch set %d (%s) is merged into destination ref %s (%s), but change"
+                + " status is %s",
+            psId.get(), commit.name(), refName, tip.name(), change().getStatus()));
   }
 
-  private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit,
-      boolean merged) {
+  private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) {
     String refName = change().getDest().get();
     if (merged && change().getStatus() != Change.Status.MERGED) {
       ProblemInfo p = wrongChangeStatus(psId, commit);
@@ -376,16 +356,16 @@
         fixMerged(p);
       }
     } else if (!merged && change().getStatus() == Change.Status.MERGED) {
-      problem(String.format("Patch set %d (%s) is not merged into"
-            + " destination ref %s (%s), but change status is %s",
-            currPs.getId().get(), commit.name(), refName, tip.name(),
-            change().getStatus()));
+      problem(
+          String.format(
+              "Patch set %d (%s) is not merged into"
+                  + " destination ref %s (%s), but change status is %s",
+              currPs.getId().get(), commit.name(), refName, tip.name(), change().getStatus()));
     }
   }
 
   private void checkExpectMergedAs() {
-    ObjectId objId =
-        parseObjectId(fix.expectMergedAs, "expected merged commit");
+    ObjectId objId = parseObjectId(fix.expectMergedAs, "expected merged commit");
     RevCommit commit = parseCommit(objId, "expected merged commit");
     if (commit == null) {
       return;
@@ -393,9 +373,10 @@
 
     try {
       if (!rw.isMergedInto(commit, tip)) {
-        problem(String.format("Expected merged commit %s is not merged into"
-              + " destination ref %s (%s)",
-              commit.name(), change().getDest().get(), tip.name()));
+        problem(
+            String.format(
+                "Expected merged commit %s is not merged into destination ref %s (%s)",
+                commit.name(), change().getDest().get(), tip.name()));
         return;
       }
 
@@ -409,12 +390,14 @@
           continue;
         }
         try {
-          Change c = notesFactory.createChecked(
-              db.get(), change().getProject(), psId.getParentKey()).getChange();
+          Change c =
+              notesFactory
+                  .createChecked(db.get(), change().getProject(), psId.getParentKey())
+                  .getChange();
           if (!c.getDest().equals(change().getDest())) {
             continue;
           }
-        } catch (OrmException | NoSuchChangeException e) {
+        } catch (OrmException e) {
           warn(e);
           // Include this patch set; should cause an error below, which is good.
         }
@@ -424,13 +407,14 @@
         case 0:
           // No patch set for this commit; insert one.
           rw.parseBody(commit);
-          String changeId = Iterables.getFirst(
-              commit.getFooterLines(FooterConstants.CHANGE_ID), null);
+          String changeId =
+              Iterables.getFirst(commit.getFooterLines(FooterConstants.CHANGE_ID), null);
           // Missing Change-Id footer is ok, but mismatched is not.
           if (changeId != null && !changeId.equals(change().getKey().get())) {
-            problem(String.format("Expected merged commit %s has Change-Id: %s,"
-                  + " but expected %s",
-                  commit.name(), changeId, change().getKey().get()));
+            problem(
+                String.format(
+                    "Expected merged commit %s has Change-Id: %s, but expected %s",
+                    commit.name(), changeId, change().getKey().get()));
             return;
           }
           insertMergedPatchSet(commit, null, false);
@@ -454,41 +438,40 @@
           break;
 
         default:
-          problem(String.format(
-                "Multiple patch sets for expected merged commit %s: %s",
-                commit.name(), intKeyOrdering().sortedCopy(thisCommitPsIds)));
+          problem(
+              String.format(
+                  "Multiple patch sets for expected merged commit %s: %s",
+                  commit.name(), intKeyOrdering().sortedCopy(thisCommitPsIds)));
           break;
       }
     } catch (IOException e) {
-      error("Error looking up expected merged commit " + fix.expectMergedAs,
-          e);
+      error("Error looking up expected merged commit " + fix.expectMergedAs, e);
     }
   }
 
-  private void insertMergedPatchSet(final RevCommit commit,
-      final @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
-    ProblemInfo notFound =
-        problem("No patch set found for merged commit " + commit.name());
+  private void insertMergedPatchSet(
+      final RevCommit commit, final @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
+    ProblemInfo notFound = problem("No patch set found for merged commit " + commit.name());
     if (!user.get().isIdentifiedUser()) {
       notFound.status = Status.FIX_FAILED;
-      notFound.outcome =
-          "Must be called by an identified user to insert new patch set";
+      notFound.outcome = "Must be called by an identified user to insert new patch set";
       return;
     }
     ProblemInfo insertPatchSetProblem;
     ProblemInfo deleteOldPatchSetProblem;
 
     if (psIdToDelete == null) {
-      insertPatchSetProblem = problem(String.format(
-          "Expected merged commit %s has no associated patch set",
-          commit.name()));
+      insertPatchSetProblem =
+          problem(
+              String.format(
+                  "Expected merged commit %s has no associated patch set", commit.name()));
       deleteOldPatchSetProblem = null;
     } else {
-      String msg = String.format(
-          "Expected merge commit %s corresponds to patch set %s,"
-              + " not the current patch set %s",
-          commit.name(), psIdToDelete.get(),
-          change().currentPatchSetId().get());
+      String msg =
+          String.format(
+              "Expected merge commit %s corresponds to patch set %s,"
+                  + " not the current patch set %s",
+              commit.name(), psIdToDelete.get(), change().currentPatchSetId().get());
       // Maybe an identical problem, but different fix.
       deleteOldPatchSetProblem = reuseOldPsId ? null : problem(msg);
       insertPatchSetProblem = problem(msg);
@@ -502,11 +485,11 @@
     currProblems.add(insertPatchSetProblem);
 
     try {
-      PatchSet.Id psId = (psIdToDelete != null && reuseOldPsId)
-          ? psIdToDelete
-          : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
-      PatchSetInserter inserter =
-          patchSetInserterFactory.create(ctl, psId, commit);
+      PatchSet.Id psId =
+          (psIdToDelete != null && reuseOldPsId)
+              ? psIdToDelete
+              : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
+      PatchSetInserter inserter = patchSetInserterFactory.create(ctl, psId, commit);
       try (BatchUpdate bu = newBatchUpdate();
           ObjectInserter oi = repo.newObjectInserter()) {
         bu.setRepository(repo, rw, oi);
@@ -514,35 +497,37 @@
         if (psIdToDelete != null) {
           // Delete the given patch set ref. If reuseOldPsId is true,
           // PatchSetInserter will reinsert the same ref, making it a no-op.
-          bu.addOp(ctl.getId(), new BatchUpdate.Op() {
-            @Override
-            public void updateRepo(RepoContext ctx) throws IOException {
-              ctx.addRefUpdate(new ReceiveCommand(
-                  commit, ObjectId.zeroId(), psIdToDelete.toRefName()));
-            }
-          });
+          bu.addOp(
+              ctl.getId(),
+              new BatchUpdateOp() {
+                @Override
+                public void updateRepo(RepoContext ctx) throws IOException {
+                  ctx.addRefUpdate(
+                      new ReceiveCommand(commit, ObjectId.zeroId(), psIdToDelete.toRefName()));
+                }
+              });
           if (!reuseOldPsId) {
-            bu.addOp(ctl.getId(), new DeletePatchSetFromDbOp(
-                checkNotNull(deleteOldPatchSetProblem), psIdToDelete));
+            bu.addOp(
+                ctl.getId(),
+                new DeletePatchSetFromDbOp(checkNotNull(deleteOldPatchSetProblem), psIdToDelete));
           }
         }
 
-        bu.addOp(ctl.getId(), inserter
-            .setValidatePolicy(CommitValidators.Policy.NONE)
-            .setFireRevisionCreated(false)
-            .setSendMail(false)
-            .setAllowClosed(true)
-            .setMessage(
-                "Patch set for merged commit inserted by consistency checker"));
+        bu.addOp(
+            ctl.getId(),
+            inserter
+                .setValidatePolicy(CommitValidators.Policy.NONE)
+                .setFireRevisionCreated(false)
+                .setNotify(NotifyHandling.NONE)
+                .setAllowClosed(true)
+                .setMessage("Patch set for merged commit inserted by consistency checker"));
         bu.addOp(ctl.getId(), new FixMergedOp(notFound));
         bu.execute();
       }
-      ctl = changeControlFactory.controlFor(
-          db.get(), inserter.getChange(), ctl.getUser());
+      ctl = changeControlFactory.controlFor(db.get(), inserter.getChange(), ctl.getUser());
       insertPatchSetProblem.status = Status.FIXED;
       insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
-    } catch (OrmException | IOException | NoSuchChangeException
-        | UpdateException | RestApiException e) {
+    } catch (OrmException | IOException | UpdateException | RestApiException e) {
       warn(e);
       for (ProblemInfo pi : currProblems) {
         pi.status = Status.FIX_FAILED;
@@ -552,7 +537,7 @@
     }
   }
 
-  private static class FixMergedOp extends BatchUpdate.Op {
+  private static class FixMergedOp implements BatchUpdateOp {
     private final ProblemInfo p;
 
     private FixMergedOp(ProblemInfo p) {
@@ -562,8 +547,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx) throws OrmException {
       ctx.getChange().setStatus(Change.Status.MERGED);
-      ctx.getUpdate(ctx.getChange().currentPatchSetId())
-        .fixStatus(Change.Status.MERGED);
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
       p.status = Status.FIXED;
       p.outcome = "Marked change as merged";
       return true;
@@ -584,8 +568,7 @@
   }
 
   private BatchUpdate newBatchUpdate() {
-    return updateFactory.create(
-        db.get(), change().getProject(), ctl.getUser(), TimeUtil.nowTs());
+    return updateFactory.create(db.get(), change().getProject(), ctl.getUser(), TimeUtil.nowTs());
   }
 
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
@@ -650,7 +633,7 @@
     }
   }
 
-  private class DeletePatchSetFromDbOp extends BatchUpdate.Op {
+  private class DeletePatchSetFromDbOp implements BatchUpdateOp {
     private final ProblemInfo p;
     private final PatchSet.Id psId;
 
@@ -663,14 +646,11 @@
     public boolean updateChange(ChangeContext ctx)
         throws OrmException, PatchSetInfoNotAvailableException {
       // Delete dangling key references.
-      ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
+      ReviewDb db = DeleteChangeOp.unwrap(ctx.getDb());
       accountPatchReviewStore.get().clearReviewed(psId);
-      db.changeMessages().delete(
-          db.changeMessages().byChange(psId.getParentKey()));
-      db.patchSetApprovals().delete(
-          db.patchSetApprovals().byPatchSet(psId));
-      db.patchComments().delete(
-          db.patchComments().byPatchSet(psId));
+      db.changeMessages().delete(db.changeMessages().byChange(psId.getParentKey()));
+      db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
+      db.patchComments().delete(db.patchComments().byPatchSet(psId));
       db.patchSets().deleteKeys(Collections.singleton(psId));
 
       // NoteDb requires no additional fiddling; setting the state to deleted is
@@ -683,8 +663,7 @@
     }
   }
 
-  private static class NoPatchSetsWouldRemainException
-      extends RestApiException {
+  private static class NoPatchSetsWouldRemainException extends RestApiException {
     private static final long serialVersionUID = 1L;
 
     private NoPatchSetsWouldRemainException() {
@@ -692,7 +671,7 @@
     }
   }
 
-  private class UpdateCurrentPatchSetOp extends BatchUpdate.Op {
+  private class UpdateCurrentPatchSetOp implements BatchUpdateOp {
     private final Set<PatchSet.Id> toDelete;
 
     private UpdateCurrentPatchSetOp(List<DeletePatchSetFromDbOp> deleteOps) {
@@ -704,8 +683,7 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, PatchSetInfoNotAvailableException,
-        NoPatchSetsWouldRemainException {
+        throws OrmException, PatchSetInfoNotAvailableException, NoPatchSetsWouldRemainException {
       if (!toDelete.contains(ctx.getChange().currentPatchSetId())) {
         return false;
       }
@@ -722,8 +700,8 @@
         throw new NoPatchSetsWouldRemainException();
       }
       PatchSet.Id latest = ReviewDbUtil.intKeyOrdering().max(all);
-      ctx.getChange().setCurrentPatchSet(
-          patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), latest));
+      ctx.getChange()
+          .setCurrentPatchSet(patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), latest));
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
index cf4be18..97d2b70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
@@ -52,10 +52,8 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -63,15 +61,23 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.List;
+import java.util.TimeZone;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -80,16 +86,8 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
 
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.List;
-import java.util.TimeZone;
-
 @Singleton
-public class CreateChange implements
-    RestModifyView<TopLevelResource, ChangeInput> {
+public class CreateChange implements RestModifyView<TopLevelResource, ChangeInput> {
 
   private final String anonymousCowardName;
   private final Provider<ReviewDb> db;
@@ -107,9 +105,11 @@
   private final boolean allowDrafts;
   private final MergeUtil.Factory mergeUtilFactory;
   private final SubmitType submitType;
+  private final NotifyUtil notifyUtil;
 
   @Inject
-  CreateChange(@AnonymousCowardName String anonymousCowardName,
+  CreateChange(
+      @AnonymousCowardName String anonymousCowardName,
       Provider<ReviewDb> db,
       GitRepositoryManager gitManager,
       AccountCache accountCache,
@@ -123,7 +123,8 @@
       BatchUpdate.Factory updateFactory,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
-      MergeUtil.Factory mergeUtilFactory) {
+      MergeUtil.Factory mergeUtilFactory,
+      NotifyUtil notifyUtil) {
     this.anonymousCowardName = anonymousCowardName;
     this.db = db;
     this.gitManager = gitManager;
@@ -138,15 +139,15 @@
     this.updateFactory = updateFactory;
     this.psUtil = psUtil;
     this.allowDrafts = config.getBoolean("change", "allowDrafts", true);
-    this.submitType = config
-        .getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
+    this.submitType = config.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
     this.mergeUtilFactory = mergeUtilFactory;
+    this.notifyUtil = notifyUtil;
   }
 
   @Override
   public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInput input)
-      throws OrmException, IOException, InvalidChangeOperationException,
-      RestApiException, UpdateException {
+      throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
+          UpdateException {
     if (Strings.isNullOrEmpty(input.project)) {
       throw new BadRequestException("project must be non-empty");
     }
@@ -155,13 +156,13 @@
       throw new BadRequestException("branch must be non-empty");
     }
 
-    if (Strings.isNullOrEmpty(input.subject)) {
+    String subject = clean(Strings.nullToEmpty(input.subject));
+    if (Strings.isNullOrEmpty(subject)) {
       throw new BadRequestException("commit message must be non-empty");
     }
 
     if (input.status != null) {
-      if (input.status != ChangeStatus.NEW
-          && input.status != ChangeStatus.DRAFT) {
+      if (input.status != ChangeStatus.NEW && input.status != ChangeStatus.DRAFT) {
         throw new BadRequestException("unsupported change status");
       }
 
@@ -185,21 +186,19 @@
 
     Project.NameKey project = rsrc.getNameKey();
     try (Repository git = gitManager.openRepository(project);
-         ObjectInserter oi = git.newObjectInserter();
-         RevWalk rw = new RevWalk(oi.newReader())) {
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
       ObjectId parentCommit;
       List<String> groups;
       if (input.baseChange != null) {
-        List<ChangeControl> ctls = changeFinder.find(
-            input.baseChange, rsrc.getControl().getUser());
+        List<ChangeControl> ctls = changeFinder.find(input.baseChange, rsrc.getControl().getUser());
         if (ctls.size() != 1) {
-          throw new InvalidChangeOperationException(
-              "Base change not found: " + input.baseChange);
+          throw new InvalidChangeOperationException("Base change not found: " + input.baseChange);
         }
         ChangeControl ctl = Iterables.getOnlyElement(ctls);
         if (!ctl.isVisible(db.get())) {
-          throw new InvalidChangeOperationException(
-              "Base change not found: " + input.baseChange);
+          throw new InvalidChangeOperationException("Base change not found: " + input.baseChange);
         }
         PatchSet ps = psUtil.current(db.get(), ctl.getNotes());
         parentCommit = ObjectId.fromString(ps.getRevision().get());
@@ -208,67 +207,67 @@
         Ref destRef = git.getRefDatabase().exactRef(refName);
         if (destRef != null) {
           if (Boolean.TRUE.equals(input.newBranch)) {
-            throw new ResourceConflictException(String.format(
-                "Branch %s already exists.", refName));
+            throw new ResourceConflictException(
+                String.format("Branch %s already exists.", refName));
           }
           parentCommit = destRef.getObjectId();
         } else {
           if (Boolean.TRUE.equals(input.newBranch)) {
             parentCommit = null;
           } else {
-            throw new UnprocessableEntityException(String.format(
-                "Branch %s does not exist.", refName));
+            throw new UnprocessableEntityException(
+                String.format("Branch %s does not exist.", refName));
           }
         }
         groups = Collections.emptyList();
       }
-      RevCommit mergeTip =
-          parentCommit == null ? null : rw.parseCommit(parentCommit);
+      RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
 
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
       AccountState account = accountCache.get(me.getAccountId());
-      GeneralPreferencesInfo info =
-          account.getAccount().getGeneralPreferencesInfo();
+      GeneralPreferencesInfo info = account.getAccount().getGeneralPreferencesInfo();
 
       // Add a Change-Id line if there isn't already one
-      String commitMessage = input.subject;
+      String commitMessage = subject;
       if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
-        ObjectId treeId =
-            mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree();
-        ObjectId id = ChangeIdUtil.computeChangeId(treeId,
-            mergeTip, author, author, commitMessage);
+        ObjectId treeId = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree();
+        ObjectId id = ChangeIdUtil.computeChangeId(treeId, mergeTip, author, author, commitMessage);
         commitMessage = ChangeIdUtil.insertId(commitMessage, id);
       }
 
       if (Boolean.TRUE.equals(info.signedOffBy)) {
         commitMessage =
-            Joiner.on("\n").join(commitMessage.trim(), String.format(
-                "%s%s", SIGNED_OFF_BY_TAG,
-                account.getAccount().getNameEmail(anonymousCowardName)));
+            Joiner.on("\n")
+                .join(
+                    commitMessage.trim(),
+                    String.format(
+                        "%s%s",
+                        SIGNED_OFF_BY_TAG, account.getAccount().getNameEmail(anonymousCowardName)));
       }
 
       RevCommit c;
       if (input.merge != null) {
         // create a merge commit
-        if (!(submitType.equals(SubmitType.MERGE_ALWAYS) ||
-              submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
-          throw new BadRequestException(
-              "Submit type: " + submitType + " is not supported");
+        if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
+            || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
+          throw new BadRequestException("Submit type: " + submitType + " is not supported");
         }
-        c = newMergeCommit(git, oi, rw, rsrc.getControl(), mergeTip, input.merge,
-            author, commitMessage);
+        c =
+            newMergeCommit(
+                git, oi, rw, rsrc.getControl(), mergeTip, input.merge, author, commitMessage);
       } else {
         // create an empty commit
         c = newCommit(oi, rw, author, mergeTip, commitMessage);
       }
 
       Change.Id changeId = new Change.Id(seq.nextChangeId());
-      ChangeInserter ins = changeInserterFactory.create(changeId, c, refName)
-          .setValidatePolicy(CommitValidators.Policy.GERRIT);
-      ins.setMessage(String.format("Uploaded patch set %s.",
-          ins.getPatchSetId().get()));
+      ChangeInserter ins =
+          changeInserterFactory
+              .create(changeId, c, refName)
+              .setValidatePolicy(CommitValidators.Policy.GERRIT);
+      ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
       String topic = input.topic;
       if (topic != null) {
         topic = Strings.emptyToNull(topic.trim());
@@ -276,21 +275,26 @@
       ins.setTopic(topic);
       ins.setDraft(input.status == ChangeStatus.DRAFT);
       ins.setGroups(groups);
-      try (BatchUpdate bu = updateFactory.create(
-          db.get(), project, me, now)) {
+      ins.setNotify(input.notify);
+      ins.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
         bu.setRepository(git, rw, oi);
         bu.insertChange(ins);
         bu.execute();
       }
-      ChangeJson json = jsonFactory.create(ChangeJson.NO_OPTIONS);
+      ChangeJson json = jsonFactory.noOptions();
       return Response.created(json.format(ins.getChange()));
     } catch (IllegalArgumentException e) {
       throw new BadRequestException(e.getMessage());
     }
   }
 
-  private static RevCommit newCommit(ObjectInserter oi, RevWalk rw,
-      PersonIdent authorIdent, RevCommit mergeTip, String commitMessage)
+  private static RevCommit newCommit(
+      ObjectInserter oi,
+      RevWalk rw,
+      PersonIdent authorIdent,
+      RevCommit mergeTip,
+      String commitMessage)
       throws IOException {
     CommitBuilder commit = new CommitBuilder();
     if (mergeTip == null) {
@@ -305,9 +309,15 @@
     return rw.parseCommit(insert(oi, commit));
   }
 
-  private RevCommit newMergeCommit(Repository repo, ObjectInserter oi,
-      RevWalk rw, ProjectControl projectControl, RevCommit mergeTip,
-      MergeInput merge, PersonIdent authorIdent, String commitMessage)
+  private RevCommit newMergeCommit(
+      Repository repo,
+      ObjectInserter oi,
+      RevWalk rw,
+      ProjectControl projectControl,
+      RevCommit mergeTip,
+      MergeInput merge,
+      PersonIdent authorIdent,
+      String commitMessage)
       throws RestApiException, IOException {
     if (Strings.isNullOrEmpty(merge.source)) {
       throw new BadRequestException("merge.source must be non-empty");
@@ -315,31 +325,39 @@
 
     RevCommit sourceCommit = MergeUtil.resolveCommit(repo, rw, merge.source);
     if (!projectControl.canReadCommit(db.get(), repo, sourceCommit)) {
-      throw new BadRequestException(
-          "do not have read permission for: " + merge.source);
+      throw new BadRequestException("do not have read permission for: " + merge.source);
     }
 
-    MergeUtil mergeUtil =
-        mergeUtilFactory.create(projectControl.getProjectState());
+    MergeUtil mergeUtil = mergeUtilFactory.create(projectControl.getProjectState());
     // default merge strategy from project settings
-    String mergeStrategy = MoreObjects.firstNonNull(
-        Strings.emptyToNull(merge.strategy),
-        mergeUtil.mergeStrategyName());
+    String mergeStrategy =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
 
-    return MergeUtil.createMergeCommit(repo, oi, mergeTip, sourceCommit,
-        mergeStrategy, authorIdent, commitMessage, rw);
+    return MergeUtil.createMergeCommit(
+        repo, oi, mergeTip, sourceCommit, mergeStrategy, authorIdent, commitMessage, rw);
   }
 
-  private static ObjectId insert(ObjectInserter inserter,
-      CommitBuilder commit) throws IOException,
-      UnsupportedEncodingException {
+  private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit)
+      throws IOException, UnsupportedEncodingException {
     ObjectId id = inserter.insert(commit);
     inserter.flush();
     return id;
   }
 
-  private static ObjectId emptyTreeId(ObjectInserter inserter)
-      throws IOException {
+  private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
     return inserter.insert(new TreeFormatter());
   }
+
+  /**
+   * Remove comment lines from a commit message.
+   *
+   * <p>Based on {@link org.eclipse.jgit.util.ChangeIdUtil#clean}.
+   *
+   * @param msg
+   * @return message without comment lines, possibly empty.
+   */
+  private String clean(String msg) {
+    return msg.replaceAll("(?m)^#.*$\n?", "").trim();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 7cb2aac..5032e57 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -14,8 +14,7 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
-import static com.google.gerrit.server.change.PutDraftComment.side;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
@@ -26,23 +25,23 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.Collections;
 
 @Singleton
@@ -50,21 +49,22 @@
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
 
   @Inject
-  CreateDraftComment(Provider<ReviewDb> db,
+  CreateDraftComment(
+      Provider<ReviewDb> db,
       BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache) {
     this.db = db;
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
   }
@@ -82,21 +82,21 @@
       throw new BadRequestException("range endLine must be on the same line as the comment");
     }
 
-    try (BatchUpdate bu = updateFactory.create(
-        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op(rsrc.getPatchSet().getId(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.created(
-          commentJson.get().setFillAccounts(false).format(op.comment));
+          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
     }
   }
 
-  private class Op extends BatchUpdate.Op {
+  private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
     private final DraftInput in;
 
-    private PatchLineComment comment;
+    private Comment comment;
 
     private Op(PatchSet.Id psId, DraftInput in) {
       this.psId = psId;
@@ -105,28 +105,23 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException {
+        throws ResourceNotFoundException, OrmException, UnprocessableEntityException {
       PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      int line = in.line != null
-          ? in.line
-          : in.range != null ? in.range.endLine : 0;
-      comment = new PatchLineComment(
-          new PatchLineComment.Key(
-              new Patch.Key(ps.getId(), in.path),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          line, ctx.getAccountId(), Url.decode(in.inReplyTo),
-          ctx.getWhen());
-      comment.setSide(side(in));
-      comment.setMessage(in.message.trim());
-      comment.setRange(in.range);
-      comment.setTag(in.tag);
-      setCommentRevId(
-          comment, patchListCache, ctx.getChange(), ps);
-      plcUtil.putComments(
-          ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(comment));
+      String parentUuid = Url.decode(in.inReplyTo);
+
+      comment =
+          commentsUtil.newComment(
+              ctx, in.path, ps.getId(), in.side(), in.message.trim(), in.unresolved, parentUuid);
+      comment.setLineNbrAndRange(in.line, in.range);
+      comment.tag = in.tag;
+
+      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+
+      commentsUtil.putComments(
+          ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
       ctx.bumpLastUpdatedOn(false);
       return true;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
new file mode 100644
index 0000000..5bd651d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
@@ -0,0 +1,221 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeIdenticalTreeException;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+@Singleton
+public class CreateMergePatchSet implements RestModifyView<ChangeResource, MergePatchSetInput> {
+
+  private final Provider<ReviewDb> db;
+  private final GitRepositoryManager gitManager;
+  private final TimeZone serverTimeZone;
+  private final Provider<CurrentUser> user;
+  private final ChangeJson.Factory jsonFactory;
+  private final PatchSetUtil psUtil;
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+
+  @Inject
+  CreateMergePatchSet(
+      Provider<ReviewDb> db,
+      GitRepositoryManager gitManager,
+      @GerritPersonIdent PersonIdent myIdent,
+      Provider<CurrentUser> user,
+      ChangeJson.Factory json,
+      PatchSetUtil psUtil,
+      MergeUtil.Factory mergeUtilFactory,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetInserter.Factory patchSetInserterFactory) {
+    this.db = db;
+    this.gitManager = gitManager;
+    this.serverTimeZone = myIdent.getTimeZone();
+    this.user = user;
+    this.jsonFactory = json;
+    this.psUtil = psUtil;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource req, MergePatchSetInput in)
+      throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
+          UpdateException {
+    if (in.merge == null) {
+      throw new BadRequestException("merge field is required");
+    }
+
+    MergeInput merge = in.merge;
+    if (Strings.isNullOrEmpty(merge.source)) {
+      throw new BadRequestException("merge.source must be non-empty");
+    }
+
+    ChangeControl ctl = req.getControl();
+    if (!ctl.isVisible(db.get())) {
+      throw new InvalidChangeOperationException("Base change not found: " + req.getId());
+    }
+    PatchSet ps = psUtil.current(db.get(), ctl.getNotes());
+    if (!ctl.canAddPatchSet(db.get())) {
+      throw new AuthException("cannot add patch set");
+    }
+
+    ProjectControl projectControl = ctl.getProjectControl();
+    Change change = ctl.getChange();
+    Project.NameKey project = change.getProject();
+    Branch.NameKey dest = change.getDest();
+    try (Repository git = gitManager.openRepository(project);
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+
+      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, merge.source);
+      if (!projectControl.canReadCommit(db.get(), git, sourceCommit)) {
+        throw new ResourceNotFoundException(
+            "cannot find source commit: " + merge.source + " to merge.");
+      }
+
+      RevCommit currentPsCommit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+
+      Timestamp now = TimeUtil.nowTs();
+      IdentifiedUser me = user.get().asIdentifiedUser();
+      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+
+      RevCommit newCommit =
+          createMergeCommit(
+              in,
+              projectControl,
+              dest,
+              git,
+              oi,
+              rw,
+              currentPsCommit,
+              sourceCommit,
+              author,
+              ObjectId.fromString(change.getKey().get().substring(1)));
+
+      PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
+      PatchSetInserter psInserter = patchSetInserterFactory.create(ctl, nextPsId, newCommit);
+      try (BatchUpdate bu = batchUpdateFactory.create(db.get(), project, me, now)) {
+        bu.setRepository(git, rw, oi);
+        bu.addOp(
+            ctl.getId(),
+            psInserter
+                .setMessage("Uploaded patch set " + nextPsId.get() + ".")
+                .setDraft(ps.isDraft())
+                .setNotify(NotifyHandling.NONE));
+        bu.execute();
+      }
+
+      ChangeJson json = jsonFactory.create(ListChangesOption.CURRENT_REVISION);
+      return Response.ok(json.format(psInserter.getChange()));
+    }
+  }
+
+  private RevCommit createMergeCommit(
+      MergePatchSetInput in,
+      ProjectControl projectControl,
+      Branch.NameKey dest,
+      Repository git,
+      ObjectInserter oi,
+      RevWalk rw,
+      RevCommit currentPsCommit,
+      RevCommit sourceCommit,
+      PersonIdent author,
+      ObjectId changeId)
+      throws ResourceNotFoundException, MergeIdenticalTreeException, MergeConflictException,
+          IOException {
+
+    ObjectId parentCommit;
+    if (in.inheritParent) {
+      // inherit first parent from previous patch set
+      parentCommit = currentPsCommit.getParent(0);
+    } else {
+      // get the current branch tip of destination branch
+      Ref destRef = git.getRefDatabase().exactRef(dest.get());
+      if (destRef != null) {
+        parentCommit = destRef.getObjectId();
+      } else {
+        throw new ResourceNotFoundException("cannot find destination branch");
+      }
+    }
+    RevCommit mergeTip = rw.parseCommit(parentCommit);
+
+    String commitMsg;
+    if (Strings.emptyToNull(in.subject) != null) {
+      commitMsg = ChangeIdUtil.insertId(in.subject, changeId);
+    } else {
+      // reuse previous patch set commit message
+      commitMsg = currentPsCommit.getFullMessage();
+    }
+
+    String mergeStrategy =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(in.merge.strategy),
+            mergeUtilFactory.create(projectControl.getProjectState()).mergeStrategyName());
+
+    return MergeUtil.createMergeCommit(
+        git, oi, mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
new file mode 100644
index 0000000..b8556d6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+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.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.DeleteAssignee.Input;
+import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeleteAssignee implements RestModifyView<ChangeResource, Input> {
+  public static class Input {}
+
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> db;
+  private final AssigneeChanged assigneeChanged;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  DeleteAssignee(
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeMessagesUtil cmUtil,
+      Provider<ReviewDb> db,
+      AssigneeChanged assigneeChanged,
+      IdentifiedUser.GenericFactory userFactory,
+      AccountLoader.Factory accountLoaderFactory) {
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.cmUtil = cmUtil;
+    this.db = db;
+    this.assigneeChanged = assigneeChanged;
+    this.userFactory = userFactory;
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, OrmException {
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Op op = new Op();
+      bu.addOp(rsrc.getChange().getId(), op);
+      bu.execute();
+      Account.Id deletedAssignee = op.getDeletedAssignee();
+      return deletedAssignee == null
+          ? Response.none()
+          : Response.ok(accountLoaderFactory.create(true).fillOne(deletedAssignee));
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private Change change;
+    private Account deletedAssignee;
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws RestApiException, OrmException {
+      if (!ctx.getControl().canEditAssignee()) {
+        throw new AuthException("Delete Assignee not permitted");
+      }
+      change = ctx.getChange();
+      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+      Account.Id currentAssigneeId = change.getAssignee();
+      if (currentAssigneeId == null) {
+        return false;
+      }
+      IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
+      deletedAssignee = deletedAssigneeUser.getAccount();
+      // noteDb
+      update.removeAssignee();
+      // reviewDb
+      change.setAssignee(null);
+      addMessage(ctx, update, deletedAssigneeUser);
+      return true;
+    }
+
+    public Account.Id getDeletedAssignee() {
+      return deletedAssignee != null ? deletedAssignee.getId() : null;
+    }
+
+    private void addMessage(ChangeContext ctx, ChangeUpdate update, IdentifiedUser deletedAssignee)
+        throws OrmException {
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(
+              ctx,
+              "Assignee deleted: " + deletedAssignee.getNameEmail(),
+              ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws OrmException {
+      assigneeChanged.fire(change, ctx.getAccount(), deletedAssignee, ctx.getWhen());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
new file mode 100644
index 0000000..ad823d4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.DeleteChange.Input;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.Order;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class DeleteChange
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  public static class Input {}
+
+  private final Provider<ReviewDb> db;
+  private final BatchUpdate.Factory updateFactory;
+  private final Provider<DeleteChangeOp> opProvider;
+  private final boolean allowDrafts;
+
+  @Inject
+  public DeleteChange(
+      Provider<ReviewDb> db,
+      BatchUpdate.Factory updateFactory,
+      Provider<DeleteChangeOp> opProvider,
+      @GerritServerConfig Config cfg) {
+    this.db = db;
+    this.updateFactory = updateFactory;
+    this.opProvider = opProvider;
+    this.allowDrafts = DeleteChangeOp.allowDrafts(cfg);
+  }
+
+  @Override
+  public Response<?> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Change.Id id = rsrc.getChange().getId();
+      bu.setOrder(Order.DB_BEFORE_REPO);
+      bu.addOp(id, opProvider.get());
+      bu.execute();
+    }
+    return Response.none();
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    try {
+      Change.Status status = rsrc.getChange().getStatus();
+      ChangeControl changeControl = rsrc.getControl();
+      boolean visible =
+          isActionAllowed(changeControl, status) && changeControl.canDelete(db.get(), status);
+      return new UiAction.Description()
+          .setLabel("Delete")
+          .setTitle("Delete change " + rsrc.getId())
+          .setVisible(visible);
+    } catch (OrmException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private boolean isActionAllowed(ChangeControl changeControl, Status status) {
+    return status != Status.DRAFT || allowDrafts || changeControl.isAdmin();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
index 7c1e959..f196ec8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -25,13 +24,12 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import java.util.Optional;
 
 @Singleton
 public class DeleteChangeEdit implements RestModifyView<ChangeResource, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   private final ChangeEditUtil editUtil;
 
@@ -42,8 +40,7 @@
 
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, IOException,
-      OrmException {
+      throws AuthException, ResourceNotFoundException, IOException, OrmException {
     Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
     if (edit.isPresent()) {
       editUtil.delete(edit.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
new file mode 100644
index 0000000..3db995a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -0,0 +1,189 @@
+// 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.change;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.ChangeDeleted;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Order;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+class DeleteChangeOp implements BatchUpdateOp {
+  static boolean allowDrafts(Config cfg) {
+    return cfg.getBoolean("change", "allowDrafts", true);
+  }
+
+  static ReviewDb unwrap(ReviewDb db) {
+    // This is special. We want to delete exactly the rows that are present in
+    // the database, even when reading everything else from NoteDb, so we need
+    // to bypass the write-only wrapper.
+    if (db instanceof BatchUpdateReviewDb) {
+      db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+    }
+    return ReviewDbUtil.unwrapDb(db);
+  }
+
+  private final PatchSetUtil psUtil;
+  private final StarredChangesUtil starredChangesUtil;
+  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+  private final boolean allowDrafts;
+  private final ChangeDeleted changeDeleted;
+
+  private Change.Id id;
+
+  @Inject
+  DeleteChangeOp(
+      PatchSetUtil psUtil,
+      StarredChangesUtil starredChangesUtil,
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
+      @GerritServerConfig Config cfg,
+      ChangeDeleted changeDeleted) {
+    this.psUtil = psUtil;
+    this.starredChangesUtil = starredChangesUtil;
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.allowDrafts = allowDrafts(cfg);
+    this.changeDeleted = changeDeleted;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException, NoSuchChangeException {
+    checkState(
+        ctx.getOrder() == Order.DB_BEFORE_REPO, "must use DeleteChangeOp with DB_BEFORE_REPO");
+    checkState(id == null, "cannot reuse DeleteChangeOp");
+
+    id = ctx.getChange().getId();
+    Collection<PatchSet> patchSets = psUtil.byChange(ctx.getDb(), ctx.getNotes());
+
+    ensureDeletable(ctx, id, patchSets);
+    // Cleaning up is only possible as long as the change and its elements are
+    // still part of the database.
+    cleanUpReferences(ctx, id, patchSets);
+    deleteChangeElementsFromDb(ctx, id);
+
+    ctx.deleteChange();
+    changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
+    return true;
+  }
+
+  private void ensureDeletable(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
+      throws ResourceConflictException, MethodNotAllowedException, OrmException, AuthException,
+          IOException {
+    Change.Status status = ctx.getChange().getStatus();
+    if (status == Change.Status.MERGED) {
+      throw new MethodNotAllowedException("Deleting merged change " + id + " is not allowed");
+    }
+    for (PatchSet patchSet : patchSets) {
+      if (isPatchSetMerged(ctx, patchSet)) {
+        throw new ResourceConflictException(
+            String.format(
+                "Cannot delete change %s: patch set %s is already merged",
+                id, patchSet.getPatchSetId()));
+      }
+    }
+
+    if (!ctx.getControl().canDelete(ctx.getDb(), status)) {
+      throw new AuthException("Deleting change " + id + " is not permitted");
+    }
+
+    if (status == Change.Status.DRAFT) {
+      if (!allowDrafts && !ctx.getControl().isAdmin()) {
+        throw new MethodNotAllowedException("Draft workflow is disabled");
+      }
+      for (PatchSet ps : patchSets) {
+        if (!ps.isDraft()) {
+          throw new ResourceConflictException(
+              "Cannot delete draft change "
+                  + id
+                  + ": patch set "
+                  + ps.getPatchSetId()
+                  + " is not a draft");
+        }
+      }
+    }
+  }
+
+  private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
+    Repository repository = ctx.getRepository();
+    Ref destinationRef = repository.exactRef(ctx.getChange().getDest().get());
+    if (destinationRef == null) {
+      return false;
+    }
+
+    RevWalk revWalk = ctx.getRevWalk();
+    ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
+    RevCommit revCommit = revWalk.parseCommit(objectId);
+    return IncludedInResolver.includedInOne(
+        repository, revWalk, revCommit, Collections.singletonList(destinationRef));
+  }
+
+  private void deleteChangeElementsFromDb(ChangeContext ctx, Change.Id id) throws OrmException {
+    // Only delete from ReviewDb here; deletion from NoteDb is handled in
+    // BatchUpdate.
+    ReviewDb db = unwrap(ctx.getDb());
+    db.patchComments().delete(db.patchComments().byChange(id));
+    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+    db.patchSets().delete(db.patchSets().byChange(id));
+    db.changeMessages().delete(db.changeMessages().byChange(id));
+  }
+
+  private void cleanUpReferences(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
+      throws OrmException, NoSuchChangeException {
+    for (PatchSet ps : patchSets) {
+      accountPatchReviewStore.get().clearReviewed(ps.getId());
+    }
+
+    // Non-atomic operation on Accounts table; not much we can do to make it
+    // atomic.
+    starredChangesUtil.unstarAll(ctx.getChange().getProject(), id);
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws IOException {
+    String prefix = new PatchSet.Id(id, 1).toRefName();
+    prefix = prefix.substring(0, prefix.length() - 1);
+    for (Ref ref : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
+      ctx.addRefUpdate(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java
deleted file mode 100644
index a125272..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChange.java
+++ /dev/null
@@ -1,84 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.DeleteDraftChange.Input;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class DeleteDraftChange implements
-    RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  public static class Input {
-  }
-
-  private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory updateFactory;
-  private final Provider<DeleteDraftChangeOp> opProvider;
-  private final boolean allowDrafts;
-
-  @Inject
-  public DeleteDraftChange(Provider<ReviewDb> db,
-      BatchUpdate.Factory updateFactory,
-      Provider<DeleteDraftChangeOp> opProvider,
-      @GerritServerConfig Config cfg) {
-    this.db = db;
-    this.updateFactory = updateFactory;
-    this.opProvider = opProvider;
-    this.allowDrafts = DeleteDraftChangeOp.allowDrafts(cfg);
-  }
-
-  @Override
-  public Response<?> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException {
-    try (BatchUpdate bu = updateFactory.create(
-        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Change.Id id = rsrc.getChange().getId();
-      bu.setOrder(BatchUpdate.Order.DB_BEFORE_REPO);
-      bu.addOp(id, opProvider.get());
-      bu.execute();
-    }
-    return Response.none();
-  }
-
-  @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
-    try {
-      return new UiAction.Description()
-        .setLabel("Delete")
-        .setTitle("Delete draft change " + rsrc.getId())
-        .setVisible(allowDrafts
-            && rsrc.getChange().getStatus() == Status.DRAFT
-            && rsrc.getControl().canDeleteDraft(db.get()));
-    } catch (OrmException e) {
-      throw new IllegalStateException(e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
deleted file mode 100644
index 3ca0e1b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
-import com.google.gerrit.server.git.BatchUpdateReviewDb;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-import java.io.IOException;
-import java.util.List;
-
-class DeleteDraftChangeOp extends BatchUpdate.Op {
-  static boolean allowDrafts(Config cfg) {
-    return cfg.getBoolean("change", "allowDrafts", true);
-  }
-
-  static ReviewDb unwrap(ReviewDb db) {
-    // This is special. We want to delete exactly the rows that are present in
-    // the database, even when reading everything else from NoteDb, so we need
-    // to bypass the write-only wrapper.
-    if (db instanceof BatchUpdateReviewDb) {
-      db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-    }
-    return ReviewDbUtil.unwrapDb(db);
-  }
-
-
-  private final PatchSetUtil psUtil;
-  private final StarredChangesUtil starredChangesUtil;
-  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-  private final boolean allowDrafts;
-
-  private Change.Id id;
-
-  @Inject
-  DeleteDraftChangeOp(PatchSetUtil psUtil,
-      StarredChangesUtil starredChangesUtil,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
-      @GerritServerConfig Config cfg) {
-    this.psUtil = psUtil;
-    this.starredChangesUtil = starredChangesUtil;
-    this.accountPatchReviewStore = accountPatchReviewStore;
-    this.allowDrafts = allowDrafts(cfg);
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws RestApiException,
-      OrmException, IOException, NoSuchChangeException {
-    checkState(ctx.getOrder() == BatchUpdate.Order.DB_BEFORE_REPO,
-        "must use DeleteDraftChangeOp with DB_BEFORE_REPO");
-    checkState(id == null, "cannot reuse DeleteDraftChangeOp");
-
-    Change change = ctx.getChange();
-    id = change.getId();
-
-    ReviewDb db = unwrap(ctx.getDb());
-    if (change.getStatus() != Change.Status.DRAFT) {
-      throw new ResourceConflictException("Change is not a draft: " + id);
-    }
-    if (!allowDrafts) {
-      throw new MethodNotAllowedException("Draft workflow is disabled");
-    }
-    if (!ctx.getControl().canDeleteDraft(ctx.getDb())) {
-      throw new AuthException("Not permitted to delete this draft change");
-    }
-    List<PatchSet> patchSets = ImmutableList.copyOf(
-        psUtil.byChange(ctx.getDb(), ctx.getNotes()));
-    for (PatchSet ps : patchSets) {
-      if (!ps.isDraft()) {
-        throw new ResourceConflictException("Cannot delete draft change " + id
-            + ": patch set " + ps.getPatchSetId() + " is not a draft");
-      }
-      accountPatchReviewStore.get().clearReviewed(ps.getId());
-    }
-
-    // Only delete from ReviewDb here; deletion from NoteDb is handled in
-    // BatchUpdate.
-    db.patchComments().delete(db.patchComments().byChange(id));
-    db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
-    db.patchSets().delete(db.patchSets().byChange(id));
-    db.changeMessages().delete(db.changeMessages().byChange(id));
-
-    // Non-atomic operation on Accounts table; not much we can do to make it
-    // atomic.
-    starredChangesUtil.unstarAll(change.getProject(), id);
-
-    ctx.deleteChange();
-    return true;
-  }
-
-  @Override
-  public void updateRepo(RepoContext ctx) throws IOException {
-    String prefix = new PatchSet.Id(id, 1).toRefName();
-    prefix = prefix.substring(0, prefix.length() - 1);
-    for (Ref ref
-        : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
-      ctx.addRefUpdate(
-          new ReceiveCommand(
-            ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 56dbfa7..7787260 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -14,52 +14,51 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteDraftComment.Input;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.Collections;
+import java.util.Optional;
 
 @Singleton
-public class DeleteDraftComment
-    implements RestModifyView<DraftCommentResource, Input> {
-  static class Input {
-  }
+public class DeleteDraftComment implements RestModifyView<DraftCommentResource, Input> {
+  static class Input {}
 
   private final Provider<ReviewDb> db;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory updateFactory;
   private final PatchListCache patchListCache;
 
   @Inject
-  DeleteDraftComment(Provider<ReviewDb> db,
-      PatchLineCommentsUtil plcUtil,
+  DeleteDraftComment(
+      Provider<ReviewDb> db,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       BatchUpdate.Factory updateFactory,
       PatchListCache patchListCache) {
     this.db = db;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.updateFactory = updateFactory;
     this.patchListCache = patchListCache;
@@ -68,40 +67,40 @@
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
-    try (BatchUpdate bu = updateFactory.create(
-        db.get(), rsrc.getChange().getProject(), rsrc.getControl().getUser(),
-        TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getComment().getKey());
+    try (BatchUpdate bu =
+        updateFactory.create(
+            db.get(),
+            rsrc.getChange().getProject(),
+            rsrc.getControl().getUser(),
+            TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getComment().key);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
     }
     return Response.none();
   }
 
-  private class Op extends BatchUpdate.Op {
-    private final PatchLineComment.Key key;
+  private class Op implements BatchUpdateOp {
+    private final Comment.Key key;
 
-    private Op(PatchLineComment.Key key) {
+    private Op(Comment.Key key) {
       this.key = key;
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException {
-      Optional<PatchLineComment> maybeComment =
-          plcUtil.get(ctx.getDb(), ctx.getNotes(), key);
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
+      Optional<Comment> maybeComment = commentsUtil.get(ctx.getDb(), ctx.getNotes(), key);
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
       }
-      PatchSet.Id psId = key.getParentKey().getParentKey();
+      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), key.patchSetId);
       PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      PatchLineComment c = maybeComment.get();
+      Comment c = maybeComment.get();
       setCommentRevId(c, patchListCache, ctx.getChange(), ps);
-      plcUtil.deleteComments(
-          ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
+      commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
       ctx.bumpLastUpdatedOn(false);
       return true;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
index 1cd8726..d452489 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -30,45 +32,46 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteDraftPatchSet.Input;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Order;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-import java.io.IOException;
-import java.util.Collection;
-
 @Singleton
-public class DeleteDraftPatchSet implements RestModifyView<RevisionResource, Input>,
-    UiAction<RevisionResource> {
-  public static class Input {
-  }
+public class DeleteDraftPatchSet
+    implements RestModifyView<RevisionResource, Input>, UiAction<RevisionResource> {
+  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory updateFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
-  private final Provider<DeleteDraftChangeOp> deleteChangeOpProvider;
+  private final Provider<DeleteChangeOp> deleteChangeOpProvider;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
   private final boolean allowDrafts;
 
   @Inject
-  public DeleteDraftPatchSet(Provider<ReviewDb> db,
+  public DeleteDraftPatchSet(
+      Provider<ReviewDb> db,
       BatchUpdate.Factory updateFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
-      Provider<DeleteDraftChangeOp> deleteChangeOpProvider,
+      Provider<DeleteChangeOp> deleteChangeOpProvider,
       DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
       @GerritServerConfig Config cfg) {
     this.db = db;
@@ -83,30 +86,31 @@
   @Override
   public Response<?> apply(RevisionResource rsrc, Input input)
       throws RestApiException, UpdateException {
-    try (BatchUpdate bu = updateFactory.create(
-        db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      bu.setOrder(BatchUpdate.Order.DB_BEFORE_REPO);
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.setOrder(Order.DB_BEFORE_REPO);
       bu.addOp(rsrc.getChange().getId(), new Op(rsrc.getPatchSet().getId()));
       bu.execute();
     }
     return Response.none();
   }
 
-  private class Op extends BatchUpdate.Op {
+  private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
 
     private Collection<PatchSet> patchSetsBeforeDeletion;
     private PatchSet patchSet;
-    private DeleteDraftChangeOp deleteChangeOp;
+    private DeleteChangeOp deleteChangeOp;
 
     private Op(PatchSet.Id psId) {
       this.psId = psId;
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws RestApiException,
-        OrmException, IOException, NoSuchChangeException {
-      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    public boolean updateChange(ChangeContext ctx)
+        throws RestApiException, OrmException, IOException, NoSuchChangeException {
+      Map<PatchSet.Id, PatchSet> patchSets = psUtil.byChangeAsMap(db.get(), ctx.getNotes());
+      patchSet = patchSets.get(psId);
       if (patchSet == null) {
         return false; // Nothing to do.
       }
@@ -116,13 +120,13 @@
       if (!allowDrafts) {
         throw new MethodNotAllowedException("Draft workflow is disabled");
       }
-      if (!ctx.getControl().canDeleteDraft(ctx.getDb())) {
+      if (!ctx.getControl().canDelete(ctx.getDb(), Change.Status.DRAFT)) {
         throw new AuthException("Not permitted to delete this draft patch set");
       }
 
-      patchSetsBeforeDeletion = psUtil.byChange(ctx.getDb(), ctx.getNotes());
+      patchSetsBeforeDeletion = patchSets.values();
       deleteDraftPatchSet(patchSet, ctx);
-      deleteOrUpdateDraftChange(ctx);
+      deleteOrUpdateDraftChange(ctx, patchSets);
       return true;
     }
 
@@ -139,23 +143,21 @@
               patchSet.getRefName()));
     }
 
-    private void deleteDraftPatchSet(PatchSet patchSet, ChangeContext ctx)
-        throws OrmException {
+    private void deleteDraftPatchSet(PatchSet patchSet, ChangeContext ctx) throws OrmException {
       // For NoteDb itself, no need to delete these entities, as they are
       // automatically filtered out when patch sets are deleted.
       psUtil.delete(ctx.getDb(), ctx.getUpdate(patchSet.getId()), patchSet);
 
       accountPatchReviewStore.get().clearReviewed(psId);
-      // Use the unwrap from DeleteDraftChangeOp to handle BatchUpdateReviewDb.
-      ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
+      // Use the unwrap from DeleteChangeOp to handle BatchUpdateReviewDb.
+      ReviewDb db = DeleteChangeOp.unwrap(ctx.getDb());
       db.changeMessages().delete(db.changeMessages().byPatchSet(psId));
       db.patchComments().delete(db.patchComments().byPatchSet(psId));
       db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
     }
 
-    private void deleteOrUpdateDraftChange(ChangeContext ctx)
-        throws OrmException, RestApiException, IOException,
-        NoSuchChangeException {
+    private void deleteOrUpdateDraftChange(ChangeContext ctx, Map<PatchSet.Id, PatchSet> patchSets)
+        throws OrmException, RestApiException, IOException, NoSuchChangeException {
       Change c = ctx.getChange();
       if (deletedOnlyPatchSet()) {
         deleteChangeOp = deleteChangeOpProvider.get();
@@ -163,7 +165,7 @@
         return;
       }
       if (c.currentPatchSetId().equals(psId)) {
-        c.setCurrentPatchSet(previousPatchSetInfo(ctx));
+        c.setCurrentPatchSet(previousPatchSetInfo(ctx, patchSets));
       }
     }
 
@@ -172,13 +174,22 @@
           && patchSetsBeforeDeletion.iterator().next().getId().equals(psId);
     }
 
-    private PatchSetInfo previousPatchSetInfo(ChangeContext ctx)
-        throws OrmException {
+    private PatchSetInfo previousPatchSetInfo(
+        ChangeContext ctx, Map<PatchSet.Id, PatchSet> patchSets) throws OrmException {
+      PatchSet.Id prevPsId = null;
+      for (PatchSet.Id id : patchSets.keySet()) {
+        if (id.get() < psId.get() && (prevPsId == null || id.get() > prevPsId.get())) {
+          prevPsId = id;
+        }
+      }
+
       try {
         // TODO(dborowitz): Get this in a way that doesn't involve re-opening
         // the repo after the updateRepo phase.
-        return patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(),
-            new PatchSet.Id(psId.getParentKey(), psId.get() - 1));
+        return patchSetInfoFactory.get(
+            ctx.getDb(),
+            ctx.getNotes(),
+            new PatchSet.Id(psId.getParentKey(), checkNotNull(prevPsId).get()));
       } catch (PatchSetInfoNotAvailableException e) {
         throw new OrmException(e);
       }
@@ -190,13 +201,13 @@
     try {
       int psCount = psUtil.byChange(db.get(), rsrc.getNotes()).size();
       return new UiAction.Description()
-        .setLabel("Delete")
-        .setTitle(String.format("Delete draft revision %d",
-            rsrc.getPatchSet().getPatchSetId()))
-        .setVisible(allowDrafts
-            && rsrc.getPatchSet().isDraft()
-            && rsrc.getControl().canDeleteDraft(db.get())
-            && psCount > 1);
+          .setLabel("Delete")
+          .setTitle(String.format("Delete draft revision %d", rsrc.getPatchSet().getPatchSetId()))
+          .setVisible(
+              allowDrafts
+                  && rsrc.getPatchSet().isDraft()
+                  && rsrc.getControl().canDelete(db.get(), Change.Status.DRAFT)
+                  && psCount > 1);
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
index bdefa93..1485d03 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Predicate;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -35,39 +35,34 @@
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.change.DeleteReviewer.Input;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdateReviewDb;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
-public class DeleteReviewer implements RestModifyView<ReviewerResource, Input> {
-  private static final Logger log = LoggerFactory
-      .getLogger(DeleteReviewer.class);
-
-  public static class Input {
-  }
+public class DeleteReviewer implements RestModifyView<ReviewerResource, DeleteReviewerInput> {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
 
   private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
@@ -79,9 +74,11 @@
   private final Provider<IdentifiedUser> user;
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
 
   @Inject
-  DeleteReviewer(Provider<ReviewDb> dbProvider,
+  DeleteReviewer(
+      Provider<ReviewDb> dbProvider,
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
@@ -90,7 +87,8 @@
       ReviewerDeleted reviewerDeleted,
       Provider<IdentifiedUser> user,
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotesMigration migration) {
+      NotesMigration migration,
+      NotifyUtil notifyUtil) {
     this.dbProvider = dbProvider;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
@@ -101,15 +99,26 @@
     this.user = user;
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.migration = migration;
+    this.notifyUtil = notifyUtil;
   }
 
   @Override
-  public Response<?> apply(ReviewerResource rsrc, Input input)
+  public Response<?> apply(ReviewerResource rsrc, DeleteReviewerInput input)
       throws RestApiException, UpdateException {
-    try (BatchUpdate bu = batchUpdateFactory.create(dbProvider.get(),
-        rsrc.getChangeResource().getProject(),
-        rsrc.getChangeResource().getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getReviewerUser().getAccount());
+    if (input == null) {
+      input = new DeleteReviewerInput();
+    }
+    if (input.notify == null) {
+      input.notify = NotifyHandling.ALL;
+    }
+
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            dbProvider.get(),
+            rsrc.getChangeResource().getProject(),
+            rsrc.getChangeResource().getUser(),
+            TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getReviewerUser().getAccount(), input);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
     }
@@ -117,25 +126,25 @@
     return Response.none();
   }
 
-  private class Op extends BatchUpdate.Op {
+  private class Op implements BatchUpdateOp {
     private final Account reviewer;
+    private final DeleteReviewerInput input;
     ChangeMessage changeMessage;
     Change currChange;
     PatchSet currPs;
-    List<PatchSetApproval> del = new ArrayList<>();
     Map<String, Short> newApprovals = new HashMap<>();
     Map<String, Short> oldApprovals = new HashMap<>();
 
-    Op(Account reviewerAccount) {
+    Op(Account reviewerAccount, DeleteReviewerInput input) {
       this.reviewer = reviewerAccount;
+      this.input = input;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws AuthException, ResourceNotFoundException, OrmException {
       Account.Id reviewerId = reviewer.getId();
-      if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all()
-          .contains(reviewerId)) {
+      if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
         throw new ResourceNotFoundException();
       }
       currChange = ctx.getChange();
@@ -148,61 +157,71 @@
       }
 
       StringBuilder msg = new StringBuilder();
+      msg.append("Removed reviewer " + reviewer.getFullName());
+      StringBuilder removedVotesMsg = new StringBuilder();
+      removedVotesMsg.append(" with the following votes:\n\n");
+      List<PatchSetApproval> del = new ArrayList<>();
+      boolean votesRemoved = false;
       for (PatchSetApproval a : approvals(ctx, reviewerId)) {
         if (ctx.getControl().canRemoveReviewer(a)) {
           del.add(a);
           if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
             oldApprovals.put(a.getLabel(), a.getValue());
-            if (msg.length() == 0) {
-              msg.append("Removed reviewer ").append(reviewer.getFullName())
-                  .append(" with the following votes:\n\n");
-            }
-            msg.append("* ").append(a.getLabel())
-                .append(formatLabelValue(a.getValue())).append(" by ")
+            removedVotesMsg
+                .append("* ")
+                .append(a.getLabel())
+                .append(formatLabelValue(a.getValue()))
+                .append(" by ")
                 .append(userFactory.create(a.getAccountId()).getNameEmail())
                 .append("\n");
+            votesRemoved = true;
           }
         } else {
           throw new AuthException("delete reviewer not permitted");
         }
       }
 
+      if (votesRemoved) {
+        msg.append(removedVotesMsg);
+      } else {
+        msg.append(".");
+      }
       ctx.getDb().patchSetApprovals().delete(del);
       ChangeUpdate update = ctx.getUpdate(currPs.getId());
       update.removeReviewer(reviewerId);
 
-      if (msg.length() > 0) {
-        changeMessage = new ChangeMessage(
-            new ChangeMessage.Key(currChange.getId(),
-                ChangeUtil.messageUUID(ctx.getDb())),
-            ctx.getAccountId(), ctx.getWhen(), currPs.getId());
-        changeMessage.setMessage(msg.toString());
-        cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
-      }
+      changeMessage =
+          ChangeMessagesUtil.newMessage(
+              ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
+      cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
 
       return true;
     }
 
     @Override
     public void postUpdate(Context ctx) {
-      if (changeMessage == null) {
-        return;
+      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+        emailReviewers(ctx.getProject(), currChange, changeMessage);
       }
-
-      emailReviewers(ctx.getProject(), currChange, del, changeMessage);
-      reviewerDeleted.fire(currChange, currPs, reviewer,
+      reviewerDeleted.fire(
+          currChange,
+          currPs,
+          reviewer,
           ctx.getAccount(),
           changeMessage.getMessage(),
-          newApprovals, oldApprovals,
+          newApprovals,
+          oldApprovals,
+          input.notify,
           ctx.getWhen());
     }
 
-    private Iterable<PatchSetApproval> approvals(ChangeContext ctx,
-        final Account.Id accountId) throws OrmException {
+    private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
+        throws OrmException {
       Change.Id changeId = ctx.getNotes().getChangeId();
       Iterable<PatchSetApproval> approvals;
+      PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
 
-      if (migration.readChanges()) {
+      if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
         // Because NoteDb and ReviewDb have different semantics for zero-value
         // approvals, we must fall back to ReviewDb as the source of truth here.
         ReviewDb db = ctx.getDb();
@@ -213,18 +232,10 @@
         db = ReviewDbUtil.unwrapDb(db);
         approvals = db.patchSetApprovals().byChange(changeId);
       } else {
-        approvals =
-            approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
+        approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
       }
 
-      return Iterables.filter(
-          approvals,
-          new Predicate<PatchSetApproval>() {
-            @Override
-            public boolean apply(PatchSetApproval input) {
-              return accountId.equals(input.getAccountId());
-            }
-          });
+      return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
     }
 
     private String formatLabelValue(short value) {
@@ -233,27 +244,21 @@
       }
       return Short.toString(value);
     }
-  }
 
-  private void emailReviewers(Project.NameKey projectName, Change change,
-      List<PatchSetApproval> dels, ChangeMessage changeMessage) {
-
-    // The user knows they removed themselves, don't bother emailing them.
-    List<Account.Id> toMail = Lists.newArrayListWithCapacity(dels.size());
-    Account.Id userId = user.get().getAccountId();
-    for (PatchSetApproval psa : dels) {
-      if (!psa.getAccountId().equals(userId)) {
-        toMail.add(psa.getAccountId());
+    private void emailReviewers(
+        Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
+      Account.Id userId = user.get().getAccountId();
+      if (userId.equals(reviewer.getId())) {
+        // The user knows they removed themselves, don't bother emailing them.
+        return;
       }
-    }
-    if (!toMail.isEmpty()) {
       try {
-        DeleteReviewerSender cm =
-            deleteReviewerSenderFactory.create(projectName, change.getId());
+        DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
         cm.setFrom(userId);
-        cm.addReviewers(toMail);
-        cm.setChangeMessage(changeMessage.getMessage(),
-            changeMessage.getWrittenOn());
+        cm.addReviewers(Collections.singleton(reviewer.getId()));
+        cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+        cm.setNotify(input.notify);
+        cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
         cm.send();
       } catch (Exception err) {
         log.error("Cannot email update for change " + change.getId(), err);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
index f1bdba5..e02dee9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -28,37 +30,36 @@
 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.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.DeleteVoteSender;
-import com.google.gerrit.server.mail.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
-public class DeleteVote
-    implements RestModifyView<VoteResource, DeleteVoteInput> {
+public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput> {
   private static final Logger log = LoggerFactory.getLogger(DeleteVote.class);
 
   private final Provider<ReviewDb> db;
@@ -69,16 +70,19 @@
   private final IdentifiedUser.GenericFactory userFactory;
   private final VoteDeleted voteDeleted;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+  private final NotifyUtil notifyUtil;
 
   @Inject
-  DeleteVote(Provider<ReviewDb> db,
+  DeleteVote(
+      Provider<ReviewDb> db,
       BatchUpdate.Factory batchUpdateFactory,
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
       IdentifiedUser.GenericFactory userFactory,
       VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory) {
+      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      NotifyUtil notifyUtil) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
     this.approvalsUtil = approvalsUtil;
@@ -87,6 +91,7 @@
     this.userFactory = userFactory;
     this.voteDeleted = voteDeleted;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.notifyUtil = notifyUtil;
   }
 
   @Override
@@ -103,18 +108,23 @@
     }
     ReviewerResource r = rsrc.getReviewer();
     Change change = r.getChange();
-    try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
-          change.getProject(), r.getControl().getUser(), TimeUtil.nowTs())) {
-      bu.addOp(change.getId(),
-          new Op(r.getReviewerUser().getAccountId(), rsrc.getLabel(), input));
+
+    if (r.getRevisionResource() != null && !r.getRevisionResource().isCurrent()) {
+      throw new MethodNotAllowedException("Cannot delete vote on non-current patch set");
+    }
+
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db.get(), change.getProject(), r.getControl().getUser(), TimeUtil.nowTs())) {
+      bu.addOp(change.getId(), new Op(r.getReviewerUser().getAccount(), rsrc.getLabel(), input));
       bu.execute();
     }
 
     return Response.none();
   }
 
-  private class Op extends BatchUpdate.Op {
-    private final Account.Id accountId;
+  private class Op implements BatchUpdateOp {
+    private final Account account;
     private final String label;
     private final DeleteVoteInput input;
     private ChangeMessage changeMessage;
@@ -123,8 +133,8 @@
     private Map<String, Short> newApprovals = new HashMap<>();
     private Map<String, Short> oldApprovals = new HashMap<>();
 
-    private Op(Account.Id accountId, String label, DeleteVoteInput input) {
-      this.accountId = accountId;
+    private Op(Account account, String label, DeleteVoteInput input) {
+      this.account = account;
       this.label = label;
       this.input = input;
     }
@@ -137,64 +147,56 @@
       PatchSet.Id psId = change.currentPatchSetId();
       ps = psUtil.current(db.get(), ctl.getNotes());
 
-      PatchSetApproval psa = null;
-      StringBuilder msg = new StringBuilder();
-
-      // get all of the current approvals
+      boolean found = false;
       LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      Map<String, Short> currentApprovals = new HashMap<>();
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        currentApprovals.put(lt.getName(), (short) 0);
-        for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
-            ctx.getDb(), ctl, psId, accountId)) {
-          if (lt.getLabelId().equals(a.getLabelId())) {
-            currentApprovals.put(lt.getName(), a.getValue());
-          }
-        }
-      }
-      // removing votes so we need to determine the new set of approval scores
-      newApprovals.putAll(currentApprovals);
-      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
-            ctx.getDb(), ctl, psId, accountId)) {
-        if (ctl.canRemoveReviewer(a)) {
-          if (a.getLabel().equals(label)) {
-            // set the approval to 0 if vote is being removed
-            newApprovals.put(a.getLabel(), (short) 0);
-            // set old value only if the vote changed
-            oldApprovals.put(a.getLabel(), a.getValue());
-            msg.append("Removed ")
-                .append(a.getLabel()).append(formatLabelValue(a.getValue()))
-                .append(" by ").append(userFactory.create(a.getAccountId())
-                    .getNameEmail())
-                .append("\n");
-            psa = a;
-            a.setValue((short)0);
-            ctx.getUpdate(psId).removeApprovalFor(a.getAccountId(), label);
-            break;
-          }
-        } else {
+
+      for (PatchSetApproval a :
+          approvalsUtil.byPatchSetUser(ctx.getDb(), ctl, psId, account.getId())) {
+        if (labelTypes.byLabel(a.getLabelId()) == null) {
+          continue; // Ignore undefined labels.
+        } else if (!a.getLabel().equals(label)) {
+          // Populate map for non-matching labels, needed by VoteDeleted.
+          newApprovals.put(a.getLabel(), a.getValue());
+          continue;
+        } else if (!ctl.canRemoveReviewer(a)) {
           throw new AuthException("delete vote not permitted");
         }
+        // Set the approval to 0 if vote is being removed.
+        newApprovals.put(a.getLabel(), (short) 0);
+        found = true;
+
+        // Set old value, as required by VoteDeleted.
+        oldApprovals.put(a.getLabel(), a.getValue());
+        break;
       }
-      if (psa == null) {
+      if (!found) {
         throw new ResourceNotFoundException();
       }
-      ctx.getDb().patchSetApprovals().update(Collections.singleton(psa));
 
-      if (msg.length() > 0) {
-        changeMessage =
-            new ChangeMessage(new ChangeMessage.Key(change.getId(),
-                ChangeUtil.messageUUID(ctx.getDb())),
-                ctx.getAccountId(),
-                ctx.getWhen(),
-                change.currentPatchSetId());
-        changeMessage.setMessage(msg.toString());
-        cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId),
-            changeMessage);
-      }
+      ctx.getUpdate(psId).removeApprovalFor(account.getId(), label);
+      ctx.getDb().patchSetApprovals().upsert(Collections.singleton(deletedApproval(ctx)));
+
+      StringBuilder msg = new StringBuilder();
+      msg.append("Removed ");
+      LabelVote.appendTo(msg, label, checkNotNull(oldApprovals.get(label)));
+      msg.append(" by ").append(userFactory.create(account.getId()).getNameEmail()).append("\n");
+      changeMessage =
+          ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
+
       return true;
     }
 
+    private PatchSetApproval deletedApproval(ChangeContext ctx) {
+      // Set the effective user to the account we're trying to remove, and don't
+      // set the real user; this preserves the calling user as the NoteDb
+      // committer.
+      return new PatchSetApproval(
+          new PatchSetApproval.Key(ps.getId(), account.getId(), new LabelId(label)),
+          (short) 0,
+          ctx.getWhen());
+    }
+
     @Override
     public void postUpdate(Context ctx) {
       if (changeMessage == null) {
@@ -202,29 +204,29 @@
       }
 
       IdentifiedUser user = ctx.getIdentifiedUser();
-      if (input.notify.compareTo(NotifyHandling.NONE) > 0) {
+      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
         try {
-          ReplyToChangeSender cm = deleteVoteSenderFactory.create(
-              ctx.getProject(), change.getId());
+          ReplyToChangeSender cm = deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
           cm.setFrom(user.getAccountId());
           cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
           cm.setNotify(input.notify);
+          cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
           cm.send();
         } catch (Exception e) {
           log.error("Cannot email update for change " + change.getId(), e);
         }
       }
 
-      voteDeleted.fire(change, ps,
-          newApprovals, oldApprovals, input.notify, changeMessage.getMessage(),
-          user.getAccount(), ctx.getWhen());
+      voteDeleted.fire(
+          change,
+          ps,
+          account,
+          newApprovals,
+          oldApprovals,
+          input.notify,
+          changeMessage.getMessage(),
+          user.getAccount(),
+          ctx.getWhen());
     }
   }
-
-  private static String formatLabelValue(short value) {
-    if (value > 0) {
-      return "+" + value;
-    }
-    return Short.toString(value);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java
index 24c2f0e..827dfcd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java
@@ -21,12 +21,10 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-
 public class DownloadContent implements RestReadView<FileResource> {
   private final FileContentUtil fileContentUtil;
 
@@ -40,13 +38,11 @@
 
   @Override
   public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, NoSuchChangeException,
-      OrmException {
+      throws ResourceNotFoundException, IOException, NoSuchChangeException, OrmException {
     String path = rsrc.getPatchKey().get();
     ProjectState projectState =
         rsrc.getRevision().getControl().getProjectControl().getProjectState();
-    ObjectId revstr = ObjectId.fromString(
-        rsrc.getRevision().getPatchSet().getRevision().get());
+    ObjectId revstr = ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get());
     return fileContentUtil.downloadContent(projectState, revstr, path, parent);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
index 3dc0c78..781216c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
@@ -28,9 +28,9 @@
       new TypeLiteral<RestView<DraftCommentResource>>() {};
 
   private final RevisionResource rev;
-  private final PatchLineComment comment;
+  private final Comment comment;
 
-  public DraftCommentResource(RevisionResource rev, PatchLineComment c) {
+  public DraftCommentResource(RevisionResource rev, Comment c) {
     this.rev = rev;
     this.comment = c;
   }
@@ -47,12 +47,12 @@
     return rev.getPatchSet();
   }
 
-  PatchLineComment getComment() {
+  Comment getComment() {
     return comment;
   }
 
   String getId() {
-    return comment.getKey().get();
+    return comment.key.uuid;
   }
 
   Account.Id getAuthorId() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java
index acb50ac..4befc5b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftComments.java
@@ -20,10 +20,10 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,19 +35,20 @@
   private final Provider<CurrentUser> user;
   private final ListRevisionDrafts list;
   private final Provider<ReviewDb> dbProvider;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
-  DraftComments(DynamicMap<RestView<DraftCommentResource>> views,
+  DraftComments(
+      DynamicMap<RestView<DraftCommentResource>> views,
       Provider<CurrentUser> user,
       ListRevisionDrafts list,
       Provider<ReviewDb> dbProvider,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.views = views;
     this.user = user;
     this.list = list;
     this.dbProvider = dbProvider;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
@@ -66,9 +67,10 @@
       throws ResourceNotFoundException, OrmException, AuthException {
     checkIdentifiedUser();
     String uuid = id.get();
-    for (PatchLineComment c : plcUtil.draftByPatchSetAuthor(dbProvider.get(),
-        rev.getPatchSet().getId(), rev.getAccountId(), rev.getNotes())) {
-      if (uuid.equals(c.getKey().get())) {
+    for (Comment c :
+        commentsUtil.draftByPatchSetAuthor(
+            dbProvider.get(), rev.getPatchSet().getId(), rev.getAccountId(), rev.getNotes())) {
+      if (uuid.equals(c.key.uuid)) {
         return new DraftCommentResource(rev, c);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
index 390f416..2e8fc2d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -14,19 +14,24 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.SendEmailExecutor;
-import com.google.gerrit.server.mail.CommentSender;
+import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gwtorm.server.OrmException;
@@ -35,24 +40,26 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.assistedinject.Assisted;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.List;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class EmailReviewComments implements Runnable, RequestContext {
   private static final Logger log = LoggerFactory.getLogger(EmailReviewComments.class);
 
-  interface Factory {
+  public interface Factory {
     EmailReviewComments create(
         NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify,
         ChangeNotes notes,
         PatchSet patchSet,
         IdentifiedUser user,
         ChangeMessage message,
-        List<PatchLineComment> comments);
+        List<Comment> comments,
+        String patchSetComment,
+        List<LabelVote> labels);
   }
 
   private final ExecutorService sendEmailsExecutor;
@@ -62,11 +69,14 @@
   private final ThreadLocalRequestContext requestContext;
 
   private final NotifyHandling notify;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
   private final ChangeNotes notes;
   private final PatchSet patchSet;
   private final IdentifiedUser user;
   private final ChangeMessage message;
-  private List<PatchLineComment> comments;
+  private final List<Comment> comments;
+  private final String patchSetComment;
+  private final List<LabelVote> labels;
   private ReviewDb db;
 
   @Inject
@@ -77,26 +87,33 @@
       SchemaFactory<ReviewDb> schemaFactory,
       ThreadLocalRequestContext requestContext,
       @Assisted NotifyHandling notify,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
       @Assisted ChangeMessage message,
-      @Assisted List<PatchLineComment> comments) {
+      @Assisted List<Comment> comments,
+      @Nullable @Assisted String patchSetComment,
+      @Assisted List<LabelVote> labels) {
     this.sendEmailsExecutor = executor;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commentSenderFactory = commentSenderFactory;
     this.schemaFactory = schemaFactory;
     this.requestContext = requestContext;
     this.notify = notify;
+    this.accountsToNotify = accountsToNotify;
     this.notes = notes;
     this.patchSet = patchSet;
     this.user = user;
     this.message = message;
-    this.comments = PLC_ORDER.sortedCopy(comments);
+    this.comments = COMMENT_ORDER.sortedCopy(comments);
+    this.patchSetComment = patchSetComment;
+    this.labels = labels;
   }
 
-  void sendAsync() {
-    sendEmailsExecutor.submit(this);
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
   }
 
   @Override
@@ -104,14 +121,15 @@
     RequestContext old = requestContext.setContext(this);
     try {
 
-      CommentSender cm = commentSenderFactory.create(notes.getProjectName(),
-          notes.getChangeId());
+      CommentSender cm = commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
       cm.setFrom(user.getAccountId());
-      cm.setPatchSet(patchSet,
-          patchSetInfoFactory.get(notes.getProjectName(), patchSet));
+      cm.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
       cm.setChangeMessage(message.getMessage(), message.getWrittenOn());
-      cm.setPatchLineComments(comments);
+      cm.setComments(comments);
+      cm.setPatchSetComment(patchSetComment);
+      cm.setLabels(labels);
       cm.setNotify(notify);
+      cm.setAccountsToNotify(accountsToNotify);
       cm.send();
     } catch (Exception e) {
       log.error("Cannot email comments for " + patchSet.getId(), e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
index d145ddf..cda2c11 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -30,9 +30,12 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import eu.medsea.mimeutil.MimeType;
-
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Random;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
@@ -45,15 +48,10 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.util.NB;
 
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Random;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-
 @Singleton
 public class FileContentUtil {
   public static final String TEXT_X_GERRIT_COMMIT_MESSAGE = "text/x-gerrit-commit-message";
+  public static final String TEXT_X_GERRIT_MERGE_LIST = "text/x-gerrit-merge-list";
   private static final String X_GIT_SYMLINK = "x-git/symlink";
   private static final String X_GIT_GITLINK = "x-git/gitlink";
   private static final int MAX_SIZE = 5 << 20;
@@ -64,14 +62,13 @@
   private final FileTypeRegistry registry;
 
   @Inject
-  FileContentUtil(GitRepositoryManager repoManager,
-      FileTypeRegistry ftr) {
+  FileContentUtil(GitRepositoryManager repoManager, FileTypeRegistry ftr) {
     this.repoManager = repoManager;
     this.registry = ftr;
   }
 
-  public BinaryResult getContent(ProjectState project, ObjectId revstr,
-      String path) throws ResourceNotFoundException, IOException {
+  public BinaryResult getContent(ProjectState project, ObjectId revstr, String path)
+      throws ResourceNotFoundException, IOException {
     try (Repository repo = openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(revstr);
@@ -84,9 +81,7 @@
       org.eclipse.jgit.lib.FileMode mode = tw.getFileMode(0);
       ObjectId id = tw.getObjectId(0);
       if (mode == org.eclipse.jgit.lib.FileMode.GITLINK) {
-        return BinaryResult.create(id.name())
-            .setContentType(X_GIT_GITLINK)
-            .base64();
+        return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64();
       }
 
       ObjectLoader obj = repo.open(id, OBJ_BLOB);
@@ -109,24 +104,24 @@
     }
   }
 
-  private static BinaryResult asBinaryResult(byte[] raw,
-      final ObjectLoader obj) {
+  private static BinaryResult asBinaryResult(byte[] raw, final ObjectLoader obj) {
     if (raw != null) {
       return BinaryResult.create(raw);
     }
-    BinaryResult result = new BinaryResult() {
-      @Override
-      public void writeTo(OutputStream os) throws IOException {
-        obj.copyTo(os);
-      }
-    };
+    BinaryResult result =
+        new BinaryResult() {
+          @Override
+          public void writeTo(OutputStream os) throws IOException {
+            obj.copyTo(os);
+          }
+        };
     result.setContentLength(obj.getSize());
     return result;
   }
 
-  public BinaryResult downloadContent(ProjectState project, ObjectId revstr,
-      String path, @Nullable Integer parent)
-          throws ResourceNotFoundException, IOException {
+  public BinaryResult downloadContent(
+      ProjectState project, ObjectId revstr, String path, @Nullable Integer parent)
+      throws ResourceNotFoundException, IOException {
     try (Repository repo = openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       String suffix = "new";
@@ -166,16 +161,20 @@
     }
   }
 
-  private BinaryResult wrapBlob(String path, final ObjectLoader obj, byte[] raw,
-      MimeType contentType, @Nullable String suffix) {
+  private BinaryResult wrapBlob(
+      String path,
+      final ObjectLoader obj,
+      byte[] raw,
+      MimeType contentType,
+      @Nullable String suffix) {
     return asBinaryResult(raw, obj)
         .setContentType(contentType.toString())
         .setAttachmentName(safeFileName(path, suffix));
   }
 
   @SuppressWarnings("resource")
-  private BinaryResult zipBlob(final String path, final ObjectLoader obj,
-      RevCommit commit, @Nullable final String suffix) {
+  private BinaryResult zipBlob(
+      final String path, final ObjectLoader obj, RevCommit commit, @Nullable final String suffix) {
     final String commitName = commit.getName();
     final long when = commit.getCommitTime() * 1000L;
     return new BinaryResult() {
@@ -195,9 +194,7 @@
           zipOut.closeEntry();
         }
       }
-    }.setContentType(ZIP_TYPE)
-        .setAttachmentName(safeFileName(path, suffix) + ".zip")
-        .disableGzip();
+    }.setContentType(ZIP_TYPE).setAttachmentName(safeFileName(path, suffix) + ".zip").disableGzip();
   }
 
   private static String safeFileName(String fileName, @Nullable String suffix) {
@@ -234,11 +231,11 @@
     } else if (ext <= 0) {
       return fileName + "_" + suffix;
     } else {
-      return fileName.substring(0, ext) + "_" + suffix
-          + fileName.substring(ext);
+      return fileName.substring(0, ext) + "_" + suffix + fileName.substring(ext);
     }
   }
 
+  @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
   private static String randSuffix() {
     // Produce a random suffix that is difficult (or nearly impossible)
     // for an attacker to guess in advance. This reduces the risk that
@@ -257,13 +254,16 @@
     return h.hash().toString();
   }
 
-  public static String resolveContentType(ProjectState project, String path,
-      FileMode fileMode, String mimeType) {
+  public static String resolveContentType(
+      ProjectState project, String path, FileMode fileMode, String mimeType) {
     switch (fileMode) {
       case FILE:
         if (Patch.COMMIT_MSG.equals(path)) {
           return TEXT_X_GERRIT_COMMIT_MESSAGE;
         }
+        if (Patch.MERGE_LIST.equals(path)) {
+          return TEXT_X_GERRIT_MERGE_LIST;
+        }
         if (project != null) {
           for (ProjectState p : project.tree()) {
             String t = p.getConfig().getMimeTypes().getMimeType(path);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
index e0591f4..60a4daf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.util.GitUtil.getParent;
-
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -23,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -31,25 +28,16 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-
-import java.io.IOException;
 import java.util.Map;
 import java.util.TreeMap;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class FileInfoJson {
   private final PatchListCache patchListCache;
-  private final GitRepositoryManager repoManager;
 
   @Inject
-  FileInfoJson(
-      PatchListCache patchListCache,
-      GitRepositoryManager repoManager) {
-    this.repoManager = repoManager;
+  FileInfoJson(PatchListCache patchListCache) {
     this.patchListCache = patchListCache;
   }
 
@@ -60,34 +48,27 @@
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
       throws PatchListNotAvailableException {
-    ObjectId a = (base == null)
-        ? null
-        : ObjectId.fromString(base.getRevision().get());
+    ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
     ObjectId b = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, a, b);
+    return toFileInfoMap(change, new PatchListKey(a, b, Whitespace.IGNORE_NONE));
   }
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
-      throws RepositoryNotFoundException, IOException,
-          PatchListNotAvailableException {
+      throws PatchListNotAvailableException {
     ObjectId b = ObjectId.fromString(revision.get());
-    ObjectId a;
-    try (Repository git = repoManager.openRepository(change.getProject())) {
-      a = getParent(git, b, parent);
-    }
-    return toFileInfoMap(change, a, b);
+    return toFileInfoMap(
+        change, PatchListKey.againstParentNum(parent + 1, b, Whitespace.IGNORE_NONE));
   }
 
-  private Map<String, FileInfo> toFileInfoMap(Change change,
-      ObjectId a, ObjectId b) throws PatchListNotAvailableException {
-    PatchList list = patchListCache.get(
-        new PatchListKey(a, b, Whitespace.IGNORE_NONE), change.getProject());
+  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
+      throws PatchListNotAvailableException {
+    PatchList list = patchListCache.get(key, change.getProject());
 
     Map<String, FileInfo> files = new TreeMap<>();
     for (PatchListEntry e : list.getPatches()) {
       FileInfo d = new FileInfo();
-      d.status = e.getChangeType() != Patch.ChangeType.MODIFIED
-          ? e.getChangeType().getCode() : null;
+      d.status =
+          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
       d.oldPath = e.getOldName();
       d.sizeDelta = e.getSizeDelta();
       d.size = e.getSize();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
index dd584cf..040b6de 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -44,7 +43,15 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -57,15 +64,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-
 @Singleton
 public class Files implements ChildCollection<RevisionResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
@@ -117,7 +115,8 @@
     private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
-    ListFiles(Provider<ReviewDb> db,
+    ListFiles(
+        Provider<ReviewDb> db,
         Provider<CurrentUser> self,
         FileInfoJson fileInfoJson,
         Revisions revisions,
@@ -141,9 +140,9 @@
     }
 
     @Override
-    public Response<?> apply(RevisionResource resource) throws AuthException,
-        BadRequestException, ResourceNotFoundException, OrmException,
-        RepositoryNotFoundException, IOException {
+    public Response<?> apply(RevisionResource resource)
+        throws AuthException, BadRequestException, ResourceNotFoundException, OrmException,
+            RepositoryNotFoundException, IOException, PatchListNotAvailableException {
       checkOptions();
       if (reviewed) {
         return Response.ok(reviewed(resource));
@@ -152,26 +151,22 @@
       }
 
       Response<Map<String, FileInfo>> r;
-      try {
-        if (base != null) {
-          RevisionResource baseResource = revisions.parse(
-              resource.getChangeResource(), IdString.fromDecoded(base));
-          r = Response.ok(fileInfoJson.toFileInfoMap(
-              resource.getChange(),
-              resource.getPatchSet().getRevision(),
-              baseResource.getPatchSet()));
-        } else if (parentNum > 0) {
-          r = Response.ok(fileInfoJson.toFileInfoMap(
-              resource.getChange(),
-              resource.getPatchSet().getRevision(),
-              parentNum - 1));
-        } else {
-          r = Response.ok(fileInfoJson.toFileInfoMap(
-              resource.getChange(),
-              resource.getPatchSet()));
-        }
-      } catch (PatchListNotAvailableException e) {
-        throw new ResourceNotFoundException(e.getMessage());
+      if (base != null) {
+        RevisionResource baseResource =
+            revisions.parse(resource.getChangeResource(), IdString.fromDecoded(base));
+        r =
+            Response.ok(
+                fileInfoJson.toFileInfoMap(
+                    resource.getChange(),
+                    resource.getPatchSet().getRevision(),
+                    baseResource.getPatchSet()));
+      } else if (parentNum > 0) {
+        r =
+            Response.ok(
+                fileInfoJson.toFileInfoMap(
+                    resource.getChange(), resource.getPatchSet().getRevision(), parentNum - 1));
+      } else {
+        r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
 
       if (resource.isCacheable()) {
@@ -195,8 +190,7 @@
         supplied++;
       }
       if (supplied > 1) {
-        throw new BadRequestException(
-            "cannot combine base, parent, reviewed, query");
+        throw new BadRequestException("cannot combine base, parent, reviewed, query");
       }
     }
 
@@ -207,8 +201,8 @@
           ObjectReader or = git.newObjectReader();
           RevWalk rw = new RevWalk(or);
           TreeWalk tw = new TreeWalk(or)) {
-        RevCommit c = rw.parseCommit(
-            ObjectId.fromString(resource.getPatchSet().getRevision().get()));
+        RevCommit c =
+            rw.parseCommit(ObjectId.fromString(resource.getPatchSet().getRevision().get()));
 
         tw.addTree(c.getTree());
         tw.setRecursive(true);
@@ -232,8 +226,8 @@
 
       Account.Id userId = user.getAccountId();
       PatchSet patchSetId = resource.getPatchSet();
-      Optional<PatchSetWithReviewedFiles> o = accountPatchReviewStore.get()
-          .findReviewed(patchSetId.getId(), userId);
+      Optional<PatchSetWithReviewedFiles> o =
+          accountPatchReviewStore.get().findReviewed(patchSetId.getId(), userId);
 
       if (o.isPresent()) {
         PatchSetWithReviewedFiles res = o.get();
@@ -242,8 +236,7 @@
         }
 
         try {
-          return copy(res.files(), res.patchSetId(), resource,
-              userId);
+          return copy(res.files(), res.patchSetId(), resource, userId);
         } catch (IOException | PatchListNotAvailableException e) {
           log.warn("Cannot copy patch review flags", e);
         }
@@ -252,9 +245,9 @@
       return Collections.emptyList();
     }
 
-    private List<String> copy(Set<String> paths, PatchSet.Id old,
-        RevisionResource resource, Account.Id userId) throws IOException,
-        PatchListNotAvailableException, OrmException {
+    private List<String> copy(
+        Set<String> paths, PatchSet.Id old, RevisionResource resource, Account.Id userId)
+        throws IOException, PatchListNotAvailableException, OrmException {
       Project.NameKey project = resource.getChange().getProject();
       try (Repository git = gitManager.openRepository(project);
           ObjectReader reader = git.newObjectReader();
@@ -265,8 +258,7 @@
         if (patchSet == null) {
           throw new PatchListNotAvailableException(
               String.format(
-                  "patch set %s of change %s not found",
-                  old.get(), change.getId().get()));
+                  "patch set %s of change %s not found", old.get(), change.getId().get()));
         }
 
         PatchList oldList = patchListCache.get(change, patchSet);
@@ -293,15 +285,19 @@
 
         while (tw.next()) {
           String path = tw.getPathString();
-          if (tw.getRawMode(o) != 0 && tw.getRawMode(c) != 0
+          if (tw.getRawMode(o) != 0
+              && tw.getRawMode(c) != 0
               && tw.idEqual(o, c)
               && paths.contains(path)) {
             // File exists in previously reviewed oldList and in curList.
             // File content is identical.
             pathList.add(path);
-          } else if (op >= 0 && cp >= 0
-              && tw.getRawMode(o) == 0 && tw.getRawMode(c) == 0
-              && tw.getRawMode(op) != 0 && tw.getRawMode(cp) != 0
+          } else if (op >= 0
+              && cp >= 0
+              && tw.getRawMode(o) == 0
+              && tw.getRawMode(c) == 0
+              && tw.getRawMode(op) != 0
+              && tw.getRawMode(cp) != 0
               && tw.idEqual(op, cp)
               && paths.contains(path)) {
             // File was deleted in previously reviewed oldList and curList.
@@ -310,7 +306,8 @@
             pathList.add(path);
           }
         }
-        accountPatchReviewStore.get()
+        accountPatchReviewStore
+            .get()
             .markReviewed(resource.getPatchSet().getId(), userId, pathList);
         return pathList;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
index a2fd004..371127b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
@@ -14,19 +14,15 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.io.OutputStream;
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -35,50 +31,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-
 public class GetArchive implements RestReadView<RevisionResource> {
-  @Singleton
-  public static class AllowedFormats {
-    final ImmutableMap<String, ArchiveFormat> extensions;
-    final Set<ArchiveFormat> allowed;
-
-    @Inject
-    AllowedFormats(DownloadConfig cfg) {
-      Map<String, ArchiveFormat> exts = new HashMap<>();
-      for (ArchiveFormat format : cfg.getArchiveFormats()) {
-        for (String ext : format.getSuffixes()) {
-          exts.put(ext, format);
-        }
-        exts.put(format.name().toLowerCase(), format);
-      }
-      extensions = ImmutableMap.copyOf(exts);
-
-      // Zip is not supported because it may be interpreted by a Java plugin as a
-      // valid JAR file, whose code would have access to cookies on the domain.
-      allowed = Sets.filter(
-          cfg.getArchiveFormats(),
-          new Predicate<ArchiveFormat>() {
-            @Override
-            public boolean apply(ArchiveFormat format) {
-              return (format != ArchiveFormat.ZIP);
-            }
-          });
-    }
-
-    public Set<ArchiveFormat> getAllowed() {
-      return allowed;
-    }
-
-    public ImmutableMap<String, ArchiveFormat> getExtensions() {
-      return extensions;
-    }
-  }
-
   private final GitRepositoryManager repoManager;
   private final AllowedFormats allowedFormats;
 
@@ -92,8 +45,8 @@
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc) throws BadRequestException,
-      IOException, MethodNotAllowedException {
+  public BinaryResult apply(RevisionResource rsrc)
+      throws BadRequestException, IOException, MethodNotAllowedException {
     if (Strings.isNullOrEmpty(format)) {
       throw new BadRequestException("format is not specified");
     }
@@ -105,40 +58,37 @@
       throw new MethodNotAllowedException("zip format is disabled");
     }
     boolean close = true;
-    final Repository repo = repoManager
-        .openRepository(rsrc.getControl().getProject().getNameKey());
+    final Repository repo = repoManager.openRepository(rsrc.getControl().getProject().getNameKey());
     try {
       final RevCommit commit;
       String name;
       try (RevWalk rw = new RevWalk(repo)) {
-        commit = rw.parseCommit(ObjectId.fromString(
-            rsrc.getPatchSet().getRevision().get()));
+        commit = rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
         name = name(f, rw, commit);
       }
 
-      BinaryResult bin = new BinaryResult() {
-        @Override
-        public void writeTo(OutputStream out) throws IOException {
-          try {
-            new ArchiveCommand(repo)
-                .setFormat(f.name())
-                .setTree(commit.getTree())
-                .setOutputStream(out)
-                .call();
-          } catch (GitAPIException e) {
-            throw new IOException(e);
-          }
-        }
+      BinaryResult bin =
+          new BinaryResult() {
+            @Override
+            public void writeTo(OutputStream out) throws IOException {
+              try {
+                new ArchiveCommand(repo)
+                    .setFormat(f.name())
+                    .setTree(commit.getTree())
+                    .setOutputStream(out)
+                    .call();
+              } catch (GitAPIException e) {
+                throw new IOException(e);
+              }
+            }
 
-        @Override
-        public void close() throws IOException {
-          repo.close();
-        }
-      };
+            @Override
+            public void close() throws IOException {
+              repo.close();
+            }
+          };
 
-      bin.disableGzip()
-          .setContentType(f.getMimeType())
-          .setAttachmentName(name);
+      bin.disableGzip().setContentType(f.getMimeType()).setAttachmentName(name);
 
       close = false;
       return bin;
@@ -151,8 +101,7 @@
 
   private static String name(ArchiveFormat format, RevWalk rw, RevCommit commit)
       throws IOException {
-    return String.format("%s%s",
-        rw.getObjectReader().abbreviate(commit,7).name(),
-        format.getDefaultSuffix());
+    return String.format(
+        "%s%s", rw.getObjectReader().abbreviate(commit, 7).name(), format.getDefaultSuffix());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java
new file mode 100644
index 0000000..d491b91
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetAssignee.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+@Singleton
+public class GetAssignee implements RestReadView<ChangeResource> {
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  GetAssignee(AccountLoader.Factory accountLoaderFactory) {
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource rsrc) throws OrmException {
+    Optional<Account.Id> assignee = Optional.ofNullable(rsrc.getChange().getAssignee());
+    if (assignee.isPresent()) {
+      return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.get()));
+    }
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
index 0f0f5a6..1dcdbb8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.extensions.common.BlameInfo;
 import com.google.gerrit.extensions.common.RangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -34,10 +34,14 @@
 import com.google.gitiles.blame.Region;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -46,11 +50,6 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
 public class GetBlame implements RestReadView<FileResource> {
 
   private final GitRepositoryManager repoManager;
@@ -59,13 +58,17 @@
   private final ThreeWayMergeStrategy mergeStrategy;
   private final AutoMerger autoMerger;
 
-  @Option(name = "--base", aliases = {"-b"},
-    usage = "whether to load the blame of the base revision (the direct"
-      + " parent of the change) instead of the change")
+  @Option(
+      name = "--base",
+      aliases = {"-b"},
+      usage =
+          "whether to load the blame of the base revision (the direct"
+              + " parent of the change) instead of the change")
   private boolean base;
 
   @Inject
-  GetBlame(GitRepositoryManager repoManager,
+  GetBlame(
+      GitRepositoryManager repoManager,
       BlameCache blameCache,
       @GerritServerConfig Config cfg,
       AutoMerger autoMerger) {
@@ -78,8 +81,7 @@
 
   @Override
   public Response<List<BlameInfo>> apply(FileResource resource)
-      throws RestApiException, OrmException, IOException,
-      InvalidChangeOperationException {
+      throws RestApiException, OrmException, IOException, InvalidChangeOperationException {
     if (!allowBlame) {
       throw new BadRequestException("blame is disabled");
     }
@@ -87,10 +89,12 @@
     Project.NameKey project = resource.getRevision().getChange().getProject();
     try (Repository repository = repoManager.openRepository(project);
         ObjectInserter ins = repository.newObjectInserter();
-        RevWalk revWalk = new RevWalk(ins.newReader())) {
-      String refName = resource.getRevision().getEdit().isPresent()
-          ? resource.getRevision().getEdit().get().getRefName()
-          : resource.getRevision().getPatchSet().getRefName();
+        ObjectReader reader = ins.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+      String refName =
+          resource.getRevision().getEdit().isPresent()
+              ? resource.getRevision().getEdit().get().getRefName()
+              : resource.getRevision().getPatchSet().getRefName();
 
       Ref ref = repository.findRef(refName);
       if (ref == null) {
@@ -113,8 +117,7 @@
         result = blame(parents[0], path, repository, revWalk);
 
       } else if (parents.length == 2) {
-        ObjectId automerge = autoMerger.merge(repository, revWalk, ins,
-            revCommit, mergeStrategy);
+        ObjectId automerge = autoMerger.merge(repository, revWalk, ins, revCommit, mergeStrategy);
         result = blame(automerge, path, repository, revWalk);
 
       } else {
@@ -130,9 +133,10 @@
     }
   }
 
-  private List<BlameInfo> blame(ObjectId id, String path,
-      Repository repository, RevWalk revWalk) throws IOException {
-    ListMultimap<BlameInfo, RangeInfo> ranges = ArrayListMultimap.create();
+  private List<BlameInfo> blame(ObjectId id, String path, Repository repository, RevWalk revWalk)
+      throws IOException {
+    ListMultimap<BlameInfo, RangeInfo> ranges =
+        MultimapBuilder.hashKeys().arrayListValues().build();
     List<BlameInfo> result = new ArrayList<>();
     if (blameCache.findLastCommit(repository, id, path) == null) {
       return result;
@@ -154,8 +158,7 @@
     return result;
   }
 
-  private static BlameInfo toBlameInfo(RevCommit commit,
-      PersonIdent sourceAuthor) {
+  private static BlameInfo toBlameInfo(RevCommit commit, PersonIdent sourceAuthor) {
     BlameInfo blameInfo = new BlameInfo();
     blameInfo.author = sourceAuthor.getName();
     blameInfo.id = commit.getName();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetChange.java
index b66a18b..22b0b1c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetChange.java
@@ -20,15 +20,12 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.EnumSet;
+import org.kohsuke.args4j.Option;
 
 public class GetChange implements RestReadView<ChangeResource> {
   private final ChangeJson.Factory json;
-  private final EnumSet<ListChangesOption> options =
-      EnumSet.noneOf(ListChangesOption.class);
+  private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
 
   @Option(name = "-o", usage = "Output options")
   void addOption(ListChangesOption o) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
index d87c7eb..d601737 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
@@ -33,6 +33,6 @@
 
   @Override
   public CommentInfo apply(CommentResource rsrc) throws OrmException {
-    return commentJson.get().format(rsrc.getComment());
+    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
index 8c9a0ad..a874699 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
@@ -21,21 +21,19 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
 public class GetCommit implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
   private final ChangeJson.Factory json;
 
-  @Option(name = "--links", usage = "Add weblinks")
+  @Option(name = "--links", usage = "Include weblinks")
   private boolean addLinks;
 
   @Inject
@@ -52,8 +50,7 @@
       String rev = rsrc.getPatchSet().getRevision().get();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
-      CommitInfo info = json.create(ChangeJson.NO_OPTIONS)
-          .toCommit(rsrc.getControl(), rw, commit, addLinks, true);
+      CommitInfo info = json.noOptions().toCommit(rsrc.getControl(), rw, commit, addLinks, true);
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
index 5a546f3..abb9e66 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
@@ -24,20 +24,20 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.Text;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-
 @Singleton
 public class GetContent implements RestReadView<FileResource> {
   private final Provider<ReviewDb> db;
@@ -59,15 +59,18 @@
 
   @Override
   public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, NoSuchChangeException,
-      OrmException {
+      throws ResourceNotFoundException, IOException, NoSuchChangeException, OrmException {
     String path = rsrc.getPatchKey().get();
     if (Patch.COMMIT_MSG.equals(path)) {
-      String msg = getMessage(
-          rsrc.getRevision().getChangeResource().getNotes());
+      String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes());
       return BinaryResult.create(msg)
           .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE)
           .base64();
+    } else if (Patch.MERGE_LIST.equals(path)) {
+      byte[] mergeList = getMergeList(rsrc.getRevision().getChangeResource().getNotes());
+      return BinaryResult.create(mergeList)
+          .setContentType(FileContentUtil.TEXT_X_GERRIT_MERGE_LIST)
+          .base64();
     }
     return fileContentUtil.getContent(
         rsrc.getRevision().getControl().getProjectControl().getProjectState(),
@@ -75,8 +78,7 @@
         path);
   }
 
-  private String getMessage(ChangeNotes notes)
-      throws NoSuchChangeException, OrmException, IOException {
+  private String getMessage(ChangeNotes notes) throws OrmException, IOException {
     Change.Id changeId = notes.getChangeId();
     PatchSet ps = psUtil.current(db.get(), notes);
     if (ps == null) {
@@ -85,11 +87,29 @@
 
     try (Repository git = gitManager.openRepository(notes.getProjectName());
         RevWalk revWalk = new RevWalk(git)) {
-      RevCommit commit = revWalk.parseCommit(
-          ObjectId.fromString(ps.getRevision().get()));
+      RevCommit commit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
       return commit.getFullMessage();
     } catch (RepositoryNotFoundException e) {
       throw new NoSuchChangeException(changeId, e);
     }
   }
+
+  private byte[] getMergeList(ChangeNotes notes) throws OrmException, IOException {
+    Change.Id changeId = notes.getChangeId();
+    PatchSet ps = psUtil.current(db.get(), notes);
+    if (ps == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    try (Repository git = gitManager.openRepository(notes.getProjectName());
+        RevWalk revWalk = new RevWalk(git)) {
+      return Text.forMergeList(
+              ComparisonType.againstAutoMerge(),
+              revWalk.getObjectReader(),
+              ObjectId.fromString(ps.getRevision().get()))
+          .getContent();
+    } catch (RepositoryNotFoundException e) {
+      throw new NoSuchChangeException(changeId, e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java
new file mode 100644
index 0000000..b8a34d2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDescription.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetDescription implements RestReadView<RevisionResource> {
+  @Override
+  public String apply(RevisionResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getPatchSet().getDescription());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
index 48bd2f4..8213193 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDetail.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
 import org.kohsuke.args4j.Option;
 
 public class GetDetail implements RestReadView<ChangeResource> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
index 6e284bb..df583fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
@@ -53,7 +52,9 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.ReplaceEdit;
 import org.kohsuke.args4j.CmdLineException;
@@ -65,10 +66,6 @@
 import org.kohsuke.args4j.spi.Parameters;
 import org.kohsuke.args4j.spi.Setter;
 
-import java.io.IOException;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
 public class GetDiff implements RestReadView<FileResource> {
   private static final ImmutableMap<Patch.ChangeType, ChangeType> CHANGE_TYPE =
       Maps.immutableEnumMap(
@@ -109,7 +106,8 @@
   boolean webLinksOnly;
 
   @Inject
-  GetDiff(ProjectCache projectCache,
+  GetDiff(
+      ProjectCache projectCache,
       PatchScriptFactory.Factory patchScriptFactoryFactory,
       Revisions revisions,
       WebLinks webLinks) {
@@ -121,8 +119,8 @@
 
   @Override
   public Response<DiffInfo> apply(FileResource resource)
-      throws ResourceConflictException, ResourceNotFoundException,
-      OrmException, AuthException, InvalidChangeOperationException, IOException {
+      throws ResourceConflictException, ResourceNotFoundException, OrmException, AuthException,
+          InvalidChangeOperationException, IOException {
     DiffPreferencesInfo prefs = new DiffPreferencesInfo();
     if (whitespace != null) {
       prefs.ignoreWhitespace = whitespace;
@@ -137,29 +135,32 @@
     PatchScriptFactory psf;
     PatchSet basePatchSet = null;
     if (base != null) {
-      RevisionResource baseResource = revisions.parse(
-          resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
+      RevisionResource baseResource =
+          revisions.parse(resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
       basePatchSet = baseResource.getPatchSet();
-      psf = patchScriptFactoryFactory.create(
-          resource.getRevision().getControl(),
-          resource.getPatchKey().getFileName(),
-          basePatchSet.getId(),
-          resource.getPatchKey().getParentKey(),
-          prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              resource.getRevision().getControl(),
+              resource.getPatchKey().getFileName(),
+              basePatchSet.getId(),
+              resource.getPatchKey().getParentKey(),
+              prefs);
     } else if (parentNum > 0) {
-      psf = patchScriptFactoryFactory.create(
-          resource.getRevision().getControl(),
-          resource.getPatchKey().getFileName(),
-          parentNum - 1,
-          resource.getPatchKey().getParentKey(),
-          prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              resource.getRevision().getControl(),
+              resource.getPatchKey().getFileName(),
+              parentNum - 1,
+              resource.getPatchKey().getParentKey(),
+              prefs);
     } else {
-      psf = patchScriptFactoryFactory.create(
-          resource.getRevision().getControl(),
-          resource.getPatchKey().getFileName(),
-          null,
-          resource.getPatchKey().getParentKey(),
-          prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              resource.getRevision().getControl(),
+              resource.getPatchKey().getFileName(),
+              null,
+              resource.getPatchKey().getParentKey(),
+              prefs);
     }
 
     try {
@@ -173,17 +174,22 @@
         }
         content.addCommon(edit.getBeginA());
 
-        checkState(content.nextA == edit.getBeginA(),
-            "nextA = %s; want %s", content.nextA, edit.getBeginA());
-        checkState(content.nextB == edit.getBeginB(),
-            "nextB = %s; want %s", content.nextB, edit.getBeginB());
+        checkState(
+            content.nextA == edit.getBeginA(),
+            "nextA = %s; want %s",
+            content.nextA,
+            edit.getBeginA());
+        checkState(
+            content.nextB == edit.getBeginB(),
+            "nextB = %s; want %s",
+            content.nextB,
+            edit.getBeginB());
         switch (edit.getType()) {
           case DELETE:
           case INSERT:
           case REPLACE:
-            List<Edit> internalEdit = edit instanceof ReplaceEdit
-                ? ((ReplaceEdit) edit).getInternalEdits()
-                : null;
+            List<Edit> internalEdit =
+                edit instanceof ReplaceEdit ? ((ReplaceEdit) edit).getInternalEdits() : null;
             content.addDiff(edit.getEndA(), edit.getEndB(), internalEdit);
             break;
           case EMPTY:
@@ -193,21 +199,18 @@
       }
       content.addCommon(ps.getA().size());
 
-      ProjectState state =
-          projectCache.get(resource.getRevision().getChange().getProject());
+      ProjectState state = projectCache.get(resource.getRevision().getChange().getProject());
 
       DiffInfo result = new DiffInfo();
-      // TODO referring to the parent commit by refs/changes/12/60012/1^1
-      // will likely not work for inline edits
-      String revA = basePatchSet != null
-          ? basePatchSet.getRefName()
-          : resource.getRevision().getPatchSet().getRefName() + "^1";
-      String revB = resource.getRevision().getEdit().isPresent()
-           ? resource.getRevision().getEdit().get().getRefName()
-           : resource.getRevision().getPatchSet().getRefName();
+      String revA = basePatchSet != null ? basePatchSet.getRefName() : content.commitIdA;
+      String revB =
+          resource.getRevision().getEdit().isPresent()
+              ? resource.getRevision().getEdit().get().getRefName()
+              : resource.getRevision().getPatchSet().getRefName();
 
-      FluentIterable<DiffWebLinkInfo> links =
-          webLinks.getDiffLinks(state.getProject().getName(),
+      List<DiffWebLinkInfo> links =
+          webLinks.getDiffLinks(
+              state.getProject().getName(),
               resource.getPatchKey().getParentKey().getParentKey().get(),
               basePatchSet != null ? basePatchSet.getId().get() : null,
               revA,
@@ -215,7 +218,7 @@
               resource.getPatchKey().getParentKey().get(),
               revB,
               ps.getNewName());
-      result.webLinks = links.isEmpty() ? null : links.toList();
+      result.webLinks = links.isEmpty() ? null : links;
 
       if (!webLinksOnly) {
         if (ps.isBinary()) {
@@ -223,24 +226,23 @@
         }
         if (ps.getDisplayMethodA() != DisplayMethod.NONE) {
           result.metaA = new FileMeta();
-          result.metaA.name = MoreObjects.firstNonNull(ps.getOldName(),
-              ps.getNewName());
-          result.metaA.contentType = FileContentUtil.resolveContentType(
-              state, result.metaA.name, ps.getFileModeA(), ps.getMimeTypeA());
+          result.metaA.name = MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName());
+          result.metaA.contentType =
+              FileContentUtil.resolveContentType(
+                  state, result.metaA.name, ps.getFileModeA(), ps.getMimeTypeA());
           result.metaA.lines = ps.getA().size();
-          result.metaA.webLinks =
-              getFileWebLinks(state.getProject(), revA, result.metaA.name);
+          result.metaA.webLinks = getFileWebLinks(state.getProject(), revA, result.metaA.name);
           result.metaA.commitId = content.commitIdA;
         }
 
         if (ps.getDisplayMethodB() != DisplayMethod.NONE) {
           result.metaB = new FileMeta();
           result.metaB.name = ps.getNewName();
-          result.metaB.contentType = FileContentUtil.resolveContentType(
-              state, result.metaB.name, ps.getFileModeB(), ps.getMimeTypeB());
+          result.metaB.contentType =
+              FileContentUtil.resolveContentType(
+                  state, result.metaB.name, ps.getFileModeB(), ps.getMimeTypeB());
           result.metaB.lines = ps.getB().size();
-          result.metaB.webLinks =
-              getFileWebLinks(state.getProject(), revB, result.metaB.name);
+          result.metaB.webLinks = getFileWebLinks(state.getProject(), revB, result.metaB.name);
           result.metaB.commitId = content.commitIdB;
         }
 
@@ -256,8 +258,7 @@
 
         result.changeType = CHANGE_TYPE.get(ps.getChangeType());
         if (result.changeType == null) {
-          throw new IllegalStateException(
-              "unknown change type: " + ps.getChangeType());
+          throw new IllegalStateException("unknown change type: " + ps.getChangeType());
         }
 
         if (ps.getPatchHeader().size() > 0) {
@@ -278,11 +279,9 @@
     }
   }
 
-  private List<WebLinkInfo> getFileWebLinks(Project project, String rev,
-      String file) {
-    FluentIterable<WebLinkInfo> links =
-        webLinks.getFileLinks(project.getName(), rev, file);
-    return links.isEmpty() ? null : links.toList();
+  private List<WebLinkInfo> getFileWebLinks(Project project, String rev, String file) {
+    List<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
+    return links.isEmpty() ? null : links;
   }
 
   public GetDiff setBase(String base) {
@@ -338,9 +337,7 @@
 
       while (nextA < end) {
         if (!fileA.contains(nextA)) {
-          int endRegion = Math.min(
-              end,
-              nextA == 0 ? fileA.first() : fileA.next(nextA - 1));
+          int endRegion = Math.min(end, nextA == 0 ? fileA.first() : fileA.next(nextA - 1));
           int len = endRegion - nextA;
           entry().skip = len;
           nextA = endRegion;
@@ -349,9 +346,7 @@
         }
 
         ContentEntry e = null;
-        for (int i = nextA;
-            i == nextA && i < end;
-            i = fileA.next(i), nextA++, nextB++) {
+        for (int i = nextA; i == nextA && i < end; i = fileA.next(i), nextA++, nextB++) {
           if (ignoreWS && fileB.contains(nextB)) {
             if (e == null || e.common == null) {
               e = entry();
@@ -397,13 +392,13 @@
         int lastB = 0;
         for (Edit edit : internalEdit) {
           if (edit.getBeginA() != edit.getEndA()) {
-            e.editA.add(ImmutableList.of(
-                edit.getBeginA() - lastA, edit.getEndA() - edit.getBeginA()));
+            e.editA.add(
+                ImmutableList.of(edit.getBeginA() - lastA, edit.getEndA() - edit.getBeginA()));
             lastA = edit.getEndA();
           }
           if (edit.getBeginB() != edit.getEndB()) {
-            e.editB.add(ImmutableList.of(
-                edit.getBeginB() - lastB, edit.getEndB() - edit.getBeginB()));
+            e.editB.add(
+                ImmutableList.of(edit.getBeginB() - lastB, edit.getEndB() - edit.getBeginB()));
             lastB = edit.getEndB();
           }
         }
@@ -432,14 +427,12 @@
   }
 
   public static class ContextOptionHandler extends OptionHandler<Short> {
-    public ContextOptionHandler(
-        CmdLineParser parser, OptionDef option, Setter<Short> setter) {
+    public ContextOptionHandler(CmdLineParser parser, OptionDef option, Setter<Short> setter) {
       super(parser, option, setter);
     }
 
     @Override
-    public final int parseArguments(final Parameters params)
-        throws CmdLineException {
+    public final int parseArguments(final Parameters params) throws CmdLineException {
       final String value = params.getParameter(0);
       short context;
       if ("all".equalsIgnoreCase(value)) {
@@ -451,8 +444,10 @@
             throw new NumberFormatException();
           }
         } catch (NumberFormatException e) {
-          throw new CmdLineException(owner,
-              String.format("\"%s\" is not a valid value for \"%s\"",
+          throw new CmdLineException(
+              owner,
+              String.format(
+                  "\"%s\" is not a valid value for \"%s\"",
                   value, ((NamedOptionDef) option).name()));
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
index 22f90c9..a380ce3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
@@ -33,6 +33,6 @@
 
   @Override
   public CommentInfo apply(DraftCommentResource rsrc) throws OrmException {
-    return commentJson.get().format(rsrc.getComment());
+    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java
index d0c1e83..4ea1c02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.util.Collections;
 import java.util.Set;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
new file mode 100644
index 0000000..9d40df4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.CacheControl;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.MergeListBuilder;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class GetMergeList implements RestReadView<RevisionResource> {
+  private final GitRepositoryManager repoManager;
+  private final ChangeJson.Factory json;
+
+  @Option(name = "--parent", usage = "Uninteresting parent (1-based, default = 1)")
+  private int uninterestingParent = 1;
+
+  @Option(name = "--links", usage = "Include weblinks")
+  private boolean addLinks;
+
+  @Inject
+  GetMergeList(GitRepositoryManager repoManager, ChangeJson.Factory json) {
+    this.repoManager = repoManager;
+    this.json = json;
+  }
+
+  public void setUninterestingParent(int uninterestingParent) {
+    this.uninterestingParent = uninterestingParent;
+  }
+
+  public void setAddLinks(boolean addLinks) {
+    this.addLinks = addLinks;
+  }
+
+  @Override
+  public Response<List<CommitInfo>> apply(RevisionResource rsrc)
+      throws BadRequestException, IOException {
+    Project.NameKey p = rsrc.getChange().getProject();
+    try (Repository repo = repoManager.openRepository(p);
+        RevWalk rw = new RevWalk(repo)) {
+      String rev = rsrc.getPatchSet().getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+
+      if (uninterestingParent < 1 || uninterestingParent > commit.getParentCount()) {
+        throw new BadRequestException("No such parent: " + uninterestingParent);
+      }
+
+      if (commit.getParentCount() < 2) {
+        return createResponse(rsrc, ImmutableList.<CommitInfo>of());
+      }
+
+      List<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
+      List<CommitInfo> result = new ArrayList<>(commits.size());
+      ChangeJson changeJson = json.noOptions();
+      for (RevCommit c : commits) {
+        result.add(changeJson.toCommit(rsrc.getControl(), rw, c, addLinks, true));
+      }
+      return createResponse(rsrc, result);
+    }
+  }
+
+  private static Response<List<CommitInfo>> createResponse(
+      RevisionResource rsrc, List<CommitInfo> result) {
+    Response<List<CommitInfo>> r = Response.ok(result);
+    if (rsrc.isCacheable()) {
+      r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+    }
+    return r;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
new file mode 100644
index 0000000..eaa3a28
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+@Singleton
+public class GetPastAssignees implements RestReadView<ChangeResource> {
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  GetPastAssignees(AccountLoader.Factory accountLoaderFactory) {
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws OrmException {
+
+    Set<Account.Id> pastAssignees = rsrc.getControl().getNotes().load().getPastAssignees();
+    if (pastAssignees == null) {
+      return Response.ok(Collections.emptyList());
+    }
+
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
+    List<AccountInfo> infos = pastAssignees.stream().map(accountLoader::get).collect(toList());
+    accountLoader.fill();
+    return Response.ok(infos);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
index a13e7be..2275e06 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
@@ -18,20 +18,11 @@
 
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.lib.AbbreviatedObjectId;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.io.OutputStream;
 import java.text.SimpleDateFormat;
@@ -39,16 +30,30 @@
 import java.util.Locale;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
+import org.kohsuke.args4j.Option;
 
 public class GetPatch implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
 
+  private final String FILE_NOT_FOUND = "File not found: %s.";
+
   @Option(name = "--zip")
   private boolean zip;
 
   @Option(name = "--download")
   private boolean download;
 
+  @Option(name = "--path")
+  private String path;
+
   @Inject
   GetPatch(GitRepositoryManager repoManager) {
     this.repoManager = repoManager;
@@ -56,7 +61,7 @@
 
   @Override
   public BinaryResult apply(RevisionResource rsrc)
-      throws ResourceConflictException, IOException {
+      throws ResourceConflictException, IOException, ResourceNotFoundException {
     Project.NameKey project = rsrc.getControl().getProject().getNameKey();
     final Repository repo = repoManager.openRepository(project);
     boolean close = true;
@@ -64,60 +69,67 @@
       final RevWalk rw = new RevWalk(repo);
       try {
         final RevCommit commit =
-            rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet()
-                .getRevision().get()));
+            rw.parseCommit(ObjectId.fromString(rsrc.getPatchSet().getRevision().get()));
         RevCommit[] parents = commit.getParents();
         if (parents.length > 1) {
-          throw new ResourceConflictException(
-              "Revision has more than 1 parent.");
+          throw new ResourceConflictException("Revision has more than 1 parent.");
         } else if (parents.length == 0) {
           throw new ResourceConflictException("Revision has no parent.");
         }
         final RevCommit base = parents[0];
         rw.parseBody(base);
 
-        BinaryResult bin = new BinaryResult() {
-          @Override
-          public void writeTo(OutputStream out) throws IOException {
-            if (zip) {
-              ZipOutputStream zos = new ZipOutputStream(out);
-              ZipEntry e = new ZipEntry(fileName(rw, commit));
-              e.setTime(commit.getCommitTime() * 1000L);
-              zos.putNextEntry(e);
-              format(zos);
-              zos.closeEntry();
-              zos.finish();
-            } else {
-              format(out);
-            }
-          }
+        BinaryResult bin =
+            new BinaryResult() {
+              @Override
+              public void writeTo(OutputStream out) throws IOException {
+                if (zip) {
+                  ZipOutputStream zos = new ZipOutputStream(out);
+                  ZipEntry e = new ZipEntry(fileName(rw, commit));
+                  e.setTime(commit.getCommitTime() * 1000L);
+                  zos.putNextEntry(e);
+                  format(zos);
+                  zos.closeEntry();
+                  zos.finish();
+                } else {
+                  format(out);
+                }
+              }
 
-          private void format(OutputStream out) throws IOException {
-            out.write(formatEmailHeader(commit).getBytes(UTF_8));
-            try (DiffFormatter fmt = new DiffFormatter(out)) {
-              fmt.setRepository(repo);
-              fmt.format(base.getTree(), commit.getTree());
-              fmt.flush();
-            }
-          }
+              private void format(OutputStream out) throws IOException {
+                // Only add header if no path is specified
+                if (path == null) {
+                  out.write(formatEmailHeader(commit).getBytes(UTF_8));
+                }
+                try (DiffFormatter fmt = new DiffFormatter(out)) {
+                  fmt.setRepository(repo);
+                  if (path != null) {
+                    fmt.setPathFilter(PathFilter.create(path));
+                  }
+                  fmt.format(base.getTree(), commit.getTree());
+                  fmt.flush();
+                }
+              }
 
-          @Override
-          public void close() throws IOException {
-            rw.close();
-            repo.close();
-          }
-        };
+              @Override
+              public void close() throws IOException {
+                rw.close();
+                repo.close();
+              }
+            };
+
+        if (path != null && bin.asString().isEmpty()) {
+          throw new ResourceNotFoundException(String.format(FILE_NOT_FOUND, path));
+        }
 
         if (zip) {
           bin.disableGzip()
-             .setContentType("application/zip")
-             .setAttachmentName(fileName(rw, commit) + ".zip");
+              .setContentType("application/zip")
+              .setAttachmentName(fileName(rw, commit) + ".zip");
         } else {
           bin.base64()
-             .setContentType("application/mbox")
-             .setAttachmentName(download
-                 ? fileName(rw, commit) + ".base64"
-                 : null);
+              .setContentType("application/mbox")
+              .setAttachmentName(download ? fileName(rw, commit) + ".base64" : null);
         }
 
         close = false;
@@ -134,6 +146,11 @@
     }
   }
 
+  public GetPatch setPath(String path) {
+    this.path = path;
+    return this;
+  }
+
   private static String formatEmailHeader(RevCommit commit) {
     StringBuilder b = new StringBuilder();
     PersonIdent author = commit.getAuthorIdent();
@@ -142,31 +159,37 @@
     if (msg.startsWith("\n\n")) {
       msg = msg.substring(2);
     }
-    b.append("From ").append(commit.getName())
-     .append(' ')
-     .append("Mon Sep 17 00:00:00 2001\n") // Fixed timestamp to match output of C Git's format-patch
-     .append("From: ").append(author.getName())
-     .append(" <").append(author.getEmailAddress()).append(">\n")
-     .append("Date: ").append(formatDate(author)).append('\n')
-     .append("Subject: [PATCH] ").append(subject).append('\n')
-     .append('\n')
-     .append(msg);
+    b.append("From ")
+        .append(commit.getName())
+        .append(' ')
+        .append(
+            "Mon Sep 17 00:00:00 2001\n") // Fixed timestamp to match output of C Git's format-patch
+        .append("From: ")
+        .append(author.getName())
+        .append(" <")
+        .append(author.getEmailAddress())
+        .append(">\n")
+        .append("Date: ")
+        .append(formatDate(author))
+        .append('\n')
+        .append("Subject: [PATCH] ")
+        .append(subject)
+        .append('\n')
+        .append('\n')
+        .append(msg);
     if (!msg.endsWith("\n")) {
-     b.append('\n');
+      b.append('\n');
     }
     return b.append("---\n\n").toString();
   }
 
   private static String formatDate(PersonIdent author) {
-    SimpleDateFormat df = new SimpleDateFormat(
-        "EEE, dd MMM yyyy HH:mm:ss Z",
-        Locale.US);
+    SimpleDateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
     df.setCalendar(Calendar.getInstance(author.getTimeZone(), Locale.US));
     return df.format(author.getWhen());
   }
 
-  private static String fileName(RevWalk rw, RevCommit commit)
-      throws IOException {
+  private static String fileName(RevWalk rw, RevCommit commit) throws IOException {
     AbbreviatedObjectId id = rw.getObjectReader().abbreviate(commit, 7);
     return id.name() + ".diff";
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
index 12e4276..10c7a5a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
@@ -32,16 +32,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.revwalk.RevCommit;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class GetRelated implements RestReadView<RevisionResource> {
@@ -51,7 +49,8 @@
   private final RelatedChangesSorter sorter;
 
   @Inject
-  GetRelated(Provider<ReviewDb> db,
+  GetRelated(
+      Provider<ReviewDb> db,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
       RelatedChangesSorter sorter) {
@@ -69,29 +68,30 @@
     return relatedInfo;
   }
 
-  private List<ChangeAndCommit> getRelated(RevisionResource rsrc)
-      throws OrmException, IOException {
+  private List<ChangeAndCommit> getRelated(RevisionResource rsrc) throws OrmException, IOException {
     Set<String> groups = getAllGroups(rsrc.getNotes());
     if (groups.isEmpty()) {
       return Collections.emptyList();
     }
 
-    List<ChangeData> cds = queryProvider.get()
-        .enforceVisibility(true)
-        .byProjectGroups(rsrc.getChange().getProject(), groups);
+    List<ChangeData> cds =
+        queryProvider
+            .get()
+            .enforceVisibility(true)
+            .byProjectGroups(rsrc.getChange().getProject(), groups);
     if (cds.isEmpty()) {
       return Collections.emptyList();
     }
-    if (cds.size() == 1
-        && cds.get(0).getId().equals(rsrc.getChange().getId())) {
+    if (cds.size() == 1 && cds.get(0).getId().equals(rsrc.getChange().getId())) {
       return Collections.emptyList();
     }
     List<ChangeAndCommit> result = new ArrayList<>(cds.size());
 
     boolean isEdit = rsrc.getEdit().isPresent();
-    PatchSet basePs = isEdit
-        ? rsrc.getEdit().get().getBasePatchSet()
-        : rsrc.getPatchSet();
+    PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
+
+    reloadChangeIfStale(cds, basePs);
+
     for (PatchSetData d : sorter.sort(cds, basePs)) {
       PatchSet ps = d.patchSet();
       RevCommit commit;
@@ -107,8 +107,7 @@
 
     if (result.size() == 1) {
       ChangeAndCommit r = result.get(0);
-      if (r.commit != null
-          && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
+      if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().getRevision().get())) {
         return Collections.emptyList();
       }
     }
@@ -123,6 +122,16 @@
     return result;
   }
 
+  private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs) throws OrmException {
+    for (ChangeData cd : cds) {
+      if (cd.getId().equals(wantedPs.getId().getParentKey())) {
+        if (cd.patchSet(wantedPs.getId()) == null) {
+          cd.reloadChange();
+        }
+      }
+    }
+  }
+
   public static class RelatedInfo {
     public List<ChangeAndCommit> changes;
   }
@@ -135,8 +144,7 @@
     public Integer _currentRevisionNumber;
     public String status;
 
-    public ChangeAndCommit() {
-    }
+    public ChangeAndCommit() {}
 
     ChangeAndCommit(@Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
       if (change != null) {
@@ -174,14 +182,14 @@
 
     private static String toString(CommitInfo commit) {
       return MoreObjects.toStringHelper(commit)
-        .add("commit", commit.commit)
-        .add("parent", commit.parents)
-        .add("author", commit.author)
-        .add("committer", commit.committer)
-        .add("subject", commit.subject)
-        .add("message", commit.message)
-        .add("webLinks", commit.webLinks)
-        .toString();
+          .add("commit", commit.commit)
+          .add("parent", commit.parents)
+          .add("author", commit.author)
+          .add("committer", commit.committer)
+          .add("subject", commit.subject)
+          .add("message", commit.message)
+          .add("webLinks", commit.webLinks)
+          .toString();
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
index 533468d..aa0b339 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
@@ -19,7 +19,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.List;
 
 @Singleton
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
index eae67a2..4972576 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
@@ -30,25 +30,23 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.util.Map;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class GetRevisionActions implements ETagView<RevisionResource> {
   private final ActionJson delegate;
   private final Config config;
   private final Provider<ReviewDb> dbProvider;
-  private final MergeSuperSet mergeSuperSet;
+  private final Provider<MergeSuperSet> mergeSuperSet;
   private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
   GetRevisionActions(
       ActionJson delegate,
       Provider<ReviewDb> dbProvider,
-      MergeSuperSet mergeSuperSet,
+      Provider<MergeSuperSet> mergeSuperSet,
       ChangeResource.Factory changeResourceFactory,
       @GerritServerConfig Config config) {
     this.delegate = delegate;
@@ -59,11 +57,12 @@
   }
 
   @Override
-  public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
+  public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) throws OrmException {
     return Response.withMustRevalidate(delegate.format(rsrc));
   }
 
   @Override
+  @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
   public String getETag(RevisionResource rsrc) {
     Hasher h = Hashing.md5().newHasher();
     CurrentUser user = rsrc.getControl().getUser();
@@ -71,8 +70,7 @@
       rsrc.getChangeResource().prepareETag(h, user);
       h.putBoolean(Submit.wholeTopicEnabled(config));
       ReviewDb db = dbProvider.get();
-      ChangeSet cs =
-          mergeSuperSet.completeChangeSet(db, rsrc.getChange(), user);
+      ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, rsrc.getChange(), user);
       for (ChangeData cd : cs.changes()) {
         changeResourceFactory.create(cd.changeControl()).prepareETag(h, user);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java
new file mode 100644
index 0000000..d4d53ad
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetRobotComment implements RestReadView<RobotCommentResource> {
+
+  private final Provider<CommentJson> commentJson;
+
+  @Inject
+  GetRobotComment(Provider<CommentJson> commentJson) {
+    this.commentJson = commentJson;
+  }
+
+  @Override
+  public RobotCommentInfo apply(RobotCommentResource rsrc) throws OrmException {
+    return commentJson.get().newRobotCommentFormatter().format(rsrc.getComment());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
index 39fdf3e..e9b0af2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
-
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
@@ -24,8 +23,7 @@
 import java.util.regex.Pattern;
 
 public class HashtagsUtil {
-  private static final CharMatcher LEADER =
-      CharMatcher.whitespace().or(CharMatcher.is('#'));
+  private static final CharMatcher LEADER = CharMatcher.whitespace().or(CharMatcher.is('#'));
   private static final String PATTERN = "(?:\\s|\\A)#[\\p{L}[0-9]-_]+";
 
   public static String cleanupHashtag(String hashtag) {
@@ -45,8 +43,7 @@
     return result;
   }
 
-  static Set<String> extractTags(Set<String> input)
-      throws IllegalArgumentException {
+  static Set<String> extractTags(Set<String> input) throws IllegalArgumentException {
     if (input == null) {
       return Collections.emptySet();
     }
@@ -63,6 +60,5 @@
     return result;
   }
 
-  private HashtagsUtil() {
-  }
+  private HashtagsUtil() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java
index 344cb44..8f8925a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedIn.java
@@ -14,24 +14,19 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -39,41 +34,25 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Map;
-
 @Singleton
-class IncludedIn implements RestReadView<ChangeResource> {
-
-  private final Provider<ReviewDb> db;
+public class IncludedIn {
   private final GitRepositoryManager repoManager;
-  private final PatchSetUtil psUtil;
-  private final DynamicSet<ExternalIncludedIn> includedIn;
+  private final DynamicSet<ExternalIncludedIn> externalIncludedIn;
 
   @Inject
-  IncludedIn(Provider<ReviewDb> db,
-      GitRepositoryManager repoManager,
-      PatchSetUtil psUtil,
-      DynamicSet<ExternalIncludedIn> includedIn) {
-    this.db = db;
+  IncludedIn(GitRepositoryManager repoManager, DynamicSet<ExternalIncludedIn> externalIncludedIn) {
     this.repoManager = repoManager;
-    this.psUtil = psUtil;
-    this.includedIn = includedIn;
+    this.externalIncludedIn = externalIncludedIn;
   }
 
-  @Override
-  public IncludedInInfo apply(ChangeResource rsrc) throws BadRequestException,
-      ResourceConflictException, OrmException, IOException {
-    ChangeControl ctl = rsrc.getControl();
-    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
-    Project.NameKey project = ctl.getProject().getNameKey();
+  public IncludedInInfo apply(Project.NameKey project, String revisionId)
+      throws RestApiException, IOException {
     try (Repository r = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(r)) {
       rw.setRetainBody(false);
       RevCommit rev;
       try {
-        rev = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+        rev = rw.parseCommit(ObjectId.fromString(revisionId));
       } catch (IncorrectObjectTypeException err) {
         throw new BadRequestException(err.getMessage());
       } catch (MissingObjectException err) {
@@ -81,28 +60,16 @@
       }
 
       IncludedInResolver.Result d = IncludedInResolver.resolve(r, rw, rev);
-      Multimap<String, String> external = ArrayListMultimap.create();
-      for (ExternalIncludedIn ext : includedIn) {
-        Multimap<String, String> extIncludedIns = ext.getIncludedIn(
-            project.get(), rev.name(), d.getTags(), d.getBranches());
+      ListMultimap<String, String> external = MultimapBuilder.hashKeys().arrayListValues().build();
+      for (ExternalIncludedIn ext : externalIncludedIn) {
+        ListMultimap<String, String> extIncludedIns =
+            ext.getIncludedIn(project.get(), rev.name(), d.getTags(), d.getBranches());
         if (extIncludedIns != null) {
           external.putAll(extIncludedIns);
         }
       }
-      return new IncludedInInfo(d,
-          (!external.isEmpty() ? external.asMap() : null));
-    }
-  }
-
-  static class IncludedInInfo {
-    Collection<String> branches;
-    Collection<String> tags;
-    Map<String, Collection<String>> external;
-
-    IncludedInInfo(IncludedInResolver.Result in, Map<String, Collection<String>> e) {
-      branches = in.getBranches();
-      tags = in.getTags();
-      external = e;
+      return new IncludedInInfo(
+          d.getBranches(), d.getTags(), (!external.isEmpty() ? external.asMap() : null));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
index 0c3ecd9..99b5245 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -15,9 +15,16 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -30,25 +37,13 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashSet;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Resolve in which tags and branches a commit is included.
- */
+/** Resolve in which tags and branches a commit is included. */
 public class IncludedInResolver {
 
-  private static final Logger log = LoggerFactory
-      .getLogger(IncludedInResolver.class);
+  private static final Logger log = LoggerFactory.getLogger(IncludedInResolver.class);
 
-  public static Result resolve(final Repository repo,
-      final RevWalk rw, final RevCommit commit) throws IOException {
+  public static Result resolve(final Repository repo, final RevWalk rw, final RevCommit commit)
+      throws IOException {
     RevFlag flag = newFlag(rw);
     try {
       return new IncludedInResolver(repo, rw, commit, flag).resolve();
@@ -57,8 +52,9 @@
     }
   }
 
-  public static boolean includedInOne(final Repository repo, final RevWalk rw,
-      final RevCommit commit, final Collection<Ref> refs) throws IOException {
+  public static boolean includedInOne(
+      final Repository repo, final RevWalk rw, final RevCommit commit, final Collection<Ref> refs)
+      throws IOException {
     RevFlag flag = newFlag(rw);
     try {
       return new IncludedInResolver(repo, rw, commit, flag).includedInOne(refs);
@@ -76,11 +72,11 @@
   private final RevCommit target;
 
   private final RevFlag containsTarget;
-  private Multimap<RevCommit, String> commitToRef;
+  private ListMultimap<RevCommit, String> commitToRef;
   private List<RevCommit> tipsByCommitTime;
 
-  private IncludedInResolver(Repository repo, RevWalk rw, RevCommit target,
-      RevFlag containsTarget) {
+  private IncludedInResolver(
+      Repository repo, RevWalk rw, RevCommit target, RevFlag containsTarget) {
     this.repo = repo;
     this.rw = rw;
     this.target = target;
@@ -91,16 +87,14 @@
     RefDatabase refDb = repo.getRefDatabase();
     Collection<Ref> tags = refDb.getRefs(Constants.R_TAGS).values();
     Collection<Ref> branches = refDb.getRefs(Constants.R_HEADS).values();
-    List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(
-        tags.size() + branches.size());
+    List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(tags.size() + branches.size());
     allTagsAndBranches.addAll(tags);
     allTagsAndBranches.addAll(branches);
     parseCommits(allTagsAndBranches);
     Set<String> allMatchingTagsAndBranches = includedIn(tipsByCommitTime, 0);
 
     Result detail = new Result();
-    detail
-        .setBranches(getMatchingRefNames(allMatchingTagsAndBranches, branches));
+    detail.setBranches(getMatchingRefNames(allMatchingTagsAndBranches, branches));
     detail.setTags(getMatchingRefNames(allMatchingTagsAndBranches, tags));
 
     return detail;
@@ -108,8 +102,8 @@
 
   private boolean includedInOne(final Collection<Ref> refs) throws IOException {
     parseCommits(refs);
-    List<RevCommit> before = new LinkedList<>();
-    List<RevCommit> after = new LinkedList<>();
+    List<RevCommit> before = new ArrayList<>();
+    List<RevCommit> after = new ArrayList<>();
     partition(before, after);
     rw.reset();
     // It is highly likely that the target is reachable from the "after" set
@@ -117,9 +111,7 @@
     return !includedIn(after, 1).isEmpty() || !includedIn(before, 1).isEmpty();
   }
 
-  /**
-   * Resolves which tip refs include the target commit.
-   */
+  /** Resolves which tip refs include the target commit. */
   private Set<String> includedIn(final Collection<RevCommit> tips, int limit)
       throws IOException, MissingObjectException, IncorrectObjectTypeException {
     Set<String> result = new HashSet<>();
@@ -146,27 +138,30 @@
 
   /**
    * Partition the reference tips into two sets:
+   *
    * <ul>
-   * <li> before = commits with time <  target.getCommitTime()
-   * <li> after  = commits with time >= target.getCommitTime()
+   *   <li>before = commits with time < target.getCommitTime()
+   *   <li>after = commits with time >= target.getCommitTime()
    * </ul>
    *
-   * Each of the before/after lists is sorted by the the commit time.
+   * Each of the before/after lists is sorted by the commit time.
    *
    * @param before
    * @param after
    */
-  private void partition(final List<RevCommit> before,
-      final List<RevCommit> after) {
-    int insertionPoint = Collections.binarySearch(tipsByCommitTime, target,
-        new Comparator<RevCommit>() {
-      @Override
-      public int compare(RevCommit c1, RevCommit c2) {
-        return c1.getCommitTime() - c2.getCommitTime();
-      }
-    });
+  private void partition(final List<RevCommit> before, final List<RevCommit> after) {
+    int insertionPoint =
+        Collections.binarySearch(
+            tipsByCommitTime,
+            target,
+            new Comparator<RevCommit>() {
+              @Override
+              public int compare(RevCommit c1, RevCommit c2) {
+                return c1.getCommitTime() - c2.getCommitTime();
+              }
+            });
     if (insertionPoint < 0) {
-      insertionPoint = - (insertionPoint + 1);
+      insertionPoint = -(insertionPoint + 1);
     }
     if (0 < insertionPoint) {
       before.addAll(tipsByCommitTime.subList(0, insertionPoint));
@@ -177,11 +172,11 @@
   }
 
   /**
-   * Returns the short names of refs which are as well in the matchingRefs list
-   * as well as in the allRef list.
+   * Returns the short names of refs which are as well in the matchingRefs list as well as in the
+   * allRef list.
    */
-  private static List<String> getMatchingRefNames(Set<String> matchingRefs,
-      Collection<Ref> allRefs) {
+  private static List<String> getMatchingRefNames(
+      Set<String> matchingRefs, Collection<Ref> allRefs) {
     List<String> refNames = Lists.newArrayListWithCapacity(matchingRefs.size());
     for (Ref r : allRefs) {
       if (matchingRefs.contains(r.getName())) {
@@ -191,9 +186,7 @@
     return refNames;
   }
 
-  /**
-   * Parse commit of ref and store the relation between ref and commit.
-   */
+  /** Parse commit of ref and store the relation between ref and commit. */
   private void parseCommits(final Collection<Ref> refs) throws IOException {
     if (commitToRef != null) {
       return;
@@ -211,8 +204,13 @@
       } catch (MissingObjectException notHere) {
         // Log the problem with this branch, but keep processing.
         //
-        log.warn("Reference " + ref.getName() + " in " + repo.getDirectory()
-            + " points to dangling object " + ref.getObjectId());
+        log.warn(
+            "Reference "
+                + ref.getName()
+                + " in "
+                + repo.getDirectory()
+                + " points to dangling object "
+                + ref.getObjectId());
         continue;
       }
       commitToRef.put(commit, ref.getName());
@@ -222,20 +220,21 @@
   }
 
   private void sortOlderFirst(final List<RevCommit> tips) {
-    Collections.sort(tips, new Comparator<RevCommit>() {
-      @Override
-      public int compare(RevCommit c1, RevCommit c2) {
-        return c1.getCommitTime() - c2.getCommitTime();
-      }
-    });
+    Collections.sort(
+        tips,
+        new Comparator<RevCommit>() {
+          @Override
+          public int compare(RevCommit c1, RevCommit c2) {
+            return c1.getCommitTime() - c2.getCommitTime();
+          }
+        });
   }
 
   public static class Result {
     private List<String> branches;
     private List<String> tags;
 
-    public Result() {
-    }
+    public Result() {}
 
     public void setBranches(final List<String> b) {
       Collections.sort(b);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
index 44a9975..9257445 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
@@ -25,20 +25,17 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 
 @Singleton
 public class Index implements RestModifyView<ChangeResource, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final ChangeIndexer indexer;
 
   @Inject
-  Index(Provider<ReviewDb> db,
-      ChangeIndexer indexer) {
+  Index(Provider<ReviewDb> db, ChangeIndexer indexer) {
     this.db = db;
     this.indexer = indexer;
   }
@@ -47,10 +44,8 @@
   public Response<?> apply(ChangeResource rsrc, Input input)
       throws IOException, AuthException, OrmException {
     ChangeControl ctl = rsrc.getControl();
-    if (!ctl.isOwner()
-        && !ctl.getUser().getCapabilities().canMaintainServer()) {
-      throw new AuthException(
-          "Only change owner or server maintainer can reindex");
+    if (!ctl.isOwner() && !ctl.getUser().getCapabilities().canMaintainServer()) {
+      throw new AuthException("Only change owner or server maintainer can reindex");
     }
     indexer.index(db.get(), rsrc.getChange());
     return Response.none();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/LimitedByteArrayOutputStream.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/LimitedByteArrayOutputStream.java
new file mode 100644
index 0000000..facc03c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/LimitedByteArrayOutputStream.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.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+class LimitedByteArrayOutputStream extends OutputStream {
+
+  private final int maxSize;
+  private final ByteArrayOutputStream buffer;
+
+  /**
+   * Constructs a LimitedByteArrayOutputStream, which stores output in memory up to a certain
+   * specified size. When the output exceeds the specified size a LimitExceededException is thrown.
+   *
+   * @param max the maximum size in bytes which may be stored.
+   * @param initial the initial size. It must be smaller than the max size.
+   */
+  LimitedByteArrayOutputStream(int max, int initial) {
+    checkArgument(initial <= max);
+    maxSize = max;
+    buffer = new ByteArrayOutputStream(initial);
+  }
+
+  private void checkOversize(int additionalSize) throws IOException {
+    if (buffer.size() + additionalSize > maxSize) {
+      throw new LimitExceededException();
+    }
+  }
+
+  @Override
+  public void write(int b) throws IOException {
+    checkOversize(1);
+    buffer.write(b);
+  }
+
+  @Override
+  public void write(byte[] b, int off, int len) throws IOException {
+    checkOversize(len);
+    buffer.write(b, off, len);
+  }
+
+  /** @return a newly allocated byte array with contents of the buffer. */
+  public byte[] toByteArray() {
+    return buffer.toByteArray();
+  }
+
+  static class LimitExceededException extends IOException {
+    private static final long serialVersionUID = 1L;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
index 97befa0..942c3b4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
@@ -18,13 +18,12 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.List;
 import java.util.Map;
 
@@ -33,26 +32,29 @@
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
-  ListChangeComments(Provider<ReviewDb> db,
+  ListChangeComments(
+      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(
-      ChangeResource rsrc) throws AuthException, OrmException {
+  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
+      throws AuthException, OrmException {
     ChangeData cd = changeDataFactory.create(db.get(), rsrc.getControl());
-    return commentJson.get()
+    return commentJson
+        .get()
         .setFillAccounts(true)
         .setFillPatchSet(true)
-        .format(plcUtil.publishedByChange(db.get(), cd.notes()));
+        .newCommentFormatter()
+        .format(commentsUtil.publishedByChange(db.get(), cd.notes()));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
index 561a040..2bf7aa0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
@@ -17,15 +17,14 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.List;
 import java.util.Map;
 
@@ -34,31 +33,35 @@
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
-  ListChangeDrafts(Provider<ReviewDb> db,
+  ListChangeDrafts(
+      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(
-      ChangeResource rsrc) throws AuthException, OrmException {
+  public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
+      throws AuthException, OrmException {
     if (!rsrc.getControl().getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
     ChangeData cd = changeDataFactory.create(db.get(), rsrc.getControl());
-    List<PatchLineComment> drafts = plcUtil.draftByChangeAuthor(
-        db.get(), cd.notes(), rsrc.getControl().getUser().getAccountId());
-    return commentJson.get()
+    List<Comment> drafts =
+        commentsUtil.draftByChangeAuthor(
+            db.get(), cd.notes(), rsrc.getControl().getUser().getAccountId());
+    return commentJson
+        .get()
         .setFillAccounts(false)
         .setFillPatchSet(true)
+        .newCommentFormatter()
         .format(drafts);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java
new file mode 100644
index 0000000..881c6f53
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
+import java.util.Map;
+
+public class ListChangeRobotComments implements RestReadView<ChangeResource> {
+  private final Provider<ReviewDb> db;
+  private final ChangeData.Factory changeDataFactory;
+  private final Provider<CommentJson> commentJson;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  ListChangeRobotComments(
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil) {
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public Map<String, List<RobotCommentInfo>> apply(ChangeResource rsrc)
+      throws AuthException, OrmException {
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getControl());
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .setFillPatchSet(true)
+        .newRobotCommentFormatter()
+        .format(commentsUtil.robotCommentsByChange(cd.notes()));
+  }
+}
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 ccbd552..27ec89d 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
@@ -23,7 +23,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -36,7 +35,8 @@
   private final ReviewerResource.Factory resourceFactory;
 
   @Inject
-  ListReviewers(Provider<ReviewDb> dbProvider,
+  ListReviewers(
+      Provider<ReviewDb> dbProvider,
       ApprovalsUtil approvalsUtil,
       ReviewerResource.Factory resourceFactory,
       ReviewerJson json) {
@@ -50,8 +50,7 @@
   public List<ReviewerInfo> apply(ChangeResource rsrc) throws OrmException {
     Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
-    for (Account.Id accountId
-        : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
+    for (Account.Id accountId : 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/ListRevisionComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java
index 2392781..037a856 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionComments.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -26,10 +26,9 @@
 @Singleton
 public class ListRevisionComments extends ListRevisionDrafts {
   @Inject
-  ListRevisionComments(Provider<ReviewDb> db,
-      Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil) {
-    super(db, commentJson, plcUtil);
+  ListRevisionComments(
+      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
+    super(db, commentJson, commentsUtil);
   }
 
   @Override
@@ -38,9 +37,8 @@
   }
 
   @Override
-  protected Iterable<PatchLineComment> listComments(RevisionResource rsrc)
-      throws OrmException {
+  protected Iterable<Comment> listComments(RevisionResource rsrc) throws OrmException {
     ChangeNotes notes = rsrc.getNotes();
-    return plcUtil.publishedByPatchSet(db.get(), notes, rsrc.getPatchSet().getId());
+    return commentsUtil.publishedByPatchSet(db.get(), notes, rsrc.getPatchSet().getId());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
index ef12b2a..0463601 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
@@ -16,14 +16,13 @@
 
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.List;
 import java.util.Map;
 
@@ -31,21 +30,19 @@
 public class ListRevisionDrafts implements RestReadView<RevisionResource> {
   protected final Provider<ReviewDb> db;
   protected final Provider<CommentJson> commentJson;
-  protected final PatchLineCommentsUtil plcUtil;
+  protected final CommentsUtil commentsUtil;
 
   @Inject
-  ListRevisionDrafts(Provider<ReviewDb> db,
-      Provider<CommentJson> commentJson,
-      PatchLineCommentsUtil plcUtil) {
+  ListRevisionDrafts(
+      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
     this.db = db;
     this.commentJson = commentJson;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
-  protected Iterable<PatchLineComment> listComments(RevisionResource rsrc)
-      throws OrmException {
-    return plcUtil.draftByPatchSetAuthor(db.get(), rsrc.getPatchSet().getId(),
-        rsrc.getAccountId(), rsrc.getNotes());
+  protected Iterable<Comment> listComments(RevisionResource rsrc) throws OrmException {
+    return commentsUtil.draftByPatchSetAuthor(
+        db.get(), rsrc.getPatchSet().getId(), rsrc.getAccountId(), rsrc.getNotes());
   }
 
   protected boolean includeAuthorInfo() {
@@ -53,17 +50,19 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> apply(RevisionResource rsrc)
-      throws OrmException {
-    return commentJson.get()
+  public Map<String, List<CommentInfo>> apply(RevisionResource rsrc) throws OrmException {
+    return commentJson
+        .get()
         .setFillAccounts(includeAuthorInfo())
+        .newCommentFormatter()
         .format(listComments(rsrc));
   }
 
-  public List<CommentInfo> getComments(RevisionResource rsrc)
-      throws OrmException {
-    return commentJson.get()
+  public List<CommentInfo> getComments(RevisionResource rsrc) throws OrmException {
+    return commentJson
+        .get()
         .setFillAccounts(includeAuthorInfo())
+        .newCommentFormatter()
         .formatAsList(listComments(rsrc));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
new file mode 100644
index 0000000..d0c8ca0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+class ListRevisionReviewers implements RestReadView<RevisionResource> {
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+  private final ReviewerJson json;
+  private final ReviewerResource.Factory resourceFactory;
+
+  @Inject
+  ListRevisionReviewers(
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      ReviewerResource.Factory resourceFactory,
+      ReviewerJson json) {
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+    this.resourceFactory = resourceFactory;
+    this.json = json;
+  }
+
+  @Override
+  public List<ReviewerInfo> apply(RevisionResource rsrc)
+      throws OrmException, MethodNotAllowedException {
+    if (!rsrc.isCurrent()) {
+      throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
+    }
+
+    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+    ReviewDb db = dbProvider.get();
+    for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
+      if (!reviewers.containsKey(accountId)) {
+        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      }
+    }
+    return json.format(reviewers.values());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java
new file mode 100644
index 0000000..de2b91a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class ListRobotComments implements RestReadView<RevisionResource> {
+  protected final Provider<ReviewDb> db;
+  protected final Provider<CommentJson> commentJson;
+  protected final CommentsUtil commentsUtil;
+
+  @Inject
+  ListRobotComments(
+      Provider<ReviewDb> db, Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
+    this.db = db;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc) throws OrmException {
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .newRobotCommentFormatter()
+        .format(listComments(rsrc));
+  }
+
+  public List<RobotCommentInfo> getComments(RevisionResource rsrc) throws OrmException {
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .newRobotCommentFormatter()
+        .formatAsList(listComments(rsrc));
+  }
+
+  private Iterable<RobotComment> listComments(RevisionResource rsrc) throws OrmException {
+    return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().getId());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
index 93c4ac3..3a7f3ab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCache.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -25,21 +24,30 @@
 public interface MergeabilityCache {
   class NotImplemented implements MergeabilityCache {
     @Override
-    public boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
-        String mergeStrategy, Branch.NameKey dest, Repository repo) {
+    public boolean get(
+        ObjectId commit,
+        Ref intoRef,
+        SubmitType submitType,
+        String mergeStrategy,
+        Branch.NameKey dest,
+        Repository repo) {
       throw new UnsupportedOperationException("Mergeability checking disabled");
     }
 
     @Override
-    public Boolean getIfPresent(ObjectId commit, Ref intoRef,
-        SubmitType submitType, String mergeStrategy) {
+    public Boolean getIfPresent(
+        ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy) {
       throw new UnsupportedOperationException("Mergeability checking disabled");
     }
   }
 
-  boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
-      String mergeStrategy, Branch.NameKey dest, Repository repo);
+  boolean get(
+      ObjectId commit,
+      Ref intoRef,
+      SubmitType submitType,
+      String mergeStrategy,
+      Branch.NameKey dest,
+      Repository repo);
 
-  Boolean getIfPresent(ObjectId commit, Ref intoRef,
-      SubmitType submitType, String mergeStrategy);
+  Boolean getIfPresent(ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index 62d75aa..70d9b96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -24,7 +24,6 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.cache.Cache;
 import com.google.common.cache.Weigher;
-import com.google.common.collect.BiMap;
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -39,14 +38,6 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
@@ -56,23 +47,32 @@
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class MergeabilityCacheImpl implements MergeabilityCache {
-  private static final Logger log =
-      LoggerFactory.getLogger(MergeabilityCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(MergeabilityCacheImpl.class);
 
   private static final String CACHE_NAME = "mergeability";
 
-  public static final BiMap<SubmitType, Character> SUBMIT_TYPES = ImmutableBiMap.of(
-        SubmitType.FAST_FORWARD_ONLY, 'F',
-        SubmitType.MERGE_IF_NECESSARY, 'M',
-        SubmitType.REBASE_IF_NECESSARY, 'R',
-        SubmitType.MERGE_ALWAYS, 'A',
-        SubmitType.CHERRY_PICK, 'C');
+  public static final ImmutableBiMap<SubmitType, Character> SUBMIT_TYPES =
+      new ImmutableBiMap.Builder<SubmitType, Character>()
+          .put(SubmitType.FAST_FORWARD_ONLY, 'F')
+          .put(SubmitType.MERGE_IF_NECESSARY, 'M')
+          .put(SubmitType.REBASE_ALWAYS, 'P')
+          .put(SubmitType.REBASE_IF_NECESSARY, 'R')
+          .put(SubmitType.MERGE_ALWAYS, 'A')
+          .put(SubmitType.CHERRY_PICK, 'C')
+          .build();
 
   static {
-    checkState(SUBMIT_TYPES.size() == SubmitType.values().length,
+    checkState(
+        SUBMIT_TYPES.size() == SubmitType.values().length,
         "SubmitType <-> char BiMap needs updating");
   }
 
@@ -89,9 +89,7 @@
   }
 
   public static ObjectId toId(Ref ref) {
-    return ref != null && ref.getObjectId() != null
-        ? ref.getObjectId()
-        : ObjectId.zeroId();
+    return ref != null && ref.getObjectId() != null ? ref.getObjectId() : ObjectId.zeroId();
   }
 
   public static class EntryKey implements Serializable {
@@ -102,8 +100,7 @@
     private SubmitType submitType;
     private String mergeStrategy;
 
-    public EntryKey(ObjectId commit, ObjectId into, SubmitType submitType,
-        String mergeStrategy) {
+    public EntryKey(ObjectId commit, ObjectId into, SubmitType submitType, String mergeStrategy) {
       this.commit = checkNotNull(commit, "commit");
       this.into = checkNotNull(into, "into");
       this.submitType = checkNotNull(submitType, "submitType");
@@ -188,8 +185,7 @@
     }
 
     @Override
-    public Boolean call()
-        throws NoSuchProjectException, IntegrationException, IOException {
+    public Boolean call() throws NoSuchProjectException, IntegrationException, IOException {
       if (key.into.equals(ObjectId.zeroId())) {
         return true; // Assume yes on new branch.
       }
@@ -197,17 +193,17 @@
         Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
         accepted.add(rw.parseCommit(key.into));
         accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
-        return submitDryRun.run(
-            key.submitType, repo, rw, dest, key.into, key.commit, accepted);
+        return submitDryRun.run(key.submitType, repo, rw, dest, key.into, key.commit, accepted);
       }
     }
   }
 
-  public static class MergeabilityWeigher
-      implements Weigher<EntryKey, Boolean> {
+  public static class MergeabilityWeigher implements Weigher<EntryKey, Boolean> {
     @Override
     public int weigh(EntryKey k, Boolean v) {
-      return 16 + 2 * (16 + 20) + 3 * 8 // Size of EntryKey, 64-bit JVM.
+      return 16
+          + 2 * (16 + 20)
+          + 3 * 8 // Size of EntryKey, 64-bit JVM.
           + 8; // Size of Boolean.
     }
   }
@@ -217,31 +213,37 @@
 
   @Inject
   MergeabilityCacheImpl(
-      SubmitDryRun submitDryRun,
-      @Named(CACHE_NAME) Cache<EntryKey, Boolean> cache) {
+      SubmitDryRun submitDryRun, @Named(CACHE_NAME) Cache<EntryKey, Boolean> cache) {
     this.submitDryRun = submitDryRun;
     this.cache = cache;
   }
 
   @Override
-  public boolean get(ObjectId commit, Ref intoRef, SubmitType submitType,
-      String mergeStrategy, Branch.NameKey dest, Repository repo) {
+  public boolean get(
+      ObjectId commit,
+      Ref intoRef,
+      SubmitType submitType,
+      String mergeStrategy,
+      Branch.NameKey dest,
+      Repository repo) {
     ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
     EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
     try {
       return cache.get(key, new Loader(key, dest, repo));
     } catch (ExecutionException | UncheckedExecutionException e) {
-      log.error(String.format("Error checking mergeability of %s into %s (%s)",
-            key.commit.name(), key.into.name(), key.submitType.name()),
+      log.error(
+          "Error checking mergeability of {} into {} ({})",
+          key.commit.name(),
+          key.into.name(),
+          key.submitType.name(),
           e.getCause());
       return false;
     }
   }
 
   @Override
-  public Boolean getIfPresent(ObjectId commit, Ref intoRef,
-      SubmitType submitType, String mergeStrategy) {
-    return cache.getIfPresent(
-        new EntryKey(commit, toId(intoRef), submitType, mergeStrategy));
+  public Boolean getIfPresent(
+      ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy) {
+    return cache.getIfPresent(new EntryKey(commit, toId(intoRef), submitType, mergeStrategy));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
index 7796d18..f585e14 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
@@ -36,7 +36,11 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -45,16 +49,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Objects;
-
 public class Mergeable implements RestReadView<RevisionResource> {
   private static final Logger log = LoggerFactory.getLogger(Mergeable.class);
 
-  @Option(name = "--other-branches", aliases = {"-o"},
+  @Option(
+      name = "--other-branches",
+      aliases = {"-o"},
       usage = "test mergeability for other branches too")
   private boolean otherBranches;
 
@@ -67,7 +67,8 @@
   private final MergeabilityCache cache;
 
   @Inject
-  Mergeable(GitRepositoryManager gitManager,
+  Mergeable(
+      GitRepositoryManager gitManager,
       ProjectCache projectCache,
       MergeUtil.Factory mergeUtilFactory,
       ChangeData.Factory changeDataFactory,
@@ -88,8 +89,9 @@
   }
 
   @Override
-  public MergeableInfo apply(RevisionResource resource) throws AuthException,
-      ResourceConflictException, BadRequestException, OrmException, IOException {
+  public MergeableInfo apply(RevisionResource resource)
+      throws AuthException, ResourceConflictException, BadRequestException, OrmException,
+          IOException {
     Change change = resource.getChange();
     PatchSet ps = resource.getPatchSet();
     MergeableInfo result = new MergeableInfo();
@@ -108,11 +110,9 @@
       ObjectId commit = toId(ps);
       Ref ref = git.getRefDatabase().exactRef(change.getDest().get());
       ProjectState projectState = projectCache.get(change.getProject());
-      String strategy = mergeUtilFactory.create(projectState)
-          .mergeStrategyName();
+      String strategy = mergeUtilFactory.create(projectState).mergeStrategyName();
       result.strategy = strategy;
-      result.mergeable =
-          isMergable(git, change, commit, ref, result.submitType, strategy);
+      result.mergeable = isMergable(git, change, commit, ref, result.submitType, strategy);
 
       if (otherBranches) {
         result.mergeableInto = new ArrayList<>();
@@ -126,8 +126,7 @@
             if (other == null) {
               continue;
             }
-            if (cache.get(commit, other, SubmitType.CHERRY_PICK, strategy,
-                change.getDest(), git)) {
+            if (cache.get(commit, other, SubmitType.CHERRY_PICK, strategy, change.getDest(), git)) {
               result.mergeableInto.add(other.getName().substring(prefixLen));
             }
           }
@@ -137,19 +136,22 @@
     return result;
   }
 
-  private SubmitType getSubmitType(ChangeData cd, PatchSet patchSet)
-      throws OrmException {
-    SubmitTypeRecord rec =
-        new SubmitRuleEvaluator(cd).setPatchSet(patchSet).getSubmitType();
+  private SubmitType getSubmitType(ChangeData cd, PatchSet patchSet) throws OrmException {
+    SubmitTypeRecord rec = new SubmitRuleEvaluator(cd).setPatchSet(patchSet).getSubmitType();
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new OrmException("Submit type rule failed: " + rec);
     }
     return rec.type;
   }
 
-  private boolean isMergable(Repository git, Change change, ObjectId commit,
-      Ref ref, SubmitType submitType, String strategy)
-          throws IOException, OrmException {
+  private boolean isMergable(
+      Repository git,
+      Change change,
+      ObjectId commit,
+      Ref ref,
+      SubmitType submitType,
+      String strategy)
+      throws IOException, OrmException {
     if (commit == null) {
       return false;
     }
@@ -158,8 +160,7 @@
     if (old != null) {
       return old;
     }
-    return refresh(change, commit, ref, submitType,
-          strategy, git, old);
+    return refresh(change, commit, ref, submitType, strategy, git, old);
   }
 
   private static ObjectId toId(PatchSet ps) {
@@ -171,11 +172,16 @@
     }
   }
 
-  private boolean refresh(final Change change, ObjectId commit,
-      final Ref ref, SubmitType type, String strategy, Repository git,
-      Boolean old) throws OrmException, IOException {
-    final boolean mergeable =
-        cache.get(commit, ref, type, strategy, change.getDest(), git);
+  private boolean refresh(
+      final Change change,
+      ObjectId commit,
+      final Ref ref,
+      SubmitType type,
+      String strategy,
+      Repository git,
+      Boolean old)
+      throws OrmException, IOException {
+    final boolean mergeable = cache.get(commit, ref, type, strategy, change.getDest(), git);
     if (!Objects.equals(mergeable, old)) {
       invalidateETag(change.getId(), db.get());
       indexer.index(db.get(), change);
@@ -183,8 +189,7 @@
     return mergeable;
   }
 
-  private static void invalidateETag(Change.Id id, ReviewDb db)
-      throws OrmException {
+  private static void invalidateETag(Change.Id id, ReviewDb db) throws OrmException {
     // Empty update of Change to bump rowVersion, changing its ETag.
     // TODO(dborowitz): Include cache info in ETag somehow instead.
     db = ReviewDbUtil.unwrapDb(db);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index 6de7deb..aca6ef1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
 import static com.google.gerrit.server.change.VoteResource.VOTE_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -35,13 +36,16 @@
     bind(ChangesCollection.class);
     bind(Revisions.class);
     bind(Reviewers.class);
+    bind(RevisionReviewers.class);
     bind(DraftComments.class);
     bind(Comments.class);
+    bind(RobotComments.class);
     bind(Files.class);
     bind(Votes.class);
 
     DynamicMap.mapOf(binder(), CHANGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
+    DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
@@ -50,17 +54,23 @@
     DynamicMap.mapOf(binder(), VOTE_KIND);
 
     get(CHANGE_KIND).to(GetChange.class);
+    post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
     get(CHANGE_KIND, "detail").to(GetDetail.class);
     get(CHANGE_KIND, "topic").to(GetTopic.class);
-    get(CHANGE_KIND, "in").to(IncludedIn.class);
+    get(CHANGE_KIND, "in").to(ChangeIncludedIn.class);
+    get(CHANGE_KIND, "assignee").to(GetAssignee.class);
+    get(CHANGE_KIND, "past_assignees").to(GetPastAssignees.class);
+    put(CHANGE_KIND, "assignee").to(PutAssignee.class);
+    delete(CHANGE_KIND, "assignee").to(DeleteAssignee.class);
     get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
     get(CHANGE_KIND, "comments").to(ListChangeComments.class);
+    get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
     get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
     get(CHANGE_KIND, "check").to(Check.class);
     post(CHANGE_KIND, "check").to(Check.class);
     put(CHANGE_KIND, "topic").to(PutTopic.class);
     delete(CHANGE_KIND, "topic").to(PutTopic.class);
-    delete(CHANGE_KIND).to(DeleteDraftChange.class);
+    delete(CHANGE_KIND).to(DeleteChange.class);
     post(CHANGE_KIND, "abandon").to(Abandon.class);
     post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
     post(CHANGE_KIND, "publish").to(PublishDraftPatchSet.CurrentRevision.class);
@@ -78,6 +88,7 @@
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
     get(REVIEWER_KIND).to(GetReviewer.class);
     delete(REVIEWER_KIND).to(DeleteReviewer.class);
+    post(REVIEWER_KIND, "delete").to(DeleteReviewer.class);
     child(REVIEWER_KIND, "votes").to(Votes.class);
     delete(VOTE_KIND).to(DeleteVote.class);
     post(VOTE_KIND, "delete").to(DeleteVote.class);
@@ -92,13 +103,19 @@
     get(REVISION_KIND, "related").to(GetRelated.class);
     get(REVISION_KIND, "review").to(GetReview.class);
     post(REVISION_KIND, "review").to(PostReview.class);
+    get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
     post(REVISION_KIND, "submit").to(Submit.class);
     post(REVISION_KIND, "rebase").to(Rebase.class);
+    put(REVISION_KIND, "description").to(PutDescription.class);
+    get(REVISION_KIND, "description").to(GetDescription.class);
     get(REVISION_KIND, "patch").to(GetPatch.class);
     get(REVISION_KIND, "submit_type").to(TestSubmitType.Get.class);
     post(REVISION_KIND, "test.submit_rule").to(TestSubmitRule.class);
     post(REVISION_KIND, "test.submit_type").to(TestSubmitType.class);
     get(REVISION_KIND, "archive").to(GetArchive.class);
+    get(REVISION_KIND, "mergelist").to(GetMergeList.class);
+
+    child(REVISION_KIND, "reviewers").to(RevisionReviewers.class);
 
     child(REVISION_KIND, "drafts").to(DraftComments.class);
     put(REVISION_KIND, "drafts").to(CreateDraftComment.class);
@@ -109,6 +126,9 @@
     child(REVISION_KIND, "comments").to(Comments.class);
     get(COMMENT_KIND).to(GetComment.class);
 
+    child(REVISION_KIND, "robotcomments").to(RobotComments.class);
+    get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
+
     child(REVISION_KIND, "files").to(Files.class);
     put(FILE_KIND, "reviewed").to(PutReviewed.class);
     delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
@@ -136,6 +156,7 @@
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
+    factory(SetAssigneeOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
     factory(ChangeResource.Factory.class);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
index 2139ec4..c3445d0c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -33,29 +34,26 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-
 @Singleton
 public class Move implements RestModifyView<ChangeResource, MoveInput> {
   private final Provider<ReviewDb> dbProvider;
@@ -67,7 +65,8 @@
   private final PatchSetUtil psUtil;
 
   @Inject
-  Move(Provider<ReviewDb> dbProvider,
+  Move(
+      Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
@@ -92,33 +91,37 @@
       throw new AuthException("Move not permitted");
     }
 
-    try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
-        req.getChange().getProject(), control.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), new Op(control, input));
+    Op op = new Op(input);
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(), req.getChange().getProject(), control.getUser(), TimeUtil.nowTs())) {
+      u.addOp(req.getChange().getId(), op);
       u.execute();
     }
 
-    return json.create(ChangeJson.NO_OPTIONS).format(req.getChange());
+    return json.noOptions().format(op.getChange());
   }
 
-  private class Op extends BatchUpdate.Op {
+  private class Op implements BatchUpdateOp {
     private final MoveInput input;
-    private final IdentifiedUser caller;
 
     private Change change;
     private Branch.NameKey newDestKey;
 
-    Op(ChangeControl ctl, MoveInput input) {
+    Op(MoveInput input) {
       this.input = input;
-      this.caller = ctl.getUser().asIdentifiedUser();
+    }
+
+    @Nullable
+    public Change getChange() {
+      return change;
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException,
-        ResourceConflictException, RepositoryNotFoundException, IOException {
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, ResourceConflictException, RepositoryNotFoundException, IOException {
       change = ctx.getChange();
-      if (change.getStatus() != Status.NEW
-          && change.getStatus() != Status.DRAFT) {
+      if (change.getStatus() != Status.NEW && change.getStatus() != Status.DRAFT) {
         throw new ResourceConflictException("Change is " + status(change));
       }
 
@@ -126,16 +129,16 @@
       newDestKey = new Branch.NameKey(projectKey, input.destinationBranch);
       Branch.NameKey changePrevDest = change.getDest();
       if (changePrevDest.equals(newDestKey)) {
-        throw new ResourceConflictException(
-            "Change is already destined for the specified branch");
+        throw new ResourceConflictException("Change is already destined for the specified branch");
       }
 
       final PatchSet.Id patchSetId = change.currentPatchSetId();
       try (Repository repo = repoManager.openRepository(projectKey);
           RevWalk revWalk = new RevWalk(repo)) {
-        RevCommit currPatchsetRevCommit = revWalk.parseCommit(
-            ObjectId.fromString(psUtil.current(ctx.getDb(), ctx.getNotes())
-                .getRevision().get()));
+        RevCommit currPatchsetRevCommit =
+            revWalk.parseCommit(
+                ObjectId.fromString(
+                    psUtil.current(ctx.getDb(), ctx.getNotes()).getRevision().get()));
         if (currPatchsetRevCommit.getParentCount() > 1) {
           throw new ResourceConflictException("Merge commit cannot be moved");
         }
@@ -149,17 +152,17 @@
         RevCommit refCommit = revWalk.parseCommit(refId);
         if (revWalk.isMergedInto(currPatchsetRevCommit, refCommit)) {
           throw new ResourceConflictException(
-              "Current patchset revision is reachable from tip of "
-                  + input.destinationBranch);
+              "Current patchset revision is reachable from tip of " + input.destinationBranch);
         }
       }
 
       Change.Key changeKey = change.getKey();
-      if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey))
-          .isEmpty()) {
+      if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) {
         throw new ResourceConflictException(
-            "Destination " + newDestKey.getShortName()
-                + " has a different change with same change key " + changeKey);
+            "Destination "
+                + newDestKey.getShortName()
+                + " has a different change with same change key "
+                + changeKey);
       }
 
       if (!change.currentPatchSetId().equals(patchSetId)) {
@@ -179,11 +182,8 @@
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
       }
-      ChangeMessage cmsg = new ChangeMessage(
-          new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          caller.getAccountId(), ctx.getWhen(), change.currentPatchSetId());
-      cmsg.setMessage(msgBuf.toString());
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
       cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
 
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java
new file mode 100644
index 0000000..8516615
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+@Singleton
+public class NotifyUtil {
+  private final Provider<ReviewDb> dbProvider;
+  private final AccountResolver accountResolver;
+
+  @Inject
+  NotifyUtil(Provider<ReviewDb> dbProvider, AccountResolver accountResolver) {
+    this.dbProvider = dbProvider;
+    this.accountResolver = accountResolver;
+  }
+
+  public static boolean shouldNotify(
+      NotifyHandling notify, @Nullable Map<RecipientType, NotifyInfo> notifyDetails) {
+    if (!isNullOrEmpty(notifyDetails)) {
+      return true;
+    }
+
+    return notify.compareTo(NotifyHandling.NONE) > 0;
+  }
+
+  private static boolean isNullOrEmpty(@Nullable Map<RecipientType, NotifyInfo> notifyDetails) {
+    if (notifyDetails == null || notifyDetails.isEmpty()) {
+      return true;
+    }
+
+    for (NotifyInfo notifyInfo : notifyDetails.values()) {
+      if (!isEmpty(notifyInfo)) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  private static boolean isEmpty(NotifyInfo notifyInfo) {
+    return notifyInfo.accounts == null || notifyInfo.accounts.isEmpty();
+  }
+
+  public ListMultimap<RecipientType, Account.Id> resolveAccounts(
+      @Nullable Map<RecipientType, NotifyInfo> notifyDetails)
+      throws OrmException, BadRequestException {
+    if (isNullOrEmpty(notifyDetails)) {
+      return ImmutableListMultimap.of();
+    }
+
+    ListMultimap<RecipientType, Account.Id> m = null;
+    for (Entry<RecipientType, NotifyInfo> e : notifyDetails.entrySet()) {
+      List<String> accounts = e.getValue().accounts;
+      if (accounts != null) {
+        if (m == null) {
+          m = MultimapBuilder.hashKeys().arrayListValues().build();
+        }
+        m.putAll(e.getKey(), find(dbProvider.get(), accounts));
+      }
+    }
+
+    return m != null ? m : ImmutableListMultimap.of();
+  }
+
+  private List<Account.Id> find(ReviewDb db, List<String> nameOrEmails)
+      throws OrmException, BadRequestException {
+    List<String> missing = new ArrayList<>(nameOrEmails.size());
+    List<Account.Id> r = new ArrayList<>(nameOrEmails.size());
+    for (String nameOrEmail : nameOrEmails) {
+      Account a = accountResolver.find(db, nameOrEmail);
+      if (a != null) {
+        r.add(a.getId());
+      } else {
+        missing.add(nameOrEmail);
+      }
+    }
+
+    if (!missing.isEmpty()) {
+      throw new BadRequestException(
+          "The following accounts that should be notified could not be resolved: "
+              + missing.stream().distinct().sorted().collect(joining(", ")));
+    }
+
+    return r;
+  }
+}
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 f41d41d..7cf62a0 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,9 +19,13 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.AuthException;
 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;
@@ -30,46 +34,38 @@
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 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.extensions.events.RevisionCreated;
-import com.google.gerrit.server.git.BanCommit;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
-import com.google.gerrit.server.ssh.SshInfo;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-
-public class PatchSetInserter extends BatchUpdate.Op {
-  private static final Logger log =
-      LoggerFactory.getLogger(PatchSetInserter.class);
+public class PatchSetInserter implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(PatchSetInserter.class);
 
   public interface Factory {
-    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId,
-        RevCommit commit);
+    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId, RevCommit commit);
   }
 
   // Injected fields.
@@ -91,15 +87,15 @@
   private final ChangeControl origCtl;
 
   // Fields exposed as setters.
-  private SshInfo sshInfo;
   private String message;
-  private CommitValidators.Policy validatePolicy =
-      CommitValidators.Policy.GERRIT;
+  private String description;
+  private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
   private boolean checkAddPatchSetPermission = true;
   private boolean draft;
   private List<String> groups = Collections.emptyList();
   private boolean fireRevisionCreated = true;
-  private boolean sendMail = true;
+  private NotifyHandling notify = NotifyHandling.ALL;
+  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
   private boolean allowClosed;
   private boolean copyApprovals = true;
 
@@ -111,7 +107,8 @@
   private ReviewerSet oldReviewers;
 
   @AssistedInject
-  public PatchSetInserter(ApprovalsUtil approvalsUtil,
+  public PatchSetInserter(
+      ApprovalsUtil approvalsUtil,
       ApprovalCopier approvalCopier,
       ChangeMessagesUtil cmUtil,
       PatchSetInfoFactory patchSetInfoFactory,
@@ -145,8 +142,8 @@
     return this;
   }
 
-  public PatchSetInserter setSshInfo(SshInfo sshInfo) {
-    this.sshInfo = sshInfo;
+  public PatchSetInserter setDescription(String description) {
+    this.description = description;
     return this;
   }
 
@@ -155,8 +152,7 @@
     return this;
   }
 
-  public PatchSetInserter setCheckAddPatchSetPermission(
-      boolean checkAddPatchSetPermission) {
+  public PatchSetInserter setCheckAddPatchSetPermission(boolean checkAddPatchSetPermission) {
     this.checkAddPatchSetPermission = checkAddPatchSetPermission;
     return this;
   }
@@ -177,8 +173,14 @@
     return this;
   }
 
-  public PatchSetInserter setSendMail(boolean sendMail) {
-    this.sendMail = sendMail;
+  public PatchSetInserter setNotify(NotifyHandling notify) {
+    this.notify = notify;
+    return this;
+  }
+
+  public PatchSetInserter setAccountsToNotify(
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.accountsToNotify = checkNotNull(accountsToNotify);
     return this;
   }
 
@@ -205,10 +207,10 @@
   @Override
   public void updateRepo(RepoContext ctx)
       throws AuthException, ResourceConflictException, IOException, OrmException {
-    init();
     validate(ctx);
-    ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(),
-        commit, getPatchSetId().toRefName(), ReceiveCommand.Type.CREATE));
+    ctx.addRefUpdate(
+        new ReceiveCommand(
+            ObjectId.zeroId(), commit, getPatchSetId().toRefName(), ReceiveCommand.Type.CREATE));
   }
 
   @Override
@@ -222,9 +224,10 @@
     update.setSubjectForCommit("Create patch set " + psId.get());
 
     if (!change.getStatus().isOpen() && !allowClosed) {
-      throw new ResourceConflictException(String.format(
-          "Cannot create new patch set of change %s because it is %s",
-          change.getId(), change.getStatus().name().toLowerCase()));
+      throw new ResourceConflictException(
+          String.format(
+              "Cannot create new patch set of change %s because it is %s",
+              change.getId(), change.getStatus().name().toLowerCase()));
     }
 
     List<String> newGroups = groups;
@@ -234,17 +237,30 @@
         newGroups = prevPs.getGroups();
       }
     }
-    patchSet = psUtil.insert(db, ctx.getRevWalk(), ctx.getUpdate(psId),
-        psId, commit, draft, newGroups, null);
+    patchSet =
+        psUtil.insert(
+            db,
+            ctx.getRevWalk(),
+            ctx.getUpdate(psId),
+            psId,
+            commit,
+            draft,
+            newGroups,
+            null,
+            description);
 
-    if (sendMail) {
+    if (notify != NotifyHandling.NONE) {
       oldReviewers = approvalsUtil.getReviewers(db, ctl.getNotes());
     }
 
     if (message != null) {
-      changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(ctl.getId(), ChangeUtil.messageUUID(db)),
-          ctx.getAccountId(), ctx.getWhen(), patchSet.getId());
+      changeMessage =
+          ChangeMessagesUtil.newMessage(
+              patchSet.getId(),
+              ctx.getUser(),
+              ctx.getWhen(),
+              message,
+              ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
       changeMessage.setMessage(message);
     }
 
@@ -264,70 +280,52 @@
 
   @Override
   public void postUpdate(Context ctx) throws OrmException {
-    if (sendMail) {
+    if (notify != NotifyHandling.NONE || !accountsToNotify.isEmpty()) {
       try {
-        ReplacePatchSetSender cm = replacePatchSetFactory.create(
-            ctx.getProject(), change.getId());
+        ReplacePatchSetSender cm = replacePatchSetFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
         cm.setPatchSet(patchSet, patchSetInfo);
         cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
         cm.addReviewers(oldReviewers.byState(REVIEWER));
         cm.addExtraCC(oldReviewers.byState(CC));
+        cm.setNotify(notify);
+        cm.setAccountsToNotify(accountsToNotify);
         cm.send();
       } catch (Exception err) {
-        log.error("Cannot send email for new patch set on change "
-            + change.getId(), err);
+        log.error("Cannot send email for new patch set on change " + change.getId(), err);
       }
     }
 
-    NotifyHandling notify = sendMail
-        ? NotifyHandling.ALL
-        : NotifyHandling.NONE;
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(),
-          ctx.getWhen(), notify);
-    }
-  }
-
-  private void init() {
-    if (sshInfo == null) {
-      sshInfo = new NoSshInfo();
+      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
     }
   }
 
   private void validate(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException,
-      OrmException {
-    CommitValidators cv = commitValidatorsFactory.create(
-        origCtl.getRefControl(), sshInfo, ctx.getRepository());
-
+      throws AuthException, ResourceConflictException, IOException, OrmException {
     if (checkAddPatchSetPermission && !origCtl.canAddPatchSet(ctx.getDb())) {
       throw new AuthException("cannot add patch set");
     }
+    if (validatePolicy == CommitValidators.Policy.NONE) {
+      return;
+    }
 
     String refName = getPatchSetId().toRefName();
-    CommitReceivedEvent event = new CommitReceivedEvent(
-        new ReceiveCommand(
-            ObjectId.zeroId(),
-            commit.getId(),
-            refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
-        origCtl.getProjectControl().getProject(),
-        origCtl.getRefControl().getRefName(),
-        commit, ctx.getIdentifiedUser());
+    CommitReceivedEvent event =
+        new CommitReceivedEvent(
+            new ReceiveCommand(
+                ObjectId.zeroId(),
+                commit.getId(),
+                refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
+            origCtl.getProjectControl().getProject(),
+            origCtl.getRefControl().getRefName(),
+            commit,
+            ctx.getIdentifiedUser());
 
     try {
-      switch (validatePolicy) {
-      case RECEIVE_COMMITS:
-        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(
-            ctx.getRepository(), ctx.getRevWalk());
-        cv.validateForReceiveCommits(event, rejectCommits);
-        break;
-      case GERRIT:
-        cv.validateForGerritCommits(event);
-        break;
-      case NONE:
-        break;
-      }
+      commitValidatorsFactory
+          .create(validatePolicy, origCtl.getRefControl(), new NoSshInfo(), ctx.getRepository())
+          .validate(event);
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
index 6d7720d..5aa41b1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
@@ -22,22 +22,22 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class PostHashtags
-    implements RestModifyView<ChangeResource, HashtagsInput>,
-    UiAction<ChangeResource> {
+    implements RestModifyView<ChangeResource, HashtagsInput>, UiAction<ChangeResource> {
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final SetHashtagsOp.Factory hashtagsFactory;
 
   @Inject
-  PostHashtags(Provider<ReviewDb> db,
+  PostHashtags(
+      Provider<ReviewDb> db,
       BatchUpdate.Factory batchUpdateFactory,
       SetHashtagsOp.Factory hashtagsFactory) {
     this.db = db;
@@ -46,22 +46,22 @@
   }
 
   @Override
-  public Response<ImmutableSortedSet<String>> apply(ChangeResource req,
-      HashtagsInput input) throws RestApiException, UpdateException {
-    try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
-          req.getChange().getProject(), req.getControl().getUser(),
-          TimeUtil.nowTs())) {
+  public Response<ImmutableSortedSet<String>> apply(ChangeResource req, HashtagsInput input)
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db.get(), req.getChange().getProject(), req.getControl().getUser(), TimeUtil.nowTs())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
       bu.addOp(req.getId(), op);
       bu.execute();
-      return Response.<ImmutableSortedSet<String>> ok(op.getUpdatedHashtags());
+      return Response.<ImmutableSortedSet<String>>ok(op.getUpdatedHashtags());
     }
   }
 
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
     return new UiAction.Description()
-      .setLabel("Edit Hashtags")
-      .setVisible(resource.getControl().canEditHashtags());
+        .setLabel("Edit Hashtags")
+        .setVisible(resource.getControl().canEditHashtags());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 6e2d51a..2fa5642 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -14,22 +14,24 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
-import static com.google.gerrit.server.change.PutDraftComment.side;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
@@ -41,14 +43,21 @@
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -58,38 +67,41 @@
 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.CommentRange;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.reviewdb.client.FixSuggestion;
+import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
 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.account.AccountsCollection;
 import com.google.gerrit.server.extensions.events.CommentAdded;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -100,7 +112,10 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
@@ -112,33 +127,38 @@
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountsCollection accounts;
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
   private final PostReviewers postReviewers;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
 
   @Inject
-  PostReview(Provider<ReviewDb> db,
+  PostReview(
+      Provider<ReviewDb> db,
       BatchUpdate.Factory batchUpdateFactory,
       ChangesCollection changes,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountsCollection accounts,
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
-      PostReviewers postReviewers) {
+      PostReviewers postReviewers,
+      NotesMigration migration,
+      NotifyUtil notifyUtil) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changes = changes;
     this.changeDataFactory = changeDataFactory;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
@@ -147,6 +167,8 @@
     this.email = email;
     this.commentAdded = commentAdded;
     this.postReviewers = postReviewers;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
   }
 
   @Override
@@ -155,8 +177,7 @@
     return apply(revision, input, TimeUtil.nowTs());
   }
 
-  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input,
-      Timestamp ts)
+  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Timestamp ts)
       throws RestApiException, UpdateException, OrmException, IOException {
     // Respect timestamp, but truncate at change created-on time.
     ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
@@ -165,18 +186,31 @@
     }
     if (input.onBehalfOf != null) {
       revision = onBehalfOf(revision, input);
+    } else if (input.drafts == null) {
+      input.drafts = DraftHandling.DELETE;
     }
     if (input.labels != null) {
       checkLabels(revision, input.strictLabels, input.labels);
     }
     if (input.comments != null) {
+      cleanUpComments(input.comments);
       checkComments(revision, input.comments);
     }
+    if (input.robotComments != null) {
+      if (!migration.readChanges()) {
+        throw new MethodNotAllowedException("robot comments not supported");
+      }
+      checkRobotComments(revision, input.robotComments);
+    }
+
     if (input.notify == null) {
       log.warn("notify = null; assuming notify = NONE");
       input.notify = NotifyHandling.NONE;
     }
 
+    ListMultimap<RecipientType, Account.Id> accountsToNotify =
+        notifyUtil.resolveAccounts(input.notifyDetails);
+
     Map<String, AddReviewerResult> reviewerJsonResults = null;
     List<PostReviewers.Addition> reviewerResults = Lists.newArrayList();
     boolean hasError = false;
@@ -187,8 +221,8 @@
         // Prevent notifications because setting reviewers is batched.
         reviewerInput.notify = NotifyHandling.NONE;
 
-        PostReviewers.Addition result = postReviewers.prepareApplication(
-            revision.getChangeResource(), reviewerInput);
+        PostReviewers.Addition result =
+            postReviewers.prepareApplication(revision.getChangeResource(), reviewerInput, true);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
         if (result.result.error != null) {
           hasError = true;
@@ -209,30 +243,74 @@
     }
     output.labels = input.labels;
 
-    try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
-          revision.getChange().getProject(), revision.getUser(), ts)) {
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
+      Account.Id id = revision.getUser().getAccountId();
+      boolean ccOrReviewer = false;
+      if (input.labels != null && !input.labels.isEmpty()) {
+        ccOrReviewer = input.labels.values().stream().filter(v -> v != 0).findFirst().isPresent();
+      }
+
+      if (!ccOrReviewer) {
+        // Check if user was already CCed or reviewing prior to this review.
+        ReviewerSet currentReviewers =
+            approvalsUtil.getReviewers(db.get(), revision.getChangeResource().getNotes());
+        ccOrReviewer = currentReviewers.all().contains(id);
+      }
+
       // Apply reviewer changes first. Revision emails should be sent to the
-      // updated set of reviewers.
+      // updated set of reviewers. Also keep track of whether the user added
+      // themselves as a reviewer or to the CC list.
       for (PostReviewers.Addition reviewerResult : reviewerResults) {
         bu.addOp(revision.getChange().getId(), reviewerResult.op);
+        if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
+          for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
+            if (Objects.equals(id.get(), reviewerInfo._accountId)) {
+              ccOrReviewer = true;
+              break;
+            }
+          }
+        }
+        if (!ccOrReviewer && reviewerResult.result.ccs != null) {
+          for (AccountInfo accountInfo : reviewerResult.result.ccs) {
+            if (Objects.equals(id.get(), accountInfo._accountId)) {
+              ccOrReviewer = true;
+              break;
+            }
+          }
+        }
       }
+
+      if (!ccOrReviewer) {
+        // User posting this review isn't currently in the reviewer or CC list,
+        // isn't being explicitly added, and isn't voting on any label.
+        // Automatically CC them on this change so they receive replies.
+        PostReviewers.Addition selfAddition =
+            postReviewers.ccCurrentUser(revision.getUser(), revision);
+        bu.addOp(revision.getChange().getId(), selfAddition.op);
+      }
+
       bu.addOp(
           revision.getChange().getId(),
-          new Op(revision.getPatchSet().getId(), input));
+          new Op(revision.getPatchSet().getId(), input, accountsToNotify, reviewerResults));
       bu.execute();
 
       for (PostReviewers.Addition reviewerResult : reviewerResults) {
         reviewerResult.gatherResults();
       }
 
-      emailReviewers(revision.getChange(), reviewerResults, input.notify);
+      emailReviewers(revision.getChange(), reviewerResults, input.notify, accountsToNotify);
     }
 
     return Response.ok(output);
   }
 
-  private void emailReviewers(Change change,
-      List<PostReviewers.Addition> reviewerAdditions, NotifyHandling notify) {
+  private void emailReviewers(
+      Change change,
+      List<PostReviewers.Addition> reviewerAdditions,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
     List<Account.Id> to = new ArrayList<>();
     List<Account.Id> cc = new ArrayList<>();
     for (PostReviewers.Addition addition : reviewerAdditions) {
@@ -242,16 +320,20 @@
         cc.addAll(addition.op.reviewers.keySet());
       }
     }
-    postReviewers.emailReviewers(change, to, cc, notify);
+    postReviewers.emailReviewers(change, to, cc, notify, accountsToNotify);
   }
 
   private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
-      throws BadRequestException, AuthException, UnprocessableEntityException,
-      OrmException {
+      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException {
     if (in.labels == null || in.labels.isEmpty()) {
-      throw new AuthException(String.format(
-          "label required to post review on behalf of \"%s\"",
-          in.onBehalfOf));
+      throw new AuthException(
+          String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
+    }
+    if (in.drafts == null) {
+      in.drafts = DraftHandling.KEEP;
+    }
+    if (in.drafts != DraftHandling.KEEP) {
+      throw new AuthException("not allowed to modify other user's drafts");
     }
 
     ChangeControl caller = rev.getControl();
@@ -260,43 +342,53 @@
       Map.Entry<String, Short> ent = itr.next();
       LabelType type = caller.getLabelTypes().byLabel(ent.getKey());
       if (type == null && in.strictLabels) {
-        throw new BadRequestException(String.format(
-            "label \"%s\" is not a configured label", ent.getKey()));
+        throw new BadRequestException(
+            String.format("label \"%s\" is not a configured label", ent.getKey()));
       } else if (type == null) {
         itr.remove();
         continue;
       }
 
+      if (caller.getUser().isInternalUser()) {
+        continue;
+      }
+
       PermissionRange r = caller.getRange(Permission.forLabelAs(type.getName()));
       if (r == null || r.isEmpty() || !r.contains(ent.getValue())) {
-        throw new AuthException(String.format(
-            "not permitted to modify label \"%s\" on behalf of \"%s\"",
-            ent.getKey(), in.onBehalfOf));
+        throw new AuthException(
+            String.format(
+                "not permitted to modify label \"%s\" on behalf of \"%s\"",
+                ent.getKey(), in.onBehalfOf));
       }
     }
     if (in.labels.isEmpty()) {
-      throw new AuthException(String.format(
-          "label required to post review on behalf of \"%s\"",
-          in.onBehalfOf));
+      throw new AuthException(
+          String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
     }
 
-    ChangeControl target = caller.forUser(accounts.parse(in.onBehalfOf));
+    ChangeControl target =
+        caller.forUser(accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
+    if (!target.getRefControl().isVisible()) {
+      throw new UnprocessableEntityException(
+          String.format(
+              "on_behalf_of account %s cannot see destination ref",
+              target.getUser().getAccountId()));
+    }
     return new RevisionResource(changes.parse(target), rev.getPatchSet());
   }
 
-  private void checkLabels(RevisionResource revision, boolean strict,
-      Map<String, Short> labels) throws BadRequestException, AuthException {
+  private void checkLabels(RevisionResource revision, boolean strict, Map<String, Short> labels)
+      throws BadRequestException, AuthException {
     ChangeControl ctl = revision.getControl();
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
 
-      LabelType lt = revision.getControl().getLabelTypes()
-          .byLabel(ent.getKey());
+      LabelType lt = revision.getControl().getLabelTypes().byLabel(ent.getKey());
       if (lt == null) {
         if (strict) {
-          throw new BadRequestException(String.format(
-              "label \"%s\" is not a configured label", ent.getKey()));
+          throw new BadRequestException(
+              String.format("label \"%s\" is not a configured label", ent.getKey()));
         }
         itr.remove();
         continue;
@@ -310,9 +402,8 @@
 
       if (lt.getValue(ent.getValue()) == null) {
         if (strict) {
-          throw new BadRequestException(String.format(
-              "label \"%s\": %d is not a valid value",
-              ent.getKey(), ent.getValue()));
+          throw new BadRequestException(
+              String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
         }
         itr.remove();
         continue;
@@ -322,9 +413,9 @@
       PermissionRange range = ctl.getRange(Permission.forLabel(name));
       if (range == null || !range.contains(ent.getValue())) {
         if (strict) {
-          throw new AuthException(String.format(
-              "Applying label \"%s\": %d is restricted",
-              ent.getKey(), ent.getValue()));
+          throw new AuthException(
+              String.format(
+                  "Applying label \"%s\": %d is restricted", ent.getKey(), ent.getValue()));
         } else if (range == null || range.isEmpty()) {
           ent.setValue((short) 0);
         } else {
@@ -334,135 +425,337 @@
     }
   }
 
-  private void checkComments(RevisionResource revision, Map<String, List<CommentInput>> in)
-      throws BadRequestException, OrmException {
-    Iterator<Map.Entry<String, List<CommentInput>>> mapItr =
-        in.entrySet().iterator();
-    Set<String> filePaths =
-        Sets.newHashSet(changeDataFactory.create(
-            db.get(), revision.getControl()).filePaths(
-                revision.getPatchSet()));
-    while (mapItr.hasNext()) {
-      Map.Entry<String, List<CommentInput>> ent = mapItr.next();
-      String path = ent.getKey();
-      if (!filePaths.contains(path) && !Patch.COMMIT_MSG.equals(path)) {
-        throw new BadRequestException(String.format(
-            "file %s not found in revision %s",
-            path, revision.getChange().currentPatchSetId()));
-      }
-
-      List<CommentInput> list = ent.getValue();
-      if (list == null) {
-        mapItr.remove();
+  private <T extends CommentInput> void cleanUpComments(Map<String, List<T>> commentsPerPath) {
+    Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
+    while (mapValueIterator.hasNext()) {
+      List<T> comments = mapValueIterator.next();
+      if (comments == null) {
+        mapValueIterator.remove();
         continue;
       }
 
-      Iterator<CommentInput> listItr = list.iterator();
-      while (listItr.hasNext()) {
-        CommentInput c = listItr.next();
-        if (c == null) {
-          listItr.remove();
-          continue;
-        }
-        if (c.line != null && c.line < 0) {
-          throw new BadRequestException(String.format(
-              "negative line number %d not allowed on %s",
-              c.line, path));
-        }
-        c.message = Strings.nullToEmpty(c.message).trim();
-        if (c.message.isEmpty()) {
-          listItr.remove();
-        }
-      }
-      if (list.isEmpty()) {
-        mapItr.remove();
+      cleanUpComments(comments);
+      if (comments.isEmpty()) {
+        mapValueIterator.remove();
       }
     }
   }
 
-  /**
-   * Used to compare PatchLineComments with CommentInput comments.
-   */
+  private <T extends CommentInput> void cleanUpComments(List<T> comments) {
+    Iterator<T> commentsIterator = comments.iterator();
+    while (commentsIterator.hasNext()) {
+      T comment = commentsIterator.next();
+      if (comment == null) {
+        commentsIterator.remove();
+        continue;
+      }
+
+      comment.message = Strings.nullToEmpty(comment.message).trim();
+      if (comment.message.isEmpty()) {
+        commentsIterator.remove();
+      }
+    }
+  }
+
+  private <T extends CommentInput> void checkComments(
+      RevisionResource revision, Map<String, List<T>> commentsPerPath)
+      throws OrmException, BadRequestException {
+    Set<String> revisionFilePaths = getAffectedFilePaths(revision);
+    for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
+      String path = entry.getKey();
+      PatchSet.Id patchSetId = revision.getChange().currentPatchSetId();
+      ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
+
+      List<T> comments = entry.getValue();
+      for (T comment : comments) {
+        ensureLineIsNonNegative(comment.line, path);
+        ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
+        ensureRangeIsValid(path, comment.range);
+      }
+    }
+  }
+
+  private Set<String> getAffectedFilePaths(RevisionResource revision) throws OrmException {
+    ChangeData changeData = changeDataFactory.create(db.get(), revision.getControl());
+    return new HashSet<>(changeData.filePaths(revision.getPatchSet()));
+  }
+
+  private void ensurePathRefersToAvailableOrMagicFile(
+      String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
+      throws BadRequestException {
+    if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
+      throw new BadRequestException(
+          String.format("file %s not found in revision %s", path, patchSetId));
+    }
+  }
+
+  private void ensureLineIsNonNegative(Integer line, String path) throws BadRequestException {
+    if (line != null && line < 0) {
+      throw new BadRequestException(
+          String.format("negative line number %d not allowed on %s", line, path));
+    }
+  }
+
+  private <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
+      String path, T comment) throws BadRequestException {
+    if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
+      throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
+    }
+  }
+
+  private void checkRobotComments(
+      RevisionResource revision, Map<String, List<RobotCommentInput>> in)
+      throws BadRequestException, OrmException {
+    cleanUpComments(in);
+    for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
+      String commentPath = e.getKey();
+      for (RobotCommentInput c : e.getValue()) {
+        ensureRobotIdIsSet(c.robotId, commentPath);
+        ensureRobotRunIdIsSet(c.robotRunId, commentPath);
+        ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
+      }
+    }
+    checkComments(revision, in);
+  }
+
+  private void ensureRobotIdIsSet(String robotId, String commentPath) throws BadRequestException {
+    if (robotId == null) {
+      throw new BadRequestException(
+          String.format("robotId is missing for robot comment on %s", commentPath));
+    }
+  }
+
+  private void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
+      throws BadRequestException {
+    if (robotRunId == null) {
+      throw new BadRequestException(
+          String.format("robotRunId is missing for robot comment on %s", commentPath));
+    }
+  }
+
+  private void ensureFixSuggestionsAreAddable(
+      List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
+    if (fixSuggestionInfos == null) {
+      return;
+    }
+
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      ensureDescriptionIsSet(commentPath, fixSuggestionInfo.description);
+      ensureFixReplacementsAreAddable(commentPath, fixSuggestionInfo.replacements);
+    }
+  }
+
+  private void ensureDescriptionIsSet(String commentPath, String description)
+      throws BadRequestException {
+    if (description == null) {
+      throw new BadRequestException(
+          String.format(
+              "A description is required for the suggested fix of the robot comment on %s",
+              commentPath));
+    }
+  }
+
+  private void ensureFixReplacementsAreAddable(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    ensureReplacementsArePresent(commentPath, fixReplacementInfos);
+
+    for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
+      ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
+      ensureReplacementPathRefersToFileOfComment(commentPath, fixReplacementInfo.path);
+      ensureRangeIsSet(commentPath, fixReplacementInfo.range);
+      ensureRangeIsValid(commentPath, fixReplacementInfo.range);
+      ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
+    }
+  }
+
+  private void ensureReplacementsArePresent(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
+      throw new BadRequestException(
+          String.format(
+              "At least one replacement is "
+                  + "required for the suggested fix of the robot comment on %s",
+              commentPath));
+    }
+  }
+
+  private void ensureReplacementPathIsSet(String commentPath, String replacementPath)
+      throws BadRequestException {
+    if (replacementPath == null) {
+      throw new BadRequestException(
+          String.format(
+              "A file path must be given for the replacement of the robot comment on %s",
+              commentPath));
+    }
+  }
+
+  private void ensureReplacementPathRefersToFileOfComment(
+      String commentPath, String replacementPath) throws BadRequestException {
+    if (!Objects.equals(commentPath, replacementPath)) {
+      throw new BadRequestException(
+          String.format(
+              "Replacements may only be "
+                  + "specified for the file %s on which the robot comment was added",
+              commentPath));
+    }
+  }
+
+  private void ensureRangeIsSet(
+      String commentPath, com.google.gerrit.extensions.client.Comment.Range range)
+      throws BadRequestException {
+    if (range == null) {
+      throw new BadRequestException(
+          String.format(
+              "A range must be given for the replacement of the robot comment on %s", commentPath));
+    }
+  }
+
+  private void ensureRangeIsValid(
+      String commentPath, com.google.gerrit.extensions.client.Comment.Range range)
+      throws BadRequestException {
+    if (range == null) {
+      return;
+    }
+    if (!range.isValid()) {
+      throw new BadRequestException(
+          String.format(
+              "Range (%s:%s - %s:%s) is not valid for the comment on %s",
+              range.startLine,
+              range.startCharacter,
+              range.endLine,
+              range.endCharacter,
+              commentPath));
+    }
+  }
+
+  private void ensureReplacementStringIsSet(String commentPath, String replacement)
+      throws BadRequestException {
+    if (replacement == null) {
+      throw new BadRequestException(
+          String.format(
+              "A content for replacement "
+                  + "must be indicated for the replacement of the robot comment on %s",
+              commentPath));
+    }
+  }
+
+  /** Used to compare Comments with CommentInput comments. */
   @AutoValue
   abstract static class CommentSetEntry {
-    private static CommentSetEntry create(Patch.Key key,
-        Integer line, Side side, HashCode message, CommentRange range) {
-      return new AutoValue_PostReview_CommentSetEntry(key, line, side, message,
-          range);
+    private static CommentSetEntry create(
+        String filename,
+        int patchSetId,
+        Integer line,
+        Side side,
+        HashCode message,
+        Comment.Range range) {
+      return new AutoValue_PostReview_CommentSetEntry(
+          filename, patchSetId, line, side, message, range);
     }
 
-    public static CommentSetEntry create(PatchLineComment comment) {
-      return create(comment.getKey().getParentKey(),
-          comment.getLine(),
-          Side.fromShort(comment.getSide()),
-          Hashing.sha1().hashString(comment.getMessage(), UTF_8),
-          comment.getRange());
+    @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
+    public static CommentSetEntry create(Comment comment) {
+      return create(
+          comment.key.filename,
+          comment.key.patchSetId,
+          comment.lineNbr,
+          Side.fromShort(comment.side),
+          Hashing.sha1().hashString(comment.message, UTF_8),
+          comment.range);
     }
 
-    abstract Patch.Key key();
-    @Nullable abstract Integer line();
+    abstract String filename();
+
+    abstract int patchSetId();
+
+    @Nullable
+    abstract Integer line();
+
     abstract Side side();
+
     abstract HashCode message();
-    @Nullable abstract CommentRange range();
+
+    @Nullable
+    abstract Comment.Range range();
   }
 
-  private class Op extends BatchUpdate.Op {
+  private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
     private final ReviewInput in;
+    private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+    private final List<PostReviewers.Addition> reviewerResults;
 
     private IdentifiedUser user;
     private ChangeNotes notes;
     private PatchSet ps;
     private ChangeMessage message;
-    private List<PatchLineComment> comments = new ArrayList<>();
-    private List<String> labelDelta = new ArrayList<>();
+    private List<Comment> comments = new ArrayList<>();
+    private List<LabelVote> labelDelta = new ArrayList<>();
     private Map<String, Short> approvals = new HashMap<>();
     private Map<String, Short> oldApprovals = new HashMap<>();
 
-    private Op(PatchSet.Id psId, ReviewInput in) {
+    private Op(
+        PatchSet.Id psId,
+        ReviewInput in,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify,
+        List<PostReviewers.Addition> reviewerResults) {
       this.psId = psId;
       this.in = in;
+      this.accountsToNotify = checkNotNull(accountsToNotify);
+      this.reviewerResults = reviewerResults;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException {
+        throws OrmException, ResourceConflictException, UnprocessableEntityException {
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       boolean dirty = false;
       dirty |= insertComments(ctx);
+      dirty |= insertRobotComments(ctx);
       dirty |= updateLabels(ctx);
       dirty |= insertMessage(ctx);
       return dirty;
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(Context ctx) throws OrmException {
       if (message == null) {
         return;
       }
-      if (in.notify.compareTo(NotifyHandling.NONE) > 0) {
-        email.create(
-            in.notify,
-            notes,
-            ps,
-            user,
-            message,
-            comments).sendAsync();
+      if (in.notify.compareTo(NotifyHandling.NONE) > 0 || !accountsToNotify.isEmpty()) {
+        email
+            .create(
+                in.notify,
+                accountsToNotify,
+                notes,
+                ps,
+                user,
+                message,
+                comments,
+                in.message,
+                labelDelta)
+            .sendAsync();
       }
       commentAdded.fire(
-          notes.getChange(), ps, user.getAccount(), message.getMessage(),
-          approvals, oldApprovals, ctx.getWhen());
+          notes.getChange(),
+          ps,
+          user.getAccount(),
+          message.getMessage(),
+          approvals,
+          oldApprovals,
+          ctx.getWhen());
     }
 
-    private boolean insertComments(ChangeContext ctx) throws OrmException {
+    private boolean insertComments(ChangeContext ctx)
+        throws OrmException, UnprocessableEntityException {
       Map<String, List<CommentInput>> map = in.comments;
       if (map == null) {
         map = Collections.emptyMap();
       }
 
-      Map<String, PatchLineComment> drafts = Collections.emptyMap();
+      Map<String, Comment> drafts = Collections.emptyMap();
       if (!map.isEmpty() || in.drafts != DraftHandling.KEEP) {
         if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
           drafts = changeDrafts(ctx);
@@ -471,107 +764,171 @@
         }
       }
 
-      List<PatchLineComment> del = new ArrayList<>();
-      List<PatchLineComment> ups = new ArrayList<>();
+      List<Comment> toDel = new ArrayList<>();
+      List<Comment> toPublish = new ArrayList<>();
 
-      Set<CommentSetEntry> existingIds = in.omitDuplicateComments
-          ? readExistingComments(ctx)
-          : Collections.<CommentSetEntry>emptySet();
+      Set<CommentSetEntry> existingIds =
+          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
 
       for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
         String path = ent.getKey();
         for (CommentInput c : ent.getValue()) {
           String parent = Url.decode(c.inReplyTo);
-          PatchLineComment e = drafts.remove(Url.decode(c.id));
+          Comment e = drafts.remove(Url.decode(c.id));
           if (e == null) {
-            e = new PatchLineComment(
-                new PatchLineComment.Key(new Patch.Key(psId, path), null),
-                c.line != null ? c.line : 0,
-                user.getAccountId(),
-                parent, ctx.getWhen());
-          } else if (parent != null) {
-            e.setParentUuid(parent);
+            e = commentsUtil.newComment(ctx, path, psId, c.side(), c.message, c.unresolved, parent);
+          } else {
+            e.writtenOn = ctx.getWhen();
+            e.side = c.side();
+            e.message = c.message;
           }
-          e.setStatus(PatchLineComment.Status.PUBLISHED);
-          e.setWrittenOn(ctx.getWhen());
-          e.setSide(side(c));
+
           setCommentRevId(e, patchListCache, ctx.getChange(), ps);
-          e.setMessage(c.message);
-          e.setTag(in.tag);
-          if (c.range != null) {
-            e.setRange(new CommentRange(
-                c.range.startLine,
-                c.range.startCharacter,
-                c.range.endLine,
-                c.range.endCharacter));
-            e.setLine(c.range.endLine);
-          }
+          e.setLineNbrAndRange(c.line, c.range);
+          e.tag = in.tag;
+
           if (existingIds.contains(CommentSetEntry.create(e))) {
             continue;
           }
-          if (e.getKey().get() == null) {
-            e.getKey().set(ChangeUtil.messageUUID(ctx.getDb()));
-          }
-          ups.add(e);
+          toPublish.add(e);
         }
       }
 
-      switch (firstNonNull(in.drafts, DraftHandling.DELETE)) {
+      switch (in.drafts) {
         case KEEP:
         default:
           break;
         case DELETE:
-          del.addAll(drafts.values());
+          toDel.addAll(drafts.values());
           break;
         case PUBLISH:
-          for (PatchLineComment e : drafts.values()) {
-            ups.add(publishComment(ctx, e, ps));
+          for (Comment e : drafts.values()) {
+            toPublish.add(publishComment(ctx, e, ps));
           }
           break;
         case PUBLISH_ALL_REVISIONS:
-          publishAllRevisions(ctx, drafts, ups);
+          publishAllRevisions(ctx, drafts, toPublish);
           break;
       }
       ChangeUpdate u = ctx.getUpdate(psId);
-      plcUtil.deleteComments(ctx.getDb(), u, del);
-      plcUtil.putComments(ctx.getDb(), u, ups);
-      comments.addAll(ups);
-      return !del.isEmpty() || !ups.isEmpty();
+      commentsUtil.deleteComments(ctx.getDb(), u, toDel);
+      commentsUtil.putComments(ctx.getDb(), u, Status.PUBLISHED, toPublish);
+      comments.addAll(toPublish);
+      return !toDel.isEmpty() || !toPublish.isEmpty();
     }
 
-    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx)
-        throws OrmException {
-      Set<CommentSetEntry> r = new HashSet<>();
-      for (PatchLineComment c : plcUtil.publishedByChange(ctx.getDb(),
-            ctx.getNotes())) {
-        r.add(CommentSetEntry.create(c));
+    private boolean insertRobotComments(ChangeContext ctx) throws OrmException {
+      if (in.robotComments == null) {
+        return false;
       }
-      return r;
+
+      List<RobotComment> newRobotComments = getNewRobotComments(ctx);
+      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
+      comments.addAll(newRobotComments);
+      return !newRobotComments.isEmpty();
     }
 
-    private Map<String, PatchLineComment> changeDrafts(ChangeContext ctx)
-        throws OrmException {
-      Map<String, PatchLineComment> drafts = new HashMap<>();
-      for (PatchLineComment c : plcUtil.draftByChangeAuthor(
-          ctx.getDb(), ctx.getNotes(), user.getAccountId())) {
-        c.setTag(in.tag);
-        drafts.put(c.getKey().get(), c);
+    private List<RobotComment> getNewRobotComments(ChangeContext ctx) throws OrmException {
+      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+      Set<CommentSetEntry> existingIds =
+          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
+
+      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+        String path = ent.getKey();
+        for (RobotCommentInput c : ent.getValue()) {
+          RobotComment e = createRobotCommentFromInput(ctx, path, c);
+          if (existingIds.contains(CommentSetEntry.create(e))) {
+            continue;
+          }
+          toAdd.add(e);
+        }
+      }
+      return toAdd;
+    }
+
+    private RobotComment createRobotCommentFromInput(
+        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) throws OrmException {
+      RobotComment robotComment =
+          commentsUtil.newRobotComment(
+              ctx,
+              path,
+              psId,
+              robotCommentInput.side(),
+              robotCommentInput.message,
+              robotCommentInput.robotId,
+              robotCommentInput.robotRunId);
+      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
+      robotComment.url = robotCommentInput.url;
+      robotComment.properties = robotCommentInput.properties;
+      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
+      robotComment.tag = in.tag;
+      setCommentRevId(robotComment, patchListCache, ctx.getChange(), ps);
+      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+      return robotComment;
+    }
+
+    private List<FixSuggestion> createFixSuggestionsFromInput(
+        List<FixSuggestionInfo> fixSuggestionInfos) {
+      if (fixSuggestionInfos == null) {
+        return Collections.emptyList();
+      }
+
+      List<FixSuggestion> fixSuggestions = new ArrayList<>(fixSuggestionInfos.size());
+      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+      }
+      return fixSuggestions;
+    }
+
+    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+      String fixId = ChangeUtil.messageUuid();
+      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+    }
+
+    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
+      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
+    }
+
+    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
+    }
+
+    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) throws OrmException {
+      return commentsUtil.publishedByChange(ctx.getDb(), ctx.getNotes()).stream()
+          .map(CommentSetEntry::create)
+          .collect(toSet());
+    }
+
+    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) throws OrmException {
+      return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
+          .map(CommentSetEntry::create)
+          .collect(toSet());
+    }
+
+    private Map<String, Comment> changeDrafts(ChangeContext ctx) throws OrmException {
+      Map<String, Comment> drafts = new HashMap<>();
+      for (Comment c :
+          commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), user.getAccountId())) {
+        c.tag = in.tag;
+        drafts.put(c.key.uuid, c);
       }
       return drafts;
     }
 
-    private Map<String, PatchLineComment> patchSetDrafts(ChangeContext ctx)
-        throws OrmException {
-      Map<String, PatchLineComment> drafts = new HashMap<>();
-      for (PatchLineComment c : plcUtil.draftByPatchSetAuthor(ctx.getDb(),
-          psId, user.getAccountId(), ctx.getNotes())) {
-        drafts.put(c.getKey().get(), c);
+    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) throws OrmException {
+      Map<String, Comment> drafts = new HashMap<>();
+      for (Comment c :
+          commentsUtil.draftByPatchSetAuthor(
+              ctx.getDb(), psId, user.getAccountId(), ctx.getNotes())) {
+        drafts.put(c.key.uuid, c);
       }
       return drafts;
     }
 
-    private Map<String, Short> approvalsByKey(
-        Collection<PatchSetApproval> patchsetApprovals) {
+    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
       Map<String, Short> labels = new HashMap<>();
       for (PatchSetApproval psa : patchsetApprovals) {
         labels.put(psa.getLabel(), psa.getValue());
@@ -579,35 +936,39 @@
       return labels;
     }
 
-    private PatchLineComment publishComment(ChangeContext ctx,
-        PatchLineComment c, PatchSet ps) throws OrmException {
-      c.setStatus(PatchLineComment.Status.PUBLISHED);
-      c.setWrittenOn(ctx.getWhen());
-      c.setTag(in.tag);
+    private Comment publishComment(ChangeContext ctx, Comment c, PatchSet ps) throws OrmException {
+      c.writtenOn = ctx.getWhen();
+      c.tag = in.tag;
+      // Draft may have been created by a different real user; copy the current
+      // real user. (Only applies to X-Gerrit-RunAs, since modifying drafts via
+      // on_behalf_of is not allowed.)
+      ctx.getUser().updateRealAccountId(c::setRealAuthor);
       setCommentRevId(c, patchListCache, ctx.getChange(), checkNotNull(ps));
       return c;
     }
 
-    private void publishAllRevisions(ChangeContext ctx,
-        Map<String, PatchLineComment> drafts, List<PatchLineComment> ups)
-        throws OrmException {
+    private void publishAllRevisions(
+        ChangeContext ctx, Map<String, Comment> drafts, List<Comment> ups) throws OrmException {
       boolean needOtherPatchSets = false;
-      for (PatchLineComment c : drafts.values()) {
-        if (!c.getPatchSetId().equals(psId)) {
+      for (Comment c : drafts.values()) {
+        if (c.key.patchSetId != psId.get()) {
           needOtherPatchSets = true;
           break;
         }
       }
-      Map<PatchSet.Id, PatchSet> patchSets = needOtherPatchSets
-          ? psUtil.byChangeAsMap(ctx.getDb(), ctx.getNotes())
-          : ImmutableMap.of(psId, ps);
-      for (PatchLineComment e : drafts.values()) {
-        ups.add(publishComment(ctx, e, patchSets.get(e.getPatchSetId())));
+      Map<PatchSet.Id, PatchSet> patchSets =
+          needOtherPatchSets
+              ? psUtil.byChangeAsMap(ctx.getDb(), ctx.getNotes())
+              : ImmutableMap.of(psId, ps);
+      for (Comment e : drafts.values()) {
+        ups.add(
+            publishComment(
+                ctx, e, patchSets.get(new PatchSet.Id(ctx.getChange().getId(), e.key.patchSetId))));
       }
     }
 
-    private Map<String, Short> getAllApprovals(LabelTypes labelTypes,
-        Map<String, Short> current, Map<String, Short> input) {
+    private Map<String, Short> getAllApprovals(
+        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
       Map<String, Short> allApprovals = new HashMap<>();
       for (LabelType lt : labelTypes.getLabelTypes()) {
         allApprovals.put(lt.getName(), (short) 0);
@@ -637,10 +998,31 @@
       return previous;
     }
 
-    private boolean updateLabels(ChangeContext ctx)
-        throws OrmException, ResourceConflictException {
-      Map<String, Short> inLabels = MoreObjects.firstNonNull(in.labels,
-          Collections.<String, Short> emptyMap());
+    private boolean isReviewer(ChangeContext ctx) throws OrmException {
+      if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
+        return true;
+      }
+      for (PostReviewers.Addition addition : reviewerResults) {
+        if (addition.op.addedReviewers == null) {
+          continue;
+        }
+        for (PatchSetApproval psa : addition.op.addedReviewers) {
+          if (psa.getAccountId().equals(ctx.getAccountId())) {
+            return true;
+          }
+        }
+      }
+      ChangeData cd = changeDataFactory.create(db.get(), ctx.getControl());
+      ReviewerSet reviewers = cd.reviewers();
+      if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
+        return true;
+      }
+      return false;
+    }
+
+    private boolean updateLabels(ChangeContext ctx) throws OrmException, ResourceConflictException {
+      Map<String, Short> inLabels =
+          MoreObjects.firstNonNull(in.labels, Collections.<String, Short>emptyMap());
 
       // If no labels were modified and change is closed, abort early.
       // This avoids trying to record a modified label caused by a user
@@ -653,10 +1035,10 @@
       List<PatchSetApproval> ups = new ArrayList<>();
       Map<String, PatchSetApproval> current = scanLabels(ctx, del);
       LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      Map<String, Short> allApprovals = getAllApprovals(labelTypes,
-          approvalsByKey(current.values()), inLabels);
-      Map<String, Short> previous = getPreviousApprovals(allApprovals,
-          approvalsByKey(current.values()));
+      Map<String, Short> allApprovals =
+          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
+      Map<String, Short> previous =
+          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
 
       ChangeUpdate update = ctx.getUpdate(psId);
       for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
@@ -681,6 +1063,7 @@
           c.setValue(ent.getValue());
           c.setGranted(ctx.getWhen());
           c.setTag(in.tag);
+          ctx.getUser().updateRealAccountId(c::setRealAccountId);
           ups.add(c);
           addLabelDelta(normName, c.getValue());
           oldApprovals.put(normName, previous.get(normName));
@@ -691,11 +1074,7 @@
           oldApprovals.put(normName, null);
           approvals.put(normName, c.getValue());
         } else if (c == null) {
-          c = new PatchSetApproval(new PatchSetApproval.Key(
-                  psId,
-                  user.getAccountId(),
-                  lt.getLabelId()),
-              ent.getValue(), ctx.getWhen());
+          c = ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
           c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
           ups.add(c);
@@ -707,30 +1086,102 @@
         }
       }
 
-      if ((!del.isEmpty() || !ups.isEmpty())
-          && ctx.getChange().getStatus().isClosed()) {
-        throw new ResourceConflictException("change is closed");
+      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+      // Return early if user is not a reviewer and not posting any labels.
+      // This allows us to preserve their CC status.
+      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
+        return false;
       }
+
       forceCallerAsReviewer(ctx, current, ups, del);
       ctx.getDb().patchSetApprovals().delete(del);
       ctx.getDb().patchSetApprovals().upsert(ups);
       return !del.isEmpty() || !ups.isEmpty();
     }
 
-    private void forceCallerAsReviewer(ChangeContext ctx,
-        Map<String, PatchSetApproval> current, List<PatchSetApproval> ups,
+    private void validatePostSubmitLabels(
+        ChangeContext ctx,
+        LabelTypes labelTypes,
+        Map<String, Short> previous,
+        List<PatchSetApproval> ups,
+        List<PatchSetApproval> del)
+        throws ResourceConflictException {
+      if (ctx.getChange().getStatus().isOpen()) {
+        return; // Not closed, nothing to validate.
+      } else if (del.isEmpty() && ups.isEmpty()) {
+        return; // No new votes.
+      } else if (ctx.getChange().getStatus() != Change.Status.MERGED) {
+        throw new ResourceConflictException("change is closed");
+      }
+
+      // Disallow reducing votes on any labels post-submit. This assumes the
+      // high values were broadly necessary to submit, so reducing them would
+      // make it possible to take a merged change and make it no longer
+      // submittable.
+      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
+
+      for (PatchSetApproval psa : del) {
+        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
+        String normName = lt.getName();
+        if (!lt.allowPostSubmit()) {
+          disallowed.add(normName);
+        }
+        Short prev = previous.get(normName);
+        if (prev != null && prev != 0) {
+          reduced.add(psa);
+        }
+      }
+
+      for (PatchSetApproval psa : ups) {
+        LabelType lt = checkNotNull(labelTypes.byLabel(psa.getLabel()));
+        String normName = lt.getName();
+        if (!lt.allowPostSubmit()) {
+          disallowed.add(normName);
+        }
+        Short prev = previous.get(normName);
+        if (prev == null) {
+          continue;
+        }
+        checkState(prev != psa.getValue()); // Should be filtered out above.
+        if (prev > psa.getValue()) {
+          reduced.add(psa);
+        } else {
+          // Set postSubmit bit in ReviewDb; not required for NoteDb, which sets
+          // it automatically.
+          psa.setPostSubmit(true);
+        }
+      }
+
+      if (!disallowed.isEmpty()) {
+        throw new ResourceConflictException(
+            "Voting on labels disallowed after submit: "
+                + disallowed.stream().distinct().sorted().collect(joining(", ")));
+      }
+      if (!reduced.isEmpty()) {
+        throw new ResourceConflictException(
+            "Cannot reduce vote on labels for closed change: "
+                + reduced.stream()
+                    .map(p -> p.getLabel())
+                    .distinct()
+                    .sorted()
+                    .collect(joining(", ")));
+      }
+    }
+
+    private void forceCallerAsReviewer(
+        ChangeContext ctx,
+        Map<String, PatchSetApproval> current,
+        List<PatchSetApproval> ups,
         List<PatchSetApproval> del) {
       if (current.isEmpty() && ups.isEmpty()) {
         // TODO Find another way to link reviewers to changes.
         if (del.isEmpty()) {
           // If no existing label is being set to 0, hack in the caller
           // as a reviewer by picking the first server-wide LabelType.
-          PatchSetApproval c = new PatchSetApproval(new PatchSetApproval.Key(
-              psId,
-              user.getAccountId(),
-              ctx.getControl().getLabelTypes().getLabelTypes().get(0)
-                  .getLabelId()),
-              (short) 0, ctx.getWhen());
+          LabelId labelId = ctx.getControl().getLabelTypes().getLabelTypes().get(0).getLabelId();
+          PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
           c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
           ups.add(c);
@@ -744,17 +1195,16 @@
           ups.add(c);
         }
       }
-      ctx.getUpdate(ctx.getChange().currentPatchSetId())
-          .putReviewer(user.getAccountId(), REVIEWER);
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
     }
 
-    private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx,
-        List<PatchSetApproval> del) throws OrmException {
+    private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, List<PatchSetApproval> del)
+        throws OrmException {
       LabelTypes labelTypes = ctx.getControl().getLabelTypes();
       Map<String, PatchSetApproval> current = new HashMap<>();
 
-      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
-          ctx.getDb(), ctx.getControl(), psId, user.getAccountId())) {
+      for (PatchSetApproval a :
+          approvalsUtil.byPatchSetUser(ctx.getDb(), ctx.getControl(), psId, user.getAccountId())) {
         if (a.isLegacySubmit()) {
           continue;
         }
@@ -769,13 +1219,12 @@
       return current;
     }
 
-    private boolean insertMessage(ChangeContext ctx)
-        throws OrmException {
+    private boolean insertMessage(ChangeContext ctx) throws OrmException {
       String msg = Strings.nullToEmpty(in.message).trim();
 
       StringBuilder buf = new StringBuilder();
-      for (String d : labelDelta) {
-        buf.append(" ").append(d);
+      for (LabelVote d : labelDelta) {
+        buf.append(" ").append(d.format());
       }
       if (comments.size() == 1) {
         buf.append("\n\n(1 comment)");
@@ -789,23 +1238,15 @@
         return false;
       }
 
-      message = new ChangeMessage(
-          new ChangeMessage.Key(
-            psId.getParentKey(), ChangeUtil.messageUUID(ctx.getDb())),
-          user.getAccountId(),
-          ctx.getWhen(),
-          psId);
-      message.setTag(in.tag);
-      message.setMessage(String.format(
-          "Patch Set %d:%s",
-          psId.get(),
-          buf.toString()));
+      message =
+          ChangeMessagesUtil.newMessage(
+              psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), message);
       return true;
     }
 
     private void addLabelDelta(String name, short value) {
-      labelDelta.add(LabelVote.create(name, value).format());
+      labelDelta.add(LabelVote.create(name, value));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index 358e05b..debd32a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
@@ -27,6 +29,7 @@
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -40,6 +43,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
@@ -48,25 +52,21 @@
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.AddReviewerSender;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.text.MessageFormat;
 import java.util.ArrayList;
@@ -75,12 +75,13 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
-public class PostReviewers
-    implements RestModifyView<ChangeResource, AddReviewerInput> {
-  private static final Logger log =
-      LoggerFactory.getLogger(PostReviewers.class);
+public class PostReviewers implements RestModifyView<ChangeResource, AddReviewerInput> {
+  private static final Logger log = LoggerFactory.getLogger(PostReviewers.class);
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
@@ -98,13 +99,15 @@
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final Config cfg;
-  private final AccountCache accountCache;
   private final ReviewerJson json;
   private final ReviewerAdded reviewerAdded;
   private final NotesMigration migration;
+  private final AccountCache accountCache;
+  private final NotifyUtil notifyUtil;
 
   @Inject
-  PostReviewers(AccountsCollection accounts,
+  PostReviewers(
+      AccountsCollection accounts,
       ReviewerResource.Factory reviewerFactory,
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
@@ -117,10 +120,11 @@
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       @GerritServerConfig Config cfg,
-      AccountCache accountCache,
       ReviewerJson json,
       ReviewerAdded reviewerAdded,
-      NotesMigration migration) {
+      NotesMigration migration,
+      AccountCache accountCache,
+      NotifyUtil notifyUtil) {
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
     this.approvalsUtil = approvalsUtil;
@@ -134,10 +138,11 @@
     this.user = user;
     this.identifiedUserFactory = identifiedUserFactory;
     this.cfg = cfg;
-    this.accountCache = accountCache;
     this.json = json;
     this.reviewerAdded = reviewerAdded;
     this.migration = migration;
+    this.accountCache = accountCache;
+    this.notifyUtil = notifyUtil;
   }
 
   @Override
@@ -147,12 +152,13 @@
       throw new BadRequestException("missing reviewer field");
     }
 
-    Addition addition = prepareApplication(rsrc, input);
+    Addition addition = prepareApplication(rsrc, input, true);
     if (addition.op == null) {
       return addition.result;
     }
-    try (BatchUpdate bu = batchUpdateFactory.create(dbProvider.get(),
-        rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, addition.op);
       bu.execute();
@@ -161,50 +167,96 @@
     return addition.result;
   }
 
-  public Addition prepareApplication(ChangeResource rsrc, AddReviewerInput input)
+  public Addition prepareApplication(
+      ChangeResource rsrc, AddReviewerInput input, boolean allowGroup)
       throws OrmException, RestApiException, IOException {
-    Account.Id accountId;
+    IdentifiedUser user = null;
+    boolean accountFound = true;
+    boolean isExactMatch = false;
     try {
-      accountId = accounts.parse(input.reviewer).getAccountId();
+      user = accounts.parse(input.reviewer);
+      if (input.reviewer.equalsIgnoreCase(user.getName())
+          || input.reviewer.equals(String.valueOf(user.getAccountId()))) {
+        isExactMatch = true;
+      }
     } catch (UnprocessableEntityException e) {
+      accountFound = false;
+    }
+
+    if (allowGroup && !isExactMatch) {
       try {
         return putGroup(rsrc, input);
       } catch (UnprocessableEntityException e2) {
-        throw new UnprocessableEntityException(MessageFormat
-            .format(ChangeMessages.get().reviewerNotFound, input.reviewer));
+        if (!accountFound) {
+          throw new UnprocessableEntityException(
+              MessageFormat.format(
+                  ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
+        }
       }
     }
-    return putAccount(input.reviewer, reviewerFactory.create(rsrc, accountId),
-        input.state(), input.notify);
+    if (!accountFound) {
+      throw new UnprocessableEntityException(
+          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
+    }
+    return putAccount(
+        input.reviewer,
+        reviewerFactory.create(rsrc, user.getAccountId()),
+        input.state(),
+        input.notify,
+        notifyUtil.resolveAccounts(input.notifyDetails));
   }
 
-  private Addition putAccount(String reviewer, ReviewerResource rsrc,
-      ReviewerState state, NotifyHandling notify)
+  Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
+    return new Addition(
+        user.getUserName(),
+        revision.getChangeResource(),
+        ImmutableMap.of(user.getAccountId(), revision.getControl()),
+        CC,
+        NotifyHandling.NONE,
+        ImmutableListMultimap.of());
+  }
+
+  private Addition putAccount(
+      String reviewer,
+      ReviewerResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
       throws UnprocessableEntityException {
     Account member = rsrc.getReviewerUser().getAccount();
     ChangeControl control = rsrc.getReviewerControl();
     if (isValidReviewer(member, control)) {
-      return new Addition(reviewer, rsrc.getChangeResource(),
-          ImmutableMap.of(member.getId(), control), state, notify);
+      return new Addition(
+          reviewer,
+          rsrc.getChangeResource(),
+          ImmutableMap.of(member.getId(), control),
+          state,
+          notify,
+          accountsToNotify);
     }
-    throw new UnprocessableEntityException("Change not visible to " + reviewer);
+    if (member.isActive()) {
+      throw new UnprocessableEntityException(String.format("Change not visible to %s", reviewer));
+    }
+    throw new UnprocessableEntityException(String.format("Account of %s is inactive.", reviewer));
   }
 
   private Addition putGroup(ChangeResource rsrc, AddReviewerInput input)
       throws RestApiException, OrmException, IOException {
-    GroupDescription.Basic group =
-        groupsCollection.parseInternal(input.reviewer);
+    GroupDescription.Basic group = groupsCollection.parseInternal(input.reviewer);
     if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      return fail(input.reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed,
-          group.getName()));
+      return fail(
+          input.reviewer,
+          MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
     }
 
     Map<Account.Id, ChangeControl> reviewers = new HashMap<>();
     ChangeControl control = rsrc.getControl();
     Set<Account> members;
     try {
-      members = groupMembersFactory.create(control.getUser()).listAccounts(
-          group.getGroupUUID(), control.getProject().getNameKey());
+      members =
+          groupMembersFactory
+              .create(control.getUser())
+              .listAccounts(group.getGroupUUID(), control.getProject().getNameKey());
     } catch (NoSuchGroupException e) {
       throw new UnprocessableEntityException(e.getMessage());
     } catch (NoSuchProjectException e) {
@@ -213,22 +265,24 @@
 
     // if maxAllowed is set to 0, it is allowed to add any number of
     // reviewers
-    int maxAllowed =
-        cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
+    int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
     if (maxAllowed > 0 && members.size() > maxAllowed) {
-      return fail(input.reviewer, MessageFormat.format(
-          ChangeMessages.get().groupHasTooManyMembers, group.getName()));
+      return fail(
+          input.reviewer,
+          MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
     }
 
     // if maxWithoutCheck is set to 0, we never ask for confirmation
-    int maxWithoutConfirmation = cfg.getInt("addreviewer",
-        "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
-    if (!input.confirmed() && maxWithoutConfirmation > 0
+    int maxWithoutConfirmation =
+        cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+    if (!input.confirmed()
+        && maxWithoutConfirmation > 0
         && members.size() > maxWithoutConfirmation) {
-      return fail(input.reviewer, true,
+      return fail(
+          input.reviewer,
+          true,
           MessageFormat.format(
-              ChangeMessages.get().groupManyMembersConfirmation,
-              group.getName(), members.size()));
+              ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
     }
 
     for (Account member : members) {
@@ -237,8 +291,13 @@
       }
     }
 
-    return new Addition(input.reviewer, rsrc, reviewers, input.state(),
-        input.notify);
+    return new Addition(
+        input.reviewer,
+        rsrc,
+        reviewers,
+        input.state(),
+        input.notify,
+        notifyUtil.resolveAccounts(input.notifyDetails));
   }
 
   private boolean isValidReviewer(Account member, ChangeControl control) {
@@ -262,19 +321,23 @@
     return addition;
   }
 
-  class Addition {
+  public class Addition {
     final AddReviewerResult result;
     final Op op;
 
     private final Map<Account.Id, ChangeControl> reviewers;
 
     protected Addition(String reviewer) {
-      this(reviewer, null, null, REVIEWER, null);
+      this(reviewer, null, null, REVIEWER, null, ImmutableListMultimap.of());
     }
 
-    protected Addition(String reviewer, ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers, ReviewerState state,
-        NotifyHandling notify) {
+    protected Addition(
+        String reviewer,
+        ChangeResource rsrc,
+        Map<Account.Id, ChangeControl> reviewers,
+        ReviewerState state,
+        NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
       result = new AddReviewerResult(reviewer);
       if (reviewers == null) {
         this.reviewers = ImmutableMap.of();
@@ -282,7 +345,7 @@
         return;
       }
       this.reviewers = reviewers;
-      op = new Op(rsrc, reviewers, state, notify);
+      op = new Op(rsrc, reviewers, state, notify, accountsToNotify);
     }
 
     void gatherResults() throws OrmException {
@@ -291,8 +354,7 @@
       if (migration.readChanges() && op.state == CC) {
         result.ccs = Lists.newArrayListWithCapacity(op.addedCCs.size());
         for (Account.Id accountId : op.addedCCs) {
-          result.ccs.add(
-              json.format(new ReviewerInfo(accountId.get()), reviewers.get(accountId)));
+          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), reviewers.get(accountId)));
         }
         accountLoaderFactory.create(true).fill(result.ccs);
       } else {
@@ -300,48 +362,61 @@
         for (PatchSetApproval psa : op.addedReviewers) {
           // New reviewers have value 0, don't bother normalizing.
           result.reviewers.add(
-            json.format(new ReviewerInfo(psa.getAccountId().get()),
-                reviewers.get(psa.getAccountId()),
-                ImmutableList.of(psa)));
+              json.format(
+                  new ReviewerInfo(psa.getAccountId().get()),
+                  reviewers.get(psa.getAccountId()),
+                  ImmutableList.of(psa)));
         }
         accountLoaderFactory.create(true).fill(result.reviewers);
       }
     }
   }
 
-  class Op extends BatchUpdate.Op {
+  public class Op implements BatchUpdateOp {
     final Map<Account.Id, ChangeControl> reviewers;
     final ReviewerState state;
     final NotifyHandling notify;
+    final ListMultimap<RecipientType, Account.Id> accountsToNotify;
     List<PatchSetApproval> addedReviewers;
     Collection<Account.Id> addedCCs;
 
     private final ChangeResource rsrc;
     private PatchSet patchSet;
 
-    Op(ChangeResource rsrc, Map<Account.Id, ChangeControl> reviewers,
-        ReviewerState state, NotifyHandling notify) {
+    Op(
+        ChangeResource rsrc,
+        Map<Account.Id, ChangeControl> reviewers,
+        ReviewerState state,
+        NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
       this.rsrc = rsrc;
       this.reviewers = reviewers;
       this.state = state;
       this.notify = notify;
+      this.accountsToNotify = checkNotNull(accountsToNotify);
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws RestApiException, OrmException, IOException {
       if (migration.readChanges() && state == CC) {
-        addedCCs = approvalsUtil.addCcs(ctx.getNotes(),
-            ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-            reviewers.keySet());
+        addedCCs =
+            approvalsUtil.addCcs(
+                ctx.getNotes(),
+                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+                reviewers.keySet());
         if (addedCCs.isEmpty()) {
           return false;
         }
       } else {
-        addedReviewers = approvalsUtil.addReviewers(ctx.getDb(), ctx.getNotes(),
-            ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-            rsrc.getControl().getLabelTypes(), rsrc.getChange(),
-            reviewers.keySet());
+        addedReviewers =
+            approvalsUtil.addReviewers(
+                ctx.getDb(),
+                ctx.getNotes(),
+                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+                rsrc.getControl().getLabelTypes(),
+                rsrc.getChange(),
+                reviewers.keySet());
         if (addedReviewers.isEmpty()) {
           return false;
         }
@@ -360,28 +435,29 @@
         if (addedCCs == null) {
           addedCCs = new ArrayList<>();
         }
-        List<Account.Id> accounts = Lists.transform(addedReviewers,
-            new Function<PatchSetApproval, Account.Id>() {
-              @Override
-              public Account.Id apply(PatchSetApproval psa) {
-                return psa.getAccountId();
-              }
-            });
-
-        emailReviewers(rsrc.getChange(), accounts, addedCCs, notify);
+        emailReviewers(
+            rsrc.getChange(),
+            Lists.transform(addedReviewers, r -> r.getAccountId()),
+            addedCCs,
+            notify,
+            accountsToNotify);
         if (!addedReviewers.isEmpty()) {
-          for (PatchSetApproval psa : addedReviewers) {
-            Account account = accountCache.get(psa.getAccountId()).getAccount();
-            reviewerAdded.fire(rsrc.getChange(), patchSet, account,
-              ctx.getAccount(), ctx.getWhen());
-          }
+          List<Account> reviewers =
+              Lists.transform(
+                  addedReviewers, psa -> accountCache.get(psa.getAccountId()).getAccount());
+          reviewerAdded.fire(
+              rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
         }
       }
     }
   }
 
-  public void emailReviewers(Change change, Collection<Account.Id> added,
-      Collection<Account.Id> copied, NotifyHandling notify) {
+  public void emailReviewers(
+      Change change,
+      Collection<Account.Id> added,
+      Collection<Account.Id> copied,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
     if (added.isEmpty() && copied.isEmpty()) {
       return;
     }
@@ -407,15 +483,17 @@
     }
 
     try {
-      AddReviewerSender cm = addReviewerSenderFactory
-          .create(change.getProject(), change.getId(), notify);
+      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
+      if (notify != null) {
+        cm.setNotify(notify);
+      }
+      cm.setAccountsToNotify(accountsToNotify);
       cm.setFrom(userId);
       cm.addReviewers(toMail);
       cm.addExtraCC(toCopy);
       cm.send();
     } catch (Exception err) {
-      log.error("Cannot send email to new reviewers of change "
-          + change.getId(), err);
+      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
new file mode 100644
index 0000000..f4356db
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
@@ -0,0 +1,177 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+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.IdentifiedUser;
+import com.google.gerrit.server.change.LimitedByteArrayOutputStream.LimitExceededException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.MergeOp;
+import com.google.gerrit.server.git.MergeOpRepoManager;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Collection;
+import org.apache.commons.compress.archivers.ArchiveOutputStream;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.BundleWriter;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.kohsuke.args4j.Option;
+
+@Singleton
+public class PreviewSubmit implements RestReadView<RevisionResource> {
+  private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
+
+  private final Provider<ReviewDb> dbProvider;
+  private final Provider<MergeOp> mergeOpProvider;
+  private final AllowedFormats allowedFormats;
+  private int maxBundleSize;
+  private String format;
+
+  @Option(name = "--format")
+  public void setFormat(String f) {
+    this.format = f;
+  }
+
+  @Inject
+  PreviewSubmit(
+      Provider<ReviewDb> dbProvider,
+      Provider<MergeOp> mergeOpProvider,
+      AllowedFormats allowedFormats,
+      @GerritServerConfig Config cfg) {
+    this.dbProvider = dbProvider;
+    this.mergeOpProvider = mergeOpProvider;
+    this.allowedFormats = allowedFormats;
+    this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
+  }
+
+  @Override
+  public BinaryResult apply(RevisionResource rsrc) throws OrmException, RestApiException {
+    if (Strings.isNullOrEmpty(format)) {
+      throw new BadRequestException("format is not specified");
+    }
+    ArchiveFormat f = allowedFormats.extensions.get("." + format);
+    if (f == null && format.equals("tgz")) {
+      // Always allow tgz, even when the allowedFormats doesn't contain it.
+      // Then we allow at least one format even if the list of allowed
+      // formats is empty.
+      f = ArchiveFormat.TGZ;
+    }
+    if (f == null) {
+      throw new BadRequestException("unknown archive format");
+    }
+
+    Change change = rsrc.getChange();
+    if (!change.getStatus().isOpen()) {
+      throw new PreconditionFailedException("change is " + Submit.status(change));
+    }
+    ChangeControl control = rsrc.getControl();
+    if (!control.getUser().isIdentifiedUser()) {
+      throw new MethodNotAllowedException("Anonymous users cannot submit");
+    }
+
+    return getBundles(rsrc, f);
+  }
+
+  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormat f)
+      throws OrmException, RestApiException {
+    ReviewDb db = dbProvider.get();
+    ChangeControl control = rsrc.getControl();
+    IdentifiedUser caller = control.getUser().asIdentifiedUser();
+    Change change = rsrc.getChange();
+
+    @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
+    MergeOp op = mergeOpProvider.get();
+    try {
+      op.merge(db, change, caller, false, new SubmitInput(), true);
+      BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
+      bin.disableGzip()
+          .setContentType(f.getMimeType())
+          .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
+      return bin;
+    } catch (OrmException | RestApiException | RuntimeException e) {
+      op.close();
+      throw e;
+    }
+  }
+
+  private static class SubmitPreviewResult extends BinaryResult {
+
+    private final MergeOp mergeOp;
+    private final ArchiveFormat archiveFormat;
+    private final int maxBundleSize;
+
+    private SubmitPreviewResult(MergeOp mergeOp, ArchiveFormat archiveFormat, int maxBundleSize) {
+      this.mergeOp = mergeOp;
+      this.archiveFormat = archiveFormat;
+      this.maxBundleSize = maxBundleSize;
+    }
+
+    @Override
+    public void writeTo(OutputStream out) throws IOException {
+      try (ArchiveOutputStream aos = archiveFormat.createArchiveOutputStream(out)) {
+        MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
+        for (Project.NameKey p : mergeOp.getAllProjects()) {
+          OpenRepo or = orm.getRepo(p);
+          BundleWriter bw = new BundleWriter(or.getRepo());
+          bw.setObjectCountCallback(null);
+          bw.setPackConfig(null);
+          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates();
+          for (ReceiveCommand r : refs) {
+            bw.include(r.getRefName(), r.getNewId());
+            ObjectId oldId = r.getOldId();
+            if (!oldId.equals(ObjectId.zeroId())) {
+              bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
+            }
+          }
+          LimitedByteArrayOutputStream bos = new LimitedByteArrayOutputStream(maxBundleSize, 1024);
+          bw.writeBundle(NullProgressMonitor.INSTANCE, bos);
+          // This naming scheme cannot produce directory/file conflicts
+          // as no projects contains ".git/":
+          String path = p.get() + ".git";
+          archiveFormat.putEntry(aos, path, bos.toByteArray());
+        }
+      } catch (LimitExceededException e) {
+        throw new NotImplementedException("The bundle is too big to generate at the server");
+      } catch (NoSuchProjectException e) {
+        throw new IOException(e);
+      }
+    }
+
+    @Override
+    public void close() throws IOException {
+      mergeOp.close();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
index c86e98f..0e72979 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -29,18 +29,16 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import java.util.Optional;
 
 @Singleton
-public class PublishChangeEdit implements
-    ChildCollection<ChangeResource, ChangeEditResource>,
-    AcceptsPost<ChangeResource> {
+public class PublishChangeEdit
+    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
 
   private final Publish publish;
 
@@ -71,34 +69,34 @@
   }
 
   @Singleton
-  public static class Publish implements RestModifyView<ChangeResource, Publish.Input> {
-    public static class Input {
-    }
+  public static class Publish implements RestModifyView<ChangeResource, PublishChangeEditInput> {
 
     private final ChangeEditUtil editUtil;
+    private final NotifyUtil notifyUtil;
 
     @Inject
-    Publish(ChangeEditUtil editUtil) {
+    Publish(ChangeEditUtil editUtil, NotifyUtil notifyUtil) {
       this.editUtil = editUtil;
+      this.notifyUtil = notifyUtil;
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, Publish.Input in)
-        throws NoSuchChangeException, IOException, OrmException,
-        RestApiException, UpdateException {
-      Capable r =
-          rsrc.getControl().getProjectControl().canPushToAtLeastOneRef();
+    public Response<?> apply(ChangeResource rsrc, PublishChangeEditInput in)
+        throws IOException, OrmException, RestApiException, UpdateException {
+      Capable r = rsrc.getControl().getProjectControl().canPushToAtLeastOneRef();
       if (r != Capable.OK) {
         throw new AuthException(r.getMessage());
       }
 
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
       if (!edit.isPresent()) {
-        throw new ResourceConflictException(String.format(
-            "no edit exists for change %s",
-            rsrc.getChange().getChangeId()));
+        throw new ResourceConflictException(
+            String.format("no edit exists for change %s", rsrc.getChange().getChangeId()));
       }
-      editUtil.publish(edit.get());
+      if (in == null) {
+        in = new PublishChangeEditInput();
+      }
+      editUtil.publish(edit.get(), in.notify, notifyUtil.resolveAccounts(in.notifyDetails));
       return Response.none();
     }
   }
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 d17d69b..4cbeaf63c 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
@@ -34,44 +34,41 @@
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.PublishDraftPatchSet.Input;
 import com.google.gerrit.server.extensions.events.DraftPublished;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.CreateChangeSender;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collection;
-import java.util.List;
-
 @Singleton
-public class PublishDraftPatchSet implements RestModifyView<RevisionResource, Input>,
-    UiAction<RevisionResource> {
-  private static final Logger log =
-      LoggerFactory.getLogger(PublishDraftPatchSet.class);
+public class PublishDraftPatchSet
+    implements RestModifyView<RevisionResource, Input>, UiAction<RevisionResource> {
+  private static final Logger log = LoggerFactory.getLogger(PublishDraftPatchSet.class);
 
-  public static class Input {
-  }
+  public static class Input {}
 
   private final AccountResolver accountResolver;
   private final ApprovalsUtil approvalsUtil;
@@ -108,14 +105,13 @@
   @Override
   public Response<?> apply(RevisionResource rsrc, Input input)
       throws RestApiException, UpdateException {
-    return apply(rsrc.getUser(), rsrc.getChange(), rsrc.getPatchSet().getId(),
-        rsrc.getPatchSet());
+    return apply(rsrc.getUser(), rsrc.getChange(), rsrc.getPatchSet().getId(), rsrc.getPatchSet());
   }
 
-  private Response<?> apply(CurrentUser u, Change c, PatchSet.Id psId,
-      PatchSet ps) throws RestApiException, UpdateException {
-    try (BatchUpdate bu = updateFactory.create(
-        dbProvider.get(), c.getProject(), u, TimeUtil.nowTs())) {
+  private Response<?> apply(CurrentUser u, Change c, PatchSet.Id psId, PatchSet ps)
+      throws RestApiException, UpdateException {
+    try (BatchUpdate bu =
+        updateFactory.create(dbProvider.get(), c.getProject(), u, TimeUtil.nowTs())) {
       bu.addOp(c.getId(), new Op(psId, ps));
       bu.execute();
     }
@@ -126,18 +122,16 @@
   public UiAction.Description getDescription(RevisionResource rsrc) {
     try {
       return new UiAction.Description()
-        .setLabel("Publish")
-        .setTitle(String.format("Publish revision %d",
-            rsrc.getPatchSet().getPatchSetId()))
-        .setVisible(rsrc.getPatchSet().isDraft()
-            && rsrc.getControl().canPublish(dbProvider.get()));
+          .setLabel("Publish")
+          .setTitle(String.format("Publish revision %d", rsrc.getPatchSet().getPatchSetId()))
+          .setVisible(
+              rsrc.getPatchSet().isDraft() && rsrc.getControl().canPublish(dbProvider.get()));
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
   }
 
-  public static class CurrentRevision implements
-      RestModifyView<ChangeResource, Input> {
+  public static class CurrentRevision implements RestModifyView<ChangeResource, Input> {
     private final PublishDraftPatchSet publish;
 
     @Inject
@@ -148,12 +142,15 @@
     @Override
     public Response<?> apply(ChangeResource rsrc, Input input)
         throws RestApiException, UpdateException {
-      return publish.apply(rsrc.getControl().getUser(), rsrc.getChange(),
-          rsrc.getChange().currentPatchSetId(), null);
+      return publish.apply(
+          rsrc.getControl().getUser(),
+          rsrc.getChange(),
+          rsrc.getChange().currentPatchSetId(),
+          null);
     }
   }
 
-  private class Op extends BatchUpdate.Op {
+  private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
 
     private PatchSet patchSet;
@@ -195,36 +192,39 @@
       }
     }
 
-    private void savePatchSet(ChangeContext ctx)
-        throws RestApiException, OrmException {
+    private void savePatchSet(ChangeContext ctx) throws RestApiException, OrmException {
       if (!patchSet.isDraft()) {
         throw new ResourceConflictException("Patch set is not a draft");
       }
       psUtil.publish(ctx.getDb(), ctx.getUpdate(psId), patchSet);
     }
 
-    private void addReviewers(ChangeContext ctx)
-        throws OrmException, IOException {
+    private void addReviewers(ChangeContext ctx) throws OrmException, IOException {
       LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      Collection<Account.Id> oldReviewers = approvalsUtil.getReviewers(
-          ctx.getDb(), ctx.getNotes()).all();
-      RevCommit commit = ctx.getRevWalk().parseCommit(
-          ObjectId.fromString(patchSet.getRevision().get()));
+      Collection<Account.Id> oldReviewers =
+          approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all();
+      RevCommit commit =
+          ctx.getRevWalk().parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
       patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
 
       List<FooterLine> footerLines = commit.getFooterLines();
-      recipients = getRecipientsFromFooters(
-          ctx.getDb(), accountResolver, patchSet.isDraft(), footerLines);
+      recipients =
+          getRecipientsFromFooters(ctx.getDb(), accountResolver, patchSet.isDraft(), footerLines);
       recipients.remove(ctx.getAccountId());
-      approvalsUtil.addReviewers(ctx.getDb(), ctx.getUpdate(psId), labelTypes,
-          change, patchSet, patchSetInfo, recipients.getReviewers(),
+      approvalsUtil.addReviewers(
+          ctx.getDb(),
+          ctx.getUpdate(psId),
+          labelTypes,
+          change,
+          patchSet,
+          patchSetInfo,
+          recipients.getReviewers(),
           oldReviewers);
     }
 
     @Override
     public void postUpdate(Context ctx) throws OrmException {
-      draftPublished.fire(change, patchSet, ctx.getAccount(),
-          ctx.getWhen());
+      draftPublished.fire(change, patchSet, ctx.getAccount(), ctx.getWhen());
       if (patchSet.isDraft() && change.getStatus() == Change.Status.DRAFT) {
         // Skip emails if the patch set is still a draft.
         return;
@@ -235,14 +235,13 @@
         } else {
           sendReplacePatchSet(ctx);
         }
-      } catch (EmailException | OrmException e) {
+      } catch (EmailException e) {
         log.error("Cannot send email for publishing draft " + psId, e);
       }
     }
 
     private void sendCreateChange(Context ctx) throws EmailException {
-      CreateChangeSender cm =
-          createChangeSenderFactory.create(ctx.getProject(), change.getId());
+      CreateChangeSender cm = createChangeSenderFactory.create(ctx.getProject(), change.getId());
       cm.setFrom(ctx.getAccountId());
       cm.setPatchSet(patchSet, patchSetInfo);
       cm.addReviewers(recipients.getReviewers());
@@ -250,17 +249,16 @@
       cm.send();
     }
 
-    private void sendReplacePatchSet(Context ctx)
-        throws EmailException, OrmException {
-      Account.Id accountId = ctx.getAccountId();
+    private void sendReplacePatchSet(Context ctx) throws EmailException {
       ChangeMessage msg =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())), accountId,
-              ctx.getWhen(), psId);
-      msg.setMessage("Uploaded patch set " + psId.get() + ".");
-      ReplacePatchSetSender cm =
-          replacePatchSetFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(accountId);
+          ChangeMessagesUtil.newMessage(
+              psId,
+              ctx.getUser(),
+              ctx.getWhen(),
+              "Uploaded patch set " + psId.get() + ".",
+              ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+      ReplacePatchSetSender cm = replacePatchSetFactory.create(ctx.getProject(), change.getId());
+      cm.setFrom(ctx.getAccountId());
       cm.setPatchSet(patchSet, patchSetInfo);
       cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
       cm.addReviewers(recipients.getReviewers());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
new file mode 100644
index 0000000..e64abaa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.PostReviewers.Addition;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class PutAssignee
+    implements RestModifyView<ChangeResource, AssigneeInput>, UiAction<ChangeResource> {
+
+  private final SetAssigneeOp.Factory assigneeFactory;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final Provider<ReviewDb> db;
+  private final PostReviewers postReviewers;
+  private final AccountLoader.Factory accountLoaderFactory;
+
+  @Inject
+  PutAssignee(
+      SetAssigneeOp.Factory assigneeFactory,
+      BatchUpdate.Factory batchUpdateFactory,
+      Provider<ReviewDb> db,
+      PostReviewers postReviewers,
+      AccountLoader.Factory accountLoaderFactory) {
+    this.assigneeFactory = assigneeFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.db = db;
+    this.postReviewers = postReviewers;
+    this.accountLoaderFactory = accountLoaderFactory;
+  }
+
+  @Override
+  public Response<AccountInfo> apply(ChangeResource rsrc, AssigneeInput input)
+      throws RestApiException, UpdateException, OrmException, IOException {
+    if (!rsrc.getControl().canEditAssignee()) {
+      throw new AuthException("Changing Assignee not permitted");
+    }
+    if (input.assignee == null || input.assignee.trim().isEmpty()) {
+      throw new BadRequestException("missing assignee field");
+    }
+
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db.get(),
+            rsrc.getChange().getProject(),
+            rsrc.getControl().getUser(),
+            TimeUtil.nowTs())) {
+      SetAssigneeOp op = assigneeFactory.create(input.assignee);
+      bu.addOp(rsrc.getId(), op);
+
+      PostReviewers.Addition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
+      bu.addOp(rsrc.getId(), reviewersAddition.op);
+
+      bu.execute();
+      return Response.ok(accountLoaderFactory.create(true).fillOne(op.getNewAssignee()));
+    }
+  }
+
+  private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
+      throws OrmException, RestApiException, IOException {
+    AddReviewerInput reviewerInput = new AddReviewerInput();
+    reviewerInput.reviewer = assignee;
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.confirmed = true;
+    reviewerInput.notify = NotifyHandling.NONE;
+    return postReviewers.prepareApplication(rsrc, reviewerInput, false);
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource resource) {
+    return new UiAction.Description()
+        .setLabel("Edit Assignee")
+        .setVisible(resource.getControl().canEditAssignee());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
new file mode 100644
index 0000000..3c2633e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collections;
+
+@Singleton
+public class PutDescription
+    implements RestModifyView<RevisionResource, PutDescription.Input>, UiAction<RevisionResource> {
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeMessagesUtil cmUtil;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetUtil psUtil;
+
+  public static class Input {
+    @DefaultInput public String description;
+  }
+
+  @Inject
+  PutDescription(
+      Provider<ReviewDb> dbProvider,
+      ChangeMessagesUtil cmUtil,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetUtil psUtil) {
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.psUtil = psUtil;
+  }
+
+  @Override
+  public Response<String> apply(RevisionResource rsrc, Input input)
+      throws UpdateException, RestApiException {
+    ChangeControl ctl = rsrc.getControl();
+    if (!ctl.canEditDescription()) {
+      throw new AuthException("changing description not permitted");
+    }
+    Op op = new Op(input != null ? input : new Input(), rsrc.getPatchSet().getId());
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(), rsrc.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+      u.addOp(rsrc.getChange().getId(), op);
+      u.execute();
+    }
+    return Strings.isNullOrEmpty(op.newDescription)
+        ? Response.none()
+        : Response.ok(op.newDescription);
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final Input input;
+    private final PatchSet.Id psId;
+
+    private String oldDescription;
+    private String newDescription;
+
+    Op(Input input, PatchSet.Id psId) {
+      this.input = input;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      ChangeUpdate update = ctx.getUpdate(psId);
+      newDescription = Strings.nullToEmpty(input.description);
+      oldDescription = Strings.nullToEmpty(ps.getDescription());
+      if (oldDescription.equals(newDescription)) {
+        return false;
+      }
+      String summary;
+      if (oldDescription.isEmpty()) {
+        summary = "Description set to \"" + newDescription + "\"";
+      } else if (newDescription.isEmpty()) {
+        summary = "Description \"" + oldDescription + "\" removed";
+      } else {
+        summary = "Description changed to \"" + newDescription + "\"";
+      }
+
+      ps.setDescription(newDescription);
+      update.setPsDescription(newDescription);
+
+      ctx.getDb().patchSets().update(Collections.singleton(ps));
+
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(
+              psId, ctx.getUser(), ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
+      cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+      return true;
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(RevisionResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Edit Description")
+        .setVisible(rsrc.getControl().canEditDescription());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index 655e07d..b289da8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.client.Comment;
-import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -28,47 +25,49 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.sql.Timestamp;
 import java.util.Collections;
+import java.util.Optional;
 
 @Singleton
 public class PutDraftComment implements RestModifyView<DraftCommentResource, DraftInput> {
 
   private final Provider<ReviewDb> db;
   private final DeleteDraftComment delete;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final PatchListCache patchListCache;
 
   @Inject
-  PutDraftComment(Provider<ReviewDb> db,
+  PutDraftComment(
+      Provider<ReviewDb> db,
       DeleteDraftComment delete,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       PatchListCache patchListCache) {
     this.db = db;
     this.delete = delete;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
@@ -76,8 +75,8 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(DraftCommentResource rsrc, DraftInput in) throws
-      RestApiException, UpdateException, OrmException {
+  public Response<CommentInfo> apply(DraftCommentResource rsrc, DraftInput in)
+      throws RestApiException, UpdateException, OrmException {
     if (in == null || in.message == null || in.message.trim().isEmpty()) {
       return delete.apply(rsrc, null);
     } else if (in.id != null && !rsrc.getId().equals(in.id)) {
@@ -88,104 +87,87 @@
       throw new BadRequestException("range endLine must be on the same line as the comment");
     }
 
-    try (BatchUpdate bu = updateFactory.create(
-        db.get(), rsrc.getChange().getProject(), rsrc.getControl().getUser(),
-        TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getComment().getKey(), in);
+    try (BatchUpdate bu =
+        updateFactory.create(
+            db.get(),
+            rsrc.getChange().getProject(),
+            rsrc.getControl().getUser(),
+            TimeUtil.nowTs())) {
+      Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.ok(
-          commentJson.get().setFillAccounts(false).format(op.comment));
+          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
     }
   }
 
-  private class Op extends BatchUpdate.Op {
-    private final PatchLineComment.Key key;
+  private class Op implements BatchUpdateOp {
+    private final Comment.Key key;
     private final DraftInput in;
 
-    private PatchLineComment comment;
+    private Comment comment;
 
-    private Op(PatchLineComment.Key key, DraftInput in) {
+    private Op(Comment.Key key, DraftInput in) {
       this.key = key;
       this.in = in;
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException {
-      Optional<PatchLineComment> maybeComment =
-          plcUtil.get(ctx.getDb(), ctx.getNotes(), key);
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
+      Optional<Comment> maybeComment = commentsUtil.get(ctx.getDb(), ctx.getNotes(), key);
       if (!maybeComment.isPresent()) {
         // Disappeared out from under us. Can't easily fall back to insert,
         // because the input might be missing required fields. Just give up.
         throw new ResourceNotFoundException("comment not found: " + key);
       }
-      PatchLineComment origComment = maybeComment.get();
-      comment = new PatchLineComment(origComment);
+      Comment origComment = maybeComment.get();
+      comment = new Comment(origComment);
+      // Copy constructor preserved old real author; replace with current real
+      // user.
+      ctx.getUser().updateRealAccountId(comment::setRealAuthor);
 
-      PatchSet.Id psId = comment.getKey().getParentKey().getParentKey();
+      PatchSet.Id psId = new PatchSet.Id(ctx.getChange().getId(), origComment.key.patchSetId);
       ChangeUpdate update = ctx.getUpdate(psId);
 
       PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      if (in.path != null
-          && !in.path.equals(comment.getKey().getParentKey().getFileName())) {
+      if (in.path != null && !in.path.equals(origComment.key.filename)) {
         // Updating the path alters the primary key, which isn't possible.
         // Delete then recreate the comment instead of an update.
 
-        plcUtil.deleteComments(
-            ctx.getDb(), update, Collections.singleton(origComment));
-        comment = new PatchLineComment(
-            new PatchLineComment.Key(
-                new Patch.Key(psId, in.path),
-                comment.getKey().get()),
-            comment.getLine(),
-            ctx.getAccountId(),
-            comment.getParentUuid(), ctx.getWhen());
-        comment.setTag(origComment.getTag());
-        setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
-        plcUtil.putComments(ctx.getDb(), update,
-            Collections.singleton(update(comment, in, ctx.getWhen())));
-      } else {
-        if (comment.getRevId() == null) {
-          setCommentRevId(
-              comment, patchListCache, ctx.getChange(), ps);
-        }
-        plcUtil.putComments(ctx.getDb(), update,
-            Collections.singleton(update(comment, in, ctx.getWhen())));
+        commentsUtil.deleteComments(ctx.getDb(), update, Collections.singleton(origComment));
+        comment.key.filename = in.path;
       }
+      setCommentRevId(comment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.putComments(
+          ctx.getDb(),
+          update,
+          Status.DRAFT,
+          Collections.singleton(update(comment, in, ctx.getWhen())));
       ctx.bumpLastUpdatedOn(false);
       return true;
     }
   }
 
-  private static PatchLineComment update(PatchLineComment e, DraftInput in,
-      Timestamp when) {
+  private static Comment update(Comment e, DraftInput in, Timestamp when) {
     if (in.side != null) {
-      e.setSide(side(in));
+      e.side = in.side();
     }
     if (in.inReplyTo != null) {
-      e.setParentUuid(Url.decode(in.inReplyTo));
+      e.parentUuid = Url.decode(in.inReplyTo);
     }
-    e.setMessage(in.message.trim());
-    if (in.range != null || in.line != null) {
-      e.setRange(in.range);
-      e.setLine(in.range != null ? in.range.endLine : in.line);
-    }
-    e.setWrittenOn(when);
+    e.setLineNbrAndRange(in.line, in.range);
+    e.message = in.message.trim();
+    e.writtenOn = when;
     if (in.tag != null) {
       // TODO(dborowitz): Can we support changing tags via PUT?
-      e.setTag(in.tag);
+      e.tag = in.tag;
+    }
+    if (in.unresolved != null) {
+      e.unresolved = in.unresolved;
     }
     return e;
   }
-
-  static short side(Comment c) {
-    if (c.side == Side.PARENT) {
-      return (short) (c.parent == null ? 0 : -c.parent.shortValue());
-    }
-    return 1;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
index 31ae892..783ab9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
@@ -26,35 +26,34 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.PutTopic.Input;
 import com.google.gerrit.server.extensions.events.TopicEdited;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutTopic implements RestModifyView<ChangeResource, Input>,
-    UiAction<ChangeResource> {
+public class PutTopic implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final TopicEdited topicEdited;
 
   public static class Input {
-    @DefaultInput
-    public String topic;
+    @DefaultInput public String topic;
   }
 
   @Inject
-  PutTopic(Provider<ReviewDb> dbProvider,
+  PutTopic(
+      Provider<ReviewDb> dbProvider,
       ChangeMessagesUtil cmUtil,
       BatchUpdate.Factory batchUpdateFactory,
       TopicEdited topicEdited) {
@@ -73,17 +72,18 @@
     }
 
     Op op = new Op(input != null ? input : new Input());
-    try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
-        req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(), req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
     return Strings.isNullOrEmpty(op.newTopicName)
-        ? Response.<String> none()
+        ? Response.<String>none()
         : Response.ok(op.newTopicName);
   }
 
-  private class Op extends BatchUpdate.Op {
+  private class Op implements BatchUpdateOp {
     private final Input input;
 
     private Change change;
@@ -109,19 +109,13 @@
       } else if (newTopicName.isEmpty()) {
         summary = "Topic " + oldTopicName + " removed";
       } else {
-        summary = String.format("Topic changed from %s to %s",
-            oldTopicName, newTopicName);
+        summary = String.format("Topic changed from %s to %s", oldTopicName, newTopicName);
       }
       change.setTopic(Strings.emptyToNull(newTopicName));
       update.setTopic(change.getTopic());
 
-      ChangeMessage cmsg = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          ctx.getAccountId(), ctx.getWhen(),
-          change.currentPatchSetId());
-      cmsg.setMessage(summary);
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
       cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
       return true;
     }
@@ -129,10 +123,7 @@
     @Override
     public void postUpdate(Context ctx) {
       if (change != null) {
-        topicEdited.fire(change,
-            ctx.getAccount(),
-            oldTopicName,
-            ctx.getWhen());
+        topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
       }
     }
   }
@@ -140,7 +131,7 @@
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
     return new UiAction.Description()
-      .setLabel("Edit Topic")
-      .setVisible(resource.getControl().canEditTopicName());
+        .setLabel("Edit Topic")
+        .setVisible(resource.getControl().canEditTopicName());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
index 4b81c31..93e1e4e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
@@ -29,18 +31,19 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
-import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
@@ -49,16 +52,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.EnumSet;
-
 @Singleton
-public class Rebase implements RestModifyView<RevisionResource, RebaseInput>,
-    UiAction<RevisionResource> {
+public class Rebase
+    implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
   private static final Logger log = LoggerFactory.getLogger(Rebase.class);
-  private static final EnumSet<ListChangesOption> OPTIONS = EnumSet.of(
-      ListChangesOption.CURRENT_REVISION,
-      ListChangesOption.CURRENT_COMMIT);
+  private static final ImmutableSet<ListChangesOption> OPTIONS =
+      Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
 
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
@@ -68,7 +67,8 @@
   private final Provider<ReviewDb> dbProvider;
 
   @Inject
-  public Rebase(BatchUpdate.Factory updateFactory,
+  public Rebase(
+      BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
       RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
@@ -84,39 +84,40 @@
 
   @Override
   public ChangeInfo apply(RevisionResource rsrc, RebaseInput input)
-      throws EmailException, OrmException, UpdateException, RestApiException,
-      IOException, NoSuchChangeException {
+      throws EmailException, OrmException, UpdateException, RestApiException, IOException,
+          NoSuchChangeException {
     ChangeControl control = rsrc.getControl();
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
         RevWalk rw = new RevWalk(repo);
         ObjectInserter oi = repo.newObjectInserter();
-        BatchUpdate bu = updateFactory.create(dbProvider.get(),
-          change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        BatchUpdate bu =
+            updateFactory.create(
+                dbProvider.get(), change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       if (!control.canRebase(dbProvider.get())) {
         throw new AuthException("rebase not permitted");
       } else if (!change.getStatus().isOpen()) {
-        throw new ResourceConflictException("change is "
-            + change.getStatus().name().toLowerCase());
+        throw new ResourceConflictException("change is " + change.getStatus().name().toLowerCase());
       } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
         throw new ResourceConflictException(
             "cannot rebase merge commits or commit with no ancestor");
       }
       bu.setRepository(repo, rw, oi);
-      bu.addOp(change.getId(), rebaseFactory.create(
-            control, rsrc.getPatchSet(),
-            findBaseRev(rw, rsrc, input))
-          .setForceContentMerge(true)
-          .setFireRevisionCreated(true)
-          .setValidatePolicy(CommitValidators.Policy.GERRIT));
+      bu.addOp(
+          change.getId(),
+          rebaseFactory
+              .create(control, rsrc.getPatchSet(), findBaseRev(rw, rsrc, input))
+              .setForceContentMerge(true)
+              .setFireRevisionCreated(true)
+              .setValidatePolicy(CommitValidators.Policy.GERRIT));
       bu.execute();
     }
     return json.create(OPTIONS).format(change.getProject(), change.getId());
   }
 
-  private String findBaseRev(RevWalk rw, RevisionResource rsrc,
-      RebaseInput input) throws AuthException, ResourceConflictException,
-      OrmException, IOException, NoSuchChangeException {
+  private String findBaseRev(RevWalk rw, RevisionResource rsrc, RebaseInput input)
+      throws AuthException, ResourceConflictException, OrmException, IOException,
+          NoSuchChangeException {
     if (input == null || input.base == null) {
       return null;
     }
@@ -149,18 +150,17 @@
       throw new ResourceConflictException(
           "base change is targeting wrong branch: " + baseChange.getDest());
     } else if (baseChange.getStatus() == Status.ABANDONED) {
-      throw new ResourceConflictException(
-          "base change is abandoned: " + baseChange.getKey());
+      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
     } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
       throw new ResourceConflictException(
-          "base change " + baseChange.getKey()
-          + " is a descendant of the current change - recursion not allowed");
+          "base change "
+              + baseChange.getKey()
+              + " is a descendant of the current change - recursion not allowed");
     }
     return base.patchSet().getRevision().get();
   }
 
-  private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip)
-      throws IOException {
+  private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
     ObjectId baseId = ObjectId.fromString(base.getRevision().get());
     ObjectId tipId = ObjectId.fromString(tip.getRevision().get());
     return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
@@ -182,9 +182,8 @@
     } catch (OrmException e) {
       log.error("Cannot check canRebase status. Assuming false.", e);
     }
-    boolean visible = resource.getChange().getStatus().isOpen()
-          && resource.isCurrent()
-          && canRebase;
+    boolean visible =
+        resource.getChange().getStatus().isOpen() && resource.isCurrent() && canRebase;
     boolean enabled = true;
 
     if (visible) {
@@ -193,35 +192,33 @@
         visible = hasOneParent(rw, resource.getPatchSet());
         enabled = rebaseUtil.canRebase(patchSet, dest, repo, rw);
       } catch (IOException e) {
-        log.error("Failed to check if patch set can be rebased: "
-            + resource.getPatchSet(), e);
+        log.error("Failed to check if patch set can be rebased: " + resource.getPatchSet(), e);
         visible = false;
       }
     }
-    UiAction.Description descr = new UiAction.Description()
-      .setLabel("Rebase")
-      .setTitle("Rebase onto tip of branch or parent change")
-      .setVisible(visible)
-      .setEnabled(enabled);
+    UiAction.Description descr =
+        new UiAction.Description()
+            .setLabel("Rebase")
+            .setTitle("Rebase onto tip of branch or parent change")
+            .setVisible(visible)
+            .setEnabled(enabled);
     return descr;
   }
 
-  public static class CurrentRevision implements
-      RestModifyView<ChangeResource, RebaseInput> {
+  public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
+    private final PatchSetUtil psUtil;
     private final Rebase rebase;
 
     @Inject
-    CurrentRevision(Rebase rebase) {
+    CurrentRevision(PatchSetUtil psUtil, Rebase rebase) {
+      this.psUtil = psUtil;
       this.rebase = rebase;
     }
 
     @Override
     public ChangeInfo apply(ChangeResource rsrc, RebaseInput input)
-        throws EmailException, OrmException, UpdateException, RestApiException,
-        IOException, NoSuchChangeException {
-      PatchSet ps =
-          rebase.dbProvider.get().patchSets()
-              .get(rsrc.getChange().currentPatchSetId());
+        throws EmailException, OrmException, UpdateException, RestApiException, IOException {
+      PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       } else if (!rsrc.getControl().isPatchVisible(ps, rebase.dbProvider.get())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
index baa0990..7b673dd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -26,24 +25,19 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class RebaseChangeEdit implements
-    ChildCollection<ChangeResource, ChangeEditResource>,
-    AcceptsPost<ChangeResource> {
+public class RebaseChangeEdit
+    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
 
   private final Rebase rebase;
 
@@ -75,44 +69,26 @@
 
   @Singleton
   public static class Rebase implements RestModifyView<ChangeResource, Rebase.Input> {
-    public static class Input {
-    }
+    public static class Input {}
 
+    private final GitRepositoryManager repositoryManager;
     private final ChangeEditModifier editModifier;
-    private final ChangeEditUtil editUtil;
-    private final PatchSetUtil psUtil;
-    private final Provider<ReviewDb> db;
 
     @Inject
-    Rebase(ChangeEditModifier editModifier,
-        ChangeEditUtil editUtil,
-        PatchSetUtil psUtil,
-        Provider<ReviewDb> db) {
+    Rebase(GitRepositoryManager repositoryManager, ChangeEditModifier editModifier) {
+      this.repositoryManager = repositoryManager;
       this.editModifier = editModifier;
-      this.editUtil = editUtil;
-      this.psUtil = psUtil;
-      this.db = db;
     }
 
     @Override
     public Response<?> apply(ChangeResource rsrc, Rebase.Input in)
-        throws AuthException, ResourceConflictException, IOException,
-        InvalidChangeOperationException, OrmException {
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
-      if (!edit.isPresent()) {
-        throw new ResourceConflictException(String.format(
-            "no edit exists for change %s",
-            rsrc.getChange().getChangeId()));
+        throws AuthException, ResourceConflictException, IOException, OrmException {
+      Project.NameKey project = rsrc.getProject();
+      try (Repository repository = repositoryManager.openRepository(project)) {
+        editModifier.rebaseEdit(repository, rsrc.getControl());
+      } catch (InvalidChangeOperationException e) {
+        throw new ResourceConflictException(e.getMessage());
       }
-
-      PatchSet current = psUtil.current(db.get(), rsrc.getNotes());
-      if (current.getId().equals(edit.get().getBasePatchSet().getId())) {
-        throw new ResourceConflictException(String.format(
-            "edit for change %s is already on latest patch set: %s",
-            rsrc.getChange().getChangeId(),
-            current.getId()));
-      }
-      editModifier.rebaseEdit(edit.get(), current);
       return Response.none();
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 20dbfb3..c03bb6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -24,20 +25,20 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -45,12 +46,10 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-
-public class RebaseChangeOp extends BatchUpdate.Op {
+public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
-    RebaseChangeOp create(ChangeControl ctl, PatchSet originalPatchSet,
-        @Nullable String baseCommitish);
+    RebaseChangeOp create(
+        ChangeControl ctl, PatchSet originalPatchSet, @Nullable String baseCommitish);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
@@ -68,6 +67,8 @@
   private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
   private boolean copyApprovals = true;
+  private boolean detailedCommitMessage;
+  private boolean postMessage = true;
 
   private RevCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -102,8 +103,7 @@
     return this;
   }
 
-  public RebaseChangeOp setCheckAddPatchSetPermission(
-      boolean checkAddPatchSetPermission) {
+  public RebaseChangeOp setCheckAddPatchSetPermission(boolean checkAddPatchSetPermission) {
     this.checkAddPatchSetPermission = checkAddPatchSetPermission;
     return this;
   }
@@ -123,10 +123,20 @@
     return this;
   }
 
+  public RebaseChangeOp setDetailedCommitMessage(boolean detailedCommitMessage) {
+    this.detailedCommitMessage = detailedCommitMessage;
+    return this;
+  }
+
+  public RebaseChangeOp setPostMessage(boolean postMessage) {
+    this.postMessage = postMessage;
+    return this;
+  }
+
   @Override
-  public void updateRepo(RepoContext ctx) throws MergeConflictException,
-       InvalidChangeOperationException, RestApiException, IOException,
-       OrmException, NoSuchChangeException {
+  public void updateRepo(RepoContext ctx)
+      throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
+          OrmException, NoSuchChangeException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevId oldRev = originalPatchSet.getRevision();
@@ -134,36 +144,58 @@
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(ObjectId.fromString(oldRev.get()));
     rw.parseBody(original);
+
     RevCommit baseCommit;
     if (baseCommitish != null) {
-       baseCommit = rw.parseCommit(ctx.getRepository().resolve(baseCommitish));
+      baseCommit = rw.parseCommit(ctx.getRepository().resolve(baseCommitish));
     } else {
-       baseCommit = rw.parseCommit(rebaseUtil.findBaseRevision(
-           originalPatchSet, ctl.getChange().getDest(),
-           ctx.getRepository(), ctx.getRevWalk()));
+      baseCommit =
+          rw.parseCommit(
+              rebaseUtil.findBaseRevision(
+                  originalPatchSet,
+                  ctl.getChange().getDest(),
+                  ctx.getRepository(),
+                  ctx.getRevWalk()));
     }
 
-    rebasedCommit = rebaseCommit(ctx, original, baseCommit);
+    String newCommitMessage;
+    if (detailedCommitMessage) {
+      rw.parseBody(baseCommit);
+      newCommitMessage =
+          newMergeUtil()
+              .createCommitMessageOnSubmit(original, baseCommit, ctl, originalPatchSet.getId());
+    } else {
+      newCommitMessage = original.getFullMessage();
+    }
 
-    RevId baseRevId = new RevId((baseCommitish != null) ? baseCommitish
-        : ObjectId.toString(baseCommit.getId()));
-    Base base = rebaseUtil.parseBase(
-        new RevisionResource(
-            changeResourceFactory.create(ctl), originalPatchSet),
-        baseRevId.get());
+    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
 
-    rebasedPatchSetId = ChangeUtil.nextPatchSetId(
-        ctx.getRepository(), ctl.getChange().currentPatchSetId());
-    patchSetInserter = patchSetInserterFactory
-        .create(ctl, rebasedPatchSetId, rebasedCommit)
-        .setDraft(originalPatchSet.isDraft())
-        .setSendMail(false)
-        .setFireRevisionCreated(fireRevisionCreated)
-        .setCopyApprovals(copyApprovals)
-        .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
-        .setMessage(
-          "Patch Set " + rebasedPatchSetId.get()
-          + ": Patch Set " + originalPatchSet.getId().get() + " was rebased");
+    RevId baseRevId =
+        new RevId((baseCommitish != null) ? baseCommitish : ObjectId.toString(baseCommit.getId()));
+    Base base =
+        rebaseUtil.parseBase(
+            new RevisionResource(changeResourceFactory.create(ctl), originalPatchSet),
+            baseRevId.get());
+
+    rebasedPatchSetId =
+        ChangeUtil.nextPatchSetId(ctx.getRepository(), ctl.getChange().currentPatchSetId());
+    patchSetInserter =
+        patchSetInserterFactory
+            .create(ctl, rebasedPatchSetId, rebasedCommit)
+            .setDescription("Rebase")
+            .setDraft(originalPatchSet.isDraft())
+            .setNotify(NotifyHandling.NONE)
+            .setFireRevisionCreated(fireRevisionCreated)
+            .setCopyApprovals(copyApprovals)
+            .setCheckAddPatchSetPermission(checkAddPatchSetPermission);
+    if (postMessage) {
+      patchSetInserter.setMessage(
+          "Patch Set "
+              + rebasedPatchSetId.get()
+              + ": Patch Set "
+              + originalPatchSet.getId().get()
+              + " was rebased");
+    }
 
     if (base != null) {
       patchSetInserter.setGroups(base.patchSet().getGroups());
@@ -188,20 +220,17 @@
   }
 
   public RevCommit getRebasedCommit() {
-    checkState(rebasedCommit != null,
-        "getRebasedCommit() only valid after updateRepo");
+    checkState(rebasedCommit != null, "getRebasedCommit() only valid after updateRepo");
     return rebasedCommit;
   }
 
   public PatchSet.Id getPatchSetId() {
-    checkState(rebasedPatchSetId != null,
-        "getPatchSetId() only valid after updateRepo");
+    checkState(rebasedPatchSetId != null, "getPatchSetId() only valid after updateRepo");
     return rebasedPatchSetId;
   }
 
   public PatchSet getPatchSet() {
-    checkState(rebasedPatchSet != null,
-        "getPatchSet() only valid after executing update");
+    checkState(rebasedPatchSet != null, "getPatchSet() only valid after executing update");
     return rebasedPatchSet;
   }
 
@@ -222,17 +251,17 @@
    * @throws MergeConflictException the rebase failed due to a merge conflict.
    * @throws IOException the merge failed for another reason.
    */
-  private RevCommit rebaseCommit(RepoContext ctx, RevCommit original,
-      ObjectId base) throws ResourceConflictException, MergeConflictException,
-      IOException {
+  private RevCommit rebaseCommit(
+      RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+      throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
     if (base.equals(parentCommit)) {
       throw new ResourceConflictException("Change is already up to date.");
     }
 
-    ThreeWayMerger merger = newMergeUtil().newThreeWayMerger(
-        ctx.getRepository(), ctx.getInserter());
+    ThreeWayMerger merger =
+        newMergeUtil().newThreeWayMerger(ctx.getRepository(), ctx.getInserter());
     merger.setBase(parentCommit);
     merger.merge(original, base);
 
@@ -245,12 +274,11 @@
     cb.setTreeId(merger.getResultTreeId());
     cb.setParentId(base);
     cb.setAuthor(original.getAuthorIdent());
-    cb.setMessage(original.getFullMessage());
+    cb.setMessage(commitMessage);
     if (committerIdent != null) {
       cb.setCommitter(committerIdent);
     } else {
-      cb.setCommitter(ctx.getIdentifiedUser()
-          .newCommitterIdent(ctx.getWhen(), ctx.getTimeZone()));
+      cb.setCommitter(ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone()));
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
     ctx.getInserter().flush();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
index 0956f9e..0f85c2f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -28,13 +28,12 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -43,8 +42,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 /** Utility methods related to rebasing changes. */
 public class RebaseUtil {
   private static final Logger log = LoggerFactory.getLogger(RebaseUtil.class);
@@ -55,7 +52,8 @@
   private final PatchSetUtil psUtil;
 
   @Inject
-  RebaseUtil(Provider<InternalChangeQuery> queryProvider,
+  RebaseUtil(
+      Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       Provider<ReviewDb> dbProvider,
       PatchSetUtil psUtil) {
@@ -65,17 +63,14 @@
     this.psUtil = psUtil;
   }
 
-  public boolean canRebase(PatchSet patchSet, Branch.NameKey dest,
-      Repository git, RevWalk rw) {
+  public boolean canRebase(PatchSet patchSet, Branch.NameKey dest, Repository git, RevWalk rw) {
     try {
       findBaseRevision(patchSet, dest, git, rw);
       return true;
     } catch (RestApiException e) {
       return false;
     } catch (OrmException | IOException e) {
-      log.warn(String.format(
-          "Error checking if patch set %s on %s can be rebased",
-          patchSet.getId(), dest), e);
+      log.warn("Error checking if patch set {} on {} can be rebased", patchSet.getId(), dest, e);
       return false;
     }
   }
@@ -90,11 +85,11 @@
     }
 
     abstract ChangeControl control();
+
     abstract PatchSet patchSet();
   }
 
-  Base parseBase(RevisionResource rsrc, String base)
-      throws OrmException, NoSuchChangeException {
+  Base parseBase(RevisionResource rsrc, String base) throws OrmException {
     ReviewDb db = dbProvider.get();
 
     // Try parsing the base as a ref string.
@@ -120,38 +115,32 @@
 
     // Try parsing as SHA-1.
     Base ret = null;
-    for (ChangeData cd : queryProvider.get()
-        .byProjectCommit(rsrc.getProject(), base)) {
+    for (ChangeData cd : queryProvider.get().byProjectCommit(rsrc.getProject(), base)) {
       for (PatchSet ps : cd.patchSets()) {
         if (!ps.getRevision().matches(base)) {
           continue;
         }
         if (ret == null || ret.patchSet().getId().get() < ps.getId().get()) {
-          ret = Base.create(
-              rsrc.getControl().getProjectControl().controlFor(cd.notes()),
-              ps);
+          ret = Base.create(rsrc.getControl().getProjectControl().controlFor(cd.notes()), ps);
         }
       }
     }
     return ret;
   }
 
-  private ChangeControl controlFor(RevisionResource rsrc, Change.Id id)
-      throws OrmException, NoSuchChangeException {
+  private ChangeControl controlFor(RevisionResource rsrc, Change.Id id) throws OrmException {
     if (rsrc.getChange().getId().equals(id)) {
       return rsrc.getControl();
     }
-    ChangeNotes notes =
-        notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
+    ChangeNotes notes = notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
     return rsrc.getControl().getProjectControl().controlFor(notes);
   }
 
   /**
    * Find the commit onto which a patch set should be rebased.
-   * <p>
-   * This is defined as the latest patch set of the change corresponding to
-   * this commit's parent, or the destination branch tip in the case where the
-   * parent's change is merged.
+   *
+   * <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
+   * or the destination branch tip in the case where the parent's change is merged.
    *
    * @param patchSet patch set for which the new base commit should be found.
    * @param destBranch the destination branch.
@@ -162,26 +151,23 @@
    * @throws IOException if accessing the repository fails.
    * @throws OrmException if accessing the database fails.
    */
-  ObjectId findBaseRevision(PatchSet patchSet, Branch.NameKey destBranch,
-      Repository git, RevWalk rw)
+  ObjectId findBaseRevision(
+      PatchSet patchSet, Branch.NameKey destBranch, Repository git, RevWalk rw)
       throws RestApiException, IOException, OrmException {
     String baseRev = null;
-    RevCommit commit = rw.parseCommit(
-        ObjectId.fromString(patchSet.getRevision().get()));
+    RevCommit commit = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
 
     if (commit.getParentCount() > 1) {
-      throw new UnprocessableEntityException(
-          "Cannot rebase a change with multiple parents.");
+      throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
     } else if (commit.getParentCount() == 0) {
       throw new UnprocessableEntityException(
-          "Cannot rebase a change without any parents"
-          + " (is this the initial commit?).");
+          "Cannot rebase a change without any parents (is this the initial commit?).");
     }
 
     RevId parentRev = new RevId(commit.getParent(0).name());
 
-    CHANGES: for (ChangeData cd : queryProvider.get()
-        .byBranchCommit(destBranch, parentRev.get())) {
+    CHANGES:
+    for (ChangeData cd : queryProvider.get().byBranchCommit(destBranch, parentRev.get())) {
       for (PatchSet depPatchSet : cd.patchSets()) {
         if (!depPatchSet.getRevision().equals(parentRev)) {
           continue;
@@ -189,15 +175,13 @@
         Change depChange = cd.change();
         if (depChange.getStatus() == Status.ABANDONED) {
           throw new ResourceConflictException(
-              "Cannot rebase a change with an abandoned parent: "
-              + depChange.getKey());
+              "Cannot rebase a change with an abandoned parent: " + depChange.getKey());
         }
 
         if (depChange.getStatus().isOpen()) {
           if (depPatchSet.getId().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
-                "Change is already based on the latest patch set of the"
-                + " dependent change.");
+                "Change is already based on the latest patch set of the dependent change.");
           }
           baseRev = cd.currentPatchSet().getRevision().get();
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
index 5fe0e0b..682b45f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
@@ -14,55 +14,91 @@
 
 package com.google.gerrit.server.change;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.Rebuild.Input;
-import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class Rebuild implements RestModifyView<ChangeResource, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final NotesMigration migration;
   private final ChangeRebuilder rebuilder;
+  private final ChangeBundleReader bundleReader;
+  private final CommentsUtil commentsUtil;
+  private final ChangeNotes.Factory notesFactory;
 
   @Inject
-  Rebuild(Provider<ReviewDb> db,
+  Rebuild(
+      Provider<ReviewDb> db,
       NotesMigration migration,
-      ChangeRebuilder rebuilder) {
+      ChangeRebuilder rebuilder,
+      ChangeBundleReader bundleReader,
+      CommentsUtil commentsUtil,
+      ChangeNotes.Factory notesFactory) {
     this.db = db;
     this.migration = migration;
     this.rebuilder = rebuilder;
+    this.bundleReader = bundleReader;
+    this.commentsUtil = commentsUtil;
+    this.notesFactory = notesFactory;
   }
 
   @Override
-  public Response<?> apply(ChangeResource rsrc, Input input)
-      throws ResourceNotFoundException, IOException, OrmException,
-      ConfigInvalidException {
+  public BinaryResult apply(ChangeResource rsrc, Input input)
+      throws ResourceNotFoundException, IOException, OrmException, ConfigInvalidException {
     if (!migration.commitChangeWrites()) {
       throw new ResourceNotFoundException();
     }
+    if (!migration.readChanges()) {
+      // ChangeBundle#fromNotes currently doesn't work if reading isn't enabled,
+      // so don't attempt a diff.
+      rebuild(rsrc);
+      return BinaryResult.create("Rebuilt change successfully");
+    }
+
+    // Not the same transaction as the rebuild, so may result in spurious diffs
+    // in the case of races. This should be easy enough to detect by rerunning.
+    ChangeBundle reviewDbBundle =
+        bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db.get()), rsrc.getId());
+    rebuild(rsrc);
+    ChangeNotes notes = notesFactory.create(db.get(), rsrc.getChange().getProject(), rsrc.getId());
+    ChangeBundle noteDbBundle = ChangeBundle.fromNotes(commentsUtil, notes);
+    List<String> diffs = reviewDbBundle.differencesFrom(noteDbBundle);
+    if (diffs.isEmpty()) {
+      return BinaryResult.create("No differences between ReviewDb and NoteDb");
+    }
+    return BinaryResult.create(
+        diffs.stream().collect(joining("\n", "Differences between ReviewDb and NoteDb:\n", "\n")));
+  }
+
+  private void rebuild(ChangeResource rsrc)
+      throws ResourceNotFoundException, OrmException, IOException {
     try {
       rebuilder.rebuild(db.get(), rsrc.getId());
     } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(
-          IdString.fromDecoded(rsrc.getId().toString()));
+      throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getId().toString()));
     }
-    return Response.none();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 74d7552..4810a02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -19,11 +19,11 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -33,12 +33,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -51,6 +45,10 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
 class RelatedChangesSorter {
@@ -72,10 +70,10 @@
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
-        ArrayListMultimap.create(in.size(), 3);
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
     // Map of patch set -> immediate children.
     ListMultimap<PatchSetData, PatchSetData> children =
-        ArrayListMultimap.create(in.size(), 3);
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
     // All other patch sets of the same change as startPs.
     List<PatchSetData> otherPatchSetsOfStart = new ArrayList<>();
 
@@ -98,8 +96,7 @@
     Collection<PatchSetData> ancestors = walkAncestors(ctl, parents, start);
     List<PatchSetData> descendants =
         walkDescendants(ctl, children, start, otherPatchSetsOfStart, ancestors);
-    List<PatchSetData> result =
-        new ArrayList<>(ancestors.size() + descendants.size() - 1);
+    List<PatchSetData> result = new ArrayList<>(ancestors.size() + descendants.size() - 1);
     result.addAll(Lists.reverse(descendants));
     result.addAll(ancestors);
     return result;
@@ -108,15 +105,17 @@
   private Map<String, PatchSetData> collectById(List<ChangeData> in)
       throws OrmException, IOException {
     Project.NameKey project = in.get(0).change().getProject();
-    Map<String, PatchSetData> result =
-        Maps.newHashMapWithExpectedSize(in.size() * 3);
+    Map<String, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(true);
       for (ChangeData cd : in) {
-        checkArgument(cd.change().getProject().equals(project),
+        checkArgument(
+            cd.change().getProject().equals(project),
             "Expected change %s in project %s, found %s",
-            cd.getId(), project, cd.change().getProject());
+            cd.getId(),
+            project,
+            cd.change().getProject());
         for (PatchSet ps : cd.patchSets()) {
           String id = ps.getRevision().get();
           RevCommit c = rw.parseCommit(ObjectId.fromString(id));
@@ -128,8 +127,8 @@
     return result;
   }
 
-  private static Collection<PatchSetData> walkAncestors(ProjectControl ctl,
-      ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
+  private static Collection<PatchSetData> walkAncestors(
+      ProjectControl ctl, ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
       throws OrmException {
     LinkedHashSet<PatchSetData> result = new LinkedHashSet<>();
     Deque<PatchSetData> pending = new ArrayDeque<>();
@@ -145,38 +144,41 @@
     return result;
   }
 
-  private static List<PatchSetData> walkDescendants(ProjectControl ctl,
+  private static List<PatchSetData> walkDescendants(
+      ProjectControl ctl,
       ListMultimap<PatchSetData, PatchSetData> children,
-      PatchSetData start, List<PatchSetData> otherPatchSetsOfStart,
+      PatchSetData start,
+      List<PatchSetData> otherPatchSetsOfStart,
       Iterable<PatchSetData> ancestors)
       throws OrmException {
     Set<Change.Id> alreadyEmittedChanges = new HashSet<>();
     addAllChangeIds(alreadyEmittedChanges, ancestors);
 
     // Prefer descendants found by following the original patch set passed in.
-    List<PatchSetData> result = walkDescendentsImpl(
-        ctl, alreadyEmittedChanges, children, ImmutableList.of(start));
+    List<PatchSetData> result =
+        walkDescendentsImpl(ctl, alreadyEmittedChanges, children, ImmutableList.of(start));
     addAllChangeIds(alreadyEmittedChanges, result);
 
     // Then, go back and add new indirect descendants found by following any
     // other patch sets of start. These show up after all direct descendants,
     // because we wouldn't know where in the walk to insert them.
-    result.addAll(walkDescendentsImpl(
-          ctl, alreadyEmittedChanges, children, otherPatchSetsOfStart));
+    result.addAll(walkDescendentsImpl(ctl, alreadyEmittedChanges, children, otherPatchSetsOfStart));
     return result;
   }
 
-  private static void addAllChangeIds(Collection<Change.Id> changeIds,
-      Iterable<PatchSetData> psds) {
+  private static void addAllChangeIds(
+      Collection<Change.Id> changeIds, Iterable<PatchSetData> psds) {
     for (PatchSetData psd : psds) {
       changeIds.add(psd.id());
     }
   }
 
-  private static List<PatchSetData> walkDescendentsImpl(ProjectControl ctl,
+  private static List<PatchSetData> walkDescendentsImpl(
+      ProjectControl ctl,
       Set<Change.Id> alreadyEmittedChanges,
       ListMultimap<PatchSetData, PatchSetData> children,
-      List<PatchSetData> start) throws OrmException {
+      List<PatchSetData> start)
+      throws OrmException {
     if (start.isEmpty()) {
       return ImmutableList.of();
     }
@@ -218,12 +220,10 @@
     return result;
   }
 
-  private static boolean isVisible(PatchSetData psd, ProjectControl ctl)
-      throws OrmException {
+  private static boolean isVisible(PatchSetData psd, ProjectControl ctl) throws OrmException {
     // Reuse existing project control rather than lazily creating a new one for
     // each ChangeData.
-    return ctl.controlFor(psd.data().notes())
-        .isPatchVisible(psd.patchSet(), psd.data());
+    return ctl.controlFor(psd.data().notes()).isPatchVisible(psd.patchSet(), psd.data());
   }
 
   @AutoValue
@@ -234,7 +234,9 @@
     }
 
     abstract ChangeData data();
+
     abstract PatchSet patchSet();
+
     abstract RevCommit commit();
 
     PatchSet.Id psId() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
index 9c4c6d9..b6c4d02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
@@ -29,28 +29,27 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.UpdateException;
-import com.google.gerrit.server.mail.ReplyToChangeSender;
-import com.google.gerrit.server.mail.RestoredSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Restore implements RestModifyView<ChangeResource, RestoreInput>,
-    UiAction<ChangeResource> {
+public class Restore
+    implements RestModifyView<ChangeResource, RestoreInput>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Restore.class);
 
   private final RestoredSender.Factory restoredSenderFactory;
@@ -62,7 +61,8 @@
   private final ChangeRestored changeRestored;
 
   @Inject
-  Restore(RestoredSender.Factory restoredSenderFactory,
+  Restore(
+      RestoredSender.Factory restoredSenderFactory,
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
@@ -87,14 +87,15 @@
     }
 
     Op op = new Op(input);
-    try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
-        req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(), req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op).execute();
     }
-    return json.create(ChangeJson.NO_OPTIONS).format(op.change);
+    return json.noOptions().format(op.change);
   }
 
-  private class Op extends BatchUpdate.Op {
+  private class Op implements BatchUpdateOp {
     private final RestoreInput input;
 
     private Change change;
@@ -106,8 +107,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws OrmException,
-        ResourceConflictException {
+    public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
       change = ctx.getChange();
       if (change == null || change.getStatus() != Status.ABANDONED) {
         throw new ResourceConflictException("change is " + status(change));
@@ -124,40 +124,28 @@
       return true;
     }
 
-    private ChangeMessage newMessage(ChangeContext ctx) throws OrmException {
+    private ChangeMessage newMessage(ChangeContext ctx) {
       StringBuilder msg = new StringBuilder();
       msg.append("Restored");
       if (!Strings.nullToEmpty(input.message).trim().isEmpty()) {
         msg.append("\n\n");
         msg.append(input.message.trim());
       }
-
-      ChangeMessage message = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          ctx.getAccountId(),
-          ctx.getWhen(),
-          change.currentPatchSetId());
-      message.setMessage(msg.toString());
-      return message;
+      return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_RESTORE);
     }
 
     @Override
     public void postUpdate(Context ctx) throws OrmException {
       try {
-        ReplyToChangeSender cm =
-            restoredSenderFactory.create(ctx.getProject(), change.getId());
+        ReplyToChangeSender cm = restoredSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
         cm.setChangeMessage(message.getMessage(), ctx.getWhen());
         cm.send();
       } catch (Exception e) {
         log.error("Cannot email update for change " + change.getId(), e);
       }
-      changeRestored.fire(change, patchSet,
-          ctx.getAccount(),
-          Strings.emptyToNull(input.message),
-          ctx.getWhen());
+      changeRestored.fire(
+          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
     }
   }
 
@@ -170,10 +158,9 @@
       log.error("Cannot check canRestore status. Assuming false.", e);
     }
     return new UiAction.Description()
-      .setLabel("Restore")
-      .setTitle("Restore the change")
-      .setVisible(resource.getChange().getStatus() == Status.ABANDONED
-          && canRestore);
+        .setLabel("Restore")
+        .setTitle("Restore the change")
+        .setVisible(resource.getChange().getStatus() == Status.ABANDONED && canRestore);
   }
 
   private static String status(Change change) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
index 3ca496a..14ba111 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
@@ -34,32 +34,37 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.RevertedSender;
+import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
+import java.util.HashSet;
+import java.util.Set;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -68,15 +73,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.text.MessageFormat;
-import java.util.HashSet;
-import java.util.Set;
-
 @Singleton
-public class Revert implements RestModifyView<ChangeResource, RevertInput>,
-    UiAction<ChangeResource> {
+public class Revert
+    implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Revert.class);
 
   private final Provider<ReviewDb> db;
@@ -93,7 +92,8 @@
   private final ChangeReverted changeReverted;
 
   @Inject
-  Revert(Provider<ReviewDb> db,
+  Revert(
+      Provider<ReviewDb> db,
       GitRepositoryManager repoManager,
       ChangeInserter.Factory changeInserterFactory,
       ChangeMessagesUtil cmUtil,
@@ -121,8 +121,7 @@
 
   @Override
   public ChangeInfo apply(ChangeResource req, RevertInput input)
-      throws IOException, OrmException, RestApiException,
-      UpdateException, NoSuchChangeException {
+      throws IOException, OrmException, RestApiException, UpdateException, NoSuchChangeException {
     RefControl refControl = req.getControl().getRefControl();
     ProjectControl projectControl = req.getControl().getProjectControl();
 
@@ -138,10 +137,8 @@
       throw new ResourceConflictException("change is " + status(change));
     }
 
-    Change.Id revertedChangeId =
-        revert(req.getControl(), Strings.emptyToNull(input.message));
-    return json.create(ChangeJson.NO_OPTIONS).format(req.getProject(),
-        revertedChangeId);
+    Change.Id revertedChangeId = revert(req.getControl(), Strings.emptyToNull(input.message));
+    return json.noOptions().format(req.getProject(), revertedChangeId);
   }
 
   private Change.Id revert(ChangeControl ctl, String message)
@@ -156,7 +153,9 @@
     Project.NameKey project = ctl.getProject().getNameKey();
     CurrentUser user = ctl.getUser();
     try (Repository git = repoManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(git)) {
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
       RevCommit commitToRevert =
           revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
       if (commitToRevert.getParentCount() == 0) {
@@ -165,8 +164,8 @@
 
       Timestamp now = TimeUtil.nowTs();
       PersonIdent committerIdent = new PersonIdent(serverIdent, now);
-      PersonIdent authorIdent = user.asIdentifiedUser()
-          .newCommitterIdent(now, committerIdent.getTimeZone());
+      PersonIdent authorIdent =
+          user.asIdentifiedUser().newCommitterIdent(now, committerIdent.getTimeZone());
 
       RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
       revWalk.parseHeaders(parentToCommitToRevert);
@@ -179,45 +178,45 @@
 
       Change changeToRevert = ctl.getChange();
       if (message == null) {
-        message = MessageFormat.format(
-            ChangeMessages.get().revertChangeDefaultMessage,
-            changeToRevert.getSubject(), patch.getRevision().get());
+        message =
+            MessageFormat.format(
+                ChangeMessages.get().revertChangeDefaultMessage,
+                changeToRevert.getSubject(),
+                patch.getRevision().get());
       }
 
       ObjectId computedChangeId =
-          ChangeIdUtil.computeChangeId(parentToCommitToRevert.getTree(),
-              commitToRevert, authorIdent, committerIdent, message);
-      revertCommitBuilder.setMessage(
-          ChangeIdUtil.insertId(message, computedChangeId, true));
+          ChangeIdUtil.computeChangeId(
+              parentToCommitToRevert.getTree(),
+              commitToRevert,
+              authorIdent,
+              committerIdent,
+              message);
+      revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, computedChangeId, true));
 
       Change.Id changeId = new Change.Id(seq.nextChangeId());
-      try (ObjectInserter oi = git.newObjectInserter()) {
-        ObjectId id = oi.insert(revertCommitBuilder);
-        oi.flush();
-        RevCommit revertCommit = revWalk.parseCommit(id);
+      ObjectId id = oi.insert(revertCommitBuilder);
+      RevCommit revertCommit = revWalk.parseCommit(id);
 
-        ChangeInserter ins = changeInserterFactory.create(
-            changeId, revertCommit, ctl.getChange().getDest().get())
-            .setValidatePolicy(CommitValidators.Policy.GERRIT)
-            .setTopic(changeToRevert.getTopic());
-        ins.setMessage("Uploaded patch set 1.");
+      ChangeInserter ins =
+          changeInserterFactory
+              .create(changeId, revertCommit, ctl.getChange().getDest().get())
+              .setValidatePolicy(CommitValidators.Policy.GERRIT)
+              .setTopic(changeToRevert.getTopic());
+      ins.setMessage("Uploaded patch set 1.");
 
-        Set<Account.Id> reviewers = new HashSet<>();
-        reviewers.add(changeToRevert.getOwner());
-        reviewers.addAll(
-            approvalsUtil.getReviewers(db.get(), ctl.getNotes()).all());
-        reviewers.remove(user.getAccountId());
-        ins.setReviewers(reviewers);
+      Set<Account.Id> reviewers = new HashSet<>();
+      reviewers.add(changeToRevert.getOwner());
+      reviewers.addAll(approvalsUtil.getReviewers(db.get(), ctl.getNotes()).all());
+      reviewers.remove(user.getAccountId());
+      ins.setReviewers(reviewers);
 
-        try (BatchUpdate bu = updateFactory.create(
-            db.get(), project, user, now)) {
-          bu.setRepository(git, revWalk, oi);
-          bu.insertChange(ins);
-          bu.addOp(changeId, new NotifyOp(ctl.getChange(), ins));
-          bu.addOp(changeToRevert.getId(),
-              new PostRevertedMessageOp(computedChangeId));
-          bu.execute();
-        }
+      try (BatchUpdate bu = updateFactory.create(db.get(), project, user, now)) {
+        bu.setRepository(git, revWalk, oi);
+        bu.insertChange(ins);
+        bu.addOp(changeId, new NotifyOp(ctl.getChange(), ins));
+        bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(computedChangeId));
+        bu.execute();
       }
       return changeId;
     } catch (RepositoryNotFoundException e) {
@@ -228,17 +227,18 @@
   @Override
   public UiAction.Description getDescription(ChangeResource resource) {
     return new UiAction.Description()
-      .setLabel("Revert")
-      .setTitle("Revert the change")
-      .setVisible(resource.getChange().getStatus() == Status.MERGED
-          && resource.getControl().getRefControl().canUpload());
+        .setLabel("Revert")
+        .setTitle("Revert the change")
+        .setVisible(
+            resource.getChange().getStatus() == Status.MERGED
+                && resource.getControl().getRefControl().canUpload());
   }
 
   private static String status(Change change) {
     return change != null ? change.getStatus().name().toLowerCase() : "deleted";
   }
 
-  private class NotifyOp extends BatchUpdate.Op {
+  private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
 
@@ -252,8 +252,7 @@
       changeReverted.fire(change, ins.getChange(), ctx.getWhen());
       Change.Id changeId = ins.getChange().getId();
       try {
-        RevertedSender cm =
-            revertedSenderFactory.create(ctx.getProject(), changeId);
+        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), changeId);
         cm.setFrom(ctx.getAccountId());
         cm.setChangeMessage(ins.getChangeMessage().getMessage(), ctx.getWhen());
         cm.send();
@@ -263,7 +262,7 @@
     }
   }
 
-  private class PostRevertedMessageOp extends BatchUpdate.Op {
+  private class PostRevertedMessageOp implements BatchUpdateOp {
     private final ObjectId computedChangeId;
 
     PostRevertedMessageOp(ObjectId computedChangeId) {
@@ -274,16 +273,12 @@
     public boolean updateChange(ChangeContext ctx) throws Exception {
       Change change = ctx.getChange();
       PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(db.get())),
-          ctx.getAccountId(), ctx.getWhen(), patchSetId);
-      StringBuilder msgBuf = new StringBuilder();
-      msgBuf.append("Created a revert of this change as ")
-          .append("I").append(computedChangeId.name());
-      changeMessage.setMessage(msgBuf.toString());
-      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(patchSetId),
-          changeMessage);
+      ChangeMessage changeMessage =
+          ChangeMessagesUtil.newMessage(
+              ctx,
+              "Created a revert of this change as I" + computedChangeId.name(),
+              ChangeMessagesUtil.TAG_REVERT);
+      cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(patchSetId), changeMessage);
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
index 997a8f9..0d25d35 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
@@ -22,12 +22,10 @@
 import com.google.inject.Singleton;
 
 public class Reviewed {
-  public static class Input {
-  }
+  public static class Input {}
 
   @Singleton
-  public static class PutReviewed
-      implements RestModifyView<FileResource, Input> {
+  public static class PutReviewed implements RestModifyView<FileResource, Input> {
     private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
@@ -36,11 +34,13 @@
     }
 
     @Override
-    public Response<String> apply(FileResource resource, Input input)
-        throws OrmException {
-      if (accountPatchReviewStore.get().markReviewed(
-          resource.getPatchKey().getParentKey(), resource.getAccountId(),
-          resource.getPatchKey().getFileName())) {
+    public Response<String> apply(FileResource resource, Input input) throws OrmException {
+      if (accountPatchReviewStore
+          .get()
+          .markReviewed(
+              resource.getPatchKey().getParentKey(),
+              resource.getAccountId(),
+              resource.getPatchKey().getFileName())) {
         return Response.created("");
       }
       return Response.ok("");
@@ -48,26 +48,25 @@
   }
 
   @Singleton
-  public static class DeleteReviewed
-      implements RestModifyView<FileResource, Input> {
+  public static class DeleteReviewed implements RestModifyView<FileResource, Input> {
     private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
 
     @Inject
-    DeleteReviewed(
-        DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+    DeleteReviewed(DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
       this.accountPatchReviewStore = accountPatchReviewStore;
     }
 
     @Override
-    public Response<?> apply(FileResource resource, Input input)
-        throws OrmException {
-      accountPatchReviewStore.get().clearReviewed(
-          resource.getPatchKey().getParentKey(), resource.getAccountId(),
-          resource.getPatchKey().getFileName());
+    public Response<?> apply(FileResource resource, Input input) throws OrmException {
+      accountPatchReviewStore
+          .get()
+          .clearReviewed(
+              resource.getPatchKey().getParentKey(),
+              resource.getAccountId(),
+              resource.getPatchKey().getFileName());
       return Response.none();
     }
   }
 
-  private Reviewed() {
-  }
+  private Reviewed() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
index 69cd439..ac7f15e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -37,7 +37,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.Collection;
 import java.util.List;
 import java.util.TreeMap;
@@ -50,7 +49,8 @@
   private final AccountLoader.Factory accountLoaderFactory;
 
   @Inject
-  ReviewerJson(Provider<ReviewDb> db,
+  ReviewerJson(
+      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       AccountLoader.Factory accountLoaderFactory) {
@@ -60,14 +60,14 @@
     this.accountLoaderFactory = accountLoaderFactory;
   }
 
-  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
-      throws OrmException {
+  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs) throws OrmException {
     List<ReviewerInfo> infos = Lists.newArrayListWithCapacity(rsrcs.size());
     AccountLoader loader = accountLoaderFactory.create(true);
     for (ReviewerResource rsrc : rsrcs) {
-      ReviewerInfo info = format(new ReviewerInfo(
-          rsrc.getReviewerUser().getAccountId().get()),
-          rsrc.getReviewerControl());
+      ReviewerInfo info =
+          format(
+              new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
+              rsrc.getReviewerControl());
       loader.put(info);
       infos.add(info);
     }
@@ -76,18 +76,20 @@
   }
 
   public List<ReviewerInfo> format(ReviewerResource rsrc) throws OrmException {
-    return format(ImmutableList.<ReviewerResource> of(rsrc));
+    return format(ImmutableList.<ReviewerResource>of(rsrc));
   }
 
   public ReviewerInfo format(ReviewerInfo out, ChangeControl ctl) throws OrmException {
     PatchSet.Id psId = ctl.getChange().currentPatchSetId();
-    return format(out, ctl,
-        approvalsUtil.byPatchSetUser(db.get(), ctl, psId,
-            new Account.Id(out._accountId)));
+    return format(
+        out,
+        ctl,
+        approvalsUtil.byPatchSetUser(db.get(), ctl, psId, new Account.Id(out._accountId)));
   }
 
-  public ReviewerInfo format(ReviewerInfo out, ChangeControl ctl,
-      Iterable<PatchSetApproval> approvals) throws OrmException {
+  public ReviewerInfo format(
+      ReviewerInfo out, ChangeControl ctl, Iterable<PatchSetApproval> approvals)
+      throws OrmException {
     LabelTypes labelTypes = ctl.getLabelTypes();
 
     // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
@@ -108,10 +110,8 @@
     ChangeData cd = changeDataFactory.create(db.get(), ctl);
     PatchSet ps = cd.currentPatchSet();
     if (ps != null) {
-      for (SubmitRecord rec : new SubmitRuleEvaluator(cd)
-          .setFastEvalLabels(true)
-          .setAllowDraft(true)
-          .evaluate()) {
+      for (SubmitRecord rec :
+          new SubmitRuleEvaluator(cd).setFastEvalLabels(true).setAllowDraft(true).evaluate()) {
         if (rec.labels == null) {
           continue;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
index aac9252..6ff4a50 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -30,16 +30,31 @@
 
   public interface Factory {
     ReviewerResource create(ChangeResource change, Account.Id id);
+
+    ReviewerResource create(RevisionResource revision, Account.Id id);
   }
 
   private final ChangeResource change;
+  private final RevisionResource revision;
   private final IdentifiedUser user;
 
   @AssistedInject
-  ReviewerResource(IdentifiedUser.GenericFactory userFactory,
+  ReviewerResource(
+      IdentifiedUser.GenericFactory userFactory,
       @Assisted ChangeResource change,
       @Assisted Account.Id id) {
     this.change = change;
+    this.revision = null;
+    this.user = userFactory.create(id);
+  }
+
+  @AssistedInject
+  ReviewerResource(
+      IdentifiedUser.GenericFactory userFactory,
+      @Assisted RevisionResource revision,
+      @Assisted Account.Id id) {
+    this.revision = revision;
+    this.change = revision.getChangeResource();
     this.user = userFactory.create(id);
   }
 
@@ -47,6 +62,10 @@
     return change;
   }
 
+  public RevisionResource getRevisionResource() {
+    return revision;
+  }
+
   public Change.Id getChangeId() {
     return change.getId();
   }
@@ -60,16 +79,16 @@
   }
 
   /**
-   * @return the control for the caller's user (as opposed to the reviewer's
-   *     user as returned by {@link #getReviewerControl()}).
+   * @return the control for the caller's user (as opposed to the reviewer's user as returned by
+   *     {@link #getReviewerControl()}).
    */
   public ChangeControl getControl() {
     return change.getControl();
   }
 
   /**
-   * @return the control for the reviewer's user (as opposed to the caller's
-   *     user as returned by {@link #getControl()}).
+   * @return the control for the reviewer's user (as opposed to the caller's user as returned by
+   *     {@link #getControl()}).
    */
   public ChangeControl getReviewerControl() {
     return change.getControl().forUser(user);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java
new file mode 100644
index 0000000..198a5fd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Set;
+
+/**
+ * Listener to provide reviewer suggestions.
+ *
+ * <p>Invoked by Gerrit when a user clicks "Add Reviewer" on a change.
+ */
+@ExtensionPoint
+public interface ReviewerSuggestion {
+  /**
+   * Suggest reviewers to add to a change.
+   *
+   * @param project The name key of the project the suggestion is for.
+   * @param changeId The changeId that the suggestion is for. Can be {@code null}.
+   * @param query The query as typed by the user. Can be {@code null}.
+   * @param candidates A set of candidates for the ranking. Can be empty.
+   * @return Set of {@link SuggestedReviewer}s. The {@link
+   *     com.google.gerrit.reviewdb.client.Account.Id}s listed here don't have to be included in
+   *     {@code candidates}.
+   */
+  Set<SuggestedReviewer> suggestReviewers(
+      Project.NameKey project,
+      @Nullable Change.Id changeId,
+      @Nullable String query,
+      Set<Account.Id> candidates);
+}
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 d45d260..14c74bc 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
@@ -29,12 +29,10 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.Collection;
 
 @Singleton
-public class Reviewers implements
-    ChildCollection<ChangeResource, ReviewerResource> {
+public class Reviewers implements ChildCollection<ChangeResource, ReviewerResource> {
   private final DynamicMap<RestView<ReviewerResource>> views;
   private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
@@ -43,7 +41,8 @@
   private final ListReviewers list;
 
   @Inject
-  Reviewers(Provider<ReviewDb> dbProvider,
+  Reviewers(
+      Provider<ReviewDb> dbProvider,
       ApprovalsUtil approvalsUtil,
       AccountsCollection accounts,
       ReviewerResource.Factory resourceFactory,
@@ -70,8 +69,7 @@
   @Override
   public ReviewerResource parse(ChangeResource rsrc, IdString id)
       throws OrmException, ResourceNotFoundException, AuthException {
-    Account.Id accountId =
-        accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
 
     // See if the id exists as a reviewer for this change
     if (fetchAccountIds(rsrc).contains(accountId)) {
@@ -80,9 +78,7 @@
     throw new ResourceNotFoundException(id);
   }
 
-  private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc)
-      throws OrmException {
-    return approvalsUtil.getReviewers(
-        dbProvider.get(), rsrc.getNotes()).all();
+  private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc) throws OrmException {
+    return approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
index a8fd013..4d35f9e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -27,6 +26,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
 
 public class RevisionResource implements RestResource, HasETag {
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
@@ -38,11 +38,10 @@
   private boolean cacheable = true;
 
   public RevisionResource(ChangeResource change, PatchSet ps) {
-    this(change, ps, Optional.<ChangeEdit> absent());
+    this(change, ps, Optional.empty());
   }
 
-  public RevisionResource(ChangeResource change, PatchSet ps,
-      Optional<ChangeEdit> edit) {
+  public RevisionResource(ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit) {
     this.change = change;
     this.ps = ps;
     this.edit = edit;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
new file mode 100644
index 0000000..d3623cf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collection;
+
+@Singleton
+public class RevisionReviewers implements ChildCollection<RevisionResource, ReviewerResource> {
+  private final DynamicMap<RestView<ReviewerResource>> views;
+  private final Provider<ReviewDb> dbProvider;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountsCollection accounts;
+  private final ReviewerResource.Factory resourceFactory;
+  private final ListRevisionReviewers list;
+
+  @Inject
+  RevisionReviewers(
+      Provider<ReviewDb> dbProvider,
+      ApprovalsUtil approvalsUtil,
+      AccountsCollection accounts,
+      ReviewerResource.Factory resourceFactory,
+      DynamicMap<RestView<ReviewerResource>> views,
+      ListRevisionReviewers list) {
+    this.dbProvider = dbProvider;
+    this.approvalsUtil = approvalsUtil;
+    this.accounts = accounts;
+    this.resourceFactory = resourceFactory;
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public DynamicMap<RestView<ReviewerResource>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<RevisionResource> list() {
+    return list;
+  }
+
+  @Override
+  public ReviewerResource parse(RevisionResource rsrc, IdString id)
+      throws OrmException, ResourceNotFoundException, AuthException, MethodNotAllowedException {
+    if (!rsrc.isCurrent()) {
+      throw new MethodNotAllowedException("Cannot access on non-current patch set");
+    }
+
+    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+
+    Collection<Account.Id> reviewers =
+        approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+    if (reviewers.contains(accountId)) {
+      return resourceFactory.create(rsrc, accountId);
+    }
+    throw new ResourceNotFoundException(id);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
index 30a09cf..ad55fd3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -33,11 +32,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
@@ -47,7 +46,8 @@
   private final PatchSetUtil psUtil;
 
   @Inject
-  Revisions(DynamicMap<RestView<RevisionResource>> views,
+  Revisions(
+      DynamicMap<RestView<RevisionResource>> views,
       Provider<ReviewDb> dbProvider,
       ChangeEditUtil editUtil,
       PatchSetUtil psUtil) {
@@ -69,9 +69,8 @@
 
   @Override
   public RevisionResource parse(ChangeResource change, IdString id)
-      throws ResourceNotFoundException, AuthException, OrmException,
-      IOException {
-    if (id.equals("current")) {
+      throws ResourceNotFoundException, AuthException, OrmException, IOException {
+    if (id.get().equals("current")) {
       PatchSet ps = psUtil.current(dbProvider.get(), change.getNotes());
       if (ps != null && visible(change, ps)) {
         return new RevisionResource(change, ps).doNotCache();
@@ -92,13 +91,11 @@
         return match.get(0);
       default:
         throw new ResourceNotFoundException(
-            "Multiple patch sets for \"" + id.get() + "\": "
-            + Joiner.on("; ").join(match));
+            "Multiple patch sets for \"" + id.get() + "\": " + Joiner.on("; ").join(match));
     }
   }
 
-  private boolean visible(ChangeResource change, PatchSet ps)
-      throws OrmException {
+  private boolean visible(ChangeResource change, PatchSet ps) throws OrmException {
     return change.getControl().isPatchVisible(ps, dbProvider.get());
   }
 
@@ -128,10 +125,13 @@
     }
   }
 
-  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change,
-      String id) throws OrmException {
-    PatchSet ps = psUtil.get(dbProvider.get(), change.getNotes(),
-        new PatchSet.Id(change.getId(), Integer.parseInt(id)));
+  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id)
+      throws OrmException {
+    PatchSet ps =
+        psUtil.get(
+            dbProvider.get(),
+            change.getNotes(),
+            new PatchSet.Id(change.getId(), Integer.parseInt(id)));
     if (ps != null) {
       return Collections.singletonList(new RevisionResource(change, ps));
     }
@@ -145,8 +145,7 @@
       PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
       ps.setRevision(edit.get().getRevision());
       if (revid == null || edit.get().getRevision().equals(revid)) {
-        return Collections.singletonList(
-            new RevisionResource(change, ps, edit));
+        return Collections.singletonList(new RevisionResource(change, ps, edit));
       }
     }
     return Collections.emptyList();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java
new file mode 100644
index 0000000..856c777
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.inject.TypeLiteral;
+
+public class RobotCommentResource implements RestResource {
+  public static final TypeLiteral<RestView<RobotCommentResource>> ROBOT_COMMENT_KIND =
+      new TypeLiteral<RestView<RobotCommentResource>>() {};
+
+  private final RevisionResource rev;
+  private final RobotComment comment;
+
+  public RobotCommentResource(RevisionResource rev, RobotComment c) {
+    this.rev = rev;
+    this.comment = c;
+  }
+
+  public PatchSet getPatchSet() {
+    return rev.getPatchSet();
+  }
+
+  RobotComment getComment() {
+    return comment;
+  }
+
+  String getId() {
+    return comment.key.uuid;
+  }
+
+  Account.Id getAuthorId() {
+    return comment.author.getId();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java
new file mode 100644
index 0000000..d1443af
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RobotComments implements ChildCollection<RevisionResource, RobotCommentResource> {
+  private final DynamicMap<RestView<RobotCommentResource>> views;
+  private final ListRobotComments list;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  RobotComments(
+      DynamicMap<RestView<RobotCommentResource>> views,
+      ListRobotComments list,
+      CommentsUtil commentsUtil) {
+    this.views = views;
+    this.list = list;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public DynamicMap<RestView<RobotCommentResource>> views() {
+    return views;
+  }
+
+  @Override
+  public ListRobotComments list() {
+    return list;
+  }
+
+  @Override
+  public RobotCommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String uuid = id.get();
+    ChangeNotes notes = rev.getNotes();
+
+    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(notes, rev.getPatchSet().getId())) {
+      if (uuid.equals(c.key.uuid)) {
+        return new RobotCommentResource(rev, c);
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
new file mode 100644
index 0000000..409be9d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+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.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.mail.send.SetAssigneeSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.validators.AssigneeValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SetAssigneeOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(SetAssigneeOp.class);
+
+  public interface Factory {
+    SetAssigneeOp create(String assignee);
+  }
+
+  private final AccountsCollection accounts;
+  private final ChangeMessagesUtil cmUtil;
+  private final DynamicSet<AssigneeValidationListener> validationListeners;
+  private final String assignee;
+  private final AssigneeChanged assigneeChanged;
+  private final SetAssigneeSender.Factory setAssigneeSenderFactory;
+  private final Provider<IdentifiedUser> user;
+  private final IdentifiedUser.GenericFactory userFactory;
+
+  private Change change;
+  private Account newAssignee;
+  private Account oldAssignee;
+
+  @AssistedInject
+  SetAssigneeOp(
+      AccountsCollection accounts,
+      ChangeMessagesUtil cmUtil,
+      DynamicSet<AssigneeValidationListener> validationListeners,
+      AssigneeChanged assigneeChanged,
+      SetAssigneeSender.Factory setAssigneeSenderFactory,
+      Provider<IdentifiedUser> user,
+      IdentifiedUser.GenericFactory userFactory,
+      @Assisted String assignee) {
+    this.accounts = accounts;
+    this.cmUtil = cmUtil;
+    this.validationListeners = validationListeners;
+    this.assigneeChanged = assigneeChanged;
+    this.setAssigneeSenderFactory = setAssigneeSenderFactory;
+    this.user = user;
+    this.userFactory = userFactory;
+    this.assignee = checkNotNull(assignee);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException, RestApiException {
+    change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    IdentifiedUser newAssigneeUser = accounts.parse(assignee);
+    newAssignee = newAssigneeUser.getAccount();
+    IdentifiedUser oldAssigneeUser = null;
+    if (change.getAssignee() != null) {
+      oldAssigneeUser = userFactory.create(change.getAssignee());
+      oldAssignee = oldAssigneeUser.getAccount();
+      if (newAssignee.equals(oldAssignee)) {
+        return false;
+      }
+    }
+    if (!newAssignee.isActive()) {
+      throw new UnprocessableEntityException(
+          String.format("Account of %s is not active", assignee));
+    }
+    if (!ctx.getControl().forUser(newAssigneeUser).isRefVisible()) {
+      throw new AuthException(
+          String.format("Change %s is not visible to %s.", change.getChangeId(), assignee));
+    }
+    try {
+      for (AssigneeValidationListener validator : validationListeners) {
+        validator.validateAssignee(change, newAssignee);
+      }
+    } catch (ValidationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+    // notedb
+    update.setAssignee(newAssignee.getId());
+    // reviewdb
+    change.setAssignee(newAssignee.getId());
+    addMessage(ctx, update, oldAssigneeUser, newAssigneeUser);
+    return true;
+  }
+
+  private void addMessage(
+      ChangeContext ctx,
+      ChangeUpdate update,
+      IdentifiedUser previousAssignee,
+      IdentifiedUser newAssignee)
+      throws OrmException {
+    StringBuilder msg = new StringBuilder();
+    msg.append("Assignee ");
+    if (previousAssignee == null) {
+      msg.append("added: ");
+      msg.append(newAssignee.getNameEmail());
+    } else {
+      msg.append("changed from: ");
+      msg.append(previousAssignee.getNameEmail());
+      msg.append(" to: ");
+      msg.append(newAssignee.getNameEmail());
+    }
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws OrmException {
+    try {
+      SetAssigneeSender cm =
+          setAssigneeSenderFactory.create(change.getProject(), change.getId(), newAssignee.getId());
+      cm.setFrom(user.get().getAccountId());
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot send email to new assignee of change " + change.getId(), err);
+    }
+    assigneeChanged.fire(change, ctx.getAccount(), oldAssignee, ctx.getWhen());
+  }
+
+  public Account.Id getNewAssignee() {
+    return newAssignee != null ? newAssignee.getId() : null;
+  }
+}
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 50f6e74..0e78c18 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
@@ -25,29 +25,28 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.extensions.events.HashtagsEdited;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
 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.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
 
-public class SetHashtagsOp extends BatchUpdate.Op {
+public class SetHashtagsOp implements BatchUpdateOp {
   public interface Factory {
     SetHashtagsOp create(HashtagsInput input);
   }
@@ -86,12 +85,12 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws AuthException, BadRequestException, OrmException, IOException {
+      throws AuthException, BadRequestException, MethodNotAllowedException, OrmException,
+          IOException {
     if (!notesMigration.readChanges()) {
-      throw new BadRequestException("Cannot add hashtags; NoteDb is disabled");
+      throw new MethodNotAllowedException("Cannot add hashtags; NoteDb is disabled");
     }
-    if (input == null
-        || (input.add == null && input.remove == null)) {
+    if (input == null || (input.add == null && input.remove == null)) {
       updatedHashtags = ImmutableSortedSet.of();
       return false;
     }
@@ -129,23 +128,16 @@
     return true;
   }
 
-  private void addMessage(Context ctx, ChangeUpdate update)
-      throws OrmException {
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
     StringBuilder msg = new StringBuilder();
     appendHashtagMessage(msg, "added", toAdd);
     appendHashtagMessage(msg, "removed", toRemove);
-    ChangeMessage cmsg = new ChangeMessage(
-        new ChangeMessage.Key(
-            change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(),
-        change.currentPatchSetId());
-    cmsg.setMessage(msg.toString());
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_HASHTAGS);
     cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
   }
 
-  private void appendHashtagMessage(StringBuilder b, String action,
-      Set<String> hashtags) {
+  private void appendHashtagMessage(StringBuilder b, String action, Set<String> hashtags) {
     if (isNullOrEmpty(hashtags)) {
       return;
     }
@@ -166,14 +158,13 @@
   @Override
   public void postUpdate(Context ctx) throws OrmException {
     if (updated() && fireEvent) {
-      hashtagsEdited.fire(change, ctx.getAccount(), updatedHashtags,
-          toAdd, toRemove, ctx.getWhen());
+      hashtagsEdited.fire(
+          change, ctx.getAccount(), updatedHashtags, toAdd, toRemove, ctx.getWhen());
     }
   }
 
   public ImmutableSortedSet<String> getUpdatedHashtags() {
-    checkState(updatedHashtags != null,
-        "getUpdatedHashtags() only valid after executing op");
+    checkState(updatedHashtags != null, "getUpdatedHashtags() only valid after executing op");
     return updatedHashtags;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
index 4750197..807ca5f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
@@ -14,16 +14,14 @@
 
 package com.google.gerrit.server.change;
 
+import static java.util.stream.Collectors.joining;
+
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -62,7 +60,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -72,38 +76,26 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 @Singleton
-public class Submit implements RestModifyView<RevisionResource, SubmitInput>,
-    UiAction<RevisionResource> {
+public class Submit
+    implements RestModifyView<RevisionResource, SubmitInput>, UiAction<RevisionResource> {
   private static final Logger log = LoggerFactory.getLogger(Submit.class);
 
-  private static final String DEFAULT_TOOLTIP =
-      "Submit patch set ${patchSet} into ${branch}";
+  private static final String DEFAULT_TOOLTIP = "Submit patch set ${patchSet} into ${branch}";
   private static final String DEFAULT_TOOLTIP_ANCESTORS =
-      "Submit patch set ${patchSet} and ancestors (${submitSize} changes " +
-      "altogether) into ${branch}";
+      "Submit patch set ${patchSet} and ancestors (${submitSize} changes "
+          + "altogether) into ${branch}";
   private static final String DEFAULT_TOPIC_TOOLTIP =
-      "Submit all ${topicSize} changes of the same topic " +
-      "(${submitSize} changes including ancestors and other " +
-      "changes related by topic)";
+      "Submit all ${topicSize} changes of the same topic "
+          + "(${submitSize} changes including ancestors and other "
+          + "changes related by topic)";
   private static final String BLOCKED_SUBMIT_TOOLTIP =
       "This change depends on other changes which are not ready";
   private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
       "This change depends on other hidden changes which are not ready";
-  private static final String CLICK_FAILURE_TOOLTIP =
-      "Clicking the button would fail";
-  private static final String CHANGE_UNMERGEABLE =
-      "Problems with integrating this change";
-  private static final String CHANGES_NOT_MERGEABLE =
-      "Problems with change(s): ";
+  private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
+  private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
+  private static final String CHANGES_NOT_MERGEABLE = "Problems with change(s): ";
 
   public static class Output {
     transient Change change;
@@ -114,8 +106,8 @@
   }
 
   /**
-   * Subclass of {@link SubmitInput} with special bits that may be flipped for
-   * testing purposes only.
+   * Subclass of {@link SubmitInput} with special bits that may be flipped for testing purposes
+   * only.
    */
   @VisibleForTesting
   public static class TestSubmitInput extends SubmitInput {
@@ -134,7 +126,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
-  private final MergeSuperSet mergeSuperSet;
+  private final Provider<MergeSuperSet> mergeSuperSet;
   private final AccountsCollection accounts;
   private final ChangesCollection changes;
   private final String label;
@@ -148,13 +140,14 @@
   private final PatchSetUtil psUtil;
 
   @Inject
-  Submit(Provider<ReviewDb> dbProvider,
+  Submit(
+      Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
       ChangeData.Factory changeDataFactory,
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
-      MergeSuperSet mergeSuperSet,
+      Provider<MergeSuperSet> mergeSuperSet,
       AccountsCollection accounts,
       ChangesCollection changes,
       @GerritServerConfig Config cfg,
@@ -169,35 +162,38 @@
     this.mergeSuperSet = mergeSuperSet;
     this.accounts = accounts;
     this.changes = changes;
-    this.label = MoreObjects.firstNonNull(
-        Strings.emptyToNull(cfg.getString("change", null, "submitLabel")),
-        "Submit");
-    this.labelWithParents = MoreObjects.firstNonNull(
-        Strings.emptyToNull(
-            cfg.getString("change", null, "submitLabelWithParents")),
-        "Submit including parents");
-    this.titlePattern = new ParameterizedString(MoreObjects.firstNonNull(
-        cfg.getString("change", null, "submitTooltip"),
-        DEFAULT_TOOLTIP));
-    this.titlePatternWithAncestors = new ParameterizedString(
+    this.label =
         MoreObjects.firstNonNull(
-            cfg.getString("change", null, "submitTooltipAncestors"),
-            DEFAULT_TOOLTIP_ANCESTORS));
+            Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
+    this.labelWithParents =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(cfg.getString("change", null, "submitLabelWithParents")),
+            "Submit including parents");
+    this.titlePattern =
+        new ParameterizedString(
+            MoreObjects.firstNonNull(
+                cfg.getString("change", null, "submitTooltip"), DEFAULT_TOOLTIP));
+    this.titlePatternWithAncestors =
+        new ParameterizedString(
+            MoreObjects.firstNonNull(
+                cfg.getString("change", null, "submitTooltipAncestors"),
+                DEFAULT_TOOLTIP_ANCESTORS));
     submitWholeTopic = wholeTopicEnabled(cfg);
-    this.submitTopicLabel = MoreObjects.firstNonNull(
-        Strings.emptyToNull(cfg.getString("change", null, "submitTopicLabel")),
-        "Submit whole topic");
-    this.submitTopicTooltip = new ParameterizedString(MoreObjects.firstNonNull(
-        cfg.getString("change", null, "submitTopicTooltip"),
-        DEFAULT_TOPIC_TOOLTIP));
+    this.submitTopicLabel =
+        MoreObjects.firstNonNull(
+            Strings.emptyToNull(cfg.getString("change", null, "submitTopicLabel")),
+            "Submit whole topic");
+    this.submitTopicTooltip =
+        new ParameterizedString(
+            MoreObjects.firstNonNull(
+                cfg.getString("change", null, "submitTopicTooltip"), DEFAULT_TOPIC_TOOLTIP));
     this.queryProvider = queryProvider;
     this.psUtil = psUtil;
   }
 
   @Override
   public Output apply(RevisionResource rsrc, SubmitInput input)
-      throws RestApiException, RepositoryNotFoundException, IOException,
-      OrmException {
+      throws RestApiException, RepositoryNotFoundException, IOException, OrmException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
     if (input.onBehalfOf != null) {
       rsrc = onBehalfOf(rsrc, input);
@@ -210,22 +206,21 @@
     } else if (!change.getStatus().isOpen()) {
       throw new ResourceConflictException("change is " + status(change));
     } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
-      throw new ResourceConflictException(String.format(
-          "destination branch \"%s\" not found.",
-          change.getDest().get()));
+      throw new ResourceConflictException(
+          String.format("destination branch \"%s\" not found.", change.getDest().get()));
     } else if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) {
       // TODO Allow submitting non-current revision by changing the current.
-      throw new ResourceConflictException(String.format(
-          "revision %s is not current revision",
-          rsrc.getPatchSet().getRevision().get()));
+      throw new ResourceConflictException(
+          String.format(
+              "revision %s is not current revision", rsrc.getPatchSet().getRevision().get()));
     }
 
     try (MergeOp op = mergeOpProvider.get()) {
       ReviewDb db = dbProvider.get();
-      op.merge(db, change, caller, true, input);
+      op.merge(db, change, caller, true, input, false);
       try {
-        change = changeNotesFactory
-            .createChecked(db, change.getProject(), change.getId()).getChange();
+        change =
+            changeNotesFactory.createChecked(db, change.getProject(), change.getId()).getChange();
       } catch (NoSuchChangeException e) {
         throw new ResourceConflictException("change is deleted");
       }
@@ -239,7 +234,7 @@
         if (msg != null) {
           throw new ResourceConflictException(msg.getMessage());
         }
-        //$FALL-THROUGH$
+        // $FALL-THROUGH$
       case ABANDONED:
       case DRAFT:
       default:
@@ -253,8 +248,7 @@
    * @param user the user who is checking to submit
    * @return a reason why any of the changes is not submittable or null
    */
-  private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs,
-      CurrentUser user) {
+  private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
     try {
       @SuppressWarnings("resource")
       ReviewDb db = dbProvider.get();
@@ -282,14 +276,8 @@
             return CHANGE_UNMERGEABLE;
           }
         }
-        return CHANGES_NOT_MERGEABLE + Joiner.on(", ").join(
-            Iterables.transform(unmergeable,
-                new Function<ChangeData, String>() {
-              @Override
-              public String apply(ChangeData cd) {
-                return String.valueOf(cd.getId().get());
-              }
-            }));
+        return CHANGES_NOT_MERGEABLE
+            + unmergeable.stream().map(c -> c.getId().toString()).collect(joining(", "));
       }
     } catch (ResourceConflictException e) {
       return BLOCKED_SUBMIT_TOOLTIP;
@@ -300,30 +288,15 @@
     return null;
   }
 
-  /**
-   * Check if there are any problems with the given change. It doesn't take
-   * any problems of related changes into account.
-   * <p>
-   * @param cd the change to check for submittability
-   * @return if the change has any problems for submission
-   */
-  public static boolean submittable(ChangeData cd) {
-    try {
-      MergeOp.checkSubmitRule(cd);
-      return true;
-    } catch (ResourceConflictException | OrmException e) {
-      return false;
-    }
-  }
-
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
     PatchSet.Id current = resource.getChange().currentPatchSetId();
     String topic = resource.getChange().getTopic();
-    boolean visible = !resource.getPatchSet().isDraft()
-        && resource.getChange().getStatus().isOpen()
-        && resource.getPatchSet().getId().equals(current)
-        && resource.getControl().canSubmit();
+    boolean visible =
+        !resource.getPatchSet().isDraft()
+            && resource.getChange().getStatus().isOpen()
+            && resource.getPatchSet().getId().equals(current)
+            && resource.getControl().canSubmit();
     ReviewDb db = dbProvider.get();
     ChangeData cd = changeDataFactory.create(db, resource.getControl());
 
@@ -337,31 +310,24 @@
     }
 
     if (!visible) {
-      return new UiAction.Description()
-        .setLabel("")
-        .setTitle("")
-        .setVisible(false);
+      return new UiAction.Description().setLabel("").setTitle("").setVisible(false);
     }
 
     ChangeSet cs;
     try {
-      cs = mergeSuperSet.completeChangeSet(
-          db, cd.change(), resource.getControl().getUser());
+      cs = mergeSuperSet.get().completeChangeSet(db, cd.change(), resource.getControl().getUser());
     } catch (OrmException | IOException e) {
-      throw new OrmRuntimeException("Could not determine complete set of " +
-          "changes to be submitted", e);
+      throw new OrmRuntimeException(
+          "Could not determine complete set of changes to be submitted", e);
     }
 
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
       topicSize = getChangesByTopic(topic).size();
     }
-    boolean treatWithTopic = submitWholeTopic
-        && !Strings.isNullOrEmpty(topic)
-        && topicSize > 1;
+    boolean treatWithTopic = submitWholeTopic && !Strings.isNullOrEmpty(topic) && topicSize > 1;
 
-    String submitProblems =
-        problemsForSubmittingChangeset(cd, cs, resource.getUser());
+    String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
 
     Boolean enabled;
     try {
@@ -380,55 +346,46 @@
 
     if (submitProblems != null) {
       return new UiAction.Description()
-        .setLabel(treatWithTopic
-            ? submitTopicLabel : (cs.size() > 1)
-                ? labelWithParents : label)
-        .setTitle(submitProblems)
-        .setVisible(true)
-        .setEnabled(false);
+          .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
+          .setTitle(submitProblems)
+          .setVisible(true)
+          .setEnabled(false);
     }
 
     if (treatWithTopic) {
-      Map<String, String> params = ImmutableMap.of(
-          "topicSize", String.valueOf(topicSize),
-          "submitSize", String.valueOf(cs.size()));
+      Map<String, String> params =
+          ImmutableMap.of(
+              "topicSize", String.valueOf(topicSize),
+              "submitSize", String.valueOf(cs.size()));
       return new UiAction.Description()
           .setLabel(submitTopicLabel)
-          .setTitle(Strings.emptyToNull(
-              submitTopicTooltip.replace(params)))
+          .setTitle(Strings.emptyToNull(submitTopicTooltip.replace(params)))
           .setVisible(true)
           .setEnabled(Boolean.TRUE.equals(enabled));
     }
     RevId revId = resource.getPatchSet().getRevision();
-    Map<String, String> params = ImmutableMap.of(
-        "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
-        "branch", resource.getChange().getDest().getShortName(),
-        "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
-        "submitSize", String.valueOf(cs.size()));
-    ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors :
-        titlePattern;
+    Map<String, String> params =
+        ImmutableMap.of(
+            "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
+            "branch", resource.getChange().getDest().getShortName(),
+            "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
+            "submitSize", String.valueOf(cs.size()));
+    ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
     return new UiAction.Description()
-      .setLabel(cs.size() > 1 ? labelWithParents : label)
-      .setTitle(Strings.emptyToNull(tp.replace(params)))
-      .setVisible(true)
-      .setEnabled(Boolean.TRUE.equals(enabled));
+        .setLabel(cs.size() > 1 ? labelWithParents : label)
+        .setTitle(Strings.emptyToNull(tp.replace(params)))
+        .setVisible(true)
+        .setEnabled(Boolean.TRUE.equals(enabled));
   }
 
   /**
-   * If the merge was attempted and it failed the system usually writes a
-   * comment as a ChangeMessage and sets status to NEW. Find the relevant
-   * message and return it.
+   * If the merge was attempted and it failed the system usually writes a comment as a ChangeMessage
+   * and sets status to NEW. Find the relevant message and return it.
    */
-  public ChangeMessage getConflictMessage(RevisionResource rsrc)
-      throws OrmException {
-    return FluentIterable.from(cmUtil.byPatchSet(dbProvider.get(), rsrc.getNotes(),
-        rsrc.getPatchSet().getId()))
-        .filter(new Predicate<ChangeMessage>() {
-          @Override
-          public boolean apply(ChangeMessage input) {
-            return input.getAuthor() == null;
-          }
-        })
+  public ChangeMessage getConflictMessage(RevisionResource rsrc) throws OrmException {
+    return FluentIterable.from(
+            cmUtil.byPatchSet(dbProvider.get(), rsrc.getNotes(), rsrc.getPatchSet().getId()))
+        .filter(cm -> cm.getAuthor() == null)
         .last()
         .orNull();
   }
@@ -437,18 +394,16 @@
     return change != null ? change.getStatus().name().toLowerCase() : "deleted";
   }
 
-  public Collection<ChangeData> unmergeableChanges(ChangeSet cs)
-      throws OrmException, IOException {
+  public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws OrmException, IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
     for (ChangeData change : cs.changes()) {
       mergeabilityMap.add(change);
     }
 
-    Multimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
+    ListMultimap<Branch.NameKey, ChangeData> cbb = cs.changesByBranch();
     for (Branch.NameKey branch : cbb.keySet()) {
       Collection<ChangeData> targetBranch = cbb.get(branch);
-      HashMap<Change.Id, RevCommit> commits =
-          findCommits(targetBranch, branch.getParentKey());
+      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.getParentKey());
 
       Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
       for (RevCommit commit : commits.values()) {
@@ -488,14 +443,15 @@
   }
 
   private HashMap<Change.Id, RevCommit> findCommits(
-      Collection<ChangeData> changes, Project.NameKey project)
-          throws IOException, OrmException {
+      Collection<ChangeData> changes, Project.NameKey project) throws IOException, OrmException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
     try (Repository repo = repoManager.openRepository(project);
         RevWalk walk = new RevWalk(repo)) {
       for (ChangeData change : changes) {
-        RevCommit commit = walk.parseCommit(ObjectId.fromString(
-            psUtil.current(dbProvider.get(), change.notes()).getRevision().get()));
+        RevCommit commit =
+            walk.parseCommit(
+                ObjectId.fromString(
+                    psUtil.current(dbProvider.get(), change.notes()).getRevision().get()));
         commits.put(change.getId(), commit);
       }
     }
@@ -511,22 +467,19 @@
     if (!caller.canSubmitAs()) {
       throw new AuthException("submit on behalf of not permitted");
     }
-    IdentifiedUser targetUser = accounts.parseId(in.onBehalfOf);
-    if (targetUser == null) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", in.onBehalfOf));
-    }
-    ChangeControl target = caller.forUser(targetUser);
+    ChangeControl target =
+        caller.forUser(accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
     if (!target.getRefControl().isVisible()) {
-      throw new UnprocessableEntityException(String.format(
-          "on_behalf_of account %s cannot see destination ref",
-          targetUser.getAccountId()));
+      throw new UnprocessableEntityException(
+          String.format(
+              "on_behalf_of account %s cannot see destination ref",
+              target.getUser().getAccountId()));
     }
     return new RevisionResource(changes.parse(target), rsrc.getPatchSet());
   }
 
   public static boolean wholeTopicEnabled(Config config) {
-    return config.getBoolean("change", null, "submitWholeTopic" , false);
+    return config.getBoolean("change", null, "submitWholeTopic", false);
   }
 
   private List<ChangeData> getChangesByTopic(String topic) {
@@ -537,15 +490,15 @@
     }
   }
 
-  public static class CurrentRevision implements
-      RestModifyView<ChangeResource, SubmitInput> {
+  public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
     private final Provider<ReviewDb> dbProvider;
     private final Submit submit;
     private final ChangeJson.Factory json;
     private final PatchSetUtil psUtil;
 
     @Inject
-    CurrentRevision(Provider<ReviewDb> dbProvider,
+    CurrentRevision(
+        Provider<ReviewDb> dbProvider,
         Submit submit,
         ChangeJson.Factory json,
         PatchSetUtil psUtil) {
@@ -557,8 +510,7 @@
 
     @Override
     public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
-        throws RestApiException, RepositoryNotFoundException, IOException,
-        OrmException {
+        throws RestApiException, RepositoryNotFoundException, IOException, OrmException {
       PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
@@ -567,7 +519,7 @@
       }
 
       Output out = submit.apply(new RevisionResource(rsrc, ps), input);
-      return json.create(ChangeJson.NO_OPTIONS).format(out.change);
+      return json.noOptions().format(out.change);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
index c4c0e98..568b50a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -32,41 +34,58 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-@Singleton
 public class SubmittedTogether implements RestReadView<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(
-      SubmittedTogether.class);
+  private static final Logger log = LoggerFactory.getLogger(SubmittedTogether.class);
 
   private final EnumSet<SubmittedTogetherOption> options =
       EnumSet.noneOf(SubmittedTogetherOption.class);
+
+  private final EnumSet<ListChangesOption> jsonOpt =
+      EnumSet.of(
+          ListChangesOption.CURRENT_REVISION,
+          ListChangesOption.CURRENT_COMMIT,
+          ListChangesOption.SUBMITTABLE);
+
   private final ChangeJson.Factory json;
   private final Provider<ReviewDb> dbProvider;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final MergeSuperSet mergeSuperSet;
+  private final Provider<MergeSuperSet> mergeSuperSet;
   private final Provider<WalkSorter> sorter;
 
   @Option(name = "-o", usage = "Output options")
-  void addOption(SubmittedTogetherOption o) {
-    options.add(o);
+  void addOption(String option) {
+    for (ListChangesOption o : ListChangesOption.values()) {
+      if (o.name().equalsIgnoreCase(option)) {
+        jsonOpt.add(o);
+        return;
+      }
+    }
+
+    for (SubmittedTogetherOption o : SubmittedTogetherOption.values()) {
+      if (o.name().equalsIgnoreCase(option)) {
+        options.add(o);
+        return;
+      }
+    }
+
+    throw new IllegalArgumentException("option not recognized: " + option);
   }
 
   @Inject
-  SubmittedTogether(ChangeJson.Factory json,
+  SubmittedTogether(
+      ChangeJson.Factory json,
       Provider<ReviewDb> dbProvider,
       Provider<InternalChangeQuery> queryProvider,
-      MergeSuperSet mergeSuperSet,
+      Provider<MergeSuperSet> mergeSuperSet,
       Provider<WalkSorter> sorter) {
     this.json = json;
     this.dbProvider = dbProvider;
@@ -75,19 +94,28 @@
     this.sorter = sorter;
   }
 
+  public SubmittedTogether addListChangesOption(EnumSet<ListChangesOption> o) {
+    jsonOpt.addAll(o);
+    return this;
+  }
+
+  public SubmittedTogether addSubmittedTogetherOption(EnumSet<SubmittedTogetherOption> o) {
+    options.addAll(o);
+    return this;
+  }
+
   @Override
   public Object apply(ChangeResource resource)
-      throws AuthException, BadRequestException,
-      ResourceConflictException, IOException, OrmException {
-    SubmittedTogetherInfo info = apply(resource, options);
+      throws AuthException, BadRequestException, ResourceConflictException, IOException,
+          OrmException {
+    SubmittedTogetherInfo info = applyInfo(resource);
     if (options.isEmpty()) {
       return info.changes;
     }
     return info;
   }
 
-  public SubmittedTogetherInfo apply(ChangeResource resource,
-      EnumSet<SubmittedTogetherOption> options)
+  public SubmittedTogetherInfo applyInfo(ChangeResource resource)
       throws AuthException, IOException, OrmException {
     Change c = resource.getChange();
     try {
@@ -96,8 +124,9 @@
 
       if (c.getStatus().isOpen()) {
         ChangeSet cs =
-            mergeSuperSet.completeChangeSet(
-                dbProvider.get(), c, resource.getControl().getUser());
+            mergeSuperSet
+                .get()
+                .completeChangeSet(dbProvider.get(), c, resource.getControl().getUser());
         cds = cs.changes().asList();
         hidden = cs.nonVisibleChanges().size();
       } else if (c.getStatus().asChangeStatus() == ChangeStatus.MERGED) {
@@ -108,10 +137,8 @@
         hidden = 0;
       }
 
-      if (hidden != 0
-          && !options.contains(SubmittedTogetherOption.NON_VISIBLE_CHANGES)) {
-        throw new AuthException(
-            "change would be submitted with a change that you cannot see");
+      if (hidden != 0 && !options.contains(NON_VISIBLE_CHANGES)) {
+        throw new AuthException("change would be submitted with a change that you cannot see");
       }
 
       if (cds.size() <= 1 && hidden == 0) {
@@ -123,10 +150,7 @@
       }
 
       SubmittedTogetherInfo info = new SubmittedTogetherInfo();
-      info.changes = json.create(EnumSet.of(
-          ListChangesOption.CURRENT_REVISION,
-          ListChangesOption.CURRENT_COMMIT))
-        .formatChangeDatas(cds);
+      info.changes = json.create(jsonOpt).formatChangeDatas(cds);
       info.nonVisibleChanges = hidden;
       return info;
     } catch (OrmException | IOException e) {
@@ -135,8 +159,7 @@
     }
   }
 
-  private List<ChangeData> sort(List<ChangeData> cds)
-      throws OrmException, IOException {
+  private List<ChangeData> sort(List<ChangeData> cds) throws OrmException, IOException {
     List<ChangeData> sorted = new ArrayList<>(cds.size());
     for (PatchSetData psd : sorter.get().sort(cds)) {
       sorted.add(psd.data());
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 02d3afe..fd14adf 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
@@ -15,10 +15,12 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.ReviewersUtil;
@@ -28,28 +30,46 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
 
 public class SuggestChangeReviewers extends SuggestReviewers
     implements RestReadView<ChangeResource> {
+
+  @Option(
+      name = "--exclude-groups",
+      aliases = {"-e"},
+      usage = "exclude groups from query")
+  boolean excludeGroups;
+
+  private final Provider<CurrentUser> self;
+
   @Inject
-  SuggestChangeReviewers(AccountVisibility av,
+  SuggestChangeReviewers(
+      AccountVisibility av,
       GenericFactory identifiedUserFactory,
       Provider<ReviewDb> dbProvider,
+      Provider<CurrentUser> self,
       @GerritServerConfig Config cfg,
       ReviewersUtil reviewersUtil) {
     super(av, identifiedUserFactory, dbProvider, cfg, reviewersUtil);
+    this.self = self;
   }
 
   @Override
   public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
-      throws BadRequestException, OrmException, IOException {
-    return reviewersUtil.suggestReviewers(this,
-        rsrc.getControl().getProjectControl(), getVisibility(rsrc));
+      throws AuthException, BadRequestException, OrmException, IOException {
+    if (!self.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    return reviewersUtil.suggestReviewers(
+        rsrc.getNotes(),
+        this,
+        rsrc.getControl().getProjectControl(),
+        getVisibility(rsrc),
+        excludeGroups);
   }
 
   private VisibilityControl getVisibility(final ChangeResource rsrc) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
index f159c69..e1d8f17 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Option;
 
@@ -33,22 +32,25 @@
   protected final ReviewersUtil reviewersUtil;
 
   private final boolean suggestAccounts;
-  private final int suggestFrom;
   private final int maxAllowed;
   private final int maxAllowedWithoutConfirmation;
   protected int limit;
   protected String query;
   protected final int maxSuggestedReviewers;
 
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT",
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
       usage = "maximum number of reviewers to list")
   public void setLimit(int l) {
-    this.limit =
-        l <= 0 ? maxSuggestedReviewers : Math.min(l,
-            maxSuggestedReviewers);
+    this.limit = l <= 0 ? maxSuggestedReviewers : Math.min(l, maxSuggestedReviewers);
   }
 
-  @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY",
+  @Option(
+      name = "--query",
+      aliases = {"-q"},
+      metaVar = "QUERY",
       usage = "match reviewers query")
   public void setQuery(String q) {
     this.query = q;
@@ -62,10 +64,6 @@
     return suggestAccounts;
   }
 
-  public int getSuggestFrom() {
-    return suggestFrom;
-  }
-
   public int getLimit() {
     return limit;
   }
@@ -79,7 +77,8 @@
   }
 
   @Inject
-  public SuggestReviewers(AccountVisibility av,
+  public SuggestReviewers(
+      AccountVisibility av,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       Provider<ReviewDb> dbProvider,
       @GerritServerConfig Config cfg,
@@ -91,18 +90,17 @@
         cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
     this.limit = this.maxSuggestedReviewers;
     String suggest = cfg.getString("suggest", null, "accounts");
-    if ("OFF".equalsIgnoreCase(suggest)
-        || "false".equalsIgnoreCase(suggest)) {
+    if ("OFF".equalsIgnoreCase(suggest) || "false".equalsIgnoreCase(suggest)) {
       this.suggestAccounts = false;
     } else {
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
-    this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
-    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed",
-        PostReviewers.DEFAULT_MAX_REVIEWERS);
-    this.maxAllowedWithoutConfirmation = cfg.getInt(
-        "addreviewer", "maxWithoutConfirmation",
-        PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", PostReviewers.DEFAULT_MAX_REVIEWERS);
+    this.maxAllowedWithoutConfirmation =
+        cfg.getInt(
+            "addreviewer",
+            "maxWithoutConfirmation",
+            PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java
new file mode 100644
index 0000000..353bf3b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.reviewdb.client.Account;
+
+public class SuggestedReviewer {
+
+  public Account.Id account;
+  public double score;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
index 45d9669..524f4d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
@@ -30,15 +30,12 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import org.kohsuke.args4j.Option;
 
-public class TestSubmitRule
-    implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
+public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
@@ -48,7 +45,8 @@
   private Filters filters = Filters.RUN;
 
   @Inject
-  TestSubmitRule(Provider<ReviewDb> db,
+  TestSubmitRule(
+      Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       RulesCache rules,
       AccountLoader.Factory infoFactory) {
@@ -68,14 +66,16 @@
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
-    SubmitRuleEvaluator evaluator = new SubmitRuleEvaluator(
-        changeDataFactory.create(db.get(), rsrc.getControl()));
+    SubmitRuleEvaluator evaluator =
+        new SubmitRuleEvaluator(changeDataFactory.create(db.get(), rsrc.getControl()));
 
-    List<SubmitRecord> records = evaluator.setPatchSet(rsrc.getPatchSet())
-          .setLogErrors(false)
-          .setSkipSubmitFilters(input.filters == Filters.SKIP)
-          .setRule(input.rule)
-          .evaluate();
+    List<SubmitRecord> records =
+        evaluator
+            .setPatchSet(rsrc.getPatchSet())
+            .setLogErrors(false)
+            .setSkipSubmitFilters(input.filters == Filters.SKIP)
+            .setRule(input.rule)
+            .evaluate();
     List<Record> out = Lists.newArrayListWithCapacity(records.size());
     AccountLoader accounts = accountInfoFactory.create(true);
     for (SubmitRecord r : records) {
@@ -104,9 +104,7 @@
 
       if (r.labels != null) {
         for (SubmitRecord.Label n : r.labels) {
-          AccountInfo who = n.appliedBy != null
-              ? accounts.get(n.appliedBy)
-              : new AccountInfo(null);
+          AccountInfo who = n.appliedBy != null ? accounts.get(n.appliedBy) : new AccountInfo(null);
           label(n, who);
         }
       }
@@ -148,6 +146,5 @@
     }
   }
 
-  static class None {
-  }
+  static class None {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
index 4855012..b19f1d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
@@ -30,11 +30,9 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.kohsuke.args4j.Option;
 
-public class TestSubmitType
-    implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
+public class TestSubmitType implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
@@ -43,9 +41,7 @@
   private Filters filters = Filters.RUN;
 
   @Inject
-  TestSubmitType(Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      RulesCache rules) {
+  TestSubmitType(Provider<ReviewDb> db, ChangeData.Factory changeDataFactory, RulesCache rules) {
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.rules = rules;
@@ -61,18 +57,19 @@
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
-    SubmitRuleEvaluator evaluator = new SubmitRuleEvaluator(
-          changeDataFactory.create(db.get(), rsrc.getControl()));
+    SubmitRuleEvaluator evaluator =
+        new SubmitRuleEvaluator(changeDataFactory.create(db.get(), rsrc.getControl()));
 
-    SubmitTypeRecord rec = evaluator.setPatchSet(rsrc.getPatchSet())
-        .setLogErrors(false)
-        .setSkipSubmitFilters(input.filters == Filters.SKIP)
-        .setRule(input.rule)
-        .getSubmitType();
+    SubmitTypeRecord rec =
+        evaluator
+            .setPatchSet(rsrc.getPatchSet())
+            .setLogErrors(false)
+            .setSkipSubmitFilters(input.filters == Filters.SKIP)
+            .setRule(input.rule)
+            .getSubmitType();
     if (rec.status != SubmitTypeRecord.Status.OK) {
-      throw new BadRequestException(String.format(
-          "rule %s produced invalid result: %s",
-          evaluator.getSubmitRuleName(), rec));
+      throw new BadRequestException(
+          String.format("rule %s produced invalid result: %s", evaluator.getSubmitRuleName(), rec));
     }
 
     return rec.type;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
index 3bba37e..b2ca405 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -28,7 +29,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.Map;
 import java.util.TreeMap;
 
@@ -38,8 +38,7 @@
   private final List list;
 
   @Inject
-  Votes(DynamicMap<RestView<VoteResource>> views,
-      List list) {
+  Votes(DynamicMap<RestView<VoteResource>> views, List list) {
     this.views = views;
     this.list = list;
   }
@@ -56,7 +55,10 @@
 
   @Override
   public VoteResource parse(ReviewerResource reviewer, IdString id)
-      throws ResourceNotFoundException, OrmException, AuthException {
+      throws ResourceNotFoundException, OrmException, AuthException, MethodNotAllowedException {
+    if (reviewer.getRevisionResource() != null && !reviewer.getRevisionResource().isCurrent()) {
+      throw new MethodNotAllowedException("Cannot access on non-current patch set");
+    }
     return new VoteResource(reviewer, id.get());
   }
 
@@ -66,20 +68,25 @@
     private final ApprovalsUtil approvalsUtil;
 
     @Inject
-    List(Provider<ReviewDb> db,
-        ApprovalsUtil approvalsUtil) {
+    List(Provider<ReviewDb> db, ApprovalsUtil approvalsUtil) {
       this.db = db;
       this.approvalsUtil = approvalsUtil;
     }
 
     @Override
-    public Map<String, Short> apply(ReviewerResource rsrc) throws OrmException {
+    public Map<String, Short> apply(ReviewerResource rsrc)
+        throws OrmException, MethodNotAllowedException {
+      if (rsrc.getRevisionResource() != null && !rsrc.getRevisionResource().isCurrent()) {
+        throw new MethodNotAllowedException("Cannot list votes on non-current patch set");
+      }
+
       Map<String, Short> votes = new TreeMap<>();
-      Iterable<PatchSetApproval> byPatchSetUser = approvalsUtil.byPatchSetUser(
-          db.get(),
-          rsrc.getControl(),
-          rsrc.getChange().currentPatchSetId(),
-          rsrc.getReviewerUser().getAccountId());
+      Iterable<PatchSetApproval> byPatchSetUser =
+          approvalsUtil.byPatchSetUser(
+              db.get(),
+              rsrc.getControl(),
+              rsrc.getChange().currentPatchSetId(),
+              rsrc.getReviewerUser().getAccountId());
       for (PatchSetApproval psa : byPatchSetUser) {
         votes.put(psa.getLabel(), psa.getValue());
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
index d31805d..56d7ec0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/WalkSorter.java
@@ -18,11 +18,10 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -30,17 +29,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -51,32 +39,37 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Helper to sort {@link ChangeData}s based on {@link RevWalk} ordering.
- * <p>
- * Split changes by project, and map each change to a single commit based on the
- * latest patch set. The set of patch sets considered may be limited by calling
- * {@link #includePatchSets(Iterable)}. Perform a standard {@link RevWalk} on
- * each project repository, do an approximate topo sort, and record the order in
- * which each change's commit is seen.
- * <p>
- * Once an order within each project is determined, groups of changes are sorted
- * based on the project name. This is slightly more stable than sorting on
- * something like the commit or change timestamp, as it will not unexpectedly
- * reorder large groups of changes on subsequent calls if one of the changes was
- * updated.
+ *
+ * <p>Split changes by project, and map each change to a single commit based on the latest patch
+ * set. The set of patch sets considered may be limited by calling {@link
+ * #includePatchSets(Iterable)}. Perform a standard {@link RevWalk} on each project repository, do
+ * an approximate topo sort, and record the order in which each change's commit is seen.
+ *
+ * <p>Once an order within each project is determined, groups of changes are sorted based on the
+ * project name. This is slightly more stable than sorting on something like the commit or change
+ * timestamp, as it will not unexpectedly reorder large groups of changes on subsequent calls if one
+ * of the changes was updated.
  */
 class WalkSorter {
-  private static final Logger log =
-      LoggerFactory.getLogger(WalkSorter.class);
+  private static final Logger log = LoggerFactory.getLogger(WalkSorter.class);
 
   private static final Ordering<List<PatchSetData>> PROJECT_LIST_SORTER =
-      Ordering.natural().nullsFirst()
+      Ordering.natural()
+          .nullsFirst()
           .onResultOf(
-            new Function<List<PatchSetData>, Project.NameKey>() {
-              @Override
-              public Project.NameKey apply(List<PatchSetData> in) {
+              (List<PatchSetData> in) -> {
                 if (in == null || in.isEmpty()) {
                   return null;
                 }
@@ -85,8 +78,7 @@
                 } catch (OrmException e) {
                   throw new IllegalStateException(e);
                 }
-              }
-            });
+              });
 
   private final GitRepositoryManager repoManager;
   private final Set<PatchSet.Id> includePatchSets;
@@ -108,30 +100,27 @@
     return this;
   }
 
-  public Iterable<PatchSetData> sort(Iterable<ChangeData> in)
-      throws OrmException, IOException {
-    Multimap<Project.NameKey, ChangeData> byProject =
-        ArrayListMultimap.create();
+  public Iterable<PatchSetData> sort(Iterable<ChangeData> in) throws OrmException, IOException {
+    ListMultimap<Project.NameKey, ChangeData> byProject =
+        MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeData cd : in) {
       byProject.put(cd.change().getProject(), cd);
     }
 
-    List<List<PatchSetData>> sortedByProject =
-        new ArrayList<>(byProject.keySet().size());
-    for (Map.Entry<Project.NameKey, Collection<ChangeData>> e
-        : byProject.asMap().entrySet()) {
+    List<List<PatchSetData>> sortedByProject = new ArrayList<>(byProject.keySet().size());
+    for (Map.Entry<Project.NameKey, Collection<ChangeData>> e : byProject.asMap().entrySet()) {
       sortedByProject.add(sortProject(e.getKey(), e.getValue()));
     }
     Collections.sort(sortedByProject, PROJECT_LIST_SORTER);
     return Iterables.concat(sortedByProject);
   }
 
-  private List<PatchSetData> sortProject(Project.NameKey project,
-      Collection<ChangeData> in) throws OrmException, IOException {
+  private List<PatchSetData> sortProject(Project.NameKey project, Collection<ChangeData> in)
+      throws OrmException, IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(retainBody);
-      Multimap<RevCommit, PatchSetData> byCommit = byCommit(rw, in);
+      ListMultimap<RevCommit, PatchSetData> byCommit = byCommit(rw, in);
       if (byCommit.isEmpty()) {
         return ImmutableList.of();
       } else if (byCommit.size() == 1) {
@@ -156,8 +145,9 @@
       // the input size is small enough that this is not an issue.)
 
       Set<RevCommit> commits = byCommit.keySet();
-      Multimap<RevCommit, RevCommit> children = collectChildren(commits);
-      Multimap<RevCommit, RevCommit> pending = ArrayListMultimap.create();
+      ListMultimap<RevCommit, RevCommit> children = collectChildren(commits);
+      ListMultimap<RevCommit, RevCommit> pending =
+          MultimapBuilder.hashKeys().arrayListValues().build();
       Deque<RevCommit> todo = new ArrayDeque<>();
 
       RevFlag done = rw.newFlag("done");
@@ -176,8 +166,7 @@
         while (!todo.isEmpty()) {
           // Sanity check: we can't pop more than N pending commits, otherwise
           // we have an infinite loop due to programmer error or something.
-          checkState(++i <= commits.size(),
-              "Too many pending steps while sorting %s", commits);
+          checkState(++i <= commits.size(), "Too many pending steps while sorting %s", commits);
           RevCommit t = todo.removeFirst();
           if (t.has(done)) {
             continue;
@@ -199,9 +188,9 @@
     }
   }
 
-  private static Multimap<RevCommit, RevCommit> collectChildren(
-      Set<RevCommit> commits) {
-    Multimap<RevCommit, RevCommit> children = ArrayListMultimap.create();
+  private static ListMultimap<RevCommit, RevCommit> collectChildren(Set<RevCommit> commits) {
+    ListMultimap<RevCommit, RevCommit> children =
+        MultimapBuilder.hashKeys().arrayListValues().build();
     for (RevCommit c : commits) {
       for (RevCommit p : c.getParents()) {
         if (commits.contains(p)) {
@@ -212,8 +201,11 @@
     return children;
   }
 
-  private static int emit(RevCommit c, Multimap<RevCommit, PatchSetData> byCommit,
-      List<PatchSetData> result, RevFlag done) {
+  private static int emit(
+      RevCommit c,
+      ListMultimap<RevCommit, PatchSetData> byCommit,
+      List<PatchSetData> result,
+      RevFlag done) {
     if (c.has(done)) {
       return 0;
     }
@@ -226,29 +218,26 @@
     return 0;
   }
 
-  private Multimap<RevCommit, PatchSetData> byCommit(RevWalk rw,
-      Collection<ChangeData> in) throws OrmException, IOException {
-    Multimap<RevCommit, PatchSetData> byCommit =
-        ArrayListMultimap.create(in.size(), 1);
+  private ListMultimap<RevCommit, PatchSetData> byCommit(RevWalk rw, Collection<ChangeData> in)
+      throws OrmException, IOException {
+    ListMultimap<RevCommit, PatchSetData> byCommit =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(1).build();
     for (ChangeData cd : in) {
       PatchSet maxPs = null;
       for (PatchSet ps : cd.patchSets()) {
-        if (shouldInclude(ps)
-            && (maxPs == null || ps.getId().get() > maxPs.getId().get())) {
+        if (shouldInclude(ps) && (maxPs == null || ps.getId().get() > maxPs.getId().get())) {
           maxPs = ps;
         }
       }
       if (maxPs == null) {
-       continue; // No patch sets matched.
+        continue; // No patch sets matched.
       }
       ObjectId id = ObjectId.fromString(maxPs.getRevision().get());
       try {
         RevCommit c = rw.parseCommit(id);
         byCommit.put(c, PatchSetData.create(cd, maxPs, c));
       } catch (MissingObjectException | IncorrectObjectTypeException e) {
-        log.warn(
-            "missing commit " + id.name() + " for patch set " + maxPs.getId(),
-            e);
+        log.warn("missing commit " + id.name() + " for patch set " + maxPs.getId(), e);
       }
     }
     return byCommit;
@@ -258,8 +247,7 @@
     return includePatchSets.isEmpty() || includePatchSets.contains(ps.getId());
   }
 
-  private static void markStart(RevWalk rw, Iterable<RevCommit> commits)
-      throws IOException {
+  private static void markStart(RevWalk rw, Iterable<RevCommit> commits) throws IOException {
     for (RevCommit c : commits) {
       rw.markStart(c);
     }
@@ -273,7 +261,9 @@
     }
 
     abstract ChangeData data();
+
     abstract PatchSet patchSet();
+
     abstract RevCommit commit();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java
index caeb771..89090f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroups.java
@@ -17,7 +17,6 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
@@ -30,5 +29,4 @@
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface AdministrateServerGroups {
-}
+public @interface AdministrateServerGroups {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
index dd3b8329..29f288a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -34,7 +33,8 @@
   private final ImmutableSet<GroupReference> groups;
 
   @Inject
-  public AdministrateServerGroupsProvider(GroupBackend groupBackend,
+  public AdministrateServerGroupsProvider(
+      GroupBackend groupBackend,
       @GerritServerConfig Config config,
       ThreadLocalRequestContext threadContext,
       ServerRequestContext serverCtx) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java
new file mode 100644
index 0000000..83eca9c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AgreementJson.java
@@ -0,0 +1,75 @@
+// 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 com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.AgreementInfo;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupJson;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AgreementJson {
+  private static final Logger log = LoggerFactory.getLogger(AgreementJson.class);
+
+  private final Provider<CurrentUser> self;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final GroupControl.GenericFactory genericGroupControlFactory;
+  private final GroupJson groupJson;
+
+  @Inject
+  AgreementJson(
+      Provider<CurrentUser> self,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      GroupControl.GenericFactory genericGroupControlFactory,
+      GroupJson groupJson) {
+    this.self = self;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.genericGroupControlFactory = genericGroupControlFactory;
+    this.groupJson = groupJson;
+  }
+
+  public AgreementInfo format(ContributorAgreement ca) {
+    AgreementInfo info = new AgreementInfo();
+    info.name = ca.getName();
+    info.description = ca.getDescription();
+    info.url = ca.getAgreementUrl();
+    GroupReference autoVerifyGroup = ca.getAutoVerify();
+    if (autoVerifyGroup != null && self.get().isIdentifiedUser()) {
+      IdentifiedUser user = identifiedUserFactory.create(self.get().getAccountId());
+      try {
+        GroupControl gc = genericGroupControlFactory.controlFor(user, autoVerifyGroup.getUUID());
+        GroupResource group = new GroupResource(gc);
+        info.autoVerifyGroup = groupJson.format(group);
+      } catch (NoSuchGroupException | OrmException e) {
+        log.warn(
+            "autoverify group \""
+                + autoVerifyGroup.getName()
+                + "\" does not exist, referenced in CLA \""
+                + ca.getName()
+                + "\"");
+      }
+    }
+    return info;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java
index af681db..3250185 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsNameProvider.java
@@ -17,7 +17,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java
index 09f1c50..0020d5c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersNameProvider.java
@@ -17,7 +17,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardName.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardName.java
index 197d64e..3cc0a8f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardName.java
@@ -17,11 +17,9 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /** Special name for a user that hasn't set a name. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface AnonymousCowardName {
-}
+public @interface AnonymousCowardName {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
index b44affa..3f3d6fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
@@ -16,7 +16,6 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.eclipse.jgit.lib.Config;
 
 public class AnonymousCowardNameProvider implements Provider<String> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index 3511705..2382809 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -14,23 +14,25 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.XsrfException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
 
 /** Authentication related settings from {@code gerrit.config}. */
 @Singleton
@@ -46,7 +48,6 @@
   private final boolean trustContainerAuth;
   private final boolean enableRunAs;
   private final boolean userNameToLowerCase;
-  private final boolean gitBasicAuth;
   private final boolean useContributorAgreements;
   private final String loginUrl;
   private final String loginText;
@@ -66,8 +67,7 @@
   private GitBasicAuthPolicy gitBasicAuthPolicy;
 
   @Inject
-  AuthConfig(@GerritServerConfig final Config cfg)
-      throws XsrfException {
+  AuthConfig(@GerritServerConfig final Config cfg) throws XsrfException {
     authType = toType(cfg);
     httpHeader = cfg.getString("auth", null, "httpheader");
     httpDisplaynameHeader = cfg.getString("auth", null, "httpdisplaynameheader");
@@ -91,19 +91,32 @@
     cookieSecure = cfg.getBoolean("auth", "cookiesecure", false);
     trustContainerAuth = cfg.getBoolean("auth", "trustContainerAuth", false);
     enableRunAs = cfg.getBoolean("auth", null, "enableRunAs", true);
-    gitBasicAuth = cfg.getBoolean("auth", "gitBasicAuth", false);
     gitBasicAuthPolicy = getBasicAuthPolicy(cfg);
-    useContributorAgreements =
-        cfg.getBoolean("auth", "contributoragreements", false);
+    useContributorAgreements = cfg.getBoolean("auth", "contributoragreements", false);
     userNameToLowerCase = cfg.getBoolean("auth", "userNameToLowerCase", false);
     allowRegisterNewEmail = cfg.getBoolean("auth", "allowRegisterNewEmail", true);
 
+    if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP
+        && authType != AuthType.LDAP
+        && authType != AuthType.LDAP_BIND) {
+      throw new IllegalStateException(
+          "use auth.gitBasicAuthPolicy HTTP_LDAP only with auth.type LDAP or LDAP_BIND");
+    } else if (gitBasicAuthPolicy == GitBasicAuthPolicy.OAUTH && authType != AuthType.OAUTH) {
+      throw new IllegalStateException(
+          "use auth.gitBasicAuthPolicy OAUTH only with auth.type OAUTH");
+    }
+
     String key = cfg.getString("auth", null, "registerEmailPrivateKey");
     if (key != null && !key.isEmpty()) {
-      int age = (int) ConfigUtil.getTimeUnit(cfg,
-          "auth", null, "maxRegisterEmailTokenAge",
-          TimeUnit.SECONDS.convert(12, TimeUnit.HOURS),
-          TimeUnit.SECONDS);
+      int age =
+          (int)
+              ConfigUtil.getTimeUnit(
+                  cfg,
+                  "auth",
+                  null,
+                  "maxRegisterEmailTokenAge",
+                  TimeUnit.SECONDS.convert(12, TimeUnit.HOURS),
+                  TimeUnit.SECONDS);
       emailReg = new SignedToken(age, key);
     } else {
       emailReg = null;
@@ -129,7 +142,9 @@
 
   private GitBasicAuthPolicy getBasicAuthPolicy(Config cfg) {
     GitBasicAuthPolicy defaultAuthPolicy =
-        isLdapAuthType() ? GitBasicAuthPolicy.LDAP : GitBasicAuthPolicy.HTTP;
+        isLdapAuthType()
+            ? GitBasicAuthPolicy.LDAP
+            : isOAuthType() ? GitBasicAuthPolicy.OAUTH : GitBasicAuthPolicy.HTTP;
     return cfg.getEnum("auth", null, "gitBasicAuthPolicy", defaultAuthPolicy);
   }
 
@@ -222,11 +237,6 @@
     return userNameToLowerCase;
   }
 
-  /** Whether git-over-http should use Gerrit basic authentication scheme. */
-  public boolean isGitBasicAuth() {
-    return gitBasicAuth;
-  }
-
   public GitBasicAuthPolicy getGitBasicAuthPolicy() {
     return gitBasicAuthPolicy;
   }
@@ -236,7 +246,7 @@
     return useContributorAgreements;
   }
 
-  public boolean isIdentityTrustable(final Collection<AccountExternalId> ids) {
+  public boolean isIdentityTrustable(Collection<ExternalId> ids) {
     switch (getAuthType()) {
       case DEVELOPMENT_BECOME_ANY_ACCOUNT:
       case HTTP:
@@ -257,7 +267,7 @@
       case OPENID:
         // All identities must be trusted in order to trust the account.
         //
-        for (final AccountExternalId e : ids) {
+        for (ExternalId e : ids) {
           if (!isTrusted(e)) {
             return false;
           }
@@ -271,8 +281,8 @@
     }
   }
 
-  private boolean isTrusted(final AccountExternalId id) {
-    if (id.isScheme(AccountExternalId.SCHEME_MAILTO)) {
+  private boolean isTrusted(ExternalId id) {
+    if (id.isScheme(SCHEME_MAILTO)) {
       // mailto identities are created by sending a unique validation
       // token to the address and asking them to come back to the site
       // with that token.
@@ -280,20 +290,20 @@
       return true;
     }
 
-    if (id.isScheme(AccountExternalId.SCHEME_UUID)) {
+    if (id.isScheme(SCHEME_UUID)) {
       // UUID identities are absolutely meaningless and cannot be
       // constructed through any normal login process we use.
       //
       return true;
     }
 
-    if (id.isScheme(AccountExternalId.SCHEME_USERNAME)) {
+    if (id.isScheme(SCHEME_USERNAME)) {
       // We can trust their username, its local to our server only.
       //
       return true;
     }
 
-    for (final OpenIdProviderPattern p : trustedOpenIDs) {
+    for (OpenIdProviderPattern p : trustedOpenIDs) {
       if (p.matches(id)) {
         return true;
       }
@@ -314,8 +324,11 @@
   }
 
   public boolean isLdapAuthType() {
-    return authType == AuthType.LDAP ||
-        authType == AuthType.LDAP_BIND;
+    return authType == AuthType.LDAP || authType == AuthType.LDAP_BIND;
+  }
+
+  public boolean isOAuthType() {
+    return authType == AuthType.OAUTH;
   }
 
   public boolean isAllowRegisterNewEmail() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
index 8e181a9..5b0f73d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.account.DefaultRealm;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.auth.AuthBackend;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
index 05ad33d..7b40786 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
@@ -32,12 +32,15 @@
   }
 
   public CacheResource(String pluginName, String cacheName, final Cache<?, ?> cache) {
-    this(pluginName, cacheName, new Provider<Cache<?, ?>>() {
-      @Override
-      public Cache<?, ?> get() {
-        return cache;
-      }
-    });
+    this(
+        pluginName,
+        cacheName,
+        new Provider<Cache<?, ?>>() {
+          @Override
+          public Cache<?, ?> get() {
+            return cache;
+          }
+        });
   }
 
   public String getName() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
index 7b567e1..f002f8d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
@@ -35,8 +35,8 @@
 
 @RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
 @Singleton
-public class CachesCollection implements
-    ChildCollection<ConfigResource, CacheResource>, AcceptsPost<ConfigResource> {
+public class CachesCollection
+    implements ChildCollection<ConfigResource, CacheResource>, AcceptsPost<ConfigResource> {
 
   private final DynamicMap<RestView<CacheResource>> views;
   private final Provider<ListCaches> list;
@@ -45,8 +45,10 @@
   private final PostCaches postCaches;
 
   @Inject
-  CachesCollection(DynamicMap<RestView<CacheResource>> views,
-      Provider<ListCaches> list, Provider<CurrentUser> self,
+  CachesCollection(
+      DynamicMap<RestView<CacheResource>> views,
+      Provider<ListCaches> list,
+      Provider<CurrentUser> self,
       DynamicMap<Cache<?, ?>> cacheMap,
       PostCaches postCaches) {
     this.views = views;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrl.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrl.java
index e40c6a1..c8d39b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrl.java
@@ -17,17 +17,15 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
  * Marker on a {@link String} holding the canonical address for this server.
- * <p>
- * Note that the String may be null, if the administrator has not configured the
- * value and we are not in an HTTP request where the URL can be guessed from the
- * request state. Clients must handle such cases explicitly.
+ *
+ * <p>Note that the String may be null, if the administrator has not configured the value and we are
+ * not in an HTTP request where the URL can be guessed from the request state. Clients must handle
+ * such cases explicitly.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface CanonicalWebUrl {
-}
+public @interface CanonicalWebUrl {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlModule.java
index 8d66d33..646d395 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlModule.java
@@ -29,8 +29,7 @@
     // running in an HTTP environment.
     //
     final Class<? extends Provider<String>> provider = provider();
-    bind(String.class).annotatedWith(CanonicalWebUrl.class)
-        .toProvider(provider);
+    bind(String.class).annotatedWith(CanonicalWebUrl.class).toProvider(provider);
   }
 
   protected abstract Class<? extends Provider<String>> provider();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
index 3cce4f0..e670e2c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
@@ -16,7 +16,6 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.eclipse.jgit.lib.Config;
 
 /** Provides {@link CanonicalWebUrl} from {@code gerrit.canonicalWebUrl}. */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilitiesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilitiesCollection.java
index 7ce5a88..1124048 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilitiesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CapabilitiesCollection.java
@@ -23,14 +23,12 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class CapabilitiesCollection implements
-    ChildCollection<ConfigResource, CapabilityResource> {
+public class CapabilitiesCollection implements ChildCollection<ConfigResource, CapabilityResource> {
   private final DynamicMap<RestView<CapabilityResource>> views;
   private final ListCapabilities list;
 
   @Inject
-  CapabilitiesCollection(DynamicMap<RestView<CapabilityResource>> views,
-      ListCapabilities list) {
+  CapabilitiesCollection(DynamicMap<RestView<CapabilityResource>> views, ListCapabilities list) {
     this.views = views;
     this.list = list;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ChangeCleanupConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
index b4b1865..b2b5fab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
@@ -18,10 +18,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class ChangeCleanupConfig {
@@ -31,9 +29,9 @@
   private static String KEY_ABANDON_MESSAGE = "abandonMessage";
   private static String DEFAULT_ABANDON_MESSAGE =
       "Auto-Abandoned due to inactivity, see "
-      + "${URL}Documentation/user-change-cleanup.html#auto-abandon\n"
-      + "\n"
-      + "If this change is still wanted it should be restored.";
+          + "${URL}Documentation/user-change-cleanup.html#auto-abandon\n"
+          + "\n"
+          + "If this change is still wanted it should be restored.";
 
   private final ScheduleConfig scheduleConfig;
   private final long abandonAfter;
@@ -41,19 +39,17 @@
   private final String abandonMessage;
 
   @Inject
-  ChangeCleanupConfig(@GerritServerConfig Config cfg,
-      @CanonicalWebUrl @Nullable String canonicalWebUrl) {
+  ChangeCleanupConfig(
+      @GerritServerConfig Config cfg, @CanonicalWebUrl @Nullable String canonicalWebUrl) {
     scheduleConfig = new ScheduleConfig(cfg, SECTION);
     abandonAfter = readAbandonAfter(cfg);
-    abandonIfMergeable =
-        cfg.getBoolean(SECTION, null, KEY_ABANDON_IF_MERGEABLE, true);
+    abandonIfMergeable = cfg.getBoolean(SECTION, null, KEY_ABANDON_IF_MERGEABLE, true);
     abandonMessage = readAbandonMessage(cfg, canonicalWebUrl);
   }
 
   private long readAbandonAfter(Config cfg) {
     long abandonAfter =
-        ConfigUtil.getTimeUnit(cfg, SECTION, null, KEY_ABANDON_AFTER, 0,
-            TimeUnit.MILLISECONDS);
+        ConfigUtil.getTimeUnit(cfg, SECTION, null, KEY_ABANDON_AFTER, 0, TimeUnit.MILLISECONDS);
     return abandonAfter >= 0 ? abandonAfter : 0;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigCollection.java
index 72c4f2e..f268110 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigCollection.java
@@ -24,8 +24,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class ConfigCollection implements
-    RestCollection<TopLevelResource, ConfigResource> {
+public class ConfigCollection implements RestCollection<TopLevelResource, ConfigResource> {
   private final DynamicMap<RestView<ConfigResource>> views;
 
   @Inject
@@ -44,9 +43,8 @@
   }
 
   @Override
-  public ConfigResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException {
-    if (id.equals("server")) {
+  public ConfigResource parse(TopLevelResource root, IdString id) throws ResourceNotFoundException {
+    if (id.get().equals("server")) {
       return new ConfigResource();
     }
     throw new ResourceNotFoundException(id);
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 eaeb850..0da1d3b 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
@@ -15,10 +15,6 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.base.Preconditions;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
@@ -29,6 +25,8 @@
 import java.util.concurrent.TimeUnit;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 
 public class ConfigUtil {
 
@@ -36,8 +34,10 @@
   private static <T> T[] allValuesOf(final T defaultValue) {
     try {
       return (T[]) defaultValue.getClass().getMethod("values").invoke(null);
-    } catch (IllegalArgumentException | NoSuchMethodException
-        | InvocationTargetException | IllegalAccessException
+    } catch (IllegalArgumentException
+        | NoSuchMethodException
+        | InvocationTargetException
+        | IllegalAccessException
         | SecurityException e) {
       throw new IllegalArgumentException("Cannot obtain enumeration values", e);
     }
@@ -51,12 +51,15 @@
    * @param subsection subsection the key is in, or null if not in a subsection.
    * @param setting name of the setting to read.
    * @param valueString string value from git Config
-   * @param all all possible values in the enumeration which should be
-   *        recognized. This should be {@code EnumType.values()}.
+   * @param all all possible values in the enumeration which should be recognized. This should be
+   *     {@code EnumType.values()}.
    * @return the selected enumeration value, or {@code defaultValue}.
    */
-  private static <T extends Enum<?>> T getEnum(final String section,
-      final String subsection, final String setting, String valueString,
+  private static <T extends Enum<?>> T getEnum(
+      final String section,
+      final String subsection,
+      final String setting,
+      String valueString,
       final T[] all) {
 
     String n = valueString.replace(' ', '_').replace('-', '_');
@@ -94,12 +97,15 @@
    * @param section section the key is in.
    * @param subsection subsection the key is in, or null if not in a subsection.
    * @param setting name of the setting to read.
-   * @param defaultValue default value to return if the setting was not set.
-   *        Must not be null as the enumeration values are derived from this.
+   * @param defaultValue default value to return if the setting was not set. Must not be null as the
+   *     enumeration values are derived from this.
    * @return the selected enumeration values list, or {@code defaultValue}.
    */
-  public static <T extends Enum<?>> List<T> getEnumList(final Config config,
-      final String section, final String subsection, final String setting,
+  public static <T extends Enum<?>> List<T> getEnumList(
+      final Config config,
+      final String section,
+      final String subsection,
+      final String setting,
       final T defaultValue) {
     final T[] all = allValuesOf(defaultValue);
     return getEnumList(config, section, subsection, setting, all, defaultValue);
@@ -113,15 +119,18 @@
    * @param section section the key is in.
    * @param subsection subsection the key is in, or null if not in a subsection.
    * @param setting name of the setting to read.
-   * @param all all possible values in the enumeration which should be
-   *        recognized. This should be {@code EnumType.values()}.
-   * @param defaultValue default value to return if the setting was not set.
-   *        This value may be null.
+   * @param all all possible values in the enumeration which should be recognized. This should be
+   *     {@code EnumType.values()}.
+   * @param defaultValue default value to return if the setting was not set. This value may be null.
    * @return the selected enumeration values list, or {@code defaultValue}.
    */
-  public static <T extends Enum<?>> List<T> getEnumList(final Config config,
-      final String section, final String subsection, final String setting,
-      final T[] all, final T defaultValue) {
+  public static <T extends Enum<?>> List<T> getEnumList(
+      final Config config,
+      final String section,
+      final String subsection,
+      final String setting,
+      final T[] all,
+      final T defaultValue) {
     final List<T> list = new ArrayList<>();
     final String[] values = config.getStringList(section, subsection, setting);
     if (values.length == 0) {
@@ -143,16 +152,17 @@
    * @param section section the key is in.
    * @param subsection subsection the key is in, or null if not in a subsection.
    * @param setting name of the setting to read.
-   * @param defaultValue default value to return if no value was set in the
-   *        configuration file.
-   * @param wantUnit the units of {@code defaultValue} and the return value, as
-   *        well as the units to assume if the value does not contain an
-   *        indication of the units.
-   * @return the setting, or {@code defaultValue} if not set, expressed in
-   *         {@code units}.
+   * @param defaultValue default value to return if no value was set in the configuration file.
+   * @param wantUnit the units of {@code defaultValue} and the return value, as well as the units to
+   *     assume if the value does not contain an indication of the units.
+   * @return the setting, or {@code defaultValue} if not set, expressed in {@code units}.
    */
-  public static long getTimeUnit(final Config config, final String section,
-      final String subsection, final String setting, final long defaultValue,
+  public static long getTimeUnit(
+      final Config config,
+      final String section,
+      final String subsection,
+      final String setting,
+      final long defaultValue,
       final TimeUnit wantUnit) {
     final String valueString = config.getString(section, subsection, setting);
     if (valueString == null) {
@@ -164,7 +174,7 @@
       return defaultValue;
     }
 
-    if (s.startsWith("-")/* negative */) {
+    if (s.startsWith("-") /* negative */) {
       throw notTimeUnit(section, subsection, setting, valueString);
     }
 
@@ -179,16 +189,12 @@
    * Parse a numerical time unit, such as "1 minute", from a string.
    *
    * @param valueString the string to parse.
-   * @param defaultValue default value to return if no value was set in the
-   *        configuration file.
-   * @param wantUnit the units of {@code defaultValue} and the return value, as
-   *        well as the units to assume if the value does not contain an
-   *        indication of the units.
-   * @return the setting, or {@code defaultValue} if not set, expressed in
-   *         {@code units}.
+   * @param defaultValue default value to return if no value was set in the configuration file.
+   * @param wantUnit the units of {@code defaultValue} and the return value, as well as the units to
+   *     assume if the value does not contain an indication of the units.
+   * @return the setting, or {@code defaultValue} if not set, expressed in {@code units}.
    */
-  public static long getTimeUnit(final String valueString, long defaultValue,
-      TimeUnit wantUnit) {
+  public static long getTimeUnit(final String valueString, long defaultValue, TimeUnit wantUnit) {
     Matcher m = Pattern.compile("^(0|[1-9][0-9]*)\\s*(.*)$").matcher(valueString);
     if (!m.matches()) {
       return defaultValue;
@@ -250,19 +256,18 @@
   public static String getRequired(Config cfg, String section, String name) {
     final String v = cfg.getString(section, null, name);
     if (v == null || "".equals(v)) {
-      throw new IllegalArgumentException("No " + section + "." + name
-          + " configured");
+      throw new IllegalArgumentException("No " + section + "." + name + " configured");
     }
     return v;
   }
 
   /**
    * Store section by inspecting Java class attributes.
-   * <p>
-   * Optimize the storage by unsetting a variable if it is
-   * being set to default value by the server.
-   * <p>
-   * Fields marked with final or transient modifiers are skipped.
+   *
+   * <p>Optimize the storage by unsetting a variable if it is being set to default value by the
+   * server.
+   *
+   * <p>Fields marked with final or transient modifiers are skipped.
    *
    * @param cfg config in which the values should be stored
    * @param section section
@@ -271,8 +276,8 @@
    * @param defaults instance of class with default values
    * @throws ConfigInvalidException
    */
-  public static <T> void storeSection(Config cfg, String section, String sub,
-      T s, T defaults) throws ConfigInvalidException {
+  public static <T> void storeSection(Config cfg, String section, String sub, T s, T defaults)
+      throws ConfigInvalidException {
     try {
       for (Field f : s.getClass().getDeclaredFields()) {
         if (skipField(f)) {
@@ -307,32 +312,31 @@
           }
         }
       }
-    } catch (SecurityException | IllegalArgumentException
-        | IllegalAccessException e) {
+    } catch (SecurityException | IllegalArgumentException | IllegalAccessException e) {
       throw new ConfigInvalidException("cannot save values", e);
     }
   }
 
   /**
    * Load section by inspecting Java class attributes.
-   * <p>
-   * Config values are stored optimized: no default values are stored.
-   * The loading is performed eagerly: all values are set.
-   * <p>
-   * Fields marked with final or transient modifiers are skipped.
+   *
+   * <p>Config values are stored optimized: no default values are stored. The loading is performed
+   * eagerly: all values are set.
+   *
+   * <p>Fields marked with final or transient modifiers are skipped.
    *
    * @param cfg config from which the values are loaded
    * @param section section
    * @param sub subsection
    * @param s instance of class in which the values are set
    * @param defaults instance of class with default values
-   * @param i instance to merge during the load. When present, the
-   * boolean fields are not nullified when their values are false
+   * @param i instance to merge during the load. When present, the boolean fields are not nullified
+   *     when their values are false
    * @return loaded instance
    * @throws ConfigInvalidException
    */
-  public static <T> T loadSection(Config cfg, String section, String sub,
-      T s, T defaults, T i) throws ConfigInvalidException {
+  public static <T> T loadSection(Config cfg, String section, String sub, T s, T defaults, T i)
+      throws ConfigInvalidException {
     try {
       for (Field f : s.getClass().getDeclaredFields()) {
         if (skipField(f)) {
@@ -348,7 +352,7 @@
         if (isString(t)) {
           String v = cfg.getString(section, sub, n);
           if (v == null) {
-            v = (String)d;
+            v = (String) d;
           }
           f.set(s, v);
         } else if (isInteger(t)) {
@@ -375,8 +379,7 @@
           }
         }
       }
-    } catch (SecurityException | IllegalArgumentException
-        | IllegalAccessException e) {
+    } catch (SecurityException | IllegalArgumentException | IllegalAccessException e) {
       throw new ConfigInvalidException("cannot load values", e);
     }
     return s;
@@ -388,8 +391,7 @@
   }
 
   private static boolean isCollectionOrMap(Class<?> t) {
-    return Collection.class.isAssignableFrom(t)
-        || Map.class.isAssignableFrom(t);
+    return Collection.class.isAssignableFrom(t) || Map.class.isAssignableFrom(t);
   }
 
   private static boolean isString(Class<?> t) {
@@ -417,17 +419,24 @@
     return false;
   }
 
-  private static IllegalArgumentException notTimeUnit(final String section,
-      final String subsection, final String setting, final String valueString) {
-    return new IllegalArgumentException("Invalid time unit value: " + section
-        + (subsection != null ? "." + subsection : "") + "." + setting + " = "
-        + valueString);
+  private static IllegalArgumentException notTimeUnit(
+      final String section,
+      final String subsection,
+      final String setting,
+      final String valueString) {
+    return new IllegalArgumentException(
+        "Invalid time unit value: "
+            + section
+            + (subsection != null ? "." + subsection : "")
+            + "."
+            + setting
+            + " = "
+            + valueString);
   }
 
   private static IllegalArgumentException notTimeUnit(final String val) {
     return new IllegalArgumentException("Invalid time unit value: " + val);
   }
 
-  private ConfigUtil() {
-  }
+  private ConfigUtil() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
index 81a3366..1044bbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfirmEmail.java
@@ -29,14 +29,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class ConfirmEmail implements RestModifyView<ConfigResource, Input> {
   public static class Input {
-    @DefaultInput
-    public String token;
+    @DefaultInput public String token;
   }
 
   private final Provider<CurrentUser> self;
@@ -44,7 +43,8 @@
   private final AccountManager accountManager;
 
   @Inject
-  public ConfirmEmail(Provider<CurrentUser> self,
+  public ConfirmEmail(
+      Provider<CurrentUser> self,
       EmailTokenVerifier emailTokenVerifier,
       AccountManager accountManager) {
     this.self = self;
@@ -54,8 +54,8 @@
 
   @Override
   public Response<?> apply(ConfigResource rsrc, Input input)
-      throws AuthException, UnprocessableEntityException, AccountException,
-      OrmException, IOException {
+      throws AuthException, UnprocessableEntityException, AccountException, OrmException,
+          IOException, ConfigInvalidException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
index a22f52d..29ca20f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
@@ -16,22 +16,26 @@
 
 import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.config.DeleteTask.Input;
+import com.google.gerrit.server.git.WorkQueue.Task;
 import com.google.inject.Singleton;
 
 @Singleton
 @RequiresAnyCapability({KILL_TASK, MAINTAIN_SERVER})
 public class DeleteTask implements RestModifyView<TaskResource, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   @Override
   public Response<?> apply(TaskResource rsrc, Input input) {
-    rsrc.getTask().cancel(true);
-    return Response.none();
+    Task<?> task = rsrc.getTask();
+    boolean taskDeleted = task.cancel(true);
+    return taskDeleted
+        ? Response.none()
+        : Response.withStatusCode(SC_INTERNAL_SERVER_ERROR, "Unable to kill task " + task);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java
index 04712f9..336edeb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookup.java
@@ -17,10 +17,8 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface DisableReverseDnsLookup {
-}
+public @interface DisableReverseDnsLookup {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java
index 8c42714..87d6bac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/DisableReverseDnsLookupProvider.java
@@ -16,7 +16,6 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.eclipse.jgit.lib.Config;
 
 public class DisableReverseDnsLookupProvider implements Provider<Boolean> {
@@ -24,8 +23,7 @@
 
   @Inject
   DisableReverseDnsLookupProvider(@GerritServerConfig Config config) {
-    disableReverseDnsLookup =
-        config.getBoolean("gerrit", null, "disableReverseDnsLookup", false);
+    disableReverseDnsLookup = config.getBoolean("gerrit", null, "disableReverseDnsLookup", false);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
index 2db4ec9..48d4507 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -20,20 +20,18 @@
 import com.google.gerrit.server.change.ArchiveFormat;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.List;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Download protocol from {@code gerrit.config}.
- * <p>
- * Only used to configure the built-in set of schemes and commands in the core
- * download-commands plugin; not used by other plugins.
+ *
+ * <p>Only used to configure the built-in set of schemes and commands in the core download-commands
+ * plugin; not used by other plugins.
  */
 @Singleton
 public class DownloadConfig {
@@ -45,17 +43,15 @@
   DownloadConfig(@GerritServerConfig final Config cfg) {
     String[] allSchemes = cfg.getStringList("download", null, "scheme");
     if (allSchemes.length == 0) {
-      downloadSchemes = ImmutableSet.of(
-          CoreDownloadSchemes.SSH,
-          CoreDownloadSchemes.HTTP,
-          CoreDownloadSchemes.ANON_HTTP);
+      downloadSchemes =
+          ImmutableSet.of(
+              CoreDownloadSchemes.SSH, CoreDownloadSchemes.HTTP, CoreDownloadSchemes.ANON_HTTP);
     } else {
       List<String> normalized = new ArrayList<>(allSchemes.length);
       for (String s : allSchemes) {
         String core = toCoreScheme(s);
         if (core == null) {
-          throw new IllegalArgumentException(
-              "not a core download scheme: " + s);
+          throw new IllegalArgumentException("not a core download scheme: " + s);
         }
         normalized.add(core);
       }
@@ -64,8 +60,7 @@
 
     DownloadCommand[] downloadCommandValues = DownloadCommand.values();
     List<DownloadCommand> allCommands =
-        ConfigUtil.getEnumList(cfg, "download", null, "command",
-            downloadCommandValues, null);
+        ConfigUtil.getEnumList(cfg, "download", null, "command", downloadCommandValues, null);
     if (isOnlyNull(allCommands)) {
       downloadCommands = ImmutableSet.copyOf(downloadCommandValues);
     } else {
@@ -78,9 +73,9 @@
     } else if (v.isEmpty() || "off".equalsIgnoreCase(v)) {
       archiveFormats = ImmutableSet.of();
     } else {
-      archiveFormats = ImmutableSet.copyOf(ConfigUtil.getEnumList(cfg,
-          "download", null, "archive",
-          ArchiveFormat.TGZ));
+      archiveFormats =
+          ImmutableSet.copyOf(
+              ConfigUtil.getEnumList(cfg, "download", null, "archive", ArchiveFormat.TGZ));
     }
   }
 
@@ -96,7 +91,9 @@
         return (String) f.get(null);
       }
       return null;
-    } catch (NoSuchFieldException | SecurityException | IllegalArgumentException
+    } catch (NoSuchFieldException
+        | SecurityException
+        | IllegalArgumentException
         | IllegalAccessException e) {
       return null;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java
index a55ef84d..1c42c09 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java
@@ -12,13 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-
 package com.google.gerrit.server.config;
 
 import com.google.gerrit.server.account.EmailExpander;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.eclipse.jgit.lib.Config;
 
 class EmailExpanderProvider implements Provider<EmailExpander> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
index eb5ef22..5e19091 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
@@ -30,8 +30,7 @@
 @RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
 @Singleton
 public class FlushCache implements RestModifyView<CacheResource, Input> {
-  public static class Input {
-  }
+  public static class Input {}
 
   public static final String WEB_SESSIONS = "web_sessions";
 
@@ -43,12 +42,9 @@
   }
 
   @Override
-  public Response<String> apply(CacheResource rsrc, Input input)
-      throws AuthException {
-    if (WEB_SESSIONS.equals(rsrc.getName())
-        && !self.get().getCapabilities().canMaintainServer()) {
-      throw new AuthException(String.format(
-          "only site maintainers can flush %s", WEB_SESSIONS));
+  public Response<String> apply(CacheResource rsrc, Input input) throws AuthException {
+    if (WEB_SESSIONS.equals(rsrc.getName()) && !self.get().getCapabilities().canMaintainServer()) {
+      throw new AuthException(String.format("only site maintainers can flush %s", WEB_SESSIONS));
     }
 
     rsrc.getCache().invalidateAll();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GcConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GcConfig.java
index 572b56e..36f5e29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GcConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GcConfig.java
@@ -16,7 +16,6 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ConfigConstants;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java
index fff29fa..fdc3b7f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.config;
 
 import com.google.gerrit.server.securestore.SecureStore;
-
 import org.eclipse.jgit.lib.Config;
 
 class GerritConfig extends Config {
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 704682a..4e0096b 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
@@ -1,4 +1,4 @@
-// Copyright (C) 2009 The Android Open Source Project
+// Copyright (C) 2016 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -20,6 +20,8 @@
 import com.google.gerrit.audit.AuditModule;
 import com.google.gerrit.common.EventListener;
 import com.google.gerrit.common.UserScopedEventListener;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
@@ -31,7 +33,9 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
+import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
@@ -40,6 +44,7 @@
 import com.google.gerrit.extensions.events.DraftPublishedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -63,6 +68,7 @@
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.rules.PrologModule;
@@ -76,7 +82,6 @@
 import com.google.gerrit.server.account.AccountByEmailCacheImpl;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountControl;
-import com.google.gerrit.server.account.AccountInfoCacheFactory;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountVisibility;
@@ -101,10 +106,12 @@
 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.ReviewerSuggestion;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.AbandonOp;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.EmailMerge;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitModules;
@@ -118,28 +125,33 @@
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.strategy.SubmitStrategy;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.git.validators.MergeValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidators;
 import com.google.gerrit.server.git.validators.MergeValidators.ProjectConfigValidator;
+import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
+import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.UploadValidationListener;
 import com.google.gerrit.server.git.validators.UploadValidators;
-import com.google.gerrit.server.group.GroupInfoCache;
 import com.google.gerrit.server.group.GroupModule;
 import com.google.gerrit.server.index.change.ReindexAfterUpdate;
-import com.google.gerrit.server.mail.AddKeySender;
-import com.google.gerrit.server.mail.AddReviewerSender;
-import com.google.gerrit.server.mail.CreateChangeSender;
-import com.google.gerrit.server.mail.DeleteReviewerSender;
 import com.google.gerrit.server.mail.EmailModule;
-import com.google.gerrit.server.mail.FromAddressGenerator;
-import com.google.gerrit.server.mail.FromAddressGeneratorProvider;
-import com.google.gerrit.server.mail.MergedSender;
-import com.google.gerrit.server.mail.RegisterNewEmailSender;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
-import com.google.gerrit.server.mail.VelocityRuntimeProvider;
+import com.google.gerrit.server.mail.ListMailFilter;
+import com.google.gerrit.server.mail.MailFilter;
+import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.FromAddressGenerator;
+import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
+import com.google.gerrit.server.mail.send.MailSoyTofuProvider;
+import com.google.gerrit.server.mail.send.MailTemplates;
+import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.SetAssigneeSender;
+import com.google.gerrit.server.mail.send.VelocityRuntimeProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
@@ -161,8 +173,10 @@
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
@@ -172,33 +186,28 @@
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
-
+import com.google.template.soy.tofu.SoyTofu;
+import java.util.List;
 import org.apache.velocity.runtime.RuntimeInstance;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.PostReceiveHook;
 import org.eclipse.jgit.transport.PostUploadHook;
 import org.eclipse.jgit.transport.PreUploadHook;
 
-import java.util.List;
-
-
 /** Starts global state with standard dependencies. */
 public class GerritGlobalModule extends FactoryModule {
   private final Config cfg;
   private final AuthModule authModule;
 
   @Inject
-  GerritGlobalModule(
-      @GerritServerConfig Config cfg,
-      AuthModule authModule) {
+  GerritGlobalModule(@GerritServerConfig Config cfg, AuthModule authModule) {
     this.cfg = cfg;
     this.authModule = authModule;
   }
 
   @Override
   protected void configure() {
-    bind(EmailExpander.class).toProvider(EmailExpanderProvider.class).in(
-        SINGLETON);
+    bind(EmailExpander.class).toProvider(EmailExpanderProvider.class).in(SINGLETON);
 
     bind(IdGenerator.class);
     bind(RulesCache.class);
@@ -206,7 +215,8 @@
     bind(Sequences.class);
     install(authModule);
     install(AccountByEmailCacheImpl.module());
-    install(AccountCacheImpl.module());
+    install(AccountCacheImpl.module(true));
+    install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ConflictsCacheImpl.module());
     install(GroupCacheImpl.module());
@@ -231,18 +241,15 @@
 
     bind(AccountResolver.class);
 
-    factory(AccountInfoCacheFactory.Factory.class);
     factory(AddReviewerSender.Factory.class);
     factory(DeleteReviewerSender.Factory.class);
     factory(AddKeySender.Factory.class);
-    factory(BatchUpdate.Factory.class);
     factory(CapabilityCollection.Factory.class);
     factory(CapabilityControl.Factory.class);
     factory(ChangeData.Factory.class);
-    factory(ChangeJson.Factory.class);
+    factory(ChangeJson.AssistedFactory.class);
     factory(CreateChangeSender.Factory.class);
     factory(GroupDetailFactory.Factory.class);
-    factory(GroupInfoCache.Factory.class);
     factory(GroupMembers.Factory.class);
     factory(EmailMerge.Factory.class);
     factory(MergedSender.Factory.class);
@@ -253,10 +260,9 @@
     factory(ProjectState.Factory.class);
     factory(RegisterNewEmailSender.Factory.class);
     factory(ReplacePatchSetSender.Factory.class);
+    factory(SetAssigneeSender.Factory.class);
     bind(PermissionCollection.Factory.class);
-    bind(AccountVisibility.class)
-        .toProvider(AccountVisibilityProvider.class)
-        .in(SINGLETON);
+    bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
     factory(ProjectOwnerGroupsProvider.Factory.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
@@ -275,12 +281,13 @@
 
     bind(ApprovalsUtil.class);
 
-    bind(RuntimeInstance.class)
-        .toProvider(VelocityRuntimeProvider.class);
-    bind(FromAddressGenerator.class).toProvider(
-        FromAddressGeneratorProvider.class).in(SINGLETON);
-    bind(Boolean.class).annotatedWith(DisableReverseDnsLookup.class)
-        .toProvider(DisableReverseDnsLookupProvider.class).in(SINGLETON);
+    bind(RuntimeInstance.class).toProvider(VelocityRuntimeProvider.class);
+    bind(SoyTofu.class).annotatedWith(MailTemplates.class).toProvider(MailSoyTofuProvider.class);
+    bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
+    bind(Boolean.class)
+        .annotatedWith(DisableReverseDnsLookup.class)
+        .toProvider(DisableReverseDnsLookupProvider.class)
+        .in(SINGLETON);
 
     bind(PatchSetInfoFactory.class);
     bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
@@ -302,7 +309,9 @@
     DynamicSet.setOf(binder(), CacheRemovalListener.class);
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
+    DynamicSet.setOf(binder(), ChangeDeletedListener.class);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
     DynamicSet.setOf(binder(), DraftPublishedListener.class);
     DynamicSet.setOf(binder(), HashtagsEditedListener.class);
@@ -322,6 +331,7 @@
     DynamicSet.setOf(binder(), PostUploadHook.class);
     DynamicSet.setOf(binder(), AccountIndexedListener.class);
     DynamicSet.setOf(binder(), ChangeIndexedListener.class);
+    DynamicSet.setOf(binder(), GroupIndexedListener.class);
     DynamicSet.setOf(binder(), NewProjectCreatedListener.class);
     DynamicSet.setOf(binder(), ProjectDeletedListener.class);
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
@@ -334,7 +344,9 @@
     DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
+    DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
+    DynamicSet.setOf(binder(), OnSubmitValidationListener.class);
     DynamicSet.setOf(binder(), MergeValidationListener.class);
     DynamicSet.setOf(binder(), ProjectCreationValidationListener.class);
     DynamicSet.setOf(binder(), GroupCreationValidationListener.class);
@@ -347,6 +359,7 @@
     DynamicMap.mapOf(binder(), DownloadScheme.class);
     DynamicMap.mapOf(binder(), DownloadCommand.class);
     DynamicMap.mapOf(binder(), CloneCommand.class);
+    DynamicMap.mapOf(binder(), ReviewerSuggestion.class);
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
@@ -356,22 +369,30 @@
     DynamicSet.setOf(binder(), DiffWebLink.class);
     DynamicSet.setOf(binder(), ProjectWebLink.class);
     DynamicSet.setOf(binder(), BranchWebLink.class);
+    DynamicSet.setOf(binder(), TagWebLink.class);
     DynamicMap.mapOf(binder(), OAuthLoginProvider.class);
     DynamicItem.itemOf(binder(), OAuthTokenEncrypter.class);
     DynamicSet.setOf(binder(), AccountExternalIdCreator.class);
     DynamicSet.setOf(binder(), WebUiPlugin.class);
     DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
+    DynamicSet.setOf(binder(), AssigneeValidationListener.class);
+    DynamicSet.setOf(binder(), ActionVisitor.class);
+
+    DynamicMap.mapOf(binder(), MailFilter.class);
+    bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
 
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
     install(new GitwebConfig.LegacyModule(cfg));
 
     bind(AnonymousUser.class);
 
-    factory(CommitValidators.Factory.class);
+    factory(AbandonOp.Factory.class);
     factory(RefOperationValidators.Factory.class);
+    factory(OnSubmitValidators.Factory.class);
     factory(MergeValidators.Factory.class);
     factory(ProjectConfigValidator.Factory.class);
     factory(NotesBranchUtil.Factory.class);
@@ -385,7 +406,8 @@
     factory(ChangeUserName.Factory.class);
 
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
-        .toProvider(CommentLinkProvider.class).in(SINGLETON);
+        .toProvider(CommentLinkProvider.class)
+        .in(SINGLETON);
 
     bind(ReloadPluginListener.class)
         .annotatedWith(UniqueAnnotations.create())
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritOptions.java
new file mode 100644
index 0000000..725a69a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritOptions.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2013 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.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.extensions.client.UiType;
+import org.eclipse.jgit.lib.Config;
+
+public class GerritOptions {
+  private final boolean headless;
+  private final boolean slave;
+  private final boolean enablePolyGerrit;
+  private final boolean enableGwtUi;
+  private final boolean forcePolyGerritDev;
+  private final UiType defaultUi;
+
+  public GerritOptions(Config cfg, boolean headless, boolean slave, boolean forcePolyGerritDev) {
+    this.slave = slave;
+    this.enablePolyGerrit =
+        forcePolyGerritDev || cfg.getBoolean("gerrit", null, "enablePolyGerrit", true);
+    this.enableGwtUi = cfg.getBoolean("gerrit", null, "enableGwtUi", true);
+    this.forcePolyGerritDev = forcePolyGerritDev;
+    this.headless = headless || (!enableGwtUi && !enablePolyGerrit);
+
+    UiType defaultUi = enablePolyGerrit && !enableGwtUi ? UiType.POLYGERRIT : UiType.GWT;
+    String uiStr = firstNonNull(cfg.getString("gerrit", null, "ui"), defaultUi.name());
+    this.defaultUi = firstNonNull(UiType.parse(uiStr), UiType.NONE);
+
+    switch (defaultUi) {
+      case GWT:
+        checkArgument(enableGwtUi, "gerrit.ui = %s but GWT UI is disabled", defaultUi);
+        break;
+      case POLYGERRIT:
+        checkArgument(enablePolyGerrit, "gerrit.ui = %s but PolyGerrit is disabled", defaultUi);
+        break;
+      case NONE:
+      default:
+        throw new IllegalArgumentException("invalid gerrit.ui: " + uiStr);
+    }
+  }
+
+  public boolean headless() {
+    return headless;
+  }
+
+  public boolean enableGwtUi() {
+    return !headless && enableGwtUi;
+  }
+
+  public boolean enableMasterFeatures() {
+    return !slave;
+  }
+
+  public boolean enablePolyGerrit() {
+    return !headless && enablePolyGerrit;
+  }
+
+  public boolean forcePolyGerritDev() {
+    return !headless && forcePolyGerritDev;
+  }
+
+  public UiType defaultUi() {
+    return defaultUi;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfig.java
index 0dc1e68..ead0d63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfig.java
@@ -17,16 +17,14 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
  * Marker on {@link org.eclipse.jgit.lib.Config} holding {@code gerrit.config} .
- * <p>
- * The {@code gerrit.config} file contains almost all site-wide configuration
- * settings for the Gerrit Code Review server.
+ *
+ * <p>The {@code gerrit.config} file contains almost all site-wide configuration settings for the
+ * Gerrit Code Review server.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface GerritServerConfig {
-}
+public @interface GerritServerConfig {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 281600d..100a7cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -23,15 +23,13 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.ProvisionException;
-
+import java.io.IOException;
+import java.nio.file.Path;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 
-import java.io.IOException;
-import java.nio.file.Path;
-
 /** Creates {@link GerritServerConfig}. */
 public class GerritServerConfigModule extends AbstractModule {
   public static String getSecureStoreClassName(final Path sitePath) {
@@ -44,13 +42,14 @@
   }
 
   private static String getSecureStoreFromGerritConfig(final Path sitePath) {
-    AbstractModule m = new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-        bind(SitePaths.class);
-      }
-    };
+    AbstractModule m =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+            bind(SitePaths.class);
+          }
+        };
     Injector injector = Guice.createInjector(m);
     SitePaths site = injector.getInstance(SitePaths.class);
     FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
@@ -74,9 +73,11 @@
   @Override
   protected void configure() {
     bind(SitePaths.class);
-    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON) ;
-    bind(Config.class).annotatedWith(GerritServerConfig.class).toProvider(
-        GerritServerConfigProvider.class).in(SINGLETON);
+    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
+    bind(Config.class)
+        .annotatedWith(GerritServerConfig.class)
+        .toProvider(GerritServerConfigProvider.class)
+        .in(SINGLETON);
     bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
index 8be42db..494b63a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
@@ -18,7 +18,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
@@ -26,12 +26,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 /** Provides {@link Config} annotated with {@link GerritServerConfig}. */
 class GerritServerConfigProvider implements Provider<Config> {
-  private static final Logger log =
-      LoggerFactory.getLogger(GerritServerConfigProvider.class);
+  private static final Logger log = LoggerFactory.getLogger(GerritServerConfigProvider.class);
 
   private final SitePaths site;
   private final SecureStore secureStore;
@@ -44,12 +41,10 @@
 
   @Override
   public Config get() {
-    FileBasedConfig cfg =
-        new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
+    FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
 
     if (!cfg.getFile().exists()) {
-      log.info("No " + site.gerrit_config.toAbsolutePath()
-          + "; assuming defaults");
+      log.info("No " + site.gerrit_config.toAbsolutePath() + "; assuming defaults");
       return new GerritConfig(cfg, secureStore);
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java
index f3fa9b1..237f18c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerId.java
@@ -17,16 +17,14 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
  * Marker on a string holding a unique identifier for the server.
- * <p>
- * This value is generated on first use and stored in {@code
- * $site_path/etc/uuid}.
+ *
+ * <p>This value is generated on first use and stored in {@code gerrit.serverId} in {@code
+ * gerrit.config}.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface GerritServerId {
-}
+public @interface GerritServerId {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
index 9479438..83b60e2f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
@@ -19,16 +19,14 @@
 import com.google.common.base.Strings;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.UUID;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 
-import java.io.IOException;
-import java.nio.file.Files;
-import java.util.UUID;
-
 public class GerritServerIdProvider implements Provider<String> {
   public static final String SECTION = "gerrit";
   public static final String KEY = "serverId";
@@ -40,8 +38,8 @@
   private final String id;
 
   @Inject
-  GerritServerIdProvider(@GerritServerConfig Config cfg,
-      SitePaths sitePaths) throws IOException, ConfigInvalidException {
+  GerritServerIdProvider(@GerritServerConfig Config cfg, SitePaths sitePaths)
+      throws IOException, ConfigInvalidException {
     String origId = cfg.getString(SECTION, null, KEY);
     if (!Strings.isNullOrEmpty(origId)) {
       id = origId;
@@ -69,8 +67,7 @@
     // Reread gerrit.config from disk before writing. We can't just use
     // cfg.toText(), as the @GerritServerConfig only has gerrit.config as a
     // fallback.
-    FileBasedConfig cfg =
-        new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
+    FileBasedConfig cfg = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
     if (!cfg.getFile().exists()) {
       return new Config();
     }
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
index 9f18fc3..8393fb4 100644
--- 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
@@ -24,13 +24,11 @@
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 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> {
 
@@ -38,30 +36,32 @@
   private final GitRepositoryManager gitManager;
 
   @Inject
-  GetDiffPreferences(GitRepositoryManager gitManager,
-      AllUsersName allUsersName) {
+  GetDiffPreferences(GitRepositoryManager gitManager, AllUsersName allUsersName) {
     this.allUsersName = allUsersName;
     this.gitManager = gitManager;
   }
 
   @Override
   public DiffPreferencesInfo apply(ConfigResource configResource)
-      throws BadRequestException, ResourceConflictException, IOException,
-      ConfigInvalidException {
+      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     return readFromGit(gitManager, allUsersName, null);
   }
 
-  static DiffPreferencesInfo readFromGit(GitRepositoryManager gitMgr,
-             AllUsersName allUsersName, DiffPreferencesInfo in)
+  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();
+      VersionedAccountPreferences dp = VersionedAccountPreferences.forDefault();
       dp.load(git);
       DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
-      loadSection(dp.getConfig(), UserConfigSections.DIFF, null, allUserPrefs,
-          DiffPreferencesInfo.defaults(), in);
+      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/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java
index 66e45b6..ed212f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetPreferences.java
@@ -24,13 +24,11 @@
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 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 GetPreferences implements RestReadView<ConfigResource> {
   private final GeneralPreferencesLoader loader;
@@ -38,8 +36,8 @@
   private final AllUsersName allUsersName;
 
   @Inject
-  public GetPreferences(GeneralPreferencesLoader loader,
-      GitRepositoryManager gitMgr, AllUsersName allUsersName) {
+  public GetPreferences(
+      GeneralPreferencesLoader loader, GitRepositoryManager gitMgr, AllUsersName allUsersName) {
     this.loader = loader;
     this.gitMgr = gitMgr;
     this.allUsersName = allUsersName;
@@ -51,17 +49,24 @@
     return readFromGit(gitMgr, loader, allUsersName, null);
   }
 
-  static GeneralPreferencesInfo readFromGit(GitRepositoryManager gitMgr,
-      GeneralPreferencesLoader loader, AllUsersName allUsersName,
-      GeneralPreferencesInfo in) throws IOException, ConfigInvalidException,
-          RepositoryNotFoundException {
+  static GeneralPreferencesInfo readFromGit(
+      GitRepositoryManager gitMgr,
+      GeneralPreferencesLoader loader,
+      AllUsersName allUsersName,
+      GeneralPreferencesInfo in)
+      throws IOException, ConfigInvalidException, RepositoryNotFoundException {
     try (Repository git = gitMgr.openRepository(allUsersName)) {
       VersionedAccountPreferences p = VersionedAccountPreferences.forDefault();
       p.load(git);
 
-      GeneralPreferencesInfo r = loadSection(p.getConfig(),
-          UserConfigSections.GENERAL, null, new GeneralPreferencesInfo(),
-          GeneralPreferencesInfo.defaults(), in);
+      GeneralPreferencesInfo r =
+          loadSection(
+              p.getConfig(),
+              UserConfigSections.GENERAL,
+              null,
+              new GeneralPreferencesInfo(),
+              GeneralPreferencesInfo.defaults(),
+              in);
 
       // TODO(davido): Maintain cache of default values in AllUsers repository
       return loader.loadMyMenusAndUrlAliases(r, p, null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
index aebe74a..c0da3f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
@@ -14,13 +14,24 @@
 
 package com.google.gerrit.server.config;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.extensions.client.UiType;
+import com.google.gerrit.extensions.common.AuthInfo;
+import com.google.gerrit.extensions.common.ChangeConfigInfo;
+import com.google.gerrit.extensions.common.DownloadInfo;
+import com.google.gerrit.extensions.common.DownloadSchemeInfo;
+import com.google.gerrit.extensions.common.GerritInfo;
+import com.google.gerrit.extensions.common.PluginConfigInfo;
+import com.google.gerrit.extensions.common.ReceiveInfo;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.common.SshdInfo;
+import com.google.gerrit.extensions.common.SuggestInfo;
+import com.google.gerrit.extensions.common.UserConfigInfo;
 import com.google.gerrit.extensions.config.CloneCommand;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -29,26 +40,27 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.server.EnableSignedPush;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.gerrit.server.change.AllowedFormats;
 import com.google.gerrit.server.change.ArchiveFormat;
-import com.google.gerrit.server.change.GetArchive;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.net.MalformedURLException;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
 
 public class GetServerInfo implements RestReadView<ConfigResource> {
   private static final String URL_ALIAS = "urlAlias";
@@ -62,7 +74,7 @@
   private final DynamicMap<DownloadCommand> downloadCommands;
   private final DynamicMap<CloneCommand> cloneCommands;
   private final DynamicSet<WebUiPlugin> plugins;
-  private final GetArchive.AllowedFormats archiveFormats;
+  private final AllowedFormats archiveFormats;
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
   private final String anonymousCowardName;
@@ -70,6 +82,10 @@
   private final boolean enableSignedPush;
   private final QueryDocumentationExecutor docSearcher;
   private final NotesMigration migration;
+  private final ProjectCache projectCache;
+  private final AgreementJson agreementJson;
+  private final GerritOptions gerritOptions;
+  private final ChangeIndexCollection indexes;
 
   @Inject
   public GetServerInfo(
@@ -80,14 +96,18 @@
       DynamicMap<DownloadCommand> downloadCommands,
       DynamicMap<CloneCommand> cloneCommands,
       DynamicSet<WebUiPlugin> webUiPlugins,
-      GetArchive.AllowedFormats archiveFormats,
+      AllowedFormats archiveFormats,
       AllProjectsName allProjectsName,
       AllUsersName allUsersName,
       @AnonymousCowardName String anonymousCowardName,
       DynamicItem<AvatarProvider> avatar,
       @EnableSignedPush boolean enableSignedPush,
       QueryDocumentationExecutor docSearcher,
-      NotesMigration migration) {
+      NotesMigration migration,
+      ProjectCache projectCache,
+      AgreementJson agreementJson,
+      GerritOptions gerritOptions,
+      ChangeIndexCollection indexes) {
     this.config = config;
     this.authConfig = authConfig;
     this.realm = realm;
@@ -103,6 +123,10 @@
     this.enableSignedPush = enableSignedPush;
     this.docSearcher = docSearcher;
     this.migration = migration;
+    this.projectCache = projectCache;
+    this.agreementJson = agreementJson;
+    this.gerritOptions = gerritOptions;
+    this.indexes = indexes;
   }
 
   @Override
@@ -111,8 +135,7 @@
     info.auth = getAuthInfo(authConfig, realm);
     info.change = getChangeInfo(config);
     info.download =
-        getDownloadInfo(downloadSchemes, downloadCommands, cloneCommands,
-            archiveFormats);
+        getDownloadInfo(downloadSchemes, downloadCommands, cloneCommands, archiveFormats);
     info.gerrit = getGerritInfo(config, allProjectsName, allUsersName);
     info.noteDbEnabled = toBoolean(isNoteDbEnabled());
     info.plugin = getPluginInfo();
@@ -133,9 +156,19 @@
     info.useContributorAgreements = toBoolean(cfg.isUseContributorAgreements());
     info.editableAccountFields = new ArrayList<>(realm.getEditableFields());
     info.switchAccountUrl = cfg.getSwitchAccountUrl();
-    info.isGitBasicAuth = toBoolean(cfg.isGitBasicAuth());
     info.gitBasicAuthPolicy = cfg.getGitBasicAuthPolicy();
 
+    if (info.useContributorAgreements != null) {
+      Collection<ContributorAgreement> agreements =
+          projectCache.getAllProjects().getConfig().getContributorAgreements();
+      if (!agreements.isEmpty()) {
+        info.contributorAgreements = Lists.newArrayListWithCapacity(agreements.size());
+        for (ContributorAgreement agreement : agreements) {
+          info.contributorAgreements.add(agreementJson.format(agreement));
+        }
+      }
+    }
+
     switch (info.authType) {
       case LDAP:
       case LDAP_BIND:
@@ -171,15 +204,19 @@
     ChangeConfigInfo info = new ChangeConfigInfo();
     info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
     info.allowDrafts = toBoolean(cfg.getBoolean("change", "allowDrafts", true));
+    boolean hasAssigneeInIndex =
+        indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
+    info.showAssigneeInChangesTable =
+        toBoolean(
+            cfg.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
     info.largeChange = cfg.getInt("change", "largeChange", 500);
     info.replyTooltip =
-        Optional.fromNullable(cfg.getString("change", null, "replyTooltip"))
-            .or("Reply and score") + " (Shortcut: a)";
+        Optional.ofNullable(cfg.getString("change", null, "replyTooltip")).orElse("Reply and score")
+            + " (Shortcut: a)";
     info.replyLabel =
-        Optional.fromNullable(cfg.getString("change", null, "replyLabel"))
-            .or("Reply") + "\u2026";
-    info.updateDelay = (int) ConfigUtil.getTimeUnit(
-        cfg, "change", null, "updateDelay", 30, TimeUnit.SECONDS);
+        Optional.ofNullable(cfg.getString("change", null, "replyLabel")).orElse("Reply") + "\u2026";
+    info.updateDelay =
+        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 30, TimeUnit.SECONDS);
     info.submitWholeTopic = Submit.wholeTopicEnabled(cfg);
     return info;
   }
@@ -188,28 +225,23 @@
       DynamicMap<DownloadScheme> downloadSchemes,
       DynamicMap<DownloadCommand> downloadCommands,
       DynamicMap<CloneCommand> cloneCommands,
-      GetArchive.AllowedFormats archiveFormats) {
+      AllowedFormats archiveFormats) {
     DownloadInfo info = new DownloadInfo();
     info.schemes = new HashMap<>();
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
       DownloadScheme scheme = e.getProvider().get();
       if (scheme.isEnabled() && scheme.getUrl("${project}") != null) {
-        info.schemes.put(e.getExportName(),
-            getDownloadSchemeInfo(scheme, downloadCommands, cloneCommands));
+        info.schemes.put(
+            e.getExportName(), getDownloadSchemeInfo(scheme, downloadCommands, cloneCommands));
       }
     }
-    info.archives = Lists.newArrayList(Iterables.transform(
-        archiveFormats.getAllowed(),
-        new Function<ArchiveFormat, String>() {
-          @Override
-          public String apply(ArchiveFormat in) {
-            return in.getShortName();
-          }
-        }));
+    info.archives =
+        archiveFormats.getAllowed().stream().map(ArchiveFormat::getShortName).collect(toList());
     return info;
   }
 
-  private DownloadSchemeInfo getDownloadSchemeInfo(DownloadScheme scheme,
+  private DownloadSchemeInfo getDownloadSchemeInfo(
+      DownloadScheme scheme,
       DynamicMap<DownloadCommand> downloadCommands,
       DynamicMap<CloneCommand> cloneCommands) {
     DownloadSchemeInfo info = new DownloadSchemeInfo();
@@ -233,8 +265,7 @@
       CloneCommand command = e.getProvider().get();
       String c = command.getCommand(scheme, "${project-path}/${project-base-name}");
       if (c != null) {
-        c = c.replaceAll("\\$\\{project-path\\}/\\$\\{project-base-name\\}",
-            "\\$\\{project\\}");
+        c = c.replaceAll("\\$\\{project-path\\}/\\$\\{project-base-name\\}", "\\$\\{project\\}");
         info.cloneCommands.put(commandName, c);
       }
     }
@@ -242,8 +273,8 @@
     return info;
   }
 
-  private GerritInfo getGerritInfo(Config cfg, AllProjectsName allProjectsName,
-      AllUsersName allUsersName) {
+  private GerritInfo getGerritInfo(
+      Config cfg, AllProjectsName allProjectsName, AllUsersName allUsersName) {
     GerritInfo info = new GerritInfo();
     info.allProjects = allProjectsName.get();
     info.allUsers = allUsersName.get();
@@ -251,8 +282,15 @@
     info.reportBugText = cfg.getString("gerrit", null, "reportBugText");
     info.docUrl = getDocUrl(cfg);
     info.docSearch = docSearcher.isAvailable();
-    info.editGpgKeys = toBoolean(enableSignedPush
-        && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
+    info.editGpgKeys =
+        toBoolean(enableSignedPush && cfg.getBoolean("gerrit", null, "editGpgKeys", true));
+    info.webUis = EnumSet.noneOf(UiType.class);
+    if (gerritOptions.enableGwtUi()) {
+      info.webUis.add(UiType.GWT);
+    }
+    if (gerritOptions.enablePolyGerrit()) {
+      info.webUis.add(UiType.POLYGERRIT);
+    }
     return info;
   }
 
@@ -273,9 +311,8 @@
     info.hasAvatars = toBoolean(avatar.get() != null);
     info.jsResourcePaths = new ArrayList<>();
     for (WebUiPlugin u : plugins) {
-      info.jsResourcePaths.add(String.format("plugins/%s/%s",
-          u.getPluginName(),
-          u.getJavaScriptResourcePath()));
+      info.jsResourcePaths.add(
+          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath()));
     }
     return info;
   }
@@ -283,8 +320,9 @@
   private Map<String, String> getUrlAliasesInfo(Config cfg) {
     Map<String, String> urlAliases = new HashMap<>();
     for (String subsection : cfg.getSubsections(URL_ALIAS)) {
-      urlAliases.put(cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
-         cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
+      urlAliases.put(
+          cfg.getString(URL_ALIAS, subsection, KEY_MATCH),
+          cfg.getString(URL_ALIAS, subsection, KEY_TOKEN));
     }
     return urlAliases;
   }
@@ -324,86 +362,4 @@
   private static Boolean toBoolean(boolean v) {
     return v ? v : null;
   }
-
-  public static class ServerInfo {
-    public AuthInfo auth;
-    public ChangeConfigInfo change;
-    public DownloadInfo download;
-    public GerritInfo gerrit;
-    public Boolean noteDbEnabled;
-    public PluginConfigInfo plugin;
-    public SshdInfo sshd;
-    public SuggestInfo suggest;
-    public Map<String, String> urlAliases;
-    public UserConfigInfo user;
-    public ReceiveInfo receive;
-  }
-
-  public static class AuthInfo {
-    public AuthType authType;
-    public Boolean useContributorAgreements;
-    public List<Account.FieldName> editableAccountFields;
-    public String loginUrl;
-    public String loginText;
-    public String switchAccountUrl;
-    public String registerUrl;
-    public String registerText;
-    public String editFullNameUrl;
-    public String httpPasswordUrl;
-    public Boolean isGitBasicAuth;
-    public GitBasicAuthPolicy gitBasicAuthPolicy;
-  }
-
-  public static class ChangeConfigInfo {
-    public Boolean allowBlame;
-    public Boolean allowDrafts;
-    public int largeChange;
-    public String replyLabel;
-    public String replyTooltip;
-    public int updateDelay;
-    public Boolean submitWholeTopic;
-  }
-
-  public static class DownloadInfo {
-    public Map<String, DownloadSchemeInfo> schemes;
-    public List<String> archives;
-  }
-
-  public static class DownloadSchemeInfo {
-    public String url;
-    public Boolean isAuthRequired;
-    public Boolean isAuthSupported;
-    public Map<String, String> commands;
-    public Map<String, String> cloneCommands;
-  }
-
-  public static class GerritInfo {
-    public String allProjects;
-    public String allUsers;
-    public Boolean docSearch;
-    public String docUrl;
-    public Boolean editGpgKeys;
-    public String reportBugUrl;
-    public String reportBugText;
-  }
-
-  public static class PluginConfigInfo {
-    public Boolean hasAvatars;
-    public List<String> jsResourcePaths;
-  }
-
-  public static class SshdInfo {
-  }
-
-  public static class SuggestInfo {
-    public int from;
-  }
-
-  public static class UserConfigInfo {
-    public String anonymousCowardName;
-  }
-
-  public static class ReceiveInfo {
-    public Boolean enableSignedPush;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetSummary.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetSummary.java
index 722eb91..82912c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetSummary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetSummary.java
@@ -20,10 +20,6 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.Task;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.lang.management.ManagementFactory;
 import java.lang.management.OperatingSystemMXBean;
@@ -39,6 +35,8 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.internal.storage.file.WindowCacheStatAccessor;
+import org.kohsuke.args4j.Option;
 
 @RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
 public class GetSummary implements RestReadView<ConfigResource> {
@@ -69,7 +67,7 @@
   }
 
   @Override
-  public SummaryInfo apply(ConfigResource rsrc)  {
+  public SummaryInfo apply(ConfigResource rsrc) {
     if (gc) {
       System.gc();
       System.runFinalization();
@@ -94,11 +92,14 @@
     int tasksSleeping = 0;
     for (Task<?> task : pending) {
       switch (task.getState()) {
-        case RUNNING: tasksRunning++;
+        case RUNNING:
+          tasksRunning++;
           break;
-        case READY: tasksReady++;
+        case READY:
+          tasksReady++;
           break;
-        case SLEEPING: tasksSleeping++;
+        case SLEEPING:
+          tasksSleeping++;
           break;
         case CANCELLED:
         case DONE:
@@ -141,9 +142,18 @@
     threadInfo.cpus = r.availableProcessors();
     threadInfo.threads = toInteger(ManagementFactory.getThreadMXBean().getThreadCount());
 
-    List<String> prefixes = Arrays.asList("HTTP", "IntraLineDiff", "ReceiveCommits",
-        "SSH git-receive-pack", "SSH git-upload-pack", "SSH-Interactive-Worker",
-        "SSH-Stream-Worker", "SshCommandStart");
+    List<String> prefixes =
+        Arrays.asList(
+            "H2",
+            "HTTP",
+            "IntraLineDiff",
+            "ReceiveCommits",
+            "SSH git-receive-pack",
+            "SSH git-upload-pack",
+            "SSH-Interactive-Worker",
+            "SSH-Stream-Worker",
+            "SshCommandStart",
+            "sshd-SshServer");
     String other = "Other";
     ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
 
@@ -193,8 +203,7 @@
       // Ignored
     }
 
-    jvmSummary.currentWorkingDirectory =
-        path(Paths.get(".").toAbsolutePath().getParent());
+    jvmSummary.currentWorkingDirectory = path(Paths.get(".").toAbsolutePath().getParent());
     jvmSummary.site = path(sitePath);
     return jvmSummary;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroups.java
index 35ea9e6..d74ce79 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroups.java
@@ -17,14 +17,12 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
- * Used to populate the groups of users that are allowed to run
- * receive-pack on the server.
+ * Used to populate the groups of users that are allowed to run receive-pack on the server.
  *
- * Gerrit.config example:
+ * <p>Gerrit.config example:
  *
  * <pre>
  * [receive]
@@ -33,5 +31,4 @@
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface GitReceivePackGroups {
-}
+public @interface GitReceivePackGroups {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
index 49b3467..d28f87a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitReceivePackGroupsProvider.java
@@ -20,19 +20,21 @@
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.Collections;
+import org.eclipse.jgit.lib.Config;
 
 public class GitReceivePackGroupsProvider extends GroupSetProvider {
   @Inject
-  public GitReceivePackGroupsProvider(GroupBackend gb,
+  public GitReceivePackGroupsProvider(
+      GroupBackend gb,
       @GerritServerConfig Config config,
       ThreadLocalRequestContext threadContext,
       ServerRequestContext serverCtx) {
-    super(gb, threadContext, serverCtx, ImmutableList.copyOf(
-        config.getStringList("receive", null, "allowGroup")));
+    super(
+        gb,
+        threadContext,
+        serverCtx,
+        ImmutableList.copyOf(config.getStringList("receive", null, "allowGroup")));
 
     // If no group was set, default to "registered users"
     //
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroups.java
index fa8ccb7..a41d0a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroups.java
@@ -17,14 +17,12 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
- * Used to populate the groups of users that are allowed to run
- * upload-pack on the server.
+ * Used to populate the groups of users that are allowed to run upload-pack on the server.
  *
- * Gerrit.config example:
+ * <p>Gerrit.config example:
  *
  * <pre>
  * [upload]
@@ -33,5 +31,4 @@
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface GitUploadPackGroups {
-}
+public @interface GitUploadPackGroups {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
index b772089..8d6926a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitUploadPackGroupsProvider.java
@@ -21,24 +21,26 @@
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.Config;
 
 public class GitUploadPackGroupsProvider extends GroupSetProvider {
   @Inject
-  public GitUploadPackGroupsProvider(GroupBackend gb,
+  public GitUploadPackGroupsProvider(
+      GroupBackend gb,
       @GerritServerConfig Config config,
       ThreadLocalRequestContext threadContext,
       ServerRequestContext serverCtx) {
-    super(gb, threadContext, serverCtx, ImmutableList.copyOf(
-        config.getStringList("upload", null, "allowGroup")));
+    super(
+        gb,
+        threadContext,
+        serverCtx,
+        ImmutableList.copyOf(config.getStringList("upload", null, "allowGroup")));
 
     // If no group was set, default to "registered users" and "anonymous"
     //
     if (groupIds.isEmpty()) {
-      groupIds = ImmutableSet.of(
-          SystemGroupBackend.REGISTERED_USERS,
-          SystemGroupBackend.ANONYMOUS_USERS);
+      groupIds =
+          ImmutableSet.of(SystemGroupBackend.REGISTERED_USERS, SystemGroupBackend.ANONYMOUS_USERS);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java
index 830579f..153cddc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebCgiConfig.java
@@ -19,18 +19,15 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.nio.file.Path;
-import java.nio.file.Paths;
-
 @Singleton
 public class GitwebCgiConfig {
-  private static final Logger log =
-      LoggerFactory.getLogger(GitwebCgiConfig.class);
+  private static final Logger log = LoggerFactory.getLogger(GitwebCgiConfig.class);
 
   public GitwebCgiConfig disabled() {
     return new GitwebCgiConfig();
@@ -54,10 +51,7 @@
     String cfgCgi = cfg.getString("gitweb", null, "cgi");
     Path pkgCgi = Paths.get("/usr/lib/cgi-bin/gitweb.cgi");
     String[] resourcePaths = {
-        "/usr/share/gitweb/static",
-        "/usr/share/gitweb",
-        "/var/www/static",
-        "/var/www",
+      "/usr/share/gitweb/static", "/usr/share/gitweb", "/var/www/static", "/var/www",
     };
     Path cgi;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java
index 7d86aa2..91eaf3c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -19,6 +19,7 @@
 import static com.google.common.base.Strings.isNullOrEmpty;
 import static com.google.common.base.Strings.nullToEmpty;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GitwebType;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.extensions.common.WebLinkInfo;
@@ -30,10 +31,12 @@
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.net.MalformedURLException;
+import java.net.URL;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -64,8 +67,11 @@
           DynamicSet.bind(binder(), BranchWebLink.class).to(GitwebLinks.class);
         }
 
-        if (!isNullOrEmpty(type.getFile())
-            || !isNullOrEmpty(type.getRootTree())) {
+        if (!isNullOrEmpty(type.getTag())) {
+          DynamicSet.bind(binder(), TagWebLink.class).to(GitwebLinks.class);
+        }
+
+        if (!isNullOrEmpty(type.getFile()) || !isNullOrEmpty(type.getRootTree())) {
           DynamicSet.bind(binder(), FileWebLink.class).to(GitwebLinks.class);
         }
 
@@ -85,8 +91,7 @@
     }
   }
 
-  private static boolean isEmptyString(Config cfg, String section,
-      String subsection, String name) {
+  private static boolean isEmptyString(Config cfg, String section, String subsection, String name) {
     // This is currently the only way to check for the empty string in a JGit
     // config. Fun!
     String[] values = cfg.getStringList(section, subsection, name);
@@ -100,43 +105,31 @@
     }
     GitwebType type = new GitwebType();
 
-    type.setLinkName(firstNonNull(
-        cfg.getString("gitweb", null, "linkname"),
-        defaultType.getLinkName()));
-    type.setBranch(firstNonNull(
-        cfg.getString("gitweb", null, "branch"),
-        defaultType.getBranch()));
-    type.setProject(firstNonNull(
-        cfg.getString("gitweb", null, "project"),
-        defaultType.getProject()));
-    type.setRevision(firstNonNull(
-        cfg.getString("gitweb", null, "revision"),
-        defaultType.getRevision()));
-    type.setRootTree(firstNonNull(
-        cfg.getString("gitweb", null, "roottree"),
-        defaultType.getRootTree()));
-    type.setFile(firstNonNull(
-        cfg.getString("gitweb", null, "file"),
-        defaultType.getFile()));
-    type.setFileHistory(firstNonNull(
-        cfg.getString("gitweb", null, "filehistory"),
-        defaultType.getFileHistory()));
-    type.setUrlEncode(
-        cfg.getBoolean("gitweb", null, "urlencode",
-            defaultType.getUrlEncode()));
+    type.setLinkName(
+        firstNonNull(cfg.getString("gitweb", null, "linkname"), defaultType.getLinkName()));
+    type.setBranch(firstNonNull(cfg.getString("gitweb", null, "branch"), defaultType.getBranch()));
+    type.setTag(firstNonNull(cfg.getString("gitweb", null, "tag"), defaultType.getTag()));
+    type.setProject(
+        firstNonNull(cfg.getString("gitweb", null, "project"), defaultType.getProject()));
+    type.setRevision(
+        firstNonNull(cfg.getString("gitweb", null, "revision"), defaultType.getRevision()));
+    type.setRootTree(
+        firstNonNull(cfg.getString("gitweb", null, "roottree"), defaultType.getRootTree()));
+    type.setFile(firstNonNull(cfg.getString("gitweb", null, "file"), defaultType.getFile()));
+    type.setFileHistory(
+        firstNonNull(cfg.getString("gitweb", null, "filehistory"), defaultType.getFileHistory()));
+    type.setUrlEncode(cfg.getBoolean("gitweb", null, "urlencode", defaultType.getUrlEncode()));
     String pathSeparator = cfg.getString("gitweb", null, "pathSeparator");
     if (pathSeparator != null) {
       if (pathSeparator.length() == 1) {
         char c = pathSeparator.charAt(0);
         if (isValidPathSeparator(c)) {
-          type.setPathSeparator(
-              firstNonNull(c, defaultType.getPathSeparator()));
+          type.setPathSeparator(firstNonNull(c, defaultType.getPathSeparator()));
         } else {
           log.warn("Invalid gitweb.pathSeparator: " + c);
         }
       } else {
-        log.warn(
-            "gitweb.pathSeparator is not a single character: " + pathSeparator);
+        log.warn("gitweb.pathSeparator is not a single character: " + pathSeparator);
       }
     }
     return type;
@@ -150,16 +143,17 @@
         type.setProject("?p=${project}.git;a=summary");
         type.setRevision("?p=${project}.git;a=commit;h=${commit}");
         type.setBranch("?p=${project}.git;a=shortlog;h=${branch}");
+        type.setTag("?p=${project}.git;a=tag;h=${tag}");
         type.setRootTree("?p=${project}.git;a=tree;hb=${commit}");
         type.setFile("?p=${project}.git;hb=${commit};f=${file}");
-        type.setFileHistory(
-            "?p=${project}.git;a=history;hb=${branch};f=${file}");
+        type.setFileHistory("?p=${project}.git;a=history;hb=${branch};f=${file}");
         break;
       case "cgit":
         type.setLinkName("cgit");
         type.setProject("${project}.git/summary");
         type.setRevision("${project}.git/commit/?id=${commit}");
         type.setBranch("${project}.git/log/?h=${branch}");
+        type.setTag("${project}.git/tag/?h=${tag}");
         type.setRootTree("${project}.git/tree/?h=${commit}");
         type.setFile("${project}.git/tree/${file}?h=${commit}");
         type.setFileHistory("${project}.git/log/${file}?h=${branch}");
@@ -170,6 +164,7 @@
         type.setProject("");
         type.setRevision("");
         type.setBranch("");
+        type.setTag("");
         type.setRootTree("");
         type.setFile("");
         type.setFileHistory("");
@@ -186,7 +181,11 @@
   private final GitwebType type;
 
   @Inject
-  GitwebConfig(GitwebCgiConfig cgiConfig, @GerritServerConfig Config cfg) {
+  GitwebConfig(
+      GitwebCgiConfig cgiConfig,
+      @GerritServerConfig Config cfg,
+      @Nullable @CanonicalWebUrl String gerritUrl)
+      throws MalformedURLException {
     if (isDisabled(cfg)) {
       type = null;
       url = null;
@@ -199,35 +198,47 @@
         // Use an externally managed gitweb instance, and not an internal one.
         url = cfgUrl;
       } else {
-        url = firstNonNull(cfgUrl, "gitweb");
+        String baseGerritUrl;
+        if (gerritUrl != null) {
+          URL u = new URL(gerritUrl);
+          baseGerritUrl = u.getPath();
+        } else {
+          baseGerritUrl = "/";
+        }
+        url = firstNonNull(cfgUrl, baseGerritUrl + "gitweb");
       }
     }
   }
 
+  /** @return GitwebType for gitweb viewer. */
+  @Nullable
+  public GitwebType getGitwebType() {
+    return type;
+  }
+
   /**
-   * @return URL of the entry point into gitweb. This URL may be relative to our
-   *         context if gitweb is hosted by ourselves; or absolute if its hosted
-   *         elsewhere; or null if gitweb has not been configured.
+   * @return URL of the entry point into gitweb. This URL may be relative to our context if gitweb
+   *     is hosted by ourselves; or absolute if its hosted elsewhere; or null if gitweb has not been
+   *     configured.
    */
   public String getUrl() {
     return url;
   }
 
   /**
-   * Determines if a given character can be used unencoded in an URL as a
-   * replacement for the path separator '/'.
+   * Determines if a given character can be used unencoded in an URL as a replacement for the path
+   * separator '/'.
    *
-   * Reasoning: http://www.ietf.org/rfc/rfc1738.txt § 2.2:
+   * <p>Reasoning: http://www.ietf.org/rfc/rfc1738.txt § 2.2:
    *
-   * ... only alphanumerics, the special characters "$-_.+!*'(),", and
-   *  reserved characters used for their reserved purposes may be used
-   * unencoded within a URL.
+   * <p>... only alphanumerics, the special characters "$-_.+!*'(),", and reserved characters used
+   * for their reserved purposes may be used unencoded within a URL.
    *
-   * The following characters might occur in file names, however:
+   * <p>The following characters might occur in file names, however:
    *
-   * alphanumeric characters,
+   * <p>alphanumeric characters,
    *
-   * "$-_.+!',"
+   * <p>"$-_.+!',"
    */
   static boolean isValidPathSeparator(char c) {
     switch (c) {
@@ -241,8 +252,14 @@
   }
 
   @Singleton
-  static class GitwebLinks implements BranchWebLink, FileHistoryWebLink,
-      FileWebLink, PatchSetWebLink, ParentWebLink, ProjectWebLink {
+  static class GitwebLinks
+      implements BranchWebLink,
+          FileHistoryWebLink,
+          FileWebLink,
+          PatchSetWebLink,
+          ParentWebLink,
+          ProjectWebLink,
+          TagWebLink {
     private final String url;
     private final GitwebType type;
     private final ParameterizedString branch;
@@ -250,53 +267,62 @@
     private final ParameterizedString fileHistory;
     private final ParameterizedString project;
     private final ParameterizedString revision;
+    private final ParameterizedString tag;
 
     @Inject
     GitwebLinks(GitwebConfig config, GitwebType type) {
       this.url = config.getUrl();
       this.type = type;
       this.branch = parse(type.getBranch());
-      this.file = parse(firstNonNull(
-          emptyToNull(type.getFile()),
-          nullToEmpty(type.getRootTree())));
+      this.file = parse(firstNonNull(emptyToNull(type.getFile()), nullToEmpty(type.getRootTree())));
       this.fileHistory = parse(type.getFileHistory());
       this.project = parse(type.getProject());
       this.revision = parse(type.getRevision());
+      this.tag = parse(type.getTag());
     }
 
     @Override
     public WebLinkInfo getBranchWebLink(String projectName, String branchName) {
       if (branch != null) {
-        return link(branch
-            .replace("project", encode(projectName))
-            .replace("branch", encode(branchName))
-            .toString());
+        return link(
+            branch
+                .replace("project", encode(projectName))
+                .replace("branch", encode(branchName))
+                .toString());
       }
       return null;
     }
 
     @Override
-    public WebLinkInfo getFileHistoryWebLink(String projectName,
-        String revision, String fileName) {
+    public WebLinkInfo getTagWebLink(String projectName, String tagName) {
+      if (tag != null) {
+        return link(
+            tag.replace("project", encode(projectName)).replace("tag", encode(tagName)).toString());
+      }
+      return null;
+    }
+
+    @Override
+    public WebLinkInfo getFileHistoryWebLink(String projectName, String revision, String fileName) {
       if (fileHistory != null) {
-        return link(fileHistory
-            .replace("project", encode(projectName))
-            .replace("branch", encode(revision))
-            .replace("file", encode(fileName))
-            .toString());
+        return link(
+            fileHistory
+                .replace("project", encode(projectName))
+                .replace("branch", encode(revision))
+                .replace("file", encode(fileName))
+                .toString());
       }
       return null;
     }
 
     @Override
-    public WebLinkInfo getFileWebLink(String projectName, String revision,
-        String fileName) {
+    public WebLinkInfo getFileWebLink(String projectName, String revision, String fileName) {
       if (file != null) {
-        return link(file
-            .replace("project", encode(projectName))
-            .replace("commit", encode(revision))
-            .replace("file", encode(fileName))
-            .toString());
+        return link(
+            file.replace("project", encode(projectName))
+                .replace("commit", encode(revision))
+                .replace("file", encode(fileName))
+                .toString());
       }
       return null;
     }
@@ -304,10 +330,11 @@
     @Override
     public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
       if (revision != null) {
-        return link(revision
-            .replace("project", encode(projectName))
-            .replace("commit", encode(commit))
-            .toString());
+        return link(
+            revision
+                .replace("project", encode(projectName))
+                .replace("commit", encode(commit))
+                .toString());
       }
       return null;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java
index 2a2c316..19ceaa1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GlobalPluginConfig.java
@@ -15,18 +15,14 @@
 package com.google.gerrit.server.config;
 
 import com.google.gerrit.server.securestore.SecureStore;
-
 import org.eclipse.jgit.lib.Config;
 
-/**
- * Plugin configuration in etc/$PLUGIN.config and etc/$PLUGIN.secure.config.
- */
+/** Plugin configuration in etc/$PLUGIN.config and etc/$PLUGIN.secure.config. */
 public class GlobalPluginConfig extends Config {
   private final SecureStore secureStore;
   private final String pluginName;
 
-  GlobalPluginConfig(String pluginName, Config baseConfig,
-      SecureStore secureStore) {
+  GlobalPluginConfig(String pluginName, Config baseConfig, SecureStore secureStore) {
     super(baseConfig);
     this.pluginName = pluginName;
     this.secureStore = secureStore;
@@ -34,8 +30,7 @@
 
   @Override
   public String getString(String section, String subsection, String name) {
-    String secure = secureStore.getForPlugin(
-        pluginName, section, subsection, name);
+    String secure = secureStore.getForPlugin(pluginName, section, subsection, name);
     if (secure != null) {
       return secure;
     }
@@ -44,8 +39,7 @@
 
   @Override
   public String[] getStringList(String section, String subsection, String name) {
-    String[] secure = secureStore.getListForPlugin(
-        pluginName, section, subsection, name);
+    String[] secure = secureStore.getListForPlugin(pluginName, section, subsection, name);
     if (secure != null && secure.length > 0) {
       return secure;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java
index 78af1ae..a8c1674 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GroupSetProvider.java
@@ -23,22 +23,21 @@
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Provider;
-
+import java.util.List;
+import java.util.Set;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.List;
-import java.util.Set;
-
 /** Parses groups referenced in the {@code gerrit.config} file. */
-public abstract class GroupSetProvider implements
-    Provider<Set<AccountGroup.UUID>> {
+public abstract class GroupSetProvider implements Provider<Set<AccountGroup.UUID>> {
 
   protected Set<AccountGroup.UUID> groupIds;
 
-  protected GroupSetProvider(GroupBackend groupBackend,
+  protected GroupSetProvider(
+      GroupBackend groupBackend,
       ThreadLocalRequestContext threadContext,
-      ServerRequestContext serverCtx, List<String> groupNames) {
+      ServerRequestContext serverCtx,
+      List<String> groupNames) {
     RequestContext ctx = threadContext.setContext(serverCtx);
     try {
       ImmutableSet.Builder<AccountGroup.UUID> builder = ImmutableSet.builder();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
index 15981d0..d78f61d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCaches.java
@@ -28,21 +28,20 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
+import org.kohsuke.args4j.Option;
 
 @RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
 public class ListCaches implements RestReadView<ConfigResource> {
   private final DynamicMap<Cache<?, ?>> cacheMap;
 
   public enum OutputFormat {
-    LIST, TEXT_LIST
+    LIST,
+    TEXT_LIST
   }
 
   @Option(name = "--format", usage = "output format")
@@ -61,8 +60,8 @@
   public Map<String, CacheInfo> getCacheInfos() {
     Map<String, CacheInfo> cacheInfos = new TreeMap<>();
     for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-      cacheInfos.put(cacheNameOf(e.getPluginName(), e.getExportName()),
-          new CacheInfo(e.getProvider().get()));
+      cacheInfos.put(
+          cacheNameOf(e.getPluginName(), e.getExportName()), new CacheInfo(e.getProvider().get()));
     }
     return cacheInfos;
   }
@@ -88,7 +87,8 @@
   }
 
   public enum CacheType {
-    MEM, DISK
+    MEM,
+    DISK
   }
 
   public static class CacheInfo {
@@ -98,11 +98,11 @@
     public String averageGet;
     public HitRatioInfo hitRatio;
 
-    public CacheInfo(Cache<?,?> cache) {
+    public CacheInfo(Cache<?, ?> cache) {
       this(null, cache);
     }
 
-    public CacheInfo(String name, Cache<?,?> cache) {
+    public CacheInfo(String name, Cache<?, ?> cache) {
       this.name = name;
 
       CacheStats stat = cache.stats();
@@ -117,8 +117,7 @@
 
       if (cache instanceof PersistentCache) {
         type = CacheType.DISK;
-        PersistentCache.DiskStats diskStats =
-            ((PersistentCache) cache).diskStats();
+        PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
         entries.setDisk(diskStats.size());
         entries.setSpace(diskStats.space());
         hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java
index 3a87239..b8d1888 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListCapabilities.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.base.CharMatcher;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -22,17 +21,18 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.regex.Pattern;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** List capabilities visible to the calling user. */
 @Singleton
 public class ListCapabilities implements RestReadView<ConfigResource> {
   private static final Logger log = LoggerFactory.getLogger(ListCapabilities.class);
+  private static final Pattern PLUGIN_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9-]+$");
+
   private final DynamicMap<CapabilityDefinition> pluginCapabilities;
 
   @Inject
@@ -51,8 +51,7 @@
 
   private void collectCoreCapabilities(Map<String, CapabilityInfo> output)
       throws IllegalAccessException, NoSuchFieldException {
-    Class<? extends CapabilityConstants> bundleClass =
-        CapabilityConstants.get().getClass();
+    Class<? extends CapabilityConstants> bundleClass = CapabilityConstants.get().getClass();
     CapabilityConstants c = CapabilityConstants.get();
     for (String id : GlobalCapability.getAllNames()) {
       String name = (String) bundleClass.getField(id).get(c);
@@ -62,29 +61,21 @@
 
   private void collectPluginCapabilities(Map<String, CapabilityInfo> output) {
     for (String pluginName : pluginCapabilities.plugins()) {
-      if (!isPluginNameSane(pluginName)) {
-        log.warn(String.format(
-            "Plugin name %s must match [A-Za-z0-9-]+ to use capabilities;"
-            + " rename the plugin",
-            pluginName));
+      if (!PLUGIN_NAME_PATTERN.matcher(pluginName).matches()) {
+        log.warn(
+            "Plugin name '{}' must match '{}' to use capabilities; rename the plugin",
+            pluginName,
+            PLUGIN_NAME_PATTERN.pattern());
         continue;
       }
       for (Map.Entry<String, Provider<CapabilityDefinition>> entry :
           pluginCapabilities.byPlugin(pluginName).entrySet()) {
         String id = String.format("%s-%s", pluginName, entry.getKey());
-        output.put(id, new CapabilityInfo(
-            id,
-            entry.getValue().get().getDescription()));
+        output.put(id, new CapabilityInfo(id, entry.getValue().get().getDescription()));
       }
     }
   }
 
-  private static boolean isPluginNameSane(String pluginName) {
-    return CharMatcher.javaLetterOrDigit()
-        .or(CharMatcher.is('-'))
-        .matchesAllOf(pluginName);
-  }
-
   public static class CapabilityInfo {
     public String id;
     public String name;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
index b96d5d9..7e9bd71 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
@@ -30,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -47,8 +46,7 @@
   private final Provider<IdentifiedUser> self;
 
   @Inject
-  public ListTasks(WorkQueue workQueue, ProjectCache projectCache,
-      Provider<IdentifiedUser> self) {
+  public ListTasks(WorkQueue workQueue, ProjectCache projectCache, Provider<IdentifiedUser> self) {
     this.workQueue = workQueue;
     this.projectCache = projectCache;
     this.self = self;
@@ -86,22 +84,25 @@
 
   private List<TaskInfo> getTasks() {
     List<TaskInfo> taskInfos =
-        workQueue.getTaskInfos(new TaskInfoFactory<TaskInfo>() {
+        workQueue.getTaskInfos(
+            new TaskInfoFactory<TaskInfo>() {
+              @Override
+              public TaskInfo getTaskInfo(Task<?> task) {
+                return new TaskInfo(task);
+              }
+            });
+    Collections.sort(
+        taskInfos,
+        new Comparator<TaskInfo>() {
           @Override
-          public TaskInfo getTaskInfo(Task<?> task) {
-            return new TaskInfo(task);
+          public int compare(TaskInfo a, TaskInfo b) {
+            return ComparisonChain.start()
+                .compare(a.state.ordinal(), b.state.ordinal())
+                .compare(a.delay, b.delay)
+                .compare(a.command, b.command)
+                .result();
           }
         });
-    Collections.sort(taskInfos, new Comparator<TaskInfo>() {
-      @Override
-      public int compare(TaskInfo a, TaskInfo b) {
-        return ComparisonChain.start()
-          .compare(a.state.ordinal(), b.state.ordinal())
-          .compare(a.delay, b.delay)
-          .compare(a.command, b.command)
-          .result();
-      }
-    });
     return taskInfos;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java
index c7d10a3..a7ba938 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTopMenus.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.List;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
index d8485fe..fd1a12c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfig.java
@@ -17,15 +17,14 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 
 public class PluginConfig {
   private static final String PLUGIN = "plugin";
@@ -53,14 +52,21 @@
     ProjectState parent = Iterables.getFirst(state.parents(), null);
     if (parent != null) {
       PluginConfig parentPluginConfig =
-          parent.getConfig().getPluginConfig(pluginName)
-              .withInheritance(projectStateFactory);
+          parent.getConfig().getPluginConfig(pluginName).withInheritance(projectStateFactory);
       Set<String> allNames = cfg.getNames(PLUGIN, pluginName);
       cfg = copyConfig(cfg);
       for (String name : parentPluginConfig.cfg.getNames(PLUGIN, pluginName)) {
         if (!allNames.contains(name)) {
-          cfg.setStringList(PLUGIN, pluginName, name, Arrays
-              .asList(parentPluginConfig.cfg.getStringList(PLUGIN, pluginName, name)));
+          List<String> values =
+              Arrays.asList(parentPluginConfig.cfg.getStringList(PLUGIN, pluginName, name));
+          for (String value : values) {
+            GroupReference groupRef =
+                parentPluginConfig.projectConfig.getGroup(GroupReference.extractGroupName(value));
+            if (groupRef != null) {
+              projectConfig.resolve(groupRef);
+            }
+          }
+          cfg.setStringList(PLUGIN, pluginName, name, values);
         }
       }
     }
@@ -86,8 +92,7 @@
     if (defaultValue == null) {
       return cfg.getString(PLUGIN, pluginName, name);
     }
-    return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name),
-        defaultValue);
+    return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name), defaultValue);
   }
 
   public void setString(String name, String value) {
@@ -153,4 +158,13 @@
   public Set<String> getNames() {
     return cfg.getNames(PLUGIN, pluginName, true);
   }
+
+  public GroupReference getGroupReference(String name) {
+    return projectConfig.getGroup(GroupReference.extractGroupName(getString(name)));
+  }
+
+  public void setGroupReference(String name, GroupReference value) {
+    GroupReference groupRef = projectConfig.resolve(value);
+    setString(name, groupRef.toConfigValue());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java
index eb6169e..09f2837 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -25,7 +25,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 import org.eclipse.jgit.lib.Config;
@@ -34,16 +38,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Path;
-import java.util.HashMap;
-import java.util.Map;
-
 @Singleton
 public class PluginConfigFactory implements ReloadPluginListener {
-  private static final Logger log =
-      LoggerFactory.getLogger(PluginConfigFactory.class);
+  private static final Logger log = LoggerFactory.getLogger(PluginConfigFactory.class);
   private static final String EXTENSION = ".config";
 
   private final SitePaths site;
@@ -75,19 +72,14 @@
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * 'gerrit.config' file.
+   * Returns the configuration for the specified plugin that is stored in the 'gerrit.config' file.
    *
-   * The returned plugin configuration provides access to all parameters of the
-   * 'gerrit.config' file that are set in the 'plugin' subsection of the
-   * specified plugin.
+   * <p>The returned plugin configuration provides access to all parameters of the 'gerrit.config'
+   * file that are set in the 'plugin' subsection of the specified plugin.
    *
-   * E.g.:
-   *   [plugin "my-plugin"]
-   *     myKey = myValue
+   * <p>E.g.: [plugin "my-plugin"] myKey = myValue
    *
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
    * @return the plugin configuration from the 'gerrit.config' file
    */
   public PluginConfig getFromGerritConfig(String pluginName) {
@@ -95,23 +87,23 @@
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * 'gerrit.config' file.
+   * Returns the configuration for the specified plugin that is stored in the 'gerrit.config' file.
    *
-   * The returned plugin configuration provides access to all parameters of the
-   * 'gerrit.config' file that are set in the 'plugin' subsection of the
-   * specified plugin.
+   * <p>The returned plugin configuration provides access to all parameters of the 'gerrit.config'
+   * file that are set in the 'plugin' subsection of the specified plugin.
    *
-   * E.g.: [plugin "my-plugin"] myKey = myValue
+   * <p>E.g.: [plugin "my-plugin"] myKey = myValue
    *
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @param refresh if <code>true</code> it is checked if the 'gerrit.config'
-   *        file was modified and if yes the Gerrit configuration is reloaded,
-   *        if <code>false</code> the cached Gerrit configuration is used
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @param refresh if <code>true</code> it is checked if the 'gerrit.config' file was modified and
+   *     if yes the Gerrit configuration is reloaded, if <code>false</code> the cached Gerrit
+   *     configuration is used
    * @return the plugin configuration from the 'gerrit.config' file
    */
   public PluginConfig getFromGerritConfig(String pluginName, boolean refresh) {
+    if (refresh && secureStore.isOutdated()) {
+      secureStore.reload();
+    }
     File configFile = site.gerrit_config.toFile();
     if (refresh && cfgSnapshot.isModified(configFile)) {
       cfgSnapshot = FileSnapshot.save(configFile);
@@ -121,28 +113,22 @@
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * 'project.config' file of the specified project.
+   * Returns the configuration for the specified plugin that is stored in the 'project.config' file
+   * of the specified project.
    *
-   * The returned plugin configuration provides access to all parameters of the
-   * 'project.config' file that are set in the 'plugin' subsection of the
-   * specified plugin.
+   * <p>The returned plugin configuration provides access to all parameters of the 'project.config'
+   * file that are set in the 'plugin' subsection of the specified plugin.
    *
-   * E.g.:
-   *   [plugin "my-plugin"]
-   *     myKey = myValue
+   * <p>E.g.: [plugin "my-plugin"] myKey = myValue
    *
-   * @param projectName the name of the project for which the plugin
-   *        configuration should be returned
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the 'project.config' file of the
-   *         specified project
-   * @throws NoSuchProjectException thrown if the specified project does not
-   *         exist
+   * @param projectName the name of the project for which the plugin configuration should be
+   *     returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the 'project.config' file of the specified project
+   * @throws NoSuchProjectException thrown if the specified project does not exist
    */
-  public PluginConfig getFromProjectConfig(Project.NameKey projectName,
-      String pluginName) throws NoSuchProjectException {
+  public PluginConfig getFromProjectConfig(Project.NameKey projectName, String pluginName)
+      throws NoSuchProjectException {
     ProjectState projectState = projectCache.get(projectName);
     if (projectState == null) {
       throw new NoSuchProjectException(projectName);
@@ -151,110 +137,80 @@
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * 'project.config' file of the specified project.
+   * Returns the configuration for the specified plugin that is stored in the 'project.config' file
+   * of the specified project.
    *
-   * The returned plugin configuration provides access to all parameters of the
-   * 'project.config' file that are set in the 'plugin' subsection of the
-   * specified plugin.
+   * <p>The returned plugin configuration provides access to all parameters of the 'project.config'
+   * file that are set in the 'plugin' subsection of the specified plugin.
    *
-   * E.g.: [plugin "my-plugin"] myKey = myValue
+   * <p>E.g.: [plugin "my-plugin"] myKey = myValue
    *
-   * @param projectState the project for which the plugin configuration should
-   *        be returned
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the 'project.config' file of the
-   *         specified project
+   * @param projectState the project for which the plugin configuration should be returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the 'project.config' file of the specified project
    */
-  public PluginConfig getFromProjectConfig(ProjectState projectState,
-      String pluginName) {
+  public PluginConfig getFromProjectConfig(ProjectState projectState, String pluginName) {
     return projectState.getConfig().getPluginConfig(pluginName);
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * 'project.config' file of the specified project. Parameters which are not
-   * set in the 'project.config' of this project are inherited from the parent
-   * project's 'project.config' files.
+   * Returns the configuration for the specified plugin that is stored in the 'project.config' file
+   * of the specified project. Parameters which are not set in the 'project.config' of this project
+   * are inherited from the parent project's 'project.config' files.
    *
-   * The returned plugin configuration provides access to all parameters of the
-   * 'project.config' file that are set in the 'plugin' subsection of the
-   * specified plugin.
+   * <p>The returned plugin configuration provides access to all parameters of the 'project.config'
+   * file that are set in the 'plugin' subsection of the specified plugin.
    *
-   * E.g.:
-   * child project:
-   *   [plugin "my-plugin"]
-   *     myKey = childValue
+   * <p>E.g.: child project: [plugin "my-plugin"] myKey = childValue
    *
-   * parent project:
-   *   [plugin "my-plugin"]
-   *     myKey = parentValue
-   *     anotherKey = someValue
+   * <p>parent project: [plugin "my-plugin"] myKey = parentValue anotherKey = someValue
    *
-   * return:
-   *   [plugin "my-plugin"]
-   *     myKey = childValue
-   *     anotherKey = someValue
+   * <p>return: [plugin "my-plugin"] myKey = childValue anotherKey = someValue
    *
-   * @param projectName the name of the project for which the plugin
-   *        configuration should be returned
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the 'project.config' file of the
-   *         specified project with inherited non-set parameters from the
-   *         parent projects
-   * @throws NoSuchProjectException thrown if the specified project does not
-   *         exist
+   * @param projectName the name of the project for which the plugin configuration should be
+   *     returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the 'project.config' file of the specified project with
+   *     inherited non-set parameters from the parent projects
+   * @throws NoSuchProjectException thrown if the specified project does not exist
    */
   public PluginConfig getFromProjectConfigWithInheritance(
-      Project.NameKey projectName, String pluginName)
-      throws NoSuchProjectException {
-    return getFromProjectConfig(projectName, pluginName).withInheritance(
-        projectStateFactory);
+      Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
+    return getFromProjectConfig(projectName, pluginName).withInheritance(projectStateFactory);
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * 'project.config' file of the specified project. Parameters which are not
-   * set in the 'project.config' of this project are inherited from the parent
-   * project's 'project.config' files.
+   * Returns the configuration for the specified plugin that is stored in the 'project.config' file
+   * of the specified project. Parameters which are not set in the 'project.config' of this project
+   * are inherited from the parent project's 'project.config' files.
    *
-   * The returned plugin configuration provides access to all parameters of the
-   * 'project.config' file that are set in the 'plugin' subsection of the
-   * specified plugin.
+   * <p>The returned plugin configuration provides access to all parameters of the 'project.config'
+   * file that are set in the 'plugin' subsection of the specified plugin.
    *
-   * E.g.: child project: [plugin "my-plugin"] myKey = childValue
+   * <p>E.g.: child project: [plugin "my-plugin"] myKey = childValue
    *
-   * parent project: [plugin "my-plugin"] myKey = parentValue anotherKey =
-   * someValue
+   * <p>parent project: [plugin "my-plugin"] myKey = parentValue anotherKey = someValue
    *
-   * return: [plugin "my-plugin"] myKey = childValue anotherKey = someValue
+   * <p>return: [plugin "my-plugin"] myKey = childValue anotherKey = someValue
    *
-   * @param projectState the project for which the plugin configuration should
-   *        be returned
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the 'project.config' file of the
-   *         specified project with inherited non-set parameters from the parent
-   *         projects
+   * @param projectState the project for which the plugin configuration should be returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the 'project.config' file of the specified project with
+   *     inherited non-set parameters from the parent projects
    */
   public PluginConfig getFromProjectConfigWithInheritance(
       ProjectState projectState, String pluginName) {
-    return getFromProjectConfig(projectState, pluginName).withInheritance(
-        projectStateFactory);
+    return getFromProjectConfig(projectState, pluginName).withInheritance(projectStateFactory);
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * plugin configuration file '{@code etc/<plugin-name>.config}'.
+   * Returns the configuration for the specified plugin that is stored in the plugin configuration
+   * file '{@code etc/<plugin-name>.config}'.
    *
-   * The plugin configuration is only loaded once and is then cached.
+   * <p>The plugin configuration is only loaded once and is then cached.
    *
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the
-   *         '{@code etc/<plugin-name>.config}' file
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code etc/<plugin-name>.config}' file
    */
   public synchronized Config getGlobalPluginConfig(String pluginName) {
     if (pluginConfigs.containsKey(pluginName)) {
@@ -262,10 +218,8 @@
     }
 
     Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
-    FileBasedConfig cfg =
-        new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
-    GlobalPluginConfig pluginConfig =
-        new GlobalPluginConfig(pluginName, cfg, secureStore);
+    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    GlobalPluginConfig pluginConfig = new GlobalPluginConfig(pluginName, cfg, secureStore);
     pluginConfigs.put(pluginName, pluginConfig);
     if (!cfg.getFile().exists()) {
       log.info("No " + pluginConfigFile.toAbsolutePath() + "; assuming defaults");
@@ -274,7 +228,10 @@
 
     try {
       cfg.load();
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (ConfigInvalidException e) {
+      // This is an error in user input, don't spam logs with a stack trace.
+      log.warn("Failed to load " + pluginConfigFile.toAbsolutePath() + ": " + e);
+    } catch (IOException e) {
       log.warn("Failed to load " + pluginConfigFile.toAbsolutePath(), e);
     }
 
@@ -282,101 +239,130 @@
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * '{@code <plugin-name>.config}' file in the 'refs/meta/config' branch of
-   * the specified project.
+   * Returns the configuration for the specified plugin that is stored in the '{@code
+   * <plugin-name>.config}' file in the 'refs/meta/config' branch of the specified project.
    *
-   * @param projectName the name of the project for which the plugin
-   *        configuration should be returned
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the '{@code <plugin-name>.config}'
-   *         file of the specified project
-   * @throws NoSuchProjectException thrown if the specified project does not
-   *         exist
+   * @param projectName the name of the project for which the plugin configuration should be
+   *     returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code <plugin-name>.config}' file of the specified
+   *     project
+   * @throws NoSuchProjectException thrown if the specified project does not exist
    */
-  public Config getProjectPluginConfig(Project.NameKey projectName,
-      String pluginName) throws NoSuchProjectException {
+  public Config getProjectPluginConfig(Project.NameKey projectName, String pluginName)
+      throws NoSuchProjectException {
     return getPluginConfig(projectName, pluginName).get();
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * '{@code <plugin-name>.config}' file in the 'refs/meta/config' branch of
-   * the specified project.
+   * Returns the configuration for the specified plugin that is stored in the '{@code
+   * <plugin-name>.config}' file in the 'refs/meta/config' branch of the specified project.
    *
-   * @param projectState the project for which the plugin configuration should
-   *        be returned
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the '{@code <plugin-name>.config}'
-   *         file of the specified project
+   * @param projectState the project for which the plugin configuration should be returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code <plugin-name>.config}' file of the specified
+   *     project
    */
-  public Config getProjectPluginConfig(ProjectState projectState,
-      String pluginName) {
+  public Config getProjectPluginConfig(ProjectState projectState, String pluginName) {
     return projectState.getConfig(pluginName + EXTENSION).get();
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * '{@code <plugin-name>.config}' file in the 'refs/meta/config' branch of
-   * the specified project. Parameters which are not set in the
-   * '{@code <plugin-name>.config}' of this project are inherited from the
-   * parent project's '{@code <plugin-name>.config}' files.
+   * Returns the configuration for the specified plugin that is stored in the '{@code
+   * <plugin-name>.config}' file in the 'refs/meta/config' branch of the specified project.
+   * Parameters which are not set in the '{@code <plugin-name>.config}' of this project are
+   * inherited from the parent project's '{@code <plugin-name>.config}' files.
    *
-   * E.g.: child project: [mySection "mySubsection"] myKey = childValue
+   * <p>E.g.: child project: [mySection "mySubsection"] myKey = childValue
    *
-   * parent project: [mySection "mySubsection"] myKey = parentValue anotherKey =
-   * someValue
+   * <p>parent project: [mySection "mySubsection"] myKey = parentValue anotherKey = someValue
    *
-   * return: [mySection "mySubsection"] myKey = childValue anotherKey =
-   * someValue
+   * <p>return: [mySection "mySubsection"] myKey = childValue anotherKey = someValue
    *
-   * @param projectName the name of the project for which the plugin
-   *        configuration should be returned
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the '{@code <plugin-name>.config}'
-   *         file of the specified project with inheriting non-set parameters
-   *         from the parent projects
-   * @throws NoSuchProjectException thrown if the specified project does not
-   *         exist
+   * @param projectName the name of the project for which the plugin configuration should be
+   *     returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code <plugin-name>.config}' file of the specified
+   *     project with inheriting non-set parameters from the parent projects
+   * @throws NoSuchProjectException thrown if the specified project does not exist
    */
-  public Config getProjectPluginConfigWithInheritance(Project.NameKey projectName,
-      String pluginName) throws NoSuchProjectException {
-    return getPluginConfig(projectName, pluginName).getWithInheritance();
+  public Config getProjectPluginConfigWithInheritance(
+      Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
+    return getPluginConfig(projectName, pluginName).getWithInheritance(false);
   }
 
   /**
-   * Returns the configuration for the specified plugin that is stored in the
-   * '{@code <plugin-name>.config}' file in the 'refs/meta/config' branch of
-   * the specified project. Parameters which are not set in the
-   * '{@code <plugin-name>.config}' of this project are inherited from the
-   * parent project's '{@code <plugin-name>.config}' files.
+   * Returns the configuration for the specified plugin that is stored in the '{@code
+   * <plugin-name>.config}' file in the 'refs/meta/config' branch of the specified project.
+   * Parameters from the '{@code <plugin-name>.config}' of the parent project are appended to this
+   * project's '{@code <plugin-name>.config}' files.
    *
-   * E.g.: child project: [mySection "mySubsection"] myKey = childValue
+   * <p>E.g.: child project: [mySection "mySubsection"] myKey = childValue
    *
-   * parent project: [mySection "mySubsection"] myKey = parentValue anotherKey =
+   * <p>parent project: [mySection "mySubsection"] myKey = parentValue anotherKey = someValue
+   *
+   * <p>return: [mySection "mySubsection"] myKey = childValue myKey = parentValue anotherKey =
    * someValue
    *
-   * return: [mySection "mySubsection"] myKey = childValue anotherKey =
-   * someValue
-   *
-   * @param projectState the project for which the plugin configuration should
-   *        be returned
-   * @param pluginName the name of the plugin for which the configuration should
-   *        be returned
-   * @return the plugin configuration from the '{@code <plugin-name>.config}'
-   *         file of the specified project with inheriting non-set parameters
-   *         from the parent projects
+   * @param projectName the name of the project for which the plugin configuration should be
+   *     returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code <plugin-name>.config}' file of the specified
+   *     project with parameters from the parent projects appended to the project values
+   * @throws NoSuchProjectException thrown if the specified project does not exist
    */
-  public Config getProjectPluginConfigWithInheritance(ProjectState projectState,
-      String pluginName) {
-    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance();
+  public Config getProjectPluginConfigWithMergedInheritance(
+      Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
+    return getPluginConfig(projectName, pluginName).getWithInheritance(true);
   }
 
-  private ProjectLevelConfig getPluginConfig(Project.NameKey projectName,
-      String pluginName) throws NoSuchProjectException {
+  /**
+   * Returns the configuration for the specified plugin that is stored in the '{@code
+   * <plugin-name>.config}' file in the 'refs/meta/config' branch of the specified project.
+   * Parameters which are not set in the '{@code <plugin-name>.config}' of this project are
+   * inherited from the parent project's '{@code <plugin-name>.config}' files.
+   *
+   * <p>E.g.: child project: [mySection "mySubsection"] myKey = childValue
+   *
+   * <p>parent project: [mySection "mySubsection"] myKey = parentValue anotherKey = someValue
+   *
+   * <p>return: [mySection "mySubsection"] myKey = childValue anotherKey = someValue
+   *
+   * @param projectState the project for which the plugin configuration should be returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code <plugin-name>.config}' file of the specified
+   *     project with inheriting non-set parameters from the parent projects
+   */
+  public Config getProjectPluginConfigWithInheritance(
+      ProjectState projectState, String pluginName) {
+    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(false);
+  }
+
+  /**
+   * Returns the configuration for the specified plugin that is stored in the '{@code
+   * <plugin-name>.config}' file in the 'refs/meta/config' branch of the specified project.
+   * Parameters from the '{@code <plugin-name>.config}' of the parent project are appended to this
+   * project's '{@code <plugin-name>.config}' files.
+   *
+   * <p>E.g.: child project: [mySection "mySubsection"] myKey = childValue
+   *
+   * <p>parent project: [mySection "mySubsection"] myKey = parentValue anotherKey = someValue
+   *
+   * <p>return: [mySection "mySubsection"] myKey = childValue myKey = parentValue anotherKey =
+   * someValue
+   *
+   * @param projectState the project for which the plugin configuration should be returned
+   * @param pluginName the name of the plugin for which the configuration should be returned
+   * @return the plugin configuration from the '{@code <plugin-name>.config}' file of the specified
+   *     project with inheriting non-set parameters from the parent projects
+   */
+  public Config getProjectPluginConfigWithMergedInheritance(
+      ProjectState projectState, String pluginName) {
+    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(true);
+  }
+
+  private ProjectLevelConfig getPluginConfig(Project.NameKey projectName, String pluginName)
+      throws NoSuchProjectException {
     ProjectState projectState = projectCache.get(projectName);
     if (projectState == null) {
       throw new NoSuchProjectException(projectName);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
index 33a458e..3cfa2b9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.config.PostCaches.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -39,8 +38,7 @@
     public Operation operation;
     public List<String> caches;
 
-    public Input() {
-    }
+    public Input() {}
 
     public Input(Operation op) {
       this(op, null);
@@ -53,21 +51,21 @@
   }
 
   public enum Operation {
-    FLUSH_ALL, FLUSH
+    FLUSH_ALL,
+    FLUSH
   }
 
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final FlushCache flushCache;
 
   @Inject
-  public PostCaches(DynamicMap<Cache<?, ?>> cacheMap,
-      FlushCache flushCache) {
+  public PostCaches(DynamicMap<Cache<?, ?>> cacheMap, FlushCache flushCache) {
     this.cacheMap = cacheMap;
     this.flushCache = flushCache;
   }
 
   @Override
-  public Object apply(ConfigResource rsrc, Input input)
+  public Response<String> apply(ConfigResource rsrc, Input input)
       throws AuthException, BadRequestException, UnprocessableEntityException {
     if (input == null || input.operation == null) {
       throw new BadRequestException("operation must be specified");
@@ -83,8 +81,7 @@
         return Response.ok("");
       case FLUSH:
         if (input.caches == null || input.caches.isEmpty()) {
-          throw new BadRequestException(
-              "caches must be specified for operation 'FLUSH'");
+          throw new BadRequestException("caches must be specified for operation 'FLUSH'");
         }
         flush(input.caches);
         return Response.ok("");
@@ -96,8 +93,7 @@
   private void flushAll() throws AuthException {
     for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
       CacheResource cacheResource =
-          new CacheResource(e.getPluginName(), e.getExportName(),
-              e.getProvider());
+          new CacheResource(e.getPluginName(), e.getExportName(), e.getProvider());
       if (FlushCache.WEB_SESSIONS.equals(cacheResource.getName())) {
         continue;
       }
@@ -105,8 +101,7 @@
     }
   }
 
-  private void flush(List<String> cacheNames)
-      throws UnprocessableEntityException, AuthException {
+  private void flush(List<String> cacheNames) throws UnprocessableEntityException, AuthException {
     List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
 
     for (String n : cacheNames) {
@@ -122,8 +117,7 @@
       if (cache != null) {
         cacheResources.add(new CacheResource(pluginName, cacheName, cache));
       } else {
-        throw new UnprocessableEntityException(String.format(
-            "cache %s not found", n));
+        throw new UnprocessableEntityException(String.format("cache %s not found", n));
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index cc7857c..943edbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.base.Function;
-import com.google.common.collect.Lists;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
@@ -29,7 +29,9 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
-
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -37,10 +39,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.List;
-
 @ExtensionPoint
 public class ProjectConfigEntry {
   private final String displayName;
@@ -54,45 +52,51 @@
     this(displayName, defaultValue, false);
   }
 
-  public ProjectConfigEntry(String displayName, String defaultValue,
-      boolean inheritable) {
+  public ProjectConfigEntry(String displayName, String defaultValue, boolean inheritable) {
     this(displayName, defaultValue, inheritable, null);
   }
 
-  public ProjectConfigEntry(String displayName, String defaultValue,
-      boolean inheritable, String description) {
-    this(displayName, defaultValue, ProjectConfigEntryType.STRING, null,
-        inheritable, description);
+  public ProjectConfigEntry(
+      String displayName, String defaultValue, boolean inheritable, String description) {
+    this(displayName, defaultValue, ProjectConfigEntryType.STRING, null, inheritable, description);
   }
 
   public ProjectConfigEntry(String displayName, int defaultValue) {
     this(displayName, defaultValue, false);
   }
 
-  public ProjectConfigEntry(String displayName, int defaultValue,
-      boolean inheritable) {
+  public ProjectConfigEntry(String displayName, int defaultValue, boolean inheritable) {
     this(displayName, defaultValue, inheritable, null);
   }
 
-  public ProjectConfigEntry(String displayName, int defaultValue,
-      boolean inheritable, String description) {
-    this(displayName, Integer.toString(defaultValue),
-        ProjectConfigEntryType.INT, null, inheritable, description);
+  public ProjectConfigEntry(
+      String displayName, int defaultValue, boolean inheritable, String description) {
+    this(
+        displayName,
+        Integer.toString(defaultValue),
+        ProjectConfigEntryType.INT,
+        null,
+        inheritable,
+        description);
   }
 
   public ProjectConfigEntry(String displayName, long defaultValue) {
     this(displayName, defaultValue, false);
   }
 
-  public ProjectConfigEntry(String displayName, long defaultValue,
-      boolean inheritable) {
+  public ProjectConfigEntry(String displayName, long defaultValue, boolean inheritable) {
     this(displayName, defaultValue, inheritable, null);
   }
 
-  public ProjectConfigEntry(String displayName, long defaultValue,
-      boolean inheritable, String description) {
-    this(displayName, Long.toString(defaultValue),
-        ProjectConfigEntryType.LONG, null, inheritable, description);
+  public ProjectConfigEntry(
+      String displayName, long defaultValue, boolean inheritable, String description) {
+    this(
+        displayName,
+        Long.toString(defaultValue),
+        ProjectConfigEntryType.LONG,
+        null,
+        inheritable,
+        description);
   }
 
   // For inheritable boolean use 'LIST' type with InheritableBoolean
@@ -100,56 +104,73 @@
     this(displayName, defaultValue, null);
   }
 
-  //For inheritable boolean use 'LIST' type with InheritableBoolean
-  public ProjectConfigEntry(String displayName, boolean defaultValue,
-      String description) {
-    this(displayName, Boolean.toString(defaultValue),
-        ProjectConfigEntryType.BOOLEAN, null, false, description);
+  // For inheritable boolean use 'LIST' type with InheritableBoolean
+  public ProjectConfigEntry(String displayName, boolean defaultValue, String description) {
+    this(
+        displayName,
+        Boolean.toString(defaultValue),
+        ProjectConfigEntryType.BOOLEAN,
+        null,
+        false,
+        description);
   }
 
-  public ProjectConfigEntry(String displayName, String defaultValue,
-      List<String> permittedValues) {
+  public ProjectConfigEntry(String displayName, String defaultValue, List<String> permittedValues) {
     this(displayName, defaultValue, permittedValues, false);
   }
 
-  public ProjectConfigEntry(String displayName, String defaultValue,
-      List<String> permittedValues, boolean inheritable) {
+  public ProjectConfigEntry(
+      String displayName, String defaultValue, List<String> permittedValues, boolean inheritable) {
     this(displayName, defaultValue, permittedValues, inheritable, null);
   }
 
-  public ProjectConfigEntry(String displayName, String defaultValue,
-      List<String> permittedValues, boolean inheritable, String description) {
-    this(displayName, defaultValue, ProjectConfigEntryType.LIST,
-        permittedValues, inheritable, description);
+  public ProjectConfigEntry(
+      String displayName,
+      String defaultValue,
+      List<String> permittedValues,
+      boolean inheritable,
+      String description) {
+    this(
+        displayName,
+        defaultValue,
+        ProjectConfigEntryType.LIST,
+        permittedValues,
+        inheritable,
+        description);
   }
 
-  public <T extends Enum<?>> ProjectConfigEntry(String displayName,
-      T defaultValue, Class<T> permittedValues) {
+  public <T extends Enum<?>> ProjectConfigEntry(
+      String displayName, T defaultValue, Class<T> permittedValues) {
     this(displayName, defaultValue, permittedValues, false);
   }
 
-  public <T extends Enum<?>> ProjectConfigEntry(String displayName,
-      T defaultValue, Class<T> permittedValues, boolean inheritable) {
+  public <T extends Enum<?>> ProjectConfigEntry(
+      String displayName, T defaultValue, Class<T> permittedValues, boolean inheritable) {
     this(displayName, defaultValue, permittedValues, inheritable, null);
   }
 
-  public <T extends Enum<?>> ProjectConfigEntry(String displayName,
-      T defaultValue, Class<T> permittedValues, boolean inheritable,
+  public <T extends Enum<?>> ProjectConfigEntry(
+      String displayName,
+      T defaultValue,
+      Class<T> permittedValues,
+      boolean inheritable,
       String description) {
-    this(displayName, defaultValue.name(), ProjectConfigEntryType.LIST,
-        Lists.transform(
-            Arrays.asList(permittedValues.getEnumConstants()),
-            new Function<Enum<?>, String>() {
-              @Override
-              public String apply(Enum<?> e) {
-                return e.name();
-              }
-            }), inheritable, description);
+    this(
+        displayName,
+        defaultValue.name(),
+        ProjectConfigEntryType.LIST,
+        Arrays.stream(permittedValues.getEnumConstants()).map(Enum::name).collect(toList()),
+        inheritable,
+        description);
   }
 
-  public ProjectConfigEntry(String displayName, String defaultValue,
-      ProjectConfigEntryType type, List<String> permittedValues,
-      boolean inheritable, String description) {
+  public ProjectConfigEntry(
+      String displayName,
+      String defaultValue,
+      ProjectConfigEntryType type,
+      List<String> permittedValues,
+      boolean inheritable,
+      String description) {
     this.displayName = displayName;
     this.defaultValue = defaultValue;
     this.type = type;
@@ -157,8 +178,7 @@
     this.inheritable = inheritable;
     this.description = description;
     if (type == ProjectConfigEntryType.ARRAY && inheritable) {
-      throw new ProvisionException(
-          "ARRAY doesn't support inheritable values");
+      throw new ProvisionException("ARRAY doesn't support inheritable values");
     }
   }
 
@@ -203,9 +223,9 @@
   }
 
   /**
-   * Called before the project config is updated. To modify the value before the
-   * project config is updated, override this method and return the modified
-   * value. Default implementation returns the same value.
+   * Called before the project config is updated. To modify the value before the project config is
+   * updated, override this method and return the modified value. Default implementation returns the
+   * same value.
    *
    * @param configValue the original configValue that was entered.
    * @return the modified configValue.
@@ -215,13 +235,13 @@
   }
 
   /**
-   * Called after reading the project config value. To modify the value before
-   * returning it to the client, override this method and return the modified
-   * value. Default implementation returns the same value.
+   * Called after reading the project config value. To modify the value before returning it to the
+   * client, override this method and return the modified value. Default implementation returns the
+   * same value.
    *
    * @param project the project.
-   * @param value the actual value of the config entry (computed out of the
-   *        configured value, the inherited value and the default value).
+   * @param value the actual value of the config entry (computed out of the configured value, the
+   *     inherited value and the default value).
    * @return the modified value.
    */
   public String onRead(ProjectState project, String value) {
@@ -229,13 +249,13 @@
   }
 
   /**
-   * Called after reading the project config value of type ARRAY. To modify the
-   * values before returning it to the client, override this method and return
-   * the modified values. Default implementation returns the same values.
+   * Called after reading the project config value of type ARRAY. To modify the values before
+   * returning it to the client, override this method and return the modified values. Default
+   * implementation returns the same values.
    *
    * @param project the project.
-   * @param values the actual values of the config entry (computed out of the
-   *        configured value, the inherited value and the default value).
+   * @param values the actual values of the config entry (computed out of the configured value, the
+   *     inherited value and the default value).
    * @return the modified values.
    */
   public List<String> onRead(ProjectState project, List<String> values) {
@@ -249,8 +269,7 @@
    * @param oldValue old entry value.
    * @param newValue new entry value.
    */
-  public void onUpdate(Project.NameKey project, String oldValue, String newValue) {
-  }
+  public void onUpdate(Project.NameKey project, String oldValue, String newValue) {}
 
   /**
    * Called after a project config is updated.
@@ -259,8 +278,7 @@
    * @param oldValue old entry value.
    * @param newValue new entry value.
    */
-  public void onUpdate(Project.NameKey project, Boolean oldValue, Boolean newValue) {
-  }
+  public void onUpdate(Project.NameKey project, Boolean oldValue, Boolean newValue) {}
 
   /**
    * Called after a project config is updated.
@@ -269,8 +287,7 @@
    * @param oldValue old entry value.
    * @param newValue new entry value.
    */
-  public void onUpdate(Project.NameKey project, Integer oldValue, Integer newValue) {
-  }
+  public void onUpdate(Project.NameKey project, Integer oldValue, Integer newValue) {}
 
   /**
    * Called after a project config is updated.
@@ -279,8 +296,7 @@
    * @param oldValue old entry value.
    * @param newValue new entry value.
    */
-  public void onUpdate(Project.NameKey project, Long oldValue, Long newValue) {
-  }
+  public void onUpdate(Project.NameKey project, Long oldValue, Long newValue) {}
 
   public static class UpdateChecker implements GitReferenceUpdatedListener {
     private static final Logger log = LoggerFactory.getLogger(UpdateChecker.class);
@@ -289,8 +305,8 @@
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
 
     @Inject
-    UpdateChecker(GitRepositoryManager repoManager,
-        DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+    UpdateChecker(
+        GitRepositoryManager repoManager, DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
       this.repoManager = repoManager;
       this.pluginConfigEntries = pluginConfigEntries;
     }
@@ -334,9 +350,7 @@
           }
         }
       } catch (IOException | ConfigInvalidException e) {
-        log.error(String.format(
-            "Failed to check if plugin config of project %s was updated.",
-            p.get()), e);
+        log.error("Failed to check if plugin config of project {} was updated.", p.get(), e);
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
index 34946ec..33e68d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
@@ -22,9 +22,9 @@
 import com.google.inject.assistedinject.AssistedInject;
 
 /**
- * Provider of the group(s) which should become owners of a newly created
- * project. The only matching patterns supported are exact match or wildcard
- * matching which can be specified by ending the name with a {@code *}.
+ * Provider of the group(s) which should become owners of a newly created project. The only matching
+ * patterns supported are exact match or wildcard matching which can be specified by ending the name
+ * with a {@code *}.
  *
  * <pre>
  * [repository &quot;*&quot;]
@@ -41,8 +41,10 @@
   }
 
   @AssistedInject
-  public ProjectOwnerGroupsProvider(GroupBackend gb,
-      ThreadLocalRequestContext context, ServerRequestContext serverCtx,
+  public ProjectOwnerGroupsProvider(
+      GroupBackend gb,
+      ThreadLocalRequestContext context,
+      ServerRequestContext serverCtx,
       RepositoryConfig repositoryCfg,
       @Assisted Project.NameKey project) {
     super(gb, context, serverCtx, repositoryCfg.getOwnerGroups(project));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java
index e250395..4f35fc7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -19,13 +19,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class RepositoryConfig {
@@ -43,18 +41,20 @@
   }
 
   public SubmitType getDefaultSubmitType(Project.NameKey project) {
-    return cfg.getEnum(SECTION_NAME, findSubSection(project.get()),
-        DEFAULT_SUBMIT_TYPE_NAME, SubmitType.MERGE_IF_NECESSARY);
+    return cfg.getEnum(
+        SECTION_NAME,
+        findSubSection(project.get()),
+        DEFAULT_SUBMIT_TYPE_NAME,
+        SubmitType.MERGE_IF_NECESSARY);
   }
 
   public List<String> getOwnerGroups(Project.NameKey project) {
-    return ImmutableList.copyOf(cfg.getStringList(SECTION_NAME,
-        findSubSection(project.get()), OWNER_GROUP_NAME));
+    return ImmutableList.copyOf(
+        cfg.getStringList(SECTION_NAME, findSubSection(project.get()), OWNER_GROUP_NAME));
   }
 
   public Path getBasePath(Project.NameKey project) {
-    String basePath = cfg.getString(SECTION_NAME, findSubSection(project.get()),
-        BASE_PATH_NAME);
+    String basePath = cfg.getString(SECTION_NAME, findSubSection(project.get()), BASE_PATH_NAME);
     return basePath != null ? Paths.get(basePath) : null;
   }
 
@@ -71,10 +71,9 @@
 
   /**
    * Find the subSection to get repository configuration from.
-   * <p>
-   * SubSection can use the * pattern so if project name matches more than one
-   * section, return the more precise one. E.g if the following subSections are
-   * defined:
+   *
+   * <p>SubSection can use the * pattern so if project name matches more than one section, return
+   * the more precise one. E.g if the following subSections are defined:
    *
    * <pre>
    * [repository "somePath/*"]
@@ -83,8 +82,8 @@
    *   name = value
    * </pre>
    *
-   * and this method is called with "somePath/somePath/someProject" as project
-   * name, it will return the subSection "somePath/somePath/*"
+   * and this method is called with "somePath/somePath/someProject" as project name, it will return
+   * the subSection "somePath/somePath/*"
    *
    * @param project Name of the project
    * @return the name of the subSection, null if none is found
@@ -93,8 +92,7 @@
     String subSectionFound = null;
     for (String subSection : cfg.getSubsections(SECTION_NAME)) {
       if (isMatch(subSection, project)
-          && (subSectionFound == null || subSectionFound.length() < subSection
-              .length())) {
+          && (subSectionFound == null || subSectionFound.length() < subSection.length())) {
         subSectionFound = subSection;
       }
     }
@@ -103,7 +101,7 @@
 
   private boolean isMatch(String subSection, String project) {
     return project.equals(subSection)
-        || (subSection.endsWith("*") && project.startsWith(subSection
-            .substring(0, subSection.length() - 1)));
+        || (subSection.endsWith("*")
+            && project.startsWith(subSection.substring(0, subSection.length() - 1)));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
index c2c002e..3987aed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
@@ -31,8 +31,8 @@
   private ReviewDb db;
 
   @Inject
-  public RequestScopedReviewDbProvider(final SchemaFactory<ReviewDb> schema,
-      final Provider<RequestCleanup> cleanup) {
+  public RequestScopedReviewDbProvider(
+      final SchemaFactory<ReviewDb> schema, final Provider<RequestCleanup> cleanup) {
     this.schema = schema;
     this.cleanup = cleanup;
   }
@@ -48,13 +48,16 @@
         throw new ProvisionException("Cannot open ReviewDb", e);
       }
       try {
-        cleanup.get().add(new Runnable() {
-          @Override
-          public void run() {
-            c.close();
-            db = null;
-          }
-        });
+        cleanup
+            .get()
+            .add(
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    c.close();
+                    db = null;
+                  }
+                });
       } catch (Throwable e) {
         c.close();
         throw new ProvisionException("Cannot defer cleanup of ReviewDb", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
index a2835a6..4a87474 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ScheduleConfig.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.annotations.VisibleForTesting;
-
+import java.text.MessageFormat;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.joda.time.DateTime;
 import org.joda.time.LocalDateTime;
@@ -27,13 +29,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.text.MessageFormat;
-import java.util.Locale;
-import java.util.concurrent.TimeUnit;
-
 public class ScheduleConfig {
-  private static final Logger log = LoggerFactory
-      .getLogger(ScheduleConfig.class);
+  private static final Logger log = LoggerFactory.getLogger(ScheduleConfig.class);
   public static final long MISSING_CONFIG = -1L;
   public static final long INVALID_CONFIG = -2L;
   private static final String KEY_INTERVAL = "interval";
@@ -55,8 +52,8 @@
     this(rc, section, subsection, DateTime.now());
   }
 
-  public ScheduleConfig(Config rc, String section, String subsection,
-      String keyInterval, String keyStartTime) {
+  public ScheduleConfig(
+      Config rc, String section, String subsection, String keyInterval, String keyStartTime) {
     this(rc, section, subsection, keyInterval, keyStartTime, DateTime.now());
   }
 
@@ -66,8 +63,13 @@
   }
 
   @VisibleForTesting
-  ScheduleConfig(Config rc, String section, String subsection,
-      String keyInterval, String keyStartTime, DateTime now) {
+  ScheduleConfig(
+      Config rc,
+      String section,
+      String subsection,
+      String keyInterval,
+      String keyStartTime,
+      DateTime now) {
     this.rc = rc;
     this.section = section;
     this.subsection = subsection;
@@ -75,8 +77,7 @@
     this.keyStartTime = keyStartTime;
     this.interval = interval(rc, section, subsection, keyInterval);
     if (interval > 0) {
-      this.initialDelay = initialDelay(rc, section, subsection, keyStartTime, now,
-          interval);
+      this.initialDelay = initialDelay(rc, section, subsection, keyStartTime, now, interval);
     } else {
       this.initialDelay = interval;
     }
@@ -84,10 +85,9 @@
 
   /**
    * Milliseconds between constructor invocation and first event time.
-   * <p>
-   * If there is any lag between the constructor invocation and queuing the
-   * object into an executor the event will run later, as there is no method
-   * to adjust for the scheduling delay.
+   *
+   * <p>If there is any lag between the constructor invocation and queuing the object into an
+   * executor the event will run later, as there is no method to adjust for the scheduling delay.
    */
   public long getInitialDelay() {
     return initialDelay;
@@ -98,29 +98,32 @@
     return interval;
   }
 
-  private static long interval(Config rc, String section, String subsection,
-      String keyInterval) {
+  private static long interval(Config rc, String section, String subsection, String keyInterval) {
     long interval = MISSING_CONFIG;
     try {
       interval =
-          ConfigUtil.getTimeUnit(rc, section, subsection, keyInterval, -1,
-              TimeUnit.MILLISECONDS);
+          ConfigUtil.getTimeUnit(rc, section, subsection, keyInterval, -1, TimeUnit.MILLISECONDS);
       if (interval == MISSING_CONFIG) {
-        log.info(MessageFormat.format(
-            "{0} schedule parameter \"{0}.{1}\" is not configured", section,
-            keyInterval));
+        log.info(
+            MessageFormat.format(
+                "{0} schedule parameter \"{0}.{1}\" is not configured", section, keyInterval));
       }
     } catch (IllegalArgumentException e) {
-      log.error(MessageFormat.format(
-          "Invalid {0} schedule parameter \"{0}.{1}\"", section, keyInterval),
+      log.error(
+          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"", section, keyInterval),
           e);
       interval = INVALID_CONFIG;
     }
     return interval;
   }
 
-  private static long initialDelay(Config rc, String section,
-      String subsection, String keyStartTime, DateTime now, long interval) {
+  private static long initialDelay(
+      Config rc,
+      String section,
+      String subsection,
+      String keyStartTime,
+      DateTime now,
+      long interval) {
     long delay = MISSING_CONFIG;
     String start = rc.getString(section, subsection, keyStartTime);
     try {
@@ -133,8 +136,7 @@
           startTime.hourOfDay().set(firstStartTime.getHourOfDay());
           startTime.minuteOfHour().set(firstStartTime.getMinuteOfHour());
         } catch (IllegalArgumentException e1) {
-          formatter = DateTimeFormat.forPattern("E HH:mm")
-              .withLocale(Locale.US);
+          formatter = DateTimeFormat.forPattern("E HH:mm").withLocale(Locale.US);
           LocalDateTime firstStartDateTime = formatter.parseLocalDateTime(start);
           startTime.dayOfWeek().set(firstStartDateTime.getDayOfWeek());
           startTime.hourOfDay().set(firstStartDateTime.getHourOfDay());
@@ -149,14 +151,14 @@
           delay += interval;
         }
       } else {
-        log.info(MessageFormat.format(
-            "{0} schedule parameter \"{0}.{1}\" is not configured", section,
-            keyStartTime));
+        log.info(
+            MessageFormat.format(
+                "{0} schedule parameter \"{0}.{1}\" is not configured", section, keyStartTime));
       }
     } catch (IllegalArgumentException e2) {
       log.error(
-          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"",
-              section, keyStartTime), e2);
+          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"", section, keyStartTime),
+          e2);
       delay = INVALID_CONFIG;
     }
     return delay;
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
index 8ca072a..80c4625 100644
--- 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
@@ -30,28 +30,25 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.lang.reflect.Field;
 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);
+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,
+  SetDiffPreferences(
+      GitRepositoryManager gitManager,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName) {
     this.gitManager = gitManager;
@@ -60,9 +57,8 @@
   }
 
   @Override
-  public DiffPreferencesInfo apply(ConfigResource configResource,
-      DiffPreferencesInfo in)
-          throws BadRequestException, IOException, ConfigInvalidException {
+  public DiffPreferencesInfo apply(ConfigResource configResource, DiffPreferencesInfo in)
+      throws BadRequestException, IOException, ConfigInvalidException {
     if (in == null) {
       throw new BadRequestException("input must be provided");
     }
@@ -76,15 +72,18 @@
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     DiffPreferencesInfo out = new DiffPreferencesInfo();
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
-      VersionedAccountPreferences prefs =
-          VersionedAccountPreferences.forDefault();
+      VersionedAccountPreferences prefs = VersionedAccountPreferences.forDefault();
       prefs.load(md);
       DiffPreferencesInfo defaults = DiffPreferencesInfo.defaults();
-      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in,
-          defaults);
+      storeSection(prefs.getConfig(), UserConfigSections.DIFF, null, in, defaults);
       prefs.commit(md);
-      loadSection(prefs.getConfig(), UserConfigSections.DIFF, null, out,
-          DiffPreferencesInfo.defaults(), null);
+      loadSection(
+          prefs.getConfig(),
+          UserConfigSections.DIFF,
+          null,
+          out,
+          DiffPreferencesInfo.defaults(),
+          null);
     }
     return out;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
index c5c75ee..4792131 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
@@ -33,21 +33,17 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.lang.reflect.Field;
 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 SetPreferences implements
-    RestModifyView<ConfigResource, GeneralPreferencesInfo> {
-  private static final Logger log =
-      LoggerFactory.getLogger(SetPreferences.class);
+public class SetPreferences implements RestModifyView<ConfigResource, GeneralPreferencesInfo> {
+  private static final Logger log = LoggerFactory.getLogger(SetPreferences.class);
 
   private final GeneralPreferencesLoader loader;
   private final GitRepositoryManager gitManager;
@@ -56,7 +52,8 @@
   private final AccountCache accountCache;
 
   @Inject
-  SetPreferences(GeneralPreferencesLoader loader,
+  SetPreferences(
+      GeneralPreferencesLoader loader,
       GitRepositoryManager gitManager,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
@@ -69,9 +66,8 @@
   }
 
   @Override
-  public GeneralPreferencesInfo apply(ConfigResource rsrc,
-      GeneralPreferencesInfo i)
-          throws BadRequestException, IOException, ConfigInvalidException {
+  public GeneralPreferencesInfo apply(ConfigResource rsrc, GeneralPreferencesInfo i)
+      throws BadRequestException, IOException, ConfigInvalidException {
     if (!hasSetFields(i)) {
       throw new BadRequestException("unsupported option");
     }
@@ -83,22 +79,26 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       VersionedAccountPreferences p = VersionedAccountPreferences.forDefault();
       p.load(md);
-      storeSection(p.getConfig(), UserConfigSections.GENERAL, null, i,
-          GeneralPreferencesInfo.defaults());
+      storeSection(
+          p.getConfig(), UserConfigSections.GENERAL, null, i, GeneralPreferencesInfo.defaults());
       com.google.gerrit.server.account.SetPreferences.storeMyMenus(p, i.my);
       com.google.gerrit.server.account.SetPreferences.storeUrlAliases(p, i.urlAliases);
       p.commit(md);
 
       accountCache.evictAll();
 
-      GeneralPreferencesInfo r = loadSection(p.getConfig(),
-          UserConfigSections.GENERAL, null, new GeneralPreferencesInfo(),
-          GeneralPreferencesInfo.defaults(), null);
+      GeneralPreferencesInfo r =
+          loadSection(
+              p.getConfig(),
+              UserConfigSections.GENERAL,
+              null,
+              new GeneralPreferencesInfo(),
+              GeneralPreferencesInfo.defaults(),
+              null);
       return loader.loadMyMenusAndUrlAliases(r, p, null);
     }
   }
 
-
   private static boolean hasSetFields(GeneralPreferencesInfo in) {
     try {
       for (Field field : in.getClass().getDeclaredFields()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePath.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePath.java
index d0b3905..a1e66fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePath.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePath.java
@@ -17,15 +17,13 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
  * Marker on a {@link java.io.File} pointing to the site path.
- * <p>
- * The site path is where Gerrit Code Review stores most of its configuration.
+ *
+ * <p>The site path is where Gerrit Code Review stores most of its configuration.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface SitePath {
-}
+public @interface SitePath {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
index 192ca49..87f22e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.Iterables;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
@@ -47,6 +46,8 @@
   public final Path index_dir;
 
   public final Path gerrit_sh;
+  public final Path gerrit_service;
+  public final Path gerrit_socket;
   public final Path gerrit_war;
 
   public final Path gerrit_config;
@@ -56,6 +57,10 @@
   public final Path ssh_key;
   public final Path ssh_rsa;
   public final Path ssh_dsa;
+  public final Path ssh_ecdsa_256;
+  public final Path ssh_ecdsa_384;
+  public final Path ssh_ecdsa_521;
+  public final Path ssh_ed25519;
   public final Path peer_keys;
 
   public final Path site_css;
@@ -86,6 +91,8 @@
     index_dir = p.resolve("index");
 
     gerrit_sh = bin_dir.resolve("gerrit.sh");
+    gerrit_service = bin_dir.resolve("gerrit.service");
+    gerrit_socket = bin_dir.resolve("gerrit.socket");
     gerrit_war = bin_dir.resolve("gerrit.war");
 
     gerrit_config = etc_dir.resolve("gerrit.config");
@@ -95,6 +102,10 @@
     ssh_key = etc_dir.resolve("ssh_host_key");
     ssh_rsa = etc_dir.resolve("ssh_host_rsa_key");
     ssh_dsa = etc_dir.resolve("ssh_host_dsa_key");
+    ssh_ecdsa_256 = etc_dir.resolve("ssh_host_ecdsa_key");
+    ssh_ecdsa_384 = etc_dir.resolve("ssh_host_ecdsa_384_key");
+    ssh_ecdsa_521 = etc_dir.resolve("ssh_host_ecdsa_521_key");
+    ssh_ed25519 = etc_dir.resolve("ssh_host_ed25519_key");
     peer_keys = etc_dir.resolve("peer_keys");
 
     site_css = etc_dir.resolve(CSS_FILENAME);
@@ -113,8 +124,8 @@
 
   /**
    * Resolve an absolute or relative path.
-   * <p>
-   * Relative paths are resolved relative to the {@link #site_path}.
+   *
+   * <p>Relative paths are resolved relative to the {@link #site_path}.
    *
    * @param path the path string to resolve. May be null.
    * @return the resolved path; null if {@code path} was null or empty.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
index b92cfc2..b239856 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
@@ -32,8 +32,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class TasksCollection implements
-    ChildCollection<ConfigResource, TaskResource> {
+public class TasksCollection implements ChildCollection<ConfigResource, TaskResource> {
   private final DynamicMap<RestView<TaskResource>> views;
   private final ListTasks list;
   private final WorkQueue workQueue;
@@ -41,8 +40,11 @@
   private final ProjectCache projectCache;
 
   @Inject
-  TasksCollection(DynamicMap<RestView<TaskResource>> views, ListTasks list,
-      WorkQueue workQueue, Provider<IdentifiedUser> self,
+  TasksCollection(
+      DynamicMap<RestView<TaskResource>> views,
+      ListTasks list,
+      WorkQueue workQueue,
+      Provider<IdentifiedUser> self,
       ProjectCache projectCache) {
     this.views = views;
     this.list = list;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
index c62583e..6cb32cc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ThreadSettingsConfig.java
@@ -16,7 +16,6 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -32,8 +31,7 @@
     sshdThreads = cfg.getInt("sshd", "threads", 2 * cores);
     httpdMaxThreads = cfg.getInt("httpd", "maxThreads", 25);
     int defaultDatabasePoolLimit = sshdThreads + httpdMaxThreads + 2;
-    databasePoolLimit =
-        cfg.getInt("database", "poolLimit", defaultDatabasePoolLimit);
+    databasePoolLimit = cfg.getInt("database", "poolLimit", defaultDatabasePoolLimit);
     sshdBatchThreads = cores == 1 ? 1 : 2;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuCollection.java
index 32416c52..2fc2dc1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TopMenuCollection.java
@@ -23,14 +23,12 @@
 import com.google.inject.Singleton;
 
 @Singleton
-class TopMenuCollection implements
-    ChildCollection<ConfigResource, TopMenuResource> {
+class TopMenuCollection implements ChildCollection<ConfigResource, TopMenuResource> {
   private final DynamicMap<RestView<TopMenuResource>> views;
   private final ListTopMenus list;
 
   @Inject
-  TopMenuCollection(DynamicMap<RestView<TopMenuResource>> views,
-      ListTopMenus list) {
+  TopMenuCollection(DynamicMap<RestView<TopMenuResource>> views, ListTopMenus list) {
     this.views = views;
     this.list = list;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java
index aeedecd..ac2f0c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.config;
 
-import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.revwalk.FooterLine;
-
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.FooterLine;
 
 /** Tracking entry in the configuration file */
 public class TrackingFooter {
@@ -26,8 +25,7 @@
   private final Pattern match;
   private final String system;
 
-  public TrackingFooter(String f, final String m, final String s)
-      throws PatternSyntaxException {
+  public TrackingFooter(String f, final String m, final String s) throws PatternSyntaxException {
     f = f.trim();
     if (f.endsWith(":")) {
       f = f.substring(0, f.length() - 1);
@@ -54,7 +52,6 @@
 
   @Override
   public String toString() {
-    return "footer = " + key.getName() + ", match = " + match.pattern()
-        + ", system = " + system;
+    return "footer = " + key.getName() + ", match = " + match.pattern() + ", system = " + system;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java
index 672c461..a897bdc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java
@@ -14,18 +14,16 @@
 
 package com.google.gerrit.server.config;
 
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
-
-import org.eclipse.jgit.revwalk.FooterLine;
-
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import java.util.List;
 import java.util.regex.Matcher;
+import org.eclipse.jgit.revwalk.FooterLine;
 
 public class TrackingFooters {
   protected List<TrackingFooter> trackingFooters;
 
-  public TrackingFooters (final List<TrackingFooter> trFooters) {
+  public TrackingFooters(final List<TrackingFooter> trFooters) {
     trackingFooters = trFooters;
   }
 
@@ -37,8 +35,8 @@
     return trackingFooters.isEmpty();
   }
 
-  public Multimap<String, String> extract(List<FooterLine> lines) {
-    Multimap<String, String> r = ArrayListMultimap.create();
+  public ListMultimap<String, String> extract(List<FooterLine> lines) {
+    ListMultimap<String, String> r = MultimapBuilder.hashKeys().arrayListValues().build();
     if (lines == null) {
       return r;
     }
@@ -48,9 +46,7 @@
         if (footer.matches(config.footerKey())) {
           Matcher m = config.match().matcher(footer.getValue());
           while (m.find()) {
-            String id = m.groupCount() > 0
-                ? m.group(1)
-                : m.group();
+            String id = m.groupCount() > 0 ? m.group(1) : m.group();
             if (!id.isEmpty()) {
               r.put(config.system(), id);
             }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java
index 4c1bde9..103cb9a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java
@@ -18,11 +18,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -30,6 +25,9 @@
 import java.util.List;
 import java.util.Set;
 import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Provides a list of all configured {@link TrackingFooter}s. */
 @Singleton
@@ -39,41 +37,49 @@
   private static String SYSTEM_TAG = "system";
   private static String REGEX_TAG = "match";
   private final List<TrackingFooter> trackingFooters = new ArrayList<>();
-  private static final Logger log =
-      LoggerFactory.getLogger(TrackingFootersProvider.class);
+  private static final Logger log = LoggerFactory.getLogger(TrackingFootersProvider.class);
 
   @Inject
   TrackingFootersProvider(@GerritServerConfig final Config cfg) {
     for (String name : cfg.getSubsections(TRACKING_ID_TAG)) {
       boolean configValid = true;
 
-      Set<String> footers = new HashSet<>(
-          Arrays.asList(cfg.getStringList(TRACKING_ID_TAG, name, FOOTER_TAG)));
+      Set<String> footers =
+          new HashSet<>(Arrays.asList(cfg.getStringList(TRACKING_ID_TAG, name, FOOTER_TAG)));
       footers.removeAll(Collections.singleton(null));
 
       if (footers.isEmpty()) {
         configValid = false;
-        log.error("Missing " + TRACKING_ID_TAG + "." + name + "." + FOOTER_TAG
-            + " in gerrit.config");
+        log.error(
+            "Missing " + TRACKING_ID_TAG + "." + name + "." + FOOTER_TAG + " in gerrit.config");
       }
 
       String system = cfg.getString(TRACKING_ID_TAG, name, SYSTEM_TAG);
       if (system == null || system.isEmpty()) {
         configValid = false;
-        log.error("Missing " + TRACKING_ID_TAG + "." + name + "." + SYSTEM_TAG
-            + " in gerrit.config");
+        log.error(
+            "Missing " + TRACKING_ID_TAG + "." + name + "." + SYSTEM_TAG + " in gerrit.config");
       } else if (system.length() > TrackingId.TRACKING_SYSTEM_MAX_CHAR) {
         configValid = false;
-        log.error("String to long \"" + system + "\" in gerrit.config "
-            + TRACKING_ID_TAG + "." + name + "." + SYSTEM_TAG + " (max "
-            + TrackingId.TRACKING_SYSTEM_MAX_CHAR + " char)");
+        log.error(
+            "String too long \""
+                + system
+                + "\" in gerrit.config "
+                + TRACKING_ID_TAG
+                + "."
+                + name
+                + "."
+                + SYSTEM_TAG
+                + " (max "
+                + TrackingId.TRACKING_SYSTEM_MAX_CHAR
+                + " char)");
       }
 
       String match = cfg.getString(TRACKING_ID_TAG, name, REGEX_TAG);
       if (match == null || match.isEmpty()) {
         configValid = false;
-        log.error("Missing " + TRACKING_ID_TAG + "." + name + "." + REGEX_TAG
-            + " in gerrit.config");
+        log.error(
+            "Missing " + TRACKING_ID_TAG + "." + name + "." + REGEX_TAG + " in gerrit.config");
       }
 
       if (configValid) {
@@ -82,9 +88,17 @@
             trackingFooters.add(new TrackingFooter(footer, match, system));
           }
         } catch (PatternSyntaxException e) {
-          log.error("Invalid pattern \"" + match + "\" in gerrit.config "
-              + TRACKING_ID_TAG + "." + name + "." + REGEX_TAG + ": "
-              + e.getMessage());
+          log.error(
+              "Invalid pattern \""
+                  + match
+                  + "\" in gerrit.config "
+                  + TRACKING_ID_TAG
+                  + "."
+                  + name
+                  + "."
+                  + REGEX_TAG
+                  + ": "
+                  + e.getMessage());
         }
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java
index f328b1ff..96467a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/VerboseSuperprojectUpdate.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.server.config;
 
-/**
- * Verbosity level of the commit message for submodule subscriptions.
- */
+/** Verbosity level of the commit message for submodule subscriptions. */
 public enum VerboseSuperprojectUpdate {
   /** Do not include any commit messages for the gitlink update. */
   FALSE,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/AccountAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/AccountAttribute.java
index e5627c2..19605a2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/AccountAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/AccountAttribute.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.data;
 
 public class AccountAttribute {
-    public String name;
-    public String email;
-    public String username;
+  public String name;
+  public String email;
+  public String username;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
index 90b6fc4..8928a5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/ApprovalAttribute.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.data;
 
 public class ApprovalAttribute {
-    public String type;
-    public String description;
-    public String value;
-    public String oldValue;
-    public Long grantedOn;
-    public AccountAttribute by;
+  public String type;
+  public String description;
+  public String value;
+  public String oldValue;
+  public Long grantedOn;
+  public AccountAttribute by;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
index 8c18514..1a8a788 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -15,32 +15,32 @@
 package com.google.gerrit.server.data;
 
 import com.google.gerrit.reviewdb.client.Change;
-
 import java.util.List;
 
 public class ChangeAttribute {
-    public String project;
-    public String branch;
-    public String topic;
-    public String id;
-    public String number;
-    public String subject;
-    public AccountAttribute owner;
-    public String url;
-    public String commitMessage;
+  public String project;
+  public String branch;
+  public String topic;
+  public String id;
+  public int number;
+  public String subject;
+  public AccountAttribute owner;
+  public AccountAttribute assignee;
+  public String url;
+  public String commitMessage;
 
-    public Long createdOn;
-    public Long lastUpdated;
-    public Boolean open;
-    public Change.Status status;
-    public List<MessageAttribute> comments;
+  public Long createdOn;
+  public Long lastUpdated;
+  public Boolean open;
+  public Change.Status status;
+  public List<MessageAttribute> comments;
 
-    public List<TrackingIdAttribute> trackingIds;
-    public PatchSetAttribute currentPatchSet;
-    public List<PatchSetAttribute> patchSets;
+  public List<TrackingIdAttribute> trackingIds;
+  public PatchSetAttribute currentPatchSet;
+  public List<PatchSetAttribute> patchSets;
 
-    public List<DependencyAttribute> dependsOn;
-    public List<DependencyAttribute> neededBy;
-    public List<SubmitRecordAttribute> submitRecords;
-    public List<AccountAttribute> allReviewers;
+  public List<DependencyAttribute> dependsOn;
+  public List<DependencyAttribute> neededBy;
+  public List<SubmitRecordAttribute> submitRecords;
+  public List<AccountAttribute> allReviewers;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/DependencyAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/DependencyAttribute.java
index 4c796f2..3b7820c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/DependencyAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/DependencyAttribute.java
@@ -16,7 +16,7 @@
 
 public class DependencyAttribute {
   public String id;
-  public String number;
+  public int number;
   public String revision;
   public String ref;
   public Boolean isCurrentPatchSet;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/MessageAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/MessageAttribute.java
index f18beba..6837d44 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/MessageAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/MessageAttribute.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.data;
 
 public class MessageAttribute {
-    public Long timestamp;
-    public AccountAttribute reviewer;
-    public String message;
+  public Long timestamp;
+  public AccountAttribute reviewer;
+  public String message;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchAttribute.java
index 12ac30a..22f18af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchAttribute.java
@@ -17,9 +17,9 @@
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 
 public class PatchAttribute {
-    public String file;
-    public String fileOld;
-    public ChangeType type;
-    public int insertions;
-    public int deletions;
+  public String file;
+  public String fileOld;
+  public ChangeType type;
+  public int insertions;
+  public int deletions;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
index 824d800..d3b3786 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.data;
 
 import com.google.gerrit.extensions.client.ChangeKind;
-
 import java.util.List;
 
 public class PatchSetAttribute {
-  public String number;
+  public int number;
   public String revision;
   public List<String> parents;
   public String ref;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetCommentAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetCommentAttribute.java
index 7610068..d004e6c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetCommentAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetCommentAttribute.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.data;
 
 public class PatchSetCommentAttribute {
-    public String file;
-    public Integer line;
-    public AccountAttribute reviewer;
-    public String message;
+  public String file;
+  public Integer line;
+  public AccountAttribute reviewer;
+  public String message;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitLabelAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
index 4c774c2..1b3c6a48 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.data;
 
 public class SubmitLabelAttribute {
-    public String label;
-    public String status;
-    public AccountAttribute by;
+  public String label;
+  public String status;
+  public AccountAttribute by;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitRecordAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
index 1ce2ce6..fec870f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
@@ -17,6 +17,6 @@
 import java.util.List;
 
 public class SubmitRecordAttribute {
-    public String status;
-    public List<SubmitLabelAttribute> labels;
+  public String status;
+  public List<SubmitLabelAttribute> labels;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 1d9c795..68d2a34 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -20,7 +20,13 @@
 import static org.pegdown.Extensions.SUPPRESS_ALL_HTML;
 
 import com.google.common.base.Strings;
-
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
@@ -34,17 +40,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.UnsupportedEncodingException;
-import java.net.URL;
-import java.nio.charset.Charset;
-import java.util.concurrent.atomic.AtomicBoolean;
-
 public class MarkdownFormatter {
-  private static final Logger log =
-      LoggerFactory.getLogger(MarkdownFormatter.class);
+  private static final Logger log = LoggerFactory.getLogger(MarkdownFormatter.class);
 
   private static final String defaultCss;
 
@@ -85,8 +82,7 @@
     return this;
   }
 
-  public byte[] markdownToDocHtml(String md, String charEnc)
-      throws UnsupportedEncodingException {
+  public byte[] markdownToDocHtml(String md, String charEnc) throws UnsupportedEncodingException {
     RootNode root = parseMarkdown(md);
     String title = findTitle(root);
 
@@ -118,9 +114,7 @@
   private String findTitle(Node root) {
     if (root instanceof HeaderNode) {
       HeaderNode h = (HeaderNode) root;
-      if (h.getLevel() == 1
-          && h.getChildren() != null
-          && !h.getChildren().isEmpty()) {
+      if (h.getLevel() == 1 && h.getChildren() != null && !h.getChildren().isEmpty()) {
         StringBuilder b = new StringBuilder();
         for (Node n : root.getChildren()) {
           if (n instanceof TextNode) {
@@ -145,12 +139,10 @@
     if (suppressHtml) {
       options |= SUPPRESS_ALL_HTML;
     }
-    return new PegDownProcessor(options)
-        .parseMarkdown(md.toCharArray());
+    return new PegDownProcessor(options).parseMarkdown(md.toCharArray());
   }
 
-  private static String readPegdownCss(AtomicBoolean file)
-      throws IOException {
+  private static String readPegdownCss(AtomicBoolean file) throws IOException {
     String name = "pegdown.css";
     URL url = MarkdownFormatter.class.getResource(name);
     if (url == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 9b15a42..eef6d35 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -18,7 +18,12 @@
 import com.google.common.collect.Lists;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
 import org.apache.lucene.analysis.standard.StandardAnalyzer;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.DirectoryReader;
@@ -34,21 +39,14 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.List;
-import java.util.Map;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
 @Singleton
 public class QueryDocumentationExecutor {
-  private static final Logger log =
-      LoggerFactory.getLogger(QueryDocumentationExecutor.class);
+  private static final Logger log = LoggerFactory.getLogger(QueryDocumentationExecutor.class);
 
-  private static Map<String, Float> WEIGHTS = ImmutableMap.of(
-      Constants.TITLE_FIELD, 2.0f,
-      Constants.DOC_FIELD, 1.0f);
+  private static Map<String, Float> WEIGHTS =
+      ImmutableMap.of(
+          Constants.TITLE_FIELD, 2.0f,
+          Constants.DOC_FIELD, 1.0f);
 
   private IndexSearcher searcher;
   private SimpleQueryParser parser;
@@ -134,8 +132,7 @@
 
   @SuppressWarnings("serial")
   public static class DocQueryException extends Exception {
-    DocQueryException() {
-    }
+    DocQueryException() {}
 
     DocQueryException(String msg) {
       super(msg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
index b7bb360..a6464a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -21,18 +21,16 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.IdentifiedUser;
-
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * A single user's edit for a change.
- * <p>
- * There is max. one edit per user per change. Edits are stored on refs:
- * refs/users/UU/UUUU/edit-CCCC/P where UU/UUUU is sharded representation
- * of user account, CCCC is change number and P is the patch set number it
- * is based on.
+ *
+ * <p>There is max. one edit per user per change. Edits are stored on refs:
+ * refs/users/UU/UUUU/edit-CCCC/P where UU/UUUU is sharded representation of user account, CCCC is
+ * change number and P is the patch set number it is based on.
  */
 public class ChangeEdit {
   private final IdentifiedUser user;
@@ -41,8 +39,8 @@
   private final RevCommit editCommit;
   private final PatchSet basePatchSet;
 
-  public ChangeEdit(IdentifiedUser user, Change change, Ref ref,
-      RevCommit editCommit, PatchSet basePatchSet) {
+  public ChangeEdit(
+      IdentifiedUser user, Change change, Ref ref, RevCommit editCommit, PatchSet basePatchSet) {
     checkNotNull(user);
     checkNotNull(change);
     checkNotNull(ref);
@@ -72,8 +70,7 @@
   }
 
   public String getRefName() {
-    return RefNames.refsEdit(user.getAccountId(), change.getId(),
-        basePatchSet.getId());
+    return RefNames.refsEdit(user.getAccountId(), change.getId(), basePatchSet.getId());
   }
 
   public RevCommit getEditCommit() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
index be9e1b5..78baef7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
@@ -26,12 +26,10 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.revwalk.RevCommit;
-
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class ChangeEditJson {
@@ -40,7 +38,8 @@
   private final Provider<CurrentUser> userProvider;
 
   @Inject
-  ChangeEditJson(DynamicMap<DownloadCommand> downloadCommand,
+  ChangeEditJson(
+      DynamicMap<DownloadCommand> downloadCommand,
       DynamicMap<DownloadScheme> downloadSchemes,
       Provider<CurrentUser> userProvider) {
     this.downloadCommands = downloadCommand;
@@ -52,6 +51,7 @@
     EditInfo out = new EditInfo();
     out.commit = fillCommit(edit.getEditCommit());
     out.baseRevision = edit.getBasePatchSet().getRevision().get();
+    out.basePatchSetNumber = edit.getBasePatchSet().getPatchSetId();
     if (downloadCommands) {
       out.fetch = fillFetchMap(edit);
     }
@@ -62,8 +62,7 @@
     CommitInfo commit = new CommitInfo();
     commit.commit = editCommit.toObjectId().getName();
     commit.author = CommonConverters.toGitPerson(editCommit.getAuthorIdent());
-    commit.committer = CommonConverters.toGitPerson(
-        editCommit.getCommitterIdent());
+    commit.committer = CommonConverters.toGitPerson(editCommit.getCommitterIdent());
     commit.subject = editCommit.getShortMessage();
     commit.message = editCommit.getFullMessage();
 
@@ -83,8 +82,7 @@
       String schemeName = e.getExportName();
       DownloadScheme scheme = e.getProvider().get();
       if (!scheme.isEnabled()
-          || (scheme.isAuthRequired()
-              && !userProvider.get().isIdentifiedUser())) {
+          || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
         continue;
       }
 
@@ -98,8 +96,7 @@
       FetchInfo fetchInfo = new FetchInfo(scheme.getUrl(projectName), refName);
       r.put(schemeName, fetchInfo);
 
-      ChangeJson.populateFetchMap(scheme, downloadCommands, projectName,
-          refName, fetchInfo);
+      ChangeJson.populateFetchMap(scheme, downloadCommands, projectName, refName, fetchInfo);
     }
 
     return r;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 45128bd..43b8d5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -14,527 +14,521 @@
 
 package com.google.gerrit.server.edit;
 
-import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.base.Strings;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.RenameFileModification;
+import com.google.gerrit.server.edit.tree.RestoreFileModification;
+import com.google.gerrit.server.edit.tree.TreeCreator;
+import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEditor;
-import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
-import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Optional;
+import java.util.TimeZone;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.merge.MergeStrategy;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.treewalk.TreeWalk;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.sql.Timestamp;
-import java.util.Map;
-import java.util.TimeZone;
-import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * Utility functions to manipulate change edits.
- * <p>
- * This class contains methods to modify edit's content.
- * For retrieving, publishing and deleting edit see
- * {@link ChangeEditUtil}.
+ *
+ * <p>This class contains methods to modify edit's content. For retrieving, publishing and deleting
+ * edit see {@link ChangeEditUtil}.
+ *
  * <p>
  */
 @Singleton
 public class ChangeEditModifier {
 
-  private enum TreeOperation {
-    CHANGE_ENTRY,
-    DELETE_ENTRY,
-    RENAME_ENTRY,
-    RESTORE_ENTRY
-  }
   private final TimeZone tz;
-  private final GitRepositoryManager gitManager;
   private final ChangeIndexer indexer;
   private final Provider<ReviewDb> reviewDb;
   private final Provider<CurrentUser> currentUser;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeEditUtil changeEditUtil;
+  private final PatchSetUtil patchSetUtil;
 
   @Inject
-  ChangeEditModifier(@GerritPersonIdent PersonIdent gerritIdent,
-      GitRepositoryManager gitManager,
+  ChangeEditModifier(
+      @GerritPersonIdent PersonIdent gerritIdent,
       ChangeIndexer indexer,
       Provider<ReviewDb> reviewDb,
       Provider<CurrentUser> currentUser,
-      ChangeControl.GenericFactory changeControlFactory) {
-    this.gitManager = gitManager;
+      ChangeEditUtil changeEditUtil,
+      PatchSetUtil patchSetUtil) {
     this.indexer = indexer;
     this.reviewDb = reviewDb;
     this.currentUser = currentUser;
     this.tz = gerritIdent.getTimeZone();
-    this.changeControlFactory = changeControlFactory;
+    this.changeEditUtil = changeEditUtil;
+    this.patchSetUtil = patchSetUtil;
   }
 
   /**
-   * Create new change edit.
+   * Creates a new change edit.
    *
-   * @param change to create change edit for
-   * @param ps patch set to create change edit on
-   * @return result
-   * @throws AuthException
-   * @throws IOException
-   * @throws ResourceConflictException When change edit already
-   * exists for the change
-   * @throws OrmException
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change for which the change edit should
+   *     be created
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if a change edit already existed for the change
    */
-  public RefUpdate.Result createEdit(Change change, PatchSet ps)
-      throws AuthException, IOException, ResourceConflictException, OrmException {
-    if (!currentUser.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-    IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    String refPrefix = RefNames.refsEditPrefix(me.getAccountId(), change.getId());
+  public void createEdit(Repository repository, ChangeControl changeControl)
+      throws AuthException, IOException, InvalidChangeOperationException, OrmException {
+    ensureAuthenticatedAndPermitted(changeControl);
 
-    try {
-      ChangeControl c =
-          changeControlFactory.controlFor(reviewDb.get(), change, me);
-      if (!c.canAddPatchSet(reviewDb.get())) {
-        return RefUpdate.Result.REJECTED;
-      }
-    } catch (NoSuchChangeException e) {
-      return RefUpdate.Result.NO_CHANGE;
+    Optional<ChangeEdit> changeEdit = lookupChangeEdit(changeControl);
+    if (changeEdit.isPresent()) {
+      throw new InvalidChangeOperationException(
+          String.format("A change edit already exists for change %s", changeControl.getId()));
     }
 
-    try (Repository repo = gitManager.openRepository(change.getProject())) {
-      Map<String, Ref> refs = repo.getRefDatabase().getRefs(refPrefix);
-      if (!refs.isEmpty()) {
-        throw new ResourceConflictException("edit already exists");
-      }
-
-      try (RevWalk rw = new RevWalk(repo)) {
-        ObjectId revision = ObjectId.fromString(ps.getRevision().get());
-        String editRefName = RefNames.refsEdit(me.getAccountId(), change.getId(),
-            ps.getId());
-        Result res = update(repo, me, editRefName, rw, ObjectId.zeroId(),
-            revision, TimeUtil.nowTs());
-        indexer.index(reviewDb.get(), change);
-        return res;
-      }
-    }
+    PatchSet currentPatchSet = lookupCurrentPatchSet(changeControl);
+    ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
+    createEditReference(
+        repository, changeControl, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
   }
 
   /**
    * Rebase change edit on latest patch set
    *
-   * @param edit change edit that contains edit to rebase
-   * @param current patch set to rebase the edit on
-   * @throws AuthException
-   * @throws ResourceConflictException thrown if rebase fails due to merge conflicts
-   * @throws InvalidChangeOperationException
-   * @throws IOException
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
+   *     rebased
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if a change edit doesn't exist for the specified
+   *     change, the change edit is already based on the latest patch set, or the change represents
+   *     the root commit
+   * @throws MergeConflictException if rebase fails due to merge conflicts
    */
-  public void rebaseEdit(ChangeEdit edit, PatchSet current)
-      throws AuthException, ResourceConflictException,
-      InvalidChangeOperationException, IOException {
-    if (!currentUser.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
+  public void rebaseEdit(Repository repository, ChangeControl changeControl)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          MergeConflictException {
+    ensureAuthenticatedAndPermitted(changeControl);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
+    if (!optionalChangeEdit.isPresent()) {
+      throw new InvalidChangeOperationException(
+          String.format("No change edit exists for change %s", changeControl.getId()));
+    }
+    ChangeEdit changeEdit = optionalChangeEdit.get();
+
+    PatchSet currentPatchSet = lookupCurrentPatchSet(changeControl);
+    if (isBasedOn(changeEdit, currentPatchSet)) {
+      throw new InvalidChangeOperationException(
+          String.format(
+              "Change edit for change %s is already based on latest patch set %s",
+              changeControl.getId(), currentPatchSet.getId()));
     }
 
-    Change change = edit.getChange();
-    IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    String refName = RefNames.refsEdit(me.getAccountId(), change.getId(),
-        current.getId());
-    try (Repository repo = gitManager.openRepository(change.getProject());
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter inserter = repo.newObjectInserter()) {
-      BatchRefUpdate ru = repo.getRefDatabase().newBatchUpdate();
-      RevCommit editCommit = edit.getEditCommit();
-      if (editCommit.getParentCount() == 0) {
-        throw new InvalidChangeOperationException(
-            "Rebase edit against root commit not supported");
-      }
-      RevCommit tip = rw.parseCommit(ObjectId.fromString(
-          current.getRevision().get()));
-      ThreeWayMerger m = MergeStrategy.RESOLVE.newMerger(repo, true);
-      m.setObjectInserter(inserter);
-      m.setBase(ObjectId.fromString(
-          edit.getBasePatchSet().getRevision().get()));
+    rebase(repository, changeEdit, currentPatchSet);
+  }
 
-      if (m.merge(tip, editCommit)) {
-        ObjectId tree = m.getResultTreeId();
-
-        CommitBuilder commit = new CommitBuilder();
-        commit.setTreeId(tree);
-        for (int i = 0; i < tip.getParentCount(); i++) {
-          commit.addParentId(tip.getParent(i));
-        }
-        commit.setAuthor(editCommit.getAuthorIdent());
-        commit.setCommitter(new PersonIdent(
-            editCommit.getCommitterIdent(), TimeUtil.nowTs()));
-        commit.setMessage(editCommit.getFullMessage());
-        ObjectId newEdit = inserter.insert(commit);
-        inserter.flush();
-
-        ru.addCommand(new ReceiveCommand(ObjectId.zeroId(), newEdit,
-            refName));
-        ru.addCommand(new ReceiveCommand(edit.getRef().getObjectId(),
-            ObjectId.zeroId(), edit.getRefName()));
-        ru.execute(rw, NullProgressMonitor.INSTANCE);
-        for (ReceiveCommand cmd : ru.getCommands()) {
-          if (cmd.getResult() != ReceiveCommand.Result.OK) {
-            throw new IOException("failed: " + cmd);
-          }
-        }
-      } else {
-        // TODO(davido): Allow to resolve conflicts inline
-        throw new ResourceConflictException("merge conflict");
-      }
+  private void rebase(Repository repository, ChangeEdit changeEdit, PatchSet currentPatchSet)
+      throws IOException, MergeConflictException, InvalidChangeOperationException, OrmException {
+    RevCommit currentEditCommit = changeEdit.getEditCommit();
+    if (currentEditCommit.getParentCount() == 0) {
+      throw new InvalidChangeOperationException(
+          "Rebase change edit against root commit not supported");
     }
+
+    Change change = changeEdit.getChange();
+    RevCommit basePatchSetCommit = lookupCommit(repository, currentPatchSet);
+    RevTree basePatchSetTree = basePatchSetCommit.getTree();
+
+    ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    String commitMessage = currentEditCommit.getFullMessage();
+    ObjectId newEditCommitId =
+        createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
+
+    String newEditRefName = getEditRefName(change, currentPatchSet);
+    updateReferenceWithNameChange(
+        repository,
+        changeEdit.getRefName(),
+        currentEditCommit,
+        newEditRefName,
+        newEditCommitId,
+        nowTimestamp);
+    reindex(change);
   }
 
   /**
-   * Modify commit message in existing change edit.
+   * Modifies the commit message of a change edit. If the change edit doesn't exist, a new one will
+   * be created based on the current patch set.
    *
-   * @param edit change edit
-   * @param msg new commit message
-   * @return result
-   * @throws AuthException
-   * @throws InvalidChangeOperationException
-   * @throws IOException
-   * @throws UnchangedCommitMessageException
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change whose change edit's message should
+   *     be modified
+   * @param newCommitMessage the new commit message
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws UnchangedCommitMessageException if the commit message is the same as before
+   * @throws ResourceConflictException if the commit message has a Change-Id modification
    */
-  public RefUpdate.Result modifyMessage(ChangeEdit edit, String msg)
-      throws AuthException, InvalidChangeOperationException, IOException,
-      UnchangedCommitMessageException {
-    msg = msg.trim() + "\n";
-    checkState(!Strings.isNullOrEmpty(msg), "message cannot be null");
-    if (!currentUser.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
+  public void modifyMessage(
+      Repository repository, ChangeControl changeControl, String newCommitMessage)
+      throws AuthException, IOException, UnchangedCommitMessageException, OrmException,
+          ResourceConflictException {
+    ensureAuthenticatedAndPermitted(changeControl);
+    newCommitMessage = getWellFormedCommitMessage(newCommitMessage);
 
-    RevCommit prevEdit = edit.getEditCommit();
-    if (prevEdit.getFullMessage().equals(msg)) {
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
+    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, changeControl);
+    RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
+    RevCommit baseCommit =
+        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
+
+    String currentCommitMessage = baseCommit.getFullMessage();
+    if (newCommitMessage.equals(currentCommitMessage)) {
       throw new UnchangedCommitMessageException();
     }
 
-    IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    Project.NameKey project = edit.getChange().getProject();
-    try (Repository repo = gitManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter inserter = repo.newObjectInserter()) {
-      String refName = edit.getRefName();
-      Timestamp now = TimeUtil.nowTs();
-      ObjectId commit = createCommit(me, inserter, prevEdit,
-          prevEdit.getTree(),
-          msg, now);
-      inserter.flush();
-      return update(repo, me, refName, rw, prevEdit, commit, now);
+    RevTree baseTree = baseCommit.getTree();
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    ObjectId newEditCommit =
+        createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
+
+    if (changeControl.getProjectControl().getProjectState().isRequireChangeID()) {
+      try (RevWalk revWalk = new RevWalk(repository)) {
+        if (!revWalk
+            .parseCommit(newEditCommit)
+            .getFooterLines(FooterConstants.CHANGE_ID)
+            .contains(changeControl.getChange().getKey().get())) {
+          throw new ResourceConflictException("Editing of the Change-Id footer is not allowed");
+        }
+      }
+    }
+
+    if (optionalChangeEdit.isPresent()) {
+      updateEditReference(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+    } else {
+      createEditReference(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
     }
   }
 
   /**
-   * Modify file in existing change edit from its base commit.
+   * Modifies the contents of a file of a change edit. If the change edit doesn't exist, a new one
+   * will be created based on the current patch set.
    *
-   * @param edit change edit
-   * @param file path to modify
-   * @param content new content
-   * @return result
-   * @throws AuthException
-   * @throws InvalidChangeOperationException
-   * @throws IOException
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
+   *     modified
+   * @param filePath the path of the file whose contents should be modified
+   * @param newContent the new file content
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the file already had the specified content
    */
-  public RefUpdate.Result modifyFile(ChangeEdit edit,
-      String file, RawInput content) throws AuthException,
-      InvalidChangeOperationException, IOException {
-    return modify(TreeOperation.CHANGE_ENTRY, edit, file, null, content);
+  public void modifyFile(
+      Repository repository, ChangeControl changeControl, String filePath, RawInput newContent)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
+    modifyTree(repository, changeControl, new ChangeFileContentModification(filePath, newContent));
   }
 
   /**
-   * Delete file in existing change edit.
+   * Deletes a file from the Git tree of a change edit. If the change edit doesn't exist, a new one
+   * will be created based on the current patch set.
    *
-   * @param edit change edit
-   * @param file path to delete
-   * @return result
-   * @throws AuthException
-   * @throws InvalidChangeOperationException
-   * @throws IOException
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
+   *     modified
+   * @param file path of the file which should be deleted
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the file does not exist
    */
-  public RefUpdate.Result deleteFile(ChangeEdit edit,
-      String file) throws AuthException, InvalidChangeOperationException,
-      IOException {
-    return modify(TreeOperation.DELETE_ENTRY, edit, file, null, null);
+  public void deleteFile(Repository repository, ChangeControl changeControl, String file)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
+    modifyTree(repository, changeControl, new DeleteFileModification(file));
   }
 
   /**
-   * Rename file in existing change edit.
+   * Renames a file of a change edit or moves it to another directory. If the change edit doesn't
+   * exist, a new one will be created based on the current patch set.
    *
-   * @param edit change edit
-   * @param file path to rename
-   * @param newFile path to rename the file to
-   * @return result
-   * @throws AuthException
-   * @throws InvalidChangeOperationException
-   * @throws IOException
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
+   *     modified
+   * @param currentFilePath the current path/name of the file
+   * @param newFilePath the desired path/name of the file
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the file was already renamed to the specified new
+   *     name
    */
-  public RefUpdate.Result renameFile(ChangeEdit edit, String file,
-      String newFile) throws AuthException, InvalidChangeOperationException,
-      IOException {
-    return modify(TreeOperation.RENAME_ENTRY, edit, file, newFile, null);
+  public void renameFile(
+      Repository repository,
+      ChangeControl changeControl,
+      String currentFilePath,
+      String newFilePath)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
+    modifyTree(repository, changeControl, new RenameFileModification(currentFilePath, newFilePath));
   }
 
   /**
-   * Restore file in existing change edit.
+   * Restores a file of a change edit to the state it was in before the patch set on which the
+   * change edit is based. If the change edit doesn't exist, a new one will be created based on the
+   * current patch set.
    *
-   * @param edit change edit
-   * @param file path to restore
-   * @return result
-   * @throws AuthException
-   * @throws InvalidChangeOperationException
-   * @throws IOException
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
+   *     modified
+   * @param file the path of the file which should be restored
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the file was already restored
    */
-  public RefUpdate.Result restoreFile(ChangeEdit edit,
-      String file) throws AuthException, InvalidChangeOperationException,
-      IOException {
-    return modify(TreeOperation.RESTORE_ENTRY, edit, file, null, null);
+  public void restoreFile(Repository repository, ChangeControl changeControl, String file)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
+    modifyTree(repository, changeControl, new RestoreFileModification(file));
   }
 
-  private RefUpdate.Result modify(TreeOperation op, ChangeEdit edit,
-      String file, @Nullable String newFile, @Nullable RawInput content)
-      throws AuthException, IOException, InvalidChangeOperationException {
+  private void modifyTree(
+      Repository repository, ChangeControl changeControl, TreeModification treeModification)
+      throws AuthException, IOException, OrmException, InvalidChangeOperationException {
+    ensureAuthenticatedAndPermitted(changeControl);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
+    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, changeControl);
+    RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
+    RevCommit baseCommit =
+        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
+
+    ObjectId newTreeId = createNewTree(repository, baseCommit, treeModification);
+
+    String commitMessage = baseCommit.getFullMessage();
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    ObjectId newEditCommit =
+        createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
+
+    if (optionalChangeEdit.isPresent()) {
+      updateEditReference(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+    } else {
+      createEditReference(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
+    }
+  }
+
+  private void ensureAuthenticatedAndPermitted(ChangeControl changeControl)
+      throws AuthException, OrmException {
+    ensureAuthenticated();
+    ensurePermitted(changeControl);
+  }
+
+  private void ensureAuthenticated() throws AuthException {
     if (!currentUser.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
+  }
+
+  private void ensurePermitted(ChangeControl changeControl) throws OrmException, AuthException {
+    if (!changeControl.canAddPatchSet(reviewDb.get())) {
+      throw new AuthException("Not allowed to edit a change.");
+    }
+  }
+
+  private String getWellFormedCommitMessage(String commitMessage) {
+    String wellFormedMessage = Strings.nullToEmpty(commitMessage).trim();
+    checkState(!wellFormedMessage.isEmpty(), "Commit message cannot be null or empty");
+    wellFormedMessage = wellFormedMessage + "\n";
+    return wellFormedMessage;
+  }
+
+  private Optional<ChangeEdit> lookupChangeEdit(ChangeControl changeControl)
+      throws AuthException, IOException {
+    return changeEditUtil.byChange(changeControl);
+  }
+
+  private PatchSet getBasePatchSet(
+      Optional<ChangeEdit> optionalChangeEdit, ChangeControl changeControl) throws OrmException {
+    Optional<PatchSet> editBasePatchSet = optionalChangeEdit.map(ChangeEdit::getBasePatchSet);
+    return editBasePatchSet.isPresent()
+        ? editBasePatchSet.get()
+        : lookupCurrentPatchSet(changeControl);
+  }
+
+  private PatchSet lookupCurrentPatchSet(ChangeControl changeControl) throws OrmException {
+    return patchSetUtil.current(reviewDb.get(), changeControl.getNotes());
+  }
+
+  private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
+    PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
+    return editBasePatchSet.getId().equals(patchSet.getId());
+  }
+
+  private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
+      throws IOException {
+    ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
+    try (RevWalk revWalk = new RevWalk(repository)) {
+      return revWalk.parseCommit(patchSetCommitId);
+    }
+  }
+
+  private static ObjectId createNewTree(
+      Repository repository, RevCommit baseCommit, TreeModification treeModification)
+      throws IOException, InvalidChangeOperationException {
+    TreeCreator treeCreator = new TreeCreator(baseCommit);
+    treeCreator.addTreeModification(treeModification);
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    if (ObjectId.equals(newTreeId, baseCommit.getTree())) {
+      throw new InvalidChangeOperationException("no changes were made");
+    }
+    return newTreeId;
+  }
+
+  private ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
+      throws IOException, MergeConflictException {
+    PatchSet basePatchSet = changeEdit.getBasePatchSet();
+    ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
+    ObjectId editCommitId = changeEdit.getEditCommit();
+
+    ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repository, true);
+    threeWayMerger.setBase(basePatchSetCommitId);
+    boolean successful = threeWayMerger.merge(newTreeId, editCommitId);
+
+    if (!successful) {
+      throw new MergeConflictException(
+          "The existing change edit could not be merged with another tree.");
+    }
+    return threeWayMerger.getResultTreeId();
+  }
+
+  private ObjectId createCommit(
+      Repository repository,
+      RevCommit basePatchSetCommit,
+      ObjectId tree,
+      String commitMessage,
+      Timestamp timestamp)
+      throws IOException {
+    try (ObjectInserter objectInserter = repository.newObjectInserter()) {
+      CommitBuilder builder = new CommitBuilder();
+      builder.setTreeId(tree);
+      builder.setParentIds(basePatchSetCommit.getParents());
+      builder.setAuthor(basePatchSetCommit.getAuthorIdent());
+      builder.setCommitter(getCommitterIdent(timestamp));
+      builder.setMessage(commitMessage);
+      ObjectId newCommitId = objectInserter.insert(builder);
+      objectInserter.flush();
+      return newCommitId;
+    }
+  }
+
+  private PersonIdent getCommitterIdent(Timestamp commitTimestamp) {
+    IdentifiedUser user = currentUser.get().asIdentifiedUser();
+    return user.newCommitterIdent(commitTimestamp, tz);
+  }
+
+  private static ObjectId getPatchSetCommitId(PatchSet patchSet) {
+    return ObjectId.fromString(patchSet.getRevision().get());
+  }
+
+  private void createEditReference(
+      Repository repository,
+      ChangeControl changeControl,
+      PatchSet basePatchSet,
+      ObjectId newEditCommit,
+      Timestamp timestamp)
+      throws IOException, OrmException {
+    Change change = changeControl.getChange();
+    String editRefName = getEditRefName(change, basePatchSet);
+    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommit, timestamp);
+    reindex(change);
+  }
+
+  private String getEditRefName(Change change, PatchSet basePatchSet) {
     IdentifiedUser me = currentUser.get().asIdentifiedUser();
-    Project.NameKey project = edit.getChange().getProject();
-    try (Repository repo = gitManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter inserter = repo.newObjectInserter();
-        ObjectReader reader = repo.newObjectReader()) {
-      String refName = edit.getRefName();
-      RevCommit prevEdit = edit.getEditCommit();
-      ObjectId newTree = writeNewTree(
-          op,
-          rw,
-          inserter,
-          prevEdit,
-          reader,
-          file,
-          newFile,
-          content);
-      if (ObjectId.equals(newTree, prevEdit.getTree())) {
-        throw new InvalidChangeOperationException("no changes were made");
-      }
-
-      Timestamp now = TimeUtil.nowTs();
-      ObjectId commit = createCommit(me, inserter, prevEdit, newTree, now);
-      inserter.flush();
-      return update(repo, me, refName, rw, prevEdit, commit, now);
-    }
+    return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
   }
 
-  private static ObjectId toBlob(ObjectInserter ins, @Nullable RawInput content)
+  private void updateEditReference(
+      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommit, Timestamp timestamp)
+      throws IOException, OrmException {
+    String editRefName = changeEdit.getRefName();
+    RevCommit currentEditCommit = changeEdit.getEditCommit();
+    updateReference(repository, editRefName, currentEditCommit, newEditCommit, timestamp);
+    reindex(changeEdit.getChange());
+  }
+
+  private void updateReference(
+      Repository repository,
+      String refName,
+      ObjectId currentObjectId,
+      ObjectId targetObjectId,
+      Timestamp timestamp)
       throws IOException {
-    if (content == null) {
-      return null;
-    }
-
-    long len = content.getContentLength();
-    InputStream in = content.getInputStream();
-    if (len < 0) {
-      return ins.insert(OBJ_BLOB, ByteStreams.toByteArray(in));
-    }
-    return ins.insert(OBJ_BLOB, len, in);
-  }
-
-  private ObjectId createCommit(IdentifiedUser me, ObjectInserter inserter,
-      RevCommit revision, ObjectId tree, Timestamp when) throws IOException {
-    return createCommit(me, inserter, revision, tree,
-        revision.getFullMessage(), when);
-  }
-
-  private ObjectId createCommit(IdentifiedUser me, ObjectInserter inserter,
-      RevCommit revision, ObjectId tree, String msg, Timestamp when)
-      throws IOException {
-    CommitBuilder builder = new CommitBuilder();
-    builder.setTreeId(tree);
-    builder.setParentIds(revision.getParents());
-    builder.setAuthor(revision.getAuthorIdent());
-    builder.setCommitter(getCommitterIdent(me, when));
-    builder.setMessage(msg);
-    return inserter.insert(builder);
-  }
-
-  private RefUpdate.Result update(Repository repo, IdentifiedUser me,
-      String refName, RevWalk rw, ObjectId oldObjectId, ObjectId newEdit,
-      Timestamp when) throws IOException {
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setExpectedOldObjectId(oldObjectId);
-    ru.setNewObjectId(newEdit);
-    ru.setRefLogIdent(getRefLogIdent(me, when));
+    RefUpdate ru = repository.updateRef(refName);
+    ru.setExpectedOldObjectId(currentObjectId);
+    ru.setNewObjectId(targetObjectId);
+    ru.setRefLogIdent(getRefLogIdent(timestamp));
     ru.setRefLogMessage("inline edit (amend)", false);
     ru.setForceUpdate(true);
-    RefUpdate.Result res = ru.update(rw);
-    if (res != RefUpdate.Result.NEW &&
-        res != RefUpdate.Result.FORCED) {
-      throw new IOException("update failed: " + ru);
-    }
-    return res;
-  }
-
-  private static ObjectId writeNewTree(
-      TreeOperation op,
-      RevWalk rw,
-      final ObjectInserter ins,
-      RevCommit prevEdit,
-      ObjectReader reader,
-      String fileName,
-      @Nullable String newFile,
-      @Nullable final RawInput content)
-      throws InvalidChangeOperationException, IOException {
-    DirCache newTree = readTree(reader, prevEdit);
-    DirCacheEditor dce = newTree.editor();
-    switch (op) {
-      case DELETE_ENTRY:
-        dce.add(new DeletePath(fileName));
-        break;
-
-      case RENAME_ENTRY:
-        rw.parseHeaders(prevEdit);
-        TreeWalk tw =
-            TreeWalk.forPath(rw.getObjectReader(), fileName, prevEdit.getTree());
-        if (tw != null) {
-          dce.add(new DeletePath(fileName));
-          addFileToCommit(newFile, dce, tw);
-        }
-        break;
-
-      case CHANGE_ENTRY:
-        checkNotNull(content, "new content required");
-
-        final AtomicReference<IOException> ioe =
-            new AtomicReference<>(null);
-        final AtomicReference<InvalidChangeOperationException> icoe =
-            new AtomicReference<>(null);
-        dce.add(new PathEdit(fileName) {
-          @Override
-          public void apply(DirCacheEntry ent) {
-            try {
-              if (ent.getFileMode() == FileMode.GITLINK) {
-                ent.setLength(0);
-                ent.setLastModified(0);
-                ent.setObjectId(ObjectId.fromString(
-                    ByteStreams.toByteArray(content.getInputStream()), 0));
-              } else {
-                if (ent.getRawMode() == 0) {
-                  ent.setFileMode(FileMode.REGULAR_FILE);
-                }
-                ent.setObjectId(toBlob(ins, content));
-              }
-            } catch (IOException e) {
-              ioe.set(e);
-            } catch (InvalidObjectIdException e) {
-              icoe.set(new InvalidChangeOperationException(
-                  "Invalid object id in submodule link: " + e.getMessage()));
-              icoe.get().initCause(e);
-            }
-          }
-        });
-        if (ioe.get() != null) {
-          throw ioe.get();
-        }
-        if (icoe.get() != null) {
-          throw icoe.get();
-        }
-        break;
-
-      case RESTORE_ENTRY:
-        if (prevEdit.getParentCount() == 0) {
-          dce.add(new DeletePath(fileName));
-          break;
-        }
-
-        RevCommit base = prevEdit.getParent(0);
-        rw.parseHeaders(base);
-        tw = TreeWalk.forPath(rw.getObjectReader(), fileName, base.getTree());
-        if (tw == null) {
-          dce.add(new DeletePath(fileName));
-          break;
-        }
-
-        addFileToCommit(fileName, dce, tw);
-        break;
-    }
-    dce.finish();
-    return newTree.writeTree(ins);
-  }
-
-  private static void addFileToCommit(String newFile, DirCacheEditor dce,
-      TreeWalk tw) {
-    final FileMode mode = tw.getFileMode(0);
-    final ObjectId oid = tw.getObjectId(0);
-    dce.add(new PathEdit(newFile) {
-      @Override
-      public void apply(DirCacheEntry ent) {
-        ent.setFileMode(mode);
-        ent.setObjectId(oid);
+    try (RevWalk revWalk = new RevWalk(repository)) {
+      RefUpdate.Result res = ru.update(revWalk);
+      if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
+        throw new IOException("update failed: " + ru);
       }
-    });
+    }
   }
 
-  private static DirCache readTree(ObjectReader reader, RevCommit prevEdit)
+  private void updateReferenceWithNameChange(
+      Repository repository,
+      String currentRefName,
+      ObjectId currentObjectId,
+      String newRefName,
+      ObjectId targetObjectId,
+      Timestamp timestamp)
       throws IOException {
-    DirCache dc = DirCache.newInCore();
-    DirCacheBuilder b = dc.builder();
-    b.addTree(new byte[0], DirCacheEntry.STAGE_0, reader, prevEdit.getTree());
-    b.finish();
-    return dc;
+    BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
+    batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
+    batchRefUpdate.addCommand(
+        new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
+    batchRefUpdate.setRefLogMessage("rebase edit", false);
+    batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
+    try (RevWalk revWalk = new RevWalk(repository)) {
+      batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+    }
+    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("failed: " + cmd);
+      }
+    }
   }
 
-  private PersonIdent getCommitterIdent(IdentifiedUser user, Timestamp when) {
-    return user.newCommitterIdent(when, tz);
+  private PersonIdent getRefLogIdent(Timestamp timestamp) {
+    IdentifiedUser user = currentUser.get().asIdentifiedUser();
+    return user.newRefLogIdent(timestamp, tz);
   }
 
-  private PersonIdent getRefLogIdent(IdentifiedUser user, Timestamp when) {
-    return user.newRefLogIdent(when, tz);
+  private void reindex(Change change) throws IOException, OrmException {
+    indexer.index(reviewDb.get(), change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6811056..6509ecc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -16,12 +16,15 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Optional;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -33,19 +36,20 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -55,13 +59,11 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-
 /**
  * Utility functions to manipulate change edits.
- * <p>
- * This class contains methods to retrieve, publish and delete edits.
- * For changing edits see {@link ChangeEditModifier}.
+ *
+ * <p>This class contains methods to retrieve, publish and delete edits. For changing edits see
+ * {@link ChangeEditModifier}.
  */
 @Singleton
 public class ChangeEditUtil {
@@ -69,7 +71,6 @@
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeIndexer indexer;
-  private final ProjectCache projectCache;
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final ChangeKindCache changeKindCache;
@@ -77,11 +78,11 @@
   private final PatchSetUtil psUtil;
 
   @Inject
-  ChangeEditUtil(GitRepositoryManager gitManager,
+  ChangeEditUtil(
+      GitRepositoryManager gitManager,
       PatchSetInserter.Factory patchSetInserterFactory,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
-      ProjectCache projectCache,
       Provider<ReviewDb> db,
       Provider<CurrentUser> user,
       ChangeKindCache changeKindCache,
@@ -91,7 +92,6 @@
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.changeControlFactory = changeControlFactory;
     this.indexer = indexer;
-    this.projectCache = projectCache;
     this.db = db;
     this.user = user;
     this.changeKindCache = changeKindCache;
@@ -101,8 +101,8 @@
 
   /**
    * Retrieve edit for a change and the user from the request scope.
-   * <p>
-   * At most one change edit can exist per user and change.
+   *
+   * <p>At most one change edit can exist per user and change.
    *
    * @param change
    * @return edit for this change for this user, if present.
@@ -113,8 +113,7 @@
   public Optional<ChangeEdit> byChange(Change change)
       throws AuthException, IOException, OrmException {
     try {
-      return byChange(
-          changeControlFactory.controlFor(db.get(), change, user.get()));
+      return byChange(changeControlFactory.controlFor(db.get(), change, user.get()));
     } catch (NoSuchChangeException e) {
       throw new IOException(e);
     }
@@ -122,16 +121,15 @@
 
   /**
    * Retrieve edit for a change and the given user.
-   * <p>
-   * At most one change edit can exist per user and change.
+   *
+   * <p>At most one change edit can exist per user and change.
    *
    * @param ctl control with user to retrieve change edits for.
    * @return edit for this change for this user, if present.
    * @throws AuthException if this is not a logged-in user.
    * @throws IOException if an error occurs.
    */
-  public Optional<ChangeEdit> byChange(ChangeControl ctl)
-      throws AuthException, IOException {
+  public Optional<ChangeEdit> byChange(ChangeControl ctl) throws AuthException, IOException {
     if (!ctl.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
@@ -141,13 +139,12 @@
       int n = change.currentPatchSetId().get();
       String[] refNames = new String[n];
       for (int i = n; i > 0; i--) {
-        refNames[i - 1] = RefNames.refsEdit(
-            u.getAccountId(), change.getId(),
-            new PatchSet.Id(change.getId(), i));
+        refNames[i - 1] =
+            RefNames.refsEdit(u.getAccountId(), change.getId(), new PatchSet.Id(change.getId(), i));
       }
       Ref ref = repo.getRefDatabase().firstExactRef(refNames);
       if (ref == null) {
-        return Optional.absent();
+        return Optional.empty();
       }
       try (RevWalk rw = new RevWalk(repo)) {
         RevCommit commit = rw.parseCommit(ref.getObjectId());
@@ -158,34 +155,87 @@
   }
 
   /**
-   * Promote change edit to patch set, by squashing the edit into
-   * its parent.
+   * Promote change edit to patch set, by squashing the edit into its parent.
    *
    * @param edit change edit to publish
-   * @throws NoSuchChangeException
+   * @param notify Notify handling that defines to whom email notifications should be sent after the
+   *     change edit is published.
+   * @param accountsToNotify Accounts that should be notified after the change edit is published.
    * @throws IOException
    * @throws OrmException
    * @throws UpdateException
    * @throws RestApiException
    */
-  public void publish(ChangeEdit edit) throws NoSuchChangeException,
-      IOException, OrmException, RestApiException, UpdateException {
+  public void publish(
+      final ChangeEdit edit,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws IOException, OrmException, RestApiException, UpdateException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject());
         RevWalk rw = new RevWalk(repo);
-        ObjectInserter inserter = repo.newObjectInserter()) {
+        ObjectInserter oi = repo.newObjectInserter()) {
       PatchSet basePatchSet = edit.getBasePatchSet();
       if (!basePatchSet.getId().equals(change.currentPatchSetId())) {
-        throw new ResourceConflictException(
-            "only edit for current patch set can be published");
+        throw new ResourceConflictException("only edit for current patch set can be published");
       }
 
-      Change updatedChange =
-          insertPatchSet(edit, change, repo, rw, inserter, basePatchSet,
-              squashEdit(rw, inserter, edit.getEditCommit(), basePatchSet));
-      // TODO(davido): This should happen in the same BatchRefUpdate.
-      deleteRef(repo, edit);
-      indexer.index(db.get(), updatedChange);
+      RevCommit squashed = squashEdit(rw, oi, edit.getEditCommit(), basePatchSet);
+      ChangeControl ctl = changeControlFactory.controlFor(db.get(), change, edit.getUser());
+      PatchSet.Id psId = ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
+      PatchSetInserter inserter =
+          patchSetInserterFactory
+              .create(ctl, psId, squashed)
+              .setNotify(notify)
+              .setAccountsToNotify(accountsToNotify);
+
+      StringBuilder message =
+          new StringBuilder("Patch Set ").append(inserter.getPatchSetId().get()).append(": ");
+
+      // Previously checked that the base patch set is the current patch set.
+      ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
+      ChangeKind kind = changeKindCache.getChangeKind(change.getProject(), repo, prior, squashed);
+      if (kind == ChangeKind.NO_CODE_CHANGE) {
+        message.append("Commit message was updated.");
+        inserter.setDescription("Edit commit message");
+      } else {
+        message
+            .append("Published edit on patch set ")
+            .append(basePatchSet.getPatchSetId())
+            .append(".");
+      }
+
+      try (BatchUpdate bu =
+          updateFactory.create(db.get(), change.getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+        bu.setRepository(repo, rw, oi);
+        bu.addOp(
+            change.getId(),
+            inserter
+                .setDraft(change.getStatus() == Status.DRAFT || basePatchSet.isDraft())
+                .setMessage(message.toString()));
+        bu.addOp(
+            change.getId(),
+            new BatchUpdateOp() {
+              @Override
+              public void updateRepo(RepoContext ctx) throws Exception {
+                deleteRef(ctx.getRepository(), edit);
+              }
+            });
+        bu.execute();
+      } catch (UpdateException e) {
+        if (e.getCause() instanceof IOException
+            && e.getMessage()
+                .equals(
+                    String.format(
+                        "%s: Failed to delete ref %s: %s",
+                        IOException.class.getName(),
+                        edit.getRefName(),
+                        RefUpdate.Result.LOCK_FAILURE.name()))) {
+          throw new ResourceConflictException("edit is already published");
+        }
+      }
+
+      indexer.index(db.get(), inserter.getChange());
     }
   }
 
@@ -196,8 +246,7 @@
    * @throws IOException
    * @throws OrmException
    */
-  public void delete(ChangeEdit edit)
-      throws IOException, OrmException {
+  public void delete(ChangeEdit edit) throws IOException, OrmException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject())) {
       deleteRef(repo, edit);
@@ -205,24 +254,22 @@
     indexer.index(db.get(), change);
   }
 
-  private PatchSet getBasePatchSet(ChangeControl ctl, Ref ref)
-      throws IOException {
+  private PatchSet getBasePatchSet(ChangeControl ctl, Ref ref) throws IOException {
     try {
       int pos = ref.getName().lastIndexOf("/");
       checkArgument(pos > 0, "invalid edit ref: %s", ref.getName());
       String psId = ref.getName().substring(pos + 1);
-      return psUtil.get(db.get(), ctl.getNotes(),
-          new PatchSet.Id(ctl.getId(), Integer.parseInt(psId)));
+      return psUtil.get(
+          db.get(), ctl.getNotes(), new PatchSet.Id(ctl.getId(), Integer.parseInt(psId)));
     } catch (OrmException | NumberFormatException e) {
       throw new IOException(e);
     }
   }
 
-  private RevCommit squashEdit(RevWalk rw, ObjectInserter inserter,
-      RevCommit edit, PatchSet basePatchSet)
+  private RevCommit squashEdit(
+      RevWalk rw, ObjectInserter inserter, RevCommit edit, PatchSet basePatchSet)
       throws IOException, ResourceConflictException {
-    RevCommit parent = rw.parseCommit(ObjectId.fromString(
-        basePatchSet.getRevision().get()));
+    RevCommit parent = rw.parseCommit(ObjectId.fromString(basePatchSet.getRevision().get()));
     if (parent.getTree().equals(edit.getTree())
         && edit.getFullMessage().equals(parent.getFullMessage())) {
       throw new ResourceConflictException("identical tree and message");
@@ -230,49 +277,7 @@
     return writeSquashedCommit(rw, inserter, parent, edit);
   }
 
-  private Change insertPatchSet(ChangeEdit edit, Change change,
-      Repository repo, RevWalk rw, ObjectInserter oi, PatchSet basePatchSet,
-      RevCommit squashed) throws NoSuchChangeException, RestApiException,
-      UpdateException, OrmException, IOException {
-    ChangeControl ctl =
-        changeControlFactory.controlFor(db.get(), change, edit.getUser());
-    PatchSet.Id psId =
-        ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
-    PatchSetInserter inserter =
-        patchSetInserterFactory.create(ctl, psId, squashed);
-
-    StringBuilder message = new StringBuilder("Patch Set ")
-      .append(inserter.getPatchSetId().get())
-      .append(": ");
-
-    ProjectState project = projectCache.get(change.getDest().getParentKey());
-    // Previously checked that the base patch set is the current patch set.
-    ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
-    ChangeKind kind = changeKindCache.getChangeKind(project, repo, prior, squashed);
-    if (kind == ChangeKind.NO_CODE_CHANGE) {
-      message.append("Commit message was updated.");
-    } else {
-      message.append("Published edit on patch set ")
-        .append(basePatchSet.getPatchSetId())
-        .append(".");
-    }
-
-    try (BatchUpdate bu = updateFactory.create(
-        db.get(), change.getProject(), ctl.getUser(),
-        TimeUtil.nowTs())) {
-      bu.setRepository(repo, rw, oi);
-      bu.addOp(change.getId(), inserter
-        .setDraft(change.getStatus() == Status.DRAFT ||
-            basePatchSet.isDraft())
-        .setMessage(message.toString()));
-      bu.execute();
-    }
-
-    return inserter.getChange();
-  }
-
-  private static void deleteRef(Repository repo, ChangeEdit edit)
-      throws IOException {
+  private static void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
     String refName = edit.getRefName();
     RefUpdate ru = repo.updateRef(refName, true);
     ru.setExpectedOldObjectId(edit.getRef().getObjectId());
@@ -291,14 +296,12 @@
       case REJECTED_CURRENT_BRANCH:
       case RENAMED:
       default:
-        throw new IOException(String.format("Failed to delete ref %s: %s",
-            refName, result));
+        throw new IOException(String.format("Failed to delete ref %s: %s", refName, result));
     }
   }
 
-  private static RevCommit writeSquashedCommit(RevWalk rw,
-      ObjectInserter inserter, RevCommit parent, RevCommit edit)
-      throws IOException {
+  private static RevCommit writeSquashedCommit(
+      RevWalk rw, ObjectInserter inserter, RevCommit parent, RevCommit edit) throws IOException {
     CommitBuilder mergeCommit = new CommitBuilder();
     for (int i = 0; i < parent.getParentCount(); i++) {
       mergeCommit.addParentId(parent.getParent(i));
@@ -311,8 +314,8 @@
     return rw.parseCommit(commit(inserter, mergeCommit));
   }
 
-  private static ObjectId commit(ObjectInserter inserter,
-      CommitBuilder mergeCommit) throws IOException {
+  private static ObjectId commit(ObjectInserter inserter, CommitBuilder mergeCommit)
+      throws IOException {
     ObjectId id = inserter.insert(mergeCommit);
     inserter.flush();
     return id;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/AddPath.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/AddPath.java
new file mode 100644
index 0000000..d7cf29d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/AddPath.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * A {@code PathEdit} which adds a file path to the index. This operation is the counterpart to
+ * {@link org.eclipse.jgit.dircache.DirCacheEditor.DeletePath}.
+ */
+class AddPath extends DirCacheEditor.PathEdit {
+
+  private final FileMode fileMode;
+  private final ObjectId objectId;
+
+  AddPath(String filePath, FileMode fileMode, ObjectId objectId) {
+    super(filePath);
+    this.fileMode = fileMode;
+    this.objectId = objectId;
+  }
+
+  @Override
+  public void apply(DirCacheEntry dirCacheEntry) {
+    dirCacheEntry.setFileMode(fileMode);
+    dirCacheEntry.setObjectId(objectId);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
new file mode 100644
index 0000000..dc35309
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.extensions.restapi.RawInput;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** A {@code TreeModification} which changes the content of a file. */
+public class ChangeFileContentModification implements TreeModification {
+
+  private static final Logger log = LoggerFactory.getLogger(ChangeFileContentModification.class);
+
+  private final String filePath;
+  private final RawInput newContent;
+
+  public ChangeFileContentModification(String filePath, RawInput newContent) {
+    this.filePath = filePath;
+    this.newContent = checkNotNull(newContent, "new content required");
+  }
+
+  @Override
+  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+    DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
+    return Collections.singletonList(changeContentEdit);
+  }
+
+  /** A {@code PathEdit} which changes the contents of a file. */
+  private static class ChangeContent extends DirCacheEditor.PathEdit {
+
+    private final RawInput newContent;
+    private final Repository repository;
+
+    ChangeContent(String filePath, RawInput newContent, Repository repository) {
+      super(filePath);
+      this.newContent = newContent;
+      this.repository = repository;
+    }
+
+    @Override
+    public void apply(DirCacheEntry dirCacheEntry) {
+      try {
+        if (dirCacheEntry.getFileMode() == FileMode.GITLINK) {
+          dirCacheEntry.setLength(0);
+          dirCacheEntry.setLastModified(0);
+          ObjectId newObjectId = ObjectId.fromString(getNewContentBytes(), 0);
+          dirCacheEntry.setObjectId(newObjectId);
+        } else {
+          if (dirCacheEntry.getRawMode() == 0) {
+            dirCacheEntry.setFileMode(FileMode.REGULAR_FILE);
+          }
+          ObjectId newBlobObjectId = createNewBlobAndGetItsId();
+          dirCacheEntry.setObjectId(newBlobObjectId);
+        }
+        // Previously, these two exceptions were swallowed. To improve the
+        // situation, we log them now. However, we should think of a better
+        // approach.
+      } catch (IOException e) {
+        String message =
+            String.format("Could not change the content of %s", dirCacheEntry.getPathString());
+        log.error(message, e);
+      } catch (InvalidObjectIdException e) {
+        log.error("Invalid object id in submodule link", e);
+      }
+    }
+
+    private ObjectId createNewBlobAndGetItsId() throws IOException {
+      try (ObjectInserter objectInserter = repository.newObjectInserter()) {
+        ObjectId blobObjectId = createNewBlobAndGetItsId(objectInserter);
+        objectInserter.flush();
+        return blobObjectId;
+      }
+    }
+
+    private ObjectId createNewBlobAndGetItsId(ObjectInserter objectInserter) throws IOException {
+      long contentLength = newContent.getContentLength();
+      if (contentLength < 0) {
+        return objectInserter.insert(OBJ_BLOB, getNewContentBytes());
+      }
+      InputStream contentInputStream = newContent.getInputStream();
+      return objectInserter.insert(OBJ_BLOB, contentLength, contentInputStream);
+    }
+
+    private byte[] getNewContentBytes() throws IOException {
+      return ByteStreams.toByteArray(newContent.getInputStream());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
new file mode 100644
index 0000000..62da19a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** A {@code TreeModification} which deletes a file. */
+public class DeleteFileModification implements TreeModification {
+
+  private final String filePath;
+
+  public DeleteFileModification(String filePath) {
+    this.filePath = filePath;
+  }
+
+  @Override
+  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+    DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(filePath);
+    return Collections.singletonList(deletePathEdit);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
new file mode 100644
index 0000000..aeacd23
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/** A {@code TreeModification} which renames a file or moves it to a different path. */
+public class RenameFileModification implements TreeModification {
+
+  private final String currentFilePath;
+  private final String newFilePath;
+
+  public RenameFileModification(String currentFilePath, String newFilePath) {
+    this.currentFilePath = currentFilePath;
+    this.newFilePath = newFilePath;
+  }
+
+  @Override
+  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+      throws IOException {
+    try (RevWalk revWalk = new RevWalk(repository)) {
+      revWalk.parseHeaders(baseCommit);
+      try (TreeWalk treeWalk =
+          TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, baseCommit.getTree())) {
+        if (treeWalk == null) {
+          return Collections.emptyList();
+        }
+        DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(currentFilePath);
+        AddPath addPathEdit =
+            new AddPath(newFilePath, treeWalk.getFileMode(0), treeWalk.getObjectId(0));
+        return Arrays.asList(deletePathEdit, addPathEdit);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
new file mode 100644
index 0000000..1bd55f6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A {@code TreeModification} which restores a file. The file is added again if it was present
+ * before the specified commit or deleted if it was absent.
+ */
+public class RestoreFileModification implements TreeModification {
+
+  private final String filePath;
+
+  public RestoreFileModification(String filePath) {
+    this.filePath = filePath;
+  }
+
+  @Override
+  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+      throws IOException {
+    if (baseCommit.getParentCount() == 0) {
+      DirCacheEditor.DeletePath deletePath = new DirCacheEditor.DeletePath(filePath);
+      return Collections.singletonList(deletePath);
+    }
+
+    RevCommit base = baseCommit.getParent(0);
+    try (RevWalk revWalk = new RevWalk(repository)) {
+      revWalk.parseHeaders(base);
+      try (TreeWalk treeWalk =
+          TreeWalk.forPath(revWalk.getObjectReader(), filePath, base.getTree())) {
+        if (treeWalk == null) {
+          DirCacheEditor.DeletePath deletePath = new DirCacheEditor.DeletePath(filePath);
+          return Collections.singletonList(deletePath);
+        }
+
+        AddPath addPath = new AddPath(filePath, treeWalk.getFileMode(0), treeWalk.getObjectId(0));
+        return Collections.singletonList(addPath);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
new file mode 100644
index 0000000..7e9a96a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/**
+ * A creator for a new Git tree. To create the new tree, the tree of another commit is taken as a
+ * basis and modified.
+ */
+public class TreeCreator {
+
+  private final RevCommit baseCommit;
+  // At the moment, a list wouldn't be necessary as only one modification is
+  // applied per created tree. This is going to change in the near future.
+  private final List<TreeModification> treeModifications = new ArrayList<>();
+
+  public TreeCreator(RevCommit baseCommit) {
+    this.baseCommit = checkNotNull(baseCommit, "baseCommit is required");
+  }
+
+  /**
+   * Apply a modification to the tree which is taken as a basis. If this method is called multiple
+   * times, the modifications are applied subsequently in exactly the order they were provided.
+   *
+   * @param treeModification a modification which should be applied to the base tree
+   */
+  public void addTreeModification(TreeModification treeModification) {
+    checkNotNull(treeModification, "treeModification must not be null");
+    treeModifications.add(treeModification);
+  }
+
+  /**
+   * Creates the new tree. When this method is called, the specified base tree is read from the
+   * repository, the specified modifications are applied, and the resulting tree is written to the
+   * object store of the repository.
+   *
+   * @param repository the affected Git repository
+   * @return the {@code ObjectId} of the created tree
+   * @throws IOException if problems arise when accessing the repository
+   */
+  public ObjectId createNewTreeAndGetId(Repository repository) throws IOException {
+    DirCache newTree = createNewTree(repository);
+    return writeAndGetId(repository, newTree);
+  }
+
+  private DirCache createNewTree(Repository repository) throws IOException {
+    DirCache newTree = readBaseTree(repository);
+    List<DirCacheEditor.PathEdit> pathEdits = getPathEdits(repository);
+    applyPathEdits(newTree, pathEdits);
+    return newTree;
+  }
+
+  private DirCache readBaseTree(Repository repository) throws IOException {
+    try (ObjectReader objectReader = repository.newObjectReader()) {
+      DirCache dirCache = DirCache.newInCore();
+      DirCacheBuilder dirCacheBuilder = dirCache.builder();
+      dirCacheBuilder.addTree(
+          new byte[0], DirCacheEntry.STAGE_0, objectReader, baseCommit.getTree());
+      dirCacheBuilder.finish();
+      return dirCache;
+    }
+  }
+
+  private List<DirCacheEditor.PathEdit> getPathEdits(Repository repository) throws IOException {
+    List<DirCacheEditor.PathEdit> pathEdits = new ArrayList<>();
+    for (TreeModification treeModification : treeModifications) {
+      pathEdits.addAll(treeModification.getPathEdits(repository, baseCommit));
+    }
+    return pathEdits;
+  }
+
+  private static void applyPathEdits(DirCache tree, List<DirCacheEditor.PathEdit> pathEdits) {
+    DirCacheEditor dirCacheEditor = tree.editor();
+    pathEdits.forEach(dirCacheEditor::add);
+    dirCacheEditor.finish();
+  }
+
+  private static ObjectId writeAndGetId(Repository repository, DirCache tree) throws IOException {
+    try (ObjectInserter objectInserter = repository.newObjectInserter()) {
+      ObjectId treeId = tree.writeTree(objectInserter);
+      objectInserter.flush();
+      return treeId;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
new file mode 100644
index 0000000..217a309
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** A specific modification of a Git tree. */
+public interface TreeModification {
+
+  /**
+   * Returns a list of {@code PathEdit}s which are necessary in order to achieve the desired
+   * modification of the Git tree. The order of the {@code PathEdit}s can be crucial and hence
+   * shouldn't be changed.
+   *
+   * @param repository the affected Git repository
+   * @param baseCommit the commit to whose tree this modification is applied
+   * @return an ordered list of necessary {@code PathEdit}s
+   * @throws IOException if problems arise when accessing the repository
+   */
+  List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+      throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/AssigneeChangedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
new file mode 100644
index 0000000..60a0935
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class AssigneeChangedEvent extends ChangeEvent {
+  static final String TYPE = "assignee-changed";
+  public Supplier<AccountAttribute> changer;
+  public Supplier<AccountAttribute> oldAssignee;
+
+  public AssigneeChangedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeDeletedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
new file mode 100644
index 0000000..63142fd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class ChangeDeletedEvent extends ChangeEvent {
+  public static final String TYPE = "change-deleted";
+  public Supplier<AccountAttribute> deleter;
+
+  public ChangeDeletedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
index 639ca55..7c86d70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeRestoredEvent.java
@@ -23,7 +23,7 @@
   public Supplier<AccountAttribute> restorer;
   public String reason;
 
-  public ChangeRestoredEvent (Change change) {
+  public ChangeRestoredEvent(Change change) {
     super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index 085abe3..17fc52b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
-
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -32,8 +31,12 @@
     super(TYPE);
   }
 
-  public CommitReceivedEvent(ReceiveCommand command, Project project,
-      String refName, RevCommit commit, IdentifiedUser user) {
+  public CommitReceivedEvent(
+      ReceiveCommand command,
+      Project project,
+      String refName,
+      RevCommit commit,
+      IdentifiedUser user) {
     this();
     this.command = command;
     this.project = project;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventDeserializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventDeserializer.java
index 3508acf..2a7eada 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventDeserializer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventDeserializer.java
@@ -18,24 +18,24 @@
 import com.google.gson.JsonDeserializer;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonParseException;
-
 import java.lang.reflect.Type;
 
 /**
  * JSON deserializer for {@link Event}s.
- * <p>
- * Deserialized objects are of an appropriate subclass based on the value of the
- * top-level "type" element.
+ *
+ * <p>Deserialized objects are of an appropriate subclass based on the value of the top-level "type"
+ * element.
  */
 public class EventDeserializer implements JsonDeserializer<Event> {
   @Override
-  public Event deserialize(JsonElement json, Type typeOfT,
-      JsonDeserializationContext context) throws JsonParseException {
+  public Event deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
     if (!json.isJsonObject()) {
       throw new JsonParseException("Not an object");
     }
     JsonElement typeJson = json.getAsJsonObject().get("type");
-    if (typeJson == null || !typeJson.isJsonPrimitive()
+    if (typeJson == null
+        || !typeJson.isJsonPrimitive()
         || !typeJson.getAsJsonPrimitive().isString()) {
       throw new JsonParseException("Type is not a string: " + typeJson);
     }
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 56daccc..5c3da33 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
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.events;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Comparator.comparing;
 
-import com.google.common.base.Function;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
@@ -28,8 +27,8 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.UserIdentity;
@@ -64,14 +63,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -80,6 +71,12 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class EventFactory {
@@ -97,7 +94,8 @@
   private final SchemaFactory<ReviewDb> schema;
 
   @Inject
-  EventFactory(AccountCache accountCache,
+  EventFactory(
+      AccountCache accountCache,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       AccountByEmailCache byEmailCache,
       PatchListCache patchListCache,
@@ -120,8 +118,7 @@
   }
 
   /**
-   * Create a ChangeAttribute for the given change suitable for serialization to
-   * JSON.
+   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
    *
    * @param change
    * @return object suitable for serialization to JSON
@@ -136,8 +133,7 @@
   }
 
   /**
-   * Create a ChangeAttribute for the given change suitable for serialization to
-   * JSON.
+   * Create a ChangeAttribute for the given change suitable for serialization to JSON.
    *
    * @param db Review database
    * @param change
@@ -149,31 +145,32 @@
     a.branch = change.getDest().getShortName();
     a.topic = change.getTopic();
     a.id = change.getKey().get();
-    a.number = change.getId().toString();
+    a.number = change.getId().get();
     a.subject = change.getSubject();
     try {
       a.commitMessage = changeDataFactory.create(db, change).commitMessage();
     } catch (Exception e) {
-      log.error("Error while getting full commit message for"
-          + " change " + a.number);
+      log.error("Error while getting full commit message for change {}", a.number, e);
     }
     a.url = getChangeUrl(change);
     a.owner = asAccountAttribute(change.getOwner());
+    a.assignee = asAccountAttribute(change.getAssignee());
     a.status = change.getStatus();
+    a.createdOn = change.getCreatedOn().getTime() / 1000L;
     return a;
   }
 
   /**
-   * Create a RefUpdateAttribute for the given old ObjectId, new ObjectId, and
-   * branch that is suitable for serialization to JSON.
+   * Create a RefUpdateAttribute for the given old ObjectId, new ObjectId, and branch that is
+   * suitable for serialization to JSON.
    *
    * @param oldId
    * @param newId
    * @param refName
    * @return object suitable for serialization to JSON
    */
-  public RefUpdateAttribute asRefUpdateAttribute(ObjectId oldId, ObjectId newId,
-      Branch.NameKey refName) {
+  public RefUpdateAttribute asRefUpdateAttribute(
+      ObjectId oldId, ObjectId newId, Branch.NameKey refName) {
     RefUpdateAttribute ru = new RefUpdateAttribute();
     ru.newRev = newId != null ? newId.getName() : ObjectId.zeroId().getName();
     ru.oldRev = oldId != null ? oldId.getName() : ObjectId.zeroId().getName();
@@ -189,7 +186,6 @@
    * @param change
    */
   public void extend(ChangeAttribute a, Change change) {
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
     a.lastUpdated = change.getLastUpdatedOn().getTime() / 1000L;
     a.open = change.getStatus().isOpen();
   }
@@ -202,8 +198,7 @@
    */
   public void addAllReviewers(ReviewDb db, ChangeAttribute a, ChangeNotes notes)
       throws OrmException {
-    Collection<Account.Id> reviewers =
-        approvalsUtil.getReviewers(db, notes).all();
+    Collection<Account.Id> reviewers = approvalsUtil.getReviewers(db, notes).all();
     if (!reviewers.isEmpty()) {
       a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
       for (Account.Id id : reviewers) {
@@ -218,8 +213,7 @@
    * @param ca
    * @param submitRecords
    */
-  public void addSubmitRecords(ChangeAttribute ca,
-      List<SubmitRecord> submitRecords) {
+  public void addSubmitRecords(ChangeAttribute ca, List<SubmitRecord> submitRecords) {
     ca.submitRecords = new ArrayList<>();
 
     for (SubmitRecord submitRecord : submitRecords) {
@@ -236,8 +230,7 @@
     }
   }
 
-  private void addSubmitRecordLabels(SubmitRecord submitRecord,
-      SubmitRecordAttribute sa) {
+  private void addSubmitRecordLabels(SubmitRecord submitRecord, SubmitRecordAttribute sa) {
     if (submitRecord.labels != null && !submitRecord.labels.isEmpty()) {
       sa.labels = new ArrayList<>();
       for (SubmitRecord.Label lbl : submitRecord.labels) {
@@ -253,8 +246,7 @@
     }
   }
 
-  public void addDependencies(RevWalk rw, ChangeAttribute ca, Change change,
-      PatchSet currentPs) {
+  public void addDependencies(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs) {
     if (change == null || currentPs == null) {
       return;
     }
@@ -275,10 +267,9 @@
     }
   }
 
-  private void addDependsOn(RevWalk rw, ChangeAttribute ca, Change change,
-      PatchSet currentPs) throws OrmException, IOException {
-    RevCommit commit =
-        rw.parseCommit(ObjectId.fromString(currentPs.getRevision().get()));
+  private void addDependsOn(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
+      throws OrmException, IOException {
+    RevCommit commit = rw.parseCommit(ObjectId.fromString(currentPs.getRevision().get()));
     final List<String> parentNames = new ArrayList<>(commit.getParentCount());
     for (RevCommit p : commit.getParents()) {
       parentNames.add(p.name());
@@ -286,8 +277,7 @@
 
     // Find changes in this project having a patch set matching any parent of
     // this patch set's revision.
-    for (ChangeData cd : queryProvider.get().byProjectCommits(
-        change.getProject(), parentNames)) {
+    for (ChangeData cd : queryProvider.get().byProjectCommits(change.getProject(), parentNames)) {
       for (PatchSet ps : cd.patchSets()) {
         for (String p : parentNames) {
           if (!ps.getRevision().get().equals(p)) {
@@ -298,33 +288,32 @@
       }
     }
     // Sort by original parent order.
-    Collections.sort(ca.dependsOn, Ordering.natural().onResultOf(
-        new Function<DependencyAttribute, Integer>() {
-          @Override
-          public Integer apply(DependencyAttribute d) {
-            for (int i = 0; i < parentNames.size(); i++) {
-              if (parentNames.get(i).equals(d.revision)) {
-                return i;
+    Collections.sort(
+        ca.dependsOn,
+        comparing(
+            (DependencyAttribute d) -> {
+              for (int i = 0; i < parentNames.size(); i++) {
+                if (parentNames.get(i).equals(d.revision)) {
+                  return i;
+                }
               }
-            }
-            return parentNames.size() + 1;
-          }
-        }));
+              return parentNames.size() + 1;
+            }));
   }
 
-  private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change,
-      PatchSet currentPs) throws OrmException, IOException {
+  private void addNeededBy(RevWalk rw, ChangeAttribute ca, Change change, PatchSet currentPs)
+      throws OrmException, IOException {
     if (currentPs.getGroups().isEmpty()) {
       return;
     }
     String rev = currentPs.getRevision().get();
     // Find changes in the same related group as this patch set, having a patch
     // set whose parent matches this patch set's revision.
-    for (ChangeData cd : queryProvider.get().byProjectGroups(
-        change.getProject(), currentPs.getGroups())) {
-      patchSets: for (PatchSet ps : cd.patchSets()) {
-        RevCommit commit =
-            rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+    for (ChangeData cd :
+        queryProvider.get().byProjectGroups(change.getProject(), currentPs.getGroups())) {
+      patchSets:
+      for (PatchSet ps : cd.patchSets()) {
+        RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
         for (RevCommit p : commit.getParents()) {
           if (!p.name().equals(rev)) {
             continue;
@@ -348,14 +337,14 @@
 
   private DependencyAttribute newDependencyAttribute(Change c, PatchSet ps) {
     DependencyAttribute d = new DependencyAttribute();
-    d.number = c.getId().toString();
+    d.number = c.getId().get();
     d.id = c.getKey().toString();
     d.revision = ps.getRevision().get();
     d.ref = ps.getRefName();
     return d;
   }
 
-  public void addTrackingIds(ChangeAttribute a, Multimap<String, String> set) {
+  public void addTrackingIds(ChangeAttribute a, ListMultimap<String, String> set) {
     if (!set.isEmpty()) {
       a.trackingIds = new ArrayList<>(set.size());
       for (Map.Entry<String, Collection<String>> e : set.asMap().entrySet()) {
@@ -373,17 +362,25 @@
     a.commitMessage = commitMessage;
   }
 
-  public void addPatchSets(ReviewDb db, RevWalk revWalk, ChangeAttribute ca,
+  public void addPatchSets(
+      ReviewDb db,
+      RevWalk revWalk,
+      ChangeAttribute ca,
       Collection<PatchSet> ps,
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
       LabelTypes labelTypes) {
     addPatchSets(db, revWalk, ca, ps, approvals, false, null, labelTypes);
   }
 
-  public void addPatchSets(ReviewDb db, RevWalk revWalk, ChangeAttribute ca,
+  public void addPatchSets(
+      ReviewDb db,
+      RevWalk revWalk,
+      ChangeAttribute ca,
       Collection<PatchSet> ps,
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
-      boolean includeFiles, Change change, LabelTypes labelTypes) {
+      boolean includeFiles,
+      Change change,
+      LabelTypes labelTypes) {
     if (!ps.isEmpty()) {
       ca.patchSets = new ArrayList<>(ps.size());
       for (PatchSet p : ps) {
@@ -399,11 +396,10 @@
     }
   }
 
-  public void addPatchSetComments(PatchSetAttribute patchSetAttribute,
-      Collection<PatchLineComment> patchLineComments) {
-    for (PatchLineComment comment : patchLineComments) {
-      if (comment.getKey().getParentKey().getParentKey().get()
-          == Integer.parseInt(patchSetAttribute.number)) {
+  public void addPatchSetComments(
+      PatchSetAttribute patchSetAttribute, Collection<Comment> comments) {
+    for (Comment comment : comments) {
+      if (comment.key.patchSetId == patchSetAttribute.number) {
         if (patchSetAttribute.comments == null) {
           patchSetAttribute.comments = new ArrayList<>();
         }
@@ -412,8 +408,8 @@
     }
   }
 
-  public void addPatchSetFileNames(PatchSetAttribute patchSetAttribute,
-      Change change, PatchSet patchSet) {
+  public void addPatchSetFileNames(
+      PatchSetAttribute patchSetAttribute, Change change, PatchSet patchSet) {
     try {
       PatchList patchList = patchListCache.get(change, patchSet);
       for (PatchListEntry patch : patchList.getPatches()) {
@@ -430,12 +426,11 @@
         patchSetAttribute.files.add(p);
       }
     } catch (PatchListNotAvailableException e) {
-      log.warn("Cannot get patch list", e);
+      log.error("Cannot get patch list", e);
     }
   }
 
-  public void addComments(ChangeAttribute ca,
-      Collection<ChangeMessage> messages) {
+  public void addComments(ChangeAttribute ca, Collection<ChangeMessage> messages) {
     if (!messages.isEmpty()) {
       ca.comments = new ArrayList<>();
       for (ChangeMessage message : messages) {
@@ -445,15 +440,13 @@
   }
 
   /**
-   * Create a PatchSetAttribute for the given patchset suitable for
-   * serialization to JSON.
+   * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
    *
    * @param revWalk
    * @param patchSet
    * @return object suitable for serialization to JSON
    */
-  public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk,
-      Change change, PatchSet patchSet) {
+  public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
     try (ReviewDb db = schema.open()) {
       return asPatchSetAttribute(db, revWalk, change, patchSet);
     } catch (OrmException e) {
@@ -463,18 +456,17 @@
   }
 
   /**
-   * Create a PatchSetAttribute for the given patchset suitable for
-   * serialization to JSON.
+   * Create a PatchSetAttribute for the given patchset suitable for serialization to JSON.
    *
    * @param db Review database
    * @param patchSet
    * @return object suitable for serialization to JSON
    */
-  public PatchSetAttribute asPatchSetAttribute(ReviewDb db, RevWalk revWalk,
-      Change change, PatchSet patchSet) {
+  public PatchSetAttribute asPatchSetAttribute(
+      ReviewDb db, RevWalk revWalk, Change change, PatchSet patchSet) {
     PatchSetAttribute p = new PatchSetAttribute();
     p.revision = patchSet.getRevision().get();
-    p.number = Integer.toString(patchSet.getPatchSetId());
+    p.number = patchSet.getPatchSetId();
     p.ref = patchSet.getRefName();
     p.uploader = asAccountAttribute(patchSet.getUploader());
     p.createdOn = patchSet.getCreatedOn().getTime() / 1000L;
@@ -497,19 +489,18 @@
         p.author = asAccountAttribute(author.getAccount());
       }
 
-      List<Patch> list =
-          patchListCache.get(change, patchSet).toPatchList(pId);
+      List<Patch> list = patchListCache.get(change, patchSet).toPatchList(pId);
       for (Patch pe : list) {
-        if (!Patch.COMMIT_MSG.equals(pe.getFileName())) {
+        if (!Patch.isMagic(pe.getFileName())) {
           p.sizeDeletions -= pe.getDeletions();
           p.sizeInsertions += pe.getInsertions();
         }
       }
       p.kind = changeKindCache.getChangeKind(db, change, patchSet);
     } catch (IOException e) {
-      log.error("Cannot load patch set data for " + patchSet.getId(), e);
+      log.error("Cannot load patch set data for {}", patchSet.getId(), e);
     } catch (PatchListNotAvailableException e) {
-      log.error(String.format("Cannot get size information for %s.", pId), e);
+      log.error("Cannot get size information for {}.", pId, e);
     }
     return p;
   }
@@ -534,7 +525,9 @@
     return u;
   }
 
-  public void addApprovals(PatchSetAttribute p, PatchSet.Id id,
+  public void addApprovals(
+      PatchSetAttribute p,
+      PatchSet.Id id,
       Map<PatchSet.Id, Collection<PatchSetApproval>> all,
       LabelTypes labelTypes) {
     Collection<PatchSetApproval> list = all.get(id);
@@ -543,8 +536,8 @@
     }
   }
 
-  public void addApprovals(PatchSetAttribute p,
-      Collection<PatchSetApproval> list, LabelTypes labelTypes) {
+  public void addApprovals(
+      PatchSetAttribute p, Collection<PatchSetApproval> list, LabelTypes labelTypes) {
     if (!list.isEmpty()) {
       p.approvals = new ArrayList<>(list.size());
       for (PatchSetApproval a : list) {
@@ -559,8 +552,7 @@
   }
 
   /**
-   * Create an AuthorAttribute for the given account suitable for serialization
-   * to JSON.
+   * Create an AuthorAttribute for the given account suitable for serialization to JSON.
    *
    * @param id
    * @return object suitable for serialization to JSON
@@ -573,8 +565,7 @@
   }
 
   /**
-   * Create an AuthorAttribute for the given account suitable for serialization
-   * to JSON.
+   * Create an AuthorAttribute for the given account suitable for serialization to JSON.
    *
    * @param account
    * @return object suitable for serialization to JSON
@@ -592,8 +583,7 @@
   }
 
   /**
-   * Create an AuthorAttribute for the given person ident suitable for
-   * serialization to JSON.
+   * Create an AuthorAttribute for the given person ident suitable for serialization to JSON.
    *
    * @param ident
    * @return object suitable for serialization to JSON
@@ -606,15 +596,13 @@
   }
 
   /**
-   * Create an ApprovalAttribute for the given approval suitable for
-   * serialization to JSON.
+   * Create an ApprovalAttribute for the given approval suitable for serialization to JSON.
    *
    * @param approval
    * @param labelTypes label types for the containing project
    * @return object suitable for serialization to JSON
    */
-  public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval,
-      LabelTypes labelTypes) {
+  public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval, LabelTypes labelTypes) {
     ApprovalAttribute a = new ApprovalAttribute();
     a.type = approval.getLabelId().get();
     a.value = Short.toString(approval.getValue());
@@ -633,18 +621,19 @@
     MessageAttribute a = new MessageAttribute();
     a.timestamp = message.getWrittenOn().getTime() / 1000L;
     a.reviewer =
-        message.getAuthor() != null ? asAccountAttribute(message.getAuthor())
+        message.getAuthor() != null
+            ? asAccountAttribute(message.getAuthor())
             : asAccountAttribute(myIdent);
     a.message = message.getMessage();
     return a;
   }
 
-  public PatchSetCommentAttribute asPatchSetLineAttribute(PatchLineComment c) {
+  public PatchSetCommentAttribute asPatchSetLineAttribute(Comment c) {
     PatchSetCommentAttribute a = new PatchSetCommentAttribute();
-    a.reviewer = asAccountAttribute(c.getAuthor());
-    a.file = c.getKey().getParentKey().get();
-    a.line = c.getLine();
-    a.message = c.getMessage();
+    a.reviewer = asAccountAttribute(c.author.getId());
+    a.file = c.key.filename;
+    a.line = c.lineNbr;
+    a.message = c.message;
     return a;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
index 447e8b2..19470ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
@@ -22,20 +22,23 @@
   private static final Map<String, Class<?>> typesByString = new HashMap<>();
 
   static {
+    register(AssigneeChangedEvent.TYPE, AssigneeChangedEvent.class);
     register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
+    register(ChangeDeletedEvent.TYPE, ChangeDeletedEvent.class);
     register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
     register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
     register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
     register(CommitReceivedEvent.TYPE, CommitReceivedEvent.class);
     register(DraftPublishedEvent.TYPE, DraftPublishedEvent.class);
     register(HashtagsChangedEvent.TYPE, HashtagsChangedEvent.class);
-    register(RefUpdatedEvent.TYPE, RefUpdatedEvent.class);
+    register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
+    register(ProjectCreatedEvent.TYPE, ProjectCreatedEvent.class);
     register(RefReceivedEvent.TYPE, RefReceivedEvent.class);
+    register(RefUpdatedEvent.TYPE, RefUpdatedEvent.class);
     register(ReviewerAddedEvent.TYPE, ReviewerAddedEvent.class);
     register(ReviewerDeletedEvent.TYPE, ReviewerDeletedEvent.class);
-    register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
     register(TopicChangedEvent.TYPE, TopicChangedEvent.class);
-    register(ProjectCreatedEvent.TYPE, ProjectCreatedEvent.class);
+    register(VoteDeletedEvent.TYPE, VoteDeletedEvent.class);
   }
 
   /**
@@ -43,18 +46,17 @@
    *
    * @param eventType The event type to register.
    * @param eventClass The event class to register.
-   **/
-  public static void register(String eventType,
-      Class<? extends Event> eventClass) {
+   */
+  public static void register(String eventType, Class<? extends Event> eventClass) {
     typesByString.put(eventType, eventClass);
   }
 
-  /** Get the class for an event type.
+  /**
+   * Get the class for an event type.
    *
    * @param type The type.
-   * @return The event class, or null if no class is registered with the
-   * given type
-   **/
+   * @return The event class, or null if no class is registered with the given type
+   */
   public static Class<?> getClass(String type) {
     return typesByString.get(type);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
index eaaf1a83..fcf4a08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventsMetrics.java
@@ -28,12 +28,11 @@
 
   @Inject
   public EventsMetrics(MetricMaker metricMaker) {
-    events = metricMaker.newCounter(
-        "events",
-        new Description("Triggered events")
-          .setRate()
-          .setUnit("triggered events"),
-        Field.ofString("type"));
+    events =
+        metricMaker.newCounter(
+            "events",
+            new Description("Triggered events").setRate().setUnit("triggered events"),
+            Field.ofString("type"));
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
index 4365c74..1de0fc3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/HashtagsChangedEvent.java
@@ -25,7 +25,7 @@
   public String[] removed;
   public String[] hashtags;
 
-  public HashtagsChangedEvent (Change change) {
+  public HashtagsChangedEvent(Change change) {
     super(TYPE, change);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
index e2f51ac..743b314 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ProjectNameKeySerializer.java
@@ -19,14 +19,12 @@
 import com.google.gson.JsonPrimitive;
 import com.google.gson.JsonSerializationContext;
 import com.google.gson.JsonSerializer;
-
 import java.lang.reflect.Type;
 
-public class ProjectNameKeySerializer
-    implements JsonSerializer<Project.NameKey> {
+public class ProjectNameKeySerializer implements JsonSerializer<Project.NameKey> {
   @Override
-  public JsonElement serialize(Project.NameKey project, Type typeOfSrc,
-      JsonSerializationContext context) {
+  public JsonElement serialize(
+      Project.NameKey project, Type typeOfSrc, JsonSerializationContext context) {
     return new JsonPrimitive(project.get());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java
index 6cc1ae5..aa02d11 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/RefReceivedEvent.java
@@ -15,7 +15,6 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
-
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class RefReceivedEvent extends RefEvent {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
index f206cac..02f9d43 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ReviewerDeletedEvent.java
@@ -22,6 +22,7 @@
 public class ReviewerDeletedEvent extends PatchSetEvent {
   public static final String TYPE = "reviewer-deleted";
   public Supplier<AccountAttribute> reviewer;
+  public Supplier<AccountAttribute> remover;
   public Supplier<ApprovalAttribute[]> approvals;
   public String comment;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index a5fe0a6..de6cec1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -24,7 +24,9 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -36,6 +38,7 @@
 import com.google.gerrit.extensions.events.ReviewerDeletedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
 import com.google.gerrit.extensions.events.TopicEditedListener;
+import com.google.gerrit.extensions.events.VoteDeletedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
@@ -58,63 +61,55 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Map.Entry;
-
 @Singleton
-public class StreamEventsApiListener implements
-    ChangeAbandonedListener,
-    ChangeMergedListener,
-    ChangeRestoredListener,
-    CommentAddedListener,
-    DraftPublishedListener,
-    GitReferenceUpdatedListener,
-    HashtagsEditedListener,
-    NewProjectCreatedListener,
-    ReviewerAddedListener,
-    ReviewerDeletedListener,
-    RevisionCreatedListener,
-    TopicEditedListener {
-  private static final Logger log =
-      LoggerFactory.getLogger(StreamEventsApiListener.class);
+public class StreamEventsApiListener
+    implements AssigneeChangedListener,
+        ChangeAbandonedListener,
+        ChangeDeletedListener,
+        ChangeMergedListener,
+        ChangeRestoredListener,
+        CommentAddedListener,
+        DraftPublishedListener,
+        GitReferenceUpdatedListener,
+        HashtagsEditedListener,
+        NewProjectCreatedListener,
+        ReviewerAddedListener,
+        ReviewerDeletedListener,
+        RevisionCreatedListener,
+        TopicEditedListener,
+        VoteDeletedListener {
+  private static final Logger log = LoggerFactory.getLogger(StreamEventsApiListener.class);
 
   public static class Module extends AbstractModule {
     @Override
     protected void configure() {
-      DynamicSet.bind(binder(), ChangeAbandonedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ChangeMergedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ChangeRestoredListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), CommentAddedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), DraftPublishedListener.class)
-        .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeDeletedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeRestoredListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), CommentAddedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), DraftPublishedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), HashtagsEditedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), NewProjectCreatedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ReviewerAddedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), ReviewerDeletedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), RevisionCreatedListener.class)
-        .to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), TopicEditedListener.class)
-        .to(StreamEventsApiListener.class);
+          .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HashtagsEditedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ReviewerAddedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ReviewerDeletedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), RevisionCreatedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), TopicEditedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
     }
   }
 
@@ -127,7 +122,8 @@
   private final ChangeNotes.Factory changeNotesFactory;
 
   @Inject
-  StreamEventsApiListener(DynamicItem<EventDispatcher> dispatcher,
+  StreamEventsApiListener(
+      DynamicItem<EventDispatcher> dispatcher,
       Provider<ReviewDb> db,
       EventFactory eventFactory,
       ProjectCache projectCache,
@@ -155,13 +151,11 @@
     return getNotes(info).getChange();
   }
 
-  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info)
-      throws OrmException {
+  private PatchSet getPatchSet(ChangeNotes notes, RevisionInfo info) throws OrmException {
     return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
   }
 
-  private Supplier<ChangeAttribute> changeAttributeSupplier(
-      final Change change) {
+  private Supplier<ChangeAttribute> changeAttributeSupplier(final Change change) {
     return Suppliers.memoize(
         new Supplier<ChangeAttribute>() {
           @Override
@@ -171,14 +165,14 @@
         });
   }
 
-  private Supplier<AccountAttribute> accountAttributeSupplier(
-      final AccountInfo account) {
+  private Supplier<AccountAttribute> accountAttributeSupplier(final AccountInfo account) {
     return Suppliers.memoize(
         new Supplier<AccountAttribute>() {
           @Override
           public AccountAttribute get() {
-            return account != null ? eventFactory.asAccountAttribute(
-                new Account.Id(account._accountId)) : null;
+            return account != null
+                ? eventFactory.asAccountAttribute(new Account.Id(account._accountId))
+                : null;
           }
         });
   }
@@ -189,11 +183,9 @@
         new Supplier<PatchSetAttribute>() {
           @Override
           public PatchSetAttribute get() {
-            try (Repository repo =
-                  repoManager.openRepository(change.getProject());
+            try (Repository repo = repoManager.openRepository(change.getProject());
                 RevWalk revWalk = new RevWalk(repo)) {
-              return eventFactory.asPatchSetAttribute(
-                  revWalk, change, patchSet);
+              return eventFactory.asPatchSetAttribute(revWalk, change, patchSet);
             } catch (IOException e) {
               throw new RuntimeException(e);
             }
@@ -201,21 +193,18 @@
         });
   }
 
-  private static Map<String, Short> convertApprovalsMap(
-      Map<String, ApprovalInfo> approvals) {
+  private static Map<String, Short> convertApprovalsMap(Map<String, ApprovalInfo> approvals) {
     Map<String, Short> result = new HashMap<>();
     for (Entry<String, ApprovalInfo> e : approvals.entrySet()) {
-      Short value =
-          e.getValue().value == null ? null : e.getValue().value.shortValue();
+      Short value = e.getValue().value == null ? null : e.getValue().value.shortValue();
       result.put(e.getKey(), value);
     }
     return result;
   }
 
-  private ApprovalAttribute getApprovalAttribute(LabelTypes labelTypes,
-      Entry<String, Short> approval,
-      Map<String, Short> oldApprovals) {
-  ApprovalAttribute a = new ApprovalAttribute();
+  private ApprovalAttribute getApprovalAttribute(
+      LabelTypes labelTypes, Entry<String, Short> approval, Map<String, Short> oldApprovals) {
+    ApprovalAttribute a = new ApprovalAttribute();
     a.type = approval.getKey();
 
     if (oldApprovals != null && !oldApprovals.isEmpty()) {
@@ -234,21 +223,21 @@
   }
 
   private Supplier<ApprovalAttribute[]> approvalsAttributeSupplier(
-      final Change change, Map<String, ApprovalInfo> newApprovals,
+      final Change change,
+      Map<String, ApprovalInfo> newApprovals,
       final Map<String, ApprovalInfo> oldApprovals) {
     final Map<String, Short> approvals = convertApprovalsMap(newApprovals);
     return Suppliers.memoize(
         new Supplier<ApprovalAttribute[]>() {
           @Override
           public ApprovalAttribute[] get() {
-            LabelTypes labelTypes = projectCache.get(
-                change.getProject()).getLabelTypes();
+            LabelTypes labelTypes = projectCache.get(change.getProject()).getLabelTypes();
             if (approvals.size() > 0) {
               ApprovalAttribute[] r = new ApprovalAttribute[approvals.size()];
               int i = 0;
               for (Map.Entry<String, Short> approval : approvals.entrySet()) {
-                r[i++] = getApprovalAttribute(labelTypes, approval,
-                    convertApprovalsMap(oldApprovals));
+                r[i++] =
+                    getApprovalAttribute(labelTypes, approval, convertApprovalsMap(oldApprovals));
               }
               return r;
             }
@@ -259,13 +248,28 @@
 
   String[] hashtagArray(Collection<String> hashtags) {
     if (hashtags != null && hashtags.size() > 0) {
-      return Sets.newHashSet(hashtags).toArray(
-          new String[hashtags.size()]);
+      return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
     }
     return null;
   }
 
   @Override
+  public void onAssigneeChanged(AssigneeChangedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      AssigneeChangedEvent event = new AssigneeChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+      event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
   public void onTopicEdited(TopicEditedListener.Event ev) {
     try {
       Change change = getChange(ev.getChange());
@@ -306,33 +310,32 @@
       Change change = notes.getChange();
       ReviewerDeletedEvent event = new ReviewerDeletedEvent(change);
       event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change,
-          psUtil.current(db.get(), notes));
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
+      event.remover = accountAttributeSupplier(ev.getWho());
       event.comment = ev.getComment();
-      event.approvals = approvalsAttributeSupplier(change,
-          ev.getNewApprovals(), ev.getOldApprovals());
+      event.approvals =
+          approvalsAttributeSupplier(change, ev.getNewApprovals(), ev.getOldApprovals());
 
       dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
-
   }
 
   @Override
-  public void onReviewerAdded(ReviewerAddedListener.Event ev) {
+  public void onReviewersAdded(ReviewerAddedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
       ReviewerAddedEvent event = new ReviewerAddedEvent(change);
 
       event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change,
-          psUtil.current(db.get(), notes));
-      event.reviewer = accountAttributeSupplier(ev.getReviewer());
-
-      dispatcher.get().postEvent(change, event);
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      for (AccountInfo reviewer : ev.getReviewers()) {
+        event.reviewer = accountAttributeSupplier(reviewer);
+        dispatcher.get().postEvent(change, event);
+      }
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -371,18 +374,18 @@
     if (ev.getUpdater() != null) {
       event.submitter = accountAttributeSupplier(ev.getUpdater());
     }
-    final Branch.NameKey refName =
-        new Branch.NameKey(ev.getProjectName(), ev.getRefName());
-    event.refUpdate = Suppliers.memoize(
-        new Supplier<RefUpdateAttribute>() {
-          @Override
-          public RefUpdateAttribute get() {
-            return eventFactory.asRefUpdateAttribute(
-                ObjectId.fromString(ev.getOldObjectId()),
-                ObjectId.fromString(ev.getNewObjectId()),
-                refName);
-          }
-        });
+    final Branch.NameKey refName = new Branch.NameKey(ev.getProjectName(), ev.getRefName());
+    event.refUpdate =
+        Suppliers.memoize(
+            new Supplier<RefUpdateAttribute>() {
+              @Override
+              public RefUpdateAttribute get() {
+                return eventFactory.asRefUpdateAttribute(
+                    ObjectId.fromString(ev.getOldObjectId()),
+                    ObjectId.fromString(ev.getNewObjectId()),
+                    refName);
+              }
+            });
     dispatcher.get().postEvent(refName, event);
   }
 
@@ -413,11 +416,10 @@
       CommentAddedEvent event = new CommentAddedEvent(change);
 
       event.change = changeAttributeSupplier(change);
-      event.author =  accountAttributeSupplier(ev.getWho());
+      event.author = accountAttributeSupplier(ev.getWho());
       event.patchSet = patchSetAttributeSupplier(change, ps);
       event.comment = ev.getComment();
-      event.approvals = approvalsAttributeSupplier(
-          change, ev.getApprovals(), ev.getOldApprovals());
+      event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
 
       dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
@@ -434,8 +436,7 @@
 
       event.change = changeAttributeSupplier(change);
       event.restorer = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change,
-          psUtil.current(db.get(), notes));
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.reason = ev.getReason();
 
       dispatcher.get().postEvent(change, event);
@@ -453,8 +454,7 @@
 
       event.change = changeAttributeSupplier(change);
       event.submitter = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change,
-          psUtil.current(db.get(), notes));
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.newRev = ev.getNewRevisionId();
 
       dispatcher.get().postEvent(change, event);
@@ -472,8 +472,7 @@
 
       event.change = changeAttributeSupplier(change);
       event.abandoner = accountAttributeSupplier(ev.getWho());
-      event.patchSet = patchSetAttributeSupplier(change,
-          psUtil.current(db.get(), notes));
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
       event.reason = ev.getReason();
 
       dispatcher.get().postEvent(change, event);
@@ -481,4 +480,40 @@
       log.error("Failed to dispatch event", e);
     }
   }
+
+  @Override
+  public void onVoteDeleted(VoteDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      VoteDeletedEvent event = new VoteDeletedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.patchSet = patchSetAttributeSupplier(change, psUtil.current(db.get(), notes));
+      event.comment = ev.getMessage();
+      event.reviewer = accountAttributeSupplier(ev.getReviewer());
+      event.remover = accountAttributeSupplier(ev.getWho());
+      event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onChangeDeleted(ChangeDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeDeletedEvent event = new ChangeDeletedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.deleter = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java
index fd7b350..a6e27b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierDeserializer.java
@@ -21,20 +21,18 @@
 import com.google.gson.JsonDeserializer;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonParseException;
-
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
 
 public class SupplierDeserializer implements JsonDeserializer<Supplier<?>> {
 
   @Override
-  public Supplier<?> deserialize(JsonElement json, Type typeOfT,
-      JsonDeserializationContext context) throws JsonParseException {
+  public Supplier<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
     checkArgument(typeOfT instanceof ParameterizedType);
     ParameterizedType parameterizedType = (ParameterizedType) typeOfT;
     if (parameterizedType.getActualTypeArguments().length != 1) {
-      throw new JsonParseException(
-          "Expected one parameter type in Supplier interface.");
+      throw new JsonParseException("Expected one parameter type in Supplier interface.");
     }
     Type supplierOf = parameterizedType.getActualTypeArguments()[0];
     return Suppliers.ofInstance(context.deserialize(json, supplierOf));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java
index 76138b0..5f66c49 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/SupplierSerializer.java
@@ -18,14 +18,12 @@
 import com.google.gson.JsonElement;
 import com.google.gson.JsonSerializationContext;
 import com.google.gson.JsonSerializer;
-
 import java.lang.reflect.Type;
 
 public class SupplierSerializer implements JsonSerializer<Supplier<?>> {
 
   @Override
-  public JsonElement serialize(Supplier<?> src, Type typeOfSrc,
-      JsonSerializationContext context) {
+  public JsonElement serialize(Supplier<?> src, Type typeOfSrc, JsonSerializationContext context) {
     return context.serialize(src.get());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/VoteDeletedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/VoteDeletedEvent.java
new file mode 100644
index 0000000..87c4c05
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/VoteDeletedEvent.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.ApprovalAttribute;
+
+public class VoteDeletedEvent extends PatchSetEvent {
+  static final String TYPE = "vote-deleted";
+  public Supplier<AccountAttribute> reviewer;
+  public Supplier<AccountAttribute> remover;
+  public Supplier<ApprovalAttribute[]> approvals;
+  public String comment;
+
+  public VoteDeletedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
index be6f692..a5800fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeEvent;
-
 import java.sql.Timestamp;
 
 public abstract class AbstractChangeEvent implements ChangeEvent {
@@ -27,8 +26,8 @@
   private final Timestamp when;
   private final NotifyHandling notify;
 
-  protected AbstractChangeEvent(ChangeInfo change, AccountInfo who,
-      Timestamp when, NotifyHandling notify) {
+  protected AbstractChangeEvent(
+      ChangeInfo change, AccountInfo who, Timestamp when, NotifyHandling notify) {
     this.changeInfo = change;
     this.who = who;
     this.when = when;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
index d3d7e09..6b72b5e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
@@ -19,16 +19,18 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.RevisionEvent;
-
 import java.sql.Timestamp;
 
-public abstract class AbstractRevisionEvent extends AbstractChangeEvent
-    implements RevisionEvent {
+public abstract class AbstractRevisionEvent extends AbstractChangeEvent implements RevisionEvent {
 
   private final RevisionInfo revisionInfo;
 
-  protected AbstractRevisionEvent(ChangeInfo change, RevisionInfo revision,
-      AccountInfo who, Timestamp when, NotifyHandling notify) {
+  protected AbstractRevisionEvent(
+      ChangeInfo change,
+      RevisionInfo revision,
+      AccountInfo who,
+      Timestamp when,
+      NotifyHandling notify) {
     super(change, who, when, notify);
     revisionInfo = revision;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
index c910a7a..45f1159 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
@@ -19,14 +19,15 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
+@Singleton
 public class AgreementSignup {
   private final DynamicSet<AgreementSignupListener> listeners;
   private final EventUtil util;
 
   @Inject
-  AgreementSignup(DynamicSet<AgreementSignupListener> listeners,
-      EventUtil util) {
+  AgreementSignup(DynamicSet<AgreementSignupListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
new file mode 100644
index 0000000..8f8f13e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.AssigneeChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AssigneeChanged {
+  private static final Logger log = LoggerFactory.getLogger(AssigneeChanged.class);
+
+  private final DynamicSet<AssigneeChangedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  AssigneeChanged(DynamicSet<AssigneeChangedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Account account, Account oldAssignee, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.accountInfo(account),
+              util.accountInfo(oldAssignee),
+              when);
+      for (AssigneeChangedListener l : listeners) {
+        try {
+          l.onAssigneeChanged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(event, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent implements AssigneeChangedListener.Event {
+    private final AccountInfo oldAssignee;
+
+    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Timestamp when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.oldAssignee = oldAssignee;
+    }
+
+    @Override
+    public AccountInfo getOldAssignee() {
+      return oldAssignee;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index e303d8b..36574f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -27,38 +27,44 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-
+@Singleton
 public class ChangeAbandoned {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeAbandoned.class);
+  private static final Logger log = LoggerFactory.getLogger(ChangeAbandoned.class);
 
   private final DynamicSet<ChangeAbandonedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ChangeAbandoned(DynamicSet<ChangeAbandonedListener> listeners,
-      EventUtil util) {
+  ChangeAbandoned(DynamicSet<ChangeAbandonedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet ps, Account abandoner, String reason,
-      Timestamp when, NotifyHandling notifyHandling) {
+  public void fire(
+      Change change,
+      PatchSet ps,
+      Account abandoner,
+      String reason,
+      Timestamp when,
+      NotifyHandling notifyHandling) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.revisionInfo(change.getProject(), ps),
-          util.accountInfo(abandoner),
-          reason, when, notifyHandling);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(abandoner),
+              reason,
+              when,
+              notifyHandling);
       for (ChangeAbandonedListener l : listeners) {
         try {
           l.onChangeAbandoned(event);
@@ -66,30 +72,27 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
   private static class Event extends AbstractRevisionEvent
       implements ChangeAbandonedListener.Event {
-    private final AccountInfo abandoner;
     private final String reason;
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo abandoner,
-        String reason, Timestamp when, NotifyHandling notifyHandling) {
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo abandoner,
+        String reason,
+        Timestamp when,
+        NotifyHandling notifyHandling) {
       super(change, revision, abandoner, when, notifyHandling);
-      this.abandoner = abandoner;
       this.reason = reason;
     }
 
     @Override
-    public AccountInfo getAbandoner() {
-      return abandoner;
-    }
-
-    @Override
     public String getReason() {
       return reason;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
new file mode 100644
index 0000000..26bc229
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class ChangeDeleted {
+  private static final Logger log = LoggerFactory.getLogger(ChangeDeleted.class);
+
+  private final DynamicSet<ChangeDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeDeleted(DynamicSet<ChangeDeletedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Account deleter, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(util.changeInfo(change), util.accountInfo(deleter), when);
+      for (ChangeDeletedListener l : listeners) {
+        try {
+          l.onChangeDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent implements ChangeDeletedListener.Event {
+    Event(ChangeInfo change, AccountInfo deleter, Timestamp when) {
+      super(change, deleter, when, NotifyHandling.ALL);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 00d276b..d969406 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -27,38 +27,38 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-
+@Singleton
 public class ChangeMerged {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeMerged.class);
+  private static final Logger log = LoggerFactory.getLogger(ChangeMerged.class);
 
   private final DynamicSet<ChangeMergedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ChangeMerged(DynamicSet<ChangeMergedListener> listeners,
-      EventUtil util) {
+  ChangeMerged(DynamicSet<ChangeMergedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet ps, Account merger,
-      String newRevisionId, Timestamp when) {
+  public void fire(
+      Change change, PatchSet ps, Account merger, String newRevisionId, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.revisionInfo(change.getProject(), ps),
-          util.accountInfo(merger),
-          newRevisionId, when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(merger),
+              newRevisionId,
+              when);
       for (ChangeMergedListener l : listeners) {
         try {
           l.onChangeMerged(event);
@@ -66,30 +66,25 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
-  private static class Event extends AbstractRevisionEvent
-      implements ChangeMergedListener.Event {
-    private final AccountInfo merger;
+  private static class Event extends AbstractRevisionEvent implements ChangeMergedListener.Event {
     private final String newRevisionId;
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo merger,
-        String newRevisionId, Timestamp when) {
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo merger,
+        String newRevisionId,
+        Timestamp when) {
       super(change, revision, merger, when, NotifyHandling.ALL);
-      this.merger = merger;
       this.newRevisionId = newRevisionId;
     }
 
     @Override
-    public AccountInfo getMerger() {
-      return merger;
-    }
-
-    @Override
     public String getNewRevisionId() {
       return newRevisionId;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 5dda4d1..323bd34 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -27,38 +27,37 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-
+@Singleton
 public class ChangeRestored {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeRestored.class);
+  private static final Logger log = LoggerFactory.getLogger(ChangeRestored.class);
 
   private final DynamicSet<ChangeRestoredListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ChangeRestored(DynamicSet<ChangeRestoredListener> listeners,
-      EventUtil util) {
+  ChangeRestored(DynamicSet<ChangeRestoredListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet ps, Account restorer, String reason,
-      Timestamp when) {
+  public void fire(Change change, PatchSet ps, Account restorer, String reason, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.revisionInfo(change.getProject(), ps),
-          util.accountInfo(restorer),
-          reason, when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(restorer),
+              reason,
+              when);
       for (ChangeRestoredListener l : listeners) {
         try {
           l.onChangeRestored(event);
@@ -66,31 +65,26 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
-  private static class Event extends AbstractRevisionEvent
-      implements ChangeRestoredListener.Event {
+  private static class Event extends AbstractRevisionEvent implements ChangeRestoredListener.Event {
 
-    private AccountInfo restorer;
     private String reason;
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo restorer,
-        String reason, Timestamp when) {
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo restorer,
+        String reason,
+        Timestamp when) {
       super(change, revision, restorer, when, NotifyHandling.ALL);
-      this.restorer = restorer;
       this.reason = reason;
     }
 
     @Override
-    public AccountInfo getRestorer() {
-      return restorer;
-    }
-
-    @Override
     public String getReason() {
       return reason;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index d963a47..1e91ab3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -21,22 +21,20 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.sql.Timestamp;
-
+@Singleton
 public class ChangeReverted {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeReverted.class);
+  private static final Logger log = LoggerFactory.getLogger(ChangeReverted.class);
 
   private final DynamicSet<ChangeRevertedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ChangeReverted(DynamicSet<ChangeRevertedListener> listeners,
-      EventUtil util) {
+  ChangeReverted(DynamicSet<ChangeRevertedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -46,8 +44,7 @@
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change), util.changeInfo(revertChange), when);
+      Event event = new Event(util.changeInfo(change), util.changeInfo(revertChange), when);
       for (ChangeRevertedListener l : listeners) {
         try {
           l.onChangeReverted(event);
@@ -60,8 +57,7 @@
     }
   }
 
-  private static class Event extends AbstractChangeEvent
-      implements ChangeRevertedListener.Event {
+  private static class Event extends AbstractChangeEvent implements ChangeRevertedListener.Event {
     private final ChangeInfo revertChange;
 
     Event(ChangeInfo change, ChangeInfo revertChange, Timestamp when) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 0c75e2e..bfbdc7f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -28,42 +28,47 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+@Singleton
 public class CommentAdded {
-  private static final Logger log =
-      LoggerFactory.getLogger(CommentAdded.class);
+  private static final Logger log = LoggerFactory.getLogger(CommentAdded.class);
 
   private final DynamicSet<CommentAddedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  CommentAdded(DynamicSet<CommentAddedListener> listeners,
-      EventUtil util) {
+  CommentAdded(DynamicSet<CommentAddedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet ps, Account author,
-      String comment, Map<String, Short> approvals,
-      Map<String, Short> oldApprovals, Timestamp when) {
+  public void fire(
+      Change change,
+      PatchSet ps,
+      Account author,
+      String comment,
+      Map<String, Short> approvals,
+      Map<String, Short> oldApprovals,
+      Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change),
-          util.revisionInfo(change.getProject(), ps),
-          util.accountInfo(author),
-          comment,
-          util.approvals(author, approvals, when),
-          util.approvals(author, oldApprovals, when),
-          when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(author),
+              comment,
+              util.approvals(author, approvals, when),
+              util.approvals(author, oldApprovals, when),
+              when);
       for (CommentAddedListener l : listeners) {
         try {
           l.onCommentAdded(event);
@@ -71,36 +76,32 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
-  private static class Event extends AbstractRevisionEvent
-      implements CommentAddedListener.Event {
+  private static class Event extends AbstractRevisionEvent implements CommentAddedListener.Event {
 
-    private final AccountInfo author;
     private final String comment;
     private final Map<String, ApprovalInfo> approvals;
     private final Map<String, ApprovalInfo> oldApprovals;
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo author,
-        String comment, Map<String, ApprovalInfo> approvals,
-        Map<String, ApprovalInfo> oldApprovals, Timestamp when) {
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo author,
+        String comment,
+        Map<String, ApprovalInfo> approvals,
+        Map<String, ApprovalInfo> oldApprovals,
+        Timestamp when) {
       super(change, revision, author, when, NotifyHandling.ALL);
-      this.author = author;
       this.comment = comment;
       this.approvals = approvals;
       this.oldApprovals = oldApprovals;
     }
 
     @Override
-    public AccountInfo getAuthor() {
-      return author;
-    }
-
-    @Override
     public String getComment() {
       return comment;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
index 9e3e5a2..32a1531 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
@@ -27,35 +27,33 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-
+@Singleton
 public class DraftPublished {
-  private static final Logger log =
-      LoggerFactory.getLogger(DraftPublished.class);
+  private static final Logger log = LoggerFactory.getLogger(DraftPublished.class);
 
   private final DynamicSet<DraftPublishedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  public DraftPublished(DynamicSet<DraftPublishedListener> listeners,
-      EventUtil util) {
+  public DraftPublished(DynamicSet<DraftPublishedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet patchSet, Account accountId,
-      Timestamp when) {
+  public void fire(Change change, PatchSet patchSet, Account accountId, Timestamp when) {
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.revisionInfo(change.getProject(), patchSet),
-          util.accountInfo(accountId),
-          when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(accountId),
+              when);
       for (DraftPublishedListener l : listeners) {
         try {
           l.onDraftPublished(event);
@@ -63,25 +61,15 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
-  private static class Event extends AbstractRevisionEvent
-      implements DraftPublishedListener.Event {
-    private final AccountInfo publisher;
+  private static class Event extends AbstractRevisionEvent implements DraftPublishedListener.Event {
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo publisher,
-        Timestamp when) {
+    Event(ChangeInfo change, RevisionInfo revision, AccountInfo publisher, Timestamp when) {
       super(change, revision, publisher, when, NotifyHandling.ALL);
-      this.publisher = publisher;
-    }
-
-    @Override
-    public AccountInfo getPublisher() {
-      return publisher;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index e519410..57382f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -32,85 +34,108 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+@Singleton
 public class EventUtil {
   private static final Logger log = LoggerFactory.getLogger(EventUtil.class);
 
+  private static final ImmutableSet<ListChangesOption> CHANGE_OPTIONS;
+
+  static {
+    EnumSet<ListChangesOption> opts = EnumSet.allOf(ListChangesOption.class);
+
+    // Some options, like actions, are expensive to compute because they potentially have to walk
+    // lots of history and inspect lots of other changes.
+    opts.remove(ListChangesOption.CHANGE_ACTIONS);
+    opts.remove(ListChangesOption.CURRENT_ACTIONS);
+
+    // CHECK suppresses some exceptions on corrupt changes, which is not appropriate for passing
+    // through the event system as we would rather let them propagate.
+    opts.remove(ListChangesOption.CHECK);
+
+    CHANGE_OPTIONS = Sets.immutableEnumSet(opts);
+  }
+
   private final ChangeData.Factory changeDataFactory;
   private final Provider<ReviewDb> db;
-  private final ChangeJson changeJson;
+  private final ChangeJson.Factory changeJsonFactory;
 
   @Inject
-  EventUtil(ChangeJson.Factory changeJsonFactory,
+  EventUtil(
+      ChangeJson.Factory changeJsonFactory,
       ChangeData.Factory changeDataFactory,
       Provider<ReviewDb> db) {
     this.changeDataFactory = changeDataFactory;
     this.db = db;
-    EnumSet<ListChangesOption> opts = EnumSet.allOf(ListChangesOption.class);
-    opts.remove(ListChangesOption.CHECK);
-    this.changeJson = changeJsonFactory.create(opts);
+    this.changeJsonFactory = changeJsonFactory;
   }
 
   public ChangeInfo changeInfo(Change change) throws OrmException {
-    return changeJson.format(change);
+    return changeJsonFactory.create(CHANGE_OPTIONS).format(change);
   }
 
   public RevisionInfo revisionInfo(Project project, PatchSet ps)
-      throws OrmException, PatchListNotAvailableException, GpgException,
-             IOException {
+      throws OrmException, PatchListNotAvailableException, GpgException, IOException {
     return revisionInfo(project.getNameKey(), ps);
   }
 
   public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
-      throws OrmException, PatchListNotAvailableException, GpgException,
-             IOException {
-    ChangeData cd = changeDataFactory.create(db.get(),
-        project, ps.getId().getParentKey());
+      throws OrmException, PatchListNotAvailableException, GpgException, IOException {
+    ChangeData cd = changeDataFactory.create(db.get(), project, ps.getId().getParentKey());
     ChangeControl ctl = cd.changeControl();
-    return changeJson.getRevisionInfo(ctl, ps);
+    return changeJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(ctl, ps);
   }
 
   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;
+    AccountInfo accountInfo = new AccountInfo(a.getId().get());
+    accountInfo.email = a.getPreferredEmail();
+    accountInfo.name = a.getFullName();
+    accountInfo.username = a.getUserName();
+    return accountInfo;
   }
 
-  public Map<String, ApprovalInfo> approvals(Account a,
-      Map<String, Short> approvals, Timestamp ts) {
+  public Map<String, ApprovalInfo> approvals(
+      Account a, Map<String, Short> approvals, Timestamp ts) {
     Map<String, ApprovalInfo> result = new HashMap<>();
     for (Map.Entry<String, Short> e : approvals.entrySet()) {
-      Integer value = e.getValue() != null ? new Integer(e.getValue()) : null;
-      result.put(e.getKey(),
-          ChangeJson.getApprovalInfo(a.getId(), value, null, ts));
+      Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
+      result.put(e.getKey(), ChangeJson.getApprovalInfo(a.getId(), value, null, null, ts));
     }
     return result;
   }
 
-  public void logEventListenerError(Object event, Object listener,
-      Exception error) {
+  public void logEventListenerError(Object event, Object listener, Exception error) {
     if (log.isDebugEnabled()) {
-      log.debug(String.format(
-          "Error in event listener %s for event %s",
-          listener.getClass().getName(), event.getClass().getName()), error);
+      log.debug(
+          "Error in event listener {} for event {}",
+          listener.getClass().getName(),
+          event.getClass().getName(),
+          error);
     } else {
-      log.warn("Error in listener {} for event {}: {}",
-          listener.getClass().getName(), event.getClass().getName(),
+      log.warn(
+          "Error in event listener {} for event {}: {}",
+          listener.getClass().getName(),
+          event.getClass().getName(),
           error.getMessage());
     }
   }
+
+  public static void logEventListenerError(Object listener, Exception error) {
+    if (log.isDebugEnabled()) {
+      log.debug("Error in event listener {}", listener.getClass().getName(), error);
+    } else {
+      log.warn("Error in event listener {}: {}", listener.getClass().getName(), error.getMessage());
+    }
+  }
 }
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 381dced..be14827 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
@@ -21,41 +21,46 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
+@Singleton
 public class GitReferenceUpdated {
-  public static final GitReferenceUpdated DISABLED = new GitReferenceUpdated() {
-    @Override
-    public void fire(Project.NameKey project, RefUpdate refUpdate,
-        ReceiveCommand.Type type, Account updater) {}
+  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,
-        Account updater) {}
+        @Override
+        public void fire(Project.NameKey project, RefUpdate refUpdate, Account updater) {}
 
-    @Override
-    public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
-        ObjectId newObjectId, Account 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, ReceiveCommand cmd, Account updater) {}
 
-    @Override
-    public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
-        Account updater) {}
-  };
+        @Override
+        public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate, Account updater) {}
+      };
 
   private final DynamicSet<GitReferenceUpdatedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  GitReferenceUpdated(DynamicSet<GitReferenceUpdatedListener> listeners,
-      EventUtil util) {
+  GitReferenceUpdated(DynamicSet<GitReferenceUpdatedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
@@ -65,38 +70,60 @@
     this.util = null;
   }
 
-  public void fire(Project.NameKey project, RefUpdate refUpdate,
-      ReceiveCommand.Type type, Account updater) {
-    fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
-        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,
+  public void fire(
+      Project.NameKey project, RefUpdate refUpdate, ReceiveCommand.Type type, Account updater) {
+    fire(
+        project,
+        refUpdate.getName(),
+        refUpdate.getOldObjectId(),
+        refUpdate.getNewObjectId(),
+        type,
         util.accountInfo(updater));
   }
 
-  public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
-      ObjectId newObjectId, Account updater) {
-    fire(project, ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE,
+  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,
+      String ref,
+      ObjectId oldObjectId,
+      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(),
+    fire(
+        project,
+        cmd.getRefName(),
+        cmd.getOldId(),
+        cmd.getNewId(),
+        cmd.getType(),
         util.accountInfo(updater));
   }
 
-  public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
-      Account updater) {
+  public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate, Account updater) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() == ReceiveCommand.Result.OK) {
-        fire(project,
+        fire(
+            project,
             cmd.getRefName(),
             cmd.getOldId(),
             cmd.getNewId(),
@@ -106,8 +133,13 @@
     }
   }
 
-  private void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
-      ObjectId newObjectId, ReceiveCommand.Type type, AccountInfo updater) {
+  private void fire(
+      Project.NameKey project,
+      String ref,
+      ObjectId oldObjectId,
+      ObjectId newObjectId,
+      ReceiveCommand.Type type,
+      AccountInfo updater) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
@@ -131,8 +163,11 @@
     private final ReceiveCommand.Type type;
     private final AccountInfo updater;
 
-    Event(Project.NameKey project, String ref,
-        String oldObjectId, String newObjectId,
+    Event(
+        Project.NameKey project,
+        String ref,
+        String oldObjectId,
+        String newObjectId,
         ReceiveCommand.Type type,
         AccountInfo updater) {
       this.projectName = project.get();
@@ -185,8 +220,9 @@
 
     @Override
     public String toString() {
-      return String.format("%s[%s,%s: %s -> %s]", getClass().getSimpleName(),
-          projectName, ref, oldObjectId, newObjectId);
+      return String.format(
+          "%s[%s,%s: %s -> %s]",
+          getClass().getSimpleName(), projectName, ref, oldObjectId, newObjectId);
     }
 
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 27770fd..1c4b43c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -24,40 +24,40 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import com.google.inject.Singleton;
 import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+@Singleton
 public class HashtagsEdited {
-  private static final Logger log =
-      LoggerFactory.getLogger(HashtagsEdited.class);
+  private static final Logger log = LoggerFactory.getLogger(HashtagsEdited.class);
 
   private final DynamicSet<HashtagsEditedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  public HashtagsEdited(DynamicSet<HashtagsEditedListener> listeners,
-      EventUtil util) {
+  public HashtagsEdited(DynamicSet<HashtagsEditedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, Account editor,
-      ImmutableSortedSet<String> hashtags, Set<String> added,
-      Set<String> removed, Timestamp when) {
+  public void fire(
+      Change change,
+      Account editor,
+      ImmutableSortedSet<String> hashtags,
+      Set<String> added,
+      Set<String> removed,
+      Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.accountInfo(editor),
-          hashtags, added, removed,
-          when);
+      Event event =
+          new Event(
+              util.changeInfo(change), util.accountInfo(editor), hashtags, added, removed, when);
       for (HashtagsEditedListener l : listeners) {
         try {
           l.onHashtagsEdited(event);
@@ -70,29 +70,26 @@
     }
   }
 
-  private static class Event extends AbstractChangeEvent
-      implements HashtagsEditedListener.Event {
+  private static class Event extends AbstractChangeEvent implements HashtagsEditedListener.Event {
 
-    private AccountInfo editor;
     private Collection<String> updatedHashtags;
     private Collection<String> addedHashtags;
     private Collection<String> removedHashtags;
 
-    Event(ChangeInfo change, AccountInfo editor, Collection<String> updated,
-        Collection<String> added, Collection<String> removed, Timestamp when) {
+    Event(
+        ChangeInfo change,
+        AccountInfo editor,
+        Collection<String> updated,
+        Collection<String> added,
+        Collection<String> removed,
+        Timestamp when) {
       super(change, editor, when, NotifyHandling.ALL);
-      this.editor = editor;
       this.updatedHashtags = updated;
       this.addedHashtags = added;
       this.removedHashtags = removed;
     }
 
     @Override
-    public AccountInfo getEditor() {
-      return editor;
-    }
-
-    @Override
     public Collection<String> getHashtags() {
       return updatedHashtags;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java
index fbca02e..8680ab1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PluginEvent.java
@@ -17,7 +17,9 @@
 import com.google.gerrit.extensions.events.PluginEventListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
+@Singleton
 public class PluginEvent {
   private final DynamicSet<PluginEventListener> listeners;
 
@@ -36,8 +38,7 @@
     }
   }
 
-  private static class Event extends AbstractNoNotifyEvent
-      implements PluginEventListener.Event {
+  private static class Event extends AbstractNoNotifyEvent implements PluginEventListener.Event {
     private final String pluginName;
     private final String type;
     private final String data;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index e9c44a5..b729781 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -27,65 +28,68 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-
+@Singleton
 public class ReviewerAdded {
-  private static final Logger log =
-      LoggerFactory.getLogger(ReviewerAdded.class);
+  private static final Logger log = LoggerFactory.getLogger(ReviewerAdded.class);
 
   private final DynamicSet<ReviewerAddedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ReviewerAdded(DynamicSet<ReviewerAddedListener> listeners,
-      EventUtil util) {
+  ReviewerAdded(DynamicSet<ReviewerAddedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet patchSet, Account account,
-      Account adder, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
+  public void fire(
+      Change change, PatchSet patchSet, List<Account> reviewers, Account adder, Timestamp when) {
+    if (!listeners.iterator().hasNext() || reviewers.isEmpty()) {
       return;
     }
+
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.revisionInfo(change.getProject(), patchSet),
-          util.accountInfo(account),
-          util.accountInfo(adder),
-          when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              Lists.transform(reviewers, util::accountInfo),
+              util.accountInfo(adder),
+              when);
       for (ReviewerAddedListener l : listeners) {
         try {
-          l.onReviewerAdded(event);
+          l.onReviewersAdded(event);
         } catch (Exception e) {
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
-  private static class Event extends AbstractRevisionEvent
-      implements ReviewerAddedListener.Event {
-    private final AccountInfo reviewer;
+  private static class Event extends AbstractRevisionEvent implements ReviewerAddedListener.Event {
+    private final List<AccountInfo> reviewers;
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo reviewer,
-        AccountInfo adder, Timestamp when) {
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        List<AccountInfo> reviewers,
+        AccountInfo adder,
+        Timestamp when) {
       super(change, revision, adder, when, NotifyHandling.ALL);
-      this.reviewer = reviewer;
+      this.reviewers = reviewers;
     }
 
     @Override
-    public AccountInfo getReviewer() {
-      return reviewer;
+    public List<AccountInfo> getReviewers() {
+      return reviewers;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 42aa9a3..8edfb1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -28,45 +28,51 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+@Singleton
 public class ReviewerDeleted {
-  private static final Logger log =
-      LoggerFactory.getLogger(ReviewerDeleted.class);
+  private static final Logger log = LoggerFactory.getLogger(ReviewerDeleted.class);
 
   private final DynamicSet<ReviewerDeletedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  ReviewerDeleted(DynamicSet<ReviewerDeletedListener> listeners,
-      EventUtil util) {
+  ReviewerDeleted(DynamicSet<ReviewerDeletedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet patchSet, Account reviewer,
-      Account remover, String message,
+  public void fire(
+      Change change,
+      PatchSet patchSet,
+      Account reviewer,
+      Account remover,
+      String message,
       Map<String, Short> newApprovals,
-      Map<String, Short> oldApprovals, Timestamp when) {
+      Map<String, Short> oldApprovals,
+      NotifyHandling notify,
+      Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.revisionInfo(change.getProject(), patchSet),
-          util.accountInfo(reviewer),
-          util.accountInfo(remover),
-          message,
-          util.approvals(reviewer, newApprovals, when),
-          util.approvals(reviewer, oldApprovals, when),
-          when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(reviewer),
+              util.accountInfo(remover),
+              message,
+              util.approvals(reviewer, newApprovals, when),
+              util.approvals(reviewer, oldApprovals, when),
+              notify,
+              when);
       for (ReviewerDeletedListener listener : listeners) {
         try {
           listener.onReviewerDeleted(event);
@@ -74,11 +80,9 @@
           util.logEventListenerError(this, listener, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
-
   }
 
   private static class Event extends AbstractRevisionEvent
@@ -88,11 +92,17 @@
     private final Map<String, ApprovalInfo> newApprovals;
     private final Map<String, ApprovalInfo> oldApprovals;
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo reviewer,
-        AccountInfo remover, String comment,
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo reviewer,
+        AccountInfo remover,
+        String comment,
         Map<String, ApprovalInfo> newApprovals,
-        Map<String, ApprovalInfo> oldApprovals, Timestamp when) {
-      super(change, revision, remover, when, NotifyHandling.ALL);
+        Map<String, ApprovalInfo> oldApprovals,
+        NotifyHandling notify,
+        Timestamp when) {
+      super(change, revision, remover, when, notify);
       this.reviewer = reviewer;
       this.comment = comment;
       this.newApprovals = newApprovals;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 27f3be5..20d0bf4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -27,38 +27,54 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-
+@Singleton
 public class RevisionCreated {
-  private static final Logger log =
-      LoggerFactory.getLogger(RevisionCreated.class);
+  private static final Logger log = LoggerFactory.getLogger(RevisionCreated.class);
+
+  public static final RevisionCreated DISABLED =
+      new RevisionCreated() {
+        @Override
+        public void fire(
+            Change change,
+            PatchSet patchSet,
+            Account uploader,
+            Timestamp when,
+            NotifyHandling notify) {}
+      };
 
   private final DynamicSet<RevisionCreatedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  RevisionCreated(DynamicSet<RevisionCreatedListener> listeners,
-      EventUtil util) {
+  RevisionCreated(DynamicSet<RevisionCreatedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet patchSet, Account uploader,
-      Timestamp when, NotifyHandling notify) {
+  private RevisionCreated() {
+    this.listeners = null;
+    this.util = null;
+  }
+
+  public void fire(
+      Change change, PatchSet patchSet, Account uploader, Timestamp when, NotifyHandling notify) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.revisionInfo(change.getProject(), patchSet),
-          util.accountInfo(uploader),
-          when, notify);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(uploader),
+              when,
+              notify);
       for (RevisionCreatedListener l : listeners) {
         try {
           l.onRevisionCreated(event);
@@ -66,25 +82,21 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch ( PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
   private static class Event extends AbstractRevisionEvent
       implements RevisionCreatedListener.Event {
-    private final AccountInfo uploader;
 
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo uploader,
-        Timestamp when, NotifyHandling notify) {
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo uploader,
+        Timestamp when,
+        NotifyHandling notify) {
       super(change, revision, uploader, when, notify);
-      this.uploader = uploader;
-    }
-
-    @Override
-    public AccountInfo getUploader() {
-      return uploader;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index bf1b2ba..7275ced 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -23,37 +23,31 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.sql.Timestamp;
-
+@Singleton
 public class TopicEdited {
-  private static final Logger log =
-      LoggerFactory.getLogger(TopicEdited.class);
+  private static final Logger log = LoggerFactory.getLogger(TopicEdited.class);
 
   private final DynamicSet<TopicEditedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  TopicEdited(DynamicSet<TopicEditedListener> listeners,
-      EventUtil util) {
+  TopicEdited(DynamicSet<TopicEditedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, Account account, String oldTopicName,
-      Timestamp when) {
+  public void fire(Change change, Account account, String oldTopicName, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.accountInfo(account),
-          oldTopicName,
-          when);
+      Event event =
+          new Event(util.changeInfo(change), util.accountInfo(account), oldTopicName, when);
       for (TopicEditedListener l : listeners) {
         try {
           l.onTopicEdited(event);
@@ -66,24 +60,15 @@
     }
   }
 
-  private static class Event extends AbstractChangeEvent
-      implements TopicEditedListener.Event {
-    private final AccountInfo editor;
+  private static class Event extends AbstractChangeEvent implements TopicEditedListener.Event {
     private final String oldTopic;
 
-    Event(ChangeInfo change, AccountInfo editor, String oldTopic,
-        Timestamp when) {
+    Event(ChangeInfo change, AccountInfo editor, String oldTopic, Timestamp when) {
       super(change, editor, when, NotifyHandling.ALL);
-      this.editor = editor;
       this.oldTopic = oldTopic;
     }
 
     @Override
-    public AccountInfo getEditor() {
-      return editor;
-    }
-
-    @Override
     public String getOldTopic() {
       return oldTopic;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index e421ea6..0f9c943 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.extensions.events;
 
-import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -29,44 +28,51 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+@Singleton
 public class VoteDeleted {
-  private static final Logger log =
-      LoggerFactory.getLogger(VoteDeleted.class);
+  private static final Logger log = LoggerFactory.getLogger(VoteDeleted.class);
 
   private final DynamicSet<VoteDeletedListener> listeners;
   private final EventUtil util;
 
   @Inject
-  VoteDeleted(DynamicSet<VoteDeletedListener> listeners,
-      EventUtil util) {
+  VoteDeleted(DynamicSet<VoteDeletedListener> listeners, EventUtil util) {
     this.listeners = listeners;
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet ps,
+  public void fire(
+      Change change,
+      PatchSet ps,
+      Account reviewer,
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
-      NotifyHandling notify, String message,
-      Account remover, Timestamp when) {
+      NotifyHandling notify,
+      String message,
+      Account remover,
+      Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      Event event = new Event(
-          util.changeInfo(change),
-          util.revisionInfo(change.getProject(), ps),
-          util.approvals(remover, approvals, when),
-          util.approvals(remover, oldApprovals, when),
-          notify, message,
-          util.accountInfo(remover), when);
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), ps),
+              util.accountInfo(reviewer),
+              util.approvals(remover, approvals, when),
+              util.approvals(remover, oldApprovals, when),
+              notify,
+              message,
+              util.accountInfo(remover),
+              when);
       for (VoteDeletedListener l : listeners) {
         try {
           l.onVoteDeleted(event);
@@ -74,25 +80,29 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException
-        | OrmException e) {
+    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
-  private static class Event extends AbstractRevisionEvent
-      implements VoteDeletedListener.Event {
-
+  private static class Event extends AbstractRevisionEvent implements VoteDeletedListener.Event {
+    private final AccountInfo reviewer;
     private final Map<String, ApprovalInfo> approvals;
     private final Map<String, ApprovalInfo> oldApprovals;
     private final String message;
 
-    Event(ChangeInfo change, RevisionInfo revision,
+    Event(
+        ChangeInfo change,
+        RevisionInfo revision,
+        AccountInfo reviewer,
         Map<String, ApprovalInfo> approvals,
         Map<String, ApprovalInfo> oldApprovals,
-        NotifyHandling notify, String message,
-        AccountInfo remover, Timestamp when) {
+        NotifyHandling notify,
+        String message,
+        AccountInfo remover,
+        Timestamp when) {
       super(change, revision, remover, when, notify);
+      this.reviewer = reviewer;
       this.approvals = approvals;
       this.oldApprovals = oldApprovals;
       this.message = message;
@@ -109,13 +119,13 @@
     }
 
     @Override
-    public Map<String, ApprovalInfo> getRemoved() {
-      return Maps.difference(oldApprovals, approvals).entriesOnlyOnLeft();
+    public String getMessage() {
+      return message;
     }
 
     @Override
-    public String getMessage() {
-      return message;
+    public AccountInfo getReviewer() {
+      return reviewer;
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 601bcc6..548853c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.server.extensions.webui;
 
-import com.google.common.base.Function;
 import com.google.common.base.Predicate;
-import com.google.common.base.Predicates;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.Nullable;
+import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestCollection;
@@ -29,7 +26,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.CapabilityUtils;
 import com.google.inject.Provider;
-
+import java.util.Objects;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -37,79 +34,57 @@
   private static final Logger log = LoggerFactory.getLogger(UiActions.class);
 
   public static Predicate<UiAction.Description> enabled() {
-    return new Predicate<UiAction.Description>() {
-      @Override
-      public boolean apply(UiAction.Description input) {
-        return input.isEnabled();
-      }
-    };
+    return UiAction.Description::isEnabled;
   }
 
-  public static <R extends RestResource> Iterable<UiAction.Description> from(
-      RestCollection<?, R> collection,
-      R resource,
-      Provider<CurrentUser> userProvider) {
+  public static <R extends RestResource> FluentIterable<UiAction.Description> from(
+      RestCollection<?, R> collection, R resource, Provider<CurrentUser> userProvider) {
     return from(collection.views(), resource, userProvider);
   }
 
-  public static <R extends RestResource> Iterable<UiAction.Description> from(
-      DynamicMap<RestView<R>> views,
-      final R resource,
-      final Provider<CurrentUser> userProvider) {
-    return Iterables.filter(
-      Iterables.transform(
-        views,
-        new Function<DynamicMap.Entry<RestView<R>>, UiAction.Description> () {
-          @Override
-          @Nullable
-          public UiAction.Description apply(DynamicMap.Entry<RestView<R>> e) {
-            int d = e.getExportName().indexOf('.');
-            if (d < 0) {
-              return null;
-            }
+  public static <R extends RestResource> FluentIterable<UiAction.Description> from(
+      DynamicMap<RestView<R>> views, R resource, Provider<CurrentUser> userProvider) {
+    return FluentIterable.from(views)
+        .transform(
+            (DynamicMap.Entry<RestView<R>> e) -> {
+              int d = e.getExportName().indexOf('.');
+              if (d < 0) {
+                return null;
+              }
 
-            RestView<R> view;
-            try {
-              view = e.getProvider().get();
-            } catch (RuntimeException err) {
-              log.error(String.format(
-                  "error creating view %s.%s",
-                  e.getPluginName(), e.getExportName()), err);
-              return null;
-            }
+              RestView<R> view;
+              try {
+                view = e.getProvider().get();
+              } catch (RuntimeException err) {
+                log.error("error creating view {}.{}", e.getPluginName(), e.getExportName(), err);
+                return null;
+              }
 
-            if (!(view instanceof UiAction)) {
-              return null;
-            }
+              if (!(view instanceof UiAction)) {
+                return null;
+              }
 
-            try {
-              CapabilityUtils.checkRequiresCapability(userProvider,
-                  e.getPluginName(), view.getClass());
-            } catch (AuthException exc) {
-              return null;
-            }
+              try {
+                CapabilityUtils.checkRequiresCapability(
+                    userProvider, e.getPluginName(), view.getClass());
+              } catch (AuthException exc) {
+                return null;
+              }
 
-            UiAction.Description dsc =
-                ((UiAction<R>) view).getDescription(resource);
-            if (dsc == null || !dsc.isVisible()) {
-              return null;
-            }
+              UiAction.Description dsc = ((UiAction<R>) view).getDescription(resource);
+              if (dsc == null || !dsc.isVisible()) {
+                return null;
+              }
 
-            String name = e.getExportName().substring(d + 1);
-            PrivateInternals_UiActionDescription.setMethod(
-                dsc,
-                e.getExportName().substring(0, d));
-            PrivateInternals_UiActionDescription.setId(
-                dsc,
-                "gerrit".equals(e.getPluginName())
-                  ? name
-                  : e.getPluginName() + '~' + name);
-            return dsc;
-          }
-        }),
-      Predicates.notNull());
+              String name = e.getExportName().substring(d + 1);
+              PrivateInternals_UiActionDescription.setMethod(
+                  dsc, e.getExportName().substring(0, d));
+              PrivateInternals_UiActionDescription.setId(
+                  dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
+              return dsc;
+            })
+        .filter(Objects::nonNull);
   }
 
-  private UiActions() {
-  }
+  private UiActions() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
new file mode 100644
index 0000000..99b647a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+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;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ChangeAbandoned;
+import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AbandonOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(AbandonOp.class);
+
+  private final AbandonedSender.Factory abandonedSenderFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeAbandoned changeAbandoned;
+
+  private final String msgTxt;
+  private final NotifyHandling notifyHandling;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+  private final Account account;
+
+  private Change change;
+  private PatchSet patchSet;
+  private ChangeMessage message;
+
+  public interface Factory {
+    AbandonOp create(
+        @Assisted @Nullable Account account,
+        @Assisted @Nullable String msgTxt,
+        @Assisted NotifyHandling notifyHandling,
+        @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify);
+  }
+
+  @AssistedInject
+  AbandonOp(
+      AbandonedSender.Factory abandonedSenderFactory,
+      ChangeMessagesUtil cmUtil,
+      PatchSetUtil psUtil,
+      ChangeAbandoned changeAbandoned,
+      @Assisted @Nullable Account account,
+      @Assisted @Nullable String msgTxt,
+      @Assisted NotifyHandling notifyHandling,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.abandonedSenderFactory = abandonedSenderFactory;
+    this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
+    this.changeAbandoned = changeAbandoned;
+
+    this.account = account;
+    this.msgTxt = Strings.nullToEmpty(msgTxt);
+    this.notifyHandling = notifyHandling;
+    this.accountsToNotify = accountsToNotify;
+  }
+
+  @Nullable
+  public Change getChange() {
+    return change;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
+    change = ctx.getChange();
+    PatchSet.Id psId = change.currentPatchSetId();
+    ChangeUpdate update = ctx.getUpdate(psId);
+    if (!change.getStatus().isOpen()) {
+      throw new ResourceConflictException("change is " + status(change));
+    } else if (change.getStatus() == Change.Status.DRAFT) {
+      throw new ResourceConflictException("draft changes cannot be abandoned");
+    }
+    patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+    change.setStatus(Change.Status.ABANDONED);
+    change.setLastUpdatedOn(ctx.getWhen());
+
+    update.setStatus(change.getStatus());
+    message = newMessage(ctx);
+    cmUtil.addChangeMessage(ctx.getDb(), update, message);
+    return true;
+  }
+
+  private ChangeMessage newMessage(ChangeContext ctx) {
+    StringBuilder msg = new StringBuilder();
+    msg.append("Abandoned");
+    if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
+      msg.append("\n\n");
+      msg.append(msgTxt.trim());
+    }
+
+    return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws OrmException {
+    try {
+      ReplyToChangeSender cm = abandonedSenderFactory.create(ctx.getProject(), change.getId());
+      if (account != null) {
+        cm.setFrom(account.getId());
+      }
+      cm.setChangeMessage(message.getMessage(), ctx.getWhen());
+      cm.setNotify(notifyHandling);
+      cm.setAccountsToNotify(accountsToNotify);
+      cm.send();
+    } catch (Exception e) {
+      log.error("Cannot email update for change " + change.getId(), e);
+    }
+    changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(), notifyHandling);
+  }
+
+  private static String status(Change change) {
+    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AccountsSection.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AccountsSection.java
index 4ea24ef..ffecc36 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/AccountsSection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/AccountsSection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.common.data.PermissionRule;
-
 import java.util.ArrayList;
 import java.util.List;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AsyncReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AsyncReceiveCommits.java
index 899b06a..420d5b3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/AsyncReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/AsyncReceiveCommits.java
@@ -27,7 +27,10 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.name.Named;
-
+import java.io.OutputStream;
+import java.util.Collection;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PreReceiveHook;
@@ -37,33 +40,24 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.OutputStream;
-import java.util.Collection;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-
 /** Hook that delegates to {@link ReceiveCommits} in a worker thread. */
 public class AsyncReceiveCommits implements PreReceiveHook {
-  private static final Logger log =
-      LoggerFactory.getLogger(AsyncReceiveCommits.class);
+  private static final Logger log = LoggerFactory.getLogger(AsyncReceiveCommits.class);
 
   private static final String TIMEOUT_NAME = "ReceiveCommitsOverallTimeout";
 
   public interface Factory {
-    AsyncReceiveCommits create(ProjectControl projectControl,
-        Repository repository);
+    AsyncReceiveCommits create(ProjectControl projectControl, Repository repository);
   }
 
   public static class Module extends PrivateModule {
     @Override
     public void configure() {
-      install(new FactoryModuleBuilder()
-          .build(AsyncReceiveCommits.Factory.class));
+      install(new FactoryModuleBuilder().build(AsyncReceiveCommits.Factory.class));
       expose(AsyncReceiveCommits.Factory.class);
       // Don't expose the binding for ReceiveCommits.Factory. All callers should
       // be using AsyncReceiveCommits.Factory instead.
-      install(new FactoryModuleBuilder()
-          .build(ReceiveCommits.Factory.class));
+      install(new FactoryModuleBuilder().build(ReceiveCommits.Factory.class));
     }
 
     @Provides
@@ -71,9 +65,7 @@
     @Named(TIMEOUT_NAME)
     long getTimeoutMillis(@GerritServerConfig final Config cfg) {
       return ConfigUtil.getTimeUnit(
-          cfg, "receive", null, "timeout",
-          TimeUnit.MINUTES.toMillis(4),
-          TimeUnit.MILLISECONDS);
+          cfg, "receive", null, "timeout", TimeUnit.MINUTES.toMillis(4), TimeUnit.MILLISECONDS);
     }
   }
 
@@ -113,7 +105,7 @@
   private class MessageSenderOutputStream extends OutputStream {
     @Override
     public void write(int b) {
-      rc.getMessageSender().sendBytes(new byte[]{(byte)b});
+      rc.getMessageSender().sendBytes(new byte[] {(byte) b});
     }
 
     @Override
@@ -139,7 +131,8 @@
   private final long timeoutMillis;
 
   @Inject
-  AsyncReceiveCommits(final ReceiveCommits.Factory factory,
+  AsyncReceiveCommits(
+      final ReceiveCommits.Factory factory,
       @ReceiveCommitsExecutor final Executor executor,
       final RequestScopePropagator scopePropagator,
       @Named(TIMEOUT_NAME) final long timeoutMillis,
@@ -150,22 +143,22 @@
     rc = factory.create(projectControl, repo);
     rc.getReceivePack().setPreReceiveHook(this);
 
-    progress = new MultiProgressMonitor(
-        new MessageSenderOutputStream(), "Processing changes");
+    progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
     this.timeoutMillis = timeoutMillis;
   }
 
   @Override
-  public void onPreReceive(final ReceivePack rp,
-      final Collection<ReceiveCommand> commands) {
+  public void onPreReceive(final ReceivePack rp, final Collection<ReceiveCommand> commands) {
     try {
       progress.waitFor(
           executor.submit(scopePropagator.wrap(new Worker(commands))),
-          timeoutMillis, TimeUnit.MILLISECONDS);
+          timeoutMillis,
+          TimeUnit.MILLISECONDS);
     } catch (ExecutionException e) {
-      log.warn(String.format(
-          "Error in ReceiveCommits while processing changes for project %s",
-              rc.getProject().getName()), e);
+      log.warn(
+          "Error in ReceiveCommits while processing changes for project {}",
+          rc.getProject().getName(),
+          e);
       rc.addError("internal error while processing changes");
       // ReceiveCommits has tried its best to catch errors, so anything at this
       // point is very bad.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java
index be17776..d09e857 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java
@@ -26,7 +26,10 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
 import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -41,11 +44,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.util.Date;
-import java.util.List;
-import java.util.TimeZone;
-
 @Singleton
 public class BanCommit {
   /**
@@ -56,8 +54,7 @@
    * @return NoteMap of commits to be rejected, null if there are none.
    * @throws IOException the map cannot be loaded.
    */
-  public static NoteMap loadRejectCommitsMap(Repository repo, RevWalk walk)
-      throws IOException {
+  public static NoteMap loadRejectCommitsMap(Repository repo, RevWalk walk) throws IOException {
     try {
       Ref ref = repo.getRefDatabase().exactRef(RefNames.REFS_REJECT_COMMITS);
       if (ref == null) {
@@ -67,8 +64,7 @@
       RevCommit map = walk.parseCommit(ref.getObjectId());
       return NoteMap.read(walk.getObjectReader(), map);
     } catch (IOException badMap) {
-      throw new IOException("Cannot load " + RefNames.REFS_REJECT_COMMITS,
-          badMap);
+      throw new IOException("Cannot load " + RefNames.REFS_REJECT_COMMITS, badMap);
     }
   }
 
@@ -78,7 +74,8 @@
   private NotesBranchUtil.Factory notesBranchUtilFactory;
 
   @Inject
-  BanCommit(final Provider<IdentifiedUser> currentUser,
+  BanCommit(
+      final Provider<IdentifiedUser> currentUser,
       final GitRepositoryManager repoManager,
       @GerritPersonIdent final PersonIdent gerritIdent,
       final NotesBranchUtil.Factory notesBranchUtilFactory) {
@@ -88,13 +85,11 @@
     this.tz = gerritIdent.getTimeZone();
   }
 
-  public BanCommitResult ban(final ProjectControl projectControl,
-      final List<ObjectId> commitsToBan, final String reason)
-      throws PermissionDeniedException, IOException,
-      ConcurrentRefUpdateException {
+  public BanCommitResult ban(
+      final ProjectControl projectControl, final List<ObjectId> commitsToBan, final String reason)
+      throws PermissionDeniedException, IOException, ConcurrentRefUpdateException {
     if (!projectControl.isOwner()) {
-      throw new PermissionDeniedException(
-          "Not project owner: not permitted to ban commits");
+      throw new PermissionDeniedException("Not project owner: not permitted to ban commits");
     }
 
     final BanCommitResult result = new BanCommitResult();
@@ -119,11 +114,13 @@
         }
         banCommitNotes.set(commitToBan, noteId);
       }
-      NotesBranchUtil notesBranchUtil =
-          notesBranchUtilFactory.create(project, repo, inserter);
+      NotesBranchUtil notesBranchUtil = notesBranchUtilFactory.create(project, repo, inserter);
       NoteMap newlyCreated =
-          notesBranchUtil.commitNewNotes(banCommitNotes, REFS_REJECT_COMMITS,
-              createPersonIdent(), buildCommitMessage(commitsToBan, reason));
+          notesBranchUtil.commitNewNotes(
+              banCommitNotes,
+              REFS_REJECT_COMMITS,
+              createPersonIdent(),
+              buildCommitMessage(commitsToBan, reason));
 
       for (Note n : banCommitNotes) {
         if (newlyCreated.contains(n)) {
@@ -136,8 +133,7 @@
     }
   }
 
-  private ObjectId createNoteContent(String reason, ObjectInserter inserter)
-      throws IOException {
+  private ObjectId createNoteContent(String reason, ObjectInserter inserter) throws IOException {
     String noteContent = reason != null ? reason : "";
     if (noteContent.length() > 0 && !noteContent.endsWith("\n")) {
       noteContent = noteContent + "\n";
@@ -150,8 +146,8 @@
     return currentUser.get().newCommitterIdent(now, tz);
   }
 
-  private static String buildCommitMessage(final List<ObjectId> bannedCommits,
-      final String reason) {
+  private static String buildCommitMessage(
+      final List<ObjectId> bannedCommits, final String reason) {
     final StringBuilder commitMsg = new StringBuilder();
     commitMsg.append("Banning ");
     commitMsg.append(bannedCommits.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java
index 92c3b6c..baa6013 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.server.git;
 
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class BanCommitResult {
   private final List<ObjectId> newlyBannedCommits = new ArrayList<>(4);
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
deleted file mode 100644
index fdee6ce..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
+++ /dev/null
@@ -1,1076 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.NANOSECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.util.concurrent.CheckedFuture;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gwtorm.server.OrmConcurrencyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.TimeZone;
-import java.util.TreeMap;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-
-/**
- * Context for a set of updates that should be applied for a site.
- * <p>
- * An update operation can be divided into three phases:
- * <ol>
- * <li>Git reference updates</li>
- * <li>Database updates</li>
- * <li>Post-update steps<li>
- * </ol>
- * A single conceptual operation, such as a REST API call or a merge operation,
- * may make multiple changes at each step, which all need to be serialized
- * relative to each other. Moreover, for consistency, <em>all</em> git ref
- * updates must be performed before <em>any</em> database updates, since
- * database updates might refer to newly-created patch set refs. And all
- * post-update steps, such as hooks, should run only after all storage
- * mutations have completed.
- * <p>
- * Depending on the backend used, each step might support batching, for example
- * in a {@code BatchRefUpdate} or one or more database transactions. All
- * operations in one phase must complete successfully before proceeding to the
- * next phase.
- */
-public class BatchUpdate implements AutoCloseable {
-  private static final Logger log = LoggerFactory.getLogger(BatchUpdate.class);
-
-  public interface Factory {
-    BatchUpdate create(ReviewDb db, Project.NameKey project,
-        CurrentUser user, Timestamp when);
-  }
-
-  /** Order of execution of the various phases. */
-  public enum Order {
-    /**
-     * Update the repository and execute all ref updates before touching the
-     * database.
-     * <p>
-     * The default and most common, as Gerrit does not behave well when a patch
-     * set has no corresponding ref in the repo.
-     */
-    REPO_BEFORE_DB,
-
-    /**
-     * Update the database before touching the repository.
-     * <p>
-     * Generally only used when deleting patch sets, which should be deleted
-     * first from the database (for the same reason as above.)
-     */
-    DB_BEFORE_REPO;
-  }
-
-  public class Context {
-    private Repository repoWrapper;
-
-    public Repository getRepository() throws IOException {
-      if (repoWrapper == null) {
-        repoWrapper = new ReadOnlyRepository(BatchUpdate.this.getRepository());
-      }
-      return repoWrapper;
-    }
-
-    public RevWalk getRevWalk() throws IOException {
-      return BatchUpdate.this.getRevWalk();
-    }
-
-    public Project.NameKey getProject() {
-      return project;
-    }
-
-    public Timestamp getWhen() {
-      return when;
-    }
-
-    public ReviewDb getDb() {
-      return db;
-    }
-
-    public CurrentUser getUser() {
-      return user;
-    }
-
-    public IdentifiedUser getIdentifiedUser() {
-      checkNotNull(user);
-      return user.asIdentifiedUser();
-    }
-
-    public Account getAccount() {
-      checkNotNull(user);
-      return user.asIdentifiedUser().getAccount();
-    }
-
-    public Account.Id getAccountId() {
-      checkNotNull(user);
-      return user.getAccountId();
-    }
-
-    public Order getOrder() {
-      return order;
-    }
-  }
-
-  public class RepoContext extends Context {
-    @Override
-    public Repository getRepository() throws IOException {
-      return BatchUpdate.this.getRepository();
-    }
-
-    public ObjectInserter getInserter() throws IOException {
-      return BatchUpdate.this.getObjectInserter();
-    }
-
-    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
-      initRepository();
-      commands.add(cmd);
-    }
-
-    public TimeZone getTimeZone() {
-      return tz;
-    }
-  }
-
-  public class ChangeContext extends Context {
-    private final ChangeControl ctl;
-    private final Map<PatchSet.Id, ChangeUpdate> updates;
-    private final ReviewDbWrapper dbWrapper;
-    private final Repository threadLocalRepo;
-    private final RevWalk threadLocalRevWalk;
-
-    private boolean deleted;
-    private boolean bumpLastUpdatedOn = true;
-
-    protected ChangeContext(ChangeControl ctl, ReviewDbWrapper dbWrapper,
-        Repository repo, RevWalk rw) {
-      this.ctl = ctl;
-      this.dbWrapper = dbWrapper;
-      this.threadLocalRepo = repo;
-      this.threadLocalRevWalk = rw;
-      updates = new TreeMap<>(ReviewDbUtil.intKeyOrdering());
-    }
-
-    @Override
-    public ReviewDb getDb() {
-      checkNotNull(dbWrapper);
-      return dbWrapper;
-    }
-
-    @Override
-    public Repository getRepository() {
-      return threadLocalRepo;
-    }
-
-    @Override
-    public RevWalk getRevWalk() {
-      return threadLocalRevWalk;
-    }
-
-    public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = updates.get(psId);
-      if (u == null) {
-        u = changeUpdateFactory.create(ctl, when);
-        if (newChanges.containsKey(ctl.getId())) {
-          u.setAllowWriteToNewRef(true);
-        }
-        u.setPatchSetId(psId);
-        updates.put(psId, u);
-      }
-      return u;
-    }
-
-    public ChangeNotes getNotes() {
-      ChangeNotes n = ctl.getNotes();
-      checkNotNull(n);
-      return n;
-    }
-
-    public ChangeControl getControl() {
-      checkNotNull(ctl);
-      return ctl;
-    }
-
-    public Change getChange() {
-      Change c = ctl.getChange();
-      checkNotNull(c);
-      return c;
-    }
-
-    public void bumpLastUpdatedOn(boolean bump) {
-      bumpLastUpdatedOn = bump;
-    }
-
-    public void deleteChange() {
-      deleted = true;
-    }
-  }
-
-  public static class RepoOnlyOp {
-    /**
-     * Override this method to update the repo.
-     *
-     * @param ctx context
-     */
-    public void updateRepo(RepoContext ctx) throws Exception {
-    }
-
-    /**
-     * Override this method to do something after the update
-     * e.g. send email or run hooks
-     *
-     * @param ctx context
-     */
-    //TODO(dborowitz): Support async operations?
-    public void postUpdate(Context ctx) throws Exception {
-    }
-  }
-
-  public static class Op extends RepoOnlyOp {
-    /**
-     * Override this method to modify a change.
-     *
-     * @param ctx context
-     * @return whether anything was changed that might require a write to
-     * the metadata storage.
-     */
-    public boolean updateChange(ChangeContext ctx) throws Exception {
-      return false;
-    }
-  }
-
-  public abstract static class InsertChangeOp extends Op {
-    public abstract Change createChange(Context ctx);
-  }
-
-  /**
-   * Interface for listening during batch update execution.
-   * <p>
-   * When used during execution of multiple batch updates, the {@code after*}
-   * methods are called after that phase has been completed for <em>all</em> updates.
-   */
-  public static class Listener {
-    public static final Listener NONE = new Listener();
-
-    /**
-     * Called after updating all repositories and flushing objects but before
-     * updating any refs.
-     */
-    public void afterUpdateRepos() throws Exception {
-    }
-
-    /** Called after updating all refs. */
-    public void afterRefUpdates() throws Exception {
-    }
-
-    /** Called after updating all changes. */
-    public void afterUpdateChanges() throws Exception {
-    }
-  }
-
-  private static Order getOrder(Collection<BatchUpdate> updates) {
-    Order o = null;
-    for (BatchUpdate u : updates) {
-      if (o == null) {
-        o = u.order;
-      } else if (u.order != o) {
-        throw new IllegalArgumentException("cannot mix execution orders");
-      }
-    }
-    return o;
-  }
-
-  private static boolean getUpdateChangesInParallel(
-      Collection<BatchUpdate> updates) {
-    checkArgument(!updates.isEmpty());
-    Boolean p = null;
-    for (BatchUpdate u : updates) {
-      if (p == null) {
-        p = u.updateChangesInParallel;
-      } else if (u.updateChangesInParallel != p) {
-        throw new IllegalArgumentException(
-            "cannot mix parallel and non-parallel operations");
-      }
-    }
-    // Properly implementing this would involve hoisting the parallel loop up
-    // even further. As of this writing, the only user is ReceiveCommits,
-    // which only executes a single BatchUpdate at a time. So bail for now.
-    checkArgument(!p || updates.size() <= 1,
-        "cannot execute ChangeOps in parallel with more than 1 BatchUpdate");
-    return p;
-  }
-
-  static void execute(Collection<BatchUpdate> updates, Listener listener,
-      @Nullable RequestId requestId) throws UpdateException, RestApiException {
-    if (updates.isEmpty()) {
-      return;
-    }
-    if (requestId != null) {
-      for (BatchUpdate u : updates) {
-        checkArgument(u.requestId == null || u.requestId == requestId,
-            "refusing to overwrite RequestId %s in update with %s",
-            u.requestId, requestId);
-        u.setRequestId(requestId);
-      }
-    }
-    try {
-      Order order = getOrder(updates);
-      boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
-      switch (order) {
-        case REPO_BEFORE_DB:
-          for (BatchUpdate u : updates) {
-            u.executeUpdateRepo();
-          }
-          listener.afterUpdateRepos();
-          for (BatchUpdate u : updates) {
-            u.executeRefUpdates();
-          }
-          listener.afterRefUpdates();
-          for (BatchUpdate u : updates) {
-            u.executeChangeOps(updateChangesInParallel);
-          }
-          listener.afterUpdateChanges();
-          break;
-        case DB_BEFORE_REPO:
-          for (BatchUpdate u : updates) {
-            u.executeChangeOps(updateChangesInParallel);
-          }
-          listener.afterUpdateChanges();
-          for (BatchUpdate u : updates) {
-            u.executeUpdateRepo();
-          }
-          listener.afterUpdateRepos();
-          for (BatchUpdate u : updates) {
-            u.executeRefUpdates();
-          }
-          listener.afterRefUpdates();
-          break;
-        default:
-          throw new IllegalStateException("invalid execution order: " + order);
-      }
-
-      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
-      for (BatchUpdate u : updates) {
-        indexFutures.addAll(u.indexFutures);
-      }
-      ChangeIndexer.allAsList(indexFutures).get();
-
-      for (BatchUpdate u : updates) {
-        if (u.batchRefUpdate != null) {
-          // Fire ref update events only after all mutations are finished, since
-          // 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.getUser().isIdentifiedUser()
-                  ? u.getUser().asIdentifiedUser().getAccount()
-                  : null);
-        }
-      }
-
-      for (BatchUpdate u : updates) {
-        u.executePostOps();
-      }
-    } catch (UpdateException | RestApiException e) {
-      // Propagate REST API exceptions thrown by operations; they commonly throw
-      // exceptions like ResourceConflictException to indicate an atomic update
-      // failure.
-      throw e;
-
-    // Convert other common non-REST exception types with user-visible
-    // messages to corresponding REST exception types
-    } catch (InvalidChangeOperationException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } catch (NoSuchChangeException | NoSuchRefException
-        | NoSuchProjectException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-
-    } catch (Exception e) {
-      Throwables.propagateIfPossible(e);
-      throw new UpdateException(e);
-    }
-  }
-
-  private final AllUsersName allUsers;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final ChangeIndexer indexer;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeUpdate.Factory changeUpdateFactory;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final GitRepositoryManager repoManager;
-  private final ListeningExecutorService changeUpdateExector;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final NotesMigration notesMigration;
-  private final ReviewDb db;
-  private final SchemaFactory<ReviewDb> schemaFactory;
-
-  private final long logThresholdNanos;
-  private final Project.NameKey project;
-  private final CurrentUser user;
-  private final Timestamp when;
-  private final TimeZone tz;
-
-  private final ListMultimap<Change.Id, Op> ops =
-      MultimapBuilder.linkedHashKeys().arrayListValues().build();
-  private final Map<Change.Id, Change> newChanges = new HashMap<>();
-  private final List<CheckedFuture<?, IOException>> indexFutures =
-      new ArrayList<>();
-  private final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
-
-  private Repository repo;
-  private ObjectInserter inserter;
-  private RevWalk revWalk;
-  private ChainedReceiveCommands commands;
-  private BatchRefUpdate batchRefUpdate;
-  private boolean closeRepo;
-  private Order order;
-  private boolean updateChangesInParallel;
-  private RequestId requestId;
-
-  @AssistedInject
-  BatchUpdate(
-      @GerritServerConfig Config cfg,
-      AllUsersName allUsers,
-      ChangeControl.GenericFactory changeControlFactory,
-      ChangeIndexer indexer,
-      ChangeNotes.Factory changeNotesFactory,
-      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
-      ChangeUpdate.Factory changeUpdateFactory,
-      @GerritPersonIdent PersonIdent serverIdent,
-      GitReferenceUpdated gitRefUpdated,
-      GitRepositoryManager repoManager,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      NotesMigration notesMigration,
-      SchemaFactory<ReviewDb> schemaFactory,
-      @Assisted ReviewDb db,
-      @Assisted Project.NameKey project,
-      @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
-    this.allUsers = allUsers;
-    this.changeControlFactory = changeControlFactory;
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeUpdateExector = changeUpdateExector;
-    this.changeUpdateFactory = changeUpdateFactory;
-    this.gitRefUpdated = gitRefUpdated;
-    this.indexer = indexer;
-    this.notesMigration = notesMigration;
-    this.repoManager = repoManager;
-    this.schemaFactory = schemaFactory;
-    this.updateManagerFactory = updateManagerFactory;
-
-    this.logThresholdNanos = MILLISECONDS.toNanos(
-        ConfigUtil.getTimeUnit(
-            cfg, "change", null, "updateDebugLogThreshold",
-            SECONDS.toMillis(2), MILLISECONDS));
-    this.db = db;
-    this.project = project;
-    this.user = user;
-    this.when = when;
-    tz = serverIdent.getTimeZone();
-    order = Order.REPO_BEFORE_DB;
-  }
-
-  @Override
-  public void close() {
-    if (closeRepo) {
-      revWalk.close();
-      inserter.close();
-      repo.close();
-    }
-  }
-
-  public BatchUpdate setRequestId(RequestId requestId) {
-    this.requestId = requestId;
-    return this;
-  }
-
-  public BatchUpdate setRepository(Repository repo, RevWalk revWalk,
-      ObjectInserter inserter) {
-    checkState(this.repo == null, "repo already set");
-    closeRepo = false;
-    this.repo = checkNotNull(repo, "repo");
-    this.revWalk = checkNotNull(revWalk, "revWalk");
-    this.inserter = checkNotNull(inserter, "inserter");
-    commands = new ChainedReceiveCommands(repo);
-    return this;
-  }
-
-  public BatchUpdate setOrder(Order order) {
-    this.order = order;
-    return this;
-  }
-
-  /**
-   * Execute {@link Op#updateChange(ChangeContext)} in parallel for each change.
-   */
-  public BatchUpdate updateChangesInParallel() {
-    this.updateChangesInParallel = true;
-    return this;
-  }
-
-  private void initRepository() throws IOException {
-    if (repo == null) {
-      this.repo = repoManager.openRepository(project);
-      closeRepo = true;
-      inserter = repo.newObjectInserter();
-      revWalk = new RevWalk(inserter.newReader());
-      commands = new ChainedReceiveCommands(repo);
-    }
-  }
-
-  public CurrentUser getUser() {
-    return user;
-  }
-
-  public Repository getRepository() throws IOException {
-    initRepository();
-    return repo;
-  }
-
-  public RevWalk getRevWalk() throws IOException {
-    initRepository();
-    return revWalk;
-  }
-
-  public ObjectInserter getObjectInserter() throws IOException {
-    initRepository();
-    return inserter;
-  }
-
-  public BatchUpdate addOp(Change.Id id, Op op) {
-    checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
-    checkNotNull(op);
-    ops.put(id, op);
-    return this;
-  }
-
-  public BatchUpdate addRepoOnlyOp(RepoOnlyOp op) {
-    checkArgument(!(op instanceof Op), "use addOp()");
-    repoOnlyOps.add(op);
-    return this;
-  }
-
-  public BatchUpdate insertChange(InsertChangeOp op) {
-    Context ctx = new Context();
-    Change c = op.createChange(ctx);
-    checkArgument(!newChanges.containsKey(c.getId()),
-        "only one op allowed to create change %s", c.getId());
-    newChanges.put(c.getId(), c);
-    ops.get(c.getId()).add(0, op);
-    return this;
-  }
-
-  public void execute() throws UpdateException, RestApiException {
-    execute(Listener.NONE);
-  }
-
-  public void execute(Listener listener)
-      throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), listener, requestId);
-  }
-
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
-    try {
-      logDebug("Executing updateRepo on {} ops", ops.size());
-      RepoContext ctx = new RepoContext();
-      for (Op op : ops.values()) {
-        op.updateRepo(ctx);
-      }
-
-      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
-      for (RepoOnlyOp op : repoOnlyOps) {
-        op.updateRepo(ctx);
-      }
-
-      if (inserter != null) {
-        logDebug("Flushing inserter");
-        inserter.flush();
-      } else {
-        logDebug("No objects to flush");
-      }
-    } catch (Exception e) {
-      Throwables.propagateIfPossible(e, RestApiException.class);
-      throw new UpdateException(e);
-    }
-  }
-
-  private void executeRefUpdates() throws IOException, RestApiException {
-    if (commands == null || commands.isEmpty()) {
-      logDebug("No ref updates to execute");
-      return;
-    }
-    // May not be opened if the caller added ref updates but no new objects.
-    initRepository();
-    batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
-    commands.addTo(batchRefUpdate);
-    logDebug("Executing batch of {} ref updates",
-        batchRefUpdate.getCommands().size());
-    batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
-    boolean ok = true;
-    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        ok = false;
-        break;
-      }
-    }
-    if (!ok) {
-      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
-    }
-  }
-
-  private void executeChangeOps(boolean parallel)
-      throws UpdateException, RestApiException {
-    logDebug("Executing change ops (parallel? {})", parallel);
-    ListeningExecutorService executor = parallel
-        ? changeUpdateExector
-        : MoreExecutors.newDirectExecutorService();
-
-    List<ChangeTask> tasks = new ArrayList<>(ops.keySet().size());
-    try {
-      if (notesMigration.commitChangeWrites() && repo != null) {
-        // A NoteDb change may have been rebuilt since the repo was originally
-        // opened, so make sure we see that.
-        logDebug("Preemptively scanning for repo changes");
-        repo.scanForRepoChanges();
-      }
-      if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
-        // Fail fast before attempting any writes if changes are read-only, as
-        // this is a programmer error.
-        logDebug("Failing early due to read-only Changes table");
-        throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
-      }
-      List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
-      for (Map.Entry<Change.Id, Collection<Op>> e : ops.asMap().entrySet()) {
-        ChangeTask task =
-            new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread());
-        tasks.add(task);
-        if (!parallel) {
-          logDebug("Direct execution of task for ops: {}", ops);
-        }
-        futures.add(executor.submit(task));
-      }
-      if (parallel) {
-        logDebug("Waiting on futures for {} ops spanning {} changes",
-            ops.size(), ops.keySet().size());
-      }
-      // TODO(dborowitz): Timing is wrong for non-parallel updates.
-      long startNanos = System.nanoTime();
-      Futures.allAsList(futures).get();
-      maybeLogSlowUpdate(startNanos, "change");
-
-      if (notesMigration.commitChangeWrites()) {
-        startNanos = System.nanoTime();
-        executeNoteDbUpdates(tasks);
-        maybeLogSlowUpdate(startNanos, "NoteDb");
-      }
-    } catch (ExecutionException | InterruptedException e) {
-      Throwables.propagateIfInstanceOf(e.getCause(), UpdateException.class);
-      Throwables.propagateIfInstanceOf(e.getCause(), RestApiException.class);
-      throw new UpdateException(e);
-    } catch (OrmException | IOException e) {
-      throw new UpdateException(e);
-    }
-
-    // Reindex changes.
-    for (ChangeTask task : tasks) {
-      if (task.deleted) {
-        indexFutures.add(indexer.deleteAsync(task.id));
-      } else if (task.dirty) {
-        indexFutures.add(indexer.indexAsync(project, task.id));
-      }
-    }
-  }
-
-  private static class SlowUpdateException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    private SlowUpdateException(String fmt, Object... args) {
-      super(String.format(fmt, args));
-    }
-  }
-
-  private void maybeLogSlowUpdate(long startNanos, String desc) {
-    long elapsedNanos = System.nanoTime() - startNanos;
-    if (!log.isDebugEnabled() || elapsedNanos <= logThresholdNanos) {
-      return;
-    }
-    // Always log even without RequestId.
-    log.debug("Slow " + desc + " update",
-        new SlowUpdateException(
-            "Slow %s update (%d ms) to %s for %s",
-            desc, NANOSECONDS.toMillis(elapsedNanos), project, ops.keySet()));
-  }
-
-  private void executeNoteDbUpdates(List<ChangeTask> tasks) {
-    // Aggregate together all NoteDb ref updates from the ops we executed,
-    // possibly in parallel. Each task had its own NoteDbUpdateManager instance
-    // with its own thread-local copy of the repo(s), but each of those was just
-    // used for staging updates and was never executed.
-    //
-    // Use a new BatchRefUpdate as the original batchRefUpdate field is intended
-    // for use only by the updateRepo phase.
-    //
-    // See the comments in NoteDbUpdateManager#execute() for why we execute the
-    // updates on the change repo first.
-    logDebug("Executing NoteDb updates for {} changes", tasks.size());
-    try {
-      BatchRefUpdate changeRefUpdate =
-          getRepository().getRefDatabase().newBatchUpdate();
-      boolean hasAllUsersCommands = false;
-      try (ObjectInserter ins = getRepository().newObjectInserter()) {
-        int objs = 0;
-        for (ChangeTask task : tasks) {
-          if (task.noteDbResult == null) {
-            logDebug("No-op update to {}", task.id);
-            continue;
-          }
-          for (ReceiveCommand cmd : task.noteDbResult.changeCommands()) {
-            changeRefUpdate.addCommand(cmd);
-          }
-          for (InsertedObject obj : task.noteDbResult.changeObjects()) {
-            objs++;
-            ins.insert(obj.type(), obj.data().toByteArray());
-          }
-          hasAllUsersCommands |=
-              !task.noteDbResult.allUsersCommands().isEmpty();
-        }
-        logDebug("Collected {} objects and {} ref updates to change repo",
-            objs, changeRefUpdate.getCommands().size());
-        executeNoteDbUpdate(getRevWalk(), ins, changeRefUpdate);
-      }
-
-      if (hasAllUsersCommands) {
-        try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-            RevWalk allUsersRw = new RevWalk(allUsersRepo);
-            ObjectInserter allUsersIns = allUsersRepo.newObjectInserter()) {
-          int objs = 0;
-          BatchRefUpdate allUsersRefUpdate =
-              allUsersRepo.getRefDatabase().newBatchUpdate();
-          for (ChangeTask task : tasks) {
-            for (ReceiveCommand cmd : task.noteDbResult.allUsersCommands()) {
-              allUsersRefUpdate.addCommand(cmd);
-            }
-            for (InsertedObject obj : task.noteDbResult.allUsersObjects()) {
-              allUsersIns.insert(obj.type(), obj.data().toByteArray());
-            }
-          }
-          logDebug("Collected {} objects and {} ref updates to All-Users",
-              objs, allUsersRefUpdate.getCommands().size());
-          executeNoteDbUpdate(allUsersRw, allUsersIns, allUsersRefUpdate);
-        }
-      } else {
-        logDebug("No All-Users updates");
-      }
-    } catch (IOException e) {
-      // Ignore all errors trying to update NoteDb at this point. We've
-      // already written the NoteDbChangeState to ReviewDb, which means
-      // if the state is out of date it will be rebuilt the next time it
-      // is needed.
-      // Always log even without RequestId.
-      log.debug(
-          "Ignoring NoteDb update error after ReviewDb write", e);
-    }
-  }
-
-  private void executeNoteDbUpdate(RevWalk rw, ObjectInserter ins,
-      BatchRefUpdate bru) throws IOException {
-    if (bru.getCommands().isEmpty()) {
-      logDebug("No commands, skipping flush and ref update");
-      return;
-    }
-    ins.flush();
-    bru.setAllowNonFastForwards(true);
-    bru.execute(rw, NullProgressMonitor.INSTANCE);
-    for (ReceiveCommand cmd : bru.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("Update failed: " + bru);
-      }
-    }
-  }
-
-  private class ChangeTask implements Callable<Void> {
-    final Change.Id id;
-    private final Collection<Op> changeOps;
-    private final Thread mainThread;
-
-    NoteDbUpdateManager.StagedResult noteDbResult;
-    boolean dirty;
-    boolean deleted;
-    private String taskId;
-
-    private ChangeTask(Change.Id id, Collection<Op> changeOps,
-        Thread mainThread) {
-      this.id = id;
-      this.changeOps = changeOps;
-      this.mainThread = mainThread;
-    }
-
-    @Override
-    public Void call() throws Exception {
-      taskId = id.toString() + "-" + Thread.currentThread().getId();
-      if (Thread.currentThread() == mainThread) {
-        Repository repo = getRepository();
-        try (ObjectReader reader = repo.newObjectReader();
-            RevWalk rw = new RevWalk(repo)) {
-          call(BatchUpdate.this.db, repo, rw);
-        }
-      } else {
-        // Possible optimization: allow Ops to declare whether they need to
-        // access the repo from updateChange, and don't open in this thread
-        // unless we need it. However, as of this writing the only operations
-        // that are executed in parallel are during ReceiveCommits, and they
-        // all need the repo open anyway. (The non-parallel case above does not
-        // reopen the repo.)
-        try (ReviewDb threadLocalDb = schemaFactory.open();
-            Repository repo = repoManager.openRepository(project);
-            RevWalk rw = new RevWalk(repo)) {
-          call(threadLocalDb, repo, rw);
-        }
-      }
-      return null;
-    }
-
-    private void call(ReviewDb db, Repository repo, RevWalk rw)
-        throws Exception {
-      @SuppressWarnings("resource") // Not always opened.
-      NoteDbUpdateManager updateManager = null;
-      try {
-        ChangeContext ctx;
-        db.changes().beginTransaction(id);
-        try {
-          ctx = newChangeContext(db, repo, rw, id);
-          // Call updateChange on each op.
-          logDebug("Calling updateChange on {} ops", changeOps.size());
-          for (Op op : changeOps) {
-            dirty |= op.updateChange(ctx);
-          }
-          if (!dirty) {
-            logDebug("No ops reported dirty, short-circuiting");
-            return;
-          }
-          deleted = ctx.deleted;
-          if (deleted) {
-            logDebug("Change was deleted");
-          }
-
-          // Stage the NoteDb update and store its state in the Change.
-          if (notesMigration.commitChangeWrites()) {
-            updateManager = stageNoteDbUpdate(ctx, deleted);
-          }
-
-          // Bump lastUpdatedOn or rowVersion and commit.
-          Iterable<Change> cs = changesToUpdate(ctx);
-          if (newChanges.containsKey(id)) {
-            // Insert rather than upsert in case of a race on change IDs.
-            logDebug("Inserting change");
-            db.changes().insert(cs);
-          } else if (deleted) {
-            logDebug("Deleting change");
-            db.changes().delete(cs);
-          } else {
-            logDebug("Updating change");
-            db.changes().update(cs);
-          }
-          db.commit();
-        } finally {
-          db.rollback();
-        }
-
-        if (notesMigration.commitChangeWrites()) {
-          try {
-            // Do not execute the NoteDbUpdateManager, as we don't want too much
-            // contention on the underlying repo, and we would rather use a
-            // single ObjectInserter/BatchRefUpdate later.
-            //
-            // TODO(dborowitz): May or may not be worth trying to batch
-            // together flushed inserters as well.
-            noteDbResult = updateManager.stage().get(id);
-          } catch (IOException ex) {
-            // Ignore all errors trying to update NoteDb at this point. We've
-            // already written the NoteDbChangeState to ReviewDb, which means
-            // if the state is out of date it will be rebuilt the next time it
-            // is needed.
-            log.debug(
-                "Ignoring NoteDb update error after ReviewDb write", ex);
-          }
-        }
-      } catch (Exception e) {
-        logDebug("Error updating change (should be rethrown)", e);
-        Throwables.propagateIfPossible(e, RestApiException.class);
-        throw new UpdateException(e);
-      } finally {
-        if (updateManager != null) {
-          updateManager.close();
-        }
-      }
-    }
-
-    private ChangeContext newChangeContext(ReviewDb db, Repository repo,
-        RevWalk rw, Change.Id id) throws OrmException, NoSuchChangeException {
-      Change c = newChanges.get(id);
-      if (c == null) {
-        c = ReviewDbUtil.unwrapDb(db).changes().get(id);
-        if (c == null) {
-          logDebug("Failed to get change {} from unwrapped db", id);
-          throw new NoSuchChangeException(id);
-        }
-      }
-      // Pass in preloaded change to controlFor, to avoid:
-      //  - reading from a db that does not belong to this update
-      //  - attempting to read a change that doesn't exist yet
-      ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c);
-      ChangeControl ctl = changeControlFactory.controlFor(notes, user);
-      return new ChangeContext(ctl, new BatchUpdateReviewDb(db), repo, rw);
-    }
-
-    private NoteDbUpdateManager stageNoteDbUpdate(ChangeContext ctx,
-        boolean deleted) throws OrmException, IOException {
-      logDebug("Staging NoteDb update");
-      NoteDbUpdateManager updateManager = updateManagerFactory
-          .create(ctx.getProject())
-          .setChangeRepo(ctx.getRepository(), ctx.getRevWalk(), null,
-              new ChainedReceiveCommands(repo));
-      for (ChangeUpdate u : ctx.updates.values()) {
-        updateManager.add(u);
-      }
-      if (deleted) {
-        updateManager.deleteChange(ctx.getChange().getId());
-      }
-      try {
-        updateManager.stageAndApplyDelta(ctx.getChange());
-      } catch (OrmConcurrencyException ex) {
-        // Refused to apply update because NoteDb was out of sync. Go ahead with
-        // this ReviewDb update; it's still out of sync, but this is no worse
-        // than before, and it will eventually get rebuilt.
-        logDebug("Ignoring OrmConcurrencyException while staging");
-      }
-      return updateManager;
-    }
-
-    private void logDebug(String msg, Throwable t) {
-      if (log.isDebugEnabled()) {
-        BatchUpdate.this.logDebug("[" + taskId + "]" + msg, t);
-      }
-    }
-
-    private void logDebug(String msg, Object... args) {
-      if (log.isDebugEnabled()) {
-        BatchUpdate.this.logDebug("[" + taskId + "]" + msg, args);
-      }
-    }
-  }
-
-  private static Iterable<Change> changesToUpdate(ChangeContext ctx) {
-    Change c = ctx.getChange();
-    if (ctx.bumpLastUpdatedOn && c.getLastUpdatedOn().before(ctx.getWhen())) {
-      c.setLastUpdatedOn(ctx.getWhen());
-    }
-    return Collections.singleton(c);
-  }
-
-  private void executePostOps() throws Exception {
-    Context ctx = new Context();
-    for (Op op : ops.values()) {
-      op.postUpdate(ctx);
-    }
-
-    for (RepoOnlyOp op : repoOnlyOps) {
-      op.postUpdate(ctx);
-    }
-  }
-
-  private void logDebug(String msg, Throwable t) {
-    if (requestId != null && log.isDebugEnabled()) {
-      log.debug(requestId + msg, t);
-    }
-  }
-
-  private void logDebug(String msg, Object... args) {
-    // Only log if there is a requestId assigned, since those are the
-    // expensive/complicated requests like MergeOp. Doing it every time would be
-    // noisy.
-    if (requestId != null && log.isDebugEnabled()) {
-      log.debug(requestId + msg, args);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdateReviewDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdateReviewDb.java
deleted file mode 100644
index 1de98d3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdateReviewDb.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gwtorm.server.AtomicUpdate;
-
-public class BatchUpdateReviewDb extends ReviewDbWrapper {
-  private final ChangeAccess changesWrapper;
-
-  BatchUpdateReviewDb(ReviewDb delegate) {
-    super(delegate);
-    changesWrapper = new BatchUpdateChanges(delegate.changes());
-  }
-
-  public ReviewDb unsafeGetDelegate() {
-    return delegate;
-  }
-
-  @Override
-  public ChangeAccess changes() {
-    return changesWrapper;
-  }
-
-  @Override
-  public void commit() {
-    throw new UnsupportedOperationException(
-        "do not call commit; BatchUpdate always manages transactions");
-  }
-
-  @Override
-  public void rollback() {
-    throw new UnsupportedOperationException(
-        "do not call rollback; BatchUpdate always manages transactions");
-  }
-
-  private static class BatchUpdateChanges extends ChangeAccessWrapper {
-    private BatchUpdateChanges(ChangeAccess delegate) {
-      super(delegate);
-    }
-
-    @Override
-    public void insert(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call insert; change is automatically inserted");
-    }
-
-    @Override
-    public void upsert(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call upsert; existing changes are updated automatically,"
-          + " or use InsertChangeOp for insertion");
-    }
-
-    @Override
-    public void update(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call update; change is updated automatically");
-    }
-
-    @Override
-    public void beginTransaction(Change.Id key) {
-      throw new UnsupportedOperationException(
-          "updateChange is always called within a transaction");
-    }
-
-    @Override
-    public void deleteKeys(Iterable<Change.Id> keys) {
-      throw new UnsupportedOperationException(
-          "do not call deleteKeys; use ChangeContext#deleteChange()");
-    }
-
-    @Override
-    public void delete(Iterable<Change> instances) {
-      throw new UnsupportedOperationException(
-          "do not call delete; use ChangeContext#deleteChange()");
-    }
-
-    @Override
-    public Change atomicUpdate(Change.Id key,
-        AtomicUpdate<Change> update) {
-      throw new UnsupportedOperationException(
-          "do not call atomicUpdate; updateChange is always called within a"
-          + " transaction");
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BranchOrderSection.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BranchOrderSection.java
index 0ef9e0c..d4b537e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BranchOrderSection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BranchOrderSection.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.RefNames;
-
 import java.util.List;
 
 public class BranchOrderSection {
@@ -24,7 +23,7 @@
   /**
    * Branch names ordered from least to the most stable.
    *
-   * Typically the order will be like: master, stable-M.N, stable-M.N-1, ...
+   * <p>Typically the order will be like: master, stable-M.N, stable-M.N-1, ...
    */
   private final ImmutableList<String> order;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
deleted file mode 100644
index cfbaa41..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChainedReceiveCommands.java
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.base.Optional;
-
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.Map;
-
-/**
- * Collection of {@link ReceiveCommand}s that supports multiple updates per ref.
- * <p>
- * The underlying behavior of {@link BatchRefUpdate} is undefined (an
- * implementations vary) when more than one command per ref is added. This class
- * works around that limitation by allowing multiple updates per ref, as long as
- * the previous new SHA-1 matches the next old SHA-1.
- */
-public class ChainedReceiveCommands implements RefCache {
-  private final Map<String, ReceiveCommand> commands = new LinkedHashMap<>();
-  private final RepoRefCache refCache;
-
-  public ChainedReceiveCommands(Repository repo) {
-    this(new RepoRefCache(repo));
-  }
-
-  public ChainedReceiveCommands(RepoRefCache refCache) {
-    this.refCache = checkNotNull(refCache);
-  }
-
-  public RepoRefCache getRepoRefCache() {
-    return refCache;
-  }
-
-  public boolean isEmpty() {
-    return commands.isEmpty();
-  }
-
-  /**
-   * Add a command.
-   *
-   * @param cmd command to add. If a command has been previously added for the
-   *     same ref, the new SHA-1 of the most recent previous command must match
-   *     the old SHA-1 of this command.
-   */
-  public void add(ReceiveCommand cmd) {
-    checkArgument(!cmd.getOldId().equals(cmd.getNewId()),
-        "ref update is a no-op: %s", cmd);
-    ReceiveCommand old = commands.get(cmd.getRefName());
-    if (old == null) {
-      commands.put(cmd.getRefName(), cmd);
-      return;
-    }
-    checkArgument(old.getResult() == ReceiveCommand.Result.NOT_ATTEMPTED,
-        "cannot chain ref update %s after update %s with result %s",
-        cmd, old, old.getResult());
-    checkArgument(cmd.getOldId().equals(old.getNewId()),
-        "cannot chain ref update %s after update %s with different new ID",
-        cmd, old);
-    commands.put(cmd.getRefName(), new ReceiveCommand(
-        old.getOldId(), cmd.getNewId(), cmd.getRefName()));
-  }
-
-  /**
-   * Get the latest value of a ref according to this sequence of commands.
-   * <p>
-   * After the value for a ref is read from the repo once, it is cached as in
-   * {@link RepoRefCache}.
-   *
-   * @see RefCache#get(String)
-   */
-  @Override
-  public Optional<ObjectId> get(String refName) throws IOException {
-    ReceiveCommand cmd = commands.get(refName);
-    if (cmd != null) {
-      return !cmd.getNewId().equals(ObjectId.zeroId())
-          ? Optional.of(cmd.getNewId())
-          : Optional.<ObjectId>absent();
-    }
-    return refCache.get(refName);
-  }
-
-  /**
-   * Add commands from this instance to a native JGit batch update.
-   * <p>
-   * Exactly one command per ref will be added to the update. The old SHA-1 will
-   * be the old SHA-1 of the first command added to this instance for that ref;
-   * the new SHA-1 will be the new SHA-1 of the last command.
-   *
-   * @param bru batch update
-   */
-  public void addTo(BatchRefUpdate bru) {
-    for (ReceiveCommand cmd : commands.values()) {
-      bru.addCommand(cmd);
-    }
-  }
-
-  /** @return an unmodifiable view of commands. */
-  public Map<String, ReceiveCommand> getCommands() {
-    return Collections.unmodifiableMap(commands);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java
index f2e7f78..0af4b72 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeAlreadyMergedException.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.server.git;
 
-/**
- * Indicates that the change or commit is already in the source tree.
- */
+/** Indicates that the change or commit is already in the source tree. */
 public class ChangeAlreadyMergedException extends MergeIdenticalTreeException {
   private static final long serialVersionUID = 1L;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeMessageModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeMessageModifier.java
new file mode 100644
index 0000000..7d4edcf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeMessageModifier.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Branch;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/**
+ * Allows to modify the commit message for new commits generated by Rebase Always submit strategy.
+ *
+ * <p>Invoked by Gerrit when all information about new commit is already known such as parent(s),
+ * tree hash, etc, but commit's message can still be modified.
+ */
+@ExtensionPoint
+public interface ChangeMessageModifier {
+
+  /**
+   * Implementation must return non-Null commit message.
+   *
+   * <p>mergeTip and original commit are guaranteed to have their body parsed, meaning that their
+   * commit messages and footers can be accessed.
+   *
+   * @param newCommitMessage the new commit message that was result of either
+   *     <ul>
+   *       <li>{@link MergeUtil#createDetailedCommitMessage} called before
+   *       <li>other extensions or plugins implementing the same point and called before.
+   *     </ul>
+   *
+   * @param original the commit of the change being submitted. <b>Note that its commit message may
+   *     be different than newCommitMessage argument.</b>
+   * @param mergeTip the current HEAD of the destination branch, which will be a parent of a new
+   *     commit being generated
+   * @param destination the branch onto which the change is being submitted
+   * @return a new not null commit message.
+   */
+  String onSubmit(
+      String newCommitMessage, RevCommit original, RevCommit mergeTip, Branch.NameKey destination);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
index 370bc2d..1a39a76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
 import org.eclipse.jgit.lib.ProgressMonitor;
 
 /** Trivial op to update a counter during {@code updateChange} */
-class ChangeProgressOp extends BatchUpdate.Op {
+class ChangeProgressOp implements BatchUpdateOp {
   private final ProgressMonitor progress;
 
   ChangeProgressOp(ProgressMonitor progress) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java
new file mode 100644
index 0000000..3bdfe4c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+
+public interface ChangeReportFormatter {
+  @AutoValue
+  public abstract static class Input {
+    public abstract Change change();
+
+    @Nullable
+    public abstract String subject();
+
+    @Nullable
+    public abstract Boolean isDraft();
+
+    @Nullable
+    public abstract Boolean isEdit();
+
+    public static Builder builder() {
+      return new AutoValue_ChangeReportFormatter_Input.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder setChange(Change val);
+
+      public abstract Builder setSubject(String val);
+
+      public abstract Builder setIsDraft(Boolean val);
+
+      public abstract Builder setIsEdit(Boolean val);
+
+      abstract Change change();
+
+      abstract String subject();
+
+      abstract Boolean isDraft();
+
+      abstract Boolean isEdit();
+
+      abstract Input autoBuild();
+
+      public Input build() {
+        setChange(change());
+        setSubject(subject() == null ? change().getSubject() : subject());
+        setIsDraft(isDraft() == null ? Change.Status.DRAFT == change().getStatus() : isDraft());
+        setIsEdit(isEdit() == null ? false : isEdit());
+        return autoBuild();
+      }
+    }
+  }
+
+  String newChange(Input input);
+
+  String changeUpdated(Input input);
+
+  String changeClosed(Input input);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
index 16e4bd9..03d44ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
@@ -14,40 +14,36 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.Collection;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
  * A set of changes grouped together to be submitted atomically.
- * <p>
- * MergeSuperSet constructs ChangeSets to accumulate intermediate
- * results toward the ChangeSet it returns when done.
- * <p>
- * This class is not thread safe.
+ *
+ * <p>MergeSuperSet constructs ChangeSets to accumulate intermediate results toward the ChangeSet it
+ * returns when done.
+ *
+ * <p>This class is not thread safe.
  */
 public class ChangeSet {
   private final ImmutableMap<Change.Id, ChangeData> changeData;
 
   /**
-   * Additional changes not included in changeData because their
-   * connection to the original change is not visible to the
-   * current user.  That is, this map includes both
-   * - changes that are not visible to the current user, and
-   * - changes whose only relationship to the set is via a change
-   *   that is not visible to the current user
+   * Additional changes not included in changeData because their connection to the original change
+   * is not visible to the current user. That is, this map includes both - changes that are not
+   * visible to the current user, and - changes whose only relationship to the set is via a change
+   * that is not visible to the current user
    */
   private final ImmutableMap<Change.Id, ChangeData> nonVisibleChanges;
 
@@ -63,14 +59,14 @@
     return ImmutableMap.copyOf(ret);
   }
 
-  public ChangeSet(
-      Iterable<ChangeData> changes, Iterable<ChangeData> hiddenChanges) {
+  public ChangeSet(Iterable<ChangeData> changes, Iterable<ChangeData> hiddenChanges) {
     changeData = index(changes, ImmutableList.<Change.Id>of());
     nonVisibleChanges = index(hiddenChanges, changeData.keySet());
   }
 
   public ChangeSet(ChangeData change, boolean visible) {
-    this(visible ? ImmutableList.of(change) : ImmutableList.<ChangeData>of(),
+    this(
+        visible ? ImmutableList.of(change) : ImmutableList.<ChangeData>of(),
         ImmutableList.of(change));
   }
 
@@ -82,10 +78,9 @@
     return changeData;
   }
 
-  public Multimap<Branch.NameKey, ChangeData> changesByBranch()
-      throws OrmException {
+  public ListMultimap<Branch.NameKey, ChangeData> changesByBranch() throws OrmException {
     ListMultimap<Branch.NameKey, ChangeData> ret =
-        ArrayListMultimap.create();
+        MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeData cd : changeData.values()) {
       ret.put(cd.change().getDest(), cd);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeUpdateExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeUpdateExecutor.java
deleted file mode 100644
index 3452bb0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeUpdateExecutor.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.inject.BindingAnnotation;
-
-import java.lang.annotation.Retention;
-
-/**
- * Marker on the global {@link ListeningExecutorService} used by
- * {@link ReceiveCommits} to create or replace changes.
- */
-@Retention(RUNTIME)
-@BindingAnnotation
-public @interface ChangeUpdateExecutor {
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
index f07b922..80c705e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -16,14 +16,13 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.git.strategy.CommitMergeStatus;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -32,28 +31,22 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.util.List;
-
 /** Extended commit entity with code review specific metadata. */
 public class CodeReviewCommit extends RevCommit {
   /**
    * Default ordering when merging multiple topologically-equivalent commits.
-   * <p>
-   * Operates only on these commits and does not take ancestry into account.
-   * <p>
-   * Use this in preference to the default order, which comes from {@link
-   * AnyObjectId} and only orders on SHA-1.
+   *
+   * <p>Operates only on these commits and does not take ancestry into account.
+   *
+   * <p>Use this in preference to the default order, which comes from {@link AnyObjectId} and only
+   * orders on SHA-1.
    */
-  public static final Ordering<CodeReviewCommit> ORDER = Ordering.natural()
-      .onResultOf(new Function<CodeReviewCommit, Integer>() {
-        @Override
-        public Integer apply(CodeReviewCommit in) {
-          return in.getPatchsetId() != null
-              ? in.getPatchsetId().getParentKey().get()
-              : null;
-        }
-      }).nullsFirst();
+  public static final Ordering<CodeReviewCommit> ORDER =
+      Ordering.natural()
+          .onResultOf(
+              (CodeReviewCommit c) ->
+                  c.getPatchsetId() != null ? c.getPatchsetId().getParentKey().get() : null)
+          .nullsFirst();
 
   public static CodeReviewRevWalk newRevWalk(Repository repo) {
     return new CodeReviewRevWalk(repo);
@@ -78,22 +71,21 @@
     }
 
     @Override
-    public CodeReviewCommit next() throws MissingObjectException,
-         IncorrectObjectTypeException, IOException {
+    public CodeReviewCommit next()
+        throws MissingObjectException, IncorrectObjectTypeException, IOException {
       return (CodeReviewCommit) super.next();
     }
 
     @Override
-    public void markStart(RevCommit c) throws MissingObjectException,
-        IncorrectObjectTypeException, IOException {
+    public void markStart(RevCommit c)
+        throws MissingObjectException, IncorrectObjectTypeException, IOException {
       checkArgument(c instanceof CodeReviewCommit);
       super.markStart(c);
     }
 
     @Override
     public void markUninteresting(final RevCommit c)
-        throws MissingObjectException, IncorrectObjectTypeException,
-        IOException {
+        throws MissingObjectException, IncorrectObjectTypeException, IOException {
       checkArgument(c instanceof CodeReviewCommit);
       super.markUninteresting(c);
     }
@@ -105,17 +97,16 @@
 
     @Override
     public CodeReviewCommit parseCommit(AnyObjectId id)
-        throws MissingObjectException, IncorrectObjectTypeException,
-        IOException {
+        throws MissingObjectException, IncorrectObjectTypeException, IOException {
       return (CodeReviewCommit) super.parseCommit(id);
     }
   }
 
   /**
    * Unique key of the PatchSet entity from the code review system.
-   * <p>
-   * This value is only available on commits that have a PatchSet represented in
-   * the code review system.
+   *
+   * <p>This value is only available on commits that have a PatchSet represented in the code review
+   * system.
    */
   private PatchSet.Id patchsetId;
 
@@ -124,14 +115,11 @@
 
   /**
    * The result status for this commit.
-   * <p>
-   * Only valid if {@link #patchsetId} is not null.
+   *
+   * <p>Only valid if {@link #patchsetId} is not null.
    */
   private CommitMergeStatus statusCode;
 
-  /** Commits which are missing ancestors of this commit. */
-  List<CodeReviewCommit> missing;
-
   public CodeReviewCommit(final AnyObjectId id) {
     super(id);
   }
@@ -160,7 +148,6 @@
     control = src.control;
     patchsetId = src.patchsetId;
     statusCode = src.statusCode;
-    missing = src.missing;
   }
 
   public Change change() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ConfiguredMimeTypes.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ConfiguredMimeTypes.java
index 082e1a2..5362ee6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ConfiguredMimeTypes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ConfiguredMimeTypes.java
@@ -14,22 +14,20 @@
 
 package com.google.gerrit.server.git;
 
-import org.eclipse.jgit.errors.InvalidPatternException;
-import org.eclipse.jgit.fnmatch.FileNameMatcher;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.InvalidPatternException;
+import org.eclipse.jgit.fnmatch.FileNameMatcher;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class ConfiguredMimeTypes {
-  private static final Logger log = LoggerFactory
-      .getLogger(ConfiguredMimeTypes.class);
+  private static final Logger log = LoggerFactory.getLogger(ConfiguredMimeTypes.class);
 
   private static final String MIMETYPE = "mimetype";
   private static final String KEY_PATH = "path";
@@ -47,10 +45,14 @@
           try {
             add(typeName, path);
           } catch (PatternSyntaxException | InvalidPatternException e) {
-            log.warn(String.format(
-                "Ignoring invalid %s.%s.%s = %s in project %s: %s",
-                MIMETYPE, typeName, KEY_PATH,
-                path, projectName, e.getMessage()));
+            log.warn(
+                "Ignoring invalid {}.{}.{} = {} in project {}: {}",
+                MIMETYPE,
+                typeName,
+                KEY_PATH,
+                path,
+                projectName,
+                e.getMessage());
           }
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
new file mode 100644
index 0000000..7a266ff
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.inject.Inject;
+
+public class DefaultChangeReportFormatter implements ChangeReportFormatter {
+  private final String canonicalWebUrl;
+
+  @Inject
+  DefaultChangeReportFormatter(@CanonicalWebUrl String canonicalWebUrl) {
+    this.canonicalWebUrl = canonicalWebUrl;
+  }
+
+  @Override
+  public String newChange(ChangeReportFormatter.Input input) {
+    return formatChangeUrl(canonicalWebUrl, input);
+  }
+
+  @Override
+  public String changeUpdated(ChangeReportFormatter.Input input) {
+    return formatChangeUrl(canonicalWebUrl, input);
+  }
+
+  @Override
+  public String changeClosed(ChangeReportFormatter.Input input) {
+    return String.format(
+        "change %s closed", ChangeUtil.formatChangeUrl(canonicalWebUrl, input.change()));
+  }
+
+  private String formatChangeUrl(String url, Input input) {
+    StringBuilder m =
+        new StringBuilder()
+            .append("  ")
+            .append(ChangeUtil.formatChangeUrl(url, input.change()))
+            .append(" ")
+            .append(ChangeUtil.cropSubject(input.subject()));
+    if (input.isDraft()) {
+      m.append(" [DRAFT]");
+    }
+    if (input.isEdit()) {
+      m.append(" [EDIT]");
+    }
+    return m.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java
index cff69c6..a9c21ff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java
@@ -28,9 +28,7 @@
     return workQueue.getDefaultQueue().schedule(this, delay, unit);
   }
 
-  public ScheduledFuture<?> startWithFixedDelay(long initialDelay, long delay,
-      TimeUnit unit) {
-    return workQueue.getDefaultQueue()
-        .scheduleWithFixedDelay(this, initialDelay, delay, unit);
+  public ScheduledFuture<?> startWithFixedDelay(long initialDelay, long delay, TimeUnit unit) {
+    return workQueue.getDefaultQueue().scheduleWithFixedDelay(this, initialDelay, delay, unit);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
index 7c02e5b..eacf66e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java
@@ -14,29 +14,28 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-
 import java.io.IOException;
 import java.util.List;
 import java.util.Set;
 
 public class DestinationList extends TabFile {
   public static final String DIR_NAME = "destinations";
-  private SetMultimap<String, Branch.NameKey> destinations = HashMultimap.create();
+  private SetMultimap<String, Branch.NameKey> destinations =
+      MultimapBuilder.hashKeys().hashSetValues().build();
 
   public Set<Branch.NameKey> getDestinations(String label) {
     return destinations.get(label);
   }
 
-  public void parseLabel(String label, String text,
-      ValidationError.Sink errors) throws IOException {
-    destinations.replaceValues(label,
-        toSet(parse(text, DIR_NAME + label, TRIM, null, errors)));
+  public void parseLabel(String label, String text, ValidationError.Sink errors)
+      throws IOException {
+    destinations.replaceValues(label, toSet(parse(text, DIR_NAME + label, TRIM, null, errors)));
   }
 
   public String asText(String label) {
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 66e0704..44450f0 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
@@ -14,15 +14,17 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 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.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.MergedSender;
+import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gwtorm.server.OrmException;
@@ -32,18 +34,21 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.assistedinject.Assisted;
-
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.concurrent.ExecutorService;
-
 public class EmailMerge implements Runnable, RequestContext {
   private static final Logger log = LoggerFactory.getLogger(EmailMerge.class);
 
   public interface Factory {
-    EmailMerge create(Project.NameKey project, Change.Id changeId,
-        Account.Id submitter, NotifyHandling notifyHandling);
+    EmailMerge create(
+        Project.NameKey project,
+        Change.Id changeId,
+        Account.Id submitter,
+        NotifyHandling notifyHandling,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify);
   }
 
   private final ExecutorService sendEmailsExecutor;
@@ -56,10 +61,13 @@
   private final Change.Id changeId;
   private final Account.Id submitter;
   private final NotifyHandling notifyHandling;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+
   private ReviewDb db;
 
   @Inject
-  EmailMerge(@SendEmailExecutor ExecutorService executor,
+  EmailMerge(
+      @SendEmailExecutor ExecutorService executor,
       MergedSender.Factory mergedSenderFactory,
       SchemaFactory<ReviewDb> schemaFactory,
       ThreadLocalRequestContext requestContext,
@@ -67,7 +75,8 @@
       @Assisted Project.NameKey project,
       @Assisted Change.Id changeId,
       @Assisted @Nullable Account.Id submitter,
-      @Assisted NotifyHandling notifyHandling) {
+      @Assisted NotifyHandling notifyHandling,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.schemaFactory = schemaFactory;
@@ -77,10 +86,12 @@
     this.changeId = changeId;
     this.submitter = submitter;
     this.notifyHandling = notifyHandling;
+    this.accountsToNotify = accountsToNotify;
   }
 
   public void sendAsync() {
-    sendEmailsExecutor.submit(this);
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
   }
 
   @Override
@@ -92,6 +103,7 @@
         cm.setFrom(submitter);
       }
       cm.setNotify(notifyHandling);
+      cm.setAccountsToNotify(accountsToNotify);
       cm.send();
     } catch (Exception e) {
       log.error("Cannot email merged notification for " + changeId, e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
index 5bb4dfd..33c31fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -22,7 +22,10 @@
 import com.google.gerrit.server.config.GcConfig;
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.inject.Inject;
-
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
 import org.eclipse.jgit.api.GarbageCollectCommand;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -35,19 +38,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.PrintWriter;
-import java.util.List;
-import java.util.Properties;
-import java.util.Set;
-
 public class GarbageCollection {
-  private static final Logger log = LoggerFactory
-      .getLogger(GarbageCollection.class);
+  private static final Logger log = LoggerFactory.getLogger(GarbageCollection.class);
 
   public static final String LOG_NAME = "gc_log";
   private static final Logger gcLog = LoggerFactory.getLogger(LOG_NAME);
 
-
   private final GitRepositoryManager repoManager;
   private final GarbageCollectionQueue gcQueue;
   private final GcConfig gcConfig;
@@ -58,8 +54,10 @@
   }
 
   @Inject
-  GarbageCollection(GitRepositoryManager repoManager,
-      GarbageCollectionQueue gcQueue, GcConfig config,
+  GarbageCollection(
+      GitRepositoryManager repoManager,
+      GarbageCollectionQueue gcQueue,
+      GcConfig config,
       DynamicSet<GarbageCollectorListener> listeners) {
     this.repoManager = repoManager;
     this.gcQueue = gcQueue;
@@ -71,19 +69,19 @@
     return run(projectNames, null);
   }
 
-  public GarbageCollectionResult run(List<Project.NameKey> projectNames,
-      PrintWriter writer) {
+  public GarbageCollectionResult run(List<Project.NameKey> projectNames, PrintWriter writer) {
     return run(projectNames, gcConfig.isAggressive(), writer);
   }
 
-  public GarbageCollectionResult run(List<Project.NameKey> projectNames,
-      boolean aggressive, PrintWriter writer) {
+  public GarbageCollectionResult run(
+      List<Project.NameKey> projectNames, boolean aggressive, PrintWriter writer) {
     GarbageCollectionResult result = new GarbageCollectionResult();
     Set<Project.NameKey> projectsToGc = gcQueue.addAll(projectNames);
-    for (Project.NameKey projectName : Sets.difference(
-        Sets.newHashSet(projectNames), projectsToGc)) {
-      result.addError(new GarbageCollectionResult.Error(
-          GarbageCollectionResult.Error.Type.GC_ALREADY_SCHEDULED, projectName));
+    for (Project.NameKey projectName :
+        Sets.difference(Sets.newHashSet(projectNames), projectsToGc)) {
+      result.addError(
+          new GarbageCollectionResult.Error(
+              GarbageCollectionResult.Error.Type.GC_ALREADY_SCHEDULED, projectName));
     }
     for (Project.NameKey p : projectsToGc) {
       try (Repository repo = repoManager.openRepository(p)) {
@@ -92,21 +90,21 @@
         GarbageCollectCommand gc = Git.wrap(repo).gc();
         gc.setAggressive(aggressive);
         logGcInfo(p, "before:", gc.getStatistics());
-        gc.setProgressMonitor(writer != null ? new TextProgressMonitor(writer)
-            : NullProgressMonitor.INSTANCE);
+        gc.setProgressMonitor(
+            writer != null ? new TextProgressMonitor(writer) : NullProgressMonitor.INSTANCE);
         Properties statistics = gc.call();
         logGcInfo(p, "after: ", statistics);
         print(writer, "done.\n\n");
         fire(p, statistics);
       } catch (RepositoryNotFoundException e) {
         logGcError(writer, p, e);
-        result.addError(new GarbageCollectionResult.Error(
-            GarbageCollectionResult.Error.Type.REPOSITORY_NOT_FOUND,
-            p));
+        result.addError(
+            new GarbageCollectionResult.Error(
+                GarbageCollectionResult.Error.Type.REPOSITORY_NOT_FOUND, p));
       } catch (Exception e) {
         logGcError(writer, p, e);
-        result.addError(new GarbageCollectionResult.Error(
-            GarbageCollectionResult.Error.Type.GC_FAILED, p));
+        result.addError(
+            new GarbageCollectionResult.Error(GarbageCollectionResult.Error.Type.GC_FAILED, p));
       } finally {
         gcQueue.gcFinished(p);
       }
@@ -132,8 +130,7 @@
     logGcInfo(projectName, msg, null);
   }
 
-  private static void logGcInfo(Project.NameKey projectName, String msg,
-      Properties statistics) {
+  private static void logGcInfo(Project.NameKey projectName, String msg, Properties statistics) {
     StringBuilder b = new StringBuilder();
     b.append("[").append(projectName.get()).append("] ");
     b.append(msg);
@@ -148,15 +145,14 @@
     gcLog.info(b.toString());
   }
 
-  private static void logGcConfiguration(Project.NameKey projectName,
-      Repository repo, boolean aggressive) {
+  private static void logGcConfiguration(
+      Project.NameKey projectName, Repository repo, boolean aggressive) {
     StringBuilder b = new StringBuilder();
     Config cfg = repo.getConfig();
     b.append("gc.aggressive=").append(aggressive).append("; ");
     b.append(formatConfigValues(cfg, ConfigConstants.CONFIG_GC_SECTION, null));
     for (String subsection : cfg.getSubsections(ConfigConstants.CONFIG_GC_SECTION)) {
-      b.append(formatConfigValues(cfg, ConfigConstants.CONFIG_GC_SECTION,
-          subsection));
+      b.append(formatConfigValues(cfg, ConfigConstants.CONFIG_GC_SECTION, subsection));
     }
     if (b.length() == 0) {
       b.append("no set");
@@ -166,8 +162,7 @@
     logGcInfo(projectName, "pack config: " + (new PackConfig(repo)).toString());
   }
 
-  private static String formatConfigValues(Config config, String section,
-      String subsection) {
+  private static String formatConfigValues(Config config, String section, String subsection) {
     StringBuilder b = new StringBuilder();
     Set<String> names = config.getNames(section, subsection);
     for (String name : names) {
@@ -183,8 +178,7 @@
     return b.toString();
   }
 
-  private static void logGcError(PrintWriter writer,
-      Project.NameKey projectName, Exception e) {
+  private static void logGcError(PrintWriter writer, Project.NameKey projectName, Exception e) {
     print(writer, "failed.\n\n");
     StringBuilder b = new StringBuilder();
     b.append("[").append(projectName.get()).append("]");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java
index b2a4311..e1f0594 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionLogFile.java
@@ -18,13 +18,11 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.inject.Inject;
-
+import java.nio.file.Path;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
 import org.apache.log4j.PatternLayout;
 
-import java.nio.file.Path;
-
 public class GarbageCollectionLogFile implements LifecycleListener {
 
   @Inject
@@ -35,8 +33,7 @@
   }
 
   @Override
-  public void start() {
-  }
+  public void start() {}
 
   @Override
   public void stop() {
@@ -46,8 +43,9 @@
   private static void initLogSystem(Path logdir) {
     Logger gcLogger = LogManager.getLogger(GarbageCollection.LOG_NAME);
     gcLogger.removeAllAppenders();
-    gcLogger.addAppender(SystemLog.createAppender(logdir,
-        GarbageCollection.LOG_NAME, new PatternLayout("[%d] %-5p %x: %m%n")));
+    gcLogger.addAppender(
+        SystemLog.createAppender(
+            logdir, GarbageCollection.LOG_NAME, new PatternLayout("[%d] %-5p %x: %m%n")));
     gcLogger.setAdditivity(false);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
index 90e2aac..31cd880 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Singleton;
-
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
@@ -27,8 +26,7 @@
   private final Set<Project.NameKey> projectsScheduledForGc = new HashSet<>();
 
   public synchronized Set<Project.NameKey> addAll(Collection<Project.NameKey> projects) {
-    Set<Project.NameKey> added =
-        Sets.newLinkedHashSetWithExpectedSize(projects.size());
+    Set<Project.NameKey> added = Sets.newLinkedHashSetWithExpectedSize(projects.size());
     for (Project.NameKey p : projects) {
       if (projectsScheduledForGc.add(p)) {
         added.add(p);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionRunner.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionRunner.java
index e01924e..4d8a61f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollectionRunner.java
@@ -22,16 +22,15 @@
 import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
-
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.concurrent.TimeUnit;
-
 /** Runnable to enable scheduling gc to run periodically */
 public class GarbageCollectionRunner implements Runnable {
-  private static final Logger gcLog = LoggerFactory
-      .getLogger(GarbageCollection.LOG_NAME);
+  private static final Logger gcLog = LoggerFactory.getLogger(GarbageCollection.LOG_NAME);
+  private static final Logger log = LoggerFactory.getLogger(GarbageCollectionRunner.class);
 
   static class Lifecycle implements LifecycleListener {
     private final WorkQueue queue;
@@ -39,8 +38,7 @@
     private final GcConfig gcConfig;
 
     @Inject
-    Lifecycle(WorkQueue queue, GarbageCollectionRunner gcRunner,
-        GcConfig config) {
+    Lifecycle(WorkQueue queue, GarbageCollectionRunner gcRunner, GcConfig config) {
       this.queue = queue;
       this.gcRunner = gcRunner;
       this.gcConfig = config;
@@ -52,13 +50,15 @@
       long interval = scheduleConfig.getInterval();
       long delay = scheduleConfig.getInitialDelay();
       if (delay == MISSING_CONFIG && interval == MISSING_CONFIG) {
-        gcLog.info("Ignoring missing gc schedule configuration");
+        log.info("Ignoring missing gc schedule configuration");
       } else if (delay < 0 || interval <= 0) {
-        gcLog.warn(String.format(
-            "Ignoring invalid gc schedule configuration: %s", scheduleConfig));
+        log.warn("Ignoring invalid gc schedule configuration: {}", scheduleConfig);
       } else {
-        queue.getDefaultQueue().scheduleAtFixedRate(gcRunner, delay,
-            interval, TimeUnit.MILLISECONDS);
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError =
+            queue
+                .getDefaultQueue()
+                .scheduleAtFixedRate(gcRunner, delay, interval, TimeUnit.MILLISECONDS);
       }
     }
 
@@ -72,8 +72,8 @@
   private final ProjectCache projectCache;
 
   @Inject
-  GarbageCollectionRunner(GarbageCollection.Factory garbageCollectionFactory,
-      ProjectCache projectCache) {
+  GarbageCollectionRunner(
+      GarbageCollection.Factory garbageCollectionFactory, ProjectCache projectCache) {
     this.garbageCollectionFactory = garbageCollectionFactory;
     this.projectCache = projectCache;
   }
@@ -81,8 +81,7 @@
   @Override
   public void run() {
     gcLog.info("Triggering gc on all repositories");
-    garbageCollectionFactory.create().run(
-        Lists.newArrayList(projectCache.all()));
+    garbageCollectionFactory.create().run(Lists.newArrayList(projectCache.all()));
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
index e609d68..03910eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
-
 import org.eclipse.jgit.transport.PostUploadHook;
 
 /** Configures the Git support. */
@@ -27,7 +27,8 @@
     factory(MetaDataUpdate.InternalFactory.class);
     bind(MetaDataUpdate.Server.class);
     bind(ReceiveConfig.class);
-    DynamicSet.bind(binder(), PostUploadHook.class)
-        .to(UploadPackMetricsHook.class);
+    DynamicSet.bind(binder(), PostUploadHook.class).to(UploadPackMetricsHook.class);
+    DynamicItem.itemOf(binder(), ChangeReportFormatter.class);
+    DynamicItem.bind(binder(), ChangeReportFormatter.class).to(DefaultChangeReportFormatter.class);
   }
 }
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 26c59c2..e680ea7 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
@@ -25,7 +25,11 @@
 import com.google.gerrit.server.util.SubmoduleSectionParser;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BlobBasedConfig;
 import org.eclipse.jgit.lib.FileMode;
@@ -35,15 +39,9 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Set;
-
 /**
- * Loads the .gitmodules file of the specified project/branch.
- * It can be queried which submodules this branch is subscribed to.
+ * Loads the .gitmodules file of the specified project/branch. It can be queried which submodules
+ * this branch is subscribed to.
  */
 public class GitModules {
   private static final Logger log = LoggerFactory.getLogger(GitModules.class);
@@ -61,39 +59,37 @@
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted Branch.NameKey branch,
-      @Assisted MergeOpRepoManager orm) throws IOException {
+      @Assisted MergeOpRepoManager orm)
+      throws IOException {
     this.submissionId = orm.getSubmissionId();
     Project.NameKey project = branch.getParentKey();
     logDebug("Loading .gitmodules of {} for project {}", branch, project);
-    OpenRepo or;
     try {
-      or = orm.openRepo(project);
+      OpenRepo or = orm.getRepo(project);
+      ObjectId id = or.repo.resolve(branch.get());
+      if (id == null) {
+        throw new IOException("Cannot open branch " + branch.get());
+      }
+      RevCommit commit = or.rw.parseCommit(id);
+
+      try (TreeWalk tw = TreeWalk.forPath(or.repo, GIT_MODULES, commit.getTree())) {
+        if (tw == null || (tw.getRawMode(0) & FileMode.TYPE_MASK) != FileMode.TYPE_FILE) {
+          subscriptions = Collections.emptySet();
+          logDebug("The .gitmodules file doesn't exist in " + branch);
+          return;
+        }
+      }
+      BlobBasedConfig bbc;
+      try {
+        bbc = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
+      } catch (ConfigInvalidException e) {
+        throw new IOException(
+            "Could not read .gitmodules of super project: " + branch.getParentKey(), e);
+      }
+      subscriptions = new SubmoduleSectionParser(bbc, canonicalWebUrl, branch).parseAllSections();
     } catch (NoSuchProjectException e) {
       throw new IOException(e);
     }
-
-    ObjectId id = or.repo.resolve(branch.get());
-    if (id == null) {
-      throw new IOException("Cannot open branch " + branch.get());
-    }
-    RevCommit commit = or.rw.parseCommit(id);
-
-    TreeWalk tw = TreeWalk.forPath(or.repo, GIT_MODULES, commit.getTree());
-    if (tw == null
-        || (tw.getRawMode(0) & FileMode.TYPE_MASK) != FileMode.TYPE_FILE) {
-      subscriptions = Collections.emptySet();
-      logDebug("The .gitmodules file doesn't exist in " + branch);
-      return;
-    }
-    BlobBasedConfig bbc;
-    try {
-      bbc = new BlobBasedConfig(null, or.repo, commit, GIT_MODULES);
-    } catch (ConfigInvalidException e) {
-      throw new IOException("Could not read .gitmodules of super project: " +
-              branch.getParentKey(), e);
-    }
-    subscriptions = new SubmoduleSectionParser(bbc, canonicalWebUrl,
-          branch).parseAllSections();
   }
 
   public Collection<SubmoduleSubscription> subscribedTo(Branch.NameKey src) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
index 29e14ec..f8d8a49 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -15,77 +15,46 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.ImplementedBy;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.SortedSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-import java.util.SortedSet;
-
 /**
  * Manages Git repositories for the Gerrit server process.
- * <p>
- * Implementations of this interface should be a {@link Singleton} and
- * registered in Guice so they are globally available within the server
- * environment.
+ *
+ * <p>Implementations of this interface should be a {@link Singleton} and registered in Guice so
+ * they are globally available within the server environment.
  */
+@ImplementedBy(value = LocalDiskRepositoryManager.class)
 public interface GitRepositoryManager {
   /**
    * Get (or open) a repository by name.
    *
    * @param name the repository name, relative to the base directory.
-   * @return the cached Repository instance. Caller must call {@code close()}
-   *         when done to decrement the resource handle.
-   * @throws RepositoryNotFoundException the name does not denote an existing
-   *         repository.
+   * @return the cached Repository instance. Caller must call {@code close()} when done to decrement
+   *     the resource handle.
+   * @throws RepositoryNotFoundException the name does not denote an existing repository.
    * @throws IOException the name cannot be read as a repository.
    */
-  Repository openRepository(Project.NameKey name)
-      throws RepositoryNotFoundException, IOException;
+  Repository openRepository(Project.NameKey name) throws RepositoryNotFoundException, IOException;
 
   /**
    * Create (and open) a repository by name.
    *
    * @param name the repository name, relative to the base directory.
-   * @return the cached Repository instance. Caller must call {@code close()}
-   *         when done to decrement the resource handle.
-   * @throws RepositoryCaseMismatchException the name collides with an existing
-   *         repository name, but only in case of a character within the name.
+   * @return the cached Repository instance. Caller must call {@code close()} when done to decrement
+   *     the resource handle.
+   * @throws RepositoryCaseMismatchException the name collides with an existing repository name, but
+   *     only in case of a character within the name.
    * @throws RepositoryNotFoundException the name is invalid.
    * @throws IOException the repository cannot be created.
    */
   Repository createRepository(Project.NameKey name)
-      throws RepositoryCaseMismatchException, RepositoryNotFoundException,
-      IOException;
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException;
 
   /** @return set of all known projects, sorted by natural NameKey order. */
   SortedSet<Project.NameKey> list();
-
-  /**
-   * Read the {@code GIT_DIR/description} file for gitweb.
-   * <p>
-   * NB: This code should really be in JGit, as a member of the Repository
-   * object. Until it moves there, its here.
-   *
-   * @param name the repository name, relative to the base directory.
-   * @return description text; null if no description has been configured.
-   * @throws RepositoryNotFoundException the named repository does not exist.
-   * @throws IOException the description file exists, but is not readable by
-   *         this process.
-   */
-  String getProjectDescription(Project.NameKey name)
-      throws RepositoryNotFoundException, IOException;
-
-  /**
-   * Set the {@code GIT_DIR/description} file for gitweb.
-   * <p>
-   * NB: This code should really be in JGit, as a member of the Repository
-   * object. Until it moves there, its here.
-   *
-   * @param name the repository name, relative to the base directory.
-   * @param description new description text for the repository.
-   */
-  void setProjectDescription(Project.NameKey name,
-      final String description);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
index d832260..960c72a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
@@ -18,14 +18,10 @@
 import static org.eclipse.jgit.revwalk.RevFlag.UNINTERESTING;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.SetMultimap;
@@ -37,15 +33,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayDeque;
 import java.util.Collection;
 import java.util.Deque;
@@ -53,38 +41,41 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Helper for assigning groups to commits during {@link ReceiveCommits}.
- * <p>
- * For each commit encountered along a walk between the branch tip and the tip
- * of the push, the group of a commit is defined as follows:
+ *
+ * <p>For each commit encountered along a walk between the branch tip and the tip of the push, the
+ * group of a commit is defined as follows:
+ *
  * <ul>
- *   <li>If the commit is an existing patch set of a change, the group is read
- *   from the group field in the corresponding {@link PatchSet} record.</li>
- *   <li>If all of a commit's parents are merged into the branch, then its group
- *   is its own SHA-1.</li>
- *   <li>If the commit has a single parent that is not yet merged into the
- *   branch, then its group is the same as the parent's group.<li>
- *   <li>For a merge commit, choose a parent and use that parent's group. If one
- *   of the parents has a group from a patch set, use that group, otherwise, use
- *   the group from the first parent. In addition to setting this merge commit's
- *   group, use the chosen group for all commits that would otherwise use a
- *   group from the parents that were not chosen.</li>
- *   <li>If a merge commit has multiple parents whose group comes from separate
- *   patch sets, concatenate the groups from those parents together. This
- *   indicates two side branches were pushed separately, followed by the merge.
+ *   <li>If the commit is an existing patch set of a change, the group is read from the group field
+ *       in the corresponding {@link PatchSet} record.
+ *   <li>If all of a commit's parents are merged into the branch, then its group is its own SHA-1.
+ *   <li>If the commit has a single parent that is not yet merged into the branch, then its group is
+ *       the same as the parent's group.
+ *   <li>
+ *   <li>For a merge commit, choose a parent and use that parent's group. If one of the parents has
+ *       a group from a patch set, use that group, otherwise, use the group from the first parent.
+ *       In addition to setting this merge commit's group, use the chosen group for all commits that
+ *       would otherwise use a group from the parents that were not chosen.
+ *   <li>If a merge commit has multiple parents whose group comes from separate patch sets,
+ *       concatenate the groups from those parents together. This indicates two side branches were
+ *       pushed separately, followed by the merge.
  *   <li>
  * </ul>
- * <p>
- * Callers must call {@link #visit(RevCommit)} on all commits between the
- * current branch tip and the tip of a push, in reverse topo order (parents
- * before children). Once all commits have been visited, call {@link
- * #getGroups()} for the result.
+ *
+ * <p>Callers must call {@link #visit(RevCommit)} on all commits between the current branch tip and
+ * the tip of a push, in reverse topo order (parents before children). Once all commits have been
+ * visited, call {@link #getGroups()} for the result.
  */
 public class GroupCollector {
-  private static final Logger log =
-      LoggerFactory.getLogger(GroupCollector.class);
+  private static final Logger log = LoggerFactory.getLogger(GroupCollector.class);
 
   public static List<String> getDefaultGroups(PatchSet ps) {
     return ImmutableList.of(ps.getRevision().get());
@@ -104,29 +95,29 @@
   }
 
   private interface Lookup {
-    List<String> lookup(PatchSet.Id psId)
-        throws OrmException, NoSuchChangeException;
+    List<String> lookup(PatchSet.Id psId) throws OrmException;
   }
 
-  private final Multimap<ObjectId, PatchSet.Id> patchSetsBySha;
-  private final Multimap<ObjectId, String> groups;
+  private final ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha;
+  private final ListMultimap<ObjectId, String> groups;
   private final SetMultimap<String, String> groupAliases;
   private final Lookup groupLookup;
 
   private boolean done;
 
-  public static GroupCollector create(Multimap<ObjectId, Ref> changeRefsById,
-      final ReviewDb db, final PatchSetUtil psUtil,
-      final ChangeNotes.Factory notesFactory, final Project.NameKey project) {
+  public static GroupCollector create(
+      ListMultimap<ObjectId, Ref> changeRefsById,
+      ReviewDb db,
+      PatchSetUtil psUtil,
+      ChangeNotes.Factory notesFactory,
+      Project.NameKey project) {
     return new GroupCollector(
         transformRefs(changeRefsById),
         new Lookup() {
           @Override
-          public List<String> lookup(PatchSet.Id psId)
-              throws OrmException, NoSuchChangeException {
+          public List<String> lookup(PatchSet.Id psId) throws OrmException {
             // TODO(dborowitz): Reuse open repository from caller.
-            ChangeNotes notes =
-                notesFactory.createChecked(db, project, psId.getParentKey());
+            ChangeNotes notes = notesFactory.createChecked(db, project, psId.getParentKey());
             PatchSet ps = psUtil.get(db, notes, psId);
             return ps != null ? ps.getGroups() : null;
           }
@@ -134,7 +125,7 @@
   }
 
   public static GroupCollector createForSchemaUpgradeOnly(
-      Multimap<ObjectId, Ref> changeRefsById, final ReviewDb db) {
+      ListMultimap<ObjectId, Ref> changeRefsById, ReviewDb db) {
     return new GroupCollector(
         transformRefs(changeRefsById),
         new Lookup() {
@@ -146,31 +137,22 @@
         });
   }
 
-  private GroupCollector(
-      Multimap<ObjectId, PatchSet.Id> patchSetsBySha,
-      Lookup groupLookup) {
+  private GroupCollector(ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha, Lookup groupLookup) {
     this.patchSetsBySha = patchSetsBySha;
     this.groupLookup = groupLookup;
-    groups = ArrayListMultimap.create();
-    groupAliases = HashMultimap.create();
+    groups = MultimapBuilder.hashKeys().arrayListValues().build();
+    groupAliases = MultimapBuilder.hashKeys().hashSetValues().build();
   }
 
-  private static Multimap<ObjectId, PatchSet.Id> transformRefs(
-      Multimap<ObjectId, Ref> refs) {
-    return Multimaps.transformValues(
-        refs,
-        new Function<Ref, PatchSet.Id>() {
-          @Override
-          public PatchSet.Id apply(Ref in) {
-            return PatchSet.Id.fromRef(in.getName());
-          }
-        });
+  private static ListMultimap<ObjectId, PatchSet.Id> transformRefs(
+      ListMultimap<ObjectId, Ref> refs) {
+    return Multimaps.transformValues(refs, r -> PatchSet.Id.fromRef(r.getName()));
   }
 
   @VisibleForTesting
   GroupCollector(
-      Multimap<ObjectId, PatchSet.Id> patchSetsBySha,
-      final ListMultimap<PatchSet.Id, String> groupLookup) {
+      ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha,
+      ListMultimap<PatchSet.Id, String> groupLookup) {
     this(
         patchSetsBySha,
         new Lookup() {
@@ -208,8 +190,8 @@
     for (RevCommit p : interestingParents) {
       Collection<String> parentGroups = groups.get(p);
       if (parentGroups.isEmpty()) {
-        throw new IllegalStateException(String.format(
-            "no group assigned to parent %s of commit %s", p.name(), c.name()));
+        throw new IllegalStateException(
+            String.format("no group assigned to parent %s of commit %s", p.name(), c.name()));
       }
 
       for (String parentGroup : parentGroups) {
@@ -242,15 +224,11 @@
     }
   }
 
-  public SortedSetMultimap<ObjectId, String> getGroups()
-      throws OrmException, NoSuchChangeException {
+  public SortedSetMultimap<ObjectId, String> getGroups() throws OrmException {
     done = true;
-    SortedSetMultimap<ObjectId, String> result = MultimapBuilder
-        .hashKeys(groups.keySet().size())
-        .treeSetValues()
-        .build();
-    for (Map.Entry<ObjectId, Collection<String>> e
-        : groups.asMap().entrySet()) {
+    SortedSetMultimap<ObjectId, String> result =
+        MultimapBuilder.hashKeys(groups.keySet().size()).treeSetValues().build();
+    for (Map.Entry<ObjectId, Collection<String>> e : groups.asMap().entrySet()) {
       ObjectId id = e.getKey();
       result.putAll(id.copy(), resolveGroups(id, e.getValue()));
     }
@@ -258,8 +236,7 @@
   }
 
   private Set<RevCommit> getInterestingParents(RevCommit commit) {
-    Set<RevCommit> result =
-        Sets.newLinkedHashSetWithExpectedSize(commit.getParentCount());
+    Set<RevCommit> result = Sets.newLinkedHashSetWithExpectedSize(commit.getParentCount());
     for (RevCommit p : commit.getParents()) {
       if (!p.has(UNINTERESTING)) {
         result.add(p);
@@ -273,9 +250,8 @@
     return id != null && patchSetsBySha.containsKey(id);
   }
 
-  private Set<String> resolveGroups(ObjectId forCommit,
-      Collection<String> candidates)
-          throws OrmException, NoSuchChangeException {
+  private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
+      throws OrmException {
     Set<String> actual = Sets.newTreeSet();
     Set<String> done = Sets.newHashSetWithExpectedSize(candidates.size());
     Set<String> seen = Sets.newHashSetWithExpectedSize(candidates.size());
@@ -305,14 +281,12 @@
       return ObjectId.fromString(group);
     } catch (IllegalArgumentException e) {
       // Shouldn't happen; some sort of corruption or manual tinkering?
-      log.warn("group for commit {} is not a SHA-1: {}",
-          forCommit.name(), group);
+      log.warn("group for commit {} is not a SHA-1: {}", forCommit.name(), group);
       return null;
     }
   }
 
-  private Iterable<String> resolveGroup(ObjectId forCommit, String group)
-      throws OrmException, NoSuchChangeException {
+  private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws OrmException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
       PatchSet.Id psId = Iterables.getFirst(patchSetsBySha.get(id), null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
index bd76ad4..5791843 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
+import com.google.gerrit.reviewdb.client.Project;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -24,8 +24,12 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class GroupList extends TabFile {
+  private static final Logger log = LoggerFactory.getLogger(GroupList.class);
+
   public static final String FILE_NAME = "groups";
 
   private final Map<AccountGroup.UUID, GroupReference> byUUID;
@@ -34,12 +38,15 @@
     this.byUUID = byUUID;
   }
 
-  public static GroupList parse(String text, ValidationError.Sink errors)
+  public static GroupList parse(Project.NameKey project, String text, ValidationError.Sink errors)
       throws IOException {
     List<Row> rows = parse(text, FILE_NAME, TRIM, TRIM, errors);
-    Map<AccountGroup.UUID, GroupReference> groupsByUUID =
-        new HashMap<>(rows.size());
+    Map<AccountGroup.UUID, GroupReference> groupsByUUID = new HashMap<>(rows.size());
     for (Row row : rows) {
+      if (row.left == null) {
+        log.warn("null field in group list for {}:\n{}", project, text);
+        continue;
+      }
       AccountGroup.UUID uuid = new AccountGroup.UUID(row.left);
       String name = row.right;
       GroupReference ref = new GroupReference(uuid, name);
@@ -105,5 +112,4 @@
   public void retainUUIDs(Collection<AccountGroup.UUID> toBeRetained) {
     byUUID.keySet().retainAll(toBeRetained);
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
index 8080419..0011994 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/HackPushNegotiateHook.java
@@ -17,7 +17,11 @@
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
 import com.google.common.collect.Sets;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -30,43 +34,32 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Set;
-
 /**
  * Advertises part of history to git push clients.
- * <p>
- * This is a hack to work around the lack of negotiation in the
- * send-pack/receive-pack wire protocol.
- * <p>
- * When the server is frequently advancing master by creating merge commits, the
- * client may not be able to discover a common ancestor during push. Attempting
- * to push will re-upload a very large amount of history. This hook hacks in a
- * fake negotiation replacement by walking history and sending recent commits as
- * {@code ".have"} lines in the wire protocol, allowing the client to find a
- * common ancestor.
+ *
+ * <p>This is a hack to work around the lack of negotiation in the send-pack/receive-pack wire
+ * protocol.
+ *
+ * <p>When the server is frequently advancing master by creating merge commits, the client may not
+ * be able to discover a common ancestor during push. Attempting to push will re-upload a very large
+ * amount of history. This hook hacks in a fake negotiation replacement by walking history and
+ * sending recent commits as {@code ".have"} lines in the wire protocol, allowing the client to find
+ * a common ancestor.
  */
 public class HackPushNegotiateHook implements AdvertiseRefsHook {
-  private static final Logger log = LoggerFactory
-      .getLogger(HackPushNegotiateHook.class);
+  private static final Logger log = LoggerFactory.getLogger(HackPushNegotiateHook.class);
 
   /** Size of an additional ".have" line. */
-  private static final int HAVE_LINE_LEN = 4
-      + Constants.OBJECT_ID_STRING_LENGTH
-      + 1 + 5 + 1;
+  private static final int HAVE_LINE_LEN = 4 + Constants.OBJECT_ID_STRING_LENGTH + 1 + 5 + 1;
 
   /**
-   * Maximum number of bytes to "waste" in the advertisement with a peek at this
-   * repository's current reachable history.
+   * Maximum number of bytes to "waste" in the advertisement with a peek at this repository's
+   * current reachable history.
    */
   private static final int MAX_EXTRA_BYTES = 8192;
 
   /**
-   * Number of recent commits to advertise immediately, hoping to show a client
-   * a nearby merge base.
+   * Number of recent commits to advertise immediately, hoping to show a client a nearby merge base.
    */
   private static final int BASE_COMMITS = 64;
 
@@ -78,13 +71,11 @@
 
   @Override
   public void advertiseRefs(UploadPack us) {
-    throw new UnsupportedOperationException(
-        "HackPushNegotiateHook cannot be used for UploadPack");
+    throw new UnsupportedOperationException("HackPushNegotiateHook cannot be used for UploadPack");
   }
 
   @Override
-  public void advertiseRefs(BaseReceivePack rp)
-      throws ServiceMayNotContinueException {
+  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
     Map<String, Ref> r = rp.getAdvertisedRefs();
     if (r == null) {
       try {
@@ -128,7 +119,7 @@
       Set<ObjectId> history = Sets.newHashSetWithExpectedSize(max);
       try {
         int stepCnt = 0;
-        for (RevCommit c; history.size() < max && (c = rw.next()) != null;) {
+        for (RevCommit c; history.size() < max && (c = rw.next()) != null; ) {
           if (c.getParentCount() <= 1
               && !alreadySending.contains(c)
               && (history.size() < BASE_COMMITS || (++stepCnt % STEP_COMMITS) == 0)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
index a70c235..9c43bdb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
@@ -17,7 +17,13 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.collect.ImmutableList;
-
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -28,14 +34,6 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PackParser;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Set;
-
 public class InMemoryInserter extends ObjectInserter {
   private final ObjectReader reader;
   private final Map<ObjectId, InsertedObject> inserted = new LinkedHashMap<>();
@@ -52,8 +50,7 @@
   }
 
   @Override
-  public ObjectId insert(int type, long length, InputStream in)
-      throws IOException {
+  public ObjectId insert(int type, long length, InputStream in) throws IOException {
     return insert(InsertedObject.create(type, in));
   }
 
@@ -109,8 +106,7 @@
     }
 
     @Override
-    public Collection<ObjectId> resolve(AbbreviatedObjectId id)
-        throws IOException {
+    public Collection<ObjectId> resolve(AbbreviatedObjectId id) throws IOException {
       Set<ObjectId> result = new HashSet<>();
       for (ObjectId insId : inserted.keySet()) {
         if (id.prefixCompare(insId) == 0) {
@@ -122,8 +118,7 @@
     }
 
     @Override
-    public ObjectLoader open(AnyObjectId objectId, int typeHint)
-        throws IOException {
+    public ObjectLoader open(AnyObjectId objectId, int typeHint) throws IOException {
       InsertedObject obj = inserted.get(objectId);
       if (obj == null) {
         return reader.open(objectId, typeHint);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java
index 1aee14f..8a766af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/InsertedObject.java
@@ -16,14 +16,12 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.protobuf.ByteString;
-
+import java.io.IOException;
+import java.io.InputStream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectLoader;
 
-import java.io.IOException;
-import java.io.InputStream;
-
 @AutoValue
 public abstract class InsertedObject {
   static InsertedObject create(int type, InputStream in) throws IOException {
@@ -45,7 +43,9 @@
   }
 
   public abstract ObjectId id();
+
   public abstract int type();
+
   public abstract ByteString data();
 
   ObjectLoader newLoader() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
index f3b2ac9..6a05d22 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
@@ -32,23 +32,20 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.Collection;
 import java.util.List;
 
 /**
  * Normalizes votes on labels according to project config and permissions.
- * <p>
- * Votes are recorded in the database for a user based on the state of the
- * project at that time: what labels are defined for the project, and what the
- * user is allowed to vote on. Both of those can change between the time a vote
- * is originally made and a later point, for example when a change is submitted.
- * This class normalizes old votes against current project configuration.
+ *
+ * <p>Votes are recorded in the database for a user based on the state of the project at that time:
+ * what labels are defined for the project, and what the user is allowed to vote on. Both of those
+ * can change between the time a vote is originally made and a later point, for example when a
+ * change is submitted. This class normalizes old votes against current project configuration.
  */
 @Singleton
 public class LabelNormalizer {
@@ -66,7 +63,9 @@
     }
 
     public abstract ImmutableList<PatchSetApproval> unchanged();
+
     public abstract ImmutableList<PatchSetApproval> updated();
+
     public abstract ImmutableList<PatchSetApproval> deleted();
 
     public Iterable<PatchSetApproval> getNormalized() {
@@ -91,42 +90,36 @@
   /**
    * @param change change containing the given approvals.
    * @param approvals list of approvals.
-   * @return copies of approvals normalized to the defined ranges for the label
-   *     type and permissions for the user. Approvals for unknown labels are not
-   *     included in the output, nor are approvals where the user has no
-   *     permissions for that label.
-   * @throws NoSuchChangeException
+   * @return copies of approvals normalized to the defined ranges for the label type and permissions
+   *     for the user. Approvals for unknown labels are not included in the output, nor are
+   *     approvals where the user has no permissions for that label.
    * @throws OrmException
    */
   public Result normalize(Change change, Collection<PatchSetApproval> approvals)
-      throws NoSuchChangeException, OrmException {
+      throws OrmException {
     IdentifiedUser user = userFactory.create(change.getOwner());
-    return normalize(
-        changeFactory.controlFor(db.get(), change, user), approvals);
+    return normalize(changeFactory.controlFor(db.get(), change, user), approvals);
   }
 
   /**
    * @param ctl change control containing the given approvals.
    * @param approvals list of approvals.
-   * @return copies of approvals normalized to the defined ranges for the label
-   *     type and permissions for the user. Approvals for unknown labels are not
-   *     included in the output, nor are approvals where the user has no
-   *     permissions for that label.
+   * @return copies of approvals normalized to the defined ranges for the label type and permissions
+   *     for the user. Approvals for unknown labels are not included in the output, nor are
+   *     approvals where the user has no permissions for that label.
    */
-  public Result normalize(ChangeControl ctl,
-      Collection<PatchSetApproval> approvals) {
-    List<PatchSetApproval> unchanged =
-        Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> updated =
-        Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> deleted =
-        Lists.newArrayListWithCapacity(approvals.size());
+  public Result normalize(ChangeControl ctl, Collection<PatchSetApproval> approvals) {
+    List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
+    List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
+    List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
     LabelTypes labelTypes = ctl.getLabelTypes();
     for (PatchSetApproval psa : approvals) {
       Change.Id changeId = psa.getKey().getParentKey().getParentKey();
-      checkArgument(changeId.equals(ctl.getId()),
+      checkArgument(
+          changeId.equals(ctl.getId()),
           "Approval %s does not match change %s",
-          psa.getKey(), ctl.getChange().getKey());
+          psa.getKey(),
+          ctl.getChange().getKey());
       if (psa.isLegacySubmit()) {
         unchanged.add(psa);
         continue;
@@ -153,8 +146,7 @@
    * @param ctl change control (for any user).
    * @param lt label type.
    * @param id account ID.
-   * @return whether the given account ID has any permissions to vote on this
-   *     label for this change.
+   * @return whether the given account ID has any permissions to vote on this label for this change.
    */
   public boolean canVote(ChangeControl ctl, LabelType lt, Account.Id id) {
     return !getRange(ctl, lt, id).isEmpty();
@@ -164,15 +156,13 @@
     return new PatchSetApproval(src.getPatchSetId(), src);
   }
 
-  private PermissionRange getRange(ChangeControl ctl, LabelType lt,
-      Account.Id id) {
+  private PermissionRange getRange(ChangeControl ctl, LabelType lt, Account.Id id) {
     String permission = Permission.forLabel(lt.getName());
     IdentifiedUser user = userFactory.create(id);
     return ctl.forUser(user).getRange(permission);
   }
 
-  private boolean applyRightFloor(ChangeControl ctl, LabelType lt,
-      PatchSetApproval a) {
+  private boolean applyRightFloor(ChangeControl ctl, LabelType lt, PatchSetApproval a) {
     PermissionRange range = getRange(ctl, lt, a.getAccountId());
     if (range.isEmpty()) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java
index d08b8768..bcde7f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java
@@ -16,18 +16,17 @@
 
 /**
  * Wrapper for {@link org.eclipse.jgit.errors.LargeObjectException}. Since
- * org.eclipse.jgit.errors.LargeObjectException is a {@link RuntimeException}
- * the GerritJsonServlet would treat it as internal failure and as result the
- * web ui would just show 'Internal Server Error'. Wrapping
- * org.eclipse.jgit.errors.LargeObjectException into a normal {@link Exception}
+ * org.eclipse.jgit.errors.LargeObjectException is a {@link RuntimeException} the GerritJsonServlet
+ * would treat it as internal failure and as result the web ui would just show 'Internal Server
+ * Error'. Wrapping org.eclipse.jgit.errors.LargeObjectException into a normal {@link Exception}
  * allows to display a proper error message.
  */
 public class LargeObjectException extends Exception {
 
   private static final long serialVersionUID = 1L;
 
-  public LargeObjectException(final String message,
-      final org.eclipse.jgit.errors.LargeObjectException cause) {
+  public LargeObjectException(
+      final String message, final org.eclipse.jgit.errors.LargeObjectException cause) {
     super(message, cause);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
index ebfaae7..bc12e02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
@@ -16,13 +16,11 @@
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.inject.Inject;
-
+import java.util.Collection;
 import org.eclipse.jgit.transport.PostReceiveHook;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.ReceivePack;
 
-import java.util.Collection;
-
 class LazyPostReceiveHookChain implements PostReceiveHook {
   private final DynamicSet<PostReceiveHook> hooks;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index d532078..5f836ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -22,26 +22,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.internal.storage.file.LockFile;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ConfigConstants;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.lib.RepositoryCacheConfig;
-import org.eclipse.jgit.lib.StoredConfig;
-import org.eclipse.jgit.storage.file.WindowCacheConfig;
-import org.eclipse.jgit.util.FS;
-import org.eclipse.jgit.util.IO;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.File;
-import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.file.FileVisitOption;
 import java.nio.file.FileVisitResult;
@@ -55,22 +36,28 @@
 import java.util.TreeSet;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ConfigConstants;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.lib.RepositoryCacheConfig;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.storage.file.WindowCacheConfig;
+import org.eclipse.jgit.util.FS;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Manages Git repositories stored on the local filesystem. */
 @Singleton
-public class LocalDiskRepositoryManager implements GitRepositoryManager,
-    LifecycleListener {
-  private static final Logger log =
-      LoggerFactory.getLogger(LocalDiskRepositoryManager.class);
-
-  private static final String UNNAMED =
-      "Unnamed repository; edit this file to name it for gitweb.";
+public class LocalDiskRepositoryManager implements GitRepositoryManager {
+  private static final Logger log = LoggerFactory.getLogger(LocalDiskRepositoryManager.class);
 
   public static class Module extends LifecycleModule {
     @Override
     protected void configure() {
-      bind(GitRepositoryManager.class).to(LocalDiskRepositoryManager.class);
-      listener().to(LocalDiskRepositoryManager.class);
       listener().to(LocalDiskRepositoryManager.Lifecycle.class);
     }
   }
@@ -93,9 +80,11 @@
       cfg.fromConfig(serverConfig);
       if (serverConfig.getString("core", null, "streamFileThreshold") == null) {
         long mx = Runtime.getRuntime().maxMemory();
-        int limit = (int) Math.min(
-            mx / 4, // don't use more than 1/4 of the heap.
-            2047 << 20); // cannot exceed array length
+        int limit =
+            (int)
+                Math.min(
+                    mx / 4, // don't use more than 1/4 of the heap.
+                    2047 << 20); // cannot exceed array length
         if ((5 << 20) < limit && limit % (1 << 20) != 0) {
           // If the limit is at least 5 MiB but is not a whole multiple
           // of MiB round up to the next one full megabyte. This is a very
@@ -111,17 +100,14 @@
         } else {
           desc = String.format("%d", limit);
         }
-        log.info(String.format(
-            "Defaulting core.streamFileThreshold to %s",
-            desc));
+        log.info("Defaulting core.streamFileThreshold to {}", desc);
         cfg.setStreamFileThreshold(limit);
       }
       cfg.install();
     }
 
     @Override
-    public void stop() {
-    }
+    public void stop() {}
   }
 
   private final Path basePath;
@@ -129,8 +115,7 @@
   private volatile SortedSet<Project.NameKey> names = new TreeSet<>();
 
   @Inject
-  LocalDiskRepositoryManager(SitePaths site,
-      @GerritServerConfig Config cfg) {
+  LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
     basePath = site.resolve(cfg.getString("gerrit", null, "basePath"));
     if (basePath == null) {
       throw new IllegalStateException("gerrit.basePath must be configured");
@@ -139,15 +124,6 @@
     namesUpdateLock = new ReentrantLock(true /* fair */);
   }
 
-  @Override
-  public void start() {
-    names = list();
-  }
-
-  @Override
-  public void stop() {
-  }
-
   /**
    * Return the basePath under which the specified project is stored.
    *
@@ -159,8 +135,7 @@
   }
 
   @Override
-  public Repository openRepository(Project.NameKey name)
-      throws RepositoryNotFoundException {
+  public Repository openRepository(Project.NameKey name) throws RepositoryNotFoundException {
     return openRepository(getBasePath(name), name);
   }
 
@@ -183,11 +158,11 @@
         }
       } else {
         final File directory = gitDir;
-        if (FileKey.isGitRepository(new File(directory, Constants.DOT_GIT),
-            FS.DETECTED)) {
+        if (FileKey.isGitRepository(new File(directory, Constants.DOT_GIT), FS.DETECTED)) {
           onCreateProject(name);
-        } else if (FileKey.isGitRepository(new File(directory.getParentFile(),
-            directory.getName() + Constants.DOT_GIT_EXT), FS.DETECTED)) {
+        } else if (FileKey.isGitRepository(
+            new File(directory.getParentFile(), directory.getName() + Constants.DOT_GIT_EXT),
+            FS.DETECTED)) {
           onCreateProject(name);
         } else {
           throw new RepositoryNotFoundException(gitDir);
@@ -207,7 +182,7 @@
 
   @Override
   public Repository createRepository(Project.NameKey name)
-      throws RepositoryNotFoundException, RepositoryCaseMismatchException {
+      throws RepositoryNotFoundException, RepositoryCaseMismatchException, IOException {
     Path path = getBasePath(name);
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
@@ -218,6 +193,9 @@
     if (dir != null) {
       // Already exists on disk, use the repository we found.
       //
+      Project.NameKey onDiskName = getProjectName(path, dir.getCanonicalFile().toPath());
+      onCreateProject(onDiskName);
+
       loc = FileKey.exact(dir, FS.DETECTED);
 
       if (!names.contains(name)) {
@@ -236,20 +214,19 @@
       db.create(true /* bare */);
 
       StoredConfig config = db.getConfig();
-      config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION,
-        null, ConfigConstants.CONFIG_KEY_LOGALLREFUPDATES, true);
+      config.setBoolean(
+          ConfigConstants.CONFIG_CORE_SECTION,
+          null,
+          ConfigConstants.CONFIG_KEY_LOGALLREFUPDATES,
+          true);
       config.save();
 
       // JGit only writes to the reflog for refs/meta/config if the log file
       // already exists.
       //
-      File metaConfigLog =
-          new File(db.getDirectory(), "logs/" + RefNames.REFS_CONFIG);
-      if (!metaConfigLog.getParentFile().mkdirs()
-          || !metaConfigLog.createNewFile()) {
-        log.error(String.format(
-            "Failed to create ref log for %s in repository %s",
-            RefNames.REFS_CONFIG, name));
+      File metaConfigLog = new File(db.getDirectory(), "logs/" + RefNames.REFS_CONFIG);
+      if (!metaConfigLog.getParentFile().mkdirs() || !metaConfigLog.createNewFile()) {
+        log.error("Failed to create ref log for {} in repository {}", RefNames.REFS_CONFIG, name);
       }
 
       onCreateProject(name);
@@ -274,115 +251,73 @@
     }
   }
 
-  @Override
-  public String getProjectDescription(final Project.NameKey name)
-      throws RepositoryNotFoundException, IOException {
-    try (Repository e = openRepository(name)) {
-      return getProjectDescription(e);
-    }
-  }
-
-  private String getProjectDescription(final Repository e) throws IOException {
-    final File d = new File(e.getDirectory(), "description");
-
-    String description;
-    try {
-      description = RawParseUtils.decode(IO.readFully(d));
-    } catch (FileNotFoundException err) {
-      return null;
-    }
-
-    if (description != null) {
-      description = description.trim();
-      if (description.isEmpty()) {
-        description = null;
-      }
-      if (UNNAMED.equals(description)) {
-        description = null;
-      }
-    }
-    return description;
-  }
-
-  @Override
-  public void setProjectDescription(Project.NameKey name, String description) {
-    // Update git's description file, in case gitweb is being used
-    //
-    try (Repository e = openRepository(name)) {
-      String old = getProjectDescription(e);
-      if ((old == null && description == null)
-          || (old != null && old.equals(description))) {
-        return;
-      }
-
-      LockFile f = new LockFile(new File(e.getDirectory(), "description"));
-      if (f.lock()) {
-        String d = description;
-        if (d != null) {
-          d = d.trim();
-          if (d.length() > 0) {
-            d += "\n";
-          }
-        } else {
-          d = "";
-        }
-        f.write(Constants.encode(d));
-        f.commit();
-      }
-    } catch (IOException e) {
-      log.error("Cannot update description for " + name, e);
-    }
-  }
-
   private boolean isUnreasonableName(final Project.NameKey nameKey) {
     final String name = nameKey.get();
 
-    return name.length() == 0  // no empty paths
-      || name.charAt(name.length() - 1) == '/' // no suffix
-      || name.indexOf('\\') >= 0 // no windows/dos style paths
-      || name.charAt(0) == '/' // no absolute paths
-      || new File(name).isAbsolute() // no absolute paths
-      || name.startsWith("../") // no "l../etc/passwd"
-      || name.contains("/../") // no "foo/../etc/passwd"
-      || name.contains("/./") // "foo/./foo" is insane to ask
-      || name.contains("//") // windows UNC path can be "//..."
-      || name.contains(".git/") // no path segments that end with '.git' as "foo.git/bar"
-      || name.contains("?") // common unix wildcard
-      || name.contains("%") // wildcard or string parameter
-      || name.contains("*") // wildcard
-      || name.contains(":") // Could be used for absolute paths in windows?
-      || name.contains("<") // redirect input
-      || name.contains(">") // redirect output
-      || name.contains("|") // pipe
-      || name.contains("$") // dollar sign
-      || name.contains("\r"); // carriage return
+    return name.length() == 0 // no empty paths
+        || name.charAt(name.length() - 1) == '/' // no suffix
+        || name.indexOf('\\') >= 0 // no windows/dos style paths
+        || name.charAt(0) == '/' // no absolute paths
+        || new File(name).isAbsolute() // no absolute paths
+        || name.startsWith("../") // no "l../etc/passwd"
+        || name.contains("/../") // no "foo/../etc/passwd"
+        || name.contains("/./") // "foo/./foo" is insane to ask
+        || name.contains("//") // windows UNC path can be "//..."
+        || name.contains(".git/") // no path segments that end with '.git' as "foo.git/bar"
+        || name.contains("?") // common unix wildcard
+        || name.contains("%") // wildcard or string parameter
+        || name.contains("*") // wildcard
+        || name.contains(":") // Could be used for absolute paths in windows?
+        || name.contains("<") // redirect input
+        || name.contains(">") // redirect output
+        || name.contains("|") // pipe
+        || name.contains("$") // dollar sign
+        || name.contains("\r"); // carriage return
   }
 
   @Override
   public SortedSet<Project.NameKey> list() {
     // The results of this method are cached by ProjectCacheImpl. Control only
     // enters here if the cache was flushed by the administrator to force
-    // scanning the filesystem. Don't rely on the cached names collection.
+    // scanning the filesystem.
+    // Don't rely on the cached names collection but update it to contain
+    // the set of found project names
+    ProjectVisitor visitor = new ProjectVisitor(basePath);
+    scanProjects(visitor);
+
     namesUpdateLock.lock();
     try {
-      ProjectVisitor visitor = new ProjectVisitor(basePath);
-      scanProjects(visitor);
-      return Collections.unmodifiableSortedSet(visitor.found);
+      names = Collections.unmodifiableSortedSet(visitor.found);
     } finally {
       namesUpdateLock.unlock();
     }
+    return names;
   }
 
   protected void scanProjects(ProjectVisitor visitor) {
     try {
-      Files.walkFileTree(visitor.startFolder,
-          EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, visitor);
+      Files.walkFileTree(
+          visitor.startFolder,
+          EnumSet.of(FileVisitOption.FOLLOW_LINKS),
+          Integer.MAX_VALUE,
+          visitor);
     } catch (IOException e) {
-      log.error("Error walking repository tree "
-          + visitor.startFolder.toAbsolutePath(), e);
+      log.error("Error walking repository tree {}", visitor.startFolder.toAbsolutePath(), e);
     }
   }
 
+  private static Project.NameKey getProjectName(Path startFolder, Path p) {
+    String projectName = startFolder.relativize(p).toString();
+    if (File.separatorChar != '/') {
+      projectName = projectName.replace(File.separatorChar, '/');
+    }
+    if (projectName.endsWith(Constants.DOT_GIT_EXT)) {
+      int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
+      projectName = projectName.substring(0, newLen);
+    }
+    return new Project.NameKey(projectName);
+  }
+
   protected class ProjectVisitor extends SimpleFileVisitor<Path> {
     private final SortedSet<Project.NameKey> found = new TreeSet<>();
     private Path startFolder;
@@ -396,8 +331,8 @@
     }
 
     @Override
-    public FileVisitResult preVisitDirectory(Path dir,
-        BasicFileAttributes attrs) throws IOException {
+    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
+        throws IOException {
       if (!dir.equals(startFolder) && isRepo(dir)) {
         addProject(dir);
         return FileVisitResult.SKIP_SUBTREE;
@@ -419,27 +354,14 @@
     }
 
     private void addProject(Path p) {
-      Project.NameKey nameKey = getProjectName(p);
+      Project.NameKey nameKey = getProjectName(startFolder, p);
       if (getBasePath(nameKey).equals(startFolder)) {
         if (isUnreasonableName(nameKey)) {
-          log.warn(
-              "Ignoring unreasonably named repository " + p.toAbsolutePath());
+          log.warn("Ignoring unreasonably named repository {}", p.toAbsolutePath());
         } else {
           found.add(nameKey);
         }
       }
     }
-
-    private Project.NameKey getProjectName(Path p) {
-      String projectName = startFolder.relativize(p).toString();
-      if (File.separatorChar != '/') {
-        projectName = projectName.replace(File.separatorChar, '/');
-      }
-      if (projectName.endsWith(Constants.DOT_GIT_EXT)) {
-        int newLen = projectName.length() - Constants.DOT_GIT_EXT.length();
-        projectName = projectName.substring(0, newLen);
-      }
-      return new Project.NameKey(projectName);
-    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java
new file mode 100644
index 0000000..7380b0a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import java.io.IOException;
+
+/** Thrown when updating a ref in Git fails with LOCK_FAILURE. */
+public class LockFailureException extends IOException {
+  private static final long serialVersionUID = 1L;
+
+  public LockFailureException(String message) {
+    super(message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
index dd6b717..938f6cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 
 /**
- * Indicates that the commit is already contained in destination branch.
- * Either the commit itself is in the source tree, or the content is merged
+ * Indicates that the commit is already contained in destination branch. Either the commit itself is
+ * in the source tree, or the content is merged
  */
-public class MergeIdenticalTreeException extends RestApiException {
+public class MergeIdenticalTreeException extends ResourceConflictException {
   private static final long serialVersionUID = 1L;
 
   /** @param msg message to return to the client describing the error. */
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 f16c997..1511da0 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
@@ -17,31 +17,30 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Ordering;
-import com.google.common.collect.Sets;
+import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -49,10 +48,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.change.NotifyUtil;
 import com.google.gerrit.server.git.MergeOpRepoManager.OpenBranch;
 import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.git.strategy.SubmitStrategy;
@@ -62,21 +60,16 @@
 import com.google.gerrit.server.git.validators.MergeValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -87,48 +80,47 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Merges changes in submission order into a single branch.
- * <p>
- * Branches are reduced to the minimum number of heads needed to merge
- * everything. This allows commits to be entered into the queue in any order
- * (such as ancestors before descendants) and only the most recent commit on any
- * line of development will be merged. All unmerged commits along a line of
- * development must be in the submission queue in order to merge the tip of that
- * line.
- * <p>
- * Conflicts are handled by discarding the entire line of development and
- * marking it as conflicting, even if an earlier commit along that same line can
- * be merged cleanly.
+ *
+ * <p>Branches are reduced to the minimum number of heads needed to merge everything. This allows
+ * commits to be entered into the queue in any order (such as ancestors before descendants) and only
+ * the most recent commit on any line of development will be merged. All unmerged commits along a
+ * line of development must be in the submission queue in order to merge the tip of that line.
+ *
+ * <p>Conflicts are handled by discarding the entire line of development and marking it as
+ * conflicting, even if an earlier commit along that same line can be merged cleanly.
  */
 public class MergeOp implements AutoCloseable {
   private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
 
+  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.defaults().build();
+
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
     private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
     private final Map<Change.Id, CodeReviewCommit> commits;
-    private final Multimap<Change.Id, String> problems;
+    private final ListMultimap<Change.Id, String> problems;
 
     private CommitStatus(ChangeSet cs) throws OrmException {
-      checkArgument(!cs.furtherHiddenChanges(),
-          "CommitStatus must not be called with hidden changes");
+      checkArgument(
+          !cs.furtherHiddenChanges(), "CommitStatus must not be called with hidden changes");
       changes = cs.changesById();
-      ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> bb =
-          ImmutableSetMultimap.builder();
+      ImmutableSetMultimap.Builder<Branch.NameKey, Change.Id> bb = ImmutableSetMultimap.builder();
       for (ChangeData cd : cs.changes()) {
         bb.put(cd.change().getDest(), cd.getId());
       }
       byBranch = bb.build();
       commits = new HashMap<>();
-      problems = MultimapBuilder.treeKeys(
-          Ordering.natural().onResultOf(new Function<Change.Id, Integer>() {
-            @Override
-            public Integer apply(Change.Id in) {
-              return in.get();
-            }
-          })).arrayListValues(1).build();
+      problems = MultimapBuilder.treeKeys(comparing(Change.Id::get)).arrayListValues(1).build();
     }
 
     public ImmutableSet<Change.Id> getChangeIds() {
@@ -166,8 +158,8 @@
       return problems.isEmpty();
     }
 
-    public ImmutableMultimap<Change.Id, String> getProblems() {
-      return ImmutableMultimap.copyOf(problems);
+    public ImmutableListMultimap<Change.Id, String> getProblems() {
+      return ImmutableListMultimap.copyOf(problems);
     }
 
     public List<SubmitRecord> getSubmitRecords(Change.Id id) {
@@ -180,7 +172,8 @@
       // However, do NOT expose that ChangeData directly, as it is way out of
       // date by this point.
       ChangeData cd = checkNotNull(changes.get(id), "ChangeData for %s", id);
-      return checkNotNull(cd.getSubmitRecords(),
+      return checkNotNull(
+          cd.getSubmitRecords(SUBMIT_RULE_OPTIONS),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
 
@@ -188,9 +181,12 @@
       if (isOk()) {
         return;
       }
-      String msg = "Failed to submit " + changes.size() + " change"
-          + (changes.size() > 1 ? "s" : "")
-          + " due to the following problems:\n";
+      String msg =
+          "Failed to submit "
+              + changes.size()
+              + " change"
+              + (changes.size() > 1 ? "s" : "")
+              + " due to the following problems:\n";
       List<String> ps = new ArrayList<>(problems.keySet().size());
       for (Change.Id id : problems.keySet()) {
         ps.add("Change " + id + ": " + Joiner.on("; ").join(problems.get(id)));
@@ -222,17 +218,22 @@
   private final SubmitStrategyFactory submitStrategyFactory;
   private final SubmoduleOp.Factory subOpFactory;
   private final MergeOpRepoManager orm;
+  private final NotifyUtil notifyUtil;
 
   private Timestamp ts;
   private RequestId submissionId;
   private IdentifiedUser caller;
 
-  private CommitStatus commits;
+  private CommitStatus commitStatus;
   private ReviewDb db;
   private SubmitInput submitInput;
+  private ListMultimap<RecipientType, Account.Id> accountsToNotify;
+  private Set<Project.NameKey> allProjects;
+  private boolean dryrun;
 
   @Inject
-  MergeOp(ChangeMessagesUtil cmUtil,
+  MergeOp(
+      ChangeMessagesUtil cmUtil,
       BatchUpdate.Factory batchUpdateFactory,
       InternalUser.Factory internalUserFactory,
       MergeSuperSet mergeSuperSet,
@@ -240,7 +241,8 @@
       InternalChangeQuery internalChangeQuery,
       SubmitStrategyFactory submitStrategyFactory,
       SubmoduleOp.Factory subOpFactory,
-      MergeOpRepoManager orm) {
+      MergeOpRepoManager orm,
+      NotifyUtil notifyUtil) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -250,6 +252,7 @@
     this.submitStrategyFactory = submitStrategyFactory;
     this.subOpFactory = subOpFactory;
     this.orm = orm;
+    this.notifyUtil = notifyUtil;
   }
 
   @Override
@@ -257,37 +260,20 @@
     orm.close();
   }
 
-  private static Optional<SubmitRecord> findOkRecord(
-      Collection<SubmitRecord> in) {
-    if (in == null) {
-      return Optional.absent();
-    }
-    return Iterables.tryFind(in, new Predicate<SubmitRecord>() {
-      @Override
-      public boolean apply(SubmitRecord input) {
-        return input.status == SubmitRecord.Status.OK;
-      }
-    });
-  }
-
-  public static void checkSubmitRule(ChangeData cd)
-      throws ResourceConflictException, OrmException {
+  public static void checkSubmitRule(ChangeData cd) throws ResourceConflictException, OrmException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
-      throw new ResourceConflictException(
-          "missing current patch set for change " + cd.getId());
+      throw new ResourceConflictException("missing current patch set for change " + cd.getId());
     }
     List<SubmitRecord> results = getSubmitRecords(cd);
-    if (findOkRecord(results).isPresent()) {
+    if (SubmitRecord.findOkRecord(results).isPresent()) {
       // Rules supplied a valid solution.
       return;
     } else if (results.isEmpty()) {
-      throw new IllegalStateException(String.format(
-          "SubmitRuleEvaluator.evaluate for change %s " +
-          "returned empty list for %s in %s",
-          cd.getId(),
-          patchSet.getId(),
-          cd.change().getProject().get()));
+      throw new IllegalStateException(
+          String.format(
+              "SubmitRuleEvaluator.evaluate for change %s returned empty list for %s in %s",
+              cd.getId(), patchSet.getId(), cd.change().getProject().get()));
     }
 
     for (SubmitRecord record : results) {
@@ -296,38 +282,29 @@
           throw new ResourceConflictException("change is closed");
 
         case RULE_ERROR:
-          throw new ResourceConflictException(
-              "submit rule error: " + record.errorMessage);
+          throw new ResourceConflictException("submit rule error: " + record.errorMessage);
 
         case NOT_READY:
-          throw new ResourceConflictException(
-              describeLabels(cd, record.labels));
+          throw new ResourceConflictException(describeLabels(cd, record.labels));
 
         case FORCED:
         case OK:
         default:
-          throw new IllegalStateException(String.format(
-              "Unexpected SubmitRecord status %s for %s in %s",
-              record.status,
-              patchSet.getId().getId(),
-              cd.change().getProject().get()));
+          throw new IllegalStateException(
+              String.format(
+                  "Unexpected SubmitRecord status %s for %s in %s",
+                  record.status, patchSet.getId().getId(), cd.change().getProject().get()));
       }
     }
     throw new IllegalStateException();
   }
 
-  private static List<SubmitRecord> getSubmitRecords(ChangeData cd)
-      throws OrmException {
-    List<SubmitRecord> results = cd.getSubmitRecords();
-    if (results == null) {
-      results = new SubmitRuleEvaluator(cd).evaluate();
-      cd.setSubmitRecords(results);
-    }
-    return results;
+  private static List<SubmitRecord> getSubmitRecords(ChangeData cd) throws OrmException {
+    return cd.submitRecords(SUBMIT_RULE_OPTIONS);
   }
 
-  private static String describeLabels(ChangeData cd,
-      List<SubmitRecord.Label> labels) throws OrmException {
+  private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels)
+      throws OrmException {
     List<String> labelResults = new ArrayList<>();
     for (SubmitRecord.Label lbl : labels) {
       switch (lbl.status) {
@@ -344,47 +321,45 @@
           break;
 
         case IMPOSSIBLE:
-          labelResults.add(
-              "needs " + lbl.label + " (check project access)");
+          labelResults.add("needs " + lbl.label + " (check project access)");
           break;
 
         default:
-          throw new IllegalStateException(String.format(
-              "Unsupported SubmitRecord.Label %s for %s in %s",
-              lbl,
-              cd.change().currentPatchSetId(),
-              cd.change().getProject()));
+          throw new IllegalStateException(
+              String.format(
+                  "Unsupported SubmitRecord.Label %s for %s in %s",
+                  lbl, cd.change().currentPatchSetId(), cd.change().getProject()));
       }
     }
     return Joiner.on("; ").join(labelResults);
   }
 
-  private void checkSubmitRulesAndState(ChangeSet cs)
-      throws ResourceConflictException {
-    checkArgument(!cs.furtherHiddenChanges(),
-        "checkSubmitRulesAndState called for topic with hidden change");
+  private void checkSubmitRulesAndState(ChangeSet cs) throws ResourceConflictException {
+    checkArgument(
+        !cs.furtherHiddenChanges(), "checkSubmitRulesAndState called for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
       try {
         if (cd.change().getStatus() != Change.Status.NEW) {
-          commits.problem(cd.getId(), "Change " + cd.getId() + " is "
-              + cd.change().getStatus().toString().toLowerCase());
+          commitStatus.problem(
+              cd.getId(),
+              "Change " + cd.getId() + " is " + cd.change().getStatus().toString().toLowerCase());
         } else {
           checkSubmitRule(cd);
         }
       } catch (ResourceConflictException e) {
-        commits.problem(cd.getId(), e.getMessage());
+        commitStatus.problem(cd.getId(), e.getMessage());
       } catch (OrmException e) {
         String msg = "Error checking submit rules for change";
         log.warn(msg + " " + cd.getId(), e);
-        commits.problem(cd.getId(), msg);
+        commitStatus.problem(cd.getId(), msg);
       }
     }
-    commits.maybeFailVerbose();
+    commitStatus.maybeFailVerbose();
   }
 
   private void bypassSubmitRules(ChangeSet cs) {
-    checkArgument(!cs.furtherHiddenChanges(),
-        "cannot bypass submit rules for topic with hidden change");
+    checkArgument(
+        !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
       List<SubmitRecord> records;
       try {
@@ -396,14 +371,36 @@
       SubmitRecord forced = new SubmitRecord();
       forced.status = SubmitRecord.Status.FORCED;
       records.add(forced);
-      cd.setSubmitRecords(records);
+      cd.setSubmitRecords(SUBMIT_RULE_OPTIONS, records);
     }
   }
 
-  public void merge(ReviewDb db, Change change, IdentifiedUser caller,
-      boolean checkSubmitRules, SubmitInput submitInput)
+  /**
+   * Merges the given change.
+   *
+   * <p>Depending on the server configuration, more changes may be affected, e.g. by submission of a
+   * topic or via superproject subscriptions. All affected changes are integrated using the projects
+   * integration strategy.
+   *
+   * @param db the review database.
+   * @param change the change to be merged.
+   * @param caller the identity of the caller
+   * @param checkSubmitRules whether the prolog submit rules should be evaluated
+   * @param submitInput parameters regarding the merge
+   * @throws OrmException an error occurred reading or writing the database.
+   * @throws RestApiException if an error occurred.
+   */
+  public void merge(
+      ReviewDb db,
+      Change change,
+      IdentifiedUser caller,
+      boolean checkSubmitRules,
+      SubmitInput submitInput,
+      boolean dryrun)
       throws OrmException, RestApiException {
     this.submitInput = submitInput;
+    this.accountsToNotify = notifyUtil.resolveAccounts(submitInput.notifyDetails);
+    this.dryrun = dryrun;
     this.caller = caller;
     this.ts = TimeUtil.nowTs();
     submissionId = RequestId.forChange(change);
@@ -412,14 +409,14 @@
 
     logDebug("Beginning integration of {}", change);
     try {
-      ChangeSet cs = mergeSuperSet.completeChangeSet(db, change, caller);
-      checkState(cs.ids().contains(change.getId()),
-          "change %s missing from %s", change.getId(), cs);
+      ChangeSet cs = mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(db, change, caller);
+      checkState(
+          cs.ids().contains(change.getId()), "change %s missing from %s", change.getId(), cs);
       if (cs.furtherHiddenChanges()) {
-        throw new AuthException("A change to be submitted with "
-            + change.getId() + " is not visible");
+        throw new AuthException(
+            "A change to be submitted with " + change.getId() + " is not visible");
       }
-      this.commits = new CommitStatus(cs);
+      this.commitStatus = new CommitStatus(cs);
       MergeSuperSet.reloadChanges(cs);
       logDebug("Calculated to merge {}", cs);
       if (checkSubmitRules) {
@@ -441,14 +438,12 @@
     }
   }
 
-  private void integrateIntoHistory(ChangeSet cs)
-      throws IntegrationException, RestApiException {
-    checkArgument(!cs.furtherHiddenChanges(),
-        "cannot integrate hidden changes into history");
+  private void integrateIntoHistory(ChangeSet cs) throws IntegrationException, RestApiException {
+    checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
     logDebug("Beginning merge attempt on {}", cs);
     Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
 
-    Multimap<Branch.NameKey, ChangeData> cbb;
+    ListMultimap<Branch.NameKey, ChangeData> cbb;
     try {
       cbb = cs.changesByBranch();
     } catch (OrmException e) {
@@ -462,15 +457,19 @@
       }
     }
     // Done checks that don't involve running submit strategies.
-    commits.maybeFailVerbose();
+    commitStatus.maybeFailVerbose();
     SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
     try {
-      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp);
-      Set<Project.NameKey> allProjects = submoduleOp.getProjectsInOrder();
-      BatchUpdate.execute(orm.batchUpdates(allProjects),
-          new SubmitStrategyListener(submitInput, strategies, commits),
-          submissionId);
-    } catch (SubmoduleException e) {
+      List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
+      this.allProjects = submoduleOp.getProjectsInOrder();
+      batchUpdateFactory.execute(
+          orm.batchUpdates(allProjects),
+          new SubmitStrategyListener(submitInput, strategies, commitStatus),
+          submissionId,
+          dryrun);
+    } catch (NoSuchProjectException e) {
+      throw new ResourceNotFoundException(e.getMessage());
+    } catch (IOException | SubmoduleException e) {
       throw new IntegrationException(e);
     } catch (UpdateException e) {
       // BatchUpdate may have inadvertently wrapped an IntegrationException
@@ -490,27 +489,55 @@
     }
   }
 
+  public Set<Project.NameKey> getAllProjects() {
+    return allProjects;
+  }
+
+  public MergeOpRepoManager getMergeOpRepoManager() {
+    return orm;
+  }
+
   private List<SubmitStrategy> getSubmitStrategies(
-      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp)
-      throws IntegrationException {
+      Map<Branch.NameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
+      throws IntegrationException, NoSuchProjectException, IOException {
     List<SubmitStrategy> strategies = new ArrayList<>();
     Set<Branch.NameKey> allBranches = submoduleOp.getBranchesInOrder();
+    Set<CodeReviewCommit> allCommits =
+        toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
     for (Branch.NameKey branch : allBranches) {
       OpenRepo or = orm.getRepo(branch.getParentKey());
       if (toSubmit.containsKey(branch)) {
         BranchBatch submitting = toSubmit.get(branch);
         OpenBranch ob = or.getBranch(branch);
-        checkNotNull(submitting.submitType(),
+        checkNotNull(
+            submitting.submitType(),
             "null submit type for %s; expected to previously fail fast",
             submitting);
-        Set<CodeReviewCommit> commitsToSubmit = commits(submitting.changes());
+        Set<CodeReviewCommit> commitsToSubmit = submitting.commits();
         ob.mergeTip = new MergeTip(ob.oldTip, commitsToSubmit);
-        SubmitStrategy strategy = createStrategy(or, ob.mergeTip, branch,
-            submitting.submitType(), ob.oldTip, submoduleOp);
+        SubmitStrategy strategy =
+            submitStrategyFactory.create(
+                submitting.submitType(),
+                db,
+                or.repo,
+                or.rw,
+                or.ins,
+                or.canMergeFlag,
+                getAlreadyAccepted(or, ob.oldTip),
+                allCommits,
+                branch,
+                caller,
+                ob.mergeTip,
+                commitStatus,
+                submissionId,
+                submitInput.notify,
+                accountsToNotify,
+                submoduleOp,
+                dryrun);
         strategies.add(strategy);
         strategy.addOps(or.getUpdate(), commitsToSubmit);
-        if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY) &&
-            submoduleOp.hasSubscription(branch)) {
+        if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY)
+            && submoduleOp.hasSubscription(branch)) {
           submoduleOp.addOp(or.getUpdate(), branch);
         }
       } else {
@@ -522,28 +549,8 @@
     return strategies;
   }
 
-  private Set<CodeReviewCommit> commits(List<ChangeData> cds) {
-    LinkedHashSet<CodeReviewCommit> result =
-        Sets.newLinkedHashSetWithExpectedSize(cds.size());
-    for (ChangeData cd : cds) {
-      CodeReviewCommit commit = commits.get(cd.getId());
-      checkState(commit != null,
-          "commit for %s not found by validateChangeList", cd.getId());
-      result.add(commit);
-    }
-    return result;
-  }
-
-  private SubmitStrategy createStrategy(OpenRepo or,
-      MergeTip mergeTip, Branch.NameKey destBranch, SubmitType submitType,
-      CodeReviewCommit branchTip, SubmoduleOp submoduleOp) throws IntegrationException {
-    return submitStrategyFactory.create(submitType, db, or.repo, or.rw, or.ins,
-        or.canMergeFlag, getAlreadyAccepted(or, branchTip), destBranch, caller,
-        mergeTip, commits, submissionId, submitInput.notify, submoduleOp);
-  }
-
-  private Set<RevCommit> getAlreadyAccepted(OpenRepo or,
-      CodeReviewCommit branchTip) throws IntegrationException {
+  private Set<RevCommit> getAlreadyAccepted(OpenRepo or, CodeReviewCommit branchTip)
+      throws IntegrationException {
     Set<RevCommit> alreadyAccepted = new HashSet<>();
 
     if (branchTip != null) {
@@ -551,11 +558,10 @@
     }
 
     try {
-      for (Ref r : or.repo.getRefDatabase().getRefs(Constants.R_HEADS)
-          .values()) {
+      for (Ref r : or.repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
         try {
           CodeReviewCommit aac = or.rw.parseCommit(r.getObjectId());
-          if (!commits.commits.values().contains(aac)) {
+          if (!commitStatus.commits.values().contains(aac)) {
             alreadyAccepted.add(aac);
           }
         } catch (IncorrectObjectTypeException iote) {
@@ -563,8 +569,7 @@
         }
       }
     } catch (IOException e) {
-      throw new IntegrationException(
-          "Failed to determine already accepted commits.", e);
+      throw new IntegrationException("Failed to determine already accepted commits.", e);
     }
 
     logDebug("Found {} existing heads", alreadyAccepted.size());
@@ -573,15 +578,17 @@
 
   @AutoValue
   abstract static class BranchBatch {
-    @Nullable abstract SubmitType submitType();
-    abstract List<ChangeData> changes();
+    @Nullable
+    abstract SubmitType submitType();
+
+    abstract Set<CodeReviewCommit> commits();
   }
 
-  private BranchBatch validateChangeList(OpenRepo or,
-      Collection<ChangeData> submitted) throws IntegrationException {
+  private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted)
+      throws IntegrationException {
     logDebug("Validating {} changes", submitted.size());
-    List<ChangeData> toSubmit = new ArrayList<>(submitted.size());
-    Multimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
+    Set<CodeReviewCommit> toSubmit = new LinkedHashSet<>(submitted.size());
+    SetMultimap<ObjectId, PatchSet.Id> revisions = getRevisions(or, submitted);
 
     SubmitType submitType = null;
     ChangeData choseSubmitTypeFrom = null;
@@ -593,29 +600,31 @@
         ctl = cd.changeControl();
         chg = cd.change();
       } catch (OrmException e) {
-        commits.logProblem(changeId, e);
+        commitStatus.logProblem(changeId, e);
         continue;
       }
 
       SubmitType st = getSubmitType(cd);
       if (st == null) {
-        commits.logProblem(changeId, "No submit type for change");
+        commitStatus.logProblem(changeId, "No submit type for change");
         continue;
       }
       if (submitType == null) {
         submitType = st;
         choseSubmitTypeFrom = cd;
       } else if (st != submitType) {
-        commits.problem(changeId, String.format(
-            "Change has submit type %s, but previously chose submit type %s "
-            + "from change %s in the same batch",
-            st, submitType, choseSubmitTypeFrom.getId()));
+        commitStatus.problem(
+            changeId,
+            String.format(
+                "Change has submit type %s, but previously chose submit type %s "
+                    + "from change %s in the same batch",
+                st, submitType, choseSubmitTypeFrom.getId()));
         continue;
       }
       if (chg.currentPatchSetId() == null) {
         String msg = "Missing current patch set on change";
         logError(msg + " " + changeId);
-        commits.problem(changeId, msg);
+        commitStatus.problem(changeId, msg);
         continue;
       }
 
@@ -624,12 +633,11 @@
       try {
         ps = cd.currentPatchSet();
       } catch (OrmException e) {
-        commits.logProblem(changeId, e);
+        commitStatus.logProblem(changeId, e);
         continue;
       }
-      if (ps == null || ps.getRevision() == null
-          || ps.getRevision().get() == null) {
-        commits.logProblem(changeId, "Missing patch set or revision on change");
+      if (ps == null || ps.getRevision() == null || ps.getRevision().get() == null) {
+        commitStatus.logProblem(changeId, "Missing patch set or revision on change");
         continue;
       }
 
@@ -638,7 +646,7 @@
       try {
         id = ObjectId.fromString(idstr);
       } catch (IllegalArgumentException e) {
-        commits.logProblem(changeId, e);
+        commitStatus.logProblem(changeId, e);
         continue;
       }
 
@@ -647,9 +655,15 @@
         // want to merge the issue. We can't safely do that if the
         // tip is not reachable.
         //
-        commits.logProblem(changeId, "Revision " + idstr + " of patch set "
-            + ps.getPatchSetId() + " does not match " + ps.getId().toRefName()
-            + " for change");
+        commitStatus.logProblem(
+            changeId,
+            "Revision "
+                + idstr
+                + " of patch set "
+                + ps.getPatchSetId()
+                + " does not match "
+                + ps.getId().toRefName()
+                + " for change");
         continue;
       }
 
@@ -657,32 +671,32 @@
       try {
         commit = or.rw.parseCommit(id);
       } catch (IOException e) {
-        commits.logProblem(changeId, e);
+        commitStatus.logProblem(changeId, e);
         continue;
       }
 
       // TODO(dborowitz): Consider putting ChangeData in CodeReviewCommit.
       commit.setControl(ctl);
       commit.setPatchsetId(ps.getId());
-      commits.put(commit);
+      commitStatus.put(commit);
 
       MergeValidators mergeValidators = mergeValidatorsFactory.create();
       try {
         mergeValidators.validatePreMerge(
             or.repo, commit, or.project, destBranch, ps.getId(), caller);
       } catch (MergeValidationException mve) {
-        commits.problem(changeId, mve.getMessage());
+        commitStatus.problem(changeId, mve.getMessage());
         continue;
       }
       commit.add(or.canMergeFlag);
-      toSubmit.add(cd);
+      toSubmit.add(commit);
     }
     logDebug("Submitting on this run: {}", toSubmit);
     return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
   }
 
-  private Multimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or,
-      Collection<ChangeData> cds) throws IntegrationException {
+  private SetMultimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or, Collection<ChangeData> cds)
+      throws IntegrationException {
     try {
       List<String> refNames = new ArrayList<>(cds.size());
       for (ChangeData cd : cds) {
@@ -691,12 +705,14 @@
           refNames.add(c.currentPatchSetId().toRefName());
         }
       }
-      Multimap<ObjectId, PatchSet.Id> revisions =
-          HashMultimap.create(cds.size(), 1);
-      for (Map.Entry<String, Ref> e : or.repo.getRefDatabase().exactRef(
-          refNames.toArray(new String[refNames.size()])).entrySet()) {
-        revisions.put(
-            e.getValue().getObjectId(), PatchSet.Id.fromRef(e.getKey()));
+      SetMultimap<ObjectId, PatchSet.Id> revisions =
+          MultimapBuilder.hashKeys(cds.size()).hashSetValues(1).build();
+      for (Map.Entry<String, Ref> e :
+          or.repo
+              .getRefDatabase()
+              .exactRef(refNames.toArray(new String[refNames.size()]))
+              .entrySet()) {
+        revisions.put(e.getValue().getObjectId(), PatchSet.Id.fromRef(e.getKey()));
       }
       return revisions;
     } catch (IOException | OrmException e) {
@@ -714,53 +730,53 @@
     }
   }
 
-  private OpenRepo openRepo(Project.NameKey project)
-      throws IntegrationException {
+  private OpenRepo openRepo(Project.NameKey project) throws IntegrationException {
     try {
-      return orm.openRepo(project);
-    } catch (NoSuchProjectException noProject) {
-      logWarn("Project " + noProject.project() + " no longer exists, "
-          + "abandoning open changes");
-      abandonAllOpenChangeForDeletedProject(noProject.project());
+      return orm.getRepo(project);
+    } catch (NoSuchProjectException e) {
+      logWarn("Project " + project + " no longer exists, abandoning open changes.");
+      abandonAllOpenChangeForDeletedProject(project);
     } catch (IOException e) {
       throw new IntegrationException("Error opening project " + project, e);
     }
     return null;
   }
 
-  private void abandonAllOpenChangeForDeletedProject(
-      Project.NameKey destProject) {
+  private void abandonAllOpenChangeForDeletedProject(Project.NameKey destProject) {
     try {
       for (ChangeData cd : internalChangeQuery.byProjectOpen(destProject)) {
-        try (BatchUpdate bu = batchUpdateFactory.create(db, destProject,
-            internalUserFactory.create(), ts)) {
+        try (BatchUpdate bu =
+            batchUpdateFactory.create(db, destProject, internalUserFactory.create(), ts)) {
           bu.setRequestId(submissionId);
-          bu.addOp(cd.getId(), new BatchUpdate.Op() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              Change change = ctx.getChange();
-              if (!change.getStatus().isOpen()) {
-                return false;
-              }
+          bu.addOp(
+              cd.getId(),
+              new BatchUpdateOp() {
+                @Override
+                public boolean updateChange(ChangeContext ctx) throws OrmException {
+                  Change change = ctx.getChange();
+                  if (!change.getStatus().isOpen()) {
+                    return false;
+                  }
 
-              change.setStatus(Change.Status.ABANDONED);
+                  change.setStatus(Change.Status.ABANDONED);
 
-              ChangeMessage msg = new ChangeMessage(
-                  new ChangeMessage.Key(change.getId(),
-                      ChangeUtil.messageUUID(ctx.getDb())),
-                  null, change.getLastUpdatedOn(), change.currentPatchSetId());
-              msg.setMessage("Project was deleted.");
-              cmUtil.addChangeMessage(ctx.getDb(),
-                  ctx.getUpdate(change.currentPatchSetId()), msg);
+                  ChangeMessage msg =
+                      ChangeMessagesUtil.newMessage(
+                          change.currentPatchSetId(),
+                          internalUserFactory.create(),
+                          change.getLastUpdatedOn(),
+                          ChangeMessagesUtil.TAG_MERGED,
+                          "Project was deleted.");
+                  cmUtil.addChangeMessage(
+                      ctx.getDb(), ctx.getUpdate(change.currentPatchSetId()), msg);
 
-              return true;
-            }
-          });
+                  return true;
+                }
+              });
           try {
             bu.execute();
           } catch (UpdateException | RestApiException e) {
-            logWarn("Cannot abandon changes for deleted project " + destProject,
-                e);
+            logWarn("Cannot abandon changes for deleted project " + destProject, e);
           }
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
index cd76aff..ad205f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
@@ -22,12 +22,21 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -37,21 +46,11 @@
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.eclipse.jgit.revwalk.RevSort;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-
 /**
  * This is a helper class for MergeOp and not intended for general use.
  *
- * Some database backends require to open a repository just once within
- * a transaction of a submission, this caches open repositories to satisfy
- * that requirement.
+ * <p>Some database backends require to open a repository just once within a transaction of a
+ * submission, this caches open repositories to satisfy that requirement.
  */
 public class MergeOpRepoManager implements AutoCloseable {
   public class OpenRepo {
@@ -90,29 +89,32 @@
       return ob;
     }
 
+    public Repository getRepo() {
+      return repo;
+    }
+
     Project.NameKey getProjectName() {
       return project.getProject().getNameKey();
     }
 
-    BatchUpdate getUpdate() {
+    public CodeReviewRevWalk getCodeReviewRevWalk() {
+      return rw;
+    }
+
+    public BatchUpdate getUpdate() {
       checkState(db != null, "call setContext before getUpdate");
       if (update == null) {
-        update = batchUpdateFactory.create(db, getProjectName(), caller, ts)
-            .setRepository(repo, rw, ins)
-            .setRequestId(submissionId);
+        update =
+            batchUpdateFactory
+                .create(db, getProjectName(), caller, ts)
+                .setRepository(repo, rw, ins)
+                .setRequestId(submissionId)
+                .setOnSubmitValidators(onSubmitValidatorsFactory.create());
       }
       return update;
     }
 
-    /**
-     * Make sure the update has already executed before reset it.
-     * TODO:czhen Have a flag in BatchUpdate to mark if it has been executed
-     */
-    void resetUpdate() {
-      update = null;
-    }
-
-    void close() {
+    private void close() {
       if (update != null) {
         update.close();
       }
@@ -137,8 +139,8 @@
           oldTip = null;
           update.setExpectedOldObjectId(ObjectId.zeroId());
         } else {
-          throw new IntegrationException("The destination branch "
-              + name + " does not exist anymore.");
+          throw new IntegrationException(
+              "The destination branch " + name + " does not exist anymore.");
         }
       } catch (IOException e) {
         throw new IntegrationException("Cannot open branch " + name, e);
@@ -146,9 +148,9 @@
     }
   }
 
-
   private final Map<Project.NameKey, OpenRepo> openRepos;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final OnSubmitValidators.Factory onSubmitValidatorsFactory;
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
 
@@ -161,16 +163,17 @@
   MergeOpRepoManager(
       GitRepositoryManager repoManager,
       ProjectCache projectCache,
-      BatchUpdate.Factory batchUpdateFactory) {
+      BatchUpdate.Factory batchUpdateFactory,
+      OnSubmitValidators.Factory onSubmitValidatorsFactory) {
     this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.onSubmitValidatorsFactory = onSubmitValidatorsFactory;
 
     openRepos = new HashMap<>();
   }
 
-  void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller,
-      RequestId submissionId) {
+  void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller, RequestId submissionId) {
     this.db = db;
     this.ts = ts;
     this.caller = caller;
@@ -181,14 +184,7 @@
     return submissionId;
   }
 
-  public OpenRepo getRepo(Project.NameKey project) {
-    OpenRepo or = openRepos.get(project);
-    checkState(or != null, "repo not yet opened: %s", project);
-    return or;
-  }
-
-  public OpenRepo openRepo(Project.NameKey project)
-      throws NoSuchProjectException, IOException {
+  public OpenRepo getRepo(Project.NameKey project) throws NoSuchProjectException, IOException {
     if (openRepos.containsKey(project)) {
       return openRepos.get(project);
     }
@@ -198,19 +194,19 @@
       throw new NoSuchProjectException(project);
     }
     try {
-      OpenRepo or =
-          new OpenRepo(repoManager.openRepository(project), projectState);
+      OpenRepo or = new OpenRepo(repoManager.openRepository(project), projectState);
       openRepos.put(project, or);
       return or;
     } catch (RepositoryNotFoundException e) {
-      throw new NoSuchProjectException(project);
+      throw new NoSuchProjectException(project, e);
     }
   }
 
-  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects) {
+  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects)
+      throws NoSuchProjectException, IOException {
     List<BatchUpdate> updates = new ArrayList<>(projects.size());
     for (Project.NameKey project : projects) {
-      updates.add(getRepo(project).getUpdate());
+      updates.add(getRepo(project).getUpdate().setRefLogMessage("merged"));
     }
     return updates;
   }
@@ -220,5 +216,6 @@
     for (OpenRepo repo : openRepos.values()) {
       repo.close();
     }
+    openRepos.clear();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
index 197b8c5..78fc495 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
@@ -16,34 +16,35 @@
 
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.strategy.CommitMergeStatus;
-
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevCommitList;
-import org.eclipse.jgit.revwalk.RevFlag;
-
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Set;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevCommitList;
+import org.eclipse.jgit.revwalk.RevFlag;
 
 public class MergeSorter {
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
   private final Set<RevCommit> accepted;
 
-  public MergeSorter(CodeReviewRevWalk rw, Set<RevCommit> alreadyAccepted,
-      RevFlag canMergeFlag) {
+  public MergeSorter(CodeReviewRevWalk rw, Set<RevCommit> alreadyAccepted, RevFlag canMergeFlag) {
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
     this.accepted = alreadyAccepted;
   }
 
-  Collection<CodeReviewCommit> sort(final Collection<CodeReviewCommit> incoming)
+  Collection<CodeReviewCommit> sort(final Collection<CodeReviewCommit> toMerge) throws IOException {
+    return sort(toMerge, toMerge);
+  }
+
+  Collection<CodeReviewCommit> sort(
+      final Collection<CodeReviewCommit> toMerge, final Collection<CodeReviewCommit> incoming)
       throws IOException {
     final Set<CodeReviewCommit> heads = new HashSet<>();
-    final Set<CodeReviewCommit> sort = new HashSet<>(incoming);
+    final Set<CodeReviewCommit> sort = new HashSet<>(toMerge);
     while (!sort.isEmpty()) {
       final CodeReviewCommit n = removeOne(sort);
 
@@ -60,14 +61,10 @@
           // We cannot merge n as it would bring something we
           // aren't permitted to merge at this time. Drop n.
           //
-          if (n.missing == null) {
-            n.setStatusCode(CommitMergeStatus.MISSING_DEPENDENCY);
-            n.missing = new ArrayList<>();
-          }
-          n.missing.add(c);
-        } else {
-          contents.add(c);
+          n.setStatusCode(CommitMergeStatus.MISSING_DEPENDENCY);
+          break;
         }
+        contents.add(c);
       }
 
       if (n.getStatusCode() == CommitMergeStatus.MISSING_DEPENDENCY) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
index 284e9ed..9dc13d0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -31,96 +33,117 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
 /**
  * Calculates the minimal superset of changes required to be merged.
- * <p>
- * This includes all parents between a change and the tip of its target
- * branch for the merging/rebasing submit strategies. For the cherry-pick
- * strategy no additional changes are included.
- * <p>
- * If change.submitWholeTopic is enabled, also all changes of the topic
- * and their parents are included.
+ *
+ * <p>This includes all parents between a change and the tip of its target branch for the
+ * merging/rebasing submit strategies. For the cherry-pick strategy no additional changes are
+ * included.
+ *
+ * <p>If change.submitWholeTopic is enabled, also all changes of the topic and their parents are
+ * included.
  */
-@Singleton
 public class MergeSuperSet {
-  private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
+  private static final Logger log = LoggerFactory.getLogger(MergeSuperSet.class);
 
   public static void reloadChanges(ChangeSet cs) throws OrmException {
     // Clear exactly the fields requested by query() below.
     for (ChangeData cd : cs.changes()) {
       cd.reloadChange();
       cd.setPatchSets(null);
+      cd.setMergeable(null);
     }
   }
 
+  @AutoValue
+  abstract static class QueryKey {
+    private static QueryKey create(Branch.NameKey branch, Iterable<String> hashes) {
+      return new AutoValue_MergeSuperSet_QueryKey(branch, ImmutableSet.copyOf(hashes));
+    }
+
+    abstract Branch.NameKey branch();
+
+    abstract ImmutableSet<String> hashes();
+  }
+
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final GitRepositoryManager repoManager;
+  private final Provider<MergeOpRepoManager> repoManagerProvider;
   private final Config cfg;
+  private final Map<QueryKey, List<ChangeData>> queryCache;
+  private final Map<Branch.NameKey, Optional<RevCommit>> heads;
+
+  private MergeOpRepoManager orm;
+  private boolean closeOrm;
 
   @Inject
-  MergeSuperSet(@GerritServerConfig Config cfg,
+  MergeSuperSet(
+      @GerritServerConfig Config cfg,
       ChangeData.Factory changeDataFactory,
       Provider<InternalChangeQuery> queryProvider,
-      GitRepositoryManager repoManager) {
+      Provider<MergeOpRepoManager> repoManagerProvider) {
     this.cfg = cfg;
     this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
-    this.repoManager = repoManager;
+    this.repoManagerProvider = repoManagerProvider;
+    queryCache = new HashMap<>();
+    heads = new HashMap<>();
+  }
+
+  public MergeSuperSet setMergeOpRepoManager(MergeOpRepoManager orm) {
+    checkState(this.orm == null);
+    this.orm = checkNotNull(orm);
+    closeOrm = false;
+    return this;
   }
 
   public ChangeSet completeChangeSet(ReviewDb db, Change change, CurrentUser user)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-      OrmException {
-    ChangeData cd =
-        changeDataFactory.create(db, change.getProject(), change.getId());
-    cd.changeControl(user);
-    ChangeSet cs = new ChangeSet(cd, cd.changeControl().isVisible(db, cd));
-    if (Submit.wholeTopicEnabled(cfg)) {
-      return completeChangeSetIncludingTopics(db, cs, user);
+      throws IOException, OrmException {
+    try {
+      ChangeData cd = changeDataFactory.create(db, change.getProject(), change.getId());
+      cd.changeControl(user);
+      ChangeSet cs = new ChangeSet(cd, cd.changeControl().isVisible(db, cd));
+      if (Submit.wholeTopicEnabled(cfg)) {
+        return completeChangeSetIncludingTopics(db, cs, user);
+      }
+      return completeChangeSetWithoutTopic(db, cs, user);
+    } finally {
+      if (closeOrm && orm != null) {
+        orm.close();
+        orm = null;
+      }
     }
-    return completeChangeSetWithoutTopic(db, cs, user);
   }
 
-  private static ImmutableListMultimap<Project.NameKey, ChangeData>
-      byProject(Iterable<ChangeData> changes) throws OrmException {
-    ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
-        new ImmutableListMultimap.Builder<>();
-    for (ChangeData cd : changes) {
-      builder.put(cd.change().getProject(), cd);
-    }
-    return builder.build();
-  }
-
-  private SubmitType submitType(ChangeData cd, PatchSet ps, boolean visible)
-      throws OrmException {
+  private SubmitType submitType(ChangeData cd, PatchSet ps, boolean visible) throws OrmException {
     // Submit type prolog rules mean that the submit type can depend on the
     // submitting user and the content of the change.
     //
@@ -140,116 +163,192 @@
             ? cd.submitTypeRecord()
             : new SubmitRuleEvaluator(cd).setPatchSet(ps).getSubmitType();
     if (!str.isOk()) {
-      logErrorAndThrow("Failed to get submit type for " + cd.getId()
-          + ": " + str.errorMessage);
+      logErrorAndThrow("Failed to get submit type for " + cd.getId() + ": " + str.errorMessage);
     }
     return str.type;
   }
 
-  private ChangeSet completeChangeSetWithoutTopic(ReviewDb db, ChangeSet changes,
-      CurrentUser user) throws MissingObjectException,
-      IncorrectObjectTypeException, IOException, OrmException {
-    List<ChangeData> visibleChanges = new ArrayList<>();
-    List<ChangeData> nonVisibleChanges = new ArrayList<>();
+  private static ImmutableListMultimap<Branch.NameKey, ChangeData> byBranch(
+      Iterable<ChangeData> changes) throws OrmException {
+    ImmutableListMultimap.Builder<Branch.NameKey, ChangeData> builder =
+        ImmutableListMultimap.builder();
+    for (ChangeData cd : changes) {
+      builder.put(cd.change().getDest(), cd);
+    }
+    return builder.build();
+  }
 
-    Multimap<Project.NameKey, ChangeData> pc =
-        byProject(
-            Iterables.concat(changes.changes(), changes.nonVisibleChanges()));
-    for (Project.NameKey project : pc.keySet()) {
-      try (Repository repo = repoManager.openRepository(project);
-           RevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        for (ChangeData cd : pc.get(project)) {
-          checkState(cd.hasChangeControl(),
-              "completeChangeSet forgot to set changeControl for current user"
-              + " at ChangeData creation time");
-          boolean visible = changes.ids().contains(cd.getId());
-          if (visible && !cd.changeControl().isVisible(db, cd)) {
-            // We thought the change was visible, but it isn't.
-            // This can happen if the ACL changes during the
-            // completeChangeSet computation, for example.
-            visible = false;
-          }
-          List<ChangeData> dest = visible ? visibleChanges : nonVisibleChanges;
+  private Set<String> walkChangesByHashes(
+      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, Branch.NameKey b)
+      throws IOException {
+    Set<String> destHashes = new HashSet<>();
+    or.rw.reset();
+    markHeadUninteresting(or, b);
+    for (RevCommit c : sourceCommits) {
+      String name = c.name();
+      if (ignoreHashes.contains(name)) {
+        continue;
+      }
+      destHashes.add(name);
+      or.rw.markStart(c);
+    }
+    for (RevCommit c : or.rw) {
+      String name = c.name();
+      if (ignoreHashes.contains(name)) {
+        continue;
+      }
+      destHashes.add(name);
+    }
 
-          // Pick a revision to use for traversal.  If any of the patch sets
-          // is visible, we use the most recent one.  Otherwise, use the current
-          // patch set.
-          PatchSet ps = cd.currentPatchSet();
-          boolean visiblePatchSet = visible;
-          if (!cd.changeControl().isPatchVisible(ps, cd)) {
-            Iterable<PatchSet> visiblePatchSets = cd.visiblePatchSets();
-            if (Iterables.isEmpty(visiblePatchSets)) {
-              visiblePatchSet = false;
-            } else {
-              ps = Iterables.getLast(visiblePatchSets);
-            }
-          }
+    return destHashes;
+  }
 
-          if (submitType(cd, ps, visiblePatchSet) == SubmitType.CHERRY_PICK) {
-            dest.add(cd);
-            continue;
-          }
+  private ChangeSet completeChangeSetWithoutTopic(ReviewDb db, ChangeSet changes, CurrentUser user)
+      throws IOException, OrmException {
+    Collection<ChangeData> visibleChanges = new ArrayList<>();
+    Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
 
-          // Get the underlying git commit object
-          String objIdStr = ps.getRevision().get();
-          RevCommit commit = rw.parseCommit(ObjectId.fromString(objIdStr));
+    // For each target branch we run a separate rev walk to find open changes
+    // reachable from changes already in the merge super set.
+    ImmutableListMultimap<Branch.NameKey, ChangeData> bc =
+        byBranch(Iterables.concat(changes.changes(), changes.nonVisibleChanges()));
+    for (Branch.NameKey b : bc.keySet()) {
+      OpenRepo or = getRepo(b.getParentKey());
+      List<RevCommit> visibleCommits = new ArrayList<>();
+      List<RevCommit> nonVisibleCommits = new ArrayList<>();
+      for (ChangeData cd : bc.get(b)) {
+        checkState(
+            cd.hasChangeControl(),
+            "completeChangeSet forgot to set changeControl for current user"
+                + " at ChangeData creation time");
 
-          // Collect unmerged ancestors
-          Branch.NameKey destBranch = cd.change().getDest();
-          repo.getRefDatabase().refresh();
-          Ref ref = repo.getRefDatabase().getRef(destBranch.get());
+        boolean visible = changes.ids().contains(cd.getId());
+        if (visible && !cd.changeControl().isVisible(db, cd)) {
+          // We thought the change was visible, but it isn't.
+          // This can happen if the ACL changes during the
+          // completeChangeSet computation, for example.
+          visible = false;
+        }
+        Collection<RevCommit> toWalk = visible ? visibleCommits : nonVisibleCommits;
 
-          rw.reset();
-          rw.sort(RevSort.TOPO);
-          rw.markStart(commit);
-          if (ref != null) {
-            RevCommit head = rw.parseCommit(ref.getObjectId());
-            rw.markUninteresting(head);
-          }
-
-          List<String> hashes = new ArrayList<>();
-          // Always include the input, even if merged. This allows
-          // SubmitStrategyOp to correct the situation later, assuming it gets
-          // returned by byCommitsOnBranchNotMerged below.
-          hashes.add(objIdStr);
-          for (RevCommit c : rw) {
-            if (!c.equals(commit)) {
-              hashes.add(c.name());
-            }
-          }
-
-          if (!hashes.isEmpty()) {
-            Iterable<ChangeData> destChanges = query()
-                .byCommitsOnBranchNotMerged(
-                  repo, db, cd.change().getDest(), hashes);
-            for (ChangeData chd : destChanges) {
-              chd.changeControl(user);
-              dest.add(chd);
-            }
+        // Pick a revision to use for traversal.  If any of the patch sets
+        // is visible, we use the most recent one.  Otherwise, use the current
+        // patch set.
+        PatchSet ps = cd.currentPatchSet();
+        boolean visiblePatchSet = visible;
+        if (!cd.changeControl().isPatchVisible(ps, cd)) {
+          Iterable<PatchSet> visiblePatchSets = cd.visiblePatchSets();
+          if (Iterables.isEmpty(visiblePatchSets)) {
+            visiblePatchSet = false;
+          } else {
+            ps = Iterables.getLast(visiblePatchSets);
           }
         }
+
+        if (submitType(cd, ps, visiblePatchSet) == SubmitType.CHERRY_PICK) {
+          if (visible) {
+            visibleChanges.add(cd);
+          } else {
+            nonVisibleChanges.add(cd);
+          }
+
+          continue;
+        }
+
+        // Get the underlying git commit object
+        String objIdStr = ps.getRevision().get();
+        RevCommit commit = or.rw.parseCommit(ObjectId.fromString(objIdStr));
+
+        // Always include the input, even if merged. This allows
+        // SubmitStrategyOp to correct the situation later, assuming it gets
+        // returned by byCommitsOnBranchNotMerged below.
+        toWalk.add(commit);
       }
+
+      Set<String> emptySet = Collections.emptySet();
+      Set<String> visibleHashes = walkChangesByHashes(visibleCommits, emptySet, or, b);
+
+      List<ChangeData> cds = byCommitsOnBranchNotMerged(or, db, user, b, visibleHashes);
+      for (ChangeData chd : cds) {
+        chd.changeControl(user);
+        visibleChanges.add(chd);
+      }
+
+      Set<String> nonVisibleHashes = walkChangesByHashes(nonVisibleCommits, visibleHashes, or, b);
+      Iterables.addAll(
+          nonVisibleChanges, byCommitsOnBranchNotMerged(or, db, user, b, nonVisibleHashes));
     }
 
     return new ChangeSet(visibleChanges, nonVisibleChanges);
   }
 
+  private OpenRepo getRepo(Project.NameKey project) throws IOException {
+    if (orm == null) {
+      orm = repoManagerProvider.get();
+      closeOrm = true;
+    }
+    try {
+      OpenRepo or = orm.getRepo(project);
+      checkState(or.rw.hasRevSort(RevSort.TOPO));
+      return or;
+    } catch (NoSuchProjectException e) {
+      throw new IOException(e);
+    }
+  }
+
+  private void markHeadUninteresting(OpenRepo or, Branch.NameKey b) throws IOException {
+    Optional<RevCommit> head = heads.get(b);
+    if (head == null) {
+      Ref ref = or.repo.getRefDatabase().exactRef(b.get());
+      head = ref != null ? Optional.of(or.rw.parseCommit(ref.getObjectId())) : Optional.empty();
+      heads.put(b, head);
+    }
+    if (head.isPresent()) {
+      or.rw.markUninteresting(head.get());
+    }
+  }
+
+  private List<ChangeData> byCommitsOnBranchNotMerged(
+      OpenRepo or, ReviewDb db, CurrentUser user, Branch.NameKey branch, Set<String> hashes)
+      throws OrmException, IOException {
+    if (hashes.isEmpty()) {
+      return ImmutableList.of();
+    }
+    QueryKey k = QueryKey.create(branch, hashes);
+    List<ChangeData> cached = queryCache.get(k);
+    if (cached != null) {
+      return cached;
+    }
+
+    List<ChangeData> result = new ArrayList<>();
+    Iterable<ChangeData> destChanges =
+        query().byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
+    for (ChangeData chd : destChanges) {
+      chd.changeControl(user);
+      result.add(chd);
+    }
+    queryCache.put(k, result);
+    return result;
+  }
+
   /**
    * Completes {@code cs} with any additional changes from its topics
-   * <p>
-   * {@link #completeChangeSetIncludingTopics} calls this repeatedly,
-   * alternating with {@link #completeChangeSetWithoutTopic}, to discover
-   * what additional changes should be submitted with a change until the
-   * set stops growing.
-   * <p>
-   * {@code topicsSeen} and {@code visibleTopicsSeen} keep track of topics
-   * already explored to avoid wasted work.
+   *
+   * <p>{@link #completeChangeSetIncludingTopics} calls this repeatedly, alternating with {@link
+   * #completeChangeSetWithoutTopic}, to discover what additional changes should be submitted with a
+   * change until the set stops growing.
+   *
+   * <p>{@code topicsSeen} and {@code visibleTopicsSeen} keep track of topics already explored to
+   * avoid wasted work.
    *
    * @return the resulting larger {@link ChangeSet}
    */
   private ChangeSet topicClosure(
-      ReviewDb db, ChangeSet cs, CurrentUser user,
-      Set<String> topicsSeen, Set<String> visibleTopicsSeen)
+      ReviewDb db,
+      ChangeSet cs,
+      CurrentUser user,
+      Set<String> topicsSeen,
+      Set<String> visibleTopicsSeen)
       throws OrmException {
     List<ChangeData> visibleChanges = new ArrayList<>();
     List<ChangeData> nonVisibleChanges = new ArrayList<>();
@@ -261,11 +360,19 @@
         continue;
       }
       for (ChangeData topicCd : query().byTopicOpen(topic)) {
-        topicCd.changeControl(user);
-        if (topicCd.changeControl().isVisible(db, topicCd)) {
-          visibleChanges.add(topicCd);
-        } else {
-          nonVisibleChanges.add(topicCd);
+        try {
+          topicCd.changeControl(user);
+          if (topicCd.changeControl().isVisible(db, topicCd)) {
+            visibleChanges.add(topicCd);
+          } else {
+            nonVisibleChanges.add(topicCd);
+          }
+        } catch (OrmException e) {
+          if (e.getCause() instanceof NoSuchChangeException) {
+            // Ignore and skip this change
+          } else {
+            throw e;
+          }
         }
       }
       topicsSeen.add(topic);
@@ -287,9 +394,7 @@
   }
 
   private ChangeSet completeChangeSetIncludingTopics(
-      ReviewDb db, ChangeSet changes, CurrentUser user)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-      OrmException {
+      ReviewDb db, ChangeSet changes, CurrentUser user) throws IOException, OrmException {
     Set<String> topicsSeen = new HashSet<>();
     Set<String> visibleTopicsSeen = new HashSet<>();
     int oldSeen;
@@ -307,13 +412,16 @@
   }
 
   private InternalChangeQuery query() {
-    // Request fields required for completing the ChangeSet without having to
-    // touch the database. This provides reasonable performance when loading the
-    // change screen; callers that care about reading the latest value of these
-    // fields should clear them explicitly using reloadChanges().
-    Set<String> fields = ImmutableSet.of(
-        ChangeField.CHANGE.getName(),
-        ChangeField.PATCH_SET.getName());
+    // Request fields required for completing the ChangeSet and converting to
+    // ChangeInfo without having to touch the database or opening the repository
+    // more than necessary. This provides reasonable performance when loading
+    // the change screen; callers that care about reading the latest value of
+    // these fields should clear them explicitly using reloadChanges().
+    Set<String> fields =
+        ImmutableSet.of(
+            ChangeField.CHANGE.getName(),
+            ChangeField.PATCH_SET.getName(),
+            ChangeField.MERGEABLE.getName());
     return queryProvider.get().setRequestedFields(fields);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java
index 5ea0c02..3bd0f38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeTip.java
@@ -18,20 +18,17 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.gerrit.common.Nullable;
-
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Class describing a merge tip during merge operation.
- * <p>
- * The current tip of a {@link MergeTip} may be null if the merge operation is
- * against an unborn branch, and has not yet been attempted. This is distinct
- * from a null {@link MergeTip} instance, which may be used to indicate that a
- * merge failed or another error state.
+ *
+ * <p>The current tip of a {@link MergeTip} may be null if the merge operation is against an unborn
+ * branch, and has not yet been attempted. This is distinct from a null {@link MergeTip} instance,
+ * which may be used to indicate that a merge failed or another error state.
  */
 public class MergeTip {
   private CodeReviewCommit initialTip;
@@ -39,13 +36,10 @@
   private Map<ObjectId, ObjectId> mergeResults;
 
   /**
-   * @param initialTip tip before the merge operation; may be null, indicating
-   *     an unborn branch.
-   * @param toMerge list of commits to be merged in merge operation; may not be
-   *     null or empty.
+   * @param initialTip tip before the merge operation; may be null, indicating an unborn branch.
+   * @param toMerge list of commits to be merged in merge operation; may not be null or empty.
    */
-  public MergeTip(@Nullable CodeReviewCommit initialTip,
-      Collection<CodeReviewCommit> toMerge) {
+  public MergeTip(@Nullable CodeReviewCommit initialTip, Collection<CodeReviewCommit> toMerge) {
     checkNotNull(toMerge, "toMerge may not be null");
     checkArgument(!toMerge.isEmpty(), "toMerge may not be empty");
     this.initialTip = initialTip;
@@ -58,8 +52,8 @@
   }
 
   /**
-   * @return the initial tip of the branch before the merge operation started;
-   *     may be null, indicating a previously unborn branch.
+   * @return the initial tip of the branch before the merge operation started; may be null,
+   *     indicating a previously unborn branch.
    */
   public CodeReviewCommit getInitialTip() {
     return initialTip;
@@ -80,16 +74,16 @@
   /**
    * The merge results of all the merges of this merge operation.
    *
-   * @return The merge results of the merge operation as a map of SHA-1 to be
-   *     merged to SHA-1 of the merge result.
+   * @return The merge results of the merge operation as a map of SHA-1 to be merged to SHA-1 of the
+   *     merge result.
    */
   public Map<ObjectId, ObjectId> getMergeResults() {
     return mergeResults;
   }
 
   /**
-   * @return The current tip of the current merge operation; may be null,
-   *     indicating an unborn branch.
+   * @return The current tip of the current merge operation; may be null, indicating an unborn
+   *     branch.
    */
   @Nullable
   public CodeReviewCommit getCurrentTip() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
index ae11630..2526db194 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
@@ -15,16 +15,18 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
+import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -33,6 +35,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSet.Id;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
@@ -44,10 +47,19 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
 import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -77,42 +89,56 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-
 /**
  * Utility methods used during the merge process.
- * <p>
- * <strong>Note:</strong> Unless otherwise specified, the methods in this class
- * <strong>do not</strong> flush {@link ObjectInserter}s. Callers that want to
- * read back objects before flushing should use {@link
- * ObjectInserter#newReader()}. This is already the default behavior of {@code
- * BatchUpdate}.
+ *
+ * <p><strong>Note:</strong> Unless otherwise specified, the methods in this class <strong>do
+ * not</strong> flush {@link ObjectInserter}s. Callers that want to read back objects before
+ * flushing should use {@link ObjectInserter#newReader()}. This is already the default behavior of
+ * {@code BatchUpdate}.
  */
 public class MergeUtil {
   private static final Logger log = LoggerFactory.getLogger(MergeUtil.class);
-  private static final String R_HEADS_MASTER =
-      Constants.R_HEADS + Constants.MASTER;
+
+  static class PluggableCommitMessageGenerator {
+    private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
+
+    @Inject
+    PluggableCommitMessageGenerator(DynamicSet<ChangeMessageModifier> changeMessageModifiers) {
+      this.changeMessageModifiers = changeMessageModifiers;
+    }
+
+    public String generate(
+        RevCommit original, RevCommit mergeTip, ChangeControl ctl, String current) {
+      checkNotNull(original.getRawBuffer());
+      if (mergeTip != null) {
+        checkNotNull(mergeTip.getRawBuffer());
+      }
+      for (ChangeMessageModifier changeMessageModifier : changeMessageModifiers) {
+        current =
+            changeMessageModifier.onSubmit(current, original, mergeTip, ctl.getChange().getDest());
+        checkNotNull(
+            current,
+            changeMessageModifier.getClass().getName()
+                + ".OnSubmit returned null instead of new commit message");
+      }
+      return current;
+    }
+  }
+
+  private static final String R_HEADS_MASTER = Constants.R_HEADS + Constants.MASTER;
 
   public static boolean useRecursiveMerge(Config cfg) {
     return cfg.getBoolean("core", null, "useRecursiveMerge", true);
   }
 
   public static ThreeWayMergeStrategy getMergeStrategy(Config cfg) {
-    return useRecursiveMerge(cfg)
-        ? MergeStrategy.RECURSIVE
-        : MergeStrategy.RESOLVE;
+    return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE;
   }
 
   public interface Factory {
     MergeUtil create(ProjectState project);
+
     MergeUtil create(ProjectState project, boolean useContentMerge);
   }
 
@@ -123,25 +149,37 @@
   private final ProjectState project;
   private final boolean useContentMerge;
   private final boolean useRecursiveMerge;
+  private final PluggableCommitMessageGenerator commitMessageGenerator;
 
   @AssistedInject
-  MergeUtil(@GerritServerConfig Config serverConfig,
-      final Provider<ReviewDb> db,
-      final IdentifiedUser.GenericFactory identifiedUserFactory,
-      @CanonicalWebUrl @Nullable final Provider<String> urlProvider,
-      final ApprovalsUtil approvalsUtil,
-      @Assisted final ProjectState project) {
-    this(serverConfig, db, identifiedUserFactory, urlProvider, approvalsUtil,
-        project, project.isUseContentMerge());
+  MergeUtil(
+      @GerritServerConfig Config serverConfig,
+      Provider<ReviewDb> db,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      ApprovalsUtil approvalsUtil,
+      PluggableCommitMessageGenerator commitMessageGenerator,
+      @Assisted ProjectState project) {
+    this(
+        serverConfig,
+        db,
+        identifiedUserFactory,
+        urlProvider,
+        approvalsUtil,
+        project,
+        commitMessageGenerator,
+        project.isUseContentMerge());
   }
 
   @AssistedInject
-  MergeUtil(@GerritServerConfig Config serverConfig,
-      final Provider<ReviewDb> db,
-      final IdentifiedUser.GenericFactory identifiedUserFactory,
-      @CanonicalWebUrl @Nullable final Provider<String> urlProvider,
-      final ApprovalsUtil approvalsUtil,
-      @Assisted final ProjectState project,
+  MergeUtil(
+      @GerritServerConfig Config serverConfig,
+      Provider<ReviewDb> db,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      ApprovalsUtil approvalsUtil,
+      @Assisted ProjectState project,
+      PluggableCommitMessageGenerator commitMessageGenerator,
       @Assisted boolean useContentMerge) {
     this.db = db;
     this.identifiedUserFactory = identifiedUserFactory;
@@ -150,12 +188,13 @@
     this.project = project;
     this.useContentMerge = useContentMerge;
     this.useRecursiveMerge = useRecursiveMerge(serverConfig);
+    this.commitMessageGenerator = commitMessageGenerator;
   }
 
   public CodeReviewCommit getFirstFastForward(
-      final CodeReviewCommit mergeTip, final RevWalk rw,
-      final List<CodeReviewCommit> toMerge) throws IntegrationException {
-    for (final Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext();) {
+      final CodeReviewCommit mergeTip, final RevWalk rw, final List<CodeReviewCommit> toMerge)
+      throws IntegrationException {
+    for (final Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext(); ) {
       try {
         final CodeReviewCommit n = i.next();
         if (mergeTip == null || rw.isMergedInto(mergeTip, n)) {
@@ -163,18 +202,18 @@
           return n;
         }
       } catch (IOException e) {
-        throw new IntegrationException(
-            "Cannot fast-forward test during merge", e);
+        throw new IntegrationException("Cannot fast-forward test during merge", e);
       }
     }
     return mergeTip;
   }
 
-  public List<CodeReviewCommit> reduceToMinimalMerge(MergeSorter mergeSorter,
-      Collection<CodeReviewCommit> toSort) throws IntegrationException {
+  public List<CodeReviewCommit> reduceToMinimalMerge(
+      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort, Set<CodeReviewCommit> incoming)
+      throws IntegrationException {
     List<CodeReviewCommit> result = new ArrayList<>();
     try {
-      result.addAll(mergeSorter.sort(toSort));
+      result.addAll(mergeSorter.sort(toSort, incoming));
     } catch (IOException e) {
       throw new IntegrationException("Branch head sorting failed", e);
     }
@@ -182,19 +221,25 @@
     return result;
   }
 
-  public CodeReviewCommit createCherryPickFromCommit(Repository repo,
-      ObjectInserter inserter, RevCommit mergeTip, RevCommit originalCommit,
-      PersonIdent cherryPickCommitterIdent, String commitMsg,
-      CodeReviewRevWalk rw)
+  public CodeReviewCommit createCherryPickFromCommit(
+      Repository repo,
+      ObjectInserter inserter,
+      RevCommit mergeTip,
+      RevCommit originalCommit,
+      PersonIdent cherryPickCommitterIdent,
+      String commitMsg,
+      CodeReviewRevWalk rw,
+      int parentIndex,
+      boolean ignoreIdenticalTree)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
-      MergeIdenticalTreeException, MergeConflictException {
+          MergeIdenticalTreeException, MergeConflictException {
 
     final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
 
-    m.setBase(originalCommit.getParent(0));
+    m.setBase(originalCommit.getParent(parentIndex));
     if (m.merge(mergeTip, originalCommit)) {
       ObjectId tree = m.getResultTreeId();
-      if (tree.equals(mergeTip.getTree())) {
+      if (tree.equals(mergeTip.getTree()) && !ignoreIdenticalTree) {
         throw new MergeIdenticalTreeException("identical tree");
       }
 
@@ -209,12 +254,19 @@
     throw new MergeConflictException("merge conflict");
   }
 
-  public static RevCommit createMergeCommit(Repository repo, ObjectInserter inserter,
-      RevCommit mergeTip, RevCommit originalCommit, String mergeStrategy,
-      PersonIdent committerIndent, String commitMsg, RevWalk rw)
+  public static RevCommit createMergeCommit(
+      Repository repo,
+      ObjectInserter inserter,
+      RevCommit mergeTip,
+      RevCommit originalCommit,
+      String mergeStrategy,
+      PersonIdent committerIndent,
+      String commitMsg,
+      RevWalk rw)
       throws IOException, MergeIdenticalTreeException, MergeConflictException {
 
-    if (rw.isMergedInto(originalCommit, mergeTip)) {
+    if (!MergeStrategy.THEIRS.getName().equals(mergeStrategy)
+        && rw.isMergedInto(originalCommit, mergeTip)) {
       throw new ChangeAlreadyMergedException(
           "'" + originalCommit.getName() + "' has already been merged");
     }
@@ -246,8 +298,23 @@
     return sb.toString();
   }
 
-  public String createCherryPickCommitMessage(RevCommit n, ChangeControl ctl,
-      PatchSet.Id psId) {
+  /**
+   * Adds footers to existing commit message based on the state of the change.
+   *
+   * <p>This adds the following footers if they are missing:
+   *
+   * <ul>
+   *   <li>Reviewed-on: <i>url</i>
+   *   <li>Reviewed-by | Tested-by | <i>Other-Label-Name</i>: <i>reviewer</i>
+   *   <li>Change-Id
+   * </ul>
+   *
+   * @param n
+   * @param ctl
+   * @param psId
+   * @return new message
+   */
+  private String createDetailedCommitMessage(RevCommit n, ChangeControl ctl, PatchSet.Id psId) {
     Change c = ctl.getChange();
     final List<FooterLine> footers = n.getFooterLines();
     final StringBuilder msgbuf = new StringBuilder();
@@ -297,15 +364,13 @@
       if (a.isLegacySubmit()) {
         // Submit is treated specially, below (becomes committer)
         //
-        if (submitAudit == null
-            || a.getGranted().compareTo(submitAudit.getGranted()) > 0) {
+        if (submitAudit == null || a.getGranted().compareTo(submitAudit.getGranted()) > 0) {
           submitAudit = a;
         }
         continue;
       }
 
-      final Account acc =
-          identifiedUserFactory.create(a.getAccountId()).getAccount();
+      final Account acc = identifiedUserFactory.create(a.getAccountId()).getAccount();
       final StringBuilder identbuf = new StringBuilder();
       if (acc.getFullName() != null && acc.getFullName().length() > 0) {
         if (identbuf.length() > 0) {
@@ -313,8 +378,7 @@
         }
         identbuf.append(acc.getFullName());
       }
-      if (acc.getPreferredEmail() != null
-          && acc.getPreferredEmail().length() > 0) {
+      if (acc.getPreferredEmail() != null && acc.getPreferredEmail().length() > 0) {
         if (isSignedOffBy(footers, acc.getPreferredEmail())) {
           continue;
         }
@@ -350,12 +414,30 @@
         msgbuf.append('\n');
       }
     }
-
     return msgbuf.toString();
   }
 
-  public String createCherryPickCommitMessage(final CodeReviewCommit n) {
-    return createCherryPickCommitMessage(n, n.getControl(), n.getPatchsetId());
+  public String createCommitMessageOnSubmit(CodeReviewCommit n, RevCommit mergeTip) {
+    return createCommitMessageOnSubmit(n, mergeTip, n.getControl(), n.getPatchsetId());
+  }
+
+  /**
+   * Creates a commit message for a change, which can be customized by plugins.
+   *
+   * <p>By default, adds footers to existing commit message based on the state of the change.
+   * Plugins implementing {@link ChangeMessageModifier} can modify the resulting commit message
+   * arbitrarily.
+   *
+   * @param n
+   * @param mergeTip
+   * @param ctl
+   * @param id
+   * @return new message
+   */
+  public String createCommitMessageOnSubmit(
+      RevCommit n, RevCommit mergeTip, ChangeControl ctl, Id id) {
+    return commitMessageGenerator.generate(
+        n, mergeTip, ctl, createDetailedCommitMessage(n, ctl, id));
   }
 
   private static boolean isCodeReview(LabelId id) {
@@ -366,8 +448,7 @@
     return "Verified".equalsIgnoreCase(id.get());
   }
 
-  private Iterable<PatchSetApproval> safeGetApprovals(
-      ChangeControl ctl, PatchSet.Id psId) {
+  private Iterable<PatchSetApproval> safeGetApprovals(ChangeControl ctl, PatchSet.Id psId) {
     try {
       return approvalsUtil.byPatchSet(db.get(), ctl, psId);
     } catch (OrmException e) {
@@ -387,16 +468,17 @@
 
   private static boolean isSignedOffBy(List<FooterLine> footers, String email) {
     for (final FooterLine line : footers) {
-      if (line.matches(FooterKey.SIGNED_OFF_BY)
-          && email.equals(line.getEmailAddress())) {
+      if (line.matches(FooterKey.SIGNED_OFF_BY) && email.equals(line.getEmailAddress())) {
         return true;
       }
     }
     return false;
   }
 
-  public boolean canMerge(final MergeSorter mergeSorter,
-      final Repository repo, final CodeReviewCommit mergeTip,
+  public boolean canMerge(
+      final MergeSorter mergeSorter,
+      final Repository repo,
+      final CodeReviewCommit mergeTip,
       final CodeReviewCommit toMerge)
       throws IntegrationException {
     if (hasMissingDependencies(mergeSorter, toMerge)) {
@@ -404,8 +486,7 @@
     }
 
     try (ObjectInserter ins = new InMemoryInserter(repo)) {
-      return newThreeWayMerger(repo, ins)
-          .merge(new AnyObjectId[] {mergeTip, toMerge});
+      return newThreeWayMerger(repo, ins).merge(new AnyObjectId[] {mergeTip, toMerge});
     } catch (LargeObjectException e) {
       log.warn("Cannot merge due to LargeObjectException: " + toMerge.name());
       return false;
@@ -416,22 +497,31 @@
     }
   }
 
-  public boolean canFastForward(MergeSorter mergeSorter,
-      CodeReviewCommit mergeTip, CodeReviewRevWalk rw, CodeReviewCommit toMerge)
+  public boolean canFastForward(
+      MergeSorter mergeSorter,
+      CodeReviewCommit mergeTip,
+      CodeReviewRevWalk rw,
+      CodeReviewCommit toMerge)
       throws IntegrationException {
     if (hasMissingDependencies(mergeSorter, toMerge)) {
       return false;
     }
 
     try {
-      return mergeTip == null || rw.isMergedInto(mergeTip, toMerge);
+      return mergeTip == null
+          || rw.isMergedInto(mergeTip, toMerge)
+          || rw.isMergedInto(toMerge, mergeTip);
     } catch (IOException e) {
       throw new IntegrationException("Cannot fast-forward test during merge", e);
     }
   }
 
-  public boolean canCherryPick(MergeSorter mergeSorter, Repository repo,
-      CodeReviewCommit mergeTip, CodeReviewRevWalk rw, CodeReviewCommit toMerge)
+  public boolean canCherryPick(
+      MergeSorter mergeSorter,
+      Repository repo,
+      CodeReviewCommit mergeTip,
+      CodeReviewRevWalk rw,
+      CodeReviewCommit toMerge)
       throws IntegrationException {
     if (mergeTip == null) {
       // The branch is unborn. Fast-forward is possible.
@@ -457,8 +547,8 @@
         return m.merge(mergeTip, toMerge);
       } catch (IOException e) {
         throw new IntegrationException(
-            String.format("Cannot merge commit %s with mergetip %s",
-                toMerge.name(), mergeTip.name()),
+            String.format(
+                "Cannot merge commit %s with mergetip %s", toMerge.name(), mergeTip.name()),
             e);
       }
     }
@@ -473,8 +563,8 @@
         || canMerge(mergeSorter, repo, mergeTip, toMerge);
   }
 
-  public boolean hasMissingDependencies(final MergeSorter mergeSorter,
-      final CodeReviewCommit toMerge) throws IntegrationException {
+  public boolean hasMissingDependencies(
+      final MergeSorter mergeSorter, final CodeReviewCommit toMerge) throws IntegrationException {
     try {
       return !mergeSorter.sort(Collections.singleton(toMerge)).contains(toMerge);
     } catch (IOException e) {
@@ -482,16 +572,21 @@
     }
   }
 
-  public CodeReviewCommit mergeOneCommit(PersonIdent author,
-      PersonIdent committer, Repository repo, CodeReviewRevWalk rw,
-      ObjectInserter inserter, Branch.NameKey destBranch,
-      CodeReviewCommit mergeTip, CodeReviewCommit n)
+  public CodeReviewCommit mergeOneCommit(
+      PersonIdent author,
+      PersonIdent committer,
+      Repository repo,
+      CodeReviewRevWalk rw,
+      ObjectInserter inserter,
+      Branch.NameKey destBranch,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit n)
       throws IntegrationException {
     final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
     try {
       if (m.merge(new AnyObjectId[] {mergeTip, n})) {
-        return writeMergeCommit(author, committer, rw, inserter, destBranch,
-            mergeTip, m.getResultTreeId(), n);
+        return writeMergeCommit(
+            author, committer, rw, inserter, destBranch, mergeTip, m.getResultTreeId(), n);
       }
       failed(rw, mergeTip, n, CommitMergeStatus.PATH_CONFLICT);
     } catch (NoMergeBaseException e) {
@@ -506,8 +601,7 @@
     return mergeTip;
   }
 
-  private static CommitMergeStatus getCommitMergeStatus(
-      MergeBaseFailureReason reason) {
+  private static CommitMergeStatus getCommitMergeStatus(MergeBaseFailureReason reason) {
     switch (reason) {
       case MULTIPLE_MERGE_BASES_NOT_SUPPORTED:
       case TOO_MANY_MERGE_BASES:
@@ -518,8 +612,11 @@
     }
   }
 
-  private static CodeReviewCommit failed(CodeReviewRevWalk rw,
-      CodeReviewCommit mergeTip, CodeReviewCommit n, CommitMergeStatus failure)
+  private static CodeReviewCommit failed(
+      CodeReviewRevWalk rw,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit n,
+      CommitMergeStatus failure)
       throws MissingObjectException, IncorrectObjectTypeException, IOException {
     rw.reset();
     rw.markStart(n);
@@ -531,11 +628,16 @@
     return failed;
   }
 
-  public CodeReviewCommit writeMergeCommit(PersonIdent author,
-      PersonIdent committer, CodeReviewRevWalk rw, ObjectInserter inserter,
-      Branch.NameKey destBranch, CodeReviewCommit mergeTip, ObjectId treeId,
-      CodeReviewCommit n) throws IOException, MissingObjectException,
-      IncorrectObjectTypeException {
+  public CodeReviewCommit writeMergeCommit(
+      PersonIdent author,
+      PersonIdent committer,
+      CodeReviewRevWalk rw,
+      ObjectInserter inserter,
+      Branch.NameKey destBranch,
+      CodeReviewCommit mergeTip,
+      ObjectId treeId,
+      CodeReviewCommit n)
+      throws IOException, MissingObjectException, IncorrectObjectTypeException {
     final List<CodeReviewCommit> merged = new ArrayList<>();
     rw.reset();
     rw.markStart(n);
@@ -570,14 +672,12 @@
     mergeCommit.setCommitter(committer);
     mergeCommit.setMessage(msgbuf.toString());
 
-    CodeReviewCommit mergeResult =
-        rw.parseCommit(inserter.insert(mergeCommit));
+    CodeReviewCommit mergeResult = rw.parseCommit(inserter.insert(mergeCommit));
     mergeResult.setControl(n.getControl());
     return mergeResult;
   }
 
-  private String summarize(RevWalk rw, List<CodeReviewCommit> merged)
-      throws IOException {
+  private String summarize(RevWalk rw, List<CodeReviewCommit> merged) throws IOException {
     if (merged.size() == 1) {
       CodeReviewCommit c = merged.get(0);
       rw.parseBody(c);
@@ -592,27 +692,21 @@
     }
 
     if (topics.size() == 1) {
-      return String.format("Merge changes from topic '%s'",
-          Iterables.getFirst(topics, null));
+      return String.format("Merge changes from topic '%s'", Iterables.getFirst(topics, null));
     } else if (topics.size() > 1) {
-      return String.format("Merge changes from topics '%s'",
-          Joiner.on("', '").join(topics));
+      return String.format("Merge changes from topics '%s'", Joiner.on("', '").join(topics));
     } else {
-      return String.format("Merge changes %s%s",
-          Joiner.on(',').join(Iterables.transform(
-              Iterables.limit(merged, 5),
-              new Function<CodeReviewCommit, String>() {
-                @Override
-                public String apply(CodeReviewCommit in) {
-                  return in.change().getKey().abbreviate();
-                }
-              })),
+      return String.format(
+          "Merge changes %s%s",
+          FluentIterable.from(merged)
+              .limit(5)
+              .transform(c -> c.change().getKey().abbreviate())
+              .join(Joiner.on(',')),
           merged.size() > 5 ? ", ..." : "");
     }
   }
 
-  public ThreeWayMerger newThreeWayMerger(final Repository repo,
-      final ObjectInserter inserter) {
+  public ThreeWayMerger newThreeWayMerger(final Repository repo, final ObjectInserter inserter) {
     return newThreeWayMerger(repo, inserter, mergeStrategyName());
   }
 
@@ -620,8 +714,7 @@
     return mergeStrategyName(useContentMerge, useRecursiveMerge);
   }
 
-  public static String mergeStrategyName(boolean useContentMerge,
-      boolean useRecursiveMerge) {
+  public static String mergeStrategyName(boolean useContentMerge, boolean useRecursiveMerge) {
     if (useContentMerge) {
       // Settings for this project allow us to try and automatically resolve
       // conflicts within files if needed. Use either the old resolve merger or
@@ -636,39 +729,43 @@
     return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.getName();
   }
 
-  public static ThreeWayMerger newThreeWayMerger(Repository repo,
-      final ObjectInserter inserter, String strategyName) {
+  public static ThreeWayMerger newThreeWayMerger(
+      Repository repo, final ObjectInserter inserter, String strategyName) {
     Merger m = newMerger(repo, inserter, strategyName);
-    checkArgument(m instanceof ThreeWayMerger,
-        "merge strategy %s does not support three-way merging", strategyName);
+    checkArgument(
+        m instanceof ThreeWayMerger,
+        "merge strategy %s does not support three-way merging",
+        strategyName);
     return (ThreeWayMerger) m;
   }
 
-  public static Merger newMerger(Repository repo,
-      final ObjectInserter inserter, String strategyName) {
+  public static Merger newMerger(
+      Repository repo, final ObjectInserter inserter, String strategyName) {
     MergeStrategy strategy = MergeStrategy.get(strategyName);
     checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
     Merger m = strategy.newMerger(repo, true);
-    m.setObjectInserter(new ObjectInserter.Filter() {
-      @Override
-      protected ObjectInserter delegate() {
-        return inserter;
-      }
+    m.setObjectInserter(
+        new ObjectInserter.Filter() {
+          @Override
+          protected ObjectInserter delegate() {
+            return inserter;
+          }
 
-      @Override
-      public void flush() {
-      }
+          @Override
+          public void flush() {}
 
-      @Override
-      public void close() {
-      }
-    });
+          @Override
+          public void close() {}
+        });
     return m;
   }
 
-  public void markCleanMerges(final RevWalk rw,
-      final RevFlag canMergeFlag, final CodeReviewCommit mergeTip,
-      final Set<RevCommit> alreadyAccepted) throws IntegrationException {
+  public void markCleanMerges(
+      final RevWalk rw,
+      final RevFlag canMergeFlag,
+      final CodeReviewCommit mergeTip,
+      final Set<RevCommit> alreadyAccepted)
+      throws IntegrationException {
     if (mergeTip == null) {
       // If mergeTip is null here, branchTip was null, indicating a new branch
       // at the start of the merge process. We also elected to merge nothing,
@@ -700,9 +797,13 @@
     }
   }
 
-  public Set<Change.Id> findUnmergedChanges(Set<Change.Id> expected,
-      CodeReviewRevWalk rw, RevFlag canMergeFlag, CodeReviewCommit oldTip,
-      CodeReviewCommit mergeTip, Iterable<Change.Id> alreadyMerged)
+  public Set<Change.Id> findUnmergedChanges(
+      Set<Change.Id> expected,
+      CodeReviewRevWalk rw,
+      RevFlag canMergeFlag,
+      CodeReviewCommit oldTip,
+      CodeReviewCommit mergeTip,
+      Iterable<Change.Id> alreadyMerged)
       throws IntegrationException {
     if (mergeTip == null) {
       return expected;
@@ -738,8 +839,9 @@
     }
   }
 
-  public static CodeReviewCommit findAnyMergedInto(CodeReviewRevWalk rw,
-      Iterable<CodeReviewCommit> commits, CodeReviewCommit tip) throws IOException {
+  public static CodeReviewCommit findAnyMergedInto(
+      CodeReviewRevWalk rw, Iterable<CodeReviewCommit> commits, CodeReviewCommit tip)
+      throws IOException {
     for (CodeReviewCommit c : commits) {
       // TODO(dborowitz): Seems like this could get expensive for many patch
       // sets. Is there a more efficient implementation?
@@ -755,12 +857,10 @@
     try {
       ObjectId commitId = repo.resolve(str);
       if (commitId == null) {
-        throw new BadRequestException(
-            "Cannot resolve '" + str + "' to a commit");
+        throw new BadRequestException("Cannot resolve '" + str + "' to a commit");
       }
       return rw.parseCommit(commitId);
-    } catch (AmbiguousObjectException | IncorrectObjectTypeException |
-        RevisionSyntaxException e) {
+    } catch (AmbiguousObjectException | IncorrectObjectTypeException | RevisionSyntaxException e) {
       throw new BadRequestException(e.getMessage());
     } catch (MissingObjectException e) {
       throw new ResourceNotFoundException(e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
index ce445c6..e96fdb8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -22,21 +22,25 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.mail.MergedSender;
+import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.util.Collections;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -45,17 +49,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collections;
-import java.util.concurrent.ExecutorService;
-
-public class MergedByPushOp extends BatchUpdate.Op {
-  private static final Logger log =
-      LoggerFactory.getLogger(MergedByPushOp.class);
+public class MergedByPushOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(MergedByPushOp.class);
 
   public interface Factory {
-    MergedByPushOp create(RequestScopePropagator requestScopePropagator,
-        PatchSet.Id psId, String refName);
+    MergedByPushOp create(
+        RequestScopePropagator requestScopePropagator, PatchSet.Id psId, String refName);
   }
 
   private final RequestScopePropagator requestScopePropagator;
@@ -101,15 +100,13 @@
     return refName;
   }
 
-  public MergedByPushOp setPatchSetProvider(
-      Provider<PatchSet> patchSetProvider) {
+  public MergedByPushOp setPatchSetProvider(Provider<PatchSet> patchSetProvider) {
     this.patchSetProvider = checkNotNull(patchSetProvider);
     return this;
   }
 
   @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws OrmException, IOException {
+  public boolean updateChange(ChangeContext ctx) throws OrmException, IOException {
     change = ctx.getChange();
     correctBranch = refName.equals(change.getDest().get());
     if (!correctBranch) {
@@ -121,9 +118,9 @@
       // that is not present in the old notes so we can't use PatchSetUtil.
       patchSet = patchSetProvider.get();
     } else {
-      patchSet = checkNotNull(
-          psUtil.get(ctx.getDb(), ctx.getNotes(), psId),
-          "patch set %s not found", psId);
+      patchSet =
+          checkNotNull(
+              psUtil.get(ctx.getDb(), ctx.getNotes(), psId), "patch set %s not found", psId);
     }
     info = getPatchSetInfo(ctx);
 
@@ -132,15 +129,12 @@
     if (status == Change.Status.MERGED) {
       return true;
     }
-    if (status.isOpen()) {
-      change.setCurrentPatchSet(info);
-      change.setStatus(Change.Status.MERGED);
-
-      // we cannot reconstruct the submit records for when this change was
-      // submitted, this is why we must fix the status
-      update.fixStatus(Change.Status.MERGED);
-    }
-
+    change.setCurrentPatchSet(info);
+    change.setStatus(Change.Status.MERGED);
+    // we cannot reconstruct the submit records for when this change was
+    // submitted, this is why we must fix the status
+    update.fixStatus(Change.Status.MERGED);
+    update.setCurrentPatchSet();
     StringBuilder msgBuf = new StringBuilder();
     msgBuf.append("Change has been successfully pushed");
     if (!refName.equals(change.getDest().get())) {
@@ -153,22 +147,16 @@
       }
     }
     msgBuf.append(".");
-    ChangeMessage msg = new ChangeMessage(
-        new ChangeMessage.Key(change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(), psId);
-    msg.setMessage(msgBuf.toString());
+    ChangeMessage msg =
+        ChangeMessagesUtil.newMessage(
+            psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
     cmUtil.addChangeMessage(ctx.getDb(), update, msg);
 
-    PatchSetApproval submitter = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              change.currentPatchSetId(),
-              ctx.getAccountId(),
-              LabelId.legacySubmit()),
-              (short) 1, ctx.getWhen());
+    PatchSetApproval submitter =
+        ApprovalsUtil.newApproval(
+            change.currentPatchSetId(), ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
     update.putApproval(submitter.getLabel(), submitter.getValue());
-    ctx.getDb().patchSetApprovals().upsert(
-        Collections.singleton(submitter));
+    ctx.getDb().patchSetApprovals().upsert(Collections.singleton(submitter));
 
     return true;
   }
@@ -178,36 +166,38 @@
     if (!correctBranch) {
       return;
     }
-    sendEmailExecutor.submit(requestScopePropagator.wrap(new Runnable() {
-      @Override
-      public void run() {
-        try {
-          MergedSender cm =
-              mergedSenderFactory.create(ctx.getProject(), psId.getParentKey());
-          cm.setFrom(ctx.getAccountId());
-          cm.setPatchSet(patchSet, info);
-          cm.send();
-        } catch (Exception e) {
-          log.error("Cannot send email for submitted patch set " + psId, e);
-        }
-      }
+    @SuppressWarnings("unused") // Runnable already handles errors
+    Future<?> possiblyIgnoredError =
+        sendEmailExecutor.submit(
+            requestScopePropagator.wrap(
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    try {
+                      MergedSender cm =
+                          mergedSenderFactory.create(ctx.getProject(), psId.getParentKey());
+                      cm.setFrom(ctx.getAccountId());
+                      cm.setPatchSet(patchSet, info);
+                      cm.send();
+                    } catch (Exception e) {
+                      log.error("Cannot send email for submitted patch set " + psId, e);
+                    }
+                  }
 
-      @Override
-      public String toString() {
-        return "send-email merged";
-      }
-    }));
+                  @Override
+                  public String toString() {
+                    return "send-email merged";
+                  }
+                }));
 
-    changeMerged.fire(change, patchSet,
-        ctx.getAccount(),
-        patchSet.getRevision().get(),
-        ctx.getWhen());
+    changeMerged.fire(
+        change, patchSet, ctx.getAccount(), patchSet.getRevision().get(), ctx.getWhen());
   }
 
   private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
     RevWalk rw = ctx.getRevWalk();
-    RevCommit commit = rw.parseCommit(
-        ObjectId.fromString(checkNotNull(patchSet).getRevision().get()));
+    RevCommit commit =
+        rw.parseCommit(ObjectId.fromString(checkNotNull(patchSet).getRevision().get()));
     return patchSetInfoFactory.get(rw, commit, psId);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
index 7e47d1e..b16ccef 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
@@ -23,7 +23,7 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -31,8 +31,6 @@
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-
 /** Helps with the updating of a {@link VersionedMetaData}. */
 public class MetaDataUpdate implements AutoCloseable {
   public static class User {
@@ -42,7 +40,9 @@
     private final Provider<IdentifiedUser> identifiedUser;
 
     @Inject
-    User(InternalFactory factory, GitRepositoryManager mgr,
+    User(
+        InternalFactory factory,
+        GitRepositoryManager mgr,
         @GerritPersonIdent PersonIdent serverIdent,
         Provider<IdentifiedUser> identifiedUser) {
       this.factory = factory;
@@ -65,20 +65,18 @@
       return create(name, user, null);
     }
 
-  /**
-   * Create an update using an existing batch ref update.
-   * <p>
-   * This allows batching together updates to multiple metadata refs. For making
-   * multiple commits to a single metadata ref, see
-   * {@link VersionedMetaData#openUpdate(MetaDataUpdate)}.
-   *
-   * @param name project name.
-   * @param user user for the update.
-   * @param batch batch update to use; the caller is responsible for committing
-   *     the update.
-   */
-    public MetaDataUpdate create(Project.NameKey name, IdentifiedUser user,
-        BatchRefUpdate batch) throws RepositoryNotFoundException, IOException {
+    /**
+     * Create an update using an existing batch ref update.
+     *
+     * <p>This allows batching together updates to multiple metadata refs. For making multiple
+     * commits to a single metadata ref, see {@link VersionedMetaData#openUpdate(MetaDataUpdate)}.
+     *
+     * @param name project name.
+     * @param user user for the update.
+     * @param batch batch update to use; the caller is responsible for committing the update.
+     */
+    public MetaDataUpdate create(Project.NameKey name, IdentifiedUser user, BatchRefUpdate batch)
+        throws RepositoryNotFoundException, IOException {
       Repository repo = mgr.openRepository(name);
       MetaDataUpdate md = create(name, repo, user, batch);
       md.setCloseRepository(true);
@@ -87,16 +85,16 @@
 
     /**
      * Create an update using an existing batch ref update.
-     * <p>
-     * This allows batching together updates to multiple metadata refs. For making
-     * multiple commits to a single metadata ref, see
-     * {@link VersionedMetaData#openUpdate(MetaDataUpdate)}.
      *
-     * Important: Create a new MetaDataUpdate instance for each update:
+     * <p>This allows batching together updates to multiple metadata refs. For making multiple
+     * commits to a single metadata ref, see {@link VersionedMetaData#openUpdate(MetaDataUpdate)}.
+     *
+     * <p>Important: Create a new MetaDataUpdate instance for each update:
+     *
      * <pre>
      * <code>
      *   try (Repository repo = repoMgr.openRepository(allUsersName);
-     *       RevWalk rw = new RevWalk(repo) {
+     *       RevWalk rw = new RevWalk(repo)) {
      *     BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
      *     // WRONG: create the MetaDataUpdate instance here and reuse it for
      *     //        all updates in the loop
@@ -120,14 +118,13 @@
      * </pre>
      *
      * @param name project name.
-     * @param repository the repository to update; the caller is responsible for
-     *     closing the repository.
+     * @param repository the repository to update; the caller is responsible for closing the
+     *     repository.
      * @param user user for the update.
-     * @param batch batch update to use; the caller is responsible for committing
-     *     the update.
+     * @param batch batch update to use; the caller is responsible for committing the update.
      */
-    public MetaDataUpdate create(Project.NameKey name, Repository repository,
-        IdentifiedUser user, BatchRefUpdate batch) {
+    public MetaDataUpdate create(
+        Project.NameKey name, Repository repository, IdentifiedUser user, BatchRefUpdate batch) {
       MetaDataUpdate md = factory.create(name, repository, batch);
       md.getCommitBuilder().setCommitter(serverIdent);
       md.setAuthor(user);
@@ -135,8 +132,7 @@
     }
 
     private PersonIdent createPersonIdent(IdentifiedUser user) {
-      return user.newCommitterIdent(
-          serverIdent.getWhen(), serverIdent.getTimeZone());
+      return user.newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
     }
   }
 
@@ -146,7 +142,9 @@
     private final PersonIdent serverIdent;
 
     @Inject
-    Server(InternalFactory factory, GitRepositoryManager mgr,
+    Server(
+        InternalFactory factory,
+        GitRepositoryManager mgr,
         @GerritPersonIdent PersonIdent serverIdent) {
       this.factory = factory;
       this.mgr = mgr;
@@ -171,7 +169,8 @@
   }
 
   interface InternalFactory {
-    MetaDataUpdate create(@Assisted Project.NameKey projectName,
+    MetaDataUpdate create(
+        @Assisted Project.NameKey projectName,
         @Assisted Repository repository,
         @Assisted @Nullable BatchRefUpdate batch);
   }
@@ -187,8 +186,10 @@
   private IdentifiedUser author;
 
   @AssistedInject
-  public MetaDataUpdate(GitReferenceUpdated gitRefUpdated,
-      @Assisted Project.NameKey projectName, @Assisted Repository repository,
+  public MetaDataUpdate(
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted Project.NameKey projectName,
+      @Assisted Repository repository,
       @Assisted @Nullable BatchRefUpdate batch) {
     this.gitRefUpdated = gitRefUpdated;
     this.projectName = projectName;
@@ -197,8 +198,8 @@
     this.commit = new CommitBuilder();
   }
 
-  public MetaDataUpdate(GitReferenceUpdated gitRefUpdated,
-      Project.NameKey projectName, Repository repository) {
+  public MetaDataUpdate(
+      GitReferenceUpdated gitRefUpdated, Project.NameKey projectName, Repository repository) {
     this(gitRefUpdated, projectName, repository, null);
   }
 
@@ -209,9 +210,11 @@
 
   public void setAuthor(IdentifiedUser author) {
     this.author = author;
-    getCommitBuilder().setAuthor(author.newCommitterIdent(
-        getCommitBuilder().getCommitter().getWhen(),
-        getCommitBuilder().getCommitter().getTimeZone()));
+    getCommitBuilder()
+        .setAuthor(
+            author.newCommitterIdent(
+                getCommitBuilder().getCommitter().getWhen(),
+                getCommitBuilder().getCommitter().getTimeZone()));
   }
 
   public void setAllowEmpty(boolean allowEmpty) {
@@ -260,7 +263,6 @@
   }
 
   protected void fireGitRefUpdatedEvent(RefUpdate ru) {
-    gitRefUpdated.fire(
-        projectName, ru, author == null ? null : author.getAccount());
+    gitRefUpdated.fire(projectName, ru, author == null ? null : author.getAccount());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
index dffcf30..6fafe4e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -1,16 +1,16 @@
-//Copyright (C) 2015 The Android Open Source Project
+// 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
+// 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
+// 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.
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.git;
 
@@ -22,22 +22,17 @@
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.nio.file.Path;
 import org.eclipse.jgit.lib.Config;
 
-import java.nio.file.Path;
-
-public class MultiBaseLocalDiskRepositoryManager extends
-    LocalDiskRepositoryManager {
+@Singleton
+public class MultiBaseLocalDiskRepositoryManager extends LocalDiskRepositoryManager {
 
   public static class Module extends LifecycleModule {
     @Override
     protected void configure() {
-      bind(GitRepositoryManager.class).to(
-          MultiBaseLocalDiskRepositoryManager.class);
-      bind(LocalDiskRepositoryManager.class).to(
-          MultiBaseLocalDiskRepositoryManager.class);
-      listener().to(MultiBaseLocalDiskRepositoryManager.class);
+      bind(GitRepositoryManager.class).to(MultiBaseLocalDiskRepositoryManager.class);
       listener().to(MultiBaseLocalDiskRepositoryManager.Lifecycle.class);
     }
   }
@@ -45,24 +40,23 @@
   private final RepositoryConfig config;
 
   @Inject
-  MultiBaseLocalDiskRepositoryManager(SitePaths site,
-      @GerritServerConfig Config cfg,
-      RepositoryConfig config) {
+  MultiBaseLocalDiskRepositoryManager(
+      SitePaths site, @GerritServerConfig Config cfg, RepositoryConfig config) {
     super(site, cfg);
     this.config = config;
 
     for (Path alternateBasePath : config.getAllBasePaths()) {
-      checkState(alternateBasePath.isAbsolute(),
-          "repository.<name>.basePath must be absolute: %s", alternateBasePath);
+      checkState(
+          alternateBasePath.isAbsolute(),
+          "repository.<name>.basePath must be absolute: %s",
+          alternateBasePath);
     }
   }
 
   @Override
   public Path getBasePath(NameKey name) {
     Path alternateBasePath = config.getBasePath(name);
-    return alternateBasePath != null
-        ? alternateBasePath
-        : super.getBasePath(name);
+    return alternateBasePath != null ? alternateBasePath : super.getBasePath(name);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index d081fe6..710eb7f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -17,12 +17,6 @@
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.common.base.Strings;
-
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.List;
@@ -32,31 +26,34 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Progress reporting interface that multiplexes multiple sub-tasks.
- * <p>
- * Output is of the format:
+ *
+ * <p>Output is of the format:
+ *
  * <pre>
  *   Task: subA: 1, subB: 75% (3/4) (-)\r
  *   Task: subA: 2, subB: 75% (3/4), subC: 1 (\)\r
  *   Task: subA: 2, subB: 100% (4/4), subC: 1 (|)\r
  *   Task: subA: 4, subB: 100% (4/4), subC: 4, done    \n
  * </pre>
- * <p>
- * Callers should try to keep task and sub-task descriptions short, since the
- * output should fit on one terminal line. (Note that git clients do not accept
- * terminal control characters, so true multi-line progress messages would be
- * impossible.)
+ *
+ * <p>Callers should try to keep task and sub-task descriptions short, since the output should fit
+ * on one terminal line. (Note that git clients do not accept terminal control characters, so true
+ * multi-line progress messages would be impossible.)
  */
 public class MultiProgressMonitor {
-  private static final Logger log =
-      LoggerFactory.getLogger(MultiProgressMonitor.class);
+  private static final Logger log = LoggerFactory.getLogger(MultiProgressMonitor.class);
 
   /** Constant indicating the total work units cannot be predicted. */
   public static final int UNKNOWN = 0;
 
-  private static final char[] SPINNER_STATES = new char[]{'-', '\\', '|', '/'};
+  private static final char[] SPINNER_STATES = new char[] {'-', '\\', '|', '/'};
   private static final char NO_SPINNER = ' ';
 
   /** Handle for a sub-task. */
@@ -73,15 +70,15 @@
 
     /**
      * Indicate that work has been completed on this sub-task.
-     * <p>
-     * Must be called from a worker thread.
+     *
+     * <p>Must be called from a worker thread.
      *
      * @param completed number of work units completed.
      */
     @Override
     public void update(final int completed) {
       boolean w = false;
-      synchronized (this) {
+      synchronized (MultiProgressMonitor.this) {
         count += completed;
         if (total != UNKNOWN) {
           int percent = count * 100 / total;
@@ -98,8 +95,8 @@
 
     /**
      * Indicate that this sub-task is finished.
-     * <p>
-     * Must be called from a worker thread.
+     *
+     * <p>Must be called from a worker thread.
      */
     public void end() {
       if (total == UNKNOWN && getCount() > 0) {
@@ -108,24 +105,23 @@
     }
 
     @Override
-    public void start(int totalTasks) {
-    }
+    public void start(int totalTasks) {}
 
     @Override
-    public void beginTask(String title, int totalWork) {
-    }
+    public void beginTask(String title, int totalWork) {}
 
     @Override
-    public void endTask() {
-    }
+    public void endTask() {}
 
     @Override
     public boolean isCancelled() {
       return false;
     }
 
-    public synchronized int getCount() {
-      return count;
+    public int getCount() {
+      synchronized (MultiProgressMonitor.this) {
+        return count;
+      }
     }
   }
 
@@ -157,8 +153,11 @@
    * @param maxIntervalTime maximum interval between progress messages.
    * @param maxIntervalUnit time unit for progress interval.
    */
-  public MultiProgressMonitor(final OutputStream out, final String taskName,
-      long maxIntervalTime, TimeUnit maxIntervalUnit) {
+  public MultiProgressMonitor(
+      final OutputStream out,
+      final String taskName,
+      long maxIntervalTime,
+      TimeUnit maxIntervalUnit) {
     this.out = out;
     this.taskName = taskName;
     maxIntervalNanos = NANOSECONDS.convert(maxIntervalTime, maxIntervalUnit);
@@ -175,23 +174,21 @@
 
   /**
    * Wait for a task managed by a {@link Future}.
-   * <p>
-   * Must be called from the main thread, <em>not</em> a worker thread. Once a
-   * worker thread calls {@link #end()}, the future has an additional
-   * {@code maxInterval} to finish before it is forcefully cancelled and
-   * {@link ExecutionException} is thrown.
+   *
+   * <p>Must be called from the main thread, <em>not</em> a worker thread. Once a worker thread
+   * calls {@link #end()}, the future has an additional {@code maxInterval} to finish before it is
+   * forcefully cancelled and {@link ExecutionException} is thrown.
    *
    * @param workerFuture a future that returns when worker threads are finished.
-   * @param timeoutTime overall timeout for the task; the future is forcefully
-   *     cancelled if the task exceeds the timeout. Non-positive values indicate
-   *     no timeout.
+   * @param timeoutTime overall timeout for the task; the future is forcefully cancelled if the task
+   *     exceeds the timeout. Non-positive values indicate no timeout.
    * @param timeoutUnit unit for overall task timeout.
-   * @throws ExecutionException if this thread or a worker thread was
-   *     interrupted, the worker was cancelled, or timed out waiting for a
-   *     worker to call {@link #end()}.
+   * @throws ExecutionException if this thread or a worker thread was interrupted, the worker was
+   *     cancelled, or timed out waiting for a worker to call {@link #end()}.
    */
-  public void waitFor(final Future<?> workerFuture, final long timeoutTime,
-      final TimeUnit timeoutUnit) throws ExecutionException {
+  public void waitFor(
+      final Future<?> workerFuture, final long timeoutTime, final TimeUnit timeoutUnit)
+      throws ExecutionException {
     long overallStart = System.nanoTime();
     long deadline;
     String detailMessage = "";
@@ -218,12 +215,14 @@
         if (deadline > 0 && now > deadline) {
           workerFuture.cancel(true);
           if (workerFuture.isCancelled()) {
-            detailMessage = String.format(
+            detailMessage =
+                String.format(
                     "(timeout %sms, cancelled)",
                     TimeUnit.MILLISECONDS.convert(now - deadline, NANOSECONDS));
-            log.warn(String.format(
-                    "MultiProgressMonitor worker killed after %sms" + detailMessage, //
-                    TimeUnit.MILLISECONDS.convert(now - overallStart, NANOSECONDS)));
+            log.warn(
+                "MultiProgressMonitor worker killed after {}ms {}",
+                TimeUnit.MILLISECONDS.convert(now - overallStart, NANOSECONDS),
+                detailMessage);
           }
           break;
         }
@@ -237,8 +236,7 @@
         if (!done && workerFuture.isDone()) {
           // The worker may not have called end() explicitly, which is likely a
           // programming error.
-          log.warn("MultiProgressMonitor worker did not call end()"
-              + " before returning");
+          log.warn("MultiProgressMonitor worker did not call end() before returning");
           end();
         }
       }
@@ -278,8 +276,8 @@
 
   /**
    * End the overall task.
-   * <p>
-   * Must be called from a worker thread.
+   *
+   * <p>Must be called from a worker thread.
    */
   public synchronized void end() {
     done = true;
@@ -313,13 +311,12 @@
   }
 
   private StringBuilder format() {
-    StringBuilder s = new StringBuilder().append("\r").append(taskName)
-        .append(':');
+    StringBuilder s = new StringBuilder().append("\r").append(taskName).append(':');
 
     if (!tasks.isEmpty()) {
       boolean first = true;
       for (Task t : tasks) {
-        int count = t.count;
+        int count = t.getCount();
         if (count == 0) {
           continue;
         }
@@ -337,9 +334,7 @@
         if (t.total == UNKNOWN) {
           s.append(count);
         } else {
-          s.append(String.format("%d%% (%d/%d)",
-              count * 100 / t.total,
-              count, t.total));
+          s.append(String.format("%d%% (%d/%d)", count * 100 / t.total, count, t.total));
         }
       }
     }
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 99abbc8..2020550 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
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
 import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -41,16 +41,10 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-
-/**
- * A utility class for updating a notes branch with automatic merge of note
- * trees.
- */
+/** A utility class for updating a notes branch with automatic merge of note trees. */
 public class NotesBranchUtil {
   public interface Factory {
-    NotesBranchUtil create(Project.NameKey project, Repository db,
-        ObjectInserter inserter);
+    NotesBranchUtil create(Project.NameKey project, Repository db, ObjectInserter inserter);
   }
 
   private static final int MAX_LOCK_FAILURE_CALLS = 10;
@@ -75,7 +69,8 @@
   private ReviewNoteMerger noteMerger;
 
   @Inject
-  public NotesBranchUtil(@GerritPersonIdent final PersonIdent gerritIdent,
+  public NotesBranchUtil(
+      @GerritPersonIdent final PersonIdent gerritIdent,
       final GitReferenceUpdated gitRefUpdated,
       @Assisted Project.NameKey project,
       @Assisted Repository db,
@@ -88,8 +83,8 @@
   }
 
   /**
-   * Create a new commit in the {@code notesBranch} by updating existing
-   * or creating new notes from the {@code notes} map.
+   * Create a new commit in the {@code notesBranch} by updating existing or creating new notes from
+   * the {@code notes} map.
    *
    * @param notes map of notes
    * @param notesBranch notes branch to update
@@ -98,31 +93,29 @@
    * @throws IOException
    * @throws ConcurrentRefUpdateException
    */
-  public final void commitAllNotes(NoteMap notes, String notesBranch,
-      PersonIdent commitAuthor, String commitMessage) throws IOException,
-      ConcurrentRefUpdateException {
+  public final void commitAllNotes(
+      NoteMap notes, String notesBranch, PersonIdent commitAuthor, String commitMessage)
+      throws IOException, ConcurrentRefUpdateException {
     this.overwrite = true;
     commitNotes(notes, notesBranch, commitAuthor, commitMessage);
   }
 
   /**
-   * Create a new commit in the {@code notesBranch} by creating not yet
-   * existing notes from the {@code notes} map. The notes from the
-   * {@code notes} map which already exist in the note-tree of the
-   * tip of the {@code notesBranch} will not be updated.
+   * Create a new commit in the {@code notesBranch} by creating not yet existing notes from the
+   * {@code notes} map. The notes from the {@code notes} map which already exist in the note-tree of
+   * the tip of the {@code notesBranch} will not be updated.
    *
    * @param notes map of notes
    * @param notesBranch notes branch to update
    * @param commitAuthor author of the commit in the notes branch
    * @param commitMessage for the commit in the notes branch
-   * @return map with those notes from the {@code notes} that were newly
-   *         created
+   * @return map with those notes from the {@code notes} that were newly created
    * @throws IOException
    * @throws ConcurrentRefUpdateException
    */
-  public final NoteMap commitNewNotes(NoteMap notes, String notesBranch,
-      PersonIdent commitAuthor, String commitMessage) throws IOException,
-      ConcurrentRefUpdateException {
+  public final NoteMap commitNewNotes(
+      NoteMap notes, String notesBranch, PersonIdent commitAuthor, String commitMessage)
+      throws IOException, ConcurrentRefUpdateException {
     this.overwrite = false;
     commitNotes(notes, notesBranch, commitAuthor, commitMessage);
     NoteMap newlyCreated = NoteMap.newEmptyMap();
@@ -134,9 +127,9 @@
     return newlyCreated;
   }
 
-  private void commitNotes(NoteMap notes, String notesBranch,
-      PersonIdent commitAuthor, String commitMessage) throws IOException,
-      ConcurrentRefUpdateException {
+  private void commitNotes(
+      NoteMap notes, String notesBranch, PersonIdent commitAuthor, String commitMessage)
+      throws IOException, ConcurrentRefUpdateException {
     try {
       revWalk = new RevWalk(db);
       reader = db.newObjectReader();
@@ -160,7 +153,7 @@
 
   private void addNewNotes(NoteMap notes) throws IOException {
     for (Note n : notes) {
-      if (! ours.contains(n)) {
+      if (!ours.contains(n)) {
         ours.set(n, n.getData());
       }
     }
@@ -172,8 +165,8 @@
         // Merge the existing and the new note as if they are both new,
         // means: base == null
         // There is no really a common ancestry for these two note revisions
-        ObjectId noteContent = getNoteMerger().merge(null, n, ours.getNote(n),
-            reader, inserter).getData();
+        ObjectId noteContent =
+            getNoteMerger().merge(null, n, ours.getNote(n), reader, inserter).getData();
         ours.set(n, noteContent);
       } else {
         ours.set(n, n.getData());
@@ -201,8 +194,8 @@
     }
   }
 
-  private RevCommit createCommit(NoteMap map, PersonIdent author,
-      String message, RevCommit... parents) throws IOException {
+  private RevCommit createCommit(
+      NoteMap map, PersonIdent author, String message, RevCommit... parents) throws IOException {
     CommitBuilder b = new CommitBuilder();
     b.setTreeId(map.writeTree(inserter));
     b.setAuthor(author != null ? author : gerritIdent);
@@ -216,9 +209,9 @@
     return revWalk.parseCommit(commitId);
   }
 
-  private void updateRef(String notesBranch) throws IOException,
-      MissingObjectException, IncorrectObjectTypeException,
-      CorruptObjectException, ConcurrentRefUpdateException {
+  private void updateRef(String notesBranch)
+      throws IOException, MissingObjectException, IncorrectObjectTypeException,
+          CorruptObjectException, ConcurrentRefUpdateException {
     if (baseCommit != null && oursCommit.getTree().equals(baseCommit.getTree())) {
       // If the trees are identical, there is no change in the notes.
       // Avoid saving this commit as it has no new information.
@@ -228,7 +221,7 @@
     int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
     RefUpdate refUpdate = createRefUpdate(notesBranch, oursCommit, baseCommit);
 
-    for (;;) {
+    for (; ; ) {
       Result result = refUpdate.update();
 
       if (result == Result.LOCK_FAILURE) {
@@ -239,27 +232,22 @@
             // ignore
           }
         } else {
-          throw new ConcurrentRefUpdateException("Failed to lock the ref: "
-              + notesBranch, refUpdate.getRef(), result);
+          throw new ConcurrentRefUpdateException(
+              "Failed to lock the ref: " + notesBranch, refUpdate.getRef(), result);
         }
 
       } else if (result == Result.REJECTED) {
-        RevCommit theirsCommit =
-            revWalk.parseCommit(refUpdate.getOldObjectId());
-        NoteMap theirs =
-            NoteMap.read(revWalk.getObjectReader(), theirsCommit);
-        NoteMapMerger merger =
-            new NoteMapMerger(db, getNoteMerger(), MergeStrategy.RESOLVE);
+        RevCommit theirsCommit = revWalk.parseCommit(refUpdate.getOldObjectId());
+        NoteMap theirs = NoteMap.read(revWalk.getObjectReader(), theirsCommit);
+        NoteMapMerger merger = new NoteMapMerger(db, getNoteMerger(), MergeStrategy.RESOLVE);
         NoteMap merged = merger.merge(base, ours, theirs);
         RevCommit mergeCommit =
-            createCommit(merged, gerritIdent, "Merged note commits\n",
-                theirsCommit, oursCommit);
+            createCommit(merged, gerritIdent, "Merged note commits\n", theirsCommit, oursCommit);
         refUpdate = createRefUpdate(notesBranch, mergeCommit, theirsCommit);
         remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
 
       } else if (result == Result.IO_FAILURE) {
-        throw new IOException("Couldn't update " + notesBranch + ". "
-            + result.name());
+        throw new IOException("Couldn't update " + notesBranch + ". " + result.name());
       } else {
         gitRefUpdated.fire(project, refUpdate, null);
         break;
@@ -267,8 +255,8 @@
     }
   }
 
-  private RefUpdate createRefUpdate(String notesBranch, ObjectId newObjectId,
-      ObjectId expectedOldObjectId) throws IOException {
+  private RefUpdate createRefUpdate(
+      String notesBranch, ObjectId newObjectId, ObjectId expectedOldObjectId) throws IOException {
     RefUpdate refUpdate = db.updateRef(notesBranch);
     refUpdate.setNewObjectId(newObjectId);
     if (expectedOldObjectId == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java
index d8ed075..55b94e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotifyConfig.java
@@ -16,16 +16,17 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.mail.Address;
-
 import java.util.EnumSet;
 import java.util.HashSet;
 import java.util.Set;
 
 public class NotifyConfig implements Comparable<NotifyConfig> {
   public enum Header {
-    TO, CC, BCC
+    TO,
+    CC,
+    BCC
   }
 
   private String name;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index 91bc428..20f053a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -23,7 +23,6 @@
 import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
 import com.google.inject.Scope;
-
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Callable;
@@ -53,7 +52,8 @@
 
   public static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
     @Inject
-    Propagator(ThreadLocalRequestContext local,
+    Propagator(
+        ThreadLocalRequestContext local,
         Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
       super(REQUEST, current, local, dbProviderProvider);
     }
@@ -91,25 +91,26 @@
     return ctx;
   }
 
-  public static final Scope REQUEST = new Scope() {
-    @Override
-    public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
-      return new Provider<T>() {
+  public static final Scope REQUEST =
+      new Scope() {
         @Override
-        public T get() {
-          return requireContext().get(key, creator);
+        public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
+          return new Provider<T>() {
+            @Override
+            public T get() {
+              return requireContext().get(key, creator);
+            }
+
+            @Override
+            public String toString() {
+              return String.format("%s[%s]", creator, REQUEST);
+            }
+          };
         }
 
         @Override
         public String toString() {
-          return String.format("%s[%s]", creator, REQUEST);
+          return "PerThreadRequestScope.REQUEST";
         }
       };
-    }
-
-    @Override
-    public String toString() {
-      return "PerThreadRequestScope.REQUEST";
-    }
-  };
 }
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 585909a..12a62f9 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
@@ -45,24 +45,16 @@
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 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.account.GroupBackend;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.CommentLinkInfoImpl;
 import com.google.gerrit.server.project.RefPattern;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.util.StringUtils;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -73,11 +65,17 @@
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.transport.RefSpec;
 
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
   public static final String COMMENTLINK = "commentlink";
@@ -91,7 +89,7 @@
   private static final String PROJECT = "project";
   private static final String KEY_DESCRIPTION = "description";
 
-  private static final String ACCESS = "access";
+  public static final String ACCESS = "access";
   private static final String KEY_INHERIT_FROM = "inheritFrom";
   private static final String KEY_GROUP_PERMISSIONS = "exclusiveGroupPermissions";
 
@@ -117,11 +115,9 @@
   private static final String RECEIVE = "receive";
   private static final String KEY_REQUIRE_SIGNED_OFF_BY = "requireSignedOffBy";
   private static final String KEY_REQUIRE_CHANGE_ID = "requireChangeId";
-  private static final String KEY_USE_ALL_NOT_IN_TARGET =
-      "createNewChangeForAllNotInTarget";
+  private static final String KEY_USE_ALL_NOT_IN_TARGET = "createNewChangeForAllNotInTarget";
   private static final String KEY_MAX_OBJECT_SIZE_LIMIT = "maxObjectSizeLimit";
-  private static final String KEY_REQUIRE_CONTRIBUTOR_AGREEMENT =
-      "requireContributorAgreement";
+  private static final String KEY_REQUIRE_CONTRIBUTOR_AGREEMENT = "requireContributorAgreement";
   private static final String KEY_CHECK_RECEIVED_OBJECTS = "checkReceivedObjects";
   private static final String KEY_ENABLE_SIGNED_PUSH = "enableSignedPush";
   private static final String KEY_REQUIRE_SIGNED_PUSH = "requireSignedPush";
@@ -144,23 +140,28 @@
   private static final String KEY_FUNCTION = "function";
   private static final String KEY_DEFAULT_VALUE = "defaultValue";
   private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+  private static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   private static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
-  private static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = "copyAllScoresOnMergeFirstParentUpdate";
-  private static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
+  private static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+  private static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE =
+      "copyAllScoresOnTrivialRebase";
   private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
   private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
   private static final String KEY_VALUE = "value";
   private static final String KEY_CAN_OVERRIDE = "canOverride";
-  private static final String KEY_Branch = "branch";
-  private static final Set<String> LABEL_FUNCTIONS = ImmutableSet.of(
-      "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
+  private static final String KEY_BRANCH = "branch";
+  private static final ImmutableSet<String> LABEL_FUNCTIONS =
+      ImmutableSet.of(
+          "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
+
+  private static final String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
+  private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
 
   private static final String PLUGIN = "plugin";
 
-  private static final SubmitType defaultSubmitAction =
-      SubmitType.MERGE_IF_NECESSARY;
-  private static final ProjectState defaultStateValue =
-      ProjectState.ACTIVE;
+  private static final SubmitType DEFAULT_SUBMIT_ACTION = SubmitType.MERGE_IF_NECESSARY;
+  private static final ProjectState DEFAULT_STATE_VALUE = ProjectState.ACTIVE;
 
   private Project.NameKey projectName;
   private Project project;
@@ -180,9 +181,11 @@
   private Map<String, Config> pluginConfigs;
   private boolean checkReceivedObjects;
   private Set<String> sectionsWithUnknownPermissions;
+  private boolean hasLegacyPermissions;
+  private Map<String, GroupReference> groupsByName;
 
-  public static ProjectConfig read(MetaDataUpdate update) throws IOException,
-      ConfigInvalidException {
+  public static ProjectConfig read(MetaDataUpdate update)
+      throws IOException, ConfigInvalidException {
     ProjectConfig r = new ProjectConfig(update.getProjectName());
     r.load(update);
     return r;
@@ -195,8 +198,8 @@
     return r;
   }
 
-  public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name,
-      boolean allowRaw) throws IllegalArgumentException {
+  public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
+      throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
     if (match != null) {
       // Unfortunately this validation isn't entirely complete. Clients
@@ -220,7 +223,9 @@
     }
     checkArgument(allowRaw || !hasHtml, "Raw html replacement not allowed");
 
-    if (Strings.isNullOrEmpty(match) && Strings.isNullOrEmpty(link) && !hasHtml
+    if (Strings.isNullOrEmpty(match)
+        && Strings.isNullOrEmpty(link)
+        && !hasHtml
         && enabled != null) {
       if (enabled) {
         return new CommentLinkInfoImpl.Enabled(name);
@@ -271,8 +276,7 @@
     return subscribeSections;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(
-      Branch.NameKey branch) {
+  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
     for (SubscribeSection s : subscribeSections.values()) {
       if (s.appliesTo(branch)) {
@@ -310,8 +314,7 @@
     }
   }
 
-  public void remove(AccessSection section,
-      Permission permission, PermissionRule rule) {
+  public void remove(AccessSection section, Permission permission, PermissionRule rule) {
     if (rule == null) {
       remove(section, permission);
     } else if (section != null && permission != null) {
@@ -391,6 +394,10 @@
     return commentLinkSections;
   }
 
+  public void addCommentLinkSection(CommentLinkInfoImpl commentLink) {
+    commentLinkSections.add(commentLink);
+  }
+
   public ConfiguredMimeTypes getMimeTypes() {
     return mimeTypes;
   }
@@ -400,7 +407,13 @@
   }
 
   public GroupReference resolve(GroupReference group) {
-    return groupList.resolve(group);
+    GroupReference groupRef = groupList.resolve(group);
+    if (groupRef != null
+        && groupRef.getUUID() != null
+        && !groupsByName.containsKey(groupRef.getName())) {
+      groupsByName.put(groupRef.getName(), groupRef);
+    }
+    return groupRef;
   }
 
   /** @return the group reference, if the group is used by at least one rule. */
@@ -408,30 +421,32 @@
     return groupList.byUUID(uuid);
   }
 
+  /**
+   * @return the group reference corresponding to the specified group name if the group is used by
+   *     at least one rule or plugin value.
+   */
+  public GroupReference getGroup(String groupName) {
+    return groupsByName.get(groupName);
+  }
+
   /** @return set of all groups used by this configuration. */
   public Set<AccountGroup.UUID> getAllGroupUUIDs() {
     return groupList.uuids();
   }
 
   /**
-   * @return the project's rules.pl ObjectId, if present in the branch.
-   *    Null if it doesn't exist.
+   * @return the project's rules.pl ObjectId, if present in the branch. Null if it doesn't exist.
    */
   public ObjectId getRulesId() {
     return rulesId;
   }
 
-  /**
-   * @return the maxObjectSizeLimit for this project, if set. Zero if this
-   *         project doesn't define own maxObjectSizeLimit.
-   */
+  /** @return the maxObjectSizeLimit configured on this project, or zero if not configured. */
   public long getMaxObjectSizeLimit() {
     return maxObjectSizeLimit;
   }
 
-  /**
-   * @return the checkReceivedObjects for this project, default is true.
-   */
+  /** @return the checkReceivedObjects for this project, default is true. */
   public boolean getCheckReceivedObjects() {
     return checkReceivedObjects;
   }
@@ -474,7 +489,7 @@
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     readGroupList();
-    Map<String, GroupReference> groupsByName = mapGroupReferences();
+    groupsByName = mapGroupReferences();
 
     rulesId = getObjectId("rules.pl");
     Config rc = readConfig(PROJECT_CONFIG);
@@ -485,32 +500,42 @@
     if (p.getDescription() == null) {
       p.setDescription("");
     }
+
+    if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
+      // The config must not contain more than one parent to inherit from
+      // as there is no guarantee which of the parents would be used then.
+      error(new ValidationError(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
+    }
     p.setParentName(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
 
-    p.setUseContributorAgreements(getEnum(rc, RECEIVE, null, KEY_REQUIRE_CONTRIBUTOR_AGREEMENT, InheritableBoolean.INHERIT));
-    p.setUseSignedOffBy(getEnum(rc, RECEIVE, null, KEY_REQUIRE_SIGNED_OFF_BY, InheritableBoolean.INHERIT));
-    p.setRequireChangeID(getEnum(rc, RECEIVE, null, KEY_REQUIRE_CHANGE_ID, InheritableBoolean.INHERIT));
-    p.setCreateNewChangeForAllNotInTarget(getEnum(rc, RECEIVE, null, KEY_USE_ALL_NOT_IN_TARGET, InheritableBoolean.INHERIT));
-    p.setEnableSignedPush(getEnum(rc, RECEIVE, null,
-          KEY_ENABLE_SIGNED_PUSH, InheritableBoolean.INHERIT));
-    p.setRequireSignedPush(getEnum(rc, RECEIVE, null,
-          KEY_REQUIRE_SIGNED_PUSH, InheritableBoolean.INHERIT));
+    p.setUseContributorAgreements(
+        getEnum(rc, RECEIVE, null, KEY_REQUIRE_CONTRIBUTOR_AGREEMENT, InheritableBoolean.INHERIT));
+    p.setUseSignedOffBy(
+        getEnum(rc, RECEIVE, null, KEY_REQUIRE_SIGNED_OFF_BY, InheritableBoolean.INHERIT));
+    p.setRequireChangeID(
+        getEnum(rc, RECEIVE, null, KEY_REQUIRE_CHANGE_ID, InheritableBoolean.INHERIT));
+    p.setCreateNewChangeForAllNotInTarget(
+        getEnum(rc, RECEIVE, null, KEY_USE_ALL_NOT_IN_TARGET, InheritableBoolean.INHERIT));
+    p.setEnableSignedPush(
+        getEnum(rc, RECEIVE, null, KEY_ENABLE_SIGNED_PUSH, InheritableBoolean.INHERIT));
+    p.setRequireSignedPush(
+        getEnum(rc, RECEIVE, null, KEY_REQUIRE_SIGNED_PUSH, InheritableBoolean.INHERIT));
     p.setMaxObjectSizeLimit(rc.getString(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT));
-    p.setRejectImplicitMerges(getEnum(rc, RECEIVE, null,
-        KEY_REJECT_IMPLICIT_MERGES, InheritableBoolean.INHERIT));
+    p.setRejectImplicitMerges(
+        getEnum(rc, RECEIVE, null, KEY_REJECT_IMPLICIT_MERGES, InheritableBoolean.INHERIT));
 
-    p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, defaultSubmitAction));
+    p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, DEFAULT_SUBMIT_ACTION));
     p.setUseContentMerge(getEnum(rc, SUBMIT, null, KEY_MERGE_CONTENT, InheritableBoolean.INHERIT));
-    p.setState(getEnum(rc, PROJECT, null, KEY_STATE, defaultStateValue));
+    p.setState(getEnum(rc, PROJECT, null, KEY_STATE, DEFAULT_STATE_VALUE));
 
     p.setDefaultDashboard(rc.getString(DASHBOARD, null, KEY_DEFAULT));
     p.setLocalDefaultDashboard(rc.getString(DASHBOARD, null, KEY_LOCAL_DEFAULT));
 
-    loadAccountsSection(rc, groupsByName);
-    loadContributorAgreements(rc, groupsByName);
-    loadAccessSections(rc, groupsByName);
+    loadAccountsSection(rc);
+    loadContributorAgreements(rc);
+    loadAccessSections(rc);
     loadBranchOrderSection(rc);
-    loadNotifySections(rc, groupsByName);
+    loadNotifySections(rc);
     loadLabelSections(rc);
     loadCommentLinkSections(rc);
     loadSubscribeSections(rc);
@@ -519,39 +544,48 @@
     loadReceiveSection(rc);
   }
 
-  private void loadAccountsSection(
-      Config rc, Map<String, GroupReference> groupsByName) {
+  private void loadAccountsSection(Config rc) {
     accountsSection = new AccountsSection();
-    accountsSection.setSameGroupVisibility(loadPermissionRules(
-        rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
+    accountsSection.setSameGroupVisibility(
+        loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
   }
 
-  private void loadContributorAgreements(
-      Config rc, Map<String, GroupReference> groupsByName) {
+  private void loadContributorAgreements(Config rc) {
     contributorAgreements = new HashMap<>();
     for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
       ContributorAgreement ca = getContributorAgreement(name, true);
       ca.setDescription(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_DESCRIPTION));
       ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL));
-      ca.setAccepted(loadPermissionRules(
-          rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
+      ca.setAccepted(
+          loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
 
-      List<PermissionRule> rules = loadPermissionRules(
-          rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, groupsByName, false);
+      List<PermissionRule> rules =
+          loadPermissionRules(
+              rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, groupsByName, false);
       if (rules.isEmpty()) {
         ca.setAutoVerify(null);
       } else if (rules.size() > 1) {
-        error(new ValidationError(PROJECT_CONFIG, "Invalid rule in "
-            + CONTRIBUTOR_AGREEMENT
-            + "." + name
-            + "." + KEY_AUTO_VERIFY
-            + ": at most one group may be set"));
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                "Invalid rule in "
+                    + CONTRIBUTOR_AGREEMENT
+                    + "."
+                    + name
+                    + "."
+                    + KEY_AUTO_VERIFY
+                    + ": at most one group may be set"));
       } else if (rules.get(0).getAction() != Action.ALLOW) {
-        error(new ValidationError(PROJECT_CONFIG, "Invalid rule in "
-            + CONTRIBUTOR_AGREEMENT
-            + "." + name
-            + "." + KEY_AUTO_VERIFY
-            + ": the group must be allowed"));
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                "Invalid rule in "
+                    + CONTRIBUTOR_AGREEMENT
+                    + "."
+                    + name
+                    + "."
+                    + KEY_AUTO_VERIFY
+                    + ": the group must be allowed"));
       } else {
         ca.setAutoVerify(rules.get(0).getGroup());
       }
@@ -576,8 +610,7 @@
    *     type = submitted_changes
    * </pre>
    */
-  private void loadNotifySections(
-      Config rc, Map<String, GroupReference> groupsByName) {
+  private void loadNotifySections(Config rc) {
     notifySections = new HashMap<>();
     for (String sectionName : rc.getSubsections(NOTIFY)) {
       NotifyConfig n = new NotifyConfig();
@@ -585,16 +618,13 @@
       n.setFilter(rc.getString(NOTIFY, sectionName, KEY_FILTER));
 
       EnumSet<NotifyType> types = EnumSet.noneOf(NotifyType.class);
-      types.addAll(ConfigUtil.getEnumList(rc,
-          NOTIFY, sectionName, KEY_TYPE,
-          NotifyType.ALL));
+      types.addAll(ConfigUtil.getEnumList(rc, NOTIFY, sectionName, KEY_TYPE, NotifyType.ALL));
       n.setTypes(types);
-      n.setHeader(rc.getEnum(NOTIFY, sectionName, KEY_HEADER,
-          NotifyConfig.Header.BCC));
+      n.setHeader(rc.getEnum(NOTIFY, sectionName, KEY_HEADER, NotifyConfig.Header.BCC));
 
       for (String dst : rc.getStringList(NOTIFY, sectionName, KEY_EMAIL)) {
-        if (dst.startsWith("group ")) {
-          String groupName = dst.substring(6).trim();
+        String groupName = GroupReference.extractGroupName(dst);
+        if (groupName != null) {
           GroupReference ref = groupsByName.get(groupName);
           if (ref == null) {
             ref = new GroupReference(null, groupName);
@@ -603,8 +633,10 @@
           if (ref.getUUID() != null) {
             n.addEmail(ref);
           } else {
-            error(new ValidationError(PROJECT_CONFIG,
-                "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG,
+                    "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
           }
         } else if (dst.startsWith("user ")) {
           error(new ValidationError(PROJECT_CONFIG, dst + " not supported"));
@@ -612,8 +644,10 @@
           try {
             n.addEmail(Address.parse(dst));
           } catch (IllegalArgumentException err) {
-            error(new ValidationError(PROJECT_CONFIG,
-                "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG,
+                    "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
           }
         }
       }
@@ -621,8 +655,7 @@
     }
   }
 
-  private void loadAccessSections(
-      Config rc, Map<String, GroupReference> groupsByName) {
+  private void loadAccessSections(Config rc) {
     accessSections = new HashMap<>();
     sectionsWithUnknownPermissions = new HashSet<>();
     for (String refName : rc.getSubsections(ACCESS)) {
@@ -631,6 +664,7 @@
 
         for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
           for (String n : varName.split("[, \t]{1,}")) {
+            n = convertLegacyPermission(n);
             if (isPermission(n)) {
               as.getPermission(n, true).setExclusiveGroup(true);
             }
@@ -638,10 +672,17 @@
         }
 
         for (String varName : rc.getNames(ACCESS, refName)) {
-          if (isPermission(varName)) {
-            Permission perm = as.getPermission(varName, true);
-            loadPermissionRules(rc, ACCESS, refName, varName, groupsByName,
-                perm, Permission.hasRange(varName));
+          String convertedName = convertLegacyPermission(varName);
+          if (isPermission(convertedName)) {
+            Permission perm = as.getPermission(convertedName, true);
+            loadPermissionRules(
+                rc,
+                ACCESS,
+                refName,
+                varName,
+                groupsByName,
+                perm,
+                Permission.hasRange(convertedName));
           } else {
             sectionsWithUnknownPermissions.add(as.getName());
           }
@@ -656,8 +697,8 @@
         accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability);
       }
       Permission perm = capability.getPermission(varName, true);
-      loadPermissionRules(rc, CAPABILITY, null, varName, groupsByName, perm,
-          GlobalCapability.hasRange(varName));
+      loadPermissionRules(
+          rc, CAPABILITY, null, varName, groupsByName, perm, GlobalCapability.hasRange(varName));
     }
   }
 
@@ -665,8 +706,7 @@
     try {
       RefPattern.validateRegExp(refPattern);
     } catch (InvalidNameException e) {
-      error(new ValidationError(PROJECT_CONFIG, "Invalid ref name: "
-          + e.getMessage()));
+      error(new ValidationError(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
       return false;
     }
     return true;
@@ -674,13 +714,15 @@
 
   private void loadBranchOrderSection(Config rc) {
     if (rc.getSections().contains(BRANCH_ORDER)) {
-      branchOrderSection = new BranchOrderSection(
-          rc.getStringList(BRANCH_ORDER, null, BRANCH));
+      branchOrderSection = new BranchOrderSection(rc.getStringList(BRANCH_ORDER, null, BRANCH));
     }
   }
 
-  private List<PermissionRule> loadPermissionRules(Config rc, String section,
-      String subsection, String varName,
+  private List<PermissionRule> loadPermissionRules(
+      Config rc,
+      String section,
+      String subsection,
+      String varName,
       Map<String, GroupReference> groupsByName,
       boolean useRange) {
     Permission perm = new Permission(varName);
@@ -688,20 +730,29 @@
     return perm.getRules();
   }
 
-  private void loadPermissionRules(Config rc, String section,
-      String subsection, String varName,
-      Map<String, GroupReference> groupsByName, Permission perm,
+  private void loadPermissionRules(
+      Config rc,
+      String section,
+      String subsection,
+      String varName,
+      Map<String, GroupReference> groupsByName,
+      Permission perm,
       boolean useRange) {
     for (String ruleString : rc.getStringList(section, subsection, varName)) {
       PermissionRule rule;
       try {
         rule = PermissionRule.fromString(ruleString, useRange);
       } catch (IllegalArgumentException notRule) {
-        error(new ValidationError(PROJECT_CONFIG, "Invalid rule in "
-            + section
-            + (subsection != null ? "." + subsection : "")
-            + "." + varName + ": "
-            + notRule.getMessage()));
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                "Invalid rule in "
+                    + section
+                    + (subsection != null ? "." + subsection : "")
+                    + "."
+                    + varName
+                    + ": "
+                    + notRule.getMessage()));
         continue;
       }
 
@@ -713,8 +764,9 @@
         //
         ref = rule.getGroup();
         groupsByName.put(ref.getName(), ref);
-        error(new ValidationError(PROJECT_CONFIG,
-            "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+        error(
+            new ValidationError(
+                PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
       }
 
       rule.setGroup(ref);
@@ -723,16 +775,14 @@
   }
 
   private static LabelValue parseLabelValue(String src) {
-    List<String> parts = ImmutableList.copyOf(
-        Splitter.on(CharMatcher.whitespace()).omitEmptyStrings().limit(2)
-        .split(src));
+    List<String> parts =
+        ImmutableList.copyOf(
+            Splitter.on(CharMatcher.whitespace()).omitEmptyStrings().limit(2).split(src));
     if (parts.isEmpty()) {
       throw new IllegalArgumentException("empty value");
     }
     String valueText = parts.size() > 1 ? parts.get(1) : "";
-    return new LabelValue(
-        Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))),
-        valueText);
+    return new LabelValue(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
   }
 
   private void loadLabelSections(Config rc) {
@@ -741,9 +791,10 @@
     for (String name : rc.getSubsections(LABEL)) {
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
-        error(new ValidationError(PROJECT_CONFIG, String.format(
-            "Label \"%s\" conflicts with \"%s\"",
-            name, lowerNames.get(lower))));
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
       }
       lowerNames.put(lower, name);
 
@@ -752,9 +803,12 @@
         try {
           values.add(parseLabelValue(value));
         } catch (IllegalArgumentException notValue) {
-          error(new ValidationError(PROJECT_CONFIG, String.format(
-              "Invalid %s \"%s\" for label \"%s\": %s",
-              KEY_VALUE, value, name, notValue.getMessage())));
+          error(
+              new ValidationError(
+                  PROJECT_CONFIG,
+                  String.format(
+                      "Invalid %s \"%s\" for label \"%s\": %s",
+                      KEY_VALUE, value, name, notValue.getMessage())));
         }
       }
 
@@ -762,19 +816,21 @@
       try {
         label = new LabelType(name, values);
       } catch (IllegalArgumentException badName) {
-        error(new ValidationError(PROJECT_CONFIG, String.format(
-            "Invalid label \"%s\"", name)));
+        error(new ValidationError(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
         continue;
       }
 
-      String functionName = MoreObjects.firstNonNull(
-          rc.getString(LABEL, name, KEY_FUNCTION), "MaxWithBlock");
+      String functionName =
+          MoreObjects.firstNonNull(rc.getString(LABEL, name, KEY_FUNCTION), "MaxWithBlock");
       if (LABEL_FUNCTIONS.contains(functionName)) {
         label.setFunctionName(functionName);
       } else {
-        error(new ValidationError(PROJECT_CONFIG, String.format(
-            "Invalid %s for label \"%s\". Valid names are: %s",
-            KEY_FUNCTION, name, Joiner.on(", ").join(LABEL_FUNCTIONS))));
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Invalid %s for label \"%s\". Valid names are: %s",
+                    KEY_FUNCTION, name, Joiner.on(", ").join(LABEL_FUNCTIONS))));
         label.setFunctionName(null);
       }
 
@@ -783,33 +839,46 @@
         if (isInRange(dv, values)) {
           label.setDefaultValue(dv);
         } else {
-          error(new ValidationError(PROJECT_CONFIG, String.format(
-              "Invalid %s \"%s\" for label \"%s\"",
-              KEY_DEFAULT_VALUE, dv, name)));
+          error(
+              new ValidationError(
+                  PROJECT_CONFIG,
+                  String.format(
+                      "Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name)));
         }
       }
+      label.setAllowPostSubmit(
+          rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
       label.setCopyMinScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE,
-              LabelType.DEF_COPY_MIN_SCORE));
+          rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
       label.setCopyMaxScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE,
-              LabelType.DEF_COPY_MAX_SCORE));
+          rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE, LabelType.DEF_COPY_MAX_SCORE));
       label.setCopyAllScoresOnMergeFirstParentUpdate(
-          rc.getBoolean(LABEL, name, KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
               LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE));
       label.setCopyAllScoresOnTrivialRebase(
-          rc.getBoolean(LABEL, name, KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
               LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE));
       label.setCopyAllScoresIfNoCodeChange(
-          rc.getBoolean(LABEL, name, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
               LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE));
       label.setCopyAllScoresIfNoChange(
-          rc.getBoolean(LABEL, name, KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
               LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
       label.setCanOverride(
-          rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE,
-              LabelType.DEF_CAN_OVERRIDE));
-      label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_Branch));
+          rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
+      label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_BRANCH));
       labelSections.put(name, label);
     }
   }
@@ -823,8 +892,8 @@
     return false;
   }
 
-  private List<String> getStringListOrNull(Config rc, String section,
-      String subSection, String name) {
+  private List<String> getStringListOrNull(
+      Config rc, String section, String subSection, String name) {
     String[] ac = rc.getStringList(section, subSection, name);
     return ac.length == 0 ? null : Arrays.asList(ac);
   }
@@ -836,16 +905,21 @@
       try {
         commentLinkSections.add(buildCommentLink(rc, name, false));
       } catch (PatternSyntaxException e) {
-        error(new ValidationError(PROJECT_CONFIG, String.format(
-            "Invalid pattern \"%s\" in commentlink.%s.match: %s",
-            rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Invalid pattern \"%s\" in commentlink.%s.match: %s",
+                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
       } catch (IllegalArgumentException e) {
-        error(new ValidationError(PROJECT_CONFIG, String.format(
-            "Error in pattern \"%s\" in commentlink.%s.match: %s",
-            rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Error in pattern \"%s\" in commentlink.%s.match: %s",
+                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
       }
     }
-    commentLinkSections = ImmutableList.copyOf(commentLinkSections);
   }
 
   private void loadSubscribeSections(Config rc) throws ConfigInvalidException {
@@ -855,12 +929,11 @@
       for (String projectName : subsections) {
         Project.NameKey p = new Project.NameKey(projectName);
         SubscribeSection ss = new SubscribeSection(p);
-        for (String s : rc.getStringList(SUBSCRIBE_SECTION,
-            projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
+        for (String s :
+            rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
           ss.addMultiMatchRefSpec(s);
         }
-        for (String s : rc.getStringList(SUBSCRIBE_SECTION,
-            projectName, SUBSCRIBE_MATCH_REFS)) {
+        for (String s : rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MATCH_REFS)) {
           ss.addMatchingRefSpec(s);
         }
         subscribeSections.put(p, ss);
@@ -882,18 +955,18 @@
       pluginConfigs.put(plugin, pluginConfig);
       for (String name : rc.getNames(PLUGIN, plugin)) {
         String value = rc.getString(PLUGIN, plugin, name);
-        if (value.startsWith("Group[")) {
-          GroupReference refFromString = GroupReference.fromString(value);
-          GroupReference ref = groupList.byUUID(refFromString.getUUID());
+        String groupName = GroupReference.extractGroupName(value);
+        if (groupName != null) {
+          GroupReference ref = groupsByName.get(groupName);
           if (ref == null) {
-            ref = refFromString;
-            error(new ValidationError(PROJECT_CONFIG,
-                "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG, "group \"" + groupName + "\" not in " + GroupList.FILE_NAME));
           }
-          rc.setString(PLUGIN, plugin, name, ref.toString());
+          rc.setString(PLUGIN, plugin, name, value);
         }
-        pluginConfig.setStringList(PLUGIN, plugin, name,
-            Arrays.asList(rc.getStringList(PLUGIN, plugin, name)));
+        pluginConfig.setStringList(
+            PLUGIN, plugin, name, Arrays.asList(rc.getStringList(PLUGIN, plugin, name)));
       }
     }
   }
@@ -908,7 +981,7 @@
   }
 
   private void readGroupList() throws IOException {
-    groupList = GroupList.parse(readUTF8(GroupList.FILE_NAME), this);
+    groupList = GroupList.parse(projectName, readUTF8(GroupList.FILE_NAME), this);
   }
 
   private Map<String, GroupReference> mapGroupReferences() {
@@ -922,8 +995,7 @@
   }
 
   @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     if (commit.getMessage() == null || "".equals(commit.getMessage())) {
       commit.setMessage("Updated project configuration\n");
     }
@@ -938,22 +1010,66 @@
     }
     set(rc, ACCESS, null, KEY_INHERIT_FROM, p.getParentName());
 
-    set(rc, RECEIVE, null, KEY_REQUIRE_CONTRIBUTOR_AGREEMENT, p.getUseContributorAgreements(), InheritableBoolean.INHERIT);
-    set(rc, RECEIVE, null, KEY_REQUIRE_SIGNED_OFF_BY, p.getUseSignedOffBy(), InheritableBoolean.INHERIT);
-    set(rc, RECEIVE, null, KEY_REQUIRE_CHANGE_ID, p.getRequireChangeID(), InheritableBoolean.INHERIT);
-    set(rc, RECEIVE, null, KEY_USE_ALL_NOT_IN_TARGET, p.getCreateNewChangeForAllNotInTarget(), InheritableBoolean.INHERIT);
-    set(rc, RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT, validMaxObjectSizeLimit(p.getMaxObjectSizeLimit()));
-    set(rc, RECEIVE, null, KEY_ENABLE_SIGNED_PUSH,
-        p.getEnableSignedPush(), InheritableBoolean.INHERIT);
-    set(rc, RECEIVE, null, KEY_REQUIRE_SIGNED_PUSH,
-        p.getRequireSignedPush(), InheritableBoolean.INHERIT);
-    set(rc, RECEIVE, null, KEY_REJECT_IMPLICIT_MERGES,
-        p.getRejectImplicitMerges(), InheritableBoolean.INHERIT);
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_REQUIRE_CONTRIBUTOR_AGREEMENT,
+        p.getUseContributorAgreements(),
+        InheritableBoolean.INHERIT);
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_REQUIRE_SIGNED_OFF_BY,
+        p.getUseSignedOffBy(),
+        InheritableBoolean.INHERIT);
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_REQUIRE_CHANGE_ID,
+        p.getRequireChangeID(),
+        InheritableBoolean.INHERIT);
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_USE_ALL_NOT_IN_TARGET,
+        p.getCreateNewChangeForAllNotInTarget(),
+        InheritableBoolean.INHERIT);
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_MAX_OBJECT_SIZE_LIMIT,
+        validMaxObjectSizeLimit(p.getMaxObjectSizeLimit()));
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_ENABLE_SIGNED_PUSH,
+        p.getEnableSignedPush(),
+        InheritableBoolean.INHERIT);
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_REQUIRE_SIGNED_PUSH,
+        p.getRequireSignedPush(),
+        InheritableBoolean.INHERIT);
+    set(
+        rc,
+        RECEIVE,
+        null,
+        KEY_REJECT_IMPLICIT_MERGES,
+        p.getRejectImplicitMerges(),
+        InheritableBoolean.INHERIT);
 
-    set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), defaultSubmitAction);
+    set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), DEFAULT_SUBMIT_ACTION);
     set(rc, SUBMIT, null, KEY_MERGE_CONTENT, p.getUseContentMerge(), InheritableBoolean.INHERIT);
 
-    set(rc, PROJECT, null, KEY_STATE, p.getState(), defaultStateValue);
+    set(rc, PROJECT, null, KEY_STATE, p.getState(), DEFAULT_STATE_VALUE);
 
     set(rc, DASHBOARD, null, KEY_DEFAULT, p.getDefaultDashboard());
     set(rc, DASHBOARD, null, KEY_LOCAL_DEFAULT, p.getLocalDefaultDashboard());
@@ -966,6 +1082,7 @@
     savePluginSections(rc, keepGroups);
     groupList.retainUUIDs(keepGroups);
     saveLabelSections(rc);
+    saveCommentLinkSections(rc);
     saveSubscribeSections(rc);
 
     saveConfig(PROJECT_CONFIG, rc);
@@ -973,8 +1090,7 @@
     return true;
   }
 
-  public static final String validMaxObjectSizeLimit(String value)
-      throws ConfigInvalidException {
+  public static final String validMaxObjectSizeLimit(String value) throws ConfigInvalidException {
     if (value == null) {
       return null;
     }
@@ -987,9 +1103,9 @@
     try {
       long s = cfg.getLong("s", "n", 0);
       if (s < 0) {
-        throw new ConfigInvalidException(String.format(
-            "Negative value '%s' not allowed as %s", value,
-            KEY_MAX_OBJECT_SIZE_LIMIT));
+        throw new ConfigInvalidException(
+            String.format(
+                "Negative value '%s' not allowed as %s", value, KEY_MAX_OBJECT_SIZE_LIMIT));
       }
       if (s == 0) {
         // return null for the default so that it is not persisted
@@ -1004,13 +1120,15 @@
 
   private void saveAccountsSection(Config rc, Set<AccountGroup.UUID> keepGroups) {
     if (accountsSection != null) {
-      rc.setStringList(ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY,
+      rc.setStringList(
+          ACCOUNTS,
+          null,
+          KEY_SAME_GROUP_VISIBILITY,
           ruleToStringList(accountsSection.getSameGroupVisibility(), keepGroups));
     }
   }
 
-  private void saveContributorAgreements(
-      Config rc, Set<AccountGroup.UUID> keepGroups) {
+  private void saveContributorAgreements(Config rc, Set<AccountGroup.UUID> keepGroups) {
     for (ContributorAgreement ca : sort(contributorAgreements.values())) {
       set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_DESCRIPTION, ca.getDescription());
       set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AGREEMENT_URL, ca.getAgreementUrl());
@@ -1025,13 +1143,15 @@
         rc.unset(CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY);
       }
 
-      rc.setStringList(CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_ACCEPTED,
+      rc.setStringList(
+          CONTRIBUTOR_AGREEMENT,
+          ca.getName(),
+          KEY_ACCEPTED,
           ruleToStringList(ca.getAccepted(), keepGroups));
     }
   }
 
-  private void saveNotifySections(
-      Config rc, Set<AccountGroup.UUID> keepGroups) {
+  private void saveNotifySections(Config rc, Set<AccountGroup.UUID> keepGroups) {
     for (NotifyConfig nc : sort(notifySections.values())) {
       List<String> email = new ArrayList<>();
       for (GroupReference gr : nc.getGroups()) {
@@ -1049,8 +1169,7 @@
       Collections.sort(addrs);
       email.addAll(addrs);
 
-      set(rc, NOTIFY, nc.getName(), KEY_HEADER,
-          nc.getHeader(), NotifyConfig.Header.BCC);
+      set(rc, NOTIFY, nc.getName(), KEY_HEADER, nc.getHeader(), NotifyConfig.Header.BCC);
       if (email.isEmpty()) {
         rc.unset(NOTIFY, nc.getName(), KEY_EMAIL);
       } else {
@@ -1063,7 +1182,7 @@
         List<String> types = Lists.newArrayListWithCapacity(4);
         for (NotifyType t : NotifyType.values()) {
           if (nc.isNotify(t)) {
-            types.add(StringUtils.toLowerCase(t.name()));
+            types.add(t.name().toLowerCase(Locale.US));
           }
         }
         rc.setStringList(NOTIFY, nc.getName(), KEY_TYPE, types);
@@ -1085,8 +1204,7 @@
     return rules;
   }
 
-  private void saveAccessSections(
-      Config rc, Set<AccountGroup.UUID> keepGroups) {
+  private void saveAccessSections(Config rc, Set<AccountGroup.UUID> keepGroups) {
     AccessSection capability = accessSections.get(AccessSection.GLOBAL_CAPABILITIES);
     if (capability != null) {
       Set<String> have = new HashSet<>();
@@ -1151,7 +1269,8 @@
       }
 
       for (String varName : rc.getNames(ACCESS, refName)) {
-        if (isPermission(varName) && !have.contains(varName.toLowerCase())) {
+        if (isPermission(convertLegacyPermission(varName))
+            && !have.contains(varName.toLowerCase())) {
           rc.unset(ACCESS, refName, varName);
         }
       }
@@ -1181,30 +1300,52 @@
       rc.setString(LABEL, name, KEY_FUNCTION, label.getFunctionName());
       rc.setInt(LABEL, name, KEY_DEFAULT_VALUE, label.getDefaultValue());
 
-      setBooleanConfigKey(rc, name, KEY_COPY_MIN_SCORE, label.isCopyMinScore(),
-          LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(rc, name, KEY_COPY_MAX_SCORE, label.isCopyMaxScore(),
-          LabelType.DEF_COPY_MAX_SCORE);
-      setBooleanConfigKey(rc, name, KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+      setBooleanConfigKey(
+          rc,
+          name,
+          KEY_ALLOW_POST_SUBMIT,
+          label.allowPostSubmit(),
+          LabelType.DEF_ALLOW_POST_SUBMIT);
+      setBooleanConfigKey(
+          rc, name, KEY_COPY_MIN_SCORE, label.isCopyMinScore(), LabelType.DEF_COPY_MIN_SCORE);
+      setBooleanConfigKey(
+          rc, name, KEY_COPY_MAX_SCORE, label.isCopyMaxScore(), LabelType.DEF_COPY_MAX_SCORE);
+      setBooleanConfigKey(
+          rc,
+          name,
+          KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
           label.isCopyAllScoresOnTrivialRebase(),
           LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-      setBooleanConfigKey(rc, name, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+      setBooleanConfigKey(
+          rc,
+          name,
+          KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
           label.isCopyAllScoresIfNoCodeChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-      setBooleanConfigKey(rc, name, KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+      setBooleanConfigKey(
+          rc,
+          name,
+          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
           label.isCopyAllScoresIfNoChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-      setBooleanConfigKey(rc, name, KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+      setBooleanConfigKey(
+          rc,
+          name,
+          KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-      setBooleanConfigKey(rc, name, KEY_CAN_OVERRIDE, label.canOverride(),
-          LabelType.DEF_CAN_OVERRIDE);
-      List<String> values =
-          Lists.newArrayListWithCapacity(label.getValues().size());
+      setBooleanConfigKey(
+          rc, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
+      List<String> values = Lists.newArrayListWithCapacity(label.getValues().size());
       for (LabelValue value : label.getValues()) {
         values.add(value.format());
       }
       rc.setStringList(LABEL, name, KEY_VALUE, values);
+
+      List<String> refPatterns = label.getRefPatterns();
+      if (refPatterns != null && !refPatterns.isEmpty()) {
+        rc.setStringList(LABEL, name, KEY_BRANCH, refPatterns);
+      }
     }
 
     for (String name : toUnset) {
@@ -1212,6 +1353,23 @@
     }
   }
 
+  private void saveCommentLinkSections(Config rc) {
+    if (commentLinkSections != null) {
+      for (CommentLinkInfoImpl cm : commentLinkSections) {
+        rc.setString(COMMENTLINK, cm.name, KEY_MATCH, cm.match);
+        if (!Strings.isNullOrEmpty(cm.html)) {
+          rc.setString(COMMENTLINK, cm.name, KEY_HTML, cm.html);
+        }
+        if (!Strings.isNullOrEmpty(cm.link)) {
+          rc.setString(COMMENTLINK, cm.name, KEY_LINK, cm.link);
+        }
+        if (cm.enabled != null && !cm.enabled) {
+          rc.setBoolean(COMMENTLINK, cm.name, KEY_ENABLED, cm.enabled);
+        }
+      }
+    }
+  }
+
   private static void setBooleanConfigKey(
       Config rc, String name, String key, boolean value, boolean defaultValue) {
     if (value == defaultValue) {
@@ -1232,15 +1390,16 @@
       Config pluginConfig = e.getValue();
       for (String name : pluginConfig.getNames(PLUGIN, plugin)) {
         String value = pluginConfig.getString(PLUGIN, plugin, name);
-        if (value.startsWith("Group[")) {
-          GroupReference ref = resolve(GroupReference.fromString(value));
-          if (ref.getUUID() != null) {
+        String groupName = GroupReference.extractGroupName(value);
+        if (groupName != null) {
+          GroupReference ref = groupsByName.get(groupName);
+          if (ref != null && ref.getUUID() != null) {
             keepGroups.add(ref.getUUID());
-            pluginConfig.setString(PLUGIN, plugin, name, ref.toString());
+            pluginConfig.setString(PLUGIN, plugin, name, "group " + ref.getName());
           }
         }
-        rc.setStringList(PLUGIN, plugin, name,
-            Arrays.asList(pluginConfig.getStringList(PLUGIN, plugin, name)));
+        rc.setStringList(
+            PLUGIN, plugin, name, Arrays.asList(pluginConfig.getStringList(PLUGIN, plugin, name)));
       }
     }
   }
@@ -1256,20 +1415,18 @@
       for (RefSpec r : s.getMatchingRefSpecs()) {
         matchings.add(r.toString());
       }
-      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MATCH_REFS,
-          matchings);
+      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MATCH_REFS, matchings);
 
       List<String> multimatchs = new ArrayList<>();
       for (RefSpec r : s.getMultiMatchRefSpecs()) {
         multimatchs.add(r.toString());
       }
-      rc.setStringList(SUBSCRIBE_SECTION, p.get(),
-          SUBSCRIBE_MULTI_MATCH_REFS, multimatchs);
+      rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MULTI_MATCH_REFS, multimatchs);
     }
   }
 
-  private <E extends Enum<?>> E getEnum(Config rc, String section,
-      String subsection, String name, E defaultValue) {
+  private <E extends Enum<?>> E getEnum(
+      Config rc, String section, String subsection, String name, E defaultValue) {
     try {
       return rc.getEnum(section, subsection, name, defaultValue);
     } catch (IllegalArgumentException err) {
@@ -1291,4 +1448,21 @@
     Collections.sort(r);
     return r;
   }
+
+  public boolean hasLegacyPermissions() {
+    return hasLegacyPermissions;
+  }
+
+  private String convertLegacyPermission(String permissionName) {
+    switch (permissionName) {
+      case LEGACY_PERMISSION_PUSH_TAG:
+        hasLegacyPermissions = true;
+        return Permission.CREATE_TAG;
+      case LEGACY_PERMISSION_PUSH_SIGNED_TAG:
+        hasLegacyPermissions = true;
+        return Permission.CREATE_SIGNED_TAG;
+      default:
+        return permissionName;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectLevelConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectLevelConfig.java
index b4f41a0..2044db0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectLevelConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectLevelConfig.java
@@ -14,17 +14,19 @@
 
 package com.google.gerrit.server.git;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.project.ProjectState;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
 
 /** Configuration file in the projects refs/meta/config branch. */
 public class ProjectLevelConfig extends VersionedMetaData {
@@ -55,6 +57,22 @@
   }
 
   public Config getWithInheritance() {
+    return getWithInheritance(false);
+  }
+
+  /**
+   * Get a Config that includes the values from all parent projects.
+   *
+   * <p>Merging means that matching sections/subsection will be merged to include the values from
+   * both parent and child config.
+   *
+   * <p>No merging means that matching sections/subsections in the child project will replace the
+   * corresponding value from the parent.
+   *
+   * @param merge whether to merge parent values with child values or not.
+   * @return a combined config.
+   */
+  public Config getWithInheritance(boolean merge) {
     Config cfgWithInheritance = new Config();
     try {
       cfgWithInheritance.fromText(get().toText());
@@ -67,18 +85,41 @@
       for (String section : parentCfg.getSections()) {
         Set<String> allNames = get().getNames(section);
         for (String name : parentCfg.getNames(section)) {
+          String[] parentValues = parentCfg.getStringList(section, null, name);
           if (!allNames.contains(name)) {
-            cfgWithInheritance.setStringList(section, null, name,
-                Arrays.asList(parentCfg.getStringList(section, null, name)));
+            cfgWithInheritance.setStringList(section, null, name, Arrays.asList(parentValues));
+          } else if (merge) {
+            cfgWithInheritance.setStringList(
+                section,
+                null,
+                name,
+                Stream.concat(
+                        Arrays.stream(cfg.getStringList(section, null, name)),
+                        Arrays.stream(parentValues))
+                    .sorted()
+                    .distinct()
+                    .collect(toList()));
           }
         }
 
         for (String subsection : parentCfg.getSubsections(section)) {
           allNames = get().getNames(section, subsection);
           for (String name : parentCfg.getNames(section, subsection)) {
+            String[] parentValues = parentCfg.getStringList(section, subsection, name);
             if (!allNames.contains(name)) {
-              cfgWithInheritance.setStringList(section, subsection, name,
-                  Arrays.asList(parentCfg.getStringList(section, subsection, name)));
+              cfgWithInheritance.setStringList(
+                  section, subsection, name, Arrays.asList(parentValues));
+            } else if (merge) {
+              cfgWithInheritance.setStringList(
+                  section,
+                  subsection,
+                  name,
+                  Streams.concat(
+                          Arrays.stream(cfg.getStringList(section, subsection, name)),
+                          Arrays.stream(parentValues))
+                      .sorted()
+                      .distinct()
+                      .collect(toList()));
             }
           }
         }
@@ -88,8 +129,7 @@
   }
 
   @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     if (commit.getMessage() == null || "".equals(commit.getMessage())) {
       commit.setMessage("Updated configuration\n");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java
index 7032878..23d2326 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectRunnable.java
@@ -15,7 +15,7 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 
-/** Used to retrieve the project name from an operation **/
+/** Used to retrieve the project name from an operation * */
 public interface ProjectRunnable extends Runnable {
   Project.NameKey getProjectNameKey();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java
index dffb18a..1666bae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java
@@ -26,8 +26,7 @@
     this.queriesByName = toMap(queriesByName);
   }
 
-  public static QueryList parse(String text, ValidationError.Sink errors)
-      throws IOException {
+  public static QueryList parse(String text, ValidationError.Sink errors) throws IOException {
     return new QueryList(parse(text, FILE_NAME, TRIM, TRIM, errors));
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
index 06b87f2..28425e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
@@ -16,7 +16,8 @@
 
 public interface QueueProvider {
   enum QueueType {
-    INTERACTIVE, BATCH
+    INTERACTIVE,
+    BATCH
   }
 
   WorkQueue.Executor getQueue(QueueType type);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java
deleted file mode 100644
index 32faeac..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReadOnlyRepository.java
+++ /dev/null
@@ -1,180 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import org.eclipse.jgit.attributes.AttributesNodeProvider;
-import org.eclipse.jgit.lib.BaseRepositoryBuilder;
-import org.eclipse.jgit.lib.ObjectDatabase;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefRename;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.ReflogReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.StoredConfig;
-
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-
-class ReadOnlyRepository extends Repository {
-  private static final String MSG =
-      "Cannot modify a " + ReadOnlyRepository.class.getSimpleName();
-
-  private static BaseRepositoryBuilder<?, ?> builder(Repository r) {
-    checkNotNull(r);
-    BaseRepositoryBuilder<?, ?> builder = new BaseRepositoryBuilder<>()
-        .setFS(r.getFS())
-        .setGitDir(r.getDirectory());
-
-    if (!r.isBare()) {
-      builder.setWorkTree(r.getWorkTree())
-          .setIndexFile(r.getIndexFile());
-    }
-    return builder;
-  }
-
-  private final Repository delegate;
-  private final RefDb refdb;
-  private final ObjDb objdb;
-
-  ReadOnlyRepository(Repository delegate) {
-    super(builder(delegate));
-    this.delegate = delegate;
-    this.refdb = new RefDb(delegate.getRefDatabase());
-    this.objdb = new ObjDb(delegate.getObjectDatabase());
-  }
-
-  @Override
-  public void create(boolean bare) throws IOException {
-    throw new UnsupportedOperationException(MSG);
-  }
-
-  @Override
-  public ObjectDatabase getObjectDatabase() {
-    return objdb;
-  }
-
-  @Override
-  public RefDatabase getRefDatabase() {
-    return refdb;
-  }
-
-  @Override
-  public StoredConfig getConfig() {
-    return delegate.getConfig();
-  }
-
-  @Override
-  public AttributesNodeProvider createAttributesNodeProvider() {
-    return delegate.createAttributesNodeProvider();
-  }
-
-  @Override
-  public void scanForRepoChanges() throws IOException {
-    delegate.scanForRepoChanges();
-  }
-
-  @Override
-  public void notifyIndexChanged() {
-    delegate.notifyIndexChanged();
-  }
-
-  @Override
-  public ReflogReader getReflogReader(String refName) throws IOException {
-    return delegate.getReflogReader(refName);
-  }
-
-  private static class RefDb extends RefDatabase {
-    private final RefDatabase delegate;
-
-    private RefDb(RefDatabase delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public void create() throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public void close() {
-      delegate.close();
-    }
-
-    @Override
-    public boolean isNameConflicting(String name) throws IOException {
-      return delegate.isNameConflicting(name);
-    }
-
-    @Override
-    public RefUpdate newUpdate(String name, boolean detach) throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public RefRename newRename(String fromName, String toName)
-        throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public Ref getRef(String name) throws IOException {
-      return delegate.getRef(name);
-    }
-
-    @Override
-    public Map<String, Ref> getRefs(String prefix) throws IOException {
-      return delegate.getRefs(prefix);
-    }
-
-    @Override
-    public List<Ref> getAdditionalRefs() throws IOException {
-      return delegate.getAdditionalRefs();
-    }
-
-    @Override
-    public Ref peel(Ref ref) throws IOException {
-      return delegate.peel(ref);
-    }
-  }
-
-  private static class ObjDb extends ObjectDatabase {
-    private final ObjectDatabase delegate;
-
-    private ObjDb(ObjectDatabase delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public ObjectInserter newInserter() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ObjectReader newReader() {
-      return delegate.newReader();
-    }
-
-    @Override
-    public void close() {
-      delegate.close();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
index ccf876a..94e78f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.strategy.CommitMergeStatus;
-
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -28,23 +29,34 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevFlag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class RebaseSorter {
+  private static final Logger log = LoggerFactory.getLogger(RebaseSorter.class);
+
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
   private final RevCommit initialTip;
   private final Set<RevCommit> alreadyAccepted;
+  private final InternalChangeQuery internalChangeQuery;
 
-  public RebaseSorter(CodeReviewRevWalk rw, RevCommit initialTip,
-      Set<RevCommit> alreadyAccepted, RevFlag canMergeFlag) {
+  public RebaseSorter(
+      CodeReviewRevWalk rw,
+      RevCommit initialTip,
+      Set<RevCommit> alreadyAccepted,
+      RevFlag canMergeFlag,
+      InternalChangeQuery internalChangeQuery) {
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
     this.initialTip = initialTip;
     this.alreadyAccepted = alreadyAccepted;
+    this.internalChangeQuery = internalChangeQuery;
   }
 
-  public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> incoming)
-      throws IOException {
+  public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> incoming) throws IOException {
     final List<CodeReviewCommit> sorted = new ArrayList<>();
     final Set<CodeReviewCommit> sort = new HashSet<>(incoming);
     while (!sort.isEmpty()) {
@@ -60,21 +72,19 @@
       final List<CodeReviewCommit> contents = new ArrayList<>();
       while ((c = rw.next()) != null) {
         if (!c.has(canMergeFlag) || !incoming.contains(c)) {
-          if (isAlreadyMerged(c)) {
+          if (isAlreadyMerged(c, n.change().getDest())) {
             rw.markUninteresting(c);
-            break;
-          }
-          // We cannot merge n as it would bring something we
-          // aren't permitted to merge at this time. Drop n.
-          //
-          if (n.missing == null) {
+          } else {
+            // We cannot merge n as it would bring something we
+            // aren't permitted to merge at this time. Drop n.
+            //
             n.setStatusCode(CommitMergeStatus.MISSING_DEPENDENCY);
-            n.missing = new ArrayList<>();
           }
-          n.missing.add(c);
-        } else {
-          contents.add(c);
+          // Stop RevWalk because c is either a merged commit or a missing
+          // dependency. Not need to walk further.
+          break;
         }
+        contents.add(c);
       }
 
       if (n.getStatusCode() == CommitMergeStatus.MISSING_DEPENDENCY) {
@@ -89,19 +99,33 @@
     return sorted;
   }
 
-  private boolean isAlreadyMerged(CodeReviewCommit commit) throws IOException {
-    try (CodeReviewRevWalk mirw =
-        CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
+  private boolean isAlreadyMerged(CodeReviewCommit commit, Branch.NameKey dest) throws IOException {
+    try (CodeReviewRevWalk mirw = CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
       mirw.reset();
       mirw.markStart(commit);
+      // check if the commit is merged in other branches
       for (RevCommit accepted : alreadyAccepted) {
-        if (mirw.isMergedInto(mirw.parseCommit(commit),
-            mirw.parseCommit(accepted))) {
+        if (mirw.isMergedInto(mirw.parseCommit(commit), mirw.parseCommit(accepted))) {
+          log.debug(
+              "Dependency {} merged into branch head {}.", commit.getName(), accepted.getName());
           return true;
         }
       }
+
+      // check if the commit associated change is merged in the same branch
+      List<ChangeData> changes = internalChangeQuery.byCommit(commit);
+      for (ChangeData change : changes) {
+        if (change.change().getStatus() == Status.MERGED
+            && change.change().getDest().equals(dest)) {
+          log.debug(
+              "Dependency {} associated with merged change {}.", commit.getName(), change.getId());
+          return true;
+        }
+      }
+      return false;
+    } catch (OrmException e) {
+      throw new IOException(e);
     }
-    return false;
   }
 
   private static <T> T removeOne(final Collection<T> c) {
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 49b66331..ae94021 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
@@ -21,6 +21,9 @@
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
+import static java.util.Comparator.comparingInt;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
@@ -30,25 +33,18 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
 
 import com.google.common.base.Function;
-import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.BiMap;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashBiMap;
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
-import com.google.common.collect.SetMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.gerrit.common.Nullable;
@@ -60,12 +56,15 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.extensions.registration.DynamicSet;
 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.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -88,14 +87,12 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -103,6 +100,7 @@
 import com.google.gerrit.server.git.validators.RefOperationValidationException;
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -116,6 +114,10 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.ssh.SshInfo;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.RequestId;
@@ -125,7 +127,24 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -157,30 +176,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.io.StringWriter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
 /** Receives change upload using the Git receive-pack protocol. */
 public class ReceiveCommits {
-  private static final Logger log =
-      LoggerFactory.getLogger(ReceiveCommits.class);
+  private static final Logger log = LoggerFactory.getLogger(ReceiveCommits.class);
 
-  public static final Pattern NEW_PATCHSET = Pattern.compile(
-      "^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/[1-9][0-9]*)?$");
+  public static final Pattern NEW_PATCHSET =
+      Pattern.compile("^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/[1-9][0-9]*)?$");
 
   private static final String COMMAND_REJECTION_MESSAGE_FOOTER =
       "Please read the documentation and contact an administrator\n"
@@ -192,15 +193,20 @@
           + "ensure Change-Ids are unique for each commit";
 
   private enum Error {
-        CONFIG_UPDATE("You are not allowed to perform this operation.\n"
-        + "Configuration changes can only be pushed by project owners\n"
-        + "who also have 'Push' rights on " + RefNames.REFS_CONFIG),
-        UPDATE("You are not allowed to perform this operation.\n"
-        + "To push into this reference you need 'Push' rights."),
-        DELETE("You need 'Push' rights with the 'Force Push'\n"
-            + "flag set to delete references."),
-        DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
-        CODE_REVIEW("You need 'Push' rights to upload code review requests.\n"
+    CONFIG_UPDATE(
+        "You are not allowed to perform this operation.\n"
+            + "Configuration changes can only be pushed by project owners\n"
+            + "who also have 'Push' rights on "
+            + RefNames.REFS_CONFIG),
+    UPDATE(
+        "You are not allowed to perform this operation.\n"
+            + "To push into this reference you need 'Push' rights."),
+    DELETE(
+        "You need 'Delete Reference' rights or 'Push' rights with the \n"
+            + "'Force Push' flag set to delete references."),
+    DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
+    CODE_REVIEW(
+        "You need 'Push' rights to upload code review requests.\n"
             + "Verify that you are pushing to the right branch.");
 
     private final String value;
@@ -220,9 +226,13 @@
 
   public interface MessageSender {
     void sendMessage(String what);
+
     void sendError(String what);
+
     void sendBytes(byte[] what);
+
     void sendBytes(byte[] what, int off, int len);
+
     void flush();
   }
 
@@ -288,9 +298,7 @@
   private final GitReferenceUpdated gitRefUpdated;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
-  private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
-  private final String canonicalWebUrl;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RefOperationValidators.Factory refValidatorsFactory;
   private final TagCache tagCache;
@@ -315,15 +323,15 @@
   private final RequestId receiveId;
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
+  private final ListMultimap<String, String> pushOptions = LinkedListMultimap.create();
 
   private List<CreateRequest> newChanges = Collections.emptyList();
-  private final Map<Change.Id, ReplaceRequest> replaceByChange =
-      new LinkedHashMap<>();
+  private final Map<Change.Id, ReplaceRequest> replaceByChange = new LinkedHashMap<>();
   private final List<UpdateGroupsRequest> updateGroups = new ArrayList<>();
   private final Set<ObjectId> validCommits = new HashSet<>();
 
   private ListMultimap<Change.Id, Ref> refsByChange;
-  private SetMultimap<ObjectId, Ref> refsById;
+  private ListMultimap<ObjectId, Ref> refsById;
   private Map<String, Ref> allRefs;
 
   private final SubmoduleOp.Factory subOpFactory;
@@ -332,6 +340,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final NotesMigration notesMigration;
   private final ChangeEditUtil editUtil;
+  private final ChangeIndexer indexer;
 
   private final List<ValidationMessage> messages = new ArrayList<>();
   private ListMultimap<Error, String> errors = LinkedListMultimap.create();
@@ -341,9 +350,11 @@
   private Task commandProgress;
   private MessageSender messageSender;
   private BatchRefUpdate batch;
+  private final ChangeReportFormatter changeFormatter;
 
   @Inject
-  ReceiveCommits(ReviewDb db,
+  ReceiveCommits(
+      ReviewDb db,
       Sequences seq,
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
@@ -353,14 +364,12 @@
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       ProjectCache projectCache,
-      GitRepositoryManager repoManager,
       TagCache tagCache,
       AccountCache accountCache,
       @Nullable SearchingChangeCacheImpl changeCache,
       ChangeInserter.Factory changeInserterFactory,
       CommitValidators.Factory commitValidatorsFactory,
       RefOperationValidators.Factory refValidatorsFactory,
-      @CanonicalWebUrl String canonicalWebUrl,
       RequestScopePropagator requestScopePropagator,
       SshInfo sshInfo,
       AllProjectsName allProjectsName,
@@ -376,10 +385,13 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       NotesMigration notesMigration,
       ChangeEditUtil editUtil,
+      ChangeIndexer indexer,
       BatchUpdate.Factory batchUpdateFactory,
       SetHashtagsOp.Factory hashtagsFactory,
       ReplaceOp.Factory replaceOpFactory,
-      MergedByPushOp.Factory mergedByPushOpFactory) throws IOException {
+      MergedByPushOp.Factory mergedByPushOpFactory,
+      DynamicItem<ChangeReportFormatter> changeFormatterProvider)
+      throws IOException {
     this.user = projectControl.getUser().asIdentifiedUser();
     this.db = db;
     this.seq = seq;
@@ -391,8 +403,6 @@
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
-    this.repoManager = repoManager;
-    this.canonicalWebUrl = canonicalWebUrl;
     this.tagCache = tagCache;
     this.accountCache = accountCache;
     this.changeInserterFactory = changeInserterFactory;
@@ -423,8 +433,10 @@
     this.notesMigration = notesMigration;
 
     this.editUtil = editUtil;
+    this.indexer = indexer;
 
     this.messageSender = new ReceivePackMessageSender();
+    this.changeFormatter = changeFormatterProvider.get();
 
     ProjectState ps = projectControl.getProjectState();
 
@@ -434,62 +446,61 @@
     rp.setAllowNonFastForwards(true);
     rp.setRefLogIdent(user.newRefLogIdent());
     rp.setTimeout(transferConfig.getTimeout());
-    rp.setMaxObjectSizeLimit(transferConfig.getEffectiveMaxObjectSizeLimit(
-        projectControl.getProjectState()));
+    rp.setMaxObjectSizeLimit(
+        projectControl.getProjectState().getEffectiveMaxObjectSizeLimit().value);
     rp.setCheckReceivedObjects(ps.getConfig().getCheckReceivedObjects());
-    rp.setRefFilter(new RefFilter() {
-      @Override
-      public Map<String, Ref> filter(Map<String, Ref> refs) {
-        Map<String, Ref> filteredRefs =
-            Maps.newHashMapWithExpectedSize(refs.size());
-        for (Map.Entry<String, Ref> e : refs.entrySet()) {
-          String name = e.getKey();
-          if (!name.startsWith(REFS_CHANGES)
-              && !name.startsWith(RefNames.REFS_CACHE_AUTOMERGE)) {
-            filteredRefs.put(name, e.getValue());
+    rp.setRefFilter(
+        new RefFilter() {
+          @Override
+          public Map<String, Ref> filter(Map<String, Ref> refs) {
+            Map<String, Ref> filteredRefs = Maps.newHashMapWithExpectedSize(refs.size());
+            for (Map.Entry<String, Ref> e : refs.entrySet()) {
+              String name = e.getKey();
+              if (!name.startsWith(REFS_CHANGES)
+                  && !name.startsWith(RefNames.REFS_CACHE_AUTOMERGE)) {
+                filteredRefs.put(name, e.getValue());
+              }
+            }
+            return filteredRefs;
           }
-        }
-        return filteredRefs;
-      }
-    });
+        });
 
     if (!projectControl.allRefsAreVisible()) {
-      rp.setCheckReferencedObjectsAreReachable(
-          receiveConfig.checkReferencedObjectsAreReachable);
+      rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
     }
-    rp.setAdvertiseRefsHook(new VisibleRefFilter(tagCache, notesFactory,
-        changeCache, repo, projectControl, db, false));
+    rp.setAdvertiseRefsHook(
+        new VisibleRefFilter(tagCache, notesFactory, changeCache, repo, projectControl, db, false));
     List<AdvertiseRefsHook> advHooks = new ArrayList<>(3);
-    advHooks.add(new AdvertiseRefsHook() {
-      @Override
-      public void advertiseRefs(BaseReceivePack rp)
-          throws ServiceMayNotContinueException {
-        allRefs = rp.getAdvertisedRefs();
-        if (allRefs == null) {
-          try {
-            allRefs = rp.getRepository().getRefDatabase().getRefs(ALL);
-          } catch (ServiceMayNotContinueException e) {
-            throw e;
-          } catch (IOException e) {
-            ServiceMayNotContinueException ex =
-                new ServiceMayNotContinueException();
-            ex.initCause(e);
-            throw ex;
+    advHooks.add(
+        new AdvertiseRefsHook() {
+          @Override
+          public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
+            allRefs = rp.getAdvertisedRefs();
+            if (allRefs == null) {
+              try {
+                allRefs = rp.getRepository().getRefDatabase().getRefs(ALL);
+              } catch (ServiceMayNotContinueException e) {
+                throw e;
+              } catch (IOException e) {
+                ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
+                ex.initCause(e);
+                throw ex;
+              }
+            }
+            rp.setAdvertisedRefs(allRefs, rp.getAdvertisedObjects());
           }
-        }
-        rp.setAdvertisedRefs(allRefs, rp.getAdvertisedObjects());
-      }
 
-      @Override
-      public void advertiseRefs(UploadPack uploadPack) {
-      }
-    });
+          @Override
+          public void advertiseRefs(UploadPack uploadPack) {}
+        });
     advHooks.add(rp.getAdvertiseRefsHook());
-    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(
-        queryProvider, projectControl.getProject().getNameKey()));
+    advHooks.add(
+        new ReceiveCommitsAdvertiseRefsHook(
+            queryProvider, projectControl.getProject().getNameKey()));
     advHooks.add(new HackPushNegotiateHook());
     rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
     rp.setPostReceiveHook(lazyPostReceive.get());
+    rp.setAllowPushOptions(true);
   }
 
   public void init() {
@@ -559,8 +570,7 @@
     }
   }
 
-  void processCommands(Collection<ReceiveCommand> commands,
-      MultiProgressMonitor progress) {
+  void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     newProgress = progress.beginSubTask("new", UNKNOWN);
     replaceProgress = progress.beginSubTask("updated", UNKNOWN);
     closeProgress = progress.beginSubTask("closed", UNKNOWN);
@@ -580,8 +590,7 @@
     logDebug("Executing batch with {} commands", batch.getCommands().size());
     if (!batch.getCommands().isEmpty()) {
       try {
-        if (!batch.isAllowNonFastForwards() && magicBranch != null
-            && magicBranch.edit) {
+        if (!batch.isAllowNonFastForwards() && magicBranch != null && magicBranch.edit) {
           logDebug("Allowing non-fast-forward for edit ref");
           batch.setAllowNonFastForwards(true);
         }
@@ -594,8 +603,7 @@
             cnt++;
           }
         }
-        logError(String.format(
-            "Failed to store %d refs in %s", cnt, project.getName()), err);
+        logError(String.format("Failed to store %d refs in %s", cnt, project.getName()), err);
       }
     }
 
@@ -618,10 +626,7 @@
         String refName = c.getRefName();
         if (c.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
           logDebug("Updating tag cache on fast-forward of {}", c.getRefName());
-          tagCache.updateFastForward(project.getNameKey(),
-              refName,
-              c.getOldId(),
-              c.getNewId());
+          tagCache.updateFastForward(project.getNameKey(), refName, c.getOldId(), c.getNewId());
         }
 
         if (isHead(c) || isConfig(c)) {
@@ -630,8 +635,7 @@
             case UPDATE:
             case UPDATE_NONFASTFORWARD:
               autoCloseChanges(c);
-              branches.add(new Branch.NameKey(project.getNameKey(),
-                  refName));
+              branches.add(new Branch.NameKey(project.getNameKey(), refName));
               break;
 
             case DELETE:
@@ -643,31 +647,31 @@
           logDebug("Reloading project in cache");
           projectCache.evict(project);
           ProjectState ps = projectCache.get(project.getNameKey());
-          repoManager.setProjectDescription(project.getNameKey(), //
-              ps.getProject().getDescription());
+          try {
+            repo.setGitwebDescription(ps.getProject().getDescription());
+          } catch (IOException e) {
+            log.warn("cannot update description of {}", project.getName(), e);
+          }
         }
 
-        if (!MagicBranch.isMagicBranch(refName)
-            && !refName.startsWith(REFS_CHANGES)) {
+        if (!MagicBranch.isMagicBranch(refName)) {
           logDebug("Firing ref update for {}", c.getRefName());
-          // We only fire gitRefUpdated for direct refs updates.
-          // Events for change refs are fired when they are created.
-          //
           gitRefUpdated.fire(project.getNameKey(), c, user.getAccount());
         } else {
-          logDebug("Assuming ref update event for {} has fired",
-              c.getRefName());
+          logDebug("Assuming ref update event for {} has fired", c.getRefName());
         }
       }
     }
 
     // Update superproject gitlinks if required.
-    try (MergeOpRepoManager orm = ormProvider.get()) {
-      orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
-      SubmoduleOp op = subOpFactory.create(branches, orm);
-      op.updateSuperProjects();
-    } catch (SubmoduleException e) {
-      logError("Can't update the superprojects", e);
+    if (!branches.isEmpty()) {
+      try (MergeOpRepoManager orm = ormProvider.get()) {
+        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
+        SubmoduleOp op = subOpFactory.create(branches, orm);
+        op.updateSuperProjects();
+      } catch (SubmoduleException e) {
+        logError("Can't update the superprojects", e);
+      }
     }
 
     closeProgress.end();
@@ -677,39 +681,24 @@
   }
 
   private void reportMessages() {
-    Iterable<CreateRequest> created =
-        Iterables.filter(newChanges, new Predicate<CreateRequest>() {
-          @Override
-          public boolean apply(CreateRequest input) {
-            return input.change != null;
-          }
-        });
-    if (!Iterables.isEmpty(created)) {
+    List<CreateRequest> created =
+        newChanges.stream().filter(r -> r.change != null).collect(toList());
+    if (!created.isEmpty()) {
       addMessage("");
       addMessage("New Changes:");
       for (CreateRequest c : created) {
         addMessage(
-            formatChangeUrl(canonicalWebUrl, c.change, c.change.getSubject(),
-                c.change.getStatus() == Change.Status.DRAFT, false));
+            changeFormatter.newChange(
+                ChangeReportFormatter.Input.builder().setChange(c.change).build()));
       }
       addMessage("");
     }
 
-    List<ReplaceRequest> updated = FluentIterable
-        .from(replaceByChange.values())
-        .filter(new Predicate<ReplaceRequest>() {
-          @Override
-          public boolean apply(ReplaceRequest input) {
-            return !input.skip && input.inputCommand.getResult() == OK;
-          }
-        })
-        .toSortedList(Ordering.natural().onResultOf(
-            new Function<ReplaceRequest, Integer>() {
-              @Override
-              public Integer apply(ReplaceRequest in) {
-                return in.notes.getChangeId().get();
-              }
-            }));
+    List<ReplaceRequest> updated =
+        replaceByChange.values().stream()
+            .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
+            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
+            .collect(toList());
     if (!updated.isEmpty()) {
       addMessage("");
       addMessage("Updated Changes:");
@@ -718,8 +707,7 @@
         String subject;
         if (edit) {
           try {
-            subject =
-                rp.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
+            subject = rp.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
           } catch (IOException e) {
             // Log and fall back to original change subject
             logWarn("failed to get subject for edit patch set", e);
@@ -728,31 +716,20 @@
         } else {
           subject = u.info.getSubject();
         }
-        addMessage(formatChangeUrl(canonicalWebUrl, u.notes.getChange(),
-            subject, u.replaceOp != null && u.replaceOp.getPatchSet().isDraft(),
-            edit));
+
+        ChangeReportFormatter.Input input =
+            ChangeReportFormatter.Input.builder()
+                .setChange(u.notes.getChange())
+                .setSubject(subject)
+                .setIsDraft(u.replaceOp != null && u.replaceOp.getPatchSet().isDraft())
+                .setIsEdit(edit)
+                .build();
+        addMessage(changeFormatter.changeUpdated(input));
       }
       addMessage("");
     }
   }
 
-  private static String formatChangeUrl(String url, Change change,
-      String subject, boolean draft, boolean edit) {
-    StringBuilder m = new StringBuilder()
-        .append("  ")
-        .append(url)
-        .append(change.getChangeId())
-        .append(" ")
-        .append(ChangeUtil.cropSubject(subject));
-    if (draft) {
-      m.append(" [DRAFT]");
-    }
-    if (edit) {
-      m.append(" [EDIT]");
-    }
-    return m.toString();
-  }
-
   private void insertChangesAndPatchSets() {
     int replaceCount = 0;
     int okToInsert = 0;
@@ -769,23 +746,24 @@
         String refName = replace.inputCommand.getRefName();
         checkState(
             NEW_PATCHSET.matcher(refName).matches(),
-            "expected a new patch set command as input when creating %s;"
-                + " got %s",
-            replace.cmd.getRefName(), refName);
+            "expected a new patch set command as input when creating %s; got %s",
+            replace.cmd.getRefName(),
+            refName);
         try {
           logDebug("One-off insertion of patch set for {}", refName);
           replace.insertPatchSetWithoutBatchUpdate();
           replace.inputCommand.setResult(OK);
         } catch (IOException | UpdateException | RestApiException err) {
           reject(replace.inputCommand, "internal server error");
-          logError(String.format(
-              "Cannot add patch set to change %d in project %s",
-              e.getKey().get(), project.getName()), err);
+          logError(
+              String.format(
+                  "Cannot add patch set to change %d in project %s",
+                  e.getKey().get(), project.getName()),
+              err);
         }
       } else if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
         reject(replace.inputCommand, "internal server error");
-        logError(String.format("Replacement for project %s was not attempted",
-            project.getName()));
+        logError(String.format("Replacement for project %s was not attempted", project.getName()));
       }
     }
 
@@ -795,10 +773,12 @@
       logDebug("No magic branch, nothing more to do");
       return;
     } else if (magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
-      logWarn(String.format(
-          "Skipping change updates on %s because ref update failed: %s %s",
-          project.getName(), magicBranch.cmd.getResult(),
-          Strings.nullToEmpty(magicBranch.cmd.getMessage())));
+      logWarn(
+          String.format(
+              "Skipping change updates on %s because ref update failed: %s %s",
+              project.getName(),
+              magicBranch.cmd.getResult(),
+              Strings.nullToEmpty(magicBranch.cmd.getMessage())));
       return;
     }
 
@@ -808,37 +788,44 @@
         okToInsert++;
       } else {
         String createChangeResult =
-            String.format("%s %s",
-                create.cmd.getResult(),
-                Strings.nullToEmpty(create.cmd.getMessage())).trim();
+            String.format(
+                    "%s %s", create.cmd.getResult(), Strings.nullToEmpty(create.cmd.getMessage()))
+                .trim();
         lastCreateChangeErrors.add(createChangeResult);
-        logError(String.format("Command %s on %s:%s not completed: %s",
-            create.cmd.getType(),
-            project.getName(),
-            create.cmd.getRefName(),
-            createChangeResult));
+        logError(
+            String.format(
+                "Command %s on %s:%s not completed: %s",
+                create.cmd.getType(),
+                project.getName(),
+                create.cmd.getRefName(),
+                createChangeResult));
       }
     }
 
-    logDebug("Counted {} ok to insert, out of {} to replace and {} new",
-        okToInsert, replaceCount, newChanges.size());
+    logDebug(
+        "Counted {} ok to insert, out of {} to replace and {} new",
+        okToInsert,
+        replaceCount,
+        newChanges.size());
 
     if (okToInsert != replaceCount + newChanges.size()) {
       // One or more new references failed to create. Assume the
       // system isn't working correctly anymore and abort.
-      reject(magicBranch.cmd, "Unable to create changes: "
-          + Joiner.on(' ').join(lastCreateChangeErrors));
-      logError(String.format(
-          "Only %d of %d new change refs created in %s; aborting",
-          okToInsert, replaceCount + newChanges.size(), project.getName()));
+      reject(
+          magicBranch.cmd,
+          "Unable to create changes: " + lastCreateChangeErrors.stream().collect(joining(" ")));
+      logError(
+          String.format(
+              "Only %d of %d new change refs created in %s; aborting",
+              okToInsert, replaceCount + newChanges.size(), project.getName()));
       return;
     }
 
-    try (BatchUpdate bu = batchUpdateFactory.create(db,
-          magicBranch.dest.getParentKey(), user, TimeUtil.nowTs());
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db, magicBranch.dest.getParentKey(), user.materializedCopy(), TimeUtil.nowTs());
         ObjectInserter ins = repo.newObjectInserter()) {
-      bu.setRepository(repo, rp.getRevWalk(), ins)
-          .updateChangesInParallel();
+      bu.setRepository(repo, rp.getRevWalk(), ins).updateChangesInParallel();
       bu.setRequestId(receiveId);
       for (ReplaceRequest replace : replaceByChange.values()) {
         if (replace.inputCommand == magicBranch.cmd) {
@@ -915,6 +902,18 @@
   }
 
   private void parseCommands(Collection<ReceiveCommand> commands) {
+    List<String> optionList = rp.getPushOptions();
+    if (optionList != null) {
+      for (String option : optionList) {
+        int e = option.indexOf('=');
+        if (e > 0) {
+          pushOptions.put(option.substring(0, e), option.substring(e + 1));
+        } else {
+          pushOptions.put(option, "");
+        }
+      }
+    }
+
     logDebug("Parsing {} commands", commands.size());
     for (ReceiveCommand cmd : commands) {
       if (cmd.getResult() != NOT_ATTEMPTED) {
@@ -923,8 +922,7 @@
         continue;
       }
 
-      if (!Repository.isValidRefName(cmd.getRefName())
-          || cmd.getRefName().contains("//")) {
+      if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
         reject(cmd, "not valid ref");
         continue;
       }
@@ -937,17 +935,16 @@
       if (projectControl.getProjectState().isAllUsers()
           && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
         String newName = RefNames.refsUsers(user.getAccountId());
-        logDebug("Swapping out command for {} to {}",
-            RefNames.REFS_USERS_SELF, newName);
+        logDebug("Swapping out command for {} to {}", RefNames.REFS_USERS_SELF, newName);
         final ReceiveCommand orgCmd = cmd;
-        cmd = new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName,
-            cmd.getType()) {
-          @Override
-          public void setResult(Result s, String m) {
-            super.setResult(s, m);
-            orgCmd.setResult(s, m);
-          }
-        };
+        cmd =
+            new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName, cmd.getType()) {
+              @Override
+              public void setResult(Result s, String m) {
+                super.setResult(s, m);
+                orgCmd.setResult(s, m);
+              }
+            };
       }
 
       Matcher m = NEW_PATCHSET.matcher(cmd.getRefName());
@@ -977,7 +974,7 @@
           break;
 
         default:
-          reject(cmd);
+          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
           continue;
       }
 
@@ -1005,9 +1002,13 @@
                   addError("  " + err.getMessage());
                 }
                 reject(cmd, "invalid project configuration");
-                logError("User " + user.getUserName()
-                    + " tried to push invalid project configuration "
-                    + cmd.getNewId().name() + " for " + project.getName());
+                logError(
+                    "User "
+                        + user.getUserName()
+                        + " tried to push invalid project configuration "
+                        + cmd.getNewId().name()
+                        + " for "
+                        + project.getName());
                 continue;
               }
               Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
@@ -1036,39 +1037,54 @@
                 ProjectConfigEntry configEntry = e.getProvider().get();
                 String value = pluginCfg.getString(e.getExportName());
                 String oldValue =
-                    projectControl.getProjectState().getConfig()
+                    projectControl
+                        .getProjectState()
+                        .getConfig()
                         .getPluginConfig(e.getPluginName())
                         .getString(e.getExportName());
                 if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-                  List<String> l =
-                      Arrays.asList(projectControl.getProjectState()
-                          .getConfig().getPluginConfig(e.getPluginName())
-                          .getStringList(e.getExportName()));
-                  oldValue = Joiner.on("\n").join(l);
+                  oldValue =
+                      Arrays.stream(
+                              projectControl
+                                  .getProjectState()
+                                  .getConfig()
+                                  .getPluginConfig(e.getPluginName())
+                                  .getStringList(e.getExportName()))
+                          .collect(joining("\n"));
                 }
 
-                if ((value == null ? oldValue != null : !value.equals(oldValue)) &&
-                    !configEntry.isEditable(projectControl.getProjectState())) {
-                  reject(cmd, String.format(
-                      "invalid project configuration: Not allowed to set parameter"
-                          + " '%s' of plugin '%s' on project '%s'.",
-                      e.getExportName(), e.getPluginName(), project.getName()));
+                if ((value == null ? oldValue != null : !value.equals(oldValue))
+                    && !configEntry.isEditable(projectControl.getProjectState())) {
+                  reject(
+                      cmd,
+                      String.format(
+                          "invalid project configuration: Not allowed to set parameter"
+                              + " '%s' of plugin '%s' on project '%s'.",
+                          e.getExportName(), e.getPluginName(), project.getName()));
                   continue;
                 }
 
                 if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
-                    && value != null && !configEntry.getPermittedValues().contains(value)) {
-                  reject(cmd, String.format(
-                      "invalid project configuration: The value '%s' is "
-                          + "not permitted for parameter '%s' of plugin '%s'.",
-                      value, e.getExportName(), e.getPluginName()));
+                    && value != null
+                    && !configEntry.getPermittedValues().contains(value)) {
+                  reject(
+                      cmd,
+                      String.format(
+                          "invalid project configuration: The value '%s' is "
+                              + "not permitted for parameter '%s' of plugin '%s'.",
+                          value, e.getExportName(), e.getPluginName()));
                 }
               }
             } catch (Exception e) {
               reject(cmd, "invalid project configuration");
-              logError("User " + user.getUserName()
-                  + " tried to push invalid project configuration "
-                  + cmd.getNewId().name() + " for " + project.getName(), e);
+              logError(
+                  "User "
+                      + user.getUserName()
+                      + " tried to push invalid project configuration "
+                      + cmd.getNewId().name()
+                      + " for "
+                      + project.getName(),
+                  e);
               continue;
             }
             break;
@@ -1077,7 +1093,10 @@
             break;
 
           default:
-            reject(cmd);
+            reject(
+                cmd,
+                "prohibited by Gerrit: don't know how to handle config update of type "
+                    + cmd.getType());
             continue;
         }
       }
@@ -1089,8 +1108,9 @@
     try {
       obj = rp.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for "
-          + cmd.getRefName() + " creation", err);
+      logError(
+          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
+          err);
       reject(cmd, "invalid object");
       return;
     }
@@ -1108,7 +1128,7 @@
       validateNewCommits(ctl, cmd);
       batch.addCommand(cmd);
     } else {
-      reject(cmd);
+      reject(cmd, "prohibited by Gerrit: create access denied for " + cmd.getRefName());
     }
   }
 
@@ -1131,7 +1151,7 @@
       } else {
         errors.put(Error.UPDATE, ctl.getRefName());
       }
-      reject(cmd);
+      reject(cmd, "prohibited by Gerrit: ref update access denied");
     }
   }
 
@@ -1140,8 +1160,7 @@
     try {
       obj = rp.getRevWalk().parseAny(cmd.getNewId());
     } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for "
-          + cmd.getRefName(), err);
+      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
       reject(cmd, "invalid object");
       return false;
     }
@@ -1181,8 +1200,9 @@
     } catch (IncorrectObjectTypeException notCommit) {
       newObject = null;
     } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for "
-          + cmd.getRefName() + " forced update", err);
+      logError(
+          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
+          err);
       reject(cmd, "invalid object");
       return;
     }
@@ -1202,8 +1222,8 @@
       }
       batch.setAllowNonFastForwards(true).addCommand(cmd);
     } else {
-      cmd.setResult(REJECTED_NONFASTFORWARD, " need '"
-          + PermissionRule.FORCE_PUSH + "' privilege.");
+      cmd.setResult(
+          REJECTED_NONFASTFORWARD, " need '" + PermissionRule.FORCE_PUSH + "' privilege.");
     }
   }
 
@@ -1232,19 +1252,39 @@
     @Option(name = "--draft", usage = "mark new/updated changes as draft")
     boolean draft;
 
-    @Option(name = "--edit", aliases = {"-e"}, usage = "upload as change edit")
+    @Option(
+        name = "--edit",
+        aliases = {"-e"},
+        usage = "upload as change edit")
     boolean edit;
 
     @Option(name = "--submit", usage = "immediately submit the change")
     boolean submit;
 
-    @Option(name = "--notify",
-        usage = "Notify handling that defines to whom email notifications "
-            + "should be sent. Allowed values are NONE, OWNER, "
-            + "OWNER_REVIEWERS, ALL. If not set, the default is ALL.")
+    @Option(name = "--merged", usage = "create single change for a merged commit")
+    boolean merged;
+
+    @Option(
+        name = "--notify",
+        usage =
+            "Notify handling that defines to whom email notifications "
+                + "should be sent. Allowed values are NONE, OWNER, "
+                + "OWNER_REVIEWERS, ALL. If not set, the default is ALL.")
     NotifyHandling notify = NotifyHandling.ALL;
 
-    @Option(name = "--reviewer", aliases = {"-r"}, metaVar = "EMAIL",
+    @Option(name = "--notify-to", metaVar = "USER", usage = "user that should be notified")
+    List<Account.Id> tos = new ArrayList<>();
+
+    @Option(name = "--notify-cc", metaVar = "USER", usage = "user that should be CC'd")
+    List<Account.Id> ccs = new ArrayList<>();
+
+    @Option(name = "--notify-bcc", metaVar = "USER", usage = "user that should be BCC'd")
+    List<Account.Id> bccs = new ArrayList<>();
+
+    @Option(
+        name = "--reviewer",
+        aliases = {"-r"},
+        metaVar = "EMAIL",
         usage = "add reviewer to changes")
     void reviewer(Account.Id id) {
       reviewer.add(id);
@@ -1260,27 +1300,36 @@
       draft = !publish;
     }
 
-    @Option(name = "--label", aliases = {"-l"}, metaVar = "LABEL+VALUE",
+    @Option(
+        name = "--label",
+        aliases = {"-l"},
+        metaVar = "LABEL+VALUE",
         usage = "label(s) to assign (defaults to +1 if no value provided")
     void addLabel(String token) throws CmdLineException {
       LabelVote v = LabelVote.parse(token);
       try {
         LabelType.checkName(v.label());
         ApprovalsUtil.checkLabel(labelTypes, v.label(), v.value());
-      } catch (IllegalArgumentException e) {
+      } catch (BadRequestException e) {
         throw clp.reject(e.getMessage());
       }
       labels.put(v.label(), v.value());
     }
 
-    @Option(name = "--message", aliases = {"-m"}, metaVar = "MESSAGE",
+    @Option(
+        name = "--message",
+        aliases = {"-m"},
+        metaVar = "MESSAGE",
         usage = "Comment message to apply to the review")
     void addMessage(final String token) {
       // git push does not allow spaces in refs.
       message = token.replace("_", " ");
     }
 
-    @Option(name = "--hashtag", aliases = {"-t"}, metaVar = "HASHTAG",
+    @Option(
+        name = "--hashtag",
+        aliases = {"-t"},
+        metaVar = "HASHTAG",
         usage = "add hashtag to changes")
     void addHashtag(String token) throws CmdLineException {
       if (!notesMigration.readChanges()) {
@@ -1290,11 +1339,10 @@
       if (!hashtag.isEmpty()) {
         hashtags.add(hashtag);
       }
-      //TODO(dpursehouse): validate hashtags
+      // TODO(dpursehouse): validate hashtags
     }
 
-    MagicBranchInput(ReceiveCommand cmd, LabelTypes labelTypes,
-        NotesMigration notesMigration) {
+    MagicBranchInput(ReceiveCommand cmd, LabelTypes labelTypes, NotesMigration notesMigration) {
       this.cmd = cmd;
       this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
       this.labelTypes = labelTypes;
@@ -1305,14 +1353,26 @@
       return new MailRecipients(reviewer, cc);
     }
 
-    String parse(CmdLineParser clp, Repository repo, Set<String> refs)
-        throws CmdLineException {
-      String ref = RefNames.fullName(
-          MagicBranch.getDestBranchName(cmd.getRefName()));
+    ListMultimap<RecipientType, Account.Id> getAccountsToNotify() {
+      ListMultimap<RecipientType, Account.Id> accountsToNotify =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      accountsToNotify.putAll(RecipientType.TO, tos);
+      accountsToNotify.putAll(RecipientType.CC, ccs);
+      accountsToNotify.putAll(RecipientType.BCC, bccs);
+      return accountsToNotify;
+    }
 
+    String parse(
+        CmdLineParser clp,
+        Repository repo,
+        Set<String> refs,
+        ListMultimap<String, String> pushOptions)
+        throws CmdLineException {
+      String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
+
+      ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
       int optionStart = ref.indexOf('%');
       if (0 < optionStart) {
-        ListMultimap<String, String> options = LinkedListMultimap.create();
         for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
           int e = s.indexOf('=');
           if (0 < e) {
@@ -1321,15 +1381,18 @@
             options.put(s, "");
           }
         }
-        clp.parseOptionMap(options);
         ref = ref.substring(0, optionStart);
       }
 
+      if (!options.isEmpty()) {
+        clp.parseOptionMap(options);
+      }
+
       // Split the destination branch by branch and topic. The topic
       // suffix is entirely optional, so it might not even exist.
       String head = readHEAD(repo);
       int split = ref.length();
-      for (;;) {
+      for (; ; ) {
         String name = ref.substring(0, split);
         if (refs.contains(name) || name.equals(head)) {
           break;
@@ -1347,6 +1410,19 @@
     }
   }
 
+  /**
+   * Gets an unmodifiable view of the pushOptions.
+   *
+   * <p>The collection is empty if the client does not support push options, or if the client did
+   * not send any options.
+   *
+   * @return an unmodifiable view of pushOptions.
+   */
+  @Nullable
+  public ListMultimap<String, String> getPushOptions() {
+    return ImmutableListMultimap.copyOf(pushOptions);
+  }
+
   private void parseMagicBranch(ReceiveCommand cmd) {
     // Permit exactly one new change request per push.
     if (magicBranch != null) {
@@ -1362,8 +1438,9 @@
     String ref;
     CmdLineParser clp = optionParserFactory.create(magicBranch);
     magicBranch.clp = clp;
+
     try {
-      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet());
+      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet(), pushOptions);
     } catch (CmdLineException e) {
       if (!clp.wasHelpRequestedByOption()) {
         logDebug("Invalid branch syntax");
@@ -1380,8 +1457,7 @@
       reject(cmd, "see help");
       return;
     }
-    if (projectControl.getProjectState().isAllUsers()
-        && RefNames.REFS_USERS_SELF.equals(ref)) {
+    if (projectControl.getProjectState().isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
       logDebug("Handling {}", RefNames.REFS_USERS_SELF);
       ref = RefNames.refsUsers(user.getAccountId());
     }
@@ -1408,7 +1484,8 @@
         errors.put(Error.CODE_REVIEW, ref);
         reject(cmd, "draft workflow is disabled");
         return;
-      } else if (projectControl.controlForRef("refs/drafts/" + ref)
+      } else if (projectControl
+          .controlForRef(MagicBranch.NEW_DRAFT_CHANGE + ref)
           .isBlocked(Permission.PUSH)) {
         errors.put(Error.CODE_REVIEW, ref);
         reject(cmd, "cannot upload drafts");
@@ -1427,8 +1504,8 @@
       return;
     }
 
-    if (magicBranch.submit && !projectControl.controlForRef(
-        MagicBranch.NEW_CHANGE + ref).canSubmit()) {
+    if (magicBranch.submit
+        && !projectControl.controlForRef(MagicBranch.NEW_CHANGE + ref).canSubmit(true)) {
       reject(cmd, "submit not allowed");
       return;
     }
@@ -1444,56 +1521,68 @@
       return;
     }
 
-    // If tip is a merge commit, or the root commit or
-    // if %base was specified, ignore newChangeForAllNotInTarget
-    if (tip.getParentCount() > 1
-        || magicBranch.base != null
-        || tip.getParentCount() == 0) {
-      logDebug("Forcing newChangeForAllNotInTarget = false");
-      newChangeForAllNotInTarget = false;
-    }
-
-    if (magicBranch.base != null) {
-      logDebug("Handling %base: {}", magicBranch.base);
-      magicBranch.baseCommit = Lists.newArrayListWithCapacity(
-          magicBranch.base.size());
-      for (ObjectId id : magicBranch.base) {
-        try {
-          magicBranch.baseCommit.add(walk.parseCommit(id));
-        } catch (IncorrectObjectTypeException notCommit) {
-          reject(cmd, "base must be a commit");
+    String destBranch = magicBranch.dest.get();
+    try {
+      if (magicBranch.merged) {
+        if (magicBranch.draft) {
+          reject(cmd, "cannot be draft & merged");
           return;
-        } catch (MissingObjectException e) {
-          reject(cmd, "base not found");
+        }
+        if (magicBranch.base != null) {
+          reject(cmd, "cannot use merged with base");
           return;
-        } catch (IOException e) {
-          logWarn(String.format(
-              "Project %s cannot read %s",
-              project.getName(), id.name()), e);
-          reject(cmd, "internal server error");
+        }
+        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
+        if (branchTip == null) {
+          return; // readBranchTip already rejected cmd.
+        }
+        if (!walk.isMergedInto(tip, branchTip)) {
+          reject(cmd, "not merged into branch");
           return;
         }
       }
-    } else if (newChangeForAllNotInTarget) {
-      logDebug("Handling newChangeForAllNotInTarget");
-      String destBranch = magicBranch.dest.get();
-      try {
-        Ref r = repo.getRefDatabase().exactRef(destBranch);
-        if (r == null) {
-          reject(cmd, destBranch + " not found");
-          return;
-        }
 
-        ObjectId baseHead = r.getObjectId();
-        magicBranch.baseCommit =
-            Collections.singletonList(walk.parseCommit(baseHead));
+      // If tip is a merge commit, or the root commit or
+      // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
+      if (tip.getParentCount() > 1
+          || magicBranch.base != null
+          || magicBranch.merged
+          || tip.getParentCount() == 0) {
+        logDebug("Forcing newChangeForAllNotInTarget = false");
+        newChangeForAllNotInTarget = false;
+      }
+
+      if (magicBranch.base != null) {
+        logDebug("Handling %base: {}", magicBranch.base);
+        magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
+        for (ObjectId id : magicBranch.base) {
+          try {
+            magicBranch.baseCommit.add(walk.parseCommit(id));
+          } catch (IncorrectObjectTypeException notCommit) {
+            reject(cmd, "base must be a commit");
+            return;
+          } catch (MissingObjectException e) {
+            reject(cmd, "base not found");
+            return;
+          } catch (IOException e) {
+            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
+            reject(cmd, "internal server error");
+            return;
+          }
+        }
+      } else if (newChangeForAllNotInTarget) {
+        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
+        if (branchTip == null) {
+          return; // readBranchTip already rejected cmd.
+        }
+        magicBranch.baseCommit = Collections.singletonList(branchTip);
         logDebug("Set baseCommit = {}", magicBranch.baseCommit.get(0).name());
-      } catch (IOException ex) {
-        logWarn(String.format("Project %s cannot read %s", project.getName(),
-            destBranch), ex);
-        reject(cmd, "internal server error");
-        return;
       }
+    } catch (IOException ex) {
+      logWarn(
+          String.format("Error walking to %s in project %s", destBranch, project.getName()), ex);
+      reject(cmd, "internal server error");
+      return;
     }
 
     // Validate that the new commits are connected with the target
@@ -1540,6 +1629,15 @@
     }
   }
 
+  private RevCommit readBranchTip(ReceiveCommand cmd, Branch.NameKey branch) throws IOException {
+    Ref r = allRefs.get(branch.get());
+    if (r == null) {
+      reject(cmd, branch.get() + " not found");
+      return null;
+    }
+    return rp.getRevWalk().parseCommit(r.getObjectId());
+  }
+
   private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
     logDebug("Parsing replace command");
     if (cmd.getType() != ReceiveCommand.Type.CREATE) {
@@ -1559,16 +1657,15 @@
 
     Change changeEnt;
     try {
-      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId)
-          .getChange();
-    } catch (OrmException e) {
-      logError("Cannot lookup existing change " + changeId, e);
-      reject(cmd, "database error");
-      return;
+      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
     } catch (NoSuchChangeException e) {
       logError("Change not found " + changeId, e);
       reject(cmd, "change " + changeId + " not found");
       return;
+    } catch (OrmException e) {
+      logError("Cannot lookup existing change " + changeId, e);
+      reject(cmd, "database error");
+      return;
     }
     if (!project.getNameKey().equals(changeEnt.getProject())) {
       reject(cmd, "change " + changeId + " does not belong to project " + project.getName());
@@ -1579,15 +1676,17 @@
     requestReplace(cmd, true, changeEnt, newCommit);
   }
 
-  private boolean requestReplace(ReceiveCommand cmd, boolean checkMergedInto,
-      Change change, RevCommit newCommit) {
+  private boolean requestReplace(
+      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
     if (change.getStatus().isClosed()) {
-      reject(cmd, "change " + canonicalWebUrl + change.getId() + " closed");
+      reject(
+          cmd,
+          changeFormatter.changeClosed(
+              ChangeReportFormatter.Input.builder().setChange(change).build()));
       return false;
     }
 
-    ReplaceRequest req =
-        new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
+    ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
     if (replaceByChange.containsKey(req.ontoChange)) {
       reject(cmd, "duplicate request");
       return false;
@@ -1600,43 +1699,28 @@
     logDebug("Finding new and replaced changes");
     newChanges = new ArrayList<>();
 
-    SetMultimap<ObjectId, Ref> existing = changeRefsById();
-    GroupCollector groupCollector = GroupCollector.create(changeRefsById(), db, psUtil,
-        notesFactory, project.getNameKey());
+    ListMultimap<ObjectId, Ref> existing = changeRefsById();
+    GroupCollector groupCollector =
+        GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
 
-    rp.getRevWalk().reset();
-    rp.getRevWalk().sort(RevSort.TOPO);
-    rp.getRevWalk().sort(RevSort.REVERSE, true);
     try {
-      RevCommit start = rp.getRevWalk().parseCommit(magicBranch.cmd.getNewId());
-      rp.getRevWalk().markStart(start);
-      if (magicBranch.baseCommit != null) {
-        logDebug("Marking {} base commits uninteresting",
-            magicBranch.baseCommit.size());
-        for (RevCommit c : magicBranch.baseCommit) {
-          rp.getRevWalk().markUninteresting(c);
-        }
-        Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
-        if (targetRef != null) {
-          logDebug("Marking target ref {} ({}) uninteresting",
-              magicBranch.ctl.getRefName(), targetRef.getObjectId().name());
-          rp.getRevWalk().markUninteresting(
-              rp.getRevWalk().parseCommit(targetRef.getObjectId()));
-        }
-      } else {
-        markHeadsAsUninteresting(
-            rp.getRevWalk(),
-            magicBranch.ctl != null ? magicBranch.ctl.getRefName() : null);
+      RevCommit start = setUpWalkForSelectingChanges();
+      if (start == null) {
+        return;
       }
 
-      List<ChangeLookup> pending = new ArrayList<>();
+      LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
       Set<Change.Key> newChangeIds = new HashSet<>();
-      int maxBatchChanges =
-          receiveConfig.getEffectiveMaxBatchChangesLimit(user);
+      int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
       int total = 0;
       int alreadyTracked = 0;
-      boolean rejectImplicitMerges = start.getParentCount() == 1
-          && projectCache.get(project.getNameKey()).isRejectImplicitMerges();
+      boolean rejectImplicitMerges =
+          start.getParentCount() == 1
+              && projectCache.get(project.getNameKey()).isRejectImplicitMerges()
+              // Don't worry about implicit merges when creating changes for
+              // already-merged commits; they're already in history, so it's too
+              // late.
+              && !magicBranch.merged;
       Set<RevCommit> mergedParents;
       if (rejectImplicitMerges) {
         mergedParents = new HashSet<>();
@@ -1644,7 +1728,7 @@
         mergedParents = null;
       }
 
-      for (;;) {
+      for (; ; ) {
         RevCommit c = rp.getRevWalk().next();
         if (c == null) {
           break;
@@ -1656,13 +1740,12 @@
         Collection<Ref> existingRefs = existing.get(c);
 
         if (rejectImplicitMerges) {
-          for (RevCommit p : c.getParents()) {
-            mergedParents.add(p);
-          }
+          Collections.addAll(mergedParents, c.getParents());
           mergedParents.remove(c);
         }
 
-        if (!existingRefs.isEmpty()) { // Commit is already tracked.
+        boolean commitAlreadyTracked = !existingRefs.isEmpty();
+        if (commitAlreadyTracked) {
           alreadyTracked++;
           // Corner cases where an existing commit might need a new group:
           // A) Existing commit has a null group; wasn't assigned during schema
@@ -1683,12 +1766,43 @@
           if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
             continue;
           }
-          logDebug("Creating new change for {} even though it is already tracked",
-              name);
         }
 
-        if (!validCommit(
-            rp.getRevWalk(), magicBranch.ctl, magicBranch.cmd, c)) {
+        List<String> idList = c.getFooterLines(CHANGE_ID);
+
+        String idStr = !idList.isEmpty() ? idList.get(idList.size() - 1).trim() : null;
+
+        if (idStr != null) {
+          pending.put(c, new ChangeLookup(c, new Change.Key(idStr)));
+        } else {
+          pending.put(c, new ChangeLookup(c));
+        }
+        int n = pending.size() + newChanges.size();
+        if (maxBatchChanges != 0 && n > maxBatchChanges) {
+          logDebug("{} changes exceeds limit of {}", n, maxBatchChanges);
+          reject(
+              magicBranch.cmd,
+              "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        if (commitAlreadyTracked) {
+          boolean changeExistsOnDestBranch = false;
+          for (ChangeData cd : pending.get(c).destChanges) {
+            if (cd.change().getDest().equals(magicBranch.dest)) {
+              changeExistsOnDestBranch = true;
+              break;
+            }
+          }
+          if (changeExistsOnDestBranch) {
+            continue;
+          }
+
+          logDebug("Creating new change for {} even though it is already tracked", name);
+        }
+
+        if (!validCommit(rp.getRevWalk(), magicBranch.ctl, magicBranch.cmd, c)) {
           // Not a change the user can propose? Abort as early as possible.
           newChanges = Collections.emptyList();
           logDebug("Aborting early due to invalid commit");
@@ -1697,43 +1811,38 @@
 
         // Don't allow merges to be uploaded in commit chain via all-not-in-target
         if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
-          reject(magicBranch.cmd,
+          reject(
+              magicBranch.cmd,
               "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
-            + "to override please set the base manually");
-          logDebug("Rejecting merge commit {} with newChangeForAllNotInTarget",
-              name);
+                  + "to override please set the base manually");
+          logDebug("Rejecting merge commit {} with newChangeForAllNotInTarget", name);
           // TODO(dborowitz): Should we early return here?
         }
 
-        List<String> idList = c.getFooterLines(CHANGE_ID);
         if (idList.isEmpty()) {
           newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
           continue;
         }
-
-        String idStr = idList.get(idList.size() - 1).trim();
-        pending.add(new ChangeLookup(c, new Change.Key(idStr)));
-        int n = pending.size() + newChanges.size();
-        if (maxBatchChanges != 0 && n > maxBatchChanges) {
-          logDebug("{} changes exceeds limit of {}", n, maxBatchChanges);
-          reject(magicBranch.cmd,
-              "the number of pushed changes in a batch exceeds the max limit "
-                  + maxBatchChanges);
-          newChanges = Collections.emptyList();
-          return;
-        }
       }
-      logDebug("Finished initial RevWalk with {} commits total: {} already"
-          + " tracked, {} new changes with no Change-Id, and {} deferred"
-          + " lookups", total, alreadyTracked, newChanges.size(),
+      logDebug(
+          "Finished initial RevWalk with {} commits total: {} already"
+              + " tracked, {} new changes with no Change-Id, and {} deferred"
+              + " lookups",
+          total,
+          alreadyTracked,
+          newChanges.size(),
           pending.size());
 
       if (rejectImplicitMerges) {
         rejectImplicitMerges(mergedParents);
       }
 
-      for (Iterator<ChangeLookup> itr = pending.iterator(); itr.hasNext();) {
+      for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
         ChangeLookup p = itr.next();
+        if (p.changeKey == null) {
+          continue;
+        }
+
         if (newChangeIds.contains(p.changeKey)) {
           logDebug("Multiple commits with Change-Id {}", p.changeKey);
           reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
@@ -1743,19 +1852,15 @@
 
         List<ChangeData> changes = p.destChanges;
         if (changes.size() > 1) {
-          logDebug("Multiple changes in project with Change-Id {}: {}",
-              p.changeKey, Lists.transform(
-                  changes,
-                  new Function<ChangeData, String>() {
-                    @Override
-                    public String apply(ChangeData in) {
-                      return in.getId().toString();
-                    }
-                  }));
-          // WTF, multiple changes in this project have the same key?
+          logDebug(
+              "Multiple changes in branch {} with Change-Id {}: {}",
+              magicBranch.dest,
+              p.changeKey,
+              changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
+          // WTF, multiple changes in this branch have the same key?
           // Since the commit is new, the user should recreate it with
           // a different Change-Id. In practice, we should never see
-          // this error message as Change-Id should be unique.
+          // this error message as Change-Id should be unique per branch.
           //
           reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
           newChanges = Collections.emptyList();
@@ -1780,8 +1885,7 @@
               continue;
             }
           }
-          if (requestReplace(
-              magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
+          if (requestReplace(magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
             continue;
           }
           newChanges = Collections.emptyList();
@@ -1795,12 +1899,25 @@
             return;
           }
 
+          // In case the change look up from the index failed,
+          // double check against the existing refs
+          if (foundInExistingRef(existing.get(p.commit))) {
+            if (pending.size() == 1) {
+              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+              newChanges = Collections.emptyList();
+              return;
+            }
+            itr.remove();
+            continue;
+          }
           newChangeIds.add(p.changeKey);
         }
         newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
       }
-      logDebug("Finished deferred lookups with {} updates and {} new changes",
-          replaceByChange.size(), newChanges.size());
+      logDebug(
+          "Finished deferred lookups with {} updates and {} new changes",
+          replaceByChange.size(),
+          newChanges.size());
     } catch (IOException e) {
       // Should never happen, the core receive process would have
       // identified the missing object earlier before we got control.
@@ -1841,15 +1958,66 @@
         update.groups = ImmutableList.copyOf((groups.get(update.commit)));
       }
       logDebug("Finished updating groups from GroupCollector");
-    } catch (OrmException | NoSuchChangeException e) {
+    } catch (OrmException e) {
       logError("Error collecting groups for changes", e);
       reject(magicBranch.cmd, "internal server error");
       return;
     }
   }
 
-  private void rejectImplicitMerges(Set<RevCommit> mergedParents)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+  private boolean foundInExistingRef(Collection<Ref> existingRefs) throws OrmException {
+    for (Ref ref : existingRefs) {
+      ChangeNotes notes =
+          notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
+      Change change = notes.getChange();
+      if (change.getDest().equals(magicBranch.dest)) {
+        logDebug("Found change {} from existing refs.", change.getKey());
+        // Reindex the change asynchronously, ignoring errors.
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private RevCommit setUpWalkForSelectingChanges() throws IOException {
+    RevWalk rw = rp.getRevWalk();
+    RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
+
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+    rp.getRevWalk().markStart(start);
+    if (magicBranch.baseCommit != null) {
+      markExplicitBasesUninteresting();
+    } else if (magicBranch.merged) {
+      logDebug("Marking parents of merged commit {} uninteresting", start.name());
+      for (RevCommit c : start.getParents()) {
+        rw.markUninteresting(c);
+      }
+    } else {
+      markHeadsAsUninteresting(rw, magicBranch.ctl != null ? magicBranch.ctl.getRefName() : null);
+    }
+    return start;
+  }
+
+  private void markExplicitBasesUninteresting() throws IOException {
+    logDebug("Marking {} base commits uninteresting", magicBranch.baseCommit.size());
+    for (RevCommit c : magicBranch.baseCommit) {
+      rp.getRevWalk().markUninteresting(c);
+    }
+    Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
+    if (targetRef != null) {
+      logDebug(
+          "Marking target ref {} ({}) uninteresting",
+          magicBranch.ctl.getRefName(),
+          targetRef.getObjectId().name());
+      rp.getRevWalk().markUninteresting(rp.getRevWalk().parseCommit(targetRef.getObjectId()));
+    }
+  }
+
+  private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
     if (!mergedParents.isEmpty()) {
       Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
       if (targetRef != null) {
@@ -1869,10 +2037,13 @@
           RevCommit c;
           while ((c = rw.next()) != null) {
             rw.parseBody(c);
-            messages.add(new CommitValidationMessage(
-                "ERROR: Implicit Merge of " + c.abbreviate(7).name()
-                + " " + c.getShortMessage(), false));
-
+            messages.add(
+                new CommitValidationMessage(
+                    "ERROR: Implicit Merge of "
+                        + c.abbreviate(7).name()
+                        + " "
+                        + c.getShortMessage(),
+                    false));
           }
           reject(magicBranch.cmd, "implicit merges detected");
         }
@@ -1889,8 +2060,7 @@
           rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
           i++;
         } catch (IOException e) {
-          logWarn(String.format("Invalid ref %s in %s",
-              ref.getName(), project.getName()), e);
+          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
         }
       }
     }
@@ -1911,6 +2081,12 @@
       changeKey = key;
       destChanges = queryProvider.get().byBranchKey(magicBranch.dest, key);
     }
+
+    ChangeLookup(RevCommit c) throws OrmException {
+      commit = c;
+      destChanges = queryProvider.get().byBranchCommit(magicBranch.dest, c.getName());
+      changeKey = null;
+    }
   }
 
   private class CreateRequest {
@@ -1931,14 +2107,19 @@
 
     private void setChangeId(int id) {
       changeId = new Change.Id(id);
-      ins = changeInserterFactory.create(changeId, commit, refName)
-          .setDraft(magicBranch.draft)
-          .setTopic(magicBranch.topic)
-          // Changes already validated in validateNewCommits.
-          .setValidatePolicy(CommitValidators.Policy.NONE);
-      cmd = new ReceiveCommand(ObjectId.zeroId(), commit,
-          ins.getPatchSetId().toRefName());
-      ins.setUpdateRefCommand(cmd);
+      ins =
+          changeInserterFactory
+              .create(changeId, commit, refName)
+              .setTopic(magicBranch.topic)
+              // Changes already validated in validateNewCommits.
+              .setValidatePolicy(CommitValidators.Policy.NONE);
+
+      if (magicBranch.draft) {
+        ins.setDraft(magicBranch.draft);
+      } else if (magicBranch.merged) {
+        ins.setStatus(Change.Status.MERGED);
+      }
+      cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
       if (rp.getPushCertificate() != null) {
         ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
       }
@@ -1957,37 +2138,38 @@
         checkNotNull(magicBranch);
         recipients.add(magicBranch.getMailRecipients());
         approvals = magicBranch.labels;
-        recipients.add(getRecipientsFromFooters(
-            db, accountResolver, magicBranch.draft, footerLines));
+        recipients.add(
+            getRecipientsFromFooters(db, accountResolver, magicBranch.draft, footerLines));
         recipients.remove(me);
-        StringBuilder msg = new StringBuilder(
-            ApprovalsUtil.renderMessageWithApprovals(
-                psId.get(), approvals,
-                Collections.<String, PatchSetApproval> emptyMap()));
+        StringBuilder msg =
+            new StringBuilder(
+                ApprovalsUtil.renderMessageWithApprovals(
+                    psId.get(), approvals, Collections.<String, PatchSetApproval>emptyMap()));
         msg.append('.');
         if (!Strings.isNullOrEmpty(magicBranch.message)) {
           msg.append("\n").append(magicBranch.message);
         }
 
-        bu.insertChange(ins
-            .setReviewers(recipients.getReviewers())
-            .setExtraCC(recipients.getCcOnly())
-            .setApprovals(approvals)
-            .setMessage(msg.toString())
-            .setNotify(magicBranch.notify)
-            .setRequestScopePropagator(requestScopePropagator)
-            .setSendMail(true)
-            .setUpdateRef(true));
+        bu.insertChange(
+            ins.setReviewers(recipients.getReviewers())
+                .setExtraCC(recipients.getCcOnly())
+                .setApprovals(approvals)
+                .setMessage(msg.toString())
+                .setNotify(magicBranch.notify)
+                .setAccountsToNotify(magicBranch.getAccountsToNotify())
+                .setRequestScopePropagator(requestScopePropagator)
+                .setSendMail(true)
+                .setUpdateRef(false)
+                .setPatchSetDescription(magicBranch.message));
         if (!magicBranch.hashtags.isEmpty()) {
           bu.addOp(
               changeId,
-              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags))
-                .setFireEvent(false));
+              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
         }
         if (!Strings.isNullOrEmpty(magicBranch.topic)) {
           bu.addOp(
               changeId,
-              new BatchUpdate.Op() {
+              new BatchUpdateOp() {
                 @Override
                 public boolean updateChange(ChangeContext ctx) {
                   ctx.getUpdate(psId).setTopic(magicBranch.topic);
@@ -1995,13 +2177,15 @@
                 }
               });
         }
-        bu.addOp(changeId, new BatchUpdate.Op() {
-          @Override
-          public boolean updateChange(ChangeContext ctx) {
-            change = ctx.getChange();
-            return false;
-          }
-        });
+        bu.addOp(
+            changeId,
+            new BatchUpdateOp() {
+              @Override
+              public boolean updateChange(ChangeContext ctx) {
+                change = ctx.getChange();
+                return false;
+              }
+            });
         bu.addOp(changeId, new ChangeProgressOp(newProgress));
       } catch (Exception e) {
         throw INSERT_EXCEPTION.apply(e);
@@ -2009,35 +2193,30 @@
     }
   }
 
-  private void submit(
-      Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
+  private void submit(Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
       throws OrmException, RestApiException {
-    Map<ObjectId, Change> bySha =
-        Maps.newHashMapWithExpectedSize(create.size() + replace.size());
+    Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
     for (CreateRequest r : create) {
-      checkNotNull(r.change,
-          "cannot submit new change %s; op may not have run", r.changeId);
+      checkNotNull(r.change, "cannot submit new change %s; op may not have run", r.changeId);
       bySha.put(r.commit, r.change);
     }
     for (ReplaceRequest r : replace) {
       bySha.put(r.newCommitId, r.notes.getChange());
     }
     Change tipChange = bySha.get(magicBranch.cmd.getNewId());
-    checkNotNull(tipChange,
-        "tip of push does not correspond to a change; found these changes: %s",
-        bySha);
-    logDebug("Processing submit with tip change {} ({})",
-        tipChange.getId(), magicBranch.cmd.getNewId());
-    try (MergeOp op  = mergeOpProvider.get()) {
-      op.merge(db, tipChange, user, false, new SubmitInput());
+    checkNotNull(
+        tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
+    logDebug(
+        "Processing submit with tip change {} ({})", tipChange.getId(), magicBranch.cmd.getNewId());
+    try (MergeOp op = mergeOpProvider.get()) {
+      op.merge(db, tipChange, user, false, new SubmitInput(), false);
     }
   }
 
   private void preparePatchSetsForReplace() {
     try {
       readChangesForReplace();
-      for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator();
-          itr.hasNext();) {
+      for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
         ReplaceRequest req = itr.next();
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.validate(false);
@@ -2047,18 +2226,20 @@
         }
       }
     } catch (OrmException err) {
-      logError(String.format(
-          "Cannot read database before replacement for project %s",
-          project.getName()), err);
+      logError(
+          String.format(
+              "Cannot read database before replacement for project %s", project.getName()),
+          err);
       for (ReplaceRequest req : replaceByChange.values()) {
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
     } catch (IOException err) {
-      logError(String.format(
-          "Cannot read repository before replacement for project %s",
-          project.getName()), err);
+      logError(
+          String.format(
+              "Cannot read repository before replacement for project %s", project.getName()),
+          err);
       for (ReplaceRequest req : replaceByChange.values()) {
         if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
@@ -2092,15 +2273,7 @@
   private void readChangesForReplace() throws OrmException {
     Collection<ChangeNotes> allNotes =
         notesFactory.create(
-            db,
-            Collections2.transform(
-                replaceByChange.values(),
-                new Function<ReplaceRequest, Change.Id>() {
-                  @Override
-                  public Change.Id apply(ReplaceRequest in) {
-                    return in.ontoChange;
-                  }
-                }));
+            db, replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
     for (ChangeNotes notes : allNotes) {
       replaceByChange.get(notes.getChangeId()).notes = notes;
     }
@@ -2123,8 +2296,8 @@
     List<String> groups = ImmutableList.of();
     private ReplaceOp replaceOp;
 
-    ReplaceRequest(Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd,
-        boolean checkMergedInto) {
+    ReplaceRequest(
+        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
       this.ontoChange = toChange;
       this.newCommitId = newCommit.copy();
       this.inputCommand = cmd;
@@ -2134,29 +2307,29 @@
       for (Ref ref : refs(toChange)) {
         try {
           revisions.forcePut(
-              rp.getRevWalk().parseCommit(ref.getObjectId()),
-              PatchSet.Id.fromRef(ref.getName()));
+              rp.getRevWalk().parseCommit(ref.getObjectId()), PatchSet.Id.fromRef(ref.getName()));
         } catch (IOException err) {
-          logWarn(String.format(
-              "Project %s contains invalid change ref %s",
-              project.getName(), ref.getName()), err);
+          logWarn(
+              String.format(
+                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
+              err);
         }
       }
     }
 
     /**
      * Validate the new patch set commit for this change.
-     * <p>
-     * <strong>Side effects:</strong>
+     *
+     * <p><strong>Side effects:</strong>
+     *
      * <ul>
-     *   <li>May add error or warning messages to the progress monitor</li>
-     *   <li>Will reject {@code cmd} prior to returning false</li>
-     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a
-     *     walk.</li>
+     *   <li>May add error or warning messages to the progress monitor
+     *   <li>Will reject {@code cmd} prior to returning false
+     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a walk.
      * </ul>
      *
-     * @param autoClose whether the caller intends to auto-close the change
-     *     after adding a new patch set.
+     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
+     *     set.
      * @return whether the new commit is valid
      * @throws IOException
      * @throws OrmException
@@ -2184,8 +2357,7 @@
         if (changeCtl.isPatchSetLocked(db)) {
           locked = ". Change is patch set locked.";
         }
-        reject(inputCommand, "cannot add patch set to "
-            + ontoChange + locked);
+        reject(inputCommand, "cannot add patch set to " + ontoChange + locked);
         return false;
       } else if (notes.getChange().getStatus().isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
@@ -2195,8 +2367,7 @@
         return false;
       }
 
-      for (Ref r : rp.getRepository().getRefDatabase()
-          .getRefs("refs/changes").values()) {
+      for (Ref r : rp.getRepository().getRefDatabase().getRefs("refs/changes").values()) {
         if (r.getObjectId().equals(newCommit)) {
           reject(inputCommand, "commit already exists (in the project)");
           return false;
@@ -2213,8 +2384,7 @@
         }
       }
 
-      if (!validCommit(rp.getRevWalk(), changeCtl.getRefControl(), inputCommand,
-            newCommit)) {
+      if (!validCommit(rp.getRevWalk(), changeCtl.getRefControl(), inputCommand, newCommit)) {
         return false;
       }
       rp.getRevWalk().parseBody(priorCommit);
@@ -2223,17 +2393,16 @@
       // or no parents were updated (rebase), else warn that only part
       // of the commit was modified.
       if (newCommit.getTree().equals(priorCommit.getTree())) {
-        boolean messageEq =
-            eq(newCommit.getFullMessage(), priorCommit.getFullMessage());
+        boolean messageEq = eq(newCommit.getFullMessage(), priorCommit.getFullMessage());
         boolean parentsEq = parentsEqual(newCommit, priorCommit);
         boolean authorEq = authorEqual(newCommit, priorCommit);
         ObjectReader reader = rp.getRevWalk().getObjectReader();
 
         if (messageEq && parentsEq && authorEq && !autoClose) {
-          addMessage(String.format(
-              "(W) No changes between prior commit %s and new commit %s",
-              reader.abbreviate(priorCommit).name(),
-              reader.abbreviate(newCommit).name()));
+          addMessage(
+              String.format(
+                  "(W) No changes between prior commit %s and new commit %s",
+                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
         } else {
           StringBuilder msg = new StringBuilder();
           msg.append("(I) ");
@@ -2275,16 +2444,14 @@
       if (edit.isPresent()) {
         if (edit.get().getBasePatchSet().getId().equals(psId)) {
           // replace edit
-          cmd = new ReceiveCommand(
-              edit.get().getRef().getObjectId(),
-              newCommitId,
-              edit.get().getRefName());
+          cmd =
+              new ReceiveCommand(
+                  edit.get().getRef().getObjectId(), newCommitId, edit.get().getRefName());
         } else {
           // delete old edit ref on rebase
-          prev = new ReceiveCommand(
-              edit.get().getRef().getObjectId(),
-              ObjectId.zeroId(),
-              edit.get().getRefName());
+          prev =
+              new ReceiveCommand(
+                  edit.get().getRef().getObjectId(), ObjectId.zeroId(), edit.get().getRefName());
           createEditCommand();
         }
       } else {
@@ -2296,35 +2463,36 @@
 
     private void createEditCommand() {
       // create new edit
-      cmd = new ReceiveCommand(
-          ObjectId.zeroId(),
-          newCommitId,
-          RefNames.refsEdit(
-              user.getAccountId(),
-              notes.getChangeId(),
-              psId));
+      cmd =
+          new ReceiveCommand(
+              ObjectId.zeroId(),
+              newCommitId,
+              RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
     }
 
     private void newPatchSet() throws IOException {
       RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      psId = ChangeUtil.nextPatchSetId(
-          allRefs, notes.getChange().currentPatchSetId());
-      info = patchSetInfoFactory.get(
-          rp.getRevWalk(), newCommit, psId);
-      cmd = new ReceiveCommand(
-          ObjectId.zeroId(),
-          newCommitId,
-          psId.toRefName());
+      psId = ChangeUtil.nextPatchSetId(allRefs, notes.getChange().currentPatchSetId());
+      info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
+      cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
     }
 
-    void addOps(BatchUpdate bu, @Nullable Task progress)
-        throws IOException {
+    void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
       if (cmd.getResult() == NOT_ATTEMPTED) {
         // TODO(dborowitz): When does this happen? Only when an edit ref is
         // involved?
         cmd.execute(rp);
       }
       if (magicBranch != null && magicBranch.edit) {
+        bu.addOp(
+            notes.getChangeId(),
+            new BatchUpdateOp() {
+              @Override
+              public boolean updateChange(ChangeContext ctx) throws Exception {
+                // return pseudo dirty state to trigger reindexing
+                return true;
+              }
+            });
         return;
       }
       RevWalk rw = rp.getRevWalk();
@@ -2333,20 +2501,32 @@
       rw.parseBody(newCommit);
 
       RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-      replaceOp = replaceOpFactory.create(requestScopePropagator,
-          projectControl, notes.getChange().getDest(), checkMergedInto,
-          priorPatchSet, priorCommit, psId, newCommit, info, groups,
-          magicBranch, rp.getPushCertificate());
+      replaceOp =
+          replaceOpFactory
+              .create(
+                  projectControl,
+                  notes.getChange().getDest(),
+                  checkMergedInto,
+                  priorPatchSet,
+                  priorCommit,
+                  psId,
+                  newCommit,
+                  info,
+                  groups,
+                  magicBranch,
+                  rp.getPushCertificate())
+              .setRequestScopePropagator(requestScopePropagator)
+              .setUpdateRef(false);
       bu.addOp(notes.getChangeId(), replaceOp);
       if (progress != null) {
         bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
       }
     }
 
-    void insertPatchSetWithoutBatchUpdate()
-        throws IOException, UpdateException, RestApiException {
-      try (BatchUpdate bu = batchUpdateFactory.create(db,
-            projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
+    void insertPatchSetWithoutBatchUpdate() throws IOException, UpdateException, RestApiException {
+      try (BatchUpdate bu =
+              batchUpdateFactory.create(
+                  db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
           ObjectInserter ins = repo.newObjectInserter()) {
         bu.setRepository(repo, rp.getRevWalk(), ins);
         bu.setRequestId(receiveId);
@@ -2371,22 +2551,24 @@
     }
 
     private void addOps(BatchUpdate bu) {
-      bu.addOp(psId.getParentKey(), new BatchUpdate.Op() {
-        @Override
-        public boolean updateChange(ChangeContext ctx) throws OrmException {
-          PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-          List<String> oldGroups = ps.getGroups();
-          if (oldGroups == null) {
-            if (groups == null) {
-              return false;
+      bu.addOp(
+          psId.getParentKey(),
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+              List<String> oldGroups = ps.getGroups();
+              if (oldGroups == null) {
+                if (groups == null) {
+                  return false;
+                }
+              } else if (sameGroups(oldGroups, groups)) {
+                return false;
+              }
+              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
+              return true;
             }
-          } else if (sameGroups(oldGroups, groups)) {
-            return false;
-          }
-          psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
-          return true;
-        }
-      });
+          });
     }
 
     private boolean sameGroups(List<String> a, List<String> b) {
@@ -2401,10 +2583,11 @@
   private void initChangeRefMaps() {
     if (refsByChange == null) {
       int estRefsPerChange = 4;
-      refsById = HashMultimap.create();
-      refsByChange = ArrayListMultimap.create(
-          allRefs.size() / estRefsPerChange,
-          estRefsPerChange);
+      refsById = MultimapBuilder.hashKeys().arrayListValues().build();
+      refsByChange =
+          MultimapBuilder.hashKeys(allRefs.size() / estRefsPerChange)
+              .arrayListValues(estRefsPerChange)
+              .build();
       for (Ref ref : allRefs.values()) {
         ObjectId obj = ref.getObjectId();
         if (obj != null) {
@@ -2423,7 +2606,7 @@
     return refsByChange;
   }
 
-  private SetMultimap<ObjectId, Ref> changeRefsById() {
+  private ListMultimap<ObjectId, Ref> changeRefsById() {
     initChangeRefMaps();
     return refsById;
   }
@@ -2465,8 +2648,7 @@
   }
 
   private boolean validRefOperation(ReceiveCommand cmd) {
-    RefOperationValidators refValidators =
-        refValidatorsFactory.create(getProject(), user, cmd);
+    RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
 
     try {
       messages.addAll(refValidators.validateForRefOperation());
@@ -2493,8 +2675,7 @@
       return;
     }
 
-    boolean defaultName =
-        Strings.isNullOrEmpty(user.getAccount().getFullName());
+    boolean defaultName = Strings.isNullOrEmpty(user.getAccount().getFullName());
     RevWalk walk = rp.getRevWalk();
     walk.reset();
     walk.sort(RevSort.NONE);
@@ -2503,11 +2684,11 @@
       if (!(parsedObject instanceof RevCommit)) {
         return;
       }
-      SetMultimap<ObjectId, Ref> existing = changeRefsById();
-      walk.markStart((RevCommit)parsedObject);
+      ListMultimap<ObjectId, Ref> existing = changeRefsById();
+      walk.markStart((RevCommit) parsedObject);
       markHeadsAsUninteresting(walk, cmd.getRefName());
       int i = 0;
-      for (RevCommit c; (c = walk.next()) != null;) {
+      for (RevCommit c; (c = walk.next()) != null; ) {
         i++;
         if (existing.keySet().contains(c)) {
           continue;
@@ -2515,8 +2696,7 @@
           break;
         }
 
-        if (defaultName && user.hasEmailAddress(
-              c.getCommitterIdent().getEmailAddress())) {
+        if (defaultName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
           try {
             Account a = db.accounts().get(user.getAccountId());
             if (a != null && Strings.isNullOrEmpty(a.getFullName())) {
@@ -2539,8 +2719,8 @@
     }
   }
 
-  private boolean validCommit(RevWalk rw, RefControl ctl, ReceiveCommand cmd,
-      ObjectId id) throws IOException {
+  private boolean validCommit(RevWalk rw, RefControl ctl, ReceiveCommand cmd, ObjectId id)
+      throws IOException {
 
     if (validCommits.contains(id)) {
       return true;
@@ -2550,12 +2730,19 @@
     rw.parseBody(c);
     CommitReceivedEvent receiveEvent =
         new CommitReceivedEvent(cmd, project, ctl.getRefName(), c, user);
-    CommitValidators commitValidators =
-        commitValidatorsFactory.create(ctl, sshInfo, repo);
+
+    CommitValidators.Policy policy;
+    if (magicBranch != null
+        && cmd.getRefName().equals(magicBranch.cmd.getRefName())
+        && magicBranch.merged) {
+      policy = CommitValidators.Policy.MERGED;
+    } else {
+      policy = CommitValidators.Policy.RECEIVE_COMMITS;
+    }
 
     try {
-      messages.addAll(commitValidators.validateForReceiveCommits(
-          receiveEvent, rejectCommits));
+      messages.addAll(
+          commitValidatorsFactory.create(policy, ctl, sshInfo, repo).validate(receiveEvent));
     } catch (CommitValidationException e) {
       logDebug("Commit validation failed on {}", c.name());
       messages.addAll(e.getMessages());
@@ -2569,22 +2756,23 @@
   private void autoCloseChanges(final ReceiveCommand cmd) {
     logDebug("Starting auto-closing of changes");
     String refName = cmd.getRefName();
-    checkState(!MagicBranch.isMagicBranch(refName),
-        "shouldn't be auto-closing changes on magic branch %s", refName);
+    checkState(
+        !MagicBranch.isMagicBranch(refName),
+        "shouldn't be auto-closing changes on magic branch %s",
+        refName);
     RevWalk rw = rp.getRevWalk();
     // TODO(dborowitz): Combine this BatchUpdate with the main one in
     // insertChangesAndPatchSets.
-    try (BatchUpdate bu = batchUpdateFactory.create(db,
-          projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
         ObjectInserter ins = repo.newObjectInserter()) {
-      bu.setRepository(repo, rp.getRevWalk(), ins)
-          .updateChangesInParallel();
+      bu.setRepository(repo, rp.getRevWalk(), ins).updateChangesInParallel();
       bu.setRequestId(receiveId);
       // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
       RevCommit newTip = rw.parseCommit(cmd.getNewId());
-      Branch.NameKey branch =
-          new Branch.NameKey(project.getNameKey(), refName);
+      Branch.NameKey branch = new Branch.NameKey(project.getNameKey(), refName);
 
       rw.reset();
       rw.markStart(newTip);
@@ -2592,28 +2780,31 @@
         rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
       }
 
-      SetMultimap<ObjectId, Ref> byCommit = changeRefsById();
+      ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
       Map<Change.Key, ChangeNotes> byKey = null;
       List<ReplaceRequest> replaceAndClose = new ArrayList<>();
 
       int existingPatchSets = 0;
       int newPatchSets = 0;
-      COMMIT: for (RevCommit c; (c = rw.next()) != null;) {
+      COMMIT:
+      for (RevCommit c; (c = rw.next()) != null; ) {
         rw.parseBody(c);
 
         for (Ref ref : byCommit.get(c.copy())) {
-          existingPatchSets++;
           PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-          bu.addOp(
-              psId.getParentKey(),
-              mergedByPushOpFactory.create(
-                  requestScopePropagator, psId, refName));
-          continue COMMIT;
+          Optional<ChangeData> cd = byLegacyId(psId.getParentKey());
+          if (cd.isPresent() && cd.get().change().getDest().equals(branch)) {
+            existingPatchSets++;
+            bu.addOp(
+                psId.getParentKey(),
+                mergedByPushOpFactory.create(requestScopePropagator, psId, refName));
+            continue COMMIT;
+          }
         }
 
         for (String changeId : c.getFooterLines(CHANGE_ID)) {
           if (byKey == null) {
-            byKey = openChangesByBranch(branch);
+            byKey = openChangesByKeyByBranch(branch);
           }
 
           ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
@@ -2621,8 +2812,7 @@
             newPatchSets++;
             // Hold onto this until we're done with the walk, as the call to
             // req.validate below calls isMergedInto which resets the walk.
-            ReplaceRequest req =
-                new ReplaceRequest(onto.getChangeId(), c, cmd, false);
+            ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
             req.notes = onto;
             replaceAndClose.add(req);
             continue COMMIT;
@@ -2639,19 +2829,22 @@
         req.addOps(bu, null);
         bu.addOp(
             id,
-            mergedByPushOpFactory.create(
-                    requestScopePropagator, req.psId, refName)
-                .setPatchSetProvider(new Provider<PatchSet>() {
-                  @Override
-                  public PatchSet get() {
-                    return req.replaceOp.getPatchSet();
-                  }
-                }));
+            mergedByPushOpFactory
+                .create(requestScopePropagator, req.psId, refName)
+                .setPatchSetProvider(
+                    new Provider<PatchSet>() {
+                      @Override
+                      public PatchSet get() {
+                        return req.replaceOp.getPatchSet();
+                      }
+                    }));
         bu.addOp(id, new ChangeProgressOp(closeProgress));
       }
 
-      logDebug("Auto-closing {} changes with existing patch sets and {} with"
-          + " new patch sets", existingPatchSets, newPatchSets);
+      logDebug(
+          "Auto-closing {} changes with existing patch sets and {} with new patch sets",
+          existingPatchSets,
+          newPatchSets);
       bu.execute();
     } catch (RestApiException e) {
       logError("Can't insert patchset", e);
@@ -2660,17 +2853,25 @@
     }
   }
 
-  private Map<Change.Key, ChangeNotes> openChangesByBranch(
-      Branch.NameKey branch) throws OrmException {
+  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch)
+      throws OrmException {
     Map<Change.Key, ChangeNotes> r = new HashMap<>();
     for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
-      r.put(cd.change().getKey(), cd.notes());
+      try {
+        r.put(cd.change().getKey(), cd.notes());
+      } catch (NoSuchChangeException e) {
+        // Ignore deleted change
+      }
     }
     return r;
   }
 
-  private void reject(ReceiveCommand cmd) {
-    reject(cmd, "prohibited by Gerrit");
+  private Optional<ChangeData> byLegacyId(Change.Id legacyId) throws OrmException {
+    List<ChangeData> res = queryProvider.get().byLegacyChangeId(legacyId);
+    if (res.isEmpty()) {
+      return Optional.empty();
+    }
+    return Optional.of(res.get(0));
   }
 
   private void reject(ReceiveCommand cmd, String why) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
index 51c2a80..2316782 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
@@ -16,17 +16,24 @@
 
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.transport.AdvertiseRefsHook;
@@ -36,22 +43,23 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Set;
-
 /** Exposes only the non refs/changes/ reference names. */
 public class ReceiveCommitsAdvertiseRefsHook implements AdvertiseRefsHook {
-  private static final Logger log = LoggerFactory
-      .getLogger(ReceiveCommitsAdvertiseRefsHook.class);
+  private static final Logger log = LoggerFactory.getLogger(ReceiveCommitsAdvertiseRefsHook.class);
+
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class Result {
+    public abstract Map<String, Ref> allRefs();
+
+    public abstract Set<ObjectId> additionalHaves();
+  }
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final Project.NameKey projectName;
 
   public ReceiveCommitsAdvertiseRefsHook(
-      Provider<InternalChangeQuery> queryProvider,
-      Project.NameKey projectName) {
+      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
     this.queryProvider = queryProvider;
     this.projectName = projectName;
   }
@@ -63,8 +71,7 @@
   }
 
   @Override
-  public void advertiseRefs(BaseReceivePack rp)
-      throws ServiceMayNotContinueException {
+  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
     Map<String, Ref> oldRefs = rp.getAdvertisedRefs();
     if (oldRefs == null) {
       try {
@@ -77,28 +84,56 @@
         throw ex;
       }
     }
+    Result r = advertiseRefs(oldRefs);
+    rp.setAdvertisedRefs(r.allRefs(), r.additionalHaves());
+  }
+
+  @VisibleForTesting
+  public Result advertiseRefs(Map<String, Ref> oldRefs) {
     Map<String, Ref> r = Maps.newHashMapWithExpectedSize(oldRefs.size());
+    Set<ObjectId> allPatchSets = Sets.newHashSetWithExpectedSize(oldRefs.size());
     for (Map.Entry<String, Ref> e : oldRefs.entrySet()) {
       String name = e.getKey();
       if (!skip(name)) {
         r.put(name, e.getValue());
       }
+      if (name.startsWith(RefNames.REFS_CHANGES)) {
+        allPatchSets.add(e.getValue().getObjectId());
+      }
     }
-    rp.setAdvertisedRefs(r, advertiseOpenChanges());
+    return new AutoValue_ReceiveCommitsAdvertiseRefsHook_Result(
+        r, advertiseOpenChanges(allPatchSets));
   }
 
-  private Set<ObjectId> advertiseOpenChanges() {
+  private static final ImmutableSet<String> OPEN_CHANGES_FIELDS =
+      ImmutableSet.of(
+          // Required for ChangeIsVisibleToPrdicate.
+          ChangeField.CHANGE.getName(),
+          ChangeField.REVIEWER.getName(),
+          // Required during advertiseOpenChanges.
+          ChangeField.PATCH_SET.getName());
+
+  private Set<ObjectId> advertiseOpenChanges(Set<ObjectId> allPatchSets) {
     // Advertise some recent open changes, in case a commit is based on one.
     int limit = 32;
     try {
       Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
-      for (ChangeData cd : queryProvider.get()
-          .enforceVisibility(true)
-          .setLimit(limit)
-          .byProjectOpen(projectName)) {
+      for (ChangeData cd :
+          queryProvider
+              .get()
+              .setRequestedFields(OPEN_CHANGES_FIELDS)
+              .enforceVisibility(true)
+              .setLimit(limit)
+              .byProjectOpen(projectName)) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
-          r.add(ObjectId.fromString(ps.getRevision().get()));
+          ObjectId id = ObjectId.fromString(ps.getRevision().get());
+          // Ensure we actually observed a patch set ref pointing to this
+          // object, in case the database is out of sync with the repo and the
+          // object doesn't actually exist.
+          if (allPatchSets.contains(id)) {
+            r.add(id);
+          }
         }
       }
       return r;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutor.java
index 2049cea..ddf24cde 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutor.java
@@ -17,14 +17,9 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
-/**
- * Marker on the global {@link WorkQueue.Executor} used by
- * {@link ReceiveCommits}.
- */
+/** Marker on the global {@link WorkQueue.Executor} used by {@link ReceiveCommits}. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface ReceiveCommitsExecutor {
-}
+public @interface ReceiveCommitsExecutor {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutorModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutorModule.java
index 8c428a2..7c3dae5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutorModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutorModule.java
@@ -18,32 +18,30 @@
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.update.ChangeUpdateExecutor;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
 
 /** Module providing the {@link ReceiveCommitsExecutor}. */
 public class ReceiveCommitsExecutorModule extends AbstractModule {
   @Override
-  protected void configure() {
-  }
+  protected void configure() {}
 
   @Provides
   @Singleton
   @ReceiveCommitsExecutor
   public WorkQueue.Executor createReceiveCommitsExecutor(
-      @GerritServerConfig Config config,
-      WorkQueue queues) {
-    int poolSize = config.getInt("receive", null, "threadPoolSize",
-        Runtime.getRuntime().availableProcessors());
-    return queues.createQueue(poolSize, "ReceiveCommits");
+      @GerritServerConfig Config config, WorkQueue queues) {
+    int poolSize =
+        config.getInt(
+            "receive", null, "threadPoolSize", Runtime.getRuntime().availableProcessors());
+    return queues.createQueue(poolSize, "ReceiveCommits", true);
   }
 
   @Provides
@@ -55,7 +53,7 @@
     if (poolSize == 0) {
       return MoreExecutors.newDirectExecutorService();
     }
-    return queues.createQueue(poolSize, "SendEmail");
+    return queues.createQueue(poolSize, "SendEmail", true);
   }
 
   @Provides
@@ -68,13 +66,13 @@
     }
     return MoreExecutors.listeningDecorator(
         MoreExecutors.getExitingExecutorService(
-          new ThreadPoolExecutor(1, poolSize,
-              10, TimeUnit.MINUTES,
-              new ArrayBlockingQueue<Runnable>(poolSize),
-              new ThreadFactoryBuilder()
-                .setNameFormat("ChangeUpdate-%d")
-                .setDaemon(true)
-                .build(),
-              new ThreadPoolExecutor.CallerRunsPolicy())));
+            new ThreadPoolExecutor(
+                1,
+                poolSize,
+                10,
+                TimeUnit.MINUTES,
+                new ArrayBlockingQueue<Runnable>(poolSize),
+                new ThreadFactoryBuilder().setNameFormat("ChangeUpdate-%d").setDaemon(true).build(),
+                new ThreadPoolExecutor.CallerRunsPolicy())));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
index ac6116c..063f395 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -32,15 +31,10 @@
 
   @Inject
   ReceiveConfig(@GerritServerConfig Config config) {
-    checkMagicRefs = config.getBoolean(
-        "receive", null, "checkMagicRefs",
-        true);
-    checkReferencedObjectsAreReachable = config.getBoolean(
-        "receive", null, "checkReferencedObjectsAreReachable",
-        true);
-    allowDrafts = config.getBoolean(
-        "change", null, "allowDrafts",
-        true);
+    checkMagicRefs = config.getBoolean("receive", null, "checkMagicRefs", true);
+    checkReferencedObjectsAreReachable =
+        config.getBoolean("receive", null, "checkReferencedObjectsAreReachable", true);
+    allowDrafts = config.getBoolean("change", null, "allowDrafts", true);
     systemMaxBatchChanges = config.getInt("receive", "maxBatchChanges", 0);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
index ee229d4..f374215 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceivePackInitializer.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.reviewdb.client.Project;
-
 import org.eclipse.jgit.transport.ReceivePack;
 
 @ExtensionPoint
@@ -25,9 +24,9 @@
   /**
    * ReceivePack initialization.
    *
-   * Invoked by Gerrit when a new ReceivePack instance is created and just
-   * before it is used. Implementors will usually call setXXX methods on the
-   * receivePack parameter in order to set additional properties on it.
+   * <p>Invoked by Gerrit when a new ReceivePack instance is created and just before it is used.
+   * Implementors will usually call setXXX methods on the receivePack parameter in order to set
+   * additional properties on it.
    *
    * @param project project for which the ReceivePack is created
    * @param receivePack the ReceivePack instance which is being initialized
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
index 562db08..5a5cae9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RefCache.java
@@ -14,31 +14,27 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.base.Optional;
-
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Simple short-lived cache of individual refs read from a repo.
- * <p>
- * Within a single request that is known to read a small bounded number of refs,
- * this class can be used to ensure a consistent view of one ref, and avoid
- * multiple system calls to read refs multiple times.
- * <p>
- * <strong>Note:</strong> Implementations of this class are only appropriate
- * for short-term caching, and do not support invalidation. It is also not
- * threadsafe.
+ *
+ * <p>Within a single request that is known to read a small bounded number of refs, this class can
+ * be used to ensure a consistent view of one ref, and avoid multiple system calls to read refs
+ * multiple times.
+ *
+ * <p><strong>Note:</strong> Implementations of this class are only appropriate for short-term
+ * caching, and do not support invalidation. It is also not threadsafe.
  */
 public interface RefCache {
   /**
    * Get the possibly-cached value of a ref.
    *
    * @param refName name of the ref.
-   * @return value of the ref; absent if the ref does not exist in the repo.
-   *     Never null, and never present with a value of {@link
-   *     ObjectId#zeroId()}.
+   * @return value of the ref; absent if the ref does not exist in the repo. Never null, and never
+   *     present with a value of {@link ObjectId#zeroId()}.
    */
   Optional<ObjectId> get(String refName) throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java
index 00c9a7c..ccaaa36 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RenameGroupOp.java
@@ -20,28 +20,28 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
 public class RenameGroupOp extends DefaultQueueOp {
   public interface Factory {
-    RenameGroupOp create(@Assisted("author") PersonIdent author,
-        @Assisted AccountGroup.UUID uuid, @Assisted("oldName") String oldName,
+    RenameGroupOp create(
+        @Assisted("author") PersonIdent author,
+        @Assisted AccountGroup.UUID uuid,
+        @Assisted("oldName") String oldName,
         @Assisted("newName") String newName);
   }
 
   private static final int MAX_TRIES = 10;
-  private static final Logger log =
-      LoggerFactory.getLogger(RenameGroupOp.class);
+  private static final Logger log = LoggerFactory.getLogger(RenameGroupOp.class);
 
   private final ProjectCache projectCache;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
@@ -55,11 +55,14 @@
   private boolean tryingAgain;
 
   @Inject
-  public RenameGroupOp(WorkQueue workQueue, ProjectCache projectCache,
+  public RenameGroupOp(
+      WorkQueue workQueue,
+      ProjectCache projectCache,
       MetaDataUpdate.Server metaDataUpdateFactory,
-
-      @Assisted("author") PersonIdent author, @Assisted AccountGroup.UUID uuid,
-      @Assisted("oldName") String oldName, @Assisted("newName") String newName) {
+      @Assisted("author") PersonIdent author,
+      @Assisted AccountGroup.UUID uuid,
+      @Assisted("oldName") String oldName,
+      @Assisted("newName") String newName) {
     super(workQueue);
     this.projectCache = projectCache;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -73,9 +76,7 @@
 
   @Override
   public void run() {
-    Iterable<Project.NameKey> names = tryingAgain
-        ? retryOn
-        : projectCache.all();
+    Iterable<Project.NameKey> names = tryingAgain ? retryOn : projectCache.all();
     for (Project.NameKey projectName : names) {
       ProjectConfig config = projectCache.get(projectName).getConfig();
       GroupReference ref = config.getGroup(uuid);
@@ -92,16 +93,16 @@
       }
     }
 
-    // If one or more projects did not update, wait 5 minutes
-    // and give it another attempt.
+    // If one or more projects did not update, wait 5 minutes and give it
+    // another attempt. If it doesn't update after that, give up.
     if (!retryOn.isEmpty() && !tryingAgain) {
       tryingAgain = true;
-      start(5, TimeUnit.MINUTES);
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = start(5, TimeUnit.MINUTES);
     }
   }
 
-  private void rename(MetaDataUpdate md) throws IOException,
-      ConfigInvalidException {
+  private void rename(MetaDataUpdate md) throws IOException, ConfigInvalidException {
     boolean success = false;
     for (int attempts = 0; !success && attempts < MAX_TRIES; attempts++) {
       ProjectConfig config = ProjectConfig.read(md);
@@ -122,8 +123,14 @@
         projectCache.evict(config.getProject());
         success = true;
       } catch (IOException e) {
-        log.error("Could not commit rename of group " + oldName + " to "
-            + newName + " in " + md.getProjectName().get(), e);
+        log.error(
+            "Could not commit rename of group "
+                + oldName
+                + " to "
+                + newName
+                + " in "
+                + md.getProjectName().get(),
+            e);
         try {
           Thread.sleep(25 /* milliseconds */);
         } catch (InterruptedException wakeUp) {
@@ -134,8 +141,13 @@
 
     if (!success) {
       if (tryingAgain) {
-        log.warn("Could not rename group " + oldName + " to " + newName
-            + " in " + md.getProjectName().get());
+        log.warn(
+            "Could not rename group "
+                + oldName
+                + " to "
+                + newName
+                + " in "
+                + md.getProjectName().get());
       } else {
         retryOn.add(md.getProjectName());
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
index 7754813..50a14e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
@@ -24,7 +25,7 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -34,30 +35,34 @@
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.extensions.events.CommentAdded;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import com.google.inject.util.Providers;
-
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -65,21 +70,13 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ExecutorService;
-
-public class ReplaceOp extends BatchUpdate.Op {
+public class ReplaceOp implements BatchUpdateOp {
   public interface Factory {
     ReplaceOp create(
-        RequestScopePropagator requestScopePropagator,
         ProjectControl projectControl,
         Branch.NameKey dest,
         boolean checkMergedInto,
@@ -93,8 +90,7 @@
         @Nullable PushCertificate pushCertificate);
   }
 
-  private static final Logger log =
-      LoggerFactory.getLogger(ReplaceOp.class);
+  private static final Logger log = LoggerFactory.getLogger(ReplaceOp.class);
 
   private static final String CHANGE_IS_CLOSED = "change is closed";
 
@@ -106,14 +102,12 @@
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
   private final ExecutorService sendEmailExecutor;
-  private final GitReferenceUpdated gitRefUpdated;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetUtil psUtil;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
 
-  private final RequestScopePropagator requestScopePropagator;
   private final ProjectControl projectControl;
   private final Branch.NameKey dest;
   private final boolean checkMergedInto;
@@ -134,23 +128,24 @@
   private ChangeMessage msg;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
+  private RequestScopePropagator requestScopePropagator;
+  private boolean updateRef;
 
   @AssistedInject
-  ReplaceOp(AccountResolver accountResolver,
+  ReplaceOp(
+      AccountResolver accountResolver,
       ApprovalCopier approvalCopier,
       ApprovalsUtil approvalsUtil,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
       ChangeMessagesUtil cmUtil,
-      GitReferenceUpdated gitRefUpdated,
       RevisionCreated revisionCreated,
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
       PatchSetUtil psUtil,
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
-      @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted ProjectControl projectControl,
       @Assisted Branch.NameKey dest,
       @Assisted boolean checkMergedInto,
@@ -169,7 +164,6 @@
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
     this.cmUtil = cmUtil;
-    this.gitRefUpdated = gitRefUpdated;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
@@ -177,7 +171,6 @@
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.sendEmailExecutor = sendEmailExecutor;
 
-    this.requestScopePropagator = requestScopePropagator;
     this.projectControl = projectControl;
     this.dest = dest;
     this.checkMergedInto = checkMergedInto;
@@ -189,25 +182,31 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.updateRef = true;
   }
 
   @Override
   public void updateRepo(RepoContext ctx) throws Exception {
-    changeKind = changeKindCache.getChangeKind(projectControl.getProjectState(),
-        ctx.getRepository(), priorCommit, commit);
+    changeKind =
+        changeKindCache.getChangeKind(
+            projectControl.getProject().getNameKey(), ctx.getRepository(), priorCommit, commit);
 
     if (checkMergedInto) {
       Ref mergedInto = findMergedInto(ctx, dest.get(), commit);
       if (mergedInto != null) {
-        mergedByPushOp = mergedByPushOpFactory.create(
-            requestScopePropagator, patchSetId, mergedInto.getName());
+        mergedByPushOp =
+            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto.getName());
       }
     }
+
+    if (updateRef) {
+      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), commit, patchSetId.toRefName()));
+    }
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws OrmException, IOException {
+      throws RestApiException, OrmException, IOException {
     change = ctx.getChange();
     if (change == null || change.getStatus().isClosed()) {
       rejectMessage = CHANGE_IS_CLOSED;
@@ -215,26 +214,25 @@
     }
     if (groups.isEmpty()) {
       PatchSet prevPs = psUtil.current(ctx.getDb(), ctx.getNotes());
-      groups = prevPs != null
-          ? prevPs.getGroups()
-          : ImmutableList.<String> of();
+      groups = prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of();
     }
 
     ChangeUpdate update = ctx.getUpdate(patchSetId);
     update.setSubjectForCommit("Create patch set " + patchSetId.get());
 
     String reviewMessage = null;
+    String psDescription = null;
     if (magicBranch != null) {
       recipients.add(magicBranch.getMailRecipients());
       reviewMessage = magicBranch.message;
+      psDescription = magicBranch.message;
       approvals.putAll(magicBranch.labels);
       Set<String> hashtags = magicBranch.hashtags;
       if (hashtags != null && !hashtags.isEmpty()) {
         hashtags.addAll(ctx.getNotes().getHashtags());
         update.setHashtags(hashtags);
       }
-      if (magicBranch.topic != null
-          && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
+      if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
         update.setTopic(magicBranch.topic);
       }
     }
@@ -243,29 +241,55 @@
     if (change.getStatus() == Change.Status.DRAFT && !draft) {
       update.setStatus(Change.Status.NEW);
     }
-    newPatchSet = psUtil.insert(
-        ctx.getDb(), ctx.getRevWalk(), update, patchSetId, commit, draft, groups,
-        pushCertificate != null
-          ? pushCertificate.toTextWithSignature()
-          : null);
+    newPatchSet =
+        psUtil.insert(
+            ctx.getDb(),
+            ctx.getRevWalk(),
+            update,
+            patchSetId,
+            commit,
+            draft,
+            groups,
+            pushCertificate != null ? pushCertificate.toTextWithSignature() : null,
+            psDescription);
 
-    recipients.add(getRecipientsFromFooters(
-        ctx.getDb(), accountResolver, draft, commit.getFooterLines()));
+    update.setPsDescription(psDescription);
+    recipients.add(
+        getRecipientsFromFooters(ctx.getDb(), accountResolver, draft, commit.getFooterLines()));
     recipients.remove(ctx.getAccountId());
     ChangeData cd = changeDataFactory.create(ctx.getDb(), ctx.getControl());
-    MailRecipients oldRecipients =
-        getRecipientsFromReviewers(cd.reviewers());
-    approvalCopier.copy(ctx.getDb(), ctx.getControl(), newPatchSet);
-    approvalsUtil.addReviewers(ctx.getDb(), update,
-        projectControl.getLabelTypes(), change, newPatchSet, info,
-        recipients.getReviewers(), oldRecipients.getAll());
-    approvalsUtil.addApprovals(ctx.getDb(), update,
-        projectControl.getLabelTypes(), newPatchSet, ctx.getControl(),
-        approvals);
+    MailRecipients oldRecipients = getRecipientsFromReviewers(cd.reviewers());
+    Iterable<PatchSetApproval> newApprovals =
+        approvalsUtil.addApprovalsForNewPatchSet(
+            ctx.getDb(),
+            update,
+            projectControl.getLabelTypes(),
+            newPatchSet,
+            ctx.getControl(),
+            approvals);
+    approvalCopier.copy(ctx.getDb(), ctx.getControl(), newPatchSet, newApprovals);
+    approvalsUtil.addReviewers(
+        ctx.getDb(),
+        update,
+        projectControl.getLabelTypes(),
+        change,
+        newPatchSet,
+        info,
+        recipients.getReviewers(),
+        oldRecipients.getAll());
+
+    // Check if approvals are changing in with this update. If so, add current user to reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // reviewer which is needed in several other code paths.
+    if (magicBranch != null && !magicBranch.labels.isEmpty()) {
+      update.putReviewer(ctx.getAccountId(), REVIEWER);
+    }
+
     recipients.add(oldRecipients);
 
-    String approvalMessage = ApprovalsUtil.renderMessageWithApprovals(
-        patchSetId.get(), approvals, scanLabels(ctx, approvals));
+    String approvalMessage =
+        ApprovalsUtil.renderMessageWithApprovals(
+            patchSetId.get(), approvals, scanLabels(ctx, approvals));
     String kindMessage = changeKindMessage(changeKind);
     StringBuilder message = new StringBuilder(approvalMessage);
     if (!Strings.isNullOrEmpty(kindMessage)) {
@@ -276,18 +300,19 @@
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n").append(reviewMessage);
     }
-    msg = new ChangeMessage(
-        new ChangeMessage.Key(change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(), patchSetId);
-    msg.setMessage(message.toString());
+    msg =
+        ChangeMessagesUtil.newMessage(
+            patchSetId,
+            ctx.getUser(),
+            ctx.getWhen(),
+            message.toString(),
+            ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
     cmUtil.addChangeMessage(ctx.getDb(), update, msg);
 
     if (mergedByPushOp == null) {
-      resetChange(ctx, msg);
+      resetChange(ctx);
     } else {
-      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet))
-          .updateChange(ctx);
+      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
     }
 
     return true;
@@ -307,14 +332,14 @@
     }
   }
 
-  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx,
-      Map<String, Short> approvals) throws OrmException {
+  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
+      throws OrmException {
     Map<String, PatchSetApproval> current = new HashMap<>();
     // We optimize here and only retrieve current when approvals provided
     if (!approvals.isEmpty()) {
-      for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getDb(),
-          ctx.getControl(), priorPatchSetId,
-          ctx.getAccountId())) {
+      for (PatchSetApproval a :
+          approvalsUtil.byPatchSetUser(
+              ctx.getDb(), ctx.getControl(), priorPatchSetId, ctx.getAccountId())) {
         if (a.isLegacySubmit()) {
           continue;
         }
@@ -328,16 +353,8 @@
     return current;
   }
 
-  private void resetChange(ChangeContext ctx, ChangeMessage msg)
-      throws OrmException {
+  private void resetChange(ChangeContext ctx) {
     Change change = ctx.getChange();
-    if (change.getStatus().isClosed()) {
-      ctx.getDb().patchSets().delete(Collections.singleton(newPatchSet));
-      ctx.getDb().changeMessages().delete(Collections.singleton(msg));
-      rejectMessage = CHANGE_IS_CLOSED;
-      return;
-    }
-
     if (!change.currentPatchSetId().equals(priorPatchSetId)) {
       return;
     }
@@ -362,53 +379,48 @@
 
   @Override
   public void postUpdate(final Context ctx) throws Exception {
-    // Normally the ref updated hook is fired by BatchUpdate, but ReplaceOp is
-    // special because its ref is actually updated by ReceiveCommits, so from
-    // BatchUpdate's perspective there is no ref update. Thus we have to fire it
-    // manually.
-    final Account account = ctx.getAccount();
-    gitRefUpdated.fire(ctx.getProject(), newPatchSet.getRefName(),
-        ObjectId.zeroId(), commit, account);
-
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      Runnable sender = new Runnable() {
-        @Override
-        public void run() {
-          try {
-            ReplacePatchSetSender cm = replacePatchSetFactory.create(
-                projectControl.getProject().getNameKey(), change.getId());
-            cm.setFrom(account.getId());
-            cm.setPatchSet(newPatchSet, info);
-            cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
-            if (magicBranch != null && magicBranch.notify != null) {
-              cm.setNotify(magicBranch.notify);
+      Runnable sender =
+          new Runnable() {
+            @Override
+            public void run() {
+              try {
+                ReplacePatchSetSender cm =
+                    replacePatchSetFactory.create(
+                        projectControl.getProject().getNameKey(), change.getId());
+                cm.setFrom(ctx.getAccount().getId());
+                cm.setPatchSet(newPatchSet, info);
+                cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
+                if (magicBranch != null) {
+                  cm.setNotify(magicBranch.notify);
+                  cm.setAccountsToNotify(magicBranch.getAccountsToNotify());
+                }
+                cm.addReviewers(recipients.getReviewers());
+                cm.addExtraCC(recipients.getCcOnly());
+                cm.send();
+              } catch (Exception e) {
+                log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
+              }
             }
-            cm.addReviewers(recipients.getReviewers());
-            cm.addExtraCC(recipients.getCcOnly());
-            cm.send();
-          } catch (Exception e) {
-            log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
-          }
-        }
 
-        @Override
-        public String toString() {
-          return "send-email newpatchset";
-        }
-      };
+            @Override
+            public String toString() {
+              return "send-email newpatchset";
+            }
+          };
 
       if (requestScopePropagator != null) {
-        sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError =
+            sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
       } else {
         sender.run();
       }
     }
 
-    NotifyHandling notify = magicBranch != null && magicBranch.notify != null
-        ? magicBranch.notify
-        : NotifyHandling.ALL;
-    revisionCreated.fire(change, newPatchSet, ctx.getAccount(),
-        ctx.getWhen(), notify);
+    NotifyHandling notify =
+        magicBranch != null && magicBranch.notify != null ? magicBranch.notify : NotifyHandling.ALL;
+    revisionCreated.fire(change, newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
       fireCommentAddedEvent(ctx);
     } catch (Exception e) {
@@ -419,8 +431,7 @@
     }
   }
 
-  private void fireCommentAddedEvent(final Context ctx)
-      throws NoSuchChangeException, OrmException {
+  private void fireCommentAddedEvent(Context ctx) throws OrmException {
     if (approvals.isEmpty()) {
       return;
     }
@@ -430,8 +441,8 @@
      * For labels that are set in this operation, the value was modified, so
      * show a transition from an oldValue of 0 to the new value.
      */
-    ChangeControl changeControl = changeControlFactory.controlFor(
-        ctx.getDb(), change, ctx.getUser());
+    ChangeControl changeControl =
+        changeControlFactory.controlFor(ctx.getDb(), change, ctx.getUser());
     List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
     Map<String, Short> allApprovals = new HashMap<>();
     Map<String, Short> oldApprovals = new HashMap<>();
@@ -446,26 +457,38 @@
       }
     }
 
-    commentAdded.fire(change, newPatchSet,
-        ctx.getAccount(), null,
-        allApprovals, oldApprovals, ctx.getWhen());
+    commentAdded.fire(
+        change, newPatchSet, ctx.getAccount(), null, allApprovals, oldApprovals, ctx.getWhen());
   }
 
   public PatchSet getPatchSet() {
     return newPatchSet;
   }
 
+  public Change getChange() {
+    return change;
+  }
+
   public String getRejectMessage() {
     return rejectMessage;
   }
 
+  public ReplaceOp setUpdateRef(boolean updateRef) {
+    this.updateRef = updateRef;
+    return this;
+  }
+
+  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
   private Ref findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
       RefDatabase refDatabase = ctx.getRepository().getRefDatabase();
 
       Ref firstRef = refDatabase.exactRef(first);
-      if (firstRef != null
-          && isMergedInto(ctx.getRevWalk(), commit, firstRef)) {
+      if (firstRef != null && isMergedInto(ctx.getRevWalk(), commit, firstRef)) {
         return firstRef;
       }
 
@@ -481,8 +504,7 @@
     }
   }
 
-  private static boolean isMergedInto(RevWalk rw, RevCommit commit, Ref ref)
-      throws IOException {
+  private static boolean isMergedInto(RevWalk rw, RevCommit commit, Ref ref) throws IOException {
     return rw.isMergedInto(commit, rw.parseCommit(ref.getObjectId()));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
index 1dfa51e..e7a86f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -14,17 +14,15 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.base.Optional;
-
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-
 /** {@link RefCache} backed directly by a repository. */
 public class RepoRefCache implements RefCache {
   private final RefDatabase refdb;
@@ -42,9 +40,7 @@
       return id;
     }
     Ref ref = refdb.exactRef(refName);
-    id = ref != null
-        ? Optional.of(ref.getObjectId())
-        : Optional.<ObjectId>absent();
+    id = Optional.ofNullable(ref).map(Ref::getObjectId);
     ids.put(refName, id);
     return id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
index 98ddf80..45ec769 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
@@ -15,26 +15,20 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.reviewdb.client.Project;
-
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
 /**
- * This exception is thrown if a project cannot be created because a project
- * with the same name in a different case already exists. This can only happen
- * if the OS has a case insensitive file system (e.g. Windows), because in this
- * case the name for the git repository in the file system is already occupied
- * by the existing project.
+ * This exception is thrown if a project cannot be created because a project with the same name in a
+ * different case already exists. This can only happen if the OS has a case insensitive file system
+ * (e.g. Windows), because in this case the name for the git repository in the file system is
+ * already occupied by the existing project.
  */
-public class RepositoryCaseMismatchException extends
-    RepositoryNotFoundException {
+public class RepositoryCaseMismatchException extends RepositoryNotFoundException {
 
   private static final long serialVersionUID = 1L;
 
-  /**
-   * @param projectName name of the project that cannot be created
-   */
+  /** @param projectName name of the project that cannot be created */
   public RepositoryCaseMismatchException(final Project.NameKey projectName) {
-    super("Name occupied in other case. Project " + projectName.get()
-        + " cannot be created.");
+    super("Name occupied in other case. Project " + projectName.get() + " cannot be created.");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReviewNoteMerger.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReviewNoteMerger.java
index 8b6da7b..2c30203 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReviewNoteMerger.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReviewNoteMerger.java
@@ -38,6 +38,8 @@
 
 package com.google.gerrit.server.git;
 
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -48,13 +50,10 @@
 import org.eclipse.jgit.notes.NoteMerger;
 import org.eclipse.jgit.util.io.UnionInputStream;
 
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-
 class ReviewNoteMerger implements NoteMerger {
   @Override
-  public Note merge(Note base, Note ours, Note theirs, ObjectReader reader,
-      ObjectInserter inserter) throws IOException {
+  public Note merge(Note base, Note ours, Note theirs, ObjectReader reader, ObjectInserter inserter)
+      throws IOException {
     if (ours == null) {
       return theirs;
     }
@@ -72,8 +71,8 @@
         ByteArrayInputStream b = new ByteArrayInputStream(sep);
         ObjectStream ts = lt.openStream();
         UnionInputStream union = new UnionInputStream(os, b, ts)) {
-      ObjectId noteData = inserter.insert(Constants.OBJ_BLOB,
-          lo.getSize() + sep.length + lt.getSize(), union);
+      ObjectId noteData =
+          inserter.insert(Constants.OBJ_BLOB, lo.getSize() + sep.length + lt.getSize(), union);
       return new Note(ours, noteData);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 54ec249..abce2d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -37,19 +38,16 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import com.google.inject.util.Providers;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class SearchingChangeCacheImpl implements GitReferenceUpdatedListener {
-  private static final Logger log =
-      LoggerFactory.getLogger(SearchingChangeCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(SearchingChangeCacheImpl.class);
   static final String ID_CACHE = "changes";
 
   public static class Module extends CacheModule {
@@ -67,13 +65,11 @@
     protected void configure() {
       if (slave) {
         bind(SearchingChangeCacheImpl.class)
-            .toProvider(Providers.<SearchingChangeCacheImpl> of(null));
+            .toProvider(Providers.<SearchingChangeCacheImpl>of(null));
       } else {
-        cache(ID_CACHE,
-            Project.NameKey.class,
-            new TypeLiteral<List<CachedChange>>() {})
-          .maximumWeight(0)
-          .loader(Loader.class);
+        cache(ID_CACHE, Project.NameKey.class, new TypeLiteral<List<CachedChange>>() {})
+            .maximumWeight(0)
+            .loader(Loader.class);
 
         bind(SearchingChangeCacheImpl.class);
         DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
@@ -88,7 +84,9 @@
     // VisibleRefFilter without touching the database. More can be added as
     // necessary.
     abstract Change change();
-    @Nullable abstract ReviewerSet reviewers();
+
+    @Nullable
+    abstract ReviewerSet reviewers();
   }
 
   private final LoadingCache<Project.NameKey, List<CachedChange>> cache;
@@ -104,12 +102,11 @@
 
   /**
    * Read changes for the project from the secondary index.
-   * <p>
-   * Returned changes only include the {@code Change} object (with id, branch)
-   * and the reviewers. Additional stored fields are not loaded from the index.
    *
-   * @param db database handle to populate missing change data (probably
-   *        unused).
+   * <p>Returned changes only include the {@code Change} object (with id, branch) and the reviewers.
+   * Additional stored fields are not loaded from the index.
+   *
+   * @param db database handle to populate missing change data (probably unused).
    * @param project project to read.
    * @return list of known changes; empty if no changes.
    */
@@ -141,24 +138,24 @@
     private final Provider<InternalChangeQuery> queryProvider;
 
     @Inject
-    Loader(OneOffRequestContext requestContext,
-        Provider<InternalChangeQuery> queryProvider) {
+    Loader(OneOffRequestContext requestContext, Provider<InternalChangeQuery> queryProvider) {
       this.requestContext = requestContext;
       this.queryProvider = queryProvider;
     }
 
     @Override
     public List<CachedChange> load(Project.NameKey key) throws Exception {
-      try (AutoCloseable ctx = requestContext.open()) {
-        List<ChangeData> cds = queryProvider.get()
-            .setRequestedFields(ImmutableSet.of(
-                ChangeField.CHANGE.getName(),
-                ChangeField.REVIEWER.getName()))
-            .byProject(key);
+      try (ManualRequestContext ctx = requestContext.open()) {
+        List<ChangeData> cds =
+            queryProvider
+                .get()
+                .setRequestedFields(
+                    ImmutableSet.of(ChangeField.CHANGE.getName(), ChangeField.REVIEWER.getName()))
+                .byProject(key);
         List<CachedChange> result = new ArrayList<>(cds.size());
         for (ChangeData cd : cds) {
-          result.add(new AutoValue_SearchingChangeCacheImpl_CachedChange(
-              cd.change(), cd.getReviewers()));
+          result.add(
+              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.getReviewers()));
         }
         return Collections.unmodifiableList(result);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java
index 68fa98a..feb32fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java
@@ -17,13 +17,9 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
-/**
- * Marker on the global {@link WorkQueue.Executor} used to send email.
- */
+/** Marker on the global {@link WorkQueue.Executor} used to send email. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface SendEmailExecutor {
-}
+public @interface SendEmailExecutor {}
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 470ea84..acd0e42 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
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,15 +26,29 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
-import com.google.gerrit.server.git.BatchUpdate.Listener;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 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.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RepoOnlyOp;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -55,25 +68,10 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-
 public class SubmoduleOp {
 
-  /**
-   * Only used for branches without code review changes
-   */
-  public class GitlinkOp extends BatchUpdate.RepoOnlyOp {
+  /** Only used for branches without code review changes */
+  public class GitlinkOp implements RepoOnlyOp {
     private final Branch.NameKey branch;
 
     GitlinkOp(Branch.NameKey branch) {
@@ -91,8 +89,7 @@
   }
 
   public interface Factory {
-    SubmoduleOp create(
-        Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm);
+    SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm);
   }
 
   private static final Logger log = LoggerFactory.getLogger(SubmoduleOp.class);
@@ -101,6 +98,7 @@
   private final PersonIdent myIdent;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
+  private final BatchUpdate.Factory batchUpdateFactory;
   private final VerboseSuperprojectUpdate verboseSuperProject;
   private final boolean enableSuperProjectSubscriptions;
   private final MergeOpRepoManager orm;
@@ -115,7 +113,7 @@
   // sorted version of affectedBranches
   private final ImmutableSet<Branch.NameKey> sortedBranches;
   // map of superproject branch and its submodule subscriptions
-  private final Multimap<Branch.NameKey, SubmoduleSubscription> targets;
+  private final SetMultimap<Branch.NameKey, SubmoduleSubscription> targets;
   // map of superproject and its branches which has submodule subscriptions
   private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
@@ -126,29 +124,30 @@
       @GerritServerConfig Config cfg,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
+      BatchUpdate.Factory batchUpdateFactory,
       @Assisted Set<Branch.NameKey> updatedBranches,
-      @Assisted MergeOpRepoManager orm) throws SubmoduleException {
+      @Assisted MergeOpRepoManager orm)
+      throws SubmoduleException {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
     this.verboseSuperProject =
-        cfg.getEnum("submodule", null, "verboseSuperprojectUpdate",
-            VerboseSuperprojectUpdate.TRUE);
-    this.enableSuperProjectSubscriptions = cfg.getBoolean("submodule",
-        "enableSuperProjectSubscriptions", true);
+        cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
+    this.enableSuperProjectSubscriptions =
+        cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true);
     this.orm = orm;
     this.updatedBranches = updatedBranches;
-    this.targets = HashMultimap.create();
+    this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
     this.affectedBranches = new HashSet<>();
     this.branchTips = new HashMap<>();
     this.branchGitModules = new HashMap<>();
-    this.branchesByProject = HashMultimap.create();
+    this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
     this.sortedBranches = calculateSubscriptionMap();
   }
 
-  private ImmutableSet<Branch.NameKey> calculateSubscriptionMap()
-      throws SubmoduleException {
+  private ImmutableSet<Branch.NameKey> calculateSubscriptionMap() throws SubmoduleException {
     if (!enableSuperProjectSubscriptions) {
       logDebug("Updating superprojects disabled");
       return null;
@@ -161,8 +160,7 @@
         continue;
       }
 
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<Branch.NameKey>(),
-          allVisited);
+      searchForSuperprojects(updatedBranch, new LinkedHashSet<Branch.NameKey>(), allVisited);
     }
 
     // Since the searchForSuperprojects will add all branches (related or
@@ -174,7 +172,8 @@
     return ImmutableSet.copyOf(allVisited);
   }
 
-  private void searchForSuperprojects(Branch.NameKey current,
+  private void searchForSuperprojects(
+      Branch.NameKey current,
       LinkedHashSet<Branch.NameKey> currentVisited,
       LinkedHashSet<Branch.NameKey> allVisited)
       throws SubmoduleException {
@@ -182,8 +181,8 @@
 
     if (currentVisited.contains(current)) {
       throw new SubmoduleException(
-          "Branch level circular subscriptions detected:  " +
-              printCircularPath(currentVisited, current));
+          "Branch level circular subscriptions detected:  "
+              + printCircularPath(currentVisited, current));
     }
 
     if (allVisited.contains(current)) {
@@ -203,8 +202,7 @@
         affectedBranches.add(sub.getSubmodule());
       }
     } catch (IOException e) {
-      throw new SubmoduleException("Cannot find superprojects for " + current,
-          e);
+      throw new SubmoduleException("Cannot find superprojects for " + current, e);
     }
     currentVisited.remove(current);
     allVisited.add(current);
@@ -238,8 +236,8 @@
     return sb.toString();
   }
 
-  private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src,
-      SubscribeSection s) throws IOException {
+  private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src, SubscribeSection s)
+      throws IOException {
     Collection<Branch.NameKey> ret = new HashSet<>();
     logDebug("Inspecting SubscribeSection " + s);
     for (RefSpec r : s.getMatchingRefSpecs()) {
@@ -249,8 +247,7 @@
       }
       if (r.isWildcard()) {
         // refs/heads/*[:refs/somewhere/*]
-        ret.add(new Branch.NameKey(s.getProject(),
-            r.expandFromSource(src.get()).getDestination()));
+        ret.add(new Branch.NameKey(s.getProject(), r.expandFromSource(src.get()).getDestination()));
       } else {
         // e.g. refs/heads/master[:refs/heads/stable]
         String dest = r.getDestination();
@@ -268,7 +265,7 @@
       }
       OpenRepo or;
       try {
-        or = orm.openRepo(s.getProject());
+        or = orm.getRepo(s.getProject());
       } catch (NoSuchProjectException e) {
         // A project listed a non existent project to be allowed
         // to subscribe to it. Allow this for now, i.e. no exception is
@@ -276,8 +273,7 @@
         continue;
       }
 
-      for (Ref ref : or.repo.getRefDatabase().getRefs(
-          RefNames.REFS_HEADS).values()) {
+      for (Ref ref : or.repo.getRefDatabase().getRefs(RefNames.REFS_HEADS).values()) {
         if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
           continue;
         }
@@ -287,27 +283,23 @@
         }
       }
     }
-    logDebug("Returning possible branches: " + ret +
-        "for project " + s.getProject());
+    logDebug("Returning possible branches: " + ret + "for project " + s.getProject());
     return ret;
   }
 
-  public Collection<SubmoduleSubscription>
-      superProjectSubscriptionsForSubmoduleBranch(Branch.NameKey srcBranch)
-      throws IOException {
+  public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
+      Branch.NameKey srcBranch) throws IOException {
     logDebug("Calculating possible superprojects for " + srcBranch);
     Collection<SubmoduleSubscription> ret = new ArrayList<>();
     Project.NameKey srcProject = srcBranch.getParentKey();
     ProjectConfig cfg = projectCache.get(srcProject).getConfig();
-    for (SubscribeSection s : projectStateFactory.create(cfg)
-        .getSubscribeSections(srcBranch)) {
+    for (SubscribeSection s : projectStateFactory.create(cfg).getSubscribeSections(srcBranch)) {
       logDebug("Checking subscribe section " + s);
-      Collection<Branch.NameKey> branches =
-          getDestinationBranches(srcBranch, s);
+      Collection<Branch.NameKey> branches = getDestinationBranches(srcBranch, s);
       for (Branch.NameKey targetBranch : branches) {
         Project.NameKey targetProject = targetBranch.getParentKey();
         try {
-          OpenRepo or = orm.openRepo(targetProject);
+          OpenRepo or = orm.getRepo(targetProject);
           ObjectId id = or.repo.resolve(targetBranch.get());
           if (id == null) {
             logDebug("The branch " + targetBranch + " doesn't exist.");
@@ -343,28 +335,25 @@
         if (branchesByProject.containsKey(project)) {
           superProjects.add(project);
           // get a new BatchUpdate for the super project
-          OpenRepo or = orm.openRepo(project);
+          OpenRepo or = orm.getRepo(project);
           for (Branch.NameKey branch : branchesByProject.get(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
       }
-      BatchUpdate.execute(orm.batchUpdates(superProjects), Listener.NONE,
-          orm.getSubmissionId());
-    } catch (RestApiException | UpdateException | IOException |
-        NoSuchProjectException e) {
+      batchUpdateFactory.execute(
+          orm.batchUpdates(superProjects), BatchUpdateListener.NONE, orm.getSubmissionId(), false);
+    } catch (RestApiException | UpdateException | IOException | NoSuchProjectException e) {
       throw new SubmoduleException("Cannot update gitlinks", e);
     }
   }
 
-  /**
-   * Create a separate gitlink commit
-   */
+  /** Create a separate gitlink commit */
   public CodeReviewCommit composeGitlinksCommit(final Branch.NameKey subscriber)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.openRepo(subscriber.getParentKey());
+      or = orm.getRepo(subscriber.getParentKey());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
@@ -379,14 +368,20 @@
             "The branch was probably deleted from the subscriber repository");
       }
       currentCommit = or.rw.parseCommit(r.getObjectId());
+      addBranchTip(subscriber, currentCommit);
     }
 
     StringBuilder msgbuf = new StringBuilder("");
     PersonIdent author = null;
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
+    int count = 0;
     for (SubmoduleSubscription s : targets.get(subscriber)) {
+      if (count > 0) {
+        msgbuf.append("\n\n");
+      }
       RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
+      count++;
       if (newCommit != null) {
         if (author == null) {
           author = newCommit.getAuthorIdent();
@@ -416,15 +411,13 @@
     return or.rw.parseCommit(id);
   }
 
-  /**
-   * Amend an existing commit with gitlink updates
-   */
+  /** Amend an existing commit with gitlink updates */
   public CodeReviewCommit composeGitlinksCommit(
       final Branch.NameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
-      or = orm.openRepo(subscriber.getParentKey());
+      or = orm.getRepo(subscriber.getParentKey());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
@@ -447,9 +440,8 @@
     commit.setTreeId(newTreeId);
     commit.setParentIds(currentCommit.getParents());
     if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      //TODO:czhen handle cherrypick footer
-      commit.setMessage(currentCommit.getFullMessage()
-          + "\n\n* submodules:\n" + msgbuf.toString());
+      // TODO:czhen handle cherrypick footer
+      commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
     } else {
       commit.setMessage(currentCommit.getFullMessage());
     }
@@ -461,12 +453,12 @@
     return newCommit;
   }
 
-  private RevCommit updateSubmodule(DirCache dc, DirCacheEditor ed,
-      StringBuilder msgbuf, final SubmoduleSubscription s)
+  private RevCommit updateSubmodule(
+      DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, final SubmoduleSubscription s)
       throws SubmoduleException, IOException {
     OpenRepo subOr;
     try {
-      subOr = orm.openRepo(s.getSubmodule().getParentKey());
+      subOr = orm.getRepo(s.getSubmodule().getParentKey());
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access submodule", e);
     }
@@ -475,15 +467,19 @@
     RevCommit oldCommit = null;
     if (dce != null) {
       if (!dce.getFileMode().equals(FileMode.GITLINK)) {
-        String errMsg = "Requested to update gitlink " + s.getPath() + " in "
-            + s.getSubmodule().getParentKey().get() + " but entry "
-            + "doesn't have gitlink file mode.";
+        String errMsg =
+            "Requested to update gitlink "
+                + s.getPath()
+                + " in "
+                + s.getSubmodule().getParentKey().get()
+                + " but entry "
+                + "doesn't have gitlink file mode.";
         throw new SubmoduleException(errMsg);
       }
       oldCommit = subOr.rw.parseCommit(dce.getObjectId());
     }
 
-    final RevCommit newCommit;
+    final CodeReviewCommit newCommit;
     if (branchTips.containsKey(s.getSubmodule())) {
       newCommit = branchTips.get(s.getSubmodule());
     } else {
@@ -493,19 +489,21 @@
         return null;
       }
       newCommit = subOr.rw.parseCommit(ref.getObjectId());
+      addBranchTip(s.getSubmodule(), newCommit);
     }
 
     if (Objects.equals(newCommit, oldCommit)) {
       // gitlink have already been updated for this submodule
       return null;
     }
-    ed.add(new PathEdit(s.getPath()) {
-      @Override
-      public void apply(DirCacheEntry ent) {
-        ent.setFileMode(FileMode.GITLINK);
-        ent.setObjectId(newCommit.getId());
-      }
-    });
+    ed.add(
+        new PathEdit(s.getPath()) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.GITLINK);
+            ent.setObjectId(newCommit.getId());
+          }
+        });
 
     if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
       createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
@@ -514,11 +512,16 @@
     return newCommit;
   }
 
-  private void createSubmoduleCommitMsg(StringBuilder msgbuf,
-      SubmoduleSubscription s, OpenRepo subOr, RevCommit newCommit, RevCommit oldCommit)
+  private void createSubmoduleCommitMsg(
+      StringBuilder msgbuf,
+      SubmoduleSubscription s,
+      OpenRepo subOr,
+      RevCommit newCommit,
+      RevCommit oldCommit)
       throws SubmoduleException {
     msgbuf.append("* Update " + s.getPath());
     msgbuf.append(" from branch '" + s.getSubmodule().getShortName() + "'");
+    msgbuf.append("\n  to " + newCommit.getName());
 
     // newly created submodule gitlink, do not append whole history
     if (oldCommit == null) {
@@ -538,27 +541,27 @@
         }
       }
     } catch (IOException e) {
-      throw new SubmoduleException("Could not perform a revwalk to "
-          + "create superproject commit message", e);
+      throw new SubmoduleException(
+          "Could not perform a revwalk to create superproject commit message", e);
     }
   }
 
-  private static DirCache readTree(RevWalk rw, ObjectId base)
-      throws IOException {
+  private static DirCache readTree(RevWalk rw, ObjectId base) throws IOException {
     final DirCache dc = DirCache.newInCore();
     final DirCacheBuilder b = dc.builder();
-    b.addTree(new byte[0], // no prefix path
+    b.addTree(
+        new byte[0], // no prefix path
         DirCacheEntry.STAGE_0, // standard stage
-        rw.getObjectReader(), rw.parseTree(base));
+        rw.getObjectReader(),
+        rw.parseTree(base));
     b.finish();
     return dc;
   }
 
-  public ImmutableSet<Project.NameKey> getProjectsInOrder()
-      throws SubmoduleException {
+  public ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
     for (Project.NameKey project : branchesByProject.keySet()) {
-      addAllSubmoduleProjects(project, new LinkedHashSet<Project.NameKey>(), projects);
+      addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
     }
 
     for (Branch.NameKey branch : updatedBranches) {
@@ -567,14 +570,14 @@
     return ImmutableSet.copyOf(projects);
   }
 
-  private void addAllSubmoduleProjects(Project.NameKey project,
+  private void addAllSubmoduleProjects(
+      Project.NameKey project,
       LinkedHashSet<Project.NameKey> current,
       LinkedHashSet<Project.NameKey> projects)
       throws SubmoduleException {
     if (current.contains(project)) {
       throw new SubmoduleException(
-          "Project level circular subscriptions detected:  " +
-              printCircularPath(current, project));
+          "Project level circular subscriptions detected:  " + printCircularPath(current, project));
     }
 
     if (projects.contains(project)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
index d77c7e2..c417965 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import org.slf4j.Logger;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.StringReader;
@@ -31,12 +29,13 @@
     String parse(String str);
   }
 
-  public static Parser TRIM = new Parser() {
-    @Override
-    public String parse(String str) {
-       return str.trim();
-    }
-  };
+  public static Parser TRIM =
+      new Parser() {
+        @Override
+        public String parse(String str) {
+          return str.trim();
+        }
+      };
 
   protected static class Row {
     public String left;
@@ -48,8 +47,9 @@
     }
   }
 
-  protected static List<Row> parse(String text, String filename, Parser left,
-      Parser right, ValidationError.Sink errors) throws IOException {
+  protected static List<Row> parse(
+      String text, String filename, Parser left, Parser right, ValidationError.Sink errors)
+      throws IOException {
     List<Row> rows = new ArrayList<>();
     BufferedReader br = new BufferedReader(new StringReader(text));
     String s;
@@ -60,8 +60,7 @@
 
       int tab = s.indexOf('\t');
       if (tab < 0) {
-        errors.error(new ValidationError(filename, lineNumber,
-            "missing tab delimiter"));
+        errors.error(new ValidationError(filename, lineNumber, "missing tab delimiter"));
         continue;
       }
 
@@ -86,8 +85,7 @@
     return map;
   }
 
-  protected static String asText(String left, String right,
-      Map<String, String> entries) {
+  protected static String asText(String left, String right, Map<String, String> entries) {
     if (entries.isEmpty()) {
       return null;
     }
@@ -146,9 +144,4 @@
     }
     return r.toString();
   }
-
-  public static ValidationError.Sink createLoggerSink(String file, Logger log) {
-    return ValidationError.createLoggerSink("Error parsing file " + file + ": ",
-        log);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagCache.java
index dec1768..0822161 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagCache.java
@@ -21,13 +21,11 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class TagCache {
@@ -53,20 +51,19 @@
 
   /**
    * Advise the cache that a reference fast-forwarded.
-   * <p>
-   * This operation is not necessary, the cache will automatically detect changes
-   * made to references and update itself on demand. However, this method may
-   * allow the cache to update more quickly and reuse the caller's computation of
-   * the fast-forward status of a branch.
+   *
+   * <p>This operation is not necessary, the cache will automatically detect changes made to
+   * references and update itself on demand. However, this method may allow the cache to update more
+   * quickly and reuse the caller's computation of the fast-forward status of a branch.
    *
    * @param name project the branch is contained in.
    * @param refName the branch name.
-   * @param oldValue the old value, before the fast-forward. The cache
-   *        will only update itself if it is still using this old value.
+   * @param oldValue the old value, before the fast-forward. The cache will only update itself if it
+   *     is still using this old value.
    * @param newValue the current value, after the fast-forward.
    */
-  public void updateFastForward(Project.NameKey name, String refName,
-      ObjectId oldValue, ObjectId newValue) {
+  public void updateFastForward(
+      Project.NameKey name, String refName, ObjectId oldValue, ObjectId newValue) {
     // Be really paranoid and null check everything. This method should
     // never fail with an exception. Some of these references can be null
     // (e.g. not all projects are cached, or the cache is not current).
@@ -111,8 +108,7 @@
 
     transient TagSetHolder holder;
 
-    private void readObject(ObjectInputStream in) throws IOException,
-        ClassNotFoundException {
+    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
       holder = new TagSetHolder(new Project.NameKey(in.readUTF()));
       if (in.readBoolean()) {
         TagSet tags = new TagSet(holder.getProjectName());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagMatcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagMatcher.java
index e550927..6e46d76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagMatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagMatcher.java
@@ -15,15 +15,13 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.server.git.TagSet.Tag;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-
 import java.util.ArrayList;
 import java.util.BitSet;
 import java.util.Collection;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 class TagMatcher {
   final BitSet mask = new BitSet();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSet.java
index 3c7666e..f131bc9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSet.java
@@ -19,7 +19,13 @@
 
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.BitSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.Constants;
@@ -34,14 +40,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import java.util.BitSet;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicReference;
-
 class TagSet {
   private static final Logger log = LoggerFactory.getLogger(TagSet.class);
 
@@ -59,8 +57,7 @@
     return tags.get(id);
   }
 
-  boolean updateFastForward(String refName, ObjectId oldValue,
-      ObjectId newValue) {
+  boolean updateFastForward(String refName, ObjectId oldValue, ObjectId newValue) {
     CachedRef ref = refs.get(refName);
     if (ref != null) {
       // compareAndSet works on reference equality, but this operation
@@ -191,8 +188,7 @@
     }
   }
 
-  void readObject(ObjectInputStream in) throws IOException,
-      ClassNotFoundException {
+  void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
     int refCnt = in.readInt();
     for (int i = 0; i < refCnt; i++) {
       String name = in.readUTF();
@@ -331,8 +327,7 @@
   }
 
   static boolean skip(Ref ref) {
-    return ref.isSymbolic() || ref.getObjectId() == null
-        || PatchSet.isChangeRef(ref.getName());
+    return ref.isSymbolic() || ref.getObjectId() == null || PatchSet.isChangeRef(ref.getName());
   }
 
   private static boolean isTag(Ref ref) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java
index 5260aab..e1faa65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -14,15 +14,13 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
-import com.google.gerrit.reviewdb.client.Project;
+import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Collection;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-import java.util.Collection;
-
 class TagSetHolder {
   private final Object buildLock = new Object();
   private final Project.NameKey projectName;
@@ -45,12 +43,7 @@
   }
 
   TagMatcher matcher(TagCache cache, Repository db, Collection<Ref> include) {
-    include = FluentIterable.from(include).filter(new Predicate<Ref>() {
-      @Override
-      public boolean apply(Ref ref) {
-        return !TagSet.skip(ref);
-      }
-    }).toList();
+    include = include.stream().filter(r -> !TagSet.skip(r)).collect(toList());
 
     TagSet tags = this.tags;
     if (tags == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
index bc805c4..773f612 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
@@ -16,28 +16,35 @@
 
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.pack.PackConfig;
 
-import java.util.concurrent.TimeUnit;
-
 @Singleton
 public class TransferConfig {
   private final int timeout;
   private final PackConfig packConfig;
   private final long maxObjectSizeLimit;
   private final String maxObjectSizeLimitFormatted;
+  private final boolean inheritProjectMaxObjectSizeLimit;
 
   @Inject
   TransferConfig(@GerritServerConfig final Config cfg) {
-    timeout = (int) ConfigUtil.getTimeUnit(cfg, "transfer", null, "timeout", //
-        0, TimeUnit.SECONDS);
+    timeout =
+        (int)
+            ConfigUtil.getTimeUnit(
+                cfg,
+                "transfer",
+                null,
+                "timeout", //
+                0,
+                TimeUnit.SECONDS);
     maxObjectSizeLimit = cfg.getLong("receive", "maxObjectSizeLimit", 0);
     maxObjectSizeLimitFormatted = cfg.getString("receive", null, "maxObjectSizeLimit");
+    inheritProjectMaxObjectSizeLimit =
+        cfg.getBoolean("receive", "inheritProjectMaxObjectSizeLimit", false);
 
     packConfig = new PackConfig();
     packConfig.setDeltaCompress(false);
@@ -62,13 +69,7 @@
     return maxObjectSizeLimitFormatted;
   }
 
-  public long getEffectiveMaxObjectSizeLimit(ProjectState p) {
-    long global = getMaxObjectSizeLimit();
-    long local = p.getMaxObjectSizeLimit();
-    if (global > 0 && local > 0) {
-      return Math.min(global, local);
-    }
-    // zero means "no limit", in this case the max is more limiting
-    return Math.max(global, local);
+  public boolean getInheritProjectMaxObjectSizeLimit() {
+    return inheritProjectMaxObjectSizeLimit;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UpdateException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/UpdateException.java
deleted file mode 100644
index 087af6c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/UpdateException.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-/** Exception type thrown by {@link BatchUpdate} steps. */
-public class UpdateException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public UpdateException(String message) {
-    super(message);
-  }
-
-  public UpdateException(Throwable cause) {
-    super(cause);
-  }
-
-  public UpdateException(String message, Throwable cause) {
-    super(message, cause);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
index d4fcbcd..aa02fba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.metrics.Timer1;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import org.eclipse.jgit.storage.pack.PackStatistics;
 import org.eclipse.jgit.transport.PostUploadHook;
 
@@ -45,47 +44,51 @@
   @Inject
   UploadPackMetricsHook(MetricMaker metricMaker) {
     Field<Operation> operation = Field.ofEnum(Operation.class, "operation");
-    requestCount = metricMaker.newCounter(
-        "git/upload-pack/request_count",
-        new Description("Total number of git-upload-pack requests")
-          .setRate()
-          .setUnit("requests"),
-        operation);
+    requestCount =
+        metricMaker.newCounter(
+            "git/upload-pack/request_count",
+            new Description("Total number of git-upload-pack requests")
+                .setRate()
+                .setUnit("requests"),
+            operation);
 
-    counting = metricMaker.newTimer(
-        "git/upload-pack/phase_counting",
-        new Description("Time spent in the 'Counting...' phase")
-          .setCumulative()
-          .setUnit(Units.MILLISECONDS),
-        operation);
+    counting =
+        metricMaker.newTimer(
+            "git/upload-pack/phase_counting",
+            new Description("Time spent in the 'Counting...' phase")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            operation);
 
-    compressing = metricMaker.newTimer(
-        "git/upload-pack/phase_compressing",
-        new Description("Time spent in the 'Compressing...' phase")
-          .setCumulative()
-          .setUnit(Units.MILLISECONDS),
-        operation);
+    compressing =
+        metricMaker.newTimer(
+            "git/upload-pack/phase_compressing",
+            new Description("Time spent in the 'Compressing...' phase")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            operation);
 
-    writing = metricMaker.newTimer(
-        "git/upload-pack/phase_writing",
-        new Description("Time spent transferring bytes to client")
-          .setCumulative()
-          .setUnit(Units.MILLISECONDS),
-        operation);
+    writing =
+        metricMaker.newTimer(
+            "git/upload-pack/phase_writing",
+            new Description("Time spent transferring bytes to client")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            operation);
 
-    packBytes = metricMaker.newHistogram(
-        "git/upload-pack/pack_bytes",
-        new Description("Distribution of sizes of packs sent to clients")
-          .setCumulative()
-          .setUnit(Units.BYTES),
-        operation);
+    packBytes =
+        metricMaker.newHistogram(
+            "git/upload-pack/pack_bytes",
+            new Description("Distribution of sizes of packs sent to clients")
+                .setCumulative()
+                .setUnit(Units.BYTES),
+            operation);
   }
 
   @Override
   public void onPostUpload(PackStatistics stats) {
     Operation op = Operation.FETCH;
-    if (stats.getUninterestingObjects() == null
-        || stats.getUninterestingObjects().isEmpty()) {
+    if (stats.getUninterestingObjects() == null || stats.getUninterestingObjects().isEmpty()) {
       op = Operation.CLONE;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
index a09466d..859e40d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/UserConfigSections.java
@@ -29,12 +29,16 @@
   public static final String KEY_MATCH = "match";
   public static final String KEY_TOKEN = "token";
 
+  /** The table column user preferences. */
+  public static final String CHANGE_TABLE = "changeTable";
+
+  public static final String CHANGE_TABLE_COLUMN = "column";
+
   /** The edit user preferences. */
   public static final String EDIT = "edit";
 
   /** The diff user preferences. */
   public static final String DIFF = "diff";
 
-  private UserConfigSections() {
-  }
+  private UserConfigSections() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java
index e6a8ae4..28d5171 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.git;
 
-import org.slf4j.Logger;
+import java.util.Objects;
 
 /** Indicates a problem with Git based data. */
 public class ValidationError {
@@ -45,12 +45,20 @@
     void error(ValidationError error);
   }
 
-  public static Sink createLoggerSink(final String message, final Logger log) {
-    return new ValidationError.Sink() {
-          @Override
-          public void error(ValidationError error) {
-            log.error(message + error.getMessage());
-          }
-        };
+  @Override
+  public boolean equals(Object o) {
+    if (o == this) {
+      return true;
+    }
+    if (o instanceof ValidationError) {
+      ValidationError that = (ValidationError) o;
+      return Objects.equals(this.message, that.message);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(message);
   }
 }
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 c959443..2b9151b 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
@@ -15,7 +15,12 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.base.MoreObjects;
-
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -46,25 +51,16 @@
 import org.eclipse.jgit.util.ChangeIdUtil;
 import org.eclipse.jgit.util.RawParseUtils;
 
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.StringReader;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-
 /**
  * Support for metadata stored within a version controlled branch.
- * <p>
- * Implementors are responsible for supplying implementations of the onLoad and
- * onSave methods to read from the repository, or format an update that can
- * later be written back to the repository.
+ *
+ * <p>Implementors are responsible for supplying implementations of the onLoad and onSave methods to
+ * read from the repository, or format an update that can later be written back to the repository.
  */
 public abstract class VersionedMetaData {
   /**
-   * Path information that does not hold references to any repository
-   * data structures, allowing the application to retain this object
-   * for long periods of time.
+   * Path information that does not hold references to any repository data structures, allowing the
+   * application to retain this object for long periods of time.
    */
   public static class PathInfo {
     public final FileMode fileMode;
@@ -96,8 +92,8 @@
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  protected abstract boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException;
+  protected abstract boolean onSave(CommitBuilder commit)
+      throws IOException, ConfigInvalidException;
 
   /** @return revision of the metadata that was loaded. */
   public ObjectId getRevision() {
@@ -106,9 +102,9 @@
 
   /**
    * Load the current version from the branch.
-   * <p>
-   * The repository is not held after the call completes, allowing the
-   * application to retain this object for long periods of time.
+   *
+   * <p>The repository is not held after the call completes, allowing the application to retain this
+   * object for long periods of time.
    *
    * @param db repository to access.
    * @throws IOException
@@ -121,22 +117,20 @@
 
   /**
    * Load a specific version from the repository.
-   * <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 repository is not held after the call completes, allowing the
-   * application to retain this object for long periods of time.
+   *
+   * <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 repository is not held after the call completes, allowing the application to retain this
+   * object for long periods of time.
    *
    * @param db repository to access.
    * @param id revision to load.
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  public void load(Repository db, ObjectId id) throws IOException,
-      ConfigInvalidException {
+  public void load(Repository db, ObjectId id) throws IOException, ConfigInvalidException {
     try (RevWalk walk = new RevWalk(db)) {
       load(walk, id);
     }
@@ -144,40 +138,35 @@
 
   /**
    * 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.
+   *
+   * <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 {
+  public void load(RevWalk walk, ObjectId id) throws IOException, ConfigInvalidException {
     this.reader = walk.getObjectReader();
     try {
-      revision = id != null ? new RevWalk(reader).parseCommit(id) : null;
+      revision = id != null ? walk.parseCommit(id) : null;
       onLoad();
     } finally {
       reader = null;
     }
   }
 
-  public void load(MetaDataUpdate update) throws IOException,
-      ConfigInvalidException {
+  public void load(MetaDataUpdate update) throws IOException, ConfigInvalidException {
     load(update.getRepository());
   }
 
-  public void load(MetaDataUpdate update, ObjectId id) throws IOException,
-      ConfigInvalidException {
+  public void load(MetaDataUpdate update, ObjectId id) throws IOException, ConfigInvalidException {
     load(update.getRepository(), id);
   }
 
@@ -186,9 +175,8 @@
    *
    * @param update helper information to define the update that will occur.
    * @return the commit that was created
-   * @throws IOException if there is a storage problem and the update cannot be
-   *         executed as requested or if it failed because of a concurrent
-   *         update to the same reference
+   * @throws IOException if there is a storage problem and the update cannot be executed as
+   *     requested or if it failed because of a concurrent update to the same reference
    */
   public RevCommit commit(MetaDataUpdate update) throws IOException {
     BatchMetaDataUpdate batch = openUpdate(update);
@@ -206,9 +194,8 @@
    * @param update helper information to define the update that will occur.
    * @param refName name of the ref that should be created
    * @return the commit that was created
-   * @throws IOException if there is a storage problem and the update cannot be
-   *         executed as requested or if it failed because of a concurrent
-   *         update to the same reference
+   * @throws IOException if there is a storage problem and the update cannot be executed as
+   *     requested or if it failed because of a concurrent update to the same reference
    */
   public RevCommit commitToNewRef(MetaDataUpdate update, String refName) throws IOException {
     BatchMetaDataUpdate batch = openUpdate(update);
@@ -222,25 +209,31 @@
 
   public interface BatchMetaDataUpdate {
     void write(CommitBuilder commit) throws IOException;
+
     void write(VersionedMetaData config, CommitBuilder commit) throws IOException;
+
     RevCommit createRef(String refName) throws IOException;
+
     void removeRef(String refName) throws IOException;
+
     RevCommit commit() throws IOException;
+
     RevCommit commitAt(ObjectId revision) throws IOException;
+
     void close();
   }
 
   /**
    * Open a batch of updates to the same metadata ref.
-   * <p>
-   * This allows making multiple commits to a single metadata ref, at the end of
-   * which is a single ref update. For batching together updates to multiple
-   * refs (each consisting of one or more commits against their respective
-   * refs), create the {@link MetaDataUpdate} with a {@link BatchRefUpdate}.
-   * <p>
-   * A ref update produced by this {@link BatchMetaDataUpdate} is only committed
-   * if there is no associated {@link BatchRefUpdate}. As a result, the
-   * configured ref updated event is not fired if there is an associated batch.
+   *
+   * <p>This allows making multiple commits to a single metadata ref, at the end of which is a
+   * single ref update. For batching together updates to multiple refs (each consisting of one or
+   * more commits against their respective refs), create the {@link MetaDataUpdate} with a {@link
+   * BatchRefUpdate}.
+   *
+   * <p>A ref update produced by this {@link BatchMetaDataUpdate} is only committed if there is no
+   * associated {@link BatchRefUpdate}. As a result, the configured ref updated event is not fired
+   * if there is an associated batch.
    *
    * @param update helper info about the update.
    * @throws IOException if the update failed.
@@ -272,8 +265,9 @@
           config.inserter = inserter;
           return config.onSave(commit);
         } catch (ConfigInvalidException e) {
-          throw new IOException("Cannot update " + getRefName() + " in "
-              + db.getDirectory() + ": " + e.getMessage(), e);
+          throw new IOException(
+              "Cannot update " + getRefName() + " in " + db.getDirectory() + ": " + e.getMessage(),
+              e);
         } finally {
           config.newTree = nt;
           config.reader = r;
@@ -313,8 +307,11 @@
 
         if (update.insertChangeId()) {
           ObjectId id =
-              ChangeIdUtil.computeChangeId(res, getRevision(),
-                  commit.getAuthor(), commit.getCommitter(),
+              ChangeIdUtil.computeChangeId(
+                  res,
+                  getRevision(),
+                  commit.getAuthor(),
+                  commit.getCommitter(),
                   commit.getMessage());
           commit.setMessage(ChangeIdUtil.insertId(commit.getMessage(), id));
         }
@@ -343,9 +340,16 @@
           case FORCED:
             update.fireGitRefUpdatedEvent(ru);
             return;
+          case LOCK_FAILURE:
+            throw new LockFailureException(
+                "Cannot delete "
+                    + ru.getName()
+                    + " in "
+                    + db.getDirectory()
+                    + ": "
+                    + ru.getResult());
           case FAST_FORWARD:
           case IO_FAILURE:
-          case LOCK_FAILURE:
           case NEW:
           case NOT_ATTEMPTED:
           case NO_CHANGE:
@@ -353,8 +357,13 @@
           case REJECTED_CURRENT_BRANCH:
           case RENAMED:
           default:
-            throw new IOException("Cannot delete " + ru.getName() + " in "
-                + db.getDirectory() + ": " + ru.getResult());
+            throw new IOException(
+                "Cannot delete "
+                    + ru.getName()
+                    + " in "
+                    + db.getDirectory()
+                    + ": "
+                    + ru.getResult());
         }
       }
 
@@ -368,8 +377,7 @@
         if (Objects.equals(src, expected)) {
           return revision;
         }
-        return updateRef(MoreObjects.firstNonNull(expected, ObjectId.zeroId()),
-            src, getRefName());
+        return updateRef(MoreObjects.firstNonNull(expected, ObjectId.zeroId()), src, getRefName());
       }
 
       @Override
@@ -388,12 +396,11 @@
         }
       }
 
-      private RevCommit updateRef(AnyObjectId oldId, AnyObjectId newId,
-          String refName) throws IOException {
+      private RevCommit updateRef(AnyObjectId oldId, AnyObjectId newId, String refName)
+          throws IOException {
         BatchRefUpdate bru = update.getBatch();
         if (bru != null) {
-          bru.addCommand(new ReceiveCommand(
-              oldId.toObjectId(), newId.toObjectId(), refName));
+          bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
           inserter.flush();
           revision = rw.parseCommit(newId);
           return revision;
@@ -407,8 +414,7 @@
         if (message == null) {
           message = "meta data update";
         }
-        try (BufferedReader reader = new BufferedReader(
-            new StringReader(message))) {
+        try (BufferedReader reader = new BufferedReader(new StringReader(message))) {
           // read the subject line and use it as reflog message
           ru.setRefLogMessage("commit: " + reader.readLine(), true);
         }
@@ -420,24 +426,36 @@
             revision = rw.parseCommit(ru.getNewObjectId());
             update.fireGitRefUpdatedEvent(ru);
             return revision;
+          case LOCK_FAILURE:
+            throw new LockFailureException(
+                "Cannot update "
+                    + ru.getName()
+                    + " in "
+                    + db.getDirectory()
+                    + ": "
+                    + ru.getResult());
           case FORCED:
           case IO_FAILURE:
-          case LOCK_FAILURE:
           case NOT_ATTEMPTED:
           case NO_CHANGE:
           case REJECTED:
           case REJECTED_CURRENT_BRANCH:
           case RENAMED:
           default:
-            throw new IOException("Cannot update " + ru.getName() + " in "
-                + db.getDirectory() + ": " + ru.getResult());
+            throw new IOException(
+                "Cannot update "
+                    + ru.getName()
+                    + " in "
+                    + db.getDirectory()
+                    + ": "
+                    + ru.getResult());
         }
       }
     };
   }
 
-  protected DirCache readTree(RevTree tree) throws IOException,
-      MissingObjectException, IncorrectObjectTypeException {
+  protected DirCache readTree(RevTree tree)
+      throws IOException, MissingObjectException, IncorrectObjectTypeException {
     DirCache dc = DirCache.newInCore();
     if (tree != null) {
       DirCacheBuilder b = dc.builder();
@@ -447,16 +465,22 @@
     return dc;
   }
 
-  protected Config readConfig(String fileName) throws IOException,
-      ConfigInvalidException {
+  protected Config readConfig(String fileName) throws IOException, ConfigInvalidException {
     Config rc = new Config();
     String text = readUTF8(fileName);
     if (!text.isEmpty()) {
       try {
         rc.fromText(text);
       } catch (ConfigInvalidException err) {
-        throw new ConfigInvalidException("Invalid config file " + fileName
-            + " in commit " + revision.name(), err);
+        StringBuilder msg =
+            new StringBuilder("Invalid config file ")
+                .append(fileName)
+                .append(" in commit ")
+                .append(revision.name());
+        if (err.getCause() != null) {
+          msg.append(": ").append(err.getCause());
+        }
+        throw new ConfigInvalidException(msg.toString(), err);
       }
     }
     return rc;
@@ -476,7 +500,6 @@
     if (tw != null) {
       ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
       return obj.getCachedBytes(Integer.MAX_VALUE);
-
     }
     return new byte[] {};
   }
@@ -488,25 +511,26 @@
 
     TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
     if (tw != null) {
-     return tw.getObjectId(0);
+      return tw.getObjectId(0);
     }
 
     return null;
   }
 
   public List<PathInfo> getPathInfos(boolean recursive) throws IOException {
-    TreeWalk tw = new TreeWalk(reader);
-    tw.addTree(revision.getTree());
-    tw.setRecursive(recursive);
-    List<PathInfo> paths = new ArrayList<>();
-    while (tw.next()) {
-      paths.add(new PathInfo(tw));
+    try (TreeWalk tw = new TreeWalk(reader)) {
+      tw.addTree(revision.getTree());
+      tw.setRecursive(recursive);
+      List<PathInfo> paths = new ArrayList<>();
+      while (tw.next()) {
+        paths.add(new PathInfo(tw));
+      }
+      return paths;
     }
-    return paths;
   }
 
-  protected static void set(Config rc, String section, String subsection,
-      String name, String value) {
+  protected static void set(
+      Config rc, String section, String subsection, String name, String value) {
     if (value != null) {
       rc.setString(section, subsection, name, value);
     } else {
@@ -514,8 +538,8 @@
     }
   }
 
-  protected static void set(Config rc, String section, String subsection,
-      String name, boolean value) {
+  protected static void set(
+      Config rc, String section, String subsection, String name, boolean value) {
     if (value) {
       rc.setBoolean(section, subsection, name, value);
     } else {
@@ -523,8 +547,8 @@
     }
   }
 
-  protected static <E extends Enum<?>> void set(Config rc, String section,
-      String subsection, String name, E value, E defaultValue) {
+  protected static <E extends Enum<?>> void set(
+      Config rc, String section, String subsection, String name, E value, E defaultValue) {
     if (value != defaultValue) {
       rc.setEnum(section, subsection, name, value);
     } else {
@@ -544,13 +568,14 @@
     DirCacheEditor editor = newTree.editor();
     if (raw != null && 0 < raw.length) {
       final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
-      editor.add(new PathEdit(fileName) {
-        @Override
-        public void apply(DirCacheEntry ent) {
-          ent.setFileMode(FileMode.REGULAR_FILE);
-          ent.setObjectId(blobId);
-        }
-      });
+      editor.add(
+          new PathEdit(fileName) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.REGULAR_FILE);
+              ent.setObjectId(blobId);
+            }
+          });
     } else {
       editor.add(new DeletePath(fileName));
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
index c339d70..47d416c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -31,7 +31,14 @@
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
@@ -43,18 +50,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 public class VisibleRefFilter extends AbstractAdvertiseRefsHook {
-  private static final Logger log =
-      LoggerFactory.getLogger(VisibleRefFilter.class);
+  private static final Logger log = LoggerFactory.getLogger(VisibleRefFilter.class);
 
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
@@ -114,7 +111,7 @@
       Change.Id changeId;
       Account.Id accountId;
       if (name.startsWith(REFS_CACHE_AUTOMERGE)
-          || (!showMetadata && isMetadata(name))) {
+          || (!showMetadata && isMetadata(projectCtl, name))) {
         continue;
       } else if (RefNames.isRefsEdit(name)) {
         // Edits are visible only to the owning user, if change is visible.
@@ -128,8 +125,8 @@
         }
       } else if ((accountId = Account.Id.fromRef(name)) != null) {
         // Account ref is visible only to corresponding account.
-        if (viewMetadata || (accountId.equals(userId)
-            && projectCtl.controlForRef(name).isVisible())) {
+        if (viewMetadata
+            || (accountId.equals(userId) && projectCtl.controlForRef(name).isVisible())) {
           result.put(name, ref);
         }
       } else if (isTag(ref)) {
@@ -142,6 +139,12 @@
         if (viewMetadata) {
           result.put(name, ref);
         }
+      } else if (projectCtl.getProjectState().isAllUsers()
+          && name.equals(RefNames.REFS_EXTERNAL_IDS)) {
+        // The notes branch with the external IDs of all users must not be exposed to normal users.
+        if (viewMetadata) {
+          result.put(name, ref);
+        }
       } else if (projectCtl.controlForRef(ref.getLeaf().getName()).isVisible()) {
         // Use the leaf to lookup the control data. If the reference is
         // symbolic we want the control around the final target. If its
@@ -154,10 +157,13 @@
     // to identify what tags we can actually reach, and what we cannot.
     //
     if (!deferredTags.isEmpty() && (!result.isEmpty() || filterTagsSeparately)) {
-      TagMatcher tags = tagCache.get(projectName).matcher(
-          tagCache,
-          db,
-          filterTagsSeparately ? filter(db.getAllRefs()).values() : result.values());
+      TagMatcher tags =
+          tagCache
+              .get(projectName)
+              .matcher(
+                  tagCache,
+                  db,
+                  filterTagsSeparately ? filter(db.getAllRefs()).values() : result.values());
       for (Ref tag : deferredTags) {
         if (tags.isReachable(tag)) {
           result.put(tag.getName(), tag);
@@ -169,8 +175,7 @@
   }
 
   private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs) {
-    if (refs.containsKey(REFS_CONFIG)
-        && !projectCtl.controlForRef(REFS_CONFIG).isVisible()) {
+    if (refs.containsKey(REFS_CONFIG) && !projectCtl.controlForRef(REFS_CONFIG).isVisible()) {
       Map<String, Ref> r = new HashMap<>(refs);
       r.remove(REFS_CONFIG);
       return r;
@@ -191,8 +196,8 @@
   }
 
   @Override
-  protected Map<String, Ref> getAdvertisedRefs(Repository repository,
-      RevWalk revWalk) throws ServiceMayNotContinueException {
+  protected Map<String, Ref> getAdvertisedRefs(Repository repository, RevWalk revWalk)
+      throws ServiceMayNotContinueException {
     try {
       return filter(repository.getRefDatabase().getRefs(RefDatabase.ALL));
     } catch (ServiceMayNotContinueException e) {
@@ -233,17 +238,18 @@
     Project project = projectCtl.getProject();
     try {
       Set<Change.Id> visibleChanges = new HashSet<>();
-      for (ChangeData cd : changeCache.getChangeData(
-          reviewDb, project.getNameKey())) {
-        if (projectCtl.controlForIndexedChange(cd.change())
-            .isVisible(reviewDb, cd)) {
+      for (ChangeData cd : changeCache.getChangeData(reviewDb, project.getNameKey())) {
+        if (projectCtl.controlForIndexedChange(cd.change()).isVisible(reviewDb, cd)) {
           visibleChanges.add(cd.getId());
         }
       }
       return visibleChanges;
     } catch (OrmException e) {
-      log.error("Cannot load changes for project " + project.getName()
-          + ", assuming no changes are visible", e);
+      log.error(
+          "Cannot load changes for project "
+              + project.getName()
+              + ", assuming no changes are visible",
+          e);
       return Collections.emptySet();
     }
   }
@@ -259,14 +265,16 @@
       }
       return visibleChanges;
     } catch (IOException | OrmException e) {
-      log.error("Cannot load changes for project " + project
-          + ", assuming no changes are visible", e);
+      log.error(
+          "Cannot load changes for project " + project + ", assuming no changes are visible", e);
       return Collections.emptySet();
     }
   }
 
-  private static boolean isMetadata(String name) {
-    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
+  private static boolean isMetadata(ProjectControl projectCtl, String name) {
+    return name.startsWith(REFS_CHANGES)
+        || RefNames.isRefsEdit(name)
+        || (projectCtl.getProjectState().isAllUsers() && name.equals(RefNames.REFS_EXTERNAL_IDS));
   }
 
   private static boolean isTag(Ref ref) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
index a0f729a..4e9d937 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
@@ -14,18 +14,17 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.common.base.CaseFormat;
+import com.google.common.base.Supplier;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.lang.Thread.UncaughtExceptionHandler;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
@@ -46,6 +45,9 @@
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Delayed execution of tasks using a background thread pool. */
 @Singleton
@@ -59,8 +61,7 @@
     }
 
     @Override
-    public void start() {
-    }
+    public void start() {}
 
     @Override
     public void stop() {
@@ -85,35 +86,62 @@
         }
       };
 
-  private Executor defaultQueue;
-  private int defaultQueueSize;
+  private final Executor defaultQueue;
   private final IdGenerator idGenerator;
+  private final MetricMaker metrics;
   private final CopyOnWriteArrayList<Executor> queues;
 
   @Inject
-  WorkQueue(IdGenerator idGenerator, @GerritServerConfig Config cfg) {
-    this(idGenerator, cfg.getInt("execution", "defaultThreadPoolSize", 1));
+  WorkQueue(IdGenerator idGenerator, @GerritServerConfig Config cfg, MetricMaker metrics) {
+    this(idGenerator, cfg.getInt("execution", "defaultThreadPoolSize", 1), metrics);
   }
 
-  public WorkQueue(IdGenerator idGenerator, int defaultThreadPoolSize) {
+  /** Constructor to allow binding the WorkQueue more explicitly in a vhost setup. */
+  public WorkQueue(IdGenerator idGenerator, int defaultThreadPoolSize, MetricMaker metrics) {
     this.idGenerator = idGenerator;
+    this.metrics = metrics;
     this.queues = new CopyOnWriteArrayList<>();
-    this.defaultQueueSize = defaultThreadPoolSize;
+    this.defaultQueue = createQueue(defaultThreadPoolSize, "WorkQueue", true);
   }
 
   /** Get the default work queue, for miscellaneous tasks. */
-  public synchronized Executor getDefaultQueue() {
-    if (defaultQueue == null) {
-      defaultQueue = createQueue(defaultQueueSize, "WorkQueue");
-    }
+  public Executor getDefaultQueue() {
     return defaultQueue;
   }
 
-  /** Create a new executor queue. */
-  public Executor createQueue(int poolsize, String prefix) {
-    final Executor r = new Executor(poolsize, prefix);
+  /**
+   * Create a new executor queue.
+   *
+   * <p>Creates a new executor queue without associated metrics. This method is suitable for use by
+   * plugins.
+   *
+   * <p>If metrics are needed, use {@link #createQueue(int, String, boolean)} instead.
+   *
+   * @param poolsize the size of the pool.
+   * @param queueName the name of the queue.
+   */
+  public Executor createQueue(int poolsize, String queueName) {
+    return createQueue(poolsize, queueName, false);
+  }
+
+  /**
+   * Create a new executor queue, optionally with metrics.
+   *
+   * <p>Creates a new executor queue, optionally with associated metrics. Metrics should not be
+   * requested for queues created by plugins.
+   *
+   * @param poolsize the size of the pool.
+   * @param queueName the name of the queue.
+   * @param withMetrics whether to create metrics.
+   */
+  public Executor createQueue(int poolsize, String queueName, boolean withMetrics) {
+    final Executor r = new Executor(poolsize, queueName);
+    if (withMetrics) {
+      log.info("Adding metrics for '{}' queue", queueName);
+      r.buildMetrics(queueName);
+    }
     r.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
-    r.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+    r.setExecuteExistingDelayedTasksAfterShutdownPolicy(true);
     queues.add(r);
     return r;
   }
@@ -182,26 +210,108 @@
     private final ConcurrentHashMap<Integer, Task<?>> all;
     private final String queueName;
 
-    Executor(int corePoolSize, final String prefix) {
-      super(corePoolSize, new ThreadFactory() {
-        private final ThreadFactory parent = Executors.defaultThreadFactory();
-        private final AtomicInteger tid = new AtomicInteger(1);
+    Executor(int corePoolSize, final String queueName) {
+      super(
+          corePoolSize,
+          new ThreadFactory() {
+            private final ThreadFactory parent = Executors.defaultThreadFactory();
+            private final AtomicInteger tid = new AtomicInteger(1);
 
-        @Override
-        public Thread newThread(final Runnable task) {
-          final Thread t = parent.newThread(task);
-          t.setName(prefix + "-" + tid.getAndIncrement());
-          t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
-          return t;
-        }
-      });
+            @Override
+            public Thread newThread(final Runnable task) {
+              final Thread t = parent.newThread(task);
+              t.setName(queueName + "-" + tid.getAndIncrement());
+              t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
+              return t;
+            }
+          });
 
-      all = new ConcurrentHashMap<>( //
-          corePoolSize << 1, // table size
-          0.75f, // load factor
-          corePoolSize + 4 // concurrency level
-          );
-      queueName = prefix;
+      all =
+          new ConcurrentHashMap<>( //
+              corePoolSize << 1, // table size
+              0.75f, // load factor
+              corePoolSize + 4 // concurrency level
+              );
+      this.queueName = queueName;
+    }
+
+    private void buildMetrics(String queueName) {
+      metrics.newCallbackMetric(
+          getMetricName(queueName, "max_pool_size"),
+          Long.class,
+          new Description("Maximum allowed number of threads in the pool")
+              .setGauge()
+              .setUnit("threads"),
+          new Supplier<Long>() {
+            @Override
+            public Long get() {
+              return (long) getMaximumPoolSize();
+            }
+          });
+      metrics.newCallbackMetric(
+          getMetricName(queueName, "pool_size"),
+          Long.class,
+          new Description("Current number of threads in the pool").setGauge().setUnit("threads"),
+          new Supplier<Long>() {
+            @Override
+            public Long get() {
+              return (long) getPoolSize();
+            }
+          });
+      metrics.newCallbackMetric(
+          getMetricName(queueName, "active_threads"),
+          Long.class,
+          new Description("Number number of threads that are actively executing tasks")
+              .setGauge()
+              .setUnit("threads"),
+          new Supplier<Long>() {
+            @Override
+            public Long get() {
+              return (long) getActiveCount();
+            }
+          });
+      metrics.newCallbackMetric(
+          getMetricName(queueName, "scheduled_tasks"),
+          Integer.class,
+          new Description("Number of scheduled tasks in the queue").setGauge().setUnit("tasks"),
+          new Supplier<Integer>() {
+            @Override
+            public Integer get() {
+              return getQueue().size();
+            }
+          });
+      metrics.newCallbackMetric(
+          getMetricName(queueName, "total_scheduled_tasks_count"),
+          Long.class,
+          new Description("Total number of tasks that have been scheduled for execution")
+              .setCumulative()
+              .setUnit("tasks"),
+          new Supplier<Long>() {
+            @Override
+            public Long get() {
+              return (long) getTaskCount();
+            }
+          });
+      metrics.newCallbackMetric(
+          getMetricName(queueName, "total_completed_tasks_count"),
+          Long.class,
+          new Description("Total number of tasks that have completed execution")
+              .setCumulative()
+              .setUnit("tasks"),
+          new Supplier<Long>() {
+            @Override
+            public Long get() {
+              return (long) getCompletedTaskCount();
+            }
+          });
+    }
+
+    private String getMetricName(String queueName, String metricName) {
+      String name =
+          CaseFormat.UPPER_CAMEL.to(
+              CaseFormat.LOWER_UNDERSCORE,
+              queueName.replaceFirst("SSH", "Ssh").replaceAll("-", ""));
+      return metrics.sanitizeMetricName(String.format("queue/%s/%s", name, metricName));
     }
 
     public void unregisterWorkQueue() {
@@ -212,7 +322,7 @@
     protected <V> RunnableScheduledFuture<V> decorateTask(
         final Runnable runnable, RunnableScheduledFuture<V> r) {
       r = super.decorateTask(runnable, r);
-      for (;;) {
+      for (; ; ) {
         final int id = idGenerator.next();
 
         Task<V> task;
@@ -253,9 +363,8 @@
   }
 
   /**
-   * Runnable needing to know it was canceled.
-   * Note that cancel is called only in case the task is not in
-   * progress already.
+   * Runnable needing to know it was canceled. Note that cancel is called only in case the task is
+   * not in progress already.
    */
   public interface CancelableRunnable extends Runnable {
     /** Notifies the runnable it was canceled. */
@@ -263,15 +372,13 @@
   }
 
   /**
-   * Base interface handles the case when task was canceled before
-   * actual execution and in case it was started cancel method is
-   * not called yet the task itself will be destroyed anyway (it
-   * will result in resource opening errors).
-   * This interface gives a chance to implementing classes for
+   * Base interface handles the case when task was canceled before actual execution and in case it
+   * was started cancel method is not called yet the task itself will be destroyed anyway (it will
+   * result in resource opening errors). This interface gives a chance to implementing classes for
    * handling such scenario and act accordingly.
    */
   public interface CanceledWhileRunning extends CancelableRunnable {
-    /** Notifies the runnable it was canceled during execution. **/
+    /** Notifies the runnable it was canceled during execution. * */
     void setCanceledWhileRunning();
   }
 
@@ -279,13 +386,14 @@
   public static class Task<V> implements RunnableScheduledFuture<V> {
     /**
      * Summarized status of a single task.
-     * <p>
-     * Tasks have the following state flow:
+     *
+     * <p>Tasks have the following state flow:
+     *
      * <ol>
-     * <li>{@link #SLEEPING}: if scheduled with a non-zero delay.</li>
-     * <li>{@link #READY}: waiting for an available worker thread.</li>
-     * <li>{@link #RUNNING}: actively executing on a worker thread.</li>
-     * <li>{@link #DONE}: finished executing, if not periodic.</li>
+     *   <li>{@link #SLEEPING}: if scheduled with a non-zero delay.
+     *   <li>{@link #READY}: waiting for an available worker thread.
+     *   <li>{@link #RUNNING}: actively executing on a worker thread.
+     *   <li>{@link #DONE}: finished executing, if not periodic.
      * </ol>
      */
     public enum State {
@@ -293,7 +401,12 @@
       // prefer to see tasks sorted in: done before running,
       // running before ready, ready before sleeping.
       //
-      DONE, CANCELLED, RUNNING, READY, SLEEPING, OTHER
+      DONE,
+      CANCELLED,
+      RUNNING,
+      READY,
+      SLEEPING,
+      OTHER
     }
 
     private final Runnable runnable;
@@ -303,8 +416,7 @@
     private final AtomicBoolean running;
     private final Date startTime;
 
-    Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor,
-        int taskId) {
+    Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor, int taskId) {
       this.runnable = runnable;
       this.task = task;
       this.executor = executor;
@@ -369,7 +481,6 @@
         executor.remove(this);
         executor.purge();
         return true;
-
       }
       return false;
     }
@@ -385,8 +496,8 @@
     }
 
     @Override
-    public V get(long timeout, TimeUnit unit) throws InterruptedException,
-        ExecutionException, TimeoutException {
+    public V get(long timeout, TimeUnit unit)
+        throws InterruptedException, ExecutionException, TimeoutException {
       return task.get(timeout, unit);
     }
 
@@ -427,13 +538,16 @@
 
     @Override
     public String toString() {
-      //This is a workaround to be able to print a proper name when the task
-      //is wrapped into a TrustedListenableFutureTask.
+      // This is a workaround to be able to print a proper name when the task
+      // is wrapped into a TrustedListenableFutureTask.
       try {
-        if (runnable.getClass().isAssignableFrom(Class.forName(
-            "com.google.common.util.concurrent.TrustedListenableFutureTask"))) {
-          Class<?> trustedFutureInterruptibleTask = Class.forName(
-              "com.google.common.util.concurrent.TrustedListenableFutureTask$TrustedFutureInterruptibleTask");
+        if (runnable
+            .getClass()
+            .isAssignableFrom(
+                Class.forName("com.google.common.util.concurrent.TrustedListenableFutureTask"))) {
+          Class<?> trustedFutureInterruptibleTask =
+              Class.forName(
+                  "com.google.common.util.concurrent.TrustedListenableFutureTask$TrustedFutureInterruptibleTask");
           for (Field field : runnable.getClass().getDeclaredFields()) {
             if (field.getType().isAssignableFrom(trustedFutureInterruptibleTask)) {
               field.setAccessible(true);
@@ -449,25 +563,23 @@
             }
           }
         }
-      } catch (ClassNotFoundException | IllegalArgumentException
-          | IllegalAccessException e) {
-        log.debug("Cannot get a proper name for TrustedListenableFutureTask: {}",
-            e.getMessage());
+      } catch (ClassNotFoundException | IllegalArgumentException | IllegalAccessException e) {
+        log.debug("Cannot get a proper name for TrustedListenableFutureTask: {}", e.getMessage());
       }
       return runnable.toString();
     }
   }
 
   /**
-   * Same as Task class, but with a reference to ProjectRunnable, used to
-   * retrieve the project name from the operation queued
-   **/
+   * Same as Task class, but with a reference to ProjectRunnable, used to retrieve the project name
+   * from the operation queued
+   */
   public static class ProjectTask<V> extends Task<V> implements ProjectRunnable {
 
     private final ProjectRunnable runnable;
 
-    ProjectTask(ProjectRunnable runnable, RunnableScheduledFuture<V> task,
-        Executor executor, int taskId) {
+    ProjectTask(
+        ProjectRunnable runnable, RunnableScheduledFuture<V> task, Executor executor, int taskId) {
       super(runnable, task, executor, taskId);
       this.runnable = runnable;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index 31da05c..96b5b55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -22,23 +22,22 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class CherryPick extends SubmitStrategy {
 
@@ -47,8 +46,8 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(
-      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException {
     List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     boolean first = true;
@@ -91,23 +90,30 @@
     }
 
     @Override
-    protected void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, IOException {
+    protected void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
       // If there is only one parent, a cherry-pick can be done by taking the
       // delta relative to that one parent and redoing that on the current merge
       // tip.
       args.rw.parseBody(toMerge);
-      psId = ChangeUtil.nextPatchSetId(
-          args.repo, toMerge.change().currentPatchSetId());
-      String cherryPickCmtMsg =
-          args.mergeUtil.createCherryPickCommitMessage(toMerge);
+      psId = ChangeUtil.nextPatchSetId(args.repo, toMerge.change().currentPatchSetId());
+      RevCommit mergeTip = args.mergeTip.getCurrentTip();
+      args.rw.parseBody(mergeTip);
+      String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
 
-      PersonIdent committer = args.caller.newCommitterIdent(
-          ctx.getWhen(), args.serverIdent.getTimeZone());
+      PersonIdent committer =
+          args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
       try {
-        newCommit = args.mergeUtil.createCherryPickFromCommit(
-            args.repo, args.inserter, args.mergeTip.getCurrentTip(), toMerge,
-            committer, cherryPickCmtMsg, args.rw);
+        newCommit =
+            args.mergeUtil.createCherryPickFromCommit(
+                args.repo,
+                args.inserter,
+                args.mergeTip.getCurrentTip(),
+                toMerge,
+                committer,
+                cherryPickCmtMsg,
+                args.rw,
+                0,
+                false);
       } catch (MergeConflictException mce) {
         // Keep going in the case of a single merge failure; the goal is to
         // cherry-pick as many commits as possible.
@@ -124,29 +130,34 @@
       newCommit.setPatchsetId(psId);
       newCommit.setStatusCode(CommitMergeStatus.CLEAN_PICK);
       args.mergeTip.moveTipTo(newCommit, newCommit);
-      args.commits.put(newCommit);
+      args.commitStatus.put(newCommit);
 
-      ctx.addRefUpdate(
-          new ReceiveCommand(ObjectId.zeroId(), newCommit, psId.toRefName()));
-      patchSetInfo =
-          args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
+      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), newCommit, psId.toRefName()));
+      patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
     }
 
     @Override
-    public PatchSet updateChangeImpl(ChangeContext ctx) throws OrmException,
-         NoSuchChangeException, IOException {
-      if (newCommit == null
-          && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
+    public PatchSet updateChangeImpl(ChangeContext ctx)
+        throws OrmException, NoSuchChangeException, IOException {
+      if (newCommit == null && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
         return null;
       }
-      checkNotNull(newCommit,
+      checkNotNull(
+          newCommit,
           "no new commit produced by CherryPick of %s, expected to fail fast",
           toMerge.change().getId());
       PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
-      PatchSet newPs = args.psUtil.insert(ctx.getDb(), ctx.getRevWalk(),
-          ctx.getUpdate(psId), psId, newCommit, false,
-          prevPs != null ? prevPs.getGroups() : ImmutableList.<String> of(),
-          null);
+      PatchSet newPs =
+          args.psUtil.insert(
+              ctx.getDb(),
+              ctx.getRevWalk(),
+              ctx.getUpdate(psId),
+              psId,
+              newCommit,
+              false,
+              prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
+              null,
+              null);
       ctx.getChange().setCurrentPatchSet(patchSetInfo);
 
       // Don't copy approvals, as this is already taken care of by
@@ -163,8 +174,7 @@
     }
 
     @Override
-    public void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, IOException {
+    public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
       if (args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)) {
         // One or more dependencies were not met. The status was already marked
         // on the commit so we have nothing further to perform at this time.
@@ -176,26 +186,32 @@
       // different first parent. So instead behave as though MERGE_IF_NECESSARY
       // was configured.
       MergeTip mergeTip = args.mergeTip;
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge) &&
-          !args.submoduleOp.hasSubscription(args.destBranch)) {
+      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
+          && !args.submoduleOp.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
         PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
-        CodeReviewCommit result = args.mergeUtil.mergeOneCommit(myIdent,
-            myIdent, args.repo, args.rw, args.inserter, args.destBranch,
-            mergeTip.getCurrentTip(), toMerge);
+        CodeReviewCommit result =
+            args.mergeUtil.mergeOneCommit(
+                myIdent,
+                myIdent,
+                args.repo,
+                args.rw,
+                args.inserter,
+                args.destBranch,
+                mergeTip.getCurrentTip(),
+                toMerge);
         result = amendGitlink(result);
         mergeTip.moveTipTo(result, toMerge);
-        args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-            mergeTip.getCurrentTip(), args.alreadyAccepted);
+        args.mergeUtil.markCleanMerges(
+            args.rw, args.canMergeFlag, mergeTip.getCurrentTip(), args.alreadyAccepted);
       }
     }
   }
 
-  static boolean dryRun(SubmitDryRun.Arguments args,
-      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+  static boolean dryRun(
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
-    return args.mergeUtil.canCherryPick(args.mergeSorter, args.repo,
-        mergeTip, args.rw, toMerge);
+    return args.mergeUtil.canCherryPick(args.mergeSorter, args.repo, mergeTip, args.rw, toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
index bb9d359..e5c253d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
@@ -15,21 +15,22 @@
 package com.google.gerrit.server.git.strategy;
 
 /**
- * Status codes set on {@link com.google.gerrit.server.git.CodeReviewCommit}s by
- * {@link SubmitStrategy} implementations.
+ * Status codes set on {@link com.google.gerrit.server.git.CodeReviewCommit}s by {@link
+ * SubmitStrategy} implementations.
  */
 public enum CommitMergeStatus {
   CLEAN_MERGE("Change has been successfully merged"),
 
   CLEAN_PICK("Change has been successfully cherry-picked"),
 
-  CLEAN_REBASE("Change has been successfully rebased"),
+  CLEAN_REBASE("Change has been successfully rebased and submitted"),
 
   ALREADY_MERGED(""),
 
-  PATH_CONFLICT("Change could not be merged due to a path conflict.\n"
-                  + "\n"
-                  + "Please rebase the change locally and upload the rebased commit for review."),
+  PATH_CONFLICT(
+      "Change could not be merged due to a path conflict.\n"
+          + "\n"
+          + "Please rebase the change locally and upload the rebased commit for review."),
 
   REBASE_MERGE_CONFLICT(
       "Change could not be merged due to a conflict.\n"
@@ -41,23 +42,27 @@
 
   MISSING_DEPENDENCY(""),
 
-  MANUAL_RECURSIVE_MERGE("The change requires a local merge to resolve.\n"
-                       + "\n"
-                       + "Please merge (or rebase) the change locally and upload the resolution for review."),
+  MANUAL_RECURSIVE_MERGE(
+      "The change requires a local merge to resolve.\n"
+          + "\n"
+          + "Please merge (or rebase) the change locally and upload the resolution for review."),
 
-  CANNOT_CHERRY_PICK_ROOT("Cannot cherry-pick an initial commit onto an existing branch.\n"
-                  + "\n"
-                  + "Please merge the change locally and upload the merge commit for review."),
+  CANNOT_CHERRY_PICK_ROOT(
+      "Cannot cherry-pick an initial commit onto an existing branch.\n"
+          + "\n"
+          + "Please merge the change locally and upload the merge commit for review."),
 
-  CANNOT_REBASE_ROOT("Cannot rebase an initial commit onto an existing branch.\n"
-                   + "\n"
-                   + "Please merge the change locally and upload the merge commit for review."),
+  CANNOT_REBASE_ROOT(
+      "Cannot rebase an initial commit onto an existing branch.\n"
+          + "\n"
+          + "Please merge the change locally and upload the merge commit for review."),
 
-  NOT_FAST_FORWARD("Project policy requires all submissions to be a fast-forward.\n"
-                  + "\n"
-                  + "Please rebase the change locally and upload again for review.");
+  NOT_FAST_FORWARD(
+      "Project policy requires all submissions to be a fast-forward.\n"
+          + "\n"
+          + "Please rebase the change locally and upload again for review.");
 
-  private String message;
+  private final String message;
 
   CommitMergeStatus(String message) {
     this.message = message;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
index 66eb40e..7151486 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
@@ -14,10 +14,9 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
-
+import com.google.gerrit.server.update.RepoContext;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -28,13 +27,13 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(
-      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException {
     List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
-    CodeReviewCommit newTipCommit = args.mergeUtil.getFirstFastForward(
-            args.mergeTip.getInitialTip(), args.rw, sorted);
+    CodeReviewCommit newTipCommit =
+        args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
     if (!newTipCommit.equals(args.mergeTip.getInitialTip())) {
       ops.add(new FastForwardOp(args, newTipCommit));
     }
@@ -55,10 +54,9 @@
     }
   }
 
-  static boolean dryRun(SubmitDryRun.Arguments args,
-      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+  static boolean dryRun(
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
-    return args.mergeUtil.canFastForward(args.mergeSorter, mergeTip, args.rw,
-        toMerge);
+    return args.mergeUtil.canFastForward(args.mergeSorter, mergeTip, args.rw, toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
index bb58540..a3b10cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.update.RepoContext;
 
 class FastForwardOp extends SubmitStrategyOp {
   FastForwardOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java
index 12f5993..f252015 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/ImplicitIntegrateOp.java
@@ -17,11 +17,10 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 
 /**
- * Operation for a change that is implicitly integrated by integrating another
- * commit.
- * <p>
- * Updates the change status and message based on {@link
- * CodeReviewCommit#getStatusCode()}, but does not touch the repository.
+ * Operation for a change that is implicitly integrated by integrating another commit.
+ *
+ * <p>Updates the change status and message based on {@link CodeReviewCommit#getStatusCode()}, but
+ * does not touch the repository.
  */
 class ImplicitIntegrateOp extends SubmitStrategyOp {
   ImplicitIntegrateOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
index dfa13dc..ce045f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -27,10 +26,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(
-      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException {
     List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     if (args.mergeTip.getInitialTip() == null && !sorted.isEmpty()) {
       // The branch is unborn. Take a fast-forward resolution to
@@ -45,10 +44,9 @@
     return ops;
   }
 
-  static boolean dryRun(SubmitDryRun.Arguments args,
-      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+  static boolean dryRun(
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
-    return args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip,
-        toMerge);
+    return args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip, toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
index 5b2e213..e7db1a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -27,18 +26,17 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(
-      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException {
     List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
 
-    if (args.mergeTip.getInitialTip() == null || !args.submoduleOp
-        .hasSubscription(args.destBranch)) {
-      CodeReviewCommit firstFastForward = args.mergeUtil.getFirstFastForward(
-          args.mergeTip.getInitialTip(), args.rw, sorted);
-      if (firstFastForward != null &&
-          !firstFastForward.equals(args.mergeTip.getInitialTip())) {
+    if (args.mergeTip.getInitialTip() == null
+        || !args.submoduleOp.hasSubscription(args.destBranch)) {
+      CodeReviewCommit firstFastForward =
+          args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
+      if (firstFastForward != null && !firstFastForward.equals(args.mergeTip.getInitialTip())) {
         ops.add(new FastForwardOp(args, firstFastForward));
       }
     }
@@ -51,12 +49,10 @@
     return ops;
   }
 
-  static boolean dryRun(SubmitDryRun.Arguments args,
-      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+  static boolean dryRun(
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
-    return args.mergeUtil.canFastForward(
-          args.mergeSorter, mergeTip, args.rw, toMerge)
-        || args.mergeUtil.canMerge(
-          args.mergeSorter, args.repo, mergeTip, toMerge);
+    return args.mergeUtil.canFastForward(args.mergeSorter, mergeTip, args.rw, toMerge)
+        || args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip, toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
index b1590bf..2a6680c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
@@ -14,13 +14,11 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
-
-import org.eclipse.jgit.lib.PersonIdent;
-
+import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
+import org.eclipse.jgit.lib.PersonIdent;
 
 class MergeOneOp extends SubmitStrategyOp {
   MergeOneOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
@@ -28,22 +26,29 @@
   }
 
   @Override
-  public void updateRepoImpl(RepoContext ctx)
-      throws IntegrationException, IOException {
-    PersonIdent caller = ctx.getIdentifiedUser().newCommitterIdent(
-        ctx.getWhen(), ctx.getTimeZone());
+  public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+    PersonIdent caller =
+        ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone());
     if (args.mergeTip.getCurrentTip() == null) {
-      throw new IllegalStateException("cannot merge commit " + toMerge.name()
-          + " onto a null tip; expected at least one fast-forward prior to"
-          + " this operation");
+      throw new IllegalStateException(
+          "cannot merge commit "
+              + toMerge.name()
+              + " onto a null tip; expected at least one fast-forward prior to"
+              + " this operation");
     }
     // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
     // When hoisting BatchUpdate into MergeOp, we will need to teach
     // BatchUpdate how to produce CodeReviewRevWalks.
     CodeReviewCommit merged =
-        args.mergeUtil.mergeOneCommit(caller, args.serverIdent,
-            ctx.getRepository(), args.rw, ctx.getInserter(), args.destBranch,
-            args.mergeTip.getCurrentTip(), toMerge);
+        args.mergeUtil.mergeOneCommit(
+            caller,
+            args.serverIdent,
+            ctx.getRepository(),
+            args.rw,
+            ctx.getInserter(),
+            args.destBranch,
+            args.mergeTip.getCurrentTip(),
+            toMerge);
     args.mergeTip.moveTipTo(amendGitlink(merged), toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseAlways.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseAlways.java
new file mode 100644
index 0000000..26bb4c1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseAlways.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+public class RebaseAlways extends RebaseSubmitStrategy {
+
+  RebaseAlways(SubmitStrategy.Arguments args) {
+    super(args, true);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
index f8772d1..104074a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseIfNecessary.java
@@ -14,213 +14,9 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.git.RebaseSorter;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
-public class RebaseIfNecessary extends SubmitStrategy {
+public class RebaseIfNecessary extends RebaseSubmitStrategy {
 
   RebaseIfNecessary(SubmitStrategy.Arguments args) {
-    super(args);
-  }
-
-  @Override
-  public List<SubmitStrategyOp> buildOps(
-      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-    List<CodeReviewCommit> sorted = sort(toMerge, args.mergeTip.getCurrentTip());
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
-    boolean first = true;
-
-    for (CodeReviewCommit c : sorted) {
-      if (c.getParentCount() > 1) {
-        // Since there is a merge commit, sort and prune again using
-        // MERGE_IF_NECESSARY semantics to avoid creating duplicate
-        // commits.
-        //
-        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        break;
-      }
-    }
-
-    while (!sorted.isEmpty()) {
-      CodeReviewCommit n = sorted.remove(0);
-      if (first && args.mergeTip.getInitialTip() == null) {
-        ops.add(new FastForwardOp(args, n));
-      } else if (n.getParentCount() == 0) {
-        ops.add(new RebaseRootOp(n));
-      } else if (n.getParentCount() == 1) {
-        ops.add(new RebaseOneOp(n));
-      } else {
-        ops.add(new RebaseMultipleParentsOp(n));
-      }
-      first = false;
-    }
-    return ops;
-  }
-
-  private class RebaseRootOp extends SubmitStrategyOp {
-    private RebaseRootOp(CodeReviewCommit toMerge) {
-      super(RebaseIfNecessary.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) {
-      // Refuse to merge a root commit into an existing branch, we cannot obtain
-      // a delta for the cherry-pick to apply.
-      toMerge.setStatusCode(CommitMergeStatus.CANNOT_REBASE_ROOT);
-    }
-  }
-
-  private class RebaseOneOp extends SubmitStrategyOp {
-    private RebaseChangeOp rebaseOp;
-    private CodeReviewCommit newCommit;
-
-    private RebaseOneOp(CodeReviewCommit toMerge) {
-      super(RebaseIfNecessary.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, InvalidChangeOperationException,
-        RestApiException, IOException, OrmException {
-      // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
-      // When hoisting BatchUpdate into MergeOp, we will need to teach
-      // BatchUpdate how to produce CodeReviewRevWalks.
-      if (args.mergeUtil
-          .canFastForward(args.mergeSorter, args.mergeTip.getCurrentTip(),
-              args.rw, toMerge)) {
-        args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
-        toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-        acceptMergeTip(args.mergeTip);
-        return;
-      }
-
-      // Stale read of patch set is ok; see comments in RebaseChangeOp.
-      PatchSet origPs = args.psUtil.get(
-          ctx.getDb(), toMerge.getControl().getNotes(), toMerge.getPatchsetId());
-      rebaseOp = args.rebaseFactory.create(
-            toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
-          .setFireRevisionCreated(false)
-          // Bypass approval copier since SubmitStrategyOp copy all approvals
-          // later anyway.
-          .setCopyApprovals(false)
-          .setValidatePolicy(CommitValidators.Policy.NONE)
-          .setCheckAddPatchSetPermission(false);
-      try {
-        rebaseOp.updateRepo(ctx);
-      } catch (MergeConflictException | NoSuchChangeException e) {
-        toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-        throw new IntegrationException(
-            "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
-      }
-      newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
-      newCommit = amendGitlink(newCommit);
-      newCommit.copyFrom(toMerge);
-      newCommit.setStatusCode(CommitMergeStatus.CLEAN_REBASE);
-      newCommit.setPatchsetId(rebaseOp.getPatchSetId());
-      args.mergeTip.moveTipTo(newCommit, newCommit);
-      args.commits.put(args.mergeTip.getCurrentTip());
-      acceptMergeTip(args.mergeTip);
-    }
-
-    @Override
-    public PatchSet updateChangeImpl(ChangeContext ctx)
-        throws NoSuchChangeException, ResourceConflictException,
-        OrmException, IOException  {
-      if (rebaseOp == null) {
-        // Took the fast-forward option, nothing to do.
-        return null;
-      }
-
-      rebaseOp.updateChange(ctx);
-      ctx.getChange().setCurrentPatchSet(
-          args.patchSetInfoFactory.get(
-              args.rw, newCommit, rebaseOp.getPatchSetId()));
-      newCommit.setControl(ctx.getControl());
-      return rebaseOp.getPatchSet();
-    }
-
-    @Override
-    public void postUpdateImpl(Context ctx) throws OrmException {
-      if (rebaseOp != null) {
-        rebaseOp.postUpdate(ctx);
-      }
-    }
-  }
-
-  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
-    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
-      super(RebaseIfNecessary.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx)
-        throws IntegrationException, IOException {
-      // There are multiple parents, so this is a merge commit. We don't want
-      // to rebase the merge as clients can't easily rebase their history with
-      // that merge present and replaced by an equivalent merge with a different
-      // first parent. So instead behave as though MERGE_IF_NECESSARY was
-      // configured.
-      MergeTip mergeTip = args.mergeTip;
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge) &&
-          !args.submoduleOp.hasSubscription(args.destBranch)) {
-        mergeTip.moveTipTo(toMerge, toMerge);
-      } else {
-        PersonIdent caller = ctx.getIdentifiedUser().newCommitterIdent(
-            ctx.getWhen(), ctx.getTimeZone());
-        CodeReviewCommit newTip = args.mergeUtil.mergeOneCommit(
-            caller, caller, args.repo, args.rw,
-            args.inserter, args.destBranch, mergeTip.getCurrentTip(), toMerge);
-        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
-      }
-      args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-          mergeTip.getCurrentTip(), args.alreadyAccepted);
-      acceptMergeTip(mergeTip);
-    }
-  }
-
-  private void acceptMergeTip(MergeTip mergeTip) {
-    args.alreadyAccepted.add(mergeTip.getCurrentTip());
-  }
-
-  private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort,
-      RevCommit initialTip) throws IntegrationException {
-    try {
-      return new RebaseSorter(args.rw, initialTip, args.alreadyAccepted,
-          args.canMergeFlag).sort(toSort);
-    } catch (IOException e) {
-      throw new IntegrationException("Commit sorting failed", e);
-    }
-  }
-
-  static boolean dryRun(SubmitDryRun.Arguments args,
-      CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
-      throws IntegrationException {
-    // Test for merge instead of cherry pick to avoid false negatives
-    // on commit chains.
-    return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
-        && args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip,
-             toMerge);
+    super(args, false);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
new file mode 100644
index 0000000..43ab01b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
@@ -0,0 +1,313 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeIdenticalTreeException;
+import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.git.RebaseSorter;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
+public class RebaseSubmitStrategy extends SubmitStrategy {
+  private final boolean rebaseAlways;
+
+  RebaseSubmitStrategy(SubmitStrategy.Arguments args, boolean rebaseAlways) {
+    super(args);
+    this.rebaseAlways = rebaseAlways;
+  }
+
+  @Override
+  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException {
+    List<CodeReviewCommit> sorted = sort(toMerge);
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    boolean first = true;
+
+    for (CodeReviewCommit c : sorted) {
+      if (c.getParentCount() > 1) {
+        // Since there is a merge commit, sort and prune again using
+        // MERGE_IF_NECESSARY semantics to avoid creating duplicate
+        // commits.
+        //
+        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted, args.incoming);
+        break;
+      }
+    }
+
+    while (!sorted.isEmpty()) {
+      CodeReviewCommit n = sorted.remove(0);
+      if (first && args.mergeTip.getInitialTip() == null) {
+        // TODO(tandrii): Cherry-Pick strategy does this too, but it's wrong
+        // and can be fixed.
+        ops.add(new FastForwardOp(args, n));
+      } else if (n.getParentCount() == 0) {
+        ops.add(new RebaseRootOp(n));
+      } else if (n.getParentCount() == 1) {
+        ops.add(new RebaseOneOp(n));
+      } else {
+        ops.add(new RebaseMultipleParentsOp(n));
+      }
+      first = false;
+    }
+    return ops;
+  }
+
+  private class RebaseRootOp extends SubmitStrategyOp {
+    private RebaseRootOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx) {
+      // Refuse to merge a root commit into an existing branch, we cannot obtain
+      // a delta for the cherry-pick to apply.
+      toMerge.setStatusCode(CommitMergeStatus.CANNOT_REBASE_ROOT);
+    }
+  }
+
+  private class RebaseOneOp extends SubmitStrategyOp {
+    private RebaseChangeOp rebaseOp;
+    private CodeReviewCommit newCommit;
+    private PatchSet.Id newPatchSetId;
+
+    private RebaseOneOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, InvalidChangeOperationException, RestApiException, IOException,
+            OrmException {
+      // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
+      // When hoisting BatchUpdate into MergeOp, we will need to teach
+      // BatchUpdate how to produce CodeReviewRevWalks.
+      if (args.mergeUtil.canFastForward(
+          args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
+        if (!rebaseAlways) {
+          args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
+          toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
+          acceptMergeTip(args.mergeTip);
+          return;
+        }
+        // RebaseAlways means we modify commit message.
+        args.rw.parseBody(toMerge);
+        newPatchSetId = ChangeUtil.nextPatchSetId(args.repo, toMerge.change().currentPatchSetId());
+        RevCommit mergeTip = args.mergeTip.getCurrentTip();
+        args.rw.parseBody(mergeTip);
+        String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
+        PersonIdent committer =
+            args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+        try {
+          newCommit =
+              args.mergeUtil.createCherryPickFromCommit(
+                  args.repo,
+                  args.inserter,
+                  args.mergeTip.getCurrentTip(),
+                  toMerge,
+                  committer,
+                  cherryPickCmtMsg,
+                  args.rw,
+                  0,
+                  true);
+        } catch (MergeConflictException mce) {
+          // Unlike in Cherry-pick case, this should never happen.
+          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
+          throw new IllegalStateException("MergeConflictException on message edit must not happen");
+        } catch (MergeIdenticalTreeException mie) {
+          // this should not happen
+          toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
+          return;
+        }
+        ctx.addRefUpdate(
+            new ReceiveCommand(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName()));
+      } else {
+        // Stale read of patch set is ok; see comments in RebaseChangeOp.
+        PatchSet origPs =
+            args.psUtil.get(ctx.getDb(), toMerge.getControl().getNotes(), toMerge.getPatchsetId());
+        rebaseOp =
+            args.rebaseFactory
+                .create(toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
+                .setFireRevisionCreated(false)
+                // Bypass approval copier since SubmitStrategyOp copy all approvals
+                // later anyway.
+                .setCopyApprovals(false)
+                .setValidatePolicy(CommitValidators.Policy.NONE)
+                .setCheckAddPatchSetPermission(false)
+                // RebaseAlways should set always modify commit message like
+                // Cherry-Pick strategy.
+                .setDetailedCommitMessage(rebaseAlways)
+                // Do not post message after inserting new patchset because there
+                // will be one about change being merged already.
+                .setPostMessage(false);
+        try {
+          rebaseOp.updateRepo(ctx);
+        } catch (MergeConflictException | NoSuchChangeException e) {
+          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
+          throw new IntegrationException(
+              "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
+        }
+        newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
+        newPatchSetId = rebaseOp.getPatchSetId();
+      }
+      newCommit = amendGitlink(newCommit);
+      newCommit.copyFrom(toMerge);
+      newCommit.setPatchsetId(newPatchSetId);
+      newCommit.setStatusCode(CommitMergeStatus.CLEAN_REBASE);
+      args.mergeTip.moveTipTo(newCommit, newCommit);
+      args.commitStatus.put(args.mergeTip.getCurrentTip());
+      acceptMergeTip(args.mergeTip);
+    }
+
+    @Override
+    public PatchSet updateChangeImpl(ChangeContext ctx)
+        throws NoSuchChangeException, ResourceConflictException, OrmException, IOException {
+      if (newCommit == null) {
+        checkState(!rebaseAlways, "RebaseAlways must never fast forward");
+        // otherwise, took the fast-forward option, nothing to do.
+        return null;
+      }
+
+      PatchSet newPs;
+      if (rebaseOp != null) {
+        rebaseOp.updateChange(ctx);
+        newPs = rebaseOp.getPatchSet();
+      } else {
+        // CherryPick
+        PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
+        newPs =
+            args.psUtil.insert(
+                ctx.getDb(),
+                ctx.getRevWalk(),
+                ctx.getUpdate(newPatchSetId),
+                newPatchSetId,
+                newCommit,
+                false,
+                prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
+                null,
+                null);
+      }
+      ctx.getChange()
+          .setCurrentPatchSet(
+              args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, newPatchSetId));
+      newCommit.setControl(ctx.getControl());
+      return newPs;
+    }
+
+    @Override
+    public void postUpdateImpl(Context ctx) throws OrmException {
+      if (rebaseOp != null) {
+        rebaseOp.postUpdate(ctx);
+      }
+    }
+  }
+
+  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
+    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
+      super(RebaseSubmitStrategy.this.args, toMerge);
+    }
+
+    @Override
+    public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+      // There are multiple parents, so this is a merge commit. We don't want
+      // to rebase the merge as clients can't easily rebase their history with
+      // that merge present and replaced by an equivalent merge with a different
+      // first parent. So instead behave as though MERGE_IF_NECESSARY was
+      // configured.
+      // TODO(tandrii): this is not in spirit of RebaseAlways strategy because
+      // the commit messages can not be modified in the process. It's also
+      // possible to implement rebasing of merge commits. E.g., the Cherry Pick
+      // REST endpoint already supports cherry-picking of merge commits.
+      // For now, users of RebaseAlways strategy for whom changed commit footers
+      // are important would be well advised to prohibit uploading patches with
+      // merge commits.
+      MergeTip mergeTip = args.mergeTip;
+      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
+          && !args.submoduleOp.hasSubscription(args.destBranch)) {
+        mergeTip.moveTipTo(toMerge, toMerge);
+      } else {
+        PersonIdent caller =
+            ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone());
+        CodeReviewCommit newTip =
+            args.mergeUtil.mergeOneCommit(
+                caller,
+                caller,
+                args.repo,
+                args.rw,
+                args.inserter,
+                args.destBranch,
+                mergeTip.getCurrentTip(),
+                toMerge);
+        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
+      }
+      args.mergeUtil.markCleanMerges(
+          args.rw, args.canMergeFlag, mergeTip.getCurrentTip(), args.alreadyAccepted);
+      acceptMergeTip(mergeTip);
+    }
+  }
+
+  private void acceptMergeTip(MergeTip mergeTip) {
+    args.alreadyAccepted.add(mergeTip.getCurrentTip());
+  }
+
+  private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort)
+      throws IntegrationException {
+    try {
+      return new RebaseSorter(
+              args.rw,
+              args.mergeTip.getInitialTip(),
+              args.alreadyAccepted,
+              args.canMergeFlag,
+              args.internalChangeQuery)
+          .sort(toSort);
+    } catch (IOException e) {
+      throw new IntegrationException("Commit sorting failed", e);
+    }
+  }
+
+  static boolean dryRun(
+      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+      throws IntegrationException {
+    // Test for merge instead of cherry pick to avoid false negatives
+    // on commit chains.
+    return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
+        && args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip, toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
index c784379..d375b6e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -27,7 +26,10 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -39,11 +41,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
-
 /** Dry run of a submit strategy. */
 public class SubmitDryRun {
   private static final Logger log = LoggerFactory.getLogger(SubmitDryRun.class);
@@ -54,10 +51,7 @@
     final MergeUtil mergeUtil;
     final MergeSorter mergeSorter;
 
-    Arguments(Repository repo,
-        CodeReviewRevWalk rw,
-        MergeUtil mergeUtil,
-        MergeSorter mergeSorter) {
+    Arguments(Repository repo, CodeReviewRevWalk rw, MergeUtil mergeUtil, MergeSorter mergeSorter) {
       this.repo = repo;
       this.rw = rw;
       this.mergeUtil = mergeUtil;
@@ -65,28 +59,20 @@
     }
   }
 
-  public static Iterable<ObjectId> getAlreadyAccepted(Repository repo)
-      throws IOException {
-    return FluentIterable
-        .from(repo.getRefDatabase().getRefs(Constants.R_HEADS).values())
+  public static Iterable<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
+    return FluentIterable.from(repo.getRefDatabase().getRefs(Constants.R_HEADS).values())
         .append(repo.getRefDatabase().getRefs(Constants.R_TAGS).values())
-        .transform(new Function<Ref, ObjectId>() {
-          @Override
-          public ObjectId apply(Ref r) {
-            return r.getObjectId();
-          }
-        });
+        .transform(Ref::getObjectId);
   }
 
-  public static Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
-      throws IOException {
+  public static Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw) throws IOException {
     Set<RevCommit> accepted = new HashSet<>();
     addCommits(getAlreadyAccepted(repo), rw, accepted);
     return accepted;
   }
 
-  public static void addCommits(Iterable<ObjectId> ids, RevWalk rw,
-      Collection<RevCommit> out) throws IOException {
+  public static void addCommits(Iterable<ObjectId> ids, RevWalk rw, Collection<RevCommit> out)
+      throws IOException {
     for (ObjectId id : ids) {
       RevObject obj = rw.parseAny(id);
       if (obj instanceof RevCommit) {
@@ -99,23 +85,30 @@
   private final MergeUtil.Factory mergeUtilFactory;
 
   @Inject
-  SubmitDryRun(ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory) {
+  SubmitDryRun(ProjectCache projectCache, MergeUtil.Factory mergeUtilFactory) {
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
   }
 
-  public boolean run(SubmitType submitType, Repository repo,
-      CodeReviewRevWalk rw, Branch.NameKey destBranch, ObjectId tip,
-      ObjectId toMerge, Set<RevCommit> alreadyAccepted)
+  public boolean run(
+      SubmitType submitType,
+      Repository repo,
+      CodeReviewRevWalk rw,
+      Branch.NameKey destBranch,
+      ObjectId tip,
+      ObjectId toMerge,
+      Set<RevCommit> alreadyAccepted)
       throws IntegrationException, NoSuchProjectException, IOException {
     CodeReviewCommit tipCommit = rw.parseCommit(tip);
     CodeReviewCommit toMergeCommit = rw.parseCommit(toMerge);
     RevFlag canMerge = rw.newFlag("CAN_MERGE");
     toMergeCommit.add(canMerge);
-    Arguments args = new Arguments(repo, rw,
-        mergeUtilFactory.create(getProject(destBranch)),
-        new MergeSorter(rw, alreadyAccepted, canMerge));
+    Arguments args =
+        new Arguments(
+            repo,
+            rw,
+            mergeUtilFactory.create(getProject(destBranch)),
+            new MergeSorter(rw, alreadyAccepted, canMerge));
 
     switch (submitType) {
       case CHERRY_PICK:
@@ -128,6 +121,8 @@
         return MergeIfNecessary.dryRun(args, tipCommit, toMergeCommit);
       case REBASE_IF_NECESSARY:
         return RebaseIfNecessary.dryRun(args, tipCommit, toMergeCommit);
+      case REBASE_ALWAYS:
+        return RebaseAlways.dryRun(args, tipCommit, toMergeCommit);
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         log.error(errorMsg);
@@ -135,8 +130,7 @@
     }
   }
 
-  private ProjectState getProject(Branch.NameKey branch)
-      throws NoSuchProjectException {
+  private ProjectState getProject(Branch.NameKey branch) throws NoSuchProjectException {
     ProjectState p = projectCache.get(branch.getParentKey());
     if (p == null) {
       throw new NoSuchProjectException(branch.getParentKey());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
index 36de70e..f721978 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -16,10 +16,13 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
@@ -30,7 +33,6 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
-import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.EmailMerge;
@@ -43,32 +45,33 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.SubmoduleOp;
 import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-
 /**
  * Base class that submit strategies must extend.
- * <p>
- * A submit strategy for a certain {@link SubmitType} defines how the submitted
- * commits should be merged.
+ *
+ * <p>A submit strategy for a certain {@link SubmitType} defines how the submitted commits should be
+ * merged.
  */
 public abstract class SubmitStrategy {
   public static Module module() {
@@ -85,7 +88,7 @@
       Arguments create(
           SubmitType submitType,
           Branch.NameKey destBranch,
-          CommitStatus commits,
+          CommitStatus commitStatus,
           CodeReviewRevWalk rw,
           IdentifiedUser caller,
           MergeTip mergeTip,
@@ -94,9 +97,12 @@
           RevFlag canMergeFlag,
           ReviewDb db,
           Set<RevCommit> alreadyAccepted,
+          Set<CodeReviewCommit> incoming,
           RequestId submissionId,
           NotifyHandling notifyHandling,
-          SubmoduleOp submoduleOp);
+          ListMultimap<RecipientType, Account.Id> accountsToNotify,
+          SubmoduleOp submoduleOp,
+          boolean dryrun);
     }
 
     final AccountCache accountCache;
@@ -113,11 +119,13 @@
     final ProjectCache projectCache;
     final PersonIdent serverIdent;
     final RebaseChangeOp.Factory rebaseFactory;
+    final OnSubmitValidators.Factory onSubmitValidatorsFactory;
     final TagCache tagCache;
+    final InternalChangeQuery internalChangeQuery;
 
     final Branch.NameKey destBranch;
     final CodeReviewRevWalk rw;
-    final CommitStatus commits;
+    final CommitStatus commitStatus;
     final IdentifiedUser caller;
     final MergeTip mergeTip;
     final ObjectInserter inserter;
@@ -125,14 +133,17 @@
     final RevFlag canMergeFlag;
     final ReviewDb db;
     final Set<RevCommit> alreadyAccepted;
+    final Set<CodeReviewCommit> incoming;
     final RequestId submissionId;
     final SubmitType submitType;
     final NotifyHandling notifyHandling;
+    final ListMultimap<RecipientType, Account.Id> accountsToNotify;
     final SubmoduleOp submoduleOp;
 
     final ProjectState project;
     final MergeSorter mergeSorter;
     final MergeUtil mergeUtil;
+    final boolean dryrun;
 
     @AssistedInject
     Arguments(
@@ -151,9 +162,11 @@
         @GerritPersonIdent PersonIdent serverIdent,
         ProjectCache projectCache,
         RebaseChangeOp.Factory rebaseFactory,
+        OnSubmitValidators.Factory onSubmitValidatorsFactory,
         TagCache tagCache,
+        InternalChangeQuery internalChangeQuery,
         @Assisted Branch.NameKey destBranch,
-        @Assisted CommitStatus commits,
+        @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
         @Assisted IdentifiedUser caller,
         @Assisted MergeTip mergeTip,
@@ -162,10 +175,13 @@
         @Assisted RevFlag canMergeFlag,
         @Assisted ReviewDb db,
         @Assisted Set<RevCommit> alreadyAccepted,
+        @Assisted Set<CodeReviewCommit> incoming,
         @Assisted RequestId submissionId,
         @Assisted SubmitType submitType,
         @Assisted NotifyHandling notifyHandling,
-        @Assisted SubmoduleOp submoduleOp) {
+        @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify,
+        @Assisted SubmoduleOp submoduleOp,
+        @Assisted boolean dryrun) {
       this.accountCache = accountCache;
       this.approvalsUtil = approvalsUtil;
       this.batchUpdateFactory = batchUpdateFactory;
@@ -180,10 +196,11 @@
       this.projectCache = projectCache;
       this.rebaseFactory = rebaseFactory;
       this.tagCache = tagCache;
+      this.internalChangeQuery = internalChangeQuery;
 
       this.serverIdent = serverIdent;
       this.destBranch = destBranch;
-      this.commits = commits;
+      this.commitStatus = commitStatus;
       this.rw = rw;
       this.caller = caller;
       this.mergeTip = mergeTip;
@@ -192,15 +209,22 @@
       this.canMergeFlag = canMergeFlag;
       this.db = db;
       this.alreadyAccepted = alreadyAccepted;
+      this.incoming = incoming;
       this.submissionId = submissionId;
       this.submitType = submitType;
       this.notifyHandling = notifyHandling;
+      this.accountsToNotify = accountsToNotify;
       this.submoduleOp = submoduleOp;
+      this.dryrun = dryrun;
 
-      this.project = checkNotNull(projectCache.get(destBranch.getParentKey()),
-            "project not found: %s", destBranch.getParentKey());
+      this.project =
+          checkNotNull(
+              projectCache.get(destBranch.getParentKey()),
+              "project not found: %s",
+              destBranch.getParentKey());
       this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag);
       this.mergeUtil = mergeUtilFactory.create(project);
+      this.onSubmitValidatorsFactory = onSubmitValidatorsFactory;
     }
   }
 
@@ -212,17 +236,16 @@
 
   /**
    * Add operations to a batch update that execute this submit strategy.
-   * <p>
-   * Guarantees exactly one op is added to the update for each change in the
-   * input set.
+   *
+   * <p>Guarantees exactly one op is added to the update for each change in the input set.
    *
    * @param bu batch update to add operations to.
-   * @param toMerge the set of submitted commits that should be merged using
-   *     this submit strategy. Implementations are responsible for ordering of
-   *     commits, and will not modify the input in place.
-   * @throws IntegrationException if an error occurred initializing the
-   *     operations (as opposed to an error during execution, which will be
-   *     reported only when the batch update executes the operations).
+   * @param toMerge the set of submitted commits that should be merged using this submit strategy.
+   *     Implementations are responsible for ordering of commits, and will not modify the input in
+   *     place.
+   * @throws IntegrationException if an error occurred initializing the operations (as opposed to an
+   *     error during execution, which will be reported only when the batch update executes the
+   *     operations).
    */
   public final void addOps(BatchUpdate bu, Set<CodeReviewCommit> toMerge)
       throws IntegrationException {
@@ -234,8 +257,7 @@
     }
 
     // First add ops for any implicitly merged changes.
-    List<CodeReviewCommit> difference =
-        new ArrayList<>(Sets.difference(toMerge, added));
+    List<CodeReviewCommit> difference = new ArrayList<>(Sets.difference(toMerge, added));
     Collections.reverse(difference);
     for (CodeReviewCommit c : difference) {
       bu.addOp(c.change().getId(), new ImplicitIntegrateOp(args, c));
@@ -247,6 +269,6 @@
     }
   }
 
-  protected abstract List<SubmitStrategyOp> buildOps(
-      Collection<CodeReviewCommit> toMerge) throws IntegrationException;
+  protected abstract List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
+      throws IntegrationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
index 6bb6fa6..fc4817d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.git.strategy;
 
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeOp.CommitStatus;
@@ -27,7 +31,7 @@
 import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -35,13 +39,10 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.Set;
-
 /** Factory to create a {@link SubmitStrategy} for a {@link SubmitType}. */
 @Singleton
 public class SubmitStrategyFactory {
-  private static final Logger log = LoggerFactory
-      .getLogger(SubmitStrategyFactory.class);
+  private static final Logger log = LoggerFactory.getLogger(SubmitStrategyFactory.class);
 
   private final SubmitStrategy.Arguments.Factory argsFactory;
 
@@ -50,16 +51,44 @@
     this.argsFactory = argsFactory;
   }
 
-  public SubmitStrategy create(SubmitType submitType, ReviewDb db,
-      Repository repo, CodeReviewRevWalk rw, ObjectInserter inserter,
-      RevFlag canMergeFlag, Set<RevCommit> alreadyAccepted,
-      Branch.NameKey destBranch, IdentifiedUser caller, MergeTip mergeTip,
-      CommitStatus commits, RequestId submissionId, NotifyHandling notifyHandling,
-      SubmoduleOp submoduleOp)
+  public SubmitStrategy create(
+      SubmitType submitType,
+      ReviewDb db,
+      Repository repo,
+      CodeReviewRevWalk rw,
+      ObjectInserter inserter,
+      RevFlag canMergeFlag,
+      Set<RevCommit> alreadyAccepted,
+      Set<CodeReviewCommit> incoming,
+      Branch.NameKey destBranch,
+      IdentifiedUser caller,
+      MergeTip mergeTip,
+      CommitStatus commitStatus,
+      RequestId submissionId,
+      NotifyHandling notifyHandling,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      SubmoduleOp submoduleOp,
+      boolean dryrun)
       throws IntegrationException {
-    SubmitStrategy.Arguments args = argsFactory.create(submitType, destBranch,
-        commits, rw, caller, mergeTip, inserter, repo, canMergeFlag, db,
-        alreadyAccepted, submissionId, notifyHandling, submoduleOp);
+    SubmitStrategy.Arguments args =
+        argsFactory.create(
+            submitType,
+            destBranch,
+            commitStatus,
+            rw,
+            caller,
+            mergeTip,
+            inserter,
+            repo,
+            canMergeFlag,
+            db,
+            alreadyAccepted,
+            incoming,
+            submissionId,
+            notifyHandling,
+            accountsToNotify,
+            submoduleOp,
+            dryrun);
     switch (submitType) {
       case CHERRY_PICK:
         return new CherryPick(args);
@@ -71,6 +100,8 @@
         return new MergeIfNecessary(args);
       case REBASE_IF_NECESSARY:
         return new RebaseIfNecessary(args);
+      case REBASE_ALWAYS:
+        return new RebaseAlways(args);
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         log.error(errorMsg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
index eedfe70..97291e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyListener.java
@@ -20,27 +20,25 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.Submit.TestSubmitInput;
-import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeOp.CommitStatus;
-
-import org.eclipse.jgit.revwalk.RevCommit;
-
+import com.google.gerrit.server.update.BatchUpdateListener;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.revwalk.RevCommit;
 
-public class SubmitStrategyListener extends BatchUpdate.Listener {
+public class SubmitStrategyListener implements BatchUpdateListener {
   private final Collection<SubmitStrategy> strategies;
-  private final CommitStatus commits;
+  private final CommitStatus commitStatus;
   private final boolean failAfterRefUpdates;
 
-  public SubmitStrategyListener(SubmitInput input,
-      Collection<SubmitStrategy> strategies, CommitStatus commits) {
+  public SubmitStrategyListener(
+      SubmitInput input, Collection<SubmitStrategy> strategies, CommitStatus commitStatus) {
     this.strategies = strategies;
-    this.commits = commits;
+    this.commitStatus = commitStatus;
     if (input instanceof TestSubmitInput) {
       failAfterRefUpdates = ((TestSubmitInput) input).failAfterRefUpdates;
     } else {
@@ -60,7 +58,7 @@
   }
 
   @Override
-  public void afterRefUpdates() throws ResourceConflictException {
+  public void afterUpdateRefs() throws ResourceConflictException {
     if (failAfterRefUpdates) {
       throw new ResourceConflictException("Failing after ref updates");
     }
@@ -70,41 +68,46 @@
       throws ResourceConflictException, IntegrationException {
     for (SubmitStrategy strategy : strategies) {
       if (strategy instanceof CherryPick) {
-        // Might have picked a subset of changes, can't do this sanity check.
+        // Can't do this sanity check for CherryPick since:
+        // * CherryPick might have picked a subset of changes
+        // * CherryPick might have status SKIPPED_IDENTICAL_TREE
         continue;
       }
       SubmitStrategy.Arguments args = strategy.args;
-      Set<Change.Id> unmerged = args.mergeUtil.findUnmergedChanges(
-          args.commits.getChangeIds(args.destBranch), args.rw,
-          args.canMergeFlag, args.mergeTip.getInitialTip(),
-          args.mergeTip.getCurrentTip(), alreadyMerged);
+      Set<Change.Id> unmerged =
+          args.mergeUtil.findUnmergedChanges(
+              args.commitStatus.getChangeIds(args.destBranch),
+              args.rw,
+              args.canMergeFlag,
+              args.mergeTip.getInitialTip(),
+              args.mergeTip.getCurrentTip(),
+              alreadyMerged);
       for (Change.Id id : unmerged) {
-        commits.problem(id,
-            "internal error: change not reachable from new branch tip");
+        commitStatus.problem(id, "internal error: change not reachable from new branch tip");
       }
     }
-    commits.maybeFailVerbose();
+    commitStatus.maybeFailVerbose();
   }
 
   private void markCleanMerges() throws IntegrationException {
     for (SubmitStrategy strategy : strategies) {
       SubmitStrategy.Arguments args = strategy.args;
       RevCommit initialTip = args.mergeTip.getInitialTip();
-      args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-          args.mergeTip.getCurrentTip(), initialTip == null ?
-              ImmutableSet.<RevCommit>of() : ImmutableSet.of(initialTip));
+      args.mergeUtil.markCleanMerges(
+          args.rw,
+          args.canMergeFlag,
+          args.mergeTip.getCurrentTip(),
+          initialTip == null ? ImmutableSet.<RevCommit>of() : ImmutableSet.of(initialTip));
     }
   }
 
   private List<Change.Id> checkCommitStatus() throws ResourceConflictException {
-    List<Change.Id> alreadyMerged =
-        new ArrayList<>(commits.getChangeIds().size());
-    for (Change.Id id : commits.getChangeIds()) {
-      CodeReviewCommit commit = commits.get(id);
+    List<Change.Id> alreadyMerged = new ArrayList<>(commitStatus.getChangeIds().size());
+    for (Change.Id id : commitStatus.getChangeIds()) {
+      CodeReviewCommit commit = commitStatus.get(id);
       CommitMergeStatus s = commit != null ? commit.getStatusCode() : null;
       if (s == null) {
-        commits.problem(id,
-            "internal error: change not processed by merge strategy");
+        commitStatus.problem(id, "internal error: change not processed by merge strategy");
         continue;
       }
       switch (s) {
@@ -127,25 +130,24 @@
         case NOT_FAST_FORWARD:
           // TODO(dborowitz): Reformat these messages to be more appropriate for
           // short problem descriptions.
-          commits.problem(id,
-              CharMatcher.is('\n').collapseFrom(s.getMessage(), ' '));
+          commitStatus.problem(id, CharMatcher.is('\n').collapseFrom(s.getMessage(), ' '));
           break;
 
         case MISSING_DEPENDENCY:
-          commits.problem(id, "depends on change that was not submitted");
+          commitStatus.problem(id, "depends on change that was not submitted");
           break;
 
         default:
-          commits.problem(id, "unspecified merge failure: " + s);
+          commitStatus.problem(id, "unspecified merge failure: " + s);
           break;
       }
     }
-    commits.maybeFailVerbose();
+    commitStatus.maybeFailVerbose();
     return alreadyMerged;
   }
 
   @Override
   public void afterUpdateChanges() throws ResourceConflictException {
-    commits.maybeFail("Error updating status");
+    commitStatus.maybeFail("Error updating status");
   }
 }
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 d62edb5..89bd560 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
@@ -33,12 +33,9 @@
 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.ChangeUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.BatchUpdate;
-import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
-import com.google.gerrit.server.git.BatchUpdate.Context;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
@@ -49,16 +46,11 @@
 import com.google.gerrit.server.git.SubmoduleException;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
-
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -67,10 +59,17 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-abstract class SubmitStrategyOp extends BatchUpdate.Op {
-  private static final Logger log =
-      LoggerFactory.getLogger(SubmitStrategyOp.class);
+abstract class SubmitStrategyOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(SubmitStrategyOp.class);
 
   protected final SubmitStrategy.Arguments args;
   protected final CodeReviewCommit toMerge;
@@ -82,8 +81,7 @@
   private Change updatedChange;
   private CodeReviewCommit alreadyMerged;
 
-  protected SubmitStrategyOp(SubmitStrategy.Arguments args,
-      CodeReviewCommit toMerge) {
+  protected SubmitStrategyOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
     this.args = args;
     this.toMerge = toMerge;
   }
@@ -106,8 +104,7 @@
 
   @Override
   public final void updateRepo(RepoContext ctx) throws Exception {
-    logDebug("{}#updateRepo for change {}", getClass().getSimpleName(),
-        toMerge.change().getId());
+    logDebug("{}#updateRepo for change {}", getClass().getSimpleName(), toMerge.change().getId());
     // Run the submit strategy implementation and record the merge tip state so
     // we can create the ref update.
     CodeReviewCommit tipBefore = args.mergeTip.getCurrentTip();
@@ -132,10 +129,8 @@
 
     // Needed by postUpdate, at which point mergeTip will have advanced further,
     // so it's easier to just snapshot the command.
-    command = new ReceiveCommand(
-        firstNonNull(tipBefore, ObjectId.zeroId()),
-        tipAfter,
-        getDest().get());
+    command =
+        new ReceiveCommand(firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().get());
     ctx.addRefUpdate(command);
     args.submoduleOp.addBranchTip(getDest(), tipAfter);
   }
@@ -149,15 +144,18 @@
         ProjectConfig cfg = new ProjectConfig(getProject());
         cfg.load(ctx.getRevWalk(), commit);
       } catch (Exception e) {
-        throw new IntegrationException("Submit would store invalid"
-            + " project configuration " + commit.name() + " for "
-            + getProject(), e);
+        throw new IntegrationException(
+            "Submit would store invalid"
+                + " project configuration "
+                + commit.name()
+                + " for "
+                + getProject(),
+            e);
       }
     }
   }
 
-  private CodeReviewCommit getAlreadyMergedCommit(RepoContext ctx)
-      throws IOException {
+  private CodeReviewCommit getAlreadyMergedCommit(RepoContext ctx) throws IOException {
     CodeReviewCommit tip = args.mergeTip.getInitialTip();
     if (tip == null) {
       return null;
@@ -165,8 +163,7 @@
     CodeReviewRevWalk rw = (CodeReviewRevWalk) ctx.getRevWalk();
     Change.Id id = getId();
 
-    Collection<Ref> refs = ctx.getRepository().getRefDatabase()
-        .getRefs(id.toRefPrefix()).values();
+    Collection<Ref> refs = ctx.getRepository().getRefDatabase().getRefs(id.toRefPrefix()).values();
     List<CodeReviewCommit> commits = new ArrayList<>(refs.size());
     for (Ref ref : refs) {
       PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
@@ -181,14 +178,8 @@
         continue; // Bogus ref, can't be merged into tip so we don't care.
       }
     }
-    Collections.sort(commits, ReviewDbUtil.intKeyOrdering().reverse()
-        .onResultOf(
-          new Function<CodeReviewCommit, PatchSet.Id>() {
-            @Override
-            public PatchSet.Id apply(CodeReviewCommit in) {
-              return in.getPatchsetId();
-            }
-          }));
+    Collections.sort(
+        commits, ReviewDbUtil.intKeyOrdering().reverse().onResultOf(c -> c.getPatchsetId()));
     CodeReviewCommit result = MergeUtil.findAnyMergedInto(rw, commits, tip);
     if (result == null) {
       return null;
@@ -212,14 +203,13 @@
     result.copyFrom(toMerge);
     result.setPatchsetId(psId); // Got overwriten by copyFrom.
     result.setStatusCode(CommitMergeStatus.ALREADY_MERGED);
-    args.commits.put(result);
+    args.commitStatus.put(result);
     return result;
   }
 
   @Override
   public final boolean updateChange(ChangeContext ctx) throws Exception {
-    logDebug("{}#updateChange for change {}", getClass().getSimpleName(),
-        toMerge.change().getId());
+    logDebug("{}#updateChange for change {}", getClass().getSimpleName(), toMerge.change().getId());
     toMerge.setControl(ctx.getControl()); // Update change and notes from ctx.
     PatchSet.Id oldPsId = checkNotNull(toMerge.getPatchsetId());
     PatchSet.Id newPsId;
@@ -232,47 +222,54 @@
       PatchSet newPatchSet = updateChangeImpl(ctx);
       newPsId = checkNotNull(ctx.getChange().currentPatchSetId());
       if (newPatchSet == null) {
-        checkState(oldPsId.equals(newPsId),
+        checkState(
+            oldPsId.equals(newPsId),
             "patch set advanced from %s to %s but updateChangeImpl did not"
-            + " return new patch set instance", oldPsId, newPsId);
+                + " return new patch set instance",
+            oldPsId,
+            newPsId);
         // Ok to use stale notes to get the old patch set, which didn't change
         // during the submit strategy.
-        mergedPatchSet = checkNotNull(
-            args.psUtil.get(ctx.getDb(), ctx.getNotes(), oldPsId),
-            "missing old patch set %s", oldPsId);
+        mergedPatchSet =
+            checkNotNull(
+                args.psUtil.get(ctx.getDb(), ctx.getNotes(), oldPsId),
+                "missing old patch set %s",
+                oldPsId);
       } else {
         PatchSet.Id n = newPatchSet.getId();
-        checkState(!n.equals(oldPsId) && n.equals(newPsId),
+        checkState(
+            !n.equals(oldPsId) && n.equals(newPsId),
             "current patch was %s and is now %s, but updateChangeImpl returned"
-            + " new patch set instance at %s", oldPsId, newPsId, n);
+                + " new patch set instance at %s",
+            oldPsId,
+            newPsId,
+            n);
         mergedPatchSet = newPatchSet;
       }
     }
 
     Change c = ctx.getChange();
     Change.Id id = c.getId();
-    CodeReviewCommit commit = args.commits.get(id);
+    CodeReviewCommit commit = args.commitStatus.get(id);
     checkNotNull(commit, "missing commit for change " + id);
     CommitMergeStatus s = commit.getStatusCode();
-    checkNotNull(s,
-        "status not set for change " + id
-        + " expected to previously fail fast");
-    logDebug("Status of change {} ({}) on {}: {}", id, commit.name(),
-        c.getDest(), s);
+    checkNotNull(s, "status not set for change " + id + " expected to previously fail fast");
+    logDebug("Status of change {} ({}) on {}: {}", id, commit.name(), c.getDest(), s);
     setApproval(ctx, args.caller);
 
-    mergeResultRev = alreadyMerged == null
-        ? args.mergeTip.getMergeResults().get(commit)
-        // Our fixup code is not smart enough to find a merge commit
-        // corresponding to the merge result. This results in a different
-        // ChangeMergedEvent in the fixup case, but we'll just live with that.
-        : alreadyMerged;
+    mergeResultRev =
+        alreadyMerged == null
+            ? args.mergeTip.getMergeResults().get(commit)
+            // Our fixup code is not smart enough to find a merge commit
+            // corresponding to the merge result. This results in a different
+            // ChangeMergedEvent in the fixup case, but we'll just live with that.
+            : alreadyMerged;
     try {
       setMerged(ctx, message(ctx, commit, s));
     } catch (OrmException err) {
       String msg = "Error updating change status for " + id;
       log.error(msg, err);
-      args.commits.logProblem(id, msg);
+      args.commitStatus.logProblem(id, msg);
       // It's possible this happened before updating anything in the db, but
       // it's hard to know for sure, so just return true below to be safe.
     }
@@ -286,9 +283,9 @@
     logDebug("Fixing up already-merged patch set {}", psId);
     PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
     ctx.getRevWalk().parseBody(alreadyMerged);
-    ctx.getChange().setCurrentPatchSet(psId,
-        alreadyMerged.getShortMessage(),
-        ctx.getChange().getOriginalSubject());
+    ctx.getChange()
+        .setCurrentPatchSet(
+            psId, alreadyMerged.getShortMessage(), ctx.getChange().getOriginalSubject());
     PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
     if (existing != null) {
       logDebug("Patch set row exists, only updating change");
@@ -297,17 +294,23 @@
     // No patch set for the already merged commit, although we know it came form
     // a patch set ref. Fix up the database. Note that this uses the current
     // user as the uploader, which is as good a guess as any.
-    List<String> groups = prevPs != null
-        ? prevPs.getGroups()
-        : GroupCollector.getDefaultGroups(alreadyMerged);
-    return args.psUtil.insert(ctx.getDb(), ctx.getRevWalk(),
-        ctx.getUpdate(psId), psId, alreadyMerged, false, groups, null);
+    List<String> groups =
+        prevPs != null ? prevPs.getGroups() : GroupCollector.getDefaultGroups(alreadyMerged);
+    return args.psUtil.insert(
+        ctx.getDb(),
+        ctx.getRevWalk(),
+        ctx.getUpdate(psId),
+        psId,
+        alreadyMerged,
+        false,
+        groups,
+        null,
+        null);
   }
 
-  private void setApproval(ChangeContext ctx, IdentifiedUser user)
-      throws OrmException {
+  private void setApproval(ChangeContext ctx, IdentifiedUser user) throws OrmException {
     Change.Id id = ctx.getChange().getId();
-    List<SubmitRecord> records = args.commits.getSubmitRecords(id);
+    List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
     PatchSet.Id newPsId = ctx.getChange().currentPatchSetId();
 
@@ -330,20 +333,14 @@
       throws OrmException {
     PatchSet.Id psId = update.getPatchSetId();
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
-    for (PatchSetApproval psa : args.approvalsUtil.byPatchSet(
-        ctx.getDb(), ctx.getControl(), psId)) {
+    for (PatchSetApproval psa :
+        args.approvalsUtil.byPatchSet(ctx.getDb(), ctx.getControl(), psId)) {
       byKey.put(psa.getKey(), psa);
     }
 
-    submitter = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              psId,
-              ctx.getAccountId(),
-              LabelId.legacySubmit()),
-              (short) 1, ctx.getWhen());
+    submitter =
+        ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
     byKey.put(submitter.getKey(), submitter);
-    submitter.setValue((short) 1);
-    submitter.setGranted(ctx.getWhen());
 
     // Flatten out existing approvals for this patch set based upon the current
     // permissions. Once the change is closed the approvals are not updated at
@@ -357,14 +354,15 @@
     return normalized;
   }
 
-  private void saveApprovals(LabelNormalizer.Result normalized,
-      ChangeContext ctx, ChangeUpdate update, boolean includeUnchanged)
+  private void saveApprovals(
+      LabelNormalizer.Result normalized,
+      ChangeContext ctx,
+      ChangeUpdate update,
+      boolean includeUnchanged)
       throws OrmException {
     PatchSet.Id psId = update.getPatchSetId();
-    ctx.getDb().patchSetApprovals().upsert(
-        convertPatchSet(normalized.getNormalized(), psId));
-    ctx.getDb().patchSetApprovals().upsert(
-        zero(convertPatchSet(normalized.deleted(), psId)));
+    ctx.getDb().patchSetApprovals().upsert(convertPatchSet(normalized.getNormalized(), psId));
+    ctx.getDb().patchSetApprovals().upsert(zero(convertPatchSet(normalized.deleted(), psId)));
     for (PatchSetApproval psa : normalized.updated()) {
       update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
     }
@@ -377,22 +375,18 @@
     for (PatchSetApproval psa : normalized.unchanged()) {
       if (includeUnchanged || psa.isLegacySubmit()) {
         logDebug("Adding submit label " + psa);
-        update.putApprovalFor(
-            psa.getAccountId(), psa.getLabel(), psa.getValue());
+        update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue());
       }
     }
   }
 
-  private static Function<PatchSetApproval, PatchSetApproval>
-      convertPatchSet(final PatchSet.Id psId) {
-    return new Function<PatchSetApproval, PatchSetApproval>() {
-      @Override
-      public PatchSetApproval apply(PatchSetApproval in) {
-        if (in.getPatchSetId().equals(psId)) {
-          return in;
-        }
-        return new PatchSetApproval(psId, in);
+  private static Function<PatchSetApproval, PatchSetApproval> convertPatchSet(
+      final PatchSet.Id psId) {
+    return psa -> {
+      if (psa.getPatchSetId().equals(psId)) {
+        return psa;
       }
+      return new PatchSetApproval(psId, psa);
     };
   }
 
@@ -401,40 +395,34 @@
     return Iterables.transform(approvals, convertPatchSet(psId));
   }
 
-  private static Iterable<PatchSetApproval> zero(
-      Iterable<PatchSetApproval> approvals) {
-    return Iterables.transform(approvals,
-        new Function<PatchSetApproval, PatchSetApproval>() {
-          @Override
-          public PatchSetApproval apply(PatchSetApproval in) {
-            PatchSetApproval copy = new PatchSetApproval(in.getPatchSetId(), in);
-            copy.setValue((short) 0);
-            return copy;
-          }
+  private static Iterable<PatchSetApproval> zero(Iterable<PatchSetApproval> approvals) {
+    return Iterables.transform(
+        approvals,
+        a -> {
+          PatchSetApproval copy = new PatchSetApproval(a.getPatchSetId(), a);
+          copy.setValue((short) 0);
+          return copy;
         });
   }
 
   private String getByAccountName() {
-    checkNotNull(submitter,
-        "getByAccountName called before submitter populated");
-    Account account =
-        args.accountCache.get(submitter.getAccountId()).getAccount();
+    checkNotNull(submitter, "getByAccountName called before submitter populated");
+    Account account = args.accountCache.get(submitter.getAccountId()).getAccount();
     if (account != null && account.getFullName() != null) {
       return " by " + account.getFullName();
     }
     return "";
   }
 
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit,
-      CommitMergeStatus s) {
+  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
+      throws OrmException {
     checkNotNull(s, "CommitMergeStatus may not be null");
     String txt = s.getMessage();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
       return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
-    } else if (s == CommitMergeStatus.CLEAN_REBASE
-        || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(ctx, commit.getPatchsetId(),
-          txt + " as " + commit.name() + getByAccountName());
+    } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
+      return message(
+          ctx, commit.getPatchsetId(), txt + " as " + commit.name() + getByAccountName());
     } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
       return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.ALREADY_MERGED) {
@@ -448,38 +436,31 @@
         case CHERRY_PICK:
           return message(ctx, commit, CommitMergeStatus.CLEAN_PICK);
         case REBASE_IF_NECESSARY:
+        case REBASE_ALWAYS:
           return message(ctx, commit, CommitMergeStatus.CLEAN_REBASE);
         default:
-          throw new IllegalStateException("unexpected submit type "
-              + args.submitType.toString()
-              + " for change "
-              + commit.change().getId());
+          throw new IllegalStateException(
+              "unexpected submit type "
+                  + args.submitType.toString()
+                  + " for change "
+                  + commit.change().getId());
       }
     } else {
-      throw new IllegalStateException("unexpected status " + s
-          + " for change " + commit.change().getId()
-          + "; expected to previously fail fast");
+      throw new IllegalStateException(
+          "unexpected status "
+              + s
+              + " for change "
+              + commit.change().getId()
+              + "; expected to previously fail fast");
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId,
-      String body) {
-    checkNotNull(psId);
-    String uuid;
-    try {
-      uuid = ChangeUtil.messageUUID(ctx.getDb());
-    } catch (OrmException e) {
-      return null;
-    }
-    ChangeMessage m = new ChangeMessage(
-        new ChangeMessage.Key(psId.getParentKey(), uuid),
-        ctx.getAccountId(), ctx.getWhen(), psId);
-    m.setMessage(body);
-    return m;
+  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body) {
+    return ChangeMessagesUtil.newMessage(
+        psId, ctx.getUser(), ctx.getWhen(), body, ChangeMessagesUtil.TAG_MERGED);
   }
 
-  private void setMerged(ChangeContext ctx, ChangeMessage msg)
-      throws OrmException {
+  private void setMerged(ChangeContext ctx, ChangeMessage msg) throws OrmException {
     Change c = ctx.getChange();
     ReviewDb db = ctx.getDb();
     logDebug("Setting change {} merged", c.getId());
@@ -500,17 +481,17 @@
 
     if (command != null) {
       args.tagCache.updateFastForward(
-          getProject(),
-          command.getRefName(),
-          command.getOldId(),
-          command.getNewId());
+          getProject(), command.getRefName(), command.getOldId(), command.getNewId());
       // TODO(dborowitz): Move to BatchUpdate? Would also allow us to run once
       // per project even if multiple changes to refs/meta/config are submitted.
       if (RefNames.REFS_CONFIG.equals(getDest().get())) {
         args.projectCache.evict(getProject());
         ProjectState p = args.projectCache.get(getProject());
-        args.repoManager.setProjectDescription(
-            p.getProject().getNameKey(), p.getProject().getDescription());
+        try (Repository git = args.repoManager.openRepository(getProject())) {
+          git.setGitwebDescription(p.getProject().getDescription());
+        } catch (IOException e) {
+          log.error("cannot update description of " + p.getProject().getName(), e);
+        }
       }
     }
 
@@ -518,13 +499,17 @@
     // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
-          .create(ctx.getProject(), getId(), submitter.getAccountId(),
-              args.notifyHandling)
+          .create(
+              ctx.getProject(),
+              getId(),
+              submitter.getAccountId(),
+              args.notifyHandling,
+              args.accountsToNotify)
           .sendAsync();
     } catch (Exception e) {
       log.error("Cannot email merged notification for " + getId(), e);
     }
-    if (mergeResultRev != null) {
+    if (mergeResultRev != null && !args.dryrun) {
       args.changeMerged.fire(
           updatedChange,
           mergedPatchSet,
@@ -538,14 +523,12 @@
    * @see #updateRepo(RepoContext)
    * @param ctx
    */
-  protected void updateRepoImpl(RepoContext ctx) throws Exception {
-  }
+  protected void updateRepoImpl(RepoContext ctx) throws Exception {}
 
   /**
    * @see #updateChange(ChangeContext)
    * @param ctx
-   * @return a new patch set if one was created by the submit strategy, or null
-   *     if not.
+   * @return a new patch set if one was created by the submit strategy, or null if not.
    */
   protected PatchSet updateChangeImpl(ChangeContext ctx) throws Exception {
     return null;
@@ -555,15 +538,14 @@
    * @see #postUpdate(Context)
    * @param ctx
    */
-  protected void postUpdateImpl(Context ctx) throws Exception {
-  }
+  protected void postUpdateImpl(Context ctx) throws Exception {}
 
   /**
    * Amend the commit with gitlink update
+   *
    * @param commit
    */
-  protected CodeReviewCommit amendGitlink(CodeReviewCommit commit)
-      throws IntegrationException {
+  protected CodeReviewCommit amendGitlink(CodeReviewCommit commit) throws IntegrationException {
     if (!args.submoduleOp.hasSubscription(args.destBranch)) {
       return commit;
     }
@@ -573,8 +555,7 @@
       return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
     } catch (SubmoduleException | IOException e) {
       throw new IntegrationException(
-          "cannot update gitlink for the commit at branch: "
-              + args.destBranch);
+          "cannot update gitlink for the commit at branch: " + args.destBranch);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
index 91c6a14..24ff379 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.git.validators;
 
 import com.google.gerrit.server.validators.ValidationException;
-
 import java.util.Collections;
 import java.util.List;
 
@@ -23,8 +22,7 @@
   private static final long serialVersionUID = 1L;
   private final List<CommitValidationMessage> messages;
 
-  public CommitValidationException(String reason,
-      List<CommitValidationMessage> messages) {
+  public CommitValidationException(String reason, List<CommitValidationMessage> messages) {
     super(reason);
     this.messages = messages;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
index 9d7b3e6..d9fab05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
@@ -16,14 +16,13 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.events.CommitReceivedEvent;
-
 import java.util.List;
 
 /**
  * Listener to provide validation on received commits.
  *
- * Invoked by Gerrit when a new commit is received, has passed basic Gerrit
- * validation and can be then subject to extra validation checks.
+ * <p>Invoked by Gerrit when a new commit is received, has passed basic Gerrit validation and can be
+ * then subject to extra validation checks.
  */
 @ExtensionPoint
 public interface CommitValidationListener {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index dc998d6..2722067 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -16,14 +16,17 @@
 
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
+import static com.google.gerrit.server.git.ReceiveCommits.NEW_PATCHSET;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig;
@@ -31,18 +34,23 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.ReceiveCommits;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
+import com.google.inject.Singleton;
 import com.jcraft.jsch.HostKey;
-
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -51,124 +59,137 @@
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.Collections;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.regex.Pattern;
-
 public class CommitValidators {
-  private static final Logger log = LoggerFactory
-      .getLogger(CommitValidators.class);
+  private static final Logger log = LoggerFactory.getLogger(CommitValidators.class);
 
   public enum Policy {
-    /** Use {@link #validateForGerritCommits}. */
+    /** Use {@link Factory#forGerritCommits}. */
     GERRIT,
 
-    /** Use {@link #validateForReceiveCommits}. */
+    /** Use {@link Factory#forReceiveCommits}. */
     RECEIVE_COMMITS,
 
+    /** Use {@link Factory#forMergedCommits}. */
+    MERGED,
+
     /** Do not validate commits. */
     NONE
   }
 
-  public interface Factory {
-    CommitValidators create(RefControl refControl, SshInfo sshInfo,
-        Repository repo);
-  }
+  @Singleton
+  public static class Factory {
+    private final PersonIdent gerritIdent;
+    private final String canonicalWebUrl;
+    private final DynamicSet<CommitValidationListener> pluginValidators;
+    private final AllUsersName allUsers;
+    private final String installCommitMsgHookCommand;
 
-  private final PersonIdent gerritIdent;
-  private final RefControl refControl;
-  private final String canonicalWebUrl;
-  private final String installCommitMsgHookCommand;
-  private final SshInfo sshInfo;
-  private final Repository repo;
-  private final DynamicSet<CommitValidationListener> commitValidationListeners;
-  private final AllUsersName allUsers;
-
-  @Inject
-  CommitValidators(@GerritPersonIdent PersonIdent gerritIdent,
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
-      @GerritServerConfig Config config,
-      DynamicSet<CommitValidationListener> commitValidationListeners,
-      AllUsersName allUsers,
-      @Assisted SshInfo sshInfo,
-      @Assisted Repository repo,
-      @Assisted RefControl refControl) {
-    this.gerritIdent = gerritIdent;
-    this.canonicalWebUrl = canonicalWebUrl;
-    this.installCommitMsgHookCommand =
-        config.getString("gerrit", null, "installCommitMsgHookCommand");
-    this.commitValidationListeners = commitValidationListeners;
-    this.allUsers = allUsers;
-    this.sshInfo = sshInfo;
-    this.repo = repo;
-    this.refControl = refControl;
-  }
-
-  public List<CommitValidationMessage> validateForReceiveCommits(
-      CommitReceivedEvent receiveEvent, NoteMap rejectCommits)
-      throws CommitValidationException {
-
-    List<CommitValidationListener> validators = new LinkedList<>();
-
-    validators.add(new UploadMergesPermissionValidator(refControl));
-    validators.add(new AmendedGerritMergeCommitValidationListener(
-        refControl, gerritIdent));
-    validators.add(new AuthorUploaderValidator(refControl, canonicalWebUrl));
-    validators.add(new CommitterUploaderValidator(refControl, canonicalWebUrl));
-    validators.add(new SignedOffByValidator(refControl));
-    if (MagicBranch.isMagicBranch(receiveEvent.command.getRefName())
-        || ReceiveCommits.NEW_PATCHSET.matcher(
-            receiveEvent.command.getRefName()).matches()) {
-      validators.add(new ChangeIdValidator(refControl, canonicalWebUrl,
-          installCommitMsgHookCommand, sshInfo));
+    @Inject
+    Factory(
+        @GerritPersonIdent PersonIdent gerritIdent,
+        @CanonicalWebUrl @Nullable String canonicalWebUrl,
+        @GerritServerConfig Config cfg,
+        DynamicSet<CommitValidationListener> pluginValidators,
+        AllUsersName allUsers) {
+      this.gerritIdent = gerritIdent;
+      this.canonicalWebUrl = canonicalWebUrl;
+      this.pluginValidators = pluginValidators;
+      this.allUsers = allUsers;
+      this.installCommitMsgHookCommand =
+          cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
     }
-    validators.add(new ConfigValidator(refControl, repo, allUsers));
-    validators.add(new BannedCommitsValidator(rejectCommits));
-    validators.add(new PluginCommitValidationListener(commitValidationListeners));
 
-    List<CommitValidationMessage> messages = new LinkedList<>();
-
-    try {
-      for (CommitValidationListener commitValidator : validators) {
-        messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+    public CommitValidators create(
+        Policy policy, RefControl refControl, SshInfo sshInfo, Repository repo) throws IOException {
+      switch (policy) {
+        case RECEIVE_COMMITS:
+          return forReceiveCommits(refControl, sshInfo, repo);
+        case GERRIT:
+          return forGerritCommits(refControl, sshInfo, repo);
+        case MERGED:
+          return forMergedCommits(refControl);
+        case NONE:
+          return none();
+        default:
+          throw new IllegalArgumentException("unspported policy: " + policy);
       }
-    } catch (CommitValidationException e) {
-      // Keep the old messages (and their order) in case of an exception
-      messages.addAll(e.getMessages());
-      throw new CommitValidationException(e.getMessage(), messages);
     }
-    return messages;
+
+    private CommitValidators forReceiveCommits(
+        RefControl refControl, SshInfo sshInfo, Repository repo) throws IOException {
+      try (RevWalk rw = new RevWalk(repo)) {
+        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+        return new CommitValidators(
+            ImmutableList.of(
+                new UploadMergesPermissionValidator(refControl),
+                new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent),
+                new AuthorUploaderValidator(refControl, canonicalWebUrl),
+                new CommitterUploaderValidator(refControl, canonicalWebUrl),
+                new SignedOffByValidator(refControl),
+                new ChangeIdValidator(
+                    refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
+                new ConfigValidator(refControl, repo, allUsers),
+                new BannedCommitsValidator(rejectCommits),
+                new PluginCommitValidationListener(pluginValidators),
+                new BlockExternalIdUpdateListener(allUsers)));
+      }
+    }
+
+    private CommitValidators forGerritCommits(
+        RefControl refControl, SshInfo sshInfo, Repository repo) {
+      return new CommitValidators(
+          ImmutableList.of(
+              new UploadMergesPermissionValidator(refControl),
+              new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent),
+              new AuthorUploaderValidator(refControl, canonicalWebUrl),
+              new SignedOffByValidator(refControl),
+              new ChangeIdValidator(
+                  refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
+              new ConfigValidator(refControl, repo, allUsers),
+              new PluginCommitValidationListener(pluginValidators),
+              new BlockExternalIdUpdateListener(allUsers)));
+    }
+
+    private CommitValidators forMergedCommits(RefControl refControl) {
+      // Generally only include validators that are based on permissions of the
+      // user creating a change for a merged commit; generally exclude
+      // validators that would require amending the change in order to correct.
+      //
+      // Examples:
+      //  - Change-Id and Signed-off-by can't be added to an already-merged
+      //    commit.
+      //  - If the commit is banned, we can't ban it here. In fact, creating a
+      //    review of a previously merged and recently-banned commit is a use
+      //    case for post-commit code review: so reviewers have a place to
+      //    discuss what to do about it.
+      //  - Plugin validators may do things like require certain commit message
+      //    formats, so we play it safe and exclude them.
+      return new CommitValidators(
+          ImmutableList.of(
+              new UploadMergesPermissionValidator(refControl),
+              new AuthorUploaderValidator(refControl, canonicalWebUrl),
+              new CommitterUploaderValidator(refControl, canonicalWebUrl)));
+    }
+
+    private CommitValidators none() {
+      return new CommitValidators(ImmutableList.<CommitValidationListener>of());
+    }
   }
 
-  public List<CommitValidationMessage> validateForGerritCommits(
-      CommitReceivedEvent receiveEvent) throws CommitValidationException {
+  private final List<CommitValidationListener> validators;
 
-    List<CommitValidationListener> validators = new LinkedList<>();
+  CommitValidators(List<CommitValidationListener> validators) {
+    this.validators = validators;
+  }
 
-    validators.add(new UploadMergesPermissionValidator(refControl));
-    validators.add(new AmendedGerritMergeCommitValidationListener(
-        refControl, gerritIdent));
-    validators.add(new AuthorUploaderValidator(refControl, canonicalWebUrl));
-    validators.add(new SignedOffByValidator(refControl));
-    if (MagicBranch.isMagicBranch(receiveEvent.command.getRefName())
-        || ReceiveCommits.NEW_PATCHSET.matcher(
-            receiveEvent.command.getRefName()).matches()) {
-      validators.add(new ChangeIdValidator(refControl, canonicalWebUrl,
-          installCommitMsgHookCommand, sshInfo));
-    }
-    validators.add(new ConfigValidator(refControl, repo, allUsers));
-    validators.add(new PluginCommitValidationListener(commitValidationListeners));
-
-    List<CommitValidationMessage> messages = new LinkedList<>();
-
+  public List<CommitValidationMessage> validate(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    List<CommitValidationMessage> messages = new ArrayList<>();
     try {
       for (CommitValidationListener commitValidator : validators) {
         messages.addAll(commitValidator.onCommitReceived(receiveEvent));
@@ -183,25 +204,19 @@
   }
 
   public static class ChangeIdValidator implements CommitValidationListener {
-    private static final int SHA1_LENGTH = 7;
-    private static final String CHANGE_ID_PREFIX =
-        FooterConstants.CHANGE_ID.getName() + ":";
+    private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":";
     private static final String MISSING_CHANGE_ID_MSG =
-        "[%s] missing "
-        + FooterConstants.CHANGE_ID.getName()
-        + " in commit message footer";
+        "[%s] missing " + FooterConstants.CHANGE_ID.getName() + " in commit message footer";
     private static final String MISSING_SUBJECT_MSG =
         "[%s] missing subject; "
-        + FooterConstants.CHANGE_ID.getName()
-        + " must be in commit message footer";
+            + FooterConstants.CHANGE_ID.getName()
+            + " must be in commit message footer";
     private static final String MULTIPLE_CHANGE_ID_MSG =
-        "[%s] multiple "
-        + FooterConstants.CHANGE_ID.getName()
-        + " lines in commit message footer";
+        "[%s] multiple " + FooterConstants.CHANGE_ID.getName() + " lines in commit message footer";
     private static final String INVALID_CHANGE_ID_MSG =
         "[%s] invalid "
-        + FooterConstants.CHANGE_ID.getName() +
-        " line format in commit message footer";
+            + FooterConstants.CHANGE_ID.getName()
+            + " line format in commit message footer";
     private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
 
     private final ProjectControl projectControl;
@@ -210,8 +225,11 @@
     private final SshInfo sshInfo;
     private final IdentifiedUser user;
 
-    public ChangeIdValidator(RefControl refControl, String canonicalWebUrl,
-        String installCommitMsgHookCommand, SshInfo sshInfo) {
+    public ChangeIdValidator(
+        RefControl refControl,
+        String canonicalWebUrl,
+        String installCommitMsgHookCommand,
+        SshInfo sshInfo) {
       this.projectControl = refControl.getProjectControl();
       this.canonicalWebUrl = canonicalWebUrl;
       this.installCommitMsgHookCommand = installCommitMsgHookCommand;
@@ -220,18 +238,20 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (!shouldValidateChangeId(receiveEvent)) {
+        return Collections.emptyList();
+      }
       RevCommit commit = receiveEvent.commit;
-      List<CommitValidationMessage> messages = new LinkedList<>();
+      List<CommitValidationMessage> messages = new ArrayList<>();
       List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
-      String sha1 = commit.abbreviate(SHA1_LENGTH).name();
+      String sha1 = commit.abbreviate(RevId.ABBREV_LEN).name();
 
       if (idList.isEmpty()) {
         String shortMsg = commit.getShortMessage();
         if (shortMsg.startsWith(CHANGE_ID_PREFIX)
-            && CHANGE_ID.matcher(shortMsg.substring(
-                CHANGE_ID_PREFIX.length()).trim()).matches()) {
+            && CHANGE_ID.matcher(shortMsg.substring(CHANGE_ID_PREFIX.length()).trim()).matches()) {
           String errMsg = String.format(MISSING_SUBJECT_MSG, sha1);
           throw new CommitValidationException(errMsg);
         }
@@ -241,8 +261,7 @@
           throw new CommitValidationException(errMsg, messages);
         }
       } else if (idList.size() > 1) {
-        String errMsg = String.format(
-            MULTIPLE_CHANGE_ID_MSG, sha1);
+        String errMsg = String.format(MULTIPLE_CHANGE_ID_MSG, sha1);
         throw new CommitValidationException(errMsg, messages);
       } else {
         String v = idList.get(idList.size() - 1).trim();
@@ -250,14 +269,18 @@
         // Egit (I0000000000000000000000000000000000000000).
         if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
           String errMsg = String.format(INVALID_CHANGE_ID_MSG, sha1);
-          messages.add(
-            getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit));
+          messages.add(getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit));
           throw new CommitValidationException(errMsg, messages);
         }
       }
       return Collections.emptyList();
     }
 
+    private static boolean shouldValidateChangeId(CommitReceivedEvent event) {
+      return MagicBranch.isMagicBranch(event.command.getRefName())
+          || NEW_PATCHSET.matcher(event.command.getRefName()).matches();
+    }
+
     private CommitValidationMessage getMissingChangeIdErrorMsg(
         final String errMsg, final RevCommit c) {
       StringBuilder sb = new StringBuilder();
@@ -272,7 +295,7 @@
           sb.append('\n');
           sb.append("Hint: A potential ");
           sb.append(FooterConstants.CHANGE_ID.getName());
-          sb.append("Change-Id was found, but it was not in the ");
+          sb.append(" was found, but it was not in the ");
           sb.append("footer (last paragraph) of the commit message.");
         }
       }
@@ -300,8 +323,8 @@
       if (hostKeys.isEmpty()) {
         String p = "${gitdir}/hooks/commit-msg";
         return String.format(
-          "  gitdir=$(git rev-parse --git-dir); curl -o %s %s/tools/hooks/commit-msg ; chmod +x %s", p,
-          getGerritUrl(canonicalWebUrl), p);
+            "  gitdir=$(git rev-parse --git-dir); curl -o %s %s/tools/hooks/commit-msg ; chmod +x %s",
+            p, getGerritUrl(canonicalWebUrl), p);
       }
 
       // SSH keys exist, so the hook can be installed with scp.
@@ -321,37 +344,34 @@
         sshPort = 22;
       }
 
-      return String.format("  gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
+      return String.format(
+          "  gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
           sshPort, user.getUserName(), sshHost);
     }
   }
 
-  /**
-   * If this is the special project configuration branch, validate the config.
-   */
+  /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
     private final RefControl refControl;
     private final Repository repo;
     private final AllUsersName allUsers;
 
-    public ConfigValidator(RefControl refControl, Repository repo,
-        AllUsersName allUsers) {
+    public ConfigValidator(RefControl refControl, Repository repo, AllUsersName allUsers) {
       this.refControl = refControl;
       this.repo = repo;
       this.allUsers = allUsers;
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
       IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser();
 
       if (REFS_CONFIG.equals(refControl.getRefName())) {
-        List<CommitValidationMessage> messages = new LinkedList<>();
+        List<CommitValidationMessage> messages = new ArrayList<>();
 
         try {
-          ProjectConfig cfg =
-              new ProjectConfig(receiveEvent.project.getNameKey());
+          ProjectConfig cfg = new ProjectConfig(receiveEvent.project.getNameKey());
           cfg.load(repo, receiveEvent.command.getNewId());
           if (!cfg.getValidationErrors().isEmpty()) {
             addError("Invalid project configuration:", messages);
@@ -361,19 +381,21 @@
             throw new ConfigInvalidException("invalid project configuration");
           }
         } catch (ConfigInvalidException | IOException e) {
-          log.error("User " + currentUser.getUserName()
-              + " tried to push an invalid project configuration "
-              + receiveEvent.command.getNewId().name() + " for project "
-              + receiveEvent.project.getName(), e);
-          throw new CommitValidationException("invalid project configuration",
-              messages);
+          log.error(
+              "User "
+                  + currentUser.getUserName()
+                  + " tried to push an invalid project configuration "
+                  + receiveEvent.command.getNewId().name()
+                  + " for project "
+                  + receiveEvent.project.getName(),
+              e);
+          throw new CommitValidationException("invalid project configuration", messages);
         }
       }
 
-      if (allUsers.equals(
-              refControl.getProjectControl().getProject().getNameKey())
+      if (allUsers.equals(refControl.getProjectControl().getProject().getNameKey())
           && RefNames.isRefsUsers(refControl.getRefName())) {
-        List<CommitValidationMessage> messages = new LinkedList<>();
+        List<CommitValidationMessage> messages = new ArrayList<>();
         Account.Id accountId = Account.Id.fromRef(refControl.getRefName());
         if (accountId != null) {
           try {
@@ -387,12 +409,15 @@
               throw new ConfigInvalidException("invalid watch configuration");
             }
           } catch (IOException | ConfigInvalidException e) {
-            log.error("User " + currentUser.getUserName()
-                + " tried to push an invalid watch configuration "
-                + receiveEvent.command.getNewId().name() + " for account "
-                + accountId.get(), e);
-            throw new CommitValidationException("invalid watch configuration",
-                messages);
+            log.error(
+                "User "
+                    + currentUser.getUserName()
+                    + " tried to push an invalid watch configuration "
+                    + receiveEvent.command.getNewId().name()
+                    + " for account "
+                    + accountId.get(),
+                e);
+            throw new CommitValidationException("invalid watch configuration", messages);
           }
         }
       }
@@ -402,8 +427,7 @@
   }
 
   /** Require permission to upload merges. */
-  public static class UploadMergesPermissionValidator implements
-      CommitValidationListener {
+  public static class UploadMergesPermissionValidator implements CommitValidationListener {
     private final RefControl refControl;
 
     public UploadMergesPermissionValidator(RefControl refControl) {
@@ -411,10 +435,9 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
-      if (receiveEvent.commit.getParentCount() > 1
-          && !refControl.canUploadMerges()) {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (receiveEvent.commit.getParentCount() > 1 && !refControl.canUploadMerges()) {
         throw new CommitValidationException("you are not allowed to upload merges");
       }
       return Collections.emptyList();
@@ -422,8 +445,7 @@
   }
 
   /** Execute commit validation plug-ins */
-  public static class PluginCommitValidationListener implements
-      CommitValidationListener {
+  public static class PluginCommitValidationListener implements CommitValidationListener {
     private final DynamicSet<CommitValidationListener> commitValidationListeners;
 
     public PluginCommitValidationListener(
@@ -432,9 +454,9 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
-      List<CommitValidationMessage> messages = new LinkedList<>();
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      List<CommitValidationMessage> messages = new ArrayList<>();
 
       for (CommitValidationListener validator : commitValidationListeners) {
         try {
@@ -456,8 +478,8 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
       IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser();
       final PersonIdent committer = receiveEvent.commit.getCommitterIdent();
       final PersonIdent author = receiveEvent.commit.getAuthorIdent();
@@ -477,8 +499,7 @@
             }
           }
         }
-        if (!sboAuthor && !sboCommitter && !sboMe
-            && !refControl.canForgeCommitter()) {
+        if (!sboAuthor && !sboCommitter && !sboMe && !refControl.canForgeCommitter()) {
           throw new CommitValidationException(
               "not Signed-off-by author/committer/uploader in commit message footer");
         }
@@ -488,8 +509,7 @@
   }
 
   /** Require that author matches the uploader. */
-  public static class AuthorUploaderValidator implements
-      CommitValidationListener {
+  public static class AuthorUploaderValidator implements CommitValidationListener {
     private final RefControl refControl;
     private final String canonicalWebUrl;
 
@@ -499,17 +519,17 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
       IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser();
       final PersonIdent author = receiveEvent.commit.getAuthorIdent();
 
-      if (!currentUser.hasEmailAddress(author.getEmailAddress())
-          && !refControl.canForgeAuthor()) {
-        List<CommitValidationMessage> messages = new LinkedList<>();
+      if (!currentUser.hasEmailAddress(author.getEmailAddress()) && !refControl.canForgeAuthor()) {
+        List<CommitValidationMessage> messages = new ArrayList<>();
 
-        messages.add(getInvalidEmailError(receiveEvent.commit, "author", author,
-            currentUser, canonicalWebUrl));
+        messages.add(
+            getInvalidEmailError(
+                receiveEvent.commit, "author", author, currentUser, canonicalWebUrl));
         throw new CommitValidationException("invalid author", messages);
       }
       return Collections.emptyList();
@@ -517,27 +537,26 @@
   }
 
   /** Require that committer matches the uploader. */
-  public static class CommitterUploaderValidator implements
-      CommitValidationListener {
+  public static class CommitterUploaderValidator implements CommitValidationListener {
     private final RefControl refControl;
     private final String canonicalWebUrl;
 
-    public CommitterUploaderValidator(RefControl refControl,
-        String canonicalWebUrl) {
+    public CommitterUploaderValidator(RefControl refControl, String canonicalWebUrl) {
       this.refControl = refControl;
       this.canonicalWebUrl = canonicalWebUrl;
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
       IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser();
       final PersonIdent committer = receiveEvent.commit.getCommitterIdent();
       if (!currentUser.hasEmailAddress(committer.getEmailAddress())
           && !refControl.canForgeCommitter()) {
-        List<CommitValidationMessage> messages = new LinkedList<>();
-        messages.add(getInvalidEmailError(receiveEvent.commit, "committer", committer,
-            currentUser, canonicalWebUrl));
+        List<CommitValidationMessage> messages = new ArrayList<>();
+        messages.add(
+            getInvalidEmailError(
+                receiveEvent.commit, "committer", committer, currentUser, canonicalWebUrl));
         throw new CommitValidationException("invalid committer", messages);
       }
       return Collections.emptyList();
@@ -545,12 +564,11 @@
   }
 
   /**
-   * Don't allow the user to amend a merge created by Gerrit Code Review. This
-   * seems to happen all too often, due to users not paying any attention to
-   * what they are doing.
+   * Don't allow the user to amend a merge created by Gerrit Code Review. This seems to happen all
+   * too often, due to users not paying any attention to what they are doing.
    */
-  public static class AmendedGerritMergeCommitValidationListener implements
-      CommitValidationListener {
+  public static class AmendedGerritMergeCommitValidationListener
+      implements CommitValidationListener {
     private final PersonIdent gerritIdent;
     private final RefControl refControl;
 
@@ -561,8 +579,8 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
       final PersonIdent author = receiveEvent.commit.getAuthorIdent();
 
       if (receiveEvent.commit.getParentCount() > 1
@@ -576,8 +594,7 @@
   }
 
   /** Reject banned commits. */
-  public static class BannedCommitsValidator implements
-      CommitValidationListener {
+  public static class BannedCommitsValidator implements CommitValidationListener {
     private final NoteMap rejectCommits;
 
     public BannedCommitsValidator(NoteMap rejectCommits) {
@@ -585,12 +602,12 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(
-        CommitReceivedEvent receiveEvent) throws CommitValidationException {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
       try {
         if (rejectCommits.contains(receiveEvent.commit)) {
-          throw new CommitValidationException("contains banned commit "
-              + receiveEvent.commit.getName());
+          throw new CommitValidationException(
+              "contains banned commit " + receiveEvent.commit.getName());
         }
         return Collections.emptyList();
       } catch (IOException e) {
@@ -601,13 +618,39 @@
     }
   }
 
-  private static CommitValidationMessage getInvalidEmailError(RevCommit c, String type,
-      PersonIdent who, IdentifiedUser currentUser, String canonicalWebUrl) {
+  /** Blocks any update to refs/meta/external-ids */
+  public static class BlockExternalIdUpdateListener implements CommitValidationListener {
+    private final AllUsersName allUsers;
+
+    public BlockExternalIdUpdateListener(AllUsersName allUsers) {
+      this.allUsers = allUsers;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (allUsers.equals(receiveEvent.project.getNameKey())
+          && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) {
+        throw new CommitValidationException("not allowed to update " + RefNames.REFS_EXTERNAL_IDS);
+      }
+      return Collections.emptyList();
+    }
+  }
+
+  private static CommitValidationMessage getInvalidEmailError(
+      RevCommit c,
+      String type,
+      PersonIdent who,
+      IdentifiedUser currentUser,
+      String canonicalWebUrl) {
     StringBuilder sb = new StringBuilder();
     sb.append("\n");
     sb.append("ERROR:  In commit ").append(c.name()).append("\n");
-    sb.append("ERROR:  ").append(type).append(" email address ")
-      .append(who.getEmailAddress()).append("\n");
+    sb.append("ERROR:  ")
+        .append(type)
+        .append(" email address ")
+        .append(who.getEmailAddress())
+        .append("\n");
     sb.append("ERROR:  does not match your user account.\n");
     sb.append("ERROR:\n");
     if (currentUser.getEmailAddresses().isEmpty()) {
@@ -621,8 +664,11 @@
     sb.append("ERROR:\n");
     if (canonicalWebUrl != null) {
       sb.append("ERROR:  To register an email address, please visit:\n");
-      sb.append("ERROR:  ").append(canonicalWebUrl).append("#")
-        .append(PageLinks.SETTINGS_CONTACT).append("\n");
+      sb.append("ERROR:  ")
+          .append(canonicalWebUrl)
+          .append("#")
+          .append(PageLinks.SETTINGS_CONTACT)
+          .append("\n");
     }
     sb.append("\n");
     return new CommitValidationMessage(sb.toString(), false);
@@ -631,9 +677,9 @@
   /**
    * Get the Gerrit URL.
    *
-   * @return the canonical URL (with any trailing slash removed) if it is
-   *         configured, otherwise fall back to "http://hostname" where hostname
-   *         is the value returned by {@link #getGerritHost(String)}.
+   * @return the canonical URL (with any trailing slash removed) if it is configured, otherwise fall
+   *     back to "http://hostname" where hostname is the value returned by {@link
+   *     #getGerritHost(String)}.
    */
   private static String getGerritUrl(String canonicalWebUrl) {
     if (canonicalWebUrl != null) {
@@ -645,8 +691,8 @@
   /**
    * Get the Gerrit hostname.
    *
-   * @return the hostname from the canonical URL if it is configured, otherwise
-   *         whatever the OS says the hostname is.
+   * @return the hostname from the canonical URL if it is configured, otherwise whatever the OS says
+   *     the hostname is.
    */
   private static String getGerritHost(String canonicalWebUrl) {
     String host;
@@ -662,8 +708,7 @@
     return host;
   }
 
-  private static void addError(String error,
-      List<CommitValidationMessage> messages) {
+  private static void addError(String error, List<CommitValidationMessage> messages) {
     messages.add(new CommitValidationMessage(error, true));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java
index 018ec1b..3624fe0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationException.java
@@ -18,9 +18,9 @@
 
 /**
  * Exception that occurs during a validation step before merging changes.
- * <p>
- * Used by {@link MergeValidationListener}s provided by plugins. Messages should
- * be considered human-readable.
+ *
+ * <p>Used by {@link MergeValidationListener}s provided by plugins. Messages should be considered
+ * human-readable.
  */
 public class MergeValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index d89d3b3..6edd04e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -20,13 +20,12 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.ProjectState;
-
 import org.eclipse.jgit.lib.Repository;
 
 /**
  * Listener to provide validation of commits before merging.
  *
- * Invoked by Gerrit before a commit is merged.
+ * <p>Invoked by Gerrit before a commit is merged.
  */
 @ExtensionPoint
 public interface MergeValidationListener {
@@ -41,7 +40,8 @@
    * @param caller the user who initiated the merge request
    * @throws MergeValidationException if the commit fails to validate
    */
-  void onPreMerge(Repository repo,
+  void onPreMerge(
+      Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
       Branch.NameKey destBranch,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 4bf1deb..150965c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
@@ -31,14 +32,11 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-import java.util.LinkedList;
-import java.util.List;
-
 public class MergeValidators {
   private final DynamicSet<MergeValidationListener> mergeValidationListeners;
   private final ProjectConfigValidator.Factory projectConfigValidatorFactory;
@@ -48,51 +46,50 @@
   }
 
   @Inject
-  MergeValidators(DynamicSet<MergeValidationListener> mergeValidationListeners,
+  MergeValidators(
+      DynamicSet<MergeValidationListener> mergeValidationListeners,
       ProjectConfigValidator.Factory projectConfigValidatorFactory) {
     this.mergeValidationListeners = mergeValidationListeners;
     this.projectConfigValidatorFactory = projectConfigValidatorFactory;
   }
 
-  public void validatePreMerge(Repository repo,
+  public void validatePreMerge(
+      Repository repo,
       CodeReviewCommit commit,
       ProjectState destProject,
       Branch.NameKey destBranch,
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException {
-    List<MergeValidationListener> validators = new LinkedList<>();
-
-    validators.add(new PluginMergeValidationListener(mergeValidationListeners));
-    validators.add(projectConfigValidatorFactory.create());
+    List<MergeValidationListener> validators =
+        ImmutableList.of(
+            new PluginMergeValidationListener(mergeValidationListeners),
+            projectConfigValidatorFactory.create());
 
     for (MergeValidationListener validator : validators) {
-      validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId,
-          caller);
+      validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
     }
   }
 
-  public static class ProjectConfigValidator implements
-      MergeValidationListener {
+  public static class ProjectConfigValidator implements MergeValidationListener {
     private static final String INVALID_CONFIG =
         "Change contains an invalid project configuration.";
     private static final String PARENT_NOT_FOUND =
-        "Change contains an invalid project configuration:\n"
-        + "Parent project does not exist.";
+        "Change contains an invalid project configuration:\nParent project does not exist.";
     private static final String PLUGIN_VALUE_NOT_EDITABLE =
         "Change contains an invalid project configuration:\n"
-        + "One of the plugin configuration parameters is not editable.";
+            + "One of the plugin configuration parameters is not editable.";
     private static final String PLUGIN_VALUE_NOT_PERMITTED =
         "Change contains an invalid project configuration:\n"
-        + "One of the plugin configuration parameters has a value that is not"
-        + " permitted.";
+            + "One of the plugin configuration parameters has a value that is not"
+            + " permitted.";
     private static final String ROOT_NO_PARENT =
         "Change contains an invalid project configuration:\n"
-        + "The root project cannot have a parent.";
+            + "The root project cannot have a parent.";
     private static final String SET_BY_ADMIN =
         "Change contains a project configuration that changes the parent"
-        + " project.\n"
-        + "The change must be submitted by a Gerrit administrator.";
+            + " project.\n"
+            + "The change must be submitted by a Gerrit administrator.";
 
     private final AllProjectsName allProjectsName;
     private final ProjectCache projectCache;
@@ -103,7 +100,8 @@
     }
 
     @Inject
-    public ProjectConfigValidator(AllProjectsName allProjectsName,
+    public ProjectConfigValidator(
+        AllProjectsName allProjectsName,
         ProjectCache projectCache,
         DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
       this.allProjectsName = allProjectsName;
@@ -112,7 +110,8 @@
     }
 
     @Override
-    public void onPreMerge(final Repository repo,
+    public void onPreMerge(
+        final Repository repo,
         final CodeReviewCommit commit,
         final ProjectState destProject,
         final Branch.NameKey destBranch,
@@ -122,12 +121,10 @@
       if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
         final Project.NameKey newParent;
         try {
-          ProjectConfig cfg =
-              new ProjectConfig(destProject.getProject().getNameKey());
+          ProjectConfig cfg = new ProjectConfig(destProject.getProject().getNameKey());
           cfg.load(repo, commit);
           newParent = cfg.getProject().getParent(allProjectsName);
-          final Project.NameKey oldParent =
-              destProject.getProject().getParent(allProjectsName);
+          final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
           if (oldParent == null) {
             // update of the 'All-Projects' project
             if (newParent != null) {
@@ -150,17 +147,20 @@
             ProjectConfigEntry configEntry = e.getProvider().get();
 
             String value = pluginCfg.getString(e.getExportName());
-            String oldValue = destProject.getConfig()
-                .getPluginConfig(e.getPluginName())
-                .getString(e.getExportName());
+            String oldValue =
+                destProject
+                    .getConfig()
+                    .getPluginConfig(e.getPluginName())
+                    .getString(e.getExportName());
 
-            if ((value == null ? oldValue != null : !value.equals(oldValue)) &&
-                !configEntry.isEditable(destProject)) {
+            if ((value == null ? oldValue != null : !value.equals(oldValue))
+                && !configEntry.isEditable(destProject)) {
               throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
             }
 
             if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
-                && value != null && !configEntry.getPermittedValues().contains(value)) {
+                && value != null
+                && !configEntry.getPermittedValues().contains(value)) {
               throw new MergeValidationException(PLUGIN_VALUE_NOT_PERMITTED);
             }
           }
@@ -172,8 +172,7 @@
   }
 
   /** Execute merge validation plug-ins */
-  public static class PluginMergeValidationListener implements
-      MergeValidationListener {
+  public static class PluginMergeValidationListener implements MergeValidationListener {
     private final DynamicSet<MergeValidationListener> mergeValidationListeners;
 
     public PluginMergeValidationListener(
@@ -182,7 +181,8 @@
     }
 
     @Override
-    public void onPreMerge(Repository repo,
+    public void onPreMerge(
+        Repository repo,
         CodeReviewCommit commit,
         ProjectState destProject,
         Branch.NameKey destBranch,
@@ -190,8 +190,7 @@
         IdentifiedUser caller)
         throws MergeValidationException {
       for (MergeValidationListener validator : mergeValidationListeners) {
-        validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId,
-            caller);
+        validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
new file mode 100644
index 0000000..da3c123
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.git.validators;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.server.validators.ValidationException;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Listener to validate ref updates performed during submit operation.
+ *
+ * <p>As submit strategies may generate new commits (e.g. Cherry Pick), this listener allows
+ * validation of resulting new commit before destination branch is updated and new patchset ref is
+ * created.
+ *
+ * <p>If you only care about validating the change being submitted and not the resulting new commit,
+ * consider using {@link MergeValidationListener} instead.
+ */
+@ExtensionPoint
+public interface OnSubmitValidationListener {
+  class Arguments {
+    private Project.NameKey project;
+    private Repository repository;
+    private ObjectReader objectReader;
+    private Map<String, ReceiveCommand> commands;
+
+    public Arguments(
+        NameKey project,
+        Repository repository,
+        ObjectReader objectReader,
+        Map<String, ReceiveCommand> commands) {
+      this.project = project;
+      this.repository = repository;
+      this.objectReader = objectReader;
+      this.commands = commands;
+    }
+
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    /** @return a read only repository */
+    public Repository getRepository() {
+      return repository;
+    }
+
+    public RevWalk newRevWalk() {
+      return new RevWalk(objectReader);
+    }
+
+    /**
+     * @return a map from ref to op on it covering all ref ops to be performed on this repository as
+     *     part of ongoing submit operation.
+     */
+    public Map<String, ReceiveCommand> getCommands() {
+      return commands;
+    }
+  }
+
+  /**
+   * Called right before branch is updated with new commit or commits as a result of submit.
+   *
+   * <p>If ValidationException is thrown, submitting is aborted.
+   */
+  void preBranchUpdate(Arguments args) throws ValidationException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
new file mode 100644
index 0000000..55935d1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.git.validators;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.validators.OnSubmitValidationListener.Arguments;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+public class OnSubmitValidators {
+  public interface Factory {
+    OnSubmitValidators create();
+  }
+
+  private final DynamicSet<OnSubmitValidationListener> listeners;
+
+  @Inject
+  OnSubmitValidators(DynamicSet<OnSubmitValidationListener> listeners) {
+    this.listeners = listeners;
+  }
+
+  public void validate(
+      Project.NameKey project,
+      Repository repo,
+      ObjectReader objectReader,
+      Map<String, ReceiveCommand> commands)
+      throws IntegrationException {
+    try {
+      for (OnSubmitValidationListener listener : this.listeners) {
+        listener.preBranchUpdate(new Arguments(project, repo, objectReader, commands));
+      }
+    } catch (ValidationException e) {
+      throw new IntegrationException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
index 5864833..9eaf2d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
@@ -20,8 +20,7 @@
   private static final long serialVersionUID = 1L;
   private final Iterable<ValidationMessage> messages;
 
-  public RefOperationValidationException(String reason,
-      Iterable<ValidationMessage> messages) {
+  public RefOperationValidationException(String reason, Iterable<ValidationMessage> messages) {
     super(reason);
     this.messages = messages;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationListener.java
index 5d04e2a..b57b254 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidationListener.java
@@ -16,13 +16,9 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.events.RefReceivedEvent;
 import com.google.gerrit.server.validators.ValidationException;
-
 import java.util.List;
 
-/**
- * Listener to provide validation on operation that is going to be performed on
- * given ref
- */
+/** Listener to provide validation on operation that is going to be performed on given ref */
 @ExtensionPoint
 public interface RefOperationValidationListener {
   /**
@@ -32,6 +28,5 @@
    * @return empty list or informational messages on success
    * @throws ValidationException if the ref operation fails to validate
    */
-  List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
-      throws ValidationException;
+  List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent) throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 580de95..80792c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -22,27 +22,24 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.ArrayList;
-import java.util.List;
-
 public class RefOperationValidators {
   private static final GetErrorMessages GET_ERRORS = new GetErrorMessages();
-  private static final Logger LOG = LoggerFactory
-      .getLogger(RefOperationValidators.class);
+  private static final Logger LOG = LoggerFactory.getLogger(RefOperationValidators.class);
 
   public interface Factory {
     RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
   }
 
   public static ReceiveCommand getCommand(RefUpdate update, ReceiveCommand.Type type) {
-    return new ReceiveCommand(update.getExpectedOldObjectId(), update.getNewObjectId(),
-        update.getName(), type);
+    return new ReceiveCommand(
+        update.getExpectedOldObjectId(), update.getNewObjectId(), update.getName(), type);
   }
 
   private final RefReceivedEvent event;
@@ -51,7 +48,8 @@
   @Inject
   RefOperationValidators(
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
-      @Assisted Project project, @Assisted IdentifiedUser user,
+      @Assisted Project project,
+      @Assisted IdentifiedUser user,
       @Assisted ReceiveCommand cmd) {
     this.refOperationValidationListeners = refOperationValidationListeners;
     event = new RefReceivedEvent();
@@ -60,8 +58,7 @@
     event.user = user;
   }
 
-  public List<ValidationMessage> validateForRefOperation()
-    throws RefOperationValidationException {
+  public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
 
     List<ValidationMessage> messages = new ArrayList<>();
     boolean withException = false;
@@ -81,12 +78,13 @@
     return messages;
   }
 
-  private void throwException(Iterable<ValidationMessage> messages,
-      RefReceivedEvent event) throws RefOperationValidationException {
+  private void throwException(Iterable<ValidationMessage> messages, RefReceivedEvent event)
+      throws RefOperationValidationException {
     Iterable<ValidationMessage> errors = Iterables.filter(messages, GET_ERRORS);
-    String header = String.format(
-        "Ref \"%s\" %S in project %s validation failed", event.command.getRefName(),
-        event.command.getType(), event.project.getName());
+    String header =
+        String.format(
+            "Ref \"%s\" %S in project %s validation failed",
+            event.command.getRefName(), event.command.getType(), event.project.getName());
     LOG.error(header);
     throw new RefOperationValidationException(header, errors);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
index c86e87a..13065e4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidationListener.java
@@ -17,21 +17,18 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.validators.ValidationException;
-
+import java.util.Collection;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.UploadPack;
 
-import java.util.Collection;
-
 /**
  * Listener to provide validation for upload operations.
  *
- * Invoked by Gerrit before it begins to send a pack to the client.
+ * <p>Invoked by Gerrit before it begins to send a pack to the client.
  *
- * Implementors can block the upload operation by throwing a
- * ValidationException. The exception's message text will be reported to
- * the end-user over the client's protocol connection.
+ * <p>Implementors can block the upload operation by throwing a ValidationException. The exception's
+ * message text will be reported to the end-user over the client's protocol connection.
  */
 @ExtensionPoint
 public interface UploadValidationListener {
@@ -42,20 +39,23 @@
    * @param repository The repository
    * @param project The project
    * @param remoteHost Remote address/hostname of the user
-   * @param up the UploadPack instance being processed
-   * @param wants The list of wanted objects. These may be RevObject or
-   *        RevCommit if the processor parsed them. Implementors should not rely
-   *        on the values being parsed.
-   * @param haves The list of common objects. Empty on an initial clone request.
-   *        These may be RevObject or RevCommit if the processor parsed them.
-   *        Implementors should not rely on the values being parsed.
-   * @throws ValidationException to block the upload and send a message
-   *         back to the end-user over the client's protocol connection.
+   * @param up the UploadPack instance being processed.
+   * @param wants The list of wanted objects. These may be RevObject or RevCommit if the processor
+   *     parsed them. Implementors should not rely on the values being parsed.
+   * @param haves The list of common objects. Empty on an initial clone request. These may be
+   *     RevObject or RevCommit if the processor parsed them. Implementors should not rely on the
+   *     values being parsed.
+   * @throws ValidationException to block the upload and send a message back to the end-user over
+   *     the client's protocol connection.
    */
-  void onPreUpload(Repository repository, Project project,
-      String remoteHost, UploadPack up, Collection<? extends ObjectId> wants,
+  default void onPreUpload(
+      Repository repository,
+      Project project,
+      String remoteHost,
+      UploadPack up,
+      Collection<? extends ObjectId> wants,
       Collection<? extends ObjectId> haves)
-      throws ValidationException;
+      throws ValidationException {}
 
   /**
    * Invoked before negotiation round is started.
@@ -64,14 +64,18 @@
    * @param project The project
    * @param remoteHost Remote address/hostname of the user
    * @param up the UploadPack instance being processed
-   * @param wants The list of wanted objects. These may be RevObject or
-   *        RevCommit if the processor parsed them. Implementors should not rely
-   *        on the values being parsed.
+   * @param wants The list of wanted objects. These may be RevObject or RevCommit if the processor
+   *     parsed them. Implementors should not rely on the values being parsed.
    * @param cntOffered number of objects the client has offered.
-   * @throws ValidationException to block the upload and send a message back to
-   *         the end-user over the client's protocol connection.
+   * @throws ValidationException to block the upload and send a message back to the end-user over
+   *     the client's protocol connection.
    */
-  void onBeginNegotiate(Repository repository, Project project,
-      String remoteHost, UploadPack up, Collection<? extends ObjectId> wants,
-      int cntOffered) throws ValidationException;
+  default void onBeginNegotiate(
+      Repository repository,
+      Project project,
+      String remoteHost,
+      UploadPack up,
+      Collection<? extends ObjectId> wants,
+      int cntOffered)
+      throws ValidationException {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
index 66cc0e5..84d4586 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/UploadValidators.java
@@ -19,15 +19,13 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
+import java.util.Collection;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PreUploadHook;
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 
-import java.util.Collection;
-
 public class UploadValidators implements PreUploadHook {
 
   private final DynamicSet<UploadValidationListener> uploadValidationListeners;
@@ -36,14 +34,14 @@
   private final String remoteHost;
 
   public interface Factory {
-    UploadValidators create(Project project, Repository repository,
-        String remoteAddress);
+    UploadValidators create(Project project, Repository repository, String remoteAddress);
   }
 
   @Inject
   UploadValidators(
       DynamicSet<UploadValidationListener> uploadValidationListeners,
-      @Assisted Project project, @Assisted Repository repository,
+      @Assisted Project project,
+      @Assisted Repository repository,
       @Assisted String remoteHost) {
     this.uploadValidationListeners = uploadValidationListeners;
     this.project = project;
@@ -52,8 +50,8 @@
   }
 
   @Override
-  public void onSendPack(UploadPack up, Collection<? extends ObjectId> wants,
-      Collection<? extends ObjectId> haves)
+  public void onSendPack(
+      UploadPack up, Collection<? extends ObjectId> wants, Collection<? extends ObjectId> haves)
       throws ServiceMayNotContinueException {
     for (UploadValidationListener validator : uploadValidationListeners) {
       try {
@@ -61,18 +59,16 @@
       } catch (ValidationException e) {
         throw new UploadValidationException(e.getMessage());
       }
-
     }
   }
 
   @Override
-  public void onBeginNegotiateRound(UploadPack up,
-      Collection<? extends ObjectId> wants, int cntOffered)
+  public void onBeginNegotiateRound(
+      UploadPack up, Collection<? extends ObjectId> wants, int cntOffered)
       throws ServiceMayNotContinueException {
     for (UploadValidationListener validator : uploadValidationListeners) {
       try {
-        validator.onBeginNegotiate(repository, project, remoteHost, up, wants,
-            cntOffered);
+        validator.onBeginNegotiate(repository, project, remoteHost, up, wants, cntOffered);
       } catch (ValidationException e) {
         throw new UploadValidationException(e.getMessage());
       }
@@ -80,8 +76,11 @@
   }
 
   @Override
-  public void onEndNegotiateRound(UploadPack up,
-      Collection<? extends ObjectId> wants, int cntCommon, int cntNotFound,
-      boolean ready) throws ServiceMayNotContinueException {
-  }
+  public void onEndNegotiateRound(
+      UploadPack up,
+      Collection<? extends ObjectId> wants,
+      int cntCommon,
+      int cntNotFound,
+      boolean ready)
+      throws ServiceMayNotContinueException {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java
index 2deb44a..040550c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java
@@ -37,17 +37,15 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
 @Singleton
 public class AddIncludedGroups implements RestModifyView<GroupResource, Input> {
   public static class Input {
-    @DefaultInput
-    String _oneGroup;
+    @DefaultInput String _oneGroup;
 
     public List<String> groups;
 
@@ -78,9 +76,12 @@
   private final AuditService auditService;
 
   @Inject
-  public AddIncludedGroups(GroupsCollection groupsCollection,
-      GroupIncludeCache groupIncludeCache, Provider<ReviewDb> db,
-      GroupJson json, AuditService auditService) {
+  public AddIncludedGroups(
+      GroupsCollection groupsCollection,
+      GroupIncludeCache groupIncludeCache,
+      Provider<ReviewDb> db,
+      GroupJson json,
+      AuditService auditService) {
     this.groupsCollection = groupsCollection;
     this.groupIncludeCache = groupIncludeCache;
     this.db = db;
@@ -90,8 +91,7 @@
 
   @Override
   public List<GroupInfo> apply(GroupResource resource, Input input)
-      throws MethodNotAllowedException, AuthException,
-      UnprocessableEntityException, OrmException {
+      throws MethodNotAllowedException, AuthException, UnprocessableEntityException, OrmException {
     AccountGroup group = resource.toAccountGroup();
     if (group == null) {
       throw new MethodNotAllowedException();
@@ -100,20 +100,17 @@
 
     GroupControl control = resource.getControl();
     Map<AccountGroup.UUID, AccountGroupById> newIncludedGroups = new HashMap<>();
-    List<GroupInfo> result = new LinkedList<>();
+    List<GroupInfo> result = new ArrayList<>();
     Account.Id me = control.getUser().getAccountId();
 
     for (String includedGroup : input.groups) {
       GroupDescription.Basic d = groupsCollection.parse(includedGroup);
       if (!control.canAddGroup()) {
-        throw new AuthException(String.format("Cannot add group: %s",
-            d.getName()));
+        throw new AuthException(String.format("Cannot add group: %s", d.getName()));
       }
 
       if (!newIncludedGroups.containsKey(d.getGroupUUID())) {
-        AccountGroupById.Key agiKey =
-            new AccountGroupById.Key(group.getId(),
-                d.getGroupUUID());
+        AccountGroupById.Key agiKey = new AccountGroupById.Key(group.getId(), d.getGroupUUID());
         AccountGroupById agi = db.get().accountGroupById().get(agiKey);
         if (agi == null) {
           agi = new AccountGroupById(agiKey);
@@ -136,8 +133,7 @@
   }
 
   static class PutIncludedGroup implements RestModifyView<GroupResource, PutIncludedGroup.Input> {
-    static class Input {
-    }
+    static class Input {}
 
     private final AddIncludedGroups put;
     private final String id;
@@ -149,8 +145,7 @@
 
     @Override
     public GroupInfo apply(GroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException,
-        ResourceNotFoundException, OrmException {
+        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException {
       AddIncludedGroups.Input in = new AddIncludedGroups.Input();
       in.groups = ImmutableList.of(id);
       try {
@@ -166,7 +161,8 @@
   }
 
   @Singleton
-  static class UpdateIncludedGroup implements RestModifyView<IncludedGroupResource, PutIncludedGroup.Input> {
+  static class UpdateIncludedGroup
+      implements RestModifyView<IncludedGroupResource, PutIncludedGroup.Input> {
     private final Provider<GetIncludedGroup> get;
 
     @Inject
@@ -175,8 +171,8 @@
     }
 
     @Override
-    public GroupInfo apply(IncludedGroupResource resource,
-        PutIncludedGroup.Input input) throws OrmException {
+    public GroupInfo apply(IncludedGroupResource resource, PutIncludedGroup.Input input)
+        throws OrmException {
       // Do nothing, the group is already included.
       return get.get().apply(resource);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
index bd74fff..150ac01 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.audit.AuditService;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -27,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.group.AddMembers.Input;
@@ -44,12 +45,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -57,10 +57,10 @@
 @Singleton
 public class AddMembers implements RestModifyView<GroupResource, Input> {
   public static class Input {
-    @DefaultInput
-    String _oneMember;
+    @DefaultInput String _oneMember;
 
     List<String> members;
+
     public static Input fromMembers(List<String> members) {
       Input in = new Input();
       in.members = members;
@@ -92,7 +92,8 @@
   private final AuditService auditService;
 
   @Inject
-  AddMembers(Provider<IdentifiedUser> self,
+  AddMembers(
+      Provider<IdentifiedUser> self,
       AccountManager accountManager,
       AuthConfig authConfig,
       AccountsCollection accounts,
@@ -114,8 +115,8 @@
 
   @Override
   public List<AccountInfo> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException,
-      UnprocessableEntityException, OrmException, IOException {
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+          IOException {
     AccountGroup internalGroup = resource.toAccountGroup();
     if (internalGroup == null) {
       throw new MethodNotAllowedException();
@@ -128,8 +129,8 @@
     for (String nameOrEmailOrId : input.members) {
       Account a = findAccount(nameOrEmailOrId);
       if (!a.isActive()) {
-        throw new UnprocessableEntityException(String.format(
-            "Account Inactive: %s", nameOrEmailOrId));
+        throw new UnprocessableEntityException(
+            String.format("Account Inactive: %s", nameOrEmailOrId));
       }
 
       if (!control.canAddMember()) {
@@ -142,8 +143,8 @@
     return toAccountInfoList(newMemberIds);
   }
 
-  Account findAccount(String nameOrEmailOrId) throws AuthException,
-      UnprocessableEntityException, OrmException, IOException {
+  Account findAccount(String nameOrEmailOrId)
+      throws AuthException, UnprocessableEntityException, OrmException, IOException {
     try {
       return accounts.parse(nameOrEmailOrId).getAccount();
     } catch (UnprocessableEntityException e) {
@@ -174,14 +175,12 @@
     }
   }
 
-  public void addMembers(AccountGroup.Id groupId,
-      Collection<? extends Account.Id> newMemberIds)
-          throws OrmException, IOException {
+  public void addMembers(AccountGroup.Id groupId, Collection<? extends Account.Id> newMemberIds)
+      throws OrmException, IOException {
     Map<Account.Id, AccountGroupMember> newAccountGroupMembers = new HashMap<>();
     for (Account.Id accId : newMemberIds) {
       if (!newAccountGroupMembers.containsKey(accId)) {
-        AccountGroupMember.Key key =
-            new AccountGroupMember.Key(accId, groupId);
+        AccountGroupMember.Key key = new AccountGroupMember.Key(accId, groupId);
         AccountGroupMember m = db.get().accountGroupMembers().get(key);
         if (m == null) {
           m = new AccountGroupMember(key);
@@ -190,8 +189,8 @@
       }
     }
     if (!newAccountGroupMembers.isEmpty()) {
-      auditService.dispatchAddAccountsToGroup(self.get().getAccountId(),
-          newAccountGroupMembers.values());
+      auditService.dispatchAddAccountsToGroup(
+          self.get().getAccountId(), newAccountGroupMembers.values());
       db.get().accountGroupMembers().insert(newAccountGroupMembers.values());
       for (AccountGroupMember m : newAccountGroupMembers.values()) {
         accountCache.evict(m.getAccountId());
@@ -200,23 +199,21 @@
   }
 
   private Account createAccountByLdap(String user) throws IOException {
-    if (!user.matches(Account.USER_NAME_PATTERN)) {
+    if (!ExternalId.isValidUsername(user)) {
       return null;
     }
 
     try {
       AuthRequest req = AuthRequest.forUser(user);
       req.setSkipAuthentication(true);
-      return accountCache.get(accountManager.authenticate(req).getAccountId())
-          .getAccount();
+      return accountCache.get(accountManager.authenticate(req).getAccountId()).getAccount();
     } catch (AccountException e) {
       return null;
     }
   }
 
-  private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds)
-      throws OrmException {
-    List<AccountInfo> result = new LinkedList<>();
+  private List<AccountInfo> toAccountInfoList(Set<Account.Id> accountIds) throws OrmException {
+    List<AccountInfo> result = new ArrayList<>();
     AccountLoader loader = infoFactory.create(true);
     for (Account.Id accId : accountIds) {
       result.add(loader.get(accId));
@@ -226,8 +223,7 @@
   }
 
   static class PutMember implements RestModifyView<GroupResource, PutMember.Input> {
-    static class Input {
-    }
+    static class Input {}
 
     private final AddMembers put;
     private final String id;
@@ -239,8 +235,8 @@
 
     @Override
     public AccountInfo apply(GroupResource resource, PutMember.Input input)
-        throws AuthException, MethodNotAllowedException,
-        ResourceNotFoundException, OrmException, IOException {
+        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
+            IOException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = id;
       try {
@@ -265,8 +261,7 @@
     }
 
     @Override
-    public AccountInfo apply(MemberResource resource, PutMember.Input input)
-        throws OrmException {
+    public AccountInfo apply(MemberResource resource, PutMember.Input input) throws OrmException {
       // Do nothing, the user is already a member.
       return get.apply(resource);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
index 0fd4728..4d78a7d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
@@ -48,16 +48,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
 
 @RequiresCapability(GlobalCapability.CREATE_GROUP)
 public class CreateGroup implements RestModifyView<TopLevelResource, GroupInput> {
@@ -73,6 +71,7 @@
   private final GroupJson json;
   private final DynamicSet<GroupCreationValidationListener> groupCreationValidationListeners;
   private final AddMembers addMembers;
+  private final SystemGroupBackend systemGroupBackend;
   private final boolean defaultVisibleToAll;
   private final String name;
 
@@ -86,6 +85,7 @@
       GroupJson json,
       DynamicSet<GroupCreationValidationListener> groupCreationValidationListeners,
       AddMembers addMembers,
+      SystemGroupBackend systemGroupBackend,
       @GerritServerConfig Config cfg,
       @Assisted String name) {
     this.self = self;
@@ -96,6 +96,7 @@
     this.json = json;
     this.groupCreationValidationListeners = groupCreationValidationListeners;
     this.addMembers = addMembers;
+    this.systemGroupBackend = systemGroupBackend;
     this.defaultVisibleToAll = cfg.getBoolean("groups", "newGroupsVisibleToAll", false);
     this.name = name;
   }
@@ -113,7 +114,7 @@
   @Override
   public GroupInfo apply(TopLevelResource resource, GroupInput input)
       throws AuthException, BadRequestException, UnprocessableEntityException,
-      ResourceConflictException, OrmException, IOException {
+          ResourceConflictException, OrmException, IOException {
     if (input == null) {
       input = new GroupInput();
     }
@@ -125,24 +126,24 @@
     CreateGroupArgs args = new CreateGroupArgs();
     args.setGroupName(name);
     args.groupDescription = Strings.emptyToNull(input.description);
-    args.visibleToAll = MoreObjects.firstNonNull(input.visibleToAll,
-        defaultVisibleToAll);
+    args.visibleToAll = MoreObjects.firstNonNull(input.visibleToAll, defaultVisibleToAll);
     args.ownerGroupId = ownerId;
     if (input.members != null && !input.members.isEmpty()) {
       List<Account.Id> members = new ArrayList<>();
       for (String nameOrEmailOrId : input.members) {
         Account a = addMembers.findAccount(nameOrEmailOrId);
         if (!a.isActive()) {
-          throw new UnprocessableEntityException(String.format(
-              "Account Inactive: %s", nameOrEmailOrId));
+          throw new UnprocessableEntityException(
+              String.format("Account Inactive: %s", nameOrEmailOrId));
         }
         members.add(a.getId());
       }
       args.initialMembers = members;
     } else {
-      args.initialMembers = ownerId == null
-          ? Collections.singleton(self.get().getAccountId())
-          : Collections.<Account.Id> emptySet();
+      args.initialMembers =
+          ownerId == null
+              ? Collections.singleton(self.get().getAccountId())
+              : Collections.<Account.Id>emptySet();
     }
 
     for (GroupCreationValidationListener l : groupCreationValidationListeners) {
@@ -156,8 +157,7 @@
     return json.format(GroupDescriptions.forAccountGroup(createGroup(args)));
   }
 
-  private AccountGroup.Id owner(GroupInput input)
-      throws UnprocessableEntityException {
+  private AccountGroup.Id owner(GroupInput input) throws UnprocessableEntityException {
     if (input.ownerId != null) {
       GroupDescription.Basic d = groups.parseInternal(Url.decode(input.ownerId));
       return GroupDescriptions.toAccountGroup(d).getId();
@@ -169,12 +169,17 @@
       throws OrmException, ResourceConflictException, IOException {
 
     // Do not allow creating groups with the same name as system groups
-    List<String> sysGroupNames = SystemGroupBackend.getNames();
-    for (String name : sysGroupNames) {
-      if (name.toLowerCase(Locale.US).equals(
-          createGroupArgs.getGroupName().toLowerCase(Locale.US))) {
-        throw new ResourceConflictException("group '" + name
-            + "' already exists");
+    for (String name : systemGroupBackend.getNames()) {
+      if (name.toLowerCase(Locale.US)
+          .equals(createGroupArgs.getGroupName().toLowerCase(Locale.US))) {
+        throw new ResourceConflictException("group '" + name + "' already exists");
+      }
+    }
+
+    for (String name : systemGroupBackend.getReservedNames()) {
+      if (name.toLowerCase(Locale.US)
+          .equals(createGroupArgs.getGroupName().toLowerCase(Locale.US))) {
+        throw new ResourceConflictException("group name '" + name + "' is reserved");
       }
     }
 
@@ -182,10 +187,8 @@
     AccountGroup.UUID uuid =
         GroupUUID.make(
             createGroupArgs.getGroupName(),
-            self.get().newCommitterIdent(serverIdent.getWhen(),
-                serverIdent.getTimeZone()));
-    AccountGroup group =
-        new AccountGroup(createGroupArgs.getGroup(), groupId, uuid);
+            self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone()));
+    AccountGroup group = new AccountGroup(createGroupArgs.getGroup(), groupId, uuid);
     group.setVisibleToAll(createGroupArgs.visibleToAll);
     if (createGroupArgs.ownerGroupId != null) {
       AccountGroup ownerGroup = groupCache.get(createGroupArgs.ownerGroupId);
@@ -202,8 +205,8 @@
     try {
       db.accountGroupNames().insert(Collections.singleton(gn));
     } catch (OrmDuplicateKeyException e) {
-      throw new ResourceConflictException("group '"
-          + createGroupArgs.getGroupName() + "' already exists");
+      throw new ResourceConflictException(
+          "group '" + createGroupArgs.getGroupName() + "' already exists");
     }
     db.accountGroups().insert(Collections.singleton(group));
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
index 30b856a..930d572 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
@@ -30,18 +30,15 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
-
-import org.slf4j.Logger;
-
 import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.LinkedList;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class DbGroupMemberAuditListener implements GroupMemberAuditListener {
-  private static final Logger log = org.slf4j.LoggerFactory
-      .getLogger(DbGroupMemberAuditListener.class);
+  private static final Logger log = LoggerFactory.getLogger(DbGroupMemberAuditListener.class);
 
   private final SchemaFactory<ReviewDb> schema;
   private final AccountCache accountCache;
@@ -49,8 +46,10 @@
   private final UniversalGroupBackend groupBackend;
 
   @Inject
-  DbGroupMemberAuditListener(SchemaFactory<ReviewDb> schema,
-      AccountCache accountCache, GroupCache groupCache,
+  DbGroupMemberAuditListener(
+      SchemaFactory<ReviewDb> schema,
+      AccountCache accountCache,
+      GroupCache groupCache,
       UniversalGroupBackend groupBackend) {
     this.schema = schema;
     this.accountCache = accountCache;
@@ -59,33 +58,29 @@
   }
 
   @Override
-  public void onAddAccountsToGroup(Account.Id me,
-      Collection<AccountGroupMember> added) {
-    List<AccountGroupMemberAudit> auditInserts = new LinkedList<>();
+  public void onAddAccountsToGroup(Account.Id me, Collection<AccountGroupMember> added) {
+    List<AccountGroupMemberAudit> auditInserts = new ArrayList<>();
     for (AccountGroupMember m : added) {
-      AccountGroupMemberAudit audit =
-          new AccountGroupMemberAudit(m, me, TimeUtil.nowTs());
+      AccountGroupMemberAudit audit = new AccountGroupMemberAudit(m, me, TimeUtil.nowTs());
       auditInserts.add(audit);
     }
     try (ReviewDb db = schema.open()) {
       db.accountGroupMembersAudit().insert(auditInserts);
     } catch (OrmException e) {
       logOrmExceptionForAccounts(
-          "Cannot log add accounts to group event performed by user", me,
-          added, e);
+          "Cannot log add accounts to group event performed by user", me, added, e);
     }
   }
 
   @Override
-  public void onDeleteAccountsFromGroup(Account.Id me,
-      Collection<AccountGroupMember> removed) {
-    List<AccountGroupMemberAudit> auditInserts = new LinkedList<>();
-    List<AccountGroupMemberAudit> auditUpdates = new LinkedList<>();
+  public void onDeleteAccountsFromGroup(Account.Id me, Collection<AccountGroupMember> removed) {
+    List<AccountGroupMemberAudit> auditInserts = new ArrayList<>();
+    List<AccountGroupMemberAudit> auditUpdates = new ArrayList<>();
     try (ReviewDb db = schema.open()) {
       for (AccountGroupMember m : removed) {
         AccountGroupMemberAudit audit = null;
-        for (AccountGroupMemberAudit a : db.accountGroupMembersAudit()
-            .byGroupAccount(m.getAccountGroupId(), m.getAccountId())) {
+        for (AccountGroupMemberAudit a :
+            db.accountGroupMembersAudit().byGroupAccount(m.getAccountGroupId(), m.getAccountId())) {
           if (a.isActive()) {
             audit = a;
             break;
@@ -105,38 +100,33 @@
       db.accountGroupMembersAudit().insert(auditInserts);
     } catch (OrmException e) {
       logOrmExceptionForAccounts(
-          "Cannot log delete accounts from group event performed by user", me,
-          removed, e);
+          "Cannot log delete accounts from group event performed by user", me, removed, e);
     }
   }
 
   @Override
-  public void onAddGroupsToGroup(Account.Id me,
-      Collection<AccountGroupById> added) {
+  public void onAddGroupsToGroup(Account.Id me, Collection<AccountGroupById> added) {
     List<AccountGroupByIdAud> includesAudit = new ArrayList<>();
     for (AccountGroupById groupInclude : added) {
-      AccountGroupByIdAud audit =
-          new AccountGroupByIdAud(groupInclude, me, TimeUtil.nowTs());
+      AccountGroupByIdAud audit = new AccountGroupByIdAud(groupInclude, me, TimeUtil.nowTs());
       includesAudit.add(audit);
     }
     try (ReviewDb db = schema.open()) {
       db.accountGroupByIdAud().insert(includesAudit);
     } catch (OrmException e) {
       logOrmExceptionForGroups(
-          "Cannot log add groups to group event performed by user", me, added,
-          e);
+          "Cannot log add groups to group event performed by user", me, added, e);
     }
   }
 
   @Override
-  public void onDeleteGroupsFromGroup(Account.Id me,
-      Collection<AccountGroupById> removed) {
-    final List<AccountGroupByIdAud> auditUpdates = new LinkedList<>();
+  public void onDeleteGroupsFromGroup(Account.Id me, Collection<AccountGroupById> removed) {
+    final List<AccountGroupByIdAud> auditUpdates = new ArrayList<>();
     try (ReviewDb db = schema.open()) {
       for (final AccountGroupById g : removed) {
         AccountGroupByIdAud audit = null;
-        for (AccountGroupByIdAud a : db.accountGroupByIdAud()
-            .byGroupInclude(g.getGroupId(), g.getIncludeUUID())) {
+        for (AccountGroupByIdAud a :
+            db.accountGroupByIdAud().byGroupInclude(g.getGroupId(), g.getIncludeUUID())) {
           if (a.isActive()) {
             audit = a;
             break;
@@ -151,13 +141,12 @@
       db.accountGroupByIdAud().update(auditUpdates);
     } catch (OrmException e) {
       logOrmExceptionForGroups(
-          "Cannot log delete groups from group event performed by user", me,
-          removed, e);
+          "Cannot log delete groups from group event performed by user", me, removed, e);
     }
   }
 
-  private void logOrmExceptionForAccounts(String header, Account.Id me,
-      Collection<AccountGroupMember> values, OrmException e) {
+  private void logOrmExceptionForAccounts(
+      String header, Account.Id me, Collection<AccountGroupMember> values, OrmException e) {
     List<String> descriptions = new ArrayList<>();
     for (AccountGroupMember m : values) {
       Account.Id accountId = m.getAccountId();
@@ -165,14 +154,15 @@
       AccountGroup.Id groupId = m.getAccountGroupId();
       String groupName = groupCache.get(groupId).getName();
 
-      descriptions.add(MessageFormat.format("account {0}/{1}, group {2}/{3}",
-          accountId, userName, groupId, groupName));
+      descriptions.add(
+          MessageFormat.format(
+              "account {0}/{1}, group {2}/{3}", accountId, userName, groupId, groupName));
     }
     logOrmException(header, me, descriptions, e);
   }
 
-  private void logOrmExceptionForGroups(String header, Account.Id me,
-      Collection<AccountGroupById> values, OrmException e) {
+  private void logOrmExceptionForGroups(
+      String header, Account.Id me, Collection<AccountGroupById> values, OrmException e) {
     List<String> descriptions = new ArrayList<>();
     for (AccountGroupById m : values) {
       AccountGroup.UUID groupUuid = m.getIncludeUUID();
@@ -180,14 +170,15 @@
       AccountGroup.Id targetGroupId = m.getGroupId();
       String targetGroupName = groupCache.get(targetGroupId).getName();
 
-      descriptions.add(MessageFormat.format("group {0}/{1}, group {2}/{3}",
-          groupUuid, groupName, targetGroupId, targetGroupName));
+      descriptions.add(
+          MessageFormat.format(
+              "group {0}/{1}, group {2}/{3}",
+              groupUuid, groupName, targetGroupId, targetGroupName));
     }
     logOrmException(header, me, descriptions, e);
   }
 
-  private void logOrmException(String header, Account.Id me,
-      Iterable<?> values, OrmException e) {
+  private void logOrmException(String header, Account.Id me, Iterable<?> values, OrmException e) {
     StringBuilder message = new StringBuilder(header);
     message.append(" ");
     message.append(me);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java
index da683a3..9f612bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java
@@ -34,9 +34,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
@@ -49,9 +48,12 @@
   private final AuditService auditService;
 
   @Inject
-  DeleteIncludedGroups(GroupsCollection groupsCollection,
-      GroupIncludeCache groupIncludeCache, Provider<ReviewDb> db,
-      Provider<CurrentUser> self, AuditService auditService) {
+  DeleteIncludedGroups(
+      GroupsCollection groupsCollection,
+      GroupIncludeCache groupIncludeCache,
+      Provider<ReviewDb> db,
+      Provider<CurrentUser> self,
+      AuditService auditService) {
     this.groupsCollection = groupsCollection;
     this.groupIncludeCache = groupIncludeCache;
     this.db = db;
@@ -61,8 +63,7 @@
 
   @Override
   public Response<?> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException,
-      UnprocessableEntityException, OrmException {
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException {
     AccountGroup internalGroup = resource.toAccountGroup();
     if (internalGroup == null) {
       throw new MethodNotAllowedException();
@@ -70,14 +71,14 @@
     input = Input.init(input);
 
     final GroupControl control = resource.getControl();
-    final Map<AccountGroup.UUID, AccountGroupById> includedGroups = getIncludedGroups(internalGroup.getId());
-    final List<AccountGroupById> toRemove = new LinkedList<>();
+    final Map<AccountGroup.UUID, AccountGroupById> includedGroups =
+        getIncludedGroups(internalGroup.getId());
+    final List<AccountGroupById> toRemove = new ArrayList<>();
 
     for (final String includedGroup : input.groups) {
       GroupDescription.Basic d = groupsCollection.parse(includedGroup);
       if (!control.canRemoveGroup()) {
-        throw new AuthException(String.format("Cannot delete group: %s",
-            d.getName()));
+        throw new AuthException(String.format("Cannot delete group: %s", d.getName()));
       }
 
       AccountGroupById g = includedGroups.remove(d.getGroupUUID());
@@ -98,8 +99,8 @@
     return Response.none();
   }
 
-  private Map<AccountGroup.UUID, AccountGroupById> getIncludedGroups(
-      final AccountGroup.Id groupId) throws OrmException {
+  private Map<AccountGroup.UUID, AccountGroupById> getIncludedGroups(final AccountGroup.Id groupId)
+      throws OrmException {
     final Map<AccountGroup.UUID, AccountGroupById> groups = new HashMap<>();
     for (AccountGroupById g : db.get().accountGroupById().byGroup(groupId)) {
       groups.put(g.getIncludeUUID(), g);
@@ -113,10 +114,9 @@
   }
 
   @Singleton
-  static class DeleteIncludedGroup implements
-      RestModifyView<IncludedGroupResource, DeleteIncludedGroup.Input> {
-    static class Input {
-    }
+  static class DeleteIncludedGroup
+      implements RestModifyView<IncludedGroupResource, DeleteIncludedGroup.Input> {
+    static class Input {}
 
     private final Provider<DeleteIncludedGroups> delete;
 
@@ -127,8 +127,8 @@
 
     @Override
     public Response<?> apply(IncludedGroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException,
-        UnprocessableEntityException, OrmException {
+        throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
+            OrmException {
       AddIncludedGroups.Input in = new AddIncludedGroups.Input();
       in.groups = ImmutableList.of(resource.getMember().get());
       return delete.get().apply(resource, in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
index e1a6921..e365ce3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
@@ -33,10 +33,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
@@ -49,8 +48,10 @@
   private final AuditService auditService;
 
   @Inject
-  DeleteMembers(AccountsCollection accounts,
-      AccountCache accountCache, Provider<ReviewDb> db,
+  DeleteMembers(
+      AccountsCollection accounts,
+      AccountCache accountCache,
+      Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       AuditService auditService) {
     this.accounts = accounts;
@@ -62,8 +63,8 @@
 
   @Override
   public Response<?> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException,
-      UnprocessableEntityException, OrmException, IOException {
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+          IOException {
     AccountGroup internalGroup = resource.toAccountGroup();
     if (internalGroup == null) {
       throw new MethodNotAllowedException();
@@ -72,7 +73,7 @@
 
     final GroupControl control = resource.getControl();
     final Map<Account.Id, AccountGroupMember> members = getMembers(internalGroup.getId());
-    final List<AccountGroupMember> toRemove = new LinkedList<>();
+    final List<AccountGroupMember> toRemove = new ArrayList<>();
 
     for (final String nameOrEmail : input.members) {
       Account a = accounts.parse(nameOrEmail).getAccount();
@@ -101,11 +102,10 @@
     auditService.dispatchDeleteAccountsFromGroup(me, toRemove);
   }
 
-  private Map<Account.Id, AccountGroupMember> getMembers(
-      final AccountGroup.Id groupId) throws OrmException {
+  private Map<Account.Id, AccountGroupMember> getMembers(final AccountGroup.Id groupId)
+      throws OrmException {
     final Map<Account.Id, AccountGroupMember> members = new HashMap<>();
-    for (final AccountGroupMember m : db.get().accountGroupMembers()
-        .byGroup(groupId)) {
+    for (final AccountGroupMember m : db.get().accountGroupMembers().byGroup(groupId)) {
       members.put(m.getAccountId(), m);
     }
     return members;
@@ -113,8 +113,7 @@
 
   @Singleton
   static class DeleteMember implements RestModifyView<MemberResource, DeleteMember.Input> {
-    static class Input {
-    }
+    static class Input {}
 
     private final Provider<DeleteMembers> delete;
 
@@ -125,8 +124,8 @@
 
     @Override
     public Response<?> apply(MemberResource resource, Input input)
-        throws AuthException, MethodNotAllowedException,
-        UnprocessableEntityException, OrmException, IOException {
+        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+            IOException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = resource.getMember().getAccountId().toString();
       return delete.get().apply(resource, in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
index f9ae694..09aad6c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
@@ -35,7 +35,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -50,7 +49,8 @@
   private final GroupBackend groupBackend;
 
   @Inject
-  public GetAuditLog(Provider<ReviewDb> db,
+  public GetAuditLog(
+      Provider<ReviewDb> db,
       AccountLoader.Factory accountLoaderFactory,
       GroupCache groupCache,
       GroupJson groupJson,
@@ -64,16 +64,14 @@
 
   @Override
   public List<? extends GroupAuditEventInfo> apply(GroupResource rsrc)
-      throws AuthException, ResourceNotFoundException,
-      MethodNotAllowedException, OrmException {
+      throws AuthException, ResourceNotFoundException, MethodNotAllowedException, OrmException {
     if (rsrc.toAccountGroup() == null) {
       throw new MethodNotAllowedException();
     } else if (!rsrc.getControl().isOwner()) {
       throw new AuthException("Not group owner");
     }
 
-    AccountGroup group = db.get().accountGroups().get(
-        rsrc.toAccountGroup().getId());
+    AccountGroup group = db.get().accountGroups().get(rsrc.toAccountGroup().getId());
     if (group == null) {
       throw new ResourceNotFoundException();
     }
@@ -83,17 +81,19 @@
     List<GroupAuditEventInfo> auditEvents = new ArrayList<>();
 
     for (AccountGroupMemberAudit auditEvent :
-      db.get().accountGroupMembersAudit().byGroup(group.getId()).toList()) {
+        db.get().accountGroupMembersAudit().byGroup(group.getId()).toList()) {
       AccountInfo member = accountLoader.get(auditEvent.getKey().getParentKey());
 
-      auditEvents.add(GroupAuditEventInfo.createAddUserEvent(
-          accountLoader.get(auditEvent.getAddedBy()),
-          auditEvent.getKey().getAddedOn(), member));
+      auditEvents.add(
+          GroupAuditEventInfo.createAddUserEvent(
+              accountLoader.get(auditEvent.getAddedBy()),
+              auditEvent.getKey().getAddedOn(),
+              member));
 
       if (!auditEvent.isActive()) {
-        auditEvents.add(GroupAuditEventInfo.createRemoveUserEvent(
-            accountLoader.get(auditEvent.getRemovedBy()),
-            auditEvent.getRemovedOn(), member));
+        auditEvents.add(
+            GroupAuditEventInfo.createRemoveUserEvent(
+                accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
       }
     }
 
@@ -105,33 +105,38 @@
       if (includedGroup != null) {
         member = groupJson.format(GroupDescriptions.forAccountGroup(includedGroup));
       } else {
-        GroupDescription.Basic groupDescription =
-            groupBackend.get(includedGroupUUID);
+        GroupDescription.Basic groupDescription = groupBackend.get(includedGroupUUID);
         member = new GroupInfo();
         member.id = Url.encode(includedGroupUUID.get());
-        member.name = groupDescription.getName();
+        if (groupDescription != null) {
+          member.name = groupDescription.getName();
+        }
       }
 
-      auditEvents.add(GroupAuditEventInfo.createAddGroupEvent(
-          accountLoader.get(auditEvent.getAddedBy()),
-          auditEvent.getKey().getAddedOn(), member));
+      auditEvents.add(
+          GroupAuditEventInfo.createAddGroupEvent(
+              accountLoader.get(auditEvent.getAddedBy()),
+              auditEvent.getKey().getAddedOn(),
+              member));
 
       if (!auditEvent.isActive()) {
-        auditEvents.add(GroupAuditEventInfo.createRemoveGroupEvent(
-            accountLoader.get(auditEvent.getRemovedBy()),
-            auditEvent.getRemovedOn(), member));
+        auditEvents.add(
+            GroupAuditEventInfo.createRemoveGroupEvent(
+                accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
       }
     }
 
     accountLoader.fill();
 
     // sort by date in reverse order so that the newest audit event comes first
-    Collections.sort(auditEvents, new Comparator<GroupAuditEventInfo>() {
-      @Override
-      public int compare(GroupAuditEventInfo e1, GroupAuditEventInfo e2) {
-        return e2.date.compareTo(e1.date);
-      }
-    });
+    Collections.sort(
+        auditEvents,
+        new Comparator<GroupAuditEventInfo>() {
+          @Override
+          public int compare(GroupAuditEventInfo e1, GroupAuditEventInfo e2) {
+            return e2.date.compareTo(e1.date);
+          }
+        });
 
     return auditEvents;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDetail.java
index 615c862..47fe319 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDetail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDetail.java
@@ -27,8 +27,7 @@
 
   @Inject
   GetDetail(GroupJson json) {
-    this.json = json.addOption(ListGroupsOption.MEMBERS)
-        .addOption(ListGroupsOption.INCLUDES);
+    this.json = json.addOption(ListGroupsOption.MEMBERS).addOption(ListGroupsOption.INCLUDES);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetIncludedGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetIncludedGroup.java
index dbc2e0c..4cf0cb2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetIncludedGroup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetIncludedGroup.java
@@ -21,7 +21,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class GetIncludedGroup implements RestReadView<IncludedGroupResource>  {
+public class GetIncludedGroup implements RestReadView<IncludedGroupResource> {
   private final GroupJson json;
 
   @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfoCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfoCache.java
deleted file mode 100644
index d660db0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupInfoCache.java
+++ /dev/null
@@ -1,55 +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.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.inject.Inject;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/** Efficiently builds a {@link GroupInfoCache}. */
-public class GroupInfoCache {
-  public interface Factory {
-    GroupInfoCache create();
-  }
-
-  private final GroupBackend groupBackend;
-  private final Map<AccountGroup.UUID, GroupDescription.Basic> out;
-
-  @Inject
-  GroupInfoCache(GroupBackend groupBackend) {
-    this.groupBackend = groupBackend;
-    this.out = new HashMap<>();
-  }
-
-  /**
-   * Indicate a group will be needed later on.
-   *
-   * @param uuid identity that will be needed in the future; may be null.
-   */
-  public void want(final AccountGroup.UUID uuid) {
-    if (uuid != null && !out.containsKey(uuid)) {
-      out.put(uuid, groupBackend.get(uuid));
-    }
-  }
-
-  public GroupDescription.Basic get(final AccountGroup.UUID uuid) {
-    want(uuid);
-    return out.get(uuid);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java
index 8f339de..43e70ff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java
@@ -31,7 +31,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.util.Collection;
 import java.util.EnumSet;
 
@@ -52,7 +51,8 @@
   private EnumSet<ListGroupsOption> options;
 
   @Inject
-  GroupJson(GroupBackend groupBackend,
+  GroupJson(
+      GroupBackend groupBackend,
       GroupControl.Factory groupControlFactory,
       Provider<ListMembers> listMembers,
       Provider<ListIncludedGroups> listIncludes) {
@@ -83,8 +83,7 @@
   public GroupInfo format(GroupDescription.Basic group) throws OrmException {
     GroupInfo info = init(group);
     if (options.contains(MEMBERS) || options.contains(INCLUDES)) {
-      GroupResource rsrc =
-          new GroupResource(groupControlFactory.controlFor(group));
+      GroupResource rsrc = new GroupResource(groupControlFactory.controlFor(group));
       initMembersAndIncludes(rsrc, info);
     }
     return info;
@@ -113,8 +112,7 @@
     return info;
   }
 
-  private GroupInfo initMembersAndIncludes(GroupResource rsrc, GroupInfo info)
-      throws OrmException {
+  private GroupInfo initMembersAndIncludes(GroupResource rsrc, GroupInfo info) throws OrmException {
     if (rsrc.toAccountGroup() == null) {
       return info;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
index 6268d72..397bf08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.group;
 
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
@@ -21,7 +22,9 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NeedsParams;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -35,28 +38,33 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 
-@Singleton
-public class GroupsCollection implements
-    RestCollection<TopLevelResource, GroupResource>,
-    AcceptsCreate<TopLevelResource> {
+public class GroupsCollection
+    implements RestCollection<TopLevelResource, GroupResource>,
+        AcceptsCreate<TopLevelResource>,
+        NeedsParams {
   private final DynamicMap<RestView<GroupResource>> views;
   private final Provider<ListGroups> list;
+  private final Provider<QueryGroups> queryGroups;
   private final CreateGroup.Factory createGroup;
   private final GroupControl.Factory groupControlFactory;
   private final GroupBackend groupBackend;
   private final Provider<CurrentUser> self;
 
+  private boolean hasQuery2;
+
   @Inject
-  GroupsCollection(final DynamicMap<RestView<GroupResource>> views,
-      final Provider<ListGroups> list,
-      final CreateGroup.Factory createGroup,
-      final GroupControl.Factory groupControlFactory,
-      final GroupBackend groupBackend,
-      final Provider<CurrentUser> self) {
+  GroupsCollection(
+      DynamicMap<RestView<GroupResource>> views,
+      Provider<ListGroups> list,
+      Provider<QueryGroups> queryGroups,
+      CreateGroup.Factory createGroup,
+      GroupControl.Factory groupControlFactory,
+      GroupBackend groupBackend,
+      Provider<CurrentUser> self) {
     this.views = views;
     this.list = list;
+    this.queryGroups = queryGroups;
     this.createGroup = createGroup;
     this.groupControlFactory = groupControlFactory;
     this.groupBackend = groupBackend;
@@ -64,8 +72,17 @@
   }
 
   @Override
-  public RestView<TopLevelResource> list() throws ResourceNotFoundException,
-      AuthException {
+  public void setParams(ListMultimap<String, String> params) throws BadRequestException {
+    if (params.containsKey("query") && params.containsKey("query2")) {
+      throw new BadRequestException("\"query\" and \"query2\" options are mutually exclusive");
+    }
+
+    // The --query2 option is defined in QueryGroups
+    this.hasQuery2 = params.containsKey("query2");
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() throws ResourceNotFoundException, AuthException {
     final CurrentUser user = self.get();
     if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
@@ -73,6 +90,10 @@
       throw new ResourceNotFoundException();
     }
 
+    if (hasQuery2) {
+      return queryGroups.get();
+    }
+
     return list.get();
   }
 
@@ -100,49 +121,40 @@
   /**
    * Parses a group ID from a request body and returns the group.
    *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy
-   *        group ID
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
    * @return the group
-   * @throws UnprocessableEntityException thrown if the group ID cannot be
-   *         resolved or if the group is not visible to the calling user
+   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved or if the group
+   *     is not visible to the calling user
    */
-  public GroupDescription.Basic parse(String id)
-      throws UnprocessableEntityException {
+  public GroupDescription.Basic parse(String id) throws UnprocessableEntityException {
     GroupDescription.Basic group = parseId(id);
     if (group == null || !groupControlFactory.controlFor(group).isVisible()) {
-      throw new UnprocessableEntityException(String.format(
-          "Group Not Found: %s", id));
+      throw new UnprocessableEntityException(String.format("Group Not Found: %s", id));
     }
     return group;
   }
 
   /**
-   * Parses a group ID from a request body and returns the group if it is a
-   * Gerrit internal group.
+   * Parses a group ID from a request body and returns the group if it is a Gerrit internal group.
    *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy
-   *        group ID
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
    * @return the group
-   * @throws UnprocessableEntityException thrown if the group ID cannot be
-   *         resolved, if the group is not visible to the calling user or if
-   *         it's an external group
+   * @throws UnprocessableEntityException thrown if the group ID cannot be resolved, if the group is
+   *     not visible to the calling user or if it's an external group
    */
-  public GroupDescription.Basic parseInternal(String id)
-      throws UnprocessableEntityException {
+  public GroupDescription.Basic parseInternal(String id) throws UnprocessableEntityException {
     GroupDescription.Basic group = parse(id);
     if (GroupDescriptions.toAccountGroup(group) == null) {
-      throw new UnprocessableEntityException(String.format(
-          "External Group Not Allowed: %s", id));
+      throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
     }
     return group;
   }
 
   /**
-   * Parses a group ID and returns the group without making any permission
-   * check whether the current user can see the group.
+   * Parses a group ID and returns the group without making any permission check whether the current
+   * user can see the group.
    *
-   * @param id ID of the group, can be a group UUID, a group name or a legacy
-   *        group ID
+   * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
    * @return the group, null if no group is found for the given group ID
    */
   public GroupDescription.Basic parseId(String id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupResource.java
index 7975f24..467de4c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupResource.java
@@ -25,8 +25,7 @@
 
   private final GroupDescription.Basic member;
 
-  public IncludedGroupResource(GroupResource group,
-      GroupDescription.Basic member) {
+  public IncludedGroupResource(GroupResource group, GroupDescription.Basic member) {
     super(group);
     this.member = member;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupsCollection.java
index 8d0831d..865f8b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupsCollection.java
@@ -34,9 +34,8 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class IncludedGroupsCollection implements
-    ChildCollection<GroupResource, IncludedGroupResource>,
-    AcceptsCreate<GroupResource> {
+public class IncludedGroupsCollection
+    implements ChildCollection<GroupResource, IncludedGroupResource>, AcceptsCreate<GroupResource> {
   private final DynamicMap<RestView<IncludedGroupResource>> views;
   private final ListIncludedGroups list;
   private final GroupsCollection groupsCollection;
@@ -44,7 +43,8 @@
   private final AddIncludedGroups put;
 
   @Inject
-  IncludedGroupsCollection(DynamicMap<RestView<IncludedGroupResource>> views,
+  IncludedGroupsCollection(
+      DynamicMap<RestView<IncludedGroupResource>> views,
       ListIncludedGroups list,
       GroupsCollection groupsCollection,
       Provider<ReviewDb> dbProvider,
@@ -63,8 +63,7 @@
 
   @Override
   public IncludedGroupResource parse(GroupResource resource, IdString id)
-      throws MethodNotAllowedException, AuthException,
-      ResourceNotFoundException, OrmException {
+      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException {
     AccountGroup parent = resource.toAccountGroup();
     if (parent == null) {
       throw new MethodNotAllowedException();
@@ -72,19 +71,18 @@
 
     GroupDescription.Basic member =
         groupsCollection.parse(TopLevelResource.INSTANCE, id).getGroup();
-    if (isMember(parent, member)
-        && resource.getControl().canSeeGroup()) {
+    if (isMember(parent, member) && resource.getControl().canSeeGroup()) {
       return new IncludedGroupResource(resource, member);
     }
     throw new ResourceNotFoundException(id);
   }
 
-  private boolean isMember(AccountGroup parent, GroupDescription.Basic member)
-      throws OrmException {
-    return dbProvider.get().accountGroupById().get(
-        new AccountGroupById.Key(
-            parent.getId(),
-            member.getGroupUUID())) != null;
+  private boolean isMember(AccountGroup parent, GroupDescription.Basic member) throws OrmException {
+    return dbProvider
+            .get()
+            .accountGroupById()
+            .get(new AccountGroupById.Key(parent.getId(), member.getGroupUUID()))
+        != null;
   }
 
   @SuppressWarnings("unchecked")
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
new file mode 100644
index 0000000..b7b98b2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.common.data.GroupDescriptions;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.Index.Input;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class Index implements RestModifyView<GroupResource, Input> {
+  public static class Input {}
+
+  private final GroupCache groupCache;
+
+  @Inject
+  Index(GroupCache groupCache) {
+    this.groupCache = groupCache;
+  }
+
+  @Override
+  public Response<?> apply(GroupResource rsrc, Input input)
+      throws IOException, AuthException, UnprocessableEntityException {
+    if (!rsrc.getControl().isOwner()) {
+      throw new AuthException("not allowed to index group");
+    }
+
+    AccountGroup group = GroupDescriptions.toAccountGroup(rsrc.getGroup());
+    if (group == null) {
+      throw new UnprocessableEntityException(
+          String.format("External Group Not Allowed: %s", rsrc.getGroupUUID().get()));
+    }
+
+    // evicting the group from the cache, reindexes the group
+    groupCache.evict(group);
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
index d5081b8..7b55a80 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
@@ -41,10 +41,8 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
@@ -55,6 +53,7 @@
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import org.kohsuke.args4j.Option;
 
 /** List groups visible to the calling user. */
 public class ListGroups implements RestReadView<TopLevelResource> {
@@ -71,8 +70,7 @@
   private final GroupJson json;
   private final GroupBackend groupBackend;
 
-  private EnumSet<ListGroupsOption> options =
-      EnumSet.noneOf(ListGroupsOption.class);
+  private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
   private boolean visibleToAll;
   private Account.Id user;
   private boolean owned;
@@ -81,54 +79,91 @@
   private String matchSubstring;
   private String suggest;
 
-  @Option(name = "--project", aliases = {"-p"},
+  @Option(
+      name = "--project",
+      aliases = {"-p"},
       usage = "projects for which the groups should be listed")
   public void addProject(ProjectControl project) {
     projects.add(project);
   }
 
-  @Option(name = "--visible-to-all",
+  @Option(
+      name = "--visible-to-all",
       usage = "to list only groups that are visible to all registered users")
   public void setVisibleToAll(boolean visibleToAll) {
     this.visibleToAll = visibleToAll;
   }
 
-  @Option(name = "--user", aliases = {"-u"},
+  @Option(
+      name = "--user",
+      aliases = {"-u"},
       usage = "user for which the groups should be listed")
   public void setUser(Account.Id user) {
     this.user = user;
   }
 
-  @Option(name = "--owned", usage = "to list only groups that are owned by the"
-      + " specified user or by the calling user if no user was specifed")
+  @Option(
+      name = "--owned",
+      usage =
+          "to list only groups that are owned by the"
+              + " specified user or by the calling user if no user was specifed")
   public void setOwned(boolean owned) {
     this.owned = owned;
   }
 
-  @Option(name = "-q", usage = "group to inspect")
-  public void addGroup(AccountGroup.UUID id) {
-    groupsToInspect.add(id);
+  /**
+   * Add a group to inspect.
+   *
+   * @param uuid UUID of the group
+   * @deprecated use {@link #addGroup(AccountGroup.UUID)}.
+   */
+  @Deprecated
+  @Option(
+      name = "--query",
+      aliases = {"-q"},
+      usage = "group to inspect (deprecated: use --group/-g instead)")
+  void addGroup_Deprecated(AccountGroup.UUID uuid) {
+    addGroup(uuid);
   }
 
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT",
+  @Option(
+      name = "--group",
+      aliases = {"-g"},
+      usage = "group to inspect")
+  public void addGroup(AccountGroup.UUID uuid) {
+    groupsToInspect.add(uuid);
+  }
+
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
       usage = "maximum number of groups to list")
   public void setLimit(int limit) {
     this.limit = limit;
   }
 
-  @Option(name = "--start", aliases = {"-S"}, metaVar = "CNT",
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
       usage = "number of groups to skip")
   public void setStart(int start) {
     this.start = start;
   }
 
-  @Option(name = "--match", aliases = {"-m"}, metaVar = "MATCH",
+  @Option(
+      name = "--match",
+      aliases = {"-m"},
+      metaVar = "MATCH",
       usage = "match group substring")
   public void setMatchSubstring(String matchSubstring) {
     this.matchSubstring = matchSubstring;
   }
 
-  @Option(name = "--suggest", aliases = {"-s"},
+  @Option(
+      name = "--suggest",
+      aliases = {"-s"},
       usage = "to get a suggestion of groups")
   public void setSuggest(String suggest) {
     this.suggest = suggest;
@@ -145,7 +180,8 @@
   }
 
   @Inject
-  protected ListGroups(final GroupCache groupCache,
+  protected ListGroups(
+      final GroupCache groupCache,
       final GroupControl.Factory groupControlFactory,
       final GroupControl.GenericFactory genericGroupControlFactory,
       final Provider<IdentifiedUser> identifiedUser,
@@ -180,9 +216,7 @@
       throws OrmException, BadRequestException {
     SortedMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
-      output.put(MoreObjects.firstNonNull(
-          info.name,
-          "Group " + Url.decode(info.id)), info);
+      output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
       info.name = null;
     }
     return output;
@@ -194,13 +228,11 @@
     }
 
     if (owned) {
-      return getGroupsOwnedBy(
-          user != null ? userFactory.create(user) : identifiedUser.get());
+      return getGroupsOwnedBy(user != null ? userFactory.create(user) : identifiedUser.get());
     }
 
     if (user != null) {
-      return accountGetGroups.apply(
-          new AccountResource(userFactory.create(user)));
+      return accountGetGroups.apply(new AccountResource(userFactory.create(user)));
     }
 
     return getAllGroups();
@@ -234,8 +266,7 @@
       if (limit > 0 && ++found > limit) {
         break;
       }
-      groupInfos.add(json.addOptions(options).format(
-          GroupDescriptions.forAccountGroup(group)));
+      groupInfos.add(json.addOptions(options).format(GroupDescriptions.forAccountGroup(group)));
     }
     return groupInfos;
   }
@@ -246,10 +277,11 @@
           "You should only have no more than one --project and -n with --suggest");
     }
 
-    List<GroupReference> groupRefs = Lists.newArrayList(Iterables.limit(
-        groupBackend.suggest(
-            suggest, Iterables.getFirst(projects, null)),
-        limit <= 0 ? 10 : Math.min(limit, 10)));
+    List<GroupReference> groupRefs =
+        Lists.newArrayList(
+            Iterables.limit(
+                groupBackend.suggest(suggest, Iterables.getFirst(projects, null)),
+                limit <= 0 ? 10 : Math.min(limit, 10)));
 
     List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groupRefs.size());
     for (final GroupReference ref : groupRefs) {
@@ -289,16 +321,14 @@
     return false;
   }
 
-  private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user)
-      throws OrmException {
+  private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user) throws OrmException {
     List<GroupInfo> groups = new ArrayList<>();
     int found = 0;
     int foundIndex = 0;
     for (AccountGroup g : filterGroups(groupCache.all())) {
       GroupControl ctl = groupControlFactory.controlFor(g);
       try {
-        if (genericGroupControlFactory.controlFor(user, g.getGroupUUID())
-            .isOwner()) {
+        if (genericGroupControlFactory.controlFor(user, g.getGroupUUID()).isOwner()) {
           if (foundIndex++ < start) {
             continue;
           }
@@ -314,13 +344,14 @@
     return groups;
   }
 
-  private List<AccountGroup> filterGroups(final Iterable<AccountGroup> groups) {
-    final List<AccountGroup> filteredGroups = new ArrayList<>();
-    final boolean isAdmin =
-        identifiedUser.get().getCapabilities().canAdministrateServer();
-    for (final AccountGroup group : groups) {
+  private List<AccountGroup> filterGroups(Collection<AccountGroup> groups) {
+    List<AccountGroup> filteredGroups = new ArrayList<>(groups.size());
+    boolean isAdmin = identifiedUser.get().getCapabilities().canAdministrateServer();
+    for (AccountGroup group : groups) {
       if (!Strings.isNullOrEmpty(matchSubstring)) {
-        if (!group.getName().toLowerCase(Locale.US)
+        if (!group
+            .getName()
+            .toLowerCase(Locale.US)
             .contains(matchSubstring.toLowerCase(Locale.US))) {
           continue;
         }
@@ -328,12 +359,11 @@
       if (visibleToAll && !group.isVisibleToAll()) {
         continue;
       }
-      if (!groupsToInspect.isEmpty()
-          && !groupsToInspect.contains(group.getGroupUUID())) {
+      if (!groupsToInspect.isEmpty() && !groupsToInspect.contains(group.getGroupUUID())) {
         continue;
       }
       if (!isAdmin) {
-        final GroupControl c = groupControlFactory.controlFor(group);
+        GroupControl c = groupControlFactory.controlFor(group);
         if (!c.isVisible()) {
           continue;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java
index 803c498..66b2edb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java
@@ -27,64 +27,64 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class ListIncludedGroups implements RestReadView<GroupResource> {
-  private static final Logger log = org.slf4j.LoggerFactory.getLogger(ListIncludedGroups.class);
+  private static final Logger log = LoggerFactory.getLogger(ListIncludedGroups.class);
 
   private final GroupControl.Factory controlFactory;
   private final Provider<ReviewDb> dbProvider;
   private final GroupJson json;
 
   @Inject
-  ListIncludedGroups(GroupControl.Factory controlFactory,
-      Provider<ReviewDb> dbProvider, GroupJson json) {
+  ListIncludedGroups(
+      GroupControl.Factory controlFactory, Provider<ReviewDb> dbProvider, GroupJson json) {
     this.controlFactory = controlFactory;
     this.dbProvider = dbProvider;
     this.json = json;
   }
 
   @Override
-  public List<GroupInfo> apply(GroupResource rsrc)
-      throws MethodNotAllowedException, OrmException {
+  public List<GroupInfo> apply(GroupResource rsrc) throws MethodNotAllowedException, OrmException {
     if (rsrc.toAccountGroup() == null) {
       throw new MethodNotAllowedException();
     }
 
     boolean ownerOfParent = rsrc.getControl().isOwner();
     List<GroupInfo> included = new ArrayList<>();
-    for (AccountGroupById u : dbProvider.get()
-        .accountGroupById()
-        .byGroup(rsrc.toAccountGroup().getId())) {
+    for (AccountGroupById u :
+        dbProvider.get().accountGroupById().byGroup(rsrc.toAccountGroup().getId())) {
       try {
         GroupControl i = controlFactory.controlFor(u.getIncludeUUID());
         if (ownerOfParent || i.isVisible()) {
           included.add(json.format(i.getGroup()));
         }
       } catch (NoSuchGroupException notFound) {
-        log.warn(String.format("Group %s no longer available, included into %s",
+        log.warn(
+            "Group {} no longer available, included into {}",
             u.getIncludeUUID(),
-            rsrc.getGroup().getName()));
+            rsrc.getGroup().getName());
         continue;
       }
     }
-    Collections.sort(included, new Comparator<GroupInfo>() {
-      @Override
-      public int compare(GroupInfo a, GroupInfo b) {
-        int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
-        if (cmp != 0) {
-          return cmp;
-        }
-        return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
-      }
-    });
+    Collections.sort(
+        included,
+        new Comparator<GroupInfo>() {
+          @Override
+          public int compare(GroupInfo a, GroupInfo b) {
+            int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
+            if (cmp != 0) {
+              return cmp;
+            }
+            return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
+          }
+        });
     return included;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
index 98d18ca..8e2c925 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
@@ -30,14 +30,12 @@
 import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import org.kohsuke.args4j.Option;
 
 public class ListMembers implements RestReadView<GroupResource> {
   private final GroupCache groupCache;
@@ -48,7 +46,8 @@
   private boolean recursive;
 
   @Inject
-  protected ListMembers(GroupCache groupCache,
+  protected ListMembers(
+      GroupCache groupCache,
       GroupDetailFactory.Factory groupDetailFactory,
       AccountLoader.Factory accountLoaderFactory) {
     this.groupCache = groupCache;
@@ -71,13 +70,11 @@
     return apply(resource.getGroupUUID());
   }
 
-  public List<AccountInfo> apply(AccountGroup group)
-      throws OrmException {
+  public List<AccountInfo> apply(AccountGroup group) throws OrmException {
     return apply(group.getGroupUUID());
   }
 
-  public List<AccountInfo> apply(AccountGroup.UUID groupId)
-      throws OrmException {
+  public List<AccountInfo> apply(AccountGroup.UUID groupId) throws OrmException {
     final Map<Account.Id, AccountInfo> members =
         getMembers(groupId, new HashSet<AccountGroup.UUID>());
     final List<AccountInfo> memberInfos = Lists.newArrayList(members.values());
@@ -86,8 +83,8 @@
   }
 
   private Map<Account.Id, AccountInfo> getMembers(
-      final AccountGroup.UUID groupUUID,
-      final HashSet<AccountGroup.UUID> seenGroups) throws OrmException {
+      final AccountGroup.UUID groupUUID, final HashSet<AccountGroup.UUID> seenGroups)
+      throws OrmException {
     seenGroups.add(groupUUID);
 
     final Map<Account.Id, AccountInfo> members = new HashMap<>();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java
index fc69a1f..8f4d65e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java
@@ -34,9 +34,8 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class MembersCollection implements
-    ChildCollection<GroupResource, MemberResource>,
-    AcceptsCreate<GroupResource> {
+public class MembersCollection
+    implements ChildCollection<GroupResource, MemberResource>, AcceptsCreate<GroupResource> {
   private final DynamicMap<RestView<MemberResource>> views;
   private final Provider<ListMembers> list;
   private final AccountsCollection accounts;
@@ -44,7 +43,8 @@
   private final AddMembers put;
 
   @Inject
-  MembersCollection(DynamicMap<RestView<MemberResource>> views,
+  MembersCollection(
+      DynamicMap<RestView<MemberResource>> views,
       Provider<ListMembers> list,
       AccountsCollection accounts,
       Provider<ReviewDb> db,
@@ -57,15 +57,13 @@
   }
 
   @Override
-  public RestView<GroupResource> list() throws ResourceNotFoundException,
-      AuthException {
+  public RestView<GroupResource> list() throws ResourceNotFoundException, AuthException {
     return list.get();
   }
 
   @Override
   public MemberResource parse(GroupResource parent, IdString id)
-      throws MethodNotAllowedException, AuthException,
-      ResourceNotFoundException, OrmException {
+      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException {
     if (parent.toAccountGroup() == null) {
       throw new MethodNotAllowedException();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java
index 58b3ffb..366cc4d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java
@@ -39,6 +39,7 @@
     get(GROUP_KIND).to(GetGroup.class);
     put(GROUP_KIND).to(PutGroup.class);
     get(GROUP_KIND, "detail").to(GetDetail.class);
+    post(GROUP_KIND, "index").to(Index.class);
     post(GROUP_KIND, "members").to(AddMembers.class);
     post(GROUP_KIND, "members.add").to(AddMembers.class);
     post(GROUP_KIND, "members.delete").to(DeleteMembers.class);
@@ -68,8 +69,6 @@
 
     factory(CreateGroup.Factory.class);
 
-    DynamicSet.bind(binder(), GroupMemberAuditListener.class).to(
-        DbGroupMemberAuditListener.class);
-
+    DynamicSet.bind(binder(), GroupMemberAuditListener.class).to(DbGroupMemberAuditListener.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
index c2dc23a..b04da91 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
@@ -29,14 +29,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import java.util.Collections;
 
 @Singleton
 public class PutDescription implements RestModifyView<GroupResource, Input> {
   public static class Input {
-    @DefaultInput
-    public String description;
+    @DefaultInput public String description;
   }
 
   private final GroupCache groupCache;
@@ -50,8 +49,8 @@
 
   @Override
   public Response<String> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException,
-      ResourceNotFoundException, OrmException {
+      throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
+          IOException {
     if (input == null) {
       input = new Input(); // Delete would set description to null.
     }
@@ -62,8 +61,7 @@
       throw new AuthException("Not group owner");
     }
 
-    AccountGroup group = db.get().accountGroups().get(
-        resource.toAccountGroup().getId());
+    AccountGroup group = db.get().accountGroups().get(resource.toAccountGroup().getId());
     if (group == null) {
       throw new ResourceNotFoundException();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
index c59f0ff..b78f4a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
@@ -35,17 +35,17 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import java.util.Collections;
 import java.util.Date;
 import java.util.TimeZone;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 
 @Singleton
 public class PutName implements RestModifyView<GroupResource, Input> {
   public static class Input {
-    @DefaultInput
-    public String name;
+    @DefaultInput public String name;
   }
 
   private final Provider<ReviewDb> db;
@@ -55,7 +55,8 @@
   private final Provider<IdentifiedUser> currentUser;
 
   @Inject
-  PutName(Provider<ReviewDb> db,
+  PutName(
+      Provider<ReviewDb> db,
       GroupCache groupCache,
       GroupDetailFactory.Factory groupDetailFactory,
       RenameGroupOp.Factory renameGroupOpFactory,
@@ -70,7 +71,7 @@
   @Override
   public String apply(GroupResource rsrc, Input input)
       throws MethodNotAllowedException, AuthException, BadRequestException,
-      ResourceConflictException, OrmException, NoSuchGroupException {
+          ResourceConflictException, OrmException, NoSuchGroupException, IOException {
     if (rsrc.toAccountGroup() == null) {
       throw new MethodNotAllowedException();
     } else if (!rsrc.getControl().isOwner()) {
@@ -91,8 +92,7 @@
   }
 
   private GroupDetail renameGroup(AccountGroup group, String newName)
-      throws ResourceConflictException, OrmException,
-      NoSuchGroupException {
+      throws ResourceConflictException, OrmException, NoSuchGroupException, IOException {
     AccountGroup.Id groupId = group.getId();
     AccountGroup.NameKey old = group.getNameKey();
     AccountGroup.NameKey key = new AccountGroup.NameKey(newName);
@@ -111,8 +111,7 @@
 
         // Otherwise, someone else has this identity.
         //
-        throw new ResourceConflictException("group with name " + newName
-            + "already exists");
+        throw new ResourceConflictException("group with name " + newName + "already exists");
       }
       throw e;
     }
@@ -127,10 +126,15 @@
 
     groupCache.evict(group);
     groupCache.evictAfterRename(old, key);
-    renameGroupOpFactory.create(
-        currentUser.get().newCommitterIdent(new Date(), TimeZone.getDefault()),
-        group.getGroupUUID(),
-        old.get(), newName).start(0, TimeUnit.MILLISECONDS);
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        renameGroupOpFactory
+            .create(
+                currentUser.get().newCommitterIdent(new Date(), TimeZone.getDefault()),
+                group.getGroupUUID(),
+                old.get(),
+                newName)
+            .start(0, TimeUnit.MILLISECONDS);
 
     return groupDetailFactory.create(groupId).call();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java
index 5788503..701d16f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java
@@ -27,12 +27,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import java.util.Collections;
 
 @Singleton
-public class PutOptions
-    implements RestModifyView<GroupResource, GroupOptionsInfo> {
+public class PutOptions implements RestModifyView<GroupResource, GroupOptionsInfo> {
   private final GroupCache groupCache;
   private final Provider<ReviewDb> db;
 
@@ -45,7 +44,7 @@
   @Override
   public GroupOptionsInfo apply(GroupResource resource, GroupOptionsInfo input)
       throws MethodNotAllowedException, AuthException, BadRequestException,
-      ResourceNotFoundException, OrmException {
+          ResourceNotFoundException, OrmException, IOException {
     if (resource.toAccountGroup() == null) {
       throw new MethodNotAllowedException();
     } else if (!resource.getControl().isOwner()) {
@@ -59,8 +58,7 @@
       input.visibleToAll = false;
     }
 
-    AccountGroup group = db.get().accountGroups().get(
-        resource.toAccountGroup().getId());
+    AccountGroup group = db.get().accountGroups().get(resource.toAccountGroup().getId());
     if (group == null) {
       throw new ResourceNotFoundException();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
index b88ead5..0c82b9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
@@ -32,14 +32,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import java.util.Collections;
 
 @Singleton
 public class PutOwner implements RestModifyView<GroupResource, Input> {
   public static class Input {
-    @DefaultInput
-    public String owner;
+    @DefaultInput public String owner;
   }
 
   private final GroupsCollection groupsCollection;
@@ -48,8 +47,11 @@
   private final GroupJson json;
 
   @Inject
-  PutOwner(GroupsCollection groupsCollection, GroupCache groupCache,
-      Provider<ReviewDb> db, GroupJson json) {
+  PutOwner(
+      GroupsCollection groupsCollection,
+      GroupCache groupCache,
+      Provider<ReviewDb> db,
+      GroupJson json) {
     this.groupsCollection = groupsCollection;
     this.groupCache = groupCache;
     this.db = db;
@@ -58,9 +60,8 @@
 
   @Override
   public GroupInfo apply(GroupResource resource, Input input)
-      throws ResourceNotFoundException, MethodNotAllowedException,
-      AuthException, BadRequestException, UnprocessableEntityException,
-      OrmException {
+      throws ResourceNotFoundException, MethodNotAllowedException, AuthException,
+          BadRequestException, UnprocessableEntityException, OrmException, IOException {
     AccountGroup group = resource.toAccountGroup();
     if (group == null) {
       throw new MethodNotAllowedException();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java
new file mode 100644
index 0000000..1b95537
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java
@@ -0,0 +1,139 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.GroupDescriptions;
+import com.google.gerrit.extensions.client.ListGroupsOption;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.QueryResult;
+import com.google.gerrit.server.query.group.GroupQueryBuilder;
+import com.google.gerrit.server.query.group.GroupQueryProcessor;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+public class QueryGroups implements RestReadView<TopLevelResource> {
+  private final GroupIndexCollection indexes;
+  private final GroupQueryBuilder queryBuilder;
+  private final GroupQueryProcessor queryProcessor;
+  private final GroupJson json;
+
+  private String query;
+  private int limit;
+  private int start;
+  private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
+
+  // TODO(ekempin): --query in ListGroups is marked as deprecated, once it is
+  // removed we want to rename --query2 to --query here.
+  /** --query (-q) is already used by {@link ListGroups} */
+  @Option(
+      name = "--query2",
+      aliases = {"-q2"},
+      usage = "group query")
+  public void setQuery(String query) {
+    this.query = query;
+  }
+
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of groups to list")
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of groups to skip")
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Option(name = "-o", usage = "Output options per group")
+  public void addOption(ListGroupsOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  public void setOptionFlagsHex(String hex) {
+    options.addAll(ListGroupsOption.fromBits(Integer.parseInt(hex, 16)));
+  }
+
+  @Inject
+  protected QueryGroups(
+      GroupIndexCollection indexes,
+      GroupQueryBuilder queryBuilder,
+      GroupQueryProcessor queryProcessor,
+      GroupJson json) {
+    this.indexes = indexes;
+    this.queryBuilder = queryBuilder;
+    this.queryProcessor = queryProcessor;
+    this.json = json;
+  }
+
+  @Override
+  public List<GroupInfo> apply(TopLevelResource resource)
+      throws BadRequestException, MethodNotAllowedException, OrmException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new BadRequestException("missing query field");
+    }
+
+    GroupIndex searchIndex = indexes.getSearchIndex();
+    if (searchIndex == null) {
+      throw new MethodNotAllowedException("no group index");
+    }
+
+    if (start != 0) {
+      queryProcessor.setStart(start);
+    }
+
+    if (limit != 0) {
+      queryProcessor.setLimit(limit);
+    }
+
+    try {
+      QueryResult<AccountGroup> result = queryProcessor.query(queryBuilder.parse(query));
+      List<AccountGroup> groups = result.entities();
+
+      ArrayList<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groups.size());
+      json.addOptions(options);
+      for (AccountGroup group : groups) {
+        groupInfos.add(json.format(GroupDescriptions.forAccountGroup(group)));
+      }
+      if (!groupInfos.isEmpty() && result.more()) {
+        groupInfos.get(groupInfos.size() - 1)._moreGroups = true;
+      }
+      return groupInfos;
+    } catch (QueryParseException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 9809ef3..cbab0fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -15,27 +15,39 @@
 package com.google.gerrit.server.group;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toSet;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StartupCheck;
+import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.account.AbstractGroupBackend;
+import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectControl;
-
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import org.eclipse.jgit.lib.Config;
 
+@Singleton
 public class SystemGroupBackend extends AbstractGroupBackend {
   public static final String SYSTEM_GROUP_SCHEME = "global:";
 
@@ -55,31 +67,10 @@
   public static final AccountGroup.UUID CHANGE_OWNER =
       new AccountGroup.UUID(SYSTEM_GROUP_SCHEME + "Change-Owner");
 
-  private static final SortedMap<String, GroupReference> names;
-  private static final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
   private static final AccountGroup.UUID[] all = {
-      ANONYMOUS_USERS,
-      REGISTERED_USERS,
-      PROJECT_OWNERS,
-      CHANGE_OWNER,
+    ANONYMOUS_USERS, REGISTERED_USERS, PROJECT_OWNERS, CHANGE_OWNER,
   };
 
-  static {
-    SortedMap<String, GroupReference> n = new TreeMap<>();
-    ImmutableMap.Builder<AccountGroup.UUID, GroupReference> u =
-        ImmutableMap.builder();
-
-    for (AccountGroup.UUID uuid : all) {
-      int c = uuid.get().indexOf(':');
-      String name = uuid.get().substring(c + 1).replace('-', ' ');
-      GroupReference ref = new GroupReference(uuid, name);
-      n.put(ref.getName().toLowerCase(Locale.US), ref);
-      u.put(ref.getUUID(), ref);
-    }
-    names = Collections.unmodifiableSortedMap(n);
-    uuids = u.build();
-  }
-
   public static boolean isSystemGroup(AccountGroup.UUID uuid) {
     return uuid.get().startsWith(SYSTEM_GROUP_SCHEME);
   }
@@ -92,17 +83,42 @@
     return ANONYMOUS_USERS.equals(uuid) || REGISTERED_USERS.equals(uuid);
   }
 
-  public static GroupReference getGroup(AccountGroup.UUID uuid) {
+  private final ImmutableSet<String> reservedNames;
+  private final SortedMap<String, GroupReference> names;
+  private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
+
+  @Inject
+  @VisibleForTesting
+  public SystemGroupBackend(@GerritServerConfig Config cfg) {
+    SortedMap<String, GroupReference> n = new TreeMap<>();
+    ImmutableMap.Builder<AccountGroup.UUID, GroupReference> u = ImmutableMap.builder();
+
+    ImmutableSet.Builder<String> reservedNamesBuilder = ImmutableSet.builder();
+    for (AccountGroup.UUID uuid : all) {
+      int c = uuid.get().indexOf(':');
+      String defaultName = uuid.get().substring(c + 1).replace('-', ' ');
+      reservedNamesBuilder.add(defaultName);
+      String configuredName = cfg.getString("groups", uuid.get(), "name");
+      GroupReference ref =
+          new GroupReference(uuid, MoreObjects.firstNonNull(configuredName, defaultName));
+      n.put(ref.getName().toLowerCase(Locale.US), ref);
+      u.put(ref.getUUID(), ref);
+    }
+    reservedNames = reservedNamesBuilder.build();
+    names = Collections.unmodifiableSortedMap(n);
+    uuids = u.build();
+  }
+
+  public GroupReference getGroup(AccountGroup.UUID uuid) {
     return checkNotNull(uuids.get(uuid), "group %s not found", uuid.get());
   }
 
-  public static List<String> getNames() {
-    List<String> names = new ArrayList<>();
-    for (AccountGroup.UUID uuid : all) {
-      int c = uuid.get().indexOf(':');
-      names.add(uuid.get().substring(c + 1).replace('-', ' '));
-    }
-    return names;
+  public Set<String> getNames() {
+    return names.values().stream().map(r -> r.getName()).collect(toSet());
+  }
+
+  public Set<String> getReservedNames() {
+    return reservedNames;
   }
 
   @Override
@@ -112,7 +128,10 @@
 
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
-    final GroupReference ref = getGroup(uuid);
+    final GroupReference ref = uuids.get(uuid);
+    if (ref == null) {
+      return null;
+    }
     return new GroupDescription.Basic() {
       @Override
       public String getName() {
@@ -157,8 +176,50 @@
 
   @Override
   public GroupMembership membershipsOf(IdentifiedUser user) {
-    return new ListGroupMembership(ImmutableSet.of(
-        ANONYMOUS_USERS,
-        REGISTERED_USERS));
+    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
+  }
+
+  public static class NameCheck implements StartupCheck {
+    private final Config cfg;
+    private final GroupCache groupCache;
+
+    @Inject
+    NameCheck(@GerritServerConfig Config cfg, GroupCache groupCache) {
+      this.cfg = cfg;
+      this.groupCache = groupCache;
+    }
+
+    @Override
+    public void check() throws StartupException {
+      Map<AccountGroup.UUID, String> configuredNames = new HashMap<>();
+      Map<String, AccountGroup.UUID> byLowerCaseConfiguredName = new HashMap<>();
+      for (AccountGroup.UUID uuid : all) {
+        String configuredName = cfg.getString("groups", uuid.get(), "name");
+        if (configuredName != null) {
+          configuredNames.put(uuid, configuredName);
+          byLowerCaseConfiguredName.put(configuredName.toLowerCase(Locale.US), uuid);
+        }
+      }
+      if (configuredNames.isEmpty()) {
+        return;
+      }
+      for (AccountGroup g : groupCache.all()) {
+        String name = g.getName().toLowerCase(Locale.US);
+        if (byLowerCaseConfiguredName.keySet().contains(name)) {
+          AccountGroup.UUID uuidSystemGroup = byLowerCaseConfiguredName.get(name);
+          throw new StartupException(
+              String.format(
+                  "The configured name '%s' for system group '%s' is ambiguous"
+                      + " with the name '%s' of existing group '%s'."
+                      + " Please remove/change the value for groups.%s.name in"
+                      + " gerrit.config.",
+                  configuredNames.get(uuidSystemGroup),
+                  uuidSystemGroup.get(),
+                  g.getName(),
+                  g.getGroupUUID().get(),
+                  uuidSystemGroup.get()));
+        }
+      }
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractIndexModule.java
new file mode 100644
index 0000000..ec78043
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public abstract class AbstractIndexModule extends AbstractModule {
+
+  private final int threads;
+  private final Map<String, Integer> singleVersions;
+
+  protected AbstractIndexModule(Map<String, Integer> singleVersions, int threads) {
+    this.singleVersions = singleVersions;
+    this.threads = threads;
+  }
+
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(AccountIndex.class, getAccountIndex())
+            .build(AccountIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            .implement(ChangeIndex.class, getChangeIndex())
+            .build(ChangeIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            .implement(GroupIndex.class, getGroupIndex())
+            .build(GroupIndex.Factory.class));
+
+    install(new IndexModule(threads));
+    if (singleVersions == null) {
+      install(new MultiVersionModule());
+    } else {
+      install(new SingleVersionModule(singleVersions));
+    }
+  }
+
+  protected abstract Class<? extends AccountIndex> getAccountIndex();
+
+  protected abstract Class<? extends ChangeIndex> getChangeIndex();
+
+  protected abstract Class<? extends GroupIndex> getGroupIndex();
+
+  protected abstract Class<? extends AbstractVersionManager> getVersionManager();
+
+  @Provides
+  @Singleton
+  IndexConfig provideIndexConfig(@GerritServerConfig Config cfg) {
+    return getIndexConfig(cfg);
+  }
+
+  protected IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+    return IndexConfig.fromConfig(cfg);
+  }
+
+  private class MultiVersionModule extends LifecycleModule {
+    @Override
+    public void configure() {
+      Class<? extends AbstractVersionManager> versionManagerClass = getVersionManager();
+      bind(AbstractVersionManager.class).to(versionManagerClass);
+      listener().to(versionManagerClass);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java
new file mode 100644
index 0000000..8d0f550
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java
@@ -0,0 +1,237 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexDefinition.IndexFactory;
+import com.google.inject.ProvisionException;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+public abstract class AbstractVersionManager implements LifecycleListener {
+  public static class Version<V> {
+    public final Schema<V> schema;
+    public final int version;
+    public final boolean exists;
+    public final boolean ready;
+
+    public Version(Schema<V> schema, int version, boolean exists, boolean ready) {
+      checkArgument(schema == null || schema.getVersion() == version);
+      this.schema = schema;
+      this.version = version;
+      this.exists = exists;
+      this.ready = ready;
+    }
+  }
+
+  protected final boolean onlineUpgrade;
+  protected final String runReindexMsg;
+  protected final SitePaths sitePaths;
+  protected final Map<String, IndexDefinition<?, ?, ?>> defs;
+  protected final Map<String, OnlineReindexer<?, ?, ?>> reindexers;
+
+  protected AbstractVersionManager(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Collection<IndexDefinition<?, ?, ?>> defs) {
+    this.sitePaths = sitePaths;
+    this.defs = Maps.newHashMapWithExpectedSize(defs.size());
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      this.defs.put(def.getName(), def);
+    }
+
+    reindexers = Maps.newHashMapWithExpectedSize(defs.size());
+    onlineUpgrade = cfg.getBoolean("index", null, "onlineUpgrade", true);
+    runReindexMsg =
+        "No index versions ready; run java -jar "
+            + sitePaths.gerrit_war.toAbsolutePath()
+            + " reindex";
+  }
+
+  @Override
+  public void start() {
+    GerritIndexStatus cfg = createIndexStatus();
+    for (IndexDefinition<?, ?, ?> def : defs.values()) {
+      initIndex(def, cfg);
+    }
+  }
+
+  @Override
+  public void stop() {
+    // Do nothing; indexes are closed on demand by IndexCollection.
+  }
+
+  /**
+   * Start the online reindexer if the current index is not already the latest.
+   *
+   * @param name index name
+   * @param force start re-index
+   * @return true if started, otherwise false.
+   * @throws ReindexerAlreadyRunningException
+   */
+  public synchronized boolean startReindexer(String name, boolean force)
+      throws ReindexerAlreadyRunningException {
+    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
+    validateReindexerNotRunning(reindexer);
+    if (force || !isLatestIndexVersion(name, reindexer)) {
+      reindexer.start();
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Activate the latest index if the current index is not already the latest.
+   *
+   * @param name index name
+   * @return true if index was activated, otherwise false.
+   * @throws ReindexerAlreadyRunningException
+   */
+  public synchronized boolean activateLatestIndex(String name)
+      throws ReindexerAlreadyRunningException {
+    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
+    validateReindexerNotRunning(reindexer);
+    if (!isLatestIndexVersion(name, reindexer)) {
+      reindexer.activateIndex();
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Tells if an index with this name is currently known or not.
+   *
+   * @param name index name
+   * @return true if index is known and can be used, otherwise false.
+   */
+  public boolean isKnownIndex(String name) {
+    return defs.get(name) != null;
+  }
+
+  protected <K, V, I extends Index<K, V>> void initIndex(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, Version<V>> versions = scanVersions(def, cfg);
+    // Search from the most recent ready version.
+    // Write to the most recent ready version and the most recent version.
+    Version<V> search = null;
+    List<Version<V>> write = Lists.newArrayListWithCapacity(2);
+    for (Version<V> v : versions.descendingMap().values()) {
+      if (v.schema == null) {
+        continue;
+      }
+      if (write.isEmpty() && onlineUpgrade) {
+        write.add(v);
+      }
+      if (v.ready) {
+        search = v;
+        if (!write.contains(v)) {
+          write.add(v);
+        }
+        break;
+      }
+    }
+    if (search == null) {
+      throw new ProvisionException(runReindexMsg);
+    }
+
+    IndexFactory<K, V, I> factory = def.getIndexFactory();
+    I searchIndex = factory.create(search.schema);
+    IndexCollection<K, V, I> indexes = def.getIndexCollection();
+    indexes.setSearchIndex(searchIndex);
+    for (Version<V> v : write) {
+      if (v.version != search.version) {
+        indexes.addWriteIndex(factory.create(v.schema));
+      } else {
+        indexes.addWriteIndex(searchIndex);
+      }
+    }
+
+    markNotReady(def.getName(), versions.values(), write);
+
+    synchronized (this) {
+      if (!reindexers.containsKey(def.getName())) {
+        int latest = write.get(0).version;
+        OnlineReindexer<K, V, I> reindexer = new OnlineReindexer<>(def, latest);
+        reindexers.put(def.getName(), reindexer);
+        if (onlineUpgrade && latest != search.version) {
+          reindexer.start();
+        }
+      }
+    }
+  }
+
+  protected GerritIndexStatus createIndexStatus() {
+    try {
+      return new GerritIndexStatus(sitePaths);
+    } catch (ConfigInvalidException | IOException e) {
+      throw fail(e);
+    }
+  }
+
+  protected abstract <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg);
+
+  private <V> boolean isDirty(Collection<Version<V>> inUse, Version<V> v) {
+    return !inUse.contains(v) && v.exists;
+  }
+
+  private boolean isLatestIndexVersion(String name, OnlineReindexer<?, ?, ?> reindexer) {
+    int readVersion = defs.get(name).getIndexCollection().getSearchIndex().getSchema().getVersion();
+    return reindexer == null || reindexer.getVersion() == readVersion;
+  }
+
+  private static void validateReindexerNotRunning(OnlineReindexer<?, ?, ?> reindexer)
+      throws ReindexerAlreadyRunningException {
+    if (reindexer != null && reindexer.isRunning()) {
+      throw new ReindexerAlreadyRunningException();
+    }
+  }
+
+  private <V> void markNotReady(
+      String name, Iterable<Version<V>> versions, Collection<Version<V>> inUse) {
+    GerritIndexStatus cfg = createIndexStatus();
+    boolean dirty = false;
+    for (Version<V> v : versions) {
+      if (isDirty(inUse, v)) {
+        cfg.setReady(name, v.version, false);
+        dirty = true;
+      }
+    }
+    if (dirty) {
+      try {
+        cfg.save();
+      } catch (IOException e) {
+        throw fail(e);
+      }
+    }
+  }
+
+  private ProvisionException fail(Throwable t) {
+    ProvisionException e = new ProvisionException("Error scanning indexes");
+    e.initCause(t);
+    return e;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
index ed196c1..1706761 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.index;
 
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.DummyChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 
@@ -36,6 +38,13 @@
     }
   }
 
+  private static class DummyGroupIndexFactory implements GroupIndex.Factory {
+    @Override
+    public GroupIndex create(Schema<AccountGroup> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
   @Override
   protected void configure() {
     install(new IndexModule(1));
@@ -43,5 +52,6 @@
     bind(Index.class).toInstance(new DummyChangeIndex());
     bind(AccountIndex.Factory.class).toInstance(new DummyAccountIndexFactory());
     bind(ChangeIndex.Factory.class).toInstance(new DummyChangeIndexFactory());
+    bind(GroupIndex.Factory.class).toInstance(new DummyGroupIndexFactory());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
index 386092d..d5f1091 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
@@ -15,98 +15,151 @@
 package com.google.gerrit.server.index;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.CharMatcher;
-import com.google.common.base.Preconditions;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.sql.Timestamp;
 import org.eclipse.jgit.lib.Config;
 
 /**
  * Definition of a field stored in the secondary index.
  *
- * @param <I> input type from which documents are created and search results are
- *     returned.
- * @param <T> type that should be extracted from the input object when converting
- *     to an index document.
+ * @param <I> input type from which documents are created and search results are returned.
+ * @param <T> type that should be extracted from the input object when converting to an index
+ *     document.
  */
-public abstract class FieldDef<I, T> {
-  /** Definition of a single (non-repeatable) field. */
-  public abstract static class Single<I, T> extends FieldDef<I, T> {
-    protected Single(String name, FieldType<T> type, boolean stored) {
-      super(name, type, stored);
-    }
-
-    @Override
-    public final boolean isRepeatable() {
-      return false;
-    }
+public final class FieldDef<I, T> {
+  public static FieldDef.Builder<String> exact(String name) {
+    return new FieldDef.Builder<>(FieldType.EXACT, name);
   }
 
-  /** Definition of a repeatable field. */
-  public abstract static class Repeatable<I, T>
-      extends FieldDef<I, Iterable<T>> {
-    protected Repeatable(String name, FieldType<T> type, boolean stored) {
-      super(name, type, stored);
-      Preconditions.checkArgument(type != FieldType.INTEGER_RANGE,
-          "Range queries against repeated fields are unsupported");
-    }
+  public static FieldDef.Builder<String> fullText(String name) {
+    return new FieldDef.Builder<>(FieldType.FULL_TEXT, name);
+  }
 
-    @Override
-    public final boolean isRepeatable() {
-      return true;
-    }
+  public static FieldDef.Builder<Integer> intRange(String name) {
+    return new FieldDef.Builder<>(FieldType.INTEGER_RANGE, name).stored();
+  }
+
+  public static FieldDef.Builder<Integer> integer(String name) {
+    return new FieldDef.Builder<>(FieldType.INTEGER, name);
+  }
+
+  public static FieldDef.Builder<String> prefix(String name) {
+    return new FieldDef.Builder<>(FieldType.PREFIX, name);
+  }
+
+  public static FieldDef.Builder<byte[]> storedOnly(String name) {
+    return new FieldDef.Builder<>(FieldType.STORED_ONLY, name).stored();
+  }
+
+  public static FieldDef.Builder<Timestamp> timestamp(String name) {
+    return new FieldDef.Builder<>(FieldType.TIMESTAMP, name);
+  }
+
+  @FunctionalInterface
+  public interface Getter<I, T> {
+    T get(I input) throws OrmException, IOException;
+  }
+
+  @FunctionalInterface
+  public interface GetterWithArgs<I, T> {
+    T get(I input, FillArgs args) throws OrmException, IOException;
   }
 
   /** Arguments needed to fill in missing data in the input object. */
   public static class FillArgs {
     public final TrackingFooters trackingFooters;
     public final boolean allowsDrafts;
+    public final AllUsersName allUsers;
 
     @Inject
-    FillArgs(TrackingFooters trackingFooters,
-        @GerritServerConfig Config cfg) {
+    FillArgs(
+        TrackingFooters trackingFooters, @GerritServerConfig Config cfg, AllUsersName allUsers) {
       this.trackingFooters = trackingFooters;
-      this.allowsDrafts = cfg == null
-          ? true
-          : cfg.getBoolean("change", "allowDrafts", true);
+      this.allowsDrafts = cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true);
+      this.allUsers = allUsers;
+    }
+  }
+
+  public static class Builder<T> {
+    private final FieldType<T> type;
+    private final String name;
+    private boolean stored;
+
+    public Builder(FieldType<T> type, String name) {
+      this.type = checkNotNull(type);
+      this.name = checkNotNull(name);
+    }
+
+    public Builder<T> stored() {
+      this.stored = true;
+      return this;
+    }
+
+    public <I> FieldDef<I, T> build(Getter<I, T> getter) {
+      return build((in, a) -> getter.get(in));
+    }
+
+    public <I> FieldDef<I, T> build(GetterWithArgs<I, T> getter) {
+      return new FieldDef<>(name, type, stored, false, getter);
+    }
+
+    public <I> FieldDef<I, Iterable<T>> buildRepeatable(Getter<I, Iterable<T>> getter) {
+      return buildRepeatable((in, a) -> getter.get(in));
+    }
+
+    public <I> FieldDef<I, Iterable<T>> buildRepeatable(GetterWithArgs<I, Iterable<T>> getter) {
+      return new FieldDef<>(name, type, stored, true, getter);
     }
   }
 
   private final String name;
   private final FieldType<?> type;
   private final boolean stored;
+  private final boolean repeatable;
+  private final GetterWithArgs<I, T> getter;
 
-  private FieldDef(String name, FieldType<?> type, boolean stored) {
+  private FieldDef(
+      String name,
+      FieldType<?> type,
+      boolean stored,
+      boolean repeatable,
+      GetterWithArgs<I, T> getter) {
+    checkArgument(
+        !(repeatable && type == FieldType.INTEGER_RANGE),
+        "Range queries against repeated fields are unsupported");
     this.name = checkName(name);
-    this.type = type;
+    this.type = checkNotNull(type);
     this.stored = stored;
+    this.repeatable = repeatable;
+    this.getter = checkNotNull(getter);
   }
 
   private static String checkName(String name) {
     CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
-    checkArgument(m.matchesAllOf(name), "illegal field name: %s", name);
+    checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
     return name;
   }
 
   /** @return name of the field. */
-  public final String getName() {
+  public String getName() {
     return name;
   }
 
-  /**
-   * @return type of the field; for repeatable fields, the inner type, not the
-   *     iterable type.
-   */
-  public final FieldType<?> getType() {
+  /** @return type of the field; for repeatable fields, the inner type, not the iterable type. */
+  public FieldType<?> getType() {
     return type;
   }
 
   /** @return whether the field should be stored in the index. */
-  public final boolean isStored() {
+  public boolean isStored() {
     return stored;
   }
 
@@ -114,14 +167,20 @@
    * Get the field contents from the input object.
    *
    * @param input input object.
-   * @param args arbitrary arguments needed to fill in indexable fields of the
-   *     input object.
+   * @param args arbitrary arguments needed to fill in indexable fields of the input object.
    * @return the field value(s) to index.
-   *
    * @throws OrmException
    */
-  public abstract T get(I input, FillArgs args) throws OrmException;
+  public T get(I input, FillArgs args) throws OrmException {
+    try {
+      return getter.get(input, args);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
 
   /** @return whether the field is repeatable. */
-  public abstract boolean isRepeatable();
+  public boolean isRepeatable() {
+    return repeatable;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldType.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldType.java
index 89dc808..820b62a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldType.java
@@ -16,40 +16,31 @@
 
 import java.sql.Timestamp;
 
-
 /** Document field types supported by the secondary index system. */
 public class FieldType<T> {
   /** A single integer-valued field. */
-  public static final FieldType<Integer> INTEGER =
-      new FieldType<>("INTEGER");
+  public static final FieldType<Integer> INTEGER = new FieldType<>("INTEGER");
 
   /** A single-integer-valued field matched using range queries. */
-  public static final FieldType<Integer> INTEGER_RANGE =
-      new FieldType<>("INTEGER_RANGE");
+  public static final FieldType<Integer> INTEGER_RANGE = new FieldType<>("INTEGER_RANGE");
 
   /** A single integer-valued field. */
-  public static final FieldType<Long> LONG =
-      new FieldType<>("LONG");
+  public static final FieldType<Long> LONG = new FieldType<>("LONG");
 
   /** A single date/time-valued field. */
-  public static final FieldType<Timestamp> TIMESTAMP =
-      new FieldType<>("TIMESTAMP");
+  public static final FieldType<Timestamp> TIMESTAMP = new FieldType<>("TIMESTAMP");
 
   /** A string field searched using exact-match semantics. */
-  public static final FieldType<String> EXACT =
-      new FieldType<>("EXACT");
+  public static final FieldType<String> EXACT = new FieldType<>("EXACT");
 
   /** A string field searched using prefix. */
-  public static final FieldType<String> PREFIX =
-      new FieldType<>("PREFIX");
+  public static final FieldType<String> PREFIX = new FieldType<>("PREFIX");
 
   /** A string field searched using fuzzy-match semantics. */
-  public static final FieldType<String> FULL_TEXT =
-      new FieldType<>("FULL_TEXT");
+  public static final FieldType<String> FULL_TEXT = new FieldType<>("FULL_TEXT");
 
   /** A field that is only stored as raw bytes and cannot be queried. */
-  public static final FieldType<byte[]> STORED_ONLY =
-      new FieldType<>("STORED_ONLY");
+  public static final FieldType<byte[]> STORED_ONLY = new FieldType<>("STORED_ONLY");
 
   private final String name;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/GerritIndexStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/GerritIndexStatus.java
new file mode 100644
index 0000000..d835227
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/GerritIndexStatus.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+public class GerritIndexStatus {
+  private static final String SECTION = "index";
+  private static final String KEY_READY = "ready";
+
+  private final FileBasedConfig cfg;
+
+  public GerritIndexStatus(SitePaths sitePaths) throws ConfigInvalidException, IOException {
+    cfg =
+        new FileBasedConfig(
+            sitePaths.index_dir.resolve("gerrit_index.config").toFile(), FS.detect());
+    cfg.load();
+    convertLegacyConfig();
+  }
+
+  public void setReady(String indexName, int version, boolean ready) {
+    cfg.setBoolean(SECTION, indexDirName(indexName, version), KEY_READY, ready);
+  }
+
+  public boolean getReady(String indexName, int version) {
+    return cfg.getBoolean(SECTION, indexDirName(indexName, version), KEY_READY, false);
+  }
+
+  public void save() throws IOException {
+    cfg.save();
+  }
+
+  private void convertLegacyConfig() throws IOException {
+    boolean dirty = false;
+    // Convert legacy [index "25"] to modern [index "changes_0025"].
+    for (String subsection : cfg.getSubsections(SECTION)) {
+      Integer v = Ints.tryParse(subsection);
+      if (v != null) {
+        String ready = cfg.getString(SECTION, subsection, KEY_READY);
+        if (ready != null) {
+          dirty = false;
+          cfg.unset(SECTION, subsection, KEY_READY);
+          cfg.setString(SECTION, indexDirName(ChangeSchemaDefinitions.NAME, v), KEY_READY, ready);
+        }
+      }
+    }
+    if (dirty) {
+      cfg.save();
+    }
+  }
+
+  private static String indexDirName(String indexName, int version) {
+    return String.format("%s_%04d", indexName, version);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
index d12de44..93cb09c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
@@ -17,39 +17,35 @@
 import com.google.gerrit.server.query.DataSource;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
-
+import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
 
 /**
  * Secondary index implementation for arbitrary documents.
- * <p>
- * Documents are inserted into the index and are queried by converting special
- * {@link com.google.gerrit.server.query.Predicate} instances into index-aware
- * predicates that use the index search results as a source.
- * <p>
- * Implementations must be thread-safe and should batch inserts/updates where
- * appropriate.
+ *
+ * <p>Documents are inserted into the index and are queried by converting special {@link
+ * com.google.gerrit.server.query.Predicate} instances into index-aware predicates that use the
+ * index search results as a source.
+ *
+ * <p>Implementations must be thread-safe and should batch inserts/updates where appropriate.
  */
 public interface Index<K, V> {
   /** @return the schema version used by this index. */
   Schema<V> getSchema();
 
-  /** Stop and await termination of all executor threads */
-  void stop();
-
   /** Close this index. */
   void close();
 
   /**
    * Update a document in the index.
-   * <p>
-   * Semantically equivalent to deleting the document and reinserting it with
-   * new field values. A document that does not already exist is created. Results
-   * may not be immediately visible to searchers, but should be visible within a
-   * reasonable amount of time.
+   *
+   * <p>Semantically equivalent to deleting the document and reinserting it with new field values. A
+   * document that does not already exist is created. Results may not be immediately visible to
+   * searchers, but should be visible within a reasonable amount of time.
    *
    * @param obj document object
-   *
    * @throws IOException
    */
   void replace(V obj) throws IOException;
@@ -58,7 +54,6 @@
    * Delete a document from the index by key.
    *
    * @param key document key
-   *
    * @throws IOException
    */
   void delete(K key) throws IOException;
@@ -71,27 +66,58 @@
   void deleteAll() throws IOException;
 
   /**
-   * Convert the given operator predicate into a source searching the index and
-   * returning only the documents matching that predicate.
-   * <p>
-   * This method may be called multiple times for variations on the same
-   * predicate or multiple predicate subtrees in the course of processing a
-   * single query, so it should not have any side effects (e.g. starting a
-   * search in the background).
+   * Convert the given operator predicate into a source searching the index and returning only the
+   * documents matching that predicate.
    *
-   * @param p the predicate to match. Must be a tree containing only AND, OR,
-   *     or NOT predicates as internal nodes, and {@link IndexPredicate}s as
-   *     leaves.
-   * @param opts query options not implied by the predicate, such as start and
-   *     limit.
-   * @return a source of documents matching the predicate, returned in a
-   *     defined order depending on the type of documents.
+   * <p>This method may be called multiple times for variations on the same predicate or multiple
+   * predicate subtrees in the course of processing a single query, so it should not have any side
+   * effects (e.g. starting a search in the background).
    *
-   * @throws QueryParseException if the predicate could not be converted to an
-   *     indexed data source.
+   * @param p the predicate to match. Must be a tree containing only AND, OR, or NOT predicates as
+   *     internal nodes, and {@link IndexPredicate}s as leaves.
+   * @param opts query options not implied by the predicate, such as start and limit.
+   * @return a source of documents matching the predicate, returned in a defined order depending on
+   *     the type of documents.
+   * @throws QueryParseException if the predicate could not be converted to an indexed data source.
    */
-  DataSource<V> getSource(Predicate<V> p, QueryOptions opts)
-      throws QueryParseException;
+  DataSource<V> getSource(Predicate<V> p, QueryOptions opts) throws QueryParseException;
+
+  /**
+   * Get a single document from the index.
+   *
+   * @param key document key.
+   * @param opts query options. Options that do not make sense in the context of a single document,
+   *     such as start, will be ignored.
+   * @return a single document if present.
+   * @throws IOException
+   */
+  default Optional<V> get(K key, QueryOptions opts) throws IOException {
+    opts = opts.withStart(0).withLimit(2);
+    List<V> results;
+    try {
+      results = getSource(keyPredicate(key), opts).read().toList();
+    } catch (QueryParseException e) {
+      throw new IOException("Unexpected QueryParseException during get()", e);
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+    switch (results.size()) {
+      case 0:
+        return Optional.empty();
+      case 1:
+        return Optional.of(results.get(0));
+      default:
+        throw new IOException("Multiple results found in index for key " + key + ": " + results);
+    }
+  }
+
+  /**
+   * Get a predicate that looks up a single document by key.
+   *
+   * @param key document key.
+   * @return a single predicate.
+   */
+  Predicate<V> keyPredicate(K key);
 
   /**
    * Mark whether this index is up-to-date and ready to serve reads.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
index 61c4675..1ad8e05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
@@ -16,15 +16,13 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.events.LifecycleListener;
-
 import java.util.Collection;
 import java.util.Collections;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicReference;
 
 /** Dynamic pointers to the index versions used for searching and writing. */
-public abstract class IndexCollection<K, V, I extends Index<K, V>>
-    implements LifecycleListener {
+public abstract class IndexCollection<K, V, I extends Index<K, V>> implements LifecycleListener {
   private final CopyOnWriteArrayList<I> writeIndexes;
   private final AtomicReference<I> searchIndex;
 
@@ -87,8 +85,7 @@
   }
 
   @Override
-  public void start() {
-  }
+  public void start() {}
 
   @Override
   public void stop() {
@@ -98,7 +95,6 @@
     }
     for (I write : writeIndexes) {
       if (write != read) {
-        write.stop();
         write.close();
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
index 12eb347..a368190 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
@@ -17,15 +17,13 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.auto.value.AutoValue;
-
 import org.eclipse.jgit.lib.Config;
 
 /**
  * Implementation-specific configuration for secondary indexes.
- * <p>
- * Contains configuration that is tied to a specific index implementation but is
- * otherwise global, i.e. not tied to a specific {@link Index} and schema
- * version.
+ *
+ * <p>Contains configuration that is tied to a specific index implementation but is otherwise
+ * global, i.e. not tied to a specific {@link Index} and schema version.
  */
 @AutoValue
 public abstract class IndexConfig {
@@ -42,8 +40,7 @@
         cfg.getInt("index", null, "maxTerms", 0));
   }
 
-  public static IndexConfig create(int maxLimit, int maxPages,
-      int maxTerms) {
+  public static IndexConfig create(int maxLimit, int maxPages, int maxTerms) {
     return new AutoValue_IndexConfig(
         checkLimit(maxLimit, "maxLimit", Integer.MAX_VALUE),
         checkLimit(maxPages, "maxPages", Integer.MAX_VALUE),
@@ -59,20 +56,19 @@
   }
 
   /**
-   * @return maximum limit supported by the underlying index, or limited for
-   * performance reasons.
+   * @return maximum limit supported by the underlying index, or limited for performance reasons.
    */
   public abstract int maxLimit();
 
   /**
-   * @return maximum number of pages (limit / start) supported by the
-   *     underlying index, or limited for performance reasons.
+   * @return maximum number of pages (limit / start) supported by the underlying index, or limited
+   *     for performance reasons.
    */
   public abstract int maxPages();
 
   /**
-   * @return maximum number of total index query terms supported by the
-   *     underlying index, or limited for performance reasons.
+   * @return maximum number of total index query terms supported by the underlying index, or limited
+   *     for performance reasons.
    */
   public abstract int maxTerms();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java
index 629dff8..340e35e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java
@@ -15,14 +15,15 @@
 package com.google.gerrit.server.index;
 
 import com.google.common.collect.ImmutableSortedMap;
+import com.google.inject.Provider;
 
 /**
  * Definition of an index over a Gerrit data type.
- * <p>
- * An <em>index</em> includes a set of schema definitions along with the
- * specific implementations used to query the secondary index implementation in
- * a running server. If you are just interested in the static definition of one
- * or more schemas, see the implementations of {@link SchemaDefinitions}.
+ *
+ * <p>An <em>index</em> includes a set of schema definitions along with the specific implementations
+ * used to query the secondary index implementation in a running server. If you are just interested
+ * in the static definition of one or more schemas, see the implementations of {@link
+ * SchemaDefinitions}.
  */
 public abstract class IndexDefinition<K, V, I extends Index<K, V>> {
   public interface IndexFactory<K, V, I extends Index<K, V>> {
@@ -32,13 +33,13 @@
   private final SchemaDefinitions<V> schemaDefs;
   private final IndexCollection<K, V, I> indexCollection;
   private final IndexFactory<K, V, I> indexFactory;
-  private final SiteIndexer<K, V, I> siteIndexer;
+  private final Provider<SiteIndexer<K, V, I>> siteIndexer;
 
   protected IndexDefinition(
       SchemaDefinitions<V> schemaDefs,
       IndexCollection<K, V, I> indexCollection,
       IndexFactory<K, V, I> indexFactory,
-      SiteIndexer<K, V, I> siteIndexer) {
+      Provider<SiteIndexer<K, V, I>> siteIndexer) {
     this.schemaDefs = schemaDefs;
     this.indexCollection = indexCollection;
     this.indexFactory = indexFactory;
@@ -66,6 +67,6 @@
   }
 
   public final SiteIndexer<K, V, I> getSiteIndexer() {
-    return siteIndexer;
+    return siteIndexer.get();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexExecutor.java
index eb97fdc..f8145a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexExecutor.java
@@ -19,13 +19,9 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.server.git.QueueProvider.QueueType;
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
-/**
- * Marker on {@link ListeningExecutorService} used by secondary indexing
- * threads.
- */
+/** Marker on {@link ListeningExecutorService} used by secondary indexing threads. */
 @Retention(RUNTIME)
 @BindingAnnotation
 public @interface IndexExecutor {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
index d5d90d3..b4c47e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
@@ -17,12 +17,12 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 
-import com.google.common.base.Function;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
@@ -37,55 +37,65 @@
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndexDefinition;
+import com.google.gerrit.server.index.group.GroupIndexRewriter;
+import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.gerrit.server.index.group.GroupIndexerImpl;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Provides;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.Collection;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Module for non-indexer-specific secondary index setup.
- * <p>
- * This module should not be used directly except by specific secondary indexer
- * implementations (e.g. Lucene).
+ *
+ * <p>This module should not be used directly except by specific secondary indexer implementations
+ * (e.g. Lucene).
  */
 public class IndexModule extends LifecycleModule {
   public enum IndexType {
-    LUCENE
+    LUCENE,
+    ELASTICSEARCH
   }
 
   public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
-      ImmutableList.<SchemaDefinitions<?>> of(
+      ImmutableList.<SchemaDefinitions<?>>of(
           AccountSchemaDefinitions.INSTANCE,
-          ChangeSchemaDefinitions.INSTANCE);
+          ChangeSchemaDefinitions.INSTANCE,
+          GroupSchemaDefinitions.INSTANCE);
 
   /** Type of secondary index. */
   public static IndexType getIndexType(Injector injector) {
-    Config cfg = injector.getInstance(
-        Key.get(Config.class, GerritServerConfig.class));
+    Config cfg = injector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     return cfg.getEnum("index", null, "type", IndexType.LUCENE);
   }
 
   private final int threads;
   private final ListeningExecutorService interactiveExecutor;
   private final ListeningExecutorService batchExecutor;
+  private final boolean closeExecutorsOnShutdown;
 
   public IndexModule(int threads) {
     this.threads = threads;
     this.interactiveExecutor = null;
     this.batchExecutor = null;
+    this.closeExecutorsOnShutdown = true;
   }
 
-  public IndexModule(ListeningExecutorService interactiveExecutor,
-      ListeningExecutorService batchExecutor) {
+  public IndexModule(
+      ListeningExecutorService interactiveExecutor, ListeningExecutorService batchExecutor) {
     this.threads = -1;
     this.interactiveExecutor = interactiveExecutor;
     this.batchExecutor = batchExecutor;
+    this.closeExecutorsOnShutdown = false;
   }
 
   @Override
@@ -99,42 +109,43 @@
     bind(ChangeIndexCollection.class);
     listener().to(ChangeIndexCollection.class);
     factory(ChangeIndexer.Factory.class);
+
+    bind(GroupIndexRewriter.class);
+    bind(GroupIndexCollection.class);
+    listener().to(GroupIndexCollection.class);
+    factory(GroupIndexerImpl.Factory.class);
+
+    if (closeExecutorsOnShutdown) {
+      // The executors must be shutdown _before_ closing the indexes.
+      // On Gerrit start the LifecycleListeners are invoked in the order in which they are
+      // registered, but on shutdown of Gerrit the order is reversed. This means the
+      // LifecycleListener to shutdown the executors must be registered _after_ the
+      // LifecycleListeners that close the indexes. The closing of the indexes is done by
+      // *IndexCollection which have been registered as LifecycleListener above. The
+      // registration of the ShutdownIndexExecutors LifecycleListener must happen afterwards.
+      listener().to(ShutdownIndexExecutors.class);
+    }
   }
 
   @Provides
   Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
-      AccountIndexDefinition accounts,
-      ChangeIndexDefinition changes) {
+      AccountIndexDefinition accounts, ChangeIndexDefinition changes, GroupIndexDefinition groups) {
     Collection<IndexDefinition<?, ?, ?>> result =
-        ImmutableList.<IndexDefinition<?, ?, ?>> of(
-            accounts,
-            changes);
-    Set<String> expected = FluentIterable.from(ALL_SCHEMA_DEFS)
-        .transform(new Function<SchemaDefinitions<?>, String>() {
-          @Override
-          public String apply(SchemaDefinitions<?> in) {
-            return in.getName();
-          }
-        }).toSet();
-    Set<String> actual = FluentIterable.from(result)
-        .transform(new Function<IndexDefinition<?, ?, ?>, String>() {
-          @Override
-          public String apply(IndexDefinition<?, ?, ?> in) {
-            return in.getName();
-          }
-        }).toSet();
+        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups, changes);
+    Set<String> expected =
+        FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
+    Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
     if (!expected.equals(actual)) {
       throw new ProvisionException(
-          "need index definitions for all schemas: "
-          + expected + " != " + actual);
+          "need index definitions for all schemas: " + expected + " != " + actual);
     }
     return result;
   }
 
   @Provides
   @Singleton
-  AccountIndexer getAccountIndexer(AccountIndexerImpl.Factory factory,
-      AccountIndexCollection indexes) {
+  AccountIndexer getAccountIndexer(
+      AccountIndexerImpl.Factory factory, AccountIndexCollection indexes) {
     return factory.create(indexes);
   }
 
@@ -151,10 +162,15 @@
 
   @Provides
   @Singleton
+  GroupIndexer getGroupIndexer(GroupIndexerImpl.Factory factory, GroupIndexCollection indexes) {
+    return factory.create(indexes);
+  }
+
+  @Provides
+  @Singleton
   @IndexExecutor(INTERACTIVE)
   ListeningExecutorService getInteractiveIndexExecutor(
-      @GerritServerConfig Config config,
-      WorkQueue workQueue) {
+      @GerritServerConfig Config config, WorkQueue workQueue) {
     if (interactiveExecutor != null) {
       return interactiveExecutor;
     }
@@ -166,23 +182,49 @@
       threads = Runtime.getRuntime().availableProcessors() / 2 + 1;
     }
     return MoreExecutors.listeningDecorator(
-        workQueue.createQueue(threads, "Index-Interactive"));
+        workQueue.createQueue(threads, "Index-Interactive", true));
   }
 
   @Provides
   @Singleton
   @IndexExecutor(BATCH)
   ListeningExecutorService getBatchIndexExecutor(
-      @GerritServerConfig Config config,
-      WorkQueue workQueue) {
+      @GerritServerConfig Config config, WorkQueue workQueue) {
     if (batchExecutor != null) {
       return batchExecutor;
     }
-    int threads = config.getInt("index", null, "batchThreads", 0);
-    if (threads <= 0) {
-      threads = Runtime.getRuntime().availableProcessors();
+    int batchThreads = this.threads;
+    if (batchThreads <= 0) {
+      batchThreads = config.getInt("index", null, "batchThreads", 0);
+    }
+    if (batchThreads <= 0) {
+      batchThreads = Runtime.getRuntime().availableProcessors();
     }
     return MoreExecutors.listeningDecorator(
-        workQueue.createQueue(threads, "Index-Batch"));
+        workQueue.createQueue(batchThreads, "Index-Batch", true));
+  }
+
+  @Singleton
+  private static class ShutdownIndexExecutors implements LifecycleListener {
+    private final ListeningExecutorService interactiveExecutor;
+    private final ListeningExecutorService batchExecutor;
+
+    @Inject
+    ShutdownIndexExecutors(
+        @IndexExecutor(INTERACTIVE) ListeningExecutorService interactiveExecutor,
+        @IndexExecutor(BATCH) ListeningExecutorService batchExecutor) {
+      this.interactiveExecutor = interactiveExecutor;
+      this.batchExecutor = batchExecutor;
+    }
+
+    @Override
+    public void start() {}
+
+    @Override
+    public void stop() {
+      MoreExecutors.shutdownAndAwaitTermination(
+          interactiveExecutor, Long.MAX_VALUE, TimeUnit.SECONDS);
+      MoreExecutors.shutdownAndAwaitTermination(batchExecutor, Long.MAX_VALUE, TimeUnit.SECONDS);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
index 276c52b..5c1d838 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
@@ -19,6 +19,5 @@
 
 public interface IndexRewriter<T> {
 
-  Predicate<T> rewrite(Predicate<T> in, QueryOptions opts)
-      throws QueryParseException;
+  Predicate<T> rewrite(Predicate<T> in, QueryOptions opts) throws QueryParseException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
new file mode 100644
index 0000000..7000e04
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.group.GroupField;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public final class IndexUtils {
+  public static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
+      ImmutableMap.of("_", " ", ".", " ");
+
+  public static void setReady(SitePaths sitePaths, String name, int version, boolean ready)
+      throws IOException {
+    try {
+      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
+      cfg.setReady(name, version, ready);
+      cfg.save();
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    }
+  }
+
+  public static boolean getReady(SitePaths sitePaths, String name, int version) throws IOException {
+    try {
+      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
+      return cfg.getReady(name, version);
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    }
+  }
+
+  public static Set<String> accountFields(QueryOptions opts) {
+    Set<String> fs = opts.fields();
+    return fs.contains(AccountField.ID.getName())
+        ? fs
+        : Sets.union(fs, ImmutableSet.of(AccountField.ID.getName()));
+  }
+
+  public static Set<String> changeFields(QueryOptions opts) {
+    // Ensure we request enough fields to construct a ChangeData. We need both
+    // change ID and project, which can either come via the Change field or
+    // separate fields.
+    Set<String> fs = opts.fields();
+    if (fs.contains(CHANGE.getName())) {
+      // A Change is always sufficient.
+      return fs;
+    }
+    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
+      return fs;
+    }
+    return Sets.union(fs, ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
+  }
+
+  public static Set<String> groupFields(QueryOptions opts) {
+    Set<String> fs = opts.fields();
+    return fs.contains(GroupField.UUID.getName())
+        ? fs
+        : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
+  }
+
+  private IndexUtils() {
+    // hide default constructor
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
index 65097b4..b8f21f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
@@ -22,31 +22,29 @@
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
-
 import java.util.Collection;
 import java.util.List;
 
 /**
- * Wrapper combining an {@link IndexPredicate} together with a
- * {@link DataSource} that returns matching results from the index.
- * <p>
- * Appropriate to return as the rootmost predicate that can be processed using
- * the secondary index; such predicates must also implement {@link DataSource}
- * to be chosen by the query processor.
+ * Wrapper combining an {@link IndexPredicate} together with a {@link DataSource} that returns
+ * matching results from the index.
+ *
+ * <p>Appropriate to return as the rootmost predicate that can be processed using the secondary
+ * index; such predicates must also implement {@link DataSource} to be chosen by the query
+ * processor.
  *
  * @param <I> The type of the IDs by which the entities are stored in the index.
  * @param <T> The type of the entities that are stored in the index.
  */
-public class IndexedQuery<I, T> extends Predicate<T>
-    implements DataSource<T>, Paginated<T> {
+public class IndexedQuery<I, T> extends Predicate<T> implements DataSource<T>, Paginated<T> {
   protected final Index<I, T> index;
 
   private QueryOptions opts;
   private final Predicate<T> pred;
   protected DataSource<T> source;
 
-  public IndexedQuery(Index<I, T> index, Predicate<T> pred,
-      QueryOptions opts) throws QueryParseException {
+  public IndexedQuery(Index<I, T> index, Predicate<T> pred, QueryOptions opts)
+      throws QueryParseException {
     this.index = index;
     this.opts = opts;
     this.pred = pred;
@@ -118,15 +116,11 @@
       return false;
     }
     IndexedQuery<?, ?> o = (IndexedQuery<?, ?>) other;
-    return pred.equals(o.pred)
-        && opts.equals(o.opts);
+    return pred.equals(o.pred) && opts.equals(o.opts);
   }
 
   @Override
   public String toString() {
-    return MoreObjects.toStringHelper("index")
-        .add("p", pred)
-        .add("opts", opts)
-        .toString();
+    return MoreObjects.toStringHelper("index").add("p", pred).add("opts", opts).toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
index e62a685..5832694 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
@@ -22,8 +22,8 @@
 public abstract class IntegerRangePredicate<T> extends IndexPredicate<T> {
   private final Range range;
 
-  protected IntegerRangePredicate(FieldDef<T, Integer> type,
-      String value) throws QueryParseException {
+  protected IntegerRangePredicate(FieldDef<T, Integer> type, String value)
+      throws QueryParseException {
     super(type, value);
     range = RangeUtil.getRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE);
     if (range == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
index 133d78b..e40015a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
@@ -17,17 +17,14 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.collect.Lists;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicBoolean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class OnlineReindexer<K, V, I extends Index<K, V>> {
-  private static final Logger log = LoggerFactory
-      .getLogger(OnlineReindexer.class);
+  private static final Logger log = LoggerFactory.getLogger(OnlineReindexer.class);
 
   private final IndexCollection<K, V, I> indexes;
   private final SiteIndexer<K, V, I> batchIndexer;
@@ -35,9 +32,7 @@
   private I index;
   private final AtomicBoolean running = new AtomicBoolean();
 
-  public OnlineReindexer(
-      IndexDefinition<K, V, I> def,
-      int version) {
+  public OnlineReindexer(IndexDefinition<K, V, I> def, int version) {
     this.indexes = def.getIndexCollection();
     this.batchIndexer = def.getSiteIndexer();
     this.version = version;
@@ -45,18 +40,18 @@
 
   public void start() {
     if (running.compareAndSet(false, true)) {
-      Thread t = new Thread() {
-        @Override
-        public void run() {
-          try {
-            reindex();
-          } finally {
-            running.set(false);
-          }
-        }
-      };
-      t.setName(String.format("Reindex v%d-v%d",
-          version(indexes.getSearchIndex()), version));
+      Thread t =
+          new Thread() {
+            @Override
+            public void run() {
+              try {
+                reindex();
+              } finally {
+                running.set(false);
+              }
+            }
+          };
+      t.setName(String.format("Reindex v%d-v%d", version(indexes.getSearchIndex()), version));
       t.start();
     }
   }
@@ -74,15 +69,21 @@
   }
 
   private void reindex() {
-    index = checkNotNull(indexes.getWriteIndex(version),
-        "not an active write schema version: %s", version);
-    log.info("Starting online reindex from schema version {} to {}",
-        version(indexes.getSearchIndex()), version(index));
+    index =
+        checkNotNull(
+            indexes.getWriteIndex(version), "not an active write schema version: %s", version);
+    log.info(
+        "Starting online reindex from schema version {} to {}",
+        version(indexes.getSearchIndex()),
+        version(index));
     SiteIndexer.Result result = batchIndexer.indexAll(index);
     if (!result.success()) {
-      log.error("Online reindex of schema version {} failed. Successfully"
-          + " indexed {} changes, failed to index {} changes",
-          version(index), result.doneCount(), result.failedCount());
+      log.error(
+          "Online reindex of schema version {} failed. Successfully"
+              + " indexed {} changes, failed to index {} changes",
+          version(index),
+          result.doneCount(),
+          result.failedCount());
       return;
     }
     log.info("Reindex to version {} complete", version(index));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
index d0c2095..a26b0ac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
@@ -19,17 +19,14 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
-
 import java.util.Set;
 
 @AutoValue
 public abstract class QueryOptions {
-  public static QueryOptions create(IndexConfig config, int start, int limit,
-      Set<String> fields) {
+  public static QueryOptions create(IndexConfig config, int start, int limit, Set<String> fields) {
     checkArgument(start >= 0, "start must be nonnegative: %s", start);
     checkArgument(limit > 0, "limit must be positive: %s", limit);
-    return new AutoValue_QueryOptions(config, start, limit,
-        ImmutableSet.copyOf(fields));
+    return new AutoValue_QueryOptions(config, start, limit, ImmutableSet.copyOf(fields));
   }
 
   public QueryOptions convertForBackend() {
@@ -42,8 +39,11 @@
   }
 
   public abstract IndexConfig config();
+
   public abstract int start();
+
   public abstract int limit();
+
   public abstract ImmutableSet<String> fields();
 
   public QueryOptions withLimit(int newLimit) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java
new file mode 100644
index 0000000..8bf99a5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java
@@ -0,0 +1,24 @@
+// 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.index;
+
+public class ReindexerAlreadyRunningException extends Exception {
+
+  private static final long serialVersionUID = 1L;
+
+  public ReindexerAlreadyRunningException() {
+    super("Reindexer is already running.");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
index 10f5ecb..c7e5755 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
@@ -18,21 +18,19 @@
 
 import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Optional;
 import com.google.common.base.Predicates;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gwtorm.server.OrmException;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Specific version of a secondary index schema. */
 public class Schema<T> {
@@ -81,10 +79,8 @@
     }
   }
 
-  private static <T> FieldDef<T, ?> checkSame(FieldDef<T, ?> f1,
-      FieldDef<T, ?> f2) {
-    checkState(f1 == f2, "Mismatched %s fields: %s != %s",
-        f1.getName(), f1, f2);
+  private static <T> FieldDef<T, ?> checkSame(FieldDef<T, ?> f1, FieldDef<T, ?> f2) {
+    checkState(f1 == f2, "Mismatched %s fields: %s != %s", f1.getName(), f1, f2);
     return f1;
   }
 
@@ -117,10 +113,10 @@
 
   /**
    * Get all fields in this schema.
-   * <p>
-   * This is primarily useful for iteration. Most callers should prefer one
-   * of the helper methods {@link #getField(FieldDef, FieldDef...)} or {@link
-   * #hasField(FieldDef)} to looking up fields by name
+   *
+   * <p>This is primarily useful for iteration. Most callers should prefer one of the helper methods
+   * {@link #getField(FieldDef, FieldDef...)} or {@link #hasField(FieldDef)} to looking up fields by
+   * name
    *
    * @return all fields in this schema indexed by name.
    */
@@ -128,10 +124,7 @@
     return fields;
   }
 
-  /**
-   * @return all fields in this schema where {@link FieldDef#isStored()} is
-   *     true.
-   */
+  /** @return all fields in this schema where {@link FieldDef#isStored()} is true. */
   public final ImmutableMap<String, FieldDef<T, ?>> getStoredFields() {
     return storedFields;
   }
@@ -141,23 +134,22 @@
    *
    * @param first the preferred field to look up.
    * @param rest additional fields to look up.
-   * @return the first field in the schema matching {@code first} or {@code
-   *     rest}, in order, or absent if no field matches.
+   * @return the first field in the schema matching {@code first} or {@code rest}, in order, or
+   *     absent if no field matches.
    */
   @SafeVarargs
-  public final Optional<FieldDef<T, ?>> getField(FieldDef<T, ?> first,
-      FieldDef<T, ?>... rest) {
+  public final Optional<FieldDef<T, ?>> getField(FieldDef<T, ?> first, FieldDef<T, ?>... rest) {
     FieldDef<T, ?> field = fields.get(first.getName());
     if (field != null) {
-      return Optional.<FieldDef<T, ?>> of(checkSame(field, first));
+      return Optional.of(checkSame(field, first));
     }
     for (FieldDef<T, ?> f : rest) {
       field = fields.get(f.getName());
       if (field != null) {
-        return Optional.<FieldDef<T, ?>> of(checkSame(field, f));
+        return Optional.of(checkSame(field, f));
       }
     }
-    return Optional.absent();
+    return Optional.empty();
   }
 
   /**
@@ -177,44 +169,41 @@
 
   /**
    * Build all fields in the schema from an input object.
-   * <p>
-   * Null values are omitted, as are fields which cause errors, which are
-   * logged.
+   *
+   * <p>Null values are omitted, as are fields which cause errors, which are logged.
    *
    * @param obj input object.
    * @param fillArgs arguments for filling fields.
    * @return all non-null field values from the object.
    */
-  public final Iterable<Values<T>> buildFields(
-      final T obj, final FillArgs fillArgs) {
+  public final Iterable<Values<T>> buildFields(final T obj, final FillArgs fillArgs) {
     return FluentIterable.from(fields.values())
-        .transform(new Function<FieldDef<T, ?>, Values<T>>() {
-          @Override
-          public Values<T> apply(FieldDef<T, ?> f) {
-            Object v;
-            try {
-              v = f.get(obj, fillArgs);
-            } catch (OrmException e) {
-              log.error(String.format("error getting field %s of %s",
-                  f.getName(), obj), e);
-              return null;
-            }
-            if (v == null) {
-              return null;
-            } else if (f.isRepeatable()) {
-              return new Values<>(f, (Iterable<?>) v);
-            } else {
-              return new Values<>(f, Collections.singleton(v));
-            }
-          }
-        }).filter(Predicates.notNull());
+        .transform(
+            new Function<FieldDef<T, ?>, Values<T>>() {
+              @Override
+              public Values<T> apply(FieldDef<T, ?> f) {
+                Object v;
+                try {
+                  v = f.get(obj, fillArgs);
+                } catch (OrmException e) {
+                  log.error("error getting field {} of {}", f.getName(), obj, e);
+                  return null;
+                }
+                if (v == null) {
+                  return null;
+                } else if (f.isRepeatable()) {
+                  return new Values<>(f, (Iterable<?>) v);
+                } else {
+                  return new Values<>(f, Collections.singleton(v));
+                }
+              }
+            })
+        .filter(Predicates.notNull());
   }
 
   @Override
   public String toString() {
-    return MoreObjects.toStringHelper(this)
-        .addValue(fields.keySet())
-        .toString();
+    return MoreObjects.toStringHelper(this).addValue(fields.keySet()).toString();
   }
 
   public void setVersion(int version) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java
index f9a799e..2bcf03a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java
@@ -21,12 +21,11 @@
 
 /**
  * Definitions of the various schema versions over a given Gerrit data type.
- * <p>
- * A <em>schema</em> is a description of the fields that are indexed over the
- * given data type. This class contains all the versions of a schema defined
- * over its data type, exposed as a map of version number to schema definition.
- * If you are interested in the classes responsible for backend-specific runtime
- * implementations, see the implementations of {@link IndexDefinition}.
+ *
+ * <p>A <em>schema</em> is a description of the fields that are indexed over the given data type.
+ * This class contains all the versions of a schema defined over its data type, exposed as a map of
+ * version number to schema definition. If you are interested in the classes responsible for
+ * backend-specific runtime implementations, see the implementations of {@link IndexDefinition}.
  */
 public abstract class SchemaDefinitions<V> {
   private final String name;
@@ -47,8 +46,7 @@
 
   public final Schema<V> get(int version) {
     Schema<V> schema = schemas.get(version);
-    checkArgument(schema != null,
-        "Unrecognized %s schema version: %s", name, version);
+    checkArgument(schema != null, "Unrecognized %s schema version: %s", name, version);
     return schema;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java
index ca61b00..ea33190 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java
@@ -22,9 +22,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.Iterables;
-
-import org.eclipse.jgit.lib.PersonIdent;
-
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.ParameterizedType;
@@ -32,8 +29,10 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
 
 public class SchemaUtil {
   public static <V> ImmutableSortedMap<Integer, Schema<V>> schemasFromClass(
@@ -72,8 +71,7 @@
   }
 
   @SafeVarargs
-  public static <V> Schema<V> schema(Schema<V> schema,
-      FieldDef<V, ?>... moreFields) {
+  public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
     return new Schema<>(
         new ImmutableList.Builder<FieldDef<V, ?>>()
             .addAll(schema.getFields().values())
@@ -90,31 +88,31 @@
     if (person == null) {
       return ImmutableSet.of();
     }
-    return getPersonParts(
-        person.getName(),
-        Collections.singleton(person.getEmailAddress()));
+    return getNameParts(person.getName(), Collections.singleton(person.getEmailAddress()));
   }
 
-  public static Set<String> getPersonParts(String name,
-      Iterable<String> emails) {
+  public static Set<String> getNameParts(String name) {
+    return getNameParts(name, Collections.emptySet());
+  }
+
+  public static Set<String> getNameParts(String name, Iterable<String> emails) {
     Splitter at = Splitter.on('@');
-    Splitter s = Splitter.on(CharMatcher.anyOf("@.- ")).omitEmptyStrings();
+    Splitter s = Splitter.on(CharMatcher.anyOf("@.- /_")).omitEmptyStrings();
     HashSet<String> parts = new HashSet<>();
     for (String email : emails) {
       if (email == null) {
         continue;
       }
-      String lowerEmail = email.toLowerCase();
+      String lowerEmail = email.toLowerCase(Locale.US);
       parts.add(lowerEmail);
       Iterables.addAll(parts, at.split(lowerEmail));
       Iterables.addAll(parts, s.split(lowerEmail));
     }
     if (name != null) {
-      Iterables.addAll(parts, s.split(name.toLowerCase()));
+      Iterables.addAll(parts, s.split(name.toLowerCase(Locale.US)));
     }
     return parts;
   }
 
-  private SchemaUtil() {
-  }
+  private SchemaUtil() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
new file mode 100644
index 0000000..bf28d7d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+import com.google.inject.util.Providers;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class SingleVersionModule extends LifecycleModule {
+  public static final String SINGLE_VERSIONS = "IndexModule/SingleVersions";
+
+  private final Map<String, Integer> singleVersions;
+
+  public SingleVersionModule(Map<String, Integer> singleVersions) {
+    this.singleVersions = singleVersions;
+  }
+
+  @Override
+  public void configure() {
+    listener().to(SingleVersionListener.class);
+    bind(new TypeLiteral<Map<String, Integer>>() {})
+        .annotatedWith(Names.named(SINGLE_VERSIONS))
+        .toProvider(Providers.of(singleVersions));
+  }
+
+  @Singleton
+  public static class SingleVersionListener implements LifecycleListener {
+    private final Set<String> disabled;
+    private final Collection<IndexDefinition<?, ?, ?>> defs;
+    private final Map<String, Integer> singleVersions;
+
+    @Inject
+    SingleVersionListener(
+        @GerritServerConfig Config cfg,
+        Collection<IndexDefinition<?, ?, ?>> defs,
+        @Named(SINGLE_VERSIONS) Map<String, Integer> singleVersions) {
+      this.defs = defs;
+      this.singleVersions = singleVersions;
+
+      disabled = ImmutableSet.copyOf(cfg.getStringList("index", null, "testDisable"));
+    }
+
+    @Override
+    public void start() {
+      for (IndexDefinition<?, ?, ?> def : defs) {
+        start(def);
+      }
+    }
+
+    private <K, V, I extends Index<K, V>> void start(IndexDefinition<K, V, I> def) {
+      if (disabled.contains(def.getName())) {
+        return;
+      }
+      Schema<V> schema;
+      Integer v = singleVersions.get(def.getName());
+      if (v == null) {
+        schema = def.getLatest();
+      } else {
+        schema = def.getSchemas().get(v);
+        if (schema == null) {
+          throw new ProvisionException(
+              String.format("Unrecognized %s schema version: %s", def.getName(), v));
+        }
+      }
+      I index = def.getIndexFactory().create(schema);
+      def.getIndexCollection().setSearchIndex(index);
+      def.getIndexCollection().addWriteIndex(index);
+    }
+
+    @Override
+    public void stop() {
+      // Do nothing; indexes are closed by IndexCollection.
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java
index 8ee1ced..c13209f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java
@@ -15,21 +15,21 @@
 package com.google.gerrit.server.index;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
-
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.util.io.NullOutputStream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.OutputStream;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.util.io.NullOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public abstract class SiteIndexer<K, V, I extends Index<K, V>> {
   private static final Logger log = LoggerFactory.getLogger(SiteIndexer.class);
@@ -66,8 +66,7 @@
 
   protected int totalWork = -1;
   protected OutputStream progressOut = NullOutputStream.INSTANCE;
-  protected PrintWriter verboseWriter =
-      new PrintWriter(NullOutputStream.INSTANCE);
+  protected PrintWriter verboseWriter = newPrintWriter(NullOutputStream.INSTANCE);
 
   public void setTotalWork(int num) {
     totalWork = num;
@@ -78,16 +77,19 @@
   }
 
   public void setVerboseOut(OutputStream out) {
-    verboseWriter = new PrintWriter(checkNotNull(out));
+    verboseWriter = newPrintWriter(checkNotNull(out));
   }
 
   public abstract Result indexAll(I index);
 
-  protected final void addErrorListener(ListenableFuture<?> future,
-      String desc, ProgressMonitor progress, AtomicBoolean ok) {
+  protected final void addErrorListener(
+      ListenableFuture<?> future, String desc, ProgressMonitor progress, AtomicBoolean ok) {
     future.addListener(
-        new ErrorListener(future, desc, progress, ok),
-        MoreExecutors.directExecutor());
+        new ErrorListener(future, desc, progress, ok), MoreExecutors.directExecutor());
+  }
+
+  protected PrintWriter newPrintWriter(OutputStream out) {
+    return new PrintWriter(new OutputStreamWriter(out, UTF_8));
   }
 
   private static class ErrorListener implements Runnable {
@@ -96,8 +98,8 @@
     private final ProgressMonitor progress;
     private final AtomicBoolean ok;
 
-    private ErrorListener(ListenableFuture<?> future, String desc,
-        ProgressMonitor progress, AtomicBoolean ok) {
+    private ErrorListener(
+        ListenableFuture<?> future, String desc, ProgressMonitor progress, AtomicBoolean ok) {
       this.future = future;
       this.desc = desc;
       this.progress = progress;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
index 1e2e80b..7e194b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtjsonrpc.common.JavaSqlTimestampHelper;
-
 import java.sql.Timestamp;
 import java.util.Date;
 
@@ -31,11 +30,11 @@
     }
   }
 
-  protected TimestampRangePredicate(FieldDef<I, Timestamp> def,
-      String name, String value) {
+  protected TimestampRangePredicate(FieldDef<I, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
   public abstract Date getMinTimestamp();
+
   public abstract Date getMaxTimestamp();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index 824739e..96aec3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,154 +14,81 @@
 
 package com.google.gerrit.server.index.account;
 
-import com.google.common.base.Function;
+import static com.google.gerrit.server.index.FieldDef.exact;
+import static com.google.gerrit.server.index.FieldDef.integer;
+import static com.google.gerrit.server.index.FieldDef.prefix;
+import static com.google.gerrit.server.index.FieldDef.timestamp;
+
 import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.FieldType;
 import com.google.gerrit.server.index.SchemaUtil;
-
 import java.sql.Timestamp;
 import java.util.Collections;
+import java.util.Locale;
 import java.util.Set;
 
 /** Secondary index schemas for accounts. */
 public class AccountField {
   public static final FieldDef<AccountState, Integer> ID =
-      new FieldDef.Single<AccountState, Integer>(
-          "id", FieldType.INTEGER, true) {
-        @Override
-        public Integer get(AccountState input, FillArgs args) {
-          return input.getAccount().getId().get();
-        }
-      };
+      integer("id").stored().build(a -> a.getAccount().getId().get());
 
   public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
-      new FieldDef.Repeatable<AccountState, String>(
-          "external_id", FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(AccountState input, FillArgs args) {
-          return Iterables.transform(
-              input.getExternalIds(),
-              new Function<AccountExternalId, String>() {
-                @Override
-                public String apply(AccountExternalId in) {
-                  return in.getKey().get();
-                }
-              });
-        }
-      };
+      exact("external_id")
+          .buildRepeatable(a -> Iterables.transform(a.getExternalIds(), id -> id.key().get()));
 
   /** Fuzzy prefix match on name and email parts. */
   public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
-      new FieldDef.Repeatable<AccountState, String>(
-          "name", FieldType.PREFIX, false) {
-        @Override
-        public Iterable<String> get(AccountState input, FillArgs args) {
-          String fullName = input.getAccount().getFullName();
-          Set<String> parts = SchemaUtil.getPersonParts(
-              fullName,
-              Iterables.transform(
-                  input.getExternalIds(),
-                  new Function<AccountExternalId, String>() {
-                    @Override
-                    public String apply(AccountExternalId in) {
-                      return in.getEmailAddress();
-                    }
-                  }));
+      prefix("name")
+          .buildRepeatable(
+              a -> {
+                String fullName = a.getAccount().getFullName();
+                Set<String> parts =
+                    SchemaUtil.getNameParts(
+                        fullName, Iterables.transform(a.getExternalIds(), ExternalId::email));
 
-          // Additional values not currently added by getPersonParts.
-          // TODO(dborowitz): Move to getPersonParts and remove this hack.
-          if (fullName != null) {
-            parts.add(fullName.toLowerCase());
-          }
-          return parts;
-        }
-      };
+                // Additional values not currently added by getPersonParts.
+                // TODO(dborowitz): Move to getPersonParts and remove this hack.
+                if (fullName != null) {
+                  parts.add(fullName.toLowerCase(Locale.US));
+                }
+                return parts;
+              });
 
   public static final FieldDef<AccountState, String> FULL_NAME =
-      new FieldDef.Single<AccountState, String>("full_name", FieldType.EXACT,
-          false) {
-        @Override
-        public String get(AccountState input, FillArgs args) {
-          return input.getAccount().getFullName();
-        }
-      };
+      exact("full_name").build(a -> a.getAccount().getFullName());
 
   public static final FieldDef<AccountState, String> ACTIVE =
-      new FieldDef.Single<AccountState, String>(
-          "inactive", FieldType.EXACT, false) {
-        @Override
-        public String get(AccountState input, FillArgs args) {
-          return input.getAccount().isActive() ? "1" : "0";
-        }
-      };
+      exact("inactive").build(a -> a.getAccount().isActive() ? "1" : "0");
 
   public static final FieldDef<AccountState, Iterable<String>> EMAIL =
-      new FieldDef.Repeatable<AccountState, String>(
-          "email", FieldType.PREFIX, false) {
-        @Override
-        public Iterable<String> get(AccountState input, FillArgs args) {
-          return FluentIterable.from(input.getExternalIds())
-            .transform(
-                new Function<AccountExternalId, String>() {
-                  @Override
-                  public String apply(AccountExternalId in) {
-                    return in.getEmailAddress();
-                  }
-                })
-            .append(
-                Collections.singleton(input.getAccount().getPreferredEmail()))
-            .filter(Predicates.notNull())
-            .transform(
-                new Function<String, String>() {
-                  @Override
-                  public String apply(String in) {
-                    return in.toLowerCase();
-                  }
-                })
-            .toSet();
-        }
-      };
+      prefix("email")
+          .buildRepeatable(
+              a ->
+                  FluentIterable.from(a.getExternalIds())
+                      .transform(ExternalId::email)
+                      .append(Collections.singleton(a.getAccount().getPreferredEmail()))
+                      .filter(Predicates.notNull())
+                      .transform(String::toLowerCase)
+                      .toSet());
 
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      new FieldDef.Single<AccountState, Timestamp>(
-          "registered", FieldType.TIMESTAMP, false) {
-        @Override
-        public Timestamp get(AccountState input, FillArgs args) {
-          return input.getAccount().getRegisteredOn();
-        }
-      };
+      timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
 
   public static final FieldDef<AccountState, String> USERNAME =
-      new FieldDef.Single<AccountState, String>(
-            "username", FieldType.EXACT, false) {
-        @Override
-        public String get(AccountState input, FillArgs args) {
-          return Strings.nullToEmpty(input.getUserName()).toLowerCase();
-        }
-      };
+      exact("username").build(a -> Strings.nullToEmpty(a.getUserName()).toLowerCase());
 
   public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
-      new FieldDef.Repeatable<AccountState, String>(
-          "watchedproject", FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(AccountState input, FillArgs args) {
-          return FluentIterable.from(input.getProjectWatches().keySet())
-              .transform(new Function<ProjectWatchKey, String>() {
-            @Override
-            public String apply(ProjectWatchKey in) {
-              return in.project().get();
-            }
-          }).toSet();
-        }
-      };
+      exact("watchedproject")
+          .buildRepeatable(
+              a ->
+                  FluentIterable.from(a.getProjectWatches().keySet())
+                      .transform(k -> k.project().get())
+                      .toSet());
 
-  private AccountField() {
-  }
+  private AccountField() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
index cb7b3ef..ffa94ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -18,9 +18,15 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.account.AccountPredicates;
 
 public interface AccountIndex extends Index<Account.Id, AccountState> {
-  public interface Factory extends
-      IndexDefinition.IndexFactory<Account.Id, AccountState, AccountIndex> {
+  public interface Factory
+      extends IndexDefinition.IndexFactory<Account.Id, AccountState, AccountIndex> {}
+
+  @Override
+  default Predicate<AccountState> keyPredicate(Account.Id id) {
+    return AccountPredicates.id(id);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
index 6aa516c..2eb8235 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
@@ -22,10 +22,9 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class AccountIndexCollection extends
-    IndexCollection<Account.Id, AccountState, AccountIndex> {
+public class AccountIndexCollection
+    extends IndexCollection<Account.Id, AccountState, AccountIndex> {
   @Inject
   @VisibleForTesting
-  public AccountIndexCollection() {
-  }
+  public AccountIndexCollection() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
index ea16e13..72f23be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.index.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.IndexDefinition;
 import com.google.inject.Inject;
+import com.google.inject.util.Providers;
 
 public class AccountIndexDefinition
     extends IndexDefinition<Account.Id, AccountState, AccountIndex> {
@@ -26,8 +28,11 @@
   AccountIndexDefinition(
       AccountIndexCollection indexCollection,
       AccountIndex.Factory indexFactory,
-      AllAccountsIndexer allAccountsIndexer) {
-    super(AccountSchemaDefinitions.INSTANCE, indexCollection, indexFactory,
-        allAccountsIndexer);
+      @Nullable AllAccountsIndexer allAccountsIndexer) {
+    super(
+        AccountSchemaDefinitions.INSTANCE,
+        indexCollection,
+        indexFactory,
+        Providers.of(allAccountsIndexer));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index 65e9b09..4fd7833 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -36,8 +36,8 @@
   }
 
   @Override
-  public Predicate<AccountState> rewrite(Predicate<AccountState> in,
-      QueryOptions opts) throws QueryParseException {
+  public Predicate<AccountState> rewrite(Predicate<AccountState> in, QueryOptions opts)
+      throws QueryParseException {
     if (!AccountPredicates.hasActive(in)) {
       in = Predicate.and(in, AccountPredicates.isActive());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java
index 3203563..dd24714 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexer.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.index.account;
 
 import com.google.gerrit.reviewdb.client.Account;
-
 import java.io.IOException;
 
 public interface AccountIndexer {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index 7cdf269..8796360 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.index.Index;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
@@ -32,6 +31,7 @@
 public class AccountIndexerImpl implements AccountIndexer {
   public interface Factory {
     AccountIndexerImpl create(AccountIndexCollection indexes);
+
     AccountIndexerImpl create(@Nullable AccountIndex index);
   }
 
@@ -41,7 +41,8 @@
   private final AccountIndex index;
 
   @AssistedInject
-  AccountIndexerImpl(AccountCache byIdCache,
+  AccountIndexerImpl(
+      AccountCache byIdCache,
       DynamicSet<AccountIndexedListener> indexedListener,
       @Assisted AccountIndexCollection indexes) {
     this.byIdCache = byIdCache;
@@ -51,7 +52,8 @@
   }
 
   @AssistedInject
-  AccountIndexerImpl(AccountCache byIdCache,
+  AccountIndexerImpl(
+      AccountCache byIdCache,
       DynamicSet<AccountIndexedListener> indexedListener,
       @Assisted AccountIndex index) {
     this.byIdCache = byIdCache;
@@ -62,8 +64,13 @@
 
   @Override
   public void index(Account.Id id) throws IOException {
-    for (Index<?, AccountState> i : getWriteIndexes()) {
-      i.replace(byIdCache.get(id));
+    for (Index<Account.Id, AccountState> i : getWriteIndexes()) {
+      AccountState accountState = byIdCache.getOrNull(id);
+      if (accountState != null) {
+        i.replace(accountState);
+      } else {
+        i.delete(id);
+      }
     }
     fireAccountIndexedEvent(id.get());
   }
@@ -79,8 +86,6 @@
       return indexes.getWriteIndexes();
     }
 
-    return index != null
-        ? Collections.singleton(index)
-        : ImmutableSet.<AccountIndex> of();
+    return index != null ? Collections.singleton(index) : ImmutableSet.<AccountIndex>of();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index bebe668..888ee4a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -21,25 +21,27 @@
 import com.google.gerrit.server.index.SchemaDefinitions;
 
 public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
-  static final Schema<AccountState> V1 = schema(
-      AccountField.ID,
-      AccountField.ACTIVE,
-      AccountField.EMAIL,
-      AccountField.EXTERNAL_ID,
-      AccountField.NAME_PART,
-      AccountField.REGISTERED,
-      AccountField.USERNAME);
+  @Deprecated
+  static final Schema<AccountState> V1 =
+      schema(
+          AccountField.ID,
+          AccountField.ACTIVE,
+          AccountField.EMAIL,
+          AccountField.EXTERNAL_ID,
+          AccountField.NAME_PART,
+          AccountField.REGISTERED,
+          AccountField.USERNAME);
 
-  static final Schema<AccountState> V2 =
-      schema(V1, AccountField.WATCHED_PROJECT);
+  @Deprecated static final Schema<AccountState> V2 = schema(V1, AccountField.WATCHED_PROJECT);
 
-  static final Schema<AccountState> V3 =
-      schema(V2, AccountField.FULL_NAME);
+  @Deprecated static final Schema<AccountState> V3 = schema(V2, AccountField.FULL_NAME);
 
-  public static final AccountSchemaDefinitions INSTANCE =
-      new AccountSchemaDefinitions();
+  static final Schema<AccountState> V4 = schema(V3);
+
+  public static final String NAME = "accounts";
+  public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
 
   private AccountSchemaDefinitions() {
-    super("accounts", AccountState.class);
+    super(NAME, AccountState.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 1c008b46..c7c740b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -30,25 +30,20 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
-public class AllAccountsIndexer
-    extends SiteIndexer<Account.Id, AccountState, AccountIndex> {
-  private static final Logger log =
-      LoggerFactory.getLogger(AllAccountsIndexer.class);
+public class AllAccountsIndexer extends SiteIndexer<Account.Id, AccountState, AccountIndex> {
+  private static final Logger log = LoggerFactory.getLogger(AllAccountsIndexer.class);
 
   private final SchemaFactory<ReviewDb> schemaFactory;
   private final ListeningExecutorService executor;
@@ -66,8 +61,7 @@
 
   @Override
   public SiteIndexer.Result indexAll(final AccountIndex index) {
-    ProgressMonitor progress =
-        new TextProgressMonitor(new PrintWriter(progressOut));
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
     List<Account.Id> ids;
@@ -75,13 +69,13 @@
       ids = collectAccounts(progress);
     } catch (OrmException e) {
       log.error("Error collecting accounts", e);
-      return new Result(sw, false, 0, 0);
+      return new SiteIndexer.Result(sw, false, 0, 0);
     }
     return reindexAccounts(index, ids, progress);
   }
 
-  private SiteIndexer.Result reindexAccounts(final AccountIndex index,
-      List<Account.Id> ids, ProgressMonitor progress) {
+  private SiteIndexer.Result reindexAccounts(
+      final AccountIndex index, List<Account.Id> ids, ProgressMonitor progress) {
     progress.beginTask("Reindexing accounts", ids.size());
     List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
     AtomicBoolean ok = new AtomicBoolean(true);
@@ -90,24 +84,23 @@
     Stopwatch sw = Stopwatch.createStarted();
     for (final Account.Id id : ids) {
       final String desc = "account " + id;
-      ListenableFuture<?> future = executor.submit(
-          new Callable<Void>() {
-            @Override
-            public Void call() throws Exception {
-              try {
-                accountCache.evict(id);
-                index.replace(accountCache.get(id));
-                if (verboseWriter != null) {
-                  verboseWriter.println("Reindexed " + desc);
+      ListenableFuture<?> future =
+          executor.submit(
+              new Callable<Void>() {
+                @Override
+                public Void call() throws Exception {
+                  try {
+                    accountCache.evict(id);
+                    index.replace(accountCache.get(id));
+                    verboseWriter.println("Reindexed " + desc);
+                    done.incrementAndGet();
+                  } catch (Exception e) {
+                    failed.incrementAndGet();
+                    throw e;
+                  }
+                  return null;
                 }
-                done.incrementAndGet();
-              } catch (Exception e) {
-                failed.incrementAndGet();
-                throw e;
-              }
-              return null;
-            }
-          });
+              });
       addErrorListener(future, desc, progress, ok);
       futures.add(future);
     }
@@ -116,15 +109,14 @@
       Futures.successfulAsList(futures).get();
     } catch (ExecutionException | InterruptedException e) {
       log.error("Error waiting on account futures", e);
-      return new Result(sw, false, 0, 0);
+      return new SiteIndexer.Result(sw, false, 0, 0);
     }
 
     progress.endTask();
-    return new Result(sw, ok.get(), done.get(), failed.get());
+    return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
   }
 
-  private List<Account.Id> collectAccounts(ProgressMonitor progress)
-      throws OrmException {
+  private List<Account.Id> collectAccounts(ProgressMonitor progress) throws OrmException {
     progress.beginTask("Collecting accounts", ProgressMonitor.UNKNOWN);
     List<Account.Id> ids = new ArrayList<>();
     try (ReviewDb db = schemaFactory.open()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
index 76103fc..d5a2462 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
@@ -14,21 +14,41 @@
 
 package com.google.gerrit.server.index.account;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexedQuery;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Matchable;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
 
 public class IndexedAccountQuery extends IndexedQuery<Account.Id, AccountState>
-    implements DataSource<AccountState> {
+    implements DataSource<AccountState>, Matchable<AccountState> {
 
-  public IndexedAccountQuery(Index<Account.Id, AccountState> index,
-      Predicate<AccountState> pred, QueryOptions opts)
-          throws QueryParseException {
+  public IndexedAccountQuery(
+      Index<Account.Id, AccountState> index, Predicate<AccountState> pred, QueryOptions opts)
+      throws QueryParseException {
     super(index, pred, opts.convertForBackend());
   }
+
+  @Override
+  public boolean match(AccountState accountState) throws OrmException {
+    Predicate<AccountState> pred = getChild(0);
+    checkState(
+        pred.isMatchable(),
+        "match invoked, but child predicate %s doesn't implement %s",
+        pred,
+        Matchable.class.getName());
+    return pred.asMatchable().match(accountState);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index d659215..58969d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -14,76 +14,59 @@
 
 package com.google.gerrit.server.index.change;
 
+import static com.google.common.util.concurrent.Futures.successfulAsList;
+import static com.google.common.util.concurrent.Futures.transform;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
 import com.google.common.base.Stopwatch;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
-import com.google.common.util.concurrent.AsyncFunction;
-import com.google.common.util.concurrent.Futures;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 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.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.SiteIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-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.merge.ThreeWayMergeStrategy;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.SortedSet;
 import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+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;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-public class AllChangesIndexer
-    extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
-  private static final Logger log =
-      LoggerFactory.getLogger(AllChangesIndexer.class);
+public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
+  private static final Logger log = LoggerFactory.getLogger(AllChangesIndexer.class);
 
   private final SchemaFactory<ReviewDb> schemaFactory;
   private final ChangeData.Factory changeDataFactory;
@@ -92,19 +75,16 @@
   private final ChangeIndexer.Factory indexerFactory;
   private final ChangeNotes.Factory notesFactory;
   private final ProjectCache projectCache;
-  private final ThreeWayMergeStrategy mergeStrategy;
-  private final AutoMerger autoMerger;
 
   @Inject
-  AllChangesIndexer(SchemaFactory<ReviewDb> schemaFactory,
+  AllChangesIndexer(
+      SchemaFactory<ReviewDb> schemaFactory,
       ChangeData.Factory changeDataFactory,
       GitRepositoryManager repoManager,
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       ChangeIndexer.Factory indexerFactory,
       ChangeNotes.Factory notesFactory,
-      @GerritServerConfig Config config,
-      ProjectCache projectCache,
-      AutoMerger autoMerger) {
+      ProjectCache projectCache) {
     this.schemaFactory = schemaFactory;
     this.changeDataFactory = changeDataFactory;
     this.repoManager = repoManager;
@@ -112,8 +92,6 @@
     this.indexerFactory = indexerFactory;
     this.notesFactory = notesFactory;
     this.projectCache = projectCache;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(config);
-    this.autoMerger = autoMerger;
   }
 
   private static class ProjectHolder implements Comparable<ProjectHolder> {
@@ -141,56 +119,65 @@
     SortedSet<ProjectHolder> projects = new TreeSet<>();
     int changeCount = 0;
     Stopwatch sw = Stopwatch.createStarted();
+    int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
         int size = ChangeNotes.Factory.scan(repo).size();
         changeCount += size;
         projects.add(new ProjectHolder(name, size));
       } catch (IOException e) {
-        log.error("Error collecting projects", e);
-        return new Result(sw, false, 0, 0);
+        log.error("Error collecting project {}", name, e);
+        projectsFailed++;
+        if (projectsFailed > projects.size() / 2) {
+          log.error("Over 50% of the projects could not be collected: aborted");
+          return new Result(sw, false, 0, 0);
+        }
       }
       pm.update(1);
     }
     pm.endTask();
     setTotalWork(changeCount);
-
     return indexAll(index, projects);
   }
 
-  public SiteIndexer.Result indexAll(ChangeIndex index,
-      Iterable<ProjectHolder> projects) {
+  public SiteIndexer.Result indexAll(ChangeIndex index, Iterable<ProjectHolder> projects) {
     Stopwatch sw = Stopwatch.createStarted();
-    final MultiProgressMonitor mpm =
-        new MultiProgressMonitor(progressOut, "Reindexing changes");
-    final Task projTask = mpm.beginSubTask("projects",
-        (projects instanceof Collection)
-          ? ((Collection<?>) projects).size()
-          : MultiProgressMonitor.UNKNOWN);
-    final Task doneTask = mpm.beginSubTask(null,
-        totalWork >= 0 ? totalWork : MultiProgressMonitor.UNKNOWN);
+    final MultiProgressMonitor mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
+    final Task projTask =
+        mpm.beginSubTask(
+            "projects",
+            (projects instanceof Collection)
+                ? ((Collection<?>) projects).size()
+                : MultiProgressMonitor.UNKNOWN);
+    final Task doneTask =
+        mpm.beginSubTask(null, totalWork >= 0 ? totalWork : MultiProgressMonitor.UNKNOWN);
     final Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
 
     final List<ListenableFuture<?>> futures = new ArrayList<>();
     final AtomicBoolean ok = new AtomicBoolean(true);
 
     for (final ProjectHolder project : projects) {
-      ListenableFuture<?> future = executor.submit(reindexProject(
-          indexerFactory.create(executor, index), project.name, doneTask,
-          failedTask, verboseWriter));
+      ListenableFuture<?> future =
+          executor.submit(
+              reindexProject(
+                  indexerFactory.create(executor, index),
+                  project.name,
+                  doneTask,
+                  failedTask,
+                  verboseWriter));
       addErrorListener(future, "project " + project.name, projTask, ok);
       futures.add(future);
     }
 
     try {
-      mpm.waitFor(Futures.transformAsync(Futures.successfulAsList(futures),
-          new AsyncFunction<List<?>, Void>() {
-            @Override
-            public ListenableFuture<Void> apply(List<?> input) {
-              mpm.end();
-              return Futures.immediateFuture(null);
-            }
-      }));
+      mpm.waitFor(
+          transform(
+              successfulAsList(futures),
+              x -> {
+                mpm.end();
+                return null;
+              },
+              directExecutor()));
     } catch (ExecutionException e) {
       log.error("Error in batch indexer", e);
       ok.set(false);
@@ -199,43 +186,46 @@
     // trust the results. This is not an exact percentage since we bump the same
     // failure counter if a project can't be read, but close enough.
     int nFailed = failedTask.getCount();
-    int nTotal = nFailed + doneTask.getCount();
+    int nDone = doneTask.getCount();
+    int nTotal = nFailed + nDone;
     double pctFailed = ((double) nFailed) / nTotal * 100;
     if (pctFailed > 10) {
-      log.error("Failed {}/{} changes ({}%); not marking new index as ready",
+      log.error(
+          "Failed {}/{} changes ({}%); not marking new index as ready",
           nFailed, nTotal, Math.round(pctFailed));
       ok.set(false);
     }
-    return new Result(sw, ok.get(), doneTask.getCount(), failedTask.getCount());
+    return new Result(sw, ok.get(), nDone, nFailed);
   }
 
-  private Callable<Void> reindexProject(final ChangeIndexer indexer,
-      final Project.NameKey project, final Task done, final Task failed,
+  public Callable<Void> reindexProject(
+      final ChangeIndexer indexer,
+      final Project.NameKey project,
+      final Task done,
+      final Task failed,
       final PrintWriter verboseWriter) {
     return new Callable<Void>() {
       @Override
       public Void call() throws Exception {
-        Multimap<ObjectId, ChangeData> byId = ArrayListMultimap.create();
+        ListMultimap<ObjectId, ChangeData> byId =
+            MultimapBuilder.hashKeys().arrayListValues().build();
         // TODO(dborowitz): Opening all repositories in a live server may be
         // wasteful; see if we can determine which ones it is safe to close
         // with RepositoryCache.close(repo).
         try (Repository repo = repoManager.openRepository(project);
             ReviewDb db = schemaFactory.open()) {
           Map<String, Ref> refs = repo.getRefDatabase().getRefs(ALL);
+          // TODO(dborowitz): Pre-loading all notes is almost certainly a
+          // terrible idea for performance. If we can get rid of walking by
+          // commit (see note below), then all we need to discover here is the
+          // change IDs.
           for (ChangeNotes cn : notesFactory.scan(repo, db, project)) {
             Ref r = refs.get(cn.getChange().currentPatchSetId().toRefName());
             if (r != null) {
               byId.put(r.getObjectId(), changeDataFactory.create(db, cn));
             }
           }
-          new ProjectIndexer(indexer,
-              mergeStrategy,
-              autoMerger,
-              byId,
-              repo,
-              done,
-              failed,
-              verboseWriter).call();
+          new ProjectIndexer(indexer, byId, repo, done, failed, verboseWriter).call();
         } catch (RepositoryNotFoundException rnfe) {
           log.error(rnfe.getMessage());
         }
@@ -251,25 +241,20 @@
 
   private static class ProjectIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
-    private final ThreeWayMergeStrategy mergeStrategy;
-    private final AutoMerger autoMerger;
-    private final Multimap<ObjectId, ChangeData> byId;
+    private final ListMultimap<ObjectId, ChangeData> byId;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
     private final PrintWriter verboseWriter;
     private final Repository repo;
 
-    private ProjectIndexer(ChangeIndexer indexer,
-        ThreeWayMergeStrategy mergeStrategy,
-        AutoMerger autoMerger,
-        Multimap<ObjectId, ChangeData> changesByCommitId,
+    private ProjectIndexer(
+        ChangeIndexer indexer,
+        ListMultimap<ObjectId, ChangeData> changesByCommitId,
         Repository repo,
         ProgressMonitor done,
         ProgressMonitor failed,
         PrintWriter verboseWriter) {
       this.indexer = indexer;
-      this.mergeStrategy = mergeStrategy;
-      this.autoMerger = autoMerger;
       this.byId = changesByCommitId;
       this.repo = repo;
       this.done = done;
@@ -279,8 +264,7 @@
 
     @Override
     public Void call() throws Exception {
-      try (ObjectInserter ins = repo.newObjectInserter();
-          RevWalk walk = new RevWalk(ins.newReader())) {
+      try (RevWalk walk = new RevWalk(repo)) {
         // Walk only refs first to cover as many changes as we can without having
         // to mark every single change.
         for (Ref ref : repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
@@ -293,40 +277,29 @@
         RevCommit bCommit;
         while ((bCommit = walk.next()) != null && !byId.isEmpty()) {
           if (byId.containsKey(bCommit)) {
-            getPathsAndIndex(walk, ins, bCommit);
+            index(bCommit);
             byId.removeAll(bCommit);
           }
         }
 
         for (ObjectId id : byId.keySet()) {
-          getPathsAndIndex(walk, ins, id);
+          index(id);
         }
       }
       return null;
     }
 
-    private void getPathsAndIndex(RevWalk walk, ObjectInserter ins, ObjectId b)
-        throws Exception {
+    private void index(ObjectId b) throws Exception {
       List<ChangeData> cds = Lists.newArrayList(byId.get(b));
-      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        RevCommit bCommit = walk.parseCommit(b);
-        RevTree bTree = bCommit.getTree();
-        RevTree aTree = aFor(bCommit, walk, ins);
-        df.setRepository(repo);
+      try {
         if (!cds.isEmpty()) {
-          List<String> paths = (aTree != null)
-              ? getPaths(df.scan(aTree, bTree))
-              : Collections.<String>emptyList();
           Iterator<ChangeData> cdit = cds.iterator();
-          for (ChangeData cd ; cdit.hasNext(); cdit.remove()) {
+          for (ChangeData cd; cdit.hasNext(); cdit.remove()) {
             cd = cdit.next();
             try {
-              cd.setCurrentFilePaths(paths);
               indexer.index(cd);
               done.update(1);
-              if (verboseWriter != null) {
-                verboseWriter.println("Reindexed change " + cd.getId());
-              }
+              verboseWriter.println("Reindexed change " + cd.getId());
             } catch (Exception e) {
               fail("Failed to index change " + cd.getId(), true, e);
             }
@@ -340,44 +313,6 @@
       }
     }
 
-    private List<String> getPaths(List<DiffEntry> filenames) {
-      Set<String> paths = Sets.newTreeSet();
-      for (DiffEntry e : filenames) {
-        if (e.getOldPath() != null) {
-          paths.add(e.getOldPath());
-        }
-        if (e.getNewPath() != null) {
-          paths.add(e.getNewPath());
-        }
-      }
-      return ImmutableList.copyOf(paths);
-    }
-
-    private RevTree aFor(RevCommit b, RevWalk walk, ObjectInserter ins)
-        throws IOException {
-      switch (b.getParentCount()) {
-        case 0:
-          return walk.parseTree(emptyTree());
-        case 1:
-          RevCommit a = b.getParent(0);
-          walk.parseBody(a);
-          return walk.parseTree(a.getTree());
-        case 2:
-          RevCommit am = autoMerger.merge(repo, walk, ins, b, mergeStrategy);
-          return am == null ? null : am.getTree();
-        default:
-          return null;
-      }
-    }
-
-    private ObjectId emptyTree() throws IOException {
-      try (ObjectInserter oi = repo.newObjectInserter()) {
-        ObjectId id = oi.insert(Constants.OBJ_TREE, new byte[] {});
-        oi.flush();
-        return id;
-      }
-    }
-
     private void fail(String error, boolean failed, Exception e) {
       if (failed) {
         this.failed.update(1);
@@ -389,9 +324,7 @@
         log.warn(error);
       }
 
-      if (verboseWriter != null) {
-        verboseWriter.println(error);
-      }
+      verboseWriter.println(error);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index fe448c6..7a2fb05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -15,11 +15,19 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.index.FieldDef.exact;
+import static com.google.gerrit.server.index.FieldDef.fullText;
+import static com.google.gerrit.server.index.FieldDef.intRange;
+import static com.google.gerrit.server.index.FieldDef.integer;
+import static com.google.gerrit.server.index.FieldDef.prefix;
+import static com.google.gerrit.server.index.FieldDef.storedOnly;
+import static com.google.gerrit.server.index.FieldDef.timestamp;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -28,30 +36,36 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
+import com.google.gerrit.common.data.SubmitRecord;
 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.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.SchemaUtil;
+import com.google.gerrit.server.index.change.StalenessChecker.RefState;
+import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.RobotCommentNotes;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gson.Gson;
 import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
 import com.google.protobuf.CodedOutputStream;
-
-import org.eclipse.jgit.revwalk.FooterLine;
-
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -59,171 +73,73 @@
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
+import org.eclipse.jgit.revwalk.FooterLine;
 
 /**
  * Fields indexed on change documents.
- * <p>
- * Each field corresponds to both a field name supported by
- * {@link ChangeQueryBuilder} for querying that field, and a method on
- * {@link ChangeData} used for populating the corresponding document fields in
- * the secondary index.
- * <p>
- * Field names are all lowercase alphanumeric plus underscore; index
- * implementations may create unambiguous derived field names containing other
- * characters.
+ *
+ * <p>Each field corresponds to both a field name supported by {@link ChangeQueryBuilder} for
+ * querying that field, and a method on {@link ChangeData} used for populating the corresponding
+ * document fields in the secondary index.
+ *
+ * <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create
+ * unambiguous derived field names containing other characters.
  */
 public class ChangeField {
+  public static final int NO_ASSIGNEE = -1;
+
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+
   /** Legacy change ID. */
   public static final FieldDef<ChangeData, Integer> LEGACY_ID =
-      new FieldDef.Single<ChangeData, Integer>("legacy_id",
-          FieldType.INTEGER, true) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args) {
-          return input.getId().get();
-        }
-      };
+      integer("legacy_id").stored().build(cd -> cd.getId().get());
 
   /** Newer style Change-Id key. */
   public static final FieldDef<ChangeData, String> ID =
-      new FieldDef.Single<ChangeData, String>(ChangeQueryBuilder.FIELD_CHANGE_ID,
-          FieldType.PREFIX, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getKey().get();
-        }
-      };
+      prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
 
   /** Change status string, in the same format as {@code status:}. */
   public static final FieldDef<ChangeData, String> STATUS =
-      new FieldDef.Single<ChangeData, String>(ChangeQueryBuilder.FIELD_STATUS,
-          FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return ChangeStatusPredicate.canonicalize(c.getStatus());
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_STATUS)
+          .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
 
   /** Project containing the change. */
   public static final FieldDef<ChangeData, String> PROJECT =
-      new FieldDef.Single<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_PROJECT, FieldType.EXACT, true) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getProject().get();
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_PROJECT)
+          .stored()
+          .build(changeGetter(c -> c.getProject().get()));
 
   /** Project containing the change, as a prefix field. */
   public static final FieldDef<ChangeData, String> PROJECTS =
-      new FieldDef.Single<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_PROJECTS, FieldType.PREFIX, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getProject().get();
-        }
-      };
+      prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get()));
 
   /** Reference (aka branch) the change will submit onto. */
   public static final FieldDef<ChangeData, String> REF =
-      new FieldDef.Single<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_REF, FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getDest().get();
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get()));
 
   /** Topic, a short annotation on the branch. */
   public static final FieldDef<ChangeData, String> EXACT_TOPIC =
-      new FieldDef.Single<ChangeData, String>(
-          "topic4", FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getTopic(input);
-        }
-      };
+      exact("topic4").build(ChangeField::getTopic);
 
   /** Topic, a short annotation on the branch. */
   public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
-      new FieldDef.Single<ChangeData, String>(
-          "topic5", FieldType.FULL_TEXT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getTopic(input);
-        }
-      };
+      fullText("topic5").build(ChangeField::getTopic);
 
   /** Submission id assigned by MergeOp. */
   public static final FieldDef<ChangeData, String> SUBMISSIONID =
-      new FieldDef.Single<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_SUBMISSIONID, FieldType.EXACT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getSubmissionId();
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
 
   /** Last update time since January 1, 1970. */
   public static final FieldDef<ChangeData, Timestamp> UPDATED =
-      new FieldDef.Single<ChangeData, Timestamp>(
-          "updated2", FieldType.TIMESTAMP, true) {
-        @Override
-        public Timestamp get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getLastUpdatedOn();
-        }
-      };
+      timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
 
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
-      new FieldDef.Repeatable<ChangeData, String>(
-          // Named for backwards compatibility.
-          ChangeQueryBuilder.FIELD_FILE, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return firstNonNull(input.currentFilePaths(),
-              ImmutableList.<String> of());
-        }
-      };
+      // Named for backwards compatibility.
+      exact(ChangeQueryBuilder.FIELD_FILE)
+          .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
 
   public static Set<String> getFileParts(ChangeData cd) throws OrmException {
     List<String> paths = cd.currentFilePaths();
@@ -242,103 +158,37 @@
 
   /** Hashtags tied to a change */
   public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_HASHTAG, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.hashtags(),
-              new Function<String, String>() {
-            @Override
-            public String apply(String input) {
-              return input.toLowerCase();
-            }
-          }));
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_HASHTAG)
+          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
 
   /** Hashtags with original case. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
-      new FieldDef.Repeatable<ChangeData, byte[]>(
-          "_hashtag", FieldType.STORED_ONLY, true) {
-        @Override
-        public Iterable<byte[]> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.hashtags(),
-              new Function<String, byte[]>() {
-            @Override
-            public byte[] apply(String hashtag) {
-              return hashtag.getBytes(UTF_8);
-            }
-          }));
-        }
-      };
+      storedOnly("_hashtag")
+          .buildRepeatable(
+              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()));
 
   /** Components of each file path modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_FILEPART, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getFileParts(input);
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
 
   /** Owner/creator of the change. */
   public static final FieldDef<ChangeData, Integer> OWNER =
-      new FieldDef.Single<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_OWNER, FieldType.INTEGER, false) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return null;
-          }
-          return c.getOwner().get();
-        }
-      };
+      integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
 
-  /** Reviewer(s) associated with the change. */
-  @Deprecated
-  public static final FieldDef<ChangeData, Iterable<Integer>> LEGACY_REVIEWER =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_REVIEWER, FieldType.INTEGER, false) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Change c = input.change();
-          if (c == null) {
-            return ImmutableSet.of();
-          }
-          Set<Integer> r = new HashSet<>();
-          if (!args.allowsDrafts && c.getStatus() == Change.Status.DRAFT) {
-            return r;
-          }
-          for (PatchSetApproval a : input.approvals().values()) {
-            r.add(a.getAccountId().get());
-          }
-          return r;
-        }
-      };
+  /** The user assigned to the change. */
+  public static final FieldDef<ChangeData, Integer> ASSIGNEE =
+      integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
+          .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
 
   /** Reviewer(s) associated with the change. */
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
-      new FieldDef.Repeatable<ChangeData, String>(
-          "reviewer2", FieldType.EXACT, true) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getReviewerFieldValues(input.reviewers());
-        }
-      };
+      exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
 
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c
-        : reviewers.asTable().cellSet()) {
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c :
+        reviewers.asTable().cellSet()) {
       String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
       r.add(v);
       r.add(v + ',' + c.getValue().getTime());
@@ -346,8 +196,7 @@
     return r;
   }
 
-  public static String getReviewerFieldValue(ReviewerStateInternal state,
-      Account.Id id) {
+  public static String getReviewerFieldValue(ReviewerStateInternal state, Account.Id id) {
     return state.toString() + ',' + id;
   }
 
@@ -373,25 +222,11 @@
 
   /** Commit ID of any patch set on the change, using prefix match. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_COMMIT, FieldType.PREFIX, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getRevisions(input);
-        }
-      };
+      prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
 
   /** Commit ID of any patch set on the change, using exact match. */
   public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_EXACTCOMMIT, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getRevisions(input);
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
 
   private static Set<String> getRevisions(ChangeData cd) throws OrmException {
     Set<String> revisions = new HashSet<>();
@@ -405,401 +240,438 @@
 
   /** Tracking id extracted from a footer. */
   public static final FieldDef<ChangeData, Iterable<String>> TR =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_TR, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          try {
-            List<FooterLine> footers = input.commitFooters();
-            if (footers == null) {
-              return ImmutableSet.of();
-            }
-            return Sets.newHashSet(
-                args.trackingFooters.extract(footers).values());
-          } catch (IOException e) {
-            throw new OrmException(e);
-          }
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_TR)
+          .buildRepeatable(
+              (ChangeData cd, FillArgs a) -> {
+                List<FooterLine> footers = cd.commitFooters();
+                if (footers == null) {
+                  return ImmutableSet.of();
+                }
+                return Sets.newHashSet(a.trackingFooters.extract(footers).values());
+              });
 
   /** List of labels on the current patch set. */
+  @Deprecated
   public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_LABEL, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<String> allApprovals = new HashSet<>();
-          Set<String> distinctApprovals = new HashSet<>();
-          for (PatchSetApproval a : input.currentApprovals()) {
-            if (a.getValue() != 0 && !a.isLegacySubmit()) {
-              allApprovals.add(formatLabel(a.getLabel(), a.getValue(),
-                  a.getAccountId()));
-              distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
-            }
-          }
-          allApprovals.addAll(distinctApprovals);
-          return allApprovals;
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_LABEL).buildRepeatable(cd -> getLabels(cd, false));
 
-  public static Set<String> getAuthorParts(ChangeData cd) throws OrmException {
-    try {
-      return SchemaUtil.getPersonParts(cd.getAuthor());
-    } catch (IOException e) {
-      throw new OrmException(e);
+  /** List of labels on the current patch set including change owner votes. */
+  public static final FieldDef<ChangeData, Iterable<String>> LABEL2 =
+      exact("label2").buildRepeatable(cd -> getLabels(cd, true));
+
+  private static Iterable<String> getLabels(ChangeData cd, boolean owners) throws OrmException {
+    Set<String> allApprovals = new HashSet<>();
+    Set<String> distinctApprovals = new HashSet<>();
+    for (PatchSetApproval a : cd.currentApprovals()) {
+      if (a.getValue() != 0 && !a.isLegacySubmit()) {
+        allApprovals.add(formatLabel(a.getLabel(), a.getValue(), a.getAccountId()));
+        if (owners && cd.change().getOwner().equals(a.getAccountId())) {
+          allApprovals.add(
+              formatLabel(a.getLabel(), a.getValue(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+        }
+        distinctApprovals.add(formatLabel(a.getLabel(), a.getValue()));
+      }
     }
+    allApprovals.addAll(distinctApprovals);
+    return allApprovals;
   }
 
-  public static Set<String> getCommitterParts(ChangeData cd) throws OrmException {
-    try {
-      return SchemaUtil.getPersonParts(cd.getCommitter());
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
+  public static Set<String> getAuthorParts(ChangeData cd) throws OrmException, IOException {
+    return SchemaUtil.getPersonParts(cd.getAuthor());
+  }
+
+  public static Set<String> getCommitterParts(ChangeData cd) throws OrmException, IOException {
+    return SchemaUtil.getPersonParts(cd.getCommitter());
   }
 
   /**
-   * The exact email address, or any part of the author name or email address,
-   * in the current patch set.
+   * The exact email address, or any part of the author name or email address, in the current patch
+   * set.
    */
   public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_AUTHOR, FieldType.FULL_TEXT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getAuthorParts(input);
-        }
-      };
+      fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
 
   /**
-   * The exact email address, or any part of the committer name or email address,
-   * in the current patch set.
+   * The exact email address, or any part of the committer name or email address, in the current
+   * patch set.
    */
   public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_COMMITTER, FieldType.FULL_TEXT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return getCommitterParts(input);
-        }
-      };
+      fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
 
-  public static class ChangeProtoField extends FieldDef.Single<ChangeData, byte[]> {
-    public static final ProtobufCodec<Change> CODEC =
-        CodecFactory.encoder(Change.class);
-
-    private ChangeProtoField() {
-      super("_change", FieldType.STORED_ONLY, true);
-    }
-
-    @Override
-    public byte[] get(ChangeData input, FieldDef.FillArgs args)
-        throws OrmException {
-      Change c = input.change();
-      if (c == null) {
-        return null;
-      }
-      return CODEC.encodeToByteArray(c);
-    }
-  }
+  public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
 
   /** Serialized change object, used for pre-populating results. */
-  public static final ChangeProtoField CHANGE = new ChangeProtoField();
+  public static final FieldDef<ChangeData, byte[]> CHANGE =
+      storedOnly("_change").build(changeGetter(CHANGE_CODEC::encodeToByteArray));
 
-  public static class PatchSetApprovalProtoField
-      extends FieldDef.Repeatable<ChangeData, byte[]> {
-    public static final ProtobufCodec<PatchSetApproval> CODEC =
-        CodecFactory.encoder(PatchSetApproval.class);
+  public static final ProtobufCodec<PatchSetApproval> APPROVAL_CODEC =
+      CodecFactory.encoder(PatchSetApproval.class);
 
-    private PatchSetApprovalProtoField() {
-      super("_approval", FieldType.STORED_ONLY, true);
-    }
-
-    @Override
-    public Iterable<byte[]> get(ChangeData input, FillArgs args)
-        throws OrmException {
-      return toProtos(CODEC, input.currentApprovals());
-    }
-  }
-
-  /**
-   * Serialized approvals for the current patch set, used for pre-populating
-   * results.
-   */
-  public static final PatchSetApprovalProtoField APPROVAL =
-      new PatchSetApprovalProtoField();
+  /** Serialized approvals for the current patch set, used for pre-populating results. */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
+      storedOnly("_approval")
+          .buildRepeatable(cd -> toProtos(APPROVAL_CODEC, cd.currentApprovals()));
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, null);
   }
 
   public static String formatLabel(String label, int value, Account.Id accountId) {
-    return label.toLowerCase() + (value >= 0 ? "+" : "") + value
-        + (accountId != null ? "," + accountId.get() : "");
+    return label.toLowerCase()
+        + (value >= 0 ? "+" : "")
+        + value
+        + (accountId != null ? "," + formatAccount(accountId) : "");
+  }
+
+  private static String formatAccount(Account.Id accountId) {
+    if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
+      return ChangeQueryBuilder.ARG_ID_OWNER;
+    }
+    return Integer.toString(accountId.get());
   }
 
   /** Commit message of the current patch set. */
   public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
-      new FieldDef.Single<ChangeData, String>(ChangeQueryBuilder.FIELD_MESSAGE,
-          FieldType.FULL_TEXT, false) {
-        @Override
-        public String get(ChangeData input, FillArgs args) throws OrmException {
-          try {
-            return input.commitMessage();
-          } catch (IOException e) {
-            throw new OrmException(e);
-          }
-        }
-      };
+      fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
 
   /** Summary or inline comment. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
-      new FieldDef.Repeatable<ChangeData, String>(ChangeQueryBuilder.FIELD_COMMENT,
-          FieldType.FULL_TEXT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<String> r = new HashSet<>();
-          for (PatchLineComment c : input.publishedComments()) {
-            r.add(c.getMessage());
-          }
-          for (ChangeMessage m : input.messages()) {
-            r.add(m.getMessage());
-          }
-          return r;
-        }
-      };
+      fullText(ChangeQueryBuilder.FIELD_COMMENT)
+          .buildRepeatable(
+              cd -> {
+                Set<String> r = new HashSet<>();
+                for (Comment c : cd.publishedComments()) {
+                  r.add(c.message);
+                }
+                for (ChangeMessage m : cd.messages()) {
+                  r.add(m.getMessage());
+                }
+                return r;
+              });
+
+  /** Number of unresolved comments of the change. */
+  public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
+      intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
+          .stored()
+          .build(ChangeData::unresolvedCommentCount);
 
   /** Whether the change is mergeable. */
   public static final FieldDef<ChangeData, String> MERGEABLE =
-      new FieldDef.Single<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_MERGEABLE, FieldType.EXACT, true) {
-        @Override
-        public String get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Boolean m = input.isMergeable();
-          if (m == null) {
-            return null;
-          }
-          return m ? "1" : "0";
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_MERGEABLE)
+          .stored()
+          .build(
+              cd -> {
+                Boolean m = cd.isMergeable();
+                if (m == null) {
+                  return null;
+                }
+                return m ? "1" : "0";
+              });
 
   /** The number of inserted lines in this change. */
   public static final FieldDef<ChangeData, Integer> ADDED =
-      new FieldDef.Single<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_ADDED, FieldType.INTEGER_RANGE, true) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return input.changedLines().isPresent()
-              ? input.changedLines().get().insertions
-              : null;
-        }
-      };
+      intRange(ChangeQueryBuilder.FIELD_ADDED)
+          .stored()
+          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null);
 
   /** The number of deleted lines in this change. */
   public static final FieldDef<ChangeData, Integer> DELETED =
-      new FieldDef.Single<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_DELETED, FieldType.INTEGER_RANGE, true) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return input.changedLines().isPresent()
-              ? input.changedLines().get().deletions
-              : null;
-        }
-      };
+      intRange(ChangeQueryBuilder.FIELD_DELETED)
+          .stored()
+          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null);
 
   /** The total number of modified lines in this change. */
   public static final FieldDef<ChangeData, Integer> DELTA =
-      new FieldDef.Single<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_DELTA, FieldType.INTEGER_RANGE, false) {
-        @Override
-        public Integer get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Optional<ChangedLines> changedLines = input.changedLines();
-          return changedLines.isPresent()
-              ? changedLines.get().insertions + changedLines.get().deletions
-              : null;
-        }
-      };
+      intRange(ChangeQueryBuilder.FIELD_DELTA)
+          .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
 
   /** Users who have commented on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_COMMENTBY, FieldType.INTEGER, false) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<Integer> r = new HashSet<>();
-          for (ChangeMessage m : input.messages()) {
-            if (m.getAuthor() != null) {
-              r.add(m.getAuthor().get());
-            }
-          }
-          for (PatchLineComment c : input.publishedComments()) {
-            r.add(c.getAuthor().get());
-          }
-          return r;
-        }
-      };
+      integer(ChangeQueryBuilder.FIELD_COMMENTBY)
+          .buildRepeatable(
+              cd -> {
+                Set<Integer> r = new HashSet<>();
+                for (ChangeMessage m : cd.messages()) {
+                  if (m.getAuthor() != null) {
+                    r.add(m.getAuthor().get());
+                  }
+                }
+                for (Comment c : cd.publishedComments()) {
+                  r.add(c.author.getId().get());
+                }
+                return r;
+              });
 
-  /** Users who have starred this change. */
-  @Deprecated
-  public static final FieldDef<ChangeData, Iterable<Integer>> STARREDBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_STARREDBY, FieldType.INTEGER, true) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return Iterables.transform(input.starredBy(),
-              new Function<Account.Id, Integer>() {
-            @Override
-            public Integer apply(Account.Id accountId) {
-              return accountId.get();
-            }
-          });
-        }
-      };
-
-  /**
-   * Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt;
-   */
+  /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
   public static final FieldDef<ChangeData, Iterable<String>> STAR =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_STAR, FieldType.EXACT, true) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return Iterables.transform(input.stars().entries(),
-              new Function<Map.Entry<Account.Id, String>, String>() {
-            @Override
-            public String apply(Map.Entry<Account.Id, String> e) {
-              return StarredChangesUtil.StarField.create(
-                  e.getKey(), e.getValue()).toString();
-            }
-          });
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_STAR)
+          .stored()
+          .buildRepeatable(
+              cd ->
+                  Iterables.transform(
+                      cd.stars().entries(),
+                      e ->
+                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue())
+                              .toString()));
 
   /** Users that have starred the change with any label. */
   public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_STARBY, FieldType.INTEGER, false) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return Iterables.transform(input.stars().keySet(),
-              ReviewDbUtil.INT_KEY_FUNCTION);
-        }
-      };
+      integer(ChangeQueryBuilder.FIELD_STARBY)
+          .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
 
   /** Opaque group identifiers for this change's patch sets. */
   public static final FieldDef<ChangeData, Iterable<String>> GROUP =
-      new FieldDef.Repeatable<ChangeData, String>(
-          ChangeQueryBuilder.FIELD_GROUP, FieldType.EXACT, false) {
-        @Override
-        public Iterable<String> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<String> r = Sets.newHashSetWithExpectedSize(1);
-          for (PatchSet ps : input.patchSets()) {
-            r.addAll(ps.getGroups());
-          }
-          return r;
-        }
-      };
+      exact(ChangeQueryBuilder.FIELD_GROUP)
+          .buildRepeatable(
+              cd -> {
+                Set<String> r = Sets.newHashSetWithExpectedSize(1);
+                for (PatchSet ps : cd.patchSets()) {
+                  r.addAll(ps.getGroups());
+                }
+                return r;
+              });
 
-  public static class PatchSetProtoField
-      extends FieldDef.Repeatable<ChangeData, byte[]> {
-    public static final ProtobufCodec<PatchSet> CODEC =
-        CodecFactory.encoder(PatchSet.class);
-
-    private PatchSetProtoField() {
-      super("_patch_set", FieldType.STORED_ONLY, true);
-    }
-
-    @Override
-    public Iterable<byte[]> get(ChangeData input, FieldDef.FillArgs args)
-        throws OrmException {
-      return toProtos(CODEC, input.patchSets());
-    }
-  }
+  public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
+      CodecFactory.encoder(PatchSet.class);
 
   /** Serialized patch set object, used for pre-populating results. */
-  public static final PatchSetProtoField PATCH_SET = new PatchSetProtoField();
+  public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
+      storedOnly("_patch_set").buildRepeatable(cd -> toProtos(PATCH_SET_CODEC, cd.patchSets()));
 
   /** Users who have edits on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_EDITBY, FieldType.INTEGER, false) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.editsByUser(),
-              new Function<Account.Id, Integer>() {
-            @Override
-            public Integer apply(Account.Id account) {
-              return account.get();
-            }
-          }));
-        }
-      };
-
+      integer(ChangeQueryBuilder.FIELD_EDITBY)
+          .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
 
   /** Users who have draft comments on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_DRAFTBY, FieldType.INTEGER, false) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.draftsByUser(),
-              new Function<Account.Id, Integer>() {
-            @Override
-            public Integer apply(Account.Id account) {
-              return account.get();
-            }
-          }));
-        }
-      };
-
-  /**
-   * Users the change was reviewed by since the last author update.
-   * <p>
-   * A change is considered reviewed by a user if the latest update by that user
-   * is newer than the latest update by the change author. Both top-level change
-   * messages and new patch sets are considered to be updates.
-   * <p>
-   * If the latest update is by the change owner, then the special value {@link
-   * #NOT_REVIEWED} is emitted.
-   */
-  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
-      new FieldDef.Repeatable<ChangeData, Integer>(
-          ChangeQueryBuilder.FIELD_REVIEWEDBY, FieldType.INTEGER, true) {
-        @Override
-        public Iterable<Integer> get(ChangeData input, FillArgs args)
-            throws OrmException {
-          Set<Account.Id> reviewedBy = input.reviewedBy();
-          if (reviewedBy.isEmpty()) {
-            return ImmutableSet.of(NOT_REVIEWED);
-          }
-          List<Integer> result = new ArrayList<>(reviewedBy.size());
-          for (Account.Id id : reviewedBy) {
-            result.add(id.get());
-          }
-          return result;
-        }
-      };
+      integer(ChangeQueryBuilder.FIELD_DRAFTBY)
+          .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
 
   public static final Integer NOT_REVIEWED = -1;
 
-  private static String getTopic(ChangeData input) throws OrmException {
-    Change c = input.change();
+  /**
+   * Users the change was reviewed by since the last author update.
+   *
+   * <p>A change is considered reviewed by a user if the latest update by that user is newer than
+   * the latest update by the change author. Both top-level change messages and new patch sets are
+   * considered to be updates.
+   *
+   * <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is
+   * emitted.
+   */
+  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
+      integer(ChangeQueryBuilder.FIELD_REVIEWEDBY)
+          .stored()
+          .buildRepeatable(
+              cd -> {
+                Set<Account.Id> reviewedBy = cd.reviewedBy();
+                if (reviewedBy.isEmpty()) {
+                  return ImmutableSet.of(NOT_REVIEWED);
+                }
+                List<Integer> result = new ArrayList<>(reviewedBy.size());
+                for (Account.Id id : reviewedBy) {
+                  result.add(id.get());
+                }
+                return result;
+              });
+
+  // Submit rule options in this class should never use fastEvalLabels. This
+  // slows down indexing slightly but produces correct search results.
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
+      SubmitRuleOptions.defaults().allowClosed(true).allowDraft(true).build();
+
+  public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
+      SubmitRuleOptions.defaults().build();
+
+  /**
+   * JSON type for storing SubmitRecords.
+   *
+   * <p>Stored fields need to use a stable format over a long period; this type insulates the index
+   * from implementation changes in SubmitRecord itself.
+   */
+  static class StoredSubmitRecord {
+    static class StoredLabel {
+      String label;
+      SubmitRecord.Label.Status status;
+      Integer appliedBy;
+    }
+
+    SubmitRecord.Status status;
+    List<StoredLabel> labels;
+    String errorMessage;
+
+    StoredSubmitRecord(SubmitRecord rec) {
+      this.status = rec.status;
+      this.errorMessage = rec.errorMessage;
+      if (rec.labels != null) {
+        this.labels = new ArrayList<>(rec.labels.size());
+        for (SubmitRecord.Label label : rec.labels) {
+          StoredLabel sl = new StoredLabel();
+          sl.label = label.label;
+          sl.status = label.status;
+          sl.appliedBy = label.appliedBy != null ? label.appliedBy.get() : null;
+          this.labels.add(sl);
+        }
+      }
+    }
+
+    private SubmitRecord toSubmitRecord() {
+      SubmitRecord rec = new SubmitRecord();
+      rec.status = status;
+      rec.errorMessage = errorMessage;
+      if (labels != null) {
+        rec.labels = new ArrayList<>(labels.size());
+        for (StoredLabel label : labels) {
+          SubmitRecord.Label srl = new SubmitRecord.Label();
+          srl.label = label.label;
+          srl.status = label.status;
+          srl.appliedBy = label.appliedBy != null ? new Account.Id(label.appliedBy) : null;
+          rec.labels.add(srl);
+        }
+      }
+      return rec;
+    }
+  }
+
+  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
+      exact("submit_record").buildRepeatable(cd -> formatSubmitRecordValues(cd));
+
+  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
+      storedOnly("full_submit_record_strict")
+          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT));
+
+  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
+      storedOnly("full_submit_record_lenient")
+          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT));
+
+  public static void parseSubmitRecords(
+      Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
+    checkArgument(!opts.fastEvalLabels());
+    List<SubmitRecord> records = parseSubmitRecords(values);
+    if (records.isEmpty()) {
+      // Assume no values means the field is not in the index;
+      // SubmitRuleEvaluator ensures the list is non-empty.
+      return;
+    }
+    out.setSubmitRecords(opts, records);
+
+    // Cache the fastEvalLabels variant as well so it can be used by
+    // ChangeJson.
+    out.setSubmitRecords(opts.toBuilder().fastEvalLabels(true).build(), records);
+  }
+
+  @VisibleForTesting
+  static List<SubmitRecord> parseSubmitRecords(Collection<String> values) {
+    return values.stream()
+        .map(v -> GSON.fromJson(v, StoredSubmitRecord.class).toSubmitRecord())
+        .collect(toList());
+  }
+
+  @VisibleForTesting
+  static List<byte[]> storedSubmitRecords(List<SubmitRecord> records) {
+    return Lists.transform(records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8));
+  }
+
+  private static Iterable<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts)
+      throws OrmException {
+    return storedSubmitRecords(cd.submitRecords(opts));
+  }
+
+  public static List<String> formatSubmitRecordValues(ChangeData cd) throws OrmException {
+    return formatSubmitRecordValues(
+        cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner());
+  }
+
+  @VisibleForTesting
+  static List<String> formatSubmitRecordValues(List<SubmitRecord> records, Account.Id changeOwner) {
+    List<String> result = new ArrayList<>();
+    for (SubmitRecord rec : records) {
+      result.add(rec.status.name());
+      if (rec.labels == null) {
+        continue;
+      }
+      for (SubmitRecord.Label label : rec.labels) {
+        String sl = label.status.toString() + ',' + label.label.toLowerCase();
+        result.add(sl);
+        String slc = sl + ',';
+        if (label.appliedBy != null) {
+          result.add(slc + label.appliedBy.get());
+          if (label.appliedBy.equals(changeOwner)) {
+            result.add(slc + ChangeQueryBuilder.OWNER_ACCOUNT_ID.get());
+          }
+        }
+      }
+    }
+    return result;
+  }
+
+  /**
+   * All values of all refs that were used in the course of indexing this document.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
+   */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
+      storedOnly("ref_state")
+          .buildRepeatable(
+              (cd, a) -> {
+                List<byte[]> result = new ArrayList<>();
+                Project.NameKey project = cd.change().getProject();
+
+                cd.editRefs()
+                    .values()
+                    .forEach(r -> result.add(RefState.of(r).toByteArray(project)));
+                cd.starRefs()
+                    .values()
+                    .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(a.allUsers)));
+
+                if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
+                  ChangeNotes notes = cd.notes();
+                  result.add(
+                      RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project));
+                  notes.getRobotComments(); // Force loading robot comments.
+                  RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
+                  result.add(
+                      RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
+                          .toByteArray(project));
+                  cd.draftRefs()
+                      .values()
+                      .forEach(r -> result.add(RefState.of(r).toByteArray(a.allUsers)));
+                }
+
+                return result;
+              });
+
+  /**
+   * All ref wildcard patterns that were used in the course of indexing this document.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link
+   * RefStatePattern} for the pattern format.
+   */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
+      storedOnly("ref_state_pattern")
+          .buildRepeatable(
+              (cd, a) -> {
+                Change.Id id = cd.getId();
+                Project.NameKey project = cd.change().getProject();
+                List<byte[]> result = new ArrayList<>(3);
+                result.add(
+                    RefStatePattern.create(
+                            RefNames.REFS_USERS + "*/" + RefNames.EDIT_PREFIX + id + "/*")
+                        .toByteArray(project));
+                result.add(
+                    RefStatePattern.create(RefNames.refsStarredChangesPrefix(id) + "*")
+                        .toByteArray(a.allUsers));
+                if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
+                  result.add(
+                      RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
+                          .toByteArray(a.allUsers));
+                }
+                return result;
+              });
+
+  private static String getTopic(ChangeData cd) throws OrmException {
+    Change c = cd.change();
     if (c == null) {
       return null;
     }
@@ -823,4 +695,8 @@
     }
     return result;
   }
+
+  private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
+    return in -> in.change() != null ? func.apply(in.change()) : null;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 9545c0a..27b0c26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -17,10 +17,16 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.LegacyChangeIdPredicate;
 
 public interface ChangeIndex extends Index<Change.Id, ChangeData> {
-  public interface Factory extends
-      IndexDefinition.IndexFactory<Change.Id, ChangeData, ChangeIndex> {
+  public interface Factory
+      extends IndexDefinition.IndexFactory<Change.Id, ChangeData, ChangeIndex> {}
+
+  @Override
+  default Predicate<ChangeData> keyPredicate(Change.Id id) {
+    return new LegacyChangeIdPredicate(id);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
index dc1c4a5..f8acb74 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
@@ -22,10 +22,8 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class ChangeIndexCollection extends
-    IndexCollection<Change.Id, ChangeData, ChangeIndex> {
+public class ChangeIndexCollection extends IndexCollection<Change.Id, ChangeData, ChangeIndex> {
   @Inject
   @VisibleForTesting
-  public ChangeIndexCollection() {
-  }
+  public ChangeIndexCollection() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
index 9bfd11f..4404298 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
@@ -14,20 +14,24 @@
 
 package com.google.gerrit.server.index.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import com.google.inject.util.Providers;
 
-public class ChangeIndexDefinition
-    extends IndexDefinition<Change.Id, ChangeData, ChangeIndex> {
+public class ChangeIndexDefinition extends IndexDefinition<Change.Id, ChangeData, ChangeIndex> {
 
   @Inject
   ChangeIndexDefinition(
       ChangeIndexCollection indexCollection,
       ChangeIndex.Factory indexFactory,
-      AllChangesIndexer allChangesIndexer) {
-    super(ChangeSchemaDefinitions.INSTANCE, indexCollection, indexFactory,
-        allChangesIndexer);
+      @Nullable AllChangesIndexer allChangesIndexer) {
+    super(
+        ChangeSchemaDefinitions.INSTANCE,
+        indexCollection,
+        indexFactory,
+        Providers.of(allChangesIndexer));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 3523e5f..a9e1362 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -38,13 +38,11 @@
 import com.google.gerrit.server.query.change.OrSource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.util.MutableInteger;
-
 import java.util.BitSet;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.util.MutableInteger;
 
 /** Rewriter that pushes boolean logic into the secondary index. */
 @Singleton
@@ -73,9 +71,8 @@
    * Get the set of statuses that changes matching the given predicate may have.
    *
    * @param in predicate
-   * @return the maximal set of statuses that any changes matching the input
-   *     predicates may have, based on examining boolean and
-   *     {@link ChangeStatusPredicate}s.
+   * @return the maximal set of statuses that any changes matching the input predicates may have,
+   *     based on examining boolean and {@link ChangeStatusPredicate}s.
    */
   public static EnumSet<Change.Status> getPossibleStatus(Predicate<ChangeData> in) {
     EnumSet<Change.Status> s = extractStatus(in);
@@ -129,15 +126,14 @@
   private final IndexConfig config;
 
   @Inject
-  ChangeIndexRewriter(ChangeIndexCollection indexes,
-      IndexConfig config) {
+  ChangeIndexRewriter(ChangeIndexCollection indexes, IndexConfig config) {
     this.indexes = indexes;
     this.config = config;
   }
 
   @Override
-  public Predicate<ChangeData> rewrite(Predicate<ChangeData> in,
-      QueryOptions opts) throws QueryParseException {
+  public Predicate<ChangeData> rewrite(Predicate<ChangeData> in, QueryOptions opts)
+      throws QueryParseException {
     Predicate<ChangeData> s = rewriteImpl(in, opts);
     if (!(s instanceof ChangeDataSource)) {
       in = Predicate.and(open(), in);
@@ -149,8 +145,8 @@
     return s;
   }
 
-  private Predicate<ChangeData> rewriteImpl(Predicate<ChangeData> in,
-      QueryOptions opts) throws QueryParseException {
+  private Predicate<ChangeData> rewriteImpl(Predicate<ChangeData> in, QueryOptions opts)
+      throws QueryParseException {
     ChangeIndex index = indexes.getSearchIndex();
 
     MutableInteger leafTerms = new MutableInteger();
@@ -171,16 +167,15 @@
    * @param index index whose schema determines which fields are indexed.
    * @param opts other query options.
    * @param leafTerms number of leaf index query terms encountered so far.
-   * @return {@code null} if no part of this subtree can be queried in the
-   *     index directly. {@code in} if this subtree and all its children can be
-   *     queried directly in the index. Otherwise, a predicate that is
-   *     semantically equivalent, with some of its subtrees wrapped to query the
+   * @return {@code null} if no part of this subtree can be queried in the index directly. {@code
+   *     in} if this subtree and all its children can be queried directly in the index. Otherwise, a
+   *     predicate that is semantically equivalent, with some of its subtrees wrapped to query the
    *     index directly.
-   * @throws QueryParseException if the underlying index implementation does not
-   *     support this predicate.
+   * @throws QueryParseException if the underlying index implementation does not support this
+   *     predicate.
    */
-  private Predicate<ChangeData> rewriteImpl(Predicate<ChangeData> in,
-      ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
+  private Predicate<ChangeData> rewriteImpl(
+      Predicate<ChangeData> in, ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
       throws QueryParseException {
     if (isIndexPredicate(in, index)) {
       if (++leafTerms.value > config.maxTerms()) {
@@ -226,8 +221,7 @@
     return partitionChildren(in, newChildren, isIndexed, index, opts);
   }
 
-  private boolean isIndexPredicate(Predicate<ChangeData> in,
-      ChangeIndex index) {
+  private boolean isIndexPredicate(Predicate<ChangeData> in, ChangeIndex index) {
     if (!(in instanceof IndexPredicate)) {
       return false;
     }
@@ -240,21 +234,19 @@
       List<Predicate<ChangeData>> newChildren,
       BitSet isIndexed,
       ChangeIndex index,
-      QueryOptions opts) throws QueryParseException {
+      QueryOptions opts)
+      throws QueryParseException {
     if (isIndexed.cardinality() == 1) {
       int i = isIndexed.nextSetBit(0);
-      newChildren.add(
-          0, new IndexedChangeQuery(index, newChildren.remove(i), opts));
+      newChildren.add(0, new IndexedChangeQuery(index, newChildren.remove(i), opts));
       return copy(in, newChildren);
     }
 
     // Group all indexed predicates into a wrapped subtree.
-    List<Predicate<ChangeData>> indexed =
-        Lists.newArrayListWithCapacity(isIndexed.cardinality());
+    List<Predicate<ChangeData>> indexed = Lists.newArrayListWithCapacity(isIndexed.cardinality());
 
     List<Predicate<ChangeData>> all =
-        Lists.newArrayListWithCapacity(
-            newChildren.size() - isIndexed.cardinality() + 1);
+        Lists.newArrayListWithCapacity(newChildren.size() - isIndexed.cardinality() + 1);
 
     for (int i = 0; i < newChildren.size(); i++) {
       Predicate<ChangeData> c = newChildren.get(i);
@@ -268,9 +260,7 @@
     return copy(in, all);
   }
 
-  private Predicate<ChangeData> copy(
-      Predicate<ChangeData> in,
-      List<Predicate<ChangeData>> all) {
+  private Predicate<ChangeData> copy(Predicate<ChangeData> in, List<Predicate<ChangeData>> all) {
     if (in instanceof AndPredicate) {
       return new AndChangeSource(all);
     } else if (in instanceof OrPredicate) {
@@ -280,9 +270,7 @@
   }
 
   private static boolean isRewritePossible(Predicate<ChangeData> p) {
-    return p.getChildCount() > 0 && (
-           p instanceof AndPredicate
-        || p instanceof OrPredicate
-        || p instanceof NotPredicate);
+    return p.getChildCount() > 0
+        && (p instanceof AndPredicate || p instanceof OrPredicate || p instanceof NotPredicate);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index fa4f2fa..6a294ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.index.change;
 
+import static com.google.gerrit.server.extensions.events.EventUtil.logEventListenerError;
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
 import com.google.common.base.Function;
 import com.google.common.util.concurrent.Atomics;
-import com.google.common.util.concurrent.CheckedFuture;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
@@ -26,9 +28,12 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -40,10 +45,6 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import com.google.inject.util.Providers;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -51,25 +52,30 @@
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Helper for (re)indexing a change document.
- * <p>
- * Indexing is run in the background, as it may require substantial work to
- * compute some of the fields and/or update the index.
+ *
+ * <p>Indexing is run in the background, as it may require substantial work to compute some of the
+ * fields and/or update the index.
  */
 public class ChangeIndexer {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeIndexer.class);
+  private static final Logger log = LoggerFactory.getLogger(ChangeIndexer.class);
 
   public interface Factory {
     ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
-    ChangeIndexer create(ListeningExecutorService executor,
-        ChangeIndexCollection indexes);
+
+    ChangeIndexer create(ListeningExecutorService executor, ChangeIndexCollection indexes);
   }
 
-  public static CheckedFuture<?, IOException> allAsList(
+  @SuppressWarnings("deprecation")
+  public static com.google.common.util.concurrent.CheckedFuture<?, IOException> allAsList(
       List<? extends ListenableFuture<?>> futures) {
     // allAsList propagates the first seen exception, wrapped in
     // ExecutionException, so we can reuse the same mapper as for a single
@@ -80,18 +86,17 @@
 
   private static final Function<Exception, IOException> MAPPER =
       new Function<Exception, IOException>() {
-    @Override
-    public IOException apply(Exception in) {
-      if (in instanceof IOException) {
-        return (IOException) in;
-      } else if (in instanceof ExecutionException
-          && in.getCause() instanceof IOException) {
-        return (IOException) in.getCause();
-      } else {
-        return new IOException(in);
-      }
-    }
-  };
+        @Override
+        public IOException apply(Exception in) {
+          if (in instanceof IOException) {
+            return (IOException) in;
+          } else if (in instanceof ExecutionException && in.getCause() instanceof IOException) {
+            return (IOException) in.getCause();
+          } else {
+            return new IOException(in);
+          }
+        }
+      };
 
   private final ChangeIndexCollection indexes;
   private final ChangeIndex index;
@@ -100,16 +105,23 @@
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ThreadLocalRequestContext context;
+  private final ListeningExecutorService batchExecutor;
   private final ListeningExecutorService executor;
-  private final DynamicSet<ChangeIndexedListener> indexedListener;
+  private final DynamicSet<ChangeIndexedListener> indexedListeners;
+  private final StalenessChecker stalenessChecker;
+  private final boolean autoReindexIfStale;
 
   @AssistedInject
-  ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      SchemaFactory<ReviewDb> schemaFactory,
       NotesMigration notesMigration,
       ChangeNotes.Factory changeNotesFactory,
       ChangeData.Factory changeDataFactory,
       ThreadLocalRequestContext context,
-      DynamicSet<ChangeIndexedListener> indexedListener,
+      DynamicSet<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndex index) {
     this.executor = executor;
@@ -118,18 +130,25 @@
     this.changeNotesFactory = changeNotesFactory;
     this.changeDataFactory = changeDataFactory;
     this.context = context;
+    this.indexedListeners = indexedListeners;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = index;
     this.indexes = null;
-    this.indexedListener = indexedListener;
   }
 
   @AssistedInject
-  ChangeIndexer(SchemaFactory<ReviewDb> schemaFactory,
+  ChangeIndexer(
+      SchemaFactory<ReviewDb> schemaFactory,
+      @GerritServerConfig Config cfg,
       NotesMigration notesMigration,
       ChangeNotes.Factory changeNotesFactory,
       ChangeData.Factory changeDataFactory,
       ThreadLocalRequestContext context,
-      DynamicSet<ChangeIndexedListener> indexedListener,
+      DynamicSet<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndexCollection indexes) {
     this.executor = executor;
@@ -138,9 +157,16 @@
     this.changeNotesFactory = changeNotesFactory;
     this.changeDataFactory = changeDataFactory;
     this.context = context;
+    this.indexedListeners = indexedListeners;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = null;
     this.indexes = indexes;
-    this.indexedListener = indexedListener;
+  }
+
+  private static boolean autoReindexIfStale(Config cfg) {
+    return cfg.getBoolean("index", null, "autoReindexIfStale", false);
   }
 
   /**
@@ -149,11 +175,10 @@
    * @param id change to index.
    * @return future for the indexing task.
    */
-  public CheckedFuture<?, IOException> indexAsync(Project.NameKey project,
-      Change.Id id) {
-    return executor != null
-        ? submit(new IndexTask(project, id))
-        : Futures.<Object, IOException> immediateCheckedFuture(null);
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<?, IOException> indexAsync(
+      Project.NameKey project, Change.Id id) {
+    return submit(new IndexTask(project, id));
   }
 
   /**
@@ -162,8 +187,9 @@
    * @param ids changes to index.
    * @return future for completing indexing of all changes.
    */
-  public CheckedFuture<?, IOException> indexAsync(Project.NameKey project,
-      Collection<Change.Id> ids) {
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<?, IOException> indexAsync(
+      Project.NameKey project, Collection<Change.Id> ids) {
     List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
       futures.add(indexAsync(project, id));
@@ -181,17 +207,45 @@
       i.replace(cd);
     }
     fireChangeIndexedEvent(cd.getId().get());
+
+    // Always double-check whether the change might be stale immediately after
+    // interactively indexing it. This fixes up the case where two writers write
+    // to the primary storage in one order, and the corresponding index writes
+    // happen in the opposite order:
+    //  1. Writer A writes to primary storage.
+    //  2. Writer B writes to primary storage.
+    //  3. Writer B updates index.
+    //  4. Writer A updates index.
+    //
+    // Without the extra reindexIfStale step, A has no way of knowing that it's
+    // about to overwrite the index document with stale data. It doesn't work to
+    // have A check for staleness before attempting its index update, because
+    // B's index update might not have happened when it does the check.
+    //
+    // With the extra reindexIfStale step after (3)/(4), we are able to detect
+    // and fix the staleness. It doesn't matter which order the two
+    // reindexIfStale calls actually execute in; we are guaranteed that at least
+    // one of them will execute after the second index write, (4).
+    autoReindexIfStale(cd);
   }
 
   private void fireChangeIndexedEvent(int id) {
-    for (ChangeIndexedListener listener : indexedListener) {
-      listener.onChangeIndexed(id);
+    for (ChangeIndexedListener listener : indexedListeners) {
+      try {
+        listener.onChangeIndexed(id);
+      } catch (Exception e) {
+        logEventListenerError(listener, e);
+      }
     }
   }
 
   private void fireChangeDeletedFromIndexEvent(int id) {
-    for (ChangeIndexedListener listener : indexedListener) {
-      listener.onChangeDeleted(id);
+    for (ChangeIndexedListener listener : indexedListeners) {
+      try {
+        listener.onChangeDeleted(id);
+      } catch (Exception e) {
+        logEventListenerError(listener, e);
+      }
     }
   }
 
@@ -201,9 +255,10 @@
    * @param db review database.
    * @param change change to index.
    */
-  public void index(ReviewDb db, Change change)
-      throws IOException, OrmException {
+  public void index(ReviewDb db, Change change) throws IOException, OrmException {
     index(newChangeData(db, change));
+    // See comment in #index(ChangeData).
+    autoReindexIfStale(change.getProject(), change.getId());
   }
 
   /**
@@ -215,7 +270,10 @@
    */
   public void index(ReviewDb db, Project.NameKey project, Change.Id changeId)
       throws IOException, OrmException {
-    index(newChangeData(db, project, changeId));
+    ChangeData cd = newChangeData(db, project, changeId);
+    index(cd);
+    // See comment in #index(ChangeData).
+    autoReindexIfStale(cd);
   }
 
   /**
@@ -224,10 +282,9 @@
    * @param id change to delete.
    * @return future for the deleting task.
    */
-  public CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
-    return executor != null
-        ? submit(new DeleteTask(id))
-        : Futures.<Object, IOException> immediateCheckedFuture(null);
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
+    return submit(new DeleteTask(id));
   }
 
   /**
@@ -239,61 +296,99 @@
     new DeleteTask(id).call();
   }
 
+  /**
+   * Asynchronously check if a change is stale, and reindex if it is.
+   *
+   * <p>Always run on the batch executor, even if this indexer instance is configured to use a
+   * different executor.
+   *
+   * @param project the project to which the change belongs.
+   * @param id ID of the change to index.
+   * @return future for reindexing the change; returns true if the change was stale.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStale(
+      Project.NameKey project, Change.Id id) {
+    return submit(new ReindexIfStaleTask(project, id), batchExecutor);
+  }
+
+  private void autoReindexIfStale(ChangeData cd) throws IOException {
+    try {
+      autoReindexIfStale(cd.project(), cd.getId());
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+  }
+
+  private void autoReindexIfStale(Project.NameKey project, Change.Id id) {
+    if (autoReindexIfStale) {
+      // Don't retry indefinitely; if this fails the change will be stale.
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = reindexIfStale(project, id);
+    }
+  }
+
   private Collection<ChangeIndex> getWriteIndexes() {
-    return indexes != null
-        ? indexes.getWriteIndexes()
-        : Collections.singleton(index);
+    return indexes != null ? indexes.getWriteIndexes() : Collections.singleton(index);
   }
 
-  private CheckedFuture<?, IOException> submit(Callable<?> task) {
-    return Futures.makeChecked(
-        Futures.nonCancellationPropagating(executor.submit(task)), MAPPER);
+  @SuppressWarnings("deprecation")
+  private <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
+      Callable<T> task) {
+    return submit(task, executor);
   }
 
-  private class IndexTask implements Callable<Void> {
-    private final Project.NameKey project;
-    private final Change.Id id;
+  @SuppressWarnings("deprecation")
+  private static <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
+      Callable<T> task, ListeningExecutorService executor) {
+    return Futures.makeChecked(Futures.nonCancellationPropagating(executor.submit(task)), MAPPER);
+  }
 
-    private IndexTask(Project.NameKey project, Change.Id id) {
+  private abstract class AbstractIndexTask<T> implements Callable<T> {
+    protected final Project.NameKey project;
+    protected final Change.Id id;
+
+    protected AbstractIndexTask(Project.NameKey project, Change.Id id) {
       this.project = project;
       this.id = id;
     }
 
-    @Override
-    public Void call() throws Exception {
-      try {
-        final AtomicReference<Provider<ReviewDb>> dbRef =
-            Atomics.newReference();
-        RequestContext newCtx = new RequestContext() {
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            Provider<ReviewDb> db = dbRef.get();
-            if (db == null) {
-              try {
-                db = Providers.of(schemaFactory.open());
-              } catch (OrmException e) {
-                ProvisionException pe =
-                    new ProvisionException("error opening ReviewDb");
-                pe.initCause(e);
-                throw pe;
-              }
-              dbRef.set(db);
-            }
-            return db;
-          }
+    protected abstract T callImpl(Provider<ReviewDb> db) throws Exception;
 
-          @Override
-          public CurrentUser getUser() {
-            throw new OutOfScopeException("No user during ChangeIndexer");
-          }
-        };
+    @Override
+    public abstract String toString();
+
+    @Override
+    public final T call() throws Exception {
+      try {
+        final AtomicReference<Provider<ReviewDb>> dbRef = Atomics.newReference();
+        RequestContext newCtx =
+            new RequestContext() {
+              @Override
+              public Provider<ReviewDb> getReviewDbProvider() {
+                Provider<ReviewDb> db = dbRef.get();
+                if (db == null) {
+                  try {
+                    db = Providers.of(schemaFactory.open());
+                  } catch (OrmException e) {
+                    ProvisionException pe = new ProvisionException("error opening ReviewDb");
+                    pe.initCause(e);
+                    throw pe;
+                  }
+                  dbRef.set(db);
+                }
+                return db;
+              }
+
+              @Override
+              public CurrentUser getUser() {
+                throw new OutOfScopeException("No user during ChangeIndexer");
+              }
+            };
         RequestContext oldCtx = context.setContext(newCtx);
         try {
-          ChangeData cd = newChangeData(
-              newCtx.getReviewDbProvider().get(), project, id);
-          index(cd);
-          return null;
-        } finally  {
+          return callImpl(newCtx.getReviewDbProvider());
+        } finally {
           context.setContext(oldCtx);
           Provider<ReviewDb> db = dbRef.get();
           if (db != null) {
@@ -301,17 +396,31 @@
           }
         }
       } catch (Exception e) {
-        log.error(String.format("Failed to index change %d", id.get()), e);
+        log.error("Failed to execute " + this, e);
         throw e;
       }
     }
+  }
+
+  private class IndexTask extends AbstractIndexTask<Void> {
+    private IndexTask(Project.NameKey project, Change.Id id) {
+      super(project, id);
+    }
+
+    @Override
+    public Void callImpl(Provider<ReviewDb> db) throws Exception {
+      ChangeData cd = newChangeData(db.get(), project, id);
+      index(cd);
+      return null;
+    }
 
     @Override
     public String toString() {
-      return "index-change-" + id.get();
+      return "index-change-" + id;
     }
   }
 
+  // Not AbstractIndexTask as it doesn't need ReviewDb.
   private class DeleteTask implements Callable<Void> {
     private final Change.Id id;
 
@@ -327,33 +436,73 @@
       for (ChangeIndex i : getWriteIndexes()) {
         i.delete(id);
       }
+      log.info("Deleted change {} from index.", id.get());
       fireChangeDeletedFromIndexEvent(id.get());
       return null;
     }
   }
 
+  private class ReindexIfStaleTask extends AbstractIndexTask<Boolean> {
+    private ReindexIfStaleTask(Project.NameKey project, Change.Id id) {
+      super(project, id);
+    }
+
+    @Override
+    public Boolean callImpl(Provider<ReviewDb> db) throws Exception {
+      try {
+        if (stalenessChecker.isStale(id)) {
+          index(newChangeData(db.get(), project, id));
+          return true;
+        }
+      } catch (NoSuchChangeException nsce) {
+        log.debug("Change {} was deleted, aborting reindexing the change.", id.get());
+      } catch (Exception e) {
+        if (!isCausedByRepositoryNotFoundException(e)) {
+          throw e;
+        }
+        log.debug(
+            "Change {} belongs to deleted project {}, aborting reindexing the change.",
+            id.get(),
+            project.get());
+      }
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      return "reindex-if-stale-change-" + id;
+    }
+  }
+
+  private boolean isCausedByRepositoryNotFoundException(Throwable throwable) {
+    while (throwable != null) {
+      if (throwable instanceof RepositoryNotFoundException) {
+        return true;
+      }
+      throwable = throwable.getCause();
+    }
+    return false;
+  }
+
   // Avoid auto-rebuilding when reindexing if reading is disabled. This just
   // increases contention on the meta ref from a background indexing thread
   // with little benefit. The next actual write to the entity may still incur a
   // less-contentious rebuild.
-  private ChangeData newChangeData(ReviewDb db, Change change)
-      throws OrmException {
+  private ChangeData newChangeData(ReviewDb db, Change change) throws OrmException {
     if (!notesMigration.readChanges()) {
-      ChangeNotes notes = changeNotesFactory.createWithAutoRebuildingDisabled(
-          change, null);
+      ChangeNotes notes = changeNotesFactory.createWithAutoRebuildingDisabled(change, null);
       return changeDataFactory.create(db, notes);
     }
     return changeDataFactory.create(db, change);
   }
 
-  private ChangeData newChangeData(ReviewDb db, Project.NameKey project,
-      Change.Id changeId) throws OrmException {
+  private ChangeData newChangeData(ReviewDb db, Project.NameKey project, Change.Id changeId)
+      throws OrmException {
     if (!notesMigration.readChanges()) {
-      ChangeNotes notes = changeNotesFactory.createWithAutoRebuildingDisabled(
-          db, project, changeId);
+      ChangeNotes notes =
+          changeNotesFactory.createWithAutoRebuildingDisabled(db, project, changeId);
       return changeDataFactory.create(db, notes);
     }
     return changeDataFactory.create(db, project, changeId);
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index c98d311..d988612 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -22,75 +22,78 @@
 
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
-  static final Schema<ChangeData> V25 = schema(
-      ChangeField.LEGACY_ID,
-      ChangeField.ID,
-      ChangeField.STATUS,
-      ChangeField.PROJECT,
-      ChangeField.PROJECTS,
-      ChangeField.REF,
-      ChangeField.EXACT_TOPIC,
-      ChangeField.FUZZY_TOPIC,
-      ChangeField.UPDATED,
-      ChangeField.FILE_PART,
-      ChangeField.PATH,
-      ChangeField.OWNER,
-      ChangeField.LEGACY_REVIEWER,
-      ChangeField.COMMIT,
-      ChangeField.TR,
-      ChangeField.LABEL,
-      ChangeField.COMMIT_MESSAGE,
-      ChangeField.COMMENT,
-      ChangeField.CHANGE,
-      ChangeField.APPROVAL,
-      ChangeField.MERGEABLE,
-      ChangeField.ADDED,
-      ChangeField.DELETED,
-      ChangeField.DELTA,
-      ChangeField.HASHTAG,
-      ChangeField.COMMENTBY,
-      ChangeField.PATCH_SET,
-      ChangeField.GROUP,
-      ChangeField.SUBMISSIONID,
-      ChangeField.EDITBY,
-      ChangeField.REVIEWEDBY,
-      ChangeField.EXACT_COMMIT,
-      ChangeField.AUTHOR,
-      ChangeField.COMMITTER);
+  static final Schema<ChangeData> V32 =
+      schema(
+          ChangeField.LEGACY_ID,
+          ChangeField.ID,
+          ChangeField.STATUS,
+          ChangeField.PROJECT,
+          ChangeField.PROJECTS,
+          ChangeField.REF,
+          ChangeField.EXACT_TOPIC,
+          ChangeField.FUZZY_TOPIC,
+          ChangeField.UPDATED,
+          ChangeField.FILE_PART,
+          ChangeField.PATH,
+          ChangeField.OWNER,
+          ChangeField.COMMIT,
+          ChangeField.TR,
+          ChangeField.LABEL,
+          ChangeField.COMMIT_MESSAGE,
+          ChangeField.COMMENT,
+          ChangeField.CHANGE,
+          ChangeField.APPROVAL,
+          ChangeField.MERGEABLE,
+          ChangeField.ADDED,
+          ChangeField.DELETED,
+          ChangeField.DELTA,
+          ChangeField.HASHTAG,
+          ChangeField.COMMENTBY,
+          ChangeField.PATCH_SET,
+          ChangeField.GROUP,
+          ChangeField.SUBMISSIONID,
+          ChangeField.EDITBY,
+          ChangeField.REVIEWEDBY,
+          ChangeField.EXACT_COMMIT,
+          ChangeField.AUTHOR,
+          ChangeField.COMMITTER,
+          ChangeField.DRAFTBY,
+          ChangeField.HASHTAG_CASE_AWARE,
+          ChangeField.STAR,
+          ChangeField.STARBY,
+          ChangeField.REVIEWER);
+
+  @Deprecated static final Schema<ChangeData> V33 = schema(V32, ChangeField.ASSIGNEE);
 
   @Deprecated
-  static final Schema<ChangeData> V26 = schema(V25, ChangeField.DRAFTBY);
+  static final Schema<ChangeData> V34 =
+      new Schema.Builder<ChangeData>()
+          .add(V33)
+          .remove(ChangeField.LABEL)
+          .add(ChangeField.LABEL2)
+          .build();
 
   @Deprecated
-  static final Schema<ChangeData> V27 = schema(V26.getFields().values());
+  static final Schema<ChangeData> V35 =
+      schema(
+          V34,
+          ChangeField.SUBMIT_RECORD,
+          ChangeField.STORED_SUBMIT_RECORD_LENIENT,
+          ChangeField.STORED_SUBMIT_RECORD_STRICT);
 
   @Deprecated
-  static final Schema<ChangeData> V28 = schema(V27, ChangeField.STARREDBY);
+  static final Schema<ChangeData> V36 =
+      schema(V35, ChangeField.REF_STATE, ChangeField.REF_STATE_PATTERN);
+
+  @Deprecated static final Schema<ChangeData> V37 = schema(V36);
 
   @Deprecated
-  static final Schema<ChangeData> V29 =
-      schema(V28, ChangeField.HASHTAG_CASE_AWARE);
+  static final Schema<ChangeData> V38 = schema(V37, ChangeField.UNRESOLVED_COMMENT_COUNT);
 
-  @Deprecated
-  static final Schema<ChangeData> V30 =
-      schema(V29, ChangeField.STAR, ChangeField.STARBY);
-
-  @Deprecated
-  static final Schema<ChangeData> V31 = new Schema.Builder<ChangeData>()
-      .add(V30)
-      .remove(ChangeField.STARREDBY)
-      .build();
-
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V32 = new Schema.Builder<ChangeData>()
-      .add(V31)
-      .remove(ChangeField.LEGACY_REVIEWER)
-      .add(ChangeField.REVIEWER)
-      .build();
+  static final Schema<ChangeData> V39 = schema(V38);
 
   public static final String NAME = "changes";
-  public static final ChangeSchemaDefinitions INSTANCE =
-      new ChangeSchemaDefinitions();
+  public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
 
   private ChangeSchemaDefinitions() {
     super(NAME, ChangeData.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
index 78c463c..6cbc1cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
-
 import java.io.IOException;
 
 public class DummyChangeIndex implements ChangeIndex {
@@ -30,20 +29,16 @@
   }
 
   @Override
-  public void close() {
-  }
+  public void close() {}
 
   @Override
-  public void replace(ChangeData cd) throws IOException {
-  }
+  public void replace(ChangeData cd) throws IOException {}
 
   @Override
-  public void delete(Change.Id id) throws IOException {
-  }
+  public void delete(Change.Id id) throws IOException {}
 
   @Override
-  public void deleteAll() throws IOException {
-  }
+  public void deleteAll() throws IOException {}
 
   @Override
   public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts) {
@@ -51,14 +46,9 @@
   }
 
   @Override
-  public void markReady(boolean ready) throws IOException {
-  }
+  public void markReady(boolean ready) throws IOException {}
 
   public int getMaxLimit() {
     return Integer.MAX_VALUE;
   }
-
-  @Override
-  public void stop() {
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 996caa7..f99f3b4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Change;
@@ -35,7 +34,6 @@
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
-
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -44,25 +42,23 @@
 import java.util.Set;
 
 /**
- * Wrapper combining an {@link IndexPredicate} together with a
- * {@link ChangeDataSource} that returns matching results from the index.
- * <p>
- * Appropriate to return as the rootmost predicate that can be processed using
- * the secondary index; such predicates must also implement
- * {@link ChangeDataSource} to be chosen by the query processor.
+ * Wrapper combining an {@link IndexPredicate} together with a {@link ChangeDataSource} that returns
+ * matching results from the index.
+ *
+ * <p>Appropriate to return as the rootmost predicate that can be processed using the secondary
+ * index; such predicates must also implement {@link ChangeDataSource} to be chosen by the query
+ * processor.
  */
 public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
     implements ChangeDataSource, Matchable<ChangeData> {
   public static QueryOptions oneResult() {
-    return createOptions(IndexConfig.createDefault(), 0, 1,
-        ImmutableSet.<String> of());
+    return createOptions(IndexConfig.createDefault(), 0, 1, ImmutableSet.<String>of());
   }
 
-  public static QueryOptions createOptions(IndexConfig config, int start,
-      int limit, Set<String> fields) {
+  public static QueryOptions createOptions(
+      IndexConfig config, int start, int limit, Set<String> fields) {
     // Always include project since it is needed to load the change from NoteDb.
-    if (!fields.contains(CHANGE.getName())
-        && !fields.contains(PROJECT.getName())) {
+    if (!fields.contains(CHANGE.getName()) && !fields.contains(PROJECT.getName())) {
       fields = new HashSet<>(fields);
       fields.add(PROJECT.getName());
     }
@@ -72,14 +68,14 @@
   @VisibleForTesting
   static QueryOptions convertOptions(QueryOptions opts) {
     opts = opts.convertForBackend();
-    return IndexedChangeQuery.createOptions(opts.config(), opts.start(),
-        opts.limit(), opts.fields());
+    return IndexedChangeQuery.createOptions(
+        opts.config(), opts.start(), opts.limit(), opts.fields());
   }
 
   private final Map<ChangeData, DataSource<ChangeData>> fromSource;
 
-  public IndexedChangeQuery(ChangeIndex index, Predicate<ChangeData> pred,
-      QueryOptions opts) throws QueryParseException {
+  public IndexedChangeQuery(ChangeIndex index, Predicate<ChangeData> pred, QueryOptions opts)
+      throws QueryParseException {
     super(index, pred, convertOptions(opts));
     this.fromSource = new HashMap<>();
   }
@@ -93,14 +89,12 @@
       @Override
       public Iterator<ChangeData> iterator() {
         return Iterables.transform(
-            rs,
-            new Function<ChangeData, ChangeData>() {
-              @Override
-              public ChangeData apply(ChangeData cd) {
-                fromSource.put(cd, currSource);
-                return cd;
-              }
-            }).iterator();
+                rs,
+                cd -> {
+                  fromSource.put(cd, currSource);
+                  return cd;
+                })
+            .iterator();
       }
 
       @Override
@@ -126,8 +120,10 @@
     }
 
     Predicate<ChangeData> pred = getChild(0);
-    checkState(pred.isMatchable(),
-        "match invoked, but child predicate %s " + "doesn't implement %s", pred,
+    checkState(
+        pred.isMatchable(),
+        "match invoked, but child predicate %s doesn't implement %s",
+        pred,
         Matchable.class.getName());
     return pred.asMatchable().match(cd);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
index 942ce88..2f6f898 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index.change;
 
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
 import com.google.common.util.concurrent.FutureCallback;
@@ -36,17 +37,15 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.List;
 import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class ReindexAfterUpdate implements GitReferenceUpdatedListener {
-  private static final Logger log = LoggerFactory
-      .getLogger(ReindexAfterUpdate.class);
+  private static final Logger log = LoggerFactory.getLogger(ReindexAfterUpdate.class);
 
   private final OneOffRequestContext requestContext;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -84,7 +83,9 @@
           @Override
           public void onSuccess(List<Change> changes) {
             for (Change c : changes) {
-              executor.submit(new Index(event, c.getId()));
+              // Don't retry indefinitely; if this fails changes may be stale.
+              @SuppressWarnings("unused")
+              Future<?> possiblyIgnoredError = executor.submit(new Index(event, c.getId()));
             }
           }
 
@@ -92,7 +93,8 @@
           public void onFailure(Throwable ignored) {
             // Logged by {@link GetChanges#call()}.
           }
-        });
+        },
+        directExecutor());
   }
 
   private abstract class Task<V> implements Callable<V> {
@@ -127,14 +129,15 @@
       if (ref.equals(RefNames.REFS_CONFIG)) {
         return asChanges(queryProvider.get().byProjectOpen(project));
       }
-      return asChanges(queryProvider.get().byBranchNew(
-          new Branch.NameKey(project, ref)));
+      return asChanges(queryProvider.get().byBranchNew(new Branch.NameKey(project, ref)));
     }
 
     @Override
     public String toString() {
-      return "Get changes to reindex caused by " + event.getRefName()
-          + " update of project " + event.getProjectName();
+      return "Get changes to reindex caused by "
+          + event.getRefName()
+          + " update of project "
+          + event.getProjectName();
     }
   }
 
@@ -147,14 +150,14 @@
     }
 
     @Override
-    protected Void impl(RequestContext ctx)
-        throws OrmException, IOException, NoSuchChangeException {
+    protected Void impl(RequestContext ctx) throws OrmException, IOException {
       // Reload change, as some time may have passed since GetChanges.
       ReviewDb db = ctx.getReviewDbProvider().get();
       try {
-        Change c = notesFactory
-            .createChecked(db, new Project.NameKey(event.getProjectName()), id)
-            .getChange();
+        Change c =
+            notesFactory
+                .createChecked(db, new Project.NameKey(event.getProjectName()), id)
+                .getChange();
         indexerFactory.create(executor, indexes).index(db, c);
       } catch (NoSuchChangeException e) {
         indexerFactory.create(executor, indexes).delete(id);
@@ -164,8 +167,7 @@
 
     @Override
     public String toString() {
-      return "Index change " + id.get() + " of project "
-          + event.getProjectName();
+      return "Index change " + id.get() + " of project " + event.getProjectName();
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
new file mode 100644
index 0000000..54787c6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -0,0 +1,307 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.Url;
+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.git.GitRepositoryManager;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.StreamSupport;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class StalenessChecker {
+  private static final Logger log = LoggerFactory.getLogger(StalenessChecker.class);
+
+  public static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(
+          ChangeField.CHANGE.getName(),
+          ChangeField.REF_STATE.getName(),
+          ChangeField.REF_STATE_PATTERN.getName());
+
+  private final ChangeIndexCollection indexes;
+  private final GitRepositoryManager repoManager;
+  private final IndexConfig indexConfig;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  StalenessChecker(
+      ChangeIndexCollection indexes,
+      GitRepositoryManager repoManager,
+      IndexConfig indexConfig,
+      Provider<ReviewDb> db) {
+    this.indexes = indexes;
+    this.repoManager = repoManager;
+    this.indexConfig = indexConfig;
+    this.db = db;
+  }
+
+  public boolean isStale(Change.Id id) throws IOException, OrmException {
+    ChangeIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      return false; // No index; caller couldn't do anything if it is stale.
+    }
+    if (!i.getSchema().hasField(ChangeField.REF_STATE)
+        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
+      return false; // Index version not new enough for this check.
+    }
+
+    Optional<ChangeData> result =
+        i.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, FIELDS));
+    if (!result.isPresent()) {
+      return true; // Not in index, but caller wants it to be.
+    }
+    ChangeData cd = result.get();
+    return isStale(
+        repoManager,
+        id,
+        cd.change(),
+        ChangeNotes.readOneReviewDbChange(db.get(), id),
+        parseStates(cd),
+        parsePatterns(cd));
+  }
+
+  public static boolean isStale(
+      GitRepositoryManager repoManager,
+      Change.Id id,
+      Change indexChange,
+      @Nullable Change reviewDbChange,
+      SetMultimap<Project.NameKey, RefState> states,
+      ListMultimap<Project.NameKey, RefStatePattern> patterns) {
+    return reviewDbChangeIsStale(indexChange, reviewDbChange)
+        || refsAreStale(repoManager, id, states, patterns);
+  }
+
+  @VisibleForTesting
+  static boolean refsAreStale(
+      GitRepositoryManager repoManager,
+      Change.Id id,
+      SetMultimap<Project.NameKey, RefState> states,
+      ListMultimap<Project.NameKey, RefStatePattern> patterns) {
+    Set<Project.NameKey> projects = Sets.union(states.keySet(), patterns.keySet());
+
+    for (Project.NameKey p : projects) {
+      if (refsAreStale(repoManager, id, p, states, patterns)) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  @VisibleForTesting
+  static boolean reviewDbChangeIsStale(Change indexChange, @Nullable Change reviewDbChange) {
+    if (reviewDbChange == null) {
+      return false; // Nothing the caller can do.
+    }
+    checkArgument(
+        indexChange.getId().equals(reviewDbChange.getId()),
+        "mismatched change ID: %s != %s",
+        indexChange.getId(),
+        reviewDbChange.getId());
+    if (PrimaryStorage.of(reviewDbChange) != PrimaryStorage.REVIEW_DB) {
+      return false; // Not a ReviewDb change, don't check rowVersion.
+    }
+    return reviewDbChange.getRowVersion() != indexChange.getRowVersion();
+  }
+
+  private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
+    return parseStates(cd.getRefStates());
+  }
+
+  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
+    RefState.check(states != null, null);
+    SetMultimap<Project.NameKey, RefState> result =
+        MultimapBuilder.hashKeys().hashSetValues().build();
+    for (byte[] b : states) {
+      RefState.check(b != null, null);
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
+      result.put(new Project.NameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
+    }
+    return result;
+  }
+
+  private ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(ChangeData cd) {
+    return parsePatterns(cd.getRefStatePatterns());
+  }
+
+  public static ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(
+      Iterable<byte[]> patterns) {
+    RefStatePattern.check(patterns != null, null);
+    ListMultimap<Project.NameKey, RefStatePattern> result =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    for (byte[] b : patterns) {
+      RefStatePattern.check(b != null, null);
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      RefStatePattern.check(parts.size() == 2, s);
+      result.put(
+          new Project.NameKey(Url.decode(parts.get(0))), RefStatePattern.create(parts.get(1)));
+    }
+    return result;
+  }
+
+  private static boolean refsAreStale(
+      GitRepositoryManager repoManager,
+      Change.Id id,
+      Project.NameKey project,
+      SetMultimap<Project.NameKey, RefState> allStates,
+      ListMultimap<Project.NameKey, RefStatePattern> allPatterns) {
+    try (Repository repo = repoManager.openRepository(project)) {
+      Set<RefState> states = allStates.get(project);
+      for (RefState state : states) {
+        if (!state.match(repo)) {
+          return true;
+        }
+      }
+      for (RefStatePattern pattern : allPatterns.get(project)) {
+        if (!pattern.match(repo, states)) {
+          return true;
+        }
+      }
+      return false;
+    } catch (IOException e) {
+      log.warn("error checking staleness of {} in {}", id, project, e);
+      return true;
+    }
+  }
+
+  @AutoValue
+  public abstract static class RefState {
+    static RefState create(String ref, String sha) {
+      return new AutoValue_StalenessChecker_RefState(ref, ObjectId.fromString(sha));
+    }
+
+    static RefState create(String ref, @Nullable ObjectId id) {
+      return new AutoValue_StalenessChecker_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
+    }
+
+    static RefState of(Ref ref) {
+      return new AutoValue_StalenessChecker_RefState(ref.getName(), ref.getObjectId());
+    }
+
+    byte[] toByteArray(Project.NameKey project) {
+      byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
+      byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
+      System.arraycopy(a, 0, b, 0, a.length);
+      id().copyTo(b, a.length);
+      return b;
+    }
+
+    private static void check(boolean condition, String str) {
+      checkArgument(condition, "invalid RefState: %s", str);
+    }
+
+    abstract String ref();
+
+    abstract ObjectId id();
+
+    private boolean match(Repository repo) throws IOException {
+      Ref ref = repo.exactRef(ref());
+      ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
+      return id().equals(expected);
+    }
+  }
+
+  /**
+   * Pattern for matching refs.
+   *
+   * <p>Similar to '*' syntax for native Git refspecs, but slightly more powerful: the pattern may
+   * contain arbitrarily many asterisks. There must be at least one '*' and the first one must
+   * immediately follow a '/'.
+   */
+  @AutoValue
+  public abstract static class RefStatePattern {
+    static RefStatePattern create(String pattern) {
+      int star = pattern.indexOf('*');
+      check(star > 0 && pattern.charAt(star - 1) == '/', pattern);
+      String prefix = pattern.substring(0, star);
+      check(Repository.isValidRefName(pattern.replace('*', 'x')), pattern);
+
+      // Quote everything except the '*'s, which become ".*".
+      String regex =
+          StreamSupport.stream(Splitter.on('*').split(pattern).spliterator(), false)
+              .map(Pattern::quote)
+              .collect(joining(".*", "^", "$"));
+      return new AutoValue_StalenessChecker_RefStatePattern(
+          pattern, prefix, Pattern.compile(regex));
+    }
+
+    byte[] toByteArray(Project.NameKey project) {
+      return (project.toString() + ':' + pattern()).getBytes(UTF_8);
+    }
+
+    private static void check(boolean condition, String str) {
+      checkArgument(condition, "invalid RefStatePattern: %s", str);
+    }
+
+    abstract String pattern();
+
+    abstract String prefix();
+
+    abstract Pattern regex();
+
+    boolean match(String refName) {
+      return regex().matcher(refName).find();
+    }
+
+    private boolean match(Repository repo, Set<RefState> expected) throws IOException {
+      for (Ref r : repo.getRefDatabase().getRefs(prefix()).values()) {
+        if (!match(r.getName())) {
+          continue;
+        }
+        if (!expected.contains(RefState.of(r))) {
+          return false;
+        }
+      }
+      return true;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
new file mode 100644
index 0000000..5ad0f1e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.SiteIndexer;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AllGroupsIndexer extends SiteIndexer<AccountGroup.UUID, AccountGroup, GroupIndex> {
+  private static final Logger log = LoggerFactory.getLogger(AllGroupsIndexer.class);
+
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final ListeningExecutorService executor;
+  private final GroupCache groupCache;
+
+  @Inject
+  AllGroupsIndexer(
+      SchemaFactory<ReviewDb> schemaFactory,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      GroupCache groupCache) {
+    this.schemaFactory = schemaFactory;
+    this.executor = executor;
+    this.groupCache = groupCache;
+  }
+
+  @Override
+  public SiteIndexer.Result indexAll(GroupIndex index) {
+    ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
+    progress.start(2);
+    Stopwatch sw = Stopwatch.createStarted();
+    List<AccountGroup.UUID> uuids;
+    try {
+      uuids = collectGroups(progress);
+    } catch (OrmException e) {
+      log.error("Error collecting groups", e);
+      return new SiteIndexer.Result(sw, false, 0, 0);
+    }
+    return reindexGroups(index, uuids, progress);
+  }
+
+  private SiteIndexer.Result reindexGroups(
+      GroupIndex index, List<AccountGroup.UUID> uuids, ProgressMonitor progress) {
+    progress.beginTask("Reindexing groups", uuids.size());
+    List<ListenableFuture<?>> futures = new ArrayList<>(uuids.size());
+    AtomicBoolean ok = new AtomicBoolean(true);
+    final AtomicInteger done = new AtomicInteger();
+    final AtomicInteger failed = new AtomicInteger();
+    Stopwatch sw = Stopwatch.createStarted();
+    for (final AccountGroup.UUID uuid : uuids) {
+      final String desc = "group " + uuid;
+      ListenableFuture<?> future =
+          executor.submit(
+              new Callable<Void>() {
+                @Override
+                public Void call() throws Exception {
+                  try {
+                    AccountGroup oldGroup = groupCache.get(uuid);
+                    if (oldGroup != null) {
+                      groupCache.evict(oldGroup);
+                    }
+                    index.replace(groupCache.get(uuid));
+                    verboseWriter.println("Reindexed " + desc);
+                    done.incrementAndGet();
+                  } catch (Exception e) {
+                    failed.incrementAndGet();
+                    throw e;
+                  }
+                  return null;
+                }
+              });
+      addErrorListener(future, desc, progress, ok);
+      futures.add(future);
+    }
+
+    try {
+      Futures.successfulAsList(futures).get();
+    } catch (ExecutionException | InterruptedException e) {
+      log.error("Error waiting on group futures", e);
+      return new SiteIndexer.Result(sw, false, 0, 0);
+    }
+
+    progress.endTask();
+    return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
+  }
+
+  private List<AccountGroup.UUID> collectGroups(ProgressMonitor progress) throws OrmException {
+    progress.beginTask("Collecting groups", ProgressMonitor.UNKNOWN);
+    List<AccountGroup.UUID> uuids = new ArrayList<>();
+    try (ReviewDb db = schemaFactory.open()) {
+      for (AccountGroup group : db.accountGroups().all()) {
+        uuids.add(group.getGroupUUID());
+      }
+    }
+    progress.endTask();
+    return uuids;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java
new file mode 100644
index 0000000..5e72327
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import static com.google.gerrit.server.index.FieldDef.exact;
+import static com.google.gerrit.server.index.FieldDef.fullText;
+import static com.google.gerrit.server.index.FieldDef.integer;
+import static com.google.gerrit.server.index.FieldDef.prefix;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.SchemaUtil;
+
+/** Secondary index schemas for groups. */
+public class GroupField {
+  /** Legacy group ID. */
+  public static final FieldDef<AccountGroup, Integer> ID =
+      integer("id").build(g -> g.getId().get());
+
+  /** Group UUID. */
+  public static final FieldDef<AccountGroup, String> UUID =
+      exact("uuid").stored().build(g -> g.getGroupUUID().get());
+
+  /** Group owner UUID. */
+  public static final FieldDef<AccountGroup, String> OWNER_UUID =
+      exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
+
+  /** Group name. */
+  public static final FieldDef<AccountGroup, String> NAME =
+      exact("name").build(AccountGroup::getName);
+
+  /** Prefix match on group name parts. */
+  public static final FieldDef<AccountGroup, Iterable<String>> NAME_PART =
+      prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
+
+  /** Group description. */
+  public static final FieldDef<AccountGroup, String> DESCRIPTION =
+      fullText("description").build(AccountGroup::getDescription);
+
+  /** Whether the group is visible to all users. */
+  public static final FieldDef<AccountGroup, String> IS_VISIBLE_TO_ALL =
+      exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java
new file mode 100644
index 0000000..48480f8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.group.GroupPredicates;
+
+public interface GroupIndex extends Index<AccountGroup.UUID, AccountGroup> {
+  public interface Factory
+      extends IndexDefinition.IndexFactory<AccountGroup.UUID, AccountGroup, GroupIndex> {}
+
+  @Override
+  default Predicate<AccountGroup> keyPredicate(AccountGroup.UUID uuid) {
+    return GroupPredicates.uuid(uuid);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
new file mode 100644
index 0000000..2f0d8e0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.IndexCollection;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GroupIndexCollection
+    extends IndexCollection<AccountGroup.UUID, AccountGroup, GroupIndex> {
+  @Inject
+  @VisibleForTesting
+  public GroupIndexCollection() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
new file mode 100644
index 0000000..0dbea79
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.index.group;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.inject.Inject;
+import com.google.inject.util.Providers;
+
+public class GroupIndexDefinition
+    extends IndexDefinition<AccountGroup.UUID, AccountGroup, GroupIndex> {
+
+  @Inject
+  GroupIndexDefinition(
+      GroupIndexCollection indexCollection,
+      GroupIndex.Factory indexFactory,
+      @Nullable AllGroupsIndexer allGroupsIndexer) {
+    super(
+        GroupSchemaDefinitions.INSTANCE,
+        indexCollection,
+        indexFactory,
+        Providers.of(allGroupsIndexer));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
new file mode 100644
index 0000000..82f55ed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.IndexRewriter;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GroupIndexRewriter implements IndexRewriter<AccountGroup> {
+  private final GroupIndexCollection indexes;
+
+  @Inject
+  GroupIndexRewriter(GroupIndexCollection indexes) {
+    this.indexes = indexes;
+  }
+
+  @Override
+  public Predicate<AccountGroup> rewrite(Predicate<AccountGroup> in, QueryOptions opts)
+      throws QueryParseException {
+    GroupIndex index = indexes.getSearchIndex();
+    checkNotNull(index, "no active search index configured for groups");
+    return new IndexedGroupQuery(index, in, opts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexer.java
new file mode 100644
index 0000000..0925cf4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexer.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.io.IOException;
+
+public interface GroupIndexer {
+
+  /**
+   * Synchronously index a group.
+   *
+   * @param uuid group UUID to index.
+   */
+  void index(AccountGroup.UUID uuid) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
new file mode 100644
index 0000000..b137fb3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.events.GroupIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.index.Index;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+
+public class GroupIndexerImpl implements GroupIndexer {
+  public interface Factory {
+    GroupIndexerImpl create(GroupIndexCollection indexes);
+
+    GroupIndexerImpl create(@Nullable GroupIndex index);
+  }
+
+  private final GroupCache groupCache;
+  private final DynamicSet<GroupIndexedListener> indexedListener;
+  private final GroupIndexCollection indexes;
+  private final GroupIndex index;
+
+  @AssistedInject
+  GroupIndexerImpl(
+      GroupCache groupCache,
+      DynamicSet<GroupIndexedListener> indexedListener,
+      @Assisted GroupIndexCollection indexes) {
+    this.groupCache = groupCache;
+    this.indexedListener = indexedListener;
+    this.indexes = indexes;
+    this.index = null;
+  }
+
+  @AssistedInject
+  GroupIndexerImpl(
+      GroupCache groupCache,
+      DynamicSet<GroupIndexedListener> indexedListener,
+      @Assisted GroupIndex index) {
+    this.groupCache = groupCache;
+    this.indexedListener = indexedListener;
+    this.indexes = null;
+    this.index = index;
+  }
+
+  @Override
+  public void index(AccountGroup.UUID uuid) throws IOException {
+    for (Index<?, AccountGroup> i : getWriteIndexes()) {
+      i.replace(groupCache.get(uuid));
+    }
+    fireGroupIndexedEvent(uuid.get());
+  }
+
+  private void fireGroupIndexedEvent(String uuid) {
+    for (GroupIndexedListener listener : indexedListener) {
+      listener.onGroupIndexed(uuid);
+    }
+  }
+
+  private Collection<GroupIndex> getWriteIndexes() {
+    if (indexes != null) {
+      return indexes.getWriteIndexes();
+    }
+
+    return index != null ? Collections.singleton(index) : ImmutableSet.of();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
new file mode 100644
index 0000000..6ba46cb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import static com.google.gerrit.server.index.SchemaUtil.schema;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.SchemaDefinitions;
+
+public class GroupSchemaDefinitions extends SchemaDefinitions<AccountGroup> {
+  @Deprecated
+  static final Schema<AccountGroup> V1 =
+      schema(
+          GroupField.ID,
+          GroupField.UUID,
+          GroupField.OWNER_UUID,
+          GroupField.NAME,
+          GroupField.NAME_PART,
+          GroupField.DESCRIPTION,
+          GroupField.IS_VISIBLE_TO_ALL);
+
+  static final Schema<AccountGroup> V2 = schema(V1);
+
+  public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
+
+  private GroupSchemaDefinitions() {
+    super("groups", AccountGroup.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
new file mode 100644
index 0000000..1ea4478
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.group;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexedQuery;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+
+public class IndexedGroupQuery extends IndexedQuery<AccountGroup.UUID, AccountGroup>
+    implements DataSource<AccountGroup> {
+
+  public IndexedGroupQuery(
+      Index<AccountGroup.UUID, AccountGroup> index, Predicate<AccountGroup> pred, QueryOptions opts)
+      throws QueryParseException {
+    super(index, pred, opts.convertForBackend());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
index 989b270..5f77877 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
@@ -32,13 +32,11 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.reviewdb.client.CodedEnum;
-
-import org.eclipse.jgit.util.IO;
-
 import java.io.EOFException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import org.eclipse.jgit.util.IO;
 
 public class BasicSerialization {
   private static final byte[] NO_BYTES = {};
@@ -59,8 +57,7 @@
   }
 
   /** Write a fixed-width 64 bit integer in network byte order (big-endian). */
-  public static void writeFixInt64(final OutputStream output, final long val)
-      throws IOException {
+  public static void writeFixInt64(final OutputStream output, final long val) throws IOException {
     writeFixInt32(output, (int) (val >>> 32));
     writeFixInt32(output, (int) (val & 0xFFFFFFFFL));
   }
@@ -75,8 +72,7 @@
   }
 
   /** Write a fixed-width 32 bit integer in network byte order (big-endian). */
-  public static void writeFixInt32(final OutputStream output, final int val)
-      throws IOException {
+  public static void writeFixInt32(final OutputStream output, final int val) throws IOException {
     output.write((val >>> 24) & 0xFF);
     output.write((val >>> 16) & 0xFF);
     output.write((val >>> 8) & 0xFF);
@@ -98,8 +94,7 @@
   }
 
   /** Write a varint; value is treated as an unsigned value. */
-  public static void writeVarInt32(final OutputStream output, int value)
-      throws IOException {
+  public static void writeVarInt32(final OutputStream output, int value) throws IOException {
     while (true) {
       if ((value & ~0x7F) == 0) {
         output.write(value);
@@ -122,14 +117,14 @@
   }
 
   /** Write a byte array prefixed by its length in a varint. */
-  public static void writeBytes(final OutputStream output, final byte[] data)
-      throws IOException {
+  public static void writeBytes(final OutputStream output, final byte[] data) throws IOException {
     writeBytes(output, data, 0, data.length);
   }
 
   /** Write a byte array prefixed by its length in a varint. */
-  public static void writeBytes(final OutputStream output, final byte[] data,
-      final int offset, final int len) throws IOException {
+  public static void writeBytes(
+      final OutputStream output, final byte[] data, final int offset, final int len)
+      throws IOException {
     writeVarInt32(output, len);
     output.write(data, offset, len);
   }
@@ -144,8 +139,7 @@
   }
 
   /** Write a UTF-8 string, prefixed by its byte length in a varint. */
-  public static void writeString(final OutputStream output, final String s)
-      throws IOException {
+  public static void writeString(final OutputStream output, final String s) throws IOException {
     if (s == null) {
       writeVarInt32(output, 0);
     } else {
@@ -154,8 +148,8 @@
   }
 
   /** Read an enum whose code is stored as a varint. */
-  public static <T extends CodedEnum> T readEnum(final InputStream input,
-      final T[] all) throws IOException {
+  public static <T extends CodedEnum> T readEnum(final InputStream input, final T[] all)
+      throws IOException {
     final int val = readVarInt32(input);
     for (T t : all) {
       if (t.getCode() == val) {
@@ -166,11 +160,10 @@
   }
 
   /** Write an enum whose code is stored as a varint. */
-  public static <T extends CodedEnum> void writeEnum(final OutputStream output,
-      final T e) throws IOException {
+  public static <T extends CodedEnum> void writeEnum(final OutputStream output, final T e)
+      throws IOException {
     writeVarInt32(output, e.getCode());
   }
 
-  private BasicSerialization() {
-  }
+  private BasicSerialization() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
index a73f1cb..c96e808 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
@@ -15,14 +15,12 @@
 package com.google.gerrit.server.ioutil;
 
 import com.google.gerrit.server.StringUtil;
-
 import java.io.PrintWriter;
 
 /**
- * Simple output formatter for column-oriented data, writing its output to
- * a {@link java.io.PrintWriter} object. Handles escaping of the column
- * data so that the resulting output is unambiguous and reasonably safe and
- * machine parsable.
+ * Simple output formatter for column-oriented data, writing its output to a {@link
+ * java.io.PrintWriter} object. Handles escaping of the column data so that the resulting output is
+ * unambiguous and reasonably safe and machine parsable.
  */
 public class ColumnFormatter {
   private char columnSeparator;
@@ -31,11 +29,9 @@
 
   /**
    * @param out The writer to which output should be sent.
-   * @param columnSeparator A character that should serve as the separator
-   *        token between columns of output. As only non-printable characters
-   *        in the column text are ever escaped, the column separator must be
-   *        a non-printable character if the output needs to be unambiguously
-   *        parsed.
+   * @param columnSeparator A character that should serve as the separator token between columns of
+   *     output. As only non-printable characters in the column text are ever escaped, the column
+   *     separator must be a non-printable character if the output needs to be unambiguously parsed.
    */
   public ColumnFormatter(final PrintWriter out, final char columnSeparator) {
     this.out = out;
@@ -44,8 +40,8 @@
   }
 
   /**
-   * Adds a text string as a new column in the current line of output,
-   * taking care of escaping as necessary.
+   * Adds a text string as a new column in the current line of output, taking care of escaping as
+   * necessary.
    *
    * @param content the string to add.
    */
@@ -58,8 +54,7 @@
   }
 
   /**
-   * Finishes the output by flushing the current line and takes care of any
-   * other cleanup action.
+   * Finishes the output by flushing the current line and takes care of any other cleanup action.
    */
   public void finish() {
     nextLine();
@@ -67,11 +62,10 @@
   }
 
   /**
-   * Flushes the current line of output and makes the formatter ready to
-   * start receiving new column data for a new line (or end-of-file).
-   * If the current line is empty nothing is done, i.e. consecutive calls
-   * to this method without intervening calls to {@link #addColumn} will
-   * be squashed.
+   * Flushes the current line of output and makes the formatter ready to start receiving new column
+   * data for a new line (or end-of-file). If the current line is empty nothing is done, i.e.
+   * consecutive calls to this method without intervening calls to {@link #addColumn} will be
+   * squashed.
    */
   public void nextLine() {
     if (!firstColumn) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java
deleted file mode 100644
index 1e8bdf4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AbandonedSender.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being abandoned by its owner. */
-public class AbandonedSender extends ReplyToChangeSender {
-  public interface Factory extends
-      ReplyToChangeSender.Factory<AbandonedSender> {
-    @Override
-    AbandonedSender create(Project.NameKey project, Change.Id change);
-  }
-
-  @Inject
-  public AbandonedSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "abandon", newChangeData(ea, project, id));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ABANDONED_CHANGES);
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("Abandoned.vm"));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java
deleted file mode 100644
index f825d1c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddKeySender.java
+++ /dev/null
@@ -1,113 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-
-import java.util.List;
-
-public class AddKeySender extends OutgoingEmail {
-  public interface Factory {
-    AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    AddKeySender create(IdentifiedUser user, List<String> gpgKey);
-  }
-
-  private final IdentifiedUser callingUser;
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeys;
-
-  @AssistedInject
-  public AddKeySender(EmailArguments ea,
-      IdentifiedUser callingUser,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(ea, "addkey");
-    this.callingUser = callingUser;
-    this.user = user;
-    this.sshKey = sshKey;
-    this.gpgKeys = null;
-  }
-
-  @AssistedInject
-  public AddKeySender(EmailArguments ea,
-      IdentifiedUser callingUser,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeys) {
-    super(ea, "addkey");
-    this.callingUser = callingUser;
-    this.user = user;
-    this.sshKey = null;
-    this.gpgKeys = gpgKeys;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject",
-        String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
-    add(RecipientType.TO, new Address(getEmail()));
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    /*
-     * Don't send an email if no keys are added, or an admin is adding a key to
-     * a user.
-     */
-    return (sshKey != null || gpgKeys.size() > 0) &&
-        (user.equals(callingUser) ||
-        !callingUser.getCapabilities().canAdministrateServer());
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(velocifyFile("AddKey.vm"));
-  }
-
-  public String getEmail() {
-    return user.getAccount().getPreferredEmail();
-  }
-
-  public String getUserNameEmail() {
-    return getUserNameEmailFor(user.getAccountId());
-  }
-
-  public String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeys != null) {
-      return "GPG";
-    }
-    return "Unknown";
-  }
-
-  public String getSshKey() {
-    return (sshKey != null) ? sshKey.getSshPublicKey() + "\n" : null;
-  }
-
-  public String getGpgKeys() {
-    if (gpgKeys != null) {
-      return Joiner.on("\n").join(gpgKeys);
-    }
-    return null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java
deleted file mode 100644
index 5f61353..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/AddReviewerSender.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Asks a user to review a change. */
-public class AddReviewerSender extends NewChangeSender {
-  public interface Factory {
-    AddReviewerSender create(Project.NameKey project, Change.Id id,
-        NotifyHandling notify);
-  }
-
-  @Inject
-  public AddReviewerSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id,
-      @Assisted @Nullable NotifyHandling notify)
-      throws OrmException {
-    super(ea, newChangeData(ea, project, id));
-    if (notify != null) {
-      setNotify(notify);
-    }
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccExistingReviewers();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
index 863cb82..f3b08fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.mail;
 
+import com.google.gerrit.server.mail.send.EmailHeader;
+
 public class Address {
   public static Address parse(final String in) {
     final int lt = in.indexOf('<');
@@ -22,7 +24,15 @@
     if (0 <= lt && lt < gt && lt + 1 < at && at + 1 < gt) {
       final String email = in.substring(lt + 1, gt).trim();
       final String name = in.substring(0, lt).trim();
-      return new Address(name.length() > 0 ? name : null, email);
+      int nameStart = 0;
+      int nameEnd = name.length();
+      if (name.startsWith("\"")) {
+        nameStart++;
+      }
+      if (name.endsWith("\"")) {
+        nameEnd--;
+      }
+      return new Address(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
     }
 
     if (lt < 0 && gt < 0 && 0 < at && at < in.length() - 1) {
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
deleted file mode 100644
index badc706..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
+++ /dev/null
@@ -1,482 +0,0 @@
-// Copyright (C) 2010 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.mail;
-
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-
-import com.google.common.collect.Multimap;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.ProjectWatch.Watchers;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.internal.JGitText;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.eclipse.jgit.util.TemporaryBuffer;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.text.MessageFormat;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeSet;
-
-/** Sends an email to one or more interested parties. */
-public abstract class ChangeEmail extends NotificationEmail {
-  private static final Logger log = LoggerFactory.getLogger(ChangeEmail.class);
-
-  protected static ChangeData newChangeData(EmailArguments ea,
-      Project.NameKey project, Change.Id id) {
-    return ea.changeDataFactory.create(ea.db.get(), project, id);
-  }
-
-  protected final Change change;
-  protected final ChangeData changeData;
-  protected PatchSet patchSet;
-  protected PatchSetInfo patchSetInfo;
-  protected String changeMessage;
-  protected Timestamp timestamp;
-
-  protected ProjectState projectState;
-  protected Set<Account.Id> authors;
-  protected boolean emailOnlyAuthors;
-
-  protected ChangeEmail(EmailArguments ea, String mc, ChangeData cd)
-      throws OrmException {
-    super(ea, mc, cd.change().getDest());
-    changeData = cd;
-    change = cd.change();
-    emailOnlyAuthors = false;
-  }
-
-  @Override
-  public void setFrom(final Account.Id id) {
-    super.setFrom(id);
-
-    /** Is the from user in an email squelching group? */
-    final IdentifiedUser user =  args.identifiedUserFactory.create(id);
-    emailOnlyAuthors = !user.getCapabilities().canEmailReviewers();
-  }
-
-  public void setPatchSet(final PatchSet ps) {
-    patchSet = ps;
-  }
-
-  public void setPatchSet(final PatchSet ps, final PatchSetInfo psi) {
-    patchSet = ps;
-    patchSetInfo = psi;
-  }
-
-  @Deprecated
-  public void setChangeMessage(final ChangeMessage cm) {
-    setChangeMessage(cm.getMessage(), cm.getWrittenOn());
-  }
-
-  public void setChangeMessage(String cm, Timestamp t) {
-    changeMessage = cm;
-    timestamp = t;
-  }
-
-  /** Format the message body by calling {@link #appendText(String)}. */
-  @Override
-  protected void format() throws EmailException {
-    formatChange();
-    appendText(velocifyFile("ChangeFooter.vm"));
-    try {
-      TreeSet<String> names = new TreeSet<>();
-      for (Account.Id who : changeData.reviewers().all()) {
-        names.add(getNameEmailFor(who));
-      }
-      for (String name : names) {
-        appendText("Gerrit-Reviewer: " + name + "\n");
-      }
-    } catch (OrmException e) {
-      log.warn("Cannot get change reviewers", e);
-    }
-    formatFooter();
-  }
-
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void formatChange() throws EmailException;
-
-  /**
-   * Format the message footer by calling {@link #appendText(String)}.
-   *
-   * @throws EmailException if an error occurred.
-   */
-  protected void formatFooter() throws EmailException {
-  }
-
-  /** Setup the message headers and envelope (TO, CC, BCC). */
-  @Override
-  protected void init() throws EmailException {
-    if (args.projectCache != null) {
-      projectState = args.projectCache.get(change.getProject());
-    } else {
-      projectState = null;
-    }
-
-    if (patchSet == null) {
-      try {
-        patchSet = changeData.currentPatchSet();
-      } catch (OrmException err) {
-        patchSet = null;
-      }
-    }
-
-    if (patchSet != null && patchSetInfo == null) {
-      try {
-        patchSetInfo = args.patchSetInfoFactory.get(
-            args.db.get(), changeData.notes(), patchSet.getId());
-      } catch (PatchSetInfoNotAvailableException | OrmException err) {
-        patchSetInfo = null;
-      }
-    }
-    authors = getAuthors();
-
-    super.init();
-    if (timestamp != null) {
-      setHeader("Date", new Date(timestamp.getTime()));
-    }
-    setChangeSubjectHeader();
-    setHeader("X-Gerrit-Change-Id", "" + change.getKey().get());
-    setChangeUrlHeader();
-    setCommitIdHeader();
-  }
-
-  private void setChangeUrlHeader() {
-    final String u = getChangeUrl();
-    if (u != null) {
-      setHeader("X-Gerrit-ChangeURL", "<" + u + ">");
-    }
-  }
-
-  private void setCommitIdHeader() {
-    if (patchSet != null && patchSet.getRevision() != null
-        && patchSet.getRevision().get() != null
-        && patchSet.getRevision().get().length() > 0) {
-      setHeader("X-Gerrit-Commit", patchSet.getRevision().get());
-    }
-  }
-
-  private void setChangeSubjectHeader() throws EmailException {
-    setHeader("Subject", velocifyFile("ChangeSubject.vm"));
-  }
-
-  /** Get a link to the change; null if the server doesn't know its own address. */
-  public String getChangeUrl() {
-    if (getGerritUrl() != null) {
-      final StringBuilder r = new StringBuilder();
-      r.append(getGerritUrl());
-      r.append(change.getChangeId());
-      return r.toString();
-    }
-    return null;
-  }
-
-  public String getChangeMessageThreadId() throws EmailException {
-    return velocify("<gerrit.${change.createdOn.time}.$change.key.get()" +
-                    "@$email.gerritHost>");
-  }
-
-  /** Format the sender's "cover letter", {@link #getCoverLetter()}. */
-  protected void formatCoverLetter() {
-    final String cover = getCoverLetter();
-    if (!"".equals(cover)) {
-      appendText(cover);
-      appendText("\n\n");
-    }
-  }
-
-  /** Get the text of the "cover letter". */
-  public String getCoverLetter() {
-    if (changeMessage != null) {
-      return changeMessage.trim();
-    }
-    return "";
-  }
-
-  /** Format the change message and the affected file list. */
-  protected void formatChangeDetail() {
-    appendText(getChangeDetail());
-  }
-
-  /** Create the change message and the affected file list. */
-  public String getChangeDetail() {
-    try {
-      StringBuilder detail = new StringBuilder();
-
-      if (patchSetInfo != null) {
-        detail.append(patchSetInfo.getMessage().trim()).append("\n");
-      } else {
-        detail.append(change.getSubject().trim()).append("\n");
-      }
-
-      if (patchSet != null) {
-        detail.append("---\n");
-        PatchList patchList = getPatchList();
-        for (PatchListEntry p : patchList.getPatches()) {
-          if (Patch.COMMIT_MSG.equals(p.getNewName())) {
-            continue;
-          }
-          detail.append(p.getChangeType().getCode())
-                .append(" ").append(p.getNewName()).append("\n");
-        }
-        detail.append(MessageFormat.format("" //
-            + "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
-            + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
-            + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
-            + "\n", patchList.getPatches().size() - 1, //
-            patchList.getInsertions(), //
-            patchList.getDeletions()));
-        detail.append("\n");
-      }
-      return detail.toString();
-    } catch (Exception err) {
-      log.warn("Cannot format change detail", err);
-      return "";
-    }
-  }
-
-  /** Get the patch list corresponding to this patch set. */
-  protected PatchList getPatchList() throws PatchListNotAvailableException {
-    if (patchSet != null) {
-      return args.patchListCache.get(change, patchSet);
-    }
-    throw new PatchListNotAvailableException("no patchSet specified");
-  }
-
-  /** Get the project entity the change is in; null if its been deleted. */
-  protected ProjectState getProjectState() {
-    return projectState;
-  }
-
-  /** Get the groups which own the project. */
-  protected Set<AccountGroup.UUID> getProjectOwners() {
-    final ProjectState r;
-
-    r = args.projectCache.get(change.getProject());
-    return r != null ? r.getOwners() : Collections.<AccountGroup.UUID> emptySet();
-  }
-
-  /** TO or CC all vested parties (change owner, patch set uploader, author). */
-  protected void rcptToAuthors(final RecipientType rt) {
-    for (final Account.Id id : authors) {
-      add(rt, id);
-    }
-  }
-
-  /** BCC any user who has starred this change. */
-  protected void bccStarredBy() {
-    if (!NotifyHandling.ALL.equals(notify)) {
-      return;
-    }
-
-    try {
-      // BCC anyone who has starred this change
-      // and remove anyone who has ignored this change.
-      //
-      Multimap<Account.Id, String> stars =
-          args.starredChangesUtil.byChangeFromIndex(change.getId());
-      for (Map.Entry<Account.Id, Collection<String>> e :
-          stars.asMap().entrySet()) {
-        if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-          super.add(RecipientType.BCC, e.getKey());
-        }
-        if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
-          AccountState accountState = args.accountCache.get(e.getKey());
-          if (accountState != null) {
-            removeUser(accountState.getAccount());
-          }
-        }
-      }
-    } catch (OrmException | NoSuchChangeException err) {
-      // Just don't BCC everyone. Better to send a partial message to those
-      // we already have queued up then to fail deliver entirely to people
-      // who have a lower interest in the change.
-      log.warn("Cannot BCC users that starred updated change", err);
-    }
-  }
-
-  @Override
-  protected final Watchers getWatchers(NotifyType type) throws OrmException {
-    if (!NotifyHandling.ALL.equals(notify)) {
-      return new Watchers();
-    }
-
-    ProjectWatch watch = new ProjectWatch(
-        args, branch.getParentKey(), projectState, changeData);
-    return watch.getWatchers(type);
-  }
-
-  /** Any user who has published comments on this change. */
-  protected void ccAllApprovals() {
-    if (!NotifyHandling.ALL.equals(notify)
-        && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
-      return;
-    }
-
-    try {
-      for (Account.Id id : changeData.reviewers().all()) {
-        add(RecipientType.CC, id);
-      }
-    } catch (OrmException err) {
-      log.warn("Cannot CC users that reviewed updated change", err);
-    }
-  }
-
-  /** Users who have non-zero approval codes on the change. */
-  protected void ccExistingReviewers() {
-    if (!NotifyHandling.ALL.equals(notify)
-        && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
-      return;
-    }
-
-    try {
-      for (Account.Id id : changeData.reviewers().byState(REVIEWER)) {
-        add(RecipientType.CC, id);
-      }
-    } catch (OrmException err) {
-      log.warn("Cannot CC users that commented on updated change", err);
-    }
-  }
-
-  @Override
-  protected void add(final RecipientType rt, final Account.Id to) {
-    if (! emailOnlyAuthors || authors.contains(to)) {
-      super.add(rt, to);
-    }
-  }
-
-  @Override
-  protected boolean isVisibleTo(final Account.Id to) throws OrmException {
-    return projectState == null
-        || projectState.controlFor(args.identifiedUserFactory.create(to))
-            .controlFor(args.db.get(), change).isVisible(args.db.get());
-  }
-
-  /** Find all users who are authors of any part of this change. */
-  protected Set<Account.Id> getAuthors() {
-    Set<Account.Id> authors = new HashSet<>();
-
-    switch (notify) {
-      case NONE:
-        break;
-      case ALL:
-      default:
-        if (patchSet != null) {
-          authors.add(patchSet.getUploader());
-        }
-        if (patchSetInfo != null) {
-          if (patchSetInfo.getAuthor().getAccount() != null) {
-            authors.add(patchSetInfo.getAuthor().getAccount());
-          }
-          if (patchSetInfo.getCommitter().getAccount() != null) {
-            authors.add(patchSetInfo.getCommitter().getAccount());
-          }
-        }
-        //$FALL-THROUGH$
-      case OWNER_REVIEWERS:
-      case OWNER:
-        authors.add(change.getOwner());
-        break;
-    }
-
-    return authors;
-  }
-
-  @Override
-  protected void setupVelocityContext() {
-    super.setupVelocityContext();
-    velocityContext.put("change", change);
-    velocityContext.put("changeId", change.getKey());
-    velocityContext.put("coverLetter", getCoverLetter());
-    velocityContext.put("fromName", getNameFor(fromId));
-    velocityContext.put("patchSet", patchSet);
-    velocityContext.put("patchSetInfo", patchSetInfo);
-  }
-
-  public boolean getIncludeDiff() {
-    return args.settings.includeDiff;
-  }
-
-  private static int HEAP_EST_SIZE = 32 * 1024;
-
-  /** Show patch set as unified difference. */
-  public String getUnifiedDiff() {
-    PatchList patchList;
-    try {
-      patchList = getPatchList();
-      if (patchList.getOldId() == null) {
-        // Octopus merges are not well supported for diff output by Gerrit.
-        // Currently these always have a null oldId in the PatchList.
-        return "[Octopus merge; cannot be formatted as a diff.]\n";
-      }
-    } catch (PatchListNotAvailableException e) {
-      log.error("Cannot format patch", e);
-      return "";
-    }
-
-    int maxSize = args.settings.maximumDiffSize;
-    TemporaryBuffer.Heap buf =
-        new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
-    try (DiffFormatter fmt = new DiffFormatter(buf)) {
-      try (Repository git = args.server.openRepository(change.getProject())) {
-        try {
-          fmt.setRepository(git);
-          fmt.setDetectRenames(true);
-          fmt.format(patchList.getOldId(), patchList.getNewId());
-          return RawParseUtils.decode(buf.toByteArray());
-        } catch (IOException e) {
-          if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
-            return "";
-          }
-          log.error("Cannot format patch", e);
-          return "";
-        }
-      } catch (IOException e) {
-        log.error("Cannot open repository to format patch", e);
-        return "";
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
deleted file mode 100644
index b56b737..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
+++ /dev/null
@@ -1,286 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import static com.google.gerrit.server.PatchLineCommentsUtil.getCommentPsId;
-
-import com.google.common.base.Optional;
-import com.google.common.base.Strings;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.Patch;
-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.server.PatchLineCommentsUtil;
-import com.google.gerrit.server.patch.PatchFile;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Send comments, after the author of them hit used Publish Comments in the UI. */
-public class CommentSender extends ReplyToChangeSender {
-  private static final Logger log = LoggerFactory
-      .getLogger(CommentSender.class);
-
-  public interface Factory {
-    CommentSender create(Project.NameKey project, Change.Id id);
-  }
-
-  private List<PatchLineComment> inlineComments = Collections.emptyList();
-  private final PatchLineCommentsUtil plcUtil;
-
-  @Inject
-  public CommentSender(EmailArguments ea,
-      PatchLineCommentsUtil plcUtil,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id) throws OrmException {
-    super(ea, "comment", newChangeData(ea, project, id));
-    this.plcUtil = plcUtil;
-  }
-
-  public void setPatchLineComments(final List<PatchLineComment> plc)
-      throws OrmException {
-    inlineComments = plc;
-
-    Set<String> paths = new HashSet<>();
-    for (PatchLineComment c : plc) {
-      Patch.Key p = c.getKey().getParentKey();
-      if (!Patch.COMMIT_MSG.equals(p.getFileName())) {
-        paths.add(p.getFileName());
-      }
-    }
-    changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    if (notify.compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
-      ccAllApprovals();
-    }
-    if (notify.compareTo(NotifyHandling.ALL) >= 0) {
-      bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS);
-    }
-  }
-
-  @Override
-  public void formatChange() throws EmailException {
-    appendText(velocifyFile("Comment.vm"));
-  }
-
-  @Override
-  public void formatFooter() throws EmailException {
-    appendText(velocifyFile("CommentFooter.vm"));
-  }
-
-  public boolean hasInlineComments() {
-    return !inlineComments.isEmpty();
-  }
-
-  public String getInlineComments() {
-    return getInlineComments(1);
-  }
-
-  public String getInlineComments(int lines) {
-    StringBuilder cmts = new StringBuilder();
-    try (Repository repo = getRepository()) {
-      PatchList patchList = null;
-      if (repo != null) {
-        try {
-          patchList = getPatchList();
-        } catch (PatchListNotAvailableException e) {
-          log.error("Failed to get patch list", e);
-        }
-      }
-
-      Patch.Key currentFileKey = null;
-      PatchFile currentFileData = null;
-      for (final PatchLineComment c : inlineComments) {
-        final Patch.Key pk = c.getKey().getParentKey();
-
-        if (!pk.equals(currentFileKey)) {
-          String link = makeLink(pk);
-          if (link != null) {
-            cmts.append(link).append('\n');
-          }
-          if (Patch.COMMIT_MSG.equals(pk.get())) {
-            cmts.append("Commit Message:\n\n");
-          } else {
-            cmts.append("File ").append(pk.get()).append(":\n\n");
-          }
-          currentFileKey = pk;
-
-          if (patchList != null) {
-            try {
-              currentFileData =
-                  new PatchFile(repo, patchList, pk.get());
-            } catch (IOException e) {
-              log.warn(String.format(
-                  "Cannot load %s from %s in %s",
-                  pk.getFileName(),
-                  patchList.getNewId().name(),
-                  projectState.getProject().getName()), e);
-              currentFileData = null;
-            }
-          }
-        }
-
-        if (currentFileData != null) {
-          appendComment(cmts, lines, currentFileData, c);
-        }
-        cmts.append("\n\n");
-      }
-    }
-    return cmts.toString();
-  }
-
-  private void appendComment(StringBuilder out, int contextLines,
-      PatchFile currentFileData, PatchLineComment comment) {
-    short side = comment.getSide();
-    CommentRange range = comment.getRange();
-    if (range != null) {
-      String prefix = "PS" + getCommentPsId(comment).get()
-        + ", Line " + range.getStartLine() + ": ";
-      for (int n = range.getStartLine(); n <= range.getEndLine(); n++) {
-        out.append(n == range.getStartLine()
-            ? prefix
-            : Strings.padStart(": ", prefix.length(), ' '));
-        try {
-          String s = currentFileData.getLine(side, n);
-          if (n == range.getStartLine() && n == range.getEndLine()) {
-            s = s.substring(
-                Math.min(range.getStartCharacter(), s.length()),
-                Math.min(range.getEndCharacter(), s.length()));
-          } else if (n == range.getStartLine()) {
-            s = s.substring(Math.min(range.getStartCharacter(), s.length()));
-          } else if (n == range.getEndLine()) {
-            s = s.substring(0, Math.min(range.getEndCharacter(), s.length()));
-          }
-          out.append(s);
-        } catch (Throwable e) {
-          // Don't quote the line if we can't safely convert it.
-        }
-        out.append('\n');
-      }
-      appendQuotedParent(out, comment);
-      out.append(comment.getMessage().trim()).append('\n');
-    } else {
-      int lineNbr = comment.getLine();
-      int maxLines;
-      try {
-        maxLines = currentFileData.getLineCount(side);
-      } catch (Throwable e) {
-        maxLines = lineNbr;
-      }
-
-      final int startLine = Math.max(1, lineNbr - contextLines + 1);
-      final int stopLine = Math.min(maxLines, lineNbr + contextLines);
-
-      for (int line = startLine; line <= lineNbr; ++line) {
-        appendFileLine(out, currentFileData, side, line);
-      }
-      appendQuotedParent(out, comment);
-      out.append(comment.getMessage().trim()).append('\n');
-
-      for (int line = lineNbr + 1; line < stopLine; ++line) {
-        appendFileLine(out, currentFileData, side, line);
-      }
-    }
-  }
-
-  private void appendFileLine(StringBuilder cmts, PatchFile fileData, short side, int line) {
-    cmts.append("Line " + line);
-    try {
-      final String lineStr = fileData.getLine(side, line);
-      cmts.append(": ");
-      cmts.append(lineStr);
-    } catch (Throwable e) {
-      // Don't quote the line if we can't safely convert it.
-    }
-    cmts.append("\n");
-  }
-
-  private void appendQuotedParent(StringBuilder out, PatchLineComment child) {
-    if (child.getParentUuid() != null) {
-      Optional<PatchLineComment> parent;
-      PatchLineComment.Key key = new PatchLineComment.Key(
-          child.getKey().getParentKey(),
-          child.getParentUuid());
-      try {
-        parent = plcUtil.get(args.db.get(), changeData.notes(), key);
-      } catch (OrmException e) {
-        log.warn("Could not find the parent of this comment: "
-            + child.toString());
-        parent = Optional.absent();
-      }
-      if (parent.isPresent()) {
-        String msg = parent.get().getMessage().trim();
-        if (msg.length() > 75) {
-          msg = msg.substring(0, 75);
-        }
-        int lf = msg.indexOf('\n');
-        if (lf > 0) {
-          msg = msg.substring(0, lf);
-        }
-        out.append("> ").append(msg).append('\n');
-      }
-    }
-  }
-
-  // Makes a link back to the given patch set and file.
-  private String makeLink(Patch.Key patch) {
-    String url = getGerritUrl();
-    if (url == null) {
-      return null;
-    }
-
-    PatchSet.Id ps = patch.getParentKey();
-    Change.Id c = ps.getParentKey();
-    return new StringBuilder()
-      .append(url)
-      .append("#/c/").append(c)
-      .append('/').append(ps.get())
-      .append('/').append(KeyUtil.encode(patch.get()))
-      .toString();
-  }
-
-  private Repository getRepository() {
-    try {
-      return args.server.openRepository(projectState.getProject().getNameKey());
-    } catch (IOException e) {
-      return null;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java
deleted file mode 100644
index 2110e37..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CreateChangeSender.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.mail.ProjectWatch.Watchers;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Notify interested parties of a brand new change. */
-public class CreateChangeSender extends NewChangeSender {
-  private static final Logger log =
-      LoggerFactory.getLogger(CreateChangeSender.class);
-
-  public interface Factory {
-    CreateChangeSender create(Project.NameKey project, Change.Id id);
-  }
-
-  @Inject
-  public CreateChangeSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, newChangeData(ea, project, id));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    if (change.getStatus() == Change.Status.NEW) {
-      try {
-        // Try to mark interested owners with TO and CC or BCC line.
-        Watchers matching = getWatchers(NotifyType.NEW_CHANGES);
-        for (Account.Id user : Iterables.concat(
-            matching.to.accounts,
-            matching.cc.accounts,
-            matching.bcc.accounts)) {
-          if (isOwnerOfProjectOrBranch(user)) {
-            add(RecipientType.TO, user);
-          }
-        }
-
-        // Add everyone else. Owners added above will not be duplicated.
-        add(RecipientType.TO, matching.to);
-        add(RecipientType.CC, matching.cc);
-        add(RecipientType.BCC, matching.bcc);
-      } catch (OrmException err) {
-        // Just don't CC everyone. Better to send a partial message to those
-        // we already have queued up then to fail deliver entirely to people
-        // who have a lower interest in the change.
-        log.warn("Cannot notify watchers for new change", err);
-      }
-
-      includeWatchers(NotifyType.NEW_PATCHSETS);
-    }
-  }
-
-  private boolean isOwnerOfProjectOrBranch(Account.Id user) {
-    return projectState != null
-        && projectState.controlFor(args.identifiedUserFactory.create(user))
-          .controlForRef(change.getDest())
-          .isOwner();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java
deleted file mode 100644
index 75f9f82..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteReviewerSender.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Let users know that a reviewer and possibly her review have
- * been removed. */
-public class DeleteReviewerSender extends ReplyToChangeSender {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-
-  public interface Factory extends
-      ReplyToChangeSender.Factory<DeleteReviewerSender> {
-    @Override
-    DeleteReviewerSender create(Project.NameKey project, Change.Id change);
-  }
-
-  @Inject
-  public DeleteReviewerSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "deleteReviewer", newChangeData(ea, project, id));
-  }
-
-  public void addReviewers(Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    ccExistingReviewers();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-    add(RecipientType.TO, reviewers);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("DeleteReviewer.vm"));
-  }
-
-  public List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    return names;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java
deleted file mode 100644
index d861109..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/DeleteVoteSender.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a vote that was removed from a change. */
-public class DeleteVoteSender extends ReplyToChangeSender {
-  public interface Factory extends
-      ReplyToChangeSender.Factory<DeleteVoteSender> {
-    @Override
-    DeleteVoteSender create(Project.NameKey project, Change.Id change);
-  }
-
-  @Inject
-  protected DeleteVoteSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "deleteVote", newChangeData(ea, project, id));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("DeleteVote.vm"));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java
deleted file mode 100644
index 68e5e50..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailArguments.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// Copyright (C) 2010 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.mail;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.IdentifiedUser.GenericFactory;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.CapabilityControl;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupIncludeCache;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
-import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-import org.apache.velocity.runtime.RuntimeInstance;
-import org.eclipse.jgit.lib.PersonIdent;
-
-import java.util.List;
-
-public class EmailArguments {
-  final GitRepositoryManager server;
-  final ProjectCache projectCache;
-  final GroupBackend groupBackend;
-  final GroupIncludeCache groupIncludes;
-  final AccountCache accountCache;
-  final PatchListCache patchListCache;
-  final ApprovalsUtil approvalsUtil;
-  final FromAddressGenerator fromAddressGenerator;
-  final EmailSender emailSender;
-  final PatchSetInfoFactory patchSetInfoFactory;
-  final IdentifiedUser.GenericFactory identifiedUserFactory;
-  final CapabilityControl.Factory capabilityControlFactory;
-  final ChangeNotes.Factory changeNotesFactory;
-  final AnonymousUser anonymousUser;
-  final String anonymousCowardName;
-  final PersonIdent gerritPersonIdent;
-  final Provider<String> urlProvider;
-  final AllProjectsName allProjectsName;
-  final List<String> sshAddresses;
-
-  final ChangeQueryBuilder queryBuilder;
-  final Provider<ReviewDb> db;
-  final ChangeData.Factory changeDataFactory;
-  final RuntimeInstance velocityRuntime;
-  final EmailSettings settings;
-  final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
-  final StarredChangesUtil starredChangesUtil;
-  final AccountIndexCollection accountIndexes;
-  final Provider<InternalAccountQuery> accountQueryProvider;
-
-  @Inject
-  EmailArguments(GitRepositoryManager server, ProjectCache projectCache,
-      GroupBackend groupBackend, GroupIncludeCache groupIncludes,
-      AccountCache accountCache,
-      PatchListCache patchListCache,
-      ApprovalsUtil approvalsUtil,
-      FromAddressGenerator fromAddressGenerator,
-      EmailSender emailSender, PatchSetInfoFactory patchSetInfoFactory,
-      GenericFactory identifiedUserFactory,
-      CapabilityControl.Factory capabilityControlFactory,
-      ChangeNotes.Factory changeNotesFactory,
-      AnonymousUser anonymousUser,
-      @AnonymousCowardName String anonymousCowardName,
-      GerritPersonIdentProvider gerritPersonIdentProvider,
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AllProjectsName allProjectsName,
-      ChangeQueryBuilder queryBuilder,
-      Provider<ReviewDb> db,
-      ChangeData.Factory changeDataFactory,
-      RuntimeInstance velocityRuntime,
-      EmailSettings settings,
-      @SshAdvertisedAddresses List<String> sshAddresses,
-      DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
-      StarredChangesUtil starredChangesUtil,
-      AccountIndexCollection accountIndexes,
-      Provider<InternalAccountQuery> accountQueryProvider) {
-    this.server = server;
-    this.projectCache = projectCache;
-    this.groupBackend = groupBackend;
-    this.groupIncludes = groupIncludes;
-    this.accountCache = accountCache;
-    this.patchListCache = patchListCache;
-    this.approvalsUtil = approvalsUtil;
-    this.fromAddressGenerator = fromAddressGenerator;
-    this.emailSender = emailSender;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.identifiedUserFactory = identifiedUserFactory;
-    this.capabilityControlFactory = capabilityControlFactory;
-    this.changeNotesFactory = changeNotesFactory;
-    this.anonymousUser = anonymousUser;
-    this.anonymousCowardName = anonymousCowardName;
-    this.gerritPersonIdent = gerritPersonIdentProvider.get();
-    this.urlProvider = urlProvider;
-    this.allProjectsName = allProjectsName;
-    this.queryBuilder = queryBuilder;
-    this.db = db;
-    this.changeDataFactory = changeDataFactory;
-    this.velocityRuntime = velocityRuntime;
-    this.settings = settings;
-    this.sshAddresses = sshAddresses;
-    this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
-    this.starredChangesUtil = starredChangesUtil;
-    this.accountIndexes = accountIndexes;
-    this.accountQueryProvider = accountQueryProvider;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java
deleted file mode 100644
index 6a964a3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailHeader.java
+++ /dev/null
@@ -1,245 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-
-import java.io.IOException;
-import java.io.Writer;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Locale;
-import java.util.Objects;
-
-public abstract class EmailHeader {
-  public abstract boolean isEmpty();
-
-  public abstract void write(Writer w) throws IOException;
-
-  public static class String extends EmailHeader {
-    private final java.lang.String value;
-
-    public String(java.lang.String v) {
-      value = v;
-    }
-
-    public java.lang.String getString() {
-      return value;
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return value == null || value.length() == 0;
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      if (needsQuotedPrintable(value)) {
-        w.write(quotedPrintable(value));
-      } else {
-        w.write(value);
-      }
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(value);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof String)
-          && Objects.equals(value, ((String) o).value);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(value).toString();
-    }
-  }
-
-  static boolean needsQuotedPrintable(java.lang.String value) {
-    for (int i = 0; i < value.length(); i++) {
-      if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  static boolean needsQuotedPrintableWithinPhrase(final int cp) {
-    switch (cp) {
-      case '!':
-      case '*':
-      case '+':
-      case '-':
-      case '/':
-      case '=':
-      case '_':
-        return false;
-      default:
-        if (('a' <= cp && cp <= 'z')
-            || ('A' <= cp && cp <= 'Z')
-            || ('0' <= cp && cp <= '9')) {
-          return false;
-        }
-        return true;
-    }
-  }
-
-  static java.lang.String quotedPrintable(java.lang.String value) {
-    final StringBuilder r = new StringBuilder();
-
-    r.append("=?UTF-8?Q?");
-    for (int i = 0; i < value.length(); i++) {
-      final int cp = value.codePointAt(i);
-      if (cp == ' ') {
-        r.append('_');
-
-      } else if (needsQuotedPrintableWithinPhrase(cp)) {
-        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
-        for (byte b: buf) {
-          r.append('=');
-          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
-          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
-        }
-
-      } else {
-        r.append(Character.toChars(cp));
-      }
-    }
-    r.append("?=");
-
-    return r.toString();
-  }
-
-  public static class Date extends EmailHeader {
-    private final java.util.Date value;
-
-    public Date(java.util.Date v) {
-      value = v;
-    }
-
-    public java.util.Date getDate() {
-      return value;
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return value == null;
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      final SimpleDateFormat fmt;
-      // Mon, 1 Jun 2009 10:49:44 -0700
-      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
-      w.write(fmt.format(value));
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(value);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof Date)
-          && Objects.equals(value, ((Date) o).value);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(value).toString();
-    }
-  }
-
-  public static class AddressList extends EmailHeader {
-    private final List<Address> list = new ArrayList<>();
-
-    public AddressList() {
-    }
-
-    public AddressList(Address addr) {
-      add(addr);
-    }
-
-    public List<Address> getAddressList() {
-      return Collections.unmodifiableList(list);
-    }
-
-    public void add(Address addr) {
-      list.add(addr);
-    }
-
-    void remove(java.lang.String email) {
-      for (Iterator<Address> i = list.iterator(); i.hasNext();) {
-        if (i.next().email.equals(email)) {
-          i.remove();
-        }
-      }
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return list.isEmpty();
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      int len = 8;
-      boolean firstAddress = true;
-      boolean needComma = false;
-      for (final Address addr : list) {
-        java.lang.String s = addr.toHeaderString();
-        if (firstAddress) {
-          firstAddress = false;
-        } else if (72 < len + s.length()) {
-          w.write(",\r\n\t");
-          len = 8;
-          needComma = false;
-        }
-
-        if (needComma) {
-          w.write(", ");
-        }
-        w.write(s);
-        len += s.length();
-        needComma = true;
-      }
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(list);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof AddressList)
-          && Objects.equals(list, ((AddressList) o).list);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(list).toString();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
index 7ceb0ae..9bf97dd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailModule.java
@@ -15,6 +15,12 @@
 package com.google.gerrit.server.mail;
 
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.RestoredSender;
+import com.google.gerrit.server.mail.send.RevertedSender;
 
 public class EmailModule extends FactoryModule {
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSender.java
deleted file mode 100644
index a7a1028..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSender.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-
-import java.util.Collection;
-import java.util.Map;
-
-/** Sends email messages to third parties. */
-public interface EmailSender {
-  boolean isEnabled();
-
-  /**
-   * Can the address receive messages from us?
-   *
-   * @param address the address to consider.
-   * @return true if this sender will deliver to the address.
-   */
-  boolean canEmail(String address);
-
-  /**
-   * Sends an email message.
-   *
-   * @param from who the message is from.
-   * @param rcpt one or more address where the message will be delivered to.
-   *        This list overrides any To or CC headers in {@code headers}.
-   * @param headers message headers.
-   * @param body text to appear in the body of the message.
-   * @throws EmailException the message cannot be sent.
-   */
-  void send(Address from, Collection<Address> rcpt,
-      Map<String, EmailHeader> headers, String body) throws EmailException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java
index 3c14f2f..6bdb076 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -15,19 +15,48 @@
 package com.google.gerrit.server.mail;
 
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.receive.Protocol;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class EmailSettings {
+  private static final String SEND_EMAL = "sendemail";
+  private static final String RECEIVE_EMAL = "receiveemail";
+  // Send
+  public final boolean html;
   public final boolean includeDiff;
   public final int maximumDiffSize;
+  // Receive
+  public final Protocol protocol;
+  public final String host;
+  public final int port;
+  public final String username;
+  public final String password;
+  public final Encryption encryption;
+  public final long fetchInterval; // in milliseconds
 
   @Inject
   EmailSettings(@GerritServerConfig Config cfg) {
-    includeDiff = cfg.getBoolean("sendemail", "includeDiff", false);
-    maximumDiffSize = cfg.getInt("sendemail", "maximumDiffSize", 256 << 10);
+    // Send
+    html = cfg.getBoolean(SEND_EMAL, "html", true);
+    includeDiff = cfg.getBoolean(SEND_EMAL, "includeDiff", false);
+    maximumDiffSize = cfg.getInt(SEND_EMAL, "maximumDiffSize", 256 << 10);
+    // Receive
+    protocol = cfg.getEnum(RECEIVE_EMAL, null, "protocol", Protocol.NONE);
+    host = cfg.getString(RECEIVE_EMAL, null, "host");
+    port = cfg.getInt(RECEIVE_EMAL, "port", 0);
+    username = cfg.getString(RECEIVE_EMAL, null, "username");
+    password = cfg.getString(RECEIVE_EMAL, null, "password");
+    encryption = cfg.getEnum(RECEIVE_EMAL, null, "encryption", Encryption.NONE);
+    fetchInterval =
+        cfg.getTimeUnit(
+            RECEIVE_EMAL,
+            null,
+            "fetchInterval",
+            TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
+            TimeUnit.MILLISECONDS);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index 41e1e2c..5b5f33d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 
 /** Verifies the token sent by {@link RegisterNewEmailSender}. */
 public interface EmailTokenVerifier {
@@ -24,14 +25,14 @@
    *
    * @param accountId the caller that wants to add an email to their account.
    * @param emailAddress the address to add.
-   * @return an unforgeable string to email to {@code emailAddress}. Presenting
-   *         the string provides proof the user has the ability to read messages
-   *         sent to that address. Must not be null.
+   * @return an unforgeable string to email to {@code emailAddress}. Presenting the string provides
+   *     proof the user has the ability to read messages sent to that address. Must not be null.
    */
   String encode(Account.Id accountId, String emailAddress);
 
   /**
    * Decode a token previously created.
+   *
    * @param tokenString the string created by encode. Never null.
    * @return a pair of account id and email address.
    * @throws InvalidTokenException the token is invalid, expired, malformed, etc.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Encryption.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Encryption.java
new file mode 100644
index 0000000..99c3022
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Encryption.java
@@ -0,0 +1,21 @@
+// 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.mail;
+
+public enum Encryption {
+  NONE,
+  SSL,
+  TLS
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
deleted file mode 100644
index 9bcabc3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGenerator.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.gerrit.reviewdb.client.Account;
-
-/** Constructs an address to send email from. */
-public interface FromAddressGenerator {
-  boolean isGenericAddress(Account.Id fromId);
-
-  Address from(Account.Id fromId);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGeneratorProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGeneratorProvider.java
deleted file mode 100644
index 51f7ad1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/FromAddressGeneratorProvider.java
+++ /dev/null
@@ -1,189 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.common.data.ParameterizedString;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-import org.apache.commons.codec.binary.Base64;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-
-/** Creates a {@link FromAddressGenerator} from the {@link GerritServerConfig} */
-@Singleton
-public class FromAddressGeneratorProvider implements
-    Provider<FromAddressGenerator> {
-  private final FromAddressGenerator generator;
-
-  @Inject
-  FromAddressGeneratorProvider(@GerritServerConfig final Config cfg,
-      @AnonymousCowardName final String anonymousCowardName,
-      @GerritPersonIdent final PersonIdent myIdent,
-      final AccountCache accountCache) {
-
-    final String from = cfg.getString("sendemail", null, "from");
-    final Address srvAddr = toAddress(myIdent);
-
-    if (from == null || "MIXED".equalsIgnoreCase(from)) {
-      ParameterizedString name = new ParameterizedString("${user} (Code Review)");
-      generator =
-          new PatternGen(srvAddr, accountCache, anonymousCowardName, name,
-              srvAddr.email);
-
-    } else if ("USER".equalsIgnoreCase(from)) {
-      generator = new UserGen(accountCache, srvAddr);
-
-    } else if ("SERVER".equalsIgnoreCase(from)) {
-      generator = new ServerGen(srvAddr);
-
-    } else {
-      final Address a = Address.parse(from);
-      final ParameterizedString name = a.name != null ? new ParameterizedString(a.name) : null;
-      if (name == null || name.getParameterNames().isEmpty()) {
-        generator = new ServerGen(a);
-      } else {
-        generator =
-            new PatternGen(srvAddr, accountCache, anonymousCowardName, name,
-                a.email);
-      }
-    }
-  }
-
-  private static Address toAddress(final PersonIdent myIdent) {
-    return new Address(myIdent.getName(), myIdent.getEmailAddress());
-  }
-
-  @Override
-  public FromAddressGenerator get() {
-    return generator;
-  }
-
-  static final class UserGen implements FromAddressGenerator {
-    private final AccountCache accountCache;
-    private final Address srvAddr;
-
-    UserGen(AccountCache accountCache, Address srvAddr) {
-      this.accountCache = accountCache;
-      this.srvAddr = srvAddr;
-    }
-
-    @Override
-    public boolean isGenericAddress(Account.Id fromId) {
-      return false;
-    }
-
-    @Override
-    public Address from(final Account.Id fromId) {
-      if (fromId != null) {
-        Account a = accountCache.get(fromId).getAccount();
-        String userEmail = a.getPreferredEmail();
-        return new Address(
-            a.getFullName(),
-            userEmail != null ? userEmail : srvAddr.getEmail());
-      }
-      return srvAddr;
-    }
-  }
-
-  static final class ServerGen implements FromAddressGenerator {
-    private final Address srvAddr;
-
-    ServerGen(Address srvAddr) {
-      this.srvAddr = srvAddr;
-    }
-
-    @Override
-    public boolean isGenericAddress(Account.Id fromId) {
-      return true;
-    }
-
-    @Override
-    public Address from(final Account.Id fromId) {
-      return srvAddr;
-    }
-  }
-
-  static final class PatternGen implements FromAddressGenerator {
-    private final ParameterizedString senderEmailPattern;
-    private final Address serverAddress;
-    private final AccountCache accountCache;
-    private final String anonymousCowardName;
-    private final ParameterizedString namePattern;
-
-    PatternGen(final Address serverAddress, final AccountCache accountCache,
-        final String anonymousCowardName,
-        final ParameterizedString namePattern, final String senderEmail) {
-      this.senderEmailPattern = new ParameterizedString(senderEmail);
-      this.serverAddress = serverAddress;
-      this.accountCache = accountCache;
-      this.anonymousCowardName = anonymousCowardName;
-      this.namePattern = namePattern;
-    }
-
-    @Override
-    public boolean isGenericAddress(Account.Id fromId) {
-      return false;
-    }
-
-    @Override
-    public Address from(final Account.Id fromId) {
-      final String senderName;
-
-      if (fromId != null) {
-        final Account account = accountCache.get(fromId).getAccount();
-        String fullName = account.getFullName();
-        if (fullName == null || "".equals(fullName)) {
-          fullName = anonymousCowardName;
-        }
-        senderName = namePattern.replace("user", fullName).toString();
-
-      } else {
-        senderName = serverAddress.name;
-      }
-
-      String senderEmail;
-      if (senderEmailPattern.getParameterNames().isEmpty()) {
-        senderEmail = senderEmailPattern.getRawPattern();
-      } else {
-        senderEmail = senderEmailPattern
-            .replace("userHash", hashOf(senderName))
-            .toString();
-      }
-      return new Address(senderName, senderEmail);
-    }
-  }
-
-  private static String hashOf(String data) {
-    try {
-      MessageDigest hash = MessageDigest.getInstance("MD5");
-      byte[] bytes = hash.digest(data.getBytes(UTF_8));
-      return Base64.encodeBase64URLSafeString(bytes);
-    } catch (NoSuchAlgorithmException e) {
-      throw new RuntimeException("No MD5 available", e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ListMailFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ListMailFilter.java
new file mode 100644
index 0000000..2e7c828
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class ListMailFilter implements MailFilter {
+  public enum ListFilterMode {
+    OFF,
+    WHITELIST,
+    BLACKLIST
+  }
+
+  private static final Logger log = LoggerFactory.getLogger(ListMailFilter.class);
+
+  private final ListFilterMode mode;
+  private final Pattern mailPattern;
+
+  @Inject
+  ListMailFilter(@GerritServerConfig Config cfg) {
+    this.mode = cfg.getEnum("receiveemail", "filter", "mode", ListFilterMode.OFF);
+    String[] addresses = cfg.getStringList("receiveemail", "filter", "patterns");
+    String concat = Arrays.asList(addresses).stream().collect(joining("|"));
+    this.mailPattern = Pattern.compile(concat);
+  }
+
+  @Override
+  public boolean shouldProcessMessage(MailMessage message) {
+    if (mode == ListFilterMode.OFF) {
+      return true;
+    }
+
+    boolean match = mailPattern.matcher(message.from().email).find();
+    if ((mode == ListFilterMode.WHITELIST && !match)
+        || (mode == ListFilterMode.BLACKLIST && match)) {
+      log.info("Mail message from " + message.from() + " rejected by list filter");
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailFilter.java
new file mode 100644
index 0000000..d50064d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailFilter.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.mail.receive.MailMessage;
+
+/**
+ * Listener to filter incoming email.
+ *
+ * <p>Invoked by Gerrit for each incoming email.
+ */
+@ExtensionPoint
+public interface MailFilter {
+  /**
+   * Determine if Gerrit should discard or further process the message.
+   *
+   * @param message MailMessage parsed by Gerrit.
+   * @return {@code true}, if Gerrit should process the message, {@code false} otherwise.
+   */
+  boolean shouldProcessMessage(MailMessage message);
+}
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 048a4a4..b6d7fa8 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
@@ -24,29 +24,34 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gwtorm.server.OrmException;
-
-import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.revwalk.FooterLine;
-
+import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.revwalk.FooterLine;
 
 public class MailUtil {
+  public static DateTimeFormatter rfcDateformatter =
+      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
+
   public static MailRecipients getRecipientsFromFooters(
-      ReviewDb db, AccountResolver accountResolver, boolean draftPatchSet,
-      List<FooterLine> footerLines) throws OrmException {
+      ReviewDb db,
+      AccountResolver accountResolver,
+      boolean draftPatchSet,
+      List<FooterLine> footerLines)
+      throws OrmException {
     MailRecipients recipients = new MailRecipients();
     if (!draftPatchSet) {
       for (FooterLine footerLine : footerLines) {
         try {
           if (isReviewer(footerLine)) {
-            recipients.reviewers.add(toAccountId(db, accountResolver, footerLine
-                .getValue().trim()));
+            recipients.reviewers.add(
+                toAccountId(db, accountResolver, footerLine.getValue().trim()));
           } else if (footerLine.matches(FooterKey.CC)) {
-            recipients.cc.add(toAccountId(db, accountResolver, footerLine
-                .getValue().trim()));
+            recipients.cc.add(toAccountId(db, accountResolver, footerLine.getValue().trim()));
           }
         } catch (NoSuchAccountException e) {
           continue;
@@ -56,21 +61,19 @@
     return recipients;
   }
 
-  public static MailRecipients getRecipientsFromReviewers(
-      ReviewerSet reviewers) {
+  public static MailRecipients getRecipientsFromReviewers(ReviewerSet reviewers) {
     MailRecipients recipients = new MailRecipients();
     recipients.reviewers.addAll(reviewers.byState(REVIEWER));
     recipients.cc.addAll(reviewers.byState(CC));
     return recipients;
   }
 
-  private static Account.Id toAccountId(ReviewDb db,
-      AccountResolver accountResolver, String nameOrEmail)
+  private static Account.Id toAccountId(
+      ReviewDb db, AccountResolver accountResolver, String nameOrEmail)
       throws OrmException, NoSuchAccountException {
     Account a = accountResolver.findByNameOrEmail(db, nameOrEmail);
     if (a == null) {
-      throw new NoSuchAccountException("\"" + nameOrEmail
-          + "\" is not registered");
+      throw new NoSuchAccountException("\"" + nameOrEmail + "\" is not registered");
     }
     return a.getId();
   }
@@ -91,8 +94,7 @@
       this.cc = new HashSet<>();
     }
 
-    public MailRecipients(final Set<Account.Id> reviewers,
-        final Set<Account.Id> cc) {
+    public MailRecipients(final Set<Account.Id> reviewers, final Set<Account.Id> cc) {
       this.reviewers = new HashSet<>(reviewers);
       this.cc = new HashSet<>(cc);
     }
@@ -124,4 +126,19 @@
       return Collections.unmodifiableSet(all);
     }
   }
+
+  /** allow wildcard matching for {@code domains} */
+  public static Pattern glob(String[] domains) {
+    // if domains is not set, match anything
+    if (domains == null || domains.length == 0) {
+      return Pattern.compile(".*");
+    }
+
+    StringBuilder sb = new StringBuilder("");
+    for (String domain : domains) {
+      String quoted = "\\Q" + domain.replace("\\E", "\\E\\\\E\\Q") + "\\E|";
+      sb.append(quoted.replace("*", "\\E.*\\Q"));
+    }
+    return Pattern.compile(sb.substring(0, sb.length() - 1));
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
deleted file mode 100644
index f6c3d0f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.Table;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change successfully merged. */
-public class MergedSender extends ReplyToChangeSender {
-  public interface Factory {
-    MergedSender create(Project.NameKey project, Change.Id id);
-  }
-
-  private final LabelTypes labelTypes;
-
-  @Inject
-  public MergedSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "merged", newChangeData(ea, project, id));
-    labelTypes = changeData.changeControl().getLabelTypes();
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-    includeWatchers(NotifyType.SUBMITTED_CHANGES);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("Merged.vm"));
-  }
-
-  public String getApprovals() {
-    try {
-      Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
-      Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
-      for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(
-            args.db.get(), changeData.changeControl(), patchSet.getId())) {
-        LabelType lt = labelTypes.byLabel(ca.getLabelId());
-        if (lt == null) {
-          continue;
-        }
-        if (ca.getValue() > 0) {
-          pos.put(ca.getAccountId(), lt.getName(), ca);
-        } else if (ca.getValue() < 0) {
-          neg.put(ca.getAccountId(), lt.getName(), ca);
-        }
-      }
-
-      return format("Approvals", pos) + format("Objections", neg);
-    } catch (OrmException err) {
-      // Don't list the approvals
-    }
-    return "";
-  }
-
-  private String format(String type,
-      Table<Account.Id, String, PatchSetApproval> approvals) {
-    StringBuilder txt = new StringBuilder();
-    if (approvals.isEmpty()) {
-      return "";
-    }
-    txt.append(type).append(":\n");
-    for (Account.Id id : approvals.rowKeySet()) {
-      txt.append("  ");
-      txt.append(getNameFor(id));
-      txt.append(": ");
-      boolean first = true;
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        PatchSetApproval ca = approvals.get(id, lt.getName());
-        if (ca == null) {
-          continue;
-        }
-
-        if (first) {
-          first = false;
-        } else {
-          txt.append("; ");
-        }
-
-        LabelValue v = lt.getValue(ca);
-        if (v != null) {
-          txt.append(v.getText());
-        } else {
-          txt.append(lt.getName());
-          txt.append('=');
-          txt.append(LabelValue.formatValue(ca.getValue()));
-        }
-      }
-      txt.append('\n');
-    }
-    txt.append('\n');
-    return txt.toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MetadataName.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MetadataName.java
new file mode 100644
index 0000000..3080e4f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MetadataName.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail;
+
+public final class MetadataName {
+  public static final String CHANGE_NUMBER = "Gerrit-Change-Number";
+  public static final String PATCH_SET = "Gerrit-PatchSet";
+  public static final String MESSAGE_TYPE = "Gerrit-MessageType";
+  public static final String TIMESTAMP = "Gerrit-Comment-Date";
+
+  public static String toHeader(String metadataName) {
+    return "X-" + metadataName;
+  }
+
+  public static String toHeaderWithDelimiter(String metadataName) {
+    return toHeader(metadataName) + ": ";
+  }
+
+  public static String toFooterWithDelimiter(String metadataName) {
+    return metadataName + ": ";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java
deleted file mode 100644
index 62385d9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NewChangeSender.java
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Sends an email alerting a user to a new change for them to review. */
-public abstract class NewChangeSender extends ChangeEmail {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Account.Id> extraCC = new HashSet<>();
-
-  protected NewChangeSender(EmailArguments ea, ChangeData cd)
-      throws OrmException {
-    super(ea, "newchange", cd);
-  }
-
-  public void addReviewers(final Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addExtraCC(final Collection<Account.Id> cc) {
-    extraCC.addAll(cc);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    setHeader("Message-ID", getChangeMessageThreadId());
-
-    switch (notify) {
-      case NONE:
-      case OWNER:
-        break;
-      case ALL:
-      default:
-        add(RecipientType.CC, extraCC);
-        //$FALL-THROUGH$
-      case OWNER_REVIEWERS:
-        add(RecipientType.TO, reviewers);
-        break;
-    }
-
-    rcptToAuthors(RecipientType.CC);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("NewChange.vm"));
-  }
-
-  public List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    return names;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NotificationEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/NotificationEmail.java
deleted file mode 100644
index de338ec..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/NotificationEmail.java
+++ /dev/null
@@ -1,106 +0,0 @@
-// Copyright (C) 2012 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.mail;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.mail.ProjectWatch.Watchers;
-import com.google.gwtorm.server.OrmException;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Common class for notifications that are related to a project and branch
- */
-public abstract class NotificationEmail extends OutgoingEmail {
-  private static final Logger log =
-      LoggerFactory.getLogger(NotificationEmail.class);
-
-  protected Branch.NameKey branch;
-
-  protected NotificationEmail(EmailArguments ea,
-      String mc, Branch.NameKey branch) {
-    super(ea, mc);
-    this.branch = branch;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setListIdHeader();
-  }
-
-  private void setListIdHeader() throws EmailException {
-    // Set a reasonable list id so that filters can be used to sort messages
-    setVHeader("List-Id", "<$email.listId.replace('@', '.')>");
-    if (getSettingsUrl() != null) {
-      setVHeader("List-Unsubscribe", "<$email.settingsUrl>");
-    }
-  }
-
-  public String getListId() throws EmailException {
-    return velocify("gerrit-$projectName.replace('/', '-')@$email.gerritHost");
-  }
-
-  /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type) {
-    try {
-      Watchers matching = getWatchers(type);
-      add(RecipientType.TO, matching.to);
-      add(RecipientType.CC, matching.cc);
-      add(RecipientType.BCC, matching.bcc);
-    } catch (OrmException err) {
-      // Just don't CC everyone. Better to send a partial message to those
-      // we already have queued up then to fail deliver entirely to people
-      // who have a lower interest in the change.
-      log.warn("Cannot BCC watchers for " + type, err);
-    }
-  }
-
-  /** Returns all watchers that are relevant */
-  protected abstract Watchers getWatchers(NotifyType type) throws OrmException;
-
-  /** Add users or email addresses to the TO, CC, or BCC list. */
-  protected void add(RecipientType type, Watchers.List list) {
-    for (Account.Id user : list.accounts) {
-      add(type, user);
-    }
-    for (Address addr : list.emails) {
-      add(type, addr);
-    }
-  }
-
-  public String getSshHost() {
-    String host = Iterables.getFirst(args.sshAddresses, null);
-    if (host == null) {
-      return null;
-    }
-    if (host.startsWith("*:")) {
-      return getGerritHost() + host.substring(1);
-    }
-    return host;
-  }
-
-  @Override
-  protected void setupVelocityContext() {
-    super.setupVelocityContext();
-    velocityContext.put("projectName", branch.getParentKey().get());
-    velocityContext.put("branch", branch);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
deleted file mode 100644
index 6200688..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java
+++ /dev/null
@@ -1,507 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
-import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.UserIdentity;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.EmailHeader.AddressList;
-import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.gwtorm.server.OrmException;
-
-import org.apache.commons.lang.StringUtils;
-import org.apache.velocity.Template;
-import org.apache.velocity.VelocityContext;
-import org.apache.velocity.context.InternalContextAdapterImpl;
-import org.apache.velocity.runtime.RuntimeInstance;
-import org.apache.velocity.runtime.parser.node.SimpleNode;
-import org.eclipse.jgit.util.SystemReader;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.StringReader;
-import java.io.StringWriter;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.Collection;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Set;
-
-/** Sends an email to one or more interested parties. */
-public abstract class OutgoingEmail {
-  private static final Logger log = LoggerFactory.getLogger(OutgoingEmail.class);
-
-  private static final String HDR_TO = "To";
-  private static final String HDR_CC = "CC";
-
-  protected String messageClass;
-  private final HashSet<Account.Id> rcptTo = new HashSet<>();
-  private final Map<String, EmailHeader> headers;
-  private final Set<Address> smtpRcptTo = new HashSet<>();
-  private Address smtpFromAddress;
-  private StringBuilder body;
-  protected VelocityContext velocityContext;
-
-  protected final EmailArguments args;
-  protected Account.Id fromId;
-  protected NotifyHandling notify = NotifyHandling.ALL;
-
-  protected OutgoingEmail(EmailArguments ea, String mc) {
-    args = ea;
-    messageClass = mc;
-    headers = new LinkedHashMap<>();
-  }
-
-  public void setFrom(final Account.Id id) {
-    fromId = id;
-  }
-
-  public void setNotify(NotifyHandling notify) {
-    this.notify = notify;
-  }
-
-  /**
-   * Format and enqueue the message for delivery.
-   *
-   * @throws EmailException
-   */
-  public void send() throws EmailException {
-    if (NotifyHandling.NONE.equals(notify)) {
-      return;
-    }
-
-    if (!args.emailSender.isEnabled()) {
-      // Server has explicitly disabled email sending.
-      //
-      return;
-    }
-
-    init();
-    format();
-    appendText(velocifyFile("Footer.vm"));
-    if (shouldSendMessage()) {
-      if (fromId != null) {
-        final Account fromUser = args.accountCache.get(fromId).getAccount();
-        GeneralPreferencesInfo senderPrefs = fromUser.getGeneralPreferencesInfo();
-
-        if (senderPrefs != null
-            && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
-          // If we are impersonating a user, make sure they receive a CC of
-          // this message so they can always review and audit what we sent
-          // on their behalf to others.
-          //
-          add(RecipientType.CC, fromId);
-        } else if (rcptTo.remove(fromId)) {
-          // If they don't want a copy, but we queued one up anyway,
-          // drop them from the recipient lists.
-          //
-          removeUser(fromUser);
-        }
-
-        // Check the preferences of all recipients. If any user has disabled
-        // his email notifications then drop him from recipients' list
-        for (Account.Id id : rcptTo) {
-          Account thisUser = args.accountCache.get(id).getAccount();
-          GeneralPreferencesInfo prefs = thisUser.getGeneralPreferencesInfo();
-          if (prefs == null || prefs.getEmailStrategy() == DISABLED) {
-            removeUser(thisUser);
-          }
-          if (smtpRcptTo.isEmpty()) {
-            return;
-          }
-        }
-      }
-
-      OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
-      va.messageClass = messageClass;
-      va.smtpFromAddress = smtpFromAddress;
-      va.smtpRcptTo = smtpRcptTo;
-      va.headers = headers;
-      va.body = body.toString();
-      for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
-        try {
-          validator.validateOutgoingEmail(va);
-        } catch (ValidationException e) {
-          return;
-        }
-      }
-
-      args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body);
-    }
-  }
-
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void format() throws EmailException;
-
-  /**
-   * Setup the message headers and envelope (TO, CC, BCC).
-   *
-   * @throws EmailException if an error occurred.
-   */
-  protected void init() throws EmailException {
-    setupVelocityContext();
-
-    smtpFromAddress = args.fromAddressGenerator.from(fromId);
-    setHeader("Date", new Date());
-    headers.put("From", new EmailHeader.AddressList(smtpFromAddress));
-    headers.put(HDR_TO, new EmailHeader.AddressList());
-    headers.put(HDR_CC, new EmailHeader.AddressList());
-    setHeader("Message-ID", "");
-
-    if (fromId != null) {
-      // If we have a user that this message is supposedly caused by
-      // but the From header on the email does not match the user as
-      // it is a generic header for this Gerrit server, include the
-      // Reply-To header with the current user's email address.
-      //
-      final Address a = toAddress(fromId);
-      if (a != null && !smtpFromAddress.email.equals(a.email)) {
-        setHeader("Reply-To", a.email);
-      }
-    }
-
-    setHeader("X-Gerrit-MessageType", messageClass);
-    body = new StringBuilder();
-
-    if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) {
-      appendText(getFromLine());
-    }
-  }
-
-  protected String getFromLine() {
-    final Account account = args.accountCache.get(fromId).getAccount();
-    final String name = account.getFullName();
-    final String email = account.getPreferredEmail();
-    StringBuilder f = new StringBuilder();
-
-    if ((name != null && !name.isEmpty())
-        || (email != null && !email.isEmpty())) {
-      f.append("From");
-      if (name != null && !name.isEmpty()) {
-        f.append(" ").append(name);
-      }
-      if (email != null && !email.isEmpty()) {
-        f.append(" <").append(email).append(">");
-      }
-      f.append(":\n\n");
-    }
-    return f.toString();
-  }
-
-  public String getGerritHost() {
-    if (getGerritUrl() != null) {
-      try {
-        return new URL(getGerritUrl()).getHost();
-      } catch (MalformedURLException e) {
-        // Try something else.
-      }
-    }
-
-    // Fall back onto whatever the local operating system thinks
-    // this server is called. We hopefully didn't get here as a
-    // good admin would have configured the canonical url.
-    //
-    return SystemReader.getInstance().getHostname();
-  }
-
-  public String getSettingsUrl() {
-    if (getGerritUrl() != null) {
-      final StringBuilder r = new StringBuilder();
-      r.append(getGerritUrl());
-      r.append("settings");
-      return r.toString();
-    }
-    return null;
-  }
-
-  public String getGerritUrl() {
-    return args.urlProvider.get();
-  }
-
-  /** Set a header in the outgoing message using a template. */
-  protected void setVHeader(final String name, final String value) throws
-      EmailException {
-    setHeader(name, velocify(value));
-  }
-
-  /** Set a header in the outgoing message. */
-  protected void setHeader(final String name, final String value) {
-    headers.put(name, new EmailHeader.String(value));
-  }
-
-  protected void setHeader(final String name, final Date date) {
-    headers.put(name, new EmailHeader.Date(date));
-  }
-
-  /** Append text to the outgoing email body. */
-  protected void appendText(final String text) {
-    if (text != null) {
-      body.append(text);
-    }
-  }
-
-  /** Lookup a human readable name for an account, usually the "full name". */
-  protected String getNameFor(final Account.Id accountId) {
-    if (accountId == null) {
-      return args.gerritPersonIdent.getName();
-    }
-
-    final Account userAccount = args.accountCache.get(accountId).getAccount();
-    String name = userAccount.getFullName();
-    if (name == null) {
-      name = userAccount.getPreferredEmail();
-    }
-    if (name == null) {
-      name = args.anonymousCowardName + " #" + accountId;
-    }
-    return name;
-  }
-
-  /**
-   * Gets the human readable name and email for an account;
-   * if neither are available, returns the Anonymous Coward name.
-   *
-   * @param accountId user to fetch.
-   * @return name/email of account, or Anonymous Coward if unset.
-   */
-  public String getNameEmailFor(Account.Id accountId) {
-    AccountState who = args.accountCache.get(accountId);
-    String name = who.getAccount().getFullName();
-    String email = who.getAccount().getPreferredEmail();
-
-    if (name != null && email != null) {
-      return name + " <" + email + ">";
-
-    } else if (name != null) {
-      return name;
-    } else if (email != null) {
-      return email;
-
-    } else /* (name == null && email == null) */ {
-      return args.anonymousCowardName + " #" + accountId;
-    }
-  }
-
-  /**
-   * Gets the human readable name and email for an account;
-   * if both are unavailable, returns the username.  If no
-   * username is set, this function returns null.
-   *
-   * @param accountId user to fetch.
-   * @return name/email of account, username, or null if unset.
-   */
-  public String getUserNameEmailFor(Account.Id accountId) {
-    AccountState who = args.accountCache.get(accountId);
-    String name = who.getAccount().getFullName();
-    String email = who.getAccount().getPreferredEmail();
-
-    if (name != null && email != null) {
-      return name + " <" + email + ">";
-    } else if (email != null) {
-      return email;
-    } else if (name != null) {
-      return name;
-    }
-    String username = who.getUserName();
-    if (username != null) {
-      return username;
-    }
-    return null;
-  }
-
-  protected boolean shouldSendMessage() {
-    if (body.length() == 0) {
-      // If we have no message body, don't send.
-      return false;
-    }
-
-    if (smtpRcptTo.isEmpty()) {
-      // If we have nobody to send this message to, then all of our
-      // selection filters previously for this type of message were
-      // unable to match a destination. Don't bother sending it.
-      return false;
-    }
-
-    if (smtpRcptTo.size() == 1 && rcptTo.size() == 1 && rcptTo.contains(fromId)) {
-      // If the only recipient is also the sender, don't bother.
-      //
-      return false;
-    }
-
-    return true;
-  }
-
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(final RecipientType rt, final Collection<Account.Id> list) {
-    for (final Account.Id id : list) {
-      add(rt, id);
-    }
-  }
-
-  protected void add(final RecipientType rt, final UserIdentity who) {
-    if (who != null && who.getAccount() != null) {
-      add(rt, who.getAccount());
-    }
-  }
-
-  /** Schedule delivery of this message to the given account. */
-  protected void add(final RecipientType rt, final Account.Id to) {
-    try {
-      if (!rcptTo.contains(to) && isVisibleTo(to)) {
-        rcptTo.add(to);
-        add(rt, toAddress(to));
-      }
-    } catch (OrmException e) {
-      log.error("Error reading database for account: " + to, e);
-    }
-  }
-
-  /**
-   * @param to account.
-   * @throws OrmException
-   * @return whether this email is visible to the given account.
-   */
-  protected boolean isVisibleTo(final Account.Id to) throws OrmException {
-    return true;
-  }
-
-  /** Schedule delivery of this message to the given account. */
-  protected void add(final RecipientType rt, final Address addr) {
-    if (addr != null && addr.email != null && addr.email.length() > 0) {
-      if (!OutgoingEmailValidator.isValid(addr.email)) {
-        log.warn("Not emailing " + addr.email + " (invalid email address)");
-      } else if (!args.emailSender.canEmail(addr.email)) {
-        log.warn("Not emailing " + addr.email + " (prohibited by allowrcpt)");
-      } else if (smtpRcptTo.add(addr)) {
-        switch (rt) {
-          case TO:
-            ((EmailHeader.AddressList) headers.get(HDR_TO)).add(addr);
-            break;
-          case CC:
-            ((EmailHeader.AddressList) headers.get(HDR_CC)).add(addr);
-            break;
-          case BCC:
-            break;
-        }
-      }
-    }
-  }
-
-  private Address toAddress(final Account.Id id) {
-    final Account a = args.accountCache.get(id).getAccount();
-    final String e = a.getPreferredEmail();
-    if (!a.isActive() || e == null) {
-      return null;
-    }
-    return new Address(a.getFullName(), e);
-  }
-
-  protected void setupVelocityContext() {
-    velocityContext = new VelocityContext();
-
-    velocityContext.put("email", this);
-    velocityContext.put("messageClass", messageClass);
-    velocityContext.put("StringUtils", StringUtils.class);
-  }
-
-  protected String velocify(String template) throws EmailException {
-    try {
-      RuntimeInstance runtime = args.velocityRuntime;
-      String templateName = "OutgoingEmail";
-      SimpleNode tree = runtime.parse(new StringReader(template), templateName);
-      InternalContextAdapterImpl ica = new InternalContextAdapterImpl(velocityContext);
-      ica.pushCurrentTemplateName(templateName);
-      try {
-        tree.init(ica, runtime);
-        StringWriter w = new StringWriter();
-        tree.render(ica, w);
-        return w.toString();
-      } finally {
-        ica.popCurrentTemplateName();
-      }
-    } catch (Exception e) {
-      throw new EmailException("Cannot format velocity template: " + template, e);
-    }
-  }
-
-  protected String velocifyFile(String name) throws EmailException {
-    try {
-      RuntimeInstance runtime = args.velocityRuntime;
-      if (runtime.getLoaderNameForResource(name) == null) {
-        name = "com/google/gerrit/server/mail/" + name;
-      }
-      Template template = runtime.getTemplate(name, UTF_8.name());
-      StringWriter w = new StringWriter();
-      template.merge(velocityContext, w);
-      return w.toString();
-    } catch (Exception e) {
-      throw new EmailException("Cannot format velocity template " + name, e);
-    }
-  }
-
-  public String joinStrings(Iterable<Object> in, String joiner) {
-    return joinStrings(in.iterator(), joiner);
-  }
-
-  public String joinStrings(Iterator<Object> in, String joiner) {
-    if (!in.hasNext()) {
-      return "";
-    }
-
-    Object first = in.next();
-    if (!in.hasNext()) {
-      return safeToString(first);
-    }
-
-    StringBuilder r = new StringBuilder();
-    r.append(safeToString(first));
-    while (in.hasNext()) {
-      r.append(joiner).append(safeToString(in.next()));
-    }
-    return r.toString();
-  }
-
-  protected void removeUser(Account user) {
-    String fromEmail = user.getPreferredEmail();
-    for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext();) {
-      if (j.next().email.equals(fromEmail)) {
-        j.remove();
-      }
-    }
-    for (Map.Entry<String, EmailHeader> entry : headers.entrySet()) {
-      // Don't remove fromEmail from the "From" header though!
-      if (entry.getValue() instanceof AddressList
-          && !entry.getKey().equals("From")) {
-        ((AddressList) entry.getValue()).remove(fromEmail);
-      }
-    }
-  }
-
-  private static String safeToString(Object obj) {
-    return obj != null ? obj.toString() : "";
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmailValidator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmailValidator.java
deleted file mode 100644
index 5ab5f4e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmailValidator.java
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import static org.apache.commons.validator.routines.DomainValidator.ArrayType.GENERIC_PLUS;
-
-import org.apache.commons.validator.routines.DomainValidator;
-import org.apache.commons.validator.routines.EmailValidator;
-
-public class OutgoingEmailValidator {
-  static {
-    DomainValidator.updateTLDOverride(GENERIC_PLUS, new String[]{"local"});
-  }
-
-  public static boolean isValid(String addr) {
-    return EmailValidator.getInstance(true, true).isValid(addr);
-  }
-}
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
deleted file mode 100644
index f19b2a8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
+++ /dev/null
@@ -1,283 +0,0 @@
-// Copyright (C) 2013 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.mail;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.git.NotifyConfig;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
-import com.google.gwtorm.server.OrmException;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-public class ProjectWatch {
-  private static final Logger log = LoggerFactory.getLogger(ProjectWatch.class);
-
-  protected final EmailArguments args;
-  protected final ProjectState projectState;
-  protected final Project.NameKey project;
-  protected final ChangeData changeData;
-
-  public ProjectWatch(EmailArguments args, Project.NameKey project,
-    ProjectState projectState, ChangeData changeData) {
-    this.args = args;
-    this.project = project;
-    this.projectState = projectState;
-    this.changeData = changeData;
-  }
-
-  /** Returns all watchers that are relevant */
-  public final Watchers getWatchers(NotifyType type) throws OrmException {
-    Watchers matching;
-    if (args.accountIndexes.getSearchIndex() != null) {
-      matching = getWatchersFromIndex(type);
-    } else {
-      matching = getWatchersFromDb(type);
-    }
-
-    for (ProjectState state : projectState.tree()) {
-      for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
-        if (nc.isNotify(type)) {
-          try {
-            add(matching, nc);
-          } catch (QueryParseException e) {
-            log.warn("Project {} has invalid notify {} filter \"{}\": {}",
-                state.getProject().getName(), nc.getName(),
-                nc.getFilter(), e.getMessage());
-          }
-        }
-      }
-    }
-
-    return matching;
-  }
-
-  private Watchers getWatchersFromIndex(NotifyType type)
-      throws OrmException {
-    Watchers matching = new Watchers();
-    Set<Account.Id> projectWatchers = new HashSet<>();
-
-    for (AccountState a : args.accountQueryProvider.get()
-        .byWatchedProject(project)) {
-      Account.Id accountId = a.getAccount().getId();
-      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
-          a.getProjectWatches().entrySet()) {
-        if (project.equals(e.getKey().project())
-                && add(matching, accountId, e.getKey(), e.getValue(), type)) {
-          // We only want to prevent matching All-Projects if this filter hits
-          projectWatchers.add(accountId);
-        }
-      }
-    }
-
-    for (AccountState a : args.accountQueryProvider.get()
-        .byWatchedProject(args.allProjectsName)) {
-      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
-        a.getProjectWatches().entrySet()) {
-        if (args.allProjectsName.equals(e.getKey().project())) {
-          Account.Id accountId = a.getAccount().getId();
-          if (!projectWatchers.contains(accountId)) {
-            add(matching, accountId, e.getKey(), e.getValue(), type);
-          }
-        }
-      }
-    }
-    return matching;
-  }
-
-  private Watchers getWatchersFromDb(NotifyType type)
-      throws OrmException {
-    Watchers matching = new Watchers();
-    Set<Account.Id> projectWatchers = new HashSet<>();
-
-    for (AccountProjectWatch w : args.db.get().accountProjectWatches()
-        .byProject(project)) {
-      if (add(matching, w, type)) {
-        // We only want to prevent matching All-Projects if this filter hits
-        projectWatchers.add(w.getAccountId());
-      }
-    }
-
-    for (AccountProjectWatch w : args.db.get().accountProjectWatches()
-        .byProject(args.allProjectsName)) {
-      if (!projectWatchers.contains(w.getAccountId())) {
-        add(matching, w, type);
-      }
-    }
-    return matching;
-  }
-
-  public static class Watchers {
-    static class List {
-      protected final Set<Account.Id> accounts = new HashSet<>();
-      protected final Set<Address> emails = new HashSet<>();
-    }
-    protected final List to = new List();
-    protected final List cc = new List();
-    protected final List bcc = new List();
-
-    List list(NotifyConfig.Header header) {
-      switch (header) {
-        case TO:
-          return to;
-        case CC:
-          return cc;
-        default:
-        case BCC:
-          return bcc;
-      }
-    }
-  }
-
-  private void add(Watchers matching, NotifyConfig nc)
-      throws OrmException, QueryParseException {
-    for (GroupReference ref : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(args.capabilityControlFactory,
-          ref.getUUID());
-      if (filterMatch(user, nc.getFilter())) {
-        deliverToMembers(matching.list(nc.getHeader()), ref.getUUID());
-      }
-    }
-
-    if (!nc.getAddresses().isEmpty()) {
-      if (filterMatch(null, nc.getFilter())) {
-        matching.list(nc.getHeader()).emails.addAll(nc.getAddresses());
-      }
-    }
-  }
-
-  private void deliverToMembers(
-      Watchers.List matching,
-      AccountGroup.UUID startUUID) throws OrmException {
-    ReviewDb db = args.db.get();
-    Set<AccountGroup.UUID> seen = new HashSet<>();
-    List<AccountGroup.UUID> q = new ArrayList<>();
-
-    seen.add(startUUID);
-    q.add(startUUID);
-
-    while (!q.isEmpty()) {
-      AccountGroup.UUID uuid = q.remove(q.size() - 1);
-      GroupDescription.Basic group = args.groupBackend.get(uuid);
-      if (!Strings.isNullOrEmpty(group.getEmailAddress())) {
-        // If the group has an email address, do not expand membership.
-        matching.emails.add(new Address(group.getEmailAddress()));
-        continue;
-      }
-
-      AccountGroup ig = GroupDescriptions.toAccountGroup(group);
-      if (ig == null) {
-        // Non-internal groups cannot be expanded by the server.
-        continue;
-      }
-
-      for (AccountGroupMember m : db.accountGroupMembers().byGroup(ig.getId())) {
-        matching.accounts.add(m.getAccountId());
-      }
-      for (AccountGroup.UUID m : args.groupIncludes.subgroupsOf(uuid)) {
-        if (seen.add(m)) {
-          q.add(m);
-        }
-      }
-    }
-  }
-
-  private boolean add(Watchers matching, Account.Id accountId,
-      ProjectWatchKey key, Set<NotifyType> watchedTypes, NotifyType type)
-      throws OrmException {
-    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
-
-    try {
-      if (filterMatch(user, key.filter())) {
-        // If we are set to notify on this type, add the user.
-        // Otherwise, still return true to stop notifications for this user.
-        if (watchedTypes.contains(type)) {
-          matching.bcc.accounts.add(accountId);
-        }
-        return true;
-      }
-    } catch (QueryParseException e) {
-      // Ignore broken filter expressions.
-    }
-    return false;
-  }
-
-  private boolean add(Watchers matching, AccountProjectWatch w, NotifyType type)
-      throws OrmException {
-    IdentifiedUser user = args.identifiedUserFactory.create(w.getAccountId());
-
-    try {
-      if (filterMatch(user, w.getFilter())) {
-        // If we are set to notify on this type, add the user.
-        // Otherwise, still return true to stop notifications for this user.
-        if (w.isNotify(type)) {
-          matching.bcc.accounts.add(w.getAccountId());
-        }
-        return true;
-      }
-    } catch (QueryParseException e) {
-      // Ignore broken filter expressions.
-    }
-    return false;
-  }
-
-  private boolean filterMatch(CurrentUser user, String filter)
-      throws OrmException, QueryParseException {
-    ChangeQueryBuilder qb;
-    Predicate<ChangeData> p = null;
-
-    if (user == null) {
-      qb = args.queryBuilder.asUser(args.anonymousUser);
-    } else {
-      qb = args.queryBuilder.asUser(user);
-      p = qb.is_visible();
-    }
-
-    if (filter != null) {
-      Predicate<ChangeData> filterPredicate = qb.parse(filter);
-      if (p == null) {
-        p = filterPredicate;
-      } else {
-        p = Predicate.and(filterPredicate, p);
-      }
-    }
-    return p == null || p.asMatchable().match(changeData);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RecipientType.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RecipientType.java
deleted file mode 100644
index ea0def0..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RecipientType.java
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-public enum RecipientType {
-  TO, CC, BCC
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java
deleted file mode 100644
index cfdeb8f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RegisterNewEmailSender.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-public class RegisterNewEmailSender extends OutgoingEmail {
-  public interface Factory {
-    RegisterNewEmailSender create(String address);
-  }
-
-  private final EmailTokenVerifier tokenVerifier;
-  private final IdentifiedUser user;
-  private final String addr;
-  private String emailToken;
-
-  @Inject
-  public RegisterNewEmailSender(EmailArguments ea,
-      EmailTokenVerifier etv,
-      IdentifiedUser callingUser,
-      @Assisted final String address) {
-    super(ea, "registernewemail");
-    tokenVerifier = etv;
-    user = callingUser;
-    addr = address;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] Email Verification");
-    add(RecipientType.TO, new Address(addr));
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(velocifyFile("RegisterNewEmail.vm"));
-  }
-
-  public String getUserNameEmail() {
-    return getUserNameEmailFor(user.getAccountId());
-  }
-
-  public String getEmailRegistrationToken() {
-    if (emailToken == null) {
-      emailToken = checkNotNull(
-          tokenVerifier.encode(user.getAccountId(), addr), "token");
-    }
-    return emailToken;
-  }
-
-  public boolean isAllowed() {
-    return args.emailSender.canEmail(addr);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java
deleted file mode 100644
index df9f20e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplacePatchSetSender.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Send notice of new patch sets for reviewers. */
-public class ReplacePatchSetSender extends ReplyToChangeSender {
-  public interface Factory {
-    ReplacePatchSetSender create(Project.NameKey project, Change.Id id);
-  }
-
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Account.Id> extraCC = new HashSet<>();
-
-  @Inject
-  public ReplacePatchSetSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "newpatchset", newChangeData(ea, project, id));
-  }
-
-  public void addReviewers(final Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addExtraCC(final Collection<Account.Id> cc) {
-    extraCC.addAll(cc);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    if (fromId != null) {
-      // Don't call yourself a reviewer of your own patch set.
-      //
-      reviewers.remove(fromId);
-    }
-    add(RecipientType.TO, reviewers);
-    add(RecipientType.CC, extraCC);
-    rcptToAuthors(RecipientType.CC);
-    bccStarredBy();
-    includeWatchers(NotifyType.NEW_PATCHSETS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("ReplacePatchSet.vm"));
-  }
-
-  public List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    return names;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java
deleted file mode 100644
index dd922d3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ReplyToChangeSender.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.OrmException;
-
-/** Alert a user to a reply to a change, usually commentary made during review. */
-public abstract class ReplyToChangeSender extends ChangeEmail {
-  public interface Factory<T extends ReplyToChangeSender> {
-    T create(Project.NameKey project, Change.Id id);
-  }
-
-  protected ReplyToChangeSender(EmailArguments ea, String mc, ChangeData cd)
-      throws OrmException {
-    super(ea, mc, cd);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    final String threadId = getChangeMessageThreadId();
-    setHeader("In-Reply-To", threadId);
-    setHeader("References", threadId);
-
-    rcptToAuthors(RecipientType.TO);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java
deleted file mode 100644
index d946eb2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RestoredSender.java
+++ /dev/null
@@ -1,54 +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.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being restored by its owner. */
-public class RestoredSender extends ReplyToChangeSender {
-  public interface Factory extends
-      ReplyToChangeSender.Factory<RestoredSender> {
-    @Override
-    RestoredSender create(Project.NameKey project, Change.Id id);
-  }
-
-  @Inject
-  public RestoredSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "restore", newChangeData(ea, project, id));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("Restored.vm"));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java
deleted file mode 100644
index 2c9c37e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/RevertedSender.java
+++ /dev/null
@@ -1,52 +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.mail;
-
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being reverted. */
-public class RevertedSender extends ReplyToChangeSender {
-  public interface Factory {
-    RevertedSender create(Project.NameKey project, Change.Id id);
-  }
-
-  @Inject
-  public RevertedSender(EmailArguments ea,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id id)
-      throws OrmException {
-    super(ea, "revert", newChangeData(ea, project, id));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(velocifyFile("Reverted.vm"));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index f12859f..aaf3243 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -18,17 +18,16 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.ValidToken;
 import com.google.gwtjsonrpc.server.XsrfException;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.util.Base64;
-
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.util.Base64;
 
 /** Verifies the token sent by {@link RegisterNewEmailSender}. */
 @Singleton
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java
deleted file mode 100644
index e263c6a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/SmtpEmailSender.java
+++ /dev/null
@@ -1,294 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.Version;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-import org.apache.commons.net.smtp.AuthSMTPClient;
-import org.apache.commons.net.smtp.SMTPClient;
-import org.apache.commons.net.smtp.SMTPReply;
-import org.eclipse.jgit.lib.Config;
-
-import java.io.BufferedWriter;
-import java.io.IOException;
-import java.io.Writer;
-import java.text.SimpleDateFormat;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-
-/** Sends email via a nearby SMTP server. */
-@Singleton
-public class SmtpEmailSender implements EmailSender {
-  /** The socket's connect timeout (0 = infinite timeout) */
-  private static final int DEFAULT_CONNECT_TIMEOUT = 0;
-
-  public static class Module extends AbstractModule {
-    @Override
-    protected void configure() {
-      bind(EmailSender.class).to(SmtpEmailSender.class);
-    }
-  }
-
-  public enum Encryption {
-    NONE, SSL, TLS
-  }
-
-  private final boolean enabled;
-  private final int connectTimeout;
-
-  private String smtpHost;
-  private int smtpPort;
-  private String smtpUser;
-  private String smtpPass;
-  private Encryption smtpEncryption;
-  private boolean sslVerify;
-  private Set<String> allowrcpt;
-  private String importance;
-  private int expiryDays;
-
-  @Inject
-  SmtpEmailSender(@GerritServerConfig final Config cfg) {
-    enabled = cfg.getBoolean("sendemail", null, "enable", true);
-    connectTimeout =
-        Ints.checkedCast(ConfigUtil.getTimeUnit(cfg, "sendemail", null,
-            "connectTimeout", DEFAULT_CONNECT_TIMEOUT, TimeUnit.MILLISECONDS));
-
-
-    smtpHost = cfg.getString("sendemail", null, "smtpserver");
-    if (smtpHost == null) {
-      smtpHost = "127.0.0.1";
-    }
-
-    smtpEncryption =
-        cfg.getEnum("sendemail", null, "smtpencryption", Encryption.NONE);
-    sslVerify = cfg.getBoolean("sendemail", null, "sslverify", true);
-
-    final int defaultPort;
-    switch (smtpEncryption) {
-      case SSL:
-        defaultPort = 465;
-        break;
-
-      case NONE:
-      case TLS:
-      default:
-        defaultPort = 25;
-        break;
-    }
-    smtpPort = cfg.getInt("sendemail", null, "smtpserverport", defaultPort);
-
-    smtpUser = cfg.getString("sendemail", null, "smtpuser");
-    smtpPass = cfg.getString("sendemail", null, "smtppass");
-
-    Set<String> rcpt = new HashSet<>();
-    for (String addr : cfg.getStringList("sendemail", null, "allowrcpt")) {
-      rcpt.add(addr);
-    }
-    allowrcpt = Collections.unmodifiableSet(rcpt);
-    importance = cfg.getString("sendemail", null, "importance");
-    expiryDays = cfg.getInt("sendemail", null, "expiryDays", 0);
-  }
-
-  @Override
-  public boolean isEnabled() {
-    return enabled;
-  }
-
-  @Override
-  public boolean canEmail(String address) {
-    if (!isEnabled()) {
-      return false;
-    }
-
-    if (allowrcpt.isEmpty()) {
-      return true;
-    }
-
-    if (allowrcpt.contains(address)) {
-      return true;
-    }
-
-    String domain = address.substring(address.lastIndexOf('@') + 1);
-    if (allowrcpt.contains(domain) || allowrcpt.contains("@" + domain)) {
-      return true;
-    }
-
-    return false;
-  }
-
-  @Override
-  public void send(final Address from, Collection<Address> rcpt,
-      final Map<String, EmailHeader> callerHeaders, final String body)
-      throws EmailException {
-    if (!isEnabled()) {
-      throw new EmailException("Sending email is disabled");
-    }
-
-    final Map<String, EmailHeader> hdrs =
-        new LinkedHashMap<>(callerHeaders);
-    setMissingHeader(hdrs, "MIME-Version", "1.0");
-    setMissingHeader(hdrs, "Content-Type", "text/plain; charset=UTF-8");
-    setMissingHeader(hdrs, "Content-Transfer-Encoding", "8bit");
-    setMissingHeader(hdrs, "Content-Disposition", "inline");
-    setMissingHeader(hdrs, "User-Agent", "Gerrit/" + Version.getVersion());
-    if (importance != null) {
-      setMissingHeader(hdrs, "Importance", importance);
-    }
-    if (expiryDays > 0) {
-      Date expiry = new Date(TimeUtil.nowMs() +
-        expiryDays * 24 * 60 * 60 * 1000L );
-      setMissingHeader(hdrs, "Expiry-Date",
-        new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z").format(expiry));
-    }
-
-    StringBuffer rejected = new StringBuffer();
-    try {
-      final SMTPClient client = open();
-      try {
-        if (!client.setSender(from.email)) {
-          throw new EmailException("Server " + smtpHost
-              + " rejected from address " + from.email);
-        }
-
-        /* Do not prevent the email from being sent to "good" users simply
-         * because some users get rejected.  If not, a single rejected
-         * project watcher could prevent email for most actions on a project
-         * from being sent to any user!  Instead, queue up the errors, and
-         * throw an exception after sending the email to get the rejected
-         * error(s) logged.
-         */
-        for (Address addr : rcpt) {
-          if (!client.addRecipient(addr.email)) {
-            String error = client.getReplyString();
-            rejected.append("Server ").append(smtpHost)
-                    .append(" rejected recipient ").append(addr)
-                    .append(": ").append(error);
-          }
-        }
-
-        Writer messageDataWriter = client.sendMessageData();
-        if (messageDataWriter == null) {
-          /* Include rejected recipient error messages here to not lose that
-           * information. That piece of the puzzle is vital if zero recipients
-           * are accepted and the server consequently rejects the DATA command.
-           */
-          throw new EmailException(rejected + "Server " + smtpHost
-              + " rejected DATA command: " + client.getReplyString());
-        }
-        try (Writer w = new BufferedWriter(messageDataWriter)) {
-          for (Map.Entry<String, EmailHeader> h : hdrs.entrySet()) {
-            if (!h.getValue().isEmpty()) {
-              w.write(h.getKey());
-              w.write(": ");
-              h.getValue().write(w);
-              w.write("\r\n");
-            }
-          }
-
-          w.write("\r\n");
-          w.write(body);
-          w.flush();
-        }
-
-        if (!client.completePendingCommand()) {
-          throw new EmailException("Server " + smtpHost
-              + " rejected message body: " + client.getReplyString());
-        }
-
-        client.logout();
-        if (rejected.length() > 0) {
-          throw new EmailException(rejected.toString());
-        }
-      } finally {
-        client.disconnect();
-      }
-    } catch (IOException e) {
-      throw new EmailException("Cannot send outgoing email", e);
-    }
-  }
-
-  private void setMissingHeader(final Map<String, EmailHeader> hdrs,
-      final String name, final String value) {
-    if (!hdrs.containsKey(name) || hdrs.get(name).isEmpty()) {
-      hdrs.put(name, new EmailHeader.String(value));
-    }
-  }
-
-  private SMTPClient open() throws EmailException {
-    final AuthSMTPClient client = new AuthSMTPClient(UTF_8.name());
-
-    if (smtpEncryption == Encryption.SSL) {
-      client.enableSSL(sslVerify);
-    }
-
-    client.setConnectTimeout(connectTimeout);
-    try {
-      client.connect(smtpHost, smtpPort);
-      int replyCode = client.getReplyCode();
-      String replyString = client.getReplyString();
-      if (!SMTPReply.isPositiveCompletion(replyCode)) {
-        throw new EmailException(
-            String.format("SMTP server rejected connection: %d: %s",
-                replyCode, replyString));
-      }
-      if (!client.login()) {
-        throw new EmailException(
-            "SMTP server rejected HELO/EHLO greeting: " + replyString);
-      }
-
-      if (smtpEncryption == Encryption.TLS) {
-        if (!client.startTLS(smtpHost, smtpPort, sslVerify)) {
-          throw new EmailException("SMTP server does not support TLS");
-        }
-        if (!client.login()) {
-          throw new EmailException("SMTP server rejected login: " + replyString);
-        }
-      }
-
-      if (smtpUser != null && !client.auth(smtpUser, smtpPass)) {
-        throw new EmailException("SMTP server rejected auth: " + replyString);
-      }
-      return client;
-    } catch (IOException | EmailException e) {
-      if (client.isConnected()) {
-        try {
-          client.disconnect();
-        } catch (IOException e2) {
-          //Ignored
-        }
-      }
-      if (e instanceof EmailException) {
-        throw (EmailException) e;
-      }
-      throw new EmailException(e.getMessage(), e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/VelocityRuntimeProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/VelocityRuntimeProvider.java
deleted file mode 100644
index 3fdc550..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/VelocityRuntimeProvider.java
+++ /dev/null
@@ -1,122 +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.mail;
-
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-
-import org.apache.velocity.runtime.RuntimeConstants;
-import org.apache.velocity.runtime.RuntimeInstance;
-import org.apache.velocity.runtime.RuntimeServices;
-import org.apache.velocity.runtime.log.LogChute;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.nio.file.Files;
-import java.util.Properties;
-
-/** Configures Velocity template engine for sending email. */
-@Singleton
-public class VelocityRuntimeProvider implements Provider<RuntimeInstance> {
-  private final SitePaths site;
-
-  @Inject
-  VelocityRuntimeProvider(SitePaths site) {
-    this.site = site;
-  }
-
-  @Override
-  public RuntimeInstance get() {
-    String rl = "resource.loader";
-    String pkg = "org.apache.velocity.runtime.resource.loader";
-
-    Properties p = new Properties();
-    p.setProperty(RuntimeConstants.VM_PERM_INLINE_LOCAL, "true");
-    p.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS,
-        Slf4jLogChute.class.getName());
-    p.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true");
-    p.setProperty("runtime.log.logsystem.log4j.category", "velocity");
-
-    if (Files.isDirectory(site.mail_dir)) {
-      p.setProperty(rl, "file, class");
-      p.setProperty("file." + rl + ".class", pkg + ".FileResourceLoader");
-      p.setProperty("file." + rl + ".path",
-          site.mail_dir.toAbsolutePath().toString());
-      p.setProperty("class." + rl + ".class", pkg + ".ClasspathResourceLoader");
-    } else {
-      p.setProperty(rl, "class");
-      p.setProperty("class." + rl + ".class", pkg + ".ClasspathResourceLoader");
-    }
-
-    RuntimeInstance ri = new RuntimeInstance();
-    try {
-      ri.init(p);
-    } catch (Exception err) {
-      throw new ProvisionException("Cannot configure Velocity templates", err);
-    }
-    return ri;
-  }
-
-  /** Connects Velocity to sfl4j. */
-  public static class Slf4jLogChute implements LogChute {
-    private static final Logger log = LoggerFactory.getLogger("velocity");
-
-    @Override
-    public void init(RuntimeServices rs) {
-    }
-
-    @Override
-    public boolean isLevelEnabled(int level) {
-      switch (level) {
-        default:
-        case DEBUG_ID:
-          return log.isDebugEnabled();
-        case INFO_ID:
-          return log.isInfoEnabled();
-        case WARN_ID:
-          return log.isWarnEnabled();
-        case ERROR_ID:
-          return log.isErrorEnabled();
-      }
-    }
-
-    @Override
-    public void log(int level, String message) {
-      log(level, message, null);
-    }
-
-    @Override
-    public void log(int level, String msg, Throwable err) {
-      switch (level) {
-        default:
-        case DEBUG_ID:
-          log.debug(msg, err);
-          break;
-        case INFO_ID:
-          log.info(msg, err);
-          break;
-        case WARN_ID:
-          log.warn(msg, err);
-          break;
-        case ERROR_ID:
-          log.error(msg, err);
-          break;
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
new file mode 100644
index 0000000..2ecaeb1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
@@ -0,0 +1,126 @@
+// 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.mail.receive;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.PeekingIterator;
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+
+/** HTMLParser provides parsing functionality for html email. */
+public class HtmlParser {
+  private static ImmutableList<String> MAIL_PROVIDER_EXTRAS =
+      ImmutableList.of(
+          "gmail_extra", // "On 01/01/2017 User<user@gmail.com> wrote:"
+          "gmail_quote" // Used for quoting original content
+          );
+
+  /**
+   * Parses comments from html email.
+   *
+   * @param email MailMessage as received from the email service.
+   * @param comments A specific set of comments as sent out in the original notification email.
+   *     Comments are expected to be in the same order as they were sent out to in the email
+   * @param changeUrl Canonical change URL that points to the change on this Gerrit instance.
+   *     Example: https://go-review.googlesource.com/#/c/91570
+   * @return List of MailComments parsed from the html part of the email.
+   */
+  public static List<MailComment> parse(
+      MailMessage email, Collection<Comment> comments, String changeUrl) {
+    // TODO(hiesel) Add support for Gmail Mobile
+    // TODO(hiesel) Add tests for other popular email clients
+
+    // This parser goes though all html elements in the email and checks for
+    // matching patterns. It keeps track of the last file and comments it
+    // encountered to know in which context a parsed comment belongs.
+    // It uses the href attributes of <a> tags to identify comments sent out by
+    // Gerrit as these are generally more reliable then the text captions.
+    List<MailComment> parsedComments = new ArrayList<>();
+    Document d = Jsoup.parse(email.htmlContent());
+    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+
+    String lastEncounteredFileName = null;
+    Comment lastEncounteredComment = null;
+    for (Element e : d.body().getAllElements()) {
+      String elementName = e.tagName();
+      boolean isInBlockQuote =
+          e.parents().stream().filter(p -> p.tagName().equals("blockquote")).findAny().isPresent();
+
+      if (elementName.equals("a")) {
+        String href = e.attr("href");
+        // Check if there is still a next comment that could be contained in
+        // this <a> tag
+        if (!iter.hasNext()) {
+          continue;
+        }
+        Comment perspectiveComment = iter.peek();
+        if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
+          if (lastEncounteredFileName == null
+              || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
+            // Not a file-level comment, but users could have typed a comment
+            // right after this file annotation to create a new file-level
+            // comment. If this file has a file-level comment, we have already
+            // set lastEncounteredComment to that file-level comment when we
+            // encountered the file link and should not reset it now.
+            lastEncounteredFileName = perspectiveComment.key.filename;
+            lastEncounteredComment = null;
+          } else if (perspectiveComment.lineNbr == 0) {
+            // This was originally a file-level comment
+            lastEncounteredComment = perspectiveComment;
+            iter.next();
+          }
+        } else if (ParserUtil.isCommentUrl(href, changeUrl, perspectiveComment)) {
+          // This is a regular inline comment
+          lastEncounteredComment = perspectiveComment;
+          iter.next();
+        }
+      } else if (!isInBlockQuote
+          && elementName.equals("div")
+          && !MAIL_PROVIDER_EXTRAS.contains(e.className())) {
+        // This is a comment typed by the user
+        // Replace non-breaking spaces and trim string
+        String content = e.ownText().replace('\u00a0', ' ').trim();
+        if (!Strings.isNullOrEmpty(content)) {
+          if (lastEncounteredComment == null && lastEncounteredFileName == null) {
+            // Remove quotation line, email signature and
+            // "Sent from my xyz device"
+            content = ParserUtil.trimQuotation(content);
+            // TODO(hiesel) Add more sanitizer
+            if (!Strings.isNullOrEmpty(content)) {
+              parsedComments.add(
+                  new MailComment(content, null, null, MailComment.CommentType.CHANGE_MESSAGE));
+            }
+          } else if (lastEncounteredComment == null) {
+            parsedComments.add(
+                new MailComment(
+                    content, lastEncounteredFileName, null, MailComment.CommentType.FILE_COMMENT));
+          } else {
+            parsedComments.add(
+                new MailComment(
+                    content, null, lastEncounteredComment, MailComment.CommentType.INLINE_COMMENT));
+          }
+        }
+      }
+    }
+    return parsedComments;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
new file mode 100644
index 0000000..f350e63
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
@@ -0,0 +1,143 @@
+// 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.mail.receive;
+
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.mail.EmailSettings;
+import com.google.gerrit.server.mail.Encryption;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.net.imap.IMAPClient;
+import org.apache.commons.net.imap.IMAPSClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class ImapMailReceiver extends MailReceiver {
+  private static final Logger log = LoggerFactory.getLogger(ImapMailReceiver.class);
+  private static final String INBOX_FOLDER = "INBOX";
+
+  @Inject
+  ImapMailReceiver(EmailSettings mailSettings, MailProcessor mailProcessor, WorkQueue workQueue) {
+    super(mailSettings, mailProcessor, workQueue);
+  }
+
+  /**
+   * handleEmails will open a connection to the mail server, remove emails where deletion is
+   * pending, read new email and close the connection.
+   *
+   * @param async Determines if processing messages should happen asynchronous.
+   */
+  @Override
+  public synchronized void handleEmails(boolean async) {
+    IMAPClient imap;
+    if (mailSettings.encryption != Encryption.NONE) {
+      imap = new IMAPSClient(mailSettings.encryption.name(), true);
+    } else {
+      imap = new IMAPClient();
+    }
+    if (mailSettings.port > 0) {
+      imap.setDefaultPort(mailSettings.port);
+    }
+    // Set a 30s timeout for each operation
+    imap.setDefaultTimeout(30 * 1000);
+    try {
+      imap.connect(mailSettings.host);
+      try {
+        if (!imap.login(mailSettings.username, mailSettings.password)) {
+          log.error("Could not login to IMAP server");
+          return;
+        }
+        try {
+          if (!imap.select(INBOX_FOLDER)) {
+            log.error("Could not select IMAP folder " + INBOX_FOLDER);
+            return;
+          }
+          // Fetch just the internal dates first to know how many messages we
+          // should fetch.
+          if (!imap.fetch("1:*", "(INTERNALDATE)")) {
+            // false indicates that there are no messages to fetch
+            log.info("Fetched 0 messages via IMAP");
+            return;
+          }
+          // Format of reply is one line per email and one line to indicate
+          // that the fetch was successful.
+          // Example:
+          // * 1 FETCH (INTERNALDATE "Mon, 24 Oct 2016 16:53:22 +0200 (CEST)")
+          // * 2 FETCH (INTERNALDATE "Mon, 24 Oct 2016 16:53:22 +0200 (CEST)")
+          // AAAC OK FETCH completed.
+          int numMessages = imap.getReplyStrings().length - 1;
+          log.info("Fetched " + numMessages + " messages via IMAP");
+          if (numMessages == 0) {
+            return;
+          }
+          // Fetch the full version of all emails
+          List<MailMessage> mailMessages = new ArrayList<>(numMessages);
+          for (int i = 1; i <= numMessages; i++) {
+            if (imap.fetch(i + ":" + i, "(BODY.PEEK[])")) {
+              // Obtain full reply
+              String[] rawMessage = imap.getReplyStrings();
+              if (rawMessage.length < 2) {
+                continue;
+              }
+              // First and last line are IMAP status codes. We have already
+              // checked, that the fetch returned true (OK), so we safely ignore
+              // those two lines.
+              StringBuilder b = new StringBuilder(2 * (rawMessage.length - 2));
+              for (int j = 1; j < rawMessage.length - 1; j++) {
+                if (j > 1) {
+                  b.append("\n");
+                }
+                b.append(rawMessage[j]);
+              }
+              try {
+                MailMessage mailMessage = RawMailParser.parse(b.toString());
+                if (pendingDeletion.contains(mailMessage.id())) {
+                  // Mark message as deleted
+                  if (imap.store(i + ":" + i, "+FLAGS", "(\\Deleted)")) {
+                    pendingDeletion.remove(mailMessage.id());
+                  } else {
+                    log.error("Could not mark mail message as deleted: " + mailMessage.id());
+                  }
+                } else {
+                  mailMessages.add(mailMessage);
+                }
+              } catch (MailParsingException e) {
+                log.error("Exception while parsing email after IMAP fetch", e);
+              }
+            } else {
+              log.error("IMAP fetch failed. Will retry in next fetch cycle.");
+            }
+          }
+          // Permanently delete emails marked for deletion
+          if (!imap.expunge()) {
+            log.error("Could not expunge IMAP emails");
+          }
+          dispatchMailProcessor(mailMessages, async);
+        } finally {
+          imap.logout();
+        }
+      } finally {
+        imap.disconnect();
+      }
+    } catch (IOException e) {
+      log.error("Error while talking to IMAP server", e);
+      return;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
new file mode 100644
index 0000000..8afbe81
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import com.google.gerrit.reviewdb.client.Comment;
+
+/** A comment parsed from inbound email */
+public class MailComment {
+  enum CommentType {
+    CHANGE_MESSAGE,
+    FILE_COMMENT,
+    INLINE_COMMENT
+  }
+
+  CommentType type;
+  Comment inReplyTo;
+  String fileName;
+  String message;
+
+  public MailComment() {}
+
+  public MailComment(String message, String fileName, Comment inReplyTo, CommentType type) {
+    this.message = message;
+    this.fileName = fileName;
+    this.inReplyTo = inReplyTo;
+    this.type = type;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
new file mode 100644
index 0000000..dcac25c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
@@ -0,0 +1,108 @@
+// 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.mail.receive;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.mail.Address;
+import org.joda.time.DateTime;
+
+/**
+ * MailMessage is a simplified representation of an RFC 2045-2047 mime email message used for
+ * representing received emails inside Gerrit. It is populated by the MailParser after MailReceiver
+ * has received a message. Transformations done by the parser include stitching mime parts together,
+ * transforming all content to UTF-16 and removing attachments.
+ *
+ * <p>A valid MailMessage contains at least the following fields: id, from, to, subject and
+ * dateReceived.
+ */
+@AutoValue
+public abstract class MailMessage {
+  // Unique Identifier
+  public abstract String id();
+  // Envelop Information
+  public abstract Address from();
+
+  public abstract ImmutableList<Address> to();
+
+  public abstract ImmutableList<Address> cc();
+  // Metadata
+  public abstract DateTime dateReceived();
+
+  public abstract ImmutableList<String> additionalHeaders();
+  // Content
+  public abstract String subject();
+
+  @Nullable
+  public abstract String textContent();
+
+  @Nullable
+  public abstract String htmlContent();
+  // Raw content as received over the wire
+  @Nullable
+  public abstract ImmutableList<Integer> rawContent();
+
+  @Nullable
+  public abstract String rawContentUTF();
+
+  public static Builder builder() {
+    return new AutoValue_MailMessage.Builder();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder id(String val);
+
+    public abstract Builder from(Address val);
+
+    public abstract ImmutableList.Builder<Address> toBuilder();
+
+    public Builder addTo(Address val) {
+      toBuilder().add(val);
+      return this;
+    }
+
+    public abstract ImmutableList.Builder<Address> ccBuilder();
+
+    public Builder addCc(Address val) {
+      ccBuilder().add(val);
+      return this;
+    }
+
+    public abstract Builder dateReceived(DateTime val);
+
+    public abstract ImmutableList.Builder<String> additionalHeadersBuilder();
+
+    public Builder addAdditionalHeader(String val) {
+      additionalHeadersBuilder().add(val);
+      return this;
+    }
+
+    public abstract Builder subject(String val);
+
+    public abstract Builder textContent(String val);
+
+    public abstract Builder htmlContent(String val);
+
+    public abstract Builder rawContent(ImmutableList<Integer> val);
+
+    public abstract Builder rawContentUTF(String val);
+
+    public abstract MailMessage build();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMetadata.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMetadata.java
new file mode 100644
index 0000000..04c2add
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMetadata.java
@@ -0,0 +1,46 @@
+// 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.mail.receive;
+
+import com.google.common.base.MoreObjects;
+import java.sql.Timestamp;
+
+/** MailMetadata represents metadata parsed from inbound email. */
+public class MailMetadata {
+  public Integer changeNumber;
+  public Integer patchSet;
+  public String author; // Author of the email
+  public Timestamp timestamp;
+  public String messageType; // we expect comment here
+
+  public boolean hasRequiredFields() {
+    return changeNumber != null
+        && patchSet != null
+        && author != null
+        && timestamp != null
+        && messageType != null;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("Change-Number", changeNumber)
+        .add("Patch-Set", patchSet)
+        .add("Author", author)
+        .add("Timestamp", timestamp)
+        .add("Message-Type", messageType)
+        .toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java
new file mode 100644
index 0000000..edadef8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java
@@ -0,0 +1,28 @@
+// 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.mail.receive;
+
+/** MailParsingException indicates that an email could not be parsed. */
+public class MailParsingException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public MailParsingException(String msg) {
+    super(msg);
+  }
+
+  public MailParsingException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
new file mode 100644
index 0000000..020c74b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -0,0 +1,369 @@
+// 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.mail.receive;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+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.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountByEmailCache;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.MailFilter;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class MailProcessor {
+  private static final Logger log = LoggerFactory.getLogger(MailProcessor.class);
+
+  private final AccountByEmailCache accountByEmailCache;
+  private final BatchUpdate.Factory buf;
+  private final ChangeMessagesUtil changeMessagesUtil;
+  private final CommentsUtil commentsUtil;
+  private final OneOffRequestContext oneOffRequestContext;
+  private final PatchListCache patchListCache;
+  private final PatchSetUtil psUtil;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final DynamicMap<MailFilter> mailFilters;
+  private final EmailReviewComments.Factory outgoingMailFactory;
+  private final CommentAdded commentAdded;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountCache accountCache;
+  private final Provider<String> canonicalUrl;
+
+  @Inject
+  public MailProcessor(
+      AccountByEmailCache accountByEmailCache,
+      BatchUpdate.Factory buf,
+      ChangeMessagesUtil changeMessagesUtil,
+      CommentsUtil commentsUtil,
+      OneOffRequestContext oneOffRequestContext,
+      PatchListCache patchListCache,
+      PatchSetUtil psUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      DynamicMap<MailFilter> mailFilters,
+      EmailReviewComments.Factory outgoingMailFactory,
+      ApprovalsUtil approvalsUtil,
+      CommentAdded commentAdded,
+      AccountCache accountCache,
+      @CanonicalWebUrl Provider<String> canonicalUrl) {
+    this.accountByEmailCache = accountByEmailCache;
+    this.buf = buf;
+    this.changeMessagesUtil = changeMessagesUtil;
+    this.commentsUtil = commentsUtil;
+    this.oneOffRequestContext = oneOffRequestContext;
+    this.patchListCache = patchListCache;
+    this.psUtil = psUtil;
+    this.queryProvider = queryProvider;
+    this.mailFilters = mailFilters;
+    this.outgoingMailFactory = outgoingMailFactory;
+    this.commentAdded = commentAdded;
+    this.approvalsUtil = approvalsUtil;
+    this.accountCache = accountCache;
+    this.canonicalUrl = canonicalUrl;
+  }
+
+  /**
+   * Parse comments from MailMessage and persist them on the change.
+   *
+   * @param message MailMessage to process.
+   * @throws OrmException
+   */
+  public void process(MailMessage message) throws OrmException {
+    for (DynamicMap.Entry<MailFilter> filter : mailFilters) {
+      if (!filter.getProvider().get().shouldProcessMessage(message)) {
+        log.warn(
+            "Message {} filtered by plugin {} {}. Will delete message.",
+            message.id(),
+            filter.getPluginName(),
+            filter.getExportName());
+        return;
+      }
+    }
+
+    MailMetadata metadata = MetadataParser.parse(message);
+    if (!metadata.hasRequiredFields()) {
+      log.error(
+          "Message {} is missing required metadata, have {}. Will delete message.",
+          message.id(),
+          metadata);
+      return;
+    }
+
+    Set<Account.Id> accounts = accountByEmailCache.get(metadata.author);
+    if (accounts.size() != 1) {
+      log.error(
+          "Address {} could not be matched to a unique account. It was matched to {}."
+              + " Will delete message.",
+          metadata.author,
+          accounts);
+      return;
+    }
+    Account.Id account = accounts.iterator().next();
+    if (!accountCache.get(account).getAccount().isActive()) {
+      log.warn("Mail: Account {} is inactive. Will delete message.", account);
+      return;
+    }
+
+    try (ManualRequestContext ctx = oneOffRequestContext.openAs(account)) {
+      List<ChangeData> changeDataList =
+          queryProvider.get().byLegacyChangeId(new Change.Id(metadata.changeNumber));
+      if (changeDataList.size() != 1) {
+        log.error(
+            "Message {} references unique change {}, but there are {} matching changes in "
+                + "the index. Will delete message.",
+            message.id(),
+            metadata.changeNumber,
+            changeDataList.size());
+        return;
+      }
+      ChangeData cd = changeDataList.get(0);
+      if (existingMessageIds(cd).contains(message.id())) {
+        log.info("Message {} was already processed. Will delete message.", message.id());
+        return;
+      }
+      // Get all comments; filter and sort them to get the original list of
+      // comments from the outbound email.
+      // TODO(hiesel) Also filter by original comment author.
+      Collection<Comment> comments =
+          cd.publishedComments().stream()
+              .filter(c -> (c.writtenOn.getTime() / 1000) == (metadata.timestamp.getTime() / 1000))
+              .sorted(CommentsUtil.COMMENT_ORDER)
+              .collect(toList());
+      Project.NameKey project = cd.project();
+      String changeUrl = canonicalUrl.get() + "#/c/" + cd.getId().get();
+
+      List<MailComment> parsedComments;
+      if (useHtmlParser(message)) {
+        parsedComments = HtmlParser.parse(message, comments, changeUrl);
+      } else {
+        parsedComments = TextParser.parse(message, comments, changeUrl);
+      }
+
+      if (parsedComments.isEmpty()) {
+        log.warn("Could not parse any comments from {}. Will delete message.", message.id());
+        return;
+      }
+
+      Op o = new Op(new PatchSet.Id(cd.getId(), metadata.patchSet), parsedComments, message.id());
+      BatchUpdate batchUpdate = buf.create(cd.db(), project, ctx.getUser(), TimeUtil.nowTs());
+      batchUpdate.addOp(cd.getId(), o);
+      try {
+        batchUpdate.execute();
+      } catch (UpdateException | RestApiException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
+
+  private class Op implements BatchUpdateOp {
+    private final PatchSet.Id psId;
+    private final List<MailComment> parsedComments;
+    private final String tag;
+    private ChangeMessage changeMessage;
+    private List<Comment> comments;
+    private PatchSet patchSet;
+    private ChangeControl changeControl;
+
+    private Op(PatchSet.Id psId, List<MailComment> parsedComments, String messageId) {
+      this.psId = psId;
+      this.parsedComments = parsedComments;
+      this.tag = "mailMessageId=" + messageId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, UnprocessableEntityException {
+      changeControl = ctx.getControl();
+      patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (patchSet == null) {
+        throw new OrmException("patch set not found: " + psId);
+      }
+
+      String changeMsg = "Patch Set " + psId.get() + ":";
+      if (parsedComments.get(0).type == MailComment.CommentType.CHANGE_MESSAGE) {
+        // Add a blank line after Patch Set to follow the default format
+        if (parsedComments.size() > 1) {
+          changeMsg += "\n\n" + numComments(parsedComments.size() - 1);
+        }
+        changeMsg += "\n\n" + parsedComments.get(0).message;
+      } else {
+        changeMsg += "\n\n" + numComments(parsedComments.size());
+      }
+
+      changeMessage = ChangeMessagesUtil.newMessage(ctx, changeMsg, tag);
+      changeMessagesUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
+      comments = new ArrayList<>();
+      for (MailComment c : parsedComments) {
+        if (c.type == MailComment.CommentType.CHANGE_MESSAGE) {
+          continue;
+        }
+
+        String fileName;
+        // The patch set that this comment is based on is different if this
+        // comment was sent in reply to a comment on a previous patch set.
+        PatchSet psForComment;
+        Side side;
+        if (c.inReplyTo != null) {
+          fileName = c.inReplyTo.key.filename;
+          psForComment =
+              psUtil.get(
+                  ctx.getDb(),
+                  ctx.getNotes(),
+                  new PatchSet.Id(ctx.getChange().getId(), c.inReplyTo.key.patchSetId));
+          side = Side.fromShort(c.inReplyTo.side);
+        } else {
+          fileName = c.fileName;
+          psForComment = patchSet;
+          side = Side.REVISION;
+        }
+
+        Comment comment =
+            commentsUtil.newComment(
+                ctx,
+                fileName,
+                psForComment.getId(),
+                (short) side.ordinal(),
+                c.message,
+                false,
+                null);
+        comment.tag = tag;
+        if (c.inReplyTo != null) {
+          comment.parentUuid = c.inReplyTo.key.uuid;
+          comment.lineNbr = c.inReplyTo.lineNbr;
+          comment.range = c.inReplyTo.range;
+          comment.unresolved = c.inReplyTo.unresolved;
+        }
+        CommentsUtil.setCommentRevId(comment, patchListCache, ctx.getChange(), psForComment);
+        comments.add(comment);
+      }
+      commentsUtil.putComments(
+          ctx.getDb(),
+          ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+          Status.PUBLISHED,
+          comments);
+
+      return true;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws Exception {
+      String patchSetComment = null;
+      if (parsedComments.get(0).type == MailComment.CommentType.CHANGE_MESSAGE) {
+        patchSetComment = parsedComments.get(0).message;
+      }
+      // Send email notifications
+      outgoingMailFactory
+          .create(
+              NotifyHandling.ALL,
+              ArrayListMultimap.create(),
+              changeControl.getNotes(),
+              patchSet,
+              ctx.getUser().asIdentifiedUser(),
+              changeMessage,
+              comments,
+              patchSetComment,
+              ImmutableList.of())
+          .sendAsync();
+      // Get previous approvals from this user
+      Map<String, Short> approvals = new HashMap<>();
+      approvalsUtil
+          .byPatchSetUser(ctx.getDb(), changeControl, psId, ctx.getAccountId())
+          .forEach(a -> approvals.put(a.getLabel(), a.getValue()));
+      // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
+      // are always the same here.
+      commentAdded.fire(
+          changeControl.getChange(),
+          patchSet,
+          ctx.getAccount(),
+          changeMessage.getMessage(),
+          approvals,
+          approvals,
+          ctx.getWhen());
+    }
+  }
+
+  private static boolean useHtmlParser(MailMessage m) {
+    return !Strings.isNullOrEmpty(m.htmlContent());
+  }
+
+  private static String numComments(int numComments) {
+    return "(" + numComments + (numComments > 1 ? " comments)" : " comment)");
+  }
+
+  private Set<String> existingMessageIds(ChangeData cd) throws OrmException {
+    Set<String> existingMessageIds = new HashSet<>();
+    cd.messages().stream()
+        .forEach(
+            m -> {
+              String messageId = CommentsUtil.extractMessageId(m.getTag());
+              if (messageId != null) {
+                existingMessageIds.add(messageId);
+              }
+            });
+    cd.publishedComments().stream()
+        .forEach(
+            c -> {
+              String messageId = CommentsUtil.extractMessageId(c.tag);
+              if (messageId != null) {
+                existingMessageIds.add(messageId);
+              }
+            });
+    return existingMessageIds;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
new file mode 100644
index 0000000..5068985
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -0,0 +1,150 @@
+// 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.mail.receive;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.mail.EmailSettings;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.Future;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** MailReceiver implements base functionality for receiving emails. */
+public abstract class MailReceiver implements LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(MailReceiver.class);
+
+  protected EmailSettings mailSettings;
+  protected Set<String> pendingDeletion;
+  private MailProcessor mailProcessor;
+  private WorkQueue workQueue;
+  private Timer timer;
+
+  public static class Module extends LifecycleModule {
+    private final EmailSettings mailSettings;
+
+    @Inject
+    Module(EmailSettings mailSettings) {
+      this.mailSettings = mailSettings;
+    }
+
+    @Override
+    protected void configure() {
+      if (mailSettings.protocol == Protocol.NONE) {
+        return;
+      }
+      listener().to(MailReceiver.class);
+      switch (mailSettings.protocol) {
+        case IMAP:
+          bind(MailReceiver.class).to(ImapMailReceiver.class);
+          break;
+        case POP3:
+          bind(MailReceiver.class).to(Pop3MailReceiver.class);
+          break;
+        case NONE:
+        default:
+      }
+    }
+  }
+
+  MailReceiver(EmailSettings mailSettings, MailProcessor mailProcessor, WorkQueue workQueue) {
+    this.mailSettings = mailSettings;
+    this.mailProcessor = mailProcessor;
+    this.workQueue = workQueue;
+    pendingDeletion = Collections.synchronizedSet(new HashSet<>());
+  }
+
+  @Override
+  public void start() {
+    if (timer == null) {
+      timer = new Timer();
+    } else {
+      timer.cancel();
+    }
+    timer.scheduleAtFixedRate(
+        new TimerTask() {
+          @Override
+          public void run() {
+            MailReceiver.this.handleEmails(true);
+          }
+        },
+        0L,
+        mailSettings.fetchInterval);
+  }
+
+  @Override
+  public void stop() {
+    if (timer != null) {
+      timer.cancel();
+    }
+  }
+
+  /**
+   * requestDeletion will enqueue an email for deletion and delete it the next time we connect to
+   * the email server. This does not guarantee deletion as the Gerrit instance might fail before we
+   * connect to the email server.
+   *
+   * @param messageId
+   */
+  public void requestDeletion(String messageId) {
+    pendingDeletion.add(messageId);
+  }
+
+  /**
+   * handleEmails will open a connection to the mail server, remove emails where deletion is
+   * pending, read new email and close the connection.
+   *
+   * @param async Determines if processing messages should happen asynchronous.
+   */
+  @VisibleForTesting
+  public abstract void handleEmails(boolean async);
+
+  protected void dispatchMailProcessor(List<MailMessage> messages, boolean async) {
+    for (MailMessage m : messages) {
+      if (async) {
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError =
+            workQueue
+                .getDefaultQueue()
+                .submit(
+                    () -> {
+                      try {
+                        mailProcessor.process(m);
+                        requestDeletion(m.id());
+                      } catch (OrmException e) {
+                        log.error("Mail: Can't process message " + m.id() + " . Won't delete.", e);
+                      }
+                    });
+      } else {
+        // Synchronous processing is used only in tests.
+        try {
+          mailProcessor.process(m);
+          requestDeletion(m.id());
+        } catch (OrmException e) {
+          log.error("Mail: Can't process messages. Won't delete.", e);
+        }
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java
new file mode 100644
index 0000000..7085051
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MetadataParser.java
@@ -0,0 +1,110 @@
+// 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.mail.receive;
+
+import static com.google.gerrit.server.mail.MetadataName.toFooterWithDelimiter;
+import static com.google.gerrit.server.mail.MetadataName.toHeaderWithDelimiter;
+
+import com.google.common.base.Strings;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.MetadataName;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Parse metadata from inbound email */
+public class MetadataParser {
+  private static final Logger log = LoggerFactory.getLogger(MailProcessor.class);
+
+  public static MailMetadata parse(MailMessage m) {
+    MailMetadata metadata = new MailMetadata();
+    // Find author
+    metadata.author = m.from().getEmail();
+
+    // Check email headers for X-Gerrit-<Name>
+    for (String header : m.additionalHeaders()) {
+      if (header.startsWith(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER))) {
+        String num = header.substring(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER).length());
+        metadata.changeNumber = Ints.tryParse(num);
+      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.PATCH_SET))) {
+        String ps = header.substring(toHeaderWithDelimiter(MetadataName.PATCH_SET).length());
+        metadata.patchSet = Ints.tryParse(ps);
+      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.TIMESTAMP))) {
+        String ts = header.substring(toHeaderWithDelimiter(MetadataName.TIMESTAMP).length()).trim();
+        try {
+          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
+        } catch (DateTimeParseException e) {
+          log.error("Mail: Error while parsing timestamp from header of message " + m.id(), e);
+        }
+      } else if (header.startsWith(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE))) {
+        metadata.messageType =
+            header.substring(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE).length());
+      }
+    }
+    if (metadata.hasRequiredFields()) {
+      return metadata;
+    }
+
+    // If the required fields were not yet found, continue to parse the text
+    if (!Strings.isNullOrEmpty(m.textContent())) {
+      String[] lines = m.textContent().replace("\r\n", "\n").split("\n");
+      extractFooters(lines, metadata, m);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    // If the required fields were not yet found, continue to parse the HTML
+    // HTML footer are contained inside a <div> tag
+    if (!Strings.isNullOrEmpty(m.htmlContent())) {
+      String[] lines = m.htmlContent().replace("\r\n", "\n").split("</div>");
+      extractFooters(lines, metadata, m);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    return metadata;
+  }
+
+  private static void extractFooters(String[] lines, MailMetadata metadata, MailMessage m) {
+    for (String line : lines) {
+      if (metadata.changeNumber == null && line.contains(MetadataName.CHANGE_NUMBER)) {
+        metadata.changeNumber =
+            Ints.tryParse(extractFooter(toFooterWithDelimiter(MetadataName.CHANGE_NUMBER), line));
+      } else if (metadata.patchSet == null && line.contains(MetadataName.PATCH_SET)) {
+        metadata.patchSet =
+            Ints.tryParse(extractFooter(toFooterWithDelimiter(MetadataName.PATCH_SET), line));
+      } else if (metadata.timestamp == null && line.contains(MetadataName.TIMESTAMP)) {
+        String ts = extractFooter(toFooterWithDelimiter(MetadataName.TIMESTAMP), line);
+        try {
+          metadata.timestamp = Timestamp.from(MailUtil.rfcDateformatter.parse(ts, Instant::from));
+        } catch (DateTimeParseException e) {
+          log.error("Mail: Error while parsing timestamp from footer of message " + m.id(), e);
+        }
+      } else if (metadata.messageType == null && line.contains(MetadataName.MESSAGE_TYPE)) {
+        metadata.messageType =
+            extractFooter(toFooterWithDelimiter(MetadataName.MESSAGE_TYPE), line);
+      }
+    }
+  }
+
+  private static String extractFooter(String key, String line) {
+    return line.substring(line.indexOf(key) + key.length(), line.length()).trim();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java
new file mode 100644
index 0000000..f8f64e2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.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.mail.receive;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.StringJoiner;
+import java.util.regex.Pattern;
+
+public class ParserUtil {
+  private static final Pattern SIMPLE_EMAIL_PATTERN =
+      Pattern.compile(
+          "[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+"
+              + "(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})");
+
+  /**
+   * Trims the quotation that email clients add Example: On Sun, Nov 20, 2016 at 10:33 PM,
+   * <gerrit@gerritcodereview.com> wrote:
+   *
+   * @param comment Comment parsed from an email.
+   * @return Trimmed comment.
+   */
+  public static String trimQuotation(String comment) {
+    StringJoiner j = new StringJoiner("\n");
+    String[] lines = comment.split("\n");
+    for (int i = 0; i < lines.length - 2; i++) {
+      j.add(lines[i]);
+    }
+
+    // Check if the last line contains the full quotation pattern (date + email)
+    String lastLine = lines[lines.length - 1];
+    if (containsQuotationPattern(lastLine)) {
+      if (lines.length > 1) {
+        j.add(lines[lines.length - 2]);
+      }
+      return j.toString().trim();
+    }
+
+    // Check if the second last line + the last line contain the full quotation pattern. This is
+    // necessary, as the quotation line can be split across the last two lines if it gets too long.
+    if (lines.length > 1) {
+      String lastLines = lines[lines.length - 2] + lastLine;
+      if (containsQuotationPattern(lastLines)) {
+        return j.toString().trim();
+      }
+    }
+
+    // Add the last two lines
+    if (lines.length > 1) {
+      j.add(lines[lines.length - 2]);
+    }
+    j.add(lines[lines.length - 1]);
+
+    return j.toString().trim();
+  }
+
+  /** Check if string is an inline comment url on a patch set or the base */
+  public static boolean isCommentUrl(String str, String changeUrl, Comment comment) {
+    int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
+    return str.equals(filePath(changeUrl, comment) + "@" + lineNbr)
+        || str.equals(filePath(changeUrl, comment) + "@a" + lineNbr);
+  }
+
+  /** Generate the fully qualified filepath */
+  public static String filePath(String changeUrl, Comment comment) {
+    return changeUrl + "/" + comment.key.patchSetId + "/" + comment.key.filename;
+  }
+
+  private static boolean containsQuotationPattern(String s) {
+    // Identifying the quotation line is hard, as it can be in any language.
+    // We identify this line by it's characteristics: It usually contains a
+    // valid email address, some digits for the date in groups of 1-4 in a row
+    // as well as some characters.
+
+    // Count occurrences of digit groups
+    int numConsecutiveDigits = 0;
+    int maxConsecutiveDigits = 0;
+    int numDigitGroups = 0;
+    for (char c : s.toCharArray()) {
+      if (c >= '0' && c <= '9') {
+        numConsecutiveDigits++;
+      } else if (numConsecutiveDigits > 0) {
+        maxConsecutiveDigits = Integer.max(maxConsecutiveDigits, numConsecutiveDigits);
+        numConsecutiveDigits = 0;
+        numDigitGroups++;
+      }
+    }
+    if (numDigitGroups < 4 || maxConsecutiveDigits > 4) {
+      return false;
+    }
+
+    // Check if the string contains an email address
+    return SIMPLE_EMAIL_PATTERN.matcher(s).find();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
new file mode 100644
index 0000000..d70d651
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.mail.EmailSettings;
+import com.google.gerrit.server.mail.Encryption;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.net.pop3.POP3Client;
+import org.apache.commons.net.pop3.POP3MessageInfo;
+import org.apache.commons.net.pop3.POP3SClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Pop3MailReceiver extends MailReceiver {
+  private static final Logger log = LoggerFactory.getLogger(Pop3MailReceiver.class);
+
+  @Inject
+  Pop3MailReceiver(EmailSettings mailSettings, MailProcessor mailProcessor, WorkQueue workQueue) {
+    super(mailSettings, mailProcessor, workQueue);
+  }
+
+  /**
+   * handleEmails will open a connection to the mail server, remove emails where deletion is
+   * pending, read new email and close the connection.
+   *
+   * @param async Determines if processing messages should happen asynchronous.
+   */
+  @Override
+  public synchronized void handleEmails(boolean async) {
+    POP3Client pop3;
+    if (mailSettings.encryption != Encryption.NONE) {
+      pop3 = new POP3SClient(mailSettings.encryption.name(), true);
+    } else {
+      pop3 = new POP3Client();
+    }
+    if (mailSettings.port > 0) {
+      pop3.setDefaultPort(mailSettings.port);
+    }
+    try {
+      pop3.connect(mailSettings.host);
+    } catch (IOException e) {
+      log.error("Could not connect to POP3 email server", e);
+      return;
+    }
+    try {
+      try {
+        if (!pop3.login(mailSettings.username, mailSettings.password)) {
+          log.error("Could not login to POP3 email server. Check username and password");
+          return;
+        }
+        try {
+          POP3MessageInfo[] messages = pop3.listMessages();
+          if (messages == null) {
+            log.error("Could not retrieve message list via POP3");
+            return;
+          }
+          log.info("Received " + messages.length + " messages via POP3");
+          // Fetch messages
+          List<MailMessage> mailMessages = new ArrayList<>();
+          for (POP3MessageInfo msginfo : messages) {
+            if (msginfo == null) {
+              // Message was deleted
+              continue;
+            }
+            try (BufferedReader reader = (BufferedReader) pop3.retrieveMessage(msginfo.number)) {
+              if (reader == null) {
+                log.error(
+                    "Could not retrieve POP3 message header for message {}", msginfo.identifier);
+                return;
+              }
+              int[] message = fetchMessage(reader);
+              MailMessage mailMessage = RawMailParser.parse(message);
+              // Delete messages where deletion is pending. This requires
+              // knowing the integer message ID of the email. We therefore parse
+              // the message first and extract the Message-ID specified in RFC
+              // 822 and delete the message if deletion is pending.
+              if (pendingDeletion.contains(mailMessage.id())) {
+                if (pop3.deleteMessage(msginfo.number)) {
+                  pendingDeletion.remove(mailMessage.id());
+                } else {
+                  log.error("Could not delete message " + msginfo.number);
+                }
+              } else {
+                // Process message further
+                mailMessages.add(mailMessage);
+              }
+            } catch (MailParsingException e) {
+              log.error("Could not parse message " + msginfo.number);
+            }
+          }
+          dispatchMailProcessor(mailMessages, async);
+        } finally {
+          pop3.logout();
+        }
+      } finally {
+        pop3.disconnect();
+      }
+    } catch (IOException e) {
+      log.error("Error while issuing POP3 command", e);
+    }
+  }
+
+  public final int[] fetchMessage(BufferedReader reader) throws IOException {
+    List<Integer> character = new ArrayList<>();
+    int ch;
+    while ((ch = reader.read()) != -1) {
+      character.add(ch);
+    }
+    return Ints.toArray(character);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Protocol.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Protocol.java
new file mode 100644
index 0000000..cbd40ed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Protocol.java
@@ -0,0 +1,21 @@
+// 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.mail.receive;
+
+public enum Protocol {
+  NONE,
+  POP3,
+  IMAP
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
new file mode 100644
index 0000000..2ee1ea7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
@@ -0,0 +1,175 @@
+// 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.mail.receive;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.io.CharStreams;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.mail.Address;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import org.apache.james.mime4j.MimeException;
+import org.apache.james.mime4j.dom.Entity;
+import org.apache.james.mime4j.dom.Message;
+import org.apache.james.mime4j.dom.MessageBuilder;
+import org.apache.james.mime4j.dom.Multipart;
+import org.apache.james.mime4j.dom.TextBody;
+import org.apache.james.mime4j.dom.address.Mailbox;
+import org.apache.james.mime4j.message.DefaultMessageBuilder;
+import org.joda.time.DateTime;
+
+/**
+ * RawMailParser parses raw email content received through POP3 or IMAP into an internal {@link
+ * MailMessage}.
+ */
+public class RawMailParser {
+  private static final ImmutableSet<String> MAIN_HEADERS =
+      ImmutableSet.of("to", "from", "cc", "date", "message-id", "subject", "content-type");
+
+  /**
+   * Parses a MailMessage from a string.
+   *
+   * @param raw String as received over the wire
+   * @return Parsed MailMessage
+   * @throws MailParsingException
+   */
+  public static MailMessage parse(String raw) throws MailParsingException {
+    MailMessage.Builder messageBuilder = MailMessage.builder();
+    messageBuilder.rawContentUTF(raw);
+    Message mimeMessage;
+    try {
+      MessageBuilder builder = new DefaultMessageBuilder();
+      mimeMessage = builder.parseMessage(new ByteArrayInputStream(raw.getBytes(UTF_8)));
+    } catch (IOException | MimeException e) {
+      throw new MailParsingException("Can't parse email", e);
+    }
+    // Add general headers
+    if (mimeMessage.getMessageId() != null) {
+      messageBuilder.id(mimeMessage.getMessageId());
+    }
+    if (mimeMessage.getSubject() != null) {
+      messageBuilder.subject(mimeMessage.getSubject());
+    }
+    messageBuilder.dateReceived(new DateTime(mimeMessage.getDate()));
+
+    // Add From, To and Cc
+    if (mimeMessage.getFrom() != null && mimeMessage.getFrom().size() > 0) {
+      Mailbox from = mimeMessage.getFrom().get(0);
+      messageBuilder.from(new Address(from.getName(), from.getAddress()));
+    }
+    if (mimeMessage.getTo() != null) {
+      for (Mailbox m : mimeMessage.getTo().flatten()) {
+        messageBuilder.addTo(new Address(m.getName(), m.getAddress()));
+      }
+    }
+    if (mimeMessage.getCc() != null) {
+      for (Mailbox m : mimeMessage.getCc().flatten()) {
+        messageBuilder.addCc(new Address(m.getName(), m.getAddress()));
+      }
+    }
+
+    // Add additional headers
+    mimeMessage.getHeader().getFields().stream()
+        .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase()))
+        .forEach(f -> messageBuilder.addAdditionalHeader(f.getName() + ": " + f.getBody()));
+
+    // Add text and html body parts
+    StringBuilder textBuilder = new StringBuilder();
+    StringBuilder htmlBuilder = new StringBuilder();
+    try {
+      handleMimePart(mimeMessage, textBuilder, htmlBuilder);
+    } catch (IOException e) {
+      throw new MailParsingException("Can't parse email", e);
+    }
+    messageBuilder.textContent(Strings.emptyToNull(textBuilder.toString()));
+    messageBuilder.htmlContent(Strings.emptyToNull(htmlBuilder.toString()));
+
+    try {
+      // build() will only succeed if all required attributes were set. We wrap
+      // the IllegalStateException in a MailParsingException indicating that
+      // required attributes are missing, so that the caller doesn't fall over.
+      return messageBuilder.build();
+    } catch (IllegalStateException e) {
+      throw new MailParsingException("Missing required attributes after email was parsed", e);
+    }
+  }
+
+  /**
+   * Parses a MailMessage from an array of characters. Note that the character array is int-typed.
+   * This method is only used by POP3, which specifies that all transferred characters are US-ASCII
+   * (RFC 6856). When reading the input in Java, io.Reader yields ints. These can be safely
+   * converted to chars as all US-ASCII characters fit in a char. If emails contain non-ASCII
+   * characters, such as UTF runes, these will be encoded in ASCII using either Base64 or
+   * quoted-printable encoding.
+   *
+   * @param chars Array as received over the wire
+   * @return Parsed MailMessage
+   * @throws MailParsingException
+   */
+  public static MailMessage parse(int[] chars) throws MailParsingException {
+    StringBuilder b = new StringBuilder(chars.length);
+    for (int c : chars) {
+      b.append((char) c);
+    }
+
+    MailMessage.Builder messageBuilder = parse(b.toString()).toBuilder();
+    messageBuilder.rawContent(ImmutableList.copyOf(Ints.asList(chars)));
+    return messageBuilder.build();
+  }
+
+  /**
+   * Traverses a mime tree and parses out text and html parts. All other parts will be dropped.
+   *
+   * @param part MimePart to parse
+   * @param textBuilder StringBuilder to append all plaintext parts
+   * @param htmlBuilder StringBuilder to append all html parts
+   * @throws IOException
+   */
+  private static void handleMimePart(
+      Entity part, StringBuilder textBuilder, StringBuilder htmlBuilder) throws IOException {
+    if (isPlainOrHtml(part.getMimeType()) && !isAttachment(part.getDispositionType())) {
+      TextBody tb = (TextBody) part.getBody();
+      String result =
+          CharStreams.toString(new InputStreamReader(tb.getInputStream(), tb.getMimeCharset()));
+      if (part.getMimeType().equals("text/plain")) {
+        textBuilder.append(result);
+      } else if (part.getMimeType().equals("text/html")) {
+        htmlBuilder.append(result);
+      }
+    } else if (isMultipart(part.getMimeType())) {
+      Multipart multipart = (Multipart) part.getBody();
+      for (Entity e : multipart.getBodyParts()) {
+        handleMimePart(e, textBuilder, htmlBuilder);
+      }
+    }
+  }
+
+  private static boolean isPlainOrHtml(String mimeType) {
+    return (mimeType.equals("text/plain") || mimeType.equals("text/html"));
+  }
+
+  private static boolean isMultipart(String mimeType) {
+    return mimeType.startsWith("multipart/");
+  }
+
+  private static boolean isAttachment(String dispositionType) {
+    return dispositionType != null && dispositionType.equals("attachment");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java
new file mode 100644
index 0000000..fa33cc6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.PeekingIterator;
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/** TextParser provides parsing functionality for plaintext email. */
+public class TextParser {
+  /**
+   * Parses comments from plaintext email.
+   *
+   * @param email MailMessage as received from the email service.
+   * @param comments Comments previously persisted on the change that caused the original
+   *     notification email to be sent out. Ordering must be the same as in the outbound email
+   * @param changeUrl Canonical change url that points to the change on this Gerrit instance.
+   *     Example: https://go-review.googlesource.com/#/c/91570
+   * @return List of MailComments parsed from the plaintext part of the email.
+   */
+  public static List<MailComment> parse(
+      MailMessage email, Collection<Comment> comments, String changeUrl) {
+    String body = email.textContent();
+    // Replace CR-LF by \n
+    body = body.replace("\r\n", "\n");
+
+    List<MailComment> parsedComments = new ArrayList<>();
+
+    // Some email clients (like GMail) use >> for enquoting text when there are
+    // inline comments that the users typed. These will then be enquoted by a
+    // single >. We sanitize this by unifying it into >. Inline comments typed
+    // by the user will not be enquoted.
+    //
+    // Example:
+    // Some comment
+    // >> Quoted Text
+    // >> Quoted Text
+    // > A comment typed in the email directly
+    String singleQuotePattern = "\n> ";
+    String doubleQuotePattern = "\n>> ";
+    if (countOccurrences(body, doubleQuotePattern) > countOccurrences(body, singleQuotePattern)) {
+      body = body.replace(doubleQuotePattern, singleQuotePattern);
+    }
+
+    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+
+    String[] lines = body.split("\n");
+    MailComment currentComment = null;
+    String lastEncounteredFileName = null;
+    Comment lastEncounteredComment = null;
+    for (String line : lines) {
+      if (line.equals(">")) {
+        // Skip empty lines
+        continue;
+      }
+      if (line.startsWith("> ")) {
+        line = line.substring("> ".length()).trim();
+        // This is not a comment, try to advance the file/comment pointers and
+        // add previous comment to list if applicable
+        if (currentComment != null) {
+          if (currentComment.type == MailComment.CommentType.CHANGE_MESSAGE) {
+            currentComment.message = ParserUtil.trimQuotation(currentComment.message);
+          }
+          if (!Strings.isNullOrEmpty(currentComment.message)) {
+            parsedComments.add(currentComment);
+          }
+          currentComment = null;
+        }
+
+        if (!iter.hasNext()) {
+          continue;
+        }
+        Comment perspectiveComment = iter.peek();
+        if (line.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
+          if (lastEncounteredFileName == null
+              || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
+            // This is the annotation of a file
+            lastEncounteredFileName = perspectiveComment.key.filename;
+            lastEncounteredComment = null;
+          } else if (perspectiveComment.lineNbr == 0) {
+            // This was originally a file-level comment
+            lastEncounteredComment = perspectiveComment;
+            iter.next();
+          }
+        } else if (ParserUtil.isCommentUrl(line, changeUrl, perspectiveComment)) {
+          lastEncounteredComment = perspectiveComment;
+          iter.next();
+        }
+      } else {
+        // This is a comment. Try to append to previous comment if applicable or
+        // create a new comment.
+        if (currentComment == null) {
+          // Start new comment
+          currentComment = new MailComment();
+          currentComment.message = line;
+          if (lastEncounteredComment == null) {
+            if (lastEncounteredFileName == null) {
+              // Change message
+              currentComment.type = MailComment.CommentType.CHANGE_MESSAGE;
+            } else {
+              // File comment not sent in reply to another comment
+              currentComment.type = MailComment.CommentType.FILE_COMMENT;
+              currentComment.fileName = lastEncounteredFileName;
+            }
+          } else {
+            // Comment sent in reply to another comment
+            currentComment.inReplyTo = lastEncounteredComment;
+            currentComment.type = MailComment.CommentType.INLINE_COMMENT;
+          }
+        } else {
+          // Attach to previous comment
+          currentComment.message += "\n" + line;
+        }
+      }
+    }
+    // There is no need to attach the currentComment after this loop as all
+    // emails have footers and other enquoted text after the last comment
+    // appeared and the last comment will have already been added to the list
+    // at this point.
+
+    return parsedComments;
+  }
+
+  /** Counts the occurrences of pattern in s */
+  private static int countOccurrences(String s, String pattern) {
+    return (s.length() - s.replace(pattern, "").length()) / pattern.length();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AbandonedSender.java
new file mode 100644
index 0000000..ec62833
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Send notice about a change being abandoned by its owner. */
+public class AbandonedSender extends ReplyToChangeSender {
+  public interface Factory extends ReplyToChangeSender.Factory<AbandonedSender> {
+    @Override
+    AbandonedSender create(Project.NameKey project, Change.Id change);
+  }
+
+  @Inject
+  public AbandonedSender(
+      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "abandon", ChangeEmail.newChangeData(ea, project, id));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    includeWatchers(NotifyType.ABANDONED_CHANGES);
+    removeUsersThatIgnoredTheChange();
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("Abandoned"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("AbandonedHtml"));
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
new file mode 100644
index 0000000..0c09639
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -0,0 +1,132 @@
+// 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.mail.send;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.Address;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.util.List;
+
+public class AddKeySender extends OutgoingEmail {
+  public interface Factory {
+    AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
+
+    AddKeySender create(IdentifiedUser user, List<String> gpgKey);
+  }
+
+  private final IdentifiedUser callingUser;
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeys;
+
+  @AssistedInject
+  public AddKeySender(
+      EmailArguments ea,
+      IdentifiedUser callingUser,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
+    super(ea, "addkey");
+    this.callingUser = callingUser;
+    this.user = user;
+    this.sshKey = sshKey;
+    this.gpgKeys = null;
+  }
+
+  @AssistedInject
+  public AddKeySender(
+      EmailArguments ea,
+      IdentifiedUser callingUser,
+      @Assisted IdentifiedUser user,
+      @Assisted List<String> gpgKeys) {
+    super(ea, "addkey");
+    this.callingUser = callingUser;
+    this.user = user;
+    this.sshKey = null;
+    this.gpgKeys = gpgKeys;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+    setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    add(RecipientType.TO, new Address(getEmail()));
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    /*
+     * Don't send an email if no keys are added, or an admin is adding a key to
+     * a user.
+     */
+    return (sshKey != null || gpgKeys.size() > 0)
+        && (user.equals(callingUser) || !callingUser.getCapabilities().canAdministrateServer());
+  }
+
+  @Override
+  protected void format() throws EmailException {
+    appendText(textTemplate("AddKey"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("AddKeyHtml"));
+    }
+  }
+
+  public String getEmail() {
+    return user.getAccount().getPreferredEmail();
+  }
+
+  public String getUserNameEmail() {
+    return getUserNameEmailFor(user.getAccountId());
+  }
+
+  public String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeys != null) {
+      return "GPG";
+    }
+    return "Unknown";
+  }
+
+  public String getSshKey() {
+    return (sshKey != null) ? sshKey.getSshPublicKey() + "\n" : null;
+  }
+
+  public String getGpgKeys() {
+    if (gpgKeys != null) {
+      return Joiner.on("\n").join(gpgKeys);
+    }
+    return null;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("email", getEmail());
+    soyContextEmailData.put("gpgKeys", getGpgKeys());
+    soyContextEmailData.put("keyType", getKeyType());
+    soyContextEmailData.put("sshKey", getSshKey());
+    soyContextEmailData.put("userNameEmail", getUserNameEmail());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
new file mode 100644
index 0000000..cb70106
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Asks a user to review a change. */
+public class AddReviewerSender extends NewChangeSender {
+  public interface Factory {
+    AddReviewerSender create(Project.NameKey project, Change.Id id);
+  }
+
+  @Inject
+  public AddReviewerSender(
+      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, newChangeData(ea, project, id));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccExistingReviewers();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
new file mode 100644
index 0000000..4ee88fb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -0,0 +1,542 @@
+// 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.mail.send;
+
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.eclipse.jgit.util.TemporaryBuffer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Sends an email to one or more interested parties. */
+public abstract class ChangeEmail extends NotificationEmail {
+  private static final Logger log = LoggerFactory.getLogger(ChangeEmail.class);
+
+  protected static ChangeData newChangeData(
+      EmailArguments ea, Project.NameKey project, Change.Id id) {
+    return ea.changeDataFactory.create(ea.db.get(), project, id);
+  }
+
+  protected final Change change;
+  protected final ChangeData changeData;
+  protected ListMultimap<Account.Id, String> stars;
+  protected PatchSet patchSet;
+  protected PatchSetInfo patchSetInfo;
+  protected String changeMessage;
+  protected Timestamp timestamp;
+
+  protected ProjectState projectState;
+  protected Set<Account.Id> authors;
+  protected boolean emailOnlyAuthors;
+
+  protected ChangeEmail(EmailArguments ea, String mc, ChangeData cd) throws OrmException {
+    super(ea, mc, cd.change().getDest());
+    changeData = cd;
+    change = cd.change();
+    emailOnlyAuthors = false;
+  }
+
+  @Override
+  public void setFrom(final Account.Id id) {
+    super.setFrom(id);
+
+    /** Is the from user in an email squelching group? */
+    final IdentifiedUser user = args.identifiedUserFactory.create(id);
+    emailOnlyAuthors = !user.getCapabilities().canEmailReviewers();
+  }
+
+  public void setPatchSet(final PatchSet ps) {
+    patchSet = ps;
+  }
+
+  public void setPatchSet(final PatchSet ps, final PatchSetInfo psi) {
+    patchSet = ps;
+    patchSetInfo = psi;
+  }
+
+  @Deprecated
+  public void setChangeMessage(final ChangeMessage cm) {
+    setChangeMessage(cm.getMessage(), cm.getWrittenOn());
+  }
+
+  public void setChangeMessage(String cm, Timestamp t) {
+    changeMessage = cm;
+    timestamp = t;
+  }
+
+  /** Format the message body by calling {@link #appendText(String)}. */
+  @Override
+  protected void format() throws EmailException {
+    formatChange();
+    appendText(textTemplate("ChangeFooter"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
+    }
+    formatFooter();
+  }
+
+  /** Format the message body by calling {@link #appendText(String)}. */
+  protected abstract void formatChange() throws EmailException;
+
+  /**
+   * Format the message footer by calling {@link #appendText(String)}.
+   *
+   * @throws EmailException if an error occurred.
+   */
+  protected void formatFooter() throws EmailException {}
+
+  /** Setup the message headers and envelope (TO, CC, BCC). */
+  @Override
+  protected void init() throws EmailException {
+    if (args.projectCache != null) {
+      projectState = args.projectCache.get(change.getProject());
+    } else {
+      projectState = null;
+    }
+
+    if (patchSet == null) {
+      try {
+        patchSet = changeData.currentPatchSet();
+      } catch (OrmException err) {
+        patchSet = null;
+      }
+    }
+
+    if (patchSet != null) {
+      setHeader("X-Gerrit-PatchSet", patchSet.getPatchSetId() + "");
+      if (patchSetInfo == null) {
+        try {
+          patchSetInfo =
+              args.patchSetInfoFactory.get(args.db.get(), changeData.notes(), patchSet.getId());
+        } catch (PatchSetInfoNotAvailableException | OrmException err) {
+          patchSetInfo = null;
+        }
+      }
+    }
+    authors = getAuthors();
+
+    try {
+      stars = args.starredChangesUtil.byChangeFromIndex(change.getId());
+    } catch (OrmException e) {
+      throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
+    }
+
+    super.init();
+    if (timestamp != null) {
+      setHeader("Date", new Date(timestamp.getTime()));
+    }
+    setChangeSubjectHeader();
+    setHeader("X-Gerrit-Change-Id", "" + change.getKey().get());
+    setHeader("X-Gerrit-Change-Number", "" + change.getChangeId());
+    setChangeUrlHeader();
+    setCommitIdHeader();
+  }
+
+  private void setChangeUrlHeader() {
+    final String u = getChangeUrl();
+    if (u != null) {
+      setHeader("X-Gerrit-ChangeURL", "<" + u + ">");
+    }
+  }
+
+  private void setCommitIdHeader() {
+    if (patchSet != null
+        && patchSet.getRevision() != null
+        && patchSet.getRevision().get() != null
+        && patchSet.getRevision().get().length() > 0) {
+      setHeader("X-Gerrit-Commit", patchSet.getRevision().get());
+    }
+  }
+
+  private void setChangeSubjectHeader() throws EmailException {
+    setHeader("Subject", textTemplate("ChangeSubject"));
+  }
+
+  /** Get a link to the change; null if the server doesn't know its own address. */
+  public String getChangeUrl() {
+    if (getGerritUrl() != null) {
+      final StringBuilder r = new StringBuilder();
+      r.append(getGerritUrl());
+      r.append(change.getChangeId());
+      return r.toString();
+    }
+    return null;
+  }
+
+  public String getChangeMessageThreadId() throws EmailException {
+    return velocify("<gerrit.${change.createdOn.time}.$change.key.get()@$email.gerritHost>");
+  }
+
+  /** Format the sender's "cover letter", {@link #getCoverLetter()}. */
+  protected void formatCoverLetter() {
+    final String cover = getCoverLetter();
+    if (!"".equals(cover)) {
+      appendText(cover);
+      appendText("\n\n");
+    }
+  }
+
+  /** Get the text of the "cover letter". */
+  public String getCoverLetter() {
+    if (changeMessage != null) {
+      return changeMessage.trim();
+    }
+    return "";
+  }
+
+  /** Format the change message and the affected file list. */
+  protected void formatChangeDetail() {
+    appendText(getChangeDetail());
+  }
+
+  /** Create the change message and the affected file list. */
+  public String getChangeDetail() {
+    try {
+      StringBuilder detail = new StringBuilder();
+
+      if (patchSetInfo != null) {
+        detail.append(patchSetInfo.getMessage().trim()).append("\n");
+      } else {
+        detail.append(change.getSubject().trim()).append("\n");
+      }
+
+      if (patchSet != null) {
+        detail.append("---\n");
+        PatchList patchList = getPatchList();
+        for (PatchListEntry p : patchList.getPatches()) {
+          if (Patch.isMagic(p.getNewName())) {
+            continue;
+          }
+          detail
+              .append(p.getChangeType().getCode())
+              .append(" ")
+              .append(p.getNewName())
+              .append("\n");
+        }
+        detail.append(
+            MessageFormat.format(
+                "" //
+                    + "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
+                    + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
+                    + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
+                    + "\n",
+                patchList.getPatches().size() - 1, //
+                patchList.getInsertions(), //
+                patchList.getDeletions()));
+        detail.append("\n");
+      }
+      return detail.toString();
+    } catch (Exception err) {
+      log.warn("Cannot format change detail", err);
+      return "";
+    }
+  }
+
+  /** Get the patch list corresponding to this patch set. */
+  protected PatchList getPatchList() throws PatchListNotAvailableException {
+    if (patchSet != null) {
+      return args.patchListCache.get(change, patchSet);
+    }
+    throw new PatchListNotAvailableException("no patchSet specified");
+  }
+
+  /** Get the project entity the change is in; null if its been deleted. */
+  protected ProjectState getProjectState() {
+    return projectState;
+  }
+
+  /** Get the groups which own the project. */
+  protected Set<AccountGroup.UUID> getProjectOwners() {
+    final ProjectState r;
+
+    r = args.projectCache.get(change.getProject());
+    return r != null ? r.getOwners() : Collections.<AccountGroup.UUID>emptySet();
+  }
+
+  /** TO or CC all vested parties (change owner, patch set uploader, author). */
+  protected void rcptToAuthors(final RecipientType rt) {
+    for (final Account.Id id : authors) {
+      add(rt, id);
+    }
+  }
+
+  /** BCC any user who has starred this change. */
+  protected void bccStarredBy() {
+    if (!NotifyHandling.ALL.equals(notify)) {
+      return;
+    }
+
+    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
+      if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
+        super.add(RecipientType.BCC, e.getKey());
+      }
+    }
+  }
+
+  protected void removeUsersThatIgnoredTheChange() {
+    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
+      if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
+        AccountState accountState = args.accountCache.get(e.getKey());
+        if (accountState != null) {
+          removeUser(accountState.getAccount());
+        }
+      }
+    }
+  }
+
+  @Override
+  protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
+      throws OrmException {
+    if (!NotifyHandling.ALL.equals(notify)) {
+      return new Watchers();
+    }
+
+    ProjectWatch watch = new ProjectWatch(args, branch.getParentKey(), projectState, changeData);
+    return watch.getWatchers(type, includeWatchersFromNotifyConfig);
+  }
+
+  /** Any user who has published comments on this change. */
+  protected void ccAllApprovals() {
+    if (!NotifyHandling.ALL.equals(notify) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+      return;
+    }
+
+    try {
+      for (Account.Id id : changeData.reviewers().all()) {
+        add(RecipientType.CC, id);
+      }
+    } catch (OrmException err) {
+      log.warn("Cannot CC users that reviewed updated change", err);
+    }
+  }
+
+  /** Users who have non-zero approval codes on the change. */
+  protected void ccExistingReviewers() {
+    if (!NotifyHandling.ALL.equals(notify) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+      return;
+    }
+
+    try {
+      for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
+        add(RecipientType.CC, id);
+      }
+    } catch (OrmException err) {
+      log.warn("Cannot CC users that commented on updated change", err);
+    }
+  }
+
+  @Override
+  protected void add(final RecipientType rt, final Account.Id to) {
+    if (!emailOnlyAuthors || authors.contains(to)) {
+      super.add(rt, to);
+    }
+  }
+
+  @Override
+  protected boolean isVisibleTo(final Account.Id to) throws OrmException {
+    return projectState == null
+        || projectState
+            .controlFor(args.identifiedUserFactory.create(to))
+            .controlFor(args.db.get(), change)
+            .isVisible(args.db.get());
+  }
+
+  /** Find all users who are authors of any part of this change. */
+  protected Set<Account.Id> getAuthors() {
+    Set<Account.Id> authors = new HashSet<>();
+
+    switch (notify) {
+      case NONE:
+        break;
+      case ALL:
+      default:
+        if (patchSet != null) {
+          authors.add(patchSet.getUploader());
+        }
+        if (patchSetInfo != null) {
+          if (patchSetInfo.getAuthor().getAccount() != null) {
+            authors.add(patchSetInfo.getAuthor().getAccount());
+          }
+          if (patchSetInfo.getCommitter().getAccount() != null) {
+            authors.add(patchSetInfo.getCommitter().getAccount());
+          }
+        }
+        // $FALL-THROUGH$
+      case OWNER_REVIEWERS:
+      case OWNER:
+        authors.add(change.getOwner());
+        break;
+    }
+
+    return authors;
+  }
+
+  @Override
+  protected void setupVelocityContext() {
+    super.setupVelocityContext();
+    velocityContext.put("change", change);
+    velocityContext.put("changeId", change.getKey());
+    velocityContext.put("coverLetter", getCoverLetter());
+    velocityContext.put("fromName", getNameFor(fromId));
+    velocityContext.put("patchSet", patchSet);
+    velocityContext.put("patchSetInfo", patchSetInfo);
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+
+    soyContext.put("changeId", change.getKey().get());
+    soyContext.put("coverLetter", getCoverLetter());
+    soyContext.put("fromName", getNameFor(fromId));
+    soyContext.put("fromEmail", getNameEmailFor(fromId));
+
+    soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
+    soyContextEmailData.put("changeDetail", getChangeDetail());
+    soyContextEmailData.put("changeUrl", getChangeUrl());
+    soyContextEmailData.put("includeDiff", getIncludeDiff());
+
+    Map<String, String> changeData = new HashMap<>();
+    changeData.put("subject", change.getSubject());
+    changeData.put("originalSubject", change.getOriginalSubject());
+    changeData.put("ownerName", getNameFor(change.getOwner()));
+    changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
+    changeData.put("changeNumber", Integer.toString(change.getChangeId()));
+    soyContext.put("change", changeData);
+
+    String subject = change.getSubject();
+    changeData.put("subject", subject);
+    // shortSubject is the subject limited to 63 characters, with an ellipsis if
+    // it exceeds that.
+    if (subject.length() < 73) {
+      changeData.put("shortSubject", subject);
+    } else {
+      changeData.put("shortSubject", subject.substring(0, 69) + "...");
+    }
+
+    Map<String, Object> patchSetData = new HashMap<>();
+    patchSetData.put("patchSetId", patchSet.getPatchSetId());
+    patchSetData.put("refName", patchSet.getRefName());
+    soyContext.put("patchSet", patchSetData);
+
+    // TODO(wyatta): patchSetInfo
+
+    footers.add("Gerrit-MessageType: " + messageClass);
+    footers.add("Gerrit-Change-Id: " + change.getKey().get());
+    footers.add("Gerrit-Change-Number: " + Integer.toString(change.getChangeId()));
+    footers.add("Gerrit-PatchSet: " + patchSet.getPatchSetId());
+    footers.add("Gerrit-Owner: " + getNameEmailFor(change.getOwner()));
+    for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
+      footers.add("Gerrit-Reviewer: " + reviewer);
+    }
+    for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
+      footers.add("Gerrit-CC: " + reviewer);
+    }
+  }
+
+  private Set<String> getEmailsByState(ReviewerStateInternal state) {
+    Set<String> reviewers = new TreeSet<>();
+    try {
+      for (Account.Id who : changeData.reviewers().byState(state)) {
+        reviewers.add(getNameEmailFor(who));
+      }
+    } catch (OrmException e) {
+      log.warn("Cannot get change reviewers", e);
+    }
+    return reviewers;
+  }
+
+  public boolean getIncludeDiff() {
+    return args.settings.includeDiff;
+  }
+
+  private static final int HEAP_EST_SIZE = 32 * 1024;
+
+  /** Show patch set as unified difference. */
+  public String getUnifiedDiff() {
+    PatchList patchList;
+    try {
+      patchList = getPatchList();
+      if (patchList.getOldId() == null) {
+        // Octopus merges are not well supported for diff output by Gerrit.
+        // Currently these always have a null oldId in the PatchList.
+        return "[Octopus merge; cannot be formatted as a diff.]\n";
+      }
+    } catch (PatchListNotAvailableException e) {
+      log.error("Cannot format patch", e);
+      return "";
+    }
+
+    int maxSize = args.settings.maximumDiffSize;
+    TemporaryBuffer.Heap buf = new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
+    try (DiffFormatter fmt = new DiffFormatter(buf)) {
+      try (Repository git = args.server.openRepository(change.getProject())) {
+        try {
+          fmt.setRepository(git);
+          fmt.setDetectRenames(true);
+          fmt.format(patchList.getOldId(), patchList.getNewId());
+          return RawParseUtils.decode(buf.toByteArray());
+        } catch (IOException e) {
+          if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
+            return "";
+          }
+          log.error("Cannot format patch", e);
+          return "";
+        }
+      } catch (IOException e) {
+        log.error("Cannot open repository to format patch", e);
+        return "";
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java
new file mode 100644
index 0000000..74d1480
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java
@@ -0,0 +1,181 @@
+// 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.mail.send;
+
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class CommentFormatter {
+  public enum BlockType {
+    LIST,
+    PARAGRAPH,
+    PRE_FORMATTED,
+    QUOTE
+  }
+
+  public static class Block {
+    public BlockType type;
+    public String text;
+    public List<String> items; // For the items of list blocks.
+    public List<Block> quotedBlocks; // For the contents of quote blocks.
+  }
+
+  /**
+   * Take a string of comment text that was written using the wiki-Like format and emit a list of
+   * blocks that can be rendered to block-level HTML. This method does not escape HTML.
+   *
+   * <p>Adapted from the {@code wikify} method found in:
+   * com.google.gwtexpui.safehtml.client.SafeHtml
+   *
+   * @param source The raw, unescaped comment in the Gerrit wiki-like format.
+   * @return List of block objects, each with unescaped comment content.
+   */
+  public static List<Block> parse(@Nullable String source) {
+    if (isNullOrEmpty(source)) {
+      return Collections.emptyList();
+    }
+
+    List<Block> result = new ArrayList<>();
+    for (String p : source.split("\n\n")) {
+      if (isQuote(p)) {
+        result.add(makeQuote(p));
+      } else if (isPreFormat(p)) {
+        result.add(makePre(p));
+      } else if (isList(p)) {
+        makeList(p, result);
+      } else if (!p.isEmpty()) {
+        result.add(makeParagraph(p));
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Take a block of comment text that contains a list and potentially paragraphs (but does not
+   * contain blank lines), generate appropriate block elements and append them to the output list.
+   *
+   * <p>In simple cases, this will generate a single list block. For example, on the following
+   * input.
+   *
+   * <p>* Item one. * Item two. * item three.
+   *
+   * <p>However, if the list is adjacent to a paragraph, it will need to also generate that
+   * paragraph. Consider the following input.
+   *
+   * <p>A bit of text describing the context of the list: * List item one. * List item two. * Et
+   * cetera.
+   *
+   * <p>In this case, {@code makeList} generates a paragraph block object containing the
+   * non-bullet-prefixed text, followed by a list block.
+   *
+   * <p>Adapted from the {@code wikifyList} method found in:
+   * com.google.gwtexpui.safehtml.client.SafeHtml
+   *
+   * @param p The block containing the list (as well as potential paragraphs).
+   * @param out The list of blocks to append to.
+   */
+  private static void makeList(String p, List<Block> out) {
+    Block block = null;
+    StringBuilder textBuilder = null;
+    boolean inList = false;
+    boolean inParagraph = false;
+
+    for (String line : p.split("\n")) {
+      if (line.startsWith("-") || line.startsWith("*")) {
+        // The next line looks like a list item. If not building a list already,
+        // then create one. Remove the list item marker (* or -) from the line.
+        if (!inList) {
+          if (inParagraph) {
+            // Add the finished paragraph block to the result.
+            inParagraph = false;
+            block.text = textBuilder.toString();
+            out.add(block);
+          }
+
+          inList = true;
+          block = new Block();
+          block.type = BlockType.LIST;
+          block.items = new ArrayList<>();
+        }
+        line = line.substring(1).trim();
+
+      } else if (!inList) {
+        // Otherwise, if a list has not yet been started, but the next line does
+        // not look like a list item, then add the line to a paragraph block. If
+        // a paragraph block has not yet been started, then create one.
+        if (!inParagraph) {
+          inParagraph = true;
+          block = new Block();
+          block.type = BlockType.PARAGRAPH;
+          textBuilder = new StringBuilder();
+        } else {
+          textBuilder.append(" ");
+        }
+        textBuilder.append(line);
+        continue;
+      }
+
+      block.items.add(line);
+    }
+
+    if (block != null) {
+      out.add(block);
+    }
+  }
+
+  private static Block makeQuote(String p) {
+    String quote = p.replaceAll("\n\\s?>\\s?", "\n");
+    if (quote.startsWith("> ")) {
+      quote = quote.substring(2);
+    } else if (quote.startsWith(" > ")) {
+      quote = quote.substring(3);
+    }
+
+    Block block = new Block();
+    block.type = BlockType.QUOTE;
+    block.quotedBlocks = CommentFormatter.parse(quote);
+    return block;
+  }
+
+  private static Block makePre(String p) {
+    Block block = new Block();
+    block.type = BlockType.PRE_FORMATTED;
+    block.text = p;
+    return block;
+  }
+
+  private static Block makeParagraph(String p) {
+    Block block = new Block();
+    block.type = BlockType.PARAGRAPH;
+    block.text = p;
+    return block;
+  }
+
+  private static boolean isQuote(String p) {
+    return p.startsWith("> ") || p.startsWith(" > ");
+  }
+
+  private static boolean isPreFormat(String p) {
+    return p.startsWith(" ") || p.startsWith("\t") || p.contains("\n ") || p.contains("\n\t");
+  }
+
+  private static boolean isList(String p) {
+    return p.startsWith("- ") || p.startsWith("* ") || p.contains("\n- ") || p.contains("\n* ");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
new file mode 100644
index 0000000..d827503
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -0,0 +1,634 @@
+// 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.mail.send;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.data.FilenameComparator;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.common.errors.NoSuchEntityException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.receive.Protocol;
+import com.google.gerrit.server.patch.PatchFile;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Send comments, after the author of them hit used Publish Comments in the UI. */
+public class CommentSender extends ReplyToChangeSender {
+  private static final Logger log = LoggerFactory.getLogger(CommentSender.class);
+
+  public interface Factory {
+    CommentSender create(Project.NameKey project, Change.Id id);
+  }
+
+  private class FileCommentGroup {
+    public String filename;
+    public int patchSetId;
+    public PatchFile fileData;
+    public List<Comment> comments = new ArrayList<>();
+
+    /** @return a web link to the given patch set and file. */
+    public String getLink() {
+      String url = getGerritUrl();
+      if (url == null) {
+        return null;
+      }
+
+      return new StringBuilder()
+          .append(url)
+          .append("#/c/")
+          .append(change.getId())
+          .append('/')
+          .append(patchSetId)
+          .append('/')
+          .append(KeyUtil.encode(filename))
+          .toString();
+    }
+
+    /**
+     * @return A title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
+     */
+    public String getTitle() {
+      if (Patch.COMMIT_MSG.equals(filename)) {
+        return "Commit Message";
+      } else if (Patch.MERGE_LIST.equals(filename)) {
+        return "Merge List";
+      } else {
+        return "File " + filename;
+      }
+    }
+  }
+
+  private List<Comment> inlineComments = Collections.emptyList();
+  private String patchSetComment;
+  private List<LabelVote> labels = Collections.emptyList();
+  private final CommentsUtil commentsUtil;
+  private final boolean incomingEmailEnabled;
+  private final String replyToAddress;
+
+  @Inject
+  public CommentSender(
+      EmailArguments ea,
+      CommentsUtil commentsUtil,
+      @GerritServerConfig Config cfg,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "comment", newChangeData(ea, project, id));
+    this.commentsUtil = commentsUtil;
+    this.incomingEmailEnabled =
+        cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
+            > Protocol.NONE.ordinal();
+    this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
+  }
+
+  public void setComments(List<Comment> comments) throws OrmException {
+    inlineComments = comments;
+
+    Set<String> paths = new HashSet<>();
+    for (Comment c : comments) {
+      if (!Patch.isMagic(c.key.filename)) {
+        paths.add(c.key.filename);
+      }
+    }
+    changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths));
+  }
+
+  public void setPatchSetComment(String comment) {
+    this.patchSetComment = comment;
+  }
+
+  public void setLabels(List<LabelVote> labels) {
+    this.labels = labels;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    if (notify.compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
+      ccAllApprovals();
+    }
+    if (notify.compareTo(NotifyHandling.ALL) >= 0) {
+      bccStarredBy();
+      includeWatchers(NotifyType.ALL_COMMENTS, !patchSet.isDraft());
+    }
+    removeUsersThatIgnoredTheChange();
+
+    // Add header that enables identifying comments on parsed email.
+    // Grouping is currently done by timestamp.
+    setHeader("X-Gerrit-Comment-Date", timestamp);
+
+    if (incomingEmailEnabled) {
+      if (replyToAddress == null) {
+        // Remove Reply-To and use outbound SMTP (default) instead.
+        removeHeader("Reply-To");
+      } else {
+        setHeader("Reply-To", replyToAddress);
+      }
+    }
+  }
+
+  @Override
+  public void formatChange() throws EmailException {
+    appendText(textTemplate("Comment"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("CommentHtml"));
+    }
+  }
+
+  @Override
+  public void formatFooter() throws EmailException {
+    appendText(textTemplate("CommentFooter"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("CommentFooterHtml"));
+    }
+  }
+
+  /** No longer used outside Velocity. Remove this method when VTL support is removed. */
+  @Deprecated
+  public boolean hasInlineComments() {
+    return !inlineComments.isEmpty();
+  }
+
+  /** No longer used outside Velocity. Remove this method when VTL support is removed. */
+  @Deprecated
+  public String getInlineComments() {
+    return getInlineComments(1);
+  }
+
+  /** No longer used outside Velocity. Remove this method when VTL support is removed. */
+  @Deprecated
+  public String getInlineComments(int lines) {
+    try (Repository repo = getRepository()) {
+      StringBuilder cmts = new StringBuilder();
+      for (FileCommentGroup group : getGroupedInlineComments(repo)) {
+        String link = group.getLink();
+        if (link != null) {
+          cmts.append(link).append('\n');
+        }
+        cmts.append(group.getTitle()).append(":\n\n");
+        for (Comment c : group.comments) {
+          appendComment(cmts, lines, group.fileData, c);
+        }
+        cmts.append("\n\n");
+      }
+      return cmts.toString();
+    }
+  }
+
+  /**
+   * @return a list of FileCommentGroup objects representing the inline comments grouped by the
+   *     file.
+   */
+  private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
+    List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
+    // Get the patch list:
+    PatchList patchList = null;
+    if (repo != null) {
+      try {
+        patchList = getPatchList();
+      } catch (PatchListNotAvailableException e) {
+        log.error("Failed to get patch list", e);
+      }
+    }
+
+    // Loop over the comments and collect them into groups based on the file
+    // location of the comment.
+    FileCommentGroup currentGroup = null;
+    for (Comment c : inlineComments) {
+      // If it's a new group:
+      if (currentGroup == null
+          || !c.key.filename.equals(currentGroup.filename)
+          || c.key.patchSetId != currentGroup.patchSetId) {
+        currentGroup = new FileCommentGroup();
+        currentGroup.filename = c.key.filename;
+        currentGroup.patchSetId = c.key.patchSetId;
+        groups.add(currentGroup);
+        if (patchList != null) {
+          try {
+            currentGroup.fileData = new PatchFile(repo, patchList, c.key.filename);
+          } catch (IOException e) {
+            log.warn(
+                "Cannot load {} from {} in {}",
+                c.key.filename,
+                patchList.getNewId().name(),
+                projectState.getProject().getName(),
+                e);
+            currentGroup.fileData = null;
+          }
+        }
+      }
+
+      if (currentGroup.fileData != null) {
+        currentGroup.comments.add(c);
+      }
+    }
+
+    Collections.sort(groups, Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
+    return groups;
+  }
+
+  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
+  @Deprecated
+  private void appendComment(
+      StringBuilder out, int contextLines, PatchFile currentFileData, Comment comment) {
+    if (comment instanceof RobotComment) {
+      RobotComment robotComment = (RobotComment) comment;
+      out.append("Robot Comment from ")
+          .append(robotComment.robotId)
+          .append(" (run ID ")
+          .append(robotComment.robotRunId)
+          .append("):\n");
+    }
+    if (comment.range != null) {
+      appendRangedComment(out, currentFileData, comment);
+    } else {
+      appendLineComment(out, contextLines, currentFileData, comment);
+    }
+  }
+
+  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
+  @Deprecated
+  private void appendRangedComment(StringBuilder out, PatchFile fileData, Comment comment) {
+    String prefix = getCommentLinePrefix(comment);
+    String emptyPrefix = Strings.padStart(": ", prefix.length(), ' ');
+    boolean firstLine = true;
+    for (String line : getLinesByRange(comment.range, fileData, comment.side)) {
+      out.append(firstLine ? prefix : emptyPrefix).append(line).append('\n');
+      firstLine = false;
+    }
+    appendQuotedParent(out, comment);
+    out.append(comment.message.trim()).append('\n');
+  }
+
+  private String getCommentLinePrefix(Comment comment) {
+    int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
+    StringBuilder sb = new StringBuilder();
+    sb.append("PS").append(comment.key.patchSetId);
+    if (lineNbr != 0) {
+      sb.append(", Line ").append(lineNbr);
+    }
+    sb.append(": ");
+    return sb.toString();
+  }
+
+  /**
+   * @return the lines of file content in fileData that are encompassed by range on the given side.
+   */
+  private List<String> getLinesByRange(Comment.Range range, PatchFile fileData, short side) {
+    List<String> lines = new ArrayList<>();
+
+    for (int n = range.startLine; n <= range.endLine; n++) {
+      String s = getLine(fileData, side, n);
+      if (n == range.startLine && n == range.endLine && range.startChar < range.endChar) {
+        s = s.substring(Math.min(range.startChar, s.length()), Math.min(range.endChar, s.length()));
+      } else if (n == range.startLine) {
+        s = s.substring(Math.min(range.startChar, s.length()));
+      } else if (n == range.endLine) {
+        s = s.substring(0, Math.min(range.endChar, s.length()));
+      }
+      lines.add(s);
+    }
+    return lines;
+  }
+
+  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
+  @Deprecated
+  private void appendLineComment(
+      StringBuilder out, int contextLines, PatchFile currentFileData, Comment comment) {
+    short side = comment.side;
+    int lineNbr = comment.lineNbr;
+
+    // Initialize maxLines to the known line number.
+    int maxLines = lineNbr;
+
+    try {
+      maxLines = currentFileData.getLineCount(side);
+    } catch (IOException err) {
+      // The file could not be read, leave the max as is.
+      log.warn("Failed to read file {} on side {}", comment.key.filename, side, err);
+    } catch (NoSuchEntityException err) {
+      // The file could not be read, leave the max as is.
+      log.warn("Side {} of file {} didn't exist", side, comment.key.filename, err);
+    }
+
+    int startLine = Math.max(1, lineNbr - contextLines + 1);
+    int stopLine = Math.min(maxLines, lineNbr + contextLines);
+
+    for (int line = startLine; line <= lineNbr; ++line) {
+      appendFileLine(out, currentFileData, side, line);
+    }
+    appendQuotedParent(out, comment);
+    out.append(comment.message.trim()).append('\n');
+
+    for (int line = lineNbr + 1; line < stopLine; ++line) {
+      appendFileLine(out, currentFileData, side, line);
+    }
+  }
+
+  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
+  @Deprecated
+  private void appendFileLine(StringBuilder cmts, PatchFile fileData, short side, int line) {
+    String lineStr = getLine(fileData, side, line);
+    cmts.append("Line ").append(line).append(": ").append(lineStr).append("\n");
+  }
+
+  /** No longer used except for Velocity. Remove this method when VTL support is removed. */
+  @Deprecated
+  private void appendQuotedParent(StringBuilder out, Comment child) {
+    Optional<Comment> parent = getParent(child);
+    if (parent.isPresent()) {
+      out.append("> ").append(getShortenedCommentMessage(parent.get())).append('\n');
+    }
+  }
+
+  /**
+   * Get the parent comment of a given comment.
+   *
+   * @param child the comment with a potential parent comment.
+   * @return an optional comment that will be present if the given comment has a parent, and is
+   *     empty if it does not.
+   */
+  private Optional<Comment> getParent(Comment child) {
+    if (child.parentUuid == null) {
+      return Optional.empty();
+    }
+
+    Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
+    try {
+      return commentsUtil.get(args.db.get(), changeData.notes(), key);
+    } catch (OrmException e) {
+      log.warn("Could not find the parent of this comment: {}", child.toString());
+      return Optional.empty();
+    }
+  }
+
+  /**
+   * Retrieve the file lines referred to by a comment.
+   *
+   * @param comment The comment that refers to some file contents. The comment may be a line comment
+   *     or a ranged comment.
+   * @param fileData The file on which the comment appears.
+   * @return file contents referred to by the comment. If the comment is a line comment, the result
+   *     will be a list of one string. Otherwise it will be a list of one or more strings.
+   */
+  private List<String> getLinesOfComment(Comment comment, PatchFile fileData) {
+    List<String> lines = new ArrayList<>();
+    if (comment.lineNbr == 0) {
+      // file level comment has no line
+      return lines;
+    }
+    if (comment.range == null) {
+      lines.add(getLine(fileData, comment.side, comment.lineNbr));
+    } else {
+      lines.addAll(getLinesByRange(comment.range, fileData, comment.side));
+    }
+    return lines;
+  }
+
+  /**
+   * @return a shortened version of the given comment's message. Will be shortened to 75 characters
+   *     or the first line, whichever is shorter.
+   */
+  private String getShortenedCommentMessage(Comment comment) {
+    String msg = comment.message.trim();
+    if (msg.length() > 75) {
+      msg = msg.substring(0, 75);
+    }
+    int lf = msg.indexOf('\n');
+    if (lf > 0) {
+      msg = msg.substring(0, lf);
+    }
+    return msg;
+  }
+
+  /**
+   * @return grouped inline comment data mapped to data structures that are suitable for passing
+   *     into Soy.
+   */
+  private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) {
+    List<Map<String, Object>> commentGroups = new ArrayList<>();
+
+    for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
+      Map<String, Object> groupData = new HashMap<>();
+      groupData.put("link", group.getLink());
+      groupData.put("title", group.getTitle());
+      groupData.put("patchSetId", group.patchSetId);
+
+      List<Map<String, Object>> commentsList = new ArrayList<>();
+      for (Comment comment : group.comments) {
+        Map<String, Object> commentData = new HashMap<>();
+        commentData.put("lines", getLinesOfComment(comment, group.fileData));
+        commentData.put("message", comment.message.trim());
+        List<CommentFormatter.Block> blocks = CommentFormatter.parse(comment.message);
+        commentData.put("messageBlocks", commentBlocksToSoyData(blocks));
+
+        // Set the prefix.
+        String prefix = getCommentLinePrefix(comment);
+        commentData.put("linePrefix", prefix);
+        commentData.put("linePrefixEmpty", Strings.padStart(": ", prefix.length(), ' '));
+
+        // Set line numbers.
+        int startLine;
+        if (comment.range == null) {
+          startLine = comment.lineNbr;
+        } else {
+          startLine = comment.range.startLine;
+          commentData.put("endLine", comment.range.endLine);
+        }
+        commentData.put("startLine", startLine);
+
+        // Set the comment link.
+        if (comment.lineNbr == 0) {
+          commentData.put("link", group.getLink());
+        } else if (comment.side == 0) {
+          commentData.put("link", group.getLink() + "@a" + startLine);
+        } else {
+          commentData.put("link", group.getLink() + '@' + startLine);
+        }
+
+        // Set robot comment data.
+        if (comment instanceof RobotComment) {
+          RobotComment robotComment = (RobotComment) comment;
+          commentData.put("isRobotComment", true);
+          commentData.put("robotId", robotComment.robotId);
+          commentData.put("robotRunId", robotComment.robotRunId);
+          commentData.put("robotUrl", robotComment.url);
+        } else {
+          commentData.put("isRobotComment", false);
+        }
+
+        // If the comment has a quote, don't bother loading the parent message.
+        if (!hasQuote(blocks)) {
+          // Set parent comment info.
+          Optional<Comment> parent = getParent(comment);
+          if (parent.isPresent()) {
+            commentData.put("parentMessage", getShortenedCommentMessage(parent.get()));
+          }
+        }
+
+        commentsList.add(commentData);
+      }
+      groupData.put("comments", commentsList);
+
+      commentGroups.add(groupData);
+    }
+    return commentGroups;
+  }
+
+  private List<Map<String, Object>> commentBlocksToSoyData(List<CommentFormatter.Block> blocks) {
+    return blocks.stream()
+        .map(
+            b -> {
+              Map<String, Object> map = new HashMap<>();
+              switch (b.type) {
+                case PARAGRAPH:
+                  map.put("type", "paragraph");
+                  map.put("text", b.text);
+                  break;
+                case PRE_FORMATTED:
+                  map.put("type", "pre");
+                  map.put("text", b.text);
+                  break;
+                case QUOTE:
+                  map.put("type", "quote");
+                  map.put("quotedBlocks", commentBlocksToSoyData(b.quotedBlocks));
+                  break;
+                case LIST:
+                  map.put("type", "list");
+                  map.put("items", b.items);
+                  break;
+              }
+              return map;
+            })
+        .collect(toList());
+  }
+
+  private boolean hasQuote(List<CommentFormatter.Block> blocks) {
+    for (CommentFormatter.Block block : blocks) {
+      if (block.type == CommentFormatter.BlockType.QUOTE) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private Repository getRepository() {
+    try {
+      return args.server.openRepository(projectState.getProject().getNameKey());
+    } catch (IOException e) {
+      return null;
+    }
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    boolean hasComments = false;
+    try (Repository repo = getRepository()) {
+      List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
+      soyContext.put("commentFiles", files);
+      hasComments = !files.isEmpty();
+    }
+
+    soyContext.put(
+        "patchSetCommentBlocks", commentBlocksToSoyData(CommentFormatter.parse(patchSetComment)));
+    soyContext.put("labels", getLabelVoteSoyData(labels));
+    soyContext.put("commentCount", inlineComments.size());
+    soyContext.put("commentTimestamp", getCommentTimestamp());
+    soyContext.put(
+        "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
+
+    footers.add("Gerrit-Comment-Date: " + getCommentTimestamp());
+    footers.add("Gerrit-HasComments: " + (hasComments ? "Yes" : "No"));
+  }
+
+  private String getLine(PatchFile fileInfo, short side, int lineNbr) {
+    try {
+      return fileInfo.getLine(side, lineNbr);
+    } catch (IOException err) {
+      // Default to the empty string if the file cannot be safely read.
+      log.warn("Failed to read file on side {}", side, err);
+      return "";
+    } catch (IndexOutOfBoundsException err) {
+      // Default to the empty string if the given line number does not appear
+      // in the file.
+      log.debug("Failed to get line number of file on side {}", side, err);
+      return "";
+    } catch (NoSuchEntityException err) {
+      // Default to the empty string if the side cannot be found.
+      log.warn("Side {} of file didn't exist", side, err);
+      return "";
+    }
+  }
+
+  private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
+    List<Map<String, Object>> result = new ArrayList<>();
+    for (LabelVote vote : votes) {
+      Map<String, Object> data = new HashMap<>();
+      data.put("label", vote.label());
+
+      // Soy needs the short to be cast as an int for it to get converted to the
+      // correct tamplate type.
+      data.put("value", (int) vote.value());
+      result.add(data);
+    }
+    return result;
+  }
+
+  private String getCommentTimestamp() {
+    // Grouping is currently done by timestamp.
+    return MailUtil.rfcDateformatter.format(
+        ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
new file mode 100644
index 0000000..3e9e62c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+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.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Notify interested parties of a brand new change. */
+public class CreateChangeSender extends NewChangeSender {
+  private static final Logger log = LoggerFactory.getLogger(CreateChangeSender.class);
+
+  public interface Factory {
+    CreateChangeSender create(Project.NameKey project, Change.Id id);
+  }
+
+  @Inject
+  public CreateChangeSender(
+      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, newChangeData(ea, project, id));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    boolean isDraft = change.getStatus() == Change.Status.DRAFT;
+    try {
+      // Try to mark interested owners with TO and CC or BCC line.
+      Watchers matching = getWatchers(NotifyType.NEW_CHANGES, !isDraft);
+      for (Account.Id user :
+          Iterables.concat(matching.to.accounts, matching.cc.accounts, matching.bcc.accounts)) {
+        if (isOwnerOfProjectOrBranch(user)) {
+          add(RecipientType.TO, user);
+        }
+      }
+
+      // Add everyone else. Owners added above will not be duplicated.
+      add(RecipientType.TO, matching.to);
+      add(RecipientType.CC, matching.cc);
+      add(RecipientType.BCC, matching.bcc);
+    } catch (OrmException err) {
+      // Just don't CC everyone. Better to send a partial message to those
+      // we already have queued up then to fail deliver entirely to people
+      // who have a lower interest in the change.
+      log.warn("Cannot notify watchers for new change", err);
+    }
+
+    includeWatchers(NotifyType.NEW_PATCHSETS, !isDraft);
+  }
+
+  private boolean isOwnerOfProjectOrBranch(Account.Id user) {
+    return projectState != null
+        && projectState
+            .controlFor(args.identifiedUserFactory.create(user))
+            .controlForRef(change.getDest())
+            .isOwner();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
new file mode 100644
index 0000000..a563846
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+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.server.account.WatchConfig.NotifyType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Let users know that a reviewer and possibly her review have been removed. */
+public class DeleteReviewerSender extends ReplyToChangeSender {
+  private final Set<Account.Id> reviewers = new HashSet<>();
+
+  public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
+    @Override
+    DeleteReviewerSender create(Project.NameKey project, Change.Id change);
+  }
+
+  @Inject
+  public DeleteReviewerSender(
+      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "deleteReviewer", newChangeData(ea, project, id));
+  }
+
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    ccExistingReviewers();
+    includeWatchers(NotifyType.ALL_COMMENTS);
+    add(RecipientType.TO, reviewers);
+    removeUsersThatIgnoredTheChange();
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("DeleteReviewer"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("DeleteReviewerHtml"));
+    }
+  }
+
+  public List<String> getReviewerNames() {
+    if (reviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(getNameFor(id));
+    }
+    return names;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("reviewerNames", getReviewerNames());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
new file mode 100644
index 0000000..8509f73
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -0,0 +1,61 @@
+// 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Send notice about a vote that was removed from a change. */
+public class DeleteVoteSender extends ReplyToChangeSender {
+  public interface Factory extends ReplyToChangeSender.Factory<DeleteVoteSender> {
+    @Override
+    DeleteVoteSender create(Project.NameKey project, Change.Id change);
+  }
+
+  @Inject
+  protected DeleteVoteSender(
+      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "deleteVote", newChangeData(ea, project, id));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    includeWatchers(NotifyType.ALL_COMMENTS);
+    removeUsersThatIgnoredTheChange();
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("DeleteVote"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("DeleteVoteHtml"));
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
new file mode 100644
index 0000000..9306c7a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -0,0 +1,145 @@
+// 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.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.CapabilityControl;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupIncludeCache;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.EmailSettings;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
+import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.template.soy.tofu.SoyTofu;
+import java.util.List;
+import org.apache.velocity.runtime.RuntimeInstance;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class EmailArguments {
+  final GitRepositoryManager server;
+  final ProjectCache projectCache;
+  final GroupBackend groupBackend;
+  final GroupIncludeCache groupIncludes;
+  final AccountCache accountCache;
+  final PatchListCache patchListCache;
+  final ApprovalsUtil approvalsUtil;
+  final FromAddressGenerator fromAddressGenerator;
+  final EmailSender emailSender;
+  final PatchSetInfoFactory patchSetInfoFactory;
+  final IdentifiedUser.GenericFactory identifiedUserFactory;
+  final CapabilityControl.Factory capabilityControlFactory;
+  final ChangeNotes.Factory changeNotesFactory;
+  final AnonymousUser anonymousUser;
+  final String anonymousCowardName;
+  final PersonIdent gerritPersonIdent;
+  final Provider<String> urlProvider;
+  final AllProjectsName allProjectsName;
+  final List<String> sshAddresses;
+  final SitePaths site;
+
+  final ChangeQueryBuilder queryBuilder;
+  final Provider<ReviewDb> db;
+  final ChangeData.Factory changeDataFactory;
+  final RuntimeInstance velocityRuntime;
+  final SoyTofu soyTofu;
+  final EmailSettings settings;
+  final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
+  final StarredChangesUtil starredChangesUtil;
+  final Provider<InternalAccountQuery> accountQueryProvider;
+
+  @Inject
+  EmailArguments(
+      GitRepositoryManager server,
+      ProjectCache projectCache,
+      GroupBackend groupBackend,
+      GroupIncludeCache groupIncludes,
+      AccountCache accountCache,
+      PatchListCache patchListCache,
+      ApprovalsUtil approvalsUtil,
+      FromAddressGenerator fromAddressGenerator,
+      EmailSender emailSender,
+      PatchSetInfoFactory patchSetInfoFactory,
+      GenericFactory identifiedUserFactory,
+      CapabilityControl.Factory capabilityControlFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      AnonymousUser anonymousUser,
+      @AnonymousCowardName String anonymousCowardName,
+      GerritPersonIdentProvider gerritPersonIdentProvider,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      AllProjectsName allProjectsName,
+      ChangeQueryBuilder queryBuilder,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      RuntimeInstance velocityRuntime,
+      @MailTemplates SoyTofu soyTofu,
+      EmailSettings settings,
+      @SshAdvertisedAddresses List<String> sshAddresses,
+      SitePaths site,
+      DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
+      StarredChangesUtil starredChangesUtil,
+      Provider<InternalAccountQuery> accountQueryProvider) {
+    this.server = server;
+    this.projectCache = projectCache;
+    this.groupBackend = groupBackend;
+    this.groupIncludes = groupIncludes;
+    this.accountCache = accountCache;
+    this.patchListCache = patchListCache;
+    this.approvalsUtil = approvalsUtil;
+    this.fromAddressGenerator = fromAddressGenerator;
+    this.emailSender = emailSender;
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.capabilityControlFactory = capabilityControlFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.anonymousUser = anonymousUser;
+    this.anonymousCowardName = anonymousCowardName;
+    this.gerritPersonIdent = gerritPersonIdentProvider.get();
+    this.urlProvider = urlProvider;
+    this.allProjectsName = allProjectsName;
+    this.queryBuilder = queryBuilder;
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    this.velocityRuntime = velocityRuntime;
+    this.soyTofu = soyTofu;
+    this.settings = settings;
+    this.sshAddresses = sshAddresses;
+    this.site = site;
+    this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
+    this.starredChangesUtil = starredChangesUtil;
+    this.accountQueryProvider = accountQueryProvider;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
new file mode 100644
index 0000000..e2b5894
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
@@ -0,0 +1,239 @@
+// Copyright (C) 2009 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.mail.send;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.server.mail.Address;
+import java.io.IOException;
+import java.io.Writer;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+public abstract class EmailHeader {
+  public abstract boolean isEmpty();
+
+  public abstract void write(Writer w) throws IOException;
+
+  public static class String extends EmailHeader {
+    private final java.lang.String value;
+
+    public String(java.lang.String v) {
+      value = v;
+    }
+
+    public java.lang.String getString() {
+      return value;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return value == null || value.length() == 0;
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      if (needsQuotedPrintable(value)) {
+        w.write(quotedPrintable(value));
+      } else {
+        w.write(value);
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof String) && Objects.equals(value, ((String) o).value);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(value).toString();
+    }
+  }
+
+  public static boolean needsQuotedPrintable(java.lang.String value) {
+    for (int i = 0; i < value.length(); i++) {
+      if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  static boolean needsQuotedPrintableWithinPhrase(final int cp) {
+    switch (cp) {
+      case '!':
+      case '*':
+      case '+':
+      case '-':
+      case '/':
+      case '=':
+      case '_':
+        return false;
+      default:
+        if (('a' <= cp && cp <= 'z') || ('A' <= cp && cp <= 'Z') || ('0' <= cp && cp <= '9')) {
+          return false;
+        }
+        return true;
+    }
+  }
+
+  public static java.lang.String quotedPrintable(java.lang.String value) {
+    final StringBuilder r = new StringBuilder();
+
+    r.append("=?UTF-8?Q?");
+    for (int i = 0; i < value.length(); i++) {
+      final int cp = value.codePointAt(i);
+      if (cp == ' ') {
+        r.append('_');
+
+      } else if (needsQuotedPrintableWithinPhrase(cp)) {
+        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
+        for (byte b : buf) {
+          r.append('=');
+          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
+          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
+        }
+
+      } else {
+        r.append(Character.toChars(cp));
+      }
+    }
+    r.append("?=");
+
+    return r.toString();
+  }
+
+  public static class Date extends EmailHeader {
+    private final java.util.Date value;
+
+    public Date(java.util.Date v) {
+      value = v;
+    }
+
+    public java.util.Date getDate() {
+      return value;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return value == null;
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      final SimpleDateFormat fmt;
+      // Mon, 1 Jun 2009 10:49:44 -0700
+      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
+      w.write(fmt.format(value));
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof Date) && Objects.equals(value, ((Date) o).value);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(value).toString();
+    }
+  }
+
+  public static class AddressList extends EmailHeader {
+    private final List<Address> list = new ArrayList<>();
+
+    public AddressList() {}
+
+    public AddressList(Address addr) {
+      add(addr);
+    }
+
+    public List<Address> getAddressList() {
+      return Collections.unmodifiableList(list);
+    }
+
+    public void add(Address addr) {
+      list.add(addr);
+    }
+
+    void remove(java.lang.String email) {
+      for (Iterator<Address> i = list.iterator(); i.hasNext(); ) {
+        if (i.next().getEmail().equals(email)) {
+          i.remove();
+        }
+      }
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return list.isEmpty();
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      int len = 8;
+      boolean firstAddress = true;
+      boolean needComma = false;
+      for (final Address addr : list) {
+        java.lang.String s = addr.toHeaderString();
+        if (firstAddress) {
+          firstAddress = false;
+        } else if (72 < len + s.length()) {
+          w.write(",\r\n\t");
+          len = 8;
+          needComma = false;
+        }
+
+        if (needComma) {
+          w.write(", ");
+        }
+        w.write(s);
+        len += s.length();
+        needComma = true;
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(list);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof AddressList) && Objects.equals(list, ((AddressList) o).list);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(list).toString();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailSender.java
new file mode 100644
index 0000000..23fa1fe
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailSender.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.server.mail.Address;
+import java.util.Collection;
+import java.util.Map;
+
+/** Sends email messages to third parties. */
+public interface EmailSender {
+  boolean isEnabled();
+
+  /**
+   * Can the address receive messages from us?
+   *
+   * @param address the address to consider.
+   * @return true if this sender will deliver to the address.
+   */
+  boolean canEmail(String address);
+
+  /**
+   * Sends an email message. Messages always contain a text body, but messages can optionally
+   * include an additional HTML body. If both body types are present, {@code send} should construct
+   * a {@code multipart/alternative} message with an appropriately-selected boundary.
+   *
+   * @param from who the message is from.
+   * @param rcpt one or more address where the message will be delivered to. This list overrides any
+   *     To or CC headers in {@code headers}.
+   * @param headers message headers.
+   * @param textBody text to appear in the {@code text/plain} body of the message.
+   * @param htmlBody optional HTML code to appear in the {@code text/html} body of the message.
+   * @throws EmailException the message cannot be sent.
+   */
+  default void send(
+      Address from,
+      Collection<Address> rcpt,
+      Map<String, EmailHeader> headers,
+      String textBody,
+      @Nullable String htmlBody)
+      throws EmailException {
+    send(from, rcpt, headers, textBody);
+  }
+
+  /**
+   * Sends an email message with a text body only (i.e. not HTML or multipart).
+   *
+   * <p>Authors of new implementations of this interface should not use this method to send a
+   * message because this method does not accept the HTML body. Instead, authors should use the
+   * above signature of {@code send}.
+   *
+   * <p>This version of the method is preserved for support of legacy implementations.
+   *
+   * @param from who the message is from.
+   * @param rcpt one or more address where the message will be delivered to. This list overrides any
+   *     To or CC headers in {@code headers}.
+   * @param headers message headers.
+   * @param body text to appear in the body of the message.
+   * @throws EmailException the message cannot be sent.
+   */
+  void send(Address from, Collection<Address> rcpt, Map<String, EmailHeader> headers, String body)
+      throws EmailException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
new file mode 100644
index 0000000..2489063
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.Address;
+
+/** Constructs an address to send email from. */
+public interface FromAddressGenerator {
+  boolean isGenericAddress(Account.Id fromId);
+
+  Address from(Account.Id fromId);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
new file mode 100644
index 0000000..db52626
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -0,0 +1,240 @@
+// Copyright (C) 2009 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.mail.send;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.regex.Pattern;
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/** Creates a {@link FromAddressGenerator} from the {@link GerritServerConfig} */
+@Singleton
+public class FromAddressGeneratorProvider implements Provider<FromAddressGenerator> {
+  private final FromAddressGenerator generator;
+
+  @Inject
+  FromAddressGeneratorProvider(
+      @GerritServerConfig final Config cfg,
+      @AnonymousCowardName final String anonymousCowardName,
+      @GerritPersonIdent final PersonIdent myIdent,
+      final AccountCache accountCache) {
+
+    final String from = cfg.getString("sendemail", null, "from");
+    final Address srvAddr = toAddress(myIdent);
+
+    if (from == null || "MIXED".equalsIgnoreCase(from)) {
+      ParameterizedString name = new ParameterizedString("${user} (Code Review)");
+      generator =
+          new PatternGen(srvAddr, accountCache, anonymousCowardName, name, srvAddr.getEmail());
+    } else if ("USER".equalsIgnoreCase(from)) {
+      String[] domains = cfg.getStringList("sendemail", null, "allowedDomain");
+      Pattern domainPattern = MailUtil.glob(domains);
+      ParameterizedString namePattern = new ParameterizedString("${user} (Code Review)");
+      generator =
+          new UserGen(accountCache, domainPattern, anonymousCowardName, namePattern, srvAddr);
+    } else if ("SERVER".equalsIgnoreCase(from)) {
+      generator = new ServerGen(srvAddr);
+    } else {
+      final Address a = Address.parse(from);
+      final ParameterizedString name =
+          a.getName() != null ? new ParameterizedString(a.getName()) : null;
+      if (name == null || name.getParameterNames().isEmpty()) {
+        generator = new ServerGen(a);
+      } else {
+        generator = new PatternGen(srvAddr, accountCache, anonymousCowardName, name, a.getEmail());
+      }
+    }
+  }
+
+  private static Address toAddress(final PersonIdent myIdent) {
+    return new Address(myIdent.getName(), myIdent.getEmailAddress());
+  }
+
+  @Override
+  public FromAddressGenerator get() {
+    return generator;
+  }
+
+  static final class UserGen implements FromAddressGenerator {
+    private final AccountCache accountCache;
+    private final Pattern domainPattern;
+    private final String anonymousCowardName;
+    private final ParameterizedString nameRewriteTmpl;
+    private final Address serverAddress;
+
+    /**
+     * From address generator for USER mode
+     *
+     * @param accountCache get user account from id
+     * @param domainPattern allowed user domain pattern that Gerrit can send as the user
+     * @param anonymousCowardName name used when user's full name is missing
+     * @param nameRewriteTmpl name template used for rewriting the sender's name when Gerrit can not
+     *     send as the user
+     * @param serverAddress serverAddress.name is used when fromId is null and serverAddress.email
+     *     is used when Gerrit can not send as the user
+     */
+    UserGen(
+        AccountCache accountCache,
+        Pattern domainPattern,
+        String anonymousCowardName,
+        ParameterizedString nameRewriteTmpl,
+        Address serverAddress) {
+      this.accountCache = accountCache;
+      this.domainPattern = domainPattern;
+      this.anonymousCowardName = anonymousCowardName;
+      this.nameRewriteTmpl = nameRewriteTmpl;
+      this.serverAddress = serverAddress;
+    }
+
+    @Override
+    public boolean isGenericAddress(Account.Id fromId) {
+      return false;
+    }
+
+    @Override
+    public Address from(final Account.Id fromId) {
+      String senderName;
+      if (fromId != null) {
+        Account a = accountCache.get(fromId).getAccount();
+        String fullName = a.getFullName();
+        String userEmail = a.getPreferredEmail();
+        if (canRelay(userEmail)) {
+          return new Address(fullName, userEmail);
+        }
+
+        if (fullName == null || "".equals(fullName.trim())) {
+          fullName = anonymousCowardName;
+        }
+        senderName = nameRewriteTmpl.replace("user", fullName).toString();
+      } else {
+        senderName = serverAddress.getName();
+      }
+
+      String senderEmail;
+      ParameterizedString senderEmailPattern = new ParameterizedString(serverAddress.getEmail());
+      if (senderEmailPattern.getParameterNames().isEmpty()) {
+        senderEmail = senderEmailPattern.getRawPattern();
+      } else {
+        senderEmail = senderEmailPattern.replace("userHash", hashOf(senderName)).toString();
+      }
+      return new Address(senderName, senderEmail);
+    }
+
+    /** check if Gerrit is allowed to send from {@code userEmail}. */
+    private boolean canRelay(String userEmail) {
+      if (userEmail != null) {
+        int index = userEmail.indexOf('@');
+        if (index > 0 && index < userEmail.length() - 1) {
+          return domainPattern.matcher(userEmail.substring(index + 1)).matches();
+        }
+      }
+      return false;
+    }
+  }
+
+  static final class ServerGen implements FromAddressGenerator {
+    private final Address srvAddr;
+
+    ServerGen(Address srvAddr) {
+      this.srvAddr = srvAddr;
+    }
+
+    @Override
+    public boolean isGenericAddress(Account.Id fromId) {
+      return true;
+    }
+
+    @Override
+    public Address from(final Account.Id fromId) {
+      return srvAddr;
+    }
+  }
+
+  static final class PatternGen implements FromAddressGenerator {
+    private final ParameterizedString senderEmailPattern;
+    private final Address serverAddress;
+    private final AccountCache accountCache;
+    private final String anonymousCowardName;
+    private final ParameterizedString namePattern;
+
+    PatternGen(
+        final Address serverAddress,
+        final AccountCache accountCache,
+        final String anonymousCowardName,
+        final ParameterizedString namePattern,
+        final String senderEmail) {
+      this.senderEmailPattern = new ParameterizedString(senderEmail);
+      this.serverAddress = serverAddress;
+      this.accountCache = accountCache;
+      this.anonymousCowardName = anonymousCowardName;
+      this.namePattern = namePattern;
+    }
+
+    @Override
+    public boolean isGenericAddress(Account.Id fromId) {
+      return false;
+    }
+
+    @Override
+    public Address from(final Account.Id fromId) {
+      final String senderName;
+
+      if (fromId != null) {
+        final Account account = accountCache.get(fromId).getAccount();
+        String fullName = account.getFullName();
+        if (fullName == null || "".equals(fullName)) {
+          fullName = anonymousCowardName;
+        }
+        senderName = namePattern.replace("user", fullName).toString();
+
+      } else {
+        senderName = serverAddress.getName();
+      }
+
+      String senderEmail;
+      if (senderEmailPattern.getParameterNames().isEmpty()) {
+        senderEmail = senderEmailPattern.getRawPattern();
+      } else {
+        senderEmail = senderEmailPattern.replace("userHash", hashOf(senderName)).toString();
+      }
+      return new Address(senderName, senderEmail);
+    }
+  }
+
+  private static String hashOf(String data) {
+    try {
+      MessageDigest hash = MessageDigest.getInstance("MD5");
+      byte[] bytes = hash.digest(data.getBytes(UTF_8));
+      return Base64.encodeBase64URLSafeString(bytes);
+    } catch (NoSuchAlgorithmException e) {
+      throw new RuntimeException("No MD5 available", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
new file mode 100644
index 0000000..b267275
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
@@ -0,0 +1,113 @@
+// 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.mail.send;
+
+import com.google.common.io.CharStreams;
+import com.google.common.io.Resources;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.shared.SoyAstCache;
+import com.google.template.soy.tofu.SoyTofu;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/** Configures Soy Tofu object for rendering email templates. */
+@Singleton
+public class MailSoyTofuProvider implements Provider<SoyTofu> {
+
+  // Note: will fail to construct the tofu object if this array is empty.
+  private static final String[] TEMPLATES = {
+    "Abandoned.soy",
+    "AbandonedHtml.soy",
+    "AddKey.soy",
+    "AddKeyHtml.soy",
+    "ChangeFooter.soy",
+    "ChangeFooterHtml.soy",
+    "ChangeSubject.soy",
+    "Comment.soy",
+    "CommentHtml.soy",
+    "CommentFooter.soy",
+    "CommentFooterHtml.soy",
+    "DeleteReviewer.soy",
+    "DeleteReviewerHtml.soy",
+    "DeleteVote.soy",
+    "DeleteVoteHtml.soy",
+    "Footer.soy",
+    "FooterHtml.soy",
+    "HeaderHtml.soy",
+    "Merged.soy",
+    "MergedHtml.soy",
+    "NewChange.soy",
+    "NewChangeHtml.soy",
+    "Private.soy",
+    "RegisterNewEmail.soy",
+    "ReplacePatchSet.soy",
+    "ReplacePatchSetHtml.soy",
+    "Restored.soy",
+    "RestoredHtml.soy",
+    "Reverted.soy",
+    "RevertedHtml.soy",
+    "SetAssignee.soy",
+    "SetAssigneeHtml.soy",
+  };
+
+  private final SitePaths site;
+  private final SoyAstCache cache;
+
+  @Inject
+  MailSoyTofuProvider(SitePaths site, SoyAstCache cache) {
+    this.site = site;
+    this.cache = cache;
+  }
+
+  @Override
+  public SoyTofu get() throws ProvisionException {
+    SoyFileSet.Builder builder = SoyFileSet.builder();
+    builder.setSoyAstCache(cache);
+    for (String name : TEMPLATES) {
+      addTemplate(builder, name);
+    }
+    return builder.build().compileToTofu();
+  }
+
+  private void addTemplate(SoyFileSet.Builder builder, String name) throws ProvisionException {
+    // Load as a file in the mail templates directory if present.
+    Path tmpl = site.mail_dir.resolve(name);
+    if (Files.isRegularFile(tmpl)) {
+      String content;
+      // TODO(davido): Consider using JGit's FileSnapshot to cache based on
+      // mtime.
+      try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
+        content = CharStreams.toString(r);
+      } catch (IOException err) {
+        throw new ProvisionException(
+            "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
+      }
+      builder.add(content, tmpl.toAbsolutePath().toString());
+      return;
+    }
+
+    // Otherwise load the template as a resource.
+    String resourcePath = "com/google/gerrit/server/mail/" + name;
+    builder.add(Resources.getResource(resourcePath));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailTemplates.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailTemplates.java
new file mode 100644
index 0000000..71f5246
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MailTemplates.java
@@ -0,0 +1,24 @@
+// 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.mail.send;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface MailTemplates {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
new file mode 100644
index 0000000..47115af
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Send notice about a change successfully merged. */
+public class MergedSender extends ReplyToChangeSender {
+  public interface Factory {
+    MergedSender create(Project.NameKey project, Change.Id id);
+  }
+
+  private final LabelTypes labelTypes;
+
+  @Inject
+  public MergedSender(EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "merged", newChangeData(ea, project, id));
+    labelTypes = changeData.changeControl().getLabelTypes();
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    includeWatchers(NotifyType.ALL_COMMENTS);
+    includeWatchers(NotifyType.SUBMITTED_CHANGES);
+    removeUsersThatIgnoredTheChange();
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("Merged"));
+
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("MergedHtml"));
+    }
+  }
+
+  public String getApprovals() {
+    try {
+      Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
+      Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
+      for (PatchSetApproval ca :
+          args.approvalsUtil.byPatchSet(
+              args.db.get(), changeData.changeControl(), patchSet.getId())) {
+        LabelType lt = labelTypes.byLabel(ca.getLabelId());
+        if (lt == null) {
+          continue;
+        }
+        if (ca.getValue() > 0) {
+          pos.put(ca.getAccountId(), lt.getName(), ca);
+        } else if (ca.getValue() < 0) {
+          neg.put(ca.getAccountId(), lt.getName(), ca);
+        }
+      }
+
+      return format("Approvals", pos) + format("Objections", neg);
+    } catch (OrmException err) {
+      // Don't list the approvals
+    }
+    return "";
+  }
+
+  private String format(String type, Table<Account.Id, String, PatchSetApproval> approvals) {
+    StringBuilder txt = new StringBuilder();
+    if (approvals.isEmpty()) {
+      return "";
+    }
+    txt.append(type).append(":\n");
+    for (Account.Id id : approvals.rowKeySet()) {
+      txt.append("  ");
+      txt.append(getNameFor(id));
+      txt.append(": ");
+      boolean first = true;
+      for (LabelType lt : labelTypes.getLabelTypes()) {
+        PatchSetApproval ca = approvals.get(id, lt.getName());
+        if (ca == null) {
+          continue;
+        }
+
+        if (first) {
+          first = false;
+        } else {
+          txt.append("; ");
+        }
+
+        LabelValue v = lt.getValue(ca);
+        if (v != null) {
+          txt.append(v.getText());
+        } else {
+          txt.append(lt.getName());
+          txt.append('=');
+          txt.append(LabelValue.formatValue(ca.getValue()));
+        }
+      }
+      txt.append('\n');
+    }
+    txt.append('\n');
+    return txt.toString();
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("approvals", getApprovals());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
new file mode 100644
index 0000000..45fdeb7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Sends an email alerting a user to a new change for them to review. */
+public abstract class NewChangeSender extends ChangeEmail {
+  private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Account.Id> extraCC = new HashSet<>();
+
+  protected NewChangeSender(EmailArguments ea, ChangeData cd) throws OrmException {
+    super(ea, "newchange", cd);
+  }
+
+  public void addReviewers(final Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  public void addExtraCC(final Collection<Account.Id> cc) {
+    extraCC.addAll(cc);
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    setHeader("Message-ID", getChangeMessageThreadId());
+
+    switch (notify) {
+      case NONE:
+      case OWNER:
+        break;
+      case ALL:
+      default:
+        add(RecipientType.CC, extraCC);
+        // $FALL-THROUGH$
+      case OWNER_REVIEWERS:
+        add(RecipientType.TO, reviewers);
+        break;
+    }
+
+    rcptToAuthors(RecipientType.CC);
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("NewChange"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("NewChangeHtml"));
+    }
+  }
+
+  public List<String> getReviewerNames() {
+    if (reviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(getNameFor(id));
+    }
+    return names;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContext.put("ownerName", getNameFor(change.getOwner()));
+    soyContextEmailData.put("reviewerNames", getReviewerNames());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
new file mode 100644
index 0000000..bceac72
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2012 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.mail.send;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gwtorm.server.OrmException;
+import java.util.HashMap;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Common class for notifications that are related to a project and branch */
+public abstract class NotificationEmail extends OutgoingEmail {
+  private static final Logger log = LoggerFactory.getLogger(NotificationEmail.class);
+
+  protected Branch.NameKey branch;
+
+  protected NotificationEmail(EmailArguments ea, String mc, Branch.NameKey branch) {
+    super(ea, mc);
+    this.branch = branch;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+    setListIdHeader();
+  }
+
+  private void setListIdHeader() throws EmailException {
+    // Set a reasonable list id so that filters can be used to sort messages
+    setVHeader("List-Id", "<$email.listId.replace('@', '.')>");
+    if (getSettingsUrl() != null) {
+      setVHeader("List-Unsubscribe", "<$email.settingsUrl>");
+    }
+  }
+
+  public String getListId() throws EmailException {
+    return velocify("gerrit-$projectName.replace('/', '-')@$email.gerritHost");
+  }
+
+  /** Include users and groups that want notification of events. */
+  protected void includeWatchers(NotifyType type) {
+    includeWatchers(type, true);
+  }
+
+  /** Include users and groups that want notification of events. */
+  protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+    try {
+      Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
+      add(RecipientType.TO, matching.to);
+      add(RecipientType.CC, matching.cc);
+      add(RecipientType.BCC, matching.bcc);
+    } catch (OrmException err) {
+      // Just don't CC everyone. Better to send a partial message to those
+      // we already have queued up then to fail deliver entirely to people
+      // who have a lower interest in the change.
+      log.warn("Cannot BCC watchers for " + type, err);
+    }
+  }
+
+  /** Returns all watchers that are relevant */
+  protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
+      throws OrmException;
+
+  /** Add users or email addresses to the TO, CC, or BCC list. */
+  protected void add(RecipientType type, Watchers.List list) {
+    for (Account.Id user : list.accounts) {
+      add(type, user);
+    }
+    for (Address addr : list.emails) {
+      add(type, addr);
+    }
+  }
+
+  public String getSshHost() {
+    String host = Iterables.getFirst(args.sshAddresses, null);
+    if (host == null) {
+      return null;
+    }
+    if (host.startsWith("*:")) {
+      return getGerritHost() + host.substring(1);
+    }
+    return host;
+  }
+
+  @Override
+  protected void setupVelocityContext() {
+    super.setupVelocityContext();
+    velocityContext.put("projectName", branch.getParentKey().get());
+    velocityContext.put("branch", branch);
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+
+    String projectName = branch.getParentKey().get();
+    soyContext.put("projectName", projectName);
+    // shortProjectName is the project name with the path abbreviated.
+    soyContext.put("shortProjectName", projectName.replaceAll("/.*/", "..."));
+
+    soyContextEmailData.put("sshHost", getSshHost());
+
+    Map<String, String> branchData = new HashMap<>();
+    branchData.put("shortName", branch.getShortName());
+    soyContext.put("branch", branchData);
+
+    footers.add("Gerrit-Project: " + branch.getParentKey().get());
+    footers.add("Gerrit-Branch: " + branch.getShortName());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
new file mode 100644
index 0000000..465f131
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -0,0 +1,603 @@
+// 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.mail.send;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.UserIdentity;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
+import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.gwtorm.server.OrmException;
+import com.google.template.soy.data.SanitizedContent;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringJoiner;
+import org.apache.commons.lang.StringUtils;
+import org.apache.velocity.Template;
+import org.apache.velocity.VelocityContext;
+import org.apache.velocity.context.InternalContextAdapterImpl;
+import org.apache.velocity.runtime.RuntimeInstance;
+import org.apache.velocity.runtime.parser.node.SimpleNode;
+import org.eclipse.jgit.util.SystemReader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Sends an email to one or more interested parties. */
+public abstract class OutgoingEmail {
+  private static final Logger log = LoggerFactory.getLogger(OutgoingEmail.class);
+
+  private static final String HDR_TO = "To";
+  private static final String HDR_CC = "CC";
+
+  protected String messageClass;
+  private final HashSet<Account.Id> rcptTo = new HashSet<>();
+  private final Map<String, EmailHeader> headers;
+  private final Set<Address> smtpRcptTo = new HashSet<>();
+  private Address smtpFromAddress;
+  private StringBuilder textBody;
+  private StringBuilder htmlBody;
+  private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
+  protected VelocityContext velocityContext;
+  protected Map<String, Object> soyContext;
+  protected Map<String, Object> soyContextEmailData;
+  protected List<String> footers;
+  protected final EmailArguments args;
+  protected Account.Id fromId;
+  protected NotifyHandling notify = NotifyHandling.ALL;
+
+  protected OutgoingEmail(EmailArguments ea, String mc) {
+    args = ea;
+    messageClass = mc;
+    headers = new LinkedHashMap<>();
+  }
+
+  public void setFrom(final Account.Id id) {
+    fromId = id;
+  }
+
+  public void setNotify(NotifyHandling notify) {
+    this.notify = checkNotNull(notify);
+  }
+
+  public void setAccountsToNotify(ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.accountsToNotify = checkNotNull(accountsToNotify);
+  }
+
+  /**
+   * Format and enqueue the message for delivery.
+   *
+   * @throws EmailException
+   */
+  public void send() throws EmailException {
+    if (NotifyHandling.NONE.equals(notify) && accountsToNotify.isEmpty()) {
+      return;
+    }
+
+    if (!args.emailSender.isEnabled()) {
+      // Server has explicitly disabled email sending.
+      //
+      return;
+    }
+
+    init();
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("HeaderHtml"));
+    }
+    format();
+    appendText(textTemplate("Footer"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("FooterHtml"));
+    }
+
+    Set<Address> smtpRcptToPlaintextOnly = new HashSet<>();
+    if (shouldSendMessage()) {
+      if (fromId != null) {
+        final Account fromUser = args.accountCache.get(fromId).getAccount();
+        GeneralPreferencesInfo senderPrefs = fromUser.getGeneralPreferencesInfo();
+
+        if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
+          // If we are impersonating a user, make sure they receive a CC of
+          // this message so they can always review and audit what we sent
+          // on their behalf to others.
+          //
+          add(RecipientType.CC, fromId);
+        } else if (!accountsToNotify.containsValue(fromId) && rcptTo.remove(fromId)) {
+          // If they don't want a copy, but we queued one up anyway,
+          // drop them from the recipient lists.
+          //
+          removeUser(fromUser);
+        }
+      }
+      // Check the preferences of all recipients. If any user has disabled
+      // his email notifications then drop him from recipients' list.
+      // In addition, check if users only want to receive plaintext email.
+      for (Account.Id id : rcptTo) {
+        Account thisUser = args.accountCache.get(id).getAccount();
+        GeneralPreferencesInfo prefs = thisUser.getGeneralPreferencesInfo();
+        if (prefs == null || prefs.getEmailStrategy() == DISABLED) {
+          removeUser(thisUser);
+        } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) {
+          removeUser(thisUser);
+          smtpRcptToPlaintextOnly.add(
+              new Address(thisUser.getFullName(), thisUser.getPreferredEmail()));
+        }
+        if (smtpRcptTo.isEmpty() && smtpRcptToPlaintextOnly.isEmpty()) {
+          return;
+        }
+      }
+
+      // Set Reply-To only if it hasn't been set by a child class
+      // Reply-To will already be populated for the message types where Gerrit supports
+      // inbound email replies.
+      if (!headers.containsKey("Reply-To")) {
+        StringJoiner j = new StringJoiner(", ");
+        if (fromId != null) {
+          Address address = toAddress(fromId);
+          if (address != null) {
+            j.add(address.getEmail());
+          }
+        }
+        smtpRcptTo.stream().forEach(a -> j.add(a.getEmail()));
+        smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.getEmail()));
+        setHeader("Reply-To", j.toString());
+      }
+
+      String textPart = textBody.toString();
+      OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
+      va.messageClass = messageClass;
+      va.smtpFromAddress = smtpFromAddress;
+      va.smtpRcptTo = smtpRcptTo;
+      va.headers = headers;
+      va.body = textPart;
+
+      if (useHtml()) {
+        va.htmlBody = htmlBody.toString();
+      } else {
+        va.htmlBody = null;
+      }
+
+      for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
+        try {
+          validator.validateOutgoingEmail(va);
+        } catch (ValidationException e) {
+          return;
+        }
+      }
+
+      if (!smtpRcptTo.isEmpty()) {
+        // Send multipart message
+        args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
+      }
+
+      if (!smtpRcptToPlaintextOnly.isEmpty()) {
+        // Send plaintext message
+        Map<String, EmailHeader> shallowCopy = new HashMap<>();
+        shallowCopy.putAll(headers);
+        // Remove To and Cc
+        shallowCopy.remove(HDR_TO);
+        shallowCopy.remove(HDR_CC);
+        for (Address a : smtpRcptToPlaintextOnly) {
+          // Add new To
+          EmailHeader.AddressList to = new EmailHeader.AddressList();
+          to.add(a);
+          shallowCopy.put(HDR_TO, to);
+        }
+        args.emailSender.send(va.smtpFromAddress, smtpRcptToPlaintextOnly, shallowCopy, va.body);
+      }
+    }
+  }
+
+  /** Format the message body by calling {@link #appendText(String)}. */
+  protected abstract void format() throws EmailException;
+
+  /**
+   * Setup the message headers and envelope (TO, CC, BCC).
+   *
+   * @throws EmailException if an error occurred.
+   */
+  protected void init() throws EmailException {
+    setupVelocityContext();
+    setupSoyContext();
+
+    smtpFromAddress = args.fromAddressGenerator.from(fromId);
+    setHeader("Date", new Date());
+    headers.put("From", new EmailHeader.AddressList(smtpFromAddress));
+    headers.put(HDR_TO, new EmailHeader.AddressList());
+    headers.put(HDR_CC, new EmailHeader.AddressList());
+    setHeader("Message-ID", "");
+
+    for (RecipientType recipientType : accountsToNotify.keySet()) {
+      add(recipientType, accountsToNotify.get(recipientType));
+    }
+
+    setHeader("X-Gerrit-MessageType", messageClass);
+    textBody = new StringBuilder();
+    htmlBody = new StringBuilder();
+
+    if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) {
+      appendText(getFromLine());
+    }
+  }
+
+  protected String getFromLine() {
+    final Account account = args.accountCache.get(fromId).getAccount();
+    final String name = account.getFullName();
+    final String email = account.getPreferredEmail();
+    StringBuilder f = new StringBuilder();
+
+    if ((name != null && !name.isEmpty()) || (email != null && !email.isEmpty())) {
+      f.append("From");
+      if (name != null && !name.isEmpty()) {
+        f.append(" ").append(name);
+      }
+      if (email != null && !email.isEmpty()) {
+        f.append(" <").append(email).append(">");
+      }
+      f.append(":\n\n");
+    }
+    return f.toString();
+  }
+
+  public String getGerritHost() {
+    if (getGerritUrl() != null) {
+      try {
+        return new URL(getGerritUrl()).getHost();
+      } catch (MalformedURLException e) {
+        // Try something else.
+      }
+    }
+
+    // Fall back onto whatever the local operating system thinks
+    // this server is called. We hopefully didn't get here as a
+    // good admin would have configured the canonical url.
+    //
+    return SystemReader.getInstance().getHostname();
+  }
+
+  public String getSettingsUrl() {
+    if (getGerritUrl() != null) {
+      final StringBuilder r = new StringBuilder();
+      r.append(getGerritUrl());
+      r.append("settings");
+      return r.toString();
+    }
+    return null;
+  }
+
+  public String getGerritUrl() {
+    return args.urlProvider.get();
+  }
+
+  /** Set a header in the outgoing message using a template. */
+  protected void setVHeader(final String name, final String value) throws EmailException {
+    setHeader(name, velocify(value));
+  }
+
+  /** Set a header in the outgoing message. */
+  protected void setHeader(final String name, final String value) {
+    headers.put(name, new EmailHeader.String(value));
+  }
+
+  /** Remove a header from the outgoing message. */
+  protected void removeHeader(final String name) {
+    headers.remove(name);
+  }
+
+  protected void setHeader(final String name, final Date date) {
+    headers.put(name, new EmailHeader.Date(date));
+  }
+
+  /** Append text to the outgoing email body. */
+  protected void appendText(final String text) {
+    if (text != null) {
+      textBody.append(text);
+    }
+  }
+
+  /** Append html to the outgoing email body. */
+  protected void appendHtml(String html) {
+    if (html != null) {
+      htmlBody.append(html);
+    }
+  }
+
+  /**
+   * Gets the human readable name for an account, usually the "full name".
+   *
+   * @param accountId user to fetch.
+   * @return name of the account, or the server identity name if null.
+   */
+  protected String getNameFor(@Nullable Account.Id accountId) {
+    if (accountId == null) {
+      return args.gerritPersonIdent.getName();
+    }
+
+    return args.accountCache.get(accountId).getAccount().getName(args.anonymousCowardName);
+  }
+
+  /**
+   * Gets the human readable name and email for an account.
+   *
+   * @param accountId user to fetch.
+   * @return name/email of account; Anonymous Coward if unset or the server identity if null.
+   */
+  protected String getNameEmailFor(@Nullable Account.Id accountId) {
+    if (accountId == null) {
+      return String.format(
+          "%s <%s>", args.gerritPersonIdent.getName(), args.gerritPersonIdent.getEmailAddress());
+    }
+
+    return args.accountCache.get(accountId).getAccount().getNameEmail(args.anonymousCowardName);
+  }
+
+  /**
+   * Gets the human readable name and email for an account; if both are unavailable, returns the
+   * username. If no username is set, this function returns null.
+   *
+   * @param accountId user to fetch.
+   * @return name/email of account, username, or null if unset.
+   */
+  @Nullable
+  protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
+    if (accountId == null) {
+      return null;
+    }
+    AccountState who = args.accountCache.get(accountId);
+    String name = who.getAccount().getFullName();
+    String email = who.getAccount().getPreferredEmail();
+
+    if (name != null && email != null) {
+      return name + " <" + email + ">";
+    } else if (email != null) {
+      return email;
+    } else if (name != null) {
+      return name;
+    }
+    String username = who.getUserName();
+    if (username != null) {
+      return username;
+    }
+    return null;
+  }
+
+  protected boolean shouldSendMessage() {
+    if (textBody.length() == 0) {
+      // If we have no message body, don't send.
+      return false;
+    }
+
+    if (smtpRcptTo.isEmpty()) {
+      // If we have nobody to send this message to, then all of our
+      // selection filters previously for this type of message were
+      // unable to match a destination. Don't bother sending it.
+      return false;
+    }
+
+    if ((accountsToNotify == null || accountsToNotify.isEmpty())
+        && smtpRcptTo.size() == 1
+        && rcptTo.size() == 1
+        && rcptTo.contains(fromId)) {
+      // If the only recipient is also the sender, don't bother.
+      //
+      return false;
+    }
+
+    return true;
+  }
+
+  /** Schedule this message for delivery to the listed accounts. */
+  protected void add(final RecipientType rt, final Collection<Account.Id> list) {
+    for (final Account.Id id : list) {
+      add(rt, id);
+    }
+  }
+
+  protected void add(final RecipientType rt, final UserIdentity who) {
+    if (who != null && who.getAccount() != null) {
+      add(rt, who.getAccount());
+    }
+  }
+
+  /** Schedule delivery of this message to the given account. */
+  protected void add(final RecipientType rt, final Account.Id to) {
+    try {
+      if (!rcptTo.contains(to) && isVisibleTo(to)) {
+        rcptTo.add(to);
+        add(rt, toAddress(to));
+      }
+    } catch (OrmException e) {
+      log.error("Error reading database for account: " + to, e);
+    }
+  }
+
+  /**
+   * @param to account.
+   * @throws OrmException
+   * @return whether this email is visible to the given account.
+   */
+  protected boolean isVisibleTo(final Account.Id to) throws OrmException {
+    return true;
+  }
+
+  /** Schedule delivery of this message to the given account. */
+  protected void add(final RecipientType rt, final Address addr) {
+    if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) {
+      if (!OutgoingEmailValidator.isValid(addr.getEmail())) {
+        log.warn("Not emailing " + addr.getEmail() + " (invalid email address)");
+      } else if (!args.emailSender.canEmail(addr.getEmail())) {
+        log.warn("Not emailing " + addr.getEmail() + " (prohibited by allowrcpt)");
+      } else if (smtpRcptTo.add(addr)) {
+        switch (rt) {
+          case TO:
+            ((EmailHeader.AddressList) headers.get(HDR_TO)).add(addr);
+            break;
+          case CC:
+            ((EmailHeader.AddressList) headers.get(HDR_CC)).add(addr);
+            break;
+          case BCC:
+            break;
+        }
+      }
+    }
+  }
+
+  private Address toAddress(final Account.Id id) {
+    final Account a = args.accountCache.get(id).getAccount();
+    final String e = a.getPreferredEmail();
+    if (!a.isActive() || e == null) {
+      return null;
+    }
+    return new Address(a.getFullName(), e);
+  }
+
+  protected void setupVelocityContext() {
+    velocityContext = new VelocityContext();
+
+    velocityContext.put("email", this);
+    velocityContext.put("messageClass", messageClass);
+    velocityContext.put("StringUtils", StringUtils.class);
+  }
+
+  protected void setupSoyContext() {
+    soyContext = new HashMap<>();
+    footers = new ArrayList<>();
+
+    soyContext.put("messageClass", messageClass);
+    soyContext.put("footers", footers);
+
+    soyContextEmailData = new HashMap<>();
+    soyContextEmailData.put("settingsUrl", getSettingsUrl());
+    soyContextEmailData.put("gerritHost", getGerritHost());
+    soyContextEmailData.put("gerritUrl", getGerritUrl());
+    soyContext.put("email", soyContextEmailData);
+  }
+
+  protected String velocify(String template) throws EmailException {
+    try {
+      RuntimeInstance runtime = args.velocityRuntime;
+      String templateName = "OutgoingEmail";
+      SimpleNode tree = runtime.parse(new StringReader(template), templateName);
+      InternalContextAdapterImpl ica = new InternalContextAdapterImpl(velocityContext);
+      ica.pushCurrentTemplateName(templateName);
+      try {
+        tree.init(ica, runtime);
+        StringWriter w = new StringWriter();
+        tree.render(ica, w);
+        return w.toString();
+      } finally {
+        ica.popCurrentTemplateName();
+      }
+    } catch (Exception e) {
+      throw new EmailException("Cannot format velocity template: " + template, e);
+    }
+  }
+
+  protected String velocifyFile(String name) throws EmailException {
+    try {
+      RuntimeInstance runtime = args.velocityRuntime;
+      if (runtime.getLoaderNameForResource(name) == null) {
+        name = "com/google/gerrit/server/mail/" + name;
+      }
+      Template template = runtime.getTemplate(name, UTF_8.name());
+      StringWriter w = new StringWriter();
+      template.merge(velocityContext, w);
+      return w.toString();
+    } catch (Exception e) {
+      throw new EmailException("Cannot format velocity template " + name, e);
+    }
+  }
+
+  private String soyTemplate(String name, SanitizedContent.ContentKind kind) {
+    return args.soyTofu
+        .newRenderer("com.google.gerrit.server.mail.template." + name)
+        .setContentKind(kind)
+        .setData(soyContext)
+        .render();
+  }
+
+  protected String soyTextTemplate(String name) {
+    return soyTemplate(name, SanitizedContent.ContentKind.TEXT);
+  }
+
+  protected String soyHtmlTemplate(String name) {
+    return soyTemplate(name, SanitizedContent.ContentKind.HTML);
+  }
+
+  /**
+   * Evaluate the named template according to the following priority: 1) Velocity file override,
+   * OR... 2) Soy file override, OR... 3) Soy resource.
+   */
+  protected String textTemplate(String name) throws EmailException {
+    String velocityName = name + ".vm";
+    Path filePath = args.site.mail_dir.resolve(velocityName);
+    if (Files.isRegularFile(filePath)) {
+      return velocifyFile(velocityName);
+    }
+    return soyTextTemplate(name);
+  }
+
+  protected void removeUser(Account user) {
+    String fromEmail = user.getPreferredEmail();
+    for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
+      if (j.next().getEmail().equals(fromEmail)) {
+        j.remove();
+      }
+    }
+    for (Map.Entry<String, EmailHeader> entry : headers.entrySet()) {
+      // Don't remove fromEmail from the "From" header though!
+      if (entry.getValue() instanceof AddressList && !entry.getKey().equals("From")) {
+        ((AddressList) entry.getValue()).remove(fromEmail);
+      }
+    }
+  }
+
+  protected final boolean useHtml() {
+    return args.settings.html && supportsHtml();
+  }
+
+  /** Override this method to enable HTML in a subclass. */
+  protected boolean supportsHtml() {
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
new file mode 100644
index 0000000..2d9db1d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
@@ -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.
+
+package com.google.gerrit.server.mail.send;
+
+import static org.apache.commons.validator.routines.DomainValidator.ArrayType.GENERIC_PLUS;
+
+import org.apache.commons.validator.routines.DomainValidator;
+import org.apache.commons.validator.routines.EmailValidator;
+
+public class OutgoingEmailValidator {
+  static {
+    DomainValidator.updateTLDOverride(GENERIC_PLUS, new String[] {"local"});
+  }
+
+  public static boolean isValid(String addr) {
+    return EmailValidator.getInstance(true, true).isValid(addr);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
new file mode 100644
index 0000000..b459d25
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -0,0 +1,238 @@
+// Copyright (C) 2013 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.mail.send;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupDescriptions;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.git.NotifyConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gwtorm.server.OrmException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ProjectWatch {
+  private static final Logger log = LoggerFactory.getLogger(ProjectWatch.class);
+
+  protected final EmailArguments args;
+  protected final ProjectState projectState;
+  protected final Project.NameKey project;
+  protected final ChangeData changeData;
+
+  public ProjectWatch(
+      EmailArguments args,
+      Project.NameKey project,
+      ProjectState projectState,
+      ChangeData changeData) {
+    this.args = args;
+    this.project = project;
+    this.projectState = projectState;
+    this.changeData = changeData;
+  }
+
+  /** Returns all watchers that are relevant */
+  public final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig)
+      throws OrmException {
+    Watchers matching = new Watchers();
+    Set<Account.Id> projectWatchers = new HashSet<>();
+
+    for (AccountState a : args.accountQueryProvider.get().byWatchedProject(project)) {
+      Account.Id accountId = a.getAccount().getId();
+      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : a.getProjectWatches().entrySet()) {
+        if (project.equals(e.getKey().project())
+            && add(matching, accountId, e.getKey(), e.getValue(), type)) {
+          // We only want to prevent matching All-Projects if this filter hits
+          projectWatchers.add(accountId);
+        }
+      }
+    }
+
+    for (AccountState a : args.accountQueryProvider.get().byWatchedProject(args.allProjectsName)) {
+      for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : a.getProjectWatches().entrySet()) {
+        if (args.allProjectsName.equals(e.getKey().project())) {
+          Account.Id accountId = a.getAccount().getId();
+          if (!projectWatchers.contains(accountId)) {
+            add(matching, accountId, e.getKey(), e.getValue(), type);
+          }
+        }
+      }
+    }
+
+    if (!includeWatchersFromNotifyConfig) {
+      return matching;
+    }
+
+    for (ProjectState state : projectState.tree()) {
+      for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
+        if (nc.isNotify(type)) {
+          try {
+            add(matching, nc);
+          } catch (QueryParseException e) {
+            log.warn(
+                "Project {} has invalid notify {} filter \"{}\": {}",
+                state.getProject().getName(),
+                nc.getName(),
+                nc.getFilter(),
+                e.getMessage());
+          }
+        }
+      }
+    }
+
+    return matching;
+  }
+
+  public static class Watchers {
+    static class List {
+      protected final Set<Account.Id> accounts = new HashSet<>();
+      protected final Set<Address> emails = new HashSet<>();
+    }
+
+    protected final List to = new List();
+    protected final List cc = new List();
+    protected final List bcc = new List();
+
+    List list(NotifyConfig.Header header) {
+      switch (header) {
+        case TO:
+          return to;
+        case CC:
+          return cc;
+        default:
+        case BCC:
+          return bcc;
+      }
+    }
+  }
+
+  private void add(Watchers matching, NotifyConfig nc) throws OrmException, QueryParseException {
+    for (GroupReference ref : nc.getGroups()) {
+      CurrentUser user = new SingleGroupUser(args.capabilityControlFactory, ref.getUUID());
+      if (filterMatch(user, nc.getFilter())) {
+        deliverToMembers(matching.list(nc.getHeader()), ref.getUUID());
+      }
+    }
+
+    if (!nc.getAddresses().isEmpty()) {
+      if (filterMatch(null, nc.getFilter())) {
+        matching.list(nc.getHeader()).emails.addAll(nc.getAddresses());
+      }
+    }
+  }
+
+  private void deliverToMembers(Watchers.List matching, AccountGroup.UUID startUUID)
+      throws OrmException {
+    ReviewDb db = args.db.get();
+    Set<AccountGroup.UUID> seen = new HashSet<>();
+    List<AccountGroup.UUID> q = new ArrayList<>();
+
+    seen.add(startUUID);
+    q.add(startUUID);
+
+    while (!q.isEmpty()) {
+      AccountGroup.UUID uuid = q.remove(q.size() - 1);
+      GroupDescription.Basic group = args.groupBackend.get(uuid);
+      if (!Strings.isNullOrEmpty(group.getEmailAddress())) {
+        // If the group has an email address, do not expand membership.
+        matching.emails.add(new Address(group.getEmailAddress()));
+        continue;
+      }
+
+      AccountGroup ig = GroupDescriptions.toAccountGroup(group);
+      if (ig == null) {
+        // Non-internal groups cannot be expanded by the server.
+        continue;
+      }
+
+      for (AccountGroupMember m : db.accountGroupMembers().byGroup(ig.getId())) {
+        matching.accounts.add(m.getAccountId());
+      }
+      for (AccountGroup.UUID m : args.groupIncludes.subgroupsOf(uuid)) {
+        if (seen.add(m)) {
+          q.add(m);
+        }
+      }
+    }
+  }
+
+  private boolean add(
+      Watchers matching,
+      Account.Id accountId,
+      ProjectWatchKey key,
+      Set<NotifyType> watchedTypes,
+      NotifyType type)
+      throws OrmException {
+    IdentifiedUser user = args.identifiedUserFactory.create(accountId);
+
+    try {
+      if (filterMatch(user, key.filter())) {
+        // If we are set to notify on this type, add the user.
+        // Otherwise, still return true to stop notifications for this user.
+        if (watchedTypes.contains(type)) {
+          matching.bcc.accounts.add(accountId);
+        }
+        return true;
+      }
+    } catch (QueryParseException e) {
+      // Ignore broken filter expressions.
+    }
+    return false;
+  }
+
+  private boolean filterMatch(CurrentUser user, String filter)
+      throws OrmException, QueryParseException {
+    ChangeQueryBuilder qb;
+    Predicate<ChangeData> p = null;
+
+    if (user == null) {
+      qb = args.queryBuilder.asUser(args.anonymousUser);
+    } else {
+      qb = args.queryBuilder.asUser(user);
+      p = qb.is_visible();
+    }
+
+    if (filter != null) {
+      Predicate<ChangeData> filterPredicate = qb.parse(filter);
+      if (p == null) {
+        p = filterPredicate;
+      } else {
+        p = Predicate.and(filterPredicate, p);
+      }
+    }
+    return p == null || p.asMatchable().match(changeData);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
new file mode 100644
index 0000000..c667026
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2009 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.mail.send;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class RegisterNewEmailSender extends OutgoingEmail {
+  public interface Factory {
+    RegisterNewEmailSender create(String address);
+  }
+
+  private final EmailTokenVerifier tokenVerifier;
+  private final IdentifiedUser user;
+  private final String addr;
+  private String emailToken;
+
+  @Inject
+  public RegisterNewEmailSender(
+      EmailArguments ea,
+      EmailTokenVerifier etv,
+      IdentifiedUser callingUser,
+      @Assisted final String address) {
+    super(ea, "registernewemail");
+    tokenVerifier = etv;
+    user = callingUser;
+    addr = address;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+    setHeader("Subject", "[Gerrit Code Review] Email Verification");
+    add(RecipientType.TO, new Address(addr));
+  }
+
+  @Override
+  protected void format() throws EmailException {
+    appendText(textTemplate("RegisterNewEmail"));
+  }
+
+  public String getUserNameEmail() {
+    return getUserNameEmailFor(user.getAccountId());
+  }
+
+  public String getEmailRegistrationToken() {
+    if (emailToken == null) {
+      emailToken = checkNotNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
+    }
+    return emailToken;
+  }
+
+  public boolean isAllowed() {
+    return args.emailSender.canEmail(addr);
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("emailRegistrationToken", getEmailRegistrationToken());
+    soyContextEmailData.put("userNameEmail", getUserNameEmail());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
new file mode 100644
index 0000000..c90000f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+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.server.account.WatchConfig.NotifyType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Send notice of new patch sets for reviewers. */
+public class ReplacePatchSetSender extends ReplyToChangeSender {
+  public interface Factory {
+    ReplacePatchSetSender create(Project.NameKey project, Change.Id id);
+  }
+
+  private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Account.Id> extraCC = new HashSet<>();
+
+  @Inject
+  public ReplacePatchSetSender(
+      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "newpatchset", newChangeData(ea, project, id));
+  }
+
+  public void addReviewers(final Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  public void addExtraCC(final Collection<Account.Id> cc) {
+    extraCC.addAll(cc);
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    if (fromId != null) {
+      // Don't call yourself a reviewer of your own patch set.
+      //
+      reviewers.remove(fromId);
+    }
+    add(RecipientType.TO, reviewers);
+    add(RecipientType.CC, extraCC);
+    rcptToAuthors(RecipientType.CC);
+    bccStarredBy();
+    includeWatchers(NotifyType.NEW_PATCHSETS, !patchSet.isDraft());
+    removeUsersThatIgnoredTheChange();
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("ReplacePatchSet"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("ReplacePatchSetHtml"));
+    }
+  }
+
+  public List<String> getReviewerNames() {
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      if (id.equals(fromId)) {
+        continue;
+      }
+      names.add(getNameFor(id));
+    }
+    if (names.isEmpty()) {
+      return null;
+    }
+    return names;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("reviewerNames", getReviewerNames());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
new file mode 100644
index 0000000..61e9d1d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2009 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+
+/** Alert a user to a reply to a change, usually commentary made during review. */
+public abstract class ReplyToChangeSender extends ChangeEmail {
+  public interface Factory<T extends ReplyToChangeSender> {
+    T create(Project.NameKey project, Change.Id id);
+  }
+
+  protected ReplyToChangeSender(EmailArguments ea, String mc, ChangeData cd) throws OrmException {
+    super(ea, mc, cd);
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    final String threadId = getChangeMessageThreadId();
+    setHeader("In-Reply-To", threadId);
+    setHeader("References", threadId);
+
+    rcptToAuthors(RecipientType.TO);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RestoredSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RestoredSender.java
new file mode 100644
index 0000000..6076b46
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -0,0 +1,61 @@
+// 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Send notice about a change being restored by its owner. */
+public class RestoredSender extends ReplyToChangeSender {
+  public interface Factory extends ReplyToChangeSender.Factory<RestoredSender> {
+    @Override
+    RestoredSender create(Project.NameKey project, Change.Id id);
+  }
+
+  @Inject
+  public RestoredSender(
+      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "restore", ChangeEmail.newChangeData(ea, project, id));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    includeWatchers(NotifyType.ALL_COMMENTS);
+    removeUsersThatIgnoredTheChange();
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("Restored"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("RestoredHtml"));
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RevertedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RevertedSender.java
new file mode 100644
index 0000000..c4c0a69
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -0,0 +1,60 @@
+// 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Send notice about a change being reverted. */
+public class RevertedSender extends ReplyToChangeSender {
+  public interface Factory {
+    RevertedSender create(Project.NameKey project, Change.Id id);
+  }
+
+  @Inject
+  public RevertedSender(
+      EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
+      throws OrmException {
+    super(ea, "revert", ChangeEmail.newChangeData(ea, project, id));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    includeWatchers(NotifyType.ALL_COMMENTS);
+    removeUsersThatIgnoredTheChange();
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("Reverted"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("RevertedHtml"));
+    }
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
new file mode 100644
index 0000000..9708b1b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
@@ -0,0 +1,73 @@
+// 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.mail.send;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SetAssigneeSender extends ChangeEmail {
+  public interface Factory {
+    SetAssigneeSender create(Project.NameKey project, Change.Id id, Account.Id assignee);
+  }
+
+  private final Account.Id assignee;
+
+  @Inject
+  public SetAssigneeSender(
+      EmailArguments ea,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id id,
+      @Assisted Account.Id assignee)
+      throws OrmException {
+    super(ea, "setassignee", newChangeData(ea, project, id));
+    this.assignee = assignee;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    add(RecipientType.TO, assignee);
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("SetAssignee"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("SetAssigneeHtml"));
+    }
+  }
+
+  public String getAssigneeName() {
+    return getNameFor(assignee);
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("assigneeName", getAssigneeName());
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
new file mode 100644
index 0000000..583cfe6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -0,0 +1,408 @@
+// Copyright (C) 2009 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.mail.send;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.io.BaseEncoding;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.Encryption;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.Writer;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.net.smtp.AuthSMTPClient;
+import org.apache.commons.net.smtp.SMTPClient;
+import org.apache.commons.net.smtp.SMTPReply;
+import org.apache.james.mime4j.codec.QuotedPrintableOutputStream;
+import org.eclipse.jgit.lib.Config;
+
+/** Sends email via a nearby SMTP server. */
+@Singleton
+public class SmtpEmailSender implements EmailSender {
+  /** The socket's connect timeout (0 = infinite timeout) */
+  private static final int DEFAULT_CONNECT_TIMEOUT = 0;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(EmailSender.class).to(SmtpEmailSender.class);
+    }
+  }
+
+  private final boolean enabled;
+  private final int connectTimeout;
+
+  private String smtpHost;
+  private int smtpPort;
+  private String smtpUser;
+  private String smtpPass;
+  private Encryption smtpEncryption;
+  private boolean sslVerify;
+  private Set<String> allowrcpt;
+  private String importance;
+  private int expiryDays;
+
+  @Inject
+  SmtpEmailSender(@GerritServerConfig final Config cfg) {
+    enabled = cfg.getBoolean("sendemail", null, "enable", true);
+    connectTimeout =
+        Ints.checkedCast(
+            ConfigUtil.getTimeUnit(
+                cfg,
+                "sendemail",
+                null,
+                "connectTimeout",
+                DEFAULT_CONNECT_TIMEOUT,
+                TimeUnit.MILLISECONDS));
+
+    smtpHost = cfg.getString("sendemail", null, "smtpserver");
+    if (smtpHost == null) {
+      smtpHost = "127.0.0.1";
+    }
+
+    smtpEncryption = cfg.getEnum("sendemail", null, "smtpencryption", Encryption.NONE);
+    sslVerify = cfg.getBoolean("sendemail", null, "sslverify", true);
+
+    final int defaultPort;
+    switch (smtpEncryption) {
+      case SSL:
+        defaultPort = 465;
+        break;
+
+      case NONE:
+      case TLS:
+      default:
+        defaultPort = 25;
+        break;
+    }
+    smtpPort = cfg.getInt("sendemail", null, "smtpserverport", defaultPort);
+
+    smtpUser = cfg.getString("sendemail", null, "smtpuser");
+    smtpPass = cfg.getString("sendemail", null, "smtppass");
+
+    Set<String> rcpt = new HashSet<>();
+    for (String addr : cfg.getStringList("sendemail", null, "allowrcpt")) {
+      rcpt.add(addr);
+    }
+    allowrcpt = Collections.unmodifiableSet(rcpt);
+    importance = cfg.getString("sendemail", null, "importance");
+    expiryDays = cfg.getInt("sendemail", null, "expiryDays", 0);
+  }
+
+  @Override
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  @Override
+  public boolean canEmail(String address) {
+    if (!isEnabled()) {
+      return false;
+    }
+
+    if (allowrcpt.isEmpty()) {
+      return true;
+    }
+
+    if (allowrcpt.contains(address)) {
+      return true;
+    }
+
+    String domain = address.substring(address.lastIndexOf('@') + 1);
+    if (allowrcpt.contains(domain) || allowrcpt.contains("@" + domain)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  @Override
+  public void send(
+      final Address from,
+      Collection<Address> rcpt,
+      final Map<String, EmailHeader> callerHeaders,
+      String body)
+      throws EmailException {
+    send(from, rcpt, callerHeaders, body, null);
+  }
+
+  @Override
+  public void send(
+      final Address from,
+      Collection<Address> rcpt,
+      final Map<String, EmailHeader> callerHeaders,
+      String textBody,
+      @Nullable String htmlBody)
+      throws EmailException {
+    if (!isEnabled()) {
+      throw new EmailException("Sending email is disabled");
+    }
+
+    StringBuffer rejected = new StringBuffer();
+    try {
+      final SMTPClient client = open();
+      try {
+        if (!client.setSender(from.getEmail())) {
+          throw new EmailException(
+              "Server " + smtpHost + " rejected from address " + from.getEmail());
+        }
+
+        /* Do not prevent the email from being sent to "good" users simply
+         * because some users get rejected.  If not, a single rejected
+         * project watcher could prevent email for most actions on a project
+         * from being sent to any user!  Instead, queue up the errors, and
+         * throw an exception after sending the email to get the rejected
+         * error(s) logged.
+         */
+        for (Address addr : rcpt) {
+          if (!client.addRecipient(addr.getEmail())) {
+            String error = client.getReplyString();
+            rejected
+                .append("Server ")
+                .append(smtpHost)
+                .append(" rejected recipient ")
+                .append(addr)
+                .append(": ")
+                .append(error);
+          }
+        }
+
+        Writer messageDataWriter = client.sendMessageData();
+        if (messageDataWriter == null) {
+          /* Include rejected recipient error messages here to not lose that
+           * information. That piece of the puzzle is vital if zero recipients
+           * are accepted and the server consequently rejects the DATA command.
+           */
+          throw new EmailException(
+              rejected
+                  + "Server "
+                  + smtpHost
+                  + " rejected DATA command: "
+                  + client.getReplyString());
+        }
+
+        render(messageDataWriter, callerHeaders, textBody, htmlBody);
+
+        if (!client.completePendingCommand()) {
+          throw new EmailException(
+              "Server " + smtpHost + " rejected message body: " + client.getReplyString());
+        }
+
+        client.logout();
+        if (rejected.length() > 0) {
+          throw new EmailException(rejected.toString());
+        }
+      } finally {
+        client.disconnect();
+      }
+    } catch (IOException e) {
+      throw new EmailException("Cannot send outgoing email", e);
+    }
+  }
+
+  private void render(
+      Writer out,
+      Map<String, EmailHeader> callerHeaders,
+      String textBody,
+      @Nullable String htmlBody)
+      throws IOException, EmailException {
+    final Map<String, EmailHeader> hdrs = new LinkedHashMap<>(callerHeaders);
+    setMissingHeader(hdrs, "MIME-Version", "1.0");
+    setMissingHeader(hdrs, "Content-Transfer-Encoding", "8bit");
+    setMissingHeader(hdrs, "Content-Disposition", "inline");
+    setMissingHeader(hdrs, "User-Agent", "Gerrit/" + Version.getVersion());
+    if (importance != null) {
+      setMissingHeader(hdrs, "Importance", importance);
+    }
+    if (expiryDays > 0) {
+      Date expiry = new Date(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
+      setMissingHeader(
+          hdrs, "Expiry-Date", new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z").format(expiry));
+    }
+
+    String encodedBody;
+    if (htmlBody == null) {
+      setMissingHeader(hdrs, "Content-Type", "text/plain; charset=UTF-8");
+      encodedBody = textBody;
+    } else {
+      String boundary = generateMultipartBoundary(textBody, htmlBody);
+      setMissingHeader(
+          hdrs,
+          "Content-Type",
+          "multipart/alternative; boundary=\"" + boundary + "\"; charset=UTF-8");
+      encodedBody = buildMultipartBody(boundary, textBody, htmlBody);
+    }
+
+    try (Writer w = new BufferedWriter(out)) {
+      for (Map.Entry<String, EmailHeader> h : hdrs.entrySet()) {
+        if (!h.getValue().isEmpty()) {
+          w.write(h.getKey());
+          w.write(": ");
+          h.getValue().write(w);
+          w.write("\r\n");
+        }
+      }
+
+      w.write("\r\n");
+      w.write(encodedBody);
+      w.flush();
+    }
+  }
+
+  public static String generateMultipartBoundary(String textBody, String htmlBody)
+      throws EmailException {
+    byte[] bytes = new byte[8];
+    ThreadLocalRandom rng = ThreadLocalRandom.current();
+
+    // The probability of the boundary being valid is approximately
+    // (2^64 - len(message)) / 2^64.
+    //
+    // The message is much shorter than 2^64 bytes, so if two tries don't
+    // suffice, something is seriously wrong.
+    for (int i = 0; i < 2; i++) {
+      rng.nextBytes(bytes);
+      String boundary = BaseEncoding.base64().encode(bytes);
+      String encBoundary = "--" + boundary;
+      if (textBody.contains(encBoundary) || htmlBody.contains(encBoundary)) {
+        continue;
+      }
+      return boundary;
+    }
+    throw new EmailException("Gave up generating unique MIME boundary");
+  }
+
+  protected String buildMultipartBody(String boundary, String textPart, String htmlPart)
+      throws IOException {
+    String encodedTextPart = quotedPrintableEncode(textPart);
+    String encodedHtmlPart = quotedPrintableEncode(htmlPart);
+
+    // Only declare quoted-printable encoding if there are characters that need to be encoded.
+    String textTransferEncoding = textPart.equals(encodedTextPart) ? "7bit" : "quoted-printable";
+    String htmlTransferEncoding = htmlPart.equals(encodedHtmlPart) ? "7bit" : "quoted-printable";
+
+    return
+    // Output the text part:
+    "--"
+        + boundary
+        + "\r\n"
+        + "Content-Type: text/plain; charset=UTF-8\r\n"
+        + "Content-Transfer-Encoding: "
+        + textTransferEncoding
+        + "\r\n"
+        + "\r\n"
+        + encodedTextPart
+        + "\r\n"
+
+        // Output the HTML part:
+        + "--"
+        + boundary
+        + "\r\n"
+        + "Content-Type: text/html; charset=UTF-8\r\n"
+        + "Content-Transfer-Encoding: "
+        + htmlTransferEncoding
+        + "\r\n"
+        + "\r\n"
+        + encodedHtmlPart
+        + "\r\n"
+
+        // Output the closing boundary.
+        + "--"
+        + boundary
+        + "--\r\n";
+  }
+
+  protected String quotedPrintableEncode(String input) throws IOException {
+    ByteArrayOutputStream s = new ByteArrayOutputStream();
+    try (QuotedPrintableOutputStream qp = new QuotedPrintableOutputStream(s, false)) {
+      qp.write(input.getBytes(UTF_8));
+    }
+    return s.toString();
+  }
+
+  private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
+    if (!hdrs.containsKey(name) || hdrs.get(name).isEmpty()) {
+      hdrs.put(name, new EmailHeader.String(value));
+    }
+  }
+
+  private SMTPClient open() throws EmailException {
+    final AuthSMTPClient client = new AuthSMTPClient(UTF_8.name());
+
+    if (smtpEncryption == Encryption.SSL) {
+      client.enableSSL(sslVerify);
+    }
+
+    client.setConnectTimeout(connectTimeout);
+    try {
+      client.connect(smtpHost, smtpPort);
+      int replyCode = client.getReplyCode();
+      String replyString = client.getReplyString();
+      if (!SMTPReply.isPositiveCompletion(replyCode)) {
+        throw new EmailException(
+            String.format("SMTP server rejected connection: %d: %s", replyCode, replyString));
+      }
+      if (!client.login()) {
+        throw new EmailException("SMTP server rejected HELO/EHLO greeting: " + replyString);
+      }
+
+      if (smtpEncryption == Encryption.TLS) {
+        if (!client.startTLS(smtpHost, smtpPort, sslVerify)) {
+          throw new EmailException("SMTP server does not support TLS");
+        }
+        if (!client.login()) {
+          throw new EmailException("SMTP server rejected login: " + replyString);
+        }
+      }
+
+      if (smtpUser != null && !client.auth(smtpUser, smtpPass)) {
+        throw new EmailException("SMTP server rejected auth: " + replyString);
+      }
+      return client;
+    } catch (IOException | EmailException e) {
+      if (client.isConnected()) {
+        try {
+          client.disconnect();
+        } catch (IOException e2) {
+          // Ignored
+        }
+      }
+      if (e instanceof EmailException) {
+        throw (EmailException) e;
+      }
+      throw new EmailException(e.getMessage(), e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java
new file mode 100644
index 0000000..524bbed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/VelocityRuntimeProvider.java
@@ -0,0 +1,118 @@
+// 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.mail.send;
+
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.nio.file.Files;
+import java.util.Properties;
+import org.apache.velocity.runtime.RuntimeConstants;
+import org.apache.velocity.runtime.RuntimeInstance;
+import org.apache.velocity.runtime.RuntimeServices;
+import org.apache.velocity.runtime.log.LogChute;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Configures Velocity template engine for sending email. */
+@Singleton
+public class VelocityRuntimeProvider implements Provider<RuntimeInstance> {
+  private final SitePaths site;
+
+  @Inject
+  VelocityRuntimeProvider(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public RuntimeInstance get() {
+    String rl = "resource.loader";
+    String pkg = "org.apache.velocity.runtime.resource.loader";
+
+    Properties p = new Properties();
+    p.setProperty(RuntimeConstants.VM_PERM_INLINE_LOCAL, "true");
+    p.setProperty(RuntimeConstants.RUNTIME_LOG_LOGSYSTEM_CLASS, Slf4jLogChute.class.getName());
+    p.setProperty(RuntimeConstants.RUNTIME_REFERENCES_STRICT, "true");
+    p.setProperty("runtime.log.logsystem.log4j.category", "velocity");
+
+    if (Files.isDirectory(site.mail_dir)) {
+      p.setProperty(rl, "file, class");
+      p.setProperty("file." + rl + ".class", pkg + ".FileResourceLoader");
+      p.setProperty("file." + rl + ".path", site.mail_dir.toAbsolutePath().toString());
+      p.setProperty("class." + rl + ".class", pkg + ".ClasspathResourceLoader");
+    } else {
+      p.setProperty(rl, "class");
+      p.setProperty("class." + rl + ".class", pkg + ".ClasspathResourceLoader");
+    }
+
+    RuntimeInstance ri = new RuntimeInstance();
+    try {
+      ri.init(p);
+    } catch (Exception err) {
+      throw new ProvisionException("Cannot configure Velocity templates", err);
+    }
+    return ri;
+  }
+
+  /** Connects Velocity to sfl4j. */
+  public static class Slf4jLogChute implements LogChute {
+    // Logger should be named 'velocity' for consistency with log4j config
+    private static final Logger log = LoggerFactory.getLogger("velocity");
+
+    @Override
+    public void init(RuntimeServices rs) {}
+
+    @Override
+    public boolean isLevelEnabled(int level) {
+      switch (level) {
+        default:
+        case DEBUG_ID:
+          return log.isDebugEnabled();
+        case INFO_ID:
+          return log.isInfoEnabled();
+        case WARN_ID:
+          return log.isWarnEnabled();
+        case ERROR_ID:
+          return log.isErrorEnabled();
+      }
+    }
+
+    @Override
+    public void log(int level, String message) {
+      log(level, message, null);
+    }
+
+    @Override
+    public void log(int level, String msg, Throwable err) {
+      switch (level) {
+        default:
+        case DEBUG_ID:
+          log.debug(msg, err);
+          break;
+        case INFO_ID:
+          log.info(msg, err);
+          break;
+        case WARN_ID:
+          log.warn(msg, err);
+          break;
+        case ERROR_ID:
+          log.error(msg, err);
+          break;
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/DefaultFileExtensionRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/DefaultFileExtensionRegistry.java
index 8c6bb3e..7f0661c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/DefaultFileExtensionRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/DefaultFileExtensionRegistry.java
@@ -15,15 +15,10 @@
 package com.google.gerrit.server.mime;
 
 import com.google.common.collect.ImmutableMap;
-
 import eu.medsea.mimeutil.MimeException;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil;
 import eu.medsea.mimeutil.detector.MimeDetector;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -32,6 +27,8 @@
 import java.util.Collections;
 import java.util.Map;
 import java.util.Properties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Loads mime types from {@code mime-types.properties} at specificity of 2. */
 public class DefaultFileExtensionRegistry extends MimeDetector {
@@ -40,8 +37,8 @@
 
   static {
     Properties prop = new Properties();
-    try (InputStream in = DefaultFileExtensionRegistry.class
-        .getResourceAsStream("mime-types.properties")) {
+    try (InputStream in =
+        DefaultFileExtensionRegistry.class.getResourceAsStream("mime-types.properties")) {
       prop.load(in);
     } catch (IOException e) {
       log.warn("Cannot load mime-types.properties", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
index 96486e96..e9e3c71 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
@@ -21,27 +21,24 @@
   /**
    * Get the most specific MIME type available for a file.
    *
-   * @param path name of the file. The base name (component after the last '/')
-   *        may be used to help determine the MIME type, such as by examining
-   *        the extension (portion after the last '.' if present).
-   * @param content the complete file content. If non-null the content may be
-   *        used to guess the MIME type by examining the beginning for common
-   *        file headers.
-   * @return the MIME type for this content. If the MIME type is not recognized
-   *         or cannot be determined, {@link MimeUtil2#UNKNOWN_MIME_TYPE} which
-   *         is an alias for {@code application/octet-stream}.
+   * @param path name of the file. The base name (component after the last '/') may be used to help
+   *     determine the MIME type, such as by examining the extension (portion after the last '.' if
+   *     present).
+   * @param content the complete file content. If non-null the content may be used to guess the MIME
+   *     type by examining the beginning for common file headers.
+   * @return the MIME type for this content. If the MIME type is not recognized or cannot be
+   *     determined, {@link MimeUtil2#UNKNOWN_MIME_TYPE} which is an alias for {@code
+   *     application/octet-stream}.
    */
-  MimeType getMimeType(final String path, final byte[] content);
+  MimeType getMimeType(String path, byte[] content);
 
   /**
    * Is this content type safe to transmit to a browser directly?
    *
    * @param type the MIME type of the file content.
-   * @return true if the Gerrit administrator wants to permit this content to be
-   *         served as-is; false if the administrator does not trust this
-   *         content type and wants it to be protected (typically by wrapping
-   *         the data in a ZIP archive).
+   * @return true if the Gerrit administrator wants to permit this content to be served as-is; false
+   *     if the administrator does not trust this content type and wants it to be protected
+   *     (typically by wrapping the data in a ZIP archive).
    */
-  boolean isSafeInline(final MimeType type);
-
+  boolean isSafeInline(MimeType type);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtil2Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtil2Module.java
index 61b01af8..387482a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtil2Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtil2Module.java
@@ -18,15 +18,13 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
-
 import eu.medsea.mimeutil.MimeUtil2;
 import eu.medsea.mimeutil.detector.ExtensionMimeDetector;
 import eu.medsea.mimeutil.detector.MagicMimeMimeDetector;
 
 public class MimeUtil2Module extends AbstractModule {
   @Override
-  protected void configure() {
-  }
+  protected void configure() {}
 
   @Provides
   @Singleton
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
index 2387200..859363c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
@@ -17,15 +17,9 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import eu.medsea.mimeutil.MimeException;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -33,13 +27,15 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class MimeUtilFileTypeRegistry implements FileTypeRegistry {
   private static final String KEY_SAFE = "safe";
   private static final String SECTION_MIMETYPE = "mimetype";
-  private static final Logger log =
-      LoggerFactory.getLogger(MimeUtilFileTypeRegistry.class);
+  private static final Logger log = LoggerFactory.getLogger(MimeUtilFileTypeRegistry.class);
 
   private final Config cfg;
   private final MimeUtil2 mimeUtil;
@@ -53,9 +49,8 @@
   /**
    * Get specificity of mime types with generic types forced to low values
    *
-   * "application/octet-stream" is forced to -1.
-   * "text/plain" is forced to 0.
-   * All other mime types return the specificity reported by mimeType itself.
+   * <p>"application/octet-stream" is forced to -1. "text/plain" is forced to 0. All other mime
+   * types return the specificity reported by mimeType itself.
    *
    * @param mimeType The mimeType to get the corrected specificity for.
    * @return The corrected specificity.
@@ -103,12 +98,14 @@
     }
 
     final List<MimeType> types = new ArrayList<>(mimeTypes);
-    Collections.sort(types, new Comparator<MimeType>() {
-      @Override
-      public int compare(MimeType a, MimeType b) {
-        return getCorrectedMimeSpecificity(b) - getCorrectedMimeSpecificity(a);
-      }
-    });
+    Collections.sort(
+        types,
+        new Comparator<MimeType>() {
+          @Override
+          public int compare(MimeType a, MimeType b) {
+            return getCorrectedMimeSpecificity(b) - getCorrectedMimeSpecificity(a);
+          }
+        });
     return types.get(0);
   }
 
@@ -135,7 +132,6 @@
     if (mimeTypes.isEmpty()) {
       return true;
     }
-    return mimeTypes.size() == 1
-        && mimeTypes.contains(MimeUtil2.UNKNOWN_MIME_TYPE);
+    return mimeTypes.size() == 1 && mimeTypes.contains(MimeUtil2.UNKNOWN_MIME_TYPE);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 679a9de..7b19b39 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -27,19 +27,20 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-
-/** View of contents at a single ref related to some change. **/
+/** View of contents at a single ref related to some change. * */
 public abstract class AbstractChangeNotes<T> {
   @VisibleForTesting
   @Singleton
@@ -88,16 +89,18 @@
       } else if (id != null) {
         id = id.copy();
       }
-      return new AutoValue_AbstractChangeNotes_LoadHandle(
-          checkNotNull(walk), id);
+      return new AutoValue_AbstractChangeNotes_LoadHandle(checkNotNull(walk), id);
     }
 
     public static LoadHandle missing() {
       return new AutoValue_AbstractChangeNotes_LoadHandle(null, null);
     }
 
-    @Nullable public abstract ChangeNotesRevWalk walk();
-    @Nullable public abstract ObjectId id();
+    @Nullable
+    public abstract ChangeNotesRevWalk walk();
+
+    @Nullable
+    public abstract ObjectId id();
 
     @Override
     public void close() {
@@ -108,16 +111,19 @@
   }
 
   protected final Args args;
+  protected final PrimaryStorage primaryStorage;
   protected final boolean autoRebuild;
   private final Change.Id changeId;
 
   private ObjectId revision;
   private boolean loaded;
 
-  AbstractChangeNotes(Args args, Change.Id changeId, boolean autoRebuild) {
+  AbstractChangeNotes(
+      Args args, Change.Id changeId, @Nullable PrimaryStorage primaryStorage, boolean autoRebuild) {
     this.args = checkNotNull(args);
     this.changeId = checkNotNull(changeId);
-    this.autoRebuild = autoRebuild;
+    this.primaryStorage = primaryStorage;
+    this.autoRebuild = primaryStorage == PrimaryStorage.REVIEW_DB && autoRebuild;
   }
 
   public Change.Id getChangeId() {
@@ -134,8 +140,14 @@
       return self();
     }
     boolean read = args.migration.readChanges();
+    if (!read && primaryStorage == PrimaryStorage.NOTE_DB) {
+      throw new OrmException("NoteDb is required to read change " + changeId);
+    }
     boolean readOrWrite = read || args.migration.writeChanges();
-    if (!readOrWrite && !autoRebuild) {
+    if (!readOrWrite) {
+      // Don't even open the repo if we neither write to nor read from NoteDb. It's possible that
+      // there is some garbage in the noteDbState field and/or the repo, but at this point NoteDb is
+      // completely off so it's none of our business.
       loadDefaults();
       return self();
     }
@@ -165,7 +177,17 @@
     return ref != null ? ref.getObjectId() : null;
   }
 
-  protected LoadHandle openHandle(Repository repo) throws IOException {
+  /**
+   * Open a handle for reading this entity from a repository.
+   *
+   * <p>Implementations may override this method to provide auto-rebuilding behavior.
+   *
+   * @param repo open repository.
+   * @return handle for reading the entity.
+   * @throws NoSuchChangeException change does not exist.
+   * @throws IOException a repo-level error occurred.
+   */
+  protected LoadHandle openHandle(Repository repo) throws NoSuchChangeException, IOException {
     return openHandle(repo, readRef(repo));
   }
 
@@ -173,7 +195,7 @@
     return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), id);
   }
 
-  public T reload() throws OrmException {
+  public T reload() throws NoSuchChangeException, OrmException {
     loaded = false;
     return load();
   }
@@ -196,8 +218,8 @@
   protected abstract void loadDefaults();
 
   /**
-   * @return the NameKey for the project where the notes should be stored,
-   *    which is not necessarily the same as the change's project.
+   * @return the NameKey for the project where the notes should be stored, which is not necessarily
+   *     the same as the change's project.
    */
   public abstract Project.NameKey getProjectName();
 
@@ -206,7 +228,7 @@
 
   /** Set up the metadata, parsing any state from the loaded revision. */
   protected abstract void onLoad(LoadHandle handle)
-      throws IOException, ConfigInvalidException;
+      throws NoSuchChangeException, IOException, ConfigInvalidException;
 
   @SuppressWarnings("unchecked")
   protected final T self() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 70a5f4f..472eda1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -20,6 +20,7 @@
 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.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -27,8 +28,11 @@
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Date;
 import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -36,26 +40,26 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.util.Date;
-
 /** A single delta related to a specific patch-set of a change. */
 public abstract class AbstractChangeUpdate {
   protected final NotesMigration migration;
   protected final ChangeNoteUtil noteUtil;
   protected final String anonymousCowardName;
   protected final Account.Id accountId;
+  protected final Account.Id realAccountId;
   protected final PersonIdent authorIdent;
   protected final Date when;
+  private final long readOnlySkewMs;
 
   @Nullable private final ChangeNotes notes;
   private final Change change;
-  private final PersonIdent serverIdent;
+  protected final PersonIdent serverIdent;
 
   protected PatchSet.Id psId;
   private ObjectId result;
 
   protected AbstractChangeUpdate(
+      Config cfg,
       NotesMigration migration,
       ChangeControl ctl,
       PersonIdent serverIdent,
@@ -69,12 +73,15 @@
     this.notes = ctl.getNotes();
     this.change = notes.getChange();
     this.accountId = accountId(ctl.getUser());
-    this.authorIdent =
-        ident(noteUtil, serverIdent, anonymousCowardName, ctl.getUser(), when);
+    Account.Id realAccountId = accountId(ctl.getUser().getRealUser());
+    this.realAccountId = realAccountId != null ? realAccountId : accountId;
+    this.authorIdent = ident(noteUtil, serverIdent, anonymousCowardName, ctl.getUser(), when);
     this.when = when;
+    this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
   }
 
   protected AbstractChangeUpdate(
+      Config cfg,
       NotesMigration migration,
       ChangeNoteUtil noteUtil,
       PersonIdent serverIdent,
@@ -82,11 +89,11 @@
       @Nullable ChangeNotes notes,
       @Nullable Change change,
       Account.Id accountId,
+      Account.Id realAccountId,
       PersonIdent authorIdent,
       Date when) {
     checkArgument(
-        (notes != null && change == null)
-            || (notes == null && change != null),
+        (notes != null && change == null) || (notes == null && change != null),
         "exactly one of notes or change required");
     this.migration = migration;
     this.noteUtil = noteUtil;
@@ -95,14 +102,17 @@
     this.notes = notes;
     this.change = change != null ? change : notes.getChange();
     this.accountId = accountId;
+    this.realAccountId = realAccountId;
     this.authorIdent = authorIdent;
     this.when = when;
+    this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
   }
 
   private static void checkUserType(CurrentUser user) {
     checkArgument(
         (user instanceof IdentifiedUser) || (user instanceof InternalUser),
-        "user must be IdentifiedUser or InternalUser: %s", user);
+        "user must be IdentifiedUser or InternalUser: %s",
+        user);
   }
 
   private static Account.Id accountId(CurrentUser u) {
@@ -110,13 +120,16 @@
     return (u instanceof IdentifiedUser) ? u.getAccountId() : null;
   }
 
-  private static PersonIdent ident(ChangeNoteUtil noteUtil,
-      PersonIdent serverIdent, String anonymousCowardName, CurrentUser u,
+  private static PersonIdent ident(
+      ChangeNoteUtil noteUtil,
+      PersonIdent serverIdent,
+      String anonymousCowardName,
+      CurrentUser u,
       Date when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
-      return noteUtil.newIdent(u.asIdentifiedUser().getAccount(), when,
-          serverIdent, anonymousCowardName);
+      return noteUtil.newIdent(
+          u.asIdentifiedUser().getAccount(), when, serverIdent, anonymousCowardName);
     } else if (u instanceof InternalUser) {
       return serverIdent;
     }
@@ -127,6 +140,14 @@
     return change.getId();
   }
 
+  /**
+   * @return notes for the state of this change prior to this update. If this update is part of a
+   *     series managed by a {@link NoteDbUpdateManager}, then this reflects the state prior to the
+   *     first update in the series. A null return value can only happen when the change is being
+   *     rebuilt from NoteDb. A change that is in the process of being created will result in a
+   *     non-null return value from this method, but a null return value from {@link
+   *     ChangeNotes#getRevision()}.
+   */
   @Nullable
   public ChangeNotes getNotes() {
     return notes;
@@ -150,9 +171,11 @@
   }
 
   public Account.Id getAccountId() {
-    checkState(accountId != null,
+    checkState(
+        accountId != null,
         "author identity for %s is not from an IdentifiedUser: %s",
-        getClass().getSimpleName(), authorIdent.toExternalString());
+        getClass().getSimpleName(),
+        authorIdent.toExternalString());
     return accountId;
   }
 
@@ -168,8 +191,8 @@
   public abstract boolean isEmpty();
 
   /**
-   * @return the NameKey for the project where the update will be stored,
-   *    which is not necessarily the same as the change's project.
+   * @return the NameKey for the project where the update will be stored, which is not necessarily
+   *     the same as the change's project.
    */
   protected abstract Project.NameKey getProjectName();
 
@@ -181,9 +204,9 @@
    * @param rw walk for reading back any objects needed for the update.
    * @param ins inserter to write to; callers should not flush.
    * @param curr the current tip of the branch prior to this update.
-   * @return commit ID produced by inserting this update's commit, or null if
-   *     this update is a no-op and should be skipped. The zero ID is a valid
-   *     return value, and indicates the ref should be deleted.
+   * @return commit ID produced by inserting this update's commit, or null if this update is a no-op
+   *     and should be skipped. The zero ID is a valid return value, and indicates the ref should be
+   *     deleted.
    * @throws OrmException if a Gerrit-level error occurred.
    * @throws IOException if a lower-level error occurred.
    */
@@ -198,6 +221,7 @@
     // to actually store.
 
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
+    checkNotReadOnly();
     ObjectId z = ObjectId.zeroId();
     CommitBuilder cb = applyImpl(rw, ins, curr);
     if (cb == null) {
@@ -225,22 +249,33 @@
     return result;
   }
 
+  protected void checkNotReadOnly() throws OrmException {
+    ChangeNotes notes = getNotes();
+    if (notes == null) {
+      // Can only happen during ChangeRebuilder, which will never include a read-only lease.
+      return;
+    }
+    Timestamp until = notes.getReadOnlyUntil();
+    if (until != null && NoteDbChangeState.timeForReadOnlyCheck(readOnlySkewMs).before(until)) {
+      throw new OrmException("change " + notes.getChangeId() + " is read-only until " + until);
+    }
+  }
+
   /**
    * Create a commit containing the contents of this update.
    *
    * @param ins inserter to write to; callers should not flush.
-   * @return a new commit builder representing this commit, or null to indicate
-   *     the meta ref should be deleted as a result of this update. The parent,
-   *     author, and committer fields in the return value are always
-   *     overwritten. The tree ID may be unset by this method, which indicates
-   *     to the caller that it should be copied from the parent commit. To
-   *     indicate that this update is a no-op (but this could not be determined
-   *     by {@link #isEmpty()}), return the sentinel {@link #NO_OP_UPDATE}.
+   * @return a new commit builder representing this commit, or null to indicate the meta ref should
+   *     be deleted as a result of this update. The parent, author, and committer fields in the
+   *     return value are always overwritten. The tree ID may be unset by this method, which
+   *     indicates to the caller that it should be copied from the parent commit. To indicate that
+   *     this update is a no-op (but this could not be determined by {@link #isEmpty()}), return the
+   *     sentinel {@link #NO_OP_UPDATE}.
    * @throws OrmException if a Gerrit-level error occurred.
    * @throws IOException if a lower-level error occurred.
    */
-  protected abstract CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins,
-      ObjectId curr) throws OrmException, IOException;
+  protected abstract CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException;
 
   protected static final CommitBuilder NO_OP_UPDATE = new CommitBuilder();
 
@@ -255,4 +290,21 @@
   private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
     return ins.insert(Constants.OBJ_TREE, new byte[] {});
   }
+
+  protected void verifyComment(Comment c) {
+    checkArgument(c.revId != null, "RevId required for comment: %s", c);
+    checkArgument(
+        c.author.getId().equals(getAccountId()),
+        "The author for the following comment does not match the author of this %s (%s): %s",
+        getClass().getSimpleName(),
+        getAccountId(),
+        c);
+    checkArgument(
+        c.getRealAuthor().getId().equals(realAccountId),
+        "The real author for the following comment does not match the real"
+            + " author of this %s (%s): %s",
+        getClass().getSimpleName(),
+        realAccountId,
+        c);
+  }
 }
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 e15af9d..9eb4532 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
@@ -22,6 +22,7 @@
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
+import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.CharMatcher;
@@ -37,9 +38,9 @@
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
@@ -50,12 +51,12 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 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.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.server.OrmException;
-
 import java.lang.reflect.Field;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -73,61 +74,50 @@
 
 /**
  * A bundle of all entities rooted at a single {@link Change} entity.
- * <p>
- * See the {@link Change} Javadoc for a depiction of this tree. Bundles may be
- * compared using {@link #differencesFrom(ChangeBundle)}, which normalizes out
- * the minor implementation differences between ReviewDb and NoteDb.
+ *
+ * <p>See the {@link Change} Javadoc for a depiction of this tree. Bundles may be compared using
+ * {@link #differencesFrom(ChangeBundle)}, which normalizes out the minor implementation differences
+ * between ReviewDb and NoteDb.
  */
 public class ChangeBundle {
   public enum Source {
-    REVIEW_DB, NOTE_DB;
+    REVIEW_DB,
+    NOTE_DB;
   }
 
-  public static ChangeBundle fromReviewDb(ReviewDb db, Change.Id id)
+  public static ChangeBundle fromNotes(CommentsUtil commentsUtil, ChangeNotes notes)
       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),
-          approvals,
-          db.patchComments().byChange(id),
-          ReviewerSet.fromApprovals(approvals),
-          Source.REVIEW_DB);
-    } finally {
-      db.rollback();
-    }
-  }
-
-  public static ChangeBundle fromNotes(PatchLineCommentsUtil plcUtil,
-      ChangeNotes notes) throws OrmException {
     return new ChangeBundle(
         notes.getChange(),
         notes.getChangeMessages(),
         notes.getPatchSets().values(),
         notes.getApprovals().values(),
         Iterables.concat(
-            plcUtil.draftByChange(null, notes),
-            plcUtil.publishedByChange(null, notes)),
+            CommentsUtil.toPatchLineComments(
+                notes.getChangeId(),
+                PatchLineComment.Status.DRAFT,
+                commentsUtil.draftByChange(null, notes)),
+            CommentsUtil.toPatchLineComments(
+                notes.getChangeId(),
+                PatchLineComment.Status.PUBLISHED,
+                commentsUtil.publishedByChange(null, notes))),
         notes.getReviewers(),
         Source.NOTE_DB);
   }
 
   private static Map<ChangeMessage.Key, ChangeMessage> changeMessageMap(
       Iterable<ChangeMessage> in) {
-    Map<ChangeMessage.Key, ChangeMessage> out = new TreeMap<>(
-        new Comparator<ChangeMessage.Key>() {
-          @Override
-          public int compare(ChangeMessage.Key a, ChangeMessage.Key b) {
-            return ComparisonChain.start()
-                .compare(a.getParentKey().get(), b.getParentKey().get())
-                .compare(a.get(), b.get())
-                .result();
-          }
-        });
+    Map<ChangeMessage.Key, ChangeMessage> out =
+        new TreeMap<>(
+            new Comparator<ChangeMessage.Key>() {
+              @Override
+              public int compare(ChangeMessage.Key a, ChangeMessage.Key b) {
+                return ComparisonChain.start()
+                    .compare(a.getParentKey().get(), b.getParentKey().get())
+                    .compare(a.get(), b.get())
+                    .result();
+              }
+            });
     for (ChangeMessage cm : in) {
       out.put(cm.getKey(), cm);
     }
@@ -138,15 +128,13 @@
   // this comparator sorts first on timestamp, then on every other field.
   private static final Ordering<ChangeMessage> CHANGE_MESSAGE_ORDER =
       new Ordering<ChangeMessage>() {
-        final Ordering<Comparable<?>> nullsFirst =
-            Ordering.natural().nullsFirst();
+        final Ordering<Comparable<?>> nullsFirst = Ordering.natural().nullsFirst();
 
         @Override
         public int compare(ChangeMessage a, ChangeMessage b) {
           return ComparisonChain.start()
               .compare(a.getWrittenOn(), b.getWrittenOn())
-              .compare(a.getKey().getParentKey().get(),
-                  b.getKey().getParentKey().get())
+              .compare(a.getKey().getParentKey().get(), b.getKey().getParentKey().get())
               .compare(psId(a), psId(b), nullsFirst)
               .compare(a.getAuthor(), b.getAuthor(), intKeyOrdering())
               .compare(a.getMessage(), b.getMessage(), nullsFirst)
@@ -158,57 +146,59 @@
         }
       };
 
-  private static ImmutableList<ChangeMessage> changeMessageList(
-      Iterable<ChangeMessage> in) {
+  private static ImmutableList<ChangeMessage> changeMessageList(Iterable<ChangeMessage> in) {
     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>() {
-          @Override
-          public int compare(PatchSet.Id a, PatchSet.Id b) {
-            return patchSetIdChain(a, b).result();
-          }
-        });
+    TreeMap<PatchSet.Id, PatchSet> out =
+        new TreeMap<>(
+            new Comparator<PatchSet.Id>() {
+              @Override
+              public int compare(PatchSet.Id a, PatchSet.Id b) {
+                return patchSetIdChain(a, b).result();
+              }
+            });
     for (PatchSet ps : in) {
       out.put(ps.getId(), ps);
     }
     return out;
   }
 
-  private static Map<PatchSetApproval.Key, PatchSetApproval>
-      patchSetApprovalMap(Iterable<PatchSetApproval> in) {
-    Map<PatchSetApproval.Key, PatchSetApproval> out = new TreeMap<>(
-        new Comparator<PatchSetApproval.Key>() {
-          @Override
-          public int compare(PatchSetApproval.Key a, PatchSetApproval.Key b) {
-            return patchSetIdChain(a.getParentKey(), b.getParentKey())
-                .compare(a.getAccountId().get(), b.getAccountId().get())
-                .compare(a.getLabelId(), b.getLabelId())
-                .result();
-          }
-        });
+  private static Map<PatchSetApproval.Key, PatchSetApproval> patchSetApprovalMap(
+      Iterable<PatchSetApproval> in) {
+    Map<PatchSetApproval.Key, PatchSetApproval> out =
+        new TreeMap<>(
+            new Comparator<PatchSetApproval.Key>() {
+              @Override
+              public int compare(PatchSetApproval.Key a, PatchSetApproval.Key b) {
+                return patchSetIdChain(a.getParentKey(), b.getParentKey())
+                    .compare(a.getAccountId().get(), b.getAccountId().get())
+                    .compare(a.getLabelId(), b.getLabelId())
+                    .result();
+              }
+            });
     for (PatchSetApproval psa : in) {
       out.put(psa.getKey(), psa);
     }
     return out;
   }
 
-  private static Map<PatchLineComment.Key, PatchLineComment>
-      patchLineCommentMap(Iterable<PatchLineComment> in) {
-    Map<PatchLineComment.Key, PatchLineComment> out = new TreeMap<>(
-        new Comparator<PatchLineComment.Key>() {
-          @Override
-          public int compare(PatchLineComment.Key a, PatchLineComment.Key b) {
-            Patch.Key pka = a.getParentKey();
-            Patch.Key pkb = b.getParentKey();
-            return patchSetIdChain(pka.getParentKey(), pkb.getParentKey())
-                .compare(pka.get(), pkb.get())
-                .compare(a.get(), b.get())
-                .result();
-          }
-        });
+  private static Map<PatchLineComment.Key, PatchLineComment> patchLineCommentMap(
+      Iterable<PatchLineComment> in) {
+    Map<PatchLineComment.Key, PatchLineComment> out =
+        new TreeMap<>(
+            new Comparator<PatchLineComment.Key>() {
+              @Override
+              public int compare(PatchLineComment.Key a, PatchLineComment.Key b) {
+                Patch.Key pka = a.getParentKey();
+                Patch.Key pkb = b.getParentKey();
+                return patchSetIdChain(pka.getParentKey(), pkb.getParentKey())
+                    .compare(pka.get(), pkb.get())
+                    .compare(a.get(), b.get())
+                    .result();
+              }
+            });
     for (PatchLineComment plc : in) {
       out.put(plc.getKey(), plc);
     }
@@ -230,9 +220,12 @@
       }
     }
     Set<Integer> expectedIds = Sets.newTreeSet(Arrays.asList(expected));
-    checkState(ids.equals(expectedIds),
+    checkState(
+        ids.equals(expectedIds),
         "Unexpected column set for %s: %s != %s",
-        clazz.getSimpleName(), ids, expectedIds);
+        clazz.getSimpleName(),
+        ids,
+        expectedIds);
   }
 
   static {
@@ -240,28 +233,22 @@
     // last time this file was updated.
     checkColumns(Change.Id.class, 1);
 
-    checkColumns(Change.class,
-        1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18,
-        // TODO(dborowitz): It's potentially possible to compare noteDbState in
-        // the Change with the state implied by a ChangeNotes.
-        101);
+    checkColumns(Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 101);
     checkColumns(ChangeMessage.Key.class, 1, 2);
-    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6);
+    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
     checkColumns(PatchSet.Id.class, 1, 2);
-    checkColumns(PatchSet.class, 1, 2, 3, 4, 5, 6, 8);
+    checkColumns(PatchSet.class, 1, 2, 3, 4, 5, 6, 8, 9);
     checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
-    checkColumns(PatchSetApproval.class, 1, 2, 3, 6);
+    checkColumns(PatchSetApproval.class, 1, 2, 3, 6, 7, 8);
     checkColumns(PatchLineComment.Key.class, 1, 2);
-    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
+    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
   }
 
   private final Change change;
   private final ImmutableList<ChangeMessage> changeMessages;
   private final ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
-  private final ImmutableMap<PatchSetApproval.Key, PatchSetApproval>
-      patchSetApprovals;
-  private final ImmutableMap<PatchLineComment.Key, PatchLineComment>
-      patchLineComments;
+  private final ImmutableMap<PatchSetApproval.Key, PatchSetApproval> patchSetApprovals;
+  private final ImmutableMap<PatchLineComment.Key, PatchLineComment> patchLineComments;
   private final ReviewerSet reviewers;
   private final Source source;
 
@@ -276,10 +263,8 @@
     this.change = checkNotNull(change);
     this.changeMessages = changeMessageList(changeMessages);
     this.patchSets = ImmutableSortedMap.copyOfSorted(patchSetMap(patchSets));
-    this.patchSetApprovals =
-        ImmutableMap.copyOf(patchSetApprovalMap(patchSetApprovals));
-    this.patchLineComments =
-        ImmutableMap.copyOf(patchLineCommentMap(patchLineComments));
+    this.patchSetApprovals = ImmutableMap.copyOf(patchSetApprovalMap(patchSetApprovals));
+    this.patchLineComments = ImmutableMap.copyOf(patchLineCommentMap(patchLineComments));
     this.reviewers = checkNotNull(reviewers);
     this.source = checkNotNull(source);
 
@@ -293,8 +278,7 @@
       checkArgument(k.getParentKey().getParentKey().equals(change.getId()));
     }
     for (PatchLineComment.Key k : this.patchLineComments.keySet()) {
-      checkArgument(k.getParentKey().getParentKey().getParentKey()
-          .equals(change.getId()));
+      checkArgument(k.getParentKey().getParentKey().getParentKey().equals(change.getId()));
     }
   }
 
@@ -347,16 +331,16 @@
   private Timestamp getLatestTimestamp() {
     Ordering<Timestamp> o = Ordering.natural().nullsFirst();
     Timestamp ts = null;
-    for (ChangeMessage cm : getChangeMessages()) {
+    for (ChangeMessage cm : filterChangeMessages()) {
       ts = o.max(ts, cm.getWrittenOn());
     }
     for (PatchSet ps : getPatchSets()) {
       ts = o.max(ts, ps.getCreatedOn());
     }
-    for (PatchSetApproval psa : getPatchSetApprovals()) {
+    for (PatchSetApproval psa : filterPatchSetApprovals().values()) {
       ts = o.max(ts, psa.getGranted());
     }
-    for (PatchLineComment plc : getPatchLineComments()) {
+    for (PatchLineComment plc : filterPatchLineComments().values()) {
       // Ignore draft comments, as they do not show up in the change meta graph.
       if (plc.getStatus() != PatchLineComment.Status.DRAFT) {
         ts = o.max(ts, plc.getWrittenOn());
@@ -365,79 +349,36 @@
     return firstNonNull(ts, change.getLastUpdatedOn());
   }
 
-  private Map<PatchSetApproval.Key, PatchSetApproval>
-      filterPatchSetApprovals() {
-    return limitToValidPatchSets(patchSetApprovals,
-        new Function<PatchSetApproval.Key, PatchSet.Id>() {
-          @Override
-          public PatchSet.Id apply(PatchSetApproval.Key in) {
-            return in.getParentKey();
-          }
-        });
+  private Map<PatchSetApproval.Key, PatchSetApproval> filterPatchSetApprovals() {
+    return limitToValidPatchSets(patchSetApprovals, PatchSetApproval.Key::getParentKey);
   }
 
-  private Map<PatchLineComment.Key, PatchLineComment>
-      filterPatchLineComments() {
-    return limitToValidPatchSets(patchLineComments,
-        new Function<PatchLineComment.Key, PatchSet.Id>() {
-          @Override
-          public PatchSet.Id apply(PatchLineComment.Key in) {
-            return in.getParentKey().getParentKey();
-          }
-        });
+  private Map<PatchLineComment.Key, PatchLineComment> filterPatchLineComments() {
+    return limitToValidPatchSets(patchLineComments, k -> k.getParentKey().getParentKey());
   }
 
-  private <K, V> Map<K, V> limitToValidPatchSets(Map<K, V> in,
-      final Function<K, PatchSet.Id> func) {
-    return Maps.filterKeys(
-        in, Predicates.compose(validPatchSetPredicate(), func));
+  private <K, V> Map<K, V> limitToValidPatchSets(Map<K, V> in, Function<K, PatchSet.Id> func) {
+    return Maps.filterKeys(in, Predicates.compose(validPatchSetPredicate(), func));
   }
 
   private Predicate<PatchSet.Id> validPatchSetPredicate() {
-    final Predicate<PatchSet.Id> upToCurrent = upToCurrentPredicate();
-    return new Predicate<PatchSet.Id>() {
-      @Override
-      public boolean apply(PatchSet.Id in) {
-        return upToCurrent.apply(in) && patchSets.containsKey(in);
-      }
-    };
+    return patchSets::containsKey;
   }
 
   private Collection<ChangeMessage> filterChangeMessages() {
     final Predicate<PatchSet.Id> validPatchSet = validPatchSetPredicate();
-    return Collections2.filter(changeMessages,
-        new Predicate<ChangeMessage>() {
-          @Override
-          public boolean apply(ChangeMessage in) {
-            PatchSet.Id psId = in.getPatchSetId();
-            if (psId == null) {
-              return true;
-            }
-            return validPatchSet.apply(psId);
+    return Collections2.filter(
+        changeMessages,
+        m -> {
+          PatchSet.Id psId = m.getPatchSetId();
+          if (psId == null) {
+            return true;
           }
+          return validPatchSet.apply(psId);
         });
   }
 
-  private Predicate<PatchSet.Id> upToCurrentPredicate() {
-    PatchSet.Id current = change.currentPatchSetId();
-    if (current == null) {
-      return Predicates.alwaysFalse();
-    }
-    final int max = current.get();
-    return new Predicate<PatchSet.Id>() {
-      @Override
-      public boolean apply(PatchSet.Id in) {
-        return in.get() <= max;
-      }
-    };
-  }
-
-  private Map<PatchSet.Id, PatchSet> filterPatchSets() {
-    return Maps.filterKeys(patchSets, upToCurrentPredicate());
-  }
-
-  private static void diffChanges(List<String> diffs, ChangeBundle bundleA,
-      ChangeBundle bundleB) {
+  private static void diffChanges(List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
     Change a = bundleA.change;
     Change b = bundleB.change;
     String desc = a.getId().equals(b.getId()) ? describe(a.getId()) : "Changes";
@@ -492,35 +433,35 @@
     //
     // Use max timestamp of all ReviewDb entities when comparing with NoteDb.
     if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-      excludeCreatedOn = !timestampsDiffer(
-          bundleA, bundleA.getFirstPatchSetTime(), bundleB, b.getCreatedOn());
+      excludeCreatedOn =
+          !timestampsDiffer(bundleA, bundleA.getFirstPatchSetTime(), bundleB, b.getCreatedOn());
       aSubj = cleanReviewDbSubject(aSubj);
-      excludeCurrentPatchSetId =
-          !bundleA.validPatchSetPredicate().apply(a.currentPatchSetId());
+      bSubj = cleanNoteDbSubject(bSubj);
+      excludeCurrentPatchSetId = !bundleA.validPatchSetPredicate().apply(a.currentPatchSetId());
       excludeSubject = bSubj.startsWith(aSubj) || excludeCurrentPatchSetId;
       excludeOrigSubj = true;
-      String aTopic = trimLeadingOrNull(a.getTopic());
-      excludeTopic = Objects.equals(aTopic, b.getTopic())
-          || "".equals(aTopic) && b.getTopic() == null;
+      String aTopic = trimOrNull(a.getTopic());
+      excludeTopic =
+          Objects.equals(aTopic, b.getTopic()) || ("".equals(aTopic) && b.getTopic() == null);
       aUpdated = bundleA.getLatestTimestamp();
     } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-      excludeCreatedOn = !timestampsDiffer(
-          bundleA, a.getCreatedOn(), bundleB, bundleB.getFirstPatchSetTime());
+      excludeCreatedOn =
+          !timestampsDiffer(bundleA, a.getCreatedOn(), bundleB, bundleB.getFirstPatchSetTime());
+      aSubj = cleanNoteDbSubject(aSubj);
       bSubj = cleanReviewDbSubject(bSubj);
-      excludeCurrentPatchSetId =
-          !bundleB.validPatchSetPredicate().apply(b.currentPatchSetId());
+      excludeCurrentPatchSetId = !bundleB.validPatchSetPredicate().apply(b.currentPatchSetId());
       excludeSubject = aSubj.startsWith(bSubj) || excludeCurrentPatchSetId;
       excludeOrigSubj = true;
-      String bTopic = trimLeadingOrNull(b.getTopic());
-      excludeTopic = Objects.equals(bTopic, a.getTopic())
-          || a.getTopic() == null && "".equals(bTopic);
+      String bTopic = trimOrNull(b.getTopic());
+      excludeTopic =
+          Objects.equals(bTopic, a.getTopic()) || (a.getTopic() == null && "".equals(bTopic));
       bUpdated = bundleB.getLatestTimestamp();
     }
 
     String subjectField = "subject";
     String updatedField = "lastUpdatedOn";
-    List<String> exclude = Lists.newArrayList(
-        subjectField, updatedField, "noteDbState", "rowVersion");
+    List<String> exclude =
+        Lists.newArrayList(subjectField, updatedField, "noteDbState", "rowVersion");
     if (excludeCreatedOn) {
       exclude.add("createdOn");
     }
@@ -533,24 +474,22 @@
     if (excludeTopic) {
       exclude.add("topic");
     }
-    diffColumnsExcluding(diffs, Change.class, desc, bundleA, a, bundleB, b,
-        exclude);
+    diffColumnsExcluding(diffs, Change.class, desc, bundleA, a, bundleB, b, exclude);
 
     // Allow last updated timestamps to either be exactly equal (within slop),
     // or the NoteDb timestamp to be equal to the latest entity timestamp in the
     // whole ReviewDb bundle (within slop).
-    if (timestampsDiffer(bundleA, a.getLastUpdatedOn(),
-          bundleB, b.getLastUpdatedOn())) {
-      diffTimestamps(diffs, desc, bundleA, aUpdated, bundleB, bUpdated,
-          "effective last updated time");
+    if (timestampsDiffer(bundleA, a.getLastUpdatedOn(), bundleB, b.getLastUpdatedOn())) {
+      diffTimestamps(
+          diffs, desc, bundleA, aUpdated, bundleB, bUpdated, "effective last updated time");
     }
     if (!excludeSubject) {
       diffValues(diffs, desc, aSubj, bSubj, subjectField);
     }
   }
 
-  private static String trimLeadingOrNull(String s) {
-    return s != null ? CharMatcher.whitespace().trimLeadingFrom(s) : null;
+  private static String trimOrNull(String s) {
+    return s != null ? CharMatcher.whitespace().trimFrom(s) : null;
   }
 
   private static String cleanReviewDbSubject(String s) {
@@ -566,27 +505,33 @@
     if (rn >= 0) {
       s = s.substring(0, rn);
     }
-    return s;
+    return ChangeNoteUtil.sanitizeFooter(s);
+  }
+
+  private static String cleanNoteDbSubject(String s) {
+    return ChangeNoteUtil.sanitizeFooter(s);
   }
 
   /**
    * Set of fields that must always exactly match between ReviewDb and NoteDb.
-   * <p>
-   * Used to limit the worst-case quadratic search when pairing off matching
-   * messages below.
+   *
+   * <p>Used to limit the worst-case quadratic search when pairing off matching messages below.
    */
   @AutoValue
   abstract static class ChangeMessageCandidate {
     static ChangeMessageCandidate create(ChangeMessage cm) {
       return new AutoValue_ChangeBundle_ChangeMessageCandidate(
-          cm.getAuthor(),
-          cm.getMessage(),
-          cm.getTag());
+          cm.getAuthor(), cm.getMessage(), cm.getTag());
     }
 
-    @Nullable abstract Account.Id author();
-    @Nullable abstract String message();
-    @Nullable abstract String tag();
+    @Nullable
+    abstract Account.Id author();
+
+    @Nullable
+    abstract String message();
+
+    @Nullable
+    abstract String tag();
 
     // Exclude:
     //  - patch set, which may be null on ReviewDb side but not NoteDb
@@ -594,14 +539,12 @@
     //  - writtenOn, which is fuzzy
   }
 
-  private static void diffChangeMessages(List<String> diffs,
-      ChangeBundle bundleA, ChangeBundle bundleB) {
+  private static void diffChangeMessages(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
     if (bundleA.source == REVIEW_DB && bundleB.source == REVIEW_DB) {
       // Both came from ReviewDb: check all fields exactly.
-      Map<ChangeMessage.Key, ChangeMessage> as =
-          changeMessageMap(bundleA.filterChangeMessages());
-      Map<ChangeMessage.Key, ChangeMessage> bs =
-          changeMessageMap(bundleB.filterChangeMessages());
+      Map<ChangeMessage.Key, ChangeMessage> as = changeMessageMap(bundleA.filterChangeMessages());
+      Map<ChangeMessage.Key, ChangeMessage> bs = changeMessageMap(bundleB.filterChangeMessages());
 
       for (ChangeMessage.Key k : diffKeySets(diffs, as, bs)) {
         ChangeMessage a = as.get(k);
@@ -619,17 +562,16 @@
     // but easy to reason about.
     List<ChangeMessage> as = new LinkedList<>(bundleA.filterChangeMessages());
 
-    Multimap<ChangeMessageCandidate, ChangeMessage> bs =
-        LinkedListMultimap.create();
+    ListMultimap<ChangeMessageCandidate, ChangeMessage> bs = LinkedListMultimap.create();
     for (ChangeMessage b : bundleB.filterChangeMessages()) {
       bs.put(ChangeMessageCandidate.create(b), b);
     }
 
     Iterator<ChangeMessage> ait = as.iterator();
-    A: while (ait.hasNext()) {
+    A:
+    while (ait.hasNext()) {
       ChangeMessage a = ait.next();
-      Iterator<ChangeMessage> bit =
-          bs.get(ChangeMessageCandidate.create(a)).iterator();
+      Iterator<ChangeMessage> bit = bs.get(ChangeMessageCandidate.create(a)).iterator();
       while (bit.hasNext()) {
         ChangeMessage b = bit.next();
         if (changeMessagesMatch(bundleA, a, bundleB, b)) {
@@ -643,8 +585,8 @@
     if (as.isEmpty() && bs.isEmpty()) {
       return;
     }
-    StringBuilder sb = new StringBuilder("ChangeMessages differ for Change.Id ")
-        .append(id).append('\n');
+    StringBuilder sb =
+        new StringBuilder("ChangeMessages differ for Change.Id ").append(id).append('\n');
     if (!as.isEmpty()) {
       sb.append("Only in A:");
       for (ChangeMessage cm : as) {
@@ -664,39 +606,91 @@
   }
 
   private static boolean changeMessagesMatch(
-      ChangeBundle bundleA, ChangeMessage a,
-      ChangeBundle bundleB, ChangeMessage b) {
+      ChangeBundle bundleA, ChangeMessage a, ChangeBundle bundleB, ChangeMessage b) {
     List<String> tempDiffs = new ArrayList<>();
     String temp = "temp";
 
+    // ReviewDb allows timestamps before patch set was created, but NoteDb
+    // truncates this to the patch set creation timestamp.
+    Timestamp ta = a.getWrittenOn();
+    Timestamp tb = b.getWrittenOn();
+    PatchSet psa = bundleA.patchSets.get(a.getPatchSetId());
+    PatchSet psb = bundleB.patchSets.get(b.getPatchSetId());
     boolean excludePatchSet = false;
+    boolean excludeWrittenOn = false;
     if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
       excludePatchSet = a.getPatchSetId() == null;
+      excludeWrittenOn =
+          psa != null
+              && psb != null
+              && ta.before(psa.getCreatedOn())
+              && tb.equals(psb.getCreatedOn());
     } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
       excludePatchSet = b.getPatchSetId() == null;
+      excludeWrittenOn =
+          psa != null
+              && psb != null
+              && tb.before(psb.getCreatedOn())
+              && ta.equals(psa.getCreatedOn());
     }
 
     List<String> exclude = Lists.newArrayList("key");
     if (excludePatchSet) {
       exclude.add("patchset");
     }
+    if (excludeWrittenOn) {
+      exclude.add("writtenOn");
+    }
 
-    diffColumnsExcluding(
-        tempDiffs, ChangeMessage.class, temp, bundleA, a, bundleB, b, exclude);
+    diffColumnsExcluding(tempDiffs, ChangeMessage.class, temp, bundleA, a, bundleB, b, exclude);
     return tempDiffs.isEmpty();
   }
 
-  private static void diffPatchSets(List<String> diffs, ChangeBundle bundleA,
-      ChangeBundle bundleB) {
-    Map<PatchSet.Id, PatchSet> as = bundleA.filterPatchSets();
-    Map<PatchSet.Id, PatchSet> bs = bundleB.filterPatchSets();
-    for (PatchSet.Id id : diffKeySets(diffs, as, bs)) {
+  private static void diffPatchSets(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<PatchSet.Id, PatchSet> as = bundleA.patchSets;
+    Map<PatchSet.Id, PatchSet> bs = bundleB.patchSets;
+    Set<PatchSet.Id> ids = diffKeySets(diffs, as, bs);
+
+    // Old versions of Gerrit had a bug that created patch sets during
+    // rebase or submission with a createdOn timestamp earlier than the patch
+    // set it was replacing. (In the cases I examined, it was equal to createdOn
+    // for the change, but we're not counting on this exact behavior.)
+    //
+    // ChangeRebuilder ensures patch set events come out in order, but it's hard
+    // to predict what the resulting timestamps would look like. So, completely
+    // ignore the createdOn timestamps if both:
+    //   * ReviewDb timestamps are non-monotonic.
+    //   * NoteDb timestamps are monotonic.
+    boolean excludeCreatedOn = false;
+    if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+      excludeCreatedOn = !createdOnIsMonotonic(as, ids) && createdOnIsMonotonic(bs, ids);
+    } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+      excludeCreatedOn = createdOnIsMonotonic(as, ids) && !createdOnIsMonotonic(bs, ids);
+    }
+
+    for (PatchSet.Id id : ids) {
       PatchSet a = as.get(id);
       PatchSet b = bs.get(id);
       String desc = describe(id);
       String pushCertField = "pushCertificate";
-      diffColumnsExcluding(diffs, PatchSet.class, desc, bundleA, a, bundleB, b,
-          pushCertField);
+
+      boolean excludeDesc = false;
+      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+        excludeDesc = Objects.equals(trimOrNull(a.getDescription()), b.getDescription());
+      } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+        excludeDesc = Objects.equals(a.getDescription(), trimOrNull(b.getDescription()));
+      }
+
+      List<String> exclude = Lists.newArrayList(pushCertField);
+      if (excludeCreatedOn) {
+        exclude.add("createdOn");
+      }
+      if (excludeDesc) {
+        exclude.add("description");
+      }
+
+      diffColumnsExcluding(diffs, PatchSet.class, desc, bundleA, a, bundleB, b, exclude);
       diffValues(diffs, desc, trimPushCert(a), trimPushCert(b), pushCertField);
     }
   }
@@ -708,32 +702,84 @@
     return CharMatcher.is('\n').trimTrailingFrom(ps.getPushCertificate());
   }
 
-  private static void diffPatchSetApprovals(List<String> diffs,
-      ChangeBundle bundleA, ChangeBundle bundleB) {
-    Map<PatchSetApproval.Key, PatchSetApproval> as =
-          bundleA.filterPatchSetApprovals();
-    Map<PatchSetApproval.Key, PatchSetApproval> bs =
-        bundleB.filterPatchSetApprovals();
+  private static boolean createdOnIsMonotonic(
+      Map<?, PatchSet> patchSets, Set<PatchSet.Id> limitToIds) {
+    List<PatchSet> orderedById =
+        patchSets.values().stream()
+            .filter(ps -> limitToIds.contains(ps.getId()))
+            .sorted(ChangeUtil.PS_ID_ORDER)
+            .collect(toList());
+    return Ordering.natural().onResultOf(PatchSet::getCreatedOn).isOrdered(orderedById);
+  }
+
+  private static void diffPatchSetApprovals(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<PatchSetApproval.Key, PatchSetApproval> as = bundleA.filterPatchSetApprovals();
+    Map<PatchSetApproval.Key, PatchSetApproval> bs = bundleB.filterPatchSetApprovals();
     for (PatchSetApproval.Key k : diffKeySets(diffs, as, bs)) {
       PatchSetApproval a = as.get(k);
       PatchSetApproval b = bs.get(k);
       String desc = describe(k);
-      diffColumns(diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b);
+
+      // ReviewDb allows timestamps before patch set was created, but NoteDb
+      // truncates this to the patch set creation timestamp.
+      //
+      // ChangeRebuilder ensures all post-submit approvals happen after the
+      // actual submit, so the timestamps may not line up. This shouldn't really
+      // happen, because postSubmit shouldn't be set in ReviewDb until after the
+      // change is submitted in ReviewDb, but you never know.
+      //
+      // Due to a quirk of PostReview, post-submit 0 votes might not have the
+      // postSubmit bit set in ReviewDb. As these are only used for tombstone
+      // purposes, ignore the postSubmit bit in NoteDb in this case.
+      Timestamp ta = a.getGranted();
+      Timestamp tb = b.getGranted();
+      PatchSet psa = checkNotNull(bundleA.patchSets.get(a.getPatchSetId()));
+      PatchSet psb = checkNotNull(bundleB.patchSets.get(b.getPatchSetId()));
+      boolean excludeGranted = false;
+      boolean excludePostSubmit = false;
+      List<String> exclude = new ArrayList<>(1);
+      if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+        excludeGranted =
+            (ta.before(psa.getCreatedOn()) && tb.equals(psb.getCreatedOn()))
+                || ta.compareTo(tb) < 0;
+        excludePostSubmit = a.getValue() == 0 && b.isPostSubmit();
+      } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+        excludeGranted =
+            (tb.before(psb.getCreatedOn()) && ta.equals(psa.getCreatedOn()))
+                || (tb.compareTo(ta) < 0);
+        excludePostSubmit = b.getValue() == 0 && a.isPostSubmit();
+      }
+
+      // Legacy submit approvals may or may not have tags associated with them,
+      // depending on whether ChangeRebuilder happened to group them with the
+      // status change.
+      boolean excludeTag =
+          bundleA.source != bundleB.source && a.isLegacySubmit() && b.isLegacySubmit();
+
+      if (excludeGranted) {
+        exclude.add("granted");
+      }
+      if (excludePostSubmit) {
+        exclude.add("postSubmit");
+      }
+      if (excludeTag) {
+        exclude.add("tag");
+      }
+
+      diffColumnsExcluding(diffs, PatchSetApproval.class, desc, bundleA, a, bundleB, b, exclude);
     }
   }
 
-  private static void diffReviewers(List<String> diffs,
-      ChangeBundle bundleA, ChangeBundle bundleB) {
-    diffSets(
-        diffs, bundleA.reviewers.all(), bundleB.reviewers.all(), "reviewer");
+  private static void diffReviewers(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    diffSets(diffs, bundleA.reviewers.all(), bundleB.reviewers.all(), "reviewer");
   }
 
-  private static void diffPatchLineComments(List<String> diffs,
-      ChangeBundle bundleA, ChangeBundle bundleB) {
-    Map<PatchLineComment.Key, PatchLineComment> as =
-        bundleA.filterPatchLineComments();
-    Map<PatchLineComment.Key, PatchLineComment> bs =
-        bundleB.filterPatchLineComments();
+  private static void diffPatchLineComments(
+      List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
+    Map<PatchLineComment.Key, PatchLineComment> as = bundleA.filterPatchLineComments();
+    Map<PatchLineComment.Key, PatchLineComment> bs = bundleB.filterPatchLineComments();
     for (PatchLineComment.Key k : diffKeySets(diffs, as, bs)) {
       PatchLineComment a = as.get(k);
       PatchLineComment b = bs.get(k);
@@ -742,18 +788,15 @@
     }
   }
 
-  private static <T> Set<T> diffKeySets(List<String> diffs, Map<T, ?> a,
-      Map<T, ?> b) {
+  private static <T> Set<T> diffKeySets(List<String> diffs, Map<T, ?> a, Map<T, ?> b) {
     if (a.isEmpty() && b.isEmpty()) {
       return a.keySet();
     }
-    String clazz =
-        keyClass((!a.isEmpty() ? a.keySet() : b.keySet()).iterator().next());
+    String clazz = keyClass((!a.isEmpty() ? a.keySet() : b.keySet()).iterator().next());
     return diffSets(diffs, a.keySet(), b.keySet(), clazz);
   }
 
-  private static <T> Set<T> diffSets(List<String> diffs, Set<T> as,
-      Set<T> bs, String desc) {
+  private static <T> Set<T> diffSets(List<String> diffs, Set<T> as, Set<T> bs, String desc) {
     if (as.isEmpty() && bs.isEmpty()) {
       return as;
     }
@@ -763,26 +806,42 @@
     if (aNotB.isEmpty() && bNotA.isEmpty()) {
       return as;
     }
-    diffs.add(desc + " sets differ: " + aNotB + " only in A; "
-        + bNotA + " only in B");
+    diffs.add(desc + " sets differ: " + aNotB + " only in A; " + bNotA + " only in B");
     return Sets.intersection(as, bs);
   }
 
-  private static <T> void diffColumns(List<String> diffs, Class<T> clazz,
-      String desc, ChangeBundle bundleA, T a, ChangeBundle bundleB, T b) {
+  private static <T> void diffColumns(
+      List<String> diffs,
+      Class<T> clazz,
+      String desc,
+      ChangeBundle bundleA,
+      T a,
+      ChangeBundle bundleB,
+      T b) {
     diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b);
   }
 
-  private static <T> void diffColumnsExcluding(List<String> diffs,
-      Class<T> clazz, String desc, ChangeBundle bundleA, T a,
-      ChangeBundle bundleB, T b, String... exclude) {
-    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b,
-        Arrays.asList(exclude));
+  private static <T> void diffColumnsExcluding(
+      List<String> diffs,
+      Class<T> clazz,
+      String desc,
+      ChangeBundle bundleA,
+      T a,
+      ChangeBundle bundleB,
+      T b,
+      String... exclude) {
+    diffColumnsExcluding(diffs, clazz, desc, bundleA, a, bundleB, b, Arrays.asList(exclude));
   }
 
-  private static <T> void diffColumnsExcluding(List<String> diffs,
-      Class<T> clazz, String desc, ChangeBundle bundleA, T a,
-      ChangeBundle bundleB, T b, Iterable<String> exclude) {
+  private static <T> void diffColumnsExcluding(
+      List<String> diffs,
+      Class<T> clazz,
+      String desc,
+      ChangeBundle bundleA,
+      T a,
+      ChangeBundle bundleB,
+      T b,
+      Iterable<String> exclude) {
     Set<String> toExclude = Sets.newLinkedHashSet(exclude);
     for (Field f : clazz.getDeclaredFields()) {
       Column col = f.getAnnotation(Column.class);
@@ -802,13 +861,20 @@
         throw new IllegalArgumentException(e);
       }
     }
-    checkArgument(toExclude.isEmpty(),
+    checkArgument(
+        toExclude.isEmpty(),
         "requested columns to exclude not present in %s: %s",
-        clazz.getSimpleName(), toExclude);
+        clazz.getSimpleName(),
+        toExclude);
   }
 
-  private static void diffTimestamps(List<String> diffs, String desc,
-      ChangeBundle bundleA, Object a, ChangeBundle bundleB, Object b,
+  private static void diffTimestamps(
+      List<String> diffs,
+      String desc,
+      ChangeBundle bundleA,
+      Object a,
+      ChangeBundle bundleB,
+      Object b,
       String field) {
     checkArgument(a.getClass() == b.getClass());
     Class<?> clazz = a.getClass();
@@ -821,50 +887,53 @@
       f.setAccessible(true);
       ta = (Timestamp) f.get(a);
       tb = (Timestamp) f.get(b);
-    } catch (IllegalAccessException | NoSuchFieldException
-        | SecurityException e) {
+    } catch (IllegalAccessException | NoSuchFieldException | SecurityException e) {
       throw new IllegalArgumentException(e);
     }
     diffTimestamps(diffs, desc, bundleA, ta, bundleB, tb, field);
   }
 
-  private static void diffTimestamps(List<String> diffs, String desc,
-      ChangeBundle bundleA, Timestamp ta, ChangeBundle bundleB, Timestamp tb,
+  private static void diffTimestamps(
+      List<String> diffs,
+      String desc,
+      ChangeBundle bundleA,
+      Timestamp ta,
+      ChangeBundle bundleB,
+      Timestamp tb,
       String fieldDesc) {
     if (bundleA.source == bundleB.source || ta == null || tb == null) {
       diffValues(diffs, desc, ta, tb, fieldDesc);
     } else if (bundleA.source == NOTE_DB) {
-      diffTimestamps(
-          diffs, desc,
-          bundleA.getChange(), ta,
-          bundleB.getChange(), tb,
-          fieldDesc);
+      diffTimestamps(diffs, desc, bundleA.getChange(), ta, bundleB.getChange(), tb, fieldDesc);
     } else {
-      diffTimestamps(
-          diffs, desc,
-          bundleB.getChange(), tb,
-          bundleA.getChange(), ta,
-          fieldDesc);
+      diffTimestamps(diffs, desc, bundleB.getChange(), tb, bundleA.getChange(), ta, fieldDesc);
     }
   }
 
-  private static boolean timestampsDiffer(ChangeBundle bundleA, Timestamp ta,
-      ChangeBundle bundleB, Timestamp tb) {
+  private static boolean timestampsDiffer(
+      ChangeBundle bundleA, Timestamp ta, ChangeBundle bundleB, Timestamp tb) {
     List<String> tempDiffs = new ArrayList<>(1);
     diffTimestamps(tempDiffs, "temp", bundleA, ta, bundleB, tb, "temp");
     return !tempDiffs.isEmpty();
   }
 
-  private static void diffTimestamps(List<String> diffs, String desc,
-      Change changeFromNoteDb, Timestamp tsFromNoteDb,
-      Change changeFromReviewDb, Timestamp tsFromReviewDb,
+  private static void diffTimestamps(
+      List<String> diffs,
+      String desc,
+      Change changeFromNoteDb,
+      Timestamp tsFromNoteDb,
+      Change changeFromReviewDb,
+      Timestamp tsFromReviewDb,
       String field) {
     // Because ChangeRebuilder may batch events together that are several
     // seconds apart, the timestamp in NoteDb may actually be several seconds
     // *earlier* than the timestamp in ReviewDb that it was converted from.
-    checkArgument(tsFromNoteDb.equals(roundToSecond(tsFromNoteDb)),
+    checkArgument(
+        tsFromNoteDb.equals(roundToSecond(tsFromNoteDb)),
         "%s from NoteDb has non-rounded %s timestamp: %s",
-        desc, field, tsFromNoteDb);
+        desc,
+        field,
+        tsFromNoteDb);
 
     if (tsFromReviewDb.before(changeFromReviewDb.getCreatedOn())
         && tsFromNoteDb.equals(changeFromNoteDb.getCreatedOn())) {
@@ -874,21 +943,26 @@
       return;
     }
 
-
     long delta = tsFromReviewDb.getTime() - tsFromNoteDb.getTime();
     long max = ChangeRebuilderImpl.MAX_WINDOW_MS;
     if (delta < 0 || delta > max) {
       diffs.add(
-          field + " differs for " + desc + " in NoteDb vs. ReviewDb:"
-          + " {" + tsFromNoteDb + "} != {" + tsFromReviewDb + "}");
+          field
+              + " differs for "
+              + desc
+              + " in NoteDb vs. ReviewDb:"
+              + " {"
+              + tsFromNoteDb
+              + "} != {"
+              + tsFromReviewDb
+              + "}");
     }
   }
 
-  private static void diffValues(List<String> diffs, String desc, Object va,
-      Object vb, String name) {
+  private static void diffValues(
+      List<String> diffs, String desc, Object va, Object vb, String name) {
     if (!Objects.equals(va, vb)) {
-      diffs.add(
-          name + " differs for " + desc + ": {" + va + "} != {" + vb + "}");
+      diffs.add(name + " differs for " + desc + ": {" + va + "} != {" + vb + "}");
     }
   }
 
@@ -899,8 +973,7 @@
   private static String keyClass(Object obj) {
     Class<?> clazz = obj.getClass();
     String name = clazz.getSimpleName();
-    checkArgument(name.endsWith("Key") || name.endsWith("Id"),
-        "not an Id/Key class: %s", name);
+    checkArgument(name.endsWith("Key") || name.endsWith("Id"), "not an Id/Key class: %s", name);
     if (name.equals("Key") || name.equals("Id")) {
       return clazz.getEnclosingClass().getSimpleName() + "." + name;
     } else if (name.startsWith("AutoValue_")) {
@@ -911,11 +984,21 @@
 
   @Override
   public String toString() {
-    return getClass().getSimpleName() + "{id=" + change.getId()
-        + ", ChangeMessage[" + changeMessages.size() + "]"
-        + ", PatchSet[" + patchSets.size() + "]"
-        + ", PatchSetApproval[" + patchSetApprovals.size() + "]"
-        + ", PatchLineComment[" + patchLineComments.size() + "]"
+    return getClass().getSimpleName()
+        + "{id="
+        + change.getId()
+        + ", ChangeMessage["
+        + changeMessages.size()
+        + "]"
+        + ", PatchSet["
+        + patchSets.size()
+        + "]"
+        + ", PatchSetApproval["
+        + patchSetApprovals.size()
+        + "]"
+        + ", PatchLineComment["
+        + patchLineComments.size()
+        + "]"
         + "}";
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
new file mode 100644
index 0000000..9e7a1fe1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.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.server.notedb;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmException;
+
+public interface ChangeBundleReader {
+  ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) throws OrmException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 7b59a47..428faef 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -29,18 +29,10 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -49,108 +41,140 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * A single delta to apply atomically to a change.
- * <p>
- * This delta contains only draft comments on a single patch set of a change by
- * a single author. This delta will become a single commit in the All-Users
- * repository.
- * <p>
- * This class is not thread safe.
+ *
+ * <p>This delta contains only draft comments on a single patch set of a change by a single author.
+ * This delta will become a single commit in the All-Users repository.
+ *
+ * <p>This class is not thread safe.
  */
 public class ChangeDraftUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeDraftUpdate create(ChangeNotes notes, Account.Id accountId,
-        PersonIdent authorIdent, Date when);
-    ChangeDraftUpdate create(Change change, Account.Id accountId,
-        PersonIdent authorIdent, Date when);
+    ChangeDraftUpdate create(
+        ChangeNotes notes,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+
+    ChangeDraftUpdate create(
+        Change change,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
   }
 
   @AutoValue
   abstract static class Key {
-    abstract RevId revId();
-    abstract PatchLineComment.Key key();
+    abstract String revId();
+
+    abstract Comment.Key key();
   }
 
-  private static Key key(PatchLineComment c) {
-    return new AutoValue_ChangeDraftUpdate_Key(c.getRevId(), c.getKey());
+  private static Key key(Comment c) {
+    return new AutoValue_ChangeDraftUpdate_Key(c.revId, c.key);
   }
 
   private final AllUsersName draftsProject;
 
-  private List<PatchLineComment> put = new ArrayList<>();
+  private List<Comment> put = new ArrayList<>();
   private Set<Key> delete = new HashSet<>();
 
   @AssistedInject
   private ChangeDraftUpdate(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
       NotesMigration migration,
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       @Assisted ChangeNotes notes,
-      @Assisted Account.Id accountId,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
-    super(migration, noteUtil, serverIdent, anonymousCowardName, notes, null,
-        accountId, authorIdent, when);
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        anonymousCowardName,
+        notes,
+        null,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
     this.draftsProject = allUsers;
   }
 
   @AssistedInject
   private ChangeDraftUpdate(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
       NotesMigration migration,
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
-      @Assisted Account.Id accountId,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
-    super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
-        accountId, authorIdent, when);
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        anonymousCowardName,
+        null,
+        change,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
     this.draftsProject = allUsers;
   }
 
-  public void putComment(PatchLineComment c) {
+  public void putComment(Comment c) {
     verifyComment(c);
-    checkArgument(c.getStatus() == PatchLineComment.Status.DRAFT,
-        "Cannot insert a published comment into a ChangeDraftUpdate");
     put.add(c);
   }
 
-  public void deleteComment(PatchLineComment c) {
+  public void deleteComment(Comment c) {
     verifyComment(c);
     delete.add(key(c));
   }
 
-  public void deleteComment(RevId revId, PatchLineComment.Key key) {
+  public void deleteComment(String revId, Comment.Key key) {
     delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
   }
 
-  private void verifyComment(PatchLineComment comment) {
-    checkArgument(comment.getAuthor().equals(accountId),
-        "The author for the following comment does not match the author of"
-        + " this ChangeDraftUpdate (%s): %s", accountId, comment);
-  }
-
-  private CommitBuilder storeCommentsInNotes(RevWalk rw, ObjectInserter ins,
-      ObjectId curr, CommitBuilder cb)
+  private CommitBuilder storeCommentsInNotes(
+      RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, OrmException, IOException {
-    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
-    Set<RevId> updatedRevs =
-        Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
+    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
-    for (PatchLineComment c : put) {
+    for (Comment c : put) {
       if (!delete.contains(key(c))) {
-        cache.get(c.getRevId()).putComment(c);
+        cache.get(new RevId(c.revId)).putComment(c);
       }
     }
     for (Key k : delete) {
-      cache.get(k.revId()).deleteComment(k.key());
+      cache.get(new RevId(k.revId())).deleteComment(k.key());
     }
 
     Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
@@ -159,7 +183,7 @@
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       updatedRevs.add(e.getKey());
       ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data = e.getValue().build(noteUtil);
+      byte[] data = e.getValue().build(noteUtil, noteUtil.getWriteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
@@ -190,7 +214,7 @@
     return cb;
   }
 
-  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
+  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
       throws ConfigInvalidException, OrmException, IOException {
     if (migration.readChanges()) {
       // If reading from changes is enabled, then the old DraftCommentNotes
@@ -198,12 +222,10 @@
       // hasn't advanced.
       ChangeNotes changeNotes = getNotes();
       if (changeNotes != null) {
-        DraftCommentNotes draftNotes =
-            changeNotes.load().getDraftCommentNotes();
+        DraftCommentNotes draftNotes = changeNotes.load().getDraftCommentNotes();
         if (draftNotes != null) {
-          ObjectId idFromNotes =
-              firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
-          RevisionNoteMap rnm = draftNotes.getRevisionNoteMap();
+          ObjectId idFromNotes = firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
+          RevisionNoteMap<ChangeRevisionNote> rnm = draftNotes.getRevisionNoteMap();
           if (idFromNotes.equals(curr) && rnm != null) {
             return rnm;
           }
@@ -219,12 +241,12 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil, getId(), rw.getObjectReader(), noteMap, true);
+        noteUtil, getId(), rw.getObjectReader(), noteMap, PatchLineComment.Status.DRAFT);
   }
 
   @Override
-  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins,
-      ObjectId curr) throws OrmException, IOException {
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException {
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage("Update draft comments");
     try {
@@ -246,7 +268,6 @@
 
   @Override
   public boolean isEmpty() {
-    return delete.isEmpty()
-        && put.isEmpty();
+    return delete.isEmpty() && put.isEmpty();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 4c1a734..c848987 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,30 +15,43 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.PatchLineCommentsUtil.PLC_ORDER;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 import static com.google.gerrit.server.notedb.ChangeNotes.parseException;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
 import com.google.inject.Inject;
-
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.sql.Timestamp;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.util.GitDateFormatter;
@@ -48,33 +61,26 @@
 import org.eclipse.jgit.util.QuotedString;
 import org.eclipse.jgit.util.RawParseUtils;
 
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.sql.Timestamp;
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-
 public class ChangeNoteUtil {
-  static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
-  static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
-  static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
-  static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
-  static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
-  static final FooterKey FOOTER_LABEL = new FooterKey("Label");
-  static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
-  static final FooterKey FOOTER_STATUS = new FooterKey("Status");
-  static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
-  static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
-  static final FooterKey FOOTER_SUBMITTED_WITH =
-      new FooterKey("Submitted-with");
-  static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
-  static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
+  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
+  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
+      new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
+  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
+  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
+  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
+  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
 
   private static final String AUTHOR = "Author";
   private static final String BASE_PATCH_SET = "Base-for-patch-set";
@@ -84,8 +90,10 @@
   private static final String PARENT = "Parent";
   private static final String PARENT_NUMBER = "Parent-number";
   private static final String PATCH_SET = "Patch-set";
+  private static final String REAL_AUTHOR = "Real-author";
   private static final String REVISION = "Revision";
   private static final String UUID = "UUID";
+  private static final String UNRESOLVED = "Unresolved";
   private static final String TAG = FOOTER_TAG.getName();
 
   public static String formatTime(PersonIdent ident, Timestamp t) {
@@ -95,29 +103,54 @@
     return dateFormatter.formatDate(newIdent);
   }
 
+  static Gson newGson() {
+    return new GsonBuilder()
+        .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
+        .setPrettyPrinting()
+        .create();
+  }
+
   private final AccountCache accountCache;
   private final PersonIdent serverIdent;
   private final String anonymousCowardName;
   private final String serverId;
+  private final Gson gson = newGson();
+  private final boolean writeJson;
 
   @Inject
-  public ChangeNoteUtil(AccountCache accountCache,
+  public ChangeNoteUtil(
+      AccountCache accountCache,
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
-      @GerritServerId String serverId) {
+      @GerritServerId String serverId,
+      @GerritServerConfig Config config) {
     this.accountCache = accountCache;
     this.serverIdent = serverIdent;
     this.anonymousCowardName = anonymousCowardName;
     this.serverId = serverId;
+    this.writeJson = config.getBoolean("notedb", "writeJson", false);
   }
 
   @VisibleForTesting
-  public PersonIdent newIdent(Account author, Date when,
-      PersonIdent serverIdent, String anonymousCowardName) {
+  public PersonIdent newIdent(
+      Account author, Date when, PersonIdent serverIdent, String anonymousCowardName) {
     return new PersonIdent(
         author.getName(anonymousCowardName),
         author.getId().get() + "@" + serverId,
-        when, serverIdent.getTimeZone());
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  public boolean getWriteJson() {
+    return writeJson;
+  }
+
+  public Gson getGson() {
+    return gson;
+  }
+
+  public String getServerId() {
+    return serverId;
   }
 
   public Account.Id parseIdent(PersonIdent ident, Change.Id changeId)
@@ -133,8 +166,7 @@
         }
       }
     }
-    throw parseException(changeId, "invalid identity, expected <id>@%s: %s",
-        serverId, email);
+    throw parseException(changeId, "invalid identity, expected <id>@%s: %s", serverId, email);
   }
 
   private static boolean match(byte[] note, MutableInteger p, byte[] expected) {
@@ -142,13 +174,13 @@
     return m == p.value + expected.length;
   }
 
-  public List<PatchLineComment> parseNote(byte[] note, MutableInteger p,
-      Change.Id changeId, Status status) throws ConfigInvalidException {
+  public List<Comment> parseNote(byte[] note, MutableInteger p, Change.Id changeId)
+      throws ConfigInvalidException {
     if (p.value >= note.length) {
       return ImmutableList.of();
     }
-    Set<PatchLineComment.Key> seen = new HashSet<>();
-    List<PatchLineComment> result = new ArrayList<>();
+    Set<Comment.Key> seen = new HashSet<>();
+    List<Comment> result = new ArrayList<>();
     int sizeOfNote = note.length;
     byte[] psb = PATCH_SET.getBytes(UTF_8);
     byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
@@ -175,30 +207,32 @@
           parentNumber = parseParentNumber(note, p, changeId);
         }
       } else if (psId == null) {
-        throw parseException(changeId, "missing %s or %s header",
-            PATCH_SET, BASE_PATCH_SET);
+        throw parseException(changeId, "missing %s or %s header", PATCH_SET, BASE_PATCH_SET);
       }
 
-      PatchLineComment c = parseComment(
-          note, p, fileName, psId, revId, isForBase, parentNumber, status);
-      fileName = c.getKey().getParentKey().getFileName();
-      if (!seen.add(c.getKey())) {
-        throw parseException(
-            changeId, "multiple comments for %s in note", c.getKey());
+      Comment c = parseComment(note, p, fileName, psId, revId, isForBase, parentNumber);
+      fileName = c.key.filename;
+      if (!seen.add(c.key)) {
+        throw parseException(changeId, "multiple comments for %s in note", c.key);
       }
       result.add(c);
     }
     return result;
   }
 
-  private PatchLineComment parseComment(byte[] note, MutableInteger curr,
-      String currentFileName, PatchSet.Id psId, RevId revId, boolean isForBase,
-      Integer parentNumber, Status status) throws ConfigInvalidException {
+  private Comment parseComment(
+      byte[] note,
+      MutableInteger curr,
+      String currentFileName,
+      PatchSet.Id psId,
+      RevId revId,
+      boolean isForBase,
+      Integer parentNumber)
+      throws ConfigInvalidException {
     Change.Id changeId = psId.getParentKey();
 
     // Check if there is a new file.
-    boolean newFile =
-        (RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1;
+    boolean newFile = (RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1;
     if (newFile) {
       // If so, parse the new file name.
       currentFileName = parseFilename(note, curr, changeId);
@@ -212,19 +246,29 @@
     }
 
     Timestamp commentTime = parseTimestamp(note, curr, changeId);
-    Account.Id aId = parseAuthor(note, curr, changeId);
+    Account.Id aId = parseAuthor(note, curr, changeId, AUTHOR);
+    boolean hasRealAuthor =
+        (RawParseUtils.match(note, curr.value, REAL_AUTHOR.getBytes(UTF_8))) != -1;
+    Account.Id raId = null;
+    if (hasRealAuthor) {
+      raId = parseAuthor(note, curr, changeId, REAL_AUTHOR);
+    }
 
-    boolean hasParent =
-        (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
+    boolean hasParent = (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
     String parentUUID = null;
+    boolean unresolved = false;
     if (hasParent) {
       parentUUID = parseStringField(note, curr, changeId, PARENT);
     }
+    boolean hasUnresolved =
+        (RawParseUtils.match(note, curr.value, UNRESOLVED.getBytes(UTF_8))) != -1;
+    if (hasUnresolved) {
+      unresolved = parseBooleanField(note, curr, changeId, UNRESOLVED);
+    }
 
     String uuid = parseStringField(note, curr, changeId, UUID);
 
-    boolean hasTag =
-        (RawParseUtils.match(note, curr.value, TAG.getBytes(UTF_8))) != -1;
+    boolean hasTag = (RawParseUtils.match(note, curr.value, TAG.getBytes(UTF_8))) != -1;
     String tag = null;
     if (hasTag) {
       tag = parseStringField(note, curr, changeId, TAG);
@@ -232,35 +276,38 @@
 
     int commentLength = parseCommentLength(note, curr, changeId);
 
-    String message = RawParseUtils.decode(
-        UTF_8, note, curr.value, curr.value + commentLength);
+    String message = RawParseUtils.decode(UTF_8, note, curr.value, curr.value + commentLength);
     checkResult(message, "message contents", changeId);
 
-    PatchLineComment plc = new PatchLineComment(
-        new PatchLineComment.Key(new Patch.Key(psId, currentFileName), uuid),
-        range.getEndLine(), aId, parentUUID, commentTime);
-    plc.setMessage(message);
-    plc.setTag(tag);
-
-    if (isForBase) {
-      plc.setSide((short) (parentNumber == null ? 0 : -parentNumber));
-    } else {
-      plc.setSide((short) 1);
+    Comment c =
+        new Comment(
+            new Comment.Key(uuid, currentFileName, psId.get()),
+            aId,
+            commentTime,
+            isForBase ? (short) (parentNumber == null ? 0 : -parentNumber) : (short) 1,
+            message,
+            serverId,
+            unresolved);
+    c.lineNbr = range.getEndLine();
+    c.parentUuid = parentUUID;
+    c.tag = tag;
+    c.setRevId(revId);
+    if (raId != null) {
+      c.setRealAuthor(raId);
     }
 
     if (range.getStartCharacter() != -1) {
-      plc.setRange(range);
+      c.setRange(range);
     }
-    plc.setRevId(revId);
-    plc.setStatus(status);
 
     curr.value = RawParseUtils.nextLF(note, curr.value + commentLength);
     curr.value = RawParseUtils.nextLF(note, curr.value);
-    return plc;
+    return c;
   }
 
-  private static String parseStringField(byte[] note, MutableInteger curr,
-      Change.Id changeId, String fieldName) throws ConfigInvalidException {
+  private static String parseStringField(
+      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
+      throws ConfigInvalidException {
     int endOfLine = RawParseUtils.nextLF(note, curr.value);
     checkHeaderLineFormat(note, curr, fieldName, changeId);
     int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
@@ -269,11 +316,10 @@
   }
 
   /**
-   * @return a comment range. If the comment range line in the note only has
-   *    one number, we return a CommentRange with that one number as the end
-   *    line and the other fields as -1. If the comment range line in the note
-   *    contains a whole comment range, then we return a CommentRange with all
-   *    fields set. If the line is not correctly formatted, return null.
+   * @return a comment range. If the comment range line in the note only has one number, we return a
+   *     CommentRange with that one number as the end line and the other fields as -1. If the
+   *     comment range line in the note contains a whole comment range, then we return a
+   *     CommentRange with all fields set. If the line is not correctly formatted, return null.
    */
   private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) {
     CommentRange range = new CommentRange(-1, -1, -1, -1);
@@ -328,14 +374,13 @@
     return range;
   }
 
-  private static PatchSet.Id parsePsId(byte[] note, MutableInteger curr,
-      Change.Id changeId, String fieldName) throws ConfigInvalidException {
+  private static PatchSet.Id parsePsId(
+      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
+      throws ConfigInvalidException {
     checkHeaderLineFormat(note, curr, fieldName, changeId);
-    int startOfPsId =
-        RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    int startOfPsId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
     MutableInteger i = new MutableInteger();
-    int patchSetId =
-        RawParseUtils.parseBase10(note, startOfPsId, i);
+    int patchSetId = RawParseUtils.parseBase10(note, startOfPsId, i);
     int endOfLine = RawParseUtils.nextLF(note, curr.value);
     if (i.value != endOfLine - 1) {
       throw parseException(changeId, "could not parse %s", fieldName);
@@ -345,8 +390,8 @@
     return new PatchSet.Id(changeId, patchSetId);
   }
 
-  private static Integer parseParentNumber(byte[] note, MutableInteger curr,
-      Change.Id changeId) throws ConfigInvalidException {
+  private static Integer parseParentNumber(byte[] note, MutableInteger curr, Change.Id changeId)
+      throws ConfigInvalidException {
     checkHeaderLineFormat(note, curr, PARENT_NUMBER, changeId);
 
     int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
@@ -359,14 +404,12 @@
     checkResult(parentNumber, "parent number", changeId);
     curr.value = endOfLine;
     return Integer.valueOf(parentNumber);
-
   }
 
-  private static String parseFilename(byte[] note, MutableInteger curr,
-      Change.Id changeId) throws ConfigInvalidException {
+  private static String parseFilename(byte[] note, MutableInteger curr, Change.Id changeId)
+      throws ConfigInvalidException {
     checkHeaderLineFormat(note, curr, FILE, changeId);
-    int startOfFileName =
-        RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
+    int startOfFileName = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
     int endOfLine = RawParseUtils.nextLF(note, curr.value);
     curr.value = endOfLine;
     curr.value = RawParseUtils.nextLF(note, curr.value);
@@ -374,15 +417,13 @@
         RawParseUtils.decode(UTF_8, note, startOfFileName, endOfLine - 1));
   }
 
-  private static Timestamp parseTimestamp(byte[] note, MutableInteger curr,
-      Change.Id changeId) throws ConfigInvalidException {
+  private static Timestamp parseTimestamp(byte[] note, MutableInteger curr, Change.Id changeId)
+      throws ConfigInvalidException {
     int endOfLine = RawParseUtils.nextLF(note, curr.value);
     Timestamp commentTime;
-    String dateString =
-        RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1);
+    String dateString = RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1);
     try {
-      commentTime = new Timestamp(
-          GitDateParser.parse(dateString, null, Locale.US).getTime());
+      commentTime = new Timestamp(GitDateParser.parse(dateString, null, Locale.US).getTime());
     } catch (ParseException e) {
       throw new ConfigInvalidException("could not parse comment timestamp", e);
     }
@@ -390,27 +431,24 @@
     return checkResult(commentTime, "comment timestamp", changeId);
   }
 
-  private Account.Id parseAuthor(byte[] note, MutableInteger curr,
-      Change.Id changeId) throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, AUTHOR, changeId);
-    int startOfAccountId =
-        RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
-    PersonIdent ident =
-        RawParseUtils.parsePersonIdent(note, startOfAccountId);
+  private Account.Id parseAuthor(
+      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
+      throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, fieldName, changeId);
+    int startOfAccountId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
+    PersonIdent ident = RawParseUtils.parsePersonIdent(note, startOfAccountId);
     Account.Id aId = parseIdent(ident, changeId);
     curr.value = RawParseUtils.nextLF(note, curr.value);
-    return checkResult(aId, "comment author", changeId);
+    return checkResult(aId, fieldName, changeId);
   }
 
-  private static int parseCommentLength(byte[] note, MutableInteger curr,
-      Change.Id changeId) throws ConfigInvalidException {
+  private static int parseCommentLength(byte[] note, MutableInteger curr, Change.Id changeId)
+      throws ConfigInvalidException {
     checkHeaderLineFormat(note, curr, LENGTH, changeId);
-    int startOfLength =
-        RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    int startOfLength = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
     MutableInteger i = new MutableInteger();
     i.value = startOfLength;
-    int commentLength =
-        RawParseUtils.parseBase10(note, startOfLength, i);
+    int commentLength = RawParseUtils.parseBase10(note, startOfLength, i);
     if (i.value == startOfLength) {
       throw parseException(changeId, "could not parse %s", LENGTH);
     }
@@ -422,8 +460,20 @@
     return checkResult(commentLength, "comment length", changeId);
   }
 
-  private static <T> T checkResult(T o, String fieldName,
-      Change.Id changeId) throws ConfigInvalidException {
+  private boolean parseBooleanField(
+      byte[] note, MutableInteger curr, Change.Id changeId, String fieldName)
+      throws ConfigInvalidException {
+    String str = parseStringField(note, curr, changeId, fieldName);
+    if ("true".equalsIgnoreCase(str)) {
+      return true;
+    } else if ("false".equalsIgnoreCase(str)) {
+      return false;
+    }
+    throw parseException(changeId, "invalid boolean for %s: %s", fieldName, str);
+  }
+
+  private static <T> T checkResult(T o, String fieldName, Change.Id changeId)
+      throws ConfigInvalidException {
     if (o == null) {
       throw parseException(changeId, "could not parse %s", fieldName);
     }
@@ -438,18 +488,17 @@
     return i;
   }
 
-  private void appendHeaderField(PrintWriter writer,
-      String field, String value) {
+  private void appendHeaderField(PrintWriter writer, String field, String value) {
     writer.print(field);
     writer.print(": ");
     writer.print(value);
     writer.print('\n');
   }
 
-  private static void checkHeaderLineFormat(byte[] note, MutableInteger curr,
-      String fieldName, Change.Id changeId) throws ConfigInvalidException {
-    boolean correct =
-        RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1;
+  private static void checkHeaderLineFormat(
+      byte[] note, MutableInteger curr, String fieldName, Change.Id changeId)
+      throws ConfigInvalidException {
+    boolean correct = RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1;
     int p = curr.value + fieldName.length();
     correct &= (p < note.length && note[p] == ':');
     p++;
@@ -460,57 +509,56 @@
   }
 
   /**
-   * Build a note that contains the metadata for and the contents of all of the
-   * comments in the given comments.
+   * Build a note that contains the metadata for and the contents of all of the comments in the
+   * given comments.
    *
-   * @param comments Comments to be written to the output stream, keyed by patch
-   *     set ID; multiple patch sets are allowed since base revisions may be
-   *     shared across patch sets. All of the comments must share the same
-   *     RevId, and all the comments for a given patch set must have the same
-   *     side.
+   * @param comments Comments to be written to the output stream, keyed by patch set ID; multiple
+   *     patch sets are allowed since base revisions may be shared across patch sets. All of the
+   *     comments must share the same RevId, and all the comments for a given patch set must have
+   *     the same side.
    * @param out output stream to write to.
    */
-  void buildNote(Multimap<PatchSet.Id, PatchLineComment> comments,
-      OutputStream out) {
+  void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) {
     if (comments.isEmpty()) {
       return;
     }
 
-    List<PatchSet.Id> psIds =
-        ReviewDbUtil.intKeyOrdering().sortedCopy(comments.keySet());
+    List<Integer> psIds = new ArrayList<>(comments.keySet());
+    Collections.sort(psIds);
 
     OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8);
     try (PrintWriter writer = new PrintWriter(streamWriter)) {
-      RevId revId = comments.values().iterator().next().getRevId();
-      appendHeaderField(writer, REVISION, revId.get());
+      String revId = comments.values().iterator().next().revId;
+      appendHeaderField(writer, REVISION, revId);
 
-      for (PatchSet.Id psId : psIds) {
-        List<PatchLineComment> psComments =
-            PLC_ORDER.sortedCopy(comments.get(psId));
-        PatchLineComment first = psComments.get(0);
+      for (int psId : psIds) {
+        List<Comment> psComments = COMMENT_ORDER.sortedCopy(comments.get(psId));
+        Comment first = psComments.get(0);
 
-        short side = first.getSide();
-        appendHeaderField(writer, side <= 0
-            ? BASE_PATCH_SET
-            : PATCH_SET,
-            Integer.toString(psId.get()));
+        short side = first.side;
+        appendHeaderField(writer, side <= 0 ? BASE_PATCH_SET : PATCH_SET, Integer.toString(psId));
         if (side < 0) {
           appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side));
         }
 
         String currentFilename = null;
 
-        for (PatchLineComment c : psComments) {
-          checkArgument(revId.equals(c.getRevId()),
+        for (Comment c : psComments) {
+          checkArgument(
+              revId.equals(c.revId),
               "All comments being added must have all the same RevId. The "
-              + "comment below does not have the same RevId as the others "
-              + "(%s).\n%s", revId, c);
-          checkArgument(side == c.getSide(),
+                  + "comment below does not have the same RevId as the others "
+                  + "(%s).\n%s",
+              revId,
+              c);
+          checkArgument(
+              side == c.side,
               "All comments being added must all have the same side. The "
-              + "comment below does not have the same side as the others "
-              + "(%s).\n%s", side, c);
-          String commentFilename = QuotedString.GIT_PATH.quote(
-              c.getKey().getParentKey().getFileName());
+                  + "comment below does not have the same side as the others "
+                  + "(%s).\n%s",
+              side,
+              c);
+          String commentFilename = QuotedString.GIT_PATH.quote(c.key.filename);
 
           if (!commentFilename.equals(currentFilename)) {
             currentFilename = commentFilename;
@@ -525,53 +573,72 @@
     }
   }
 
-  private void appendOneComment(PrintWriter writer, PatchLineComment c) {
+  private void appendOneComment(PrintWriter writer, Comment c) {
     // The CommentRange field for a comment is allowed to be null. If it is
     // null, then in the first line, we simply use the line number field for a
     // comment instead. If it isn't null, we write the comment range itself.
-    CommentRange range = c.getRange();
+    Comment.Range range = c.range;
     if (range != null) {
-      writer.print(range.getStartLine());
+      writer.print(range.startLine);
       writer.print(':');
-      writer.print(range.getStartCharacter());
+      writer.print(range.startChar);
       writer.print('-');
-      writer.print(range.getEndLine());
+      writer.print(range.endLine);
       writer.print(':');
-      writer.print(range.getEndCharacter());
+      writer.print(range.endChar);
     } else {
-      writer.print(c.getLine());
+      writer.print(c.lineNbr);
     }
     writer.print("\n");
 
-    writer.print(formatTime(serverIdent, c.getWrittenOn()));
+    writer.print(formatTime(serverIdent, c.writtenOn));
     writer.print("\n");
 
-    PersonIdent ident = newIdent(
-        accountCache.get(c.getAuthor()).getAccount(),
-        c.getWrittenOn(), serverIdent, anonymousCowardName);
+    appendIdent(writer, AUTHOR, c.author.getId(), c.writtenOn);
+    if (!c.getRealAuthor().equals(c.author)) {
+      appendIdent(writer, REAL_AUTHOR, c.getRealAuthor().getId(), c.writtenOn);
+    }
+
+    String parent = c.parentUuid;
+    if (parent != null) {
+      appendHeaderField(writer, PARENT, parent);
+    }
+
+    appendHeaderField(writer, UNRESOLVED, Boolean.toString(c.unresolved));
+    appendHeaderField(writer, UUID, c.key.uuid);
+
+    if (c.tag != null) {
+      appendHeaderField(writer, TAG, c.tag);
+    }
+
+    byte[] messageBytes = c.message.getBytes(UTF_8);
+    appendHeaderField(writer, LENGTH, Integer.toString(messageBytes.length));
+
+    writer.print(c.message);
+    writer.print("\n\n");
+  }
+
+  private void appendIdent(PrintWriter writer, String header, Account.Id id, Timestamp ts) {
+    PersonIdent ident =
+        newIdent(accountCache.get(id).getAccount(), ts, serverIdent, anonymousCowardName);
     StringBuilder name = new StringBuilder();
     PersonIdent.appendSanitized(name, ident.getName());
     name.append(" <");
     PersonIdent.appendSanitized(name, ident.getEmailAddress());
     name.append('>');
-    appendHeaderField(writer, AUTHOR, name.toString());
+    appendHeaderField(writer, header, name.toString());
+  }
 
-    String parent = c.getParentUuid();
-    if (parent != null) {
-      appendHeaderField(writer, PARENT, parent);
-    }
+  private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0");
 
-    appendHeaderField(writer, UUID, c.getKey().get());
-
-    if (c.getTag() != null) {
-      appendHeaderField(writer, TAG, c.getTag());
-    }
-
-    byte[] messageBytes = c.getMessage().getBytes(UTF_8);
-    appendHeaderField(writer, LENGTH,
-        Integer.toString(messageBytes.length));
-
-    writer.print(c.getMessage());
-    writer.print("\n\n");
+  static String sanitizeFooter(String value) {
+    // Remove characters that would confuse JGit's footer parser if they were
+    // included in footer values, for example by splitting the footer block into
+    // multiple paragraphs.
+    //
+    // One painful example: RevCommit#getShorMessage() might return a message
+    // containing "\r\r", which RevCommit#getFooterLines() will treat as an
+    // empty paragraph for the purposes of footer parsing.
+    return INVALID_FOOTER_CHARS.trimAndCollapseFrom(value, ' ');
   }
 }
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 6327682..9582fb3 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
@@ -19,39 +19,41 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Predicate;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 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.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
 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.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
 import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -60,14 +62,6 @@
 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.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -77,33 +71,31 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
   private static final Logger log = LoggerFactory.getLogger(ChangeNotes.class);
 
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
-      Ordering.natural().onResultOf(
-        new Function<PatchSetApproval, Timestamp>() {
-          @Override
-          public Timestamp apply(PatchSetApproval input) {
-            return input.getGranted();
-          }
-        });
+      Ordering.from(comparing(PatchSetApproval::getGranted));
 
   public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
-      Ordering.natural().onResultOf(
-        new Function<ChangeMessage, Timestamp>() {
-          @Override
-          public Timestamp apply(ChangeMessage input) {
-            return input.getWrittenOn();
-          }
-        });
+      Ordering.from(comparing(ChangeMessage::getWrittenOn));
 
-  public static ConfigInvalidException parseException(Change.Id changeId,
-      String fmt, Object... args) {
-    return new ConfigInvalidException("Change " + changeId + ": "
-        + String.format(fmt, args));
+  public static ConfigInvalidException parseException(
+      Change.Id changeId, String fmt, Object... args) {
+    return new ConfigInvalidException("Change " + changeId + ": " + String.format(fmt, args));
+  }
+
+  public static Change readOneReviewDbChange(ReviewDb db, Change.Id id) throws OrmException {
+    return ReviewDbUtil.unwrapDb(db).changes().get(id);
   }
 
   @Singleton
@@ -114,73 +106,88 @@
 
     @VisibleForTesting
     @Inject
-    public Factory(Args args,
-        Provider<InternalChangeQuery> queryProvider,
-        ProjectCache projectCache) {
+    public Factory(
+        Args args, Provider<InternalChangeQuery> queryProvider, ProjectCache projectCache) {
       this.args = args;
       this.queryProvider = queryProvider;
       this.projectCache = projectCache;
     }
 
-    public ChangeNotes createChecked(ReviewDb db, Change c)
-        throws OrmException, NoSuchChangeException {
+    public ChangeNotes createChecked(ReviewDb db, Change c) throws OrmException {
       return createChecked(db, c.getProject(), c.getId());
     }
 
-    public ChangeNotes createChecked(ReviewDb db, Project.NameKey project,
-        Change.Id changeId) throws OrmException, NoSuchChangeException {
-      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
-      if (change == null || !change.getProject().equals(project)) {
+    public ChangeNotes createChecked(ReviewDb db, Project.NameKey project, Change.Id changeId)
+        throws OrmException {
+      Change change = readOneReviewDbChange(db, changeId);
+      if (change == null) {
+        if (!args.migration.readChanges()) {
+          throw new NoSuchChangeException(changeId);
+        }
+        // Change isn't in ReviewDb, but its primary storage might be in NoteDb.
+        // Prepopulate the change exists with proper noteDbState field.
+        change = newNoteDbOnlyChange(project, changeId);
+      } else if (!change.getProject().equals(project)) {
         throw new NoSuchChangeException(changeId);
       }
       return new ChangeNotes(args, change).load();
     }
 
-    public ChangeNotes createChecked(Change.Id changeId)
-        throws OrmException, NoSuchChangeException {
+    public ChangeNotes createChecked(Change.Id changeId) throws OrmException {
       InternalChangeQuery query = queryProvider.get().noFields();
       List<ChangeData> changes = query.byLegacyChangeId(changeId);
       if (changes.isEmpty()) {
         throw new NoSuchChangeException(changeId);
       }
       if (changes.size() != 1) {
-        log.error(
-            String.format("Multiple changes found for %d", changeId.get()));
+        log.error("Multiple changes found for {}", changeId.get());
         throw new NoSuchChangeException(changeId);
       }
       return changes.get(0).notes();
     }
 
-    private Change loadChangeFromDb(ReviewDb db, Project.NameKey project,
-        Change.Id changeId) throws OrmException {
-      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
-      checkNotNull(change,
-          "change %s not found in ReviewDb", changeId);
-      checkArgument(change.getProject().equals(project),
-          "passed project %s when creating ChangeNotes for %s, but actual"
-          + " project is %s",
-          project, changeId, change.getProject());
-      // TODO: Throw NoSuchChangeException when the change is not found in the
-      // database
+    public static Change newNoteDbOnlyChange(Project.NameKey project, Change.Id changeId) {
+      Change change =
+          new Change(
+              null, changeId, null, new Branch.NameKey(project, "INVALID_NOTE_DB_ONLY"), null);
+      change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
       return change;
     }
 
-    public ChangeNotes create(ReviewDb db, Project.NameKey project,
-        Change.Id changeId) throws OrmException {
-      return new ChangeNotes(args, loadChangeFromDb(db, project, changeId))
+    private Change loadChangeFromDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
+        throws OrmException {
+      checkArgument(project != null, "project is required");
+      Change change = readOneReviewDbChange(db, changeId);
+
+      if (change == null) {
+        if (args.migration.readChanges()) {
+          return newNoteDbOnlyChange(project, changeId);
+        }
+        throw new NoSuchChangeException(changeId);
+      }
+      checkArgument(
+          change.getProject().equals(project),
+          "passed project %s when creating ChangeNotes for %s, but actual project is %s",
+          project,
+          changeId,
+          change.getProject());
+      return change;
+    }
+
+    public ChangeNotes create(ReviewDb db, Project.NameKey project, Change.Id changeId)
+        throws OrmException {
+      return new ChangeNotes(args, loadChangeFromDb(db, project, changeId)).load();
+    }
+
+    public ChangeNotes createWithAutoRebuildingDisabled(
+        ReviewDb db, Project.NameKey project, Change.Id changeId) throws OrmException {
+      return new ChangeNotes(args, loadChangeFromDb(db, project, changeId), true, false, null)
           .load();
     }
 
-    public ChangeNotes createWithAutoRebuildingDisabled(ReviewDb db,
-        Project.NameKey project, Change.Id changeId) throws OrmException {
-      return new ChangeNotes(
-          args, loadChangeFromDb(db, project, changeId), false, null).load();
-    }
-
     /**
-     * Create change notes for a change that was loaded from index. This method
-     * should only be used when database access is harmful and potentially stale
-     * data from the index is acceptable.
+     * Create change notes for a change that was loaded from index. This method should only be used
+     * when database access is harmful and potentially stale data from the index is acceptable.
      *
      * @param change change loaded from secondary index
      * @return change notes
@@ -189,40 +196,29 @@
       return new ChangeNotes(args, change);
     }
 
-    public ChangeNotes createForBatchUpdate(Change change) throws OrmException {
-      return new ChangeNotes(args, change, false, null).load();
+    public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist)
+        throws OrmException {
+      return new ChangeNotes(args, change, shouldExist, false, null).load();
     }
 
-    // TODO(dborowitz): Remove when deleting index schemas <27.
-    public ChangeNotes createFromIdOnlyWhenNoteDbDisabled(
-        ReviewDb db, Change.Id changeId) throws OrmException {
-      checkState(!args.migration.readChanges(), "do not call"
-          + " createFromIdOnlyWhenNoteDbDisabled when NoteDb is enabled");
-      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
-      checkNotNull(change,
-          "change %s not found in ReviewDb", changeId);
-      return new ChangeNotes(args, change).load();
-    }
-
-    public ChangeNotes createWithAutoRebuildingDisabled(Change change,
-        RefCache refs) throws OrmException {
-      return new ChangeNotes(args, change, false, refs).load();
+    public ChangeNotes createWithAutoRebuildingDisabled(Change change, RefCache refs)
+        throws OrmException {
+      return new ChangeNotes(args, change, true, false, refs).load();
     }
 
     // TODO(ekempin): Remove when database backend is deleted
     /**
-     * Instantiate ChangeNotes for a change that has been loaded by a batch read
-     * from the database.
+     * Instantiate ChangeNotes for a change that has been loaded by a batch read from the database.
      */
-    private ChangeNotes createFromChangeOnlyWhenNoteDbDisabled(Change change)
-        throws OrmException {
-      checkState(!args.migration.readChanges(), "do not call"
-          + " createFromChangeWhenNoteDbDisabled when NoteDb is enabled");
+    private ChangeNotes createFromChangeOnlyWhenNoteDbDisabled(Change change) throws OrmException {
+      checkState(
+          !args.migration.readChanges(),
+          "do not call createFromChangeWhenNoteDbDisabled when NoteDb is enabled");
       return new ChangeNotes(args, change).load();
     }
 
-    public List<ChangeNotes> create(ReviewDb db,
-        Collection<Change.Id> changeIds) throws OrmException {
+    public List<ChangeNotes> create(ReviewDb db, Collection<Change.Id> changeIds)
+        throws OrmException {
       List<ChangeNotes> notes = new ArrayList<>();
       if (args.migration.enabled()) {
         for (Change.Id changeId : changeIds) {
@@ -241,14 +237,17 @@
       return notes;
     }
 
-    public List<ChangeNotes> create(ReviewDb db, Project.NameKey project,
-        Collection<Change.Id> changeIds, Predicate<ChangeNotes> predicate)
-            throws OrmException {
+    public List<ChangeNotes> create(
+        ReviewDb db,
+        Project.NameKey project,
+        Collection<Change.Id> changeIds,
+        Predicate<ChangeNotes> predicate)
+        throws OrmException {
       List<ChangeNotes> notes = new ArrayList<>();
       if (args.migration.enabled()) {
         for (Change.Id cid : changeIds) {
           ChangeNotes cn = create(db, project, cid);
-          if (cn.getChange() != null && predicate.apply(cn)) {
+          if (cn.getChange() != null && predicate.test(cn)) {
             notes.add(cn);
           }
         }
@@ -258,7 +257,7 @@
       for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
         if (c != null && project.equals(c.getDest().getParentKey())) {
           ChangeNotes cn = createFromChangeOnlyWhenNoteDbDisabled(c);
-          if (predicate.apply(cn)) {
+          if (predicate.test(cn)) {
             notes.add(cn);
           }
         }
@@ -266,15 +265,16 @@
       return notes;
     }
 
-    public ListMultimap<Project.NameKey, ChangeNotes> create(ReviewDb db,
-        Predicate<ChangeNotes> predicate) throws IOException, OrmException {
-      ListMultimap<Project.NameKey, ChangeNotes> m = ArrayListMultimap.create();
+    public ListMultimap<Project.NameKey, ChangeNotes> create(
+        ReviewDb db, Predicate<ChangeNotes> predicate) throws IOException, OrmException {
+      ListMultimap<Project.NameKey, ChangeNotes> m =
+          MultimapBuilder.hashKeys().arrayListValues().build();
       if (args.migration.readChanges()) {
         for (Project.NameKey project : projectCache.all()) {
           try (Repository repo = args.repoManager.openRepository(project)) {
             List<ChangeNotes> changes = scanNoteDb(repo, db, project);
             for (ChangeNotes cn : changes) {
-              if (predicate.apply(cn)) {
+              if (predicate.test(cn)) {
                 m.put(project, cn);
               }
             }
@@ -283,7 +283,7 @@
       } else {
         for (Change change : ReviewDbUtil.unwrapDb(db).changes().all()) {
           ChangeNotes notes = createFromChangeOnlyWhenNoteDbDisabled(change);
-          if (predicate.apply(notes)) {
+          if (predicate.test(notes)) {
             m.put(change.getProject(), notes);
           }
         }
@@ -291,8 +291,8 @@
       return ImmutableListMultimap.copyOf(m);
     }
 
-    public List<ChangeNotes> scan(Repository repo, ReviewDb db,
-        Project.NameKey project) throws OrmException, IOException {
+    public List<ChangeNotes> scan(Repository repo, ReviewDb db, Project.NameKey project)
+        throws OrmException, IOException {
       if (!args.migration.readChanges()) {
         return scanDb(repo, db);
       }
@@ -314,35 +314,36 @@
       return notes;
     }
 
-    private List<ChangeNotes> scanNoteDb(Repository repo, ReviewDb db,
-        Project.NameKey project) throws OrmException, IOException {
+    private List<ChangeNotes> scanNoteDb(Repository repo, ReviewDb db, Project.NameKey project)
+        throws OrmException, IOException {
       Set<Change.Id> ids = scan(repo);
       List<ChangeNotes> changeNotes = new ArrayList<>(ids.size());
-      db = ReviewDbUtil.unwrapDb(db);
+      PrimaryStorage defaultStorage = args.migration.changePrimaryStorage();
       for (Change.Id id : ids) {
-        Change change = db.changes().get(id);
+        Change change = readOneReviewDbChange(db, id);
         if (change == null) {
-          log.warn("skipping change {} found in project {} " +
-              "but not in ReviewDb",
-              id, project);
-          continue;
+          if (defaultStorage == PrimaryStorage.REVIEW_DB) {
+            log.warn("skipping change {} found in project {} but not in ReviewDb", id, project);
+            continue;
+          }
+          // TODO(dborowitz): See discussion in BatchUpdate#newChangeContext.
+          change = newNoteDbOnlyChange(project, id);
         } else if (!change.getProject().equals(project)) {
           log.error(
-              "skipping change {} found in project {} " +
-              "because ReviewDb change has project {}",
-              id, project, change.getProject());
+              "skipping change {} found in project {} because ReviewDb change has project {}",
+              id,
+              project,
+              change.getProject());
           continue;
         }
         log.debug("adding change {} found in project {}", id, project);
         changeNotes.add(new ChangeNotes(args, change).load());
-
       }
       return changeNotes;
     }
 
     public static Set<Change.Id> scan(Repository repo) throws IOException {
-      Map<String, Ref> refs =
-          repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES);
+      Map<String, Ref> refs = repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES);
       Set<Change.Id> ids = new HashSet<>(refs.size());
       for (Ref r : refs.values()) {
         Change.Id id = Change.Id.fromRef(r.getName());
@@ -354,6 +355,7 @@
     }
   }
 
+  private final boolean shouldExist;
   private final RefCache refs;
 
   private Change change;
@@ -361,20 +363,28 @@
 
   // Parsed note map state, used by ChangeUpdate to make in-place editing of
   // notes easier.
-  RevisionNoteMap revisionNoteMap;
+  RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   private NoteDbUpdateManager.Result rebuildResult;
   private DraftCommentNotes draftCommentNotes;
+  private RobotCommentNotes robotCommentNotes;
+
+  // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
+  // ChangeNotesCache from handlers.
+  private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
+  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
+  private ImmutableSet<Comment.Key> commentKeys;
 
   @VisibleForTesting
   public ChangeNotes(Args args, Change change) {
-    this(args, change, true, null);
+    this(args, change, true, true, null);
   }
 
-  private ChangeNotes(Args args, Change change, boolean autoRebuild,
-      @Nullable RefCache refs) {
-    super(args, change.getId(), autoRebuild);
+  private ChangeNotes(
+      Args args, Change change, boolean shouldExist, boolean autoRebuild, @Nullable RefCache refs) {
+    super(args, change.getId(), PrimaryStorage.of(change), autoRebuild);
     this.change = new Change(change);
+    this.shouldExist = shouldExist;
     this.refs = refs;
   }
 
@@ -382,12 +392,32 @@
     return change;
   }
 
-  public ImmutableMap<PatchSet.Id, PatchSet> getPatchSets() {
-    return state.patchSets();
+  public ObjectId getMetaId() {
+    return state.metaId();
+  }
+
+  public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
+    if (patchSets == null) {
+      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
+          ImmutableSortedMap.orderedBy(comparing(PatchSet.Id::get));
+      for (Map.Entry<PatchSet.Id, PatchSet> e : state.patchSets()) {
+        b.put(e.getKey(), new PatchSet(e.getValue()));
+      }
+      patchSets = b.build();
+    }
+    return patchSets;
   }
 
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
-    return state.approvals();
+    if (approvals == null) {
+      ImmutableListMultimap.Builder<PatchSet.Id, PatchSetApproval> b =
+          ImmutableListMultimap.builder();
+      for (Map.Entry<PatchSet.Id, PatchSetApproval> e : state.approvals()) {
+        b.put(e.getKey(), new PatchSetApproval(e.getValue()));
+      }
+      approvals = b.build();
+    }
+    return approvals;
   }
 
   public ReviewerSet getReviewers() {
@@ -398,24 +428,24 @@
     return state.reviewerUpdates();
   }
 
-  /**
-   *
-   * @return a ImmutableSet of all hashtags for this change sorted in alphabetical order.
-   */
+  /** @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. */
+  public ImmutableSet<Account.Id> getPastAssignees() {
+    return state.pastAssignees();
+  }
+
+  /** @return a ImmutableSet of all hashtags for this change sorted in alphabetical order. */
   public ImmutableSet<String> getHashtags() {
     return ImmutableSortedSet.copyOf(state.hashtags());
   }
 
-  /**
-   * @return a list of all users who have ever been a reviewer on this change.
-   */
+  /** @return a list of all users who have ever been a reviewer on this change. */
   public ImmutableList<Account.Id> getAllPastReviewers() {
     return state.allPastReviewers();
   }
 
   /**
-   * @return submit records stored during the most recent submit; only for
-   *     changes that were actually submitted.
+   * @return submit records stored during the most recent submit; only for changes that were
+   *     actually submitted.
    */
   public ImmutableList<SubmitRecord> getSubmitRecords() {
     return state.submitRecords();
@@ -426,77 +456,88 @@
     return state.allChangeMessages();
   }
 
-  /**
-   * @return change messages by patch set, in chronological order, oldest
-   *     first.
-   */
-  public ImmutableListMultimap<PatchSet.Id, ChangeMessage>
-      getChangeMessagesByPatchSet() {
+  /** @return change messages by patch set, in chronological order, oldest first. */
+  public ImmutableListMultimap<PatchSet.Id, ChangeMessage> getChangeMessagesByPatchSet() {
     return state.changeMessagesByPatchSet();
   }
 
   /** @return inline comments on each revision. */
-  public ImmutableListMultimap<RevId, PatchLineComment> getComments() {
+  public ImmutableListMultimap<RevId, Comment> getComments() {
     return state.publishedComments();
   }
 
-  public ImmutableListMultimap<RevId, PatchLineComment> getDraftComments(
-      Account.Id author) throws OrmException {
-    loadDraftComments(author);
-    final Multimap<RevId, PatchLineComment> published =
-        state.publishedComments();
-    // Filter out any draft comments that also exist in the published map, in
-    // case the update to All-Users to delete them during the publish operation
-    // failed.
-    Multimap<RevId, PatchLineComment> filtered = Multimaps.filterEntries(
-        draftCommentNotes.getComments(),
-        new Predicate<Map.Entry<RevId, PatchLineComment>>() {
-          @Override
-          public boolean apply(Map.Entry<RevId, PatchLineComment> in) {
-            for (PatchLineComment c : published.get(in.getKey())) {
-              if (c.getKey().equals(in.getValue().getKey())) {
-                return false;
-              }
-            }
-            return true;
-          }
-        });
+  public ImmutableSet<Comment.Key> getCommentKeys() {
+    if (commentKeys == null) {
+      ImmutableSet.Builder<Comment.Key> b = ImmutableSet.builder();
+      for (Comment c : getComments().values()) {
+        b.add(new Comment.Key(c.key));
+      }
+      commentKeys = b.build();
+    }
+    return commentKeys;
+  }
+
+  public ImmutableListMultimap<RevId, Comment> getDraftComments(Account.Id author)
+      throws OrmException {
+    return getDraftComments(author, null);
+  }
+
+  public ImmutableListMultimap<RevId, Comment> getDraftComments(
+      Account.Id author, @Nullable Ref ref) throws OrmException {
+    loadDraftComments(author, ref);
+    // Filter out any zombie draft comments. These are drafts that are also in
+    // the published map, and arise when the update to All-Users to delete them
+    // during the publish operation failed.
     return ImmutableListMultimap.copyOf(
-        filtered);
+        Multimaps.filterEntries(
+            draftCommentNotes.getComments(), e -> !getCommentKeys().contains(e.getValue().key)));
+  }
+
+  public ImmutableListMultimap<RevId, RobotComment> getRobotComments() throws OrmException {
+    loadRobotComments();
+    return robotCommentNotes.getComments();
   }
 
   /**
-   * If draft comments have already been loaded for this author, then they will
-   * not be reloaded. However, this method will load the comments if no draft
-   * comments have been loaded or if the caller would like the drafts for
-   * another author.
+   * If draft comments have already been loaded for this author, then they will not be reloaded.
+   * However, this method will load the comments if no draft comments have been loaded or if the
+   * caller would like the drafts for another author.
    */
-  private void loadDraftComments(Account.Id author)
-      throws OrmException {
-    if (draftCommentNotes == null ||
-        !author.equals(draftCommentNotes.getAuthor())) {
-      draftCommentNotes = new DraftCommentNotes(
-          args, change, author, autoRebuild, rebuildResult);
+  private void loadDraftComments(Account.Id author, @Nullable Ref ref) throws OrmException {
+    if (draftCommentNotes == null || !author.equals(draftCommentNotes.getAuthor()) || ref != null) {
+      draftCommentNotes =
+          new DraftCommentNotes(args, change, author, autoRebuild, rebuildResult, ref);
       draftCommentNotes.load();
     }
   }
 
+  private void loadRobotComments() throws OrmException {
+    if (robotCommentNotes == null) {
+      robotCommentNotes = new RobotCommentNotes(args, change);
+      robotCommentNotes.load();
+    }
+  }
+
   @VisibleForTesting
   DraftCommentNotes getDraftCommentNotes() {
     return draftCommentNotes;
   }
 
-  public boolean containsComment(PatchLineComment c) throws OrmException {
+  public RobotCommentNotes getRobotCommentNotes() {
+    return robotCommentNotes;
+  }
+
+  public boolean containsComment(Comment c) throws OrmException {
     if (containsCommentPublished(c)) {
       return true;
     }
-    loadDraftComments(c.getAuthor());
+    loadDraftComments(c.author.getId(), null);
     return draftCommentNotes.containsComment(c);
   }
 
-  public boolean containsCommentPublished(PatchLineComment c) {
-    for (PatchLineComment l : getComments().values()) {
-      if (c.getKey().equals(l.getKey())) {
+  public boolean containsCommentPublished(Comment c) {
+    for (Comment l : getComments().values()) {
+      if (c.key.equals(l.key)) {
         return true;
       }
     }
@@ -504,27 +545,36 @@
   }
 
   @Override
-  protected String getRefName() {
+  public String getRefName() {
     return changeMetaRef(getChangeId());
   }
 
   public PatchSet getCurrentPatchSet() {
     PatchSet.Id psId = change.currentPatchSetId();
-    return checkNotNull(state.patchSets().get(psId),
-        "missing current patch set %s", psId.get());
+    return checkNotNull(getPatchSets().get(psId), "missing current patch set %s", psId.get());
+  }
+
+  @VisibleForTesting
+  public Timestamp getReadOnlyUntil() {
+    return state.readOnlyUntil();
   }
 
   @Override
   protected void onLoad(LoadHandle handle)
-      throws IOException, ConfigInvalidException {
+      throws NoSuchChangeException, IOException, ConfigInvalidException {
     ObjectId rev = handle.id();
     if (rev == null) {
+      if (args.migration.readChanges()
+          && PrimaryStorage.of(change) == PrimaryStorage.NOTE_DB
+          && shouldExist) {
+        throw new NoSuchChangeException(getChangeId());
+      }
       loadDefaults();
       return;
     }
 
-    ChangeNotesCache.Value v = args.cache.get().get(
-        getProjectName(), getChangeId(), rev, handle.walk());
+    ChangeNotesCache.Value v =
+        args.cache.get().get(getProjectName(), getChangeId(), rev, handle.walk());
     state = v.state();
     state.copyColumnsTo(change);
     revisionNoteMap = v.revisionNoteMap();
@@ -542,18 +592,23 @@
 
   @Override
   protected ObjectId readRef(Repository repo) throws IOException {
-    return refs != null
-        ? refs.get(getRefName()).orNull()
-        : super.readRef(repo);
+    return refs != null ? refs.get(getRefName()).orElse(null) : super.readRef(repo);
   }
 
   @Override
-  protected LoadHandle openHandle(Repository repo) throws IOException {
+  protected LoadHandle openHandle(Repository repo) throws NoSuchChangeException, IOException {
     if (autoRebuild) {
       NoteDbChangeState state = NoteDbChangeState.parse(change);
       ObjectId id = readRef(repo);
-      if (state == null && id == null) {
-        return super.openHandle(repo, id);
+      if (id == null) {
+        if (state == null) {
+          return super.openHandle(repo, id);
+        } else if (shouldExist) {
+          // TODO(dborowitz): This means we have a state recorded in noteDbState but the ref doesn't
+          // exist for whatever reason. Doesn't this mean we should trigger an auto-rebuild, rather
+          // than throwing?
+          throw new NoSuchChangeException(getChangeId());
+        }
       }
       RefCache refs = this.refs != null ? this.refs : new RepoRefCache(repo);
       if (!NoteDbChangeState.isChangeUpToDate(state, refs, getChangeId())) {
@@ -563,8 +618,7 @@
     return super.openHandle(repo);
   }
 
-  private LoadHandle rebuildAndOpen(Repository repo, ObjectId oldId)
-      throws IOException {
+  private LoadHandle rebuildAndOpen(Repository repo, ObjectId oldId) throws IOException {
     Timer1.Context timer = args.metrics.autoRebuildLatency.start(CHANGES);
     try {
       Change.Id cid = getChangeId();
@@ -575,6 +629,7 @@
         if (manager == null) {
           return super.openHandle(repo, oldId); // May be null in tests.
         }
+        manager.setRefLogMessage("Auto-rebuilding change");
         r = manager.stageAndApplyDelta(change);
         try {
           rebuilder.execute(db, cid, manager);
@@ -589,27 +644,26 @@
           //
           // Parse notes from the staged result so we can return something useful
           // to the caller instead of throwing.
-          log.debug("Rebuilding change {} failed: {}",
-              getChangeId(), e.getMessage());
+          log.debug("Rebuilding change {} failed: {}", getChangeId(), e.getMessage());
           args.metrics.autoRebuildFailureCount.increment(CHANGES);
           rebuildResult = checkNotNull(r);
           checkNotNull(r.newState());
           checkNotNull(r.staged());
           return LoadHandle.create(
-              ChangeNotesCommit.newStagedRevWalk(
-                  repo, r.staged().changeObjects()),
+              ChangeNotesCommit.newStagedRevWalk(repo, r.staged().changeObjects()),
               r.newState().getChangeMetaId());
         }
       }
-      return LoadHandle.create(
-          ChangeNotesCommit.newRevWalk(repo), r.newState().getChangeMetaId());
+      return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), r.newState().getChangeMetaId());
     } catch (NoSuchChangeException e) {
       return super.openHandle(repo, oldId);
     } catch (OrmException e) {
       throw new IOException(e);
     } finally {
-      log.debug("Rebuilt change {} in project {} in {} ms",
-          getChangeId(), getProjectName(),
+      log.debug(
+          "Rebuilt change {} in project {} in {} ms",
+          getChangeId(),
+          getProjectName(),
           TimeUnit.MILLISECONDS.convert(timer.stop(), TimeUnit.NANOSECONDS));
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index a8f85a4..8c7e762 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -28,28 +28,27 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 
-import java.io.IOException;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-
 @Singleton
 public class ChangeNotesCache {
-  @VisibleForTesting
-  static final String CACHE_NAME = "change_notes";
+  @VisibleForTesting static final String CACHE_NAME = "change_notes";
 
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
         bind(ChangeNotesCache.class);
-        cache(CACHE_NAME,
-            Key.class,
-            ChangeNotesState.class)
-          .maximumWeight(1000);
+        cache(CACHE_NAME, Key.class, ChangeNotesState.class)
+            .weigher(Weigher.class)
+            .maximumWeight(10 << 20);
       }
     };
   }
@@ -57,30 +56,217 @@
   @AutoValue
   public abstract static class Key {
     abstract Project.NameKey project();
+
     abstract Change.Id changeId();
+
     abstract ObjectId id();
   }
 
+  public static class Weigher implements com.google.common.cache.Weigher<Key, ChangeNotesState> {
+    // Single object overhead.
+    private static final int O = 16;
+
+    // Single pointer overhead.
+    private static final int P = 8;
+
+    // Single IntKey overhead.
+    private static final int K = O + 4;
+
+    // Single Timestamp overhead.
+    private static final int T = O + 8;
+
+    @Override
+    public int weigh(Key key, ChangeNotesState state) {
+      // Take all columns and all collection sizes into account, but use
+      // estimated average element sizes rather than iterating over collections.
+      // Numbers are largely hand-wavy based on
+      // http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
+      return P
+          + O
+          + 20 // metaId
+          + K // changeId
+          + str(40) // changeKey
+          + T // createdOn
+          + T // lastUpdatedOn
+          + P
+          + K // owner
+          + P
+          + str(state.columns().branch())
+          + P
+          + patchSetId() // currentPatchSetId
+          + P
+          + str(state.columns().subject())
+          + P
+          + str(state.columns().topic())
+          + P
+          + str(state.columns().originalSubject())
+          + P
+          + str(state.columns().submissionId())
+          + ptr(state.columns().assignee(), K) // assignee
+          + P // status
+          + P
+          + set(state.pastAssignees(), K)
+          + P
+          + set(state.hashtags(), str(10))
+          + P
+          + list(state.patchSets(), patchSet())
+          + P
+          + list(state.allPastReviewers(), approval())
+          + P
+          + list(state.reviewerUpdates(), 4 * O + K + K + P)
+          + P
+          + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
+          + P
+          + list(state.allChangeMessages(), changeMessage())
+          // Just key overhead for map, already counted messages in previous.
+          + P
+          + map(state.changeMessagesByPatchSet().asMap(), patchSetId())
+          + P
+          + map(state.publishedComments().asMap(), comment());
+    }
+
+    private static int ptr(Object o, int size) {
+      return o != null ? P + size : P;
+    }
+
+    private static int str(String s) {
+      if (s == null) {
+        return P;
+      }
+      return str(s.length());
+    }
+
+    private static int str(int n) {
+      return 8 + 24 + 2 * n;
+    }
+
+    private static int patchSetId() {
+      return O + 4 + O + 4;
+    }
+
+    private static int set(Set<?> set, int elemSize) {
+      if (set == null) {
+        return P;
+      }
+      return hashtable(set.size(), elemSize);
+    }
+
+    private static int map(Map<?, ?> map, int elemSize) {
+      if (map == null) {
+        return P;
+      }
+      return hashtable(map.size(), elemSize);
+    }
+
+    private static int hashtable(int n, int elemSize) {
+      // Made up numbers.
+      int overhead = 32;
+      int elemOverhead = O + 32;
+      return overhead + elemOverhead * n * elemSize;
+    }
+
+    private static int list(List<?> list, int elemSize) {
+      if (list == null) {
+        return P;
+      }
+      return list(list.size(), elemSize);
+    }
+
+    private static int list(int n, int elemSize) {
+      return O + O + n * (P + elemSize);
+    }
+
+    private static int patchSet() {
+      return O
+          + P
+          + patchSetId()
+          + str(40) // revision
+          + P
+          + K // uploader
+          + P
+          + T // createdOn
+          + 1 // draft
+          + str(40) // groups
+          + P; // pushCertificate
+    }
+
+    private static int approval() {
+      return O
+          + P
+          + patchSetId()
+          + P
+          + K
+          + P
+          + O
+          + str(10)
+          + 2 // value
+          + P
+          + T // granted
+          + P // tag
+          + P; // realAccountId
+    }
+
+    private static int changeMessage() {
+      int key = K + str(20);
+      return O
+          + P
+          + key
+          + P
+          + K // author
+          + P
+          + T // writtenON
+          + str(64) // message
+          + P
+          + patchSetId()
+          + P
+          + P; // realAuthor
+    }
+
+    private static int comment() {
+      int key = P + str(20) + P + str(32) + 4;
+      int ident = O + 4;
+      return O
+          + P
+          + key
+          + 4 // lineNbr
+          + P
+          + ident // author
+          + P
+          + ident // realAuthor
+          + P
+          + T // writtenOn
+          + 2 // side
+          + str(32) // message
+          + str(10) // parentUuid
+          + (P + O + 4 + 4 + 4 + 4) / 2 // range on 50% of comments
+          + P // tag
+          + P
+          + str(40) // revId
+          + P
+          + str(36); // serverId
+    }
+  }
+
   @AutoValue
   abstract static class Value {
     abstract ChangeNotesState state();
 
     /**
      * The {@link RevisionNoteMap} produced while parsing this change.
-     * <p>
-     * These instances are mutable and non-threadsafe, so it is only safe to
-     * return it to the caller that actually incurred the cache miss. It is only
-     * used as an optimization; {@link ChangeNotes} is capable of lazily loading
-     * it as necessary.
+     *
+     * <p>These instances are mutable and non-threadsafe, so it is only safe to return it to the
+     * caller that actually incurred the cache miss. It is only used as an optimization; {@link
+     * ChangeNotes} is capable of lazily loading it as necessary.
      */
-    @Nullable abstract RevisionNoteMap revisionNoteMap();
+    @Nullable
+    abstract RevisionNoteMap<ChangeRevisionNote> revisionNoteMap();
   }
 
   private class Loader implements Callable<ChangeNotesState> {
     private final Key key;
     private final ChangeNotesRevWalk rw;
 
-    private RevisionNoteMap revisionNoteMap;
+    private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
     private Loader(Key key, ChangeNotesRevWalk rw) {
       this.key = key;
@@ -89,8 +275,8 @@
 
     @Override
     public ChangeNotesState call() throws ConfigInvalidException, IOException {
-      ChangeNotesParser parser = new ChangeNotesParser(
-          key.changeId(), key.id(), rw, args.noteUtil, args.metrics);
+      ChangeNotesParser parser =
+          new ChangeNotesParser(key.changeId(), key.id(), rw, args.noteUtil, args.metrics);
       ChangeNotesState result = parser.parseAll();
       // This assignment only happens if call() was actually called, which only
       // happens when Cache#get(K, Callable<V>) incurs a cache miss.
@@ -103,23 +289,21 @@
   private final Args args;
 
   @Inject
-  ChangeNotesCache(
-      @Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache,
-      Args args) {
+  ChangeNotesCache(@Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache, Args args) {
     this.cache = cache;
     this.args = args;
   }
 
-  Value get(Project.NameKey project, Change.Id changeId,
-      ObjectId metaId, ChangeNotesRevWalk rw) throws IOException {
+  Value get(Project.NameKey project, Change.Id changeId, ObjectId metaId, ChangeNotesRevWalk rw)
+      throws IOException {
     try {
-      Key key =
-          new AutoValue_ChangeNotesCache_Key(project, changeId, metaId.copy());
+      Key key = new AutoValue_ChangeNotesCache_Key(project, changeId, metaId.copy());
       Loader loader = new Loader(key, rw);
       ChangeNotesState s = cache.get(key, loader);
       return new AutoValue_ChangeNotesCache_Value(s, loader.revisionNoteMap);
     } catch (ExecutionException e) {
-      throw new IOException(String.format(
+      throw new IOException(
+          String.format(
               "Error loading %s in %s at %s",
               RefNames.changeMetaRef(changeId), project, metaId.name()),
           e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 272f3a6..78f6afc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -16,11 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.InsertedObject;
-
+import java.io.IOException;
+import java.util.List;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -31,15 +32,14 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.util.List;
-
 /**
  * Commit implementation with some optimizations for change notes parsing.
+ *
  * <p>
+ *
  * <ul>
- *   <li>Caches the result of {@link #getFooterLines()}, which is
- *     otherwise very wasteful with allocations.</li>
+ *   <li>Caches the result of {@link #getFooterLines()}, which is otherwise very wasteful with
+ *       allocations.
  * </ul>
  */
 public class ChangeNotesCommit extends RevCommit {
@@ -47,8 +47,8 @@
     return new ChangeNotesRevWalk(repo);
   }
 
-  public static ChangeNotesRevWalk newStagedRevWalk(Repository repo,
-      Iterable<InsertedObject> stagedObjs) {
+  public static ChangeNotesRevWalk newStagedRevWalk(
+      Repository repo, Iterable<InsertedObject> stagedObjs) {
     final InMemoryInserter ins = new InMemoryInserter(repo);
     for (InsertedObject obj : stagedObjs) {
       ins.insert(obj);
@@ -77,21 +77,21 @@
     }
 
     @Override
-    public ChangeNotesCommit next() throws MissingObjectException,
-         IncorrectObjectTypeException, IOException {
+    public ChangeNotesCommit next()
+        throws MissingObjectException, IncorrectObjectTypeException, IOException {
       return (ChangeNotesCommit) super.next();
     }
 
     @Override
-    public void markStart(RevCommit c) throws MissingObjectException,
-        IncorrectObjectTypeException, IOException {
+    public void markStart(RevCommit c)
+        throws MissingObjectException, IncorrectObjectTypeException, IOException {
       checkArgument(c instanceof ChangeNotesCommit);
       super.markStart(c);
     }
 
     @Override
-    public void markUninteresting(RevCommit c) throws MissingObjectException,
-        IncorrectObjectTypeException, IOException {
+    public void markUninteresting(RevCommit c)
+        throws MissingObjectException, IncorrectObjectTypeException, IOException {
       checkArgument(c instanceof ChangeNotesCommit);
       super.markUninteresting(c);
     }
@@ -103,8 +103,7 @@
 
     @Override
     public ChangeNotesCommit parseCommit(AnyObjectId id)
-        throws MissingObjectException, IncorrectObjectTypeException,
-        IOException {
+        throws MissingObjectException, IncorrectObjectTypeException, IOException {
       return (ChangeNotesCommit) super.parseCommit(id);
     }
   }
@@ -118,7 +117,7 @@
   public List<String> getFooterLineValues(FooterKey key) {
     if (footerLines == null) {
       List<FooterLine> src = getFooterLines();
-      footerLines = ArrayListMultimap.create(src.size(), 1);
+      footerLines = MultimapBuilder.hashKeys(src.size()).arrayListValues(1).build();
       for (FooterLine fl : src) {
         footerLines.put(fl.getKey().toLowerCase(), fl.getValue());
       }
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 8272aaf..cd51e0d 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
@@ -14,13 +14,18 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
@@ -28,20 +33,17 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static java.util.stream.Collectors.joining;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Enums;
-import com.google.common.base.Function;
-import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
 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.ImmutableSet;
 import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
 import com.google.common.collect.Tables;
@@ -52,6 +54,7 @@
 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.Comment;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -63,19 +66,10 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.FooterKey;
-import org.eclipse.jgit.util.RawParseUtils;
-
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.sql.Timestamp;
+import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -84,18 +78,44 @@
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
-import java.util.Map.Entry;
-import java.util.NavigableSet;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
-import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.function.Function;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.FooterKey;
+import org.eclipse.jgit.util.GitDateParser;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class ChangeNotesParser {
+  private static final Logger log = LoggerFactory.getLogger(ChangeNotesParser.class);
+
   // Sentinel RevId indicating a mutable field on a patch set was parsed, but
   // the parser does not yet know its commit SHA-1.
-  private static final RevId PARTIAL_PATCH_SET =
-      new RevId("INVALID PARTIAL PATCH SET");
+  private static final RevId PARTIAL_PATCH_SET = new RevId("INVALID PARTIAL PATCH SET");
+
+  @AutoValue
+  abstract static class ApprovalKey {
+    abstract PatchSet.Id psId();
+
+    abstract Account.Id accountId();
+
+    abstract String label();
+
+    private static ApprovalKey create(PatchSet.Id psId, Account.Id accountId, String label) {
+      return new AutoValue_ChangeNotesParser_ApprovalKey(psId, accountId, label);
+    }
+  }
 
   // Private final members initialized in the constructor.
   private final ChangeNoteUtil noteUtil;
@@ -110,19 +130,22 @@
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   private final List<SubmitRecord> submitRecords;
-  private final Multimap<RevId, PatchLineComment> comments;
-  private final TreeMap<PatchSet.Id, PatchSet> patchSets;
+  private final ListMultimap<RevId, Comment> comments;
+  private final Map<PatchSet.Id, PatchSet> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
-  private final Map<PatchSet.Id,
-      Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>>> approvals;
+  private final List<PatchSet.Id> currentPatchSets;
+  private final Map<ApprovalKey, PatchSetApproval> approvals;
+  private final List<PatchSetApproval> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
-  private final Multimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet;
+  private final ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet;
 
   // Non-final private members filled in during the parsing process.
   private String branch;
   private Change.Status status;
   private String topic;
+  private Optional<Account.Id> assignee;
+  private List<Account.Id> pastAssignees;
   private Set<String> hashtags;
   private Timestamp createdOn;
   private Timestamp lastUpdatedOn;
@@ -132,31 +155,36 @@
   private String originalSubject;
   private String submissionId;
   private String tag;
-  private PatchSet.Id currentPatchSetId;
-  private RevisionNoteMap revisionNoteMap;
+  private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
+  private Timestamp readOnlyUntil;
 
-  ChangeNotesParser(Change.Id changeId, ObjectId tip, ChangeNotesRevWalk walk,
-      ChangeNoteUtil noteUtil, NoteDbMetrics metrics) {
+  ChangeNotesParser(
+      Change.Id changeId,
+      ObjectId tip,
+      ChangeNotesRevWalk walk,
+      ChangeNoteUtil noteUtil,
+      NoteDbMetrics metrics) {
     this.id = changeId;
     this.tip = tip;
     this.walk = walk;
     this.noteUtil = noteUtil;
     this.metrics = metrics;
-    approvals = new HashMap<>();
+    approvals = new LinkedHashMap<>();
+    bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
     changeMessagesByPatchSet = LinkedListMultimap.create();
-    comments = ArrayListMultimap.create();
-    patchSets = Maps.newTreeMap(ReviewDbUtil.intKeyOrdering());
+    comments = MultimapBuilder.hashKeys().arrayListValues().build();
+    patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
+    currentPatchSets = new ArrayList<>();
   }
 
-  ChangeNotesState parseAll()
-      throws ConfigInvalidException, IOException {
+  ChangeNotesState parseAll() throws ConfigInvalidException, IOException {
     // Don't include initial parse in timer, as this might do more I/O to page
     // in the block containing most commits. Later reads are not guaranteed to
     // avoid I/O, but often should.
@@ -171,6 +199,7 @@
       parseNotes();
       allPastReviewers.addAll(reviewers.rowKeySet());
       pruneReviewers();
+
       updatePatchSetStates();
       checkMandatoryFooters();
     }
@@ -178,25 +207,27 @@
     return buildState();
   }
 
-  RevisionNoteMap getRevisionNoteMap() {
+  RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
     return revisionNoteMap;
   }
 
   private ChangeNotesState buildState() {
     return ChangeNotesState.create(
+        tip.copy(),
         id,
         new Change.Key(changeId),
         createdOn,
         lastUpdatedOn,
         ownerId,
         branch,
-        currentPatchSetId,
+        buildCurrentPatchSetId(),
         subject,
         topic,
         originalSubject,
         submissionId,
+        assignee != null ? assignee.orElse(null) : null,
         status,
-
+        Sets.newLinkedHashSet(Lists.reverse(pastAssignees)),
         hashtags,
         patchSets,
         buildApprovals(),
@@ -206,19 +237,33 @@
         submitRecords,
         buildAllMessages(),
         buildMessagesByPatchSet(),
-        comments);
+        comments,
+        readOnlyUntil);
   }
 
-  private Multimap<PatchSet.Id, PatchSetApproval> buildApprovals() {
-    Multimap<PatchSet.Id, PatchSetApproval> result =
-        ArrayListMultimap.create(approvals.keySet().size(), 3);
-    for (Table<?, ?, Optional<PatchSetApproval>> curr : approvals.values()) {
-      for (Optional<PatchSetApproval> psa : curr.values()) {
-        if (psa.isPresent()) {
-          result.put(psa.get().getPatchSetId(), psa.get());
-        }
+  private PatchSet.Id buildCurrentPatchSetId() {
+    // currentPatchSets are in parse order, i.e. newest first. Pick the first
+    // patch set that was marked as current, excluding deleted patch sets.
+    for (PatchSet.Id psId : currentPatchSets) {
+      if (patchSets.containsKey(psId)) {
+        return psId;
       }
     }
+    return null;
+  }
+
+  private ListMultimap<PatchSet.Id, PatchSetApproval> buildApprovals() {
+    ListMultimap<PatchSet.Id, PatchSetApproval> result =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    for (PatchSetApproval a : approvals.values()) {
+      if (!patchSets.containsKey(a.getPatchSetId())) {
+        continue; // Patch set deleted or missing.
+      } else if (allPastReviewers.contains(a.getAccountId())
+          && !reviewers.containsRow(a.getAccountId())) {
+        continue; // Reviewer was explicitly removed.
+      }
+      result.put(a.getPatchSetId(), a);
+    }
     for (Collection<PatchSetApproval> v : result.asMap().values()) {
       Collections.sort((List<PatchSetApproval>) v, ChangeNotes.PSA_BY_TIME);
     }
@@ -229,8 +274,7 @@
     List<ReviewerStatusUpdate> result = new ArrayList<>();
     HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
     for (ReviewerStatusUpdate u : Lists.reverse(reviewerUpdates)) {
-      if (!Objects.equals(ownerId, u.reviewer()) &&
-          lastState.get(u.reviewer()) != u.state()) {
+      if (!Objects.equals(ownerId, u.reviewer()) && lastState.get(u.reviewer()) != u.state()) {
         result.add(u);
         lastState.put(u.reviewer(), u.state());
       }
@@ -242,17 +286,15 @@
     return Lists.reverse(allChangeMessages);
   }
 
-  private Multimap<PatchSet.Id, ChangeMessage> buildMessagesByPatchSet() {
-    for (Collection<ChangeMessage> v :
-        changeMessagesByPatchSet.asMap().values()) {
+  private ListMultimap<PatchSet.Id, ChangeMessage> buildMessagesByPatchSet() {
+    for (Collection<ChangeMessage> v : changeMessagesByPatchSet.asMap().values()) {
       Collections.reverse((List<ChangeMessage>) v);
     }
     return changeMessagesByPatchSet;
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    Timestamp ts =
-        new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+    Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
 
     createdOn = ts;
     parseTag(commit);
@@ -260,15 +302,8 @@
     if (branch == null) {
       branch = parseBranch(commit);
     }
-    if (status == null) {
-      status = parseStatus(commit);
-    }
 
     PatchSet.Id psId = parsePatchSetId(commit);
-    if (currentPatchSetId == null || psId.get() > currentPatchSetId.get()) {
-      currentPatchSetId = psId;
-    }
-
     PatchSetState psState = parsePatchSetState(commit);
     if (psState != null) {
       if (!patchSetStates.containsKey(psId)) {
@@ -283,6 +318,7 @@
     if (accountId != null) {
       ownerId = accountId;
     }
+    Account.Id realAccountId = parseRealAccountId(commit, accountId);
 
     if (changeId == null) {
       changeId = parseChangeId(commit);
@@ -296,12 +332,13 @@
       originalSubject = currSubject;
     }
 
-    parseChangeMessage(psId, accountId, commit, ts);
+    parseChangeMessage(psId, accountId, realAccountId, commit, ts);
     if (topic == null) {
       topic = parseTopic(commit);
     }
 
     parseHashtags(commit);
+    parseAssignee(commit);
 
     if (submissionId == null) {
       submissionId = parseSubmissionId(commit);
@@ -312,6 +349,7 @@
       parsePatchSet(psId, currRev, accountId, ts);
     }
     parseGroups(psId, commit);
+    parseCurrentPatchSet(psId, commit);
 
     if (submitRecords.isEmpty()) {
       // Only parse the most recent set of submit records; any older ones are
@@ -319,8 +357,14 @@
       parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
     }
 
+    if (status == null) {
+      status = parseStatus(commit);
+    }
+
+    // Parse approvals after status to treat approvals in the same commit as
+    // "Status: merged" as non-post-submit.
     for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
-      parseApproval(psId, accountId, ts, line);
+      parseApproval(psId, accountId, realAccountId, ts, line);
     }
 
     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
@@ -331,34 +375,45 @@
       // behavior.
     }
 
+    if (readOnlyUntil == null) {
+      parseReadOnlyUntil(commit);
+    }
+
     if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
       lastUpdatedOn = ts;
     }
+
+    parseDescription(psId, commit);
   }
 
-  private String parseSubmissionId(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
   }
 
-  private String parseBranch(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private String parseBranch(ChangeNotesCommit commit) throws ConfigInvalidException {
     String branch = parseOneFooter(commit, FOOTER_BRANCH);
     return branch != null ? RefNames.fullName(branch) : null;
   }
 
-  private String parseChangeId(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private String parseChangeId(ChangeNotesCommit commit) throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_CHANGE_ID);
   }
 
-  private String parseSubject(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private String parseSubject(ChangeNotesCommit commit) throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_SUBJECT);
   }
 
-  private String parseTopic(ChangeNotesCommit commit)
+  private Account.Id parseRealAccountId(ChangeNotesCommit commit, Account.Id effectiveAccountId)
       throws ConfigInvalidException {
+    String realUser = parseOneFooter(commit, FOOTER_REAL_USER);
+    if (realUser == null) {
+      return effectiveAccountId;
+    }
+    PersonIdent ident = RawParseUtils.parsePersonIdent(realUser);
+    return noteUtil.parseIdent(ident, id);
+  }
+
+  private String parseTopic(ChangeNotesCommit commit) throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_TOPIC);
   }
 
@@ -373,17 +428,16 @@
     return footerLines.get(0);
   }
 
-  private String parseExactlyOneFooter(ChangeNotesCommit commit,
-      FooterKey footerKey) throws ConfigInvalidException {
+  private String parseExactlyOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
+      throws ConfigInvalidException {
     String line = parseOneFooter(commit, footerKey);
     if (line == null) {
-      throw expectedOneFooter(footerKey, Collections.<String> emptyList());
+      throw expectedOneFooter(footerKey, Collections.<String>emptyList());
     }
     return line;
   }
 
-  private ObjectId parseRevision(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private ObjectId parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException {
     String sha = parseOneFooter(commit, FOOTER_COMMIT);
     if (sha == null) {
       return null;
@@ -397,11 +451,10 @@
     }
   }
 
-  private void parsePatchSet(PatchSet.Id psId, ObjectId rev,
-      Account.Id accountId, Timestamp ts) throws ConfigInvalidException {
+  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Timestamp ts)
+      throws ConfigInvalidException {
     if (accountId == null) {
-      throw parseException(
-          "patch set %s requires an identified user as uploader", psId.get());
+      throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
     PatchSet ps = patchSets.get(psId);
     if (ps == null) {
@@ -440,8 +493,29 @@
     ps.setGroups(PatchSet.splitGroups(groupsStr));
   }
 
-  private void parseHashtags(ChangeNotesCommit commit)
+  private void parseCurrentPatchSet(PatchSet.Id psId, ChangeNotesCommit commit)
       throws ConfigInvalidException {
+    // This commit implies a new current patch set if either it creates a new
+    // patch set, or sets the current field explicitly.
+    boolean current = false;
+    if (parseOneFooter(commit, FOOTER_COMMIT) != null) {
+      current = true;
+    } else {
+      String currentStr = parseOneFooter(commit, FOOTER_CURRENT);
+      if (Boolean.TRUE.toString().equalsIgnoreCase(currentStr)) {
+        current = true;
+      } else if (currentStr != null) {
+        // Only "true" is allowed; unsetting the current patch set makes no
+        // sense.
+        throw invalidFooter(FOOTER_CURRENT, currentStr);
+      }
+    }
+    if (current) {
+      currentPatchSets.add(psId);
+    }
+  }
+
+  private void parseHashtags(ChangeNotesCommit commit) throws ConfigInvalidException {
     // Commits are parsed in reverse order and only the last set of hashtags
     // should be used.
     if (hashtags != null) {
@@ -459,8 +533,30 @@
     }
   }
 
-  private void parseTag(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private void parseAssignee(ChangeNotesCommit commit) throws ConfigInvalidException {
+    if (pastAssignees == null) {
+      pastAssignees = Lists.newArrayList();
+    }
+    String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
+    if (assigneeValue != null) {
+      Optional<Account.Id> parsedAssignee;
+      if (assigneeValue.equals("")) {
+        // Empty footer found, assignee deleted
+        parsedAssignee = Optional.empty();
+      } else {
+        PersonIdent ident = RawParseUtils.parsePersonIdent(assigneeValue);
+        parsedAssignee = Optional.ofNullable(noteUtil.parseIdent(ident, id));
+      }
+      if (assignee == null) {
+        assignee = parsedAssignee;
+      }
+      if (parsedAssignee.isPresent()) {
+        pastAssignees.add(parsedAssignee.get());
+      }
+    }
+  }
+
+  private void parseTag(ChangeNotesCommit commit) throws ConfigInvalidException {
     tag = null;
     List<String> tagLines = commit.getFooterLineValues(FOOTER_TAG);
     if (tagLines.isEmpty()) {
@@ -472,24 +568,34 @@
     }
   }
 
-  private Change.Status parseStatus(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private Change.Status parseStatus(ChangeNotesCommit commit) throws ConfigInvalidException {
     List<String> statusLines = commit.getFooterLineValues(FOOTER_STATUS);
     if (statusLines.isEmpty()) {
       return null;
     } else if (statusLines.size() > 1) {
       throw expectedOneFooter(FOOTER_STATUS, statusLines);
     }
-    Optional<Change.Status> status = Enums.getIfPresent(
-        Change.Status.class, statusLines.get(0).toUpperCase());
-    if (!status.isPresent()) {
+    Change.Status status =
+        Enums.getIfPresent(Change.Status.class, statusLines.get(0).toUpperCase()).orNull();
+    if (status == null) {
       throw invalidFooter(FOOTER_STATUS, statusLines.get(0));
     }
-    return status.get();
+    // All approvals after MERGED and before the next status change get the postSubmit
+    // bit. (Currently the state can't change from MERGED to something else, but just in case.) The
+    // exception is the legacy SUBM approval, which is never considered post-submit, but might end
+    // up sorted after the submit during rebuilding.
+    if (status == Change.Status.MERGED) {
+      for (PatchSetApproval psa : bufferedApprovals) {
+        if (!psa.isLegacySubmit()) {
+          psa.setPostSubmit(true);
+        }
+      }
+    }
+    bufferedApprovals.clear();
+    return status;
   }
 
-  private PatchSet.Id parsePatchSetId(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private PatchSet.Id parsePatchSetId(ChangeNotesCommit commit) throws ConfigInvalidException {
     String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
     int s = psIdLine.indexOf(' ');
     String psIdStr = s < 0 ? psIdLine : psIdLine.substring(0, s);
@@ -500,8 +606,7 @@
     return new PatchSet.Id(id, psId);
   }
 
-  private PatchSetState parsePatchSetState(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
     String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
     int s = psIdLine.indexOf(' ');
     if (s < 0) {
@@ -509,17 +614,45 @@
     }
     String withParens = psIdLine.substring(s + 1);
     if (withParens.startsWith("(") && withParens.endsWith(")")) {
-      Optional<PatchSetState> state = Enums.getIfPresent(PatchSetState.class,
-          withParens.substring(1, withParens.length() - 1).toUpperCase());
-      if (state.isPresent()) {
-        return state.get();
+      PatchSetState state =
+          Enums.getIfPresent(
+                  PatchSetState.class,
+                  withParens.substring(1, withParens.length() - 1).toUpperCase())
+              .orNull();
+      if (state != null) {
+        return state;
       }
     }
     throw invalidFooter(FOOTER_PATCH_SET, psIdLine);
   }
 
-  private void parseChangeMessage(PatchSet.Id psId,
-      Account.Id accountId, ChangeNotesCommit commit, Timestamp ts) {
+  private void parseDescription(PatchSet.Id psId, ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    List<String> descLines = commit.getFooterLineValues(FOOTER_PATCH_SET_DESCRIPTION);
+    if (descLines.isEmpty()) {
+      return;
+    } else if (descLines.size() == 1) {
+      String desc = descLines.get(0).trim();
+      PatchSet ps = patchSets.get(psId);
+      if (ps == null) {
+        ps = new PatchSet(psId);
+        ps.setRevision(PARTIAL_PATCH_SET);
+        patchSets.put(psId, ps);
+      }
+      if (ps.getDescription() == null) {
+        ps.setDescription(desc);
+      }
+    } else {
+      throw expectedOneFooter(FOOTER_PATCH_SET_DESCRIPTION, descLines);
+    }
+  }
+
+  private void parseChangeMessage(
+      PatchSet.Id psId,
+      Account.Id accountId,
+      Account.Id realAccountId,
+      ChangeNotesCommit commit,
+      Timestamp ts) {
     byte[] raw = commit.getRawBuffer();
     int size = raw.length;
     Charset enc = RawParseUtils.parseEncoding(raw);
@@ -537,9 +670,9 @@
     int changeMessageStart;
 
     if (raw[subjectEnd] == '\n') {
-      changeMessageStart = subjectEnd + 2; //\n\n ends paragraph
+      changeMessageStart = subjectEnd + 2; // \n\n ends paragraph
     } else if (raw[subjectEnd] == '\r') {
-      changeMessageStart = subjectEnd + 4; //\r\n\r\n ends paragraph
+      changeMessageStart = subjectEnd + 4; // \r\n\r\n ends paragraph
     } else {
       return;
     }
@@ -564,153 +697,157 @@
       return;
     }
 
-    String changeMsgString = RawParseUtils.decode(enc, raw,
-        changeMessageStart, changeMessageEnd + 1);
-    ChangeMessage changeMessage = new ChangeMessage(
-        new ChangeMessage.Key(psId.getParentKey(), commit.name()),
-        accountId,
-        ts,
-        psId);
+    String changeMsgString =
+        RawParseUtils.decode(enc, raw, changeMessageStart, changeMessageEnd + 1);
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(psId.getParentKey(), commit.name()), accountId, ts, psId);
     changeMessage.setMessage(changeMsgString);
     changeMessage.setTag(tag);
+    changeMessage.setRealAuthor(realAccountId);
     changeMessagesByPatchSet.put(psId, changeMessage);
     allChangeMessages.add(changeMessage);
   }
 
-  private void parseNotes()
-      throws IOException, ConfigInvalidException {
+  private void parseNotes() throws IOException, ConfigInvalidException {
     ObjectReader reader = walk.getObjectReader();
     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
-    revisionNoteMap = RevisionNoteMap.parse(
-        noteUtil, id, reader, NoteMap.read(reader, tipCommit), false);
-    Map<RevId, RevisionNote> rns = revisionNoteMap.revisionNotes;
+    revisionNoteMap =
+        RevisionNoteMap.parse(
+            noteUtil,
+            id,
+            reader,
+            NoteMap.read(reader, tipCommit),
+            PatchLineComment.Status.PUBLISHED);
+    Map<RevId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
-    for (Map.Entry<RevId, RevisionNote> e : rns.entrySet()) {
-      for (PatchLineComment plc : e.getValue().comments) {
-        comments.put(e.getKey(), plc);
+    for (Map.Entry<RevId, ChangeRevisionNote> e : rns.entrySet()) {
+      for (Comment c : e.getValue().getComments()) {
+        comments.put(e.getKey(), c);
       }
     }
 
     for (PatchSet ps : patchSets.values()) {
-      RevisionNote rn = rns.get(ps.getRevision());
-      if (rn != null && rn.pushCert != null) {
-        ps.setPushCertificate(rn.pushCert);
+      ChangeRevisionNote rn = rns.get(ps.getRevision());
+      if (rn != null && rn.getPushCert() != null) {
+        ps.setPushCertificate(rn.getPushCert());
       }
     }
   }
 
-  private void parseApproval(PatchSet.Id psId, Account.Id accountId,
-      Timestamp ts, String line) throws ConfigInvalidException {
+  private void parseApproval(
+      PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Timestamp ts, String line)
+      throws ConfigInvalidException {
     if (accountId == null) {
-      throw parseException(
-          "patch set %s requires an identified user as uploader", psId.get());
+      throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
+    PatchSetApproval psa;
     if (line.startsWith("-")) {
-      parseRemoveApproval(psId, accountId, line);
+      psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
     } else {
-      parseAddApproval(psId, accountId, ts, line);
+      psa = parseAddApproval(psId, accountId, realAccountId, ts, line);
     }
+    bufferedApprovals.add(psa);
   }
 
-  private void parseAddApproval(PatchSet.Id psId, Account.Id committerId,
-      Timestamp ts, String line) throws ConfigInvalidException {
-    Account.Id accountId;
+  private PatchSetApproval parseAddApproval(
+      PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
+      throws ConfigInvalidException {
+    // There are potentially 3 accounts involved here:
+    //  1. The account from the commit, which is the effective IdentifiedUser
+    //     that produced the update.
+    //  2. The account in the label footer itself, which is used during submit
+    //     to copy other users' labels to a new patch set.
+    //  3. The account in the Real-user footer, indicating that the whole
+    //     update operation was executed by this user on behalf of the effective
+    //     user.
+    Account.Id effectiveAccountId;
     String labelVoteStr;
     int s = line.indexOf(' ');
     if (s > 0) {
+      // Account in the label line (2) becomes the effective ID of the
+      // approval. If there is a real user (3) different from the commit user
+      // (2), we actually don't store that anywhere in this case; it's more
+      // important to record that the real user (3) actually initiated submit.
       labelVoteStr = line.substring(0, s);
       PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
       checkFooter(ident != null, FOOTER_LABEL, line);
-      accountId = noteUtil.parseIdent(ident, id);
+      effectiveAccountId = noteUtil.parseIdent(ident, id);
     } else {
       labelVoteStr = line;
-      accountId = committerId;
+      effectiveAccountId = committerId;
     }
 
     LabelVote l;
     try {
       l = LabelVote.parseWithEquals(labelVoteStr);
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe =
-          parseException("invalid %s: %s", FOOTER_LABEL, line);
+      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
       pe.initCause(e);
       throw pe;
     }
 
-    Entry<String, String> label = Maps.immutableEntry(l.label(), tag);
-    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
-        getApprovalsTableIfNoVotePresent(psId, accountId, label);
-    if (curr != null) {
-      PatchSetApproval psa = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              psId,
-              accountId,
-              new LabelId(l.label())),
-          l.value(),
-          ts);
-      psa.setTag(tag);
-      curr.put(accountId, label, Optional.of(psa));
+    PatchSetApproval psa =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(l.label())),
+            l.value(),
+            ts);
+    psa.setTag(tag);
+    if (!Objects.equals(realAccountId, committerId)) {
+      psa.setRealAccountId(realAccountId);
     }
+    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, l.label());
+    if (!approvals.containsKey(k)) {
+      approvals.put(k, psa);
+    }
+    return psa;
   }
 
-  private void parseRemoveApproval(PatchSet.Id psId, Account.Id committerId,
-      String line) throws ConfigInvalidException {
-    Account.Id accountId;
-    Entry<String, String> label;
+  private PatchSetApproval parseRemoveApproval(
+      PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
+      throws ConfigInvalidException {
+    // See comments in parseAddApproval about the various users involved.
+    Account.Id effectiveAccountId;
+    String label;
     int s = line.indexOf(' ');
     if (s > 0) {
-      label = Maps.immutableEntry(line.substring(1, s), tag);
+      label = line.substring(1, s);
       PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
       checkFooter(ident != null, FOOTER_LABEL, line);
-      accountId = noteUtil.parseIdent(ident, id);
+      effectiveAccountId = noteUtil.parseIdent(ident, id);
     } else {
-      label = Maps.immutableEntry(line.substring(1), tag);
-      accountId = committerId;
+      label = line.substring(1);
+      effectiveAccountId = committerId;
     }
 
     try {
-      LabelType.checkNameInternal(label.getKey());
+      LabelType.checkNameInternal(label);
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe =
-          parseException("invalid %s: %s", FOOTER_LABEL, line);
+      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
       pe.initCause(e);
       throw pe;
     }
 
-    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
-        getApprovalsTableIfNoVotePresent(psId, accountId, label);
-    if (curr != null) {
-      curr.put(accountId, label, Optional.<PatchSetApproval> absent());
+    // Store an actual 0-vote approval in the map for a removed approval, for
+    // several reasons:
+    //  - This is closer to the ReviewDb representation, which leads to less
+    //    confusion and special-casing of NoteDb.
+    //  - More importantly, ApprovalCopier needs an actual approval in order to
+    //    block copying an earlier approval over a later delete.
+    PatchSetApproval remove =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(label)), (short) 0, ts);
+    if (!Objects.equals(realAccountId, committerId)) {
+      remove.setRealAccountId(realAccountId);
     }
+    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, label);
+    if (!approvals.containsKey(k)) {
+      approvals.put(k, remove);
+    }
+    return remove;
   }
 
-  private Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>>
-      getApprovalsTableIfNoVotePresent(PatchSet.Id psId, Account.Id accountId,
-        Entry<String, String> label) {
-
-    Table<Account.Id, Entry<String, String>, Optional<PatchSetApproval>> curr =
-        approvals.get(psId);
-    if (curr != null) {
-      if (curr.contains(accountId, label)) {
-        return null;
-      }
-    } else {
-      curr = Tables.newCustomTable(
-          Maps.<Account.Id, Map<Entry<String, String>, Optional<PatchSetApproval>>>
-              newHashMapWithExpectedSize(2),
-          new Supplier<Map<Entry<String, String>, Optional<PatchSetApproval>>>() {
-            @Override
-            public Map<Entry<String, String>, Optional<PatchSetApproval>> get() {
-              return new LinkedHashMap<>();
-            }
-          });
-      approvals.put(psId, curr);
-    }
-    return curr;
-  }
-
-  private void parseSubmitRecords(List<String> lines)
-      throws ConfigInvalidException {
+  private void parseSubmitRecords(List<String> lines) throws ConfigInvalidException {
     SubmitRecord rec = null;
 
     for (String line : lines) {
@@ -720,10 +857,8 @@
         submitRecords.add(rec);
         int s = line.indexOf(' ');
         String statusStr = s >= 0 ? line.substring(0, s) : line;
-        Optional<SubmitRecord.Status> status =
-            Enums.getIfPresent(SubmitRecord.Status.class, statusStr);
-        checkFooter(status.isPresent(), FOOTER_SUBMITTED_WITH, line);
-        rec.status = status.get();
+        rec.status = Enums.getIfPresent(SubmitRecord.Status.class, statusStr).orNull();
+        checkFooter(rec.status != null, FOOTER_SUBMITTED_WITH, line);
         if (s >= 0) {
           rec.errorMessage = line.substring(s);
         }
@@ -735,15 +870,13 @@
         }
         rec.labels.add(label);
 
-        Optional<SubmitRecord.Label.Status> status = Enums.getIfPresent(
-            SubmitRecord.Label.Status.class, line.substring(0, c));
-        checkFooter(status.isPresent(), FOOTER_SUBMITTED_WITH, line);
-        label.status = status.get();
+        label.status =
+            Enums.getIfPresent(SubmitRecord.Label.Status.class, line.substring(0, c)).orNull();
+        checkFooter(label.status != null, FOOTER_SUBMITTED_WITH, line);
         int c2 = line.indexOf(": ", c + 2);
         if (c2 >= 0) {
           label.label = line.substring(c + 2, c2);
-          PersonIdent ident =
-              RawParseUtils.parsePersonIdent(line.substring(c2 + 2));
+          PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(c2 + 2));
           checkFooter(ident != null, FOOTER_SUBMITTED_WITH, line);
           label.appliedBy = noteUtil.parseIdent(ident, id);
         } else {
@@ -753,33 +886,44 @@
     }
   }
 
-  private Account.Id parseIdent(ChangeNotesCommit commit)
-      throws ConfigInvalidException {
+  private Account.Id parseIdent(ChangeNotesCommit commit) throws ConfigInvalidException {
     // Check if the author name/email is the same as the committer name/email,
     // i.e. was the server ident at the time this commit was made.
     PersonIdent a = commit.getAuthorIdent();
     PersonIdent c = commit.getCommitterIdent();
-    if (a.getName().equals(c.getName())
-        && a.getEmailAddress().equals(c.getEmailAddress())) {
+    if (a.getName().equals(c.getName()) && a.getEmailAddress().equals(c.getEmailAddress())) {
       return null;
     }
     return noteUtil.parseIdent(commit.getAuthorIdent(), id);
   }
 
-  private void parseReviewer(Timestamp ts, 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);
-    reviewerUpdates.add(
-        ReviewerStatusUpdate.create(ts, ownerId, accountId, state));
+    reviewerUpdates.add(ReviewerStatusUpdate.create(ts, ownerId, accountId, state));
     if (!reviewers.containsRow(accountId)) {
       reviewers.put(accountId, state, ts);
     }
   }
 
+  private void parseReadOnlyUntil(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String raw = parseOneFooter(commit, FOOTER_READ_ONLY_UNTIL);
+    if (raw == null) {
+      return;
+    }
+    try {
+      readOnlyUntil = new Timestamp(GitDateParser.parse(raw, null, Locale.US).getTime());
+    } catch (ParseException e) {
+      ConfigInvalidException cie = invalidFooter(FOOTER_READ_ONLY_UNTIL, raw);
+      cie.initCause(e);
+      throw cie;
+    }
+  }
+
   private void pruneReviewers() {
     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
         reviewers.cellSet().iterator();
@@ -787,25 +931,19 @@
       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.getRowKey());
-        }
       }
     }
   }
 
-  private void updatePatchSetStates() throws ConfigInvalidException {
-    for (PatchSet ps : patchSets.values()) {
+  private void updatePatchSetStates() {
+    Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
+    for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {
+      PatchSet ps = it.next();
       if (ps.getRevision().equals(PARTIAL_PATCH_SET)) {
-        throw parseException("No %s found for patch set %s",
-            FOOTER_COMMIT, ps.getPatchSetId());
+        missing.add(ps.getId());
+        it.remove();
       }
     }
-    if (patchSetStates.isEmpty()) {
-      return;
-    }
-
-    boolean deleted = false;
     for (Map.Entry<PatchSet.Id, PatchSetState> e : patchSetStates.entrySet()) {
       switch (e.getValue()) {
         case PUBLISHED:
@@ -813,7 +951,6 @@
           break;
 
         case DELETED:
-          deleted = true;
           patchSets.remove(e.getKey());
           break;
 
@@ -825,35 +962,41 @@
           break;
       }
     }
-    if (!deleted) {
-      return;
-    }
 
     // Post-process other collections to remove items corresponding to deleted
-    // patch sets. This is safer than trying to prevent insertion, as it will
-    // also filter out items racily added after the patch set was deleted.
-    NavigableSet<PatchSet.Id> all = patchSets.navigableKeySet();
-    if (!all.isEmpty()) {
-      currentPatchSetId = all.last();
-    } else {
-      currentPatchSetId = null;
-    }
-    approvals.keySet().retainAll(all);
-    changeMessagesByPatchSet.keys().retainAll(all);
+    // (or otherwise missing) patch sets. This is safer than trying to prevent
+    // insertion, as it will also filter out items racily added after the patch
+    // set was deleted.
+    changeMessagesByPatchSet.keys().retainAll(patchSets.keySet());
 
-    for (Iterator<ChangeMessage> it = allChangeMessages.iterator();
-        it.hasNext();) {
-      if (!all.contains(it.next().getPatchSetId())) {
+    int pruned =
+        pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
+    pruned +=
+        pruneEntitiesForMissingPatchSets(
+            comments.values(), c -> new PatchSet.Id(id, c.key.patchSetId), missing);
+    pruned +=
+        pruneEntitiesForMissingPatchSets(
+            approvals.values(), PatchSetApproval::getPatchSetId, missing);
+
+    if (!missing.isEmpty()) {
+      log.warn("ignoring {} additional entities due to missing patch sets: {}", pruned, missing);
+    }
+  }
+
+  private <T> int pruneEntitiesForMissingPatchSets(
+      Iterable<T> ents, Function<T, PatchSet.Id> psIdFunc, Set<PatchSet.Id> missing) {
+    int pruned = 0;
+    for (Iterator<T> it = ents.iterator(); it.hasNext(); ) {
+      PatchSet.Id psId = psIdFunc.apply(it.next());
+      if (!patchSets.containsKey(psId)) {
+        pruned++;
+        missing.add(psId);
         it.remove();
+      } else if (deletedPatchSets.contains(psId)) {
+        it.remove(); // Not an error we need to report, don't increment pruned.
       }
     }
-    for (Iterator<PatchLineComment> it = comments.values().iterator();
-        it.hasNext();) {
-      PatchSet.Id psId = it.next().getKey().getParentKey().getParentKey();
-      if (!all.contains(psId)) {
-        it.remove();
-      }
-    }
+    return pruned;
   }
 
   private void checkMandatoryFooters() throws ConfigInvalidException {
@@ -868,24 +1011,16 @@
       missing.add(FOOTER_SUBJECT);
     }
     if (!missing.isEmpty()) {
-      throw parseException("Missing footers: " + Joiner.on(", ")
-          .join(Lists.transform(missing, new Function<FooterKey, String>() {
-            @Override
-            public String apply(FooterKey input) {
-              return input.getName();
-            }
-          })));
+      throw parseException(
+          "Missing footers: " + missing.stream().map(FooterKey::getName).collect(joining(", ")));
     }
   }
 
-  private ConfigInvalidException expectedOneFooter(FooterKey footer,
-      List<String> actual) {
-    return parseException("missing or multiple %s: %s",
-        footer.getName(), actual);
+  private ConfigInvalidException expectedOneFooter(FooterKey footer, List<String> actual) {
+    return parseException("missing or multiple %s: %s", footer.getName(), actual);
   }
 
-  private ConfigInvalidException invalidFooter(FooterKey footer,
-      String actual) {
+  private ConfigInvalidException invalidFooter(FooterKey footer, String actual) {
     return parseException("invalid %s: %s", footer.getName(), actual);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 988184f..7b25bbd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -15,63 +15,67 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedMap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 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.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Immutable state associated with a change meta ref at a given commit.
- * <p>
- * One instance is the output of a single {@link ChangeNotesParser}, and
- * contains types required to support public methods on {@link ChangeNotes}. It
- * is intended to be cached in-process.
- * <p>
- * Note that {@link ChangeNotes} contains more than just a single {@code
- * ChangeNoteState}, such as per-draft information, so that class is not cached
- * directly.
+ *
+ * <p>One instance is the output of a single {@link ChangeNotesParser}, and contains types required
+ * to support public methods on {@link ChangeNotes}. It is intended to be cached in-process.
+ *
+ * <p>Note that {@link ChangeNotes} contains more than just a single {@code ChangeNoteState}, such
+ * as per-draft information, so that class is not cached directly.
  */
 @AutoValue
 public abstract class ChangeNotesState {
   static ChangeNotesState empty(Change change) {
     return new AutoValue_ChangeNotesState(
+        null,
         change.getId(),
         null,
-        ImmutableSet.<String>of(),
-        ImmutableSortedMap.<PatchSet.Id, PatchSet>of(),
-        ImmutableListMultimap.<PatchSet.Id, PatchSetApproval>of(),
+        ImmutableSet.of(),
+        ImmutableSet.of(),
+        ImmutableList.of(),
+        ImmutableList.of(),
         ReviewerSet.empty(),
-        ImmutableList.<Account.Id>of(),
-        ImmutableList.<ReviewerStatusUpdate>of(),
-        ImmutableList.<SubmitRecord>of(),
-        ImmutableList.<ChangeMessage>of(),
-        ImmutableListMultimap.<PatchSet.Id, ChangeMessage>of(),
-        ImmutableListMultimap.<RevId, PatchLineComment>of());
+        ImmutableList.of(),
+        ImmutableList.of(),
+        ImmutableList.of(),
+        ImmutableList.of(),
+        ImmutableListMultimap.of(),
+        ImmutableListMultimap.of(),
+        null);
   }
 
   static ChangeNotesState create(
+      @Nullable ObjectId metaId,
       Change.Id changeId,
       Change.Key changeKey,
       Timestamp createdOn,
@@ -83,21 +87,25 @@
       @Nullable String topic,
       @Nullable String originalSubject,
       @Nullable String submissionId,
+      @Nullable Account.Id assignee,
       @Nullable Change.Status status,
+      @Nullable Set<Account.Id> pastAssignees,
       @Nullable Set<String> hashtags,
       Map<PatchSet.Id, PatchSet> patchSets,
-      Multimap<PatchSet.Id, PatchSetApproval> approvals,
+      ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> allChangeMessages,
-      Multimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
-      Multimap<RevId, PatchLineComment> publishedComments) {
+      ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
+      ListMultimap<RevId, Comment> publishedComments,
+      @Nullable Timestamp readOnlyUntil) {
     if (hashtags == null) {
       hashtags = ImmutableSet.of();
     }
     return new AutoValue_ChangeNotesState(
+        metaId,
         changeId,
         new AutoValue_ChangeNotesState_ChangeColumns(
             changeKey,
@@ -110,79 +118,160 @@
             topic,
             originalSubject,
             submissionId,
+            assignee,
             status),
+        ImmutableSet.copyOf(pastAssignees),
         ImmutableSet.copyOf(hashtags),
-        ImmutableSortedMap.copyOf(patchSets, ReviewDbUtil.intKeyOrdering()),
-        ImmutableListMultimap.copyOf(approvals),
+        ImmutableList.copyOf(patchSets.entrySet()),
+        ImmutableList.copyOf(approvals.entries()),
         reviewers,
         ImmutableList.copyOf(allPastReviewers),
         ImmutableList.copyOf(reviewerUpdates),
         ImmutableList.copyOf(submitRecords),
         ImmutableList.copyOf(allChangeMessages),
         ImmutableListMultimap.copyOf(changeMessagesByPatchSet),
-        ImmutableListMultimap.copyOf(publishedComments));
+        ImmutableListMultimap.copyOf(publishedComments),
+        readOnlyUntil);
   }
 
-
   /**
    * Subset of Change columns that can be represented in NoteDb.
-   * <p>
-   * Notable exceptions include rowVersion and noteDbState, which are only make
-   * sense when read from NoteDb, so they cannot be cached.
-   * <p>
-   * Fields are in listed column order.
+   *
+   * <p>Notable exceptions include rowVersion and noteDbState, which are only make sense when read
+   * from NoteDb, so they cannot be cached.
+   *
+   * <p>Fields are in listed column order.
    */
   @AutoValue
   abstract static class ChangeColumns {
     abstract Change.Key changeKey();
+
     abstract Timestamp createdOn();
+
     abstract Timestamp lastUpdatedOn();
+
     abstract Account.Id owner();
-    abstract String branch(); // Project not included.
-    @Nullable abstract PatchSet.Id currentPatchSetId();
+
+    // Project not included, as it's not stored anywhere in the meta ref.
+    abstract String branch();
+
+    @Nullable
+    abstract PatchSet.Id currentPatchSetId();
+
     abstract String subject();
-    @Nullable abstract String topic();
-    @Nullable abstract String originalSubject();
-    @Nullable abstract String submissionId();
+
+    @Nullable
+    abstract String topic();
+
+    @Nullable
+    abstract String originalSubject();
+
+    @Nullable
+    abstract String submissionId();
+
+    @Nullable
+    abstract Account.Id assignee();
     // TODO(dborowitz): Use a sensible default other than null
-    @Nullable abstract Change.Status status();
+    @Nullable
+    abstract Change.Status status();
   }
 
+  // Only null if NoteDb is disabled.
+  @Nullable
+  abstract ObjectId metaId();
+
   abstract Change.Id changeId();
 
-  @Nullable abstract ChangeColumns columns();
+  // Only null if NoteDb is disabled.
+  @Nullable
+  abstract ChangeColumns columns();
 
   // Other related to this Change.
+  abstract ImmutableSet<Account.Id> pastAssignees();
+
   abstract ImmutableSet<String> hashtags();
-  abstract ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets();
-  abstract ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals();
+
+  abstract ImmutableList<Map.Entry<PatchSet.Id, PatchSet>> patchSets();
+
+  abstract ImmutableList<Map.Entry<PatchSet.Id, PatchSetApproval>> approvals();
 
   abstract ReviewerSet reviewers();
+
   abstract ImmutableList<Account.Id> allPastReviewers();
+
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
   abstract ImmutableList<SubmitRecord> submitRecords();
-  abstract ImmutableList<ChangeMessage> allChangeMessages();
-  abstract ImmutableListMultimap<PatchSet.Id, ChangeMessage>
-      changeMessagesByPatchSet();
-  abstract ImmutableListMultimap<RevId, PatchLineComment> publishedComments();
 
-  void copyColumnsTo(Change change) {
-    ChangeColumns c = checkNotNull(columns());
+  abstract ImmutableList<ChangeMessage> allChangeMessages();
+
+  abstract ImmutableListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet();
+
+  abstract ImmutableListMultimap<RevId, Comment> publishedComments();
+
+  @Nullable
+  abstract Timestamp readOnlyUntil();
+
+  Change newChange(Project.NameKey project) {
+    ChangeColumns c = checkNotNull(columns(), "columns are required");
+    Change change =
+        new Change(
+            c.changeKey(),
+            changeId(),
+            c.owner(),
+            new Branch.NameKey(project, c.branch()),
+            c.createdOn());
+    copyNonConstructorColumnsTo(change);
+    change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+    return change;
+  }
+
+  void copyColumnsTo(Change change) throws IOException {
+    ChangeColumns c = columns();
+    checkState(
+        c != null && metaId() != null,
+        "missing columns or metaId in ChangeNotesState; is NoteDb enabled? %s",
+        this);
+    checkMetaId(change);
+    change.setKey(c.changeKey());
+    change.setOwner(c.owner());
+    change.setDest(new Branch.NameKey(change.getProject(), c.branch()));
+    change.setCreatedOn(c.createdOn());
+    copyNonConstructorColumnsTo(change);
+  }
+
+  private void checkMetaId(Change change) throws IOException {
+    NoteDbChangeState state = NoteDbChangeState.parse(change);
+    if (state == null) {
+      return; // Can happen during small NoteDb tests.
+    } else if (state.getPrimaryStorage() == PrimaryStorage.NOTE_DB) {
+      return;
+    }
+    checkState(state.getRefState().isPresent(), "expected RefState: %s", state);
+    ObjectId idFromState = state.getRefState().get().changeMetaId();
+    if (!idFromState.equals(metaId())) {
+      throw new IOException(
+          "cannot copy ChangeNotesState into Change "
+              + changeId()
+              + "; this ChangeNotesState was created from "
+              + metaId()
+              + ", but change requires state "
+              + idFromState);
+    }
+  }
+
+  private void copyNonConstructorColumnsTo(Change change) {
+    ChangeColumns c = checkNotNull(columns(), "columns are required");
     if (c.status() != null) {
       change.setStatus(c.status());
     }
-    change.setKey(c.changeKey());
-    change.setDest(new Branch.NameKey(change.getProject(), c.branch()));
     change.setTopic(Strings.emptyToNull(c.topic()));
-    change.setCreatedOn(c.createdOn());
     change.setLastUpdatedOn(c.lastUpdatedOn());
-    change.setOwner(c.owner());
     change.setSubmissionId(c.submissionId());
+    change.setAssignee(c.assignee());
 
     if (!patchSets().isEmpty()) {
-      change.setCurrentPatchSet(
-          c.currentPatchSetId(), c.subject(), c.originalSubject());
+      change.setCurrentPatchSet(c.currentPatchSetId(), c.subject(), c.originalSubject());
     } else {
       // TODO(dborowitz): This should be an error, but for now it's required for
       // some tests to pass.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
deleted file mode 100644
index 679b5e2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilder.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import com.google.common.collect.ImmutableMultimap;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
-
-import java.io.IOException;
-import java.util.concurrent.Callable;
-
-public abstract class ChangeRebuilder {
-  public static class NoPatchSetsException extends OrmException {
-    private static final long serialVersionUID = 1L;
-
-    NoPatchSetsException(Change.Id changeId) {
-      super("Change " + changeId
-          + " cannot be rebuilt because it has no patch sets");
-    }
-  }
-
-  private final SchemaFactory<ReviewDb> schemaFactory;
-
-  protected ChangeRebuilder(SchemaFactory<ReviewDb> schemaFactory) {
-    this.schemaFactory = schemaFactory;
-  }
-
-  public final ListenableFuture<Result> rebuildAsync(
-      final Change.Id id, ListeningExecutorService executor) {
-    return executor.submit(new Callable<Result>() {
-        @Override
-      public Result call() throws Exception {
-        try (ReviewDb db = schemaFactory.open()) {
-          return rebuild(db, id);
-        }
-      }
-    });
-  }
-
-  public abstract Result rebuild(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException;
-
-  public abstract Result rebuild(NoteDbUpdateManager manager,
-      ChangeBundle bundle) throws NoSuchChangeException, IOException,
-      OrmException, ConfigInvalidException;
-
-  public abstract boolean rebuildProject(ReviewDb db,
-      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
-      Project.NameKey project, Repository allUsersRepo)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException;
-
-  public abstract NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException;
-
-  public abstract Result execute(ReviewDb db, Change.Id changeId,
-      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
-      IOException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
deleted file mode 100644
index 08acbad..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
+++ /dev/null
@@ -1,1060 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
-import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableMultimap;
-import com.google.common.collect.ImmutableSet;
-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;
-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.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
-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;
-import com.google.gerrit.server.PatchLineCommentsUtil;
-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.notedb.NoteDbUpdateManager.Result;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gwtorm.server.AtomicUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.ObjectId;
-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;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class ChangeRebuilderImpl extends ChangeRebuilder {
-  private static final Logger log =
-      LoggerFactory.getLogger(ChangeRebuilderImpl.class);
-
-  /**
-   * The maximum amount of time between the ReviewDb timestamp of the first and
-   * last events batched together into a single NoteDb update.
-   * <p>
-   * Used to account for the fact that different records with their own
-   * timestamps (e.g. {@link PatchSetApproval} and {@link ChangeMessage})
-   * historically didn't necessarily use the same timestamp, and tended to call
-   * {@code System.currentTimeMillis()} independently.
-   */
-  static final long MAX_WINDOW_MS = SECONDS.toMillis(3);
-
-  /**
-   * The maximum amount of time between two consecutive events to consider them
-   * to be in the same batch.
-   */
-  private static final long MAX_DELTA_MS = SECONDS.toMillis(1);
-
-  private final AccountCache accountCache;
-  private final ChangeDraftUpdate.Factory draftUpdateFactory;
-  private final ChangeNoteUtil changeNoteUtil;
-  private final ChangeUpdate.Factory updateFactory;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final NotesMigration migration;
-  private final PatchListCache patchListCache;
-  private final PersonIdent serverIdent;
-  private final ProjectCache projectCache;
-  private final String anonymousCowardName;
-
-  @Inject
-  ChangeRebuilderImpl(SchemaFactory<ReviewDb> schemaFactory,
-      AccountCache accountCache,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
-      ChangeNoteUtil changeNoteUtil,
-      ChangeUpdate.Factory updateFactory,
-      NoteDbUpdateManager.Factory updateManagerFactory,
-      NotesMigration migration,
-      PatchListCache patchListCache,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Nullable ProjectCache projectCache,
-      @AnonymousCowardName String anonymousCowardName) {
-    super(schemaFactory);
-    this.accountCache = accountCache;
-    this.draftUpdateFactory = draftUpdateFactory;
-    this.changeNoteUtil = changeNoteUtil;
-    this.updateFactory = updateFactory;
-    this.updateManagerFactory = updateManagerFactory;
-    this.migration = migration;
-    this.patchListCache = patchListCache;
-    this.serverIdent = serverIdent;
-    this.projectCache = projectCache;
-    this.anonymousCowardName = anonymousCowardName;
-  }
-
-  @Override
-  public Result rebuild(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = db.changes().get(changeId);
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    try (NoteDbUpdateManager manager =
-        updateManagerFactory.create(change.getProject())) {
-      buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
-      return execute(db, changeId, manager);
-    }
-  }
-
-  private static class AbortUpdateException extends OrmRuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    AbortUpdateException() {
-      super("aborted");
-    }
-  }
-
-  private static class ConflictingUpdateException extends OrmRuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    ConflictingUpdateException(Change change, String expectedNoteDbState) {
-      super(String.format(
-          "Expected change %s to have noteDbState %s but was %s",
-          change.getId(), expectedNoteDbState, change.getNoteDbState()));
-    }
-  }
-
-  @Override
-  public Result rebuild(NoteDbUpdateManager manager,
-      ChangeBundle bundle) throws NoSuchChangeException, IOException,
-      OrmException, ConfigInvalidException {
-    Change change = new Change(bundle.getChange());
-    buildUpdates(manager, bundle);
-    return manager.stageAndApplyDelta(change);
-  }
-
-  @Override
-  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = db.changes().get(changeId);
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-    NoteDbUpdateManager manager =
-        updateManagerFactory.create(change.getProject());
-    buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
-    manager.stage();
-    return manager;
-  }
-
-  @Override
-  public Result execute(ReviewDb db, Change.Id changeId,
-      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
-      IOException {
-    db = ReviewDbUtil.unwrapDb(db);
-    Change change = db.changes().get(changeId);
-    if (change == null) {
-      throw new NoSuchChangeException(changeId);
-    }
-
-    final String oldNoteDbState = change.getNoteDbState();
-    Result r = manager.stageAndApplyDelta(change);
-    final String newNoteDbState = change.getNoteDbState();
-    try {
-      db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
-        @Override
-        public Change update(Change change) {
-          String currNoteDbState = change.getNoteDbState();
-          if (Objects.equals(currNoteDbState, newNoteDbState)) {
-            // Another thread completed the same rebuild we were about to.
-            throw new AbortUpdateException();
-          } else if (!Objects.equals(oldNoteDbState, currNoteDbState)) {
-            // Another thread updated the state to something else.
-            throw new ConflictingUpdateException(change, oldNoteDbState);
-          }
-          change.setNoteDbState(newNoteDbState);
-          return change;
-        }
-      });
-    } catch (ConflictingUpdateException e) {
-      // Rethrow as an OrmException so the caller knows to use staged results.
-      // Strictly speaking they are not completely up to date, but result we
-      // send to the caller is the same as if this rebuild had executed before
-      // the other thread.
-      throw new OrmException(e.getMessage());
-    } catch (AbortUpdateException e) {
-      if (NoteDbChangeState.parse(changeId, newNoteDbState).isUpToDate(
-          manager.getChangeRepo().cmds.getRepoRefCache(),
-          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
-        // If the state in ReviewDb matches NoteDb at this point, it means
-        // another thread successfully completed this rebuild. It's ok to not
-        // execute the update in this case, since the object referenced in the
-        // Result was flushed to the repo by whatever thread won the race.
-        return r;
-      }
-      // If the state doesn't match, that means another thread attempted this
-      // rebuild, but failed. Fall through and try to update the ref again.
-    }
-    if (migration.failChangeWrites()) {
-      // Don't even attempt to execute if read-only, it would fail anyway. But
-      // do throw an exception to the caller so they know to use the staged
-      // results instead of reading from the repo.
-      throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
-    }
-    manager.execute();
-    return r;
-  }
-
-  @Override
-  public boolean rebuildProject(ReviewDb db,
-      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
-      Project.NameKey project, Repository allUsersRepo)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
-    checkArgument(allChanges.containsKey(project));
-    boolean ok = true;
-    ProgressMonitor pm = new TextProgressMonitor(new PrintWriter(System.out));
-    pm.beginTask(
-        FormatUtil.elide(project.get(), 50), allChanges.get(project).size());
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(project);
-        ObjectInserter allUsersInserter = allUsersRepo.newObjectInserter();
-        RevWalk allUsersRw = new RevWalk(allUsersInserter.newReader())) {
-      manager.setAllUsersRepo(allUsersRepo, allUsersRw, allUsersInserter,
-          new ChainedReceiveCommands(allUsersRepo));
-      for (Change.Id changeId : allChanges.get(project)) {
-        try {
-          buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
-        } catch (NoPatchSetsException e) {
-          log.warn(e.getMessage());
-        } catch (Throwable t) {
-          log.error("Failed to rebuild change " + changeId, t);
-          ok = false;
-        }
-        pm.update(1);
-      }
-      manager.execute();
-    } finally {
-      pm.endTask();
-    }
-    return ok;
-  }
-
-  private void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
-      throws IOException, OrmException {
-    manager.setCheckExpectedState(false);
-    Change change = new Change(bundle.getChange());
-    if (bundle.getPatchSets().isEmpty()) {
-      throw new NoPatchSetsException(change.getId());
-    }
-
-    PatchSet.Id currPsId = change.currentPatchSetId();
-    // We will rebuild all events, except for draft comments, in buckets based
-    // on author and timestamp.
-    List<Event> events = new ArrayList<>();
-    Multimap<Account.Id, PatchLineCommentEvent> draftCommentEvents =
-        ArrayListMultimap.create();
-
-    events.addAll(getHashtagsEvents(change, manager));
-
-    // Delete ref only after hashtags have been read
-    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
-    deleteDraftRefs(change, manager.getAllUsersRepo());
-
-    Integer minPsNum = getMinPatchSetNum(bundle);
-    Set<PatchSet.Id> psIds =
-        Sets.newHashSetWithExpectedSize(bundle.getPatchSets().size());
-
-    for (PatchSet ps : bundle.getPatchSets()) {
-      if (ps.getId().get() > currPsId.get()) {
-        log.info(
-            "Skipping patch set {}, which is higher than current patch set {}",
-            ps.getId(), currPsId);
-        continue;
-      }
-      psIds.add(ps.getId());
-      events.add(new PatchSetEvent(
-          change, ps, manager.getChangeRepo().rw));
-      for (PatchLineComment c : getPatchLineComments(bundle, ps)) {
-        PatchLineCommentEvent e =
-            new PatchLineCommentEvent(c, change, ps, patchListCache);
-        if (c.getStatus() == Status.PUBLISHED) {
-          events.add(e);
-        } else {
-          draftCommentEvents.put(c.getAuthor(), e);
-        }
-      }
-    }
-
-    for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
-      if (psIds.contains(psa.getPatchSetId())) {
-        events.add(new ApprovalEvent(psa, change.getCreatedOn()));
-      }
-    }
-
-    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())) {
-        events.add(
-            new ChangeMessageEvent(msg, noteDbChange, change.getCreatedOn()));
-      }
-    }
-
-    sortAndFillEvents(change, noteDbChange, events, minPsNum);
-
-    EventList<Event> el = new EventList<>();
-    for (Event e : events) {
-      if (!el.canAdd(e)) {
-        flushEventsToUpdate(manager, el, change);
-        checkState(el.canAdd(e));
-      }
-      el.add(e);
-    }
-    flushEventsToUpdate(manager, el, change);
-
-    EventList<PatchLineCommentEvent> plcel = new EventList<>();
-    for (Account.Id author : draftCommentEvents.keys()) {
-      for (PatchLineCommentEvent e :
-          EVENT_ORDER.sortedCopy(draftCommentEvents.get(author))) {
-        if (!plcel.canAdd(e)) {
-          flushEventsToDraftUpdate(manager, plcel, change);
-          checkState(plcel.canAdd(e));
-        }
-        plcel.add(e);
-      }
-      flushEventsToDraftUpdate(manager, plcel, change);
-    }
-  }
-
-  private static Integer getMinPatchSetNum(ChangeBundle bundle) {
-    Integer minPsNum = null;
-    for (PatchSet ps : bundle.getPatchSets()) {
-      int n = ps.getId().get();
-      if (minPsNum == null || n < minPsNum) {
-        minPsNum = n;
-      }
-    }
-    return minPsNum;
-  }
-
-  private static List<PatchLineComment> getPatchLineComments(ChangeBundle bundle,
-      final PatchSet ps) {
-    return FluentIterable.from(bundle.getPatchLineComments())
-        .filter(new Predicate<PatchLineComment>() {
-          @Override
-          public boolean apply(PatchLineComment in) {
-            return in.getPatchSetId().equals(ps.getId());
-          }
-        }).toSortedList(PatchLineCommentsUtil.PLC_ORDER);
-  }
-
-  private void sortAndFillEvents(Change change, Change noteDbChange,
-      List<Event> events, Integer minPsNum) {
-    Collections.sort(events, EVENT_ORDER);
-    events.add(new FinalUpdatesEvent(change, noteDbChange));
-
-    // Ensure the first event in the list creates the change, setting the author
-    // and any required footers.
-    Event first = events.get(0);
-    if (first instanceof PatchSetEvent && change.getOwner().equals(first.who)) {
-      ((PatchSetEvent) first).createChange = true;
-    } else {
-      events.add(0, new CreateChangeEvent(change, minPsNum));
-    }
-
-    // Fill in any missing patch set IDs using the latest patch set of the
-    // change at the time of the event, because NoteDb can't represent actions
-    // with no associated patch set ID. This workaround is as if a user added a
-    // ChangeMessage on the change by replying from the latest patch set.
-    //
-    // Start with the first patch set that actually exists. If there are no
-    // patch sets at all, minPsNum will be null, so just bail and use 1 as the
-    // patch set ID. The corresponding patch set won't exist, but this change is
-    // probably corrupt anyway, as deleting the last draft patch set should have
-    // deleted the whole change.
-    int ps = firstNonNull(minPsNum, 1);
-    for (Event e : events) {
-      if (e.psId == null) {
-        e.psId = new PatchSet.Id(change.getId(), ps);
-      } else {
-        ps = Math.max(ps, e.psId.get());
-      }
-    }
-  }
-
-  private void flushEventsToUpdate(NoteDbUpdateManager manager,
-      EventList<Event> events, Change change) throws OrmException, IOException {
-    if (events.isEmpty()) {
-      return;
-    }
-    Comparator<String> labelNameComparator;
-    if (projectCache != null) {
-      labelNameComparator = projectCache.get(change.getProject())
-          .getLabelTypes().nameComparator();
-    } else {
-      // No project cache available, bail and use natural ordering; there's no
-      // semantic difference anyway difference.
-      labelNameComparator = Ordering.natural();
-    }
-    ChangeUpdate update = updateFactory.create(
-        change,
-        events.getAccountId(),
-        events.newAuthorIdent(),
-        events.getWhen(),
-        labelNameComparator);
-    update.setAllowWriteToNewRef(true);
-    update.setPatchSetId(events.getPatchSetId());
-    update.setTag(events.getTag());
-    for (Event e : events) {
-      e.apply(update);
-    }
-    manager.add(update);
-    events.clear();
-  }
-
-  private void flushEventsToDraftUpdate(NoteDbUpdateManager manager,
-      EventList<PatchLineCommentEvent> events, Change change)
-      throws OrmException {
-    if (events.isEmpty()) {
-      return;
-    }
-    ChangeDraftUpdate update = draftUpdateFactory.create(
-        change,
-        events.getAccountId(),
-        events.newAuthorIdent(),
-        events.getWhen());
-    update.setPatchSetId(events.getPatchSetId());
-    for (PatchLineCommentEvent e : events) {
-      e.applyDraft(update);
-    }
-    manager.add(update);
-    events.clear();
-  }
-
-  private List<HashtagsEvent> getHashtagsEvents(Change change,
-      NoteDbUpdateManager manager) throws IOException {
-    String refName = changeMetaRef(change.getId());
-    Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
-    if (!old.isPresent()) {
-      return Collections.emptyList();
-    }
-
-    RevWalk rw = manager.getChangeRepo().rw;
-    List<HashtagsEvent> events = new ArrayList<>();
-    rw.reset();
-    rw.markStart(rw.parseCommit(old.get()));
-    for (RevCommit commit : rw) {
-      Account.Id authorId;
-      try {
-        authorId =
-            changeNoteUtil.parseIdent(commit.getAuthorIdent(), change.getId());
-      } catch (ConfigInvalidException e) {
-        continue; // Corrupt data, no valid hashtags in this commit.
-      }
-      PatchSet.Id psId = parsePatchSetId(change, commit);
-      Set<String> hashtags = parseHashtags(commit);
-      if (authorId == null || psId == null || hashtags == null) {
-        continue;
-      }
-
-      Timestamp commitTime =
-          new Timestamp(commit.getCommitterIdent().getWhen().getTime());
-      events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags,
-            change.getCreatedOn()));
-    }
-    return events;
-  }
-
-  private Set<String> parseHashtags(RevCommit commit) {
-    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
-    if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
-      return null;
-    }
-
-    if (hashtagsLines.get(0).isEmpty()) {
-      return ImmutableSet.of();
-    }
-    return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
-  }
-
-  private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
-    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
-    if (psIdLines.size() != 1) {
-      return null;
-    }
-    Integer psId = Ints.tryParse(psIdLines.get(0));
-    if (psId == null) {
-      return null;
-    }
-    return new PatchSet.Id(change.getId(), psId);
-  }
-
-  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds)
-      throws IOException {
-    String refName = changeMetaRef(change.getId());
-    Optional<ObjectId> old = cmds.get(refName);
-    if (old.isPresent()) {
-      cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
-    }
-  }
-
-  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) {
-      return ComparisonChain.start()
-          .compare(a.when, b.when)
-          .compareTrueFirst(isPatchSet(a), isPatchSet(b))
-          .compareTrueFirst(a.predatesChange, b.predatesChange)
-          .compare(a.who, b.who, ReviewDbUtil.intKeyOrdering())
-          .compare(a.psId, b.psId, ReviewDbUtil.intKeyOrdering().nullsLast())
-          .result();
-    }
-
-    private boolean isPatchSet(Event e) {
-      return e instanceof PatchSetEvent;
-    }
-  };
-
-  private abstract static class Event {
-    // NOTE: EventList only supports direct subclasses, not an arbitrary
-    // hierarchy.
-
-    final Account.Id who;
-    final Timestamp when;
-    final String tag;
-    final boolean predatesChange;
-    PatchSet.Id psId;
-
-    protected Event(PatchSet.Id psId, Account.Id who, Timestamp when,
-        Timestamp changeCreatedOn, String tag) {
-      this.psId = psId;
-      this.who = who;
-      this.tag = tag;
-      // Truncate timestamps at the change's createdOn timestamp.
-      predatesChange = when.before(changeCreatedOn);
-      this.when = predatesChange ? changeCreatedOn : when;
-    }
-
-    protected void checkUpdate(AbstractChangeUpdate update) {
-      checkState(Objects.equals(update.getPatchSetId(), psId),
-          "cannot apply event for %s to update for %s",
-          update.getPatchSetId(), psId);
-      checkState(when.getTime() - update.getWhen().getTime() <= MAX_WINDOW_MS,
-          "event at %s outside update window starting at %s",
-          when, update.getWhen());
-      checkState(Objects.equals(update.getNullableAccountId(), who),
-          "cannot apply event by %s to update by %s",
-          who, update.getNullableAccountId());
-    }
-
-    /**
-     * @return whether this event type must be unique per {@link ChangeUpdate},
-     *     i.e. there may be at most one of this type.
-     */
-    abstract boolean uniquePerUpdate();
-
-    abstract void apply(ChangeUpdate update) throws OrmException, IOException;
-
-    @Override
-    public String toString() {
-      return MoreObjects.toStringHelper(this)
-          .add("psId", psId)
-          .add("who", who)
-          .add("when", when)
-          .toString();
-    }
-  }
-
-  private class EventList<E extends Event> extends ArrayList<E> {
-    private static final long serialVersionUID = 1L;
-
-    private E getLast() {
-      return get(size() - 1);
-    }
-
-    private long getLastTime() {
-      return getLast().when.getTime();
-    }
-
-    private long getFirstTime() {
-      return get(0).when.getTime();
-    }
-
-    boolean canAdd(E e) {
-      if (isEmpty()) {
-        return true;
-      }
-      if (e instanceof FinalUpdatesEvent) {
-        return false; // FinalUpdatesEvent always gets its own update.
-      }
-
-      Event last = getLast();
-      if (!Objects.equals(e.who, last.who)
-          || !e.psId.equals(last.psId)
-          || !Objects.equals(e.tag, last.tag)) {
-        return false; // Different patch set, author, or tag.
-      }
-
-      long t = e.when.getTime();
-      long tFirst = getFirstTime();
-      long tLast = getLastTime();
-      checkArgument(t >= tLast,
-          "event %s is before previous event in list %s", e, last);
-      if (t - tLast > MAX_DELTA_MS || t - tFirst > MAX_WINDOW_MS) {
-        return false; // Too much time elapsed.
-      }
-
-      if (!e.uniquePerUpdate()) {
-        return true;
-      }
-      for (Event o : this) {
-        if (e.getClass() == o.getClass()) {
-          return false; // Only one event of this type allowed per update.
-        }
-      }
-
-      // TODO(dborowitz): Additional heuristics, like keeping events separate if
-      // they affect overlapping fields within a single entity.
-
-      return true;
-    }
-
-    Timestamp getWhen() {
-      return get(0).when;
-    }
-
-    PatchSet.Id getPatchSetId() {
-      PatchSet.Id id = checkNotNull(get(0).psId);
-      for (int i = 1; i < size(); i++) {
-        checkState(get(i).psId.equals(id),
-            "mismatched patch sets in EventList: %s != %s", id, get(i).psId);
-      }
-      return id;
-    }
-
-    Account.Id getAccountId() {
-      Account.Id id = get(0).who;
-      for (int i = 1; i < size(); i++) {
-        checkState(Objects.equals(id, get(i).who),
-            "mismatched users in EventList: %s != %s", id, get(i).who);
-      }
-      return id;
-    }
-
-    PersonIdent newAuthorIdent() {
-      Account.Id id = getAccountId();
-      if (id == null) {
-        return new PersonIdent(serverIdent, getWhen());
-      }
-      return changeNoteUtil.newIdent(
-          accountCache.get(id).getAccount(), getWhen(), serverIdent,
-          anonymousCowardName);
-    }
-
-    String getTag() {
-      return getLast().tag;
-    }
-  }
-
-  private static void createChange(ChangeUpdate update, Change change) {
-    update.setSubjectForCommit("Create change");
-    update.setChangeId(change.getKey().get());
-    update.setBranch(change.getDest().get());
-    update.setSubject(change.getOriginalSubject());
-  }
-
-  private static class CreateChangeEvent extends Event {
-    private final Change change;
-
-    private static PatchSet.Id psId(Change change, Integer minPsNum) {
-      int n;
-      if (minPsNum == null) {
-        // There were no patch sets for the change at all, so something is very
-        // wrong. Bail and use 1 as the patch set.
-        n = 1;
-      } else {
-        n = minPsNum;
-      }
-      return new PatchSet.Id(change.getId(), n);
-    }
-
-    CreateChangeEvent(Change change, Integer minPsNum) {
-      super(psId(change, minPsNum), change.getOwner(), change.getCreatedOn(),
-          change.getCreatedOn(), null);
-      this.change = change;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return true;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws IOException, OrmException {
-      checkUpdate(update);
-      createChange(update, change);
-    }
-  }
-
-  private static class ApprovalEvent extends Event {
-    private PatchSetApproval psa;
-
-    ApprovalEvent(PatchSetApproval psa, Timestamp changeCreatedOn) {
-      super(psa.getPatchSetId(), psa.getAccountId(), psa.getGranted(),
-          changeCreatedOn, psa.getTag());
-      this.psa = psa;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return false;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) {
-      checkUpdate(update);
-      update.putApproval(psa.getLabel(), psa.getValue());
-    }
-  }
-
-  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;
-    private final RevWalk rw;
-    private boolean createChange;
-
-    PatchSetEvent(Change change, PatchSet ps, RevWalk rw) {
-      super(ps.getId(), ps.getUploader(), ps.getCreatedOn(),
-          change.getCreatedOn(), null);
-      this.change = change;
-      this.ps = ps;
-      this.rw = rw;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return true;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws IOException, OrmException {
-      checkUpdate(update);
-      if (createChange) {
-        createChange(update, change);
-      } else {
-        update.setSubject(change.getSubject());
-        update.setSubjectForCommit("Create patch set " + ps.getPatchSetId());
-      }
-      setRevision(update, ps);
-      List<String> groups = ps.getGroups();
-      if (!groups.isEmpty()) {
-        update.setGroups(ps.getGroups());
-      }
-      if (ps.isDraft()) {
-        update.setPatchSetState(PatchSetState.DRAFT);
-      }
-    }
-
-    private void setRevision(ChangeUpdate update, PatchSet ps)
-        throws IOException {
-      String rev = ps.getRevision().get();
-      String cert = ps.getPushCertificate();
-      ObjectId id;
-      try {
-        id = ObjectId.fromString(rev);
-      } catch (InvalidObjectIdException e) {
-        update.setRevisionForMissingCommit(rev, cert);
-        return;
-      }
-      try {
-        update.setCommit(rw, id, cert);
-      } catch (MissingObjectException e) {
-        update.setRevisionForMissingCommit(rev, cert);
-        return;
-      }
-    }
-  }
-
-  private static class PatchLineCommentEvent extends Event {
-    public final PatchLineComment c;
-    private final Change change;
-    private final PatchSet ps;
-    private final PatchListCache cache;
-
-    PatchLineCommentEvent(PatchLineComment c, Change change, PatchSet ps,
-        PatchListCache cache) {
-      super(PatchLineCommentsUtil.getCommentPsId(c), c.getAuthor(),
-          c.getWrittenOn(), change.getCreatedOn(), c.getTag());
-      this.c = c;
-      this.change = change;
-      this.ps = ps;
-      this.cache = cache;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return false;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      checkUpdate(update);
-      if (c.getRevId() == null) {
-        setCommentRevId(c, cache, change, ps);
-      }
-      update.putComment(c);
-    }
-
-    void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
-      if (c.getRevId() == null) {
-        setCommentRevId(c, cache, change, ps);
-      }
-      draftUpdate.putComment(c);
-    }
-  }
-
-  private static class HashtagsEvent extends Event {
-    private final Set<String> hashtags;
-
-    HashtagsEvent(PatchSet.Id psId, Account.Id who, Timestamp when,
-        Set<String> hashtags, Timestamp changeCreatdOn) {
-      super(psId, who, when, changeCreatdOn,
-          // Somewhat confusingly, hashtags do not use the setTag method on
-          // AbstractChangeUpdate, so pass null as the tag.
-          null);
-      this.hashtags = hashtags;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      // Since these are produced from existing commits in the old NoteDb graph,
-      // we know that there must be one per commit in the rebuilt graph.
-      return true;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      update.setHashtags(hashtags);
-    }
-  }
-
-  private static class ChangeMessageEvent extends Event {
-    private static final Pattern TOPIC_SET_REGEXP =
-        Pattern.compile("^Topic set to (.+)$");
-    private static final Pattern TOPIC_CHANGED_REGEXP =
-        Pattern.compile("^Topic changed from (.+) to (.+)$");
-    private static final Pattern TOPIC_REMOVED_REGEXP =
-        Pattern.compile("^Topic (.+) removed$");
-
-    private static final Pattern STATUS_ABANDONED_REGEXP =
-        Pattern.compile("^Abandoned(\n.*)*$");
-    private static final Pattern STATUS_RESTORED_REGEXP =
-        Pattern.compile("^Restored(\n.*)*$");
-
-    private final ChangeMessage message;
-    private final Change noteDbChange;
-
-    ChangeMessageEvent(ChangeMessage message, Change noteDbChange,
-        Timestamp changeCreatedOn) {
-      super(message.getPatchSetId(), message.getAuthor(),
-          message.getWrittenOn(), changeCreatedOn, message.getTag());
-      this.message = message;
-      this.noteDbChange = noteDbChange;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return true;
-    }
-
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      checkUpdate(update);
-      update.setChangeMessage(message.getMessage());
-      setTopic(update);
-      setStatus(update);
-    }
-
-    private void setTopic(ChangeUpdate update) {
-      String msg = message.getMessage();
-      if (msg == null) {
-        return;
-      }
-      Matcher m = TOPIC_SET_REGEXP.matcher(msg);
-      if (m.matches()) {
-        String topic = m.group(1);
-        update.setTopic(topic);
-        noteDbChange.setTopic(topic);
-        return;
-      }
-
-      m = TOPIC_CHANGED_REGEXP.matcher(msg);
-      if (m.matches()) {
-        String topic = m.group(2);
-        update.setTopic(topic);
-        noteDbChange.setTopic(topic);
-        return;
-      }
-
-      if (TOPIC_REMOVED_REGEXP.matcher(msg).matches()) {
-        update.setTopic(null);
-        noteDbChange.setTopic(null);
-      }
-    }
-
-    private void setStatus(ChangeUpdate update) {
-      String msg = message.getMessage();
-      if (msg == null) {
-        return;
-      }
-      if (STATUS_ABANDONED_REGEXP.matcher(msg).matches()) {
-        update.setStatus(Change.Status.ABANDONED);
-        noteDbChange.setStatus(Change.Status.ABANDONED);
-        return;
-      }
-
-      if (STATUS_RESTORED_REGEXP.matcher(msg).matches()) {
-        update.setStatus(Change.Status.NEW);
-        noteDbChange.setStatus(Change.Status.NEW);
-      }
-    }
-  }
-
-  private static class FinalUpdatesEvent extends Event {
-    private final Change change;
-    private final Change noteDbChange;
-
-    FinalUpdatesEvent(Change change, Change noteDbChange) {
-      super(change.currentPatchSetId(), change.getOwner(),
-          change.getLastUpdatedOn(), change.getCreatedOn(), null);
-      this.change = change;
-      this.noteDbChange = noteDbChange;
-    }
-
-    @Override
-    boolean uniquePerUpdate() {
-      return true;
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    void apply(ChangeUpdate update) throws OrmException {
-      if (!Objects.equals(change.getTopic(), noteDbChange.getTopic())) {
-        update.setTopic(change.getTopic());
-      }
-      if (!Objects.equals(change.getStatus(), noteDbChange.getStatus())) {
-        // TODO(dborowitz): Stamp approximate approvals at this time.
-        update.fixStatus(change.getStatus());
-      }
-      if (change.getSubmissionId() != null) {
-        update.setSubmissionId(change.getSubmissionId());
-      }
-      if (!update.isEmpty()) {
-        update.setSubjectForCommit("Final NoteDb migration updates");
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
new file mode 100644
index 0000000..153c9c3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.primitives.Bytes;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.util.MutableInteger;
+import org.eclipse.jgit.util.RawParseUtils;
+
+class ChangeRevisionNote extends RevisionNote<Comment> {
+  private static final byte[] CERT_HEADER = "certificate version ".getBytes(UTF_8);
+  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
+  private static final byte[] END_SIGNATURE = "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
+
+  private final ChangeNoteUtil noteUtil;
+  private final Change.Id changeId;
+  private final PatchLineComment.Status status;
+  private String pushCert;
+
+  ChangeRevisionNote(
+      ChangeNoteUtil noteUtil,
+      Change.Id changeId,
+      ObjectReader reader,
+      ObjectId noteId,
+      PatchLineComment.Status status) {
+    super(reader, noteId);
+    this.noteUtil = noteUtil;
+    this.changeId = changeId;
+    this.status = status;
+  }
+
+  public String getPushCert() {
+    checkParsed();
+    return pushCert;
+  }
+
+  @Override
+  protected List<Comment> parse(byte[] raw, int offset) throws IOException, ConfigInvalidException {
+    MutableInteger p = new MutableInteger();
+    p.value = offset;
+
+    if (isJson(raw, p.value)) {
+      RevisionNoteData data = parseJson(noteUtil, raw, p.value);
+      if (status == PatchLineComment.Status.PUBLISHED) {
+        pushCert = data.pushCert;
+      } else {
+        pushCert = null;
+      }
+      return data.comments;
+    }
+
+    if (status == PatchLineComment.Status.PUBLISHED) {
+      pushCert = parsePushCert(changeId, raw, p);
+      trimLeadingEmptyLines(raw, p);
+    } else {
+      pushCert = null;
+    }
+    return noteUtil.parseNote(raw, p, changeId);
+  }
+
+  private static boolean isJson(byte[] raw, int offset) {
+    return raw[offset] == '{' || raw[offset] == '[';
+  }
+
+  private RevisionNoteData parseJson(ChangeNoteUtil noteUtil, byte[] raw, int offset)
+      throws IOException {
+    try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
+        Reader r = new InputStreamReader(is, UTF_8)) {
+      return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
+    }
+  }
+
+  private static String parsePushCert(Change.Id changeId, byte[] bytes, MutableInteger p)
+      throws ConfigInvalidException {
+    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
+      return null;
+    }
+    int end = Bytes.indexOf(bytes, END_SIGNATURE);
+    if (end < 0) {
+      throw ChangeNotes.parseException(changeId, "invalid push certificate in note");
+    }
+    int start = p.value;
+    p.value = end + END_SIGNATURE.length;
+    return new String(bytes, start, p.value, UTF_8);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 77b8dc0..7af0cb4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -19,24 +19,30 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.sanitizeFooter;
+import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Table;
@@ -45,23 +51,38 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.client.IntKey;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -70,48 +91,43 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 /**
  * A delta to apply to a change.
- * <p>
- * This delta will become two unique commits: one in the AllUsers repo that will
- * contain the draft comments on this change and one in the notes branch that
- * will contain approvals, reviewers, change status, subject, submit records,
- * the change message, and published comments. There are limitations on the set
- * of modifications that can be handled in a single update. In particular, there
- * is a single author and timestamp for each update.
- * <p>
- * This class is not thread-safe.
+ *
+ * <p>This delta will become two unique commits: one in the AllUsers repo that will contain the
+ * draft comments on this change and one in the notes branch that will contain approvals, reviewers,
+ * change status, subject, submit records, the change message, and published comments. There are
+ * limitations on the set of modifications that can be handled in a single update. In particular,
+ * there is a single author and timestamp for each update.
+ *
+ * <p>This class is not thread-safe.
  */
 public class ChangeUpdate extends AbstractChangeUpdate {
   public interface Factory {
     ChangeUpdate create(ChangeControl ctl);
+
     ChangeUpdate create(ChangeControl ctl, Date when);
-    ChangeUpdate create(Change change, @Nullable Account.Id accountId,
-        PersonIdent authorIdent, Date when,
+
+    ChangeUpdate create(
+        Change change,
+        @Assisted("effective") @Nullable Account.Id accountId,
+        @Assisted("real") @Nullable Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when,
         Comparator<String> labelNameComparator);
 
     @VisibleForTesting
-    ChangeUpdate create(ChangeControl ctl, Date when,
-        Comparator<String> labelNameComparator);
+    ChangeUpdate create(ChangeControl ctl, Date when, Comparator<String> labelNameComparator);
   }
 
   private final AccountCache accountCache;
   private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
-  private final List<PatchLineComment> comments = new ArrayList<>();
+  private final List<Comment> comments = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -122,6 +138,7 @@
   private String submissionId;
   private String topic;
   private String commit;
+  private Optional<Account.Id> assignee;
   private Set<String> hashtags;
   private String changeMessage;
   private String tag;
@@ -129,39 +146,65 @@
   private Iterable<String> groups;
   private String pushCert;
   private boolean isAllowWriteToNewtRef;
+  private String psDescription;
+  private boolean currentPatchSet;
+  private Timestamp readOnlyUntil;
 
   private ChangeDraftUpdate draftUpdate;
+  private RobotCommentUpdate robotCommentUpdate;
 
   @AssistedInject
   private ChangeUpdate(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
       NotesMigration migration,
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       ChangeNoteUtil noteUtil) {
-    this(serverIdent, anonymousCowardName, migration, accountCache,
-        updateManagerFactory, draftUpdateFactory,
-        projectCache, ctl, serverIdent.getWhen(), noteUtil);
+    this(
+        cfg,
+        serverIdent,
+        anonymousCowardName,
+        migration,
+        accountCache,
+        updateManagerFactory,
+        draftUpdateFactory,
+        robotCommentUpdateFactory,
+        projectCache,
+        ctl,
+        serverIdent.getWhen(),
+        noteUtil);
   }
 
   @AssistedInject
   private ChangeUpdate(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
       NotesMigration migration,
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
       ChangeNoteUtil noteUtil) {
-    this(serverIdent, anonymousCowardName, migration, accountCache,
-        updateManagerFactory, draftUpdateFactory, ctl,
+    this(
+        cfg,
+        serverIdent,
+        anonymousCowardName,
+        migration,
+        accountCache,
+        updateManagerFactory,
+        draftUpdateFactory,
+        robotCommentUpdateFactory,
+        ctl,
         when,
         projectCache.get(getProjectName(ctl)).getLabelTypes().nameComparator(),
         noteUtil);
@@ -173,54 +216,69 @@
 
   private static Table<String, Account.Id, Optional<Short>> approvals(
       Comparator<String> nameComparator) {
-    return TreeBasedTable.create(nameComparator, ReviewDbUtil.intKeyOrdering());
+    return TreeBasedTable.create(nameComparator, comparing(IntKey::get));
   }
 
   @AssistedInject
   private ChangeUpdate(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
       NotesMigration migration,
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator,
       ChangeNoteUtil noteUtil) {
-    super(migration, ctl, serverIdent,
-        anonymousCowardName, noteUtil, when);
+    super(cfg, migration, ctl, serverIdent, anonymousCowardName, noteUtil, when);
     this.accountCache = accountCache;
     this.draftUpdateFactory = draftUpdateFactory;
+    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
     this.approvals = approvals(labelNameComparator);
   }
 
   @AssistedInject
   private ChangeUpdate(
+      @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent serverIdent,
       @AnonymousCowardName String anonymousCowardName,
       NotesMigration migration,
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
-      @Assisted @Nullable Account.Id accountId,
+      @Assisted("effective") @Nullable Account.Id accountId,
+      @Assisted("real") @Nullable Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator) {
-    super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
-        accountId, authorIdent, when);
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        anonymousCowardName,
+        null,
+        change,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
     this.accountCache = accountCache;
     this.draftUpdateFactory = draftUpdateFactory;
+    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
     this.approvals = approvals(labelNameComparator);
   }
 
   public ObjectId commit() throws IOException, OrmException {
-    try (NoteDbUpdateManager updateManager =
-        updateManagerFactory.create(getProjectName())) {
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
       updateManager.add(this);
       updateManager.stageAndApplyDelta(getChange());
       updateManager.execute();
@@ -230,9 +288,11 @@
 
   public void setChangeId(String changeId) {
     String old = getChange().getKey().get();
-    checkArgument(old.equals(changeId),
+    checkArgument(
+        old.equals(changeId),
         "The Change-Id was already set to %s, so we cannot set this Change-Id: %s",
-        old, changeId);
+        old,
+        changeId);
     this.changeId = changeId;
   }
 
@@ -241,8 +301,7 @@
   }
 
   public void setStatus(Change.Status status) {
-    checkArgument(status != Change.Status.MERGED,
-        "use merge(Iterable<SubmitRecord>)");
+    checkArgument(status != Change.Status.MERGED, "use merge(Iterable<SubmitRecord>)");
     this.status = status;
   }
 
@@ -263,16 +322,14 @@
   }
 
   public void removeApprovalFor(Account.Id reviewer, String label) {
-    approvals.put(label, reviewer, Optional.<Short> absent());
+    approvals.put(label, reviewer, Optional.empty());
   }
 
-  public void merge(RequestId submissionId,
-      Iterable<SubmitRecord> submitRecords) {
+  public void merge(RequestId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
     this.submissionId = submissionId.toStringForStorage();
     this.submitRecords = ImmutableList.copyOf(submitRecords);
-    checkArgument(!this.submitRecords.isEmpty(),
-        "no submit records specified at submit time");
+    checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time");
   }
 
   @Deprecated // Only until we improve ChangeRebuilder to call merge().
@@ -284,7 +341,7 @@
     this.commitSubject = commitSubject;
   }
 
-  void setSubject(String subject) {
+  public void setSubject(String subject) {
     this.subject = subject;
   }
 
@@ -301,10 +358,14 @@
     this.tag = tag;
   }
 
-  public void putComment(PatchLineComment c) {
+  public void setPsDescription(String psDescription) {
+    this.psDescription = psDescription;
+  }
+
+  public void putComment(PatchLineComment.Status status, Comment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
-    if (c.getStatus() == PatchLineComment.Status.DRAFT) {
+    if (status == PatchLineComment.Status.DRAFT) {
       draftUpdate.putComment(c);
     } else {
       comments.add(c);
@@ -316,14 +377,15 @@
     }
   }
 
-  public void deleteComment(PatchLineComment c) {
+  public void putRobotComment(RobotComment c) {
     verifyComment(c);
-    if (c.getStatus() == PatchLineComment.Status.DRAFT) {
-      createDraftUpdateIfNull().deleteComment(c);
-    } else {
-      throw new IllegalArgumentException(
-          "Cannot delete published comment " + c);
-    }
+    createRobotCommentUpdateIfNull();
+    robotCommentUpdate.putComment(c);
+  }
+
+  public void deleteComment(Comment c) {
+    verifyComment(c);
+    createDraftUpdateIfNull().deleteComment(c);
   }
 
   @VisibleForTesting
@@ -331,22 +393,29 @@
     if (draftUpdate == null) {
       ChangeNotes notes = getNotes();
       if (notes != null) {
-        draftUpdate =
-            draftUpdateFactory.create(notes, accountId, authorIdent, when);
+        draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
       } else {
-        draftUpdate = draftUpdateFactory.create(
-            getChange(), accountId, authorIdent, when);
+        draftUpdate =
+            draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when);
       }
     }
     return draftUpdate;
   }
 
-  private void verifyComment(PatchLineComment c) {
-    checkArgument(c.getRevId() != null, "RevId required for comment: %s", c);
-    checkArgument(c.getAuthor().equals(getAccountId()),
-        "The author for the following comment does not match the author of"
-        + " this ChangeDraftUpdate (%s): %s", getAccountId(), c);
-
+  @VisibleForTesting
+  RobotCommentUpdate createRobotCommentUpdateIfNull() {
+    if (robotCommentUpdate == null) {
+      ChangeNotes notes = getNotes();
+      if (notes != null) {
+        robotCommentUpdate =
+            robotCommentUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
+      } else {
+        robotCommentUpdate =
+            robotCommentUpdateFactory.create(
+                getChange(), accountId, realAccountId, authorIdent, when);
+      }
+    }
+    return robotCommentUpdate;
   }
 
   public void setTopic(String topic) {
@@ -357,8 +426,7 @@
     setCommit(rw, id, null);
   }
 
-  public void setCommit(RevWalk rw, ObjectId id, String pushCert)
-      throws IOException {
+  public void setCommit(RevWalk rw, ObjectId id, String pushCert) throws IOException {
     RevCommit commit = rw.parseCommit(id);
     rw.parseBody(commit);
     this.commit = commit.name();
@@ -367,8 +435,8 @@
   }
 
   /**
-   * Set the revision without depending on the commit being present in the
-   * repository; should only be used for converting old corrupt commits.
+   * Set the revision without depending on the commit being present in the repository; should only
+   * be used for converting old corrupt commits.
    */
   public void setRevisionForMissingCommit(String id, String pushCert) {
     commit = id;
@@ -379,6 +447,15 @@
     this.hashtags = hashtags;
   }
 
+  public void setAssignee(Account.Id assignee) {
+    checkArgument(assignee != null, "use removeAssignee");
+    this.assignee = Optional.of(assignee);
+  }
+
+  public void removeAssignee() {
+    this.assignee = Optional.empty();
+  }
+
   public Map<Account.Id, ReviewerStateInternal> getReviewers() {
     return reviewers;
   }
@@ -396,23 +473,27 @@
     this.psState = psState;
   }
 
+  public void setCurrentPatchSet() {
+    this.currentPatchSet = true;
+  }
+
   public void setGroups(List<String> groups) {
     checkNotNull(groups, "groups may not be null");
     this.groups = groups;
   }
 
   /** @return the tree id for the updated tree */
-  private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter,
-      ObjectId curr) throws ConfigInvalidException, OrmException, IOException {
+  private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
+      throws ConfigInvalidException, OrmException, IOException {
     if (comments.isEmpty() && pushCert == null) {
       return null;
     }
-    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
+    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
 
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
-    for (PatchLineComment c : comments) {
-      c.setTag(tag);
-      cache.get(c.getRevId()).putComment(c);
+    for (Comment c : comments) {
+      c.tag = tag;
+      cache.get(new RevId(c.revId)).putComment(c);
     }
     if (pushCert != null) {
       checkState(commit != null);
@@ -422,15 +503,15 @@
     checkComments(rnm.revisionNotes, builders);
 
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      ObjectId data = inserter.insert(
-          OBJ_BLOB, e.getValue().build(noteUtil));
+      ObjectId data =
+          inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil, noteUtil.getWriteJson()));
       rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
     }
 
     return rnm.noteMap.writeTree(inserter);
   }
 
-  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
+  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
       throws ConfigInvalidException, OrmException, IOException {
     if (curr.equals(ObjectId.zeroId())) {
       return RevisionNoteMap.emptyMap();
@@ -441,8 +522,7 @@
       // hasn't advanced.
       ChangeNotes notes = getNotes();
       if (notes != null && notes.revisionNoteMap != null) {
-        ObjectId idFromNotes =
-            firstNonNull(notes.load().getRevision(), ObjectId.zeroId());
+        ObjectId idFromNotes = firstNonNull(notes.load().getRevision(), ObjectId.zeroId());
         if (idFromNotes.equals(curr)) {
           return notes.revisionNoteMap;
         }
@@ -452,16 +532,17 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil, getId(), rw.getObjectReader(), noteMap, false);
+        noteUtil, getId(), rw.getObjectReader(), noteMap, PatchLineComment.Status.PUBLISHED);
   }
 
-  private void checkComments(Map<RevId, RevisionNote> existingNotes,
-      Map<RevId, RevisionNoteBuilder> toUpdate) throws OrmException {
+  private void checkComments(
+      Map<RevId, ChangeRevisionNote> existingNotes, Map<RevId, RevisionNoteBuilder> toUpdate)
+      throws OrmException {
     // Prohibit various kinds of illegal operations on comments.
-    Set<PatchLineComment.Key> existing = new HashSet<>();
-    for (RevisionNote rn : existingNotes.values()) {
-      for (PatchLineComment c : rn.comments) {
-        existing.add(c.getKey());
+    Set<Comment.Key> existing = new HashSet<>();
+    for (ChangeRevisionNote rn : existingNotes.values()) {
+      for (Comment c : rn.getComments()) {
+        existing.add(c.key);
         if (draftUpdate != null) {
           // Take advantage of an existing update on All-Users to prune any
           // published comments from drafts. NoteDbUpdateManager takes care of
@@ -478,16 +559,15 @@
           // separate commit. But note that we don't care much about the commit
           // graph of the draft ref, particularly because the ref is completely
           // deleted when all drafts are gone.
-          draftUpdate.deleteComment(c.getRevId(), c.getKey());
+          draftUpdate.deleteComment(c.revId, c.key);
         }
       }
     }
 
     for (RevisionNoteBuilder b : toUpdate.values()) {
-      for (PatchLineComment c : b.put.values()) {
-        if (existing.contains(c.getKey())) {
-          throw new OrmException(
-              "Cannot update existing published comment: " + c);
+      for (Comment c : b.put.values()) {
+        if (existing.contains(c.key)) {
+          throw new OrmException("Cannot update existing published comment: " + c);
         }
       }
     }
@@ -499,8 +579,8 @@
   }
 
   @Override
-  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins,
-      ObjectId curr) throws OrmException, IOException {
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException {
     CommitBuilder cb = new CommitBuilder();
 
     int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
@@ -519,6 +599,14 @@
 
     addPatchSetFooter(msg, ps);
 
+    if (currentPatchSet) {
+      addFooter(msg, FOOTER_CURRENT, Boolean.TRUE);
+    }
+
+    if (psDescription != null) {
+      addFooter(msg, FOOTER_PATCH_SET_DESCRIPTION, psDescription);
+    }
+
     if (changeId != null) {
       addFooter(msg, FOOTER_CHANGE_ID, changeId);
     }
@@ -543,6 +631,15 @@
       addFooter(msg, FOOTER_COMMIT, commit);
     }
 
+    if (assignee != null) {
+      if (assignee.isPresent()) {
+        addFooter(msg, FOOTER_ASSIGNEE);
+        addIdent(msg, assignee.get()).append('\n');
+      } else {
+        addFooter(msg, FOOTER_ASSIGNEE).append('\n');
+      }
+    }
+
     Joiner comma = Joiner.on(',');
     if (hashtags != null) {
       addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
@@ -561,14 +658,13 @@
       addIdent(msg, e.getKey()).append('\n');
     }
 
-    for (Table.Cell<String, Account.Id, Optional<Short>> c
-        : approvals.cellSet()) {
+    for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
       addFooter(msg, FOOTER_LABEL);
+      // Label names/values are safe to append without sanitizing.
       if (!c.getValue().isPresent()) {
         msg.append('-').append(c.getRowKey());
       } else {
-        msg.append(LabelVote.create(
-            c.getRowKey(), c.getValue().get()).formatWithEquals());
+        msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals());
       }
       Account.Id id = c.getColumnKey();
       if (!id.equals(getAccountId())) {
@@ -583,8 +679,7 @@
 
     if (submitRecords != null) {
       for (SubmitRecord rec : submitRecords) {
-        addFooter(msg, FOOTER_SUBMITTED_WITH)
-            .append(rec.status);
+        addFooter(msg, FOOTER_SUBMITTED_WITH).append(rec.status);
         if (rec.errorMessage != null) {
           msg.append(' ').append(sanitizeFooter(rec.errorMessage));
         }
@@ -592,13 +687,14 @@
 
         if (rec.labels != null) {
           for (SubmitRecord.Label label : rec.labels) {
+            // Label names/values are safe to append without sanitizing.
             addFooter(msg, FOOTER_SUBMITTED_WITH)
-                .append(label.status).append(": ").append(label.label);
+                .append(label.status)
+                .append(": ")
+                .append(label.label);
             if (label.appliedBy != null) {
-              PersonIdent ident =
-                  newIdent(accountCache.get(label.appliedBy).getAccount(), when);
-              msg.append(": ").append(ident.getName())
-                  .append(" <").append(ident.getEmailAddress()).append('>');
+              msg.append(": ");
+              addIdent(msg, label.appliedBy);
             }
             msg.append('\n');
           }
@@ -606,6 +702,15 @@
       }
     }
 
+    if (!Objects.equals(accountId, realAccountId)) {
+      addFooter(msg, FOOTER_REAL_USER);
+      addIdent(msg, realAccountId).append('\n');
+    }
+
+    if (readOnlyUntil != null) {
+      addFooter(msg, FOOTER_READ_ONLY_UNTIL, ChangeNoteUtil.formatTime(serverIdent, readOnlyUntil));
+    }
+
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
@@ -643,18 +748,26 @@
         && status == null
         && submissionId == null
         && submitRecords == null
+        && assignee == null
         && hashtags == null
         && topic == null
         && commit == null
         && psState == null
         && groups == null
-        && tag == null;
+        && tag == null
+        && psDescription == null
+        && !currentPatchSet
+        && readOnlyUntil == null;
   }
 
   ChangeDraftUpdate getDraftUpdate() {
     return draftUpdate;
   }
 
+  RobotCommentUpdate getRobotCommentUpdate() {
+    return robotCommentUpdate;
+  }
+
   public void setAllowWriteToNewRef(boolean allow) {
     isAllowWriteToNewtRef = allow;
   }
@@ -664,15 +777,18 @@
     return isAllowWriteToNewtRef;
   }
 
+  void setReadOnlyUntil(Timestamp readOnlyUntil) {
+    this.readOnlyUntil = readOnlyUntil;
+  }
+
   private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
     return sb.append(footer.getName()).append(": ");
   }
 
-  private static void addFooter(StringBuilder sb, FooterKey footer,
-      Object... values) {
+  private static void addFooter(StringBuilder sb, FooterKey footer, Object... values) {
     addFooter(sb, footer);
     for (Object value : values) {
-      sb.append(value);
+      sb.append(sanitizeFooter(Objects.toString(value)));
     }
     sb.append('\n');
   }
@@ -688,7 +804,12 @@
     return sb;
   }
 
-  private static String sanitizeFooter(String value) {
-    return value.replace('\n', ' ').replace('\0', ' ');
+  @Override
+  protected void checkNotReadOnly() throws OrmException {
+    // Allow setting Read-only-until to 0 to release an existing lease.
+    if (readOnlyUntil != null && readOnlyUntil.getTime() == 0) {
+      return;
+    }
+    super.checkNotReadOnly();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
new file mode 100644
index 0000000..2f47107
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static java.time.format.DateTimeFormatter.ISO_INSTANT;
+
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.format.FormatStyle;
+import java.time.temporal.TemporalAccessor;
+
+/**
+ * Adapter that reads/writes {@link Timestamp}s as ISO 8601 instant in UTC.
+ *
+ * <p>This adapter reads and writes the ISO 8601 UTC instant format, {@code "2015-06-22T17:11:00Z"}.
+ * This format is specially chosen because it is also readable by the default Gson type adapter,
+ * despite the fact that the default adapter writes in a different format lacking timezones, {@code
+ * "Jun 22, 2015 10:11:00 AM"}. Unlike the default adapter format, this representation is not
+ * ambiguous during the transition away from DST.
+ *
+ * <p>This adapter is mutually compatible with the old adapter: the old adapter is able to read the
+ * UTC instant format, and this adapter can fall back to parsing the old format.
+ *
+ * <p>Older Gson versions are not able to parse milliseconds out of ISO 8601 instants, so this
+ * implementation truncates to seconds when writing. This is no worse than the truncation that
+ * happens to fit NoteDb timestamps into git commit formatting.
+ */
+class CommentTimestampAdapter extends TypeAdapter<Timestamp> {
+  private static final DateTimeFormatter FALLBACK =
+      DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
+
+  @Override
+  public void write(JsonWriter out, Timestamp ts) throws IOException {
+    Timestamp truncated = new Timestamp(ts.getTime() / 1000 * 1000);
+    out.value(ISO_INSTANT.format(truncated.toInstant()));
+  }
+
+  @Override
+  public Timestamp read(JsonReader in) throws IOException {
+    String str = in.nextString();
+    TemporalAccessor ta;
+    try {
+      ta = ISO_INSTANT.parse(str);
+    } catch (DateTimeParseException e) {
+      ta = LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault());
+    }
+    return Timestamp.from(Instant.from(ta));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
index 802359c..c0b0525 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
@@ -15,28 +15,24 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.NoteDbTable.ACCOUNTS;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
-import java.util.HashSet;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Implement NoteDb migration stages using {@code gerrit.config}.
- * <p>
- * This class controls the state of the migration according to options in
- * {@code gerrit.config}. In general, any changes to these options should only
- * be made by adventurous administrators, who know what they're doing, on
- * non-production data, for the purposes of testing the NoteDb implementation.
- * Changing options quite likely requires re-running {@code RebuildNoteDb}. For
+ *
+ * <p>This class controls the state of the migration according to options in {@code gerrit.config}.
+ * In general, any changes to these options should only be made by adventurous administrators, who
+ * know what they're doing, on non-production data, for the purposes of testing the NoteDb
+ * implementation. Changing options quite likely requires re-running {@code RebuildNoteDb}. For
  * these reasons, the options remain undocumented.
  */
 @Singleton
@@ -49,41 +45,43 @@
   }
 
   private static final String NOTE_DB = "noteDb";
+
+  // All of these names must be reflected in the allowed set in checkConfig.
+  private static final String DISABLE_REVIEW_DB = "disableReviewDb";
+  private static final String PRIMARY_STORAGE = "primaryStorage";
   private static final String READ = "read";
-  private static final String WRITE = "write";
   private static final String SEQUENCE = "sequence";
+  private static final String WRITE = "write";
 
   private static void checkConfig(Config cfg) {
-    Set<String> keys = new HashSet<>();
-    for (NoteDbTable t : NoteDbTable.values()) {
-      keys.add(t.key());
-    }
-    Set<String> allowed = ImmutableSet.of(READ, WRITE, SEQUENCE);
+    Set<String> keys = ImmutableSet.of(CHANGES.key());
+    Set<String> allowed =
+        ImmutableSet.of(
+            DISABLE_REVIEW_DB.toLowerCase(),
+            PRIMARY_STORAGE.toLowerCase(),
+            READ.toLowerCase(),
+            WRITE.toLowerCase(),
+            SEQUENCE.toLowerCase());
     for (String t : cfg.getSubsections(NOTE_DB)) {
-      checkArgument(keys.contains(t.toLowerCase()),
-          "invalid NoteDb table: %s", t);
+      checkArgument(keys.contains(t.toLowerCase()), "invalid NoteDb table: %s", t);
       for (String key : cfg.getNames(NOTE_DB, t)) {
-        checkArgument(allowed.contains(key.toLowerCase()),
-            "invalid NoteDb key: %s.%s", t, key);
+        checkArgument(allowed.contains(key.toLowerCase()), "invalid NoteDb key: %s.%s", t, key);
       }
     }
   }
 
   public static Config allEnabledConfig() {
     Config cfg = new Config();
-    for (NoteDbTable t : NoteDbTable.values()) {
-      cfg.setBoolean(NOTE_DB, t.key(), WRITE, true);
-      cfg.setBoolean(NOTE_DB, t.key(), READ, true);
-    }
+    cfg.setBoolean(NOTE_DB, CHANGES.key(), WRITE, true);
+    cfg.setBoolean(NOTE_DB, CHANGES.key(), READ, true);
     return cfg;
   }
 
   private final boolean writeChanges;
   private final boolean readChanges;
   private final boolean readChangeSequence;
-
-  private final boolean writeAccounts;
-  private final boolean readAccounts;
+  private final PrimaryStorage changePrimaryStorage;
+  private final boolean disableChangeReviewDb;
 
   @Inject
   ConfigNotesMigration(@GerritServerConfig Config cfg) {
@@ -98,8 +96,13 @@
     // NoteDb. This decision for the default may be reevaluated later.
     readChangeSequence = cfg.getBoolean(NOTE_DB, CHANGES.key(), SEQUENCE, false);
 
-    writeAccounts = cfg.getBoolean(NOTE_DB, ACCOUNTS.key(), WRITE, false);
-    readAccounts = cfg.getBoolean(NOTE_DB, ACCOUNTS.key(), READ, false);
+    changePrimaryStorage =
+        cfg.getEnum(NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB);
+    disableChangeReviewDb = cfg.getBoolean(NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false);
+
+    checkArgument(
+        !(disableChangeReviewDb && changePrimaryStorage != PrimaryStorage.NOTE_DB),
+        "cannot disable ReviewDb for changes if default change primary storage is ReviewDb");
   }
 
   @Override
@@ -118,12 +121,12 @@
   }
 
   @Override
-  public boolean writeAccounts() {
-    return writeAccounts;
+  public PrimaryStorage changePrimaryStorage() {
+    return changePrimaryStorage;
   }
 
   @Override
-  public boolean readAccounts() {
-    return readAccounts;
+  public boolean disableChangeReviewDb() {
+    return disableChangeReviewDb;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 08195e4..008f31f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -14,31 +14,38 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.StagedResult;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -46,47 +53,38 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-/**
- * View of the draft comments for a single {@link Change} based on the log of
- * its drafts branch.
- */
+/** View of the draft comments for a single {@link Change} based on the log of its drafts branch. */
 public class DraftCommentNotes extends AbstractChangeNotes<DraftCommentNotes> {
-  private static final Logger log =
-      LoggerFactory.getLogger(DraftCommentNotes.class);
+  private static final Logger log = LoggerFactory.getLogger(DraftCommentNotes.class);
 
   public interface Factory {
     DraftCommentNotes create(Change change, Account.Id accountId);
-    DraftCommentNotes createWithAutoRebuildingDisabled(
-        Change.Id changeId, Account.Id accountId);
+
+    DraftCommentNotes createWithAutoRebuildingDisabled(Change.Id changeId, Account.Id accountId);
   }
 
   private final Change change;
   private final Account.Id author;
   private final NoteDbUpdateManager.Result rebuildResult;
+  private final Ref ref;
 
-  private ImmutableListMultimap<RevId, PatchLineComment> comments;
-  private RevisionNoteMap revisionNoteMap;
+  private ImmutableListMultimap<RevId, Comment> comments;
+  private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
-  DraftCommentNotes(
-      Args args,
-      @Assisted Change change,
-      @Assisted Account.Id author) {
-    this(args, change, author, true, null);
+  DraftCommentNotes(Args args, @Assisted Change change, @Assisted Account.Id author) {
+    this(args, change, author, true, null, null);
   }
 
   @AssistedInject
-  DraftCommentNotes(
-      Args args,
-      @Assisted Change.Id changeId,
-      @Assisted Account.Id author) {
-    super(args, changeId, true);
+  DraftCommentNotes(Args args, @Assisted Change.Id changeId, @Assisted Account.Id author) {
+    // PrimaryStorage is unknown; this should only called by
+    // PatchLineCommentsUtil#draftByAuthor, which can live with this.
+    super(args, changeId, null, false);
     this.change = null;
     this.author = author;
     this.rebuildResult = null;
+    this.ref = null;
   }
 
   DraftCommentNotes(
@@ -94,14 +92,24 @@
       Change change,
       Account.Id author,
       boolean autoRebuild,
-      NoteDbUpdateManager.Result rebuildResult) {
-    super(args, change.getId(), autoRebuild);
+      @Nullable NoteDbUpdateManager.Result rebuildResult,
+      @Nullable Ref ref) {
+    super(args, change.getId(), PrimaryStorage.of(change), autoRebuild);
     this.change = change;
     this.author = author;
     this.rebuildResult = rebuildResult;
+    this.ref = ref;
+    if (ref != null) {
+      checkArgument(
+          ref.getName().equals(getRefName()),
+          "draft ref not for change %s and account %s: %s",
+          getChangeId(),
+          author,
+          ref.getName());
+    }
   }
 
-  RevisionNoteMap getRevisionNoteMap() {
+  RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
     return revisionNoteMap;
   }
 
@@ -109,13 +117,13 @@
     return author;
   }
 
-  public ImmutableListMultimap<RevId, PatchLineComment> getComments() {
+  public ImmutableListMultimap<RevId, Comment> getComments() {
     return comments;
   }
 
-  public boolean containsComment(PatchLineComment c) {
-    for (PatchLineComment existing : comments.values()) {
-      if (c.getKey().equals(existing.getKey())) {
+  public boolean containsComment(Comment c) {
+    for (Comment existing : comments.values()) {
+      if (c.key.equals(existing.key)) {
         return true;
       }
     }
@@ -124,12 +132,19 @@
 
   @Override
   protected String getRefName() {
-    return RefNames.refsDraftComments(getChangeId(), author);
+    return refsDraftComments(getChangeId(), author);
   }
 
   @Override
-  protected void onLoad(LoadHandle handle)
-      throws IOException, ConfigInvalidException {
+  protected ObjectId readRef(Repository repo) throws IOException {
+    if (ref != null) {
+      return ref.getObjectId();
+    }
+    return super.readRef(repo);
+  }
+
+  @Override
+  protected void onLoad(LoadHandle handle) throws IOException, ConfigInvalidException {
     ObjectId rev = handle.id();
     if (rev == null) {
       loadDefaults();
@@ -138,13 +153,17 @@
 
     RevCommit tipCommit = handle.walk().parseCommit(rev);
     ObjectReader reader = handle.walk().getObjectReader();
-    revisionNoteMap = RevisionNoteMap.parse(
-        args.noteUtil, getChangeId(), reader, NoteMap.read(reader, tipCommit),
-        true);
-    Multimap<RevId, PatchLineComment> cs = ArrayListMultimap.create();
-    for (RevisionNote rn : revisionNoteMap.revisionNotes.values()) {
-      for (PatchLineComment c : rn.comments) {
-        cs.put(c.getRevId(), c);
+    revisionNoteMap =
+        RevisionNoteMap.parse(
+            args.noteUtil,
+            getChangeId(),
+            reader,
+            NoteMap.read(reader, tipCommit),
+            PatchLineComment.Status.DRAFT);
+    ListMultimap<RevId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
+      for (Comment c : rn.getComments()) {
+        cs.put(new RevId(c.revId), c);
       }
     }
     comments = ImmutableListMultimap.copyOf(cs);
@@ -161,7 +180,7 @@
   }
 
   @Override
-  protected LoadHandle openHandle(Repository repo) throws IOException {
+  protected LoadHandle openHandle(Repository repo) throws NoSuchChangeException, IOException {
     if (rebuildResult != null) {
       StagedResult sr = checkNotNull(rebuildResult.staged());
       return LoadHandle.create(
@@ -179,8 +198,7 @@
     return super.openHandle(repo);
   }
 
-  private static ObjectId findNewId(
-      Iterable<ReceiveCommand> cmds, String refName) {
+  private static ObjectId findNewId(Iterable<ReceiveCommand> cmds, String refName) {
     for (ReceiveCommand cmd : cmds) {
       if (cmd.getRefName().equals(refName)) {
         return cmd.getNewId();
@@ -189,7 +207,7 @@
     return null;
   }
 
-  private LoadHandle rebuildAndOpen(Repository repo) throws IOException {
+  private LoadHandle rebuildAndOpen(Repository repo) throws NoSuchChangeException, IOException {
     Timer1.Context timer = args.metrics.autoRebuildLatency.start(CHANGES);
     try {
       Change.Id cid = getChangeId();
@@ -206,14 +224,11 @@
           repo.scanForRepoChanges();
         } catch (OrmException | IOException e) {
           // See ChangeNotes#rebuildAndOpen.
-          log.debug("Rebuilding change {} via drafts failed: {}",
-              getChangeId(), e.getMessage());
+          log.debug("Rebuilding change {} via drafts failed: {}", getChangeId(), e.getMessage());
           args.metrics.autoRebuildFailureCount.increment(CHANGES);
           checkNotNull(r.staged());
           return LoadHandle.create(
-              ChangeNotesCommit.newStagedRevWalk(
-                  repo, r.staged().allUsersObjects()),
-              draftsId(r));
+              ChangeNotesCommit.newStagedRevWalk(repo, r.staged().allUsersObjects()), draftsId(r));
         }
       }
       return LoadHandle.create(ChangeNotesCommit.newRevWalk(repo), draftsId(r));
@@ -222,11 +237,10 @@
     } catch (OrmException e) {
       throw new IOException(e);
     } finally {
-      log.debug("Rebuilt change {} in {} in {} ms via drafts",
+      log.debug(
+          "Rebuilt change {} in {} in {} ms via drafts",
           getChangeId(),
-          change != null
-              ? "project " + change.getProject()
-              : "unknown project",
+          change != null ? "project " + change.getProject() : "unknown project",
           TimeUnit.MILLISECONDS.convert(timer.stop(), TimeUnit.NANOSECONDS));
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
new file mode 100644
index 0000000..ee28d29
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.ChangeBundle.Source;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+
+@Singleton
+public class GwtormChangeBundleReader implements ChangeBundleReader {
+  @Inject
+  GwtormChangeBundleReader() {}
+
+  @Override
+  public ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) 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),
+          approvals,
+          db.patchComments().byChange(id),
+          ReviewerSet.fromApprovals(approvals),
+          Source.REVIEW_DB);
+    } finally {
+      db.rollback();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
index 4a7a781..fef7fdf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
@@ -16,90 +16,201 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicates;
 import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.primitives.Longs;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.git.RefCache;
-
-import org.eclipse.jgit.lib.ObjectId;
-
+import com.google.gwtorm.server.OrmRuntimeException;
 import java.io.IOException;
-import java.util.Collections;
+import java.sql.Timestamp;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
- * The state of all relevant NoteDb refs across all repos corresponding to a
- * given Change entity.
- * <p>
- * Stored serialized in the {@code Change#noteDbState} field, and used to
- * determine whether the state in NoteDb is out of date.
- * <p>
- * Serialized in the form:
- * <pre>
- *   [meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
- * </pre>
+ * The state of all relevant NoteDb refs across all repos corresponding to a given Change entity.
+ *
+ * <p>Stored serialized in the {@code Change#noteDbState} field, and used to determine whether the
+ * state in NoteDb is out of date.
+ *
+ * <p>Serialized in one of the forms:
+ *
+ * <ul>
+ *   <li>[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
+ *   <li>R,[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
+ *   <li>R=[read-only-until],[meta-sha],[account1]=[drafts-sha],[account2]=[drafts-sha]...
+ *   <li>N
+ *   <li>N=[read-only-until]
+ * </ul>
+ *
  * in numeric account ID order, with hex SHA-1s for human readability.
  */
 public class NoteDbChangeState {
+  public static final String NOTE_DB_PRIMARY_STATE = "N";
+
+  public enum PrimaryStorage {
+    REVIEW_DB('R'),
+    NOTE_DB('N');
+
+    private final char code;
+
+    PrimaryStorage(char code) {
+      this.code = code;
+    }
+
+    public static PrimaryStorage of(Change c) {
+      return of(NoteDbChangeState.parse(c));
+    }
+
+    public static PrimaryStorage of(NoteDbChangeState s) {
+      return s != null ? s.getPrimaryStorage() : REVIEW_DB;
+    }
+  }
+
   @AutoValue
   public abstract static class Delta {
-    static Delta create(Change.Id changeId, Optional<ObjectId> newChangeMetaId,
+    @VisibleForTesting
+    public static Delta create(
+        Change.Id changeId,
+        Optional<ObjectId> newChangeMetaId,
         Map<Account.Id, ObjectId> newDraftIds) {
       if (newDraftIds == null) {
         newDraftIds = ImmutableMap.of();
       }
       return new AutoValue_NoteDbChangeState_Delta(
-          changeId,
-          newChangeMetaId,
-          ImmutableMap.copyOf(newDraftIds));
+          changeId, newChangeMetaId, ImmutableMap.copyOf(newDraftIds));
     }
 
     abstract Change.Id changeId();
+
     abstract Optional<ObjectId> newChangeMetaId();
+
     abstract ImmutableMap<Account.Id, ObjectId> newDraftIds();
   }
 
+  @AutoValue
+  public abstract static class RefState {
+    @VisibleForTesting
+    public static RefState create(ObjectId changeMetaId, Map<Account.Id, ObjectId> draftIds) {
+      return new AutoValue_NoteDbChangeState_RefState(
+          changeMetaId.copy(),
+          ImmutableMap.copyOf(Maps.filterValues(draftIds, id -> !ObjectId.zeroId().equals(id))));
+    }
+
+    private static Optional<RefState> parse(Change.Id changeId, List<String> parts) {
+      checkArgument(!parts.isEmpty(), "missing state string for change %s", changeId);
+      ObjectId changeMetaId = ObjectId.fromString(parts.get(0));
+      Map<Account.Id, ObjectId> draftIds = Maps.newHashMapWithExpectedSize(parts.size() - 1);
+      Splitter s = Splitter.on('=');
+      for (int i = 1; i < parts.size(); i++) {
+        String p = parts.get(i);
+        List<String> draftParts = s.splitToList(p);
+        checkArgument(
+            draftParts.size() == 2, "invalid draft state part for change %s: %s", changeId, p);
+        draftIds.put(Account.Id.parse(draftParts.get(0)), ObjectId.fromString(draftParts.get(1)));
+      }
+      return Optional.of(create(changeMetaId, draftIds));
+    }
+
+    abstract ObjectId changeMetaId();
+
+    abstract ImmutableMap<Account.Id, ObjectId> draftIds();
+
+    @Override
+    public String toString() {
+      return appendTo(new StringBuilder()).toString();
+    }
+
+    StringBuilder appendTo(StringBuilder sb) {
+      sb.append(changeMetaId().name());
+      for (Account.Id id : ReviewDbUtil.intKeyOrdering().sortedCopy(draftIds().keySet())) {
+        sb.append(',').append(id.get()).append('=').append(draftIds().get(id).name());
+      }
+      return sb;
+    }
+  }
+
   public static NoteDbChangeState parse(Change c) {
-    return parse(c.getId(), c.getNoteDbState());
+    return c != null ? parse(c.getId(), c.getNoteDbState()) : null;
   }
 
   @VisibleForTesting
-  static NoteDbChangeState parse(Change.Id id, String str) {
-    if (str == null) {
+  public static NoteDbChangeState parse(Change.Id id, String str) {
+    if (Strings.isNullOrEmpty(str)) {
+      // Return null rather than Optional as this is what goes in the field in
+      // ReviewDb.
       return null;
     }
     List<String> parts = Splitter.on(',').splitToList(str);
-    checkArgument(!parts.isEmpty(),
-        "invalid state string for change %s: %s", id, str);
-    ObjectId changeMetaId = ObjectId.fromString(parts.get(0));
-    Map<Account.Id, ObjectId> draftIds =
-        Maps.newHashMapWithExpectedSize(parts.size() - 1);
-    Splitter s = Splitter.on('=');
-    for (int i = 1; i < parts.size(); i++) {
-      String p = parts.get(i);
-      List<String> draftParts = s.splitToList(p);
-      checkArgument(draftParts.size() == 2,
-          "invalid draft state part for change %s: %s", id, p);
-      draftIds.put(Account.Id.parse(draftParts.get(0)),
-          ObjectId.fromString(draftParts.get(1)));
+    String first = parts.get(0);
+    Optional<Timestamp> readOnlyUntil = parseReadOnlyUntil(id, str, first);
+
+    // Only valid NOTE_DB state is "N".
+    if (parts.size() == 1 && first.charAt(0) == NOTE_DB.code) {
+      return new NoteDbChangeState(id, NOTE_DB, Optional.empty(), readOnlyUntil);
     }
-    return new NoteDbChangeState(id, changeMetaId, draftIds);
+
+    // Otherwise it must be REVIEW_DB, either "R,<RefState>" or just
+    // "<RefState>". Allow length > 0 for forward compatibility.
+    if (first.length() > 0) {
+      Optional<RefState> refState;
+      if (first.charAt(0) == REVIEW_DB.code) {
+        refState = RefState.parse(id, parts.subList(1, parts.size()));
+      } else {
+        refState = RefState.parse(id, parts);
+      }
+      return new NoteDbChangeState(id, REVIEW_DB, refState, readOnlyUntil);
+    }
+    throw invalidState(id, str);
   }
 
+  private static Optional<Timestamp> parseReadOnlyUntil(
+      Change.Id id, String fullStr, String first) {
+    if (first.length() > 2 && first.charAt(1) == '=') {
+      Long ts = Longs.tryParse(first.substring(2));
+      if (ts == null) {
+        throw invalidState(id, fullStr);
+      }
+      return Optional.of(new Timestamp(ts));
+    }
+    return Optional.empty();
+  }
+
+  private static IllegalArgumentException invalidState(Change.Id id, String str) {
+    return new IllegalArgumentException("invalid state string for change " + id + ": " + str);
+  }
+
+  /**
+   * Apply a delta to the state stored in a change entity.
+   *
+   * <p>This method does not check whether the old state was read-only; it is up to the caller to
+   * not violate read-only semantics when storing the change back in ReviewDb.
+   *
+   * @param change change entity. The delta is applied against this entity's {@code noteDbState} and
+   *     the new state is stored back in the entity as a side effect.
+   * @param delta delta to apply.
+   * @return new state, equivalent to what is stored in {@code change} as a side effect.
+   */
   public static NoteDbChangeState applyDelta(Change change, Delta delta) {
     if (delta == null) {
       return null;
@@ -112,6 +223,10 @@
       return null;
     }
     NoteDbChangeState oldState = parse(change.getId(), oldStr);
+    if (oldState != null && oldState.getPrimaryStorage() == NOTE_DB) {
+      // NOTE_DB state doesn't include RefState, so applying a delta is a no-op.
+      return oldState;
+    }
 
     ObjectId changeMetaId;
     if (delta.newChangeMetaId().isPresent()) {
@@ -121,12 +236,12 @@
         return null;
       }
     } else {
-      changeMetaId = oldState.changeMetaId;
+      changeMetaId = oldState.getChangeMetaId();
     }
 
     Map<Account.Id, ObjectId> draftIds = new HashMap<>();
     if (oldState != null) {
-      draftIds.putAll(oldState.draftIds);
+      draftIds.putAll(oldState.getDraftIds());
     }
     for (Map.Entry<Account.Id, ObjectId> e : delta.newDraftIds().entrySet()) {
       if (e.getValue().equals(ObjectId.zeroId())) {
@@ -136,80 +251,148 @@
       }
     }
 
-    NoteDbChangeState state = new NoteDbChangeState(
-        change.getId(), changeMetaId, draftIds);
+    NoteDbChangeState state =
+        new NoteDbChangeState(
+            change.getId(),
+            oldState != null ? oldState.getPrimaryStorage() : REVIEW_DB,
+            Optional.of(RefState.create(changeMetaId, draftIds)),
+            // Copy old read-only deadline rather than advancing it; the caller is
+            // still responsible for finishing the rest of its work before the lease
+            // runs out.
+            oldState != null ? oldState.getReadOnlyUntil() : Optional.empty());
     change.setNoteDbState(state.toString());
     return state;
   }
 
-  public static boolean isChangeUpToDate(@Nullable NoteDbChangeState state,
-      RefCache changeRepoRefs, Change.Id changeId) throws IOException {
+  // TODO(dborowitz): Ugly. Refactor these static methods into a Checker class
+  // or something. They do not belong in NoteDbChangeState itself because:
+  //  - need to inject Config but don't want a whole Factory
+  //  - can't be methods on NoteDbChangeState because state is nullable (though
+  //    we could also solve this by inventing an empty-but-non-null state)
+  // Also we should clean up duplicated code between static/non-static methods.
+  public static boolean isChangeUpToDate(
+      @Nullable NoteDbChangeState state, RefCache changeRepoRefs, Change.Id changeId)
+      throws IOException {
+    if (PrimaryStorage.of(state) == NOTE_DB) {
+      return true; // Primary storage is NoteDb, up to date by definition.
+    }
     if (state == null) {
       return !changeRepoRefs.get(changeMetaRef(changeId)).isPresent();
     }
     return state.isChangeUpToDate(changeRepoRefs);
   }
 
-  public static boolean areDraftsUpToDate(@Nullable NoteDbChangeState state,
-      RefCache draftsRepoRefs, Change.Id changeId, Account.Id accountId)
+  public static boolean areDraftsUpToDate(
+      @Nullable NoteDbChangeState state,
+      RefCache draftsRepoRefs,
+      Change.Id changeId,
+      Account.Id accountId)
       throws IOException {
+    if (PrimaryStorage.of(state) == NOTE_DB) {
+      return true; // Primary storage is NoteDb, up to date by definition.
+    }
     if (state == null) {
-      return !draftsRepoRefs.get(refsDraftComments(changeId, accountId))
-          .isPresent();
+      return !draftsRepoRefs.get(refsDraftComments(changeId, accountId)).isPresent();
     }
     return state.areDraftsUpToDate(draftsRepoRefs, accountId);
   }
 
-  public static String toString(ObjectId changeMetaId,
-      Map<Account.Id, ObjectId> draftIds) {
-    List<Account.Id> accountIds = Lists.newArrayList(draftIds.keySet());
-    Collections.sort(accountIds, ReviewDbUtil.intKeyOrdering());
-    StringBuilder sb = new StringBuilder(changeMetaId.name());
-    for (Account.Id id : accountIds) {
-      sb.append(',')
-          .append(id.get())
-          .append('=')
-          .append(draftIds.get(id).name());
+  public static long getReadOnlySkew(Config cfg) {
+    return cfg.getTimeUnit("notedb", null, "maxTimestampSkew", 1000, TimeUnit.MILLISECONDS);
+  }
+
+  static Timestamp timeForReadOnlyCheck(long skewMs) {
+    // Subtract some slop in case the machine that set the change's read-only
+    // lease has a clock behind ours.
+    return new Timestamp(TimeUtil.nowMs() - skewMs);
+  }
+
+  public static void checkNotReadOnly(@Nullable Change change, long skewMs) {
+    checkNotReadOnly(parse(change), skewMs);
+  }
+
+  public static void checkNotReadOnly(@Nullable NoteDbChangeState state, long skewMs) {
+    if (state == null) {
+      return; // No state means ReviewDb primary non-read-only.
+    } else if (state.isReadOnly(timeForReadOnlyCheck(skewMs))) {
+      throw new OrmRuntimeException(
+          "change "
+              + state.getChangeId()
+              + " is read-only until "
+              + state.getReadOnlyUntil().get());
     }
-    return sb.toString();
   }
 
   private final Change.Id changeId;
-  private final ObjectId changeMetaId;
-  private final ImmutableMap<Account.Id, ObjectId> draftIds;
+  private final PrimaryStorage primaryStorage;
+  private final Optional<RefState> refState;
+  private final Optional<Timestamp> readOnlyUntil;
 
-  public NoteDbChangeState(Change.Id changeId, ObjectId changeMetaId,
-      Map<Account.Id, ObjectId> draftIds) {
+  public NoteDbChangeState(
+      Change.Id changeId,
+      PrimaryStorage primaryStorage,
+      Optional<RefState> refState,
+      Optional<Timestamp> readOnlyUntil) {
     this.changeId = checkNotNull(changeId);
-    this.changeMetaId = checkNotNull(changeMetaId);
-    this.draftIds = ImmutableMap.copyOf(Maps.filterValues(
-        draftIds, Predicates.not(Predicates.equalTo(ObjectId.zeroId()))));
+    this.primaryStorage = checkNotNull(primaryStorage);
+    this.refState = checkNotNull(refState);
+    this.readOnlyUntil = checkNotNull(readOnlyUntil);
+
+    switch (primaryStorage) {
+      case REVIEW_DB:
+        checkArgument(
+            refState.isPresent(),
+            "expected RefState for change %s with primary storage %s",
+            changeId,
+            primaryStorage);
+        break;
+      case NOTE_DB:
+        checkArgument(
+            !refState.isPresent(),
+            "expected no RefState for change %s with primary storage %s",
+            changeId,
+            primaryStorage);
+        break;
+      default:
+        throw new IllegalStateException("invalid PrimaryStorage: " + primaryStorage);
+    }
+  }
+
+  public PrimaryStorage getPrimaryStorage() {
+    return primaryStorage;
   }
 
   public boolean isChangeUpToDate(RefCache changeRepoRefs) throws IOException {
+    if (primaryStorage == NOTE_DB) {
+      return true; // Primary storage is NoteDb, up to date by definition.
+    }
     Optional<ObjectId> id = changeRepoRefs.get(changeMetaRef(changeId));
     if (!id.isPresent()) {
-      return changeMetaId.equals(ObjectId.zeroId());
+      return getChangeMetaId().equals(ObjectId.zeroId());
     }
-    return id.get().equals(changeMetaId);
+    return id.get().equals(getChangeMetaId());
   }
 
   public boolean areDraftsUpToDate(RefCache draftsRepoRefs, Account.Id accountId)
       throws IOException {
-    Optional<ObjectId> id =
-        draftsRepoRefs.get(refsDraftComments(changeId, accountId));
-    if (!id.isPresent()) {
-      return !draftIds.containsKey(accountId);
+    if (primaryStorage == NOTE_DB) {
+      return true; // Primary storage is NoteDb, up to date by definition.
     }
-    return id.get().equals(draftIds.get(accountId));
+    Optional<ObjectId> id = draftsRepoRefs.get(refsDraftComments(changeId, accountId));
+    if (!id.isPresent()) {
+      return !getDraftIds().containsKey(accountId);
+    }
+    return id.get().equals(getDraftIds().get(accountId));
   }
 
-  boolean isUpToDate(RefCache changeRepoRefs, RefCache draftsRepoRefs)
-      throws IOException {
+  public boolean isUpToDate(RefCache changeRepoRefs, RefCache draftsRepoRefs) throws IOException {
+    if (primaryStorage == NOTE_DB) {
+      return true; // Primary storage is NoteDb, up to date by definition.
+    }
     if (!isChangeUpToDate(changeRepoRefs)) {
       return false;
     }
-    for (Account.Id accountId : draftIds.keySet()) {
+    for (Account.Id accountId : getDraftIds().keySet()) {
       if (!areDraftsUpToDate(draftsRepoRefs, accountId)) {
         return false;
       }
@@ -217,23 +400,72 @@
     return true;
   }
 
-  @VisibleForTesting
-  Change.Id getChangeId() {
+  public boolean isReadOnly(Timestamp now) {
+    return readOnlyUntil.isPresent() && now.before(readOnlyUntil.get());
+  }
+
+  public Optional<Timestamp> getReadOnlyUntil() {
+    return readOnlyUntil;
+  }
+
+  public NoteDbChangeState withReadOnlyUntil(Timestamp ts) {
+    return new NoteDbChangeState(changeId, primaryStorage, refState, Optional.of(ts));
+  }
+
+  public Change.Id getChangeId() {
     return changeId;
   }
 
-  @VisibleForTesting
   public ObjectId getChangeMetaId() {
-    return changeMetaId;
+    return refState().changeMetaId();
   }
 
-  @VisibleForTesting
-  ImmutableMap<Account.Id, ObjectId> getDraftIds() {
-    return draftIds;
+  public ImmutableMap<Account.Id, ObjectId> getDraftIds() {
+    return refState().draftIds();
+  }
+
+  public Optional<RefState> getRefState() {
+    return refState;
+  }
+
+  private RefState refState() {
+    checkState(refState.isPresent(), "state for %s has no RefState: %s", changeId, this);
+    return refState.get();
   }
 
   @Override
   public String toString() {
-    return toString(changeMetaId, draftIds);
+    switch (primaryStorage) {
+      case REVIEW_DB:
+        if (!readOnlyUntil.isPresent()) {
+          // Don't include enum field, just IDs (though parse would accept it).
+          return refState().toString();
+        }
+        return primaryStorage.code + "=" + readOnlyUntil.get().getTime() + "," + refState.get();
+      case NOTE_DB:
+        if (!readOnlyUntil.isPresent()) {
+          return NOTE_DB_PRIMARY_STATE;
+        }
+        return primaryStorage.code + "=" + readOnlyUntil.get().getTime();
+      default:
+        throw new IllegalArgumentException("Unsupported PrimaryStorage: " + primaryStorage);
+    }
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(changeId, primaryStorage, refState, readOnlyUntil);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof NoteDbChangeState)) {
+      return false;
+    }
+    NoteDbChangeState s = (NoteDbChangeState) o;
+    return changeId.equals(s.changeId)
+        && primaryStorage.equals(s.primaryStorage)
+        && refState.equals(s.refState)
+        && readOnlyUntil.equals(s.readOnlyUntil);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
index 24e87de..be06d11 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
@@ -29,30 +29,25 @@
   final Timer1<NoteDbTable> updateLatency;
 
   /**
-   * The portion of {@link #updateLatency} due to preparing the sequence of
-   * updates.
-   * <p>
-   * May include some I/O (e.g. reading old refs), but excludes writes.
+   * The portion of {@link #updateLatency} due to preparing the sequence of updates.
+   *
+   * <p>May include some I/O (e.g. reading old refs), but excludes writes.
    */
   final Timer1<NoteDbTable> stageUpdateLatency;
 
-  /**
-   * End-to-end latency for reading changes from NoteDb, including reading
-   * ref(s) and parsing.
-   */
+  /** End-to-end latency for reading changes from NoteDb, including reading ref(s) and parsing. */
   final Timer1<NoteDbTable> readLatency;
 
   /**
-   * The portion of {@link #readLatency} due to parsing commits, but excluding
-   * I/O (to a best effort).
+   * The portion of {@link #readLatency} due to parsing commits, but excluding I/O (to a best
+   * effort).
    */
   final Timer1<NoteDbTable> parseLatency;
 
   /**
    * Latency due to auto-rebuilding entities when out of date.
-   * <p>
-   * Excludes latency from reading ref to check whether the entity is up to
-   * date.
+   *
+   * <p>Excludes latency from reading ref to check whether the entity is up to date.
    */
   final Timer1<NoteDbTable> autoRebuildLatency;
 
@@ -63,45 +58,50 @@
   NoteDbMetrics(MetricMaker metrics) {
     Field<NoteDbTable> view = Field.ofEnum(NoteDbTable.class, "table");
 
-    updateLatency = metrics.newTimer(
-        "notedb/update_latency",
-        new Description("NoteDb update latency by table")
-            .setCumulative()
-            .setUnit(Units.MILLISECONDS),
-        view);
+    updateLatency =
+        metrics.newTimer(
+            "notedb/update_latency",
+            new Description("NoteDb update latency by table")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            view);
 
-    stageUpdateLatency = metrics.newTimer(
-        "notedb/stage_update_latency",
-        new Description("Latency for staging updates to NoteDb by table")
-            .setCumulative()
-            .setUnit(Units.MICROSECONDS),
-        view);
+    stageUpdateLatency =
+        metrics.newTimer(
+            "notedb/stage_update_latency",
+            new Description("Latency for staging updates to NoteDb by table")
+                .setCumulative()
+                .setUnit(Units.MICROSECONDS),
+            view);
 
-    readLatency = metrics.newTimer(
-        "notedb/read_latency",
-        new Description("NoteDb read latency by table")
-            .setCumulative()
-            .setUnit(Units.MILLISECONDS),
-        view);
+    readLatency =
+        metrics.newTimer(
+            "notedb/read_latency",
+            new Description("NoteDb read latency by table")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            view);
 
-    parseLatency = metrics.newTimer(
-        "notedb/parse_latency",
-        new Description("NoteDb parse latency by table")
-            .setCumulative()
-            .setUnit(Units.MICROSECONDS),
-        view);
+    parseLatency =
+        metrics.newTimer(
+            "notedb/parse_latency",
+            new Description("NoteDb parse latency by table")
+                .setCumulative()
+                .setUnit(Units.MICROSECONDS),
+            view);
 
-    autoRebuildLatency = metrics.newTimer(
-        "notedb/auto_rebuild_latency",
-        new Description("NoteDb auto-rebuilding latency by table")
-            .setCumulative()
-            .setUnit(Units.MILLISECONDS),
-        view);
+    autoRebuildLatency =
+        metrics.newTimer(
+            "notedb/auto_rebuild_latency",
+            new Description("NoteDb auto-rebuilding latency by table")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            view);
 
-    autoRebuildFailureCount = metrics.newCounter(
-        "notedb/auto_rebuild_failure_count",
-        new Description("NoteDb auto-rebuilding attempts that failed by table")
-            .setCumulative(),
-        view);
+    autoRebuildFailureCount =
+        metrics.newCounter(
+            "notedb/auto_rebuild_failure_count",
+            new Description("NoteDb auto-rebuilding attempts that failed by table").setCumulative(),
+            view);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
index ff3b4b8..d249689 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -16,18 +16,17 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
-import com.google.common.collect.ImmutableMultimap;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Id;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
-
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
 
 public class NoteDbModule extends FactoryModule {
   private final Config cfg;
@@ -51,6 +50,8 @@
     factory(ChangeUpdate.Factory.class);
     factory(ChangeDraftUpdate.Factory.class);
     factory(DraftCommentNotes.Factory.class);
+    factory(RobotCommentUpdate.Factory.class);
+    factory(RobotCommentNotes.Factory.class);
     factory(NoteDbUpdateManager.Factory.class);
     if (!useTestBindings) {
       install(ChangeNotesCache.module());
@@ -62,40 +63,48 @@
         bind(ChangeRebuilder.class).to(ChangeRebuilderImpl.class);
       }
     } else {
-      bind(ChangeRebuilder.class).toInstance(new ChangeRebuilder(null) {
-        @Override
-        public Result rebuild(ReviewDb db, Change.Id changeId) {
-          return null;
-        }
+      bind(ChangeRebuilder.class)
+          .toInstance(
+              new ChangeRebuilder(null) {
+                @Override
+                public Result rebuild(ReviewDb db, Change.Id changeId) {
+                  return null;
+                }
 
-        @Override
-        public Result rebuild(NoteDbUpdateManager manager,
-            ChangeBundle bundle) {
-          return null;
-        }
+                @Override
+                public Result rebuildEvenIfReadOnly(ReviewDb db, Id changeId) {
+                  return null;
+                }
 
-        @Override
-        public boolean rebuildProject(ReviewDb db,
-            ImmutableMultimap<NameKey, Id> allChanges, NameKey project,
-            Repository allUsersRepo) {
-          return false;
-        }
+                @Override
+                public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle) {
+                  return null;
+                }
 
-        @Override
-        public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId) {
-          return null;
-        }
+                @Override
+                public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId) {
+                  return null;
+                }
 
-        @Override
-        public Result execute(ReviewDb db, Change.Id changeId,
-            NoteDbUpdateManager manager) {
-          return null;
-        }
-      });
+                @Override
+                public Result execute(
+                    ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager) {
+                  return null;
+                }
+
+                @Override
+                public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle) {
+                  // Do nothing.
+                }
+
+                @Override
+                public void rebuildReviewDb(ReviewDb db, Project.NameKey project, Id changeId) {
+                  // Do nothing.
+                }
+              });
       bind(new TypeLiteral<Cache<ChangeNotesCache.Key, ChangeNotesState>>() {})
           .annotatedWith(Names.named(ChangeNotesCache.CACHE_NAME))
-          .toInstance(CacheBuilder.newBuilder()
-              .<ChangeNotesCache.Key, ChangeNotesState>build());
+          .toInstance(CacheBuilder.newBuilder().<ChangeNotesCache.Key, ChangeNotesState>build());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index cad531f..59d7cbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
@@ -21,11 +22,10 @@
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Optional;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Timer1;
@@ -33,44 +33,48 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.ChainedReceiveCommands;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.InsertedObject;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * Object to manage a single sequence of updates to NoteDb.
- * <p>
- * Instances are one-time-use. Handles updating both the change repo and the
- * All-Users repo for any affected changes, with proper ordering.
- * <p>
- * To see the state that would be applied prior to executing the full sequence
- * of updates, use {@link #stage()}.
+ *
+ * <p>Instances are one-time-use. Handles updating both the change repo and the All-Users repo for
+ * any affected changes, with proper ordering.
+ *
+ * <p>To see the state that would be applied prior to executing the full sequence of updates, use
+ * {@link #stage()}.
  */
 public class NoteDbUpdateManager implements AutoCloseable {
-  public static String CHANGES_READ_ONLY = "NoteDb changes are read-only";
+  public static final String CHANGES_READ_ONLY = "NoteDb changes are read-only";
 
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
@@ -78,8 +82,8 @@
 
   @AutoValue
   public abstract static class StagedResult {
-    private static StagedResult create(Change.Id id, NoteDbChangeState.Delta delta,
-        OpenRepo changeRepo, OpenRepo allUsersRepo) {
+    private static StagedResult create(
+        Change.Id id, NoteDbChangeState.Delta delta, OpenRepo changeRepo, OpenRepo allUsersRepo) {
       ImmutableList<ReceiveCommand> changeCommands = ImmutableList.of();
       ImmutableList<InsertedObject> changeObjects = ImmutableList.of();
       if (changeRepo != null) {
@@ -99,42 +103,54 @@
     }
 
     public abstract Change.Id id();
-    @Nullable public abstract NoteDbChangeState.Delta delta();
+
+    @Nullable
+    public abstract NoteDbChangeState.Delta delta();
+
     public abstract ImmutableList<ReceiveCommand> changeCommands();
+
     public abstract ImmutableList<InsertedObject> changeObjects();
 
     public abstract ImmutableList<ReceiveCommand> allUsersCommands();
+
     public abstract ImmutableList<InsertedObject> allUsersObjects();
   }
 
   @AutoValue
   public abstract static class Result {
-    static Result create(NoteDbUpdateManager.StagedResult staged,
-        NoteDbChangeState newState) {
+    static Result create(NoteDbUpdateManager.StagedResult staged, NoteDbChangeState newState) {
       return new AutoValue_NoteDbUpdateManager_Result(newState, staged);
     }
 
-    @Nullable public abstract NoteDbChangeState newState();
+    @Nullable
+    public abstract NoteDbChangeState newState();
 
-    @Nullable abstract NoteDbUpdateManager.StagedResult staged();
+    @Nullable
+    abstract NoteDbUpdateManager.StagedResult staged();
   }
 
-  static class OpenRepo implements AutoCloseable {
-    final Repository repo;
-    final RevWalk rw;
-    final ChainedReceiveCommands cmds;
+  public static class OpenRepo implements AutoCloseable {
+    public final Repository repo;
+    public final RevWalk rw;
+    public final ChainedReceiveCommands cmds;
 
     private final InMemoryInserter tempIns;
     @Nullable private final ObjectInserter finalIns;
 
     private final boolean close;
 
-    private OpenRepo(Repository repo, RevWalk rw, @Nullable ObjectInserter ins,
-        ChainedReceiveCommands cmds, boolean close) {
+    private OpenRepo(
+        Repository repo,
+        RevWalk rw,
+        @Nullable ObjectInserter ins,
+        ChainedReceiveCommands cmds,
+        boolean close) {
       ObjectReader reader = rw.getObjectReader();
-      checkArgument(ins == null || reader.getCreatedFromInserter() == ins,
+      checkArgument(
+          ins == null || reader.getCreatedFromInserter() == ins,
           "expected reader to be created from %s, but was %s",
-          ins, reader.getCreatedFromInserter());
+          ins,
+          reader.getCreatedFromInserter());
       this.repo = checkNotNull(repo);
       this.tempIns = new InMemoryInserter(rw.getObjectReader());
       this.rw = new RevWalk(tempIns.newReader());
@@ -143,7 +159,7 @@
       this.close = close;
     }
 
-    Optional<ObjectId> getObjectId(String refName) throws IOException {
+    public Optional<ObjectId> getObjectId(String refName) throws IOException {
       return cmds.get(refName);
     }
 
@@ -162,6 +178,7 @@
 
     @Override
     public void close() {
+      rw.getObjectReader().close();
       rw.close();
       if (close) {
         if (finalIns != null) {
@@ -172,6 +189,7 @@
     }
   }
 
+  private final Provider<PersonIdent> serverIdent;
   private final GitRepositoryManager repoManager;
   private final NotesMigration migration;
   private final AllUsersName allUsersName;
@@ -179,26 +197,33 @@
   private final Project.NameKey projectName;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+  private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
   private final Set<Change.Id> toDelete;
 
   private OpenRepo changeRepo;
   private OpenRepo allUsersRepo;
   private Map<Change.Id, StagedResult> staged;
   private boolean checkExpectedState = true;
+  private String refLogMessage;
+  private PersonIdent refLogIdent;
 
   @AssistedInject
-  NoteDbUpdateManager(GitRepositoryManager repoManager,
+  NoteDbUpdateManager(
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      GitRepositoryManager repoManager,
       NotesMigration migration,
       AllUsersName allUsersName,
       NoteDbMetrics metrics,
       @Assisted Project.NameKey projectName) {
+    this.serverIdent = serverIdent;
     this.repoManager = repoManager;
     this.migration = migration;
     this.allUsersName = allUsersName;
     this.metrics = metrics;
     this.projectName = projectName;
-    changeUpdates = ArrayListMultimap.create();
-    draftUpdates = ArrayListMultimap.create();
+    changeUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
+    draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
+    robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     toDelete = new HashSet<>();
   }
 
@@ -219,31 +244,41 @@
     }
   }
 
-  public NoteDbUpdateManager setChangeRepo(Repository repo, RevWalk rw,
-      @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
+  public NoteDbUpdateManager setChangeRepo(
+      Repository repo, RevWalk rw, @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
     checkState(changeRepo == null, "change repo already initialized");
     changeRepo = new OpenRepo(repo, rw, ins, cmds, false);
     return this;
   }
 
-  public NoteDbUpdateManager setAllUsersRepo(Repository repo, RevWalk rw,
-      @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
+  public NoteDbUpdateManager setAllUsersRepo(
+      Repository repo, RevWalk rw, @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
     checkState(allUsersRepo == null, "All-Users repo already initialized");
     allUsersRepo = new OpenRepo(repo, rw, ins, cmds, false);
     return this;
   }
 
-  NoteDbUpdateManager setCheckExpectedState(boolean checkExpectedState) {
+  public NoteDbUpdateManager setCheckExpectedState(boolean checkExpectedState) {
     this.checkExpectedState = checkExpectedState;
     return this;
   }
 
-  OpenRepo getChangeRepo() throws IOException {
+  public NoteDbUpdateManager setRefLogMessage(String message) {
+    this.refLogMessage = message;
+    return this;
+  }
+
+  public NoteDbUpdateManager setRefLogIdent(PersonIdent ident) {
+    this.refLogIdent = ident;
+    return this;
+  }
+
+  public OpenRepo getChangeRepo() throws IOException {
     initChangeRepo();
     return changeRepo;
   }
 
-  OpenRepo getAllUsersRepo() throws IOException {
+  public OpenRepo getAllUsersRepo() throws IOException {
     initAllUsersRepo();
     return allUsersRepo;
   }
@@ -261,10 +296,18 @@
   }
 
   private OpenRepo openRepo(Project.NameKey p) throws IOException {
-    Repository repo = repoManager.openRepository(p);
-    ObjectInserter ins = repo.newObjectInserter();
-    return new OpenRepo(repo, new RevWalk(ins.newReader()), ins,
-        new ChainedReceiveCommands(repo), true);
+    Repository repo = repoManager.openRepository(p); // Closed by OpenRepo#close.
+    ObjectInserter ins = repo.newObjectInserter(); // Closed by OpenRepo#close.
+    ObjectReader reader = ins.newReader(); // Not closed by OpenRepo#close.
+    try (RevWalk rw = new RevWalk(reader)) { // Doesn't escape OpenRepo constructor.
+      return new OpenRepo(repo, rw, ins, new ChainedReceiveCommands(repo), true) {
+        @Override
+        public void close() {
+          reader.close();
+          super.close();
+        }
+      };
+    }
   }
 
   private boolean isEmpty() {
@@ -273,27 +316,34 @@
     }
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
+        && robotCommentUpdates.isEmpty()
         && toDelete.isEmpty();
   }
 
   /**
    * Add an update to the list of updates to execute.
-   * <p>
-   * Updates should only be added to the manager after all mutations have been
-   * made, as this method may eagerly access the update.
+   *
+   * <p>Updates should only be added to the manager after all mutations have been made, as this
+   * method may eagerly access the update.
    *
    * @param update the update to add.
    */
   public void add(ChangeUpdate update) {
-    checkArgument(update.getProjectName().equals(projectName),
-      "update for project %s cannot be added to manager for project %s",
-      update.getProjectName(), projectName);
+    checkArgument(
+        update.getProjectName().equals(projectName),
+        "update for project %s cannot be added to manager for project %s",
+        update.getProjectName(),
+        projectName);
     checkState(staged == null, "cannot add new update after staging");
     changeUpdates.put(update.getRefName(), update);
     ChangeDraftUpdate du = update.getDraftUpdate();
     if (du != null) {
       draftUpdates.put(du.getRefName(), du);
     }
+    RobotCommentUpdate rcu = update.getRobotCommentUpdate();
+    if (rcu != null) {
+      robotCommentUpdates.put(rcu.getRefName(), rcu);
+    }
   }
 
   public void add(ChangeDraftUpdate draftUpdate) {
@@ -309,13 +359,12 @@
   /**
    * Stage updates in the manager's internal list of commands.
    *
-   * @return map of the state that would get written to the applicable repo(s)
-   *     for each affected change.
+   * @return map of the state that would get written to the applicable repo(s) for each affected
+   *     change.
    * @throws OrmException if a database layer error occurs.
    * @throws IOException if a storage layer error occurs.
    */
-  public Map<Change.Id, StagedResult> stage()
-      throws OrmException, IOException {
+  public Map<Change.Id, StagedResult> stage() throws OrmException, IOException {
     if (staged != null) {
       return staged;
     }
@@ -344,20 +393,23 @@
                 changeId,
                 NoteDbChangeState.Delta.create(
                     changeId, metaId, allDraftIds.rowMap().remove(changeId)),
-                changeRepo, allUsersRepo));
+                changeRepo,
+                allUsersRepo));
       }
 
-      for (Map.Entry<Change.Id, Map<Account.Id, ObjectId>> e
-          : allDraftIds.rowMap().entrySet()) {
+      for (Map.Entry<Change.Id, Map<Account.Id, ObjectId>> e : allDraftIds.rowMap().entrySet()) {
         // If a change remains in the table at this point, it means we are
         // updating its drafts but not the change itself.
-        StagedResult r = StagedResult.create(
-            e.getKey(),
-            NoteDbChangeState.Delta.create(
-                e.getKey(), Optional.<ObjectId>absent(), e.getValue()),
-            changeRepo, allUsersRepo);
-        checkState(r.changeCommands().isEmpty(),
-            "should not have change commands when updating only drafts: %s", r);
+        StagedResult r =
+            StagedResult.create(
+                e.getKey(),
+                NoteDbChangeState.Delta.create(e.getKey(), Optional.empty(), e.getValue()),
+                changeRepo,
+                allUsersRepo);
+        checkState(
+            r.changeCommands().isEmpty(),
+            "should not have change commands when updating only drafts: %s",
+            r);
         staged.put(r.id(), r);
       }
 
@@ -365,8 +417,7 @@
     }
   }
 
-  public Result stageAndApplyDelta(Change change)
-      throws OrmException, IOException {
+  public Result stageAndApplyDelta(Change change) throws OrmException, IOException {
     StagedResult sr = stage().get(change.getId());
     NoteDbChangeState newState =
         NoteDbChangeState.applyDelta(change, sr != null ? sr.delta() : null);
@@ -381,8 +432,7 @@
     for (ReceiveCommand cmd : allUsersRepo.getCommandsSnapshot()) {
       String r = cmd.getRefName();
       if (r.startsWith(REFS_DRAFT_COMMENTS)) {
-        Change.Id changeId =
-            Change.Id.fromRefPart(r.substring(REFS_DRAFT_COMMENTS.length()));
+        Change.Id changeId = Change.Id.fromRefPart(r.substring(REFS_DRAFT_COMMENTS.length()));
         Account.Id accountId = Account.Id.fromRefSuffix(r);
         checkDraftRef(accountId != null && changeId != null, r);
         draftIds.put(changeId, accountId, cmd.getNewId());
@@ -425,20 +475,29 @@
     }
   }
 
-  private static void execute(OpenRepo or) throws IOException {
+  private void execute(OpenRepo or) throws IOException {
     if (or == null || or.cmds.isEmpty()) {
       return;
     }
     or.flush();
     BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
+    bru.setRefLogMessage(firstNonNull(refLogMessage, "Update NoteDb refs"), false);
+    bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     or.cmds.addTo(bru);
     bru.setAllowNonFastForwards(true);
     bru.execute(or.rw, NullProgressMonitor.INSTANCE);
+
+    boolean lockFailure = false;
     for (ReceiveCommand cmd : bru.getCommands()) {
-      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+      if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
+        lockFailure = true;
+      } else if (cmd.getResult() != ReceiveCommand.Result.OK) {
         throw new IOException("Update failed: " + bru);
       }
     }
+    if (lockFailure) {
+      throw new LockFailureException("Update failed with one or more lock failures: " + bru);
+    }
   }
 
   private void addCommands() throws OrmException, IOException {
@@ -453,6 +512,9 @@
     if (!draftUpdates.isEmpty()) {
       addUpdates(draftUpdates, allUsersRepo);
     }
+    if (!robotCommentUpdates.isEmpty()) {
+      addUpdates(robotCommentUpdates, changeRepo);
+    }
     for (Change.Id id : toDelete) {
       doDelete(id);
     }
@@ -463,21 +525,30 @@
     String metaRef = RefNames.changeMetaRef(id);
     Optional<ObjectId> old = changeRepo.cmds.get(metaRef);
     if (old.isPresent()) {
-      changeRepo.cmds.add(
-          new ReceiveCommand(old.get(), ObjectId.zeroId(), metaRef));
+      changeRepo.cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), metaRef));
     }
 
     // Just scan repo for ref names, but get "old" values from cmds.
-    for (Ref r : allUsersRepo.repo.getRefDatabase().getRefs(
-        RefNames.refsDraftCommentsPrefix(id)).values()) {
+    for (Ref r :
+        allUsersRepo.repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(id)).values()) {
       old = allUsersRepo.cmds.get(r.getName());
       if (old.isPresent()) {
-        allUsersRepo.cmds.add(
-            new ReceiveCommand(old.get(), ObjectId.zeroId(), r.getName()));
+        allUsersRepo.cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), r.getName()));
       }
     }
   }
 
+  public static class MismatchedStateException extends OrmException {
+    private static final long serialVersionUID = 1L;
+
+    private MismatchedStateException(Change.Id id, NoteDbChangeState expectedState) {
+      super(
+          String.format(
+              "cannot apply NoteDb updates for change %s; change meta ref does not match %s",
+              id, expectedState.getChangeMetaId().name()));
+    }
+  }
+
   private void checkExpectedState() throws OrmException, IOException {
     if (!checkExpectedState) {
       return;
@@ -502,15 +573,17 @@
         //  - We short-circuited before adding any commands that update this
         //    ref, and we won't stage a delta for this change either.
         // Either way, it is safe to proceed here rather than throwing
-        // OrmConcurrencyException.
+        // MismatchedStateException.
+        continue;
+      }
+
+      if (expectedState.getPrimaryStorage() == PrimaryStorage.NOTE_DB) {
+        // NoteDb is primary, no need to compare state to ReviewDb.
         continue;
       }
 
       if (!expectedState.isChangeUpToDate(changeRepo.cmds.getRepoRefCache())) {
-        throw new OrmConcurrencyException(String.format(
-            "cannot apply NoteDb updates for change %s;"
-            + " change meta ref does not match %s",
-            u.getId(), expectedState.getChangeMetaId().name()));
+        throw new MismatchedStateException(u.getId(), expectedState);
       }
     }
 
@@ -518,28 +591,29 @@
       ChangeDraftUpdate u = us.iterator().next();
       NoteDbChangeState expectedState = NoteDbChangeState.parse(u.getChange());
 
-      if (expectedState == null) {
+      if (expectedState == null || expectedState.getPrimaryStorage() == PrimaryStorage.NOTE_DB) {
         continue; // See above.
       }
 
       Account.Id accountId = u.getAccountId();
-      if (!expectedState.areDraftsUpToDate(
-          allUsersRepo.cmds.getRepoRefCache(), accountId)) {
-        throw new OrmConcurrencyException(String.format(
-            "cannot apply NoteDb updates for change %s;"
-            + " draft ref for account %s does not match %s",
-            u.getId(), accountId, expectedState.getChangeMetaId().name()));
+      if (!expectedState.areDraftsUpToDate(allUsersRepo.cmds.getRepoRefCache(), accountId)) {
+        ObjectId expectedDraftId =
+            firstNonNull(expectedState.getDraftIds().get(accountId), ObjectId.zeroId());
+        throw new OrmConcurrencyException(
+            String.format(
+                "cannot apply NoteDb updates for change %s;"
+                    + " draft ref for account %s does not match %s",
+                u.getId(), accountId, expectedDraftId.name()));
       }
     }
   }
 
   private static <U extends AbstractChangeUpdate> void addUpdates(
-      ListMultimap<String, U> all, OpenRepo or)
-      throws OrmException, IOException {
+      ListMultimap<String, U> all, OpenRepo or) throws OrmException, IOException {
     for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
       String refName = e.getKey();
       Collection<U> updates = e.getValue();
-      ObjectId old = or.cmds.get(refName).or(ObjectId.zeroId());
+      ObjectId old = or.cmds.get(refName).orElse(ObjectId.zeroId());
       // Only actually write to the ref if one of the updates explicitly allows
       // us to do so, i.e. it is known to represent a new change. This avoids
       // writing partial change meta if the change hasn't been backfilled yet.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
index 56b41d9..c708bfe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -14,71 +14,75 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+
 /**
  * Holds the current state of the NoteDb migration.
- * <p>
- * The migration will proceed one root entity type at a time. A <em>root
- * entity</em> is an entity stored in ReviewDb whose key's
- * {@code getParentKey()} method returns null. For an example of the entity
- * hierarchy rooted at Change, see the diagram in
- * {@code com.google.gerrit.reviewdb.client.Change}.
- * <p>
- * During a transitional period, each root entity group from ReviewDb may be
- * either <em>written to</em> or <em>both written to and read from</em> NoteDb.
- * <p>
- * This class controls the state of the migration according to options in
- * {@code gerrit.config}. In general, any changes to these options should only
- * be made by adventurous administrators, who know what they're doing, on
- * non-production data, for the purposes of testing the NoteDb implementation.
- * Changing options quite likely requires re-running {@code RebuildNoteDb}. For
+ *
+ * <p>The migration will proceed one root entity type at a time. A <em>root entity</em> is an entity
+ * stored in ReviewDb whose key's {@code getParentKey()} method returns null. For an example of the
+ * entity hierarchy rooted at Change, see the diagram in {@code
+ * com.google.gerrit.reviewdb.client.Change}.
+ *
+ * <p>During a transitional period, each root entity group from ReviewDb may be either <em>written
+ * to</em> or <em>both written to and read from</em> NoteDb.
+ *
+ * <p>This class controls the state of the migration according to options in {@code gerrit.config}.
+ * In general, any changes to these options should only be made by adventurous administrators, who
+ * know what they're doing, on non-production data, for the purposes of testing the NoteDb
+ * implementation. Changing options quite likely requires re-running {@code RebuildNoteDb}. For
  * these reasons, the options remain undocumented.
  */
 public abstract class NotesMigration {
   /**
    * Read changes from NoteDb.
-   * <p>
-   * Change data is read from NoteDb refs, but ReviewDb is still the source of
-   * truth. If the loader determines NoteDb is out of date, the change data in
-   * NoteDb will be transparently rebuilt. This means that some code paths that
-   * look read-only may in fact attempt to write.
-   * <p>
-   * If true and {@code writeChanges() = false}, changes can still be read from
-   * NoteDb, but any attempts to write will generate an error.
+   *
+   * <p>Change data is read from NoteDb refs, but ReviewDb is still the source of truth. If the
+   * loader determines NoteDb is out of date, the change data in NoteDb will be transparently
+   * rebuilt. This means that some code paths that look read-only may in fact attempt to write.
+   *
+   * <p>If true and {@code writeChanges() = false}, changes can still be read from NoteDb, but any
+   * attempts to write will generate an error.
    */
   public abstract boolean readChanges();
 
   /**
    * Write changes to NoteDb.
-   * <p>
-   * Updates to change data are written to NoteDb refs, but ReviewDb is still
-   * the source of truth. Change data will not be written unless the NoteDb refs
-   * are already up to date, and the write path will attempt to rebuild the
-   * change if not.
-   * <p>
-   * If false, the behavior when attempting to write depends on
-   * {@code readChanges()}. If {@code readChanges() = false}, writes to NoteDb
-   * are simply ignored; if {@code true}, any attempts to write will generate an
-   * error.
+   *
+   * <p>Updates to change data are written to NoteDb refs, but ReviewDb is still the source of
+   * truth. Change data will not be written unless the NoteDb refs are already up to date, and the
+   * write path will attempt to rebuild the change if not.
+   *
+   * <p>If false, the behavior when attempting to write depends on {@code readChanges()}. If {@code
+   * readChanges() = false}, writes to NoteDb are simply ignored; if {@code true}, any attempts to
+   * write will generate an error.
    */
   protected abstract boolean writeChanges();
 
   /**
    * Read sequential change ID numbers from NoteDb.
-   * <p>
-   * If true, change IDs are read from {@code refs/sequences/changes} in
-   * All-Projects. If false, change IDs are read from ReviewDb's native
-   * sequences.
+   *
+   * <p>If true, change IDs are read from {@code refs/sequences/changes} in All-Projects. If false,
+   * change IDs are read from ReviewDb's native sequences.
    */
   public abstract boolean readChangeSequence();
 
-  public abstract boolean readAccounts();
+  /** @return default primary storage for new changes. */
+  public abstract PrimaryStorage changePrimaryStorage();
 
-  public abstract boolean writeAccounts();
+  /**
+   * Disable ReviewDb access for changes.
+   *
+   * <p>When set, ReviewDb operations involving the Changes table become no-ops. Lookups return no
+   * results; updates do nothing, as does opening, committing, or rolling back a transaction on the
+   * Changes table.
+   */
+  public abstract boolean disableChangeReviewDb();
 
   /**
    * Whether to fail when reading any data from NoteDb.
-   * <p>
-   * Used in conjunction with {@link #readChanges()} for tests.
+   *
+   * <p>Used in conjunction with {@link #readChanges()} for tests.
    */
   public boolean failOnLoad() {
     return false;
@@ -103,7 +107,6 @@
   }
 
   public boolean enabled() {
-    return writeChanges() || readChanges()
-        || writeAccounts() || readAccounts();
+    return writeChanges() || readChanges();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
index 39cd6cc..32be9c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 public enum PatchSetState {
-  /** Published and visible to anyone who can see the change; the default.*/
+  /** Published and visible to anyone who can see the change; the default. */
   PUBLISHED,
 
   /** Draft patch set, only visible to certain users. */
@@ -23,9 +23,9 @@
 
   /**
    * Deleted patch set.
-   * <p>
-   * Used internally as a tombstone; patch sets exposed by public NoteDb
-   * interfaces never have this state.
+   *
+   * <p>Used internally as a tombstone; patch sets exposed by public NoteDb interfaces never have
+   * this state.
    */
   DELETED;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
new file mode 100644
index 0000000..3f0db77
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
@@ -0,0 +1,491 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Helper to migrate the {@link PrimaryStorage} of individual changes. */
+@Singleton
+public class PrimaryStorageMigrator {
+  private static final Logger log = LoggerFactory.getLogger(PrimaryStorageMigrator.class);
+
+  private final AllUsersName allUsers;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeRebuilder rebuilder;
+  private final ChangeUpdate.Factory updateFactory;
+  private final GitRepositoryManager repoManager;
+  private final InternalUser.Factory internalUserFactory;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<ReviewDb> db;
+
+  private final long skewMs;
+  private final long timeoutMs;
+  private final Retryer<NoteDbChangeState> testEnsureRebuiltRetryer;
+
+  @Inject
+  PrimaryStorageMigrator(
+      @GerritServerConfig Config cfg,
+      Provider<ReviewDb> db,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      ChangeRebuilder rebuilder,
+      ChangeControl.GenericFactory changeControlFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeUpdate.Factory updateFactory,
+      InternalUser.Factory internalUserFactory,
+      BatchUpdate.Factory batchUpdateFactory) {
+    this(
+        cfg,
+        db,
+        repoManager,
+        allUsers,
+        rebuilder,
+        null,
+        changeControlFactory,
+        queryProvider,
+        updateFactory,
+        internalUserFactory,
+        batchUpdateFactory);
+  }
+
+  @VisibleForTesting
+  public PrimaryStorageMigrator(
+      Config cfg,
+      Provider<ReviewDb> db,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      ChangeRebuilder rebuilder,
+      @Nullable Retryer<NoteDbChangeState> testEnsureRebuiltRetryer,
+      ChangeControl.GenericFactory changeControlFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeUpdate.Factory updateFactory,
+      InternalUser.Factory internalUserFactory,
+      BatchUpdate.Factory batchUpdateFactory) {
+    this.db = db;
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.rebuilder = rebuilder;
+    this.testEnsureRebuiltRetryer = testEnsureRebuiltRetryer;
+    this.changeControlFactory = changeControlFactory;
+    this.queryProvider = queryProvider;
+    this.updateFactory = updateFactory;
+    this.internalUserFactory = internalUserFactory;
+    this.batchUpdateFactory = batchUpdateFactory;
+    skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
+
+    String s = "notedb";
+    timeoutMs =
+        cfg.getTimeUnit(
+            s,
+            null,
+            "primaryStorageMigrationTimeout",
+            MILLISECONDS.convert(60, SECONDS),
+            MILLISECONDS);
+  }
+
+  /**
+   * Migrate a change's primary storage from ReviewDb to NoteDb.
+   *
+   * <p>This method will return only if the primary storage of the change is NoteDb afterwards. (It
+   * may return early if the primary storage was already NoteDb.)
+   *
+   * <p>If this method throws an exception, then the primary storage of the change is probably not
+   * NoteDb. (It is possible that the primary storage of the change is NoteDb in this case, but
+   * there was an error reading the state.) Moreover, after an exception, the change may be
+   * read-only until a lease expires. If the caller chooses to retry, they should wait until the
+   * read-only lease expires; this method will fail relatively quickly if called on a read-only
+   * change.
+   *
+   * <p>Note that if the change is read-only after this method throws an exception, that does not
+   * necessarily guarantee that the read-only lease was acquired during that particular method
+   * invocation; this call may have in fact failed because another thread acquired the lease first.
+   *
+   * @param id change ID.
+   * @throws OrmException if a ReviewDb-level error occurs.
+   * @throws IOException if a repo-level error occurs.
+   */
+  public void migrateToNoteDbPrimary(Change.Id id) throws OrmException, IOException {
+    // Since there are multiple non-atomic steps in this method, we need to
+    // consider what happens when there is another writer concurrent with the
+    // thread executing this method.
+    //
+    // Let:
+    // * OR = other writer writes noteDbState & new data to ReviewDb (in one
+    //        transaction)
+    // * ON = other writer writes to NoteDb
+    // * MRO = migrator sets state to read-only
+    // * MR = ensureRebuilt writes rebuilt noteDbState to ReviewDb (but does not
+    //        otherwise update ReviewDb in this transaction)
+    // * MN = ensureRebuilt writes rebuilt state to NoteDb
+    //
+    // Consider all the interleavings of these operations.
+    //
+    // * OR,ON,MRO,...
+    //   Other writer completes before migrator begins; this is not a concurrent
+    //   write.
+    // * MRO,...,OR,...
+    //   OR will fail, since it atomically checks that the noteDbState is not
+    //   read-only before proceeding. This results in an exception, but not a
+    //   concurrent write.
+    //
+    // Thus all the "interesting" interleavings start with OR,MRO, and differ on
+    // where ON falls relative to MR/MN.
+    //
+    // * OR,MRO,ON,MR,MN
+    //   The other NoteDb write succeeds despite the noteDbState being
+    //   read-only. Because the read-only state from MRO includes the update
+    //   from OR, the change is up-to-date at this point. Thus MR,MN is a no-op.
+    //   The end result is an up-to-date, read-only change.
+    //
+    // * OR,MRO,MR,ON,MN
+    //   The change is out-of-date when ensureRebuilt begins, because OR
+    //   succeeded but the corresponding ON has not happened yet. ON will
+    //   succeed, because there have been no intervening NoteDb writes. MN will
+    //   fail, because ON updated the state in NoteDb to something other than
+    //   what MR claimed. This leaves the change in an out-of-date, read-only
+    //   state.
+    //
+    //   If this method threw an exception in this case, the change would
+    //   eventually switch back to read-write when the read-only lease expires,
+    //   so this situation is recoverable. However, it would be inconvenient for
+    //   a change to be read-only for so long.
+    //
+    //   Thus, as an optimization, we have a retry loop that attempts
+    //   ensureRebuilt while still holding the same read-only lease. This
+    //   effectively results in the interleaving OR,MR,ON,MR,MN; in contrast
+    //   with the previous case, here, MR/MN actually rebuilds the change. In
+    //   the case of a write failure, MR/MN might fail and get retried again. If
+    //   it exceeds the maximum number of retries, an exception is thrown.
+    //
+    // * OR,MRO,MR,MN,ON
+    //   The change is out-of-date when ensureRebuilt begins. The change is
+    //   rebuilt, leaving a new state in NoteDb. ON will fail, because the old
+    //   NoteDb state has changed since the ref state was read when the update
+    //   began (prior to OR). This results in an exception from ON, but the end
+    //   result is still an up-to-date, read-only change. The end user that
+    //   initiated the other write observes an error, but this is no different
+    //   from other errors that need retrying, e.g. due to a backend write
+    //   failure.
+
+    Stopwatch sw = Stopwatch.createStarted();
+    Change readOnlyChange = setReadOnlyInReviewDb(id); // MRO
+    if (readOnlyChange == null) {
+      return; // Already migrated.
+    }
+
+    NoteDbChangeState rebuiltState;
+    try {
+      // MR,MN
+      rebuiltState =
+          ensureRebuiltRetryer(sw)
+              .call(
+                  () ->
+                      ensureRebuilt(
+                          readOnlyChange.getProject(),
+                          id,
+                          NoteDbChangeState.parse(readOnlyChange)));
+    } catch (RetryException | ExecutionException e) {
+      throw new OrmException(e);
+    }
+
+    // At this point, the noteDbState in ReviewDb is read-only, and it is
+    // guaranteed to match the state actually in NoteDb. Now it is safe to set
+    // the primary storage to NoteDb.
+
+    setPrimaryStorageNoteDb(id, rebuiltState);
+    log.info("Migrated change {} to NoteDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
+  }
+
+  private Change setReadOnlyInReviewDb(Change.Id id) throws OrmException {
+    AtomicBoolean alreadyMigrated = new AtomicBoolean(false);
+    Change result =
+        db().changes()
+            .atomicUpdate(
+                id,
+                new AtomicUpdate<Change>() {
+                  @Override
+                  public Change update(Change change) {
+                    NoteDbChangeState state = NoteDbChangeState.parse(change);
+                    if (state == null) {
+                      // Could rebuild the change here, but that's more complexity, and this
+                      // really shouldn't happen.
+                      throw new OrmRuntimeException(
+                          "change " + id + " has no note_db_state; rebuild it first");
+                    }
+                    // If the change is already read-only, then the lease is held by another
+                    // (likely failed) migrator thread. Fail early, as we can't take over
+                    // the lease.
+                    NoteDbChangeState.checkNotReadOnly(change, skewMs);
+                    if (state.getPrimaryStorage() != PrimaryStorage.NOTE_DB) {
+                      Timestamp now = TimeUtil.nowTs();
+                      Timestamp until = new Timestamp(now.getTime() + timeoutMs);
+                      change.setNoteDbState(state.withReadOnlyUntil(until).toString());
+                    } else {
+                      alreadyMigrated.set(true);
+                    }
+                    return change;
+                  }
+                });
+    return alreadyMigrated.get() ? null : result;
+  }
+
+  private Retryer<NoteDbChangeState> ensureRebuiltRetryer(Stopwatch sw) {
+    if (testEnsureRebuiltRetryer != null) {
+      return testEnsureRebuiltRetryer;
+    }
+    // Retry the ensureRebuilt step with backoff until half the timeout has
+    // expired, leaving the remaining half for the rest of the steps.
+    long remainingNanos = (MILLISECONDS.toNanos(timeoutMs) / 2) - sw.elapsed(NANOSECONDS);
+    remainingNanos = Math.max(remainingNanos, 0);
+    return RetryerBuilder.<NoteDbChangeState>newBuilder()
+        .retryIfException(e -> (e instanceof IOException) || (e instanceof OrmException))
+        .withWaitStrategy(
+            WaitStrategies.join(
+                WaitStrategies.exponentialWait(250, MILLISECONDS),
+                WaitStrategies.randomWait(50, MILLISECONDS)))
+        .withStopStrategy(StopStrategies.stopAfterDelay(remainingNanos, NANOSECONDS))
+        .build();
+  }
+
+  private NoteDbChangeState ensureRebuilt(
+      Project.NameKey project, Change.Id id, NoteDbChangeState readOnlyState)
+      throws IOException, OrmException, RepositoryNotFoundException {
+    try (Repository changeRepo = repoManager.openRepository(project);
+        Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      if (!readOnlyState.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo))) {
+        NoteDbUpdateManager.Result r = rebuilder.rebuildEvenIfReadOnly(db(), id);
+        checkState(
+            r.newState().getReadOnlyUntil().equals(readOnlyState.getReadOnlyUntil()),
+            "state after rebuilding has different read-only lease: %s != %s",
+            r.newState(),
+            readOnlyState);
+        readOnlyState = r.newState();
+      }
+    }
+    return readOnlyState;
+  }
+
+  private void setPrimaryStorageNoteDb(Change.Id id, NoteDbChangeState expectedState)
+      throws OrmException {
+    db().changes()
+        .atomicUpdate(
+            id,
+            new AtomicUpdate<Change>() {
+              @Override
+              public Change update(Change change) {
+                NoteDbChangeState state = NoteDbChangeState.parse(change);
+                if (!Objects.equals(state, expectedState)) {
+                  throw new OrmRuntimeException(badState(state, expectedState));
+                }
+                Timestamp until = state.getReadOnlyUntil().get();
+                if (TimeUtil.nowTs().after(until)) {
+                  throw new OrmRuntimeException(
+                      "read-only lease on change " + id + " expired at " + until);
+                }
+                change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+                return change;
+              }
+            });
+  }
+
+  private ReviewDb db() {
+    return ReviewDbUtil.unwrapDb(db.get());
+  }
+
+  private String badState(NoteDbChangeState actual, NoteDbChangeState expected) {
+    return "state changed unexpectedly: " + actual + " != " + expected;
+  }
+
+  public void migrateToReviewDbPrimary(Change.Id id, @Nullable Project.NameKey project)
+      throws OrmException, IOException {
+    // Migrating back to ReviewDb primary is much simpler than the original migration to NoteDb
+    // primary, because when NoteDb is primary, each write only goes to one storage location rather
+    // than both. We only need to consider whether a concurrent writer (OR) conflicts with the first
+    // setReadOnlyInNoteDb step (MR) in this method.
+    //
+    // If OR wins, then either:
+    // * MR will set read-only after OR is completed, which is not a concurrent write.
+    // * MR will fail to set read-only with a lock failure. The caller will have to retry, but the
+    //   change is not in a read-only state, so behavior is not degraded in the meantime.
+    //
+    // If MR wins, then either:
+    // * OR will fail with a read-only exception (via AbstractChangeNotes#apply).
+    // * OR will fail with a lock failure.
+    //
+    // In all of these scenarios, the change is read-only if and only if MR succeeds.
+    //
+    // There will be no concurrent writes to ReviewDb for this change until
+    // setPrimaryStorageReviewDb completes, because ReviewDb writes are not attempted when primary
+    // storage is NoteDb. After the primary storage changes back, it is possible for subsequent
+    // NoteDb writes to conflict with the releaseReadOnlyLeaseInNoteDb step, but at this point,
+    // since ReviewDb is primary, we are back to ignoring them.
+    Stopwatch sw = Stopwatch.createStarted();
+    if (project == null) {
+      project = getProject(id);
+    }
+    ObjectId newMetaId = setReadOnlyInNoteDb(project, id);
+    rebuilder.rebuildReviewDb(db(), project, id);
+    setPrimaryStorageReviewDb(id, newMetaId);
+    releaseReadOnlyLeaseInNoteDb(project, id);
+    log.info("Migrated change {} to ReviewDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
+  }
+
+  private ObjectId setReadOnlyInNoteDb(Project.NameKey project, Change.Id id)
+      throws OrmException, IOException {
+    Timestamp now = TimeUtil.nowTs();
+    Timestamp until = new Timestamp(now.getTime() + timeoutMs);
+    ChangeUpdate update =
+        updateFactory.create(
+            changeControlFactory.controlFor(db.get(), project, id, internalUserFactory.create()));
+    update.setReadOnlyUntil(until);
+    return update.commit();
+  }
+
+  private void setPrimaryStorageReviewDb(Change.Id id, ObjectId newMetaId)
+      throws OrmException, IOException {
+    ImmutableMap.Builder<Account.Id, ObjectId> draftIds = ImmutableMap.builder();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      for (Ref draftRef :
+          repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(id)).values()) {
+        Account.Id accountId = Account.Id.fromRef(draftRef.getName());
+        if (accountId != null) {
+          draftIds.put(accountId, draftRef.getObjectId().copy());
+        }
+      }
+    }
+    NoteDbChangeState newState =
+        new NoteDbChangeState(
+            id,
+            PrimaryStorage.REVIEW_DB,
+            Optional.of(RefState.create(newMetaId, draftIds.build())),
+            Optional.empty());
+    db().changes()
+        .atomicUpdate(
+            id,
+            new AtomicUpdate<Change>() {
+              @Override
+              public Change update(Change change) {
+                if (PrimaryStorage.of(change) != PrimaryStorage.NOTE_DB) {
+                  throw new OrmRuntimeException(
+                      "change " + id + " is not NoteDb primary: " + change.getNoteDbState());
+                }
+                change.setNoteDbState(newState.toString());
+                return change;
+              }
+            });
+  }
+
+  private void releaseReadOnlyLeaseInNoteDb(Project.NameKey project, Change.Id id)
+      throws OrmException {
+    // Use a BatchUpdate since ReviewDb is primary at this point, so it needs to reflect the update.
+    try (BatchUpdate bu =
+        batchUpdateFactory.create(
+            db.get(), project, internalUserFactory.create(), TimeUtil.nowTs())) {
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) {
+              ctx.getUpdate(ctx.getChange().currentPatchSetId()).setReadOnlyUntil(new Timestamp(0));
+              return true;
+            }
+          });
+      bu.execute();
+    } catch (RestApiException | UpdateException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  private Project.NameKey getProject(Change.Id id) throws OrmException {
+    List<ChangeData> cds =
+        queryProvider
+            .get()
+            .setRequestedFields(ImmutableSet.of(ChangeField.PROJECT.getName()))
+            .byLegacyChangeId(id);
+    Set<Project.NameKey> projects = new TreeSet<>();
+    for (ChangeData cd : cds) {
+      projects.add(cd.project());
+    }
+    if (projects.size() != 1) {
+      throw new OrmException(
+          "zero or multiple projects found for change "
+              + id
+              + ", must specify project explicitly: "
+              + projects);
+    }
+    return projects.iterator().next();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
index 071e12c..0b097d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -16,9 +16,16 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Predicates;
@@ -31,22 +38,6 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.server.OrmException;
-
-import com.github.rholder.retry.RetryException;
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.github.rholder.retry.WaitStrategies;
-
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -55,17 +46,25 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * Class for managing an incrementing sequence backed by a git repository.
- * <p>
- * The current sequence number is stored as UTF-8 text in a blob pointed to
- * by a ref in the {@code refs/sequences/*} namespace. Multiple processes can
- * share the same sequence by incrementing the counter using normal git ref
- * updates. To amortize the cost of these ref updates, processes can increment
- * the counter by a larger number and hand out numbers from that range in memory
- * until they run out. This means concurrent processes will hand out somewhat
- * non-monotonic numbers.
+ *
+ * <p>The current sequence number is stored as UTF-8 text in a blob pointed to by a ref in the
+ * {@code refs/sequences/*} namespace. Multiple processes can share the same sequence by
+ * incrementing the counter using normal git ref updates. To amortize the cost of these ref updates,
+ * processes can increment the counter by a larger number and hand out numbers from that range in
+ * memory until they run out. This means concurrent processes will hand out somewhat non-monotonic
+ * numbers.
  */
 public class RepoSequence {
   public interface Seed {
@@ -74,16 +73,16 @@
 
   @VisibleForTesting
   static RetryerBuilder<RefUpdate.Result> retryerBuilder() {
-    return RetryerBuilder.<RefUpdate.Result> newBuilder()
+    return RetryerBuilder.<RefUpdate.Result>newBuilder()
         .retryIfResult(Predicates.equalTo(RefUpdate.Result.LOCK_FAILURE))
         .withWaitStrategy(
             WaitStrategies.join(
-              WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
-              WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
+                WaitStrategies.exponentialWait(5, TimeUnit.SECONDS),
+                WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
         .withStopStrategy(StopStrategies.stopAfterDelay(30, TimeUnit.SECONDS));
   }
 
-  private static Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
+  private static final Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
   private final Project.NameKey projectName;
@@ -99,8 +98,7 @@
   private int limit;
   private int counter;
 
-  @VisibleForTesting
-  int acquireCount;
+  @VisibleForTesting int acquireCount;
 
   public RepoSequence(
       GitRepositoryManager repoManager,
@@ -108,8 +106,7 @@
       String name,
       Seed seed,
       int batchSize) {
-    this(repoManager, projectName, name, seed, batchSize, Runnables.doNothing(),
-        RETRYER);
+    this(repoManager, projectName, name, seed, batchSize, Runnables.doNothing(), RETRYER);
   }
 
   @VisibleForTesting
@@ -123,7 +120,15 @@
       Retryer<RefUpdate.Result> retryer) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
     this.projectName = checkNotNull(projectName, "projectName");
-    this.refName = RefNames.REFS_SEQUENCES + checkNotNull(name, "name");
+
+    checkArgument(
+        name != null
+            && !name.startsWith(REFS)
+            && !name.startsWith(REFS_SEQUENCES.substring(REFS.length())),
+        "name should be a suffix to follow \"refs/sequences/\", got: %s",
+        name);
+    this.refName = RefNames.REFS_SEQUENCES + name;
+
     this.seed = checkNotNull(seed, "seed");
 
     checkArgument(batchSize > 0, "expected batchSize > 0, got: %s", batchSize);
@@ -197,7 +202,9 @@
       limit = counter + count;
       acquireCount++;
     } catch (ExecutionException | RetryException e) {
-      Throwables.propagateIfInstanceOf(e.getCause(), OrmException.class);
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
+      }
       throw new OrmException(e);
     } catch (IOException e) {
       throw new OrmException(e);
@@ -245,19 +252,17 @@
         // may do it properly (certainly InMemoryRepository doesn't).
         throw new IncorrectObjectTypeException(id, OBJ_BLOB);
       }
-      String str = CharMatcher.whitespace().trimFrom(
-          new String(ol.getCachedBytes(), UTF_8));
+      String str = CharMatcher.whitespace().trimFrom(new String(ol.getCachedBytes(), UTF_8));
       Integer val = Ints.tryParse(str);
       if (val == null) {
-        throw new OrmException(
-            "invalid value in " + refName + " blob at " + id.name());
+        throw new OrmException("invalid value in " + refName + " blob at " + id.name());
       }
       return val;
     }
   }
 
-  private RefUpdate.Result store(Repository repo, RevWalk rw,
-      @Nullable ObjectId oldId, int val) throws IOException {
+  private RefUpdate.Result store(Repository repo, RevWalk rw, @Nullable ObjectId oldId, int val)
+      throws IOException {
     ObjectId newId;
     try (ObjectInserter ins = repo.newObjectInserter()) {
       newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
@@ -271,4 +276,10 @@
     ru.setForceUpdate(true); // Required for non-commitish updates.
     return ru.update(rw);
   }
+
+  public static ReceiveCommand storeNew(ObjectInserter ins, String name, int val)
+      throws IOException {
+    ObjectId newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
+    return new ReceiveCommand(ObjectId.zeroId(), newId, RefNames.REFS_SEQUENCES + name);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index be7f8d5..f250646 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -15,10 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.extensions.client.ReviewerState;
-
-import org.eclipse.jgit.revwalk.FooterKey;
-
 import java.util.Arrays;
+import org.eclipse.jgit.revwalk.FooterKey;
 
 /** State of a reviewer on a change. */
 public enum ReviewerStateInternal {
@@ -40,9 +38,11 @@
       ok &= s.name().equals(s.state.name());
     }
     if (!ok) {
-      throw new IllegalStateException("Mismatched reviewer state mapping: "
-          + Arrays.asList(ReviewerStateInternal.values()) + " != "
-          + Arrays.asList(ReviewerState.values()));
+      throw new IllegalStateException(
+          "Mismatched reviewer state mapping: "
+              + Arrays.asList(ReviewerStateInternal.values())
+              + " != "
+              + Arrays.asList(ReviewerState.values()));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
index 73ad68e..aec8442 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
@@ -14,72 +14,64 @@
 
 package com.google.gerrit.server.notedb;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.common.base.Preconditions.checkState;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.primitives.Bytes;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-
+import com.google.gerrit.reviewdb.client.Comment;
+import java.io.IOException;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.util.MutableInteger;
-import org.eclipse.jgit.util.RawParseUtils;
 
-import java.io.IOException;
-
-class RevisionNote {
+abstract class RevisionNote<T extends Comment> {
   static final int MAX_NOTE_SZ = 25 << 20;
 
-  private static final byte[] CERT_HEADER =
-      "certificate version ".getBytes(UTF_8);
-  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
-  private static final byte[] END_SIGNATURE =
-      "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
-
-  private static void trimLeadingEmptyLines(byte[] bytes, MutableInteger p) {
+  protected static void trimLeadingEmptyLines(byte[] bytes, MutableInteger p) {
     while (p.value < bytes.length && bytes[p.value] == '\n') {
       p.value++;
     }
   }
 
-  private static String parsePushCert(Change.Id changeId, byte[] bytes,
-      MutableInteger p) throws ConfigInvalidException {
-    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
-      return null;
-    }
-    int end = Bytes.indexOf(bytes, END_SIGNATURE);
-    if (end < 0) {
-      throw ChangeNotes.parseException(
-          changeId, "invalid push certificate in note");
-    }
-    int start = p.value;
-    p.value = end + END_SIGNATURE.length;
-    return new String(bytes, start, p.value);
+  private final ObjectReader reader;
+  private final ObjectId noteId;
+
+  private byte[] raw;
+  private ImmutableList<T> comments;
+
+  RevisionNote(ObjectReader reader, ObjectId noteId) {
+    this.reader = reader;
+    this.noteId = noteId;
   }
 
-  final byte[] raw;
-  final ImmutableList<PatchLineComment> comments;
-  final String pushCert;
+  public byte[] getRaw() {
+    checkParsed();
+    return raw;
+  }
 
-  RevisionNote(ChangeNoteUtil noteUtil, Change.Id changeId,
-      ObjectReader reader, ObjectId noteId, boolean draftsOnly)
-      throws ConfigInvalidException, IOException {
+  public ImmutableList<T> getComments() {
+    checkParsed();
+    return comments;
+  }
+
+  public void parse() throws IOException, ConfigInvalidException {
     raw = reader.open(noteId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
     MutableInteger p = new MutableInteger();
     trimLeadingEmptyLines(raw, p);
-    if (!draftsOnly) {
-      pushCert = parsePushCert(changeId, raw, p);
-      trimLeadingEmptyLines(raw, p);
-    } else {
-      pushCert = null;
+    if (p.value >= raw.length) {
+      comments = null;
+      return;
     }
-    PatchLineComment.Status status = draftsOnly
-        ? PatchLineComment.Status.DRAFT
-        : PatchLineComment.Status.PUBLISHED;
-    comments = ImmutableList.copyOf(
-        noteUtil.parseNote(raw, p, changeId, status));
+
+    comments = ImmutableList.copyOf(parse(raw, p.value));
+  }
+
+  protected abstract List<T> parse(byte[] raw, int offset)
+      throws IOException, ConfigInvalidException;
+
+  protected void checkParsed() {
+    checkState(raw != null, "revision note not parsed yet");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index c8364d3..b341ea8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -15,16 +15,18 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.RevId;
-
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -34,10 +36,10 @@
 
 class RevisionNoteBuilder {
   static class Cache {
-    private final RevisionNoteMap revisionNoteMap;
+    private final RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap;
     private final Map<RevId, RevisionNoteBuilder> builders;
 
-    Cache(RevisionNoteMap revisionNoteMap) {
+    Cache(RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap) {
       this.revisionNoteMap = revisionNoteMap;
       this.builders = new HashMap<>();
     }
@@ -45,8 +47,7 @@
     RevisionNoteBuilder get(RevId revId) {
       RevisionNoteBuilder b = builders.get(revId);
       if (b == null) {
-        b = new RevisionNoteBuilder(
-            revisionNoteMap.revisionNotes.get(revId));
+        b = new RevisionNoteBuilder(revisionNoteMap.revisionNotes.get(revId));
         builders.put(revId, b);
       }
       return b;
@@ -58,18 +59,20 @@
   }
 
   final byte[] baseRaw;
-  final List<PatchLineComment> baseComments;
-  final Map<PatchLineComment.Key, PatchLineComment> put;
-  final Set<PatchLineComment.Key> delete;
+  final List<? extends Comment> baseComments;
+  final Map<Comment.Key, Comment> put;
+  final Set<Comment.Key> delete;
 
   private String pushCert;
 
-  RevisionNoteBuilder(RevisionNote base) {
+  RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
     if (base != null) {
-      baseRaw = base.raw;
-      baseComments = base.comments;
-      put = Maps.newHashMapWithExpectedSize(base.comments.size());
-      pushCert = base.pushCert;
+      baseRaw = base.getRaw();
+      baseComments = base.getComments();
+      put = Maps.newHashMapWithExpectedSize(baseComments.size());
+      if (base instanceof ChangeRevisionNote) {
+        pushCert = ((ChangeRevisionNote) base).getPushCert();
+      }
     } else {
       baseRaw = new byte[0];
       baseComments = Collections.emptyList();
@@ -79,13 +82,22 @@
     delete = new HashSet<>();
   }
 
-  void putComment(PatchLineComment comment) {
-    checkArgument(!delete.contains(comment.getKey()),
-        "cannot both delete and put %s", comment.getKey());
-    put.put(comment.getKey(), comment);
+  public byte[] build(ChangeNoteUtil noteUtil, boolean writeJson) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    if (writeJson) {
+      buildNoteJson(noteUtil, out);
+    } else {
+      buildNoteLegacy(noteUtil, out);
+    }
+    return out.toByteArray();
   }
 
-  void deleteComment(PatchLineComment.Key key) {
+  void putComment(Comment comment) {
+    checkArgument(!delete.contains(comment.key), "cannot both delete and put %s", comment.key);
+    put.put(comment.key, comment);
+  }
+
+  void deleteComment(Comment.Key key) {
     checkArgument(!put.containsKey(key), "cannot both delete and put %s", key);
     delete.add(key);
   }
@@ -94,27 +106,44 @@
     this.pushCert = pushCert;
   }
 
-  byte[] build(ChangeNoteUtil noteUtil) {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
+  private ListMultimap<Integer, Comment> buildCommentMap() {
+    ListMultimap<Integer, Comment> all = MultimapBuilder.hashKeys().arrayListValues().build();
+
+    for (Comment c : baseComments) {
+      if (!delete.contains(c.key) && !put.containsKey(c.key)) {
+        all.put(c.key.patchSetId, c);
+      }
+    }
+    for (Comment c : put.values()) {
+      if (!delete.contains(c.key)) {
+        all.put(c.key.patchSetId, c);
+      }
+    }
+    return all;
+  }
+
+  private void buildNoteJson(ChangeNoteUtil noteUtil, OutputStream out) throws IOException {
+    ListMultimap<Integer, Comment> comments = buildCommentMap();
+    if (comments.isEmpty() && pushCert == null) {
+      return;
+    }
+
+    RevisionNoteData data = new RevisionNoteData();
+    data.comments = COMMENT_ORDER.sortedCopy(comments.values());
+    data.pushCert = pushCert;
+
+    try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
+      noteUtil.getGson().toJson(data, osw);
+    }
+  }
+
+  private void buildNoteLegacy(ChangeNoteUtil noteUtil, OutputStream out) throws IOException {
     if (pushCert != null) {
       byte[] certBytes = pushCert.getBytes(UTF_8);
       out.write(certBytes, 0, trimTrailingNewlines(certBytes));
       out.write('\n');
     }
-
-    Multimap<PatchSet.Id, PatchLineComment> all = ArrayListMultimap.create();
-    for (PatchLineComment c : baseComments) {
-      if (!delete.contains(c.getKey()) && !put.containsKey(c.getKey())) {
-        all.put(c.getPatchSetId(), c);
-      }
-    }
-    for (PatchLineComment c : put.values()) {
-      if (!delete.contains(c.getKey())) {
-        all.put(c.getPatchSetId(), c);
-      }
-    }
-    noteUtil.buildNote(all, out);
-    return out.toByteArray();
+    noteUtil.buildNote(buildCommentMap(), out);
   }
 
   private static int trimTrailingNewlines(byte[] bytes) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteData.java
new file mode 100644
index 0000000..1e16b22
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+
+/**
+ * Holds the raw data of a RevisionNote.
+ *
+ * <p>It is intended for (de)serialization to JSON only.
+ */
+class RevisionNoteData {
+  String pushCert;
+  List<Comment> comments;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index cd70528..aa82d1a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,40 +16,56 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.RevId;
-
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-
-class RevisionNoteMap {
+class RevisionNoteMap<T extends RevisionNote<? extends Comment>> {
   final NoteMap noteMap;
-  final ImmutableMap<RevId, RevisionNote> revisionNotes;
+  final ImmutableMap<RevId, T> revisionNotes;
 
-  static RevisionNoteMap parse(ChangeNoteUtil noteUtil,
-      Change.Id changeId, ObjectReader reader, NoteMap noteMap,
-      boolean draftsOnly) throws ConfigInvalidException, IOException {
-    Map<RevId, RevisionNote> result = new HashMap<>();
+  static RevisionNoteMap<ChangeRevisionNote> parse(
+      ChangeNoteUtil noteUtil,
+      Change.Id changeId,
+      ObjectReader reader,
+      NoteMap noteMap,
+      PatchLineComment.Status status)
+      throws ConfigInvalidException, IOException {
+    Map<RevId, ChangeRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
-      RevisionNote rn = new RevisionNote(
-          noteUtil, changeId, reader, note.getData(), draftsOnly);
+      ChangeRevisionNote rn =
+          new ChangeRevisionNote(noteUtil, changeId, reader, note.getData(), status);
+      rn.parse();
       result.put(new RevId(note.name()), rn);
     }
-    return new RevisionNoteMap(noteMap, ImmutableMap.copyOf(result));
+    return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
 
-  static RevisionNoteMap emptyMap() {
-    return new RevisionNoteMap(NoteMap.newEmptyMap(),
-        ImmutableMap.<RevId, RevisionNote> of());
+  static RevisionNoteMap<RobotCommentsRevisionNote> parseRobotComments(
+      ChangeNoteUtil noteUtil, ObjectReader reader, NoteMap noteMap)
+      throws ConfigInvalidException, IOException {
+    Map<RevId, RobotCommentsRevisionNote> result = new HashMap<>();
+    for (Note note : noteMap) {
+      RobotCommentsRevisionNote rn =
+          new RobotCommentsRevisionNote(noteUtil, reader, note.getData());
+      rn.parse();
+      result.put(new RevId(note.name()), rn);
+    }
+    return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
 
-  private RevisionNoteMap(NoteMap noteMap,
-      ImmutableMap<RevId, RevisionNote> revisionNotes) {
+  static <T extends RevisionNote<? extends Comment>> RevisionNoteMap<T> emptyMap() {
+    return new RevisionNoteMap<>(NoteMap.newEmptyMap(), ImmutableMap.<RevId, T>of());
+  }
+
+  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<RevId, T> revisionNotes) {
     this.noteMap = noteMap;
     this.revisionNotes = revisionNotes;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
new file mode 100644
index 0000000..e6549f0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+public class RobotCommentNotes extends AbstractChangeNotes<RobotCommentNotes> {
+  public interface Factory {
+    RobotCommentNotes create(Change change);
+  }
+
+  private final Change change;
+
+  private ImmutableListMultimap<RevId, RobotComment> comments;
+  private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
+  private ObjectId metaId;
+
+  @AssistedInject
+  RobotCommentNotes(Args args, @Assisted Change change) {
+    super(args, change.getId(), PrimaryStorage.of(change), false);
+    this.change = change;
+  }
+
+  RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap() {
+    return revisionNoteMap;
+  }
+
+  public ImmutableListMultimap<RevId, RobotComment> getComments() {
+    return comments;
+  }
+
+  public boolean containsComment(RobotComment c) {
+    for (RobotComment existing : comments.values()) {
+      if (c.key.equals(existing.key)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Override
+  public String getRefName() {
+    return RefNames.robotCommentsRef(getChangeId());
+  }
+
+  @Nullable
+  public ObjectId getMetaId() {
+    return metaId;
+  }
+
+  @Override
+  protected void onLoad(LoadHandle handle) throws IOException, ConfigInvalidException {
+    metaId = handle.id();
+    if (metaId == null) {
+      loadDefaults();
+      return;
+    }
+    metaId = metaId.copy();
+
+    RevCommit tipCommit = handle.walk().parseCommit(metaId);
+    ObjectReader reader = handle.walk().getObjectReader();
+    revisionNoteMap =
+        RevisionNoteMap.parseRobotComments(args.noteUtil, reader, NoteMap.read(reader, tipCommit));
+    ListMultimap<RevId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    for (RobotCommentsRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
+      for (RobotComment c : rn.getComments()) {
+        cs.put(new RevId(c.revId), c);
+      }
+    }
+    comments = ImmutableListMultimap.copyOf(cs);
+  }
+
+  @Override
+  protected void loadDefaults() {
+    comments = ImmutableListMultimap.of();
+  }
+
+  @Override
+  public Project.NameKey getProjectName() {
+    return change.getProject();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
new file mode 100644
index 0000000..82593eb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -0,0 +1,239 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.reviewdb.client.RefNames.robotCommentsRef;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A single delta to apply atomically to a change.
+ *
+ * <p>This delta contains only robot comments on a single patch set of a change by a single author.
+ * This delta will become a single commit in the repository.
+ *
+ * <p>This class is not thread safe.
+ */
+public class RobotCommentUpdate extends AbstractChangeUpdate {
+  public interface Factory {
+    RobotCommentUpdate create(
+        ChangeNotes notes,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+
+    RobotCommentUpdate create(
+        Change change,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+  }
+
+  private List<RobotComment> put = new ArrayList<>();
+
+  @AssistedInject
+  private RobotCommentUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      @Assisted ChangeNotes notes,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        anonymousCowardName,
+        notes,
+        null,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
+  }
+
+  @AssistedInject
+  private RobotCommentUpdate(
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      @Assisted Change change,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(
+        cfg,
+        migration,
+        noteUtil,
+        serverIdent,
+        anonymousCowardName,
+        null,
+        change,
+        accountId,
+        realAccountId,
+        authorIdent,
+        when);
+  }
+
+  public void putComment(RobotComment c) {
+    verifyComment(c);
+    put.add(c);
+  }
+
+  private CommitBuilder storeCommentsInNotes(
+      RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
+      throws ConfigInvalidException, OrmException, IOException {
+    RevisionNoteMap<RobotCommentsRevisionNote> rnm = getRevisionNoteMap(rw, curr);
+    Set<RevId> updatedRevs = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
+
+    for (RobotComment c : put) {
+      cache.get(new RevId(c.revId)).putComment(c);
+    }
+
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    boolean touchedAnyRevs = false;
+    boolean hasComments = false;
+    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedRevs.add(e.getKey());
+      ObjectId id = ObjectId.fromString(e.getKey().get());
+      byte[] data = e.getValue().build(noteUtil, true);
+      if (!Arrays.equals(data, e.getValue().baseRaw)) {
+        touchedAnyRevs = true;
+      }
+      if (data.length == 0) {
+        rnm.noteMap.remove(id);
+      } else {
+        hasComments = true;
+        ObjectId dataBlob = ins.insert(OBJ_BLOB, data);
+        rnm.noteMap.set(id, dataBlob);
+      }
+    }
+
+    // If we didn't touch any notes, tell the caller this was a no-op update. We
+    // couldn't have done this in isEmpty() below because we hadn't read the old
+    // data yet.
+    if (!touchedAnyRevs) {
+      return NO_OP_UPDATE;
+    }
+
+    // If we touched every revision and there are no comments left, tell the
+    // caller to delete the entire ref.
+    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
+    if (touchedAllRevs && !hasComments) {
+      return null;
+    }
+
+    cb.setTreeId(rnm.noteMap.writeTree(ins));
+    return cb;
+  }
+
+  private RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
+      throws ConfigInvalidException, OrmException, IOException {
+    if (curr.equals(ObjectId.zeroId())) {
+      return RevisionNoteMap.emptyMap();
+    }
+    if (migration.readChanges()) {
+      // If reading from changes is enabled, then the old RobotCommentNotes
+      // already parsed the revision notes. We can reuse them as long as the ref
+      // hasn't advanced.
+      ChangeNotes changeNotes = getNotes();
+      if (changeNotes != null) {
+        RobotCommentNotes robotCommentNotes = changeNotes.load().getRobotCommentNotes();
+        if (robotCommentNotes != null) {
+          ObjectId idFromNotes = firstNonNull(robotCommentNotes.getRevision(), ObjectId.zeroId());
+          RevisionNoteMap<RobotCommentsRevisionNote> rnm = robotCommentNotes.getRevisionNoteMap();
+          if (idFromNotes.equals(curr) && rnm != null) {
+            return rnm;
+          }
+        }
+      }
+    }
+    NoteMap noteMap;
+    if (!curr.equals(ObjectId.zeroId())) {
+      noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
+    } else {
+      noteMap = NoteMap.newEmptyMap();
+    }
+    // Even though reading from changes might not be enabled, we need to
+    // parse any existing revision notes so we can merge them.
+    return RevisionNoteMap.parseRobotComments(noteUtil, rw.getObjectReader(), noteMap);
+  }
+
+  @Override
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
+      throws OrmException, IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setMessage("Update robot comments");
+    try {
+      return storeCommentsInNotes(rw, ins, curr, cb);
+    } catch (ConfigInvalidException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  protected Project.NameKey getProjectName() {
+    return getNotes().getProjectName();
+  }
+
+  @Override
+  protected String getRefName() {
+    return robotCommentsRef(getId());
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return put.isEmpty();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
new file mode 100644
index 0000000..aa229ab
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.reviewdb.client.RobotComment;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+
+public class RobotCommentsRevisionNote extends RevisionNote<RobotComment> {
+  private final ChangeNoteUtil noteUtil;
+
+  RobotCommentsRevisionNote(ChangeNoteUtil noteUtil, ObjectReader reader, ObjectId noteId) {
+    super(reader, noteId);
+    this.noteUtil = noteUtil;
+  }
+
+  @Override
+  protected List<RobotComment> parse(byte[] raw, int offset) throws IOException {
+    try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
+        Reader r = new InputStreamReader(is, UTF_8)) {
+      return noteUtil.getGson().fromJson(r, RobotCommentsRevisionNoteData.class).comments;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
new file mode 100644
index 0000000..116b30e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.reviewdb.client.RobotComment;
+import java.util.List;
+
+public class RobotCommentsRevisionNoteData {
+  List<RobotComment> comments;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
index c0bb8ab..11fef24 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/TestChangeRebuilderWrapper.java
@@ -15,20 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableMultimap;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -40,8 +37,7 @@
   private final AtomicBoolean stealNextUpdate;
 
   @Inject
-  TestChangeRebuilderWrapper(SchemaFactory<ReviewDb> schemaFactory,
-      ChangeRebuilderImpl rebuilder) {
+  TestChangeRebuilderWrapper(SchemaFactory<ReviewDb> schemaFactory, ChangeRebuilderImpl rebuilder) {
     super(schemaFactory);
     this.delegate = rebuilder;
     this.failNextUpdate = new AtomicBoolean();
@@ -57,13 +53,25 @@
   }
 
   @Override
-  public Result rebuild(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
+  public Result rebuild(ReviewDb db, Change.Id changeId) throws IOException, OrmException {
+    return rebuild(db, changeId, true);
+  }
+
+  @Override
+  public Result rebuildEvenIfReadOnly(ReviewDb db, Change.Id changeId)
+      throws IOException, OrmException {
+    return rebuild(db, changeId, false);
+  }
+
+  private Result rebuild(ReviewDb db, Change.Id changeId, boolean checkReadOnly)
+      throws IOException, OrmException {
     if (failNextUpdate.getAndSet(false)) {
       throw new IOException("Update failed");
     }
-    Result result = delegate.rebuild(db, changeId);
+    Result result =
+        checkReadOnly
+            ? delegate.rebuild(db, changeId)
+            : delegate.rebuildEvenIfReadOnly(db, changeId);
     if (stealNextUpdate.getAndSet(false)) {
       throw new IOException("Update stolen");
     }
@@ -71,9 +79,8 @@
   }
 
   @Override
-  public Result rebuild(NoteDbUpdateManager manager,
-      ChangeBundle bundle) throws NoSuchChangeException, IOException,
-      OrmException, ConfigInvalidException {
+  public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException {
     // stealNextUpdate doesn't really apply in this case because the IOException
     // would normally come from the manager.execute() method, which isn't called
     // here.
@@ -81,33 +88,15 @@
   }
 
   @Override
-  public boolean rebuildProject(ReviewDb db,
-      ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
-      Project.NameKey project, Repository allUsersRepo)
-      throws NoSuchChangeException, IOException, OrmException,
-      ConfigInvalidException {
-    if (failNextUpdate.getAndSet(false)) {
-      throw new IOException("Update failed");
-    }
-    boolean result =
-        delegate.rebuildProject(db, allChanges, project, allUsersRepo);
-    if (stealNextUpdate.getAndSet(false)) {
-      throw new IOException("Update stolen");
-    }
-    return result;
-  }
-
-  @Override
   public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
-      throws NoSuchChangeException, IOException, OrmException {
+      throws IOException, OrmException {
     // Don't inspect stealNextUpdate; that happens in execute() below.
     return delegate.stage(db, changeId);
   }
 
   @Override
-  public Result execute(ReviewDb db, Change.Id changeId,
-      NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
-      IOException {
+  public Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
+      throws OrmException, IOException {
     if (failNextUpdate.getAndSet(false)) {
       throw new IOException("Update failed");
     }
@@ -117,4 +106,20 @@
     }
     return result;
   }
+
+  @Override
+  public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException {
+    // Don't check for manual failure; that happens in execute().
+    delegate.buildUpdates(manager, bundle);
+  }
+
+  @Override
+  public void rebuildReviewDb(ReviewDb db, Project.NameKey project, Id changeId)
+      throws OrmException {
+    if (failNextUpdate.getAndSet(false)) {
+      throw new OrmException("Update failed");
+    }
+    delegate.rebuildReviewDb(db, project, changeId);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
new file mode 100644
index 0000000..0e6d3e9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/AbortUpdateException.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gwtorm.server.OrmRuntimeException;
+
+class AbortUpdateException extends OrmRuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  AbortUpdateException() {
+    super("aborted");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
new file mode 100644
index 0000000..9ecf476
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import java.sql.Timestamp;
+
+class ApprovalEvent extends Event {
+  private PatchSetApproval psa;
+
+  ApprovalEvent(PatchSetApproval psa, Timestamp changeCreatedOn) {
+    super(
+        psa.getPatchSetId(),
+        psa.getAccountId(),
+        psa.getRealAccountId(),
+        psa.getGranted(),
+        changeCreatedOn,
+        psa.getTag());
+    this.psa = psa;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return false;
+  }
+
+  @Override
+  protected boolean canHaveTag() {
+    // Legacy SUBM approvals don't have a tag field set, but the corresponding
+    // ChangeMessage for merging the change does. We need to let these be in the
+    // same meta commit so the SUBM approval isn't counted as post-submit.
+    return !psa.isLegacySubmit();
+  }
+
+  @Override
+  void apply(ChangeUpdate update) {
+    checkUpdate(update);
+    update.putApproval(psa.getLabel(), psa.getValue());
+  }
+
+  @Override
+  protected boolean isPostSubmitApproval() {
+    return psa.isPostSubmit();
+  }
+
+  @Override
+  protected void addToString(ToStringHelper helper) {
+    helper.add("approval", psa);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
new file mode 100644
index 0000000..ad22330
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+import java.sql.Timestamp;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+class ChangeMessageEvent extends Event {
+  private static final ImmutableMap<Change.Status, Pattern> STATUS_PATTERNS =
+      ImmutableMap.of(
+          Change.Status.ABANDONED, Pattern.compile("^Abandoned(\n.*)*$"),
+          Change.Status.MERGED,
+              Pattern.compile(
+                  "^Change has been successfully (merged|cherry-picked|rebased|pushed).*$"),
+          Change.Status.NEW, Pattern.compile("^Restored(\n.*)*$"));
+
+  private static final Pattern TOPIC_SET_REGEXP = Pattern.compile("^Topic set to (.+)$");
+  private static final Pattern TOPIC_CHANGED_REGEXP =
+      Pattern.compile("^Topic changed from (.+) to (.+)$");
+  private static final Pattern TOPIC_REMOVED_REGEXP = Pattern.compile("^Topic (.+) removed$");
+
+  private final Change change;
+  private final Change noteDbChange;
+  private final Optional<Change.Status> status;
+  private final ChangeMessage message;
+
+  ChangeMessageEvent(
+      Change change, Change noteDbChange, ChangeMessage message, Timestamp changeCreatedOn) {
+    super(
+        message.getPatchSetId(),
+        message.getAuthor(),
+        message.getRealAuthor(),
+        message.getWrittenOn(),
+        changeCreatedOn,
+        message.getTag());
+    this.change = change;
+    this.noteDbChange = noteDbChange;
+    this.message = message;
+    this.status = parseStatus(message);
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @Override
+  protected boolean isSubmit() {
+    return status.isPresent() && status.get() == Change.Status.MERGED;
+  }
+
+  @Override
+  protected boolean canHaveTag() {
+    return true;
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    checkUpdate(update);
+    update.setChangeMessage(message.getMessage());
+    setTopic(update);
+
+    if (status.isPresent()) {
+      Change.Status s = status.get();
+      update.fixStatus(s);
+      noteDbChange.setStatus(s);
+      if (s == Change.Status.MERGED) {
+        update.setSubmissionId(change.getSubmissionId());
+        noteDbChange.setSubmissionId(change.getSubmissionId());
+      }
+    }
+  }
+
+  private static Optional<Change.Status> parseStatus(ChangeMessage message) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return Optional.empty();
+    }
+    for (Map.Entry<Change.Status, Pattern> e : STATUS_PATTERNS.entrySet()) {
+      if (e.getValue().matcher(msg).matches()) {
+        return Optional.of(e.getKey());
+      }
+    }
+    return Optional.empty();
+  }
+
+  private void setTopic(ChangeUpdate update) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return;
+    }
+    Matcher m = TOPIC_SET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      String topic = m.group(1);
+      update.setTopic(topic);
+      noteDbChange.setTopic(topic);
+      return;
+    }
+
+    m = TOPIC_CHANGED_REGEXP.matcher(msg);
+    if (m.matches()) {
+      String topic = m.group(2);
+      update.setTopic(topic);
+      noteDbChange.setTopic(topic);
+      return;
+    }
+
+    if (TOPIC_REMOVED_REGEXP.matcher(msg).matches()) {
+      update.setTopic(null);
+      noteDbChange.setTopic(null);
+    }
+  }
+
+  @Override
+  protected void addToString(ToStringHelper helper) {
+    helper.add("message", message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
new file mode 100644
index 0000000..6f9090f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.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.server.notedb.rebuild;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import java.io.IOException;
+import java.util.concurrent.Callable;
+
+public abstract class ChangeRebuilder {
+  public static class NoPatchSetsException extends OrmException {
+    private static final long serialVersionUID = 1L;
+
+    NoPatchSetsException(Change.Id changeId) {
+      super("Change " + changeId + " cannot be rebuilt because it has no patch sets");
+    }
+  }
+
+  private final SchemaFactory<ReviewDb> schemaFactory;
+
+  protected ChangeRebuilder(SchemaFactory<ReviewDb> schemaFactory) {
+    this.schemaFactory = schemaFactory;
+  }
+
+  public final ListenableFuture<Result> rebuildAsync(
+      final Change.Id id, ListeningExecutorService executor) {
+    return executor.submit(
+        new Callable<Result>() {
+          @Override
+          public Result call() throws Exception {
+            try (ReviewDb db = schemaFactory.open()) {
+              return rebuild(db, id);
+            }
+          }
+        });
+  }
+
+  /**
+   * Rebuild ReviewDb contents by copying from NoteDb.
+   *
+   * <p>Requires NoteDb to be the primary storage for the change.
+   */
+  public abstract void rebuildReviewDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
+      throws OrmException;
+
+  // In the following methods "rebuilding" always refers to copying the state
+  // from ReviewDb to NoteDb, i.e. assuming ReviewDb is the primary storage.
+
+  public abstract Result rebuild(ReviewDb db, Change.Id changeId) throws IOException, OrmException;
+
+  public abstract Result rebuildEvenIfReadOnly(ReviewDb db, Change.Id changeId)
+      throws IOException, OrmException;
+
+  public abstract Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException;
+
+  public abstract void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException;
+
+  public abstract NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws IOException, OrmException;
+
+  public abstract Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
+      throws OrmException, IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
new file mode 100644
index 0000000..8370df1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -0,0 +1,666 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.MultimapBuilder;
+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.Nullable;
+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.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+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.CommentsUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeDraftUpdate;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import com.google.gwtorm.client.Key;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+public class ChangeRebuilderImpl extends ChangeRebuilder {
+  /**
+   * The maximum amount of time between the ReviewDb timestamp of the first and last events batched
+   * together into a single NoteDb update.
+   *
+   * <p>Used to account for the fact that different records with their own timestamps (e.g. {@link
+   * PatchSetApproval} and {@link ChangeMessage}) historically didn't necessarily use the same
+   * timestamp, and tended to call {@code System.currentTimeMillis()} independently.
+   */
+  public static final long MAX_WINDOW_MS = SECONDS.toMillis(3);
+
+  /**
+   * The maximum amount of time between two consecutive events to consider them to be in the same
+   * batch.
+   */
+  static final long MAX_DELTA_MS = SECONDS.toMillis(1);
+
+  private final AccountCache accountCache;
+  private final ChangeBundleReader bundleReader;
+  private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final ChangeNoteUtil changeNoteUtil;
+  private final ChangeNotes.Factory notesFactory;
+  private final ChangeUpdate.Factory updateFactory;
+  private final CommentsUtil commentsUtil;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final NotesMigration migration;
+  private final PatchListCache patchListCache;
+  private final PersonIdent serverIdent;
+  private final ProjectCache projectCache;
+  private final String anonymousCowardName;
+  private final String serverId;
+  private final long skewMs;
+
+  @Inject
+  ChangeRebuilderImpl(
+      @GerritServerConfig Config cfg,
+      SchemaFactory<ReviewDb> schemaFactory,
+      AccountCache accountCache,
+      ChangeBundleReader bundleReader,
+      ChangeDraftUpdate.Factory draftUpdateFactory,
+      ChangeNoteUtil changeNoteUtil,
+      ChangeNotes.Factory notesFactory,
+      ChangeUpdate.Factory updateFactory,
+      CommentsUtil commentsUtil,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration migration,
+      PatchListCache patchListCache,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @Nullable ProjectCache projectCache,
+      @AnonymousCowardName String anonymousCowardName,
+      @GerritServerId String serverId) {
+    super(schemaFactory);
+    this.accountCache = accountCache;
+    this.bundleReader = bundleReader;
+    this.draftUpdateFactory = draftUpdateFactory;
+    this.changeNoteUtil = changeNoteUtil;
+    this.notesFactory = notesFactory;
+    this.updateFactory = updateFactory;
+    this.commentsUtil = commentsUtil;
+    this.updateManagerFactory = updateManagerFactory;
+    this.migration = migration;
+    this.patchListCache = patchListCache;
+    this.serverIdent = serverIdent;
+    this.projectCache = projectCache;
+    this.anonymousCowardName = anonymousCowardName;
+    this.serverId = serverId;
+    this.skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
+  }
+
+  @Override
+  public Result rebuild(ReviewDb db, Change.Id changeId) throws IOException, OrmException {
+    return rebuild(db, changeId, true);
+  }
+
+  @Override
+  public Result rebuildEvenIfReadOnly(ReviewDb db, Change.Id changeId)
+      throws IOException, OrmException {
+    return rebuild(db, changeId, false);
+  }
+
+  private Result rebuild(ReviewDb db, Change.Id changeId, boolean checkReadOnly)
+      throws IOException, OrmException {
+    db = ReviewDbUtil.unwrapDb(db);
+    // Read change just to get project; this instance is then discarded so we
+    // can read a consistent ChangeBundle inside a transaction.
+    Change change = db.changes().get(changeId);
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject())) {
+      buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+      return execute(db, changeId, manager, checkReadOnly);
+    }
+  }
+
+  @Override
+  public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws NoSuchChangeException, IOException, OrmException {
+    Change change = new Change(bundle.getChange());
+    buildUpdates(manager, bundle);
+    return manager.stageAndApplyDelta(change);
+  }
+
+  @Override
+  public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
+      throws IOException, OrmException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+    NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject());
+    buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+    manager.stage();
+    return manager;
+  }
+
+  @Override
+  public Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
+      throws OrmException, IOException {
+    return execute(db, changeId, manager, true);
+  }
+
+  public Result execute(
+      ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager, boolean checkReadOnly)
+      throws OrmException, IOException {
+    db = ReviewDbUtil.unwrapDb(db);
+    Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
+    if (change == null) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    final String oldNoteDbState = change.getNoteDbState();
+    Result r = manager.stageAndApplyDelta(change);
+    final String newNoteDbState = change.getNoteDbState();
+    try {
+      db.changes()
+          .atomicUpdate(
+              changeId,
+              new AtomicUpdate<Change>() {
+                @Override
+                public Change update(Change change) {
+                  if (checkReadOnly) {
+                    NoteDbChangeState.checkNotReadOnly(change, skewMs);
+                  }
+                  String currNoteDbState = change.getNoteDbState();
+                  if (Objects.equals(currNoteDbState, newNoteDbState)) {
+                    // Another thread completed the same rebuild we were about to.
+                    throw new AbortUpdateException();
+                  } else if (!Objects.equals(oldNoteDbState, currNoteDbState)) {
+                    // Another thread updated the state to something else.
+                    throw new ConflictingUpdateException(change, oldNoteDbState);
+                  }
+                  change.setNoteDbState(newNoteDbState);
+                  return change;
+                }
+              });
+    } catch (ConflictingUpdateException e) {
+      // Rethrow as an OrmException so the caller knows to use staged results.
+      // Strictly speaking they are not completely up to date, but result we
+      // send to the caller is the same as if this rebuild had executed before
+      // the other thread.
+      throw new OrmException(e.getMessage());
+    } catch (AbortUpdateException e) {
+      if (NoteDbChangeState.parse(changeId, newNoteDbState)
+          .isUpToDate(
+              manager.getChangeRepo().cmds.getRepoRefCache(),
+              manager.getAllUsersRepo().cmds.getRepoRefCache())) {
+        // If the state in ReviewDb matches NoteDb at this point, it means
+        // another thread successfully completed this rebuild. It's ok to not
+        // execute the update in this case, since the object referenced in the
+        // Result was flushed to the repo by whatever thread won the race.
+        return r;
+      }
+      // If the state doesn't match, that means another thread attempted this
+      // rebuild, but failed. Fall through and try to update the ref again.
+    }
+    if (migration.failChangeWrites()) {
+      // Don't even attempt to execute if read-only, it would fail anyway. But
+      // do throw an exception to the caller so they know to use the staged
+      // results instead of reading from the repo.
+      throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+    }
+    manager.execute();
+    return r;
+  }
+
+  private static Change checkNoteDbState(Change c) throws OrmException {
+    // Can only rebuild a change if its primary storage is ReviewDb.
+    NoteDbChangeState s = NoteDbChangeState.parse(c);
+    if (s != null && s.getPrimaryStorage() != PrimaryStorage.REVIEW_DB) {
+      throw new OrmException(
+          String.format("cannot rebuild change " + c.getId() + " with state " + s));
+    }
+    return c;
+  }
+
+  @Override
+  public void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle)
+      throws IOException, OrmException {
+    manager.setCheckExpectedState(false).setRefLogMessage("Rebuilding change");
+    Change change = new Change(bundle.getChange());
+    if (bundle.getPatchSets().isEmpty()) {
+      throw new NoPatchSetsException(change.getId());
+    }
+
+    // We will rebuild all events, except for draft comments, in buckets based
+    // on author and timestamp.
+    List<Event> events = new ArrayList<>();
+    ListMultimap<Account.Id, DraftCommentEvent> draftCommentEvents =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+
+    events.addAll(getHashtagsEvents(change, manager));
+
+    // Delete ref only after hashtags have been read
+    deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
+    deleteDraftRefs(change, manager.getAllUsersRepo());
+
+    Integer minPsNum = getMinPatchSetNum(bundle);
+    TreeMap<PatchSet.Id, PatchSetEvent> patchSetEvents =
+        new TreeMap<>(ReviewDbUtil.intKeyOrdering());
+
+    for (PatchSet ps : bundle.getPatchSets()) {
+      PatchSetEvent pse = new PatchSetEvent(change, ps, manager.getChangeRepo().rw);
+      patchSetEvents.put(ps.getId(), pse);
+      events.add(pse);
+      for (Comment c : getComments(bundle, serverId, Status.PUBLISHED, ps)) {
+        CommentEvent e = new CommentEvent(c, change, ps, patchListCache);
+        events.add(e.addDep(pse));
+      }
+      for (Comment c : getComments(bundle, serverId, Status.DRAFT, ps)) {
+        DraftCommentEvent e = new DraftCommentEvent(c, change, ps, patchListCache);
+        draftCommentEvents.put(c.author.getId(), e);
+      }
+    }
+    ensurePatchSetOrder(patchSetEvents);
+
+    for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
+      PatchSetEvent pse = patchSetEvents.get(psa.getPatchSetId());
+      if (pse != null) {
+        events.add(new ApprovalEvent(psa, change.getCreatedOn()).addDep(pse));
+      }
+    }
+
+    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()) {
+      Event msgEvent = new ChangeMessageEvent(change, noteDbChange, msg, change.getCreatedOn());
+      if (msg.getPatchSetId() != null) {
+        PatchSetEvent pse = patchSetEvents.get(msg.getPatchSetId());
+        if (pse == null) {
+          continue; // Ignore events for missing patch sets.
+        }
+        msgEvent.addDep(pse);
+      }
+      events.add(msgEvent);
+    }
+
+    sortAndFillEvents(change, noteDbChange, bundle.getPatchSets(), events, minPsNum);
+
+    EventList<Event> el = new EventList<>();
+    for (Event e : events) {
+      if (!el.canAdd(e)) {
+        flushEventsToUpdate(manager, el, change);
+        checkState(el.canAdd(e));
+      }
+      el.add(e);
+    }
+    flushEventsToUpdate(manager, el, change);
+
+    EventList<DraftCommentEvent> plcel = new EventList<>();
+    for (Account.Id author : draftCommentEvents.keys()) {
+      for (DraftCommentEvent e : Ordering.natural().sortedCopy(draftCommentEvents.get(author))) {
+        if (!plcel.canAdd(e)) {
+          flushEventsToDraftUpdate(manager, plcel, change);
+          checkState(plcel.canAdd(e));
+        }
+        plcel.add(e);
+      }
+      flushEventsToDraftUpdate(manager, plcel, change);
+    }
+  }
+
+  private static Integer getMinPatchSetNum(ChangeBundle bundle) {
+    Integer minPsNum = null;
+    for (PatchSet ps : bundle.getPatchSets()) {
+      int n = ps.getId().get();
+      if (minPsNum == null || n < minPsNum) {
+        minPsNum = n;
+      }
+    }
+    return minPsNum;
+  }
+
+  private static void ensurePatchSetOrder(TreeMap<PatchSet.Id, PatchSetEvent> events) {
+    if (events.isEmpty()) {
+      return;
+    }
+    Iterator<PatchSetEvent> it = events.values().iterator();
+    PatchSetEvent curr = it.next();
+    while (it.hasNext()) {
+      PatchSetEvent next = it.next();
+      next.addDep(curr);
+      curr = next;
+    }
+  }
+
+  private static List<Comment> getComments(
+      ChangeBundle bundle, String serverId, PatchLineComment.Status status, PatchSet ps) {
+    return bundle.getPatchLineComments().stream()
+        .filter(c -> c.getPatchSetId().equals(ps.getId()) && c.getStatus() == status)
+        .map(plc -> plc.asComment(serverId))
+        .sorted(CommentsUtil.COMMENT_ORDER)
+        .collect(toList());
+  }
+
+  private void sortAndFillEvents(
+      Change change,
+      Change noteDbChange,
+      ImmutableCollection<PatchSet> patchSets,
+      List<Event> events,
+      Integer minPsNum) {
+    Event finalUpdates = new FinalUpdatesEvent(change, noteDbChange, patchSets);
+    events.add(finalUpdates);
+    setPostSubmitDeps(events);
+    new EventSorter(events).sort();
+
+    // Ensure the first event in the list creates the change, setting the author
+    // and any required footers.
+    Event first = events.get(0);
+    if (first instanceof PatchSetEvent && change.getOwner().equals(first.user)) {
+      ((PatchSetEvent) first).createChange = true;
+    } else {
+      events.add(0, new CreateChangeEvent(change, minPsNum));
+    }
+
+    // Final pass to correct some inconsistencies.
+    //
+    // First, fill in any missing patch set IDs using the latest patch set of
+    // the change at the time of the event, because NoteDb can't represent
+    // actions with no associated patch set ID. This workaround is as if a user
+    // added a ChangeMessage on the change by replying from the latest patch
+    // set.
+    //
+    // Start with the first patch set that actually exists. If there are no
+    // patch sets at all, minPsNum will be null, so just bail and use 1 as the
+    // patch set ID. The corresponding patch set won't exist, but this change is
+    // probably corrupt anyway, as deleting the last draft patch set should have
+    // deleted the whole change.
+    //
+    // Second, ensure timestamps are nondecreasing, by copying the previous
+    // timestamp if this happens. This assumes that the only way this can happen
+    // is due to dependency constraints, and it is ok to give an event the same
+    // timestamp as one of its dependencies.
+    int ps = firstNonNull(minPsNum, 1);
+    for (int i = 0; i < events.size(); i++) {
+      Event e = events.get(i);
+      if (e.psId == null) {
+        e.psId = new PatchSet.Id(change.getId(), ps);
+      } else {
+        ps = Math.max(ps, e.psId.get());
+      }
+
+      if (i > 0) {
+        Event p = events.get(i - 1);
+        if (e.when.before(p.when)) {
+          e.when = p.when;
+        }
+      }
+    }
+  }
+
+  private void setPostSubmitDeps(List<Event> events) {
+    Optional<Event> submitEvent =
+        Lists.reverse(events).stream().filter(Event::isSubmit).findFirst();
+    if (submitEvent.isPresent()) {
+      events.stream().filter(Event::isPostSubmitApproval).forEach(e -> e.addDep(submitEvent.get()));
+    }
+  }
+
+  private void flushEventsToUpdate(
+      NoteDbUpdateManager manager, EventList<Event> events, Change change)
+      throws OrmException, IOException {
+    if (events.isEmpty()) {
+      return;
+    }
+    Comparator<String> labelNameComparator;
+    if (projectCache != null) {
+      labelNameComparator = projectCache.get(change.getProject()).getLabelTypes().nameComparator();
+    } else {
+      // No project cache available, bail and use natural ordering; there's no
+      // semantic difference anyway difference.
+      labelNameComparator = Ordering.natural();
+    }
+    ChangeUpdate update =
+        updateFactory.create(
+            change,
+            events.getAccountId(),
+            events.getRealAccountId(),
+            newAuthorIdent(events),
+            events.getWhen(),
+            labelNameComparator);
+    update.setAllowWriteToNewRef(true);
+    update.setPatchSetId(events.getPatchSetId());
+    update.setTag(events.getTag());
+    for (Event e : events) {
+      e.apply(update);
+    }
+    manager.add(update);
+    events.clear();
+  }
+
+  private void flushEventsToDraftUpdate(
+      NoteDbUpdateManager manager, EventList<DraftCommentEvent> events, Change change)
+      throws OrmException {
+    if (events.isEmpty()) {
+      return;
+    }
+    ChangeDraftUpdate update =
+        draftUpdateFactory.create(
+            change,
+            events.getAccountId(),
+            events.getRealAccountId(),
+            newAuthorIdent(events),
+            events.getWhen());
+    update.setPatchSetId(events.getPatchSetId());
+    for (DraftCommentEvent e : events) {
+      e.applyDraft(update);
+    }
+    manager.add(update);
+    events.clear();
+  }
+
+  private PersonIdent newAuthorIdent(EventList<?> events) {
+    Account.Id id = events.getAccountId();
+    if (id == null) {
+      return new PersonIdent(serverIdent, events.getWhen());
+    }
+    return changeNoteUtil.newIdent(
+        accountCache.get(id).getAccount(), events.getWhen(), serverIdent, anonymousCowardName);
+  }
+
+  private List<HashtagsEvent> getHashtagsEvents(Change change, NoteDbUpdateManager manager)
+      throws IOException {
+    String refName = changeMetaRef(change.getId());
+    Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
+    if (!old.isPresent()) {
+      return Collections.emptyList();
+    }
+
+    RevWalk rw = manager.getChangeRepo().rw;
+    List<HashtagsEvent> events = new ArrayList<>();
+    rw.reset();
+    rw.markStart(rw.parseCommit(old.get()));
+    for (RevCommit commit : rw) {
+      Account.Id authorId;
+      try {
+        authorId = changeNoteUtil.parseIdent(commit.getAuthorIdent(), change.getId());
+      } catch (ConfigInvalidException e) {
+        continue; // Corrupt data, no valid hashtags in this commit.
+      }
+      PatchSet.Id psId = parsePatchSetId(change, commit);
+      Set<String> hashtags = parseHashtags(commit);
+      if (authorId == null || psId == null || hashtags == null) {
+        continue;
+      }
+
+      Timestamp commitTime = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+      events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags, change.getCreatedOn()));
+    }
+    return events;
+  }
+
+  private Set<String> parseHashtags(RevCommit commit) {
+    List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
+    if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
+      return null;
+    }
+
+    if (hashtagsLines.get(0).isEmpty()) {
+      return ImmutableSet.of();
+    }
+    return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
+  }
+
+  private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
+    List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
+    if (psIdLines.size() != 1) {
+      return null;
+    }
+    Integer psId = Ints.tryParse(psIdLines.get(0));
+    if (psId == null) {
+      return null;
+    }
+    return new PatchSet.Id(change.getId(), psId);
+  }
+
+  private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds) throws IOException {
+    String refName = changeMetaRef(change.getId());
+    Optional<ObjectId> old = cmds.get(refName);
+    if (old.isPresent()) {
+      cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
+    }
+  }
+
+  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()));
+    }
+  }
+
+  static void createChange(ChangeUpdate update, Change change) {
+    update.setSubjectForCommit("Create change");
+    update.setChangeId(change.getKey().get());
+    update.setBranch(change.getDest().get());
+    update.setSubject(change.getOriginalSubject());
+  }
+
+  @Override
+  public void rebuildReviewDb(ReviewDb db, Project.NameKey project, Change.Id changeId)
+      throws OrmException {
+    // TODO(dborowitz): Fail fast if changes tables are disabled in ReviewDb.
+    ChangeNotes notes = notesFactory.create(db, project, changeId);
+    ChangeBundle bundle = ChangeBundle.fromNotes(commentsUtil, notes);
+
+    db = ReviewDbUtil.unwrapDb(db);
+    db.changes().beginTransaction(changeId);
+    try {
+      Change c = db.changes().get(changeId);
+      PrimaryStorage ps = PrimaryStorage.of(c);
+      if (ps != PrimaryStorage.NOTE_DB) {
+        throw new OrmException("primary storage of " + changeId + " is " + ps);
+      }
+      db.changes().upsert(Collections.singleton(c));
+      putExactlyEntities(
+          db.changeMessages(), db.changeMessages().byChange(c.getId()), bundle.getChangeMessages());
+      putExactlyEntities(db.patchSets(), db.patchSets().byChange(c.getId()), bundle.getPatchSets());
+      putExactlyEntities(
+          db.patchSetApprovals(),
+          db.patchSetApprovals().byChange(c.getId()),
+          bundle.getPatchSetApprovals());
+      putExactlyEntities(
+          db.patchComments(),
+          db.patchComments().byChange(c.getId()),
+          bundle.getPatchLineComments());
+      db.commit();
+    } finally {
+      db.rollback();
+    }
+  }
+
+  private static <T, K extends Key<?>> void putExactlyEntities(
+      Access<T, K> access, Iterable<T> existing, Collection<T> ents) throws OrmException {
+    Set<K> toKeep = access.toMap(ents).keySet();
+    access.delete(
+        FluentIterable.from(existing).filter(e -> !toKeep.contains(access.primaryKey(e))));
+    access.upsert(ents);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
new file mode 100644
index 0000000..c8a649e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gwtorm.server.OrmException;
+
+class CommentEvent extends Event {
+  public final Comment c;
+  private final Change change;
+  private final PatchSet ps;
+  private final PatchListCache cache;
+
+  CommentEvent(Comment c, Change change, PatchSet ps, PatchListCache cache) {
+    super(
+        CommentsUtil.getCommentPsId(change.getId(), c),
+        c.author.getId(),
+        c.getRealAuthor().getId(),
+        c.writtenOn,
+        change.getCreatedOn(),
+        c.tag);
+    this.c = c;
+    this.change = change;
+    this.ps = ps;
+    this.cache = cache;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return false;
+  }
+
+  @Override
+  protected boolean canHaveTag() {
+    return true;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    checkUpdate(update);
+    if (c.revId == null) {
+      setCommentRevId(c, cache, change, ps);
+    }
+    update.putComment(PatchLineComment.Status.PUBLISHED, c);
+  }
+
+  @Override
+  protected void addToString(ToStringHelper helper) {
+    helper.add("message", c.message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
new file mode 100644
index 0000000..c6ffffc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmRuntimeException;
+
+class ConflictingUpdateException extends OrmRuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  ConflictingUpdateException(Change change, String expectedNoteDbState) {
+    super(
+        String.format(
+            "Expected change %s to have noteDbState %s but was %s",
+            change.getId(), expectedNoteDbState, change.getNoteDbState()));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
new file mode 100644
index 0000000..d01071b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+
+class CreateChangeEvent extends Event {
+  private final Change change;
+
+  private static PatchSet.Id psId(Change change, Integer minPsNum) {
+    int n;
+    if (minPsNum == null) {
+      // There were no patch sets for the change at all, so something is very
+      // wrong. Bail and use 1 as the patch set.
+      n = 1;
+    } else {
+      n = minPsNum;
+    }
+    return new PatchSet.Id(change.getId(), n);
+  }
+
+  CreateChangeEvent(Change change, Integer minPsNum) {
+    super(
+        psId(change, minPsNum),
+        change.getOwner(),
+        change.getOwner(),
+        change.getCreatedOn(),
+        change.getCreatedOn(),
+        null);
+    this.change = change;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws IOException, OrmException {
+    checkUpdate(update);
+    ChangeRebuilderImpl.createChange(update, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
new file mode 100644
index 0000000..914930c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeDraftUpdate;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gwtorm.server.OrmException;
+
+class DraftCommentEvent extends Event {
+  public final Comment c;
+  private final Change change;
+  private final PatchSet ps;
+  private final PatchListCache cache;
+
+  DraftCommentEvent(Comment c, Change change, PatchSet ps, PatchListCache cache) {
+    super(
+        CommentsUtil.getCommentPsId(change.getId(), c),
+        c.author.getId(),
+        c.getRealAuthor().getId(),
+        c.writtenOn,
+        change.getCreatedOn(),
+        c.tag);
+    this.c = c;
+    this.change = change;
+    this.ps = ps;
+    this.cache = cache;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return false;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) {
+    throw new UnsupportedOperationException();
+  }
+
+  void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
+    if (c.revId == null) {
+      setCommentRevId(c, cache, change, ps);
+    }
+    draftUpdate.putComment(c);
+  }
+
+  @Override
+  protected void addToString(ToStringHelper helper) {
+    helper.add("message", c.message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java
new file mode 100644
index 0000000..3957c5c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl.MAX_WINDOW_MS;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.common.collect.ComparisonChain;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.notedb.AbstractChangeUpdate;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+abstract class Event implements Comparable<Event> {
+  // NOTE: EventList only supports direct subclasses, not an arbitrary
+  // hierarchy.
+
+  final Account.Id user;
+  final Account.Id realUser;
+  final String tag;
+  final boolean predatesChange;
+
+  /** Dependencies of this event; other events that must happen before this one. */
+  final List<Event> deps;
+
+  Timestamp when;
+  PatchSet.Id psId;
+
+  protected Event(
+      PatchSet.Id psId,
+      Account.Id effectiveUser,
+      Account.Id realUser,
+      Timestamp when,
+      Timestamp changeCreatedOn,
+      String tag) {
+    this.psId = psId;
+    this.user = effectiveUser;
+    this.realUser = realUser != null ? realUser : effectiveUser;
+    this.tag = tag;
+    // Truncate timestamps at the change's createdOn timestamp.
+    predatesChange = when.before(changeCreatedOn);
+    this.when = predatesChange ? changeCreatedOn : when;
+    deps = new ArrayList<>();
+  }
+
+  protected void checkUpdate(AbstractChangeUpdate update) {
+    checkState(
+        Objects.equals(update.getPatchSetId(), psId),
+        "cannot apply event for %s to update for %s",
+        update.getPatchSetId(),
+        psId);
+    checkState(
+        when.getTime() - update.getWhen().getTime() <= MAX_WINDOW_MS,
+        "event at %s outside update window starting at %s",
+        when,
+        update.getWhen());
+    checkState(
+        Objects.equals(update.getNullableAccountId(), user),
+        "cannot apply event by %s to update by %s",
+        user,
+        update.getNullableAccountId());
+  }
+
+  Event addDep(Event e) {
+    deps.add(e);
+    return this;
+  }
+
+  /**
+   * @return whether this event type must be unique per {@link ChangeUpdate}, i.e. there may be at
+   *     most one of this type.
+   */
+  abstract boolean uniquePerUpdate();
+
+  abstract void apply(ChangeUpdate update) throws OrmException, IOException;
+
+  protected boolean isPostSubmitApproval() {
+    return false;
+  }
+
+  protected boolean isSubmit() {
+    return false;
+  }
+
+  protected boolean canHaveTag() {
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    ToStringHelper helper =
+        MoreObjects.toStringHelper(this)
+            .add("psId", psId)
+            .add("effectiveUser", user)
+            .add("realUser", realUser)
+            .add("when", when)
+            .add("tag", tag);
+    addToString(helper);
+    return helper.toString();
+  }
+
+  /** @param helper toString helper to add fields to */
+  protected void addToString(ToStringHelper helper) {}
+
+  @Override
+  public int compareTo(Event other) {
+    return ComparisonChain.start()
+        .compareFalseFirst(this.isFinalUpdates(), other.isFinalUpdates())
+        .compare(this.when, other.when)
+        .compareTrueFirst(isPatchSet(), isPatchSet())
+        .compareTrueFirst(this.predatesChange, other.predatesChange)
+        .compare(this.user, other.user, ReviewDbUtil.intKeyOrdering())
+        .compare(this.realUser, other.realUser, ReviewDbUtil.intKeyOrdering())
+        .compare(this.psId, other.psId, ReviewDbUtil.intKeyOrdering().nullsLast())
+        .result();
+  }
+
+  private boolean isPatchSet() {
+    return this instanceof PatchSetEvent;
+  }
+
+  private boolean isFinalUpdates() {
+    return this instanceof FinalUpdatesEvent;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java
new file mode 100644
index 0000000..773215e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.Objects;
+
+class EventList<E extends Event> implements Iterable<E> {
+  private final ArrayList<E> list = new ArrayList<>();
+  private boolean isSubmit;
+
+  @Override
+  public Iterator<E> iterator() {
+    return list.iterator();
+  }
+
+  void add(E e) {
+    list.add(e);
+    if (e.isSubmit()) {
+      isSubmit = true;
+    }
+  }
+
+  void clear() {
+    list.clear();
+    isSubmit = false;
+  }
+
+  boolean isEmpty() {
+    return list.isEmpty();
+  }
+
+  boolean canAdd(E e) {
+    if (isEmpty()) {
+      return true;
+    }
+    if (e instanceof FinalUpdatesEvent) {
+      return false; // FinalUpdatesEvent always gets its own update.
+    }
+
+    Event last = getLast();
+    if (!Objects.equals(e.user, last.user)
+        || !Objects.equals(e.realUser, last.realUser)
+        || !e.psId.equals(last.psId)) {
+      return false; // Different patch set or author.
+    }
+    if (e.canHaveTag() && canHaveTag() && !Objects.equals(e.tag, getTag())) {
+      // We should trust the tag field, and it doesn't match.
+      return false;
+    }
+    if (e.isPostSubmitApproval() && isSubmit) {
+      // Post-submit approvals must come after the update that submits.
+      return false;
+    }
+
+    long t = e.when.getTime();
+    long tFirst = getFirstTime();
+    long tLast = getLastTime();
+    checkArgument(t >= tLast, "event %s is before previous event in list %s", e, last);
+    if (t - tLast > ChangeRebuilderImpl.MAX_DELTA_MS
+        || t - tFirst > ChangeRebuilderImpl.MAX_WINDOW_MS) {
+      return false; // Too much time elapsed.
+    }
+
+    if (!e.uniquePerUpdate()) {
+      return true;
+    }
+    for (Event o : this) {
+      if (e.getClass() == o.getClass()) {
+        return false; // Only one event of this type allowed per update.
+      }
+    }
+
+    // TODO(dborowitz): Additional heuristics, like keeping events separate if
+    // they affect overlapping fields within a single entity.
+
+    return true;
+  }
+
+  Timestamp getWhen() {
+    return get(0).when;
+  }
+
+  PatchSet.Id getPatchSetId() {
+    PatchSet.Id id = checkNotNull(get(0).psId);
+    for (int i = 1; i < size(); i++) {
+      checkState(
+          get(i).psId.equals(id), "mismatched patch sets in EventList: %s != %s", id, get(i).psId);
+    }
+    return id;
+  }
+
+  Account.Id getAccountId() {
+    Account.Id id = get(0).user;
+    for (int i = 1; i < size(); i++) {
+      checkState(
+          Objects.equals(id, get(i).user),
+          "mismatched users in EventList: %s != %s",
+          id,
+          get(i).user);
+    }
+    return id;
+  }
+
+  Account.Id getRealAccountId() {
+    Account.Id id = get(0).realUser;
+    for (int i = 1; i < size(); i++) {
+      checkState(
+          Objects.equals(id, get(i).realUser),
+          "mismatched real users in EventList: %s != %s",
+          id,
+          get(i).realUser);
+    }
+    return id;
+  }
+
+  String getTag() {
+    for (E e : Lists.reverse(list)) {
+      if (e.tag != null) {
+        return e.tag;
+      }
+    }
+    return null;
+  }
+
+  private boolean canHaveTag() {
+    return list.stream().anyMatch(Event::canHaveTag);
+  }
+
+  private E get(int i) {
+    return list.get(i);
+  }
+
+  private int size() {
+    return list.size();
+  }
+
+  private E getLast() {
+    return list.get(list.size() - 1);
+  }
+
+  private long getLastTime() {
+    return getLast().when.getTime();
+  }
+
+  private long getFirstTime() {
+    return list.get(0).when.getTime();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
new file mode 100644
index 0000000..077a027
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventSorter.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.PriorityQueue;
+
+/**
+ * Helper to sort a list of events.
+ *
+ * <p>Events are sorted in two passes:
+ *
+ * <ol>
+ *   <li>Sort by natural order (timestamp, patch set, author, etc.)
+ *   <li>Postpone any events with dependencies to occur only after all of their dependencies, where
+ *       this violates natural order.
+ * </ol>
+ *
+ * {@link #sort()} modifies the event list in place (similar to {@link Collections#sort(List)}), but
+ * does not modify any event. In particular, events might end up out of order with respect to
+ * timestamp; callers are responsible for adjusting timestamps later if they prefer monotonicity.
+ */
+class EventSorter {
+  private final List<Event> out;
+  private final LinkedHashSet<Event> sorted;
+  private ListMultimap<Event, Event> waiting;
+  private SetMultimap<Event, Event> deps;
+
+  EventSorter(List<Event> events) {
+    LinkedHashSet<Event> all = new LinkedHashSet<>(events);
+    out = events;
+
+    for (Event e : events) {
+      for (Event d : e.deps) {
+        checkArgument(all.contains(d), "dep %s of %s not in input list", d, e);
+      }
+    }
+
+    all.clear();
+    sorted = all; // Presized.
+  }
+
+  void sort() {
+    // First pass: sort by natural order.
+    PriorityQueue<Event> todo = new PriorityQueue<>(out);
+
+    // Populate waiting map after initial sort to preserve natural order.
+    waiting = MultimapBuilder.hashKeys().arrayListValues().build();
+    deps = MultimapBuilder.hashKeys().hashSetValues().build();
+    for (Event e : todo) {
+      for (Event d : e.deps) {
+        deps.put(e, d);
+        waiting.put(d, e);
+      }
+    }
+
+    // Second pass: enforce dependencies.
+    int size = out.size();
+    while (!todo.isEmpty()) {
+      process(todo.remove(), todo);
+    }
+    checkState(
+        sorted.size() == size, "event sort expected %s elements, got %s", size, sorted.size());
+
+    // Modify out in-place a la Collections#sort.
+    out.clear();
+    out.addAll(sorted);
+  }
+
+  void process(Event e, PriorityQueue<Event> todo) {
+    if (sorted.contains(e)) {
+      return; // Already emitted.
+    }
+    if (!deps.get(e).isEmpty()) {
+      // Not all events that e depends on have been emitted yet. Ignore e for
+      // now; it will get added back to the queue in the block below once its
+      // last dependency is processed.
+      return;
+    }
+
+    // All events that e depends on have been emitted, so e can be emitted.
+    sorted.add(e);
+
+    // Remove e from the dependency set of all events waiting on e, and add
+    // those events back to the queue in the original priority order for
+    // reconsideration.
+    for (Event w : waiting.get(e)) {
+      deps.get(w).remove(e);
+      todo.add(w);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
new file mode 100644
index 0000000..b1bd6ec
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.common.collect.ImmutableCollection;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+import java.util.Objects;
+
+class FinalUpdatesEvent extends Event {
+  private final Change change;
+  private final Change noteDbChange;
+  private final ImmutableCollection<PatchSet> patchSets;
+
+  FinalUpdatesEvent(Change change, Change noteDbChange, ImmutableCollection<PatchSet> patchSets) {
+    super(
+        change.currentPatchSetId(),
+        change.getOwner(),
+        change.getOwner(),
+        change.getLastUpdatedOn(),
+        change.getCreatedOn(),
+        null);
+    this.change = change;
+    this.noteDbChange = noteDbChange;
+    this.patchSets = patchSets;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    if (!Objects.equals(change.getTopic(), noteDbChange.getTopic())) {
+      update.setTopic(change.getTopic());
+    }
+    if (!statusMatches()) {
+      // TODO(dborowitz): Stamp approximate approvals at this time.
+      update.fixStatus(change.getStatus());
+    }
+    if (change.getSubmissionId() != null && noteDbChange.getSubmissionId() == null) {
+      update.setSubmissionId(change.getSubmissionId());
+    }
+    if (!Objects.equals(change.getAssignee(), noteDbChange.getAssignee())) {
+      // TODO(dborowitz): Parse intermediate values out from messages.
+      update.setAssignee(change.getAssignee());
+    }
+    if (!patchSets.isEmpty() && !highestNumberedPatchSetIsCurrent()) {
+      update.setCurrentPatchSet();
+    }
+    if (!update.isEmpty()) {
+      update.setSubjectForCommit("Final NoteDb migration updates");
+    }
+  }
+
+  private boolean statusMatches() {
+    return Objects.equals(change.getStatus(), noteDbChange.getStatus());
+  }
+
+  private boolean highestNumberedPatchSetIsCurrent() {
+    PatchSet.Id max = patchSets.stream().map(PatchSet::getId).max(intKeyOrdering()).get();
+    return max.equals(change.currentPatchSetId());
+  }
+
+  @Override
+  protected boolean isSubmit() {
+    return change.getStatus() == Change.Status.MERGED;
+  }
+
+  @Override
+  protected void addToString(ToStringHelper helper) {
+    if (!statusMatches()) {
+      helper.add("status", change.getStatus());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
new file mode 100644
index 0000000..4f6f6ad
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+import java.sql.Timestamp;
+import java.util.Set;
+
+class HashtagsEvent extends Event {
+  private final Set<String> hashtags;
+
+  HashtagsEvent(
+      PatchSet.Id psId,
+      Account.Id who,
+      Timestamp when,
+      Set<String> hashtags,
+      Timestamp changeCreatdOn) {
+    super(
+        psId,
+        who,
+        who,
+        when,
+        changeCreatdOn,
+        // Somewhat confusingly, hashtags do not use the setTag method on
+        // AbstractChangeUpdate, so pass null as the tag.
+        null);
+    this.hashtags = hashtags;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    // Since these are produced from existing commits in the old NoteDb graph,
+    // we know that there must be one per commit in the rebuilt graph.
+    return true;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    update.setHashtags(hashtags);
+  }
+
+  @Override
+  protected void addToString(ToStringHelper helper) {
+    helper.add("hashtags", hashtags);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
new file mode 100644
index 0000000..e0ad640
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.PatchSetState;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+class PatchSetEvent extends Event {
+  private final Change change;
+  private final PatchSet ps;
+  private final RevWalk rw;
+  boolean createChange;
+
+  PatchSetEvent(Change change, PatchSet ps, RevWalk rw) {
+    super(
+        ps.getId(),
+        ps.getUploader(),
+        ps.getUploader(),
+        ps.getCreatedOn(),
+        change.getCreatedOn(),
+        null);
+    this.change = change;
+    this.ps = ps;
+    this.rw = rw;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @Override
+  void apply(ChangeUpdate update) throws IOException, OrmException {
+    checkUpdate(update);
+    if (createChange) {
+      ChangeRebuilderImpl.createChange(update, change);
+    } else {
+      update.setSubject(change.getSubject());
+      update.setSubjectForCommit("Create patch set " + ps.getPatchSetId());
+    }
+    setRevision(update, ps);
+    update.setPsDescription(ps.getDescription());
+    List<String> groups = ps.getGroups();
+    if (!groups.isEmpty()) {
+      update.setGroups(ps.getGroups());
+    }
+    if (ps.isDraft()) {
+      update.setPatchSetState(PatchSetState.DRAFT);
+    }
+  }
+
+  private void setRevision(ChangeUpdate update, PatchSet ps) throws IOException {
+    String rev = ps.getRevision().get();
+    String cert = ps.getPushCertificate();
+    ObjectId id;
+    try {
+      id = ObjectId.fromString(rev);
+    } catch (InvalidObjectIdException e) {
+      update.setRevisionForMissingCommit(rev, cert);
+      return;
+    }
+    try {
+      update.setCommit(rw, id, cert);
+    } catch (MissingObjectException e) {
+      update.setRevisionForMissingCommit(rev, cert);
+      return;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
new file mode 100644
index 0000000..2ecf969
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.common.collect.Table;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.sql.Timestamp;
+
+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(),
+        // TODO(dborowitz): Real account ID shouldn't really matter for
+        // reviewers, but we might have to deal with this to avoid ChangeBundle
+        // diffs when run against real data.
+        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());
+  }
+
+  @Override
+  protected void addToString(ToStringHelper helper) {
+    helper.add("account", reviewer.getColumnKey()).add("state", reviewer.getRowKey());
+  }
+}
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 c4af9fd..74a3132 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
@@ -23,7 +23,10 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
 import org.eclipse.jgit.diff.Sequence;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
@@ -49,15 +52,10 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.HashMap;
-import java.util.Map;
-
 public class AutoMerger {
   private static final Logger log = LoggerFactory.getLogger(AutoMerger.class);
 
-  static boolean cacheAutomerge(Config cfg) {
+  public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
 
@@ -65,9 +63,7 @@
   private final boolean save;
 
   @Inject
-  AutoMerger(
-      @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent gerritIdent) {
+  AutoMerger(@GerritServerConfig Config cfg, @GerritPersonIdent PersonIdent gerritIdent) {
     save = cacheAutomerge(cfg);
     this.gerritIdent = gerritIdent;
   }
@@ -75,11 +71,15 @@
   /**
    * Perform an auto-merge of the parents of the given merge commit.
    *
-   * @return auto-merge commit or {@code null} if an auto-merge commit
-   *     couldn't be created. Headers of the returned RevCommit are parsed.
+   * @return auto-merge commit or {@code null} if an auto-merge commit couldn't be created. Headers
+   *     of the returned RevCommit are parsed.
    */
-  public RevCommit merge(Repository repo, RevWalk rw, final ObjectInserter ins,
-      RevCommit merge, ThreeWayMergeStrategy mergeStrategy)
+  public RevCommit merge(
+      Repository repo,
+      RevWalk rw,
+      final ObjectInserter ins,
+      RevCommit merge,
+      ThreeWayMergeStrategy mergeStrategy)
       throws IOException {
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
     InMemoryInserter tmpIns = null;
@@ -96,11 +96,7 @@
     }
 
     rw.parseHeaders(merge);
-    String hash = merge.name();
-    String refName = RefNames.REFS_CACHE_AUTOMERGE
-        + hash.substring(0, 2)
-        + "/"
-        + hash.substring(2);
+    String refName = RefNames.refsCacheAutomerge(merge.name());
     Ref ref = repo.getRefDatabase().exactRef(refName);
     if (ref != null && ref.getObjectId() != null) {
       RevObject obj = rw.parseAny(ref.getObjectId());
@@ -139,20 +135,22 @@
       String oursMsg = ours.getShortMessage();
       String theirsMsg = theirs.getShortMessage();
 
-      String oursName = String.format("HEAD   (%s %s)",
-          ours.abbreviate(6).name(),
-          oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
-      String theirsName = String.format("BRANCH (%s %s)",
-          theirs.abbreviate(6).name(),
-          theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
+      String oursName =
+          String.format(
+              "HEAD   (%s %s)",
+              ours.abbreviate(6).name(), oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
+      String theirsName =
+          String.format(
+              "BRANCH (%s %s)",
+              theirs.abbreviate(6).name(),
+              theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
 
       MergeFormatter fmt = new MergeFormatter();
       Map<String, MergeResult<? extends Sequence>> r = m.getMergeResults();
       Map<String, ObjectId> resolved = new HashMap<>();
       for (Map.Entry<String, MergeResult<? extends Sequence>> entry : r.entrySet()) {
         MergeResult<? extends Sequence> p = entry.getValue();
-        try (TemporaryBuffer buf =
-            new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024)) {
+        try (TemporaryBuffer buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024)) {
           fmt.formatMerge(buf, p, "BASE", oursName, theirsName, UTF_8.name());
           buf.close();
 
@@ -164,7 +162,7 @@
 
       DirCacheBuilder builder = dc.builder();
       int cnt = dc.getEntryCount();
-      for (int i = 0; i < cnt;) {
+      for (int i = 0; i < cnt; ) {
         DirCacheEntry entry = dc.getEntry(i);
         if (entry.getStage() == 0) {
           builder.add(entry);
@@ -218,14 +216,14 @@
       ObjectInserter ins,
       String refName,
       ObjectId tree,
-      RevCommit merge) throws IOException {
+      RevCommit merge)
+      throws IOException {
     rw.parseHeaders(merge);
     // For maximum stability, choose a single ident using the committer time of
     // the input commit, using the server name and timezone.
-    PersonIdent ident = new PersonIdent(
-        gerritIdent,
-        merge.getCommitterIdent().getWhen(),
-        gerritIdent.getTimeZone());
+    PersonIdent ident =
+        new PersonIdent(
+            gerritIdent, merge.getCommitterIdent().getWhen(), gerritIdent.getTimeZone());
     CommitBuilder cb = new CommitBuilder();
     cb.setAuthor(ident);
     cb.setCommitter(ident);
@@ -268,11 +266,9 @@
     }
 
     @Override
-    public void flush() {
-    }
+    public void flush() {}
 
     @Override
-    public void close() {
-    }
+    public void close() {}
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/ComparisonType.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/ComparisonType.java
new file mode 100644
index 0000000..abbb680
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -0,0 +1,77 @@
+// 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.patch;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
+import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public class ComparisonType {
+
+  /** 1-based parent */
+  private final Integer parentNum;
+
+  private final boolean autoMerge;
+
+  public static ComparisonType againstOtherPatchSet() {
+    return new ComparisonType(null, false);
+  }
+
+  public static ComparisonType againstParent(int parentNum) {
+    return new ComparisonType(parentNum, false);
+  }
+
+  public static ComparisonType againstAutoMerge() {
+    return new ComparisonType(null, true);
+  }
+
+  private ComparisonType(Integer parentNum, boolean autoMerge) {
+    this.parentNum = parentNum;
+    this.autoMerge = autoMerge;
+  }
+
+  public boolean isAgainstParentOrAutoMerge() {
+    return isAgainstParent() || isAgainstAutoMerge();
+  }
+
+  public boolean isAgainstParent() {
+    return parentNum != null;
+  }
+
+  public boolean isAgainstAutoMerge() {
+    return autoMerge;
+  }
+
+  public int getParentNum() {
+    checkNotNull(parentNum);
+    return parentNum;
+  }
+
+  void writeTo(OutputStream out) throws IOException {
+    writeVarInt32(out, parentNum != null ? parentNum : 0);
+    writeVarInt32(out, autoMerge ? 1 : 0);
+  }
+
+  static ComparisonType readFrom(InputStream in) throws IOException {
+    int p = readVarInt32(in);
+    Integer parentNum = p > 0 ? p : null;
+    boolean autoMerge = readVarInt32(in) != 0;
+    return new ComparisonType(parentNum, autoMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutor.java
index 564ca58..072c2da 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutor.java
@@ -17,15 +17,12 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 import java.util.concurrent.ExecutorService;
 
 /**
- * Marker on {@link ExecutorService} used by
- * {@link IntraLineLoader} and {@link PatchListLoader}.
+ * Marker on {@link ExecutorService} used by {@link IntraLineLoader} and {@link PatchListLoader}.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface DiffExecutor {
-}
+public @interface DiffExecutor {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index 9eaea3a..5359479 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -18,7 +18,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
-
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
@@ -26,14 +25,13 @@
 public class DiffExecutorModule extends AbstractModule {
 
   @Override
-  protected void configure() {
-  }
+  protected void configure() {}
 
   @Provides
   @Singleton
   @DiffExecutor
   public ExecutorService createDiffExecutor() {
-    return Executors.newCachedThreadPool(new ThreadFactoryBuilder()
-        .setNameFormat("Diff-%d").setDaemon(true).build());
+    return Executors.newCachedThreadPool(
+        new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummary.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummary.java
new file mode 100644
index 0000000..877dba0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummary.java
@@ -0,0 +1,75 @@
+// 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.patch;
+
+import static com.google.gerrit.server.ioutil.BasicSerialization.readString;
+import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
+import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
+import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+
+import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.zip.DeflaterOutputStream;
+import java.util.zip.InflaterInputStream;
+
+public class DiffSummary implements Serializable {
+  private static final long serialVersionUID = DiffSummaryKey.serialVersionUID;
+
+  private transient String[] paths;
+  private transient int insertions;
+  private transient int deletions;
+
+  public DiffSummary(String[] paths, int insertions, int deletions) {
+    this.paths = paths;
+    this.insertions = insertions;
+    this.deletions = deletions;
+  }
+
+  public List<String> getPaths() {
+    return Collections.unmodifiableList(Arrays.asList(paths));
+  }
+
+  public ChangedLines getChangedLines() {
+    return new ChangedLines(insertions, deletions);
+  }
+
+  private void writeObject(ObjectOutputStream output) throws IOException {
+    writeVarInt32(output, insertions);
+    writeVarInt32(output, deletions);
+    writeVarInt32(output, paths.length);
+    try (DeflaterOutputStream out = new DeflaterOutputStream(output)) {
+      for (String p : paths) {
+        writeString(out, p);
+      }
+    }
+  }
+
+  private void readObject(ObjectInputStream input) throws IOException {
+    insertions = readVarInt32(input);
+    deletions = readVarInt32(input);
+    paths = new String[readVarInt32(input)];
+    try (InflaterInputStream in = new InflaterInputStream(input)) {
+      for (int i = 0; i < paths.length; i++) {
+        paths[i] = readString(in);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java
new file mode 100644
index 0000000..bfa7ec3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java
@@ -0,0 +1,114 @@
+// 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.patch;
+
+import static org.eclipse.jgit.lib.ObjectIdSerialization.readCanBeNull;
+import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
+import static org.eclipse.jgit.lib.ObjectIdSerialization.writeCanBeNull;
+import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Objects;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class DiffSummaryKey implements Serializable {
+  public static final long serialVersionUID = 1L;
+
+  /** see PatchListKey#oldId */
+  private transient ObjectId oldId;
+
+  /** see PatchListKey#parentNum */
+  private transient Integer parentNum;
+
+  private transient ObjectId newId;
+  private transient Whitespace whitespace;
+
+  public static DiffSummaryKey fromPatchListKey(PatchListKey plk) {
+    return new DiffSummaryKey(
+        plk.getOldId(), plk.getParentNum(), plk.getNewId(), plk.getWhitespace());
+  }
+
+  private DiffSummaryKey(ObjectId oldId, Integer parentNum, ObjectId newId, Whitespace whitespace) {
+    this.oldId = oldId;
+    this.parentNum = parentNum;
+    this.newId = newId;
+    this.whitespace = whitespace;
+  }
+
+  PatchListKey toPatchListKey() {
+    return new PatchListKey(oldId, parentNum, newId, whitespace);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(oldId, parentNum, newId, whitespace);
+  }
+
+  @Override
+  public boolean equals(final Object o) {
+    if (o instanceof DiffSummaryKey) {
+      DiffSummaryKey k = (DiffSummaryKey) o;
+      return Objects.equals(oldId, k.oldId)
+          && Objects.equals(parentNum, k.parentNum)
+          && Objects.equals(newId, k.newId)
+          && whitespace == k.whitespace;
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder n = new StringBuilder();
+    n.append("DiffSummaryKey[");
+    n.append(oldId != null ? oldId.name() : "BASE");
+    n.append("..");
+    n.append(newId.name());
+    n.append(" ");
+    if (parentNum != null) {
+      n.append(parentNum);
+      n.append(" ");
+    }
+    n.append(whitespace.name());
+    n.append("]");
+    return n.toString();
+  }
+
+  private void writeObject(final ObjectOutputStream out) throws IOException {
+    writeCanBeNull(out, oldId);
+    out.writeInt(parentNum == null ? 0 : parentNum);
+    writeNotNull(out, newId);
+    Character c = PatchListKey.WHITESPACE_TYPES.get(whitespace);
+    if (c == null) {
+      throw new IOException("Invalid whitespace type: " + whitespace);
+    }
+    out.writeChar(c);
+  }
+
+  private void readObject(final ObjectInputStream in) throws IOException {
+    oldId = readCanBeNull(in);
+    int n = in.readInt();
+    parentNum = n == 0 ? null : Integer.valueOf(n);
+    newId = readNotNull(in);
+    char t = in.readChar();
+    whitespace = PatchListKey.WHITESPACE_TYPES.inverse().get(t);
+    if (whitespace == null) {
+      throw new IOException("Invalid whitespace type code: " + t);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
new file mode 100644
index 0000000..f4e3d6b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -0,0 +1,73 @@
+// 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.patch;
+
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+public class DiffSummaryLoader implements Callable<DiffSummary> {
+  public interface Factory {
+    DiffSummaryLoader create(DiffSummaryKey key, Project.NameKey project);
+  }
+
+  private final PatchListCache patchListCache;
+  private final DiffSummaryKey key;
+  private final Project.NameKey project;
+
+  @AssistedInject
+  DiffSummaryLoader(PatchListCache plc, @Assisted DiffSummaryKey k, @Assisted Project.NameKey p) {
+    patchListCache = plc;
+    key = k;
+    project = p;
+  }
+
+  @Override
+  public DiffSummary call() throws Exception {
+    PatchList patchList = patchListCache.get(key.toPatchListKey(), project);
+    return toDiffSummary(patchList);
+  }
+
+  private DiffSummary toDiffSummary(PatchList patchList) {
+    List<String> r = new ArrayList<>(patchList.getPatches().size());
+    for (PatchListEntry e : patchList.getPatches()) {
+      if (Patch.isMagic(e.getNewName())) {
+        continue;
+      }
+      switch (e.getChangeType()) {
+        case ADDED:
+        case MODIFIED:
+        case DELETED:
+        case COPIED:
+        case REWRITE:
+          r.add(e.getNewName());
+          break;
+
+        case RENAMED:
+          r.add(e.getOldName());
+          r.add(e.getNewName());
+          break;
+      }
+    }
+    Collections.sort(r);
+    return new DiffSummary(
+        r.toArray(new String[r.size()]), patchList.getInsertions(), patchList.getDeletions());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
new file mode 100644
index 0000000..98e17a5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.common.cache.Weigher;
+
+/** Computes memory usage for {@link DiffSummary} in bytes of memory used. */
+public class DiffSummaryWeigher implements Weigher<DiffSummaryKey, DiffSummary> {
+
+  @Override
+  public int weigh(DiffSummaryKey key, DiffSummary value) {
+    int size =
+        16
+            + 4 * 8
+            + 2 * 36 // Size of DiffSummaryKey, 64 bit JVM
+            + 16
+            + 8
+            + 2 * 4 // Size of DiffSummary
+            + 16
+            + 8; // String[]
+    for (String p : value.getPaths()) {
+      size +=
+          16
+              + 8
+              + 4 * 4 // String
+              + 16
+              + 8
+              + p.length() * 2; // char[]
+    }
+    return size;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
index 60b97c7..e51b4ab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
@@ -20,10 +20,6 @@
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
 
 import com.google.gerrit.reviewdb.client.CodedEnum;
-
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.ReplaceEdit;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.ObjectInputStream;
@@ -33,12 +29,17 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.ReplaceEdit;
 
 public class IntraLineDiff implements Serializable {
   static final long serialVersionUID = IntraLineDiffKey.serialVersionUID;
 
   public enum Status implements CodedEnum {
-    EDIT_LIST('e'), DISABLED('D'), TIMEOUT('T'), ERROR('E');
+    EDIT_LIST('e'),
+    DISABLED('D'),
+    TIMEOUT('T'),
+    ERROR('E');
 
     private final char code;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
index 4a61e2d..46ee56a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
@@ -16,24 +16,31 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.reviewdb.client.Project;
-
+import java.util.List;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.ObjectId;
 
-import java.util.List;
-
 @AutoValue
 public abstract class IntraLineDiffArgs {
-  public static IntraLineDiffArgs create(Text aText, Text bText, List<Edit> edits,
-      Project.NameKey project, ObjectId commit, String path) {
-    return new AutoValue_IntraLineDiffArgs(aText, bText, edits,
-        project, commit, path);
+  public static IntraLineDiffArgs create(
+      Text aText,
+      Text bText,
+      List<Edit> edits,
+      Project.NameKey project,
+      ObjectId commit,
+      String path) {
+    return new AutoValue_IntraLineDiffArgs(aText, bText, edits, project, commit, path);
   }
 
   public abstract Text aText();
+
   public abstract Text bText();
+
   public abstract List<Edit> edits();
+
   public abstract Project.NameKey project();
+
   public abstract ObjectId commit();
+
   public abstract String path();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
index cdde12a..ed58408 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
@@ -16,17 +16,14 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.Serializable;
+import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
 public abstract class IntraLineDiffKey implements Serializable {
   public static final long serialVersionUID = 5L;
 
-  public static IntraLineDiffKey create(ObjectId aId, ObjectId bId,
-      Whitespace whitespace) {
+  public static IntraLineDiffKey create(ObjectId aId, ObjectId bId, Whitespace whitespace) {
     return new AutoValue_IntraLineDiffKey(aId, bId, whitespace);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
index dd15cfc..a571c46 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -20,14 +20,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.MyersDiff;
-import org.eclipse.jgit.diff.ReplaceEdit;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -36,6 +28,12 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.MyersDiff;
+import org.eclipse.jgit.diff.ReplaceEdit;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class IntraLineLoader implements Callable<IntraLineDiff> {
   static final Logger log = LoggerFactory.getLogger(IntraLineLoader.class);
@@ -44,11 +42,10 @@
     IntraLineLoader create(IntraLineDiffKey key, IntraLineDiffArgs args);
   }
 
-  private static final Pattern BLANK_LINE_RE = Pattern
-      .compile("^[ \\t]*(|[{}]|/\\*\\*?|\\*)[ \\t]*$");
+  private static final Pattern BLANK_LINE_RE =
+      Pattern.compile("^[ \\t]*(|[{}]|/\\*\\*?|\\*)[ \\t]*$");
 
-  private static final Pattern CONTROL_BLOCK_START_RE = Pattern
-      .compile("[{:][ \\t]*$");
+  private static final Pattern CONTROL_BLOCK_START_RE = Pattern.compile("[{:][ \\t]*$");
 
   private final ExecutorService diffExecutor;
   private final long timeoutMillis;
@@ -56,14 +53,19 @@
   private final IntraLineDiffArgs args;
 
   @AssistedInject
-  IntraLineLoader(@DiffExecutor ExecutorService diffExecutor,
+  IntraLineLoader(
+      @DiffExecutor ExecutorService diffExecutor,
       @GerritServerConfig Config cfg,
       @Assisted IntraLineDiffKey key,
       @Assisted IntraLineDiffArgs args) {
     this.diffExecutor = diffExecutor;
     timeoutMillis =
-        ConfigUtil.getTimeUnit(cfg, "cache", PatchListCacheImpl.INTRA_NAME,
-            "timeout", TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
+        ConfigUtil.getTimeUnit(
+            cfg,
+            "cache",
+            PatchListCacheImpl.INTRA_NAME,
+            "timeout",
+            TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
     this.key = key;
     this.args = args;
@@ -71,35 +73,41 @@
 
   @Override
   public IntraLineDiff call() throws Exception {
-    Future<IntraLineDiff> result = diffExecutor.submit(
-        new Callable<IntraLineDiff>() {
-          @Override
-          public IntraLineDiff call() throws Exception {
-            return IntraLineLoader.compute(args.aText(), args.bText(),
-                args.edits());
-          }
-        });
+    Future<IntraLineDiff> result =
+        diffExecutor.submit(
+            new Callable<IntraLineDiff>() {
+              @Override
+              public IntraLineDiff call() throws Exception {
+                return IntraLineLoader.compute(args.aText(), args.bText(), args.edits());
+              }
+            });
     try {
       return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
     } catch (InterruptedException | TimeoutException e) {
-      log.warn(timeoutMillis + " ms timeout reached for IntraLineDiff"
-          + " in project " + args.project()
-          + " on commit " + args.commit().name()
-          + " for path " + args.path()
-          + " comparing " + key.getBlobA().name()
-          + ".." + key.getBlobB().name());
+      log.warn(
+          timeoutMillis
+              + " ms timeout reached for IntraLineDiff"
+              + " in project "
+              + args.project()
+              + " on commit "
+              + args.commit().name()
+              + " for path "
+              + args.path()
+              + " comparing "
+              + key.getBlobA().name()
+              + ".."
+              + key.getBlobB().name());
       result.cancel(true);
       return new IntraLineDiff(IntraLineDiff.Status.TIMEOUT);
     } catch (ExecutionException e) {
       // If there was an error computing the result, carry it
       // up to the caller so the cache knows this key is invalid.
-      Throwables.propagateIfInstanceOf(e.getCause(), Exception.class);
+      Throwables.throwIfInstanceOf(e.getCause(), Exception.class);
       throw new Exception(e.getMessage(), e.getCause());
     }
   }
 
-  static IntraLineDiff compute(Text aText, Text bText, List<Edit> edits)
-      throws Exception {
+  static IntraLineDiff compute(Text aText, Text bText, List<Edit> edits) throws Exception {
     combineLineEdits(edits, aText, bText);
 
     for (int i = 0; i < edits.size(); i++) {
@@ -116,12 +124,11 @@
         // just a few characters apart we tend to get better results
         // by joining them together and taking the whole span.
         //
-        for (int j = 0; j < wordEdits.size() - 1;) {
+        for (int j = 0; j < wordEdits.size() - 1; ) {
           Edit c = wordEdits.get(j);
           Edit n = wordEdits.get(j + 1);
 
-          if (n.getBeginA() - c.getEndA() <= 5
-              || n.getBeginB() - c.getEndB() <= 5) {
+          if (n.getBeginA() - c.getEndA() <= 5 || n.getBeginB() - c.getEndB() <= 5) {
             int ab = c.getBeginA();
             int ae = n.getEndA();
 
@@ -188,7 +195,9 @@
           // text might be identical. Slide down that edit and use the tail
           // rather than the leading bit.
           //
-          while (0 < ab && ab < ae && a.charAt(ab - 1) != '\n'
+          while (0 < ab
+              && ab < ae
+              && a.charAt(ab - 1) != '\n'
               && cmp.equals(a, ab - 1, a, ae - 1)) {
             ab--;
             ae--;
@@ -203,7 +212,9 @@
             }
           }
 
-          while (0 < bb && bb < be && b.charAt(bb - 1) != '\n'
+          while (0 < bb
+              && bb < be
+              && b.charAt(bb - 1) != '\n'
               && cmp.equals(b, bb - 1, b, be - 1)) {
             bb--;
             be--;
@@ -223,13 +234,15 @@
           //
           if (ab < ae //
               && (ab == 0 || a.charAt(ab - 1) == '\n') //
-              && ae < a.size() && a.charAt(ae - 1) != '\n'
+              && ae < a.size()
+              && a.charAt(ae - 1) != '\n'
               && a.charAt(ae) == '\n') {
             ae++;
           }
           if (bb < be //
               && (bb == 0 || b.charAt(bb - 1) == '\n') //
-              && be < b.size() && b.charAt(be - 1) != '\n'
+              && be < b.size()
+              && b.charAt(be - 1) != '\n'
               && b.charAt(be) == '\n') {
             be++;
           }
@@ -245,7 +258,7 @@
   }
 
   private static void combineLineEdits(List<Edit> edits, Text a, Text b) {
-    for (int j = 0; j < edits.size() - 1;) {
+    for (int j = 0; j < edits.size() - 1; ) {
       Edit c = edits.get(j);
       Edit n = edits.get(j + 1);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java
index 7088fe8..7bd37af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineWeigher.java
@@ -17,12 +17,18 @@
 import com.google.common.cache.Weigher;
 
 /** Approximates memory usage for IntralineDiff in bytes of memory used. */
-public class IntraLineWeigher implements
-    Weigher<IntraLineDiffKey, IntraLineDiff> {
+public class IntraLineWeigher implements Weigher<IntraLineDiffKey, IntraLineDiff> {
   @Override
   public int weigh(IntraLineDiffKey key, IntraLineDiff value) {
-    return 16 + 8 * 8 + 2 * 36     // Size of IntraLineDiffKey, 64 bit JVM
-        + 16 + 2 * 8 + 16 + 8 + 4 + 20 // Size of IntraLineDiff, 64 bit JVM
+    return 16
+        + 8 * 8
+        + 2 * 36 // Size of IntraLineDiffKey, 64 bit JVM
+        + 16
+        + 2 * 8
+        + 16
+        + 8
+        + 4
+        + 20 // Size of IntraLineDiff, 64 bit JVM
         + (8 + 16 + 4 * 4) * value.getEdits().size();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/MergeListBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/MergeListBuilder.java
new file mode 100644
index 0000000..433fcad
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/MergeListBuilder.java
@@ -0,0 +1,50 @@
+// 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.patch;
+
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class MergeListBuilder {
+  public static List<RevCommit> build(RevWalk rw, RevCommit merge, int uninterestingParent)
+      throws IOException {
+    rw.reset();
+    rw.parseBody(merge);
+    if (merge.getParentCount() < 2) {
+      return ImmutableList.of();
+    }
+
+    for (int parent = 0; parent < merge.getParentCount(); parent++) {
+      RevCommit parentCommit = merge.getParent(parent);
+      rw.parseBody(parentCommit);
+      if (parent == uninterestingParent - 1) {
+        rw.markUninteresting(parentCommit);
+      } else {
+        rw.markStart(parentCommit);
+      }
+    }
+
+    List<RevCommit> result = new ArrayList<>();
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      result.add(c);
+    }
+    return result;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
index e570b3a..b4c2fbe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.patch;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.gerrit.common.errors.NoSuchEntityException;
 import com.google.gerrit.reviewdb.client.Patch;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -30,8 +32,6 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
 
-import java.io.IOException;
-
 /** State supporting processing of a single {@link Patch} instance. */
 public class PatchFile {
   private final Repository repo;
@@ -42,9 +42,8 @@
   private Text a;
   private Text b;
 
-  public PatchFile(final Repository repo, final PatchList patchList,
-      final String fileName) throws MissingObjectException,
-      IncorrectObjectTypeException, IOException {
+  public PatchFile(Repository repo, PatchList patchList, String fileName)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
     this.repo = repo;
     this.entry = patchList.get(fileName);
 
@@ -53,20 +52,28 @@
       final RevCommit bCommit = rw.parseCommit(patchList.getNewId());
 
       if (Patch.COMMIT_MSG.equals(fileName)) {
-        if (patchList.isAgainstParent()) {
+        if (patchList.getComparisonType().isAgainstParentOrAutoMerge()) {
           a = Text.EMPTY;
         } else {
           // For the initial commit, we have an empty tree on Side A
           RevObject object = rw.parseAny(patchList.getOldId());
-          a = object instanceof RevCommit
-              ? Text.forCommit(reader, object)
-              : Text.EMPTY;
+          a = object instanceof RevCommit ? Text.forCommit(reader, object) : Text.EMPTY;
         }
         b = Text.forCommit(reader, bCommit);
 
         aTree = null;
         bTree = null;
+      } else if (Patch.MERGE_LIST.equals(fileName)) {
+        // For the initial commit, we have an empty tree on Side A
+        RevObject object = rw.parseAny(patchList.getOldId());
+        a =
+            object instanceof RevCommit
+                ? Text.forMergeList(patchList.getComparisonType(), reader, object)
+                : Text.EMPTY;
+        b = Text.forMergeList(patchList.getComparisonType(), reader, bCommit);
 
+        aTree = null;
+        bTree = null;
       } else {
         if (patchList.getOldId() != null) {
           aTree = rw.parseTree(patchList.getOldId());
@@ -89,8 +96,7 @@
    * @throws IOException the patch or complete file content cannot be read.
    * @throws NoSuchEntityException
    */
-  public String getLine(final int file, final int line)
-      throws IOException, NoSuchEntityException {
+  public String getLine(final int file, final int line) throws IOException, NoSuchEntityException {
     switch (file) {
       case 0:
         if (a == null) {
@@ -117,8 +123,7 @@
    * @throws IOException the patch or complete file content cannot be read.
    * @throws NoSuchEntityException the file is not exist.
    */
-  public int getLineCount(final int file)
-      throws IOException, NoSuchEntityException {
+  public int getLineCount(final int file) throws IOException, NoSuchEntityException {
     switch (file) {
       case 0:
         if (a == null) {
@@ -138,8 +143,8 @@
   }
 
   private Text load(final ObjectId tree, final String path)
-      throws MissingObjectException, IncorrectObjectTypeException,
-      CorruptObjectException, IOException {
+      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+          IOException {
     if (path == null) {
       return Text.EMPTY;
     }
@@ -151,7 +156,7 @@
       return new Text(repo.open(tw.getObjectId(0), Constants.OBJ_BLOB));
     } else if (tw.getFileMode(0).getObjectType() == Constants.OBJ_COMMIT) {
       String str = "Subproject commit " + ObjectId.toString(tw.getObjectId(0));
-      return new Text(str.getBytes());
+      return new Text(str.getBytes(UTF_8));
     } else {
       return Text.EMPTY;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
index 2a4afb3..020c354 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.patch;
 
-
 import static com.google.gerrit.server.ioutil.BasicSerialization.readBytes;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeBytes;
@@ -24,13 +23,10 @@
 import static org.eclipse.jgit.lib.ObjectIdSerialization.writeCanBeNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
-
-import org.eclipse.jgit.lib.AnyObjectId;
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -44,36 +40,64 @@
 import java.util.List;
 import java.util.zip.DeflaterOutputStream;
 import java.util.zip.InflaterInputStream;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class PatchList implements Serializable {
   private static final long serialVersionUID = PatchListKey.serialVersionUID;
+
   private static final Comparator<PatchListEntry> PATCH_CMP =
       new Comparator<PatchListEntry>() {
         @Override
         public int compare(final PatchListEntry a, final PatchListEntry b) {
-          return a.getNewName().compareTo(b.getNewName());
+          return comparePaths(a.getNewName(), b.getNewName());
         }
       };
 
-  @Nullable
-  private transient ObjectId oldId;
+  @VisibleForTesting
+  static int comparePaths(String a, String b) {
+    int m1 = Patch.isMagic(a) ? (a.equals(Patch.MERGE_LIST) ? 2 : 1) : 3;
+    int m2 = Patch.isMagic(b) ? (b.equals(Patch.MERGE_LIST) ? 2 : 1) : 3;
+
+    if (m1 != m2) {
+      return m1 - m2;
+    } else if (m1 < 3) {
+      return 0;
+    }
+
+    // m1 == m2 == 3: normal names.
+    return a.compareTo(b);
+  }
+
+  @Nullable private transient ObjectId oldId;
   private transient ObjectId newId;
-  private transient boolean againstParent;
+  private transient boolean isMerge;
+  private transient ComparisonType comparisonType;
   private transient int insertions;
   private transient int deletions;
   private transient PatchListEntry[] patches;
 
-  public PatchList(@Nullable final AnyObjectId oldId, final AnyObjectId newId,
-      final boolean againstParent, final PatchListEntry[] patches) {
+  public PatchList(
+      @Nullable AnyObjectId oldId,
+      AnyObjectId newId,
+      boolean isMerge,
+      ComparisonType comparisonType,
+      PatchListEntry[] patches) {
     this.oldId = oldId != null ? oldId.copy() : null;
     this.newId = newId.copy();
-    this.againstParent = againstParent;
+    this.isMerge = isMerge;
+    this.comparisonType = comparisonType;
 
-    // We assume index 0 contains the magic commit message entry.
-    if (patches.length > 1) {
-      Arrays.sort(patches, 1, patches.length, PATCH_CMP);
+    Arrays.sort(patches, 0, patches.length, PATCH_CMP);
+
+    // Skip magic files
+    int i = 0;
+    for (; i < patches.length; i++) {
+      if (!Patch.isMagic(patches[i].getNewName())) {
+        break;
+      }
     }
-    for (int i = 1; i < patches.length; i++) {
+    for (; i < patches.length; i++) {
       insertions += patches[i].getInsertions();
       deletions += patches[i].getDeletions();
     }
@@ -97,9 +121,9 @@
     return Collections.unmodifiableList(Arrays.asList(patches));
   }
 
-  /** @return true if {@link #getOldId} is {@link #getNewId}'s ancestor. */
-  public boolean isAgainstParent() {
-    return againstParent;
+  /** @return the comparison type */
+  public ComparisonType getComparisonType() {
+    return comparisonType;
   }
 
   /** @return total number of new lines added. */
@@ -114,17 +138,18 @@
 
   /**
    * Get a sorted, modifiable list of all files in this list.
-   * <p>
-   * The returned list items do not populate:
+   *
+   * <p>The returned list items do not populate:
+   *
    * <ul>
-   * <li>{@link Patch#getCommentCount()}
-   * <li>{@link Patch#getDraftCount()}
-   * <li>{@link Patch#isReviewedByCurrentUser()}
+   *   <li>{@link Patch#getCommentCount()}
+   *   <li>{@link Patch#getDraftCount()}
+   *   <li>{@link Patch#isReviewedByCurrentUser()}
    * </ul>
    *
-   * @param setId the patch set identity these patches belong to. This really
-   *        should not need to be specified, but is a current legacy artifact of
-   *        how the cache is keyed versus how the database is keyed.
+   * @param setId the patch set identity these patches belong to. This really should not need to be
+   *     specified, but is a current legacy artifact of how the cache is keyed versus how the
+   *     database is keyed.
    */
   public List<Patch> toPatchList(final PatchSet.Id setId) {
     final ArrayList<Patch> r = new ArrayList<>(patches.length);
@@ -141,24 +166,8 @@
   }
 
   private int search(final String fileName) {
-    if (Patch.COMMIT_MSG.equals(fileName)) {
-      return 0;
-    }
-
-    int high = patches.length;
-    int low = 1;
-    while (low < high) {
-      final int mid = (low + high) >>> 1;
-      final int cmp = patches[mid].getNewName().compareTo(fileName);
-      if (cmp < 0) {
-        low = mid + 1;
-      } else if (cmp == 0) {
-        return mid;
-      } else {
-        high = mid;
-      }
-    }
-    return -(low + 1);
+    PatchListEntry want = PatchListEntry.empty(fileName);
+    return Arrays.binarySearch(patches, 0, patches.length, want, PATCH_CMP);
   }
 
   private void writeObject(final ObjectOutputStream output) throws IOException {
@@ -166,7 +175,8 @@
     try (DeflaterOutputStream out = new DeflaterOutputStream(buf)) {
       writeCanBeNull(out, oldId);
       writeNotNull(out, newId);
-      writeVarInt32(out, againstParent ? 1 : 0);
+      writeVarInt32(out, isMerge ? 1 : 0);
+      comparisonType.writeTo(out);
       writeVarInt32(out, insertions);
       writeVarInt32(out, deletions);
       writeVarInt32(out, patches.length);
@@ -182,7 +192,8 @@
     try (InflaterInputStream in = new InflaterInputStream(buf)) {
       oldId = readCanBeNull(in);
       newId = readNotNull(in);
-      againstParent = readVarInt32(in) != 0;
+      isMerge = readVarInt32(in) != 0;
+      comparisonType = ComparisonType.readFrom(in);
       insertions = readVarInt32(in);
       deletions = readVarInt32(in);
       final int cnt = readVarInt32(in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
index 8a2403f..c32a3f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -17,20 +17,22 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Provides a cached list of {@link PatchListEntry}. */
 public interface PatchListCache {
-  PatchList get(PatchListKey key, Project.NameKey project)
-      throws PatchListNotAvailableException;
+  PatchList get(PatchListKey key, Project.NameKey project) throws PatchListNotAvailableException;
 
-  PatchList get(Change change, PatchSet patchSet)
-      throws PatchListNotAvailableException;
+  PatchList get(Change change, PatchSet patchSet) throws PatchListNotAvailableException;
 
   ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
       throws PatchListNotAvailableException;
 
-  IntraLineDiff getIntraLineDiff(IntraLineDiffKey key,
-      IntraLineDiffArgs args);
+  IntraLineDiff getIntraLineDiff(IntraLineDiffKey key, IntraLineDiffArgs args);
+
+  DiffSummary getDiffSummary(Change change, PatchSet patchSet)
+      throws PatchListNotAvailableException;
+
+  DiffSummary getDiffSummary(DiffSummaryKey key, Project.NameKey project)
+      throws PatchListNotAvailableException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index abafad7..dc76c63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -27,18 +27,17 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-
+import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
-import java.util.concurrent.ExecutionException;
-
 /** Provides a cached list of {@link PatchListEntry}. */
 @Singleton
 public class PatchListCacheImpl implements PatchListCache {
   static final String FILE_NAME = "diff";
   static final String INTRA_NAME = "diff_intraline";
+  static final String DIFF_SUMMARY = "diff_summary";
 
   public static Module module() {
     return new CacheModule() {
@@ -54,6 +53,12 @@
             .maximumWeight(10 << 20)
             .weigher(IntraLineWeigher.class);
 
+        factory(DiffSummaryLoader.Factory.class);
+        persist(DIFF_SUMMARY, DiffSummaryKey.class, DiffSummary.class)
+            .maximumWeight(10 << 20)
+            .weigher(DiffSummaryWeigher.class)
+            .diskLimit(1 << 30);
+
         bind(PatchListCacheImpl.class);
         bind(PatchListCache.class).to(PatchListCacheImpl.class);
       }
@@ -62,32 +67,39 @@
 
   private final Cache<PatchListKey, PatchList> fileCache;
   private final Cache<IntraLineDiffKey, IntraLineDiff> intraCache;
+  private final Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
   private final PatchListLoader.Factory fileLoaderFactory;
   private final IntraLineLoader.Factory intraLoaderFactory;
+  private final DiffSummaryLoader.Factory diffSummaryLoaderFactory;
   private final boolean computeIntraline;
 
   @Inject
   PatchListCacheImpl(
       @Named(FILE_NAME) Cache<PatchListKey, PatchList> fileCache,
       @Named(INTRA_NAME) Cache<IntraLineDiffKey, IntraLineDiff> intraCache,
+      @Named(DIFF_SUMMARY) Cache<DiffSummaryKey, DiffSummary> diffSummaryCache,
       PatchListLoader.Factory fileLoaderFactory,
       IntraLineLoader.Factory intraLoaderFactory,
+      DiffSummaryLoader.Factory diffSummaryLoaderFactory,
       @GerritServerConfig Config cfg) {
     this.fileCache = fileCache;
     this.intraCache = intraCache;
+    this.diffSummaryCache = diffSummaryCache;
     this.fileLoaderFactory = fileLoaderFactory;
     this.intraLoaderFactory = intraLoaderFactory;
+    this.diffSummaryLoaderFactory = diffSummaryLoaderFactory;
 
     this.computeIntraline =
-        cfg.getBoolean("cache", INTRA_NAME, "enabled",
-            cfg.getBoolean("cache", "diff", "intraline", true));
+        cfg.getBoolean(
+            "cache", INTRA_NAME, "enabled", cfg.getBoolean("cache", "diff", "intraline", true));
   }
 
   @Override
   public PatchList get(PatchListKey key, Project.NameKey project)
       throws PatchListNotAvailableException {
     try {
-      return fileCache.get(key, fileLoaderFactory.create(key, project));
+      PatchList pl = fileCache.get(key, fileLoaderFactory.create(key, project));
+      return pl;
     } catch (ExecutionException e) {
       PatchListLoader.log.warn("Error computing " + key, e);
       throw new PatchListNotAvailableException(e);
@@ -101,8 +113,7 @@
   }
 
   @Override
-  public PatchList get(Change change, PatchSet patchSet)
-      throws PatchListNotAvailableException {
+  public PatchList get(Change change, PatchSet patchSet) throws PatchListNotAvailableException {
     return get(change, patchSet, null);
   }
 
@@ -116,8 +127,7 @@
       throws PatchListNotAvailableException {
     Project.NameKey project = change.getProject();
     if (patchSet.getRevision() == null) {
-      throw new PatchListNotAvailableException(
-          "revision is null for " + patchSet.getId());
+      throw new PatchListNotAvailableException("revision is null for " + patchSet.getId());
     }
     ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
     Whitespace ws = Whitespace.IGNORE_NONE;
@@ -128,8 +138,7 @@
   }
 
   @Override
-  public IntraLineDiff getIntraLineDiff(IntraLineDiffKey key,
-      IntraLineDiffArgs args) {
+  public IntraLineDiff getIntraLineDiff(IntraLineDiffKey key, IntraLineDiffArgs args) {
     if (computeIntraline) {
       try {
         return intraCache.get(key, intraLoaderFactory.create(key, args));
@@ -140,4 +149,31 @@
     }
     return new IntraLineDiff(IntraLineDiff.Status.DISABLED);
   }
+
+  @Override
+  public DiffSummary getDiffSummary(Change change, PatchSet patchSet)
+      throws PatchListNotAvailableException {
+    Project.NameKey project = change.getProject();
+    ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
+    Whitespace ws = Whitespace.IGNORE_NONE;
+    return getDiffSummary(
+        DiffSummaryKey.fromPatchListKey(PatchListKey.againstDefaultBase(b, ws)), project);
+  }
+
+  @Override
+  public DiffSummary getDiffSummary(DiffSummaryKey key, Project.NameKey project)
+      throws PatchListNotAvailableException {
+    try {
+      return diffSummaryCache.get(key, diffSummaryLoaderFactory.create(key, project));
+    } catch (ExecutionException e) {
+      PatchListLoader.log.warn("Error computing " + key, e);
+      throw new PatchListNotAvailableException(e);
+    } catch (UncheckedExecutionException e) {
+      if (e.getCause() instanceof LargeObjectException) {
+        PatchListLoader.log.warn("Error computing " + key, e);
+        throw new PatchListNotAvailableException(e);
+      }
+      throw e;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
index 3266f01..a8a8b79 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -29,14 +29,6 @@
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.reviewdb.client.Patch.PatchType;
 import com.google.gerrit.reviewdb.client.PatchSet;
-
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.patch.CombinedFileHeader;
-import org.eclipse.jgit.patch.FileHeader;
-import org.eclipse.jgit.util.IntList;
-import org.eclipse.jgit.util.RawParseUtils;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -44,13 +36,28 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.patch.CombinedFileHeader;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.util.IntList;
+import org.eclipse.jgit.util.RawParseUtils;
 
 public class PatchListEntry {
   private static final byte[] EMPTY_HEADER = {};
 
   static PatchListEntry empty(final String fileName) {
-    return new PatchListEntry(ChangeType.MODIFIED, PatchType.UNIFIED, null,
-        fileName, EMPTY_HEADER, Collections.<Edit> emptyList(), 0, 0, 0, 0);
+    return new PatchListEntry(
+        ChangeType.MODIFIED,
+        PatchType.UNIFIED,
+        null,
+        fileName,
+        EMPTY_HEADER,
+        Collections.<Edit>emptyList(),
+        0,
+        0,
+        0,
+        0);
   }
 
   private final ChangeType changeType;
@@ -66,8 +73,7 @@
   // Note: When adding new fields, the serialVersionUID in PatchListKey must be
   // incremented so that entries from the cache are automatically invalidated.
 
-  PatchListEntry(FileHeader hdr, List<Edit> editList, long size,
-      long sizeDelta) {
+  PatchListEntry(FileHeader hdr, List<Edit> editList, long size, long sizeDelta) {
     changeType = toChangeType(hdr);
     patchType = toPatchType(hdr);
 
@@ -114,9 +120,17 @@
     this.sizeDelta = sizeDelta;
   }
 
-  private PatchListEntry(ChangeType changeType, PatchType patchType,
-      String oldName, String newName, byte[] header, List<Edit> edits,
-      int insertions, int deletions, long size, long sizeDelta) {
+  private PatchListEntry(
+      ChangeType changeType,
+      PatchType patchType,
+      String oldName,
+      String newName,
+      byte[] header,
+      List<Edit> edits,
+      int insertions,
+      int deletions,
+      long size,
+      long sizeDelta) {
     this.changeType = changeType;
     this.patchType = patchType;
     this.oldName = oldName;
@@ -246,8 +260,8 @@
       editArray[i] = new Edit(beginA, endA, beginB, endB);
     }
 
-    return new PatchListEntry(changeType, patchType, oldName, newName, hdr,
-        toList(editArray), ins, del, size, sizeDelta);
+    return new PatchListEntry(
+        changeType, patchType, oldName, newName, hdr, toList(editArray), ins, del, size, sizeDelta);
   }
 
   private static List<Edit> toList(Edit[] l) {
@@ -288,8 +302,7 @@
       case COPY:
         return Patch.ChangeType.COPIED;
       default:
-        throw new IllegalArgumentException("Unsupported type "
-            + hdr.getChangeType());
+        throw new IllegalArgumentException("Unsupported type " + hdr.getChangeType());
     }
   }
 
@@ -305,8 +318,7 @@
         pt = Patch.PatchType.BINARY;
         break;
       default:
-        throw new IllegalArgumentException("Unsupported type "
-            + hdr.getPatchType());
+        throw new IllegalArgumentException("Unsupported type " + hdr.getPatchType());
     }
 
     if (pt != PatchType.BINARY) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
index 43e3dce..ae771d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -20,60 +20,56 @@
 import static org.eclipse.jgit.lib.ObjectIdSerialization.writeCanBeNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
 
-import com.google.common.collect.BiMap;
 import com.google.common.collect.ImmutableBiMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-
-import org.eclipse.jgit.lib.AnyObjectId;
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
 import java.util.Objects;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class PatchListKey implements Serializable {
-  public static final long serialVersionUID = 22L;
+  public static final long serialVersionUID = 24L;
 
-  public static final BiMap<Whitespace, Character> WHITESPACE_TYPES = ImmutableBiMap.of(
-      Whitespace.IGNORE_NONE, 'N',
-      Whitespace.IGNORE_TRAILING, 'E',
-      Whitespace.IGNORE_LEADING_AND_TRAILING, 'S',
-      Whitespace.IGNORE_ALL, 'A');
+  public static final ImmutableBiMap<Whitespace, Character> WHITESPACE_TYPES =
+      ImmutableBiMap.of(
+          Whitespace.IGNORE_NONE, 'N',
+          Whitespace.IGNORE_TRAILING, 'E',
+          Whitespace.IGNORE_LEADING_AND_TRAILING, 'S',
+          Whitespace.IGNORE_ALL, 'A');
 
   static {
     checkState(WHITESPACE_TYPES.size() == Whitespace.values().length);
   }
 
-  public static PatchListKey againstDefaultBase(AnyObjectId newId,
-      Whitespace ws) {
+  public static PatchListKey againstDefaultBase(AnyObjectId newId, Whitespace ws) {
     return new PatchListKey(null, newId, ws);
   }
 
-  public static PatchListKey againstParentNum(int parentNum, AnyObjectId newId,
-      Whitespace ws) {
+  public static PatchListKey againstParentNum(int parentNum, AnyObjectId newId, Whitespace ws) {
     return new PatchListKey(parentNum, newId, ws);
   }
 
   /**
    * Old patch-set ID
-   * <p>
-   * When null, it represents the Base of the newId for a non-merge commit.
-   * <p>
-   * When newId is a merge commit, null value of the oldId represents either
-   * the auto-merge commit of the newId or a parent commit of the newId.
-   * These two cases are distinguished by the parentNum.
+   *
+   * <p>When null, it represents the Base of the newId for a non-merge commit.
+   *
+   * <p>When newId is a merge commit, null value of the oldId represents either the auto-merge
+   * commit of the newId or a parent commit of the newId. These two cases are distinguished by the
+   * parentNum.
    */
   private transient ObjectId oldId;
 
   /**
    * 1-based parent number when newId is a merge commit
-   * <p>
-   * For the auto-merge case this field is null.
-   * <p>
-   * Used only when oldId is null and newId is a merge commit
+   *
+   * <p>For the auto-merge case this field is null.
+   *
+   * <p>Used only when oldId is null and newId is a merge commit
    */
   private transient Integer parentNum;
 
@@ -92,6 +88,14 @@
     whitespace = ws;
   }
 
+  /** For use only by DiffSummaryKey. */
+  PatchListKey(ObjectId oldId, Integer parentNum, ObjectId newId, Whitespace whitespace) {
+    this.oldId = oldId;
+    this.parentNum = parentNum;
+    this.newId = newId;
+    this.whitespace = whitespace;
+  }
+
   /** Old side commit, or null to assume ancestor or combined merge. */
   @Nullable
   public ObjectId getOldId() {
@@ -138,6 +142,10 @@
     n.append("..");
     n.append(newId.name());
     n.append(" ");
+    if (parentNum != null) {
+      n.append(parentNum);
+      n.append(" ");
+    }
     n.append(whitespace.name());
     n.append("]");
     return n.toString();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
index 2fa43bb..124fe8e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -17,11 +17,10 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
-import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
@@ -32,7 +31,18 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Stream;
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.Edit;
@@ -59,18 +69,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-
 public class PatchListLoader implements Callable<PatchList> {
   static final Logger log = LoggerFactory.getLogger(PatchListLoader.class);
 
@@ -89,7 +87,8 @@
   private final boolean save;
 
   @AssistedInject
-  PatchListLoader(GitRepositoryManager mgr,
+  PatchListLoader(
+      GitRepositoryManager mgr,
       PatchListCache plc,
       @GerritServerConfig Config cfg,
       @DiffExecutor ExecutorService de,
@@ -104,15 +103,18 @@
     key = k;
     project = p;
     timeoutMillis =
-        ConfigUtil.getTimeUnit(cfg, "cache", PatchListCacheImpl.FILE_NAME,
-            "timeout", TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
+        ConfigUtil.getTimeUnit(
+            cfg,
+            "cache",
+            PatchListCacheImpl.FILE_NAME,
+            "timeout",
+            TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
     save = AutoMerger.cacheAutomerge(cfg);
   }
 
   @Override
-  public PatchList call() throws IOException,
-      PatchListNotAvailableException {
+  public PatchList call() throws IOException, PatchListNotAvailableException {
     try (Repository repo = repoManager.openRepository(project);
         ObjectInserter ins = newInserter(repo);
         ObjectReader reader = ins.newReader();
@@ -139,13 +141,11 @@
   }
 
   private ObjectInserter newInserter(Repository repo) {
-    return save
-        ? repo.newObjectInserter()
-        : new InMemoryInserter(repo);
+    return save ? repo.newObjectInserter() : new InMemoryInserter(repo);
   }
 
-  public PatchList readPatchList(Repository repo, RevWalk rw,
-      ObjectInserter ins) throws IOException, PatchListNotAvailableException {
+  public PatchList readPatchList(Repository repo, RevWalk rw, ObjectInserter ins)
+      throws IOException, PatchListNotAvailableException {
     ObjectReader reader = rw.getObjectReader();
     checkArgument(reader.getCreatedFromInserter() == ins);
     RawTextComparator cmp = comparatorFor(key.getWhitespace());
@@ -155,14 +155,19 @@
 
       if (a == null) {
         // TODO(sop) Remove this case.
-        // This is a merge commit, compared to its ancestor.
+        // This is an octopus merge commit which should be compared against the
+        // auto-merge. However since we don't support computing the auto-merge
+        // for octopus merge commits, we fall back to diffing against the first
+        // parent, even though this wasn't what was requested.
         //
-        PatchListEntry[] entries = new PatchListEntry[1];
+        ComparisonType comparisonType = ComparisonType.againstParent(1);
+        PatchListEntry[] entries = new PatchListEntry[2];
         entries[0] = newCommitMessage(cmp, reader, null, b);
-        return new PatchList(a, b, true, entries);
+        entries[1] = newMergeList(cmp, reader, null, b, comparisonType);
+        return new PatchList(a, b, true, comparisonType, entries);
       }
 
-      boolean againstParent = isAgainstParent(a, b);
+      ComparisonType comparisonType = getComparisonType(a, b);
 
       RevCommit aCommit = a instanceof RevCommit ? (RevCommit) a : null;
       RevTree aTree = rw.parseTree(a);
@@ -175,63 +180,67 @@
 
       Set<String> paths = null;
       if (key.getOldId() != null && b.getParentCount() == 1) {
-        PatchListKey newKey = PatchListKey.againstDefaultBase(
-            key.getNewId(), key.getWhitespace());
-        PatchListKey oldKey = PatchListKey.againstDefaultBase(
-            key.getOldId(), key.getWhitespace());
-        paths = FluentIterable
-            .from(patchListCache.get(newKey, project).getPatches())
-            .append(patchListCache.get(oldKey, project).getPatches())
-            .transform(new Function<PatchListEntry, String>() {
-              @Override
-              public String apply(PatchListEntry entry) {
-                return entry.getNewName();
-              }
-            })
-            .toSet();
+        PatchListKey newKey = PatchListKey.againstDefaultBase(key.getNewId(), key.getWhitespace());
+        PatchListKey oldKey = PatchListKey.againstDefaultBase(key.getOldId(), key.getWhitespace());
+        paths =
+            Stream.concat(
+                    patchListCache.get(newKey, project).getPatches().stream(),
+                    patchListCache.get(oldKey, project).getPatches().stream())
+                .map(PatchListEntry::getNewName)
+                .collect(toSet());
       }
 
       int cnt = diffEntries.size();
       List<PatchListEntry> entries = new ArrayList<>();
-      entries.add(newCommitMessage(cmp, reader,
-          againstParent ? null : aCommit, b));
+      entries.add(
+          newCommitMessage(
+              cmp, reader, comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit, b));
+      boolean isMerge = b.getParentCount() > 1;
+      if (isMerge) {
+        entries.add(
+            newMergeList(
+                cmp,
+                reader,
+                comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit,
+                b,
+                comparisonType));
+      }
       for (int i = 0; i < cnt; i++) {
         DiffEntry e = diffEntries.get(i);
-        if (paths == null || paths.contains(e.getNewPath())
-            || paths.contains(e.getOldPath())) {
+        if (paths == null || paths.contains(e.getNewPath()) || paths.contains(e.getOldPath())) {
 
           FileHeader fh = toFileHeader(key, df, e);
-          long oldSize =
-              getFileSize(reader, e.getOldMode(), e.getOldPath(), aTree);
-          long newSize =
-              getFileSize(reader, e.getNewMode(), e.getNewPath(), bTree);
+          long oldSize = getFileSize(reader, e.getOldMode(), e.getOldPath(), aTree);
+          long newSize = getFileSize(reader, e.getNewMode(), e.getNewPath(), bTree);
           entries.add(newEntry(aTree, fh, newSize, newSize - oldSize));
         }
       }
-      return new PatchList(a, b, againstParent,
-          entries.toArray(new PatchListEntry[entries.size()]));
+      return new PatchList(
+          a, b, isMerge, comparisonType, entries.toArray(new PatchListEntry[entries.size()]));
     }
   }
 
-  private boolean isAgainstParent(RevObject a, RevCommit b) {
+  private ComparisonType getComparisonType(RevObject a, RevCommit b) {
     for (int i = 0; i < b.getParentCount(); i++) {
       if (b.getParent(i).equals(a)) {
-        return true;
+        return ComparisonType.againstParent(i + 1);
       }
     }
 
-    return false;
+    if (key.getOldId() == null && b.getParentCount() > 0) {
+      return ComparisonType.againstAutoMerge();
+    }
+
+    return ComparisonType.againstOtherPatchSet();
   }
 
-  private static long getFileSize(ObjectReader reader,
-      FileMode mode, String path, RevTree t) throws IOException {
+  private static long getFileSize(ObjectReader reader, FileMode mode, String path, RevTree t)
+      throws IOException {
     if (!isBlob(mode)) {
       return 0;
     }
     try (TreeWalk tw = TreeWalk.forPath(reader, path, t)) {
-      return tw != null
-          ? reader.open(tw.getObjectId(0), OBJ_BLOB).getSize()
-          : 0;
+      return tw != null ? reader.open(tw.getObjectId(0), OBJ_BLOB).getSize() : 0;
     }
   }
 
@@ -240,28 +249,37 @@
     return t == FileMode.TYPE_FILE || t == FileMode.TYPE_SYMLINK;
   }
 
-  private FileHeader toFileHeader(PatchListKey key,
-      final DiffFormatter diffFormatter, final DiffEntry diffEntry)
+  private FileHeader toFileHeader(
+      PatchListKey key, final DiffFormatter diffFormatter, final DiffEntry diffEntry)
       throws IOException {
 
-    Future<FileHeader> result = diffExecutor.submit(new Callable<FileHeader>() {
-      @Override
-      public FileHeader call() throws IOException {
-        synchronized (diffEntry) {
-          return diffFormatter.toFileHeader(diffEntry);
-        }
-      }
-    });
+    Future<FileHeader> result =
+        diffExecutor.submit(
+            new Callable<FileHeader>() {
+              @Override
+              public FileHeader call() throws IOException {
+                synchronized (diffEntry) {
+                  return diffFormatter.toFileHeader(diffEntry);
+                }
+              }
+            });
 
     try {
       return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
     } catch (InterruptedException | TimeoutException e) {
-      log.warn(timeoutMillis + " ms timeout reached for Diff loader"
-                      + " in project " + project
-                      + " on commit " + key.getNewId().name()
-                      + " on path " + diffEntry.getNewPath()
-                      + " comparing " + diffEntry.getOldId().name()
-                      + ".." + diffEntry.getNewId().name());
+      log.warn(
+          timeoutMillis
+              + " ms timeout reached for Diff loader"
+              + " in project "
+              + project
+              + " on commit "
+              + key.getNewId().name()
+              + " on path "
+              + diffEntry.getNewPath()
+              + " comparing "
+              + diffEntry.getOldId().name()
+              + ".."
+              + diffEntry.getNewId().name());
       result.cancel(true);
       synchronized (diffEntry) {
         return toFileHeaderWithoutMyersDiff(diffFormatter, diffEntry);
@@ -269,45 +287,42 @@
     } catch (ExecutionException e) {
       // If there was an error computing the result, carry it
       // up to the caller so the cache knows this key is invalid.
-      Throwables.propagateIfInstanceOf(e.getCause(), IOException.class);
+      Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
       throw new IOException(e.getMessage(), e.getCause());
     }
   }
 
-  private FileHeader toFileHeaderWithoutMyersDiff(DiffFormatter diffFormatter,
-      DiffEntry diffEntry) throws IOException {
+  private FileHeader toFileHeaderWithoutMyersDiff(DiffFormatter diffFormatter, DiffEntry diffEntry)
+      throws IOException {
     HistogramDiff histogramDiff = new HistogramDiff();
     histogramDiff.setFallbackAlgorithm(null);
     diffFormatter.setDiffAlgorithm(histogramDiff);
     return diffFormatter.toFileHeader(diffEntry);
   }
 
-  private PatchListEntry newCommitMessage(final RawTextComparator cmp,
-      final ObjectReader reader,
-      final RevCommit aCommit, final RevCommit bCommit) throws IOException {
-    StringBuilder hdr = new StringBuilder();
-
-    hdr.append("diff --git");
-    if (aCommit != null) {
-      hdr.append(" a/").append(Patch.COMMIT_MSG);
-    } else {
-      hdr.append(" ").append(FileHeader.DEV_NULL);
-    }
-    hdr.append(" b/").append(Patch.COMMIT_MSG);
-    hdr.append("\n");
-
-    if (aCommit != null) {
-      hdr.append("--- a/").append(Patch.COMMIT_MSG).append("\n");
-    } else {
-      hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
-    }
-    hdr.append("+++ b/").append(Patch.COMMIT_MSG).append("\n");
-
-    Text aText =
-        aCommit != null ? Text.forCommit(reader, aCommit) : Text.EMPTY;
+  private PatchListEntry newCommitMessage(
+      RawTextComparator cmp, ObjectReader reader, RevCommit aCommit, RevCommit bCommit)
+      throws IOException {
+    Text aText = aCommit != null ? Text.forCommit(reader, aCommit) : Text.EMPTY;
     Text bText = Text.forCommit(reader, bCommit);
+    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.COMMIT_MSG);
+  }
 
-    byte[] rawHdr = hdr.toString().getBytes(UTF_8);
+  private PatchListEntry newMergeList(
+      RawTextComparator cmp,
+      ObjectReader reader,
+      RevCommit aCommit,
+      RevCommit bCommit,
+      ComparisonType comparisonType)
+      throws IOException {
+    Text aText = aCommit != null ? Text.forMergeList(comparisonType, reader, aCommit) : Text.EMPTY;
+    Text bText = Text.forMergeList(comparisonType, reader, bCommit);
+    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.MERGE_LIST);
+  }
+
+  private static PatchListEntry createPatchListEntry(
+      RawTextComparator cmp, RevCommit aCommit, Text aText, Text bText, String fileName) {
+    byte[] rawHdr = getRawHeader(aCommit != null, fileName);
     byte[] aContent = aText.getContent();
     byte[] bContent = bText.getContent();
     long size = bContent.length;
@@ -319,25 +334,42 @@
     return new PatchListEntry(fh, edits, size, sizeDelta);
   }
 
-  private PatchListEntry newEntry(RevTree aTree, FileHeader fileHeader,
-      long size, long sizeDelta) {
+  private static byte[] getRawHeader(boolean hasA, String fileName) {
+    StringBuilder hdr = new StringBuilder();
+    hdr.append("diff --git");
+    if (hasA) {
+      hdr.append(" a/").append(fileName);
+    } else {
+      hdr.append(" ").append(FileHeader.DEV_NULL);
+    }
+    hdr.append(" b/").append(fileName);
+    hdr.append("\n");
+
+    if (hasA) {
+      hdr.append("--- a/").append(fileName).append("\n");
+    } else {
+      hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
+    }
+    hdr.append("+++ b/").append(fileName).append("\n");
+    return hdr.toString().getBytes(UTF_8);
+  }
+
+  private PatchListEntry newEntry(RevTree aTree, FileHeader fileHeader, long size, long sizeDelta) {
     if (aTree == null // want combined diff
         || fileHeader.getPatchType() != PatchType.UNIFIED
         || fileHeader.getHunks().isEmpty()) {
-      return new PatchListEntry(fileHeader, Collections.<Edit> emptyList(),
-          size, sizeDelta);
+      return new PatchListEntry(fileHeader, Collections.<Edit>emptyList(), size, sizeDelta);
     }
 
     List<Edit> edits = fileHeader.toEditList();
     if (edits.isEmpty()) {
-      return new PatchListEntry(fileHeader, Collections.<Edit> emptyList(),
-          size, sizeDelta);
+      return new PatchListEntry(fileHeader, Collections.<Edit>emptyList(), size, sizeDelta);
     }
     return new PatchListEntry(fileHeader, edits, size, sizeDelta);
   }
 
-  private RevObject aFor(PatchListKey key,
-      Repository repo, RevWalk rw, ObjectInserter ins, RevCommit b)
+  private RevObject aFor(
+      PatchListKey key, Repository repo, RevWalk rw, ObjectInserter ins, RevCommit b)
       throws IOException {
     if (key.getOldId() != null) {
       return rw.parseAny(key.getOldId());
@@ -346,11 +378,12 @@
     switch (b.getParentCount()) {
       case 0:
         return rw.parseAny(emptyTree(ins));
-      case 1: {
-        RevCommit r = b.getParent(0);
-        rw.parseBody(r);
-        return r;
-      }
+      case 1:
+        {
+          RevCommit r = b.getParent(0);
+          rw.parseBody(r);
+          return r;
+        }
       case 2:
         if (key.getParentNum() != null) {
           RevCommit r = b.getParent(key.getParentNum() - 1);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
index 2ccc9f1..fab66cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
@@ -21,6 +21,10 @@
     super(message);
   }
 
+  public PatchListNotAvailableException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
   public PatchListNotAvailableException(Throwable cause) {
     super(cause);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
index 2362986..f40eac6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
@@ -20,8 +20,14 @@
 public class PatchListWeigher implements Weigher<PatchListKey, PatchList> {
   @Override
   public int weigh(PatchListKey key, PatchList value) {
-    int size = 16 + 4 * 8 + 2 * 36 // Size of PatchListKey, 64 bit JVM
-        + 16 + 3 * 8 + 3 * 4 + 20; // Size of PatchList, 64 bit JVM
+    int size =
+        16
+            + 4 * 8
+            + 2 * 36 // Size of PatchListKey, 64 bit JVM
+            + 16
+            + 3 * 8
+            + 3 * 4
+            + 20; // Size of PatchList, 64 bit JVM
     for (PatchListEntry e : value.getPatches()) {
       size += e.weigh();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index e09d26f..2dd5af7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.patch;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
@@ -22,15 +24,18 @@
 import com.google.gerrit.prettify.common.EditList;
 import com.google.gerrit.prettify.common.SparseFileContent;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.inject.Inject;
-
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -44,29 +49,24 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-
 class PatchScriptBuilder {
   static final int MAX_CONTEXT = 5000000;
   static final int BIG_FILE = 9000;
 
-  private static final Comparator<Edit> EDIT_SORT = new Comparator<Edit>() {
-    @Override
-    public int compare(final Edit o1, final Edit o2) {
-      return o1.getBeginA() - o2.getBeginA();
-    }
-  };
+  private static final Comparator<Edit> EDIT_SORT =
+      new Comparator<Edit>() {
+        @Override
+        public int compare(final Edit o1, final Edit o2) {
+          return o1.getBeginA() - o2.getBeginA();
+        }
+      };
 
   private Repository db;
   private Project.NameKey projectKey;
   private ObjectReader reader;
   private Change change;
   private DiffPreferencesInfo diffPrefs;
-  private boolean againstParent;
+  private ComparisonType comparisonType;
   private ObjectId aId;
   private ObjectId bId;
 
@@ -79,7 +79,7 @@
   private int context;
 
   @Inject
-  PatchScriptBuilder(final FileTypeRegistry ftr, final PatchListCache plc) {
+  PatchScriptBuilder(FileTypeRegistry ftr, PatchListCache plc) {
     a = new Side();
     b = new Side();
     registry = ftr;
@@ -106,14 +106,14 @@
     }
   }
 
-  void setTrees(final boolean ap, final ObjectId a, final ObjectId b) {
-    againstParent = ap;
+  void setTrees(final ComparisonType ct, final ObjectId a, final ObjectId b) {
+    comparisonType = ct;
     aId = a;
     bId = b;
   }
 
-  PatchScript toPatchScript(final PatchListEntry content,
-      final CommentDetail comments, final List<Patch> history)
+  PatchScript toPatchScript(
+      final PatchListEntry content, final CommentDetail comments, final List<Patch> history)
       throws IOException {
     reader = db.newObjectReader();
     try {
@@ -123,8 +123,8 @@
     }
   }
 
-  private PatchScript build(final PatchListEntry content,
-      final CommentDetail comments, final List<Patch> history)
+  private PatchScript build(
+      final PatchListEntry content, final CommentDetail comments, final List<Patch> history)
       throws IOException {
     boolean intralineDifferenceIsPossible = true;
     boolean intralineFailure = false;
@@ -143,11 +143,8 @@
     } else if (diffPrefs.intralineDifference) {
       IntraLineDiff d =
           patchListCache.getIntraLineDiff(
-              IntraLineDiffKey.create(
-                a.id, b.id,
-                diffPrefs.ignoreWhitespace),
-              IntraLineDiffArgs.create(
-                a.src, b.src, edits, projectKey, bId, b.path));
+              IntraLineDiffKey.create(a.id, b.id, diffPrefs.ignoreWhitespace),
+              IntraLineDiffArgs.create(a.src, b.src, edits, projectKey, bId, b.path));
       if (d != null) {
         switch (d.getStatus()) {
           case EDIT_LIST:
@@ -179,8 +176,7 @@
     }
 
     boolean hugeFile = false;
-    if (a.src == b.src && a.size() <= context
-        && content.getEdits().isEmpty()) {
+    if (a.src == b.src && a.size() <= context && content.getEdits().isEmpty()) {
       // Odd special case; the files are identical (100% rename or copy)
       // and the user has asked for context that is larger than the file.
       // Send them the entire file, with an empty edit after the last line.
@@ -197,7 +193,6 @@
         // the browser client.
         //
         hugeFile = true;
-
       }
 
       // In order to expand the skipped common lines or syntax highlight the
@@ -209,12 +204,28 @@
       packContent(diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE);
     }
 
-    return new PatchScript(change.getKey(), content.getChangeType(),
-        content.getOldName(), content.getNewName(), a.fileMode, b.fileMode,
-        content.getHeaderLines(), diffPrefs, a.dst, b.dst, edits,
-        a.displayMethod, b.displayMethod, a.mimeType.toString(),
-        b.mimeType.toString(), comments, history, hugeFile,
-        intralineDifferenceIsPossible, intralineFailure, intralineTimeout,
+    return new PatchScript(
+        change.getKey(),
+        content.getChangeType(),
+        content.getOldName(),
+        content.getNewName(),
+        a.fileMode,
+        b.fileMode,
+        content.getHeaderLines(),
+        diffPrefs,
+        a.dst,
+        b.dst,
+        edits,
+        a.displayMethod,
+        b.displayMethod,
+        a.mimeType.toString(),
+        b.mimeType.toString(),
+        comments,
+        history,
+        hugeFile,
+        intralineDifferenceIsPossible,
+        intralineFailure,
+        intralineTimeout,
         content.getPatchType() == Patch.PatchType.BINARY,
         aId == null ? null : aId.getName(),
         bId == null ? null : bId.getName());
@@ -282,8 +293,8 @@
     int lastLine;
 
     lastLine = -1;
-    for (PatchLineComment plc : comments.getCommentsA()) {
-      final int a = plc.getLine();
+    for (Comment c : comments.getCommentsA()) {
+      final int a = c.lineNbr;
       if (lastLine != a) {
         final int b = mapA2B(a - 1);
         if (0 <= b) {
@@ -294,8 +305,8 @@
     }
 
     lastLine = -1;
-    for (PatchLineComment plc : comments.getCommentsB()) {
-      final int b = plc.getLine();
+    for (Comment c : comments.getCommentsB()) {
+      int b = c.lineNbr;
       if (lastLine != b) {
         final int a = mapB2A(b - 1);
         if (0 <= a) {
@@ -435,7 +446,8 @@
       try {
         final boolean reuse;
         if (Patch.COMMIT_MSG.equals(path)) {
-          if (againstParent && (aId == within || within.equals(aId))) {
+          if (comparisonType.isAgainstParentOrAutoMerge()
+              && (aId == within || within.equals(aId))) {
             id = ObjectId.zeroId();
             src = Text.EMPTY;
             srcContent = Text.NO_BYTES;
@@ -453,15 +465,35 @@
             }
           }
           reuse = false;
-
+        } else if (Patch.MERGE_LIST.equals(path)) {
+          if (comparisonType.isAgainstParentOrAutoMerge()
+              && (aId == within || within.equals(aId))) {
+            id = ObjectId.zeroId();
+            src = Text.EMPTY;
+            srcContent = Text.NO_BYTES;
+            mode = FileMode.MISSING;
+            displayMethod = DisplayMethod.NONE;
+          } else {
+            id = within;
+            src = Text.forMergeList(comparisonType, reader, within);
+            srcContent = src.getContent();
+            if (src == Text.EMPTY) {
+              mode = FileMode.MISSING;
+              displayMethod = DisplayMethod.NONE;
+            } else {
+              mode = FileMode.REGULAR_FILE;
+            }
+          }
+          reuse = false;
         } else {
           final TreeWalk tw = find(within);
 
           id = tw != null ? tw.getObjectId(0) : ObjectId.zeroId();
           mode = tw != null ? tw.getFileMode(0) : FileMode.MISSING;
-          reuse = other != null
-              && other.id.equals(id)
-              && (other.mode == mode || isBothFile(other.mode, mode));
+          reuse =
+              other != null
+                  && other.id.equals(id)
+                  && (other.mode == mode || isBothFile(other.mode, mode));
 
           if (reuse) {
             srcContent = other.srcContent;
@@ -471,7 +503,7 @@
 
           } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
             String strContent = "Subproject commit " + ObjectId.toString(id);
-            srcContent = strContent.getBytes();
+            srcContent = strContent.getBytes(UTF_8);
 
           } else {
             srcContent = Text.NO_BYTES;
@@ -484,8 +516,7 @@
 
           } else if (srcContent.length > 0 && FileMode.SYMLINK != mode) {
             mimeType = registry.getMimeType(path, srcContent);
-            if ("image".equals(mimeType.getMediaType())
-                && registry.isSafeInline(mimeType)) {
+            if ("image".equals(mimeType.getMediaType()) && registry.isSafeInline(mimeType)) {
               displayMethod = DisplayMethod.IMG;
             }
           }
@@ -519,8 +550,9 @@
       }
     }
 
-    private TreeWalk find(final ObjectId within) throws MissingObjectException,
-        IncorrectObjectTypeException, CorruptObjectException, IOException {
+    private TreeWalk find(final ObjectId within)
+        throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+            IOException {
       if (path == null || within == null) {
         return null;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index a7d2523..82c6150 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -15,9 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.util.GitUtil.getParent;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
@@ -26,16 +24,15 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-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.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountInfoCacheFactory;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -48,20 +45,18 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.Callable;
-
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class PatchScriptFactory implements Callable<PatchScript> {
   public interface Factory {
@@ -80,20 +75,17 @@
         DiffPreferencesInfo diffPrefs);
   }
 
-  private static final Logger log =
-      LoggerFactory.getLogger(PatchScriptFactory.class);
+  private static final Logger log = LoggerFactory.getLogger(PatchScriptFactory.class);
 
   private final GitRepositoryManager repoManager;
   private final PatchSetUtil psUtil;
   private final Provider<PatchScriptBuilder> builderFactory;
   private final PatchListCache patchListCache;
   private final ReviewDb db;
-  private final AccountInfoCacheFactory.Factory aicFactory;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   private final String fileName;
-  @Nullable
-  private final PatchSet.Id psa;
+  @Nullable private final PatchSet.Id psa;
   private final int parentNum;
   private final PatchSet.Id psb;
   private final DiffPreferencesInfo diffPrefs;
@@ -113,13 +105,13 @@
   private CommentDetail comments;
 
   @AssistedInject
-  PatchScriptFactory(GitRepositoryManager grm,
+  PatchScriptFactory(
+      GitRepositoryManager grm,
       PatchSetUtil psUtil,
       Provider<PatchScriptBuilder> builderFactory,
       PatchListCache patchListCache,
       ReviewDb db,
-      AccountInfoCacheFactory.Factory aicFactory,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
       @Assisted ChangeControl control,
       @Assisted final String fileName,
@@ -132,8 +124,7 @@
     this.patchListCache = patchListCache;
     this.db = db;
     this.control = control;
-    this.aicFactory = aicFactory;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.editReader = editReader;
 
     this.fileName = fileName;
@@ -146,13 +137,13 @@
   }
 
   @AssistedInject
-  PatchScriptFactory(GitRepositoryManager grm,
+  PatchScriptFactory(
+      GitRepositoryManager grm,
       PatchSetUtil psUtil,
       Provider<PatchScriptBuilder> builderFactory,
       PatchListCache patchListCache,
       ReviewDb db,
-      AccountInfoCacheFactory.Factory aicFactory,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
       @Assisted ChangeControl control,
       @Assisted String fileName,
@@ -165,8 +156,7 @@
     this.patchListCache = patchListCache;
     this.db = db;
     this.control = control;
-    this.aicFactory = aicFactory;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.editReader = editReader;
 
     this.fileName = fileName;
@@ -188,9 +178,9 @@
   }
 
   @Override
-  public PatchScript call() throws OrmException, NoSuchChangeException,
-      LargeObjectException, AuthException,
-      InvalidChangeOperationException, IOException {
+  public PatchScript call()
+      throws OrmException, LargeObjectException, AuthException, InvalidChangeOperationException,
+          IOException {
     if (parentNum < 0) {
       validatePatchSetId(psa);
     }
@@ -199,14 +189,12 @@
     change = control.getChange();
     project = change.getProject();
 
-    PatchSet psEntityA = psa != null
-        ? psUtil.get(db, control.getNotes(), psa) : null;
-    PatchSet psEntityB = psb.get() == 0
-        ? new PatchSet(psb)
-        : psUtil.get(db, control.getNotes(), psb);
+    PatchSet psEntityA = psa != null ? psUtil.get(db, control.getNotes(), psa) : null;
+    PatchSet psEntityB =
+        psb.get() == 0 ? new PatchSet(psb) : psUtil.get(db, control.getNotes(), psb);
 
-    if ((psEntityA != null && !control.isPatchVisible(psEntityA, db)) ||
-        (psEntityB != null && !control.isPatchVisible(psEntityB, db))) {
+    if ((psEntityA != null && !control.isPatchVisible(psEntityA, db))
+        || (psEntityB != null && !control.isPatchVisible(psEntityB, db))) {
       throw new NoSuchChangeException(changeId);
     }
 
@@ -214,8 +202,6 @@
       bId = toObjectId(psEntityB);
       if (parentNum < 0) {
         aId = psEntityA != null ? toObjectId(psEntityA) : null;
-      } else {
-        aId = getParent(git, bId, parentNum);
       }
 
       try {
@@ -223,7 +209,8 @@
         final PatchScriptBuilder b = newBuilder(list, git);
         final PatchListEntry content = list.get(fileName);
 
-        loadCommentsAndHistory(control.getNotes(),
+        loadCommentsAndHistory(
+            control.getNotes(),
             content.getChangeType(),
             content.getOldName(),
             content.getNewName());
@@ -247,11 +234,13 @@
   }
 
   private PatchListKey keyFor(final Whitespace whitespace) {
-    return new PatchListKey(aId, bId, whitespace);
+    if (parentNum < 0) {
+      return new PatchListKey(aId, bId, whitespace);
+    }
+    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
   }
 
-  private PatchList listFor(final PatchListKey key)
-      throws PatchListNotAvailableException {
+  private PatchList listFor(final PatchListKey key) throws PatchListNotAvailableException {
     return patchListCache.get(key, project);
   }
 
@@ -260,12 +249,13 @@
     b.setRepository(git, project);
     b.setChange(change);
     b.setDiffPrefs(diffPrefs);
-    b.setTrees(list.isAgainstParent(), list.getOldId(), list.getNewId());
+    b.setTrees(list.getComparisonType(), list.getOldId(), list.getNewId());
     return b;
   }
 
-  private ObjectId toObjectId(PatchSet ps) throws NoSuchChangeException,
-      AuthException, NoSuchChangeException, IOException, OrmException {
+  private ObjectId toObjectId(PatchSet ps)
+      throws NoSuchChangeException, AuthException, NoSuchChangeException, IOException,
+          OrmException {
     if (ps.getId().get() == 0) {
       return getEditRev();
     }
@@ -281,8 +271,8 @@
     }
   }
 
-  private ObjectId getEditRev() throws AuthException,
-      NoSuchChangeException, IOException, OrmException {
+  private ObjectId getEditRev()
+      throws AuthException, NoSuchChangeException, IOException, OrmException {
     edit = editReader.byChange(change);
     if (edit.isPresent()) {
       return edit.get().getRef().getObjectId();
@@ -290,8 +280,7 @@
     throw new NoSuchChangeException(change.getId());
   }
 
-  private void validatePatchSetId(final PatchSet.Id psId)
-      throws NoSuchChangeException {
+  private void validatePatchSetId(final PatchSet.Id psId) throws NoSuchChangeException {
     if (psId == null) { // OK, means use base;
     } else if (changeId.equals(psId.getParentKey())) { // OK, same change;
     } else {
@@ -299,8 +288,9 @@
     }
   }
 
-  private void loadCommentsAndHistory(ChangeNotes notes, ChangeType changeType,
-      String oldName, String newName) throws OrmException {
+  private void loadCommentsAndHistory(
+      ChangeNotes notes, ChangeType changeType, String oldName, String newName)
+      throws OrmException {
     Map<Patch.Key, Patch> byKey = new HashMap<>();
 
     if (loadHistory) {
@@ -337,32 +327,30 @@
         byKey.put(p.getKey(), p);
       }
       if (edit != null && edit.isPresent()) {
-        Patch p = new Patch(new Patch.Key(
-            new PatchSet.Id(psb.getParentKey(), 0), fileName));
+        Patch p = new Patch(new Patch.Key(new PatchSet.Id(psb.getParentKey(), 0), fileName));
         history.add(p);
         byKey.put(p.getKey(), p);
       }
     }
 
     if (loadComments && edit == null) {
-      AccountInfoCacheFactory aic = aicFactory.create();
       comments = new CommentDetail(psa, psb);
       switch (changeType) {
         case ADDED:
         case MODIFIED:
-          loadPublished(byKey, aic, newName);
+          loadPublished(byKey, newName);
           break;
 
         case DELETED:
-          loadPublished(byKey, aic, newName);
+          loadPublished(byKey, newName);
           break;
 
         case COPIED:
         case RENAMED:
           if (psa != null) {
-            loadPublished(byKey, aic, oldName);
+            loadPublished(byKey, oldName);
           }
-          loadPublished(byKey, aic, newName);
+          loadPublished(byKey, newName);
           break;
 
         case REWRITE:
@@ -375,57 +363,48 @@
         switch (changeType) {
           case ADDED:
           case MODIFIED:
-            loadDrafts(byKey, aic, me, newName);
+            loadDrafts(byKey, me, newName);
             break;
 
           case DELETED:
-            loadDrafts(byKey, aic, me, newName);
+            loadDrafts(byKey, me, newName);
             break;
 
           case COPIED:
           case RENAMED:
             if (psa != null) {
-              loadDrafts(byKey, aic, me, oldName);
+              loadDrafts(byKey, me, oldName);
             }
-            loadDrafts(byKey, aic, me, newName);
+            loadDrafts(byKey, me, newName);
             break;
 
           case REWRITE:
             break;
         }
       }
-
-      comments.setAccountInfoCache(aic.create());
     }
   }
 
-  private void loadPublished(final Map<Patch.Key, Patch> byKey,
-      final AccountInfoCacheFactory aic, final String file) throws OrmException {
+  private void loadPublished(Map<Patch.Key, Patch> byKey, String file) throws OrmException {
     ChangeNotes notes = control.getNotes();
-    for (PatchLineComment c : plcUtil.publishedByChangeFile(db, notes, changeId, file)) {
-      if (comments.include(c)) {
-        aic.want(c.getAuthor());
-      }
-
-      final Patch.Key pKey = c.getKey().getParentKey();
-      final Patch p = byKey.get(pKey);
+    for (Comment c : commentsUtil.publishedByChangeFile(db, notes, changeId, file)) {
+      comments.include(change.getId(), c);
+      PatchSet.Id psId = new PatchSet.Id(change.getId(), c.key.patchSetId);
+      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      Patch p = byKey.get(pKey);
       if (p != null) {
         p.setCommentCount(p.getCommentCount() + 1);
       }
     }
   }
 
-  private void loadDrafts(final Map<Patch.Key, Patch> byKey,
-      final AccountInfoCacheFactory aic, final Account.Id me, final String file)
+  private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file)
       throws OrmException {
-    for (PatchLineComment c :
-        plcUtil.draftByChangeFileAuthor(db, control.getNotes(), file, me)) {
-      if (comments.include(c)) {
-        aic.want(me);
-      }
-
-      final Patch.Key pKey = c.getKey().getParentKey();
-      final Patch p = byKey.get(pKey);
+    for (Comment c : commentsUtil.draftByChangeFileAuthor(db, control.getNotes(), file, me)) {
+      comments.include(change.getId(), c);
+      PatchSet.Id psId = new PatchSet.Id(change.getId(), c.key.patchSetId);
+      Patch.Key pKey = new Patch.Key(psId, c.key.filename);
+      Patch p = byKey.get(pKey);
       if (p != null) {
         p.setDraftCount(p.getDraftCount() + 1);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
index 85afafc..3fc6ba6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
@@ -28,7 +28,11 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -36,16 +40,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Set;
-
-
-/**
- * Factory class creating PatchSetInfo from meta-data found in Git repository.
- */
+/** Factory class creating PatchSetInfo from meta-data found in Git repository. */
 @Singleton
 public class PatchSetInfoFactory {
   private final GitRepositoryManager repoManager;
@@ -54,16 +49,13 @@
 
   @Inject
   public PatchSetInfoFactory(
-      GitRepositoryManager repoManager,
-      PatchSetUtil psUtil,
-      AccountByEmailCache byEmailCache) {
+      GitRepositoryManager repoManager, PatchSetUtil psUtil, AccountByEmailCache byEmailCache) {
     this.repoManager = repoManager;
     this.psUtil = psUtil;
     this.byEmailCache = byEmailCache;
   }
 
-  public PatchSetInfo get(RevWalk rw, RevCommit src, PatchSet.Id psi)
-      throws IOException {
+  public PatchSetInfo get(RevWalk rw, RevCommit src, PatchSet.Id psi) throws IOException {
     rw.parseBody(src);
     PatchSetInfo info = new PatchSetInfo(psi);
     info.setSubject(src.getShortMessage());
@@ -88,8 +80,7 @@
       throws PatchSetInfoNotAvailableException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      final RevCommit src =
-          rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
+      final RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
       PatchSetInfo info = get(rw, src, patchSet.getId());
       info.setParents(toParentInfos(src.getParents(), rw));
       return info;
@@ -117,8 +108,8 @@
     return u;
   }
 
-  private List<PatchSetInfo.ParentInfo> toParentInfos(final RevCommit[] parents,
-      final RevWalk walk) throws IOException, MissingObjectException {
+  private List<PatchSetInfo.ParentInfo> toParentInfos(final RevCommit[] parents, final RevWalk walk)
+      throws IOException, MissingObjectException {
     List<PatchSetInfo.ParentInfo> pInfos = new ArrayList<>(parents.length);
     for (RevCommit parent : parents) {
       walk.parseBody(parent);
@@ -128,5 +119,4 @@
     }
     return pInfos;
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoNotAvailableException.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoNotAvailableException.java
index 9763d3c..0a91b32f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoNotAvailableException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoNotAvailableException.java
@@ -20,5 +20,4 @@
   public PatchSetInfoNotAvailableException(Exception cause) {
     super(cause);
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
index 7982479..f001591 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
@@ -17,6 +17,11 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.IllegalCharsetNameException;
+import java.nio.charset.UnsupportedCharsetException;
+import java.text.SimpleDateFormat;
 import org.eclipse.jgit.diff.RawText;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -32,12 +37,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.nio.charset.Charset;
-import java.nio.charset.IllegalCharsetNameException;
-import java.nio.charset.UnsupportedCharsetException;
-import java.text.SimpleDateFormat;
-
 public class Text extends RawText {
   private static final Logger log = LoggerFactory.getLogger(Text.class);
   private static final int bigFileThreshold = PackConfig.DEFAULT_BIG_FILE_THRESHOLD;
@@ -58,16 +57,17 @@
       switch (c.getParentCount()) {
         case 0:
           break;
-        case 1: {
-          RevCommit p = c.getParent(0);
-          rw.parseBody(p);
-          b.append("Parent:     ");
-          b.append(reader.abbreviate(p, 8).name());
-          b.append(" (");
-          b.append(p.getShortMessage());
-          b.append(")\n");
-          break;
-        }
+        case 1:
+          {
+            RevCommit p = c.getParent(0);
+            rw.parseBody(p);
+            b.append("Parent:     ");
+            b.append(reader.abbreviate(p, 8).name());
+            b.append(" (");
+            b.append(p.getShortMessage());
+            b.append(")\n");
+            break;
+          }
         default:
           for (int i = 0; i < c.getParentCount(); i++) {
             RevCommit p = c.getParent(i);
@@ -87,8 +87,36 @@
     }
   }
 
-  private static void appendPersonIdent(StringBuilder b, String field,
-      PersonIdent person) {
+  public static Text forMergeList(
+      ComparisonType comparisonType, ObjectReader reader, AnyObjectId commitId) throws IOException {
+    try (RevWalk rw = new RevWalk(reader)) {
+      RevCommit c = rw.parseCommit(commitId);
+      StringBuilder b = new StringBuilder();
+      switch (c.getParentCount()) {
+        case 0:
+          break;
+        case 1:
+          {
+            break;
+          }
+        default:
+          int uniterestingParent =
+              comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
+
+          b.append("Merge List:\n\n");
+          for (RevCommit commit : MergeListBuilder.build(rw, c, uniterestingParent)) {
+            b.append("* ");
+            b.append(reader.abbreviate(commit, 8).name());
+            b.append(" ");
+            b.append(commit.getShortMessage());
+            b.append("\n");
+          }
+      }
+      return new Text(b.toString().getBytes(UTF_8));
+    }
+  }
+
+  private static void appendPersonIdent(StringBuilder b, String field, PersonIdent person) {
     if (person != null) {
       b.append(field).append(":    ");
       if (person.getName() != null) {
@@ -144,8 +172,7 @@
     super(r);
   }
 
-  public Text(ObjectLoader ldr) throws MissingObjectException,
-      LargeObjectException, IOException {
+  public Text(ObjectLoader ldr) throws MissingObjectException, LargeObjectException, IOException {
     this(asByteArray(ldr));
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
index ee8f963..c6133ea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
@@ -15,12 +15,13 @@
 package com.google.gerrit.server.plugins;
 
 import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.server.plugins.Plugin.ApiType;
 import com.google.inject.Module;
-
+import com.google.inject.servlet.ServletModule;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.lang.annotation.Annotation;
@@ -33,18 +34,16 @@
 /**
  * Base plugin scanner for a set of pre-loaded classes.
  *
- * Utility base class for simplifying the development of Server plugin scanner
- * based on a set of externally pre-loaded classes.
+ * <p>Utility base class for simplifying the development of Server plugin scanner based on a set of
+ * externally pre-loaded classes.
  *
- * Extending this class you can implement very easily a PluginContentScanner
- * from a set of pre-loaded Java Classes and an API Type.
- * The convention used by this class is:
- * - there is at most one Guice module per Gerrit module type (SysModule, HttpModule, SshModule)
- * - plugin is set to be restartable in Gerrit Plugin MANIFEST
- * - only Export and Listen annotated classes can be self-discovered
+ * <p>Extending this class you can implement very easily a PluginContentScanner from a set of
+ * pre-loaded Java Classes and an API Type. The convention used by this class is: - there is at most
+ * one Guice module per Gerrit module type (SysModule, HttpModule, SshModule) - plugin is set to be
+ * restartable in Gerrit Plugin MANIFEST - only Export and Listen annotated classes can be
+ * self-discovered
  */
-public abstract class AbstractPreloadedPluginScanner implements
-    PluginContentScanner {
+public abstract class AbstractPreloadedPluginScanner implements PluginContentScanner {
   protected final String pluginName;
   protected final String pluginVersion;
   protected final Set<Class<?>> preloadedClasses;
@@ -54,8 +53,11 @@
   private Class<?> httpModuleClass;
   private Class<?> sysModuleClass;
 
-  public AbstractPreloadedPluginScanner(String pluginName, String pluginVersion,
-      Set<Class<?>> preloadedClasses, Plugin.ApiType apiType) {
+  public AbstractPreloadedPluginScanner(
+      String pluginName,
+      String pluginVersion,
+      Set<Class<?>> preloadedClasses,
+      Plugin.ApiType apiType) {
     this.pluginName = pluginName;
     this.pluginVersion = pluginVersion;
     this.preloadedClasses = preloadedClasses;
@@ -66,15 +68,21 @@
   public Manifest getManifest() throws IOException {
     scanGuiceModules(preloadedClasses);
     StringBuilder manifestString =
-        new StringBuilder("PluginName: " + pluginName + "\n"
-            + "Implementation-Version: " + pluginVersion + "\n"
-            + "Gerrit-ReloadMode: restart\n"
-            + "Gerrit-ApiType: " + apiType + "\n");
+        new StringBuilder(
+            "PluginName: "
+                + pluginName
+                + "\n"
+                + "Implementation-Version: "
+                + pluginVersion
+                + "\n"
+                + "Gerrit-ReloadMode: restart\n"
+                + "Gerrit-ApiType: "
+                + apiType
+                + "\n");
     appendIfNotNull(manifestString, "Gerrit-SshModule: ", sshModuleClass);
     appendIfNotNull(manifestString, "Gerrit-HttpModule: ", httpModuleClass);
     appendIfNotNull(manifestString, "Gerrit-Module: ", sysModuleClass);
-    return new Manifest(new ByteArrayInputStream(manifestString.toString()
-        .getBytes()));
+    return new Manifest(new ByteArrayInputStream(manifestString.toString().getBytes(UTF_8)));
   }
 
   @Override
@@ -89,18 +97,16 @@
       result.put(annotation, classMetaDataSet);
 
       for (Class<?> clazz : preloadedClasses) {
-        if (!Modifier.isAbstract(clazz.getModifiers())
-            && clazz.getAnnotation(annotation) != null) {
-          classMetaDataSet.add(new ExtensionMetaData(clazz.getName(),
-              getExportAnnotationValue(clazz, annotation)));
+        if (!Modifier.isAbstract(clazz.getModifiers()) && clazz.getAnnotation(annotation) != null) {
+          classMetaDataSet.add(
+              new ExtensionMetaData(clazz.getName(), getExportAnnotationValue(clazz, annotation)));
         }
       }
     }
     return result.build();
   }
 
-  private void appendIfNotNull(StringBuilder string, String header,
-      Class<?> guiceModuleClass) {
+  private void appendIfNotNull(StringBuilder string, String header, Class<?> guiceModuleClass) {
     if (guiceModuleClass != null) {
       string.append(header);
       string.append(guiceModuleClass.getName());
@@ -111,10 +117,8 @@
   private void scanGuiceModules(Set<Class<?>> classes) throws IOException {
     try {
       Class<?> sysModuleBaseClass = Module.class;
-      Class<?> httpModuleBaseClass =
-          Class.forName("com.google.inject.servlet.ServletModule");
-      Class<?> sshModuleBaseClass =
-          Class.forName("com.google.gerrit.sshd.CommandModule");
+      Class<?> httpModuleBaseClass = ServletModule.class;
+      Class<?> sshModuleBaseClass = Class.forName("com.google.gerrit.sshd.CommandModule");
       sshModuleClass = null;
       httpModuleClass = null;
       sysModuleClass = null;
@@ -125,33 +129,33 @@
         }
 
         if (sshModuleBaseClass.isAssignableFrom(clazz)) {
-          sshModuleClass =
-              getUniqueGuiceModule(sshModuleBaseClass, sshModuleClass, clazz);
+          sshModuleClass = getUniqueGuiceModule(sshModuleBaseClass, sshModuleClass, clazz);
         } else if (httpModuleBaseClass.isAssignableFrom(clazz)) {
-          httpModuleClass =
-              getUniqueGuiceModule(httpModuleBaseClass, httpModuleClass, clazz);
+          httpModuleClass = getUniqueGuiceModule(httpModuleBaseClass, httpModuleClass, clazz);
         } else if (sysModuleBaseClass.isAssignableFrom(clazz)) {
-          sysModuleClass =
-              getUniqueGuiceModule(sysModuleBaseClass, sysModuleClass, clazz);
+          sysModuleClass = getUniqueGuiceModule(sysModuleBaseClass, sysModuleClass, clazz);
         }
       }
     } catch (ClassNotFoundException e) {
-      throw new IOException(
-          "Cannot find base Gerrit classes for Guice Plugin Modules", e);
+      throw new IOException("Cannot find base Gerrit classes for Guice Plugin Modules", e);
     }
   }
 
-  private Class<?> getUniqueGuiceModule(Class<?> guiceModuleBaseClass,
-      Class<?> existingGuiceModuleName, Class<?> newGuiceModuleClass) {
-    checkState(existingGuiceModuleName == null,
-        "Multiple %s implementations: %s, %s", guiceModuleBaseClass,
-        existingGuiceModuleName, newGuiceModuleClass);
+  private Class<?> getUniqueGuiceModule(
+      Class<?> guiceModuleBaseClass,
+      Class<?> existingGuiceModuleName,
+      Class<?> newGuiceModuleClass) {
+    checkState(
+        existingGuiceModuleName == null,
+        "Multiple %s implementations: %s, %s",
+        guiceModuleBaseClass,
+        existingGuiceModuleName,
+        newGuiceModuleClass);
     return newGuiceModuleClass;
   }
 
-  private String getExportAnnotationValue(Class<?> scriptClass,
-      Class<? extends Annotation> annotation) {
-    return annotation == Export.class ? scriptClass.getAnnotation(Export.class)
-        .value() : "";
+  private String getExportAnnotationValue(
+      Class<?> scriptClass, Class<? extends Annotation> annotation) {
+    return annotation == Export.class ? scriptClass.getAnnotation(Export.class).value() : "";
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
index 438add6..37eabe9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.server.plugins.PluginGuiceEnvironment.is;
 
 import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.annotations.Listen;
@@ -31,10 +31,6 @@
 import com.google.inject.Module;
 import com.google.inject.Scopes;
 import com.google.inject.TypeLiteral;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.ParameterizedType;
@@ -42,6 +38,8 @@
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class AutoRegisterModules {
   private static final Logger log = LoggerFactory.getLogger(AutoRegisterModules.class);
@@ -54,14 +52,15 @@
   private final ModuleGenerator httpGen;
 
   private Set<Class<?>> sysSingletons;
-  private Multimap<TypeLiteral<?>, Class<?>> sysListen;
+  private ListMultimap<TypeLiteral<?>, Class<?>> sysListen;
   private String initJs;
 
   Module sysModule;
   Module sshModule;
   Module httpModule;
 
-  AutoRegisterModules(String pluginName,
+  AutoRegisterModules(
+      String pluginName,
       PluginGuiceEnvironment env,
       PluginContentScanner scanner,
       ClassLoader classLoader) {
@@ -69,12 +68,8 @@
     this.env = env;
     this.scanner = scanner;
     this.classLoader = classLoader;
-    this.sshGen = env.hasSshModule()
-        ? env.newSshModuleGenerator()
-        : new ModuleGenerator.NOP();
-    this.httpGen = env.hasHttpModule()
-        ? env.newHttpModuleGenerator()
-        : new ModuleGenerator.NOP();
+    this.sshGen = env.hasSshModule() ? env.newSshModuleGenerator() : new ModuleGenerator.NOP();
+    this.httpGen = env.hasHttpModule() ? env.newHttpModuleGenerator() : new ModuleGenerator.NOP();
   }
 
   AutoRegisterModules discover() throws InvalidPluginException {
@@ -113,8 +108,7 @@
           bind(type).annotatedWith(n).to(impl);
         }
         if (initJs != null) {
-          DynamicSet.bind(binder(), WebUiPlugin.class)
-              .toInstance(new JavaScriptPlugin(initJs));
+          DynamicSet.bind(binder(), WebUiPlugin.class).toInstance(new JavaScriptPlugin(initJs));
         }
       }
     };
@@ -140,9 +134,12 @@
         initJs = STATIC_INIT_JS;
       }
     } catch (IOException e) {
-      log.warn(String.format("Cannot access %s from plugin %s: "
-          + "JavaScript auto-discovered plugin will not be registered",
-          STATIC_INIT_JS, pluginName), e);
+      log.warn(
+          "Cannot access {} from plugin {}: "
+              + "JavaScript auto-discovered plugin will not be registered",
+          STATIC_INIT_JS,
+          pluginName,
+          e);
     }
   }
 
@@ -151,16 +148,18 @@
     try {
       clazz = Class.forName(def.className, false, classLoader);
     } catch (ClassNotFoundException err) {
-      throw new InvalidPluginException(String.format(
-          "Cannot load %s with @Export(\"%s\")",
-          def.className, def.annotationValue), err);
+      throw new InvalidPluginException(
+          String.format("Cannot load %s with @Export(\"%s\")", def.className, def.annotationValue),
+          err);
     }
 
     Export export = clazz.getAnnotation(Export.class);
     if (export == null) {
-      PluginLoader.log.warn(String.format(
-          "In plugin %s asm incorrectly parsed %s with @Export(\"%s\")",
-          pluginName, clazz.getName(), def.annotationValue));
+      log.warn(
+          "In plugin {} asm incorrectly parsed {} with @Export(\"{}\")",
+          pluginName,
+          clazz.getName(),
+          def.annotationValue);
       return;
     }
 
@@ -174,9 +173,9 @@
       listen(clazz, clazz);
       if (cnt == sysListen.size()) {
         // If no bindings were recorded, the extension isn't recognized.
-        throw new InvalidPluginException(String.format(
-            "Class %s with @Export(\"%s\") not supported",
-            clazz.getName(), export.value()));
+        throw new InvalidPluginException(
+            String.format(
+                "Class %s with @Export(\"%s\") not supported", clazz.getName(), export.value()));
       }
     }
   }
@@ -186,23 +185,19 @@
     try {
       clazz = Class.forName(def.className, false, classLoader);
     } catch (ClassNotFoundException err) {
-      throw new InvalidPluginException(String.format(
-          "Cannot load %s with @Listen",
-          def.className), err);
+      throw new InvalidPluginException(
+          String.format("Cannot load %s with @Listen", def.className), err);
     }
 
     Listen listen = clazz.getAnnotation(Listen.class);
     if (listen != null) {
       listen(clazz, clazz);
     } else {
-      PluginLoader.log.warn(String.format(
-          "In plugin %s asm incorrectly parsed %s with @Listen",
-          pluginName, clazz.getName()));
+      log.warn("In plugin {} asm incorrectly parsed {} with @Listen", pluginName, clazz.getName());
     }
   }
 
-  private void listen(java.lang.reflect.Type type, Class<?> clazz)
-      throws InvalidPluginException {
+  private void listen(java.lang.reflect.Type type, Class<?> clazz) throws InvalidPluginException {
     while (type != null) {
       Class<?> rawType;
       if (type instanceof ParameterizedType) {
@@ -227,18 +222,20 @@
           sshGen.listen(tl, clazz);
         } else if (env.hasDynamicMap(tl)) {
           if (clazz.getAnnotation(Export.class) == null) {
-            throw new InvalidPluginException(String.format(
-                "Class %s requires @Export(\"name\") annotation for %s",
-                clazz.getName(), rawType.getName()));
+            throw new InvalidPluginException(
+                String.format(
+                    "Class %s requires @Export(\"name\") annotation for %s",
+                    clazz.getName(), rawType.getName()));
           }
           sysSingletons.add(clazz);
           sysListen.put(tl, clazz);
           httpGen.listen(tl, clazz);
           sshGen.listen(tl, clazz);
         } else {
-          throw new InvalidPluginException(String.format(
-              "Cannot register %s, server does not accept %s",
-              clazz.getName(), rawType.getName()));
+          throw new InvalidPluginException(
+              String.format(
+                  "Cannot register %s, server does not accept %s",
+                  clazz.getName(), rawType.getName()));
         }
         return;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
index 3256d7f..d592d17 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.inject.internal.UniqueAnnotations;
-
 import java.lang.annotation.Annotation;
 
 public final class AutoRegisterUtil {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java
index 9827812..5a60ee2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java
@@ -18,8 +18,12 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.jar.JarFile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class CleanupHandle {
+  private static final Logger log = LoggerFactory.getLogger(CleanupHandle.class);
+
   private final Path tmp;
   private final JarFile jarFile;
 
@@ -32,14 +36,17 @@
     try {
       jarFile.close();
     } catch (IOException err) {
-      PluginLoader.log.error("Cannot close " + jarFile.getName(), err);
+      log.error("Cannot close " + jarFile.getName(), err);
     }
     try {
       Files.deleteIfExists(tmp);
-      PluginLoader.log.info("Cleaned plugin " + tmp.getFileName());
+      log.info("Cleaned plugin " + tmp.getFileName());
     } catch (IOException e) {
-      PluginLoader.log.warn("Cannot delete " + tmp.toAbsolutePath()
-          + ", retrying to delete it on termination of the virtual machine", e);
+      log.warn(
+          "Cannot delete "
+              + tmp.toAbsolutePath()
+              + ", retrying to delete it on termination of the virtual machine",
+          e);
       tmp.toFile().deleteOnExit();
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CopyConfigModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CopyConfigModule.java
index 1d4233a..9f937e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CopyConfigModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CopyConfigModule.java
@@ -29,25 +29,21 @@
 import com.google.inject.Inject;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
-
+import java.nio.file.Path;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
-import java.nio.file.Path;
-
 /**
  * Copies critical objects from the {@code dbInjector} into a plugin.
- * <p>
- * Most explicit bindings are copied automatically from the cfgInjector and
- * sysInjector to be made available to a plugin's private world. This module is
- * necessary to get things bound in the dbInjector that are not otherwise easily
- * available, but that a plugin author might expect to exist.
+ *
+ * <p>Most explicit bindings are copied automatically from the cfgInjector and sysInjector to be
+ * made available to a plugin's private world. This module is necessary to get things bound in the
+ * dbInjector that are not otherwise easily available, but that a plugin author might expect to
+ * exist.
  */
 @Singleton
 class CopyConfigModule extends AbstractModule {
-  @Inject
-  @SitePath
-  private Path sitePath;
+  @Inject @SitePath private Path sitePath;
 
   @Provides
   @SitePath
@@ -55,25 +51,21 @@
     return sitePath;
   }
 
-  @Inject
-  private SitePaths sitePaths;
+  @Inject private SitePaths sitePaths;
 
   @Provides
   SitePaths getSitePaths() {
     return sitePaths;
   }
 
-  @Inject
-  private TrackingFooters trackingFooters;
+  @Inject private TrackingFooters trackingFooters;
 
   @Provides
   TrackingFooters getTrackingFooters() {
     return trackingFooters;
   }
 
-  @Inject
-  @GerritServerConfig
-  private Config gerritServerConfig;
+  @Inject @GerritServerConfig private Config gerritServerConfig;
 
   @Provides
   @GerritServerConfig
@@ -81,25 +73,21 @@
     return gerritServerConfig;
   }
 
-  @Inject
-  private SchemaFactory<ReviewDb> schemaFactory;
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
 
   @Provides
   SchemaFactory<ReviewDb> getSchemaFactory() {
     return schemaFactory;
   }
 
-  @Inject
-  private GitRepositoryManager gitRepositoryManager;
+  @Inject private GitRepositoryManager gitRepositoryManager;
 
   @Provides
   GitRepositoryManager getGitRepositoryManager() {
     return gitRepositoryManager;
   }
 
-  @Inject
-  @AnonymousCowardName
-  private String anonymousCowardName;
+  @Inject @AnonymousCowardName private String anonymousCowardName;
 
   @Provides
   @AnonymousCowardName
@@ -107,8 +95,7 @@
     return anonymousCowardName;
   }
 
-  @Inject
-  private GerritPersonIdentProvider serverIdentProvider;
+  @Inject private GerritPersonIdentProvider serverIdentProvider;
 
   @Provides
   @GerritPersonIdent
@@ -116,8 +103,7 @@
     return serverIdentProvider.get();
   }
 
-  @Inject
-  private SecureStore secureStore;
+  @Inject private SecureStore secureStore;
 
   @Provides
   SecureStore getSecureStore() {
@@ -125,10 +111,8 @@
   }
 
   @Inject
-  CopyConfigModule() {
-  }
+  CopyConfigModule() {}
 
   @Override
-  protected void configure() {
-  }
+  protected void configure() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
index d92dce0..b63c6c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -27,8 +27,7 @@
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 class DisablePlugin implements RestModifyView<PluginResource, Input> {
-  static class Input {
-  }
+  static class Input {}
 
   private final PluginLoader loader;
 
@@ -38,11 +37,9 @@
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input)
-      throws MethodNotAllowedException {
+  public PluginInfo apply(PluginResource resource, Input input) throws MethodNotAllowedException {
     if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException(
-          "remote plugin administration is disabled");
+      throw new MethodNotAllowedException("remote plugin administration is disabled");
     }
     String name = resource.getName();
     loader.disablePlugins(ImmutableSet.of(name));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
index b6f8260..c3e52cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
@@ -24,15 +24,13 @@
 import com.google.gerrit.server.plugins.ListPlugins.PluginInfo;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.PrintWriter;
 import java.io.StringWriter;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 class EnablePlugin implements RestModifyView<PluginResource, Input> {
-  static class Input {
-  }
+  static class Input {}
 
   private final PluginLoader loader;
 
@@ -45,8 +43,7 @@
   public PluginInfo apply(PluginResource resource, Input input)
       throws ResourceConflictException, MethodNotAllowedException {
     if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException(
-          "remote plugin administration is disabled");
+      throw new MethodNotAllowedException("remote plugin administration is disabled");
     }
     String name = resource.getName();
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
index 2f780bf..3882008 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.server.plugins.InstallPlugin.Input;
 import com.google.gerrit.server.plugins.ListPlugins.PluginInfo;
 import com.google.inject.Inject;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.PrintWriter;
@@ -37,8 +36,7 @@
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 class InstallPlugin implements RestModifyView<TopLevelResource, Input> {
   static class Input {
-    @DefaultInput
-    String url;
+    @DefaultInput String url;
     RawInput raw;
   }
 
@@ -61,11 +59,8 @@
     try {
       try (InputStream in = openStream(input)) {
         String pluginName = loader.installPluginFromStream(name, in);
-        ListPlugins.PluginInfo info =
-            new ListPlugins.PluginInfo(loader.get(pluginName));
-        return created
-            ? Response.created(info)
-            : Response.ok(info);
+        ListPlugins.PluginInfo info = new ListPlugins.PluginInfo(loader.get(pluginName));
+        return created ? Response.created(info) : Response.ok(info);
       }
     } catch (PluginInstallException e) {
       StringWriter buf = new StringWriter();
@@ -83,8 +78,7 @@
     }
   }
 
-  private InputStream openStream(Input input)
-      throws IOException, BadRequestException {
+  private InputStream openStream(Input input) throws IOException, BadRequestException {
     if (input.raw != null) {
       return input.raw.getInputStream();
     }
@@ -108,7 +102,7 @@
     public Response<PluginInfo> apply(PluginResource resource, Input input)
         throws BadRequestException, MethodNotAllowedException, IOException {
       return new InstallPlugin(loader, resource.getName(), false)
-        .apply(TopLevelResource.INSTANCE, input);
+          .apply(TopLevelResource.INSTANCE, input);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index fa913b2..b10d8ab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -21,11 +21,6 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.MalformedURLException;
@@ -40,6 +35,9 @@
 import java.util.List;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
+import org.eclipse.jgit.internal.storage.file.FileSnapshot;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class JarPluginProvider implements ServerPluginProvider {
   static final String PLUGIN_TMP_PREFIX = "plugin_";
@@ -50,8 +48,7 @@
   private final PluginConfigFactory configFactory;
 
   @Inject
-  JarPluginProvider(SitePaths sitePaths,
-      PluginConfigFactory configFactory) {
+  JarPluginProvider(SitePaths sitePaths, PluginConfigFactory configFactory) {
     this.tmpDir = sitePaths.tmp_dir;
     this.configFactory = configFactory;
   }
@@ -59,31 +56,28 @@
   @Override
   public boolean handles(Path srcPath) {
     String fileName = srcPath.getFileName().toString();
-    return fileName.endsWith(JAR_EXTENSION)
-        || fileName.endsWith(JAR_EXTENSION + ".disabled");
+    return fileName.endsWith(JAR_EXTENSION) || fileName.endsWith(JAR_EXTENSION + ".disabled");
   }
 
   @Override
   public String getPluginName(Path srcPath) {
     try {
-      return MoreObjects.firstNonNull(getJarPluginName(srcPath),
-          PluginLoader.nameOf(srcPath));
+      return MoreObjects.firstNonNull(getJarPluginName(srcPath), PluginLoader.nameOf(srcPath));
     } catch (IOException e) {
-      throw new IllegalArgumentException("Invalid plugin file " + srcPath
-          + ": cannot get plugin name", e);
+      throw new IllegalArgumentException(
+          "Invalid plugin file " + srcPath + ": cannot get plugin name", e);
     }
   }
 
   public static String getJarPluginName(Path srcPath) throws IOException {
     try (JarFile jarFile = new JarFile(srcPath.toFile())) {
-      return jarFile.getManifest().getMainAttributes()
-          .getValue("Gerrit-PluginName");
+      return jarFile.getManifest().getMainAttributes().getValue("Gerrit-PluginName");
     }
   }
 
   @Override
-  public ServerPlugin get(Path srcPath, FileSnapshot snapshot,
-      PluginDescription description) throws InvalidPluginException {
+  public ServerPlugin get(Path srcPath, FileSnapshot snapshot, PluginDescription description)
+      throws InvalidPluginException {
     try {
       String name = getPluginName(srcPath);
       String extension = getExtension(srcPath);
@@ -115,16 +109,16 @@
     return PLUGIN_TMP_PREFIX + name + "_" + fmt.format(new Date()) + "_";
   }
 
-  public static Path storeInTemp(String pluginName, InputStream in,
-      SitePaths sitePaths) throws IOException {
+  public static Path storeInTemp(String pluginName, InputStream in, SitePaths sitePaths)
+      throws IOException {
     if (!Files.exists(sitePaths.tmp_dir)) {
       Files.createDirectories(sitePaths.tmp_dir);
     }
     return asTemp(in, tempNameFor(pluginName), ".jar", sitePaths.tmp_dir);
   }
 
-  private ServerPlugin loadJarPlugin(String name, Path srcJar,
-      FileSnapshot snapshot, Path tmp, PluginDescription description)
+  private ServerPlugin loadJarPlugin(
+      String name, Path srcJar, FileSnapshot snapshot, Path tmp, PluginDescription description)
       throws IOException, InvalidPluginException, MalformedURLException {
     JarFile jarFile = new JarFile(tmp.toFile());
     boolean keep = false;
@@ -137,23 +131,29 @@
       if (overlay != null) {
         Path classes = Paths.get(overlay).resolve(name).resolve("main");
         if (Files.isDirectory(classes)) {
-          log.info(String.format("plugin %s: including %s", name, classes));
+          log.info("plugin {}: including {}", name, classes);
           urls.add(classes.toUri().toURL());
         }
       }
       urls.add(tmp.toUri().toURL());
 
       ClassLoader pluginLoader =
-          new URLClassLoader(urls.toArray(new URL[urls.size()]),
-              PluginLoader.parentFor(type));
+          new URLClassLoader(urls.toArray(new URL[urls.size()]), PluginLoader.parentFor(type));
 
       JarScanner jarScanner = createJarScanner(tmp);
       PluginConfig pluginConfig = configFactory.getFromGerritConfig(name);
 
-      ServerPlugin plugin = new ServerPlugin(name, description.canonicalUrl,
-          description.user, srcJar, snapshot, jarScanner,
-          description.dataDir, pluginLoader,
-          pluginConfig.getString("metricsPrefix", null));
+      ServerPlugin plugin =
+          new ServerPlugin(
+              name,
+              description.canonicalUrl,
+              description.user,
+              srcJar,
+              snapshot,
+              jarScanner,
+              description.dataDir,
+              pluginLoader,
+              pluginConfig.getString("metricsPrefix", null));
       plugin.setCleanupHandle(new CleanupHandle(tmp, jarFile));
       keep = true;
       return plugin;
@@ -164,8 +164,7 @@
     }
   }
 
-  private JarScanner createJarScanner(Path srcJar)
-      throws InvalidPluginException {
+  private JarScanner createJarScanner(Path srcJar) throws InvalidPluginException {
     try {
       return new JarScanner(srcJar);
     } catch (IOException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
index 1f612a3..863ef3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -17,27 +17,12 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.Iterables.transform;
 
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
-
-import org.eclipse.jgit.util.IO;
-import org.objectweb.asm.AnnotationVisitor;
-import org.objectweb.asm.Attribute;
-import org.objectweb.asm.ClassReader;
-import org.objectweb.asm.ClassVisitor;
-import org.objectweb.asm.FieldVisitor;
-import org.objectweb.asm.MethodVisitor;
-import org.objectweb.asm.Opcodes;
-import org.objectweb.asm.Type;
-
+import com.google.common.collect.MultimapBuilder;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
@@ -50,24 +35,28 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.jar.Attributes;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
+import org.eclipse.jgit.util.IO;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.Attribute;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-public class JarScanner implements PluginContentScanner {
-  private static final int SKIP_ALL = ClassReader.SKIP_CODE
-      | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
-  private static final Function<ClassData, ExtensionMetaData> CLASS_DATA_TO_EXTENSION_META_DATA =
-      new Function<ClassData, ExtensionMetaData>() {
-        @Override
-        public ExtensionMetaData apply(ClassData classData) {
-          return new ExtensionMetaData(classData.className,
-              classData.annotationValue);
-        }
-      };
-
+public class JarScanner implements PluginContentScanner, AutoCloseable {
+  private static final Logger log = LoggerFactory.getLogger(JarScanner.class);
+  private static final int SKIP_ALL =
+      ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES;
   private final JarFile jarFile;
 
   public JarScanner(Path src) throws IOException {
@@ -79,9 +68,9 @@
       String pluginName, Iterable<Class<? extends Annotation>> annotations)
       throws InvalidPluginException {
     Set<String> descriptors = new HashSet<>();
-    Multimap<String, JarScanner.ClassData> rawMap = ArrayListMultimap.create();
-    Map<Class<? extends Annotation>, String> classObjToClassDescr =
-        new HashMap<>();
+    ListMultimap<String, JarScanner.ClassData> rawMap =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    Map<Class<? extends Annotation>, String> classObjToClassDescr = new HashMap<>();
 
     for (Class<? extends Annotation> annotation : annotations) {
       String descriptor = Type.getType(annotation).getDescriptor();
@@ -102,19 +91,25 @@
       } catch (IOException err) {
         throw new InvalidPluginException("Cannot auto-register", err);
       } catch (RuntimeException err) {
-        PluginLoader.log.warn(String.format(
-            "Plugin %s has invalid class file %s inside of %s", pluginName,
-            entry.getName(), jarFile.getName()), err);
+        log.warn(
+            "Plugin {} has invalid class file {} inside of {}",
+            pluginName,
+            entry.getName(),
+            jarFile.getName(),
+            err);
         continue;
       }
 
       if (!Strings.isNullOrEmpty(def.annotationName)) {
         if (def.isConcrete()) {
-            rawMap.put(def.annotationName, def);
+          rawMap.put(def.annotationName, def);
         } else {
-          PluginLoader.log.warn(String.format(
-              "Plugin %s tries to @%s(\"%s\") abstract class %s", pluginName,
-              def.annotationName, def.annotationValue, def.className));
+          log.warn(
+              "Plugin {} tries to @{}(\"{}\") abstract class {}",
+              pluginName,
+              def.annotationName,
+              def.annotationValue,
+              def.className);
         }
       }
     }
@@ -125,11 +120,11 @@
     for (Class<? extends Annotation> annotoation : annotations) {
       String descr = classObjToClassDescr.get(annotoation);
       Collection<ClassData> discoverdData = rawMap.get(descr);
-      Collection<ClassData> values =
-          firstNonNull(discoverdData, Collections.<ClassData> emptySet());
+      Collection<ClassData> values = firstNonNull(discoverdData, Collections.<ClassData>emptySet());
 
-      result.put(annotoation,
-          transform(values, CLASS_DATA_TO_EXTENSION_META_DATA));
+      result.put(
+          annotoation,
+          transform(values, cd -> new ExtensionMetaData(cd.className, cd.annotationValue)));
     }
 
     return result.build();
@@ -139,6 +134,11 @@
     return findSubClassesOf(superClass.getName());
   }
 
+  @Override
+  public void close() throws IOException {
+    jarFile.close();
+  }
+
   private List<String> findSubClassesOf(String superClass) throws IOException {
     String name = superClass.replace('.', '/');
 
@@ -154,8 +154,7 @@
       try {
         new ClassReader(read(jarFile, entry)).accept(def, SKIP_ALL);
       } catch (RuntimeException err) {
-        PluginLoader.log.warn(String.format("Jar %s has invalid class file %s",
-            jarFile.getName(), entry.getName()), err);
+        log.warn("Jar {} has invalid class file {}", jarFile.getName(), entry.getName(), err);
         continue;
       }
 
@@ -183,8 +182,7 @@
     return false;
   }
 
-  private static byte[] read(JarFile jarFile, JarEntry entry)
-      throws IOException {
+  private static byte[] read(JarFile jarFile, JarEntry entry) throws IOException {
     byte[] data = new byte[(int) entry.getSize()];
     try (InputStream in = jarFile.getInputStream(entry)) {
       IO.readFully(in, data, 0, data.length);
@@ -199,21 +197,25 @@
     String annotationName;
     String annotationValue;
     String[] interfaces;
-    Iterable<String> exports;
+    Collection<String> exports;
 
-    private ClassData(Iterable<String> exports) {
-      super(Opcodes.ASM4);
+    private ClassData(Collection<String> exports) {
+      super(Opcodes.ASM5);
       this.exports = exports;
     }
 
     boolean isConcrete() {
-      return (access & Opcodes.ACC_ABSTRACT) == 0
-          && (access & Opcodes.ACC_INTERFACE) == 0;
+      return (access & Opcodes.ACC_ABSTRACT) == 0 && (access & Opcodes.ACC_INTERFACE) == 0;
     }
 
     @Override
-    public void visit(int version, int access, String name, String signature,
-        String superName, String[] interfaces) {
+    public void visit(
+        int version,
+        int access,
+        String name,
+        String signature,
+        String superName,
+        String[] interfaces) {
       this.className = Type.getObjectType(name).getClassName();
       this.access = access;
       this.superName = superName;
@@ -221,9 +223,11 @@
 
     @Override
     public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
-      Optional<String> found =
-          Iterables.tryFind(exports, Predicates.equalTo(desc));
-      if (visible && found.isPresent()) {
+      if (!visible) {
+        return null;
+      }
+      Optional<String> found = exports.stream().filter(x -> x.equals(desc)).findAny();
+      if (found.isPresent()) {
         annotationName = desc;
         return new AbstractAnnotationVisitor() {
           @Override
@@ -236,42 +240,35 @@
     }
 
     @Override
-    public void visitSource(String arg0, String arg1) {
-    }
+    public void visitSource(String arg0, String arg1) {}
 
     @Override
-    public void visitOuterClass(String arg0, String arg1, String arg2) {
-    }
+    public void visitOuterClass(String arg0, String arg1, String arg2) {}
 
     @Override
-    public MethodVisitor visitMethod(int arg0, String arg1, String arg2,
-        String arg3, String[] arg4) {
+    public MethodVisitor visitMethod(
+        int arg0, String arg1, String arg2, String arg3, String[] arg4) {
       return null;
     }
 
     @Override
-    public void visitInnerClass(String arg0, String arg1, String arg2, int arg3) {
-    }
+    public void visitInnerClass(String arg0, String arg1, String arg2, int arg3) {}
 
     @Override
-    public FieldVisitor visitField(int arg0, String arg1, String arg2,
-        String arg3, Object arg4) {
+    public FieldVisitor visitField(int arg0, String arg1, String arg2, String arg3, Object arg4) {
       return null;
     }
 
     @Override
-    public void visitEnd() {
-    }
+    public void visitEnd() {}
 
     @Override
-    public void visitAttribute(Attribute arg0) {
-    }
+    public void visitAttribute(Attribute arg0) {}
   }
 
-  private abstract static class AbstractAnnotationVisitor extends
-      AnnotationVisitor {
+  private abstract static class AbstractAnnotationVisitor extends AnnotationVisitor {
     AbstractAnnotationVisitor() {
-      super(Opcodes.ASM4);
+      super(Opcodes.ASM5);
     }
 
     @Override
@@ -285,19 +282,17 @@
     }
 
     @Override
-    public void visitEnum(String arg0, String arg1, String arg2) {
-    }
+    public void visitEnum(String arg0, String arg1, String arg2) {}
 
     @Override
-    public void visitEnd() {
-    }
+    public void visitEnd() {}
   }
 
   @Override
   public Optional<PluginEntry> getEntry(String resourcePath) throws IOException {
     JarEntry jarEntry = jarFile.getJarEntry(resourcePath);
     if (jarEntry == null || jarEntry.getSize() == 0) {
-      return Optional.absent();
+      return Optional.empty();
     }
 
     return Optional.of(resourceOf(jarEntry));
@@ -305,26 +300,22 @@
 
   @Override
   public Enumeration<PluginEntry> entries() {
-    return Collections.enumeration(Lists.transform(
-        Collections.list(jarFile.entries()),
-        new Function<JarEntry, PluginEntry>() {
-          @Override
-          public PluginEntry apply(JarEntry jarEntry) {
-            try {
-              return resourceOf(jarEntry);
-            } catch (IOException e) {
-              throw new IllegalArgumentException("Cannot convert jar entry "
-                  + jarEntry + " to a resource", e);
-            }
-          }
-        }));
+    return Collections.enumeration(
+        Lists.transform(
+            Collections.list(jarFile.entries()),
+            jarEntry -> {
+              try {
+                return resourceOf(jarEntry);
+              } catch (IOException e) {
+                throw new IllegalArgumentException(
+                    "Cannot convert jar entry " + jarEntry + " to a resource", e);
+              }
+            }));
   }
 
   @Override
-  public InputStream getInputStream(PluginEntry entry)
-      throws IOException {
-    return jarFile.getInputStream(jarFile
-        .getEntry(entry.getName()));
+  public InputStream getInputStream(PluginEntry entry) throws IOException {
+    return jarFile.getInputStream(jarFile.getEntry(entry.getName()));
   }
 
   @Override
@@ -333,17 +324,20 @@
   }
 
   private PluginEntry resourceOf(JarEntry jarEntry) throws IOException {
-    return new PluginEntry(jarEntry.getName(), jarEntry.getTime(),
-        Optional.of(jarEntry.getSize()), attributesOf(jarEntry));
+    return new PluginEntry(
+        jarEntry.getName(),
+        jarEntry.getTime(),
+        Optional.of(jarEntry.getSize()),
+        attributesOf(jarEntry));
   }
 
-  private Map<Object, String> attributesOf(JarEntry jarEntry)
-      throws IOException {
+  private Map<Object, String> attributesOf(JarEntry jarEntry) throws IOException {
     Attributes attributes = jarEntry.getAttributes();
     if (attributes == null) {
       return Collections.emptyMap();
     }
-    return Maps.transformEntries(attributes,
+    return Maps.transformEntries(
+        attributes,
         new Maps.EntryTransformer<Object, Object, String>() {
           @Override
           public String transformEntry(Object key, Object value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
index 544cc5b..625bf9e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
@@ -24,16 +24,13 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
-
 import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 
 class JsPlugin extends Plugin {
   private Injector sysInjector;
 
-  JsPlugin(String name, Path srcFile, PluginUser pluginUser,
-      FileSnapshot snapshot) {
+  JsPlugin(String name, Path srcFile, PluginUser pluginUser, FileSnapshot snapshot) {
     super(name, srcFile, pluginUser, snapshot, ApiType.JS);
   }
 
@@ -52,8 +49,7 @@
   public void start(PluginGuiceEnvironment env) throws Exception {
     manager = new LifecycleManager();
     String fileName = getSrcFile().getFileName().toString();
-    sysInjector =
-        Guice.createInjector(new StandaloneJsPluginModule(getName(), fileName));
+    sysInjector = Guice.createInjector(new StandaloneJsPluginModule(getName(), fileName));
     manager.start();
   }
 
@@ -99,8 +95,7 @@
     @Override
     protected void configure() {
       bind(String.class).annotatedWith(PluginName.class).toInstance(pluginName);
-      DynamicSet.bind(binder(), WebUiPlugin.class).toInstance(
-          new JavaScriptPlugin(fileName));
+      DynamicSet.bind(binder(), WebUiPlugin.class).toInstance(new JavaScriptPlugin(fileName));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
index b9871ab..c95ae85 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -16,24 +16,23 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.OutputFormat;
-import com.google.gson.JsonElement;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.io.PrintWriter;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
+import java.util.SortedMap;
 import java.util.TreeMap;
+import org.kohsuke.args4j.Option;
 
 /** List the installed plugins. */
 @RequiresCapability(GlobalCapability.VIEW_PLUGINS)
@@ -44,7 +43,10 @@
   @Option(name = "--format", usage = "(deprecated) output format")
   private OutputFormat format = OutputFormat.TEXT;
 
-  @Option(name = "--all", aliases = {"-a"}, usage = "List all plugins, including disabled plugins")
+  @Option(
+      name = "--all",
+      aliases = {"-a"},
+      usage = "List all plugins, including disabled plugins")
   private boolean all;
 
   @Inject
@@ -67,19 +69,22 @@
     return display(null);
   }
 
-  public JsonElement display(PrintWriter stdout) {
-    Map<String, PluginInfo> output = new TreeMap<>();
+  public SortedMap<String, PluginInfo> display(@Nullable PrintWriter stdout) {
+    SortedMap<String, PluginInfo> output = new TreeMap<>();
     List<Plugin> plugins = Lists.newArrayList(pluginLoader.getPlugins(all));
-    Collections.sort(plugins, new Comparator<Plugin>() {
-      @Override
-      public int compare(Plugin a, Plugin b) {
-        return a.getName().compareTo(b.getName());
-      }
-    });
+    Collections.sort(
+        plugins,
+        new Comparator<Plugin>() {
+          @Override
+          public int compare(Plugin a, Plugin b) {
+            return a.getName().compareTo(b.getName());
+          }
+        });
 
     if (!format.isJson()) {
       stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
-      stdout.print("-------------------------------------------------------------------------------\n");
+      stdout.print(
+          "-------------------------------------------------------------------------------\n");
     }
 
     for (Plugin p : plugins) {
@@ -87,7 +92,9 @@
       if (format.isJson()) {
         output.put(p.getName(), info);
       } else {
-        stdout.format("%-30s %-10s %-8s %s\n", p.getName(),
+        stdout.format(
+            "%-30s %-10s %-8s %s\n",
+            p.getName(),
             Strings.nullToEmpty(info.version),
             p.isDisabled() ? "DISABLED" : "ENABLED",
             p.getSrcFile().getFileName());
@@ -95,12 +102,11 @@
     }
 
     if (stdout == null) {
-      return OutputFormat.JSON.newGson().toJsonTree(
-          output,
-          new TypeToken<Map<String, Object>>() {}.getType());
+      return output;
     } else if (format.isJson()) {
-      format.newGson().toJson(output,
-          new TypeToken<Map<String, PluginInfo>>() {}.getType(), stdout);
+      format
+          .newGson()
+          .toJson(output, new TypeToken<Map<String, PluginInfo>>() {}.getType(), stdout);
       stdout.print('\n');
     }
     stdout.flush();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
index cf38310..1453854 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
@@ -14,32 +14,25 @@
 
 package com.google.gerrit.server.plugins;
 
-import com.google.common.base.Function;
-import com.google.common.base.Joiner;
-import com.google.common.collect.Iterables;
+import static java.util.stream.Collectors.joining;
 
 import java.nio.file.Path;
+import java.util.stream.StreamSupport;
 
 class MultipleProvidersForPluginException extends IllegalArgumentException {
   private static final long serialVersionUID = 1L;
 
-  MultipleProvidersForPluginException(Path pluginSrcPath,
-      Iterable<ServerPluginProvider> providersHandlers) {
-    super(pluginSrcPath.toAbsolutePath()
-        + " is claimed to be handled by more than one plugin provider: "
-        + providersListToString(providersHandlers));
+  MultipleProvidersForPluginException(
+      Path pluginSrcPath, Iterable<ServerPluginProvider> providersHandlers) {
+    super(
+        pluginSrcPath.toAbsolutePath()
+            + " is claimed to be handled by more than one plugin provider: "
+            + providersListToString(providersHandlers));
   }
 
-  private static String providersListToString(
-      Iterable<ServerPluginProvider> providersHandlers) {
-    Iterable<String> providerNames =
-        Iterables.transform(providersHandlers,
-            new Function<ServerPluginProvider, String>() {
-              @Override
-              public String apply(ServerPluginProvider provider) {
-                return provider.getProviderPluginName();
-              }
-            });
-    return Joiner.on(", ").join(providerNames);
+  private static String providersListToString(Iterable<ServerPluginProvider> providersHandlers) {
+    return StreamSupport.stream(providersHandlers.spliterator(), false)
+        .map(ServerPluginProvider::getProviderPluginName)
+        .collect(joining(", "));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
index 4fe0c2a..5759705 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
@@ -21,19 +21,19 @@
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.server.PluginUser;
 import com.google.inject.Injector;
-
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
-
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.Manifest;
+import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 
 public abstract class Plugin {
   public enum ApiType {
-    EXTENSION, PLUGIN, JS
+    EXTENSION,
+    PLUGIN,
+    JS
   }
 
   /** Unique key that changes whenever a plugin reloads. */
@@ -54,8 +54,7 @@
   static ApiType getApiType(Manifest manifest) throws InvalidPluginException {
     Attributes main = manifest.getMainAttributes();
     String v = main.getValue("Gerrit-ApiType");
-    if (Strings.isNullOrEmpty(v)
-        || ApiType.EXTENSION.name().equalsIgnoreCase(v)) {
+    if (Strings.isNullOrEmpty(v) || ApiType.EXTENSION.name().equalsIgnoreCase(v)) {
       return ApiType.EXTENSION;
     } else if (ApiType.PLUGIN.name().equalsIgnoreCase(v)) {
       return ApiType.PLUGIN;
@@ -79,18 +78,15 @@
 
   private List<ReloadableRegistrationHandle<?>> reloadableHandles;
 
-  public Plugin(String name,
-      Path srcPath,
-      PluginUser pluginUser,
-      FileSnapshot snapshot,
-      ApiType apiType) {
+  public Plugin(
+      String name, Path srcPath, PluginUser pluginUser, FileSnapshot snapshot, ApiType apiType) {
     this.name = name;
     this.srcFile = srcPath;
     this.apiType = apiType;
     this.snapshot = snapshot;
     this.pluginUser = pluginUser;
     this.cacheKey = new Plugin.CacheKey(name);
-    this.disabled = srcPath.getFileName().toString().endsWith(".disabled");
+    this.disabled = srcPath != null && srcPath.getFileName().toString().endsWith(".disabled");
   }
 
   public CleanupHandle getCleanupHandle() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginCleanerTask.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
index 6c4c451..390f0e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
@@ -18,12 +18,15 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 class PluginCleanerTask implements Runnable {
+  private static final Logger log = LoggerFactory.getLogger(PluginCleanerTask.class);
+
   private final WorkQueue workQueue;
   private final PluginLoader loader;
   private volatile int pending;
@@ -55,10 +58,10 @@
 
       if (0 < left) {
         long waiting = TimeUtil.nowMs() - start;
-        PluginLoader.log.warn(String.format(
-            "%d plugins still waiting to be reclaimed after %d minutes",
+        log.warn(
+            "{} plugins still waiting to be reclaimed after {} minutes",
             pending,
-            TimeUnit.MILLISECONDS.toMinutes(waiting)));
+            TimeUnit.MILLISECONDS.toMinutes(waiting));
         attempts = Math.min(attempts + 1, 15);
         ensureScheduled();
       } else {
@@ -87,15 +90,9 @@
   private void ensureScheduled() {
     if (self == null && 0 < pending) {
       if (attempts == 1) {
-        self = workQueue.getDefaultQueue().schedule(
-            this,
-            30,
-            TimeUnit.SECONDS);
+        self = workQueue.getDefaultQueue().schedule(this, 30, TimeUnit.SECONDS);
       } else {
-        self = workQueue.getDefaultQueue().schedule(
-            this,
-            attempts + 1,
-            TimeUnit.MINUTES);
+        self = workQueue.getDefaultQueue().schedule(this, attempts + 1, TimeUnit.MINUTES);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
index 15bb92f..b19d6de 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginContentScanner.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.plugins;
 
-import com.google.common.base.Optional;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
@@ -23,55 +21,52 @@
 import java.util.Collections;
 import java.util.Enumeration;
 import java.util.Map;
+import java.util.Optional;
 import java.util.jar.Manifest;
 
 /**
  * Scans the plugin returning classes and resources.
  *
- * Gerrit uses the scanner to automatically discover the classes
- * and resources exported by the plugin for auto discovery
- * of exported SSH commands, Servlets and listeners.
+ * <p>Gerrit uses the scanner to automatically discover the classes and resources exported by the
+ * plugin for auto discovery of exported SSH commands, Servlets and listeners.
  */
 public interface PluginContentScanner {
 
-  /**
-   * Scanner without resources.
-   */
-  PluginContentScanner EMPTY = new PluginContentScanner() {
-    @Override
-    public Manifest getManifest() throws IOException {
-      return new Manifest();
-    }
+  /** Scanner without resources. */
+  PluginContentScanner EMPTY =
+      new PluginContentScanner() {
+        @Override
+        public Manifest getManifest() throws IOException {
+          return new Manifest();
+        }
 
-    @Override
-    public Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
-        String pluginName, Iterable<Class<? extends Annotation>> annotations)
-        throws InvalidPluginException {
-     return Collections.emptyMap();
-    }
+        @Override
+        public Map<Class<? extends Annotation>, Iterable<ExtensionMetaData>> scan(
+            String pluginName, Iterable<Class<? extends Annotation>> annotations)
+            throws InvalidPluginException {
+          return Collections.emptyMap();
+        }
 
-    @Override
-    public Optional<PluginEntry> getEntry(String resourcePath)
-        throws IOException {
-      return Optional.absent();
-    }
+        @Override
+        public Optional<PluginEntry> getEntry(String resourcePath) {
+          return Optional.empty();
+        }
 
-    @Override
-    public InputStream getInputStream(PluginEntry entry) throws IOException {
-      throw new NoSuchFileException("Empty plugin");
-    }
+        @Override
+        public InputStream getInputStream(PluginEntry entry) throws IOException {
+          throw new NoSuchFileException("Empty plugin");
+        }
 
-    @Override
-    public Enumeration<PluginEntry> entries() {
-      return Collections.emptyEnumeration();
-    }
-  };
+        @Override
+        public Enumeration<PluginEntry> entries() {
+          return Collections.emptyEnumeration();
+        }
+      };
 
   /**
    * Plugin class extension meta-data
    *
-   * Class name and annotation value of the class
-   * provided by a plugin to extend an existing
+   * <p>Class name and annotation value of the class provided by a plugin to extend an existing
    * extension point in Gerrit.
    */
   class ExtensionMetaData {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
index 74ded73..f7b1e82 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
@@ -11,20 +11,19 @@
 // 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.plugins;
 
-import com.google.common.base.Optional;
+package com.google.gerrit.server.plugins;
 
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.Map;
+import java.util.Optional;
 
 /**
  * Plugin static resource entry
  *
- * Bean representing a static resource inside a plugin.
- * All static resources are available at {@code <plugin web url>/static}
- * and served by the HttpPluginServlet.
+ * <p>Bean representing a static resource inside a plugin. All static resources are available at
+ * {@code <plugin web url>/static} and served by the HttpPluginServlet.
  */
 public class PluginEntry {
   public static final String ATTR_CHARACTER_ENCODING = "Character-Encoding";
@@ -38,15 +37,14 @@
       };
 
   private static final Map<Object, String> EMPTY_ATTRS = Collections.emptyMap();
-  private static final Optional<Long> NO_SIZE = Optional.absent();
+  private static final Optional<Long> NO_SIZE = Optional.empty();
 
   private final String name;
   private final long time;
   private final Optional<Long> size;
   private final Map<Object, String> attrs;
 
-  public PluginEntry(String name, long time, Optional<Long> size,
-      Map<Object, String> attrs) {
+  public PluginEntry(String name, long time, Optional<Long> size, Map<Object, String> attrs) {
     this.name = name;
     this.time = time;
     this.size = size;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 2c5354e..740e8d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -49,7 +49,6 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
-
 import java.lang.annotation.Annotation;
 import java.lang.reflect.ParameterizedType;
 import java.util.Collections;
@@ -61,16 +60,15 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
-
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 /**
  * Tracks Guice bindings that should be exposed to loaded plugins.
- * <p>
- * This is an internal implementation detail of how the main server is able to
- * export its explicit Guice bindings to tightly coupled plugins, giving them
- * access to singletons and request scoped resources just like any core code.
+ *
+ * <p>This is an internal implementation detail of how the main server is able to export its
+ * explicit Guice bindings to tightly coupled plugins, giving them access to singletons and request
+ * scoped resources just like any core code.
  */
 @Singleton
 public class PluginGuiceEnvironment {
@@ -165,15 +163,16 @@
     final Module db = copy(dbInjector);
     final Module cm = copy(cfgInjector);
     final Module sm = copy(sysInjector);
-    sysModule = new AbstractModule() {
-      @Override
-      protected void configure() {
-        install(copyConfigModule);
-        install(db);
-        install(cm);
-        install(sm);
-      }
-    };
+    sysModule =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            install(copyConfigModule);
+            install(db);
+            install(cm);
+            install(sm);
+          }
+        };
   }
 
   public void setSshInjector(Injector injector) {
@@ -265,35 +264,31 @@
     }
   }
 
-  void onStopPlugin(Plugin plugin) {
+  public void onStopPlugin(Plugin plugin) {
     for (StopPluginListener l : onStop) {
       l.onStopPlugin(plugin);
     }
   }
 
-  private void attachItem(Map<TypeLiteral<?>, DynamicItem<?>> items,
-      @Nullable Injector src,
-      Plugin plugin) {
-    for (RegistrationHandle h : PrivateInternals_DynamicTypes
-        .attachItems(src, items, plugin.getName())) {
+  private void attachItem(
+      Map<TypeLiteral<?>, DynamicItem<?>> items, @Nullable Injector src, Plugin plugin) {
+    for (RegistrationHandle h :
+        PrivateInternals_DynamicTypes.attachItems(src, items, plugin.getName())) {
       plugin.add(h);
     }
   }
 
-  private void attachSet(Map<TypeLiteral<?>, DynamicSet<?>> sets,
-      @Nullable Injector src,
-      Plugin plugin) {
-    for (RegistrationHandle h : PrivateInternals_DynamicTypes
-        .attachSets(src, sets)) {
+  private void attachSet(
+      Map<TypeLiteral<?>, DynamicSet<?>> sets, @Nullable Injector src, Plugin plugin) {
+    for (RegistrationHandle h : PrivateInternals_DynamicTypes.attachSets(src, sets)) {
       plugin.add(h);
     }
   }
 
-  private void attachMap(Map<TypeLiteral<?>, DynamicMap<?>> maps,
-      @Nullable Injector src,
-      Plugin plugin) {
-    for (RegistrationHandle h : PrivateInternals_DynamicTypes
-        .attachMaps(src, plugin.getName(), maps)) {
+  private void attachMap(
+      Map<TypeLiteral<?>, DynamicMap<?>> maps, @Nullable Injector src, Plugin plugin) {
+    for (RegistrationHandle h :
+        PrivateInternals_DynamicTypes.attachMaps(src, plugin.getName(), maps)) {
       plugin.add(h);
     }
   }
@@ -302,8 +297,7 @@
     // Index all old registrations by the raw type. These may be replaced
     // during the reattach calls below. Any that are not replaced will be
     // removed when the old plugin does its stop routine.
-    ListMultimap<TypeLiteral<?>, ReloadableRegistrationHandle<?>> old =
-        LinkedListMultimap.create();
+    ListMultimap<TypeLiteral<?>, ReloadableRegistrationHandle<?>> old = LinkedListMultimap.create();
     for (ReloadableRegistrationHandle<?> h : oldPlugin.getReloadableHandles()) {
       old.put(h.getKey().getTypeLiteral(), h);
     }
@@ -370,18 +364,14 @@
           replace(newPlugin, h, b);
           oldHandles.remove(type, h);
         } else {
-          newPlugin.add(map.put(
-              newPlugin.getName(),
-              b.getKey(),
-              b.getProvider()));
+          newPlugin.add(map.put(newPlugin.getName(), b.getKey(), b.getProvider()));
         }
       }
     }
   }
 
   /** Type used to declare unique annotations. Guice hides this, so extract it. */
-  private static final Class<?> UNIQUE_ANNOTATION =
-      UniqueAnnotations.create().annotationType();
+  private static final Class<?> UNIQUE_ANNOTATION = UniqueAnnotations.create().annotationType();
 
   private void reattachSet(
       ListMultimap<TypeLiteral<?>, ReloadableRegistrationHandle<?>> oldHandles,
@@ -439,7 +429,7 @@
         } else if (oi.hasNext()) {
           @SuppressWarnings("unchecked")
           ReloadableRegistrationHandle<Object> h2 =
-            (ReloadableRegistrationHandle<Object>) oi.next();
+              (ReloadableRegistrationHandle<Object>) oi.next();
           oi.remove();
           replace(newPlugin, h2, b);
         } else {
@@ -465,28 +455,25 @@
       @SuppressWarnings("unchecked")
       DynamicItem<Object> item = (DynamicItem<Object>) e.getValue();
 
-      Iterator<ReloadableRegistrationHandle<?>> oi =
-          oldHandles.get(type).iterator();
+      Iterator<ReloadableRegistrationHandle<?>> oi = oldHandles.get(type).iterator();
 
       for (Binding<?> binding : bindings(src, type)) {
         @SuppressWarnings("unchecked")
         Binding<Object> b = (Binding<Object>) binding;
         if (oi.hasNext()) {
           @SuppressWarnings("unchecked")
-          ReloadableRegistrationHandle<Object> h =
-            (ReloadableRegistrationHandle<Object>) oi.next();
+          ReloadableRegistrationHandle<Object> h = (ReloadableRegistrationHandle<Object>) oi.next();
           oi.remove();
           replace(newPlugin, h, b);
         } else {
-          newPlugin.add(item.set(b.getKey(), b.getProvider(),
-              newPlugin.getName()));
+          newPlugin.add(item.set(b.getKey(), b.getProvider(), newPlugin.getName()));
         }
       }
     }
   }
 
-  private static <T> void replace(Plugin newPlugin,
-      ReloadableRegistrationHandle<T> h, Binding<T> b) {
+  private static <T> void replace(
+      Plugin newPlugin, ReloadableRegistrationHandle<T> h, Binding<T> b) {
     RegistrationHandle n = h.replace(b.getKey(), b.getProvider());
     if (n != null) {
       newPlugin.add(n);
@@ -517,8 +504,7 @@
       if (type.getRawType() == DynamicItem.class) {
         ParameterizedType t = (ParameterizedType) type.getType();
         dynamicItemTypes.add(TypeLiteral.get(t.getActualTypeArguments()[0]));
-      } else if (type.getRawType() == DynamicSet.class
-          || type.getRawType() == DynamicMap.class) {
+      } else if (type.getRawType() == DynamicSet.class || type.getRawType() == DynamicMap.class) {
         ParameterizedType t = (ParameterizedType) type.getType();
         dynamicTypes.add(TypeLiteral.get(t.getActualTypeArguments()[0]));
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index e170510..42998c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -17,7 +17,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
@@ -25,8 +24,8 @@
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
 import com.google.common.collect.Ordering;
+import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.extensions.annotations.PluginName;
@@ -43,12 +42,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -71,18 +64,22 @@
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.internal.storage.file.FileSnapshot;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class PluginLoader implements LifecycleListener {
-  static final Logger log = LoggerFactory.getLogger(PluginLoader.class);
+  private static final Logger log = LoggerFactory.getLogger(PluginLoader.class);
 
   public String getPluginName(Path srcPath) {
-    return MoreObjects.firstNonNull(getGerritPluginName(srcPath),
-        nameOf(srcPath));
+    return MoreObjects.firstNonNull(getGerritPluginName(srcPath), nameOf(srcPath));
   }
 
   private final Path pluginsDir;
   private final Path dataDir;
+  private final Path tempDir;
   private final PluginGuiceEnvironment env;
   private final ServerInformationImpl srvInfoImpl;
   private final PluginUser.Factory pluginUserFactory;
@@ -99,7 +96,8 @@
   private final UniversalServerPluginProvider serverPluginFactory;
 
   @Inject
-  public PluginLoader(SitePaths sitePaths,
+  public PluginLoader(
+      SitePaths sitePaths,
       PluginGuiceEnvironment pe,
       ServerInformationImpl sii,
       PluginUser.Factory puf,
@@ -110,6 +108,7 @@
       UniversalServerPluginProvider pluginFactory) {
     pluginsDir = sitePaths.plugins_dir;
     dataDir = sitePaths.data_dir;
+    tempDir = sitePaths.tmp_dir;
     env = pe;
     srvInfoImpl = sii;
     pluginUserFactory = puf;
@@ -123,12 +122,16 @@
     persistentCacheFactory = cacheFactory;
     serverPluginFactory = pluginFactory;
 
-    remoteAdmin =
-        cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
+    remoteAdmin = cfg.getBoolean("plugins", null, "allowRemoteAdmin", false);
 
-    long checkFrequency = ConfigUtil.getTimeUnit(cfg,
-        "plugins", null, "checkFrequency",
-        TimeUnit.MINUTES.toMillis(1), TimeUnit.MILLISECONDS);
+    long checkFrequency =
+        ConfigUtil.getTimeUnit(
+            cfg,
+            "plugins",
+            null,
+            "checkFrequency",
+            TimeUnit.MINUTES.toMillis(1),
+            TimeUnit.MILLISECONDS);
     if (checkFrequency > 0) {
       scanner = new PluginScannerThread(this, checkFrequency);
     } else {
@@ -136,26 +139,24 @@
     }
   }
 
-  public static List<Path> listPlugins(Path pluginsDir, final String suffix)
-      throws IOException {
+  public static List<Path> listPlugins(Path pluginsDir, final String suffix) throws IOException {
     if (pluginsDir == null || !Files.exists(pluginsDir)) {
       return ImmutableList.of();
     }
-    DirectoryStream.Filter<Path> filter = new DirectoryStream.Filter<Path>() {
-      @Override
-      public boolean accept(Path entry) throws IOException {
-        String n = entry.getFileName().toString();
-        boolean accept = !n.startsWith(".last_")
-            && !n.startsWith(".next_")
-            && Files.isRegularFile(entry);
-        if (!Strings.isNullOrEmpty(suffix)) {
-          accept &= n.endsWith(suffix);
-        }
-        return accept;
-      }
-    };
-    try (DirectoryStream<Path> files = Files.newDirectoryStream(
-        pluginsDir, filter)) {
+    DirectoryStream.Filter<Path> filter =
+        new DirectoryStream.Filter<Path>() {
+          @Override
+          public boolean accept(Path entry) throws IOException {
+            String n = entry.getFileName().toString();
+            boolean accept =
+                !n.startsWith(".last_") && !n.startsWith(".next_") && Files.isRegularFile(entry);
+            if (!Strings.isNullOrEmpty(suffix)) {
+              accept &= n.endsWith(suffix);
+            }
+            return accept;
+          }
+        };
+    try (DirectoryStream<Path> files = Files.newDirectoryStream(pluginsDir, filter)) {
       return Ordering.natural().sortedCopy(files);
     }
   }
@@ -191,12 +192,12 @@
 
     String fileName = originalName;
     Path tmp = asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
-    String name = MoreObjects.firstNonNull(getGerritPluginName(tmp),
-        nameOf(fileName));
+    String name = MoreObjects.firstNonNull(getGerritPluginName(tmp), nameOf(fileName));
     if (!originalName.equals(name)) {
-      log.warn(String.format("Plugin provides its own name: <%s>,"
-          + " use it instead of the input name: <%s>",
-          name, originalName));
+      log.warn(
+          "Plugin provides its own name: <{}>, use it instead of the input name: <{}>",
+          name,
+          originalName);
     }
 
     String fileExtension = getExtension(fileName);
@@ -205,7 +206,7 @@
       Plugin active = running.get(name);
       if (active != null) {
         fileName = active.getSrcFile().getFileName().toString();
-        log.info(String.format("Replacing plugin %s", active.getName()));
+        log.info("Replacing plugin {}", active.getName());
         Path old = pluginsDir.resolve(".last_" + fileName);
         Files.deleteIfExists(old);
         Files.move(active.getSrcFile(), old);
@@ -216,7 +217,7 @@
       try {
         Plugin plugin = runPlugin(name, dst, active);
         if (active == null) {
-          log.info(String.format("Installed plugin %s", plugin.getName()));
+          log.info("Installed plugin {}", plugin.getName());
         }
       } catch (PluginInstallException e) {
         Files.deleteIfExists(dst);
@@ -229,8 +230,7 @@
     return name;
   }
 
-  static Path asTemp(InputStream in, String prefix, String suffix, Path dir)
-      throws IOException {
+  static Path asTemp(InputStream in, String prefix, String suffix, Path dir) throws IOException {
     Path tmp = Files.createTempFile(dir, prefix, suffix);
     boolean keep = false;
     try (OutputStream out = Files.newOutputStream(tmp)) {
@@ -247,8 +247,7 @@
   private synchronized void unloadPlugin(Plugin plugin) {
     persistentCacheFactory.onStop(plugin);
     String name = plugin.getName();
-    log.info(String.format("Unloading plugin %s, version %s",
-        name, plugin.getVersion()));
+    log.info("Unloading plugin {}, version {}", name, plugin.getVersion());
     plugin.stop(env);
     env.onStopPlugin(plugin);
     running.remove(name);
@@ -258,8 +257,7 @@
 
   public void disablePlugins(Set<String> names) {
     if (!isRemoteAdminEnabled()) {
-      log.warn("Remote plugin administration is disabled,"
-          + " ignoring disablePlugins(" + names + ")");
+      log.warn("Remote plugin administration is disabled, ignoring disablePlugins({})", names);
       return;
     }
 
@@ -270,9 +268,9 @@
           continue;
         }
 
-        log.info(String.format("Disabling plugin %s", active.getName()));
-        Path off = active.getSrcFile().resolveSibling(
-            active.getSrcFile().getFileName() + ".disabled");
+        log.info("Disabling plugin {}", active.getName());
+        Path off =
+            active.getSrcFile().resolveSibling(active.getSrcFile().getFileName() + ".disabled");
         try {
           Files.move(active.getSrcFile(), off);
         } catch (IOException e) {
@@ -290,9 +288,7 @@
           disabled.put(name, offPlugin);
         } catch (Throwable e) {
           // This shouldn't happen, as the plugin was loaded earlier.
-          log.warn(String.format(
-              "Cannot load disabled plugin %s", active.getName()),
-              e.getCause());
+          log.warn("Cannot load disabled plugin {}", active.getName(), e.getCause());
         }
       }
       cleanInBackground();
@@ -301,8 +297,7 @@
 
   public void enablePlugins(Set<String> names) throws PluginInstallException {
     if (!isRemoteAdminEnabled()) {
-      log.warn("Remote plugin administration is disabled,"
-          + " ignoring enablePlugins(" + names + ")");
+      log.warn("Remote plugin administration is disabled, ignoring enablePlugins({})", names);
       return;
     }
 
@@ -313,7 +308,7 @@
           continue;
         }
 
-        log.info(String.format("Enabling plugin %s", name));
+        log.info("Enabling plugin {}", name);
         String n = off.getSrcFile().toFile().getName();
         if (n.endsWith(".disabled")) {
           n = n.substring(0, n.lastIndexOf('.'));
@@ -322,7 +317,7 @@
         try {
           Files.move(off.getSrcFile(), on);
         } catch (IOException e) {
-          log.error("Failed to move plugin " + name + " into place", e);
+          log.error("Failed to move plugin {} into place", name, e);
           continue;
         }
         disabled.remove(name);
@@ -332,9 +327,33 @@
     }
   }
 
+  private void removeStalePluginFiles() {
+    DirectoryStream.Filter<Path> filter =
+        new DirectoryStream.Filter<Path>() {
+          @Override
+          public boolean accept(Path entry) throws IOException {
+            return entry.getFileName().toString().startsWith("plugin_");
+          }
+        };
+    try (DirectoryStream<Path> files = Files.newDirectoryStream(tempDir, filter)) {
+      for (Path file : files) {
+        log.info("Removing stale plugin file: {}", file.toFile().getName());
+        try {
+          Files.delete(file);
+        } catch (IOException e) {
+          log.error(
+              "Failed to remove stale plugin file {}: {}", file.toFile().getName(), e.getMessage());
+        }
+      }
+    } catch (IOException e) {
+      log.warn("Unable to discover stale plugin files: {}", e.getMessage());
+    }
+  }
+
   @Override
   public synchronized void start() {
-    log.info("Loading plugins from " + pluginsDir.toAbsolutePath());
+    removeStalePluginFiles();
+    log.info("Loading plugins from {}", pluginsDir.toAbsolutePath());
     srvInfoImpl.state = ServerInformation.State.STARTUP;
     rescan();
     srvInfoImpl.state = ServerInformation.State.RUNNING;
@@ -363,8 +382,7 @@
     }
   }
 
-  public void reload(List<String> names)
-      throws InvalidPluginException, PluginInstallException {
+  public void reload(List<String> names) throws InvalidPluginException, PluginInstallException {
     synchronized (this) {
       List<Plugin> reload = Lists.newArrayListWithCapacity(names.size());
       List<String> bad = Lists.newArrayListWithExpectedSize(4);
@@ -377,20 +395,18 @@
         }
       }
       if (!bad.isEmpty()) {
-        throw new InvalidPluginException(String.format(
-            "Plugin(s) \"%s\" not running",
-            Joiner.on("\", \"").join(bad)));
+        throw new InvalidPluginException(
+            String.format("Plugin(s) \"%s\" not running", Joiner.on("\", \"").join(bad)));
       }
 
       for (Plugin active : reload) {
         String name = active.getName();
         try {
-          log.info(String.format("Reloading plugin %s", name));
+          log.info("Reloading plugin {}", name);
           Plugin newPlugin = runPlugin(name, active.getSrcFile(), active);
-          log.info(String.format("Reloaded plugin %s, version %s",
-              newPlugin.getName(), newPlugin.getVersion()));
+          log.info("Reloaded plugin {}, version {}", newPlugin.getName(), newPlugin.getVersion());
         } catch (PluginInstallException e) {
-          log.warn(String.format("Cannot reload plugin %s", name), e.getCause());
+          log.warn("Cannot reload plugin {}", name, e.getCause());
           throw e;
         }
       }
@@ -400,7 +416,7 @@
   }
 
   public synchronized void rescan() {
-    Multimap<String, Path> pluginsFiles = prunePlugins(pluginsDir);
+    SetMultimap<String, Path> pluginsFiles = prunePlugins(pluginsDir);
     if (pluginsFiles.isEmpty()) {
       return;
     }
@@ -428,58 +444,58 @@
       }
 
       if (active != null) {
-        log.info(String.format("Reloading plugin %s", active.getName()));
+        log.info("Reloading plugin {}", active.getName());
       }
 
       try {
         Plugin loadedPlugin = runPlugin(name, path, active);
         if (!loadedPlugin.isDisabled()) {
-          log.info(String.format("%s plugin %s, version %s",
+          log.info(
+              "{} plugin {}, version {}",
               active == null ? "Loaded" : "Reloaded",
-              loadedPlugin.getName(), loadedPlugin.getVersion()));
+              loadedPlugin.getName(),
+              loadedPlugin.getVersion());
         }
       } catch (PluginInstallException e) {
-        log.warn(String.format("Cannot load plugin %s", name), e.getCause());
+        log.warn("Cannot load plugin {}", name, e.getCause());
       }
     }
 
     cleanInBackground();
   }
 
-  private void addAllEntries(Map<String, Path> from,
-      TreeSet<Entry<String, Path>> to) {
+  private void addAllEntries(Map<String, Path> from, TreeSet<Entry<String, Path>> to) {
     Iterator<Entry<String, Path>> it = from.entrySet().iterator();
     while (it.hasNext()) {
-      Entry<String,Path> entry = it.next();
-      to.add(new AbstractMap.SimpleImmutableEntry<>(
-          entry.getKey(), entry.getValue()));
+      Entry<String, Path> entry = it.next();
+      to.add(new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), entry.getValue()));
     }
   }
 
-  private TreeSet<Entry<String, Path>> jarsFirstSortedPluginsSet(
-      Map<String, Path> activePlugins) {
+  private TreeSet<Entry<String, Path>> jarsFirstSortedPluginsSet(Map<String, Path> activePlugins) {
     TreeSet<Entry<String, Path>> sortedPlugins =
-        Sets.newTreeSet(new Comparator<Entry<String, Path>>() {
-          @Override
-          public int compare(Entry<String, Path> e1, Entry<String, Path> e2) {
-            Path n1 = e1.getValue().getFileName();
-            Path n2 = e2.getValue().getFileName();
-            return ComparisonChain.start()
-                .compareTrueFirst(isJar(n1), isJar(n2))
-                .compare(n1, n2)
-                .result();
-          }
+        Sets.newTreeSet(
+            new Comparator<Entry<String, Path>>() {
+              @Override
+              public int compare(Entry<String, Path> e1, Entry<String, Path> e2) {
+                Path n1 = e1.getValue().getFileName();
+                Path n2 = e2.getValue().getFileName();
+                return ComparisonChain.start()
+                    .compareTrueFirst(isJar(n1), isJar(n2))
+                    .compare(n1, n2)
+                    .result();
+              }
 
-          private boolean isJar(Path n1) {
-            return n1.toString().endsWith(".jar");
-          }
-        });
+              private boolean isJar(Path n1) {
+                return n1.toString().endsWith(".jar");
+              }
+            });
 
     addAllEntries(activePlugins, sortedPlugins);
     return sortedPlugins;
   }
 
-  private void syncDisabledPlugins(Multimap<String, Path> jars) {
+  private void syncDisabledPlugins(SetMultimap<String, Path> jars) {
     stopRemovedPlugins(jars);
     dropRemovedDisabledPlugins(jars);
   }
@@ -498,9 +514,7 @@
        * safer then to reassign it.
        */
       name = newPlugin.getName();
-      boolean reload = oldPlugin != null
-          && oldPlugin.canReload()
-          && newPlugin.canReload();
+      boolean reload = oldPlugin != null && oldPlugin.canReload() && newPlugin.canReload();
       if (!reload && oldPlugin != null) {
         unloadPlugin(oldPlugin);
       }
@@ -526,7 +540,7 @@
     }
   }
 
-  private void stopRemovedPlugins(Multimap<String, Path> jars) {
+  private void stopRemovedPlugins(SetMultimap<String, Path> jars) {
     Set<String> unload = Sets.newHashSet(running.keySet());
     for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
       for (Path path : entry.getValue()) {
@@ -540,7 +554,7 @@
     }
   }
 
-  private void dropRemovedDisabledPlugins(Multimap<String, Path> jars) {
+  private void dropRemovedDisabledPlugins(SetMultimap<String, Path> jars) {
     Set<String> unload = Sets.newHashSet(disabled.keySet());
     for (Map.Entry<String, Collection<Path>> entry : jars.asMap().entrySet()) {
       for (Path path : entry.getValue()) {
@@ -600,8 +614,8 @@
     } else if (serverPluginFactory.handles(srcPlugin)) {
       return loadServerPlugin(srcPlugin, snapshot);
     } else {
-      throw new InvalidPluginException(String.format(
-          "Unsupported plugin type: %s", srcPlugin.getFileName()));
+      throw new InvalidPluginException(
+          String.format("Unsupported plugin type: %s", srcPlugin.getFileName()));
     }
   }
 
@@ -610,9 +624,14 @@
   }
 
   private String getPluginCanonicalWebUrl(String name) {
-    String url = String.format("%s/plugins/%s/",
-        CharMatcher.is('/').trimTrailingFrom(urlProvider.get()),
-        name);
+    String canonicalWebUrl = urlProvider.get();
+    if (Strings.isNullOrEmpty(canonicalWebUrl)) {
+      return "/plugins/" + name;
+    }
+
+    String url =
+        String.format(
+            "%s/plugins/%s/", CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl), name);
     return url;
   }
 
@@ -620,16 +639,19 @@
     return new JsPlugin(name, srcJar, pluginUserFactory.create(name), snapshot);
   }
 
-  private ServerPlugin loadServerPlugin(Path scriptFile,
-      FileSnapshot snapshot) throws InvalidPluginException {
+  private ServerPlugin loadServerPlugin(Path scriptFile, FileSnapshot snapshot)
+      throws InvalidPluginException {
     String name = serverPluginFactory.getPluginName(scriptFile);
-    return serverPluginFactory.get(scriptFile, snapshot, new PluginDescription(
-        pluginUserFactory.create(name), getPluginCanonicalWebUrl(name),
-        getPluginDataDir(name)));
+    return serverPluginFactory.get(
+        scriptFile,
+        snapshot,
+        new PluginDescription(
+            pluginUserFactory.create(name),
+            getPluginCanonicalWebUrl(name),
+            getPluginDataDir(name)));
   }
 
-  static ClassLoader parentFor(Plugin.ApiType type)
-      throws InvalidPluginException {
+  static ClassLoader parentFor(Plugin.ApiType type) throws InvalidPluginException {
     switch (type) {
       case EXTENSION:
         return PluginName.class.getClassLoader();
@@ -644,10 +666,8 @@
 
   // Only one active plugin per plugin name can exist for each plugin name.
   // Filter out disabled plugins and transform the multimap to a map
-  private static Map<String, Path> filterDisabled(
-      Multimap<String, Path> pluginPaths) {
-    Map<String, Path> activePlugins = Maps.newHashMapWithExpectedSize(
-        pluginPaths.keys().size());
+  private static Map<String, Path> filterDisabled(SetMultimap<String, Path> pluginPaths) {
+    Map<String, Path> activePlugins = Maps.newHashMapWithExpectedSize(pluginPaths.keys().size());
     for (String name : pluginPaths.keys()) {
       for (Path pluginPath : pluginPaths.asMap().get(name)) {
         if (!pluginPath.getFileName().toString().endsWith(".disabled")) {
@@ -668,9 +688,9 @@
   //
   // NOTE: Bear in mind that the plugin name can be reassigned after load by the
   //       Server plugin provider.
-  public Multimap<String, Path> prunePlugins(Path pluginsDir) {
+  public SetMultimap<String, Path> prunePlugins(Path pluginsDir) {
     List<Path> pluginPaths = scanPathsInPluginsDirectory(pluginsDir);
-    Multimap<String, Path> map;
+    SetMultimap<String, Path> map;
     map = asMultimap(pluginPaths);
     for (String plugin : map.keySet()) {
       Collection<Path> files = map.asMap().get(plugin);
@@ -690,17 +710,20 @@
       Collection<Path> elementsToRemove = new ArrayList<>();
       Collection<Path> elementsToAdd = new ArrayList<>();
       for (Path loser : Iterables.skip(enabled, 1)) {
-        log.warn(String.format("Plugin <%s> was disabled, because"
-             + " another plugin <%s>"
-             + " with the same name <%s> already exists",
-             loser, winner, plugin));
+        log.warn(
+            "Plugin <{}> was disabled, because"
+                + " another plugin <{}>"
+                + " with the same name <{}> already exists",
+            loser,
+            winner,
+            plugin);
         Path disabledPlugin = Paths.get(loser + ".disabled");
         elementsToAdd.add(disabledPlugin);
         elementsToRemove.add(loser);
         try {
           Files.move(loser, disabledPlugin);
         } catch (IOException e) {
-          log.warn("Failed to fully disable plugin " + loser, e);
+          log.warn("Failed to fully disable plugin {}", loser, e);
         }
       }
       Iterables.removeAll(files, elementsToRemove);
@@ -713,19 +736,13 @@
     try {
       return listPlugins(pluginsDir);
     } catch (IOException e) {
-      log.error("Cannot list " + pluginsDir.toAbsolutePath(), e);
+      log.error("Cannot list {}", pluginsDir.toAbsolutePath(), e);
       return ImmutableList.of();
     }
   }
 
-  private static Iterable<Path> filterDisabledPlugins(
-      Collection<Path> paths) {
-    return Iterables.filter(paths, new Predicate<Path>() {
-      @Override
-      public boolean apply(Path p) {
-        return !p.getFileName().toString().endsWith(".disabled");
-      }
-    });
+  private static Iterable<Path> filterDisabledPlugins(Collection<Path> paths) {
+    return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
   }
 
   public String getGerritPluginName(Path srcPath) {
@@ -739,8 +756,8 @@
     return null;
   }
 
-  private Multimap<String, Path> asMultimap(List<Path> plugins) {
-    Multimap<String, Path> map = LinkedHashMultimap.create();
+  private SetMultimap<String, Path> asMultimap(List<Path> plugins) {
+    SetMultimap<String, Path> map = LinkedHashMultimap.create();
     for (Path srcPath : plugins) {
       map.put(getPluginName(srcPath), srcPath);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
index 23b1eee..b1a01d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginMetricMaker.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.metrics.Timer2;
 import com.google.gerrit.metrics.Timer3;
-
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -59,9 +58,7 @@
   }
 
   @Override
-  public <F1> Counter1<F1> newCounter(
-      String name, Description desc,
-      Field<F1> field1) {
+  public <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1) {
     Counter1<F1> m = root.newCounter(prefix + name, desc, field1);
     cleanup.add(m);
     return m;
@@ -69,8 +66,7 @@
 
   @Override
   public <F1, F2> Counter2<F1, F2> newCounter(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2) {
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     Counter2<F1, F2> m = root.newCounter(prefix + name, desc, field1, field2);
     cleanup.add(m);
     return m;
@@ -78,10 +74,8 @@
 
   @Override
   public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    Counter3<F1, F2, F3> m =
-        root.newCounter(prefix + name, desc, field1, field2, field3);
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Counter3<F1, F2, F3> m = root.newCounter(prefix + name, desc, field1, field2, field3);
     cleanup.add(m);
     return m;
   }
@@ -94,9 +88,7 @@
   }
 
   @Override
-  public <F1> Timer1<F1> newTimer(
-      String name, Description desc,
-      Field<F1> field1) {
+  public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
     Timer1<F1> m = root.newTimer(prefix + name, desc, field1);
     cleanup.add(m);
     return m;
@@ -104,8 +96,7 @@
 
   @Override
   public <F1, F2> Timer2<F1, F2> newTimer(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2) {
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     Timer2<F1, F2> m = root.newTimer(prefix + name, desc, field1, field2);
     cleanup.add(m);
     return m;
@@ -113,10 +104,8 @@
 
   @Override
   public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    Timer3<F1, F2, F3> m =
-        root.newTimer(prefix + name, desc, field1, field2, field3);
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Timer3<F1, F2, F3> m = root.newTimer(prefix + name, desc, field1, field2, field3);
     cleanup.add(m);
     return m;
   }
@@ -129,9 +118,7 @@
   }
 
   @Override
-  public <F1> Histogram1<F1> newHistogram(
-      String name, Description desc,
-      Field<F1> field1) {
+  public <F1> Histogram1<F1> newHistogram(String name, Description desc, Field<F1> field1) {
     Histogram1<F1> m = root.newHistogram(prefix + name, desc, field1);
     cleanup.add(m);
     return m;
@@ -139,8 +126,7 @@
 
   @Override
   public <F1, F2> Histogram2<F1, F2> newHistogram(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2) {
+      String name, Description desc, Field<F1> field1, Field<F2> field2) {
     Histogram2<F1, F2> m = root.newHistogram(prefix + name, desc, field1, field2);
     cleanup.add(m);
     return m;
@@ -148,10 +134,8 @@
 
   @Override
   public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
-      String name, Description desc,
-      Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    Histogram3<F1, F2, F3> m =
-        root.newHistogram(prefix + name, desc, field1, field2, field3);
+      String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+    Histogram3<F1, F2, F3> m = root.newHistogram(prefix + name, desc, field1, field2, field3);
     cleanup.add(m);
     return m;
   }
@@ -165,17 +149,15 @@
   }
 
   @Override
-  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(String name,
-      Class<V> valueClass, Description desc, Field<F1> field1) {
-    CallbackMetric1<F1, V> m =
-        root.newCallbackMetric(prefix + name, valueClass, desc, field1);
+  public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
+      String name, Class<V> valueClass, Description desc, Field<F1> field1) {
+    CallbackMetric1<F1, V> m = root.newCallbackMetric(prefix + name, valueClass, desc, field1);
     cleanup.add(m);
     return m;
   }
 
   @Override
-  public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics,
-      Runnable trigger) {
+  public RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics, Runnable trigger) {
     final RegistrationHandle handle = root.newTrigger(metrics, trigger);
     cleanup.add(handle);
     return new RegistrationHandle() {
@@ -188,8 +170,7 @@
   }
 
   @Override
-  public void start() {
-  }
+  public void start() {}
 
   @Override
   public void stop() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginModule.java
index 32722da..db18470 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginModule.java
@@ -31,8 +31,7 @@
     listener().to(PluginLoader.class);
 
     DynamicSet.setOf(binder(), ServerPluginProvider.class);
-    DynamicSet.bind(binder(), ServerPluginProvider.class).to(
-        JarPluginProvider.class);
+    DynamicSet.bind(binder(), ServerPluginProvider.class).to(JarPluginProvider.class);
     bind(UniversalServerPluginProvider.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
index 6b52a59..8e162ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
@@ -22,7 +22,6 @@
 public class PluginRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
-    install(new PluginModule());
     bind(PluginsCollection.class);
     DynamicMap.mapOf(binder(), PLUGIN_KIND);
     put(PLUGIN_KIND).to(InstallPlugin.Overwrite.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginScannerThread.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginScannerThread.java
index e6c3dbd..705e3c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginScannerThread.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginScannerThread.java
@@ -31,7 +31,7 @@
 
   @Override
   public void run() {
-    for (;;) {
+    for (; ; ) {
       try {
         if (done.await(checkFrequencyMillis, TimeUnit.MILLISECONDS)) {
           return;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
index a4834ba..b8a9a9e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
@@ -27,17 +27,16 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class PluginsCollection implements
-    RestCollection<TopLevelResource, PluginResource>,
-    AcceptsCreate<TopLevelResource> {
+public class PluginsCollection
+    implements RestCollection<TopLevelResource, PluginResource>, AcceptsCreate<TopLevelResource> {
 
   private final DynamicMap<RestView<PluginResource>> views;
   private final PluginLoader loader;
   private final Provider<ListPlugins> list;
 
   @Inject
-  PluginsCollection(DynamicMap<RestView<PluginResource>> views,
-      PluginLoader loader, Provider<ListPlugins> list) {
+  PluginsCollection(
+      DynamicMap<RestView<PluginResource>> views, PluginLoader loader, Provider<ListPlugins> list) {
     this.views = views;
     this.loader = loader;
     this.list = list;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
index e9c5aa2..13a1179 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
@@ -23,15 +23,13 @@
 import com.google.gerrit.server.plugins.ReloadPlugin.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.PrintWriter;
 import java.io.StringWriter;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 class ReloadPlugin implements RestModifyView<PluginResource, Input> {
-  static class Input {
-  }
+  static class Input {}
 
   private final PluginLoader loader;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 40f1fea..2d37505 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -25,27 +25,28 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.Module;
-
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
-
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.jar.Attributes;
 import java.util.jar.Manifest;
+import org.eclipse.jgit.internal.storage.file.FileSnapshot;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class ServerPlugin extends Plugin {
+  private static final Logger log = LoggerFactory.getLogger(ServerPlugin.class);
+
   private final Manifest manifest;
   private final PluginContentScanner scanner;
   private final Path dataDir;
   private final String pluginCanonicalWebUrl;
   private final ClassLoader classLoader;
   private final String metricsPrefix;
-  private Class<? extends Module> sysModule;
-  private Class<? extends Module> sshModule;
-  private Class<? extends Module> httpModule;
+  protected Class<? extends Module> sysModule;
+  protected Class<? extends Module> sshModule;
+  protected Class<? extends Module> httpModule;
 
   private Injector sysInjector;
   private Injector sshInjector;
@@ -53,7 +54,8 @@
   private LifecycleManager serverManager;
   private List<ReloadableRegistrationHandle<?>> reloadableHandles;
 
-  public ServerPlugin(String name,
+  public ServerPlugin(
+      String name,
       String pluginCanonicalWebUrl,
       PluginUser pluginUser,
       Path srcJar,
@@ -61,40 +63,58 @@
       PluginContentScanner scanner,
       Path dataDir,
       ClassLoader classLoader,
-      String metricsPrefix) throws InvalidPluginException {
-    super(name, srcJar, pluginUser, snapshot,
-        Plugin.getApiType(getPluginManifest(scanner)));
+      String metricsPrefix)
+      throws InvalidPluginException {
+    super(
+        name,
+        srcJar,
+        pluginUser,
+        snapshot,
+        scanner == null ? ApiType.PLUGIN : Plugin.getApiType(getPluginManifest(scanner)));
     this.pluginCanonicalWebUrl = pluginCanonicalWebUrl;
     this.scanner = scanner;
     this.dataDir = dataDir;
     this.classLoader = classLoader;
-    this.manifest = getPluginManifest(scanner);
+    this.manifest = scanner == null ? null : getPluginManifest(scanner);
     this.metricsPrefix = metricsPrefix;
-    loadGuiceModules(manifest, classLoader);
+    if (manifest != null) {
+      loadGuiceModules(manifest, classLoader);
+    }
   }
 
-  public ServerPlugin(String name,
+  public ServerPlugin(
+      String name,
       String pluginCanonicalWebUrl,
       PluginUser pluginUser,
       Path srcJar,
       FileSnapshot snapshot,
       PluginContentScanner scanner,
       Path dataDir,
-      ClassLoader classLoader) throws InvalidPluginException {
-    this(name, pluginCanonicalWebUrl, pluginUser, srcJar, snapshot, scanner,
-        dataDir, classLoader, null);
+      ClassLoader classLoader)
+      throws InvalidPluginException {
+    this(
+        name,
+        pluginCanonicalWebUrl,
+        pluginUser,
+        srcJar,
+        snapshot,
+        scanner,
+        dataDir,
+        classLoader,
+        null);
   }
 
-  private void loadGuiceModules(Manifest manifest, ClassLoader classLoader) throws InvalidPluginException {
+  private void loadGuiceModules(Manifest manifest, ClassLoader classLoader)
+      throws InvalidPluginException {
     Attributes main = manifest.getMainAttributes();
     String sysName = main.getValue("Gerrit-Module");
     String sshName = main.getValue("Gerrit-SshModule");
     String httpName = main.getValue("Gerrit-HttpModule");
 
     if (!Strings.isNullOrEmpty(sshName) && getApiType() != Plugin.ApiType.PLUGIN) {
-      throw new InvalidPluginException(String.format(
-          "Using Gerrit-SshModule requires Gerrit-ApiType: %s",
-          Plugin.ApiType.PLUGIN));
+      throw new InvalidPluginException(
+          String.format(
+              "Using Gerrit-SshModule requires Gerrit-ApiType: %s", Plugin.ApiType.PLUGIN));
     }
 
     try {
@@ -107,18 +127,16 @@
   }
 
   @SuppressWarnings("unchecked")
-  private static Class<? extends Module> load(String name, ClassLoader pluginLoader)
+  protected static Class<? extends Module> load(String name, ClassLoader pluginLoader)
       throws ClassNotFoundException {
     if (Strings.isNullOrEmpty(name)) {
       return null;
     }
 
-    Class<?> clazz =
-        Class.forName(name, false, pluginLoader);
+    Class<?> clazz = Class.forName(name, false, pluginLoader);
     if (!Module.class.isAssignableFrom(clazz)) {
-      throw new ClassCastException(String.format(
-          "Class %s does not implement %s",
-          name, Module.class.getName()));
+      throw new ClassCastException(
+          String.format("Class %s does not implement %s", name, Module.class.getName()));
     }
     return (Class<? extends Module>) clazz;
   }
@@ -138,7 +156,7 @@
   private static Manifest getPluginManifest(PluginContentScanner scanner)
       throws InvalidPluginException {
     try {
-       return scanner.getManifest();
+      return scanner.getManifest();
     } catch (IOException e) {
       throw new InvalidPluginException("Cannot get plugin manifest", e);
     }
@@ -160,9 +178,7 @@
     } else if ("restart".equalsIgnoreCase(v)) {
       return false;
     } else {
-      PluginLoader.log.warn(String.format(
-          "Plugin %s has invalid Gerrit-ReloadMode %s; assuming restart",
-          getName(), v));
+      log.warn("Plugin {} has invalid Gerrit-ReloadMode {}; assuming restart", getName(), v);
       return false;
     }
   }
@@ -199,7 +215,7 @@
     }
 
     if (env.hasSshModule()) {
-      List<Module> modules = new LinkedList<>();
+      List<Module> modules = new ArrayList<>();
       if (getApiType() == ApiType.PLUGIN) {
         modules.add(env.getSshModule());
       }
@@ -215,7 +231,7 @@
     }
 
     if (env.hasHttpModule()) {
-      List<Module> modules = new LinkedList<>();
+      List<Module> modules = new ArrayList<>();
       if (getApiType() == ApiType.PLUGIN) {
         modules.add(env.getHttpModule());
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index ff89cef4..639b278 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -24,7 +24,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.ProvisionException;
-
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -46,24 +45,24 @@
   @Override
   protected void configure() {
     bind(PluginUser.class).toInstance(plugin.getPluginUser());
+    bind(String.class).annotatedWith(PluginName.class).toInstance(plugin.getName());
     bind(String.class)
-      .annotatedWith(PluginName.class)
-      .toInstance(plugin.getName());
-    bind(String.class)
-      .annotatedWith(PluginCanonicalWebUrl.class)
-      .toInstance(plugin.getPluginCanonicalWebUrl());
+        .annotatedWith(PluginCanonicalWebUrl.class)
+        .toInstance(plugin.getPluginCanonicalWebUrl());
 
-    install(new LifecycleModule() {
-      @Override
-      public void configure() {
-        PluginMetricMaker metrics = new PluginMetricMaker(
-            serverMetrics,
-            MoreObjects.firstNonNull(plugin.getMetricsPrefix(),
-                String.format("plugins/%s/", plugin.getName())));
-        bind(MetricMaker.class).toInstance(metrics);
-        listener().toInstance(metrics);
-      }
-    });
+    install(
+        new LifecycleModule() {
+          @Override
+          public void configure() {
+            PluginMetricMaker metrics =
+                new PluginMetricMaker(
+                    serverMetrics,
+                    MoreObjects.firstNonNull(
+                        plugin.getMetricsPrefix(), String.format("plugins/%s/", plugin.getName())));
+            bind(MetricMaker.class).toInstance(metrics);
+            listener().toInstance(metrics);
+          }
+        });
   }
 
   @Provides
@@ -75,9 +74,10 @@
           try {
             Files.createDirectories(dataDir);
           } catch (IOException e) {
-            throw new ProvisionException(String.format(
-                "Cannot create %s for plugin %s",
-                dataDir.toAbsolutePath(), plugin.getName()), e);
+            throw new ProvisionException(
+                String.format(
+                    "Cannot create %s for plugin %s", dataDir.toAbsolutePath(), plugin.getName()),
+                e);
           }
           ready = true;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java
index 068d73c..632f838 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPluginProvider.java
@@ -16,27 +16,22 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.PluginUser;
-
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
-
 import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 
 /**
  * Provider of one Server plugin from one external file
  *
- * Allows to load one plugin from one external file or
- * one directory by declaring the ability to handle it.
+ * <p>Allows to load one plugin from one external file or one directory by declaring the ability to
+ * handle it.
  *
- * In order to load multiple files into a single plugin,
- * group them into a directory tree and then load the directory
- * root as a single plugin.
+ * <p>In order to load multiple files into a single plugin, group them into a directory tree and
+ * then load the directory root as a single plugin.
  */
 @ExtensionPoint
 public interface ServerPluginProvider {
 
-  /**
-   * Descriptor of the Plugin that ServerPluginProvider has to load.
-   */
+  /** Descriptor of the Plugin that ServerPluginProvider has to load. */
   class PluginDescription {
     public final PluginUser user;
     public final String canonicalUrl;
@@ -67,10 +62,9 @@
   /**
    * Returns the plugin name of an external file or directory
    *
-   * Should be called only if {@link #handles(Path) handles(srcFile)}
-   * returns true and thus srcFile is a supported plugin format.
-   * An IllegalArgumentException is thrown otherwise as srcFile
-   * is not a valid file format for extracting its plugin name.
+   * <p>Should be called only if {@link #handles(Path) handles(srcFile)} returns true and thus
+   * srcFile is a supported plugin format. An IllegalArgumentException is thrown otherwise as
+   * srcFile is not a valid file format for extracting its plugin name.
    *
    * @param srcPath external file or directory
    * @return plugin name
@@ -80,26 +74,24 @@
   /**
    * Loads an external file or directory into a Server plugin.
    *
-   * Should be called only if {@link #handles(Path) handles(srcFile)}
-   * returns true and thus srcFile is a supported plugin format.
-   * An IllegalArgumentException is thrown otherwise as srcFile
-   * is not a valid file format for extracting its plugin name.
+   * <p>Should be called only if {@link #handles(Path) handles(srcFile)} returns true and thus
+   * srcFile is a supported plugin format. An IllegalArgumentException is thrown otherwise as
+   * srcFile is not a valid file format for extracting its plugin name.
    *
    * @param srcPath external file or directory
    * @param snapshot snapshot of the external file
    * @param pluginDescriptor descriptor of the ServerPlugin to load
-   * @throws InvalidPluginException if plugin is supposed to be handled
-   *         but cannot be loaded for any other reason
+   * @throws InvalidPluginException if plugin is supposed to be handled but cannot be loaded for any
+   *     other reason
    */
-  ServerPlugin get(Path srcPath, FileSnapshot snapshot,
-      PluginDescription pluginDescriptor) throws InvalidPluginException;
+  ServerPlugin get(Path srcPath, FileSnapshot snapshot, PluginDescription pluginDescriptor)
+      throws InvalidPluginException;
 
   /**
    * Returns the plugin name of this provider.
    *
-   * Allows to identify which plugin provided the current ServerPluginProvider
-   * by returning the plugin name. Helpful for troubleshooting plugin loading
-   * problems.
+   * <p>Allows to identify which plugin provided the current ServerPluginProvider by returning the
+   * plugin name. Helpful for troubleshooting plugin loading problems.
    *
    * @return plugin name of this provider
    */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/TestServerPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/TestServerPlugin.java
new file mode 100644
index 0000000..dbdc576
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/TestServerPlugin.java
@@ -0,0 +1,80 @@
+// 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.plugins;
+
+import com.google.gerrit.server.PluginUser;
+import java.nio.file.Path;
+
+public class TestServerPlugin extends ServerPlugin {
+  private final ClassLoader classLoader;
+  private String sysName;
+  private String httpName;
+  private String sshName;
+
+  public TestServerPlugin(
+      String name,
+      String pluginCanonicalWebUrl,
+      PluginUser user,
+      ClassLoader classloader,
+      String sysName,
+      String httpName,
+      String sshName,
+      Path dataDir)
+      throws InvalidPluginException {
+    super(name, pluginCanonicalWebUrl, user, null, null, null, dataDir, classloader);
+    this.classLoader = classloader;
+    this.sysName = sysName;
+    this.httpName = httpName;
+    this.sshName = sshName;
+    loadGuiceModules();
+  }
+
+  private void loadGuiceModules() throws InvalidPluginException {
+    try {
+      this.sysModule = load(sysName, classLoader);
+      this.httpModule = load(httpName, classLoader);
+      this.sshModule = load(sshName, classLoader);
+    } catch (ClassNotFoundException e) {
+      throw new InvalidPluginException("Unable to load plugin Guice Modules", e);
+    }
+  }
+
+  @Override
+  public String getVersion() {
+    return "1.0";
+  }
+
+  @Override
+  protected boolean canReload() {
+    return false;
+  }
+
+  @Override
+  // Widen access modifier in derived class
+  public void start(PluginGuiceEnvironment env) throws Exception {
+    super.start(env);
+  }
+
+  @Override
+  // Widen access modifier in derived class
+  public void stop(PluginGuiceEnvironment env) {
+    super.stop(env);
+  }
+
+  @Override
+  public PluginContentScanner getContentScanner() {
+    return null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
index afdc5b3..91441d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
@@ -17,14 +17,12 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.internal.storage.file.FileSnapshot;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 class UniversalServerPluginProvider implements ServerPluginProvider {
@@ -38,8 +36,8 @@
   }
 
   @Override
-  public ServerPlugin get(Path srcPath, FileSnapshot snapshot,
-      PluginDescription pluginDescription) throws InvalidPluginException {
+  public ServerPlugin get(Path srcPath, FileSnapshot snapshot, PluginDescription pluginDescription)
+      throws InvalidPluginException {
     return providerOf(srcPath).get(srcPath, snapshot, pluginDescription);
   }
 
@@ -67,8 +65,7 @@
   }
 
   private ServerPluginProvider providerOf(Path srcPath) {
-    List<ServerPluginProvider> providers =
-        providersForHandlingPlugin(srcPath);
+    List<ServerPluginProvider> providers = providersForHandlingPlugin(srcPath);
     switch (providers.size()) {
       case 1:
         return providers.get(0);
@@ -81,13 +78,15 @@
     }
   }
 
-  private List<ServerPluginProvider> providersForHandlingPlugin(
-      final Path srcPath) {
+  private List<ServerPluginProvider> providersForHandlingPlugin(final Path srcPath) {
     List<ServerPluginProvider> providers = new ArrayList<>();
     for (ServerPluginProvider serverPluginProvider : serverPluginProviders) {
       boolean handles = serverPluginProvider.handles(srcPath);
-      log.debug("File {} handled by {} ? => {}", srcPath,
-          serverPluginProvider.getProviderPluginName(), handles);
+      log.debug(
+          "File {} handled by {} ? => {}",
+          srcPath,
+          serverPluginProvider.getProviderPluginName(),
+          handles);
       if (handles) {
         providers.add(serverPluginProvider);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
index 8e85fd0..5c0d8d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
@@ -27,16 +27,15 @@
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroupsProvider;
 import com.google.inject.TypeLiteral;
-
 import java.util.Set;
 
 public class AccessControlModule extends FactoryModule {
   @Override
   protected void configure() {
     bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
-      .annotatedWith(AdministrateServerGroups.class)
-      .toProvider(AdministrateServerGroupsProvider.class)
-      .in(SINGLETON);
+        .annotatedWith(AdministrateServerGroups.class)
+        .toProvider(AdministrateServerGroupsProvider.class)
+        .in(SINGLETON);
 
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
         .annotatedWith(GitUploadPackGroups.class)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
index f0c2b78..41e8fbc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -25,13 +24,11 @@
 import com.google.gerrit.server.project.BanCommit.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class BanCommit implements RestModifyView<ProjectResource, Input> {
@@ -59,8 +56,7 @@
 
   @Override
   public BanResultInfo apply(ProjectResource rsrc, Input input)
-      throws UnprocessableEntityException, AuthException,
-      ResourceConflictException, IOException {
+      throws UnprocessableEntityException, AuthException, ResourceConflictException, IOException {
     BanResultInfo r = new BanResultInfo();
     if (input != null && input.commits != null && !input.commits.isEmpty()) {
       List<ObjectId> commitsToBan = new ArrayList<>(input.commits.size());
@@ -73,8 +69,7 @@
       }
 
       try {
-        BanCommitResult result =
-            banCommit.ban(rsrc.getControl(), commitsToBan, input.reason);
+        BanCommitResult result = banCommit.ban(rsrc.getControl(), commitsToBan, input.reason);
         r.newlyBanned = transformCommits(result.getNewlyBannedCommits());
         r.alreadyBanned = transformCommits(result.getAlreadyBannedCommits());
         r.ignored = transformCommits(result.getIgnoredObjectIds());
@@ -91,14 +86,7 @@
     if (commits == null || commits.isEmpty()) {
       return null;
     }
-
-    return Lists.transform(commits,
-        new Function<ObjectId, String>() {
-          @Override
-          public String apply(ObjectId id) {
-            return id.getName();
-          }
-        });
+    return Lists.transform(commits, ObjectId::getName);
   }
 
   public static class BanResultInfo {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java
index 7168b1b2..db23967 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.inject.TypeLiteral;
 
-public class BranchResource extends ProjectResource {
+public class BranchResource extends RefResource {
   public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND =
       new TypeLiteral<RestView<BranchResource>>() {};
 
@@ -38,10 +38,12 @@
     return new Branch.NameKey(getNameKey(), branchInfo.ref);
   }
 
+  @Override
   public String getRef() {
     return branchInfo.ref;
   }
 
+  @Override
   public String getRevision() {
     return branchInfo.revision;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java
index 41d4920..6867dce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java
@@ -26,23 +26,22 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Constants;
-
 import java.io.IOException;
 import java.util.List;
+import org.eclipse.jgit.lib.Constants;
 
 @Singleton
-public class BranchesCollection implements
-    ChildCollection<ProjectResource, BranchResource>,
-    AcceptsCreate<ProjectResource> {
+public class BranchesCollection
+    implements ChildCollection<ProjectResource, BranchResource>, AcceptsCreate<ProjectResource> {
   private final DynamicMap<RestView<BranchResource>> views;
   private final Provider<ListBranches> list;
   private final CreateBranch.Factory createBranchFactory;
 
   @Inject
-  BranchesCollection(DynamicMap<RestView<BranchResource>> views,
-      Provider<ListBranches> list, CreateBranch.Factory createBranchFactory) {
+  BranchesCollection(
+      DynamicMap<RestView<BranchResource>> views,
+      Provider<ListBranches> list,
+      CreateBranch.Factory createBranchFactory) {
     this.views = views;
     this.list = list;
     this.createBranchFactory = createBranchFactory;
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 9086b6a..40be5f5 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
@@ -36,12 +36,10 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
 
-
 /** Access control management for a user accessing a single change. */
 public class ChangeControl {
   @Singleton
@@ -50,25 +48,22 @@
     private final ChangeNotes.Factory notesFactory;
 
     @Inject
-    GenericFactory(
-        ProjectControl.GenericFactory p,
-        ChangeNotes.Factory n) {
+    GenericFactory(ProjectControl.GenericFactory p, ChangeNotes.Factory n) {
       projectControl = p;
       notesFactory = n;
     }
 
-    public ChangeControl controlFor(ReviewDb db, Project.NameKey project,
-        Change.Id changeId, CurrentUser user)
-        throws NoSuchChangeException, OrmException {
+    public ChangeControl controlFor(
+        ReviewDb db, Project.NameKey project, Change.Id changeId, CurrentUser user)
+        throws OrmException {
       return controlFor(notesFactory.create(db, project, changeId), user);
     }
 
-    public ChangeControl controlFor(ReviewDb db, Change change,
-        CurrentUser user) throws NoSuchChangeException, OrmException {
+    public ChangeControl controlFor(ReviewDb db, Change change, CurrentUser user)
+        throws OrmException {
       final Project.NameKey projectKey = change.getProject();
       try {
-        return projectControl.controlFor(projectKey, user)
-            .controlFor(db, change);
+        return projectControl.controlFor(projectKey, user).controlFor(db, change);
       } catch (NoSuchProjectException e) {
         throw new NoSuchChangeException(change.getId(), e);
       } catch (IOException e) {
@@ -80,20 +75,19 @@
     public ChangeControl controlFor(ChangeNotes notes, CurrentUser user)
         throws NoSuchChangeException {
       try {
-        return projectControl.controlFor(notes.getProjectName(), user)
-            .controlFor(notes);
+        return projectControl.controlFor(notes.getProjectName(), user).controlFor(notes);
       } catch (NoSuchProjectException | IOException e) {
         throw new NoSuchChangeException(notes.getChangeId(), e);
       }
     }
 
-    public ChangeControl validateFor(ReviewDb db, Change.Id changeId,
-        CurrentUser user) throws NoSuchChangeException, OrmException {
+    public ChangeControl validateFor(ReviewDb db, Change.Id changeId, CurrentUser user)
+        throws OrmException {
       return validateFor(db, notesFactory.createChecked(changeId), user);
     }
 
-    public ChangeControl validateFor(ReviewDb db, ChangeNotes notes,
-        CurrentUser user) throws NoSuchChangeException, OrmException {
+    public ChangeControl validateFor(ReviewDb db, ChangeNotes notes, CurrentUser user)
+        throws OrmException {
       ChangeControl c = controlFor(notes, user);
       if (!c.isVisible(db)) {
         throw new NoSuchChangeException(c.getId());
@@ -110,7 +104,8 @@
     private final PatchSetUtil patchSetUtil;
 
     @Inject
-    Factory(ChangeData.Factory changeDataFactory,
+    Factory(
+        ChangeData.Factory changeDataFactory,
         ChangeNotes.Factory notesFactory,
         ApprovalsUtil approvalsUtil,
         PatchSetUtil patchSetUtil) {
@@ -120,16 +115,15 @@
       this.patchSetUtil = patchSetUtil;
     }
 
-    ChangeControl create(RefControl refControl, ReviewDb db, Project.NameKey
-        project, Change.Id changeId) throws OrmException {
-      return create(refControl,
-          notesFactory.create(db, project, changeId));
+    ChangeControl create(
+        RefControl refControl, ReviewDb db, Project.NameKey project, Change.Id changeId)
+        throws OrmException {
+      return create(refControl, notesFactory.create(db, project, changeId));
     }
 
     /**
-     * Create a change control for a change that was loaded from index. This
-     * method should only be used when database access is harmful and potentially
-     * stale data from the index is acceptable.
+     * Create a change control for a change that was loaded from index. This method should only be
+     * used when database access is harmful and potentially stale data from the index is acceptable.
      *
      * @param refControl ref control
      * @param change change loaded from secondary index
@@ -140,8 +134,7 @@
     }
 
     ChangeControl create(RefControl refControl, ChangeNotes notes) {
-      return new ChangeControl(changeDataFactory, approvalsUtil, refControl,
-          notes, patchSetUtil);
+      return new ChangeControl(changeDataFactory, approvalsUtil, refControl, notes, patchSetUtil);
     }
   }
 
@@ -168,8 +161,8 @@
     if (getUser().equals(who)) {
       return this;
     }
-    return new ChangeControl(changeDataFactory, approvalsUtil,
-        getRefControl().forUser(who), notes, patchSetUtil);
+    return new ChangeControl(
+        changeDataFactory, approvalsUtil, getRefControl().forUser(who), notes, patchSetUtil);
   }
 
   public RefControl getRefControl() {
@@ -206,10 +199,8 @@
   }
 
   /** Can this user see this change? */
-  public boolean isVisible(ReviewDb db, @Nullable ChangeData cd)
-      throws OrmException {
-    if (getChange().getStatus() == Change.Status.DRAFT
-        && !isDraftVisible(db, cd)) {
+  public boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+    if (getChange().getStatus() == Change.Status.DRAFT && !isDraftVisible(db, cd)) {
       return false;
     }
     return isRefVisible();
@@ -229,10 +220,9 @@
   }
 
   /** Can this user see the given patchset? */
-  public boolean isPatchVisible(PatchSet ps, ChangeData cd)
-      throws OrmException {
-    checkArgument(cd.getId().equals(ps.getId().getParentKey()),
-        "%s not for change %s", ps, cd.getId());
+  public boolean isPatchVisible(PatchSet ps, ChangeData cd) throws OrmException {
+    checkArgument(
+        cd.getId().equals(ps.getId().getParentKey()), "%s not for change %s", ps, cd.getId());
     if (ps.isDraft() && !isDraftVisible(cd.db(), cd)) {
       return false;
     }
@@ -242,35 +232,46 @@
   /** Can this user abandon this change? */
   public boolean canAbandon(ReviewDb db) throws OrmException {
     return (isOwner() // owner (aka creator) of the change can abandon
-        || getRefControl().isOwner() // branch owner can abandon
-        || getProjectControl().isOwner() // project owner can abandon
-        || getUser().getCapabilities().canAdministrateServer() // site administers are god
-        || getRefControl().canAbandon() // user can abandon a specific ref
-        ) && !isPatchSetLocked(db);
+            || getRefControl().isOwner() // branch owner can abandon
+            || getProjectControl().isOwner() // project owner can abandon
+            || getUser().getCapabilities().canAdministrateServer() // site administers are god
+            || getRefControl().canAbandon() // user can abandon a specific ref
+        )
+        && !isPatchSetLocked(db);
   }
 
-  /** Can this user change the destination branch of this change
-      to the new ref? */
+  /** Can this user change the destination branch of this change to the new ref? */
   public boolean canMoveTo(String ref, ReviewDb db) throws OrmException {
     return getProjectControl().controlForRef(ref).canUpload() && canAbandon(db);
   }
 
   /** Can this user publish this draft change or any draft patch set of this change? */
   public boolean canPublish(final ReviewDb db) throws OrmException {
-    return (isOwner() || getRefControl().canPublishDrafts())
-        && isVisible(db);
+    return (isOwner() || getRefControl().canPublishDrafts()) && isVisible(db);
   }
 
-  /** Can this user delete this draft change or any draft patch set of this change? */
-  public boolean canDeleteDraft(final ReviewDb db) throws OrmException {
-    return (isOwner() || getRefControl().canDeleteDrafts())
-        && isVisible(db);
+  /** Can this user delete this change or any patch set of this change? */
+  public boolean canDelete(ReviewDb db, Change.Status status) throws OrmException {
+    if (!isVisible(db)) {
+      return false;
+    }
+
+    switch (status) {
+      case DRAFT:
+        return (isOwner() || getRefControl().canDeleteDrafts());
+      case NEW:
+      case ABANDONED:
+        return (isAdmin() || getRefControl().canDeleteChanges(isOwner()));
+      case MERGED:
+      default:
+        return false;
+    }
   }
 
   /** Can this user rebase this change? */
   public boolean canRebase(ReviewDb db) throws OrmException {
-    return (isOwner() || getRefControl().canSubmit()
-        || getRefControl().canRebase()) && !isPatchSetLocked(db);
+    return (isOwner() || getRefControl().canSubmit(isOwner()) || getRefControl().canRebase())
+        && !isPatchSetLocked(db);
   }
 
   /** Can this user restore this change? */
@@ -291,8 +292,7 @@
         r.add(l);
       } else {
         for (String refPattern : refs) {
-          if (RefConfigSection.isValid(refPattern)
-              && match(destBranch, refPattern)) {
+          if (RefConfigSection.isValid(refPattern) && match(destBranch, refPattern)) {
             r.add(l);
             break;
           }
@@ -332,10 +332,11 @@
       return false;
     }
 
-    for (PatchSetApproval ap : approvalsUtil.byPatchSet(db, this,
-        getChange().currentPatchSetId())) {
+    for (PatchSetApproval ap :
+        approvalsUtil.byPatchSet(db, this, getChange().currentPatchSetId())) {
       LabelType type = getLabelTypes().byLabel(ap.getLabel());
-      if (type != null && ap.getValue() == 1
+      if (type != null
+          && ap.getValue() == 1
           && type.getFunctionName().equalsIgnoreCase("PatchSetLock")) {
         return true;
       }
@@ -352,14 +353,23 @@
     return false;
   }
 
+  /** Is this user assigned to this change? */
+  public boolean isAssignee() {
+    Account.Id currentAssignee = notes.getChange().getAssignee();
+    if (currentAssignee != null && getUser().isIdentifiedUser()) {
+      Account.Id id = getUser().getAccountId();
+      return id.equals(currentAssignee);
+    }
+    return false;
+  }
+
   /** Is this user a reviewer for the change? */
   public boolean isReviewer(ReviewDb db) throws OrmException {
     return isReviewer(db, null);
   }
 
   /** Is this user a reviewer for the change? */
-  public boolean isReviewer(ReviewDb db, @Nullable ChangeData cd)
-      throws OrmException {
+  public boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
     if (getUser().isIdentifiedUser()) {
       Collection<Account.Id> results = changeData(db, cd).reviewers().all();
       return results.contains(getUser().getAccountId());
@@ -367,6 +377,10 @@
     return false;
   }
 
+  public boolean isAdmin() {
+    return getUser().getCapabilities().canAdministrateServer();
+  }
+
   /** @return true if the user is allowed to remove this reviewer. */
   public boolean canRemoveReviewer(PatchSetApproval approval) {
     return canRemoveReviewer(approval.getAccountId(), approval.getValue());
@@ -414,17 +428,36 @@
     return getRefControl().canForceEditTopicName();
   }
 
+  /** Can this user edit the description? */
+  public boolean canEditDescription() {
+    if (getChange().getStatus().isOpen()) {
+      return isOwner() // owner (aka creator) of the change can edit desc
+          || getRefControl().isOwner() // branch owner can edit desc
+          || getProjectControl().isOwner() // project owner can edit desc
+          || getUser().getCapabilities().canAdministrateServer() // site administers are god
+      ;
+    }
+    return false;
+  }
+
+  public boolean canEditAssignee() {
+    return isOwner()
+        || getProjectControl().isOwner()
+        || getRefControl().canEditAssignee()
+        || isAssignee();
+  }
+
   /** Can this user edit the hashtag name? */
   public boolean canEditHashtags() {
     return isOwner() // owner (aka creator) of the change can edit hashtags
-          || getRefControl().isOwner() // branch owner can edit hashtags
-          || getProjectControl().isOwner() // project owner can edit hashtags
-          || getUser().getCapabilities().canAdministrateServer() // site administers are god
-          || getRefControl().canEditHashtags(); // user can edit hashtag on a specific ref
+        || getRefControl().isOwner() // branch owner can edit hashtags
+        || getProjectControl().isOwner() // project owner can edit hashtags
+        || getUser().getCapabilities().canAdministrateServer() // site administers are god
+        || getRefControl().canEditHashtags(); // user can edit hashtag on a specific ref
   }
 
   public boolean canSubmit() {
-    return getRefControl().canSubmit();
+    return getRefControl().canSubmit(isOwner());
   }
 
   public boolean canSubmitAs() {
@@ -432,17 +465,17 @@
   }
 
   private boolean match(String destBranch, String refPattern) {
-    return RefPatternMatcher.getMatcher(refPattern).match(destBranch,
-        getUser());
+    return RefPatternMatcher.getMatcher(refPattern).match(destBranch, getUser());
   }
 
   private ChangeData changeData(ReviewDb db, @Nullable ChangeData cd) {
     return cd != null ? cd : changeDataFactory.create(db, this);
   }
 
-  public boolean isDraftVisible(ReviewDb db, ChangeData cd)
-      throws OrmException {
-    return isOwner() || isReviewer(db, cd) || getRefControl().canViewDrafts()
+  public boolean isDraftVisible(ReviewDb db, ChangeData cd) throws OrmException {
+    return isOwner()
+        || isReviewer(db, cd)
+        || getRefControl().canViewDrafts()
         || getUser().isInternalUser();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
index 37d5295..72ebd62 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Ref;
@@ -37,11 +37,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-
-/**
- * Check the mergeability at current branch for a git object references expression.
- */
+/** Check the mergeability at current branch for a git object references expression. */
 public class CheckMergeability implements RestReadView<BranchResource> {
 
   private String source;
@@ -49,18 +45,22 @@
   private SubmitType submitType;
   private final Provider<ReviewDb> db;
 
-  @Option(name = "--source", metaVar = "COMMIT",
-      usage = "the source reference to merge, which could be any git object "
-          + "references expression, refer to "
-          + "org.eclipse.jgit.lib.Repository#resolve(String)",
+  @Option(
+      name = "--source",
+      metaVar = "COMMIT",
+      usage =
+          "the source reference to merge, which could be any git object "
+              + "references expression, refer to "
+              + "org.eclipse.jgit.lib.Repository#resolve(String)",
       required = true)
   public void setSource(String source) {
     this.source = source;
   }
 
-  @Option(name = "--strategy", metaVar = "STRATEGY",
-      usage = "name of the merge strategy, refer to "
-          + "org.eclipse.jgit.merge.MergeStrategy")
+  @Option(
+      name = "--strategy",
+      metaVar = "STRATEGY",
+      usage = "name of the merge strategy, refer to org.eclipse.jgit.merge.MergeStrategy")
   public void setStrategy(String strategy) {
     this.strategy = strategy;
   }
@@ -68,31 +68,28 @@
   private final GitRepositoryManager gitManager;
 
   @Inject
-  CheckMergeability(GitRepositoryManager gitManager,
-      @GerritServerConfig Config cfg,
-      Provider<ReviewDb> db) {
+  CheckMergeability(
+      GitRepositoryManager gitManager, @GerritServerConfig Config cfg, Provider<ReviewDb> db) {
     this.gitManager = gitManager;
     this.strategy = MergeUtil.getMergeStrategy(cfg).getName();
-    this.submitType = cfg.getEnum("project", null, "submitType",
-        SubmitType.MERGE_IF_NECESSARY);
+    this.submitType = cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
     this.db = db;
   }
 
   @Override
   public MergeableInfo apply(BranchResource resource)
       throws IOException, BadRequestException, ResourceNotFoundException {
-    if (!(submitType.equals(SubmitType.MERGE_ALWAYS) ||
-          submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
-      throw new BadRequestException(
-          "Submit type: " + submitType + " is not supported");
+    if (!(submitType.equals(SubmitType.MERGE_ALWAYS)
+        || submitType.equals(SubmitType.MERGE_IF_NECESSARY))) {
+      throw new BadRequestException("Submit type: " + submitType + " is not supported");
     }
 
     MergeableInfo result = new MergeableInfo();
     result.submitType = submitType;
     result.strategy = strategy;
     try (Repository git = gitManager.openRepository(resource.getNameKey());
-         RevWalk rw = new RevWalk(git);
-         ObjectInserter inserter = new InMemoryInserter(git)) {
+        RevWalk rw = new RevWalk(git);
+        ObjectInserter inserter = new InMemoryInserter(git)) {
       Merger m = MergeUtil.newMerger(git, inserter, strategy);
 
       Ref destRef = git.getRefDatabase().exactRef(resource.getRef());
@@ -104,8 +101,7 @@
       RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, source);
 
       if (!resource.getControl().canReadCommit(db.get(), git, sourceCommit)) {
-        throw new BadRequestException(
-            "do not have read permission for: " + source);
+        throw new BadRequestException("do not have read permission for: " + source);
       }
 
       if (rw.isMergedInto(sourceCommit, targetCommit)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
index 4257825..f0d127d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
@@ -40,9 +40,7 @@
   }
 
   public boolean isDirectChild() {
-    ProjectState firstParent =
-        Iterables.getFirst(child.getProjectState().parents(), null);
-    return firstParent != null
-        && parent.getNameKey().equals(firstParent.getProject().getNameKey());
+    ProjectState firstParent = Iterables.getFirst(child.getProjectState().parents(), null);
+    return firstParent != null && parent.getNameKey().equals(firstParent.getProject().getNameKey());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
index faba87a..7aa5f68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
@@ -24,18 +24,18 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 
 @Singleton
-public class ChildProjectsCollection implements
-    ChildCollection<ProjectResource, ChildProjectResource> {
+public class ChildProjectsCollection
+    implements ChildCollection<ProjectResource, ChildProjectResource> {
   private final Provider<ListChildProjects> list;
   private final ProjectsCollection projectsCollection;
   private final DynamicMap<RestView<ChildProjectResource>> views;
 
   @Inject
-  ChildProjectsCollection(Provider<ListChildProjects> list,
+  ChildProjectsCollection(
+      Provider<ListChildProjects> list,
       ProjectsCollection projectsCollection,
       DynamicMap<RestView<ChildProjectResource>> views) {
     this.list = list;
@@ -44,16 +44,14 @@
   }
 
   @Override
-  public ListChildProjects list() throws ResourceNotFoundException,
-      AuthException {
+  public ListChildProjects list() throws ResourceNotFoundException, AuthException {
     return list.get();
   }
 
   @Override
   public ChildProjectResource parse(ProjectResource parent, IdString id)
       throws ResourceNotFoundException, IOException {
-    ProjectResource p =
-        projectsCollection.parse(TopLevelResource.INSTANCE, id);
+    ProjectResource p = projectsCollection.parse(TopLevelResource.INSTANCE, id);
     for (ProjectState pp : p.getControl().getProjectState().parents()) {
       if (parent.getNameKey().equals(pp.getProject().getNameKey())) {
         return new ChildProjectResource(parent, p.getControl());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
index ef5af20..35de963 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
@@ -43,16 +43,15 @@
     }
   }
 
-  public CommentLinkInfoImpl(String name, String match, String link, String html,
-      Boolean enabled) {
+  public CommentLinkInfoImpl(String name, String match, String link, String html, Boolean enabled) {
     checkArgument(name != null, "invalid commentlink.name");
-    checkArgument(!Strings.isNullOrEmpty(match),
-        "invalid commentlink.%s.match", name);
+    checkArgument(!Strings.isNullOrEmpty(match), "invalid commentlink.%s.match", name);
     link = Strings.emptyToNull(link);
     html = Strings.emptyToNull(html);
     checkArgument(
         (link != null && html == null) || (link == null && html != null),
-        "commentlink.%s must have either link or html", name);
+        "commentlink.%s must have either link or html",
+        name);
     this.name = name;
     this.match = match;
     this.link = link;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
index f151b59..0d2452c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -21,17 +21,14 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.List;
-import java.util.Set;
-
 public class CommentLinkProvider implements Provider<List<CommentLinkInfo>> {
-  private static final Logger log =
-      LoggerFactory.getLogger(CommentLinkProvider.class);
+  private static final Logger log = LoggerFactory.getLogger(CommentLinkProvider.class);
 
   private final Config cfg;
 
@@ -43,8 +40,7 @@
   @Override
   public List<CommentLinkInfo> get() {
     Set<String> subsections = cfg.getSubsections(ProjectConfig.COMMENTLINK);
-    List<CommentLinkInfo> cls =
-        Lists.newArrayListWithCapacity(subsections.size());
+    List<CommentLinkInfo> cls = Lists.newArrayListWithCapacity(subsections.size());
     for (String name : subsections) {
       try {
         CommentLinkInfoImpl cl = ProjectConfig.buildCommentLink(cfg, name, true);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
new file mode 100644
index 0000000..8d9127b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.IncludedIn;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+class CommitIncludedIn implements RestReadView<CommitResource> {
+  private IncludedIn includedIn;
+
+  @Inject
+  CommitIncludedIn(IncludedIn includedIn) {
+    this.includedIn = includedIn;
+  }
+
+  @Override
+  public IncludedInInfo apply(CommitResource rsrc)
+      throws RestApiException, OrmException, IOException {
+    RevCommit commit = rsrc.getCommit();
+    Project.NameKey project = rsrc.getProject().getProject().getNameKey();
+    return includedIn.apply(project, commit.getId().getName());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java
index 36186a4..8065e0f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
-
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public class CommitResource implements RestResource {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
index 3deb7d6..d481c014 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
@@ -24,7 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -32,17 +32,15 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-
 @Singleton
-public class CommitsCollection implements
-    ChildCollection<ProjectResource, CommitResource> {
+public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
   private final DynamicMap<RestView<CommitResource>> views;
   private final GitRepositoryManager repoManager;
   private final Provider<ReviewDb> db;
 
   @Inject
-  public CommitsCollection(DynamicMap<RestView<CommitResource>> views,
+  public CommitsCollection(
+      DynamicMap<RestView<CommitResource>> views,
       GitRepositoryManager repoManager,
       Provider<ReviewDb> db) {
     this.views = views;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
index a7ba217..62b8c9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
@@ -30,18 +30,17 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.project.ProjectState.EffectiveMaxObjectSizeLimit;
 import com.google.inject.util.Providers;
-
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.TreeMap;
 
 public class ConfigInfoImpl extends ConfigInfo {
-  public ConfigInfoImpl(boolean serverEnableSignedPush,
+  public ConfigInfoImpl(
+      boolean serverEnableSignedPush,
       ProjectControl control,
-      TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
@@ -50,13 +49,11 @@
     Project p = control.getProject();
     this.description = Strings.emptyToNull(p.getDescription());
 
-    InheritedBooleanInfo useContributorAgreements =
-        new InheritedBooleanInfo();
+    InheritedBooleanInfo useContributorAgreements = new InheritedBooleanInfo();
     InheritedBooleanInfo useSignedOffBy = new InheritedBooleanInfo();
     InheritedBooleanInfo useContentMerge = new InheritedBooleanInfo();
     InheritedBooleanInfo requireChangeId = new InheritedBooleanInfo();
-    InheritedBooleanInfo createNewChangeForAllNotInTarget =
-        new InheritedBooleanInfo();
+    InheritedBooleanInfo createNewChangeForAllNotInTarget = new InheritedBooleanInfo();
     InheritedBooleanInfo enableSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo requireSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo rejectImplicitMerges = new InheritedBooleanInfo();
@@ -65,25 +62,20 @@
     useSignedOffBy.value = projectState.isUseSignedOffBy();
     useContentMerge.value = projectState.isUseContentMerge();
     requireChangeId.value = projectState.isRequireChangeID();
-    createNewChangeForAllNotInTarget.value =
-        projectState.isCreateNewChangeForAllNotInTarget();
+    createNewChangeForAllNotInTarget.value = projectState.isCreateNewChangeForAllNotInTarget();
 
-    useContributorAgreements.configuredValue =
-        p.getUseContributorAgreements();
+    useContributorAgreements.configuredValue = p.getUseContributorAgreements();
     useSignedOffBy.configuredValue = p.getUseSignedOffBy();
     useContentMerge.configuredValue = p.getUseContentMerge();
     requireChangeId.configuredValue = p.getRequireChangeID();
-    createNewChangeForAllNotInTarget.configuredValue =
-        p.getCreateNewChangeForAllNotInTarget();
+    createNewChangeForAllNotInTarget.configuredValue = p.getCreateNewChangeForAllNotInTarget();
     enableSignedPush.configuredValue = p.getEnableSignedPush();
     requireSignedPush.configuredValue = p.getRequireSignedPush();
     rejectImplicitMerges.configuredValue = p.getRejectImplicitMerges();
 
-    ProjectState parentState = Iterables.getFirst(projectState
-        .parents(), null);
+    ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
     if (parentState != null) {
-      useContributorAgreements.inheritedValue =
-          parentState.isUseContributorAgreements();
+      useContributorAgreements.inheritedValue = parentState.isUseContributorAgreements();
       useSignedOffBy.inheritedValue = parentState.isUseSignedOffBy();
       useContentMerge.inheritedValue = parentState.isUseContentMerge();
       requireChangeId.inheritedValue = parentState.isRequireChangeID();
@@ -105,18 +97,13 @@
       this.requireSignedPush = requireSignedPush;
     }
 
-    MaxObjectSizeLimitInfo maxObjectSizeLimit = new MaxObjectSizeLimitInfo();
-    maxObjectSizeLimit.value =
-        config.getEffectiveMaxObjectSizeLimit(projectState) == config
-            .getMaxObjectSizeLimit() ? config
-            .getFormattedMaxObjectSizeLimit() : p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.configuredValue = p.getMaxObjectSizeLimit();
-    maxObjectSizeLimit.inheritedValue =
-        config.getFormattedMaxObjectSizeLimit();
-    this.maxObjectSizeLimit = maxObjectSizeLimit;
+    this.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
 
     this.submitType = p.getSubmitType();
-    this.state = p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE ? p.getState() : null;
+    this.state =
+        p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE
+            ? p.getState()
+            : null;
 
     this.commentlinks = new LinkedHashMap<>();
     for (CommentLinkInfo cl : projectState.getCommentLinks()) {
@@ -124,26 +111,35 @@
     }
 
     pluginConfig =
-        getPluginConfig(control.getProjectState(), pluginConfigEntries,
-            cfgFactory, allProjects);
+        getPluginConfig(control.getProjectState(), pluginConfigEntries, cfgFactory, allProjects);
 
     actions = new TreeMap<>();
-    for (UiAction.Description d : UiActions.from(
-        views, new ProjectResource(control),
-        Providers.of(control.getUser()))) {
+    for (UiAction.Description d :
+        UiActions.from(views, new ProjectResource(control), Providers.of(control.getUser()))) {
       actions.put(d.getId(), new ActionInfo(d));
     }
     this.theme = projectState.getTheme();
   }
 
+  private MaxObjectSizeLimitInfo getMaxObjectSizeLimit(ProjectState projectState, Project p) {
+    MaxObjectSizeLimitInfo info = new MaxObjectSizeLimitInfo();
+    EffectiveMaxObjectSizeLimit limit = projectState.getEffectiveMaxObjectSizeLimit();
+    long value = limit.value;
+    info.value = value == 0 ? null : String.valueOf(value);
+    info.configuredValue = p.getMaxObjectSizeLimit();
+    info.summary = limit.summary;
+    return info;
+  }
+
   private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
-      ProjectState project, DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      PluginConfigFactory cfgFactory, AllProjectsName allProjects) {
+      ProjectState project,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      PluginConfigFactory cfgFactory,
+      AllProjectsName allProjects) {
     TreeMap<String, Map<String, ConfigParameterInfo>> pluginConfig = new TreeMap<>();
     for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
       ProjectConfigEntry configEntry = e.getProvider().get();
-      PluginConfig cfg =
-          cfgFactory.getFromProjectConfig(project, e.getPluginName());
+      PluginConfig cfg = cfgFactory.getFromProjectConfig(project, e.getPluginName());
       String configuredValue = cfg.getString(e.getExportName());
       ConfigParameterInfo p = new ConfigParameterInfo();
       p.displayName = configEntry.getDisplayName();
@@ -152,25 +148,25 @@
       p.type = configEntry.getType();
       p.permittedValues = configEntry.getPermittedValues();
       p.editable = configEntry.isEditable(project) ? true : null;
-      if (configEntry.isInheritable()
-          && !allProjects.equals(project.getProject().getNameKey())) {
+      if (configEntry.isInheritable() && !allProjects.equals(project.getProject().getNameKey())) {
         PluginConfig cfgWithInheritance =
-            cfgFactory.getFromProjectConfigWithInheritance(project,
-                e.getPluginName());
+            cfgFactory.getFromProjectConfigWithInheritance(project, e.getPluginName());
         p.inheritable = true;
-        p.value = configEntry.onRead(project,
-            cfgWithInheritance.getString(e.getExportName(),
-                configEntry.getDefaultValue()));
+        p.value =
+            configEntry.onRead(
+                project,
+                cfgWithInheritance.getString(e.getExportName(), configEntry.getDefaultValue()));
         p.configuredValue = configuredValue;
         p.inheritedValue = getInheritedValue(project, cfgFactory, e);
       } else {
         if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-          p.values = configEntry.onRead(project,
-              Arrays.asList(cfg.getStringList(e.getExportName())));
+          p.values =
+              configEntry.onRead(project, Arrays.asList(cfg.getStringList(e.getExportName())));
         } else {
-          p.value = configEntry.onRead(project, configuredValue != null
-              ? configuredValue
-              : configEntry.getDefaultValue());
+          p.value =
+              configEntry.onRead(
+                  project,
+                  configuredValue != null ? configuredValue : configEntry.getDefaultValue());
         }
       }
       Map<String, ConfigParameterInfo> pc = pluginConfig.get(e.getPluginName());
@@ -183,18 +179,16 @@
     return !pluginConfig.isEmpty() ? pluginConfig : null;
   }
 
-  private String getInheritedValue(ProjectState project,
-      PluginConfigFactory cfgFactory, Entry<ProjectConfigEntry> e) {
+  private String getInheritedValue(
+      ProjectState project, PluginConfigFactory cfgFactory, Entry<ProjectConfigEntry> e) {
     ProjectConfigEntry configEntry = e.getProvider().get();
     ProjectState parent = Iterables.getFirst(project.parents(), null);
     String inheritedValue = configEntry.getDefaultValue();
     if (parent != null) {
       PluginConfig parentCfgWithInheritance =
-          cfgFactory.getFromProjectConfigWithInheritance(parent,
-              e.getPluginName());
+          cfgFactory.getFromProjectConfigWithInheritance(parent, e.getPluginName());
       inheritedValue =
-          parentCfgWithInheritance.getString(e.getExportName(),
-              configEntry.getDefaultValue());
+          parentCfgWithInheritance.getString(e.getExportName(), configEntry.getDefaultValue());
     }
     return inheritedValue;
   }
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 c7b2922..422607b 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
@@ -30,7 +30,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -42,8 +42,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 public class CreateBranch implements RestModifyView<ProjectResource, BranchInput> {
   private static final Logger log = LoggerFactory.getLogger(CreateBranch.class);
 
@@ -59,7 +57,8 @@
   private String ref;
 
   @Inject
-  CreateBranch(Provider<IdentifiedUser> identifiedUser,
+  CreateBranch(
+      Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
       Provider<ReviewDb> db,
       GitReferenceUpdated referenceUpdated,
@@ -69,15 +68,13 @@
     this.repoManager = repoManager;
     this.db = db;
     this.referenceUpdated = referenceUpdated;
-    this.refCreationValidator =
-        refHelperFactory.create(ReceiveCommand.Type.CREATE);
+    this.refCreationValidator = refHelperFactory.create(ReceiveCommand.Type.CREATE);
     this.ref = ref;
   }
 
   @Override
   public BranchInfo apply(ProjectResource rsrc, BranchInput input)
-      throws BadRequestException, AuthException, ResourceConflictException,
-      IOException {
+      throws BadRequestException, AuthException, ResourceConflictException, IOException {
     if (input == null) {
       input = new BranchInput();
     }
@@ -95,8 +92,10 @@
       throw new BadRequestException("invalid branch name \"" + ref + "\"");
     }
     if (MagicBranch.isMagicBranch(ref)) {
-      throw new BadRequestException("not allowed to create branches under \""
-          + MagicBranch.getMagicRefNamePrefix(ref) + "\"");
+      throw new BadRequestException(
+          "not allowed to create branches under \""
+              + MagicBranch.getMagicRefNamePrefix(ref)
+              + "\"");
     }
 
     final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
@@ -127,41 +126,45 @@
         u.setNewObjectId(object.copy());
         u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
         u.setRefLogMessage("created via REST from " + input.revision, false);
-        refCreationValidator.validateRefOperation(
-            rsrc.getName(), identifiedUser.get(), u);
+        refCreationValidator.validateRefOperation(rsrc.getName(), identifiedUser.get(), u);
         final RefUpdate.Result result = u.update(rw);
         switch (result) {
           case FAST_FORWARD:
           case NEW:
           case NO_CHANGE:
             referenceUpdated.fire(
-                name.getParentKey(), u, ReceiveCommand.Type.CREATE,
+                name.getParentKey(),
+                u,
+                ReceiveCommand.Type.CREATE,
                 identifiedUser.get().getAccount());
             break;
           case LOCK_FAILURE:
             if (repo.getRefDatabase().exactRef(ref) != null) {
-              throw new ResourceConflictException("branch \"" + ref
-                  + "\" already exists");
+              throw new ResourceConflictException("branch \"" + ref + "\" already exists");
             }
             String refPrefix = RefUtil.getRefPrefix(ref);
             while (!Constants.R_HEADS.equals(refPrefix)) {
               if (repo.getRefDatabase().exactRef(refPrefix) != null) {
-                throw new ResourceConflictException("Cannot create branch \""
-                    + ref + "\" since it conflicts with branch \"" + refPrefix
-                    + "\".");
+                throw new ResourceConflictException(
+                    "Cannot create branch \""
+                        + ref
+                        + "\" since it conflicts with branch \""
+                        + refPrefix
+                        + "\".");
               }
               refPrefix = RefUtil.getRefPrefix(refPrefix);
             }
-            //$FALL-THROUGH$
+            // $FALL-THROUGH$
           case FORCED:
           case IO_FAILURE:
           case NOT_ATTEMPTED:
           case REJECTED:
           case REJECTED_CURRENT_BRANCH:
           case RENAMED:
-          default: {
-            throw new IOException(result.name());
-          }
+          default:
+            {
+              throw new IOException(result.name());
+            }
         }
 
         BranchInfo info = new BranchInfo();
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 fd6e225..ff7e31e 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
@@ -60,7 +60,10 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -75,19 +78,13 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
 @RequiresCapability(GlobalCapability.CREATE_PROJECT)
 public class CreateProject implements RestModifyView<TopLevelResource, ProjectInput> {
   public interface Factory {
     CreateProject create(String name);
   }
 
-  private static final Logger log = LoggerFactory
-      .getLogger(CreateProject.class);
+  private static final Logger log = LoggerFactory.getLogger(CreateProject.class);
 
   private final Provider<ProjectsCollection> projectsCollection;
   private final Provider<GroupsCollection> groupsCollection;
@@ -109,8 +106,10 @@
   private final String name;
 
   @Inject
-  CreateProject(Provider<ProjectsCollection> projectsCollection,
-      Provider<GroupsCollection> groupsCollection, ProjectJson json,
+  CreateProject(
+      Provider<ProjectsCollection> projectsCollection,
+      Provider<GroupsCollection> groupsCollection,
+      ProjectJson json,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
       ProjectControl.GenericFactory projectControlFactory,
       GitRepositoryManager repoManager,
@@ -147,10 +146,9 @@
   }
 
   @Override
-  public Response<ProjectInfo> apply(TopLevelResource resource,
-      ProjectInput input) throws BadRequestException,
-      UnprocessableEntityException, ResourceConflictException,
-      ResourceNotFoundException, IOException, ConfigInvalidException {
+  public Response<ProjectInfo> apply(TopLevelResource resource, ProjectInput input)
+      throws BadRequestException, UnprocessableEntityException, ResourceConflictException,
+          ResourceNotFoundException, IOException, ConfigInvalidException {
     if (input == null) {
       input = new ProjectInput();
     }
@@ -161,44 +159,36 @@
     CreateProjectArgs args = new CreateProjectArgs();
     args.setProjectName(ProjectUtil.stripGitSuffix(name));
 
-    String parentName = MoreObjects.firstNonNull(
-        Strings.emptyToNull(input.parent), allProjects.get());
-    args.newParent =
-        projectsCollection.get().parse(parentName, false).getControl();
+    String parentName =
+        MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
+    args.newParent = projectsCollection.get().parse(parentName, false).getControl();
     args.createEmptyCommit = input.createEmptyCommit;
     args.permissionsOnly = input.permissionsOnly;
     args.projectDescription = Strings.emptyToNull(input.description);
     args.submitType = input.submitType;
     args.branch = normalizeBranchNames(input.branches);
     if (input.owners == null || input.owners.isEmpty()) {
-      args.ownerIds =
-          new ArrayList<>(projectOwnerGroups.create(args.getProject()).get());
+      args.ownerIds = new ArrayList<>(projectOwnerGroups.create(args.getProject()).get());
     } else {
-      args.ownerIds =
-        Lists.newArrayListWithCapacity(input.owners.size());
+      args.ownerIds = Lists.newArrayListWithCapacity(input.owners.size());
       for (String owner : input.owners) {
         args.ownerIds.add(groupsCollection.get().parse(owner).getGroupUUID());
       }
     }
     args.contributorAgreements =
-        MoreObjects.firstNonNull(input.useContributorAgreements,
-            InheritableBoolean.INHERIT);
-    args.signedOffBy =
-        MoreObjects.firstNonNull(input.useSignedOffBy,
-            InheritableBoolean.INHERIT);
+        MoreObjects.firstNonNull(input.useContributorAgreements, InheritableBoolean.INHERIT);
+    args.signedOffBy = MoreObjects.firstNonNull(input.useSignedOffBy, InheritableBoolean.INHERIT);
     args.contentMerge =
         input.submitType == SubmitType.FAST_FORWARD_ONLY
-            ? InheritableBoolean.FALSE : MoreObjects.firstNonNull(
-                input.useContentMerge,
-                InheritableBoolean.INHERIT);
+            ? InheritableBoolean.FALSE
+            : MoreObjects.firstNonNull(input.useContentMerge, InheritableBoolean.INHERIT);
     args.newChangeForAllNotInTarget =
-        MoreObjects.firstNonNull(input.createNewChangeForAllNotInTarget,
-            InheritableBoolean.INHERIT);
+        MoreObjects.firstNonNull(
+            input.createNewChangeForAllNotInTarget, InheritableBoolean.INHERIT);
     args.changeIdRequired =
         MoreObjects.firstNonNull(input.requireChangeId, InheritableBoolean.INHERIT);
     try {
-      args.maxObjectSizeLimit =
-          ProjectConfig.validMaxObjectSizeLimit(input.maxObjectSizeLimit);
+      args.maxObjectSizeLimit = ProjectConfig.validMaxObjectSizeLimit(input.maxObjectSizeLimit);
     } catch (ConfigInvalidException e) {
       throw new BadRequestException(e.getMessage());
     }
@@ -215,8 +205,8 @@
 
     if (input.pluginConfigValues != null) {
       try {
-        ProjectControl projectControl = projectControlFactory.controlFor(
-            p.getNameKey(), identifiedUser.get());
+        ProjectControl projectControl =
+            projectControlFactory.controlFor(p.getNameKey(), identifiedUser.get());
         ConfigInput in = new ConfigInput();
         in.pluginConfigValues = input.pluginConfigValues;
         putConfig.get().apply(projectControl, in);
@@ -229,13 +219,10 @@
   }
 
   private Project createProject(CreateProjectArgs args)
-      throws BadRequestException, ResourceConflictException, IOException,
-      ConfigInvalidException {
+      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     final Project.NameKey nameKey = args.getProject();
     try {
-      final String head =
-          args.permissionsOnly ? RefNames.REFS_CONFIG
-              : args.branch.get(0);
+      final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
       try (Repository repo = repoManager.openRepository(nameKey)) {
         if (repo.getObjectDatabase().exists()) {
           throw new ResourceConflictException("project \"" + nameKey + "\" exists");
@@ -250,8 +237,7 @@
 
         createProjectConfig(args);
 
-        if (!args.permissionsOnly
-            && args.createEmptyCommit) {
+        if (!args.permissionsOnly && args.createEmptyCommit) {
           createEmptyCommits(repo, nameKey, args.branch);
         }
 
@@ -260,10 +246,12 @@
         return projectCache.get(nameKey).getProject();
       }
     } catch (RepositoryCaseMismatchException e) {
-      throw new ResourceConflictException("Cannot create " + nameKey.get()
-          + " because the name is already occupied by another project."
-          + " The other project has the same name, only spelled in a"
-          + " different case.");
+      throw new ResourceConflictException(
+          "Cannot create "
+              + nameKey.get()
+              + " because the name is already occupied by another project."
+              + " The other project has the same name, only spelled in a"
+              + " different case.");
     } catch (RepositoryNotFoundException badName) {
       throw new BadRequestException("invalid project name: " + nameKey);
     } catch (ConfigInvalidException e) {
@@ -273,49 +261,45 @@
     }
   }
 
-  private void createProjectConfig(CreateProjectArgs args) throws IOException, ConfigInvalidException {
+  private void createProjectConfig(CreateProjectArgs args)
+      throws IOException, ConfigInvalidException {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
       ProjectConfig config = ProjectConfig.read(md);
 
       Project newProject = config.getProject();
       newProject.setDescription(args.projectDescription);
-      newProject.setSubmitType(MoreObjects.firstNonNull(args.submitType,
-          repositoryCfg.getDefaultSubmitType(args.getProject())));
-      newProject
-          .setUseContributorAgreements(args.contributorAgreements);
+      newProject.setSubmitType(
+          MoreObjects.firstNonNull(
+              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
+      newProject.setUseContributorAgreements(args.contributorAgreements);
       newProject.setUseSignedOffBy(args.signedOffBy);
       newProject.setUseContentMerge(args.contentMerge);
       newProject.setCreateNewChangeForAllNotInTarget(args.newChangeForAllNotInTarget);
       newProject.setRequireChangeID(args.changeIdRequired);
       newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
       if (args.newParent != null) {
-        newProject.setParentName(args.newParent.getProject()
-            .getNameKey());
+        newProject.setParentName(args.newParent.getProject().getNameKey());
       }
 
       if (!args.ownerIds.isEmpty()) {
-        AccessSection all =
-            config.getAccessSection(AccessSection.ALL, true);
+        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
         for (AccountGroup.UUID ownerId : args.ownerIds) {
           GroupDescription.Basic g = groupBackend.get(ownerId);
           if (g != null) {
             GroupReference group = config.resolve(GroupReference.forGroup(g));
-            all.getPermission(Permission.OWNER, true).add(
-                new PermissionRule(group));
+            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
           }
         }
       }
 
       md.setMessage("Created project\n");
       config.commit(md);
+      md.getRepository().setGitwebDescription(args.projectDescription);
     }
     projectCache.onCreateProject(args.getProject());
-    repoManager.setProjectDescription(args.getProject(),
-        args.projectDescription);
   }
 
-  private List<String> normalizeBranchNames(List<String> branches)
-      throws BadRequestException {
+  private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
     if (branches == null || branches.isEmpty()) {
       return Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
     }
@@ -327,8 +311,7 @@
       }
       branch = RefNames.fullName(branch);
       if (!Repository.isValidRefName(branch)) {
-        throw new BadRequestException(String.format(
-            "Branch \"%s\" is not a valid name.", branch));
+        throw new BadRequestException(String.format("Branch \"%s\" is not a valid name.", branch));
       }
       if (!normalizedBranches.contains(branch)) {
         normalizedBranches.add(branch);
@@ -337,8 +320,8 @@
     return normalizedBranches;
   }
 
-  private void createEmptyCommits(Repository repo, Project.NameKey project,
-      List<String> refs) throws IOException {
+  private void createEmptyCommits(Repository repo, Project.NameKey project, List<String> refs)
+      throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
@@ -355,8 +338,8 @@
         Result result = ru.update();
         switch (result) {
           case NEW:
-            referenceUpdated.fire(project, ru, ReceiveCommand.Type.CREATE,
-                identifiedUser.get().getAccount());
+            referenceUpdated.fire(
+                project, ru, ReceiveCommand.Type.CREATE, identifiedUser.get().getAccount());
             break;
           case FAST_FORWARD:
           case FORCED:
@@ -367,16 +350,15 @@
           case REJECTED:
           case REJECTED_CURRENT_BRANCH:
           case RENAMED:
-          default: {
-            throw new IOException(String.format(
-              "Failed to create ref \"%s\": %s", ref, result.name()));
-          }
+          default:
+            {
+              throw new IOException(
+                  String.format("Failed to create ref \"%s\": %s", ref, result.name()));
+            }
         }
       }
     } catch (IOException e) {
-      log.error(
-          "Cannot create empty commit for "
-              + project.get(), e);
+      log.error("Cannot create empty commit for " + project.get(), e);
       throw e;
     }
   }
@@ -396,8 +378,7 @@
     }
   }
 
-  static class Event extends AbstractNoNotifyEvent
-      implements NewProjectCreatedListener.Event {
+  static class Event extends AbstractNoNotifyEvent implements NewProjectCreatedListener.Event {
     private final Project.NameKey name;
     private final String head;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java
index bbecb33..5642721 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-
 import java.util.List;
 
 public class CreateProjectArgs {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
index 446fa72..3a6db40 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.base.Strings;
@@ -29,6 +28,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.TagCache;
@@ -36,7 +36,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.IOException;
+import java.util.TimeZone;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -49,9 +50,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.TimeZone;
-
 public class CreateTag implements RestModifyView<ProjectResource, TagInput> {
   private static final Logger log = LoggerFactory.getLogger(CreateTag.class);
 
@@ -63,18 +61,22 @@
   private final GitRepositoryManager repoManager;
   private final TagCache tagCache;
   private final GitReferenceUpdated referenceUpdated;
+  private final WebLinks links;
   private String ref;
 
   @Inject
-  CreateTag(Provider<IdentifiedUser> identifiedUser,
+  CreateTag(
+      Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
       TagCache tagCache,
       GitReferenceUpdated referenceUpdated,
+      WebLinks webLinks,
       @Assisted String ref) {
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
     this.tagCache = tagCache;
     this.referenceUpdated = referenceUpdated;
+    this.links = webLinks;
     this.ref = ref;
   }
 
@@ -90,63 +92,54 @@
     if (input.revision == null) {
       input.revision = Constants.HEAD;
     }
-    while (ref.startsWith("/")) {
-      ref = ref.substring(1);
-    }
-    if (ref.startsWith(R_REFS) && !ref.startsWith(R_TAGS)) {
-      throw new BadRequestException("invalid tag name \"" + ref + "\"");
-    }
-    if (!ref.startsWith(R_TAGS)) {
-      ref = R_TAGS + ref;
-    }
-    if (!Repository.isValidRefName(ref)) {
-      throw new BadRequestException("invalid tag name \"" + ref + "\"");
-    }
+
+    ref = RefUtil.normalizeTagRef(ref);
 
     RefControl refControl = resource.getControl().controlForRef(ref);
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(
-          repo, resource.getNameKey(), input.revision);
+      ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
       RevObject object = rw.parseAny(revid);
       rw.reset();
       boolean isAnnotated = Strings.emptyToNull(input.message) != null;
-      boolean isSigned = isAnnotated
-          && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
+      boolean isSigned = isAnnotated && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
       if (isSigned) {
-        throw new MethodNotAllowedException(
-            "Cannot create signed tag \"" + ref + "\"");
-      } else if (isAnnotated && !refControl.canPerform(Permission.PUSH_TAG)) {
+        throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
+      } else if (isAnnotated && !refControl.canPerform(Permission.CREATE_TAG)) {
         throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
       } else if (!refControl.canPerform(Permission.CREATE)) {
         throw new AuthException("Cannot create tag \"" + ref + "\"");
       }
       if (repo.getRefDatabase().exactRef(ref) != null) {
-        throw new ResourceConflictException(
-            "tag \"" + ref + "\" already exists");
+        throw new ResourceConflictException("tag \"" + ref + "\" already exists");
       }
 
       try (Git git = new Git(repo)) {
-        TagCommand tag = git.tag()
-            .setObjectId(object)
-            .setName(ref.substring(R_TAGS.length()))
-            .setAnnotated(isAnnotated)
-            .setSigned(isSigned);
+        TagCommand tag =
+            git.tag()
+                .setObjectId(object)
+                .setName(ref.substring(R_TAGS.length()))
+                .setAnnotated(isAnnotated)
+                .setSigned(isSigned);
 
         if (isAnnotated) {
           tag.setMessage(input.message)
-             .setTagger(identifiedUser.get()
-                 .newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
+              .setTagger(
+                  identifiedUser.get().newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
         }
 
         Ref result = tag.call();
-        tagCache.updateFastForward(resource.getNameKey(), ref,
-            ObjectId.zeroId(), result.getObjectId());
-        referenceUpdated.fire(resource.getNameKey(), ref,
-            ObjectId.zeroId(), result.getObjectId(),
+        tagCache.updateFastForward(
+            resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
+        referenceUpdated.fire(
+            resource.getNameKey(),
+            ref,
+            ObjectId.zeroId(),
+            result.getObjectId(),
             identifiedUser.get().getAccount());
         try (RevWalk w = new RevWalk(repo)) {
-          return ListTags.createTagInfo(result, w);
+          ProjectControl pctl = resource.getControl();
+          return ListTags.createTagInfo(result, w, refControl, pctl, links);
         }
       }
     } catch (InvalidRevisionException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java
index 099350d..a3fd09e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardResource.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
-
 import org.eclipse.jgit.lib.Config;
 
 public class DashboardResource implements RestResource {
@@ -34,7 +33,8 @@
   private final Config config;
   private final boolean projectDefault;
 
-  public DashboardResource(ProjectControl control,
+  public DashboardResource(
+      ProjectControl control,
       String refName,
       String pathName,
       Config config,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
index a20c51e..2747e68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
@@ -38,7 +38,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -48,21 +50,17 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
 @Singleton
-class DashboardsCollection implements
-    ChildCollection<ProjectResource, DashboardResource>,
-    AcceptsCreate<ProjectResource> {
+class DashboardsCollection
+    implements ChildCollection<ProjectResource, DashboardResource>, AcceptsCreate<ProjectResource> {
   private final GitRepositoryManager gitManager;
   private final DynamicMap<RestView<DashboardResource>> views;
   private final Provider<ListDashboards> list;
   private final Provider<SetDefaultDashboard.CreateDefault> createDefault;
 
   @Inject
-  DashboardsCollection(GitRepositoryManager gitManager,
+  DashboardsCollection(
+      GitRepositoryManager gitManager,
       DynamicMap<RestView<DashboardResource>> views,
       Provider<ListDashboards> list,
       Provider<SetDefaultDashboard.CreateDefault> createDefault) {
@@ -79,8 +77,8 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public RestModifyView<ProjectResource, ?> create(ProjectResource parent,
-      IdString id) throws RestApiException {
+  public RestModifyView<ProjectResource, ?> create(ProjectResource parent, IdString id)
+      throws RestApiException {
     if (id.toString().equals("default")) {
       return createDefault.get();
     }
@@ -95,8 +93,7 @@
       return DashboardResource.projectDefault(myCtl);
     }
 
-    List<String> parts = Lists.newArrayList(
-        Splitter.on(':').limit(2).split(id.get()));
+    List<String> parts = Lists.newArrayList(Splitter.on(':').limit(2).split(id.get()));
     if (parts.size() != 2) {
       throw new ResourceNotFoundException(id);
     }
@@ -107,8 +104,7 @@
     for (ProjectState ps : myCtl.getProjectState().tree()) {
       try {
         return parse(ps.controlFor(user), ref, path, myCtl);
-      } catch (AmbiguousObjectException | ConfigInvalidException
-          | IncorrectObjectTypeException e) {
+      } catch (AmbiguousObjectException | ConfigInvalidException | IncorrectObjectTypeException e) {
         throw new ResourceNotFoundException(id);
       } catch (ResourceNotFoundException e) {
         continue;
@@ -117,16 +113,14 @@
     throw new ResourceNotFoundException(id);
   }
 
-  private DashboardResource parse(ProjectControl ctl, String ref, String path,
-      ProjectControl myCtl)
+  private DashboardResource parse(ProjectControl ctl, String ref, String path, ProjectControl myCtl)
       throws ResourceNotFoundException, IOException, AmbiguousObjectException,
           IncorrectObjectTypeException, ConfigInvalidException {
     String id = ref + ":" + path;
     if (!ref.startsWith(REFS_DASHBOARDS)) {
       ref = REFS_DASHBOARDS + ref;
     }
-    if (!Repository.isValidRefName(ref)
-        || !ctl.controlForRef(ref).canRead()) {
+    if (!Repository.isValidRefName(ref) || !ctl.controlForRef(ref).isVisible()) {
       throw new ResourceNotFoundException(id);
     }
 
@@ -147,8 +141,13 @@
     return views;
   }
 
-  static DashboardInfo parse(Project definingProject, String refName,
-      String path, Config config, String project, boolean setDefault) {
+  static DashboardInfo parse(
+      Project definingProject,
+      String refName,
+      String path,
+      Config config,
+      String project,
+      boolean setDefault) {
     DashboardInfo info = new DashboardInfo(refName, path);
     info.project = project;
     info.definingProject = definingProject.getName();
@@ -184,9 +183,9 @@
   }
 
   private static String defaultOf(Project proj) {
-    final String defaultId = MoreObjects.firstNonNull(
-        proj.getLocalDefaultDashboard(),
-        Strings.nullToEmpty(proj.getDefaultDashboard()));
+    final String defaultId =
+        MoreObjects.firstNonNull(
+            proj.getLocalDefaultDashboard(), Strings.nullToEmpty(proj.getDefaultDashboard()));
     if (defaultId.startsWith(REFS_DASHBOARDS)) {
       return defaultId.substring(REFS_DASHBOARDS.length());
     }
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 732d47b..049e2e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
@@ -14,125 +14,46 @@
 
 package com.google.gerrit.server.project;
 
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.DeleteBranch.Input;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.LockFailedException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 
 @Singleton
 public class DeleteBranch implements RestModifyView<BranchResource, Input> {
-  private static final Logger log = LoggerFactory.getLogger(DeleteBranch.class);
-  private static final int MAX_LOCK_FAILURE_CALLS = 10;
-  private static final long SLEEP_ON_LOCK_FAILURE_MS = 15;
+  public static class Input {}
 
-  public static class Input {
-  }
-
-  private final Provider<IdentifiedUser> identifiedUser;
-  private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final GitReferenceUpdated referenceUpdated;
-  private final RefValidationHelper refDeletionValidator;
+  private final DeleteRef.Factory deleteRefFactory;
 
   @Inject
-  DeleteBranch(Provider<IdentifiedUser> identifiedUser,
-      GitRepositoryManager repoManager,
-      Provider<InternalChangeQuery> queryProvider,
-      GitReferenceUpdated referenceUpdated,
-      RefValidationHelper.Factory refHelperFactory) {
-    this.identifiedUser = identifiedUser;
-    this.repoManager = repoManager;
+  DeleteBranch(Provider<InternalChangeQuery> queryProvider, DeleteRef.Factory deleteRefFactory) {
     this.queryProvider = queryProvider;
-    this.referenceUpdated = referenceUpdated;
-    this.refDeletionValidator =
-        refHelperFactory.create(ReceiveCommand.Type.DELETE);
+    this.deleteRefFactory = deleteRefFactory;
   }
 
   @Override
-  public Response<?> apply(BranchResource rsrc, Input input) throws AuthException,
-      ResourceConflictException, OrmException, IOException {
+  public Response<?> apply(BranchResource rsrc, Input input)
+      throws RestApiException, OrmException, IOException {
     if (!rsrc.getControl().controlForRef(rsrc.getBranchKey()).canDelete()) {
       throw new AuthException("Cannot delete branch");
     }
-    if (!queryProvider.get().setLimit(1)
-        .byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
-      throw new ResourceConflictException("branch " + rsrc.getBranchKey()
-          + " has open changes");
+
+    if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
+      throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
     }
 
-    try (Repository r = repoManager.openRepository(rsrc.getNameKey())) {
-      RefUpdate.Result result;
-      String ref = rsrc.getRef();
-      RefUpdate u = r.updateRef(ref);
-      u.setExpectedOldObjectId(r.exactRef(ref).getObjectId());
-      u.setNewObjectId(ObjectId.zeroId());
-      u.setForceUpdate(true);
-      refDeletionValidator.validateRefOperation(
-          rsrc.getName(), identifiedUser.get(), u);
-      int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
-      for (;;) {
-        try {
-          result = u.delete();
-        } catch (LockFailedException e) {
-          result = RefUpdate.Result.LOCK_FAILURE;
-        } catch (IOException e) {
-          log.error("Cannot delete " + rsrc.getBranchKey(), e);
-          throw e;
-        }
-        if (result == RefUpdate.Result.LOCK_FAILURE
-            && --remainingLockFailureCalls > 0) {
-          try {
-            Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
-          } catch (InterruptedException ie) {
-            // ignore
-          }
-        } else {
-          break;
-        }
-      }
-
-      switch (result) {
-        case NEW:
-        case NO_CHANGE:
-        case FAST_FORWARD:
-        case FORCED:
-          referenceUpdated.fire(rsrc.getNameKey(), u, ReceiveCommand.Type.DELETE,
-              identifiedUser.get().getAccount());
-          break;
-
-        case REJECTED_CURRENT_BRANCH:
-          log.error("Cannot delete " + rsrc.getBranchKey() + ": " + result.name());
-          throw new ResourceConflictException("cannot delete current branch");
-
-        case IO_FAILURE:
-        case LOCK_FAILURE:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case RENAMED:
-        default:
-          log.error("Cannot delete " + rsrc.getBranchKey() + ": " + result.name());
-          throw new ResourceConflictException("cannot delete branch: " + result.name());
-      }
-    }
+    deleteRefFactory.create(rsrc).ref(rsrc.getRef()).prefix(R_HEADS).delete();
     return Response.none();
   }
 }
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 7d53fec..4c54423 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
@@ -14,156 +14,36 @@
 
 package com.google.gerrit.server.project;
 
-import static java.lang.String.format;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 
 @Singleton
-public class DeleteBranches
-    implements RestModifyView<ProjectResource, DeleteBranchesInput> {
-  private static final Logger log = LoggerFactory.getLogger(DeleteBranches.class);
-
-  private final Provider<IdentifiedUser> identifiedUser;
-  private final GitRepositoryManager repoManager;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final GitReferenceUpdated referenceUpdated;
-  private final RefValidationHelper refDeletionValidator;
+public class DeleteBranches implements RestModifyView<ProjectResource, DeleteBranchesInput> {
+  private final DeleteRef.Factory deleteRefFactory;
 
   @Inject
-  DeleteBranches(Provider<IdentifiedUser> identifiedUser,
-      GitRepositoryManager repoManager,
-      Provider<InternalChangeQuery> queryProvider,
-      GitReferenceUpdated referenceUpdated,
-      RefValidationHelper.Factory refHelperFactory) {
-    this.identifiedUser = identifiedUser;
-    this.repoManager = repoManager;
-    this.queryProvider = queryProvider;
-    this.referenceUpdated = referenceUpdated;
-    this.refDeletionValidator =
-        refHelperFactory.create(ReceiveCommand.Type.DELETE);
+  DeleteBranches(DeleteRef.Factory deleteRefFactory) {
+    this.deleteRefFactory = deleteRefFactory;
   }
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
-      throws OrmException, IOException, ResourceConflictException {
+      throws OrmException, IOException, RestApiException {
 
-    if (input == null) {
-      input = new DeleteBranchesInput();
-    }
-    if (input.branches == null) {
-      input.branches = Lists.newArrayListWithCapacity(1);
+    if (input == null || input.branches == null || input.branches.isEmpty()) {
+      throw new BadRequestException("branches must be specified");
     }
 
-    try (Repository r = repoManager.openRepository(project.getNameKey())) {
-      BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
-      for (String branch : input.branches) {
-        batchUpdate.addCommand(createDeleteCommand(project, r, branch));
-      }
-      try (RevWalk rw = new RevWalk(r)) {
-        batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
-      }
-      StringBuilder errorMessages = new StringBuilder();
-      for (ReceiveCommand command : batchUpdate.getCommands()) {
-        if (command.getResult() == Result.OK) {
-          postDeletion(project, command);
-        } else {
-          appendAndLogErrorMessage(errorMessages, command);
-        }
-      }
-      if (errorMessages.length() > 0) {
-        throw new ResourceConflictException(errorMessages.toString());
-      }
-    }
+    deleteRefFactory.create(project).refs(input.branches).prefix(R_HEADS).delete();
     return Response.none();
   }
-
-  private ReceiveCommand createDeleteCommand(ProjectResource project,
-      Repository r, String branch)
-          throws OrmException, IOException, ResourceConflictException {
-    Ref ref = r.getRefDatabase().getRef(branch);
-    ReceiveCommand command;
-    if (ref == null) {
-      command = new ReceiveCommand(ObjectId.zeroId(), ObjectId.zeroId(), branch);
-      command.setResult(Result.REJECTED_OTHER_REASON,
-          "it doesn't exist or you do not have permission to delete it");
-      return command;
-    }
-    command =
-        new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName());
-    Branch.NameKey branchKey =
-        new Branch.NameKey(project.getNameKey(), ref.getName());
-    if (!project.getControl().controlForRef(branchKey).canDelete()) {
-      command.setResult(Result.REJECTED_OTHER_REASON,
-          "it doesn't exist or you do not have permission to delete it");
-    }
-    if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
-      command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
-    }
-    RefUpdate u = r.updateRef(branch);
-    u.setExpectedOldObjectId(r.exactRef(branch).getObjectId());
-    u.setNewObjectId(ObjectId.zeroId());
-    u.setForceUpdate(true);
-    refDeletionValidator.validateRefOperation(
-        project.getName(), identifiedUser.get(), u);
-    return command;
-  }
-
-  private void appendAndLogErrorMessage(StringBuilder errorMessages,
-      ReceiveCommand cmd) {
-    String msg = null;
-    switch (cmd.getResult()) {
-      case REJECTED_CURRENT_BRANCH:
-        msg = format("Cannot delete %s: it is the current branch",
-            cmd.getRefName());
-        break;
-      case REJECTED_OTHER_REASON:
-        msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getMessage());
-        break;
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case OK:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_NOCREATE:
-      case REJECTED_NODELETE:
-      case REJECTED_NONFASTFORWARD:
-      default:
-        msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getResult());
-        break;
-    }
-    log.error(msg);
-    errorMessages.append(msg);
-    errorMessages.append("\n");
-  }
-
-  private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
-    referenceUpdated.fire(project.getNameKey(), cmd,
-        identifiedUser.get().getAccount());
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java
index 7702b7d..a9dd253 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java
@@ -25,7 +25,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 
 @Singleton
@@ -40,7 +39,7 @@
   @Override
   public Response<DashboardInfo> apply(DashboardResource resource, SetDashboard.Input input)
       throws AuthException, BadRequestException, ResourceConflictException,
-      ResourceNotFoundException, MethodNotAllowedException, IOException {
+          ResourceNotFoundException, MethodNotAllowedException, IOException {
     if (resource.isProjectDefault()) {
       SetDashboard.Input in = new SetDashboard.Input();
       in.commitMessage = input != null ? input.commitMessage : null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
new file mode 100644
index 0000000..a0b297a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
@@ -0,0 +1,267 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static java.lang.String.format;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.LockFailedException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteRef {
+  private static final Logger log = LoggerFactory.getLogger(DeleteRef.class);
+
+  private static final int MAX_LOCK_FAILURE_CALLS = 10;
+  private static final long SLEEP_ON_LOCK_FAILURE_MS = 15;
+
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated referenceUpdated;
+  private final RefValidationHelper refDeletionValidator;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ProjectResource resource;
+  private final List<String> refsToDelete;
+  private String prefix;
+
+  public interface Factory {
+    DeleteRef create(ProjectResource r);
+  }
+
+  @AssistedInject
+  DeleteRef(
+      Provider<IdentifiedUser> identifiedUser,
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated referenceUpdated,
+      RefValidationHelper.Factory refDeletionValidatorFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      @Assisted ProjectResource resource) {
+    this.identifiedUser = identifiedUser;
+    this.repoManager = repoManager;
+    this.referenceUpdated = referenceUpdated;
+    this.refDeletionValidator = refDeletionValidatorFactory.create(DELETE);
+    this.queryProvider = queryProvider;
+    this.resource = resource;
+    this.refsToDelete = new ArrayList<>();
+  }
+
+  public DeleteRef ref(String ref) {
+    this.refsToDelete.add(ref);
+    return this;
+  }
+
+  public DeleteRef refs(List<String> refs) {
+    this.refsToDelete.addAll(refs);
+    return this;
+  }
+
+  public DeleteRef prefix(String prefix) {
+    this.prefix = prefix;
+    return this;
+  }
+
+  public void delete() throws OrmException, IOException, ResourceConflictException, AuthException {
+    if (!refsToDelete.isEmpty()) {
+      try (Repository r = repoManager.openRepository(resource.getNameKey())) {
+        if (refsToDelete.size() == 1) {
+          deleteSingleRef(r);
+        } else {
+          deleteMultipleRefs(r);
+        }
+      }
+    }
+  }
+
+  private void deleteSingleRef(Repository r)
+      throws IOException, ResourceConflictException, AuthException {
+    String ref = refsToDelete.get(0);
+    if (prefix != null && !ref.startsWith(prefix)) {
+      ref = prefix + ref;
+    }
+
+    if (!resource.getControl().controlForRef(ref).canDelete()) {
+      throw new AuthException("delete not permitted for " + ref);
+    }
+
+    RefUpdate.Result result;
+    RefUpdate u = r.updateRef(ref);
+    u.setExpectedOldObjectId(r.exactRef(ref).getObjectId());
+    u.setNewObjectId(ObjectId.zeroId());
+    u.setForceUpdate(true);
+    refDeletionValidator.validateRefOperation(resource.getName(), identifiedUser.get(), u);
+    int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
+    for (; ; ) {
+      try {
+        result = u.delete();
+      } catch (LockFailedException e) {
+        result = RefUpdate.Result.LOCK_FAILURE;
+      } catch (IOException e) {
+        log.error("Cannot delete " + ref, e);
+        throw e;
+      }
+      if (result == RefUpdate.Result.LOCK_FAILURE && --remainingLockFailureCalls > 0) {
+        try {
+          Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
+        } catch (InterruptedException ie) {
+          // ignore
+        }
+      } else {
+        break;
+      }
+    }
+
+    switch (result) {
+      case NEW:
+      case NO_CHANGE:
+      case FAST_FORWARD:
+      case FORCED:
+        referenceUpdated.fire(
+            resource.getNameKey(),
+            u,
+            ReceiveCommand.Type.DELETE,
+            identifiedUser.get().getAccount());
+        break;
+
+      case REJECTED_CURRENT_BRANCH:
+        log.error("Cannot delete " + ref + ": " + result.name());
+        throw new ResourceConflictException("cannot delete current branch");
+
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case RENAMED:
+      default:
+        log.error("Cannot delete " + ref + ": " + result.name());
+        throw new ResourceConflictException("cannot delete: " + result.name());
+    }
+  }
+
+  private void deleteMultipleRefs(Repository r)
+      throws OrmException, IOException, ResourceConflictException {
+    BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
+    List<String> refs =
+        prefix == null
+            ? refsToDelete
+            : refsToDelete.stream()
+                .map(ref -> ref.startsWith(prefix) ? ref : prefix + ref)
+                .collect(toList());
+    for (String ref : refs) {
+      batchUpdate.addCommand(createDeleteCommand(resource, r, ref));
+    }
+    try (RevWalk rw = new RevWalk(r)) {
+      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+    }
+    StringBuilder errorMessages = new StringBuilder();
+    for (ReceiveCommand command : batchUpdate.getCommands()) {
+      if (command.getResult() == Result.OK) {
+        postDeletion(resource, command);
+      } else {
+        appendAndLogErrorMessage(errorMessages, command);
+      }
+    }
+    if (errorMessages.length() > 0) {
+      throw new ResourceConflictException(errorMessages.toString());
+    }
+  }
+
+  private ReceiveCommand createDeleteCommand(ProjectResource project, Repository r, String refName)
+      throws OrmException, IOException, ResourceConflictException {
+    Ref ref = r.getRefDatabase().getRef(refName);
+    ReceiveCommand command;
+    if (ref == null) {
+      command = new ReceiveCommand(ObjectId.zeroId(), ObjectId.zeroId(), refName);
+      command.setResult(
+          Result.REJECTED_OTHER_REASON,
+          "it doesn't exist or you do not have permission to delete it");
+      return command;
+    }
+    command = new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName());
+
+    if (!project.getControl().controlForRef(refName).canDelete()) {
+      command.setResult(
+          Result.REJECTED_OTHER_REASON,
+          "it doesn't exist or you do not have permission to delete it");
+    }
+
+    if (!refName.startsWith(R_TAGS)) {
+      Branch.NameKey branchKey = new Branch.NameKey(project.getNameKey(), ref.getName());
+      if (!queryProvider.get().setLimit(1).byBranchOpen(branchKey).isEmpty()) {
+        command.setResult(Result.REJECTED_OTHER_REASON, "it has open changes");
+      }
+    }
+
+    RefUpdate u = r.updateRef(refName);
+    u.setForceUpdate(true);
+    u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
+    u.setNewObjectId(ObjectId.zeroId());
+    refDeletionValidator.validateRefOperation(project.getName(), identifiedUser.get(), u);
+    return command;
+  }
+
+  private void appendAndLogErrorMessage(StringBuilder errorMessages, ReceiveCommand cmd) {
+    String msg = null;
+    switch (cmd.getResult()) {
+      case REJECTED_CURRENT_BRANCH:
+        msg = format("Cannot delete %s: it is the current branch", cmd.getRefName());
+        break;
+      case REJECTED_OTHER_REASON:
+        msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getMessage());
+        break;
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case OK:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_NOCREATE:
+      case REJECTED_NODELETE:
+      case REJECTED_NONFASTFORWARD:
+      default:
+        msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getResult());
+        break;
+    }
+    log.error(msg);
+    errorMessages.append(msg);
+    errorMessages.append("\n");
+  }
+
+  private void postDeletion(ProjectResource project, ReceiveCommand cmd) {
+    referenceUpdated.fire(project.getNameKey(), cmd, identifiedUser.get().getAccount());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
new file mode 100644
index 0000000..f26d40f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class DeleteTag implements RestModifyView<TagResource, DeleteTag.Input> {
+  private final DeleteRef.Factory deleteRefFactory;
+
+  public static class Input {}
+
+  @Inject
+  DeleteTag(DeleteRef.Factory deleteRefFactory) {
+    this.deleteRefFactory = deleteRefFactory;
+  }
+
+  @Override
+  public Response<?> apply(TagResource resource, Input input)
+      throws OrmException, RestApiException, IOException {
+    String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
+    RefControl refControl = resource.getControl().controlForRef(tag);
+
+    if (!refControl.canDelete()) {
+      throw new AuthException("Cannot delete tag");
+    }
+
+    deleteRefFactory.create(resource).ref(tag).delete();
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
new file mode 100644
index 0000000..75cf03f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
+import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class DeleteTags implements RestModifyView<ProjectResource, DeleteTagsInput> {
+  private final DeleteRef.Factory deleteRefFactory;
+
+  @Inject
+  DeleteTags(DeleteRef.Factory deleteRefFactory) {
+    this.deleteRefFactory = deleteRefFactory;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource project, DeleteTagsInput input)
+      throws OrmException, RestApiException, IOException {
+
+    if (input == null || input.tags == null || input.tags.isEmpty()) {
+      throw new BadRequestException("tags must be specified");
+    }
+
+    deleteRefFactory.create(project).refs(input.tags).prefix(R_TAGS).delete();
+    return Response.none();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
index 47942be..43b849f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
@@ -14,16 +14,36 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.TypeLiteral;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
 
 public class FileResource implements RestResource {
   public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
       new TypeLiteral<RestView<FileResource>>() {};
 
+  public static FileResource create(
+      GitRepositoryManager repoManager, ProjectControl project, ObjectId rev, String path)
+      throws ResourceNotFoundException, IOException {
+    try (Repository repo = repoManager.openRepository(project.getProject().getNameKey());
+        RevWalk rw = new RevWalk(repo)) {
+      RevTree tree = rw.parseTree(rev);
+      if (TreeWalk.forPath(repo, path, tree) != null) {
+        return new FileResource(project, rev, path);
+      }
+    }
+    throw new ResourceNotFoundException(IdString.fromDecoded(path));
+  }
+
   private final ProjectControl project;
   private final ObjectId rev;
   private final String path;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
index d0460d5..8d462a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
@@ -19,19 +19,21 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
-public class FilesCollection implements
-    ChildCollection<BranchResource, FileResource> {
+public class FilesCollection implements ChildCollection<BranchResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
+  private final GitRepositoryManager repoManager;
 
   @Inject
-  FilesCollection(DynamicMap<RestView<FileResource>> views) {
+  FilesCollection(DynamicMap<RestView<FileResource>> views, GitRepositoryManager repoManager) {
     this.views = views;
+    this.repoManager = repoManager;
   }
 
   @Override
@@ -40,11 +42,10 @@
   }
 
   @Override
-  public FileResource parse(BranchResource parent, IdString id) {
-    return new FileResource(
-        parent.getControl(),
-        ObjectId.fromString(parent.getRevision()),
-        id.get());
+  public FileResource parse(BranchResource parent, IdString id)
+      throws ResourceNotFoundException, IOException {
+    return FileResource.create(
+        repoManager, parent.getControl(), ObjectId.fromString(parent.getRevision()), id.get());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
index 8e0aab8..807ac53 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
@@ -19,17 +19,22 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 
 @Singleton
-public class FilesInCommitCollection implements
-    ChildCollection<CommitResource, FileResource> {
+public class FilesInCommitCollection implements ChildCollection<CommitResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
+  private final GitRepositoryManager repoManager;
 
   @Inject
-  FilesInCommitCollection(DynamicMap<RestView<FileResource>> views) {
+  FilesInCommitCollection(
+      DynamicMap<RestView<FileResource>> views, GitRepositoryManager repoManager) {
     this.views = views;
+    this.repoManager = repoManager;
   }
 
   @Override
@@ -39,8 +44,11 @@
 
   @Override
   public FileResource parse(CommitResource parent, IdString id)
-      throws ResourceNotFoundException {
-    return new FileResource(parent.getProject(), parent.getCommit(), id.get());
+      throws ResourceNotFoundException, IOException {
+    if (Patch.isMagic(id.get())) {
+      return new FileResource(parent.getProject(), parent.getCommit(), id.get());
+    }
+    return FileResource.create(repoManager, parent.getProject(), parent.getCommit(), id.get());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
index 3b50129..654ce69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
@@ -34,7 +34,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
@@ -43,8 +42,8 @@
 
 @RequiresCapability(GlobalCapability.RUN_GC)
 @Singleton
-public class GarbageCollect implements RestModifyView<ProjectResource, Input>,
-    UiAction<ProjectResource> {
+public class GarbageCollect
+    implements RestModifyView<ProjectResource, Input>, UiAction<ProjectResource> {
   public static class Input {
     public boolean showProgress;
     public boolean aggressive;
@@ -57,8 +56,10 @@
   private final Provider<String> canonicalUrl;
 
   @Inject
-  GarbageCollect(GitRepositoryManager repoManager,
-      GarbageCollection.Factory garbageCollectionFactory, WorkQueue workQueue,
+  GarbageCollect(
+      GitRepositoryManager repoManager,
+      GarbageCollection.Factory garbageCollectionFactory,
+      WorkQueue workQueue,
       @CanonicalWebUrl Provider<String> canonicalUrl) {
     this.workQueue = workQueue;
     this.canonicalUrl = canonicalUrl;
@@ -76,42 +77,43 @@
   }
 
   private Response.Accepted applyAsync(final Project.NameKey project, final Input input) {
-    Runnable job = new Runnable() {
-      @Override
-      public void run() {
-       runGC(project, input, null);
-      }
+    Runnable job =
+        new Runnable() {
+          @Override
+          public void run() {
+            runGC(project, input, null);
+          }
 
-      @Override
-      public String toString() {
-        return "Run " + (input.aggressive ? "aggressive " : "")
-            + "garbage collection on project " + project.get();
-      }
-    };
+          @Override
+          public String toString() {
+            return "Run "
+                + (input.aggressive ? "aggressive " : "")
+                + "garbage collection on project "
+                + project.get();
+          }
+        };
 
     @SuppressWarnings("unchecked")
-    WorkQueue.Task<Void> task =
-        (WorkQueue.Task<Void>) workQueue.getDefaultQueue().submit(job);
+    WorkQueue.Task<Void> task = (WorkQueue.Task<Void>) workQueue.getDefaultQueue().submit(job);
 
-    String location = canonicalUrl.get() + "a/config/server/tasks/"
-            + IdGenerator.format(task.getTaskId());
+    String location =
+        canonicalUrl.get() + "a/config/server/tasks/" + IdGenerator.format(task.getTaskId());
 
     return Response.accepted(location);
   }
 
   @SuppressWarnings("resource")
-  private BinaryResult applySync(final Project.NameKey project,
-      final Input input) {
+  private BinaryResult applySync(final Project.NameKey project, final Input input) {
     return new BinaryResult() {
       @Override
       public void writeTo(OutputStream out) throws IOException {
-        PrintWriter writer = new PrintWriter(
-            new OutputStreamWriter(out, UTF_8)) {
-          @Override
-          public void println() {
-            write('\n');
-          }
-        };
+        PrintWriter writer =
+            new PrintWriter(new OutputStreamWriter(out, UTF_8)) {
+              @Override
+              public void println() {
+                write('\n');
+              }
+            };
         try {
           PrintWriter progressWriter = input.showProgress ? writer : null;
           GarbageCollectionResult result = runGC(project, input, progressWriter);
@@ -123,16 +125,24 @@
                   msg = "Error: project \"" + e.getProjectName() + "\" not found.";
                   break;
                 case GC_ALREADY_SCHEDULED:
-                  msg = "Error: garbage collection for project \""
-                      + e.getProjectName() + "\" was already scheduled.";
+                  msg =
+                      "Error: garbage collection for project \""
+                          + e.getProjectName()
+                          + "\" was already scheduled.";
                   break;
                 case GC_FAILED:
-                  msg = "Error: garbage collection for project \"" + e.getProjectName()
-                      + "\" failed.";
+                  msg =
+                      "Error: garbage collection for project \""
+                          + e.getProjectName()
+                          + "\" failed.";
                   break;
                 default:
-                  msg = "Error: garbage collection for project \"" + e.getProjectName()
-                      + "\" failed: " + e.getType() + ".";
+                  msg =
+                      "Error: garbage collection for project \""
+                          + e.getProjectName()
+                          + "\" failed: "
+                          + e.getType()
+                          + ".";
               }
             }
           }
@@ -141,16 +151,13 @@
           writer.flush();
         }
       }
-    }.setContentType("text/plain")
-     .setCharacterEncoding(UTF_8)
-     .disableGzip();
+    }.setContentType("text/plain").setCharacterEncoding(UTF_8).disableGzip();
   }
 
-  GarbageCollectionResult runGC(Project.NameKey project,
-      Input input, PrintWriter progressWriter) {
-    return garbageCollectionFactory.create().run(
-        Collections.singletonList(project), input.aggressive,
-        progressWriter);
+  GarbageCollectionResult runGC(Project.NameKey project, Input input, PrintWriter progressWriter) {
+    return garbageCollectionFactory
+        .create()
+        .run(Collections.singletonList(project), input.aggressive, progressWriter);
   }
 
   @Override
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 a6b2445..1f1275c 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,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.collect.BiMap;
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
@@ -41,24 +40,26 @@
 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 java.io.IOException;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
 @Singleton
 public class GetAccess implements RestReadView<ProjectResource> {
 
-  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,
+  public static final ImmutableBiMap<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);
 
@@ -72,7 +73,8 @@
   private final GroupBackend groupBackend;
 
   @Inject
-  public GetAccess(Provider<CurrentUser> self,
+  public GetAccess(
+      Provider<CurrentUser> self,
       GroupControl.Factory groupControlFactory,
       AllProjectsName allProjectsName,
       ProjectCache projectCache,
@@ -93,8 +95,7 @@
   public ProjectAccessInfo apply(Project.NameKey nameKey)
       throws ResourceNotFoundException, ResourceConflictException, IOException {
     try {
-      return this.apply(new ProjectResource(
-          projectControlFactory.controlFor(nameKey, self.get())));
+      return this.apply(new ProjectResource(projectControlFactory.controlFor(nameKey, self.get())));
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(nameKey.get());
     }
@@ -121,8 +122,7 @@
         projectCache.evict(config.getProject());
         pc = open(projectName);
       } else if (config.getRevision() != null
-          && !config.getRevision().equals(
-          pc.getProjectState().getConfig().getRevision())) {
+          && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
         projectCache.evict(config.getProject());
         pc = open(projectName);
       }
@@ -175,8 +175,7 @@
               Boolean canSeeGroup = visibleGroups.get(group);
               if (canSeeGroup == null) {
                 try {
-                  canSeeGroup = groupControlFactory.controlFor(group)
-                      .isVisible();
+                  canSeeGroup = groupControlFactory.controlFor(group).isVisible();
                 } catch (NoSuchGroupException e) {
                   canSeeGroup = Boolean.FALSE;
                 }
@@ -210,8 +209,7 @@
       info.revision = config.getRevision().name();
     }
 
-    ProjectState parent =
-        Iterables.getFirst(pc.getProjectState().parents(), null);
+    ProjectState parent = Iterables.getFirst(pc.getProjectState().parents(), null);
     if (parent != null) {
       info.inheritsFrom = projectJson.format(parent.getProject());
     }
@@ -223,9 +221,10 @@
     }
 
     info.isOwner = toBoolean(pc.isOwner());
-    info.canUpload = toBoolean(pc.isOwner()
-        || (metaConfigControl.isVisible() && metaConfigControl.canUpload()));
+    info.canUpload =
+        toBoolean(pc.isOwner() || (metaConfigControl.isVisible() && metaConfigControl.canUpload()));
     info.canAdd = toBoolean(pc.canAddRefs());
+    info.canAddTags = toBoolean(pc.canAddTagRefs());
     info.configVisible = pc.isOwner() || metaConfigControl.isVisible();
 
     return info;
@@ -235,12 +234,11 @@
     AccessSectionInfo accessSectionInfo = new AccessSectionInfo();
     accessSectionInfo.permissions = new HashMap<>();
     for (Permission p : section.getPermissions()) {
-      PermissionInfo pInfo = new PermissionInfo(p.getLabel(),
-          p.getExclusiveGroup() ? true : null);
+      PermissionInfo pInfo = new PermissionInfo(p.getLabel(), p.getExclusiveGroup() ? true : null);
       pInfo.rules = new HashMap<>();
       for (PermissionRule r : p.getRules()) {
-        PermissionRuleInfo info = new PermissionRuleInfo(
-            ACTION_TYPE.get(r.getAction()), r.getForce());
+        PermissionRuleInfo info =
+            new PermissionRuleInfo(ACTION_TYPE.get(r.getAction()), r.getForce());
         if (r.hasRange()) {
           info.max = r.getMax();
           info.min = r.getMin();
@@ -258,8 +256,8 @@
   private ProjectControl open(Project.NameKey projectName)
       throws ResourceNotFoundException, IOException {
     try {
-      return projectControlFactory.validateFor(projectName,
-          ProjectControl.OWNER | ProjectControl.VISIBLE, self.get());
+      return projectControlFactory.validateFor(
+          projectName, ProjectControl.OWNER | ProjectControl.VISIBLE, self.get());
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(projectName.get());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
index 7737d8c..53e1baa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.inject.Inject;
-
 import org.kohsuke.args4j.Option;
 
 public class GetChildProject implements RestReadView<ChildProjectResource> {
@@ -36,8 +35,7 @@
   }
 
   @Override
-  public ProjectInfo apply(ChildProjectResource rsrc)
-      throws ResourceNotFoundException {
+  public ProjectInfo apply(ChildProjectResource rsrc) throws ResourceNotFoundException {
     if (recursive || rsrc.isDirectChild()) {
       return json.format(rsrc.getChild().getProject());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetCommit.java
index 63cee1f..bd4492e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetCommit.java
@@ -18,10 +18,8 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommonConverters;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.revwalk.RevCommit;
-
 import java.util.ArrayList;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class GetCommit implements RestReadView<CommitResource> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
index c999119..1bf001b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
@@ -22,28 +22,25 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetConfig implements RestReadView<ProjectResource> {
   private final boolean serverEnableSignedPush;
-  private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
   private final DynamicMap<RestView<ProjectResource>> views;
 
   @Inject
-  public GetConfig(@EnableSignedPush boolean serverEnableSignedPush,
-      TransferConfig config,
+  public GetConfig(
+      @EnableSignedPush boolean serverEnableSignedPush,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
-    this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
@@ -52,7 +49,12 @@
 
   @Override
   public ConfigInfo apply(ProjectResource resource) {
-    return new ConfigInfoImpl(serverEnableSignedPush, resource.getControl(),
-        config, pluginConfigEntries, cfgFactory, allProjects, views);
+    return new ConfigInfoImpl(
+        serverEnableSignedPush,
+        resource.getControl(),
+        pluginConfigEntries,
+        cfgFactory,
+        allProjects,
+        views);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
index 23e9e30..10da990f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 
 @Singleton
@@ -33,11 +32,8 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException {
+  public BinaryResult apply(FileResource rsrc) throws ResourceNotFoundException, IOException {
     return fileContentUtil.getContent(
-        rsrc.getProject().getProjectState(),
-        rsrc.getRev(),
-        rsrc.getPath());
+        rsrc.getProject().getProjectState(), rsrc.getRev(), rsrc.getPath());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java
index 09555b7..1549658 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java
@@ -26,12 +26,10 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Option;
 
 class GetDashboard implements RestReadView<DashboardResource> {
   private final DashboardsCollection dashboards;
@@ -102,8 +100,6 @@
     List<String> p = Lists.newArrayList(Splitter.on(':').limit(2).split(id));
     String ref = Url.encode(p.get(0));
     String path = Url.encode(p.get(1));
-    return dashboards.parse(
-        new ProjectResource(ctl),
-        IdString.fromUrl(ref + ':' + path));
+    return dashboards.parse(new ProjectResource(ctl), IdString.fromUrl(ref + ':' + path));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
index 12ca2eb..03db4f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
@@ -22,7 +22,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -32,23 +32,20 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-
 @Singleton
 public class GetHead implements RestReadView<ProjectResource> {
   private GitRepositoryManager repoManager;
   private Provider<ReviewDb> db;
 
   @Inject
-  GetHead(GitRepositoryManager repoManager,
-      Provider<ReviewDb> db) {
+  GetHead(GitRepositoryManager repoManager, Provider<ReviewDb> db) {
     this.repoManager = repoManager;
     this.db = db;
   }
 
   @Override
-  public String apply(ProjectResource rsrc) throws AuthException,
-      ResourceNotFoundException, IOException {
+  public String apply(ProjectResource rsrc)
+      throws AuthException, ResourceNotFoundException, IOException {
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       Ref head = repo.getRefDatabase().exactRef(Constants.HEAD);
       if (head == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
index b957ba1..81f0873 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetReflog.java
@@ -14,49 +14,62 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.args4j.TimestampHandler;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ReflogEntry;
-import org.eclipse.jgit.lib.ReflogReader;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.ReflogReader;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class GetReflog implements RestReadView<BranchResource> {
+  private static final Logger log = LoggerFactory.getLogger(GetReflog.class);
+
   private final GitRepositoryManager repoManager;
 
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT",
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
       usage = "maximum number of reflog entries to list")
   public GetReflog setLimit(int limit) {
     this.limit = limit;
     return this;
   }
 
-  @Option(name = "--from", metaVar = "TIMESTAMP",
-      usage = "timestamp from which the reflog entries should be listed (UTC, format: "
-          + TimestampHandler.TIMESTAMP_FORMAT + ")")
+  @Option(
+      name = "--from",
+      metaVar = "TIMESTAMP",
+      usage =
+          "timestamp from which the reflog entries should be listed (UTC, format: "
+              + TimestampHandler.TIMESTAMP_FORMAT
+              + ")")
   public GetReflog setFrom(Timestamp from) {
     this.from = from;
     return this;
   }
 
-  @Option(name = "--to", metaVar = "TIMESTAMP",
-      usage = "timestamp until which the reflog entries should be listed (UTC, format: "
-          + TimestampHandler.TIMESTAMP_FORMAT + ")")
+  @Option(
+      name = "--to",
+      metaVar = "TIMESTAMP",
+      usage =
+          "timestamp until which the reflog entries should be listed (UTC, format: "
+              + TimestampHandler.TIMESTAMP_FORMAT
+              + ")")
   public GetReflog setTo(Timestamp to) {
     this.to = to;
     return this;
@@ -72,29 +85,31 @@
   }
 
   @Override
-  public List<ReflogEntryInfo> apply(BranchResource rsrc) throws AuthException,
-      ResourceNotFoundException, RepositoryNotFoundException, IOException {
+  public List<ReflogEntryInfo> apply(BranchResource rsrc) throws RestApiException, IOException {
     if (!rsrc.getControl().isOwner()) {
       throw new AuthException("not project owner");
     }
 
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      ReflogReader r = repo.getReflogReader(rsrc.getRef());
+      ReflogReader r;
+      try {
+        r = repo.getReflogReader(rsrc.getRef());
+      } catch (UnsupportedOperationException e) {
+        String msg = "reflog not supported on repo " + rsrc.getNameKey().get();
+        log.error(msg);
+        throw new MethodNotAllowedException(msg);
+      }
       if (r == null) {
         throw new ResourceNotFoundException(rsrc.getRef());
       }
       List<ReflogEntry> entries;
       if (from == null && to == null) {
-        entries =
-            limit > 0 ? r.getReverseEntries(limit) : r.getReverseEntries();
+        entries = limit > 0 ? r.getReverseEntries(limit) : r.getReverseEntries();
       } else {
-        entries = limit > 0
-            ? new ArrayList<ReflogEntry>(limit)
-            : new ArrayList<ReflogEntry>();
+        entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
         for (ReflogEntry e : r.getReverseEntries()) {
           Timestamp timestamp = new Timestamp(e.getWho().getWhen().getTime());
-          if ((from == null || from.before(timestamp)) &&
-              (to == null || to.after(timestamp))) {
+          if ((from == null || from.before(timestamp)) && (to == null || to.after(timestamp))) {
             entries.add(e);
           }
           if (limit > 0 && entries.size() >= limit) {
@@ -102,26 +117,15 @@
           }
         }
       }
-      return Lists.transform(entries, new Function<ReflogEntry, ReflogEntryInfo>() {
-        @Override
-        public ReflogEntryInfo apply(ReflogEntry e) {
-          return new ReflogEntryInfo(e);
-        }
-      });
+      return Lists.transform(entries, e -> newReflogEntryInfo(e));
     }
   }
 
-  public static class ReflogEntryInfo {
-    public String oldId;
-    public String newId;
-    public GitPerson who;
-    public String comment;
-
-    public ReflogEntryInfo(ReflogEntry e) {
-      oldId = e.getOldId().getName();
-      newId = e.getNewId().getName();
-      who = CommonConverters.toGitPerson(e.getWho());
-      comment = e.getComment();
-    }
+  private ReflogEntryInfo newReflogEntryInfo(ReflogEntry e) {
+    return new ReflogEntryInfo(
+        e.getOldId().getName(),
+        e.getNewId().getName(),
+        CommonConverters.toGitPerson(e.getWho()),
+        e.getComment());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetStatistics.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetStatistics.java
index 723ee63..36d558c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetStatistics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetStatistics.java
@@ -22,15 +22,13 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.api.GarbageCollectCommand;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.api.errors.JGitInternalException;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-
 @RequiresCapability(GlobalCapability.RUN_GC)
 @Singleton
 public class GetStatistics implements RestReadView<ProjectResource> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
new file mode 100644
index 0000000..ceace1f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.io.ByteStreams;
+import com.google.common.io.CharStreams;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.PrintWriter;
+import java.util.concurrent.Future;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class Index implements RestModifyView<ProjectResource, ProjectInput> {
+
+  private final AllChangesIndexer allChangesIndexer;
+  private final ChangeIndexer indexer;
+  private final ListeningExecutorService executor;
+
+  @Inject
+  Index(
+      AllChangesIndexer allChangesIndexer,
+      ChangeIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor) {
+    this.allChangesIndexer = allChangesIndexer;
+    this.indexer = indexer;
+    this.executor = executor;
+  }
+
+  @Override
+  public Response.Accepted apply(ProjectResource resource, ProjectInput input) {
+    Project.NameKey project = resource.getNameKey();
+    Task mpt =
+        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
+            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
+    PrintWriter pw = new PrintWriter(CharStreams.nullWriter());
+    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
+    // return value.
+    @SuppressWarnings("unused")
+    Future<Void> ignored =
+        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt, pw));
+    return Response.accepted("Project " + project + " submitted for reindexing");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/InvalidChangeOperationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/InvalidChangeOperationException.java
index e5c6ba5..c09baa0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/InvalidChangeOperationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/InvalidChangeOperationException.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-
 /** Indicates the change operation is not currently valid. */
 public class InvalidChangeOperationException extends Exception {
   private static final long serialVersionUID = 1L;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
index a50705d..9b46d94 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.common.ActionInfo;
@@ -32,13 +31,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -47,28 +39,49 @@
 import java.util.List;
 import java.util.Set;
 import java.util.TreeMap;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
 
 public class ListBranches implements RestReadView<ProjectResource> {
   private final GitRepositoryManager repoManager;
   private final DynamicMap<RestView<BranchResource>> branchViews;
   private final WebLinks webLinks;
 
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of branches to list")
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of branches to list")
   public void setLimit(int limit) {
     this.limit = limit;
   }
 
-  @Option(name = "--start", aliases = {"-s"}, metaVar = "CNT", usage = "number of branches to skip")
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of branches to skip")
   public void setStart(int start) {
     this.start = start;
   }
 
-  @Option(name = "--match", aliases = {"-m"}, metaVar = "MATCH", usage = "match branches substring")
+  @Option(
+      name = "--match",
+      aliases = {"-m"},
+      metaVar = "MATCH",
+      usage = "match branches substring")
   public void setMatchSubstring(String matchSubstring) {
     this.matchSubstring = matchSubstring;
   }
 
-  @Option(name = "--regex", aliases = {"-r"}, metaVar = "REGEX", usage = "match branches regex")
+  @Option(
+      name = "--regex",
+      aliases = {"-r"},
+      metaVar = "REGEX",
+      usage = "match branches regex")
   public void setMatchRegex(String matchRegex) {
     this.matchRegex = matchRegex;
   }
@@ -79,7 +92,8 @@
   private String matchRegex;
 
   @Inject
-  public ListBranches(GitRepositoryManager repoManager,
+  public ListBranches(
+      GitRepositoryManager repoManager,
       DynamicMap<RestView<BranchResource>> branchViews,
       WebLinks webLinks) {
     this.repoManager = repoManager;
@@ -102,14 +116,13 @@
       throws IOException, ResourceNotFoundException {
     List<Ref> refs;
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Collection<Ref> heads =
-          db.getRefDatabase().getRefs(Constants.R_HEADS).values();
+      Collection<Ref> heads = db.getRefDatabase().getRefs(Constants.R_HEADS).values();
       refs = new ArrayList<>(heads.size() + 3);
       refs.addAll(heads);
-      refs.addAll(db.getRefDatabase().exactRef(
-          Constants.HEAD,
-          RefNames.REFS_CONFIG,
-          RefNames.REFS_USERS_DEFAULT).values());
+      refs.addAll(
+          db.getRefDatabase()
+              .exactRef(Constants.HEAD, RefNames.REFS_CONFIG, RefNames.REFS_USERS_DEFAULT)
+              .values());
     } catch (RepositoryNotFoundException noGitRepository) {
       throw new ResourceNotFoundException();
     }
@@ -175,26 +188,25 @@
     }
   }
 
-  private BranchInfo createBranchInfo(Ref ref, RefControl refControl,
-      Set<String> targets) {
+  private BranchInfo createBranchInfo(Ref ref, RefControl refControl, Set<String> targets) {
     BranchInfo info = new BranchInfo();
     info.ref = ref.getName();
     info.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
-    info.canDelete = !targets.contains(ref.getName()) && refControl.canDelete()
-        ? true : null;
-    for (UiAction.Description d : UiActions.from(
-        branchViews,
-        new BranchResource(refControl.getProjectControl(), info),
-        Providers.of(refControl.getUser()))) {
+    info.canDelete = !targets.contains(ref.getName()) && refControl.canDelete() ? true : null;
+    for (UiAction.Description d :
+        UiActions.from(
+            branchViews,
+            new BranchResource(refControl.getProjectControl(), info),
+            Providers.of(refControl.getUser()))) {
       if (info.actions == null) {
         info.actions = new TreeMap<>();
       }
       info.actions.put(d.getId(), new ActionInfo(d));
     }
-    FluentIterable<WebLinkInfo> links =
+    List<WebLinkInfo> links =
         webLinks.getBranchLinks(
             refControl.getProjectControl().getProject().getName(), ref.getName());
-    info.webLinks = links.isEmpty() ? null : links.toList();
+    info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
index 0c2f9fc..c14ade6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
@@ -20,14 +20,12 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.kohsuke.args4j.Option;
 
 public class ListChildProjects implements RestReadView<ProjectResource> {
 
@@ -40,9 +38,11 @@
   private final ProjectNode.Factory projectNodeFactory;
 
   @Inject
-  ListChildProjects(ProjectCache projectCache,
+  ListChildProjects(
+      ProjectCache projectCache,
       AllProjectsName allProjectsName,
-      ProjectJson json, ProjectNode.Factory projectNodeFactory) {
+      ProjectJson json,
+      ProjectNode.Factory projectNodeFactory) {
     this.projectCache = projectCache;
     this.allProjects = allProjectsName;
     this.json = json;
@@ -56,8 +56,7 @@
   @Override
   public List<ProjectInfo> apply(ProjectResource rsrc) {
     if (recursive) {
-      return getChildProjectsRecursively(rsrc.getNameKey(),
-          rsrc.getControl().getUser());
+      return getChildProjectsRecursively(rsrc.getNameKey(), rsrc.getControl().getUser());
     }
     return getDirectChildProjects(rsrc.getNameKey());
   }
@@ -77,8 +76,7 @@
     return childProjects;
   }
 
-  private List<ProjectInfo> getChildProjectsRecursively(Project.NameKey parent,
-      CurrentUser user) {
+  private List<ProjectInfo> getChildProjectsRecursively(Project.NameKey parent, CurrentUser user) {
     Map<Project.NameKey, ProjectNode> projects = new HashMap<>();
     for (Project.NameKey name : projectCache.all()) {
       ProjectState p = projectCache.get(name);
@@ -86,8 +84,7 @@
         // If we can't get it from the cache, pretend it's not present.
         continue;
       }
-      projects.put(name, projectNodeFactory.create(p.getProject(),
-          p.controlFor(user).isVisible()));
+      projects.put(name, projectNodeFactory.create(p.getProject(), p.controlFor(user).isVisible()));
     }
     for (ProjectNode key : projects.values()) {
       ProjectNode node = projects.get(key.getParentName());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
index 2546ac6..6dca4be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
@@ -22,7 +22,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BlobBasedConfig;
@@ -35,10 +37,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
 class ListDashboards implements RestReadView<ProjectResource> {
   private static final Logger log = LoggerFactory.getLogger(ListDashboards.class);
 
@@ -53,8 +51,7 @@
   }
 
   @Override
-  public List<?> apply(ProjectResource resource)
-      throws ResourceNotFoundException, IOException {
+  public List<?> apply(ProjectResource resource) throws ResourceNotFoundException, IOException {
     ProjectControl ctl = resource.getControl();
     String project = ctl.getProject().getName();
     if (!inherited) {
@@ -80,16 +77,15 @@
     return all;
   }
 
-  private List<DashboardInfo> scan(ProjectControl ctl, String project,
-      boolean setDefault) throws ResourceNotFoundException, IOException {
+  private List<DashboardInfo> scan(ProjectControl ctl, String project, boolean setDefault)
+      throws ResourceNotFoundException, IOException {
     Project.NameKey projectName = ctl.getProject().getNameKey();
     try (Repository git = gitManager.openRepository(projectName);
         RevWalk rw = new RevWalk(git)) {
       List<DashboardInfo> all = new ArrayList<>();
       for (Ref ref : git.getRefDatabase().getRefs(REFS_DASHBOARDS).values()) {
-        if (ctl.controlForRef(ref.getName()).canRead()) {
-          all.addAll(scanDashboards(ctl.getProject(), git, rw, ref,
-              project, setDefault));
+        if (ctl.controlForRef(ref.getName()).isVisible()) {
+          all.addAll(scanDashboards(ctl.getProject(), git, rw, ref, project, setDefault));
         }
       }
       return all;
@@ -98,8 +94,13 @@
     }
   }
 
-  private List<DashboardInfo> scanDashboards(Project definingProject,
-      Repository git, RevWalk rw, Ref ref, String project, boolean setDefault)
+  private List<DashboardInfo> scanDashboards(
+      Project definingProject,
+      Repository git,
+      RevWalk rw,
+      Ref ref,
+      String project,
+      boolean setDefault)
       throws IOException {
     List<DashboardInfo> list = new ArrayList<>();
     try (TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
@@ -108,18 +109,21 @@
       while (tw.next()) {
         if (tw.getFileMode(0) == FileMode.REGULAR_FILE) {
           try {
-            list.add(DashboardsCollection.parse(
-                definingProject,
-                ref.getName().substring(REFS_DASHBOARDS.length()),
-                tw.getPathString(),
-                new BlobBasedConfig(null, git, tw.getObjectId(0)),
-                project,
-                setDefault));
+            list.add(
+                DashboardsCollection.parse(
+                    definingProject,
+                    ref.getName().substring(REFS_DASHBOARDS.length()),
+                    tw.getPathString(),
+                    new BlobBasedConfig(null, git, tw.getObjectId(0)),
+                    project,
+                    setDefault));
           } catch (ConfigInvalidException e) {
-            log.warn(String.format(
-                "Cannot parse dashboard %s:%s:%s: %s",
-                definingProject.getName(), ref.getName(), tw.getPathString(),
-                e.getMessage()));
+            log.warn(
+                "Cannot parse dashboard {}:{}:{}: {}",
+                definingProject.getName(),
+                ref.getName(),
+                tw.getPathString(),
+                e.getMessage());
           }
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
index bf17a37..eeabac8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
@@ -16,9 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GroupReference;
@@ -44,15 +42,6 @@
 import com.google.gerrit.server.util.TreeFormatter;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedWriter;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -72,6 +61,13 @@
 import java.util.SortedSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** List projects visible to the calling user. */
 public class ListProjects implements RestReadView<TopLevelResource> {
@@ -95,8 +91,8 @@
       boolean matches(Repository git) throws IOException {
         Ref head = git.getRefDatabase().exactRef(Constants.HEAD);
         return head != null
-          && head.isSymbolic()
-          && RefNames.REFS_CONFIG.equals(head.getLeaf().getName());
+            && head.isSymbolic()
+            && RefNames.REFS_CONFIG.equals(head.getLeaf().getName());
       }
     },
     ALL {
@@ -121,15 +117,20 @@
   @Option(name = "--format", usage = "(deprecated) output format")
   private OutputFormat format = OutputFormat.TEXT;
 
-  @Option(name = "--show-branch", aliases = {"-b"},
+  @Option(
+      name = "--show-branch",
+      aliases = {"-b"},
       usage = "displays the sha of each project in the specified branch")
   public void addShowBranch(String branch) {
     showBranch.add(branch);
   }
 
-  @Option(name = "--tree", aliases = {"-t"}, usage =
-      "displays project inheritance in a tree-like format\n"
-      + "this option does not work together with the show-branch option")
+  @Option(
+      name = "--tree",
+      aliases = {"-t"},
+      usage =
+          "displays project inheritance in a tree-like format\n"
+              + "this option does not work together with the show-branch option")
   public void setShowTree(boolean showTree) {
     this.showTree = showTree;
   }
@@ -139,7 +140,10 @@
     this.type = type;
   }
 
-  @Option(name = "--description", aliases = {"-d"}, usage = "include description of project in list")
+  @Option(
+      name = "--description",
+      aliases = {"-d"},
+      usage = "include description of project in list")
   public void setShowDescription(boolean showDescription) {
     this.showDescription = showDescription;
   }
@@ -149,22 +153,38 @@
     this.all = all;
   }
 
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of projects to list")
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of projects to list")
   public void setLimit(int limit) {
     this.limit = limit;
   }
 
-  @Option(name = "--start", aliases = {"-S"}, metaVar = "CNT", usage = "number of projects to skip")
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of projects to skip")
   public void setStart(int start) {
     this.start = start;
   }
 
-  @Option(name = "--prefix", aliases = {"-p"}, metaVar = "PREFIX", usage = "match project prefix")
+  @Option(
+      name = "--prefix",
+      aliases = {"-p"},
+      metaVar = "PREFIX",
+      usage = "match project prefix")
   public void setMatchPrefix(String matchPrefix) {
     this.matchPrefix = matchPrefix;
   }
 
-  @Option(name = "--match", aliases = {"-m"}, metaVar = "MATCH", usage = "match project substring")
+  @Option(
+      name = "--match",
+      aliases = {"-m"},
+      metaVar = "MATCH",
+      usage = "match project substring")
   public void setMatchSubstring(String matchSubstring) {
     this.matchSubstring = matchSubstring;
   }
@@ -174,8 +194,10 @@
     this.matchRegex = matchRegex;
   }
 
-  @Option(name = "--has-acl-for", metaVar = "GROUP", usage =
-      "displays only projects on which access rights for this group are directly assigned")
+  @Option(
+      name = "--has-acl-for",
+      metaVar = "GROUP",
+      usage = "displays only projects on which access rights for this group are directly assigned")
   public void setGroupUuid(AccountGroup.UUID groupUuid) {
     this.groupUuid = groupUuid;
   }
@@ -193,7 +215,8 @@
   private AccountGroup.UUID groupUuid;
 
   @Inject
-  protected ListProjects(CurrentUser currentUser,
+  protected ListProjects(
+      CurrentUser currentUser,
       ProjectCache projectCache,
       GroupsCollection groupsCollection,
       GroupControl.Factory groupControlFactory,
@@ -251,8 +274,8 @@
       throws BadRequestException {
     PrintWriter stdout = null;
     if (displayOutputStream != null) {
-      stdout = new PrintWriter(new BufferedWriter(
-          new OutputStreamWriter(displayOutputStream, UTF_8)));
+      stdout =
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
     }
 
     int foundIndex = 0;
@@ -280,8 +303,8 @@
           } catch (NoSuchGroupException ex) {
             break;
           }
-          if (!pctl.getLocalGroups().contains(
-              GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
+          if (!pctl.getLocalGroups()
+              .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
             continue;
           }
         }
@@ -295,8 +318,7 @@
             ProjectControl parentCtrl = parentState.controlFor(currentUser);
             if (parentCtrl.isVisible() || parentCtrl.isOwner()) {
               info.name = parentState.getProject().getName();
-              info.description = Strings.emptyToNull(
-                  parentState.getProject().getDescription());
+              info.description = Strings.emptyToNull(parentState.getProject().getDescription());
               info.state = parentState.getProject().getState();
             } else {
               rejected.add(parentState.getProject().getName());
@@ -309,8 +331,7 @@
         } else {
           final boolean isVisible = pctl.isVisible() || (all && pctl.isOwner());
           if (showTree && !format.isJson()) {
-            treeMap.put(projectName,
-                projectNodeFactory.create(pctl.getProject(), isVisible));
+            treeMap.put(projectName, projectNodeFactory.create(pctl.getProject(), isVisible));
             continue;
           }
 
@@ -379,9 +400,8 @@
             log.warn("Unexpected error reading " + projectName, err);
             continue;
           }
-          FluentIterable<WebLinkInfo> links =
-              webLinks.getProjectLinks(projectName.get());
-          info.webLinks = links.isEmpty() ? null : links.toList();
+          List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
+          info.webLinks = links.isEmpty() ? null : links;
         }
 
         if (foundIndex++ < start) {
@@ -424,8 +444,9 @@
       if (stdout == null) {
         return output;
       } else if (format.isJson()) {
-        format.newGson().toJson(
-            output, new TypeToken<Map<String, ProjectInfo>>() {}.getType(), stdout);
+        format
+            .newGson()
+            .toJson(output, new TypeToken<Map<String, ProjectInfo>>() {}.getType(), stdout);
         stdout.print('\n');
       } else if (showTree && treeMap.size() > 0) {
         printProjectTree(stdout, treeMap);
@@ -444,24 +465,20 @@
       return projectCache.byName(matchPrefix);
     } else if (matchSubstring != null) {
       checkMatchOptions(matchPrefix == null && matchRegex == null);
-      return Iterables.filter(projectCache.all(),
-          new Predicate<Project.NameKey>() {
-            @Override
-            public boolean apply(Project.NameKey in) {
-              return in.get().toLowerCase(Locale.US)
-                  .contains(matchSubstring.toLowerCase(Locale.US));
-            }
-          });
+      return Iterables.filter(
+          projectCache.all(),
+          p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US)));
     } else if (matchRegex != null) {
       checkMatchOptions(matchPrefix == null && matchSubstring == null);
       RegexListSearcher<Project.NameKey> searcher;
       try {
-        searcher = new RegexListSearcher<Project.NameKey>(matchRegex) {
-          @Override
-          public String apply(Project.NameKey in) {
-            return in.get();
-          }
-        };
+        searcher =
+            new RegexListSearcher<Project.NameKey>(matchRegex) {
+              @Override
+              public String apply(Project.NameKey in) {
+                return in.get();
+              }
+            };
       } catch (IllegalArgumentException e) {
         throw new BadRequestException(e.getMessage());
       }
@@ -471,15 +488,14 @@
     }
   }
 
-  private static void checkMatchOptions(boolean cond)
-      throws BadRequestException {
+  private static void checkMatchOptions(boolean cond) throws BadRequestException {
     if (!cond) {
       throw new BadRequestException("specify exactly one of p/m/r");
     }
   }
 
-  private void printProjectTree(final PrintWriter stdout,
-      final TreeMap<Project.NameKey, ProjectNode> treeMap) {
+  private void printProjectTree(
+      final PrintWriter stdout, final TreeMap<Project.NameKey, ProjectNode> treeMap) {
     final SortedSet<ProjectNode> sortedNodes = new TreeSet<>();
 
     // Builds the inheritance tree using a list.
@@ -503,16 +519,15 @@
     stdout.flush();
   }
 
-  private List<Ref> getBranchRefs(Project.NameKey projectName,
-      ProjectControl projectControl) {
+  private List<Ref> getBranchRefs(Project.NameKey projectName, ProjectControl projectControl) {
     Ref[] result = new Ref[showBranch.size()];
     try (Repository git = repoManager.openRepository(projectName)) {
       for (int i = 0; i < showBranch.size(); i++) {
         Ref ref = git.findRef(showBranch.get(i));
-        if (ref != null
-          && ref.getObjectId() != null
-          && (projectControl.controlForRef(ref.getLeaf().getName()).isVisible())
-              || (all && projectControl.isOwner())) {
+        if ((ref != null
+                && ref.getObjectId() != null
+                && (projectControl.controlForRef(ref.getLeaf().getName()).isVisible()))
+            || (all && projectControl.isOwner())) {
           result[i] = ref;
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
index 6088c54..6090e53 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -24,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommonConverters;
+import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
@@ -31,7 +33,12 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
@@ -43,36 +50,46 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-
 public class ListTags implements RestReadView<ProjectResource> {
   private final GitRepositoryManager repoManager;
   private final Provider<ReviewDb> dbProvider;
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
   @Nullable private final SearchingChangeCacheImpl changeCache;
+  private final WebLinks links;
 
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "maximum number of tags to list")
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of tags to list")
   public void setLimit(int limit) {
     this.limit = limit;
   }
 
-  @Option(name = "--start", aliases = {"-s"}, metaVar = "CNT", usage = "number of tags to skip")
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of tags to skip")
   public void setStart(int start) {
     this.start = start;
   }
 
-  @Option(name = "--match", aliases = {"-m"}, metaVar = "MATCH", usage = "match tags substring")
+  @Option(
+      name = "--match",
+      aliases = {"-m"},
+      metaVar = "MATCH",
+      usage = "match tags substring")
   public void setMatchSubstring(String matchSubstring) {
     this.matchSubstring = matchSubstring;
   }
 
-  @Option(name = "--regex", aliases = {"-r"}, metaVar = "REGEX", usage = "match tags regex")
+  @Option(
+      name = "--regex",
+      aliases = {"-r"},
+      metaVar = "REGEX",
+      usage = "match tags regex")
   public void setMatchRegex(String matchRegex) {
     this.matchRegex = matchRegex;
   }
@@ -83,38 +100,44 @@
   private String matchRegex;
 
   @Inject
-  public ListTags(GitRepositoryManager repoManager,
+  public ListTags(
+      GitRepositoryManager repoManager,
       Provider<ReviewDb> dbProvider,
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
-      @Nullable SearchingChangeCacheImpl changeCache) {
+      @Nullable SearchingChangeCacheImpl changeCache,
+      WebLinks webLinks) {
     this.repoManager = repoManager;
     this.dbProvider = dbProvider;
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
     this.changeCache = changeCache;
+    this.links = webLinks;
   }
 
   @Override
-  public List<TagInfo> apply(ProjectResource resource) throws IOException,
-      ResourceNotFoundException, BadRequestException {
+  public List<TagInfo> apply(ProjectResource resource)
+      throws IOException, ResourceNotFoundException, BadRequestException {
     List<TagInfo> tags = new ArrayList<>();
 
     try (Repository repo = getRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
-      Map<String, Ref> all = visibleTags(resource.getControl(), repo,
-          repo.getRefDatabase().getRefs(Constants.R_TAGS));
+      ProjectControl pctl = resource.getControl();
+      Map<String, Ref> all =
+          visibleTags(pctl, repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
       for (Ref ref : all.values()) {
-        tags.add(createTagInfo(ref, rw));
+        tags.add(createTagInfo(ref, rw, pctl.controlForRef(ref.getName()), pctl, links));
       }
     }
 
-    Collections.sort(tags, new Comparator<TagInfo>() {
-      @Override
-      public int compare(TagInfo a, TagInfo b) {
-        return a.ref.compareTo(b.ref);
-      }
-    });
+    Collections.sort(
+        tags,
+        new Comparator<TagInfo>() {
+          @Override
+          public int compare(TagInfo a, TagInfo b) {
+            return a.ref.compareTo(b.ref);
+          }
+        });
 
     return new RefFilter<TagInfo>(Constants.R_TAGS)
         .start(start)
@@ -133,33 +156,38 @@
         tagName = Constants.R_TAGS + tagName;
       }
       Ref ref = repo.getRefDatabase().exactRef(tagName);
-      if (ref != null && !visibleTags(resource.getControl(), repo,
-          ImmutableMap.of(ref.getName(), ref)).isEmpty()) {
-        return createTagInfo(ref, rw);
+      ProjectControl pctl = resource.getControl();
+      if (ref != null && !visibleTags(pctl, repo, ImmutableMap.of(ref.getName(), ref)).isEmpty()) {
+        return createTagInfo(ref, rw, pctl.controlForRef(ref.getName()), pctl, links);
       }
     }
     throw new ResourceNotFoundException(id);
   }
 
-  public static TagInfo createTagInfo(Ref ref, RevWalk rw)
+  public static TagInfo createTagInfo(
+      Ref ref, RevWalk rw, RefControl control, ProjectControl pctl, WebLinks links)
       throws MissingObjectException, IOException {
     RevObject object = rw.parseAny(ref.getObjectId());
+    List<WebLinkInfo> webLinks = links.getTagLinks(pctl.getProject().getName(), ref.getName());
     if (object instanceof RevTag) {
       // Annotated or signed tag
-      RevTag tag = (RevTag)object;
+      RevTag tag = (RevTag) object;
       PersonIdent tagger = tag.getTaggerIdent();
       return new TagInfo(
           ref.getName(),
           tag.getName(),
           tag.getObject().getName(),
           tag.getFullMessage().trim(),
-          tagger != null ?
-              CommonConverters.toGitPerson(tag.getTaggerIdent()) : null);
+          tagger != null ? CommonConverters.toGitPerson(tag.getTaggerIdent()) : null,
+          control.canDelete(),
+          webLinks.isEmpty() ? null : webLinks);
     }
     // Lightweight tag
     return new TagInfo(
         ref.getName(),
-        ref.getObjectId().getName());
+        ref.getObjectId().getName(),
+        control.canDelete(),
+        webLinks.isEmpty() ? null : webLinks);
   }
 
   private Repository getRepository(Project.NameKey project)
@@ -171,9 +199,10 @@
     }
   }
 
-  private Map<String, Ref> visibleTags(ProjectControl control, Repository repo,
-      Map<String, Ref> tags) {
-    return new VisibleRefFilter(tagCache, changeNotesFactory, changeCache, repo,
-        control, dbProvider.get(), false).filter(tags, true);
+  private Map<String, Ref> visibleTags(
+      ProjectControl pctl, Repository repo, Map<String, Ref> tags) {
+    return new VisibleRefFilter(
+            tagCache, changeNotesFactory, changeCache, repo, pctl, dbProvider.get(), false)
+        .filter(tags, true);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
index 8a6145a..d7af195 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
@@ -61,6 +61,7 @@
 
     get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
     post(PROJECT_KIND, "gc").to(GarbageCollect.class);
+    post(PROJECT_KIND, "index").to(Index.class);
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
     put(BRANCH_KIND).to(PutBranch.class);
@@ -76,11 +77,14 @@
 
     child(PROJECT_KIND, "commits").to(CommitsCollection.class);
     get(COMMIT_KIND).to(GetCommit.class);
+    get(COMMIT_KIND, "in").to(CommitIncludedIn.class);
     child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
 
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
     get(TAG_KIND).to(GetTag.class);
     put(TAG_KIND).to(PutTag.class);
+    delete(TAG_KIND).to(DeleteTag.class);
+    post(PROJECT_KIND, "tags:delete").to(DeleteTags.class);
     factory(CreateTag.Factory.class);
 
     child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
@@ -91,5 +95,7 @@
 
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
+
+    factory(DeleteRef.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchChangeException.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchChangeException.java
index f766c7f..7946a3a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchChangeException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchChangeException.java
@@ -15,16 +15,17 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
 
 /** Indicates the change does not exist. */
-public class NoSuchChangeException extends Exception {
+public class NoSuchChangeException extends OrmException {
   private static final long serialVersionUID = 1L;
 
-  public NoSuchChangeException(final Change.Id key) {
+  public NoSuchChangeException(Change.Id key) {
     this(key, null);
   }
 
-  public NoSuchChangeException(final Change.Id key, final Throwable why) {
+  public NoSuchChangeException(Change.Id key, Throwable why) {
     super(key.toString(), why);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
index f46a881..0f71ac8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
 import com.google.inject.servlet.RequestScoped;
-
 import java.util.HashMap;
 import java.util.Map;
 
@@ -30,8 +29,7 @@
   private final Map<Project.NameKey, ProjectControl> controls;
 
   @Inject
-  PerRequestProjectControlCache(ProjectCache projectCache,
-      CurrentUser userProvider) {
+  PerRequestProjectControlCache(ProjectCache projectCache, CurrentUser userProvider) {
     this.projectCache = projectCache;
     this.user = userProvider;
     this.controls = new HashMap<>();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
index ff0fd2e..9febb3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
@@ -18,8 +18,10 @@
 import static com.google.gerrit.server.project.RefPattern.isRE;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Permission;
@@ -29,7 +31,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -41,11 +42,10 @@
 
 /**
  * Effective permissions applied to a reference in a project.
- * <p>
- * A collection may be user specific if a matching {@link AccessSection} uses
- * "${username}" in its name. The permissions granted in that section may only
- * be granted to the username that appears in the reference name, and also only
- * if the user is a member of the relevant group.
+ *
+ * <p>A collection may be user specific if a matching {@link AccessSection} uses "${username}" in
+ * its name. The permissions granted in that section may only be granted to the username that
+ * appears in the reference name, and also only if the user is a member of the relevant group.
  */
 public class PermissionCollection {
   @Singleton
@@ -60,19 +60,16 @@
     /**
      * Get all permissions that apply to a reference.
      *
-     * @param matcherList collection of sections that should be considered, in
-     *        priority order (project specific definitions must appear before
-     *        inherited ones).
+     * @param matcherList collection of sections that should be considered, in priority order
+     *     (project specific definitions must appear before inherited ones).
      * @param ref reference being accessed.
-     * @param user if the reference is a per-user reference, e.g. access
-     *        sections using the parameter variable "${username}" will have
-     *        each username inserted into them to see if they apply to the
-     *        reference named by {@code ref}.
-     * @return map of permissions that apply to this reference, keyed by
-     *         permission name.
+     * @param user if the reference is a per-user reference, e.g. access sections using the
+     *     parameter variable "${username}" will have each username inserted into them to see if
+     *     they apply to the reference named by {@code ref}.
+     * @return map of permissions that apply to this reference, keyed by permission name.
      */
-    PermissionCollection filter(Iterable<SectionMatcher> matcherList,
-        String ref, CurrentUser user) {
+    PermissionCollection filter(
+        Iterable<SectionMatcher> matcherList, String ref, CurrentUser user) {
       if (isRE(ref)) {
         ref = RefPattern.shortestExample(ref);
       } else if (ref.endsWith("/*")) {
@@ -115,6 +112,8 @@
       HashMap<String, List<PermissionRule>> permissions = new HashMap<>();
       HashMap<String, List<PermissionRule>> overridden = new HashMap<>();
       Map<PermissionRule, ProjectRef> ruleProps = Maps.newIdentityHashMap();
+      ListMultimap<Project.NameKey, String> exclusivePermissionsByProject =
+          MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccessSection section : sections) {
         Project.NameKey project = sectionToProject.get(section);
         for (Permission permission : section.getPermissions()) {
@@ -125,7 +124,7 @@
             SeenRule s = SeenRule.create(section, permission, rule);
             boolean addRule;
             if (rule.isBlock()) {
-              addRule = true;
+              addRule = !exclusivePermissionsByProject.containsEntry(project, permission.getName());
             } else {
               addRule = seen.add(s) && !rule.isDeny() && !exclusivePermissionExists;
             }
@@ -149,13 +148,13 @@
           }
 
           if (permission.getExclusiveGroup()) {
+            exclusivePermissionsByProject.put(project, permission.getName());
             exclusiveGroupPermissions.add(permission.getName());
           }
         }
       }
 
-      return new PermissionCollection(permissions, overridden, ruleProps,
-          perUser);
+      return new PermissionCollection(permissions, overridden, ruleProps, perUser);
     }
   }
 
@@ -164,7 +163,8 @@
   private final Map<PermissionRule, ProjectRef> ruleProps;
   private final boolean perUser;
 
-  private PermissionCollection(Map<String, List<PermissionRule>> rules,
+  private PermissionCollection(
+      Map<String, List<PermissionRule>> rules,
       Map<String, List<PermissionRule>> overridden,
       Map<PermissionRule, ProjectRef> ruleProps,
       boolean perUser) {
@@ -175,8 +175,8 @@
   }
 
   /**
-   * @return true if a "${username}" pattern might need to be expanded to build
-   *         this collection, making the results user specific.
+   * @return true if a "${username}" pattern might need to be expanded to build this collection,
+   *     making the results user specific.
    */
   public boolean isUserSpecific() {
     return perUser;
@@ -186,18 +186,16 @@
    * Obtain all permission rules for a given type of permission.
    *
    * @param permissionName type of permission.
-   * @return all rules that apply to this reference, for any group. Never null;
-   *         the empty list is returned when there are no rules for the requested
-   *         permission name.
+   * @return all rules that apply to this reference, for any group. Never null; the empty list is
+   *     returned when there are no rules for the requested permission name.
    */
   public List<PermissionRule> getPermission(String permissionName) {
     List<PermissionRule> r = rules.get(permissionName);
-    return r != null ? r : Collections.<PermissionRule> emptyList();
+    return r != null ? r : Collections.<PermissionRule>emptyList();
   }
 
   List<PermissionRule> getOverridden(String permissionName) {
-    return firstNonNull(
-        overridden.get(permissionName), Collections.<PermissionRule> emptyList());
+    return firstNonNull(overridden.get(permissionName), Collections.<PermissionRule>emptyList());
   }
 
   ProjectRef getRuleProps(PermissionRule rule) {
@@ -207,12 +205,11 @@
   /**
    * Obtain all declared permission rules that match the reference.
    *
-   * @return all rules. The collection will iterate a permission if it was
-   *         declared in the project configuration, either directly or
-   *         inherited. If the project owner did not use a known permission (for
-   *         example {@link Permission#FORGE_SERVER}, then it will not be
-   *         represented in the result even if {@link #getPermission(String)}
-   *         returns an empty list for the same permission.
+   * @return all rules. The collection will iterate a permission if it was declared in the project
+   *     configuration, either directly or inherited. If the project owner did not use a known
+   *     permission (for example {@link Permission#FORGE_SERVER}, then it will not be represented in
+   *     the result even if {@link #getPermission(String)} returns an empty list for the same
+   *     permission.
    */
   public Iterable<Map.Entry<String, List<PermissionRule>>> getDeclaredPermissions() {
     return rules.entrySet();
@@ -222,14 +219,16 @@
   @AutoValue
   abstract static class SeenRule {
     public abstract String refPattern();
-    public abstract String permissionName();
-    @Nullable public abstract AccountGroup.UUID group();
 
-    static SeenRule create(AccessSection section, Permission permission,
-        @Nullable PermissionRule rule) {
-      AccountGroup.UUID group = rule != null && rule.getGroup() != null
-          ? rule.getGroup().getUUID()
-          : null;
+    public abstract String permissionName();
+
+    @Nullable
+    public abstract AccountGroup.UUID group();
+
+    static SeenRule create(
+        AccessSection section, Permission permission, @Nullable PermissionRule rule) {
+      AccountGroup.UUID group =
+          rule != null && rule.getGroup() != null ? rule.getGroup().getUUID() : null;
       return new AutoValue_PermissionCollection_SeenRule(
           section.getName(), permission.getName(), group);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
index fa2f639..4ed6480 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-
 import java.io.IOException;
 import java.util.Set;
 
@@ -44,8 +43,7 @@
    * @throws IOException when there was an error.
    * @return the cached data; null if no such project exists.
    */
-  ProjectState checkedGet(Project.NameKey projectName)
-      throws IOException;
+  ProjectState checkedGet(Project.NameKey projectName) throws IOException;
 
   /** Invalidate the cached information about the given project. */
   void evict(Project p);
@@ -54,8 +52,8 @@
   void evict(Project.NameKey p);
 
   /**
-   * Remove information about the given project from the cache. It will no
-   * longer be returned from {@link #all()}.
+   * Remove information about the given project from the cache. It will no longer be returned from
+   * {@link #all()}.
    */
   void remove(Project p);
 
@@ -69,9 +67,8 @@
   Iterable<Project.NameKey> all();
 
   /**
-   * @return estimated set of relevant groups extracted from hot project access
-   *         rules. If the cache is cold or too small for the entire project set
-   *         of the server, this set may be incomplete.
+   * @return estimated set of relevant groups extracted from hot project access rules. If the cache
+   *     is cold or too small for the entire project set of the server, this set may be incomplete.
    */
   Set<AccountGroup.UUID> guessRelevantGroupUUIDs();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheClock.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheClock.java
index c96ebdf..6f7e414 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheClock.java
@@ -15,70 +15,86 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.eclipse.jgit.lib.Config;
 
 /** Ticks periodically to force refresh events for {@link ProjectCacheImpl}. */
 @Singleton
-public class ProjectCacheClock {
-  private volatile long generation;
+public class ProjectCacheClock implements LifecycleListener {
+  private final Config serverConfig;
+
+  private final AtomicLong generation = new AtomicLong();
+
+  private ScheduledExecutorService executor;
 
   @Inject
   public ProjectCacheClock(@GerritServerConfig Config serverConfig) {
-    this(checkFrequency(serverConfig));
+    this.serverConfig = serverConfig;
   }
 
-  public ProjectCacheClock(long checkFrequencyMillis) {
+  @Override
+  public void start() {
+    long checkFrequencyMillis = checkFrequency(serverConfig);
+
     if (checkFrequencyMillis == Long.MAX_VALUE) {
       // Start with generation 1 (to avoid magic 0 below).
       // Do not begin background thread, disabling the clock.
-      generation = 1;
+      generation.set(1);
     } else if (10 < checkFrequencyMillis) {
       // Start with generation 1 (to avoid magic 0 below).
-      generation = 1;
-      ScheduledExecutorService executor = Executors.newScheduledThreadPool(
-          1,
-          new ThreadFactoryBuilder()
-            .setNameFormat("ProjectCacheClock-%d")
-            .setDaemon(true)
-            .setPriority(Thread.MIN_PRIORITY)
-            .build());
-      executor.scheduleAtFixedRate(new Runnable() {
-        @Override
-        public void run() {
-          // This is not exactly thread-safe, but is OK for our use.
-          // The only thread that writes the volatile is this task.
-          generation = generation + 1;
-        }
-      }, checkFrequencyMillis, checkFrequencyMillis, TimeUnit.MILLISECONDS);
+      generation.set(1);
+      executor =
+          Executors.newScheduledThreadPool(
+              1,
+              new ThreadFactoryBuilder()
+                  .setNameFormat("ProjectCacheClock-%d")
+                  .setDaemon(true)
+                  .setPriority(Thread.MIN_PRIORITY)
+                  .build());
+      @SuppressWarnings("unused") // Runnable already handles errors
+      Future<?> possiblyIgnoredError =
+          executor.scheduleAtFixedRate(
+              () -> {
+                generation.incrementAndGet();
+              },
+              checkFrequencyMillis,
+              checkFrequencyMillis,
+              TimeUnit.MILLISECONDS);
     } else {
       // Magic generation 0 triggers ProjectState to always
       // check on each needsRefresh() request we make to it.
-      generation = 0;
+      generation.set(0);
+    }
+  }
+
+  @Override
+  public void stop() {
+    if (executor != null) {
+      executor.shutdown();
     }
   }
 
   long read() {
-    return generation;
+    return generation.get();
   }
 
   private static long checkFrequency(Config serverConfig) {
     String freq = serverConfig.getString("cache", "projects", "checkFrequency");
-    if (freq != null
-        && ("disabled".equalsIgnoreCase(freq) || "off".equalsIgnoreCase(freq))) {
+    if (freq != null && ("disabled".equalsIgnoreCase(freq) || "off".equalsIgnoreCase(freq))) {
       return Long.MAX_VALUE;
     }
     return TimeUnit.MILLISECONDS.convert(
-        ConfigUtil.getTimeUnit(serverConfig,
-            "cache", "projects", "checkFrequency",
-            5, TimeUnit.MINUTES), TimeUnit.MINUTES);
+        ConfigUtil.getTimeUnit(
+            serverConfig, "cache", "projects", "checkFrequency", 5, TimeUnit.MINUTES),
+        TimeUnit.MINUTES);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 7ce19c8f..c747a14 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.Predicate;
+import static java.util.stream.Collectors.toSet;
+
 import com.google.common.base.Throwables;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
@@ -33,60 +32,51 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
-import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.name.Named;
-
-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.util.Collections;
-import java.util.HashSet;
 import java.util.Iterator;
 import java.util.NoSuchElementException;
+import java.util.Objects;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Cache of project information, including access rights. */
 @Singleton
 public class ProjectCacheImpl implements ProjectCache {
-  private static final Logger log = LoggerFactory
-      .getLogger(ProjectCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(ProjectCacheImpl.class);
 
   private static final String CACHE_NAME = "projects";
   private static final String CACHE_LIST = "project_list";
 
-  private static final Predicate<AccountGroup.UUID> NON_NULL_UUID =
-      new Predicate<AccountGroup.UUID>() {
-        @Override
-        public boolean apply(AccountGroup.UUID uuid) {
-          return uuid != null && uuid.get() != null;
-        }
-      };
-
   public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(CACHE_NAME, String.class, ProjectState.class)
-          .loader(Loader.class);
+        cache(CACHE_NAME, String.class, ProjectState.class).loader(Loader.class);
 
-        cache(CACHE_LIST,
-            ListKey.class,
-            new TypeLiteral<SortedSet<Project.NameKey>>() {})
-          .maximumWeight(1)
-          .loader(Lister.class);
+        cache(CACHE_LIST, ListKey.class, new TypeLiteral<SortedSet<Project.NameKey>>() {})
+            .maximumWeight(1)
+            .loader(Lister.class);
 
         bind(ProjectCacheImpl.class);
         bind(ProjectCache.class).to(ProjectCacheImpl.class);
-        bind(LifecycleListener.class)
-          .annotatedWith(UniqueAnnotations.create())
-          .to(ProjectCacheWarmer.class);
+
+        install(
+            new LifecycleModule() {
+              @Override
+              protected void configure() {
+                listener().to(ProjectCacheWarmer.class);
+                listener().to(ProjectCacheClock.class);
+              }
+            });
       }
     };
   }
@@ -136,7 +126,7 @@
 
   @Override
   public ProjectState get(final Project.NameKey projectName) {
-     try {
+    try {
       return checkedGet(projectName);
     } catch (IOException e) {
       return null;
@@ -144,8 +134,7 @@
   }
 
   @Override
-  public ProjectState checkedGet(Project.NameKey projectName)
-      throws IOException {
+  public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
     if (projectName == null) {
       return null;
     }
@@ -158,8 +147,8 @@
       return state;
     } catch (ExecutionException e) {
       if (!(e.getCause() instanceof RepositoryNotFoundException)) {
-        log.warn(String.format("Cannot read project %s", projectName.get()), e);
-        Throwables.propagateIfInstanceOf(e.getCause(), IOException.class);
+        log.warn("Cannot read project {}", projectName.get(), e);
+        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
         throw new IOException(e);
       }
       return null;
@@ -194,7 +183,7 @@
       n.remove(name);
       list.put(ListKey.ALL, Collections.unmodifiableSortedSet(n));
     } catch (ExecutionException e) {
-      log.warn("Cannot list avaliable projects", e);
+      log.warn("Cannot list available projects", e);
     } finally {
       listLock.unlock();
     }
@@ -209,7 +198,7 @@
       n.add(newProjectName);
       list.put(ListKey.ALL, Collections.unmodifiableSortedSet(n));
     } catch (ExecutionException e) {
-      log.warn("Cannot list avaliable projects", e);
+      log.warn("Cannot list available projects", e);
     } finally {
       listLock.unlock();
     }
@@ -221,23 +210,20 @@
       return list.get(ListKey.ALL);
     } catch (ExecutionException e) {
       log.warn("Cannot list available projects", e);
-      return ImmutableSortedSet.of();
+      return Collections.emptySortedSet();
     }
   }
 
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-    Set<AccountGroup.UUID> groups = new HashSet<>();
-    for (Project.NameKey n : all()) {
-      ProjectState p = byName.getIfPresent(n.get());
-      if (p != null) {
-        groups.addAll(FluentIterable
-            .from(p.getConfig().getAllGroupUUIDs())
-            .filter(NON_NULL_UUID)
-            .toSet());
-      }
-    }
-    return groups;
+    return all().stream()
+        .map(n -> byName.getIfPresent(n.get()))
+        .filter(Objects::nonNull)
+        .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
+        // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+        // against them just in case there is a bug or corner case.
+        .filter(id -> id != null && id.get() != null)
+        .collect(toSet());
   }
 
   @Override
@@ -270,7 +256,7 @@
               next = r;
               return true;
             }
-            itr = Collections.<Project.NameKey> emptyList().iterator();
+            itr = Collections.<Project.NameKey>emptyList().iterator();
             return false;
           }
 
@@ -324,8 +310,7 @@
   static class ListKey {
     static final ListKey ALL = new ListKey();
 
-    private ListKey() {
-    }
+    private ListKey() {}
   }
 
   static class Lister extends CacheLoader<ListKey, SortedSet<Project.NameKey>> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 2cdb172..5e0ba28 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -20,18 +20,18 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.ThreadPoolExecutor;
-
 @Singleton
 public class ProjectCacheWarmer implements LifecycleListener {
-  private static final Logger log =
-      LoggerFactory.getLogger(ProjectCacheWarmer.class);
+  private static final Logger log = LoggerFactory.getLogger(ProjectCacheWarmer.class);
 
   private final Config config;
   private final ProjectCache cache;
@@ -47,29 +47,37 @@
     int cpus = Runtime.getRuntime().availableProcessors();
     if (config.getBoolean("cache", "projects", "loadOnStartup", false)) {
       final ThreadPoolExecutor pool =
-          new ScheduledThreadPoolExecutor(config.getInt("cache", "projects",
-              "loadThreads", cpus), new ThreadFactoryBuilder().setNameFormat(
-              "ProjectCacheLoader-%d").build());
+          new ScheduledThreadPoolExecutor(
+              config.getInt("cache", "projects", "loadThreads", cpus),
+              new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build());
+      ExecutorService scheduler = Executors.newFixedThreadPool(1);
 
       log.info("Loading project cache");
-      pool.execute(new Runnable() {
-        @Override
-        public void run() {
-          for (final Project.NameKey name : cache.all()) {
-            pool.execute(new Runnable() {
-              @Override
-              public void run() {
-                cache.get(name);
+      scheduler.execute(
+          new Runnable() {
+            @Override
+            public void run() {
+              for (final Project.NameKey name : cache.all()) {
+                pool.execute(
+                    new Runnable() {
+                      @Override
+                      public void run() {
+                        cache.get(name);
+                      }
+                    });
               }
-            });
-          }
-          pool.shutdown();
-        }
-      });
+              pool.shutdown();
+              try {
+                pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
+                log.info("Finished loading project cache");
+              } catch (InterruptedException e) {
+                log.warn("Interrupted while waiting for project cache to load");
+              }
+            }
+          });
     }
   }
 
   @Override
-  public void stop() {
-  }
+  public void stop() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index 22e5d69..1b035b9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
+
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
@@ -25,6 +27,9 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -34,6 +39,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.change.IncludedInResolver;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
@@ -42,18 +48,13 @@
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -63,6 +64,12 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Access control management for a user accessing a project's data. */
 public class ProjectControl {
@@ -88,8 +95,8 @@
       return p.controlFor(user);
     }
 
-    public ProjectControl validateFor(Project.NameKey nameKey, int need,
-        CurrentUser user) throws NoSuchProjectException, IOException {
+    public ProjectControl validateFor(Project.NameKey nameKey, int need, CurrentUser user)
+        throws NoSuchProjectException, IOException {
       final ProjectControl c = controlFor(nameKey, user);
       if ((need & VISIBLE) == VISIBLE && c.isVisible()) {
         return c;
@@ -109,23 +116,20 @@
       userCache = uc;
     }
 
-    public ProjectControl controlFor(final Project.NameKey nameKey)
-        throws NoSuchProjectException {
+    public ProjectControl controlFor(final Project.NameKey nameKey) throws NoSuchProjectException {
       return userCache.get().get(nameKey);
     }
 
-    public ProjectControl validateFor(final Project.NameKey nameKey)
-        throws NoSuchProjectException {
+    public ProjectControl validateFor(final Project.NameKey nameKey) throws NoSuchProjectException {
       return validateFor(nameKey, VISIBLE);
     }
 
-    public ProjectControl ownerFor(final Project.NameKey nameKey)
-        throws NoSuchProjectException {
+    public ProjectControl ownerFor(final Project.NameKey nameKey) throws NoSuchProjectException {
       return validateFor(nameKey, OWNER);
     }
 
-    public ProjectControl validateFor(final Project.NameKey nameKey,
-        final int need) throws NoSuchProjectException {
+    public ProjectControl validateFor(final Project.NameKey nameKey, final int need)
+        throws NoSuchProjectException {
       final ProjectControl c = controlFor(nameKey);
       if ((need & VISIBLE) == VISIBLE && c.isVisible()) {
         return c;
@@ -141,6 +145,19 @@
     ProjectControl create(CurrentUser who, ProjectState ps);
   }
 
+  @Singleton
+  protected static class Metrics {
+    final Counter0 claCheckCount;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      claCheckCount =
+          metricMaker.newCounter(
+              "license/cla_check_count",
+              new Description("Total number of CLA check requests").setRate().setUnit("requests"));
+    }
+  }
+
   private final Set<AccountGroup.UUID> uploadGroups;
   private final Set<AccountGroup.UUID> receiveGroups;
 
@@ -153,6 +170,9 @@
   private final Collection<ContributorAgreement> contributorAgreements;
   private final TagCache tagCache;
   @Nullable private final SearchingChangeCacheImpl changeCache;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Metrics metrics;
+  private final AllUsersName allUsersName;
 
   private List<SectionMatcher> allSections;
   private List<SectionMatcher> localSections;
@@ -161,17 +181,21 @@
   private Boolean declaredOwner;
 
   @Inject
-  ProjectControl(@GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
+  ProjectControl(
+      @GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
       ProjectCache pc,
       PermissionCollection.Factory permissionFilter,
       ChangeNotes.Factory changeNotesFactory,
       ChangeControl.Factory changeControlFactory,
       TagCache tagCache,
+      Provider<InternalChangeQuery> queryProvider,
       @Nullable SearchingChangeCacheImpl changeCache,
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
+      AllUsersName allUsersName,
       @Assisted CurrentUser who,
-      @Assisted ProjectState ps) {
+      @Assisted ProjectState ps,
+      Metrics metrics) {
     this.changeNotesFactory = changeNotesFactory;
     this.changeControlFactory = changeControlFactory;
     this.tagCache = tagCache;
@@ -181,6 +205,9 @@
     this.permissionFilter = permissionFilter;
     this.contributorAgreements = pc.getAllProjects().getConfig().getContributorAgreements();
     this.canonicalWebUrl = canonicalWebUrl;
+    this.queryProvider = queryProvider;
+    this.metrics = metrics;
+    this.allUsersName = allUsersName;
     user = who;
     state = ps;
   }
@@ -192,28 +219,24 @@
     return r;
   }
 
-  public ChangeControl controlFor(ReviewDb db, Change change)
-      throws OrmException {
-    return changeControlFactory.create(controlForRef(change.getDest()), db,
-        change.getProject(), change.getId());
+  public ChangeControl controlFor(ReviewDb db, Change change) throws OrmException {
+    return changeControlFactory.create(
+        controlForRef(change.getDest()), db, change.getProject(), change.getId());
   }
 
   /**
-   * Create a change control for a change that was loaded from index. This
-   * method should only be used when database access is harmful and potentially
-   * stale data from the index is acceptable.
+   * Create a change control for a change that was loaded from index. This method should only be
+   * used when database access is harmful and potentially stale data from the index is acceptable.
    *
    * @param change change loaded from secondary index
    * @return change control
    */
   public ChangeControl controlForIndexedChange(Change change) {
-    return changeControlFactory
-        .createForIndexedChange(controlForRef(change.getDest()), change);
+    return changeControlFactory.createForIndexedChange(controlForRef(change.getDest()), change);
   }
 
   public ChangeControl controlFor(ChangeNotes notes) {
-    return changeControlFactory
-        .create(controlForRef(notes.getChange().getDest()), notes);
+    return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
   }
 
   public RefControl controlForRef(Branch.NameKey ref) {
@@ -226,8 +249,7 @@
     }
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
-      PermissionCollection relevant =
-          permissionFilter.filter(access(), refName, user);
+      PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
       ctl = new RefControl(this, refName, relevant);
       refControls.put(refName, ctl);
     }
@@ -255,30 +277,30 @@
 
   /** Returns whether the project is hidden. */
   public boolean isHidden() {
-    return getProject().getState().equals(
-        com.google.gerrit.extensions.client.ProjectState.HIDDEN);
+    return getProject().getState().equals(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
   }
 
   /**
-   * Returns whether the project is readable to the current user. Note
-   * that the project could still be hidden.
+   * Returns whether the project is readable to the current user. Note that the project could still
+   * be hidden.
    */
   public boolean isReadable() {
-    return (user.isInternalUser()
-        || canPerformOnAnyRef(Permission.READ));
+    return (user.isInternalUser() || canPerformOnAnyRef(Permission.READ));
   }
 
   /**
-   * Returns whether the project is accessible to the current user, i.e.
-   * readable and not hidden.
+   * Returns whether the project is accessible to the current user, i.e. readable and not hidden.
    */
   public boolean isVisible() {
     return isReadable() && !isHidden();
   }
 
   public boolean canAddRefs() {
-    return (canPerformOnAnyRef(Permission.CREATE)
-        || isOwnerAnyRef());
+    return (canPerformOnAnyRef(Permission.CREATE) || isOwnerAnyRef());
+  }
+
+  public boolean canAddTagRefs() {
+    return (canPerformOnTagRef(Permission.CREATE) || isOwnerAnyRef());
   }
 
   public boolean canUpload() {
@@ -286,8 +308,7 @@
       AccessSection section = matcher.section;
       if (section.getName().startsWith("refs/for/")) {
         Permission permission = section.getPermission(Permission.PUSH);
-        if (permission != null
-            && controlForRef(section.getName()).canPerform(Permission.PUSH)) {
+        if (permission != null && controlForRef(section.getName()).canPerform(Permission.PUSH)) {
           return true;
         }
       }
@@ -297,18 +318,19 @@
 
   /** Can this user see all the refs in this projects? */
   public boolean allRefsAreVisible() {
-    return allRefsAreVisible(Collections.<String> emptySet());
+    return allRefsAreVisible(Collections.<String>emptySet());
   }
 
   public boolean allRefsAreVisible(Set<String> ignore) {
     return user.isInternalUser()
-        || canPerformOnAllRefs(Permission.READ, ignore);
+        || (!getProject().getNameKey().equals(allUsersName)
+            && canPerformOnAllRefs(Permission.READ, ignore));
   }
 
   /** Is this user a project owner? Ownership does not imply {@link #isVisible()} */
   public boolean isOwner() {
-    return isDeclaredOwner()
-      || user.getCapabilities().canAdministrateServer();
+    return (isDeclaredOwner() && !controlForRef("refs/*").isBlocked(Permission.OWNER))
+        || user.getCapabilities().canAdministrateServer();
   }
 
   private boolean isDeclaredOwner() {
@@ -321,14 +343,14 @@
 
   /** Does this user have ownership on at least one reference name? */
   public boolean isOwnerAnyRef() {
-    return canPerformOnAnyRef(Permission.OWNER)
-        || user.getCapabilities().canAdministrateServer();
+    return canPerformOnAnyRef(Permission.OWNER) || user.getCapabilities().canAdministrateServer();
   }
 
   /** @return true if the user can upload to at least one reference */
   public Capable canPushToAtLeastOneRef() {
-    if (! canPerformOnAnyRef(Permission.PUSH) &&
-        ! canPerformOnAnyRef(Permission.PUSH_TAG)) {
+    if (!canPerformOnAnyRef(Permission.PUSH)
+        && !canPerformOnAnyRef(Permission.CREATE_TAG)
+        && !isOwner()) {
       String pName = state.getProject().getName();
       return new Capable("Upload denied for project '" + pName + "'");
     }
@@ -346,8 +368,7 @@
     return getGroups(localAccess());
   }
 
-  private static Set<GroupReference> getGroups(
-      final List<SectionMatcher> sectionMatcherList) {
+  private static Set<GroupReference> getGroups(final List<SectionMatcher> sectionMatcherList) {
     final Set<GroupReference> all = new HashSet<>();
     for (final SectionMatcher matcher : sectionMatcherList) {
       final AccessSection section = matcher.section;
@@ -361,7 +382,8 @@
   }
 
   private Capable verifyActiveContributorAgreement() {
-    if (! (user.isIdentifiedUser())) {
+    metrics.claCheckCount.increment();
+    if (!(user.isIdentifiedUser())) {
       return new Capable("Must be logged in to verify Contributor Agreement");
     }
     final IdentifiedUser iUser = user.asIdentifiedUser();
@@ -372,7 +394,8 @@
       groupIds = okGroupIds;
 
       for (PermissionRule rule : ca.getAccepted()) {
-        if ((rule.getAction() == Action.ALLOW) && (rule.getGroup() != null)
+        if ((rule.getAction() == Action.ALLOW)
+            && (rule.getGroup() != null)
             && (rule.getGroup().getUUID() != null)) {
           groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
         }
@@ -398,6 +421,26 @@
     return new Capable(msg.toString());
   }
 
+  private boolean canPerformOnTagRef(String permissionName) {
+    for (SectionMatcher matcher : access()) {
+      AccessSection section = matcher.section;
+
+      if (section.getName().startsWith(REFS_TAGS)) {
+        Permission permission = section.getPermission(permissionName);
+        if (permission == null) {
+          continue;
+        }
+
+        Boolean can = canPerform(permissionName, section, permission);
+        if (can != null) {
+          return can;
+        }
+      }
+    }
+
+    return false;
+  }
+
   private boolean canPerformOnAnyRef(String permissionName) {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.section;
@@ -406,25 +449,33 @@
         continue;
       }
 
-      for (PermissionRule rule : permission.getRules()) {
-        if (rule.isBlock() || rule.isDeny() || !match(rule)) {
-          continue;
-        }
-
-        // Being in a group that was granted this permission is only an
-        // approximation.  There might be overrides and doNotInherit
-        // that would render this to be false.
-        //
-        if (controlForRef(section.getName()).canPerform(permissionName)) {
-          return true;
-        }
-        break;
+      Boolean can = canPerform(permissionName, section, permission);
+      if (can != null) {
+        return can;
       }
     }
 
     return false;
   }
 
+  private Boolean canPerform(String permissionName, AccessSection section, Permission permission) {
+    for (PermissionRule rule : permission.getRules()) {
+      if (rule.isBlock() || rule.isDeny() || !match(rule)) {
+        continue;
+      }
+
+      // Being in a group that was granted this permission is only an
+      // approximation.  There might be overrides and doNotInherit
+      // that would render this to be false.
+      //
+      if (controlForRef(section.getName()).canPerform(permissionName)) {
+        return true;
+      }
+      break;
+    }
+    return null;
+  }
+
   private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
     boolean canPerform = false;
     Set<String> patterns = allRefPatterns(permission);
@@ -512,29 +563,48 @@
     return false;
   }
 
+  /** @return whether a commit is visible to user. */
   public boolean canReadCommit(ReviewDb db, Repository repo, RevCommit commit) {
+    // Look for changes associated with the commit.
+    try {
+      List<ChangeData> changes =
+          queryProvider.get().byProjectCommit(getProject().getNameKey(), commit);
+      for (ChangeData change : changes) {
+        if (controlFor(db, change.change()).isVisible(db)) {
+          return true;
+        }
+      }
+    } catch (OrmException e) {
+      log.error(
+          "Cannot look up change for commit " + commit.name() + " in " + getProject().getName(), e);
+    }
+    // Scan all visible refs.
+    return canReadCommitFromVisibleRef(db, repo, commit);
+  }
+
+  private boolean canReadCommitFromVisibleRef(ReviewDb db, Repository repo, RevCommit commit) {
     try (RevWalk rw = new RevWalk(repo)) {
-      return isMergedIntoVisibleRef(repo, db, rw, commit,
-          repo.getAllRefs().values());
+      return isMergedIntoVisibleRef(repo, db, rw, commit, repo.getAllRefs().values());
     } catch (IOException e) {
-      String msg = String.format(
-          "Cannot verify permissions to commit object %s in repository %s",
-          commit.name(), getProject().getNameKey());
+      String msg =
+          String.format(
+              "Cannot verify permissions to commit object %s in repository %s",
+              commit.name(), getProject().getNameKey());
       log.error(msg, e);
       return false;
     }
   }
 
-  boolean isMergedIntoVisibleRef(Repository repo, ReviewDb db, RevWalk rw,
-      RevCommit commit, Collection<Ref> unfilteredRefs) throws IOException {
-    VisibleRefFilter filter = new VisibleRefFilter(
-        tagCache, changeNotesFactory, changeCache, repo, this, db, true);
+  boolean isMergedIntoVisibleRef(
+      Repository repo, ReviewDb db, RevWalk rw, RevCommit commit, Collection<Ref> unfilteredRefs)
+      throws IOException {
+    VisibleRefFilter filter =
+        new VisibleRefFilter(tagCache, changeNotesFactory, changeCache, repo, this, db, true);
     Map<String, Ref> m = Maps.newHashMapWithExpectedSize(unfilteredRefs.size());
     for (Ref r : unfilteredRefs) {
       m.put(r.getName(), r);
     }
     Map<String, Ref> refs = filter.filter(m, true);
-    return !refs.isEmpty()
-        && IncludedInResolver.includedInOne(repo, rw, commit, refs.values());
+    return !refs.isEmpty() && IncludedInResolver.includedInOne(repo, rw, commit, refs.values());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
index 0724ce9..9d9e5bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
@@ -19,19 +19,17 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllProjectsName;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Iterator;
 import java.util.List;
 import java.util.NoSuchElementException;
 import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Iterates from a project up through its parents to All-Projects.
- * <p>
- * If a cycle is detected the cycle is broken and All-Projects is visited.
+ *
+ * <p>If a cycle is detected the cycle is broken and All-Projects is visited.
  */
 class ProjectHierarchyIterator implements Iterator<ProjectState> {
   private static final Logger log = LoggerFactory.getLogger(ProjectHierarchyIterator.class);
@@ -41,9 +39,7 @@
   private final Set<Project.NameKey> seen;
   private ProjectState next;
 
-  ProjectHierarchyIterator(ProjectCache c,
-      AllProjectsName all,
-      ProjectState firstResult) {
+  ProjectHierarchyIterator(ProjectCache c, AllProjectsName all, ProjectState firstResult) {
     cache = c;
     allProjectsName = all;
 
@@ -95,8 +91,8 @@
     }
     int idx = order.lastIndexOf(parentName.get());
     order.add(parentName.get());
-    log.warn("Cycle detected in projects: "
-        + Joiner.on(" -> ").join(order.subList(idx, order.size())));
+    log.warn(
+        "Cycle detected in projects: " + Joiner.on(" -> ").join(order.subList(idx, order.size())));
     return false;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
index 5b1d521..b4b9c49 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.Url;
@@ -24,6 +23,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.List;
 
 @Singleton
 public class ProjectJson {
@@ -32,8 +32,7 @@
   private final WebLinks webLinks;
 
   @Inject
-  ProjectJson(AllProjectsName allProjectsName,
-      WebLinks webLinks) {
+  ProjectJson(AllProjectsName allProjectsName, WebLinks webLinks) {
     this.allProjects = allProjectsName;
     this.webLinks = webLinks;
   }
@@ -50,9 +49,8 @@
     info.description = Strings.emptyToNull(p.getDescription());
     info.state = p.getState();
     info.id = Url.encode(info.name);
-    FluentIterable<WebLinkInfo> links =
-        webLinks.getProjectLinks(p.getName());
-    info.webLinks = links.isEmpty() ? null : links.toList();
+    List<WebLinkInfo> links = webLinks.getProjectLinks(p.getName());
+    info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java
index e74511a..403efd2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java
@@ -19,14 +19,13 @@
 import com.google.gerrit.server.util.TreeFormatter.TreeNode;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.util.SortedSet;
 import java.util.TreeSet;
 
 /** Node of a Project in a tree formatted by {@link ListProjects}. */
 public class ProjectNode implements TreeNode, Comparable<ProjectNode> {
   public interface Factory {
-    ProjectNode create(final Project project, final boolean isVisible);
+    ProjectNode create(Project project, boolean isVisible);
   }
 
   private final AllProjectsName allProjectsName;
@@ -36,8 +35,10 @@
   private final SortedSet<ProjectNode> children = new TreeSet<>();
 
   @Inject
-  protected ProjectNode(final AllProjectsName allProjectsName,
-      @Assisted final Project project, @Assisted final boolean isVisible) {
+  protected ProjectNode(
+      final AllProjectsName allProjectsName,
+      @Assisted final Project project,
+      @Assisted final boolean isVisible) {
     this.allProjectsName = allProjectsName;
     this.project = project;
     this.isVisible = isVisible;
@@ -46,8 +47,7 @@
   /**
    * Returns the project parent name.
    *
-   * @return Project parent name, {@code null} for the 'All-Projects' root
-   *         project
+   * @return Project parent name, {@code null} for the 'All-Projects' root project
    */
   public Project.NameKey getParentName() {
     return project.getParent(allProjectsName);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectRef.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectRef.java
index 8d3185d..be5fda0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectRef.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectRef.java
@@ -20,6 +20,7 @@
 @AutoValue
 abstract class ProjectRef {
   public abstract Project.NameKey project();
+
   public abstract String ref();
 
   static ProjectRef create(Project.NameKey project, String ref) {
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 68d236e..0ca4ddf6 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
@@ -17,7 +17,8 @@
 import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Function;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
@@ -46,18 +47,11 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.ProjectLevelConfig;
+import com.google.gerrit.server.git.TransferConfig;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.Reader;
 import java.nio.file.Files;
@@ -72,11 +66,16 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Cached information on a project. */
 public class ProjectState {
-  private static final Logger log =
-      LoggerFactory.getLogger(ProjectState.class);
+  private static final Logger log = LoggerFactory.getLogger(ProjectState.class);
 
   public interface Factory {
     ProjectState create(ProjectConfig config);
@@ -96,6 +95,8 @@
   private final ProjectConfig config;
   private final Map<String, ProjectLevelConfig> configs;
   private final Set<AccountGroup.UUID> localOwners;
+  private final long globalMaxObjectSizeLimit;
+  private final boolean inheritProjectMaxObjectSizeLimit;
 
   /** Prolog rule state. */
   private volatile PrologMachineCopy rulesMachine;
@@ -114,17 +115,18 @@
 
   @Inject
   public ProjectState(
-      final SitePaths sitePaths,
-      final ProjectCache projectCache,
-      final AllProjectsName allProjectsName,
-      final AllUsersName allUsersName,
-      final ProjectControl.AssistedFactory projectControlFactory,
-      final PrologEnvironment.Factory envFactory,
-      final GitRepositoryManager gitMgr,
-      final RulesCache rulesCache,
-      final List<CommentLinkInfo> commentLinks,
-      final CapabilityCollection.Factory capabilityFactory,
-      @Assisted final ProjectConfig config) {
+      SitePaths sitePaths,
+      ProjectCache projectCache,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
+      ProjectControl.AssistedFactory projectControlFactory,
+      PrologEnvironment.Factory envFactory,
+      GitRepositoryManager gitMgr,
+      RulesCache rulesCache,
+      List<CommentLinkInfo> commentLinks,
+      CapabilityCollection.Factory capabilityFactory,
+      TransferConfig transferConfig,
+      @Assisted ProjectConfig config) {
     this.sitePaths = sitePaths;
     this.projectCache = projectCache;
     this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
@@ -137,9 +139,12 @@
     this.commentLinks = commentLinks;
     this.config = config;
     this.configs = new HashMap<>();
-    this.capabilities = isAllProjects
-      ? capabilityFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
-      : null;
+    this.capabilities =
+        isAllProjects
+            ? capabilityFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
+            : null;
+    this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
+    this.inheritProjectMaxObjectSizeLimit = transferConfig.getInheritProjectMaxObjectSizeLimit();
 
     if (isAllProjects && !Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)) {
       localOwners = Collections.emptySet();
@@ -189,9 +194,8 @@
   }
 
   /**
-   * @return cached computation of all global capabilities. This should only be
-   *         invoked on the state from {@link ProjectCache#getAllProjects()}.
-   *         Null on any other project.
+   * @return cached computation of all global capabilities. This should only be invoked on the state
+   *     from {@link ProjectCache#getAllProjects()}. Null on any other project.
    */
   public CapabilityCollection getCapabilityCollection() {
     return capabilities;
@@ -201,24 +205,21 @@
   public PrologEnvironment newPrologEnvironment() throws CompileException {
     PrologMachineCopy pmc = rulesMachine;
     if (pmc == null) {
-      pmc = rulesCache.loadMachine(
-          getProject().getNameKey(),
-          config.getRulesId());
+      pmc = rulesCache.loadMachine(getProject().getNameKey(), config.getRulesId());
       rulesMachine = pmc;
     }
     return envFactory.create(pmc);
   }
 
   /**
-   * Like {@link #newPrologEnvironment()} but instead of reading the rules.pl
-   * read the provided input stream.
+   * Like {@link #newPrologEnvironment()} but instead of reading the rules.pl read the provided
+   * input stream.
    *
    * @param name a name of the input stream. Could be any name.
    * @param in stream to read prolog rules from
    * @throws CompileException
    */
-  public PrologEnvironment newPrologEnvironment(String name, Reader in)
-      throws CompileException {
+  public PrologEnvironment newPrologEnvironment(String name, Reader in) throws CompileException {
     PrologMachineCopy pmc = rulesCache.loadMachine(name, in);
     return envFactory.create(pmc);
   }
@@ -247,8 +248,58 @@
     return cfg;
   }
 
-  public long getMaxObjectSizeLimit() {
-    return config.getMaxObjectSizeLimit();
+  public static class EffectiveMaxObjectSizeLimit {
+    public long value;
+    public String summary;
+  }
+
+  private static final String MAY_NOT_SET = "This project may not set a higher limit.";
+
+  @VisibleForTesting
+  public static final String INHERITED_FROM_PARENT = "Inherited from parent project '%s'.";
+
+  @VisibleForTesting
+  public static final String OVERRIDDEN_BY_PARENT =
+      "Overridden by parent project '%s'. " + MAY_NOT_SET;
+
+  @VisibleForTesting
+  public static final String INHERITED_FROM_GLOBAL = "Inherited from the global config.";
+
+  @VisibleForTesting
+  public static final String OVERRIDDEN_BY_GLOBAL =
+      "Overridden by the global config. " + MAY_NOT_SET;
+
+  public EffectiveMaxObjectSizeLimit getEffectiveMaxObjectSizeLimit() {
+    EffectiveMaxObjectSizeLimit result = new EffectiveMaxObjectSizeLimit();
+
+    result.value = config.getMaxObjectSizeLimit();
+
+    if (inheritProjectMaxObjectSizeLimit) {
+      for (ProjectState parent : parents()) {
+        long parentValue = parent.config.getMaxObjectSizeLimit();
+        if (parentValue > 0 && result.value > 0) {
+          if (parentValue < result.value) {
+            result.value = parentValue;
+            result.summary = String.format(OVERRIDDEN_BY_PARENT, parent.config.getName());
+          }
+        } else if (parentValue > 0) {
+          result.value = parentValue;
+          result.summary = String.format(INHERITED_FROM_PARENT, parent.config.getName());
+        }
+      }
+    }
+
+    if (globalMaxObjectSizeLimit > 0 && result.value > 0) {
+      if (globalMaxObjectSizeLimit < result.value) {
+        result.value = globalMaxObjectSizeLimit;
+        result.summary = OVERRIDDEN_BY_GLOBAL;
+      }
+    } else if (globalMaxObjectSizeLimit > result.value) {
+      // zero means "no limit", in this case the max is more limiting
+      result.value = globalMaxObjectSizeLimit;
+      result.summary = INHERITED_FROM_GLOBAL;
+    }
+    return result;
   }
 
   /** Get the sections that pertain only to this project. */
@@ -259,8 +310,7 @@
       sm = new ArrayList<>(fromConfig.size());
       for (AccessSection section : fromConfig) {
         if (isAllProjects) {
-          List<Permission> copy =
-              Lists.newArrayListWithCapacity(section.getPermissions().size());
+          List<Permission> copy = Lists.newArrayListWithCapacity(section.getPermissions().size());
           for (Permission p : section.getPermissions()) {
             if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
               copy.add(p);
@@ -270,8 +320,7 @@
           section.setPermissions(copy);
         }
 
-        SectionMatcher matcher = SectionMatcher.wrap(getProject().getNameKey(),
-            section);
+        SectionMatcher matcher = SectionMatcher.wrap(getProject().getNameKey(), section);
         if (matcher != null) {
           sm.add(matcher);
         }
@@ -282,9 +331,8 @@
   }
 
   /**
-   * Obtain all local and inherited sections. This collection is looked up
-   * dynamically and is not cached. Callers should try to cache this result
-   * per-request as much as possible.
+   * Obtain all local and inherited sections. This collection is looked up dynamically and is not
+   * cached. Callers should try to cache this result per-request as much as possible.
    */
   List<SectionMatcher> getAllSections() {
     if (isAllProjects) {
@@ -299,10 +347,9 @@
   }
 
   /**
-   * @return all {@link AccountGroup}'s to which the owner privilege for
-   *         'refs/*' is assigned for this project (the local owners), if there
-   *         are no local owners the local owners of the nearest parent project
-   *         that has local owners are returned
+   * @return all {@link AccountGroup}'s to which the owner privilege for 'refs/*' is assigned for
+   *     this project (the local owners), if there are no local owners the local owners of the
+   *     nearest parent project that has local owners are returned
    */
   public Set<AccountGroup.UUID> getOwners() {
     for (ProjectState p : tree()) {
@@ -314,11 +361,10 @@
   }
 
   /**
-   * @return all {@link AccountGroup}'s that are allowed to administrate the
-   *         complete project. This includes all groups to which the owner
-   *         privilege for 'refs/*' is assigned for this project (the local
-   *         owners) and all groups to which the owner privilege for 'refs/*' is
-   *         assigned for one of the parent projects (the inherited owners).
+   * @return all {@link AccountGroup}'s that are allowed to administrate the complete project. This
+   *     includes all groups to which the owner privilege for 'refs/*' is assigned for this project
+   *     (the local owners) and all groups to which the owner privilege for 'refs/*' is assigned for
+   *     one of the parent projects (the inherited owners).
    */
   public Set<AccountGroup.UUID> getAllOwners() {
     Set<AccountGroup.UUID> result = new HashSet<>();
@@ -335,24 +381,21 @@
   }
 
   /**
-   * @return an iterable that walks through this project and then the parents of
-   *         this project. Starts from this project and progresses up the
-   *         hierarchy to All-Projects.
+   * @return an iterable that walks through this project and then the parents of this project.
+   *     Starts from this project and progresses up the hierarchy to All-Projects.
    */
   public Iterable<ProjectState> tree() {
     return new Iterable<ProjectState>() {
       @Override
       public Iterator<ProjectState> iterator() {
-        return new ProjectHierarchyIterator(
-            projectCache, allProjectsName,
-            ProjectState.this);
+        return new ProjectHierarchyIterator(projectCache, allProjectsName, ProjectState.this);
       }
     };
   }
 
   /**
-   * @return an iterable that walks in-order from All-Projects through the
-   *     project hierarchy to this project.
+   * @return an iterable that walks in-order from All-Projects through the project hierarchy to this
+   *     project.
    */
   public Iterable<ProjectState> treeInOrder() {
     List<ProjectState> projects = Lists.newArrayList(tree());
@@ -361,12 +404,11 @@
   }
 
   /**
-   * @return an iterable that walks through the parents of this project. Starts
-   *         from the immediate parent of this project and progresses up the
-   *         hierarchy to All-Projects.
+   * @return an iterable that walks through the parents of this project. Starts from the immediate
+   *     parent of this project and progresses up the hierarchy to All-Projects.
    */
-  public Iterable<ProjectState> parents() {
-    return Iterables.skip(tree(), 1);
+  public FluentIterable<ProjectState> parents() {
+    return FluentIterable.from(tree()).skip(1);
   }
 
   public boolean isAllProjects() {
@@ -378,75 +420,35 @@
   }
 
   public boolean isUseContributorAgreements() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getUseContributorAgreements();
-      }
-    });
+    return getInheritableBoolean(Project::getUseContributorAgreements);
   }
 
   public boolean isUseContentMerge() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getUseContentMerge();
-      }
-    });
+    return getInheritableBoolean(Project::getUseContentMerge);
   }
 
   public boolean isUseSignedOffBy() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getUseSignedOffBy();
-      }
-    });
+    return getInheritableBoolean(Project::getUseSignedOffBy);
   }
 
   public boolean isRequireChangeID() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getRequireChangeID();
-      }
-    });
+    return getInheritableBoolean(Project::getRequireChangeID);
   }
 
   public boolean isCreateNewChangeForAllNotInTarget() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getCreateNewChangeForAllNotInTarget();
-      }
-    });
+    return getInheritableBoolean(Project::getCreateNewChangeForAllNotInTarget);
   }
 
   public boolean isEnableSignedPush() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getEnableSignedPush();
-      }
-    });
+    return getInheritableBoolean(Project::getEnableSignedPush);
   }
 
   public boolean isRequireSignedPush() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getRequireSignedPush();
-      }
-    });
+    return getInheritableBoolean(Project::getRequireSignedPush);
   }
 
   public boolean isRejectImplicitMerges() {
-    return getInheritableBoolean(new Function<Project, InheritableBoolean>() {
-      @Override
-      public InheritableBoolean apply(Project input) {
-        return input.getRejectImplicitMerges();
-      }
-    });
+    return getInheritableBoolean(Project::getRejectImplicitMerges);
   }
 
   public LabelTypes getLabelTypes() {
@@ -501,8 +503,7 @@
     return null;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(
-      Branch.NameKey branch) {
+  public Collection<SubscribeSection> getSubscribeSections(Branch.NameKey branch) {
     Collection<SubscribeSection> ret = new ArrayList<>();
     for (ProjectState s : tree()) {
       ret.addAll(s.getConfig().getSubscribeSections(branch));
@@ -538,7 +539,8 @@
       return ThemeInfo.INHERIT;
     }
     try {
-      return new ThemeInfo(readFile(dir.resolve(SitePaths.CSS_FILENAME)),
+      return new ThemeInfo(
+          readFile(dir.resolve(SitePaths.CSS_FILENAME)),
           readFile(dir.resolve(SitePaths.HEADER_FILENAME)),
           readFile(dir.resolve(SitePaths.FOOTER_FILENAME)));
     } catch (IOException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
index 51603fc..dcb3404 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
@@ -28,15 +28,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.Constants;
 
-import java.io.IOException;
-
 @Singleton
-public class ProjectsCollection implements
-    RestCollection<TopLevelResource, ProjectResource>,
-    AcceptsCreate<TopLevelResource> {
+public class ProjectsCollection
+    implements RestCollection<TopLevelResource, ProjectResource>, AcceptsCreate<TopLevelResource> {
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<ListProjects> list;
   private final ProjectControl.GenericFactory controlFactory;
@@ -44,10 +41,12 @@
   private final CreateProject.Factory createProjectFactory;
 
   @Inject
-  ProjectsCollection(DynamicMap<RestView<ProjectResource>> views,
+  ProjectsCollection(
+      DynamicMap<RestView<ProjectResource>> views,
       Provider<ListProjects> list,
       ProjectControl.GenericFactory controlFactory,
-      CreateProject.Factory factory, Provider<CurrentUser> user) {
+      CreateProject.Factory factory,
+      Provider<CurrentUser> user) {
     this.views = views;
     this.list = list;
     this.controlFactory = controlFactory;
@@ -75,12 +74,11 @@
    *
    * @param id ID of the project, can be a project name
    * @return the project
-   * @throws UnprocessableEntityException thrown if the project ID cannot be
-   *         resolved or if the project is not visible to the calling user
+   * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
+   *     project is not visible to the calling user
    * @throws IOException thrown when there is an error.
    */
-  public ProjectResource parse(String id)
-      throws UnprocessableEntityException, IOException {
+  public ProjectResource parse(String id) throws UnprocessableEntityException, IOException {
     return parse(id, true);
   }
 
@@ -88,34 +86,28 @@
    * Parses a project ID from a request body and returns the project.
    *
    * @param id ID of the project, can be a project name
-   * @param checkVisibility Whether to check or not that project is visible to
-   *        the calling user
+   * @param checkVisibility Whether to check or not that project is visible to the calling user
    * @return the project
-   * @throws UnprocessableEntityException thrown if the project ID cannot be
-   *         resolved or if the project is not visible to the calling user and
-   *         checkVisibility is true.
+   * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
+   *     project is not visible to the calling user and checkVisibility is true.
    * @throws IOException thrown when there is an error.
    */
   public ProjectResource parse(String id, boolean checkVisibility)
       throws UnprocessableEntityException, IOException {
     ProjectResource rsrc = _parse(id, checkVisibility);
     if (rsrc == null) {
-      throw new UnprocessableEntityException(String.format(
-          "Project Not Found: %s", id));
+      throw new UnprocessableEntityException(String.format("Project Not Found: %s", id));
     }
     return rsrc;
   }
 
-  private ProjectResource _parse(String id, boolean checkVisibility)
-      throws IOException {
+  private ProjectResource _parse(String id, boolean checkVisibility) throws IOException {
     if (id.endsWith(Constants.DOT_GIT_EXT)) {
       id = id.substring(0, id.length() - Constants.DOT_GIT_EXT.length());
     }
     ProjectControl ctl;
     try {
-      ctl = controlFactory.controlFor(
-          new Project.NameKey(id),
-          user.get());
+      ctl = controlFactory.controlFor(new Project.NameKey(id), user.get());
     } catch (NoSuchProjectException e) {
       return null;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
index e06fb86..9e0db6e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -23,9 +24,8 @@
 public class PutBranch implements RestModifyView<BranchResource, BranchInput> {
 
   @Override
-  public Object apply(BranchResource rsrc, BranchInput input)
-      throws ResourceConflictException {
-    throw new ResourceConflictException("Branch \"" + rsrc.getBranchInfo().ref
-        + "\" already exists");
+  public BranchInfo apply(BranchResource rsrc, BranchInput input) throws ResourceConflictException {
+    throw new ResourceConflictException(
+        "Branch \"" + rsrc.getBranchInfo().ref + "\" already exists");
   }
 }
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 19b5b26..5521316 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
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
@@ -34,35 +33,32 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.TransferConfig;
 import com.google.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.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class PutConfig implements RestModifyView<ProjectResource, ConfigInput> {
   private static final Logger log = LoggerFactory.getLogger(PutConfig.class);
+  private static final Pattern PARAMETER_NAME_PATTERN =
+      Pattern.compile("^[a-zA-Z0-9]+[a-zA-Z0-9-]*$");
 
   private final boolean serverEnableSignedPush;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
-  private final GitRepositoryManager gitMgr;
   private final ProjectState.Factory projectStateFactory;
-  private final TransferConfig config;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
@@ -70,12 +66,11 @@
   private final Provider<CurrentUser> user;
 
   @Inject
-  PutConfig(@EnableSignedPush boolean serverEnableSignedPush,
+  PutConfig(
+      @EnableSignedPush boolean serverEnableSignedPush,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
-      GitRepositoryManager gitMgr,
       ProjectState.Factory projectStateFactory,
-      TransferConfig config,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
@@ -84,9 +79,7 @@
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
-    this.gitMgr = gitMgr;
     this.projectStateFactory = projectStateFactory;
-    this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
@@ -96,8 +89,7 @@
 
   @Override
   public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException,
-      ResourceConflictException {
+      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
     if (!rsrc.getControl().isOwner()) {
       throw new ResourceNotFoundException(rsrc.getName());
     }
@@ -105,8 +97,7 @@
   }
 
   public ConfigInfo apply(ProjectControl ctrl, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException,
-      ResourceConflictException {
+      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
     Project.NameKey projectName = ctrl.getProject().getNameKey();
     if (input == null) {
       throw new BadRequestException("config is required");
@@ -162,29 +153,31 @@
       }
 
       if (input.pluginConfigValues != null) {
-        setPluginConfigValues(ctrl.getProjectState(),
-            projectConfig, input.pluginConfigValues);
+        setPluginConfigValues(ctrl.getProjectState(), projectConfig, input.pluginConfigValues);
       }
 
       md.setMessage("Modified project settings\n");
       try {
         projectConfig.commit(md);
         projectCache.evict(projectConfig.getProject());
-        gitMgr.setProjectDescription(projectName, p.getDescription());
+        md.getRepository().setGitwebDescription(p.getDescription());
       } catch (IOException e) {
         if (e.getCause() instanceof ConfigInvalidException) {
-          throw new ResourceConflictException("Cannot update " + projectName
-              + ": " + e.getCause().getMessage());
+          throw new ResourceConflictException(
+              "Cannot update " + projectName + ": " + e.getCause().getMessage());
         }
-        log.warn(String.format("Failed to update config of project %s.",
-            projectName), e);
-        throw new ResourceConflictException("Cannot update " + projectName);
+        log.warn("Failed to update config of project {}.", projectName, e);
+        throw new ResourceConflictException("Cannot update " + projectName, e);
       }
 
-      ProjectState state = projectStateFactory.create(projectConfig);
-      return new ConfigInfoImpl(serverEnableSignedPush,
-          state.controlFor(user.get()), config, pluginConfigEntries,
-          cfgFactory, allProjects, views);
+      ProjectState state = projectStateFactory.create(ProjectConfig.read(md));
+      return new ConfigInfoImpl(
+          serverEnableSignedPush,
+          state.controlFor(user.get()),
+          pluginConfigEntries,
+          cfgFactory,
+          allProjects,
+          views);
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(projectName.get());
     } catch (ConfigInvalidException err) {
@@ -194,19 +187,23 @@
     }
   }
 
-  private void setPluginConfigValues(ProjectState projectState,
-      ProjectConfig projectConfig, Map<String, Map<String, ConfigValue>> pluginConfigValues)
+  private void setPluginConfigValues(
+      ProjectState projectState,
+      ProjectConfig projectConfig,
+      Map<String, Map<String, ConfigValue>> pluginConfigValues)
       throws BadRequestException {
     for (Entry<String, Map<String, ConfigValue>> e : pluginConfigValues.entrySet()) {
       String pluginName = e.getKey();
       PluginConfig cfg = projectConfig.getPluginConfig(pluginName);
       for (Entry<String, ConfigValue> v : e.getValue().entrySet()) {
-        ProjectConfigEntry projectConfigEntry =
-            pluginConfigEntries.get(pluginName, v.getKey());
+        ProjectConfigEntry projectConfigEntry = pluginConfigEntries.get(pluginName, v.getKey());
         if (projectConfigEntry != null) {
-          if (!isValidParameterName(v.getKey())) {
-            log.warn(String.format(
-                "Parameter name '%s' must match '^[a-zA-Z0-9]+[a-zA-Z0-9-]*$'", v.getKey()));
+          if (!PARAMETER_NAME_PATTERN.matcher(v.getKey()).matches()) {
+            // TODO check why we have this restriction
+            log.warn(
+                "Parameter name '{}' must match '{}'",
+                v.getKey(),
+                PARAMETER_NAME_PATTERN.pattern());
             continue;
           }
           String oldValue = cfg.getString(v.getKey());
@@ -218,8 +215,8 @@
           }
           if (Strings.emptyToNull(value) != null) {
             if (!value.equals(oldValue)) {
-              validateProjectConfigEntryIsEditable(projectConfigEntry,
-                  projectState, v.getKey(), pluginName);
+              validateProjectConfigEntryIsEditable(
+                  projectConfigEntry, projectState, v.getKey(), pluginName);
               v.setValue(projectConfigEntry.preUpdate(v.getValue()));
               value = v.getValue().value;
               try {
@@ -238,11 +235,15 @@
                     break;
                   case LIST:
                     if (!projectConfigEntry.getPermittedValues().contains(value)) {
-                      throw new BadRequestException(String.format(
-                          "The value '%s' is not permitted for parameter '%s' of plugin '"
-                              + pluginName + "'", value, v.getKey()));
+                      throw new BadRequestException(
+                          String.format(
+                              "The value '%s' is not permitted for parameter '%s' of plugin '"
+                                  + pluginName
+                                  + "'",
+                              value,
+                              v.getKey()));
                     }
-                    //$FALL-THROUGH$
+                    // $FALL-THROUGH$
                   case STRING:
                     cfg.setString(v.getKey(), value);
                     break;
@@ -250,45 +251,46 @@
                     cfg.setStringList(v.getKey(), v.getValue().values);
                     break;
                   default:
-                    log.warn(String.format(
-                        "The type '%s' of parameter '%s' is not supported.",
-                        projectConfigEntry.getType().name(), v.getKey()));
+                    log.warn(
+                        "The type '{}' of parameter '{}' is not supported.",
+                        projectConfigEntry.getType().name(),
+                        v.getKey());
                 }
               } catch (NumberFormatException ex) {
-                throw new BadRequestException(String.format(
-                    "The value '%s' of config parameter '%s' of plugin '%s' is invalid: %s",
-                    v.getValue(), v.getKey(), pluginName, ex.getMessage()));
+                throw new BadRequestException(
+                    String.format(
+                        "The value '%s' of config parameter '%s' of plugin '%s' is invalid: %s",
+                        v.getValue(), v.getKey(), pluginName, ex.getMessage()));
               }
             }
           } else {
             if (oldValue != null) {
-              validateProjectConfigEntryIsEditable(projectConfigEntry,
-                  projectState, v.getKey(), pluginName);
+              validateProjectConfigEntryIsEditable(
+                  projectConfigEntry, projectState, v.getKey(), pluginName);
               cfg.unset(v.getKey());
             }
           }
         } else {
-          throw new BadRequestException(String.format(
-              "The config parameter '%s' of plugin '%s' does not exist.",
-              v.getKey(), pluginName));
+          throw new BadRequestException(
+              String.format(
+                  "The config parameter '%s' of plugin '%s' does not exist.",
+                  v.getKey(), pluginName));
         }
       }
     }
   }
 
   private static void validateProjectConfigEntryIsEditable(
-      ProjectConfigEntry projectConfigEntry, ProjectState projectState,
-      String parameterName, String pluginName) throws BadRequestException {
+      ProjectConfigEntry projectConfigEntry,
+      ProjectState projectState,
+      String parameterName,
+      String pluginName)
+      throws BadRequestException {
     if (!projectConfigEntry.isEditable(projectState)) {
-      throw new BadRequestException(String.format(
-          "Not allowed to set parameter '%s' of plugin '%s' on project '%s'.",
-          parameterName, pluginName, projectState.getProject().getName()));
+      throw new BadRequestException(
+          String.format(
+              "Not allowed to set parameter '%s' of plugin '%s' on project '%s'.",
+              parameterName, pluginName, projectState.getProject().getName()));
     }
   }
-
-  private static boolean isValidParameterName(String name) {
-    return CharMatcher.javaLetterOrDigit()
-        .or(CharMatcher.is('-'))
-        .matchesAllOf(name) && !name.startsWith("-");
-  }
 }
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 17401fe..78230bd 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
@@ -24,36 +24,28 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
-import java.io.IOException;
-
 @Singleton
 public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
-  private final GitRepositoryManager gitMgr;
 
   @Inject
-  PutDescription(ProjectCache cache,
-      MetaDataUpdate.Server updateFactory,
-      GitRepositoryManager gitMgr) {
+  PutDescription(ProjectCache cache, MetaDataUpdate.Server updateFactory) {
     this.cache = cache;
     this.updateFactory = updateFactory;
-    this.gitMgr = gitMgr;
   }
 
   @Override
-  public Response<String> apply(ProjectResource resource,
-      DescriptionInput input) throws AuthException,
-      ResourceConflictException, ResourceNotFoundException, IOException {
+  public Response<String> apply(ProjectResource resource, DescriptionInput input)
+      throws AuthException, ResourceConflictException, ResourceNotFoundException, IOException {
     if (input == null) {
       input = new DescriptionInput(); // Delete would set description to null.
     }
@@ -69,9 +61,9 @@
       Project project = config.getProject();
       project.setDescription(Strings.emptyToNull(input.description));
 
-      String msg = MoreObjects.firstNonNull(
-        Strings.emptyToNull(input.commitMessage),
-        "Updated description.\n");
+      String msg =
+          MoreObjects.firstNonNull(
+              Strings.emptyToNull(input.commitMessage), "Updated description.\n");
       if (!msg.endsWith("\n")) {
         msg += "\n";
       }
@@ -79,9 +71,7 @@
       md.setMessage(msg);
       config.commit(md);
       cache.evict(ctl.getProject());
-      gitMgr.setProjectDescription(
-          resource.getNameKey(),
-          project.getDescription());
+      md.getRepository().setGitwebDescription(project.getDescription());
 
       return Strings.isNullOrEmpty(project.getDescription())
           ? Response.<String>none()
@@ -89,8 +79,8 @@
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(resource.getName());
     } catch (ConfigInvalidException e) {
-      throw new ResourceConflictException(String.format(
-          "invalid project.config: %s", e.getMessage()));
+      throw new ResourceConflictException(
+          String.format("invalid project.config: %s", e.getMessage()));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutProject.java
index fc397e1..1d2384f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutProject.java
@@ -25,7 +25,6 @@
   @Override
   public Response<?> apply(ProjectResource resource, ProjectInput input)
       throws ResourceConflictException {
-    throw new ResourceConflictException("Project \"" + resource.getName()
-        + "\" already exists");
+    throw new ResourceConflictException("Project \"" + resource.getName() + "\" already exists");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
index a87882e..b8a8f6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutTag.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -21,9 +22,7 @@
 public class PutTag implements RestModifyView<TagResource, TagInput> {
 
   @Override
-  public Object apply(TagResource resource, TagInput input)
-      throws ResourceConflictException {
-    throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref
-        + "\" already exists");
+  public TagInfo apply(TagResource resource, TagInput input) throws ResourceConflictException {
+    throw new ResourceConflictException("Tag \"" + resource.getTagInfo().ref + "\" already exists");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index ad41522..ada1855 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -22,7 +22,14 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -34,16 +41,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-
 /** Manages access control for Git references (aka branches, tags). */
 public class RefControl {
   private static final Logger log = LoggerFactory.getLogger(RefControl.class);
@@ -62,8 +59,7 @@
   private Boolean canForgeCommitter;
   private Boolean isVisible;
 
-  RefControl(ProjectControl projectControl, String ref,
-      PermissionCollection relevant) {
+  RefControl(ProjectControl projectControl, String ref, PermissionCollection relevant) {
     this.projectControl = projectControl;
     this.refName = ref;
     this.relevant = relevant;
@@ -106,16 +102,12 @@
   /** Can this user see this reference exists? */
   public boolean isVisible() {
     if (isVisible == null) {
-      isVisible =
-          (getUser().isInternalUser() || canPerform(Permission.READ))
-              && canRead();
+      isVisible = (getUser().isInternalUser() || canPerform(Permission.READ)) && canRead();
     }
     return isVisible;
   }
 
-  /**
-   * True if this reference is visible by all REGISTERED_USERS
-   */
+  /** True if this reference is visible by all REGISTERED_USERS */
   public boolean isVisibleByRegisteredUsers() {
     List<PermissionRule> access = relevant.getPermission(Permission.READ);
     List<PermissionRule> overridden = relevant.getOverridden(Permission.READ);
@@ -138,40 +130,38 @@
   }
 
   /**
-   * Determines whether the user can upload a change to the ref controlled by
-   * this object.
+   * Determines whether the user can upload a change to the ref controlled by this object.
    *
-   * @return {@code true} if the user specified can upload a change to the Git
-   *         ref
+   * @return {@code true} if the user specified can upload a change to the Git ref
    */
   public boolean canUpload() {
-    return projectControl.controlForRef("refs/for/" + getRefName())
-        .canPerform(Permission.PUSH)
+    return projectControl.controlForRef("refs/for/" + getRefName()).canPerform(Permission.PUSH)
         && canWrite();
   }
 
   /** @return true if this user can add a new patch set to this ref */
   public boolean canAddPatchSet() {
-    return projectControl.controlForRef("refs/for/" + getRefName())
-        .canPerform(Permission.ADD_PATCH_SET)
+    return projectControl
+            .controlForRef("refs/for/" + getRefName())
+            .canPerform(Permission.ADD_PATCH_SET)
         && canWrite();
   }
 
   /** @return true if this user can submit merge patch sets to this ref */
   public boolean canUploadMerges() {
-    return projectControl.controlForRef("refs/for/" + getRefName())
-        .canPerform(Permission.PUSH_MERGE)
+    return projectControl
+            .controlForRef("refs/for/" + getRefName())
+            .canPerform(Permission.PUSH_MERGE)
         && canWrite();
   }
 
   /** @return true if this user can rebase changes on this ref */
   public boolean canRebase() {
-    return canPerform(Permission.REBASE)
-        && canWrite();
+    return canPerform(Permission.REBASE) && canWrite();
   }
 
   /** @return true if this user can submit patch sets to this ref */
-  public boolean canSubmit() {
+  public boolean canSubmit(boolean isChangeOwner) {
     if (RefNames.REFS_CONFIG.equals(refName)) {
       // Always allow project owners to submit configuration changes.
       // Submitting configuration changes modifies the access control
@@ -180,8 +170,7 @@
       // granting of powers beyond submitting to the configuration.
       return projectControl.isOwner();
     }
-    return canPerform(Permission.SUBMIT)
-        && canWrite();
+    return canPerform(Permission.SUBMIT, isChangeOwner) && canWrite();
   }
 
   /** @return true if this user was granted submitAs to this ref */
@@ -191,8 +180,7 @@
 
   /** @return true if the user can update the reference as a fast-forward. */
   public boolean canUpdate() {
-    if (RefNames.REFS_CONFIG.equals(refName)
-        && !projectControl.isOwner()) {
+    if (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner()) {
       // Pushing requires being at least project owner, in addition to push.
       // Pushing configuration changes modifies the access control
       // rules. Allowing this to be done by a non-project-owner opens
@@ -202,33 +190,49 @@
       // On the AllProjects project the owner access right cannot be assigned,
       // this why for the AllProjects project we allow administrators to push
       // configuration changes if they have push without being project owner.
-      if (!(projectControl.getProjectState().isAllProjects() &&
-          getUser().getCapabilities().canAdministrateServer())) {
+      if (!(projectControl.getProjectState().isAllProjects()
+          && getUser().getCapabilities().canAdministrateServer())) {
         return false;
       }
     }
-    return canPerform(Permission.PUSH)
-        && canWrite();
+    return canPerform(Permission.PUSH) && canWrite();
   }
 
   /** @return true if the user can rewind (force push) the reference. */
   public boolean canForceUpdate() {
-    return (canPushWithForce() || canDelete()) && canWrite();
+    if (!canWrite()) {
+      return false;
+    }
+
+    if (canPushWithForce()) {
+      return true;
+    }
+
+    switch (getUser().getAccessPath()) {
+      case GIT:
+        return false;
+
+      case JSON_RPC:
+      case REST_API:
+      case SSH_COMMAND:
+      case UNKNOWN:
+      case WEB_BROWSER:
+      default:
+        return getUser().getCapabilities().canAdministrateServer()
+            || (isOwner() && !isForceBlocked(Permission.PUSH));
+    }
   }
 
   public boolean canWrite() {
-    return getProjectControl().getProject().getState().equals(
-        ProjectState.ACTIVE);
+    return getProjectControl().getProject().getState().equals(ProjectState.ACTIVE);
   }
 
   public boolean canRead() {
-    return getProjectControl().getProject().getState().equals(
-        ProjectState.READ_ONLY) || canWrite();
+    return getProjectControl().getProject().getState().equals(ProjectState.READ_ONLY) || canWrite();
   }
 
   private boolean canPushWithForce() {
-    if (!canWrite() || (RefNames.REFS_CONFIG.equals(refName)
-        && !projectControl.isOwner())) {
+    if (!canWrite() || (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner())) {
       // Pushing requires being at least project owner, in addition to push.
       // Pushing configuration changes modifies the access control
       // rules. Allowing this to be done by a non-project-owner opens
@@ -251,43 +255,13 @@
     if (!canWrite()) {
       return false;
     }
-    boolean owner;
-    boolean admin;
-    switch (getUser().getAccessPath()) {
-      case REST_API:
-      case JSON_RPC:
-      case UNKNOWN:
-        owner = isOwner();
-        admin = getUser().getCapabilities().canAdministrateServer();
-        break;
-
-      case GIT:
-      case SSH_COMMAND:
-      case WEB_BROWSER:
-      default:
-        owner = false;
-        admin = false;
-    }
 
     if (object instanceof RevCommit) {
-      if (admin || (owner && !isBlocked(Permission.CREATE))) {
-        // Admin or project owner; bypass visibility check.
-        return true;
-      } else if (!canPerform(Permission.CREATE)) {
+      if (!canPerform(Permission.CREATE)) {
         // No create permissions.
         return false;
-      } else if (canUpdate()) {
-        // If the user has push permissions, they can create the ref regardless
-        // of whether they are pushing any new objects along with the create.
-        return true;
-      } else if (isMergedIntoBranchOrTag(db, repo, (RevCommit) object)) {
-        // If the user has no push permissions, check whether the object is
-        // merged into a branch or tag readable by this user. If so, they are
-        // not effectively "pushing" more objects, so they can create the ref
-        // even if they don't have push permission.
-        return true;
       }
-      return false;
+      return canCreateCommit(db, repo, (RevCommit) object);
     } else if (object instanceof RevTag) {
       final RevTag tag = (RevTag) object;
       try (RevWalk rw = new RevWalk(repo)) {
@@ -307,7 +281,18 @@
         } else {
           valid = false;
         }
-        if (!valid && !owner && !canForgeCommitter()) {
+        if (!valid && !canForgeCommitter()) {
+          return false;
+        }
+      }
+
+      RevObject tagObject = tag.getObject();
+      if (tagObject instanceof RevCommit) {
+        if (!canCreateCommit(db, repo, (RevCommit) tagObject)) {
+          return false;
+        }
+      } else {
+        if (!canCreate(db, repo, tagObject)) {
           return false;
         }
       }
@@ -316,34 +301,46 @@
       // than if it doesn't have a PGP signature.
       //
       if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
-        return owner || canPerform(Permission.PUSH_SIGNED_TAG);
+        return canPerform(Permission.CREATE_SIGNED_TAG);
       }
-      return owner || canPerform(Permission.PUSH_TAG);
+      return canPerform(Permission.CREATE_TAG);
     } else {
       return false;
     }
   }
 
-  private boolean isMergedIntoBranchOrTag(ReviewDb db, Repository repo,
-      RevCommit commit) {
+  private boolean canCreateCommit(ReviewDb db, Repository repo, RevCommit commit) {
+    if (canUpdate()) {
+      // If the user has push permissions, they can create the ref regardless
+      // of whether they are pushing any new objects along with the create.
+      return true;
+    } else if (isMergedIntoBranchOrTag(db, repo, commit)) {
+      // If the user has no push permissions, check whether the object is
+      // merged into a branch or tag readable by this user. If so, they are
+      // not effectively "pushing" more objects, so they can create the ref
+      // even if they don't have push permission.
+      return true;
+    }
+    return false;
+  }
+
+  private boolean isMergedIntoBranchOrTag(ReviewDb db, Repository repo, RevCommit commit) {
     try (RevWalk rw = new RevWalk(repo)) {
-      List<Ref> refs = new ArrayList<>(
-          repo.getRefDatabase().getRefs(Constants.R_HEADS).values());
+      List<Ref> refs = new ArrayList<>(repo.getRefDatabase().getRefs(Constants.R_HEADS).values());
       refs.addAll(repo.getRefDatabase().getRefs(Constants.R_TAGS).values());
-      return projectControl.isMergedIntoVisibleRef(
-          repo, db, rw, commit, refs);
+      return projectControl.isMergedIntoVisibleRef(repo, db, rw, commit, refs);
     } catch (IOException e) {
-      String msg = String.format(
-          "Cannot verify permissions to commit object %s in repository %s",
-          commit.name(), projectControl.getProject().getNameKey());
+      String msg =
+          String.format(
+              "Cannot verify permissions to commit object %s in repository %s",
+              commit.name(), projectControl.getProject().getNameKey());
       log.error(msg, e);
     }
     return false;
   }
 
   /**
-   * Determines whether the user can delete the Git ref controlled by this
-   * object.
+   * Determines whether the user can delete the Git ref controlled by this object.
    *
    * @return {@code true} if the user specified can delete a Git ref.
    */
@@ -359,7 +356,7 @@
 
     switch (getUser().getAccessPath()) {
       case GIT:
-        return canPushWithForce();
+        return canPushWithForce() || canPerform(Permission.DELETE);
 
       case JSON_RPC:
       case REST_API:
@@ -369,7 +366,8 @@
       default:
         return getUser().getCapabilities().canAdministrateServer()
             || (isOwner() && !isForceBlocked(Permission.PUSH))
-            || canPushWithForce();
+            || canPushWithForce()
+            || canPerform(Permission.DELETE);
     }
   }
 
@@ -400,7 +398,7 @@
   }
 
   /** @return true if this user can remove a reviewer for a change. */
-  public boolean canRemoveReviewer() {
+  boolean canRemoveReviewer() {
     return canPerform(Permission.REMOVE_REVIEWER);
   }
 
@@ -419,6 +417,12 @@
     return canPerform(Permission.DELETE_DRAFTS);
   }
 
+  /** @return true if this user can delete changes. */
+  public boolean canDeleteChanges(boolean isChangeOwner) {
+    return canPerform(Permission.DELETE_CHANGES)
+        || (isChangeOwner && canPerform(Permission.DELETE_OWN_CHANGES, isChangeOwner));
+  }
+
   /** @return true if this user can edit topic names. */
   public boolean canEditTopicName() {
     return canPerform(Permission.EDIT_TOPIC_NAME);
@@ -429,6 +433,10 @@
     return canPerform(Permission.EDIT_HASHTAGS);
   }
 
+  public boolean canEditAssignee() {
+    return canPerform(Permission.EDIT_ASSIGNEE);
+  }
+
   /** @return true if this user can force edit topic names. */
   public boolean canForceEditTopicName() {
     return canForcePerform(Permission.EDIT_TOPIC_NAME);
@@ -487,21 +495,23 @@
     int getAllowMin() {
       return allowMin;
     }
+
     int getAllowMax() {
       return allowMax;
     }
+
     int getBlockMin() {
       // ALLOW wins over BLOCK on the same project
       return Math.min(blockMin, allowMin - 1);
     }
+
     int getBlockMax() {
       // ALLOW wins over BLOCK on the same project
       return Math.max(blockMax, allowMax + 1);
     }
   }
 
-  private PermissionRange toRange(String permissionName,
-      List<PermissionRule> ruleList) {
+  private PermissionRange toRange(String permissionName, List<PermissionRule> ruleList) {
     Map<ProjectRef, AllowedRange> ranges = new HashMap<>();
     for (PermissionRule rule : ruleList) {
       ProjectRef p = relevant.getRuleProps(rule);
@@ -531,16 +541,20 @@
 
   /** True if the user has this permission. Works only for non labels. */
   boolean canPerform(String permissionName) {
-    return doCanPerform(permissionName, false);
+    return canPerform(permissionName, false);
+  }
+
+  boolean canPerform(String permissionName, boolean isChangeOwner) {
+    return doCanPerform(permissionName, isChangeOwner, false);
   }
 
   /** True if the user is blocked from using this permission. */
   public boolean isBlocked(String permissionName) {
-    return !doCanPerform(permissionName, true);
+    return !doCanPerform(permissionName, false, true);
   }
 
-  private boolean doCanPerform(String permissionName, boolean blockOnly) {
-    List<PermissionRule> access = access(permissionName);
+  private boolean doCanPerform(String permissionName, boolean isChangeOwner, boolean blockOnly) {
+    List<PermissionRule> access = access(permissionName, isChangeOwner);
     List<PermissionRule> overridden = relevant.getOverridden(permissionName);
     Set<ProjectRef> allows = new HashSet<>();
     Set<ProjectRef> blocks = new HashSet<>();
@@ -608,8 +622,7 @@
   }
 
   /** Rules for the given permission, or the empty list. */
-  private List<PermissionRule> access(String permissionName,
-      boolean isChangeOwner) {
+  private List<PermissionRule> access(String permissionName, boolean isChangeOwner) {
     List<PermissionRule> rules = effective.get(permissionName);
     if (rules != null) {
       return rules;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefFilter.java
index 63fb595..76bafc0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefFilter.java
@@ -19,10 +19,8 @@
 import com.google.common.collect.FluentIterable;
 import com.google.gerrit.extensions.api.projects.RefInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
-
 import java.util.List;
 import java.util.Locale;
 
@@ -58,6 +56,9 @@
   }
 
   public List<T> filter(List<T> refs) throws BadRequestException {
+    if (!Strings.isNullOrEmpty(matchSubstring) && !Strings.isNullOrEmpty(matchRegex)) {
+      throw new BadRequestException("specify exactly one of m/r");
+    }
     FluentIterable<T> results = FluentIterable.from(refs);
     if (!Strings.isNullOrEmpty(matchSubstring)) {
       results = results.filter(new SubstringPredicate(matchSubstring));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
index ed50a54..72face2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPattern.java
@@ -21,35 +21,33 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.common.errors.InvalidNameException;
-
 import dk.brics.automaton.RegExp;
-
-import org.eclipse.jgit.lib.Repository;
-
 import java.util.concurrent.ExecutionException;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.lib.Repository;
 
 public class RefPattern {
   public static final String USERID_SHARDED = "shardeduserid";
   public static final String USERNAME = "username";
 
-  private static final LoadingCache<String, String> exampleCache = CacheBuilder
-      .newBuilder()
-      .maximumSize(4000)
-      .build(new CacheLoader<String, String>() {
-        @Override
-        public String load(String refPattern) {
-          return example(refPattern);
-        }
-      });
+  private static final LoadingCache<String, String> exampleCache =
+      CacheBuilder.newBuilder()
+          .maximumSize(4000)
+          .build(
+              new CacheLoader<String, String>() {
+                @Override
+                public String load(String refPattern) {
+                  return example(refPattern);
+                }
+              });
 
   public static String shortestExample(String refPattern) {
     if (isRE(refPattern)) {
       try {
         return exampleCache.get(refPattern);
       } catch (ExecutionException e) {
-        Throwables.propagateIfPossible(e.getCause());
+        Throwables.throwIfUnchecked(e.getCause());
         throw new RuntimeException(e);
       }
     } else if (refPattern.endsWith("/*")) {
@@ -65,8 +63,7 @@
     // Repository.isValidRefName() if not combined with star [*].
     // To get around this, we substitute the \0 with an arbitrary
     // accepted character.
-    return toRegExp(refPattern).toAutomaton().getShortestExample(true)
-        .replace('\0', '-');
+    return toRegExp(refPattern).toAutomaton().getShortestExample(true).replace('\0', '-');
   }
 
   public static boolean isRE(String refPattern) {
@@ -80,8 +77,7 @@
     return new RegExp(refPattern, RegExp.NONE);
   }
 
-  public static void validate(String refPattern)
-      throws InvalidNameException {
+  public static void validate(String refPattern) throws InvalidNameException {
     if (refPattern.startsWith(RefConfigSection.REGEX_PREFIX)) {
       if (!Repository.isValidRefName(shortestExample(refPattern))) {
         throw new InvalidNameException(refPattern);
@@ -99,8 +95,7 @@
     validateRegExp(refPattern);
   }
 
-  public static void validateRegExp(String refPattern)
-      throws InvalidNameException {
+  public static void validateRegExp(String refPattern) throws InvalidNameException {
     try {
       refPattern = refPattern.replace("${" + USERID_SHARDED + "}", "");
       refPattern = refPattern.replace("${" + USERNAME + "}", "");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java
index fe87b6b..63da731 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefPatternMatcher.java
@@ -23,9 +23,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.CurrentUser;
-
 import dk.brics.automaton.Automaton;
-
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
@@ -99,11 +97,11 @@
         // allows the pattern prefix to be clipped, saving time on
         // evaluation.
         String replacement = ":PLACEHOLDER:";
-        Map<String, String> params = ImmutableMap.of(
-            RefPattern.USERID_SHARDED, replacement,
-            RefPattern.USERNAME, replacement);
-        Automaton am =
-            RefPattern.toRegExp(template.replace(params)).toAutomaton();
+        Map<String, String> params =
+            ImmutableMap.of(
+                RefPattern.USERID_SHARDED, replacement,
+                RefPattern.USERNAME, replacement);
+        Automaton am = RefPattern.toRegExp(template.replace(params)).toAutomaton();
         String rePrefix = am.getCommonPrefix();
         prefix = rePrefix.substring(0, rePrefix.indexOf(replacement));
       } else {
@@ -125,9 +123,7 @@
           u = username;
         }
 
-        Account.Id accountId = user.isIdentifiedUser()
-            ? user.getAccountId()
-            : null;
+        Account.Id accountId = user.isIdentifiedUser() ? user.getAccountId() : null;
         RefPatternMatcher next = getMatcher(expand(template, u, accountId));
         if (next != null && next.match(expand(ref, u, accountId), user)) {
           return true;
@@ -163,8 +159,8 @@
       return parameterizedRef;
     }
 
-    private String expand(ParameterizedString parameterizedRef, String userName,
-        Account.Id accountId) {
+    private String expand(
+        ParameterizedString parameterizedRef, String userName, Account.Id accountId) {
       Map<String, String> params = new HashMap<>();
       params.put(RefPattern.USERNAME, userName);
       if (accountId != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java
new file mode 100644
index 0000000..124439f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefResource.java
@@ -0,0 +1,28 @@
+// 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;
+
+public abstract class RefResource extends ProjectResource {
+
+  public RefResource(ProjectControl control) {
+    super(control);
+  }
+
+  /** @return the ref's name */
+  public abstract String getRef();
+
+  /** @return the ref's revision */
+  public abstract String getRevision();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java
index 9d8fe10..8a7e5f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefUtil.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.server.project;
 
+import static org.eclipse.jgit.lib.Constants.R_REFS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
+
 import com.google.common.collect.Iterables;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-
+import java.io.IOException;
+import java.util.Collections;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RevisionSyntaxException;
@@ -31,14 +36,11 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Collections;
-
 public class RefUtil {
   private static final Logger log = LoggerFactory.getLogger(RefUtil.class);
 
-  public static ObjectId parseBaseRevision(Repository repo,
-      Project.NameKey projectName, String baseRevision)
+  public static ObjectId parseBaseRevision(
+      Repository repo, Project.NameKey projectName, String baseRevision)
       throws InvalidRevisionException {
     try {
       ObjectId revid = repo.resolve(baseRevision);
@@ -47,8 +49,8 @@
       }
       return revid;
     } catch (IOException err) {
-      log.error("Cannot resolve \"" + baseRevision + "\" in project \""
-          + projectName.get() + "\"", err);
+      log.error(
+          "Cannot resolve \"" + baseRevision + "\" in project \"" + projectName.get() + "\"", err);
       throw new InvalidRevisionException();
     } catch (RevisionSyntaxException err) {
       log.error("Invalid revision syntax \"" + baseRevision + "\"", err);
@@ -66,9 +68,9 @@
         throw new InvalidRevisionException();
       }
       RefDatabase refDb = repo.getRefDatabase();
-      Iterable<Ref> refs = Iterables.concat(
-          refDb.getRefs(Constants.R_HEADS).values(),
-          refDb.getRefs(Constants.R_TAGS).values());
+      Iterable<Ref> refs =
+          Iterables.concat(
+              refDb.getRefs(Constants.R_HEADS).values(), refDb.getRefs(Constants.R_TAGS).values());
       Ref rc = refDb.exactRef(RefNames.REFS_CONFIG);
       if (rc != null) {
         refs = Iterables.concat(refs, Collections.singleton(rc));
@@ -85,8 +87,9 @@
     } catch (IncorrectObjectTypeException | MissingObjectException err) {
       throw new InvalidRevisionException();
     } catch (IOException err) {
-      log.error("Repository \"" + repo.getDirectory()
-          + "\" may be corrupt; suggest running git fsck", err);
+      log.error(
+          "Repository \"" + repo.getDirectory() + "\" may be corrupt; suggest running git fsck",
+          err);
       throw new InvalidRevisionException();
     }
   }
@@ -99,6 +102,23 @@
     return Constants.R_HEADS;
   }
 
+  public static String normalizeTagRef(String tag) throws BadRequestException {
+    String result = tag;
+    while (result.startsWith("/")) {
+      result = result.substring(1);
+    }
+    if (result.startsWith(R_REFS) && !result.startsWith(R_TAGS)) {
+      throw new BadRequestException("invalid tag name \"" + result + "\"");
+    }
+    if (!result.startsWith(R_TAGS)) {
+      result = R_TAGS + result;
+    }
+    if (!Repository.isValidRefName(result)) {
+      throw new BadRequestException("invalid tag name \"" + result + "\"");
+    }
+    return result;
+  }
+
   /** Error indicating the revision is invalid as supplied. */
   static class InvalidRevisionException extends Exception {
     private static final long serialVersionUID = 1L;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java
index 6e2fd5d..0a5980c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand.Type;
 
@@ -34,14 +33,14 @@
   private final Type operationType;
 
   @Inject
-  RefValidationHelper(RefOperationValidators.Factory refValidatorsFactory,
-      @Assisted Type operationType) {
+  RefValidationHelper(
+      RefOperationValidators.Factory refValidatorsFactory, @Assisted Type operationType) {
     this.refValidatorsFactory = refValidatorsFactory;
     this.operationType = operationType;
   }
 
-  public void validateRefOperation(String projectName, IdentifiedUser user,
-      RefUpdate update) throws ResourceConflictException {
+  public void validateRefOperation(String projectName, IdentifiedUser user, RefUpdate update)
+      throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
             new Project(new Project.NameKey(projectName)),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java
index de045b7..3cb4bac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RepositoryStatistics.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.base.CaseFormat;
-
 import java.util.Map.Entry;
 import java.util.Properties;
 import java.util.TreeMap;
@@ -25,8 +24,9 @@
 
   RepositoryStatistics(Properties p) {
     for (Entry<Object, Object> e : p.entrySet()) {
-      put(CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE,
-          e.getKey().toString()), e.getValue());
+      put(
+          CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getKey().toString()),
+          e.getValue());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java
index 478357a..65b17bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -20,9 +20,9 @@
 
 /**
  * Matches an AccessSection against a reference name.
- * <p>
- * These matchers are "compiled" versions of the AccessSection name, supporting
- * faster selection of which sections are relevant to any given input reference.
+ *
+ * <p>These matchers are "compiled" versions of the AccessSection name, supporting faster selection
+ * of which sections are relevant to any given input reference.
  */
 class SectionMatcher extends RefPatternMatcher {
   static SectionMatcher wrap(Project.NameKey project, AccessSection section) {
@@ -37,8 +37,7 @@
   final AccessSection section;
   final RefPatternMatcher matcher;
 
-  SectionMatcher(Project.NameKey project, AccessSection section,
-      RefPatternMatcher matcher) {
+  SectionMatcher(Project.NameKey project, AccessSection section, RefPatternMatcher matcher) {
     this.project = project;
     this.section = section;
     this.matcher = matcher;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionSortCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionSortCache.java
index 7b6b5c8..a02941e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionSortCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SectionSortCache.java
@@ -24,20 +24,17 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.IdentityHashMap;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Caches the order AccessSections should be sorted for evaluation. */
 @Singleton
 public class SectionSortCache {
-  private static final Logger log =
-      LoggerFactory.getLogger(SectionSortCache.class);
+  private static final Logger log = LoggerFactory.getLogger(SectionSortCache.class);
 
   private static final String CACHE_NAME = "permission_sort";
 
@@ -108,8 +105,8 @@
     return sections.toArray(new AccessSection[sections.size()]);
   }
 
-  private static boolean isIdentityTransform(List<AccessSection> sections,
-      IdentityHashMap<AccessSection, Integer> srcMap) {
+  private static boolean isIdentityTransform(
+      List<AccessSection> sections, IdentityHashMap<AccessSection, Integer> srcMap) {
     for (int i = 0; i < sections.size(); i++) {
       if (i != srcMap.get(sections.get(i))) {
         return false;
@@ -121,7 +118,9 @@
   @AutoValue
   abstract static class EntryKey {
     public abstract String ref();
+
     public abstract List<String> patterns();
+
     public abstract int cachedHashCode();
 
     static EntryKey create(String refName, List<AccessSection> sections) {
@@ -132,8 +131,7 @@
         patterns.add(n);
         hc = hc * 31 + n.hashCode();
       }
-      return new AutoValue_SectionSortCache_EntryKey(
-          refName, ImmutableList.copyOf(patterns), hc);
+      return new AutoValue_SectionSortCache_EntryKey(refName, ImmutableList.copyOf(patterns), hc);
     }
 
     @Override
@@ -145,9 +143,9 @@
   static final class EntryVal {
     /**
      * Maps the input index to the output index.
-     * <p>
-     * For {@code x == order[y]} the expression means move the item at
-     * source position {@code x} to the output position {@code y}.
+     *
+     * <p>For {@code x == order[y]} the expression means move the item at source position {@code x}
+     * to the output position {@code y}.
      */
     final int[] order;
 
@@ -155,6 +153,4 @@
       this.order = order;
     }
   }
-
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
index 1e5a7c9..c74efc6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -44,17 +44,15 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
-import java.util.LinkedList;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class SetAccess implements
-    RestModifyView<ProjectResource, ProjectAccessInput> {
+public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
   private final GroupsCollection groupsCollection;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
@@ -65,7 +63,8 @@
   private final Provider<IdentifiedUser> identifiedUser;
 
   @Inject
-  private SetAccess(GroupBackend groupBackend,
+  private SetAccess(
+      GroupBackend groupBackend,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
@@ -84,11 +83,9 @@
   }
 
   @Override
-  public ProjectAccessInfo apply(ProjectResource rsrc,
-      ProjectAccessInput input)
-      throws ResourceNotFoundException, ResourceConflictException,
-      IOException, AuthException, BadRequestException,
-      UnprocessableEntityException{
+  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();
@@ -96,29 +93,27 @@
     ProjectControl projectControl = rsrc.getControl();
     ProjectConfig config;
 
-    Project.NameKey newParentProjectName = input.parent == null ?
-        null : new Project.NameKey(input.parent);
+    Project.NameKey newParentProjectName =
+        input.parent == null ? null : new Project.NameKey(input.parent);
 
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       config = ProjectConfig.read(md);
 
       // Perform removal checks
       for (AccessSection section : removals) {
-        boolean isGlobalCapabilities =
-            AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+        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());
+          throw new AuthException(
+              "You are not allowed to edit permissionsfor ref: " + section.getName());
         }
       }
       // Perform addition checks
       for (AccessSection section : additions) {
         String name = section.getName();
-        boolean isGlobalCapabilities =
-            AccessSection.GLOBAL_CAPABILITIES.equals(name);
+        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
 
         if (isGlobalCapabilities) {
           checkGlobalCapabilityPermissions(config.getName());
@@ -127,18 +122,16 @@
             throw new BadRequestException("invalid section name");
           }
           if (!projectControl.controlForRef(name).isOwner()) {
-            throw new AuthException("You are not allowed to edit permissions"
-                + "for ref: " + name);
+            throw new AuthException("You are not allowed to edit permissionsfor ref: " + name);
           }
           RefPattern.validate(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");
+          if (isGlobalCapabilities && !GlobalCapability.isCapability(p.getName())) {
+            throw new BadRequestException(
+                "Cannot add non-global capability " + p.getName() + " to global capabilities");
           }
         }
       }
@@ -163,16 +156,14 @@
 
       // Apply additions
       for (AccessSection section : additions) {
-        AccessSection currentAccessSection =
-            config.getAccessSection(section.getName());
+        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());
+            Permission currentPermission = currentAccessSection.getPermission(p.getName());
             if (currentPermission == null) {
               // Add Permission
               currentAccessSection.addPermission(p);
@@ -186,14 +177,16 @@
         }
       }
 
-      if (newParentProjectName != null &&
-          !config.getProject().getNameKey().equals(allProjects) &&
-          !config.getProject().getParent(allProjects)
-              .equals(newParentProjectName)) {
+      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);
+          setParent
+              .get()
+              .validateParentUpdate(
+                  projectControl,
+                  MoreObjects.firstNonNull(newParentProjectName, allProjects).get(),
+                  true);
         } catch (UnprocessableEntityException e) {
           throw new ResourceConflictException(e.getMessage(), e);
         }
@@ -220,25 +213,22 @@
     return getAccess.apply(rsrc.getNameKey());
   }
 
-  private List<AccessSection> getAccessSections(
-      Map<String, AccessSectionInfo> sectionInfos)
+  private List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
       throws UnprocessableEntityException {
-    List<AccessSection> sections = new LinkedList<>();
     if (sectionInfos == null) {
-      return sections;
+      return Collections.emptyList();
     }
 
-    for (Map.Entry<String, AccessSectionInfo> entry :
-      sectionInfos.entrySet()) {
+    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
+    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()) {
+      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);
@@ -251,14 +241,12 @@
             permissionEntry.getValue().rules.entrySet()) {
           PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
 
-          GroupDescription.Basic group = groupsCollection
-              .parseId(permissionRuleInfoEntry.getKey());
+          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
           if (group == null) {
             throw new UnprocessableEntityException(
-              permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
           }
-          PermissionRule r = new PermissionRule(
-              GroupReference.forGroup(group));
+          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
           if (pri != null) {
             if (pri.max != null) {
               r.setMax(pri.max);
@@ -281,16 +269,16 @@
   }
 
   private void checkGlobalCapabilityPermissions(Project.NameKey projectName)
-    throws BadRequestException, AuthException {
+      throws BadRequestException, AuthException {
 
     if (!allProjects.equals(projectName)) {
-      throw new BadRequestException("Cannot edit global capabilities "
-        + "for projects other than " + allProjects.get());
+      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);
+      throw new AuthException(
+          "Editing global capabilities requires " + GlobalCapability.ADMINISTRATE_SERVER);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
index cda548a..332ea76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
@@ -20,19 +20,19 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
 import com.google.gerrit.server.project.SetDashboard.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 
 @Singleton
 class SetDashboard implements RestModifyView<DashboardResource, Input> {
   static class Input {
-    @DefaultInput
-    String id;
+    @DefaultInput String id;
     String commitMessage;
   }
 
@@ -44,9 +44,9 @@
   }
 
   @Override
-  public Object apply(DashboardResource resource, Input input)
+  public Response<DashboardInfo> apply(DashboardResource resource, Input input)
       throws AuthException, BadRequestException, ResourceConflictException,
-      MethodNotAllowedException, ResourceNotFoundException, IOException {
+          MethodNotAllowedException, ResourceNotFoundException, IOException {
     if (resource.isProjectDefault()) {
       return defaultSetter.get().apply(resource, input);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
index 641c3a7..be93296 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
@@ -30,13 +30,11 @@
 import com.google.gerrit.server.project.SetDashboard.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-
 class SetDefaultDashboard implements RestModifyView<DashboardResource, Input> {
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
@@ -47,7 +45,8 @@
   private boolean inherited;
 
   @Inject
-  SetDefaultDashboard(ProjectCache cache,
+  SetDefaultDashboard(
+      ProjectCache cache,
       MetaDataUpdate.Server updateFactory,
       DashboardsCollection dashboards,
       Provider<GetDashboard> get) {
@@ -60,7 +59,7 @@
   @Override
   public Response<DashboardInfo> apply(DashboardResource resource, Input input)
       throws AuthException, BadRequestException, ResourceConflictException,
-      ResourceNotFoundException, IOException {
+          ResourceNotFoundException, IOException {
     if (input == null) {
       input = new Input(); // Delete would set input to null.
     }
@@ -74,9 +73,7 @@
     DashboardResource target = null;
     if (input.id != null) {
       try {
-        target = dashboards.parse(
-            new ProjectResource(ctl),
-            IdString.fromUrl(input.id));
+        target = dashboards.parse(new ProjectResource(ctl), IdString.fromUrl(input.id));
       } catch (ResourceNotFoundException e) {
         throw new BadRequestException("dashboard " + input.id + " not found");
       } catch (ConfigInvalidException e) {
@@ -93,11 +90,12 @@
         project.setLocalDefaultDashboard(input.id);
       }
 
-      String msg = MoreObjects.firstNonNull(
-        Strings.emptyToNull(input.commitMessage),
-        input.id == null
-          ? "Removed default dashboard.\n"
-          : String.format("Changed default dashboard to %s.\n", input.id));
+      String msg =
+          MoreObjects.firstNonNull(
+              Strings.emptyToNull(input.commitMessage),
+              input.id == null
+                  ? "Removed default dashboard.\n"
+                  : String.format("Changed default dashboard to %s.\n", input.id));
       if (!msg.endsWith("\n")) {
         msg += "\n";
       }
@@ -115,13 +113,12 @@
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(ctl.getProject().getName());
     } catch (ConfigInvalidException e) {
-      throw new ResourceConflictException(String.format(
-          "invalid project.config: %s", e.getMessage()));
+      throw new ResourceConflictException(
+          String.format("invalid project.config: %s", e.getMessage()));
     }
   }
 
-  static class CreateDefault implements
-      RestModifyView<ProjectResource, SetDashboard.Input> {
+  static class CreateDefault implements RestModifyView<ProjectResource, SetDashboard.Input> {
     private final Provider<SetDefaultDashboard> setDefault;
 
     @Option(name = "--inherited", usage = "set dashboard inherited by children")
@@ -135,11 +132,10 @@
     @Override
     public Response<DashboardInfo> apply(ProjectResource resource, Input input)
         throws AuthException, BadRequestException, ResourceConflictException,
-        ResourceNotFoundException, IOException {
+            ResourceNotFoundException, IOException {
       SetDefaultDashboard set = setDefault.get();
       set.inherited = inherited;
-      return set.apply(
-          DashboardResource.projectDefault(resource.getControl()), input);
+      return set.apply(DashboardResource.projectDefault(resource.getControl()), input);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
index 442447f..6c45bc3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
@@ -32,7 +32,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
@@ -41,16 +42,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Map;
-
 @Singleton
 public class SetHead implements RestModifyView<ProjectResource, Input> {
   private static final Logger log = LoggerFactory.getLogger(SetHead.class);
 
   public static class Input {
-    @DefaultInput
-    public String ref;
+    @DefaultInput public String ref;
   }
 
   private final GitRepositoryManager repoManager;
@@ -58,7 +55,8 @@
   private final DynamicSet<HeadUpdatedListener> headUpdatedListeners;
 
   @Inject
-  SetHead(GitRepositoryManager repoManager,
+  SetHead(
+      GitRepositoryManager repoManager,
       Provider<IdentifiedUser> identifiedUser,
       DynamicSet<HeadUpdatedListener> headUpdatedListeners) {
     this.repoManager = repoManager;
@@ -67,9 +65,9 @@
   }
 
   @Override
-  public String apply(final ProjectResource rsrc, Input input) throws AuthException,
-      ResourceNotFoundException, BadRequestException,
-      UnprocessableEntityException, IOException {
+  public String apply(final ProjectResource rsrc, Input input)
+      throws AuthException, ResourceNotFoundException, BadRequestException,
+          UnprocessableEntityException, IOException {
     if (!rsrc.getControl().isOwner()) {
       throw new AuthException("restricted to project owner");
     }
@@ -79,11 +77,9 @@
     String ref = RefNames.fullName(input.ref);
 
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      Map<String, Ref> cur =
-          repo.getRefDatabase().exactRef(Constants.HEAD, ref);
+      Map<String, Ref> cur = repo.getRefDatabase().exactRef(Constants.HEAD, ref);
       if (!cur.containsKey(ref)) {
-        throw new UnprocessableEntityException(String.format(
-            "Ref Not Found: %s", ref));
+        throw new UnprocessableEntityException(String.format("Ref Not Found: %s", ref));
       }
 
       final String oldHead = cur.get(Constants.HEAD).getTarget().getName();
@@ -130,8 +126,7 @@
     }
   }
 
-  static class Event extends AbstractNoNotifyEvent
-      implements HeadUpdatedListener.Event {
+  static class Event extends AbstractNoNotifyEvent implements HeadUpdatedListener.Event {
     private final Project.NameKey nameKey;
     private final String oldHead;
     private final String newHead;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
index 01aacfb..f8d649b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Predicate;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -34,17 +33,14 @@
 import com.google.gerrit.server.project.SetParent.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
-import java.io.IOException;
-
 @Singleton
 public class SetParent implements RestModifyView<ProjectResource, Input> {
   public static class Input {
-    @DefaultInput
-    public String parent;
+    @DefaultInput public String parent;
     public String commitMessage;
   }
 
@@ -53,27 +49,25 @@
   private final AllProjectsName allProjects;
 
   @Inject
-  SetParent(ProjectCache cache,
-      MetaDataUpdate.Server updateFactory,
-      AllProjectsName allProjects) {
+  SetParent(ProjectCache cache, MetaDataUpdate.Server updateFactory, AllProjectsName allProjects) {
     this.cache = cache;
     this.updateFactory = updateFactory;
     this.allProjects = allProjects;
   }
 
   @Override
-  public String apply(ProjectResource rsrc, Input input) throws AuthException,
-      ResourceConflictException, ResourceNotFoundException,
-      UnprocessableEntityException, IOException {
+  public String apply(ProjectResource rsrc, Input input)
+      throws AuthException, ResourceConflictException, ResourceNotFoundException,
+          UnprocessableEntityException, IOException {
     return apply(rsrc, input, true);
   }
 
   public String apply(ProjectResource rsrc, Input input, boolean checkIfAdmin)
-      throws AuthException, ResourceConflictException,
-      ResourceNotFoundException, UnprocessableEntityException, IOException {
+      throws AuthException, ResourceConflictException, ResourceNotFoundException,
+          UnprocessableEntityException, IOException {
     ProjectControl ctl = rsrc.getControl();
-    String parentName = MoreObjects.firstNonNull(
-        Strings.emptyToNull(input.parent), allProjects.get());
+    String parentName =
+        MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
     validateParentUpdate(ctl, parentName, checkIfAdmin);
     try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
       ProjectConfig config = ProjectConfig.read(md);
@@ -82,8 +76,7 @@
 
       String msg = Strings.emptyToNull(input.commitMessage);
       if (msg == null) {
-        msg = String.format(
-              "Changed parent to %s.\n", parentName);
+        msg = String.format("Changed parent to %s.\n", parentName);
       } else if (!msg.endsWith("\n")) {
         msg += "\n";
       }
@@ -98,42 +91,40 @@
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(rsrc.getName());
     } catch (ConfigInvalidException e) {
-      throw new ResourceConflictException(String.format(
-          "invalid project.config: %s", e.getMessage()));
+      throw new ResourceConflictException(
+          String.format("invalid project.config: %s", e.getMessage()));
     }
   }
 
-  public void validateParentUpdate(final ProjectControl ctl, String newParent,
-      boolean checkIfAdmin) throws AuthException, ResourceConflictException,
-      UnprocessableEntityException {
+  public void validateParentUpdate(final ProjectControl ctl, String newParent, boolean checkIfAdmin)
+      throws AuthException, ResourceConflictException, UnprocessableEntityException {
     IdentifiedUser user = ctl.getUser().asIdentifiedUser();
     if (checkIfAdmin && !user.getCapabilities().canAdministrateServer()) {
       throw new AuthException("not administrator");
     }
 
     if (ctl.getProject().getNameKey().equals(allProjects)) {
-      throw new ResourceConflictException("cannot set parent of "
-          + allProjects.get());
+      throw new ResourceConflictException("cannot set parent of " + allProjects.get());
     }
 
     newParent = Strings.emptyToNull(newParent);
     if (newParent != null) {
       ProjectState parent = cache.get(new Project.NameKey(newParent));
       if (parent == null) {
-        throw new UnprocessableEntityException("parent project " + newParent
-            + " not found");
+        throw new UnprocessableEntityException("parent project " + newParent + " not found");
       }
 
-      if (Iterables.tryFind(parent.tree(), new Predicate<ProjectState>() {
-        @Override
-        public boolean apply(ProjectState input) {
-          return input.getProject().getNameKey()
-              .equals(ctl.getProject().getNameKey());
-        }
-      }).isPresent()) {
-        throw new ResourceConflictException("cycle exists between "
-            + ctl.getProject().getName() + " and "
-            + parent.getProject().getName());
+      if (Iterables.tryFind(
+              parent.tree(),
+              p -> {
+                return p.getProject().getNameKey().equals(ctl.getProject().getNameKey());
+              })
+          .isPresent()) {
+        throw new ResourceConflictException(
+            "cycle exists between "
+                + ctl.getProject().getName()
+                + " and "
+                + parent.getProject().getName());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 5d0f4f1..d535062 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
-
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -39,26 +38,21 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.StringReader;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
- * Evaluates a submit-like Prolog rule found in the rules.pl file of the current
- * project and filters the results through rules found in the parent projects,
- * all the way up to All-Projects.
+ * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
+ * the results through rules found in the parent projects, all the way up to All-Projects.
  */
 public class SubmitRuleEvaluator {
-  private static final Logger log = LoggerFactory
-      .getLogger(SubmitRuleEvaluator.class);
+  private static final Logger log = LoggerFactory.getLogger(SubmitRuleEvaluator.class);
 
-  private static final String DEFAULT_MSG =
-      "Error evaluating project rules, check server log";
+  private static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
 
   public static List<SubmitRecord> defaultRuleError() {
     return createRuleError(DEFAULT_MSG);
@@ -76,27 +70,23 @@
   }
 
   /**
-   * Exception thrown when the label term of a submit record
-   * unexpectedly didn't contain a user term.
+   * Exception thrown when the label term of a submit record unexpectedly didn't contain a user
+   * term.
    */
   private static class UserTermExpected extends Exception {
     private static final long serialVersionUID = 1L;
 
     UserTermExpected(SubmitRecord.Label label) {
-      super(String.format("A label with the status %s must contain a user.",
-          label.toString()));
+      super(String.format("A label with the status %s must contain a user.", label.toString()));
     }
   }
 
   private final ChangeData cd;
   private final ChangeControl control;
 
+  private SubmitRuleOptions.Builder optsBuilder = SubmitRuleOptions.defaults();
+  private SubmitRuleOptions opts;
   private PatchSet patchSet;
-  private boolean fastEvalLabels;
-  private boolean allowDraft;
-  private boolean allowClosed;
-  private boolean skipFilters;
-  private String rule;
   private boolean logErrors = true;
   private long reductionsConsumed;
 
@@ -108,25 +98,51 @@
   }
 
   /**
-   * @param ps patch set of the change to evaluate. If not set, the current
-   * patch set will be loaded from {@link #evaluate()} or {@link
-   * #getSubmitType}.
+   * @return immutable snapshot of options configured so far. If neither {@link #getSubmitRule()}
+   *     nor {@link #getSubmitType()} have been called yet, state within this instance is still
+   *     mutable, so may change before evaluation. The instance's options are frozen at evaluation
+   *     time.
+   */
+  public SubmitRuleOptions getOptions() {
+    if (opts != null) {
+      return opts;
+    }
+    return optsBuilder.build();
+  }
+
+  public SubmitRuleEvaluator setOptions(SubmitRuleOptions opts) {
+    checkNotStarted();
+    if (opts != null) {
+      optsBuilder = opts.toBuilder();
+    } else {
+      optsBuilder = SubmitRuleOptions.defaults();
+    }
+    return this;
+  }
+
+  /**
+   * @param ps patch set of the change to evaluate. If not set, the current patch set will be loaded
+   *     from {@link #evaluate()} or {@link #getSubmitType}.
    * @return this
    */
   public SubmitRuleEvaluator setPatchSet(PatchSet ps) {
-    checkArgument(ps.getId().getParentKey().equals(cd.getId()),
-        "Patch set %s does not match change %s", ps.getId(), cd.getId());
+    checkArgument(
+        ps.getId().getParentKey().equals(cd.getId()),
+        "Patch set %s does not match change %s",
+        ps.getId(),
+        cd.getId());
     patchSet = ps;
     return this;
   }
 
   /**
-   * @param fast if true, infer label information from rules rather than reading
-   *     from project config.
+   * @param fast if true assume reviewers are permitted to use label values currently stored on the
+   *     change. Fast mode bypasses some reviewer permission checks.
    * @return this
    */
   public SubmitRuleEvaluator setFastEvalLabels(boolean fast) {
-    fastEvalLabels = fast;
+    checkNotStarted();
+    optsBuilder.fastEvalLabels(fast);
     return this;
   }
 
@@ -135,7 +151,8 @@
    * @return this
    */
   public SubmitRuleEvaluator setAllowClosed(boolean allow) {
-    allowClosed = allow;
+    checkNotStarted();
+    optsBuilder.allowClosed(allow);
     return this;
   }
 
@@ -144,7 +161,8 @@
    * @return this
    */
   public SubmitRuleEvaluator setAllowDraft(boolean allow) {
-    allowDraft = allow;
+    checkNotStarted();
+    optsBuilder.allowDraft(allow);
     return this;
   }
 
@@ -153,7 +171,8 @@
    * @return this
    */
   public SubmitRuleEvaluator setSkipSubmitFilters(boolean skip) {
-    skipFilters = skip;
+    checkNotStarted();
+    optsBuilder.skipFilters(skip);
     return this;
   }
 
@@ -162,13 +181,14 @@
    * @return this
    */
   public SubmitRuleEvaluator setRule(@Nullable String rule) {
-    this.rule = rule;
+    checkNotStarted();
+    optsBuilder.rule(rule);
     return this;
   }
 
   /**
-   * @param log whether to log error messages in addition to returning error
-   *     records. If true, error record messages will be less descriptive.
+   * @param log whether to log error messages in addition to returning error records. If true, error
+   *     record messages will be less descriptive.
    */
   public SubmitRuleEvaluator setLogErrors(boolean log) {
     logErrors = log;
@@ -183,36 +203,38 @@
   /**
    * Evaluate the submit rules.
    *
-   * @return List of {@link SubmitRecord} objects returned from the evaluated
-   *     rules, including any errors.
+   * @return List of {@link SubmitRecord} objects returned from the evaluated rules, including any
+   *     errors.
    */
   public List<SubmitRecord> evaluate() {
+    initOptions();
     Change c = control.getChange();
-    if (!allowClosed && c.getStatus().isClosed()) {
+    if (!opts.allowClosed() && c.getStatus().isClosed()) {
       SubmitRecord rec = new SubmitRecord();
       rec.status = SubmitRecord.Status.CLOSED;
       return Collections.singletonList(rec);
     }
-    if (!allowDraft) {
-      if (c.getStatus() == Change.Status.DRAFT) {
-        return cannotSubmitDraft();
-      }
+    if (!opts.allowDraft()) {
       try {
         initPatchSet();
       } catch (OrmException e) {
-        return ruleError("Error looking up patch set "
-            + control.getChange().currentPatchSetId());
+        return ruleError(
+            "Error looking up patch set " + control.getChange().currentPatchSetId(), e);
       }
-      if (patchSet.isDraft()) {
+      if (c.getStatus() == Change.Status.DRAFT || patchSet.isDraft()) {
         return cannotSubmitDraft();
       }
     }
 
     List<Term> results;
     try {
-      results = evaluateImpl("locate_submit_rule", "can_submit",
-          "locate_submit_filter", "filter_submit_results",
-          control.getUser());
+      results =
+          evaluateImpl(
+              "locate_submit_rule",
+              "can_submit",
+              "locate_submit_filter",
+              "filter_submit_results",
+              control.getUser());
     } catch (RuleEvalException e) {
       return ruleError(e.getMessage(), e);
     }
@@ -222,9 +244,10 @@
       // at least one result informing the caller of the labels that are
       // required for this change to be submittable. Each label will indicate
       // whether or not that is actually possible given the permissions.
-      return ruleError(String.format("Submit rule '%s' for change %s of %s has "
-            + "no solution.", getSubmitRuleName(), cd.getId(),
-            getProjectName()));
+      return ruleError(
+          String.format(
+              "Submit rule '%s' for change %s of %s has no solution.",
+              getSubmitRuleName(), cd.getId(), getProjectName()));
     }
 
     return resultsToSubmitRecord(getSubmitRule(), results);
@@ -235,13 +258,14 @@
       if (!control.isDraftVisible(cd.db(), cd)) {
         return createRuleError("Patch set " + patchSet.getId() + " not found");
       }
-      initPatchSet();
       if (patchSet.isDraft()) {
         return createRuleError("Cannot submit draft patch sets");
       }
       return createRuleError("Cannot submit draft changes");
     } catch (OrmException err) {
-      String msg = "Cannot check visibility of patch set " + patchSet.getId();
+      PatchSet.Id psId =
+          patchSet != null ? patchSet.getId() : control.getChange().currentPatchSetId();
+      String msg = "Cannot check visibility of patch set " + psId;
       log.error(msg, err);
       return createRuleError(msg);
     }
@@ -250,13 +274,12 @@
   /**
    * Convert the results from Prolog Cafe's format to Gerrit's common format.
    *
-   * can_submit/1 terminates when an ok(P) record is found. Therefore walk
-   * the results backwards, using only that ok(P) record if it exists. This
-   * skips partial results that occur early in the output. Later after the loop
-   * the out collection is reversed to restore it to the original ordering.
+   * <p>can_submit/1 terminates when an ok(P) record is found. Therefore walk the results backwards,
+   * using only that ok(P) record if it exists. This skips partial results that occur early in the
+   * output. Later after the loop the out collection is reversed to restore it to the original
+   * ordering.
    */
-  private List<SubmitRecord> resultsToSubmitRecord(
-      Term submitRule, List<Term> results) {
+  private List<SubmitRecord> resultsToSubmitRecord(Term submitRule, List<Term> results) {
     List<SubmitRecord> out = new ArrayList<>(results.size());
     for (int resultIdx = results.size() - 1; 0 <= resultIdx; resultIdx--) {
       Term submitRecord = results.get(resultIdx);
@@ -337,9 +360,14 @@
   }
 
   private List<SubmitRecord> invalidResult(Term rule, Term record, String reason) {
-    return ruleError(String.format("Submit rule %s for change %s of %s output "
-        + "invalid result: %s%s", rule, cd.getId(), getProjectName(), record,
-        (reason == null ? "" : ". Reason: " + reason)));
+    return ruleError(
+        String.format(
+            "Submit rule %s for change %s of %s output invalid result: %s%s",
+            rule,
+            cd.getId(),
+            getProjectName(),
+            record,
+            (reason == null ? "" : ". Reason: " + reason)));
   }
 
   private List<SubmitRecord> invalidResult(Term rule, Term record) {
@@ -368,22 +396,20 @@
    * @return record from the evaluated rules.
    */
   public SubmitTypeRecord getSubmitType() {
+    initOptions();
     try {
       initPatchSet();
     } catch (OrmException e) {
-      return typeError("Error looking up patch set "
-          + control.getChange().currentPatchSetId());
+      return typeError("Error looking up patch set " + control.getChange().currentPatchSetId(), e);
     }
 
     try {
       if (control.getChange().getStatus() == Change.Status.DRAFT
           && !control.isDraftVisible(cd.db(), cd)) {
-        return SubmitTypeRecord.error(
-            "Patch set " + patchSet.getId() + " not found");
+        return SubmitTypeRecord.error("Patch set " + patchSet.getId() + " not found");
       }
       if (patchSet.isDraft() && !control.isDraftVisible(cd.db(), cd)) {
-        return SubmitTypeRecord.error(
-            "Patch set " + patchSet.getId() + " not found");
+        return SubmitTypeRecord.error("Patch set " + patchSet.getId() + " not found");
       }
     } catch (OrmException err) {
       String msg = "Cannot read patch set " + patchSet.getId();
@@ -393,37 +419,57 @@
 
     List<Term> results;
     try {
-      results = evaluateImpl("locate_submit_type", "get_submit_type",
-          "locate_submit_type_filter", "filter_submit_type_results",
-          // Do not include current user in submit type evaluation. This is used
-          // for mergeability checks, which are stored persistently and so must
-          // have a consistent view of the submit type.
-          null);
+      results =
+          evaluateImpl(
+              "locate_submit_type",
+              "get_submit_type",
+              "locate_submit_type_filter",
+              "filter_submit_type_results",
+              // Do not include current user in submit type evaluation. This is used
+              // for mergeability checks, which are stored persistently and so must
+              // have a consistent view of the submit type.
+              null);
     } catch (RuleEvalException e) {
       return typeError(e.getMessage(), e);
     }
 
     if (results.isEmpty()) {
       // Should never occur for a well written rule
-      return typeError("Submit rule '" + getSubmitRuleName() + "' for change "
-          + cd.getId() + " of " + getProjectName() + " has no solution.");
+      return typeError(
+          "Submit rule '"
+              + getSubmitRuleName()
+              + "' for change "
+              + cd.getId()
+              + " of "
+              + getProjectName()
+              + " has no solution.");
     }
 
     Term typeTerm = results.get(0);
     if (!(typeTerm instanceof SymbolTerm)) {
-      return typeError("Submit rule '" + getSubmitRuleName() + "' for change "
-          + cd.getId() + " of " + getProjectName()
-          + " did not return a symbol.");
+      return typeError(
+          "Submit rule '"
+              + getSubmitRuleName()
+              + "' for change "
+              + cd.getId()
+              + " of "
+              + getProjectName()
+              + " did not return a symbol.");
     }
 
     String typeName = ((SymbolTerm) typeTerm).name();
     try {
-      return SubmitTypeRecord.OK(
-          SubmitType.valueOf(typeName.toUpperCase()));
+      return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase()));
     } catch (IllegalArgumentException e) {
-      return typeError("Submit type rule " + getSubmitRule() + " for change "
-          + cd.getId() + " of " + getProjectName() + " output invalid result: "
-          + typeName);
+      return typeError(
+          "Submit type rule "
+              + getSubmitRule()
+              + " for change "
+              + cd.getId()
+              + " of "
+              + getProjectName()
+              + " output invalid result: "
+              + typeName);
     }
   }
 
@@ -448,41 +494,42 @@
       String userRuleWrapperName,
       String filterRuleLocatorName,
       String filterRuleWrapperName,
-      CurrentUser user) throws RuleEvalException {
+      CurrentUser user)
+      throws RuleEvalException {
     PrologEnvironment env = getPrologEnvironment(user);
     try {
       Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm());
-      if (fastEvalLabels) {
+      if (opts.fastEvalLabels()) {
         env.once("gerrit", "assume_range_from_label");
       }
 
       List<Term> results = new ArrayList<>();
       try {
-        for (Term[] template : env.all("gerrit", userRuleWrapperName, sr,
-              new VariableTerm())) {
+        for (Term[] template : env.all("gerrit", userRuleWrapperName, sr, new VariableTerm())) {
           results.add(template[1]);
         }
       } catch (ReductionLimitException err) {
-        throw new RuleEvalException(String.format(
-            "%s on change %d of %s",
-            err.getMessage(), cd.getId().get(), getProjectName()));
+        throw new RuleEvalException(
+            String.format(
+                "%s on change %d of %s", err.getMessage(), cd.getId().get(), getProjectName()));
       } catch (RuntimeException err) {
-        throw new RuleEvalException(String.format(
-            "Exception calling %s on change %d of %s",
-            sr, cd.getId().get(), getProjectName()), err);
+        throw new RuleEvalException(
+            String.format(
+                "Exception calling %s on change %d of %s", sr, cd.getId().get(), getProjectName()),
+            err);
       } finally {
         reductionsConsumed = env.getReductions();
       }
 
       Term resultsTerm = toListTerm(results);
-      if (!skipFilters) {
-        resultsTerm = runSubmitFilters(
-            resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName);
+      if (!opts.skipFilters()) {
+        resultsTerm =
+            runSubmitFilters(resultsTerm, env, filterRuleLocatorName, filterRuleWrapperName);
       }
       List<Term> r;
       if (resultsTerm instanceof ListTerm) {
         r = new ArrayList<>();
-        for (Term t = resultsTerm; t instanceof ListTerm;) {
+        for (Term t = resultsTerm; t instanceof ListTerm; ) {
           ListTerm l = (ListTerm) t;
           r.add(l.car().dereference());
           t = l.cdr().dereference();
@@ -497,23 +544,20 @@
     }
   }
 
-  private PrologEnvironment getPrologEnvironment(CurrentUser user)
-      throws RuleEvalException {
+  private PrologEnvironment getPrologEnvironment(CurrentUser user) throws RuleEvalException {
     ProjectState projectState = control.getProjectControl().getProjectState();
     PrologEnvironment env;
     try {
-      if (rule == null) {
+      if (opts.rule() == null) {
         env = projectState.newPrologEnvironment();
       } else {
-        env = projectState.newPrologEnvironment("stdin", new StringReader(rule));
+        env = projectState.newPrologEnvironment("stdin", new StringReader(opts.rule()));
       }
     } catch (CompileException err) {
       String msg;
-      if (rule == null && control.getProjectControl().isOwner()) {
-        msg = String.format(
-            "Cannot load rules.pl for %s: %s",
-            getProjectName(), err.getMessage());
-      } else if (rule != null) {
+      if (opts.rule() == null && control.getProjectControl().isOwner()) {
+        msg = String.format("Cannot load rules.pl for %s: %s", getProjectName(), err.getMessage());
+      } else if (opts.rule() != null) {
         msg = err.getMessage();
       } else {
         msg = String.format("Cannot load rules.pl for %s", getProjectName());
@@ -529,8 +573,11 @@
     return env;
   }
 
-  private Term runSubmitFilters(Term results, PrologEnvironment env,
-      String filterRuleLocatorName, String filterRuleWrapperName)
+  private Term runSubmitFilters(
+      Term results,
+      PrologEnvironment env,
+      String filterRuleLocatorName,
+      String filterRuleWrapperName)
       throws RuleEvalException {
     ProjectState projectState = control.getProjectControl().getProjectState();
     PrologEnvironment childEnv = env;
@@ -539,30 +586,32 @@
       try {
         parentEnv = parentState.newPrologEnvironment();
       } catch (CompileException err) {
-        throw new RuleEvalException("Cannot consult rules.pl for "
-            + parentState.getProject().getName(), err);
+        throw new RuleEvalException(
+            "Cannot consult rules.pl for " + parentState.getProject().getName(), err);
       }
 
       parentEnv.copyStoredValues(childEnv);
-      Term filterRule =
-          parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm());
+      Term filterRule = parentEnv.once("gerrit", filterRuleLocatorName, new VariableTerm());
       try {
-        if (fastEvalLabels) {
+        if (opts.fastEvalLabels()) {
           env.once("gerrit", "assume_range_from_label");
         }
 
         Term[] template =
-            parentEnv.once("gerrit", filterRuleWrapperName, filterRule,
-                results, new VariableTerm());
+            parentEnv.once(
+                "gerrit", filterRuleWrapperName, filterRule, results, new VariableTerm());
         results = template[2];
       } catch (ReductionLimitException err) {
-        throw new RuleEvalException(String.format(
-            "%s on change %d of %s",
-            err.getMessage(), cd.getId().get(), parentState.getProject().getName()));
+        throw new RuleEvalException(
+            String.format(
+                "%s on change %d of %s",
+                err.getMessage(), cd.getId().get(), parentState.getProject().getName()));
       } catch (RuntimeException err) {
-        throw new RuleEvalException(String.format(
-            "Exception calling %s on change %d of %s",
-            filterRule, cd.getId().get(), parentState.getProject().getName()), err);
+        throw new RuleEvalException(
+            String.format(
+                "Exception calling %s on change %d of %s",
+                filterRule, cd.getId().get(), parentState.getProject().getName()),
+            err);
       } finally {
         reductionsConsumed += env.getReductions();
       }
@@ -579,8 +628,7 @@
     return list;
   }
 
-  private void appliedBy(SubmitRecord.Label label, Term status)
-      throws UserTermExpected {
+  private void appliedBy(SubmitRecord.Label label, Term status) throws UserTermExpected {
     if (status instanceof StructureTerm && status.arity() == 1) {
       Term who = status.arg(0);
       if (isUser(who)) {
@@ -607,6 +655,17 @@
     return submitRule != null ? submitRule.toString() : "<unknown rule>";
   }
 
+  private void checkNotStarted() {
+    checkState(opts == null, "cannot set options after starting evaluation");
+  }
+
+  private void initOptions() {
+    if (opts == null) {
+      opts = optsBuilder.build();
+      optsBuilder = null;
+    }
+  }
+
   private void initPatchSet() throws OrmException {
     if (patchSet == null) {
       patchSet = cd.currentPatchSet();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
new file mode 100644
index 0000000..6d6aaad
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -0,0 +1,75 @@
+// 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.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+/**
+ * Stable identifier for options passed to a particular submit rule evaluator.
+ *
+ * <p>Used to test whether it is ok to reuse a cached list of submit records. Does not include a
+ * change or patch set ID; callers are responsible for checking those on their own.
+ */
+@AutoValue
+public abstract class SubmitRuleOptions {
+  public static Builder builder() {
+    return new AutoValue_SubmitRuleOptions.Builder();
+  }
+
+  public static Builder defaults() {
+    return builder()
+        .fastEvalLabels(false)
+        .allowDraft(false)
+        .allowClosed(false)
+        .skipFilters(false)
+        .rule(null);
+  }
+
+  public abstract boolean fastEvalLabels();
+
+  public abstract boolean allowDraft();
+
+  public abstract boolean allowClosed();
+
+  public abstract boolean skipFilters();
+
+  @Nullable
+  public abstract String rule();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract SubmitRuleOptions.Builder fastEvalLabels(boolean fastEvalLabels);
+
+    public abstract SubmitRuleOptions.Builder allowDraft(boolean allowDraft);
+
+    public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
+
+    public abstract SubmitRuleOptions.Builder skipFilters(boolean skipFilters);
+
+    public abstract SubmitRuleOptions.Builder rule(@Nullable String rule);
+
+    public abstract SubmitRuleOptions build();
+  }
+
+  public Builder toBuilder() {
+    return builder()
+        .fastEvalLabels(fastEvalLabels())
+        .allowDraft(allowDraft())
+        .allowClosed(allowClosed())
+        .skipFilters(skipFilters())
+        .rule(rule());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
index a6717d5..9d3005c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
@@ -32,8 +31,10 @@
   private final AllProjectsName allProject;
 
   @Inject
-  SuggestParentCandidates(final ProjectControl.Factory projectControlFactory,
-      final ProjectCache projectCache, final AllProjectsName allProject) {
+  SuggestParentCandidates(
+      final ProjectControl.Factory projectControlFactory,
+      final ProjectCache projectCache,
+      final AllProjectsName allProject) {
     this.projectControlFactory = projectControlFactory;
     this.projectCache = projectCache;
     this.allProject = allProject;
@@ -49,12 +50,14 @@
   }
 
   public List<Project> getProjects() throws NoSuchProjectException {
-    Set<Project> projects = new TreeSet<>(new Comparator<Project>() {
-      @Override
-      public int compare(Project o1, Project o2) {
-        return o1.getName().compareTo(o2.getName());
-      }
-    });
+    Set<Project> projects =
+        new TreeSet<>(
+            new Comparator<Project>() {
+              @Override
+              public int compare(Project o1, Project o2) {
+                return o1.getName().compareTo(o2.getName());
+              }
+            });
     for (Project.NameKey p : projectCache.all()) {
       try {
         final ProjectControl control = projectControlFactory.controlFor(p);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
index afbd3be..fe4d68d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
@@ -18,18 +18,28 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
 
-public class TagResource extends ProjectResource {
+public class TagResource extends RefResource {
   public static final TypeLiteral<RestView<TagResource>> TAG_KIND =
       new TypeLiteral<RestView<TagResource>>() {};
 
-  private final TagInfo tag;
+  private final TagInfo tagInfo;
 
-  public TagResource(ProjectControl control, TagInfo tag) {
+  public TagResource(ProjectControl control, TagInfo tagInfo) {
     super(control);
-    this.tag = tag;
+    this.tagInfo = tagInfo;
   }
 
   public TagInfo getTagInfo() {
-    return tag;
+    return tagInfo;
+  }
+
+  @Override
+  public String getRef() {
+    return tagInfo.ref;
+  }
+
+  @Override
+  public String getRevision() {
+    return tagInfo.revision;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
index b324fe0..82afce4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
@@ -23,21 +23,20 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.io.IOException;
 
 @Singleton
-public class TagsCollection implements
-    ChildCollection<ProjectResource, TagResource>,
-    AcceptsCreate<ProjectResource> {
+public class TagsCollection
+    implements ChildCollection<ProjectResource, TagResource>, AcceptsCreate<ProjectResource> {
   private final DynamicMap<RestView<TagResource>> views;
   private final Provider<ListTags> list;
   private final CreateTag.Factory createTagFactory;
 
   @Inject
-  public TagsCollection(DynamicMap<RestView<TagResource>> views,
-     Provider<ListTags> list,
-     CreateTag.Factory createTagFactory) {
+  public TagsCollection(
+      DynamicMap<RestView<TagResource>> views,
+      Provider<ListTags> list,
+      CreateTag.Factory createTagFactory) {
     this.views = views;
     this.list = list;
     this.createTagFactory = createTagFactory;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
index 899e789..1bf6d8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gwtorm.server.OrmException;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -85,8 +84,11 @@
   @Override
   public boolean match(final T object) throws OrmException {
     for (Predicate<T> c : children) {
-      checkState(c.isMatchable(), "match invoked, but child predicate %s "
-          + "doesn't implement %s", c, Matchable.class.getName());
+      checkState(
+          c.isMatchable(),
+          "match invoked, but child predicate %s doesn't implement %s",
+          c,
+          Matchable.class.getName());
       if (!c.asMatchable().match(object)) {
         return false;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
index 168be5d..dcd8a66 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -25,7 +24,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.gwtorm.server.ResultSet;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -44,18 +42,18 @@
     this(that, null, 0);
   }
 
-  public AndSource(Predicate<T> that,
-      IsVisibleToPredicate<T> isVisibleToPredicate) {
+  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate) {
     this(that, isVisibleToPredicate, 0);
   }
 
-  public AndSource(Predicate<T> that,
-      IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
+  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
     this(ImmutableList.of(that), isVisibleToPredicate, start);
   }
 
-  public AndSource(Collection<? extends Predicate<T>> that,
-      IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
+  public AndSource(
+      Collection<? extends Predicate<T>> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate,
+      int start) {
     super(that);
     checkArgument(start >= 0, "negative start: %s", start);
     this.isVisibleToPredicate = isVisibleToPredicate;
@@ -84,7 +82,9 @@
     try {
       return readImpl();
     } catch (OrmRuntimeException err) {
-      Throwables.propagateIfInstanceOf(err.getCause(), OrmException.class);
+      if (err.getCause() != null) {
+        Throwables.throwIfInstanceOf(err.getCause(), OrmException.class);
+      }
       throw new OrmException(err);
     }
   }
@@ -157,12 +157,7 @@
 
   private Iterable<T> buffer(ResultSet<T> scanner) {
     return FluentIterable.from(Iterables.partition(scanner, 50))
-        .transformAndConcat(new Function<List<T>, List<T>>() {
-          @Override
-          public List<T> apply(List<T> buffer) {
-            return transformBuffer(buffer);
-          }
-        });
+        .transformAndConcat(this::transformBuffer);
   }
 
   protected List<T> transformBuffer(List<T> buffer) throws OrmRuntimeException {
@@ -190,9 +185,7 @@
       cmp = a.estimateCost() - b.estimateCost();
     }
 
-    if (cmp == 0
-        && a instanceof DataSource
-        && b instanceof DataSource) {
+    if (cmp == 0 && a instanceof DataSource && b instanceof DataSource) {
       DataSource<?> as = (DataSource<?>) a;
       DataSource<?> bs = (DataSource<?>) b;
       cmp = as.getCardinality() - bs.getCardinality();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
index d336bb5..6627687 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
@@ -44,8 +44,7 @@
     }
     if (getClass() == other.getClass()) {
       final IntPredicate<?> p = (IntPredicate<?>) other;
-      return getOperator().equals(p.getOperator())
-          && intValue() == p.intValue();
+      return getOperator().equals(p.getOperator()) && intValue() == p.intValue();
     }
     return false;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
index 36e5792..87772d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
@@ -15,22 +15,21 @@
 package com.google.gerrit.server.query;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.Schema;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.List;
 import java.util.Set;
 
 /**
  * Execute a single query over a secondary index, for use by Gerrit internals.
- * <p>
- * By default, visibility of returned entities is not enforced (unlike in {@link
- * QueryProcessor}). The methods in this class are not typically used by
- * user-facing paths, but rather by internal callers that need to process all
- * matching results.
+ *
+ * <p>By default, visibility of returned entities is not enforced (unlike in {@link
+ * QueryProcessor}). The methods in this class are not typically used by user-facing paths, but
+ * rather by internal callers that need to process all matching results.
  */
 public class InternalQuery<T> {
   private final QueryProcessor<T> queryProcessor;
@@ -38,9 +37,10 @@
 
   protected final IndexConfig indexConfig;
 
-  protected InternalQuery(QueryProcessor<T> queryProcessor,
+  protected InternalQuery(
+      QueryProcessor<T> queryProcessor,
       IndexCollection<?, T, ? extends Index<?, T>> indexes,
-          IndexConfig indexConfig) {
+      IndexConfig indexConfig) {
     this.queryProcessor = queryProcessor.enforceVisibility(false);
     this.indexes = indexes;
     this.indexConfig = indexConfig;
@@ -62,7 +62,7 @@
   }
 
   public InternalQuery<T> noFields() {
-    queryProcessor.setRequestedFields(ImmutableSet.<String> of());
+    queryProcessor.setRequestedFields(ImmutableSet.<String>of());
     return this;
   }
 
@@ -74,6 +74,24 @@
     }
   }
 
+  /**
+   * Run multiple queries in parallel.
+   *
+   * <p>If a limit was specified using {@link #setLimit(int)}, that limit is applied to each query
+   * independently.
+   *
+   * @param queries list of queries.
+   * @return results of the queries, one list of results per input query, in the same order as the
+   *     input.
+   */
+  public List<List<T>> query(List<Predicate<T>> queries) throws OrmException {
+    try {
+      return Lists.transform(queryProcessor.query(queries), QueryResult::entities);
+    } catch (QueryParseException e) {
+      throw new OrmException(e);
+    }
+  }
+
   protected Schema<T> schema() {
     Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
     return index != null ? index.getSchema() : null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
index 38411e3..9295eb9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
@@ -14,9 +14,21 @@
 
 package com.google.gerrit.server.query;
 
-public abstract class IsVisibleToPredicate<T> extends OperatorPredicate<T>
-    implements Matchable<T> {
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.query.change.SingleGroupUser;
+
+public abstract class IsVisibleToPredicate<T> extends OperatorPredicate<T> implements Matchable<T> {
   public IsVisibleToPredicate(String name, String value) {
     super(name, value);
   }
+
+  protected static String describe(CurrentUser user) {
+    if (user.isIdentifiedUser()) {
+      return user.getAccountId().toString();
+    }
+    if (user instanceof SingleGroupUser) {
+      return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
+    }
+    return user.toString();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
index 8ffba72..3716ec1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gwtorm.server.OrmException;
-
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -66,8 +65,11 @@
 
   @Override
   public boolean match(final T object) throws OrmException {
-    checkState(that.isMatchable(), "match invoked, but child predicate %s "
-        + "doesn't implement %s", that, Matchable.class.getName());
+    checkState(
+        that.isMatchable(),
+        "match invoked, but child predicate %s doesn't implement %s",
+        that,
+        Matchable.class.getName());
     return !that.asMatchable().match(object);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
index 2cb70af..96a30ee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
@@ -16,7 +16,6 @@
 
 import java.util.Collection;
 
-
 /** Predicate to filter a field by matching value. */
 public abstract class OperatorPredicate<T> extends Predicate<T> {
   private final String name;
@@ -55,8 +54,7 @@
     }
     if (getClass() == other.getClass()) {
       final OperatorPredicate<?> p = (OperatorPredicate<?>) other;
-      return getOperator().equals(p.getOperator())
-          && getValue().equals(p.getValue());
+      return getOperator().equals(p.getOperator()) && getValue().equals(p.getValue());
     }
     return false;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
index ad15286..4845a86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gwtorm.server.OrmException;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -85,8 +84,11 @@
   @Override
   public boolean match(final T object) throws OrmException {
     for (final Predicate<T> c : children) {
-      checkState(c.isMatchable(), "match invoked, but child predicate %s "
-          + "doesn't implement %s", c, Matchable.class.getName());
+      checkState(
+          c.isMatchable(),
+          "match invoked, but child predicate %s doesn't implement %s",
+          c,
+          Matchable.class.getName());
       if (c.asMatchable().match(object)) {
         return true;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/PostFilterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/PostFilterPredicate.java
new file mode 100644
index 0000000..ea2f417
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/PostFilterPredicate.java
@@ -0,0 +1,21 @@
+// 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.query;
+
+/**
+ * Matches all documents in the index, with additional filtering done in the subclass's {@code
+ * match} method.
+ */
+public abstract class PostFilterPredicate<T> extends Predicate<T> implements Matchable<T> {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
index 3a38da6..aabc066 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
@@ -17,29 +17,25 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.Iterables;
-
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 
 /**
  * An abstract predicate tree for any form of query.
- * <p>
- * Implementations should be immutable, such that the meaning of a predicate
- * never changes once constructed. They should ensure their immutable promise by
- * defensively copying any structures which might be modified externally, but
- * was passed into the object's constructor.
- * <p>
- * However, implementations <i>may</i> retain non-thread-safe caches internally,
- * to speed up evaluation operations within the context of one thread's
- * evaluation of the predicate. As a result, callers should assume predicates
- * are not thread-safe, but that two predicate graphs produce the same results
- * given the same inputs if they are {@link #equals(Object)}.
- * <p>
- * Predicates should support deep inspection whenever possible, so that generic
- * algorithms can be written to operate against them. Predicates which contain
- * other predicates should override {@link #getChildren()} to return the list of
- * children nested within the predicate.
+ *
+ * <p>Implementations should be immutable, such that the meaning of a predicate never changes once
+ * constructed. They should ensure their immutable promise by defensively copying any structures
+ * which might be modified externally, but was passed into the object's constructor.
+ *
+ * <p>However, implementations <i>may</i> retain non-thread-safe caches internally, to speed up
+ * evaluation operations within the context of one thread's evaluation of the predicate. As a
+ * result, callers should assume predicates are not thread-safe, but that two predicate graphs
+ * produce the same results given the same inputs if they are {@link #equals(Object)}.
+ *
+ * <p>Predicates should support deep inspection whenever possible, so that generic algorithms can be
+ * written to operate against them. Predicates which contain other predicates should override {@link
+ * #getChildren()} to return the list of children nested within the predicate.
  *
  * @param <T> type of object the predicate can evaluate in memory.
  */
@@ -60,8 +56,7 @@
   }
 
   /** Combine the passed predicates into a single AND node. */
-  public static <T> Predicate<T> and(
-      final Collection<? extends Predicate<T>> that) {
+  public static <T> Predicate<T> and(final Collection<? extends Predicate<T>> that) {
     if (that.size() == 1) {
       return Iterables.getOnlyElement(that);
     }
@@ -78,8 +73,7 @@
   }
 
   /** Combine the passed predicates into a single OR node. */
-  public static <T> Predicate<T> or(
-      final Collection<? extends Predicate<T>> that) {
+  public static <T> Predicate<T> or(final Collection<? extends Predicate<T>> that) {
     if (that.size() == 1) {
       return Iterables.getOnlyElement(that);
     }
@@ -141,8 +135,7 @@
   private static class Any<T> extends Predicate<T> implements Matchable<T> {
     private static final Any<Object> INSTANCE = new Any<>();
 
-    private Any() {
-    }
+    private Any() {}
 
     @Override
     public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
index 3a21ce4..62144ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
@@ -25,8 +25,7 @@
 import static com.google.gerrit.server.query.QueryParser.OR;
 import static com.google.gerrit.server.query.QueryParser.SINGLE_WORD;
 
-import org.antlr.runtime.tree.Tree;
-
+import com.google.common.base.Strings;
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -38,14 +37,14 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.antlr.runtime.tree.Tree;
 
 /**
  * Base class to support writing parsers for query languages.
- * <p>
- * Subclasses may document their supported query operators by declaring public
- * methods that perform the query conversion into a {@link Predicate}. For
- * example, to support "is:starred", "is:unread", and nothing else, a subclass
- * may write:
+ *
+ * <p>Subclasses may document their supported query operators by declaring public methods that
+ * perform the query conversion into a {@link Predicate}. For example, to support "is:starred",
+ * "is:unread", and nothing else, a subclass may write:
  *
  * <pre>
  * &#064;Operator
@@ -59,16 +58,15 @@
  *   throw new IllegalArgumentException();
  * }
  * </pre>
- * <p>
- * The available operator methods are discovered at runtime via reflection.
- * Method names (after being converted to lowercase), correspond to operators in
- * the query language, method string values correspond to the operator argument.
- * Methods must be declared {@code public}, returning {@link Predicate},
- * accepting one {@link String}, and annotated with the {@link Operator}
+ *
+ * <p>The available operator methods are discovered at runtime via reflection. Method names (after
+ * being converted to lowercase), correspond to operators in the query language, method string
+ * values correspond to the operator argument. Methods must be declared {@code public}, returning
+ * {@link Predicate}, accepting one {@link String}, and annotated with the {@link Operator}
  * annotation.
- * <p>
- * Subclasses may also declare a handler for values which appear without
- * operator by overriding {@link #defaultField(String)}.
+ *
+ * <p>Subclasses may also declare a handler for values which appear without operator by overriding
+ * {@link #defaultField(String)}.
  *
  * @param <T> type of object the predicates can evaluate in memory.
  */
@@ -81,14 +79,13 @@
   /**
    * Defines the operators known by a QueryBuilder.
    *
-   * This class is thread-safe and may be reused or cached.
+   * <p>This class is thread-safe and may be reused or cached.
    *
    * @param <T> type of object the predicates can evaluate in memory.
    * @param <Q> type of the query builder subclass.
    */
   public static class Definition<T, Q extends QueryBuilder<T>> {
-    private final Map<String, OperatorFactory<T, Q>> opFactories =
-        new HashMap<>();
+    private final Map<String, OperatorFactory<T, Q>> opFactories = new HashMap<>();
 
     public Definition(Class<Q> clazz) {
       // Guess at the supported operators by scanning methods.
@@ -142,12 +139,12 @@
    * @param p the predicate to find.
    * @param clazz type of the predicate instance.
    * @param name name of the operator.
-   * @return the first instance of a predicate having the given type, as found
-   *     by a depth-first search.
+   * @return the first instance of a predicate having the given type, as found by a depth-first
+   *     search.
    */
   @SuppressWarnings("unchecked")
-  public static <T, P extends OperatorPredicate<T>> P find(Predicate<T> p,
-      Class<P> clazz, String name) {
+  public static <T, P extends OperatorPredicate<T>> P find(
+      Predicate<T> p, Class<P> clazz, String name) {
     if (p instanceof OperatorPredicate
         && ((OperatorPredicate<?>) p).getOperator().equals(name)
         && clazz.isAssignableFrom(p.getClass())) {
@@ -166,8 +163,7 @@
 
   protected final Definition<T, ? extends QueryBuilder<T>> builderDef;
 
-  @SuppressWarnings("rawtypes")
-  protected final Map<String, OperatorFactory> opFactories;
+  protected final Map<String, OperatorFactory<?, ?>> opFactories;
 
   @SuppressWarnings({"unchecked", "rawtypes"})
   protected QueryBuilder(Definition<T, ? extends QueryBuilder<T>> def) {
@@ -180,12 +176,14 @@
    *
    * @param query the query string.
    * @return predicate representing the user query.
-   * @throws QueryParseException the query string is invalid and cannot be
-   *         parsed by this parser. This may be due to a syntax error, may be
-   *         due to an operator not being supported, or due to an invalid value
-   *         being passed to a recognized operator.
+   * @throws QueryParseException the query string is invalid and cannot be parsed by this parser.
+   *     This may be due to a syntax error, may be due to an operator not being supported, or due to
+   *     an invalid value being passed to a recognized operator.
    */
   public Predicate<T> parse(final String query) throws QueryParseException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new QueryParseException("query is empty");
+    }
     return toPredicate(QueryParser.parse(query));
   }
 
@@ -193,16 +191,12 @@
    * Parse multiple user-supplied query strings into a list of predicates.
    *
    * @param queries the query strings.
-   * @return predicates representing the user query, in the same order as the
-   *         input.
-   * @throws QueryParseException one of the query strings is invalid and cannot
-   *         be parsed by this parser. This may be due to a syntax error, may be
-   *         due to an operator not being supported, or due to an invalid value
-   *         being passed to a recognized operator.
-   *
+   * @return predicates representing the user query, in the same order as the input.
+   * @throws QueryParseException one of the query strings is invalid and cannot be parsed by this
+   *     parser. This may be due to a syntax error, may be due to an operator not being supported,
+   *     or due to an invalid value being passed to a recognized operator.
    */
-  public List<Predicate<T>> parse(final List<String> queries)
-      throws QueryParseException {
+  public List<Predicate<T>> parse(final List<String> queries) throws QueryParseException {
     List<Predicate<T>> predicates = new ArrayList<>(queries.size());
     for (String query : queries) {
       predicates.add(parse(query));
@@ -210,8 +204,8 @@
     return predicates;
   }
 
-  private Predicate<T> toPredicate(final Tree r) throws QueryParseException,
-      IllegalArgumentException {
+  private Predicate<T> toPredicate(final Tree r)
+      throws QueryParseException, IllegalArgumentException {
     switch (r.getType()) {
       case AND:
         return and(children(r));
@@ -231,24 +225,24 @@
     }
   }
 
-  private Predicate<T> operator(final String name, final Tree val)
-      throws QueryParseException {
+  private Predicate<T> operator(final String name, final Tree val) throws QueryParseException {
     switch (val.getType()) {
-      // Expand multiple values, "foo:(a b c)", as though they were written
-      // out with the longer form, "foo:a foo:b foo:c".
-      //
+        // Expand multiple values, "foo:(a b c)", as though they were written
+        // out with the longer form, "foo:a foo:b foo:c".
+        //
       case AND:
-      case OR: {
-        List<Predicate<T>> p = new ArrayList<>(val.getChildCount());
-        for (int i = 0; i < val.getChildCount(); i++) {
-          final Tree c = val.getChild(i);
-          if (c.getType() != DEFAULT_FIELD) {
-            throw error("Nested operator not expected: " + c);
+      case OR:
+        {
+          List<Predicate<T>> p = new ArrayList<>(val.getChildCount());
+          for (int i = 0; i < val.getChildCount(); i++) {
+            final Tree c = val.getChild(i);
+            if (c.getType() != DEFAULT_FIELD) {
+              throw error("Nested operator not expected: " + c);
+            }
+            p.add(operator(name, onlyChildOf(c)));
           }
-          p.add(operator(name, onlyChildOf(c)));
+          return val.getType() == AND ? and(p) : or(p);
         }
-        return val.getType() == AND ? and(p) : or(p);
-      }
 
       case SINGLE_WORD:
       case EXACT_PHRASE:
@@ -263,8 +257,7 @@
   }
 
   @SuppressWarnings("unchecked")
-  private Predicate<T> operator(final String name, final String value)
-      throws QueryParseException {
+  private Predicate<T> operator(final String name, final String value) throws QueryParseException {
     @SuppressWarnings("rawtypes")
     OperatorFactory f = opFactories.get(name);
     if (f == null) {
@@ -289,26 +282,24 @@
 
   /**
    * Handle a value present outside of an operator.
-   * <p>
-   * This default implementation always throws an "Unsupported query: " message
-   * containing the input text. Subclasses may override this method to perform
-   * do-what-i-mean guesses based on the input string.
+   *
+   * <p>This default implementation always throws an "Unsupported query: " message containing the
+   * input text. Subclasses may override this method to perform do-what-i-mean guesses based on the
+   * input string.
    *
    * @param value the value supplied by itself in the query.
    * @return predicate representing this value.
    * @throws QueryParseException the parser does not recognize this value.
    */
-  protected Predicate<T> defaultField(final String value)
-      throws QueryParseException {
+  protected Predicate<T> defaultField(final String value) throws QueryParseException {
     throw error("Unsupported query:" + value);
   }
 
-  @SuppressWarnings("unchecked")
-  private Predicate<T>[] children(final Tree r) throws QueryParseException,
-      IllegalArgumentException {
-    final Predicate<T>[] p = new Predicate[r.getChildCount()];
-    for (int i = 0; i < p.length; i++) {
-      p[i] = toPredicate(r.getChild(i));
+  private List<Predicate<T>> children(final Tree r)
+      throws QueryParseException, IllegalArgumentException {
+    List<Predicate<T>> p = new ArrayList<>(r.getChildCount());
+    for (int i = 0; i < r.getChildCount(); i++) {
+      p.add(toPredicate(r.getChild(i)));
     }
     return p;
   }
@@ -331,8 +322,7 @@
   /** Denotes a method which is a query operator. */
   @Retention(RetentionPolicy.RUNTIME)
   @Target(ElementType.METHOD)
-  protected @interface Operator {
-  }
+  protected @interface Operator {}
 
   private static class ReflectionFactory<T, Q extends QueryBuilder<T>>
       implements OperatorFactory<T, Q> {
@@ -346,13 +336,15 @@
 
     @SuppressWarnings("unchecked")
     @Override
-    public Predicate<T> create(Q builder, String value)
-        throws QueryParseException {
+    public Predicate<T> create(Q builder, String value) throws QueryParseException {
       try {
         return (Predicate<T>) method.invoke(builder, value);
       } catch (RuntimeException | IllegalAccessException e) {
         throw error("Error in operator " + name + ":" + value, e);
       } catch (InvocationTargetException e) {
+        if (e.getCause() instanceof QueryParseException) {
+          throw (QueryParseException) e.getCause();
+        }
         throw error("Error in operator " + name + ":" + value, e.getCause());
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
index 8373d4d..5fb4497 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
@@ -36,7 +36,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
@@ -50,11 +49,13 @@
     @Inject
     Metrics(MetricMaker metricMaker) {
       Field<String> index = Field.ofString("index", "index name");
-      executionTime = metricMaker.newTimer("query/query_latency",
-          new Description("Successful query latency,"
-              + " accumulated over the life of the process").setCumulative()
+      executionTime =
+          metricMaker.newTimer(
+              "query/query_latency",
+              new Description("Successful query latency, accumulated over the life of the process")
+                  .setCumulative()
                   .setUnit(Description.Units.MILLISECONDS),
-          index);
+              index);
     }
   }
 
@@ -117,21 +118,16 @@
    * @param query the query.
    * @return results of the query.
    */
-  public QueryResult<T> query(Predicate<T> query)
-      throws OrmException, QueryParseException {
+  public QueryResult<T> query(Predicate<T> query) throws OrmException, QueryParseException {
     return query(ImmutableList.of(query)).get(0);
   }
 
-  /*
-   * Perform multiple queries over a list of query strings.
-   * <p>
-   * If a limit was specified using {@link #setLimit(int)} this method may
-   * return up to {@code limit + 1} results, allowing the caller to determine if
-   * there are more than {@code limit} matches and suggest to its own caller
-   * that the query could be retried with {@link #setStart(int)}.
+  /**
+   * Perform multiple queries in parallel.
    *
-   * @param queries the queries.
-   * @return results of the queries, one list per input query.
+   * @param queries list of queries.
+   * @return results of the queries, one QueryResult per input query, in the same order as the
+   *     input.
    */
   public List<QueryResult<T>> query(List<Predicate<T>> queries)
       throws OrmException, QueryParseException {
@@ -140,13 +136,14 @@
     } catch (OrmRuntimeException e) {
       throw new OrmException(e.getMessage(), e);
     } catch (OrmException e) {
-      Throwables.propagateIfInstanceOf(e.getCause(), QueryParseException.class);
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), QueryParseException.class);
+      }
       throw e;
     }
   }
 
-  private List<QueryResult<T>> query(List<String> queryStrings,
-      List<Predicate<T>> queries)
+  private List<QueryResult<T>> query(List<String> queryStrings, List<Predicate<T>> queries)
       throws OrmException, QueryParseException {
     long startNanos = System.nanoTime();
 
@@ -172,8 +169,7 @@
       // Always bump limit by 1, even if this results in exceeding the permitted
       // max for this user. The only way to see if there are more entities is to
       // ask for one more result from the query.
-      QueryOptions opts =
-          createOptions(indexConfig, start, limit + 1, getRequestedFields());
+      QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
       Predicate<T> pred = rewriter.rewrite(q, opts);
       if (enforceVisibility) {
         pred = enforceVisibility(pred);
@@ -193,27 +189,28 @@
 
     List<QueryResult<T>> out = new ArrayList<>(cnt);
     for (int i = 0; i < cnt; i++) {
-      out.add(QueryResult.create(
-          queryStrings != null ? queryStrings.get(i) : null,
-          predicates.get(i),
-          limits.get(i),
-          matches.get(i).toList()));
+      out.add(
+          QueryResult.create(
+              queryStrings != null ? queryStrings.get(i) : null,
+              predicates.get(i),
+              limits.get(i),
+              matches.get(i).toList()));
     }
 
     // only measure successful queries
-    metrics.executionTime.record(schemaDef.getName(),
-        System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+    metrics.executionTime.record(
+        schemaDef.getName(), System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
     return out;
   }
 
-  protected QueryOptions createOptions(IndexConfig indexConfig, int start,
-      int limit, Set<String> requestedFields) {
+  protected QueryOptions createOptions(
+      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
     return QueryOptions.create(indexConfig, start, limit, requestedFields);
   }
 
   /**
-   * Invoked after the query was rewritten. Subclasses must overwrite this
-   * method to filter out results that are not visible to the calling user.
+   * Invoked after the query was rewritten. Subclasses must overwrite this method to filter out
+   * results that are not visible to the calling user.
    *
    * @param pred the query
    * @return the modified query
@@ -225,9 +222,7 @@
       return requestedFields;
     }
     Index<?, T> index = indexes.getSearchIndex();
-    return index != null
-        ? index.getSchema().getStoredFields().keySet()
-        : ImmutableSet.<String> of();
+    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.<String>of();
   }
 
   public boolean isDisabled() {
@@ -236,9 +231,7 @@
 
   private int getPermittedLimit() {
     if (enforceVisibility) {
-      return userProvider.get().getCapabilities()
-        .getRange(GlobalCapability.QUERY_LIMIT)
-        .getMax();
+      return userProvider.get().getCapabilities().getRange(GlobalCapability.QUERY_LIMIT).getMax();
     }
     return Integer.MAX_VALUE;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java
index b35bde3..f86eb707 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java
@@ -16,14 +16,13 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-
 import java.util.List;
 
 /** Results of a query over entities. */
 @AutoValue
 public abstract class QueryResult<T> {
-  static <T> QueryResult<T> create(@Nullable String query,
-      Predicate<T> predicate, int limit, List<T> entites) {
+  static <T> QueryResult<T> create(
+      @Nullable String query, Predicate<T> predicate, int limit, List<T> entites) {
     boolean more;
     if (entites.size() > limit) {
       more = true;
@@ -34,25 +33,19 @@
     return new AutoValue_QueryResult<>(query, predicate, entites, more);
   }
 
-  /**
-   * @return the original query string, or null if the query was created
-   *     programmatically.
-   */
-  @Nullable public abstract String query();
+  /** @return the original query string, or null if the query was created programmatically. */
+  @Nullable
+  public abstract String query();
 
-  /**
-   * @return the predicate after all rewriting and other modification by the
-   *     query subsystem.
-   */
+  /** @return the predicate after all rewriting and other modification by the query subsystem. */
   public abstract Predicate<T> predicate();
 
   /** @return the query results. */
   public abstract List<T> entities();
 
   /**
-   * @return whether the query could be retried with
-   *     {@link QueryProcessor#setStart(int)} to produce more results. Never
-   *     true if {@link #entities()} is empty.
+   * @return whether the query could be retried with {@link QueryProcessor#setStart(int)} to produce
+   *     more results. Never true if {@link #entities()} is empty.
    */
   public abstract boolean more();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
index dc68a61..0a74647 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -14,31 +14,16 @@
 
 package com.google.gerrit.server.query.account;
 
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.query.IsVisibleToPredicate;
-import com.google.gerrit.server.query.change.SingleGroupUser;
 import com.google.gwtorm.server.OrmException;
 
-public class AccountIsVisibleToPredicate
-    extends IsVisibleToPredicate<AccountState> {
-  private static String describe(CurrentUser user) {
-    if (user.isIdentifiedUser()) {
-      return user.getAccountId().toString();
-    }
-    if (user instanceof SingleGroupUser) {
-      return "group:" + user.getEffectiveGroups().getKnownGroups() //
-          .iterator().next().toString();
-    }
-    return user.toString();
-  }
-
+public class AccountIsVisibleToPredicate extends IsVisibleToPredicate<AccountState> {
   private final AccountControl accountControl;
 
   AccountIsVisibleToPredicate(AccountControl accountControl) {
-    super(AccountQueryBuilder.FIELD_VISIBLETO,
-        describe(accountControl.getUser()));
+    super(AccountQueryBuilder.FIELD_VISIBLETO, describe(accountControl.getUser()));
     this.accountControl = accountControl;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
index b3f92ff..4359dc8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -22,15 +22,16 @@
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.Matchable;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryBuilder;
-
+import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
 public class AccountPredicates {
   public static boolean hasActive(Predicate<AccountState> p) {
-    return QueryBuilder.find(p, AccountPredicate.class,
-        AccountField.ACTIVE.getName()) != null;
+    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE.getName()) != null;
   }
 
   static Predicate<AccountState> defaultPredicate(String query) {
@@ -47,19 +48,19 @@
     return Predicate.or(preds);
   }
 
-  static Predicate<AccountState> id(Account.Id accountId) {
-    return new AccountPredicate(AccountField.ID,
-        AccountQueryBuilder.FIELD_ACCOUNT, accountId.toString());
+  public static Predicate<AccountState> id(Account.Id accountId) {
+    return new AccountPredicate(
+        AccountField.ID, AccountQueryBuilder.FIELD_ACCOUNT, accountId.toString());
   }
 
   static Predicate<AccountState> email(String email) {
-    return new AccountPredicate(AccountField.EMAIL,
-        AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+    return new AccountPredicate(
+        AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
   }
 
   static Predicate<AccountState> equalsName(String name) {
-    return new AccountPredicate(AccountField.NAME_PART,
-        AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+    return new AccountPredicate(
+        AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
   }
 
   static Predicate<AccountState> externalId(String externalId) {
@@ -79,15 +80,22 @@
   }
 
   static Predicate<AccountState> username(String username) {
-    return new AccountPredicate(AccountField.USERNAME,
-        AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+    return new AccountPredicate(
+        AccountField.USERNAME, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
   }
 
   static Predicate<AccountState> watchedProject(Project.NameKey project) {
     return new AccountPredicate(AccountField.WATCHED_PROJECT, project.get());
   }
 
-  static class AccountPredicate extends IndexPredicate<AccountState> {
+  static Predicate<AccountState> cansee(
+      AccountQueryBuilder.Arguments args, ChangeNotes changeNotes) {
+    return new CanSeeChangePredicate(
+        args.db, args.changeControlFactory, args.userFactory, changeNotes);
+  }
+
+  static class AccountPredicate extends IndexPredicate<AccountState>
+      implements Matchable<AccountState> {
     AccountPredicate(FieldDef<AccountState, ?> def, String value) {
       super(def, value);
     }
@@ -95,8 +103,17 @@
     AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
       super(def, name, value);
     }
+
+    @Override
+    public boolean match(AccountState object) throws OrmException {
+      return true;
+    }
+
+    @Override
+    public int getCost() {
+      return 1;
+    }
   }
 
-  private AccountPredicates() {
-  }
+  private AccountPredicates() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 0288cb2..8f38f1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -14,31 +14,28 @@
 
 package com.google.gerrit.server.query.account;
 
-import com.google.common.base.Function;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.errors.NotSignedInException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.LimitPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryBuilder;
 import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 
-/**
- * Parses a query string meant to be applied to account objects.
- */
+/** Parses a query string meant to be applied to account objects. */
 public class AccountQueryBuilder extends QueryBuilder<AccountState> {
-  public interface ChangeOperatorFactory
-      extends OperatorFactory<AccountState, AccountQueryBuilder> {
-  }
-
   public static final String FIELD_ACCOUNT = "account";
   public static final String FIELD_EMAIL = "email";
   public static final String FIELD_LIMIT = "limit";
@@ -50,11 +47,25 @@
       new QueryBuilder.Definition<>(AccountQueryBuilder.class);
 
   public static class Arguments {
+    final Provider<ReviewDb> db;
+    final ChangeFinder changeFinder;
+    final ChangeControl.GenericFactory changeControlFactory;
+    final IdentifiedUser.GenericFactory userFactory;
+
     private final Provider<CurrentUser> self;
 
     @Inject
-    public Arguments(Provider<CurrentUser> self) {
+    public Arguments(
+        Provider<CurrentUser> self,
+        Provider<ReviewDb> db,
+        ChangeFinder changeFinder,
+        ChangeControl.GenericFactory changeControlFactory,
+        IdentifiedUser.GenericFactory userFactory) {
       this.self = self;
+      this.db = db;
+      this.changeFinder = changeFinder;
+      this.changeControlFactory = changeControlFactory;
+      this.userFactory = userFactory;
     }
 
     IdentifiedUser getIdentifiedUser() throws QueryParseException {
@@ -87,6 +98,16 @@
   }
 
   @Operator
+  public Predicate<AccountState> cansee(String change) throws QueryParseException, OrmException {
+    ChangeControl changeControl = args.changeFinder.findOne(change, args.getUser());
+    if (changeControl == null || !changeControl.isVisible(args.db.get())) {
+      throw error(String.format("change %s not found", change));
+    }
+
+    return AccountPredicates.cansee(args, changeControl.getNotes());
+  }
+
+  @Operator
   public Predicate<AccountState> email(String email) {
     return AccountPredicates.email(email);
   }
@@ -103,8 +124,7 @@
   }
 
   @Operator
-  public Predicate<AccountState> limit(String query)
-      throws QueryParseException {
+  public Predicate<AccountState> limit(String query) throws QueryParseException {
     Integer limit = Ints.tryParse(query);
     if (limit == null) {
       throw error("Invalid limit: " + query);
@@ -124,19 +144,20 @@
 
   public Predicate<AccountState> defaultQuery(String query) {
     return Predicate.and(
-        Lists.transform(Splitter.on(' ').omitEmptyStrings().splitToList(query),
-            new Function<String, Predicate<AccountState>>() {
-              @Override
-              public Predicate<AccountState> apply(String s) {
-                return defaultField(s);
-              }
-            }));
+        Lists.transform(
+            Splitter.on(' ').omitEmptyStrings().splitToList(query), this::defaultField));
   }
 
   @Override
   protected Predicate<AccountState> defaultField(String query) {
-    Predicate<AccountState> defaultPredicate =
-        AccountPredicates.defaultPredicate(query);
+    if (query.startsWith("cansee:")) {
+      try {
+        return cansee(query.substring(7));
+      } catch (OrmException | QueryParseException e) {
+        // Ignore, fall back to default query
+      }
+    }
+    Predicate<AccountState> defaultPredicate = AccountPredicates.defaultPredicate(query);
     if ("self".equalsIgnoreCase(query)) {
       try {
         return Predicate.or(defaultPredicate, AccountPredicates.id(self()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index 48d0897..d984e6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -42,21 +42,27 @@
   }
 
   @Inject
-  protected AccountQueryProcessor(Provider<CurrentUser> userProvider,
+  protected AccountQueryProcessor(
+      Provider<CurrentUser> userProvider,
       Metrics metrics,
       IndexConfig indexConfig,
       AccountIndexCollection indexes,
       AccountIndexRewriter rewriter,
       AccountControl.Factory accountControlFactory) {
-    super(userProvider, metrics, AccountSchemaDefinitions.INSTANCE, indexConfig,
-        indexes, rewriter, FIELD_LIMIT);
+    super(
+        userProvider,
+        metrics,
+        AccountSchemaDefinitions.INSTANCE,
+        indexConfig,
+        indexes,
+        rewriter,
+        FIELD_LIMIT);
     this.accountControlFactory = accountControlFactory;
   }
 
   @Override
-  protected Predicate<AccountState> enforceVisibility(
-      Predicate<AccountState> pred) {
-    return new AndSource<>(pred,
-        new AccountIsVisibleToPredicate(accountControlFactory.get()), start);
+  protected Predicate<AccountState> enforceVisibility(Predicate<AccountState> pred) {
+    return new AndSource<>(
+        pred, new AccountIsVisibleToPredicate(accountControlFactory.get()), start);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
new file mode 100644
index 0000000..f275272
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -0,0 +1,77 @@
+// 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.query.account;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.PostFilterPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import java.util.Collection;
+import java.util.Objects;
+
+public class CanSeeChangePredicate extends PostFilterPredicate<AccountState> {
+  private final Provider<ReviewDb> db;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ChangeNotes changeNotes;
+
+  CanSeeChangePredicate(
+      Provider<ReviewDb> db,
+      ChangeControl.GenericFactory changeControlFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      ChangeNotes changeNotes) {
+    this.db = db;
+    this.changeControlFactory = changeControlFactory;
+    this.userFactory = userFactory;
+    this.changeNotes = changeNotes;
+  }
+
+  @Override
+  public boolean match(AccountState accountState) throws OrmException {
+    return changeControlFactory
+        .controlFor(changeNotes, userFactory.create(accountState.getAccount().getId()))
+        .isVisible(db.get());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  @Override
+  public Predicate<AccountState> copy(Collection<? extends Predicate<AccountState>> children) {
+    return new CanSeeChangePredicate(db, changeControlFactory, userFactory, changeNotes);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(changeNotes.getChange().getChangeId());
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null) {
+      return false;
+    }
+    return getClass() == other.getClass()
+        && changeNotes.getChange().getChangeId()
+            == ((CanSeeChangePredicate) other).changeNotes.getChange().getChangeId();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 7bc3144..c2b92aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -18,24 +18,23 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.InternalQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.util.List;
+import java.util.Set;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.List;
-import java.util.Set;
-
 public class InternalAccountQuery extends InternalQuery<AccountState> {
-  private static final Logger log =
-      LoggerFactory.getLogger(InternalAccountQuery.class);
+  private static final Logger log = LoggerFactory.getLogger(InternalAccountQuery.class);
 
   @Inject
-  InternalAccountQuery(AccountQueryProcessor queryProcessor,
+  InternalAccountQuery(
+      AccountQueryProcessor queryProcessor,
       AccountIndexCollection indexes,
       IndexConfig indexConfig) {
     super(queryProcessor, indexes, indexConfig);
@@ -65,39 +64,49 @@
     return this;
   }
 
-  public List<AccountState> byDefault(String query)
-      throws OrmException {
+  public List<AccountState> byDefault(String query) throws OrmException {
     return query(AccountPredicates.defaultPredicate(query));
   }
 
-  public List<AccountState> byExternalId(String externalId)
-      throws OrmException {
-    return query(AccountPredicates.externalId(externalId));
+  public List<AccountState> byEmailPrefix(String emailPrefix) throws OrmException {
+    return query(AccountPredicates.email(emailPrefix));
+  }
+
+  public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
+    return byExternalId(ExternalId.Key.create(scheme, id));
+  }
+
+  public List<AccountState> byExternalId(ExternalId.Key externalId) throws OrmException {
+    return query(AccountPredicates.externalId(externalId.toString()));
   }
 
   public AccountState oneByExternalId(String externalId) throws OrmException {
+    return oneByExternalId(ExternalId.Key.parse(externalId));
+  }
+
+  public AccountState oneByExternalId(String scheme, String id) throws OrmException {
+    return oneByExternalId(ExternalId.Key.create(scheme, id));
+  }
+
+  public AccountState oneByExternalId(ExternalId.Key externalId) throws OrmException {
     List<AccountState> accountStates = byExternalId(externalId);
     if (accountStates.size() == 1) {
       return accountStates.get(0);
     } else if (accountStates.size() > 0) {
       StringBuilder msg = new StringBuilder();
-      msg.append("Ambiguous external ID ")
-          .append(externalId)
-          .append("for accounts: ");
-      Joiner.on(", ").appendTo(msg,
-          Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
+      msg.append("Ambiguous external ID ").append(externalId).append("for accounts: ");
+      Joiner.on(", ")
+          .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
       log.warn(msg.toString());
     }
     return null;
   }
 
-  public List<AccountState> byFullName(String fullName)
-      throws OrmException {
+  public List<AccountState> byFullName(String fullName) throws OrmException {
     return query(AccountPredicates.fullName(fullName));
   }
 
-  public List<AccountState> byWatchedProject(Project.NameKey project)
-      throws OrmException {
+  public List<AccountState> byWatchedProject(Project.NameKey project) throws OrmException {
     return query(AccountPredicates.watchedProject(project));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 477bf16..7d51217 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.Date;
 
 public class AfterPredicate extends TimestampRangeChangePredicate {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
index fd6cbee..0cd76bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-
 import java.sql.Timestamp;
 
 public class AgePredicate extends TimestampRangeChangePredicate {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
index bd7daed..b0fcfd1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
@@ -19,31 +19,31 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
-
 import java.util.Collection;
 import java.util.List;
 
-public class AndChangeSource extends AndSource<ChangeData>
-    implements ChangeDataSource {
+public class AndChangeSource extends AndSource<ChangeData> implements ChangeDataSource {
 
   public AndChangeSource(Collection<Predicate<ChangeData>> that) {
     super(that);
   }
 
-  public AndChangeSource(Predicate<ChangeData> that,
-      IsVisibleToPredicate<ChangeData> isVisibleToPredicate, int start) {
+  public AndChangeSource(
+      Predicate<ChangeData> that,
+      IsVisibleToPredicate<ChangeData> isVisibleToPredicate,
+      int start) {
     super(that, isVisibleToPredicate, start);
   }
 
   @Override
   public boolean hasChange() {
-    return source != null && source instanceof ChangeDataSource
+    return source != null
+        && source instanceof ChangeDataSource
         && ((ChangeDataSource) source).hasChange();
   }
 
   @Override
-  protected List<ChangeData> transformBuffer(List<ChangeData> buffer)
-      throws OrmRuntimeException {
+  protected List<ChangeData> transformBuffer(List<ChangeData> buffer) throws OrmRuntimeException {
     if (!hasChange()) {
       try {
         ChangeData.ensureChangeLoaded(buffer);
@@ -57,8 +57,7 @@
   @Override
   public int compare(Predicate<ChangeData> a, Predicate<ChangeData> b) {
     int cmp = super.compare(a, b);
-    if (cmp == 0 && a instanceof ChangeDataSource
-        && b instanceof ChangeDataSource) {
+    if (cmp == 0 && a instanceof ChangeDataSource && b instanceof ChangeDataSource) {
       ChangeDataSource as = (ChangeDataSource) a;
       ChangeDataSource bs = (ChangeDataSource) b;
       cmp = (as.hasChange() ? 0 : 1) - (bs.hasChange() ? 0 : 1);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
new file mode 100644
index 0000000..38622ed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
@@ -0,0 +1,42 @@
+// 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.query.change;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+
+class AssigneePredicate extends ChangeIndexPredicate {
+  private final Account.Id id;
+
+  AssigneePredicate(Account.Id id) {
+    super(ChangeField.ASSIGNEE, id.toString());
+    this.id = id;
+  }
+
+  @Override
+  public boolean match(final ChangeData object) throws OrmException {
+    if (id.get() == ChangeField.NO_ASSIGNEE) {
+      Account.Id assignee = object.change().getAssignee();
+      return assignee == null;
+    }
+    return id.equals(object.change().getAssignee());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
index ebaaab9..dccd17e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
@@ -19,6 +19,7 @@
 
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
 
 public class AuthorPredicate extends ChangeIndexPredicate {
   AuthorPredicate(String value) {
@@ -27,8 +28,11 @@
 
   @Override
   public boolean match(ChangeData object) throws OrmException {
-    return ChangeField.getAuthorParts(object).contains(
-        getValue().toLowerCase());
+    try {
+      return ChangeField.getAuthorParts(object).contains(getValue().toLowerCase());
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
index f36a1631..9e443c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.Date;
 
 public class BeforePredicate extends TimestampRangeChangePredicate {
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 ba58113..549f889 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
@@ -17,59 +17,75 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.ApprovalsUtil.sortApprovals;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
-import com.google.common.base.Optional;
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Multimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 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.Patch;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 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.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 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.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.StarRef;
 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.patch.PatchList;
+import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -81,23 +97,10 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 public class ChangeData {
   private static final int BATCH_SIZE = 50;
 
-  public static List<Change> asChanges(List<ChangeData> changeDatas)
-      throws OrmException {
+  public static List<Change> asChanges(List<ChangeData> changeDatas) throws OrmException {
     List<Change> result = new ArrayList<>(changeDatas.size());
     for (ChangeData cd : changeDatas) {
       result.add(cd.change());
@@ -106,16 +109,10 @@
   }
 
   public static Map<Change.Id, ChangeData> asMap(List<ChangeData> changes) {
-    Map<Change.Id, ChangeData> result =
-        Maps.newHashMapWithExpectedSize(changes.size());
-    for (ChangeData cd : changes) {
-      result.put(cd.getId(), cd);
-    }
-    return result;
+    return changes.stream().collect(toMap(ChangeData::getId, cd -> cd));
   }
 
-  public static void ensureChangeLoaded(Iterable<ChangeData> changes)
-      throws OrmException {
+  public static void ensureChangeLoaded(Iterable<ChangeData> changes) throws OrmException {
     ChangeData first = Iterables.getFirst(changes, null);
     if (first == null) {
       return;
@@ -135,14 +132,12 @@
     if (missing.isEmpty()) {
       return;
     }
-    for (ChangeNotes notes : first.notesFactory.create(
-        first.db, missing.keySet())) {
+    for (ChangeNotes notes : first.notesFactory.create(first.db, missing.keySet())) {
       missing.get(notes.getChangeId()).change = notes.getChange();
     }
   }
 
-  public static void ensureAllPatchSetsLoaded(Iterable<ChangeData> changes)
-      throws OrmException {
+  public static void ensureAllPatchSetsLoaded(Iterable<ChangeData> changes) throws OrmException {
     ChangeData first = Iterables.getFirst(changes, null);
     if (first == null) {
       return;
@@ -172,8 +167,7 @@
     }
   }
 
-  public static void ensureCurrentPatchSetLoaded(Iterable<ChangeData> changes)
-      throws OrmException {
+  public static void ensureCurrentPatchSetLoaded(Iterable<ChangeData> changes) throws OrmException {
     ChangeData first = Iterables.getFirst(changes, null);
     if (first == null) {
       return;
@@ -230,8 +224,7 @@
     }
   }
 
-  public static void ensureMessagesLoaded(Iterable<ChangeData> changes)
-      throws OrmException {
+  public static void ensureMessagesLoaded(Iterable<ChangeData> changes) throws OrmException {
     ChangeData first = Iterables.getFirst(changes, null);
     if (first == null) {
       return;
@@ -262,8 +255,8 @@
     }
   }
 
-  public static void ensureReviewedByLoadedForOpenChanges(
-      Iterable<ChangeData> changes) throws OrmException {
+  public static void ensureReviewedByLoadedForOpenChanges(Iterable<ChangeData> changes)
+      throws OrmException {
     List<ChangeData> pending = new ArrayList<>();
     for (ChangeData cd : changes) {
       if (cd.reviewedBy == null && cd.change().getStatus().isOpen()) {
@@ -282,8 +275,11 @@
 
   public interface Factory {
     ChangeData create(ReviewDb db, Project.NameKey project, Change.Id id);
+
     ChangeData create(ReviewDb db, Change c);
+
     ChangeData create(ReviewDb db, ChangeNotes cn);
+
     ChangeData create(ReviewDb db, ChangeControl c);
 
     // TODO(dborowitz): Remove when deleting index schemas <27.
@@ -292,21 +288,24 @@
 
   /**
    * Create an instance for testing only.
-   * <p>
-   * Attempting to lazy load data will fail with NPEs. Callers may consider
-   * manually setting fields that can be set.
+   *
+   * <p>Attempting to lazy load data will fail with NPEs. Callers may consider manually setting
+   * fields that can be set.
    *
    * @param id change ID
    * @return instance for testing.
    */
-  public static ChangeData createForTest(Project.NameKey project, Change.Id id,
-      int currentPatchSetId) {
-    ChangeData cd = new ChangeData(null, null, null, null, null, null, null,
-        null, null, null, null, null, null, null, null, project, id);
+  public static ChangeData createForTest(
+      Project.NameKey project, Change.Id id, int currentPatchSetId) {
+    ChangeData cd =
+        new ChangeData(
+            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
+            null, project, id);
     cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
     return cd;
   }
 
+  private boolean lazyLoad = true;
   private final ReviewDb db;
   private final GitRepositoryManager repoManager;
   private final ChangeControl.GenericFactory changeControlFactory;
@@ -316,13 +315,16 @@
   private final ChangeNotes.Factory notesFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final NotesMigration notesMigration;
   private final MergeabilityCache mergeabilityCache;
   private final StarredChangesUtil starredChangesUtil;
   private final Change.Id legacyId;
+  private final Map<SubmitRuleOptions, List<SubmitRecord>> submitRecords =
+      Maps.newLinkedHashMapWithExpectedSize(1);
+
   private Project.NameKey project;
   private Change change;
   private ChangeNotes notes;
@@ -333,26 +335,30 @@
   private ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals;
   private List<PatchSetApproval> currentApprovals;
   private Map<Integer, List<String>> files;
-  private Map<Integer, Optional<PatchList>> patchLists;
-  private Collection<PatchLineComment> publishedComments;
+  private Map<Integer, Optional<DiffSummary>> diffSummaries;
+  private Collection<Comment> publishedComments;
+  private Collection<RobotComment> robotComments;
   private CurrentUser visibleTo;
   private ChangeControl changeControl;
   private List<ChangeMessage> messages;
-  private List<SubmitRecord> submitRecords;
   private Optional<ChangedLines> changedLines;
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
   private Set<String> hashtags;
-  private Set<Account.Id> editsByUser;
+  private Map<Account.Id, Ref> editsByUser;
   private Set<Account.Id> reviewedBy;
-  private Set<Account.Id> draftsByUser;
-  @Deprecated
-  private Set<Account.Id> starredByUser;
-  private ImmutableMultimap<Account.Id, String> stars;
+  private Map<Account.Id, Ref> draftsByUser;
+  private ImmutableListMultimap<Account.Id, String> stars;
+  private StarsOf starsOf;
+  private ImmutableMap<Account.Id, StarRef> starRefs;
   private ReviewerSet reviewers;
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
+  private Integer unresolvedCommentCount;
+
+  private ImmutableList<byte[]> refStates;
+  private ImmutableList<byte[]> refStatePatterns;
 
   @AssistedInject
   private ChangeData(
@@ -364,7 +370,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -382,7 +388,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -402,7 +408,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -419,7 +425,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -440,7 +446,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -457,7 +463,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -479,7 +485,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -496,7 +502,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -519,7 +525,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      PatchLineCommentsUtil plcUtil,
+      CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache,
       NotesMigration notesMigration,
@@ -527,7 +533,8 @@
       @Nullable StarredChangesUtil starredChangesUtil,
       @Assisted ReviewDb db,
       @Assisted Change.Id id) {
-    checkState(!notesMigration.readChanges(),
+    checkState(
+        !notesMigration.readChanges(),
         "do not call createOnlyWhenNoteDbDisabled when NoteDb is enabled");
     this.db = db;
     this.repoManager = repoManager;
@@ -538,7 +545,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.notesMigration = notesMigration;
@@ -548,6 +555,11 @@
     this.project = null;
   }
 
+  public ChangeData setLazyLoad(boolean load) {
+    lazyLoad = load;
+    return this;
+  }
+
   public ReviewDb db() {
     return db;
   }
@@ -568,10 +580,7 @@
 
   public List<String> currentFilePaths() throws OrmException {
     PatchSet ps = currentPatchSet();
-    if (ps == null) {
-      return null;
-    }
-    return filePaths(currentPatchSet);
+    return ps != null ? filePaths(ps) : null;
   }
 
   public List<String> filePaths(PatchSet ps) throws OrmException {
@@ -583,53 +592,37 @@
         return null;
       }
 
-      Optional<PatchList> p = getPatchList(c, ps);
+      Optional<DiffSummary> p = getDiffSummary(c, ps);
       if (!p.isPresent()) {
         List<String> emptyFileList = Collections.emptyList();
-        files.put(ps.getPatchSetId(), emptyFileList);
+        if (lazyLoad) {
+          files.put(ps.getPatchSetId(), emptyFileList);
+        }
         return emptyFileList;
       }
 
-      r = new ArrayList<>(p.get().getPatches().size());
-      for (PatchListEntry e : p.get().getPatches()) {
-        if (Patch.COMMIT_MSG.equals(e.getNewName())) {
-          continue;
-        }
-        switch (e.getChangeType()) {
-          case ADDED:
-          case MODIFIED:
-          case DELETED:
-          case COPIED:
-          case REWRITE:
-            r.add(e.getNewName());
-            break;
-
-          case RENAMED:
-            r.add(e.getOldName());
-            r.add(e.getNewName());
-            break;
-        }
-      }
-      Collections.sort(r);
-      r = Collections.unmodifiableList(r);
+      r = p.get().getPaths();
       files.put(psId, r);
     }
     return r;
   }
 
-  private Optional<PatchList> getPatchList(Change c, PatchSet ps) {
+  private Optional<DiffSummary> getDiffSummary(Change c, PatchSet ps) {
     Integer psId = ps.getId().get();
-    if (patchLists == null) {
-      patchLists = new HashMap<>();
+    if (diffSummaries == null) {
+      diffSummaries = new HashMap<>();
     }
-    Optional<PatchList> r = patchLists.get(psId);
+    Optional<DiffSummary> r = diffSummaries.get(psId);
     if (r == null) {
-      try {
-        r = Optional.of(patchListCache.get(c, ps));
-      } catch (PatchListNotAvailableException e) {
-        r = Optional.absent();
+      if (!lazyLoad) {
+        return Optional.empty();
       }
-      patchLists.put(psId, r);
+      try {
+        r = Optional.of(patchListCache.getDiffSummary(c, ps));
+      } catch (PatchListNotAvailableException e) {
+        r = Optional.empty();
+      }
+      diffSummaries.put(psId, r);
     }
     return r;
   }
@@ -637,22 +630,24 @@
   private Optional<ChangedLines> computeChangedLines() throws OrmException {
     Change c = change();
     if (c == null) {
-      return Optional.absent();
+      return Optional.empty();
     }
     PatchSet ps = currentPatchSet();
     if (ps == null) {
-      return Optional.absent();
+      return Optional.empty();
     }
-    Optional<PatchList> p = getPatchList(c, ps);
-    if (!p.isPresent()) {
-      return Optional.absent();
+    Optional<DiffSummary> ds = getDiffSummary(c, ps);
+    if (ds.isPresent()) {
+      return Optional.of(ds.get().getChangedLines());
     }
-    return Optional.of(
-        new ChangedLines(p.get().getInsertions(), p.get().getDeletions()));
+    return Optional.empty();
   }
 
   public Optional<ChangedLines> changedLines() throws OrmException {
     if (changedLines == null) {
+      if (!lazyLoad) {
+        return Optional.empty();
+      }
       changedLines = computeChangedLines();
     }
     return changedLines;
@@ -663,7 +658,7 @@
   }
 
   public void setNoChangedLines() {
-    changedLines = Optional.absent();
+    changedLines = Optional.empty();
   }
 
   public Change.Id getId() {
@@ -672,8 +667,9 @@
 
   public Project.NameKey project() throws OrmException {
     if (project == null) {
-      checkState(!notesMigration.readChanges(), "should not have created "
-          + " ChangeData without a project when NoteDb is enabled");
+      checkState(
+          !notesMigration.readChanges(),
+          "should not have created  ChangeData without a project when NoteDb is enabled");
       project = change().getProject();
     }
     return project;
@@ -691,8 +687,7 @@
     if (changeControl == null) {
       Change c = change();
       try {
-        changeControl = changeControlFactory.controlFor(
-            db, c, userFactory.create(c.getOwner()));
+        changeControl = changeControlFactory.controlFor(db, c, userFactory.create(c.getOwner()));
       } catch (NoSuchChangeException e) {
         throw new OrmException(e);
       }
@@ -703,21 +698,16 @@
   public ChangeControl changeControl(CurrentUser user) throws OrmException {
     if (changeControl != null) {
       CurrentUser oldUser = user;
-      // TODO(dborowitz): This is a hack; general CurrentUser equality would be
-      // better.
-      if (user.isIdentifiedUser() && oldUser.isIdentifiedUser()
-          && user.getAccountId().equals(oldUser.getAccountId())) {
+      if (sameUser(user, oldUser)) {
         return changeControl;
       }
-      throw new IllegalStateException(
-          "user already specified: " + changeControl.getUser());
+      throw new IllegalStateException("user already specified: " + changeControl.getUser());
     }
     try {
       if (change != null) {
         changeControl = changeControlFactory.controlFor(db, change, user);
       } else {
-        changeControl =
-            changeControlFactory.controlFor(db, project(), legacyId, user);
+        changeControl = changeControlFactory.controlFor(db, project(), legacyId, user);
       }
     } catch (NoSuchChangeException e) {
       throw new OrmException(e);
@@ -725,13 +715,26 @@
     return changeControl;
   }
 
+  private static boolean sameUser(CurrentUser a, CurrentUser b) {
+    // TODO(dborowitz): This is a hack; general CurrentUser equality would be
+    // better.
+    if (a.isInternalUser() && b.isInternalUser()) {
+      return true;
+    } else if (a instanceof AnonymousUser && b instanceof AnonymousUser) {
+      return true;
+    } else if (a.isIdentifiedUser() && b.isIdentifiedUser()) {
+      return a.getAccountId().equals(b.getAccountId());
+    }
+    return false;
+  }
+
   void cacheVisibleTo(ChangeControl ctl) {
     visibleTo = ctl.getUser();
     changeControl = ctl;
   }
 
   public Change change() throws OrmException {
-    if (change == null) {
+    if (change == null && lazyLoad) {
       reloadChange();
     }
     return change;
@@ -742,20 +745,21 @@
   }
 
   public Change reloadChange() throws OrmException {
-    if (project == null) {
-      notes = notesFactory.createFromIdOnlyWhenNoteDbDisabled(db, legacyId);
-    } else {
-      notes = notesFactory.create(db, project, legacyId);
+    try {
+      notes = notesFactory.createChecked(db, project, legacyId);
+    } catch (NoSuchChangeException e) {
+      throw new OrmException("Unable to load change " + legacyId, e);
     }
     change = notes.getChange();
-    if (change == null) {
-      throw new OrmException("Unable to load change " + legacyId);
-    }
+    setPatchSets(null);
     return change;
   }
 
   public ChangeNotes notes() throws OrmException {
     if (notes == null) {
+      if (!lazyLoad) {
+        throw new OrmException("ChangeNotes not available, lazyLoad = false");
+      }
       notes = notesFactory.create(db, project(), legacyId);
     }
     return notes;
@@ -777,15 +781,26 @@
     return currentPatchSet;
   }
 
-  public List<PatchSetApproval> currentApprovals()
-      throws OrmException {
+  public List<PatchSetApproval> currentApprovals() throws OrmException {
     if (currentApprovals == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
       Change c = change();
       if (c == null) {
         currentApprovals = Collections.emptyList();
       } else {
-        currentApprovals = ImmutableList.copyOf(approvalsUtil.byPatchSet(
-            db, changeControl(), c.currentPatchSetId()));
+        try {
+          currentApprovals =
+              ImmutableList.copyOf(
+                  approvalsUtil.byPatchSet(db, changeControl(), c.currentPatchSetId()));
+        } catch (OrmException e) {
+          if (e.getCause() instanceof NoSuchChangeException) {
+            currentApprovals = Collections.emptyList();
+          } else {
+            throw e;
+          }
+        }
       }
     }
     return currentApprovals;
@@ -831,9 +846,9 @@
     return committer;
   }
 
-  private boolean loadCommitData() throws OrmException,
-      RepositoryNotFoundException, IOException, MissingObjectException,
-      IncorrectObjectTypeException {
+  private boolean loadCommitData()
+      throws OrmException, RepositoryNotFoundException, IOException, MissingObjectException,
+          IncorrectObjectTypeException {
     PatchSet ps = currentPatchSet();
     if (ps == null) {
       return false;
@@ -866,17 +881,15 @@
    * @throws OrmException an error occurred reading the database.
    */
   public Collection<PatchSet> visiblePatchSets() throws OrmException {
-    Predicate<PatchSet> predicate = new Predicate<PatchSet>() {
-      @Override
-      public boolean apply(PatchSet input) {
-        try {
-          return changeControl().isPatchVisible(input, db);
-        } catch (OrmException e) {
-          return false;
-        }
-      }
-    };
-    return FluentIterable.from(patchSets()).filter(predicate).toList();
+    Predicate<? super PatchSet> predicate =
+        ps -> {
+          try {
+            return changeControl().isPatchVisible(ps, db);
+          } catch (OrmException e) {
+            return false;
+          }
+        };
+    return patchSets().stream().filter(predicate).collect(toList());
   }
 
   public void setPatchSets(Collection<PatchSet> patchSets) {
@@ -901,13 +914,15 @@
   }
 
   /**
-   * @return all patch set approvals for the change, keyed by ID, ordered by
-   *     timestamp within each patch set.
+   * @return all patch set approvals for the change, keyed by ID, ordered by timestamp within each
+   *     patch set.
    * @throws OrmException an error occurred reading the database.
    */
-  public ListMultimap<PatchSet.Id, PatchSetApproval> approvals()
-      throws OrmException {
+  public ListMultimap<PatchSet.Id, PatchSetApproval> approvals() throws OrmException {
     if (allApprovals == null) {
+      if (!lazyLoad) {
+        return ImmutableListMultimap.of();
+      }
       allApprovals = approvalsUtil.byChange(db, notes());
     }
     return allApprovals;
@@ -917,18 +932,15 @@
    * @return The submit ('SUBM') approval label
    * @throws OrmException an error occurred reading the database.
    */
-  public Optional<PatchSetApproval> getSubmitApproval()
-    throws OrmException {
-    for (PatchSetApproval psa : currentApprovals()) {
-      if (psa.isLegacySubmit()) {
-        return Optional.fromNullable(psa);
-      }
-    }
-    return Optional.absent();
+  public Optional<PatchSetApproval> getSubmitApproval() throws OrmException {
+    return currentApprovals().stream().filter(PatchSetApproval::isLegacySubmit).findFirst();
   }
 
   public ReviewerSet reviewers() throws OrmException {
     if (reviewers == null) {
+      if (!lazyLoad) {
+        return ReviewerSet.empty();
+      }
       reviewers = approvalsUtil.getReviewers(notes(), approvals().values());
     }
     return reviewers;
@@ -944,6 +956,9 @@
 
   public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
     if (reviewerUpdates == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
       reviewerUpdates = approvalsUtil.getReviewerUpdates(notes());
     }
     return reviewerUpdates;
@@ -957,28 +972,76 @@
     return reviewerUpdates;
   }
 
-  public Collection<PatchLineComment> publishedComments()
-      throws OrmException {
+  public Collection<Comment> publishedComments() throws OrmException {
     if (publishedComments == null) {
-      publishedComments = plcUtil.publishedByChange(db, notes());
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      publishedComments = commentsUtil.publishedByChange(db, notes());
     }
     return publishedComments;
   }
 
-  public List<ChangeMessage> messages()
-      throws OrmException {
+  public Collection<RobotComment> robotComments() throws OrmException {
+    if (robotComments == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      robotComments = commentsUtil.robotCommentsByChange(notes());
+    }
+    return robotComments;
+  }
+
+  public Integer unresolvedCommentCount() throws OrmException {
+    if (unresolvedCommentCount == null) {
+      if (!lazyLoad) {
+        return null;
+      }
+
+      List<Comment> comments =
+          Stream.concat(publishedComments().stream(), robotComments().stream()).collect(toList());
+      Set<String> nonLeafSet = comments.stream().map(c -> c.parentUuid).collect(toSet());
+
+      Long count =
+          comments.stream().filter(c -> (c.unresolved && !nonLeafSet.contains(c.key.uuid))).count();
+      unresolvedCommentCount = count.intValue();
+    }
+    return unresolvedCommentCount;
+  }
+
+  public void setUnresolvedCommentCount(Integer count) {
+    this.unresolvedCommentCount = count;
+  }
+
+  public List<ChangeMessage> messages() throws OrmException {
     if (messages == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
       messages = cmUtil.byChange(db, notes());
     }
     return messages;
   }
 
-  public void setSubmitRecords(List<SubmitRecord> records) {
-    submitRecords = records;
+  public List<SubmitRecord> submitRecords(SubmitRuleOptions options) throws OrmException {
+    List<SubmitRecord> records = submitRecords.get(options);
+    if (records == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
+      }
+      records = new SubmitRuleEvaluator(this).setOptions(options).evaluate();
+      submitRecords.put(options, records);
+    }
+    return records;
   }
 
-  public List<SubmitRecord> getSubmitRecords() {
-    return submitRecords;
+  @Nullable
+  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
+    return submitRecords.get(options);
+  }
+
+  public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
+    submitRecords.put(options, records);
   }
 
   public SubmitTypeRecord submitTypeRecord() throws OrmException {
@@ -1000,11 +1063,25 @@
       }
       if (c.getStatus() == Change.Status.MERGED) {
         mergeable = true;
+      } else if (c.getStatus() == Change.Status.ABANDONED) {
+        return null;
       } else {
-        PatchSet ps = currentPatchSet();
-        if (ps == null || !changeControl().isPatchVisible(ps, db)) {
+        if (!lazyLoad) {
           return null;
         }
+        PatchSet ps = currentPatchSet();
+        try {
+          if (ps == null
+              || (!changeControl().isOwner() && !changeControl().isPatchVisible(ps, db))) {
+            return null;
+          }
+        } catch (OrmException e) {
+          if (e.getCause() instanceof NoSuchChangeException) {
+            return null;
+          }
+          throw e;
+        }
+
         try (Repository repo = repoManager.openRepository(project())) {
           Ref ref = repo.getRefDatabase().exactRef(c.getDest().get());
           SubmitTypeRecord str = submitTypeRecord();
@@ -1013,12 +1090,16 @@
             // No need to log, as SubmitRuleEvaluator already did it for us.
             return false;
           }
-          String mergeStrategy = mergeUtilFactory
-              .create(projectCache.get(project()))
-              .mergeStrategyName();
-          mergeable = mergeabilityCache.get(
-              ObjectId.fromString(ps.getRevision().get()),
-              ref, str.type, mergeStrategy, c.getDest(), repo);
+          String mergeStrategy =
+              mergeUtilFactory.create(projectCache.get(project())).mergeStrategyName();
+          mergeable =
+              mergeabilityCache.get(
+                  ObjectId.fromString(ps.getRevision().get()),
+                  ref,
+                  str.type,
+                  mergeStrategy,
+                  c.getDest(),
+                  repo);
         } catch (IOException e) {
           throw new OrmException(e);
         }
@@ -1028,18 +1109,25 @@
   }
 
   public Set<Account.Id> editsByUser() throws OrmException {
+    return editRefs().keySet();
+  }
+
+  public Map<Account.Id, Ref> editRefs() throws OrmException {
     if (editsByUser == null) {
+      if (!lazyLoad) {
+        return Collections.emptyMap();
+      }
       Change c = change();
       if (c == null) {
-        return Collections.emptySet();
+        return Collections.emptyMap();
       }
-      editsByUser = new HashSet<>();
+      editsByUser = new HashMap<>();
       Change.Id id = checkNotNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
-        for (String ref
-            : repo.getRefDatabase().getRefs(RefNames.REFS_USERS).keySet()) {
-          if (id.equals(Change.Id.fromEditRefPart(ref))) {
-            editsByUser.add(Account.Id.fromRefPart(ref));
+        for (Map.Entry<String, Ref> e :
+            repo.getRefDatabase().getRefs(RefNames.REFS_USERS).entrySet()) {
+          if (id.equals(Change.Id.fromEditRefPart(e.getKey()))) {
+            editsByUser.put(Account.Id.fromRefPart(e.getKey()), e.getValue());
           }
         }
       } catch (IOException e) {
@@ -1050,14 +1138,38 @@
   }
 
   public Set<Account.Id> draftsByUser() throws OrmException {
+    return draftRefs().keySet();
+  }
+
+  public Map<Account.Id, Ref> draftRefs() throws OrmException {
     if (draftsByUser == null) {
+      if (!lazyLoad) {
+        return Collections.emptyMap();
+      }
       Change c = change();
       if (c == null) {
-        return Collections.emptySet();
+        return Collections.emptyMap();
       }
-      draftsByUser = new HashSet<>();
-      for (PatchLineComment sc : plcUtil.draftByChange(db, notes())) {
-        draftsByUser.add(sc.getAuthor());
+
+      draftsByUser = new HashMap<>();
+      if (notesMigration.readChanges()) {
+        for (Ref ref : commentsUtil.getDraftRefs(notes().getChangeId())) {
+          Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+          if (account != null
+              // Double-check that any drafts exist for this user after
+              // filtering out zombies. If some but not all drafts in the ref
+              // were zombies, the returned Ref still includes those zombies;
+              // this is suboptimal, but is ok for the purposes of
+              // draftsByUser(), and easier than trying to rebuild the change at
+              // this point.
+              && !notes().getDraftComments(account, ref).isEmpty()) {
+            draftsByUser.put(account, ref);
+          }
+        }
+      } else {
+        for (Comment sc : commentsUtil.draftByChange(db, notes())) {
+          draftsByUser.put(sc.author.getId(), null);
+        }
       }
     }
     return draftsByUser;
@@ -1065,6 +1177,9 @@
 
   public Set<Account.Id> reviewedBy() throws OrmException {
     if (reviewedBy == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
       Change c = change();
       if (c == null) {
         return Collections.emptySet();
@@ -1094,6 +1209,9 @@
 
   public Set<String> hashtags() throws OrmException {
     if (hashtags == null) {
+      if (!lazyLoad) {
+        return Collections.emptySet();
+      }
       hashtags = notes().getHashtags();
     }
     return hashtags;
@@ -1103,40 +1221,51 @@
     this.hashtags = hashtags;
   }
 
-  @Deprecated
-  public Set<Account.Id> starredBy() throws OrmException {
-    if (starredByUser == null) {
-      starredByUser = checkNotNull(starredChangesUtil).byChange(
-          legacyId, StarredChangesUtil.DEFAULT_LABEL);
-    }
-    return starredByUser;
-  }
-
-  @Deprecated
-  public void setStarredBy(Set<Account.Id> starredByUser) {
-    this.starredByUser = starredByUser;
-  }
-
-  public ImmutableMultimap<Account.Id, String> stars() throws OrmException {
+  public ImmutableListMultimap<Account.Id, String> stars() throws OrmException {
     if (stars == null) {
-      stars = checkNotNull(starredChangesUtil).byChange(legacyId);
+      if (!lazyLoad) {
+        return ImmutableListMultimap.of();
+      }
+      ImmutableListMultimap.Builder<Account.Id, String> b = ImmutableListMultimap.builder();
+      for (Map.Entry<Account.Id, StarRef> e : starRefs().entrySet()) {
+        b.putAll(e.getKey(), e.getValue().labels());
+      }
+      return b.build();
     }
     return stars;
   }
 
-  public void setStars(Multimap<Account.Id, String> stars) {
-    this.stars = ImmutableMultimap.copyOf(stars);
+  public void setStars(ListMultimap<Account.Id, String> stars) {
+    this.stars = ImmutableListMultimap.copyOf(stars);
   }
 
-  @AutoValue
-  abstract static class ReviewedByEvent {
-    private static ReviewedByEvent create(ChangeMessage msg) {
-      return new AutoValue_ChangeData_ReviewedByEvent(
-          msg.getAuthor(), msg.getWrittenOn());
+  public ImmutableMap<Account.Id, StarRef> starRefs() throws OrmException {
+    if (starRefs == null) {
+      if (!lazyLoad) {
+        return ImmutableMap.of();
+      }
+      starRefs = checkNotNull(starredChangesUtil).byChange(legacyId);
     }
+    return starRefs;
+  }
 
-    public abstract Account.Id author();
-    public abstract Timestamp ts();
+  public Set<String> stars(Account.Id accountId) throws OrmException {
+    if (starsOf != null) {
+      if (!starsOf.accountId().equals(accountId)) {
+        starsOf = null;
+      }
+    }
+    if (starsOf == null) {
+      if (stars != null) {
+        starsOf = StarsOf.create(accountId, stars.get(accountId));
+      } else {
+        if (!lazyLoad) {
+          return ImmutableSet.of();
+        }
+        starsOf = StarsOf.create(accountId, starredChangesUtil.getLabels(accountId, legacyId));
+      }
+    }
+    return starsOf.stars();
   }
 
   @Override
@@ -1154,9 +1283,47 @@
     public final int insertions;
     public final int deletions;
 
-    ChangedLines(int insertions, int deletions) {
+    public ChangedLines(int insertions, int deletions) {
       this.insertions = insertions;
       this.deletions = deletions;
     }
   }
+
+  public ImmutableList<byte[]> getRefStates() {
+    return refStates;
+  }
+
+  public void setRefStates(Iterable<byte[]> refStates) {
+    this.refStates = ImmutableList.copyOf(refStates);
+  }
+
+  public ImmutableList<byte[]> getRefStatePatterns() {
+    return refStatePatterns;
+  }
+
+  public void setRefStatePatterns(Iterable<byte[]> refStatePatterns) {
+    this.refStatePatterns = ImmutableList.copyOf(refStatePatterns);
+  }
+
+  @AutoValue
+  abstract static class ReviewedByEvent {
+    private static ReviewedByEvent create(ChangeMessage msg) {
+      return new AutoValue_ChangeData_ReviewedByEvent(msg.getAuthor(), msg.getWrittenOn());
+    }
+
+    public abstract Account.Id author();
+
+    public abstract Timestamp ts();
+  }
+
+  @AutoValue
+  abstract static class StarsOf {
+    private static StarsOf create(Account.Id accountId, Iterable<String> stars) {
+      return new AutoValue_ChangeData_StarsOf(accountId, ImmutableSortedSet.copyOf(stars));
+    }
+
+    public abstract Account.Id accountId();
+
+    public abstract ImmutableSortedSet<String> stars();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index 80951fd..0604f8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -24,8 +24,7 @@
     super(def, value);
   }
 
-  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name,
-      String value) {
+  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 303c9f8..8db62a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -25,25 +25,16 @@
 import com.google.inject.Provider;
 
 class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
-  private static String describe(CurrentUser user) {
-    if (user.isIdentifiedUser()) {
-      return user.getAccountId().toString();
-    }
-    if (user instanceof SingleGroupUser) {
-      return "group:" + user.getEffectiveGroups().getKnownGroups() //
-          .iterator().next().toString();
-    }
-    return user.toString();
-  }
-
   private final Provider<ReviewDb> db;
   private final ChangeNotes.Factory notesFactory;
   private final ChangeControl.GenericFactory changeControl;
   private final CurrentUser user;
 
-  ChangeIsVisibleToPredicate(Provider<ReviewDb> db,
+  ChangeIsVisibleToPredicate(
+      Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
-      ChangeControl.GenericFactory changeControlFactory, CurrentUser user) {
+      ChangeControl.GenericFactory changeControlFactory,
+      CurrentUser user) {
     super(ChangeQueryBuilder.FIELD_VISIBLETO, describe(user));
     this.db = db;
     this.notesFactory = notesFactory;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
index 6bec598..242592e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.server.query.Matchable;
 import com.google.gerrit.server.query.OperatorPredicate;
 
-public abstract class ChangeOperatorPredicate extends
-    OperatorPredicate<ChangeData> implements Matchable<ChangeData> {
+public abstract class ChangeOperatorPredicate extends OperatorPredicate<ChangeData>
+    implements Matchable<ChangeData> {
 
   protected ChangeOperatorPredicate(String name, String value) {
     super(name, value);
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 b3fc729..de677c6 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
@@ -16,17 +16,19 @@
 
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
-import com.google.common.base.Optional;
+import com.google.common.base.Enums;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.errors.NotSignedInException;
-import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -34,9 +36,9 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
@@ -61,6 +63,7 @@
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ListChildProjects;
@@ -69,44 +72,57 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryBuilder;
 import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.QueryRequiresAuthException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
 
-/**
- * Parses a query string meant to be applied to change objects.
- */
+/** Parses a query string meant to be applied to change objects. */
 public class ChangeQueryBuilder extends QueryBuilder<ChangeData> {
-  public interface ChangeOperatorFactory
-      extends OperatorFactory<ChangeData, ChangeQueryBuilder> {
+  public interface ChangeOperatorFactory extends OperatorFactory<ChangeData, ChangeQueryBuilder> {}
+
+  /**
+   * Converts a operand (operator value) passed to an operator into a {@link Predicate}.
+   *
+   * <p>Register a ChangeOperandFactory in a config Module like this (note, for an example we are
+   * using the has predicate, when other predicate plugin operands are created they can be
+   * registered in a similar manner):
+   *
+   * <p>bind(ChangeHasOperandFactory.class) .annotatedWith(Exports.named("your has operand"))
+   * .to(YourClass.class);
+   */
+  private interface ChangeOperandFactory {
+    Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException;
   }
 
+  public interface ChangeHasOperandFactory extends ChangeOperandFactory {}
+
   private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
   private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
-  private static final Pattern DEF_CHANGE = Pattern.compile(
-      "^(?:[1-9][0-9]*|(?:[^~]+~[^~]+~)?[iI][0-9a-f]{4,}.*)$");
+  private static final Pattern DEF_CHANGE =
+      Pattern.compile("^(?:[1-9][0-9]*|(?:[^~]+~[^~]+~)?[iI][0-9a-f]{4,}.*)$");
 
   // NOTE: As new search operations are added, please keep the
   // SearchSuggestOracle up to date.
 
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
+  public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
   public static final String FIELD_BEFORE = "before";
   public static final String FIELD_CHANGE = "change";
@@ -147,55 +163,61 @@
   public static final String FIELD_STATUS = "status";
   public static final String FIELD_SUBMISSIONID = "submissionid";
   public static final String FIELD_TR = "tr";
+  public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
   public static final String FIELD_VISIBLETO = "visibleto";
   public static final String FIELD_WATCHEDBY = "watchedby";
 
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
-
+  public static final String ARG_ID_OWNER = "owner";
+  public static final Account.Id OWNER_ACCOUNT_ID = new Account.Id(0);
 
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
 
   @VisibleForTesting
   public static class Arguments {
-    final Provider<ReviewDb> db;
-    final Provider<InternalChangeQuery> queryProvider;
-    final ChangeIndexRewriter rewriter;
-    final DynamicMap<ChangeOperatorFactory> opFactories;
-    final IdentifiedUser.GenericFactory userFactory;
-    final CapabilityControl.Factory capabilityControlFactory;
-    final ChangeControl.GenericFactory changeControlGenericFactory;
-    final ChangeNotes.Factory notesFactory;
-    final ChangeData.Factory changeDataFactory;
-    final FieldDef.FillArgs fillArgs;
-    final PatchLineCommentsUtil plcUtil;
+    final AccountCache accountCache;
     final AccountResolver accountResolver;
-    final GroupBackend groupBackend;
     final AllProjectsName allProjectsName;
     final AllUsersName allUsersName;
-    final PatchListCache patchListCache;
-    final GitRepositoryManager repoManager;
-    final ProjectCache projectCache;
-    final Provider<ListChildProjects> listChildProjects;
-    final SubmitDryRun submitDryRun;
-    final ConflictsCache conflictsCache;
-    final TrackingFooters trackingFooters;
+    final CapabilityControl.Factory capabilityControlFactory;
+    final ChangeControl.GenericFactory changeControlGenericFactory;
+    final ChangeData.Factory changeDataFactory;
     final ChangeIndex index;
+    final ChangeIndexRewriter rewriter;
+    final ChangeNotes.Factory notesFactory;
+    final CommentsUtil commentsUtil;
+    final ConflictsCache conflictsCache;
+    final DynamicMap<ChangeHasOperandFactory> hasOperands;
+    final DynamicMap<ChangeOperatorFactory> opFactories;
+    final FieldDef.FillArgs fillArgs;
+    final GitRepositoryManager repoManager;
+    final GroupBackend groupBackend;
+    final IdentifiedUser.GenericFactory userFactory;
     final IndexConfig indexConfig;
+    final NotesMigration notesMigration;
+    final PatchListCache patchListCache;
+    final ProjectCache projectCache;
+    final Provider<InternalChangeQuery> queryProvider;
+    final Provider<ListChildProjects> listChildProjects;
     final Provider<ListMembers> listMembers;
+    final Provider<ReviewDb> db;
     final StarredChangesUtil starredChangesUtil;
-    final AccountCache accountCache;
+    final SubmitDryRun submitDryRun;
+    final TrackingFooters trackingFooters;
     final boolean allowsDrafts;
 
     private final Provider<CurrentUser> self;
 
     @Inject
     @VisibleForTesting
-    public Arguments(Provider<ReviewDb> db,
+    public Arguments(
+        Provider<ReviewDb> db,
         Provider<InternalChangeQuery> queryProvider,
         ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
+        DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         CapabilityControl.Factory capabilityControlFactory,
@@ -203,7 +225,7 @@
         ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
         FieldDef.FillArgs fillArgs,
-        PatchLineCommentsUtil plcUtil,
+        CommentsUtil commentsUtil,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
         AllProjectsName allProjectsName,
@@ -220,15 +242,40 @@
         Provider<ListMembers> listMembers,
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
-        @GerritServerConfig Config cfg) {
-      this(db, queryProvider, rewriter, opFactories, userFactory, self,
-          capabilityControlFactory, changeControlGenericFactory, notesFactory,
-          changeDataFactory, fillArgs, plcUtil, accountResolver, groupBackend,
-          allProjectsName, allUsersName, patchListCache, repoManager,
-          projectCache, listChildProjects, submitDryRun, conflictsCache,
-          trackingFooters, indexes != null ? indexes.getSearchIndex() : null,
-          indexConfig, listMembers, starredChangesUtil, accountCache,
-          cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true));
+        @GerritServerConfig Config cfg,
+        NotesMigration notesMigration) {
+      this(
+          db,
+          queryProvider,
+          rewriter,
+          opFactories,
+          hasOperands,
+          userFactory,
+          self,
+          capabilityControlFactory,
+          changeControlGenericFactory,
+          notesFactory,
+          changeDataFactory,
+          fillArgs,
+          commentsUtil,
+          accountResolver,
+          groupBackend,
+          allProjectsName,
+          allUsersName,
+          patchListCache,
+          repoManager,
+          projectCache,
+          listChildProjects,
+          submitDryRun,
+          conflictsCache,
+          trackingFooters,
+          indexes != null ? indexes.getSearchIndex() : null,
+          indexConfig,
+          listMembers,
+          starredChangesUtil,
+          accountCache,
+          cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true),
+          notesMigration);
     }
 
     private Arguments(
@@ -236,6 +283,7 @@
         Provider<InternalChangeQuery> queryProvider,
         ChangeIndexRewriter rewriter,
         DynamicMap<ChangeOperatorFactory> opFactories,
+        DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
         CapabilityControl.Factory capabilityControlFactory,
@@ -243,7 +291,7 @@
         ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
         FieldDef.FillArgs fillArgs,
-        PatchLineCommentsUtil plcUtil,
+        CommentsUtil commentsUtil,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
         AllProjectsName allProjectsName,
@@ -260,47 +308,74 @@
         Provider<ListMembers> listMembers,
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
-        boolean allowsDrafts) {
-     this.db = db;
-     this.queryProvider = queryProvider;
-     this.rewriter = rewriter;
-     this.opFactories = opFactories;
-     this.userFactory = userFactory;
-     this.self = self;
-     this.capabilityControlFactory = capabilityControlFactory;
-     this.notesFactory = notesFactory;
-     this.changeControlGenericFactory = changeControlGenericFactory;
-     this.changeDataFactory = changeDataFactory;
-     this.fillArgs = fillArgs;
-     this.plcUtil = plcUtil;
-     this.accountResolver = accountResolver;
-     this.groupBackend = groupBackend;
-     this.allProjectsName = allProjectsName;
-     this.allUsersName = allUsersName;
-     this.patchListCache = patchListCache;
-     this.repoManager = repoManager;
-     this.projectCache = projectCache;
-     this.listChildProjects = listChildProjects;
-     this.submitDryRun = submitDryRun;
-     this.conflictsCache = conflictsCache;
-     this.trackingFooters = trackingFooters;
-     this.index = index;
-     this.indexConfig = indexConfig;
-     this.listMembers = listMembers;
-     this.starredChangesUtil = starredChangesUtil;
-     this.accountCache = accountCache;
-     this.allowsDrafts = allowsDrafts;
+        boolean allowsDrafts,
+        NotesMigration notesMigration) {
+      this.db = db;
+      this.queryProvider = queryProvider;
+      this.rewriter = rewriter;
+      this.opFactories = opFactories;
+      this.userFactory = userFactory;
+      this.self = self;
+      this.capabilityControlFactory = capabilityControlFactory;
+      this.notesFactory = notesFactory;
+      this.changeControlGenericFactory = changeControlGenericFactory;
+      this.changeDataFactory = changeDataFactory;
+      this.fillArgs = fillArgs;
+      this.commentsUtil = commentsUtil;
+      this.accountResolver = accountResolver;
+      this.groupBackend = groupBackend;
+      this.allProjectsName = allProjectsName;
+      this.allUsersName = allUsersName;
+      this.patchListCache = patchListCache;
+      this.repoManager = repoManager;
+      this.projectCache = projectCache;
+      this.listChildProjects = listChildProjects;
+      this.submitDryRun = submitDryRun;
+      this.conflictsCache = conflictsCache;
+      this.trackingFooters = trackingFooters;
+      this.index = index;
+      this.indexConfig = indexConfig;
+      this.listMembers = listMembers;
+      this.starredChangesUtil = starredChangesUtil;
+      this.accountCache = accountCache;
+      this.allowsDrafts = allowsDrafts;
+      this.hasOperands = hasOperands;
+      this.notesMigration = notesMigration;
     }
 
     Arguments asUser(CurrentUser otherUser) {
-      return new Arguments(db, queryProvider, rewriter, opFactories, userFactory,
+      return new Arguments(
+          db,
+          queryProvider,
+          rewriter,
+          opFactories,
+          hasOperands,
+          userFactory,
           Providers.of(otherUser),
-          capabilityControlFactory, changeControlGenericFactory, notesFactory,
-          changeDataFactory, fillArgs, plcUtil, accountResolver, groupBackend,
-          allProjectsName, allUsersName, patchListCache, repoManager,
-          projectCache, listChildProjects, submitDryRun,
-          conflictsCache, trackingFooters, index, indexConfig, listMembers,
-          starredChangesUtil, accountCache, allowsDrafts);
+          capabilityControlFactory,
+          changeControlGenericFactory,
+          notesFactory,
+          changeDataFactory,
+          fillArgs,
+          commentsUtil,
+          accountResolver,
+          groupBackend,
+          allProjectsName,
+          allUsersName,
+          patchListCache,
+          repoManager,
+          projectCache,
+          listChildProjects,
+          submitDryRun,
+          conflictsCache,
+          trackingFooters,
+          index,
+          indexConfig,
+          listMembers,
+          starredChangesUtil,
+          accountCache,
+          allowsDrafts,
+          notesMigration);
     }
 
     Arguments asUser(Account.Id otherId) {
@@ -315,23 +390,23 @@
       return asUser(userFactory.create(otherId));
     }
 
-    IdentifiedUser getIdentifiedUser() throws QueryParseException {
+    IdentifiedUser getIdentifiedUser() throws QueryRequiresAuthException {
       try {
         CurrentUser u = getUser();
         if (u.isIdentifiedUser()) {
           return u.asIdentifiedUser();
         }
-        throw new QueryParseException(NotSignedInException.MESSAGE);
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE);
       } catch (ProvisionException e) {
-        throw new QueryParseException(NotSignedInException.MESSAGE, e);
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE, e);
       }
     }
 
-    CurrentUser getUser() throws QueryParseException {
+    CurrentUser getUser() throws QueryRequiresAuthException {
       try {
         return self.get();
       } catch (ProvisionException e) {
-        throw new QueryParseException(NotSignedInException.MESSAGE, e);
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE, e);
       }
     }
 
@@ -351,8 +426,7 @@
 
   @VisibleForTesting
   protected ChangeQueryBuilder(
-      Definition<ChangeData, ? extends QueryBuilder<ChangeData>> def,
-      Arguments args) {
+      Definition<ChangeData, ? extends QueryBuilder<ChangeData>> def, Arguments args) {
     super(def);
     this.args = args;
   }
@@ -364,6 +438,10 @@
     }
   }
 
+  public Arguments getArgs() {
+    return args;
+  }
+
   public ChangeQueryBuilder asUser(CurrentUser user) {
     return new ChangeQueryBuilder(builderDef, args.asUser(user));
   }
@@ -417,7 +495,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> status(String statusName) {
+  public Predicate<ChangeData> status(String statusName) throws QueryParseException {
     if ("reviewed".equalsIgnoreCase(statusName)) {
       return IsReviewedPredicate.create();
     }
@@ -445,6 +523,20 @@
     if ("edit".equalsIgnoreCase(value)) {
       return new EditByPredicate(self());
     }
+
+    if ("unresolved".equalsIgnoreCase(value)) {
+      return new IsUnresolvedPredicate();
+    }
+
+    // for plugins the value will be operandName_pluginName
+    String[] names = value.split("_");
+    if (names.length == 2) {
+      ChangeHasOperandFactory op = args.hasOperands.get(names[1], names[0]);
+      if (op != null) {
+        return op.create(this);
+      }
+    }
+
     throw new IllegalArgumentException();
   }
 
@@ -471,13 +563,29 @@
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
-      return ReviewerPredicate.create(args, self());
+      return ReviewerPredicate.reviewer(args, self());
+    }
+
+    if ("cc".equalsIgnoreCase(value)) {
+      return ReviewerPredicate.cc(args, self());
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
       return new IsMergeablePredicate(args.fillArgs);
     }
 
+    if ("assigned".equalsIgnoreCase(value)) {
+      return Predicate.not(new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE)));
+    }
+
+    if ("unassigned".equalsIgnoreCase(value)) {
+      return new AssigneePredicate(new Account.Id(ChangeField.NO_ASSIGNEE));
+    }
+
+    if ("submittable".equalsIgnoreCase(value)) {
+      return new SubmittablePredicate(SubmitRecord.Status.OK);
+    }
+
     try {
       return status(value);
     } catch (IllegalArgumentException e) {
@@ -493,8 +601,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> conflicts(String value) throws OrmException,
-      QueryParseException {
+  public Predicate<ChangeData> conflicts(String value) throws OrmException, QueryParseException {
     return new ConflictsPredicate(args, value, parseChange(value));
   }
 
@@ -518,8 +625,7 @@
 
   @Operator
   public Predicate<ChangeData> parentproject(String name) {
-    return new ParentProjectPredicate(args.projectCache, args.listChildProjects,
-        args.self, name);
+    return new ParentProjectPredicate(args.projectCache, args.listChildProjects, args.self, name);
   }
 
   @Operator
@@ -581,8 +687,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> label(String name) throws QueryParseException,
-      OrmException {
+  public Predicate<ChangeData> label(String name) throws QueryParseException, OrmException {
     Set<Account.Id> accounts = null;
     AccountGroup.UUID group = null;
 
@@ -591,9 +696,12 @@
     // label:CodeReview=1,jsmith or
     // label:CodeReview=1,group=android_approvers or
     // label:CodeReview=1,android_approvers
-    //  user/groups without a label will first attempt to match user
+    // user/groups without a label will first attempt to match user
+    // Special case: votes by owners can be tracked with ",owner":
+    // label:Code-Review+2,owner
+    // label:Code-Review+2,user=owner
     String[] splitReviewer = name.split(",", 2);
-    name = splitReviewer[0];        // remove all but the vote piece, e.g.'CodeReview=1'
+    name = splitReviewer[0]; // remove all but the vote piece, e.g.'CodeReview=1'
 
     if (splitReviewer.length == 2) {
       // process the user/group piece
@@ -601,57 +709,70 @@
 
       for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
         if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
-          accounts = parseAccount(pair.getValue());
+          if (pair.getValue().equals(ARG_ID_OWNER)) {
+            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+          } else {
+            accounts = parseAccount(pair.getValue());
+          }
         } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) {
           group = parseGroup(pair.getValue()).getUUID();
         } else {
-          throw new QueryParseException(
-              "Invalid argument identifier '"   + pair.getKey() + "'");
+          throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
         }
       }
 
       for (String value : lblArgs.positional) {
-       if (accounts != null || group != null) {
-          throw new QueryParseException("more than one user/group specified (" +
-              value + ")");
+        if (accounts != null || group != null) {
+          throw new QueryParseException("more than one user/group specified (" + value + ")");
         }
         try {
-          accounts = parseAccount(value);
+          if (value.equals(ARG_ID_OWNER)) {
+            accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+          } else {
+            accounts = parseAccount(value);
+          }
         } catch (QueryParseException qpex) {
           // If it doesn't match an account, see if it matches a group
           // (accounts get precedence)
           try {
             group = parseGroup(value).getUUID();
           } catch (QueryParseException e) {
-            throw error("Neither user nor group " + value + " found");
+            throw error("Neither user nor group " + value + " found", e);
           }
         }
       }
     }
 
-    // expand a group predicate into multiple user predicates
     if (group != null) {
-      Set<Account.Id> allMembers =
-          new HashSet<>(Lists.transform(
-              args.listMembers.get().setRecursive(true).apply(group),
-              new Function<AccountInfo, Account.Id>() {
-                @Override
-                public Account.Id apply(AccountInfo accountInfo) {
-                  return new Account.Id(accountInfo._accountId);
-                }
-              }));
-      int maxLimit = args.indexConfig.maxLimit();
-      if (allMembers.size() > maxLimit) {
-        // limit the number of query terms otherwise Gerrit will barf
-        accounts = ImmutableSet.copyOf(Iterables.limit(allMembers, maxLimit));
-      } else {
-        accounts = allMembers;
+      accounts = getMembers(group);
+    }
+
+    // If the vote piece looks like Code-Review=NEED with a valid non-numeric
+    // submit record status, interpret as a submit record query.
+    int eq = name.indexOf('=');
+    if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
+      String statusName = name.substring(eq + 1).toUpperCase();
+      if (!isInt(statusName)) {
+        SubmitRecord.Label.Status status =
+            Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
+        if (status == null) {
+          throw error("Invalid label status " + statusName + " in " + name);
+        }
+        return SubmitRecordPredicate.create(name.substring(0, eq), status, accounts);
       }
     }
 
-    return new LabelPredicate(args.projectCache,
-        args.changeControlGenericFactory, args.userFactory, args.db,
-        name, accounts, group);
+    return new LabelPredicate(args, name, accounts, group);
+  }
+
+  private static boolean isInt(String s) {
+    if (s == null) {
+      return false;
+    }
+    if (s.startsWith("+")) {
+      s = s.substring(1);
+    }
+    return Ints.tryParse(s) != null;
   }
 
   @Operator
@@ -665,13 +786,11 @@
   }
 
   @Operator
-  public Predicate<ChangeData> starredby(String who)
-      throws QueryParseException, OrmException {
+  public Predicate<ChangeData> starredby(String who) throws QueryParseException, OrmException {
     return starredby(parseAccount(who));
   }
 
-  private Predicate<ChangeData> starredby(Set<Account.Id> who)
-      throws QueryParseException {
+  private Predicate<ChangeData> starredby(Set<Account.Id> who) {
     List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
     for (Account.Id id : who) {
       p.add(starredby(id));
@@ -679,30 +798,12 @@
     return Predicate.or(p);
   }
 
-  @SuppressWarnings("deprecation")
-  private Predicate<ChangeData> starredby(Account.Id who)
-      throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.STAR)) {
-      return new StarPredicate(who, StarredChangesUtil.DEFAULT_LABEL);
-    }
-
-    if (args.getSchema().hasField(ChangeField.STARREDBY)) {
-      return new IsStarredByPredicate(who);
-    }
-
-    try {
-      // starred changes are not contained in the index, we must read them from
-      // git
-      return new IsStarredByLegacyPredicate(who, args.starredChangesUtil
-          .byAccount(who, StarredChangesUtil.DEFAULT_LABEL));
-    } catch (OrmException e) {
-      throw new QueryParseException("Failed to query starred changes.", e);
-    }
+  private Predicate<ChangeData> starredby(Account.Id who) {
+    return new StarPredicate(who, StarredChangesUtil.DEFAULT_LABEL);
   }
 
   @Operator
-  public Predicate<ChangeData> watchedby(String who)
-      throws QueryParseException, OrmException {
+  public Predicate<ChangeData> watchedby(String who) throws QueryParseException, OrmException {
     Set<Account.Id> m = parseAccount(who);
     List<IsWatchedByPredicate> p = Lists.newArrayListWithCapacity(m.size());
 
@@ -726,8 +827,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> draftby(String who) throws QueryParseException,
-      OrmException {
+  public Predicate<ChangeData> draftby(String who) throws QueryParseException, OrmException {
     Set<Account.Id> m = parseAccount(who);
     List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
     for (Account.Id id : m) {
@@ -736,16 +836,12 @@
     return Predicate.or(p);
   }
 
-  @SuppressWarnings("deprecation")
   private Predicate<ChangeData> draftby(Account.Id who) {
-    return args.getSchema().hasField(ChangeField.DRAFTBY)
-        ? new HasDraftByPredicate(who)
-        : new HasDraftByLegacyPredicate(args, who);
+    return new HasDraftByPredicate(who);
   }
 
   @Operator
-  public Predicate<ChangeData> visibleto(String who)
-      throws QueryParseException, OrmException {
+  public Predicate<ChangeData> visibleto(String who) throws QueryParseException, OrmException {
     if ("self".equals(who)) {
       return is_visible();
     }
@@ -773,8 +869,8 @@
   }
 
   public Predicate<ChangeData> visibleto(CurrentUser user) {
-    return new ChangeIsVisibleToPredicate(args.db, args.notesFactory,
-        args.changeControlGenericFactory, user);
+    return new ChangeIsVisibleToPredicate(
+        args.db, args.notesFactory, args.changeControlGenericFactory, user);
   }
 
   public Predicate<ChangeData> is_visible() throws QueryParseException {
@@ -782,14 +878,12 @@
   }
 
   @Operator
-  public Predicate<ChangeData> o(String who)
-      throws QueryParseException, OrmException {
+  public Predicate<ChangeData> o(String who) throws QueryParseException, OrmException {
     return owner(who);
   }
 
   @Operator
-  public Predicate<ChangeData> owner(String who) throws QueryParseException,
-      OrmException {
+  public Predicate<ChangeData> owner(String who) throws QueryParseException, OrmException {
     return owner(parseAccount(who));
   }
 
@@ -802,35 +896,60 @@
   }
 
   @Operator
-  public Predicate<ChangeData> ownerin(String group)
-      throws QueryParseException {
-    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
-    if (g == null) {
-      throw error("Group " + group + " not found");
-    }
-    return new OwnerinPredicate(args.userFactory, g.getUUID());
+  public Predicate<ChangeData> assignee(String who) throws QueryParseException, OrmException {
+    return assignee(parseAccount(who));
   }
 
-  @Operator
-  public Predicate<ChangeData> r(String who)
-      throws QueryParseException, OrmException {
-    return reviewer(who);
-  }
-
-  @Operator
-  public Predicate<ChangeData> reviewer(String who)
-      throws QueryParseException, OrmException {
-    Set<Account.Id> m = parseAccount(who);
-    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
-    for (Account.Id id : m) {
-      p.add(ReviewerPredicate.create(args, id));
+  private Predicate<ChangeData> assignee(Set<Account.Id> who) {
+    List<AssigneePredicate> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(new AssigneePredicate(id));
     }
     return Predicate.or(p);
   }
 
   @Operator
-  public Predicate<ChangeData> reviewerin(String group)
-      throws QueryParseException {
+  public Predicate<ChangeData> ownerin(String group) throws QueryParseException, OrmException {
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
+    if (g == null) {
+      throw error("Group " + group + " not found");
+    }
+
+    AccountGroup.UUID groupId = g.getUUID();
+    GroupDescription.Basic groupDescription = args.groupBackend.get(groupId);
+    if (!(groupDescription instanceof GroupDescription.Internal)) {
+      return new OwnerinPredicate(args.userFactory, groupId);
+    }
+
+    Set<Account.Id> accounts = getMembers(groupId);
+    List<OwnerPredicate> p = Lists.newArrayListWithCapacity(accounts.size());
+    for (Account.Id id : accounts) {
+      p.add(new OwnerPredicate(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
+  public Predicate<ChangeData> r(String who) throws QueryParseException, OrmException {
+    return reviewer(who);
+  }
+
+  @Operator
+  public Predicate<ChangeData> reviewer(String who) throws QueryParseException, OrmException {
+    return Predicate.or(
+        parseAccount(who).stream()
+            .map(id -> ReviewerPredicate.reviewer(args, id))
+            .collect(toList()));
+  }
+
+  @Operator
+  public Predicate<ChangeData> cc(String who) throws QueryParseException, OrmException {
+    return Predicate.or(
+        parseAccount(who).stream().map(id -> ReviewerPredicate.cc(args, id)).collect(toList()));
+  }
+
+  @Operator
+  public Predicate<ChangeData> reviewerin(String group) throws QueryParseException {
     GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
     if (g == null) {
       throw error("Group " + group + " not found");
@@ -858,32 +977,27 @@
   }
 
   @Operator
-  public Predicate<ChangeData> added(String value)
-      throws QueryParseException {
+  public Predicate<ChangeData> added(String value) throws QueryParseException {
     return new AddedPredicate(value);
   }
 
   @Operator
-  public Predicate<ChangeData> deleted(String value)
-      throws QueryParseException {
+  public Predicate<ChangeData> deleted(String value) throws QueryParseException {
     return new DeletedPredicate(value);
   }
 
   @Operator
-  public Predicate<ChangeData> size(String value)
-      throws QueryParseException {
+  public Predicate<ChangeData> size(String value) throws QueryParseException {
     return delta(value);
   }
 
   @Operator
-  public Predicate<ChangeData> delta(String value)
-      throws QueryParseException {
+  public Predicate<ChangeData> delta(String value) throws QueryParseException {
     return new DeltaPredicate(value);
   }
 
   @Operator
-  public Predicate<ChangeData> commentby(String who)
-      throws QueryParseException, OrmException {
+  public Predicate<ChangeData> commentby(String who) throws QueryParseException, OrmException {
     return commentby(parseAccount(who));
   }
 
@@ -896,8 +1010,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> from(String who)
-      throws QueryParseException, OrmException {
+  public Predicate<ChangeData> from(String who) throws QueryParseException, OrmException {
     Set<Account.Id> ownerIds = parseAccount(who);
     return Predicate.or(owner(ownerIds), commentby(ownerIds));
   }
@@ -912,8 +1025,8 @@
         return parse(query);
       }
     } catch (RepositoryNotFoundException e) {
-      throw new QueryParseException("Unknown named query (no " +
-          args.allUsersName + " repo): " + name, e);
+      throw new QueryParseException(
+          "Unknown named query (no " + args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
       throw new QueryParseException("Error parsing named query: " + name, e);
     }
@@ -921,26 +1034,22 @@
   }
 
   @Operator
-  public Predicate<ChangeData> reviewedby(String who)
-      throws QueryParseException, OrmException {
+  public Predicate<ChangeData> reviewedby(String who) throws QueryParseException, OrmException {
     return IsReviewedPredicate.create(parseAccount(who));
   }
 
   @Operator
-  public Predicate<ChangeData> destination(String name)
-      throws QueryParseException {
+  public Predicate<ChangeData> destination(String name) throws QueryParseException {
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountDestinations d =
-          VersionedAccountDestinations.forUser(self());
+      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
       d.load(git);
-      Set<Branch.NameKey> destinations =
-          d.getDestinationList().getDestinations(name);
-      if (destinations != null) {
+      Set<Branch.NameKey> destinations = d.getDestinationList().getDestinations(name);
+      if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, name);
       }
     } catch (RepositoryNotFoundException e) {
-      throw new QueryParseException("Unknown named destination (no " +
-          args.allUsersName + " repo): " + name, e);
+      throw new QueryParseException(
+          "Unknown named destination (no " + args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
       throw new QueryParseException("Error parsing named destination: " + name, e);
     }
@@ -957,26 +1066,44 @@
     return new CommitterPredicate(who);
   }
 
+  @Operator
+  public Predicate<ChangeData> submittable(String str) throws QueryParseException {
+    SubmitRecord.Status status =
+        Enums.getIfPresent(SubmitRecord.Status.class, str.toUpperCase()).orNull();
+    if (status == null) {
+      throw error("invalid value for submittable:" + str);
+    }
+    return new SubmittablePredicate(status);
+  }
+
+  @Operator
+  public Predicate<ChangeData> unresolved(String value) throws QueryParseException {
+    return new IsUnresolvedPredicate(value);
+  }
+
   @Override
   protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
     if (query.startsWith("refs/")) {
       return ref(query);
     } else if (DEF_CHANGE.matcher(query).matches()) {
+      List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(2);
       try {
-        return change(query);
+        predicates.add(change(query));
       } catch (QueryParseException e) {
         // Skip.
       }
+
+      // For PAT_LEGACY_ID, it may also be the prefix of some commits.
+      if (query.length() >= 6 && PAT_LEGACY_ID.matcher(query).matches()) {
+        predicates.add(commit(query));
+      }
+
+      return Predicate.or(predicates);
     }
 
     // Adapt the capacity of this list when adding more default predicates.
     List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
     try {
-      predicates.add(commit(query));
-    } catch (IllegalArgumentException e) {
-      // Skip.
-    }
-    try {
       predicates.add(owner(query));
     } catch (OrmException | QueryParseException e) {
       // Skip.
@@ -992,6 +1119,7 @@
     } catch (OrmException | QueryParseException e) {
       // Skip.
     }
+    predicates.add(commit(query));
     predicates.add(message(query));
     predicates.add(comment(query));
     predicates.add(projects(query));
@@ -1003,8 +1131,23 @@
     return Predicate.or(predicates);
   }
 
-  private Set<Account.Id> parseAccount(String who)
-      throws QueryParseException, OrmException {
+  private Set<Account.Id> getMembers(AccountGroup.UUID g) throws OrmException {
+    Set<Account.Id> accounts;
+    Set<Account.Id> allMembers =
+        args.listMembers.get().setRecursive(true).apply(g).stream()
+            .map(a -> new Account.Id(a._accountId))
+            .collect(toSet());
+    int maxTerms = args.indexConfig.maxTerms();
+    if (allMembers.size() > maxTerms) {
+      // limit the number of query terms otherwise Gerrit will barf
+      accounts = ImmutableSet.copyOf(Iterables.limit(allMembers, maxTerms));
+    } else {
+      accounts = allMembers;
+    }
+    return accounts;
+  }
+
+  private Set<Account.Id> parseAccount(String who) throws QueryParseException, OrmException {
     if ("self".equals(who)) {
       return Collections.singleton(self());
     }
@@ -1016,22 +1159,18 @@
   }
 
   private GroupReference parseGroup(String group) throws QueryParseException {
-    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend,
-        group);
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
     if (g == null) {
       throw error("Group " + group + " not found");
     }
     return g;
   }
 
-  private List<Change> parseChange(String value) throws OrmException,
-      QueryParseException {
+  private List<Change> parseChange(String value) throws OrmException, QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
-      return asChanges(
-          args.queryProvider.get().byLegacyChangeId(Change.Id.parse(value)));
+      return asChanges(args.queryProvider.get().byLegacyChangeId(Change.Id.parse(value)));
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
-      List<Change> changes =
-          asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
+      List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
       if (changes.isEmpty()) {
         throw error("Change " + value + " not found");
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 0ff5ac7..91a37d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.query.QueryProcessor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.util.Set;
 
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData> {
@@ -48,7 +47,8 @@
   }
 
   @Inject
-  ChangeQueryProcessor(Provider<CurrentUser> userProvider,
+  ChangeQueryProcessor(
+      Provider<CurrentUser> userProvider,
       Metrics metrics,
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
@@ -56,8 +56,14 @@
       Provider<ReviewDb> db,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeNotes.Factory notesFactory) {
-    super(userProvider, metrics, ChangeSchemaDefinitions.INSTANCE, indexConfig, indexes,
-        rewriter, FIELD_LIMIT);
+    super(
+        userProvider,
+        metrics,
+        ChangeSchemaDefinitions.INSTANCE,
+        indexConfig,
+        indexes,
+        rewriter,
+        FIELD_LIMIT);
     this.db = db;
     this.changeControlFactory = changeControlFactory;
     this.notesFactory = notesFactory;
@@ -70,16 +76,16 @@
   }
 
   @Override
-  protected QueryOptions createOptions(IndexConfig indexConfig, int start,
-      int limit, Set<String> requestedFields) {
-    return IndexedChangeQuery.createOptions(indexConfig, start, limit,
-        requestedFields);
+  protected QueryOptions createOptions(
+      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
+    return IndexedChangeQuery.createOptions(indexConfig, start, limit, requestedFields);
   }
 
   @Override
-  protected Predicate<ChangeData> enforceVisibility(
-      Predicate<ChangeData> pred) {
-    return new AndChangeSource(pred, new ChangeIsVisibleToPredicate(db,
-        notesFactory, changeControlFactory, userProvider.get()), start);
+  protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
+    return new AndChangeSource(
+        pred,
+        new ChangeIsVisibleToPredicate(db, notesFactory, changeControlFactory, userProvider.get()),
+        start);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
index 747d72d..f421985 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
@@ -24,8 +24,7 @@
     super(def, value);
   }
 
-  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String name,
-      String value) {
+  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 1c92ecf..9c16777 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -28,12 +28,12 @@
 
 /**
  * Predicate for a {@link Status}.
- * <p>
- * The actual name of this operator can differ, it usually comes as {@code
- * status:} but may also be {@code is:} to help do-what-i-meanery for end-users
- * searching for changes. Either operator name has the same meaning.
- * <p>
- * Status names are looked up by prefix case-insensitively.
+ *
+ * <p>The actual name of this operator can differ, it usually comes as {@code status:} but may also
+ * be {@code is:} to help do-what-i-meanery for end-users searching for changes. Either operator
+ * name has the same meaning.
+ *
+ * <p>Status names are looked up by prefix case-insensitively.
  */
 public final class ChangeStatusPredicate extends ChangeIndexPredicate {
   private static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
@@ -63,19 +63,17 @@
     return status.name().toLowerCase();
   }
 
-  public static Predicate<ChangeData> parse(String value) {
+  public static Predicate<ChangeData> parse(String value) throws QueryParseException {
     String lower = value.toLowerCase();
-    NavigableMap<String, Predicate<ChangeData>> head =
-        PREDICATES.tailMap(lower, true);
+    NavigableMap<String, Predicate<ChangeData>> head = PREDICATES.tailMap(lower, true);
     if (!head.isEmpty()) {
       // Assume no statuses share a common prefix so we can only walk one entry.
-      Map.Entry<String, Predicate<ChangeData>> e =
-          head.entrySet().iterator().next();
+      Map.Entry<String, Predicate<ChangeData>> e = head.entrySet().iterator().next();
       if (e.getKey().startsWith(lower)) {
         return e.getValue();
       }
     }
-    throw new IllegalArgumentException("invalid change status: " + value);
+    throw new QueryParseException("invalid change status: " + value);
   }
 
   public static Predicate<ChangeData> open() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index 48d6e05..668c6f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -16,10 +16,9 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.Objects;
 
 class CommentByPredicate extends ChangeIndexPredicate {
@@ -41,8 +40,8 @@
         return true;
       }
     }
-    for (PatchLineComment c : cd.publishedComments()) {
-      if (Objects.equals(c.getAuthor(), id)) {
+    for (Comment c : cd.publishedComments()) {
+      if (Objects.equals(c.author.getId(), id)) {
         return true;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
index b351740..4779a16 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -32,10 +32,8 @@
   @Override
   public boolean match(ChangeData object) throws OrmException {
     try {
-      Predicate<ChangeData> p = Predicate.and(
-          new LegacyChangeIdPredicate(object.getId()), this);
-      for (ChangeData cData
-          : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
+      Predicate<ChangeData> p = Predicate.and(new LegacyChangeIdPredicate(object.getId()), this);
+      for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
         if (cData.getId().equals(object.getId())) {
           return true;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
index aa3dde3..1188d5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -48,8 +48,7 @@
   private boolean equals(PatchSet p, String id) {
     boolean exact = getField() == EXACT_COMMIT;
     String rev = p.getRevision() != null ? p.getRevision().get() : null;
-    return (exact && id.equals(rev))
-        || (!exact && rev != null && rev.startsWith(id));
+    return (exact && id.equals(rev)) || (!exact && rev != null && rev.startsWith(id));
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
index 06f5379..cd1f3b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
@@ -19,6 +19,7 @@
 
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
 
 public class CommitterPredicate extends ChangeIndexPredicate {
   CommitterPredicate(String value) {
@@ -27,8 +28,11 @@
 
   @Override
   public boolean match(ChangeData object) throws OrmException {
-    return ChangeField.getCommitterParts(object).contains(
-        getValue().toLowerCase());
+    try {
+      return ChangeField.getCommitterParts(object).contains(getValue().toLowerCase());
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictKey.java
index 3b3d986..0101ffe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictKey.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.extensions.client.SubmitType;
-
-import org.eclipse.jgit.lib.ObjectId;
-
 import java.io.Serializable;
 import java.util.Objects;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class ConflictKey implements Serializable {
   private static final long serialVersionUID = 2L;
@@ -29,10 +27,9 @@
   private final SubmitType submitType;
   private final boolean contentMerge;
 
-  public ConflictKey(ObjectId commit, ObjectId otherCommit,
-      SubmitType submitType, boolean contentMerge) {
-    if (SubmitType.FAST_FORWARD_ONLY.equals(submitType)
-        || commit.compareTo(otherCommit) < 0) {
+  public ConflictKey(
+      ObjectId commit, ObjectId otherCommit, SubmitType submitType, boolean contentMerge) {
+    if (SubmitType.FAST_FORWARD_ONLY.equals(submitType) || commit.compareTo(otherCommit) < 0) {
       this.commit = commit;
       this.otherCommit = otherCommit;
     } else {
@@ -64,7 +61,7 @@
     if (!(o instanceof ConflictKey)) {
       return false;
     }
-    ConflictKey other = (ConflictKey)o;
+    ConflictKey other = (ConflictKey) o;
     return commit.equals(other.commit)
         && otherCommit.equals(other.otherCommit)
         && submitType.equals(other.submitType)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
index 8cca00d..1185677 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsCacheImpl.java
@@ -29,8 +29,7 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        persist(NAME, ConflictKey.class, Boolean.class)
-            .maximumWeight(37400);
+        persist(NAME, ConflictKey.class, Boolean.class).maximumWeight(37400);
         bind(ConflictsCache.class).to(ConflictsCacheImpl.class);
       }
     };
@@ -39,8 +38,7 @@
   private final Cache<ConflictKey, Boolean> conflictsCache;
 
   @Inject
-  public ConflictsCacheImpl(
-      @Named(NAME) Cache<ConflictKey, Boolean> conflictsCache) {
+  public ConflictsCacheImpl(@Named(NAME) Cache<ConflictKey, Boolean> conflictsCache) {
     this.conflictsCache = conflictsCache;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 69bc2ca..9b45890 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -27,10 +27,15 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -40,118 +45,123 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.treewalk.filter.TreeFilter;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
 class ConflictsPredicate extends OrPredicate<ChangeData> {
+  // UI code may depend on this string, so use caution when changing.
+  private static final String TOO_MANY_FILES = "too many files to find conflicts";
+
   private final String value;
 
   ConflictsPredicate(Arguments args, String value, List<Change> changes)
-      throws OrmException {
+      throws QueryParseException, OrmException {
     super(predicates(args, value, changes));
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(final Arguments args,
-      String value, List<Change> changes) throws OrmException {
-    List<Predicate<ChangeData>> changePredicates =
-        Lists.newArrayListWithCapacity(changes.size());
+  private static List<Predicate<ChangeData>> predicates(
+      final Arguments args, String value, List<Change> changes)
+      throws QueryParseException, OrmException {
+    int indexTerms = 0;
+
+    List<Predicate<ChangeData>> changePredicates = Lists.newArrayListWithCapacity(changes.size());
     final Provider<ReviewDb> db = args.db;
     for (final Change c : changes) {
-      final ChangeDataCache changeDataCache = new ChangeDataCache(
-          c, db, args.changeDataFactory, args.projectCache);
+      final ChangeDataCache changeDataCache =
+          new ChangeDataCache(c, db, args.changeDataFactory, args.projectCache);
       List<String> files = listFiles(c, args, changeDataCache);
-      List<Predicate<ChangeData>> filePredicates =
-          Lists.newArrayListWithCapacity(files.size());
-      for (String file : files) {
-        filePredicates.add(
-            new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
+      indexTerms += 3 + files.size();
+      if (indexTerms > args.indexConfig.maxTerms()) {
+        // Short-circuit with a nice error message if we exceed the index
+        // backend's term limit. This assumes that "conflicts:foo" is the entire
+        // query; if there are more terms in the input, we might not
+        // short-circuit here, which will result in a more generic error message
+        // later on in the query parsing.
+        throw new QueryParseException(TOO_MANY_FILES);
       }
 
-      List<Predicate<ChangeData>> predicatesForOneChange =
-          Lists.newArrayListWithCapacity(5);
+      List<Predicate<ChangeData>> filePredicates = Lists.newArrayListWithCapacity(files.size());
+      for (String file : files) {
+        filePredicates.add(new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
+      }
+
+      List<Predicate<ChangeData>> predicatesForOneChange = Lists.newArrayListWithCapacity(5);
+      predicatesForOneChange.add(not(new LegacyChangeIdPredicate(c.getId())));
+      predicatesForOneChange.add(new ProjectPredicate(c.getProject().get()));
+      predicatesForOneChange.add(new RefPredicate(c.getDest().get()));
+
+      predicatesForOneChange.add(or(or(filePredicates), new IsMergePredicate(args, value)));
+
       predicatesForOneChange.add(
-          not(new LegacyChangeIdPredicate(c.getId())));
-      predicatesForOneChange.add(
-          new ProjectPredicate(c.getProject().get()));
-      predicatesForOneChange.add(
-          new RefPredicate(c.getDest().get()));
+          new ChangeOperatorPredicate(ChangeQueryBuilder.FIELD_CONFLICTS, value) {
 
-      predicatesForOneChange.add(or(or(filePredicates),
-          new IsMergePredicate(args, value)));
-
-      predicatesForOneChange.add(new ChangeOperatorPredicate(
-          ChangeQueryBuilder.FIELD_CONFLICTS, value) {
-
-        @Override
-        public boolean match(ChangeData object) throws OrmException {
-          Change otherChange = object.change();
-          if (otherChange == null) {
-            return false;
-          }
-          if (!otherChange.getDest().equals(c.getDest())) {
-            return false;
-          }
-          SubmitTypeRecord str = object.submitTypeRecord();
-          if (!str.isOk()) {
-            return false;
-          }
-          ObjectId other = ObjectId.fromString(
-              object.currentPatchSet().getRevision().get());
-          ConflictKey conflictsKey =
-              new ConflictKey(changeDataCache.getTestAgainst(), other, str.type,
-                  changeDataCache.getProjectState().isUseContentMerge());
-          Boolean conflicts = args.conflictsCache.getIfPresent(conflictsKey);
-          if (conflicts != null) {
-            return conflicts;
-          }
-          try (Repository repo =
-                args.repoManager.openRepository(otherChange.getProject());
-              CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-            conflicts = !args.submitDryRun.run(
-                str.type, repo, rw, otherChange.getDest(),
-                changeDataCache.getTestAgainst(), other,
-                getAlreadyAccepted(repo, rw));
-            args.conflictsCache.put(conflictsKey, conflicts);
-            return conflicts;
-          } catch (IntegrationException | NoSuchProjectException
-              | IOException e) {
-            throw new IllegalStateException(e);
-          }
-        }
-
-        @Override
-        public int getCost() {
-          return 5;
-        }
-
-        private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
-            throws IntegrationException {
-          try {
-            Set<RevCommit> accepted = new HashSet<>();
-            SubmitDryRun.addCommits(
-                changeDataCache.getAlreadyAccepted(repo), rw, accepted);
-            ObjectId tip = changeDataCache.getTestAgainst();
-            if (tip != null) {
-              accepted.add(rw.parseCommit(tip));
+            @Override
+            public boolean match(ChangeData object) throws OrmException {
+              Change otherChange = object.change();
+              if (otherChange == null) {
+                return false;
+              }
+              if (!otherChange.getDest().equals(c.getDest())) {
+                return false;
+              }
+              SubmitTypeRecord str = object.submitTypeRecord();
+              if (!str.isOk()) {
+                return false;
+              }
+              ObjectId other = ObjectId.fromString(object.currentPatchSet().getRevision().get());
+              ConflictKey conflictsKey =
+                  new ConflictKey(
+                      changeDataCache.getTestAgainst(),
+                      other,
+                      str.type,
+                      changeDataCache.getProjectState().isUseContentMerge());
+              Boolean conflicts = args.conflictsCache.getIfPresent(conflictsKey);
+              if (conflicts != null) {
+                return conflicts;
+              }
+              try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
+                  CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+                conflicts =
+                    !args.submitDryRun.run(
+                        str.type,
+                        repo,
+                        rw,
+                        otherChange.getDest(),
+                        changeDataCache.getTestAgainst(),
+                        other,
+                        getAlreadyAccepted(repo, rw));
+                args.conflictsCache.put(conflictsKey, conflicts);
+                return conflicts;
+              } catch (IntegrationException | NoSuchProjectException | IOException e) {
+                throw new OrmException(e);
+              }
             }
-            return accepted;
-          } catch (OrmException | IOException e) {
-            throw new IntegrationException(
-                "Failed to determine already accepted commits.", e);
-          }
-        }
-      });
+
+            @Override
+            public int getCost() {
+              return 5;
+            }
+
+            private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
+                throws IntegrationException {
+              try {
+                Set<RevCommit> accepted = new HashSet<>();
+                SubmitDryRun.addCommits(changeDataCache.getAlreadyAccepted(repo), rw, accepted);
+                ObjectId tip = changeDataCache.getTestAgainst();
+                if (tip != null) {
+                  accepted.add(rw.parseCommit(tip));
+                }
+                return accepted;
+              } catch (OrmException | IOException e) {
+                throw new IntegrationException("Failed to determine already accepted commits.", e);
+              }
+            }
+          });
       changePredicates.add(and(predicatesForOneChange));
     }
     return changePredicates;
   }
 
-  private static List<String> listFiles(Change c, Arguments args,
-      ChangeDataCache changeDataCache) throws OrmException {
+  private static List<String> listFiles(Change c, Arguments args, ChangeDataCache changeDataCache)
+      throws OrmException {
     try (Repository repo = args.repoManager.openRepository(c.getProject());
         RevWalk rw = new RevWalk(repo)) {
       RevCommit ps = rw.parseCommit(changeDataCache.getTestAgainst());
@@ -200,20 +210,22 @@
     private ProjectState projectState;
     private Iterable<ObjectId> alreadyAccepted;
 
-    ChangeDataCache(Change change, Provider<ReviewDb> db,
-        ChangeData.Factory changeDataFactory, ProjectCache projectCache) {
+    ChangeDataCache(
+        Change change,
+        Provider<ReviewDb> db,
+        ChangeData.Factory changeDataFactory,
+        ProjectCache projectCache) {
       this.change = change;
       this.db = db;
       this.changeDataFactory = changeDataFactory;
       this.projectCache = projectCache;
     }
 
-    ObjectId getTestAgainst()
-        throws OrmException {
+    ObjectId getTestAgainst() throws OrmException {
       if (testAgainst == null) {
-        testAgainst = ObjectId.fromString(
-            changeDataFactory.create(db.get(), change)
-                .currentPatchSet().getRevision().get());
+        testAgainst =
+            ObjectId.fromString(
+                changeDataFactory.create(db.get(), change).currentPatchSet().getRevision().get());
       }
       return testAgainst;
     }
@@ -222,8 +234,7 @@
       if (projectState == null) {
         projectState = projectCache.get(change.getProject());
         if (projectState == null) {
-          throw new IllegalStateException(
-              new NoSuchProjectException(change.getProject()));
+          throw new IllegalStateException(new NoSuchProjectException(change.getProject()));
         }
       }
       return projectState;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 7e573dc..809e7a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.Set;
 
 class DestinationPredicate extends ChangeOperatorPredicate {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
index 6877761..fb6c56b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
@@ -21,8 +21,7 @@
 
 class EqualsFilePredicate extends ChangeIndexPredicate {
   static Predicate<ChangeData> create(Arguments args, String value) {
-    Predicate<ChangeData> eqPath =
-        new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
+    Predicate<ChangeData> eqPath = new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
     if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
       return eqPath;
     }
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 e752b05..df3b12a 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
@@ -41,9 +41,8 @@
   private final Account.Id account;
   private final AccountGroup.UUID group;
 
-  EqualsLabelPredicate(LabelPredicate.Args args, String label, int expVal,
-      Account.Id account) {
-    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+  EqualsLabelPredicate(LabelPredicate.Args args, String label, int expVal, Account.Id account) {
+    super(args.field, ChangeField.formatLabel(label, expVal, account));
     this.ccFactory = args.ccFactory;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
@@ -105,16 +104,15 @@
     return null;
   }
 
-  private boolean match(Change change, int value, Account.Id approver,
-      LabelType type) throws OrmException {
+  private boolean match(Change change, int value, Account.Id approver, LabelType type)
+      throws OrmException {
     int psVal = value;
     if (psVal == expVal) {
       // Double check the value is still permitted for the user.
       //
       IdentifiedUser reviewer = userFactory.create(approver);
       try {
-        ChangeControl cc =
-            ccFactory.controlFor(dbProvider.get(), change, reviewer);
+        ChangeControl cc = ccFactory.controlFor(dbProvider.get(), change, reviewer);
         if (!cc.isVisible(dbProvider.get())) {
           // The user can't see the change anymore.
           //
@@ -127,7 +125,15 @@
         return false;
       }
 
-      if (account != null && !account.equals(approver)) {
+      if (account != null
+          && !account.equals(approver)
+          && !account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)) {
+        return false;
+      }
+
+      if (account != null
+          && account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
+          && !change.getOwner().equals(approver)) {
         return false;
       }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
index 5edd06c..9d841f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.Collections;
 import java.util.List;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 9e9bc8d..54e1c97 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.List;
 
 class GroupPredicate extends ChangeIndexPredicate {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
deleted file mode 100644
index 45a00c6..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-@Deprecated
-class HasDraftByLegacyPredicate extends ChangeOperatorPredicate
-    implements ChangeDataSource {
-  private final Arguments args;
-  private final Account.Id accountId;
-
-  HasDraftByLegacyPredicate(Arguments args,
-      Account.Id accountId) {
-    super(ChangeQueryBuilder.FIELD_DRAFTBY, accountId.toString());
-    this.args = args;
-    this.accountId = accountId;
-  }
-
-  @Override
-  public boolean match(final ChangeData object) throws OrmException {
-    return !args.plcUtil
-        .draftByChangeAuthor(args.db.get(), object.notes(), accountId)
-        .isEmpty();
-  }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    Set<Change.Id> ids = new HashSet<>();
-    for (PatchLineComment sc :
-        args.plcUtil.draftByAuthor(args.db.get(), accountId)) {
-      ids.add(sc.getKey().getParentKey().getParentKey().getParentKey());
-    }
-
-    List<ChangeData> r = new ArrayList<>(ids.size());
-    // TODO Don't load the changes directly from the database, but provide
-    // project name + change ID to changeDataFactory, or delete this predicate.
-    for (Change c : args.db.get().changes().get(ids)) {
-      r.add(args.changeDataFactory.create(args.db.get(), c));
-    }
-    return new ListResultSet<>(r);
-  }
-
-  @Override
-  public boolean hasChange() {
-    return false;
-  }
-
-  @Override
-  public int getCardinality() {
-    return 20;
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
index 185a539..4fd4156 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
@@ -38,4 +38,3 @@
     return 1;
   }
 }
-
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
index a272fbb..d4f5620 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
@@ -19,11 +19,11 @@
 import com.google.gerrit.server.query.Matchable;
 import com.google.gerrit.server.query.QueryParseException;
 
-public abstract class IntegerRangeChangePredicate
-    extends IntegerRangePredicate<ChangeData> implements Matchable<ChangeData> {
+public abstract class IntegerRangeChangePredicate extends IntegerRangePredicate<ChangeData>
+    implements Matchable<ChangeData> {
 
-  protected IntegerRangeChangePredicate(FieldDef<ChangeData, Integer> type,
-      String value) throws QueryParseException {
+  protected IntegerRangeChangePredicate(FieldDef<ChangeData, Integer> type, String value)
+      throws QueryParseException {
     super(type, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6aa33352..fa2f5fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -15,18 +15,15 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.index.change.ChangeField.SUBMISSIONID;
 import static com.google.gerrit.server.query.Predicate.and;
 import static com.google.gerrit.server.query.Predicate.not;
 import static com.google.gerrit.server.query.Predicate.or;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -39,17 +36,15 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 public class InternalChangeQuery extends InternalQuery<ChangeData> {
   private static Predicate<ChangeData> ref(Branch.NameKey branch) {
@@ -76,7 +71,8 @@
   private final ChangeNotes.Factory notesFactory;
 
   @Inject
-  InternalChangeQuery(ChangeQueryProcessor queryProcessor,
+  InternalChangeQuery(
+      ChangeQueryProcessor queryProcessor,
       ChangeIndexCollection indexes,
       IndexConfig indexConfig,
       ChangeData.Factory changeDataFactory,
@@ -122,8 +118,7 @@
     return query(new LegacyChangeIdPredicate(id));
   }
 
-  public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids)
-      throws OrmException {
+  public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) throws OrmException {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
       preds.add(new LegacyChangeIdPredicate(id));
@@ -131,46 +126,41 @@
     return query(or(preds));
   }
 
-  public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key)
-      throws OrmException {
-    return query(and(
-        ref(branch),
-        project(branch.getParentKey()),
-        change(key)));
+  public List<ChangeData> byBranchKey(Branch.NameKey branch, Change.Key key) throws OrmException {
+    return query(and(ref(branch), project(branch.getParentKey()), change(key)));
   }
 
-  public List<ChangeData> byProject(Project.NameKey project)
-      throws OrmException {
+  public List<ChangeData> byProject(Project.NameKey project) throws OrmException {
     return query(project(project));
   }
 
-  public List<ChangeData> byBranchOpen(Branch.NameKey branch)
-      throws OrmException {
-    return query(and(
-        ref(branch),
-        project(branch.getParentKey()),
-        open()));
+  public List<ChangeData> byBranchOpen(Branch.NameKey branch) throws OrmException {
+    return query(and(ref(branch), project(branch.getParentKey()), open()));
   }
 
-  public List<ChangeData> byBranchNew(Branch.NameKey branch)
-      throws OrmException {
-    return query(and(
-        ref(branch),
-        project(branch.getParentKey()),
-        status(Change.Status.NEW)));
+  public List<ChangeData> byBranchNew(Branch.NameKey branch) throws OrmException {
+    return query(and(ref(branch), project(branch.getParentKey()), status(Change.Status.NEW)));
   }
 
-  public Iterable<ChangeData> byCommitsOnBranchNotMerged(Repository repo,
-      ReviewDb db, Branch.NameKey branch, List<String> hashes)
+  public Iterable<ChangeData> byCommitsOnBranchNotMerged(
+      Repository repo, ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
       throws OrmException, IOException {
-    return byCommitsOnBranchNotMerged(repo, db, branch, hashes,
+    return byCommitsOnBranchNotMerged(
+        repo,
+        db,
+        branch,
+        hashes,
         // Account for all commit predicates plus ref, project, status.
         indexConfig.maxTerms() - 3);
   }
 
   @VisibleForTesting
-  Iterable<ChangeData> byCommitsOnBranchNotMerged(Repository repo, ReviewDb db,
-      Branch.NameKey branch, List<String> hashes, int indexLimit)
+  Iterable<ChangeData> byCommitsOnBranchNotMerged(
+      Repository repo,
+      ReviewDb db,
+      Branch.NameKey branch,
+      Collection<String> hashes,
+      int indexLimit)
       throws OrmException, IOException {
     if (hashes.size() > indexLimit) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, db, branch, hashes);
@@ -179,12 +169,11 @@
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
-      Repository repo, final ReviewDb db, final Branch.NameKey branch,
-      List<String> hashes) throws OrmException, IOException {
+      Repository repo, final ReviewDb db, final Branch.NameKey branch, Collection<String> hashes)
+      throws OrmException, IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
-    for (Ref ref :
-        repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
+    for (Ref ref : repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
       String r = ref.getName();
       if ((lastPrefix != null && r.startsWith(lastPrefix))
           || !hashes.contains(ref.getObjectId().name())) {
@@ -199,32 +188,29 @@
       }
     }
 
-    return Lists.transform(notesFactory.create(db, branch.getParentKey(),
-        changeIds, new com.google.common.base.Predicate<ChangeNotes>() {
-          @Override
-          public boolean apply(ChangeNotes notes) {
-            Change c = notes.getChange();
-            return c.getDest().equals(branch)
-                && c.getStatus() != Change.Status.MERGED;
-          }
-        }), new Function<ChangeNotes, ChangeData>() {
-          @Override
-          public ChangeData apply(ChangeNotes notes) {
-            return changeDataFactory.create(db, notes);
-          }
-        });
+    List<ChangeNotes> notes =
+        notesFactory.create(
+            db,
+            branch.getParentKey(),
+            changeIds,
+            cn -> {
+              Change c = cn.getChange();
+              return c.getDest().equals(branch) && c.getStatus() != Change.Status.MERGED;
+            });
+    return Lists.transform(notes, n -> changeDataFactory.create(db, n));
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
-      Branch.NameKey branch, List<String> hashes) throws OrmException {
-    return query(and(
-        ref(branch),
-        project(branch.getParentKey()),
-        not(status(Change.Status.MERGED)),
-        or(commits(hashes))));
+      Branch.NameKey branch, Collection<String> hashes) throws OrmException {
+    return query(
+        and(
+            ref(branch),
+            project(branch.getParentKey()),
+            not(status(Change.Status.MERGED)),
+            or(commits(hashes))));
   }
 
-  private static List<Predicate<ChangeData>> commits(List<String> hashes) {
+  private static List<Predicate<ChangeData>> commits(Collection<String> hashes) {
     List<Predicate<ChangeData>> commits = new ArrayList<>(hashes.size());
     for (String s : hashes) {
       commits.add(commit(s));
@@ -232,13 +218,11 @@
     return commits;
   }
 
-  public List<ChangeData> byProjectOpen(Project.NameKey project)
-      throws OrmException {
+  public List<ChangeData> byProjectOpen(Project.NameKey project) throws OrmException {
     return query(and(project(project), open()));
   }
 
-  public List<ChangeData> byTopicOpen(String topic)
-      throws OrmException {
+  public List<ChangeData> byTopicOpen(String topic) throws OrmException {
     return query(and(new ExactTopicPredicate(topic), open()));
   }
 
@@ -250,49 +234,45 @@
     return query(commit(hash));
   }
 
-  public List<ChangeData> byProjectCommit(Project.NameKey project,
-      String hash) throws OrmException {
+  public List<ChangeData> byProjectCommit(Project.NameKey project, ObjectId id)
+      throws OrmException {
+    return byProjectCommit(project, id.name());
+  }
+
+  public List<ChangeData> byProjectCommit(Project.NameKey project, String hash)
+      throws OrmException {
     return query(and(project(project), commit(hash)));
   }
 
-  public List<ChangeData> byProjectCommits(Project.NameKey project,
-      List<String> hashes) throws OrmException {
+  public List<ChangeData> byProjectCommits(Project.NameKey project, List<String> hashes)
+      throws OrmException {
     int n = indexConfig.maxTerms() - 1;
     checkArgument(hashes.size() <= n, "cannot exceed %s commits", n);
     return query(and(project(project), or(commits(hashes))));
   }
 
-  public List<ChangeData> byBranchCommit(String project, String branch,
-      String hash) throws OrmException {
-    return query(and(
-        new ProjectPredicate(project),
-        new RefPredicate(branch),
-        commit(hash)));
+  public List<ChangeData> byBranchCommit(String project, String branch, String hash)
+      throws OrmException {
+    return query(and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash)));
   }
 
-  public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash)
-      throws OrmException {
+  public List<ChangeData> byBranchCommit(Branch.NameKey branch, String hash) throws OrmException {
     return byBranchCommit(branch.getParentKey().get(), branch.get(), hash);
   }
 
   public List<ChangeData> bySubmissionId(String cs) throws OrmException {
-    if (Strings.isNullOrEmpty(cs) || !schema().hasField(SUBMISSIONID)) {
+    if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
     }
     return query(new SubmissionIdPredicate(cs));
   }
 
-  public List<ChangeData> byProjectGroups(Project.NameKey project,
-      Collection<String> groups) throws OrmException {
+  public List<ChangeData> byProjectGroups(Project.NameKey project, Collection<String> groups)
+      throws OrmException {
     List<GroupPredicate> groupPredicates = new ArrayList<>(groups.size());
     for (String g : groups) {
       groupPredicates.add(new GroupPredicate(g));
     }
     return query(and(project(project), or(groupPredicates)));
   }
-
-  @SuppressWarnings("deprecation")
-  public List<ChangeData> byIsStarred(Account.Id id) throws OrmException {
-    return query(new IsStarredByPredicate(id));
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
index 376ad84..50e5bd9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
@@ -17,14 +17,12 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
-
+import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-
 public class IsMergePredicate extends ChangeOperatorPredicate {
   private final Arguments args;
 
@@ -35,15 +33,13 @@
 
   @Override
   public boolean match(ChangeData cd) throws OrmException {
-    ObjectId id = ObjectId.fromString(
-        cd.currentPatchSet().getRevision().get());
-    try (Repository repo =
-          args.repoManager.openRepository(cd.change().getProject());
+    ObjectId id = ObjectId.fromString(cd.currentPatchSet().getRevision().get());
+    try (Repository repo = args.repoManager.openRepository(cd.change().getProject());
         RevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
       RevCommit commit = rw.parseCommit(id);
       return commit.getParentCount() > 1;
     } catch (IOException e) {
-      throw new IllegalStateException(e);
+      throw new OrmException(e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index 24fcd6b..92de09a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -20,15 +20,13 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Set;
 
 class IsReviewedPredicate extends ChangeIndexPredicate {
-  private static final Account.Id NOT_REVIEWED =
-      new Account.Id(ChangeField.NOT_REVIEWED);
+  private static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
 
   static Predicate<ChangeData> create() {
     return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
@@ -52,7 +50,7 @@
   @Override
   public boolean match(ChangeData cd) throws OrmException {
     Set<Account.Id> reviewedBy = cd.reviewedBy();
-    return !reviewedBy.isEmpty() ? reviewedBy.contains(id) : id == NOT_REVIEWED;
+    return !reviewedBy.isEmpty() ? reviewedBy.contains(id) : id.equals(NOT_REVIEWED);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java
deleted file mode 100644
index 19cbd23..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByLegacyPredicate.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.collect.Lists;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
-
-import java.util.List;
-import java.util.Set;
-
-@Deprecated
-class IsStarredByLegacyPredicate extends OrPredicate<ChangeData> {
-  private static List<Predicate<ChangeData>> predicates(Set<Change.Id> ids) {
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(ids.size());
-    for (Change.Id id : ids) {
-      r.add(new LegacyChangeIdPredicate(id));
-    }
-    return r;
-  }
-
-  private final Account.Id accountId;
-  private final Set<Change.Id> starredChanges;
-
-  IsStarredByLegacyPredicate(Account.Id accountId,
-      Set<Change.Id> starredChanges) {
-    super(predicates(starredChanges));
-    this.accountId = accountId;
-    this.starredChanges = starredChanges;
-  }
-
-  @Override
-  public boolean match(final ChangeData object) {
-    return starredChanges.contains(object.getId());
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_STARREDBY + ":" + accountId.toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
deleted file mode 100644
index 929ed18..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-
-@Deprecated
-class IsStarredByPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
-
-  IsStarredByPredicate(Account.Id accountId) {
-    super(ChangeField.STARREDBY, accountId.toString());
-    this.accountId = accountId;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) throws OrmException {
-    return cd.starredBy().contains(accountId);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_STARREDBY + ":" + accountId;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
new file mode 100644
index 0000000..17a6347
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+
+public class IsUnresolvedPredicate extends IntegerRangeChangePredicate {
+  IsUnresolvedPredicate() throws QueryParseException {
+    this(">0");
+  }
+
+  IsUnresolvedPredicate(String value) throws QueryParseException {
+    super(ChangeField.UNRESOLVED_COMMENT_COUNT, value);
+  }
+
+  @Override
+  protected Integer getValueInt(ChangeData changeData) throws OrmException {
+    return ChangeField.UNRESOLVED_COMMENT_COUNT.get(changeData, null);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index 0b7a2f0..dda834b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryBuilder;
 import com.google.gerrit.server.query.QueryParseException;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -37,15 +36,14 @@
 
   private final CurrentUser user;
 
-  IsWatchedByPredicate(ChangeQueryBuilder.Arguments args,
-      boolean checkIsVisible) throws QueryParseException {
+  IsWatchedByPredicate(ChangeQueryBuilder.Arguments args, boolean checkIsVisible)
+      throws QueryParseException {
     super(filters(args, checkIsVisible));
     this.user = args.getUser();
   }
 
   private static List<Predicate<ChangeData>> filters(
-      ChangeQueryBuilder.Arguments args,
-      boolean checkIsVisible) throws QueryParseException {
+      ChangeQueryBuilder.Arguments args, boolean checkIsVisible) throws QueryParseException {
     List<Predicate<ChangeData>> r = new ArrayList<>();
     ChangeQueryBuilder builder = new ChangeQueryBuilder(args);
     for (ProjectWatchKey w : getWatches(args)) {
@@ -58,7 +56,7 @@
             // will never match and return null. Yes this test
             // prevents you from having a filter that matches what
             // another user is filtering on. :-)
-           continue;
+            continue;
           }
         } catch (QueryParseException e) {
           continue;
@@ -91,14 +89,13 @@
     }
   }
 
-  private static Collection<ProjectWatchKey> getWatches(
-      ChangeQueryBuilder.Arguments args) throws QueryParseException {
+  private static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
+      throws QueryParseException {
     CurrentUser user = args.getUser();
     if (user.isIdentifiedUser()) {
-      return args.accountCache.get(args.getUser().getAccountId())
-          .getProjectWatches().keySet();
+      return args.accountCache.get(args.getUser().getAccountId()).getProjectWatches().keySet();
     }
-    return Collections.<ProjectWatchKey> emptySet();
+    return Collections.<ProjectWatchKey>emptySet();
   }
 
   private static List<Predicate<ChangeData>> none() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2f815b2..2fbaa1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -19,6 +19,8 @@
 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.index.FieldDef;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.OrPredicate;
@@ -27,7 +29,6 @@
 import com.google.gerrit.server.util.RangeUtil;
 import com.google.gerrit.server.util.RangeUtil.Range;
 import com.google.inject.Provider;
-
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
@@ -36,6 +37,7 @@
   private static final int MAX_LABEL_VALUE = 4;
 
   static class Args {
+    final FieldDef<ChangeData, ?> field;
     final ProjectCache projectCache;
     final ChangeControl.GenericFactory ccFactory;
     final IdentifiedUser.GenericFactory userFactory;
@@ -45,6 +47,7 @@
     final AccountGroup.UUID group;
 
     private Args(
+        FieldDef<ChangeData, ?> field,
         ProjectCache projectCache,
         ChangeControl.GenericFactory ccFactory,
         IdentifiedUser.GenericFactory userFactory,
@@ -52,6 +55,7 @@
         String value,
         Set<Account.Id> accounts,
         AccountGroup.UUID group) {
+      this.field = field;
       this.projectCache = projectCache;
       this.ccFactory = ccFactory;
       this.userFactory = userFactory;
@@ -76,12 +80,23 @@
 
   private final String value;
 
-  LabelPredicate(ProjectCache projectCache,
-      ChangeControl.GenericFactory ccFactory,
-      IdentifiedUser.GenericFactory userFactory, Provider<ReviewDb> dbProvider,
-      String value, Set<Account.Id> accounts, AccountGroup.UUID group) {
-    super(predicates(new Args(projectCache, ccFactory, userFactory, dbProvider,
-        value, accounts, group)));
+  @SuppressWarnings("deprecation")
+  LabelPredicate(
+      ChangeQueryBuilder.Arguments a,
+      String value,
+      Set<Account.Id> accounts,
+      AccountGroup.UUID group) {
+    super(
+        predicates(
+            new Args(
+                a.getSchema().getField(ChangeField.LABEL2, ChangeField.LABEL).get(),
+                a.projectCache,
+                a.changeControlGenericFactory,
+                a.userFactory,
+                a.db,
+                value,
+                accounts,
+                group)));
     this.value = value;
   }
 
@@ -110,27 +125,22 @@
         range = new Range(v, 1, 1);
       }
     } else {
-      range = RangeUtil.getRange(
-          parsed.label,
-          parsed.test,
-          parsed.expVal,
-          -MAX_LABEL_VALUE,
-          MAX_LABEL_VALUE);
+      range =
+          RangeUtil.getRange(
+              parsed.label, parsed.test, parsed.expVal, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
     }
     String prefix = range.prefix;
     int min = range.min;
     int max = range.max;
 
-    List<Predicate<ChangeData>> r =
-        Lists.newArrayListWithCapacity(max - min + 1);
+    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(max - min + 1);
     for (int i = min; i <= max; i++) {
       r.add(onePredicate(args, prefix, i));
     }
     return r;
   }
 
-  private static Predicate<ChangeData> onePredicate(Args args, String label,
-      int expVal) {
+  private static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
     if (expVal != 0) {
       return equalsLabelPredicate(args, label, expVal);
     }
@@ -138,8 +148,7 @@
   }
 
   private static Predicate<ChangeData> noLabelQuery(Args args, String label) {
-    List<Predicate<ChangeData>> r =
-        Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
+    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
     for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
       r.add(equalsLabelPredicate(args, label, i));
       r.add(equalsLabelPredicate(args, label, -i));
@@ -147,8 +156,7 @@
     return not(or(r));
   }
 
-  private static Predicate<ChangeData> equalsLabelPredicate(Args args,
-      String label, int expVal) {
+  private static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
     if (args.accounts == null || args.accounts.isEmpty()) {
       return new EqualsLabelPredicate(args, label, expVal, null);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
index 425eb00..f7f98d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
@@ -22,7 +22,7 @@
 public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
   private final Change.Id id;
 
-  LegacyChangeIdPredicate(Change.Id id) {
+  public LegacyChangeIdPredicate(Change.Id id) {
     super(LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
deleted file mode 100644
index cd93ed3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-
-@Deprecated
-class LegacyReviewerPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
-
-  LegacyReviewerPredicate(Account.Id id) {
-    super(ChangeField.LEGACY_REVIEWER, id.toString());
-    this.id = id;
-  }
-
-  Account.Id getAccountId() {
-    return id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) throws OrmException {
-    return object.reviewers().all().contains(id);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
index 722a8ad..9e525c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -21,10 +21,7 @@
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-/**
- * Predicate to match changes that contains specified text in commit messages
- * body.
- */
+/** Predicate to match changes that contains specified text in commit messages body. */
 class MessagePredicate extends ChangeIndexPredicate {
   private final ChangeIndex index;
 
@@ -36,10 +33,8 @@
   @Override
   public boolean match(ChangeData object) throws OrmException {
     try {
-      Predicate<ChangeData> p = Predicate.and(
-          new LegacyChangeIdPredicate(object.getId()), this);
-      for (ChangeData cData
-          : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
+      Predicate<ChangeData> p = Predicate.and(new LegacyChangeIdPredicate(object.getId()), this);
+      for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
         if (cData.getId().equals(object.getId())) {
           return true;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
index 28aef5e..90c2fb3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
@@ -20,7 +20,6 @@
 import com.google.gwtorm.server.ListResultSet;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -57,8 +56,7 @@
   @Override
   public boolean hasChange() {
     for (Predicate<ChangeData> p : getChildren()) {
-      if (!(p instanceof ChangeDataSource)
-          || !((ChangeDataSource) p).hasChange()) {
+      if (!(p instanceof ChangeDataSource) || !((ChangeDataSource) p).hasChange()) {
         return false;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 496eff6..cd98087 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -36,15 +36,6 @@
 import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
-import org.joda.time.format.DateTimeFormat;
-import org.joda.time.format.DateTimeFormatter;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -57,20 +48,23 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+import org.joda.time.format.DateTimeFormat;
+import org.joda.time.format.DateTimeFormatter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-/**
- * Change query implementation that outputs to a stream in the style of an SSH
- * command.
- */
+/** Change query implementation that outputs to a stream in the style of an SSH command. */
 public class OutputStreamQuery {
-  private static final Logger log =
-      LoggerFactory.getLogger(OutputStreamQuery.class);
+  private static final Logger log = LoggerFactory.getLogger(OutputStreamQuery.class);
 
-  private static final DateTimeFormatter dtf =
-      DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss zzz");
+  private static final DateTimeFormatter dtf = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss zzz");
 
   public enum OutputFormat {
-    TEXT, JSON
+    TEXT,
+    JSON
   }
 
   private final ReviewDb db;
@@ -179,9 +173,10 @@
   }
 
   public void query(String queryString) throws IOException {
-    out = new PrintWriter( //
-        new BufferedWriter( //
-            new OutputStreamWriter(outputStream, UTF_8)));
+    out =
+        new PrintWriter( //
+            new BufferedWriter( //
+                new OutputStreamWriter(outputStream, UTF_8)));
     try {
       if (queryProcessor.isDisabled()) {
         ErrorMessage m = new ErrorMessage();
@@ -196,8 +191,7 @@
 
         Map<Project.NameKey, Repository> repos = new HashMap<>();
         Map<Project.NameKey, RevWalk> revWalks = new HashMap<>();
-        QueryResult<ChangeData> results =
-            queryProcessor.query(queryBuilder.parse(queryString));
+        QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString));
         try {
           for (ChangeData d : results.entities()) {
             show(buildChangeAttribute(d, repos, revWalks));
@@ -208,8 +202,7 @@
 
         stats.rowCount = results.entities().size();
         stats.moreChanges = results.more();
-        stats.runTimeMilliseconds =
-            TimeUtil.nowMs() - stats.runTimeMilliseconds;
+        stats.runTimeMilliseconds = TimeUtil.nowMs() - stats.runTimeMilliseconds;
         show(stats);
       } catch (OrmException err) {
         log.error("Cannot execute query: " + queryString, err);
@@ -232,9 +225,8 @@
     }
   }
 
-  private ChangeAttribute buildChangeAttribute(ChangeData d,
-      Map<Project.NameKey, Repository> repos,
-      Map<Project.NameKey, RevWalk> revWalks)
+  private ChangeAttribute buildChangeAttribute(
+      ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks)
       throws OrmException, IOException {
     ChangeControl cc = d.changeControl().forUser(user);
 
@@ -243,8 +235,7 @@
     eventFactory.extend(c, d.change());
 
     if (!trackingFooters.isEmpty()) {
-      eventFactory.addTrackingIds(c,
-          trackingFooters.extract(d.commitFooters()));
+      eventFactory.addTrackingIds(c, trackingFooters.extract(d.commitFooters()));
     }
 
     if (includeAllReviewers) {
@@ -252,10 +243,8 @@
     }
 
     if (includeSubmitRecords) {
-      eventFactory.addSubmitRecords(c, new SubmitRuleEvaluator(d)
-          .setAllowClosed(true)
-          .setAllowDraft(true)
-          .evaluate());
+      eventFactory.addSubmitRecords(
+          c, new SubmitRuleEvaluator(d).setAllowClosed(true).setAllowDraft(true).evaluate());
     }
 
     if (includeCommitMessage) {
@@ -276,26 +265,28 @@
     }
 
     if (includePatchSets) {
-      eventFactory.addPatchSets(db, rw, c, d.visiblePatchSets(),
+      eventFactory.addPatchSets(
+          db,
+          rw,
+          c,
+          d.visiblePatchSets(),
           includeApprovals ? d.approvals().asMap() : null,
-          includeFiles, d.change(), labelTypes);
+          includeFiles,
+          d.change(),
+          labelTypes);
     }
 
     if (includeCurrentPatchSet) {
       PatchSet current = d.currentPatchSet();
       if (current != null && cc.isPatchVisible(current, d.db())) {
-        c.currentPatchSet =
-            eventFactory.asPatchSetAttribute(db, rw, d.change(), current);
-        eventFactory.addApprovals(c.currentPatchSet,
-            d.currentApprovals(), labelTypes);
+        c.currentPatchSet = eventFactory.asPatchSetAttribute(db, rw, d.change(), current);
+        eventFactory.addApprovals(c.currentPatchSet, d.currentApprovals(), labelTypes);
 
         if (includeFiles) {
-          eventFactory.addPatchSetFileNames(c.currentPatchSet,
-              d.change(), d.currentPatchSet());
+          eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
         }
         if (includeComments) {
-          eventFactory.addPatchSetComments(c.currentPatchSet,
-              d.publishedComments());
+          eventFactory.addPatchSetComments(c.currentPatchSet, d.publishedComments());
         }
       }
     }
@@ -303,12 +294,17 @@
     if (includeComments) {
       eventFactory.addComments(c, d.messages());
       if (includePatchSets) {
-        eventFactory.addPatchSets(db, rw, c, d.visiblePatchSets(),
+        eventFactory.addPatchSets(
+            db,
+            rw,
+            c,
+            d.visiblePatchSets(),
             includeApprovals ? d.approvals().asMap() : null,
-            includeFiles, d.change(), labelTypes);
+            includeFiles,
+            d.change(),
+            labelTypes);
         for (PatchSetAttribute attribute : c.patchSets) {
-          eventFactory.addPatchSetComments(
-              attribute, d.publishedComments());
+          eventFactory.addPatchSetComments(attribute, d.publishedComments());
         }
       }
     }
@@ -320,8 +316,7 @@
     return c;
   }
 
-  private static void closeAll(Iterable<RevWalk> revWalks,
-      Iterable<Repository> repos) {
+  private static void closeAll(Iterable<RevWalk> revWalks, Iterable<Repository> repos) {
     if (repos != null) {
       for (Repository repo : repos) {
         repo.close();
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 72327ba..f3239af 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
@@ -23,8 +23,7 @@
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountGroup.UUID uuid;
 
-  OwnerinPredicate(IdentifiedUser.GenericFactory userFactory,
-    AccountGroup.UUID uuid) {
+  OwnerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_OWNERIN, uuid.toString());
     this.userFactory = userFactory;
     this.uuid = uuid;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 0cd6978..d3a3f20 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.inject.Provider;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -32,8 +31,10 @@
 class ParentProjectPredicate extends OrPredicate<ChangeData> {
   private final String value;
 
-  ParentProjectPredicate(ProjectCache projectCache,
-      Provider<ListChildProjects> listChildProjects, Provider<CurrentUser> self,
+  ParentProjectPredicate(
+      ProjectCache projectCache,
+      Provider<ListChildProjects> listChildProjects,
+      Provider<CurrentUser> self,
       String value) {
     super(predicates(projectCache, listChildProjects, self, value));
     this.value = value;
@@ -42,7 +43,8 @@
   private static List<Predicate<ChangeData>> predicates(
       ProjectCache projectCache,
       Provider<ListChildProjects> listChildProjects,
-      Provider<CurrentUser> self, String value) {
+      Provider<CurrentUser> self,
+      String value) {
     ProjectState projectState = projectCache.get(new Project.NameKey(value));
     if (projectState == null) {
       return Collections.emptyList();
@@ -52,8 +54,7 @@
     r.add(new ProjectPredicate(projectState.getProject().getName()));
     ListChildProjects children = listChildProjects.get();
     children.setRecursive(true);
-    for (ProjectInfo p : children.apply(new ProjectResource(
-        projectState.controlFor(self.get())))) {
+    for (ProjectInfo p : children.apply(new ProjectResource(projectState.controlFor(self.get())))) {
       r.add(new ProjectPredicate(p.name));
     }
     return r;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
index 2fd0177..1fbc1aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.query.QueryParseException;
-
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -23,12 +22,11 @@
 
 /**
  * This class is used to extract comma separated values in a predicate.
- * <p>
- * If tags for the values are present (e.g. "branch=jb_2.3,vote=approved") then
- * the args are placed in a map that maps tag to value (e.g., "branch" to "jb_2.3").
- * If no tag is present (e.g. "jb_2.3,approved") then the args are placed into a
- * positional list.  Args may be mixed so some may appear in the map and others
- * in the positional list (e.g. "vote=approved,jb_2.3).
+ *
+ * <p>If tags for the values are present (e.g. "branch=jb_2.3,vote=approved") then the args are
+ * placed in a map that maps tag to value (e.g., "branch" to "jb_2.3"). If no tag is present (e.g.
+ * "jb_2.3,approved") then the args are placed into a positional list. Args may be mixed so some may
+ * appear in the map and others in the positional list (e.g. "vote=approved,jb_2.3).
  */
 public class PredicateArgs {
   public List<String> positional;
@@ -36,9 +34,9 @@
 
   /**
    * Parses query arguments into {@link #keyValue} and/or {@link #positional}..
-   * <p>
-   * Labels for these arguments should be kept in ChangeQueryBuilder
-   * as {@code ARG_ID_[argument name]}.
+   *
+   * <p>Labels for these arguments should be kept in ChangeQueryBuilder as {@code ARG_ID_[argument
+   * name]}.
    *
    * @param args arguments to be parsed
    * @throws QueryParseException
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
index 69a392b..cc1b95d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
@@ -14,6 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -21,19 +27,17 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.QueryRequiresAuthException;
 import com.google.gerrit.server.query.QueryResult;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import org.kohsuke.args4j.Option;
 
 public class QueryChanges implements RestReadView<TopLevelResource> {
   private final ChangeJson.Factory json;
@@ -41,10 +45,18 @@
   private final ChangeQueryProcessor imp;
   private EnumSet<ListChangesOption> options;
 
-  @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", usage = "Query string")
+  @Option(
+      name = "--query",
+      aliases = {"-q"},
+      metaVar = "QUERY",
+      usage = "Query string")
   private List<String> queries;
 
-  @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "Maximum number of results to return")
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "Maximum number of results to return")
   public void setLimit(int limit) {
     imp.setLimit(limit);
   }
@@ -59,15 +71,17 @@
     options.addAll(ListChangesOption.fromBits(Integer.parseInt(hex, 16)));
   }
 
-  @Option(name = "--start", aliases = {"-S"}, metaVar = "CNT", usage = "Number of changes to skip")
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "Number of changes to skip")
   public void setStart(int start) {
     imp.setStart(start);
   }
 
   @Inject
-  QueryChanges(ChangeJson.Factory json,
-      ChangeQueryBuilder qb,
-      ChangeQueryProcessor qp) {
+  QueryChanges(ChangeJson.Factory json, ChangeQueryBuilder qb, ChangeQueryProcessor qp) {
     this.json = json;
     this.qb = qb;
     this.imp = qp;
@@ -92,22 +106,15 @@
     List<List<ChangeInfo>> out;
     try {
       out = query();
+    } catch (QueryRequiresAuthException e) {
+      throw new AuthException("Must be signed-in to use this operator");
     } catch (QueryParseException e) {
-      // This is a hack to detect an operator that requires authentication.
-      Pattern p = Pattern.compile(
-          "^Error in operator (.*:self|is:watched|is:owner|is:reviewer|has:.*)$");
-      Matcher m = p.matcher(e.getMessage());
-      if (m.matches()) {
-        String op = m.group(1);
-        throw new AuthException("Must be signed-in to use " + op);
-      }
-      throw new BadRequestException(e.getMessage());
+      throw new BadRequestException(e.getMessage(), e);
     }
     return out.size() == 1 ? out.get(0) : out;
   }
 
-  private List<List<ChangeInfo>> query()
-      throws OrmException, QueryParseException {
+  private List<List<ChangeInfo>> query() throws OrmException, QueryParseException {
     if (imp.isDisabled()) {
       throw new QueryParseException("query disabled");
     }
@@ -121,14 +128,24 @@
 
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
-    List<List<ChangeInfo>> res = json.create(options)
-        .formatQueryResults(results);
+    boolean requireLazyLoad =
+        containsAnyOf(options, ImmutableSet.of(DETAILED_LABELS, LABELS))
+            && !qb.getArgs().getSchema().hasField(ChangeField.STORED_SUBMIT_RECORD_LENIENT);
+    List<List<ChangeInfo>> res =
+        json.create(options)
+            .lazyLoad(requireLazyLoad || containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
+            .formatQueryResults(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
-      if (results.get(n).more()) {
-        info.get(info.size() - 1)._moreChanges = true;
+      if (results.get(n).more() && !info.isEmpty()) {
+        Iterables.getLast(info)._moreChanges = true;
       }
     }
     return res;
   }
+
+  private static boolean containsAnyOf(
+      EnumSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
+    return !Sets.intersection(toFind, set).isEmpty();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 67efd69..5b9774c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.RegexListSearcher;
 import com.google.gwtorm.server.OrmException;
-
 import java.util.List;
 
 class RegexPathPredicate extends ChangeRegexPredicate {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index 007566e..1284e88 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index c6d1577..671d4cc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
index a02edd1..a4ba059 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
@@ -18,7 +18,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
-
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
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 1c4fbbb..6ce02fb 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
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -21,38 +23,50 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
-
-import java.util.ArrayList;
-import java.util.List;
+import java.util.stream.Stream;
 
 class ReviewerPredicate extends ChangeIndexPredicate {
-  @SuppressWarnings("deprecation")
-  static Predicate<ChangeData> create(Arguments args, Account.Id id) {
-    List<Predicate<ChangeData>> and = new ArrayList<>(2);
-    if (args.getSchema().hasField(ChangeField.REVIEWER)) {
-      ReviewerStateInternal[] states = ReviewerStateInternal.values();
-      List<Predicate<ChangeData>> or = new ArrayList<>(states.length - 1);
-      for (ReviewerStateInternal state : states) {
-        if (state != ReviewerStateInternal.REMOVED) {
-          or.add(new ReviewerPredicate(state, id));
-        }
-      }
-      and.add(Predicate.or(or));
+  static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
+    Predicate<ChangeData> p;
+    if (args.notesMigration.readChanges()) {
+      // With NoteDb, Reviewer/CC are clearly distinct states, so only choose reviewer.
+      p = new ReviewerPredicate(ReviewerStateInternal.REVIEWER, id);
     } else {
-      and.add(new LegacyReviewerPredicate(id));
+      // Without NoteDb, Reviewer/CC are a bit unpredictable; maintain the old behavior of matching
+      // any reviewer state.
+      p = anyReviewerState(id);
     }
+    return create(args, p);
+  }
 
-    // TODO(dborowitz): This really belongs much higher up e.g. QueryProcessor.
+  static Predicate<ChangeData> cc(Arguments args, Account.Id id) {
+    // As noted above, CC is nebulous without NoteDb, but it certainly doesn't make sense to return
+    // Reviewers for cc:foo. Most likely this will just not match anything, but let the index sort
+    // it out.
+    return create(args, new ReviewerPredicate(ReviewerStateInternal.CC, id));
+  }
+
+  private static Predicate<ChangeData> anyReviewerState(Account.Id id) {
+    return Predicate.or(
+        Stream.of(ReviewerStateInternal.values())
+            .filter(s -> s != ReviewerStateInternal.REMOVED)
+            .map(s -> new ReviewerPredicate(s, id))
+            .collect(toList()));
+  }
+
+  private static Predicate<ChangeData> create(Arguments args, Predicate<ChangeData> p) {
     if (!args.allowsDrafts) {
-      and.add(Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
+      // TODO(dborowitz): This really belongs much higher up e.g. QueryProcessor. Also, why are we
+      // even doing this?
+      return Predicate.and(p, Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
     }
-    return Predicate.and(and);
+    return p;
   }
 
   private final ReviewerStateInternal state;
   private final Account.Id id;
 
-  ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
+  private ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
     super(ChangeField.REVIEWER, ChangeField.getReviewerFieldValue(state, id));
     this.state = state;
     this.id = id;
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 34c10e3..63e7859 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
@@ -23,8 +23,7 @@
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountGroup.UUID uuid;
 
-  ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory,
-    AccountGroup.UUID uuid) {
+  ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_REVIEWERIN, uuid.toString());
     this.userFactory = userFactory;
     this.uuid = uuid;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
index 33b338c..2661b8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
@@ -19,20 +19,19 @@
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
-
 import java.util.Collections;
 import java.util.Set;
 
 public final class SingleGroupUser extends CurrentUser {
   private final GroupMembership groups;
 
-  public SingleGroupUser(CapabilityControl.Factory capabilityControlFactory,
-      AccountGroup.UUID groupId) {
+  public SingleGroupUser(
+      CapabilityControl.Factory capabilityControlFactory, AccountGroup.UUID groupId) {
     this(capabilityControlFactory, Collections.singleton(groupId));
   }
 
-  public SingleGroupUser(CapabilityControl.Factory capabilityControlFactory,
-      Set<AccountGroup.UUID> groups) {
+  public SingleGroupUser(
+      CapabilityControl.Factory capabilityControlFactory, Set<AccountGroup.UUID> groups) {
     super(capabilityControlFactory);
     this.groups = new ListGroupMembership(groups);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
index a31254f..98965bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -24,8 +24,7 @@
   private final String label;
 
   StarPredicate(Account.Id accountId, String label) {
-    super(ChangeField.STAR,
-        StarredChangesUtil.StarField.create(accountId, label).toString());
+    super(ChangeField.STAR, StarredChangesUtil.StarField.create(accountId, label).toString());
     this.accountId = accountId;
     this.label = label;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
new file mode 100644
index 0000000..451230f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -0,0 +1,52 @@
+// 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.query.change;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gwtorm.server.OrmException;
+import java.util.Set;
+
+class SubmitRecordPredicate extends ChangeIndexPredicate {
+  static Predicate<ChangeData> create(
+      String label, SubmitRecord.Label.Status status, Set<Account.Id> accounts) {
+    String lowerLabel = label.toLowerCase();
+    if (accounts == null || accounts.isEmpty()) {
+      return new SubmitRecordPredicate(status.name() + ',' + lowerLabel);
+    }
+    return Predicate.or(
+        accounts.stream()
+            .map(a -> new SubmitRecordPredicate(status.name() + ',' + lowerLabel + ',' + a.get()))
+            .collect(toList()));
+  }
+
+  private SubmitRecordPredicate(String value) {
+    super(ChangeField.SUBMIT_RECORD, value);
+  }
+
+  @Override
+  public boolean match(ChangeData in) throws OrmException {
+    return ChangeField.formatSubmitRecordValues(in).contains(getValue());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
new file mode 100644
index 0000000..8782cfd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -0,0 +1,39 @@
+// 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.query.change;
+
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+
+class SubmittablePredicate extends ChangeIndexPredicate {
+  private final SubmitRecord.Status status;
+
+  SubmittablePredicate(SubmitRecord.Status status) {
+    super(ChangeField.SUBMIT_RECORD, status.name());
+    this.status = status;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.submitRecords(ChangeField.SUBMIT_RULE_OPTIONS_STRICT).stream()
+        .anyMatch(r -> r.status == status);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
index 9242d9d..f0ac127 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
@@ -17,13 +17,12 @@
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.TimestampRangePredicate;
 import com.google.gerrit.server.query.Matchable;
-
 import java.sql.Timestamp;
 
-public abstract class TimestampRangeChangePredicate extends
-    TimestampRangePredicate<ChangeData> implements Matchable<ChangeData> {
-  protected TimestampRangeChangePredicate(FieldDef<ChangeData, Timestamp> def,
-      String name, String value) {
+public abstract class TimestampRangeChangePredicate extends TimestampRangePredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected TimestampRangeChangePredicate(
+      FieldDef<ChangeData, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
index e9be4cd..afaea5c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
@@ -18,14 +18,12 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
-
+import java.io.IOException;
+import java.util.List;
 import org.eclipse.jgit.revwalk.FooterLine;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.List;
-
 class TrackingIdPredicate extends ChangeIndexPredicate {
   private static final Logger log = LoggerFactory.getLogger(TrackingIdPredicate.class);
 
@@ -42,8 +40,8 @@
     if (c != null) {
       try {
         List<FooterLine> footers = object.commitFooters();
-        return footers != null && trackingFooters.extract(
-            object.commitFooters()).values().contains(getValue());
+        return footers != null
+            && trackingFooters.extract(object.commitFooters()).values().contains(getValue());
       } catch (IOException e) {
         log.warn("Cannot extract footers from " + c.getChangeId(), e);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
new file mode 100644
index 0000000..8f72945
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.group;
+
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gerrit.server.query.account.AccountQueryBuilder;
+import com.google.gwtorm.server.OrmException;
+
+public class GroupIsVisibleToPredicate extends IsVisibleToPredicate<AccountGroup> {
+  private final GroupControl.GenericFactory groupControlFactory;
+  private final CurrentUser user;
+
+  GroupIsVisibleToPredicate(GroupControl.GenericFactory groupControlFactory, CurrentUser user) {
+    super(AccountQueryBuilder.FIELD_VISIBLETO, describe(user));
+    this.groupControlFactory = groupControlFactory;
+    this.user = user;
+  }
+
+  @Override
+  public boolean match(AccountGroup group) throws OrmException {
+    try {
+      return groupControlFactory.controlFor(user, group.getGroupUUID()).isVisible();
+    } catch (NoSuchGroupException e) {
+      // Ignored
+      return false;
+    }
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java
new file mode 100644
index 0000000..650024c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.group;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.query.Predicate;
+import java.util.Locale;
+
+public class GroupPredicates {
+  public static Predicate<AccountGroup> uuid(AccountGroup.UUID uuid) {
+    return new GroupPredicate(GroupField.UUID, GroupQueryBuilder.FIELD_UUID, uuid.get());
+  }
+
+  public static Predicate<AccountGroup> description(String description) {
+    return new GroupPredicate(
+        GroupField.DESCRIPTION, GroupQueryBuilder.FIELD_DESCRIPTION, description);
+  }
+
+  public static Predicate<AccountGroup> inname(String name) {
+    return new GroupPredicate(
+        GroupField.NAME_PART, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
+  }
+
+  public static Predicate<AccountGroup> name(String name) {
+    return new GroupPredicate(
+        GroupField.NAME, GroupQueryBuilder.FIELD_NAME, name.toLowerCase(Locale.US));
+  }
+
+  public static Predicate<AccountGroup> owner(AccountGroup.UUID ownerUuid) {
+    return new GroupPredicate(
+        GroupField.OWNER_UUID, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
+  }
+
+  public static Predicate<AccountGroup> isVisibleToAll() {
+    return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL, "1");
+  }
+
+  static class GroupPredicate extends IndexPredicate<AccountGroup> {
+    GroupPredicate(FieldDef<AccountGroup, ?> def, String value) {
+      super(def, value);
+    }
+
+    GroupPredicate(FieldDef<AccountGroup, ?> def, String name, String value) {
+      super(def, name, value);
+    }
+  }
+
+  private GroupPredicates() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
new file mode 100644
index 0000000..3197ab7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.group;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.query.LimitPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryBuilder;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.inject.Inject;
+import java.util.List;
+
+/** Parses a query string meant to be applied to group objects. */
+public class GroupQueryBuilder extends QueryBuilder<AccountGroup> {
+  public static final String FIELD_UUID = "uuid";
+  public static final String FIELD_DESCRIPTION = "description";
+  public static final String FIELD_INNAME = "inname";
+  public static final String FIELD_NAME = "name";
+  public static final String FIELD_OWNER = "owner";
+  public static final String FIELD_LIMIT = "limit";
+
+  private static final QueryBuilder.Definition<AccountGroup, GroupQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(GroupQueryBuilder.class);
+
+  public static class Arguments {
+    final GroupCache groupCache;
+    final GroupBackend groupBackend;
+
+    @Inject
+    Arguments(GroupCache groupCache, GroupBackend groupBackend) {
+      this.groupCache = groupCache;
+      this.groupBackend = groupBackend;
+    }
+  }
+
+  private final Arguments args;
+
+  @Inject
+  GroupQueryBuilder(Arguments args) {
+    super(mydef);
+    this.args = args;
+  }
+
+  @Operator
+  public Predicate<AccountGroup> uuid(String uuid) {
+    return GroupPredicates.uuid(new AccountGroup.UUID(uuid));
+  }
+
+  @Operator
+  public Predicate<AccountGroup> description(String description) throws QueryParseException {
+    if (Strings.isNullOrEmpty(description)) {
+      throw error("description operator requires a value");
+    }
+
+    return GroupPredicates.description(description);
+  }
+
+  @Operator
+  public Predicate<AccountGroup> inname(String namePart) {
+    if (namePart.isEmpty()) {
+      return name(namePart);
+    }
+    return GroupPredicates.inname(namePart);
+  }
+
+  @Operator
+  public Predicate<AccountGroup> name(String name) {
+    return GroupPredicates.name(name);
+  }
+
+  @Operator
+  public Predicate<AccountGroup> owner(String owner) throws QueryParseException {
+    AccountGroup group = args.groupCache.get(new AccountGroup.UUID(owner));
+    if (group != null) {
+      return GroupPredicates.owner(group.getGroupUUID());
+    }
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, owner);
+    if (g == null) {
+      throw error("Group " + owner + " not found");
+    }
+    return GroupPredicates.owner(g.getUUID());
+  }
+
+  @Operator
+  public Predicate<AccountGroup> is(String value) throws QueryParseException {
+    if ("visibletoall".equalsIgnoreCase(value)) {
+      return GroupPredicates.isVisibleToAll();
+    }
+    throw error("Invalid query");
+  }
+
+  @Override
+  protected Predicate<AccountGroup> defaultField(String query) throws QueryParseException {
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<AccountGroup>> preds = Lists.newArrayListWithCapacity(5);
+    preds.add(uuid(query));
+    preds.add(name(query));
+    preds.add(inname(query));
+    if (!Strings.isNullOrEmpty(query)) {
+      preds.add(description(query));
+    }
+    try {
+      preds.add(owner(query));
+    } catch (QueryParseException e) {
+      // Skip.
+    }
+    return Predicate.or(preds);
+  }
+
+  @Operator
+  public Predicate<AccountGroup> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
new file mode 100644
index 0000000..1cfab20
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.group;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.query.group.GroupQueryBuilder.FIELD_LIMIT;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndexRewriter;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.query.AndSource;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryProcessor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class GroupQueryProcessor extends QueryProcessor<AccountGroup> {
+  private final GroupControl.GenericFactory groupControlFactory;
+
+  static {
+    // It is assumed that basic rewrites do not touch visibleto predicates.
+    checkState(
+        !GroupIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
+        "GroupQueryProcessor assumes visibleto is not used by the index rewriter.");
+  }
+
+  @Inject
+  protected GroupQueryProcessor(
+      Provider<CurrentUser> userProvider,
+      Metrics metrics,
+      IndexConfig indexConfig,
+      GroupIndexCollection indexes,
+      GroupIndexRewriter rewriter,
+      GroupControl.GenericFactory groupControlFactory) {
+    super(
+        userProvider,
+        metrics,
+        GroupSchemaDefinitions.INSTANCE,
+        indexConfig,
+        indexes,
+        rewriter,
+        FIELD_LIMIT);
+    this.groupControlFactory = groupControlFactory;
+  }
+
+  @Override
+  protected Predicate<AccountGroup> enforceVisibility(Predicate<AccountGroup> pred) {
+    return new AndSource<>(
+        pred, new GroupIsVisibleToPredicate(groupControlFactory, userProvider.get()), start);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
index 2f49f9e..3b87fb6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AclUtil.java
@@ -22,18 +22,26 @@
 import com.google.gerrit.server.git.ProjectConfig;
 
 public class AclUtil {
-  public static void grant(ProjectConfig config, AccessSection section,
-      String permission, GroupReference... groupList) {
+  public static void grant(
+      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
     grant(config, section, permission, false, groupList);
   }
 
-  public static void grant(ProjectConfig config, AccessSection section,
-      String permission, boolean force, GroupReference... groupList) {
+  public static void grant(
+      ProjectConfig config,
+      AccessSection section,
+      String permission,
+      boolean force,
+      GroupReference... groupList) {
     grant(config, section, permission, force, null, groupList);
   }
 
-  public static void grant(ProjectConfig config, AccessSection section,
-      String permission, boolean force, Boolean exclusive,
+  public static void grant(
+      ProjectConfig config,
+      AccessSection section,
+      String permission,
+      boolean force,
+      Boolean exclusive,
       GroupReference... groupList) {
     Permission p = section.getPermission(permission, true);
     if (exclusive != null) {
@@ -48,9 +56,25 @@
     }
   }
 
-  public static void grant(ProjectConfig config,
-      AccessSection section, LabelType type,
-      int min, int max, GroupReference... groupList) {
+  public static void block(
+      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+    Permission p = section.getPermission(permission, true);
+    for (GroupReference group : groupList) {
+      if (group != null) {
+        PermissionRule r = rule(config, group);
+        r.setBlock();
+        p.add(r);
+      }
+    }
+  }
+
+  public static void grant(
+      ProjectConfig config,
+      AccessSection section,
+      LabelType type,
+      int min,
+      int max,
+      GroupReference... groupList) {
     String name = Permission.LABEL + type.getName();
     Permission p = section.getPermission(name, true);
     for (GroupReference group : groupList) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 7c7417a..9a56aa4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -35,30 +37,39 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.inject.Inject;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-
-import java.io.IOException;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** Creates the {@code All-Projects} repository and initial ACLs. */
 public class AllProjectsCreator {
   private final GitRepositoryManager mgr;
   private final AllProjectsName allProjectsName;
   private final PersonIdent serverUser;
+  private final NotesMigration notesMigration;
   private String message;
+  private int firstChangeId = ReviewDb.FIRST_CHANGE_ID;
 
   private GroupReference admin;
   private GroupReference batch;
@@ -70,14 +81,17 @@
   AllProjectsCreator(
       GitRepositoryManager mgr,
       AllProjectsName allProjectsName,
-      @GerritPersonIdent PersonIdent serverUser) {
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser,
+      NotesMigration notesMigration) {
     this.mgr = mgr;
     this.allProjectsName = allProjectsName;
     this.serverUser = serverUser;
+    this.notesMigration = notesMigration;
 
-    this.anonymous = SystemGroupBackend.getGroup(ANONYMOUS_USERS);
-    this.registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
-    this.owners = SystemGroupBackend.getGroup(PROJECT_OWNERS);
+    this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+    this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
+    this.owners = systemGroupBackend.getGroup(PROJECT_OWNERS);
   }
 
   public AllProjectsCreator setAdministrators(GroupReference admin) {
@@ -95,6 +109,12 @@
     return this;
   }
 
+  public AllProjectsCreator setFirstChangeIdForNoteDb(int id) {
+    checkArgument(id > 0, "id must be positive: %s", id);
+    firstChangeId = id;
+    return this;
+  }
+
   public void create() throws IOException, ConfigInvalidException {
     try (Repository git = mgr.openRepository(allProjectsName)) {
       initAllProjects(git);
@@ -112,17 +132,16 @@
     }
   }
 
-  private void initAllProjects(Repository git)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = new MetaDataUpdate(
-          GitReferenceUpdated.DISABLED,
-          allProjectsName,
-          git)) {
+  private void initAllProjects(Repository git) throws IOException, ConfigInvalidException {
+    BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate md =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git, bru)) {
       md.getCommitBuilder().setAuthor(serverUser);
       md.getCommitBuilder().setCommitter(serverUser);
-      md.setMessage(MoreObjects.firstNonNull(
-          Strings.emptyToNull(message),
-          "Initialized Gerrit Code Review " + Version.getVersion()));
+      md.setMessage(
+          MoreObjects.firstNonNull(
+              Strings.emptyToNull(message),
+              "Initialized Gerrit Code Review " + Version.getVersion()));
 
       ProjectConfig config = ProjectConfig.read(md);
       Project p = config.getProject();
@@ -165,8 +184,9 @@
       grant(config, heads, Permission.FORGE_COMMITTER, admin, owners);
       grant(config, heads, Permission.EDIT_TOPIC_NAME, true, admin, owners);
 
-      grant(config, tags, Permission.PUSH_TAG, admin, owners);
-      grant(config, tags, Permission.PUSH_SIGNED_TAG, admin, owners);
+      grant(config, tags, Permission.CREATE, admin, owners);
+      grant(config, tags, Permission.CREATE_TAG, admin, owners);
+      grant(config, tags, Permission.CREATE_SIGNED_TAG, admin, owners);
 
       grant(config, magic, Permission.PUSH, registered);
       grant(config, magic, Permission.PUSH_MERGE, registered);
@@ -174,23 +194,52 @@
       meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
       grant(config, meta, Permission.READ, admin, owners);
       grant(config, meta, cr, -2, 2, admin, owners);
+      grant(config, meta, Permission.CREATE, admin, owners);
       grant(config, meta, Permission.PUSH, admin, owners);
       grant(config, meta, Permission.SUBMIT, admin, owners);
 
       config.commitToNewRef(md, RefNames.REFS_CONFIG);
+      initSequences(git, bru);
+      execute(git, bru);
     }
   }
 
   public static LabelType initCodeReviewLabel(ProjectConfig c) {
-    LabelType type = new LabelType("Code-Review", ImmutableList.of(
-        new LabelValue((short) 2, "Looks good to me, approved"),
-        new LabelValue((short) 1, "Looks good to me, but someone else must approve"),
-        new LabelValue((short) 0, "No score"),
-        new LabelValue((short) -1, "I would prefer this is not merged as is"),
-        new LabelValue((short) -2, "This shall not be merged")));
+    LabelType type =
+        new LabelType(
+            "Code-Review",
+            ImmutableList.of(
+                new LabelValue((short) 2, "Looks good to me, approved"),
+                new LabelValue((short) 1, "Looks good to me, but someone else must approve"),
+                new LabelValue((short) 0, "No score"),
+                new LabelValue((short) -1, "I would prefer this is not merged as is"),
+                new LabelValue((short) -2, "This shall not be merged")));
     type.setCopyMinScore(true);
     type.setCopyAllScoresOnTrivialRebase(true);
     c.getLabelSections().put(type.getName(), type);
     return type;
   }
+
+  private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
+    if (notesMigration.readChangeSequence()
+        && git.exactRef(REFS_SEQUENCES + Sequences.CHANGES) == null) {
+      // Can't easily reuse the inserter from MetaDataUpdate, but this shouldn't slow down site
+      // initialization unduly.
+      try (ObjectInserter ins = git.newObjectInserter()) {
+        bru.addCommand(RepoSequence.storeNew(ins, Sequences.CHANGES, firstChangeId));
+        ins.flush();
+      }
+    }
+  }
+
+  private void execute(Repository git, BatchRefUpdate bru) throws IOException {
+    try (RevWalk rw = new RevWalk(git)) {
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    }
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("Failed to initialize " + allProjectsName + " refs:\n" + bru);
+      }
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java
index b697519..b524ecc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -33,14 +33,12 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.inject.Inject;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-
 /** Creates the {@code All-Users} repository. */
 public class AllUsersCreator {
   private final GitRepositoryManager mgr;
@@ -54,11 +52,12 @@
   AllUsersCreator(
       GitRepositoryManager mgr,
       AllUsersName allUsersName,
+      SystemGroupBackend systemGroupBackend,
       @GerritPersonIdent PersonIdent serverUser) {
     this.mgr = mgr;
     this.allUsersName = allUsersName;
     this.serverUser = serverUser;
-    this.registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
+    this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
   }
 
   public AllUsersCreator setAdministrators(GroupReference admin) {
@@ -79,12 +78,8 @@
     }
   }
 
-  private void initAllUsers(Repository git)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = new MetaDataUpdate(
-          GitReferenceUpdated.DISABLED,
-          allUsersName,
-          git)) {
+  private void initAllUsers(Repository git) throws IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
       md.getCommitBuilder().setAuthor(serverUser);
       md.getCommitBuilder().setCommitter(serverUser);
       md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
@@ -93,8 +88,9 @@
       Project project = config.getProject();
       project.setDescription("Individual user settings and preferences.");
 
-      AccessSection users = config.getAccessSection(
-          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
+      AccessSection users =
+          config.getAccessSection(
+              RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
       LabelType cr = AllProjectsCreator.initCodeReviewLabel(config);
       grant(config, users, Permission.READ, false, true, registered);
       grant(config, users, Permission.PUSH, false, true, registered);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java
index bf87ee0..4b3a570 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/BaseDataSourceType.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.schema;
 
 import com.google.gerrit.reviewdb.server.ReviewDb;
-
 import java.io.IOException;
 import java.io.InputStream;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
index 4f0b63f..9b8b736 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.Config;
 
 public class DB2 extends BaseDataSourceType {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
index 65843d8..ee57c8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
@@ -25,6 +25,7 @@
     bind(DataSourceType.class).annotatedWith(Names.named("derby")).to(Derby.class);
     bind(DataSourceType.class).annotatedWith(Names.named("h2")).to(H2.class);
     bind(DataSourceType.class).annotatedWith(Names.named("jdbc")).to(JDBC.class);
+    bind(DataSourceType.class).annotatedWith(Names.named("mariadb")).to(MariaDb.class);
     bind(DataSourceType.class).annotatedWith(Names.named("mysql")).to(MySql.class);
     bind(DataSourceType.class).annotatedWith(Names.named("oracle")).to(Oracle.class);
     bind(DataSourceType.class).annotatedWith(Names.named("postgresql")).to(PostgreSQL.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 69f4ba5..9b6073e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -33,21 +33,19 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-
-import org.apache.commons.dbcp.BasicDataSource;
-import org.eclipse.jgit.lib.Config;
-
 import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
 import java.sql.SQLException;
 import java.util.Properties;
-
 import javax.sql.DataSource;
+import org.apache.commons.dbcp.BasicDataSource;
+import org.eclipse.jgit.lib.Config;
 
 /** Provides access to the DataSource. */
 @Singleton
-public class DataSourceProvider implements Provider<DataSource>,
-    LifecycleListener {
+public class DataSourceProvider implements Provider<DataSource>, LifecycleListener {
+  private static final String DATABASE_KEY = "database";
+
   private final Config cfg;
   private final MetricMaker metrics;
   private final Context ctx;
@@ -56,7 +54,8 @@
   private DataSource ds;
 
   @Inject
-  protected DataSourceProvider(@GerritServerConfig Config cfg,
+  protected DataSourceProvider(
+      @GerritServerConfig Config cfg,
       MetricMaker metrics,
       ThreadSettingsConfig threadSettingsConfig,
       Context ctx,
@@ -77,8 +76,7 @@
   }
 
   @Override
-  public void start() {
-  }
+  public void start() {}
 
   @Override
   public synchronized void stop() {
@@ -92,12 +90,12 @@
   }
 
   public enum Context {
-    SINGLE_USER, MULTI_USER
+    SINGLE_USER,
+    MULTI_USER
   }
 
-  private DataSource open(final Config cfg, final Context context,
-      final DataSourceType dst) {
-    ConfigSection dbs = new ConfigSection(cfg, "database");
+  private DataSource open(Config cfg, Context context, DataSourceType dst) {
+    ConfigSection dbs = new ConfigSection(cfg, DATABASE_KEY);
     String driver = dbs.optional("driver");
     if (Strings.isNullOrEmpty(driver)) {
       driver = dst.getDriver();
@@ -116,32 +114,41 @@
     if (context == Context.SINGLE_USER) {
       usePool = false;
     } else {
-      usePool = cfg.getBoolean("database", "connectionpool", dst.usePool());
+      usePool = cfg.getBoolean(DATABASE_KEY, "connectionpool", dst.usePool());
     }
 
     if (usePool) {
-      final BasicDataSource ds = new BasicDataSource();
-      ds.setDriverClassName(driver);
-      ds.setUrl(url);
+      final BasicDataSource lds = new BasicDataSource();
+      lds.setDriverClassName(driver);
+      lds.setUrl(url);
       if (username != null && !username.isEmpty()) {
-        ds.setUsername(username);
+        lds.setUsername(username);
       }
       if (password != null && !password.isEmpty()) {
-        ds.setPassword(password);
+        lds.setPassword(password);
       }
       int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
-      ds.setMaxActive(poolLimit);
-      ds.setMinIdle(cfg.getInt("database", "poolminidle", 4));
-      ds.setMaxIdle(
-          cfg.getInt("database", "poolmaxidle", Math.min(poolLimit, 16)));
-      ds.setMaxWait(ConfigUtil.getTimeUnit(cfg, "database", null,
-          "poolmaxwait", MILLISECONDS.convert(30, SECONDS), MILLISECONDS));
-      ds.setInitialSize(ds.getMinIdle());
-      ds.setValidationQuery(dst.getValidationQuery());
-      ds.setValidationQueryTimeout(5);
-      exportPoolMetrics(ds);
-      return intercept(interceptor, ds);
-
+      lds.setMaxActive(poolLimit);
+      lds.setMinIdle(cfg.getInt(DATABASE_KEY, "poolminidle", 4));
+      lds.setMaxIdle(cfg.getInt(DATABASE_KEY, "poolmaxidle", Math.min(poolLimit, 16)));
+      lds.setMaxWait(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              DATABASE_KEY,
+              null,
+              "poolmaxwait",
+              MILLISECONDS.convert(30, SECONDS),
+              MILLISECONDS));
+      lds.setInitialSize(lds.getMinIdle());
+      long evictIdleTimeMs = 1000L * 60;
+      lds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
+      lds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
+      lds.setTestOnBorrow(true);
+      lds.setTestOnReturn(true);
+      lds.setValidationQuery(dst.getValidationQuery());
+      lds.setValidationQueryTimeout(5);
+      exportPoolMetrics(lds);
+      return intercept(interceptor, lds);
     }
     // Don't use the connection pool.
     //
@@ -162,22 +169,20 @@
   }
 
   private void exportPoolMetrics(final BasicDataSource pool) {
-    final CallbackMetric1<Boolean, Integer> cnt = metrics.newCallbackMetric(
-        "sql/connection_pool/connections",
-        Integer.class,
-        new Description("SQL database connections")
-          .setGauge()
-          .setUnit("connections"),
-        Field.ofBoolean("active"));
-    metrics.newTrigger(cnt, new Runnable() {
-      @Override
-      public void run() {
-        synchronized (pool) {
-          cnt.set(true, pool.getNumActive());
-          cnt.set(false, pool.getNumIdle());
-        }
-      }
-    });
+    final CallbackMetric1<Boolean, Integer> cnt =
+        metrics.newCallbackMetric(
+            "sql/connection_pool/connections",
+            Integer.class,
+            new Description("SQL database connections").setGauge().setUnit("connections"),
+            Field.ofBoolean("active"));
+    metrics.newTrigger(
+        cnt,
+        () -> {
+          synchronized (pool) {
+            cnt.set(true, pool.getNumActive());
+            cnt.set(false, pool.getNumIdle());
+          }
+        });
   }
 
   private DataSource intercept(String interceptor, DataSource ds) {
@@ -186,12 +191,15 @@
     }
     try {
       Constructor<?> c = Class.forName(interceptor).getConstructor();
-      DataSourceInterceptor datasourceInterceptor =
-          (DataSourceInterceptor) c.newInstance();
+      DataSourceInterceptor datasourceInterceptor = (DataSourceInterceptor) c.newInstance();
       return datasourceInterceptor.intercept("reviewDb", ds);
-    } catch (ClassNotFoundException | SecurityException | NoSuchMethodException
-        | IllegalArgumentException | InstantiationException
-        | IllegalAccessException | InvocationTargetException e) {
+    } catch (ClassNotFoundException
+        | SecurityException
+        | NoSuchMethodException
+        | IllegalArgumentException
+        | InstantiationException
+        | IllegalAccessException
+        | InvocationTargetException e) {
       throw new ProvisionException("Cannot intercept datasource", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
index ee8ce81..cbdcf0f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceType.java
@@ -16,7 +16,6 @@
 
 import java.io.IOException;
 
-
 /** Abstraction of a supported database platform */
 public interface DataSourceType {
 
@@ -29,8 +28,8 @@
   boolean usePool();
 
   /**
-   * Return a ScriptRunner that runs the index script. Must not return
-   * {@code null}, but may return a ScriptRunner that does nothing.
+   * Return a ScriptRunner that runs the index script. Must not return {@code null}, but may return
+   * a ScriptRunner that does nothing.
    *
    * @throws IOException
    */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
index 9dee9f5..38a7751 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DatabaseModule.java
@@ -18,6 +18,8 @@
 
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gwtorm.jdbc.Database;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Key;
@@ -29,13 +31,11 @@
   protected void configure() {
     TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
         new TypeLiteral<SchemaFactory<ReviewDb>>() {};
-    TypeLiteral<Database<ReviewDb>> database =
-        new TypeLiteral<Database<ReviewDb>>() {};
+    TypeLiteral<Database<ReviewDb>> database = new TypeLiteral<Database<ReviewDb>>() {};
 
     bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
-    bind(Key.get(schemaFactory, ReviewDbFactory.class))
-        .to(database)
-        .in(SINGLETON);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(database).in(SINGLETON);
     bind(database).toProvider(ReviewDbDatabaseProvider.class);
+    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java
index f98e83b..9fb761d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Derby.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.Config;
 
 class Derby extends BaseDataSourceType {
@@ -26,8 +25,7 @@
   private final SitePaths site;
 
   @Inject
-  Derby(@GerritServerConfig Config cfg,
-      SitePaths site) {
+  Derby(@GerritServerConfig Config cfg, SitePaths site) {
     super("org.apache.derby.jdbc.EmbeddedDriver");
     this.cfg = cfg;
     this.site = site;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
index 7d64437..3cffdb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
@@ -17,10 +17,8 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.nio.file.Path;
+import org.eclipse.jgit.lib.Config;
 
 class H2 extends BaseDataSourceType {
 
@@ -44,10 +42,7 @@
   }
 
   public static String createUrl(Path path) {
-    return new StringBuilder()
-        .append("jdbc:h2:")
-        .append(path.toUri().toString())
-        .toString();
+    return new StringBuilder().append("jdbc:h2:").append(path.toUri().toString()).toString();
   }
 
   public static String appendUrlOptions(Config cfg, String url) {
@@ -58,8 +53,7 @@
 
     if (h2CacheSize >= 0) {
       // H2 CACHE_SIZE is always given in KB
-      urlBuilder.append(";CACHE_SIZE=")
-          .append(h2CacheSize / 1024);
+      urlBuilder.append(";CACHE_SIZE=").append(h2CacheSize / 1024);
     }
     if (h2AutoServer) {
       urlBuilder.append(";AUTO_SERVER=TRUE");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
index d07115c..5146140 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
@@ -14,51 +14,25 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.sql.SQLException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class H2AccountPatchReviewStore extends JdbcAccountPatchReviewStore {
 
-  @VisibleForTesting
-  public static class InMemoryModule extends LifecycleModule {
-    @Override
-    protected void configure() {
-      H2AccountPatchReviewStore inMemoryStore = new H2AccountPatchReviewStore();
-      DynamicItem.bind(binder(), AccountPatchReviewStore.class)
-          .toInstance(inMemoryStore);
-      listener().toInstance(inMemoryStore);
-    }
-  }
-
   @Inject
-  H2AccountPatchReviewStore(@GerritServerConfig Config cfg,
-      SitePaths sitePaths) {
-    super(cfg, sitePaths);
-  }
-
-  /**
-   * Creates an in-memory H2 database to store the reviewed flags.
-   * This should be used for tests only.
-   */
-  @VisibleForTesting
-  private H2AccountPatchReviewStore() {
-    // DB_CLOSE_DELAY=-1: By default the content of an in-memory H2 database is
-    // lost at the moment the last connection is closed. This option keeps the
-    // content as long as the vm lives.
-    super(createDataSource("jdbc:h2:mem:account_patch_reviews;DB_CLOSE_DELAY=-1"));
+  H2AccountPatchReviewStore(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      ThreadSettingsConfig threadSettingsConfig) {
+    super(cfg, sitePaths, threadSettingsConfig);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
index 44f1f0c..26c94e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
@@ -20,10 +20,8 @@
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
 
 class HANA extends BaseDataSourceType {
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java
new file mode 100644
index 0000000..35e81b2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/InMemoryAccountPatchReviewStore.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gwtorm.jdbc.SimpleDataSource;
+import java.sql.SQLException;
+import java.util.Properties;
+import javax.sql.DataSource;
+
+public class InMemoryAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
+  @VisibleForTesting
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      InMemoryAccountPatchReviewStore inMemoryStore = new InMemoryAccountPatchReviewStore();
+      DynamicItem.bind(binder(), AccountPatchReviewStore.class).toInstance(inMemoryStore);
+      listener().toInstance(inMemoryStore);
+    }
+  }
+
+  /**
+   * Creates an in-memory H2 database to store the reviewed flags. This should be used for tests
+   * only.
+   */
+  @VisibleForTesting
+  private InMemoryAccountPatchReviewStore() {
+    super(newDataSource());
+  }
+
+  private static synchronized DataSource newDataSource() {
+    final Properties p = new Properties();
+    p.setProperty("driver", "org.h2.Driver");
+    // DB_CLOSE_DELAY=-1: By default the content of an in-memory H2 database is lost at the moment
+    // the last connection is closed. This option keeps the content as long as the vm lives.
+    p.setProperty("url", "jdbc:h2:mem:account_patch_reviews;DB_CLOSE_DELAY=-1");
+    try {
+      return new SimpleDataSource(p);
+    } catch (SQLException e) {
+      throw new RuntimeException("Unable to create test datasource", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
index 7cdf93e..a1df850 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.Config;
 
 class JDBC extends BaseDataSourceType {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 8809819..43f39b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.common.base.Optional;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -23,29 +25,34 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
-
-import org.apache.commons.dbcp.BasicDataSource;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.util.Collection;
-
+import java.util.Optional;
 import javax.sql.DataSource;
+import org.apache.commons.dbcp.BasicDataSource;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public abstract class JdbcAccountPatchReviewStore
     implements AccountPatchReviewStore, LifecycleListener {
-  private static final Logger log =
-      LoggerFactory.getLogger(JdbcAccountPatchReviewStore.class);
+  private static final String ACCOUNT_PATCH_REVIEW_DB = "accountPatchReviewDb";
+  private static final String H2_DB = "h2";
+  private static final String MARIADB = "mariadb";
+  private static final String MYSQL = "mysql";
+  private static final String POSTGRESQL = "postgresql";
+  private static final String URL = "url";
+  private static final Logger log = LoggerFactory.getLogger(JdbcAccountPatchReviewStore.class);
 
   public static class Module extends LifecycleModule {
     private final Config cfg;
@@ -56,80 +63,102 @@
 
     @Override
     protected void configure() {
-      String url = cfg.getString("accountPatchReviewDb", null, "url");
-      if (url == null || url.contains("h2")) {
-        DynamicItem.bind(binder(), AccountPatchReviewStore.class)
-            .to(H2AccountPatchReviewStore.class);
-        listener().to(H2AccountPatchReviewStore.class);
-      } else if (url.contains("postgresql")) {
-        DynamicItem.bind(binder(), AccountPatchReviewStore.class)
-            .to(PostgresqlAccountPatchReviewStore.class);
-        listener().to(PostgresqlAccountPatchReviewStore.class);
-      } else if (url.contains("mysql")) {
-        DynamicItem.bind(binder(), AccountPatchReviewStore.class)
-            .to(MysqlAccountPatchReviewStore.class);
-        listener().to(MysqlAccountPatchReviewStore.class);
+      Class<? extends JdbcAccountPatchReviewStore> impl;
+      String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
+      if (url == null || url.contains(H2_DB)) {
+        impl = H2AccountPatchReviewStore.class;
+      } else if (url.contains(POSTGRESQL)) {
+        impl = PostgresqlAccountPatchReviewStore.class;
+      } else if (url.contains(MYSQL)) {
+        impl = MysqlAccountPatchReviewStore.class;
+      } else if (url.contains(MARIADB)) {
+        impl = MariaDBAccountPatchReviewStore.class;
       } else {
         throw new IllegalArgumentException(
             "unsupported driver type for account patch reviews db: " + url);
       }
+      DynamicItem.bind(binder(), AccountPatchReviewStore.class).to(impl);
+      listener().to(impl);
     }
   }
 
-  private final DataSource ds;
+  private DataSource ds;
 
   public static JdbcAccountPatchReviewStore createAccountPatchReviewStore(
-      Config cfg, SitePaths sitePaths) {
-    String url = cfg.getString("accountPatchReviewDb", null, "url");
-    if (url == null || url.contains("h2")) {
-      return new H2AccountPatchReviewStore(cfg, sitePaths);
-    } else if (url.contains("postgresql")) {
-      return new PostgresqlAccountPatchReviewStore(cfg, sitePaths);
-    } else if (url.contains("mysql")) {
-      return new MysqlAccountPatchReviewStore(cfg, sitePaths);
-    } else {
-      throw new IllegalArgumentException(
-          "unsupported driver type for account patch reviews db: " + url);
+      Config cfg, SitePaths sitePaths, ThreadSettingsConfig threadSettingsConfig) {
+    String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
+    if (url == null || url.contains(H2_DB)) {
+      return new H2AccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
     }
+    if (url.contains(POSTGRESQL)) {
+      return new PostgresqlAccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
+    }
+    if (url.contains(MYSQL)) {
+      return new MysqlAccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
+    }
+    if (url.contains(MARIADB)) {
+      return new MariaDBAccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
+    }
+    throw new IllegalArgumentException(
+        "unsupported driver type for account patch reviews db: " + url);
   }
 
-  protected JdbcAccountPatchReviewStore(Config cfg,
-      SitePaths sitePaths) {
-    this.ds = createDataSource(getUrl(cfg, sitePaths));
+  protected JdbcAccountPatchReviewStore(
+      Config cfg, SitePaths sitePaths, ThreadSettingsConfig threadSettingsConfig) {
+    this.ds = createDataSource(cfg, sitePaths, threadSettingsConfig);
   }
 
   protected JdbcAccountPatchReviewStore(DataSource ds) {
     this.ds = ds;
   }
 
-  private static String getUrl(@GerritServerConfig Config cfg,
-      SitePaths sitePaths) {
-    String url = cfg.getString("accountPatchReviewDb", null, "url");
+  private static String getUrl(@GerritServerConfig Config cfg, SitePaths sitePaths) {
+    String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
     if (url == null) {
       return H2.createUrl(sitePaths.db_dir.resolve("account_patch_reviews"));
     }
     return url;
   }
 
-  protected static DataSource createDataSource(String url) {
+  private static DataSource createDataSource(
+      Config cfg, SitePaths sitePaths, ThreadSettingsConfig threadSettingsConfig) {
     BasicDataSource datasource = new BasicDataSource();
-    if (url.contains("postgresql")) {
-      datasource.setDriverClassName("org.postgresql.Driver");
-    } else if (url.contains("h2")) {
-      datasource.setDriverClassName("org.h2.Driver");
-    } else if (url.contains("mysql")) {
-      datasource.setDriverClassName("com.mysql.jdbc.Driver");
-    }
+    String url = getUrl(cfg, sitePaths);
+    int poolLimit = threadSettingsConfig.getDatabasePoolLimit();
     datasource.setUrl(url);
-    datasource.setMaxActive(50);
-    datasource.setMinIdle(4);
-    datasource.setMaxIdle(16);
-    long evictIdleTimeMs = 1000 * 60;
+    datasource.setDriverClassName(getDriverFromUrl(url));
+    datasource.setMaxActive(cfg.getInt(ACCOUNT_PATCH_REVIEW_DB, "poolLimit", poolLimit));
+    datasource.setMinIdle(cfg.getInt(ACCOUNT_PATCH_REVIEW_DB, "poolminidle", 4));
+    datasource.setMaxIdle(
+        cfg.getInt(ACCOUNT_PATCH_REVIEW_DB, "poolmaxidle", Math.min(poolLimit, 16)));
+    datasource.setInitialSize(datasource.getMinIdle());
+    datasource.setMaxWait(
+        ConfigUtil.getTimeUnit(
+            cfg,
+            ACCOUNT_PATCH_REVIEW_DB,
+            null,
+            "poolmaxwait",
+            MILLISECONDS.convert(30, SECONDS),
+            MILLISECONDS));
+    long evictIdleTimeMs = 1000L * 60;
     datasource.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
     datasource.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
     return datasource;
   }
 
+  private static String getDriverFromUrl(String url) {
+    if (url.contains(POSTGRESQL)) {
+      return "org.postgresql.Driver";
+    }
+    if (url.contains(MYSQL)) {
+      return "com.mysql.jdbc.Driver";
+    }
+    if (url.contains(MARIADB)) {
+      return "org.mariadb.jdbc.Driver";
+    }
+    return "org.h2.Driver";
+  }
+
   @Override
   public void start() {
     try {
@@ -152,7 +181,7 @@
     }
   }
 
-  private static void doCreateTable(Statement stmt) throws SQLException {
+  protected void doCreateTable(Statement stmt) throws SQLException {
     stmt.executeUpdate(
         "CREATE TABLE IF NOT EXISTS account_patch_reviews ("
             + "account_id INTEGER DEFAULT 0 NOT NULL, "
@@ -160,7 +189,7 @@
             + "patch_set_id INTEGER DEFAULT 0 NOT NULL, "
             + "file_name VARCHAR(4096) DEFAULT '' NOT NULL, "
             + "CONSTRAINT primary_key_account_patch_reviews "
-            + "PRIMARY KEY (account_id, change_id, patch_set_id, file_name)"
+            + "PRIMARY KEY (change_id, patch_set_id, account_id, file_name)"
             + ")");
   }
 
@@ -174,17 +203,17 @@
   }
 
   @Override
-  public void stop() {
-  }
+  public void stop() {}
 
   @Override
-  public boolean markReviewed(PatchSet.Id psId, Account.Id accountId,
-      String path) throws OrmException {
+  public boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path)
+      throws OrmException {
     try (Connection con = ds.getConnection();
         PreparedStatement stmt =
-            con.prepareStatement("INSERT INTO account_patch_reviews "
-                + "(account_id, change_id, patch_set_id, file_name) VALUES "
-                + "(?, ?, ?, ?)")) {
+            con.prepareStatement(
+                "INSERT INTO account_patch_reviews "
+                    + "(account_id, change_id, patch_set_id, file_name) VALUES "
+                    + "(?, ?, ?, ?)")) {
       stmt.setInt(1, accountId.get());
       stmt.setInt(2, psId.getParentKey().get());
       stmt.setInt(3, psId.get());
@@ -201,17 +230,18 @@
   }
 
   @Override
-  public void markReviewed(PatchSet.Id psId, Account.Id accountId,
-      Collection<String> paths) throws OrmException {
+  public void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths)
+      throws OrmException {
     if (paths == null || paths.isEmpty()) {
       return;
     }
 
     try (Connection con = ds.getConnection();
         PreparedStatement stmt =
-            con.prepareStatement("INSERT INTO account_patch_reviews "
-                + "(account_id, change_id, patch_set_id, file_name) VALUES "
-                + "(?, ?, ?, ?)")) {
+            con.prepareStatement(
+                "INSERT INTO account_patch_reviews "
+                    + "(account_id, change_id, patch_set_id, file_name) VALUES "
+                    + "(?, ?, ?, ?)")) {
       for (String path : paths) {
         stmt.setInt(1, accountId.get());
         stmt.setInt(2, psId.getParentKey().get());
@@ -234,9 +264,10 @@
       throws OrmException {
     try (Connection con = ds.getConnection();
         PreparedStatement stmt =
-            con.prepareStatement("DELETE FROM account_patch_reviews "
-                + "WHERE account_id = ? AND change_id = ? AND "
-                + "patch_set_id = ? AND file_name = ?")) {
+            con.prepareStatement(
+                "DELETE FROM account_patch_reviews "
+                    + "WHERE account_id = ? AND change_id = ? AND "
+                    + "patch_set_id = ? AND file_name = ?")) {
       stmt.setInt(1, accountId.get());
       stmt.setInt(2, psId.getParentKey().get());
       stmt.setInt(3, psId.get());
@@ -251,8 +282,9 @@
   public void clearReviewed(PatchSet.Id psId) throws OrmException {
     try (Connection con = ds.getConnection();
         PreparedStatement stmt =
-            con.prepareStatement("DELETE FROM account_patch_reviews "
-                + "WHERE change_id = ? AND patch_set_id = ?")) {
+            con.prepareStatement(
+                "DELETE FROM account_patch_reviews "
+                    + "WHERE change_id = ? AND patch_set_id = ?")) {
       stmt.setInt(1, psId.getParentKey().get());
       stmt.setInt(2, psId.get());
       stmt.executeUpdate();
@@ -262,8 +294,8 @@
   }
 
   @Override
-  public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId,
-      Account.Id accountId) throws OrmException {
+  public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId)
+      throws OrmException {
     try (Connection con = ds.getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
@@ -278,19 +310,17 @@
       stmt.setInt(3, psId.get());
       try (ResultSet rs = stmt.executeQuery()) {
         if (rs.next()) {
-          PatchSet.Id id = new PatchSet.Id(psId.getParentKey(),
-              rs.getInt("patch_set_id"));
+          PatchSet.Id id = new PatchSet.Id(psId.getParentKey(), rs.getInt("patch_set_id"));
           ImmutableSet.Builder<String> builder = ImmutableSet.builder();
           do {
             builder.add(rs.getString("file_name"));
           } while (rs.next());
 
           return Optional.of(
-              AccountPatchReviewStore.PatchSetWithReviewedFiles.create(
-                  id, builder.build()));
+              AccountPatchReviewStore.PatchSetWithReviewedFiles.create(id, builder.build()));
         }
 
-        return Optional.absent();
+        return Optional.empty();
       }
     } catch (SQLException e) {
       throw convertError("select", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java
new file mode 100644
index 0000000..aa05a08
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDBAccountPatchReviewStore.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.SQLException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class MariaDBAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
+
+  @Inject
+  MariaDBAccountPatchReviewStore(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      ThreadSettingsConfig threadSettingsConfig) {
+    super(cfg, sitePaths, threadSettingsConfig);
+  }
+
+  @Override
+  public OrmException convertError(String op, SQLException err) {
+    switch (getSQLStateInt(err)) {
+      case 1022: // ER_DUP_KEY
+      case 1062: // ER_DUP_ENTRY
+      case 1169: // ER_DUP_UNIQUE;
+        return new OrmDuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
+
+      default:
+        if (err.getCause() == null && err.getNextException() != null) {
+          err.initCause(err.getNextException());
+        }
+        return new OrmException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java
new file mode 100644
index 0000000..ed18a86
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.schema.JdbcUtil.hostname;
+import static com.google.gerrit.server.schema.JdbcUtil.port;
+
+import com.google.gerrit.server.config.ConfigSection;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+
+class MariaDb extends BaseDataSourceType {
+  private final Config cfg;
+
+  @Inject
+  MariaDb(@GerritServerConfig Config cfg) {
+    super("org.mariadb.jdbc.Driver");
+    this.cfg = cfg;
+  }
+
+  @Override
+  public String getUrl() {
+    StringBuilder b = new StringBuilder();
+    ConfigSection dbs = new ConfigSection(cfg, "database");
+    b.append("jdbc:mariadb://");
+    b.append(hostname(dbs.optional("hostname")));
+    b.append(port(dbs.optional("port")));
+    b.append("/");
+    b.append(dbs.required("database"));
+    return b.toString();
+  }
+
+  @Override
+  public boolean usePool() {
+    // MariaDB has given us trouble with the connection pool,
+    // sometimes the backend disconnects and the pool winds
+    // up with a stale connection. Fortunately opening up
+    // a new MariaDB connection is usually very fast.
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
index 9a09746..ca5a60d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
@@ -19,10 +19,8 @@
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
 
 class MaxDb extends BaseDataSourceType {
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
index 0b345e8..fc8e176 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.Config;
 
 class MySql extends BaseDataSourceType {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
index 6b49404..cb8c707 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
@@ -16,22 +16,24 @@
 
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.sql.SQLException;
+import java.sql.Statement;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class MysqlAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
 
   @Inject
-  MysqlAccountPatchReviewStore(@GerritServerConfig Config cfg,
-      SitePaths sitePaths) {
-    super(cfg, sitePaths);
+  MysqlAccountPatchReviewStore(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      ThreadSettingsConfig threadSettingsConfig) {
+    super(cfg, sitePaths, threadSettingsConfig);
   }
 
   @Override
@@ -42,7 +44,6 @@
       case 1169: // ER_DUP_UNIQUE;
         return new OrmDuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
 
-
       default:
         if (err.getCause() == null && err.getNextException() != null) {
           err.initCause(err.getNextException());
@@ -50,4 +51,17 @@
         return new OrmException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
     }
   }
+
+  @Override
+  protected void doCreateTable(Statement stmt) throws SQLException {
+    stmt.executeUpdate(
+        "CREATE TABLE IF NOT EXISTS account_patch_reviews ("
+            + "account_id INTEGER DEFAULT 0 NOT NULL, "
+            + "change_id INTEGER DEFAULT 0 NOT NULL, "
+            + "patch_set_id INTEGER DEFAULT 0 NOT NULL, "
+            + "file_name VARCHAR(255) DEFAULT '' NOT NULL, "
+            + "CONSTRAINT primary_key_account_patch_reviews "
+            + "PRIMARY KEY (change_id, patch_set_id, account_id, file_name)"
+            + ")");
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
new file mode 100644
index 0000000..b30c49a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
@@ -0,0 +1,352 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+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.PatchLineComment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ChangeAccess;
+import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
+import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
+import com.google.gerrit.reviewdb.server.PatchSetAccess;
+import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.client.Key;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * Wrapper for ReviewDb that never calls the underlying change tables.
+ *
+ * <p>See {@link NotesMigrationSchemaFactory} for discussion.
+ */
+class NoChangesReviewDbWrapper extends ReviewDbWrapper {
+  private static <T> ResultSet<T> empty() {
+    return new ListResultSet<>(ImmutableList.of());
+  }
+
+  @SuppressWarnings("deprecation")
+  private static <T, K extends Key<?>>
+      com.google.common.util.concurrent.CheckedFuture<T, OrmException> emptyFuture() {
+    return Futures.immediateCheckedFuture(null);
+  }
+
+  private final ChangeAccess changes;
+  private final PatchSetApprovalAccess patchSetApprovals;
+  private final ChangeMessageAccess changeMessages;
+  private final PatchSetAccess patchSets;
+  private final PatchLineCommentAccess patchComments;
+
+  private boolean inTransaction;
+
+  NoChangesReviewDbWrapper(ReviewDb db) {
+    super(db);
+    changes = new Changes(this, delegate);
+    patchSetApprovals = new PatchSetApprovals(this, delegate);
+    changeMessages = new ChangeMessages(this, delegate);
+    patchSets = new PatchSets(this, delegate);
+    patchComments = new PatchLineComments(this, delegate);
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return changes;
+  }
+
+  @Override
+  public PatchSetApprovalAccess patchSetApprovals() {
+    return patchSetApprovals;
+  }
+
+  @Override
+  public ChangeMessageAccess changeMessages() {
+    return changeMessages;
+  }
+
+  @Override
+  public PatchSetAccess patchSets() {
+    return patchSets;
+  }
+
+  @Override
+  public PatchLineCommentAccess patchComments() {
+    return patchComments;
+  }
+
+  @Override
+  public void commit() throws OrmException {
+    if (!inTransaction) {
+      // This reads a little weird, we're not in a transaction, so why are we calling commit?
+      // Because we want to let the underlying ReviewDb do its normal thing in this case (which may
+      // be throwing an exception, or not, depending on implementation).
+      delegate.commit();
+    }
+  }
+
+  @Override
+  public void rollback() throws OrmException {
+    if (inTransaction) {
+      inTransaction = false;
+    } else {
+      // See comment in commit(): we want to let the underlying ReviewDb do its thing.
+      delegate.rollback();
+    }
+  }
+
+  private abstract static class AbstractDisabledAccess<T, K extends Key<?>>
+      implements Access<T, K> {
+    // Don't even hold a reference to delegate, so it's not possible to use it accidentally.
+    private final NoChangesReviewDbWrapper wrapper;
+    private final String relationName;
+    private final int relationId;
+    private final Function<T, K> primaryKey;
+    private final Function<Iterable<T>, Map<K, T>> toMap;
+
+    private AbstractDisabledAccess(NoChangesReviewDbWrapper wrapper, Access<T, K> delegate) {
+      this.wrapper = wrapper;
+      this.relationName = delegate.getRelationName();
+      this.relationId = delegate.getRelationID();
+      this.primaryKey = delegate::primaryKey;
+      this.toMap = delegate::toMap;
+    }
+
+    @Override
+    public final int getRelationID() {
+      return relationId;
+    }
+
+    @Override
+    public final String getRelationName() {
+      return relationName;
+    }
+
+    @Override
+    public final K primaryKey(T entity) {
+      return primaryKey.apply(entity);
+    }
+
+    @Override
+    public final Map<K, T> toMap(Iterable<T> iterable) {
+      return toMap.apply(iterable);
+    }
+
+    @Override
+    public final ResultSet<T> iterateAllEntities() {
+      return empty();
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public final com.google.common.util.concurrent.CheckedFuture<T, OrmException> getAsync(K key) {
+      return emptyFuture();
+    }
+
+    @Override
+    public final ResultSet<T> get(Iterable<K> keys) {
+      return empty();
+    }
+
+    @Override
+    public final void insert(Iterable<T> instances) {
+      // Do nothing.
+    }
+
+    @Override
+    public final void update(Iterable<T> instances) {
+      // Do nothing.
+    }
+
+    @Override
+    public final void upsert(Iterable<T> instances) {
+      // Do nothing.
+    }
+
+    @Override
+    public final void deleteKeys(Iterable<K> keys) {
+      // Do nothing.
+    }
+
+    @Override
+    public final void delete(Iterable<T> instances) {
+      // Do nothing.
+    }
+
+    @Override
+    public final void beginTransaction(K key) {
+      // Keep track of when we've started a transaction so that we can avoid calling commit/rollback
+      // on the underlying ReviewDb. This is just a simple arm's-length approach, and may produce
+      // slightly different results from a native ReviewDb in corner cases like:
+      //  * beginning transactions on different tables simultaneously
+      //  * doing work between commit and rollback
+      // These kinds of things are already misuses of ReviewDb, and shouldn't be happening in
+      // current code anyway.
+      checkState(!wrapper.inTransaction, "already in transaction");
+      wrapper.inTransaction = true;
+    }
+
+    @Override
+    public final T atomicUpdate(K key, AtomicUpdate<T> update) {
+      return null;
+    }
+
+    @Override
+    public final T get(K id) {
+      return null;
+    }
+  }
+
+  private static class Changes extends AbstractDisabledAccess<Change, Change.Id>
+      implements ChangeAccess {
+    private Changes(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.changes());
+    }
+
+    @Override
+    public ResultSet<Change> all() {
+      return empty();
+    }
+  }
+
+  private static class ChangeMessages
+      extends AbstractDisabledAccess<ChangeMessage, ChangeMessage.Key>
+      implements ChangeMessageAccess {
+    private ChangeMessages(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.changeMessages());
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byChange(Change.Id id) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> byPatchSet(PatchSet.Id id) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<ChangeMessage> all() throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class PatchSets extends AbstractDisabledAccess<PatchSet, PatchSet.Id>
+      implements PatchSetAccess {
+    private PatchSets(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.patchSets());
+    }
+
+    @Override
+    public ResultSet<PatchSet> byChange(Change.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchSet> all() {
+      return empty();
+    }
+  }
+
+  private static class PatchSetApprovals
+      extends AbstractDisabledAccess<PatchSetApproval, PatchSetApproval.Key>
+      implements PatchSetApprovalAccess {
+    private PatchSetApprovals(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.patchSetApprovals());
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byChange(Change.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSet(PatchSet.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> byPatchSetUser(PatchSet.Id patchSet, Account.Id account) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchSetApproval> all() {
+      return empty();
+    }
+  }
+
+  private static class PatchLineComments
+      extends AbstractDisabledAccess<PatchLineComment, PatchLineComment.Key>
+      implements PatchLineCommentAccess {
+    private PatchLineComments(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.patchComments());
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byChange(Change.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> byPatchSet(PatchSet.Id id) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByChangeFile(Change.Id id, String file) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> publishedByPatchSet(PatchSet.Id patchset) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByPatchSetAuthor(
+        PatchSet.Id patchset, Account.Id author) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByChangeFileAuthor(
+        Change.Id id, String file, Account.Id author) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> draftByAuthor(Account.Id author) {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<PatchLineComment> all() {
+      return empty();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
index f38ddfa..d73a5f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.gerrit.reviewdb.server.DisabledChangesReviewDbWrapper;
+import com.google.gerrit.reviewdb.server.DisallowReadFromChangesReviewDbWrapper;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gwtorm.server.OrmException;
@@ -29,8 +29,7 @@
 
   @Inject
   NotesMigrationSchemaFactory(
-      @ReviewDbFactory SchemaFactory<ReviewDb> delegate,
-      NotesMigration migration) {
+      @ReviewDbFactory SchemaFactory<ReviewDb> delegate, NotesMigration migration) {
     this.delegate = delegate;
     this.migration = migration;
   }
@@ -41,6 +40,34 @@
     if (!migration.readChanges()) {
       return db;
     }
-    return new DisabledChangesReviewDbWrapper(db);
+
+    // There are two levels at which this class disables access to Changes and related tables,
+    // corresponding to two phases of the NoteDb migration:
+    //
+    // 1. When changes are read from NoteDb but some changes might still have their primary storage
+    //    in ReviewDb, it is generally programmer error to read changes from ReviewDb. However,
+    //    since ReviewDb is still the primary storage for most or all changes, we still need to
+    //    support writing to ReviewDb. This behavior is accomplished by wrapping in a
+    //    DisallowReadFromChangesReviewDbWrapper.
+    //
+    //    Some codepaths might need to be able to read from ReviewDb if they really need to, because
+    //    they need to operate on the underlying source of truth, for example when reading a change
+    //    to determine its primary storage. To support this, ReviewDbUtil#unwrapDb can detect and
+    //    unwrap databases of this type.
+    //
+    // 2. After all changes have their primary storage in NoteDb, we can completely shut off access
+    //    to the change tables. At this point in the migration, we are by definition not using the
+    //    ReviewDb tables at all; we could even delete the tables at this point, and Gerrit would
+    //    continue to function.
+    //
+    //    This is accomplished by setting the delegate ReviewDb *underneath* DisallowReadFromChanges
+    //    to be a complete no-op, with NoChangesReviewDbWrapper. With this wrapper, all read
+    //    operations return no results, and write operations silently do nothing. This wrapper is
+    //    not a public class and nobody should ever attempt to unwrap it.
+
+    if (migration.disableChangeReviewDb()) {
+      db = new NoChangesReviewDbWrapper(db);
+    }
+    return new DisallowReadFromChangesReviewDbWrapper(db);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
index e7d3390..e86f788 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
 import org.eclipse.jgit.lib.Config;
 
 public class Oracle extends BaseDataSourceType {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
index 3e3509e..23e7625 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
@@ -20,10 +20,8 @@
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
 
 class PostgreSQL extends BaseDataSourceType {
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
index c264c68..34f7dba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgresqlAccountPatchReviewStore.java
@@ -16,22 +16,23 @@
 
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.sql.SQLException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class PostgresqlAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
 
   @Inject
-  PostgresqlAccountPatchReviewStore(@GerritServerConfig Config cfg,
-      SitePaths sitePaths) {
-    super(cfg, sitePaths);
+  PostgresqlAccountPatchReviewStore(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      ThreadSettingsConfig threadSettingsConfig) {
+    super(cfg, sitePaths, threadSettingsConfig);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
new file mode 100644
index 0000000..67d11a8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.git.ProjectConfig.ACCESS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+
+public class ProjectConfigSchemaUpdate extends VersionedMetaData {
+
+  private final MetaDataUpdate update;
+  private Config config;
+  private boolean updated;
+
+  public static ProjectConfigSchemaUpdate read(MetaDataUpdate update)
+      throws IOException, ConfigInvalidException {
+    ProjectConfigSchemaUpdate r = new ProjectConfigSchemaUpdate(update);
+    r.load(update);
+    return r;
+  }
+
+  private ProjectConfigSchemaUpdate(MetaDataUpdate update) {
+    this.update = update;
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_CONFIG;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    config = readConfig(ProjectConfig.PROJECT_CONFIG);
+  }
+
+  public void removeForceFromPermission(String name) {
+    for (String subsection : config.getSubsections(ACCESS)) {
+      Set<String> names = config.getNames(ACCESS, subsection);
+      if (names.contains(name)) {
+        List<String> values =
+            Arrays.stream(config.getStringList(ACCESS, subsection, name))
+                .map(
+                    r -> {
+                      PermissionRule rule = PermissionRule.fromString(r, false);
+                      if (rule.getForce()) {
+                        rule.setForce(false);
+                        updated = true;
+                      }
+                      return rule.asString(false);
+                    })
+                .collect(toList());
+        config.setStringList(ACCESS, subsection, name, values);
+      }
+    }
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    saveConfig(ProjectConfig.PROJECT_CONFIG, config);
+    return true;
+  }
+
+  public void save(PersonIdent personIdent, String commitMessage) throws OrmException {
+    if (!updated) {
+      return;
+    }
+
+    update.getCommitBuilder().setAuthor(personIdent);
+    update.getCommitBuilder().setCommitter(personIdent);
+    update.setMessage(commitMessage);
+    try {
+      commit(update);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  public boolean isUpdated() {
+    return updated;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java
index 9ceaf1c..0fbaeca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbDatabaseProvider.java
@@ -21,7 +21,6 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.name.Named;
-
 import javax.sql.DataSource;
 
 /** Provides the {@code Database<ReviewDb>} database handle. */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java
index 3a63360..86f5d06 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ReviewDbFactory.java
@@ -17,19 +17,15 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
- * Marker on {@link com.google.gwtorm.server.SchemaFactory} implementation
- * that talks to the underlying traditional {@link
- * com.google.gerrit.reviewdb.server.ReviewDb} database.
- * <p>
- * During the migration to NoteDb, the actual {@code ReviewDb} will be a wrapper
- * with certain tables enabled/disabled; this marker goes on the low-level
- * implementation that has all tables.
+ * Marker on {@link com.google.gwtorm.server.SchemaFactory} implementation that talks to the
+ * underlying traditional {@link com.google.gerrit.reviewdb.server.ReviewDb} database.
+ *
+ * <p>During the migration to NoteDb, the actual {@code ReviewDb} will be a wrapper with certain
+ * tables enabled/disabled; this marker goes on the low-level implementation that has all tables.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface ReviewDbFactory {
-}
+public @interface ReviewDbFactory {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java
index 2581d56..62d0f42 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java
@@ -24,55 +24,58 @@
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gwtorm.jdbc.JdbcExecutor;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.Collections;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /** Creates the current database schema and populates initial code rows. */
 public class SchemaCreator {
-  @SitePath
-  private final
-  Path site_path;
+  @SitePath private final Path site_path;
 
   private final AllProjectsCreator allProjectsCreator;
   private final AllUsersCreator allUsersCreator;
   private final PersonIdent serverUser;
   private final DataSourceType dataSourceType;
+  private final GroupIndexCollection indexCollection;
 
   private AccountGroup admin;
   private AccountGroup batch;
 
   @Inject
-  public SchemaCreator(SitePaths site,
+  public SchemaCreator(
+      SitePaths site,
       AllProjectsCreator ap,
       AllUsersCreator auc,
       @GerritPersonIdent PersonIdent au,
-      DataSourceType dst) {
-    this(site.site_path, ap, auc, au, dst);
+      DataSourceType dst,
+      GroupIndexCollection ic) {
+    this(site.site_path, ap, auc, au, dst, ic);
   }
 
-  public SchemaCreator(@SitePath Path site,
+  public SchemaCreator(
+      @SitePath Path site,
       AllProjectsCreator ap,
       AllUsersCreator auc,
       @GerritPersonIdent PersonIdent au,
-      DataSourceType dst) {
+      DataSourceType dst,
+      GroupIndexCollection ic) {
     site_path = site;
     allProjectsCreator = ap;
     allUsersCreator = auc;
     serverUser = au;
     dataSourceType = dst;
+    indexCollection = ic;
   }
 
-  public void create(final ReviewDb db) throws OrmException, IOException,
-      ConfigInvalidException {
+  public void create(final ReviewDb db) throws OrmException, IOException, ConfigInvalidException {
     final JdbcSchema jdbc = (JdbcSchema) db;
     try (JdbcExecutor e = new JdbcExecutor(jdbc)) {
       jdbc.updateSchema(e);
@@ -82,17 +85,37 @@
     sVer.versionNbr = SchemaVersion.getBinaryVersion();
     db.schemaVersion().insert(Collections.singleton(sVer));
 
+    createDefaultGroups(db);
     initSystemConfig(db);
     allProjectsCreator
-      .setAdministrators(GroupReference.forGroup(admin))
-      .setBatchUsers(GroupReference.forGroup(batch))
-      .create();
-    allUsersCreator
-      .setAdministrators(GroupReference.forGroup(admin))
-      .create();
+        .setAdministrators(GroupReference.forGroup(admin))
+        .setBatchUsers(GroupReference.forGroup(batch))
+        .create();
+    allUsersCreator.setAdministrators(GroupReference.forGroup(admin)).create();
     dataSourceType.getIndexScript().run(db);
   }
 
+  private void createDefaultGroups(ReviewDb db) throws OrmException, IOException {
+    admin = newGroup(db, "Administrators", null);
+    admin.setDescription("Gerrit Site Administrators");
+    db.accountGroups().insert(Collections.singleton(admin));
+    db.accountGroupNames().insert(Collections.singleton(new AccountGroupName(admin)));
+    index(admin);
+
+    batch = newGroup(db, "Non-Interactive Users", null);
+    batch.setDescription("Users who perform batch actions on Gerrit");
+    batch.setOwnerGroupUUID(admin.getGroupUUID());
+    db.accountGroups().insert(Collections.singleton(batch));
+    db.accountGroupNames().insert(Collections.singleton(new AccountGroupName(batch)));
+    index(batch);
+  }
+
+  private void index(AccountGroup group) throws IOException {
+    for (GroupIndex groupIndex : indexCollection.getWriteIndexes()) {
+      groupIndex.replace(group);
+    }
+  }
+
   private AccountGroup newGroup(ReviewDb c, String name, AccountGroup.UUID uuid)
       throws OrmException {
     if (uuid == null) {
@@ -104,27 +127,14 @@
         uuid);
   }
 
-  private SystemConfig initSystemConfig(final ReviewDb c) throws OrmException {
-    admin = newGroup(c, "Administrators", null);
-    admin.setDescription("Gerrit Site Administrators");
-    c.accountGroups().insert(Collections.singleton(admin));
-    c.accountGroupNames().insert(
-        Collections.singleton(new AccountGroupName(admin)));
-
-    batch = newGroup(c, "Non-Interactive Users", null);
-    batch.setDescription("Users who perform batch actions on Gerrit");
-    batch.setOwnerGroupUUID(admin.getGroupUUID());
-    c.accountGroups().insert(Collections.singleton(batch));
-    c.accountGroupNames().insert(
-        Collections.singleton(new AccountGroupName(batch)));
-
-    final SystemConfig s = SystemConfig.create();
+  private SystemConfig initSystemConfig(ReviewDb db) throws OrmException {
+    SystemConfig s = SystemConfig.create();
     try {
       s.sitePath = site_path.toRealPath().normalize().toString();
     } catch (IOException e) {
       s.sitePath = site_path.toAbsolutePath().normalize().toString();
     }
-    c.systemConfig().insert(Collections.singleton(s));
+    db.systemConfig().insert(Collections.singleton(s));
     return s;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java
index f23dabf..9ce19fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -27,29 +27,27 @@
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
-
 import org.eclipse.jgit.lib.PersonIdent;
 
 /** Validate the schema and connect to Git. */
 public class SchemaModule extends FactoryModule {
   @Override
   protected void configure() {
-    bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class)
-      .toProvider(GerritPersonIdentProvider.class);
+    bind(PersonIdent.class)
+        .annotatedWith(GerritPersonIdent.class)
+        .toProvider(GerritPersonIdentProvider.class);
 
-    bind(AllProjectsName.class)
-      .toProvider(AllProjectsNameProvider.class)
-      .in(SINGLETON);
+    bind(AllProjectsName.class).toProvider(AllProjectsNameProvider.class).in(SINGLETON);
 
-    bind(AllUsersName.class)
-      .toProvider(AllUsersNameProvider.class)
-      .in(SINGLETON);
+    bind(AllUsersName.class).toProvider(AllUsersNameProvider.class).in(SINGLETON);
 
-    bind(String.class).annotatedWith(AnonymousCowardName.class).toProvider(
-        AnonymousCowardNameProvider.class);
+    bind(String.class)
+        .annotatedWith(AnonymousCowardName.class)
+        .toProvider(AnonymousCowardNameProvider.class);
 
-    bind(String.class).annotatedWith(GerritServerId.class)
-      .toProvider(GerritServerIdProvider.class)
-      .in(SINGLETON);
+    bind(String.class)
+        .annotatedWith(GerritServerId.class)
+        .toProvider(GerritServerIdProvider.class)
+        .in(SINGLETON);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
index 24022e9..b60b1f7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.client.SystemConfig;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -25,6 +26,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.AbstractModule;
@@ -34,14 +36,12 @@
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.Stage;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-
 import java.io.IOException;
 import java.sql.SQLException;
 import java.util.Collections;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /** Creates or updates the current database schema. */
 public class SchemaUpdater {
@@ -51,10 +51,8 @@
   private final Provider<SchemaVersion> updater;
 
   @Inject
-  SchemaUpdater(SchemaFactory<ReviewDb> schema,
-      SitePaths site,
-      SchemaCreator creator,
-      Injector parent) {
+  SchemaUpdater(
+      SchemaFactory<ReviewDb> schema, SitePaths site, SchemaCreator creator, Injector parent) {
     this.schema = schema;
     this.site = site;
     this.creator = creator;
@@ -65,34 +63,39 @@
     // Use DEVELOPMENT mode to allow lazy initialization of the
     // graph. This avoids touching ancient schema versions that
     // are behind this installation's current version.
-    return Guice.createInjector(Stage.DEVELOPMENT, new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(SchemaVersion.class).to(SchemaVersion.C);
+    return Guice.createInjector(
+        Stage.DEVELOPMENT,
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(SchemaVersion.class).to(SchemaVersion.C);
 
-        for (Key<?> k : new Key<?>[]{
-            Key.get(PersonIdent.class, GerritPersonIdent.class),
-            Key.get(String.class, AnonymousCowardName.class),
-            Key.get(Config.class, GerritServerConfig.class),
-            }) {
-          rebind(parent, k);
-        }
+            for (Key<?> k :
+                new Key<?>[] {
+                  Key.get(PersonIdent.class, GerritPersonIdent.class),
+                  Key.get(String.class, AnonymousCowardName.class),
+                  Key.get(Config.class, GerritServerConfig.class),
+                }) {
+              rebind(parent, k);
+            }
 
-        for (Class<?> c : new Class<?>[] {
-            AllProjectsName.class,
-            AllUsersCreator.class,
-            AllUsersName.class,
-            GitRepositoryManager.class,
-            SitePaths.class,
-            }) {
-          rebind(parent, Key.get(c));
-        }
-      }
+            for (Class<?> c :
+                new Class<?>[] {
+                  AllProjectsName.class,
+                  AllUsersCreator.class,
+                  AllUsersName.class,
+                  GitRepositoryManager.class,
+                  SitePaths.class,
+                  SystemGroupBackend.class,
+                }) {
+              rebind(parent, Key.get(c));
+            }
+          }
 
-      private <T> void rebind(Injector parent, Key<T> c) {
-        bind(c).toProvider(parent.getProvider(c));
-      }
-    });
+          private <T> void rebind(Injector parent, Key<T> c) {
+            bind(c).toProvider(parent.getProvider(c));
+          }
+        });
   }
 
   public void update(final UpdateUI ui) throws OrmException {
@@ -119,6 +122,11 @@
     }
   }
 
+  @VisibleForTesting
+  public SchemaVersion getLatestSchemaVersion() {
+    return updater.get();
+  }
+
   private CurrentSchemaVersion getSchemaVersion(final ReviewDb db) {
     try {
       return db.schemaVersion().get(new CurrentSchemaVersion.Key());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index 7217fd0..bbc7ce1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Stopwatch;
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -22,18 +24,28 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Provider;
-
+import java.io.IOException;
 import java.sql.PreparedStatement;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_129> C = Schema_129.class;
+  public static final Class<Schema_142> C = Schema_142.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
@@ -61,13 +73,22 @@
     return versionNbr;
   }
 
+  @VisibleForTesting
+  public final SchemaVersion getPrior() {
+    return prior.get();
+  }
+
   public final void check(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
       throws OrmException, SQLException {
     if (curr.versionNbr == versionNbr) {
       // Nothing to do, we are at the correct schema.
     } else if (curr.versionNbr > versionNbr) {
-      throw new OrmException("Cannot downgrade database schema from version "
-          + curr.versionNbr + " to " + versionNbr + ".");
+      throw new OrmException(
+          "Cannot downgrade database schema from version "
+              + curr.versionNbr
+              + " to "
+              + versionNbr
+              + ".");
     } else {
       upgradeFrom(ui, curr, db);
     }
@@ -82,17 +103,18 @@
 
     JdbcSchema s = (JdbcSchema) db;
     final List<String> pruneList = new ArrayList<>();
-    s.pruneSchema(new StatementExecutor() {
-      @Override
-      public void execute(String sql) {
-        pruneList.add(sql);
-      }
+    s.pruneSchema(
+        new StatementExecutor() {
+          @Override
+          public void execute(String sql) {
+            pruneList.add(sql);
+          }
 
-      @Override
-      public void close() {
-        // Do nothing.
-      }
-    });
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        });
 
     try (JdbcExecutor e = new JdbcExecutor(s)) {
       if (!pruneList.isEmpty()) {
@@ -110,8 +132,8 @@
     return r;
   }
 
-  private void updateSchema(List<SchemaVersion> pending, UpdateUI ui,
-      ReviewDb db) throws OrmException, SQLException {
+  private void updateSchema(List<SchemaVersion> pending, UpdateUI ui, ReviewDb db)
+      throws OrmException, SQLException {
     for (SchemaVersion v : pending) {
       ui.message(String.format("Upgrading schema to %d ...", v.getVersionNbr()));
       v.preUpdateSchema(db);
@@ -130,42 +152,39 @@
    * @throws OrmException if a Gerrit-specific exception occurred.
    * @throws SQLException if an underlying SQL exception occurred.
    */
-  protected void preUpdateSchema(ReviewDb db) throws OrmException, SQLException {
-  }
+  protected void preUpdateSchema(ReviewDb db) throws OrmException, SQLException {}
 
-  private void migrateData(List<SchemaVersion> pending, UpdateUI ui,
-      CurrentSchemaVersion curr, ReviewDb db) throws OrmException, SQLException {
+  private void migrateData(
+      List<SchemaVersion> pending, UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
+      throws OrmException, SQLException {
     for (SchemaVersion v : pending) {
-      ui.message(String.format(
-          "Migrating data to schema %d ...",
-          v.getVersionNbr()));
+      Stopwatch sw = Stopwatch.createStarted();
+      ui.message(String.format("Migrating data to schema %d ...", v.getVersionNbr()));
       v.migrateData(db, ui);
       v.finish(curr, db);
+      ui.message(String.format("\t> Done (%.3f s)", sw.elapsed(TimeUnit.MILLISECONDS) / 1000d));
     }
   }
 
   /**
-   * Invoked between updateSchema (adds new columns/tables) and pruneSchema
-   * (removes deleted columns/tables).
+   * Invoked between updateSchema (adds new columns/tables) and pruneSchema (removes deleted
+   * columns/tables).
    *
    * @param db open database handle.
    * @param ui interface for interacting with the user.
    * @throws OrmException if a Gerrit-specific exception occurred.
    * @throws SQLException if an underlying SQL exception occurred.
    */
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-  }
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {}
 
   /** Mark the current schema version. */
-  protected void finish(CurrentSchemaVersion curr, ReviewDb db)
-      throws OrmException {
+  protected void finish(CurrentSchemaVersion curr, ReviewDb db) throws OrmException {
     curr.versionNbr = versionNbr;
     db.schemaVersion().update(Collections.singleton(curr));
   }
 
   /** Rename an existing table. */
-  protected static void renameTable(ReviewDb db, String from, String to)
-      throws OrmException {
+  protected static void renameTable(ReviewDb db, String from, String to) throws OrmException {
     JdbcSchema s = (JdbcSchema) db;
     try (JdbcExecutor e = new JdbcExecutor(s)) {
       s.renameTable(e, from, to);
@@ -194,8 +213,7 @@
   }
 
   /** Open a new prepared statement. */
-  protected static PreparedStatement prepareStatement(ReviewDb db, String sql)
-      throws SQLException {
+  protected static PreparedStatement prepareStatement(ReviewDb db, String sql) throws SQLException {
     return ((JdbcSchema) db).getConnection().prepareStatement(sql);
   }
 
@@ -203,4 +221,57 @@
   protected static JdbcExecutor newExecutor(ReviewDb db) throws OrmException {
     return new JdbcExecutor(((JdbcSchema) db).getConnection());
   }
+
+  protected int getThreads() {
+    return Runtime.getRuntime().availableProcessors();
+  }
+
+  protected ExecutorService createExecutor(UpdateUI ui) {
+    int threads = getThreads();
+    ui.message(String.format("... using %d threads ...", threads));
+    return Executors.newFixedThreadPool(threads);
+  }
+
+  @FunctionalInterface
+  protected interface ThrowingFunction<I, T> {
+    default T accept(I input) {
+      try {
+        return acceptWithThrow(input);
+      } catch (OrmException | IOException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    T acceptWithThrow(I input) throws OrmException, IOException;
+  }
+
+  protected Collection<?> runParallelTasks(
+      ExecutorService executor, Collection<?> lst, ThrowingFunction task, UpdateUI ui) {
+    Collection<Object> returnSet = new HashSet<>();
+    Set<Future> futures =
+        lst.stream()
+            .map(each -> executor.submit(() -> task.accept(each)))
+            .collect(Collectors.toSet());
+    for (Future each : futures) {
+      try {
+        Object rtn = each.get();
+        if (Objects.nonNull(rtn)) {
+          returnSet.add(rtn);
+        }
+      } catch (InterruptedException e) {
+        ui.message(
+            String.format(
+                "Migration step was interrupted. Only %d of %d tasks done.",
+                countDone(futures), lst.size()));
+        throw new RuntimeException(e);
+      } catch (ExecutionException e) {
+        ui.message(e.getCause().getMessage());
+      }
+    }
+    return returnSet;
+  }
+
+  private static long countDone(Collection<Future> futures) {
+    return futures.stream().filter(Future::isDone).count();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
index 245e94d..2f3d09f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
@@ -27,7 +27,7 @@
 
 /** Validates the current schema version. */
 public class SchemaVersionCheck implements LifecycleListener {
-  public static Module module () {
+  public static Module module() {
     return new LifecycleModule() {
       @Override
       protected void configure() {
@@ -40,8 +40,7 @@
   private final SitePaths site;
 
   @Inject
-  public SchemaVersionCheck(SchemaFactory<ReviewDb> schemaFactory,
-      SitePaths site) {
+  public SchemaVersionCheck(SchemaFactory<ReviewDb> schemaFactory, SitePaths site) {
     this.schema = schemaFactory;
     this.site = site;
   }
@@ -53,21 +52,30 @@
       final int expectedVer = SchemaVersion.getBinaryVersion();
 
       if (currentVer == null) {
-        throw new ProvisionException("Schema not yet initialized."
-            + "  Run init to initialize the schema:\n"
-            + "$ java -jar gerrit.war init -d "
-            + site.site_path.toAbsolutePath());
+        throw new ProvisionException(
+            "Schema not yet initialized."
+                + "  Run init to initialize the schema:\n"
+                + "$ java -jar gerrit.war init -d "
+                + site.site_path.toAbsolutePath());
       }
       if (currentVer.versionNbr < expectedVer) {
-        throw new ProvisionException("Unsupported schema version "
-            + currentVer.versionNbr + "; expected schema version " + expectedVer
-            + ".  Run init to upgrade:\n"
-            + "$ java -jar " + site.gerrit_war.toAbsolutePath() + " init -d "
-            + site.site_path.toAbsolutePath());
+        throw new ProvisionException(
+            "Unsupported schema version "
+                + currentVer.versionNbr
+                + "; expected schema version "
+                + expectedVer
+                + ".  Run init to upgrade:\n"
+                + "$ java -jar "
+                + site.gerrit_war.toAbsolutePath()
+                + " init -d "
+                + site.site_path.toAbsolutePath());
       } else if (currentVer.versionNbr > expectedVer) {
-        throw new ProvisionException("Unsupported schema version "
-            + currentVer.versionNbr + "; expected schema version " + expectedVer
-            + ". Downgrade is not supported.");
+        throw new ProvisionException(
+            "Unsupported schema version "
+                + currentVer.versionNbr
+                + "; expected schema version "
+                + expectedVer
+                + ". Downgrade is not supported.");
       }
     } catch (OrmException e) {
       throw new ProvisionException("Cannot read schema_version", e);
@@ -75,8 +83,7 @@
   }
 
   @Override
-  public void stop() {
-  }
+  public void stop() {}
 
   private CurrentSchemaVersion getSchemaVersion(final ReviewDb db) {
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_101.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_101.java
index 4ef0d96..ccbb2de 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_101.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_101.java
@@ -27,7 +27,6 @@
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
 import java.sql.ResultSet;
@@ -55,8 +54,7 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws OrmException, SQLException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     conn = ((JdbcSchema) db).getConnection();
     dialect = ((JdbcSchema) db).getDialect();
     Map<String, PrimaryKey> corrections = findPKUpdates();
@@ -78,8 +76,7 @@
     }
   }
 
-  private Map<String, PrimaryKey> findPKUpdates()
-      throws OrmException, SQLException {
+  private Map<String, PrimaryKey> findPKUpdates() throws OrmException, SQLException {
     Map<String, PrimaryKey> corrections = new TreeMap<>();
     DatabaseMetaData meta = conn.getMetaData();
     JavaSchemaModel jsm = new JavaSchemaModel(ReviewDb.class);
@@ -104,8 +101,7 @@
     return pk;
   }
 
-  private PrimaryKey dbTablePK(DatabaseMetaData meta, String tableName)
-      throws SQLException {
+  private PrimaryKey dbTablePK(DatabaseMetaData meta, String tableName) throws SQLException {
     if (meta.storesUpperCaseIdentifiers()) {
       tableName = tableName.toUpperCase();
     } else if (meta.storesLowerCaseIdentifiers()) {
@@ -130,21 +126,19 @@
     }
   }
 
-  private void recreatePK(StatementExecutor executor, String tableName,
-      PrimaryKey pk, UpdateUI ui) throws OrmException {
+  private void recreatePK(StatementExecutor executor, String tableName, PrimaryKey pk, UpdateUI ui)
+      throws OrmException {
     if (pk.oldNameInDb == null) {
-      ui.message(String.format(
-          "warning: primary key for table %s didn't exist ... ", tableName));
+      ui.message(String.format("warning: primary key for table %s didn't exist ... ", tableName));
     } else {
       if (dialect instanceof DialectPostgreSQL) {
         // postgresql doesn't support the ALTER TABLE foo DROP PRIMARY KEY form
-        executor.execute("ALTER TABLE " + tableName + " DROP CONSTRAINT "
-            + pk.oldNameInDb);
+        executor.execute("ALTER TABLE " + tableName + " DROP CONSTRAINT " + pk.oldNameInDb);
       } else {
         executor.execute("ALTER TABLE " + tableName + " DROP PRIMARY KEY");
       }
     }
-    executor.execute("ALTER TABLE " + tableName
-        + " ADD PRIMARY KEY(" + Joiner.on(",").join(pk.cols) + ")");
+    executor.execute(
+        "ALTER TABLE " + tableName + " ADD PRIMARY KEY(" + Joiner.on(",").join(pk.cols) + ")");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_102.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_102.java
index bcb3e1a..1c1aa55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_102.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_102.java
@@ -22,7 +22,6 @@
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.SQLException;
 import java.util.Set;
 import java.util.regex.Pattern;
@@ -34,8 +33,7 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws OrmException, SQLException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     JdbcSchema schema = (JdbcSchema) db;
     SqlDialect dialect = schema.getDialect();
     try (StatementExecutor e = newExecutor(db)) {
@@ -43,11 +41,9 @@
       // See "Delete SQL index support" commit for more details:
       // d4ae3a16d5e1464574bd04f429a63eb9c02b3b43
       Pattern pattern =
-          Pattern.compile("^changes_(allOpen|allClosed|byBranchClosed)$",
-              Pattern.CASE_INSENSITIVE);
+          Pattern.compile("^changes_(allOpen|allClosed|byBranchClosed)$", Pattern.CASE_INSENSITIVE);
       String table = "changes";
-      Set<String> listIndexes = dialect.listIndexes(
-          schema.getConnection(), table);
+      Set<String> listIndexes = dialect.listIndexes(schema.getConnection(), table);
       for (String index : listIndexes) {
         if (pattern.matcher(index).matches()) {
           dialect.dropIndex(e, table, index);
@@ -56,12 +52,18 @@
 
       dialect.dropIndex(e, table, "changes_byProjectOpen");
       if (dialect instanceof DialectPostgreSQL) {
-        e.execute("CREATE INDEX changes_byProjectOpen"
-            + " ON " + table + " (dest_project_name, last_updated_on)"
-            + " WHERE open = 'Y'");
+        e.execute(
+            "CREATE INDEX changes_byProjectOpen"
+                + " ON "
+                + table
+                + " (dest_project_name, last_updated_on)"
+                + " WHERE open = 'Y'");
       } else {
-        e.execute("CREATE INDEX changes_byProjectOpen"
-            + " ON " + table + " (open, dest_project_name, last_updated_on)");
+        e.execute(
+            "CREATE INDEX changes_byProjectOpen"
+                + " ON "
+                + table
+                + " (open, dest_project_name, last_updated_on)");
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_105.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_105.java
index 74f0cf5..dd5e71a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_105.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_105.java
@@ -23,7 +23,6 @@
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.SQLException;
 import java.util.HashMap;
 import java.util.Map;
@@ -38,8 +37,7 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws SQLException, OrmException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException, OrmException {
     JdbcSchema schema = (JdbcSchema) db;
     SqlDialect dialect = schema.getDialect();
 
@@ -65,25 +63,25 @@
     }
   }
 
-  private Set<String> listChangesIndexes(JdbcSchema schema)
-      throws SQLException {
+  private Set<String> listChangesIndexes(JdbcSchema schema) throws SQLException {
     // List of all changes indexes ever created or dropped, found with the
     // following command:
-    //   find g* -name \*.sql | xargs git log -i -p -S' index changes_' | grep -io ' index changes_\w*' | cut -d' ' -f3 | tr A-Z a-z | sort -u
+    //   find g* -name \*.sql | xargs git log -i -p -S' index changes_' | grep -io ' index
+    // changes_\w*' | cut -d' ' -f3 | tr A-Z a-z | sort -u
     // Used rather than listIndexes as we're not sure whether it might include
     // primary key indexes.
-    Set<String> allChanges = ImmutableSet.of(
-        "changes_allclosed",
-        "changes_allopen",
-        "changes_bybranchclosed",
-        "changes_byownerclosed",
-        "changes_byowneropen",
-        "changes_byproject",
-        "changes_byprojectopen",
-        "changes_key",
-        "changes_submitted");
+    Set<String> allChanges =
+        ImmutableSet.of(
+            "changes_allclosed",
+            "changes_allopen",
+            "changes_bybranchclosed",
+            "changes_byownerclosed",
+            "changes_byowneropen",
+            "changes_byproject",
+            "changes_byprojectopen",
+            "changes_key",
+            "changes_submitted");
     return Sets.intersection(
-        schema.getDialect().listIndexes(schema.getConnection(), TABLE),
-        allChanges);
+        schema.getDialect().listIndexes(schema.getConnection(), TABLE), allChanges);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java
index 838706e..26cf815 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_106.java
@@ -25,22 +25,13 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.File;
 import java.io.IOException;
 import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.SortedSet;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 
 public class Schema_106 extends SchemaVersion {
   // we can use multiple threads per CPU as we can expect that threads will be
@@ -48,9 +39,11 @@
   private static final int THREADS_PER_CPU = 4;
   private final GitRepositoryManager repoManager;
   private final PersonIdent serverUser;
+  private int repoCount;
 
   @Inject
-  Schema_106(Provider<Schema_105> prior,
+  Schema_106(
+      Provider<Schema_105> prior,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
@@ -66,98 +59,50 @@
 
     ui.message("listing all repositories ...");
     SortedSet<Project.NameKey> repoList = repoManager.list();
+    repoCount = repoList.size();
     ui.message("done");
 
-    ui.message(String.format("creating reflog files for %s branches ...",
-        RefNames.REFS_CONFIG));
+    ui.message(String.format("creating reflog files for %s branches ...", RefNames.REFS_CONFIG));
 
-    ExecutorService executorPool = createExecutor(ui, repoList.size());
-    List<Future<Void>> futures = new ArrayList<>();
-
-    for (Project.NameKey project : repoList) {
-      Callable<Void> callable = new ReflogCreator(project);
-      futures.add(executorPool.submit(callable));
-    }
-
-    executorPool.shutdown();
-    try {
-      for (Future<Void> future : futures) {
-        try {
-          future.get();
-        } catch (ExecutionException e) {
-          ui.message(e.getCause().getMessage());
-        }
-      }
-      ui.message("done");
-    } catch (InterruptedException ex) {
-      String msg = String.format(
-              "Migration step 106 was interrupted. "
-              + "Reflog created in %d of %d repositories only.",
-              countDone(futures), repoList.size());
-      ui.message(msg);
-    }
+    runParallelTasks(
+        createExecutor(ui), repoList, (repo) -> createRefLog((Project.NameKey) repo), ui);
   }
 
-  private static int countDone(List<Future<Void>> futures) {
-    int count = 0;
-    for (Future<Void> future : futures) {
-      if (future.isDone()) {
-        count++;
-      }
-    }
-
-    return count;
+  @Override
+  protected int getThreads() {
+    return Math.min(Runtime.getRuntime().availableProcessors() * THREADS_PER_CPU, repoCount);
   }
 
-  private ExecutorService createExecutor(UpdateUI ui, int repoCount) {
-    int procs = Runtime.getRuntime().availableProcessors();
-    int threads = Math.min(procs * THREADS_PER_CPU, repoCount);
-    ui.message(String.format("... using %d threads ...", threads));
-    return Executors.newFixedThreadPool(threads);
-  }
-
-  private class ReflogCreator implements Callable<Void> {
-    private final Project.NameKey project;
-
-    ReflogCreator(Project.NameKey project) {
-      this.project = project;
-    }
-
-    @Override
-    public Void call() throws IOException {
-      try (Repository repo = repoManager.openRepository(project)) {
-        File metaConfigLog =
-            new File(repo.getDirectory(), "logs/" + RefNames.REFS_CONFIG);
-        if (metaConfigLog.exists()) {
-          return null;
-        }
-
-        if (!metaConfigLog.getParentFile().mkdirs()
-            || !metaConfigLog.createNewFile()) {
-          throw new IOException();
-        }
-
-        ObjectId metaConfigId = repo.resolve(RefNames.REFS_CONFIG);
-        if (metaConfigId != null) {
-          try (PrintWriter writer =
-              new PrintWriter(metaConfigLog, UTF_8.name())) {
-            writer.print(ObjectId.zeroId().name());
-            writer.print(" ");
-            writer.print(metaConfigId.name());
-            writer.print(" ");
-            writer.print(serverUser.toExternalString());
-            writer.print("\t");
-            writer.print("create reflog");
-            writer.println();
-          }
-        }
+  public Void createRefLog(Project.NameKey project) throws IOException {
+    try (Repository repo = repoManager.openRepository(project)) {
+      File metaConfigLog = new File(repo.getDirectory(), "logs/" + RefNames.REFS_CONFIG);
+      if (metaConfigLog.exists()) {
         return null;
-      } catch (IOException e) {
-        throw new IOException(String.format(
-            "ERROR: Failed to create reflog file for the"
-                + " %s branch in repository %s", RefNames.REFS_CONFIG,
-            project.get()));
       }
+
+      if (!metaConfigLog.getParentFile().mkdirs() || !metaConfigLog.createNewFile()) {
+        throw new IOException();
+      }
+
+      ObjectId metaConfigId = repo.resolve(RefNames.REFS_CONFIG);
+      if (metaConfigId != null) {
+        try (PrintWriter writer = new PrintWriter(metaConfigLog, UTF_8.name())) {
+          writer.print(ObjectId.zeroId().name());
+          writer.print(" ");
+          writer.print(metaConfigId.name());
+          writer.print(" ");
+          writer.print(serverUser.toExternalString());
+          writer.print("\t");
+          writer.print("create reflog");
+          writer.println();
+        }
+      }
+      return null;
+    } catch (IOException e) {
+      throw new IOException(
+          String.format(
+              "ERROR: Failed to create reflog file for the %s branch in repository %s",
+              RefNames.REFS_CONFIG, project.get()));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_107.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_107.java
index c2c2305..dd8868f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_107.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_107.java
@@ -18,7 +18,6 @@
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.SQLException;
 import java.sql.Statement;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
index 946ddcf..66e0d3a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_108.java
@@ -15,10 +15,9 @@
 package com.google.gerrit.server.schema;
 
 import com.google.common.base.Joiner;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Change;
@@ -28,14 +27,20 @@
 import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
 import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -46,49 +51,57 @@
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedSet;
-
 public class Schema_108 extends SchemaVersion {
   private final GitRepositoryManager repoManager;
+  private final Config cfg;
+  private ReviewDb db;
+  private UpdateUI ui;
 
   @Inject
-  Schema_108(Provider<Schema_107> prior,
-      GitRepositoryManager repoManager) {
+  Schema_108(
+      Provider<Schema_107> prior,
+      GitRepositoryManager repoManager,
+      @GerritServerConfig Config cfg) {
     super(prior);
     this.repoManager = repoManager;
+    this.cfg = cfg;
   }
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    this.db = db;
+    this.ui = ui;
     ui.message("Listing all changes ...");
-    SetMultimap<Project.NameKey, Change.Id> openByProject =
-        getOpenChangesByProject(db, ui);
+    SetMultimap<Project.NameKey, Change.Id> openByProject = getOpenChangesByProject(db, ui);
     ui.message("done");
 
     ui.message("Updating groups for open changes ...");
-    int i = 0;
-    for (Map.Entry<Project.NameKey, Collection<Change.Id>> e
-        : openByProject.asMap().entrySet()) {
-      try (Repository repo = repoManager.openRepository(e.getKey());
-          RevWalk rw = new RevWalk(repo)) {
-        updateProjectGroups(db, repo, rw, (Set<Change.Id>) e.getValue(), ui);
-      } catch (IOException | NoSuchChangeException err) {
-        throw new OrmException(err);
-      }
-      if (++i % 100 == 0) {
-        ui.message("  done " + i + " projects ...");
-      }
-    }
+    runParallelTasks(
+        createExecutor(ui),
+        openByProject.asMap().entrySet(),
+        (batch) -> processProjectBatch((Map.Entry<NameKey, Collection<Change.Id>>) batch),
+        ui);
     ui.message("done");
   }
 
-  private void updateProjectGroups(ReviewDb db, Repository repo, RevWalk rw,
-      Set<Change.Id> changes, UpdateUI ui)
-          throws OrmException, IOException, NoSuchChangeException {
+  private Void processProjectBatch(
+      Map.Entry<Project.NameKey, Collection<Change.Id>> changesByProject) throws OrmException {
+    try (Repository repo = repoManager.openRepository(changesByProject.getKey());
+        RevWalk rw = new RevWalk(repo)) {
+      updateProjectGroups(repo, rw, (Set<Change.Id>) changesByProject.getValue());
+    } catch (IOException | NoSuchChangeException err) {
+      throw new OrmException(err);
+    }
+    return null;
+  }
+
+  @Override
+  protected int getThreads() {
+    return cfg.getInt("cache", "projects", "loadThreads", super.getThreads());
+  }
+
+  private void updateProjectGroups(Repository repo, RevWalk rw, Set<Change.Id> changes)
+      throws OrmException, IOException {
     // Match sorting in ReceiveCommits.
     rw.reset();
     rw.sort(RevSort.TOPO);
@@ -102,8 +115,10 @@
       }
     }
 
-    Multimap<ObjectId, Ref> changeRefsBySha = ArrayListMultimap.create();
-    Multimap<ObjectId, PatchSet.Id> patchSetsBySha = ArrayListMultimap.create();
+    ListMultimap<ObjectId, Ref> changeRefsBySha =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha =
+        MultimapBuilder.hashKeys().arrayListValues().build();
     for (Ref ref : refdb.getRefs(RefNames.REFS_CHANGES).values()) {
       ObjectId id = ref.getObjectId();
       if (ref.getObjectId() == null) {
@@ -121,8 +136,7 @@
       }
     }
 
-    GroupCollector collector =
-        GroupCollector.createForSchemaUpgradeOnly(changeRefsBySha, db);
+    GroupCollector collector = GroupCollector.createForSchemaUpgradeOnly(changeRefsBySha, db);
     RevCommit c;
     while ((c = rw.next()) != null) {
       collector.visit(c);
@@ -131,13 +145,12 @@
     updateGroups(db, collector, patchSetsBySha);
   }
 
-  private static void updateGroups(ReviewDb db, GroupCollector collector,
-      Multimap<ObjectId, PatchSet.Id> patchSetsBySha)
-          throws OrmException, NoSuchChangeException {
+  private static void updateGroups(
+      ReviewDb db, GroupCollector collector, ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha)
+      throws OrmException {
     Map<PatchSet.Id, PatchSet> patchSets =
         db.patchSets().toMap(db.patchSets().get(patchSetsBySha.values()));
-    for (Map.Entry<ObjectId, Collection<String>> e
-        : collector.getGroups().asMap().entrySet()) {
+    for (Map.Entry<ObjectId, Collection<String>> e : collector.getGroups().asMap().entrySet()) {
       for (PatchSet.Id psId : patchSetsBySha.get(e.getKey())) {
         PatchSet ps = patchSets.get(psId);
         if (ps != null) {
@@ -149,12 +162,12 @@
     db.patchSets().update(patchSets.values());
   }
 
-  private SetMultimap<Project.NameKey, Change.Id> getOpenChangesByProject(
-      ReviewDb db, UpdateUI ui) throws OrmException {
+  private SetMultimap<Project.NameKey, Change.Id> getOpenChangesByProject(ReviewDb db, UpdateUI ui)
+      throws OrmException {
     SortedSet<NameKey> projects = repoManager.list();
     SortedSet<NameKey> nonExistentProjects = Sets.newTreeSet();
     SetMultimap<Project.NameKey, Change.Id> openByProject =
-        HashMultimap.create();
+        MultimapBuilder.hashKeys().hashSetValues().build();
     for (Change c : db.changes().all()) {
       Status status = c.getStatus();
       if (status != null && status.isClosed()) {
@@ -175,8 +188,9 @@
     if (!nonExistentProjects.isEmpty()) {
       ui.message("Detected open changes referring to the following non-existent projects:");
       ui.message(Joiner.on(", ").join(nonExistentProjects));
-      ui.message("It is highly recommended to remove\n"
-          + "the obsolete open changes, comments and patch-sets from your DB.\n");
+      ui.message(
+          "It is highly recommended to remove\n"
+              + "the obsolete open changes, comments and patch-sets from your DB.\n");
     }
     return openByProject;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java
index 26cd3e1..3c6a50e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_115.java
@@ -35,14 +35,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
 import java.io.IOException;
 import java.sql.ResultSet;
 import java.sql.ResultSetMetaData;
@@ -52,6 +44,12 @@
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 public class Schema_115 extends SchemaVersion {
   private final GitRepositoryManager mgr;
@@ -59,7 +57,8 @@
   private final PersonIdent serverUser;
 
   @Inject
-  Schema_115(Provider<Schema_114> prior,
+  Schema_115(
+      Provider<Schema_114> prior,
       GitRepositoryManager mgr,
       AllUsersName allUsersName,
       @GerritPersonIdent PersonIdent serverUser) {
@@ -70,80 +69,78 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws OrmException, SQLException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     Map<Account.Id, DiffPreferencesInfo> imports = new HashMap<>();
     try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs = stmt.executeQuery(
-          "SELECT * FROM account_diff_preferences")) {
-        Set<String> availableColumns = getColumns(rs);
-        while (rs.next()) {
-          Account.Id accountId = new Account.Id(rs.getInt("id"));
-          DiffPreferencesInfo prefs = new DiffPreferencesInfo();
-          if (availableColumns.contains("context")) {
-            prefs.context = (int)rs.getShort("context");
-          }
-          if (availableColumns.contains("expand_all_comments")) {
-            prefs.expandAllComments = toBoolean(rs.getString("expand_all_comments"));
-          }
-          if (availableColumns.contains("hide_line_numbers")) {
-            prefs.hideLineNumbers = toBoolean(rs.getString("hide_line_numbers"));
-          }
-          if (availableColumns.contains("hide_top_menu")) {
-            prefs.hideTopMenu = toBoolean(rs.getString("hide_top_menu"));
-          }
-          if (availableColumns.contains("ignore_whitespace")) {
-            // Enum with char as value
-            prefs.ignoreWhitespace = toWhitespace(rs.getString("ignore_whitespace"));
-          }
-          if (availableColumns.contains("intraline_difference")) {
-            prefs.intralineDifference = toBoolean(rs.getString("intraline_difference"));
-          }
-          if (availableColumns.contains("line_length")) {
-            prefs.lineLength = rs.getInt("line_length");
-          }
-          if (availableColumns.contains("manual_review")) {
-            prefs.manualReview = toBoolean(rs.getString("manual_review"));
-          }
-          if (availableColumns.contains("render_entire_file")) {
-            prefs.renderEntireFile = toBoolean(rs.getString("render_entire_file"));
-          }
-          if (availableColumns.contains("retain_header")) {
-            prefs.retainHeader = toBoolean(rs.getString("retain_header"));
-          }
-          if (availableColumns.contains("show_line_endings")) {
-            prefs.showLineEndings = toBoolean(rs.getString("show_line_endings"));
-          }
-          if (availableColumns.contains("show_tabs")) {
-            prefs.showTabs = toBoolean(rs.getString("show_tabs"));
-          }
-          if (availableColumns.contains("show_whitespace_errors")) {
-            prefs.showWhitespaceErrors = toBoolean(rs.getString("show_whitespace_errors"));
-          }
-          if (availableColumns.contains("skip_deleted")) {
-            prefs.skipDeleted = toBoolean(rs.getString("skip_deleted"));
-          }
-          if (availableColumns.contains("skip_uncommented")) {
-            prefs.skipUncommented = toBoolean(rs.getString("skip_uncommented"));
-          }
-          if (availableColumns.contains("syntax_highlighting")) {
-            prefs.syntaxHighlighting = toBoolean(rs.getString("syntax_highlighting"));
-          }
-          if (availableColumns.contains("tab_size")) {
-            prefs.tabSize = rs.getInt("tab_size");
-          }
-          if (availableColumns.contains("theme")) {
-            // Enum with name as values; can be null
-            prefs.theme = toTheme(rs.getString("theme"));
-          }
-          if (availableColumns.contains("hide_empty_pane")) {
-            prefs.hideEmptyPane = toBoolean(rs.getString("hide_empty_pane"));
-          }
-          if (availableColumns.contains("auto_hide_diff_table_header")) {
-            prefs.autoHideDiffTableHeader = toBoolean(rs.getString("auto_hide_diff_table_header"));
-          }
-          imports.put(accountId, prefs);
+        ResultSet rs = stmt.executeQuery("SELECT * FROM account_diff_preferences")) {
+      Set<String> availableColumns = getColumns(rs);
+      while (rs.next()) {
+        Account.Id accountId = new Account.Id(rs.getInt("id"));
+        DiffPreferencesInfo prefs = new DiffPreferencesInfo();
+        if (availableColumns.contains("context")) {
+          prefs.context = (int) rs.getShort("context");
         }
+        if (availableColumns.contains("expand_all_comments")) {
+          prefs.expandAllComments = toBoolean(rs.getString("expand_all_comments"));
+        }
+        if (availableColumns.contains("hide_line_numbers")) {
+          prefs.hideLineNumbers = toBoolean(rs.getString("hide_line_numbers"));
+        }
+        if (availableColumns.contains("hide_top_menu")) {
+          prefs.hideTopMenu = toBoolean(rs.getString("hide_top_menu"));
+        }
+        if (availableColumns.contains("ignore_whitespace")) {
+          // Enum with char as value
+          prefs.ignoreWhitespace = toWhitespace(rs.getString("ignore_whitespace"));
+        }
+        if (availableColumns.contains("intraline_difference")) {
+          prefs.intralineDifference = toBoolean(rs.getString("intraline_difference"));
+        }
+        if (availableColumns.contains("line_length")) {
+          prefs.lineLength = rs.getInt("line_length");
+        }
+        if (availableColumns.contains("manual_review")) {
+          prefs.manualReview = toBoolean(rs.getString("manual_review"));
+        }
+        if (availableColumns.contains("render_entire_file")) {
+          prefs.renderEntireFile = toBoolean(rs.getString("render_entire_file"));
+        }
+        if (availableColumns.contains("retain_header")) {
+          prefs.retainHeader = toBoolean(rs.getString("retain_header"));
+        }
+        if (availableColumns.contains("show_line_endings")) {
+          prefs.showLineEndings = toBoolean(rs.getString("show_line_endings"));
+        }
+        if (availableColumns.contains("show_tabs")) {
+          prefs.showTabs = toBoolean(rs.getString("show_tabs"));
+        }
+        if (availableColumns.contains("show_whitespace_errors")) {
+          prefs.showWhitespaceErrors = toBoolean(rs.getString("show_whitespace_errors"));
+        }
+        if (availableColumns.contains("skip_deleted")) {
+          prefs.skipDeleted = toBoolean(rs.getString("skip_deleted"));
+        }
+        if (availableColumns.contains("skip_uncommented")) {
+          prefs.skipUncommented = toBoolean(rs.getString("skip_uncommented"));
+        }
+        if (availableColumns.contains("syntax_highlighting")) {
+          prefs.syntaxHighlighting = toBoolean(rs.getString("syntax_highlighting"));
+        }
+        if (availableColumns.contains("tab_size")) {
+          prefs.tabSize = rs.getInt("tab_size");
+        }
+        if (availableColumns.contains("theme")) {
+          // Enum with name as values; can be null
+          prefs.theme = toTheme(rs.getString("theme"));
+        }
+        if (availableColumns.contains("hide_empty_pane")) {
+          prefs.hideEmptyPane = toBoolean(rs.getString("hide_empty_pane"));
+        }
+        if (availableColumns.contains("auto_hide_diff_table_header")) {
+          prefs.autoHideDiffTableHeader = toBoolean(rs.getString("auto_hide_diff_table_header"));
+        }
+        imports.put(accountId, prefs);
+      }
     }
 
     if (imports.isEmpty()) {
@@ -154,15 +151,18 @@
         RevWalk rw = new RevWalk(git)) {
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
       for (Map.Entry<Account.Id, DiffPreferencesInfo> e : imports.entrySet()) {
-        try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
-            allUsersName, git, bru)) {
+        try (MetaDataUpdate md =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
           md.getCommitBuilder().setAuthor(serverUser);
           md.getCommitBuilder().setCommitter(serverUser);
-          VersionedAccountPreferences p =
-              VersionedAccountPreferences.forUser(e.getKey());
+          VersionedAccountPreferences p = VersionedAccountPreferences.forUser(e.getKey());
           p.load(md);
-          storeSection(p.getConfig(), UserConfigSections.DIFF, null,
-              e.getValue(), DiffPreferencesInfo.defaults());
+          storeSection(
+              p.getConfig(),
+              UserConfigSections.DIFF,
+              null,
+              e.getValue(),
+              DiffPreferencesInfo.defaults());
           p.commit(md);
         }
       }
@@ -197,8 +197,7 @@
     }
     Whitespace r = PatchListKey.WHITESPACE_TYPES.inverse().get(v.charAt(0));
     if (r == null) {
-      throw new IllegalArgumentException("Cannot find Whitespace type for: "
-          + v);
+      throw new IllegalArgumentException("Cannot find Whitespace type for: " + v);
     }
     return r;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java
index 6176aeb..35e6c8a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_117.java
@@ -19,7 +19,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.Connection;
 import java.sql.SQLException;
 import java.sql.Statement;
@@ -32,21 +31,18 @@
   }
 
   @Override
-  protected void preUpdateSchema(ReviewDb db)
-      throws OrmException, SQLException {
+  protected void preUpdateSchema(ReviewDb db) throws OrmException, SQLException {
     JdbcSchema schema = (JdbcSchema) db;
     Connection connection = schema.getConnection();
     String tableName = "patch_sets";
     String oldColumnName = "push_certficate";
     String newColumnName = "push_certificate";
-    Set<String> columns =
-        schema.getDialect().listColumns(connection, tableName);
+    Set<String> columns = schema.getDialect().listColumns(connection, tableName);
     if (columns.contains(oldColumnName)) {
       renameColumn(db, tableName, oldColumnName, newColumnName);
     }
     try (Statement stmt = schema.getConnection().createStatement()) {
-      stmt.execute(
-          "ALTER TABLE " + tableName + " MODIFY " + newColumnName + " clob");
+      stmt.execute("ALTER TABLE " + tableName + " MODIFY " + newColumnName + " clob");
     } catch (SQLException e) {
       // Ignore.  Type may have already been modified manually.
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
index 9fdec25..f6abca8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_119.java
@@ -44,14 +44,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
 import java.io.IOException;
 import java.sql.Connection;
 import java.sql.ResultSet;
@@ -60,10 +52,16 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 public class Schema_119 extends SchemaVersion {
-  private static final Map<String, String> LEGACY_DISPLAYNAME_MAP =
-      ImmutableMap.<String, String> of(
+  private static final ImmutableMap<String, String> LEGACY_DISPLAYNAME_MAP =
+      ImmutableMap.<String, String>of(
           "ANON_GIT", ANON_GIT,
           "ANON_HTTP", ANON_HTTP,
           "HTTP", HTTP,
@@ -75,7 +73,8 @@
   private final PersonIdent serverUser;
 
   @Inject
-  Schema_119(Provider<Schema_118> prior,
+  Schema_119(
+      Provider<Schema_118> prior,
       GitRepositoryManager mgr,
       AllUsersName allUsersName,
       @GerritPersonIdent PersonIdent serverUser) {
@@ -86,58 +85,56 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws OrmException, SQLException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     JdbcSchema schema = (JdbcSchema) db;
     Connection connection = schema.getConnection();
     String tableName = "accounts";
     String emailStrategy = "email_strategy";
-    Set<String> columns =
-        schema.getDialect().listColumns(connection, tableName);
+    Set<String> columns = schema.getDialect().listColumns(connection, tableName);
     Map<Account.Id, GeneralPreferencesInfo> imports = new HashMap<>();
     try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs = stmt.executeQuery(
-          "select "
-          + "account_id, "
-          + "maximum_page_size, "
-          + "show_site_header, "
-          + "use_flash_clipboard, "
-          + "download_url, "
-          + "download_command, "
-          + (columns.contains(emailStrategy)
-              ? emailStrategy + ", "
-              : "copy_self_on_email, ")
-          + "date_format, "
-          + "time_format, "
-          + "relative_date_in_change_table, "
-          + "diff_view, "
-          + "size_bar_in_change_table, "
-          + "legacycid_in_change_table, "
-          + "review_category_strategy, "
-          + "mute_common_path_prefixes "
-          + "from " + tableName)) {
-        while (rs.next()) {
-          GeneralPreferencesInfo p =
-              new GeneralPreferencesInfo();
-          Account.Id accountId = new Account.Id(rs.getInt(1));
-          p.changesPerPage = (int)rs.getShort(2);
-          p.showSiteHeader = toBoolean(rs.getString(3));
-          p.useFlashClipboard = toBoolean(rs.getString(4));
-          p.downloadScheme = convertToModernNames(rs.getString(5));
-          p.downloadCommand = toDownloadCommand(rs.getString(6));
-          p.emailStrategy = toEmailStrategy(rs.getString(7),
-              columns.contains(emailStrategy));
-          p.dateFormat = toDateFormat(rs.getString(8));
-          p.timeFormat = toTimeFormat(rs.getString(9));
-          p.relativeDateInChangeTable = toBoolean(rs.getString(10));
-          p.diffView = toDiffView(rs.getString(11));
-          p.sizeBarInChangeTable = toBoolean(rs.getString(12));
-          p.legacycidInChangeTable = toBoolean(rs.getString(13));
-          p.reviewCategoryStrategy =
-              toReviewCategoryStrategy(rs.getString(14));
-          p.muteCommonPathPrefixes = toBoolean(rs.getString(15));
-          imports.put(accountId, p);
-        }
+        ResultSet rs =
+            stmt.executeQuery(
+                "select "
+                    + "account_id, "
+                    + "maximum_page_size, "
+                    + "show_site_header, "
+                    + "use_flash_clipboard, "
+                    + "download_url, "
+                    + "download_command, "
+                    + (columns.contains(emailStrategy)
+                        ? emailStrategy + ", "
+                        : "copy_self_on_email, ")
+                    + "date_format, "
+                    + "time_format, "
+                    + "relative_date_in_change_table, "
+                    + "diff_view, "
+                    + "size_bar_in_change_table, "
+                    + "legacycid_in_change_table, "
+                    + "review_category_strategy, "
+                    + "mute_common_path_prefixes "
+                    + "from "
+                    + tableName)) {
+      while (rs.next()) {
+        GeneralPreferencesInfo p = new GeneralPreferencesInfo();
+        Account.Id accountId = new Account.Id(rs.getInt(1));
+        p.changesPerPage = (int) rs.getShort(2);
+        p.showSiteHeader = toBoolean(rs.getString(3));
+        p.useFlashClipboard = toBoolean(rs.getString(4));
+        p.downloadScheme = convertToModernNames(rs.getString(5));
+        p.downloadCommand = toDownloadCommand(rs.getString(6));
+        p.emailStrategy = toEmailStrategy(rs.getString(7), columns.contains(emailStrategy));
+        p.dateFormat = toDateFormat(rs.getString(8));
+        p.timeFormat = toTimeFormat(rs.getString(9));
+        p.relativeDateInChangeTable = toBoolean(rs.getString(10));
+        p.diffView = toDiffView(rs.getString(11));
+        p.sizeBarInChangeTable = toBoolean(rs.getString(12));
+        p.legacycidInChangeTable = toBoolean(rs.getString(13));
+        p.reviewCategoryStrategy = toReviewCategoryStrategy(rs.getString(14));
+        p.muteCommonPathPrefixes = toBoolean(rs.getString(15));
+        p.defaultBaseForMerges = GeneralPreferencesInfo.defaults().defaultBaseForMerges;
+        imports.put(accountId, p);
+      }
     }
 
     if (imports.isEmpty()) {
@@ -147,17 +144,19 @@
     try (Repository git = mgr.openRepository(allUsersName);
         RevWalk rw = new RevWalk(git)) {
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      for (Map.Entry<Account.Id, GeneralPreferencesInfo> e
-          : imports.entrySet()) {
-        try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
-            allUsersName, git, bru)) {
+      for (Map.Entry<Account.Id, GeneralPreferencesInfo> e : imports.entrySet()) {
+        try (MetaDataUpdate md =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
           md.getCommitBuilder().setAuthor(serverUser);
           md.getCommitBuilder().setCommitter(serverUser);
-          VersionedAccountPreferences p =
-              VersionedAccountPreferences.forUser(e.getKey());
+          VersionedAccountPreferences p = VersionedAccountPreferences.forUser(e.getKey());
           p.load(md);
-          storeSection(p.getConfig(), UserConfigSections.GENERAL, null,
-              e.getValue(), GeneralPreferencesInfo.defaults());
+          storeSection(
+              p.getConfig(),
+              UserConfigSections.GENERAL,
+              null,
+              e.getValue(),
+              GeneralPreferencesInfo.defaults());
           p.commit(md);
         }
       }
@@ -202,8 +201,8 @@
     return DiffView.valueOf(v);
   }
 
-  private static EmailStrategy toEmailStrategy(String v,
-      boolean emailStrategyColumnExists) throws OrmException {
+  private static EmailStrategy toEmailStrategy(String v, boolean emailStrategyColumnExists)
+      throws OrmException {
     if (v == null) {
       return EmailStrategy.ENABLED;
     }
@@ -217,8 +216,7 @@
       // EMAIL_STRATEGY='CC_ON_OWN_COMMENTS' WHERE (COPY_SELF_ON_EMAIL='Y')
       return EmailStrategy.CC_ON_OWN_COMMENTS;
     } else {
-      throw new OrmException(
-          "invalid value in accounts.copy_self_on_email: " + v);
+      throw new OrmException("invalid value in accounts.copy_self_on_email: " + v);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
index fc1b0cd..072fc62 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_120.java
@@ -27,7 +27,10 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
@@ -36,18 +39,14 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-
 public class Schema_120 extends SchemaVersion {
 
   private final GitRepositoryManager mgr;
   private final PersonIdent serverUser;
 
   @Inject
-  Schema_120(Provider<Schema_119> prior,
+  Schema_120(
+      Provider<Schema_119> prior,
       GitRepositoryManager mgr,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
@@ -55,13 +54,13 @@
     this.serverUser = serverUser;
   }
 
-  private void allowSubmoduleSubscription(Branch.NameKey subbranch,
-      Branch.NameKey superBranch) throws OrmException {
+  private void allowSubmoduleSubscription(Branch.NameKey subbranch, Branch.NameKey superBranch)
+      throws OrmException {
     try (Repository git = mgr.openRepository(subbranch.getParentKey());
         RevWalk rw = new RevWalk(git)) {
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      try (MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
-          subbranch.getParentKey(), git, bru)) {
+      try (MetaDataUpdate md =
+          new MetaDataUpdate(GitReferenceUpdated.DISABLED, subbranch.getParentKey(), git, bru)) {
         md.getCommitBuilder().setAuthor(serverUser);
         md.getCommitBuilder().setCommitter(serverUser);
         md.setMessage("Added superproject subscription during upgrade");
@@ -94,25 +93,24 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws OrmException, SQLException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     ui.message("Generating Superproject subscriptions table to submodule ACLs");
 
     try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-        ResultSet rs = stmt.executeQuery("SELECT "
-            + "super_project_project_name, "
-            + "super_project_branch_name, "
-            + "submodule_project_name, "
-            + "submodule_branch_name "
-            + "FROM submodule_subscriptions")) {
+        ResultSet rs =
+            stmt.executeQuery(
+                "SELECT "
+                    + "super_project_project_name, "
+                    + "super_project_branch_name, "
+                    + "submodule_project_name, "
+                    + "submodule_branch_name "
+                    + "FROM submodule_subscriptions")) {
       while (rs.next()) {
         Project.NameKey superproject = new Project.NameKey(rs.getString(1));
-        Branch.NameKey superbranch = new Branch.NameKey(superproject,
-            rs.getString(2));
+        Branch.NameKey superbranch = new Branch.NameKey(superproject, rs.getString(2));
 
         Project.NameKey submodule = new Project.NameKey(rs.getString(3));
-        Branch.NameKey subbranch = new Branch.NameKey(submodule,
-            rs.getString(4));
+        Branch.NameKey subbranch = new Branch.NameKey(submodule, rs.getString(4));
 
         allowSubmoduleSubscription(subbranch, superbranch);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
index d698974..ec63141 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -27,7 +27,11 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Map;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -35,35 +39,24 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-import java.io.IOException;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.util.Map;
-
 public class Schema_123 extends SchemaVersion {
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
 
   @Inject
-  Schema_123(Provider<Schema_122> prior,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName) {
+  Schema_123(
+      Provider<Schema_122> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
     super(prior);
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws OrmException, SQLException {
-    Multimap<Account.Id, Change.Id> imports = ArrayListMultimap.create();
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    ListMultimap<Account.Id, Change.Id> imports =
+        MultimapBuilder.hashKeys().arrayListValues().build();
     try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-      ResultSet rs = stmt.executeQuery(
-          "SELECT "
-          + "account_id, "
-          + "change_id "
-          + "FROM starred_changes")) {
+        ResultSet rs = stmt.executeQuery("SELECT account_id, change_id FROM starred_changes")) {
       while (rs.next()) {
         Account.Id accountId = new Account.Id(rs.getInt(1));
         Change.Id changeId = new Change.Id(rs.getInt(2));
@@ -78,11 +71,11 @@
     try (Repository git = repoManager.openRepository(allUsersName);
         RevWalk rw = new RevWalk(git)) {
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
-      ObjectId id = StarredChangesUtil.writeLabels(git,
-          StarredChangesUtil.DEFAULT_LABELS);
+      ObjectId id = StarredChangesUtil.writeLabels(git, StarredChangesUtil.DEFAULT_LABELS);
       for (Map.Entry<Account.Id, Change.Id> e : imports.entries()) {
-        bru.addCommand(new ReceiveCommand(ObjectId.zeroId(), id,
-            RefNames.refsStarredChanges(e.getValue(), e.getKey())));
+        bru.addCommand(
+            new ReceiveCommand(
+                ObjectId.zeroId(), id, RefNames.refsStarredChanges(e.getValue(), e.getKey())));
       }
       bru.execute(rw, new TextProgressMonitor());
     } catch (IOException ex) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
index 16f0bcf..7842b65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_124.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.common.base.Function;
+import static java.util.Comparator.comparing;
+
 import com.google.common.base.Strings;
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
@@ -33,14 +34,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-
 import java.io.IOException;
 import java.sql.ResultSet;
 import java.sql.SQLException;
@@ -50,6 +43,12 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 public class Schema_124 extends SchemaVersion {
   private final GitRepositoryManager repoManager;
@@ -57,7 +56,8 @@
   private final PersonIdent serverUser;
 
   @Inject
-  Schema_124(Provider<Schema_123> prior,
+  Schema_124(
+      Provider<Schema_123> prior,
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       @GerritPersonIdent PersonIdent serverUser) {
@@ -68,23 +68,23 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws OrmException, SQLException {
-    Multimap<Account.Id, AccountSshKey> imports = ArrayListMultimap.create();
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    ListMultimap<Account.Id, AccountSshKey> imports =
+        MultimapBuilder.hashKeys().arrayListValues().build();
     try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
-      ResultSet rs = stmt.executeQuery(
-          "SELECT "
-          + "account_id, "
-          + "seq, "
-          + "ssh_public_key, "
-          + "valid "
-          + "FROM account_ssh_keys")) {
+        ResultSet rs =
+            stmt.executeQuery(
+                "SELECT "
+                    + "account_id, "
+                    + "seq, "
+                    + "ssh_public_key, "
+                    + "valid "
+                    + "FROM account_ssh_keys")) {
       while (rs.next()) {
         Account.Id accountId = new Account.Id(rs.getInt(1));
         int seq = rs.getInt(2);
         String sshPublicKey = rs.getString(3);
-        AccountSshKey key = new AccountSshKey(
-            new AccountSshKey.Id(accountId, seq), sshPublicKey);
+        AccountSshKey key = new AccountSshKey(new AccountSshKey.Id(accountId, seq), sshPublicKey);
         boolean valid = toBoolean(rs.getString(4));
         if (!valid) {
           key.setInvalid();
@@ -101,15 +101,14 @@
         RevWalk rw = new RevWalk(git)) {
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
 
-      for (Map.Entry<Account.Id, Collection<AccountSshKey>> e : imports.asMap()
-          .entrySet()) {
-        try (MetaDataUpdate md = new MetaDataUpdate(
-                 GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
+      for (Map.Entry<Account.Id, Collection<AccountSshKey>> e : imports.asMap().entrySet()) {
+        try (MetaDataUpdate md =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
           md.getCommitBuilder().setAuthor(serverUser);
           md.getCommitBuilder().setCommitter(serverUser);
 
-          VersionedAuthorizedKeys authorizedKeys = new VersionedAuthorizedKeys(
-              new SimpleSshKeyCreator(), e.getKey());
+          VersionedAuthorizedKeys authorizedKeys =
+              new VersionedAuthorizedKeys(new SimpleSshKeyCreator(), e.getKey());
           authorizedKeys.load(md);
           authorizedKeys.setKeys(fixInvalidSequenceNumbers(e.getValue()));
           authorizedKeys.commit(md);
@@ -122,22 +121,16 @@
     }
   }
 
-  private Collection<AccountSshKey> fixInvalidSequenceNumbers(
-      Collection<AccountSshKey> keys) {
-    Ordering<AccountSshKey> o =
-        Ordering.natural().onResultOf(new Function<AccountSshKey, Integer>() {
-          @Override
-          public Integer apply(AccountSshKey sshKey) {
-            return sshKey.getKey().get();
-          }
-        });
+  private Collection<AccountSshKey> fixInvalidSequenceNumbers(Collection<AccountSshKey> keys) {
+    Ordering<AccountSshKey> o = Ordering.from(comparing(k -> k.getKey().get()));
     List<AccountSshKey> fixedKeys = new ArrayList<>(keys);
     AccountSshKey minKey = o.min(keys);
     while (minKey.getKey().get() <= 0) {
-      AccountSshKey fixedKey = new AccountSshKey(
-          new AccountSshKey.Id(minKey.getKey().getParentKey(),
-              Math.max(o.max(keys).getKey().get() + 1, 1)),
-          minKey.getSshPublicKey());
+      AccountSshKey fixedKey =
+          new AccountSshKey(
+              new AccountSshKey.Id(
+                  minKey.getKey().getParentKey(), Math.max(o.max(keys).getKey().get() + 1, 1)),
+              minKey.getSshPublicKey());
       Collections.replaceAll(fixedKeys, minKey, fixedKey);
       minKey = o.min(fixedKeys);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
index 714eb69d..947c115 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_125.java
@@ -36,64 +36,65 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 
 public class Schema_125 extends SchemaVersion {
   private static final String COMMIT_MSG =
-      "Assign default permissions on user branches\n" +
-      "\n" +
-      "By default each user should be able to read and update the own user\n" +
-      "branch. Also the user should be able to approve and submit changes for\n" +
-      "the own user branch. Assign default permissions for this and remove the\n" +
-      "old exclusive read protection from the user branches.\n";
+      "Assign default permissions on user branches\n"
+          + "\n"
+          + "By default each user should be able to read and update the own user\n"
+          + "branch. Also the user should be able to approve and submit changes for\n"
+          + "the own user branch. Assign default permissions for this and remove the\n"
+          + "old exclusive read protection from the user branches.\n";
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final AllProjectsName allProjectsName;
+  private final SystemGroupBackend systemGroupBackend;
   private final PersonIdent serverUser;
 
   @Inject
-  Schema_125(Provider<Schema_124> prior,
+  Schema_125(
+      Provider<Schema_124> prior,
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       AllProjectsName allProjectsName,
+      SystemGroupBackend systemGroupBackend,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.allProjectsName = allProjectsName;
+    this.systemGroupBackend = systemGroupBackend;
     this.serverUser = serverUser;
   }
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
     try (Repository git = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
-            allUsersName, git)) {
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
       ProjectConfig config = ProjectConfig.read(md);
 
-      config.getAccessSection(RefNames.REFS_USERS + "*", true)
+      config
+          .getAccessSection(RefNames.REFS_USERS + "*", true)
           .remove(new Permission(Permission.READ));
-      GroupReference registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
-      AccessSection users = config.getAccessSection(
-          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
+      GroupReference registered = systemGroupBackend.getGroup(REGISTERED_USERS);
+      AccessSection users =
+          config.getAccessSection(
+              RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
       grant(config, users, Permission.READ, true, registered);
       grant(config, users, Permission.PUSH, true, registered);
       grant(config, users, Permission.SUBMIT, true, registered);
 
       for (LabelType lt : getLabelTypes(config)) {
-        if ("Code-Review".equals(lt.getName())
-            || "Verified".equals(lt.getName())) {
-          grant(config, users, lt, lt.getMin().getValue(),
-              lt.getMax().getValue(), registered);
+        if ("Code-Review".equals(lt.getName()) || "Verified".equals(lt.getName())) {
+          grant(config, users, lt, lt.getMin().getValue(), lt.getMax().getValue(), registered);
         }
       }
 
@@ -108,13 +109,11 @@
 
   private Collection<LabelType> getLabelTypes(ProjectConfig config)
       throws IOException, ConfigInvalidException {
-    Map<String, LabelType> labelTypes =
-        new HashMap<>(config.getLabelSections());
+    Map<String, LabelType> labelTypes = new HashMap<>(config.getLabelSections());
     Project.NameKey parent = config.getProject().getParent(allProjectsName);
     while (parent != null) {
       try (Repository git = repoManager.openRepository(parent);
-          MetaDataUpdate md =
-              new MetaDataUpdate(GitReferenceUpdated.DISABLED, parent, git)) {
+          MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, parent, git)) {
         ProjectConfig parentConfig = ProjectConfig.read(md);
         for (LabelType lt : parentConfig.getLabelSections().values()) {
           if (!labelTypes.containsKey(lt.getName())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java
index 50c518b..2053c1a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_126.java
@@ -33,44 +33,43 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-
 public class Schema_126 extends SchemaVersion {
-  private static final String COMMIT_MSG =
-      "Fix default permissions on user branches";
+  private static final String COMMIT_MSG = "Fix default permissions on user branches";
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
+  private final SystemGroupBackend systemGroupBackend;
   private final PersonIdent serverUser;
 
   @Inject
-  Schema_126(Provider<Schema_125> prior,
+  Schema_126(
+      Provider<Schema_125> prior,
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
+      SystemGroupBackend systemGroupBackend,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
+    this.systemGroupBackend = systemGroupBackend;
     this.serverUser = serverUser;
   }
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
     try (Repository git = repoManager.openRepository(allUsersName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
-            allUsersName, git)) {
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
       ProjectConfig config = ProjectConfig.read(md);
 
-      String refsUsersShardedId =
-          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}";
+      String refsUsersShardedId = RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}";
       config.remove(config.getAccessSection(refsUsersShardedId));
 
-      GroupReference registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
+      GroupReference registered = systemGroupBackend.getGroup(REGISTERED_USERS);
       AccessSection users = config.getAccessSection(refsUsersShardedId, true);
       grant(config, users, Permission.READ, false, true, registered);
       grant(config, users, Permission.PUSH, false, true, registered);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java
index cc2b0b2..d246b75 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_127.java
@@ -17,48 +17,53 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
+import org.eclipse.jgit.lib.Config;
 
 public class Schema_127 extends SchemaVersion {
   private static final int MAX_BATCH_SIZE = 1000;
 
   private final SitePaths sitePaths;
   private final Config cfg;
+  private final ThreadSettingsConfig threadSettingsConfig;
 
   @Inject
-  Schema_127(Provider<Schema_126> prior,
+  Schema_127(
+      Provider<Schema_126> prior,
       SitePaths sitePaths,
-      @GerritServerConfig Config cfg) {
+      @GerritServerConfig Config cfg,
+      ThreadSettingsConfig threadSettingsConfig) {
     super(prior);
     this.sitePaths = sitePaths;
     this.cfg = cfg;
+    this.threadSettingsConfig = threadSettingsConfig;
   }
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
     JdbcAccountPatchReviewStore jdbcAccountPatchReviewStore =
-        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(cfg, sitePaths);
+        JdbcAccountPatchReviewStore.createAccountPatchReviewStore(
+            cfg, sitePaths, threadSettingsConfig);
     jdbcAccountPatchReviewStore.dropTableIfExists();
     jdbcAccountPatchReviewStore.createTableIfNotExists();
     try (Connection con = jdbcAccountPatchReviewStore.getConnection();
         PreparedStatement stmt =
-            con.prepareStatement("INSERT INTO account_patch_reviews "
-                + "(account_id, change_id, patch_set_id, file_name) VALUES "
-                + "(?, ?, ?, ?)")) {
+            con.prepareStatement(
+                "INSERT INTO account_patch_reviews "
+                    + "(account_id, change_id, patch_set_id, file_name) VALUES "
+                    + "(?, ?, ?, ?)")) {
       int batchCount = 0;
 
       try (Statement s = newStatement(db);
-        ResultSet rs = s.executeQuery("SELECT * from account_patch_reviews")) {
+          ResultSet rs = s.executeQuery("SELECT * from account_patch_reviews")) {
         while (rs.next()) {
           stmt.setInt(1, rs.getInt("account_id"));
           stmt.setInt(2, rs.getInt("change_id"));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java
index a7f57b6..781f281 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_128.java
@@ -31,43 +31,43 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 
-import java.io.IOException;
-
 public class Schema_128 extends SchemaVersion {
-  private static final String COMMIT_MSG =
-      "Add addPatchSet permission to all projects";
+  private static final String COMMIT_MSG = "Add addPatchSet permission to all projects";
 
   private final GitRepositoryManager repoManager;
   private final AllProjectsName allProjectsName;
+  private final SystemGroupBackend systemGroupBackend;
   private final PersonIdent serverUser;
 
   @Inject
-  Schema_128(Provider<Schema_127> prior,
+  Schema_128(
+      Provider<Schema_127> prior,
       GitRepositoryManager repoManager,
       AllProjectsName allProjectsName,
+      SystemGroupBackend systemGroupBackend,
       @GerritPersonIdent PersonIdent serverUser) {
     super(prior);
     this.repoManager = repoManager;
     this.allProjectsName = allProjectsName;
+    this.systemGroupBackend = systemGroupBackend;
     this.serverUser = serverUser;
   }
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
     try (Repository git = repoManager.openRepository(allProjectsName);
-        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
-            allProjectsName, git)) {
+        MetaDataUpdate md =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git)) {
       ProjectConfig config = ProjectConfig.read(md);
 
-      GroupReference registered = SystemGroupBackend.getGroup(REGISTERED_USERS);
+      GroupReference registered = systemGroupBackend.getGroup(REGISTERED_USERS);
       AccessSection refsFor = config.getAccessSection("refs/for/*", true);
-      grant(config, refsFor, Permission.ADD_PATCH_SET,
-          false, false, registered);
+      grant(config, refsFor, Permission.ADD_PATCH_SET, false, false, registered);
 
       md.getCommitBuilder().setAuthor(serverUser);
       md.getCommitBuilder().setCommitter(serverUser);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java
index de02ec7..73ce3c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_129.java
@@ -19,7 +19,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.SQLException;
 import java.sql.Statement;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java
new file mode 100644
index 0000000..85adb65
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_130.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Collection;
+import java.util.SortedSet;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+public class Schema_130 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Remove force option from 'Push Annotated Tag' permission\n"
+          + "\n"
+          + "The force option on 'Push Annotated Tag' had no effect and is no longer\n"
+          + "supported.";
+
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+  private final Config cfg;
+
+  @Inject
+  Schema_130(
+      Provider<Schema_129> prior,
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverUser,
+      @GerritServerConfig Config cfg) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+    this.cfg = cfg;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    ui.message("\tMigrating " + repoList.size() + " repositories ...");
+    Collection<Project.NameKey> repoUpgraded =
+        (Collection<Project.NameKey>)
+            runParallelTasks(
+                createExecutor(ui),
+                repoList,
+                (repo) -> removePushTagForcePerms((Project.NameKey) repo),
+                ui);
+    ui.message("\tMigration completed:  " + repoUpgraded.size() + " repositories updated:");
+    ui.message("\t" + repoUpgraded.stream().map(n -> n.get()).collect(joining(" ")));
+  }
+
+  @Override
+  protected int getThreads() {
+    return cfg.getInt("cache", "projects", "loadThreads", super.getThreads());
+  }
+
+  private Project.NameKey removePushTagForcePerms(Project.NameKey project) throws OrmException {
+    try (Repository git = repoManager.openRepository(project);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, git)) {
+      ProjectConfigSchemaUpdate cfg = ProjectConfigSchemaUpdate.read(md);
+      cfg.removeForceFromPermission("pushTag");
+      cfg.save(serverUser, COMMIT_MSG);
+      return cfg.isUpdated() ? project : null;
+    } catch (Exception ex) {
+      throw new OrmException("Cannot migrate project " + project, ex);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java
new file mode 100644
index 0000000..0147669
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_131.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.SortedSet;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+public class Schema_131 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Rename 'Push Annotated/Signed Tag' permission to 'Create Annotated/Signed Tag'";
+
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+  private final Config cfg;
+
+  @Inject
+  Schema_131(
+      Provider<Schema_130> prior,
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverUser,
+      @GerritServerConfig Config cfg) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+    this.cfg = cfg;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    SortedSet<Project.NameKey> repoList = repoManager.list();
+    ui.message("\tMigrating " + repoList.size() + " repositories ...");
+    Collection<Project.NameKey> repoUpgraded =
+        (Collection<Project.NameKey>)
+            runParallelTasks(
+                createExecutor(ui),
+                repoList,
+                (repo) -> renamePushTagPermissions((Project.NameKey) repo),
+                ui);
+    ui.message("\tMigration completed:  " + repoUpgraded.size() + " repositories updated:");
+    ui.message("\t" + repoUpgraded.stream().map(n -> n.get()).collect(joining(" ")));
+  }
+
+  @Override
+  protected int getThreads() {
+    return cfg.getInt("cache", "projects", "loadThreads", super.getThreads());
+  }
+
+  private Project.NameKey renamePushTagPermissions(Project.NameKey project) throws OrmException {
+    try (Repository git = repoManager.openRepository(project);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, git)) {
+      ProjectConfig config = ProjectConfig.read(md);
+      if (config.hasLegacyPermissions()) {
+        md.getCommitBuilder().setAuthor(serverUser);
+        md.getCommitBuilder().setCommitter(serverUser);
+        md.setMessage(COMMIT_MSG);
+        config.commit(md);
+        return project;
+      }
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException("Cannot migrate project " + project, ex);
+    }
+    return null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_132.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_132.java
new file mode 100644
index 0000000..7c1cde8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_132.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_132 extends SchemaVersion {
+  @Inject
+  Schema_132(Provider<Schema_131> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java
new file mode 100644
index 0000000..31d330b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_133 extends SchemaVersion {
+  @Inject
+  Schema_133(Provider<Schema_132> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_134.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_134.java
new file mode 100644
index 0000000..fa01ff3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_134.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_134 extends SchemaVersion {
+  @Inject
+  Schema_134(Provider<Schema_133> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_135.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_135.java
new file mode 100644
index 0000000..f15e4c6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_135.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+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.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+public class Schema_135 extends SchemaVersion {
+  private static final String COMMIT_MSG =
+      "Allow admins and project owners to create refs/meta/config";
+
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsName allProjectsName;
+  private final SystemGroupBackend systemGroupBackend;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_135(
+      Provider<Schema_134> prior,
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjectsName,
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allProjectsName = allProjectsName;
+    this.systemGroupBackend = systemGroupBackend;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (Repository git = repoManager.openRepository(allProjectsName);
+        MetaDataUpdate md =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git)) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
+      Permission createRefsMetaConfigPermission = meta.getPermission(Permission.CREATE, true);
+
+      Set<GroupReference> groups =
+          Stream.concat(
+                  config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+                      .getPermission(GlobalCapability.ADMINISTRATE_SERVER, true).getRules().stream()
+                      .map(PermissionRule::getGroup),
+                  Stream.of(systemGroupBackend.getGroup(PROJECT_OWNERS)))
+              .filter(g -> createRefsMetaConfigPermission.getRule(g) == null)
+              .collect(toSet());
+
+      for (GroupReference group : groups) {
+        createRefsMetaConfigPermission.add(new PermissionRule(config.resolve(group)));
+      }
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+      config.commit(md);
+    } catch (ConfigInvalidException | IOException ex) {
+      throw new OrmException(ex);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_136.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_136.java
new file mode 100644
index 0000000..a4b1c82
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_136.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_136 extends SchemaVersion {
+  @Inject
+  Schema_136(Provider<Schema_135> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_137.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_137.java
new file mode 100644
index 0000000..1b4102f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_137.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/* change the type of SystemConfig#sitePath to CLOB */
+public class Schema_137 extends SchemaVersion {
+  @Inject
+  Schema_137(Provider<Schema_136> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_138.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_138.java
new file mode 100644
index 0000000..f824ee1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_138.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/* Add resolved field to PatchLineComment */
+public class Schema_138 extends SchemaVersion {
+  @Inject
+  Schema_138(Provider<Schema_137> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_139.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_139.java
new file mode 100644
index 0000000..7d71f6b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_139.java
@@ -0,0 +1,206 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_139 extends SchemaVersion {
+  private static final String MSG = "Migrate project watches to git";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_139(
+      Provider<Schema_138> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    ListMultimap<Account.Id, ProjectWatch> imports =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+        ResultSet rs =
+            stmt.executeQuery(
+                "SELECT "
+                    + "account_id, "
+                    + "project_name, "
+                    + "filter, "
+                    + "notify_abandoned_changes, "
+                    + "notify_all_comments, "
+                    + "notify_new_changes, "
+                    + "notify_new_patch_sets, "
+                    + "notify_submitted_changes "
+                    + "FROM account_project_watches")) {
+      while (rs.next()) {
+        Account.Id accountId = new Account.Id(rs.getInt(1));
+        ProjectWatch.Builder b =
+            ProjectWatch.builder()
+                .project(new Project.NameKey(rs.getString(2)))
+                .filter(rs.getString(3))
+                .notifyAbandonedChanges(toBoolean(rs.getString(4)))
+                .notifyAllComments(toBoolean(rs.getString(5)))
+                .notifyNewChanges(toBoolean(rs.getString(6)))
+                .notifyNewPatchSets(toBoolean(rs.getString(7)))
+                .notifySubmittedChanges(toBoolean(rs.getString(8)));
+        imports.put(accountId, b.build());
+      }
+    }
+
+    if (imports.isEmpty()) {
+      return;
+    }
+
+    try (Repository git = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(git)) {
+      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+      bru.setRefLogIdent(serverUser);
+      bru.setRefLogMessage(MSG, false);
+
+      for (Map.Entry<Account.Id, Collection<ProjectWatch>> e : imports.asMap().entrySet()) {
+        Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
+        for (ProjectWatch projectWatch : e.getValue()) {
+          ProjectWatchKey key =
+              ProjectWatchKey.create(projectWatch.project(), projectWatch.filter());
+          if (projectWatches.containsKey(key)) {
+            throw new OrmDuplicateKeyException(
+                "Duplicate key for watched project: " + key.toString());
+          }
+          Set<NotifyType> notifyValues = EnumSet.noneOf(NotifyType.class);
+          if (projectWatch.notifyAbandonedChanges()) {
+            notifyValues.add(NotifyType.ABANDONED_CHANGES);
+          }
+          if (projectWatch.notifyAllComments()) {
+            notifyValues.add(NotifyType.ALL_COMMENTS);
+          }
+          if (projectWatch.notifyNewChanges()) {
+            notifyValues.add(NotifyType.NEW_CHANGES);
+          }
+          if (projectWatch.notifyNewPatchSets()) {
+            notifyValues.add(NotifyType.NEW_PATCHSETS);
+          }
+          if (projectWatch.notifySubmittedChanges()) {
+            notifyValues.add(NotifyType.SUBMITTED_CHANGES);
+          }
+          projectWatches.put(key, notifyValues);
+        }
+
+        try (MetaDataUpdate md =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git, bru)) {
+          md.getCommitBuilder().setAuthor(serverUser);
+          md.getCommitBuilder().setCommitter(serverUser);
+          md.setMessage(MSG);
+
+          WatchConfig watchConfig = new WatchConfig(e.getKey());
+          watchConfig.load(md);
+          watchConfig.setProjectWatches(projectWatches);
+          watchConfig.commit(md);
+        }
+      }
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    } catch (IOException | ConfigInvalidException ex) {
+      throw new OrmException(ex);
+    }
+  }
+
+  @AutoValue
+  abstract static class ProjectWatch {
+    abstract Project.NameKey project();
+
+    abstract @Nullable String filter();
+
+    abstract boolean notifyAbandonedChanges();
+
+    abstract boolean notifyAllComments();
+
+    abstract boolean notifyNewChanges();
+
+    abstract boolean notifyNewPatchSets();
+
+    abstract boolean notifySubmittedChanges();
+
+    static Builder builder() {
+      return new AutoValue_Schema_139_ProjectWatch.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder project(Project.NameKey project);
+
+      abstract Builder filter(@Nullable String filter);
+
+      abstract Builder notifyAbandonedChanges(boolean notifyAbandonedChanges);
+
+      abstract Builder notifyAllComments(boolean notifyAllComments);
+
+      abstract Builder notifyNewChanges(boolean notifyNewChanges);
+
+      abstract Builder notifyNewPatchSets(boolean notifyNewPatchSets);
+
+      abstract Builder notifySubmittedChanges(boolean notifySubmittedChanges);
+
+      abstract ProjectWatch build();
+    }
+  }
+
+  private static boolean toBoolean(String v) {
+    Preconditions.checkState(!Strings.isNullOrEmpty(v));
+    return v.equals("Y");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_140.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_140.java
new file mode 100644
index 0000000..bdc5f55
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_140.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Remove ChangeMessage sequence. */
+public class Schema_140 extends SchemaVersion {
+  @Inject
+  Schema_140(Provider<Schema_139> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_141.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_141.java
new file mode 100644
index 0000000..c081ea9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_141.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Add status field to account. */
+public class Schema_141 extends SchemaVersion {
+  @Inject
+  Schema_141(Provider<Schema_140> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
new file mode 100644
index 0000000..df808df
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.HashedPassword;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+import java.util.List;
+
+public class Schema_142 extends SchemaVersion {
+  @Inject
+  Schema_142(Provider<Schema_141> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    List<AccountExternalId> newIds = db.accountExternalIds().all().toList();
+    for (AccountExternalId id : newIds) {
+      if (!id.isScheme(AccountExternalId.SCHEME_USERNAME)) {
+        continue;
+      }
+
+      String password = id.getPassword();
+      if (password != null) {
+        HashedPassword hashed = HashedPassword.fromPassword(password);
+        id.setPassword(hashed.encode());
+      }
+    }
+
+    db.accountExternalIds().upsert(newIds);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_83.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_83.java
index f703a35..decbfb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_83.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_83.java
@@ -22,11 +22,12 @@
 
   @Inject
   Schema_83() {
-    super(new Provider<SchemaVersion>() {
-      @Override
-      public SchemaVersion get() {
-        throw new ProvisionException("Upgrade first to 2.8 or 2.9");
-      }
-    });
+    super(
+        new Provider<SchemaVersion>() {
+          @Override
+          public SchemaVersion get() {
+            throw new ProvisionException("Upgrade first to 2.8 or 2.9");
+          }
+        });
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java
index 8f4028d..8ab949e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_87.java
@@ -20,7 +20,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
@@ -35,24 +34,20 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui)
-      throws OrmException, SQLException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     for (AccountGroup.Id id : scanSystemGroups(db)) {
       AccountGroup group = db.accountGroups().get(id);
-      if (group != null
-          && SystemGroupBackend.isSystemGroup(group.getGroupUUID())) {
+      if (group != null && SystemGroupBackend.isSystemGroup(group.getGroupUUID())) {
         db.accountGroups().delete(Collections.singleton(group));
-        db.accountGroupNames().deleteKeys(
-            Collections.singleton(group.getNameKey()));
+        db.accountGroupNames().deleteKeys(Collections.singleton(group.getNameKey()));
       }
     }
   }
 
-  private Set<AccountGroup.Id> scanSystemGroups(ReviewDb db)
-      throws SQLException {
+  private Set<AccountGroup.Id> scanSystemGroups(ReviewDb db) throws SQLException {
     try (Statement stmt = newStatement(db);
-        ResultSet rs = stmt.executeQuery(
-          "SELECT group_id FROM account_groups WHERE group_type = 'SYSTEM'")) {
+        ResultSet rs =
+            stmt.executeQuery("SELECT group_id FROM account_groups WHERE group_type = 'SYSTEM'")) {
       Set<AccountGroup.Id> ids = new HashSet<>();
       while (rs.next()) {
         ids.add(new AccountGroup.Id(rs.getInt(1)));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_89.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_89.java
index a818e0d..de84993 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_89.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_89.java
@@ -21,7 +21,6 @@
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.SQLException;
 
 public class Schema_89 extends SchemaVersion {
@@ -31,14 +30,11 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException,
-      SQLException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     SqlDialect dialect = ((JdbcSchema) db).getDialect();
     try (StatementExecutor e = newExecutor(db)) {
-      dialect.dropIndex(e, "patch_set_approvals",
-          "patch_set_approvals_openByUser");
-      dialect.dropIndex(e, "patch_set_approvals",
-          "patch_set_approvals_closedByU");
+      dialect.dropIndex(e, "patch_set_approvals", "patch_set_approvals_openByUser");
+      dialect.dropIndex(e, "patch_set_approvals", "patch_set_approvals_closedByU");
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_90.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_90.java
index 8f1fc5d..d8f02ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_90.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_90.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.SQLException;
 import java.sql.Statement;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_94.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_94.java
index 02f78ca..d4a189f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_94.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_94.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.SQLException;
 import java.sql.Statement;
 
@@ -30,8 +29,7 @@
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException {
     try (Statement stmt = newStatement(db)) {
-      stmt.execute("CREATE INDEX patch_sets_byRevision"
-          + " ON patch_sets (revision)");
+      stmt.execute("CREATE INDEX patch_sets_byRevision ON patch_sets (revision)");
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_95.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_95.java
index 1c839f7..0ce0294 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_95.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_95.java
@@ -18,11 +18,9 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.sql.SQLException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class Schema_95 extends SchemaVersion {
   private final AllUsersCreator allUsersCreator;
@@ -34,8 +32,7 @@
   }
 
   @Override
-  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException,
-      SQLException {
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
     try {
       allUsersCreator.create();
     } catch (IOException | ConfigInvalidException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_98.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_98.java
index 752dcd8..eec3c9f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_98.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_98.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.sql.SQLException;
 import java.sql.Statement;
 
@@ -29,12 +28,12 @@
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws SQLException {
-    ui.message("Migrate user preference showUserInReview to "
-        + "reviewCategoryStrategy");
+    ui.message("Migrate user preference showUserInReview to reviewCategoryStrategy");
     try (Statement stmt = newStatement(db)) {
-      stmt.executeUpdate("UPDATE accounts SET "
-          + "REVIEW_CATEGORY_STRATEGY='NAME' "
-          + "WHERE (SHOW_USER_IN_REVIEW='Y')");
+      stmt.executeUpdate(
+          "UPDATE accounts SET "
+              + "REVIEW_CATEGORY_STRATEGY='NAME' "
+              + "WHERE (SHOW_USER_IN_REVIEW='Y')");
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java
index 684a72e..adee5fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java
@@ -21,7 +21,6 @@
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.schema.sql.SqlDialect;
 import com.google.gwtorm.server.OrmException;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
@@ -37,11 +36,11 @@
   private final String name;
   private final List<String> commands;
 
-  static final ScriptRunner NOOP = new ScriptRunner(null, null) {
-    @Override
-    void run(final ReviewDb db) {
-    }
-  };
+  static final ScriptRunner NOOP =
+      new ScriptRunner(null, null) {
+        @Override
+        void run(final ReviewDb db) {}
+      };
 
   ScriptRunner(final String scriptName, final InputStream script) {
     this.name = scriptName;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java
index eff5575..b43aaa6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java
@@ -16,7 +16,6 @@
 
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.StatementExecutor;
-
 import java.util.List;
 
 public interface UpdateUI {
@@ -26,6 +25,5 @@
 
   boolean isBatch();
 
-  void pruneSchema(StatementExecutor e, List<String> pruneList)
-      throws OrmException;
+  void pruneSchema(StatementExecutor e, List<String> pruneList) throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
index c8190a6..b729b09 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
@@ -17,20 +17,19 @@
 import com.google.gerrit.common.FileUtil;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.LockFile;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
-
 import java.io.File;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.LockFile;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
 
 @Singleton
 public class DefaultSecureStore extends SecureStore {
@@ -56,8 +55,8 @@
   }
 
   @Override
-  public synchronized String[] getListForPlugin(String pluginName, String section,
-    String subsection, String name) {
+  public synchronized String[] getListForPlugin(
+      String pluginName, String section, String subsection, String name) {
     FileBasedConfig cfg = null;
     if (pluginSec.containsKey(pluginName)) {
       cfg = pluginSec.get(pluginName);
@@ -78,8 +77,7 @@
   }
 
   @Override
-  public void setList(String section, String subsection, String name,
-      List<String> values) {
+  public void setList(String section, String subsection, String name, List<String> values) {
     if (values != null) {
       sec.setStringList(section, subsection, name, values);
     } else {
@@ -110,6 +108,20 @@
     return result;
   }
 
+  @Override
+  public boolean isOutdated() {
+    return sec.isOutdated();
+  }
+
+  @Override
+  public void reload() {
+    try {
+      sec.load();
+    } catch (IOException | ConfigInvalidException e) {
+      throw new ProvisionException("Couldn't reload secure.config", e);
+    }
+  }
+
   private void save() {
     try {
       saveSecure(sec);
@@ -127,8 +139,7 @@
         throw new IOException("Cannot lock " + path);
       }
       try {
-        FileUtil.chmod(0600, new File(path.getParentFile(), path.getName()
-            + ".lock"));
+        FileUtil.chmod(0600, new File(path.getParentFile(), path.getName() + ".lock"));
         lf.write(out);
         if (!lf.commit()) {
           throw new IOException("Cannot commit write to " + path);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java
index 122e26b..b5aebee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStore.java
@@ -15,29 +15,25 @@
 package com.google.gerrit.server.securestore;
 
 import com.google.common.collect.Lists;
-
 import java.util.List;
 
 /**
  * Abstract class for providing new SecureStore implementation for Gerrit.
  *
- * SecureStore is responsible for storing sensitive data like passwords in a
- * secure manner.
+ * <p>SecureStore is responsible for storing sensitive data like passwords in a secure manner.
  *
- * It is implementator's responsibility to encrypt and store values.
+ * <p>It is implementator's responsibility to encrypt and store values.
  *
- * To deploy new SecureStore one needs to provide a jar file with explicitly one
- * class that extends {@code SecureStore} and put it in Gerrit server. Then run:
+ * <p>To deploy new SecureStore one needs to provide a jar file with explicitly one class that
+ * extends {@code SecureStore} and put it in Gerrit server. Then run:
  *
- * `java -jar gerrit.war SwitchSecureStore -d $gerrit_site --new-secure-store-lib
- *  $path_to_new_secure_store.jar`
+ * <p>`java -jar gerrit.war SwitchSecureStore -d $gerrit_site --new-secure-store-lib
+ * $path_to_new_secure_store.jar`
  *
- * on stopped Gerrit instance.
+ * <p>on stopped Gerrit instance.
  */
 public abstract class SecureStore {
-  /**
-   * Describes {@link SecureStore} entry
-   */
+  /** Describes {@link SecureStore} entry */
   public static class EntryKey {
     public final String name;
     public final String section;
@@ -58,8 +54,8 @@
   }
 
   /**
-   * Extract decrypted value of stored property from SecureStore or {@code null}
-   * when property was not found.
+   * Extract decrypted value of stored property from SecureStore or {@code null} when property was
+   * not found.
    *
    * @param section
    * @param subsection
@@ -75,8 +71,8 @@
   }
 
   /**
-   * Extract decrypted value of stored plugin config property from SecureStore
-   * or {@code null} when property was not found.
+   * Extract decrypted value of stored plugin config property from SecureStore or {@code null} when
+   * property was not found.
    *
    * @param pluginName
    * @param section
@@ -84,8 +80,8 @@
    * @param name
    * @return decrypted String value or {@code null} if not found
    */
-  public final String getForPlugin(String pluginName, String section,
-      String subsection, String name) {
+  public final String getForPlugin(
+      String pluginName, String section, String subsection, String name) {
     String[] values = getListForPlugin(pluginName, section, subsection, name);
     if (values != null && values.length > 0) {
       return values[0];
@@ -94,8 +90,8 @@
   }
 
   /**
-   * Extract list of plugin config values from SecureStore and decrypt every
-   * value in that list, or {@code null} when property was not found.
+   * Extract list of plugin config values from SecureStore and decrypt every value in that list, or
+   * {@code null} when property was not found.
    *
    * @param pluginName
    * @param section
@@ -103,12 +99,12 @@
    * @param name
    * @return decrypted list of string values or {@code null}
    */
-  public abstract String[] getListForPlugin(String pluginName, String section,
-      String subsection, String name);
+  public abstract String[] getListForPlugin(
+      String pluginName, String section, String subsection, String name);
 
   /**
-   * Extract list of values from SecureStore and decrypt every value in that
-   * list or {@code null} when property was not found.
+   * Extract list of values from SecureStore and decrypt every value in that list or {@code null}
+   * when property was not found.
    *
    * @param section
    * @param subsection
@@ -120,7 +116,7 @@
   /**
    * Store single value in SecureStore.
    *
-   * This method is responsible for encrypting value and storing it.
+   * <p>This method is responsible for encrypting value and storing it.
    *
    * @param section
    * @param subsection
@@ -134,7 +130,7 @@
   /**
    * Store list of values in SecureStore.
    *
-   * This method is responsible for encrypting all values in the list and storing them.
+   * <p>This method is responsible for encrypting all values in the list and storing them.
    *
    * @param section
    * @param subsection
@@ -144,8 +140,7 @@
   public abstract void setList(String section, String subsection, String name, List<String> values);
 
   /**
-   * Remove value for given {@code section}, {@code subsection} and {@code name}
-   * from SecureStore.
+   * Remove value for given {@code section}, {@code subsection} and {@code name} from SecureStore.
    *
    * @param section
    * @param subsection
@@ -153,8 +148,12 @@
    */
   public abstract void unset(String section, String subsection, String name);
 
-  /**
-   * @return list of stored entries.
-   */
+  /** @return list of stored entries. */
   public abstract Iterable<EntryKey> list();
+
+  /** @return <code>true</code> if currently loaded values are outdated */
+  public abstract boolean isOutdated();
+
+  /** Reload the values */
+  public abstract void reload();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreClassName.java b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreClassName.java
index 07635bd..0247fc1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreClassName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreClassName.java
@@ -3,10 +3,8 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface SecureStoreClassName {
-}
+public @interface SecureStoreClassName {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
index 99127d8..88c2072 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
@@ -22,16 +22,13 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
+import java.nio.file.Path;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.nio.file.Path;
-
 @Singleton
 public class SecureStoreProvider implements Provider<SecureStore> {
-  private static final Logger log = LoggerFactory
-      .getLogger(SecureStoreProvider.class);
+  private static final Logger log = LoggerFactory.getLogger(SecureStoreProvider.class);
 
   private final Path libdir;
   private final Injector injector;
@@ -39,9 +36,7 @@
 
   @Inject
   protected SecureStoreProvider(
-      Injector injector,
-      SitePaths sitePaths,
-      @Nullable @SecureStoreClassName String className) {
+      Injector injector, SitePaths sitePaths, @Nullable @SecureStoreClassName String className) {
     this.injector = injector;
     this.libdir = sitePaths.lib_dir;
     this.className = className;
@@ -62,8 +57,7 @@
     try {
       return (Class<? extends SecureStore>) Class.forName(className);
     } catch (ClassNotFoundException e) {
-      String msg =
-          String.format("Cannot load secure store class: %s", className);
+      String msg = String.format("Cannot load secure store class: %s", className);
       log.error(msg, e);
       throw new RuntimeException(msg, e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshInfo.java
index 6ceec2d..91a949b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshInfo.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.ssh;
 
 import com.jcraft.jsch.HostKey;
-
 import java.util.Collections;
 import java.util.List;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
index f3250d9..798ce38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshKeyCache.java
@@ -34,12 +34,10 @@
   }
 
   @Override
-  public void evict(String username) {
-  }
+  public void evict(String username) {}
 
   @Override
-  public AccountSshKey create(AccountSshKey.Id id, String encoded)
-      throws InvalidSshKeyException {
+  public AccountSshKey create(AccountSshKey.Id id, String encoded) throws InvalidSshKeyException {
     throw new InvalidSshKeyException();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshModule.java
index 8781d46..abbbdae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/NoSshModule.java
@@ -16,9 +16,7 @@
 
 import com.google.inject.AbstractModule;
 
-/**
- * Disables the SSH support by stubbing out relevant objects.
- */
+/** Disables the SSH support by stubbing out relevant objects. */
 public class NoSshModule extends AbstractModule {
   @Override
   protected void configure() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
index f768c5e..70a6fce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
@@ -20,27 +20,23 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class SshAddressesModule extends AbstractModule {
-  private static final Logger log =
-      LoggerFactory.getLogger(SshAddressesModule.class);
+  private static final Logger log = LoggerFactory.getLogger(SshAddressesModule.class);
 
   public static final int DEFAULT_PORT = 29418;
   public static final int IANA_SSH_PORT = 22;
 
   @Override
-  protected void configure() {
-  }
+  protected void configure() {}
 
   @Provides
   @Singleton
@@ -76,8 +72,8 @@
   @Provides
   @Singleton
   @SshAdvertisedAddresses
-  List<String> getAdvertisedAddresses(@GerritServerConfig Config cfg,
-      @SshListenAddresses List<SocketAddress> listen) {
+  List<String> getAdvertisedAddresses(
+      @GerritServerConfig Config cfg, @SshListenAddresses List<SocketAddress> listen) {
     String[] want = cfg.getStringList("sshd", null, "advertisedaddress");
     if (want.length > 0) {
       return Arrays.asList(want);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAdvertisedAddresses.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAdvertisedAddresses.java
index 4a6eb29..c047fcc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAdvertisedAddresses.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAdvertisedAddresses.java
@@ -17,14 +17,11 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
- * Marker on the list of {@link java.net.SocketAddress}es configured to be
- * advertised by the server.
+ * Marker on the list of {@link java.net.SocketAddress}es configured to be advertised by the server.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface SshAdvertisedAddresses {
-}
+public @interface SshAdvertisedAddresses {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshInfo.java
index 0081cb4..430846d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshInfo.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.ssh;
 
 import com.jcraft.jsch.HostKey;
-
 import java.util.List;
 
 public interface SshInfo {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java
index fd0c69c..a371490 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshKeyCreator.java
@@ -18,6 +18,5 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 
 public interface SshKeyCreator {
-  AccountSshKey create(AccountSshKey.Id id, String encoded)
-      throws InvalidSshKeyException;
+  AccountSshKey create(AccountSshKey.Id id, String encoded) throws InvalidSshKeyException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshListenAddresses.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshListenAddresses.java
index a4e238d..68893f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshListenAddresses.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshListenAddresses.java
@@ -17,14 +17,12 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
- * Marker on the list of {@link java.net.SocketAddress}es on which the SSH
- * daemon is configured to listen.
+ * Marker on the list of {@link java.net.SocketAddress}es on which the SSH daemon is configured to
+ * listen.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface SshListenAddresses {
-}
+public @interface SshListenAddresses {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java b/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java
index 9e48aad..b616791 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/tools/ToolsCatalog.java
@@ -21,12 +21,6 @@
 import com.google.gerrit.common.Version;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedReader;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -38,12 +32,16 @@
 import java.util.List;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Listing of all client side tools stored on this server.
- * <p>
- * Clients may download these tools through our file server, as they are
- * packaged with our own software releases.
+ *
+ * <p>Clients may download these tools through our file server, as they are packaged with our own
+ * software releases.
  */
 @Singleton
 public class ToolsCatalog {
@@ -79,8 +77,7 @@
   private static SortedMap<String, Entry> readToc() throws IOException {
     SortedMap<String, Entry> toc = new TreeMap<>();
     final BufferedReader br =
-        new BufferedReader(new InputStreamReader(new ByteArrayInputStream(
-            read("TOC")), UTF_8));
+        new BufferedReader(new InputStreamReader(new ByteArrayInputStream(read("TOC")), UTF_8));
     String line;
     while ((line = br.readLine()) != null) {
       if (line.length() > 0 && !line.startsWith("#")) {
@@ -144,7 +141,8 @@
   /** A file served out of the tools root directory. */
   public static class Entry {
     public enum Type {
-      DIR, FILE
+      DIR,
+      FILE
     }
 
     private final Type type;
@@ -209,8 +207,7 @@
         final String version = Version.getVersion();
         final int lf = RawParseUtils.nextLF(data, 0);
         if (version != null && lf < data.length) {
-          byte[] versionHeader =
-              Constants.encode("# From Gerrit Code Review " + version + "\n");
+          byte[] versionHeader = Constants.encode("# From Gerrit Code Review " + version + "\n");
 
           ByteArrayOutputStream buf = new ByteArrayOutputStream();
           buf.write(data, 0, lf);
@@ -224,7 +221,8 @@
     }
 
     private boolean isScript(byte[] data) {
-      return data != null && data.length > 3 //
+      return data != null
+          && data.length > 3 //
           && data[0] == '#' //
           && data[1] == '!' //
           && data[2] == '/';
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
new file mode 100644
index 0000000..e7a7013
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -0,0 +1,317 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.RestApiException;
+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.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Helper for a set of updates that should be applied for a site.
+ *
+ * <p>An update operation can be divided into three phases:
+ *
+ * <ol>
+ *   <li>Git reference updates
+ *   <li>Database updates
+ *   <li>Post-update steps
+ *   <li>
+ * </ol>
+ *
+ * A single conceptual operation, such as a REST API call or a merge operation, may make multiple
+ * changes at each step, which all need to be serialized relative to each other. Moreover, for
+ * consistency, <em>all</em> git ref updates must be performed before <em>any</em> database updates,
+ * since database updates might refer to newly-created patch set refs. And all post-update steps,
+ * such as hooks, should run only after all storage mutations have completed.
+ *
+ * <p>Depending on the backend used, each step might support batching, for example in a {@code
+ * BatchRefUpdate} or one or more database transactions. All operations in one phase must complete
+ * successfully before proceeding to the next phase.
+ */
+public abstract class BatchUpdate implements AutoCloseable {
+  private static final Logger log = LoggerFactory.getLogger(BatchUpdate.class);
+
+  public static Module module() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        factory(ReviewDbBatchUpdate.AssistedFactory.class);
+      }
+    };
+  }
+
+  @Singleton
+  public static class Factory {
+    private final ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory;
+
+    @Inject
+    Factory(ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory) {
+      this.reviewDbBatchUpdateFactory = reviewDbBatchUpdateFactory;
+    }
+
+    public BatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when) {
+      return reviewDbBatchUpdateFactory.create(db, project, user, when);
+    }
+
+    public void execute(
+        Collection<BatchUpdate> updates,
+        BatchUpdateListener listener,
+        @Nullable RequestId requestId,
+        boolean dryRun)
+        throws UpdateException, RestApiException {
+      // It's safe to downcast all members of the input collection in this case, because the only
+      // way a caller could have gotten any BatchUpdates in the first place is to call the create
+      // method above, which always returns instances of the type we expect. Just to be safe,
+      // copy them into an ImmutableList so there is no chance the callee can pollute the input
+      // collection.
+      @SuppressWarnings({"rawtypes", "unchecked"})
+      ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
+          (ImmutableList) ImmutableList.copyOf(updates);
+      ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+    }
+  }
+
+  protected static Order getOrder(Collection<? extends BatchUpdate> updates) {
+    Order o = null;
+    for (BatchUpdate u : updates) {
+      if (o == null) {
+        o = u.order;
+      } else if (u.order != o) {
+        throw new IllegalArgumentException("cannot mix execution orders");
+      }
+    }
+    return o;
+  }
+
+  protected static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
+    checkArgument(!updates.isEmpty());
+    Boolean p = null;
+    for (BatchUpdate u : updates) {
+      if (p == null) {
+        p = u.updateChangesInParallel;
+      } else if (u.updateChangesInParallel != p) {
+        throw new IllegalArgumentException("cannot mix parallel and non-parallel operations");
+      }
+    }
+    // Properly implementing this would involve hoisting the parallel loop up
+    // even further. As of this writing, the only user is ReceiveCommits,
+    // which only executes a single BatchUpdate at a time. So bail for now.
+    checkArgument(
+        !p || updates.size() <= 1,
+        "cannot execute ChangeOps in parallel with more than 1 BatchUpdate");
+    return p;
+  }
+
+  protected GitRepositoryManager repoManager;
+
+  protected final Project.NameKey project;
+  protected final CurrentUser user;
+  protected final Timestamp when;
+  protected final TimeZone tz;
+
+  protected final ListMultimap<Change.Id, BatchUpdateOp> ops =
+      MultimapBuilder.linkedHashKeys().arrayListValues().build();
+  protected final Map<Change.Id, Change> newChanges = new HashMap<>();
+  protected final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
+
+  protected Repository repo;
+  protected ObjectInserter inserter;
+  protected RevWalk revWalk;
+  protected ChainedReceiveCommands commands;
+  protected BatchRefUpdate batchRefUpdate;
+  protected Order order;
+  protected OnSubmitValidators onSubmitValidators;
+  protected RequestId requestId;
+  protected String refLogMessage;
+
+  private boolean updateChangesInParallel;
+  private boolean closeRepo;
+
+  protected BatchUpdate(
+      GitRepositoryManager repoManager,
+      PersonIdent serverIdent,
+      Project.NameKey project,
+      CurrentUser user,
+      Timestamp when) {
+    this.repoManager = repoManager;
+    this.project = project;
+    this.user = user;
+    this.when = when;
+    tz = serverIdent.getTimeZone();
+    order = Order.REPO_BEFORE_DB;
+  }
+
+  @Override
+  public void close() {
+    if (closeRepo) {
+      revWalk.getObjectReader().close();
+      revWalk.close();
+      inserter.close();
+      repo.close();
+    }
+  }
+
+  public abstract void execute(BatchUpdateListener listener)
+      throws UpdateException, RestApiException;
+
+  public abstract void execute() throws UpdateException, RestApiException;
+
+  protected abstract Context newContext();
+
+  public BatchUpdate setRequestId(RequestId requestId) {
+    this.requestId = requestId;
+    return this;
+  }
+
+  public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
+    checkState(this.repo == null, "repo already set");
+    closeRepo = false;
+    this.repo = checkNotNull(repo, "repo");
+    this.revWalk = checkNotNull(revWalk, "revWalk");
+    this.inserter = checkNotNull(inserter, "inserter");
+    commands = new ChainedReceiveCommands(repo);
+    return this;
+  }
+
+  public BatchUpdate setRefLogMessage(String refLogMessage) {
+    this.refLogMessage = refLogMessage;
+    return this;
+  }
+
+  public BatchUpdate setOrder(Order order) {
+    this.order = order;
+    return this;
+  }
+
+  /**
+   * Add a validation step for intended ref operations, which will be performed at the end of {@link
+   * RepoOnlyOp#updateRepo(RepoContext)} step.
+   */
+  public BatchUpdate setOnSubmitValidators(OnSubmitValidators onSubmitValidators) {
+    this.onSubmitValidators = onSubmitValidators;
+    return this;
+  }
+
+  /** Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change. */
+  public BatchUpdate updateChangesInParallel() {
+    this.updateChangesInParallel = true;
+    return this;
+  }
+
+  protected void initRepository() throws IOException {
+    if (repo == null) {
+      this.repo = repoManager.openRepository(project);
+      closeRepo = true;
+      inserter = repo.newObjectInserter();
+      revWalk = new RevWalk(inserter.newReader());
+      commands = new ChainedReceiveCommands(repo);
+    }
+  }
+
+  protected CurrentUser getUser() {
+    return user;
+  }
+
+  protected Repository getRepository() throws IOException {
+    initRepository();
+    return repo;
+  }
+
+  protected RevWalk getRevWalk() throws IOException {
+    initRepository();
+    return revWalk;
+  }
+
+  protected ObjectInserter getObjectInserter() throws IOException {
+    initRepository();
+    return inserter;
+  }
+
+  public Collection<ReceiveCommand> getRefUpdates() {
+    return commands.getCommands().values();
+  }
+
+  public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
+    checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
+    checkNotNull(op);
+    ops.put(id, op);
+    return this;
+  }
+
+  public BatchUpdate addRepoOnlyOp(RepoOnlyOp op) {
+    checkArgument(!(op instanceof BatchUpdateOp), "use addOp()");
+    repoOnlyOps.add(op);
+    return this;
+  }
+
+  public BatchUpdate insertChange(InsertChangeOp op) {
+    Context ctx = newContext();
+    Change c = op.createChange(ctx);
+    checkArgument(
+        !newChanges.containsKey(c.getId()), "only one op allowed to create change %s", c.getId());
+    newChanges.put(c.getId(), c);
+    ops.get(c.getId()).add(0, op);
+    return this;
+  }
+
+  protected void logDebug(String msg, Throwable t) {
+    if (requestId != null && log.isDebugEnabled()) {
+      log.debug(requestId + msg, t);
+    }
+  }
+
+  protected void logDebug(String msg, Object... args) {
+    // Only log if there is a requestId assigned, since those are the
+    // expensive/complicated requests like MergeOp. Doing it every time would be
+    // noisy.
+    if (requestId != null && log.isDebugEnabled()) {
+      log.debug(requestId + msg, args);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
new file mode 100644
index 0000000..765bba1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+/**
+ * Interface for listening during batch update execution.
+ *
+ * <p>When used during execution of multiple batch updates, the {@code after*} methods are called
+ * after that phase has been completed for <em>all</em> updates.
+ */
+public interface BatchUpdateListener {
+  public static final BatchUpdateListener NONE = new BatchUpdateListener() {};
+
+  /** Called after updating all repositories and flushing objects but before updating any refs. */
+  default void afterUpdateRepos() throws Exception {}
+
+  /** Called after updating all refs. */
+  default void afterUpdateRefs() throws Exception {}
+
+  /** Called after updating all changes. */
+  default void afterUpdateChanges() throws Exception {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
new file mode 100644
index 0000000..39e25dd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+/**
+ * Interface for {@link BatchUpdate} operations that touch a change.
+ *
+ * <p>Each operation has {@link #updateChange(ChangeContext)} called once the change is read in a
+ * transaction. Ops are associated with updates via {@link
+ * BatchUpdate#addOp(com.google.gerrit.reviewdb.client.Change.Id, BatchUpdateOp)}.
+ *
+ * <p>Usually, a single {@code BatchUpdateOp} instance is only associated with a single change, i.e.
+ * {@code addOp} is only called once with that instance. This allows an instance to communicate
+ * between phases by storing data in private fields.
+ */
+public interface BatchUpdateOp extends RepoOnlyOp {
+  /**
+   * Override this method to modify a change.
+   *
+   * @param ctx context
+   * @return whether anything was changed that might require a write to the metadata storage.
+   */
+  default boolean updateChange(ChangeContext ctx) throws Exception {
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java
new file mode 100644
index 0000000..21e1f92
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateReviewDb.java
@@ -0,0 +1,99 @@
+// 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.update;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ChangeAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.server.AtomicUpdate;
+
+public class BatchUpdateReviewDb extends ReviewDbWrapper {
+  private final ChangeAccess changesWrapper;
+
+  BatchUpdateReviewDb(ReviewDb delegate) {
+    super(delegate);
+    changesWrapper = new BatchUpdateChanges(delegate.changes());
+  }
+
+  public ReviewDb unsafeGetDelegate() {
+    return delegate;
+  }
+
+  @Override
+  public ChangeAccess changes() {
+    return changesWrapper;
+  }
+
+  @Override
+  public void commit() {
+    throw new UnsupportedOperationException(
+        "do not call commit; BatchUpdate always manages transactions");
+  }
+
+  @Override
+  public void rollback() {
+    throw new UnsupportedOperationException(
+        "do not call rollback; BatchUpdate always manages transactions");
+  }
+
+  private static class BatchUpdateChanges extends ChangeAccessWrapper {
+    private BatchUpdateChanges(ChangeAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public void insert(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call insert; change is automatically inserted");
+    }
+
+    @Override
+    public void upsert(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call upsert; existing changes are updated automatically,"
+              + " or use InsertChangeOp for insertion");
+    }
+
+    @Override
+    public void update(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call update; change is updated automatically");
+    }
+
+    @Override
+    public void beginTransaction(Change.Id key) {
+      throw new UnsupportedOperationException("updateChange is always called within a transaction");
+    }
+
+    @Override
+    public void deleteKeys(Iterable<Change.Id> keys) {
+      throw new UnsupportedOperationException(
+          "do not call deleteKeys; use ChangeContext#deleteChange()");
+    }
+
+    @Override
+    public void delete(Iterable<Change> instances) {
+      throw new UnsupportedOperationException(
+          "do not call delete; use ChangeContext#deleteChange()");
+    }
+
+    @Override
+    public Change atomicUpdate(Change.Id key, AtomicUpdate<Change> update) {
+      throw new UnsupportedOperationException(
+          "do not call atomicUpdate; updateChange is always called within a transaction");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChainedReceiveCommands.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
new file mode 100644
index 0000000..f5f8b1d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.server.git.RefCache;
+import com.google.gerrit.server.git.RepoRefCache;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Collection of {@link ReceiveCommand}s that supports multiple updates per ref.
+ *
+ * <p>The underlying behavior of {@link BatchRefUpdate} is undefined (an implementations vary) when
+ * more than one command per ref is added. This class works around that limitation by allowing
+ * multiple updates per ref, as long as the previous new SHA-1 matches the next old SHA-1.
+ */
+public class ChainedReceiveCommands implements RefCache {
+  private final Map<String, ReceiveCommand> commands = new LinkedHashMap<>();
+  private final RepoRefCache refCache;
+
+  public ChainedReceiveCommands(Repository repo) {
+    this(new RepoRefCache(repo));
+  }
+
+  public ChainedReceiveCommands(RepoRefCache refCache) {
+    this.refCache = checkNotNull(refCache);
+  }
+
+  public RepoRefCache getRepoRefCache() {
+    return refCache;
+  }
+
+  public boolean isEmpty() {
+    return commands.isEmpty();
+  }
+
+  /**
+   * Add a command.
+   *
+   * @param cmd command to add. If a command has been previously added for the same ref, the new
+   *     SHA-1 of the most recent previous command must match the old SHA-1 of this command.
+   */
+  public void add(ReceiveCommand cmd) {
+    checkArgument(!cmd.getOldId().equals(cmd.getNewId()), "ref update is a no-op: %s", cmd);
+    ReceiveCommand old = commands.get(cmd.getRefName());
+    if (old == null) {
+      commands.put(cmd.getRefName(), cmd);
+      return;
+    }
+    checkArgument(
+        old.getResult() == ReceiveCommand.Result.NOT_ATTEMPTED,
+        "cannot chain ref update %s after update %s with result %s",
+        cmd,
+        old,
+        old.getResult());
+    checkArgument(
+        cmd.getOldId().equals(old.getNewId()),
+        "cannot chain ref update %s after update %s with different new ID",
+        cmd,
+        old);
+    commands.put(
+        cmd.getRefName(), new ReceiveCommand(old.getOldId(), cmd.getNewId(), cmd.getRefName()));
+  }
+
+  /**
+   * Get the latest value of a ref according to this sequence of commands.
+   *
+   * <p>After the value for a ref is read from the repo once, it is cached as in {@link
+   * RepoRefCache}.
+   *
+   * @see RefCache#get(String)
+   */
+  @Override
+  public Optional<ObjectId> get(String refName) throws IOException {
+    ReceiveCommand cmd = commands.get(refName);
+    if (cmd != null) {
+      return !cmd.getNewId().equals(ObjectId.zeroId())
+          ? Optional.of(cmd.getNewId())
+          : Optional.empty();
+    }
+    return refCache.get(refName);
+  }
+
+  /**
+   * Add commands from this instance to a native JGit batch update.
+   *
+   * <p>Exactly one command per ref will be added to the update. The old SHA-1 will be the old SHA-1
+   * of the first command added to this instance for that ref; the new SHA-1 will be the new SHA-1
+   * of the last command.
+   *
+   * @param bru batch update
+   */
+  public void addTo(BatchRefUpdate bru) {
+    for (ReceiveCommand cmd : commands.values()) {
+      bru.addCommand(cmd);
+    }
+  }
+
+  /** @return an unmodifiable view of commands. */
+  public Map<String, ReceiveCommand> getCommands() {
+    return Collections.unmodifiableMap(commands);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
new file mode 100644
index 0000000..d619490
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.project.ChangeControl;
+
+/**
+ * Context for performing the {@link BatchUpdateOp#updateChange} phase.
+ *
+ * <p>A single {@code ChangeContext} corresponds to updating a single change; if a {@link
+ * BatchUpdate} spans multiple changes, then multiple {@code ChangeContext} instances will be
+ * created.
+ */
+public interface ChangeContext extends Context {
+  /**
+   * Get an update for this change at a given patch set.
+   *
+   * <p>A single operation can modify changes at different patch sets. Commits in the NoteDb graph
+   * within this update are created in patch set order.
+   *
+   * <p>To get the current patch set ID, use {@link com.google.gerrit.server.PatchSetUtil#current}.
+   *
+   * @param psId patch set ID.
+   * @return handle for change updates.
+   */
+  ChangeUpdate getUpdate(PatchSet.Id psId);
+
+  /**
+   * @return control for this change. The user will be the same as {@link #getUser()}, and the
+   *     change data is read within the same transaction that {@code updateChange} is executing.
+   */
+  ChangeControl getControl();
+
+  /**
+   * @param bump whether to bump the value of {@link Change#getLastUpdatedOn()} field before storing
+   *     to ReviewDb. For NoteDb, the value is always incremented (assuming the update is not
+   *     otherwise a no-op).
+   */
+  void bumpLastUpdatedOn(boolean bump);
+
+  /**
+   * Instruct {@link BatchUpdate} to delete this change.
+   *
+   * <p>If called, all other updates are ignored.
+   */
+  void deleteChange();
+
+  /** @return notes corresponding to {@link #getControl()}. */
+  default ChangeNotes getNotes() {
+    return checkNotNull(getControl().getNotes());
+  }
+
+  /** @return change corresponding to {@link #getControl()}. */
+  default Change getChange() {
+    return checkNotNull(getControl().getChange());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
new file mode 100644
index 0000000..42199e9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2012 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.update;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.server.git.ReceiveCommits;
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on the global {@link ListeningExecutorService} used by {@link ReceiveCommits} to create or
+ * replace changes.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface ChangeUpdateExecutor {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
new file mode 100644
index 0000000..497b7ab
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Context for performing a {@link BatchUpdate}.
+ *
+ * <p>A single update may span multiple changes, but they all belong to a single repo.
+ */
+public interface Context {
+  /** @return the project name this update operates on. */
+  Project.NameKey getProject();
+
+  /**
+   * Get an open repository instance for this project.
+   *
+   * <p>Will be opened lazily if necessary; callers should not close the repo. In some phases of the
+   * update, the repository might be read-only; see {@link BatchUpdateOp} for details.
+   *
+   * @return repository instance.
+   * @throws IOException if an error occurred opening the repo.
+   */
+  Repository getRepository() throws IOException;
+
+  /**
+   * Get a walk for this project.
+   *
+   * <p>The repository will be opened lazily if necessary; callers should not close the walk.
+   *
+   * @return walk.
+   * @throws IOException if an error occurred opening the repo.
+   */
+  RevWalk getRevWalk() throws IOException;
+
+  /** @return the timestamp at which this update takes place. */
+  Timestamp getWhen();
+
+  /**
+   * @return the time zone in which this update takes place. In the current implementation, this is
+   *     always the time zone of the server.
+   */
+  TimeZone getTimeZone();
+
+  /**
+   * @return an open ReviewDb database. Callers should not manage transactions or call mutating
+   *     methods on the Changes table. Mutations on other tables (including other entities in the
+   *     change entity group) are fine.
+   */
+  ReviewDb getDb();
+
+  /**
+   * @return user performing the update. In the current implementation, this is always an {@link
+   *     IdentifiedUser} or {@link com.google.gerrit.server.InternalUser}.
+   */
+  CurrentUser getUser();
+
+  /** @return order in which operations are executed in this update. */
+  Order getOrder();
+
+  /**
+   * @return identified user performing the update; throws an unchecked exception if the user is not
+   *     an {@link IdentifiedUser}
+   */
+  default IdentifiedUser getIdentifiedUser() {
+    return checkNotNull(getUser()).asIdentifiedUser();
+  }
+
+  /**
+   * @return account of the user performing the update; throws if the user is not an {@link
+   *     IdentifiedUser}
+   */
+  default Account getAccount() {
+    return getIdentifiedUser().getAccount();
+  }
+
+  /**
+   * @return account ID of the user performing the update; throws if the user is not an {@link
+   *     IdentifiedUser}
+   */
+  default Account.Id getAccountId() {
+    return getIdentifiedUser().getAccountId();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
new file mode 100644
index 0000000..1a947e6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.google.gerrit.reviewdb.client.Change;
+
+/**
+ * Specialization of {@link BatchUpdateOp} for creating changes.
+ *
+ * <p>A typical {@code BatchUpdateOp} operates on a change that has been read from a transaction;
+ * this type, by contrast, is responsible for creating the change from scratch.
+ *
+ * <p>Ops of this type must be used via {@link BatchUpdate#insertChange(InsertChangeOp)}. They may
+ * be mixed with other {@link BatchUpdateOp}s for the same change, in which case the insert op runs
+ * first.
+ */
+public interface InsertChangeOp extends BatchUpdateOp {
+  Change createChange(Context ctx);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/Order.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/Order.java
new file mode 100644
index 0000000..fb64b7b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/Order.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+/** Order of execution of the various phases of a {@link BatchUpdate}. */
+public enum Order {
+  /**
+   * Update the repository and execute all ref updates before touching the database.
+   *
+   * <p>The default and most common, as Gerrit does not behave well when a patch set has no
+   * corresponding ref in the repo.
+   */
+  REPO_BEFORE_DB,
+
+  /**
+   * Update the database before touching the repository.
+   *
+   * <p>Generally only used when deleting patch sets, which should be deleted first from the
+   * database (for the same reason as above.)
+   */
+  DB_BEFORE_REPO;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java
new file mode 100644
index 0000000..37c1d60
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java
@@ -0,0 +1,180 @@
+// 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.update;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.lib.BaseRepositoryBuilder;
+import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.ReflogReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+
+class ReadOnlyRepository extends Repository {
+  private static final String MSG = "Cannot modify a " + ReadOnlyRepository.class.getSimpleName();
+
+  private static BaseRepositoryBuilder<?, ?> builder(Repository r) {
+    checkNotNull(r);
+    BaseRepositoryBuilder<?, ?> builder =
+        new BaseRepositoryBuilder<>().setFS(r.getFS()).setGitDir(r.getDirectory());
+
+    if (!r.isBare()) {
+      builder.setWorkTree(r.getWorkTree()).setIndexFile(r.getIndexFile());
+    }
+    return builder;
+  }
+
+  private final Repository delegate;
+  private final RefDb refdb;
+  private final ObjDb objdb;
+
+  ReadOnlyRepository(Repository delegate) {
+    super(builder(delegate));
+    this.delegate = delegate;
+    this.refdb = new RefDb(delegate.getRefDatabase());
+    this.objdb = new ObjDb(delegate.getObjectDatabase());
+  }
+
+  @Override
+  public void create(boolean bare) throws IOException {
+    throw new UnsupportedOperationException(MSG);
+  }
+
+  @Override
+  public ObjectDatabase getObjectDatabase() {
+    return objdb;
+  }
+
+  @Override
+  public RefDatabase getRefDatabase() {
+    return refdb;
+  }
+
+  @Override
+  public StoredConfig getConfig() {
+    return delegate.getConfig();
+  }
+
+  @Override
+  public AttributesNodeProvider createAttributesNodeProvider() {
+    return delegate.createAttributesNodeProvider();
+  }
+
+  @Override
+  public void scanForRepoChanges() throws IOException {
+    delegate.scanForRepoChanges();
+  }
+
+  @Override
+  public void notifyIndexChanged() {
+    delegate.notifyIndexChanged();
+  }
+
+  @Override
+  public ReflogReader getReflogReader(String refName) throws IOException {
+    return delegate.getReflogReader(refName);
+  }
+
+  @Override
+  public String getGitwebDescription() throws IOException {
+    return delegate.getGitwebDescription();
+  }
+
+  private static class RefDb extends RefDatabase {
+    private final RefDatabase delegate;
+
+    private RefDb(RefDatabase delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public void create() throws IOException {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public void close() {
+      delegate.close();
+    }
+
+    @Override
+    public boolean isNameConflicting(String name) throws IOException {
+      return delegate.isNameConflicting(name);
+    }
+
+    @Override
+    public RefUpdate newUpdate(String name, boolean detach) throws IOException {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public RefRename newRename(String fromName, String toName) throws IOException {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public Ref getRef(String name) throws IOException {
+      return delegate.getRef(name);
+    }
+
+    @Override
+    public Map<String, Ref> getRefs(String prefix) throws IOException {
+      return delegate.getRefs(prefix);
+    }
+
+    @Override
+    public List<Ref> getAdditionalRefs() throws IOException {
+      return delegate.getAdditionalRefs();
+    }
+
+    @Override
+    public Ref peel(Ref ref) throws IOException {
+      return delegate.peel(ref);
+    }
+  }
+
+  private static class ObjDb extends ObjectDatabase {
+    private final ObjectDatabase delegate;
+
+    private ObjDb(ObjectDatabase delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public ObjectInserter newInserter() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ObjectReader newReader() {
+      return delegate.newReader();
+    }
+
+    @Override
+    public void close() {
+      delegate.close();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
new file mode 100644
index 0000000..5009c50
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Context for performing the {@link BatchUpdateOp#updateRepo} phase. */
+public interface RepoContext extends Context {
+  /**
+   * @return inserter for writing to the repo. Callers should not flush; the walk returned by {@link
+   *     #getRevWalk()} is able to read back objects inserted by this inserter without flushing
+   *     first.
+   * @throws IOException if an error occurred opening the repo.
+   */
+  ObjectInserter getInserter() throws IOException;
+
+  /**
+   * Add a command to the pending list of commands.
+   *
+   * <p>Callers should use this method instead of writing directly to the repository returned by
+   * {@link #getRepository()}.
+   *
+   * @param cmd ref update command.
+   * @throws IOException if an error occurred opening the repo.
+   */
+  void addRefUpdate(ReceiveCommand cmd) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoOnlyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoOnlyOp.java
new file mode 100644
index 0000000..7e9c47e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoOnlyOp.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+/**
+ * Base interface for operations performed as part of a {@link BatchUpdate}.
+ *
+ * <p>Operations that implement this type only touch the repository; they cannot touch change
+ * storage, nor are they even associated with a change ID. To modify a change, implement {@link
+ * BatchUpdateOp} instead.
+ */
+public interface RepoOnlyOp {
+  /**
+   * Override this method to update the repo.
+   *
+   * @param ctx context
+   */
+  default void updateRepo(RepoContext ctx) throws Exception {}
+
+  /**
+   * Override this method to do something after the update e.g. send email or run hooks
+   *
+   * @param ctx context
+   */
+  // TODO(dborowitz): Support async operations?
+  default void postUpdate(Context ctx) throws Exception {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
new file mode 100644
index 0000000..63d83ac
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -0,0 +1,915 @@
+// 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InsertedObject;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * {@link BatchUpdate} implementation that supports mixed ReviewDb/NoteDb operations, depending on
+ * the migration state specified in {@link NotesMigration}.
+ *
+ * <p>When performing change updates in a mixed ReviewDb/NoteDb environment with ReviewDb primary,
+ * the order of operations is very subtle:
+ *
+ * <ol>
+ *   <li>Stage NoteDb updates to get the new NoteDb state, but do not write to the repo.
+ *   <li>Write the new state in the Change entity, and commit this to ReviewDb.
+ *   <li>Update NoteDb, ignoring any write failures.
+ * </ol>
+ *
+ * The implementation in this class is well-tested, and it is strongly recommended that you not
+ * attempt to reimplement this logic. Use {@code BatchUpdate} if at all possible.
+ */
+class ReviewDbBatchUpdate extends BatchUpdate {
+  private static final Logger log = LoggerFactory.getLogger(ReviewDbBatchUpdate.class);
+
+  interface AssistedFactory {
+    ReviewDbBatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
+
+  class ContextImpl implements Context {
+    private Repository repoWrapper;
+
+    @Override
+    public Repository getRepository() throws IOException {
+      if (repoWrapper == null) {
+        repoWrapper = new ReadOnlyRepository(ReviewDbBatchUpdate.this.getRepository());
+      }
+      return repoWrapper;
+    }
+
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return ReviewDbBatchUpdate.this.getRevWalk();
+    }
+
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public Repository getRepository() throws IOException {
+      return ReviewDbBatchUpdate.this.getRepository();
+    }
+
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return ReviewDbBatchUpdate.this.getObjectInserter();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      initRepository();
+      commands.add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeControl ctl;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+    private final ReviewDbWrapper dbWrapper;
+    private final Repository threadLocalRepo;
+    private final RevWalk threadLocalRevWalk;
+
+    private boolean deleted;
+    private boolean bumpLastUpdatedOn = true;
+
+    protected ChangeContextImpl(
+        ChangeControl ctl, ReviewDbWrapper dbWrapper, Repository repo, RevWalk rw) {
+      this.ctl = ctl;
+      this.dbWrapper = dbWrapper;
+      this.threadLocalRepo = repo;
+      this.threadLocalRevWalk = rw;
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      checkNotNull(dbWrapper);
+      return dbWrapper;
+    }
+
+    @Override
+    public Repository getRepository() {
+      return threadLocalRepo;
+    }
+
+    @Override
+    public RevWalk getRevWalk() {
+      return threadLocalRevWalk;
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(ctl, when);
+        if (newChanges.containsKey(ctl.getId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeControl getControl() {
+      checkNotNull(ctl);
+      return ctl;
+    }
+
+    @Override
+    public void bumpLastUpdatedOn(boolean bump) {
+      bumpLastUpdatedOn = bump;
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  @Singleton
+  private static class Metrics {
+    final Timer1<Boolean> executeChangeOpsLatency;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      executeChangeOpsLatency =
+          metricMaker.newTimer(
+              "batch_update/execute_change_ops",
+              new Description("BatchUpdate change update latency, excluding reindexing")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS),
+              Field.ofBoolean("success"));
+    }
+  }
+
+  static void execute(
+      ImmutableList<ReviewDbBatchUpdate> updates,
+      BatchUpdateListener listener,
+      @Nullable RequestId requestId,
+      boolean dryrun)
+      throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    if (requestId != null) {
+      for (BatchUpdate u : updates) {
+        checkArgument(
+            u.requestId == null || u.requestId == requestId,
+            "refusing to overwrite RequestId %s in update with %s",
+            u.requestId,
+            requestId);
+        u.setRequestId(requestId);
+      }
+    }
+    try {
+      Order order = getOrder(updates);
+      boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
+      switch (order) {
+        case REPO_BEFORE_DB:
+          for (ReviewDbBatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          listener.afterUpdateRepos();
+          for (ReviewDbBatchUpdate u : updates) {
+            u.executeRefUpdates(dryrun);
+          }
+          listener.afterUpdateRefs();
+          for (ReviewDbBatchUpdate u : updates) {
+            u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
+          }
+          listener.afterUpdateChanges();
+          break;
+        case DB_BEFORE_REPO:
+          for (ReviewDbBatchUpdate u : updates) {
+            u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
+          }
+          listener.afterUpdateChanges();
+          for (ReviewDbBatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          listener.afterUpdateRepos();
+          for (ReviewDbBatchUpdate u : updates) {
+            u.executeRefUpdates(dryrun);
+          }
+          listener.afterUpdateRefs();
+          break;
+        default:
+          throw new IllegalStateException("invalid execution order: " + order);
+      }
+
+      @SuppressWarnings("deprecation")
+      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+          new ArrayList<>();
+      for (ReviewDbBatchUpdate u : updates) {
+        indexFutures.addAll(u.indexFutures);
+      }
+      ChangeIndexer.allAsList(indexFutures).get();
+
+      for (ReviewDbBatchUpdate u : updates) {
+        if (u.batchRefUpdate != null) {
+          // Fire ref update events only after all mutations are finished, since
+          // 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.getUser().isIdentifiedUser() ? u.getUser().asIdentifiedUser().getAccount() : null);
+        }
+      }
+      if (!dryrun) {
+        for (ReviewDbBatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (UpdateException | RestApiException e) {
+      // Propagate REST API exceptions thrown by operations; they commonly throw
+      // exceptions like ResourceConflictException to indicate an atomic update
+      // failure.
+      throw e;
+
+      // Convert other common non-REST exception types with user-visible
+      // messages to corresponding REST exception types
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } catch (NoSuchChangeException | NoSuchRefException | NoSuchProjectException e) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw new UpdateException(e);
+    }
+  }
+
+  private final AllUsersName allUsers;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeIndexer indexer;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ListeningExecutorService changeUpdateExector;
+  private final Metrics metrics;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final NotesMigration notesMigration;
+  private final ReviewDb db;
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final long skewMs;
+
+  @SuppressWarnings("deprecation")
+  private final List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+      new ArrayList<>();
+
+  @AssistedInject
+  ReviewDbBatchUpdate(
+      @GerritServerConfig Config cfg,
+      AllUsersName allUsers,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeIndexer indexer,
+      ChangeNotes.Factory changeNotesFactory,
+      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
+      ChangeUpdate.Factory changeUpdateFactory,
+      @GerritPersonIdent PersonIdent serverIdent,
+      GitReferenceUpdated gitRefUpdated,
+      GitRepositoryManager repoManager,
+      Metrics metrics,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration notesMigration,
+      SchemaFactory<ReviewDb> schemaFactory,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
+    super(repoManager, serverIdent, project, user, when);
+    this.allUsers = allUsers;
+    this.changeControlFactory = changeControlFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeUpdateExector = changeUpdateExector;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.gitRefUpdated = gitRefUpdated;
+    this.indexer = indexer;
+    this.metrics = metrics;
+    this.notesMigration = notesMigration;
+    this.schemaFactory = schemaFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.db = db;
+    skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
+  }
+
+  @Override
+  public void execute() throws UpdateException, RestApiException {
+    execute(BatchUpdateListener.NONE);
+  }
+
+  @Override
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId, false);
+  }
+
+  @Override
+  protected Context newContext() {
+    return new ContextImpl();
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && commands != null && !commands.isEmpty()) {
+        try (ObjectReader reader = ctx.getInserter().newReader()) {
+          // Validation of refs has to take place here and not at the beginning
+          // executeRefUpdates. Otherwise failing validation in a second BatchUpdate object will
+          // happen *after* first object's executeRefUpdates has finished, hence after first repo's
+          // refs have been updated, which is too late.
+          onSubmitValidators.validate(
+              project, new ReadOnlyRepository(getRepository()), reader, commands.getCommands());
+        }
+      }
+
+      if (inserter != null) {
+        logDebug("Flushing inserter");
+        inserter.flush();
+      } else {
+        logDebug("No objects to flush");
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
+    }
+  }
+
+  private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
+    if (commands == null || commands.isEmpty()) {
+      logDebug("No ref updates to execute");
+      return;
+    }
+    // May not be opened if the caller added ref updates but no new objects.
+    initRepository();
+    batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
+    batchRefUpdate.setRefLogMessage(refLogMessage, true);
+    if (user.isIdentifiedUser()) {
+      batchRefUpdate.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    commands.addTo(batchRefUpdate);
+    logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
+    if (dryrun) {
+      return;
+    }
+
+    batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+    boolean ok = true;
+    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        ok = false;
+        break;
+      }
+    }
+    if (!ok) {
+      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
+    }
+  }
+
+  private List<ChangeTask> executeChangeOps(boolean parallel, boolean dryrun)
+      throws UpdateException, RestApiException {
+    List<ChangeTask> tasks;
+    boolean success = false;
+    Stopwatch sw = Stopwatch.createStarted();
+    try {
+      logDebug("Executing change ops (parallel? {})", parallel);
+      ListeningExecutorService executor =
+          parallel ? changeUpdateExector : MoreExecutors.newDirectExecutorService();
+
+      tasks = new ArrayList<>(ops.keySet().size());
+      try {
+        if (notesMigration.commitChangeWrites() && repo != null) {
+          // A NoteDb change may have been rebuilt since the repo was originally
+          // opened, so make sure we see that.
+          logDebug("Preemptively scanning for repo changes");
+          repo.scanForRepoChanges();
+        }
+        if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
+          // Fail fast before attempting any writes if changes are read-only, as
+          // this is a programmer error.
+          logDebug("Failing early due to read-only Changes table");
+          throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+        }
+        List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
+        for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+          ChangeTask task =
+              new ChangeTask(e.getKey(), e.getValue(), Thread.currentThread(), dryrun);
+          tasks.add(task);
+          if (!parallel) {
+            logDebug("Direct execution of task for ops: {}", ops);
+          }
+          futures.add(executor.submit(task));
+        }
+        if (parallel) {
+          logDebug(
+              "Waiting on futures for {} ops spanning {} changes", ops.size(), ops.keySet().size());
+        }
+        Futures.allAsList(futures).get();
+
+        if (notesMigration.commitChangeWrites()) {
+          if (!dryrun) {
+            executeNoteDbUpdates(tasks);
+          }
+        }
+        success = true;
+      } catch (ExecutionException | InterruptedException e) {
+        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
+        throw new UpdateException(e);
+      } catch (OrmException | IOException e) {
+        throw new UpdateException(e);
+      }
+    } finally {
+      metrics.executeChangeOpsLatency.record(success, sw.elapsed(NANOSECONDS), NANOSECONDS);
+    }
+    return tasks;
+  }
+
+  private void reindexChanges(List<ChangeTask> tasks) {
+    // Reindex changes.
+    for (ChangeTask task : tasks) {
+      if (task.deleted) {
+        indexFutures.add(indexer.deleteAsync(task.id));
+      } else if (task.dirty) {
+        indexFutures.add(indexer.indexAsync(project, task.id));
+      }
+    }
+  }
+
+  private void executeNoteDbUpdates(List<ChangeTask> tasks)
+      throws ResourceConflictException, IOException {
+    // Aggregate together all NoteDb ref updates from the ops we executed,
+    // possibly in parallel. Each task had its own NoteDbUpdateManager instance
+    // with its own thread-local copy of the repo(s), but each of those was just
+    // used for staging updates and was never executed.
+    //
+    // Use a new BatchRefUpdate as the original batchRefUpdate field is intended
+    // for use only by the updateRepo phase.
+    //
+    // See the comments in NoteDbUpdateManager#execute() for why we execute the
+    // updates on the change repo first.
+    logDebug("Executing NoteDb updates for {} changes", tasks.size());
+    try {
+      BatchRefUpdate changeRefUpdate = getRepository().getRefDatabase().newBatchUpdate();
+      boolean hasAllUsersCommands = false;
+      try (ObjectInserter ins = getRepository().newObjectInserter()) {
+        int objs = 0;
+        for (ChangeTask task : tasks) {
+          if (task.noteDbResult == null) {
+            logDebug("No-op update to {}", task.id);
+            continue;
+          }
+          for (ReceiveCommand cmd : task.noteDbResult.changeCommands()) {
+            changeRefUpdate.addCommand(cmd);
+          }
+          for (InsertedObject obj : task.noteDbResult.changeObjects()) {
+            objs++;
+            ins.insert(obj.type(), obj.data().toByteArray());
+          }
+          hasAllUsersCommands |= !task.noteDbResult.allUsersCommands().isEmpty();
+        }
+        logDebug(
+            "Collected {} objects and {} ref updates to change repo",
+            objs,
+            changeRefUpdate.getCommands().size());
+        executeNoteDbUpdate(getRevWalk(), ins, changeRefUpdate);
+      }
+
+      if (hasAllUsersCommands) {
+        try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+            RevWalk allUsersRw = new RevWalk(allUsersRepo);
+            ObjectInserter allUsersIns = allUsersRepo.newObjectInserter()) {
+          int objs = 0;
+          BatchRefUpdate allUsersRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+          for (ChangeTask task : tasks) {
+            for (ReceiveCommand cmd : task.noteDbResult.allUsersCommands()) {
+              allUsersRefUpdate.addCommand(cmd);
+            }
+            for (InsertedObject obj : task.noteDbResult.allUsersObjects()) {
+              allUsersIns.insert(obj.type(), obj.data().toByteArray());
+            }
+          }
+          logDebug(
+              "Collected {} objects and {} ref updates to All-Users",
+              objs,
+              allUsersRefUpdate.getCommands().size());
+          executeNoteDbUpdate(allUsersRw, allUsersIns, allUsersRefUpdate);
+        }
+      } else {
+        logDebug("No All-Users updates");
+      }
+    } catch (IOException e) {
+      if (tasks.stream().allMatch(t -> t.storage == PrimaryStorage.REVIEW_DB)) {
+        // Ignore all errors trying to update NoteDb at this point. We've already written the
+        // NoteDbChangeStates to ReviewDb, which means if any state is out of date it will be
+        // rebuilt the next time it is needed.
+        //
+        // Always log even without RequestId.
+        log.debug("Ignoring NoteDb update error after ReviewDb write", e);
+
+        // Otherwise, we can't prove it's safe to ignore the error, either because some change had
+        // NOTE_DB primary, or a task failed before determining the primary storage.
+      } else if (e instanceof LockFailureException) {
+        // LOCK_FAILURE is a special case indicating there was a conflicting write to a meta ref,
+        // although it happened too late for us to produce anything but a generic error message.
+        throw new ResourceConflictException("Updating change failed due to conflicting write", e);
+      }
+      throw e;
+    }
+  }
+
+  private void executeNoteDbUpdate(RevWalk rw, ObjectInserter ins, BatchRefUpdate bru)
+      throws IOException {
+    if (bru.getCommands().isEmpty()) {
+      logDebug("No commands, skipping flush and ref update");
+      return;
+    }
+    ins.flush();
+    bru.setAllowNonFastForwards(true);
+    bru.execute(rw, NullProgressMonitor.INSTANCE);
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      // TODO(dborowitz): LOCK_FAILURE for NoteDb primary should be retried.
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("Update failed: " + bru);
+      }
+    }
+  }
+
+  private class ChangeTask implements Callable<Void> {
+    final Change.Id id;
+    private final Collection<BatchUpdateOp> changeOps;
+    private final Thread mainThread;
+    private final boolean dryrun;
+
+    PrimaryStorage storage;
+    NoteDbUpdateManager.StagedResult noteDbResult;
+    boolean dirty;
+    boolean deleted;
+    private String taskId;
+
+    private ChangeTask(
+        Change.Id id, Collection<BatchUpdateOp> changeOps, Thread mainThread, boolean dryrun) {
+      this.id = id;
+      this.changeOps = changeOps;
+      this.mainThread = mainThread;
+      this.dryrun = dryrun;
+    }
+
+    @Override
+    public Void call() throws Exception {
+      taskId = id.toString() + "-" + Thread.currentThread().getId();
+      if (Thread.currentThread() == mainThread) {
+        Repository repo = getRepository();
+        try (RevWalk rw = new RevWalk(repo)) {
+          call(ReviewDbBatchUpdate.this.db, repo, rw);
+        }
+      } else {
+        // Possible optimization: allow Ops to declare whether they need to
+        // access the repo from updateChange, and don't open in this thread
+        // unless we need it. However, as of this writing the only operations
+        // that are executed in parallel are during ReceiveCommits, and they
+        // all need the repo open anyway. (The non-parallel case above does not
+        // reopen the repo.)
+        try (ReviewDb threadLocalDb = schemaFactory.open();
+            Repository repo = repoManager.openRepository(project);
+            RevWalk rw = new RevWalk(repo)) {
+          call(threadLocalDb, repo, rw);
+        }
+      }
+      return null;
+    }
+
+    private void call(ReviewDb db, Repository repo, RevWalk rw) throws Exception {
+      @SuppressWarnings("resource") // Not always opened.
+      NoteDbUpdateManager updateManager = null;
+      try {
+        db.changes().beginTransaction(id);
+        try {
+          ChangeContextImpl ctx = newChangeContext(db, repo, rw, id);
+          NoteDbChangeState oldState = NoteDbChangeState.parse(ctx.getChange());
+          NoteDbChangeState.checkNotReadOnly(oldState, skewMs);
+
+          storage = PrimaryStorage.of(oldState);
+          if (storage == PrimaryStorage.NOTE_DB && !notesMigration.readChanges()) {
+            throw new OrmException("must have NoteDb enabled to update change " + id);
+          }
+
+          // Call updateChange on each op.
+          logDebug("Calling updateChange on {} ops", changeOps.size());
+          for (BatchUpdateOp op : changeOps) {
+            dirty |= op.updateChange(ctx);
+          }
+          if (!dirty) {
+            logDebug("No ops reported dirty, short-circuiting");
+            return;
+          }
+          deleted = ctx.deleted;
+          if (deleted) {
+            logDebug("Change was deleted");
+          }
+
+          // Stage the NoteDb update and store its state in the Change.
+          if (notesMigration.commitChangeWrites()) {
+            updateManager = stageNoteDbUpdate(ctx, deleted);
+          }
+
+          if (storage == PrimaryStorage.REVIEW_DB) {
+            // If primary storage of this change is in ReviewDb, bump
+            // lastUpdatedOn or rowVersion and commit. Otherwise, don't waste
+            // time updating ReviewDb at all.
+            Iterable<Change> cs = changesToUpdate(ctx);
+            if (isNewChange(id)) {
+              // Insert rather than upsert in case of a race on change IDs.
+              logDebug("Inserting change");
+              db.changes().insert(cs);
+            } else if (deleted) {
+              logDebug("Deleting change");
+              db.changes().delete(cs);
+            } else {
+              logDebug("Updating change");
+              db.changes().update(cs);
+            }
+            if (!dryrun) {
+              db.commit();
+            }
+          } else {
+            logDebug("Skipping ReviewDb write since primary storage is {}", storage);
+          }
+        } finally {
+          db.rollback();
+        }
+
+        // Do not execute the NoteDbUpdateManager, as we don't want too much
+        // contention on the underlying repo, and we would rather use a single
+        // ObjectInserter/BatchRefUpdate later.
+        //
+        // TODO(dborowitz): May or may not be worth trying to batch together
+        // flushed inserters as well.
+        if (storage == PrimaryStorage.NOTE_DB) {
+          // Should have failed above if NoteDb is disabled.
+          checkState(notesMigration.commitChangeWrites());
+          noteDbResult = updateManager.stage().get(id);
+        } else if (notesMigration.commitChangeWrites()) {
+          try {
+            noteDbResult = updateManager.stage().get(id);
+          } catch (IOException ex) {
+            // Ignore all errors trying to update NoteDb at this point. We've
+            // already written the NoteDbChangeState to ReviewDb, which means
+            // if the state is out of date it will be rebuilt the next time it
+            // is needed.
+            log.debug("Ignoring NoteDb update error after ReviewDb write", ex);
+          }
+        }
+      } catch (Exception e) {
+        logDebug("Error updating change (should be rethrown)", e);
+        Throwables.propagateIfPossible(e, RestApiException.class);
+        throw new UpdateException(e);
+      } finally {
+        if (updateManager != null) {
+          updateManager.close();
+        }
+      }
+    }
+
+    private ChangeContextImpl newChangeContext(
+        ReviewDb db, Repository repo, RevWalk rw, Change.Id id) throws OrmException {
+      Change c = newChanges.get(id);
+      boolean isNew = c != null;
+      if (isNew) {
+        // New change: populate noteDbState.
+        checkState(c.getNoteDbState() == null, "noteDbState should not be filled in by callers");
+        if (notesMigration.changePrimaryStorage() == PrimaryStorage.NOTE_DB) {
+          c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+        }
+      } else {
+        // Existing change.
+        c = ChangeNotes.readOneReviewDbChange(db, id);
+        if (c == null) {
+          // Not in ReviewDb, but new changes are created with default primary
+          // storage as NOTE_DB, so we can assume that a missing change is
+          // NoteDb primary. Pass a synthetic change into ChangeNotes.Factory,
+          // which lets ChangeNotes take care of the existence check.
+          //
+          // TODO(dborowitz): This assumption is potentially risky, because
+          // it means once we turn this option on and start creating changes
+          // without writing anything to ReviewDb, we can't turn this option
+          // back off without making those changes inaccessible. The problem
+          // is we have no way of distinguishing a change that only exists in
+          // NoteDb because it only ever existed in NoteDb, from a change that
+          // only exists in NoteDb because it used to exist in ReviewDb and
+          // deleting from ReviewDb succeeded but deleting from NoteDb failed.
+          //
+          // TODO(dborowitz): We actually still have that problem anyway. Maybe
+          // we need a cutoff timestamp? Or maybe we need to start leaving
+          // tombstones in ReviewDb?
+          c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+        }
+        NoteDbChangeState.checkNotReadOnly(c, skewMs);
+      }
+      ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+      ChangeControl ctl = changeControlFactory.controlFor(notes, user);
+      return new ChangeContextImpl(ctl, new BatchUpdateReviewDb(db), repo, rw);
+    }
+
+    private NoteDbUpdateManager stageNoteDbUpdate(ChangeContextImpl ctx, boolean deleted)
+        throws OrmException, IOException {
+      logDebug("Staging NoteDb update");
+      NoteDbUpdateManager updateManager =
+          updateManagerFactory
+              .create(ctx.getProject())
+              .setChangeRepo(
+                  ctx.getRepository(), ctx.getRevWalk(), null, new ChainedReceiveCommands(repo));
+      if (ctx.getUser().isIdentifiedUser()) {
+        updateManager.setRefLogIdent(
+            ctx.getUser().asIdentifiedUser().newRefLogIdent(ctx.getWhen(), tz));
+      }
+      for (ChangeUpdate u : ctx.updates.values()) {
+        updateManager.add(u);
+      }
+
+      Change c = ctx.getChange();
+      if (deleted) {
+        updateManager.deleteChange(c.getId());
+      }
+      try {
+        updateManager.stageAndApplyDelta(c);
+      } catch (MismatchedStateException ex) {
+        // Refused to apply update because NoteDb was out of sync, which can
+        // only happen if ReviewDb is the primary storage for this change.
+        //
+        // Go ahead with this ReviewDb update; it's still out of sync, but this
+        // is no worse than before, and it will eventually get rebuilt.
+        logDebug("Ignoring MismatchedStateException while staging");
+      }
+
+      return updateManager;
+    }
+
+    private boolean isNewChange(Change.Id id) {
+      return newChanges.containsKey(id);
+    }
+
+    private void logDebug(String msg, Throwable t) {
+      if (log.isDebugEnabled()) {
+        ReviewDbBatchUpdate.this.logDebug("[" + taskId + "]" + msg, t);
+      }
+    }
+
+    private void logDebug(String msg, Object... args) {
+      if (log.isDebugEnabled()) {
+        ReviewDbBatchUpdate.this.logDebug("[" + taskId + "]" + msg, args);
+      }
+    }
+  }
+
+  private static Iterable<Change> changesToUpdate(ChangeContextImpl ctx) {
+    Change c = ctx.getChange();
+    if (ctx.bumpLastUpdatedOn && c.getLastUpdatedOn().before(ctx.getWhen())) {
+      c.setLastUpdatedOn(ctx.getWhen());
+    }
+    return Collections.singleton(c);
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/UpdateException.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/UpdateException.java
new file mode 100644
index 0000000..063a761
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/UpdateException.java
@@ -0,0 +1,32 @@
+// 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.update;
+
+/** Exception type thrown by {@link BatchUpdate} steps. */
+public class UpdateException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public UpdateException(String message) {
+    super(message);
+  }
+
+  public UpdateException(Throwable cause) {
+    super(cause);
+  }
+
+  public UpdateException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/FallbackRequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/FallbackRequestContext.java
index 55c2992..de1555f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/FallbackRequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/FallbackRequestContext.java
@@ -23,8 +23,8 @@
 import com.google.inject.Singleton;
 
 /**
- * The default RequestContext to use when not in a request scope e.g.
- * ThreadLocalRequestContext is not set.
+ * The default RequestContext to use when not in a request scope e.g. ThreadLocalRequestContext is
+ * not set.
  */
 @Singleton
 public class FallbackRequestContext implements RequestContext {
@@ -46,8 +46,7 @@
     return new Provider<ReviewDb>() {
       @Override
       public ReviewDb get() {
-        throw new ProvisionException(
-            "Automatic ReviewDb only available in request scope");
+        throw new ProvisionException("Automatic ReviewDb only available in request scope");
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java
deleted file mode 100644
index 2d1e1fa..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-import java.io.IOException;
-
-public class GitUtil {
-
-  /**
-   * @param git
-   * @param commitId
-   * @param parentNum
-   * @return the {@code paretNo} parent of given commit or {@code null}
-   *             when {@code parentNo} exceed number of {@code commitId} parents.
-   * @throws IncorrectObjectTypeException
-   *             the supplied id is not a commit or an annotated tag.
-   * @throws IOException
-   *             a pack file or loose object could not be read.
-   */
-  public static RevCommit getParent(Repository git,
-      ObjectId commitId, int parentNum) throws IOException {
-    try (RevWalk walk = new RevWalk(git)) {
-      RevCommit commit = walk.parseCommit(commitId);
-      if (commit.getParentCount() > parentNum) {
-        return commit.getParent(parentNum);
-      }
-    }
-    return null;
-  }
-
-  private GitUtil() {
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
index 1568228..6dd5543 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
@@ -24,7 +24,6 @@
 import com.google.inject.servlet.ServletScopes;
 import com.google.inject.util.Providers;
 import com.google.inject.util.Types;
-
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
 import java.net.SocketAddress;
@@ -49,9 +48,7 @@
     this.peer = remotePeerProvider.get();
   }
 
-  /**
-   * @see RequestScopePropagator#wrap(Callable)
-   */
+  /** @see RequestScopePropagator#wrap(Callable) */
   // ServletScopes#continueRequest is deprecated, but it's not obvious their
   // recommended replacement is an appropriate drop-in solution; see
   // https://gerrit-review.googlesource.com/83971
@@ -63,12 +60,10 @@
     // Request scopes appear to use specific keys in their map, instead of only
     // providers. Add bindings for both the key to the instance directly and the
     // provider to the instance to be safe.
-    seedMap.put(Key.get(typeOfProvider(String.class), CanonicalWebUrl.class),
-        Providers.of(url));
+    seedMap.put(Key.get(typeOfProvider(String.class), CanonicalWebUrl.class), Providers.of(url));
     seedMap.put(Key.get(String.class, CanonicalWebUrl.class), url);
 
-    seedMap.put(Key.get(typeOfProvider(SocketAddress.class), RemotePeer.class),
-        Providers.of(peer));
+    seedMap.put(Key.get(typeOfProvider(SocketAddress.class), RemotePeer.class), Providers.of(peer));
     seedMap.put(Key.get(SocketAddress.class, RemotePeer.class), peer);
 
     return ServletScopes.continueRequest(callable, seedMap);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
index 86b3b7364..066bd4b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/HostPlatform.java
@@ -18,25 +18,29 @@
 import java.security.PrivilegedAction;
 
 public final class HostPlatform {
-  private static final boolean win32 = computeWin32();
+  private static final boolean win32 = compute("windows");
+  private static final boolean mac = compute("mac");
 
   /** @return true if this JVM is running on a Windows platform. */
   public static boolean isWin32() {
     return win32;
   }
 
-  private static boolean computeWin32() {
-    final String osDotName =
-        AccessController.doPrivileged(new PrivilegedAction<String>() {
-          @Override
-          public String run() {
-            return System.getProperty("os.name");
-          }
-        });
-    return osDotName != null
-        && osDotName.toLowerCase().contains("windows");
+  public static boolean isMac() {
+    return mac;
   }
 
-  private HostPlatform() {
+  private static boolean compute(String platform) {
+    final String osDotName =
+        AccessController.doPrivileged(
+            new PrivilegedAction<String>() {
+              @Override
+              public String run() {
+                return System.getProperty("os.name");
+              }
+            });
+    return osDotName != null && osDotName.toLowerCase().contains(platform);
   }
+
+  private HostPlatform() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java
index 78eb657..e4d2890 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java
@@ -16,7 +16,6 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import java.util.Random;
 import java.util.concurrent.atomic.AtomicInteger;
 
@@ -71,23 +70,30 @@
   }
 
   private static short hi16(final int in) {
-    return (short) ( //
-    ((in >>> 24 & 0xff)) | //
-    ((in >>> 16 & 0xff) << 8) //
-    );
+    return (short)
+        ( //
+        ((in >>> 24 & 0xff))
+            | //
+            ((in >>> 16 & 0xff) << 8) //
+        );
   }
 
   private static short lo16(final int in) {
-    return (short) ( //
-    ((in >>> 8 & 0xff)) | //
-    ((in & 0xff) << 8) //
-    );
+    return (short)
+        ( //
+        ((in >>> 8 & 0xff))
+            | //
+            ((in & 0xff) << 8) //
+        );
   }
 
   private static int result(final short v0, final short v1) {
-    return ((v0 & 0xff) << 24) | //
-        (((v0 >>> 8) & 0xff) << 16) | //
-        ((v1 & 0xff) << 8) | //
+    return ((v0 & 0xff) << 24)
+        | //
+        (((v0 >>> 8) & 0xff) << 16)
+        | //
+        ((v1 & 0xff) << 8)
+        | //
         ((v1 >>> 8) & 0xff);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
index fab0b34..538d7d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
@@ -46,16 +46,23 @@
     if (sign == 0) {
       return create(text, (short) 1);
     }
-    return create(text.substring(0, i),
-        (short)(sign * Short.parseShort(text.substring(i + 1))));
+    return create(text.substring(0, i), (short) (sign * Short.parseShort(text.substring(i + 1))));
   }
 
   public static LabelVote parseWithEquals(String text) {
     checkArgument(!Strings.isNullOrEmpty(text), "Empty label vote");
     int e = text.lastIndexOf('=');
     checkArgument(e >= 0, "Label vote missing '=': %s", text);
-    return create(text.substring(0, e),
-        Short.parseShort(text.substring(e + 1), text.length()));
+    return create(text.substring(0, e), Short.parseShort(text.substring(e + 1), text.length()));
+  }
+
+  public static StringBuilder appendTo(StringBuilder sb, String label, short value) {
+    if (value == (short) 0) {
+      return sb.append('-').append(label);
+    } else if (value < 0) {
+      return sb.append(label).append(value);
+    }
+    return sb.append(label).append('+').append(value);
   }
 
   public static LabelVote create(String label, short value) {
@@ -67,16 +74,12 @@
   }
 
   public abstract String label();
+
   public abstract short value();
 
   public String format() {
-    if (value() == (short) 0) {
-      return '-' + label();
-    } else if (value() < 0) {
-      return label() + value();
-    } else {
-      return label() + '+' + value();
-    }
+    // Max short string length is "-32768".length() == 6.
+    return appendTo(new StringBuilder(label().length() + 6), label(), value()).toString();
   }
 
   public String formatWithEquals() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
index 6e1952b..75e14cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
@@ -16,18 +16,15 @@
 
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.reviewdb.client.Project;
-
+import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.Map;
-
 public final class MagicBranch {
-  private static final Logger log =
-    LoggerFactory.getLogger(MagicBranch.class);
+  private static final Logger log = LoggerFactory.getLogger(MagicBranch.class);
 
   public static final String NEW_CHANGE = "refs/for/";
   public static final String NEW_DRAFT_CHANGE = "refs/drafts/";
@@ -66,15 +63,14 @@
   }
 
   /**
-   * Checks if a (magic branch)/branch_name reference exists in the
-   * destination repository and only returns Capable.OK if it does not match any.
+   * Checks if a (magic branch)/branch_name reference exists in the destination repository and only
+   * returns Capable.OK if it does not match any.
    *
-   * These block the client from being able to even send us a pack file, as it
-   * is very unlikely the user passed the --force flag and the new commit is
-   * probably not going to fast-forward the branch.
+   * <p>These block the client from being able to even send us a pack file, as it is very unlikely
+   * the user passed the --force flag and the new commit is probably not going to fast-forward the
+   * branch.
    */
-  public static Capable checkMagicBranchRefs(Repository repo,
-      Project project) {
+  public static Capable checkMagicBranchRefs(Repository repo, Project project) {
     Capable result = checkMagicBranchRef(NEW_CHANGE, repo, project);
     if (result != Capable.OK) {
       return result;
@@ -91,8 +87,7 @@
     return Capable.OK;
   }
 
-  private static Capable checkMagicBranchRef(String branchName, Repository repo,
-      Project project) {
+  private static Capable checkMagicBranchRef(String branchName, Repository repo, Project project) {
     Map<String, Ref> blockingFors;
     try {
       blockingFors = repo.getRefDatabase().getRefs(branchName);
@@ -103,15 +98,16 @@
     }
     if (!blockingFors.isEmpty()) {
       String projName = project.getName();
-      log.error("Repository '" + projName
-          + "' needs the following refs removed to receive changes: "
-          + blockingFors.keySet());
+      log.error(
+          "Repository '"
+              + projName
+              + "' needs the following refs removed to receive changes: "
+              + blockingFors.keySet());
       return new Capable("One or more " + branchName + " names blocks change upload");
     }
 
     return Capable.OK;
   }
 
-  private MagicBranch() {
-  }
+  private MagicBranch() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ManualRequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/ManualRequestContext.java
index 900bb42..620a2bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/ManualRequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/ManualRequestContext.java
@@ -21,19 +21,27 @@
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 
-/**
- * Closeable version of a {@link RequestContext} with manually-specified
- * providers.
- */
+/** Closeable version of a {@link RequestContext} with manually-specified providers. */
 public class ManualRequestContext implements RequestContext, AutoCloseable {
-  private final CurrentUser user;
+  private final Provider<CurrentUser> userProvider;
   private final Provider<ReviewDb> db;
   private final ThreadLocalRequestContext requestContext;
   private final RequestContext old;
 
-  public ManualRequestContext(CurrentUser user, SchemaFactory<ReviewDb> schemaFactory,
-      ThreadLocalRequestContext requestContext) throws OrmException {
-    this.user = user;
+  public ManualRequestContext(
+      CurrentUser user,
+      SchemaFactory<ReviewDb> schemaFactory,
+      ThreadLocalRequestContext requestContext)
+      throws OrmException {
+    this(Providers.of(user), schemaFactory, requestContext);
+  }
+
+  public ManualRequestContext(
+      Provider<CurrentUser> userProvider,
+      SchemaFactory<ReviewDb> schemaFactory,
+      ThreadLocalRequestContext requestContext)
+      throws OrmException {
+    this.userProvider = userProvider;
     this.db = Providers.of(schemaFactory.open());
     this.requestContext = requestContext;
     old = requestContext.setContext(this);
@@ -41,7 +49,7 @@
 
   @Override
   public CurrentUser getUser() {
-    return user;
+    return userProvider.get();
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
index 159763c..4019851 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -16,36 +16,31 @@
 
 import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.server.project.RefPattern;
-
-import org.apache.commons.lang.StringUtils;
-
 import java.util.Comparator;
+import org.apache.commons.lang.StringUtils;
 
 /**
  * Order the Ref Pattern by the most specific. This sort is done by:
+ *
  * <ul>
- * <li>1 - The minor value of Levenshtein string distance between the branch
- * name and the regex string shortest example. A shorter distance is a more
- * specific match.
- * <li>2 - Finites first, infinities after.
- * <li>3 - Number of transitions.  More transitions is more specific.
- * <li>4 - Length of the expression text.
+ *   <li>1 - The minor value of Levenshtein string distance between the branch name and the regex
+ *       string shortest example. A shorter distance is a more specific match.
+ *   <li>2 - Finites first, infinities after.
+ *   <li>3 - Number of transitions. More transitions is more specific.
+ *   <li>4 - Length of the expression text.
  * </ul>
  *
- * Levenshtein distance is a measure of the similarity between two strings.
- * The distance is the number of deletions, insertions, or substitutions
- * required to transform one string into another.
+ * Levenshtein distance is a measure of the similarity between two strings. The distance is the
+ * number of deletions, insertions, or substitutions required to transform one string into another.
  *
- * For example, if given refs/heads/m* and refs/heads/*, the distances are 5
- * and 6. It means that refs/heads/m* is more specific because it's closer to
- * refs/heads/master than refs/heads/*.
+ * <p>For example, if given refs/heads/m* and refs/heads/*, the distances are 5 and 6. It means that
+ * refs/heads/m* is more specific because it's closer to refs/heads/master than refs/heads/*.
  *
- * Another example could be refs/heads/* and refs/heads/[a-zA-Z]*, the
- * distances are both 6. Both are infinite, but refs/heads/[a-zA-Z]* has more
- * transitions, which after all turns it more specific.
+ * <p>Another example could be refs/heads/* and refs/heads/[a-zA-Z]*, the distances are both 6. Both
+ * are infinite, but refs/heads/[a-zA-Z]* has more transitions, which after all turns it more
+ * specific.
  */
-public final class MostSpecificComparator implements
-    Comparator<RefConfigSection> {
+public final class MostSpecificComparator implements Comparator<RefConfigSection> {
   private final String refName;
 
   public MostSpecificComparator(String refName) {
@@ -111,8 +106,7 @@
 
   private int transitions(String pattern) {
     if (RefPattern.isRE(pattern)) {
-      return RefPattern.toRegExp(pattern).toAutomaton()
-          .getNumberOfTransitions();
+      return RefPattern.toRegExp(pattern).toAutomaton().getNumberOfTransitions();
 
     } else if (pattern.endsWith("/*")) {
       return pattern.length();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
index f4719aa..28be669 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/OneOffRequestContext.java
@@ -25,12 +25,12 @@
 
 /**
  * Helper to create one-off request contexts.
- * <p>
- * Each call to {@link #open()} opens a new {@link ReviewDb}, so this class
- * should only be used in a bounded try/finally block.
- * <p>
- * The user in the request context is {@link InternalUser} or the
- * {@link IdentifiedUser} associated to the userId passed as parameter.
+ *
+ * <p>Each call to {@link #open()} opens a new {@link ReviewDb}, so this class should only be used
+ * in a bounded try/finally block.
+ *
+ * <p>The user in the request context is {@link InternalUser} or the {@link IdentifiedUser}
+ * associated to the userId passed as parameter.
  */
 @Singleton
 public class OneOffRequestContext {
@@ -40,7 +40,8 @@
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
 
   @Inject
-  OneOffRequestContext(InternalUser.Factory userFactory,
+  OneOffRequestContext(
+      InternalUser.Factory userFactory,
       SchemaFactory<ReviewDb> schemaFactory,
       ThreadLocalRequestContext requestContext,
       IdentifiedUser.GenericFactory identifiedUserFactory) {
@@ -51,12 +52,11 @@
   }
 
   public ManualRequestContext open() throws OrmException {
-    return new ManualRequestContext(userFactory.create(),
-        schemaFactory, requestContext);
+    return new ManualRequestContext(userFactory.create(), schemaFactory, requestContext);
   }
 
   public ManualRequestContext openAs(Account.Id userId) throws OrmException {
-    return new ManualRequestContext(identifiedUserFactory.create(userId),
-        schemaFactory, requestContext);
+    return new ManualRequestContext(
+        identifiedUserFactory.create(userId), schemaFactory, requestContext);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginLogFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginLogFile.java
index 17f6535..946a7e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -16,8 +16,6 @@
 
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
-import com.google.inject.Inject;
-
 import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Layout;
 import org.apache.log4j.LogManager;
@@ -30,11 +28,8 @@
   private final String logName;
   private final Layout layout;
 
-  @Inject
-  public PluginLogFile(SystemLog systemLog,
-      ServerInformation serverInfo,
-      String logName,
-      Layout layout) {
+  public PluginLogFile(
+      SystemLog systemLog, ServerInformation serverInfo, String logName, Layout layout) {
     this.systemLog = systemLog;
     this.serverInfo = serverInfo;
     this.logName = logName;
@@ -43,8 +38,7 @@
 
   @Override
   public void start() {
-    AsyncAppender asyncAppender =
-        systemLog.createAsyncAppender(logName, layout);
+    AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout, true);
     Logger logger = LogManager.getLogger(logName);
     logger.removeAppender(logName);
     logger.addAppender(asyncAppender);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginRequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginRequestContext.java
index 943e518..3f3f647 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginRequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/PluginRequestContext.java
@@ -38,8 +38,7 @@
     return new Provider<ReviewDb>() {
       @Override
       public ReviewDb get() {
-        throw new ProvisionException(
-            "Automatic ReviewDb only available in request scope");
+        throw new ProvisionException("Automatic ReviewDb only available in request scope");
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RangeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RangeUtil.java
index 92873d3..f7f2cff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RangeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RangeUtil.java
@@ -16,13 +16,11 @@
 
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
-
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 public final class RangeUtil {
-  private static final Pattern RANGE_PATTERN =
-      Pattern.compile("(>|>=|=|<|<=|)([+-]?\\d+)$");
+  private static final Pattern RANGE_PATTERN = Pattern.compile("(>|>=|=|<|<=|)([+-]?\\d+)$");
 
   private RangeUtil() {}
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java
index 0a99a8a..91cb709 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RegexListSearcher.java
@@ -17,16 +17,13 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.base.Function;
-import com.google.common.base.Predicate;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Chars;
-
 import dk.brics.automaton.Automaton;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
-
 import java.util.Collections;
 import java.util.List;
 
@@ -89,17 +86,10 @@
     }
 
     if (prefixOnly) {
-      return begin < end ? list.subList(begin, end) : ImmutableList.<T> of();
+      return begin < end ? list.subList(begin, end) : ImmutableList.<T>of();
     }
 
-    return Iterables.filter(
-        list.subList(begin, end),
-        new Predicate<T>() {
-          @Override
-          public boolean apply(T in) {
-            return pattern.run(RegexListSearcher.this.apply(in));
-          }
-        });
+    return Iterables.filter(list.subList(begin, end), x -> pattern.run(apply(x)));
   }
 
   public boolean hasMatch(List<T> list) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestContext.java
index 506a1c3..37fd7bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestContext.java
@@ -19,10 +19,11 @@
 import com.google.inject.Provider;
 
 /**
- * The RequestContext is an interface exposing the fields that are needed
- * by the GerritGlobalModule scope.
+ * The RequestContext is an interface exposing the fields that are needed by the GerritGlobalModule
+ * scope.
  */
 public interface RequestContext {
   CurrentUser getUser();
+
   Provider<ReviewDb> getReviewDbProvider();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
index 4f43c2a..72c693f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
@@ -19,13 +19,13 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 
 /** Unique identifier for an end-user request, used in logs and similar. */
 public class RequestId {
   private static final String MACHINE_ID;
+
   static {
     String id;
     try {
@@ -46,12 +46,18 @@
 
   private final String str;
 
+  @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
   private RequestId(String resourceId) {
     Hasher h = Hashing.sha1().newHasher();
-    h.putLong(Thread.currentThread().getId())
-        .putUnencodedChars(MACHINE_ID);
-    str = "[" + resourceId + "-" + TimeUtil.nowTs().getTime() +
-        "-" + h.hash().toString().substring(0, 8) + "]";
+    h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
+    str =
+        "["
+            + resourceId
+            + "-"
+            + TimeUtil.nowTs().getTime()
+            + "-"
+            + h.hash().toString().substring(0, 8)
+            + "]";
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 13142fa..4d66809e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -27,21 +27,18 @@
 import com.google.inject.Provider;
 import com.google.inject.Scope;
 import com.google.inject.servlet.ServletScopes;
-
 import java.util.concurrent.Callable;
 import java.util.concurrent.Executors;
 
 /**
  * Base class for propagating request-scoped data between threads.
- * <p>
- * Request scopes are typically linked to a {@link ThreadLocal}, which is only
- * available to the current thread.  In order to allow background work involving
- * RequestScoped data, the ThreadLocal data must be copied from the request thread to
- * the new background thread.
- * <p>
- * Every type of RequestScope must provide an implementation of
- * RequestScopePropagator. See {@link #wrap(Callable)} for details on the
- * implementation, usage, and restrictions.
+ *
+ * <p>Request scopes are typically linked to a {@link ThreadLocal}, which is only available to the
+ * current thread. In order to allow background work involving RequestScoped data, the ThreadLocal
+ * data must be copied from the request thread to the new background thread.
+ *
+ * <p>Every type of RequestScope must provide an implementation of RequestScopePropagator. See
+ * {@link #wrap(Callable)} for details on the implementation, usage, and restrictions.
  *
  * @see ThreadLocalRequestScopePropagator
  */
@@ -51,7 +48,8 @@
   private final ThreadLocalRequestContext local;
   private final Provider<RequestScopedReviewDbProvider> dbProviderProvider;
 
-  protected RequestScopePropagator(Scope scope,
+  protected RequestScopePropagator(
+      Scope scope,
       ThreadLocalRequestContext local,
       Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
     this.scope = scope;
@@ -60,26 +58,24 @@
   }
 
   /**
-   * Ensures that the current request state is available when the passed in
-   * Callable is invoked.
+   * Ensures that the current request state is available when the passed in Callable is invoked.
    *
-   * If needed wraps the passed in Callable in a new {@link Callable} that
-   * propagates the current request state when the returned Callable is invoked.
-   * The method must be called in a request scope and the returned Callable may
-   * only be invoked in a thread that is not already in a request scope or is in
-   * the same request scope. The returned Callable will inherit toString() from
-   * the passed in Callable. A
-   * {@link com.google.gerrit.server.git.WorkQueue.Executor} does not accept a
-   * Callable, so there is no ProjectCallable implementation. Implementations of
-   * this method must be consistent with Guice's
-   * {@link ServletScopes#continueRequest(Callable, java.util.Map)}.
-   * <p>
-   * There are some limitations:
+   * <p>If needed wraps the passed in Callable in a new {@link Callable} that propagates the current
+   * request state when the returned Callable is invoked. The method must be called in a request
+   * scope and the returned Callable may only be invoked in a thread that is not already in a
+   * request scope or is in the same request scope. The returned Callable will inherit toString()
+   * from the passed in Callable. A {@link com.google.gerrit.server.git.WorkQueue.Executor} does not
+   * accept a Callable, so there is no ProjectCallable implementation. Implementations of this
+   * method must be consistent with Guice's {@link ServletScopes#continueRequest(Callable,
+   * java.util.Map)}.
+   *
+   * <p>There are some limitations:
+   *
    * <ul>
-   * <li>Derived objects (i.e. anything marked created in a request scope) will
-   * not be transported.</li>
-   * <li>State changes to the request scoped context after this method is called
-   * will not be seen in the continued thread.</li>
+   *   <li>Derived objects (i.e. anything marked created in a request scope) will not be
+   *       transported.
+   *   <li>State changes to the request scoped context after this method is called will not be seen
+   *       in the continued thread.
    * </ul>
    *
    * @param callable the Callable to wrap.
@@ -88,8 +84,7 @@
   @SuppressWarnings("javadoc") // See GuiceRequestScopePropagator#wrapImpl
   public final <T> Callable<T> wrap(final Callable<T> callable) {
     final RequestContext callerContext = checkNotNull(local.getContext());
-    final Callable<T> wrapped =
-        wrapImpl(context(callerContext, cleanup(callable)));
+    final Callable<T> wrapped = wrapImpl(context(callerContext, cleanup(callable)));
     return new Callable<T>() {
       @Override
       public T call() throws Exception {
@@ -107,15 +102,14 @@
   }
 
   /**
-   * Wraps runnable in a new {@link Runnable} that propagates the current
-   * request state when the runnable is invoked. The method must be called in a
-   * request scope and the returned Runnable may only be invoked in a thread
-   * that is not already in a request scope. The returned Runnable will inherit
-   * toString() from the passed in Runnable. Furthermore, if the passed runnable
-   * is of type {@link ProjectRunnable}, the returned runnable will be of the
-   * same type with the methods delegated.
+   * Wraps runnable in a new {@link Runnable} that propagates the current request state when the
+   * runnable is invoked. The method must be called in a request scope and the returned Runnable may
+   * only be invoked in a thread that is not already in a request scope. The returned Runnable will
+   * inherit toString() from the passed in Runnable. Furthermore, if the passed runnable is of type
+   * {@link ProjectRunnable}, the returned runnable will be of the same type with the methods
+   * delegated.
    *
-   * See {@link #wrap(Callable)} for details on implementation and usage.
+   * <p>See {@link #wrap(Callable)} for details on implementation and usage.
    *
    * @param runnable the Runnable to wrap.
    * @return a new Runnable which will execute in the current request scope.
@@ -130,7 +124,7 @@
           try {
             wrapped.call();
           } catch (Exception e) {
-            Throwables.propagateIfPossible(e);
+            Throwables.throwIfUnchecked(e);
             throw new RuntimeException(e); // Not possible.
           }
         }
@@ -175,27 +169,26 @@
     };
   }
 
-  /**
-   * @see #wrap(Callable)
-   */
-  protected abstract <T> Callable<T> wrapImpl(final Callable<T> callable);
+  /** @see #wrap(Callable) */
+  protected abstract <T> Callable<T> wrapImpl(Callable<T> callable);
 
-  protected <T> Callable<T> context(final RequestContext context,
-      final Callable<T> callable) {
+  protected <T> Callable<T> context(final RequestContext context, final Callable<T> callable) {
     return new Callable<T>() {
       @Override
       public T call() throws Exception {
-        RequestContext old = local.setContext(new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return context.getUser();
-          }
+        RequestContext old =
+            local.setContext(
+                new RequestContext() {
+                  @Override
+                  public CurrentUser getUser() {
+                    return context.getUser();
+                  }
 
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return dbProviderProvider.get();
-          }
-        });
+                  @Override
+                  public Provider<ReviewDb> getReviewDbProvider() {
+                    return dbProviderProvider.get();
+                  }
+                });
         try {
           return callable.call();
         } finally {
@@ -209,14 +202,17 @@
     return new Callable<T>() {
       @Override
       public T call() throws Exception {
-        RequestCleanup cleanup = scope.scope(
-            Key.get(RequestCleanup.class),
-            new Provider<RequestCleanup>() {
-              @Override
-              public RequestCleanup get() {
-                return new RequestCleanup();
-              }
-            }).get();
+        RequestCleanup cleanup =
+            scope
+                .scope(
+                    Key.get(RequestCleanup.class),
+                    new Provider<RequestCleanup>() {
+                      @Override
+                      public RequestCleanup get() {
+                        return new RequestCleanup();
+                      }
+                    })
+                .get();
         try {
           return callable.call();
         } finally {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ServerRequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/ServerRequestContext.java
index ede3365..af903c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/ServerRequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/ServerRequestContext.java
@@ -40,8 +40,7 @@
     return new Provider<ReviewDb>() {
       @Override
       public ReviewDb get() {
-        throw new ProvisionException(
-            "Automatic ReviewDb only available in request scope");
+        throw new ProvisionException("Automatic ReviewDb only available in request scope");
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
index 4991c58..5b22f73 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
@@ -23,8 +23,7 @@
 public final class SocketUtil {
   /** True if this InetAddress is a raw IPv6 in dotted quad notation. */
   public static boolean isIPv6(final InetAddress ip) {
-    return ip instanceof Inet6Address
-        && ip.getHostName().equals(ip.getHostAddress());
+    return ip instanceof Inet6Address && ip.getHostName().equals(ip.getHostAddress());
   }
 
   /** Get the name or IP address, or {@code *} if this address is a wildcard IP. */
@@ -110,12 +109,10 @@
   }
 
   /** Parse and resolve an address string, looking up the IP address. */
-  public static InetSocketAddress resolve(final String desc,
-      final int defaultPort) {
+  public static InetSocketAddress resolve(final String desc, final int defaultPort) {
     final InetSocketAddress addr = parse(desc, defaultPort);
     if (addr.getAddress() != null && addr.getAddress().isAnyLocalAddress()) {
       return addr;
-
     }
     try {
       final InetAddress host = InetAddress.getByName(addr.getHostName());
@@ -125,6 +122,5 @@
     }
   }
 
-  private SocketUtil() {
-  }
+  private SocketUtil() {}
 }
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 6b5c991..61c863b 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,19 +17,17 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.HashSet;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 
 /**
  * It parses from a configuration file submodule sections.
- * <p>
- * Example of submodule sections:
+ *
+ * <p>Example of submodule sections:
  *
  * <pre>
  * [submodule "project-a"]
@@ -49,9 +47,8 @@
   private final String canonicalWebUrl;
   private final Branch.NameKey superProjectBranch;
 
-  public SubmoduleSectionParser(Config bbc,
-      String canonicalWebUrl,
-      Branch.NameKey superProjectBranch) {
+  public SubmoduleSectionParser(
+      Config bbc, String canonicalWebUrl, Branch.NameKey superProjectBranch) {
     this.bbc = bbc;
     this.canonicalWebUrl = canonicalWebUrl;
     this.superProjectBranch = superProjectBranch;
@@ -74,8 +71,12 @@
     String branch = bbc.getString("submodule", id, "branch");
 
     try {
-      if (url != null && url.length() > 0 && path != null && path.length() > 0
-          && branch != null && branch.length() > 0) {
+      if (url != null
+          && url.length() > 0
+          && path != null
+          && path.length() > 0
+          && branch != null
+          && branch.length() > 0) {
         // All required fields filled.
         String project;
 
@@ -107,8 +108,7 @@
           URI thisServerURI = new URI(canonicalWebUrl);
           String thisHost = thisServerURI.getHost();
           String targetHost = targetServerURI.getHost();
-          if (thisHost == null || targetHost == null ||
-              !targetHost.equalsIgnoreCase(thisHost)) {
+          if (thisHost == null || targetHost == null || !targetHost.equalsIgnoreCase(thisHost)) {
             return null;
           }
           String p1 = targetServerURI.getPath();
@@ -128,14 +128,14 @@
         }
 
         if (project.endsWith(Constants.DOT_GIT_EXT)) {
-          project = project.substring(0, //
-              project.length() - Constants.DOT_GIT_EXT.length());
+          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);
+            superProjectBranch, new Branch.NameKey(projectKey, branch), path);
       }
     } catch (URISyntaxException e) {
       // Error in url syntax (in fact it is uri syntax)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java
index c857c40..4efe8f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java
@@ -22,7 +22,8 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.io.IOException;
+import java.nio.file.Path;
 import org.apache.log4j.Appender;
 import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.DailyRollingFileAppender;
@@ -35,13 +36,9 @@
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.nio.file.Path;
-
 @Singleton
 public class SystemLog {
-  private static final org.slf4j.Logger log =
-      LoggerFactory.getLogger(SystemLog.class);
+  private static final org.slf4j.Logger log = LoggerFactory.getLogger(SystemLog.class);
 
   public static final String LOG4J_CONFIGURATION = "log4j.configuration";
 
@@ -73,21 +70,24 @@
   }
 
   public AsyncAppender createAsyncAppender(String name, Layout layout) {
+    return createAsyncAppender(name, layout, false);
+  }
+
+  public AsyncAppender createAsyncAppender(String name, Layout layout, boolean forPlugin) {
     AsyncAppender async = new AsyncAppender();
     async.setName(name);
     async.setBlocking(true);
     async.setBufferSize(config.getInt("core", "asyncLoggingBufferSize", 64));
     async.setLocationInfo(false);
 
-    if (shouldConfigure()) {
+    if (forPlugin || shouldConfigure()) {
       async.addAppender(createAppender(site.logs_dir, name, layout));
     } else {
       Appender appender = LogManager.getLogger(name).getAppender(name);
       if (appender != null) {
         async.addAppender(appender);
       } else {
-        log.warn("No appender with the name: " + name + " was found. " + name
-            + " logging is disabled");
+        log.warn("No appender with the name: {} was found. {} logging is disabled", name, name);
       }
     }
     async.activateOptions();
@@ -104,8 +104,7 @@
 
   private static final class DieErrorHandler implements ErrorHandler {
     @Override
-    public void error(String message, Exception e, int errorCode,
-        LoggingEvent event) {
+    public void error(String message, Exception e, int errorCode, LoggingEvent event) {
       error(e != null ? e.getMessage() : message);
     }
 
@@ -120,19 +119,15 @@
     }
 
     @Override
-    public void activateOptions() {
-    }
+    public void activateOptions() {}
 
     @Override
-    public void setAppender(Appender appender) {
-    }
+    public void setAppender(Appender appender) {}
 
     @Override
-    public void setBackupAppender(Appender appender) {
-    }
+    public void setBackupAppender(Appender appender) {}
 
     @Override
-    public void setLogger(Logger logger) {
-    }
+    public void setLogger(Logger logger) {}
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
index 3e405a9..e065c6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
@@ -29,9 +29,9 @@
 import com.google.inject.name.Names;
 
 /**
- * ThreadLocalRequestContext manages the current RequestContext using a
- * ThreadLocal. When the context is set, the fields exposed by the context
- * are considered in scope. Otherwise, the FallbackRequestContext is used.
+ * ThreadLocalRequestContext manages the current RequestContext using a ThreadLocal. When the
+ * context is set, the fields exposed by the context are considered in scope. Otherwise, the
+ * FallbackRequestContext is used.
  */
 public class ThreadLocalRequestContext {
   private static final String FALLBACK = "FALLBACK";
@@ -41,13 +41,13 @@
       @Override
       protected void configure() {
         bind(ThreadLocalRequestContext.class);
-        bind(RequestContext.class).annotatedWith(Names.named(FALLBACK))
+        bind(RequestContext.class)
+            .annotatedWith(Names.named(FALLBACK))
             .to(FallbackRequestContext.class);
       }
 
       @Provides
-      RequestContext provideRequestContext(
-          @Named(FALLBACK) RequestContext fallback) {
+      RequestContext provideRequestContext(@Named(FALLBACK) RequestContext fallback) {
         return MoreObjects.firstNonNull(local.get(), fallback);
       }
 
@@ -61,8 +61,7 @@
         if (user.isIdentifiedUser()) {
           return user.asIdentifiedUser();
         }
-        throw new ProvisionException(NotSignedInException.MESSAGE,
-            new NotSignedInException());
+        throw new ProvisionException(NotSignedInException.MESSAGE, new NotSignedInException());
       }
 
       @Provides
@@ -75,8 +74,7 @@
   private static final ThreadLocal<RequestContext> local = new ThreadLocal<>();
 
   @Inject
-  ThreadLocalRequestContext() {
-  }
+  ThreadLocalRequestContext() {}
 
   public RequestContext setContext(@Nullable RequestContext ctx) {
     RequestContext old = getContext();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
index a31c7c7..4b27208 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
@@ -18,21 +18,20 @@
 import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
 import com.google.inject.Scope;
-
 import java.util.concurrent.Callable;
 
 /**
- * {@link RequestScopePropagator} implementation for request scopes based on
- * a {@link ThreadLocal} context.
+ * {@link RequestScopePropagator} implementation for request scopes based on a {@link ThreadLocal}
+ * context.
  *
  * @param <C> "context" type stored in the {@link ThreadLocal}.
  */
-public abstract class ThreadLocalRequestScopePropagator<C>
-    extends RequestScopePropagator {
+public abstract class ThreadLocalRequestScopePropagator<C> extends RequestScopePropagator {
 
   private final ThreadLocal<C> threadLocal;
 
-  protected ThreadLocalRequestScopePropagator(Scope scope,
+  protected ThreadLocalRequestScopePropagator(
+      Scope scope,
       ThreadLocal<C> threadLocal,
       ThreadLocalRequestContext local,
       Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
@@ -40,9 +39,7 @@
     this.threadLocal = threadLocal;
   }
 
-  /**
-   * @see RequestScopePropagator#wrap(Callable)
-   */
+  /** @see RequestScopePropagator#wrap(Callable) */
   @Override
   protected final <T> Callable<T> wrapImpl(final Callable<T> callable) {
     final C ctx = continuingContext(requireContext());
@@ -73,15 +70,13 @@
   }
 
   /**
-   * Returns a new context object based on the passed in context that has no
-   * request scoped objects initialized.
-   * <p>
-   * Note that some code paths expect request-scoped objects like
-   * {@code CurrentUser} to be constructible starting from just the context
-   * object returned by this method. For example, in the SSH scope, the context
-   * includes the {@code SshSession}, which is used by
-   * {@code SshCurrentUserProvider} to construct a new {@code CurrentUser} in
-   * the new thread.
+   * Returns a new context object based on the passed in context that has no request scoped objects
+   * initialized.
+   *
+   * <p>Note that some code paths expect request-scoped objects like {@code CurrentUser} to be
+   * constructible starting from just the context object returned by this method. For example, in
+   * the SSH scope, the context includes the {@code SshSession}, which is used by {@code
+   * SshCurrentUserProvider} to construct a new {@code CurrentUser} in the new thread.
    *
    * @param ctx the context to continue.
    * @return a new context.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
index 883f972..8d511f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
@@ -21,7 +21,9 @@
 
   public interface TreeNode {
     String getDisplayName();
+
     boolean isVisible();
+
     SortedSet<? extends TreeNode> getChildren();
   }
 
@@ -62,8 +64,7 @@
     printTree(rootNode, 0, true);
   }
 
-  private void printTree(final TreeNode node, final int level,
-      final boolean isLast) {
+  private void printTree(final TreeNode node, final int level, final boolean isLast) {
     printNode(node, level, isLast);
     final SortedSet<? extends TreeNode> childNodes = node.getChildren();
     int i = 0;
@@ -80,8 +81,7 @@
     }
   }
 
-  private void printNode(final TreeNode node, final int level,
-      final boolean isLast) {
+  private void printNode(final TreeNode node, final int level, final boolean isLast) {
     printIndention(level);
     stdout.print(isLast ? LAST_NODE_PREFIX : NODE_PREFIX);
     if (node.isVisible()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/AssigneeValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
new file mode 100644
index 0000000..a97ce0b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
@@ -0,0 +1,32 @@
+// 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.validators;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+
+/** Listener to provide validation of assignees. */
+@ExtensionPoint
+public interface AssigneeValidationListener {
+  /**
+   * Invoked by Gerrit before the assignee of a change is modified.
+   *
+   * @param change the change on which the assignee is changed
+   * @param assignee the new assignee. Null if removed
+   * @throws ValidationException if validation fails
+   */
+  void validateAssignee(Change change, Account assignee) throws ValidationException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
index 03bdf37..98a09f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/GroupCreationValidationListener.java
@@ -17,19 +17,16 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.account.CreateGroupArgs;
 
-/**
- * Listener to provide validation on group creation.
- */
+/** Listener to provide validation on group creation. */
 @ExtensionPoint
 public interface GroupCreationValidationListener {
   /**
    * Group creation validation.
    *
-   * Invoked by Gerrit just before a new group is going to be created.
+   * <p>Invoked by Gerrit just before a new group is going to be created.
    *
    * @param args arguments for the group creation
    * @throws ValidationException if validation fails
    */
-  void validateNewGroup(CreateGroupArgs args)
-      throws ValidationException;
+  void validateNewGroup(CreateGroupArgs args) throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
index 1baab7c..fbf8e76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/HashtagValidationListener.java
@@ -16,12 +16,9 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.reviewdb.client.Change;
-
 import java.util.Set;
 
-/**
- * Listener to provide validation of hashtag changes.
- */
+/** Listener to provide validation of hashtag changes. */
 @ExtensionPoint
 public interface HashtagValidationListener {
   /**
@@ -32,6 +29,6 @@
    * @param toRemove the hashtags to be removed
    * @throws ValidationException if validation fails
    */
-  void validateHashtags(Change change, Set<String> toAdd,
-      Set<String> toRemove) throws ValidationException;
+  void validateHashtags(Change change, Set<String> toAdd, Set<String> toRemove)
+      throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
index b2899c1..9f152a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
@@ -14,47 +14,40 @@
 
 package com.google.gerrit.server.validators;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.EmailHeader;
-
+import com.google.gerrit.server.mail.send.EmailHeader;
 import java.util.Map;
 import java.util.Set;
 
-/**
- * Listener to provide validation on outgoing email notification.
- */
+/** Listener to provide validation on outgoing email notification. */
 @ExtensionPoint
 public interface OutgoingEmailValidationListener {
-  /**
-   * Arguments supplied to validateOutgoingEmail.
-   */
+  /** Arguments supplied to validateOutgoingEmail. */
   class Args {
     // in arguments
     public String messageClass;
+    @Nullable public String htmlBody;
 
     // in/out arguments
     public Address smtpFromAddress;
     public Set<Address> smtpRcptTo;
-    public String body;
+    public String body; // The text body of the email.
     public Map<String, EmailHeader> headers;
   }
 
   /**
    * Outgoing e-mail validation.
    *
-   * Invoked by Gerrit just before an e-mail is sent, after all e-mail templates
-   * have been applied.
+   * <p>Invoked by Gerrit just before an e-mail is sent, after all e-mail templates have been
+   * applied.
    *
-   * Plugins may modify the following fields in args:
-   * - smtpFromAddress
-   * - smtpRcptTo
-   * - body
-   * - headers
+   * <p>Plugins may modify the following fields in args: - smtpFromAddress - smtpRcptTo - body -
+   * headers
    *
    * @param args E-mail properties. Some are mutable.
    * @throws ValidationException if validation fails.
    */
-  void validateOutgoingEmail(OutgoingEmailValidationListener.Args args)
-      throws ValidationException;
+  void validateOutgoingEmail(OutgoingEmailValidationListener.Args args) throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
index 6012328..adb13a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/ProjectCreationValidationListener.java
@@ -17,19 +17,16 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.project.CreateProjectArgs;
 
-/**
- * Listener to provide validation on project creation.
- */
+/** Listener to provide validation on project creation. */
 @ExtensionPoint
 public interface ProjectCreationValidationListener {
   /**
    * Project creation validation.
    *
-   * Invoked by Gerrit just before a new project is going to be created.
+   * <p>Invoked by Gerrit just before a new project is going to be created.
    *
    * @param args arguments for the project creation
    * @throws ValidationException if validation fails
    */
-  void validateNewProject(CreateProjectArgs args)
-      throws ValidationException;
+  void validateNewProject(CreateProjectArgs args) throws ValidationException;
 }
diff --git a/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java b/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java
index a91bead..c2aaa76 100644
--- a/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/gerrit-server/src/main/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.UserIdentity;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
index b9b6c5a..8b5a33d 100644
--- a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
@@ -8,7 +8,6 @@
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
-
 import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -39,8 +38,7 @@
     Term listHead = Prolog.Nil;
     try {
       ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
-      LabelTypes types =
-          StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes();
+      LabelTypes types = StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes();
 
       for (PatchSetApproval a : cd.currentApprovals()) {
         LabelType t = types.byLabel(a.getLabelId());
@@ -48,18 +46,14 @@
           continue;
         }
 
-        StructureTerm labelTerm = new StructureTerm(
-            sym_label,
-            SymbolTerm.intern(t.getName()),
-            new IntegerTerm(a.getValue()));
+        StructureTerm labelTerm =
+            new StructureTerm(
+                sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.getValue()));
 
-        StructureTerm userTerm = new StructureTerm(
-            sym_user,
-            new IntegerTerm(a.getAccountId().get()));
+        StructureTerm userTerm =
+            new StructureTerm(sym_user, new IntegerTerm(a.getAccountId().get()));
 
-        listHead = new ListTerm(
-            new StructureTerm(sym_commit_label, labelTerm, userTerm),
-            listHead);
+        listHead = new ListTerm(new StructureTerm(sym_commit_label, labelTerm, userTerm), listHead);
       }
     } catch (OrmException err) {
       throw new JavaException(this, 1, err);
diff --git a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
index 8efc2f1..d06664e 100644
--- a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
+++ b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ChangeControl;
-
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java
index ee5bdc9..f050c7f 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_change_branch_1.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java
index b56b036..b9dac68 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_change_owner_1.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java
index e131605..568ef2b 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_change_project_1.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java b/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java
index d1a91d9..534d097 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_change_topic_1.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java
index 9ef68f5..51d0913 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_author_3.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java
index d73ed9b..7fa9ff4 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_committer_3.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java b/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java
index 8fcb98c..97e5219 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_delta_4.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
-
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
@@ -29,15 +28,13 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
-
 import java.util.Iterator;
 import java.util.regex.Pattern;
 
 /**
- * Given a regular expression, checks it against the file list in the most
- * recent patchset of a change. For all files that match the regex, returns the
- * (new) path of the file, the change type, and the old path of the file if
- * applicable (if the file was copied or renamed).
+ * Given a regular expression, checks it against the file list in the most recent patchset of a
+ * change. For all files that match the regex, returns the (new) path of the file, the change type,
+ * and the old path of the file if applicable (if the file was copied or renamed).
  *
  * <pre>
  *   'commit_delta'(+Regex, -ChangeType, -NewPath, -OldPath)
@@ -96,10 +93,9 @@
       Term a4 = engine.r4;
       Term a5 = engine.r5;
 
-      Pattern regex = (Pattern)((JavaObjectTerm)a1).object();
+      Pattern regex = (Pattern) ((JavaObjectTerm) a1).object();
       @SuppressWarnings("unchecked")
-      Iterator<PatchListEntry> iter =
-        (Iterator<PatchListEntry>)((JavaObjectTerm)a5).object();
+      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
       while (iter.hasNext()) {
         PatchListEntry patch = iter.next();
         String newName = patch.getNewName();
@@ -110,8 +106,7 @@
           continue;
         }
 
-        if (regex.matcher(newName).find() ||
-            (oldName != null && regex.matcher(oldName).find())) {
+        if (regex.matcher(newName).find() || (oldName != null && regex.matcher(oldName).find())) {
           SymbolTerm changeSym = getTypeSymbol(changeType);
           SymbolTerm newSym = SymbolTerm.create(newName);
           SymbolTerm oldSym = Prolog.Nil;
@@ -148,8 +143,7 @@
       Term a5 = engine.r5;
 
       @SuppressWarnings("unchecked")
-      Iterator<PatchListEntry> iter =
-        (Iterator<PatchListEntry>)((JavaObjectTerm)a5).object();
+      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
       if (!iter.hasNext()) {
         return engine.fail();
       }
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java b/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java
index c97a964..95be5cb 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.Text;
-
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
@@ -29,7 +28,9 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
-
+import java.io.IOException;
+import java.util.List;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -43,13 +44,8 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
 
-import java.io.IOException;
-import java.util.List;
-import java.util.regex.Pattern;
-
 /**
- * Returns true if any of the files that match FileNameRegex have edited lines
- * that match EditRegex
+ * Returns true if any of the files that match FileNameRegex have edited lines that match EditRegex
  *
  * <pre>
  *   'commit_edits'(+FileNameRegex, +EditRegex)
@@ -98,8 +94,8 @@
           continue;
         }
 
-        if (fileRegex.matcher(newName).find() ||
-            (oldName != null && fileRegex.matcher(oldName).find())) {
+        if (fileRegex.matcher(newName).find()
+            || (oldName != null && fileRegex.matcher(oldName).find())) {
           List<Edit> edits = entry.getEdits();
 
           if (edits.isEmpty()) {
@@ -146,8 +142,8 @@
   }
 
   private Text load(final ObjectId tree, final String path, final ObjectReader reader)
-      throws MissingObjectException, IncorrectObjectTypeException,
-      CorruptObjectException, IOException {
+      throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
+          IOException {
     if (path == null) {
       return Text.EMPTY;
     }
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java b/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java
index 6fc1c2f..16a5b13 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_message_1.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java b/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
index 1dbdb68..6ed82e5 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_stats_3.java
@@ -14,15 +14,17 @@
 
 package gerrit;
 
+import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.patch.PatchList;
-
+import com.google.gerrit.server.patch.PatchListEntry;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
+import java.util.List;
 
 /**
  * Exports basic commit statistics.
@@ -48,15 +50,27 @@
     Term a3 = arg3.dereference();
 
     PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    if (!a1.unify(new IntegerTerm(pl.getPatches().size() - 1),engine.trail)) { //Account for /COMMIT_MSG.
+    // Account for magic files
+    if (!a1.unify(
+        new IntegerTerm(pl.getPatches().size() - countMagicFiles(pl.getPatches())), engine.trail)) {
       return engine.fail();
     }
-    if (!a2.unify(new IntegerTerm(pl.getInsertions()),engine.trail)) {
+    if (!a2.unify(new IntegerTerm(pl.getInsertions()), engine.trail)) {
       return engine.fail();
     }
-    if (!a3.unify(new IntegerTerm(pl.getDeletions()),engine.trail)) {
+    if (!a3.unify(new IntegerTerm(pl.getDeletions()), engine.trail)) {
       return engine.fail();
     }
     return cont;
   }
+
+  private int countMagicFiles(List<PatchListEntry> entries) {
+    int count = 0;
+    for (PatchListEntry e : entries) {
+      if (Patch.isMagic(e.getNewName())) {
+        count++;
+      }
+    }
+    return count;
+  }
 }
diff --git a/gerrit-server/src/main/java/gerrit/PRED_current_user_1.java b/gerrit-server/src/main/java/gerrit/PRED_current_user_1.java
index a3e1a96..6dc1e52 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_current_user_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_current_user_1.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PeerDaemonUser;
-
 import com.googlecode.prolog_cafe.exceptions.EvaluationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -47,8 +46,7 @@
 
     CurrentUser curUser = StoredValues.CURRENT_USER.getOrNull(engine);
     if (curUser == null) {
-      throw new EvaluationException(
-          "Current user not available in this rule type");
+      throw new EvaluationException("Current user not available in this rule type");
     }
     Term resultTerm;
 
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 87c7138..7da1ce8 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
@@ -21,7 +21,6 @@
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
@@ -34,14 +33,13 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
-
 import java.util.Map;
 
 /**
  * Loads a CurrentUser object for a user identity.
- * <p>
- * Values are cached in the hash {@code current_user}, avoiding recreation
- * during a single evaluation.
+ *
+ * <p>Values are cached in the hash {@code current_user}, avoiding recreation during a single
+ * evaluation.
  *
  * <pre>
  *   current_user(user(+AccountId), -CurrentUser).
diff --git a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java b/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
index f93e424..ea3fb17 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
@@ -27,18 +26,17 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
-
 import java.util.List;
 
 /**
  * Obtain a list of label types from the server configuration.
- * <p>
- * Unifies to a Prolog list of: {@code label_type(Label, Fun, Min, Max)}
- * where:
+ *
+ * <p>Unifies to a Prolog list of: {@code label_type(Label, Fun, Min, Max)} where:
+ *
  * <ul>
- * <li>{@code Label} - the newer style label name</li>
- * <li>{@code Fun} - legacy function name</li>
- * <li>{@code Min, Max} - the smallest and largest configured values.</li>
+ *   <li>{@code Label} - the newer style label name
+ *   <li>{@code Fun} - legacy function name
+ *   <li>{@code Min, Max} - the smallest and largest configured values.
  * </ul>
  */
 class PRED_get_legacy_label_types_1 extends Predicate.P1 {
@@ -53,8 +51,7 @@
   public Operation exec(Prolog engine) throws PrologException {
     engine.setB0();
     Term a1 = arg1.dereference();
-    List<LabelType> list =
-        StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes().getLabelTypes();
+    List<LabelType> list = StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes().getLabelTypes();
     Term head = Prolog.Nil;
     for (int idx = list.size() - 1; 0 <= idx; idx--) {
       head = new ListTerm(export(list.get(idx)), head);
@@ -66,13 +63,13 @@
     return cont;
   }
 
-  static final SymbolTerm symLabelType = SymbolTerm.intern(
-      "label_type", 4);
+  static final SymbolTerm symLabelType = SymbolTerm.intern("label_type", 4);
 
   static Term export(LabelType type) {
     LabelValue min = type.getMin();
     LabelValue max = type.getMax();
-    return new StructureTerm(symLabelType,
+    return new StructureTerm(
+        symLabelType,
         SymbolTerm.intern(type.getName()),
         SymbolTerm.intern(type.getFunctionName()),
         min != null ? new IntegerTerm(min.getValue()) : NONE,
diff --git a/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java b/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java
index b1a8a74..cedad9e 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.project.ChangeControl;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/gerrit-server/src/main/java/gerrit/PRED_unresolved_comments_count_1.java b/gerrit-server/src/main/java/gerrit/PRED_unresolved_comments_count_1.java
new file mode 100644
index 0000000..10d5520
--- /dev/null
+++ b/gerrit-server/src/main/java/gerrit/PRED_unresolved_comments_count_1.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gerrit;
+
+import com.google.gerrit.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.Term;
+
+public class PRED_unresolved_comments_count_1 extends Predicate.P1 {
+  public PRED_unresolved_comments_count_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    try {
+      Integer count = StoredValues.CHANGE_DATA.get(engine).unresolvedCommentCount();
+      if (!a1.unify(new IntegerTerm(count != null ? count : 0), engine.trail)) {
+        return engine.fail();
+      }
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    }
+    return cont;
+  }
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java b/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
index bea7c8b..77d31d9 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_uploader_1.java
@@ -15,8 +15,8 @@
 package gerrit;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.rules.StoredValues;
-
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -25,8 +25,12 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class PRED_uploader_1 extends Predicate.P1 {
+  private static final Logger log = LoggerFactory.getLogger(PRED_uploader_1.class);
+
   private static final SymbolTerm user = SymbolTerm.intern("user", 1);
 
   public PRED_uploader_1(Term a1, Operation n) {
@@ -39,10 +43,17 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    Account.Id uploaderId = StoredValues.getPatchSet(engine).getUploader();
+    PatchSet patchSet = StoredValues.getPatchSet(engine);
+    if (patchSet == null) {
+      log.error(
+          "Failed to load current patch set of change "
+              + StoredValues.getChange(engine).getChangeId());
+      return engine.fail();
+    }
 
-    if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())),
-        engine.trail)) {
+    Account.Id uploaderId = patchSet.getUploader();
+
+    if (!a1.unify(new StructureTerm(user, new IntegerTerm(uploaderId.get())), engine.trail)) {
       return engine.fail();
     }
     return cont;
diff --git a/gerrit-server/src/main/prolog/BUCK b/gerrit-server/src/main/prolog/BUCK
deleted file mode 100644
index 09a6553..0000000
--- a/gerrit-server/src/main/prolog/BUCK
+++ /dev/null
@@ -1,8 +0,0 @@
-include_defs('//lib/prolog/prolog.defs')
-
-prolog_cafe_library(
-  name = 'common',
-  srcs = ['gerrit_common.pl'],
-  deps = ['//gerrit-server:server'],
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-server/src/main/prolog/BUILD b/gerrit-server/src/main/prolog/BUILD
index 555cd90..603a0bf 100644
--- a/gerrit-server/src/main/prolog/BUILD
+++ b/gerrit-server/src/main/prolog/BUILD
@@ -1,8 +1,8 @@
-load('//lib/prolog:prolog.bzl', 'prolog_cafe_library')
+load("//lib/prolog:prolog.bzl", "prolog_cafe_library")
 
 prolog_cafe_library(
-  name = 'common',
-  srcs = ['gerrit_common.pl'],
-  deps = ['//gerrit-server:server'],
-  visibility = ['//visibility:public'],
+    name = "common",
+    srcs = ["gerrit_common.pl"],
+    visibility = ["//visibility:public"],
+    deps = ["//gerrit-server:server"],
 )
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
index f05f23b..f34c992 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -1,7 +1,8 @@
 # Changes to this file should also be made in
 # gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
 revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
-reviewerNotFound = {0} does not identify a registered user or group
+reviewerNotFoundUser = {0} does not identify a registered user
+reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
 
 groupIsNotAllowed =  The group {0} cannot be added as reviewer.
 groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
new file mode 100644
index 0000000..50c5fc3
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * .Abandoned template will determine the contents of the email related to a
+ * change being abandoned.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Abandoned autoescape="strict" kind="text"}
+  {$fromName} has abandoned this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
deleted file mode 100644
index accd3b8..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Abandoned.vm
+++ /dev/null
@@ -1,46 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Abandoned.vm template will determine the contents of the email related
-## to a change being abandoned.   It is a ChangeEmail: see ChangeSubject.vm and
-## ChangeFooter.vm.
-##
-$fromName has abandoned this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($coverLetter)
-$coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
new file mode 100644
index 0000000..c7d4699
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -0,0 +1,38 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .AbandonedHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} <strong>abandoned</strong> this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
new file mode 100644
index 0000000..aa2b27d
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .AddKey template will determine the contents of the email related to
+ * adding a new SSH or GPG key to an account.
+ * @param email
+ */
+{template .AddKey autoescape="strict" kind="text"}
+  One or more new {$email.keyType} keys have been added to Gerrit Code Review at
+  {sp}{$email.gerritHost}:
+
+  {\n}
+  {\n}
+
+  {if $email.sshKey}
+    {$email.sshKey}
+  {elseif $email.gpgKeys}
+    {$email.gpgKeys}
+  {/if}
+
+  {\n}
+  {\n}
+
+  If this is not expected, please contact your Gerrit Administrators
+  immediately.
+
+  {\n}
+  {\n}
+
+  You can also manage your {$email.keyType} keys by visiting
+  {\n}
+  {if $email.sshKey}
+    {$email.gerritUrl}#/settings/ssh-keys
+  {elseif $email.gpgKeys}
+    {$email.gerritUrl}#/settings/gpg-keys
+  {/if}
+  {\n}
+  {if $email.userNameEmail}
+    (while signed in as {$email.userNameEmail})
+  {else}
+    (while signed in as {$email.email})
+  {/if}
+
+  {\n}
+  {\n}
+
+  If clicking the link above does not work, copy and paste the URL in a new
+  browser window instead.
+
+  {\n}
+  {\n}
+
+  This is a send-only email address.  Replies to this message will not be read
+  or answered.
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.vm
deleted file mode 100644
index c60ce8b..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKey.vm
+++ /dev/null
@@ -1,61 +0,0 @@
-## Copyright (C) 2015 The Android Open Source Project
-##
-## Licensed under the Apache License, Version 2.0 (the "License");
-## you may not use this file except in compliance with the License.
-## You may obtain a copy of the License at
-##
-## http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The AddKey.vm template will determine the contents of the email
-## related to adding a new SSH or GPG key to an account.
-##
-One or more new ${email.keyType} keys have been added to Gerrit Code Review at ${email.gerritHost}:
-
-#if($email.sshKey)
-$email.sshKey
-#elseif($email.gpgKeys)
-$email.gpgKeys
-#end
-
-If this is not expected, please contact your Gerrit Administrators
-immediately.
-
-You can also manage your ${email.keyType} keys by visiting
-#if($email.sshKey)
-$email.gerritUrl#/settings/ssh-keys
-#elseif($email.gpgKeys)
-$email.gerritUrl#/settings/gpg-keys
-#end
-#if($email.userNameEmail)
-(while signed in as $email.userNameEmail)
-#else
-(while signed in as $email.email)
-#end
-
-If clicking the link above does not work, copy and paste the URL in a
-new browser window instead.
-
-This is a send-only email address.  Replies to this message will not
-be read or answered.
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
new file mode 100644
index 0000000..017fd6d
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -0,0 +1,66 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ */
+{template .AddKeyHtml autoescape="strict" kind="html"}
+  <p>
+    One or more new {$email.keyType} keys have been added to Gerrit Code Review
+    at {$email.gerritHost}:
+  </p>
+
+  {let $keyStyle kind="css"}
+    background: #f0f0f0;
+    border: 1px solid #ccc;
+    color: #555;
+    padding: 12px;
+    width: 400px;
+  {/let}
+
+  {if $email.sshKey}
+    <pre style="{$keyStyle}">{$email.sshKey}</pre>
+  {elseif $email.gpgKeys}
+    <pre style="{$keyStyle}">{$email.gpgKeys}</pre>
+  {/if}
+
+  <p>
+    If this is not expected, please contact your Gerrit Administrators
+    immediately.
+  </p>
+
+  <p>
+    You can also manage your {$email.keyType} keys by following{sp}
+    {if $email.sshKey}
+      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+    {elseif $email.gpgKeys}
+      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+    {/if}
+    {sp}
+    {if $email.userNameEmail}
+      (while signed in as {$email.userNameEmail})
+    {else}
+      (while signed in as {$email.email})
+    {/if}.
+  </p>
+
+  <p>
+    This is a send-only email address.  Replies to this message will not be read
+    or answered.
+  </p>
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
new file mode 100644
index 0000000..a034872
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ChangeFooter template will determine the contents of the footer text
+ * that will be appended to ALL emails related to changes.
+ * @param email
+ */
+{template .ChangeFooter autoescape="strict" kind="text"}
+  --{sp}
+  {\n}
+
+  {if $email.changeUrl}
+    To view, visit {$email.changeUrl}{\n}
+  {/if}
+
+  {if $email.settingsUrl}
+    To unsubscribe, visit {$email.settingsUrl}{\n}
+  {/if}
+
+  {if $email.changeUrl or $email.settingsUrl}
+    {\n}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.vm
deleted file mode 100644
index f1d3e90..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.vm
+++ /dev/null
@@ -1,52 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The ChangeFooter.vm template will determine the contents of the footer
-## text that will be appended to ALL emails related to changes.
-##
-#set ($SPACE = " ")
---$SPACE
-#if ($email.changeUrl)
-To view, visit $email.changeUrl
-#set ($notblank = 1)
-#end
-#if ($email.settingsUrl)
-To unsubscribe, visit $email.settingsUrl
-#set ($notblank = 1)
-#end
-#if ($notblank)
-
-#end
-Gerrit-MessageType: $messageClass
-Gerrit-Change-Id: $changeId
-Gerrit-PatchSet: $patchSet.patchSetId
-Gerrit-Project: $projectName
-Gerrit-Branch: $branch.shortName
-Gerrit-Owner: $email.getNameEmailFor($change.owner)
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
new file mode 100644
index 0000000..61feb57
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -0,0 +1,45 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param email
+ */
+{template .ChangeFooterHtml autoescape="strict" kind="html"}
+  {if $email.changeUrl or $email.settingsUrl}
+    <p>
+      {if $email.changeUrl}
+        To view, visit{sp}
+        <a href="{$email.changeUrl}">change {$change.changeNumber}</a>.
+      {/if}
+      {if $email.changeUrl and $email.settingsUrl}{sp}{/if}
+      {if $email.settingsUrl}
+        To unsubscribe, visit <a href="{$email.settingsUrl}">settings</a>.
+      {/if}
+    </p>
+  {/if}
+
+  {if $email.changeUrl}
+    <div itemscope itemtype="http://schema.org/EmailMessage">
+      <div itemscope itemprop="action" itemtype="http://schema.org/ViewAction">
+        <link itemprop="url" href="{$email.changeUrl}"/>
+        <meta itemprop="name" content="View Change"/>
+      </div>
+    </div>
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
new file mode 100644
index 0000000..98de6e7
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -0,0 +1,28 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ChangeSubject template will determine the contents of the email subject
+ * line for ALL emails related to changes.
+ * @param branch
+ * @param change
+ * @param shortProjectName
+ */
+{template .ChangeSubject autoescape="strict" kind="text"}
+  Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.vm
deleted file mode 100644
index 4fd9a23..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeSubject.vm
+++ /dev/null
@@ -1,42 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The ChangeSubject.vm template will determine the contents of the email
-## subject line for ALL emails related to changes.
-##
-## Optionally $change.originalSubject can be used for the first subject
-## in a change. This allows subject based email clients such as GMail
-## to thread comments together even if subsequent patch sets change the
-## first line of the commit message.
-##
-#macro(ellipsis $length $str)
-#if($str.length() > $length)#set($length = $length - 3)${str.substring(0,$length)}...#else$str#end
-#end
-Change in ${projectName.replaceAll('/.*/', '...')}[$branch.shortName]: #ellipsis(63, $change.subject)
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
new file mode 100644
index 0000000..7bedc1c
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.soy
@@ -0,0 +1,76 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Comment template will determine the contents of the email related to a
+ * user submitting comments on changes.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ * @param commentFiles
+ */
+{template .Comment autoescape="strict" kind="text"}
+  {$fromName} has posted comments on this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}{\n}
+    {\n}
+  {/if}
+
+  {foreach $group in $commentFiles}
+    {$group.link}{\n}
+    {$group.title}:{\n}
+    {\n}
+
+    {foreach $comment in $group.comments}
+      {if $comment.isRobotComment}
+        Robot Comment from {$comment.robotId} (run ID {$comment.robotRunId}):
+        {\n}
+      {/if}
+
+      {foreach $line in $comment.lines}
+        {if isFirst($line)}
+          {if $comment.startLine != 0}
+            {$comment.link}
+          {/if}{\n}
+          {$comment.linePrefix}
+        {else}
+          {$comment.linePrefixEmpty}
+        {/if}
+        {$line}{\n}
+      {/foreach}
+      {if length($comment.lines) == 0}
+        {$comment.linePrefix}{\n}
+      {/if}
+
+      {if $comment.parentMessage}
+        >{sp}{$comment.parentMessage}{\n}
+      {/if}
+      {$comment.message}{\n}
+      {\n}
+      {\n}
+    {/foreach}
+  {/foreach}
+  {\n}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
deleted file mode 100644
index a442311..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Comment.vm
+++ /dev/null
@@ -1,55 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Comment.vm template will determine the contents of the email related to
-## a user submitting comments on changes.  It is a ChangeEmail: see
-## ChangeSubject.vm, ChangeFooter.vm and CommentFooter.vm.
-##
-#if ($email.coverLetter || $email.hasInlineComments())
-$fromName has posted comments on this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($email.coverLetter)
-$email.coverLetter
-
-#end
-##
-## It is possible to increase the span of the quoted lines by using the line
-## count parameter when calling $email.getInlineComments as a function.
-##
-## Example: #if($email.hasInlineComments())$email.getInlineComments(5)#end
-##
-#if($email.hasInlineComments())$email.inlineComments#end
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
new file mode 100644
index 0000000..73fdfba
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.soy
@@ -0,0 +1,25 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .CommentFooter template will determine the contents of the footer text
+ * that will be appended to emails related to a user submitting comments on
+ * changes.
+ */
+{template .CommentFooter autoescape="strict" kind="text"}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.vm
deleted file mode 100644
index e0832e6..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooter.vm
+++ /dev/null
@@ -1,40 +0,0 @@
-## Copyright (C) 2012 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The CommentFooter.vm template will determine the contents of the footer
-## text that will be appended to emails related to a user submitting comments
-## on changes.
-##
-## See ChangeSubject.vm and ChangeFooter.vm.
-#if($email.hasInlineComments())
-Gerrit-HasComments: Yes
-#else
-Gerrit-HasComments: No
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
new file mode 100644
index 0000000..7bf28e7
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -0,0 +1,20 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .CommentFooterHtml autoescape="strict" kind="html"}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
new file mode 100644
index 0000000..59790dc
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -0,0 +1,168 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param commentFiles
+ * @param commentCount
+ * @param email
+ * @param fromName
+ * @param labels
+ * @param patchSet
+ * @param patchSetCommentBlocks
+ */
+{template .CommentHtml autoescape="strict" kind="html"}
+  {let $commentHeaderStyle kind="css"}
+    margin-bottom: 4px;
+  {/let}
+
+  {let $blockquoteStyle kind="css"}
+    border-left: 1px solid #aaa;
+    margin: 10px 0;
+    padding: 0 10px;
+  {/let}
+
+  {let $ulStyle kind="css"}
+    list-style: none;
+    padding-left: 20px;
+  {/let}
+
+  {let $voteStyle kind="css"}
+    border-radius: 3px;
+    display: inline-block;
+    margin: 0 2px;
+    padding: 4px;
+  {/let}
+
+  {let $positiveVoteStyle kind="css"}
+    {$voteStyle}
+    background-color: #d4ffd4;
+  {/let}
+
+  {let $negativeVoteStyle kind="css"}
+    {$voteStyle}
+    background-color: #ffd4d4;
+  {/let}
+
+  {let $neutralVoteStyle kind="css"}
+    {$voteStyle}
+    background-color: #ddd;
+  {/let}
+
+  <p>
+    {$fromName} <strong>posted comments</strong> on this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  <p>
+    Patch set {$patchSet.patchSetId}:
+    {foreach $label in $labels}
+      {if $label.value > 0}
+        <span style="{$positiveVoteStyle}">
+          {$label.label}{sp}+{$label.value}
+        </span>
+      {elseif $label.value < 0}
+        <span style="{$negativeVoteStyle}">
+          {$label.label}{sp}{$label.value}
+        </span>
+      {else}
+        <span style="{$neutralVoteStyle}">
+          -{$label.label}
+        </span>
+      {/if}
+    {/foreach}
+  </p>
+
+  {if $patchSetCommentBlocks}
+    {call .WikiFormat}{param content: $patchSetCommentBlocks /}{/call}
+  {/if}
+
+  {if $commentCount == 1}
+    <p>(1 comment)</p>
+  {elseif $commentCount > 1}
+    <p>({$commentCount} comments)</p>
+  {/if}
+
+  <ul style="{$ulStyle}">
+    {foreach $group in $commentFiles}
+      <li>
+        <p>
+          <a href="{$group.link}">{$group.title}:</a>
+        </p>
+
+        <ul style="{$ulStyle}">
+          {foreach $comment in $group.comments}
+            <li>
+              {if $comment.isRobotComment}
+                <p style="{$commentHeaderStyle}">
+                  Robot Comment from{sp}
+                  {if $comment.robotUrl}<a href="{$comment.robotUrl}">{/if}
+                  {$comment.robotId}
+                  {if $comment.robotUrl}</a>{/if}{sp}
+                  (run ID {$comment.robotRunId}):
+                </p>
+              {/if}
+
+              <p style="{$commentHeaderStyle}">
+                <a href="{$comment.link}">
+                  {if $comment.startLine == 0}
+                    Patch Set #{$group.patchSetId}:
+                  {else}
+                    Patch Set #{$group.patchSetId},{sp}
+                    Line {$comment.startLine}:
+                  {/if}
+                </a>{sp}
+                {if length($comment.lines) == 1}
+                  <code style="font-family:monospace,monospace">
+                    {$comment.lines[0]}
+                  </code>
+                {/if}
+              </p>
+
+              {if length($comment.lines) > 1}
+                <p>
+                  <blockquote style="{$blockquoteStyle}">
+                    {call .Pre}{param content kind="html"}
+                      {foreach $line in $comment.lines}
+                        {$line}{\n}
+                      {/foreach}
+                    {/param}{/call}
+                  </blockquote>
+                </p>
+              {/if}
+
+              {if $comment.parentMessage}
+                <p>
+                  <blockquote style="{$blockquoteStyle}">
+                    {$comment.parentMessage}
+                  </blockquote>
+                </p>
+              {/if}
+
+              {call .WikiFormat}{param content: $comment.messageBlocks /}{/call}
+            </li>
+          {/foreach}
+        </ul>
+      </li>
+    {/foreach}
+  </ul>
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
new file mode 100644
index 0000000..888ee4b
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -0,0 +1,44 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .DeleteReviewer template will determine the contents of the email related
+ * to removal of a reviewer (and the reviewer's votes) from reviews.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .DeleteReviewer autoescape="strict" kind="text"}
+  {$fromName} has removed{sp}
+  {foreach $reviewerName in $email.reviewerNames}
+    {if not isFirst($reviewerName)},{sp}{/if}
+    {$reviewerName}
+  {/foreach}{sp}
+  from this change.{sp}
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
deleted file mode 100644
index 635b716..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewer.vm
+++ /dev/null
@@ -1,47 +0,0 @@
-## Copyright (C) 2016 The Android Open Source Project
-##
-## Licensed under the Apache License, Version 2.0 (the "License");
-## you may not use this file except in compliance with the License.
-## You may obtain a copy of the License at
-##
-## http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The DeleteReviewer.vm template will determine the contents of the email
-## related to removal of a reviewer (and the reviewer's votes) from reviews.
-## It is a ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
-##
-$fromName has removed $email.joinStrings($email.reviewerNames, ', ') from #**
-*#this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($email.coverLetter)
-$email.coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
new file mode 100644
index 0000000..5faa411
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -0,0 +1,43 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ */
+{template .DeleteReviewerHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName}{sp}
+    <strong>
+      removed{sp}
+      {foreach $reviewerName in $email.reviewerNames}
+        {if not isFirst($reviewerName)}
+          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+        {/if}
+        {$reviewerName}
+      {/foreach}
+    </strong>{sp}
+    from this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
new file mode 100644
index 0000000..b249ded
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.soy
@@ -0,0 +1,37 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .DeleteVote template will determine the contents of the email related
+ * to removing votes on changes.
+ * @param change
+ * @param coverLetter
+ * @param fromName
+ */
+{template .DeleteVote autoescape="strict" kind="text"}
+  {$fromName} has removed a vote on this change.{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm
deleted file mode 100644
index 294063e..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVote.vm
+++ /dev/null
@@ -1,44 +0,0 @@
-## Copyright (C) 2016 The Android Open Source Project
-##
-## Licensed under the Apache License, Version 2.0 (the "License");
-## you may not use this file except in compliance with the License.
-## You may obtain a copy of the License at
-##
-## http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The DeleteVote.vm template will determine the contents of the email related
-## to removing votes on changes.  It is a ChangeEmail: see ChangeSubject.vm
-## and ChangeFooter.vm.
-##
-$fromName has removed a vote on this change.
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($coverLetter)
-$coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
new file mode 100644
index 0000000..3d76ae2
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -0,0 +1,38 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .DeleteVoteHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} <strong>removed a vote</strong> from this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
new file mode 100644
index 0000000..24db2fd
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.soy
@@ -0,0 +1,29 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Footer template will determine the contents of the footer text
+ * appended to the end of all outgoing emails after the ChangeFooter and
+ * CommentFooter.
+ * @param footers
+ */
+{template .Footer autoescape="strict" kind="text"}
+  {foreach $footer in $footers}
+    {$footer}{\n}
+  {/foreach}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm
deleted file mode 100644
index 28f29fd..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Footer.vm
+++ /dev/null
@@ -1,33 +0,0 @@
-## Copyright (C) 2013 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Footer.vm template will determine the contents of the footer text
-## appended to the end of all outgoing emails after the ChangeFooter and
-## CommentFooter.
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
new file mode 100644
index 0000000..9f9c503
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -0,0 +1,29 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param footers
+ */
+{template .FooterHtml autoescape="strict" kind="html"}
+  {\n}
+  {\n}
+  {foreach $footer in $footers}
+    <div style="display:none">{sp}{$footer}{sp}</div>{\n}
+  {/foreach}
+  {\n}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
new file mode 100644
index 0000000..fdc3fee
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
@@ -0,0 +1,20 @@
+/**
+ * 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .HeaderHtml autoescape="strict" kind="html"}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
new file mode 100644
index 0000000..d483264
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.soy
@@ -0,0 +1,42 @@
+
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Merged template will determine the contents of the email related to
+ * a change successfully merged to the head.
+ * @param change
+ * @param email
+ * @param fromName
+ */
+{template .Merged autoescape="strict" kind="text"}
+  {$fromName} has submitted this change and it was merged.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {\n}
+  {$email.changeDetail}
+  {$email.approvals}
+  {if $email.includeDiff}
+    {\n}
+    {\n}
+    {$email.unifiedDiff}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
deleted file mode 100644
index 3e49e92..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Merged.vm
+++ /dev/null
@@ -1,47 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Merged.vm template will determine the contents of the email related to
-## a change successfully merged to the head.  It is a ChangeEmail: see
-## ChangeSubject.vm and ChangeFooter.vm.
-##
-$fromName has submitted this change and it was merged.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-$email.changeDetail$email.approvals
-
-#if($email.includeDiff)
-$email.UnifiedDiff
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
new file mode 100644
index 0000000..fa2b44d
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -0,0 +1,41 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ */
+{template .MergedHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} <strong>merged</strong> this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  <div style="white-space:pre-wrap">{$email.approvals}</div>
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.includeDiff}
+    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
new file mode 100644
index 0000000..9f7429f
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -0,0 +1,81 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .NewChange template will determine the contents of the email related to a
+ * user submitting a new change for review.
+ * @param change
+ * @param email
+ * @param ownerName
+ * @param patchSet
+ * @param projectName
+ */
+{template .NewChange autoescape="strict" kind="text"}
+  {if $email.reviewerNames}
+    Hello{sp}
+    {foreach $reviewerName in $email.reviewerNames}
+      {if not isFirst($reviewerName)},{sp}{/if}
+      {$reviewerName}
+    {/foreach},
+
+    {\n}
+    {\n}
+
+    I'd like you to do a code review.
+
+    {if $email.changeUrl}
+      {sp}Please visit
+
+      {\n}
+      {\n}
+
+      {sp}{sp}{sp}{sp}{$email.changeUrl}
+
+      {\n}
+      {\n}
+
+      to review the following change.
+    {/if}
+  {else}
+    {$ownerName} has uploaded this change for review.
+    {if $email.changeUrl} ( {$email.changeUrl}{/if}
+  {/if}{\n}
+
+  {\n}
+  {\n}
+
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+
+  {\n}
+
+  {$email.changeDetail}{\n}
+
+  {if $email.sshHost}
+    {\n}
+    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+    {\n}
+  {/if}
+
+  {if $email.includeDiff}
+    {\n}
+    {$email.unifiedDiff}
+    {\n}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
deleted file mode 100644
index 8b66e81..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChange.vm
+++ /dev/null
@@ -1,60 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The NewChange.vm template will determine the contents of the email related
-## to a user submitting a new change for review. It is a ChangeEmail: see
-## ChangeSubject.vm and ChangeFooter.vm.
-##
-#if($email.reviewerNames)
-Hello $email.joinStrings($email.reviewerNames, ', '),
-
-I'd like you to do a code review.#if($email.changeUrl)  Please visit
-
-    $email.changeUrl
-
-to review the following change.
-#end
-#else
-$fromName has uploaded a new change for review.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-#end
-
-
-Change subject: $change.subject
-......................................................................
-
-$email.changeDetail
-#if($email.sshHost)
-  git pull ssh://$email.sshHost/$projectName $patchSet.refName
-#end
-#if($email.includeDiff)
-
-$email.UnifiedDiff
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
new file mode 100644
index 0000000..559bb26
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -0,0 +1,60 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ * @param ownerName
+ * @param patchSet
+ * @param projectName
+ */
+{template .NewChangeHtml autoescape="strict" kind="html"}
+  <p>
+    {if $email.reviewerNames}
+      {$fromName} would like{sp}
+      {foreach $reviewerName in $email.reviewerNames}
+        {if not isFirst($reviewerName)}
+          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+        {/if}
+        {$reviewerName}
+      {/foreach}{sp}
+      to <strong>review</strong> this change.
+    {else}
+      {$ownerName} has uploaded this change for <strong>review</strong>.
+    {/if}
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.sshHost}
+    {call .Pre}{param content kind="html"}
+      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+          {sp}{$patchSet.refName}
+    {/param}{/call}
+  {/if}
+
+  {if $email.includeDiff}
+    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
new file mode 100644
index 0000000..93353d7
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -0,0 +1,88 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/*
+ * Private templates that cannot be overridden.
+ */
+
+/**
+ * Private template to generate "View Change" buttons.
+ * @param email
+ */
+{template .ViewChangeButton private="true" autoescape="strict" kind="html"}
+  <a href="{$email.changeUrl}">View Change</a>
+{/template}
+
+/**
+ * Private template to render PRE block with consistent font-sizing.
+ * @param content
+ */
+{template .Pre private="true" autoescape="strict" kind="html"}
+  {let $preStyle kind="css"}
+    font-family: monospace,monospace; // Use this to avoid browsers scaling down
+                                      // monospace text.
+    white-space: pre-wrap;
+  {/let}
+  <pre style="{$preStyle}">{$content|changeNewlineToBr}</pre>
+{/template}
+
+/**
+ * Take a list of unescaped comment blocks and emit safely escaped HTML to
+ * render it nicely with wiki-like format.
+ *
+ * Each block is a map with a type key. When the type is 'paragraph', or 'pre',
+ * it also has a 'text' key that maps to the unescaped text content for the
+ * block. If the type is 'list', the map will have a 'items' key which maps to
+ * list of unescaped list item strings. If the type is quote, the map will have
+ * a 'quotedBlocks' key which maps to the blocks contained within the quote.
+ *
+ * This mechanism encodes as little structure as possible in order to depend on
+ * the Soy autoescape mechanism for all of the content.
+ *
+ * @param content
+ */
+{template .WikiFormat private="true" autoescape="strict" kind="html"}
+  {let $blockquoteStyle kind="css"}
+    border-left: 1px solid #aaa;
+    margin: 10px 0;
+    padding: 0 10px;
+  {/let}
+
+  {let $pStyle kind="css"}
+    white-space: pre-wrap;
+    word-wrap: break-word;
+  {/let}
+
+  {foreach $block in $content}
+    {if $block.type == 'paragraph'}
+      <p style="{$pStyle}">{$block.text|changeNewlineToBr}</p>
+    {elseif $block.type == 'quote'}
+      <blockquote style="{$blockquoteStyle}">
+        {call .WikiFormat}{param content: $block.quotedBlocks /}{/call}
+      </blockquote>
+    {elseif $block.type == 'pre'}
+      {call .Pre}{param content: $block.text /}{/call}
+    {elseif $block.type == 'list'}
+      <ul>
+        {foreach $item in $block.items}
+          <li>{$item}</li>
+        {/foreach}
+      </ul>
+    {/if}
+  {/foreach}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
new file mode 100644
index 0000000..2b30ae6
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -0,0 +1,54 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .RegisterNewEmail template will determine the contents of the email
+ * related to registering new email accounts.
+ * @param email
+ */
+{template .RegisterNewEmail autoescape="strict" kind="text"}
+  Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
+
+  {\n}
+
+  To add a verified email address to your user account, please{\n}
+  click on the following link
+  {if $email.userNameEmail}
+    {sp}while signed in as {$email.userNameEmail}
+  {/if}:{\n}
+
+  {\n}
+
+  {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}{\n}
+
+  {\n}
+
+  If you have received this mail in error, you do not need to take any{\n}
+  action to cancel the account.  The address will not be activated, and{\n}
+  you will not receive any further emails.{\n}
+
+  {\n}
+
+  If clicking the link above does not work, copy and paste the URL in a{\n}
+  new browser window instead.{\n}
+
+  {\n}
+
+  This is a send-only email address.  Replies to this message will not{\n}
+  be read or answered.{\n}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.vm
deleted file mode 100644
index 7e095fb..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RegisterNewEmail.vm
+++ /dev/null
@@ -1,49 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The RegisterNewEmail.vm template will determine the contents of the email
-## related to registering new email accounts.
-##
-Welcome to Gerrit Code Review at ${email.gerritHost}.
-
-To add a verified email address to your user account, please
-click on the following link#if($email.userNameEmail) while signed in as $email.userNameEmail#end:
-
-$email.gerritUrl#/VE/$email.emailRegistrationToken
-
-If you have received this mail in error, you do not need to take any
-action to cancel the account.  The address will not be activated, and
-you will not receive any further emails.
-
-If clicking the link above does not work, copy and paste the URL in a
-new browser window instead.
-
-This is a send-only email address.  Replies to this message will not
-be read or answered.
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
new file mode 100644
index 0000000..e41bdda
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -0,0 +1,63 @@
+/**
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .ReplacePatchSet template will determine the contents of the email
+ * related to a user submitting a new patchset for a change.
+ * @param change
+ * @param email
+ * @param fromEmail
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .ReplacePatchSet autoescape="strict" kind="text"}
+  {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
+    Hello{sp}
+    {foreach $reviewerName in $email.reviewerNames}
+      {$reviewerName},{sp}
+    {/foreach}{\n}
+    {\n}
+    I'd like you to reexamine a change.
+    {if $email.changeUrl}
+      {sp}Please visit
+      {\n}
+      {\n}
+      {sp}{sp}{sp}{sp}{$email.changeUrl}
+      {\n}
+      {\n}
+      to look at the new patch set (#{$patchSet.patchSetId}).
+    {/if}
+  {else}
+    {$fromName} has uploaded a new patch set (#{$patchSet.patchSetId})
+    {if $fromEmail != $change.ownerEmail}
+      {sp}to the change originally created by {$change.ownerName}
+    {/if}.
+    {if $email.changeUrl} ( {$email.changeUrl} ){/if}
+  {/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {\n}
+  {$email.changeDetail}{\n}
+  {if $email.sshHost}
+    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
+        {$patchSet.refName}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
deleted file mode 100644
index e45bf30..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSet.vm
+++ /dev/null
@@ -1,56 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The ReplacePatchSet.vm template will determine the contents of the email
-## related to a user submitting a new patchset for a change.  It is a
-## ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
-##
-#if($email.reviewerNames)
-Hello $email.joinStrings($email.reviewerNames, ', '),
-
-I'd like you to reexamine a change.#if($email.changeUrl)  Please visit
-
-    $email.changeUrl
-
-to look at the new patch set (#$patchSet.patchSetId).
-#end
-#else
-$fromName has uploaded a new patch set (#$patchSet.patchSetId).#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-#end
-
-Change subject: $change.subject
-......................................................................
-
-$email.changeDetail
-#if($email.sshHost)
-  git pull ssh://$email.sshHost/$projectName $patchSet.refName
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
new file mode 100644
index 0000000..05c60a1
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -0,0 +1,52 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param email
+ * @param fromName
+ * @param fromEmail
+ * @param patchSet
+ * @param projectName
+ */
+{template .ReplacePatchSetHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
+    to{sp}
+    {if $fromEmail == $change.ownerEmail}
+      this change.
+    {else}
+      the change originally created by {$change.ownerName}.
+    {/if}
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.sshHost}
+    {call .Pre}{param content kind="html"}
+      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
+          {$patchSet.refName}
+    {/param}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
new file mode 100644
index 0000000..14ae0f3
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.soy
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Restored template will determine the contents of the email related to a
+ * change being restored.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Restored autoescape="strict" kind="text"}
+  {$fromName} has restored this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
deleted file mode 100644
index 31e1c69..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Restored.vm
+++ /dev/null
@@ -1,46 +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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Restored.vm template will determine the contents of the email related
-## to a change being restored.   It is a ChangeEmail: see ChangeSubject.vm and
-## ChangeFooter.vm.
-##
-$fromName has restored this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($coverLetter)
-$coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
new file mode 100644
index 0000000..ea4f615
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -0,0 +1,33 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ */
+{template .RestoredHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} <strong>restored</strong> this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
new file mode 100644
index 0000000..cc3d2c9
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .Reverted template will determine the contents of the email related
+ * to a change being reverted.
+ * @param change
+ * @param coverLetter
+ * @param email
+ * @param fromName
+ */
+{template .Reverted autoescape="strict" kind="text"}
+  {$fromName} has created a revert of this change.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
deleted file mode 100644
index 1e9e251..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Reverted.vm
+++ /dev/null
@@ -1,46 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The Reverted.vm template will determine the contents of the email related
-## to a change being reverted.   It is a ChangeEmail: see ChangeSubject.vm and
-## ChangeFooter.vm.
-##
-$fromName has reverted this change.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($coverLetter)
-$coverLetter
-
-#end
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
new file mode 100644
index 0000000..ff3cf24
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -0,0 +1,33 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ */
+{template .RevertedHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} has <strong>created a revert</strong> of this change.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
new file mode 100644
index 0000000..ca4f267
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssignee.soy
@@ -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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .SetAssignee template will determine the contents of the email related
+ * to a user being assigned to a change.
+ * @param change
+ * @param email
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .SetAssignee autoescape="strict" kind="text"}
+  Hello{sp}
+  {$email.assigneeName},
+
+  {\n}
+  {\n}
+
+  {$fromName} has assigned a change to you.
+
+  {sp}Please visit
+
+  {\n}
+  {\n}
+
+  {sp}{sp}{sp}{sp}{$email.changeUrl}
+
+  {\n}
+  {\n}
+
+  to view the change.
+
+  {\n}
+  {\n}
+
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+
+  {\n}
+
+  {$email.changeDetail}{\n}
+
+  {if $email.sshHost}
+    {\n}
+    {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+    {\n}
+  {/if}
+
+  {if $email.includeDiff}
+    {\n}
+    {$email.unifiedDiff}
+    {\n}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
new file mode 100644
index 0000000..bbf16d6
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -0,0 +1,49 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param email
+ * @param fromName
+ * @param patchSet
+ * @param projectName
+ */
+{template .SetAssigneeHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} has <strong>assigned</strong> a change to{sp}
+    {$email.assigneeName}.{sp}
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {call .Pre}{param content: $email.changeDetail /}{/call}
+
+  {if $email.sshHost}
+    {call .Pre}{param content kind="html"}
+      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+          {sp}{$patchSet.refName}
+    {/param}{/call}
+  {/if}
+
+  {if $email.includeDiff}
+    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+  {/if}
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
index d51547c..5a937b6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -97,7 +97,7 @@
 in = text/x-properties
 ini = text/x-properties
 intr = text/x-dylan
-jade = text/x-jade
+jade = text/x-pug
 java = text/x-java
 jl = text/x-julia
 jruby = text/x-ruby
@@ -163,6 +163,7 @@
 ps1 = application/x-powershell
 psd1 = application/x-powershell
 psm1 = application/x-powershell
+pug = text/x-pug
 py = text/x-python
 pyw = text/x-python
 pyx = text/x-cython
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 03f5375..4c64559 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -78,6 +78,9 @@
 	# - in END, the last textLine+ block is available for footer parsing
 	$AWK '
 	BEGIN {
+		if (match(ENVIRON["OS"], "Windows")) {
+			RS="\r?\n" # Required on recent Cygwin
+		}
 		# while we start with the assumption that textLine+
 		# is a footer, the first block is not.
 		isFooter = 0
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
new file mode 100644
index 0000000..0d8b97f
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+#
+# 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.
+#
+# A sceleton script to demonstrate how to use the preview_submit REST API call.
+#
+#
+
+if test -z $server
+then
+        echo "The variable 'server' needs to point to your Gerrit instance"
+        exit 1
+fi
+
+if test -z $changeId
+then
+        echo "The variable 'changeId' must contain a valid change Id"
+        exit 1
+fi
+
+if test -z $gerrituser
+then
+        echo "The variable 'gerrituser' must contain a user/password"
+        exit 1
+fi
+
+curl -u $gerrituser -w '%{http_code}' -o preview \
+    $server/a/changes/$changeId/revisions/current/preview_submit?format=tgz >http_code
+if ! grep 200 http_code >/dev/null
+then
+        # error out:
+        echo "Error previewing submit $changeId due to:"
+        cat preview
+        echo
+else
+        # valid tgz file, extract and obtain a bundle for each project
+        mkdir tmp-bundles
+        (cd tmp-bundles && tar -zxf ../preview)
+        for project in $(cd tmp-bundles && find -type f)
+        do
+                # Projects may contain slashes, so create the required
+                # directory structure
+                mkdir -p $(dirname $project)
+                # $project is in the format of "./path/name/project.git"
+                # remove the leading ./
+                proj=${project:-./}
+                git clone $server/$proj $proj
+
+                # First some nice output:
+                echo "Verify that the bundle is good:"
+                GIT_WORK_TREE=$proj GIT_DIR=$proj/.git \
+                        git bundle verify tmp-bundles/$proj
+                echo "Checking that the bundle only contains one branch..."
+                if test \
+                    "$(GIT_WORK_TREE=$proj GIT_DIR=$proj/.git \
+                    git bundle list-heads tmp-bundles/$proj |wc -l)" != 1
+                then
+                        echo "Submitting $changeId would affect the project"
+                        echo "$proj"
+                        echo "on multiple branches:"
+                        git bundle list-heads
+                        echo "This script does not demonstrate this use case."
+                        exit 1
+                fi
+                # find the target branch:
+                branch=$(GIT_WORK_TREE=$proj GIT_DIR=$proj/.git \
+                    git bundle list-heads tmp-bundles/$proj | awk '{print $2}')
+                echo "found branch $branch"
+                echo "fetch the bundle into the repository"
+                GIT_WORK_TREE=$proj GIT_DIR=$proj/.git \
+                        git fetch tmp-bundles/$proj $branch
+                echo "and checkout the state"
+                git -C $proj checkout FETCH_HEAD
+        done
+        echo "Now run a test for all of: $(cd tmp-bundles && find -type f)"
+fi
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java b/gerrit-server/src/test/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
new file mode 100644
index 0000000..9b21bf6
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class DropWizardMetricMakerTest {
+  DropWizardMetricMaker metrics =
+      new DropWizardMetricMaker(null /* MetricRegistry unused in tests */);
+
+  @Test
+  public void shouldSanitizeUnwantedChars() throws Exception {
+    assertThat(metrics.sanitizeMetricName("very+confusing$long#metric@net/name^1"))
+        .isEqualTo("very_confusing_long_metric_net/name_1");
+    assertThat(metrics.sanitizeMetricName("/metric/submetric")).isEqualTo("_metric/submetric");
+  }
+
+  @Test
+  public void shouldReduceConsecutiveSlashesToOne() throws Exception {
+    assertThat(metrics.sanitizeMetricName("/metric//submetric1///submetric2/submetric3"))
+        .isEqualTo("_metric/submetric1/submetric2/submetric3");
+  }
+
+  @Test
+  public void shouldNotFinishWithSlash() throws Exception {
+    assertThat(metrics.sanitizeMetricName("metric/")).isEqualTo("metric");
+    assertThat(metrics.sanitizeMetricName("metric//")).isEqualTo("metric");
+    assertThat(metrics.sanitizeMetricName("metric/submetric/")).isEqualTo("metric/submetric");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index aa4c4eb..9974bc6 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -16,6 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.codahale.metrics.Counter;
+import com.codahale.metrics.Gauge;
+import com.codahale.metrics.Metric;
+import com.codahale.metrics.MetricRegistry;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.metrics.CallbackMetric0;
@@ -29,52 +33,40 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-
-import com.codahale.metrics.Counter;
-import com.codahale.metrics.Gauge;
-import com.codahale.metrics.Metric;
-import com.codahale.metrics.MetricRegistry;
-
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
 
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-
 public class ProcMetricModuleTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+  @Rule public ExpectedException exception = ExpectedException.none();
 
-  @Inject
-  MetricMaker metrics;
+  @Inject MetricMaker metrics;
 
-  @Inject
-  MetricRegistry registry;
+  @Inject MetricRegistry registry;
 
   @Test
-  public void testConstantBuildLabel() {
+  public void constantBuildLabel() {
     Gauge<String> buildLabel = gauge("build/label");
     assertThat(buildLabel.getValue()).isEqualTo(Version.getVersion());
   }
 
   @Test
-  public void testProcUptime() {
+  public void procUptime() {
     Gauge<Long> birth = gauge("proc/birth_timestamp");
-    assertThat(birth.getValue()).isAtMost(
-        TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()));
+    assertThat(birth.getValue())
+        .isAtMost(TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()));
 
     Gauge<Long> uptime = gauge("proc/uptime");
     assertThat(uptime.getValue()).isAtLeast(1L);
   }
 
   @Test
-  public void testCounter0() {
-    Counter0 cntr = metrics.newCounter(
-        "test/count",
-        new Description("simple test")
-          .setCumulative());
+  public void counter0() {
+    Counter0 cntr =
+        metrics.newCounter("test/count", new Description("simple test").setCumulative());
 
     Counter raw = get("test/count", Counter.class);
     assertThat(raw.getCount()).isEqualTo(0);
@@ -87,12 +79,10 @@
   }
 
   @Test
-  public void testCounter1() {
-    Counter1<String> cntr = metrics.newCounter(
-        "test/count",
-        new Description("simple test")
-          .setCumulative(),
-        Field.ofString("action"));
+  public void counter1() {
+    Counter1<String> cntr =
+        metrics.newCounter(
+            "test/count", new Description("simple test").setCumulative(), Field.ofString("action"));
 
     Counter total = get("test/count_total", Counter.class);
     assertThat(total.getCount()).isEqualTo(0);
@@ -110,13 +100,14 @@
   }
 
   @Test
-  public void testCounterPrefixFields() {
-    Counter1<String> cntr = metrics.newCounter(
-        "test/count",
-        new Description("simple test")
-          .setCumulative()
-          .setFieldOrdering(FieldOrdering.PREFIX_FIELDS_BASENAME),
-        Field.ofString("action"));
+  public void counterPrefixFields() {
+    Counter1<String> cntr =
+        metrics.newCounter(
+            "test/count",
+            new Description("simple test")
+                .setCumulative()
+                .setFieldOrdering(FieldOrdering.PREFIX_FIELDS_BASENAME),
+            Field.ofString("action"));
 
     Counter total = get("test/count_total", Counter.class);
     assertThat(total.getCount()).isEqualTo(0);
@@ -134,21 +125,21 @@
   }
 
   @Test
-  public void testCallbackMetric0() {
-    final CallbackMetric0<Long> cntr = metrics.newCallbackMetric(
-        "test/count",
-        Long.class,
-        new Description("simple test")
-          .setCumulative());
+  public void callbackMetric0() {
+    final CallbackMetric0<Long> cntr =
+        metrics.newCallbackMetric(
+            "test/count", Long.class, new Description("simple test").setCumulative());
 
     final AtomicInteger invocations = new AtomicInteger(0);
-    metrics.newTrigger(cntr, new Runnable() {
-      @Override
-      public void run() {
-        invocations.getAndIncrement();
-        cntr.set(42L);
-      }
-    });
+    metrics.newTrigger(
+        cntr,
+        new Runnable() {
+          @Override
+          public void run() {
+            invocations.getAndIncrement();
+            cntr.set(42L);
+          }
+        });
 
     // Triggers run immediately with DropWizard binding.
     assertThat(invocations.get()).isEqualTo(1);
@@ -161,13 +152,13 @@
   }
 
   @Test
-  public void testInvalidName1() {
+  public void invalidName1() {
     exception.expect(IllegalArgumentException.class);
     metrics.newCounter("invalid name", new Description("fail"));
   }
 
   @Test
-  public void testInvalidName2() {
+  public void invalidName2() {
     exception.expect(IllegalArgumentException.class);
     metrics.newCounter("invalid/ name", new Description("fail"));
   }
@@ -189,8 +180,7 @@
 
   @Before
   public void setup() {
-    Injector injector =
-        Guice.createInjector(new DropWizardMetricMaker.ApiModule());
+    Injector injector = Guice.createInjector(new DropWizardMetricMaker.ApiModule());
 
     LifecycleManager mgr = new LifecycleManager();
     mgr.add(injector);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
index e23867f..fa4a951 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
@@ -20,49 +20,41 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
 import com.google.inject.AbstractModule;
-
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
 import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
-
+import java.io.PushbackReader;
+import java.io.StringReader;
+import java.util.Arrays;
 import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.PushbackReader;
-import java.io.StringReader;
-import java.util.Arrays;
-
 public class GerritCommonTest extends PrologTestCase {
   @Before
   public void setUp() throws Exception {
-    load("gerrit", "gerrit_common_test.pl", new AbstractModule() {
-      @Override
-      protected void configure() {
-        Config cfg = new Config();
-        cfg.setInt("rules", null, "reductionLimit", 1300);
-        cfg.setInt("rules", null, "compileReductionLimit", (int) 1e6);
-        bind(PrologEnvironment.Args.class).toInstance(
-            new PrologEnvironment.Args(
-                null,
-                null,
-                null,
-                null,
-                null,
-                null,
-                cfg));
-      }
-    });
+    load(
+        "gerrit",
+        "gerrit_common_test.pl",
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            Config cfg = new Config();
+            cfg.setInt("rules", null, "reductionLimit", 1300);
+            cfg.setInt("rules", null, "compileReductionLimit", (int) 1e6);
+            bind(PrologEnvironment.Args.class)
+                .toInstance(new PrologEnvironment.Args(null, null, null, null, null, null, cfg));
+          }
+        });
   }
 
   @Override
   protected void setUpEnvironment(PrologEnvironment env) {
-    LabelTypes labelTypes =
-        new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
+    LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
     ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
     expect(ctl.getLabelTypes()).andStubReturn(labelTypes);
     EasyMock.replay(ctl);
@@ -70,29 +62,29 @@
   }
 
   @Test
-  public void testGerritCommon() {
+  public void gerritCommon() {
     runPrologBasedTests();
   }
 
   @Test
-  public void testReductionLimit() throws CompileException {
+  public void reductionLimit() throws CompileException {
     PrologEnvironment env = envFactory.create(machine);
     setUpEnvironment(env);
 
-    String script = "loopy :- b(5).\n"
-        + "b(N) :- N > 0, !, S = N - 1, b(S).\n"
-        + "b(_) :- true.\n";
+    String script = "loopy :- b(5).\nb(N) :- N > 0, !, S = N - 1, b(S).\nb(_) :- true.\n";
 
     SymbolTerm nameTerm = SymbolTerm.create("testReductionLimit");
-    JavaObjectTerm inTerm = new JavaObjectTerm(
-        new PushbackReader(new StringReader(script), Prolog.PUSHBACK_SIZE));
+    JavaObjectTerm inTerm =
+        new JavaObjectTerm(new PushbackReader(new StringReader(script), Prolog.PUSHBACK_SIZE));
     if (!env.execute(Prolog.BUILTIN, "consult_stream", nameTerm, inTerm)) {
       throw new CompileException("Cannot consult " + nameTerm);
     }
 
     exception.expect(ReductionLimitException.class);
     exception.expectMessage("exceeded reduction limit of 1300");
-    env.once(Prolog.BUILTIN, "call", new StructureTerm(":",
-        SymbolTerm.create("user"), SymbolTerm.create("loopy")));
+    env.once(
+        Prolog.BUILTIN,
+        "call",
+        new StructureTerm(":", SymbolTerm.create("user"), SymbolTerm.create("loopy")));
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
index dc8004a..6f6d189 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.inject.Guice;
 import com.google.inject.Module;
-
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.lang.BufferingPrologControl;
 import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
@@ -33,7 +32,6 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
-
 import java.io.BufferedReader;
 import java.io.FileNotFoundException;
 import java.io.IOException;
@@ -44,7 +42,6 @@
 import java.util.Arrays;
 import java.util.List;
 
-
 /** Base class for any tests written in Prolog. */
 public abstract class PrologTestCase extends GerritBaseTests {
   private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
@@ -62,8 +59,7 @@
     moduleList.add(new PrologModule.EnvironmentModule());
     moduleList.addAll(Arrays.asList(modules));
 
-    envFactory = Guice.createInjector(moduleList)
-        .getInstance(PrologEnvironment.Factory.class);
+    envFactory = Guice.createInjector(moduleList).getInstance(PrologEnvironment.Factory.class);
     PrologEnvironment env = envFactory.create(newMachine());
     consult(env, getClass(), prologResource);
 
@@ -71,9 +67,9 @@
     hasSetup = has(env, "setup");
     hasTeardown = has(env, "teardown");
 
-    StructureTerm head = new StructureTerm(":",
-        SymbolTerm.intern(pkg),
-        new StructureTerm(test_1, new VariableTerm()));
+    StructureTerm head =
+        new StructureTerm(
+            ":", SymbolTerm.intern(pkg), new StructureTerm(test_1, new VariableTerm()));
 
     tests = new ArrayList<>();
     for (Term[] pair : env.all(Prolog.BUILTIN, "clause", head, new VariableTerm())) {
@@ -88,8 +84,7 @@
    *
    * @param env Prolog environment.
    */
-  protected void setUpEnvironment(PrologEnvironment env) {
-  }
+  protected void setUpEnvironment(PrologEnvironment env) {}
 
   private PrologMachineCopy newMachine() {
     BufferingPrologControl ctl = new BufferingPrologControl();
@@ -98,17 +93,17 @@
     return PrologMachineCopy.save(ctl);
   }
 
-  protected void consult(BufferingPrologControl env,
-      Class<?> clazz,
-      String prologResource) throws CompileException, IOException {
+  protected void consult(BufferingPrologControl env, Class<?> clazz, String prologResource)
+      throws CompileException, IOException {
     try (InputStream in = clazz.getResourceAsStream(prologResource)) {
       if (in == null) {
         throw new FileNotFoundException(prologResource);
       }
       SymbolTerm pathTerm = SymbolTerm.create(prologResource);
       JavaObjectTerm inTerm =
-          new JavaObjectTerm(new PushbackReader(new BufferedReader(
-              new InputStreamReader(in, UTF_8)), Prolog.PUSHBACK_SIZE));
+          new JavaObjectTerm(
+              new PushbackReader(
+                  new BufferedReader(new InputStreamReader(in, UTF_8)), Prolog.PUSHBACK_SIZE));
       if (!env.execute(Prolog.BUILTIN, "consult_stream", pathTerm, inTerm)) {
         throw new CompileException("Cannot consult " + prologResource);
       }
@@ -161,13 +156,14 @@
       }
 
       if (all.size() != 1) {
-       errors++;
+        errors++;
       }
     }
 
     long end = TimeUtil.nowMs();
     System.out.println("-------------------------------");
-    System.out.format("Prolog tests: %d, Failures: %d, Time elapsed %.3f sec",
+    System.out.format(
+        "Prolog tests: %d, Failures: %d, Time elapsed %.3f sec",
         tests.size(), errors, (end - start) / 1000.0);
     System.out.println();
 
@@ -177,9 +173,9 @@
   private void call(BufferingPrologControl env, String name) {
     StructureTerm head = SymbolTerm.create(pkg, name, 0);
     assert_()
-      .withFailureMessage("Cannot invoke " + pkg + ":" + name)
-      .that(env.execute(Prolog.BUILTIN, "call", head))
-      .isTrue();
+        .withFailureMessage("Cannot invoke " + pkg + ":" + name)
+        .that(env.execute(Prolog.BUILTIN, "call", head))
+        .isTrue();
   }
 
   private Term removePackage(Term test) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ChangeUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/ChangeUtilTest.java
new file mode 100644
index 0000000..5f73d2c
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/ChangeUtilTest.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.regex.Pattern;
+import org.junit.Test;
+
+public class ChangeUtilTest {
+  @Test
+  public void changeMessageUuid() throws Exception {
+    Pattern pat = Pattern.compile("^[0-9a-f]{8}_[0-9a-f]{8}$");
+    assertThat("abcd1234_0987fedc").matches(pat);
+
+    String id1 = ChangeUtil.messageUuid();
+    assertThat(id1).matches(pat);
+
+    String id2 = ChangeUtil.messageUuid();
+    assertThat(id2).isNotEqualTo(id1);
+    assertThat(id2).matches(pat);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
index 0d2de399..4689688 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
@@ -37,25 +37,21 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.util.Providers;
-
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Set;
-
 @RunWith(ConfigSuite.class)
 public class IdentifiedUserTest {
-  @ConfigSuite.Parameter
-  public Config config;
+  @ConfigSuite.Parameter public Config config;
 
   private IdentifiedUser identifiedUser;
 
-  @Inject
-  private IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject private IdentifiedUser.GenericFactory identifiedUserFactory;
 
   private static final String[] TEST_CASES = {
     "",
@@ -66,37 +62,42 @@
   @Before
   public void setUp() throws Exception {
     final FakeAccountCache accountCache = new FakeAccountCache();
-    final Realm mockRealm = new FakeRealm() {
-      HashSet<String> emails = new HashSet<>(Arrays.asList(TEST_CASES));
+    final Realm mockRealm =
+        new FakeRealm() {
+          HashSet<String> emails = new HashSet<>(Arrays.asList(TEST_CASES));
 
-      @Override
-      public boolean hasEmailAddress(IdentifiedUser who, String email) {
-        return emails.contains(email);
-      }
+          @Override
+          public boolean hasEmailAddress(IdentifiedUser who, String email) {
+            return emails.contains(email);
+          }
 
-      @Override
-      public Set<String> getEmailAddresses(IdentifiedUser who) {
-        return emails;
-      }
-    };
+          @Override
+          public Set<String> getEmailAddresses(IdentifiedUser who) {
+            return emails;
+          }
+        };
 
-    AbstractModule mod = new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(Boolean.class).annotatedWith(DisableReverseDnsLookup.class)
-          .toInstance(Boolean.FALSE);
-        bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(config);
-        bind(String.class).annotatedWith(AnonymousCowardName.class)
-          .toProvider(AnonymousCowardNameProvider.class);
-        bind(String.class).annotatedWith(CanonicalWebUrl.class)
-          .toInstance("http://localhost:8080/");
-        bind(AccountCache.class).toInstance(accountCache);
-        bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
-        bind(CapabilityControl.Factory.class)
-          .toProvider(Providers.<CapabilityControl.Factory>of(null));
-        bind(Realm.class).toInstance(mockRealm);
-      }
-    };
+    AbstractModule mod =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(Boolean.class)
+                .annotatedWith(DisableReverseDnsLookup.class)
+                .toInstance(Boolean.FALSE);
+            bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(config);
+            bind(String.class)
+                .annotatedWith(AnonymousCowardName.class)
+                .toProvider(AnonymousCowardNameProvider.class);
+            bind(String.class)
+                .annotatedWith(CanonicalWebUrl.class)
+                .toInstance("http://localhost:8080/");
+            bind(AccountCache.class).toInstance(accountCache);
+            bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
+            bind(CapabilityControl.Factory.class)
+                .toProvider(Providers.<CapabilityControl.Factory>of(null));
+            bind(Realm.class).toInstance(mockRealm);
+          }
+        };
 
     Injector injector = Guice.createInjector(mod);
     injector.injectMembers(this);
@@ -111,7 +112,7 @@
   }
 
   @Test
-  public void testEmailsExistence() {
+  public void emailsExistence() {
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[0])).isTrue();
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toLowerCase())).isTrue();
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
@@ -122,7 +123,6 @@
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2])).isTrue();
     assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2].toLowerCase())).isTrue();
 
-
     assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
     /* assert again to test cached email address by IdentifiedUser.invalidEmails */
     assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java
index 0646eef0..acf2577 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/StringUtilTest.java
@@ -19,39 +19,31 @@
 import org.junit.Test;
 
 public class StringUtilTest {
-  /**
-   * Test the boundary condition that the first character of a string
-   * should be escaped.
-   */
+  /** Test the boundary condition that the first character of a string should be escaped. */
   @Test
-  public void testEscapeFirstChar() {
+  public void escapeFirstChar() {
     assertEquals(StringUtil.escapeString("\tLeading tab"), "\\tLeading tab");
   }
 
-  /**
-   * Test the boundary condition that the last character of a string
-   * should be escaped.
-   */
+  /** Test the boundary condition that the last character of a string should be escaped. */
   @Test
-  public void testEscapeLastChar() {
+  public void escapeLastChar() {
     assertEquals(StringUtil.escapeString("Trailing tab\t"), "Trailing tab\\t");
   }
 
-  /**
-   * Test that various forms of input strings are escaped (or left as-is)
-   * in the expected way.
-   */
+  /** Test that various forms of input strings are escaped (or left as-is) in the expected way. */
   @Test
-  public void testEscapeString() {
-    final String[] testPairs =
-      { "", "",
-        "plain string", "plain string",
-        "string with \"quotes\"", "string with \"quotes\"",
-        "string with 'quotes'", "string with 'quotes'",
-        "string with 'quotes'", "string with 'quotes'",
-        "C:\\Program Files\\MyProgram", "C:\\\\Program Files\\\\MyProgram",
-        "string\nwith\nnewlines", "string\\nwith\\nnewlines",
-        "string\twith\ttabs", "string\\twith\\ttabs", };
+  public void escapeString() {
+    final String[] testPairs = {
+      "", "",
+      "plain string", "plain string",
+      "string with \"quotes\"", "string with \"quotes\"",
+      "string with 'quotes'", "string with 'quotes'",
+      "string with 'quotes'", "string with 'quotes'",
+      "C:\\Program Files\\MyProgram", "C:\\\\Program Files\\\\MyProgram",
+      "string\nwith\nnewlines", "string\\nwith\\nnewlines",
+      "string\twith\ttabs", "string\\twith\\ttabs",
+    };
     for (int i = 0; i < testPairs.length; i += 2) {
       assertEquals(StringUtil.escapeString(testPairs[i]), testPairs[i + 1]);
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
index f5849c1..4b43197 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -16,41 +16,39 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.base.Optional;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-
-import org.junit.Test;
-
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
+import org.junit.Test;
 
 public class AuthorizedKeysTest {
   private static final String KEY1 =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
-      + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
-      + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
-      + "w== john.doe@example.com";
+          + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
+          + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
+          + "w== john.doe@example.com";
   private static final String KEY2 =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDm5yP7FmEoqzQRDyskX+9+N0q9GrvZeh5"
-      + "RG52EUpE4ms/Ujm3ewV1LoGzc/lYKJAIbdcZQNJ9+06EfWZaIRA3oOwAPe1eCnX+aLr8E"
-      + "6Tw2gDMQOGc5e9HfyXpC2pDvzauoZNYqLALOG3y/1xjo7IH8GYRS2B7zO/Mf9DdCcCKSf"
-      + "w== john.doe@example.com";
+          + "RG52EUpE4ms/Ujm3ewV1LoGzc/lYKJAIbdcZQNJ9+06EfWZaIRA3oOwAPe1eCnX+aLr8E"
+          + "6Tw2gDMQOGc5e9HfyXpC2pDvzauoZNYqLALOG3y/1xjo7IH8GYRS2B7zO/Mf9DdCcCKSf"
+          + "w== john.doe@example.com";
   private static final String KEY3 =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCaS7RHEcZ/zjl9hkWkqnm29RNr2OQ/TZ5"
-      + "jk2qBVMH3BgzPsTsEs+7ag9tfD8OCj+vOcwm626mQBZoR2e3niHa/9gnHBHFtOrGfzKbp"
-      + "RjTWtiOZbB9HF+rqMVD+Dawo/oicX/dDg7VAgOFSPothe6RMhbgWf84UcK5aQd5eP5y+t"
-      + "Q== john.doe@example.com";
+          + "jk2qBVMH3BgzPsTsEs+7ag9tfD8OCj+vOcwm626mQBZoR2e3niHa/9gnHBHFtOrGfzKbp"
+          + "RjTWtiOZbB9HF+rqMVD+Dawo/oicX/dDg7VAgOFSPothe6RMhbgWf84UcK5aQd5eP5y+t"
+          + "Q== john.doe@example.com";
   private static final String KEY4 =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDIJzW9BaAeO+upFletwwEBnGS15lJmS5i"
-      + "08/NiFef0jXtNNKcLtnd13bq8jOi5VA2is0bwof1c8YbwcvUkdFa8RL5aXoyZBpfYZsWs"
-      + "/YBLZGiHy5rjooMZQMnH37A50cBPnXr0AQz0WRBxLDBDyOZho+O/DfYAKv4rzPSQ3yw4+"
-      + "w== john.doe@example.com";
+          + "08/NiFef0jXtNNKcLtnd13bq8jOi5VA2is0bwof1c8YbwcvUkdFa8RL5aXoyZBpfYZsWs"
+          + "/YBLZGiHy5rjooMZQMnH37A50cBPnXr0AQz0WRBxLDBDyOZho+O/DfYAKv4rzPSQ3yw4+"
+          + "w== john.doe@example.com";
   private static final String KEY5 =
       "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgBRKGhiXvY6D9sM+Vbth5Kate57YF7kD"
-      + "rqIyUiYIMJK93/AXc8qR/J/p3OIFQAxvLz1qozAur3j5HaiwvxVU19IiSA0vafdhaDLRi"
-      + "zRuEL5e/QOu9yGq9xkWApCmg6edpWAHG+Bx4AldU78MiZvzoB7gMMdxc9RmZ1gYj/DjxV"
-      + "w== john.doe@example.com";
+          + "rqIyUiYIMJK93/AXc8qR/J/p3OIFQAxvLz1qozAur3j5HaiwvxVU19IiSA0vafdhaDLRi"
+          + "zRuEL5e/QOu9yGq9xkWApCmg6edpWAHG+Bx4AldU78MiZvzoB7gMMdxc9RmZ1gYj/DjxV"
+          + "w== john.doe@example.com";
 
   @Test
   public void test() throws Exception {
@@ -85,7 +83,7 @@
   }
 
   @Test
-  public void testParseWindowsLineEndings() throws Exception {
+  public void parseWindowsLineEndings() throws Exception {
     List<Optional<AccountSshKey>> keys = new ArrayList<>();
     StringBuilder authorizedKeys = new StringBuilder();
     authorizedKeys.append(toWindowsLineEndings(addKey(keys, KEY1)));
@@ -105,26 +103,25 @@
 
     authorizedKeys.append(toWindowsLineEndings(addKey(keys, KEY5)));
     assertParse(authorizedKeys, keys);
-
   }
 
   private static String toWindowsLineEndings(String s) {
     return s.replaceAll("\n", "\r\n");
   }
 
-  private static void assertSerialization(List<Optional<AccountSshKey>> keys,
-      StringBuilder expected) {
+  private static void assertSerialization(
+      List<Optional<AccountSshKey>> keys, StringBuilder expected) {
     assertThat(AuthorizedKeys.serialize(keys)).isEqualTo(expected.toString());
   }
 
-  private static void assertParse(StringBuilder authorizedKeys,
-      List<Optional<AccountSshKey>> expectedKeys) {
+  private static void assertParse(
+      StringBuilder authorizedKeys, List<Optional<AccountSshKey>> expectedKeys) {
     Account.Id accountId = new Account.Id(1);
     List<Optional<AccountSshKey>> parsedKeys =
         AuthorizedKeys.parse(accountId, authorizedKeys.toString());
     assertThat(parsedKeys).containsExactlyElementsIn(expectedKeys);
     int seq = 1;
-    for(Optional<AccountSshKey> sshKey : parsedKeys) {
+    for (Optional<AccountSshKey> sshKey : parsedKeys) {
       if (sshKey.isPresent()) {
         assertThat(sshKey.get().getAccount()).isEqualTo(accountId);
         assertThat(sshKey.get().getKey().get()).isEqualTo(seq);
@@ -139,8 +136,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addKey(List<Optional<AccountSshKey>> keys, String pub) {
-    AccountSshKey.Id keyId =
-        new AccountSshKey.Id(new Account.Id(1), keys.size() + 1);
+    AccountSshKey.Id keyId = new AccountSshKey.Id(new Account.Id(1), keys.size() + 1);
     AccountSshKey key = new AccountSshKey(keyId, pub);
     keys.add(Optional.of(key));
     return key.getSshPublicKey() + "\n";
@@ -151,15 +147,12 @@
    *
    * @return the expected line for this key in the authorized_keys file
    */
-  private static String addInvalidKey(List<Optional<AccountSshKey>> keys,
-      String pub) {
-    AccountSshKey.Id keyId =
-        new AccountSshKey.Id(new Account.Id(1), keys.size() + 1);
+  private static String addInvalidKey(List<Optional<AccountSshKey>> keys, String pub) {
+    AccountSshKey.Id keyId = new AccountSshKey.Id(new Account.Id(1), keys.size() + 1);
     AccountSshKey key = new AccountSshKey(keyId, pub);
     key.setInvalid();
     keys.add(Optional.of(key));
-    return AuthorizedKeys.INVALID_KEY_COMMENT_PREFIX
-        + key.getSshPublicKey() + "\n";
+    return AuthorizedKeys.INVALID_KEY_COMMENT_PREFIX + key.getSshPublicKey() + "\n";
   }
 
   /**
@@ -168,7 +161,7 @@
    * @return the expected line for this key in the authorized_keys file
    */
   private static String addDeletedKey(List<Optional<AccountSshKey>> keys) {
-    keys.add(Optional.<AccountSshKey> absent());
+    keys.add(Optional.empty());
     return AuthorizedKeys.DELETED_KEY_COMMENT + "\n";
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/HashedPasswordTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/HashedPasswordTest.java
new file mode 100644
index 0000000..4955c06
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/HashedPasswordTest.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Strings;
+import org.apache.commons.codec.DecoderException;
+import org.junit.Test;
+
+public class HashedPasswordTest {
+
+  @Test
+  public void encodeOneLine() throws Exception {
+    String password = "secret";
+    HashedPassword hashed = HashedPassword.fromPassword(password);
+    assertThat(hashed.encode()).doesNotContain("\n");
+    assertThat(hashed.encode()).doesNotContain("\r");
+  }
+
+  @Test
+  public void encodeDecode() throws Exception {
+    String password = "secret";
+    HashedPassword hashed = HashedPassword.fromPassword(password);
+    HashedPassword roundtrip = HashedPassword.decode(hashed.encode());
+    assertThat(hashed.encode()).isEqualTo(roundtrip.encode());
+    assertThat(roundtrip.checkPassword(password)).isTrue();
+    assertThat(roundtrip.checkPassword("not the password")).isFalse();
+  }
+
+  @Test(expected = DecoderException.class)
+  public void invalidDecode() throws Exception {
+    HashedPassword.decode("invalid");
+  }
+
+  @Test
+  public void lengthLimit() throws Exception {
+    String password = Strings.repeat("1", 72);
+
+    // make sure it fits in varchar(255).
+    assertThat(HashedPassword.fromPassword(password).encode().length()).isLessThan(255);
+  }
+
+  @Test
+  public void basicFunctionality() throws Exception {
+    String password = "secret";
+    HashedPassword hashed = HashedPassword.fromPassword(password);
+
+    assertThat(hashed.checkPassword("false")).isFalse();
+    assertThat(hashed.checkPassword(password)).isTrue();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 3cec25c..5444c5b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -35,22 +35,15 @@
 import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-
+import com.google.gerrit.testutil.GerritBaseTests;
+import java.util.Set;
 import org.easymock.IAnswer;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.Set;
-
-public class UniversalGroupBackendTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
-  private static final AccountGroup.UUID OTHER_UUID =
-      new AccountGroup.UUID("other");
+public class UniversalGroupBackendTest extends GerritBaseTests {
+  private static final AccountGroup.UUID OTHER_UUID = new AccountGroup.UUID("other");
 
   private UniversalGroupBackend backend;
   private IdentifiedUser user;
@@ -62,35 +55,33 @@
     user = createNiceMock(IdentifiedUser.class);
     replay(user);
     backends = new DynamicSet<>();
-    backends.add(new SystemGroupBackend());
+    backends.add(new SystemGroupBackend(new Config()));
     backend = new UniversalGroupBackend(backends);
   }
 
   @Test
-  public void testHandles() {
+  public void handles() {
     assertTrue(backend.handles(ANONYMOUS_USERS));
     assertTrue(backend.handles(PROJECT_OWNERS));
     assertFalse(backend.handles(OTHER_UUID));
   }
 
   @Test
-  public void testGet() {
-    assertEquals("Registered Users",
-        backend.get(REGISTERED_USERS).getName());
-    assertEquals("Project Owners",
-        backend.get(PROJECT_OWNERS).getName());
+  public void get() {
+    assertEquals("Registered Users", backend.get(REGISTERED_USERS).getName());
+    assertEquals("Project Owners", backend.get(PROJECT_OWNERS).getName());
     assertNull(backend.get(OTHER_UUID));
   }
 
   @Test
-  public void testSuggest() {
+  public void suggest() {
     assertTrue(backend.suggest("X", null).isEmpty());
     assertEquals(1, backend.suggest("project", null).size());
     assertEquals(1, backend.suggest("reg", null).size());
   }
 
   @Test
-  public void testSytemGroupMemberships() {
+  public void sytemGroupMemberships() {
     GroupMembership checker = backend.membershipsOf(user);
     assertTrue(checker.contains(REGISTERED_USERS));
     assertFalse(checker.contains(OTHER_UUID));
@@ -98,7 +89,7 @@
   }
 
   @Test
-  public void testKnownGroups() {
+  public void knownGroups() {
     GroupMembership checker = backend.membershipsOf(user);
     Set<UUID> knownGroups = checker.getKnownGroups();
     assertEquals(2, knownGroups.size());
@@ -107,7 +98,7 @@
   }
 
   @Test
-  public void testOtherMemberships() {
+  public void otherMemberships() {
     final AccountGroup.UUID handled = new AccountGroup.UUID("handled");
     final AccountGroup.UUID notHandled = new AccountGroup.UUID("not handled");
     final IdentifiedUser member = createNiceMock(IdentifiedUser.class);
@@ -117,25 +108,25 @@
     expect(backend.handles(handled)).andStubReturn(true);
     expect(backend.handles(not(eq(handled)))).andStubReturn(false);
     expect(backend.membershipsOf(anyObject(IdentifiedUser.class)))
-        .andStubAnswer(new IAnswer<GroupMembership>() {
-          @Override
-          public GroupMembership answer() throws Throwable {
-            Object[] args = getCurrentArguments();
-            GroupMembership membership = createMock(GroupMembership.class);
-            expect(membership.contains(eq(handled))).andStubReturn(args[0] == member);
-            expect(membership.contains(not(eq(notHandled)))).andStubReturn(false);
-            replay(membership);
-            return membership;
-          }
-        });
+        .andStubAnswer(
+            new IAnswer<GroupMembership>() {
+              @Override
+              public GroupMembership answer() throws Throwable {
+                Object[] args = getCurrentArguments();
+                GroupMembership membership = createMock(GroupMembership.class);
+                expect(membership.contains(eq(handled))).andStubReturn(args[0] == member);
+                expect(membership.contains(not(eq(notHandled)))).andStubReturn(false);
+                replay(membership);
+                return membership;
+              }
+            });
     replay(member, notMember, backend);
 
     backends = new DynamicSet<>();
     backends.add(backend);
     backend = new UniversalGroupBackend(backends);
 
-    GroupMembership checker =
-        backend.membershipsOf(member);
+    GroupMembership checker = backend.membershipsOf(member);
     assertFalse(checker.contains(REGISTERED_USERS));
     assertFalse(checker.contains(OTHER_UUID));
     assertTrue(checker.contains(handled));
@@ -144,5 +135,4 @@
     assertFalse(checker.contains(handled));
     assertFalse(checker.contains(notHandled));
   }
-
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
index 0619a78..cf61de2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
@@ -17,22 +17,20 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.NotifyValue;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.git.ValidationError;
-
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
 
 public class WatchConfigTest implements ValidationError.Sink {
   private List<ValidationError> validationErrors = new ArrayList<>();
@@ -45,14 +43,15 @@
   @Test
   public void parseWatchConfig() throws Exception {
     Config cfg = new Config();
-    cfg.fromText("[project \"myProject\"]\n"
-        + "  notify = * [ALL_COMMENTS, NEW_PATCHSETS]\n"
-        + "  notify = branch:master [NEW_CHANGES]\n"
-        + "  notify = branch:master [NEW_PATCHSETS]\n"
-        + "  notify = branch:foo []\n"
-        + "[project \"otherProject\"]\n"
-        + "  notify = [NEW_PATCHSETS]\n"
-        + "  notify = * [NEW_PATCHSETS, ALL_COMMENTS]\n");
+    cfg.fromText(
+        "[project \"myProject\"]\n"
+            + "  notify = * [ALL_COMMENTS, NEW_PATCHSETS]\n"
+            + "  notify = branch:master [NEW_CHANGES]\n"
+            + "  notify = branch:master [NEW_PATCHSETS]\n"
+            + "  notify = branch:foo []\n"
+            + "[project \"otherProject\"]\n"
+            + "  notify = [NEW_PATCHSETS]\n"
+            + "  notify = * [NEW_PATCHSETS, ALL_COMMENTS]\n");
     Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
         WatchConfig.parse(new Account.Id(1000000), cfg, this);
 
@@ -60,18 +59,19 @@
 
     Project.NameKey myProject = new Project.NameKey("myProject");
     Project.NameKey otherProject = new Project.NameKey("otherProject");
-    Map<ProjectWatchKey, Set<NotifyType>> expectedProjectWatches =
-        new HashMap<>();
-    expectedProjectWatches.put(ProjectWatchKey.create(myProject, null),
+    Map<ProjectWatchKey, Set<NotifyType>> expectedProjectWatches = new HashMap<>();
+    expectedProjectWatches.put(
+        ProjectWatchKey.create(myProject, null),
         EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
     expectedProjectWatches.put(
         ProjectWatchKey.create(myProject, "branch:master"),
         EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.NEW_PATCHSETS));
-    expectedProjectWatches.put(ProjectWatchKey.create(myProject, "branch:foo"),
-        EnumSet.noneOf(NotifyType.class));
-    expectedProjectWatches.put(ProjectWatchKey.create(otherProject, null),
-        EnumSet.of(NotifyType.NEW_PATCHSETS));
-    expectedProjectWatches.put(ProjectWatchKey.create(otherProject, null),
+    expectedProjectWatches.put(
+        ProjectWatchKey.create(myProject, "branch:foo"), EnumSet.noneOf(NotifyType.class));
+    expectedProjectWatches.put(
+        ProjectWatchKey.create(otherProject, null), EnumSet.of(NotifyType.NEW_PATCHSETS));
+    expectedProjectWatches.put(
+        ProjectWatchKey.create(otherProject, null),
         EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
     assertThat(projectWatches).containsExactlyEntriesIn(expectedProjectWatches);
   }
@@ -79,35 +79,40 @@
   @Test
   public void parseInvalidWatchConfig() throws Exception {
     Config cfg = new Config();
-    cfg.fromText("[project \"myProject\"]\n"
-        + "  notify = * [ALL_COMMENTS, NEW_PATCHSETS]\n"
-        + "  notify = branch:master [INVALID, NEW_CHANGES]\n"
-        + "[project \"otherProject\"]\n"
-        + "  notify = [NEW_PATCHSETS]\n");
+    cfg.fromText(
+        "[project \"myProject\"]\n"
+            + "  notify = * [ALL_COMMENTS, NEW_PATCHSETS]\n"
+            + "  notify = branch:master [INVALID, NEW_CHANGES]\n"
+            + "[project \"otherProject\"]\n"
+            + "  notify = [NEW_PATCHSETS]\n");
 
     WatchConfig.parse(new Account.Id(1000000), cfg, this);
     assertThat(validationErrors).hasSize(1);
-    assertThat(validationErrors.get(0).getMessage()).isEqualTo(
-        "watch.config: Invalid notify type INVALID in project watch of"
-            + " account 1000000 for project myProject: branch:master"
-            + " [INVALID, NEW_CHANGES]");
+    assertThat(validationErrors.get(0).getMessage())
+        .isEqualTo(
+            "watch.config: Invalid notify type INVALID in project watch of"
+                + " account 1000000 for project myProject: branch:master"
+                + " [INVALID, NEW_CHANGES]");
   }
 
   @Test
   public void parseNotifyValue() throws Exception {
     assertParseNotifyValue("* []", null, EnumSet.noneOf(NotifyType.class));
-    assertParseNotifyValue("* [ALL_COMMENTS]", null,
-        EnumSet.of(NotifyType.ALL_COMMENTS));
+    assertParseNotifyValue("* [ALL_COMMENTS]", null, EnumSet.of(NotifyType.ALL_COMMENTS));
     assertParseNotifyValue("[]", null, EnumSet.noneOf(NotifyType.class));
-    assertParseNotifyValue("[ALL_COMMENTS, NEW_PATCHSETS]", null,
+    assertParseNotifyValue(
+        "[ALL_COMMENTS, NEW_PATCHSETS]",
+        null,
         EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
-    assertParseNotifyValue("branch:master []", "branch:master",
+    assertParseNotifyValue("branch:master []", "branch:master", EnumSet.noneOf(NotifyType.class));
+    assertParseNotifyValue(
+        "branch:master || branch:stable []",
+        "branch:master || branch:stable",
         EnumSet.noneOf(NotifyType.class));
-    assertParseNotifyValue("branch:master || branch:stable []",
-        "branch:master || branch:stable", EnumSet.noneOf(NotifyType.class));
-    assertParseNotifyValue("branch:master [ALL_COMMENTS]", "branch:master",
-        EnumSet.of(NotifyType.ALL_COMMENTS));
-    assertParseNotifyValue("branch:master [ALL_COMMENTS, NEW_PATCHSETS]",
+    assertParseNotifyValue(
+        "branch:master [ALL_COMMENTS]", "branch:master", EnumSet.of(NotifyType.ALL_COMMENTS));
+    assertParseNotifyValue(
+        "branch:master [ALL_COMMENTS, NEW_PATCHSETS]",
         "branch:master",
         EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
     assertParseNotifyValue("* [ALL]", null, EnumSet.of(NotifyType.ALL));
@@ -129,31 +134,34 @@
   public void toNotifyValue() throws Exception {
     assertToNotifyValue(null, EnumSet.noneOf(NotifyType.class), "* []");
     assertToNotifyValue("*", EnumSet.noneOf(NotifyType.class), "* []");
-    assertToNotifyValue(null, EnumSet.of(NotifyType.ALL_COMMENTS),
-        "* [ALL_COMMENTS]");
-    assertToNotifyValue("branch:master", EnumSet.noneOf(NotifyType.class),
-        "branch:master []");
-    assertToNotifyValue("branch:master",
+    assertToNotifyValue(null, EnumSet.of(NotifyType.ALL_COMMENTS), "* [ALL_COMMENTS]");
+    assertToNotifyValue("branch:master", EnumSet.noneOf(NotifyType.class), "branch:master []");
+    assertToNotifyValue(
+        "branch:master",
         EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS),
         "branch:master [ALL_COMMENTS, NEW_PATCHSETS]");
-    assertToNotifyValue("branch:master",
-        EnumSet.of(NotifyType.ABANDONED_CHANGES, NotifyType.ALL_COMMENTS,
-            NotifyType.NEW_CHANGES, NotifyType.NEW_PATCHSETS,
+    assertToNotifyValue(
+        "branch:master",
+        EnumSet.of(
+            NotifyType.ABANDONED_CHANGES,
+            NotifyType.ALL_COMMENTS,
+            NotifyType.NEW_CHANGES,
+            NotifyType.NEW_PATCHSETS,
             NotifyType.SUBMITTED_CHANGES),
         "branch:master [ABANDONED_CHANGES, ALL_COMMENTS, NEW_CHANGES,"
-        + " NEW_PATCHSETS, SUBMITTED_CHANGES]");
+            + " NEW_PATCHSETS, SUBMITTED_CHANGES]");
     assertToNotifyValue("*", EnumSet.of(NotifyType.ALL), "* [ALL]");
   }
 
-  private void assertParseNotifyValue(String notifyValue,
-      String expectedFilter, Set<NotifyType> expectedNotifyTypes) {
+  private void assertParseNotifyValue(
+      String notifyValue, String expectedFilter, Set<NotifyType> expectedNotifyTypes) {
     NotifyValue nv = parseNotifyValue(notifyValue);
     assertThat(nv.filter()).isEqualTo(expectedFilter);
     assertThat(nv.notifyTypes()).containsExactlyElementsIn(expectedNotifyTypes);
   }
 
-  private static void assertToNotifyValue(String filter,
-      Set<NotifyType> notifyTypes, String expectedNotifyValue) {
+  private static void assertToNotifyValue(
+      String filter, Set<NotifyType> notifyTypes, String expectedNotifyValue) {
     NotifyValue nv = NotifyValue.create(filter, notifyTypes);
     assertThat(nv.toString()).isEqualTo(expectedNotifyValue);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/HashtagsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/HashtagsTest.java
index 50a6c11..780ac71 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/HashtagsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/change/HashtagsTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Sets;
-
 import org.junit.Test;
 
 public class HashtagsTest {
@@ -41,59 +40,56 @@
   public void singleHashtag() throws Exception {
     String commitMessage = "#Subject\n\nLine 1\n\nLine 2";
     assertThat(HashtagsUtil.extractTags(commitMessage))
-      .containsExactlyElementsIn(Sets.newHashSet("Subject"));
+        .containsExactlyElementsIn(Sets.newHashSet("Subject"));
   }
 
   @Test
   public void singleHashtagNumeric() throws Exception {
     String commitMessage = "Subject\n\n#123\n\nLine 2";
     assertThat(HashtagsUtil.extractTags(commitMessage))
-      .containsExactlyElementsIn(Sets.newHashSet("123"));
+        .containsExactlyElementsIn(Sets.newHashSet("123"));
   }
 
   @Test
   public void multipleHashtags() throws Exception {
     String commitMessage = "#Subject\n\n#Hashtag\n\nLine 2";
     assertThat(HashtagsUtil.extractTags(commitMessage))
-      .containsExactlyElementsIn(Sets.newHashSet("Subject", "Hashtag"));
+        .containsExactlyElementsIn(Sets.newHashSet("Subject", "Hashtag"));
   }
 
   @Test
   public void repeatedHashtag() throws Exception {
     String commitMessage = "#Subject\n\n#Hashtag1\n\n#Hashtag2\n\n#Hashtag1";
     assertThat(HashtagsUtil.extractTags(commitMessage))
-      .containsExactlyElementsIn(
-          Sets.newHashSet("Subject", "Hashtag1", "Hashtag2"));
+        .containsExactlyElementsIn(Sets.newHashSet("Subject", "Hashtag1", "Hashtag2"));
   }
 
   @Test
   public void multipleHashtagsNoSpaces() throws Exception {
     String commitMessage = "Subject\n\n#Hashtag1#Hashtag2";
     assertThat(HashtagsUtil.extractTags(commitMessage))
-      .containsExactlyElementsIn(Sets.newHashSet("Hashtag1"));
+        .containsExactlyElementsIn(Sets.newHashSet("Hashtag1"));
   }
 
   @Test
   public void hyphenatedHashtag() throws Exception {
     String commitMessage = "Subject\n\n#Hyphenated-Hashtag";
     assertThat(HashtagsUtil.extractTags(commitMessage))
-      .containsExactlyElementsIn(Sets.newHashSet("Hyphenated-Hashtag"));
+        .containsExactlyElementsIn(Sets.newHashSet("Hyphenated-Hashtag"));
   }
 
   @Test
   public void underscoredHashtag() throws Exception {
     String commitMessage = "Subject\n\n#Underscored_Hashtag";
     assertThat(HashtagsUtil.extractTags(commitMessage))
-      .containsExactlyElementsIn(Sets.newHashSet("Underscored_Hashtag"));
+        .containsExactlyElementsIn(Sets.newHashSet("Underscored_Hashtag"));
   }
 
   @Test
   public void hashtagsWithAccentedCharacters() throws Exception {
-    String commitMessage = "Jag #måste #öva på min #Svenska!\n\n"
-        + "Jag behöver en #läkare.";
+    String commitMessage = "Jag #måste #öva på min #Svenska!\n\nJag behöver en #läkare.";
     assertThat(HashtagsUtil.extractTags(commitMessage))
-      .containsExactlyElementsIn(
-          Sets.newHashSet("måste", "öva", "Svenska", "läkare"));
+        .containsExactlyElementsIn(Sets.newHashSet("måste", "öva", "Svenska", "läkare"));
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java
index 3e19366..e91c3b4 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/change/IncludedInResolverTest.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
 import org.eclipse.jgit.junit.RepositoryTestCase;
@@ -27,11 +31,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
 public class IncludedInResolverTest extends RepositoryTestCase {
 
   // Branch names
@@ -68,21 +67,21 @@
 
     /*- The following graph will be created.
 
-      o   tag 2.5, 2.5_annotated, 2.5_annotated_twice
-      |\
-      | o tag 2.0.1
-      | o tag 2.0
-      o | tag 1.3
-      |/
-      o   c3
+     o   tag 2.5, 2.5_annotated, 2.5_annotated_twice
+     |\
+     | o tag 2.0.1
+     | o tag 2.0
+     o | tag 1.3
+     |/
+     o   c3
 
-      | o tag 1.0.1
-      |/
-      o   tag 1.0
-      o   c2
-      o   c1
+     | o tag 1.0.1
+     |/
+     o   tag 1.0
+     o   c2
+     o   c1
 
-     */
+    */
 
     // TODO(dborowitz): Use try/finally when this doesn't double-close the repo.
     @SuppressWarnings("resource")
@@ -96,8 +95,7 @@
     RevCommit c3 = git.commit().setMessage("c3").call();
     // Version 1.01
     createAndCheckoutBranch(commit_v1_0, BRANCH_1_0);
-    RevCommit commit_v1_0_1 =
-        git.commit().setMessage("verREFS_HEADS_RELsion 1.0.1").call();
+    RevCommit commit_v1_0_1 = git.commit().setMessage("verREFS_HEADS_RELsion 1.0.1").call();
     git.tag().setName(TAG_1_0_1).setObjectId(commit_v1_0_1).call();
     // Version 1.3
     createAndCheckoutBranch(c3, BRANCH_1_3);
@@ -112,18 +110,21 @@
 
     // Version 2.5
     createAndCheckoutBranch(commit_v1_3, BRANCH_2_5);
-    git.merge().include(commit_v2_0_1).setCommit(false)
-        .setFastForward(FastForwardMode.NO_FF).call();
-    commit_v2_5 = git.commit().setMessage("version 2.5").call();
-    git.tag().setName(TAG_2_5).setObjectId(commit_v2_5).setAnnotated(false)
+    git.merge()
+        .include(commit_v2_0_1)
+        .setCommit(false)
+        .setFastForward(FastForwardMode.NO_FF)
         .call();
+    commit_v2_5 = git.commit().setMessage("version 2.5").call();
+    git.tag().setName(TAG_2_5).setObjectId(commit_v2_5).setAnnotated(false).call();
     Ref ref_tag_2_5_annotated =
-        git.tag().setName(TAG_2_5_ANNOTATED).setObjectId(commit_v2_5)
-            .setAnnotated(true).call();
-    RevTag tag_2_5_annotated =
-        revWalk.parseTag(ref_tag_2_5_annotated.getObjectId());
-    git.tag().setName(TAG_2_5_ANNOTATED_TWICE).setObjectId(tag_2_5_annotated)
-        .setAnnotated(true).call();
+        git.tag().setName(TAG_2_5_ANNOTATED).setObjectId(commit_v2_5).setAnnotated(true).call();
+    RevTag tag_2_5_annotated = revWalk.parseTag(ref_tag_2_5_annotated.getObjectId());
+    git.tag()
+        .setName(TAG_2_5_ANNOTATED_TWICE)
+        .setObjectId(tag_2_5_annotated)
+        .setAnnotated(true)
+        .call();
   }
 
   @Override
@@ -198,8 +199,7 @@
     Assert.assertEquals(list1, list2);
   }
 
-  private void createAndCheckoutBranch(ObjectId objectId, String branchName)
-      throws IOException {
+  private void createAndCheckoutBranch(ObjectId objectId, String branchName) throws IOException {
     String fullBranchName = "refs/heads/" + branchName;
     super.createBranch(objectId, fullBranchName);
     super.checkoutBranch(fullBranchName);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
index 4f2166d..37e4d3f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/change/WalkSorterTest.java
@@ -26,12 +26,12 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testutil.TestChanges;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -39,14 +39,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.ArrayList;
-import java.util.List;
-
-public class WalkSorterTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class WalkSorterTest extends GerritBaseTests {
   private Account.Id userId;
   private InMemoryRepositoryManager repoManager;
 
@@ -70,10 +63,11 @@
     List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3);
     WalkSorter sorter = new WalkSorter(repoManager);
 
-    assertSorted(sorter, changes, ImmutableList.of(
-        patchSetData(cd3, c3_1),
-        patchSetData(cd2, c2_1),
-        patchSetData(cd1, c1_1)));
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd3, c3_1), patchSetData(cd2, c2_1), patchSetData(cd1, c1_1)));
 
     // Add new patch sets whose commits are in reverse order, so output is in
     // reverse order.
@@ -85,10 +79,11 @@
     addPatchSet(cd2, c2_2);
     addPatchSet(cd3, c3_2);
 
-    assertSorted(sorter, changes, ImmutableList.of(
-        patchSetData(cd1, c1_2),
-        patchSetData(cd2, c2_2),
-        patchSetData(cd3, c3_2)));
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd1, c1_2), patchSetData(cd2, c2_2), patchSetData(cd3, c3_2)));
   }
 
   @Test
@@ -104,9 +99,8 @@
     List<ChangeData> changes = ImmutableList.of(cd1, cd3);
     WalkSorter sorter = new WalkSorter(repoManager);
 
-    assertSorted(sorter, changes, ImmutableList.of(
-        patchSetData(cd3, c3_1),
-        patchSetData(cd1, c1_1)));
+    assertSorted(
+        sorter, changes, ImmutableList.of(patchSetData(cd3, c3_1), patchSetData(cd1, c1_1)));
   }
 
   @Test
@@ -120,12 +114,9 @@
 
     RevWalk rw = p.getRevWalk();
     rw.parseCommit(c1);
-    assertThat(rw.parseCommit(c2).getCommitTime())
-        .isEqualTo(c1.getCommitTime());
-    assertThat(rw.parseCommit(c3).getCommitTime())
-        .isEqualTo(c1.getCommitTime());
-    assertThat(rw.parseCommit(c4).getCommitTime())
-        .isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c2).getCommitTime()).isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime()).isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime()).isEqualTo(c1.getCommitTime());
 
     ChangeData cd1 = newChange(p, c1);
     ChangeData cd2 = newChange(p, c2);
@@ -135,11 +126,14 @@
     List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
     WalkSorter sorter = new WalkSorter(repoManager);
 
-    assertSorted(sorter, changes, ImmutableList.of(
-        patchSetData(cd4, c4),
-        patchSetData(cd3, c3),
-        patchSetData(cd2, c2),
-        patchSetData(cd1, c1)));
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd4, c4),
+            patchSetData(cd3, c3),
+            patchSetData(cd2, c2),
+            patchSetData(cd1, c1)));
   }
 
   @Test
@@ -153,12 +147,9 @@
 
     RevWalk rw = p.getRevWalk();
     rw.parseCommit(c1);
-    assertThat(rw.parseCommit(c2).getCommitTime())
-        .isLessThan(c1.getCommitTime());
-    assertThat(rw.parseCommit(c3).getCommitTime())
-        .isLessThan(c2.getCommitTime());
-    assertThat(rw.parseCommit(c4).getCommitTime())
-        .isLessThan(c3.getCommitTime());
+    assertThat(rw.parseCommit(c2).getCommitTime()).isLessThan(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime()).isLessThan(c2.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime()).isLessThan(c3.getCommitTime());
 
     ChangeData cd1 = newChange(p, c1);
     ChangeData cd2 = newChange(p, c2);
@@ -168,11 +159,14 @@
     List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
     WalkSorter sorter = new WalkSorter(repoManager);
 
-    assertSorted(sorter, changes, ImmutableList.of(
-        patchSetData(cd4, c4),
-        patchSetData(cd3, c3),
-        patchSetData(cd2, c2),
-        patchSetData(cd1, c1)));
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd4, c4),
+            patchSetData(cd3, c3),
+            patchSetData(cd2, c2),
+            patchSetData(cd1, c1)));
   }
 
   @Test
@@ -186,12 +180,9 @@
 
     RevWalk rw = p.getRevWalk();
     rw.parseCommit(c1);
-    assertThat(rw.parseCommit(c2).getCommitTime())
-        .isLessThan(c1.getCommitTime());
-    assertThat(rw.parseCommit(c3).getCommitTime())
-        .isLessThan(c2.getCommitTime());
-    assertThat(rw.parseCommit(c4).getCommitTime())
-        .isLessThan(c3.getCommitTime());
+    assertThat(rw.parseCommit(c2).getCommitTime()).isLessThan(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime()).isLessThan(c2.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime()).isLessThan(c3.getCommitTime());
 
     ChangeData cd1 = newChange(p, c1);
     ChangeData cd2 = newChange(p, c2);
@@ -199,10 +190,8 @@
 
     List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd4);
     WalkSorter sorter = new WalkSorter(repoManager);
-    List<PatchSetData> expected = ImmutableList.of(
-        patchSetData(cd4, c4),
-        patchSetData(cd2, c2),
-        patchSetData(cd1, c1));
+    List<PatchSetData> expected =
+        ImmutableList.of(patchSetData(cd4, c4), patchSetData(cd2, c2), patchSetData(cd1, c1));
 
     for (List<ChangeData> list : permutations(changes)) {
       // Not inOrder(); since child of c2 is missing, partial topo sort isn't
@@ -221,12 +210,9 @@
 
     RevWalk rw = p.getRevWalk();
     rw.parseCommit(c1);
-    assertThat(rw.parseCommit(c2).getCommitTime())
-        .isEqualTo(c1.getCommitTime());
-    assertThat(rw.parseCommit(c3).getCommitTime())
-        .isEqualTo(c1.getCommitTime());
-    assertThat(rw.parseCommit(c4).getCommitTime())
-        .isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c2).getCommitTime()).isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c3).getCommitTime()).isEqualTo(c1.getCommitTime());
+    assertThat(rw.parseCommit(c4).getCommitTime()).isEqualTo(c1.getCommitTime());
 
     ChangeData cd1 = newChange(p, c1);
     ChangeData cd2 = newChange(p, c2);
@@ -236,11 +222,14 @@
     List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
     WalkSorter sorter = new WalkSorter(repoManager);
 
-    assertSorted(sorter, changes, ImmutableList.of(
-        patchSetData(cd4, c4),
-        patchSetData(cd3, c3),
-        patchSetData(cd2, c2),
-        patchSetData(cd1, c1)));
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd4, c4),
+            patchSetData(cd3, c3),
+            patchSetData(cd2, c2),
+            patchSetData(cd1, c1)));
   }
 
   @Test
@@ -286,17 +275,14 @@
     List<ChangeData> changes = ImmutableList.of(cd1, cd2);
     WalkSorter sorter = new WalkSorter(repoManager);
 
-    assertSorted(sorter, changes, ImmutableList.of(
-        patchSetData(cd1, c1_2),
-        patchSetData(cd2, c2_2)));
+    assertSorted(
+        sorter, changes, ImmutableList.of(patchSetData(cd1, c1_2), patchSetData(cd2, c2_2)));
 
     // If we restrict to PS1 of each change, the sorter uses that commit.
-    sorter.includePatchSets(ImmutableSet.of(
-        new PatchSet.Id(cd1.getId(), 1),
-        new PatchSet.Id(cd2.getId(), 1)));
-    assertSorted(sorter, changes, ImmutableList.of(
-        patchSetData(cd2, 1, c2_1),
-        patchSetData(cd1, 1, c1_1)));
+    sorter.includePatchSets(
+        ImmutableSet.of(new PatchSet.Id(cd1.getId(), 1), new PatchSet.Id(cd2.getId(), 1)));
+    assertSorted(
+        sorter, changes, ImmutableList.of(patchSetData(cd2, 1, c2_1), patchSetData(cd1, 1, c1_1)));
   }
 
   @Test
@@ -310,8 +296,9 @@
     ChangeData cd2 = newChange(pb, c2);
 
     List<ChangeData> changes = ImmutableList.of(cd1, cd2);
-    WalkSorter sorter = new WalkSorter(repoManager)
-        .includePatchSets(ImmutableSet.of(cd1.currentPatchSet().getId()));
+    WalkSorter sorter =
+        new WalkSorter(repoManager)
+            .includePatchSets(ImmutableSet.of(cd1.currentPatchSet().getId()));
 
     assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd1, c1)));
   }
@@ -323,19 +310,13 @@
     ChangeData cd = newChange(p, c);
 
     List<ChangeData> changes = ImmutableList.of(cd);
-    RevCommit actual = new WalkSorter(repoManager)
-        .setRetainBody(true)
-        .sort(changes)
-        .iterator().next()
-        .commit();
+    RevCommit actual =
+        new WalkSorter(repoManager).setRetainBody(true).sort(changes).iterator().next().commit();
     assertThat(actual.getRawBuffer()).isNotNull();
     assertThat(actual.getShortMessage()).isEqualTo("message");
 
-    actual = new WalkSorter(repoManager)
-        .setRetainBody(false)
-        .sort(changes)
-        .iterator().next()
-        .commit();
+    actual =
+        new WalkSorter(repoManager).setRetainBody(false).sort(changes).iterator().next().commit();
     assertThat(actual.getRawBuffer()).isNull();
   }
 
@@ -351,8 +332,7 @@
     assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd, c)));
   }
 
-  private ChangeData newChange(TestRepository<Repo> tr, ObjectId id)
-      throws Exception {
+  private ChangeData newChange(TestRepository<Repo> tr, ObjectId id) throws Exception {
     Project.NameKey project = tr.getRepository().getDescription().getProject();
     Change c = TestChanges.newChange(project, userId);
     ChangeData cd = ChangeData.createForTest(project, c.getId(), 1);
@@ -372,28 +352,23 @@
     return ps;
   }
 
-  private TestRepository<Repo> newRepo(String name)
-      throws Exception {
-    return new TestRepository<>(
-        repoManager.createRepository(new Project.NameKey(name)));
+  private TestRepository<Repo> newRepo(String name) throws Exception {
+    return new TestRepository<>(repoManager.createRepository(new Project.NameKey(name)));
   }
 
-  private static PatchSetData patchSetData(ChangeData cd, RevCommit commit)
-      throws Exception {
+  private static PatchSetData patchSetData(ChangeData cd, RevCommit commit) throws Exception {
     return PatchSetData.create(cd, cd.currentPatchSet(), commit);
   }
 
-  private static PatchSetData patchSetData(ChangeData cd, int psId,
-      RevCommit commit) throws Exception {
-    return PatchSetData.create(
-        cd, cd.patchSet(new PatchSet.Id(cd.getId(), psId)), commit);
+  private static PatchSetData patchSetData(ChangeData cd, int psId, RevCommit commit)
+      throws Exception {
+    return PatchSetData.create(cd, cd.patchSet(new PatchSet.Id(cd.getId(), psId)), commit);
   }
 
-  private static void assertSorted(WalkSorter sorter, List<ChangeData> changes,
-      List<PatchSetData> expected) throws Exception {
+  private static void assertSorted(
+      WalkSorter sorter, List<ChangeData> changes, List<PatchSetData> expected) throws Exception {
     for (List<ChangeData> list : permutations(changes)) {
-      assertThat(sorter.sort(list))
-          .containsExactlyElementsIn(expected).inOrder();
+      assertThat(sorter.sort(list)).containsExactlyElementsIn(expected).inOrder();
     }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java
index 6282415..fc0b1dcd 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/ConfigUtilTest.java
@@ -23,13 +23,11 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.extensions.client.Theme;
-
-import org.eclipse.jgit.lib.Config;
-import org.junit.Test;
-
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
 
 public class ConfigUtilTest {
   private static final String SECT = "foo";
@@ -54,6 +52,7 @@
     public Theme td;
     public List<String> list;
     public Map<String, String> map;
+
     static SectionInfo defaults() {
       SectionInfo i = new SectionInfo();
       i.i = 1;
@@ -75,7 +74,7 @@
   }
 
   @Test
-  public void testStoreLoadSection() throws Exception {
+  public void storeLoadSection() throws Exception {
     SectionInfo d = SectionInfo.defaults();
     SectionInfo in = new SectionInfo();
     in.missing = "42";
@@ -142,7 +141,7 @@
   }
 
   @Test
-  public void testTimeUnit() {
+  public void timeUnit() {
     assertEquals(ms(0, MILLISECONDS), parse("0"));
     assertEquals(ms(2, MILLISECONDS), parse("2ms"));
     assertEquals(ms(200, MILLISECONDS), parse("200 milliseconds"));
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java
index 12e563f..ab7da99 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/GitwebConfigTest.java
@@ -25,14 +25,14 @@
   private static final String SOME_INVALID_CHARACTERS = "09AZaz$-_.+!',";
 
   @Test
-  public void testValidPathSeparator() {
+  public void validPathSeparator() {
     for (char c : VALID_CHARACTERS.toCharArray()) {
       assertTrue("valid character rejected: " + c, GitwebConfig.isValidPathSeparator(c));
     }
   }
 
   @Test
-  public void testInalidPathSeparator() {
+  public void inalidPathSeparator() {
     for (char c : SOME_INVALID_CHARACTERS.toCharArray()) {
       assertFalse("invalid character accepted: " + c, GitwebConfig.isValidPathSeparator(c));
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/ListCapabilitiesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/ListCapabilitiesTest.java
index 992502f..6fe48dc 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -26,39 +26,38 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-
+import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.Map;
-
 public class ListCapabilitiesTest {
   private Injector injector;
 
   @Before
   public void setUp() throws Exception {
-    AbstractModule mod = new AbstractModule() {
-      @Override
-      protected void configure() {
-        DynamicMap.mapOf(binder(), CapabilityDefinition.class);
-        bind(CapabilityDefinition.class)
-          .annotatedWith(Exports.named("printHello"))
-          .toInstance(new CapabilityDefinition() {
-            @Override
-            public String getDescription() {
-              return "Print Hello";
-            }
-          });
-      }
-    };
+    AbstractModule mod =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            DynamicMap.mapOf(binder(), CapabilityDefinition.class);
+            bind(CapabilityDefinition.class)
+                .annotatedWith(Exports.named("printHello"))
+                .toInstance(
+                    new CapabilityDefinition() {
+                      @Override
+                      public String getDescription() {
+                        return "Print Hello";
+                      }
+                    });
+          }
+        };
     injector = Guice.createInjector(mod);
   }
 
   @Test
-  public void testList() throws Exception {
+  public void list() throws Exception {
     Map<String, CapabilityInfo> m =
-        injector.getInstance(ListCapabilities.class)
-            .apply(new ConfigResource());
+        injector.getInstance(ListCapabilities.class).apply(new ConfigResource());
     for (String id : GlobalCapability.getAllNames()) {
       assertTrue("contains " + id, m.containsKey(id));
       assertEquals(id, m.get(id).id);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
index bf36738..804e2f2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -19,14 +19,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Project.NameKey;
-
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
 
 public class RepositoryConfigTest {
 
@@ -40,13 +38,13 @@
   }
 
   @Test
-  public void testDefaultSubmitTypeWhenNotConfigured() {
+  public void defaultSubmitTypeWhenNotConfigured() {
     assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
         .isEqualTo(SubmitType.MERGE_IF_NECESSARY);
   }
 
   @Test
-  public void testDefaultSubmitTypeForStarFilter() {
+  public void defaultSubmitTypeForStarFilter() {
     configureDefaultSubmitType("*", SubmitType.CHERRY_PICK);
     assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
@@ -58,10 +56,14 @@
     configureDefaultSubmitType("*", SubmitType.REBASE_IF_NECESSARY);
     assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
         .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
+
+    configureDefaultSubmitType("*", SubmitType.REBASE_ALWAYS);
+    assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
+        .isEqualTo(SubmitType.REBASE_ALWAYS);
   }
 
   @Test
-  public void testDefaultSubmitTypeForSpecificFilter() {
+  public void defaultSubmitTypeForSpecificFilter() {
     configureDefaultSubmitType("someProject", SubmitType.CHERRY_PICK);
     assertThat(repoCfg.getDefaultSubmitType(new NameKey("someOtherProject")))
         .isEqualTo(SubmitType.MERGE_IF_NECESSARY);
@@ -70,38 +72,36 @@
   }
 
   @Test
-  public void testDefaultSubmitTypeForStartWithFilter() {
-    configureDefaultSubmitType("somePath/somePath/*",
-        SubmitType.REBASE_IF_NECESSARY);
+  public void defaultSubmitTypeForStartWithFilter() {
+    configureDefaultSubmitType("somePath/somePath/*", SubmitType.REBASE_IF_NECESSARY);
     configureDefaultSubmitType("somePath/*", SubmitType.CHERRY_PICK);
     configureDefaultSubmitType("*", SubmitType.MERGE_ALWAYS);
 
     assertThat(repoCfg.getDefaultSubmitType(new NameKey("someProject")))
         .isEqualTo(SubmitType.MERGE_ALWAYS);
 
-    assertThat(
-        repoCfg.getDefaultSubmitType(new NameKey("somePath/someProject")))
+    assertThat(repoCfg.getDefaultSubmitType(new NameKey("somePath/someProject")))
         .isEqualTo(SubmitType.CHERRY_PICK);
 
-    assertThat(
-        repoCfg.getDefaultSubmitType(new NameKey(
-            "somePath/somePath/someProject"))).isEqualTo(
-        SubmitType.REBASE_IF_NECESSARY);
+    assertThat(repoCfg.getDefaultSubmitType(new NameKey("somePath/somePath/someProject")))
+        .isEqualTo(SubmitType.REBASE_IF_NECESSARY);
   }
 
-  private void configureDefaultSubmitType(String projectFilter,
-      SubmitType submitType) {
-    cfg.setString(RepositoryConfig.SECTION_NAME, projectFilter,
-        RepositoryConfig.DEFAULT_SUBMIT_TYPE_NAME, submitType.toString());
+  private void configureDefaultSubmitType(String projectFilter, SubmitType submitType) {
+    cfg.setString(
+        RepositoryConfig.SECTION_NAME,
+        projectFilter,
+        RepositoryConfig.DEFAULT_SUBMIT_TYPE_NAME,
+        submitType.toString());
   }
 
   @Test
-  public void testOwnerGroupsWhenNotConfigured() {
+  public void ownerGroupsWhenNotConfigured() {
     assertThat(repoCfg.getOwnerGroups(new NameKey("someProject"))).isEmpty();
   }
 
   @Test
-  public void testOwnerGroupsForStarFilter() {
+  public void ownerGroupsForStarFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("*", ownerGroups);
     assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
@@ -109,17 +109,16 @@
   }
 
   @Test
-  public void testOwnerGroupsForSpecificFilter() {
+  public void ownerGroupsForSpecificFilter() {
     ImmutableList<String> ownerGroups = ImmutableList.of("group1", "group2");
     configureOwnerGroups("someProject", ownerGroups);
-    assertThat(repoCfg.getOwnerGroups(new NameKey("someOtherProject")))
-        .isEmpty();
+    assertThat(repoCfg.getOwnerGroups(new NameKey("someOtherProject"))).isEmpty();
     assertThat(repoCfg.getOwnerGroups(new NameKey("someProject")))
         .containsExactlyElementsIn(ownerGroups);
   }
 
   @Test
-  public void testOwnerGroupsForStartWithFilter() {
+  public void ownerGroupsForStartWithFilter() {
     ImmutableList<String> ownerGroups1 = ImmutableList.of("group1");
     ImmutableList<String> ownerGroups2 = ImmutableList.of("group2");
     ImmutableList<String> ownerGroups3 = ImmutableList.of("group3");
@@ -134,42 +133,40 @@
     assertThat(repoCfg.getOwnerGroups(new NameKey("somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups2);
 
-    assertThat(
-        repoCfg.getOwnerGroups(new NameKey("somePath/somePath/someProject")))
+    assertThat(repoCfg.getOwnerGroups(new NameKey("somePath/somePath/someProject")))
         .containsExactlyElementsIn(ownerGroups3);
   }
 
-  private void configureOwnerGroups(String projectFilter,
-      List<String> ownerGroups) {
-    cfg.setStringList(RepositoryConfig.SECTION_NAME, projectFilter,
-        RepositoryConfig.OWNER_GROUP_NAME, ownerGroups);
+  private void configureOwnerGroups(String projectFilter, List<String> ownerGroups) {
+    cfg.setStringList(
+        RepositoryConfig.SECTION_NAME,
+        projectFilter,
+        RepositoryConfig.OWNER_GROUP_NAME,
+        ownerGroups);
   }
 
   @Test
-  public void testBasePathWhenNotConfigured() {
-    assertThat((Object)repoCfg.getBasePath(new NameKey("someProject"))).isNull();
+  public void basePathWhenNotConfigured() {
+    assertThat((Object) repoCfg.getBasePath(new NameKey("someProject"))).isNull();
   }
 
   @Test
-  public void testBasePathForStarFilter() {
+  public void basePathForStarFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("*", basePath);
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString())
-        .isEqualTo(basePath);
+    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
   @Test
-  public void testBasePathForSpecificFilter() {
+  public void basePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("someProject", basePath);
-    assertThat((Object) repoCfg.getBasePath(new NameKey("someOtherProject")))
-        .isNull();
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString())
-        .isEqualTo(basePath);
+    assertThat((Object) repoCfg.getBasePath(new NameKey("someOtherProject"))).isNull();
+    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
   @Test
-  public void testBasePathForStartWithFilter() {
+  public void basePathForStartWithFilter() {
     String basePath1 = "/someAbsolutePath1/someDirectory";
     String basePath2 = "someRelativeDirectory2";
     String basePath3 = "/someAbsolutePath3/someDirectory";
@@ -180,23 +177,19 @@
     configureBasePath("project/*", basePath3);
     configureBasePath("*", basePath4);
 
-    assertThat(repoCfg.getBasePath(new NameKey("project1")).toString())
-        .isEqualTo(basePath1);
-    assertThat(repoCfg.getBasePath(new NameKey("project/project/someProject"))
-        .toString()).isEqualTo(basePath2);
-    assertThat(
-        repoCfg.getBasePath(new NameKey("project/someProject")).toString())
-            .isEqualTo(basePath3);
-    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString())
-        .isEqualTo(basePath4);
+    assertThat(repoCfg.getBasePath(new NameKey("project1")).toString()).isEqualTo(basePath1);
+    assertThat(repoCfg.getBasePath(new NameKey("project/project/someProject")).toString())
+        .isEqualTo(basePath2);
+    assertThat(repoCfg.getBasePath(new NameKey("project/someProject")).toString())
+        .isEqualTo(basePath3);
+    assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath4);
   }
 
   @Test
-  public void testAllBasePath() {
-    ImmutableList<Path> allBasePaths = ImmutableList.of(
-        Paths.get("/someBasePath1"),
-        Paths.get("/someBasePath2"),
-        Paths.get("/someBasePath2"));
+  public void allBasePath() {
+    ImmutableList<Path> allBasePaths =
+        ImmutableList.of(
+            Paths.get("/someBasePath1"), Paths.get("/someBasePath2"), Paths.get("/someBasePath2"));
 
     configureBasePath("*", allBasePaths.get(0).toString());
     configureBasePath("project/*", allBasePaths.get(1).toString());
@@ -206,7 +199,7 @@
   }
 
   private void configureBasePath(String projectFilter, String basePath) {
-    cfg.setString(RepositoryConfig.SECTION_NAME, projectFilter,
-        RepositoryConfig.BASE_PATH_NAME, basePath);
+    cfg.setString(
+        RepositoryConfig.SECTION_NAME, projectFilter, RepositoryConfig.BASE_PATH_NAME, basePath);
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
index d5f68cc..e6f36b9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -20,19 +20,18 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static org.junit.Assert.assertEquals;
 
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.joda.time.DateTime;
 import org.junit.Test;
 
-import java.util.concurrent.TimeUnit;
-
 public class ScheduleConfigTest {
 
   // Friday June 13, 2014 10:00 UTC
   private static final DateTime NOW = DateTime.parse("2014-06-13T10:00:00-00:00");
 
   @Test
-  public void testInitialDelay() throws Exception {
+  public void initialDelay() throws Exception {
     assertEquals(ms(1, HOURS), initialDelay("11:00", "1h"));
     assertEquals(ms(30, MINUTES), initialDelay("05:30", "1h"));
     assertEquals(ms(30, MINUTES), initialDelay("09:30", "1h"));
@@ -43,8 +42,7 @@
     assertEquals(ms(19, HOURS) + ms(30, MINUTES), initialDelay("05:30", "1d"));
 
     assertEquals(ms(1, HOURS), initialDelay("11:00", "1w"));
-    assertEquals(ms(7, DAYS) - ms(4, HOURS) - ms(30, MINUTES),
-        initialDelay("05:30", "1w"));
+    assertEquals(ms(7, DAYS) - ms(4, HOURS) - ms(30, MINUTES), initialDelay("05:30", "1w"));
 
     assertEquals(ms(3, DAYS) + ms(1, HOURS), initialDelay("Mon 11:00", "1w"));
     assertEquals(ms(1, HOURS), initialDelay("Fri 11:00", "1w"));
@@ -56,7 +54,7 @@
   }
 
   @Test
-  public void testCustomKeys() {
+  public void customKeys() {
     Config rc = new Config();
     rc.setString("a", "b", "i", "1h");
     rc.setString("a", "b", "s", "01:00");
@@ -71,9 +69,8 @@
   }
 
   private static long initialDelay(String startTime, String interval) {
-    return new ScheduleConfig(
-        config(startTime, interval),
-        "section", "subsection", NOW).getInitialDelay();
+    return new ScheduleConfig(config(startTime, interval), "section", "subsection", NOW)
+        .getInitialDelay();
   }
 
   private static Config config(String startTime, String interval) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
index 8cdd42b..3fb278d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/SitePathsTest.java
@@ -14,49 +14,44 @@
 
 package com.google.gerrit.server.config;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.extensions.common.PathSubject;
 import com.google.gerrit.server.util.HostPlatform;
 import com.google.gerrit.testutil.GerritBaseTests;
-
-import org.junit.Test;
-
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.NotDirectoryException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import org.junit.Test;
 
 public class SitePathsTest extends GerritBaseTests {
   @Test
-  public void testCreate_NotExisting() throws IOException {
+  public void create_NotExisting() throws IOException {
     final Path root = random();
     final SitePaths site = new SitePaths(root);
-    assertTrue(site.isNew);
-    assertEquals(root, site.site_path);
-    assertEquals(root.resolve("etc"), site.etc_dir);
+    assertThat(site.isNew).isTrue();
+    PathSubject.assertThat(site.site_path).isEqualTo(root);
+    PathSubject.assertThat(site.etc_dir).isEqualTo(root.resolve("etc"));
   }
 
   @Test
-  public void testCreate_Empty() throws IOException {
+  public void create_Empty() throws IOException {
     final Path root = random();
     try {
       Files.createDirectory(root);
 
       final SitePaths site = new SitePaths(root);
-      assertTrue(site.isNew);
-      assertEquals(root, site.site_path);
+      assertThat(site.isNew).isTrue();
+      PathSubject.assertThat(site.site_path).isEqualTo(root);
     } finally {
       Files.delete(root);
     }
   }
 
   @Test
-  public void testCreate_NonEmpty() throws IOException {
+  public void create_NonEmpty() throws IOException {
     final Path root = random();
     final Path txt = root.resolve("test.txt");
     try {
@@ -64,8 +59,8 @@
       Files.createFile(txt);
 
       final SitePaths site = new SitePaths(root);
-      assertFalse(site.isNew);
-      assertEquals(root, site.site_path);
+      assertThat(site.isNew).isFalse();
+      PathSubject.assertThat(site.site_path).isEqualTo(root);
     } finally {
       Files.delete(txt);
       Files.delete(root);
@@ -73,7 +68,7 @@
   }
 
   @Test
-  public void testCreate_NotDirectory() throws IOException {
+  public void create_NotDirectory() throws IOException {
     final Path root = random();
     try {
       Files.createFile(root);
@@ -85,20 +80,20 @@
   }
 
   @Test
-  public void testResolve() throws IOException {
+  public void resolve() throws IOException {
     final Path root = random();
     final SitePaths site = new SitePaths(root);
 
-    assertNull(site.resolve(null));
-    assertNull(site.resolve(""));
+    PathSubject.assertThat(site.resolve(null)).isNull();
+    PathSubject.assertThat(site.resolve("")).isNull();
 
-    assertNotNull(site.resolve("a"));
-    assertEquals(root.resolve("a").toAbsolutePath().normalize(),
-        site.resolve("a"));
+    PathSubject.assertThat(site.resolve("a")).isNotNull();
+    PathSubject.assertThat(site.resolve("a"))
+        .isEqualTo(root.resolve("a").toAbsolutePath().normalize());
 
     final String pfx = HostPlatform.isWin32() ? "C:/" : "/";
-    assertNotNull(site.resolve(pfx + "a"));
-    assertEquals(Paths.get(pfx + "a"), site.resolve(pfx + "a"));
+    PathSubject.assertThat(site.resolve(pfx + "a")).isNotNull();
+    PathSubject.assertThat(site.resolve(pfx + "a")).isEqualTo(Paths.get(pfx + "a"));
   }
 
   private static Path random() throws IOException {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/ChangeEditTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/edit/ChangeEditTest.java
index 8c963bd..6509c4b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/edit/ChangeEditTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/edit/ChangeEditTest.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
-
 import org.junit.Test;
 
 public class ChangeEditTest {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
index 6a006cd..355f775 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -22,13 +22,12 @@
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
-
 import org.junit.Test;
 
 public class EventDeserializerTest {
 
   @Test
-  public void testRefUpdatedEvent() {
+  public void refUpdatedEvent() {
     RefUpdatedEvent refUpdatedEvent = new RefUpdatedEvent();
 
     RefUpdateAttribute refUpdatedAttribute = new RefUpdateAttribute();
@@ -39,32 +38,32 @@
     accountAttribute.email = "some.user@domain.com";
     refUpdatedEvent.submitter = createSupplier(accountAttribute);
 
-    Gson gsonSerializer = new GsonBuilder()
-        .registerTypeAdapter(Supplier.class, new SupplierSerializer()).create();
+    Gson gsonSerializer =
+        new GsonBuilder().registerTypeAdapter(Supplier.class, new SupplierSerializer()).create();
     String serializedEvent = gsonSerializer.toJson(refUpdatedEvent);
 
-    Gson gsonDeserializer = new GsonBuilder()
-        .registerTypeAdapter(Event.class, new EventDeserializer())
-        .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-        .create();
+    Gson gsonDeserializer =
+        new GsonBuilder()
+            .registerTypeAdapter(Event.class, new EventDeserializer())
+            .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
+            .create();
 
-    RefUpdatedEvent e = (RefUpdatedEvent) gsonDeserializer
-        .fromJson(serializedEvent, Event.class);
+    RefUpdatedEvent e = (RefUpdatedEvent) gsonDeserializer.fromJson(serializedEvent, Event.class);
 
     assertThat(e).isNotNull();
     assertThat(e.refUpdate).isInstanceOf(Supplier.class);
-    assertThat(e.refUpdate.get().refName)
-        .isEqualTo(refUpdatedAttribute.refName);
+    assertThat(e.refUpdate.get().refName).isEqualTo(refUpdatedAttribute.refName);
     assertThat(e.submitter).isInstanceOf(Supplier.class);
     assertThat(e.submitter.get().email).isEqualTo(accountAttribute.email);
   }
 
   private <T> Supplier<T> createSupplier(final T value) {
-    return Suppliers.memoize(new Supplier<T>() {
-      @Override
-      public T get() {
-        return value;
-      }
-    });
+    return Suppliers.memoize(
+        new Supplier<T>() {
+          @Override
+          public T get() {
+            return value;
+          }
+        });
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java
index 3cbb59c..c822d6c 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventTypesTest.java
@@ -21,6 +21,7 @@
 public class EventTypesTest {
   public static class TestEvent extends Event {
     private static final String TYPE = "test-event";
+
     public TestEvent() {
       super(TYPE);
     }
@@ -28,22 +29,22 @@
 
   public static class AnotherTestEvent extends Event {
     private static final String TYPE = "another-test-event";
+
     public AnotherTestEvent() {
       super("another-test-event");
     }
   }
 
   @Test
-  public void testEventTypeRegistration() {
+  public void eventTypeRegistration() {
     EventTypes.register(TestEvent.TYPE, TestEvent.class);
     EventTypes.register(AnotherTestEvent.TYPE, AnotherTestEvent.class);
     assertThat(EventTypes.getClass(TestEvent.TYPE)).isEqualTo(TestEvent.class);
-    assertThat(EventTypes.getClass(AnotherTestEvent.TYPE))
-      .isEqualTo(AnotherTestEvent.class);
+    assertThat(EventTypes.getClass(AnotherTestEvent.TYPE)).isEqualTo(AnotherTestEvent.class);
   }
 
   @Test
-  public void testGetClassForNonExistingType() {
+  public void getClassForNonExistingType() {
     Class<?> clazz = EventTypes.getClass("does-not-exist-event");
     assertThat(clazz).isNull();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java
deleted file mode 100644
index 87bfa00..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java
+++ /dev/null
@@ -1,134 +0,0 @@
-package com.google.gerrit.server.git;
-
-import static org.junit.Assert.assertEquals;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.git.BatchUpdate.RepoContext;
-import com.google.gerrit.server.git.BatchUpdate.RepoOnlyOp;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-public class BatchUpdateTest {
-  @Inject
-  private AccountManager accountManager;
-
-  @Inject
-  private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject
-  private InMemoryDatabase schemaFactory;
-
-  @Inject
-  private InMemoryRepositoryManager repoManager;
-
-  @Inject
-  private SchemaCreator schemaCreator;
-
-  @Inject
-  private ThreadLocalRequestContext requestContext;
-
-  @Inject
-  private BatchUpdate.Factory batchUpdateFactory;
-
-  private LifecycleManager lifecycle;
-  private ReviewDb db;
-  private TestRepository<InMemoryRepository> repo;
-  private Project.NameKey project;
-  private IdentifiedUser user;
-
-  @Before
-  public void setUp() throws Exception {
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    injector.injectMembers(this);
-    lifecycle = new LifecycleManager();
-    lifecycle.add(injector);
-    lifecycle.start();
-
-    db = schemaFactory.open();
-    schemaCreator.create(db);
-    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user"))
-        .getAccountId();
-    user = userFactory.create(userId);
-
-    project = new Project.NameKey("test");
-
-    InMemoryRepository inMemoryRepo = repoManager.createRepository(project);
-    repo = new TestRepository<>(inMemoryRepo);
-
-    requestContext.setContext(new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return user;
-      }
-
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    });
-  }
-
-  @After
-  public void tearDown() {
-    if (repo != null) {
-      repo.getRepository().close();
-    }
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
-  }
-
-  @Test
-  public void addRefUpdateFromFastForwardCommit() throws Exception {
-    final RevCommit masterCommit = repo.branch("master").commit().create();
-    final RevCommit branchCommit =
-        repo.branch("branch").commit().parent(masterCommit).create();
-
-    try (BatchUpdate bu = batchUpdateFactory
-        .create(db, project, user, TimeUtil.nowTs())) {
-      bu.addRepoOnlyOp(new RepoOnlyOp() {
-        @Override
-        public void updateRepo(RepoContext ctx) throws Exception {
-          ctx.addRefUpdate(
-              new ReceiveCommand(masterCommit.getId(), branchCommit.getId(),
-                  "refs/heads/master"));
-        }
-      });
-      bu.execute();
-    }
-
-    assertEquals(
-        repo.getRepository().exactRef("refs/heads/master").getObjectId(),
-        branchCommit.getId());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
index fb046fd..e067632 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java
@@ -20,14 +20,11 @@
 
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
-
-import junit.framework.TestCase;
-
-import org.junit.Test;
-
 import java.io.IOException;
 import java.util.HashSet;
 import java.util.Set;
+import junit.framework.TestCase;
+import org.junit.Test;
 
 public class DestinationListTest extends TestCase {
   public static final String R_FOO = "refs/heads/foo";
@@ -62,6 +59,7 @@
   public static final Branch.NameKey B_COMPLEX = dest(P_COMPLEX, R_FOO);
 
   public static final Set<Branch.NameKey> D_SIMPLE = new HashSet<>();
+
   static {
     D_SIMPLE.clear();
     D_SIMPLE.add(B_FOO);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java
index ba0599d..e5e8e26 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupCollectorTest.java
@@ -17,12 +17,10 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.SortedSetMultimap;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -38,8 +36,7 @@
 
   @Before
   public void setUp() throws Exception {
-    tr = new TestRepository<>(
-        new InMemoryRepository(new DfsRepositoryDescription("repo")));
+    tr = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")));
   }
 
   @Test
@@ -47,58 +44,48 @@
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
 
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(a, branchTip),
-        patchSets(),
-        groups());
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(newWalk(a, branchTip), patchSets(), groups());
 
     assertThat(groups).containsEntry(a, a.name());
   }
 
   @Test
-  public void commitWhoseParentIsNewPatchSetGetsParentsGroup()
-      throws Exception {
+  public void commitWhoseParentIsNewPatchSetGetsParentsGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(a).create();
 
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(b, branchTip),
-        patchSets(),
-        groups());
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(newWalk(b, branchTip), patchSets(), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
   }
 
   @Test
-  public void commitWhoseParentIsExistingPatchSetGetsParentsGroup()
-      throws Exception {
+  public void commitWhoseParentIsExistingPatchSetGetsParentsGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(a).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(b, branchTip),
-        patchSets().put(a, psId(1, 1)),
-        groups().put(psId(1, 1), group));
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(
+            newWalk(b, branchTip), patchSets().put(a, psId(1, 1)), groups().put(psId(1, 1), group));
 
     assertThat(groups).containsEntry(a, group);
     assertThat(groups).containsEntry(b, group);
   }
 
   @Test
-  public void commitWhoseParentIsExistingPatchSetWithNoGroup()
-      throws Exception {
+  public void commitWhoseParentIsExistingPatchSetWithNoGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(a).create();
 
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(b, branchTip),
-        patchSets().put(a, psId(1, 1)),
-        groups());
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(newWalk(b, branchTip), patchSets().put(a, psId(1, 1)), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -111,10 +98,8 @@
     RevCommit b = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m, branchTip),
-        patchSets(),
-        groups());
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(newWalk(m, branchTip), patchSets(), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -129,10 +114,9 @@
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m, branchTip),
-        patchSets().put(b, psId(1, 1)),
-        groups().put(psId(1, 1), group));
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(
+            newWalk(m, branchTip), patchSets().put(b, psId(1, 1)), groups().put(psId(1, 1), group));
 
     // Merge commit and other parent get the existing group.
     assertThat(groups).containsEntry(a, group);
@@ -141,8 +125,7 @@
   }
 
   @Test
-  public void mergeCommitWhereBothParentsHaveDifferentGroups()
-      throws Exception {
+  public void mergeCommitWhereBothParentsHaveDifferentGroups() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(branchTip).create();
@@ -150,20 +133,16 @@
 
     String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     String group2 = "1234567812345678123456781234567812345678";
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m, branchTip),
-        patchSets()
-            .put(a, psId(1, 1))
-            .put(b, psId(2, 1)),
-        groups()
-            .put(psId(1, 1), group1)
-            .put(psId(2, 1), group2));
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(
+            newWalk(m, branchTip),
+            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
+            groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
 
     assertThat(groups).containsEntry(a, group1);
     assertThat(groups).containsEntry(b, group2);
     // Merge commit gets joined group of parents.
-    assertThat(groups.asMap())
-        .containsEntry(m, ImmutableSet.of(group1, group2));
+    assertThat(groups.asMap()).containsEntry(m, ImmutableSet.of(group1, group2));
   }
 
   @Test
@@ -176,60 +155,48 @@
     String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     String group2a = "1234567812345678123456781234567812345678";
     String group2b = "ef123456ef123456ef123456ef123456ef123456";
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m, branchTip),
-        patchSets()
-            .put(a, psId(1, 1))
-            .put(b, psId(2, 1)),
-        groups()
-            .put(psId(1, 1), group1)
-            .put(psId(2, 1), group2a)
-            .put(psId(2, 1), group2b));
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(
+            newWalk(m, branchTip),
+            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
+            groups().put(psId(1, 1), group1).put(psId(2, 1), group2a).put(psId(2, 1), group2b));
 
     assertThat(groups).containsEntry(a, group1);
-    assertThat(groups.asMap())
-        .containsEntry(b, ImmutableSet.of(group2a, group2b));
+    assertThat(groups.asMap()).containsEntry(b, ImmutableSet.of(group2a, group2b));
     // Joined parent groups are split and resorted.
-    assertThat(groups.asMap())
-        .containsEntry(m, ImmutableSet.of(group1, group2a, group2b));
+    assertThat(groups.asMap()).containsEntry(m, ImmutableSet.of(group1, group2a, group2b));
   }
 
   @Test
-  public void mergeCommitWithOneUninterestingParentAndOtherParentIsExisting()
-      throws Exception {
+  public void mergeCommitWithOneUninterestingParentAndOtherParentIsExisting() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(branchTip).parent(a).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m, branchTip),
-        patchSets().put(a, psId(1, 1)),
-        groups().put(psId(1, 1), group));
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(
+            newWalk(m, branchTip), patchSets().put(a, psId(1, 1)), groups().put(psId(1, 1), group));
 
     assertThat(groups).containsEntry(a, group);
     assertThat(groups).containsEntry(m, group);
   }
 
   @Test
-  public void mergeCommitWithOneUninterestingParentAndOtherParentIsNew()
-      throws Exception {
+  public void mergeCommitWithOneUninterestingParentAndOtherParentIsNew() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(branchTip).parent(a).create();
 
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m, branchTip),
-        patchSets(),
-        groups());
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(newWalk(m, branchTip), patchSets(), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(m, a.name());
   }
 
   @Test
-  public void multipleMergeCommitsInHistoryAllResolveToSameGroup()
-      throws Exception {
+  public void multipleMergeCommitsInHistoryAllResolveToSameGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(branchTip).create();
@@ -237,10 +204,8 @@
     RevCommit m1 = tr.commit().parent(b).parent(c).create();
     RevCommit m2 = tr.commit().parent(a).parent(m1).create();
 
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m2, branchTip),
-        patchSets(),
-        groups());
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(newWalk(m2, branchTip), patchSets(), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -250,8 +215,7 @@
   }
 
   @Test
-  public void mergeCommitWithDuplicatedParentGetsParentsGroup()
-      throws Exception {
+  public void mergeCommitWithDuplicatedParentGetsParentsGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(a).parent(a).create();
@@ -259,18 +223,15 @@
     assertThat(m.getParentCount()).isEqualTo(2);
     assertThat(m.getParent(0)).isEqualTo(m.getParent(1));
 
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m, branchTip),
-        patchSets(),
-        groups());
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(newWalk(m, branchTip), patchSets(), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(m, a.name());
   }
 
   @Test
-  public void mergeCommitWithOneNewParentAndTwoExistingPatchSets()
-      throws Exception {
+  public void mergeCommitWithOneNewParentAndTwoExistingPatchSets() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(branchTip).create();
@@ -279,20 +240,16 @@
 
     String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     String group2 = "1234567812345678123456781234567812345678";
-    Multimap<ObjectId, String> groups = collectGroups(
-        newWalk(m, branchTip),
-        patchSets()
-            .put(a, psId(1, 1))
-            .put(b, psId(2, 1)),
-        groups()
-            .put(psId(1, 1), group1)
-            .put(psId(2, 1), group2));
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(
+            newWalk(m, branchTip),
+            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
+            groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
 
     assertThat(groups).containsEntry(a, group1);
     assertThat(groups).containsEntry(b, group2);
     assertThat(groups).containsEntry(c, group2);
-    assertThat(groups.asMap())
-        .containsEntry(m, ImmutableSet.of(group1, group2));
+    assertThat(groups.asMap()).containsEntry(m, ImmutableSet.of(group1, group2));
   }
 
   @Test
@@ -307,15 +264,16 @@
     rw.markStart(rw.parseCommit(d));
     // Schema upgrade case: all commits are existing patch sets, but none have
     // groups assigned yet.
-    Multimap<ObjectId, String> groups = collectGroups(
-        rw,
-        patchSets()
-            .put(branchTip, psId(1, 1))
-            .put(a, psId(2, 1))
-            .put(b, psId(3, 1))
-            .put(c, psId(4, 1))
-            .put(d, psId(5, 1)),
-        groups());
+    SortedSetMultimap<ObjectId, String> groups =
+        collectGroups(
+            rw,
+            patchSets()
+                .put(branchTip, psId(1, 1))
+                .put(a, psId(2, 1))
+                .put(b, psId(3, 1))
+                .put(c, psId(4, 1))
+                .put(d, psId(5, 1)),
+            groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -339,13 +297,12 @@
     return rw;
   }
 
-  private static Multimap<ObjectId, String> collectGroups(
+  private static SortedSetMultimap<ObjectId, String> collectGroups(
       RevWalk rw,
-      ImmutableMultimap.Builder<ObjectId, PatchSet.Id> patchSetsBySha,
+      ImmutableListMultimap.Builder<ObjectId, PatchSet.Id> patchSetsBySha,
       ImmutableListMultimap.Builder<PatchSet.Id, String> groupLookup)
       throws Exception {
-    GroupCollector gc =
-        new GroupCollector(patchSetsBySha.build(), groupLookup.build());
+    GroupCollector gc = new GroupCollector(patchSetsBySha.build(), groupLookup.build());
     RevCommit c;
     while ((c = rw.next()) != null) {
       gc.visit(c);
@@ -355,8 +312,8 @@
 
   // Helper methods for constructing various map arguments, to avoid lots of
   // type specifications.
-  private static ImmutableMultimap.Builder<ObjectId, PatchSet.Id> patchSets() {
-    return ImmutableMultimap.builder();
+  private static ImmutableListMultimap.Builder<ObjectId, PatchSet.Id> patchSets() {
+    return ImmutableListMultimap.builder();
   }
 
   private static ImmutableListMultimap.Builder<PatchSet.Id, String> groups() {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java
index fde86a5..30f0ed5 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/GroupListTest.java
@@ -27,19 +27,19 @@
 
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-
-import org.junit.Before;
-import org.junit.Test;
-
+import com.google.gerrit.reviewdb.client.Project;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
 
 public class GroupListTest {
-
+  private static final Project.NameKey PROJECT = new Project.NameKey("project");
   private static final String TEXT =
-      "# UUID                                  \tGroup Name\n" + "#\n"
+      "# UUID                                  \tGroup Name\n"
+          + "#\n"
           + "d96b998f8a66ff433af50befb975d0e2bb6e0999\tNon-Interactive Users\n"
           + "ebe31c01aec2c9ac3b3c03e87a47450829ff4310\tAdministrators\n";
 
@@ -49,13 +49,12 @@
   public void setup() throws IOException {
     ValidationError.Sink sink = createNiceMock(ValidationError.Sink.class);
     replay(sink);
-    groupList = GroupList.parse(TEXT, sink);
+    groupList = GroupList.parse(PROJECT, TEXT, sink);
   }
 
   @Test
-  public void testByUUID() throws Exception {
-    AccountGroup.UUID uuid =
-        new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
+  public void byUUID() throws Exception {
+    AccountGroup.UUID uuid = new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
 
     GroupReference groupReference = groupList.byUUID(uuid);
 
@@ -64,7 +63,7 @@
   }
 
   @Test
-  public void testPut() {
+  public void put() {
     AccountGroup.UUID uuid = new AccountGroup.UUID("abc");
     GroupReference groupReference = new GroupReference(uuid, "Hutzliputz");
 
@@ -76,50 +75,46 @@
   }
 
   @Test
-  public void testReferences() throws Exception {
+  public void references() throws Exception {
     Collection<GroupReference> result = groupList.references();
 
     assertEquals(2, result.size());
-    AccountGroup.UUID uuid =
-        new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
+    AccountGroup.UUID uuid = new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
     GroupReference expected = new GroupReference(uuid, "Administrators");
 
     assertTrue(result.contains(expected));
   }
 
   @Test
-  public void testUUIDs() throws Exception {
+  public void uUIDs() throws Exception {
     Set<AccountGroup.UUID> result = groupList.uuids();
 
     assertEquals(2, result.size());
-    AccountGroup.UUID expected =
-        new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
+    AccountGroup.UUID expected = new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
     assertTrue(result.contains(expected));
   }
 
   @Test
-  public void testValidationError() throws Exception {
+  public void validationError() throws Exception {
     ValidationError.Sink sink = createMock(ValidationError.Sink.class);
     sink.error(anyObject(ValidationError.class));
     expectLastCall().times(2);
     replay(sink);
-    groupList = GroupList.parse(TEXT.replace("\t", "    "), sink);
+    groupList = GroupList.parse(PROJECT, TEXT.replace("\t", "    "), sink);
     verify(sink);
   }
 
   @Test
-  public void testRetainAll() throws Exception {
-    AccountGroup.UUID uuid =
-        new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
+  public void retainAll() throws Exception {
+    AccountGroup.UUID uuid = new AccountGroup.UUID("d96b998f8a66ff433af50befb975d0e2bb6e0999");
     groupList.retainUUIDs(Collections.singleton(uuid));
 
     assertNotNull(groupList.byUUID(uuid));
-    assertNull(groupList.byUUID(new AccountGroup.UUID(
-        "ebe31c01aec2c9ac3b3c03e87a47450829ff4310")));
+    assertNull(groupList.byUUID(new AccountGroup.UUID("ebe31c01aec2c9ac3b3c03e87a47450829ff4310")));
   }
 
   @Test
-  public void testAsText() throws Exception {
+  public void asText() throws Exception {
     assertTrue(TEXT.equals(groupList.asText()));
   }
 }
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 aa23e50..eabccf7 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
@@ -51,14 +51,12 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
-
+import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.util.List;
-
 /** Unit tests for {@link LabelNormalizer}. */
 public class LabelNormalizerTest {
   @Inject private AccountManager accountManager;
@@ -88,21 +86,21 @@
 
     db = schemaFactory.open();
     schemaCreator.create(db);
-    userId = accountManager.authenticate(AuthRequest.forUser("user"))
-        .getAccountId();
+    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     user = userFactory.create(userId);
 
-    requestContext.setContext(new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return user;
-      }
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
 
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    });
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
 
     configureProject();
     setUpChange();
@@ -115,20 +113,20 @@
         sec.removePermission(forLabel(label));
       }
     }
-    LabelType lt = category("Verified",
-        value(1, "Verified"),
-        value(0, "No score"),
-        value(-1, "Fails"));
+    LabelType lt =
+        category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
     pc.getLabelSections().put(lt.getName(), lt);
     save(pc);
   }
 
   private void setUpChange() throws Exception {
-    change = new Change(
-        new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
-        new Change.Id(1), userId,
-        new Branch.NameKey(allProjects, "refs/heads/master"),
-        TimeUtil.nowTs());
+    change =
+        new Change(
+            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
+            new Change.Id(1),
+            userId,
+            new Branch.NameKey(allProjects, "refs/heads/master"),
+            TimeUtil.nowTs());
     PatchSetInfo ps = new PatchSetInfo(new PatchSet.Id(change.getId(), 1));
     ps.setSubject("Test change");
     change.setCurrentPatchSet(ps);
@@ -156,11 +154,8 @@
 
     PatchSetApproval cr = psa(userId, "Code-Review", 2);
     PatchSetApproval v = psa(userId, "Verified", 1);
-    assertEquals(Result.create(
-          list(v),
-          list(copy(cr, 1)),
-          list()),
-        norm.normalize(change, list(cr, v)));
+    assertEquals(
+        Result.create(list(v), list(copy(cr, 1)), list()), norm.normalize(change, list(cr, v)));
   }
 
   @Test
@@ -172,10 +167,8 @@
 
     PatchSetApproval cr = psa(userId, "Code-Review", 5);
     PatchSetApproval v = psa(userId, "Verified", 5);
-    assertEquals(Result.create(
-          list(),
-          list(copy(cr, 2), copy(v, 1)),
-          list()),
+    assertEquals(
+        Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
         norm.normalize(change, list(cr, v)));
   }
 
@@ -183,11 +176,7 @@
   public void emptyPermissionRangeOmitsResult() throws Exception {
     PatchSetApproval cr = psa(userId, "Code-Review", 1);
     PatchSetApproval v = psa(userId, "Verified", 1);
-    assertEquals(Result.create(
-          list(),
-          list(),
-          list(cr, v)),
-        norm.normalize(change, list(cr, v)));
+    assertEquals(Result.create(list(), list(), list(cr, v)), norm.normalize(change, list(cr, v)));
   }
 
   @Test
@@ -198,11 +187,7 @@
 
     PatchSetApproval cr = psa(userId, "Code-Review", 0);
     PatchSetApproval v = psa(userId, "Verified", 0);
-    assertEquals(Result.create(
-          list(cr),
-          list(),
-          list(v)),
-        norm.normalize(change, list(cr, v)));
+    assertEquals(Result.create(list(cr), list(), list(v)), norm.normalize(change, list(cr, v)));
   }
 
   private ProjectConfig loadAllProjects() throws Exception {
@@ -214,8 +199,7 @@
   }
 
   private void save(ProjectConfig pc) throws Exception {
-    try (MetaDataUpdate md =
-        metaDataUpdateFactory.create(pc.getProject().getNameKey(), user)) {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(pc.getProject().getNameKey(), user)) {
       pc.commit(md);
       projectCache.evict(pc.getProject().getNameKey());
     }
@@ -223,19 +207,18 @@
 
   private PatchSetApproval psa(Account.Id accountId, String label, int value) {
     return new PatchSetApproval(
-        new PatchSetApproval.Key(
-          change.currentPatchSetId(), accountId, new LabelId(label)),
-        (short) value, TimeUtil.nowTs());
+        new PatchSetApproval.Key(change.currentPatchSetId(), accountId, new LabelId(label)),
+        (short) value,
+        TimeUtil.nowTs());
   }
 
   private PatchSetApproval copy(PatchSetApproval src, int newValue) {
-    PatchSetApproval result =
-        new PatchSetApproval(src.getKey().getParentKey(), src);
+    PatchSetApproval result = new PatchSetApproval(src.getKey().getParentKey(), src);
     result.setValue((short) newValue);
     return result;
   }
 
   private static List<PatchSetApproval> list(PatchSetApproval... psas) {
-    return ImmutableList.<PatchSetApproval> copyOf(psas);
+    return ImmutableList.<PatchSetApproval>copyOf(psas);
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index 86fa0db..286f694 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.HostPlatform;
 import com.google.gerrit.testutil.TempFileUtil;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.StandardKeyEncoder;
-
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import org.easymock.EasyMockSupport;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -33,9 +37,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-import java.nio.file.Path;
-
 public class LocalDiskRepositoryManagerTest extends EasyMockSupport {
 
   static {
@@ -53,7 +54,6 @@
     cfg = new Config();
     cfg.setString("gerrit", null, "basePath", "git");
     repoManager = new LocalDiskRepositoryManager(site, cfg);
-    repoManager.start();
   }
 
   @Test(expected = IllegalStateException.class)
@@ -62,7 +62,7 @@
   }
 
   @Test
-  public void testProjectCreation() throws Exception {
+  public void projectCreation() throws Exception {
     Project.NameKey projectA = new Project.NameKey("projectA");
     try (Repository repo = repoManager.createRepository(projectA)) {
       assertThat(repo).isNotNull();
@@ -114,8 +114,7 @@
   }
 
   @Test(expected = RepositoryNotFoundException.class)
-  public void testProjectCreationWithPathSegmentEndingByDotGit()
-      throws Exception {
+  public void testProjectCreationWithPathSegmentEndingByDotGit() throws Exception {
     repoManager.createRepository(new Project.NameKey("a/b.git/projectA"));
   }
 
@@ -164,8 +163,21 @@
     repoManager.createRepository(new Project.NameKey("project\\rA"));
   }
 
+  @Test(expected = IllegalStateException.class)
+  public void testProjectRecreation() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(new Project.NameKey("a"));
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testProjectRecreationAfterRestart() throws Exception {
+    repoManager.createRepository(new Project.NameKey("a"));
+    LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
+    newRepoManager.createRepository(new Project.NameKey("a"));
+  }
+
   @Test
-  public void testOpenRepositoryCreatedDirectlyOnDisk() throws Exception {
+  public void openRepositoryCreatedDirectlyOnDisk() throws Exception {
     Project.NameKey projectA = new Project.NameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
     try (Repository repo = repoManager.openRepository(projectA)) {
@@ -174,13 +186,46 @@
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatch() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    repoManager.createRepository(new Project.NameKey("a"));
+    repoManager.createRepository(new Project.NameKey("A"));
+  }
+
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatchWithSymlink() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    Project.NameKey name = new Project.NameKey("a");
+    repoManager.createRepository(name);
+    createSymLink(name, "b.git");
+    repoManager.createRepository(new Project.NameKey("B"));
+  }
+
+  @Test(expected = RepositoryCaseMismatchException.class)
+  public void testNameCaseMismatchAfterRestart() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    Project.NameKey name = new Project.NameKey("a");
+    repoManager.createRepository(name);
+
+    LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
+    newRepoManager.createRepository(new Project.NameKey("A"));
+  }
+
+  private void createSymLink(Project.NameKey project, String link) throws IOException {
+    Path base = repoManager.getBasePath(project);
+    Path projectDir = base.resolve(project.get() + ".git");
+    Path symlink = base.resolve(link);
+    Files.createSymbolicLink(symlink, projectDir);
+  }
+
   @Test(expected = RepositoryNotFoundException.class)
   public void testOpenRepositoryInvalidName() throws Exception {
     repoManager.openRepository(new Project.NameKey("project%?|<>A"));
   }
 
   @Test
-  public void testList() throws Exception {
+  public void list() throws Exception {
     Project.NameKey projectA = new Project.NameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
 
@@ -193,34 +238,10 @@
     repoManager.getBasePath(null).resolve(".git").toFile().mkdir();
     // create an invalid repo name
     createRepository(repoManager.getBasePath(null), "project?A");
-    assertThat(repoManager.list())
-        .containsExactly(projectA, projectB, projectC);
+    assertThat(repoManager.list()).containsExactly(projectA, projectB, projectC);
   }
 
-  @Test
-  public void testGetSetProjectDescription() throws Exception {
-    Project.NameKey projectA = new Project.NameKey("projectA");
-    try (Repository repo = repoManager.createRepository(projectA)) {
-      assertThat(repo).isNotNull();
-    }
-
-    assertThat(repoManager.getProjectDescription(projectA)).isNull();
-    repoManager.setProjectDescription(projectA, "projectA description");
-    assertThat(repoManager.getProjectDescription(projectA)).isEqualTo(
-        "projectA description");
-
-    repoManager.setProjectDescription(projectA, "");
-    assertThat(repoManager.getProjectDescription(projectA)).isNull();
-  }
-
-  @Test(expected = RepositoryNotFoundException.class)
-  public void testGetProjectDescriptionFromUnexistingRepository()
-      throws Exception {
-    repoManager.getProjectDescription(new Project.NameKey("projectA"));
-  }
-
-  private void createRepository(Path directory, String projectName)
-      throws IOException {
+  private void createRepository(Path directory, String projectName) throws IOException {
     String n = projectName + Constants.DOT_GIT_EXT;
     FileKey loc = FileKey.exact(directory.resolve(n).toFile(), FS.DETECTED);
     try (Repository db = RepositoryCache.open(loc, false)) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index b26a228..842ddbd 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -23,10 +23,14 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.TempFileUtil;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.SortedSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -38,19 +42,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.SortedSet;
-
-public class MultiBaseLocalDiskRepositoryManagerTest {
-
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
+public class MultiBaseLocalDiskRepositoryManagerTest extends GerritBaseTests {
   private Config cfg;
   private SitePaths site;
   private MultiBaseLocalDiskRepositoryManager repoManager;
@@ -65,8 +57,7 @@
     configMock = createNiceMock(RepositoryConfig.class);
     expect(configMock.getAllBasePaths()).andReturn(new ArrayList<Path>()).anyTimes();
     replay(configMock);
-    repoManager =
-        new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
   }
 
   @After
@@ -75,101 +66,87 @@
   }
 
   @Test
-  public void testDefaultRepositoryLocation()
-      throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+  public void defaultRepositoryLocation()
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException {
     Project.NameKey someProjectKey = new Project.NameKey("someProject");
     Repository repo = repoManager.createRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent()).isEqualTo(
-        repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+    assertThat(repo.getDirectory().getParent())
+        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
 
     repo = repoManager.openRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent()).isEqualTo(
-        repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+    assertThat(repo.getDirectory().getParent())
+        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
 
-    assertThat(
-        repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
-        .isEqualTo(
-            repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+    assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
+        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
 
     SortedSet<Project.NameKey> repoList = repoManager.list();
-    assertThat(repoList.size()).isEqualTo(1);
+    assertThat(repoList).hasSize(1);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {someProjectKey});
   }
 
   @Test
-  public void testAlternateRepositoryLocation() throws IOException {
+  public void alternateRepositoryLocation() throws IOException {
     Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
     Project.NameKey someProjectKey = new Project.NameKey("someProject");
     reset(configMock);
-    expect(configMock.getBasePath(someProjectKey)).andReturn(alternateBasePath)
-        .anyTimes();
-    expect(configMock.getAllBasePaths())
-        .andReturn(Arrays.asList(alternateBasePath)).anyTimes();
+    expect(configMock.getBasePath(someProjectKey)).andReturn(alternateBasePath).anyTimes();
+    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(alternateBasePath)).anyTimes();
     replay(configMock);
 
     Repository repo = repoManager.createRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent())
-        .isEqualTo(alternateBasePath.toString());
+    assertThat(repo.getDirectory().getParent()).isEqualTo(alternateBasePath.toString());
 
     repo = repoManager.openRepository(someProjectKey);
     assertThat(repo.getDirectory()).isNotNull();
     assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent())
+    assertThat(repo.getDirectory().getParent()).isEqualTo(alternateBasePath.toString());
+
+    assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(alternateBasePath.toString());
 
-    assertThat(
-        repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
-            .isEqualTo(alternateBasePath.toString());
-
     SortedSet<Project.NameKey> repoList = repoManager.list();
-    assertThat(repoList.size()).isEqualTo(1);
+    assertThat(repoList).hasSize(1);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {someProjectKey});
   }
 
   @Test
-  public void testListReturnRepoFromProperLocation() throws IOException {
+  public void listReturnRepoFromProperLocation() throws IOException {
     Project.NameKey basePathProject = new Project.NameKey("basePathProject");
     Project.NameKey altPathProject = new Project.NameKey("altPathProject");
-    Project.NameKey misplacedProject1 =
-        new Project.NameKey("misplacedProject1");
-    Project.NameKey misplacedProject2 =
-        new Project.NameKey("misplacedProject2");
+    Project.NameKey misplacedProject1 = new Project.NameKey("misplacedProject1");
+    Project.NameKey misplacedProject2 = new Project.NameKey("misplacedProject2");
 
     Path alternateBasePath = TempFileUtil.createTempDirectory().toPath();
 
     reset(configMock);
-    expect(configMock.getBasePath(altPathProject)).andReturn(alternateBasePath)
-        .anyTimes();
-    expect(configMock.getBasePath(misplacedProject2))
-        .andReturn(alternateBasePath).anyTimes();
-    expect(configMock.getAllBasePaths())
-        .andReturn(Arrays.asList(alternateBasePath)).anyTimes();
+    expect(configMock.getBasePath(altPathProject)).andReturn(alternateBasePath).anyTimes();
+    expect(configMock.getBasePath(misplacedProject2)).andReturn(alternateBasePath).anyTimes();
+    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(alternateBasePath)).anyTimes();
     replay(configMock);
 
     repoManager.createRepository(basePathProject);
     repoManager.createRepository(altPathProject);
     // create the misplaced ones without the repomanager otherwise they would
     // end up at the proper place.
-    createRepository(repoManager.getBasePath(basePathProject),
-        misplacedProject2);
+    createRepository(repoManager.getBasePath(basePathProject), misplacedProject2);
     createRepository(alternateBasePath, misplacedProject1);
 
     SortedSet<Project.NameKey> repoList = repoManager.list();
-    assertThat(repoList.size()).isEqualTo(2);
+    assertThat(repoList).hasSize(2);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {altPathProject, basePathProject});
   }
 
-  private void createRepository(Path directory, Project.NameKey projectName)
-      throws IOException {
+  private void createRepository(Path directory, Project.NameKey projectName) throws IOException {
     String n = projectName.get() + Constants.DOT_GIT_EXT;
     FileKey loc = FileKey.exact(directory.resolve(n).toFile(), FS.DETECTED);
     try (Repository db = RepositoryCache.open(loc, false)) {
@@ -180,10 +157,8 @@
   @Test(expected = IllegalStateException.class)
   public void testRelativeAlternateLocation() {
     configMock = createNiceMock(RepositoryConfig.class);
-    expect(configMock.getAllBasePaths())
-        .andReturn(Arrays.asList(Paths.get("repos"))).anyTimes();
+    expect(configMock.getAllBasePaths()).andReturn(Arrays.asList(Paths.get("repos"))).anyTimes();
     replay(configMock);
-    repoManager =
-        new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
+    repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
index 0757a26..ad9a1ce 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
@@ -27,8 +27,13 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-
+import com.google.gerrit.server.project.CommentLinkInfoImpl;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -44,27 +49,30 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import java.io.IOException;
-import java.util.Collections;
-import java.util.Map;
-
 public class ProjectConfigTest extends LocalDiskRepositoryTestCase {
   private static final String LABEL_SCORES_CONFIG =
-      "  copyMinScore = " + !LabelType.DEF_COPY_MIN_SCORE + "\n" //
-      + "  copyMaxScore = " + !LabelType.DEF_COPY_MAX_SCORE + "\n" //
-      + "  copyAllScoresOnMergeFirstParentUpdate = "
-      + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE + "\n" //
-      + "  copyAllScoresOnTrivialRebase = "
-      + !LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE + "\n" //
-      + "  copyAllScoresIfNoCodeChange = "
-      + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE + "\n" //
-      + "  copyAllScoresIfNoChange = "
-      + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE + "\n";
+      "  copyMinScore = "
+          + !LabelType.DEF_COPY_MIN_SCORE
+          + "\n" //
+          + "  copyMaxScore = "
+          + !LabelType.DEF_COPY_MAX_SCORE
+          + "\n" //
+          + "  copyAllScoresOnMergeFirstParentUpdate = "
+          + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE
+          + "\n" //
+          + "  copyAllScoresOnTrivialRebase = "
+          + !LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE
+          + "\n" //
+          + "  copyAllScoresIfNoCodeChange = "
+          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE
+          + "\n" //
+          + "  copyAllScoresIfNoChange = "
+          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE
+          + "\n";
 
-  private final GroupReference developers = new GroupReference(
-      new AccountGroup.UUID("X"), "Developers");
-  private final GroupReference staff = new GroupReference(
-      new AccountGroup.UUID("Y"), "Staff");
+  private final GroupReference developers =
+      new GroupReference(new AccountGroup.UUID("X"), "Developers");
+  private final GroupReference staff = new GroupReference(new AccountGroup.UUID("Y"), "Staff");
 
   private Repository db;
   private TestRepository<Repository> util;
@@ -78,25 +86,30 @@
   }
 
   @Test
-  public void testReadConfig() throws Exception {
-    RevCommit rev = util.commit(util.tree( //
-        util.file("groups", util.blob(group(developers))), //
-        util.file("project.config", util.blob(""//
-            + "[access \"refs/heads/*\"]\n" //
-            + "  exclusiveGroupPermissions = read submit create\n" //
-            + "  submit = group Developers\n" //
-            + "  push = group Developers\n" //
-            + "  read = group Developers\n" //
-            + "[accounts]\n" //
-            + "  sameGroupVisibility = deny group Developers\n" //
-            + "  sameGroupVisibility = block group Staff\n" //
-            + "[contributor-agreement \"Individual\"]\n" //
-            + "  description = A simple description\n" //
-            + "  accepted = group Developers\n" //
-            + "  accepted = group Staff\n" //
-            + "  autoVerify = group Developers\n" //
-            + "  agreementUrl = http://www.example.com/agree\n")) //
-        ));
+  public void readConfig() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[access \"refs/heads/*\"]\n" //
+                            + "  exclusiveGroupPermissions = read submit create\n" //
+                            + "  submit = group Developers\n" //
+                            + "  push = group Developers\n" //
+                            + "  read = group Developers\n" //
+                            + "[accounts]\n" //
+                            + "  sameGroupVisibility = deny group Developers\n" //
+                            + "  sameGroupVisibility = block group Staff\n" //
+                            + "[contributor-agreement \"Individual\"]\n" //
+                            + "  description = A simple description\n" //
+                            + "  accepted = group Developers\n" //
+                            + "  accepted = group Staff\n" //
+                            + "  autoVerify = group Developers\n" //
+                            + "  agreementUrl = http://www.example.com/agree\n")) //
+                ));
 
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getAccountsSection().getSameGroupVisibility()).hasSize(2);
@@ -125,67 +138,86 @@
   }
 
   @Test
-  public void testReadConfigLabelDefaultValue() throws Exception {
-    RevCommit rev = util.commit(util.tree( //
-        util.file("groups", util.blob(group(developers))), //
-        util.file("project.config", util.blob(""//
-            + "[label \"CustomLabel\"]\n" //
-            + "  value = -1 Negative\n" //
-            + "  value =  0 No Score\n" //
-            + "  value =  1 Positive\n")) //
-        ));
+  public void readConfigLabelDefaultValue() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[label \"CustomLabel\"]\n" //
+                            + "  value = -1 Negative\n" //
+                            + "  value =  0 No Score\n" //
+                            + "  value =  1 Positive\n")) //
+                ));
 
     ProjectConfig cfg = read(rev);
     Map<String, LabelType> labels = cfg.getLabelSections();
     Short dv = labels.entrySet().iterator().next().getValue().getDefaultValue();
-    assertThat((int)dv).isEqualTo(0);
+    assertThat((int) dv).isEqualTo(0);
   }
 
   @Test
-  public void testReadConfigLabelDefaultValueInRange() throws Exception {
-    RevCommit rev = util.commit(util.tree( //
-        util.file("groups", util.blob(group(developers))), //
-        util.file("project.config", util.blob(""//
-            + "[label \"CustomLabel\"]\n" //
-            + "  value = -1 Negative\n" //
-            + "  value =  0 No Score\n" //
-            + "  value =  1 Positive\n" //
-            + "  defaultValue = -1\n")) //
-        ));
+  public void readConfigLabelDefaultValueInRange() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[label \"CustomLabel\"]\n" //
+                            + "  value = -1 Negative\n" //
+                            + "  value =  0 No Score\n" //
+                            + "  value =  1 Positive\n" //
+                            + "  defaultValue = -1\n")) //
+                ));
 
     ProjectConfig cfg = read(rev);
     Map<String, LabelType> labels = cfg.getLabelSections();
     Short dv = labels.entrySet().iterator().next().getValue().getDefaultValue();
-    assertThat((int)dv).isEqualTo(-1);
+    assertThat((int) dv).isEqualTo(-1);
   }
 
   @Test
-  public void testReadConfigLabelDefaultValueNotInRange() throws Exception {
-    RevCommit rev = util.commit(util.tree( //
-        util.file("groups", util.blob(group(developers))), //
-        util.file("project.config", util.blob(""//
-            + "[label \"CustomLabel\"]\n" //
-            + "  value = -1 Negative\n" //
-            + "  value =  0 No Score\n" //
-            + "  value =  1 Positive\n" //
-            + "  defaultValue = -2\n")) //
-        ));
+  public void readConfigLabelDefaultValueNotInRange() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[label \"CustomLabel\"]\n" //
+                            + "  value = -1 Negative\n" //
+                            + "  value =  0 No Score\n" //
+                            + "  value =  1 Positive\n" //
+                            + "  defaultValue = -2\n")) //
+                ));
 
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getValidationErrors()).hasSize(1);
     assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
-      .isEqualTo("project.config: Invalid defaultValue \"-2\" "
-        + "for label \"CustomLabel\"");
+        .isEqualTo("project.config: Invalid defaultValue \"-2\" for label \"CustomLabel\"");
   }
 
   @Test
-  public void testReadConfigLabelScores() throws Exception {
-    RevCommit rev = util.commit(util.tree( //
-        util.file("groups", util.blob(group(developers))), //
-        util.file("project.config", util.blob(""//
-            + "[label \"CustomLabel\"]\n" //
-            + LABEL_SCORES_CONFIG)) //
-        ));
+  public void readConfigLabelScores() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[label \"CustomLabel\"]\n" //
+                            + LABEL_SCORES_CONFIG)) //
+                ));
 
     ProjectConfig cfg = read(rev);
     Map<String, LabelType> labels = cfg.getLabelSections();
@@ -193,42 +225,47 @@
     assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
     assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
     assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
-      .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
     assertThat(type.isCopyAllScoresOnTrivialRebase())
-      .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
     assertThat(type.isCopyAllScoresIfNoCodeChange())
-      .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
     assertThat(type.isCopyAllScoresIfNoChange())
-      .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
   }
 
   @Test
-  public void testEditConfig() throws Exception {
-    RevCommit rev = util.commit(util.tree( //
-        util.file("groups", util.blob(group(developers))), //
-        util.file("project.config", util.blob(""//
-            + "[access \"refs/heads/*\"]\n" //
-            + "  exclusiveGroupPermissions = read submit\n" //
-            + "  submit = group Developers\n" //
-            + "  upload = group Developers\n" //
-            + "  read = group Developers\n" //
-            + "[accounts]\n" //
-            + "  sameGroupVisibility = deny group Developers\n" //
-            + "  sameGroupVisibility = block group Staff\n" //
-            + "[contributor-agreement \"Individual\"]\n" //
-            + "  description = A simple description\n" //
-            + "  accepted = group Developers\n" //
-            + "  autoVerify = group Developers\n" //
-            + "  agreementUrl = http://www.example.com/agree\n" //
-            + "[label \"CustomLabel\"]\n" //
-            + LABEL_SCORES_CONFIG)) //
-        ));
+  public void editConfig() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[access \"refs/heads/*\"]\n" //
+                            + "  exclusiveGroupPermissions = read submit\n" //
+                            + "  submit = group Developers\n" //
+                            + "  upload = group Developers\n" //
+                            + "  read = group Developers\n" //
+                            + "[accounts]\n" //
+                            + "  sameGroupVisibility = deny group Developers\n" //
+                            + "  sameGroupVisibility = block group Staff\n" //
+                            + "[contributor-agreement \"Individual\"]\n" //
+                            + "  description = A simple description\n" //
+                            + "  accepted = group Developers\n" //
+                            + "  autoVerify = group Developers\n" //
+                            + "  agreementUrl = http://www.example.com/agree\n" //
+                            + "[label \"CustomLabel\"]\n" //
+                            + LABEL_SCORES_CONFIG)) //
+                ));
     update(rev);
 
     ProjectConfig cfg = read(rev);
     AccessSection section = cfg.getAccessSection("refs/heads/*");
-    cfg.getAccountsSection().setSameGroupVisibility(
-        Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
+    cfg.getAccountsSection()
+        .setSameGroupVisibility(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
     Permission submit = section.getPermission(Permission.SUBMIT);
     submit.add(new PermissionRule(cfg.resolve(staff)));
     ContributorAgreement ca = cfg.getContributorAgreement("Individual");
@@ -236,36 +273,43 @@
     ca.setAutoVerify(null);
     ca.setDescription("A new description");
     rev = commit(cfg);
-    assertThat(text(rev, "project.config")).isEqualTo(""//
-        + "[access \"refs/heads/*\"]\n" //
-        + "  exclusiveGroupPermissions = read submit\n" //
-        + "  submit = group Developers\n" //
-        + "\tsubmit = group Staff\n" //
-        + "  upload = group Developers\n" //
-        + "  read = group Developers\n"//
-        + "[accounts]\n" //
-        + "  sameGroupVisibility = group Staff\n" //
-        + "[contributor-agreement \"Individual\"]\n" //
-        + "  description = A new description\n" //
-        + "  accepted = group Staff\n" //
-        + "  agreementUrl = http://www.example.com/agree\n"
-        + "[label \"CustomLabel\"]\n" //
-        + LABEL_SCORES_CONFIG
-        + "\tfunction = MaxWithBlock\n" // label gets this function when it is created
-        + "\tdefaultValue = 0\n"); //  label gets this value when it is created
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "" //
+                + "[access \"refs/heads/*\"]\n" //
+                + "  exclusiveGroupPermissions = read submit\n" //
+                + "  submit = group Developers\n" //
+                + "\tsubmit = group Staff\n" //
+                + "  upload = group Developers\n" //
+                + "  read = group Developers\n" //
+                + "[accounts]\n" //
+                + "  sameGroupVisibility = group Staff\n" //
+                + "[contributor-agreement \"Individual\"]\n" //
+                + "  description = A new description\n" //
+                + "  accepted = group Staff\n" //
+                + "  agreementUrl = http://www.example.com/agree\n"
+                + "[label \"CustomLabel\"]\n" //
+                + LABEL_SCORES_CONFIG
+                + "\tfunction = MaxWithBlock\n" // label gets this function when it is created
+                + "\tdefaultValue = 0\n"); //  label gets this value when it is created
   }
 
   @Test
-  public void testEditConfigMissingGroupTableEntry() throws Exception {
-    RevCommit rev = util.commit(util.tree( //
-        util.file("groups", util.blob(group(developers))), //
-        util.file("project.config", util.blob(""//
-            + "[access \"refs/heads/*\"]\n" //
-            + "  exclusiveGroupPermissions = read submit\n" //
-            + "  submit = group People Who Can Submit\n" //
-            + "  upload = group Developers\n" //
-            + "  read = group Developers\n")) //
-        ));
+  public void editConfigMissingGroupTableEntry() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[access \"refs/heads/*\"]\n" //
+                            + "  exclusiveGroupPermissions = read submit\n" //
+                            + "  submit = group People Who Can Submit\n" //
+                            + "  upload = group Developers\n" //
+                            + "  read = group Developers\n")) //
+                ));
     update(rev);
 
     ProjectConfig cfg = read(rev);
@@ -273,28 +317,189 @@
     Permission submit = section.getPermission(Permission.SUBMIT);
     submit.add(new PermissionRule(cfg.resolve(staff)));
     rev = commit(cfg);
-    assertThat(text(rev, "project.config")).isEqualTo(""//
-        + "[access \"refs/heads/*\"]\n" //
-        + "  exclusiveGroupPermissions = read submit\n" //
-        + "  submit = group People Who Can Submit\n" //
-        + "\tsubmit = group Staff\n" //
-        + "  upload = group Developers\n" //
-        + "  read = group Developers\n");
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "" //
+                + "[access \"refs/heads/*\"]\n" //
+                + "  exclusiveGroupPermissions = read submit\n" //
+                + "  submit = group People Who Can Submit\n" //
+                + "\tsubmit = group Staff\n" //
+                + "  upload = group Developers\n" //
+                + "  read = group Developers\n");
   }
 
-  private ProjectConfig read(RevCommit rev) throws IOException,
-      ConfigInvalidException {
+  @Test
+  public void readExistingPluginConfig() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[plugin \"somePlugin\"]\n" //
+                            + "  key1 = value1\n" //
+                            + "  key2 = value2a\n"
+                            + "  key2 = value2b\n")) //
+                ));
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    assertThat(pluginCfg.getNames()).hasSize(2);
+    assertThat(pluginCfg.getString("key1")).isEqualTo("value1");
+    assertThat(pluginCfg.getStringList(("key2"))).isEqualTo(new String[] {"value2a", "value2b"});
+  }
+
+  @Test
+  public void readUnexistingPluginConfig() throws Exception {
+    ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
+    cfg.load(db);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    assertThat(pluginCfg.getNames()).isEmpty();
+  }
+
+  @Test
+  public void editPluginConfig() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[plugin \"somePlugin\"]\n" //
+                            + "  key1 = value1\n" //
+                            + "  key2 = value2a\n" //
+                            + "  key2 = value2b\n")) //
+                ));
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    pluginCfg.setString("key1", "updatedValue1");
+    pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "" //
+                + "[plugin \"somePlugin\"]\n" //
+                + "\tkey1 = updatedValue1\n" //
+                + "\tkey2 = updatedValue2a\n" //
+                + "\tkey2 = updatedValue2b\n");
+  }
+
+  @Test
+  public void readPluginConfigGroupReference() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[plugin \"somePlugin\"]\n" //
+                            + "key1 = "
+                            + developers.toConfigValue()
+                            + "\n")) //
+                ));
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    assertThat(pluginCfg.getNames()).hasSize(1);
+    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
+  }
+
+  @Test
+  public void readPluginConfigGroupReferenceNotInGroupsFile() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[plugin \"somePlugin\"]\n" //
+                            + "key1 = "
+                            + staff.toConfigValue()
+                            + "\n")) //
+                ));
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: group \"" + staff.getName() + "\" not in " + GroupList.FILE_NAME);
+  }
+
+  @Test
+  public void editPluginConfigGroupReference() throws Exception {
+    RevCommit rev =
+        util.commit(
+            util.tree( //
+                util.file("groups", util.blob(group(developers))), //
+                util.file(
+                    "project.config",
+                    util.blob(
+                        "" //
+                            + "[plugin \"somePlugin\"]\n" //
+                            + "key1 = "
+                            + developers.toConfigValue()
+                            + "\n")) //
+                ));
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
+    assertThat(pluginCfg.getNames()).hasSize(1);
+    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
+
+    pluginCfg.setGroupReference("key1", staff);
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo(
+            "" //
+                + "[plugin \"somePlugin\"]\n" //
+                + "\tkey1 = "
+                + staff.toConfigValue()
+                + "\n");
+    assertThat(text(rev, "groups"))
+        .isEqualTo(
+            "# UUID\tGroup Name\n" //
+                + "#\n" //
+                + staff.getUUID().get()
+                + "     \t"
+                + staff.getName()
+                + "\n");
+  }
+
+  @Test
+  public void addCommentLink() throws Exception {
+    RevCommit rev = util.commit().create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    CommentLinkInfoImpl cm = new CommentLinkInfoImpl("Test", "abc.*", null, "<a>link</a>", true);
+    cfg.addCommentLinkSection(cm);
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[commentlink \"Test\"]\n\tmatch = abc.*\n\thtml = <a>link</a>\n");
+  }
+
+  private ProjectConfig read(RevCommit rev) throws IOException, ConfigInvalidException {
     ProjectConfig cfg = new ProjectConfig(new Project.NameKey("test"));
     cfg.load(db, rev);
     return cfg;
   }
 
-  private RevCommit commit(ProjectConfig cfg) throws IOException,
-      MissingObjectException, IncorrectObjectTypeException {
-    try (MetaDataUpdate md = new MetaDataUpdate(
-          GitReferenceUpdated.DISABLED,
-          cfg.getProject().getNameKey(),
-          db)) {
+  private RevCommit commit(ProjectConfig cfg)
+      throws IOException, MissingObjectException, IncorrectObjectTypeException {
+    try (MetaDataUpdate md =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, cfg.getProject().getNameKey(), db)) {
       util.tick(5);
       util.setAuthorAndCommitter(md.getCommitBuilder());
       md.setMessage("Edit\n");
@@ -311,9 +516,9 @@
     u.setNewObjectId(rev);
     Result result = u.forceUpdate();
     assert_()
-      .withFailureMessage("Cannot update ref for test: " + result)
-      .that(result)
-      .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
+        .withFailureMessage("Cannot update ref for test: " + result)
+        .that(result)
+        .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
   }
 
   private String text(RevCommit rev, String path) throws Exception {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/QueryListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/QueryListTest.java
index d022d3e..8d0f909 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/QueryListTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/QueryListTest.java
@@ -18,11 +18,9 @@
 import static org.easymock.EasyMock.createNiceMock;
 import static org.easymock.EasyMock.replay;
 
-import junit.framework.TestCase;
-
-import org.junit.Test;
-
 import java.io.IOException;
+import junit.framework.TestCase;
+import org.junit.Test;
 
 public class QueryListTest extends TestCase {
   public static final String Q_P = "project:foo";
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java
index b623ae8..1dc6a469 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java
@@ -15,16 +15,15 @@
 package com.google.gerrit.server.index;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.index.SchemaUtil.getNameParts;
 import static com.google.gerrit.server.index.SchemaUtil.getPersonParts;
 import static com.google.gerrit.server.index.SchemaUtil.schema;
 
 import com.google.gerrit.testutil.GerritBaseTests;
-
+import java.util.Map;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
-import java.util.Map;
-
 public class SchemaUtilTest extends GerritBaseTests {
   static class TestSchemas {
     static final Schema<String> V1 = schema();
@@ -39,8 +38,7 @@
 
   @Test
   public void schemasFromClassBuildsMap() {
-    Map<Integer, Schema<String>> all =
-        SchemaUtil.schemasFromClass(TestSchemas.class, String.class);
+    Map<Integer, Schema<String>> all = SchemaUtil.schemasFromClass(TestSchemas.class, String.class);
     assertThat(all.keySet()).containsExactly(1, 2, 4);
     assertThat(all.get(1)).isEqualTo(TestSchemas.V1);
     assertThat(all.get(2)).isEqualTo(TestSchemas.V2);
@@ -55,17 +53,28 @@
     // PersonIdent allows empty email, which should be extracted as the empty
     // string. However, it converts empty names to null internally.
     assertThat(getPersonParts(new PersonIdent("", ""))).containsExactly("");
-    assertThat(getPersonParts(new PersonIdent("foo bar", "")))
-        .containsExactly("foo", "bar", "");
+    assertThat(getPersonParts(new PersonIdent("foo bar", ""))).containsExactly("foo", "bar", "");
 
     assertThat(getPersonParts(new PersonIdent("", "foo@example.com")))
+        .containsExactly("foo@example.com", "foo", "example.com", "example", "com");
+    assertThat(getPersonParts(new PersonIdent("foO J. bAr", "bA-z@exAmple.cOm")))
         .containsExactly(
-            "foo@example.com", "foo", "example.com", "example", "com");
-    assertThat(
-            getPersonParts(new PersonIdent("foO J. bAr", "bA-z@exAmple.cOm")))
-        .containsExactly(
-            "foo", "j", "bar",
-            "ba-z@example.com", "ba-z", "ba", "z",
-            "example.com", "example", "com");
+            "foo",
+            "j",
+            "bar",
+            "ba-z@example.com",
+            "ba-z",
+            "ba",
+            "z",
+            "example.com",
+            "example",
+            "com");
+  }
+
+  @Test
+  public void getNamePartsExtractsParts() {
+    assertThat(getNameParts("")).isEmpty();
+    assertThat(getNameParts("foO-bAr_Baz a.b@c/d"))
+        .containsExactly("foo", "bar", "baz", "a", "b", "c", "d");
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 839d349..7ae3944 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -15,31 +15,27 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 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.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
 public class ChangeFieldTest extends GerritBaseTests {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
   @Before
   public void setUp() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
@@ -52,8 +48,7 @@
 
   @Test
   public void reviewerFieldValues() {
-    Table<ReviewerStateInternal, Account.Id, Timestamp> t =
-        HashBasedTable.create();
+    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
     Timestamp t1 = TimeUtil.nowTs();
     t.put(ReviewerStateInternal.REVIEWER, new Account.Id(1), t1);
     Timestamp t2 = TimeUtil.nowTs();
@@ -61,13 +56,64 @@
     ReviewerSet reviewers = ReviewerSet.fromTable(t);
 
     List<String> values = ChangeField.getReviewerFieldValues(reviewers);
-    assertThat(values).containsExactly(
-        "REVIEWER,1",
-        "REVIEWER,1," + t1.getTime(),
-        "CC,2",
-        "CC,2," + t2.getTime());
+    assertThat(values)
+        .containsExactly(
+            "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
 
-    assertThat(ChangeField.parseReviewerFieldValues(values))
-        .isEqualTo(reviewers);
+    assertThat(ChangeField.parseReviewerFieldValues(values)).isEqualTo(reviewers);
+  }
+
+  @Test
+  public void formatSubmitRecordValues() {
+    assertThat(
+            ChangeField.formatSubmitRecordValues(
+                ImmutableList.of(
+                    record(
+                        SubmitRecord.Status.OK,
+                        label(SubmitRecord.Label.Status.MAY, "Label-1", null),
+                        label(SubmitRecord.Label.Status.OK, "Label-2", 1))),
+                new Account.Id(1)))
+        .containsExactly("OK", "MAY,label-1", "OK,label-2", "OK,label-2,0", "OK,label-2,1");
+  }
+
+  @Test
+  public void storedSubmitRecords() {
+    assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
+    assertStoredRecordRoundTrip(
+        record(
+            SubmitRecord.Status.OK,
+            label(SubmitRecord.Label.Status.MAY, "Label-1", null),
+            label(SubmitRecord.Label.Status.OK, "Label-2", 1)));
+  }
+
+  private static SubmitRecord record(SubmitRecord.Status status, SubmitRecord.Label... labels) {
+    SubmitRecord r = new SubmitRecord();
+    r.status = status;
+    if (labels.length > 0) {
+      r.labels = ImmutableList.copyOf(labels);
+    }
+    return r;
+  }
+
+  private static SubmitRecord.Label label(
+      SubmitRecord.Label.Status status, String label, Integer appliedBy) {
+    SubmitRecord.Label l = new SubmitRecord.Label();
+    l.status = status;
+    l.label = label;
+    if (appliedBy != null) {
+      l.appliedBy = new Account.Id(appliedBy);
+    }
+    return l;
+  }
+
+  private static void assertStoredRecordRoundTrip(SubmitRecord... records) {
+    List<SubmitRecord> recordList = ImmutableList.copyOf(records);
+    List<String> stored =
+        ChangeField.storedSubmitRecords(recordList).stream()
+            .map(s -> new String(s, UTF_8))
+            .collect(toList());
+    assertThat(ChangeField.parseSubmitRecords(stored))
+        .named("JSON %s" + stored)
+        .isEqualTo(recordList);
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index ac7aed7..1724c51 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -38,13 +38,11 @@
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.OrSource;
 import com.google.gerrit.testutil.GerritBaseTests;
-
-import org.junit.Before;
-import org.junit.Test;
-
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
 
 public class ChangeIndexRewriterTest extends GerritBaseTests {
   private static final IndexConfig CONFIG = IndexConfig.createDefault();
@@ -60,18 +58,17 @@
     indexes = new ChangeIndexCollection();
     indexes.setSearchIndex(index);
     queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new ChangeIndexRewriter(indexes,
-        IndexConfig.create(0, 0, 3));
+    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.create(0, 0, 3));
   }
 
   @Test
-  public void testIndexPredicate() throws Exception {
+  public void indexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("file:a");
     assertThat(rewrite(in)).isEqualTo(query(in));
   }
 
   @Test
-  public void testNonIndexPredicate() throws Exception {
+  public void nonIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(AndChangeSource.class).isSameAs(out.getClass());
@@ -81,13 +78,13 @@
   }
 
   @Test
-  public void testIndexPredicates() throws Exception {
+  public void indexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("file:a file:b");
     assertThat(rewrite(in)).isEqualTo(query(in));
   }
 
   @Test
-  public void testNonIndexPredicates() throws Exception {
+  public void nonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("foo:a OR foo:b");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(AndChangeSource.class).isSameAs(out.getClass());
@@ -97,150 +94,115 @@
   }
 
   @Test
-  public void testOneIndexPredicate() throws Exception {
+  public void oneIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a file:b");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(AndChangeSource.class).isSameAs(out.getClass());
-    assertThat(out.getChildren())
-        .containsExactly(
-            query(in.getChild(1)),
-            in.getChild(0))
-        .inOrder();
+    assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
   }
 
   @Test
-  public void testThreeLevelTreeWithAllIndexPredicates() throws Exception {
-    Predicate<ChangeData> in =
-        parse("-status:abandoned (file:a OR file:b)");
-    assertThat(rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT)))
-        .isEqualTo(query(in));
+  public void threeLevelTreeWithAllIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("-status:abandoned (file:a OR file:b)");
+    assertThat(rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT))).isEqualTo(query(in));
   }
 
   @Test
-  public void testThreeLevelTreeWithSomeIndexPredicates() throws Exception {
+  public void threeLevelTreeWithSomeIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out.getClass()).isSameAs(AndChangeSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(in.getChild(1)),
-          in.getChild(0))
-        .inOrder();
+    assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
   }
 
   @Test
-  public void testMultipleIndexPredicates() throws Exception {
-    Predicate<ChangeData> in =
-        parse("file:a OR foo:b OR file:c OR foo:d");
+  public void multipleIndexPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("file:a OR foo:b OR file:c OR foo:d");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out.getClass()).isSameAs(OrSource.class);
     assertThat(out.getChildren())
-        .containsExactly(
-          query(or(in.getChild(0), in.getChild(2))),
-          in.getChild(1),
-          in.getChild(3))
+        .containsExactly(query(or(in.getChild(0), in.getChild(2))), in.getChild(1), in.getChild(3))
         .inOrder();
   }
 
   @Test
-  public void testIndexAndNonIndexPredicates() throws Exception {
+  public void indexAndNonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("status:new bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(AndChangeSource.class).isSameAs(out.getClass());
     assertThat(out.getChildren())
-        .containsExactly(
-          query(and(in.getChild(0), in.getChild(2))),
-          in.getChild(1))
+        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
         .inOrder();
   }
 
   @Test
-  public void testDuplicateCompoundNonIndexOnlyPredicates() throws Exception {
-    Predicate<ChangeData> in =
-        parse("(status:new OR status:draft) bar:p file:a");
+  public void duplicateCompoundNonIndexOnlyPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("(status:new OR status:draft) bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
-        .containsExactly(
-          query(and(in.getChild(0), in.getChild(2))),
-          in.getChild(1))
+        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
         .inOrder();
   }
 
   @Test
-  public void testDuplicateCompoundIndexOnlyPredicates() throws Exception {
-    Predicate<ChangeData> in =
-        parse("(status:new OR file:a) bar:p file:b");
+  public void duplicateCompoundIndexOnlyPredicates() throws Exception {
+    Predicate<ChangeData> in = parse("(status:new OR file:a) bar:p file:b");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
-        .containsExactly(
-          query(and(in.getChild(0), in.getChild(2))),
-          in.getChild(1))
+        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
         .inOrder();
   }
 
   @Test
-  public void testOptionsArgumentOverridesAllLimitPredicates()
-      throws Exception {
+  public void optionsArgumentOverridesAllLimitPredicates() throws Exception {
     Predicate<ChangeData> in = parse("limit:1 file:a limit:3");
     Predicate<ChangeData> out = rewrite(in, options(0, 5));
     assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
-        .containsExactly(
-          query(in.getChild(1), 5),
-          parse("limit:5"),
-          parse("limit:5"))
+        .containsExactly(query(in.getChild(1), 5), parse("limit:5"), parse("limit:5"))
         .inOrder();
   }
 
   @Test
-  public void testStartIncreasesLimitInQueryButNotPredicate() throws Exception {
+  public void startIncreasesLimitInQueryButNotPredicate() throws Exception {
     int n = 3;
     Predicate<ChangeData> f = parse("file:a");
     Predicate<ChangeData> l = parse("limit:" + n);
     Predicate<ChangeData> in = andSource(f, l);
-    assertThat(rewrite.rewrite(in, options(0, n)))
-        .isEqualTo(andSource(query(f, 3), l));
-    assertThat(rewrite.rewrite(in, options(1, n)))
-        .isEqualTo(andSource(query(f, 4), l));
-    assertThat(rewrite.rewrite(in, options(2, n)))
-        .isEqualTo(andSource(query(f, 5), l));
+    assertThat(rewrite.rewrite(in, options(0, n))).isEqualTo(andSource(query(f, 3), l));
+    assertThat(rewrite.rewrite(in, options(1, n))).isEqualTo(andSource(query(f, 4), l));
+    assertThat(rewrite.rewrite(in, options(2, n))).isEqualTo(andSource(query(f, 5), l));
   }
 
   @Test
-  public void testGetPossibleStatus() throws Exception {
+  public void getPossibleStatus() throws Exception {
     assertThat(status("file:a")).isEqualTo(EnumSet.allOf(Change.Status.class));
     assertThat(status("is:new")).containsExactly(NEW);
-    assertThat(status("-is:new"))
-        .containsExactly(DRAFT, MERGED, ABANDONED);
+    assertThat(status("-is:new")).containsExactly(DRAFT, MERGED, ABANDONED);
     assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
 
     assertThat(status("is:new is:merged")).isEmpty();
     assertThat(status("(is:new is:draft) (is:merged)")).isEmpty();
     assertThat(status("(is:new is:draft) (is:merged)")).isEmpty();
 
-    assertThat(status("(is:new is:draft) OR (is:merged)"))
-        .containsExactly(MERGED);
+    assertThat(status("(is:new is:draft) OR (is:merged)")).containsExactly(MERGED);
   }
 
   @Test
-  public void testUnsupportedIndexOperator() throws Exception {
+  public void unsupportedIndexOperator() throws Exception {
     Predicate<ChangeData> in = parse("status:merged file:a");
     assertThat(rewrite(in)).isEqualTo(query(in));
 
     indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out).isInstanceOf(AndPredicate.class);
-    assertThat(out.getChildren())
-        .containsExactly(
-          query(in.getChild(0)),
-          in.getChild(1))
-        .inOrder();
+    assertThat(out.getChildren()).containsExactly(query(in.getChild(0)), in.getChild(1)).inOrder();
   }
 
   @Test
-  public void testTooManyTerms() throws Exception {
+  public void tooManyTerms() throws Exception {
     String q = "file:a OR file:b OR file:c";
     Predicate<ChangeData> in = parse(q);
     assertEquals(query(in), rewrite(in));
@@ -258,7 +220,7 @@
   }
 
   @Test
-  public void testAddingStartToLimitDoesNotExceedBackendLimit() throws Exception {
+  public void addingStartToLimitDoesNotExceedBackendLimit() throws Exception {
     int max = CONFIG.maxLimit();
     assertEquals(options(0, max), convertOptions(options(0, max)));
     assertEquals(options(0, max), convertOptions(options(1, max)));
@@ -275,29 +237,25 @@
     return new AndChangeSource(Arrays.asList(preds));
   }
 
-  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in)
-      throws QueryParseException {
+  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in) throws QueryParseException {
     return rewrite.rewrite(in, options(0, DEFAULT_MAX_QUERY_LIMIT));
   }
 
-  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in,
-      QueryOptions opts) throws QueryParseException {
+  private Predicate<ChangeData> rewrite(Predicate<ChangeData> in, QueryOptions opts)
+      throws QueryParseException {
     return rewrite.rewrite(in, opts);
   }
 
-  private IndexedChangeQuery query(Predicate<ChangeData> p)
-      throws QueryParseException {
+  private IndexedChangeQuery query(Predicate<ChangeData> p) throws QueryParseException {
     return query(p, DEFAULT_MAX_QUERY_LIMIT);
   }
 
-  private IndexedChangeQuery query(Predicate<ChangeData> p, int limit)
-      throws QueryParseException {
+  private IndexedChangeQuery query(Predicate<ChangeData> p, int limit) throws QueryParseException {
     return new IndexedChangeQuery(index, p, options(0, limit));
   }
 
   private static QueryOptions options(int start, int limit) {
-    return IndexedChangeQuery.createOptions(CONFIG, start, limit,
-        ImmutableSet.<String> of());
+    return IndexedChangeQuery.createOptions(CONFIG, start, limit, ImmutableSet.<String>of());
   }
 
   private Set<Change.Status> status(String query) throws QueryParseException {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
index ea13ec4..8189c81 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -25,20 +25,15 @@
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
-
 import org.junit.Ignore;
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static Schema<ChangeData> V1 = new Schema<>(1,
-    ImmutableList.<FieldDef<ChangeData, ?>> of(
-      ChangeField.STATUS));
+  static Schema<ChangeData> V1 =
+      new Schema<>(1, ImmutableList.<FieldDef<ChangeData, ?>>of(ChangeField.STATUS));
 
-  static Schema<ChangeData> V2 = new Schema<>(2,
-    ImmutableList.of(
-      ChangeField.STATUS,
-      ChangeField.PATH,
-      ChangeField.UPDATED));
+  static Schema<ChangeData> V2 =
+      new Schema<>(2, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
 
   private static class Source implements ChangeDataSource {
     private final Predicate<ChangeData> p;
@@ -101,15 +96,10 @@
   }
 
   @Override
-  public void close() {
-  }
+  public void close() {}
 
   @Override
   public void markReady(boolean ready) {
     throw new UnsupportedOperationException();
   }
-
-  @Override
-  public void stop() {
-  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 545fd08..6fda100 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -18,19 +18,17 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-
 import org.junit.Ignore;
 
 @Ignore
 public class FakeQueryBuilder extends ChangeQueryBuilder {
   FakeQueryBuilder(ChangeIndexCollection indexes) {
     super(
-        new FakeQueryBuilder.Definition<>(
-          FakeQueryBuilder.class),
-        new ChangeQueryBuilder.Arguments(null, null, null, null, null, null,
-          null, null, null, null, null, null, null, null, null, null, null,
-          null, null, null, indexes, null, null, null, null, null, null, null,
-          null));
+        new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
+        new ChangeQueryBuilder.Arguments(
+            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
+            null, null, null, null, null, null, null, indexes, null, null, null, null, null, null,
+            null, null, null));
   }
 
   @Operator
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
new file mode 100644
index 0000000..4eef629
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -0,0 +1,342 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.server.index.change.StalenessChecker.refsAreStale;
+import static com.google.gerrit.testutil.TestChanges.newChange;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ListMultimap;
+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.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.StalenessChecker.RefState;
+import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.testutil.GerritBaseTests;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import java.util.stream.Stream;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+public class StalenessCheckerTest extends GerritBaseTests {
+  private static final String SHA1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+  private static final String SHA2 = "badc0feebadc0feebadc0feebadc0feebadc0fee";
+
+  private static final Project.NameKey P1 = new Project.NameKey("project1");
+  private static final Project.NameKey P2 = new Project.NameKey("project2");
+
+  private static final Change.Id C = new Change.Id(1234);
+
+  private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
+
+  private GitRepositoryManager repoManager;
+  private Repository r1;
+  private Repository r2;
+  private TestRepository<Repository> tr1;
+  private TestRepository<Repository> tr2;
+
+  @Before
+  public void setUp() throws Exception {
+    repoManager = new InMemoryRepositoryManager();
+    r1 = repoManager.createRepository(P1);
+    tr1 = new TestRepository<>(r1);
+    r2 = repoManager.createRepository(P2);
+    tr2 = new TestRepository<>(r2);
+  }
+
+  @Test
+  public void parseStates() {
+    assertInvalidState(null);
+    assertInvalidState("");
+    assertInvalidState("project1:refs/heads/foo");
+    assertInvalidState("project1:refs/heads/foo:notasha");
+    assertInvalidState("project1:refs/heads/foo:");
+
+    assertThat(
+            StalenessChecker.parseStates(
+                byteArrays(
+                    P1 + ":refs/heads/foo:" + SHA1,
+                    P1 + ":refs/heads/bar:" + SHA2,
+                    P2 + ":refs/heads/baz:" + SHA1)))
+        .isEqualTo(
+            ImmutableSetMultimap.of(
+                P1, RefState.create("refs/heads/foo", SHA1),
+                P1, RefState.create("refs/heads/bar", SHA2),
+                P2, RefState.create("refs/heads/baz", SHA1)));
+  }
+
+  private static void assertInvalidState(String state) {
+    try {
+      StalenessChecker.parseStates(byteArrays(state));
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void refStateToByteArray() {
+    assertThat(
+            new String(
+                RefState.create("refs/heads/foo", ObjectId.fromString(SHA1)).toByteArray(P1),
+                UTF_8))
+        .isEqualTo(P1 + ":refs/heads/foo:" + SHA1);
+    assertThat(
+            new String(RefState.create("refs/heads/foo", (ObjectId) null).toByteArray(P1), UTF_8))
+        .isEqualTo(P1 + ":refs/heads/foo:" + ObjectId.zeroId().name());
+  }
+
+  @Test
+  public void parsePatterns() {
+    assertInvalidPattern(null);
+    assertInvalidPattern("");
+    assertInvalidPattern("project:");
+    assertInvalidPattern("project:refs/heads/foo");
+    assertInvalidPattern("project:refs/he*ds/bar");
+    assertInvalidPattern("project:refs/(he)*ds/bar");
+    assertInvalidPattern("project:invalidrefname");
+
+    ListMultimap<Project.NameKey, RefStatePattern> r =
+        StalenessChecker.parsePatterns(
+            byteArrays(
+                P1 + ":refs/heads/*",
+                P2 + ":refs/heads/foo/*/bar",
+                P2 + ":refs/heads/foo/*-baz/*/quux"));
+
+    assertThat(r.keySet()).containsExactly(P1, P2);
+    RefStatePattern p = r.get(P1).get(0);
+    assertThat(p.pattern()).isEqualTo("refs/heads/*");
+    assertThat(p.prefix()).isEqualTo("refs/heads/");
+    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/\\E.*\\Q\\E$");
+    assertThat(p.match("refs/heads/foo")).isTrue();
+    assertThat(p.match("xrefs/heads/foo")).isFalse();
+    assertThat(p.match("refs/tags/foo")).isFalse();
+
+    p = r.get(P2).get(0);
+    assertThat(p.pattern()).isEqualTo("refs/heads/foo/*/bar");
+    assertThat(p.prefix()).isEqualTo("refs/heads/foo/");
+    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/foo/\\E.*\\Q/bar\\E$");
+    assertThat(p.match("refs/heads/foo//bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/y/bar")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/baz")).isFalse();
+
+    p = r.get(P2).get(1);
+    assertThat(p.pattern()).isEqualTo("refs/heads/foo/*-baz/*/quux");
+    assertThat(p.prefix()).isEqualTo("refs/heads/foo/");
+    assertThat(p.regex().pattern()).isEqualTo("^\\Qrefs/heads/foo/\\E.*\\Q-baz/\\E.*\\Q/quux\\E$");
+    assertThat(p.match("refs/heads/foo/-baz//quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x-baz/x/quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x/y-baz/x/y/quux")).isTrue();
+    assertThat(p.match("refs/heads/foo/x-baz/x/y")).isFalse();
+  }
+
+  @Test
+  public void refStatePatternToByteArray() {
+    assertThat(new String(RefStatePattern.create("refs/*").toByteArray(P1), UTF_8))
+        .isEqualTo(P1 + ":refs/*");
+  }
+
+  private static void assertInvalidPattern(String state) {
+    try {
+      StalenessChecker.parsePatterns(byteArrays(state));
+      assert_().fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void isStaleRefStatesOnly() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+    String ref2 = "refs/heads/bar";
+    ObjectId id2 = tr2.update(ref2, tr2.commit().message("commit 2"));
+
+    // Not stale.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P2, RefState.create(ref2, id2.name())),
+                ImmutableListMultimap.of()))
+        .isFalse();
+
+    // Wrong ref value.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, SHA1),
+                    P2, RefState.create(ref2, id2.name())),
+                ImmutableListMultimap.of()))
+        .isTrue();
+
+    // Swapped repos.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id2.name()),
+                    P2, RefState.create(ref2, id1.name())),
+                ImmutableListMultimap.of()))
+        .isTrue();
+
+    // Two refs in same repo, not stale.
+    String ref3 = "refs/heads/baz";
+    ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
+    tr1.update(ref3, id3);
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, id3.name())),
+                ImmutableListMultimap.of()))
+        .isFalse();
+
+    // Ignore ref not mentioned.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of()))
+        .isFalse();
+
+    // One ref wrong.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, SHA1)),
+                ImmutableListMultimap.of()))
+        .isTrue();
+  }
+
+  @Test
+  public void isStaleWithRefStatePatterns() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+
+    // ref1 is only ref matching pattern.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+        .isFalse();
+
+    // Now ref2 matches pattern, so stale unless ref2 is present in state map.
+    String ref2 = "refs/heads/bar";
+    ObjectId id2 = tr1.update(ref2, tr1.commit().message("commit 2"));
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+        .isTrue();
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref2, id2.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+        .isFalse();
+  }
+
+  @Test
+  public void isStaleWithNonPrefixPattern() throws Exception {
+    String ref1 = "refs/heads/foo";
+    ObjectId id1 = tr1.update(ref1, tr1.commit().message("commit 1"));
+    tr1.update("refs/heads/bar", tr1.commit().message("commit 2"));
+
+    // ref1 is only ref matching pattern.
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+        .isFalse();
+
+    // Now ref2 matches pattern, so stale unless ref2 is present in state map.
+    String ref3 = "refs/other/foo";
+    ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+        .isTrue();
+    assertThat(
+            refsAreStale(
+                repoManager,
+                C,
+                ImmutableSetMultimap.of(
+                    P1, RefState.create(ref1, id1.name()),
+                    P1, RefState.create(ref3, id3.name())),
+                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+        .isFalse();
+  }
+
+  @Test
+  public void reviewDbChangeIsStale() throws Exception {
+    Change indexChange = newChange(P1, new Account.Id(1));
+    indexChange.setNoteDbState(SHA1);
+
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isFalse();
+
+    Change noteDbPrimary = clone(indexChange);
+    noteDbPrimary.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isFalse();
+
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, clone(indexChange))).isFalse();
+
+    // Can't easily change row version to check true case.
+  }
+
+  private static Iterable<byte[]> byteArrays(String... strs) {
+    return Stream.of(strs).map(s -> s != null ? s.getBytes(UTF_8) : null).collect(toList());
+  }
+
+  private static Change clone(Change change) {
+    return CHANGE_CODEC.decode(CHANGE_CODEC.encodeToByteArray(change));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
index f6bdeac..800413b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
@@ -23,12 +23,11 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 
-import org.junit.Test;
-
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import org.junit.Test;
 
 public class BasicSerializationTest {
   @Test
@@ -60,20 +59,17 @@
     assertEquals(0L, readFixInt64(r(b(0, 0, 0, 0, 0, 0, 0, 0))));
     assertEquals(3L, readFixInt64(r(b(0, 0, 0, 0, 0, 0, 0, 3))));
 
-    assertEquals(0xdeadbeefL, readFixInt64(r(b(0, 0, 0, 0, 0xde, 0xad, 0xbe,
-        0xef))));
+    assertEquals(0xdeadbeefL, readFixInt64(r(b(0, 0, 0, 0, 0xde, 0xad, 0xbe, 0xef))));
 
-    assertEquals(0x0310adefL, readFixInt64(r(b(0, 0, 0, 0, 0x03, 0x10, 0xad,
-        0xef))));
+    assertEquals(0x0310adefL, readFixInt64(r(b(0, 0, 0, 0, 0x03, 0x10, 0xad, 0xef))));
 
-    assertEquals(0xc0ffee78deadbeefL, readFixInt64(r(b(0xc0, 0xff, 0xee, 0x78,
-        0xde, 0xad, 0xbe, 0xef))));
+    assertEquals(
+        0xc0ffee78deadbeefL, readFixInt64(r(b(0xc0, 0xff, 0xee, 0x78, 0xde, 0xad, 0xbe, 0xef))));
 
-    assertEquals(0x00000000ffffffffL, readFixInt64(r(b(0, 0, 0, 0, 0xff, 0xff,
-        0xff, 0xff))));
+    assertEquals(0x00000000ffffffffL, readFixInt64(r(b(0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff))));
 
-    assertEquals(0xffffffffffffffffL, readFixInt64(r(b(0xff, 0xff, 0xff, 0xff,
-        0xff, 0xff, 0xff, 0xff))));
+    assertEquals(
+        0xffffffffffffffffL, readFixInt64(r(b(0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff))));
   }
 
   @Test
@@ -109,8 +105,7 @@
   public void testReadString() throws IOException {
     assertNull(readString(r(b(0))));
     assertEquals("a", readString(r(b(1, 'a'))));
-    assertEquals("coffee4",
-        readString(r(b(7, 'c', 'o', 'f', 'f', 'e', 'e', '4'))));
+    assertEquals("coffee4", readString(r(b(7, 'c', 'o', 'f', 'f', 'e', 'e', '4'))));
   }
 
   @Test
@@ -134,8 +129,7 @@
     assertOutput(b(7, 'c', 'o', 'f', 'f', 'e', 'e', '4'), out);
   }
 
-  private static void assertOutput(final byte[] expect,
-      final ByteArrayOutputStream out) {
+  private static void assertOutput(final byte[] expect, final ByteArrayOutputStream out) {
     final byte[] buf = out.toByteArray();
     for (int i = 0; i < expect.length; i++) {
       assertEquals(expect[i], buf[i]);
@@ -155,7 +149,9 @@
   }
 
   private static byte[] b(int a, int b, int c, int d, int e, int f, int g, int h) {
-    return new byte[] {(byte) a, (byte) b, (byte) c, (byte) d, //
-        (byte) e, (byte) f, (byte) g, (byte) h,};
+    return new byte[] {
+      (byte) a, (byte) b, (byte) c, (byte) d, //
+      (byte) e, (byte) f, (byte) g, (byte) h,
+    };
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
index 049e17d..fe642ba 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/ColumnFormatterTest.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.server.ioutil;
 
+import java.io.PrintWriter;
+import java.io.StringWriter;
 import org.junit.Assert;
 import org.junit.Test;
 
-import java.io.PrintWriter;
-import java.io.StringWriter;
-
 public class ColumnFormatterTest {
   /**
-   * Holds an in-memory {@link java.io.PrintWriter} object and allows
-   * comparisons of its contents to a supplied string via an assert statement.
+   * Holds an in-memory {@link java.io.PrintWriter} object and allows comparisons of its contents to
+   * a supplied string via an assert statement.
    */
   static class PrintWriterComparator {
     private PrintWriter printWriter;
@@ -44,14 +43,11 @@
     }
   }
 
-  /**
-   * Test that only lines with at least one column of text emit output.
-   */
+  /** Test that only lines with at least one column of text emit output. */
   @Test
-  public void testEmptyLine() {
+  public void emptyLine() {
     final PrintWriterComparator comparator = new PrintWriterComparator();
-    final ColumnFormatter formatter =
-        new ColumnFormatter(comparator.getPrintWriter(), '\t');
+    final ColumnFormatter formatter = new ColumnFormatter(comparator.getPrintWriter(), '\t');
     formatter.addColumn("foo");
     formatter.addColumn("bar");
     formatter.nextLine();
@@ -63,14 +59,11 @@
     comparator.assertEquals("foo\tbar\nfoo\tbar\n");
   }
 
-  /**
-   * Test that there is no output if no columns are ever added.
-   */
+  /** Test that there is no output if no columns are ever added. */
   @Test
-  public void testEmptyOutput() {
+  public void emptyOutput() {
     final PrintWriterComparator comparator = new PrintWriterComparator();
-    final ColumnFormatter formatter =
-        new ColumnFormatter(comparator.getPrintWriter(), '\t');
+    final ColumnFormatter formatter = new ColumnFormatter(comparator.getPrintWriter(), '\t');
     formatter.nextLine();
     formatter.nextLine();
     formatter.finish();
@@ -78,44 +71,40 @@
   }
 
   /**
-   * Test that there is no output (nor any exceptions) if we finalize
-   * the output immediately after the creation of the {@link ColumnFormatter}.
+   * Test that there is no output (nor any exceptions) if we finalize the output immediately after
+   * the creation of the {@link ColumnFormatter}.
    */
   @Test
-  public void testNoNextLine() {
+  public void noNextLine() {
     final PrintWriterComparator comparator = new PrintWriterComparator();
-    final ColumnFormatter formatter =
-        new ColumnFormatter(comparator.getPrintWriter(), '\t');
+    final ColumnFormatter formatter = new ColumnFormatter(comparator.getPrintWriter(), '\t');
     formatter.finish();
     comparator.assertEquals("");
   }
 
   /**
-   * Test that the text in added columns is escaped while the column separator
-   * (which of course shouldn't be escaped) is left alone.
+   * Test that the text in added columns is escaped while the column separator (which of course
+   * shouldn't be escaped) is left alone.
    */
   @Test
-  public void testEscapingTakesPlace() {
+  public void escapingTakesPlace() {
     final PrintWriterComparator comparator = new PrintWriterComparator();
-    final ColumnFormatter formatter =
-        new ColumnFormatter(comparator.getPrintWriter(), '\t');
+    final ColumnFormatter formatter = new ColumnFormatter(comparator.getPrintWriter(), '\t');
     formatter.addColumn("foo");
-    formatter.addColumn(
-        "\tan indented multi-line\ntext");
+    formatter.addColumn("\tan indented multi-line\ntext");
     formatter.nextLine();
     formatter.finish();
     comparator.assertEquals("foo\t\\tan indented multi-line\\ntext\n");
   }
 
   /**
-   * Test that we get the correct output with multi-line input where the number
-   * of columns in each line varies.
+   * Test that we get the correct output with multi-line input where the number of columns in each
+   * line varies.
    */
   @Test
-  public void testMultiLineDifferentColumnCount() {
+  public void multiLineDifferentColumnCount() {
     final PrintWriterComparator comparator = new PrintWriterComparator();
-    final ColumnFormatter formatter =
-        new ColumnFormatter(comparator.getPrintWriter(), '\t');
+    final ColumnFormatter formatter = new ColumnFormatter(comparator.getPrintWriter(), '\t');
     formatter.addColumn("foo");
     formatter.addColumn("bar");
     formatter.addColumn("baz");
@@ -127,14 +116,11 @@
     comparator.assertEquals("foo\tbar\tbaz\nfoo\tbar\n");
   }
 
-  /**
-   * Test that we get the correct output with a single column of input.
-   */
+  /** Test that we get the correct output with a single column of input. */
   @Test
-  public void testOneColumn() {
+  public void oneColumn() {
     final PrintWriterComparator comparator = new PrintWriterComparator();
-    final ColumnFormatter formatter =
-        new ColumnFormatter(comparator.getPrintWriter(), '\t');
+    final ColumnFormatter formatter = new ColumnFormatter(comparator.getPrintWriter(), '\t');
     formatter.addColumn("foo");
     formatter.nextLine();
     formatter.finish();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
index d5f3132..2909df7 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
@@ -18,68 +18,67 @@
 import static org.junit.Assert.fail;
 
 import com.google.gerrit.testutil.GerritBaseTests;
-
 import org.junit.Test;
 
 public class AddressTest extends GerritBaseTests {
   @Test
-  public void testParse_NameEmail1() {
+  public void parse_NameEmail1() {
     final Address a = Address.parse("A U Thor <author@example.com>");
     assertThat(a.name).isEqualTo("A U Thor");
     assertThat(a.email).isEqualTo("author@example.com");
   }
 
   @Test
-  public void testParse_NameEmail2() {
+  public void parse_NameEmail2() {
     final Address a = Address.parse("A <a@b>");
     assertThat(a.name).isEqualTo("A");
     assertThat(a.email).isEqualTo("a@b");
   }
 
   @Test
-  public void testParse_NameEmail3() {
+  public void parse_NameEmail3() {
     final Address a = Address.parse("<a@b>");
     assertThat(a.name).isNull();
     assertThat(a.email).isEqualTo("a@b");
   }
 
   @Test
-  public void testParse_NameEmail4() {
+  public void parse_NameEmail4() {
     final Address a = Address.parse("A U Thor<author@example.com>");
     assertThat(a.name).isEqualTo("A U Thor");
     assertThat(a.email).isEqualTo("author@example.com");
   }
 
   @Test
-  public void testParse_NameEmail5() {
+  public void parse_NameEmail5() {
     final Address a = Address.parse("A U Thor  <author@example.com>");
     assertThat(a.name).isEqualTo("A U Thor");
     assertThat(a.email).isEqualTo("author@example.com");
   }
 
   @Test
-  public void testParse_Email1() {
+  public void parse_Email1() {
     final Address a = Address.parse("author@example.com");
     assertThat(a.name).isNull();
     assertThat(a.email).isEqualTo("author@example.com");
   }
 
   @Test
-  public void testParse_Email2() {
+  public void parse_Email2() {
     final Address a = Address.parse("a@b");
     assertThat(a.name).isNull();
     assertThat(a.email).isEqualTo("a@b");
   }
 
   @Test
-  public void testParse_NewTLD() {
+  public void parse_NewTLD() {
     Address a = Address.parse("A U Thor <author@example.systems>");
     assertThat(a.name).isEqualTo("A U Thor");
     assertThat(a.email).isEqualTo("author@example.systems");
   }
 
   @Test
-  public void testParseInvalid() {
+  public void parseInvalid() {
     assertInvalid("");
     assertInvalid("a");
     assertInvalid("a<");
@@ -107,49 +106,48 @@
   }
 
   @Test
-  public void testToHeaderString_NameEmail1() {
+  public void toHeaderString_NameEmail1() {
     assertThat(format("A", "a@a")).isEqualTo("A <a@a>");
   }
 
   @Test
-  public void testToHeaderString_NameEmail2() {
+  public void toHeaderString_NameEmail2() {
     assertThat(format("A B", "a@a")).isEqualTo("A B <a@a>");
   }
 
   @Test
-  public void testToHeaderString_NameEmail3() {
+  public void toHeaderString_NameEmail3() {
     assertThat(format("A B. C", "a@a")).isEqualTo("\"A B. C\" <a@a>");
   }
 
   @Test
-  public void testToHeaderString_NameEmail4() {
+  public void toHeaderString_NameEmail4() {
     assertThat(format("A B, C", "a@a")).isEqualTo("\"A B, C\" <a@a>");
   }
 
   @Test
-  public void testToHeaderString_NameEmail5() {
+  public void toHeaderString_NameEmail5() {
     assertThat(format("A \" C", "a@a")).isEqualTo("\"A \\\" C\" <a@a>");
   }
 
   @Test
-  public void testToHeaderString_NameEmail6() {
-    assertThat(format("A \u20ac B", "a@a"))
-      .isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B?= <a@a>");
+  public void toHeaderString_NameEmail6() {
+    assertThat(format("A \u20ac B", "a@a")).isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B?= <a@a>");
   }
 
   @Test
-  public void testToHeaderString_NameEmail7() {
+  public void toHeaderString_NameEmail7() {
     assertThat(format("A \u20ac B (Code Review)", "a@a"))
-      .isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B_=28Code_Review=29?= <a@a>");
+        .isEqualTo("=?UTF-8?Q?A_=E2=82=AC_B_=28Code_Review=29?= <a@a>");
   }
 
   @Test
-  public void testToHeaderString_Email1() {
+  public void toHeaderString_Email1() {
     assertThat(format(null, "a@a")).isEqualTo("a@a");
   }
 
   @Test
-  public void testToHeaderString_Email2() {
+  public void toHeaderString_Email2() {
     assertThat(format(null, "a,b@a")).isEqualTo("<a,b@a>");
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java
deleted file mode 100644
index 11f1d54..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/FromAddressGeneratorProviderTest.java
+++ /dev/null
@@ -1,308 +0,0 @@
-// Copyright (C) 2009 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.mail;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.easymock.EasyMock.createStrictMock;
-import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.expect;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.Before;
-import org.junit.Test;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Set;
-
-public class FromAddressGeneratorProviderTest {
-  private Config config;
-  private PersonIdent ident;
-  private AccountCache accountCache;
-
-  @Before
-  public void setUp() throws Exception {
-    config = new Config();
-    ident = new PersonIdent("NAME", "e@email", 0, 0);
-    accountCache = createStrictMock(AccountCache.class);
-  }
-
-  private FromAddressGenerator create() {
-    return new FromAddressGeneratorProvider(config, "Anonymous Coward", ident,
-        accountCache).get();
-  }
-
-  private void setFrom(final String newFrom) {
-    config.setString("sendemail", null, "from", newFrom);
-  }
-
-  @Test
-  public void testDefaultIsMIXED() {
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
-  }
-
-  @Test
-  public void testSelectUSER() {
-    setFrom("USER");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
-
-    setFrom("user");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
-
-    setFrom("uSeR");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
-  }
-
-  @Test
-  public void testUSER_FullyConfiguredUser() {
-    setFrom("USER");
-
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(name);
-    assertThat(r.email).isEqualTo(email);
-    verify(accountCache);
-  }
-
-  @Test
-  public void testUSER_NoFullNameUser() {
-    setFrom("USER");
-
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(null, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isNull();
-    assertThat(r.email).isEqualTo(email);
-    verify(accountCache);
-  }
-
-  @Test
-  public void testUSER_NoPreferredEmailUser() {
-    setFrom("USER");
-
-    final String name = "A U. Thor";
-    final Account.Id user = user(name, null);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(name);
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void testUSER_NullUser() {
-    setFrom("USER");
-    replay(accountCache);
-    final Address r = create().from(null);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void testSelectSERVER() {
-    setFrom("SERVER");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
-
-    setFrom("server");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
-
-    setFrom("sErVeR");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
-  }
-
-  @Test
-  public void testSERVER_FullyConfiguredUser() {
-    setFrom("SERVER");
-
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = userNoLookup(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void testSERVER_NullUser() {
-    setFrom("SERVER");
-    replay(accountCache);
-    final Address r = create().from(null);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void testSelectMIXED() {
-    setFrom("MIXED");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
-
-    setFrom("mixed");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
-
-    setFrom("mIxEd");
-    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
-  }
-
-  @Test
-  public void testMIXED_FullyConfiguredUser() {
-    setFrom("MIXED");
-
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(name + " (Code Review)");
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void testMIXED_NoFullNameUser() {
-    setFrom("MIXED");
-
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(null, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo("Anonymous Coward (Code Review)");
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void testMIXED_NoPreferredEmailUser() {
-    setFrom("MIXED");
-
-    final String name = "A U. Thor";
-    final Account.Id user = user(name, null);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(name + " (Code Review)");
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void testMIXED_NullUser() {
-    setFrom("MIXED");
-    replay(accountCache);
-    final Address r = create().from(null);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo(ident.getEmailAddress());
-    verify(accountCache);
-  }
-
-  @Test
-  public void testCUSTOM_FullyConfiguredUser() {
-    setFrom("A ${user} B <my.server@email.address>");
-
-    final String name = "A U. Thor";
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(name, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo("A " + name + " B");
-    assertThat(r.email).isEqualTo("my.server@email.address");
-    verify(accountCache);
-  }
-
-  @Test
-  public void testCUSTOM_NoFullNameUser() {
-    setFrom("A ${user} B <my.server@email.address>");
-
-    final String email = "a.u.thor@test.example.com";
-    final Account.Id user = user(null, email);
-
-    replay(accountCache);
-    final Address r = create().from(user);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo("A Anonymous Coward B");
-    assertThat(r.email).isEqualTo("my.server@email.address");
-    verify(accountCache);
-  }
-
-  @Test
-  public void testCUSTOM_NullUser() {
-    setFrom("A ${user} B <my.server@email.address>");
-
-    replay(accountCache);
-    final Address r = create().from(null);
-    assertThat(r).isNotNull();
-    assertThat(r.name).isEqualTo(ident.getName());
-    assertThat(r.email).isEqualTo("my.server@email.address");
-    verify(accountCache);
-  }
-
-  private Account.Id user(final String name, final String email) {
-    final AccountState s = makeUser(name, email);
-    expect(accountCache.get(eq(s.getAccount().getId()))).andReturn(s);
-    return s.getAccount().getId();
-  }
-
-  private Account.Id userNoLookup(final String name, final String email) {
-    final AccountState s = makeUser(name, email);
-    return s.getAccount().getId();
-  }
-
-  private AccountState makeUser(final String name, final String email) {
-    final Account.Id userId = new Account.Id(42);
-    final Account account = new Account(userId, TimeUtil.nowTs());
-    account.setFullName(name);
-    account.setPreferredEmail(email);
-    return new AccountState(account, Collections.<AccountGroup.UUID> emptySet(),
-        Collections.<AccountExternalId> emptySet(),
-        new HashMap<ProjectWatchKey, Set<NotifyType>>());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
deleted file mode 100644
index 4f2c776..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import org.junit.Test;
-
-import java.io.BufferedReader;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-
-public class ValidatorTest {
-  private static final String UNSUPPORTED_PREFIX = "#! ";
-
-  @Test
-  public void validateLocalDomain() throws Exception {
-    assertThat(OutgoingEmailValidator.isValid("foo@bar.local")).isTrue();
-  }
-
-  @Test
-  public void validateTopLevelDomains() throws Exception {
-    try (InputStream in =
-        this.getClass().getResourceAsStream("tlds-alpha-by-domain.txt")) {
-      if (in == null) {
-        throw new Exception("TLD list not found");
-      }
-      BufferedReader r = new BufferedReader(new InputStreamReader(in));
-      String tld;
-      while ((tld = r.readLine()) != null) {
-        if (tld.startsWith("# ") || tld.startsWith("XN--")) {
-          // Ignore comments and non-latin domains
-          continue;
-        }
-        if (tld.startsWith(UNSUPPORTED_PREFIX)) {
-          String test = "test@example."
-              + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
-          assert_()
-            .withFailureMessage("expected invalid TLD \"" + test + "\"")
-            .that(OutgoingEmailValidator.isValid(test))
-            .isFalse();
-        } else {
-          String test = "test@example." + tld.toLowerCase();
-          assert_()
-            .withFailureMessage("failed to validate TLD \"" + test + "\"")
-            .that(OutgoingEmailValidator.isValid(test))
-            .isTrue();
-        }
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
new file mode 100644
index 0000000..19ad8bb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
@@ -0,0 +1,102 @@
+// 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.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.server.mail.Address;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import org.joda.time.DateTime;
+import org.junit.Ignore;
+
+@Ignore
+public class AbstractParserTest {
+  protected static final String CHANGE_URL = "https://gerrit-review.googlesource.com/#/changes/123";
+
+  protected static void assertChangeMessage(String message, MailComment comment) {
+    assertThat(comment.fileName).isNull();
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isNull();
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.CHANGE_MESSAGE);
+  }
+
+  protected static void assertInlineComment(
+      String message, MailComment comment, Comment inReplyTo) {
+    assertThat(comment.fileName).isNull();
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isEqualTo(inReplyTo);
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.INLINE_COMMENT);
+  }
+
+  protected static void assertFileComment(String message, MailComment comment, String file) {
+    assertThat(comment.fileName).isEqualTo(file);
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isNull();
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
+  }
+
+  protected static Comment newComment(String uuid, String file, String message, int line) {
+    Comment c =
+        new Comment(
+            new Comment.Key(uuid, file, 1),
+            new Account.Id(0),
+            new Timestamp(0L),
+            (short) 0,
+            message,
+            "",
+            false);
+    c.lineNbr = line;
+    return c;
+  }
+
+  protected static Comment newRangeComment(String uuid, String file, String message, int line) {
+    Comment c =
+        new Comment(
+            new Comment.Key(uuid, file, 1),
+            new Account.Id(0),
+            new Timestamp(0L),
+            (short) 0,
+            message,
+            "",
+            false);
+    c.range = new Comment.Range(line, 1, line + 1, 1);
+    c.lineNbr = line + 1;
+    return c;
+  }
+
+  /** Returns a MailMessage.Builder with all required fields populated. */
+  protected static MailMessage.Builder newMailMessageBuilder() {
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("id");
+    b.from(new Address("Foo Bar", "foo@bar.com"));
+    b.dateReceived(new DateTime());
+    b.subject("");
+    return b;
+  }
+
+  /** Returns a List of default comments for testing. */
+  protected static List<Comment> defaultComments() {
+    List<Comment> comments = new ArrayList<>();
+    comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
+    comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
+    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
+    comments.add(newRangeComment("c4", "gerrit-server/readme.txt", "comment", 3));
+    return comments;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
new file mode 100644
index 0000000..f78953d
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
@@ -0,0 +1,121 @@
+// 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.mail.receive;
+
+/** Test parser for a generic Html email client response */
+public class GenericHtmlParserTest extends HtmlParserTest {
+  @Override
+  protected String newHtmlBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
+    String email =
+        ""
+            + "<div dir=\"ltr\">"
+            + (changeMessage != null ? changeMessage : "")
+            + "<div class=\"extra\"><br><div class=\"quote\">"
+            + "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com"
+            + "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" "
+            + "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>"
+            + "<blockquote class=\"quote\" "
+            + "<p>foobar <strong>posted comments</strong> on this change.</p>"
+            + "<p><a href=\""
+            + CHANGE_URL
+            + "/1\" "
+            + "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n"
+            + "\n"
+            + "(3 comments)</div><ul><li>"
+            + "<p>"
+            + // File #1: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "File gerrit-server/<wbr>test.txt:</a></p>"
+            + commentBlock(f1)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "Patch Set #2:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some comment on file 1</p>"
+            + "</li>"
+            + commentBlock(fc1)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@2\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c1)
+            + ""
+            + // Inline comment #2
+            "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@3\">"
+            + "Patch Set #2, Line 47:</a> </p>"
+            + "<blockquote><pre>Some comment posted on Gerrit</pre>"
+            + "</blockquote><p>Some more comments from Gerrit</p>"
+            + "</li>"
+            + commentBlock(c2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@115\">"
+            + "Patch Set #2, Line 115:</a> <code>some code</code></p>"
+            + "<p>some comment</p></li></ul></li>"
+            + ""
+            + "<li><p>"
+            + // File #2: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt\">"
+            + "File gerrit-server/<wbr>readme.txt:</a></p>"
+            + commentBlock(f2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt@3\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c3)
+            + ""
+            + // Inline comment #2
+            "</ul></li></ul>"
+            + ""
+            + // Footer
+            "<p>To view, visit <a href=\""
+            + CHANGE_URL
+            + "/1\">this change</a>. "
+            + "To unsubscribe, visit <a href=\"https://someurl\">settings</a>."
+            + "</p><p>Gerrit-MessageType: comment<br>"
+            + "Footer omitted</p>"
+            + "<div><div></div></div>"
+            + "<p>Gerrit-HasComments: Yes</p></blockquote></div><br></div></div>";
+    return email;
+  }
+
+  private static String commentBlock(String comment) {
+    if (comment == null) {
+      return "";
+    }
+    return "</ul></li></ul></blockquote><div>"
+        + comment
+        + "</div><blockquote class=\"quote\"><ul><li><ul>";
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
new file mode 100644
index 0000000..e210847
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
@@ -0,0 +1,120 @@
+// 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.mail.receive;
+
+public class GmailHtmlParserTest extends HtmlParserTest {
+  @Override
+  protected String newHtmlBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
+    String email =
+        ""
+            + "<div class=\"gmail_default\" dir=\"ltr\">"
+            + (changeMessage != null ? changeMessage : "")
+            + "<div class=\"gmail_extra\"><br><div class=\"gmail_quote\">"
+            + "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com"
+            + "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" "
+            + "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>"
+            + "<blockquote class=\"gmail_quote\" "
+            + "<p>foobar <strong>posted comments</strong> on this change.</p>"
+            + "<p><a href=\""
+            + CHANGE_URL
+            + "/1\" "
+            + "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n"
+            + "\n"
+            + "(3 comments)</div><ul><li>"
+            + "<p>"
+            + // File #1: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "File gerrit-server/<wbr>test.txt:</a></p>"
+            + commentBlock(f1)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt\">"
+            + "Patch Set #2:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some comment on file 1</p>"
+            + "</li>"
+            + commentBlock(fc1)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@2\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c1)
+            + ""
+            + // Inline comment #2
+            "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@3\">"
+            + "Patch Set #2, Line 47:</a> </p>"
+            + "<blockquote><pre>Some comment posted on Gerrit</pre>"
+            + "</blockquote><p>Some more comments from Gerrit</p>"
+            + "</li>"
+            + commentBlock(c2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/test.txt@115\">"
+            + "Patch Set #2, Line 115:</a> <code>some code</code></p>"
+            + "<p>some comment</p></li></ul></li>"
+            + ""
+            + "<li><p>"
+            + // File #2: test.txt
+            "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt\">"
+            + "File gerrit-server/<wbr>readme.txt:</a></p>"
+            + commentBlock(f2)
+            + "<li><p>"
+            + "<a href=\""
+            + CHANGE_URL
+            + "/1/gerrit-server/readme.txt@3\">"
+            + "Patch Set #2, Line 31:</a> </p>"
+            + "<blockquote><pre>Some inline comment from Gerrit</pre>"
+            + "</blockquote><p>Some text from original comment</p>"
+            + "</li>"
+            + commentBlock(c3)
+            + ""
+            + // Inline comment #2
+            "</ul></li></ul>"
+            + ""
+            + // Footer
+            "<p>To view, visit <a href=\""
+            + CHANGE_URL
+            + "/1\">this change</a>. "
+            + "To unsubscribe, visit <a href=\"https://someurl\">settings</a>."
+            + "</p><p>Gerrit-MessageType: comment<br>"
+            + "Footer omitted</p>"
+            + "<div><div></div></div>"
+            + "<p>Gerrit-HasComments: Yes</p></blockquote></div><br></div></div>";
+    return email;
+  }
+
+  private static String commentBlock(String comment) {
+    if (comment == null) {
+      return "";
+    }
+    return "</ul></li></ul></blockquote><div>"
+        + comment
+        + "</div><blockquote class=\"gmail_quote\"><ul><li><ul>";
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
new file mode 100644
index 0000000..62bc580
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
@@ -0,0 +1,122 @@
+// 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.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class HtmlParserTest extends AbstractParserTest {
+  @Test
+  public void simpleChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody("Looks good to me", null, null, null, null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+  }
+
+  @Test
+  public void simpleInlineComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Looks good to me",
+            "I have a comment on this.&nbsp;",
+            null,
+            "Also have a comment here.",
+            null,
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
+  }
+
+  @Test
+  public void simpleFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Looks good to me",
+            null,
+            null,
+            "Also have a comment here.",
+            "This is a nice file",
+            null,
+            null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
+  }
+
+  @Test
+  public void noComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).isEmpty();
+  }
+
+  @Test
+  public void noChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            null, null, null, "Also have a comment here.", "This is a nice file", null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertFileComment("This is a nice file", parsedComments.get(0), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(3));
+  }
+
+  /**
+   * Create an html message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first comment.
+   * @param c2 Comment in reply to second comment.
+   * @param c3 Comment in reply to third comment.
+   * @param f1 Comment on file one.
+   * @param f2 Comment on file two.
+   * @param fc1 Comment in reply to a comment on file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  protected abstract String newHtmlBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1);
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
new file mode 100644
index 0000000..84bae96
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/MetadataParserTest.java
@@ -0,0 +1,117 @@
+// 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.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.MetadataName.toFooterWithDelimiter;
+import static com.google.gerrit.server.mail.MetadataName.toHeaderWithDelimiter;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.MetadataName;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Test;
+
+public class MetadataParserTest {
+  @Test
+  public void parseMetadataFromHeader() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // email headers of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(new DateTime());
+    b.subject("");
+
+    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.CHANGE_NUMBER) + "123");
+    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.PATCH_SET) + "1");
+    b.addAdditionalHeader(toHeaderWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment");
+    b.addAdditionalHeader(
+        toHeaderWithDelimiter(MetadataName.TIMESTAMP) + "Tue, 25 Oct 2016 02:11:35 -0700");
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.getTime())
+        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+  }
+
+  @Test
+  public void parseMetadataFromText() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // the text body of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(new DateTime());
+    b.subject("");
+
+    StringBuilder stringBuilder = new StringBuilder();
+    stringBuilder.append(toFooterWithDelimiter(MetadataName.CHANGE_NUMBER) + "123\r\n");
+    stringBuilder.append("> " + toFooterWithDelimiter(MetadataName.PATCH_SET) + "1\n");
+    stringBuilder.append(toFooterWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment\n");
+    stringBuilder.append(
+        toFooterWithDelimiter(MetadataName.TIMESTAMP) + "Tue, 25 Oct 2016 02:11:35 -0700\r\n");
+    b.textContent(stringBuilder.toString());
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.getTime())
+        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+  }
+
+  @Test
+  public void parseMetadataFromHTML() {
+    // This tests if the metadata parser is able to parse metadata from the
+    // the HTML body of the message.
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("");
+    b.dateReceived(new DateTime());
+    b.subject("");
+
+    StringBuilder stringBuilder = new StringBuilder();
+    stringBuilder.append(
+        "<div id\"someid\">" + toFooterWithDelimiter(MetadataName.CHANGE_NUMBER) + "123</div>");
+    stringBuilder.append("<div>" + toFooterWithDelimiter(MetadataName.PATCH_SET) + "1</div>");
+    stringBuilder.append(
+        "<div>" + toFooterWithDelimiter(MetadataName.MESSAGE_TYPE) + "comment</div>");
+    stringBuilder.append(
+        "<div>"
+            + toFooterWithDelimiter(MetadataName.TIMESTAMP)
+            + "Tue, 25 Oct 2016 02:11:35 -0700"
+            + "</div>");
+    b.htmlContent(stringBuilder.toString());
+
+    Address author = new Address("Diffy", "test@gerritcodereview.com");
+    b.from(author);
+
+    MailMetadata meta = MetadataParser.parse(b.build());
+    assertThat(meta.author).isEqualTo(author.getEmail());
+    assertThat(meta.changeNumber).isEqualTo(123);
+    assertThat(meta.patchSet).isEqualTo(1);
+    assertThat(meta.messageType).isEqualTo("comment");
+    assertThat(meta.timestamp.getTime())
+        .isEqualTo(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC).getMillis());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/ParserUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/ParserUtilTest.java
new file mode 100644
index 0000000..dfa492c
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/ParserUtilTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ParserUtilTest {
+  @Test
+  public void trimQuotationLineOnMessageWithoutQuoatationLine() throws Exception {
+    assertThat(ParserUtil.trimQuotation("One line")).isEqualTo("One line");
+    assertThat(ParserUtil.trimQuotation("Two\nlines")).isEqualTo("Two\nlines");
+    assertThat(ParserUtil.trimQuotation("Thr\nee\nlines")).isEqualTo("Thr\nee\nlines");
+  }
+
+  @Test
+  public void trimQuotationLineOnMixedMessages() throws Exception {
+    assertThat(
+            ParserUtil.trimQuotation(
+                "One line\n"
+                    + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
+                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
+        .isEqualTo("One line");
+    assertThat(
+            ParserUtil.trimQuotation(
+                "One line\n"
+                    + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit) "
+                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
+        .isEqualTo("One line");
+  }
+
+  @Test
+  public void trimQuotationLineOnMessagesContainingQuoationLine() throws Exception {
+    assertThat(
+            ParserUtil.trimQuotation(
+                "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
+                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
+        .isEqualTo("");
+    assertThat(
+            ParserUtil.trimQuotation(
+                "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit) "
+                    + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote:"))
+        .isEqualTo("");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
new file mode 100644
index 0000000..4efa817
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/RawMailParserTest.java
@@ -0,0 +1,74 @@
+// 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.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.mail.receive.data.AttachmentMessage;
+import com.google.gerrit.server.mail.receive.data.Base64HeaderMessage;
+import com.google.gerrit.server.mail.receive.data.HtmlMimeMessage;
+import com.google.gerrit.server.mail.receive.data.NonUTF8Message;
+import com.google.gerrit.server.mail.receive.data.QuotedPrintableHeaderMessage;
+import com.google.gerrit.server.mail.receive.data.RawMailMessage;
+import com.google.gerrit.server.mail.receive.data.SimpleTextMessage;
+import com.google.gerrit.testutil.GerritBaseTests;
+import org.junit.Test;
+
+public class RawMailParserTest extends GerritBaseTests {
+  @Test
+  public void parseEmail() throws Exception {
+    RawMailMessage[] messages =
+        new RawMailMessage[] {
+          new SimpleTextMessage(),
+          new Base64HeaderMessage(),
+          new QuotedPrintableHeaderMessage(),
+          new HtmlMimeMessage(),
+          new AttachmentMessage(),
+          new NonUTF8Message(),
+        };
+    for (RawMailMessage rawMailMessage : messages) {
+      if (rawMailMessage.rawChars() != null) {
+        // Assert Character to Mail Parser
+        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.rawChars());
+        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
+      }
+      if (rawMailMessage.raw() != null) {
+        // Assert String to Mail Parser
+        MailMessage parsedMailMessage = RawMailParser.parse(rawMailMessage.raw());
+        assertMail(parsedMailMessage, rawMailMessage.expectedMailMessage());
+      }
+    }
+  }
+
+  /**
+   * This method makes it easier to debug failing tests by checking each property individual instead
+   * of calling equals as it will immediately reveal the property that diverges between the two
+   * objects.
+   *
+   * @param have MailMessage retrieved from the parser
+   * @param want MailMessage that would be expected
+   */
+  private void assertMail(MailMessage have, MailMessage want) {
+    assertThat(have.id()).isEqualTo(want.id());
+    assertThat(have.to()).isEqualTo(want.to());
+    assertThat(have.from()).isEqualTo(want.from());
+    assertThat(have.cc()).isEqualTo(want.cc());
+    assertThat(have.dateReceived().getMillis()).isEqualTo(want.dateReceived().getMillis());
+    assertThat(have.additionalHeaders()).isEqualTo(want.additionalHeaders());
+    assertThat(have.subject()).isEqualTo(want.subject());
+    assertThat(have.textContent()).isEqualTo(want.textContent());
+    assertThat(have.htmlContent()).isEqualTo(want.htmlContent());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java
new file mode 100644
index 0000000..0a5381e
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java
@@ -0,0 +1,248 @@
+// 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.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+import org.junit.Test;
+
+public class TextParserTest extends AbstractParserTest {
+  private static final String quotedFooter =
+      ""
+          + "> To view, visit https://gerrit-review.googlesource.com/123\n"
+          + "> To unsubscribe, visit https://gerrit-review.googlesource.com\n"
+          + "> \n"
+          + "> Gerrit-MessageType: comment\n"
+          + "> Gerrit-Change-Id: Ie1234021bf1e8d1425641af58fd648fc011db153\n"
+          + "> Gerrit-PatchSet: 1\n"
+          + "> Gerrit-Project: gerrit\n"
+          + "> Gerrit-Branch: master\n"
+          + "> Gerrit-Owner: Foo Bar <foo@bar.com>\n"
+          + "> Gerrit-HasComments: Yes";
+
+  @Test
+  public void simpleChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent("Looks good to me\n" + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+  }
+
+  @Test
+  public void simpleInlineComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        newPlaintextBody(
+                "Looks good to me",
+                "I have a comment on this.",
+                null,
+                "Also have a comment here.",
+                null,
+                null,
+                null)
+            + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
+  }
+
+  @Test
+  public void simpleFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        newPlaintextBody(
+                "Looks good to me",
+                null,
+                null,
+                "Also have a comment here.",
+                "This is a nice file",
+                null,
+                null)
+            + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
+  }
+
+  @Test
+  public void noComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(newPlaintextBody(null, null, null, null, null, null, null) + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).isEmpty();
+  }
+
+  @Test
+  public void noChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        newPlaintextBody(
+                null, null, null, "Also have a comment here.", "This is a nice file", null, null)
+            + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertFileComment("This is a nice file", parsedComments.get(0), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(3));
+  }
+
+  @Test
+  public void allCommentsGmail() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        (newPlaintextBody(
+                    "Looks good to me",
+                    null,
+                    null,
+                    "Also have a comment here.",
+                    "This is a nice file",
+                    null,
+                    null)
+                + quotedFooter)
+            .replace("> ", ">> "));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
+  }
+
+  @Test
+  public void replyToFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        newPlaintextBody(
+                "Looks good to me",
+                null,
+                null,
+                null,
+                null,
+                null,
+                "Comment in reply to file comment")
+            + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("Comment in reply to file comment", parsedComments.get(1), comments.get(0));
+  }
+
+  /**
+   * Create a plaintext message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first inline comment.
+   * @param c2 Comment in reply to second inline comment.
+   * @param c3 Comment in reply to third inline comment.
+   * @param f1 Comment on file one.
+   * @param f2 Comment on file two.
+   * @param fc1 Comment in reply to a comment of file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  private static String newPlaintextBody(
+      String changeMessage, String c1, String c2, String c3, String f1, String f2, String fc1) {
+    return (changeMessage == null ? "" : changeMessage + "\n")
+        + "On Thu, Feb 9, 2017 at 8:21 AM, ekempin (Gerrit)\n"
+        + "<noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com> wrote: \n"
+        + "> Foo Bar has posted comments on this change. (  \n"
+        + "> "
+        + CHANGE_URL
+        + "/1 )\n"
+        + "> \n"
+        + "> Change subject: Test change\n"
+        + "> ...............................................................\n"
+        + "> \n"
+        + "> \n"
+        + "> Patch Set 1: Code-Review+1\n"
+        + "> \n"
+        + "> (3 comments)\n"
+        + "> \n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/test.txt\n"
+        + "> File  \n"
+        + "> gerrit-server/test.txt:\n"
+        + (f1 == null ? "" : f1 + "\n")
+        + "> \n"
+        + "> Patch Set #4:\n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/test.txt\n"
+        + "> \n"
+        + "> Some comment"
+        + "> \n"
+        + (fc1 == null ? "" : fc1 + "\n")
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/test.txt@2\n"
+        + "> PS1, Line 2: throw new Exception(\"Object has unsupported: \" +\n"
+        + ">               :             entry.getValue() +\n"
+        + ">               :             \" must be java.util.Date\");\n"
+        + "> Should entry.getKey() be included in this message?\n"
+        + "> \n"
+        + (c1 == null ? "" : c1 + "\n")
+        + ">\n"
+        + "> \n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/test.txt@3\n"
+        + "> PS1, Line 3: throw new Exception(\"Object has: \" +\n"
+        + ">               :             entry.getValue().getClass() +\n"
+        + ">              :             \" must be java.util.Date\");\n"
+        + "> same here\n"
+        + "> \n"
+        + (c2 == null ? "" : c2 + "\n")
+        + "> \n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/readme.txt\n"
+        + "> File  \n"
+        + "> gerrit-server/readme.txt:\n"
+        + (f2 == null ? "" : f2 + "\n")
+        + "> \n"
+        + "> "
+        + CHANGE_URL
+        + "/1/gerrit-server/readme.txt@3\n"
+        + "> PS1, Line 3: E\n"
+        + "> Should this be EEE like in other places?\n"
+        + (c3 == null ? "" : c3 + "\n");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
new file mode 100644
index 0000000..d8530b5
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.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.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Ignore;
+
+/** Tests that all mime parts that are neither text/plain, nor text/html are dropped. */
+@Ignore
+public class AttachmentMessage extends RawMailMessage {
+  private static String raw =
+      "MIME-Version: 1.0\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w"
+          + "@mail.gmail.com>\n"
+          + "Subject: Test Subject\n"
+          + "From: Patrick Hiesel <hiesel@google.com>\n"
+          + "To: Patrick Hiesel <hiesel@google.com>\n"
+          + "Content-Type: multipart/mixed; boundary=001a114e019a56962d054062708f\n"
+          + "\n"
+          + "--001a114e019a56962d054062708f\n"
+          + "Content-Type: multipart/alternative; boundary=001a114e019a5696250540"
+          + "62708d\n"
+          + "\n"
+          + "--001a114e019a569625054062708d\n"
+          + "Content-Type: text/plain; charset=UTF-8\n"
+          + "\n"
+          + "Contains unwanted attachment"
+          + "\n"
+          + "--001a114e019a569625054062708d\n"
+          + "Content-Type: text/html; charset=UTF-8\n"
+          + "\n"
+          + "<div dir=\"ltr\">Contains unwanted attachment</div>"
+          + "\n"
+          + "--001a114e019a569625054062708d--\n"
+          + "--001a114e019a56962d054062708f\n"
+          + "Content-Type: text/plain; charset=US-ASCII; name=\"test.txt\"\n"
+          + "Content-Disposition: attachment; filename=\"test.txt\"\n"
+          + "Content-Transfer-Encoding: base64\n"
+          + "X-Attachment-Id: f_iv264bt50\n"
+          + "\n"
+          + "VEVTVAo=\n"
+          + "--001a114e019a56962d054062708f--";
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    System.out.println("\uD83D\uDE1B test");
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<CAM7sg=3meaAVUxW3KXeJEVs8sv_ADw1BnvpcHHiYVR2TQQi__w@mail.gmail.com>")
+        .from(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent("Contains unwanted attachment")
+        .htmlContent("<div dir=\"ltr\">Contains unwanted attachment</div>")
+        .subject("Test Subject")
+        .addAdditionalHeader("MIME-Version: 1.0")
+        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+    return expect.build();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
new file mode 100644
index 0000000..affa3bd
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/Base64HeaderMessage.java
@@ -0,0 +1,64 @@
+// 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.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Ignore;
+
+/** Tests parsing a Base64 encoded subject. */
+@Ignore
+public class Base64HeaderMessage extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("\uD83D\uDE1B test")
+        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+    return expect.build();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
new file mode 100644
index 0000000..487e9dd
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/HtmlMimeMessage.java
@@ -0,0 +1,104 @@
+// 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.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Ignore;
+
+/** Tests a message containing mime/alternative (text + html) content. */
+@Ignore
+public class HtmlMimeMessage extends RawMailMessage {
+  private static String textContent = "Simple test";
+
+  // htmlContent is encoded in quoted-printable
+  private static String htmlContent =
+      "<div dir=3D\"ltr\">Test <span style"
+          + "=3D\"background-color:rgb(255,255,0)\">Messa=\n"
+          + "ge</span> in <u>HTML=C2=A0</u><a href=3D\"https://en.wikipedia.org/"
+          + "wiki/%C3%=\n9Cmlaut_(band)\" class=3D\"gmail-mw-redirect\" title=3D\""
+          + "=C3=9Cmlaut (band)\" st=\nyle=3D\"text-decoration:none;color:rgb(11,"
+          + "0,128);background-image:none;backg=\nround-position:initial;background"
+          + "-size:initial;background-repeat:initial;ba=\nckground-origin:initial;"
+          + "background-clip:initial;font-family:sans-serif;font=\n"
+          + "-size:14px\">=C3=9C</a></div>";
+
+  private static String unencodedHtmlContent =
+      ""
+          + "<div dir=\"ltr\">Test <span style=\"background-color:rgb(255,255,0)\">"
+          + "Message</span> in <u>HTML </u><a href=\"https://en.wikipedia.org/wiki/"
+          + "%C3%9Cmlaut_(band)\" class=\"gmail-mw-redirect\" title=\"Ümlaut "
+          + "(band)\" style=\"text-decoration:none;color:rgb(11,0,128);"
+          + "background-image:none;background-position:initial;background-size:"
+          + "initial;background-repeat:initial;background-origin:initial;background"
+          + "-clip:initial;font-family:sans-serif;font-size:14px\">Ü</a></div>";
+
+  private static String raw =
+      ""
+          + "MIME-Version: 1.0\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114cd8be55b4ab053face5cd@google.com>\n"
+          + "Subject: Change in gerrit[master]: Implement receiver class structure "
+          + "and bindings\n"
+          + "From: \"ekempin (Gerrit)\" <noreply-gerritcodereview-qUgXfQecoDLHwp0Ml"
+          + "dAzig@google.com>\n"
+          + "To: Patrick Hiesel <hiesel@google.com>\n"
+          + "Cc: ekempin <ekempin@google.com>\n"
+          + "Content-Type: multipart/alternative; boundary=001a114cd8b"
+          + "e55b486053face5ca\n"
+          + "\n"
+          + "--001a114cd8be55b486053face5ca\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent
+          + "\n"
+          + "--001a114cd8be55b486053face5ca\n"
+          + "Content-Type: text/html; charset=UTF-8\n"
+          + "Content-Transfer-Encoding: quoted-printable\n"
+          + "\n"
+          + htmlContent
+          + "\n"
+          + "--001a114cd8be55b486053face5ca--";
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114cd8be55b4ab053face5cd@google.com>")
+        .from(
+            new Address(
+                "ekempin (Gerrit)", "noreply-gerritcodereview-qUgXfQecoDLHwp0MldAzig@google.com"))
+        .addCc(new Address("ekempin", "ekempin@google.com"))
+        .addTo(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent(textContent)
+        .htmlContent(unencodedHtmlContent)
+        .subject("Change in gerrit[master]: Implement receiver class structure and bindings")
+        .addAdditionalHeader("MIME-Version: 1.0")
+        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+    return expect.build();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
new file mode 100644
index 0000000..9f2af0d
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/NonUTF8Message.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Ignore;
+
+/** Tests that non-UTF8 encodings are handled correctly. */
+@Ignore
+public class NonUTF8Message extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?B?8J+YmyB0ZXN0?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return null;
+  }
+
+  @Override
+  public int[] rawChars() {
+    int[] arr = new int[raw.length()];
+    int i = 0;
+    for (char c : raw.toCharArray()) {
+      arr[i++] = c;
+    }
+    return arr;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("\uD83D\uDE1B test")
+        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+    return expect.build();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
new file mode 100644
index 0000000..2c17859
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/QuotedPrintableHeaderMessage.java
@@ -0,0 +1,65 @@
+// 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.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Ignore;
+
+/** Tests parsing a quoted printable encoded subject */
+@Ignore
+public class QuotedPrintableHeaderMessage extends RawMailMessage {
+  private static String textContent = "Some Text";
+  private static String raw =
+      ""
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: =?UTF-8?Q?=C3=A2me vulgaire?=\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-"
+          + "CtTy0igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    System.out.println("\uD83D\uDE1B test");
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .textContent(textContent)
+        .subject("âme vulgaire")
+        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC));
+    return expect.build();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/RawMailMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
new file mode 100644
index 0000000..2af82ad
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/RawMailMessage.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.receive.data;
+
+import com.google.gerrit.server.mail.receive.MailMessage;
+import org.junit.Ignore;
+
+/** Base class for all email parsing tests. */
+@Ignore
+public abstract class RawMailMessage {
+  // Raw content to feed the parser
+  public abstract String raw();
+
+  public abstract int[] rawChars();
+  // Parsed representation for asserting the expected parser output
+  public abstract MailMessage expectedMailMessage();
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
new file mode 100644
index 0000000..ce833d5
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/SimpleTextMessage.java
@@ -0,0 +1,138 @@
+// 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.mail.receive.data;
+
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Ignore;
+
+/** Tests parsing a simple text message with different headers. */
+@Ignore
+public class SimpleTextMessage extends RawMailMessage {
+  private static String textContent =
+      ""
+          + "Jonathan Nieder has posted comments on this change. (  \n"
+          + "https://gerrit-review.googlesource.com/90018 )\n"
+          + "\n"
+          + "Change subject: (Re)enable voting buttons for merged changes\n"
+          + "...........................................................\n"
+          + "\n"
+          + "\n"
+          + "Patch Set 2:\n"
+          + "\n"
+          + "This is producing NPEs server-side and 500s for the client.   \n"
+          + "when I try to load this change:\n"
+          + "\n"
+          + "  Error in GET /changes/90018/detail?O=10004\n"
+          + "  com.google.gwtorm.OrmException: java.lang.NullPointerException\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:303)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:285)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:263)\n"
+          + "\tat com.google.gerrit.change.GetChange.apply(GetChange.java:50)\n"
+          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:51)\n"
+          + "\tat com.google.gerrit.change.GetDetail.apply(GetDetail.java:26)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.RestApiServlet.service(RestApiServlet.java:367)\n"
+          + "\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:717)\n"
+          + "[...]\n"
+          + "  Caused by: java.lang.NullPointerException\n"
+          + "\tat  \n"
+          + "com.google.gerrit.ChangeJson.setLabelScores(ChangeJson.java:670)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.ChangeJson.labelsFor(ChangeJson.java:845)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.change.ChangeJson.labelsFor(ChangeJson.java:598)\n"
+          + "\tat  \n"
+          + "com.google.gerrit.change.ChangeJson.toChange(ChangeJson.java:499)\n"
+          + "\tat com.google.gerrit.change.ChangeJson.format(ChangeJson.java:294)\n"
+          + "\t... 105 more\n"
+          + "-- \n"
+          + "To view, visit https://gerrit-review.googlesource.com/90018\n"
+          + "To unsubscribe, visit https://gerrit-review.googlesource.com\n"
+          + "\n"
+          + "Gerrit-MessageType: comment\n"
+          + "Gerrit-Change-Id: Iba501e00bee77be3bd0ced72f88fd04ba0accaed\n"
+          + "Gerrit-PatchSet: 2\n"
+          + "Gerrit-Project: gerrit\n"
+          + "Gerrit-Branch: master\n"
+          + "Gerrit-Owner: ekempin <ekempin@google.com>\n"
+          + "Gerrit-Reviewer: Dave Borowitz <dborowitz@google.com>\n"
+          + "Gerrit-Reviewer: Edwin Kempin <ekempin@google.com>\n"
+          + "Gerrit-Reviewer: GerritForge CI <gerritforge@gmail.com>\n"
+          + "Gerrit-Reviewer: Jonathan Nieder <jrn@google.com>\n"
+          + "Gerrit-Reviewer: Patrick Hiesel <hiesel@google.com>\n"
+          + "Gerrit-Reviewer: ekempin <ekempin@google.com>\n"
+          + "Gerrit-HasComments: No";
+
+  private static String raw =
+      ""
+          + "Authentication-Results: mx.google.com; dkim=pass header.i="
+          + "@google.com;\n"
+          + "Date: Tue, 25 Oct 2016 02:11:35 -0700\n"
+          + "In-Reply-To: <gerrit.1477487889000.Iba501e00bee77be3bd0ced"
+          + "72f88fd04ba0accaed@gerrit-review.googlesource.com>\n"
+          + "References: <gerrit.1477487889000.Iba501e00bee77be3bd0ced72f8"
+          + "8fd04ba0accaed@gerrit-review.googlesource.com>\n"
+          + "Message-ID: <001a114da7ae26e2eb053fe0c29c@google.com>\n"
+          + "Subject: Change in gerrit[master]: (Re)enable voting buttons for "
+          + "merged changes\n"
+          + "From: \"Jonathan Nieder (Gerrit)\" <noreply-gerritcodereview-CtTy0"
+          + "igsBrnvL7dKoWEIEg@google.com>\n"
+          + "To: ekempin <ekempin@google.com>\n"
+          + "Cc: Dave Borowitz <dborowitz@google.com>, Jonathan Nieder "
+          + "<jrn@google.com>, Patrick Hiesel <hiesel@google.com>\n"
+          + "Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes\n"
+          + "\n"
+          + textContent;
+
+  @Override
+  public String raw() {
+    return raw;
+  }
+
+  @Override
+  public int[] rawChars() {
+    return null;
+  }
+
+  @Override
+  public MailMessage expectedMailMessage() {
+    MailMessage.Builder expect = MailMessage.builder();
+    expect
+        .id("<001a114da7ae26e2eb053fe0c29c@google.com>")
+        .from(
+            new Address(
+                "Jonathan Nieder (Gerrit)",
+                "noreply-gerritcodereview-CtTy0igsBrnvL7dKoWEIEg@google.com"))
+        .addTo(new Address("ekempin", "ekempin@google.com"))
+        .addCc(new Address("Dave Borowitz", "dborowitz@google.com"))
+        .addCc(new Address("Jonathan Nieder", "jrn@google.com"))
+        .addCc(new Address("Patrick Hiesel", "hiesel@google.com"))
+        .textContent(textContent)
+        .subject("Change in gerrit[master]: (Re)enable voting buttons for merged changes")
+        .dateReceived(new DateTime(2016, 10, 25, 9, 11, 35, 0, DateTimeZone.UTC))
+        .addAdditionalHeader(
+            "Authentication-Results: mx.google.com; dkim=pass header.i=@google.com;")
+        .addAdditionalHeader(
+            "In-Reply-To: <gerrit.1477487889000.Iba501e00bee"
+                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>")
+        .addAdditionalHeader(
+            "References: <gerrit.1477487889000.Iba501e00bee"
+                + "77be3bd0ced72f88fd04ba0accaed@gerrit-review.googlesource.com>");
+    return expect.build();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java
new file mode 100644
index 0000000..f4fbc78
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java
@@ -0,0 +1,450 @@
+// 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.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.LIST;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PARAGRAPH;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
+
+import java.util.List;
+import org.junit.Test;
+
+public class CommentFormatterTest {
+  private void assertBlock(
+      List<CommentFormatter.Block> list, int index, CommentFormatter.BlockType type, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(type);
+    assertThat(block.text).isEqualTo(text);
+    assertThat(block.items).isNull();
+    assertThat(block.quotedBlocks).isNull();
+  }
+
+  private void assertListBlock(
+      List<CommentFormatter.Block> list, int index, int itemIndex, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(LIST);
+    assertThat(block.items.get(itemIndex)).isEqualTo(text);
+    assertThat(block.text).isNull();
+    assertThat(block.quotedBlocks).isNull();
+  }
+
+  private void assertQuoteBlock(List<CommentFormatter.Block> list, int index, int size) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(QUOTE);
+    assertThat(block.items).isNull();
+    assertThat(block.text).isNull();
+    assertThat(block.quotedBlocks).hasSize(size);
+  }
+
+  @Test
+  public void parseNullAsEmpty() {
+    assertThat(CommentFormatter.parse(null)).isEmpty();
+  }
+
+  @Test
+  public void parseEmpty() {
+    assertThat(CommentFormatter.parse("")).isEmpty();
+  }
+
+  @Test
+  public void parseSimple() {
+    String comment = "Para1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void parseMultilinePara() {
+    String comment = "Para 1\nStill para 1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void parseParaBreak() {
+    String comment = "Para 1\n\nPara 2\n\nPara 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+    assertBlock(result, 2, PARAGRAPH, "Para 3");
+  }
+
+  @Test
+  public void parseQuote() {
+    String comment = "> Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
+  }
+
+  @Test
+  public void parseExcludesEmpty() {
+    String comment = "Para 1\n\n\n\nPara 2";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+  }
+
+  @Test
+  public void parseQuoteLeadSpace() {
+    String comment = " > Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
+  }
+
+  @Test
+  public void parseMultiLineQuote() {
+    String comment = "> Quote line 1\n> Quote line 2\n > Quote line 3\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(
+        result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote line 1\nQuote line 2\nQuote line 3\n");
+  }
+
+  @Test
+  public void parsePre() {
+    String comment = "    Four space indent.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseOneSpacePre() {
+    String comment = " One space indent.\n Another line.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseTabPre() {
+    String comment = "\tOne tab indent.\n\tAnother line.\n  Yet another!";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseIntermediateLeadingWhitespacePre() {
+    String comment = "No indent.\n\tNonzero indent.\nNo indent again.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseStarList() {
+    String comment = "* Item 1\n* Item 2\n* Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void parseDashList() {
+    String comment = "- Item 1\n- Item 2\n- Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void parseMixedList() {
+    String comment = "- Item 1\n* Item 2\n- Item 3\n* Item 4";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+    assertListBlock(result, 0, 3, "Item 4");
+  }
+
+  @Test
+  public void parseMixedBlockTypes() {
+    String comment =
+        "Paragraph\nacross\na\nfew\nlines."
+            + "\n\n"
+            + "> Quote\n> across\n> not many lines."
+            + "\n\n"
+            + "Another paragraph"
+            + "\n\n"
+            + "* Series\n* of\n* list\n* items"
+            + "\n\n"
+            + "Yet another paragraph"
+            + "\n\n"
+            + "\tPreformatted text."
+            + "\n\n"
+            + "Parting words.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(7);
+    assertBlock(result, 0, PARAGRAPH, "Paragraph\nacross\na\nfew\nlines.");
+    assertQuoteBlock(result, 1, 1);
+    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH, "Quote\nacross\nnot many lines.");
+    assertBlock(result, 2, PARAGRAPH, "Another paragraph");
+    assertListBlock(result, 3, 0, "Series");
+    assertListBlock(result, 3, 1, "of");
+    assertListBlock(result, 3, 2, "list");
+    assertListBlock(result, 3, 3, "items");
+    assertBlock(result, 4, PARAGRAPH, "Yet another paragraph");
+    assertBlock(result, 5, PRE_FORMATTED, "\tPreformatted text.");
+    assertBlock(result, 6, PARAGRAPH, "Parting words.");
+  }
+
+  @Test
+  public void bulletList1() {
+    String comment = "A\n\n* line 1\n* 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void bulletList2() {
+    String comment = "A\n\n* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void bulletList3() {
+    String comment = "* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void bulletList4() {
+    String comment =
+        "To see this bug, you have to:\n" //
+            + "* Be on IMAP or EAS (not on POP)\n" //
+            + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
+    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
+    assertListBlock(result, 1, 1, "Be very unlucky");
+  }
+
+  @Test
+  public void bulletList5() {
+    String comment =
+        "To see this bug,\n" //
+            + "you have to:\n" //
+            + "* Be on IMAP or EAS (not on POP)\n" //
+            + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
+    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
+    assertListBlock(result, 1, 1, "Be very unlucky");
+  }
+
+  @Test
+  public void dashList1() {
+    String comment = "A\n\n- line 1\n- 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void dashList2() {
+    String comment = "A\n\n- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void dashList3() {
+    String comment = "- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void preformat1() {
+    String comment = "A\n\n  This is pre\n  formatted";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+  }
+
+  @Test
+  public void preformat2() {
+    String comment = "A\n\n  This is pre\n  formatted\n\nbut this is not";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+    assertBlock(result, 2, PARAGRAPH, "but this is not");
+  }
+
+  @Test
+  public void preformat3() {
+    String comment = "A\n\n  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void preformat4() {
+    String comment = "  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void quote1() {
+    String comment = "> I'm happy\n > with quotes!\n\nSee above.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "I'm happy\nwith quotes!");
+    assertBlock(result, 1, PARAGRAPH, "See above.");
+  }
+
+  @Test
+  public void quote2() {
+    String comment = "See this said:\n\n > a quoted\n > string block\n\nOK?";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "See this said:");
+    assertQuoteBlock(result, 1, 1);
+    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH, "a quoted\nstring block");
+    assertBlock(result, 2, PARAGRAPH, "OK?");
+  }
+
+  @Test
+  public void nestedQuotes1() {
+    String comment = " > > prior\n > \n > next\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 2);
+    assertQuoteBlock(result.get(0).quotedBlocks, 0, 1);
+    assertBlock(result.get(0).quotedBlocks.get(0).quotedBlocks, 0, PARAGRAPH, "prior");
+    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "next\n");
+  }
+
+  @Test
+  public void largeMixedQuote() {
+    String comment =
+        "> > Paragraph 1.\n"
+            + "> > \n"
+            + "> > > Paragraph 2.\n"
+            + "> > \n"
+            + "> > Paragraph 3.\n"
+            + "> > \n"
+            + "> >    pre line 1;\n"
+            + "> >    pre line 2;\n"
+            + "> > \n"
+            + "> > Paragraph 4.\n"
+            + "> > \n"
+            + "> > * List item 1.\n"
+            + "> > * List item 2.\n"
+            + "> > \n"
+            + "> > Paragraph 5.\n"
+            + "> \n"
+            + "> Paragraph 6.\n"
+            + "\n"
+            + "Paragraph 7.\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertQuoteBlock(result, 0, 2);
+
+    assertQuoteBlock(result.get(0).quotedBlocks, 0, 7);
+    List<CommentFormatter.Block> bigQuote = result.get(0).quotedBlocks.get(0).quotedBlocks;
+    assertBlock(bigQuote, 0, PARAGRAPH, "Paragraph 1.");
+    assertQuoteBlock(bigQuote, 1, 1);
+    assertBlock(bigQuote.get(1).quotedBlocks, 0, PARAGRAPH, "Paragraph 2.");
+    assertBlock(bigQuote, 2, PARAGRAPH, "Paragraph 3.");
+    assertBlock(bigQuote, 3, PRE_FORMATTED, "   pre line 1;\n   pre line 2;");
+    assertBlock(bigQuote, 4, PARAGRAPH, "Paragraph 4.");
+    assertListBlock(bigQuote, 5, 0, "List item 1.");
+    assertListBlock(bigQuote, 5, 1, "List item 2.");
+    assertBlock(bigQuote, 6, PARAGRAPH, "Paragraph 5.");
+    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "Paragraph 6.");
+    assertBlock(result, 1, PARAGRAPH, "Paragraph 7.\n");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
new file mode 100644
index 0000000..a7b37a8
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -0,0 +1,388 @@
+// Copyright (C) 2009 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.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.createStrictMock;
+import static org.easymock.EasyMock.eq;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.mail.Address;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FromAddressGeneratorProviderTest {
+  private Config config;
+  private PersonIdent ident;
+  private AccountCache accountCache;
+
+  @Before
+  public void setUp() throws Exception {
+    config = new Config();
+    ident = new PersonIdent("NAME", "e@email", 0, 0);
+    accountCache = createStrictMock(AccountCache.class);
+  }
+
+  private FromAddressGenerator create() {
+    return new FromAddressGeneratorProvider(config, "Anonymous Coward", ident, accountCache).get();
+  }
+
+  private void setFrom(final String newFrom) {
+    config.setString("sendemail", null, "from", newFrom);
+  }
+
+  private void setDomains(List<String> domains) {
+    config.setStringList("sendemail", null, "allowedDomain", domains);
+  }
+
+  @Test
+  public void defaultIsMIXED() {
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
+  }
+
+  @Test
+  public void selectUSER() {
+    setFrom("USER");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
+
+    setFrom("user");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
+
+    setFrom("uSeR");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.UserGen.class);
+  }
+
+  @Test
+  public void USER_FullyConfiguredUser() {
+    setFrom("USER");
+
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void USER_NoFullNameUser() {
+    setFrom("USER");
+
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(null, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isNull();
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void USER_NoPreferredEmailUser() {
+    setFrom("USER");
+
+    final String name = "A U. Thor";
+    final Account.Id user = user(name, null);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void USER_NullUser() {
+    setFrom("USER");
+    replay(accountCache);
+    final Address r = create().from(null);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERAllowDomain() {
+    setFrom("USER");
+    setDomains(Arrays.asList("*.example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERNoAllowDomain() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERAllowDomainTwice() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com"));
+    setDomains(Arrays.asList("test.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERAllowDomainTwiceReverse() {
+    setFrom("USER");
+    setDomains(Arrays.asList("test.com"));
+    setDomains(Arrays.asList("example.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void USERAllowTwoDomains() {
+    setFrom("USER");
+    setDomains(Arrays.asList("example.com", "test.com"));
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name);
+    assertThat(r.getEmail()).isEqualTo(email);
+    verify(accountCache);
+  }
+
+  @Test
+  public void selectSERVER() {
+    setFrom("SERVER");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
+
+    setFrom("server");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
+
+    setFrom("sErVeR");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.ServerGen.class);
+  }
+
+  @Test
+  public void SERVER_FullyConfiguredUser() {
+    setFrom("SERVER");
+
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = userNoLookup(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void SERVER_NullUser() {
+    setFrom("SERVER");
+    replay(accountCache);
+    final Address r = create().from(null);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void selectMIXED() {
+    setFrom("MIXED");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
+
+    setFrom("mixed");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
+
+    setFrom("mIxEd");
+    assertThat(create()).isInstanceOf(FromAddressGeneratorProvider.PatternGen.class);
+  }
+
+  @Test
+  public void MIXED_FullyConfiguredUser() {
+    setFrom("MIXED");
+
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void MIXED_NoFullNameUser() {
+    setFrom("MIXED");
+
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(null, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo("Anonymous Coward (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void MIXED_NoPreferredEmailUser() {
+    setFrom("MIXED");
+
+    final String name = "A U. Thor";
+    final Account.Id user = user(name, null);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(name + " (Code Review)");
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void MIXED_NullUser() {
+    setFrom("MIXED");
+    replay(accountCache);
+    final Address r = create().from(null);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo(ident.getEmailAddress());
+    verify(accountCache);
+  }
+
+  @Test
+  public void CUSTOM_FullyConfiguredUser() {
+    setFrom("A ${user} B <my.server@email.address>");
+
+    final String name = "A U. Thor";
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(name, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo("A " + name + " B");
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    verify(accountCache);
+  }
+
+  @Test
+  public void CUSTOM_NoFullNameUser() {
+    setFrom("A ${user} B <my.server@email.address>");
+
+    final String email = "a.u.thor@test.example.com";
+    final Account.Id user = user(null, email);
+
+    replay(accountCache);
+    final Address r = create().from(user);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo("A Anonymous Coward B");
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    verify(accountCache);
+  }
+
+  @Test
+  public void CUSTOM_NullUser() {
+    setFrom("A ${user} B <my.server@email.address>");
+
+    replay(accountCache);
+    final Address r = create().from(null);
+    assertThat(r).isNotNull();
+    assertThat(r.getName()).isEqualTo(ident.getName());
+    assertThat(r.getEmail()).isEqualTo("my.server@email.address");
+    verify(accountCache);
+  }
+
+  private Account.Id user(final String name, final String email) {
+    final AccountState s = makeUser(name, email);
+    expect(accountCache.get(eq(s.getAccount().getId()))).andReturn(s);
+    return s.getAccount().getId();
+  }
+
+  private Account.Id userNoLookup(final String name, final String email) {
+    final AccountState s = makeUser(name, email);
+    return s.getAccount().getId();
+  }
+
+  private AccountState makeUser(final String name, final String email) {
+    final Account.Id userId = new Account.Id(42);
+    final Account account = new Account(userId, TimeUtil.nowTs());
+    account.setFullName(name);
+    account.setPreferredEmail(email);
+    return new AccountState(
+        account, Collections.emptySet(), Collections.emptySet(), new HashMap<>());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java
new file mode 100644
index 0000000..5fafcf7
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import org.junit.Test;
+
+public class ValidatorTest {
+  private static final String UNSUPPORTED_PREFIX = "#! ";
+
+  @Test
+  public void validateLocalDomain() throws Exception {
+    assertThat(OutgoingEmailValidator.isValid("foo@bar.local")).isTrue();
+  }
+
+  @Test
+  public void validateTopLevelDomains() throws Exception {
+    try (InputStream in = this.getClass().getResourceAsStream("tlds-alpha-by-domain.txt")) {
+      if (in == null) {
+        throw new Exception("TLD list not found");
+      }
+      BufferedReader r = new BufferedReader(new InputStreamReader(in, UTF_8));
+      String tld;
+      while ((tld = r.readLine()) != null) {
+        if (tld.startsWith("# ") || tld.startsWith("XN--")) {
+          // Ignore comments and non-latin domains
+          continue;
+        }
+        if (tld.startsWith(UNSUPPORTED_PREFIX)) {
+          String test = "test@example." + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
+          assert_()
+              .withFailureMessage("expected invalid TLD \"" + test + "\"")
+              .that(OutgoingEmailValidator.isValid(test))
+              .isFalse();
+        } else {
+          String test = "test@example." + tld.toLowerCase();
+          assert_()
+              .withFailureMessage("failed to validate TLD \"" + test + "\"")
+              .that(OutgoingEmailValidator.isValid(test))
+              .isTrue();
+        }
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index fabb53d..be153c9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -25,12 +25,10 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.Patch;
-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.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -54,20 +52,20 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeAccountCache;
 import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gerrit.testutil.TestNotesMigration;
 import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.StandardKeyEncoder;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.util.Providers;
-
+import java.sql.Timestamp;
+import java.util.TimeZone;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -76,17 +74,30 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
-
-import java.sql.Timestamp;
-import java.util.TimeZone;
+import org.junit.runner.RunWith;
 
 @Ignore
+@RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest extends GerritBaseTests {
-  private static final TimeZone TZ =
-      TimeZone.getTimeZone("America/Los_Angeles");
+  @ConfigSuite.Default
+  public static Config changeNotesLegacy() {
+    Config cfg = new Config();
+    cfg.setBoolean("notedb", null, "writeJson", false);
+    return cfg;
+  }
 
-  private static final NotesMigration MIGRATION =
-      new TestNotesMigration().setAllEnabled(true);
+  @ConfigSuite.Config
+  public static Config changeNotesJson() {
+    Config cfg = new Config();
+    cfg.setBoolean("notedb", null, "writeJson", true);
+    return cfg;
+  }
+
+  @ConfigSuite.Parameter public Config testConfig;
+
+  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+
+  private static final NotesMigration MIGRATION = new TestNotesMigration().setAllEnabled(true);
 
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
@@ -100,31 +111,24 @@
   protected RevWalk rw;
   protected TestRepository<InMemoryRepository> tr;
 
-  @Inject
-  protected IdentifiedUser.GenericFactory userFactory;
+  @Inject protected IdentifiedUser.GenericFactory userFactory;
 
-  @Inject
-  protected NoteDbUpdateManager.Factory updateManagerFactory;
+  @Inject protected NoteDbUpdateManager.Factory updateManagerFactory;
 
-  @Inject
-  protected AllUsersName allUsers;
+  @Inject protected AllUsersName allUsers;
 
-  @Inject
-  protected ChangeNoteUtil noteUtil;
+  @Inject protected AbstractChangeNotes.Args args;
 
-  @Inject
-  protected AbstractChangeNotes.Args args;
+  @Inject @GerritServerId private String serverId;
 
-  private Injector injector;
+  protected Injector injector;
   private String systemTimeZone;
 
   @Before
   public void setUp() throws Exception {
     setTimeForTesting();
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
 
-    serverIdent = new PersonIdent(
-        "Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
+    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
     project = new Project.NameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
@@ -140,39 +144,41 @@
     ou.setPreferredEmail("other@account.com");
     accountCache.put(ou);
 
-    injector = Guice.createInjector(new FactoryModule() {
-      @Override
-      public void configure() {
-        Config cfg = new Config();
-        install(new GitModule());
-        install(NoteDbModule.forTest(cfg));
-        bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
-        bind(String.class).annotatedWith(GerritServerId.class)
-            .toInstance("gerrit");
-        bind(NotesMigration.class).toInstance(MIGRATION);
-        bind(GitRepositoryManager.class).toInstance(repoManager);
-        bind(ProjectCache.class).toProvider(Providers.<ProjectCache> of(null));
-        bind(CapabilityControl.Factory.class)
-            .toProvider(Providers.<CapabilityControl.Factory> of(null));
-        bind(Config.class).annotatedWith(GerritServerConfig.class)
-            .toInstance(cfg);
-        bind(String.class).annotatedWith(AnonymousCowardName.class)
-            .toProvider(AnonymousCowardNameProvider.class);
-        bind(String.class).annotatedWith(CanonicalWebUrl.class)
-            .toInstance("http://localhost:8080/");
-        bind(Boolean.class).annotatedWith(DisableReverseDnsLookup.class)
-            .toInstance(Boolean.FALSE);
-        bind(Realm.class).to(FakeRealm.class);
-        bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
-        bind(AccountCache.class).toInstance(accountCache);
-        bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class)
-            .toInstance(serverIdent);
-        bind(GitReferenceUpdated.class)
-            .toInstance(GitReferenceUpdated.DISABLED);
-        bind(MetricMaker.class).to(DisabledMetricMaker.class);
-        bind(ReviewDb.class).toProvider(Providers.<ReviewDb> of(null));
-      }
-    });
+    injector =
+        Guice.createInjector(
+            new FactoryModule() {
+              @Override
+              public void configure() {
+                install(new GitModule());
+                install(NoteDbModule.forTest(testConfig));
+                bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+                bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
+                bind(NotesMigration.class).toInstance(MIGRATION);
+                bind(GitRepositoryManager.class).toInstance(repoManager);
+                bind(ProjectCache.class).toProvider(Providers.<ProjectCache>of(null));
+                bind(CapabilityControl.Factory.class)
+                    .toProvider(Providers.<CapabilityControl.Factory>of(null));
+                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
+                bind(String.class)
+                    .annotatedWith(AnonymousCowardName.class)
+                    .toProvider(AnonymousCowardNameProvider.class);
+                bind(String.class)
+                    .annotatedWith(CanonicalWebUrl.class)
+                    .toInstance("http://localhost:8080/");
+                bind(Boolean.class)
+                    .annotatedWith(DisableReverseDnsLookup.class)
+                    .toInstance(Boolean.FALSE);
+                bind(Realm.class).to(FakeRealm.class);
+                bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
+                bind(AccountCache.class).toInstance(accountCache);
+                bind(PersonIdent.class)
+                    .annotatedWith(GerritPersonIdent.class)
+                    .toInstance(serverIdent);
+                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+                bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                bind(ReviewDb.class).toProvider(Providers.<ReviewDb>of(null));
+              }
+            });
 
     injector.injectMembers(this);
     repoManager.createRepository(allUsers);
@@ -202,8 +208,7 @@
     return c;
   }
 
-  protected ChangeUpdate newUpdate(Change c, CurrentUser user)
-      throws Exception {
+  protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
     ChangeUpdate update = TestChanges.newUpdate(injector, c, user);
     update.setPatchSetId(c.currentPatchSetId());
     update.setAllowWriteToNewRef(true);
@@ -214,8 +219,8 @@
     return new ChangeNotes(args, c).load();
   }
 
-  protected static SubmitRecord submitRecord(String status,
-      String errorMessage, SubmitRecord.Label... labels) {
+  protected static SubmitRecord submitRecord(
+      String status, String errorMessage, SubmitRecord.Label... labels) {
     SubmitRecord rec = new SubmitRecord();
     rec.status = SubmitRecord.Status.valueOf(status);
     rec.errorMessage = errorMessage;
@@ -225,8 +230,8 @@
     return rec;
   }
 
-  protected static SubmitRecord.Label submitLabel(String name, String status,
-      Account.Id appliedBy) {
+  protected static SubmitRecord.Label submitLabel(
+      String name, String status, Account.Id appliedBy) {
     SubmitRecord.Label label = new SubmitRecord.Label();
     label.label = name;
     label.status = SubmitRecord.Label.Status.valueOf(status);
@@ -234,30 +239,33 @@
     return label;
   }
 
-  protected PatchLineComment newPublishedComment(PatchSet.Id psId,
-      String filename, String UUID, CommentRange range, int line,
-      IdentifiedUser commenter, String parentUUID, Timestamp t,
-      String message, short side, String commitSHA1) {
-    return newComment(psId, filename, UUID, range, line, commenter,
-        parentUUID, t, message, side, commitSHA1,
-        PatchLineComment.Status.PUBLISHED);
-  }
-
-  protected PatchLineComment newComment(PatchSet.Id psId,
-      String filename, String UUID, CommentRange range, int line,
-      IdentifiedUser commenter, String parentUUID, Timestamp t,
-      String message, short side, String commitSHA1,
-      PatchLineComment.Status status) {
-    PatchLineComment comment = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(psId, filename), UUID),
-        line, commenter.getAccountId(), parentUUID, t);
-    comment.setSide(side);
-    comment.setMessage(message);
-    comment.setRange(range);
-    comment.setRevId(new RevId(commitSHA1));
-    comment.setStatus(status);
-    return comment;
+  protected Comment newComment(
+      PatchSet.Id psId,
+      String filename,
+      String UUID,
+      CommentRange range,
+      int line,
+      IdentifiedUser commenter,
+      String parentUUID,
+      Timestamp t,
+      String message,
+      short side,
+      String commitSHA1,
+      boolean unresolved) {
+    Comment c =
+        new Comment(
+            new Comment.Key(UUID, filename, psId.get()),
+            commenter.getAccountId(),
+            t,
+            side,
+            message,
+            serverId,
+            unresolved);
+    c.lineNbr = line;
+    c.parentUuid = parentUUID;
+    c.revId = commitSHA1;
+    c.setRange(range);
+    return c;
   }
 
   protected static Timestamp truncate(Timestamp ts) {
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 c093b75..90e6800 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
@@ -39,41 +39,34 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gerrit.testutil.TestTimeUtil;
-import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
-import com.google.gwtorm.server.StandardKeyEncoder;
-
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.TimeZone;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
 
-public class ChangeBundleTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
-  }
-
-  private static final ProtobufCodec<Change> CHANGE_CODEC =
-      CodecFactory.encoder(Change.class);
+public class ChangeBundleTest extends GerritBaseTests {
+  private static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
   private static final ProtobufCodec<ChangeMessage> CHANGE_MESSAGE_CODEC =
       CodecFactory.encoder(ChangeMessage.class);
   private static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
       CodecFactory.encoder(PatchSet.class);
-  private static final ProtobufCodec<PatchSetApproval>
-      PATCH_SET_APPROVAL_CODEC = CodecFactory.encoder(PatchSetApproval.class);
-  private static final ProtobufCodec<PatchLineComment>
-      PATCH_LINE_COMMENT_CODEC = CodecFactory.encoder(PatchLineComment.class);
+  private static final ProtobufCodec<PatchSetApproval> PATCH_SET_APPROVAL_CODEC =
+      CodecFactory.encoder(PatchSetApproval.class);
+  private static final ProtobufCodec<PatchLineComment> PATCH_LINE_COMMENT_CODEC =
+      CodecFactory.encoder(PatchLineComment.class);
 
   private String systemTimeZoneProperty;
   private TimeZone systemTimeZone;
@@ -102,8 +95,7 @@
   }
 
   private void superWindowResolution() {
-    TestTimeUtil.setClockStep(
-        ChangeRebuilderImpl.MAX_WINDOW_MS * 2, MILLISECONDS);
+    TestTimeUtil.setClockStep(ChangeRebuilderImpl.MAX_WINDOW_MS * 2, MILLISECONDS);
     TimeUtil.nowTs();
   }
 
@@ -118,61 +110,73 @@
     int id1 = c1.getId().get();
     Change c2 = TestChanges.newChange(project, accountId);
     int id2 = c2.getId().get();
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
 
-    assertDiffs(b1, b2,
+    assertDiffs(
+        b1,
+        b2,
         "changeId differs for Changes: {" + id1 + "} != {" + id2 + "}",
-        "createdOn differs for Changes:"
-            + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}",
+        "createdOn differs for Changes: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}",
         "effective last updated time differs for Changes:"
             + " {2009-09-30 17:00:00.0} != {2009-09-30 17:00:06.0}");
   }
 
   @Test
   public void diffChangesSameId() throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
     Change c2 = clone(c1);
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
     c2.setTopic("topic");
-    assertDiffs(b1, b2,
-        "topic differs for Change.Id " + c1.getId() + ": {null} != {topic}");
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {null} != {topic}");
   }
 
   @Test
   public void diffChangesMixedSourcesAllowsSlop() throws Exception {
     subWindowResolution();
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
     Change c2 = clone(c1);
     c2.setCreatedOn(TimeUtil.nowTs());
     c2.setLastUpdatedOn(TimeUtil.nowTs());
 
     // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "createdOn differs for Change.Id " + c1.getId() + ":"
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), 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}",
-        "effective last updated time differs for Change.Id " + c1.getId() + ":"
+        "effective last updated time differs for Change.Id "
+            + c1.getId()
+            + ":"
             + " {2009-09-30 17:00:01.0} != {2009-09-30 17:00:03.0}");
 
     // One NoteDb, slop is allowed.
-    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
@@ -180,186 +184,264 @@
     superWindowResolution();
     Change c3 = clone(c1);
     c3.setLastUpdatedOn(TimeUtil.nowTs());
-    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 = new ChangeBundle(c3, messages(), patchSets(), approvals(),
-        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}";
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c3, messages(), patchSets(), approvals(), 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}";
     assertDiffs(b1, b3, msg);
     assertDiffs(b3, b1, msg);
   }
 
   @Test
-  public void diffChangesIgnoresOriginalSubjectInReviewDb()
-      throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
+  public void diffChangesIgnoresOriginalSubjectInReviewDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
     c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject", "Original A");
     Change c2 = clone(c1);
-    c2.setCurrentPatchSet(
-        c2.currentPatchSetId(), c1.getSubject(), "Original B");
+    c2.setCurrentPatchSet(c2.currentPatchSetId(), c1.getSubject(), "Original B");
 
     // Both ReviewDb, exact match required.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "originalSubject differs for Change.Id " + c1.getId() + ":"
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), 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(),
-        reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
-    assertDiffs(b1, b2,
-        "originalSubject differs for Change.Id " + c1.getId() + ":"
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), 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(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
 
   @Test
-  public void diffChangesConsidersEmptyReviewDbTopicEquivalentToNullInNoteDb()
-      throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
+  public void diffChangesSanitizesSubjectsBeforeComparison() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    c1.setCurrentPatchSet(c1.currentPatchSetId(), "Subject\r\rbody", "Original");
+    Change c2 = clone(c1);
+    c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject  body", "Original");
+
+    // Both ReviewDb, exact match required
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Subject\r\rbody} != {Subject  body}");
+
+    // Both NoteDb, exact match required (although it should be impossible to
+    // create a NoteDb change with '\r' in the subject).
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
+            + " {Subject\r\rbody} != {Subject  body}");
+
+    // One ReviewDb, one NoteDb, '\r' is normalized to ' '.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
+  public void diffChangesConsidersEmptyReviewDbTopicEquivalentToNullInNoteDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
     c1.setTopic("");
     Change c2 = clone(c1);
     c2.setTopic(null);
 
     // Both ReviewDb, exact match required.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "topic differs for Change.Id " + c1.getId() + ":"
-            + " {} != {null}");
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), 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(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), 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(),
-        reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "topic differs for Change.Id " + c1.getId() + ":"
-            + " {} != {null}");
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {} != {null}");
 
     // Null is not equal to a non-empty string.
     Change c3 = clone(c1);
     c3.setTopic("topic");
-    b1 = new ChangeBundle(c3, messages(), patchSets(), approvals(), comments(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
-    assertDiffs(b1, b2,
-        "topic differs for Change.Id " + c1.getId() + ":"
-            + " {topic} != {null}");
+    b1 =
+        new ChangeBundle(
+            c3, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": {topic} != {null}");
 
     // Null is equal to a string that is all whitespace.
     Change c4 = clone(c1);
     c4.setTopic("  ");
-    b1 = new ChangeBundle(c4, messages(), patchSets(), approvals(), comments(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
+    b1 =
+        new ChangeBundle(
+            c4, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
 
   @Test
-  public void diffChangesIgnoresLeadingWhitespaceInReviewDbTopics()
-      throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
-    c1.setTopic(" abc");
+  public void diffChangesIgnoresLeadingAndTrailingWhitespaceInReviewDbTopics() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    c1.setTopic(" abc ");
     Change c2 = clone(c1);
     c2.setTopic("abc");
 
     // Both ReviewDb, exact match required.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "topic differs for Change.Id " + c1.getId() + ":"
-            + " { abc} != {abc}");
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {abc}");
 
     // Leading whitespace in ReviewDb topic is ignored.
-    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
-    // Must match except for the leading whitespace.
+    // Must match except for the leading/trailing whitespace.
     Change c3 = clone(c1);
     c3.setTopic("cba");
-    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c3, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
-    assertDiffs(b1, b2,
-        "topic differs for Change.Id " + c1.getId() + ":"
-            + " { abc} != {cba}");
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c3, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(b1, b2, "topic differs for Change.Id " + c1.getId() + ": { abc } != {cba}");
   }
 
   @Test
-  public void diffChangesTakesMaxEntityTimestampFromReviewDb()
-      throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
-    PatchSetApproval a = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-        (short) 1,
-        TimeUtil.nowTs());
+  public void diffChangesTakesMaxEntityTimestampFromReviewDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    PatchSet ps = new PatchSet(c1.currentPatchSetId());
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps.setUploader(accountId);
+    ps.setCreatedOn(TimeUtil.nowTs());
+    PatchSetApproval a =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
 
     Change c2 = clone(c1);
     c2.setLastUpdatedOn(a.getGranted());
 
     // Both ReviewDb, exact match required.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
-        approvals(a), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
-        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}");
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(ps), 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:12.0}");
 
     // NoteDb allows latest timestamp from all entities in bundle.
-    b2 = new ChangeBundle(c2, messages(), patchSets(),
-        approvals(a), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
   }
 
   @Test
   public void diffChangesIgnoresChangeTimestampIfAnyOtherEntitiesExist() {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
-    PatchSetApproval a = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-        (short) 1,
-        TimeUtil.nowTs());
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    PatchSet ps = new PatchSet(c1.currentPatchSetId());
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps.setUploader(accountId);
+    ps.setCreatedOn(TimeUtil.nowTs());
+    PatchSetApproval a =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c1.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
     c1.setLastUpdatedOn(a.getGranted());
 
     Change c2 = clone(c1);
@@ -367,116 +449,144 @@
 
     // 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(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c1, messages(), patchSets(),
-        approvals(a), comments(), reviewers(), NOTE_DB);
-    assertThat(b1.getChange().getLastUpdatedOn())
-        .isGreaterThan(b2.getChange().getLastUpdatedOn());
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c2, messages(), patchSets(ps), approvals(a), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c1, messages(), patchSets(ps), 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(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
-    assertDiffs(b1, b2,
-        "effective last updated time differs for Change.Id " + c1.getId()
+    b1 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "effective last updated time differs for Change.Id "
+            + c1.getId()
             + " in NoteDb vs. ReviewDb:"
-            + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:12.0}");
+            + " {2009-09-30 17:00:12.0} != {2009-09-30 17:00:18.0}");
   }
 
   @Test
-  public void diffChangesAllowsReviewDbSubjectToBePrefixOfNoteDbSubject()
-      throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
+  public void diffChangesAllowsReviewDbSubjectToBePrefixOfNoteDbSubject() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
     Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c1.currentPatchSetId(),
-        c1.getSubject().substring(0, 10), c1.getOriginalSubject());
+    c2.setCurrentPatchSet(
+        c1.currentPatchSetId(), c1.getSubject().substring(0, 10), c1.getOriginalSubject());
     assertThat(c2.getSubject()).isNotEqualTo(c1.getSubject());
 
     // Both ReviewDb, exact match required.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "subject differs for Change.Id " + c1.getId() + ":"
-            + " {Change subject} != {Change sub}");
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), 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(), reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // NoteDb has shorter subject, not allowed.
-    b1 = new ChangeBundle(c1, messages(), latest(c1), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c2, messages(), latest(c2), approvals(),
-        comments(), reviewers(), NOTE_DB);
-    assertDiffs(b1, b2,
-        "subject differs for Change.Id " + c1.getId() + ":"
-            + " {Change subject} != {Change sub}");
+    b1 =
+        new ChangeBundle(
+            c1, messages(), latest(c1), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(c2, messages(), latest(c2), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id " + c1.getId() + ": {Change subject} != {Change sub}");
   }
 
   @Test
-  public void diffChangesTrimsLeadingSpacesFromReviewDbComparingToNoteDb()
-      throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
+  public void diffChangesTrimsLeadingSpacesFromReviewDbComparingToNoteDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
     Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c1.currentPatchSetId(),
-        "   " + c1.getSubject(), c1.getOriginalSubject());
+    c2.setCurrentPatchSet(c1.currentPatchSetId(), "   " + c1.getSubject(), c1.getOriginalSubject());
 
     // Both ReviewDb, exact match required.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "subject differs for Change.Id " + c1.getId() + ":"
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), 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(), reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
 
   @Test
-  public void diffChangesDoesntTrimLeadingNonSpaceWhitespaceFromSubject()
-      throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
+  public void diffChangesDoesntTrimLeadingNonSpaceWhitespaceFromSubject() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
     Change c2 = clone(c1);
-    c2.setCurrentPatchSet(c1.currentPatchSetId(),
-        "\t" + c1.getSubject(), c1.getOriginalSubject());
+    c2.setCurrentPatchSet(c1.currentPatchSetId(), "\t" + c1.getSubject(), c1.getOriginalSubject());
 
     // Both ReviewDb.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "subject differs for Change.Id " + c1.getId() + ":"
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), 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(), latest(c1), approvals(),
-        comments(), reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c2, messages(), latest(c2), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "subject differs for Change.Id " + c1.getId() + ":"
+    b1 =
+        new ChangeBundle(c1, messages(), latest(c1), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), latest(c2), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
             + " {Change subject} != {\tChange subject}");
-    assertDiffs(b2, b1,
-        "subject differs for Change.Id " + c1.getId() + ":"
+    assertDiffs(
+        b2,
+        b1,
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
             + " {\tChange subject} != {Change subject}");
   }
 
@@ -489,43 +599,56 @@
     c2.setCurrentPatchSet(c2.currentPatchSetId(), "Subject", "Subject");
 
     // Both ReviewDb.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "originalSubject differs for Change.Id " + c1.getId() + ":"
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "originalSubject differs for Change.Id "
+            + c1.getId()
+            + ":"
             + " {Subject\r \r Rest of message.} != {Subject}",
-        "subject differs for Change.Id " + c1.getId() + ":"
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
             + " {Subject\r \r Rest of message.} != {Subject}");
 
     // NoteDb has correct subject without "\r ".
-    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(),
-        comments(), reviewers(), NOTE_DB);
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
 
   @Test
-  public void diffChangesIgnoresInvalidCurrentPatchSetIdInReviewDb()
-      throws Exception {
-    Change c1 = TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(100));
+  public void diffChangesIgnoresInvalidCurrentPatchSetIdInReviewDb() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
     Change c2 = clone(c1);
-    c2.setCurrentPatchSet(new PatchSet.Id(c2.getId(), 0), "Unrelated subject",
-        c2.getOriginalSubject());
+    c2.setCurrentPatchSet(
+        new PatchSet.Id(c2.getId(), 0), "Unrelated subject", c2.getOriginalSubject());
 
     // Both ReviewDb.
-    ChangeBundle b1 = new ChangeBundle(c1, messages(), patchSets(),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c2, messages(), patchSets(),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "currentPatchSetId differs for Change.Id " + c1.getId() + ":"
-            + " {1} != {0}",
-        "subject differs for Change.Id " + c1.getId() + ":"
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "currentPatchSetId differs for Change.Id " + c1.getId() + ": {1} != {0}",
+        "subject differs for Change.Id "
+            + c1.getId()
+            + ":"
             + " {Change subject} != {Unrelated subject}");
 
     // One NoteDb.
@@ -533,10 +656,12 @@
     // This is based on a real corrupt change where all patch sets were deleted
     // but the Change entity stuck around, resulting in a currentPatchSetId of 0
     // after converting to NoteDb.
-    b1 = new ChangeBundle(c1, messages(), patchSets(), approvals(), comments(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c2, messages(), patchSets(), approvals(), comments(),
-        reviewers(), NOTE_DB);
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
@@ -545,40 +670,63 @@
   public void diffChangeMessageKeySets() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
-    ChangeMessage cm1 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid1"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
-    ChangeMessage cm2 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid2"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
-    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    ChangeMessage cm2 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid2"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
 
-    assertDiffs(b1, b2,
+    assertDiffs(
+        b1,
+        b2,
         "ChangeMessage.Key sets differ:"
-            + " [" + id + ",uuid1] only in A; [" + id + ",uuid2] only in B");
+            + " ["
+            + id
+            + ",uuid1] only in A; ["
+            + id
+            + ",uuid2] only in B");
   }
 
   @Test
   public void diffChangeMessages() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
-    ChangeMessage cm1 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
     cm1.setMessage("message 1");
     ChangeMessage cm2 = clone(cm1);
-    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
     cm2.setMessage("message 2");
-    assertDiffs(b1, b2,
-        "message differs for ChangeMessage.Key " + c.getId() + ",uuid:"
+    assertDiffs(
+        b1,
+        b2,
+        "message differs for ChangeMessage.Key "
+            + c.getId()
+            + ",uuid:"
             + " {message 1} != {message 2}");
   }
 
@@ -586,27 +734,40 @@
   public void diffChangeMessagesIgnoresUuids() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
-    ChangeMessage cm1 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid1"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
     cm1.setMessage("message 1");
     ChangeMessage cm2 = clone(cm1);
     cm2.getKey().set("uuid2");
 
-    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
     // Both are ReviewDb, exact UUID match is required.
-    assertDiffs(b1, b2,
+    assertDiffs(
+        b1,
+        b2,
         "ChangeMessage.Key sets differ:"
-            + " [" + id + ",uuid1] only in A; [" + id + ",uuid2] only in B");
+            + " ["
+            + id
+            + ",uuid1] only in A; ["
+            + id
+            + ",uuid2] only in B");
 
     // One NoteDb, UUIDs are ignored.
-    b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
-        reviewers(), NOTE_DB);
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
   }
 
@@ -614,91 +775,126 @@
   public void diffChangeMessagesWithDifferentCounts() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
-    ChangeMessage cm1 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid1"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
     cm1.setMessage("message 1");
-    ChangeMessage cm2 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid2"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm2 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid2"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
     cm1.setMessage("message 2");
 
     // Both ReviewDb: Uses same keySet diff as other types.
-    ChangeBundle b1 = new ChangeBundle(c, messages(cm1, cm2), latest(c),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "ChangeMessage.Key sets differ: [" + id
-        + ",uuid2] only in A; [] only in B");
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), 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(), reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        reviewers(), NOTE_DB);
-    assertDiffs(b1, b2,
-        "ChangeMessages differ for Change.Id " + id + "\n"
-            + "Only in A:\n  " + cm2);
-    assertDiffs(b2, b1,
-        "ChangeMessages differ for Change.Id " + id + "\n"
-            + "Only in B:\n  " + cm2);
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1, cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(b1, b2, "ChangeMessages differ for Change.Id " + id + "\nOnly in A:\n  " + cm2);
+    assertDiffs(b2, b1, "ChangeMessages differ for Change.Id " + id + "\nOnly in B:\n  " + cm2);
   }
 
   @Test
   public void diffChangeMessagesMixedSourcesWithDifferences() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
-    ChangeMessage cm1 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid1"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
     cm1.setMessage("message 1");
     ChangeMessage cm2 = clone(cm1);
     cm2.setMessage("message 2");
     ChangeMessage cm3 = clone(cm1);
     cm3.getKey().set("uuid2"); // Differs only in UUID.
 
-    ChangeBundle b1 = new ChangeBundle(c, messages(cm1, cm3), latest(c),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(cm2, cm3), latest(c),
-        approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1, cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2, cm3), latest(c), 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.
-    assertDiffs(b1, b2,
-        "ChangeMessages differ for Change.Id " + id + "\n"
-            + "Only in A:\n  " + cm3 + "\n"
-            + "Only in B:\n  " + cm2);
-    assertDiffs(b2, b1,
-        "ChangeMessages differ for Change.Id " + id + "\n"
-            + "Only in A:\n  " + cm2 + "\n"
-            + "Only in B:\n  " + cm3);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm3
+            + "\n"
+            + "Only in B:\n  "
+            + cm2);
+    assertDiffs(
+        b2,
+        b1,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm2
+            + "\n"
+            + "Only in B:\n  "
+            + cm3);
   }
 
   @Test
   public void diffChangeMessagesMixedSourcesAllowsSlop() throws Exception {
     subWindowResolution();
     Change c = TestChanges.newChange(project, accountId);
-    ChangeMessage cm1 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid1"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
     ChangeMessage cm2 = clone(cm1);
     cm2.setWrittenOn(TimeUtil.nowTs());
 
     // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "writtenOn differs for ChangeMessage.Key " + c.getId() + ",uuid1:"
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), 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(),
-        reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
-        reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
@@ -706,96 +902,107 @@
     superWindowResolution();
     ChangeMessage cm3 = clone(cm1);
     cm3.setWrittenOn(TimeUtil.nowTs());
-    b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        reviewers(), NOTE_DB);
-    ChangeBundle b3 = new ChangeBundle(c, messages(cm3), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c, messages(cm3), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
     int id = c.getId().get();
-    assertDiffs(b1, b3,
-        "ChangeMessages differ for Change.Id " + id + "\n"
-            + "Only in A:\n  " + cm1 + "\n"
-            + "Only in B:\n  " + cm3);
-    assertDiffs(b3, b1,
-        "ChangeMessages differ for Change.Id " + id + "\n"
-            + "Only in A:\n  " + cm3 + "\n"
-            + "Only in B:\n  " + cm1);
+    assertDiffs(
+        b1,
+        b3,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm1
+            + "\n"
+            + "Only in B:\n  "
+            + cm3);
+    assertDiffs(
+        b3,
+        b1,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm3
+            + "\n"
+            + "Only in B:\n  "
+            + cm1);
   }
 
   @Test
-  public void diffChangeMessagesAllowsNullPatchSetIdFromReviewDb()
-      throws Exception {
+  public void diffChangeMessagesAllowsNullPatchSetIdFromReviewDb() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
-    ChangeMessage cm1 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid"),
-        accountId, TimeUtil.nowTs(), c.currentPatchSetId());
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
     cm1.setMessage("message 1");
     ChangeMessage cm2 = clone(cm1);
     cm2.setPatchSetId(null);
 
-    ChangeBundle b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
 
     // Both are ReviewDb, exact patch set ID match is required.
-    assertDiffs(b1, b2,
-        "patchset differs for ChangeMessage.Key " + c.getId() + ",uuid:"
-            + " {" + id + ",1} != {null}");
+    assertDiffs(
+        b1,
+        b2,
+        "patchset differs for ChangeMessage.Key "
+            + c.getId()
+            + ",uuid:"
+            + " {"
+            + id
+            + ",1} != {null}");
 
     // Null patch set ID on ReviewDb is ignored.
-    b1 = new ChangeBundle(c, messages(cm1), latest(c), approvals(), comments(),
-        reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
-        reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), 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(),
-        reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c, messages(cm2), latest(c), approvals(), comments(),
-        reviewers(), NOTE_DB);
-    assertDiffs(b1, b2,
-        "ChangeMessages differ for Change.Id " + id + "\n"
-            + "Only in A:\n  " + cm1 + "\n"
-            + "Only in B:\n  " + cm2);
-    assertDiffs(b2, b1,
-        "ChangeMessages differ for Change.Id " + id + "\n"
-            + "Only in A:\n  " + cm2 + "\n"
-            + "Only in B:\n  " + cm1);
-  }
-
-  @Test
-  public void diffChangeMessagesIgnoresMessagesOnPatchSetGreaterThanCurrent()
-      throws Exception {
-    Change c = TestChanges.newChange(project, accountId);
-
-    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
-    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
-    ps1.setUploader(accountId);
-    ps1.setCreatedOn(TimeUtil.nowTs());
-    PatchSet ps2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
-    ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
-    ps2.setUploader(accountId);
-    ps2.setCreatedOn(TimeUtil.nowTs());
-
-    assertThat(c.currentPatchSetId()).isEqualTo(ps1.getId());
-
-    ChangeMessage cm1 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid1"),
-        accountId, TimeUtil.nowTs(), ps1.getId());
-    cm1.setMessage("a message");
-    ChangeMessage cm2 = new ChangeMessage(
-        new ChangeMessage.Key(c.getId(), "uuid2"),
-        accountId, TimeUtil.nowTs(), ps2.getId());
-    cm2.setMessage("other message");
-
-    ChangeBundle b1 = new ChangeBundle(c, messages(cm1, cm2),
-        patchSets(ps1, ps2), approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(cm1), patchSets(ps1),
-        approvals(), comments(), reviewers(), NOTE_DB);
-    assertNoDiffs(b1, b2);
-    assertNoDiffs(b2, b1);
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), latest(c), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(cm2), latest(c), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm1
+            + "\n"
+            + "Only in B:\n  "
+            + cm2);
+    assertDiffs(
+        b2,
+        b1,
+        "ChangeMessages differ for Change.Id "
+            + id
+            + "\n"
+            + "Only in A:\n  "
+            + cm2
+            + "\n"
+            + "Only in B:\n  "
+            + cm1);
   }
 
   @Test
@@ -812,14 +1019,14 @@
     ps2.setUploader(accountId);
     ps2.setCreatedOn(TimeUtil.nowTs());
 
-    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps2),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2),
-        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1, ps2), approvals(), comments(), reviewers(), REVIEW_DB);
 
-    assertDiffs(b1, b2,
-        "PatchSet.Id sets differ:"
-            + " [] only in A; [" + c.getId() + ",1] only in B");
+    assertDiffs(b1, b2, "PatchSet.Id sets differ: [] only in A; [" + c.getId() + ",1] only in B");
   }
 
   @Test
@@ -830,16 +1037,22 @@
     ps1.setUploader(accountId);
     ps1.setCreatedOn(TimeUtil.nowTs());
     PatchSet ps2 = clone(ps1);
-    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
-        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
     ps2.setRevision(new RevId("badc0feebadc0feebadc0feebadc0feebadc0fee"));
-    assertDiffs(b1, b2,
-        "revision differs for PatchSet.Id " + c.getId() + ",1:"
+    assertDiffs(
+        b1,
+        b2,
+        "revision differs for PatchSet.Id "
+            + c.getId()
+            + ",1:"
             + " {RevId{deadbeefdeadbeefdeadbeefdeadbeefdeadbeef}}"
             + " != {RevId{badc0feebadc0feebadc0feebadc0feebadc0fee}}");
   }
@@ -856,39 +1069,50 @@
     ps2.setCreatedOn(TimeUtil.nowTs());
 
     // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
-        approvals(), comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
-        "createdOn differs for PatchSet.Id " + c.getId() + ",1:"
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), 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(), reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c, messages(), patchSets(ps2), approvals(),
-        comments(), reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // But not too much slop.
     superWindowResolution();
     PatchSet ps3 = clone(ps1);
     ps3.setCreatedOn(TimeUtil.nowTs());
-    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(),
-        comments(), reviewers(), NOTE_DB);
-    ChangeBundle b3 = new ChangeBundle(c, messages(), patchSets(ps3),
-        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}";
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps3), 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}";
     assertDiffs(b1, b3, msg);
     assertDiffs(b3, b1, msg);
   }
 
   @Test
-  public void diffPatchSetsIgnoresTrailingNewlinesInPushCertificate()
-      throws Exception {
+  public void diffPatchSetsIgnoresTrailingNewlinesInPushCertificate() throws Exception {
     subWindowResolution();
     Change c = TestChanges.newChange(project, accountId);
     PatchSet ps1 = new PatchSet(c.currentPatchSetId());
@@ -899,23 +1123,27 @@
     PatchSet ps2 = clone(ps1);
     ps2.setPushCertificate(ps2.getPushCertificate() + "\n\n");
 
-    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
-        approvals(), comments(), reviewers(), NOTE_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps2),
-        approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
-    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(),
-        comments(), reviewers(), REVIEW_DB);
-    b2 = new ChangeBundle(c, messages(), patchSets(ps2), approvals(),
-        comments(), reviewers(), NOTE_DB);
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
 
   @Test
-  public void diffIgnoresPatchSetsGreaterThanCurrent() throws Exception {
+  public void diffPatchSetsGreaterThanCurrent() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
 
     PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
@@ -928,183 +1156,465 @@
     ps2.setCreatedOn(TimeUtil.nowTs());
     assertThat(ps2.getId().get()).isGreaterThan(c.currentPatchSetId().get());
 
-    PatchSetApproval a1 = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            ps1.getId(), accountId, new LabelId("Code-Review")),
-        (short) 1,
-        TimeUtil.nowTs());
-    PatchSetApproval a2 = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            ps2.getId(), accountId, new LabelId("Code-Review")),
-        (short) 1,
-        TimeUtil.nowTs());
+    ChangeMessage cm1 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid1"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+    ChangeMessage cm2 =
+        new ChangeMessage(
+            new ChangeMessage.Key(c.getId(), "uuid2"),
+            accountId,
+            TimeUtil.nowTs(),
+            c.currentPatchSetId());
+
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(ps1.getId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
+    PatchSetApproval a2 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(ps2.getId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
 
     // Both ReviewDb.
-    ChangeBundle b1 = new ChangeBundle(c, messages(), patchSets(ps1),
-        approvals(a1), comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2),
-        approvals(a1, a2), comments(), reviewers(), REVIEW_DB);
-    assertNoDiffs(b1, b2);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c,
+            messages(cm1, cm2),
+            patchSets(ps1, ps2),
+            approvals(a1, a2),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessage.Key sets differ: [] only in A; [" + cm2.getKey() + "] only in B",
+        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
+        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
 
     // One NoteDb.
-    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(a1),
-        comments(), reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2), approvals(a1, a2),
-        comments(), reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(cm1, cm2),
+            patchSets(ps1, ps2),
+            approvals(a1, a2),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n  " + cm2,
+        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
+        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
+
+    // Both NoteDb.
+    b1 =
+        new ChangeBundle(
+            c, messages(cm1), patchSets(ps1), approvals(a1), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(cm1, cm2),
+            patchSets(ps1, ps2),
+            approvals(a1, a2),
+            comments(),
+            reviewers(),
+            NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "ChangeMessages differ for Change.Id " + c.getId() + "\nOnly in B:\n  " + cm2,
+        "PatchSet.Id sets differ: [] only in A; [" + ps2.getId() + "] only in B",
+        "PatchSetApproval.Key sets differ: [] only in A; [" + a2.getKey() + "] only in B");
+  }
+
+  @Test
+  public void diffPatchSetsIgnoresLeadingAndTrailingWhitespaceInReviewDbDescriptions()
+      throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+
+    PatchSet ps1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    ps1.setUploader(accountId);
+    ps1.setCreatedOn(TimeUtil.nowTs());
+    ps1.setDescription(" abc ");
+    PatchSet ps2 = clone(ps1);
+    ps2.setDescription("abc");
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {abc}");
+
+    // Whitespace in ReviewDb description is ignored.
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps2), approvals(), comments(), reviewers(), NOTE_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
 
-    // Both NoteDb.
-    b1 = new ChangeBundle(c, messages(), patchSets(ps1), approvals(a1),
-        comments(), reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c, messages(), patchSets(ps1, ps2), approvals(a1, a2),
-        comments(), reviewers(), NOTE_DB);
+    // Must match except for the leading/trailing whitespace.
+    PatchSet ps3 = clone(ps1);
+    ps3.setDescription("cba");
+    b1 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps1), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), patchSets(ps3), approvals(), comments(), reviewers(), NOTE_DB);
+    assertDiffs(
+        b1, b2, "description differs for PatchSet.Id " + ps1.getId() + ": { abc } != {cba}");
+  }
+
+  @Test
+  public void diffPatchSetsIgnoresCreatedOnWhenReviewDbIsNonMonotonic() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+
+    Timestamp beforePs1 = TimeUtil.nowTs();
+
+    PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    goodPs1.setUploader(accountId);
+    goodPs1.setCreatedOn(TimeUtil.nowTs());
+    PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    goodPs2.setUploader(accountId);
+    goodPs2.setCreatedOn(TimeUtil.nowTs());
+    assertThat(goodPs2.getCreatedOn()).isGreaterThan(goodPs1.getCreatedOn());
+
+    PatchSet badPs2 = clone(goodPs2);
+    badPs2.setCreatedOn(beforePs1);
+    assertThat(badPs2.getCreatedOn()).isLessThan(goodPs1.getCreatedOn());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, badPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + badPs2.getId()
+            + ":"
+            + " {2009-09-30 17:00:18.0} != {2009-09-30 17:00:06.0}");
+
+    // Non-monotonic in ReviewDb but monotonic in NoteDb, timestamps are
+    // ignored, including for ps1.
+    PatchSet badPs1 = clone(goodPs1);
+    badPs1.setCreatedOn(TimeUtil.nowTs());
+    b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(badPs1, badPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            NOTE_DB);
     assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // Non-monotonic in NoteDb but monotonic in ReviewDb, timestamps are not
+    // ignored.
+    b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(badPs1, badPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + badPs1.getId()
+            + " in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:24.0} != {2009-09-30 17:00:12.0}",
+        "createdOn differs for PatchSet.Id "
+            + badPs2.getId()
+            + " in NoteDb vs. ReviewDb:"
+            + " {2009-09-30 17:00:06.0} != {2009-09-30 17:00:18.0}");
   }
 
   @Test
   public void diffPatchSetApprovalKeySets() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
-    PatchSetApproval a1 = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-        (short) 1,
-        TimeUtil.nowTs());
-    PatchSetApproval a2 = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            c.currentPatchSetId(), accountId, new LabelId("Verified")),
-        (short) 1,
-        TimeUtil.nowTs());
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
+    PatchSetApproval a2 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Verified")),
+            (short) 1,
+            TimeUtil.nowTs());
 
-    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
-        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
 
-    assertDiffs(b1, b2,
+    assertDiffs(
+        b1,
+        b2,
         "PatchSetApproval.Key sets differ:"
-            + " [" + id + "%2C1,100,Code-Review] only in A;"
-            + " [" + id + "%2C1,100,Verified] only in B");
+            + " ["
+            + id
+            + "%2C1,100,Code-Review] only in A;"
+            + " ["
+            + id
+            + "%2C1,100,Verified] only in B");
   }
 
   @Test
   public void diffPatchSetApprovals() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
-    PatchSetApproval a1 = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-        (short) 1,
-        TimeUtil.nowTs());
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            TimeUtil.nowTs());
     PatchSetApproval a2 = clone(a1);
-    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
-        comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
     a2.setValue((short) -1);
-    assertDiffs(b1, b2,
+    assertDiffs(
+        b1,
+        b2,
         "value differs for PatchSetApproval.Key "
-            + c.getId() + "%2C1,100,Code-Review: {1} != {-1}");
+            + c.getId()
+            + "%2C1,100,Code-Review: {1} != {-1}");
   }
 
   @Test
-  public void diffPatchSetApprovalsMixedSourcesAllowsSlop()
-      throws Exception {
+  public void diffPatchSetApprovalsMixedSourcesAllowsSlop() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     subWindowResolution();
-    PatchSetApproval a1 = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-        (short) 1,
-        roundToSecond(TimeUtil.nowTs()));
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            roundToSecond(TimeUtil.nowTs()));
     PatchSetApproval a2 = clone(a1);
     a2.setGranted(TimeUtil.nowTs());
 
     // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
         "granted differs for PatchSetApproval.Key "
-            + c.getId() + "%2C1,100,Code-Review:"
+            + c.getId()
+            + "%2C1,100,Code-Review:"
             + " {2009-09-30 17:00:07.0} != {2009-09-30 17:00:08.0}");
 
     // One NoteDb, slop is allowed.
-    b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
-        reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(),
-        reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // But not too much slop.
     superWindowResolution();
     PatchSetApproval a3 = clone(a1);
     a3.setGranted(TimeUtil.nowTs());
-    b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
-        reviewers(), NOTE_DB);
-    ChangeBundle b3 = new ChangeBundle(c, messages(), latest(c), approvals(a3),
-        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}";
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a3), 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}";
     assertDiffs(b1, b3, msg);
     assertDiffs(b3, b1, msg);
   }
 
   @Test
-  public void diffPatchSetApprovalsAllowsTruncatedTimestampInNoteDb()
-      throws Exception {
+  public void diffPatchSetApprovalsAllowsTruncatedTimestampInNoteDb() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
-    PatchSetApproval a1 = new PatchSetApproval(
-        new PatchSetApproval.Key(
-            c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
-        (short) 1,
-        c.getCreatedOn());
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 1,
+            c.getCreatedOn());
     PatchSetApproval a2 = clone(a1);
-    a2.setGranted(new Timestamp(new DateTime(
-            1900, 1, 1, 0, 0, 0, DateTimeZone.forTimeZone(TimeZone.getDefault()))
-        .getMillis()));
+    a2.setGranted(
+        new Timestamp(
+            new DateTime(1900, 1, 1, 0, 0, 0, DateTimeZone.forTimeZone(TimeZone.getDefault()))
+                .getMillis()));
 
     // Both are ReviewDb, exact match is required.
-    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1),
-        comments(), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2),
-        comments(), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
         "granted differs for PatchSetApproval.Key "
-            + c.getId() + "%2C1,100,Code-Review:"
+            + c.getId()
+            + "%2C1,100,Code-Review:"
             + " {2009-09-30 17:00:00.0} != {1900-01-01 00:00:00.0}");
 
     // Truncating NoteDb timestamp is allowed.
-    b1 = new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(),
-        reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(),
-        reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(a1), comments(), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
     assertNoDiffs(b2, b1);
   }
 
   @Test
+  public void diffPatchSetApprovalsIgnoresPostSubmitBitOnZeroVote() throws Exception {
+    Change c = TestChanges.newChange(project, accountId);
+    c.setStatus(Change.Status.MERGED);
+    PatchSetApproval a1 =
+        new PatchSetApproval(
+            new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
+            (short) 0,
+            TimeUtil.nowTs());
+    a1.setPostSubmit(false);
+    PatchSetApproval a2 = clone(a1);
+    a2.setPostSubmit(true);
+
+    // Both are ReviewDb, exact match is required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a2), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "postSubmit differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review:"
+            + " {false} != {true}");
+
+    // One NoteDb, postSubmit is ignored.
+    b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(a1), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(c, messages(), latest(c), approvals(a2), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+
+    // postSubmit is not ignored if vote isn't 0.
+    a1.setValue((short) 1);
+    a2.setValue((short) 1);
+    assertDiffs(
+        b1,
+        b2,
+        "postSubmit differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review:"
+            + " {false} != {true}");
+    assertDiffs(
+        b2,
+        b1,
+        "postSubmit differs for PatchSetApproval.Key "
+            + c.getId()
+            + "%2C1,100,Code-Review:"
+            + " {true} != {false}");
+  }
+
+  @Test
   public void diffReviewers() 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);
+    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,
-        "reviewer sets differ:"
-            + " [1] only in A;"
-            + " [2] only in B");
+    assertDiffs(b1, b2, "reviewer sets differ: [1] only in A; [2] only in B");
   }
 
   @Test
@@ -1113,10 +1623,10 @@
     ReviewerSet r1 = reviewers(REVIEWER, new Account.Id(1), TimeUtil.nowTs());
     ReviewerSet r2 = reviewers(CC, 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);
+    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);
   }
@@ -1125,108 +1635,147 @@
   public void diffPatchLineCommentKeySets() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
-    PatchLineComment c1 = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
-        5, accountId, null, TimeUtil.nowTs());
-    PatchLineComment c2 = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(c.currentPatchSetId(), "filename2"), "uuid2"),
-        5, accountId, null, TimeUtil.nowTs());
+    PatchLineComment c1 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
+    PatchLineComment c2 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename2"), "uuid2"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
 
-    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c2), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
 
-    assertDiffs(b1, b2,
+    assertDiffs(
+        b1,
+        b2,
         "PatchLineComment.Key sets differ:"
-            + " [" + id + ",1,filename1,uuid1] only in A;"
-            + " [" + id + ",1,filename2,uuid2] only in B");
+            + " ["
+            + id
+            + ",1,filename1,uuid1] only in A;"
+            + " ["
+            + id
+            + ",1,filename2,uuid2] only in B");
   }
 
   @Test
   public void diffPatchLineComments() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
-        5, accountId, null, TimeUtil.nowTs());
+    PatchLineComment c1 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
     PatchLineComment c2 = clone(c1);
-    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c2), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
 
     assertNoDiffs(b1, b2);
 
     c2.setStatus(PatchLineComment.Status.PUBLISHED);
-    assertDiffs(b1, b2,
-        "status differs for PatchLineComment.Key "
-            + c.getId() + ",1,filename,uuid: {d} != {P}");
+    assertDiffs(
+        b1,
+        b2,
+        "status differs for PatchLineComment.Key " + c.getId() + ",1,filename,uuid: {d} != {P}");
   }
 
   @Test
-  public void diffPatchLineCommentsMixedSourcesAllowsSlop()
-      throws Exception {
+  public void diffPatchLineCommentsMixedSourcesAllowsSlop() throws Exception {
     subWindowResolution();
     Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
-        5, accountId, null, roundToSecond(TimeUtil.nowTs()));
+    PatchLineComment c1 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename"), "uuid"),
+            5,
+            accountId,
+            null,
+            roundToSecond(TimeUtil.nowTs()));
     PatchLineComment c2 = clone(c1);
     c2.setWrittenOn(TimeUtil.nowTs());
 
     // Both are ReviewDb, exact timestamp match is required.
-    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c2), reviewers(), REVIEW_DB);
-    assertDiffs(b1, b2,
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
         "writtenOn differs for PatchLineComment.Key "
-            + c.getId() + ",1,filename,uuid:"
+            + c.getId()
+            + ",1,filename,uuid:"
             + " {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(c1),
-        reviewers(), NOTE_DB);
-    b2 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(c2),
-        reviewers(), REVIEW_DB);
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB);
+    b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c2), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
 
     // But not too much slop.
     superWindowResolution();
     PatchLineComment c3 = clone(c1);
     c3.setWrittenOn(TimeUtil.nowTs());
-    b1 = new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1),
-        reviewers(), NOTE_DB);
-    ChangeBundle b3 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        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}";
+    b1 =
+        new ChangeBundle(c, messages(), latest(c), approvals(), comments(c1), reviewers(), NOTE_DB);
+    ChangeBundle b3 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), 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}";
     assertDiffs(b1, b3, msg);
     assertDiffs(b3, b1, msg);
   }
 
   @Test
-  public void diffPatchLineCommentsIgnoresCommentsOnInvalidPatchSet()
-      throws Exception {
+  public void diffPatchLineCommentsIgnoresCommentsOnInvalidPatchSet() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
-    PatchLineComment c1 = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
-        5, accountId, null, TimeUtil.nowTs());
-    PatchLineComment c2 = new PatchLineComment(
-        new PatchLineComment.Key(
-            new Patch.Key(new PatchSet.Id(c.getId(), 0), "filename2"), "uuid2"),
-        5, accountId, null, TimeUtil.nowTs());
+    PatchLineComment c1 =
+        new PatchLineComment(
+            new PatchLineComment.Key(new Patch.Key(c.currentPatchSetId(), "filename1"), "uuid1"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
+    PatchLineComment c2 =
+        new PatchLineComment(
+            new PatchLineComment.Key(
+                new Patch.Key(new PatchSet.Id(c.getId(), 0), "filename2"), "uuid2"),
+            5,
+            accountId,
+            null,
+            TimeUtil.nowTs());
 
-    ChangeBundle b1 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1, c2), reviewers(), REVIEW_DB);
-    ChangeBundle b2 = new ChangeBundle(c, messages(), latest(c), approvals(),
-        comments(c1), reviewers(), REVIEW_DB);
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1, c2), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c, messages(), latest(c), approvals(), comments(c1), reviewers(), REVIEW_DB);
     assertNoDiffs(b1, b2);
   }
 
@@ -1235,8 +1784,7 @@
     assertThat(b.differencesFrom(a)).isEmpty();
   }
 
-  private static void assertDiffs(ChangeBundle a, ChangeBundle b, String first,
-      String... rest) {
+  private static void assertDiffs(ChangeBundle a, ChangeBundle b, String first, String... rest) {
     List<String> actual = a.differencesFrom(b);
     if (actual.size() == 1 && rest.length == 0) {
       // This error message is much easier to read.
@@ -1259,7 +1807,9 @@
   }
 
   private static List<PatchSet> latest(Change c) {
-    return ImmutableList.of(new PatchSet(c.currentPatchSetId()));
+    PatchSet ps = new PatchSet(c.currentPatchSetId());
+    ps.setCreatedOn(c.getLastUpdatedOn());
+    return ImmutableList.of(ps);
   }
 
   private static List<PatchSetApproval> approvals(PatchSetApproval... ents) {
@@ -1268,11 +1818,9 @@
 
   private static ReviewerSet reviewers(Object... ents) {
     checkArgument(ents.length % 3 == 0);
-    Table<ReviewerStateInternal, Account.Id, Timestamp> t =
-        HashBasedTable.create();
+    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]);
+      t.put((ReviewerStateInternal) ents[i], (Account.Id) ents[i + 1], (Timestamp) ents[i + 2]);
     }
     return ReviewerSet.fromTable(t);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index ab37ec9..39c4c08 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
-
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -49,413 +48,422 @@
 
   @Test
   public void parseAuthor() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Subject: This is a test change\n");
-    assertParseFails(writeCommit("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n",
-        new PersonIdent("Change Owner", "owner@example.com",
-          serverIdent.getWhen(), serverIdent.getTimeZone())));
-    assertParseFails(writeCommit("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n",
-        new PersonIdent("Change Owner", "x@gerrit",
-          serverIdent.getWhen(), serverIdent.getTimeZone())));
-    assertParseFails(writeCommit("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n",
-        new PersonIdent("Change\n\u1234<Owner>", "\n\nx<@>\u0002gerrit",
-          serverIdent.getWhen(), serverIdent.getTimeZone())));
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails(
+        writeCommit(
+            "Update change\n\nPatch-set: 1\n",
+            new PersonIdent(
+                "Change Owner",
+                "owner@example.com",
+                serverIdent.getWhen(),
+                serverIdent.getTimeZone())));
+    assertParseFails(
+        writeCommit(
+            "Update change\n\nPatch-set: 1\n",
+            new PersonIdent(
+                "Change Owner", "x@gerrit", serverIdent.getWhen(), serverIdent.getTimeZone())));
+    assertParseFails(
+        writeCommit(
+            "Update change\n\nPatch-set: 1\n",
+            new PersonIdent(
+                "Change\n\u1234<Owner>",
+                "\n\nx<@>\u0002gerrit",
+                serverIdent.getWhen(),
+                serverIdent.getTimeZone())));
   }
 
   @Test
   public void parseStatus() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Status: NEW\n"
-        + "Subject: This is a test change\n");
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Status: new\n"
-        + "Subject: This is a test change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Status: OOPS\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Status: NEW\n"
-        + "Status: NEW\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Status: NEW\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Status: new\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nStatus: OOPS\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nStatus: NEW\nStatus: NEW\n");
   }
 
   @Test
   public void parsePatchSetId() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Subject: This is a test change\n");
-    assertParseFails("Update change\n"
-        + "\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Patch-set: 1\n");
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Subject: This is a test change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: x\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nPatch-set: 1\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: x\n");
   }
 
   @Test
   public void parseApproval() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Label: Label1=+1\n"
-        + "Label: Label2=1\n"
-        + "Label: Label3=0\n"
-        + "Label: Label4=-1\n"
-        + "Subject: This is a test change\n");
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Label: -Label1\n"
-        + "Label: -Label1 Other Account <2@gerrit>\n"
-        + "Subject: This is a test change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Label: Label1=X\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Label: Label1 = 1\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Label: X+Y\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Label: Label1 Other Account <2@gerrit>\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Label: -Label!1\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Label: -Label!1 Other Account <2@gerrit>\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Label: Label1=+1\n"
+            + "Label: Label2=1\n"
+            + "Label: Label3=0\n"
+            + "Label: Label4=-1\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Label: -Label1\n"
+            + "Label: -Label1 Other Account <2@gerrit>\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=X\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1 = 1\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: X+Y\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1 Other Account <2@gerrit>\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: -Label!1\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: -Label!1 Other Account <2@gerrit>\n");
   }
 
   @Test
   public void parseSubmitRecords() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Subject: This is a test change\n"
-        + "Submitted-with: NOT_READY\n"
-        + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-        + "Submitted-with: NEED: Code-Review\n"
-        + "Submitted-with: NOT_READY\n"
-        + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-        + "Submitted-with: NEED: Alternative-Code-Review\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Submitted-with: OOPS\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Submitted-with: NEED: X+Y\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Submitted-with: OK: X+Y: Change Owner <1@gerrit>\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Submitted-with: OK: Code-Review: 1@gerrit\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Code-Review\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Alternative-Code-Review\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: OOPS\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: NEED: X+Y\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Submitted-with: OK: X+Y: Change Owner <1@gerrit>\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Submitted-with: OK: Code-Review: 1@gerrit\n");
   }
 
   @Test
   public void parseSubmissionId() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Subject: This is a test change\n"
-        + "Submission-id: 1-1453387607626-96fabc25");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Submission-id: 1-1453387607626-96fabc25\n"
-        + "Submission-id: 1-1453387901516-5d1e2450");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n"
+            + "Submission-id: 1-1453387607626-96fabc25");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Submission-id: 1-1453387607626-96fabc25\n"
+            + "Submission-id: 1-1453387901516-5d1e2450");
   }
 
   @Test
   public void parseReviewer() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Reviewer: Change Owner <1@gerrit>\n"
-        + "CC: Other Account <2@gerrit>\n"
-        + "Subject: This is a test change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Reviewer: 1@gerrit\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Reviewer: Change Owner <1@gerrit>\n"
+            + "CC: Other Account <2@gerrit>\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nReviewer: 1@gerrit\n");
   }
 
   @Test
   public void parseTopic() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Topic: Some Topic\n"
-        + "Subject: This is a test change\n");
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Topic:\n"
-        + "Subject: This is a test change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Topic: Some Topic\n"
-        + "Topic: Other Topic");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Topic: Some Topic\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Topic:\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nTopic: Some Topic\nTopic: Other Topic");
   }
 
   @Test
   public void parseBranch() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Subject: This is a test change\n");
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Subject: This is a test change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Branch: refs/heads/master\n"
-        + "Branch: refs/heads/stable");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Branch: refs/heads/stable");
   }
 
   @Test
   public void parseChangeId() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Patch-set: 1\n"
-        + "Subject: This is a test change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Change-id: I159532ef4844d7c18f7f3fd37a0b275590d41b1b");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Change-id: I159532ef4844d7c18f7f3fd37a0b275590d41b1b");
   }
 
   @Test
   public void parseSubject() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Some subject of a change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Subject: Some subject of a change\n"
-        + "Subject: Some other subject\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Subject: Some subject of a change\n"
+            + "Subject: Some other subject\n");
   }
 
   @Test
   public void parseCommit() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Patch-set: 2\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Some subject of a change\n"
-        + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 2\n"
-        + "Branch: refs/heads/master\n"
-        + "Subject: Some subject of a change\n"
-        + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-        + "Commit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertParseFails("Update patch set 1\n"
-        + "Uploaded patch set 1.\n"
-        + "Patch-set: 2\n"
-        + "Branch: refs/heads/master\n"
-        + "Subject: Some subject of a change\n"
-        + "Commit: beef");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n"
+            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Subject: Some subject of a change\n"
+            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+            + "Commit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    assertParseFails(
+        "Update patch set 1\n"
+            + "Uploaded patch set 1.\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Subject: Some subject of a change\n"
+            + "Commit: beef");
   }
 
   @Test
   public void parsePatchSetState() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Patch-set: 1 (PUBLISHED)\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Some subject of a change\n");
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Patch-set: 1 (DRAFT)\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Some subject of a change\n");
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Patch-set: 1 (DELETED)\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Some subject of a change\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1 (NOT A STATUS)\n"
-        + "Branch: refs/heads/master\n"
-        + "Subject: Some subject of a change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1 (PUBLISHED)\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1 (DRAFT)\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1 (DELETED)\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Some subject of a change\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1 (NOT A STATUS)\n"
+            + "Branch: refs/heads/master\n"
+            + "Subject: Some subject of a change\n");
   }
 
   @Test
   public void parsePatchSetGroups() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Patch-set: 2\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-        + "Subject: Change subject\n"
-        + "Groups: a,b,c\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 2\n"
-        + "Branch: refs/heads/master\n"
-        + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-        + "Subject: Change subject\n"
-        + "Groups: a,b,c\n"
-        + "Groups: d,e,f\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+            + "Subject: Change subject\n"
+            + "Groups: a,b,c\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 2\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+            + "Subject: Change subject\n"
+            + "Groups: a,b,c\n"
+            + "Groups: d,e,f\n");
   }
 
   @Test
   public void parseServerIdent() throws Exception {
-    String msg = "Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Change subject\n";
+    String msg =
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n";
     assertParseSucceeds(msg);
     assertParseSucceeds(writeCommit(msg, serverIdent));
 
-    msg = "Update change\n"
-        + "\n"
-        + "With a message."
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Change subject\n";
+    msg =
+        "Update change\n"
+            + "\n"
+            + "With a message."
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n";
     assertParseSucceeds(msg);
     assertParseSucceeds(writeCommit(msg, serverIdent));
 
-    msg = "Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Change subject\n"
-        + "Label: Label1=+1\n";
+    msg =
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n"
+            + "Label: Label1=+1\n";
     assertParseSucceeds(msg);
     assertParseFails(writeCommit(msg, serverIdent));
   }
 
   @Test
   public void parseTag() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Change subject\n"
-        + "Tag:\n");
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Change subject\n"
-        + "Tag: jenkins\n");
-    assertParseFails("Update change\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Branch: refs/heads/master\n"
-        + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "Subject: Change subject\n"
-        + "Tag: ci\n"
-        + "Tag: jenkins\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n"
+            + "Tag:\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n"
+            + "Tag: jenkins\n");
+    assertParseFails(
+        "Update change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Subject: Change subject\n"
+            + "Tag: ci\n"
+            + "Tag: jenkins\n");
   }
 
   @Test
   public void caseInsensitiveFooters() throws Exception {
-    assertParseSucceeds("Update change\n"
-        + "\n"
-        + "BRaNch: refs/heads/master\n"
-        + "Change-ID: I577fb248e474018276351785930358ec0450e9f7\n"
-        + "patcH-set: 1\n"
-        + "subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "BRaNch: refs/heads/master\n"
+            + "Change-ID: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "patcH-set: 1\n"
+            + "subject: This is a test change\n");
+  }
+
+  @Test
+  public void currentPatchSet() throws Exception {
+    assertParseSucceeds("Update change\n\nPatch-set: 1\nCurrent: true");
+    assertParseSucceeds("Update change\n\nPatch-set: 1\nCurrent: tRUe");
+    assertParseFails("Update change\n\nPatch-set: 1\nCurrent: false");
+    assertParseFails("Update change\n\nPatch-set: 1\nCurrent: blah");
   }
 
   private RevCommit writeCommit(String body) throws Exception {
-    return writeCommit(body, noteUtil.newIdent(
-        changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent,
-        "Anonymous Coward"));
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    return writeCommit(
+        body,
+        noteUtil.newIdent(
+            changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent, "Anonymous Coward"));
   }
 
-  private RevCommit writeCommit(String body, PersonIdent author)
-      throws Exception {
+  private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
     Change change = newChange();
     ChangeNotes notes = newNotes(change).load();
     try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
@@ -496,7 +504,7 @@
 
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
-    return new ChangeNotesParser(
-        newChange().getId(), tip, walk, noteUtil, args.metrics);
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    return new ChangeNotesParser(newChange().getId(), tip, walk, noteUtil, args.metrics);
   }
 }
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 0173b05..9d6cb60 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
@@ -15,46 +15,52 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static com.google.gerrit.testutil.TestChanges.incrementPatchSet;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.CommentRange;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.TestChanges;
+import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -66,15 +72,12 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-
 public class ChangeNotesTest extends AbstractChangeNotesTest {
-  @Inject
-  private DraftCommentNotes.Factory draftNotesFactory;
+  @Inject private DraftCommentNotes.Factory draftNotesFactory;
+
+  @Inject private ChangeNoteUtil noteUtil;
+
+  @Inject private @GerritServerId String serverId;
 
   @Test
   public void tagChangeMessage() throws Exception {
@@ -92,24 +95,54 @@
   }
 
   @Test
+  public void patchSetDescription() throws Exception {
+    String description = "descriptive";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPsDescription(description);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+
+    description = "new, now more descriptive!";
+    update = newUpdate(c, changeOwner);
+    update.setPsDescription(description);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getCurrentPatchSet().getDescription()).isEqualTo(description);
+  }
+
+  @Test
   public void tagInlineCommenrts() throws Exception {
     String tag = "jenkins";
     Change c = newChange();
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
-        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
-        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.putComment(
+        Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit.name(),
+            false));
     update.setTag(tag);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<RevId, PatchLineComment> comments = notes.getComments();
+    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
-    assertThat(
-        comments.entries().asList().get(0).getValue().getTag())
-            .isEqualTo(tag);
+    assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
   }
 
   @Test
@@ -129,13 +162,9 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals =
-        notes.getApprovals();
-    assertThat(approvals).hasSize(2);
-    assertThat(approvals.entries().asList().get(0).getValue().getTag())
-        .isEqualTo(tag1);
-    assertThat(approvals.entries().asList().get(1).getValue().getTag())
-        .isEqualTo(tag2);
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    assertThat(approvals).hasSize(1);
+    assertThat(approvals.entries().asList().get(0).getValue().getTag()).isEqualTo(tag2);
   }
 
   @Test
@@ -153,9 +182,21 @@
 
     RevCommit commit = tr.commit().message("PS2").create();
     update = newUpdate(c, changeOwner);
-    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
-        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
-        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.putComment(
+        Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit.name(),
+            false));
     update.setChangeMessage("coverage verification");
     update.setTag(coverageTag);
     update.commit();
@@ -167,18 +208,15 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals =
-        notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
     assertThat(approvals).hasSize(1);
     PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
     assertThat(approval.getTag()).isEqualTo(integrationTag);
     assertThat(approval.getValue()).isEqualTo(-1);
 
-    ImmutableListMultimap<RevId, PatchLineComment> comments =
-        notes.getComments();
+    ImmutableListMultimap<RevId, Comment> comments = notes.getComments();
     assertThat(comments).hasSize(1);
-    assertThat(comments.entries().asList().get(0).getValue().getTag())
-        .isEqualTo(coverageTag);
+    assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
 
     ImmutableList<ChangeMessage> messages = notes.getChangeMessages();
     assertThat(messages).hasSize(3);
@@ -196,10 +234,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet())
-        .containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas =
-      notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
@@ -245,7 +281,7 @@
     assertThat(psa2.getAccountId().get()).isEqualTo(1);
     assertThat(psa2.getLabel()).isEqualTo("Code-Review");
     assertThat(psa2.getValue()).isEqualTo((short) +1);
-    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 3000)));
+    assertThat(psa2.getGranted()).isEqualTo(truncate(after(c, 4000)));
   }
 
   @Test
@@ -256,8 +292,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    PatchSetApproval psa = Iterables.getOnlyElement(
-        notes.getApprovals().get(c.currentPatchSetId()));
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.getLabel()).isEqualTo("Code-Review");
     assertThat(psa.getValue()).isEqualTo((short) -1);
 
@@ -266,8 +302,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(
-        notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.getLabel()).isEqualTo("Code-Review");
     assertThat(psa.getValue()).isEqualTo((short) 1);
   }
@@ -284,10 +319,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet())
-        .containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas =
-      notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).getPatchSetId()).isEqualTo(c.currentPatchSetId());
@@ -311,8 +344,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    PatchSetApproval psa = Iterables.getOnlyElement(
-        notes.getApprovals().get(c.currentPatchSetId()));
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.getAccountId().get()).isEqualTo(1);
     assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
     assertThat(psa.getValue()).isEqualTo((short) 1);
@@ -322,7 +355,11 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getApprovals())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                psa.getPatchSetId(),
+                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
   }
 
   @Test
@@ -333,8 +370,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    PatchSetApproval psa = Iterables.getOnlyElement(
-        notes.getApprovals().get(c.currentPatchSetId()));
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.getAccountId()).isEqualTo(otherUserId);
     assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
     assertThat(psa.getValue()).isEqualTo((short) 1);
@@ -344,7 +381,11 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getApprovals())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                psa.getPatchSetId(),
+                new PatchSetApproval(psa.getKey(), (short) 0, update.getWhen())));
 
     // Add back approval on same label.
     update = newUpdate(c, otherUser);
@@ -352,8 +393,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(
-        notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.getAccountId()).isEqualTo(otherUserId);
     assertThat(psa.getLabel()).isEqualTo("Not-For-Long");
     assertThat(psa.getValue()).isEqualTo((short) 2);
@@ -368,27 +408,103 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Ordering.natural().onResultOf(
-        new Function<PatchSetApproval, Integer>() {
-          @Override
-          public Integer apply(PatchSetApproval in) {
-            return in.getAccountId().get();
-          }
-        }).sortedCopy(notes.getApprovals().get(c.currentPatchSetId()));
+    List<PatchSetApproval> approvals =
+        ReviewDbUtil.intKeyOrdering()
+            .onResultOf(PatchSetApproval::getAccountId)
+            .sortedCopy(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(approvals).hasSize(2);
 
-    assertThat(approvals.get(0).getAccountId())
-        .isEqualTo(changeOwner.getAccountId());
+    assertThat(approvals.get(0).getAccountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approvals.get(0).getLabel()).isEqualTo("Code-Review");
     assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
 
-    assertThat(approvals.get(1).getAccountId())
-        .isEqualTo(otherUser.getAccountId());
+    assertThat(approvals.get(1).getAccountId()).isEqualTo(otherUser.getAccountId());
     assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
     assertThat(approvals.get(1).getValue()).isEqualTo((short) -1);
   }
 
   @Test
+  public void approvalsPostSubmit() throws Exception {
+    Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.putApproval("Verified", (short) 1);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Code-Review", "NEED", null))));
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    assertThat(approvals).hasSize(2);
+    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
+    assertThat(approvals.get(0).getValue()).isEqualTo((short) 1);
+    assertThat(approvals.get(0).isPostSubmit()).isFalse();
+    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).getValue()).isEqualTo((short) 2);
+    assertThat(approvals.get(1).isPostSubmit()).isTrue();
+  }
+
+  @Test
+  public void approvalsDuringSubmit() throws Exception {
+    Change c = newChange();
+    RequestId submissionId = RequestId.forChange(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.putApproval("Verified", (short) 1);
+    update.commit();
+
+    Account.Id ownerId = changeOwner.getAccountId();
+    Account.Id otherId = otherUser.getAccountId();
+    update = newUpdate(c, otherUser);
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", ownerId),
+                submitLabel("Code-Review", "NEED", null))));
+    update.putApproval("Other-Label", (short) 1);
+    update.putApprovalFor(ownerId, "Code-Review", (short) 2);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    update.putApproval("Other-Label", (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    assertThat(approvals).hasSize(3);
+    assertThat(approvals.get(0).getAccountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(0).getLabel()).isEqualTo("Verified");
+    assertThat(approvals.get(0).getValue()).isEqualTo(1);
+    assertThat(approvals.get(0).isPostSubmit()).isFalse();
+    assertThat(approvals.get(1).getAccountId()).isEqualTo(ownerId);
+    assertThat(approvals.get(1).getLabel()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).getValue()).isEqualTo(2);
+    assertThat(approvals.get(1).isPostSubmit()).isFalse(); // During submit.
+    assertThat(approvals.get(2).getAccountId()).isEqualTo(otherId);
+    assertThat(approvals.get(2).getLabel()).isEqualTo("Other-Label");
+    assertThat(approvals.get(2).getValue()).isEqualTo(2);
+    assertThat(approvals.get(2).isPostSubmit()).isTrue();
+  }
+
+  @Test
   public void multipleReviewers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -398,11 +514,13 @@
 
     ChangeNotes notes = newNotes(c);
     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()));
+    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
@@ -415,11 +533,13 @@
 
     ChangeNotes notes = newNotes(c);
     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()));
+    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
@@ -431,8 +551,8 @@
 
     ChangeNotes notes = newNotes(c);
     Timestamp ts = new Timestamp(update.getWhen().getTime());
-    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
-        ImmutableTable.of(REVIEWER, new Account.Id(2), ts)));
+    assertThat(notes.getReviewers())
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, new Account.Id(2), ts)));
 
     update = newUpdate(c, otherUser);
     update.putReviewer(otherUser.getAccount().getId(), CC);
@@ -440,8 +560,8 @@
 
     notes = newNotes(c);
     ts = new Timestamp(update.getWhen().getTime());
-    assertThat(notes.getReviewers()).isEqualTo(ReviewerSet.fromTable(
-        ImmutableTable.of(CC, new Account.Id(2), ts)));
+    assertThat(notes.getReviewers())
+        .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, new Account.Id(2), ts)));
   }
 
   @Test
@@ -460,13 +580,10 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas =
-        notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
-    assertThat(psas.get(0).getAccountId())
-        .isEqualTo(changeOwner.getAccount().getId());
-    assertThat(psas.get(1).getAccountId())
-        .isEqualTo(otherUser.getAccount().getId());
+    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
 
     update = newUpdate(c, changeOwner);
     update.removeReviewer(otherUser.getAccount().getId());
@@ -475,8 +592,7 @@
     notes = newNotes(c);
     psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(1);
-    assertThat(psas.get(0).getAccountId())
-        .isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
   }
 
   @Test
@@ -486,28 +602,39 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    update.merge(submissionId, ImmutableList.of(
-        submitRecord("NOT_READY", null,
-          submitLabel("Verified", "OK", changeOwner.getAccountId()),
-          submitLabel("Code-Review", "NEED", null)),
-        submitRecord("NOT_READY", null,
-          submitLabel("Verified", "OK", changeOwner.getAccountId()),
-          submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Code-Review", "NEED", null)),
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     List<SubmitRecord> recs = notes.getSubmitRecords();
     assertThat(recs).hasSize(2);
-    assertThat(recs.get(0)).isEqualTo(
-        submitRecord("NOT_READY", null,
-          submitLabel("Verified", "OK", changeOwner.getAccountId()),
-          submitLabel("Code-Review", "NEED", null)));
-    assertThat(recs.get(1)).isEqualTo(
-        submitRecord("NOT_READY", null,
-          submitLabel("Verified", "OK", changeOwner.getAccountId()),
-          submitLabel("Alternative-Code-Review", "NEED", null)));
-    assertThat(notes.getChange().getSubmissionId())
-        .isEqualTo(submissionId.toStringForStorage());
+    assertThat(recs.get(0))
+        .isEqualTo(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Code-Review", "NEED", null)));
+    assertThat(recs.get(1))
+        .isEqualTo(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Alternative-Code-Review", "NEED", null)));
+    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toStringForStorage());
   }
 
   @Test
@@ -516,25 +643,27 @@
     RequestId submissionId = RequestId.forChange(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
-    update.merge(submissionId, ImmutableList.of(
-        submitRecord("OK", null,
-          submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord("OK", null, submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
     update.commit();
 
     incrementPatchSet(c);
     update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 2");
-    update.merge(submissionId, ImmutableList.of(
-        submitRecord("OK", null,
-          submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getSubmitRecords()).containsExactly(
-        submitRecord("OK", null,
-          submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
-    assertThat(notes.getChange().getSubmissionId())
-        .isEqualTo(submissionId.toStringForStorage());
+    assertThat(notes.getSubmitRecords())
+        .containsExactly(
+            submitRecord("OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
+    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toStringForStorage());
   }
 
   @Test
@@ -553,6 +682,64 @@
   }
 
   @Test
+  public void assigneeCommit() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    ObjectId result = update.commit();
+    assertThat(result).isNotNull();
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(update.getResult());
+      rw.parseBody(commit);
+      String strIdent = otherUser.getName() + " <" + otherUserId + "@" + serverId + ">";
+      assertThat(commit.getFullMessage()).contains("Assignee: " + strIdent);
+    }
+  }
+
+  @Test
+  public void assigneeChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getAssignee()).isEqualTo(otherUserId);
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(changeOwner.getAccountId());
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getChange().getAssignee()).isEqualTo(changeOwner.getAccountId());
+  }
+
+  @Test
+  public void pastAssigneesChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(changeOwner.getAccountId());
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setAssignee(otherUserId);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeAssignee();
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getPastAssignees()).hasSize(2);
+  }
+
+  @Test
   public void hashtagCommit() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -638,8 +825,11 @@
     String otherChangeId = "I577fb248e474018276351785930358ec0450e9f7";
     update = newUpdate(c, changeOwner);
     exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("The Change-Id was already set to " + c.getKey()
-        + ", so we cannot set this Change-Id: " + otherChangeId);
+    exception.expectMessage(
+        "The Change-Id was already set to "
+            + c.getKey()
+            + ", so we cannot set this Change-Id: "
+            + otherChangeId);
     update.setChangeId(otherChangeId);
   }
 
@@ -648,8 +838,7 @@
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
-    Branch.NameKey expectedBranch =
-        new Branch.NameKey(project, "refs/heads/master");
+    Branch.NameKey expectedBranch = new Branch.NameKey(project, "refs/heads/master");
     assertThat(notes.getChange().getDest()).isEqualTo(expectedBranch);
 
     // An update doesn't affect the branch
@@ -663,23 +852,21 @@
     update = newUpdate(c, changeOwner);
     update.setBranch(otherBranch);
     update.commit();
-    assertThat(newNotes(c).getChange().getDest()).isEqualTo(
-        new Branch.NameKey(project, otherBranch));
+    assertThat(newNotes(c).getChange().getDest())
+        .isEqualTo(new Branch.NameKey(project, otherBranch));
   }
 
   @Test
   public void ownerChangeNotes() throws Exception {
     Change c = newChange();
 
-    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(
-        changeOwner.getAccountId());
+    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(changeOwner.getAccountId());
 
     // An update doesn't affect the owner
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setTopic("topic"); // Change something to get a new commit.
     update.commit();
-    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(
-        changeOwner.getAccountId());
+    assertThat(newNotes(c).getChange().getOwner()).isEqualTo(changeOwner.getAccountId());
   }
 
   @Test
@@ -724,10 +911,6 @@
     assertThat(ts4).isGreaterThan(ts3);
 
     incrementPatchSet(c);
-    RevCommit commit = tr.commit().message("PS2").create();
-    update = newUpdate(c, changeOwner);
-    update.setCommit(rw, commit);
-    update.commit();
     Timestamp ts5 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts5).isGreaterThan(ts4);
 
@@ -757,10 +940,14 @@
 
     // Finish off by merging the change.
     update = newUpdate(c, changeOwner);
-    update.merge(RequestId.forChange(c), ImmutableList.of(
-        submitRecord("NOT_READY", null,
-          submitLabel("Verified", "OK", changeOwner.getAccountId()),
-          submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.merge(
+        RequestId.forChange(c),
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
     Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts10).isGreaterThan(ts9);
@@ -770,8 +957,7 @@
   public void subjectLeadingWhitespaceChangeNotes() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
     String trimmedSubj = c.getSubject();
-    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + trimmedSubj,
-        c.getOriginalSubject());
+    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + trimmedSubj, c.getOriginalSubject());
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setChangeId(c.getKey().get());
     update.setBranch(c.getDest().get());
@@ -783,8 +969,7 @@
     String tabSubj = "\t\t" + trimmedSubj;
 
     c = TestChanges.newChange(project, changeOwner.getAccountId());
-    c.setCurrentPatchSet(c.currentPatchSetId(), tabSubj,
-        c.getOriginalSubject());
+    c.setCurrentPatchSet(c.currentPatchSetId(), tabSubj, c.getOriginalSubject());
     update = newUpdate(c, changeOwner);
     update.setChangeId(c.getKey().get());
     update.setBranch(c.getDest().get());
@@ -813,9 +998,14 @@
       notes = newNotes(c);
       fail("Expected IOException");
     } catch (OrmException e) {
-      assertCause(e, ConfigInvalidException.class,
+      assertCause(
+          e,
+          ConfigInvalidException.class,
           "Multiple revisions parsed for patch set 1:"
-              + " RevId{" + commit.name() + "} and " + ps.getRevision().get());
+              + " RevId{"
+              + commit.name()
+              + "} and "
+              + ps.getRevision().get());
     }
   }
 
@@ -828,31 +1018,25 @@
     PatchSet ps1 = notes.getCurrentPatchSet();
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps1.getId());
     assertThat(notes.getChange().getSubject()).isEqualTo("Change subject");
-    assertThat(notes.getChange().getOriginalSubject())
-        .isEqualTo("Change subject");
+    assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
     assertThat(ps1.getId()).isEqualTo(new PatchSet.Id(c.getId(), 1));
     assertThat(ps1.getUploader()).isEqualTo(changeOwner.getAccountId());
 
     // ps2 by other user
-    incrementPatchSet(c);
-    RevCommit commit = tr.commit().message("PS2").create();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    update.setCommit(rw, commit);
-    update.commit();
+    RevCommit commit = incrementPatchSet(c, otherUser);
     notes = newNotes(c);
     PatchSet ps2 = notes.getCurrentPatchSet();
     assertThat(ps2.getId()).isEqualTo(new PatchSet.Id(c.getId(), 2));
     assertThat(notes.getChange().getSubject()).isEqualTo("PS2");
-    assertThat(notes.getChange().getOriginalSubject())
-        .isEqualTo("Change subject");
+    assertThat(notes.getChange().getOriginalSubject()).isEqualTo("Change subject");
     assertThat(notes.getChange().currentPatchSetId()).isEqualTo(ps2.getId());
     assertThat(ps2.getRevision().get()).isNotEqualTo(ps1.getRevision());
     assertThat(ps2.getRevision().get()).isEqualTo(commit.name());
     assertThat(ps2.getUploader()).isEqualTo(otherUser.getAccountId());
-    assertThat(ps2.getCreatedOn()).isEqualTo(update.getWhen());
+    assertThat(ps2.getCreatedOn()).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     // comment on ps1, current patch set is still ps2
-    update = newUpdate(c, changeOwner);
+    ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(ps1.getId());
     update.setChangeMessage("Comment on old patch set.");
     update.commit();
@@ -865,8 +1049,7 @@
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
 
-    // ps2
-    incrementPatchSet(c);
+    incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -874,9 +1057,21 @@
     update.setPatchSetState(PatchSetState.DRAFT);
     update.putApproval("Code-Review", (short) 1);
     update.setChangeMessage("This is a message");
-    update.putComment(newPublishedComment(c.currentPatchSetId(), "a.txt",
-        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null,
-        TimeUtil.nowTs(), "Comment", (short) 1, commit.name()));
+    update.putComment(
+        Status.PUBLISHED,
+        newComment(
+            c.currentPatchSetId(),
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            TimeUtil.nowTs(),
+            "Comment",
+            (short) 1,
+            commit.name(),
+            false));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -921,37 +1116,34 @@
     update.setGroups(ImmutableList.of("a", "b"));
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId1).getGroups())
-      .containsExactly("a", "b").inOrder();
+    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
 
-    // ps2
-    incrementPatchSet(c);
+    incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
     update = newUpdate(c, changeOwner);
     update.setCommit(rw, tr.commit().message("PS2").create());
     update.setGroups(ImmutableList.of("d"));
     update.commit();
     notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId2).getGroups())
-      .containsExactly("d");
-    assertThat(notes.getPatchSets().get(psId1).getGroups())
-      .containsExactly("a", "b").inOrder();
+    assertThat(notes.getPatchSets().get(psId2).getGroups()).containsExactly("d");
+    assertThat(notes.getPatchSets().get(psId1).getGroups()).containsExactly("a", "b").inOrder();
   }
 
   @Test
   public void pushCertificate() throws Exception {
-    String pushCert = "certificate version 0.1\n"
-      + "pusher This is not a real push cert\n"
-      + "-----BEGIN PGP SIGNATURE-----\n"
-      + "Version: GnuPG v1\n"
-      + "\n"
-      + "Nor is this a real signature.\n"
-      + "-----END PGP SIGNATURE-----\n";
+    String pushCert =
+        "certificate version 0.1\n"
+            + "pusher This is not a real push cert\n"
+            + "-----BEGIN PGP SIGNATURE-----\n"
+            + "Version: GnuPG v1\n"
+            + "\n"
+            + "Nor is this a real signature.\n"
+            + "-----END PGP SIGNATURE-----\n";
 
     // ps2 with push cert
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
-    incrementPatchSet(c);
+    incrementCurrentPatchSetFieldOnly(c);
     PatchSet.Id psId2 = c.currentPatchSetId();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
@@ -960,7 +1152,10 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(readNote(notes, commit)).isEqualTo(pushCert);
+    String note = readNote(notes, commit);
+    if (!testJson()) {
+      assertThat(note).isEqualTo(pushCert);
+    }
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
     assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
@@ -970,29 +1165,50 @@
     update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
     Timestamp ts = TimeUtil.nowTs();
-    update.putComment(newPublishedComment(psId2, "a.txt",
-        "uuid1", new CommentRange(1, 2, 3, 4), 1, changeOwner, null, ts,
-        "Comment", (short) 1, commit.name()));
+    update.putComment(
+        Status.PUBLISHED,
+        newComment(
+            psId2,
+            "a.txt",
+            "uuid1",
+            new CommentRange(1, 2, 3, 4),
+            1,
+            changeOwner,
+            null,
+            ts,
+            "Comment",
+            (short) 1,
+            commit.name(),
+            false));
     update.commit();
 
     notes = newNotes(c);
-    assertThat(readNote(notes, commit)).isEqualTo(
-        pushCert
-        + "Revision: " + commit.name() + "\n"
-        + "Patch-set: 2\n"
-        + "File: a.txt\n"
-        + "\n"
-        + "1:2-3:4\n"
-        + ChangeNoteUtil.formatTime(serverIdent, ts) + "\n"
-        + "Author: Change Owner <1@gerrit>\n"
-        + "UUID: uuid1\n"
-        + "Bytes: 7\n"
-        + "Comment\n"
-        + "\n");
+
     patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
     assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
     assertThat(notes.getComments()).isNotEmpty();
+
+    if (!testJson()) {
+      assertThat(readNote(notes, commit))
+          .isEqualTo(
+              pushCert
+                  + "Revision: "
+                  + commit.name()
+                  + "\n"
+                  + "Patch-set: 2\n"
+                  + "File: a.txt\n"
+                  + "\n"
+                  + "1:2-3:4\n"
+                  + ChangeNoteUtil.formatTime(serverIdent, ts)
+                  + "\n"
+                  + "Author: Change Owner <1@gerrit>\n"
+                  + "Unresolved: false\n"
+                  + "UUID: uuid1\n"
+                  + "Bytes: 7\n"
+                  + "Comment\n"
+                  + "\n");
+    }
   }
 
   @Test
@@ -1011,25 +1227,21 @@
     ChangeUpdate update2 = newUpdate(c, otherUser);
     update2.putApproval("Code-Review", (short) 2);
 
-    try (NoteDbUpdateManager updateManager =
-        updateManagerFactory.create(project)) {
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
       updateManager.add(update1);
       updateManager.add(update2);
       updateManager.execute();
     }
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas =
-        notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
-    assertThat(psas.get(0).getAccountId())
-        .isEqualTo(changeOwner.getAccount().getId());
+    assertThat(psas.get(0).getAccountId()).isEqualTo(changeOwner.getAccount().getId());
     assertThat(psas.get(0).getLabel()).isEqualTo("Verified");
     assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
 
-    assertThat(psas.get(1).getAccountId())
-        .isEqualTo(otherUser.getAccount().getId());
+    assertThat(psas.get(1).getAccountId()).isEqualTo(otherUser.getAccount().getId());
     assertThat(psas.get(1).getLabel()).isEqualTo("Code-Review");
     assertThat(psas.get(1).getValue()).isEqualTo((short) 2);
   }
@@ -1044,13 +1256,23 @@
     Timestamp time1 = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
     RevCommit tipCommit;
-    try (NoteDbUpdateManager updateManager =
-        updateManagerFactory.create(project)) {
-      PatchLineComment comment1 = newPublishedComment(psId, "file1",
-          uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
-          (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
+      Comment comment1 =
+          newComment(
+              psId,
+              "file1",
+              uuid1,
+              range1,
+              range1.getEndLine(),
+              otherUser,
+              null,
+              time1,
+              message1,
+              (short) 0,
+              "abcd1234abcd1234abcd1234abcd1234abcd1234",
+              false);
       update1.setPatchSetId(psId);
-      update1.putComment(comment1);
+      update1.putComment(Status.PUBLISHED, comment1);
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
@@ -1070,16 +1292,16 @@
     assertThat(commitWithComments).isNotNull();
 
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
-      ChangeNotesParser notesWithComments = new ChangeNotesParser(
-          c.getId(), commitWithComments.copy(), rw, noteUtil, args.metrics);
+      ChangeNotesParser notesWithComments =
+          new ChangeNotesParser(c.getId(), commitWithComments.copy(), rw, noteUtil, args.metrics);
       ChangeNotesState state = notesWithComments.parseAll();
       assertThat(state.approvals()).isEmpty();
       assertThat(state.publishedComments()).hasSize(1);
     }
 
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
-      ChangeNotesParser notesWithApprovals = new ChangeNotesParser(c.getId(),
-          commitWithApprovals.copy(), rw, noteUtil, args.metrics);
+      ChangeNotesParser notesWithApprovals =
+          new ChangeNotesParser(c.getId(), commitWithApprovals.copy(), rw, noteUtil, args.metrics);
       ChangeNotesState state = notesWithApprovals.parseAll();
       assertThat(state.approvals()).hasSize(1);
       assertThat(state.publishedComments()).hasSize(1);
@@ -1101,8 +1323,7 @@
     Ref initial2 = repo.exactRef(update2.getRefName());
     assertThat(initial2).isNotNull();
 
-    try (NoteDbUpdateManager updateManager =
-        updateManagerFactory.create(project)) {
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
       updateManager.add(update1);
       updateManager.add(update2);
       updateManager.execute();
@@ -1115,12 +1336,12 @@
     assertThat(ref2.getObjectId()).isEqualTo(update2.getResult());
     assertThat(ref2.getObjectId()).isNotEqualTo(initial2.getObjectId());
 
-    PatchSetApproval approval1 = newNotes(c1).getApprovals()
-        .get(c1.currentPatchSetId()).iterator().next();
+    PatchSetApproval approval1 =
+        newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
     assertThat(approval1.getLabel()).isEqualTo("Verified");
 
-    PatchSetApproval approval2 = newNotes(c2).getApprovals()
-        .get(c2.currentPatchSetId()).iterator().next();
+    PatchSetApproval approval2 =
+        newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
     assertThat(approval2.getLabel()).isEqualTo("Code-Review");
   }
 
@@ -1134,8 +1355,7 @@
     PatchSet.Id ps1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessagesByPatchSet();
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages.keySet()).containsExactly(ps1);
 
     ChangeMessage cm = Iterables.getOnlyElement(changeMessages.get(ps1));
@@ -1159,18 +1379,16 @@
   public void changeMessageWithTrailingDoubleNewline() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Testing trailing double newline\n"
-        + "\n");
+    update.setChangeMessage("Testing trailing double newline\n\n");
     update.commit();
     PatchSet.Id ps1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessagesByPatchSet();
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(1);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
-    assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n" + "\n");
+    assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
   }
 
@@ -1178,25 +1396,22 @@
   public void changeMessageWithMultipleParagraphs() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Testing paragraph 1\n"
-        + "\n"
-        + "Testing paragraph 2\n"
-        + "\n"
-        + "Testing paragraph 3");
+    update.setChangeMessage("Testing paragraph 1\n\nTesting paragraph 2\n\nTesting paragraph 3");
     update.commit();
     PatchSet.Id ps1 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessagesByPatchSet();
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(1);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
-    assertThat(cm1.getMessage()).isEqualTo("Testing paragraph 1\n"
-        + "\n"
-        + "Testing paragraph 2\n"
-        + "\n"
-        + "Testing paragraph 3");
+    assertThat(cm1.getMessage())
+        .isEqualTo(
+            "Testing paragraph 1\n"
+                + "\n"
+                + "Testing paragraph 2\n"
+                + "\n"
+                + "Testing paragraph 3");
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
   }
 
@@ -1217,19 +1432,16 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessagesByPatchSet();
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages).hasSize(2);
 
     ChangeMessage cm1 = Iterables.getOnlyElement(changeMessages.get(ps1));
-    assertThat(cm1.getMessage())
-        .isEqualTo("This is the change message for the first PS.");
+    assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
 
     ChangeMessage cm2 = Iterables.getOnlyElement(changeMessages.get(ps2));
     assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
-    assertThat(cm2.getMessage())
-        .isEqualTo("This is the change message for the second PS.");
+    assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
     assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().getId());
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
   }
@@ -1250,19 +1462,16 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages =
-        notes.getChangeMessagesByPatchSet();
+    ListMultimap<PatchSet.Id, ChangeMessage> changeMessages = notes.getChangeMessagesByPatchSet();
     assertThat(changeMessages.keySet()).hasSize(1);
 
     List<ChangeMessage> cm = changeMessages.get(ps1);
     assertThat(cm).hasSize(2);
     assertThat(cm.get(0).getMessage()).isEqualTo("First change message.\n");
-    assertThat(cm.get(0).getAuthor())
-        .isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
     assertThat(cm.get(0).getPatchSetId()).isEqualTo(ps1);
     assertThat(cm.get(1).getMessage()).isEqualTo("Second change message.\n");
-    assertThat(cm.get(1).getAuthor())
-        .isEqualTo(changeOwner.getAccount().getId());
+    assertThat(cm.get(1).getAuthor()).isEqualTo(changeOwner.getAccount().getId());
     assertThat(cm.get(1).getPatchSetId()).isEqualTo(ps1);
   }
 
@@ -1273,16 +1482,26 @@
     PatchSet.Id psId = c.currentPatchSetId();
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    PatchLineComment comment = newPublishedComment(psId, "file1",
-        "uuid", null, 0, otherUser, null,
-        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    Comment comment =
+        newComment(
+            psId,
+            "file1",
+            "uuid",
+            null,
+            0,
+            otherUser,
+            null,
+            TimeUtil.nowTs(),
+            "message",
+            (short) 1,
+            revId.get(),
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments())
-        .isEqualTo(ImmutableMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
   }
 
   @Test
@@ -1293,16 +1512,26 @@
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 0, 2, 0);
 
-    PatchLineComment comment = newPublishedComment(psId, "file1",
-        "uuid", range, range.getEndLine(), otherUser, null,
-        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    Comment comment =
+        newComment(
+            psId,
+            "file1",
+            "uuid",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            TimeUtil.nowTs(),
+            "message",
+            (short) 1,
+            revId.get(),
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments())
-        .isEqualTo(ImmutableMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
   }
 
   @Test
@@ -1313,16 +1542,26 @@
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(0, 0, 0, 0);
 
-    PatchLineComment comment = newPublishedComment(psId, "file",
-        "uuid", range, range.getEndLine(), otherUser, null,
-        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    Comment comment =
+        newComment(
+            psId,
+            "file",
+            "uuid",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            TimeUtil.nowTs(),
+            "message",
+            (short) 1,
+            revId.get(),
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments())
-        .isEqualTo(ImmutableMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
   }
 
   @Test
@@ -1333,16 +1572,26 @@
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 2, 3, 4);
 
-    PatchLineComment comment = newPublishedComment(psId, "",
-        "uuid", range, range.getEndLine(), otherUser, null,
-        TimeUtil.nowTs(), "message", (short) 1, revId.get());
+    Comment comment =
+        newComment(
+            psId,
+            "",
+            "uuid",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            TimeUtil.nowTs(),
+            "message",
+            (short) 1,
+            revId.get(),
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments())
-        .isEqualTo(ImmutableMultimap.of(revId, comment));
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
   }
 
   @Test
@@ -1361,70 +1610,110 @@
     Timestamp time3 = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment1 = newPublishedComment(psId, "file1",
-        uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
-        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment1 =
+        newComment(
+            psId,
+            "file1",
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time1,
+            message1,
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    PatchLineComment comment2 = newPublishedComment(psId, "file1",
-        uuid2, range2, range2.getEndLine(), otherUser, null, time2, message2,
-        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment2 =
+        newComment(
+            psId,
+            "file1",
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            time2,
+            message2,
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     update = newUpdate(c, otherUser);
     CommentRange range3 = new CommentRange(3, 0, 4, 1);
-    PatchLineComment comment3 = newPublishedComment(psId, "file2",
-        uuid3, range3, range3.getEndLine(), otherUser, null, time3, message3,
-        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment3 =
+        newComment(
+            psId,
+            "file2",
+            uuid3,
+            range3,
+            range3.getEndLine(),
+            otherUser,
+            null,
+            time3,
+            message3,
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment3);
+    update.putComment(Status.PUBLISHED, comment3);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
     try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree =
-          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
       Note note = Iterables.getOnlyElement(notesInTree);
 
-      byte[] bytes =
-          walk.getObjectReader().open(
-              note.getData(), Constants.OBJ_BLOB).getBytes();
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
-      assertThat(noteString).isEqualTo(
-          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-          + "Patch-set: 1\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time1) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid1\n"
-          + "Bytes: 9\n"
-          + "comment 1\n"
-          + "\n"
-          + "2:1-3:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time2) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid2\n"
-          + "Bytes: 9\n"
-          + "comment 2\n"
-          + "\n"
-          + "File: file2\n"
-          + "\n"
-          + "3:0-4:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time3) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid3\n"
-          + "Bytes: 9\n"
-          + "comment 3\n"
-          + "\n");
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time1)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid1\n"
+                    + "Bytes: 9\n"
+                    + "comment 1\n"
+                    + "\n"
+                    + "2:1-3:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time2)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid2\n"
+                    + "Bytes: 9\n"
+                    + "comment 2\n"
+                    + "\n"
+                    + "File: file2\n"
+                    + "\n"
+                    + "3:0-4:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time3)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid3\n"
+                    + "Bytes: 9\n"
+                    + "comment 3\n"
+                    + "\n");
+      }
     }
   }
 
@@ -1441,59 +1730,177 @@
     Timestamp time2 = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment1 = newPublishedComment(psId, "file1",
-        uuid1, range1, range1.getEndLine(), otherUser, null, time1, message1,
-        (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment1 =
+        newComment(
+            psId,
+            "file1",
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time1,
+            message1,
+            (short) 0,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    PatchLineComment comment2 = newPublishedComment(psId, "file1",
-        uuid2, range2, range2.getEndLine(), otherUser, null, time2, message2,
-        (short) 0, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment2 =
+        newComment(
+            psId,
+            "file1",
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            time2,
+            message2,
+            (short) 0,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
     try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree =
-          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
       Note note = Iterables.getOnlyElement(notesInTree);
 
-      byte[] bytes =
-          walk.getObjectReader().open(
-              note.getData(), Constants.OBJ_BLOB).getBytes();
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
-      assertThat(noteString).isEqualTo(
-          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-          + "Base-for-patch-set: 1\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time1) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid1\n"
-          + "Bytes: 9\n"
-          + "comment 1\n"
-          + "\n"
-          + "2:1-3:1\n"
-          + ChangeNoteUtil.formatTime(serverIdent, time2) + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid2\n"
-          + "Bytes: 9\n"
-          + "comment 2\n"
-          + "\n");
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Base-for-patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time1)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid1\n"
+                    + "Bytes: 9\n"
+                    + "comment 1\n"
+                    + "\n"
+                    + "2:1-3:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time2)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid2\n"
+                    + "Bytes: 9\n"
+                    + "comment 2\n"
+                    + "\n");
+      }
     }
   }
 
   @Test
-  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId()
-      throws Exception {
+  public void patchLineCommentNotesResolvedChangesValue() throws Exception {
     Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    String uuid1 = "uuid1";
+    String uuid2 = "uuid2";
+    String message1 = "comment 1";
+    String message2 = "comment 2";
+    CommentRange range1 = new CommentRange(1, 1, 2, 1);
+    Timestamp time1 = TimeUtil.nowTs();
+    Timestamp time2 = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+
+    Comment comment1 =
+        newComment(
+            psId,
+            "file1",
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time1,
+            message1,
+            (short) 0,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.commit();
+
+    update = newUpdate(c, otherUser);
+    Comment comment2 =
+        newComment(
+            psId,
+            "file1",
+            uuid2,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            uuid1,
+            time2,
+            message2,
+            (short) 0,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            true);
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Base-for-patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time1)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid1\n"
+                    + "Bytes: 9\n"
+                    + "comment 1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time2)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Parent: uuid1\n"
+                    + "Unresolved: true\n"
+                    + "UUID: uuid2\n"
+                    + "Bytes: 9\n"
+                    + "comment 2\n"
+                    + "\n");
+      }
+    }
+  }
+
+  @Test
+  public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId() throws Exception {
+    Change c = newChange();
+    PatchSet.Id psId1 = c.currentPatchSetId();
+    incrementPatchSet(c);
+    PatchSet.Id psId2 = c.currentPatchSetId();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
     String uuid3 = "uuid3";
@@ -1505,74 +1912,173 @@
     Timestamp time = TimeUtil.nowTs();
     RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    PatchSet.Id psId1 = c.currentPatchSetId();
-    PatchSet.Id psId2 = new PatchSet.Id(c.getId(), psId1.get() + 1);
-
-    PatchLineComment comment1 = newPublishedComment(psId1, "file1",
-        uuid1, range1, range1.getEndLine(), otherUser, null, time, message1,
-        (short) 0, revId.get());
-    PatchLineComment comment2 = newPublishedComment(psId1, "file1",
-        uuid2, range2, range2.getEndLine(), otherUser, null, time, message2,
-        (short) 0, revId.get());
-    PatchLineComment comment3 = newPublishedComment(psId2, "file1",
-        uuid3, range1, range1.getEndLine(), otherUser, null, time, message3,
-        (short) 0, revId.get());
+    Comment comment1 =
+        newComment(
+            psId1,
+            "file1",
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time,
+            message1,
+            (short) 0,
+            revId.get(),
+            false);
+    Comment comment2 =
+        newComment(
+            psId1,
+            "file1",
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            time,
+            message2,
+            (short) 0,
+            revId.get(),
+            false);
+    Comment comment3 =
+        newComment(
+            psId2,
+            "file1",
+            uuid3,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            time,
+            message3,
+            (short) 0,
+            revId.get(),
+            false);
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId2);
-    update.putComment(comment3);
-    update.putComment(comment2);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment3);
+    update.putComment(Status.PUBLISHED, comment2);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
     try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree =
-          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
       Note note = Iterables.getOnlyElement(notesInTree);
 
-      byte[] bytes =
-          walk.getObjectReader().open(
-              note.getData(), Constants.OBJ_BLOB).getBytes();
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
       String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
-      assertThat(noteString).isEqualTo(
-          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-          + "Base-for-patch-set: 1\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + timeStr + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid1\n"
-          + "Bytes: 9\n"
-          + "comment 1\n"
-          + "\n"
-          + "2:1-3:1\n"
-          + timeStr + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid2\n"
-          + "Bytes: 9\n"
-          + "comment 2\n"
-          + "\n"
-          + "Base-for-patch-set: 2\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + timeStr + "\n"
-          + "Author: Other Account <2@gerrit>\n"
-          + "UUID: uuid3\n"
-          + "Bytes: 9\n"
-          + "comment 3\n"
-          + "\n");
-    }
 
-    assertThat(notes.getComments()).isEqualTo(
-        ImmutableMultimap.of(
-            revId, comment1,
-            revId, comment2,
-            revId, comment3));
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Base-for-patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + timeStr
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid1\n"
+                    + "Bytes: 9\n"
+                    + "comment 1\n"
+                    + "\n"
+                    + "2:1-3:1\n"
+                    + timeStr
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid2\n"
+                    + "Bytes: 9\n"
+                    + "comment 2\n"
+                    + "\n"
+                    + "Base-for-patch-set: 2\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + timeStr
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid3\n"
+                    + "Bytes: 9\n"
+                    + "comment 3\n"
+                    + "\n");
+      }
+    }
+    assertThat(notes.getComments())
+        .isEqualTo(
+            ImmutableListMultimap.of(
+                revId, comment1,
+                revId, comment2,
+                revId, comment3));
+  }
+
+  @Test
+  public void patchLineCommentNotesFormatRealAuthor() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    String uuid = "uuid";
+    String message = "comment";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Timestamp time = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    Comment comment =
+        newComment(
+            psId,
+            "file",
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            time,
+            message,
+            (short) 1,
+            revId.get(),
+            false);
+    comment.setRealAuthor(changeOwner.getAccountId());
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Patch-set: 1\n"
+                    + "File: file\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + ChangeNoteUtil.formatTime(serverIdent, time)
+                    + "\n"
+                    + "Author: Other Account <2@gerrit>\n"
+                    + "Real-author: Change Owner <1@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid\n"
+                    + "Bytes: 7\n"
+                    + "comment\n"
+                    + "\n");
+      }
+    }
+    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
   }
 
   @Test
@@ -1590,46 +2096,58 @@
     Timestamp time = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment = newPublishedComment(psId, "file1",
-        uuid, range, range.getEndLine(), user, null, time, "comment",
-        (short) 1, "abcd1234abcd1234abcd1234abcd1234abcd1234");
+    Comment comment =
+        newComment(
+            psId,
+            "file1",
+            uuid,
+            range,
+            range.getEndLine(),
+            user,
+            null,
+            time,
+            "comment",
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
     try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree =
-          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
       Note note = Iterables.getOnlyElement(notesInTree);
 
-      byte[] bytes =
-          walk.getObjectReader().open(
-              note.getData(), Constants.OBJ_BLOB).getBytes();
+      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
       String noteString = new String(bytes, UTF_8);
       String timeStr = ChangeNoteUtil.formatTime(serverIdent, time);
-      assertThat(noteString).isEqualTo(
-          "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-          + "Patch-set: 1\n"
-          + "File: file1\n"
-          + "\n"
-          + "1:1-2:1\n"
-          + timeStr + "\n"
-          + "Author: Weird\u0002User <3@gerrit>\n"
-          + "UUID: uuid\n"
-          + "Bytes: 7\n"
-          + "comment\n"
-          + "\n");
-    }
 
+      if (!testJson()) {
+        assertThat(noteString)
+            .isEqualTo(
+                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                    + "Patch-set: 1\n"
+                    + "File: file1\n"
+                    + "\n"
+                    + "1:1-2:1\n"
+                    + timeStr
+                    + "\n"
+                    + "Author: Weird\u0002User <3@gerrit>\n"
+                    + "Unresolved: false\n"
+                    + "UUID: uuid\n"
+                    + "Bytes: 7\n"
+                    + "comment\n"
+                    + "\n");
+      }
+    }
     assertThat(notes.getComments())
-        .isEqualTo(ImmutableMultimap.of(comment.getRevId(), comment));
+        .isEqualTo(ImmutableListMultimap.of(new RevId(comment.revId), comment));
   }
 
   @Test
-  public void patchLineCommentMultipleOnePatchsetOneFileBothSides()
-      throws Exception {
+  public void patchLineCommentMultipleOnePatchsetOneFileBothSides() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     String uuid1 = "uuid1";
@@ -1642,27 +2160,48 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment commentForBase =
-        newPublishedComment(psId, "filename", uuid1,
-        range, range.getEndLine(), otherUser, null, now, messageForBase,
-        (short) 0, rev1);
+    Comment commentForBase =
+        newComment(
+            psId,
+            "filename",
+            uuid1,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            messageForBase,
+            (short) 0,
+            rev1,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(commentForBase);
+    update.putComment(Status.PUBLISHED, commentForBase);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    PatchLineComment commentForPS =
-        newPublishedComment(psId, "filename", uuid2,
-        range, range.getEndLine(), otherUser, null, now, messageForPS,
-        (short) 1, rev2);
+    Comment commentForPS =
+        newComment(
+            psId,
+            "filename",
+            uuid2,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            messageForPS,
+            (short) 1,
+            rev2,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(commentForPS);
+    update.putComment(Status.PUBLISHED, commentForPS);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(
-            new RevId(rev1), commentForBase,
-            new RevId(rev2), commentForPS));
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev1), commentForBase,
+                new RevId(rev2), commentForPS));
   }
 
   @Test
@@ -1679,30 +2218,53 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp timeForComment1 = TimeUtil.nowTs();
     Timestamp timeForComment2 = TimeUtil.nowTs();
-    PatchLineComment comment1 = newPublishedComment(psId, filename,
-        uuid1, range, range.getEndLine(), otherUser, null, timeForComment1,
-        "comment 1", side, rev);
+    Comment comment1 =
+        newComment(
+            psId,
+            filename,
+            uuid1,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            timeForComment1,
+            "comment 1",
+            side,
+            rev,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    PatchLineComment comment2 = newPublishedComment(psId, filename,
-        uuid2, range, range.getEndLine(), otherUser, null, timeForComment2,
-        "comment 2", side, rev);
+    Comment comment2 =
+        newComment(
+            psId,
+            filename,
+            uuid2,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            timeForComment2,
+            "comment 2",
+            side,
+            rev,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(
-          new RevId(rev), comment1,
-          new RevId(rev), comment2)).inOrder();
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev), comment1,
+                new RevId(rev), comment2))
+        .inOrder();
   }
 
   @Test
-  public void patchLineCommentMultipleOnePatchsetMultipleFiles()
-      throws Exception {
+  public void patchLineCommentMultipleOnePatchsetMultipleFiles() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
     String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
@@ -1714,25 +2276,49 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newPublishedComment(psId, filename1,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment 1",
-        side, rev);
+    Comment comment1 =
+        newComment(
+            psId,
+            filename1,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment 1",
+            side,
+            rev,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    PatchLineComment comment2 = newPublishedComment(psId, filename2,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment 2",
-        side, rev);
+    Comment comment2 =
+        newComment(
+            psId,
+            filename2,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment 2",
+            side,
+            rev,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(
-          new RevId(rev), comment1,
-          new RevId(rev), comment2)).inOrder();
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev), comment1,
+                new RevId(rev), comment2))
+        .inOrder();
   }
 
   @Test
@@ -1748,11 +2334,22 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newPublishedComment(ps1, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev1);
+    Comment comment1 =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev1,
+            false);
     update.setPatchSetId(ps1);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -1760,17 +2357,29 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    PatchLineComment comment2 = newPublishedComment(ps2, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
-        side, rev2);
+    Comment comment2 =
+        newComment(
+            ps2,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps2",
+            side,
+            rev2,
+            false);
     update.setPatchSetId(ps2);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(
-          new RevId(rev1), comment1,
-          new RevId(rev2), comment2));
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev1), comment1,
+                new RevId(rev2), comment2));
   }
 
   @Test
@@ -1785,33 +2394,42 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, filename, uuid, range,
-        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
-        rev, Status.DRAFT);
+    Comment comment1 =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
     update.setPatchSetId(ps1);
-    update.putComment(comment1);
+    update.putComment(Status.DRAFT, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).containsExactlyEntriesIn(
-        ImmutableMultimap.of(new RevId(rev), comment1));
+    assertThat(notes.getDraftComments(otherUserId))
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
     assertThat(notes.getComments()).isEmpty();
 
-    comment1.setStatus(Status.PUBLISHED);
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(new RevId(rev), comment1));
+    assertThat(notes.getComments())
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
   }
 
   @Test
-  public void patchLineCommentMultipleDraftsSameSidePublishOne()
-      throws Exception {
+  public void patchLineCommentMultipleDraftsSameSidePublishOne() throws Exception {
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
@@ -1826,40 +2444,62 @@
     // Write two drafts on the same side of one patch set.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    PatchLineComment comment1 = newComment(psId, filename, uuid1,
-        range1, range1.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev, Status.DRAFT);
-    PatchLineComment comment2 = newComment(psId, filename, uuid2,
-        range2, range2.getEndLine(), otherUser, null, now, "other on ps1",
-        side, rev, Status.DRAFT);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    Comment comment1 =
+        newComment(
+            psId,
+            filename,
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
+    Comment comment2 =
+        newComment(
+            psId,
+            filename,
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "other on ps1",
+            side,
+            rev,
+            false);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).containsExactlyEntriesIn(
-        ImmutableMultimap.of(
-          new RevId(rev), comment1,
-          new RevId(rev), comment2)).inOrder();
+    assertThat(notes.getDraftComments(otherUserId))
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev), comment1,
+                new RevId(rev), comment2))
+        .inOrder();
     assertThat(notes.getComments()).isEmpty();
 
     // Publish first draft.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    comment1.setStatus(Status.PUBLISHED);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).containsExactlyEntriesIn(
-        ImmutableMultimap.of(new RevId(rev), comment2));
-    assertThat(notes.getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(new RevId(rev), comment1));
+    assertThat(notes.getDraftComments(otherUserId))
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment2));
+    assertThat(notes.getComments())
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment1));
   }
 
   @Test
-  public void patchLineCommentsMultipleDraftsBothSidesPublishAll()
-      throws Exception {
+  public void patchLineCommentsMultipleDraftsBothSidesPublishAll() throws Exception {
     Change c = newChange();
     String uuid1 = "uuid1";
     String uuid2 = "uuid2";
@@ -1874,40 +2514,62 @@
     // Write two drafts, one on each side of the patchset.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    PatchLineComment baseComment = newComment(psId, filename, uuid1,
-        range1, range1.getEndLine(), otherUser, null, now, "comment on base",
-        (short) 0, rev1, Status.DRAFT);
-    PatchLineComment psComment = newComment(psId, filename, uuid2,
-        range2, range2.getEndLine(), otherUser, null, now, "comment on ps",
-        (short) 1, rev2, Status.DRAFT);
+    Comment baseComment =
+        newComment(
+            psId,
+            filename,
+            uuid1,
+            range1,
+            range1.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on base",
+            (short) 0,
+            rev1,
+            false);
+    Comment psComment =
+        newComment(
+            psId,
+            filename,
+            uuid2,
+            range2,
+            range2.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps",
+            (short) 1,
+            rev2,
+            false);
 
-    update.putComment(baseComment);
-    update.putComment(psComment);
+    update.putComment(Status.DRAFT, baseComment);
+    update.putComment(Status.DRAFT, psComment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId)).containsExactlyEntriesIn(
-        ImmutableMultimap.of(
-            new RevId(rev1), baseComment,
-            new RevId(rev2), psComment));
+    assertThat(notes.getDraftComments(otherUserId))
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev1), baseComment,
+                new RevId(rev2), psComment));
     assertThat(notes.getComments()).isEmpty();
 
     // Publish both comments.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
 
-    baseComment.setStatus(Status.PUBLISHED);
-    psComment.setStatus(Status.PUBLISHED);
-    update.putComment(baseComment);
-    update.putComment(psComment);
+    update.putComment(Status.PUBLISHED, baseComment);
+    update.putComment(Status.PUBLISHED, psComment);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(
-            new RevId(rev1), baseComment,
-            new RevId(rev2), psComment));
+    assertThat(notes.getComments())
+        .containsExactlyEntriesIn(
+            ImmutableListMultimap.of(
+                new RevId(rev1), baseComment,
+                new RevId(rev2), psComment));
   }
 
   @Test
@@ -1923,17 +2585,27 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment = newComment(psId, filename, uuid, range,
-        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
-        rev, Status.DRAFT);
+    Comment comment =
+        newComment(
+            psId,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.DRAFT, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(1);
-    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId))
-      .isTrue();
+    assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId)).isTrue();
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
@@ -1947,8 +2619,7 @@
   }
 
   @Test
-  public void patchLineCommentsDeleteAllDraftsForOneRevision()
-      throws Exception {
+  public void patchLineCommentsDeleteAllDraftsForOneRevision() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
     String rev1 = "abcd1234abcd1234abcd1234abcd1234abcd1234";
@@ -1962,11 +2633,22 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev1, Status.DRAFT);
+    Comment comment1 =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev1,
+            false);
     update.setPatchSetId(ps1);
-    update.putComment(comment1);
+    update.putComment(Status.DRAFT, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -1974,11 +2656,22 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    PatchLineComment comment2 = newComment(ps2, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
-        side, rev2, Status.DRAFT);
+    Comment comment2 =
+        newComment(
+            ps2,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps2",
+            side,
+            rev2,
+            false);
     update.setPatchSetId(ps2);
-    update.putComment(comment2);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1998,8 +2691,7 @@
   }
 
   @Test
-  public void addingPublishedCommentDoesNotCreateNoOpCommitOnEmptyDraftRef()
-      throws Exception {
+  public void addingPublishedCommentDoesNotCreateNoOpCommitOnEmptyDraftRef() throws Exception {
     Change c = newChange();
     String uuid = "uuid";
     String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
@@ -2010,10 +2702,21 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment = newComment(ps1, filename, uuid, range,
-        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
-        rev, Status.PUBLISHED);
-    update.putComment(comment);
+    Comment comment =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
@@ -2022,8 +2725,7 @@
   }
 
   @Test
-  public void addingPublishedCommentDoesNotCreateNoOpCommitOnNonEmptyDraftRef()
-      throws Exception {
+  public void addingPublishedCommentDoesNotCreateNoOpCommitOnNonEmptyDraftRef() throws Exception {
     Change c = newChange();
     String rev = "abcd4567abcd4567abcd4567abcd4567abcd4567";
     CommentRange range = new CommentRange(1, 1, 2, 1);
@@ -2033,10 +2735,21 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment draft = newComment(ps1, filename, "uuid1", range,
-        range.getEndLine(), otherUser, null, now, "draft comment on ps1", side,
-        rev, Status.DRAFT);
-    update.putComment(draft);
+    Comment draft =
+        newComment(
+            ps1,
+            filename,
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "draft comment on ps1",
+            side,
+            rev,
+            false);
+    update.putComment(Status.DRAFT, draft);
     update.commit();
 
     String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
@@ -2044,10 +2757,21 @@
     assertThat(old).isNotNull();
 
     update = newUpdate(c, otherUser);
-    PatchLineComment pub = newComment(ps1, filename, "uuid2", range,
-        range.getEndLine(), otherUser, null, now, "comment on ps1", side,
-        rev, Status.PUBLISHED);
-    update.putComment(pub);
+    Comment pub =
+        newComment(
+            ps1,
+            filename,
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev,
+            false);
+    update.putComment(Status.PUBLISHED, pub);
     update.commit();
 
     assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
@@ -2063,15 +2787,26 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment = newPublishedComment(
-        psId, "filename", uuid, null, 0, otherUser, null, now, messageForBase,
-        (short) 0, rev);
+    Comment comment =
+        newComment(
+            psId,
+            "filename",
+            uuid,
+            null,
+            0,
+            otherUser,
+            null,
+            now,
+            messageForBase,
+            (short) 0,
+            rev,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(new RevId(rev), comment));
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
   }
 
   @Test
@@ -2084,15 +2819,26 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    PatchLineComment comment = newPublishedComment(
-        psId, "filename", uuid, null, 1, otherUser, null, now, messageForBase,
-        (short) 0, rev);
+    Comment comment =
+        newComment(
+            psId,
+            "filename",
+            uuid,
+            null,
+            1,
+            otherUser,
+            null,
+            now,
+            messageForBase,
+            (short) 0,
+            rev,
+            false);
     update.setPatchSetId(psId);
-    update.putComment(comment);
+    update.putComment(Status.PUBLISHED, comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments()).containsExactlyEntriesIn(
-        ImmutableMultimap.of(new RevId(rev), comment));
+    assertThat(newNotes(c).getComments())
+        .containsExactlyEntriesIn(ImmutableListMultimap.of(new RevId(rev), comment));
   }
 
   @Test
@@ -2112,14 +2858,36 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev1, Status.DRAFT);
-    PatchLineComment comment2 = newComment(ps2, filename,
-        uuid, range, range.getEndLine(), otherUser, null, now, "comment on ps2",
-        side, rev2, Status.DRAFT);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    Comment comment1 =
+        newComment(
+            ps1,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev1,
+            false);
+    Comment comment2 =
+        newComment(
+            ps2,
+            filename,
+            uuid,
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps2",
+            side,
+            rev2,
+            false);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2128,10 +2896,8 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    comment1.setStatus(Status.PUBLISHED);
-    comment2.setStatus(Status.PUBLISHED);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment1);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
@@ -2150,30 +2916,49 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, "file1",
-        "uuid1", range, range.getEndLine(), otherUser, null, now, "comment1",
-        side, rev1.get(), Status.DRAFT);
-    PatchLineComment comment2 = newComment(ps1, "file2",
-        "uuid2", range, range.getEndLine(), otherUser, null, now, "comment2",
-        side, rev1.get(), Status.DRAFT);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    Comment comment1 =
+        newComment(
+            ps1,
+            "file1",
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment1",
+            side,
+            rev1.get(),
+            false);
+    Comment comment2 =
+        newComment(
+            ps1,
+            "file2",
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment2",
+            side,
+            rev1.get(),
+            false);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1))
-        .containsExactly(comment1, comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1, comment2);
     assertThat(notes.getComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    comment2.setStatus(Status.PUBLISHED);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1))
-        .containsExactly(comment1);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
     assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
   }
 
@@ -2203,14 +2988,36 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    PatchLineComment comment1 = newComment(ps1, "file1",
-        "uuid1", range, range.getEndLine(), otherUser, null, now, "comment on ps1",
-        side, rev1.get(), Status.DRAFT);
-    PatchLineComment comment2 = newComment(ps1, "file2",
-        "uuid2", range, range.getEndLine(), otherUser, null, now, "another comment",
-        side, rev1.get(), Status.DRAFT);
-    update.putComment(comment1);
-    update.putComment(comment2);
+    Comment comment1 =
+        newComment(
+            ps1,
+            "file1",
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "comment on ps1",
+            side,
+            rev1.get(),
+            false);
+    Comment comment2 =
+        newComment(
+            ps1,
+            "file2",
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            now,
+            "another comment",
+            side,
+            rev1.get(),
+            false);
+    update.putComment(Status.DRAFT, comment1);
+    update.putComment(Status.DRAFT, comment2);
     update.commit();
 
     String refName = refsDraftComments(c.getId(), otherUserId);
@@ -2218,8 +3025,7 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    comment2.setStatus(Status.PUBLISHED);
-    update.putComment(comment2);
+    update.putComment(Status.PUBLISHED, comment2);
     update.commit();
     assertThat(exactRefAllUsers(refName)).isNotNull();
     assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
@@ -2227,34 +3033,25 @@
     // Re-add draft version of comment2 back to draft ref without updating
     // change ref. Simulates the case where deleting the draft failed
     // non-atomically after adding the published comment succeeded.
-    ChangeDraftUpdate draftUpdate =
-        newUpdate(c, otherUser).createDraftUpdateIfNull();
-    comment2.setStatus(Status.DRAFT);
+    ChangeDraftUpdate draftUpdate = newUpdate(c, otherUser).createDraftUpdateIfNull();
     draftUpdate.putComment(comment2);
-    try (NoteDbUpdateManager manager =
-        updateManagerFactory.create(c.getProject())) {
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject())) {
       manager.add(draftUpdate);
       manager.execute();
     }
 
     // Looking at drafts directly shows the zombie comment.
     DraftCommentNotes draftNotes = draftNotesFactory.create(c, otherUserId);
-    assertThat(draftNotes.load().getComments().get(rev1))
-        .containsExactly(comment1, comment2);
-
-    comment2.setStatus(Status.PUBLISHED); // Reset for later assertions.
+    assertThat(draftNotes.load().getComments().get(rev1)).containsExactly(comment1, comment2);
 
     // Zombie comment is filtered out of drafts via ChangeNotes.
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(rev1))
-        .containsExactly(comment1);
-    assertThat(notes.getComments().get(rev1))
-        .containsExactly(comment2);
+    assertThat(notes.getDraftComments(otherUserId).get(rev1)).containsExactly(comment1);
+    assertThat(notes.getComments().get(rev1)).containsExactly(comment2);
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    comment1.setStatus(Status.PUBLISHED);
-    update.putComment(comment1);
+    update.putComment(Status.PUBLISHED, comment1);
     update.commit();
 
     // Updating an unrelated comment causes the zombie comment to get fixed up.
@@ -2268,18 +3065,38 @@
     String rev = "abcd1234abcd1234abcd1234abcd1234abcd1234";
 
     ChangeUpdate update1 = newUpdate(c, otherUser);
-    PatchLineComment comment1 = newComment(c.currentPatchSetId(), "filename",
-        "uuid1", range, range.getEndLine(), otherUser, null,
-        new Timestamp(update1.getWhen().getTime()), "comment 1", (short) 1, rev,
-        Status.PUBLISHED);
-    update1.putComment(comment1);
+    Comment comment1 =
+        newComment(
+            c.currentPatchSetId(),
+            "filename",
+            "uuid1",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            new Timestamp(update1.getWhen().getTime()),
+            "comment 1",
+            (short) 1,
+            rev,
+            false);
+    update1.putComment(Status.PUBLISHED, comment1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
-    PatchLineComment comment2 = newComment(c.currentPatchSetId(), "filename",
-        "uuid2", range, range.getEndLine(), otherUser, null,
-        new Timestamp(update2.getWhen().getTime()), "comment 2", (short) 1, rev,
-        Status.PUBLISHED);
-    update2.putComment(comment2);
+    Comment comment2 =
+        newComment(
+            c.currentPatchSetId(),
+            "filename",
+            "uuid2",
+            range,
+            range.getEndLine(),
+            otherUser,
+            null,
+            new Timestamp(update2.getWhen().getTime()),
+            "comment 2",
+            (short) 1,
+            rev,
+            false);
+    update2.putComment(Status.PUBLISHED, comment2);
 
     try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
       manager.add(update1);
@@ -2288,16 +3105,173 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<PatchLineComment> comments = notes.getComments().get(new RevId(rev));
+    List<Comment> comments = notes.getComments().get(new RevId(rev));
     assertThat(comments).hasSize(2);
-    assertThat(comments.get(0).getMessage()).isEqualTo("comment 1");
-    assertThat(comments.get(1).getMessage()).isEqualTo("comment 2");
+    assertThat(comments.get(0).message).isEqualTo("comment 1");
+    assertThat(comments.get(1).message).isEqualTo("comment 2");
+  }
+
+  @Test
+  public void realUser() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    update.setChangeMessage("Message on behalf of other user");
+    update.commit();
+
+    ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages());
+    assertThat(msg.getMessage()).isEqualTo("Message on behalf of other user");
+    assertThat(msg.getAuthor()).isEqualTo(otherUserId);
+    assertThat(msg.getRealAuthor()).isEqualTo(changeOwner.getAccountId());
+  }
+
+  @Test
+  public void ignoreEntitiesBeyondCurrentPatchSet() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    int numMessages = notes.getChangeMessages().size();
+    int numPatchSets = notes.getPatchSets().size();
+    int numApprovals = notes.getApprovals().size();
+    int numComments = notes.getComments().size();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPatchSetId(new PatchSet.Id(c.getId(), c.currentPatchSetId().get() + 1));
+    update.setChangeMessage("Should be ignored");
+    update.putApproval("Code-Review", (short) 2);
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Comment comment =
+        newComment(
+            update.getPatchSetId(),
+            "filename",
+            "uuid",
+            range,
+            range.getEndLine(),
+            changeOwner,
+            null,
+            new Timestamp(update.getWhen().getTime()),
+            "comment",
+            (short) 1,
+            "abcd1234abcd1234abcd1234abcd1234abcd1234",
+            false);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getChangeMessages()).hasSize(numMessages);
+    assertThat(notes.getPatchSets()).hasSize(numPatchSets);
+    assertThat(notes.getApprovals()).hasSize(numApprovals);
+    assertThat(notes.getComments()).hasSize(numComments);
+  }
+
+  @Test
+  public void currentPatchSet() throws Exception {
+    Change c = newChange();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
+
+    incrementPatchSet(c);
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setCurrentPatchSet();
+    update.commit();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
+
+    incrementPatchSet(c);
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(3);
+
+    // Delete PS3, PS1 becomes current, as the most recent event explicitly set
+    // it to current.
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetState(PatchSetState.DELETED);
+    update.commit();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(1);
+
+    // Delete PS1, PS2 becomes current.
+    update = newUpdate(c, changeOwner);
+    update.setPatchSetId(new PatchSet.Id(c.getId(), 1));
+    update.setPatchSetState(PatchSetState.DELETED);
+    update.commit();
+    assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
+  }
+
+  @Test
+  public void readOnlyUntilExpires() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    Timestamp until = new Timestamp(TimeUtil.nowMs() + 10000);
+    update.setReadOnlyUntil(until);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setTopic("failing-topic");
+    try {
+      update.commit();
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e.getMessage()).contains("read-only until");
+    }
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isNotEqualTo("failing-topic");
+    assertThat(notes.getReadOnlyUntil()).isEqualTo(until);
+
+    TestTimeUtil.incrementClock(30, TimeUnit.SECONDS);
+    update = newUpdate(c, changeOwner);
+    update.setTopic("succeeding-topic");
+    update.commit();
+
+    // Write succeeded; lease still exists, even though it's expired.
+    notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo("succeeding-topic");
+    assertThat(notes.getReadOnlyUntil()).isEqualTo(until);
+
+    // New lease takes precedence.
+    update = newUpdate(c, changeOwner);
+    until = new Timestamp(TimeUtil.nowMs() + 10000);
+    update.setReadOnlyUntil(until);
+    update.commit();
+    assertThat(newNotes(c).getReadOnlyUntil()).isEqualTo(until);
+  }
+
+  @Test
+  public void readOnlyUntilCleared() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    Timestamp until = new Timestamp(TimeUtil.nowMs() + TimeUnit.DAYS.toMillis(30));
+    update.setReadOnlyUntil(until);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setTopic("failing-topic");
+    try {
+      update.commit();
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e.getMessage()).contains("read-only until");
+    }
+
+    // Sentinel timestamp of 0 can be written to clear lease.
+    update = newUpdate(c, changeOwner);
+    update.setReadOnlyUntil(new Timestamp(0));
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setTopic("succeeding-topic");
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getChange().getTopic()).isEqualTo("succeeding-topic");
+    assertThat(notes.getReadOnlyUntil()).isEqualTo(new Timestamp(0));
+  }
+
+  private boolean testJson() {
+    return noteUtil.getWriteJson();
   }
 
   private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
     ObjectId dataId = notes.revisionNoteMap.noteMap.getNote(noteId).getData();
-    return new String(
-        rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
+    return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
   }
 
   private ObjectId exactRefAllUsers(String refName) throws Exception {
@@ -2307,8 +3281,8 @@
     }
   }
 
-  private void assertCause(Throwable e,
-      Class<? extends Throwable> expectedClass, String expectedMsg) {
+  private void assertCause(
+      Throwable e, Class<? extends Throwable> expectedClass, String expectedMsg) {
     Throwable cause = null;
     for (Throwable t : Throwables.getCausalChain(e)) {
       if (expectedClass.isAssignableFrom(t.getClass())) {
@@ -2317,9 +3291,28 @@
       }
     }
     assertThat(cause)
-        .named(expectedClass.getSimpleName() + " in causal chain of:\n"
-            + Throwables.getStackTraceAsString(e))
+        .named(
+            expectedClass.getSimpleName()
+                + " in causal chain of:\n"
+                + Throwables.getStackTraceAsString(e))
         .isNotNull();
     assertThat(cause.getMessage()).isEqualTo(expectedMsg);
   }
+
+  private void incrementCurrentPatchSetFieldOnly(Change c) {
+    TestChanges.incrementPatchSet(c);
+  }
+
+  private RevCommit incrementPatchSet(Change c) throws Exception {
+    return incrementPatchSet(c, userFactory.create(c.getOwner()));
+  }
+
+  private RevCommit incrementPatchSet(Change c, IdentifiedUser user) throws Exception {
+    incrementCurrentPatchSetFieldOnly(c);
+    RevCommit commit = tr.commit().message("PS" + c.currentPatchSetId().get()).create();
+    ChangeUpdate update = newUpdate(c, user);
+    update.setCommit(rw, commit);
+    update.commit();
+    return tr.parseBody(commit);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
new file mode 100644
index 0000000..aa37d51
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -0,0 +1,173 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.sql.Timestamp;
+import java.time.ZonedDateTime;
+import java.util.TimeZone;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CommentTimestampAdapterTest {
+  /** Arbitrary time outside of a DST transition, as an ISO instant. */
+  private static final String NON_DST_STR = "2017-02-07T10:20:30.123Z";
+
+  /** Arbitrary time outside of a DST transition, as a reasonable Java 8 representation. */
+  private static final ZonedDateTime NON_DST = ZonedDateTime.parse(NON_DST_STR);
+
+  /** {@link #NON_DST_STR} truncated to seconds. */
+  private static final String NON_DST_STR_TRUNC = "2017-02-07T10:20:30Z";
+
+  /** Arbitrary time outside of a DST transition, as an unreasonable Timestamp representation. */
+  private static final Timestamp NON_DST_TS = Timestamp.from(NON_DST.toInstant());
+
+  /** {@link #NON_DST_TS} truncated to seconds. */
+  private static final Timestamp NON_DST_TS_TRUNC =
+      Timestamp.from(ZonedDateTime.parse(NON_DST_STR_TRUNC).toInstant());
+
+  /**
+   * Real live ms since epoch timestamp of a comment that was posted during the PDT to PST
+   * transition in November 2013.
+   */
+  private static final long MID_DST_MS = 1383466224175L;
+
+  /**
+   * Ambiguous string representation of {@link #MID_DST_MS} that was actually stored in NoteDb for
+   * this comment.
+   */
+  private static final String MID_DST_STR = "Nov 3, 2013 1:10:24 AM";
+
+  private TimeZone systemTimeZone;
+  private Gson legacyGson;
+  private Gson gson;
+
+  @Before
+  public void setUp() {
+    systemTimeZone = TimeZone.getDefault();
+    TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"));
+
+    // Match ChangeNoteUtil#gson as of 4e1f02db913d91f2988f559048e513e6093a1bce
+    legacyGson = new GsonBuilder().setPrettyPrinting().create();
+    gson = ChangeNoteUtil.newGson();
+  }
+
+  @After
+  public void tearDown() {
+    TimeZone.setDefault(systemTimeZone);
+  }
+
+  @Test
+  public void legacyGsonBehavesAsExpectedDuringDstTransition() {
+    long oneHourMs = TimeUnit.HOURS.toMillis(1);
+
+    String beforeJson = "\"Nov 3, 2013 12:10:24 AM\"";
+    Timestamp beforeTs = new Timestamp(MID_DST_MS - oneHourMs);
+    assertThat(legacyGson.toJson(beforeTs)).isEqualTo(beforeJson);
+
+    String ambiguousJson = '"' + MID_DST_STR + '"';
+    Timestamp duringTs = new Timestamp(MID_DST_MS);
+    assertThat(legacyGson.toJson(duringTs)).isEqualTo(ambiguousJson);
+
+    Timestamp afterTs = new Timestamp(MID_DST_MS + oneHourMs);
+    assertThat(legacyGson.toJson(afterTs)).isEqualTo(ambiguousJson);
+
+    Timestamp beforeTsTruncated = new Timestamp(beforeTs.getTime() / 1000 * 1000);
+    assertThat(legacyGson.fromJson(beforeJson, Timestamp.class)).isEqualTo(beforeTsTruncated);
+
+    // Gson just picks one, and it happens to be the one after the PST transition.
+    Timestamp afterTsTruncated = new Timestamp(afterTs.getTime() / 1000 * 1000);
+    assertThat(legacyGson.fromJson(ambiguousJson, Timestamp.class)).isEqualTo(afterTsTruncated);
+  }
+
+  @Test
+  public void legacyAdapterViaZonedDateTime() {
+    assertThat(legacyGson.toJson(NON_DST_TS)).isEqualTo("\"Feb 7, 2017 2:20:30 AM\"");
+  }
+
+  @Test
+  public void legacyAdapterCanParseOutputOfNewAdapter() {
+    String instantJson = gson.toJson(NON_DST_TS);
+    assertThat(instantJson).isEqualTo('"' + NON_DST_STR_TRUNC + '"');
+    Timestamp result = legacyGson.fromJson(instantJson, Timestamp.class);
+    assertThat(result).isEqualTo(NON_DST_TS_TRUNC);
+  }
+
+  @Test
+  public void newAdapterCanParseOutputOfLegacyAdapter() {
+    String legacyJson = legacyGson.toJson(NON_DST_TS);
+    assertThat(legacyJson).isEqualTo("\"Feb 7, 2017 2:20:30 AM\"");
+    assertThat(gson.fromJson(legacyJson, Timestamp.class))
+        .isEqualTo(new Timestamp(NON_DST_TS.getTime() / 1000 * 1000));
+  }
+
+  @Test
+  public void newAdapterDisagreesWithLegacyAdapterDuringDstTransition() {
+    String duringJson = legacyGson.toJson(new Timestamp(MID_DST_MS));
+    Timestamp duringTs = legacyGson.fromJson(duringJson, Timestamp.class);
+
+    // This is unfortunate, but it's just documenting the current behavior, there is no real good
+    // solution here. The goal is that all these changes will be rebuilt with proper UTC instant
+    // strings shortly after the new adapter is live.
+    Timestamp newDuringTs = gson.fromJson(duringJson, Timestamp.class);
+    assertThat(newDuringTs.toString()).isEqualTo(duringTs.toString());
+    assertThat(newDuringTs).isNotEqualTo(duringTs);
+  }
+
+  @Test
+  public void newAdapterRoundTrip() {
+    String json = gson.toJson(NON_DST_TS);
+    // Round-trip lossily truncates ms, but that's ok.
+    assertThat(json).isEqualTo('"' + NON_DST_STR_TRUNC + '"');
+    assertThat(gson.fromJson(json, Timestamp.class)).isEqualTo(NON_DST_TS_TRUNC);
+  }
+
+  @Test
+  public void nullSafety() {
+    assertThat(gson.toJson(null, Timestamp.class)).isEqualTo("null");
+    assertThat(gson.fromJson("null", Timestamp.class)).isNull();
+  }
+
+  @Test
+  public void newAdapterRoundTripOfWholeComment() {
+    Comment c =
+        new Comment(
+            new Comment.Key("uuid", "filename", 1),
+            new Account.Id(100),
+            NON_DST_TS,
+            (short) 0,
+            "message",
+            "serverId",
+            false);
+    c.lineNbr = 1;
+    c.revId = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+    String json = gson.toJson(c);
+    assertThat(json).contains("\"writtenOn\": \"" + NON_DST_STR_TRUNC + "\",");
+
+    Comment result = gson.fromJson(json, Comment.class);
+    // Round-trip lossily truncates ms, but that's ok.
+    assertThat(result.writtenOn).isEqualTo(NON_DST_TS_TRUNC);
+    result.writtenOn = NON_DST_TS;
+    assertThat(result).isEqualTo(c);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index bf5abba..25b5168 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -22,18 +22,20 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestChanges;
-
+import java.util.Date;
+import java.util.TimeZone;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
+import org.junit.runner.RunWith;
 
-import java.util.Date;
-import java.util.TimeZone;
-
+@RunWith(ConfigSuite.class)
 public class CommitMessageOutputTest extends AbstractChangeNotesTest {
   @Test
   public void approvalsCommitFormatSimple() throws Exception {
@@ -47,26 +49,29 @@
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
 
     RevCommit commit = parseCommit(update.getResult());
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Change-id: " + c.getKey().get() + "\n"
-        + "Subject: Change subject\n"
-        + "Branch: refs/heads/master\n"
-        + "Commit: " + update.getCommit().name() + "\n"
-        + "Reviewer: Change Owner <1@gerrit>\n"
-        + "CC: Other Account <2@gerrit>\n"
-        + "Label: Code-Review=-1\n"
-        + "Label: Verified=+1\n",
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: Change subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + update.getCommit().name()
+            + "\n"
+            + "Reviewer: Change Owner <1@gerrit>\n"
+            + "CC: Other Account <2@gerrit>\n"
+            + "Label: Code-Review=-1\n"
+            + "Label: Verified=+1\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Change Owner");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen())
-        .isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
-    assertThat(author.getTimeZone())
-        .isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
+    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
@@ -79,21 +84,25 @@
   public void changeMessageCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Just a little code change.\n"
-        + "How about a new line");
+    update.setChangeMessage("Just a little code change.\nHow about a new line");
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Just a little code change.\n"
-        + "How about a new line\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Change-id: " + c.getKey().get() + "\n"
-        + "Subject: Change subject\n"
-        + "Branch: refs/heads/master\n"
-        + "Commit: " + update.getCommit().name() + "\n",
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Just a little code change.\n"
+            + "How about a new line\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: Change subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + update.getCommit().name()
+            + "\n",
         update.getResult());
   }
 
@@ -107,15 +116,20 @@
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Foo\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Change-id: " + c.getKey().get() + "\n"
-        + "Subject: Subject\n"
-        + "Branch: refs/heads/master\n"
-        + "Commit: " + commit.name() + "\n",
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Foo\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: Subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + commit.name()
+            + "\n",
         update.getResult());
   }
 
@@ -126,11 +140,8 @@
     update.removeApproval("Code-Review");
     update.commit();
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Label: -Code-Review\n",
-        update.getResult());
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nLabel: -Code-Review\n", update.getResult());
   }
 
   @Test
@@ -140,36 +151,43 @@
     update.setSubjectForCommit("Submit patch set 1");
 
     RequestId submissionId = RequestId.forChange(c);
-    update.merge(submissionId, ImmutableList.of(
-        submitRecord("NOT_READY", null,
-          submitLabel("Verified", "OK", changeOwner.getAccountId()),
-          submitLabel("Code-Review", "NEED", null)),
-        submitRecord("NOT_READY", null,
-          submitLabel("Verified", "OK", changeOwner.getAccountId()),
-          submitLabel("Alternative-Code-Review", "NEED", null))));
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Code-Review", "NEED", null)),
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
     RevCommit commit = parseCommit(update.getResult());
-    assertBodyEquals("Submit patch set 1\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Status: merged\n"
-        + "Submission-id: " + submissionId.toStringForStorage() + "\n"
-        + "Submitted-with: NOT_READY\n"
-        + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-        + "Submitted-with: NEED: Code-Review\n"
-        + "Submitted-with: NOT_READY\n"
-        + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-        + "Submitted-with: NEED: Alternative-Code-Review\n",
+    assertBodyEquals(
+        "Submit patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Status: merged\n"
+            + "Submission-id: "
+            + submissionId.toStringForStorage()
+            + "\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Code-Review\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Alternative-Code-Review\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Change Owner");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen())
-        .isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
-    assertThat(author.getTimeZone())
-        .isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
+    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
@@ -188,12 +206,7 @@
     update.commit();
 
     RevCommit commit = parseCommit(update.getResult());
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Comment on the change.\n"
-        + "\n"
-        + "Patch-set: 1\n",
-        commit);
+    assertBodyEquals("Update patch set 1\n\nComment on the change.\n\nPatch-set: 1\n", commit);
 
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Anonymous Coward (3)");
@@ -207,16 +220,19 @@
     update.setSubjectForCommit("Submit patch set 1");
 
     RequestId submissionId = RequestId.forChange(c);
-    update.merge(submissionId, ImmutableList.of(
-        submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
+    update.merge(
+        submissionId, ImmutableList.of(submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
     update.commit();
 
-    assertBodyEquals("Submit patch set 1\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Status: merged\n"
-        + "Submission-id: " + submissionId.toStringForStorage() + "\n"
-        + "Submitted-with: RULE_ERROR Problem with patch set: 1\n",
+    assertBodyEquals(
+        "Submit patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Status: merged\n"
+            + "Submission-id: "
+            + submissionId.toStringForStorage()
+            + "\n"
+            + "Submitted-with: RULE_ERROR Problem with patch set: 1\n",
         update.getResult());
   }
 
@@ -227,10 +243,8 @@
     update.putReviewer(changeOwner.getAccount().getId(), REVIEWER);
     update.commit();
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Reviewer: Change Owner <1@gerrit>\n",
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Change Owner <1@gerrit>\n",
         update.getResult());
   }
 
@@ -238,17 +252,17 @@
   public void changeMessageWithTrailingDoubleNewline() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Testing trailing double newline\n"
-        + "\n");
+    update.setChangeMessage("Testing trailing double newline\n\n");
     update.commit();
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Testing trailing double newline\n"
-        + "\n"
-        + "\n"
-        + "\n"
-        + "Patch-set: 1\n",
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Testing trailing double newline\n"
+            + "\n"
+            + "\n"
+            + "\n"
+            + "Patch-set: 1\n",
         update.getResult());
   }
 
@@ -256,22 +270,19 @@
   public void changeMessageWithMultipleParagraphs() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setChangeMessage("Testing paragraph 1\n"
-        + "\n"
-        + "Testing paragraph 2\n"
-        + "\n"
-        + "Testing paragraph 3");
+    update.setChangeMessage("Testing paragraph 1\n\nTesting paragraph 2\n\nTesting paragraph 3");
     update.commit();
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Testing paragraph 1\n"
-        + "\n"
-        + "Testing paragraph 2\n"
-        + "\n"
-        + "Testing paragraph 3\n"
-        + "\n"
-        + "Patch-set: 1\n",
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Testing paragraph 1\n"
+            + "\n"
+            + "Testing paragraph 2\n"
+            + "\n"
+            + "Testing paragraph 3\n"
+            + "\n"
+            + "Patch-set: 1\n",
         update.getResult());
   }
 
@@ -283,52 +294,94 @@
     update.setTag("jenkins");
     update.commit();
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Change message with tag\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Tag: jenkins\n",
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Change message with tag\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Tag: jenkins\n",
         update.getResult());
   }
 
   @Test
   public void leadingWhitespace() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + c.getSubject(),
-        c.getOriginalSubject());
+    c.setCurrentPatchSet(c.currentPatchSetId(), "  " + c.getSubject(), c.getOriginalSubject());
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setChangeId(c.getKey().get());
     update.setBranch(c.getDest().get());
     update.commit();
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Change-id: " + c.getKey().get() + "\n"
-        + "Subject:   Change subject\n"
-        + "Branch: refs/heads/master\n"
-        + "Commit: " + update.getCommit().name() + "\n",
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject:   Change subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + update.getCommit().name()
+            + "\n",
         update.getResult());
 
     c = TestChanges.newChange(project, changeOwner.getAccountId());
-    c.setCurrentPatchSet(c.currentPatchSetId(), "\t\t" + c.getSubject(),
-        c.getOriginalSubject());
+    c.setCurrentPatchSet(c.currentPatchSetId(), "\t\t" + c.getSubject(), c.getOriginalSubject());
     update = newUpdate(c, changeOwner);
     update.setChangeId(c.getKey().get());
     update.setBranch(c.getDest().get());
     update.commit();
 
-    assertBodyEquals("Update patch set 1\n"
-        + "\n"
-        + "Patch-set: 1\n"
-        + "Change-id: " + c.getKey().get() + "\n"
-        + "Subject: \t\tChange subject\n"
-        + "Branch: refs/heads/master\n"
-        + "Commit: " + update.getCommit().name() + "\n",
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Change-id: "
+            + c.getKey().get()
+            + "\n"
+            + "Subject: \t\tChange subject\n"
+            + "Branch: refs/heads/master\n"
+            + "Commit: "
+            + update.getCommit().name()
+            + "\n",
         update.getResult());
   }
 
+  @Test
+  public void realUser() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    update.setChangeMessage("Message on behalf of other user");
+    update.commit();
+
+    RevCommit commit = parseCommit(update.getResult());
+    PersonIdent author = commit.getAuthorIdent();
+    assertThat(author.getName()).isEqualTo("Other Account");
+    assertThat(author.getEmailAddress()).isEqualTo("2@gerrit");
+
+    assertBodyEquals(
+        "Update patch set 1\n"
+            + "\n"
+            + "Message on behalf of other user\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Real-user: Change Owner <1@gerrit>\n",
+        commit);
+  }
+
+  @Test
+  public void currentPatchSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setCurrentPatchSet();
+    update.commit();
+
+    assertBodyEquals("Update patch set 1\n\nPatch-set: 1\nCurrent: true\n", update.getResult());
+  }
+
   private RevCommit parseCommit(ObjectId id) throws Exception {
     if (id instanceof RevCommit) {
       return (RevCommit) id;
@@ -340,8 +393,7 @@
     }
   }
 
-  private void assertBodyEquals(String expected, ObjectId commitId)
-      throws Exception {
+  private void assertBodyEquals(String expected, ObjectId commitId) throws Exception {
     RevCommit commit = parseCommit(commitId);
     assertThat(commit.getFullMessage()).isEqualTo(expected);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
index 216f71b..0553dc5 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
@@ -16,77 +16,125 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.common.TimeUtil.nowTs;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
 import static com.google.gerrit.server.notedb.NoteDbChangeState.applyDelta;
 import static com.google.gerrit.server.notedb.NoteDbChangeState.parse;
 import static org.eclipse.jgit.lib.ObjectId.zeroId;
 
-import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableMap;
 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.server.notedb.NoteDbChangeState.Delta;
+import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
+import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.TestChanges;
-import com.google.gwtorm.client.KeyUtil;
-import com.google.gwtorm.server.StandardKeyEncoder;
-
+import com.google.gerrit.testutil.TestTimeUtil;
+import java.sql.Timestamp;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.ObjectId;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
 /** Unit tests for {@link NoteDbChangeState}. */
-public class NoteDbChangeStateTest {
-  static {
-    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+public class NoteDbChangeStateTest extends GerritBaseTests {
+  ObjectId SHA1 = ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+  ObjectId SHA2 = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+  ObjectId SHA3 = ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
   }
 
-  ObjectId SHA1 =
-      ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-  ObjectId SHA2 =
-      ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
-  ObjectId SHA3 =
-      ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee");
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
 
   @Test
-  public void parseWithoutDrafts() {
+  public void parseReviewDbWithoutDrafts() {
     NoteDbChangeState state = parse(new Change.Id(1), SHA1.name());
-
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
     assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
     assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
     assertThat(state.getDraftIds()).isEmpty();
+    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+    assertThat(state.toString()).isEqualTo(SHA1.name());
 
+    state = parse(new Change.Id(1), "R," + SHA1.name());
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds()).isEmpty();
+    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
     assertThat(state.toString()).isEqualTo(SHA1.name());
   }
 
   @Test
-  public void parseWithDrafts() {
-    NoteDbChangeState state = parse(
-        new Change.Id(1),
-        SHA1.name() + ",2003=" + SHA2.name() + ",1001=" + SHA3.name());
-
+  public void parseReviewDbWithDrafts() {
+    String str = SHA1.name() + ",2003=" + SHA2.name() + ",1001=" + SHA3.name();
+    String expected = SHA1.name() + ",1001=" + SHA3.name() + ",2003=" + SHA2.name();
+    NoteDbChangeState state = parse(new Change.Id(1), str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
     assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
     assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
-    assertThat(state.getDraftIds()).containsExactly(
-        new Account.Id(1001), SHA3,
-        new Account.Id(2003), SHA2);
+    assertThat(state.getDraftIds())
+        .containsExactly(
+            new Account.Id(1001), SHA3,
+            new Account.Id(2003), SHA2);
+    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+    assertThat(state.toString()).isEqualTo(expected);
 
-    assertThat(state.toString()).isEqualTo(
-        SHA1.name() + ",1001=" + SHA3.name() + ",2003=" + SHA2.name());
+    state = parse(new Change.Id(1), "R," + str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getDraftIds())
+        .containsExactly(
+            new Account.Id(1001), SHA3,
+            new Account.Id(2003), SHA2);
+    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+    assertThat(state.toString()).isEqualTo(expected);
   }
 
   @Test
-  public void applyDeltaToNullWithNoNewMetaId() {
+  public void parseReadOnlyUntil() {
+    Timestamp ts = new Timestamp(12345);
+    String str = "R=12345," + SHA1.name();
+    NoteDbChangeState state = parse(new Change.Id(1), str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(REVIEW_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
+    assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
+    assertThat(state.toString()).isEqualTo(str);
+
+    str = "N=12345";
+    state = parse(new Change.Id(1), str);
+    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
+    assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
+    assertThat(state.getRefState().isPresent()).isFalse();
+    assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
+    assertThat(state.toString()).isEqualTo(str);
+  }
+
+  @Test
+  public void applyDeltaToNullWithNoNewMetaId() throws Exception {
     Change c = newChange();
     assertThat(c.getNoteDbState()).isNull();
     applyDelta(c, Delta.create(c.getId(), noMetaId(), noDrafts()));
     assertThat(c.getNoteDbState()).isNull();
 
-    applyDelta(c, Delta.create(c.getId(), noMetaId(),
-          drafts(new Account.Id(1001), zeroId())));
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(1001), zeroId())));
     assertThat(c.getNoteDbState()).isNull();
   }
 
   @Test
-  public void applyDeltaToMetaId() {
+  public void applyDeltaToMetaId() throws Exception {
     Change c = newChange();
     applyDelta(c, Delta.create(c.getId(), metaId(SHA1), noDrafts()));
     assertThat(c.getNoteDbState()).isEqualTo(SHA1.name());
@@ -104,37 +152,73 @@
   }
 
   @Test
-  public void applyDeltaToDrafts() {
+  public void applyDeltaToDrafts() throws Exception {
     Change c = newChange();
-    applyDelta(c, Delta.create(c.getId(), metaId(SHA1),
-          drafts(new Account.Id(1001), SHA2)));
-    assertThat(c.getNoteDbState()).isEqualTo(
-        SHA1.name() + ",1001=" + SHA2.name());
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), drafts(new Account.Id(1001), SHA2)));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name() + ",1001=" + SHA2.name());
 
-    applyDelta(c, Delta.create(c.getId(), noMetaId(),
-          drafts(new Account.Id(2003), SHA3)));
-    assertThat(c.getNoteDbState()).isEqualTo(
-        SHA1.name() + ",1001=" + SHA2.name() + ",2003=" + SHA3.name());
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(2003), SHA3)));
+    assertThat(c.getNoteDbState())
+        .isEqualTo(SHA1.name() + ",1001=" + SHA2.name() + ",2003=" + SHA3.name());
 
-    applyDelta(c, Delta.create(c.getId(), noMetaId(),
-          drafts(new Account.Id(2003), zeroId())));
-    assertThat(c.getNoteDbState()).isEqualTo(
-        SHA1.name() + ",1001=" + SHA2.name());
+    applyDelta(c, Delta.create(c.getId(), noMetaId(), drafts(new Account.Id(2003), zeroId())));
+    assertThat(c.getNoteDbState()).isEqualTo(SHA1.name() + ",1001=" + SHA2.name());
 
     applyDelta(c, Delta.create(c.getId(), metaId(SHA3), noDrafts()));
-    assertThat(c.getNoteDbState()).isEqualTo(
-        SHA3.name() + ",1001=" + SHA2.name());
+    assertThat(c.getNoteDbState()).isEqualTo(SHA3.name() + ",1001=" + SHA2.name());
+  }
+
+  @Test
+  public void applyDeltaToReadOnly() throws Exception {
+    Timestamp ts = nowTs();
+    Change c = newChange();
+    NoteDbChangeState state =
+        new NoteDbChangeState(
+            c.getId(),
+            REVIEW_DB,
+            Optional.of(RefState.create(SHA1, ImmutableMap.of())),
+            Optional.of(new Timestamp(ts.getTime() + 10000)));
+    c.setNoteDbState(state.toString());
+    Delta delta = Delta.create(c.getId(), metaId(SHA2), noDrafts());
+    applyDelta(c, delta);
+    assertThat(NoteDbChangeState.parse(c))
+        .isEqualTo(
+            new NoteDbChangeState(
+                state.getChangeId(),
+                state.getPrimaryStorage(),
+                Optional.of(RefState.create(SHA2, ImmutableMap.of())),
+                state.getReadOnlyUntil()));
+  }
+
+  @Test
+  public void parseNoteDbPrimary() {
+    NoteDbChangeState state = parse(new Change.Id(1), "N");
+    assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
+    assertThat(state.getRefState().isPresent()).isFalse();
+    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void parseInvalidPrimaryStorage() {
+    parse(new Change.Id(1), "X");
+  }
+
+  @Test
+  public void applyDeltaToNoteDbPrimaryIsNoOp() throws Exception {
+    Change c = newChange();
+    c.setNoteDbState("N");
+    applyDelta(c, Delta.create(c.getId(), metaId(SHA1), drafts(new Account.Id(1001), SHA2)));
+    assertThat(c.getNoteDbState()).isEqualTo("N");
   }
 
   private static Change newChange() {
-    return TestChanges.newChange(
-        new Project.NameKey("project"), new Account.Id(12345));
+    return TestChanges.newChange(new Project.NameKey("project"), new Account.Id(12345));
   }
 
   // Static factory methods to avoid type arguments when using as method args.
 
   private static Optional<ObjectId> noMetaId() {
-    return Optional.absent();
+    return Optional.empty();
   }
 
   private static Optional<ObjectId> metaId(ObjectId id) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
index cab6549..df3e405 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -19,17 +19,19 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
+import com.github.rholder.retry.BlockStrategy;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gwtorm.server.OrmException;
-
-import com.github.rholder.retry.BlockStrategy;
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -42,22 +44,19 @@
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
 
-import java.io.IOException;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
-
 public class RepoSequenceTest {
   private static final Retryer<RefUpdate.Result> RETRYER =
-      RepoSequence.retryerBuilder().withBlockStrategy(new BlockStrategy() {
-        @Override
-        public void block(long sleepTime) {
-          // Don't sleep in tests.
-        }
-      }).build();
+      RepoSequence.retryerBuilder()
+          .withBlockStrategy(
+              new BlockStrategy() {
+                @Override
+                public void block(long sleepTime) {
+                  // Don't sleep in tests.
+                }
+              })
+          .build();
 
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+  @Rule public ExpectedException exception = ExpectedException.none();
 
   private InMemoryRepositoryManager repoManager;
   private Project.NameKey project;
@@ -79,8 +78,7 @@
         try {
           assertThat(s.next()).named("i=" + i + " for " + name).isEqualTo(i);
         } catch (OrmException e) {
-          throw new AssertionError(
-              "failed batchSize=" + batchSize + ", i=" + i, e);
+          throw new AssertionError("failed batchSize=" + batchSize + ", i=" + i, e);
         }
       }
       assertThat(s.acquireCount)
@@ -162,14 +160,15 @@
     writeBlob("id", "1");
 
     final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
-    Runnable bgUpdate = new Runnable() {
-      @Override
-      public void run() {
-        if (!doneBgUpdate.getAndSet(true)) {
-          writeBlob("id", "1234");
-        }
-      }
-    };
+    Runnable bgUpdate =
+        new Runnable() {
+          @Override
+          public void run() {
+            if (!doneBgUpdate.getAndSet(true)) {
+              writeBlob("id", "1234");
+            }
+          }
+        };
 
     RepoSequence s = newSequence("id", 1, 10, bgUpdate, RETRYER);
     assertThat(doneBgUpdate.get()).isFalse();
@@ -183,8 +182,7 @@
   public void failOnInvalidValue() throws Exception {
     ObjectId id = writeBlob("id", "not a number");
     exception.expect(OrmException.class);
-    exception.expectMessage(
-        "invalid value in refs/sequences/id blob at " + id.name());
+    exception.expectMessage("invalid value in refs/sequences/id blob at " + id.name());
     newSequence("id", 1, 3).next();
   }
 
@@ -198,8 +196,7 @@
         fail();
       } catch (OrmException e) {
         assertThat(e.getCause()).isInstanceOf(ExecutionException.class);
-        assertThat(e.getCause().getCause())
-            .isInstanceOf(IncorrectObjectTypeException.class);
+        assertThat(e.getCause().getCause()).isInstanceOf(IncorrectObjectTypeException.class);
       }
     }
   }
@@ -207,17 +204,22 @@
   @Test
   public void failAfterRetryerGivesUp() throws Exception {
     final AtomicInteger bgCounter = new AtomicInteger(1234);
-    Runnable bgUpdate = new Runnable() {
-      @Override
-      public void run() {
-        writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000)));
-      }
-    };
-    RepoSequence s = newSequence(
-        "id", 1, 10, bgUpdate,
-        RetryerBuilder.<RefUpdate.Result> newBuilder()
-          .withStopStrategy(StopStrategies.stopAfterAttempt(3))
-          .build());
+    Runnable bgUpdate =
+        new Runnable() {
+          @Override
+          public void run() {
+            writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000)));
+          }
+        };
+    RepoSequence s =
+        newSequence(
+            "id",
+            1,
+            10,
+            bgUpdate,
+            RetryerBuilder.<RefUpdate.Result>newBuilder()
+                .withStopStrategy(StopStrategies.stopAfterAttempt(3))
+                .build());
     exception.expect(OrmException.class);
     exception.expectMessage("failed to update refs/sequences/id: LOCK_FAILURE");
     s.next();
@@ -270,12 +272,15 @@
   }
 
   private RepoSequence newSequence(String name, int start, int batchSize) {
-    return newSequence(
-        name, start, batchSize, Runnables.doNothing(), RETRYER);
+    return newSequence(name, start, batchSize, Runnables.doNothing(), RETRYER);
   }
 
-  private RepoSequence newSequence(String name, final int start, int batchSize,
-      Runnable afterReadRef, Retryer<RefUpdate.Result> retryer) {
+  private RepoSequence newSequence(
+      String name,
+      final int start,
+      int batchSize,
+      Runnable afterReadRef,
+      Retryer<RefUpdate.Result> retryer) {
     return new RepoSequence(
         repoManager,
         project,
@@ -299,8 +304,7 @@
       ins.flush();
       RefUpdate ru = repo.updateRef(refName);
       ru.setNewObjectId(newId);
-      assertThat(ru.forceUpdate())
-          .isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED);
+      assertThat(ru.forceUpdate()).isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED);
       return newId;
     } catch (IOException e) {
       throw new RuntimeException(e);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
new file mode 100644
index 0000000..1de82b1
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
@@ -0,0 +1,231 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Collections2;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.testutil.TestTimeUtil;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+import org.junit.Before;
+import org.junit.Test;
+
+public class EventSorterTest {
+  private class TestEvent extends Event {
+    protected TestEvent(Timestamp when) {
+      super(
+          new PatchSet.Id(new Change.Id(1), 1),
+          new Account.Id(1000),
+          new Account.Id(1000),
+          when,
+          changeCreatedOn,
+          null);
+    }
+
+    @Override
+    boolean uniquePerUpdate() {
+      return false;
+    }
+
+    @Override
+    void apply(ChangeUpdate update) {
+      throw new UnsupportedOperationException();
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public String toString() {
+      return "E{" + when.getSeconds() + '}';
+    }
+  }
+
+  private Timestamp changeCreatedOn;
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(10, TimeUnit.SECONDS);
+    changeCreatedOn = TimeUtil.nowTs();
+  }
+
+  @Test
+  public void naturalSort() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+
+    for (List<Event> events : Collections2.permutations(events(e1, e2, e3))) {
+      assertSorted(events, events(e1, e2, e3));
+    }
+  }
+
+  @Test
+  public void topoSortOneDep() {
+    List<Event> es;
+
+    // Input list is 0,1,2
+
+    // 0 depends on 1 => 1,0,2
+    es = threeEventsOneDep(0, 1);
+    assertSorted(es, events(es, 1, 0, 2));
+
+    // 1 depends on 0 => 0,1,2
+    es = threeEventsOneDep(1, 0);
+    assertSorted(es, events(es, 0, 1, 2));
+
+    // 0 depends on 2 => 1,2,0
+    es = threeEventsOneDep(0, 2);
+    assertSorted(es, events(es, 1, 2, 0));
+
+    // 2 depends on 0 => 0,1,2
+    es = threeEventsOneDep(2, 0);
+    assertSorted(es, events(es, 0, 1, 2));
+
+    // 1 depends on 2 => 0,2,1
+    es = threeEventsOneDep(1, 2);
+    assertSorted(es, events(es, 0, 2, 1));
+
+    // 2 depends on 1 => 0,1,2
+    es = threeEventsOneDep(2, 1);
+    assertSorted(es, events(es, 0, 1, 2));
+  }
+
+  private List<Event> threeEventsOneDep(int depFromIdx, int depOnIdx) {
+    List<Event> events =
+        Lists.newArrayList(
+            new TestEvent(TimeUtil.nowTs()),
+            new TestEvent(TimeUtil.nowTs()),
+            new TestEvent(TimeUtil.nowTs()));
+    events.get(depFromIdx).addDep(events.get(depOnIdx));
+    return events;
+  }
+
+  @Test
+  public void lastEventDependsOnFirstEvent() {
+    List<Event> events = new ArrayList<>();
+    for (int i = 0; i < 20; i++) {
+      events.add(new TestEvent(TimeUtil.nowTs()));
+    }
+    events.get(events.size() - 1).addDep(events.get(0));
+    assertSorted(events, events);
+  }
+
+  @Test
+  public void firstEventDependsOnLastEvent() {
+    List<Event> events = new ArrayList<>();
+    for (int i = 0; i < 20; i++) {
+      events.add(new TestEvent(TimeUtil.nowTs()));
+    }
+    events.get(0).addDep(events.get(events.size() - 1));
+
+    List<Event> expected = new ArrayList<>();
+    expected.addAll(events.subList(1, events.size()));
+    expected.add(events.get(0));
+    assertSorted(events, expected);
+  }
+
+  @Test
+  public void topoSortChainOfDeps() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e2);
+    e2.addDep(e3);
+    e3.addDep(e4);
+
+    assertSorted(events(e1, e2, e3, e4), events(e4, e3, e2, e1));
+  }
+
+  @Test
+  public void topoSortMultipleDeps() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e2);
+    e1.addDep(e4);
+    e2.addDep(e3);
+
+    // Processing 3 pops 2, processing 4 pops 1.
+    assertSorted(events(e2, e3, e1, e4), events(e3, e2, e4, e1));
+  }
+
+  @Test
+  public void topoSortMultipleDepsPreservesNaturalOrder() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    Event e4 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e4);
+    e2.addDep(e4);
+    e3.addDep(e4);
+
+    // Processing 4 pops 1, 2, 3 in natural order.
+    assertSorted(events(e4, e3, e2, e1), events(e4, e1, e2, e3));
+  }
+
+  @Test
+  public void topoSortCycle() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+
+    // Implementation is not really defined, but infinite looping would be bad.
+    // According to current implementation details, 2 pops 1, 1 pops 2 which was
+    // already seen.
+    assertSorted(events(e2, e1), events(e1, e2));
+  }
+
+  @Test
+  public void topoSortDepNotInInputList() {
+    Event e1 = new TestEvent(TimeUtil.nowTs());
+    Event e2 = new TestEvent(TimeUtil.nowTs());
+    Event e3 = new TestEvent(TimeUtil.nowTs());
+    e1.addDep(e3);
+
+    List<Event> events = events(e2, e1);
+    try {
+      new EventSorter(events).sort();
+      fail("expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
+  }
+
+  private static List<Event> events(Event... es) {
+    return Lists.newArrayList(es);
+  }
+
+  private static List<Event> events(List<Event> in, Integer... indexes) {
+    return Stream.of(indexes).map(in::get).collect(toList());
+  }
+
+  private static void assertSorted(List<Event> unsorted, List<Event> expected) {
+    List<Event> actual = new ArrayList<>(unsorted);
+    new EventSorter(actual).sort();
+    assertThat(actual).named("sorted" + unsorted).isEqualTo(expected);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
index eda2b82..4411799 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -17,67 +17,56 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import java.util.List;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.EditList;
 import org.eclipse.jgit.diff.ReplaceEdit;
 import org.junit.Test;
 
-import java.util.List;
-
 public class IntraLineLoaderTest {
 
   @Test
   public void rewriteAtStartOfLineIsRecognized() throws Exception {
     String a = "abc1\n";
     String b = "def1\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .replace("abc", "def").common("1\n").edits
-    );
+    assertThat(intraline(a, b)).isEqualTo(ref().replace("abc", "def").common("1\n").edits);
   }
 
   @Test
   public void rewriteAtEndOfLineIsRecognized() throws Exception {
     String a = "abc1\n";
     String b = "abc2\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .common("abc").replace("1", "2").common("\n").edits
-    );
+    assertThat(intraline(a, b)).isEqualTo(ref().common("abc").replace("1", "2").common("\n").edits);
   }
 
   @Test
   public void completeRewriteIncludesNewline() throws Exception {
     String a = "abc1\n";
     String b = "def2\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .replace("abc1\n", "def2\n").edits
-    );
+    assertThat(intraline(a, b)).isEqualTo(ref().replace("abc1\n", "def2\n").edits);
   }
 
   @Test
   public void closeEditsAreCombined() throws Exception {
     String a = "ab1cdef2gh\n";
     String b = "ab2cdef3gh\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .common("ab").replace("1cdef2", "2cdef3").common("gh\n").edits
-    );
+    assertThat(intraline(a, b))
+        .isEqualTo(ref().common("ab").replace("1cdef2", "2cdef3").common("gh\n").edits);
   }
 
   @Test
   public void preferInsertAfterCommonPart1() throws Exception {
     String a = "start middle end\n";
     String b = "start middlemiddle end\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .common("start middle").insert("middle").common(" end\n").edits
-    );
+    assertThat(intraline(a, b))
+        .isEqualTo(ref().common("start middle").insert("middle").common(" end\n").edits);
   }
 
   @Test
   public void preferInsertAfterCommonPart2() throws Exception {
     String a = "abc def\n";
     String b = "abc  def\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .common("abc ").insert(" ").common("def\n").edits
-    );
+    assertThat(intraline(a, b)).isEqualTo(ref().common("abc ").insert(" ").common("def\n").edits);
   }
 
   @Test
@@ -86,45 +75,40 @@
     String b = "multi\nlinemulti\nline\n";
     assertThat(intraline(a, b)).isEqualTo(wordEdit(10, 10, 6, 16));
     // better would be:
-    //assertThat(intraline(a, b)).isEqualTo(wordEdit(6, 6, 6, 16));
+    // assertThat(intraline(a, b)).isEqualTo(wordEdit(6, 6, 6, 16));
     // or the equivalent:
-    //assertThat(intraline(a, b)).isEqualTo(ref()
+    // assertThat(intraline(a, b)).isEqualTo(ref()
     //    .common("multi\n").insert("linemulti\n").common("line\n").edits
-    //);
+    // );
   }
 
-  //TODO: expected failure
+  // TODO: expected failure
   // the current code does not work on the first line
   // and the insert marker is in the wrong location
   @Test(expected = AssertionError.class)
   public void preferInsertAtLineBreak2() throws Exception {
     String a = "  abc\n    def\n";
     String b = "    abc\n      def\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .insert("  ").common("  abc\n")
-        .insert("  ").common("  def\n").edits
-    );
+    assertThat(intraline(a, b))
+        .isEqualTo(ref().insert("  ").common("  abc\n").insert("  ").common("  def\n").edits);
   }
 
-  //TODO: expected failure
+  // TODO: expected failure
   // the current code does not work on the first line
   @Test(expected = AssertionError.class)
   public void preferDeleteAtLineBreak() throws Exception {
     String a = "    abc\n      def\n";
     String b = "  abc\n    def\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .remove("  ").common("  abc\n")
-        .remove("  ").common("  def\n").edits
-    );
+    assertThat(intraline(a, b))
+        .isEqualTo(ref().remove("  ").common("  abc\n").remove("  ").common("  def\n").edits);
   }
 
   @Test
   public void insertedWhitespaceIsRecognized() throws Exception {
     String a = " int *foobar\n";
     String b = " int * foobar\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .common(" int *").insert(" ").common("foobar\n").edits
-    );
+    assertThat(intraline(a, b))
+        .isEqualTo(ref().common(" int *").insert(" ").common("foobar\n").edits);
   }
 
   @Test
@@ -132,10 +116,16 @@
     //         |0    5   10  |  5   20    5   30
     String a = " int *foobar\n int *foobar\n";
     String b = " int * foobar\n int * foobar\n";
-    assertThat(intraline(a, b)).isEqualTo(ref()
-        .common(" int *").insert(" ").common("foobar\n")
-        .common(" int *").insert(" ").common("foobar\n").edits
-    );
+    assertThat(intraline(a, b))
+        .isEqualTo(
+            ref()
+                .common(" int *")
+                .insert(" ")
+                .common("foobar\n")
+                .common(" int *")
+                .insert(" ")
+                .common("foobar\n")
+                .edits);
   }
 
   // helper functions to call IntraLineLoader.compute
@@ -154,8 +144,7 @@
     return intraline(a, b, new Edit(0, countLines(a), 0, countLines(b)));
   }
 
-  private static List<Edit> intraline(String a, String b, Edit lines)
-      throws Exception {
+  private static List<Edit> intraline(String a, String b, Edit lines) throws Exception {
     Text aText = new Text(a.getBytes(UTF_8));
     Text bText = new Text(b.getBytes(UTF_8));
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListEntryTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListEntryTest.java
index bff557c..81f03af 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListEntryTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListEntryTest.java
@@ -20,12 +20,11 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.gerrit.reviewdb.client.Patch;
-
 import org.junit.Test;
 
 public class PatchListEntryTest {
   @Test
-  public void testEmpty1() {
+  public void empty1() {
     final String name = "empty-file";
     final PatchListEntry e = PatchListEntry.empty(name);
     assertNull(e.getOldName());
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
new file mode 100644
index 0000000..19adf32
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Patch;
+import java.util.Arrays;
+import java.util.Comparator;
+import org.junit.Test;
+
+public class PatchListTest {
+  @Test
+  public void fileOrder() {
+    String[] names = {
+      "zzz", "def/g", "/!xxx", "abc", Patch.MERGE_LIST, "qrx", Patch.COMMIT_MSG,
+    };
+    String[] want = {
+      Patch.COMMIT_MSG, Patch.MERGE_LIST, "/!xxx", "abc", "def/g", "qrx", "zzz",
+    };
+
+    Arrays.sort(
+        names,
+        0,
+        names.length,
+        new Comparator<String>() {
+          @Override
+          public int compare(String o1, String o2) {
+            return PatchList.comparePaths(o1, o2);
+          }
+        });
+    assertThat(names).isEqualTo(want);
+  }
+
+  @Test
+  public void fileOrderNoMerge() {
+    String[] names = {
+      "zzz", "def/g", "/!xxx", "abc", "qrx", Patch.COMMIT_MSG,
+    };
+    String[] want = {
+      Patch.COMMIT_MSG, "/!xxx", "abc", "def/g", "qrx", "zzz",
+    };
+
+    Arrays.sort(
+        names,
+        0,
+        names.length,
+        new Comparator<String>() {
+          @Override
+          public int compare(String o1, String o2) {
+            return PatchList.comparePaths(o1, o2);
+          }
+        });
+    assertThat(names).isEqualTo(want);
+  }
+}
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 79983f9..92d7a52 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
@@ -45,7 +45,6 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
-
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -94,8 +93,7 @@
     admins = groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID();
     setUpPermissions();
 
-    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user"))
-        .getAccountId();
+    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     user = userFactory.create(userId);
 
     Project.NameKey name = new Project.NameKey("project");
@@ -104,17 +102,18 @@
     project.load(inMemoryRepo);
     repo = new TestRepository<>(inMemoryRepo);
 
-    requestContext.setContext(new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return user;
-      }
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
 
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    });
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
   }
 
   @After
@@ -235,15 +234,13 @@
     return projectControlFactory.controlFor(project.getName(), user);
   }
 
-  protected void allow(ProjectConfig project, String permission,
-      AccountGroup.UUID id, String ref)
+  protected void allow(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
       throws Exception {
     Util.allow(project, permission, id, ref);
     saveProjectConfig(project);
   }
 
-  protected void deny(ProjectConfig project, String permission,
-      AccountGroup.UUID id, String ref)
+  protected void deny(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
       throws Exception {
     Util.deny(project, permission, id, ref);
     saveProjectConfig(project);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index c04c474..0c3d4c2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -57,6 +57,9 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -68,14 +71,6 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -83,6 +78,12 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
 
 public class RefControlTest {
   private void assertAdminsAreOwnersAndDevsAreNot() {
@@ -94,10 +95,9 @@
   }
 
   private void assertOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner())
-      .named("OWN " + ref)
-      .isTrue();
+    assertThat(u.controlForRef(ref).isOwner()).named("OWN " + ref).isTrue();
   }
+
   private void assertNotOwner(ProjectControl u) {
     assertThat(u.isOwner()).named("not owner").isFalse();
   }
@@ -107,127 +107,97 @@
   }
 
   private void assertNotOwner(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isOwner())
-      .named("NOT OWN " + ref)
-      .isFalse();
+    assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
   }
 
   private void assertCanRead(ProjectControl u) {
-    assertThat(u.isVisible())
-      .named("can read")
-      .isTrue();
+    assertThat(u.isVisible()).named("can read").isTrue();
+  }
+
+  private void assertAllRefsAreVisible(ProjectControl u) {
+    assertThat(u.allRefsAreVisible()).named("all refs visible").isTrue();
+  }
+
+  private void assertAllRefsAreNotVisible(ProjectControl u) {
+    assertThat(u.allRefsAreVisible()).named("all refs NOT visible").isFalse();
   }
 
   private void assertCannotRead(ProjectControl u) {
-    assertThat(u.isVisible())
-      .named("cannot read")
-      .isFalse();
+    assertThat(u.isVisible()).named("cannot read").isFalse();
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible())
-      .named("can read " + ref)
-      .isTrue();
+    assertThat(u.controlForRef(ref).isVisible()).named("can read " + ref).isTrue();
   }
 
   private void assertCannotRead(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isVisible())
-      .named("cannot read " + ref)
-      .isFalse();
+    assertThat(u.controlForRef(ref).isVisible()).named("cannot read " + ref).isFalse();
   }
 
   private void assertCanSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit())
-      .named("can submit " + ref)
-      .isTrue();
+    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isTrue();
   }
 
   private void assertCannotSubmit(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canSubmit())
-      .named("can submit " + ref)
-      .isFalse();
+    assertThat(u.controlForRef(ref).canSubmit(false)).named("can submit " + ref).isFalse();
   }
 
   private void assertCanUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef())
-      .named("can upload")
-      .isEqualTo(Capable.OK);
+    assertThat(u.canPushToAtLeastOneRef()).named("can upload").isEqualTo(Capable.OK);
   }
 
   private void assertCanUpload(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpload())
-      .named("can upload " + ref)
-      .isTrue();
+    assertThat(u.controlForRef(ref).canUpload()).named("can upload " + ref).isTrue();
   }
 
   private void assertCannotUpload(ProjectControl u) {
-    assertThat(u.canPushToAtLeastOneRef())
-      .named("cannot upload")
-      .isNotEqualTo(Capable.OK);
+    assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isNotEqualTo(Capable.OK);
   }
 
   private void assertCannotUpload(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpload())
-      .named("cannot upload " + ref)
-      .isFalse();
+    assertThat(u.controlForRef(ref).canUpload()).named("cannot upload " + ref).isFalse();
   }
 
   private void assertBlocked(String p, String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isBlocked(p))
-      .named(p + " is blocked for " + ref)
-      .isTrue();
+    assertThat(u.controlForRef(ref).isBlocked(p)).named(p + " is blocked for " + ref).isTrue();
   }
 
   private void assertNotBlocked(String p, String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).isBlocked(p))
-      .named(p + " is blocked for " + ref)
-      .isFalse();
+    assertThat(u.controlForRef(ref).isBlocked(p)).named(p + " is blocked for " + ref).isFalse();
   }
 
   private void assertCanUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpdate())
-      .named("can update " + ref)
-      .isTrue();
+    assertThat(u.controlForRef(ref).canUpdate()).named("can update " + ref).isTrue();
   }
 
   private void assertCannotUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpdate())
-      .named("cannot update " + ref)
-      .isFalse();
+    assertThat(u.controlForRef(ref).canUpdate()).named("cannot update " + ref).isFalse();
   }
 
   private void assertCanForceUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canForceUpdate())
-      .named("can force push " + ref)
-      .isTrue();
+    assertThat(u.controlForRef(ref).canForceUpdate()).named("can force push " + ref).isTrue();
   }
 
   private void assertCannotForceUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canForceUpdate())
-      .named("cannot force push " + ref)
-      .isFalse();
+    assertThat(u.controlForRef(ref).canForceUpdate()).named("cannot force push " + ref).isFalse();
   }
 
   private void assertCanVote(int score, PermissionRange range) {
-    assertThat(range.contains(score))
-      .named("can vote " + score)
-      .isTrue();
+    assertThat(range.contains(score)).named("can vote " + score).isTrue();
   }
 
   private void assertCannotVote(int score, PermissionRange range) {
-    assertThat(range.contains(score))
-      .named("cannot vote " + score)
-      .isFalse();
+    assertThat(range.contains(score)).named("cannot vote " + score).isFalse();
   }
 
   private final AllProjectsName allProjectsName =
       new AllProjectsName(AllProjectsNameProvider.DEFAULT);
-  private final AllUsersName allUsersName =
-      new AllUsersName(AllUsersNameProvider.DEFAULT);
+  private final AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
   private final AccountGroup.UUID fixers = new AccountGroup.UUID("test.fixers");
   private final Map<Project.NameKey, ProjectState> all = new HashMap<>();
   private Project.NameKey localKey = new Project.NameKey("local");
   private ProjectConfig local;
+  private ProjectConfig allUsers;
   private Project.NameKey parentKey = new Project.NameKey("parent");
   private ProjectConfig parent;
   private InMemoryRepositoryManager repoManager;
@@ -239,86 +209,95 @@
   @Inject private CapabilityCollection.Factory capabilityCollectionFactory;
   @Inject private CapabilityControl.Factory capabilityControlFactory;
   @Inject private SchemaCreator schemaCreator;
+  @Inject private SingleVersionListener singleVersionListener;
   @Inject private InMemoryDatabase schemaFactory;
   @Inject private ThreadLocalRequestContext requestContext;
+  @Inject private Provider<InternalChangeQuery> queryProvider;
+  @Inject private ProjectControl.Metrics metrics;
+  @Inject private TransferConfig transferConfig;
 
   @Before
   public void setUp() throws Exception {
     repoManager = new InMemoryRepositoryManager();
-    projectCache = new ProjectCache() {
-      @Override
-      public ProjectState getAllProjects() {
-        return get(allProjectsName);
-      }
+    projectCache =
+        new ProjectCache() {
+          @Override
+          public ProjectState getAllProjects() {
+            return get(allProjectsName);
+          }
 
-      @Override
-      public ProjectState getAllUsers() {
-        return null;
-      }
+          @Override
+          public ProjectState getAllUsers() {
+            return get(allUsersName);
+          }
 
-      @Override
-      public ProjectState get(Project.NameKey projectName) {
-        return all.get(projectName);
-      }
+          @Override
+          public ProjectState get(Project.NameKey projectName) {
+            return all.get(projectName);
+          }
 
-      @Override
-      public void evict(Project p) {
-      }
+          @Override
+          public void evict(Project p) {}
 
-      @Override
-      public void remove(Project p) {
-      }
+          @Override
+          public void remove(Project p) {}
 
-      @Override
-      public void remove(Project.NameKey name) {}
+          @Override
+          public void remove(Project.NameKey name) {}
 
-      @Override
-      public Iterable<Project.NameKey> all() {
-        return Collections.emptySet();
-      }
+          @Override
+          public Iterable<Project.NameKey> all() {
+            return Collections.emptySet();
+          }
 
-      @Override
-      public Iterable<Project.NameKey> byName(String prefix) {
-        return Collections.emptySet();
-      }
+          @Override
+          public Iterable<Project.NameKey> byName(String prefix) {
+            return Collections.emptySet();
+          }
 
-      @Override
-      public void onCreateProject(Project.NameKey newProjectName) {
-      }
+          @Override
+          public void onCreateProject(Project.NameKey newProjectName) {}
 
-      @Override
-      public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
-        return Collections.emptySet();
-      }
+          @Override
+          public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
+            return Collections.emptySet();
+          }
 
-      @Override
-      public ProjectState checkedGet(Project.NameKey projectName)
-          throws IOException {
-        return all.get(projectName);
-      }
+          @Override
+          public ProjectState checkedGet(Project.NameKey projectName) throws IOException {
+            return all.get(projectName);
+          }
 
-      @Override
-      public void evict(Project.NameKey p) {
-      }
-    };
+          @Override
+          public void evict(Project.NameKey p) {}
+        };
 
     Injector injector = Guice.createInjector(new InMemoryModule());
     injector.injectMembers(this);
 
     try {
       Repository repo = repoManager.createRepository(allProjectsName);
-      ProjectConfig allProjects =
-          new ProjectConfig(new Project.NameKey(allProjectsName.get()));
+      ProjectConfig allProjects = new ProjectConfig(new Project.NameKey(allProjectsName.get()));
       allProjects.load(repo);
       LabelType cr = Util.codeReview();
       allProjects.getLabelSections().put(cr.getName(), cr);
       add(allProjects);
+
+      Repository allUsersRepo = repoManager.createRepository(allUsersName);
+      allUsers = new ProjectConfig(new Project.NameKey(allUsersName.get()));
+      allUsers.load(allUsersRepo);
+      add(allUsers);
     } catch (IOException | ConfigInvalidException e) {
       throw new RuntimeException(e);
     }
 
     db = schemaFactory.open();
-    schemaCreator.create(db);
+    singleVersionListener.start();
+    try {
+      schemaCreator.create(db);
+    } finally {
+      singleVersionListener.stop();
+    }
 
     Cache<SectionSortCache.EntryKey, SectionSortCache.EntryVal> c =
         CacheBuilder.newBuilder().build();
@@ -333,17 +312,18 @@
     add(local);
     local.getProject().setParentName(parentKey);
 
-    requestContext.setContext(new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return null;
-      }
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return null;
+          }
 
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    });
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
 
     changeControlFactory = injector.getInstance(ChangeControl.Factory.class);
   }
@@ -358,14 +338,14 @@
   }
 
   @Test
-  public void testOwnerProject() {
+  public void ownerProject() {
     allow(local, OWNER, ADMIN, "refs/*");
 
     assertAdminsAreOwnersAndDevsAreNot();
   }
 
   @Test
-  public void testDenyOwnerProject() {
+  public void denyOwnerProject() {
     allow(local, OWNER, ADMIN, "refs/*");
     deny(local, OWNER, DEVS, "refs/*");
 
@@ -373,7 +353,7 @@
   }
 
   @Test
-  public void testBlockOwnerProject() {
+  public void blockOwnerProject() {
     allow(local, OWNER, ADMIN, "refs/*");
     block(local, OWNER, DEVS, "refs/*");
 
@@ -381,7 +361,25 @@
   }
 
   @Test
-  public void testBranchDelegation1() {
+  public void allRefsAreVisibleForRegularProject() throws Exception {
+    allow(local, READ, DEVS, "refs/*");
+    allow(local, READ, DEVS, "refs/groups/*");
+    allow(local, READ, DEVS, "refs/users/default");
+
+    assertAllRefsAreVisible(user(local, DEVS));
+  }
+
+  @Test
+  public void allRefsAreNotVisibleForAllUsers() throws Exception {
+    allow(allUsers, READ, DEVS, "refs/*");
+    allow(allUsers, READ, DEVS, "refs/groups/*");
+    allow(allUsers, READ, DEVS, "refs/users/default");
+
+    assertAllRefsAreNotVisible(user(allUsers, DEVS));
+  }
+
+  @Test
+  public void branchDelegation1() {
     allow(local, OWNER, ADMIN, "refs/*");
     allow(local, OWNER, DEVS, "refs/heads/x/*");
 
@@ -398,7 +396,7 @@
   }
 
   @Test
-  public void testBranchDelegation2() {
+  public void branchDelegation2() {
     allow(local, OWNER, ADMIN, "refs/*");
     allow(local, OWNER, DEVS, "refs/heads/x/*");
     allow(local, OWNER, fixers, "refs/heads/x/y/*");
@@ -427,7 +425,7 @@
   }
 
   @Test
-  public void testInheritRead_SingleBranchDeniesUpload() {
+  public void inheritRead_SingleBranchDeniesUpload() {
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
@@ -441,7 +439,7 @@
   }
 
   @Test
-  public void testBlockPushDrafts() {
+  public void blockPushDrafts() {
     allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
     block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
 
@@ -451,7 +449,7 @@
   }
 
   @Test
-  public void testBlockPushDraftsUnblockAdmin() {
+  public void blockPushDraftsUnblockAdmin() {
     block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
     allow(parent, PUSH, ADMIN, "refs/drafts/*");
 
@@ -462,7 +460,7 @@
   }
 
   @Test
-  public void testInheritRead_SingleBranchDoesNotOverrideInherited() {
+  public void inheritRead_SingleBranchDoesNotOverrideInherited() {
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     allow(parent, PUSH, REGISTERED_USERS, "refs/for/refs/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/foobar");
@@ -474,7 +472,7 @@
   }
 
   @Test
-  public void testInheritDuplicateSections() throws Exception {
+  public void inheritDuplicateSections() throws Exception {
     allow(parent, READ, ADMIN, "refs/*");
     allow(local, READ, DEVS, "refs/heads/*");
     assertCanRead(user(local, "a", ADMIN));
@@ -487,7 +485,7 @@
   }
 
   @Test
-  public void testInheritRead_OverrideWithDeny() {
+  public void inheritRead_OverrideWithDeny() {
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     deny(local, READ, REGISTERED_USERS, "refs/*");
 
@@ -495,7 +493,7 @@
   }
 
   @Test
-  public void testInheritRead_AppendWithDenyOfRef() {
+  public void inheritRead_AppendWithDenyOfRef() {
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     deny(local, READ, REGISTERED_USERS, "refs/heads/*");
 
@@ -507,7 +505,7 @@
   }
 
   @Test
-  public void testInheritRead_OverridesAndDeniesOfRef() {
+  public void inheritRead_OverridesAndDeniesOfRef() {
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     deny(local, READ, REGISTERED_USERS, "refs/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
@@ -520,7 +518,7 @@
   }
 
   @Test
-  public void testInheritSubmit_OverridesAndDeniesOfRef() {
+  public void inheritSubmit_OverridesAndDeniesOfRef() {
     allow(parent, SUBMIT, REGISTERED_USERS, "refs/*");
     deny(local, SUBMIT, REGISTERED_USERS, "refs/*");
     allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
@@ -532,7 +530,7 @@
   }
 
   @Test
-  public void testCannotUploadToAnyRef() {
+  public void cannotUploadToAnyRef() {
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     allow(local, READ, DEVS, "refs/heads/*");
     allow(local, PUSH, DEVS, "refs/for/refs/heads/*");
@@ -543,14 +541,14 @@
   }
 
   @Test
-  public void testUsernamePatternCanUploadToAnyRef() {
+  public void usernamePatternCanUploadToAnyRef() {
     allow(local, PUSH, REGISTERED_USERS, "refs/heads/users/${username}/*");
     ProjectControl u = user(local, "a-registered-user");
     assertCanUpload(u);
   }
 
   @Test
-  public void testUsernamePatternNonRegex() {
+  public void usernamePatternNonRegex() {
     allow(local, READ, DEVS, "refs/sb/${username}/heads/*");
 
     ProjectControl u = user(local, "u", DEVS);
@@ -560,7 +558,7 @@
   }
 
   @Test
-  public void testUsernamePatternWithRegex() {
+  public void usernamePatternWithRegex() {
     allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
 
     ProjectControl u = user(local, "d.v", DEVS);
@@ -570,7 +568,7 @@
   }
 
   @Test
-  public void testUsernameEmailPatternWithRegex() {
+  public void usernameEmailPatternWithRegex() {
     allow(local, READ, DEVS, "^refs/sb/${username}/heads/.*");
 
     ProjectControl u = user(local, "d.v@ger-rit.org", DEVS);
@@ -580,7 +578,7 @@
   }
 
   @Test
-  public void testSortWithRegex() {
+  public void sortWithRegex() {
     allow(local, READ, DEVS, "^refs/heads/.*");
     allow(parent, READ, ANONYMOUS_USERS, "^refs/heads/.*-QA-.*");
 
@@ -591,7 +589,7 @@
   }
 
   @Test
-  public void testBlockRule_ParentBlocksChild() {
+  public void blockRule_ParentBlocksChild() {
     allow(local, PUSH, DEVS, "refs/tags/*");
     block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
     ProjectControl u = user(local, DEVS);
@@ -599,7 +597,7 @@
   }
 
   @Test
-  public void testBlockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
+  public void blockRule_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
     allow(local, PUSH, DEVS, "refs/tags/*");
     block(local, PUSH, ANONYMOUS_USERS, "refs/tags/*");
     block(parent, PUSH, ANONYMOUS_USERS, "refs/tags/*");
@@ -609,7 +607,7 @@
   }
 
   @Test
-  public void testBlockLabelRange_ParentBlocksChild() {
+  public void blockLabelRange_ParentBlocksChild() {
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
     block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
@@ -623,16 +621,14 @@
   }
 
   @Test
-  public void testBlockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
+  public void blockLabelRange_ParentBlocksChildEvenIfAlreadyBlockedInChild() {
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
     block(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
-    block(parent, LABEL + "Code-Review", -2, +2, DEVS,
-        "refs/heads/*");
+    block(parent, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
     ProjectControl u = user(local, DEVS);
 
-    PermissionRange range =
-        u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCanVote(-1, range);
     assertCanVote(1, range);
     assertCannotVote(-2, range);
@@ -640,7 +636,7 @@
   }
 
   @Test
-  public void testInheritSubmit_AllowInChildDoesntAffectUnblockInParent() {
+  public void inheritSubmit_AllowInChildDoesntAffectUnblockInParent() {
     block(parent, SUBMIT, ANONYMOUS_USERS, "refs/heads/*");
     allow(parent, SUBMIT, REGISTERED_USERS, "refs/heads/*");
     allow(local, SUBMIT, REGISTERED_USERS, "refs/heads/*");
@@ -650,7 +646,7 @@
   }
 
   @Test
-  public void testUnblockNoForce() {
+  public void unblockNoForce() {
     block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, PUSH, DEVS, "refs/heads/*");
 
@@ -659,7 +655,7 @@
   }
 
   @Test
-  public void testUnblockForce() {
+  public void unblockForce() {
     PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     r.setForce(true);
     allow(local, PUSH, DEVS, "refs/heads/*").setForce(true);
@@ -669,7 +665,7 @@
   }
 
   @Test
-  public void testUnblockForceWithAllowNoForce_NotPossible() {
+  public void unblockForceWithAllowNoForce_NotPossible() {
     PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     r.setForce(true);
     allow(local, PUSH, DEVS, "refs/heads/*");
@@ -679,7 +675,7 @@
   }
 
   @Test
-  public void testUnblockMoreSpecificRef_Fails() {
+  public void unblockMoreSpecificRef_Fails() {
     block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, PUSH, DEVS, "refs/heads/master");
 
@@ -688,7 +684,44 @@
   }
 
   @Test
-  public void testUnblockLargerScope_Fails() {
+  public void unblockMoreSpecificRefInLocal_Fails() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockMoreSpecificRefWithExclusiveFlag() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCanUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() {
+    block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
+    allow(local, PUSH, DEVS, "refs/heads/master");
+    allow(local, SUBMIT, DEVS, "refs/heads/master", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockLargerScope_Fails() {
     block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
     allow(local, PUSH, DEVS, "refs/heads/*");
 
@@ -697,7 +730,7 @@
   }
 
   @Test
-  public void testUnblockInLocal_Fails() {
+  public void unblockInLocal_Fails() {
     block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, PUSH, fixers, "refs/heads/*");
 
@@ -706,7 +739,7 @@
   }
 
   @Test
-  public void testUnblockInParentBlockInLocal() {
+  public void unblockInParentBlockInLocal() {
     block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(parent, PUSH, DEVS, "refs/heads/*");
     block(local, PUSH, DEVS, "refs/heads/*");
@@ -716,51 +749,51 @@
   }
 
   @Test
-  public void testUnblockVisibilityByRegisteredUsers() {
+  public void unblockVisibilityByRegisteredUsers() {
     block(local, READ, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local, REGISTERED_USERS);
     assertThat(u.controlForRef("refs/heads/master").isVisibleByRegisteredUsers())
-      .named("u can read")
-      .isTrue();
+        .named("u can read")
+        .isTrue();
   }
 
   @Test
-  public void testUnblockInLocalVisibilityByRegisteredUsers_Fails() {
+  public void unblockInLocalVisibilityByRegisteredUsers_Fails() {
     block(parent, READ, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local, REGISTERED_USERS);
     assertThat(u.controlForRef("refs/heads/master").isVisibleByRegisteredUsers())
-      .named("u can't read")
-      .isFalse();
+        .named("u can't read")
+        .isFalse();
   }
 
   @Test
-  public void testUnblockForceEditTopicName() {
+  public void unblockForceEditTopicName() {
     block(local, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
 
     ProjectControl u = user(local, DEVS);
     assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
-      .named("u can edit topic name")
-      .isTrue();
+        .named("u can edit topic name")
+        .isTrue();
   }
 
   @Test
-  public void testUnblockInLocalForceEditTopicName_Fails() {
+  public void unblockInLocalForceEditTopicName_Fails() {
     block(parent, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
 
     ProjectControl u = user(local, REGISTERED_USERS);
     assertThat(u.controlForRef("refs/heads/master").canForceEditTopicName())
-      .named("u can't edit topic name")
-      .isFalse();
+        .named("u can't edit topic name")
+        .isFalse();
   }
 
   @Test
-  public void testUnblockRange() {
+  public void unblockRange() {
     block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
@@ -771,7 +804,7 @@
   }
 
   @Test
-  public void testUnblockRangeOnMoreSpecificRef_Fails() {
+  public void unblockRangeOnMoreSpecificRef_Fails() {
     block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/master");
 
@@ -782,7 +815,7 @@
   }
 
   @Test
-  public void testUnblockRangeOnLargerScope_Fails() {
+  public void unblockRangeOnLargerScope_Fails() {
     block(local, LABEL + "Code-Review", -1, +1, ANONYMOUS_USERS, "refs/heads/master");
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
@@ -793,42 +826,47 @@
   }
 
   @Test
-  public void testUnblockInLocalRange_Fails() {
-    block(parent, LABEL + "Code-Review", -1, 1, ANONYMOUS_USERS,
-        "refs/heads/*");
+  public void unblockInLocalRange_Fails() {
+    block(parent, LABEL + "Code-Review", -1, 1, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, LABEL + "Code-Review", -2, +2, DEVS, "refs/heads/*");
 
     ProjectControl u = user(local, DEVS);
-    PermissionRange range =
-        u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void testUnblockRangeForChangeOwner() {
+  public void unblockRangeForChangeOwner() {
     allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
 
     ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master")
-        .getRange(LABEL + "Code-Review", true);
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
 
   @Test
-  public void testUnblockRangeForNotChangeOwner() {
+  public void unblockRangeForNotChangeOwner() {
     allow(local, LABEL + "Code-Review", -2, +2, CHANGE_OWNER, "refs/heads/*");
 
     ProjectControl u = user(local, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master")
-        .getRange(LABEL + "Code-Review");
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
 
   @Test
-  public void testValidateRefPatternsOK() throws Exception {
+  public void blockOwner() {
+    block(parent, OWNER, ANONYMOUS_USERS, "refs/*");
+    allow(local, OWNER, DEVS, "refs/*");
+
+    assertThat(user(local, DEVS).isOwner()).isFalse();
+  }
+
+  @Test
+  public void validateRefPatternsOK() throws Exception {
     RefPattern.validate("refs/*");
     RefPattern.validate("^refs/heads/*");
     RefPattern.validate("^refs/tags/[0-9a-zA-Z-_.]+");
@@ -842,12 +880,11 @@
 
   @Test(expected = InvalidNameException.class)
   public void testValidateBadRefPatternDanglingCharacter() throws Exception {
-    RefPattern
-        .validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
+    RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}*");
   }
 
   @Test
-  public void testValidateRefPatternNoDanglingCharacter() throws Exception {
+  public void validateRefPatternNoDanglingCharacter() throws Exception {
     RefPattern.validate("^refs/heads/tmp/sdk/[0-9]{3,3}_R[1-9][A-Z][0-9]{3,3}");
   }
 
@@ -867,26 +904,46 @@
     } catch (IOException | ConfigInvalidException e) {
       throw new RuntimeException(e);
     }
-    all.put(pc.getName(),
-        new ProjectState(sitePaths, projectCache, allProjectsName, allUsersName,
-            projectControlFactory, envFactory, repoManager, rulesCache,
-            commentLinks, capabilityCollectionFactory, pc));
+    all.put(
+        pc.getName(),
+        new ProjectState(
+            sitePaths,
+            projectCache,
+            allProjectsName,
+            allUsersName,
+            projectControlFactory,
+            envFactory,
+            repoManager,
+            rulesCache,
+            commentLinks,
+            capabilityCollectionFactory,
+            transferConfig,
+            pc));
     return repo;
   }
 
-  private ProjectControl user(ProjectConfig local,
-      AccountGroup.UUID... memberOf) {
+  private ProjectControl user(ProjectConfig local, AccountGroup.UUID... memberOf) {
     return user(local, null, memberOf);
   }
 
-  private ProjectControl user(ProjectConfig local, String name,
-      AccountGroup.UUID... memberOf) {
+  private ProjectControl user(ProjectConfig local, String name, AccountGroup.UUID... memberOf) {
     String canonicalWebUrl = "http://localhost";
 
-    return new ProjectControl(Collections.<AccountGroup.UUID> emptySet(),
-        Collections.<AccountGroup.UUID> emptySet(), projectCache,
-        sectionSorter, null, changeControlFactory, null, null,
-        canonicalWebUrl, new MockUser(name, memberOf), newProjectState(local));
+    return new ProjectControl(
+        Collections.<AccountGroup.UUID>emptySet(),
+        Collections.<AccountGroup.UUID>emptySet(),
+        projectCache,
+        sectionSorter,
+        null,
+        changeControlFactory,
+        null,
+        queryProvider,
+        null,
+        canonicalWebUrl,
+        allUsersName,
+        new MockUser(name, memberOf),
+        newProjectState(local),
+        metrics);
   }
 
   private ProjectState newProjectState(ProjectConfig local) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
index 772c778..5a72d5c 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.git.ProjectConfig;
-
 import java.util.Arrays;
 
 public class Util {
@@ -32,7 +31,8 @@
   public static final AccountGroup.UUID DEVS = new AccountGroup.UUID("test.devs");
 
   public static final LabelType codeReview() {
-    return category("Code-Review",
+    return category(
+        "Code-Review",
         value(2, "Looks good to me, approved"),
         value(1, "Looks good to me, but someone else must approve"),
         value(0, "No score"),
@@ -41,16 +41,12 @@
   }
 
   public static final LabelType verified() {
-    return category("Verified",
-        value(1, "Verified"),
-        value(0, "No score"),
-        value(-1, "Fails"));
+    return category("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
   }
 
   public static final LabelType patchSetLock() {
-    LabelType label = category("Patch-Set-Lock",
-        value(1, "Patch Set Locked"),
-        value(0, "Patch Set Unlocked"));
+    LabelType label =
+        category("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
     label.setFunctionName("PatchSetLock");
     return label;
   }
@@ -63,16 +59,19 @@
     return new LabelType(name, Arrays.asList(values));
   }
 
-  public static PermissionRule newRule(ProjectConfig project,
-      AccountGroup.UUID groupUUID) {
+  public static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
     GroupReference group = new GroupReference(groupUUID, groupUUID.get());
     group = project.resolve(group);
 
     return new PermissionRule(group);
   }
 
-  public static PermissionRule allow(ProjectConfig project,
-      String permissionName, int min, int max, AccountGroup.UUID group,
+  public static PermissionRule allow(
+      ProjectConfig project,
+      String permissionName,
+      int min,
+      int max,
+      AccountGroup.UUID group,
       String ref) {
     PermissionRule rule = newRule(project, group);
     rule.setMin(min);
@@ -80,8 +79,12 @@
     return grant(project, permissionName, rule, ref);
   }
 
-  public static PermissionRule block(ProjectConfig project,
-      String permissionName, int min, int max, AccountGroup.UUID group,
+  public static PermissionRule block(
+      ProjectConfig project,
+      String permissionName,
+      int min,
+      int max,
+      AccountGroup.UUID group,
       String ref) {
     PermissionRule rule = newRule(project, group);
     rule.setMin(min);
@@ -91,84 +94,120 @@
     return r;
   }
 
-  public static PermissionRule allow(ProjectConfig project,
-      String permissionName, AccountGroup.UUID group, String ref) {
+  public static PermissionRule allow(
+      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
     return grant(project, permissionName, newRule(project, group), ref);
   }
 
-  public static PermissionRule allow(ProjectConfig project,
-      String capabilityName, AccountGroup.UUID group) {
+  public static PermissionRule allow(
+      ProjectConfig project,
+      String permissionName,
+      AccountGroup.UUID group,
+      String ref,
+      boolean exclusive) {
+    return grant(project, permissionName, newRule(project, group), ref, exclusive);
+  }
+
+  public static PermissionRule allow(
+      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
     PermissionRule rule = newRule(project, group);
-    project.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+    project
+        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
         .getPermission(capabilityName, true)
         .add(rule);
-      if (GlobalCapability.hasRange(capabilityName)) {
-        PermissionRange.WithDefaults range =
-            GlobalCapability.getRange(capabilityName);
-        if (range != null) {
-          rule.setRange(range.getDefaultMin(), range.getDefaultMax());
-        }
+    if (GlobalCapability.hasRange(capabilityName)) {
+      PermissionRange.WithDefaults range = GlobalCapability.getRange(capabilityName);
+      if (range != null) {
+        rule.setRange(range.getDefaultMin(), range.getDefaultMax());
       }
+    }
     return rule;
   }
 
-  public static PermissionRule remove(ProjectConfig project,
-      String capabilityName, AccountGroup.UUID group) {
+  public static PermissionRule remove(
+      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
     PermissionRule rule = newRule(project, group);
-    project.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+    project
+        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
         .getPermission(capabilityName, true)
         .remove(rule);
     return rule;
   }
 
-  public static PermissionRule block(ProjectConfig project,
-      String capabilityName, AccountGroup.UUID group) {
+  public static PermissionRule remove(
+      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
     PermissionRule rule = newRule(project, group);
-    project.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
+    project.getAccessSection(ref, true).getPermission(permissionName, true).remove(rule);
+    return rule;
+  }
+
+  public static PermissionRule block(
+      ProjectConfig project, String capabilityName, AccountGroup.UUID group) {
+    PermissionRule rule = newRule(project, group);
+    project
+        .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
         .getPermission(capabilityName, true)
         .add(rule);
     return rule;
   }
 
-  public static PermissionRule block(ProjectConfig project,
-      String permissionName, AccountGroup.UUID group, String ref) {
+  public static PermissionRule block(
+      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
     PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
     r.setBlock();
     return r;
   }
 
-  public static PermissionRule blockLabel(ProjectConfig project,
-      String labelName, AccountGroup.UUID group, String ref) {
-    PermissionRule r =
-        grant(project, Permission.LABEL + labelName, newRule(project, group),
-            ref);
+  public static PermissionRule blockLabel(
+      ProjectConfig project, String labelName, AccountGroup.UUID group, String ref) {
+    return blockLabel(project, labelName, -1, 1, group, ref);
+  }
+
+  public static PermissionRule blockLabel(
+      ProjectConfig project,
+      String labelName,
+      int min,
+      int max,
+      AccountGroup.UUID group,
+      String ref) {
+    PermissionRule r = grant(project, Permission.LABEL + labelName, newRule(project, group), ref);
     r.setBlock();
-    r.setRange(-1, 1);
+    r.setRange(min, max);
     return r;
   }
 
-  public static PermissionRule deny(ProjectConfig project,
-      String permissionName, AccountGroup.UUID group, String ref) {
+  public static PermissionRule deny(
+      ProjectConfig project, String permissionName, AccountGroup.UUID group, String ref) {
     PermissionRule r = grant(project, permissionName, newRule(project, group), ref);
     r.setDeny();
     return r;
   }
 
-  public static void doNotInherit(ProjectConfig project, String permissionName,
-      String ref) {
-    project.getAccessSection(ref, true) //
+  public static void doNotInherit(ProjectConfig project, String permissionName, String ref) {
+    project
+        .getAccessSection(ref, true) //
         .getPermission(permissionName, true) //
         .setExclusiveGroup(true);
   }
 
-  private static PermissionRule grant(ProjectConfig project,
-      String permissionName, PermissionRule rule, String ref) {
-    project.getAccessSection(ref, true) //
-        .getPermission(permissionName, true) //
-        .add(rule);
+  private static PermissionRule grant(
+      ProjectConfig project, String permissionName, PermissionRule rule, String ref) {
+    return grant(project, permissionName, rule, ref, false);
+  }
+
+  private static PermissionRule grant(
+      ProjectConfig project,
+      String permissionName,
+      PermissionRule rule,
+      String ref,
+      boolean exclusive) {
+    Permission permission = project.getAccessSection(ref, true).getPermission(permissionName, true);
+    if (exclusive) {
+      permission.setExclusiveGroup(exclusive);
+    }
+    permission.add(rule);
     return rule;
   }
 
-  private Util() {
-  }
+  private Util() {}
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
index 47df2db..cc59081 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
@@ -23,13 +23,12 @@
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
-import org.junit.Test;
-
 import java.util.List;
+import org.junit.Test;
 
 public class AndPredicateTest extends PredicateTest {
   @Test
-  public void testChildren() {
+  public void children() {
     final TestPredicate a = f("author", "alice");
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = and(a, b);
@@ -39,7 +38,7 @@
   }
 
   @Test
-  public void testChildrenUnmodifiable() {
+  public void childrenUnmodifiable() {
     final TestPredicate a = f("author", "alice");
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = and(a, b);
@@ -69,8 +68,8 @@
     assertChildren("iterator().remove()", n, of(a, b));
   }
 
-  private static void assertChildren(String o, Predicate<String> p,
-      List<? extends Predicate<String>> l) {
+  private static void assertChildren(
+      String o, Predicate<String> p, List<? extends Predicate<String>> l) {
     assertEquals(o + " did not affect child", l, p.getChildren());
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
index 8f16670..6a72fce 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
@@ -19,9 +19,8 @@
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
-import org.junit.Test;
-
 import java.util.Collections;
+import org.junit.Test;
 
 public class FieldPredicateTest extends PredicateTest {
   @Test
@@ -31,6 +30,7 @@
     assertEquals("owner:\"A U Thor\"", f("owner", "A U Thor").toString());
   }
 
+  @SuppressWarnings("unlikely-arg-type")
   @Test
   public void testEquals() {
     assertTrue(f("author", "bob").equals(f("author", "bob")));
@@ -46,7 +46,7 @@
   }
 
   @Test
-  public void testNameValue() {
+  public void nameValue() {
     final String name = "author";
     final String value = "alice";
     final OperatorPredicate<String> f = f(name, value);
@@ -58,7 +58,7 @@
   @Test
   public void testCopy() {
     final OperatorPredicate<String> f = f("author", "alice");
-    assertSame(f, f.copy(Collections.<Predicate<String>> emptyList()));
+    assertSame(f, f.copy(Collections.<Predicate<String>>emptyList()));
     assertSame(f, f.copy(f.getChildren()));
 
     exception.expect(IllegalArgumentException.class);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
index 0256081..13a566c 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
@@ -23,14 +23,13 @@
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
-import org.junit.Test;
-
 import java.util.Collections;
 import java.util.List;
+import org.junit.Test;
 
 public class NotPredicateTest extends PredicateTest {
   @Test
-  public void testNotNot() {
+  public void notNot() {
     final TestPredicate p = f("author", "bob");
     final Predicate<String> n = not(p);
     assertTrue(n instanceof NotPredicate);
@@ -39,7 +38,7 @@
   }
 
   @Test
-  public void testChildren() {
+  public void children() {
     final TestPredicate p = f("author", "bob");
     final Predicate<String> n = not(p);
     assertEquals(1, n.getChildCount());
@@ -47,25 +46,33 @@
   }
 
   @Test
-  public void testChildrenUnmodifiable() {
+  public void childrenUnmodifiable() {
     final TestPredicate p = f("author", "bob");
     final Predicate<String> n = not(p);
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().clear();
-    assertOnlyChild("clear", p, n);
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("clear", p, n);
+    }
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().remove(0);
-    assertOnlyChild("remove(0)", p, n);
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("remove(0)", p, n);
+    }
 
-    exception.expect(UnsupportedOperationException.class);
-    n.getChildren().iterator().remove();
-    assertOnlyChild("remove(0)", p, n);
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("remove()", p, n);
+    }
   }
 
-  private static void assertOnlyChild(String o, Predicate<String> c,
-      Predicate<String> p) {
+  private static void assertOnlyChild(String o, Predicate<String> c, Predicate<String> p) {
     assertEquals(o + " did not affect child", 1, p.getChildCount());
     assertSame(o + " did not affect child", c, p.getChild(0));
   }
@@ -75,6 +82,7 @@
     assertEquals("-author:bob", not(f("author", "bob")).toString());
   }
 
+  @SuppressWarnings("unlikely-arg-type")
   @Test
   public void testEquals() {
     assertTrue(not(f("author", "bob")).equals(not(f("author", "bob"))));
@@ -105,7 +113,7 @@
     assertEquals(sb, n.copy(sb).getChildren());
 
     try {
-      n.copy(Collections.<Predicate> emptyList());
+      n.copy(Collections.<Predicate>emptyList());
       fail("Expected IllegalArgumentException");
     } catch (IllegalArgumentException e) {
       assertEquals("Expected exactly one child", e.getMessage());
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
index 5640d1b..7d97a0d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
@@ -23,13 +23,12 @@
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
-import org.junit.Test;
-
 import java.util.List;
+import org.junit.Test;
 
 public class OrPredicateTest extends PredicateTest {
   @Test
-  public void testChildren() {
+  public void children() {
     final TestPredicate a = f("author", "alice");
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = or(a, b);
@@ -39,7 +38,7 @@
   }
 
   @Test
-  public void testChildrenUnmodifiable() {
+  public void childrenUnmodifiable() {
     final TestPredicate a = f("author", "alice");
     final TestPredicate b = f("author", "bob");
     final Predicate<String> n = or(a, b);
@@ -69,8 +68,8 @@
     assertChildren("iterator().remove()", n, of(a, b));
   }
 
-  private static void assertChildren(String o, Predicate<String> p,
-      List<? extends Predicate<String>> l) {
+  private static void assertChildren(
+      String o, Predicate<String> p, List<? extends Predicate<String>> l) {
     assertEquals(o + " did not affect child", l, p.getChildren());
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
index 7762e50..2d13876 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query;
 
 import com.google.gerrit.testutil.GerritBaseTests;
-
 import org.junit.Ignore;
 
 @Ignore
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/QueryParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/QueryParserTest.java
index e349273..4c0bcc0 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/QueryParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/QueryParserTest.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 package com.google.gerrit.server.query;
+
 import static org.junit.Assert.assertEquals;
 
 import org.antlr.runtime.tree.Tree;
@@ -20,7 +21,7 @@
 
 public class QueryParserTest {
   @Test
-  public void testProjectBare() throws QueryParseException {
+  public void projectBare() throws QueryParseException {
     Tree r;
 
     r = parse("project:tools/gerrit");
@@ -30,8 +31,7 @@
     assertSingleWord("project", "tools/*", r);
   }
 
-  private static void assertSingleWord(final String name, final String value,
-      final Tree r) {
+  private static void assertSingleWord(final String name, final String value, final Tree r) {
     assertEquals(QueryParser.FIELD_NAME, r.getType());
     assertEquals(name, r.getText());
     assertEquals(1, r.getChildCount());
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 83f83bb..0075042 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -15,28 +15,46 @@
 package com.google.gerrit.server.query.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.GerritApi;
+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.ProjectAccessInput;
 import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ListAccountsOption;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.ConfigSuite;
@@ -46,19 +64,17 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
-
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.TestName;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
 
 @Ignore
 public abstract class AbstractQueryAccountsTest extends GerritServerTests {
@@ -69,36 +85,31 @@
     return cfg;
   }
 
-  @Rule
-  public final TestName testName = new TestName();
+  @Inject protected AccountCache accountCache;
 
-  @Inject
-  protected AccountCache accountCache;
+  @Inject protected AccountManager accountManager;
 
-  @Inject
-  protected AccountManager accountManager;
+  @Inject protected GerritApi gApi;
 
-  @Inject
-  protected GerritApi gApi;
+  @Inject protected IdentifiedUser.GenericFactory userFactory;
 
-  @Inject
-  protected IdentifiedUser.GenericFactory userFactory;
+  @Inject private Provider<AnonymousUser> anonymousUser;
 
-  @Inject
-  private Provider<AnonymousUser> anonymousUser;
+  @Inject protected InMemoryDatabase schemaFactory;
 
-  @Inject
-  protected InMemoryDatabase schemaFactory;
+  @Inject protected SchemaCreator schemaCreator;
 
-  @Inject
-  protected InternalChangeQuery internalChangeQuery;
+  @Inject protected ThreadLocalRequestContext requestContext;
 
-  @Inject
-  protected SchemaCreator schemaCreator;
+  @Inject protected OneOffRequestContext oneOffRequestContext;
 
-  @Inject
-  protected ThreadLocalRequestContext requestContext;
+  @Inject protected InternalAccountQuery internalAccountQuery;
 
+  @Inject protected AllProjectsName allProjects;
+
+  @Inject protected AccountIndexCollection accountIndexes;
+
+  protected Injector injector;
   protected LifecycleManager lifecycle;
   protected ReviewDb db;
   protected AccountInfo currentUserInfo;
@@ -109,11 +120,11 @@
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
-    Injector injector = createInjector();
+    injector = createInjector();
     lifecycle.add(injector);
     injector.injectMembers(this);
     lifecycle.start();
-
+    initAfterLifecycleStart();
     db = schemaFactory.open();
     schemaCreator.create(db);
 
@@ -123,9 +134,16 @@
     currentUserInfo = gApi.accounts().id(userId.get()).get();
   }
 
+  @After
+  public void cleanUp() {
+    lifecycle.stop();
+    db.close();
+  }
+
+  protected void initAfterLifecycleStart() throws Exception {}
+
   protected RequestContext newRequestContext(Account.Id requestUserId) {
-    final CurrentUser requestUser =
-        userFactory.create(requestUserId);
+    final CurrentUser requestUser = userFactory.create(requestUserId);
     return new RequestContext() {
       @Override
       public CurrentUser getUser() {
@@ -140,17 +158,18 @@
   }
 
   protected void setAnonymous() {
-    requestContext.setContext(new RequestContext() {
-      @Override
-      public CurrentUser getUser() {
-        return anonymousUser.get();
-      }
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return anonymousUser.get();
+          }
 
-      @Override
-      public Provider<ReviewDb> getReviewDbProvider() {
-        return Providers.of(db);
-      }
-    });
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
   }
 
   @After
@@ -188,11 +207,9 @@
     AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
 
     String prefix = name("prefix");
-    AccountInfo user4 =
-        newAccountWithEmail("user4", prefix + "user4@example.com");
+    AccountInfo user4 = newAccountWithEmail("user4", prefix + "user4@example.com");
 
-    AccountInfo user5 =
-        newAccountWithEmail("user5", name("user5MixedCase@example.com"));
+    AccountInfo user5 = newAccountWithEmail("user5", name("user5MixedCase@example.com"));
 
     assertQuery("notexisting@test.com");
 
@@ -271,6 +288,57 @@
   }
 
   @Test
+  public void byCansee() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("account1", "account1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("account2", "account2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("account3", "account3@" + domain);
+
+    Project.NameKey p = createProject(name("p"));
+    ChangeInfo c = createChange(p);
+    assertQuery("name:" + domain + " cansee:" + c.changeId, user1, user2, user3);
+
+    GroupInfo group = createGroup(name("group"), user1, user2);
+    blockRead(p, group);
+    assertQuery("name:" + domain + " cansee:" + c.changeId, user3);
+  }
+
+  @Test
+  public void byWatchedProject() throws Exception {
+    Project.NameKey p = createProject(name("p"));
+    Project.NameKey p2 = createProject(name("p2"));
+    AccountInfo user1 = newAccountWithFullName("jdoe", "John Doe");
+    AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
+    AccountInfo user3 = newAccountWithFullName("user3", "Mr Selfish");
+
+    assertThat(internalAccountQuery.byWatchedProject(p)).isEmpty();
+
+    watch(user1, p, null);
+    assertAccounts(internalAccountQuery.byWatchedProject(p), user1);
+
+    watch(user2, p, "keyword");
+    assertAccounts(internalAccountQuery.byWatchedProject(p), user1, user2);
+
+    watch(user3, p2, "keyword");
+    watch(user3, allProjects, "keyword");
+    assertAccounts(internalAccountQuery.byWatchedProject(p), user1, user2);
+    assertAccounts(internalAccountQuery.byWatchedProject(p2), user3);
+    assertAccounts(internalAccountQuery.byWatchedProject(allProjects), user3);
+  }
+
+  @Test
+  public void byDeletedAccount() throws Exception {
+    AccountInfo user = newAccountWithFullName("jdoe", "John Doe");
+    Account.Id userId = Account.Id.parse(user._accountId.toString());
+    assertQuery("John", user);
+
+    for (AccountIndex index : accountIndexes.getWriteIndexes()) {
+      index.delete(userId);
+    }
+    assertQuery("John");
+  }
+
+  @Test
   public void withLimit() throws Exception {
     String domain = name("test.com");
     AccountInfo user1 = newAccountWithEmail("user1", "user1@" + domain);
@@ -278,10 +346,10 @@
     AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
 
     List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
-    assertThat(result.get(result.size() - 1)._moreAccounts).isNull();
+    assertThat(Iterables.getLast(result)._moreAccounts).isNull();
 
-    result = assertQuery(newQuery(domain).withLimit(2), user1, user2);
-    assertThat(result.get(result.size() - 1)._moreAccounts).isTrue();
+    result = assertQuery(newQuery(domain).withLimit(2), result.subList(0, 2));
+    assertThat(Iterables.getLast(result)._moreAccounts).isTrue();
   }
 
   @Test
@@ -291,14 +359,13 @@
     AccountInfo user2 = newAccountWithEmail("user2", "user2@" + domain);
     AccountInfo user3 = newAccountWithEmail("user3", "user3@" + domain);
 
-    assertQuery(domain, user1, user2, user3);
-    assertQuery(newQuery(domain).withStart(1), user2, user3);
+    List<AccountInfo> result = assertQuery(domain, user1, user2, user3);
+    assertQuery(newQuery(domain).withStart(1), result.subList(1, 3));
   }
 
   @Test
   public void withDetails() throws Exception {
-    AccountInfo user1 =
-        newAccount("myuser", "My User", "my.user@example.com", true);
+    AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
 
     List<AccountInfo> result = assertQuery(user1.username, user1);
     AccountInfo ai = result.get(0);
@@ -308,8 +375,7 @@
     assertThat(ai.email).isNull();
     assertThat(ai.avatars).isNull();
 
-    result = assertQuery(
-        newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
     ai = result.get(0);
     assertThat(ai._accountId).isEqualTo(user1._accountId);
     assertThat(ai.name).isEqualTo(user1.name);
@@ -320,30 +386,29 @@
 
   @Test
   public void withSecondaryEmails() throws Exception {
-    AccountInfo user1 =
-        newAccount("myuser", "My User", "my.user@example.com", true);
-    String[] secondaryEmails =
-        new String[] {"bar@example.com", "foo@example.com"};
+    AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
+    String[] secondaryEmails = new String[] {"bar@example.com", "foo@example.com"};
     addEmails(user1, secondaryEmails);
 
-
     List<AccountInfo> result = assertQuery(user1.username, user1);
     assertThat(result.get(0).secondaryEmails).isNull();
 
-    result = assertQuery(
-        newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
     assertThat(result.get(0).secondaryEmails).isNull();
 
-    result = assertQuery(
-        newQuery(user1.username).withOption(ListAccountsOption.ALL_EMAILS),
-        user1);
+    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.ALL_EMAILS), user1);
     assertThat(result.get(0).secondaryEmails)
-        .containsExactlyElementsIn(Arrays.asList(secondaryEmails)).inOrder();
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
+        .inOrder();
 
-    result = assertQuery(newQuery(user1.username).withOptions(
-        ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS), user1);
+    result =
+        assertQuery(
+            newQuery(user1.username)
+                .withOptions(ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS),
+            user1);
     assertThat(result.get(0).secondaryEmails)
-        .containsExactlyElementsIn(Arrays.asList(secondaryEmails)).inOrder();
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
+        .inOrder();
   }
 
   @Test
@@ -379,23 +444,20 @@
     return newAccountWithEmail(username, null);
   }
 
-  protected AccountInfo newAccountWithEmail(String username, String email)
-      throws Exception {
+  protected AccountInfo newAccountWithEmail(String username, String email) throws Exception {
     return newAccount(username, email, true);
   }
 
-  protected AccountInfo newAccountWithFullName(String username, String fullName)
-      throws Exception {
+  protected AccountInfo newAccountWithFullName(String username, String fullName) throws Exception {
     return newAccount(username, fullName, null, true);
   }
 
-  protected AccountInfo newAccount(String username, String email,
-      boolean active) throws Exception {
+  protected AccountInfo newAccount(String username, String email, boolean active) throws Exception {
     return newAccount(username, null, email, active);
   }
 
-  protected AccountInfo newAccount(String username, String fullName,
-      String email, boolean active) throws Exception {
+  protected AccountInfo newAccount(String username, String fullName, String email, boolean active)
+      throws Exception {
     String uniqueName = name(username);
 
     try {
@@ -409,6 +471,57 @@
     return gApi.accounts().id(id.get()).get();
   }
 
+  protected Project.NameKey createProject(String name) throws RestApiException {
+    ProjectInput in = new ProjectInput();
+    in.name = name;
+    in.createEmptyCommit = true;
+    gApi.projects().create(in);
+    return new Project.NameKey(name);
+  }
+
+  protected void blockRead(Project.NameKey project, GroupInfo group) throws RestApiException {
+    ProjectAccessInput in = new ProjectAccessInput();
+    in.add = new HashMap<>();
+
+    AccessSectionInfo a = new AccessSectionInfo();
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules =
+        ImmutableMap.of(group.id, new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false));
+    a.permissions = ImmutableMap.of(Permission.READ, p);
+    in.add = ImmutableMap.of("refs/*", a);
+
+    gApi.projects().name(project.get()).access(in);
+  }
+
+  protected ChangeInfo createChange(Project.NameKey project) throws RestApiException {
+    ChangeInput in = new ChangeInput();
+    in.subject = "A change";
+    in.project = project.get();
+    in.branch = "master";
+    return gApi.changes().create(in).get();
+  }
+
+  protected GroupInfo createGroup(String name, AccountInfo... members) throws RestApiException {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.members =
+        Arrays.asList(members).stream().map(a -> String.valueOf(a._accountId)).collect(toList());
+    return gApi.groups().create(in).get();
+  }
+
+  protected void watch(AccountInfo account, Project.NameKey project, String filter)
+      throws RestApiException {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project.get();
+    pwi.filter = filter;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+    gApi.accounts().id(account._accountId).setWatchedProjects(projectsToWatch);
+  }
+
   protected String quote(String s) {
     return "\"" + s + "\"";
   }
@@ -424,24 +537,24 @@
     return name + "_" + suffix;
   }
 
-  private Account.Id createAccount(String username, String fullName,
-      String email, boolean active) throws Exception {
-    Account.Id id =
-        accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
-    if (email != null) {
-      accountManager.link(id, AuthRequest.forEmail(email));
+  private Account.Id createAccount(String username, String fullName, String email, boolean active)
+      throws Exception {
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      if (email != null) {
+        accountManager.link(id, AuthRequest.forEmail(email));
+      }
+      Account a = db.accounts().get(id);
+      a.setFullName(fullName);
+      a.setPreferredEmail(email);
+      a.setActive(active);
+      db.accounts().update(ImmutableList.of(a));
+      accountCache.evict(id);
+      return id;
     }
-    Account a = db.accounts().get(id);
-    a.setFullName(fullName);
-    a.setPreferredEmail(email);
-    a.setActive(active);
-    db.accounts().update(ImmutableList.of(a));
-    accountCache.evict(id);
-    return id;
   }
 
-  private void addEmails(AccountInfo account, String... emails)
-      throws Exception {
+  private void addEmails(AccountInfo account, String... emails) throws Exception {
     Account.Id id = new Account.Id(account._accountId);
     for (String email : emails) {
       accountManager.link(id, AuthRequest.forEmail(email));
@@ -453,26 +566,37 @@
     return gApi.accounts().query(query.toString());
   }
 
-  protected List<AccountInfo> assertQuery(Object query, AccountInfo... accounts)
-      throws Exception {
+  protected List<AccountInfo> assertQuery(Object query, AccountInfo... accounts) throws Exception {
     return assertQuery(newQuery(query), accounts);
   }
 
   protected List<AccountInfo> assertQuery(QueryRequest query, AccountInfo... accounts)
       throws Exception {
+    return assertQuery(query, Arrays.asList(accounts));
+  }
+
+  protected List<AccountInfo> assertQuery(QueryRequest query, List<AccountInfo> accounts)
+      throws Exception {
     List<AccountInfo> result = query.get();
     Iterable<Integer> ids = ids(result);
-    assertThat(ids).named(format(query, result, accounts))
-        .containsExactlyElementsIn(ids(accounts)).inOrder();
+    assertThat(ids)
+        .named(format(query, result, accounts))
+        .containsExactlyElementsIn(ids(accounts))
+        .inOrder();
     return result;
   }
 
-  private String format(QueryRequest query, Iterable<AccountInfo> actualIds,
-      AccountInfo... expectedAccounts) {
+  protected void assertAccounts(List<AccountState> accounts, AccountInfo... expectedAccounts) {
+    assertThat(accounts.stream().map(a -> a.getAccount().getId().get()).collect(toList()))
+        .containsExactlyElementsIn(
+            Arrays.asList(expectedAccounts).stream().map(a -> a._accountId).collect(toList()));
+  }
+
+  private String format(
+      QueryRequest query, List<AccountInfo> actualIds, List<AccountInfo> expectedAccounts) {
     StringBuilder b = new StringBuilder();
-    b.append("query '").append(query.getQuery())
-        .append("' with expected accounts ");
-    b.append(format(Arrays.asList(expectedAccounts)));
+    b.append("query '").append(query.getQuery()).append("' with expected accounts ");
+    b.append(format(expectedAccounts));
     b.append(" and result ");
     b.append(format(actualIds));
     return b.toString();
@@ -484,9 +608,18 @@
     Iterator<AccountInfo> it = accounts.iterator();
     while (it.hasNext()) {
       AccountInfo a = it.next();
-      b.append("{").append(a._accountId).append(", ").append("name=")
-          .append(a.name).append(", ").append("email=").append(a.email)
-          .append(", ").append("username=").append(a.username).append("}");
+      b.append("{")
+          .append(a._accountId)
+          .append(", ")
+          .append("name=")
+          .append(a.name)
+          .append(", ")
+          .append("email=")
+          .append(a.email)
+          .append(", ")
+          .append("username=")
+          .append(a.username)
+          .append("}");
       if (it.hasNext()) {
         b.append(", ");
       }
@@ -496,22 +629,10 @@
   }
 
   protected static Iterable<Integer> ids(AccountInfo... accounts) {
-    return FluentIterable.from(Arrays.asList(accounts)).transform(
-        new Function<AccountInfo, Integer>() {
-          @Override
-          public Integer apply(AccountInfo in) {
-            return in._accountId;
-          }
-        });
+    return ids(Arrays.asList(accounts));
   }
 
-  protected static Iterable<Integer> ids(Iterable<AccountInfo> accounts) {
-    return FluentIterable.from(accounts).transform(
-        new Function<AccountInfo, Integer>() {
-          @Override
-          public Integer apply(AccountInfo in) {
-            return in._accountId;
-          }
-        });
+  protected static Iterable<Integer> ids(List<AccountInfo> accounts) {
+    return accounts.stream().map(a -> a._accountId).collect(toList());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
index 857b661..978283a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-
 import org.eclipse.jgit.lib.Config;
 
 public class LuceneQueryAccountsTest extends AbstractQueryAccountsTest {
@@ -25,7 +24,6 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(
-        new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
   }
 }
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 49e01ad..9026152 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
@@ -17,13 +17,15 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.Util.category;
+import static com.google.gerrit.server.project.Util.value;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.junit.Assert.fail;
+import static java.util.stream.Collectors.toList;
 
-import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -31,15 +33,26 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -52,9 +65,12 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountManager;
@@ -62,14 +78,25 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.QueryOptions;
 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.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.index.change.StalenessChecker;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.ConfigSuite;
@@ -83,10 +110,22 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
-
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
@@ -94,25 +133,23 @@
 import org.junit.Ignore;
 import org.junit.Test;
 
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-
 @Ignore
 public abstract class AbstractQueryChangesTest extends GerritServerTests {
   @ConfigSuite.Default
   public static Config defaultConfig() {
     Config cfg = new Config();
     cfg.setInt("index", null, "maxPages", 10);
+    cfg.setString("trackingid", "query-bug", "footer", "Bug:");
+    cfg.setString("trackingid", "query-bug", "match", "QUERY\\d{2,8}");
+    cfg.setString("trackingid", "query-bug", "system", "querytests");
+    cfg.setString("trackingid", "query-feature", "footer", "Feature");
+    cfg.setString("trackingid", "query-feature", "match", "QUERY\\d{2,8}");
+    cfg.setString("trackingid", "query-feature", "system", "querytests");
     return cfg;
   }
 
   @Inject protected AccountManager accountManager;
+  @Inject protected AllUsersName allUsersName;
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected ChangeInserter.Factory changeFactory;
   @Inject protected ChangeQueryBuilder queryBuilder;
@@ -120,17 +157,23 @@
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
   @Inject protected ChangeIndexer indexer;
+  @Inject protected IndexConfig indexConfig;
   @Inject protected InMemoryDatabase schemaFactory;
   @Inject protected InMemoryRepositoryManager repoManager;
   @Inject protected InternalChangeQuery internalChangeQuery;
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected PatchSetInserter.Factory patchSetFactory;
+  @Inject protected PatchSetUtil psUtil;
   @Inject protected ChangeControl.GenericFactory changeControlFactory;
   @Inject protected ChangeQueryProcessor queryProcessor;
   @Inject protected SchemaCreator schemaCreator;
   @Inject protected Sequences seq;
   @Inject protected ThreadLocalRequestContext requestContext;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
 
+  protected Injector injector;
   protected LifecycleManager lifecycle;
   protected ReviewDb db;
   protected Account.Id userId;
@@ -143,15 +186,27 @@
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
-    Injector injector = createInjector();
+    injector = createInjector();
     lifecycle.add(injector);
     injector.injectMembers(this);
     lifecycle.start();
+    initAfterLifecycleStart();
+    setUpDatabase();
+  }
 
+  @After
+  public void cleanUp() {
+    lifecycle.stop();
+    db.close();
+  }
+
+  protected void initAfterLifecycleStart() throws Exception {}
+
+  protected void setUpDatabase() throws Exception {
     db = schemaFactory.open();
     schemaCreator.create(db);
-    userId = accountManager.authenticate(AuthRequest.forUser("user"))
-        .getAccountId();
+
+    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     Account userAccount = db.accounts().get(userId);
     userAccount.setPreferredEmail("user@example.com");
     db.accounts().update(ImmutableList.of(userAccount));
@@ -160,8 +215,7 @@
   }
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
-    final CurrentUser requestUser =
-        userFactory.create(requestUserId);
+    final CurrentUser requestUser = userFactory.create(requestUserId);
     return new RequestContext() {
       @Override
       public CurrentUser getUser() {
@@ -189,7 +243,7 @@
 
   @Before
   public void setTimeForTesting() {
-    resetTimeWithClockStep(1, MILLISECONDS);
+    resetTimeWithClockStep(1, SECONDS);
   }
 
   private void resetTimeWithClockStep(long clockStep, TimeUnit clockStepUnit) {
@@ -244,7 +298,7 @@
     assertQuery("change:iabcde~branch~" + k.substring(0, 10), change);
 
     assertQuery("foo~bar");
-    assertBadQuery("change:foo~bar");
+    assertThatQueryException("change:foo~bar").hasMessageThat().isEqualTo("Invalid change format");
     assertQuery("otherrepo~branch~" + k);
     assertQuery("change:otherrepo~branch~" + k);
     assertQuery("iabcde~otherbranch~" + k);
@@ -289,6 +343,7 @@
     assertQuery("status:pe", expected);
     assertQuery("status:pen", expected);
     assertQuery("is:open", expected);
+    assertQuery("is:pending", expected);
   }
 
   @Test
@@ -330,6 +385,20 @@
   }
 
   @Test
+  public void byStatusAbandoned() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
+    insert(repo, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
+    Change change1 = insert(repo, ins2);
+    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+
+    assertQuery("status:abandoned", change1);
+    assertQuery("status:ABANDONED", change1);
+    assertQuery("is:abandoned", change1);
+  }
+
+  @Test
   public void byStatusPrefix() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
@@ -342,21 +411,25 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
-    assertBadQuery("status:nx");
-    assertBadQuery("status:newx");
+    assertThatQueryException("status:nx").hasMessageThat().isEqualTo("invalid change status: nx");
+    assertThatQueryException("status:newx")
+        .hasMessageThat()
+        .isEqualTo("invalid change status: newx");
   }
 
   @Test
   public void byCommit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo);
-    insert(repo, ins);
+    Change change = insert(repo, ins);
     String sha = ins.getCommit().name();
 
     assertQuery("0000000000000000000000000000000000000000");
+    assertQuery("commit:0000000000000000000000000000000000000000");
     for (int i = 0; i <= 36; i++) {
       String q = sha.substring(0, 40 - i);
-      assertQuery(q, ins.getChange());
+      assertQuery(q, change);
+      assertQuery("commit:" + q, change);
     }
   }
 
@@ -364,12 +437,16 @@
   public void byOwner() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
-    Account.Id user2 = accountManager.authenticate(
-        AuthRequest.forUser("anotheruser")).getAccountId();
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
 
+    assertQuery("is:owner", change1);
     assertQuery("owner:" + userId.get(), change1);
     assertQuery("owner:" + user2, change2);
+
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+    assertQuery("owner: \"" + nameEmail + "\"", change1);
   }
 
   @Test
@@ -430,12 +507,17 @@
   public void byOwnerIn() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
-    Account.Id user2 = accountManager.authenticate(
-        AuthRequest.forUser("anotheruser")).getAccountId();
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
+    Change change3 = insert(repo, newChange(repo), user2);
+    gApi.changes().id(change3.getId().get()).current().review(ReviewInput.approve());
+    gApi.changes().id(change3.getId().get()).current().submit();
 
     assertQuery("ownerin:Administrators", change1);
     assertQuery("ownerin:\"Registered Users\"", change2, change1);
+    assertQuery("ownerin:\"Registered Users\" project:repo", change3, change2, change1);
+    assertQuery("ownerin:\"Registered Users\" status:merged", change3);
   }
 
   @Test
@@ -452,6 +534,17 @@
   }
 
   @Test
+  public void byParentProject() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
+    Change change1 = insert(repo1, newChange(repo1));
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertQuery("parentproject:repo1", change2, change1);
+    assertQuery("parentproject:repo2", change2);
+  }
+
+  @Test
   public void byProjectPrefix() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2");
@@ -497,7 +590,13 @@
     ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
     Change change4 = insert(repo, ins4);
 
-    Change change5 = insert(repo, newChange(repo));
+    ChangeInserter ins5 = newChangeWithTopic(repo, "https://gerrit.local");
+    Change change5 = insert(repo, ins5);
+
+    ChangeInserter ins6 = newChangeWithTopic(repo, "git_gerrit_training");
+    Change change6 = insert(repo, ins6);
+
+    Change change_no_topic = insert(repo, newChange(repo));
 
     assertQuery("intopic:foo");
     assertQuery("intopic:feature1", change1);
@@ -505,10 +604,12 @@
     assertQuery("topic:feature2", change2);
     assertQuery("intopic:feature2", change4, change3, change2);
     assertQuery("intopic:fixup", change4);
-    assertQuery("topic:\"\"", change5);
-    assertQuery("intopic:\"\"", change5);
     assertQuery("intopic:^feature2.*", change4, change2);
     assertQuery("intopic:{^.*feature2$}", change3, change2);
+    assertQuery("intopic:gerrit", change6, change5);
+
+    assertQuery("topic:\"\"", change_no_topic);
+    assertQuery("intopic:\"\"", change_no_topic);
   }
 
   @Test
@@ -527,11 +628,9 @@
   @Test
   public void fullTextWithNumbers() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit1 =
-        repo.parseBody(repo.commit().message("12345 67890").create());
+    RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    RevCommit commit2 =
-        repo.parseBody(repo.commit().message("12346 67891").create());
+    RevCommit commit2 = repo.parseBody(repo.commit().message("12346 67891").create());
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
     assertQuery("message:1234");
@@ -540,6 +639,26 @@
   }
 
   @Test
+  public void byMessageMixedCase() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Hello Gerrit").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("message:gerrit", change2, change1);
+    assertQuery("message:Gerrit", change2, change1);
+  }
+
+  @Test
+  public void byMessageSubstring() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("https://gerrit.local").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    assertQuery("message:gerrit", change1);
+  }
+
+  @Test
   public void byLabel() throws Exception {
     accountManager.authenticate(AuthRequest.forUser("anotheruser"));
     TestRepository<Repo> repo = createProject("repo");
@@ -550,27 +669,24 @@
     ChangeInserter ins5 = newChange(repo, null, null, null, null);
 
     Change reviewMinus2Change = insert(repo, ins);
-    gApi.changes().id(reviewMinus2Change.getId().get()).current()
-        .review(ReviewInput.reject());
+    gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
 
     Change reviewMinus1Change = insert(repo, ins2);
-    gApi.changes().id(reviewMinus1Change.getId().get()).current()
-        .review(ReviewInput.dislike());
+    gApi.changes().id(reviewMinus1Change.getId().get()).current().review(ReviewInput.dislike());
 
     Change noLabelChange = insert(repo, ins3);
 
     Change reviewPlus1Change = insert(repo, ins4);
-    gApi.changes().id(reviewPlus1Change.getId().get()).current()
-        .review(ReviewInput.recommend());
+    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
     Change reviewPlus2Change = insert(repo, ins5);
-    gApi.changes().id(reviewPlus2Change.getId().get()).current()
-        .review(ReviewInput.approve());
+    gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
 
-    Map<String, Short> m = gApi.changes()
-        .id(reviewPlus1Change.getId().get())
-        .reviewer(user.getAccountId().toString())
-        .votes();
+    Map<String, Short> m =
+        gApi.changes()
+            .id(reviewPlus1Change.getId().get())
+            .reviewer(user.getAccountId().toString())
+            .votes();
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
 
@@ -621,10 +737,91 @@
     assertQuery("label:Code-Review=+1,user=user", reviewPlus1Change);
     assertQuery("label:Code-Review=+1,Administrators", reviewPlus1Change);
     assertQuery("label:Code-Review=+1,group=Administrators", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+2,owner", reviewPlus2Change);
+    assertQuery("label:Code-Review=-2,owner", reviewMinus2Change);
   }
 
-  private Change[] codeReviewInRange(Map<Integer, Change> changes, int start,
-      int end) {
+  @Test
+  public void byLabelMulti() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Project.NameKey project =
+        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+
+    LabelType verified =
+        category("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    cfg.getLabelSections().put(verified.getName(), verified);
+
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(Util.verified().getName()), -1, 1, REGISTERED_USERS, heads);
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      cfg.commit(md);
+    }
+    projectCache.evict(cfg.getProject());
+
+    ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
+    ChangeInserter ins = newChange(repo, null, null, null, null);
+    ChangeInserter ins2 = newChange(repo, null, null, null, null);
+    ChangeInserter ins3 = newChange(repo, null, null, null, null);
+    ChangeInserter ins4 = newChange(repo, null, null, null, null);
+    ChangeInserter ins5 = newChange(repo, null, null, null, null);
+
+    // CR+1
+    Change reviewCRplus1 = insert(repo, ins);
+    gApi.changes().id(reviewCRplus1.getId().get()).current().review(ReviewInput.recommend());
+
+    // CR+2
+    Change reviewCRplus2 = insert(repo, ins2);
+    gApi.changes().id(reviewCRplus2.getId().get()).current().review(ReviewInput.approve());
+
+    // CR+1 VR+1
+    Change reviewCRplus1VRplus1 = insert(repo, ins3);
+    gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(ReviewInput.recommend());
+    gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(reviewVerified);
+
+    // CR+2 VR+1
+    Change reviewCRplus2VRplus1 = insert(repo, ins4);
+    gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(ReviewInput.approve());
+    gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(reviewVerified);
+
+    // VR+1
+    Change reviewVRplus1 = insert(repo, ins5);
+    gApi.changes().id(reviewVRplus1.getId().get()).current().review(reviewVerified);
+
+    assertQuery("label:Code-Review=+1", reviewCRplus1VRplus1, reviewCRplus1);
+    assertQuery(
+        "label:Code-Review>=+1",
+        reviewCRplus2VRplus1,
+        reviewCRplus1VRplus1,
+        reviewCRplus2,
+        reviewCRplus1);
+    assertQuery("label:Code-Review>=+2", reviewCRplus2VRplus1, reviewCRplus2);
+
+    assertQuery(
+        "label:Code-Review>=+1 label:Verified=+1", reviewCRplus2VRplus1, reviewCRplus1VRplus1);
+    assertQuery("label:Code-Review>=+2 label:Verified=+1", reviewCRplus2VRplus1);
+  }
+
+  @Test
+  public void byLabelNotOwner() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins = newChange(repo, null, null, null, null);
+    Account.Id user1 = createAccount("user1");
+
+    Change reviewPlus1Change = insert(repo, ins);
+
+    // post a review with user1
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+
+    assertQuery("label:Code-Review=+1,user=user1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner");
+  }
+
+  private Change[] codeReviewInRange(Map<Integer, Change> changes, int start, int end) {
     int size = 0;
     Change[] range = new Change[end - start + 1];
     for (int i : changes.keySet()) {
@@ -645,8 +842,7 @@
   }
 
   private Account.Id createAccount(String name) throws Exception {
-    return accountManager.authenticate(
-        AuthRequest.forUser(name)).getAccountId();
+    return accountManager.authenticate(AuthRequest.forUser(name)).getAccountId();
   }
 
   @Test
@@ -666,8 +862,10 @@
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
-    gApi.changes().id(change1.getId().get()).current()
-      .review(new ReviewInput().label("Code-Review", 1));
+    gApi.changes()
+        .id(change1.getId().get())
+        .current()
+        .review(new ReviewInput().label("Code-Review", 1));
 
     // verify that query with user1 will return results.
     requestContext.setContext(newRequestContext(userId));
@@ -700,7 +898,8 @@
       String q = "status:new limit:" + i;
       List<ChangeInfo> results = newQuery(q).get();
       assertThat(results).named(q).hasSize(expectedSize);
-      assertThat(results.get(results.size() - 1)._moreChanges).named(q)
+      assertThat(results.get(results.size() - 1)._moreChanges)
+          .named(q)
           .isEqualTo(expectedMoreChanges);
       assertThat(results.get(0)._number).isEqualTo(last.getId().get());
     }
@@ -729,9 +928,7 @@
     }
 
     assertQuery("status:new limit:2", changes.get(2), changes.get(1));
-    assertQuery(
-        newQuery("status:new limit:2").withStart(1),
-        changes.get(1), changes.get(0));
+    assertQuery(newQuery("status:new limit:2").withStart(1), changes.get(1), changes.get(0));
     assertQuery(newQuery("status:new limit:2").withStart(2), changes.get(0));
     assertQuery(newQuery("status:new limit:2").withStart(3));
   }
@@ -745,7 +942,9 @@
     assertQuery(query, change);
     assertQuery(query.withStart(1));
     assertQuery(query.withStart(99));
-    assertBadQuery(query.withStart(100));
+    assertThatQueryException(query.withStart(100))
+        .hasMessageThat()
+        .isEqualTo("Cannot go beyond page 10 of results");
     assertQuery(query.withLimit(100).withStart(100));
   }
 
@@ -761,7 +960,9 @@
     }
 
     for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
-      gApi.changes().id(changes.get(i).getId().get()).current()
+      gApi.changes()
+          .id(changes.get(i).getId().get())
+          .current()
           .review(new ReviewInput().message("modifying " + i));
     }
 
@@ -786,8 +987,7 @@
     assertQuery("status:new", change2, change1);
 
     gApi.changes().id(change1.getId().get()).topic("new-topic");
-    change1 = notesFactory.create(db, change1.getProject(), change1.getId())
-        .getChange();
+    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
@@ -811,8 +1011,7 @@
     assertQuery("status:new", change2, change1);
 
     gApi.changes().id(change1.getId().get()).topic("new-topic");
-    change1 = notesFactory.create(db, change1.getProject(), change1.getId())
-        .getChange();
+    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
@@ -826,8 +1025,8 @@
   public void filterOutMoreThanOnePageOfResults() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo), userId);
-    Account.Id user2 = accountManager.authenticate(
-        AuthRequest.forUser("anotheruser")).getAccountId();
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
       insert(repo, newChange(repo), user2);
     }
@@ -839,8 +1038,8 @@
   @Test
   public void filterOutAllResults() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Account.Id user2 = accountManager.authenticate(
-        AuthRequest.forUser("anotheruser")).getAccountId();
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
       insert(repo, newChange(repo), user2);
     }
@@ -852,10 +1051,13 @@
   @Test
   public void byFileExact() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit = repo.parseBody(
-        repo.commit().message("one")
-        .add("dir/file1", "contents1").add("dir/file2", "contents2")
-        .create());
+    RevCommit commit =
+        repo.parseBody(
+            repo.commit()
+                .message("one")
+                .add("dir/file1", "contents1")
+                .add("dir/file2", "contents2")
+                .create());
     Change change = insert(repo, newChangeForCommit(repo, commit));
 
     assertQuery("file:file");
@@ -869,10 +1071,13 @@
   @Test
   public void byFileRegex() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit = repo.parseBody(
-        repo.commit().message("one")
-        .add("dir/file1", "contents1").add("dir/file2", "contents2")
-        .create());
+    RevCommit commit =
+        repo.parseBody(
+            repo.commit()
+                .message("one")
+                .add("dir/file1", "contents1")
+                .add("dir/file2", "contents2")
+                .create());
     Change change = insert(repo, newChangeForCommit(repo, commit));
 
     assertQuery("file:.*file.*");
@@ -883,10 +1088,13 @@
   @Test
   public void byPathExact() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit = repo.parseBody(
-        repo.commit().message("one")
-        .add("dir/file1", "contents1").add("dir/file2", "contents2")
-        .create());
+    RevCommit commit =
+        repo.parseBody(
+            repo.commit()
+                .message("one")
+                .add("dir/file1", "contents1")
+                .add("dir/file2", "contents2")
+                .create());
     Change change = insert(repo, newChangeForCommit(repo, commit));
 
     assertQuery("path:file");
@@ -900,10 +1108,13 @@
   @Test
   public void byPathRegex() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit = repo.parseBody(
-        repo.commit().message("one")
-        .add("dir/file1", "contents1").add("dir/file2", "contents2")
-        .create());
+    RevCommit commit =
+        repo.parseBody(
+            repo.commit()
+                .message("one")
+                .add("dir/file1", "contents1")
+                .add("dir/file2", "contents2")
+                .create());
     Change change = insert(repo, newChangeForCommit(repo, commit));
 
     assertQuery("path:.*file.*");
@@ -921,21 +1132,19 @@
     ReviewInput.CommentInput commentInput = new ReviewInput.CommentInput();
     commentInput.line = 1;
     commentInput.message = "inline";
-    input.comments = ImmutableMap.<String, List<ReviewInput.CommentInput>> of(
-        Patch.COMMIT_MSG,
-        ImmutableList.<ReviewInput.CommentInput> of(commentInput));
+    input.comments =
+        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
+            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(commentInput));
     gApi.changes().id(change.getId().get()).current().review(input);
 
     Map<String, List<CommentInfo>> comments =
         gApi.changes().id(change.getId().get()).current().comments();
     assertThat(comments).hasSize(1);
-    CommentInfo comment =
-        Iterables.getOnlyElement(comments.get(Patch.COMMIT_MSG));
+    CommentInfo comment = Iterables.getOnlyElement(comments.get(Patch.COMMIT_MSG));
     assertThat(comment.message).isEqualTo(commentInput.message);
-    ChangeMessageInfo lastMsg = Iterables.getLast(
-        gApi.changes().id(change.getId().get()).get().messages, null);
-    assertThat(lastMsg.message)
-        .isEqualTo("Patch Set 1:\n\n(1 comment)\n\n" + input.message);
+    ChangeMessageInfo lastMsg =
+        Iterables.getLast(gApi.changes().id(change.getId().get()).get().messages, null);
+    assertThat(lastMsg.message).isEqualTo("Patch Set 1:\n\n(1 comment)\n\n" + input.message);
 
     assertQuery("comment:foo");
     assertQuery("comment:toplevel", change);
@@ -947,15 +1156,18 @@
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    // Queried by AgePredicate constructor.
+    long startMs = TestTimeUtil.START.getMillis();
+    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
+    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+
+    // Stop time so age queries use the same endpoint.
     TestTimeUtil.setClockStep(0, MILLISECONDS);
-    long now = TimeUtil.nowMs();
-    assertThat(lastUpdatedMs(change2) - lastUpdatedMs(change1))
-        .isEqualTo(thirtyHoursInMs);
-    assertThat(now - lastUpdatedMs(change2)).isEqualTo(thirtyHoursInMs);
-    assertThat(TimeUtil.nowMs()).isEqualTo(now);
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    long nowMs = TimeUtil.nowMs();
+
+    assertThat(lastUpdatedMs(change2) - lastUpdatedMs(change1)).isEqualTo(thirtyHoursInMs);
+    assertThat(nowMs - lastUpdatedMs(change2)).isEqualTo(thirtyHoursInMs);
+    assertThat(TimeUtil.nowMs()).isEqualTo(nowMs);
 
     assertQuery("-age:1d");
     assertQuery("-age:" + (30 * 60 - 1) + "m");
@@ -967,38 +1179,46 @@
   }
 
   @Test
-  public void byBefore() throws Exception {
-    resetTimeWithClockStep(30, HOURS);
+  public void byBeforeUntil() throws Exception {
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    long startMs = TestTimeUtil.START.getMillis();
+    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
+    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
-    assertQuery("before:2009-09-29");
-    assertQuery("before:2009-09-30");
-    assertQuery("before:\"2009-09-30 16:59:00 -0400\"");
-    assertQuery("before:\"2009-09-30 20:59:00 -0000\"");
-    assertQuery("before:\"2009-09-30 20:59:00\"");
-    assertQuery("before:\"2009-09-30 17:02:00 -0400\"", change1);
-    assertQuery("before:\"2009-10-01 21:02:00 -0000\"", change1);
-    assertQuery("before:\"2009-10-01 21:02:00\"", change1);
-    assertQuery("before:2009-10-01", change1);
-    assertQuery("before:2009-10-03", change2, change1);
+    for (String predicate : Lists.newArrayList("before:", "until:")) {
+      assertQuery(predicate + "2009-09-29");
+      assertQuery(predicate + "2009-09-30");
+      assertQuery(predicate + "\"2009-09-30 16:59:00 -0400\"");
+      assertQuery(predicate + "\"2009-09-30 20:59:00 -0000\"");
+      assertQuery(predicate + "\"2009-09-30 20:59:00\"");
+      assertQuery(predicate + "\"2009-09-30 17:02:00 -0400\"", change1);
+      assertQuery(predicate + "\"2009-10-01 21:02:00 -0000\"", change1);
+      assertQuery(predicate + "\"2009-10-01 21:02:00\"", change1);
+      assertQuery(predicate + "2009-10-01", change1);
+      assertQuery(predicate + "2009-10-03", change2, change1);
+    }
   }
 
   @Test
-  public void byAfter() throws Exception {
-    resetTimeWithClockStep(30, HOURS);
+  public void byAfterSince() throws Exception {
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+    resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    long startMs = TestTimeUtil.START.getMillis();
+    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
+    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
-    assertQuery("after:2009-10-03");
-    assertQuery("after:\"2009-10-01 20:59:59 -0400\"", change2);
-    assertQuery("after:\"2009-10-01 20:59:59 -0000\"", change2);
-    assertQuery("after:2009-10-01", change2);
-    assertQuery("after:2009-09-30", change2, change1);
+    for (String predicate : Lists.newArrayList("after:", "since:")) {
+      assertQuery(predicate + "2009-10-03");
+      assertQuery(predicate + "\"2009-10-01 20:59:59 -0400\"", change2);
+      assertQuery(predicate + "\"2009-10-01 20:59:59 -0000\"", change2);
+      assertQuery(predicate + "2009-10-01", change2);
+      assertQuery(predicate + "2009-09-30", change2, change1);
+    }
   }
 
   @Test
@@ -1006,11 +1226,9 @@
     TestRepository<Repo> repo = createProject("repo");
 
     // added = 3, deleted = 0, delta = 3
-    RevCommit commit1 = repo.parseBody(
-        repo.commit().add("file1", "foo\n\foo\nfoo").create());
+    RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "foo\n\foo\nfoo").create());
     // added = 0, deleted = 2, delta = 2
-    RevCommit commit2 = repo.parseBody(
-        repo.commit().parent(commit1).add("file1", "foo").create());
+    RevCommit commit2 = repo.parseBody(repo.commit().parent(commit1).add("file1", "foo").create());
 
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
@@ -1050,13 +1268,13 @@
 
     assertQuery("deleted:<=0", change1);
 
-    for (String str : Lists.newArrayList("delta", "size")) {
-      assertQuery(str + ":<2");
-      assertQuery(str + ":3", change1);
-      assertQuery(str + ":>2", change1);
-      assertQuery(str + ":>=3", change1);
-      assertQuery(str + ":<3", change2);
-      assertQuery(str + ":<=2", change2);
+    for (String str : Lists.newArrayList("delta:", "size:")) {
+      assertQuery(str + "<2");
+      assertQuery(str + "3", change1);
+      assertQuery(str + ">2", change1);
+      assertQuery(str + ">=3", change1);
+      assertQuery(str + "<3", change2);
+      assertQuery(str + "<=2", change2);
     }
   }
 
@@ -1123,21 +1341,17 @@
 
     Change change1 = insert(repo, newChange(repo));
 
-    RevCommit commit2 = repo.parseBody(
-        repo.commit().message("foosubject").create());
+    RevCommit commit2 = repo.parseBody(repo.commit().message("foosubject").create());
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
-    RevCommit commit3 = repo.parseBody(
-        repo.commit()
-        .add("Foo.java", "foo contents")
-        .create());
+    RevCommit commit3 = repo.parseBody(repo.commit().add("Foo.java", "foo contents").create());
     Change change3 = insert(repo, newChangeForCommit(repo, commit3));
 
     ChangeInserter ins4 = newChange(repo);
     Change change4 = insert(repo, ins4);
     ReviewInput ri4 = new ReviewInput();
     ri4.message = "toplevel";
-    ri4.labels = ImmutableMap.<String, Short> of("Code-Review", (short) 1);
+    ri4.labels = ImmutableMap.<String, Short>of("Code-Review", (short) 1);
     gApi.changes().id(change4.getId().get()).current().review(ri4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
@@ -1155,25 +1369,33 @@
     assertQuery("branch6", change6);
     assertQuery("refs/heads/branch6", change6);
 
-    Change[] expected =
-        new Change[] {change6, change5, change4, change3, change2, change1};
+    Change[] expected = new Change[] {change6, change5, change4, change3, change2, change1};
     assertQuery("user@example.com", expected);
     assertQuery("repo", expected);
   }
 
   @Test
+  public void byDefaultWithCommitPrefix() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit = repo.parseBody(repo.commit().message("message").create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+
+    assertQuery(commit.getId().getName().substring(0, 6), change);
+  }
+
+  @Test
   public void implicitVisibleTo() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
-    Change change2 =
-        insert(repo, newChangeWithStatus(repo, Change.Status.DRAFT), userId);
+    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.DRAFT), userId);
 
     String q = "project:repo";
     assertQuery(q, change2, change1);
 
     // Second user cannot see first user's drafts.
-    requestContext.setContext(newRequestContext(accountManager
-        .authenticate(AuthRequest.forUser("anotheruser")).getAccountId()));
+    requestContext.setContext(
+        newRequestContext(
+            accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId()));
     assertQuery(q, change1);
   }
 
@@ -1181,17 +1403,20 @@
   public void explicitVisibleTo() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
-    Change change2 =
-        insert(repo, newChangeWithStatus(repo, Change.Status.DRAFT), userId);
+    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.DRAFT), userId);
 
     String q = "project:repo";
     assertQuery(q, change2, change1);
 
     // Second user cannot see first user's drafts.
-    Account.Id user2 = accountManager
-        .authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId();
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     assertQuery(q + " visibleto:" + user2.get(), change1);
+
+    requestContext.setContext(
+        newRequestContext(
+            accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId()));
+    assertQuery("is:visible", change1);
   }
 
   @Test
@@ -1200,16 +1425,17 @@
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
 
-    int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId().get();
+    int user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
     comment.line = 1;
     comment.message = "inline";
-    input.comments = ImmutableMap.<String, List<ReviewInput.CommentInput>> of(
-        Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput> of(comment));
+    input.comments =
+        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
+            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
     gApi.changes().id(change1.getId().get()).current().review(input);
 
     input = new ReviewInput();
@@ -1226,6 +1452,8 @@
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
 
+    assertQuery("has:draft");
+
     DraftInput in = new DraftInput();
     in.line = 1;
     in.message = "nit: trailing whitespace";
@@ -1238,14 +1466,65 @@
     in.path = Patch.COMMIT_MSG;
     gApi.changes().id(change2.getId().get()).current().createDraft(in);
 
-    int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId().get();
+    int user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
 
+    assertQuery("has:draft", change2, change1);
     assertQuery("draftby:" + userId.get(), change2, change1);
     assertQuery("draftby:" + user2);
   }
 
   @Test
+  public void byDraftByExcludesZombieDrafts() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    Change change = insert(repo, newChange(repo));
+    Change.Id id = change.getId();
+
+    DraftInput in = new DraftInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = Patch.COMMIT_MSG;
+    gApi.changes().id(id.get()).current().createDraft(in);
+
+    assertQuery("draftby:" + userId, change);
+    assertQuery("commentby:" + userId);
+
+    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
+
+    Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
+    assertThat(draftsRef).isNotNull();
+
+    ReviewInput rin = ReviewInput.dislike();
+    rin.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    gApi.changes().id(id.get()).current().review(rin);
+
+    assertQuery("draftby:" + userId);
+    assertQuery("commentby:" + userId, change);
+    assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNull();
+
+    // Re-add drafts ref and ensure it gets filtered out during indexing.
+    allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
+    assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
+
+    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB) {
+      // Record draft ref in noteDbState as well.
+      ReviewDb db = ReviewDbUtil.unwrapDb(this.db);
+      change = db.changes().get(id);
+      NoteDbChangeState.applyDelta(
+          change,
+          NoteDbChangeState.Delta.create(
+              id, Optional.empty(), ImmutableMap.of(userId, draftsRef.getObjectId())));
+      db.changes().update(Collections.singleton(change));
+    }
+
+    indexer.index(db, project, id);
+    assertQuery("draftby:" + userId);
+  }
+
+  @Test
   public void byStarredBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
@@ -1255,8 +1534,8 @@
     gApi.accounts().self().starChange(change1.getId().toString());
     gApi.accounts().self().starChange(change2.getId().toString());
 
-    int user2 = accountManager.authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId().get();
+    int user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
 
     assertQuery("starredby:self", change2, change1);
     assertQuery("starredby:" + user2);
@@ -1271,13 +1550,15 @@
 
     gApi.accounts()
         .self()
-        .setStars(change1.getId().toString(),
+        .setStars(
+            change1.getId().toString(),
             new StarsInput(new HashSet<>(Arrays.asList("red", "blue"))));
     gApi.accounts()
         .self()
-        .setStars(change2.getId().toString(),
-            new StarsInput(new HashSet<>(Arrays.asList(
-                StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
+        .setStars(
+            change2.getId().toString(),
+            new StarsInput(
+                new HashSet<>(Arrays.asList(StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
 
     // check labeled stars
     assertQuery("star:red", change1);
@@ -1296,8 +1577,8 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
 
-    Account.Id user2 = accountManager.authenticate(
-        AuthRequest.forUser("anotheruser")).getAccountId();
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
 
     ReviewInput input = new ReviewInput();
@@ -1305,8 +1586,9 @@
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
     comment.line = 1;
     comment.message = "inline";
-    input.comments = ImmutableMap.<String, List<ReviewInput.CommentInput>> of(
-        Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput> of(comment));
+    input.comments =
+        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
+            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
     gApi.changes().id(change2.getId().get()).current().review(input);
 
     assertQuery("from:" + userId.get(), change2, change1);
@@ -1316,24 +1598,17 @@
   @Test
   public void conflicts() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit1 = repo.parseBody(
-        repo.commit()
-            .add("file1", "contents1")
-            .add("dir/file2", "contents2")
-            .add("dir/file3", "contents3")
-            .create());
-    RevCommit commit2 = repo.parseBody(
-        repo.commit()
-            .add("file1", "contents1")
-            .create());
-    RevCommit commit3 = repo.parseBody(
-        repo.commit()
-            .add("dir/file2", "contents2 different")
-            .create());
-    RevCommit commit4 = repo.parseBody(
-        repo.commit()
-            .add("file4", "contents4")
-            .create());
+    RevCommit commit1 =
+        repo.parseBody(
+            repo.commit()
+                .add("file1", "contents1")
+                .add("dir/file2", "contents2")
+                .add("dir/file3", "contents3")
+                .create());
+    RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents1").create());
+    RevCommit commit3 =
+        repo.parseBody(repo.commit().add("dir/file2", "contents2 different").create());
+    RevCommit commit4 = repo.parseBody(repo.commit().add("file4", "contents4").create());
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
     Change change3 = insert(repo, newChangeForCommit(repo, commit3));
@@ -1346,6 +1621,26 @@
   }
 
   @Test
+  public void mergeable() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
+    RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("conflicts:" + change1.getId().get(), change2);
+    assertQuery("conflicts:" + change2.getId().get(), change1);
+    assertQuery("is:mergeable", change2, change1);
+
+    gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).revision("current").submit();
+
+    assertQuery("conflicts:" + change2.getId().get());
+    assertQuery("status:open is:mergeable");
+    assertQuery("status:open -is:mergeable", change2);
+  }
+
+  @Test
   public void reviewedBy() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
     TestRepository<Repo> repo = createProject("repo");
@@ -1353,20 +1648,13 @@
     Change change2 = insert(repo, newChange(repo));
     Change change3 = insert(repo, newChange(repo));
 
-    gApi.changes()
-      .id(change1.getId().get())
-      .current()
-      .review(new ReviewInput().message("comment"));
+    gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
 
-    Account.Id user2 = accountManager
-        .authenticate(AuthRequest.forUser("anotheruser"))
-        .getAccountId();
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     requestContext.setContext(newRequestContext(user2));
 
-    gApi.changes()
-        .id(change2.getId().get())
-        .current()
-        .review(new ReviewInput().message("comment"));
+    gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
     change3 = newPatchSet(repo, change3);
@@ -1378,44 +1666,231 @@
         .review(new ReviewInput().message("comment"));
 
     List<ChangeInfo> actual;
-    actual = assertQuery(
-        newQuery("is:reviewed").withOption(REVIEWED),
-        change3, change2);
+    actual = assertQuery(newQuery("is:reviewed").withOption(REVIEWED), change3, change2);
     assertThat(actual.get(0).reviewed).isTrue();
     assertThat(actual.get(1).reviewed).isTrue();
 
-    actual = assertQuery(
-        newQuery("-is:reviewed").withOption(REVIEWED),
-        change1);
+    actual = assertQuery(newQuery("-is:reviewed").withOption(REVIEWED), change1);
     assertThat(actual.get(0).reviewed).isNull();
 
     actual = assertQuery("reviewedby:" + userId.get());
 
-    actual = assertQuery(
-        newQuery("reviewedby:" + user2.get()).withOption(REVIEWED),
-        change3, change2);
+    actual =
+        assertQuery(newQuery("reviewedby:" + user2.get()).withOption(REVIEWED), change3, change2);
     assertThat(actual.get(0).reviewed).isTrue();
     assertThat(actual.get(1).reviewed).isTrue();
   }
 
   @Test
-  public void reviewer() throws Exception {
+  public void reviewerAndCc() throws Exception {
+    Account.Id user1 = createAccount("user1");
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
     insert(repo, newChange(repo));
 
-    gApi.changes()
-      .id(change1.getId().get())
-      .current()
-      .review(ReviewInput.approve());
-    gApi.changes()
-      .id(change2.getId().get())
-      .current()
-      .review(ReviewInput.approve());
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = user1.toString();
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    Account.Id id = user.getAccountId();
-    assertQuery("reviewer:" + id, change2, change1);
+    rin = new AddReviewerInput();
+    rin.reviewer = user1.toString();
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    assertQuery("is:reviewer");
+    assertQuery("reviewer:self");
+    gApi.changes().id(change3.getChangeId()).revision("current").review(ReviewInput.recommend());
+    assertQuery("is:reviewer", change3);
+    assertQuery("reviewer:self", change3);
+
+    requestContext.setContext(newRequestContext(user1));
+    if (notesMigration.readChanges()) {
+      assertQuery("reviewer:" + user1, change1);
+      assertQuery("cc:" + user1, change2);
+      assertQuery("is:cc", change2);
+      assertQuery("cc:self", change2);
+    } else {
+      assertQuery("reviewer:" + user1, change2, change1);
+      assertQuery("cc:" + user1);
+      assertQuery("is:cc");
+      assertQuery("cc:self");
+    }
+  }
+
+  @Test
+  public void byReviewed() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id otherUser =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    assertQuery("is:reviewed");
+    assertQuery("status:reviewed");
+    assertQuery("-is:reviewed", change2, change1);
+    assertQuery("-status:reviewed", change2, change1);
+
+    requestContext.setContext(newRequestContext(otherUser));
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
+
+    assertQuery("is:reviewed", change1);
+    assertQuery("status:reviewed", change1);
+    assertQuery("-is:reviewed", change2);
+    assertQuery("-status:reviewed", change2);
+  }
+
+  @Test
+  public void reviewerin() throws Exception {
+    Account.Id user1 = accountManager.authenticate(AuthRequest.forUser("user1")).getAccountId();
+    Account.Id user2 = accountManager.authenticate(AuthRequest.forUser("user2")).getAccountId();
+    Account.Id user3 = accountManager.authenticate(AuthRequest.forUser("user3")).getAccountId();
+    TestRepository<Repo> repo = createProject("repo");
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = user1.toString();
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = user2.toString();
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = user3.toString();
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change3.getId().get()).addReviewer(rin);
+
+    String group = gApi.groups().create("foo").get().name;
+    gApi.groups().id(group).addMembers(user2.toString(), user3.toString());
+
+    List<String> members =
+        gApi.groups().id(group).members().stream()
+            .map(a -> a._accountId.toString())
+            .collect(toList());
+    assertThat(members).contains(user2.toString());
+
+    if (notesMigration.readChanges()) {
+      // CC and REVIEWER are separate in NoteDB
+      assertQuery("reviewerin:\"Registered Users\"", change2, change1);
+      assertQuery("reviewerin:" + group, change2);
+    } else {
+      // CC and REVIEWER are the same in ReviewDb
+      assertQuery("reviewerin:\"Registered Users\"", change3, change2, change1);
+      assertQuery("reviewerin:" + group, change3, change2);
+    }
+
+    gApi.changes().id(change2.getId().get()).current().review(ReviewInput.approve());
+    gApi.changes().id(change2.getId().get()).current().submit();
+
+    if (notesMigration.readChanges()) {
+      // CC and REVIEWER are separate in NoteDB
+      assertQuery("reviewerin:" + group);
+      assertQuery("project:repo reviewerin:" + group, change2);
+      assertQuery("status:merged reviewerin:" + group, change2);
+    } else {
+      // CC and REVIEWER are the same in ReviewDb
+      assertQuery("reviewerin:" + group, change3);
+      assertQuery("project:repo reviewerin:" + group, change2, change3);
+      assertQuery("status:merged reviewerin:" + group, change2);
+    }
+  }
+
+  @Test
+  public void submitRecords() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    gApi.changes().id(change1.getId().get()).current().review(ReviewInput.approve());
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes().id(change2.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(user.getAccountId()));
+
+    assertQuery("is:submittable", change1);
+    assertQuery("-is:submittable", change2);
+    assertQuery("submittable:ok", change1);
+    assertQuery("submittable:not_ready", change2);
+
+    assertQuery("label:CodE-RevieW=ok", change1);
+    assertQuery("label:CodE-RevieW=ok,user=user", change1);
+    assertQuery("label:CodE-RevieW=ok,Administrators", change1);
+    assertQuery("label:CodE-RevieW=ok,group=Administrators", change1);
+    assertQuery("label:CodE-RevieW=ok,owner", change1);
+    assertQuery("label:CodE-RevieW=ok,user1");
+    assertQuery("label:CodE-RevieW=need", change2);
+    // NEED records don't have associated users.
+    assertQuery("label:CodE-RevieW=need,user1");
+    assertQuery("label:CodE-RevieW=need,user");
+
+    gApi.changes().id(change1.getId().get()).current().submit();
+    assertQuery("submittable:ok");
+    assertQuery("submittable:closed", change1);
+  }
+
+  @Test
+  public void hasEdit() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    Account.Id user2 = createAccount("user2");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    String changeId1 = change1.getKey().get();
+    Change change2 = insert(repo, newChange(repo));
+    String changeId2 = change2.getKey().get();
+
+    requestContext.setContext(newRequestContext(user1));
+    assertQuery("has:edit");
+    gApi.changes().id(changeId1).edit().create();
+    gApi.changes().id(changeId2).edit().create();
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:edit");
+    gApi.changes().id(changeId2).edit().create();
+
+    requestContext.setContext(newRequestContext(user1));
+    assertQuery("has:edit", change2, change1);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:edit", change2);
+  }
+
+  @Test
+  public void byUnresolved() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+
+    // Change1 has one resolved comment (unresolvedcount = 0)
+    // Change2 has one unresolved comment (unresolvedcount = 1)
+    // Change3 has one resolved comment and one unresolved comment (unresolvedcount = 1)
+    addComment(change1.getChangeId(), "comment 1", false);
+    addComment(change2.getChangeId(), "comment 2", true);
+    addComment(change3.getChangeId(), "comment 3", false);
+    addComment(change3.getChangeId(), "comment 4", true);
+
+    assertQuery("has:unresolved", change3, change2);
+
+    assertQuery("unresolved:0", change1);
+    List<ChangeInfo> changeInfos = assertQuery("unresolved:>=0", change3, change2, change1);
+    assertThat(changeInfos.get(0).unresolvedCommentCount).isEqualTo(1); // Change3
+    assertThat(changeInfos.get(1).unresolvedCommentCount).isEqualTo(1); // Change2
+    assertThat(changeInfos.get(2).unresolvedCommentCount).isEqualTo(0); // Change1
+    assertQuery("unresolved:>0", change3, change2);
+
+    assertQuery("unresolved:<1", change1);
+    assertQuery("unresolved:<=1", change3, change2, change1);
+    assertQuery("unresolved:1", change3, change2);
+    assertQuery("unresolved:>1");
+    assertQuery("unresolved:>=1", change3, change2);
   }
 
   @Test
@@ -1436,19 +1911,12 @@
     }
 
     for (int i = 1; i <= 11; i++) {
-      Iterable<ChangeData> cds = internalChangeQuery.byCommitsOnBranchNotMerged(
-          repo.getRepository(), db, dest, shas, i);
-      Iterable<Integer> ids = FluentIterable.from(cds).transform(
-          new Function<ChangeData, Integer>() {
-            @Override
-            public Integer apply(ChangeData in) {
-              return in.getId().get();
-            }
-          });
+      Iterable<ChangeData> cds =
+          internalChangeQuery.byCommitsOnBranchNotMerged(repo.getRepository(), db, dest, shas, i);
+      Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
       String name = "limit " + i;
       assertThat(ids).named(name).hasSize(n);
-      assertThat(ids).named(name)
-          .containsExactlyElementsIn(expectedIds);
+      assertThat(ids).named(name).containsExactlyElementsIn(expectedIds);
     }
   }
 
@@ -1461,9 +1929,8 @@
     db = new DisabledReviewDb();
     requestContext.setContext(newRequestContext(userId));
     // Use QueryProcessor directly instead of API so we get ChangeDatas back.
-    List<ChangeData> cds = queryProcessor
-        .query(queryBuilder.parse(change.getId().toString()))
-        .entities();
+    List<ChangeData> cds =
+        queryProcessor.query(queryBuilder.parse(change.getId().toString())).entities();
     assertThat(cds).hasSize(1);
 
     ChangeData cd = cds.get(0);
@@ -1473,6 +1940,7 @@
     cd.changedLines();
     cd.reviewedBy();
     cd.reviewers();
+    cd.unresolvedCommentCount();
 
     // TODO(dborowitz): Swap out GitRepositoryManager somehow? Will probably be
     // necessary for NoteDb anyway.
@@ -1491,12 +1959,12 @@
     db = new DisabledReviewDb();
     requestContext.setContext(newRequestContext(userId));
     // Use QueryProcessor directly instead of API so we get ChangeDatas back.
-    List<ChangeData> cds = queryProcessor
-        .setRequestedFields(ImmutableSet.of(
-            ChangeField.PATCH_SET.getName(),
-            ChangeField.CHANGE.getName()))
-        .query(queryBuilder.parse(change.getId().toString()))
-        .entities();
+    List<ChangeData> cds =
+        queryProcessor
+            .setRequestedFields(
+                ImmutableSet.of(ChangeField.PATCH_SET.getName(), ChangeField.CHANGE.getName()))
+            .query(queryBuilder.parse(change.getId().toString()))
+            .entities();
     assertThat(cds).hasSize(1);
 
     ChangeData cd = cds.get(0);
@@ -1507,34 +1975,310 @@
     cd.currentApprovals();
   }
 
-  protected ChangeInserter newChange(TestRepository<Repo> repo)
-      throws Exception {
+  @Test
+  public void reindexIfStale() throws Exception {
+    Account.Id user = createAccount("user");
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    Change change = insert(repo, newChange(repo));
+    String changeId = change.getKey().get();
+    ChangeNotes notes = notesFactory.create(db, change.getProject(), change.getId());
+    PatchSet ps = psUtil.get(db, notes, change.currentPatchSetId());
+
+    requestContext.setContext(newRequestContext(user));
+    gApi.changes().id(changeId).edit().create();
+    assertQuery("has:edit", change);
+    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
+
+    // Delete edit ref behind index's back.
+    RefUpdate ru =
+        repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.getId()));
+    ru.setForceUpdate(true);
+    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+
+    // Index is stale.
+    assertQuery("has:edit", change);
+    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
+    assertQuery("has:edit");
+  }
+
+  @Test
+  public void refStateFields() throws Exception {
+    // This test method manages primary storage manually.
+    assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+    Account.Id user = createAccount("user");
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    String path = "file";
+    RevCommit commit = repo.parseBody(repo.commit().message("one").add(path, "contents").create());
+    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change.Id id = change.getId();
+    int c = id.get();
+    String changeId = change.getKey().get();
+    requestContext.setContext(newRequestContext(user));
+
+    // Ensure one of each type of supported ref is present for the change. If
+    // any more refs are added, update this test to reflect them.
+
+    // Edit
+    gApi.changes().id(changeId).edit().create();
+
+    // Star
+    gApi.accounts().self().starChange(change.getId().toString());
+
+    if (notesMigration.readChanges()) {
+      // Robot comment.
+      ReviewInput rin = new ReviewInput();
+      RobotCommentInput rcin = new RobotCommentInput();
+      rcin.robotId = "happyRobot";
+      rcin.robotRunId = "1";
+      rcin.line = 1;
+      rcin.message = "nit: trailing whitespace";
+      rcin.path = path;
+      rin.robotComments = ImmutableMap.of(path, ImmutableList.of(rcin));
+      gApi.changes().id(c).current().review(rin);
+    }
+
+    // Draft.
+    DraftInput din = new DraftInput();
+    din.path = path;
+    din.line = 1;
+    din.message = "draft";
+    gApi.changes().id(c).current().createDraft(din);
+
+    if (notesMigration.readChanges()) {
+      // Force NoteDb primary.
+      change = ReviewDbUtil.unwrapDb(db).changes().get(id);
+      change.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
+      ReviewDbUtil.unwrapDb(db).changes().update(Collections.singleton(change));
+      indexer.index(db, change);
+    }
+
+    QueryOptions opts =
+        IndexedChangeQuery.createOptions(indexConfig, 0, 1, StalenessChecker.FIELDS);
+    ChangeData cd = indexes.getSearchIndex().get(id, opts).get();
+
+    String cs = RefNames.shard(c);
+    int u = user.get();
+    String us = RefNames.shard(u);
+
+    List<String> expectedStates =
+        Lists.newArrayList(
+            "repo:refs/users/" + us + "/edit-" + c + "/1",
+            "All-Users:refs/starred-changes/" + cs + "/" + u);
+    if (notesMigration.readChanges()) {
+      expectedStates.add("repo:refs/changes/" + cs + "/meta");
+      expectedStates.add("repo:refs/changes/" + cs + "/robot-comments");
+      expectedStates.add("All-Users:refs/draft-comments/" + cs + "/" + u);
+    }
+    assertThat(
+            cd.getRefStates().stream()
+                .map(String::new)
+                // Omit SHA-1, we're just concerned with the project/ref names.
+                .map(s -> s.substring(0, s.lastIndexOf(':')))
+                .collect(toList()))
+        .containsExactlyElementsIn(expectedStates);
+
+    List<String> expectedPatterns = Lists.newArrayList("repo:refs/users/*/edit-" + c + "/*");
+    expectedPatterns.add("All-Users:refs/starred-changes/" + cs + "/*");
+    if (notesMigration.readChanges()) {
+      expectedPatterns.add("All-Users:refs/draft-comments/" + cs + "/*");
+    }
+    assertThat(cd.getRefStatePatterns().stream().map(String::new).collect(toList()))
+        .containsExactlyElementsIn(expectedPatterns);
+  }
+
+  @Test
+  public void watched() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(repo, ins1);
+
+    TestRepository<Repo> repo2 = createProject("repo2");
+
+    ChangeInserter ins2 = newChangeWithStatus(repo2, Change.Status.NEW);
+    insert(repo2, ins2);
+
+    assertQuery("is:watched");
+    assertQuery("watchedby:self");
+
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = "repo";
+    pwi.filter = null;
+    pwi.notifyAbandonedChanges = true;
+    pwi.notifyNewChanges = true;
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+
+    assertQuery("is:watched", change1);
+    assertQuery("watchedby:self", change1);
+  }
+
+  @Test
+  public void trackingid() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 =
+        repo.parseBody(repo.commit().message("Change two\n\nFeature:QUERY456").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    assertQuery("tr:QUERY123", change1);
+    assertQuery("bug:QUERY123", change1);
+    assertQuery("tr:QUERY456", change2);
+    assertQuery("bug:QUERY456", change2);
+    assertQuery("tr:QUERY-123");
+    assertQuery("bug:QUERY-123");
+    assertQuery("tr:QUERY12");
+    assertQuery("bug:QUERY12");
+    assertQuery("tr:QUERY789");
+    assertQuery("bug:QUERY789");
+  }
+
+  @Test
+  public void assignee() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    AssigneeInput input = new AssigneeInput();
+    input.assignee = user.getUserName();
+    gApi.changes().id(change1.getChangeId()).setAssignee(input);
+
+    assertQuery("is:assigned", change1);
+    assertQuery("-is:assigned", change2);
+    assertQuery("is:unassigned", change2);
+    assertQuery("-is:unassigned", change1);
+    assertQuery("assignee:" + user.getUserName(), change1);
+    assertQuery("-assignee:" + user.getUserName(), change2);
+  }
+
+  @Test
+  public void userDestination() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    Change change1 = insert(repo1, newChange(repo1));
+    TestRepository<Repo> repo2 = createProject("repo2");
+    Change change2 = insert(repo2, newChange(repo2));
+
+    assertThatQueryException("destination:foo")
+        .hasMessageThat()
+        .isEqualTo("Unknown named destination: foo");
+
+    String destination1 = "refs/heads/master\trepo1";
+    String destination2 = "refs/heads/master\trepo2";
+    String destination3 = "refs/heads/master\trepo1\nrefs/heads/master\trepo2";
+    String destination4 = "refs/heads/master\trepo3";
+    String destination5 = "refs/heads/other\trepo1";
+
+    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
+    String refsUsers = RefNames.refsUsers(userId);
+    allUsers.branch(refsUsers).commit().add("destinations/destination1", destination1).create();
+    allUsers.branch(refsUsers).commit().add("destinations/destination2", destination2).create();
+    allUsers.branch(refsUsers).commit().add("destinations/destination3", destination3).create();
+    allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
+    allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
+
+    Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+    assertThat(userRef).isNotNull();
+
+    assertQuery("destination:destination1", change1);
+    assertQuery("destination:destination2", change2);
+    assertQuery("destination:destination3", change2, change1);
+    assertQuery("destination:destination4");
+    assertQuery("destination:destination5");
+  }
+
+  @Test
+  public void userQuery() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
+
+    String queries =
+        "query1\tproject:repo\n"
+            + "query2\tproject:repo status:open\n"
+            + "query3\tproject:repo branch:stable\n"
+            + "query4\tproject:repo branch:other";
+
+    TestRepository<Repo> allUsers = new TestRepository<>(repoManager.openRepository(allUsersName));
+    String refsUsers = RefNames.refsUsers(userId);
+    allUsers.branch(refsUsers).commit().add("queries", queries).create();
+
+    Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+    assertThat(userRef).isNotNull();
+
+    assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
+
+    assertQuery("query:query1", change2, change1);
+    assertQuery("query:query2", change2, change1);
+    gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).revision("current").submit();
+    assertQuery("query:query2", change2);
+    assertQuery("query:query3", change2);
+    assertQuery("query:query4");
+  }
+
+  @Test
+  public void byOwnerInvalidQuery() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    insert(repo, newChange(repo), userId);
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+    assertQuery("owner: \"" + nameEmail + "\"\\");
+  }
+
+  @Test
+  public void byDeletedChange() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+
+    String query = "change:" + change.getId();
+    assertQuery(query, change);
+
+    gApi.changes().id(change.getChangeId()).delete();
+    assertQuery(query);
+  }
+
+  @Test
+  public void byUrlEncodedProject() throws Exception {
+    TestRepository<Repo> repo = createProject("repo+foo");
+    Change change = insert(repo, newChange(repo));
+    assertQuery("project:repo+foo", change);
+  }
+
+  protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
     return newChange(repo, null, null, null, null);
   }
 
-  protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo,
-      RevCommit commit) throws Exception {
+  protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
+      throws Exception {
     return newChange(repo, commit, null, null, null);
   }
 
-  protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo,
-      String branch) throws Exception {
+  protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
+      throws Exception {
     return newChange(repo, null, branch, null, null);
   }
 
-  protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo,
-      Change.Status status) throws Exception {
+  protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
+      throws Exception {
     return newChange(repo, null, null, status, null);
   }
 
-  protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo,
-      String topic) throws Exception {
+  protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
+      throws Exception {
     return newChange(repo, null, null, null, topic);
   }
 
-  protected ChangeInserter newChange(TestRepository<Repo> repo,
-      @Nullable RevCommit commit, @Nullable String branch,
-      @Nullable Change.Status status, @Nullable String topic) throws Exception {
+  protected ChangeInserter newChange(
+      TestRepository<Repo> repo,
+      @Nullable RevCommit commit,
+      @Nullable String branch,
+      @Nullable Change.Status status,
+      @Nullable String topic)
+      throws Exception {
     if (commit == null) {
       commit = repo.parseBody(repo.commit().message("message").create());
     }
@@ -1545,51 +2289,56 @@
     }
 
     Change.Id id = new Change.Id(seq.nextChangeId());
-    ChangeInserter ins = changeFactory.create(
-        id, commit, branch)
-        .setValidatePolicy(CommitValidators.Policy.NONE)
-        .setStatus(status)
-        .setTopic(topic);
+    ChangeInserter ins =
+        changeFactory
+            .create(id, commit, branch)
+            .setValidatePolicy(CommitValidators.Policy.NONE)
+            .setStatus(status)
+            .setTopic(topic);
     return ins;
   }
 
   protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
-    return insert(repo, ins, null);
+    return insert(repo, ins, null, TimeUtil.nowTs());
   }
 
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins,
-      @Nullable Account.Id owner) throws Exception {
-    Project.NameKey project = new Project.NameKey(
-        repo.getRepository().getDescription().getRepositoryName());
+  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
+      throws Exception {
+    return insert(repo, ins, owner, TimeUtil.nowTs());
+  }
+
+  protected Change insert(
+      TestRepository<Repo> repo,
+      ChangeInserter ins,
+      @Nullable Account.Id owner,
+      Timestamp createdOn)
+      throws Exception {
+    Project.NameKey project =
+        new Project.NameKey(repo.getRepository().getDescription().getRepositoryName());
     Account.Id ownerId = owner != null ? owner : userId;
     IdentifiedUser user = userFactory.create(ownerId);
-    try (BatchUpdate bu =
-        updateFactory.create(db, project, user, TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(db, project, user, createdOn)) {
       bu.insertChange(ins);
       bu.execute();
       return ins.getChange();
     }
   }
 
-  protected Change newPatchSet(TestRepository<Repo> repo, Change c)
-      throws Exception {
+  protected Change newPatchSet(TestRepository<Repo> repo, Change c) throws Exception {
     // Add a new file so the patch set is not a trivial rebase, to avoid default
     // Code-Review label copying.
     int n = c.currentPatchSetId().get() + 1;
-    RevCommit commit = repo.parseBody(
-        repo.commit()
-            .message("message")
-            .add("file" + n, "contents " + n)
-            .create());
+    RevCommit commit =
+        repo.parseBody(repo.commit().message("message").add("file" + n, "contents " + n).create());
     ChangeControl ctl = changeControlFactory.controlFor(db, c, user);
 
-    PatchSetInserter inserter = patchSetFactory.create(
-          ctl, new PatchSet.Id(c.getId(), n), commit)
-        .setSendMail(false)
-        .setFireRevisionCreated(false)
-        .setValidatePolicy(CommitValidators.Policy.NONE);
-    try (BatchUpdate bu = updateFactory.create(
-        db, c.getProject(), user, TimeUtil.nowTs());
+    PatchSetInserter inserter =
+        patchSetFactory
+            .create(ctl, new PatchSet.Id(c.getId(), n), commit)
+            .setNotify(NotifyHandling.NONE)
+            .setFireRevisionCreated(false)
+            .setValidatePolicy(CommitValidators.Policy.NONE);
+    try (BatchUpdate bu = updateFactory.create(db, c.getProject(), user, TimeUtil.nowTs());
         ObjectInserter oi = repo.getRepository().newObjectInserter()) {
       bu.setRepository(repo.getRepository(), repo.getRevWalk(), oi);
       bu.addOp(c.getId(), inserter);
@@ -1599,75 +2348,85 @@
     return inserter.getChange();
   }
 
-  protected void assertBadQuery(Object query) throws Exception {
-    assertBadQuery(newQuery(query));
+  protected ThrowableSubject assertThatQueryException(Object query) throws Exception {
+    return assertThatQueryException(newQuery(query));
   }
 
-  protected void assertBadQuery(QueryRequest query) throws Exception {
+  protected ThrowableSubject assertThatQueryException(QueryRequest query) throws Exception {
     try {
       query.get();
-      fail("expected BadRequestException for query: " + query);
+      throw new AssertionError("expected BadRequestException for query: " + query);
     } catch (BadRequestException e) {
-      // Expected.
+      return assertThat(e);
     }
   }
 
   protected TestRepository<Repo> createProject(String name) throws Exception {
     gApi.projects().create(name).get();
-    return new TestRepository<>(
-        repoManager.openRepository(new Project.NameKey(name)));
+    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
+  }
+
+  protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
+    ProjectInput input = new ProjectInput();
+    input.name = name;
+    input.parent = parent;
+    gApi.projects().create(input).get();
+    return new TestRepository<>(repoManager.openRepository(new Project.NameKey(name)));
   }
 
   protected QueryRequest newQuery(Object query) {
     return gApi.changes().query(query.toString());
   }
 
-  protected List<ChangeInfo> assertQuery(Object query, Change... changes)
-      throws Exception {
+  protected List<ChangeInfo> assertQuery(Object query, Change... changes) throws Exception {
     return assertQuery(newQuery(query), changes);
   }
 
-  protected List<ChangeInfo> assertQuery(QueryRequest query, Change... changes)
-      throws Exception {
+  protected List<ChangeInfo> assertQuery(QueryRequest query, Change... changes) throws Exception {
     List<ChangeInfo> result = query.get();
     Iterable<Integer> ids = ids(result);
-    assertThat(ids).named(format(query, ids, changes))
-        .containsExactlyElementsIn(ids(changes)).inOrder();
+    assertThat(ids)
+        .named(format(query, ids, changes))
+        .containsExactlyElementsIn(ids(changes))
+        .inOrder();
     return result;
   }
 
-  private String format(QueryRequest query, Iterable<Integer> actualIds,
-      Change... expectedChanges) throws RestApiException {
+  private String format(QueryRequest query, Iterable<Integer> actualIds, Change... expectedChanges)
+      throws RestApiException {
     StringBuilder b = new StringBuilder();
-    b.append("query '").append(query.getQuery())
-     .append("' with expected changes ");
-    b.append(format(Iterables.transform(Arrays.asList(expectedChanges),
-        new Function<Change, Integer>() {
-          @Override
-          public Integer apply(Change change) {
-            return change.getChangeId();
-          }
-        })));
+    b.append("query '").append(query.getQuery()).append("' with expected changes ");
+    b.append(format(Arrays.stream(expectedChanges).map(Change::getChangeId).iterator()));
     b.append(" and result ");
     b.append(format(actualIds));
     return b.toString();
   }
 
   private String format(Iterable<Integer> changeIds) throws RestApiException {
+    return format(changeIds.iterator());
+  }
+
+  private String format(Iterator<Integer> changeIds) throws RestApiException {
     StringBuilder b = new StringBuilder();
     b.append("[");
-    Iterator<Integer> it = changeIds.iterator();
-    while (it.hasNext()) {
-      int id = it.next();
+    while (changeIds.hasNext()) {
+      int id = changeIds.next();
       ChangeInfo c = gApi.changes().id(id).get();
-      b.append("{").append(id).append(" (").append(c.changeId)
-          .append("), ").append("dest=").append(
-              new Branch.NameKey(
-                  new Project.NameKey(c.project), c.branch)).append(", ")
-          .append("status=").append(c.status).append(", ")
-          .append("lastUpdated=").append(c.updated.getTime())
+      b.append("{")
+          .append(id)
+          .append(" (")
+          .append(c.changeId)
+          .append("), ")
+          .append("dest=")
+          .append(new Branch.NameKey(new Project.NameKey(c.project), c.branch))
+          .append(", ")
+          .append("status=")
+          .append(c.status)
+          .append(", ")
+          .append("lastUpdated=")
+          .append(c.updated.getTime())
           .append("}");
-      if (it.hasNext()) {
+      if (changeIds.hasNext()) {
         b.append(", ");
       }
     }
@@ -1676,26 +2435,26 @@
   }
 
   protected static Iterable<Integer> ids(Change... changes) {
-    return FluentIterable.from(Arrays.asList(changes)).transform(
-        new Function<Change, Integer>() {
-          @Override
-          public Integer apply(Change in) {
-            return in.getId().get();
-          }
-        });
+    return FluentIterable.from(Arrays.asList(changes)).transform(in -> in.getId().get());
   }
 
   protected static Iterable<Integer> ids(Iterable<ChangeInfo> changes) {
-    return FluentIterable.from(changes).transform(
-        new Function<ChangeInfo, Integer>() {
-          @Override
-          public Integer apply(ChangeInfo in) {
-            return in._number;
-          }
-        });
+    return FluentIterable.from(changes).transform(in -> in._number);
   }
 
   protected static long lastUpdatedMs(Change c) {
     return c.getLastUpdatedOn().getTime();
   }
+
+  private void addComment(int changeId, String message, Boolean unresolved) throws Exception {
+    ReviewInput input = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.line = 1;
+    comment.message = message;
+    comment.unresolved = unresolved;
+    input.comments =
+        ImmutableMap.<String, List<ReviewInput.CommentInput>>of(
+            Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
+    gApi.changes().id(changeId).current().review(input);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java
index eb19ebe..def0b08 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testutil.TestChanges;
-
 import org.junit.Test;
 
 public class ChangeDataTest {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 038abda..9362ce9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -30,18 +30,15 @@
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
     InMemoryModule.setDefaults(luceneConfig);
-    return Guice.createInjector(
-        new InMemoryModule(luceneConfig, notesMigration));
+    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
   }
 
   @Test
   public void fullTextWithSpecialChars() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    RevCommit commit1 =
-        repo.parseBody(repo.commit().message("foo_bar_foo").create());
+    RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    RevCommit commit2 =
-        repo.parseBody(repo.commit().message("one.two.three").create());
+    RevCommit commit2 = repo.parseBody(repo.commit().message("one.two.three").create());
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
 
     assertQuery("message:foo_ba");
@@ -52,4 +49,16 @@
     assertQuery("message:one.two", change2);
     assertQuery("message:one two", change2);
   }
+
+  @Test
+  @Override
+  public void byOwnerInvalidQuery() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    String nameEmail = user.asIdentifiedUser().getNameEmail();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Cannot create full-text query with value: \\");
+    assertQuery("owner: \"" + nameEmail + "\"\\", change1);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
index 5532108..a13a8f7 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/RegexPathPredicateTest.java
@@ -20,14 +20,12 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwtorm.server.OrmException;
-
-import org.junit.Test;
-
 import java.util.Arrays;
+import org.junit.Test;
 
 public class RegexPathPredicateTest {
   @Test
-  public void testPrefixOnlyOptimization() throws OrmException {
+  public void prefixOnlyOptimization() throws OrmException {
     RegexPathPredicate p = predicate("^a/b/.*");
     assertTrue(p.match(change("a/b/source.c")));
     assertFalse(p.match(change("source.c")));
@@ -37,7 +35,7 @@
   }
 
   @Test
-  public void testPrefixReducesSearchSpace() throws OrmException {
+  public void prefixReducesSearchSpace() throws OrmException {
     RegexPathPredicate p = predicate("^a/b/.*\\.[ch]");
     assertTrue(p.match(change("a/b/source.c")));
     assertFalse(p.match(change("a/b/source.res")));
@@ -47,7 +45,7 @@
   }
 
   @Test
-  public void testFileExtension_Constant() throws OrmException {
+  public void fileExtension_Constant() throws OrmException {
     RegexPathPredicate p = predicate("^.*\\.res");
     assertTrue(p.match(change("test.res")));
     assertTrue(p.match(change("foo/bar/test.res")));
@@ -55,7 +53,7 @@
   }
 
   @Test
-  public void testFileExtension_CharacterGroup() throws OrmException {
+  public void fileExtension_CharacterGroup() throws OrmException {
     RegexPathPredicate p = predicate("^.*\\.[ch]");
     assertTrue(p.match(change("test.c")));
     assertTrue(p.match(change("test.h")));
@@ -63,7 +61,7 @@
   }
 
   @Test
-  public void testEndOfString() throws OrmException {
+  public void endOfString() throws OrmException {
     assertTrue(predicate("^a$").match(change("a")));
     assertFalse(predicate("^a$").match(change("a$")));
 
@@ -72,7 +70,7 @@
   }
 
   @Test
-  public void testExactMatch() throws OrmException {
+  public void exactMatch() throws OrmException {
     RegexPathPredicate p = predicate("^foo.c");
     assertTrue(p.match(change("foo.c")));
     assertFalse(p.match(change("foo.cc")));
@@ -85,8 +83,7 @@
 
   private static ChangeData change(String... files) throws OrmException {
     Arrays.sort(files);
-    ChangeData cd = ChangeData.createForTest(new Project.NameKey("project"),
-        new Change.Id(1), 1);
+    ChangeData cd = ChangeData.createForTest(new Project.NameKey("project"), new Change.Id(1), 1);
     cd.setCurrentFilePaths(Arrays.asList(files));
     return cd;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
new file mode 100644
index 0000000..5cf5d23
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -0,0 +1,464 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.groups.Groups.QueryRequest;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.lifecycle.LifecycleManager;
+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.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.GerritServerTests;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class AbstractQueryGroupsTest extends GerritServerTests {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setInt("index", null, "maxPages", 10);
+    return cfg;
+  }
+
+  @Inject protected AccountCache accountCache;
+
+  @Inject protected AccountManager accountManager;
+
+  @Inject protected GerritApi gApi;
+
+  @Inject protected IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private Provider<AnonymousUser> anonymousUser;
+
+  @Inject protected InMemoryDatabase schemaFactory;
+
+  @Inject protected SchemaCreator schemaCreator;
+
+  @Inject protected ThreadLocalRequestContext requestContext;
+
+  @Inject protected OneOffRequestContext oneOffRequestContext;
+
+  @Inject protected InternalAccountQuery internalAccountQuery;
+
+  @Inject protected AllProjectsName allProjects;
+
+  @Inject protected GroupCache groupCache;
+
+  @Inject private GroupIndexCollection groupIndexes;
+
+  protected Injector injector;
+  protected LifecycleManager lifecycle;
+  protected ReviewDb db;
+  protected AccountInfo currentUserInfo;
+  protected CurrentUser user;
+
+  protected abstract Injector createInjector();
+
+  @Before
+  public void setUpInjector() throws Exception {
+    lifecycle = new LifecycleManager();
+    injector = createInjector();
+    lifecycle.add(injector);
+    injector.injectMembers(this);
+    lifecycle.start();
+    initAfterLifecycleStart();
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+
+    Account.Id userId = createAccount("user", "User", "user@example.com", true);
+    user = userFactory.create(userId);
+    requestContext.setContext(newRequestContext(userId));
+    currentUserInfo = gApi.accounts().id(userId.get()).get();
+  }
+
+  @After
+  public void cleanUp() {
+    lifecycle.stop();
+    db.close();
+  }
+
+  protected void initAfterLifecycleStart() throws Exception {}
+
+  protected RequestContext newRequestContext(Account.Id requestUserId) {
+    final CurrentUser requestUser = userFactory.create(requestUserId);
+    return new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return requestUser;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    };
+  }
+
+  protected void setAnonymous() {
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return anonymousUser.get();
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  @After
+  public void tearDownInjector() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void byUuid() throws Exception {
+    assertQuery("uuid:6d70856bc40ded50f2585c4c0f7e179f3544a272");
+    assertQuery("uuid:non-existing");
+
+    GroupInfo group = createGroup(name("group"));
+    assertQuery("uuid:" + group.id, group);
+
+    GroupInfo admins = gApi.groups().id("Administrators").get();
+    assertQuery("uuid:" + admins.id, admins);
+  }
+
+  @Test
+  public void byName() throws Exception {
+    assertQuery("name:non-existing");
+
+    GroupInfo group = createGroup(name("group"));
+    assertQuery("name:" + group.name, group);
+    assertQuery("name:" + group.name.toUpperCase(Locale.US), group);
+
+    // only exact match
+    GroupInfo groupWithHyphen = createGroup(name("group-with-hyphen"));
+    createGroup(name("group-no-match-with-hyphen"));
+    assertQuery("name:" + groupWithHyphen.name, groupWithHyphen);
+  }
+
+  @Test
+  public void byInname() throws Exception {
+    String namePart = testName.getMethodName();
+    GroupInfo group1 = createGroup("group-" + namePart);
+    GroupInfo group2 = createGroup("group-" + namePart + "-2");
+    GroupInfo group3 = createGroup("group-" + namePart + "3");
+    assertQuery("inname:" + namePart, group1, group2, group3);
+    assertQuery("inname:" + namePart.toUpperCase(Locale.US), group1, group2, group3);
+    assertQuery("inname:" + namePart.toLowerCase(Locale.US), group1, group2, group3);
+  }
+
+  @Test
+  public void byDescription() throws Exception {
+    GroupInfo group1 = createGroupWithDescription(name("group1"), "This is a test group.");
+    GroupInfo group2 = createGroupWithDescription(name("group2"), "ANOTHER TEST GROUP.");
+    createGroupWithDescription(name("group3"), "Maintainers of project foo.");
+    assertQuery("description:test", group1, group2);
+
+    assertQuery("description:non-existing");
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("description operator requires a value");
+    assertQuery("description:\"\"");
+  }
+
+  @Test
+  public void byOwner() throws Exception {
+    GroupInfo ownerGroup = createGroup(name("owner-group"));
+    GroupInfo group = createGroupWithOwner(name("group"), ownerGroup);
+    createGroup(name("group2"));
+
+    assertQuery("owner:" + group.id);
+
+    // ownerGroup owns itself
+    assertQuery("owner:" + ownerGroup.id, group, ownerGroup);
+    assertQuery("owner:" + ownerGroup.name, group, ownerGroup);
+  }
+
+  @Test
+  public void byIsVisibleToAll() throws Exception {
+    assertQuery("is:visibletoall");
+
+    GroupInfo groupThatIsVisibleToAll =
+        createGroupThatIsVisibleToAll(name("group-that-is-visible-to-all"));
+    createGroup(name("group"));
+
+    assertQuery("is:visibletoall", groupThatIsVisibleToAll);
+  }
+
+  @Test
+  public void byDefaultField() throws Exception {
+    GroupInfo group1 = createGroup(name("foo-group"));
+    GroupInfo group2 = createGroup(name("group2"));
+    GroupInfo group3 =
+        createGroupWithDescription(
+            name("group3"), "decription that contains foo and the UUID of group2: " + group2.id);
+
+    assertQuery("non-existing");
+    assertQuery("foo", group1, group3);
+    assertQuery(group2.id, group2, group3);
+  }
+
+  @Test
+  public void withLimit() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    GroupInfo group2 = createGroup(name("group2"));
+    GroupInfo group3 = createGroup(name("group3"));
+
+    String query = "uuid:" + group1.id + " OR uuid:" + group2.id + " OR uuid:" + group3.id;
+    List<GroupInfo> result = assertQuery(query, group1, group2, group3);
+    assertThat(result.get(result.size() - 1)._moreGroups).isNull();
+
+    result = assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+    assertThat(result.get(result.size() - 1)._moreGroups).isTrue();
+  }
+
+  @Test
+  public void withStart() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    GroupInfo group2 = createGroup(name("group2"));
+    GroupInfo group3 = createGroup(name("group3"));
+
+    String query = "uuid:" + group1.id + " OR uuid:" + group2.id + " OR uuid:" + group3.id;
+    List<GroupInfo> result = assertQuery(query, group1, group2, group3);
+
+    assertQuery(newQuery(query).withStart(1), result.subList(1, 3));
+  }
+
+  @Test
+  public void asAnonymous() throws Exception {
+    GroupInfo group = createGroup(name("group"));
+
+    setAnonymous();
+    assertQuery("uuid:" + group.id);
+  }
+
+  // reindex permissions are tested by {@link GroupsIT#reindexPermissions}
+  @Test
+  public void reindex() throws Exception {
+    GroupInfo group1 = createGroupWithDescription(name("group"), "barX");
+
+    // update group in the database so that group index is stale
+    String newDescription = "barY";
+    AccountGroup group = db.accountGroups().get(new AccountGroup.Id(group1.groupId));
+    group.setDescription(newDescription);
+    db.accountGroups().update(Collections.singleton(group));
+
+    assertQuery("description:" + group1.description, group1);
+    assertQuery("description:" + newDescription);
+
+    gApi.groups().id(group1.id).index();
+    assertQuery("description:" + group1.description);
+    assertQuery("description:" + newDescription, group1);
+  }
+
+  @Test
+  public void byDeletedGroup() throws Exception {
+    GroupInfo group = createGroup(name("group"));
+    String query = "uuid:" + group.id;
+    assertQuery(query, group);
+
+    AccountGroup account = db.accountGroups().get(new AccountGroup.Id(group.groupId));
+    for (GroupIndex index : groupIndexes.getWriteIndexes()) {
+      index.delete(account.getGroupUUID());
+    }
+    assertQuery(query);
+  }
+
+  private Account.Id createAccount(String username, String fullName, String email, boolean active)
+      throws Exception {
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      if (email != null) {
+        accountManager.link(id, AuthRequest.forEmail(email));
+      }
+      Account a = db.accounts().get(id);
+      a.setFullName(fullName);
+      a.setPreferredEmail(email);
+      a.setActive(active);
+      db.accounts().update(ImmutableList.of(a));
+      accountCache.evict(id);
+      return id;
+    }
+  }
+
+  protected GroupInfo createGroup(String name, AccountInfo... members) throws Exception {
+    return createGroupWithDescription(name, null, members);
+  }
+
+  protected GroupInfo createGroupWithDescription(
+      String name, String description, AccountInfo... members) throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.description = description;
+    in.members =
+        Arrays.asList(members).stream().map(a -> String.valueOf(a._accountId)).collect(toList());
+    return gApi.groups().create(in).get();
+  }
+
+  protected GroupInfo createGroupWithOwner(String name, GroupInfo ownerGroup) throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.ownerId = ownerGroup.id;
+    return gApi.groups().create(in).get();
+  }
+
+  protected GroupInfo createGroupThatIsVisibleToAll(String name) throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.visibleToAll = true;
+    return gApi.groups().create(in).get();
+  }
+
+  protected GroupInfo getGroup(AccountGroup.UUID uuid) throws Exception {
+    return gApi.groups().id(uuid.get()).get();
+  }
+
+  protected List<GroupInfo> assertQuery(Object query, GroupInfo... groups) throws Exception {
+    return assertQuery(newQuery(query), groups);
+  }
+
+  protected List<GroupInfo> assertQuery(QueryRequest query, GroupInfo... groups) throws Exception {
+    return assertQuery(query, Arrays.asList(groups));
+  }
+
+  protected List<GroupInfo> assertQuery(QueryRequest query, List<GroupInfo> groups)
+      throws Exception {
+    List<GroupInfo> result = query.get();
+    Iterable<String> uuids = uuids(result);
+    assertThat(uuids).named(format(query, result, groups)).containsExactlyElementsIn(uuids(groups));
+    return result;
+  }
+
+  protected QueryRequest newQuery(Object query) {
+    return gApi.groups().query(query.toString());
+  }
+
+  protected String format(
+      QueryRequest query, List<GroupInfo> actualGroups, List<GroupInfo> expectedGroups) {
+    StringBuilder b = new StringBuilder();
+    b.append("query '").append(query.getQuery()).append("' with expected groups ");
+    b.append(format(expectedGroups));
+    b.append(" and result ");
+    b.append(format(actualGroups));
+    return b.toString();
+  }
+
+  protected String format(Iterable<GroupInfo> groups) {
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    Iterator<GroupInfo> it = groups.iterator();
+    while (it.hasNext()) {
+      GroupInfo g = it.next();
+      b.append("{")
+          .append(g.id)
+          .append(", ")
+          .append("name=")
+          .append(g.name)
+          .append(", ")
+          .append("groupId=")
+          .append(g.groupId)
+          .append(", ")
+          .append("url=")
+          .append(g.url)
+          .append(", ")
+          .append("ownerId=")
+          .append(g.ownerId)
+          .append(", ")
+          .append("owner=")
+          .append(g.owner)
+          .append(", ")
+          .append("description=")
+          .append(g.description)
+          .append(", ")
+          .append("visibleToAll=")
+          .append(toBoolean(g.options.visibleToAll))
+          .append("}");
+      if (it.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+
+  protected static boolean toBoolean(Boolean b) {
+    return b == null ? false : b;
+  }
+
+  protected static Iterable<String> ids(GroupInfo... groups) {
+    return uuids(Arrays.asList(groups));
+  }
+
+  protected static Iterable<String> uuids(List<GroupInfo> groups) {
+    return groups.stream().map(g -> g.id).collect(toList());
+  }
+
+  protected String name(String name) {
+    if (name == null) {
+      return null;
+    }
+    return name + "_" + testName.getMethodName().toLowerCase();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
new file mode 100644
index 0000000..0551e92
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.group;
+
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneQueryGroupsTest extends AbstractQueryGroupsTest {
+  @Override
+  protected Injector createInjector() {
+    Config luceneConfig = new Config(config);
+    InMemoryModule.setDefaults(luceneConfig);
+    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
index cb0ab11..76bee6f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
@@ -32,7 +32,7 @@
   }
 
   @Test
-  public void testGetUrl() throws Exception {
+  public void getUrl() throws Exception {
     config.setString("database", null, "instance", "3");
     assertThat(hana.getUrl()).isEqualTo("jdbc:sap://my.host:30315");
 
@@ -41,7 +41,7 @@
   }
 
   @Test
-  public void testGetIndexScript() throws Exception {
+  public void getIndexScript() throws Exception {
     assertThat(hana.getIndexScript()).isSameAs(ScriptRunner.NOOP);
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
index cd6e825..7eda3cc 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
@@ -14,17 +14,13 @@
 
 package com.google.gerrit.server.schema;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ProjectConfig;
@@ -33,12 +29,6 @@
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.lib.Repository;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.io.File;
 import java.io.IOException;
 import java.sql.ResultSet;
@@ -46,43 +36,35 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
 
 public class SchemaCreatorTest {
-  @Inject
-  private AllProjectsName allProjects;
+  @Inject private AllProjectsName allProjects;
 
-  @Inject
-  private GitRepositoryManager repoManager;
+  @Inject private GitRepositoryManager repoManager;
 
-  @Inject
-  private InMemoryDatabase db;
-
-  private LifecycleManager lifecycle;
+  @Inject private InMemoryDatabase db;
 
   @Before
   public void setUp() throws Exception {
-    lifecycle = new LifecycleManager();
     new InMemoryModule().inject(this);
-    lifecycle.start();
   }
 
   @After
   public void tearDown() throws Exception {
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
     InMemoryDatabase.drop(db);
   }
 
   @Test
-  public void testGetCauses_CreateSchema() throws OrmException, SQLException,
-      IOException {
+  public void getCauses_CreateSchema() throws OrmException, SQLException, IOException {
     // Initially the schema should be empty.
     String[] types = {"TABLE", "VIEW"};
     try (JdbcSchema d = (JdbcSchema) db.open();
-        ResultSet rs = d.getConnection().getMetaData()
-          .getTables(null, null, null, types)) {
-      assertFalse(rs.next());
+        ResultSet rs = d.getConnection().getMetaData().getTables(null, null, null, types)) {
+      assertThat(rs.next()).isFalse();
     }
 
     // Create the schema using the current schema version.
@@ -96,7 +78,7 @@
     if (sitePath.getName().equals(".")) {
       sitePath = sitePath.getParentFile();
     }
-    assertEquals(sitePath.getCanonicalPath(), db.getSystemConfig().sitePath);
+    assertThat(db.getSystemConfig().sitePath).isEqualTo(sitePath.getCanonicalPath());
   }
 
   private LabelTypes getLabelTypes() throws Exception {
@@ -104,38 +86,36 @@
     ProjectConfig c = new ProjectConfig(allProjects);
     try (Repository repo = repoManager.openRepository(allProjects)) {
       c.load(repo);
-      return new LabelTypes(
-          ImmutableList.copyOf(c.getLabelSections().values()));
+      return new LabelTypes(ImmutableList.copyOf(c.getLabelSections().values()));
     }
   }
 
   @Test
-  public void testCreateSchema_LabelTypes() throws Exception {
+  public void createSchema_LabelTypes() throws Exception {
     List<String> labels = new ArrayList<>();
     for (LabelType label : getLabelTypes().getLabelTypes()) {
       labels.add(label.getName());
     }
-    assertEquals(ImmutableList.of("Code-Review"), labels);
+    assertThat(labels).containsExactly("Code-Review");
   }
 
   @Test
-  public void testCreateSchema_Label_CodeReview() throws Exception {
+  public void createSchema_Label_CodeReview() throws Exception {
     LabelType codeReview = getLabelTypes().byLabel("Code-Review");
-    assertNotNull(codeReview);
-    assertEquals("Code-Review", codeReview.getName());
-    assertEquals(0, codeReview.getDefaultValue());
-    assertEquals("MaxWithBlock", codeReview.getFunctionName());
-    assertTrue(codeReview.isCopyMinScore());
+    assertThat(codeReview).isNotNull();
+    assertThat(codeReview.getName()).isEqualTo("Code-Review");
+    assertThat(codeReview.getDefaultValue()).isEqualTo(0);
+    assertThat(codeReview.getFunctionName()).isEqualTo("MaxWithBlock");
+    assertThat(codeReview.isCopyMinScore()).isTrue();
     assertValueRange(codeReview, 2, 1, 0, -1, -2);
   }
 
   private void assertValueRange(LabelType label, Integer... range) {
-    assertEquals(Arrays.asList(range), label.getValuesAsList());
-    assertEquals(range[0], Integer.valueOf(label.getMax().getValue()));
-    assertEquals(range[range.length - 1],
-      Integer.valueOf(label.getMin().getValue()));
+    assertThat(label.getValuesAsList()).containsExactlyElementsIn(Arrays.asList(range)).inOrder();
+    assertThat(label.getMax().getValue()).isEqualTo(range[0]);
+    assertThat(label.getMin().getValue()).isEqualTo(range[range.length - 1]);
     for (LabelValue v : label.getValues()) {
-      assertFalse(Strings.isNullOrEmpty(v.getText()));
+      assertThat(Strings.isNullOrEmpty(v.getText())).isFalse();
     }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index a161405..9a32365 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -29,6 +29,8 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.ConfigNotesMigration;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryH2Type;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
@@ -36,20 +38,19 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Guice;
+import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
 import java.util.UUID;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
 
 public class SchemaUpdaterTest {
   private LifecycleManager lifecycle;
@@ -71,72 +72,88 @@
   }
 
   @Test
-  public void testUpdate() throws OrmException, FileNotFoundException,
-      IOException {
+  public void update() throws OrmException, FileNotFoundException, IOException {
     db.create();
 
     final Path site = Paths.get(UUID.randomUUID().toString());
     final SitePaths paths = new SitePaths(site);
-    SchemaUpdater u = Guice.createInjector(new FactoryModule() {
-      @Override
-      protected void configure() {
-        bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).toInstance(db);
-        bind(SitePaths.class).toInstance(paths);
+    SchemaUpdater u =
+        Guice.createInjector(
+                new FactoryModule() {
+                  @Override
+                  protected void configure() {
+                    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).toInstance(db);
+                    bind(SitePaths.class).toInstance(paths);
 
-        Config cfg = new Config();
-        cfg.setString("user", null, "name", "Gerrit Code Review");
-        cfg.setString("user", null, "email", "gerrit@localhost");
+                    Config cfg = new Config();
+                    cfg.setString("user", null, "name", "Gerrit Code Review");
+                    cfg.setString("user", null, "email", "gerrit@localhost");
 
-        bind(Config.class) //
-            .annotatedWith(GerritServerConfig.class) //
-            .toInstance(cfg);
+                    bind(Config.class) //
+                        .annotatedWith(GerritServerConfig.class) //
+                        .toInstance(cfg);
 
-        bind(PersonIdent.class) //
-            .annotatedWith(GerritPersonIdent.class) //
-            .toProvider(GerritPersonIdentProvider.class);
+                    bind(PersonIdent.class) //
+                        .annotatedWith(GerritPersonIdent.class) //
+                        .toProvider(GerritPersonIdentProvider.class);
 
-        bind(AllProjectsName.class)
-            .toInstance(new AllProjectsName("All-Projects"));
-        bind(AllUsersName.class)
-            .toInstance(new AllUsersName("All-Users"));
+                    bind(AllProjectsName.class).toInstance(new AllProjectsName("All-Projects"));
+                    bind(AllUsersName.class).toInstance(new AllUsersName("All-Users"));
 
-        bind(GitRepositoryManager.class)
-            .toInstance(new InMemoryRepositoryManager());
+                    bind(GitRepositoryManager.class).toInstance(new InMemoryRepositoryManager());
 
-        bind(String.class) //
-          .annotatedWith(AnonymousCowardName.class) //
-          .toProvider(AnonymousCowardNameProvider.class);
+                    bind(String.class) //
+                        .annotatedWith(AnonymousCowardName.class) //
+                        .toProvider(AnonymousCowardNameProvider.class);
 
-        bind(DataSourceType.class).to(InMemoryH2Type.class);
+                    bind(DataSourceType.class).to(InMemoryH2Type.class);
+
+                    bind(SystemGroupBackend.class);
+                    install(new ConfigNotesMigration.Module());
+                  }
+                })
+            .getInstance(SchemaUpdater.class);
+
+    for (SchemaVersion s = u.getLatestSchemaVersion(); s.getVersionNbr() > 1; s = s.getPrior()) {
+      try {
+        assertThat(s.getPrior().getVersionNbr())
+            .named(
+                "schema %s has prior version %s. Not true that",
+                s.getVersionNbr(), s.getPrior().getVersionNbr())
+            .isEqualTo(s.getVersionNbr() - 1);
+      } catch (ProvisionException e) {
+        // Ignored
+        // The oldest supported schema version doesn't have a prior schema
+        // version.
+        break;
       }
-    }).getInstance(SchemaUpdater.class);
+    }
 
-    u.update(new UpdateUI() {
-      @Override
-      public void message(String msg) {
-      }
+    u.update(
+        new UpdateUI() {
+          @Override
+          public void message(String msg) {}
 
-      @Override
-      public boolean yesno(boolean def, String msg) {
-        return def;
-      }
+          @Override
+          public boolean yesno(boolean def, String msg) {
+            return def;
+          }
 
-      @Override
-      public boolean isBatch() {
-        return true;
-      }
+          @Override
+          public boolean isBatch() {
+            return true;
+          }
 
-      @Override
-      public void pruneSchema(StatementExecutor e, List<String> pruneList)
-          throws OrmException {
-        for (String sql : pruneList) {
-          e.execute(sql);
-        }
-      }
-    });
+          @Override
+          public void pruneSchema(StatementExecutor e, List<String> pruneList) throws OrmException {
+            for (String sql : pruneList) {
+              e.execute(sql);
+            }
+          }
+        });
 
     db.assertSchemaVersion();
     final SystemConfig sc = db.getSystemConfig();
-    assertEquals(paths.site_path.toAbsolutePath().toString(), sc.sitePath);
+    assertThat(sc.sitePath).isEqualTo(paths.site_path.toAbsolutePath().toString());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
index c5d9151..f53a59b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
@@ -20,7 +20,10 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.gerrit.server.util.HostPlatform;
-
+import java.io.File;
+import java.io.IOException;
+import java.util.Date;
+import java.util.TimeZone;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -35,11 +38,6 @@
 import org.junit.BeforeClass;
 import org.junit.Test;
 
-import java.io.File;
-import java.io.IOException;
-import java.util.Date;
-import java.util.TimeZone;
-
 public class CommitMsgHookTest extends HookTestCase {
   private final String SOB1 = "Signed-off-by: J Author <ja@example.com>\n";
   private final String SOB2 = "Signed-off-by: J Committer <jc@example.com>\n";
@@ -64,7 +62,7 @@
   }
 
   @Test
-  public void testEmptyMessages() throws Exception {
+  public void emptyMessages() throws Exception {
     // Empty input must yield empty output so commit will abort.
     // Note we must consider different commit templates formats.
     //
@@ -80,325 +78,547 @@
     hookDoesNotModify("\n# on branch master\n# Untracked files:\n");
     hookDoesNotModify("\n\n# on branch master\n# Untracked files:\n");
 
-    hookDoesNotModify("\n# on branch master\ndiff --git a/src b/src\n"
-        + "new file mode 100644\nindex 0000000..c78b7f0\n");
+    hookDoesNotModify(
+        "\n# on branch master\ndiff --git a/src b/src\n"
+            + "new file mode 100644\nindex 0000000..c78b7f0\n");
   }
 
   @Test
-  public void testChangeIdAlreadySet() throws Exception {
+  public void changeIdAlreadySet() throws Exception {
     // If a Change-Id is already present in the footer, the hook must
     // not modify the message but instead must leave the identity alone.
     //
-    hookDoesNotModify("a\n" + //
-        "\n" + //
-        "Change-Id: Iaeac9b4149291060228ef0154db2985a31111335\n");
-    hookDoesNotModify("fix: this thing\n" + //
-        "\n" + //
-        "Change-Id: I388bdaf52ed05b55e62a22d0a20d2c1ae0d33e7e\n");
-    hookDoesNotModify("fix-a-widget: this thing\n" + //
-        "\n" + //
-        "Change-Id: Id3bc5359d768a6400450283e12bdfb6cd135ea4b\n");
-    hookDoesNotModify("FIX: this thing\n" + //
-        "\n" + //
-        "Change-Id: I1b55098b5a2cce0b3f3da783dda50d5f79f873fa\n");
-    hookDoesNotModify("Fix-A-Widget: this thing\n" + //
-        "\n" + //
-        "Change-Id: I4f4e2e1e8568ddc1509baecb8c1270a1fb4b6da7\n");
+    hookDoesNotModify(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: Iaeac9b4149291060228ef0154db2985a31111335\n");
+    hookDoesNotModify(
+        "fix: this thing\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I388bdaf52ed05b55e62a22d0a20d2c1ae0d33e7e\n");
+    hookDoesNotModify(
+        "fix-a-widget: this thing\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: Id3bc5359d768a6400450283e12bdfb6cd135ea4b\n");
+    hookDoesNotModify(
+        "FIX: this thing\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I1b55098b5a2cce0b3f3da783dda50d5f79f873fa\n");
+    hookDoesNotModify(
+        "Fix-A-Widget: this thing\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I4f4e2e1e8568ddc1509baecb8c1270a1fb4b6da7\n");
   }
 
   @Test
-  public void testTimeAltersId() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n",//
+  public void timeAltersId() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
         call("a\n"));
 
     tick();
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I3251906b99dda598a58a6346d8126237ee1ea800\n",//
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I3251906b99dda598a58a6346d8126237ee1ea800\n", //
         call("a\n"));
 
     tick();
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I69adf9208d828f41a3d7e41afbca63aff37c0c5c\n",//
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I69adf9208d828f41a3d7e41afbca63aff37c0c5c\n", //
         call("a\n"));
   }
 
   @Test
-  public void testFirstParentAltersId() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n",//
+  public void firstParentAltersId() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
         call("a\n"));
 
     setHEAD();
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I51e86482bde7f92028541aaf724d3a3f996e7ea2\n",//
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I51e86482bde7f92028541aaf724d3a3f996e7ea2\n", //
         call("a\n"));
   }
 
   @Test
-  public void testDirCacheAltersId() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n",//
+  public void dirCacheAltersId() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
         call("a\n"));
 
     final DirCacheBuilder builder = repository.lockDirCache().builder();
     builder.add(file("A"));
     assertTrue(builder.commit());
 
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: If56597ea9759f23b070677ea6f064c60c38da631\n",//
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: If56597ea9759f23b070677ea6f064c60c38da631\n", //
         call("a\n"));
   }
 
   @Test
-  public void testSingleLineMessages() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n",//
+  public void singleLineMessages() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n", //
         call("a\n"));
 
-    assertEquals("fix: this thing\n" + //
-        "\n" + //
-        "Change-Id: I0f13d0e6c739ca3ae399a05a93792e80feb97f37\n",//
+    assertEquals(
+        "fix: this thing\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I0f13d0e6c739ca3ae399a05a93792e80feb97f37\n", //
         call("fix: this thing\n"));
-    assertEquals("fix-a-widget: this thing\n" + //
-        "\n" + //
-        "Change-Id: I1a1a0c751e4273d532e4046a501a612b9b8a775e\n",//
+    assertEquals(
+        "fix-a-widget: this thing\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I1a1a0c751e4273d532e4046a501a612b9b8a775e\n", //
         call("fix-a-widget: this thing\n"));
 
-    assertEquals("FIX: this thing\n" + //
-        "\n" + //
-        "Change-Id: If816d944c57d3893b60cf10c65931fead1290d97\n",//
+    assertEquals(
+        "FIX: this thing\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: If816d944c57d3893b60cf10c65931fead1290d97\n", //
         call("FIX: this thing\n"));
-    assertEquals("Fix-A-Widget: this thing\n" + //
-        "\n" + //
-        "Change-Id: I3e18d00cbda2ba1f73aeb63ed8c7d57d7fd16c76\n",//
+    assertEquals(
+        "Fix-A-Widget: this thing\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I3e18d00cbda2ba1f73aeb63ed8c7d57d7fd16c76\n", //
         call("Fix-A-Widget: this thing\n"));
   }
 
   @Test
-  public void testMultiLineMessagesWithoutFooter() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "b\n" + //
-        "\n" + //
-        "Change-Id: Id0b4f42d3d6fc1569595c9b97cb665e738486f5d\n",//
-        call("a\n" + "\n" + "b\n"));
+  public void multiLineMessagesWithoutFooter() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "b\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: Id0b4f42d3d6fc1569595c9b97cb665e738486f5d\n", //
+        call("a\n\nb\n"));
 
-    assertEquals("a\n" + //
-        "\n" + //
-        "b\nc\nd\ne\n" + //
-        "\n" + //
-        "Change-Id: I7d237b20058a0f46cc3f5fabc4a0476877289d75\n",//
-        call("a\n" + "\n" + "b\nc\nd\ne\n"));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "b\nc\nd\ne\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I7d237b20058a0f46cc3f5fabc4a0476877289d75\n", //
+        call("a\n\nb\nc\nd\ne\n"));
 
-    assertEquals("a\n" + //
-        "\n" + //
-        "b\nc\nd\ne\n" + //
-        "\n" + //
-        "f\ng\nh\n" + //
-        "\n" + //
-        "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n",//
-        call("a\n" + "\n" + "b\nc\nd\ne\n" + "\n" + "f\ng\nh\n"));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "b\nc\nd\ne\n"
+            + //
+            "\n"
+            + //
+            "f\ng\nh\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n", //
+        call("a\n\nb\nc\nd\ne\n\nf\ng\nh\n"));
   }
 
   @Test
-  public void testSingleLineMessagesWithSignedOffBy() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n" + //
-        SOB1,//
-        call("a\n" + "\n" + SOB1));
+  public void singleLineMessagesWithSignedOffBy() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
+            + //
+            SOB1, //
+        call("a\n\n" + SOB1));
 
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n" + //
-        SOB1 + //
-        SOB2,//
-        call("a\n" + "\n" + SOB1 + SOB2));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
+            + //
+            SOB1
+            + //
+            SOB2, //
+        call("a\n\n" + SOB1 + SOB2));
   }
 
   @Test
-  public void testMultiLineMessagesWithSignedOffBy() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "b\nc\nd\ne\n" + //
-        "\n" + //
-        "f\ng\nh\n" + //
-        "\n" + //
-        "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n" + //
-        SOB1,//
-        call("a\n" + "\n" + "b\nc\nd\ne\n" + "\n" + "f\ng\nh\n" + "\n" + SOB1));
+  public void multiLineMessagesWithSignedOffBy() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "b\nc\nd\ne\n"
+            + //
+            "\n"
+            + //
+            "f\ng\nh\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n"
+            + //
+            SOB1, //
+        call("a\n\nb\nc\nd\ne\n\nf\ng\nh\n\n" + SOB1));
 
-    assertEquals("a\n" + //
-        "\n" + //
-        "b\nc\nd\ne\n" + //
-        "\n" + //
-        "f\ng\nh\n" + //
-        "\n" + //
-        "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n" + //
-        SOB1 + //
-        SOB2,//
-        call("a\n" + //
-            "\n" + //
-            "b\nc\nd\ne\n" + //
-            "\n" + //
-            "f\ng\nh\n" + //
-            "\n" + //
-            SOB1 + //
-            SOB2));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "b\nc\nd\ne\n"
+            + //
+            "\n"
+            + //
+            "f\ng\nh\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I382e662f47bf164d6878b7fe61637873ab7fa4e8\n"
+            + //
+            SOB1
+            + //
+            SOB2, //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "b\nc\nd\ne\n"
+                + //
+                "\n"
+                + //
+                "f\ng\nh\n"
+                + //
+                "\n"
+                + //
+                SOB1
+                + //
+                SOB2));
 
-    assertEquals("a\n" + //
-        "\n" + //
-        "b: not a footer\nc\nd\ne\n" + //
-        "\n" + //
-        "f\ng\nh\n" + //
-        "\n" + //
-        "Change-Id: I8869aabd44b3017cd55d2d7e0d546a03e3931ee2\n" + //
-        SOB1 + //
-        SOB2,//
-        call("a\n" + //
-            "\n" + //
-            "b: not a footer\nc\nd\ne\n" + //
-            "\n" + //
-            "f\ng\nh\n" + //
-            "\n" + //
-            SOB1 + //
-            SOB2));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "b: not a footer\nc\nd\ne\n"
+            + //
+            "\n"
+            + //
+            "f\ng\nh\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I8869aabd44b3017cd55d2d7e0d546a03e3931ee2\n"
+            + //
+            SOB1
+            + //
+            SOB2, //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "b: not a footer\nc\nd\ne\n"
+                + //
+                "\n"
+                + //
+                "f\ng\nh\n"
+                + //
+                "\n"
+                + //
+                SOB1
+                + //
+                SOB2));
   }
 
   @Test
-  public void testNoteInMiddle() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "NOTE: This\n" + //
-        "does not fix it.\n" + //
-        "\n" + //
-        "Change-Id: I988a127969a6ee5e58db546aab74fc46e66847f8\n", //
-        call("a\n" + //
-            "\n" + //
-            "NOTE: This\n" + //
-            "does not fix it.\n"));
+  public void noteInMiddle() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "NOTE: This\n"
+            + //
+            "does not fix it.\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I988a127969a6ee5e58db546aab74fc46e66847f8\n", //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "NOTE: This\n"
+                + //
+                "does not fix it.\n"));
   }
 
   @Test
-  public void testKernelStyleFooter() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I1bd787f9e7590a2ac82b02c404c955ffb21877c4\n" + //
-        SOB1 + //
-        "[ja: Fixed\n" + //
-        "     the indentation]\n" + //
-        SOB2, //
-        call("a\n" + //
-            "\n" + //
-            SOB1 + //
-            "[ja: Fixed\n" + //
-            "     the indentation]\n" + //
-            SOB2));
+  public void kernelStyleFooter() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I1bd787f9e7590a2ac82b02c404c955ffb21877c4\n"
+            + //
+            SOB1
+            + //
+            "[ja: Fixed\n"
+            + //
+            "     the indentation]\n"
+            + //
+            SOB2, //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                SOB1
+                + //
+                "[ja: Fixed\n"
+                + //
+                "     the indentation]\n"
+                + //
+                SOB2));
   }
 
   @Test
-  public void testChangeIdAfterBugOrIssue() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "Bug: 42\n" + //
-        "Change-Id: I8c0321227c4324e670b9ae8cf40eccc87af21b1b\n" + //
-        SOB1,//
-        call("a\n" + //
-            "\n" + //
-            "Bug: 42\n" + //
-            SOB1));
+  public void changeIdAfterBugOrIssue() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Bug: 42\n"
+            + //
+            "Change-Id: I8c0321227c4324e670b9ae8cf40eccc87af21b1b\n"
+            + //
+            SOB1, //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "Bug: 42\n"
+                + //
+                SOB1));
 
-    assertEquals("a\n" + //
-        "\n" + //
-        "Issue: 42\n" + //
-        "Change-Id: Ie66e07d89ae5b114c0975b49cf326e90331dd822\n" + //
-        SOB1,//
-        call("a\n" + //
-            "\n" + //
-            "Issue: 42\n" + //
-            SOB1));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Issue: 42\n"
+            + //
+            "Change-Id: Ie66e07d89ae5b114c0975b49cf326e90331dd822\n"
+            + //
+            SOB1, //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "Issue: 42\n"
+                + //
+                SOB1));
   }
 
   @Test
-  public void testCommitDashV() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n" + //
-        SOB1 + //
-        SOB2, //
-        call("a\n" + //
-            "\n" + //
-            SOB1 + //
-            SOB2 + //
-            "\n" + //
-            "# on branch master\n" + //
-            "diff --git a/src b/src\n" + //
-            "new file mode 100644\n" + //
-            "index 0000000..c78b7f0\n"));
+  public void commitDashV() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I7fc3876fee63c766a2063df97fbe04a2dddd8d7c\n"
+            + //
+            SOB1
+            + //
+            SOB2, //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                SOB1
+                + //
+                SOB2
+                + //
+                "\n"
+                + //
+                "# on branch master\n"
+                + //
+                "diff --git a/src b/src\n"
+                + //
+                "new file mode 100644\n"
+                + //
+                "index 0000000..c78b7f0\n"));
   }
 
   @Test
-  public void testWithEndingURL() throws Exception {
-    assertEquals("a\n" + //
-        "\n" + //
-        "http://example.com/ fixes this\n" + //
-        "\n" + //
-        "Change-Id: I3b7e4e16b503ce00f07ba6ad01d97a356dad7701\n", //
-        call("a\n" + //
-            "\n" + //
-            "http://example.com/ fixes this\n"));
-    assertEquals("a\n" + //
-        "\n" + //
-        "https://example.com/ fixes this\n" + //
-        "\n" + //
-        "Change-Id: I62b9039e2fc0dce274af55e8f99312a8a80a805d\n", //
-        call("a\n" + //
-            "\n" + //
-            "https://example.com/ fixes this\n"));
-    assertEquals("a\n" + //
-        "\n" + //
-        "ftp://example.com/ fixes this\n" + //
-        "\n" + //
-        "Change-Id: I71b05dc1f6b9a5540a53a693e64d58b65a8910e8\n", //
-        call("a\n" + //
-            "\n" + //
-            "ftp://example.com/ fixes this\n"));
-    assertEquals("a\n" + //
-        "\n" + //
-        "git://example.com/ fixes this\n" + //
-        "\n" + //
-        "Change-Id: Id34e942baa68d790633737d815ddf11bac9183e5\n", //
-        call("a\n" + //
-            "\n" + //
-            "git://example.com/ fixes this\n"));
+  public void withEndingURL() throws Exception {
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "http://example.com/ fixes this\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I3b7e4e16b503ce00f07ba6ad01d97a356dad7701\n", //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "http://example.com/ fixes this\n"));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "https://example.com/ fixes this\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I62b9039e2fc0dce274af55e8f99312a8a80a805d\n", //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "https://example.com/ fixes this\n"));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "ftp://example.com/ fixes this\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I71b05dc1f6b9a5540a53a693e64d58b65a8910e8\n", //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "ftp://example.com/ fixes this\n"));
+    assertEquals(
+        "a\n"
+            + //
+            "\n"
+            + //
+            "git://example.com/ fixes this\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: Id34e942baa68d790633737d815ddf11bac9183e5\n", //
+        call(
+            "a\n"
+                + //
+                "\n"
+                + //
+                "git://example.com/ fixes this\n"));
   }
 
   @Test
-  public void testWithFalseTags() throws Exception {
-    assertEquals("foo\n" + //
-	"\n" + //
-	"FakeLine:\n" + //
-	"  foo\n" + //
-	"  bar\n" + //
-	"\n" + //
-	"Change-Id: I67632a37fd2e08a35f766f52fc9a47f4ea868c55\n" + //
-	"RealTag: abc\n", //
-	call("foo\n" + //
-	    "\n" + //
-	    "FakeLine:\n" + //
-	    "  foo\n" + //
-	    "  bar\n" + //
-	    "\n" + //
-	    "RealTag: abc\n"));
+  public void withFalseTags() throws Exception {
+    assertEquals(
+        "foo\n"
+            + //
+            "\n"
+            + //
+            "FakeLine:\n"
+            + //
+            "  foo\n"
+            + //
+            "  bar\n"
+            + //
+            "\n"
+            + //
+            "Change-Id: I67632a37fd2e08a35f766f52fc9a47f4ea868c55\n"
+            + //
+            "RealTag: abc\n", //
+        call(
+            "foo\n"
+                + //
+                "\n"
+                + //
+                "FakeLine:\n"
+                + //
+                "  foo\n"
+                + //
+                "  bar\n"
+                + //
+                "\n"
+                + //
+                "RealTag: abc\n"));
   }
 
   private void hookDoesNotModify(final String in) throws Exception {
@@ -439,9 +659,9 @@
       ref.setNewObjectId(commitId);
       Result result = ref.forceUpdate();
       assert_()
-        .withFailureMessage(Constants.HEAD + " did not change: " + ref.getResult())
-        .that(result)
-        .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
+          .withFailureMessage(Constants.HEAD + " did not change: " + ref.getResult())
+          .that(result)
+          .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
     }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
index 69a8487..3d4a1a0 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
@@ -53,23 +53,22 @@
 import static com.google.common.truth.Truth.assert_;
 
 import com.google.common.io.ByteStreams;
-
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
 import org.eclipse.jgit.junit.LocalDiskRepositoryTestCase;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
 
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-
 @Ignore
 public abstract class HookTestCase extends LocalDiskRepositoryTestCase {
   protected Repository repository;
@@ -105,20 +104,14 @@
     String path = scproot + "/hooks/" + name;
     String errorMessage = "Cannot locate " + path + " in CLASSPATH";
     URL url = cl().getResource(path);
-    assert_()
-      .withFailureMessage(errorMessage)
-      .that(url).isNotNull();
+    assert_().withFailureMessage(errorMessage).that(url).isNotNull();
 
     String protocol = url.getProtocol();
-    assert_()
-      .withFailureMessage("Cannot invoke " + url)
-      .that(protocol).isAnyOf("file", "jar");
+    assert_().withFailureMessage("Cannot invoke " + url).that(protocol).isAnyOf("file", "jar");
 
     if ("file".equals(protocol)) {
       hook = new File(url.getPath());
-      assert_()
-        .withFailureMessage(errorMessage)
-        .that(hook.isFile()).isTrue();
+      assert_().withFailureMessage(errorMessage).that(hook.isFile()).isTrue();
       long time = hook.lastModified();
       hook.setExecutable(true);
       hook.setLastModified(time);
@@ -127,7 +120,7 @@
       try (InputStream in = url.openStream()) {
         hook = File.createTempFile("hook_", ".sh");
         cleanup.add(hook);
-        try (FileOutputStream out = new FileOutputStream(hook)) {
+        try (OutputStream out = Files.newOutputStream(hook.toPath())) {
           ByteStreams.copy(in, out);
         }
       }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
new file mode 100644
index 0000000..892d037
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -0,0 +1,122 @@
+package com.google.gerrit.server.update;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class BatchUpdateTest {
+  @Inject private AccountManager accountManager;
+
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private InMemoryDatabase schemaFactory;
+
+  @Inject private InMemoryRepositoryManager repoManager;
+
+  @Inject private SchemaCreator schemaCreator;
+
+  @Inject private ThreadLocalRequestContext requestContext;
+
+  @Inject private BatchUpdate.Factory batchUpdateFactory;
+
+  private LifecycleManager lifecycle;
+  private ReviewDb db;
+  private TestRepository<InMemoryRepository> repo;
+  private Project.NameKey project;
+  private IdentifiedUser user;
+
+  @Before
+  public void setUp() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    user = userFactory.create(userId);
+
+    project = new Project.NameKey("test");
+
+    InMemoryRepository inMemoryRepo = repoManager.createRepository(project);
+    repo = new TestRepository<>(inMemoryRepo);
+
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  @After
+  public void tearDown() {
+    if (repo != null) {
+      repo.getRepository().close();
+    }
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void addRefUpdateFromFastForwardCommit() throws Exception {
+    final RevCommit masterCommit = repo.branch("master").commit().create();
+    final RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create();
+
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user, TimeUtil.nowTs())) {
+      bu.addRepoOnlyOp(
+          new RepoOnlyOp() {
+            @Override
+            public void updateRepo(RepoContext ctx) throws Exception {
+              ctx.addRefUpdate(
+                  new ReceiveCommand(
+                      masterCommit.getId(), branchCommit.getId(), "refs/heads/master"));
+            }
+          });
+      bu.execute();
+    }
+
+    assertEquals(
+        repo.getRepository().exactRef("refs/heads/master").getObjectId(), branchCommit.getId());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/IdGeneratorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/IdGeneratorTest.java
index 3be4f8a..39afcac 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/IdGeneratorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/IdGeneratorTest.java
@@ -17,9 +17,8 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 
-import org.junit.Test;
-
 import java.util.HashSet;
+import org.junit.Test;
 
 public class IdGeneratorTest {
   @Test
@@ -37,7 +36,7 @@
   }
 
   @Test
-  public void testFormat() {
+  public void format() {
     assertEquals("0000000f", IdGenerator.format(0xf));
     assertEquals("801234ab", IdGenerator.format(0x801234ab));
     assertEquals("deadbeef", IdGenerator.format(0xdeadbeef));
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java
index 4fdbdb2..0592041 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java
@@ -79,15 +79,10 @@
 
   @Test
   public void formatWithEquals() {
-    assertEquals("Code-Review=-2",
-        LabelVote.parseWithEquals("Code-Review=-2").formatWithEquals());
-    assertEquals("Code-Review=-1",
-        LabelVote.parseWithEquals("Code-Review=-1").formatWithEquals());
-    assertEquals("Code-Review=0",
-        LabelVote.parseWithEquals("Code-Review=0").formatWithEquals());
-    assertEquals("Code-Review=+1",
-        LabelVote.parseWithEquals("Code-Review=+1").formatWithEquals());
-    assertEquals("Code-Review=+2",
-        LabelVote.parseWithEquals("Code-Review=+2").formatWithEquals());
+    assertEquals("Code-Review=-2", LabelVote.parseWithEquals("Code-Review=-2").formatWithEquals());
+    assertEquals("Code-Review=-1", LabelVote.parseWithEquals("Code-Review=-1").formatWithEquals());
+    assertEquals("Code-Review=0", LabelVote.parseWithEquals("Code-Review=0").formatWithEquals());
+    assertEquals("Code-Review=+1", LabelVote.parseWithEquals("Code-Review=+1").formatWithEquals());
+    assertEquals("Code-Review=+2", LabelVote.parseWithEquals("Code-Review=+2").formatWithEquals());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/MostSpecificComparatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/MostSpecificComparatorTest.java
index e974f1f..025bf84 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/MostSpecificComparatorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/MostSpecificComparatorTest.java
@@ -33,9 +33,8 @@
   }
 
   /**
-   * Assuming two patterns have the same Levenshtein distance,
-   * the pattern which represents a finite language wins over a pattern
-   * which represents an infinite language.
+   * Assuming two patterns have the same Levenshtein distance, the pattern which represents a finite
+   * language wins over a pattern which represents an infinite language.
    */
   @Test
   public void finiteWinsOverInfinite() {
@@ -45,9 +44,8 @@
   }
 
   /**
-   * Assuming two patterns have the same Levenshtein distance
-   * and are both either finite or infinite the one with the higher
-   * number of state transitions (in an equivalent automaton) wins
+   * Assuming two patterns have the same Levenshtein distance and are both either finite or infinite
+   * the one with the higher number of state transitions (in an equivalent automaton) wins
    */
   @Test
   public void higherNumberOfTransitionsWins() {
@@ -65,8 +63,8 @@
   }
 
   /**
-   * Assuming the same Levenshtein distance, (in)finity and the number
-   * of transitions, the longer pattern wins
+   * Assuming the same Levenshtein distance, (in)finity and the number of transitions, the longer
+   * pattern wins
    */
   @Test
   public void longerPatternWins() {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/ParboiledTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/ParboiledTest.java
index 6efc881..3bcfb56 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/ParboiledTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/ParboiledTest.java
@@ -29,14 +29,14 @@
 public class ParboiledTest {
 
   private static final String EXPECTED =
-  "[Expression] '42'\n" +
-  "  [Term] '42'\n" +
-  "    [Factor] '42'\n" +
-  "      [Number] '42'\n" +
-  "        [0..9] '4'\n" +
-  "        [0..9] '2'\n" +
-  "    [zeroOrMore]\n" +
-  "  [zeroOrMore]\n";
+      "[Expression] '42'\n"
+          + "  [Term] '42'\n"
+          + "    [Factor] '42'\n"
+          + "      [Number] '42'\n"
+          + "        [0..9] '4'\n"
+          + "        [0..9] '2'\n"
+          + "    [zeroOrMore]\n"
+          + "  [zeroOrMore]\n";
 
   private CalculatorParser parser;
 
@@ -47,8 +47,7 @@
 
   @Test
   public void test() {
-    ParsingResult<String> result =
-        new ReportingParseRunner<String>(parser.Expression()).run("42");
+    ParsingResult<String> result = new ReportingParseRunner<String>(parser.Expression()).run("42");
     assertThat(result.isSuccess()).isTrue();
     // next test is optional; we could stop here.
     assertThat(ParseTreeUtils.printNodeTree(result)).isEqualTo(EXPECTED);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/RegexListSearcherTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/RegexListSearcherTest.java
index 8f73005..dc8c0d8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/RegexListSearcherTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/RegexListSearcherTest.java
@@ -20,13 +20,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Ordering;
-
+import java.util.List;
 import org.junit.Test;
 
-import java.util.List;
-
 public class RegexListSearcherTest {
-  private static final List<String> EMPTY = ImmutableList.of();
+  private static final ImmutableList<String> EMPTY = ImmutableList.of();
 
   @Test
   public void emptyList() {
@@ -55,30 +53,20 @@
     List<String> list = ImmutableList.of("bar", "foo", "quux");
     assertSearchReturns(ImmutableList.of("foo"), "f.*", list);
     assertSearchReturns(ImmutableList.of("foo"), ".*o.*", list);
-    assertSearchReturns(ImmutableList.of("bar", "foo", "quux"), ".*[aou].*",
-        list);
+    assertSearchReturns(ImmutableList.of("bar", "foo", "quux"), ".*[aou].*", list);
   }
 
   @Test
   public void commonPrefix() {
-    List<String> list = ImmutableList.of(
-        "bar",
-        "baz",
-        "foo1",
-        "foo2",
-        "foo3",
-        "quux");
+    List<String> list = ImmutableList.of("bar", "baz", "foo1", "foo2", "foo3", "quux");
     assertSearchReturns(ImmutableList.of("bar", "baz"), "b.*", list);
     assertSearchReturns(ImmutableList.of("foo1", "foo2"), "foo[12]", list);
-    assertSearchReturns(ImmutableList.of("foo1", "foo2", "foo3"), "foo.*",
-        list);
+    assertSearchReturns(ImmutableList.of("foo1", "foo2", "foo3"), "foo.*", list);
     assertSearchReturns(ImmutableList.of("quux"), "q.*", list);
   }
 
-  private void assertSearchReturns(List<?> expected, String re,
-    List<String> inputs) {
+  private void assertSearchReturns(List<?> expected, String re, List<String> inputs) {
     assertTrue(Ordering.natural().isOrdered(inputs));
-    assertEquals(expected,
-        ImmutableList.copyOf(RegexListSearcher.ofStrings(re).search(inputs)));
+    assertEquals(expected, ImmutableList.copyOf(RegexListSearcher.ofStrings(re).search(inputs)));
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
index 3e3c13e..473c44d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/SocketUtilTest.java
@@ -25,14 +25,12 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.gerrit.testutil.GerritBaseTests;
-
-import org.junit.Test;
-
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
+import org.junit.Test;
 
 public class SocketUtilTest extends GerritBaseTests {
   @Test
@@ -61,15 +59,19 @@
     assertEquals("foo:1234", SocketUtil.format(createUnresolved("foo", 1234), 80));
     assertEquals("foo", SocketUtil.format(createUnresolved("foo", 80), 80));
 
-    assertEquals("[1:2:3:4:5:6:7:8]:1234",//
+    assertEquals(
+        "[1:2:3:4:5:6:7:8]:1234", //
         SocketUtil.format(new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 1234), 80));
-    assertEquals("[1:2:3:4:5:6:7:8]",//
+    assertEquals(
+        "[1:2:3:4:5:6:7:8]", //
         SocketUtil.format(new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 80), 80));
 
-    assertEquals("localhost:1234",//
+    assertEquals(
+        "localhost:1234", //
         SocketUtil.format(new InetSocketAddress("localhost", 1234), 80));
-    assertEquals("localhost",//
-        SocketUtil. format(new InetSocketAddress("localhost", 80), 80));
+    assertEquals(
+        "localhost", //
+        SocketUtil.format(new InetSocketAddress("localhost", 80), 80));
   }
 
   @Test
@@ -79,19 +81,25 @@
     assertEquals(new InetSocketAddress(1234), parse(":1234", 80));
     assertEquals(new InetSocketAddress(80), parse("", 80));
 
-    assertEquals(createUnresolved("1:2:3:4:5:6:7:8", 1234), //
+    assertEquals(
+        createUnresolved("1:2:3:4:5:6:7:8", 1234), //
         parse("[1:2:3:4:5:6:7:8]:1234", 80));
-    assertEquals(createUnresolved("1:2:3:4:5:6:7:8", 80), //
+    assertEquals(
+        createUnresolved("1:2:3:4:5:6:7:8", 80), //
         parse("[1:2:3:4:5:6:7:8]", 80));
 
-    assertEquals(createUnresolved("localhost", 1234), //
+    assertEquals(
+        createUnresolved("localhost", 1234), //
         parse("[localhost]:1234", 80));
-    assertEquals(createUnresolved("localhost", 80), //
+    assertEquals(
+        createUnresolved("localhost", 80), //
         parse("[localhost]", 80));
 
-    assertEquals(createUnresolved("foo.bar.example.com", 1234), //
+    assertEquals(
+        createUnresolved("foo.bar.example.com", 1234), //
         parse("[foo.bar.example.com]:1234", 80));
-    assertEquals(createUnresolved("foo.bar.example.com", 80), //
+    assertEquals(
+        createUnresolved("foo.bar.example.com", 80), //
         parse("[foo.bar.example.com]", 80));
   }
 
@@ -116,14 +124,18 @@
     assertEquals(new InetSocketAddress(1234), resolve(":1234", 80));
     assertEquals(new InetSocketAddress(80), resolve("", 80));
 
-    assertEquals(new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 1234), //
+    assertEquals(
+        new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 1234), //
         resolve("[1:2:3:4:5:6:7:8]:1234", 80));
-    assertEquals(new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 80), //
+    assertEquals(
+        new InetSocketAddress(getByName("1:2:3:4:5:6:7:8"), 80), //
         resolve("[1:2:3:4:5:6:7:8]", 80));
 
-    assertEquals(new InetSocketAddress(getByName("localhost"), 1234), //
+    assertEquals(
+        new InetSocketAddress(getByName("localhost"), 1234), //
         resolve("[localhost]:1234", 80));
-    assertEquals(new InetSocketAddress(getByName("localhost"), 80), //
+    assertEquals(
+        new InetSocketAddress(getByName("localhost"), 80), //
         resolve("[localhost]", 80));
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java
index d769bcc..8d6036a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java
@@ -22,13 +22,6 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-
-import org.junit.runner.Runner;
-import org.junit.runners.BlockJUnit4ClassRunner;
-import org.junit.runners.Suite;
-import org.junit.runners.model.FrameworkMethod;
-import org.junit.runners.model.InitializationError;
-
 import java.lang.annotation.Annotation;
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
@@ -37,12 +30,17 @@
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.util.List;
+import org.junit.runner.Runner;
+import org.junit.runners.BlockJUnit4ClassRunner;
+import org.junit.runners.Suite;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.InitializationError;
 
 /**
  * Suite to run tests with different {@code gerrit.config} values.
- * <p>
- * For each {@link Config} method in the class and base classes, a new group of
- * tests is created with the {@link Parameter} field set to the config.
+ *
+ * <p>For each {@link Config} method in the class and base classes, a new group of tests is created
+ * with the {@link Parameter} field set to the config.
  *
  * <pre>
  * {@literal @}RunWith(ConfigSuite.class)
@@ -72,40 +70,37 @@
  * </pre>
  *
  * This creates a suite of tests with three groups:
+ *
  * <ul>
- *   <li><strong>default</strong>: {@code MyTest.myTest}</li>
- *   <li><strong>firstConfig</strong>: {@code MyTest.myTest[firstConfig]}</li>
- *   <li><strong>secondConfig</strong>: {@code MyTest.myTest[secondConfig]}</li>
+ *   <li><strong>default</strong>: {@code MyTest.myTest}
+ *   <li><strong>firstConfig</strong>: {@code MyTest.myTest[firstConfig]}
+ *   <li><strong>secondConfig</strong>: {@code MyTest.myTest[secondConfig]}
  * </ul>
  *
- * Additionally, config values used by <strong>default</strong> can be set
- * in a method annotated with {@code @ConfigSuite.Default}.
- * <p>
- * The name of the config method corresponding to the currently-running test can
- * be stored in a field annotated with {@code @ConfigSuite.Name}.
+ * Additionally, config values used by <strong>default</strong> can be set in a method annotated
+ * with {@code @ConfigSuite.Default}.
+ *
+ * <p>The name of the config method corresponding to the currently-running test can be stored in a
+ * field annotated with {@code @ConfigSuite.Name}.
  */
 public class ConfigSuite extends Suite {
   private static final String DEFAULT = "default";
 
   @Target({METHOD})
   @Retention(RUNTIME)
-  public static @interface Default {
-  }
+  public static @interface Default {}
 
   @Target({METHOD})
   @Retention(RUNTIME)
-  public static @interface Config {
-  }
+  public static @interface Config {}
 
   @Target({FIELD})
   @Retention(RUNTIME)
-  public static @interface Parameter {
-  }
+  public static @interface Parameter {}
 
   @Target({FIELD})
   @Retention(RUNTIME)
-  public static @interface Name {
-  }
+  public static @interface Name {}
 
   private static class ConfigRunner extends BlockJUnit4ClassRunner {
     private final Method configMethod;
@@ -113,8 +108,9 @@
     private final Field nameField;
     private final String name;
 
-    private ConfigRunner(Class<?> clazz, Field parameterField, Field nameField,
-        String name, Method configMethod) throws InitializationError {
+    private ConfigRunner(
+        Class<?> clazz, Field parameterField, Field nameField, String name, Method configMethod)
+        throws InitializationError {
       super(clazz);
       this.parameterField = parameterField;
       this.nameField = nameField;
@@ -124,7 +120,7 @@
 
     @Override
     public Object createTest() throws Exception {
-      Object test = getTestClass().getJavaClass().newInstance();
+      Object test = getTestClass().getJavaClass().getDeclaredConstructor().newInstance();
       parameterField.set(test, callConfigMethod(configMethod));
       if (nameField != null) {
         nameField.set(test, name);
@@ -148,15 +144,13 @@
     Method defaultConfig = getDefaultConfig(clazz);
     List<Method> configs = getConfigs(clazz);
     Field parameterField = getOnlyField(clazz, Parameter.class);
-    checkArgument(parameterField != null, "No @ConfigSuite.Field found");
+    checkArgument(parameterField != null, "No @ConfigSuite.Parameter found");
     Field nameField = getOnlyField(clazz, Name.class);
     List<Runner> result = Lists.newArrayListWithCapacity(configs.size() + 1);
     try {
-      result.add(new ConfigRunner(
-          clazz, parameterField, nameField, null, defaultConfig));
+      result.add(new ConfigRunner(clazz, parameterField, nameField, null, defaultConfig));
       for (Method m : configs) {
-        result.add(new ConfigRunner(
-            clazz, parameterField, nameField, m.getName(), m));
+        result.add(new ConfigRunner(clazz, parameterField, nameField, m.getName(), m));
       }
       return result;
     } catch (InitializationError e) {
@@ -173,9 +167,11 @@
     for (Method m : clazz.getMethods()) {
       Default ann = m.getAnnotation(Default.class);
       if (ann != null) {
-        checkArgument(result == null,
+        checkArgument(
+            result == null,
             "Multiple methods annotated with @ConfigSuite.Method: %s, %s",
-            result, m);
+            result,
+            m);
         result = m;
       }
     }
@@ -187,8 +183,8 @@
     for (Method m : clazz.getMethods()) {
       Config ann = m.getAnnotation(Config.class);
       if (ann != null) {
-        checkArgument(!m.getName().equals(DEFAULT),
-            "@ConfigSuite.Config cannot be named %s", DEFAULT);
+        checkArgument(
+            !m.getName().equals(DEFAULT), "@ConfigSuite.Config cannot be named %s", DEFAULT);
         result.add(m);
       }
     }
@@ -201,30 +197,29 @@
     }
     checkArgument(
         org.eclipse.jgit.lib.Config.class.isAssignableFrom(m.getReturnType()),
-        "%s must return Config", m);
-    checkArgument((m.getModifiers() & Modifier.STATIC) != 0,
-        "%s must be static", m);
-    checkArgument(m.getParameterTypes().length == 0,
-        "%s must take no parameters", m);
+        "%s must return Config",
+        m);
+    checkArgument((m.getModifiers() & Modifier.STATIC) != 0, "%s must be static", m);
+    checkArgument(m.getParameterTypes().length == 0, "%s must take no parameters", m);
     try {
       return (org.eclipse.jgit.lib.Config) m.invoke(null);
-    } catch (IllegalAccessException | IllegalArgumentException
-        | InvocationTargetException e) {
+    } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
       throw new IllegalArgumentException(e);
     }
   }
 
-  private static Field getOnlyField(Class<?> clazz,
-      Class<? extends Annotation> ann) {
+  private static Field getOnlyField(Class<?> clazz, Class<? extends Annotation> ann) {
     List<Field> fields = Lists.newArrayListWithExpectedSize(1);
     for (Field f : clazz.getFields()) {
       if (f.getAnnotation(ann) != null) {
         fields.add(f);
       }
     }
-    checkArgument(fields.size() <= 1,
+    checkArgument(
+        fields.size() <= 1,
         "expected 1 @ConfigSuite.%s field, found: %s",
-        ann.getSimpleName(), fields);
+        ann.getSimpleName(),
+        fields);
     return Iterables.getFirst(fields, null);
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
index 11d7ad0..885a1f5 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.reviewdb.server.AccountGroupMemberAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupMemberAuditAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupNameAccess;
-import com.google.gerrit.reviewdb.server.AccountProjectWatchAccess;
 import com.google.gerrit.reviewdb.server.ChangeAccess;
 import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
 import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
@@ -115,11 +114,6 @@
   }
 
   @Override
-  public AccountProjectWatchAccess accountProjectWatches() {
-    throw new Disabled();
-  }
-
-  @Override
   public ChangeAccess changes() {
     throw new Disabled();
   }
@@ -168,9 +162,4 @@
   public int nextChangeId() {
     throw new Disabled();
   }
-
-  @Override
-  public int nextChangeMessageId() {
-    throw new Disabled();
-  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountByEmailCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountByEmailCache.java
deleted file mode 100644
index c3bfe1e..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountByEmailCache.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.testutil;
-
-import com.google.common.collect.HashMultimap;
-import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.AccountByEmailCache;
-
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-
-/** Fake implementation of {@link AccountByEmailCache} for testing. */
-public class FakeAccountByEmailCache implements AccountByEmailCache {
-  private final SetMultimap<String, Account.Id> byEmail;
-  private final Set<Account.Id> anyEmail;
-
-  public FakeAccountByEmailCache() {
-    byEmail = HashMultimap.create();
-    anyEmail = new HashSet<>();
-  }
-
-  @Override
-  public synchronized Set<Account.Id> get(String email) {
-    return Collections.unmodifiableSet(
-        Sets.union(byEmail.get(email), anyEmail));
-  }
-
-  @Override
-  public synchronized void evict(String email) {
-    // Do nothing.
-  }
-
-  public synchronized void put(String email, Account.Id id) {
-    byEmail.put(email, id);
-  }
-
-  public synchronized void putAny(Account.Id id) {
-    anyEmail.add(id);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
index 07cd63e..3c5bc85 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
@@ -15,18 +15,13 @@
 package com.google.gerrit.testutil;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Set;
 
 /** Fake implementation of {@link AccountCache} for testing. */
 public class FakeAccountCache implements AccountCache {
@@ -48,6 +43,12 @@
   }
 
   @Override
+  @Nullable
+  public synchronized AccountState getOrNull(Account.Id accountId) {
+    return byId.get(accountId);
+  }
+
+  @Override
   public synchronized AccountState getIfPresent(Account.Id accountId) {
     return byId.get(accountId);
   }
@@ -82,8 +83,6 @@
   }
 
   private static AccountState newState(Account account) {
-    return new AccountState(account, ImmutableSet.<AccountGroup.UUID> of(),
-        ImmutableSet.<AccountExternalId> of(),
-        new HashMap<ProjectWatchKey, Set<NotifyType>>());
+    return new AccountState(account, ImmutableSet.of(), ImmutableSet.of(), new HashMap<>());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
index f2d563e..c9281ef 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
@@ -14,42 +14,40 @@
 
 package com.google.gerrit.testutil;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.mail.EmailHeader;
-import com.google.gerrit.server.mail.EmailSender;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Email sender implementation that records messages in memory.
- * <p>
- * This class is mostly threadsafe. The only exception is that not all {@link
- * EmailHeader} subclasses are immutable. In particular, if a caller holds a
- * reference to an {@code AddressList} and mutates it after sending, the message
- * returned by {@link #getMessages()} may or may not reflect mutations.
+ *
+ * <p>This class is mostly threadsafe. The only exception is that not all {@link EmailHeader}
+ * subclasses are immutable. In particular, if a caller holds a reference to an {@code AddressList}
+ * and mutates it after sending, the message returned by {@link #getMessages()} may or may not
+ * reflect mutations.
  */
 @Singleton
 public class FakeEmailSender implements EmailSender {
-  private static final Logger log =
-      LoggerFactory.getLogger(FakeEmailSender.class);
+  private static final Logger log = LoggerFactory.getLogger(FakeEmailSender.class);
 
   public static class Module extends AbstractModule {
     @Override
@@ -60,16 +58,26 @@
 
   @AutoValue
   public abstract static class Message {
-    private static Message create(Address from, Collection<Address> rcpt,
-        Map<String, EmailHeader> headers, String body) {
-      return new AutoValue_FakeEmailSender_Message(from,
-          ImmutableList.copyOf(rcpt), ImmutableMap.copyOf(headers), body);
+    private static Message create(
+        Address from,
+        Collection<Address> rcpt,
+        Map<String, EmailHeader> headers,
+        String body,
+        String htmlBody) {
+      return new AutoValue_FakeEmailSender_Message(
+          from, ImmutableList.copyOf(rcpt), ImmutableMap.copyOf(headers), body, htmlBody);
     }
 
     public abstract Address from();
+
     public abstract ImmutableList<Address> rcpt();
+
     public abstract ImmutableMap<String, EmailHeader> headers();
+
     public abstract String body();
+
+    @Nullable
+    public abstract String htmlBody();
   }
 
   private final WorkQueue workQueue;
@@ -92,9 +100,21 @@
   }
 
   @Override
-  public void send(Address from, Collection<Address> rcpt,
-      Map<String, EmailHeader> headers, String body) throws EmailException {
-    messages.add(Message.create(from, rcpt, headers, body));
+  public void send(
+      Address from, Collection<Address> rcpt, Map<String, EmailHeader> headers, String body)
+      throws EmailException {
+    send(from, rcpt, headers, body, null);
+  }
+
+  @Override
+  public void send(
+      Address from,
+      Collection<Address> rcpt,
+      Map<String, EmailHeader> headers,
+      String body,
+      String htmlBody)
+      throws EmailException {
+    messages.add(Message.create(from, rcpt, headers, body, htmlBody));
   }
 
   public void clear() {
@@ -111,17 +131,12 @@
     }
   }
 
-  public ImmutableList<Message> getMessages(String changeId, String type) {
+  public List<Message> getMessages(String changeId, String type) {
     final String idFooter = "\nGerrit-Change-Id: " + changeId + "\n";
     final String typeFooter = "\nGerrit-MessageType: " + type + "\n";
-    return FluentIterable.from(getMessages())
-        .filter(new Predicate<Message>() {
-          @Override
-          public boolean apply(Message in) {
-            return in.body().contains(idFooter)
-                && in.body().contains(typeFooter);
-          }
-        }).toList();
+    return getMessages().stream()
+        .filter(in -> in.body().contains(idFooter) && in.body().contains(typeFooter))
+        .collect(toList());
   }
 
   private void waitForEmails() {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FilesystemLoggingMockingTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FilesystemLoggingMockingTestCase.java
deleted file mode 100644
index bbcb6a9..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FilesystemLoggingMockingTestCase.java
+++ /dev/null
@@ -1,178 +0,0 @@
-// Copyright (C) 2013 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.testutil;
-
-import com.google.common.base.Strings;
-
-import org.eclipse.jgit.util.FileUtils;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-
-public abstract class FilesystemLoggingMockingTestCase extends LoggingMockingTestCase {
-
-  private Collection<File> toCleanup = new ArrayList<>();
-
-  /**
-   * Asserts that a given file exists.
-   *
-   * @param file The file to test.
-   */
-  protected void assertExists(File file) {
-    assertTrue("File '" + file.getAbsolutePath() + "' does not exist",
-        file.exists());
-  }
-
-  /**
-   * Asserts that a given file does not exist.
-   *
-   * @param file The file to test.
-   */
-  protected void assertDoesNotExist(File file) {
-    assertFalse("File '" + file.getAbsolutePath() + "' exists", file.exists());
-  }
-
-  /**
-   * Asserts that a given file exists and is a directory.
-   *
-   * @param file The file to test.
-   */
-  protected void assertDirectory(File file) {
-    // Although isDirectory includes checking for existence, we nevertheless
-    // explicitly check for existence, to get more appropriate error messages
-    assertExists(file);
-    assertTrue("File '" + file.getAbsolutePath() + "' is not a directory",
-        file.isDirectory());
-  }
-
-  /**
-   * Asserts that creating a directory from the given file worked
-   *
-   * @param file The directory to create
-   */
-  protected void assertMkdirs(File file) {
-    assertTrue("Could not create directory '" + file.getAbsolutePath() + "'",
-        file.mkdirs());
-  }
-
-  /**
-   * Asserts that creating a directory from the specified file worked
-   *
-   * @param parent The parent of the directory to create
-   * @param name The name of the directoryto create (relative to {@code parent}
-   * @return The created directory
-   */
-  protected File assertMkdirs(File parent, String name) {
-    File file = new File(parent, name);
-    assertMkdirs(file);
-    return file;
-  }
-
-  /**
-   * Asserts that creating a file worked
-   *
-   * @param file The file to create
-   */
-  protected void assertCreateFile(File file) throws IOException {
-    assertTrue("Could not create file '" + file.getAbsolutePath() + "'",
-        file.createNewFile());
-  }
-
-  /**
-   * Asserts that creating a file worked
-   *
-   * @param parent The parent of the file to create
-   * @param name The name of the file to create (relative to {@code parent}
-   * @return The created file
-   */
-  protected File assertCreateFile(File parent, String name) throws IOException {
-    File file = new File(parent, name);
-    assertCreateFile(file);
-    return file;
-  }
-
-  /**
-   * Creates a file in the system's default folder for temporary files.
-   *
-   * The file/directory automatically gets removed during tearDown.
-   *
-   * The name of the created file begins with 'gerrit_test_', and is located
-   * in the system's default folder for temporary files.
-   *
-   * @param suffix Trailing part of the file name.
-   * @return The temporary file.
-   * @throws IOException If a file could not be created.
-   */
-  private File createTempFile(String suffix) throws IOException {
-    String prefix = "gerrit_test_";
-    if (!Strings.isNullOrEmpty(getName())) {
-      prefix += getName() + "_";
-    }
-    File tmp = File.createTempFile(prefix, suffix);
-    toCleanup.add(tmp);
-    return tmp;
-  }
-
-  /**
-   * Creates a file in the system's default folder for temporary files.
-   *
-   * The file/directory automatically gets removed during tearDown.
-   *
-   * The name of the created file begins with 'gerrit_test_', and is located
-   * in the system's default folder for temporary files.
-   *
-   * @return The temporary file.
-   * @throws IOException If a file could not be created.
-   */
-  protected File createTempFile() throws IOException {
-    return createTempFile("");
-  }
-
-  /**
-   * Creates a directory in the system's default folder for temporary files.
-   *
-   * The directory (and all it's contained files/directory) automatically get
-   * removed during tearDown.
-   *
-   * The name of the created directory begins with 'gerrit_test_', and is be
-   * located in the system's default folder for temporary files.
-   *
-   * @return The temporary directory.
-   * @throws IOException If a file could not be created.
-   */
-  protected File createTempDir() throws IOException {
-    File tmp = createTempFile(".dir");
-    if (!tmp.delete()) {
-      throw new IOException("Cannot delete temporary file '" + tmp.getPath()
-          + "'");
-    }
-    tmp.mkdir();
-    return tmp;
-  }
-
-  private void cleanupCreatedFiles() throws IOException {
-    for (File file : toCleanup) {
-      FileUtils.delete(file,  FileUtils.RECURSIVE);
-    }
-  }
-
-  @Override
-  public void tearDown() throws Exception {
-    cleanupCreatedFiles();
-    super.tearDown();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
index 967e3f9..9135c553 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
@@ -14,12 +14,19 @@
 
 package com.google.gerrit.testutil;
 
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
 import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.rules.ExpectedException;
+import org.junit.rules.TestName;
 
 @Ignore
 public abstract class GerritBaseTests {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+  static {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
+  @Rule public ExpectedException exception = ExpectedException.none();
+  @Rule public final TestName testName = new TestName();
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
index 797f1cb..038baac 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
@@ -23,36 +23,34 @@
 
 @RunWith(ConfigSuite.class)
 public class GerritServerTests extends GerritBaseTests {
-  @ConfigSuite.Parameter
-  public Config config;
+  @ConfigSuite.Parameter public Config config;
 
-  @ConfigSuite.Name
-  private String configName;
+  @ConfigSuite.Name private String configName;
 
   protected TestNotesMigration notesMigration;
 
   @Rule
-  public TestRule testRunner = new TestRule() {
-    @Override
-    public Statement apply(final Statement base, final Description description) {
-      return new Statement() {
+  public TestRule testRunner =
+      new TestRule() {
         @Override
-        public void evaluate() throws Throwable {
-          beforeTest();
-          try {
-            base.evaluate();
-          } finally {
-            afterTest();
-          }
+        public Statement apply(final Statement base, final Description description) {
+          return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+              beforeTest();
+              try {
+                base.evaluate();
+              } finally {
+                afterTest();
+              }
+            }
+          };
         }
       };
-    }
-  };
 
   public void beforeTest() throws Exception {
     notesMigration = new TestNotesMigration().setFromEnv();
   }
 
-  public void afterTest() {
-  }
+  public void afterTest() {}
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java
index 458e100..bff27ca 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java
@@ -17,35 +17,36 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
+import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.client.SystemConfig;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.schema.SchemaVersion;
 import com.google.gwtorm.jdbc.Database;
 import com.google.gwtorm.jdbc.SimpleDataSource;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
 import java.io.IOException;
 import java.sql.Connection;
 import java.sql.SQLException;
 import java.util.Properties;
-
 import javax.sql.DataSource;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
  * An in-memory test instance of {@link ReviewDb} database.
- * <p>
- * Test classes should create one instance of this class for each unique test
- * database they want to use. When the tests needing this instance are complete,
- * ensure that {@link #drop(InMemoryDatabase)} is called to free the resources so
- * the JVM running the unit tests doesn't run out of heap space.
+ *
+ * <p>Test classes should create one instance of this class for each unique test database they want
+ * to use. When the tests needing this instance are complete, ensure that {@link
+ * #drop(InMemoryDatabase)} is called to free the resources so the JVM running the unit tests
+ * doesn't run out of heap space.
  */
 public class InMemoryDatabase implements SchemaFactory<ReviewDb> {
   public static InMemoryDatabase newDatabase(LifecycleManager lifecycle) {
@@ -59,7 +60,7 @@
   private static synchronized DataSource newDataSource() throws SQLException {
     final Properties p = new Properties();
     p.setProperty("driver", org.h2.Driver.class.getName());
-    p.setProperty("url", "jdbc:h2:mem:" + "Test_" + (++dbCnt));
+    p.setProperty("url", "jdbc:h2:mem:Test_" + (++dbCnt));
     return new SimpleDataSource(p);
   }
 
@@ -77,9 +78,34 @@
   private boolean created;
 
   @Inject
+  InMemoryDatabase(Injector injector) throws OrmException {
+    Injector childInjector =
+        injector.createChildInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                switch (IndexModule.getIndexType(injector)) {
+                  case LUCENE:
+                    install(new LuceneIndexModuleOnInit());
+                    break;
+                  case ELASTICSEARCH:
+                    install(new ElasticIndexModuleOnInit());
+                    break;
+                  default:
+                    throw new IllegalStateException("unsupported index.type");
+                }
+              }
+            });
+    this.schemaCreator = childInjector.getInstance(SchemaCreator.class);
+    initDatabase();
+  }
+
   InMemoryDatabase(SchemaCreator schemaCreator) throws OrmException {
     this.schemaCreator = schemaCreator;
+    initDatabase();
+  }
 
+  private void initDatabase() throws OrmException {
     try {
       DataSource dataSource = newDataSource();
 
@@ -147,7 +173,6 @@
   }
 
   public void assertSchemaVersion() throws OrmException {
-    assertThat(getSchemaVersion().versionNbr)
-      .isEqualTo(SchemaVersion.getBinaryVersion());
+    assertThat(getSchemaVersion().versionNbr).isEqualTo(SchemaVersion.getBinaryVersion());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index 9e5b776..f70e39e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -18,15 +18,16 @@
 
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersName;
@@ -36,28 +37,34 @@
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
-import com.google.gerrit.server.git.ChangeUpdateExecutor;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.SendEmailExecutor;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.account.AllAccountsIndexer;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.AllGroupsIndexer;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.schema.DataSourceType;
-import com.google.gerrit.server.schema.H2AccountPatchReviewStore;
+import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.ssh.NoSshKeyCache;
+import com.google.gerrit.server.update.ChangeUpdateExecutor;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.AbstractModule;
@@ -70,10 +77,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.servlet.RequestScoped;
-
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-
+import com.google.inject.util.Providers;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.nio.file.Path;
@@ -81,6 +85,8 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
 
 public class InMemoryModule extends FactoryModule {
   public static Config newDefaultConfig() {
@@ -131,13 +137,14 @@
 
     // For simplicity, don't create child injectors, just use this one to get a
     // few required modules.
-    Injector cfgInjector = Guice.createInjector(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(Config.class).annotatedWith(GerritServerConfig.class)
-            .toInstance(cfg);
-      }
-    });
+    Injector cfgInjector =
+        Guice.createInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+              }
+            });
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
     install(new SearchingChangeCacheImpl.Module());
@@ -148,60 +155,61 @@
     // TODO(dborowitz): Use jimfs.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+    bind(GerritOptions.class).toInstance(new GerritOptions(cfg, false, false, false));
     bind(PersonIdent.class)
         .annotatedWith(GerritPersonIdent.class)
         .toProvider(GerritPersonIdentProvider.class);
     bind(String.class)
         .annotatedWith(AnonymousCowardName.class)
         .toProvider(AnonymousCowardNameProvider.class);
-    bind(String.class)
-        .annotatedWith(GerritServerId.class)
-        .toInstance("gerrit");
-    bind(AllProjectsName.class)
-        .toProvider(AllProjectsNameProvider.class);
-    bind(AllUsersName.class)
-        .toProvider(AllUsersNameProvider.class);
-    bind(GitRepositoryManager.class)
-        .to(InMemoryRepositoryManager.class);
+    bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
+    bind(AllProjectsName.class).toProvider(AllProjectsNameProvider.class);
+    bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+    bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
-    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class)
-        .in(SINGLETON);
+    bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
     bind(NotesMigration.class).toInstance(notesMigration);
     bind(ListeningExecutorService.class)
         .annotatedWith(ChangeUpdateExecutor.class)
         .toInstance(MoreExecutors.newDirectExecutorService());
 
-    bind(DataSourceType.class)
-      .to(InMemoryH2Type.class);
-    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
-        .to(InMemoryDatabase.class);
+    bind(DataSourceType.class).to(InMemoryH2Type.class);
+    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).to(InMemoryDatabase.class);
+    bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
 
     bind(SecureStore.class).to(DefaultSecureStore.class);
 
     install(NoSshKeyCache.module());
-    install(new CanonicalWebUrlModule() {
-      @Override
-      protected Class<? extends Provider<String>> provider() {
-        return CanonicalWebUrlProvider.class;
-      }
-    });
-    //Replacement of DiffExecutorModule to not use thread pool in the tests
-    install(new AbstractModule() {
-      @Override
-      protected void configure() {
-      }
-      @Provides
-      @Singleton
-      @DiffExecutor
-      public ExecutorService createDiffExecutor() {
-        return MoreExecutors.newDirectExecutorService();
-      }
-    });
-    install(new DefaultCacheFactory.Module());
+    install(
+        new CanonicalWebUrlModule() {
+          @Override
+          protected Class<? extends Provider<String>> provider() {
+            return CanonicalWebUrlProvider.class;
+          }
+        });
+    // Replacement of DiffExecutorModule to not use thread pool in the tests
+    install(
+        new AbstractModule() {
+          @Override
+          protected void configure() {}
+
+          @Provides
+          @Singleton
+          @DiffExecutor
+          public ExecutorService createDiffExecutor() {
+            return MoreExecutors.newDirectExecutorService();
+          }
+        });
+    install(new DefaultMemoryCacheModule());
+    install(new H2CacheModule());
     install(new FakeEmailSender.Module());
     install(new SignedTokenEmailTokenVerifier.Module());
     install(new GpgModule(cfg));
-    install(new H2AccountPatchReviewStore.InMemoryModule());
+    install(new InMemoryAccountPatchReviewStore.Module());
+
+    bind(AllAccountsIndexer.class).toProvider(Providers.of(null));
+    bind(AllChangesIndexer.class).toProvider(Providers.of(null));
+    bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
 
     IndexType indexType = null;
     try {
@@ -214,9 +222,11 @@
         case LUCENE:
           install(luceneIndexModule());
           break;
+        case ELASTICSEARCH:
+          install(elasticIndexModule());
+          break;
         default:
-          throw new ProvisionException(
-              "index type unsupported in tests: " + indexType);
+          throw new ProvisionException("index type unsupported in tests: " + indexType);
       }
     }
   }
@@ -230,25 +240,33 @@
 
   @Provides
   @Singleton
-  InMemoryDatabase getInMemoryDatabase(SchemaCreator schemaCreator)
-      throws OrmException {
+  InMemoryDatabase getInMemoryDatabase(SchemaCreator schemaCreator) throws OrmException {
     return new InMemoryDatabase(schemaCreator);
   }
 
   private Module luceneIndexModule() {
+    return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
+  }
+
+  private Module elasticIndexModule() {
+    return indexModule("com.google.gerrit.elasticsearch.ElasticIndexModule");
+  }
+
+  private Module indexModule(String moduleClassName) {
     try {
       Map<String, Integer> singleVersions = new HashMap<>();
       int version = cfg.getInt("index", "lucene", "testVersion", -1);
       if (version > 0) {
         singleVersions.put(ChangeSchemaDefinitions.INSTANCE.getName(), version);
       }
-      Class<?> clazz =
-          Class.forName("com.google.gerrit.lucene.LuceneIndexModule");
-      Method m = clazz.getMethod(
-          "singleVersionWithExplicitVersions", Map.class, int.class);
+      Class<?> clazz = Class.forName(moduleClassName);
+      Method m = clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class);
       return (Module) m.invoke(null, singleVersions, 0);
-    } catch (ClassNotFoundException | SecurityException | NoSuchMethodException
-        | IllegalArgumentException | IllegalAccessException
+    } catch (ClassNotFoundException
+        | SecurityException
+        | NoSuchMethodException
+        | IllegalArgumentException
+        | IllegalAccessException
         | InvocationTargetException e) {
       e.printStackTrace();
       ProvisionException pe = new ProvisionException(e.getMessage());
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
index e7bd8f8..4826d9e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
@@ -19,16 +19,14 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
-
+import java.util.HashMap;
+import java.util.Map;
+import java.util.SortedSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 
-import java.util.HashMap;
-import java.util.Map;
-import java.util.SortedSet;
-
 /** Repository manager that uses in-memory repositories. */
 public class InMemoryRepositoryManager implements GitRepositoryManager {
   public static InMemoryRepository newRepository(Project.NameKey name) {
@@ -37,12 +35,10 @@
 
   public static class Description extends DfsRepositoryDescription {
     private final Project.NameKey name;
-    private String desc;
 
     private Description(Project.NameKey name) {
       super(name.get());
       this.name = name;
-      desc = "In-memory repository " + name.get();
     }
 
     public Project.NameKey getProject() {
@@ -51,6 +47,8 @@
   }
 
   public static class Repo extends InMemoryRepository {
+    private String description;
+
     private Repo(Project.NameKey name) {
       super(new Description(name));
       // TODO(dborowitz): Allow atomic transactions when this is supported:
@@ -62,13 +60,22 @@
     public Description getDescription() {
       return (Description) super.getDescription();
     }
+
+    @Override
+    public String getGitwebDescription() {
+      return description;
+    }
+
+    @Override
+    public void setGitwebDescription(String d) {
+      description = d;
+    }
   }
 
   private Map<String, Repo> repos = new HashMap<>();
 
   @Override
-  public synchronized Repo openRepository(Project.NameKey name)
-      throws RepositoryNotFoundException {
+  public synchronized Repo openRepository(Project.NameKey name) throws RepositoryNotFoundException {
     return get(name);
   }
 
@@ -97,28 +104,11 @@
     return ImmutableSortedSet.copyOf(names);
   }
 
-  @Override
-  public synchronized String getProjectDescription(Project.NameKey name)
-      throws RepositoryNotFoundException {
-    return get(name).getDescription().desc;
-  }
-
-  @Override
-  public synchronized void setProjectDescription(Project.NameKey name,
-      String description) {
-    try {
-      get(name).getDescription().desc = description;
-    } catch (RepositoryNotFoundException e) {
-      // Ignore.
-    }
-  }
-
   public synchronized void deleteRepository(Project.NameKey name) {
     repos.remove(normalize(name));
   }
 
-  private synchronized Repo get(Project.NameKey name)
-      throws RepositoryNotFoundException {
+  private synchronized Repo get(Project.NameKey name) throws RepositoryNotFoundException {
     Repo repo = repos.get(normalize(name));
     if (repo != null) {
       repo.incrementOpen();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/LoggingMockingTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/LoggingMockingTestCase.java
deleted file mode 100644
index d7140ec..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/LoggingMockingTestCase.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2013 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.testutil;
-
-import com.google.gerrit.testutil.log.LogUtil;
-
-import org.apache.log4j.LogManager;
-import org.apache.log4j.Logger;
-import org.apache.log4j.spi.LoggingEvent;
-import org.junit.After;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-
-/**
- * Testcase capturing associated logs and allowing to assert on them.
- *
- * For a test case SomeNameTest, the log for SomeName gets captured. Assertions
- * on logs run against the coptured log events from this logger. After the
- * tests, the logger are set back to their original settings.
- */
-public abstract class LoggingMockingTestCase extends MockingTestCase {
-  private String loggerName;
-  private LogUtil.LoggerSettings loggerSettings;
-  private java.util.Collection<LoggingEvent> loggedEvents;
-
-  /**
-   * Assert a logged event with a given string.
-   * <p>
-   * If such a event is found, it is removed from the captured logs.
-   *
-   * @param needle The string to look for.
-   */
-  protected final void assertLogMessageContains(String needle) {
-    LoggingEvent hit = null;
-    Iterator<LoggingEvent> iter = loggedEvents.iterator();
-    while (hit == null && iter.hasNext()) {
-      LoggingEvent event = iter.next();
-      if (event.getRenderedMessage().contains(needle)) {
-        hit = event;
-      }
-    }
-    assertNotNull("Could not find log message containing '" + needle + "'",
-        hit);
-    assertTrue("Could not remove log message containing '" + needle + "'",
-        loggedEvents.remove(hit));
-  }
-
-  /**
-   * Assert a logged event whose throwable contains a given string
-   * <p>
-   * If such a event is found, it is removed from the captured logs.
-   *
-   * @param needle The string to look for.
-   */
-  protected final void assertLogThrowableMessageContains(String needle) {
-    LoggingEvent hit = null;
-    Iterator<LoggingEvent> iter = loggedEvents.iterator();
-    while (hit == null && iter.hasNext()) {
-      LoggingEvent event = iter.next();
-      if (event.getThrowableInformation().getThrowable().toString()
-          .contains(needle)) {
-        hit = event;
-      }
-    }
-    assertNotNull("Could not find log message with a Throwable containing '"
-        + needle + "'", hit);
-    assertTrue("Could not remove log message with a Throwable containing '"
-        + needle + "'", loggedEvents.remove(hit));
-  }
-
-  /**
-   * Assert that all logged events have been asserted
-   */
-  // As the PowerMock runner does not pass through runTest, we inject log
-  // verification through @After
-  @After
-  public final void assertNoUnassertedLogEvents() {
-    if (loggedEvents.size() > 0) {
-      LoggingEvent event = loggedEvents.iterator().next();
-      String msg = "Found untreated logged events. First one is:\n";
-      msg += event.getRenderedMessage();
-      if (event.getThrowableInformation() != null) {
-        msg += "\n" + event.getThrowableInformation().getThrowable();
-      }
-      fail(msg);
-    }
-  }
-
-  @Override
-  public void setUp() throws Exception {
-    super.setUp();
-    loggedEvents = new ArrayList<>();
-
-    // The logger we're interested is class name without the trailing "Test".
-    // While this is not the most general approach it is sufficient for now,
-    // and we can improve later to allow tests to specify which loggers are
-    // to check.
-    loggerName = this.getClass().getCanonicalName();
-    loggerName = loggerName.substring(0, loggerName.length() - 4);
-    loggerSettings = LogUtil.logToCollection(loggerName, loggedEvents);
-  }
-
-  @Override
-  protected void runTest() throws Throwable {
-    super.runTest();
-    // Plain JUnit runner does not pick up @After, so we add it here
-    // explicitly. Note, that we cannot put this into tearDown, as failure
-    // to verify mocks would bail out and might leave open resources from
-    // subclasses open.
-    assertNoUnassertedLogEvents();
-  }
-
-  @Override
-  public void tearDown() throws Exception {
-    if (loggerName != null && loggerSettings != null) {
-      Logger logger = LogManager.getLogger(loggerName);
-      loggerSettings.pushOntoLogger(logger);
-    }
-    super.tearDown();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/MockingTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/MockingTestCase.java
deleted file mode 100644
index 569a57f..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/MockingTestCase.java
+++ /dev/null
@@ -1,155 +0,0 @@
-// Copyright (C) 2013 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.testutil;
-
-import junit.framework.TestCase;
-
-import org.easymock.EasyMock;
-import org.easymock.IMocksControl;
-import org.junit.After;
-import org.junit.runner.RunWith;
-import org.powermock.api.easymock.PowerMock;
-import org.powermock.modules.junit4.PowerMockRunner;
-
-import java.util.ArrayList;
-import java.util.Collection;
-
-/**
- * Test case with some support for automatically verifying mocks.
- *
- * This test case works transparently with EasyMock and PowerMock.
- */
-public abstract class MockingTestCase extends TestCase {
-  private Collection<Object> mocks;
-  private Collection<IMocksControl> mockControls;
-  private boolean mocksReplayed;
-  private boolean usePowerMock;
-
-  /**
-   * Create and register a mock control.
-   *
-   * @return The mock control instance.
-   */
-  protected final IMocksControl createMockControl() {
-    IMocksControl mockControl = EasyMock.createControl();
-    assertTrue("Adding mock control failed", mockControls.add(mockControl));
-    return mockControl;
-  }
-
-  /**
-   * Create and register a mock.
-   *
-   * Creates a mock and registers it in the list of created mocks, so it gets
-   * treated automatically upon {@code replay} and {@code verify};
-   * @param toMock The class to create a mock for.
-   * @return The mock instance.
-   */
-  protected final <T> T createMock(Class<T> toMock) {
-    return createMock(toMock, null);
-  }
-
-  /**
-   * Create a mock for a mock control and register a mock.
-   *
-   * Creates a mock and registers it in the list of created mocks, so it gets
-   * treated automatically upon {@code replay} and {@code verify};
-   * @param toMock The class to create a mock for.
-   * @param control The mock control to create the mock on. If null, do not use
-   *    a specific control.
-   * @return The mock instance.
-   */
-  protected final <T> T createMock(Class<T> toMock, IMocksControl control) {
-    assertFalse("Mocks have already been set to replay", mocksReplayed);
-    final T mock;
-    if (control == null) {
-      if (usePowerMock) {
-        mock = PowerMock.createMock(toMock);
-      } else {
-        mock = EasyMock.createMock(toMock);
-      }
-      assertTrue("Adding " + toMock.getName() + " mock failed",
-          mocks.add(mock));
-    } else {
-      mock = control.createMock(toMock);
-    }
-    return mock;
-  }
-
-  /**
-   * Set all registered mocks to replay
-   */
-  protected final void replayMocks() {
-    assertFalse("Mocks have already been set to replay", mocksReplayed);
-    if (usePowerMock) {
-      PowerMock.replayAll();
-    } else {
-      EasyMock.replay(mocks.toArray());
-    }
-    for (IMocksControl mockControl : mockControls) {
-      mockControl.replay();
-    }
-    mocksReplayed = true;
-  }
-
-  /**
-   * Verify all registered mocks
-   *
-   * This method is called automatically at the end of a test. Nevertheless,
-   * it is safe to also call it beforehand, if this better meets the
-   * verification part of a test.
-   */
-  // As the PowerMock runner does not pass through runTest, we inject mock
-  // verification through @After
-  @After
-  public final void verifyMocks() {
-    if (!mocks.isEmpty() || !mockControls.isEmpty()) {
-      assertTrue("Created mocks have not been set to replay. Call replayMocks "
-          + "within the test", mocksReplayed);
-      if (usePowerMock) {
-        PowerMock.verifyAll();
-      } else {
-        EasyMock.verify(mocks.toArray());
-      }
-      for (IMocksControl mockControl : mockControls) {
-        mockControl.verify();
-      }
-    }
-  }
-
-  @Override
-  public void setUp() throws Exception {
-    super.setUp();
-
-    usePowerMock = false;
-    RunWith runWith = this.getClass().getAnnotation(RunWith.class);
-    if (runWith != null) {
-      usePowerMock = PowerMockRunner.class.isAssignableFrom(runWith.value());
-    }
-
-    mocks = new ArrayList<>();
-    mockControls = new ArrayList<>();
-    mocksReplayed = false;
-  }
-
-  @Override
-  protected void runTest() throws Throwable {
-    super.runTest();
-    // Plain JUnit runner does not pick up @After, so we add it here
-    // explicitly. Note, that we cannot put this into tearDown, as failure
-    // to verify mocks would bail out and might leave open resources from
-    // subclasses open.
-    verifyMocks();
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
index 61bfe78..aeaaa47 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
@@ -15,31 +15,36 @@
 package com.google.gerrit.testutil;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.PatchLineCommentsUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeRebuilder;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
+import com.google.gwtorm.client.IntKey;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.runner.Description;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class NoteDbChecker {
@@ -48,38 +53,38 @@
   private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
   private final TestNotesMigration notesMigration;
+  private final ChangeBundleReader bundleReader;
   private final ChangeNotes.Factory notesFactory;
   private final ChangeRebuilder changeRebuilder;
-  private final PatchLineCommentsUtil plcUtil;
+  private final CommentsUtil commentsUtil;
 
   @Inject
-  NoteDbChecker(Provider<ReviewDb> dbProvider,
+  NoteDbChecker(
+      Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
       TestNotesMigration notesMigration,
+      ChangeBundleReader bundleReader,
       ChangeNotes.Factory notesFactory,
       ChangeRebuilder changeRebuilder,
-      PatchLineCommentsUtil plcUtil) {
+      CommentsUtil commentsUtil) {
     this.dbProvider = dbProvider;
     this.repoManager = repoManager;
+    this.bundleReader = bundleReader;
     this.notesMigration = notesMigration;
     this.notesFactory = notesFactory;
     this.changeRebuilder = changeRebuilder;
-    this.plcUtil = plcUtil;
+    this.commentsUtil = commentsUtil;
   }
 
   public void rebuildAndCheckAllChanges() throws Exception {
-    rebuildAndCheckChanges(
-        Iterables.transform(
-            getUnwrappedDb().changes().all(),
-            ReviewDbUtil.changeIdFunction()));
+    rebuildAndCheckChanges(getUnwrappedDb().changes().all().toList().stream().map(Change::getId));
   }
 
   public void rebuildAndCheckChanges(Change.Id... changeIds) throws Exception {
-    rebuildAndCheckChanges(Arrays.asList(changeIds));
+    rebuildAndCheckChanges(Arrays.stream(changeIds));
   }
 
-  public void rebuildAndCheckChanges(Iterable<Change.Id> changeIds)
-      throws Exception {
+  private void rebuildAndCheckChanges(Stream<Change.Id> changeIds) throws Exception {
     ReviewDb db = getUnwrappedDb();
 
     List<ChangeBundle> allExpected = readExpected(changeIds);
@@ -107,40 +112,54 @@
   }
 
   public void checkChanges(Change.Id... changeIds) throws Exception {
-    checkChanges(Arrays.asList(changeIds));
+    checkActual(readExpected(Arrays.stream(changeIds)), new ArrayList<>());
   }
 
-  public void checkChanges(Iterable<Change.Id> changeIds) throws Exception {
-    checkActual(readExpected(changeIds), new ArrayList<String>());
-  }
-
-  public void assertNoChangeRef(Project.NameKey project, Change.Id changeId)
-      throws Exception {
+  public void assertNoChangeRef(Project.NameKey project, Change.Id changeId) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNull();
     }
   }
 
-  private List<ChangeBundle> readExpected(Iterable<Change.Id> changeIds)
-      throws Exception {
+  public void assertNoReviewDbChanges(Description desc) throws Exception {
     ReviewDb db = getUnwrappedDb();
+    assertThat(db.changes().all().toList()).named("Changes in " + desc.getTestClass()).isEmpty();
+    assertThat(db.changeMessages().all().toList())
+        .named("ChangeMessages in " + desc.getTestClass())
+        .isEmpty();
+    assertThat(db.patchSets().all().toList())
+        .named("PatchSets in " + desc.getTestClass())
+        .isEmpty();
+    assertThat(db.patchSetApprovals().all().toList())
+        .named("PatchSetApprovals in " + desc.getTestClass())
+        .isEmpty();
+    assertThat(db.patchComments().all().toList())
+        .named("PatchLineComments in " + desc.getTestClass())
+        .isEmpty();
+  }
+
+  private List<ChangeBundle> readExpected(Stream<Change.Id> changeIds) throws Exception {
     boolean old = notesMigration.readChanges();
     try {
       notesMigration.setReadChanges(false);
-      List<Change.Id> sortedIds =
-          ReviewDbUtil.intKeyOrdering().sortedCopy(changeIds);
-      List<ChangeBundle> expected = new ArrayList<>(sortedIds.size());
-      for (Change.Id id : sortedIds) {
-        expected.add(ChangeBundle.fromReviewDb(db, id));
-      }
-      return expected;
+      return changeIds
+          .sorted(comparing(IntKey::get))
+          .map(this::readBundleUnchecked)
+          .collect(toList());
     } finally {
       notesMigration.setReadChanges(old);
     }
   }
 
-  private void checkActual(List<ChangeBundle> allExpected, List<String> msgs)
-      throws Exception {
+  private ChangeBundle readBundleUnchecked(Change.Id id) {
+    try {
+      return bundleReader.fromReviewDb(getUnwrappedDb(), id);
+    } catch (OrmException e) {
+      throw new OrmRuntimeException(e);
+    }
+  }
+
+  private void checkActual(List<ChangeBundle> allExpected, List<String> msgs) throws Exception {
     ReviewDb db = getUnwrappedDb();
     boolean oldRead = notesMigration.readChanges();
     boolean oldWrite = notesMigration.writeChanges();
@@ -151,8 +170,9 @@
         Change c = expected.getChange();
         ChangeBundle actual;
         try {
-          actual = ChangeBundle.fromNotes(
-              plcUtil, notesFactory.create(db, c.getProject(), c.getId()));
+          actual =
+              ChangeBundle.fromNotes(
+                  commentsUtil, notesFactory.create(db, c.getProject(), c.getId()));
         } catch (Throwable t) {
           String msg = "Error converting change: " + c;
           msgs.add(msg);
@@ -165,8 +185,7 @@
           msgs.addAll(diff);
           msgs.add("");
         } else {
-          System.err.println(
-              "NoteDb conversion of change " + c.getId() + " successful");
+          System.err.println("NoteDb conversion of change " + c.getId() + " successful");
         }
       }
     } finally {
@@ -180,6 +199,6 @@
 
   private ReviewDb getUnwrappedDb() {
     ReviewDb db = dbProvider.get();
-    return  ReviewDbUtil.unwrapDb(db);
+    return ReviewDbUtil.unwrapDb(db);
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
index 103fee3..552f6f1 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
@@ -14,53 +14,66 @@
 
 package com.google.gerrit.testutil;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.base.Enums;
-import com.google.common.base.Optional;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 
 public enum NoteDbMode {
   /** NoteDb is disabled. */
-  OFF,
+  OFF(false),
 
   /** Writing data to NoteDb is enabled. */
-  WRITE,
+  WRITE(false),
 
   /** Reading and writing all data to NoteDb is enabled. */
-  READ_WRITE,
+  READ_WRITE(true),
+
+  /** Changes are created with their primary storage as NoteDb. */
+  PRIMARY(true),
+
+  /** All change tables are entirely disabled. */
+  DISABLE_CHANGE_REVIEW_DB(true),
 
   /**
-   * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check
-   * that the results match.
+   * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check that the results
+   * match.
    */
-  CHECK;
+  CHECK(false);
 
-  private static final String VAR = "GERRIT_NOTEDB";
+  private static final String ENV_VAR = "GERRIT_NOTEDB";
+  private static final String SYS_PROP = "gerrit.notedb";
 
   public static NoteDbMode get() {
-    if (isEnvVarTrue("GERRIT_ENABLE_NOTEDB")) {
-      // TODO(dborowitz): Remove once GerritForge CI is migrated.
-      return READ_WRITE;
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
     }
-    String value = System.getenv(VAR);
     if (Strings.isNullOrEmpty(value)) {
       return OFF;
     }
     value = value.toUpperCase().replace("-", "_");
-    Optional<NoteDbMode> mode = Enums.getIfPresent(NoteDbMode.class, value);
-    if (!mode.isPresent()) {
-      throw new IllegalArgumentException(
-          "Invalid value for " + VAR + ": " + System.getenv(VAR));
+    NoteDbMode mode = Enums.getIfPresent(NoteDbMode.class, value).orNull();
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          mode != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
     }
-    return mode.get();
+    return mode;
   }
 
   public static boolean readWrite() {
-    return get() == READ_WRITE;
+    return get().readWrite;
   }
 
-  private static boolean isEnvVarTrue(String name) {
-    String value = Strings.nullToEmpty(System.getenv(name)).toLowerCase();
-    return ImmutableList.of("yes", "y", "true", "1").contains(value);
+  private final boolean readWrite;
+
+  private NoteDbMode(boolean readWrite) {
+    this.readWrite = readWrite;
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/PassThroughKeyUtilEncoder.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/PassThroughKeyUtilEncoder.java
deleted file mode 100644
index e008b78..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/PassThroughKeyUtilEncoder.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2013 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.testutil;
-
-import com.google.gwtorm.client.KeyUtil.Encoder;
-
-public class PassThroughKeyUtilEncoder extends Encoder {
-  @Override
-  public String encode(String e) {
-    return e;
-  }
-
-  @Override
-  public String decode(String e) {
-    return e;
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/SetMatcher.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/SetMatcher.java
deleted file mode 100644
index f1e7b7b..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/SetMatcher.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2013 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.testutil;
-
-import com.google.common.collect.Sets;
-
-import org.easymock.EasyMock;
-import org.easymock.IArgumentMatcher;
-
-import java.util.Set;
-
-/**
- * Match for Iterables via set equals
- *
- * Converts both expected and actual parameter to a set and compares those two
- * sets via equals to determine whether or not they match.
- */
-public class SetMatcher<T> implements IArgumentMatcher {
-  public static <S extends Iterable<T>,T> S setEq(S expected) {
-    EasyMock.reportMatcher(new SetMatcher<>(expected));
-    return null;
-  }
-
-  Set<T> expected;
-
-  public SetMatcher(Iterable<T> expected) {
-    this.expected = Sets.newHashSet(expected);
-  }
-
-  @Override
-  public boolean matches(Object actual) {
-    if (actual instanceof Iterable<?>) {
-      Set<?> actualSet = Sets.newHashSet((Iterable<?>)actual);
-      return expected.equals(actualSet);
-    }
-    return false;
-  }
-
-  @Override
-  public void appendTo(StringBuffer buffer) {
-    buffer.append(expected);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java
new file mode 100644
index 0000000..9320331
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testutil;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
+
+public enum SshMode {
+  /** Tests annotated with UseSsh will be disabled. */
+  NO,
+
+  /** Tests annotated with UseSsh will be enabled. */
+  YES;
+
+  private static final String ENV_VAR = "GERRIT_USE_SSH";
+  private static final String SYS_PROP = "gerrit.use.ssh";
+
+  public static SshMode get() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return YES;
+    }
+    value = value.toUpperCase();
+    SshMode mode = Enums.getIfPresent(SshMode.class, value).orNull();
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          mode != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return mode;
+  }
+
+  public static boolean useSsh() {
+    return get() == YES;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TempFileUtil.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TempFileUtil.java
index 72c2b5a..f90a4fe 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TempFileUtil.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TempFileUtil.java
@@ -38,7 +38,7 @@
     allDirsCreated.clear();
   }
 
-  private static void recursivelyDelete(File dir) throws IOException {
+  public static void recursivelyDelete(File dir) throws IOException {
     if (!dir.getPath().equals(dir.getCanonicalPath())) {
       // Directory symlink reaching outside of temporary space.
       return;
@@ -62,6 +62,5 @@
     }
   }
 
-  private TempFileUtil() {
-  }
+  private TempFileUtil() {}
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
index 594ce82..459bccd 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
@@ -36,7 +36,8 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Injector;
-
+import java.util.TimeZone;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.easymock.EasyMock;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -44,12 +45,9 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-import java.util.TimeZone;
-import java.util.concurrent.atomic.AtomicInteger;
-
 /**
- * Utility functions to create and manipulate Change, ChangeUpdate, and
- * ChangeControl objects for testing.
+ * Utility functions to create and manipulate Change, ChangeUpdate, and ChangeControl objects for
+ * testing.
  */
 public class TestChanges {
   private static final AtomicInteger nextChangeId = new AtomicInteger(1);
@@ -58,26 +56,24 @@
     return newChange(project, userId, nextChangeId.getAndIncrement());
   }
 
-  public static Change newChange(Project.NameKey project, Account.Id userId,
-      int id) {
+  public static Change newChange(Project.NameKey project, Account.Id userId, int id) {
     Change.Id changeId = new Change.Id(id);
-    Change c = new Change(
-        new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
-        changeId,
-        userId,
-        new Branch.NameKey(project, "master"),
-        TimeUtil.nowTs());
+    Change c =
+        new Change(
+            new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
+            changeId,
+            userId,
+            new Branch.NameKey(project, "master"),
+            TimeUtil.nowTs());
     incrementPatchSet(c);
     return c;
   }
 
-  public static PatchSet newPatchSet(PatchSet.Id id, ObjectId revision,
-      Account.Id userId) {
+  public static PatchSet newPatchSet(PatchSet.Id id, ObjectId revision, Account.Id userId) {
     return newPatchSet(id, revision.name(), userId);
   }
 
-  public static PatchSet newPatchSet(PatchSet.Id id, String revision,
-      Account.Id userId) {
+  public static PatchSet newPatchSet(PatchSet.Id id, String revision, Account.Id userId) {
     PatchSet ps = new PatchSet(id);
     ps.setRevision(new RevId(revision));
     ps.setUploader(userId);
@@ -85,25 +81,26 @@
     return ps;
   }
 
-  public static ChangeUpdate newUpdate(Injector injector,
-      Change c, final CurrentUser user) throws Exception  {
-    injector = injector.createChildInjector(new FactoryModule() {
-      @Override
-      public void configure() {
-        bind(CurrentUser.class).toInstance(user);
-      }
-    });
-    ChangeUpdate update = injector.getInstance(ChangeUpdate.Factory.class)
-        .create(
-            stubChangeControl(
-                injector.getInstance(AbstractChangeNotes.Args.class),
-                c,
-                user),
-            TimeUtil.nowTs(), Ordering.<String> natural());
+  public static ChangeUpdate newUpdate(Injector injector, Change c, final CurrentUser user)
+      throws Exception {
+    injector =
+        injector.createChildInjector(
+            new FactoryModule() {
+              @Override
+              public void configure() {
+                bind(CurrentUser.class).toInstance(user);
+              }
+            });
+    ChangeUpdate update =
+        injector
+            .getInstance(ChangeUpdate.Factory.class)
+            .create(
+                stubChangeControl(injector.getInstance(AbstractChangeNotes.Args.class), c, user),
+                TimeUtil.nowTs(),
+                Ordering.<String>natural());
 
     ChangeNotes notes = update.getNotes();
-    boolean hasPatchSets = notes.getPatchSets() != null
-        && !notes.getPatchSets().isEmpty();
+    boolean hasPatchSets = notes.getPatchSets() != null && !notes.getPatchSets().isEmpty();
     NotesMigration migration = injector.getInstance(NotesMigration.class);
     if (hasPatchSets || !migration.readChanges()) {
       return update;
@@ -111,16 +108,16 @@
 
     // Change doesn't exist yet. NoteDb requires that there be a commit for the
     // first patch set, so create one.
-    GitRepositoryManager repoManager =
-        injector.getInstance(GitRepositoryManager.class);
+    GitRepositoryManager repoManager = injector.getInstance(GitRepositoryManager.class);
     try (Repository repo = repoManager.openRepository(c.getProject())) {
       TestRepository<Repository> tr = new TestRepository<>(repo);
-      PersonIdent ident = user.asIdentifiedUser()
-          .newCommitterIdent(update.getWhen(), TimeZone.getDefault());
-      TestRepository<Repository>.CommitBuilder cb = tr.commit()
-          .author(ident)
-          .committer(ident)
-          .message(firstNonNull(c.getSubject(), "Test change"));
+      PersonIdent ident =
+          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
+      TestRepository<Repository>.CommitBuilder cb =
+          tr.commit()
+              .author(ident)
+              .committer(ident)
+              .message(firstNonNull(c.getSubject(), "Test change"));
       Ref parent = repo.exactRef(c.getDest().get());
       if (parent != null) {
         cb.parent(tr.getRevWalk().parseCommit(parent.getObjectId()));
@@ -133,8 +130,7 @@
   }
 
   private static ChangeControl stubChangeControl(
-      AbstractChangeNotes.Args args,
-      Change c, CurrentUser user) throws OrmException {
+      AbstractChangeNotes.Args args, Change c, CurrentUser user) throws OrmException {
     ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
     expect(ctl.getChange()).andStubReturn(c);
     expect(ctl.getProject()).andStubReturn(new Project(c.getProject()));
@@ -148,8 +144,8 @@
 
   public static void incrementPatchSet(Change change) {
     PatchSet.Id curr = change.currentPatchSetId();
-    PatchSetInfo ps = new PatchSetInfo(new PatchSet.Id(
-        change.getId(), curr != null ? curr.get() + 1 : 1));
+    PatchSetInfo ps =
+        new PatchSetInfo(new PatchSet.Id(change.getId(), curr != null ? curr.get() + 1 : 1));
     ps.setSubject("Change subject");
     change.setCurrentPatchSet(ps);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
index 2f9d67f..e6a72fc 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.testutil;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.inject.Singleton;
 
@@ -22,6 +25,8 @@
 public class TestNotesMigration extends NotesMigration {
   private volatile boolean readChanges;
   private volatile boolean writeChanges;
+  private volatile PrimaryStorage changePrimaryStorage = PrimaryStorage.REVIEW_DB;
+  private volatile boolean disableChangeReviewDb;
   private volatile boolean failOnLoad;
 
   @Override
@@ -36,6 +41,16 @@
     return readChanges;
   }
 
+  @Override
+  public PrimaryStorage changePrimaryStorage() {
+    return changePrimaryStorage;
+  }
+
+  @Override
+  public boolean disableChangeReviewDb() {
+    return disableChangeReviewDb;
+  }
+
   // Increase visbility from superclass, as tests may want to check whether
   // NoteDb data is written in specific migration scenarios.
   @Override
@@ -44,16 +59,6 @@
   }
 
   @Override
-  public boolean readAccounts() {
-    return false;
-  }
-
-  @Override
-  public boolean writeAccounts() {
-    return false;
-  }
-
-  @Override
   public boolean failOnLoad() {
     return failOnLoad;
   }
@@ -68,6 +73,16 @@
     return this;
   }
 
+  public TestNotesMigration setChangePrimaryStorage(PrimaryStorage changePrimaryStorage) {
+    this.changePrimaryStorage = checkNotNull(changePrimaryStorage);
+    return this;
+  }
+
+  public TestNotesMigration setDisableChangeReviewDb(boolean disableChangeReviewDb) {
+    this.disableChangeReviewDb = disableChangeReviewDb;
+    return this;
+  }
+
   public TestNotesMigration setFailOnLoad(boolean failOnLoad) {
     this.failOnLoad = failOnLoad;
     return this;
@@ -82,16 +97,34 @@
       case READ_WRITE:
         setWriteChanges(true);
         setReadChanges(true);
+        setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
+        setDisableChangeReviewDb(false);
         break;
       case WRITE:
         setWriteChanges(true);
         setReadChanges(false);
+        setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
+        setDisableChangeReviewDb(false);
+        break;
+      case PRIMARY:
+        setWriteChanges(true);
+        setReadChanges(true);
+        setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
+        setDisableChangeReviewDb(false);
+        break;
+      case DISABLE_CHANGE_REVIEW_DB:
+        setWriteChanges(true);
+        setReadChanges(true);
+        setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
+        setDisableChangeReviewDb(true);
         break;
       case CHECK:
       case OFF:
       default:
         setWriteChanges(false);
         setReadChanges(false);
+        setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
+        setDisableChangeReviewDb(false);
         break;
     }
     return this;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
index eae5ed9..7921204 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestTimeUtil.java
@@ -17,6 +17,9 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+import java.sql.Timestamp;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -26,28 +29,25 @@
 import org.joda.time.DateTimeUtils.MillisProvider;
 import org.joda.time.DateTimeZone;
 
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-
 /** Static utility methods for dealing with dates and times in tests. */
 public class TestTimeUtil {
+  public static final DateTime START =
+      new DateTime(2009, 9, 30, 17, 0, 0, DateTimeZone.forOffsetHours(-4));
+
   private static Long clockStepMs;
   private static AtomicLong clockMs;
 
   /**
    * Reset the clock to a known start point, then set the clock step.
-   * <p>
-   * The clock is initially set to 2009/09/30 17:00:00 -0400.
+   *
+   * <p>The clock is initially set to 2009/09/30 17:00:00 -0400.
    *
    * @param clockStep amount to increment clock by on each lookup.
    * @param clockStepUnit time unit for {@code clockStep}.
    */
-  public static synchronized void resetWithClockStep(
-      long clockStep, TimeUnit clockStepUnit) {
+  public static synchronized void resetWithClockStep(long clockStep, TimeUnit clockStepUnit) {
     // Set an arbitrary start point so tests are more repeatable.
-    clockMs = new AtomicLong(
-        new DateTime(2009, 9, 30, 17, 0, 0, DateTimeZone.forOffsetHours(-4))
-            .getMillis());
+    clockMs = new AtomicLong(START.getMillis());
     setClockStep(clockStep, clockStepUnit);
   }
 
@@ -57,69 +57,91 @@
    * @param clockStep amount to increment clock by on each lookup.
    * @param clockStepUnit time unit for {@code clockStep}.
    */
-  public static synchronized void setClockStep(
-      long clockStep, TimeUnit clockStepUnit) {
+  public static synchronized void setClockStep(long clockStep, TimeUnit clockStepUnit) {
     checkState(clockMs != null, "call resetWithClockStep first");
     clockStepMs = MILLISECONDS.convert(clockStep, clockStepUnit);
-    DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
-      @Override
-      public long getMillis() {
-        return clockMs.getAndAdd(clockStepMs);
-      }
-    });
+
+    DateTimeUtils.setCurrentMillisProvider(
+        new MillisProvider() {
+          @Override
+          public long getMillis() {
+            return clockMs.getAndAdd(clockStepMs);
+          }
+        });
 
     SystemReader.setInstance(null);
     final SystemReader defaultReader = SystemReader.getInstance();
-    SystemReader r = new SystemReader() {
-      @Override
-      public String getHostname() {
-        return defaultReader.getHostname();
-      }
+    SystemReader r =
+        new SystemReader() {
+          @Override
+          public String getHostname() {
+            return defaultReader.getHostname();
+          }
 
-      @Override
-      public String getenv(String variable) {
-        return defaultReader.getenv(variable);
-      }
+          @Override
+          public String getenv(String variable) {
+            return defaultReader.getenv(variable);
+          }
 
-      @Override
-      public String getProperty(String key) {
-        return defaultReader.getProperty(key);
-      }
+          @Override
+          public String getProperty(String key) {
+            return defaultReader.getProperty(key);
+          }
 
-      @Override
-      public FileBasedConfig openUserConfig(Config parent, FS fs) {
-        return defaultReader.openUserConfig(parent, fs);
-      }
+          @Override
+          public FileBasedConfig openUserConfig(Config parent, FS fs) {
+            return defaultReader.openUserConfig(parent, fs);
+          }
 
-      @Override
-      public FileBasedConfig openSystemConfig(Config parent, FS fs) {
-        return defaultReader.openSystemConfig(parent, fs);
-      }
+          @Override
+          public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+            return defaultReader.openSystemConfig(parent, fs);
+          }
 
-      @Override
-      public long getCurrentTime() {
-        return clockMs.getAndAdd(clockStepMs);
-      }
+          @Override
+          public long getCurrentTime() {
+            return clockMs.getAndAdd(clockStepMs);
+          }
 
-      @Override
-      public int getTimezone(long when) {
-        return defaultReader.getTimezone(when);
-      }
-    };
+          @Override
+          public int getTimezone(long when) {
+            return defaultReader.getTimezone(when);
+          }
+        };
     SystemReader.setInstance(r);
   }
 
   /**
+   * Set the clock to a specific timestamp.
+   *
+   * @param ts time to set
+   */
+  public static synchronized void setClock(Timestamp ts) {
+    checkState(clockMs != null, "call resetWithClockStep first");
+    clockMs.set(ts.getTime());
+  }
+
+  /**
+   * Increment the clock once by a given amount.
+   *
+   * @param clockStep amount to increment clock by.
+   * @param clockStepUnit time unit for {@code clockStep}.
+   */
+  public static synchronized void incrementClock(long clockStep, TimeUnit clockStepUnit) {
+    checkState(clockMs != null, "call resetWithClockStep first");
+    clockMs.addAndGet(clockStepUnit.toMillis(clockStep));
+  }
+
+  /**
    * Reset the clock to use the actual system clock.
-   * <p>
-   * As a side effect, resets the {@link SystemReader} to the original default
-   * instance.
+   *
+   * <p>As a side effect, resets the {@link SystemReader} to the original default instance.
    */
   public static synchronized void useSystemTime() {
+    clockMs = null;
     DateTimeUtils.setCurrentMillisSystem();
     SystemReader.setInstance(null);
   }
 
-  private TestTimeUtil() {
-  }
+  private TestTimeUtil() {}
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/log/CollectionAppender.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/log/CollectionAppender.java
deleted file mode 100644
index 05f52c0..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/log/CollectionAppender.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2013 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.testutil.log;
-
-import com.google.common.collect.Lists;
-
-import org.apache.log4j.AppenderSkeleton;
-import org.apache.log4j.spi.LoggingEvent;
-
-import java.util.Collection;
-import java.util.LinkedList;
-
-/**
- * Log4j appender that logs into a list
- */
-public class CollectionAppender extends AppenderSkeleton {
-  private Collection<LoggingEvent> events;
-
-  public CollectionAppender() {
-    events = new LinkedList<>();
-  }
-
-  public CollectionAppender(Collection<LoggingEvent> events) {
-    this.events = events;
-  }
-
-  @Override
-  public boolean requiresLayout() {
-    return false;
-  }
-
-  @Override
-  protected void append(LoggingEvent event) {
-    if (! events.add(event)) {
-      throw new RuntimeException("Could not append event " + event);
-    }
-  }
-
-  @Override
-  public void close() {
-  }
-
-  public Collection<LoggingEvent> getLoggedEvents() {
-    return Lists.newLinkedList(events);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/log/LogUtil.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/log/LogUtil.java
deleted file mode 100644
index e3c83ca..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/log/LogUtil.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2013 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.testutil.log;
-
-import org.apache.log4j.Appender;
-import org.apache.log4j.LogManager;
-import org.apache.log4j.Logger;
-import org.apache.log4j.spi.LoggingEvent;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Enumeration;
-import java.util.List;
-
-public class LogUtil {
-  /**
-   * Change logger's setting so it only logs to a collection.
-   *
-   * @param logName Name of the logger to modify.
-   * @param collection The collection to log into.
-   * @return The logger's original settings.
-   */
-  public static LoggerSettings logToCollection(String logName,
-      Collection<LoggingEvent> collection) {
-    Logger logger = LogManager.getLogger(logName);
-    LoggerSettings loggerSettings = new LoggerSettings(logger);
-    logger.removeAllAppenders();
-    logger.setAdditivity(false);
-    CollectionAppender listAppender = new CollectionAppender(collection);
-    logger.addAppender(listAppender);
-    return loggerSettings;
-  }
-
-  /**
-   * Capsule for a logger's settings that get mangled by rerouting logging to a collection
-   */
-  public static class LoggerSettings {
-    private final boolean additive;
-    private final List<Appender> appenders;
-
-    /**
-     * Read off logger settings from an instance.
-     *
-     * @param logger The logger to read the settings off from.
-     */
-    private LoggerSettings(Logger logger) {
-      this.additive = logger.getAdditivity();
-
-      Enumeration<?> appenders = logger.getAllAppenders();
-      this.appenders = new ArrayList<>();
-      while (appenders.hasMoreElements()) {
-        Object appender = appenders.nextElement();
-        if (appender instanceof Appender) {
-          this.appenders.add((Appender)appender);
-        } else {
-          throw new RuntimeException("getAllAppenders of " + logger
-              + " contained an object that is not an Appender");
-        }
-      }
-    }
-
-    /**
-     * Pushes this settings back onto a logger.
-     *
-     * @param logger the logger on which to push the settings.
-     */
-    public void pushOntoLogger(Logger logger) {
-      logger.setAdditivity(additive);
-
-      logger.removeAllAppenders();
-      for (Appender appender : appenders) {
-        logger.addAppender(appender);
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/test/resources/com/google/gerrit/server/mail/tlds-alpha-by-domain.txt b/gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt
similarity index 100%
rename from gerrit-server/src/test/resources/com/google/gerrit/server/mail/tlds-alpha-by-domain.txt
rename to gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt
diff --git a/gerrit-sshd/BUCK b/gerrit-sshd/BUCK
deleted file mode 100644
index 54b83e2..0000000
--- a/gerrit-sshd/BUCK
+++ /dev/null
@@ -1,60 +0,0 @@
-SRCS = glob(['src/main/java/**/*.java'])
-
-java_library(
-  name = 'sshd',
-  srcs = SRCS,
-  deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-cache-h2:cache-h2',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-lucene:lucene',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-util-cli:cli',
-    '//lib:args4j',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib/auto:auto-value',
-    '//lib/commons:codec',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',  # SSH should not depend on servlet
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
-    '//lib/log:api',
-    '//lib/log:log4j',
-    '//lib/mina:core',
-    '//lib/mina:sshd',
-  ],
-  provided_deps = [
-    '//lib/bouncycastle:bcprov',
-    '//lib:servlet-api-3_1',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_sources(
-  name = 'sshd-src',
-  srcs = SRCS,
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'sshd_tests',
-  srcs = glob(
-    ['src/test/java/**/*.java'],
-  ),
-  deps = [
-    ':sshd',
-    '//gerrit-extension-api:api',
-    '//gerrit-server:server',
-    '//lib:truth',
-    '//lib/mina:sshd',
-  ],
-  source_under_test = [':sshd'],
-)
diff --git a/gerrit-sshd/BUILD b/gerrit-sshd/BUILD
index be49c73..904fbba 100644
--- a/gerrit-sshd/BUILD
+++ b/gerrit-sshd/BUILD
@@ -1,53 +1,56 @@
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
-SRCS = glob(['src/main/java/**/*.java'])
+SRCS = glob(["src/main/java/**/*.java"])
 
 java_library(
-  name = 'sshd',
-  srcs = SRCS,
-  deps = [
-    '//gerrit-extension-api:api',
-    '//gerrit-cache-h2:cache-h2',
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//gerrit-lucene:lucene',
-    '//gerrit-patch-jgit:server',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-util-cli:cli',
-    '//lib:args4j',
-    '//lib:gson',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib:jsch',
-    '//lib:servlet-api-3_1',
-    '//lib/auto:auto-value',
-    '//lib/bouncycastle:bcprov',
-    '//lib/commons:codec',
-    '//lib/dropwizard:dropwizard-core',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-    '//lib/guice:guice-servlet',  # SSH should not depend on servlet
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
-    '//lib/log:api',
-    '//lib/log:log4j',
-    '//lib/mina:core',
-    '//lib/mina:sshd',
-  ],
-  visibility = ['//visibility:public'],
+    name = "sshd",
+    srcs = SRCS,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-cache-h2:cache-h2",
+        "//gerrit-cache-mem:mem",
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-lucene:lucene",
+        "//gerrit-patch-jgit:server",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//gerrit-util-cli:cli",
+        "//lib:args4j",
+        "//lib:gson",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:jsch",
+        "//lib:servlet-api-3_1",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/commons:codec",
+        "//lib/dropwizard:dropwizard-core",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:guice-servlet",  # SSH should not depend on servlet
+        "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+        "//lib/log:log4j",
+        "//lib/mina:core",
+        "//lib/mina:sshd",
+    ],
 )
 
 junit_tests(
-  name = 'sshd_tests',
-  srcs = glob(
-    ['src/test/java/**/*.java'],
-  ),
-  deps = [
-    ':sshd',
-    '//gerrit-extension-api:api',
-    '//gerrit-server:server',
-    '//lib:truth',
-    '//lib/mina:sshd',
-  ],
+    name = "sshd_tests",
+    srcs = glob(
+        ["src/test/java/**/*.java"],
+    ),
+    deps = [
+        ":sshd",
+        "//gerrit-extension-api:api",
+        "//gerrit-server:server",
+        "//lib:truth",
+        "//lib/mina:sshd",
+    ],
 )
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 90ffebd..cf76dcb 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -21,35 +21,27 @@
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
-
+import java.io.IOException;
 import org.apache.sshd.server.Environment;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Argument;
 
-import java.io.IOException;
-
 public abstract class AbstractGitCommand extends BaseCommand {
   @Argument(index = 0, metaVar = "PROJECT.git", required = true, usage = "project name")
   protected ProjectControl projectControl;
 
-  @Inject
-  private SshScope sshScope;
+  @Inject private SshScope sshScope;
 
-  @Inject
-  private GitRepositoryManager repoManager;
+  @Inject private GitRepositoryManager repoManager;
 
-  @Inject
-  private SshSession session;
+  @Inject private SshSession session;
 
-  @Inject
-  private SshScope.Context context;
+  @Inject private SshScope.Context context;
 
-  @Inject
-  private IdentifiedUser user;
+  @Inject private IdentifiedUser user;
 
-  @Inject
-  private IdentifiedUser.GenericFactory userFactory;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
 
   protected Repository repo;
   protected Project project;
@@ -59,32 +51,36 @@
     Context ctx = context.subContext(newSession(), context.getCommandLine());
     final Context old = sshScope.set(ctx);
     try {
-      startThread(new ProjectCommandRunnable() {
-        @Override
-        public void executeParseCommand() throws Exception {
-          parseCommandLine();
-        }
+      startThread(
+          new ProjectCommandRunnable() {
+            @Override
+            public void executeParseCommand() throws Exception {
+              parseCommandLine();
+            }
 
-        @Override
-        public void run() throws Exception {
-          AbstractGitCommand.this.service();
-        }
+            @Override
+            public void run() throws Exception {
+              AbstractGitCommand.this.service();
+            }
 
-        @Override
-        public Project.NameKey getProjectName() {
-          Project project = projectControl.getProjectState().getProject();
-          return project.getNameKey();
-        }
-      });
+            @Override
+            public Project.NameKey getProjectName() {
+              Project project = projectControl.getProjectState().getProject();
+              return project.getNameKey();
+            }
+          },
+          AccessPath.GIT);
     } finally {
       sshScope.set(old);
     }
   }
 
   private SshSession newSession() {
-    SshSession n = new SshSession(session, session.getRemoteAddress(),
-        userFactory.create(session.getRemoteAddress(), user.getAccountId()));
-    n.setAccessPath(AccessPath.GIT);
+    SshSession n =
+        new SshSession(
+            session,
+            session.getRemoteAddress(),
+            userFactory.create(session.getRemoteAddress(), user.getAccountId()));
     return n;
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AdminHighPriorityCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AdminHighPriorityCommand.java
index 04f6eb8..d5ce462 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AdminHighPriorityCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AdminHighPriorityCommand.java
@@ -21,11 +21,11 @@
 import java.lang.annotation.Target;
 
 /**
- * Annotation tagged on a concrete Command that requires
- * high priority thread creation whenever called by administrators users.
+ * Annotation tagged on a concrete Command that requires high priority thread creation whenever
+ * called by administrators users.
+ *
  * <p>
  */
-@Target( {ElementType.TYPE})
+@Target({ElementType.TYPE})
 @Retention(RUNTIME)
-public @interface AdminHighPriorityCommand {
-}
+public @interface AdminHighPriorityCommand {}
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 fde3a66..45835d9 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
@@ -19,14 +19,12 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.CapabilityControl;
-
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.Environment;
-
 import java.io.IOException;
 import java.util.LinkedList;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
 
 /** Command that executes some other command. */
 public class AliasCommand extends BaseCommand {
@@ -35,8 +33,10 @@
   private final CommandName command;
   private final AtomicReference<Command> atomicCmd;
 
-  AliasCommand(@CommandName(Commands.ROOT) DispatchCommandProvider root,
-      CurrentUser currentUser, CommandName command) {
+  AliasCommand(
+      @CommandName(Commands.ROOT) DispatchCommandProvider root,
+      CurrentUser currentUser,
+      CommandName command) {
     this.root = root;
     this.currentUser = currentUser;
     this.command = command;
@@ -81,7 +81,7 @@
     Command cmd = p.getProvider().get();
     checkRequiresCapability(cmd);
     if (cmd instanceof BaseCommand) {
-      BaseCommand bc = (BaseCommand)cmd;
+      BaseCommand bc = (BaseCommand) cmd;
       bc.setName(getName());
       bc.setArguments(getArguments());
     }
@@ -97,7 +97,7 @@
       try {
         cmd.destroy();
       } catch (Exception e) {
-        Throwables.propagateIfPossible(e);
+        Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
       }
     }
@@ -108,9 +108,10 @@
     if (rc != null) {
       CapabilityControl ctl = currentUser.getCapabilities();
       if (!ctl.canPerform(rc.value()) && !ctl.canAdministrateServer()) {
-        String msg = String.format(
-            "fatal: %s does not have \"%s\" capability.",
-            currentUser.getUserName(), rc.value());
+        String msg =
+            String.format(
+                "fatal: %s does not have \"%s\" capability.",
+                currentUser.getUserName(), rc.value());
         throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, msg);
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
index 432844c..10beb40 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import org.apache.sshd.server.Command;
 
 /** Resolves an alias to another command. */
@@ -28,8 +27,7 @@
   @CommandName(Commands.ROOT)
   private DispatchCommandProvider root;
 
-  @Inject
-  private CurrentUser currentUser;
+  @Inject private CurrentUser currentUser;
 
   public AliasCommandProvider(CommandName command) {
     this.command = command;
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 25fb7a7..7092fb0 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
@@ -16,11 +16,13 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Joiner;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestCleanup;
@@ -33,17 +35,6 @@
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.EndOfOptionsHandler;
 import com.google.inject.Inject;
-
-import org.apache.sshd.common.SshException;
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.Environment;
-import org.apache.sshd.server.ExitCallback;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
@@ -55,6 +46,15 @@
 import java.nio.charset.Charset;
 import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicReference;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public abstract class BaseCommand implements Command {
   private static final Logger log = LoggerFactory.getLogger(BaseCommand.class);
@@ -74,24 +74,17 @@
 
   private ExitCallback exit;
 
-  @Inject
-  private SshScope sshScope;
+  @Inject private SshScope sshScope;
 
-  @Inject
-  private CmdLineParser.Factory cmdLineParserFactory;
+  @Inject private CmdLineParser.Factory cmdLineParserFactory;
 
-  @Inject
-  private RequestCleanup cleanup;
+  @Inject private RequestCleanup cleanup;
 
-  @Inject
-  @CommandExecutor
-  private WorkQueue.Executor executor;
+  @Inject @CommandExecutor private WorkQueue.Executor executor;
 
-  @Inject
-  private CurrentUser user;
+  @Inject private CurrentUser user;
 
-  @Inject
-  private SshScope.Context context;
+  @Inject private SshScope.Context context;
 
   /** Commands declared by a plugin can be scoped by the plugin name. */
   @Inject(optional = true)
@@ -107,6 +100,9 @@
   /** Unparsed command line options. */
   private String[] argv;
 
+  /** trimmed command line arguments. */
+  private String[] trimmedArgv;
+
   public BaseCommand() {
     task = Atomics.newReference();
   }
@@ -152,6 +148,26 @@
     this.argv = argv;
   }
 
+  /**
+   * Trim the argument if it is spanning multiple lines.
+   *
+   * @return the arguments where all the multiple-line fields are trimmed.
+   */
+  protected String[] getTrimmedArguments() {
+    if (trimmedArgv == null && argv != null) {
+      trimmedArgv = new String[argv.length];
+      for (int i = 0; i < argv.length; i++) {
+        String arg = argv[i];
+        int indexOfMultiLine = arg.indexOf("\n");
+        if (indexOfMultiLine > -1) {
+          arg = arg.substring(0, indexOfMultiLine).concat(" [trimmed]");
+        }
+        trimmedArgv[i] = arg;
+      }
+    }
+    return trimmedArgv;
+  }
+
   @Override
   public void destroy() {
     Future<?> future = task.getAndSet(null);
@@ -162,10 +178,10 @@
 
   /**
    * Pass all state into the command, then run its start method.
-   * <p>
-   * This method copies all critical state, like the input and output streams,
-   * into the supplied command. The caller must still invoke {@code cmd.start()}
-   * if wants to pass control to the command.
+   *
+   * <p>This method copies all critical state, like the input and output streams, into the supplied
+   * command. The caller must still invoke {@code cmd.start()} if wants to pass control to the
+   * command.
    *
    * @param cmd the command that will receive the current state.
    */
@@ -178,8 +194,8 @@
 
   /**
    * Parses the command line argument, injecting parsed values into fields.
-   * <p>
-   * This method must be explicitly invoked to cause a parse.
+   *
+   * <p>This method must be explicitly invoked to cause a parse.
    *
    * @throws UnloggedFailure if the command line arguments were invalid.
    * @see Option
@@ -191,11 +207,11 @@
 
   /**
    * Parses the command line argument, injecting parsed values into fields.
-   * <p>
-   * This method must be explicitly invoked to cause a parse.
    *
-   * @param options object whose fields declare Option and Argument annotations
-   *        to describe the parameters of the command. Usually {@code this}.
+   * <p>This method must be explicitly invoked to cause a parse.
+   *
+   * @param options object whose fields declare Option and Argument annotations to describe the
+   *     parameters of the command. Usually {@code this}.
    * @throws UnloggedFailure if the command line arguments were invalid.
    * @see Option
    * @see Argument
@@ -229,9 +245,8 @@
 
   /**
    * Spawn a function into its own thread.
-   * <p>
-   * Typically this should be invoked within {@link Command#start(Environment)},
-   * such as:
+   *
+   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
    *
    * <pre>
    * startThread(new Runnable() {
@@ -241,23 +256,24 @@
    * });
    * </pre>
    *
-   * @param thunk the runnable to execute on the thread, performing the
-   *        command's logic.
+   * @param thunk the runnable to execute on the thread, performing the command's logic.
+   * @param accessPath the path used by the end user for running the SSH command
    */
-  protected void startThread(final Runnable thunk) {
-    startThread(new CommandRunnable() {
-      @Override
-      public void run() throws Exception {
-        thunk.run();
-      }
-    });
+  protected void startThread(final Runnable thunk, AccessPath accessPath) {
+    startThread(
+        new CommandRunnable() {
+          @Override
+          public void run() throws Exception {
+            thunk.run();
+          }
+        },
+        accessPath);
   }
 
   /**
    * Spawn a function into its own thread.
-   * <p>
-   * Typically this should be invoked within {@link Command#start(Environment)},
-   * such as:
+   *
+   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
    *
    * <pre>
    * startThread(new CommandRunnable() {
@@ -266,18 +282,17 @@
    *   }
    * });
    * </pre>
-   * <p>
-   * If the function throws an exception, it is translated to a simple message
-   * for the client, a non-zero exit code, and the stack trace is logged.
    *
-   * @param thunk the runnable to execute on the thread, performing the
-   *        command's logic.
+   * <p>If the function throws an exception, it is translated to a simple message for the client, a
+   * non-zero exit code, and the stack trace is logged.
+   *
+   * @param thunk the runnable to execute on the thread, performing the command's logic.
+   * @param accessPath the path used by the end user for running the SSH command
    */
-  protected void startThread(final CommandRunnable thunk) {
-    final TaskThunk tt = new TaskThunk(thunk);
+  protected void startThread(final CommandRunnable thunk, AccessPath accessPath) {
+    final TaskThunk tt = new TaskThunk(thunk, accessPath);
 
-    if (isAdminHighPriorityCommand()
-        && user.getCapabilities().canAdministrateServer()) {
+    if (isAdminHighPriorityCommand() && user.getCapabilities().canAdministrateServer()) {
       // Admin commands should not block the main work threads (there
       // might be an interactive shell there), nor should they wait
       // for the main work threads.
@@ -294,10 +309,10 @@
 
   /**
    * Terminate this command and return a result code to the remote client.
-   * <p>
-   * Commands should invoke this at most once. Once invoked, the command may
-   * lose access to request based resources as any callbacks previously
-   * registered with {@link RequestCleanup} will fire.
+   *
+   * <p>Commands should invoke this at most once. Once invoked, the command may lose access to
+   * request based resources as any callbacks previously registered with {@link RequestCleanup} will
+   * fire.
    *
    * @param rc exit code for the remote client.
    */
@@ -314,11 +329,9 @@
   }
 
   private int handleError(final Throwable e) {
-    if ((e.getClass() == IOException.class
-         && "Pipe closed".equals(e.getMessage()))
+    if ((e.getClass() == IOException.class && "Pipe closed".equals(e.getMessage()))
         || //
-        (e.getClass() == SshException.class
-         && "Already closed".equals(e.getMessage()))
+        (e.getClass() == SshException.class && "Already closed".equals(e.getMessage()))
         || //
         e.getClass() == InterruptedIOException.class) {
       // This is sshd telling us the client just dropped off while
@@ -355,7 +368,6 @@
         log.warn("Cannot send failure message to client", e2);
       }
       return f.exitCode;
-
     }
 
     try {
@@ -385,29 +397,34 @@
     }
   }
 
-  public void checkExclusivity(final Object arg1, final String arg1name,
-      final Object arg2, final String arg2name) throws UnloggedFailure {
-    if (arg1 != null && arg2 != null) {
-      throw new UnloggedFailure(String.format(
-          "%s and %s options are mutually exclusive.", arg1name, arg2name));
+  protected String getTaskDescription() {
+    String[] ta = getTrimmedArguments();
+    if (ta != null) {
+      return commandName + " " + Joiner.on(" ").join(ta);
     }
+    return commandName;
+  }
+
+  private String getTaskName() {
+    StringBuilder m = new StringBuilder();
+    m.append(getTaskDescription());
+    if (user.isIdentifiedUser()) {
+      IdentifiedUser u = user.asIdentifiedUser();
+      m.append(" (").append(u.getAccount().getUserName()).append(")");
+    }
+    return m.toString();
   }
 
   private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
     private final CommandRunnable thunk;
     private final String taskName;
+    private final AccessPath accessPath;
     private Project.NameKey projectName;
 
-    private TaskThunk(final CommandRunnable thunk) {
+    private TaskThunk(final CommandRunnable thunk, AccessPath accessPath) {
       this.thunk = thunk;
-
-      StringBuilder m = new StringBuilder();
-      m.append(context.getCommandLine());
-      if (user.isIdentifiedUser()) {
-        IdentifiedUser u = user.asIdentifiedUser();
-        m.append(" (").append(u.getAccount().getUserName()).append(")");
-      }
-      this.taskName = m.toString();
+      this.taskName = getTaskName();
+      this.accessPath = accessPath;
     }
 
     @Override
@@ -428,6 +445,7 @@
         final Thread thisThread = Thread.currentThread();
         final String thisName = thisThread.getName();
         int rc = 0;
+        context.getSession().setAccessPath(accessPath);
         final Context old = sshScope.set(context);
         try {
           context.started = TimeUtil.nowMs();
@@ -515,9 +533,8 @@
     /**
      * Create a new failure.
      *
-     * @param exitCode exit code to return the client, which indicates the
-     *        failure status of this command. Should be between 1 and 255,
-     *        inclusive.
+     * @param exitCode exit code to return the client, which indicates the failure status of this
+     *     command. Should be between 1 and 255, inclusive.
      * @param msg message to also send to the client's stderr.
      */
     public Failure(final int exitCode, final String msg) {
@@ -527,12 +544,11 @@
     /**
      * Create a new failure.
      *
-     * @param exitCode exit code to return the client, which indicates the
-     *        failure status of this command. Should be between 1 and 255,
-     *        inclusive.
+     * @param exitCode exit code to return the client, which indicates the failure status of this
+     *     command. Should be between 1 and 255, inclusive.
      * @param msg message to also send to the client's stderr.
-     * @param why stack trace to include in the server's log, but is not sent to
-     *        the client's stderr.
+     * @param why stack trace to include in the server's log, but is not sent to the client's
+     *     stderr.
      */
     public Failure(final int exitCode, final String msg, final Throwable why) {
       super(msg, why);
@@ -556,9 +572,8 @@
     /**
      * Create a new failure.
      *
-     * @param exitCode exit code to return the client, which indicates the
-     *        failure status of this command. Should be between 1 and 255,
-     *        inclusive.
+     * @param exitCode exit code to return the client, which indicates the failure status of this
+     *     command. Should be between 1 and 255, inclusive.
      * @param msg message to also send to the client's stderr.
      */
     public UnloggedFailure(final int exitCode, final String msg) {
@@ -568,15 +583,13 @@
     /**
      * Create a new failure.
      *
-     * @param exitCode exit code to return the client, which indicates the
-     *        failure status of this command. Should be between 1 and 255,
-     *        inclusive.
+     * @param exitCode exit code to return the client, which indicates the failure status of this
+     *     command. Should be between 1 and 255, inclusive.
      * @param msg message to also send to the client's stderr.
-     * @param why stack trace to include in the server's log, but is not sent to
-     *        the client's stderr.
+     * @param why stack trace to include in the server's log, but is not sent to the client's
+     *     stderr.
      */
-    public UnloggedFailure(final int exitCode, final String msg,
-        final Throwable why) {
+    public UnloggedFailure(final int exitCode, final String msg, final Throwable why) {
       super(exitCode, msg, why);
     }
   }
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
index 7986d76..51370c8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.common.base.Function;
-import com.google.common.base.Predicates;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -31,11 +30,11 @@
 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.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 public class ChangeArgumentParser {
   private final CurrentUser currentUser;
@@ -46,7 +45,8 @@
   private final ChangeControl.GenericFactory changeControlFactory;
 
   @Inject
-  ChangeArgumentParser(CurrentUser currentUser,
+  ChangeArgumentParser(
+      CurrentUser currentUser,
       ChangesCollection changesCollection,
       ChangeFinder changeFinder,
       ReviewDb db,
@@ -65,18 +65,20 @@
     addChange(id, changes, null);
   }
 
-  public void addChange(String id, Map<Change.Id, ChangeResource> changes,
-      ProjectControl projectControl) throws UnloggedFailure, OrmException {
+  public void addChange(
+      String id, Map<Change.Id, ChangeResource> changes, ProjectControl projectControl)
+      throws UnloggedFailure, OrmException {
     addChange(id, changes, projectControl, true);
   }
 
-  public void addChange(String id, Map<Change.Id, ChangeResource> changes,
-      ProjectControl projectControl, boolean useIndex) throws UnloggedFailure,
-      OrmException {
+  public void addChange(
+      String id,
+      Map<Change.Id, ChangeResource> changes,
+      ProjectControl projectControl,
+      boolean useIndex)
+      throws UnloggedFailure, OrmException {
     List<ChangeControl> matched =
-        useIndex ?
-            changeFinder.find(id, currentUser) :
-            changeFromNotesFactory(id, currentUser);
+        useIndex ? changeFinder.find(id, currentUser) : changeFromNotesFactory(id, currentUser);
     List<ChangeControl> toAdd = new ArrayList<>(changes.size());
     boolean canMaintainServer =
         currentUser.isIdentifiedUser()
@@ -98,17 +100,13 @@
     changes.put(ctl.getId(), changesCollection.parse(ctl));
   }
 
-  private List<ChangeControl> changeFromNotesFactory(String id,
-      final CurrentUser currentUser) throws OrmException, UnloggedFailure {
-    List<ChangeNotes> changes =
-        changeNotesFactory.create(db, parseId(id));
-    return FluentIterable.from(changes)
-        .transform(new Function<ChangeNotes, ChangeControl>() {
-          @Override
-          public ChangeControl apply(ChangeNotes changeNote) {
-            return controlForChange(changeNote, currentUser);
-          }
-        }).filter(Predicates.notNull()).toList();
+  private List<ChangeControl> changeFromNotesFactory(String id, CurrentUser currentUser)
+      throws OrmException, UnloggedFailure {
+    return changeNotesFactory.create(db, parseId(id)).stream()
+        .map(changeNote -> controlForChange(changeNote, currentUser))
+        .filter(changeControl -> changeControl.isPresent())
+        .map(changeControl -> changeControl.get())
+        .collect(toList());
   }
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
@@ -119,11 +117,11 @@
     }
   }
 
-  private ChangeControl controlForChange(ChangeNotes change, CurrentUser user) {
+  private Optional<ChangeControl> controlForChange(ChangeNotes change, CurrentUser user) {
     try {
-      return changeControlFactory.controlFor(change, user);
+      return Optional.of(changeControlFactory.controlFor(change, user));
     } catch (NoSuchChangeException e) {
-      return null;
+      return Optional.empty();
     }
   }
 
@@ -135,4 +133,4 @@
     // 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/CommandExecutor.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java
index 1b50d88..fa21c58 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java
@@ -18,11 +18,9 @@
 
 import com.google.gerrit.server.git.WorkQueue.Executor;
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /** Marker on {@link Executor} used by SSH threads. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface CommandExecutor {
-}
+public @interface CommandExecutor {}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java
index 5c6f80a..8c47144 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java
@@ -26,8 +26,7 @@
   private final CurrentUser user;
 
   @Inject
-  CommandExecutorProvider(QueueProvider queues,
-      CurrentUser user) {
+  CommandExecutorProvider(QueueProvider queues, CurrentUser user) {
     this.queues = queues;
     this.user = user;
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
index 88e1142..d5670f7 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
@@ -19,11 +19,11 @@
 import com.google.gerrit.server.git.QueueProvider;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
-
+import com.google.inject.Singleton;
+import java.util.concurrent.ThreadFactory;
 import org.eclipse.jgit.lib.Config;
 
-import java.util.concurrent.ThreadFactory;
-
+@Singleton
 public class CommandExecutorQueueProvider implements QueueProvider {
 
   private int poolSize;
@@ -37,34 +37,33 @@
       ThreadSettingsConfig threadsSettingsConfig,
       WorkQueue queues) {
     poolSize = threadsSettingsConfig.getSshdThreads();
-    batchThreads = config.getInt("sshd", "batchThreads",
-        threadsSettingsConfig.getSshdBatchTreads());
+    batchThreads =
+        config.getInt("sshd", "batchThreads", threadsSettingsConfig.getSshdBatchTreads());
     if (batchThreads > poolSize) {
       poolSize += batchThreads;
     }
     int interactiveThreads = Math.max(1, poolSize - batchThreads);
-    interactiveExecutor = queues.createQueue(interactiveThreads,
-        "SSH-Interactive-Worker");
-    if (batchThreads !=  0) {
-      batchExecutor = queues.createQueue(batchThreads, "SSH-Batch-Worker");
+    interactiveExecutor = queues.createQueue(interactiveThreads, "SSH-Interactive-Worker", true);
+    if (batchThreads != 0) {
+      batchExecutor = queues.createQueue(batchThreads, "SSH-Batch-Worker", true);
       setThreadFactory(batchExecutor);
     } else {
       batchExecutor = interactiveExecutor;
     }
     setThreadFactory(interactiveExecutor);
-
   }
 
   private void setThreadFactory(WorkQueue.Executor executor) {
     final ThreadFactory parent = executor.getThreadFactory();
-    executor.setThreadFactory(new ThreadFactory() {
-      @Override
-      public Thread newThread(final Runnable task) {
-        final Thread t = parent.newThread(task);
-        t.setPriority(Thread.MIN_PRIORITY);
-        return t;
-      }
-    });
+    executor.setThreadFactory(
+        new ThreadFactory() {
+          @Override
+          public Thread newThread(final Runnable task) {
+            final Thread t = parent.newThread(task);
+            t.setPriority(Thread.MIN_PRIORITY);
+            return t;
+          }
+        });
   }
 
   @Override
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index ee4984b79..501c0f6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -17,6 +17,7 @@
 import com.google.common.util.concurrent.Atomics;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
@@ -25,17 +26,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.CommandFactory;
-import org.apache.sshd.server.Environment;
-import org.apache.sshd.server.ExitCallback;
-import org.apache.sshd.server.SessionAware;
-import org.apache.sshd.server.session.ServerSession;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -47,15 +37,20 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.CommandFactory;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+import org.apache.sshd.server.SessionAware;
+import org.apache.sshd.server.session.ServerSession;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-/**
- * Creates a CommandFactory using commands registered by {@link CommandModule}.
- */
+/** Creates a CommandFactory using commands registered by {@link CommandModule}. */
 @Singleton
-class CommandFactoryProvider implements Provider<CommandFactory>,
-    LifecycleListener {
-  private static final Logger logger = LoggerFactory
-      .getLogger(CommandFactoryProvider.class);
+class CommandFactoryProvider implements Provider<CommandFactory>, LifecycleListener {
+  private static final Logger logger = LoggerFactory.getLogger(CommandFactoryProvider.class);
 
   private final DispatchCommandProvider dispatcher;
   private final SshLog log;
@@ -63,29 +58,35 @@
   private final ScheduledExecutorService startExecutor;
   private final ExecutorService destroyExecutor;
   private final SchemaFactory<ReviewDb> schemaFactory;
+  private final DynamicItem<SshCreateCommandInterceptor> createCommandInterceptor;
 
   @Inject
   CommandFactoryProvider(
       @CommandName(Commands.ROOT) final DispatchCommandProvider d,
-      @GerritServerConfig final Config cfg, final WorkQueue workQueue,
-      final SshLog l, final SshScope s, SchemaFactory<ReviewDb> sf) {
+      @GerritServerConfig final Config cfg,
+      final WorkQueue workQueue,
+      final SshLog l,
+      final SshScope s,
+      SchemaFactory<ReviewDb> sf,
+      DynamicItem<SshCreateCommandInterceptor> i) {
     dispatcher = d;
     log = l;
     sshScope = s;
     schemaFactory = sf;
+    createCommandInterceptor = i;
 
-    int threads = cfg.getInt("sshd","commandStartThreads", 2);
-    startExecutor = workQueue.createQueue(threads, "SshCommandStart");
-    destroyExecutor = Executors.newSingleThreadExecutor(
-        new ThreadFactoryBuilder()
-          .setNameFormat("SshCommandDestroy-%s")
-          .setDaemon(true)
-          .build());
+    int threads = cfg.getInt("sshd", "commandStartThreads", 2);
+    startExecutor = workQueue.createQueue(threads, "SshCommandStart", true);
+    destroyExecutor =
+        Executors.newSingleThreadExecutor(
+            new ThreadFactoryBuilder()
+                .setNameFormat("SshCommandDestroy-%s")
+                .setDaemon(true)
+                .build());
   }
 
   @Override
-  public void start() {
-  }
+  public void start() {}
 
   @Override
   public void stop() {
@@ -97,7 +98,12 @@
     return new CommandFactory() {
       @Override
       public Command createCommand(final String requestCommand) {
-        return new Trampoline(requestCommand);
+        String c = requestCommand;
+        SshCreateCommandInterceptor interceptor = createCommandInterceptor.get();
+        if (interceptor != null) {
+          c = interceptor.intercept(c);
+        }
+        return new Trampoline(c);
       }
     };
   }
@@ -152,22 +158,28 @@
     public void start(final Environment env) throws IOException {
       this.env = env;
       final Context ctx = this.ctx;
-      task.set(startExecutor.submit(new Runnable() {
-        @Override
-        public void run() {
-          try {
-            onStart();
-          } catch (Exception e) {
-            logger.warn("Cannot start command \"" + ctx.getCommandLine()
-                + "\" for user " + ctx.getSession().getUsername(), e);
-          }
-        }
+      task.set(
+          startExecutor.submit(
+              new Runnable() {
+                @Override
+                public void run() {
+                  try {
+                    onStart();
+                  } catch (Exception e) {
+                    logger.warn(
+                        "Cannot start command \""
+                            + ctx.getCommandLine()
+                            + "\" for user "
+                            + ctx.getSession().getUsername(),
+                        e);
+                  }
+                }
 
-        @Override
-        public String toString() {
-          return "start (user " + ctx.getSession().getUsername() + ")";
-        }
-      }));
+                @Override
+                public String toString() {
+                  return "start (user " + ctx.getSession().getUsername() + ")";
+                }
+              }));
     }
 
     private void onStart() throws IOException {
@@ -179,19 +191,20 @@
           cmd.setInputStream(in);
           cmd.setOutputStream(out);
           cmd.setErrorStream(err);
-          cmd.setExitCallback(new ExitCallback() {
-            @Override
-            public void onExit(int rc, String exitMessage) {
-              exit.onExit(translateExit(rc), exitMessage);
-              log(rc);
-            }
+          cmd.setExitCallback(
+              new ExitCallback() {
+                @Override
+                public void onExit(int rc, String exitMessage) {
+                  exit.onExit(translateExit(rc), exitMessage);
+                  log(rc);
+                }
 
-            @Override
-            public void onExit(int rc) {
-              exit.onExit(translateExit(rc));
-              log(rc);
-            }
-          });
+                @Override
+                public void onExit(int rc) {
+                  exit.onExit(translateExit(rc));
+                  log(rc);
+                }
+              });
           cmd.start(env);
         } finally {
           sshScope.set(old);
@@ -226,12 +239,13 @@
       Future<?> future = task.getAndSet(null);
       if (future != null) {
         future.cancel(true);
-        destroyExecutor.execute(new Runnable() {
-          @Override
-          public void run() {
-            onDestroy();
-          }
-        });
+        destroyExecutor.execute(
+            new Runnable() {
+              @Override
+              public void run() {
+                onDestroy();
+              }
+            });
       }
     }
 
@@ -258,7 +272,7 @@
     boolean inquote = false;
     boolean inDblQuote = false;
     StringBuilder r = new StringBuilder();
-    for (int ip = 0; ip < commandLine.length();) {
+    for (int ip = 0; ip < commandLine.length(); ) {
       final char b = commandLine.charAt(ip++);
       switch (b) {
         case '\t':
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java
index 5b8f5fa..12d0890 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandMetaData.java
@@ -21,19 +21,24 @@
 import java.lang.annotation.Target;
 
 /**
- * Annotation tagged on a concrete Command to describe what it is doing and
- * whether it can be run on slaves.
+ * Annotation tagged on a concrete Command to describe what it is doing and whether it can be run on
+ * slaves.
  */
 @Target({ElementType.TYPE})
 @Retention(RUNTIME)
 public @interface CommandMetaData {
   enum Mode {
-    MASTER, MASTER_OR_SLAVE;
+    MASTER,
+    MASTER_OR_SLAVE;
+
     public boolean isSupported(boolean slaveMode) {
       return this == MASTER_OR_SLAVE || !slaveMode;
     }
   }
+
   String name();
+
   String description() default "";
+
   Mode runsAt() default Mode.MASTER;
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java
index 1e409d2..54ffba6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.inject.binder.LinkedBindingBuilder;
-
 import org.apache.sshd.server.Command;
 
 /** Module to register commands in the SSH daemon. */
@@ -26,10 +25,8 @@
   /**
    * Configure a command to be invoked by name.
    *
-   * @param name the name of the command the client will provide in order to
-   *        call the command.
-   * @return a binding that must be bound to a non-singleton provider for a
-   *         {@link Command} object.
+   * @param name the name of the command the client will provide in order to call the command.
+   * @return a binding that must be bound to a non-singleton provider for a {@link Command} object.
    */
   protected LinkedBindingBuilder<Command> command(final String name) {
     return bind(Commands.key(name));
@@ -38,10 +35,8 @@
   /**
    * Configure a command to be invoked by name.
    *
-   * @param name the name of the command the client will provide in order to
-   *        call the command.
-   * @return a binding that must be bound to a non-singleton provider for a
-   *         {@link Command} object.
+   * @param name the name of the command the client will provide in order to call the command.
+   * @return a binding that must be bound to a non-singleton provider for a {@link Command} object.
    */
   protected LinkedBindingBuilder<Command> command(final CommandName name) {
     return bind(Commands.key(name));
@@ -50,28 +45,22 @@
   /**
    * Configure a command to be invoked by name.
    *
-   * @param parent context of the parent command, that this command is a
-   *        subcommand of.
-   * @param name the name of the command the client will provide in order to
-   *        call the command.
-   * @return a binding that must be bound to a non-singleton provider for a
-   *         {@link Command} object.
+   * @param parent context of the parent command, that this command is a subcommand of.
+   * @param name the name of the command the client will provide in order to call the command.
+   * @return a binding that must be bound to a non-singleton provider for a {@link Command} object.
    */
-  protected LinkedBindingBuilder<Command> command(final CommandName parent,
-      final String name) {
+  protected LinkedBindingBuilder<Command> command(final CommandName parent, final String name) {
     return bind(Commands.key(parent, name));
   }
 
   /**
    * Configure a command to be invoked by name. The command is bound to the passed class.
    *
-   * @param parent context of the parent command, that this command is a
-   *        subcommand of.
-   * @param clazz class of the command with {@link CommandMetaData} annotation
-   *        to retrieve the name and the description from
+   * @param parent context of the parent command, that this command is a subcommand of.
+   * @param clazz class of the command with {@link CommandMetaData} annotation to retrieve the name
+   *     and the description from
    */
-  protected void command(final CommandName parent,
-      final Class<? extends BaseCommand> clazz) {
+  protected void command(final CommandName parent, final Class<? extends BaseCommand> clazz) {
     CommandMetaData meta = clazz.getAnnotation(CommandMetaData.class);
     if (meta == null) {
       throw new IllegalStateException("no CommandMetaData annotation found");
@@ -84,15 +73,13 @@
   /**
    * Alias one command to another. The alias is bound to the passed class.
    *
-   * @param parent context of the parent command, that this command is a
-   *        subcommand of.
-   * @param name the name of the command the client will provide in order to
-   *        call the command.
-   * @param clazz class of the command with {@link CommandMetaData} annotation
-   *        to retrieve the description from
+   * @param parent context of the parent command, that this command is a subcommand of.
+   * @param name the name of the command the client will provide in order to call the command.
+   * @param clazz class of the command with {@link CommandMetaData} annotation to retrieve the
+   *     description from
    */
-  protected void alias(final CommandName parent, final String name,
-      final Class<? extends BaseCommand> clazz) {
+  protected void alias(
+      final CommandName parent, final String name, final Class<? extends BaseCommand> clazz) {
     CommandMetaData meta = clazz.getAnnotation(CommandMetaData.class);
     if (meta == null) {
       throw new IllegalStateException("no CommandMetaData annotation found");
@@ -103,10 +90,10 @@
   /**
    * Alias one command to another.
    *
-   * @param from the new command name that when called will actually delegate to
-   *        {@code to}'s implementation.
-   * @param to name of an already registered command that will perform the
-   *        action when {@code from} is invoked by a client.
+   * @param from the new command name that when called will actually delegate to {@code to}'s
+   *     implementation.
+   * @param to name of an already registered command that will perform the action when {@code from}
+   *     is invoked by a client.
    */
   protected void alias(final String from, final String to) {
     bind(Commands.key(from)).to(Commands.key(to));
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandName.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandName.java
index 09503bb..8461831 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandName.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandName.java
@@ -17,13 +17,12 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /**
  * Name of a command registered in an SSH daemon.
- * <p>
- * Use {@link Commands#key(String)} to construct a key for a command name.
+ *
+ * <p>Use {@link Commands#key(String)} to construct a key for a command name.
  *
  * @see CommandModule#command(String)
  * @see Commands#key(String)
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java
index 9a7c97b..200d3a0 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.sshd;
 
 import com.google.inject.Provider;
-
 import org.apache.sshd.server.Command;
 
 final class CommandProvider {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java
index e964819..620ffbe 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java
@@ -16,10 +16,8 @@
 
 import com.google.auto.value.AutoAnnotation;
 import com.google.inject.Key;
-
-import org.apache.sshd.server.Command;
-
 import java.lang.annotation.Annotation;
+import org.apache.sshd.server.Command;
 
 /** Utilities to support {@link CommandName} construction. */
 public class Commands {
@@ -37,13 +35,11 @@
     return Key.get(Command.class, name);
   }
 
-  public static Key<Command> key(final CommandName parent,
-      final String name) {
+  public static Key<Command> key(final CommandName parent, final String name) {
     return Key.get(Command.class, named(parent, name));
   }
 
-  public static Key<Command> key(final CommandName parent,
-      final String name, final String descr) {
+  public static Key<Command> key(final CommandName parent, final String name, final String descr) {
     return Key.get(Command.class, named(parent, name, descr));
   }
 
@@ -59,8 +55,7 @@
   }
 
   /** Create a CommandName annotation for the supplied name and description. */
-  public static CommandName named(final CommandName parent, final String name,
-      final String descr) {
+  public static CommandName named(final CommandName parent, final String name, final String descr) {
     return new NestedCommandNameImpl(parent, name, descr);
   }
 
@@ -101,8 +96,7 @@
       this.descr = "";
     }
 
-    NestedCommandNameImpl(final CommandName parent, final String name,
-        final String descr) {
+    NestedCommandNameImpl(final CommandName parent, final String name, final String descr) {
       this.parent = parent;
       this.name = name;
       this.descr = descr;
@@ -140,6 +134,5 @@
     }
   }
 
-  private Commands() {
-  }
+  private Commands() {}
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 3adc8d1..d655500 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -25,17 +25,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
-
-import org.apache.commons.codec.binary.Base64;
-import org.apache.sshd.common.SshException;
-import org.apache.sshd.common.keyprovider.KeyPairProvider;
-import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
-import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
-import org.apache.sshd.server.session.ServerSession;
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -48,13 +37,19 @@
 import java.util.HashSet;
 import java.util.Locale;
 import java.util.Set;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
+import org.apache.sshd.server.session.ServerSession;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-/**
- * Authenticates by public key through {@link AccountSshKey} entities.
- */
+/** Authenticates by public key through {@link AccountSshKey} entities. */
 class DatabasePubKeyAuth implements PublickeyAuthenticator {
-  private static final Logger log =
-      LoggerFactory.getLogger(DatabasePubKeyAuth.class);
+  private static final Logger log = LoggerFactory.getLogger(DatabasePubKeyAuth.class);
 
   private final SshKeyCacheImpl sshKeyCache;
   private final SshLog sshLog;
@@ -66,10 +61,15 @@
   private volatile PeerKeyCache peerKeyCache;
 
   @Inject
-  DatabasePubKeyAuth(final SshKeyCacheImpl skc, final SshLog l,
-      final IdentifiedUser.GenericFactory uf, final PeerDaemonUser.Factory pf,
-      final SitePaths site, final KeyPairProvider hostKeyProvider,
-      @GerritServerConfig final Config cfg, final SshScope s) {
+  DatabasePubKeyAuth(
+      final SshKeyCacheImpl skc,
+      final SshLog l,
+      final IdentifiedUser.GenericFactory uf,
+      final PeerDaemonUser.Factory pf,
+      final SitePaths site,
+      final KeyPairProvider hostKeyProvider,
+      @GerritServerConfig final Config cfg,
+      final SshScope s) {
     sshKeyCache = skc;
     sshLog = l;
     userFactory = uf;
@@ -81,14 +81,18 @@
   }
 
   private static Set<PublicKey> myHostKeys(KeyPairProvider p) {
-    final Set<PublicKey> keys = new HashSet<>(2);
+    final Set<PublicKey> keys = new HashSet<>(6);
+    addPublicKey(keys, p, KeyPairProvider.SSH_ED25519);
+    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP256);
+    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP384);
+    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP521);
     addPublicKey(keys, p, KeyPairProvider.SSH_RSA);
     addPublicKey(keys, p, KeyPairProvider.SSH_DSS);
     return keys;
   }
 
-  private static void addPublicKey(final Collection<PublicKey> out,
-      final KeyPairProvider p, final String type) {
+  private static void addPublicKey(
+      final Collection<PublicKey> out, final KeyPairProvider p, final String type) {
     final KeyPair pair = p.loadKey(type);
     if (pair != null && pair.getPublic() != null) {
       out.add(pair.getPublic());
@@ -96,16 +100,13 @@
   }
 
   @Override
-  public boolean authenticate(String username, PublicKey suppliedKey,
-      ServerSession session) {
+  public boolean authenticate(String username, PublicKey suppliedKey, ServerSession session) {
     SshSession sd = session.getAttribute(SshSession.KEY);
     Preconditions.checkState(sd.getUser() == null);
     if (PeerDaemonUser.USER_NAME.equals(username)) {
-      if (myHostKeys.contains(suppliedKey)
-          || getPeerKeys().contains(suppliedKey)) {
+      if (myHostKeys.contains(suppliedKey) || getPeerKeys().contains(suppliedKey)) {
         PeerDaemonUser user = peerFactory.create(sd.getRemoteAddress());
         return SshUtil.success(username, session, sshScope, sshLog, sd, user);
-
       }
       sd.authenticationError(username, "no-matching-key");
       return false;
@@ -161,8 +162,8 @@
     return p.keys;
   }
 
-  private SshKeyCacheEntry find(final Iterable<SshKeyCacheEntry> keyList,
-      final PublicKey suppliedKey) {
+  private SshKeyCacheEntry find(
+      final Iterable<SshKeyCacheEntry> keyList, final PublicKey suppliedKey) {
     for (final SshKeyCacheEntry k : keyList) {
       if (k.match(suppliedKey)) {
         return k;
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 f2911dc..2f3d10f6 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
@@ -24,21 +24,17 @@
 import com.google.gerrit.server.args4j.SubcommandHandler;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Argument;
-
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Argument;
 
-/**
- * Command that dispatches to a subcommand from its command table.
- */
+/** Command that dispatches to a subcommand from its command table. */
 final class DispatchCommand extends BaseCommand {
   interface Factory {
     DispatchCommand create(Map<String, CommandProvider> map);
@@ -55,8 +51,7 @@
   private List<String> args = new ArrayList<>();
 
   @Inject
-  DispatchCommand(CurrentUser cu,
-      @Assisted final Map<String, CommandProvider> all) {
+  DispatchCommand(CurrentUser cu, @Assisted final Map<String, CommandProvider> all) {
     currentUser = cu;
     commands = all;
     atomicCmd = Atomics.newReference();
@@ -79,8 +74,10 @@
       final CommandProvider p = commands.get(commandName);
       if (p == null) {
         String msg =
-            (getName().isEmpty() ? "Gerrit Code Review" : getName()) + ": "
-                + commandName + ": not found";
+            (getName().isEmpty() ? "Gerrit Code Review" : getName())
+                + ": "
+                + commandName
+                + ": not found";
         throw die(msg);
       }
 
@@ -114,18 +111,15 @@
     }
   }
 
-  private void checkRequiresCapability(Command cmd)
-      throws UnloggedFailure {
+  private void checkRequiresCapability(Command cmd) throws UnloggedFailure {
     String pluginName = null;
     if (cmd instanceof BaseCommand) {
       pluginName = ((BaseCommand) cmd).getPluginName();
     }
     try {
-      CapabilityUtils.checkRequiresCapability(currentUser,
-          pluginName, cmd.getClass());
+      CapabilityUtils.checkRequiresCapability(currentUser, pluginName, cmd.getClass());
     } catch (AuthException e) {
-      throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN,
-          e.getMessage());
+      throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, e.getMessage());
     }
   }
 
@@ -136,7 +130,7 @@
       try {
         cmd.destroy();
       } catch (Exception e) {
-        Throwables.propagateIfPossible(e);
+        Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
       }
     }
@@ -161,8 +155,7 @@
     for (String name : Sets.newTreeSet(commands.keySet())) {
       final CommandProvider p = commands.get(name);
       usage.append("   ");
-      usage.append(String.format(format, name,
-          Strings.nullToEmpty(p.getDescription())));
+      usage.append(String.format(format, name, Strings.nullToEmpty(p.getDescription())));
       usage.append("\n");
     }
     usage.append("\n");
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java
index ce1da95..2a88f63 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java
@@ -21,22 +21,16 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
-
-import org.apache.sshd.server.Command;
-
 import java.lang.annotation.Annotation;
 import java.util.List;
 import java.util.concurrent.ConcurrentMap;
+import org.apache.sshd.server.Command;
 
-/**
- * Creates DispatchCommand using commands registered by {@link CommandModule}.
- */
+/** Creates DispatchCommand using commands registered by {@link CommandModule}. */
 public class DispatchCommandProvider implements Provider<DispatchCommand> {
-  @Inject
-  private Injector injector;
+  @Inject private Injector injector;
 
-  @Inject
-  private DispatchCommand.Factory factory;
+  @Inject private DispatchCommand.Factory factory;
 
   private final CommandName parent;
   private volatile ConcurrentMap<String, CommandProvider> map;
@@ -50,8 +44,7 @@
     return factory.create(getMap());
   }
 
-  public RegistrationHandle register(final CommandName name,
-      final Provider<Command> cmd) {
+  public RegistrationHandle register(final CommandName name, final Provider<Command> cmd) {
     final ConcurrentMap<String, CommandProvider> m = getMap();
     final CommandProvider commandProvider = new CommandProvider(cmd, null);
     if (m.putIfAbsent(name.value(), commandProvider) != null) {
@@ -65,8 +58,7 @@
     };
   }
 
-  public RegistrationHandle replace(final CommandName name,
-      final Provider<Command> cmd) {
+  public RegistrationHandle replace(final CommandName name, final Provider<Command> cmd) {
     final ConcurrentMap<String, CommandProvider> m = getMap();
     final CommandProvider commandProvider = new CommandProvider(cmd, null);
     m.put(name.value(), commandProvider);
@@ -99,20 +91,17 @@
         if (!Commands.CMD_ROOT.equals(n) && Commands.isChild(parent, n)) {
           String descr = null;
           if (annotation instanceof Commands.NestedCommandNameImpl) {
-            Commands.NestedCommandNameImpl impl =
-                ((Commands.NestedCommandNameImpl) annotation);
+            Commands.NestedCommandNameImpl impl = ((Commands.NestedCommandNameImpl) annotation);
             descr = impl.descr();
           }
-          m.put(n.value(),
-              new CommandProvider((Provider<Command>) b.getProvider(), descr));
+          m.put(n.value(), new CommandProvider((Provider<Command>) b.getProvider(), descr));
         }
       }
     }
     return m;
   }
 
-  private static final TypeLiteral<Command> type =
-      new TypeLiteral<Command>() {};
+  private static final TypeLiteral<Command> type = new TypeLiteral<Command>() {};
 
   private List<Binding<Command>> allCommands() {
     return injector.findBindingsByType(type);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
index b331555..0b1f3ae 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
@@ -22,16 +22,12 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
+import java.util.Locale;
 import org.apache.sshd.server.auth.gss.GSSAuthenticator;
 import org.apache.sshd.server.session.ServerSession;
 import org.eclipse.jgit.lib.Config;
 
-import java.util.Locale;
-
-/**
- * Authenticates users with kerberos (gssapi-with-mic).
- */
+/** Authenticates users with kerberos (gssapi-with-mic). */
 @Singleton
 class GerritGSSAuthenticator extends GSSAuthenticator {
   private final AccountCache accounts;
@@ -41,7 +37,8 @@
   private final Config config;
 
   @Inject
-  GerritGSSAuthenticator(AccountCache accounts,
+  GerritGSSAuthenticator(
+      AccountCache accounts,
       SshScope sshScope,
       SshLog sshLog,
       IdentifiedUser.GenericFactory userFactory,
@@ -54,8 +51,7 @@
   }
 
   @Override
-  public boolean validateIdentity(final ServerSession session,
-      final String identity) {
+  public boolean validateIdentity(final ServerSession session, final String identity) {
     final SshSession sd = session.getAttribute(SshSession.KEY);
     int at = identity.indexOf('@');
     String username;
@@ -71,7 +67,12 @@
     Account account = state == null ? null : state.getAccount();
     boolean active = account != null && account.isActive();
     if (active) {
-      return SshUtil.success(username, session, sshScope, sshLog, sd,
+      return SshUtil.success(
+          username,
+          session,
+          sshScope,
+          sshLog,
+          sd,
           SshUtil.createUser(sd, userFactory, account.getId()));
     }
     return false;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
index 09fa42c..20694b2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -18,17 +18,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
-
-import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
-import org.apache.sshd.common.keyprovider.KeyPairProvider;
-import org.apache.sshd.common.util.security.SecurityUtils;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-
 import java.io.File;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 
 class HostKeyProvider implements Provider<KeyPairProvider> {
   private final SitePaths site;
@@ -43,36 +40,46 @@
     Path objKey = site.ssh_key;
     Path rsaKey = site.ssh_rsa;
     Path dsaKey = site.ssh_dsa;
+    Path ecdsaKey_256 = site.ssh_ecdsa_256;
+    Path ecdsaKey_384 = site.ssh_ecdsa_384;
+    Path ecdsaKey_521 = site.ssh_ecdsa_521;
+    Path ed25519Key = site.ssh_ed25519;
 
-    final List<File> stdKeys = new ArrayList<>(2);
+    final List<File> stdKeys = new ArrayList<>(6);
     if (Files.exists(rsaKey)) {
       stdKeys.add(rsaKey.toAbsolutePath().toFile());
     }
     if (Files.exists(dsaKey)) {
       stdKeys.add(dsaKey.toAbsolutePath().toFile());
     }
+    if (Files.exists(ecdsaKey_256)) {
+      stdKeys.add(ecdsaKey_256.toAbsolutePath().toFile());
+    }
+    if (Files.exists(ecdsaKey_384)) {
+      stdKeys.add(ecdsaKey_384.toAbsolutePath().toFile());
+    }
+    if (Files.exists(ecdsaKey_521)) {
+      stdKeys.add(ecdsaKey_521.toAbsolutePath().toFile());
+    }
+    if (Files.exists(ed25519Key)) {
+      stdKeys.add(ed25519Key.toAbsolutePath().toFile());
+    }
 
     if (Files.exists(objKey)) {
       if (stdKeys.isEmpty()) {
         SimpleGeneratorHostKeyProvider p = new SimpleGeneratorHostKeyProvider();
         p.setPath(objKey.toAbsolutePath());
         return p;
-
       }
       // Both formats of host key exist, we don't know which format
       // should be authoritative. Complain and abort.
       //
       stdKeys.add(objKey.toAbsolutePath().toFile());
       throw new ProvisionException("Multiple host keys exist: " + stdKeys);
-
     }
     if (stdKeys.isEmpty()) {
       throw new ProvisionException("No SSH keys under " + site.etc_dir);
     }
-    if (!SecurityUtils.isBouncyCastleRegistered()) {
-      throw new ProvisionException("Bouncy Castle Crypto not installed;"
-          + " needed to read server host keys: " + stdKeys + "");
-    }
     FileKeyPairProvider kp = new FileKeyPairProvider();
     kp.setFiles(stdKeys);
     return kp;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java
index 0207ede..eafdcd6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java
@@ -23,7 +23,11 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
 import org.apache.sshd.common.Factory;
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
@@ -33,18 +37,11 @@
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.SystemReader;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-
 /**
  * Dummy shell which prints a message and terminates.
- * <p>
- * This implementation is used to ensure clients who try to SSH directly to this
- * server without supplying a command will get a reasonable error message, but
- * cannot continue further.
+ *
+ * <p>This implementation is used to ensure clients who try to SSH directly to this server without
+ * supplying a command will get a reasonable error message, but cannot continue further.
  */
 class NoShell implements Factory<Command> {
   private final Provider<SendMessage> shell;
@@ -71,8 +68,8 @@
     private Context context;
 
     @Inject
-    SendMessage(Provider<MessageFactory> messageFactory,
-        SchemaFactory<ReviewDb> sf, SshScope sshScope) {
+    SendMessage(
+        Provider<MessageFactory> messageFactory, SchemaFactory<ReviewDb> sf, SshScope sshScope) {
       this.messageFactory = messageFactory;
       this.schemaFactory = sf;
       this.sshScope = sshScope;
@@ -123,8 +120,7 @@
     }
 
     @Override
-    public void destroy() {
-    }
+    public void destroy() {}
   }
 
   static class MessageFactory {
@@ -133,8 +129,8 @@
     private final Provider<String> urlProvider;
 
     @Inject
-    MessageFactory(IdentifiedUser user, SshInfo sshInfo,
-        @CanonicalWebUrl Provider<String> urlProvider) {
+    MessageFactory(
+        IdentifiedUser user, SshInfo sshInfo, @CanonicalWebUrl Provider<String> urlProvider) {
       this.user = user;
       this.sshInfo = sshInfo;
       this.urlProvider = urlProvider;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java
index b5378f1..fe8197d 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.inject.Inject;
 import com.google.inject.binder.LinkedBindingBuilder;
-
 import org.apache.sshd.server.Command;
 
 public abstract class PluginCommandModule extends CommandModule {
@@ -50,5 +49,4 @@
   protected void alias(final String name, Class<? extends BaseCommand> clazz) {
     alias(command, name, clazz);
   }
-
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SingleCommandPluginModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
index 14911b5..079661a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
@@ -18,13 +18,12 @@
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.inject.Inject;
 import com.google.inject.binder.LinkedBindingBuilder;
-
 import org.apache.sshd.server.Command;
 
 /**
  * Binds one SSH command to the plugin name itself.
- * <p>
- * Cannot be combined with {@link PluginCommandModule}.
+ *
+ * <p>Cannot be combined with {@link PluginCommandModule}.
  */
 public abstract class SingleCommandPluginModule extends CommandModule {
   private CommandName command;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
index 8b468a7..0fdde81 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
@@ -18,31 +18,26 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.server.plugins.InvalidPluginException;
 import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import com.google.inject.TypeLiteral;
-
-import org.apache.sshd.server.Command;
-
 import java.lang.annotation.Annotation;
 import java.util.HashMap;
 import java.util.Map;
+import org.apache.sshd.server.Command;
 
-class SshAutoRegisterModuleGenerator
-    extends AbstractModule
-    implements ModuleGenerator {
+class SshAutoRegisterModuleGenerator extends AbstractModule implements ModuleGenerator {
   private final Map<String, Class<Command>> commands = new HashMap<>();
-  private final Multimap<TypeLiteral<?>, Class<?>> listeners = LinkedListMultimap.create();
+  private final ListMultimap<TypeLiteral<?>, Class<?>> listeners = LinkedListMultimap.create();
   private CommandName command;
 
   @Override
   protected void configure() {
-    bind(Commands.key(command))
-        .toProvider(new DispatchCommandProvider(command));
+    bind(Commands.key(command)).toProvider(new DispatchCommandProvider(command));
     for (Map.Entry<String, Class<Command>> e : commands.entrySet()) {
       bind(Commands.key(command, e.getKey())).to(e.getValue());
     }
@@ -65,26 +60,25 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public void export(Export export, Class<?> type)
-      throws InvalidPluginException {
+  public void export(Export export, Class<?> type) throws InvalidPluginException {
     Preconditions.checkState(command != null, "pluginName must be provided");
     if (Command.class.isAssignableFrom(type)) {
       Class<Command> old = commands.get(export.value());
       if (old != null) {
-        throw new InvalidPluginException(String.format(
-            "@Export(\"%s\") has duplicate bindings:\n  %s\n  %s",
-            export.value(), old.getName(), type.getName()));
+        throw new InvalidPluginException(
+            String.format(
+                "@Export(\"%s\") has duplicate bindings:\n  %s\n  %s",
+                export.value(), old.getName(), type.getName()));
       }
       commands.put(export.value(), (Class<Command>) type);
     } else {
-      throw new InvalidPluginException(String.format(
-          "Class %s with @Export(\"%s\") must extend %s or implement %s",
-          type.getName(), export.value(),
-          SshCommand.class.getName(), Command.class.getName()));
+      throw new InvalidPluginException(
+          String.format(
+              "Class %s with @Export(\"%s\") must extend %s or implement %s",
+              type.getName(), export.value(), SshCommand.class.getName(), Command.class.getName()));
     }
   }
 
-
   @Override
   public void listen(TypeLiteral<?> tl, Class<?> clazz) {
     listeners.put(tl, clazz);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCommand.java
index f6209ba..b4e44d3 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.sshd;
 
-import org.apache.sshd.server.Environment;
-
+import com.google.gerrit.server.AccessPath;
 import java.io.IOException;
 import java.io.PrintWriter;
+import org.apache.sshd.server.Environment;
 
 public abstract class SshCommand extends BaseCommand {
   protected PrintWriter stdout;
@@ -25,20 +25,22 @@
 
   @Override
   public void start(Environment env) throws IOException {
-    startThread(new CommandRunnable() {
-      @Override
-      public void run() throws Exception {
-        parseCommandLine();
-        stdout = toPrintWriter(out);
-        stderr = toPrintWriter(err);
-        try {
-          SshCommand.this.run();
-        } finally {
-          stdout.flush();
-          stderr.flush();
-        }
-      }
-    });
+    startThread(
+        new CommandRunnable() {
+          @Override
+          public void run() throws Exception {
+            parseCommandLine();
+            stdout = toPrintWriter(out);
+            stderr = toPrintWriter(err);
+            try {
+              SshCommand.this.run();
+            } finally {
+              stdout.flush();
+              stderr.flush();
+            }
+          }
+        },
+        AccessPath.SSH_COMMAND);
   }
 
   protected abstract void run() throws UnloggedFailure, Failure, Exception;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCreateCommandInterceptor.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCreateCommandInterceptor.java
new file mode 100644
index 0000000..d3ed12b
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshCreateCommandInterceptor.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+public interface SshCreateCommandInterceptor {
+
+  /**
+   * Intrcept SSH command creation
+   *
+   * @param in command name passed in to command instance creation machinery
+   * @return intercepted command name
+   */
+  String intercept(String in);
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
index bd121ee..5964bd4 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
@@ -36,10 +36,33 @@
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-
 import com.jcraft.jsch.HostKey;
 import com.jcraft.jsch.JSchException;
-
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.net.UnknownHostException;
+import java.nio.file.FileStore;
+import java.nio.file.FileSystem;
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.nio.file.WatchService;
+import java.nio.file.attribute.UserPrincipalLookupService;
+import java.nio.file.spi.FileSystemProvider;
+import java.security.InvalidKeyException;
+import java.security.KeyPair;
+import java.security.PublicKey;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.mina.transport.socket.SocketSessionConfig;
 import org.apache.sshd.common.BaseBuilder;
 import org.apache.sshd.common.NamedFactory;
@@ -51,7 +74,9 @@
 import org.apache.sshd.common.forward.DefaultTcpipForwarderFactory;
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.future.SshFutureListener;
+import org.apache.sshd.common.io.AbstractIoServiceFactory;
 import org.apache.sshd.common.io.IoAcceptor;
+import org.apache.sshd.common.io.IoServiceFactory;
 import org.apache.sshd.common.io.IoServiceFactoryFactory;
 import org.apache.sshd.common.io.IoSession;
 import org.apache.sshd.common.io.mina.MinaServiceFactoryFactory;
@@ -60,7 +85,6 @@
 import org.apache.sshd.common.kex.KeyExchange;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.mac.Mac;
-import org.apache.sshd.common.random.JceRandomFactory;
 import org.apache.sshd.common.random.Random;
 import org.apache.sshd.common.random.SingletonRandomFactory;
 import org.apache.sshd.common.session.ConnectionService;
@@ -91,54 +115,26 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.File;
-import java.io.IOException;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.SocketAddress;
-import java.net.UnknownHostException;
-import java.nio.file.FileStore;
-import java.nio.file.FileSystem;
-import java.nio.file.Path;
-import java.nio.file.PathMatcher;
-import java.nio.file.WatchService;
-import java.nio.file.attribute.UserPrincipalLookupService;
-import java.nio.file.spi.FileSystemProvider;
-import java.security.InvalidKeyException;
-import java.security.KeyPair;
-import java.security.PublicKey;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicInteger;
-
 /**
  * SSH daemon to communicate with Gerrit.
- * <p>
- * Use a Git URL such as <code>ssh://${email}@${host}:${port}/${path}</code>,
- * e.g. {@code ssh://sop@google.com@gerrit.com:8010/tools/gerrit.git} to
- * access the SSH daemon itself.
- * <p>
- * Versions of Git before 1.5.3 may require setting the username and port
- * properties in the user's {@code ~/.ssh/config} file, and using a host
- * alias through a URL such as {@code gerrit-alias:/tools/gerrit.git}:
- * <pre>
- * {@code
+ *
+ * <p>Use a Git URL such as <code>ssh://${email}@${host}:${port}/${path}</code>, e.g. {@code
+ * ssh://sop@google.com@gerrit.com:8010/tools/gerrit.git} to access the SSH daemon itself.
+ *
+ * <p>Versions of Git before 1.5.3 may require setting the username and port properties in the
+ * user's {@code ~/.ssh/config} file, and using a host alias through a URL such as {@code
+ * gerrit-alias:/tools/gerrit.git}:
+ *
+ * <pre>{@code
  * Host gerrit-alias
  *  User sop@google.com
  *  Hostname gerrit.com
  *  Port 8010
- * }
- * </pre>
+ * }</pre>
  */
 @Singleton
 public class SshDaemon extends SshServer implements SshInfo, LifecycleListener {
-  private static final Logger sshDaemonLog =
-      LoggerFactory.getLogger(SshDaemon.class);
+  private static final Logger sshDaemonLog = LoggerFactory.getLogger(SshDaemon.class);
 
   public enum SshSessionBackend {
     MINA,
@@ -153,11 +149,15 @@
   private final Config cfg;
 
   @Inject
-  SshDaemon(final CommandFactory commandFactory, final NoShell noShell,
+  SshDaemon(
+      final CommandFactory commandFactory,
+      final NoShell noShell,
       final PublickeyAuthenticator userAuth,
       final GerritGSSAuthenticator kerberosAuth,
-      final KeyPairProvider hostKeyProvider, final IdGenerator idGenerator,
-      @GerritServerConfig final Config cfg, final SshLog sshLog,
+      final KeyPairProvider hostKeyProvider,
+      final IdGenerator idGenerator,
+      @GerritServerConfig final Config cfg,
+      final SshLog sshLog,
       @SshListenAddresses final List<SocketAddress> listen,
       @SshAdvertisedAddresses final List<String> advertised,
       MetricMaker metricMaker) {
@@ -168,65 +168,61 @@
     this.advertised = advertised;
     keepAlive = cfg.getBoolean("sshd", "tcpkeepalive", true);
 
-    getProperties().put(SERVER_IDENTIFICATION,
-        "GerritCodeReview_" + Version.getVersion() //
-            + " (" + super.getVersion() + ")");
+    getProperties()
+        .put(
+            SERVER_IDENTIFICATION,
+            "GerritCodeReview_"
+                + Version.getVersion() //
+                + " ("
+                + super.getVersion()
+                + ")");
 
-    getProperties().put(MAX_AUTH_REQUESTS,
-        String.valueOf(cfg.getInt("sshd", "maxAuthTries", 6)));
+    getProperties().put(MAX_AUTH_REQUESTS, String.valueOf(cfg.getInt("sshd", "maxAuthTries", 6)));
 
-    getProperties().put(
-        AUTH_TIMEOUT,
-        String.valueOf(MILLISECONDS.convert(ConfigUtil.getTimeUnit(cfg, "sshd",
-            null, "loginGraceTime", 120, SECONDS), SECONDS)));
+    getProperties()
+        .put(
+            AUTH_TIMEOUT,
+            String.valueOf(
+                MILLISECONDS.convert(
+                    ConfigUtil.getTimeUnit(cfg, "sshd", null, "loginGraceTime", 120, SECONDS),
+                    SECONDS)));
 
-    long idleTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null,
-        "idleTimeout", 0, SECONDS);
-    getProperties().put(
-        IDLE_TIMEOUT,
-        String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
+    long idleTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "idleTimeout", 0, SECONDS);
+    getProperties().put(IDLE_TIMEOUT, String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
+    getProperties().put(NIO2_READ_TIMEOUT, String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
 
-    long rekeyTimeLimit = ConfigUtil.getTimeUnit(cfg, "sshd", null,
-        "rekeyTimeLimit", 3600, SECONDS);
-    getProperties().put(
-        REKEY_TIME_LIMIT,
-        String.valueOf(SECONDS.toMillis(rekeyTimeLimit)));
+    long rekeyTimeLimit =
+        ConfigUtil.getTimeUnit(cfg, "sshd", null, "rekeyTimeLimit", 3600, SECONDS);
+    getProperties().put(REKEY_TIME_LIMIT, String.valueOf(SECONDS.toMillis(rekeyTimeLimit)));
 
-    getProperties().put(REKEY_BYTES_LIMIT,
-        String.valueOf(cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */)));
+    getProperties()
+        .put(
+            REKEY_BYTES_LIMIT,
+            String.valueOf(cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */)));
 
     long waitTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "waitTimeout", 30, SECONDS);
     getProperties()
         .put(WAIT_FOR_SPACE_TIMEOUT, String.valueOf(SECONDS.toMillis(waitTimeoutSeconds)));
 
-    final int maxConnectionsPerUser =
-        cfg.getInt("sshd", "maxConnectionsPerUser", 64);
+    final int maxConnectionsPerUser = cfg.getInt("sshd", "maxConnectionsPerUser", 64);
     if (0 < maxConnectionsPerUser) {
-      getProperties().put(MAX_CONCURRENT_SESSIONS,
-          String.valueOf(maxConnectionsPerUser));
+      getProperties().put(MAX_CONCURRENT_SESSIONS, String.valueOf(maxConnectionsPerUser));
     }
 
-    final String kerberosKeytab = cfg.getString(
-        "sshd", null, "kerberosKeytab");
-    final String kerberosPrincipal = cfg.getString(
-        "sshd", null, "kerberosPrincipal");
+    final String kerberosKeytab = cfg.getString("sshd", null, "kerberosKeytab");
+    final String kerberosPrincipal = cfg.getString("sshd", null, "kerberosPrincipal");
 
-    final boolean enableCompression = cfg.getBoolean(
-        "sshd", "enableCompression", false);
+    final boolean enableCompression = cfg.getBoolean("sshd", "enableCompression", false);
 
-    SshSessionBackend backend = cfg.getEnum(
-        "sshd", null, "backend", SshSessionBackend.NIO2);
+    SshSessionBackend backend = cfg.getEnum("sshd", null, "backend", SshSessionBackend.NIO2);
 
-    System.setProperty(IoServiceFactoryFactory.class.getName(),
+    System.setProperty(
+        IoServiceFactoryFactory.class.getName(),
         backend == SshSessionBackend.MINA
             ? MinaServiceFactoryFactory.class.getName()
             : Nio2ServiceFactoryFactory.class.getName());
 
-    if (SecurityUtils.isBouncyCastleRegistered()) {
-      initProviderBouncyCastle(cfg);
-    } else {
-      initProviderJce();
-    }
+    initProviderBouncyCastle(cfg);
     initCiphers(cfg);
     initKeyExchanges(cfg);
     initMacs(cfg);
@@ -245,9 +241,7 @@
     metricMaker.newCallbackMetric(
         "sshd/sessions/connected",
         Integer.class,
-        new Description("Currently connected SSH sessions")
-          .setGauge()
-          .setUnit("sessions"),
+        new Description("Currently connected SSH sessions").setGauge().setUnit("sessions"),
         new Supplier<Integer>() {
           @Override
           public Integer get() {
@@ -255,66 +249,62 @@
           }
         });
 
-    final Counter0 sessionsCreated = metricMaker.newCounter(
-        "sshd/sessions/created",
-        new Description("Rate of new SSH sessions")
-          .setRate()
-          .setUnit("sessions"));
+    final Counter0 sessionsCreated =
+        metricMaker.newCounter(
+            "sshd/sessions/created",
+            new Description("Rate of new SSH sessions").setRate().setUnit("sessions"));
 
-    final Counter0 authFailures = metricMaker.newCounter(
-        "sshd/sessions/authentication_failures",
-        new Description("Rate of SSH authentication failures")
-          .setRate()
-          .setUnit("failures"));
+    final Counter0 authFailures =
+        metricMaker.newCounter(
+            "sshd/sessions/authentication_failures",
+            new Description("Rate of SSH authentication failures").setRate().setUnit("failures"));
 
-    setSessionFactory(new SessionFactory(this) {
-      @Override
-      protected ServerSessionImpl createSession(final IoSession io)
-          throws Exception {
-        connected.incrementAndGet();
-        sessionsCreated.increment();
-        if (io instanceof MinaSession) {
-          if (((MinaSession) io).getSession()
-              .getConfig() instanceof SocketSessionConfig) {
-            ((SocketSessionConfig) ((MinaSession) io).getSession()
-                .getConfig())
-                .setKeepAlive(keepAlive);
-          }
-        }
-
-        ServerSessionImpl s = super.createSession(io);
-        int id = idGenerator.next();
-        SocketAddress peer = io.getRemoteAddress();
-        final SshSession sd = new SshSession(id, peer);
-        s.setAttribute(SshSession.KEY, sd);
-
-        // Log a session close without authentication as a failure.
-        //
-        s.addCloseFutureListener(new SshFutureListener<CloseFuture>() {
+    setSessionFactory(
+        new SessionFactory(this) {
           @Override
-          public void operationComplete(CloseFuture future) {
-            connected.decrementAndGet();
-            if (sd.isAuthenticationError()) {
-              authFailures.increment();
-              sshLog.onAuthFail(sd);
+          protected ServerSessionImpl createSession(final IoSession io) throws Exception {
+            connected.incrementAndGet();
+            sessionsCreated.increment();
+            if (io instanceof MinaSession) {
+              if (((MinaSession) io).getSession().getConfig() instanceof SocketSessionConfig) {
+                ((SocketSessionConfig) ((MinaSession) io).getSession().getConfig())
+                    .setKeepAlive(keepAlive);
+              }
             }
+
+            ServerSessionImpl s = super.createSession(io);
+            int id = idGenerator.next();
+            SocketAddress peer = io.getRemoteAddress();
+            final SshSession sd = new SshSession(id, peer);
+            s.setAttribute(SshSession.KEY, sd);
+
+            // Log a session close without authentication as a failure.
+            //
+            s.addCloseFutureListener(
+                new SshFutureListener<CloseFuture>() {
+                  @Override
+                  public void operationComplete(CloseFuture future) {
+                    connected.decrementAndGet();
+                    if (sd.isAuthenticationError()) {
+                      authFailures.increment();
+                      sshLog.onAuthFail(sd);
+                    }
+                  }
+                });
+            return s;
+          }
+
+          @Override
+          protected ServerSessionImpl doCreateSession(IoSession ioSession) throws Exception {
+            return new ServerSessionImpl(getServer(), ioSession);
           }
         });
-        return s;
-      }
-
-      @Override
-      protected ServerSessionImpl doCreateSession(IoSession ioSession)
-          throws Exception {
-        return new ServerSessionImpl(getServer(), ioSession);
-      }
-    });
-    setGlobalRequestHandlers(Arrays.<RequestHandler<ConnectionService>> asList(
-          new KeepAliveHandler(),
-          new NoMoreSessionsHandler(),
-          new TcpipForwardHandler(),
-          new CancelTcpipForwardHandler()
-        ));
+    setGlobalRequestHandlers(
+        Arrays.<RequestHandler<ConnectionService>>asList(
+            new KeepAliveHandler(),
+            new NoMoreSessionsHandler(),
+            new TcpipForwardHandler(),
+            new CancelTcpipForwardHandler()));
 
     hostKeys = computeHostKeys();
   }
@@ -340,19 +330,17 @@
 
       try {
         String listenAddress = cfg.getString("sshd", null, "listenAddress");
-        boolean rewrite = !Strings.isNullOrEmpty(listenAddress)
-            && listenAddress.endsWith(":0");
+        boolean rewrite = !Strings.isNullOrEmpty(listenAddress) && listenAddress.endsWith(":0");
         daemonAcceptor.bind(listen);
         if (rewrite) {
           SocketAddress bound = Iterables.getOnlyElement(daemonAcceptor.getBoundAddresses());
-          cfg.setString("sshd", null, "listenAddress", format((InetSocketAddress)bound));
+          cfg.setString("sshd", null, "listenAddress", format((InetSocketAddress) bound));
         }
       } catch (IOException e) {
         throw new IllegalStateException("Cannot bind to " + addressList(), e);
       }
 
-      sshDaemonLog.info(String.format("Started Gerrit %s on %s",
-          getVersion(), addressList()));
+      sshDaemonLog.info(String.format("Started Gerrit %s on %s", getVersion(), addressList()));
     }
   }
 
@@ -365,6 +353,7 @@
     if (daemonAcceptor != null) {
       try {
         daemonAcceptor.close(true).await();
+        shutdownExecutors();
         sshDaemonLog.info("Stopped Gerrit SSHD");
       } catch (IOException e) {
         sshDaemonLog.warn("Exception caught while closing", e);
@@ -374,6 +363,25 @@
     }
   }
 
+  private void shutdownExecutors() {
+    if (executor != null) {
+      executor.shutdownNow();
+    }
+
+    IoServiceFactory serviceFactory = getIoServiceFactory();
+    if (serviceFactory instanceof AbstractIoServiceFactory) {
+      shutdownServiceFactoryExecutor((AbstractIoServiceFactory) serviceFactory);
+    }
+  }
+
+  private void shutdownServiceFactoryExecutor(AbstractIoServiceFactory ioServiceFactory) {
+    ioServiceFactory.close(true);
+    ExecutorService serviceFactoryExecutor = ioServiceFactory.getExecutorService();
+    if (serviceFactoryExecutor != null && serviceFactoryExecutor != executor) {
+      serviceFactoryExecutor.shutdownNow();
+    }
+  }
+
   @Override
   protected void checkConfig() {
     super.checkConfig();
@@ -398,7 +406,9 @@
         try {
           r.add(new HostKey(addr, keyBin));
         } catch (JSchException e) {
-          sshDaemonLog.warn("Cannot format SSHD host key", e);
+          sshDaemonLog.warn(
+              String.format(
+                  "Cannot format SSHD host key [%s]: %s", pub.getAlgorithm(), e.getMessage()));
         }
       }
     }
@@ -407,14 +417,18 @@
 
   private List<PublicKey> myHostKeys() {
     final KeyPairProvider p = getKeyPairProvider();
-    final List<PublicKey> keys = new ArrayList<>(2);
+    final List<PublicKey> keys = new ArrayList<>(6);
+    addPublicKey(keys, p, KeyPairProvider.SSH_ED25519);
+    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP256);
+    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP384);
+    addPublicKey(keys, p, KeyPairProvider.ECDSA_SHA2_NISTP521);
     addPublicKey(keys, p, KeyPairProvider.SSH_RSA);
     addPublicKey(keys, p, KeyPairProvider.SSH_DSS);
     return keys;
   }
 
-  private static void addPublicKey(final Collection<PublicKey> out,
-      final KeyPairProvider p, final String type) {
+  private static void addPublicKey(
+      final Collection<PublicKey> out, final KeyPairProvider p, final String type) {
     final KeyPair pair = p.loadKey(type);
     if (pair != null && pair.getPublic() != null) {
       out.add(pair.getPublic());
@@ -423,7 +437,7 @@
 
   private String addressList() {
     final StringBuilder r = new StringBuilder();
-    for (Iterator<SocketAddress> i = listen.iterator(); i.hasNext();) {
+    for (Iterator<SocketAddress> i = listen.iterator(); i.hasNext(); ) {
       r.append(SocketUtil.format(i.next(), IANA_SSH_PORT));
       if (i.hasNext()) {
         r.append(", ");
@@ -434,10 +448,9 @@
 
   @SuppressWarnings("unchecked")
   private void initKeyExchanges(Config cfg) {
-    List<NamedFactory<KeyExchange>> a =
-        ServerBuilder.setUpDefaultKeyExchanges(true);
-    setKeyExchangeFactories(filter(cfg, "kex",
-        (NamedFactory<KeyExchange>[])a.toArray(new NamedFactory[a.size()])));
+    List<NamedFactory<KeyExchange>> a = ServerBuilder.setUpDefaultKeyExchanges(true);
+    setKeyExchangeFactories(
+        filter(cfg, "kex", (NamedFactory<KeyExchange>[]) a.toArray(new NamedFactory<?>[a.size()])));
   }
 
   private void initProviderBouncyCastle(Config cfg) {
@@ -489,7 +502,7 @@
     public int random(int n) {
       if (n > 0) {
         if ((n & -n) == n) {
-          return (int)((n * (long) next(31)) >> 31);
+          return (int) ((n * (long) next(31)) >> 31);
         }
         int bits;
         int val;
@@ -514,15 +527,11 @@
     }
   }
 
-  private void initProviderJce() {
-    setRandomFactory(new SingletonRandomFactory(JceRandomFactory.INSTANCE));
-  }
-
   @SuppressWarnings("unchecked")
   private void initCiphers(final Config cfg) {
     final List<NamedFactory<Cipher>> a = BaseBuilder.setUpDefaultCiphers(true);
 
-    for (Iterator<NamedFactory<Cipher>> i = a.iterator(); i.hasNext();) {
+    for (Iterator<NamedFactory<Cipher>> i = a.iterator(); i.hasNext(); ) {
       final NamedFactory<Cipher> f = i.next();
       try {
         final Cipher c = f.create();
@@ -530,8 +539,12 @@
         final byte[] iv = new byte[c.getIVSize()];
         c.init(Cipher.Mode.Encrypt, key, iv);
       } catch (InvalidKeyException e) {
-        sshDaemonLog.warn("Disabling cipher " + f.getName() + ": " + e.getMessage()
-            + "; try installing unlimited cryptography extension");
+        sshDaemonLog.warn(
+            "Disabling cipher "
+                + f.getName()
+                + ": "
+                + e.getMessage()
+                + "; try installing unlimited cryptography extension");
         i.remove();
       } catch (Exception e) {
         sshDaemonLog.warn("Disabling cipher " + f.getName() + ": " + e.getMessage());
@@ -540,20 +553,20 @@
     }
 
     a.add(null);
-    setCipherFactories(filter(cfg, "cipher",
-        (NamedFactory<Cipher>[])a.toArray(new NamedFactory[a.size()])));
+    setCipherFactories(
+        filter(cfg, "cipher", (NamedFactory<Cipher>[]) a.toArray(new NamedFactory<?>[a.size()])));
   }
 
   @SuppressWarnings("unchecked")
   private void initMacs(Config cfg) {
     List<NamedFactory<Mac>> m = BaseBuilder.setUpDefaultMacs(true);
-    setMacFactories(filter(cfg, "mac",
-       (NamedFactory<Mac>[]) m.toArray(new NamedFactory[m.size()])));
+    setMacFactories(
+        filter(cfg, "mac", (NamedFactory<Mac>[]) m.toArray(new NamedFactory<?>[m.size()])));
   }
 
   @SafeVarargs
-  private static <T> List<NamedFactory<T>> filter(final Config cfg,
-      final String key, final NamedFactory<T>... avail) {
+  private static <T> List<NamedFactory<T>> filter(
+      final Config cfg, final String key, final NamedFactory<T>... avail) {
     final ArrayList<NamedFactory<T>> def = new ArrayList<>();
     for (final NamedFactory<T> n : avail) {
       if (n == null) {
@@ -584,8 +597,7 @@
       final NamedFactory<T> n = find(name, avail);
       if (n == null) {
         final StringBuilder msg = new StringBuilder();
-        msg.append("sshd.").append(key).append(" = ").append(name)
-           .append(" unsupported; only ");
+        msg.append("sshd.").append(key).append(" = ").append(name).append(" unsupported; only ");
         for (int i = 0; i < avail.length; i++) {
           if (avail[i] == null) {
             continue;
@@ -610,8 +622,7 @@
   }
 
   @SafeVarargs
-  private static <T> NamedFactory<T> find(final String name,
-      final NamedFactory<T>... avail) {
+  private static <T> NamedFactory<T> find(final String name, final NamedFactory<T>... avail) {
     for (final NamedFactory<T> n : avail) {
       if (n != null && name.equals(n.getName())) {
         return n;
@@ -654,33 +665,37 @@
   }
 
   private void initSubsystems() {
-    setSubsystemFactories(Collections.<NamedFactory<Command>> emptyList());
+    setSubsystemFactories(Collections.<NamedFactory<Command>>emptyList());
   }
 
-  private void initUserAuth(final PublickeyAuthenticator pubkey,
+  private void initUserAuth(
+      final PublickeyAuthenticator pubkey,
       final GSSAuthenticator kerberosAuthenticator,
-      String kerberosKeytab, String kerberosPrincipal) {
+      String kerberosKeytab,
+      String kerberosPrincipal) {
     List<NamedFactory<UserAuth>> authFactories = new ArrayList<>();
     if (kerberosKeytab != null) {
       authFactories.add(UserAuthGSSFactory.INSTANCE);
-      log.info("Enabling kerberos with keytab " + kerberosKeytab);
+      sshDaemonLog.info("Enabling kerberos with keytab " + kerberosKeytab);
       if (!new File(kerberosKeytab).canRead()) {
-        sshDaemonLog.error("Keytab " + kerberosKeytab +
-            " does not exist or is not readable; further errors are possible");
+        sshDaemonLog.error(
+            "Keytab "
+                + kerberosKeytab
+                + " does not exist or is not readable; further errors are possible");
       }
       kerberosAuthenticator.setKeytabFile(kerberosKeytab);
       if (kerberosPrincipal == null) {
         try {
-          kerberosPrincipal = "host/" +
-              InetAddress.getLocalHost().getCanonicalHostName();
+          kerberosPrincipal = "host/" + InetAddress.getLocalHost().getCanonicalHostName();
         } catch (UnknownHostException e) {
           kerberosPrincipal = "host/localhost";
         }
       }
       sshDaemonLog.info("Using kerberos principal " + kerberosPrincipal);
       if (!kerberosPrincipal.startsWith("host/")) {
-        sshDaemonLog.warn("Host principal does not start with host/ " +
-            "which most SSH clients will supply automatically");
+        sshDaemonLog.warn(
+            "Host principal does not start with host/ "
+                + "which most SSH clients will supply automatically");
       }
       kerberosAuthenticator.setServicePrincipalName(kerberosPrincipal);
       setGSSAuthenticator(kerberosAuthenticator);
@@ -691,96 +706,96 @@
   }
 
   private void initForwarding() {
-    setTcpipForwardingFilter(new ForwardingFilter() {
-      @Override
-      public boolean canForwardAgent(Session session, String requestType) {
-          return false;
-      }
+    setTcpipForwardingFilter(
+        new ForwardingFilter() {
+          @Override
+          public boolean canForwardAgent(Session session, String requestType) {
+            return false;
+          }
 
-      @Override
-      public boolean canForwardX11(Session session, String requestType) {
-          return false;
-      }
+          @Override
+          public boolean canForwardX11(Session session, String requestType) {
+            return false;
+          }
 
-      @Override
-      public boolean canListen(SshdSocketAddress address, Session session) {
-          return false;
-      }
+          @Override
+          public boolean canListen(SshdSocketAddress address, Session session) {
+            return false;
+          }
 
-      @Override
-      public boolean canConnect(Type type, SshdSocketAddress address, Session session) {
-          return false;
-      }
-    });
+          @Override
+          public boolean canConnect(Type type, SshdSocketAddress address, Session session) {
+            return false;
+          }
+        });
     setTcpipForwarderFactory(new DefaultTcpipForwarderFactory());
   }
 
   private void initFileSystemFactory() {
-    setFileSystemFactory(new FileSystemFactory() {
-      @Override
-      public FileSystem createFileSystem(Session session)
-          throws IOException {
-        return new FileSystem() {
+    setFileSystemFactory(
+        new FileSystemFactory() {
           @Override
-          public void close() throws IOException {
-	  }
+          public FileSystem createFileSystem(Session session) throws IOException {
+            return new FileSystem() {
+              @Override
+              public void close() throws IOException {}
 
-          @Override
-          public Iterable<FileStore> getFileStores() {
-            return null;
-          }
+              @Override
+              public Iterable<FileStore> getFileStores() {
+                return null;
+              }
 
-          @Override
-          public Path getPath(String arg0, String... arg1) {
-            return null;
-          }
+              @Override
+              public Path getPath(String arg0, String... arg1) {
+                return null;
+              }
 
-          @Override
-          public PathMatcher getPathMatcher(String arg0) {
-            return null;
-          }
+              @Override
+              public PathMatcher getPathMatcher(String arg0) {
+                return null;
+              }
 
-          @Override
-          public Iterable<Path> getRootDirectories() {
-            return null;
-          }
+              @Override
+              public Iterable<Path> getRootDirectories() {
+                return null;
+              }
 
-          @Override
-          public String getSeparator() {
-            return null;
-          }
+              @Override
+              public String getSeparator() {
+                return null;
+              }
 
-          @Override
-          public UserPrincipalLookupService getUserPrincipalLookupService() {
-            return null;
-          }
+              @Override
+              public UserPrincipalLookupService getUserPrincipalLookupService() {
+                return null;
+              }
 
-          @Override
-          public boolean isOpen() {
-            return false;
-          }
+              @Override
+              public boolean isOpen() {
+                return false;
+              }
 
-          @Override
-          public boolean isReadOnly() {
-            return false;
-          }
+              @Override
+              public boolean isReadOnly() {
+                return false;
+              }
 
-          @Override
-          public WatchService newWatchService() throws IOException {
-            return null;
-          }
+              @Override
+              public WatchService newWatchService() throws IOException {
+                return null;
+              }
 
-          @Override
-          public FileSystemProvider provider() {
-            return null;
-          }
+              @Override
+              public FileSystemProvider provider() {
+                return null;
+              }
 
-          @Override
-          public Set<String> supportedFileAttributeViews() {
-            return null;
+              @Override
+              public Set<String> supportedFileAttributeViews() {
+                return null;
+              }
+            };
           }
-        };
-      }
-    });
+        });
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java
index 2f88fa9..d5b8eee 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshHostKeyModule.java
@@ -17,7 +17,6 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.inject.AbstractModule;
-
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 
 public class SshHostKeyModule extends AbstractModule {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
index 0d2f1fc..2cab00b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-
 import java.security.PublicKey;
 
 class SshKeyCacheEntry {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index bf3e6bc..837865e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -31,23 +31,20 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Provides the {@link SshKeyCacheEntry}. */
 @Singleton
 public class SshKeyCacheImpl implements SshKeyCache {
-  private static final Logger log =
-      LoggerFactory.getLogger(SshKeyCacheImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(SshKeyCacheImpl.class);
   private static final String CACHE_NAME = "sshkeys";
 
   static final Iterable<SshKeyCacheEntry> NO_SUCH_USER = none();
@@ -57,10 +54,8 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(CACHE_NAME,
-            String.class,
-            new TypeLiteral<Iterable<SshKeyCacheEntry>>(){})
-          .loader(Loader.class);
+        cache(CACHE_NAME, String.class, new TypeLiteral<Iterable<SshKeyCacheEntry>>() {})
+            .loader(Loader.class);
         bind(SshKeyCacheImpl.class);
         bind(SshKeyCache.class).to(SshKeyCacheImpl.class);
         bind(SshKeyCreator.class).to(SshKeyCreatorImpl.class);
@@ -69,15 +64,13 @@
   }
 
   private static Iterable<SshKeyCacheEntry> none() {
-    return Collections.unmodifiableCollection(Arrays
-        .asList(new SshKeyCacheEntry[0]));
+    return Collections.unmodifiableCollection(Arrays.asList(new SshKeyCacheEntry[0]));
   }
 
   private final LoadingCache<String, Iterable<SshKeyCacheEntry>> cache;
 
   @Inject
-  SshKeyCacheImpl(
-      @Named(CACHE_NAME) LoadingCache<String, Iterable<SshKeyCacheEntry>> cache) {
+  SshKeyCacheImpl(@Named(CACHE_NAME) LoadingCache<String, Iterable<SshKeyCacheEntry>> cache) {
     this.cache = cache;
   }
 
@@ -102,8 +95,7 @@
     private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema,
-        VersionedAuthorizedKeys.Accessor authorizedKeys) {
+    Loader(SchemaFactory<ReviewDb> schema, VersionedAuthorizedKeys.Accessor authorizedKeys) {
       this.schema = schema;
       this.authorizedKeys = authorizedKeys;
     }
@@ -111,15 +103,17 @@
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
-        AccountExternalId.Key key =
-            new AccountExternalId.Key(SCHEME_USERNAME, username);
-        AccountExternalId user = db.accountExternalIds().get(key);
+        ExternalId user =
+            ExternalId.from(
+                db.accountExternalIds()
+                    .get(
+                        ExternalId.Key.create(SCHEME_USERNAME, username).asAccountExternalIdKey()));
         if (user == null) {
           return NO_SUCH_USER;
         }
 
         List<SshKeyCacheEntry> kl = new ArrayList<>(4);
-        for (AccountSshKey k : authorizedKeys.getKeys(user.getAccountId())) {
+        for (AccountSshKey k : authorizedKeys.getKeys(user.accountId())) {
           if (k.isValid()) {
             add(kl, k);
           }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
index 0fd6db4..637f98e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCreatorImpl.java
@@ -17,24 +17,19 @@
 import com.google.gerrit.common.errors.InvalidSshKeyException;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.ssh.SshKeyCreator;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.security.NoSuchAlgorithmException;
 import java.security.NoSuchProviderException;
 import java.security.spec.InvalidKeySpecException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class SshKeyCreatorImpl implements SshKeyCreator {
-  private static final Logger log =
-      LoggerFactory.getLogger(SshKeyCreatorImpl.class);
+  private static final Logger log = LoggerFactory.getLogger(SshKeyCreatorImpl.class);
 
   @Override
-  public AccountSshKey create(AccountSshKey.Id id, String encoded)
-      throws InvalidSshKeyException {
+  public AccountSshKey create(AccountSshKey.Id id, String encoded) throws InvalidSshKeyException {
     try {
-      AccountSshKey key =
-          new AccountSshKey(id, SshUtil.toOpenSshPublicKey(encoded));
+      AccountSshKey key = new AccountSshKey(id, SshUtil.toOpenSshPublicKey(encoded));
       SshUtil.parse(key);
       return key;
     } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
index fed8226..dfd56f1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.sshd;
 
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.audit.SshAuditEvent;
 import com.google.gerrit.common.TimeUtil;
@@ -30,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
@@ -55,8 +54,11 @@
   private final AuditService auditService;
 
   @Inject
-  SshLog(final Provider<SshSession> session, final Provider<Context> context,
-      SystemLog systemLog, @GerritServerConfig Config config,
+  SshLog(
+      final Provider<SshSession> session,
+      final Provider<Context> context,
+      SystemLog systemLog,
+      @GerritServerConfig Config config,
       AuditService auditService) {
     this.session = session;
     this.context = context;
@@ -70,8 +72,7 @@
   }
 
   @Override
-  public void start() {
-  }
+  public void start() {}
 
   @Override
   public void stop() {
@@ -81,8 +82,7 @@
   }
 
   void onLogin() {
-    LoggingEvent entry =
-        log("LOGIN FROM " + session.get().getRemoteAddressAsString());
+    LoggingEvent entry = log("LOGIN FROM " + session.get().getRemoteAddressAsString());
     if (async != null) {
       async.append(entry);
     }
@@ -90,18 +90,19 @@
   }
 
   void onAuthFail(final SshSession sd) {
-    final LoggingEvent event = new LoggingEvent( //
-        Logger.class.getName(), // fqnOfCategoryClass
-        log, // logger
-        TimeUtil.nowMs(), // when
-        Level.INFO, // level
-        "AUTH FAILURE FROM " + sd.getRemoteAddressAsString(), // message text
-        "SSHD", // thread name
-        null, // exception information
-        null, // current NDC string
-        null, // caller location
-        null // MDC properties
-        );
+    final LoggingEvent event =
+        new LoggingEvent( //
+            Logger.class.getName(), // fqnOfCategoryClass
+            log, // logger
+            TimeUtil.nowMs(), // when
+            Level.INFO, // level
+            "AUTH FAILURE FROM " + sd.getRemoteAddressAsString(), // message text
+            "SSHD", // thread name
+            null, // exception information
+            null, // current NDC string
+            null, // caller location
+            null // MDC properties
+            );
 
     event.setProperty(P_SESSION, id(sd.getSessionId()));
     event.setProperty(P_USER_NAME, sd.getUsername());
@@ -156,14 +157,14 @@
     audit(context.get(), status, dcmd);
   }
 
-  private Multimap<String, ?> extractParameters(DispatchCommand dcmd) {
+  private ListMultimap<String, ?> extractParameters(DispatchCommand dcmd) {
     if (dcmd == null) {
-      return ArrayListMultimap.create(0, 0);
+      return MultimapBuilder.hashKeys(0).arrayListValues(0).build();
     }
     String[] cmdArgs = dcmd.getArguments();
     String paramName = null;
     int argPos = 0;
-    Multimap<String, String> parms = ArrayListMultimap.create();
+    ListMultimap<String, String> parms = MultimapBuilder.hashKeys().arrayListValues().build();
     for (int i = 2; i < cmdArgs.length; i++) {
       String arg = cmdArgs[i];
       // -- stop parameters parsing
@@ -213,18 +214,19 @@
     final SshSession sd = session.get();
     final CurrentUser user = sd.getUser();
 
-    final LoggingEvent event = new LoggingEvent( //
-        Logger.class.getName(), // fqnOfCategoryClass
-        log, // logger
-        TimeUtil.nowMs(), // when
-        Level.INFO, // level
-        msg, // message text
-        "SSHD", // thread name
-        null, // exception information
-        null, // current NDC string
-        null, // caller location
-        null // MDC properties
-        );
+    final LoggingEvent event =
+        new LoggingEvent( //
+            Logger.class.getName(), // fqnOfCategoryClass
+            log, // logger
+            TimeUtil.nowMs(), // when
+            Level.INFO, // level
+            msg, // message text
+            "SSHD", // thread name
+            null, // exception information
+            null, // current NDC string
+            null, // caller location
+            null // MDC properties
+            );
 
     event.setProperty(P_SESSION, id(sd.getSessionId()));
 
@@ -258,7 +260,7 @@
     audit(ctx, result, extractWhat(cmd), extractParameters(cmd));
   }
 
-  private void audit(Context ctx, Object result, String cmd, Multimap<String, ?> params) {
+  private void audit(Context ctx, Object result, String cmd, ListMultimap<String, ?> params) {
     String sessionId;
     CurrentUser currentUser;
     long created;
@@ -272,8 +274,7 @@
       currentUser = session.getUser();
       created = ctx.created;
     }
-    auditService.dispatch(new SshAuditEvent(sessionId, currentUser,
-        cmd, created, params, result));
+    auditService.dispatch(new SshAuditEvent(sessionId, currentUser, cmd, created, params, result));
   }
 
   private String extractWhat(DispatchCommand dcmd) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
index 541081e..627bf71 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.sshd;
 
-import org.apache.log4j.Layout;
-import org.apache.log4j.spi.LoggingEvent;
-import org.eclipse.jgit.util.QuotedString;
-
 import java.text.SimpleDateFormat;
 import java.util.Calendar;
 import java.util.TimeZone;
+import org.apache.log4j.Layout;
+import org.apache.log4j.spi.LoggingEvent;
+import org.eclipse.jgit.util.QuotedString;
 
 public final class SshLogLayout extends Layout {
 
@@ -38,7 +37,7 @@
   private final SimpleDateFormat tzFormat;
   private char[] timeZone;
 
- public SshLogLayout() {
+  public SshLogLayout() {
     final TimeZone tz = TimeZone.getDefault();
     calendar = Calendar.getInstance(tz);
 
@@ -134,6 +133,5 @@
   }
 
   @Override
-  public void activateOptions() {
-  }
+  public void activateOptions() {}
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
index 3429587..da62782 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.RemotePeer;
@@ -34,16 +35,14 @@
 import com.google.inject.Inject;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.RequestScoped;
-
+import java.net.SocketAddress;
+import java.util.HashMap;
+import java.util.Map;
 import org.apache.sshd.server.CommandFactory;
 import org.apache.sshd.server.auth.gss.GSSAuthenticator;
 import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
 import org.eclipse.jgit.lib.Config;
 
-import java.net.SocketAddress;
-import java.util.HashMap;
-import java.util.Map;
-
 /** Configures standard dependencies for {@link SshDaemon}. */
 public class SshModule extends LifecycleModule {
   private final Map<String, String> aliases;
@@ -72,13 +71,16 @@
     factory(QueryShell.Factory.class);
     factory(PeerDaemonUser.Factory.class);
 
-    bind(DispatchCommandProvider.class).annotatedWith(Commands.CMD_ROOT)
+    bind(DispatchCommandProvider.class)
+        .annotatedWith(Commands.CMD_ROOT)
         .toInstance(new DispatchCommandProvider(Commands.CMD_ROOT));
     bind(CommandFactoryProvider.class);
     bind(CommandFactory.class).toProvider(CommandFactoryProvider.class);
-    bind(WorkQueue.Executor.class).annotatedWith(StreamCommandExecutor.class)
-        .toProvider(StreamCommandExecutorProvider.class).in(SINGLETON);
-    bind(QueueProvider.class).to(CommandExecutorQueueProvider.class).in(SINGLETON);
+    bind(WorkQueue.Executor.class)
+        .annotatedWith(StreamCommandExecutor.class)
+        .toProvider(StreamCommandExecutorProvider.class)
+        .in(SINGLETON);
+    bind(QueueProvider.class).to(CommandExecutorQueueProvider.class);
 
     bind(GSSAuthenticator.class).to(GerritGSSAuthenticator.class);
     bind(PublickeyAuthenticator.class).to(CachingPublicKeyAuthenticator.class);
@@ -86,13 +88,14 @@
     bind(ModuleGenerator.class).to(SshAutoRegisterModuleGenerator.class);
     bind(SshPluginStarterCallback.class);
     bind(StartPluginListener.class)
-      .annotatedWith(UniqueAnnotations.create())
-      .to(SshPluginStarterCallback.class);
+        .annotatedWith(UniqueAnnotations.create())
+        .to(SshPluginStarterCallback.class);
 
     bind(ReloadPluginListener.class)
-      .annotatedWith(UniqueAnnotations.create())
-      .to(SshPluginStarterCallback.class);
+        .annotatedWith(UniqueAnnotations.create())
+        .to(SshPluginStarterCallback.class);
 
+    DynamicItem.itemOf(binder(), SshCreateCommandInterceptor.class);
     listener().toInstance(registerInParentInjectors());
     listener().to(SshLog.class);
     listener().to(SshDaemon.class);
@@ -108,21 +111,23 @@
       for (int i = 1; i < dest.length; i++) {
         cmd = Commands.named(cmd, dest[i]);
       }
-      bind(Commands.key(gerrit, name))
-        .toProvider(new AliasCommandProvider(cmd));
+      bind(Commands.key(gerrit, name)).toProvider(new AliasCommandProvider(cmd));
     }
   }
 
   private void configureRequestScope() {
     bind(SshScope.Context.class).toProvider(SshScope.ContextProvider.class);
 
-    bind(SshSession.class).toProvider(SshScope.SshSessionProvider.class).in(
-        SshScope.REQUEST);
-    bind(SocketAddress.class).annotatedWith(RemotePeer.class).toProvider(
-        SshRemotePeerProvider.class).in(SshScope.REQUEST);
+    bind(SshSession.class).toProvider(SshScope.SshSessionProvider.class).in(SshScope.REQUEST);
+    bind(SocketAddress.class)
+        .annotatedWith(RemotePeer.class)
+        .toProvider(SshRemotePeerProvider.class)
+        .in(SshScope.REQUEST);
 
-    bind(WorkQueue.Executor.class).annotatedWith(CommandExecutor.class)
-        .toProvider(CommandExecutorProvider.class).in(SshScope.REQUEST);
+    bind(WorkQueue.Executor.class)
+        .annotatedWith(CommandExecutor.class)
+        .toProvider(CommandExecutorProvider.class)
+        .in(SshScope.REQUEST);
 
     install(new GerritRequestModule());
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index f134d48..5f7af9a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -21,22 +21,18 @@
 import com.google.inject.Key;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import org.apache.sshd.server.Command;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @Singleton
-class SshPluginStarterCallback
-    implements StartPluginListener, ReloadPluginListener {
-  private static final Logger log = LoggerFactory
-      .getLogger(SshPluginStarterCallback.class);
+class SshPluginStarterCallback implements StartPluginListener, ReloadPluginListener {
+  private static final Logger log = LoggerFactory.getLogger(SshPluginStarterCallback.class);
 
   private final DispatchCommandProvider root;
 
   @Inject
-  SshPluginStarterCallback(
-      @CommandName(Commands.ROOT) DispatchCommandProvider root) {
+  SshPluginStarterCallback(@CommandName(Commands.ROOT) DispatchCommandProvider root) {
     this.root = root;
   }
 
@@ -62,9 +58,7 @@
       try {
         return plugin.getSshInjector().getProvider(key);
       } catch (RuntimeException err) {
-        log.warn(String.format(
-            "Plugin %s did not define its top-level command",
-            plugin.getName()), err);
+        log.warn("Plugin {} did not define its top-level command", plugin.getName(), err);
       }
     }
     return null;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java
index 2c77360..44554ca 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java
@@ -17,7 +17,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.net.SocketAddress;
 
 @Singleton
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
index 9616aec..66e5101 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
@@ -30,14 +30,12 @@
 import com.google.inject.Provider;
 import com.google.inject.Scope;
 import com.google.inject.util.Providers;
-
 import java.util.HashMap;
 import java.util.Map;
 
 /** Guice scopes for state during an SSH connection. */
 public class SshScope {
-  private static final Key<RequestCleanup> RC_KEY =
-      Key.get(RequestCleanup.class);
+  private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
 
   private static final Key<RequestScopedReviewDbProvider> DB_KEY =
       Key.get(RequestScopedReviewDbProvider.class);
@@ -53,16 +51,13 @@
     volatile long started;
     volatile long finished;
 
-    private Context(SchemaFactory<ReviewDb> sf, final SshSession s,
-        final String c, final long at) {
+    private Context(SchemaFactory<ReviewDb> sf, final SshSession s, final String c, final long at) {
       schemaFactory = sf;
       session = s;
       commandLine = c;
       created = started = finished = at;
       map.put(RC_KEY, cleanup);
-      map.put(DB_KEY, new RequestScopedReviewDbProvider(
-          schemaFactory,
-          Providers.of(cleanup)));
+      map.put(DB_KEY, new RequestScopedReviewDbProvider(schemaFactory, Providers.of(cleanup)));
     }
 
     private Context(Context p, SshSession s, String c) {
@@ -107,7 +102,7 @@
 
     synchronized Context subContext(SshSession newSession, String newCommandLine) {
       Context ctx = new Context(this, newSession, newCommandLine);
-      cleanup.add(ctx.cleanup);
+      ctx.cleanup.add(cleanup);
       return ctx;
     }
   }
@@ -130,7 +125,9 @@
     private final SshScope sshScope;
 
     @Inject
-    Propagator(SshScope sshScope, ThreadLocalRequestContext local,
+    Propagator(
+        SshScope sshScope,
+        ThreadLocalRequestContext local,
         Provider<RequestScopedReviewDbProvider> dbProviderProvider) {
       super(REQUEST, current, local, dbProviderProvider);
       this.sshScope = sshScope;
@@ -158,8 +155,7 @@
   private final IdentifiedUser.RequestFactory userFactory;
 
   @Inject
-  SshScope(ThreadLocalRequestContext local,
-      IdentifiedUser.RequestFactory userFactory) {
+  SshScope(ThreadLocalRequestContext local, IdentifiedUser.RequestFactory userFactory) {
     this.local = local;
     this.userFactory = userFactory;
   }
@@ -180,25 +176,26 @@
   }
 
   /** Returns exactly one instance per command executed. */
-  public static final Scope REQUEST = new Scope() {
-    @Override
-    public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
-      return new Provider<T>() {
+  public static final Scope REQUEST =
+      new Scope() {
         @Override
-        public T get() {
-          return requireContext().get(key, creator);
+        public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
+          return new Provider<T>() {
+            @Override
+            public T get() {
+              return requireContext().get(key, creator);
+            }
+
+            @Override
+            public String toString() {
+              return String.format("%s[%s]", creator, REQUEST);
+            }
+          };
         }
 
         @Override
         public String toString() {
-          return String.format("%s[%s]", creator, REQUEST);
+          return "SshScopes.REQUEST";
         }
       };
-    }
-
-    @Override
-    public String toString() {
-      return "SshScopes.REQUEST";
-    }
-  };
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
index 17c330f..f08fb43 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
@@ -16,12 +16,10 @@
 
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
-
-import org.apache.sshd.common.AttributeStore.AttributeKey;
-
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
+import org.apache.sshd.common.AttributeStore.AttributeKey;
 
 /** Global data related to an active SSH connection. */
 public class SshSession {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
index c2c07e1..33d253a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
@@ -19,16 +19,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.sshd.SshScope.Context;
-
-import org.apache.commons.codec.binary.Base64;
-import org.apache.sshd.common.SshException;
-import org.apache.sshd.common.future.CloseFuture;
-import org.apache.sshd.common.future.SshFutureListener;
-import org.apache.sshd.common.keyprovider.KeyPairProvider;
-import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
-import org.apache.sshd.server.session.ServerSession;
-import org.eclipse.jgit.lib.Constants;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.StringReader;
@@ -38,6 +28,14 @@
 import java.security.interfaces.DSAPublicKey;
 import java.security.interfaces.RSAPublicKey;
 import java.security.spec.InvalidKeySpecException;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.future.CloseFuture;
+import org.apache.sshd.common.future.SshFutureListener;
+import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.apache.sshd.server.session.ServerSession;
+import org.eclipse.jgit.lib.Constants;
 
 /** Utilities to support SSH operations. */
 public class SshUtil {
@@ -51,8 +49,7 @@
    * @throws NoSuchProviderException the JVM is missing the provider.
    */
   public static PublicKey parse(final AccountSshKey key)
-      throws NoSuchAlgorithmException, InvalidKeySpecException,
-      NoSuchProviderException {
+      throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
     try {
       final String s = key.getEncodedKey();
       if (s == null) {
@@ -69,8 +66,8 @@
    * Convert an RFC 4716 style key to an OpenSSH style key.
    *
    * @param keyStr the key string to convert.
-   * @return {@code keyStr} if conversion failed; otherwise the converted
-   *         key, in OpenSSH key format.
+   * @return {@code keyStr} if conversion failed; otherwise the converted key, in OpenSSH key
+   *     format.
    */
   public static String toOpenSshPublicKey(final String keyStr) {
     try {
@@ -96,8 +93,8 @@
       }
 
       final PublicKey key =
-          new ByteArrayBuffer(Base64.decodeBase64(Constants.encodeASCII(strBuf
-              .toString()))).getRawPublicKey();
+          new ByteArrayBuffer(Base64.decodeBase64(Constants.encodeASCII(strBuf.toString())))
+              .getRawPublicKey();
       if (key instanceof RSAPublicKey) {
         strBuf.insert(0, KeyPairProvider.SSH_RSA + " ");
 
@@ -118,9 +115,13 @@
     }
   }
 
-  public static boolean success(final String username, final ServerSession session,
-      final SshScope sshScope, final SshLog sshLog,
-      final SshSession sd, final CurrentUser user) {
+  public static boolean success(
+      final String username,
+      final ServerSession session,
+      final SshScope sshScope,
+      final SshLog sshLog,
+      final SshSession sd,
+      final CurrentUser user) {
     if (sd.getUser() == null) {
       sd.authenticationSuccess(username, user);
 
@@ -154,7 +155,8 @@
     return true;
   }
 
-  public static IdentifiedUser createUser(final SshSession sd,
+  public static IdentifiedUser createUser(
+      final SshSession sd,
       final IdentifiedUser.GenericFactory userFactory,
       final Account.Id account) {
     return userFactory.create(sd.getRemoteAddress(), account);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java
index 06f7de6..794ff76 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java
@@ -18,11 +18,9 @@
 
 import com.google.gerrit.server.git.WorkQueue.Executor;
 import com.google.inject.BindingAnnotation;
-
 import java.lang.annotation.Retention;
 
 /** Marker on {@link Executor} used by delayed event streaming. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface StreamCommandExecutor {
-}
+public @interface StreamCommandExecutor {}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
index 2c9dc45..96f1750 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
@@ -18,18 +18,15 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
-import org.eclipse.jgit.lib.Config;
-
 import java.util.concurrent.ThreadFactory;
+import org.eclipse.jgit.lib.Config;
 
 class StreamCommandExecutorProvider implements Provider<WorkQueue.Executor> {
   private final int poolSize;
   private final WorkQueue queues;
 
   @Inject
-  StreamCommandExecutorProvider(@GerritServerConfig final Config config,
-      final WorkQueue wq) {
+  StreamCommandExecutorProvider(@GerritServerConfig final Config config, final WorkQueue wq) {
     final int cores = Runtime.getRuntime().availableProcessors();
     poolSize = config.getInt("sshd", "streamThreads", cores + 1);
     queues = wq;
@@ -39,17 +36,18 @@
   public WorkQueue.Executor get() {
     final WorkQueue.Executor executor;
 
-    executor = queues.createQueue(poolSize, "SSH-Stream-Worker");
+    executor = queues.createQueue(poolSize, "SSH-Stream-Worker", true);
 
     final ThreadFactory parent = executor.getThreadFactory();
-    executor.setThreadFactory(new ThreadFactory() {
-      @Override
-      public Thread newThread(final Runnable task) {
-        final Thread t = parent.newThread(task);
-        t.setPriority(Thread.MIN_PRIORITY);
-        return t;
-      }
-    });
+    executor.setThreadFactory(
+        new ThreadFactory() {
+          @Override
+          public Thread newThread(final Runnable task) {
+            final Thread t = parent.newThread(task);
+            t.setPriority(Thread.MIN_PRIORITY);
+            return t;
+          }
+        });
     return executor;
   }
 }
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 24bd8c2..53a98eb 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
@@ -25,24 +25,22 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
-
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.net.SocketAddress;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 
 /**
  * Executes any other command as a different user identity.
- * <p>
- * The calling user must be authenticated as a {@link PeerDaemonUser}, which
- * usually requires public key authentication using this daemon's private host
- * key, or a key on this daemon's peer host key ring.
+ *
+ * <p>The calling user must be authenticated as a {@link PeerDaemonUser}, which usually requires
+ * public key authentication using this daemon's private host key, or a key on this daemon's peer
+ * host key ring.
  */
 public final class SuExec extends BaseCommand {
   private final SshScope sshScope;
@@ -66,7 +64,8 @@
   private final AtomicReference<Command> atomicCmd;
 
   @Inject
-  SuExec(final SshScope sshScope,
+  SuExec(
+      final SshScope sshScope,
       @CommandName(Commands.ROOT) final DispatchCommandProvider dispatcher,
       final CurrentUser caller,
       final SshSession session,
@@ -131,8 +130,7 @@
     if (caller instanceof PeerDaemonUser) {
       caller = null;
     }
-    return new SshSession(session, peer,
-        userFactory.runAs(peer, accountId, caller));
+    return new SshSession(session, peer, userFactory.runAs(peer, accountId, caller));
   }
 
   private static String join(List<String> args) {
@@ -153,7 +151,7 @@
       try {
         cmd.destroy();
       } catch (Exception e) {
-        Throwables.propagateIfPossible(e);
+        Throwables.throwIfUnchecked(e);
         throw new RuntimeException(e);
       }
     }
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 237d844..42c7578 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
@@ -22,7 +22,6 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
 import org.kohsuke.args4j.Option;
 
 /** Opens a query processor. */
@@ -30,11 +29,9 @@
 @RequiresCapability(GlobalCapability.ACCESS_DATABASE)
 @CommandMetaData(name = "gsql", description = "Administrative interface to active database")
 final class AdminQueryShell extends SshCommand {
-  @Inject
-  private QueryShell.Factory factory;
+  @Inject private QueryShell.Factory factory;
 
-  @Inject
-  private IdentifiedUser currentUser;
+  @Inject private IdentifiedUser currentUser;
 
   @Option(name = "--format", usage = "Set output format")
   private QueryShell.OutputFormat format = QueryShell.OutputFormat.PRETTY;
@@ -61,20 +58,19 @@
 
   /**
    * Assert that the current user is permitted to perform raw queries.
-   * <p>
-   * As the @RequireCapability guards at various entry points of internal
-   * commands implicitly add administrators (which we want to avoid), we also
-   * check permissions within QueryShell and grant access only to those who
-   * can access the database, regardless of whether they are administrators or
-   * not.
+   *
+   * <p>As the @RequireCapability guards at various entry points of internal commands implicitly add
+   * administrators (which we want to avoid), we also check permissions within QueryShell and grant
+   * access only to those who can access the database, regardless of whether they are administrators
+   * or not.
    *
    * @throws PermissionDeniedException
    */
   private void checkPermission() throws PermissionDeniedException {
     if (!currentUser.getCapabilities().canAccessDatabase()) {
-      throw new PermissionDeniedException(String.format(
-          "%s does not have \"Access Database\" capability.",
-          currentUser.getUserName()));
+      throw new PermissionDeniedException(
+          String.format(
+              "%s does not have \"Access Database\" capability.", currentUser.getUserName()));
     }
   }
 }
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 eb0d7b2..b7d8507 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
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.common.base.Function;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -32,7 +29,12 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Argument;
@@ -40,52 +42,56 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "set-project-parent", description = "Change the project permissions are inherited from")
+@CommandMetaData(
+    name = "set-project-parent",
+    description = "Change the project permissions are inherited from")
 final class AdminSetParent extends SshCommand {
   private static final Logger log = LoggerFactory.getLogger(AdminSetParent.class);
 
-  @Option(name = "--parent", aliases = {"-p"}, metaVar = "NAME", usage = "new parent project")
+  @Option(
+      name = "--parent",
+      aliases = {"-p"},
+      metaVar = "NAME",
+      usage = "new parent project")
   private ProjectControl newParent;
 
-  @Option(name = "--children-of", metaVar = "NAME",
+  @Option(
+      name = "--children-of",
+      metaVar = "NAME",
       usage = "parent project for which the child projects should be reparented")
   private ProjectControl oldParent;
 
-  @Option(name = "--exclude", metaVar = "NAME",
+  @Option(
+      name = "--exclude",
+      metaVar = "NAME",
       usage = "child project of old parent project which should not be reparented")
   private List<ProjectControl> excludedChildren = new ArrayList<>();
 
-  @Argument(index = 0, required = false, multiValued = true, metaVar = "NAME",
+  @Argument(
+      index = 0,
+      required = false,
+      multiValued = true,
+      metaVar = "NAME",
       usage = "projects to modify")
   private List<ProjectControl> children = new ArrayList<>();
 
-  @Inject
-  private ProjectCache projectCache;
+  @Inject private ProjectCache projectCache;
 
-  @Inject
-  private MetaDataUpdate.User metaDataUpdateFactory;
+  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
 
-  @Inject
-  private AllProjectsName allProjectsName;
+  @Inject private AllProjectsName allProjectsName;
 
-  @Inject
-  private ListChildProjects listChildProjects;
+  @Inject private ListChildProjects listChildProjects;
 
   private Project.NameKey newParentKey;
 
   @Override
   protected void run() throws Failure {
     if (oldParent == null && children.isEmpty()) {
-      throw die("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 die("--exclude can only be used together with --children-of");
@@ -135,18 +141,20 @@
         // Try to avoid creating a cycle in the parent pointers.
         //
         err.append("error: Cycle exists between '")
-           .append(name)
-           .append("' and '")
-           .append(newParentKey != null ? newParentKey.get() : allProjectsName.get())
-           .append("'\n");
+            .append(name)
+            .append("' and '")
+            .append(newParentKey != null ? newParentKey.get() : allProjectsName.get())
+            .append("'\n");
         continue;
       }
 
       try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
         ProjectConfig config = ProjectConfig.read(md);
         config.getProject().setParentName(newParentKey);
-        md.setMessage("Inherit access from "
-            + (newParentKey != null ? newParentKey.get() : allProjectsName.get()) + "\n");
+        md.setMessage(
+            "Inherit access from "
+                + (newParentKey != null ? newParentKey.get() : allProjectsName.get())
+                + "\n");
         config.commit(md);
       } catch (RepositoryNotFoundException notFound) {
         err.append("error: Project ").append(name).append(" not found\n");
@@ -168,47 +176,45 @@
   }
 
   /**
-   * Returns the children of the specified parent project that should be
-   * reparented. The returned list of child projects does not contain projects
-   * that were specified to be excluded from reparenting.
+   * Returns the children of the specified parent project that should be reparented. The returned
+   * list of child projects does not contain projects that were specified to be excluded from
+   * reparenting.
    */
   private List<Project.NameKey> getChildrenForReparenting(final ProjectControl parent) {
     final List<Project.NameKey> childProjects = new ArrayList<>();
-    final List<Project.NameKey> excluded =
-        new ArrayList<>(excludedChildren.size());
+    final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
     for (final ProjectControl excludedChild : excludedChildren) {
       excluded.add(excludedChild.getProject().getNameKey());
     }
-    final List<Project.NameKey> automaticallyExcluded =
-        new ArrayList<>(excludedChildren.size());
+    final List<Project.NameKey> automaticallyExcluded = new ArrayList<>(excludedChildren.size());
     if (newParentKey != null) {
       automaticallyExcluded.addAll(getAllParents(newParentKey));
     }
-    for (final ProjectInfo child : listChildProjects.apply(
-        new ProjectResource(parent))) {
+    for (final ProjectInfo child : listChildProjects.apply(new ProjectResource(parent))) {
       final Project.NameKey childName = new Project.NameKey(child.name);
       if (!excluded.contains(childName)) {
         if (!automaticallyExcluded.contains(childName)) {
           childProjects.add(childName);
         } else {
-          stdout.println("Automatically excluded '" + childName + "' " +
-                         "from reparenting because it is in the parent " +
-                         "line of the new parent '" + newParentKey + "'.");
+          stdout.println(
+              "Automatically excluded '"
+                  + childName
+                  + "' "
+                  + "from reparenting because it is in the parent "
+                  + "line of the new parent '"
+                  + newParentKey
+                  + "'.");
         }
       }
     }
     return childProjects;
   }
 
-  private Set<Project.NameKey> getAllParents(final Project.NameKey projectName) {
+  private Set<Project.NameKey> getAllParents(Project.NameKey projectName) {
     ProjectState ps = projectCache.get(projectName);
-    return ImmutableSet.copyOf(Iterables.transform(
-      ps != null ? ps.parents() : Collections.<ProjectState> emptySet(),
-      new Function<ProjectState, Project.NameKey> () {
-        @Override
-        public Project.NameKey apply(ProjectState in) {
-          return in.getProject().getNameKey();
-        }
-      }));
+    if (ps == null) {
+      return Collections.emptySet();
+    }
+    return ps.parents().transform(s -> s.getProject().getNameKey()).toSet();
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
index fb961d8..92098cc 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
@@ -16,7 +16,8 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
-
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedElement;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
@@ -26,9 +27,6 @@
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Setter;
 
-import java.lang.annotation.Annotation;
-import java.lang.reflect.AnnotatedElement;
-
 final class ApproveOption implements Option, Setter<Short> {
   private final String name;
   private final String usage;
@@ -124,15 +122,13 @@
     private final ApproveOption cmdOption;
 
     // CS IGNORE RedundantModifier FOR NEXT 1 LINES. REASON: needed by org.kohsuke.args4j.Option
-    public Handler(final CmdLineParser parser, final OptionDef option,
-        final Setter<Short> setter) {
+    public Handler(final CmdLineParser parser, final OptionDef option, final Setter<Short> setter) {
       super(parser, option, setter);
       this.cmdOption = (ApproveOption) setter;
     }
 
     @Override
-    protected Short parse(final String token) throws NumberFormatException,
-        CmdLineException {
+    protected Short parse(final String token) throws NumberFormatException, CmdLineException {
       String argument = token;
       if (argument.startsWith("+")) {
         argument = argument.substring(1);
@@ -145,8 +141,15 @@
       if (value < min.getValue() || value > max.getValue()) {
         final String name = cmdOption.name();
         final String e =
-            "\"" + token + "\" must be in range " + min.formatValue() + ".."
-                + max.formatValue() + " for \"" + name + "\"";
+            "\""
+                + token
+                + "\" must be in range "
+                + min.formatValue()
+                + ".."
+                + max.formatValue()
+                + " for \""
+                + name
+                + "\"";
         throw new CmdLineException(owner, e);
       }
       return value;
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 12f69ed..d3db70d 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
@@ -23,18 +23,16 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-import java.util.List;
-
-@CommandMetaData(name = "apropos", description = "Search in Gerrit documentation",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "apropos",
+    description = "Search in Gerrit documentation",
+    runsAt = MASTER_OR_SLAVE)
 final class AproposCommand extends SshCommand {
-  @Inject
-  private QueryDocumentationExecutor searcher;
-  @Inject
-  @CanonicalWebUrl String url;
+  @Inject private QueryDocumentationExecutor searcher;
+  @Inject @CanonicalWebUrl String url;
 
   @Argument(index = 0, required = true, metaVar = "QUERY")
   private String q;
@@ -44,8 +42,7 @@
     try {
       List<QueryDocumentationExecutor.DocResult> res = searcher.doQuery(q);
       for (DocResult docResult : res) {
-        stdout.println(String.format("%s:\n%s%s\n", docResult.title, url,
-            docResult.url));
+        stdout.println(String.format("%s:\n%s%s\n", docResult.title, url, docResult.url));
       }
     } catch (DocQueryException dqe) {
       throw die(dqe);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index f78b4df..51c65c6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -14,9 +14,8 @@
 
 package com.google.gerrit.sshd.commands;
 
-import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER;
 
-import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,43 +26,47 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-@CommandMetaData(name = "ban-commit", description = "Ban a commit from a project's repository",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "ban-commit",
+    description = "Ban a commit from a project's repository",
+    runsAt = MASTER)
 public class BanCommitCommand extends SshCommand {
-  @Option(name = "--reason", aliases = {"-r"}, metaVar = "REASON", usage = "reason for banning the commit")
+  @Option(
+      name = "--reason",
+      aliases = {"-r"},
+      metaVar = "REASON",
+      usage = "reason for banning the commit")
   private String reason;
 
-  @Argument(index = 0, required = true, metaVar = "PROJECT",
+  @Argument(
+      index = 0,
+      required = true,
+      metaVar = "PROJECT",
       usage = "name of the project for which the commit should be banned")
   private ProjectControl projectControl;
 
-  @Argument(index = 1, required = true, multiValued = true, metaVar = "COMMIT",
+  @Argument(
+      index = 1,
+      required = true,
+      multiValued = true,
+      metaVar = "COMMIT",
       usage = "commit(s) that should be banned")
   private List<ObjectId> commitsToBan = new ArrayList<>();
 
-  @Inject
-  private BanCommit banCommit;
+  @Inject private BanCommit banCommit;
 
   @Override
   protected void run() throws Failure {
     try {
       BanCommit.Input input =
-          BanCommit.Input.fromCommits(Lists.transform(commitsToBan,
-              new Function<ObjectId, String>() {
-                @Override
-                public String apply(ObjectId oid) {
-                  return oid.getName();
-                }
-              }));
+          BanCommit.Input.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
       input.reason = reason;
 
       BanResultInfo r = banCommit.apply(new ProjectResource(projectControl), input);
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 9f31ddc..e66422a 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
@@ -25,31 +25,31 @@
 import com.google.gerrit.server.change.Revisions;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.nio.ByteBuffer;
 import org.eclipse.jgit.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-import java.nio.ByteBuffer;
-
 abstract class BaseTestPrologCommand extends SshCommand {
   private TestSubmitRuleInput input = new TestSubmitRuleInput();
 
-  @Inject
-  private ChangesCollection changes;
+  @Inject private ChangesCollection changes;
 
-  @Inject
-  private Revisions revisions;
+  @Inject private Revisions revisions;
 
   @Argument(index = 0, required = true, usage = "ChangeId to load in prolog environment")
   protected String changeId;
 
-  @Option(name = "-s",
-      usage = "Read prolog script from stdin instead of reading rules.pl from the refs/meta/config branch")
+  @Option(
+      name = "-s",
+      usage =
+          "Read prolog script from stdin instead of reading rules.pl from the refs/meta/config branch")
   protected boolean useStdin;
 
-  @Option(name = "--no-filters", aliases = {"-n"},
+  @Option(
+      name = "--no-filters",
+      aliases = {"-n"},
       usage = "Don't run the submit_filter/2 from the parent projects")
   void setNoFilters(boolean no) {
     input.filters = no ? Filters.SKIP : Filters.RUN;
@@ -60,17 +60,13 @@
   @Override
   protected final void run() throws UnloggedFailure {
     try {
-      RevisionResource revision = revisions.parse(
-          changes.parse(
-              TopLevelResource.INSTANCE,
-              IdString.fromUrl(changeId)),
-          IdString.fromUrl("current"));
+      RevisionResource revision =
+          revisions.parse(
+              changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId)),
+              IdString.fromUrl("current"));
       if (useStdin) {
         ByteBuffer buf = IO.readWholeStream(in, 4096);
-        input.rule = RawParseUtils.decode(
-            buf.array(),
-            buf.arrayOffset(),
-            buf.limit());
+        input.rule = RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit());
       }
       Object result = createView().apply(revision, input);
       OutputFormat.JSON.newGson().toJson(result, stdout);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java
index 83b9745..4640211 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -24,7 +24,9 @@
 import com.google.gerrit.sshd.SshDaemon;
 import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -34,28 +36,28 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
 /** Close specified SSH connections */
 @AdminHighPriorityCommand
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "close-connection",
-  description = "Close the specified SSH connection", runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "close-connection",
+    description = "Close the specified SSH connection",
+    runsAt = MASTER_OR_SLAVE)
 final class CloseConnection extends SshCommand {
 
   private static final Logger log = LoggerFactory.getLogger(CloseConnection.class);
 
-  @Inject
-  private SshDaemon sshDaemon;
+  @Inject private SshDaemon sshDaemon;
 
-  @Argument(index = 0, multiValued = true, required = true,
-      metaVar = "SESSION_ID", usage = "List of SSH session IDs to be closed")
+  @Argument(
+      index = 0,
+      multiValued = true,
+      required = true,
+      metaVar = "SESSION_ID",
+      usage = "List of SSH session IDs to be closed")
   private final List<String> sessionIds = new ArrayList<>();
 
-  @Option(name = "--wait",
-      usage = "wait for connection to close before exiting")
+  @Option(name = "--wait", usage = "wait for connection to close before exiting")
   private boolean wait;
 
   @Override
@@ -70,9 +72,7 @@
       for (IoSession io : acceptor.getManagedSessions().values()) {
         AbstractSession serverSession = AbstractSession.getSession(io, true);
         SshSession sshSession =
-            serverSession != null
-                ? serverSession.getAttribute(SshSession.KEY)
-                : null;
+            serverSession != null ? serverSession.getAttribute(SshSession.KEY) : null;
         if (sshSession != null && sshSession.getSessionId() == id) {
           connectionFound = true;
           stdout.println("closing connection " + sessionId + "...");
@@ -82,8 +82,7 @@
               future.await();
               stdout.println("closed connection " + sessionId);
             } catch (IOException e) {
-              log.warn("Wait for connection to close interrupted: "
-                  + e.getMessage());
+              log.warn("Wait for connection to close interrupted: " + e.getMessage());
             }
           }
           break;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index d3ff06f..93b5695 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -16,7 +16,6 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Function;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -29,22 +28,24 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 
-/** Create a new user account. **/
+/** Create a new user account. * */
 @RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
 @CommandMetaData(name = "create-account", description = "Create a new batch/role account")
 final class CreateAccountCommand extends SshCommand {
-  @Option(name = "--group", aliases = {"-g"}, metaVar = "GROUP", usage = "groups to add account to")
+  @Option(
+      name = "--group",
+      aliases = {"-g"},
+      metaVar = "GROUP",
+      usage = "groups to add account to")
   private List<AccountGroup.Id> groups = new ArrayList<>();
 
   @Option(name = "--full-name", metaVar = "NAME", usage = "display name of the account")
@@ -56,30 +57,26 @@
   @Option(name = "--ssh-key", metaVar = "-|KEY", usage = "public key for SSH authentication")
   private String sshKey;
 
-  @Option(name = "--http-password", metaVar = "PASSWORD", usage = "password for HTTP authentication")
+  @Option(
+      name = "--http-password",
+      metaVar = "PASSWORD",
+      usage = "password for HTTP authentication")
   private String httpPassword;
 
   @Argument(index = 0, required = true, metaVar = "USERNAME", usage = "name of the user account")
   private String username;
 
-  @Inject
-  private CreateAccount.Factory createAccountFactory;
+  @Inject private CreateAccount.Factory createAccountFactory;
 
   @Override
-  protected void run() throws OrmException, IOException, ConfigInvalidException,
-      UnloggedFailure {
+  protected void run() throws OrmException, IOException, ConfigInvalidException, UnloggedFailure {
     AccountInput input = new AccountInput();
     input.username = username;
     input.email = email;
     input.name = fullName;
     input.sshKey = readSshKey();
     input.httpPassword = httpPassword;
-    input.groups = Lists.transform(groups, new Function<AccountGroup.Id, String>() {
-      @Override
-      public String apply(AccountGroup.Id id) {
-        return id.toString();
-      }
-    });
+    input.groups = Lists.transform(groups, AccountGroup.Id::toString);
     try {
       createAccountFactory.create(username).apply(TopLevelResource.INSTANCE, input);
     } catch (RestApiException e) {
@@ -93,8 +90,7 @@
     }
     if ("-".equals(sshKey)) {
       sshKey = "";
-      BufferedReader br =
-          new BufferedReader(new InputStreamReader(in, UTF_8));
+      BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8));
       String line;
       while ((line = br.readLine()) != null) {
         sshKey += line + "\n";
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 d3ec69c..46cfc9a 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
@@ -21,10 +21,9 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
 import org.kohsuke.args4j.Argument;
 
-/** Create a new branch. **/
+/** Create a new branch. * */
 @CommandMetaData(name = "create-branch", description = "Create a new branch")
 public final class CreateBranchCommand extends SshCommand {
 
@@ -34,19 +33,21 @@
   @Argument(index = 1, required = true, metaVar = "NAME", usage = "name of branch to be created")
   private String name;
 
-  @Argument(index = 2, required = true, metaVar = "REVISION", usage = "base revision of the new branch")
+  @Argument(
+      index = 2,
+      required = true,
+      metaVar = "REVISION",
+      usage = "base revision of the new branch")
   private String revision;
 
-  @Inject
-  GerritApi gApi;
+  @Inject GerritApi gApi;
 
   @Override
   protected void run() throws UnloggedFailure {
     try {
       BranchInput in = new BranchInput();
       in.revision = revision;
-      gApi.projects().name(project.getProject().getNameKey().get())
-          .branch(name).create(in);
+      gApi.projects().name(project.getProject().getNameKey().get()).branch(name).create(in);
     } catch (RestApiException e) {
       throw die(e);
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 22f9683..1446f84 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.common.base.Function;
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -34,26 +34,32 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.util.HashSet;
 import java.util.Set;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 
 /**
  * Creates a new group.
- * <p>
- * Optionally, puts an initial set of user in the newly created group.
+ *
+ * <p>Optionally, puts an initial set of user in the newly created group.
  */
 @RequiresCapability(GlobalCapability.CREATE_GROUP)
 @CommandMetaData(name = "create-group", description = "Create a new account group")
 final class CreateGroupCommand extends SshCommand {
-  @Option(name = "--owner", aliases = {"-o"}, metaVar = "GROUP", usage = "owning group, if not specified the group will be self-owning")
+  @Option(
+      name = "--owner",
+      aliases = {"-o"},
+      metaVar = "GROUP",
+      usage = "owning group, if not specified the group will be self-owning")
   private AccountGroup.Id ownerGroupId;
 
-  @Option(name = "--description", aliases = {"-d"}, metaVar = "DESC", usage = "description of group")
+  @Option(
+      name = "--description",
+      aliases = {"-d"},
+      metaVar = "DESC",
+      usage = "description of group")
   private String groupDescription = "";
 
   @Argument(index = 0, required = true, metaVar = "GROUP", usage = "name of group to be created")
@@ -61,7 +67,11 @@
 
   private final Set<Account.Id> initialMembers = new HashSet<>();
 
-  @Option(name = "--member", aliases = {"-m"}, metaVar = "USERNAME", usage = "initial set of users to become members of the group")
+  @Option(
+      name = "--member",
+      aliases = {"-m"},
+      metaVar = "USERNAME",
+      usage = "initial set of users to become members of the group")
   void addMember(final Account.Id id) {
     initialMembers.add(id);
   }
@@ -71,22 +81,22 @@
 
   private final Set<AccountGroup.UUID> initialGroups = new HashSet<>();
 
-  @Option(name = "--group", aliases = "-g", metaVar = "GROUP", usage = "initial set of groups to be included in the group")
+  @Option(
+      name = "--group",
+      aliases = "-g",
+      metaVar = "GROUP",
+      usage = "initial set of groups to be included in the group")
   void addGroup(final AccountGroup.UUID id) {
     initialGroups.add(id);
   }
 
-  @Inject
-  private CreateGroup.Factory createGroupFactory;
+  @Inject private CreateGroup.Factory createGroupFactory;
 
-  @Inject
-  private GroupsCollection groups;
+  @Inject private GroupsCollection groups;
 
-  @Inject
-  private AddMembers addMembers;
+  @Inject private AddMembers addMembers;
 
-  @Inject
-  private AddIncludedGroups addIncludedGroups;
+  @Inject private AddIncludedGroups addIncludedGroups;
 
   @Override
   protected void run() throws Failure, OrmException, IOException {
@@ -105,8 +115,7 @@
     }
   }
 
-  private GroupResource createGroup()
-      throws RestApiException, OrmException, IOException {
+  private GroupResource createGroup() throws RestApiException, OrmException, IOException {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
@@ -115,38 +124,21 @@
       input.ownerId = String.valueOf(ownerGroupId.get());
     }
 
-    GroupInfo group = createGroupFactory.create(groupName)
-        .apply(TopLevelResource.INSTANCE, input);
-    return groups.parse(TopLevelResource.INSTANCE,
-        IdString.fromUrl(group.id));
+    GroupInfo group = createGroupFactory.create(groupName).apply(TopLevelResource.INSTANCE, input);
+    return groups.parse(TopLevelResource.INSTANCE, IdString.fromUrl(group.id));
   }
 
-  private void addMembers(GroupResource rsrc) throws RestApiException,
-      OrmException, IOException {
+  private void addMembers(GroupResource rsrc) throws RestApiException, OrmException, IOException {
     AddMembers.Input input =
-        AddMembers.Input.fromMembers(FluentIterable
-            .from(initialMembers)
-            .transform(new Function<Account.Id, String>() {
-              @Override
-              public String apply(Account.Id id) {
-                return String.valueOf(id.get());
-              }
-            })
-            .toList());
+        AddMembers.Input.fromMembers(
+            initialMembers.stream().map(Object::toString).collect(toList()));
     addMembers.apply(rsrc, input);
   }
 
-  private void addIncludedGroups(GroupResource rsrc) throws RestApiException,
-      OrmException {
+  private void addIncludedGroups(GroupResource rsrc) throws RestApiException, OrmException {
     AddIncludedGroups.Input input =
-        AddIncludedGroups.Input.fromGroups(FluentIterable.from(initialGroups)
-            .transform(new Function<AccountGroup.UUID, String>() {
-              @Override
-              public String apply(AccountGroup.UUID id) {
-                return id.get();
-              }
-            }).toList());
-
+        AddIncludedGroups.Input.fromGroups(
+            initialGroups.stream().map(AccountGroup.UUID::get).collect(toList()));
     addIncludedGroups.apply(rsrc, input);
   }
 }
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 db4f313..cfefb7b 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
@@ -15,7 +15,6 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Function;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -34,35 +33,53 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 
-/** Create a new project. **/
+/** Create a new project. * */
 @RequiresCapability(GlobalCapability.CREATE_PROJECT)
-@CommandMetaData(name = "create-project", description = "Create a new project and associated Git repository")
+@CommandMetaData(
+    name = "create-project",
+    description = "Create a new project and associated Git repository")
 final class CreateProjectCommand extends SshCommand {
-  @Option(name = "--suggest-parents", aliases = {"-S"}, usage = "suggest parent candidates, "
-      + "if this option is used all other options and arguments are ignored")
+  @Option(
+      name = "--suggest-parents",
+      aliases = {"-S"},
+      usage =
+          "suggest parent candidates, "
+              + "if this option is used all other options and arguments are ignored")
   private boolean suggestParent;
 
-  @Option(name = "--owner", aliases = {"-o"}, usage = "owner(s) of project")
+  @Option(
+      name = "--owner",
+      aliases = {"-o"},
+      usage = "owner(s) of project")
   private List<AccountGroup.UUID> ownerIds;
 
-  @Option(name = "--parent", aliases = {"-p"}, metaVar = "NAME", usage = "parent project")
+  @Option(
+      name = "--parent",
+      aliases = {"-p"},
+      metaVar = "NAME",
+      usage = "parent project")
   private ProjectControl newParent;
 
   @Option(name = "--permissions-only", usage = "create project for use only as parent")
   private boolean permissionsOnly;
 
-  @Option(name = "--description", aliases = {"-d"}, metaVar = "DESCRIPTION", usage = "description of project")
+  @Option(
+      name = "--description",
+      aliases = {"-d"},
+      metaVar = "DESCRIPTION",
+      usage = "description of project")
   private String projectDescription = "";
 
-  @Option(name = "--submit-type", aliases = {"-t"}, usage = "project submit type")
+  @Option(
+      name = "--submit-type",
+      aliases = {"-t"},
+      usage = "project submit type")
   private SubmitType submitType;
 
   @Option(name = "--contributor-agreements", usage = "if contributor agreement is required")
@@ -77,15 +94,23 @@
   @Option(name = "--change-id", usage = "if change-id is required")
   private InheritableBoolean requireChangeID = InheritableBoolean.INHERIT;
 
-  @Option(name = "--new-change-for-all-not-in-target", usage = "if a new change will be created for every commit not in target branch")
+  @Option(
+      name = "--new-change-for-all-not-in-target",
+      usage = "if a new change will be created for every commit not in target branch")
   private InheritableBoolean createNewChangeForAllNotInTarget = InheritableBoolean.INHERIT;
 
-  @Option(name = "--use-contributor-agreements", aliases = {"--ca"}, usage = "if contributor agreement is required")
+  @Option(
+      name = "--use-contributor-agreements",
+      aliases = {"--ca"},
+      usage = "if contributor agreement is required")
   void setUseContributorArgreements(@SuppressWarnings("unused") boolean on) {
     contributorAgreements = InheritableBoolean.TRUE;
   }
 
-  @Option(name = "--use-signed-off-by", aliases = {"--so"}, usage = "if signed-off-by is required")
+  @Option(
+      name = "--use-signed-off-by",
+      aliases = {"--so"},
+      usage = "if signed-off-by is required")
   void setUseSignedOffBy(@SuppressWarnings("unused") boolean on) {
     signedOffBy = InheritableBoolean.TRUE;
   }
@@ -95,19 +120,27 @@
     contentMerge = InheritableBoolean.TRUE;
   }
 
-  @Option(name = "--require-change-id", aliases = {"--id"}, usage = "if change-id is required")
+  @Option(
+      name = "--require-change-id",
+      aliases = {"--id"},
+      usage = "if change-id is required")
   void setRequireChangeId(@SuppressWarnings("unused") boolean on) {
     requireChangeID = InheritableBoolean.TRUE;
   }
 
-  @Option(name = "--create-new-change-for-all-not-in-target", aliases = {"--ncfa"},
+  @Option(
+      name = "--create-new-change-for-all-not-in-target",
+      aliases = {"--ncfa"},
       usage = "if a new change will be created for every commit not in target branch")
   void setNewChangeForAllNotInTarget(@SuppressWarnings("unused") boolean on) {
     createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
   }
 
-  @Option(name = "--branch", aliases = {"-b"}, metaVar = "BRANCH", usage = "initial branch name\n"
-      + "(default: master)")
+  @Option(
+      name = "--branch",
+      aliases = {"-b"},
+      metaVar = "BRANCH",
+      usage = "initial branch name\n(default: master)")
   private List<String> branch;
 
   @Option(name = "--empty-commit", usage = "to create initial empty commit")
@@ -116,18 +149,17 @@
   @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
   private String maxObjectSizeLimit;
 
-  @Option(name = "--plugin-config",
+  @Option(
+      name = "--plugin-config",
       usage = "plugin configuration parameter with format '<plugin-name>.<parameter-name>=<value>'")
   private List<String> pluginConfigValues;
 
   @Argument(index = 0, metaVar = "NAME", usage = "name of project to be created")
   private String projectName;
 
-  @Inject
-  private GerritApi gApi;
+  @Inject private GerritApi gApi;
 
-  @Inject
-  private SuggestParentCandidates suggestParentCandidates;
+  @Inject private SuggestParentCandidates suggestParentCandidates;
 
   @Override
   protected void run() throws UnloggedFailure {
@@ -140,13 +172,7 @@
         ProjectInput input = new ProjectInput();
         input.name = projectName;
         if (ownerIds != null) {
-          input.owners = Lists.transform(ownerIds,
-            new Function<AccountGroup.UUID, String>() {
-              @Override
-              public String apply(AccountGroup.UUID uuid) {
-                return uuid.get();
-              }
-            });
+          input.owners = Lists.transform(ownerIds, AccountGroup.UUID::get);
         }
         if (newParent != null) {
           input.parent = newParent.getProject().getName();
@@ -168,8 +194,7 @@
 
         gApi.projects().create(input);
       } else {
-        List<Project.NameKey> parentCandidates =
-            suggestParentCandidates.getNameKeys();
+        List<Project.NameKey> parentCandidates = suggestParentCandidates.getNameKeys();
 
         for (Project.NameKey parent : parentCandidates) {
           stdout.print(parent + "\n");
@@ -181,17 +206,18 @@
   }
 
   @VisibleForTesting
-  Map<String, Map<String, ConfigValue>> parsePluginConfigValues(
-      List<String> pluginConfigValues) throws UnloggedFailure {
+  Map<String, Map<String, ConfigValue>> parsePluginConfigValues(List<String> pluginConfigValues)
+      throws UnloggedFailure {
     Map<String, Map<String, ConfigValue>> m = new HashMap<>();
     for (String pluginConfigValue : pluginConfigValues) {
       String[] s = pluginConfigValue.split("=");
       String[] s2 = s[0].split("\\.");
       if (s.length != 2 || s2.length != 2) {
-        throw die("Invalid plugin config value '"
-            + pluginConfigValue
-            + "', expected format '<plugin-name>.<parameter-name>=<value>'"
-            + " or '<plugin-name>.<parameter-name>=<value1,value2,...>'");
+        throw die(
+            "Invalid plugin config value '"
+                + pluginConfigValue
+                + "', expected format '<plugin-name>.<parameter-name>=<value>'"
+                + " or '<plugin-name>.<parameter-name>=<value1,value2,...>'");
       }
       ConfigValue value = new ConfigValue();
       String v = s[1];
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index c9cdbe0..ba99155 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -23,14 +23,13 @@
 import com.google.gerrit.sshd.SuExec;
 import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
 
-
 /** Register the commands a Gerrit server supports. */
 public class DefaultCommandModule extends CommandModule {
   private final DownloadConfig downloadConfig;
   private final LfsPluginAuthCommand.Module lfsPluginAuthModule;
 
-  public DefaultCommandModule(boolean slave, DownloadConfig downloadCfg,
-      LfsPluginAuthCommand.Module module) {
+  public DefaultCommandModule(
+      boolean slave, DownloadConfig downloadCfg, LfsPluginAuthCommand.Module module) {
     slaveMode = slave;
     downloadConfig = downloadCfg;
     lfsPluginAuthModule = module;
@@ -96,15 +95,13 @@
       command("git-receive-pack").to(NotSupportedInSlaveModeFailureCommand.class);
       command("gerrit-receive-pack").to(NotSupportedInSlaveModeFailureCommand.class);
       command(git, "receive-pack").to(NotSupportedInSlaveModeFailureCommand.class);
-      command(gerrit, "test-submit").to(NotSupportedInSlaveModeFailureCommand.class);
     } else {
       if (sshEnabled()) {
         command("git-receive-pack").to(Commands.key(git, "receive-pack"));
         command("gerrit-receive-pack").to(Commands.key(git, "receive-pack"));
         command(git, "receive-pack").to(Commands.key(gerrit, "receive-pack"));
       }
-      command(gerrit, "test-submit").toProvider(
-          new DispatchCommandProvider(testSubmit));
+      command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
     }
     command(gerrit, Receive.class);
 
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 1f03225..f2a466d 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
@@ -29,16 +29,16 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Option;
-
 import java.util.ArrayList;
 import java.util.List;
+import org.kohsuke.args4j.Option;
 
 /** Causes the caches to purge all entries and reload. */
 @RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
-@CommandMetaData(name = "flush-caches", description = "Flush some/all server caches from memory",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "flush-caches",
+    description = "Flush some/all server caches from memory",
+    runsAt = MASTER_OR_SLAVE)
 final class FlushCaches extends SshCommand {
   @Option(name = "--cache", usage = "flush named cache", metaVar = "NAME")
   private List<String> caches = new ArrayList<>();
@@ -49,11 +49,9 @@
   @Option(name = "--list", usage = "list available caches")
   private boolean list;
 
-  @Inject
-  private ListCaches listCaches;
+  @Inject private ListCaches listCaches;
 
-  @Inject
-  private PostCaches postCaches;
+  @Inject private PostCaches postCaches;
 
   @Override
   protected void run() throws Failure {
@@ -76,11 +74,9 @@
       }
 
       if (all) {
-        postCaches.apply(new ConfigResource(),
-            new PostCaches.Input(FLUSH_ALL));
+        postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH_ALL));
       } else {
-        postCaches.apply(new ConfigResource(),
-            new PostCaches.Input(FLUSH, caches));
+        postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH, caches));
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
@@ -89,8 +85,8 @@
 
   @SuppressWarnings("unchecked")
   private void doList() {
-    for (String name : (List<String>) listCaches
-        .setFormat(OutputFormat.LIST).apply(new ConfigResource())) {
+    for (String name :
+        (List<String>) listCaches.setFormat(OutputFormat.LIST).apply(new ConfigResource())) {
       stderr.print(name);
       stderr.print('\n');
     }
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 520d194..c4b4d60 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
@@ -28,17 +28,14 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.util.ArrayList;
+import java.util.List;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-import java.util.ArrayList;
-import java.util.List;
-
 /** Runs the Git garbage collection. */
 @RequiresAnyCapability({RUN_GC, MAINTAIN_SERVER})
-@CommandMetaData(name = "gc", description = "Run Git garbage collection",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "gc", description = "Run Git garbage collection", runsAt = MASTER_OR_SLAVE)
 public class GarbageCollectionCommand extends SshCommand {
 
   @Option(name = "--all", usage = "runs the Git garbage collection for all projects")
@@ -50,15 +47,17 @@
   @Option(name = "--aggressive", usage = "run aggressive garbage collection")
   private boolean aggressive;
 
-  @Argument(index = 0, required = false, multiValued = true, metaVar = "NAME",
+  @Argument(
+      index = 0,
+      required = false,
+      multiValued = true,
+      metaVar = "NAME",
       usage = "projects for which the Git garbage collection should be run")
   private List<ProjectControl> projects = new ArrayList<>();
 
-  @Inject
-  private ProjectCache projectCache;
+  @Inject private ProjectCache projectCache;
 
-  @Inject
-  private GarbageCollection.Factory garbageCollectionFactory;
+  @Inject private GarbageCollection.Factory garbageCollectionFactory;
 
   @Override
   public void run() throws Exception {
@@ -71,8 +70,7 @@
       throw die("needs projects as command arguments or --all option");
     }
     if (all && !projects.isEmpty()) {
-      throw die(
-          "either specify projects as command arguments or use --all option");
+      throw die("either specify projects as command arguments or use --all option");
     }
   }
 
@@ -88,8 +86,9 @@
     }
 
     GarbageCollectionResult result =
-        garbageCollectionFactory.create().run(projectNames, aggressive,
-            showProgress ? stdout : null);
+        garbageCollectionFactory
+            .create()
+            .run(projectNames, aggressive, showProgress ? stdout : null);
     if (result.hasErrors()) {
       for (GarbageCollectionResult.Error e : result.getErrors()) {
         String msg;
@@ -98,16 +97,20 @@
             msg = "error: project \"" + e.getProjectName() + "\" not found";
             break;
           case GC_ALREADY_SCHEDULED:
-            msg = "error: garbage collection for project \""
-                + e.getProjectName() + "\" was already scheduled";
+            msg =
+                "error: garbage collection for project \""
+                    + e.getProjectName()
+                    + "\" was already scheduled";
             break;
           case GC_FAILED:
-            msg = "error: garbage collection for project \"" + e.getProjectName()
-                + "\" failed";
+            msg = "error: garbage collection for project \"" + e.getProjectName() + "\" failed";
             break;
           default:
-            msg = "error: garbage collection for project \"" + e.getProjectName()
-                + "\" failed: " + e.getType();
+            msg =
+                "error: garbage collection for project \""
+                    + e.getProjectName()
+                    + "\" failed: "
+                    + e.getType();
         }
         stdout.print(msg + "\n");
       }
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 4991700..d932114 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
@@ -16,33 +16,33 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.lucene.LuceneVersionManager;
-import com.google.gerrit.lucene.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.AbstractVersionManager;
+import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
 import org.kohsuke.args4j.Argument;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "activate",
-  description = "Activate the latest index version available")
+@CommandMetaData(name = "activate", description = "Activate the latest index version available")
 public class IndexActivateCommand extends SshCommand {
 
-  @Argument(index = 0, required = true, metaVar = "INDEX",
-      usage = "index name to activate")
+  @Argument(index = 0, required = true, metaVar = "INDEX", usage = "index name to activate")
   private String name;
 
-  @Inject
-  private LuceneVersionManager luceneVersionManager;
+  @Inject private AbstractVersionManager versionManager;
 
   @Override
   protected void run() throws UnloggedFailure {
     try {
-      if (luceneVersionManager.activateLatestIndex(name)) {
-        stdout.println("Activated latest index version");
+      if (versionManager.isKnownIndex(name)) {
+        if (versionManager.activateLatestIndex(name)) {
+          stdout.println("Activated latest index version");
+        } else {
+          stdout.println("Not activating index, already using latest version");
+        }
       } else {
-        stdout.println("Not activating index, already using latest version");
+        stderr.println(String.format("Cannot activate index %s: unknown", name));
       }
     } catch (ReindexerAlreadyRunningException e) {
       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
index f7d5c87..ce01211 100644
--- 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
@@ -23,22 +23,22 @@
 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;
+import org.kohsuke.args4j.Argument;
 
 @CommandMetaData(name = "changes", description = "Index changes")
 final class IndexChangesCommand extends SshCommand {
-  @Inject
-  private Index index;
+  @Inject private Index index;
 
-  @Inject
-  private ChangeArgumentParser changeArgumentParser;
+  @Inject private ChangeArgumentParser changeArgumentParser;
 
-  @Argument(index = 0, required = true, multiValued = true, metaVar = "CHANGE",
+  @Argument(
+      index = 0,
+      required = true,
+      multiValued = true,
+      metaVar = "CHANGE",
       usage = "changes to index")
   void addChange(String token) {
     try {
@@ -58,8 +58,8 @@
         index.apply(rsrc, new Index.Input());
       } catch (IOException | RestApiException | OrmException e) {
         ok = false;
-        writeError("error", String.format(
-            "failed to index change %s: %s", rsrc.getId(), e.getMessage()));
+        writeError(
+            "error", String.format("failed to index change %s: %s", rsrc.getId(), e.getMessage()));
       }
     }
     if (!ok) {
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 633bca8..5d1f955 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
@@ -14,20 +14,32 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.server.index.AbstractVersionManager;
 import com.google.gerrit.sshd.CommandModule;
 import com.google.gerrit.sshd.CommandName;
 import com.google.gerrit.sshd.Commands;
 import com.google.gerrit.sshd.DispatchCommandProvider;
+import com.google.inject.Injector;
+import com.google.inject.Key;
 
 public class IndexCommandsModule extends CommandModule {
 
+  private final Injector injector;
+
+  public IndexCommandsModule(Injector injector) {
+    this.injector = injector;
+  }
+
   @Override
   protected void configure() {
     CommandName gerrit = Commands.named("gerrit");
     CommandName index = Commands.named(gerrit, "index");
     command(index).toProvider(new DispatchCommandProvider(index));
-    command(index, IndexActivateCommand.class);
-    command(index, IndexStartCommand.class);
+    if (injector.getExistingBinding(Key.get(AbstractVersionManager.class)) != null) {
+      command(index, IndexActivateCommand.class);
+      command(index, IndexStartCommand.class);
+    }
     command(index, IndexChangesCommand.class);
+    command(index, IndexProjectCommand.class);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
new file mode 100644
index 0000000..9c8c01c
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.server.project.Index;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Argument;
+
+@RequiresAnyCapability({MAINTAIN_SERVER})
+@CommandMetaData(name = "project", description = "Index changes of a project")
+final class IndexProjectCommand extends SshCommand {
+
+  @Inject private Index index;
+
+  @Argument(
+      index = 0,
+      required = true,
+      multiValued = true,
+      metaVar = "PROJECT",
+      usage = "projects for which the changes should be indexed")
+  private List<ProjectControl> projects = new ArrayList<>();
+
+  @Override
+  protected void run() throws UnloggedFailure, Failure, Exception {
+    if (projects.isEmpty()) {
+      throw die("needs at least one project as command arguments");
+    }
+    projects.stream().forEach(this::index);
+  }
+
+  private void index(ProjectControl projectControl) {
+    try {
+      index.apply(new ProjectResource(projectControl), null);
+    } catch (Exception e) {
+      writeError(
+          "error",
+          String.format(
+              "Unable to index %s: %s", projectControl.getProject().getName(), e.getMessage()));
+    }
+  }
+}
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 6629e3c..1f75c9a 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
@@ -16,12 +16,11 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.lucene.LuceneVersionManager;
-import com.google.gerrit.lucene.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.AbstractVersionManager;
+import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
@@ -32,20 +31,22 @@
   @Option(name = "--force", usage = "force a re-index")
   private boolean force;
 
-  @Argument(index = 0, required = true, metaVar = "INDEX",
-      usage = "index name to start")
+  @Argument(index = 0, required = true, metaVar = "INDEX", usage = "index name to start")
   private String name;
 
-  @Inject
-  private LuceneVersionManager luceneVersionManager;
+  @Inject private AbstractVersionManager versionManager;
 
   @Override
   protected void run() throws UnloggedFailure {
     try {
-      if (luceneVersionManager.startReindexer(name, force)) {
-        stdout.println("Reindexer started");
+      if (versionManager.isKnownIndex(name)) {
+        if (versionManager.startReindexer(name, force)) {
+          stdout.println("Reindexer started");
+        } else {
+          stdout.println("Nothing to reindex, index is already the latest version");
+        }
       } else {
-        stdout.println("Nothing to reindex, index is already the latest version");
+        stderr.println(String.format("Cannot reindex %s: unknown", name));
       }
     } catch (ReindexerAlreadyRunningException e) {
       throw die("Failed to start reindexer: " + e.getMessage());
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
index db9b0cc..4ebc568 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -28,21 +28,17 @@
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Argument;
-
 import java.util.ArrayList;
 import java.util.List;
+import org.kohsuke.args4j.Argument;
 
 /** Kill a task in the work queue. */
 @AdminHighPriorityCommand
 @RequiresAnyCapability({KILL_TASK, MAINTAIN_SERVER})
 final class KillCommand extends SshCommand {
-  @Inject
-  private TasksCollection tasksCollection;
+  @Inject private TasksCollection tasksCollection;
 
-  @Inject
-  private DeleteTask deleteTask;
+  @Inject private DeleteTask deleteTask;
 
   @Argument(index = 0, multiValued = true, required = true, metaVar = "ID")
   private final List<String> taskIds = new ArrayList<>();
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 2e11ef9..9bb4bd9 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
@@ -35,16 +35,15 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.io.PrintWriter;
 import org.kohsuke.args4j.Option;
 
-import java.io.PrintWriter;
-
-@CommandMetaData(name = "ls-groups", description = "List groups visible to the caller",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "ls-groups",
+    description = "List groups visible to the caller",
+    runsAt = MASTER_OR_SLAVE)
 public class ListGroupsCommand extends SshCommand {
-  @Inject
-  private MyListGroups impl;
+  @Inject private MyListGroups impl;
 
   @Override
   public void run() throws Exception {
@@ -59,15 +58,19 @@
     parseCommandLine(impl);
   }
 
-    private static class MyListGroups extends ListGroups {
-    @Option(name = "--verbose", aliases = {"-v"},
-        usage = "verbose output format with tab-separated columns for the " +
-            "group name, UUID, description, owner group name, " +
-            "owner group UUID, and whether the group is visible to all")
+  private static class MyListGroups extends ListGroups {
+    @Option(
+        name = "--verbose",
+        aliases = {"-v"},
+        usage =
+            "verbose output format with tab-separated columns for the "
+                + "group name, UUID, description, owner group name, "
+                + "owner group UUID, and whether the group is visible to all")
     private boolean verboseOutput;
 
     @Inject
-    MyListGroups(final GroupCache groupCache,
+    MyListGroups(
+        final GroupCache groupCache,
         final GroupControl.Factory groupControlFactory,
         final GroupControl.GenericFactory genericGroupControlFactory,
         final Provider<IdentifiedUser> identifiedUser,
@@ -75,8 +78,15 @@
         final GetGroups accountGetGroups,
         final GroupJson json,
         GroupBackend groupBackend) {
-      super(groupCache, groupControlFactory, genericGroupControlFactory,
-          identifiedUser, userFactory, accountGetGroups, json, groupBackend);
+      super(
+          groupCache,
+          groupControlFactory,
+          genericGroupControlFactory,
+          identifiedUser,
+          userFactory,
+          accountGetGroups,
+          json,
+          groupBackend);
     }
 
     void display(final PrintWriter out) throws OrmException, BadRequestException {
@@ -84,16 +94,17 @@
       for (final GroupInfo info : get()) {
         formatter.addColumn(MoreObjects.firstNonNull(info.name, "n/a"));
         if (verboseOutput) {
-          AccountGroup o = info.ownerId != null
-              ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
-              : null;
+          AccountGroup o =
+              info.ownerId != null
+                  ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
+                  : null;
 
           formatter.addColumn(Url.decode(info.id));
           formatter.addColumn(Strings.nullToEmpty(info.description));
           formatter.addColumn(o != null ? o.getName() : "n/a");
           formatter.addColumn(o != null ? o.getGroupUUID().get() : "");
-          formatter.addColumn(Boolean.toString(MoreObjects.firstNonNull(
-              info.options.visibleToAll, Boolean.FALSE)));
+          formatter.addColumn(
+              Boolean.toString(MoreObjects.firstNonNull(info.options.visibleToAll, Boolean.FALSE)));
         }
         formatter.nextLine();
       }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index bc6bc17..c8b8fa1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -20,18 +20,18 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-
+import java.util.Enumeration;
+import java.util.Map;
+import java.util.TreeMap;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
 import org.kohsuke.args4j.Argument;
 
-import java.util.Enumeration;
-import java.util.Map;
-import java.util.TreeMap;
-
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "ls-level", description = "list the level of loggers",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "ls-level",
+    description = "list the level of loggers",
+    runsAt = MASTER_OR_SLAVE)
 public class ListLoggingLevelCommand extends SshCommand {
 
   @Argument(index = 0, required = false, metaVar = "NAME", usage = "used to match loggers")
@@ -41,8 +41,7 @@
   @Override
   protected void run() {
     Map<String, String> logs = new TreeMap<>();
-    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger
-        .hasMoreElements();) {
+    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
       Logger log = logger.nextElement();
       if (name == null || log.getName().contains(name)) {
         logs.put(log.getName(), log.getEffectiveLevel().toString());
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index b24c4bfc..f3f9577 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -29,20 +29,17 @@
 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.PrintWriter;
 import java.util.List;
+import org.kohsuke.args4j.Argument;
 
-/**
- * Implements a command that allows the user to see the members of a group.
- */
-@CommandMetaData(name = "ls-members", description = "List the members of a given group",
-  runsAt = MASTER_OR_SLAVE)
+/** Implements a command that allows the user to see the members of a group. */
+@CommandMetaData(
+    name = "ls-members",
+    description = "List the members of a given group",
+    runsAt = MASTER_OR_SLAVE)
 public class ListMembersCommand extends SshCommand {
-  @Inject
-  ListMembersCommandImpl impl;
+  @Inject ListMembersCommandImpl impl;
 
   @Override
   public void run() throws Exception {
@@ -61,7 +58,8 @@
     private final GroupCache groupCache;
 
     @Inject
-    protected ListMembersCommandImpl(GroupCache groupCache,
+    protected ListMembersCommandImpl(
+        GroupCache groupCache,
         Factory groupDetailFactory,
         AccountLoader.Factory accountLoaderFactory) {
       super(groupCache, groupDetailFactory, accountLoaderFactory);
@@ -91,10 +89,8 @@
         }
 
         formatter.addColumn(Integer.toString(member._accountId));
-        formatter.addColumn(MoreObjects.firstNonNull(
-            member.username, "n/a"));
-        formatter.addColumn(MoreObjects.firstNonNull(
-            Strings.emptyToNull(member.name), "n/a"));
+        formatter.addColumn(MoreObjects.firstNonNull(member.username, "n/a"));
+        formatter.addColumn(MoreObjects.firstNonNull(Strings.emptyToNull(member.name), "n/a"));
         formatter.addColumn(MoreObjects.firstNonNull(member.email, "n/a"));
         formatter.nextLine();
       }
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 d81c153..1face7501 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
@@ -20,14 +20,14 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
 import java.util.List;
 
-@CommandMetaData(name = "ls-projects", description = "List projects visible to the caller",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "ls-projects",
+    description = "List projects visible to the caller",
+    runsAt = MASTER_OR_SLAVE)
 final class ListProjectsCommand extends SshCommand {
-  @Inject
-  private ListProjects impl;
+  @Inject private ListProjects impl;
 
   @Override
   public void run() throws Exception {
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 2173652..f5bb682 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
@@ -35,51 +35,51 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-import java.util.Map;
-
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "ls-user-refs", description = "List refs visible to a specific user",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "ls-user-refs",
+    description = "List refs visible to a specific user",
+    runsAt = MASTER_OR_SLAVE)
 public class LsUserRefs extends SshCommand {
-  @Inject
-  private AccountResolver accountResolver;
+  @Inject private AccountResolver accountResolver;
 
-  @Inject
-  private IdentifiedUser.GenericFactory userFactory;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject
-  private ReviewDb db;
+  @Inject private ReviewDb db;
 
-  @Inject
-  private TagCache tagCache;
+  @Inject private TagCache tagCache;
 
-  @Inject
-  private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
 
-  @Inject
-  @Nullable
-  private SearchingChangeCacheImpl changeCache;
+  @Inject @Nullable private SearchingChangeCacheImpl changeCache;
 
-  @Option(name = "--project", aliases = {"-p"}, metaVar = "PROJECT",
-      required = true, usage = "project for which the refs should be listed")
+  @Option(
+      name = "--project",
+      aliases = {"-p"},
+      metaVar = "PROJECT",
+      required = true,
+      usage = "project for which the refs should be listed")
   private ProjectControl projectControl;
 
-  @Option(name = "--user", aliases = {"-u"},  metaVar = "USER",
-      required = true, usage = "user for which the groups should be listed")
+  @Option(
+      name = "--user",
+      aliases = {"-u"},
+      metaVar = "USER",
+      required = true,
+      usage = "user for which the groups should be listed")
   private String userName;
 
   @Option(name = "--only-refs-heads", usage = "list only refs under refs/heads")
   private boolean onlyRefsHeads;
 
-  @Inject
-  private GitRepositoryManager repoManager;
+  @Inject private GitRepositoryManager repoManager;
 
   @Override
   protected void run() throws Failure {
@@ -98,13 +98,13 @@
 
     IdentifiedUser user = userFactory.create(userAccount.getId());
     ProjectControl userProjectControl = projectControl.forUser(user);
-    try (Repository repo = repoManager.openRepository(
-        userProjectControl.getProject().getNameKey())) {
+    try (Repository repo =
+        repoManager.openRepository(userProjectControl.getProject().getNameKey())) {
       try {
-        Map<String, Ref> refsMap = new VisibleRefFilter(
-                tagCache, changeNotesFactory, changeCache, repo,
-                userProjectControl, db, true)
-            .filter(repo.getRefDatabase().getRefs(ALL), false);
+        Map<String, Ref> refsMap =
+            new VisibleRefFilter(
+                    tagCache, changeNotesFactory, changeCache, repo, userProjectControl, db, true)
+                .filter(repo.getRefDatabase().getRefs(ALL), false);
 
         for (final String ref : refsMap.keySet()) {
           if (!onlyRefsHeads || ref.startsWith(RefNames.REFS_HEADS)) {
@@ -112,12 +112,11 @@
           }
         }
       } catch (IOException e) {
-        throw new Failure(1, "fatal: Error reading refs: '"
-            + projectControl.getProject().getNameKey(), e);
+        throw new Failure(
+            1, "fatal: Error reading refs: '" + projectControl.getProject().getNameKey(), e);
       }
     } catch (RepositoryNotFoundException e) {
-      throw die("'" + projectControl.getProject().getNameKey()
-          + "': not a git archive");
+      throw die("'" + projectControl.getProject().getNameKey() + "': not a git archive");
     } catch (IOException e) {
       throw die("Error opening: '" + projectControl.getProject().getNameKey());
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index a51876d..9b02f38 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -34,7 +34,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-
 import java.util.ArrayList;
 import java.util.List;
 
@@ -48,7 +47,8 @@
   private final Provider<CurrentUser> self;
 
   @Inject
-  PatchSetParser(Provider<ReviewDb> db,
+  PatchSetParser(
+      Provider<ReviewDb> db,
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       PatchSetUtil psUtil,
@@ -62,8 +62,8 @@
     this.self = self;
   }
 
-  public PatchSet parsePatchSet(String token, ProjectControl projectControl,
-      String branch) throws UnloggedFailure, OrmException {
+  public PatchSet parsePatchSet(String token, ProjectControl projectControl, String branch)
+      throws UnloggedFailure, OrmException {
     // By commit?
     //
     if (token.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) {
@@ -119,8 +119,11 @@
       if (projectControl != null || branch != null) {
         Change change = notes.getChange();
         if (!inProject(change, projectControl)) {
-          throw error("change " + change.getId() + " not in project "
-              + projectControl.getProject().getName());
+          throw error(
+              "change "
+                  + change.getId()
+                  + " not in project "
+                  + projectControl.getProject().getName());
         }
         if (!inBranch(change, branch)) {
           throw error("change " + change.getId() + " not in branch " + branch);
@@ -132,23 +135,20 @@
     throw error("\"" + token + "\" is not a valid patch set");
   }
 
-  private ChangeNotes getNotes(@Nullable ProjectControl projectControl,
-      Change.Id changeId) throws OrmException, UnloggedFailure {
+  private ChangeNotes getNotes(@Nullable ProjectControl projectControl, Change.Id changeId)
+      throws OrmException, UnloggedFailure {
     if (projectControl != null) {
-      return notesFactory.create(db.get(), projectControl.getProject().getNameKey(),
-          changeId);
+      return notesFactory.create(db.get(), projectControl.getProject().getNameKey(), changeId);
     }
     try {
       ChangeControl ctl = changeFinder.findOne(changeId, self.get());
-      return notesFactory.create(db.get(), ctl.getProject().getNameKey(),
-          changeId);
+      return notesFactory.create(db.get(), ctl.getProject().getNameKey(), changeId);
     } catch (NoSuchChangeException e) {
       throw error("\"" + changeId + "\" no such change");
     }
   }
 
-  private static boolean inProject(Change change,
-      ProjectControl projectControl) {
+  private static boolean inProject(Change change, ProjectControl projectControl) {
     if (projectControl == null) {
       // No --project option, so they want every project.
       return true;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
index 1d3ee9f..d7c8f3a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
@@ -24,20 +24,16 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-import java.util.List;
-
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "enable", description = "Enable plugins",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "enable", description = "Enable plugins", runsAt = MASTER_OR_SLAVE)
 final class PluginEnableCommand extends SshCommand {
   @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin(s) to enable")
   List<String> names;
 
-  @Inject
-  private PluginLoader loader;
+  @Inject private PluginLoader loader;
 
   @Override
   protected void run() throws UnloggedFailure {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
index 5d6621f..f649c75 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
@@ -24,23 +24,22 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
 import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.nio.file.Files;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "install", description = "Install/Add a plugin",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "install", description = "Install/Add a plugin", runsAt = MASTER_OR_SLAVE)
 final class PluginInstallCommand extends SshCommand {
-  @Option(name = "--name", aliases = {"-n"}, usage = "install under name")
+  @Option(
+      name = "--name",
+      aliases = {"-n"},
+      usage = "install under name")
   private String name;
 
   @Option(name = "-")
@@ -51,9 +50,9 @@
   @Argument(index = 0, metaVar = "-|URL", usage = "JAR to load")
   private String source;
 
-  @Inject
-  private PluginLoader loader;
+  @Inject private PluginLoader loader;
 
+  @SuppressWarnings("resource")
   @Override
   protected void run() throws UnloggedFailure {
     if (!loader.isRemoteAdminEnabled()) {
@@ -78,11 +77,10 @@
     InputStream data;
     if ("-".equalsIgnoreCase(source)) {
       data = in;
-    } else if (new File(source).isFile()
-        && source.equals(new File(source).getAbsolutePath())) {
+    } else if (new File(source).isFile() && source.equals(new File(source).getAbsolutePath())) {
       try {
-        data = new FileInputStream(new File(source));
-      } catch (FileNotFoundException e) {
+        data = Files.newInputStream(new File(source).toPath());
+      } catch (IOException e) {
         throw die("cannot read " + source);
       }
     } else {
@@ -100,8 +98,7 @@
       throw die("cannot install plugin");
     } catch (PluginInstallException e) {
       e.printStackTrace(stderr);
-      String msg =
-          String.format("Plugin failed to install. Cause: %s", e.getMessage());
+      String msg = String.format("Plugin failed to install. Cause: %s", e.getMessage());
       throw die(msg);
     } finally {
       try {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index d45d76e..78c9526 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -24,11 +24,9 @@
 import com.google.inject.Inject;
 
 @RequiresCapability(GlobalCapability.VIEW_PLUGINS)
-@CommandMetaData(name = "ls", description = "List the installed plugins",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "ls", description = "List the installed plugins", runsAt = MASTER_OR_SLAVE)
 final class PluginLsCommand extends SshCommand {
-  @Inject
-  private ListPlugins impl;
+  @Inject private ListPlugins impl;
 
   @Override
   public void run() throws Exception {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
index 4e195ca..0f2c912 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
@@ -24,20 +24,16 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-import java.util.List;
-
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "reload", description = "Reload/Restart plugins",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "reload", description = "Reload/Restart plugins", runsAt = MASTER_OR_SLAVE)
 final class PluginReloadCommand extends SshCommand {
   @Argument(index = 0, metaVar = "NAME", usage = "plugins to reload/restart")
   private List<String> names;
 
-  @Inject
-  private PluginLoader loader;
+  @Inject private PluginLoader loader;
 
   @Override
   protected void run() throws UnloggedFailure {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
index 8e87b0f..8a38739 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
@@ -23,20 +23,16 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-import java.util.List;
-
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "remove", description = "Disable plugins",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "remove", description = "Disable plugins", runsAt = MASTER_OR_SLAVE)
 final class PluginRemoveCommand extends SshCommand {
   @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin to remove")
   List<String> names;
 
-  @Inject
-  private PluginLoader loader;
+  @Inject private PluginLoader loader;
 
   @Override
   protected void run() throws UnloggedFailure {
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 8bde743..bf60776 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
@@ -19,16 +19,13 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.util.List;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-import java.util.List;
-
 @CommandMetaData(name = "query", description = "Query the change database")
 class Query extends SshCommand {
-  @Inject
-  private OutputStreamQuery processor;
+  @Inject private OutputStreamQuery processor;
 
   @Option(name = "--format", metaVar = "FMT", usage = "Output display format")
   void setFormat(OutputFormat format) {
@@ -45,7 +42,9 @@
     processor.setIncludePatchSets(on);
   }
 
-  @Option(name = "--all-approvals", usage = "Include information about all patch sets and approvals")
+  @Option(
+      name = "--all-approvals",
+      usage = "Include information about all patch sets and approvals")
   void setApprovals(boolean on) {
     if (on) {
       processor.setIncludePatchSets(on);
@@ -83,12 +82,20 @@
     processor.setIncludeSubmitRecords(on);
   }
 
-  @Option(name = "--start", aliases = {"-S"}, usage = "Number of changes to skip")
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      usage = "Number of changes to skip")
   void setStart(int start) {
     processor.setStart(start);
   }
 
-  @Argument(index = 0, required = true, multiValued = true, metaVar = "QUERY", usage = "Query to execute")
+  @Argument(
+      index = 0,
+      required = true,
+      multiValued = true,
+      metaVar = "QUERY",
+      usage = "Query to execute")
   private List<String> query;
 
   @Override
@@ -100,8 +107,8 @@
   protected void parseCommandLine() throws UnloggedFailure {
     processor.setOutput(out, OutputFormat.TEXT);
     super.parseCommandLine();
-    if (processor.getIncludeFiles() &&
-        !(processor.getIncludePatchSets() || processor.getIncludeCurrentPatchSet())) {
+    if (processor.getIncludeFiles()
+        && !(processor.getIncludePatchSets() || processor.getIncludeCurrentPatchSet())) {
       throw die("--files option needs --patch-sets or --current-patch-set");
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
index 25e49b2..4201c2c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
@@ -27,7 +27,6 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
@@ -53,7 +52,9 @@
   }
 
   public enum OutputFormat {
-    PRETTY, JSON, JSON_SINGLE
+    PRETTY,
+    JSON,
+    JSON_SINGLE
   }
 
   private final BufferedReader in;
@@ -66,8 +67,10 @@
   private Statement statement;
 
   @Inject
-  QueryShell(final SchemaFactory<ReviewDb> dbFactory,
-      @Assisted final InputStream in, @Assisted final OutputStream out) {
+  QueryShell(
+      final SchemaFactory<ReviewDb> dbFactory,
+      @Assisted final InputStream in,
+      @Assisted final OutputStream out) {
     this.dbFactory = dbFactory;
     this.in = new BufferedReader(new InputStreamReader(in, UTF_8));
     this.out = new PrintWriter(new OutputStreamWriter(out, UTF_8));
@@ -131,7 +134,7 @@
   private void readEvalPrintLoop() {
     final StringBuilder buffer = new StringBuilder();
     boolean executed = false;
-    for (;;) {
+    for (; ; ) {
       if (outputFormat == OutputFormat.PRETTY) {
         print(buffer.length() == 0 || executed ? "gerrit> " : "     -> ");
       }
@@ -176,13 +179,14 @@
           final String msg = "'\\" + line + "' not supported";
           switch (outputFormat) {
             case JSON_SINGLE:
-            case JSON: {
-              final JsonObject err = new JsonObject();
-              err.addProperty("type", "error");
-              err.addProperty("message", msg);
-              println(err.toString());
-              break;
-            }
+            case JSON:
+              {
+                final JsonObject err = new JsonObject();
+                err.addProperty("type", "error");
+                err.addProperty("message", msg);
+                println(err.toString());
+                break;
+              }
             case PRETTY:
             default:
               println("ERROR: " + msg);
@@ -224,7 +228,10 @@
       if (outputFormat == OutputFormat.PRETTY) {
         println("                     List of relations");
       }
-      showResultSet(rs, false, 0,
+      showResultSet(
+          rs,
+          false,
+          0,
           Identity.create(rs, "TABLE_SCHEM"),
           Identity.create(rs, "TABLE_NAME"),
           Identity.create(rs, "TABLE_TYPE"));
@@ -258,7 +265,10 @@
       if (outputFormat == OutputFormat.PRETTY) {
         println("                     Table " + tableName);
       }
-      showResultSet(rs, true, 0,
+      showResultSet(
+          rs,
+          true,
+          0,
           Identity.create(rs, "COLUMN_NAME"),
           new Function("TYPE") {
             @Override
@@ -355,14 +365,15 @@
         final long ms = TimeUtil.nowMs() - start;
         switch (outputFormat) {
           case JSON_SINGLE:
-          case JSON: {
-            final JsonObject tail = new JsonObject();
-            tail.addProperty("type", "update-stats");
-            tail.addProperty("rowCount", updateCount);
-            tail.addProperty("runTimeMilliseconds", ms);
-            println(tail.toString());
-            break;
-          }
+          case JSON:
+            {
+              final JsonObject tail = new JsonObject();
+              tail.addProperty("type", "update-stats");
+              tail.addProperty("rowCount", updateCount);
+              tail.addProperty("runTimeMilliseconds", ms);
+              println(tail.toString());
+              break;
+            }
 
           case PRETTY:
           default:
@@ -379,20 +390,19 @@
    * Outputs a result set to stdout.
    *
    * @param rs ResultSet to show.
-   * @param alreadyOnRow true if rs is already on the first row. false
-   *     otherwise.
-   * @param start Timestamp in milliseconds when executing the statement
-   *     started. This timestamp is used to compute statistics about the
-   *     statement. If no statistics should be shown, set it to 0.
+   * @param alreadyOnRow true if rs is already on the first row. false otherwise.
+   * @param start Timestamp in milliseconds when executing the statement started. This timestamp is
+   *     used to compute statistics about the statement. If no statistics should be shown, set it to
+   *     0.
    * @param show Functions to map columns
    * @throws SQLException
    */
-  private void showResultSet(final ResultSet rs, boolean alreadyOnRow,
-      long start, Function... show) throws SQLException {
+  private void showResultSet(final ResultSet rs, boolean alreadyOnRow, long start, Function... show)
+      throws SQLException {
     switch (outputFormat) {
       case JSON_SINGLE:
       case JSON:
-        showResultSetJson(rs, alreadyOnRow,  start, show);
+        showResultSetJson(rs, alreadyOnRow, start, show);
         break;
       case PRETTY:
       default:
@@ -405,16 +415,15 @@
    * Outputs a result set to stdout in Json format.
    *
    * @param rs ResultSet to show.
-   * @param alreadyOnRow true if rs is already on the first row. false
-   *     otherwise.
-   * @param start Timestamp in milliseconds when executing the statement
-   *     started. This timestamp is used to compute statistics about the
-   *     statement. If no statistics should be shown, set it to 0.
+   * @param alreadyOnRow true if rs is already on the first row. false otherwise.
+   * @param start Timestamp in milliseconds when executing the statement started. This timestamp is
+   *     used to compute statistics about the statement. If no statistics should be shown, set it to
+   *     0.
    * @param show Functions to map columns
    * @throws SQLException
    */
-  private void showResultSetJson(final ResultSet rs, boolean alreadyOnRow,
-      long start, Function... show) throws SQLException {
+  private void showResultSetJson(
+      final ResultSet rs, boolean alreadyOnRow, long start, Function... show) throws SQLException {
     JsonArray collector = new JsonArray();
     final ResultSetMetaData meta = rs.getMetaData();
     final Function[] columnMap;
@@ -497,16 +506,15 @@
    * Outputs a result set to stdout in plain text format.
    *
    * @param rs ResultSet to show.
-   * @param alreadyOnRow true if rs is already on the first row. false
-   *     otherwise.
-   * @param start Timestamp in milliseconds when executing the statement
-   *     started. This timestamp is used to compute statistics about the
-   *     statement. If no statistics should be shown, set it to 0.
+   * @param alreadyOnRow true if rs is already on the first row. false otherwise.
+   * @param start Timestamp in milliseconds when executing the statement started. This timestamp is
+   *     used to compute statistics about the statement. If no statistics should be shown, set it to
+   *     0.
    * @param show Functions to map columns
    * @throws SQLException
    */
-  private void showResultSetPretty(final ResultSet rs, boolean alreadyOnRow,
-      long start, Function... show) throws SQLException {
+  private void showResultSetPretty(
+      final ResultSet rs, boolean alreadyOnRow, long start, Function... show) throws SQLException {
     final ResultSetMetaData meta = rs.getMetaData();
 
     final Function[] columnMap;
@@ -608,21 +616,21 @@
     if (start != 0) {
       final int rowCount = rows.size();
       final long ms = TimeUtil.nowMs() - start;
-      println("(" + rowCount + (rowCount == 1 ? " row" : " rows")
-          + "; " + ms + " ms)");
+      println("(" + rowCount + (rowCount == 1 ? " row" : " rows") + "; " + ms + " ms)");
     }
   }
 
   private void warning(final String msg) {
     switch (outputFormat) {
       case JSON_SINGLE:
-      case JSON: {
-        final JsonObject obj = new JsonObject();
-        obj.addProperty("type", "warning");
-        obj.addProperty("message", msg);
-        println(obj.toString());
-        break;
-      }
+      case JSON:
+        {
+          final JsonObject obj = new JsonObject();
+          obj.addProperty("type", "warning");
+          obj.addProperty("message", msg);
+          println(obj.toString());
+          break;
+        }
 
       case PRETTY:
       default:
@@ -634,13 +642,14 @@
   private void error(final SQLException err) {
     switch (outputFormat) {
       case JSON_SINGLE:
-      case JSON: {
-        final JsonObject obj = new JsonObject();
-        obj.addProperty("type", "error");
-        obj.addProperty("message", err.getMessage());
-        println(obj.toString());
-        break;
-      }
+      case JSON:
+        {
+          final JsonObject obj = new JsonObject();
+          obj.addProperty("type", "error");
+          obj.addProperty("message", err.getMessage());
+          println(obj.toString());
+          break;
+        }
 
       case PRETTY:
       default:
@@ -717,8 +726,7 @@
   }
 
   private static class Identity extends Function {
-    static Identity create(final ResultSet rs, final String name)
-        throws SQLException {
+    static Identity create(final ResultSet rs, final String name) throws SQLException {
       return new Identity(rs.findColumn(name), name);
     }
 
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 011cb91..8852f0e 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
@@ -24,7 +24,12 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.errors.TooLargeObjectInPackException;
 import org.eclipse.jgit.errors.UnpackException;
 import org.eclipse.jgit.lib.Ref;
@@ -35,39 +40,34 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
 /** Receives change upload over SSH using the Git receive-pack protocol. */
-@CommandMetaData(name = "receive-pack", description = "Standard Git server side command for client side git push")
+@CommandMetaData(
+    name = "receive-pack",
+    description = "Standard Git server side command for client side git push")
 final class Receive extends AbstractGitCommand {
   private static final Logger log = LoggerFactory.getLogger(Receive.class);
 
-  @Inject
-  private AsyncReceiveCommits.Factory factory;
-
-  @Inject
-  private IdentifiedUser currentUser;
-
-  @Inject
-  private IdentifiedUser.GenericFactory identifiedUserFactory;
-
-  @Inject
-  private SshSession session;
+  @Inject private AsyncReceiveCommits.Factory factory;
+  @Inject private IdentifiedUser currentUser;
+  @Inject private SshSession session;
 
   private final Set<Account.Id> reviewerId = new HashSet<>();
   private final Set<Account.Id> ccId = new HashSet<>();
 
-  @Option(name = "--reviewer", aliases = {"--re"}, metaVar = "EMAIL", usage = "request reviewer for change(s)")
+  @Option(
+      name = "--reviewer",
+      aliases = {"--re"},
+      metaVar = "EMAIL",
+      usage = "request reviewer for change(s)")
   void addReviewer(final Account.Id id) {
     reviewerId.add(id);
   }
 
-  @Option(name = "--cc", aliases = {}, metaVar = "EMAIL", usage = "CC user on change(s)")
+  @Option(
+      name = "--cc",
+      aliases = {},
+      metaVar = "EMAIL",
+      usage = "CC user on change(s)")
   void addCC(final Account.Id id) {
     ccId.add(id);
   }
@@ -78,17 +78,13 @@
       throw new Failure(1, "fatal: receive-pack not permitted on this server");
     }
 
-    final ReceiveCommits receive = factory.create(projectControl, repo)
-        .getReceiveCommits();
+    final ReceiveCommits receive = factory.create(projectControl, repo).getReceiveCommits();
 
     Capable r = receive.canUpload();
     if (r != Capable.OK) {
       throw die(r.getMessage());
     }
 
-    verifyProjectVisible("reviewer", reviewerId);
-    verifyProjectVisible("CC", ccId);
-
     receive.init();
     receive.addReviewers(reviewerId);
     receive.addExtraCC(ccId);
@@ -103,7 +99,8 @@
       if (badStream.getCause() instanceof TooLargeObjectInPackException) {
         StringBuilder msg = new StringBuilder();
         msg.append("Receive error on project \"")
-           .append(projectControl.getProject().getName()).append("\"");
+            .append(projectControl.getProject().getName())
+            .append("\"");
         msg.append(" (user ");
         msg.append(currentUser.getAccount().getUserName());
         msg.append(" account ");
@@ -119,7 +116,8 @@
       //
       StringBuilder msg = new StringBuilder();
       msg.append("Unpack error on project \"")
-         .append(projectControl.getProject().getName()).append("\":\n");
+          .append(projectControl.getProject().getName())
+          .append("\":\n");
 
       msg.append("  AdvertiseRefsHook: ").append(rp.getAdvertiseRefsHook());
       if (rp.getAdvertiseRefsHook() == AdvertiseRefsHook.DEFAULT) {
@@ -135,12 +133,14 @@
         Map<String, Ref> adv = rp.getAdvertisedRefs();
         msg.append("  Visible references (").append(adv.size()).append("):\n");
         for (Ref ref : adv.values()) {
-          msg.append("  - ").append(ref.getObjectId().abbreviate(8).name())
-             .append(" ").append(ref.getName()).append("\n");
+          msg.append("  - ")
+              .append(ref.getObjectId().abbreviate(8).name())
+              .append(" ")
+              .append(ref.getName())
+              .append("\n");
         }
 
-        Map<String, Ref> allRefs =
-            rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
+        Map<String, Ref> allRefs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
         List<Ref> hidden = new ArrayList<>();
         for (Ref ref : allRefs.values()) {
           if (!adv.containsKey(ref.getName())) {
@@ -150,8 +150,11 @@
 
         msg.append("  Hidden references (").append(hidden.size()).append("):\n");
         for (Ref ref : hidden) {
-          msg.append("  - ").append(ref.getObjectId().abbreviate(8).name())
-              .append(" ").append(ref.getName()).append("\n");
+          msg.append("  - ")
+              .append(ref.getObjectId().abbreviate(8).name())
+              .append(" ")
+              .append(ref.getName())
+              .append("\n");
         }
       }
 
@@ -159,15 +162,4 @@
       throw new Failure(128, "fatal: Unpack error, check server log", detail);
     }
   }
-
-  private void verifyProjectVisible(final String type, final Set<Account.Id> who)
-      throws UnloggedFailure {
-    for (final Account.Id id : who) {
-      final IdentifiedUser user = identifiedUserFactory.create(id);
-      if (!projectControl.forUser(user).isVisible()) {
-        throw die(type + " "
-            + user.getAccount().getFullName() + " cannot access the project");
-      }
-    }
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
index c8ebb6c..331405a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -25,32 +25,33 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
+import java.io.IOException;
 import org.kohsuke.args4j.Argument;
 
 @CommandMetaData(name = "rename-group", description = "Rename an account group")
 public class RenameGroupCommand extends SshCommand {
-  @Argument(index = 0, required = true, metaVar = "GROUP", usage = "name of the group to be renamed")
+  @Argument(
+      index = 0,
+      required = true,
+      metaVar = "GROUP",
+      usage = "name of the group to be renamed")
   private String groupName;
 
   @Argument(index = 1, required = true, metaVar = "NEWNAME", usage = "new name of the group")
   private String newGroupName;
 
-  @Inject
-  private GroupsCollection groups;
+  @Inject private GroupsCollection groups;
 
-  @Inject
-  private PutName putName;
+  @Inject private PutName putName;
 
   @Override
   protected void run() throws Failure {
     try {
-      GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE,
-          IdString.fromDecoded(groupName));
+      GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
       PutName.Input input = new PutName.Input();
       input.name = newGroupName;
       putName.apply(rsrc, input);
-    } catch (RestApiException | OrmException | NoSuchGroupException e) {
+    } catch (RestApiException | OrmException | IOException | NoSuchGroupException e) {
       throw die(e);
     }
   }
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 02aab64..d038824 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
@@ -42,12 +42,6 @@
 import com.google.gson.JsonSyntaxException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.util.ArrayList;
@@ -57,11 +51,14 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeMap;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @CommandMetaData(name = "review", description = "Apply reviews to one or more patch sets")
 public class ReviewCommand extends SshCommand {
-  private static final Logger log =
-      LoggerFactory.getLogger(ReviewCommand.class);
+  private static final Logger log = LoggerFactory.getLogger(ReviewCommand.class);
 
   @Override
   protected final CmdLineParser newCmdLineParser(Object options) {
@@ -74,7 +71,10 @@
 
   private final Set<PatchSet> patchSets = new HashSet<>();
 
-  @Argument(index = 0, required = true, multiValued = true,
+  @Argument(
+      index = 0,
+      required = true,
+      multiValued = true,
       metaVar = "{COMMIT | CHANGE,PATCHSET}",
       usage = "list of commits or patch sets to review")
   void addPatchSetId(final String token) {
@@ -88,16 +88,27 @@
     }
   }
 
-  @Option(name = "--project", aliases = "-p", usage = "project containing the specified patch set(s)")
+  @Option(
+      name = "--project",
+      aliases = "-p",
+      usage = "project containing the specified patch set(s)")
   private ProjectControl projectControl;
 
   @Option(name = "--branch", aliases = "-b", usage = "branch containing the specified patch set(s)")
   private String branch;
 
-  @Option(name = "--message", aliases = "-m", usage = "cover message to publish on change(s)", metaVar = "MESSAGE")
+  @Option(
+      name = "--message",
+      aliases = "-m",
+      usage = "cover message to publish on change(s)",
+      metaVar = "MESSAGE")
   private String changeComment;
 
-  @Option(name = "--notify", aliases = "-n", usage = "Who to send email notifications to after the review is stored.", metaVar = "NOTIFYHANDLING")
+  @Option(
+      name = "--notify",
+      aliases = "-n",
+      usage = "Who to send email notifications to after the review is stored.",
+      metaVar = "NOTIFYHANDLING")
   private NotifyHandling notify;
 
   @Option(name = "--abandon", usage = "abandon the specified change(s)")
@@ -124,31 +135,36 @@
   @Option(name = "--json", aliases = "-j", usage = "read review input json from stdin")
   private boolean json;
 
-  @Option(name = "--strict-labels", usage = "Strictly check if the labels "
-      + "specified can be applied to the given patch set(s)")
+  @Option(
+      name = "--strict-labels",
+      usage = "Strictly check if the labels specified can be applied to the given patch set(s)")
   private boolean strictLabels;
 
-  @Option(name = "--tag", aliases = "-t", usage = "applies a tag to the given review", metaVar = "TAG")
+  @Option(
+      name = "--tag",
+      aliases = "-t",
+      usage = "applies a tag to the given review",
+      metaVar = "TAG")
   private String changeTag;
 
-  @Option(name = "--label", aliases = "-l", usage = "custom label(s) to assign", metaVar = "LABEL=VALUE")
+  @Option(
+      name = "--label",
+      aliases = "-l",
+      usage = "custom label(s) to assign",
+      metaVar = "LABEL=VALUE")
   void addLabel(final String token) {
     LabelVote v = LabelVote.parseWithEquals(token);
     LabelType.checkName(v.label()); // Disallow SUBM.
     customLabels.put(v.label(), v.value());
   }
 
-  @Inject
-  private ProjectControl.Factory projectControlFactory;
+  @Inject private ProjectControl.Factory projectControlFactory;
 
-  @Inject
-  private AllProjectsName allProjects;
+  @Inject private AllProjectsName allProjects;
 
-  @Inject
-  private GerritApi gApi;
+  @Inject private GerritApi gApi;
 
-  @Inject
-  private PatchSetParser psParser;
+  @Inject private PatchSetParser psParser;
 
   private List<ApproveOption> optionList;
   private Map<String, Short> customLabels;
@@ -245,12 +261,10 @@
         writeError("error", e.getMessage() + "\n");
       } catch (NoSuchChangeException e) {
         ok = false;
-        writeError("error",
-            "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 "
-            + patchSet.getId() + "\n");
+        writeError("fatal", "internal server error while reviewing " + patchSet.getId() + "\n");
         log.error("internal error while reviewing " + patchSet.getId(), e);
       }
     }
@@ -260,8 +274,7 @@
     }
   }
 
-  private void applyReview(PatchSet patchSet,
-      final ReviewInput review) throws RestApiException {
+  private void applyReview(PatchSet patchSet, final ReviewInput review) throws RestApiException {
     gApi.changes()
         .id(patchSet.getId().getParentKey().get())
         .revision(patchSet.getRevision().get())
@@ -270,8 +283,7 @@
 
   private ReviewInput reviewFromJson() throws UnloggedFailure {
     try (InputStreamReader r = new InputStreamReader(in, UTF_8)) {
-      return OutputFormat.JSON.newGson().
-          fromJson(CharStreams.toString(r), ReviewInput.class);
+      return OutputFormat.JSON.newGson().fromJson(CharStreams.toString(r), ReviewInput.class);
     } catch (IOException | JsonSyntaxException e) {
       writeError("error", e.getMessage() + '\n');
       throw die("internal error while reading review input");
@@ -364,9 +376,7 @@
     }
 
     for (LabelType type : allProjectsControl.getLabelTypes().getLabelTypes()) {
-      StringBuilder usage = new StringBuilder("score for ")
-        .append(type.getName())
-        .append("\n");
+      StringBuilder usage = new StringBuilder("score for ").append(type.getName()).append("\n");
 
       for (LabelValue v : type.getValues()) {
         usage.append(v.format()).append("\n");
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 5fc877c..ff45d75 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -24,19 +24,18 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.tools.ToolsCatalog;
 import com.google.gerrit.server.tools.ToolsCatalog.Entry;
 import com.google.gerrit.sshd.BaseCommand;
 import com.google.inject.Inject;
-
-import org.apache.sshd.server.Environment;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.ByteArrayOutputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
+import org.apache.sshd.server.Environment;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 final class ScpCommand extends BaseCommand {
   private static final String TYPE_DIR = "D";
@@ -48,8 +47,7 @@
   private boolean opt_f;
   private String root;
 
-  @Inject
-  private ToolsCatalog toc;
+  @Inject private ToolsCatalog toc;
   private IOException error;
 
   @Override
@@ -85,12 +83,14 @@
 
   @Override
   public void start(final Environment env) {
-    startThread(new Runnable() {
-      @Override
-      public void run() {
-        runImp();
-      }
-    });
+    startThread(
+        new Runnable() {
+          @Override
+          public void run() {
+            runImp();
+          }
+        },
+        AccessPath.SSH_COMMAND);
   }
 
   private void runImp() {
@@ -130,8 +130,7 @@
         throw new IOException("Unsupported mode");
       }
     } catch (IOException e) {
-      if (e.getClass() == IOException.class
-          && "Pipe closed".equals(e.getMessage())) {
+      if (e.getClass() == IOException.class && "Pipe closed".equals(e.getMessage())) {
         // Ignore a pipe closed error, its the user disconnecting from us
         // while we are waiting for them to stalk.
         //
@@ -140,7 +139,7 @@
 
       try {
         out.write(2);
-        out.write(e.getMessage().getBytes());
+        out.write(e.getMessage().getBytes(UTF_8));
         out.write('\n');
         out.flush();
       } catch (IOException e2) {
@@ -152,7 +151,7 @@
 
   private String readLine() throws IOException {
     ByteArrayOutputStream baos = new ByteArrayOutputStream();
-    for (;;) {
+    for (; ; ) {
       int c = in.read();
       if (c == '\n') {
         return baos.toString();
@@ -195,8 +194,8 @@
     readAck();
   }
 
-  private void header(final Entry dir, final int len) throws IOException,
-      UnsupportedEncodingException {
+  private void header(final Entry dir, final int len)
+      throws IOException, UnsupportedEncodingException {
     final StringBuilder buf = new StringBuilder();
     switch (dir.getType()) {
       case DIR:
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 082395c..d8ca77b 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
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -36,7 +37,6 @@
 import com.google.gerrit.server.account.DeleteEmail;
 import com.google.gerrit.server.account.DeleteSshKey;
 import com.google.gerrit.server.account.GetEmails;
-import com.google.gerrit.server.account.GetEmails.EmailInfo;
 import com.google.gerrit.server.account.GetSshKeys;
 import com.google.gerrit.server.account.PutActive;
 import com.google.gerrit.server.account.PutHttpPassword;
@@ -46,12 +46,6 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
@@ -59,13 +53,21 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 
-/** Set a user's account settings. **/
+/** Set a user's account settings. * */
 @CommandMetaData(name = "set-account", description = "Change an account's settings")
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 final class SetAccountCommand extends SshCommand {
 
-  @Argument(index = 0, required = true, metaVar = "USER", usage = "full name, email-address, ssh username or account id")
+  @Argument(
+      index = 0,
+      required = true,
+      metaVar = "USER",
+      usage = "full name, email-address, ssh username or account id")
   private Account.Id id;
 
   @Option(name = "--full-name", metaVar = "NAME", usage = "display name of the account")
@@ -80,59 +82,59 @@
   @Option(name = "--add-email", metaVar = "EMAIL", usage = "email addresses to add to the account")
   private List<String> addEmails = new ArrayList<>();
 
-  @Option(name = "--delete-email", metaVar = "EMAIL", usage = "email addresses to delete from the account")
+  @Option(
+      name = "--delete-email",
+      metaVar = "EMAIL",
+      usage = "email addresses to delete from the account")
   private List<String> deleteEmails = new ArrayList<>();
 
-  @Option(name = "--preferred-email", metaVar = "EMAIL", usage = "a registered email address from the account")
+  @Option(
+      name = "--preferred-email",
+      metaVar = "EMAIL",
+      usage = "a registered email address from the account")
   private String preferredEmail;
 
   @Option(name = "--add-ssh-key", metaVar = "-|KEY", usage = "public keys to add to the account")
   private List<String> addSshKeys = new ArrayList<>();
 
-  @Option(name = "--delete-ssh-key", metaVar = "-|KEY", usage = "public keys to delete from the account")
+  @Option(
+      name = "--delete-ssh-key",
+      metaVar = "-|KEY",
+      usage = "public keys to delete from the account")
   private List<String> deleteSshKeys = new ArrayList<>();
 
-  @Option(name = "--http-password", metaVar = "PASSWORD", usage = "password for HTTP authentication for the account")
+  @Option(
+      name = "--http-password",
+      metaVar = "PASSWORD",
+      usage = "password for HTTP authentication for the account")
   private String httpPassword;
 
   @Option(name = "--clear-http-password", usage = "clear HTTP password for the account")
   private boolean clearHttpPassword;
 
-  @Inject
-  private IdentifiedUser.GenericFactory genericUserFactory;
+  @Inject private IdentifiedUser.GenericFactory genericUserFactory;
 
-  @Inject
-  private CreateEmail.Factory createEmailFactory;
+  @Inject private CreateEmail.Factory createEmailFactory;
 
-  @Inject
-  private GetEmails getEmails;
+  @Inject private GetEmails getEmails;
 
-  @Inject
-  private DeleteEmail deleteEmail;
+  @Inject private DeleteEmail deleteEmail;
 
-  @Inject
-  private PutPreferred putPreferred;
+  @Inject private PutPreferred putPreferred;
 
-  @Inject
-  private PutName putName;
+  @Inject private PutName putName;
 
-  @Inject
-  private PutHttpPassword putHttpPassword;
+  @Inject private PutHttpPassword putHttpPassword;
 
-  @Inject
-  private PutActive putActive;
+  @Inject private PutActive putActive;
 
-  @Inject
-  private DeleteActive deleteActive;
+  @Inject private DeleteActive deleteActive;
 
-  @Inject
-  private AddSshKey addSshKey;
+  @Inject private AddSshKey addSshKey;
 
-  @Inject
-  private GetSshKeys getSshKeys;
+  @Inject private GetSshKeys getSshKeys;
 
-  @Inject
-  private DeleteSshKey deleteSshKey;
+  @Inject private DeleteSshKey deleteSshKey;
 
   private IdentifiedUser user;
   private AccountResource rsrc;
@@ -148,8 +150,7 @@
       throw die("--active and --inactive options are mutually exclusive.");
     }
     if (clearHttpPassword && !Strings.isNullOrEmpty(httpPassword)) {
-      throw die("--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 die("Only one option may use the stdin");
@@ -161,13 +162,14 @@
       deleteEmails = Collections.singletonList("ALL");
     }
     if (deleteEmails.contains(preferredEmail)) {
-      throw die("--preferred-email and --delete-email options are mutually " +
-          "exclusive for the same email address.");
+      throw die(
+          "--preferred-email and --delete-email options are mutually "
+              + "exclusive for the same email address.");
     }
   }
 
-  private void setAccount() throws OrmException, IOException, UnloggedFailure,
-      ConfigInvalidException {
+  private void setAccount()
+      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException {
     user = genericUserFactory.create(id);
     rsrc = new AccountResource(user);
     try {
@@ -219,18 +221,18 @@
     }
   }
 
-  private void addSshKeys(List<String> sshKeys) throws RestApiException,
-      OrmException, IOException, ConfigInvalidException {
+  private void addSshKeys(List<String> sshKeys)
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
     for (final String sshKey : sshKeys) {
       AddSshKey.Input in = new AddSshKey.Input();
-      in.raw = RawInputUtil.create(sshKey.getBytes(), "plain/text");
+      in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
       addSshKey.apply(rsrc, in);
     }
   }
 
   private void deleteSshKeys(List<String> sshKeys)
-      throws RestApiException, OrmException, RepositoryNotFoundException,
-      IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException {
     List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
     if (sshKeys.contains("ALL")) {
       for (SshKeyInfo i : infos) {
@@ -239,8 +241,7 @@
     } else {
       for (String sshKey : sshKeys) {
         for (SshKeyInfo i : infos) {
-          if (sshKey.trim().equals(i.sshPublicKey)
-              || sshKey.trim().equals(i.comment)) {
+          if (sshKey.trim().equals(i.sshPublicKey) || sshKey.trim().equals(i.comment)) {
             deleteSshKey(i);
           }
         }
@@ -248,16 +249,16 @@
     }
   }
 
-  private void deleteSshKey(SshKeyInfo i) throws AuthException, OrmException,
-      RepositoryNotFoundException, IOException, ConfigInvalidException {
-    AccountSshKey sshKey = new AccountSshKey(
-        new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
-    deleteSshKey.apply(
-        new AccountResource.SshKey(user, sshKey), null);
+  private void deleteSshKey(SshKeyInfo i)
+      throws AuthException, OrmException, RepositoryNotFoundException, IOException,
+          ConfigInvalidException {
+    AccountSshKey sshKey =
+        new AccountSshKey(new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
+    deleteSshKey.apply(new AccountResource.SshKey(user, sshKey), null);
   }
 
   private void addEmail(String email)
-      throws UnloggedFailure, RestApiException, OrmException, IOException {
+      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException {
     EmailInput in = new EmailInput();
     in.email = email;
     in.noConfirmation = true;
@@ -269,21 +270,18 @@
   }
 
   private void deleteEmail(String email)
-      throws RestApiException, OrmException, IOException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
-        deleteEmail.apply(new AccountResource.Email(user, e.email),
-            new DeleteEmail.Input());
+        deleteEmail.apply(new AccountResource.Email(user, e.email), new DeleteEmail.Input());
       }
     } else {
-      deleteEmail.apply(new AccountResource.Email(user, email),
-          new DeleteEmail.Input());
+      deleteEmail.apply(new AccountResource.Email(user, email), new DeleteEmail.Input());
     }
   }
 
-  private void putPreferred(String email)
-      throws RestApiException, OrmException, IOException {
+  private void putPreferred(String email) throws RestApiException, OrmException, IOException {
     for (EmailInfo e : getEmails.apply(rsrc)) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user, email), null);
@@ -299,12 +297,10 @@
       int idx = sshKeys.indexOf("-");
       if (idx >= 0) {
         StringBuilder sshKey = new StringBuilder();
-        BufferedReader br =
-            new BufferedReader(new InputStreamReader(in, UTF_8));
+        BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8));
         String line;
         while ((line = br.readLine()) != null) {
-          sshKey.append(line)
-            .append("\n");
+          sshKey.append(line).append("\n");
         }
         sshKeys.set(idx, sshKey.toString());
       }
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 4fef018..ce4116d 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
@@ -22,7 +22,6 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index 6277eb4..cfdd735 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -21,7 +21,9 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Enumeration;
 import org.apache.log4j.Level;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
@@ -29,13 +31,11 @@
 import org.apache.log4j.helpers.Loader;
 import org.kohsuke.args4j.Argument;
 
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.util.Enumeration;
-
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-@CommandMetaData(name = "set-level", description = "Change the level of loggers",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "set-level",
+    description = "Change the level of loggers",
+    runsAt = MASTER_OR_SLAVE)
 public class SetLoggingLevelCommand extends SshCommand {
   private static final String LOG_CONFIGURATION = "log4j.properties";
   private static final String JAVA_OPTIONS_LOG_CONFIG = "log4j.configuration";
@@ -64,8 +64,8 @@
     if (level == LevelOption.RESET) {
       reset();
     } else {
-      for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger
-          .hasMoreElements();) {
+      for (Enumeration<Logger> logger = LogManager.getCurrentLoggers();
+          logger.hasMoreElements(); ) {
         Logger log = logger.nextElement();
         if (name == null || log.getName().contains(name)) {
           log.setLevel(Level.toLevel(level.name()));
@@ -76,8 +76,7 @@
 
   @SuppressWarnings("unchecked")
   private static void reset() throws MalformedURLException {
-    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers();
-        logger.hasMoreElements();) {
+    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
       logger.nextElement().setLevel(null);
     }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 79e74d7..071f2ef 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.common.base.Function;
-import com.google.common.base.Joiner;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -35,61 +34,74 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
 import java.util.List;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
 
-@CommandMetaData(name = "set-members", description = "Modify members of specific group or number of groups")
+@CommandMetaData(
+    name = "set-members",
+    description = "Modify members of specific group or number of groups")
 public class SetMembersCommand extends SshCommand {
 
-  @Option(name = "--add", aliases = {"-a"}, metaVar = "USER", usage = "users that should be added as group member")
+  @Option(
+      name = "--add",
+      aliases = {"-a"},
+      metaVar = "USER",
+      usage = "users that should be added as group member")
   private List<Account.Id> accountsToAdd = new ArrayList<>();
 
-  @Option(name = "--remove", aliases = {"-r"}, metaVar = "USER", usage = "users that should be removed from the group")
+  @Option(
+      name = "--remove",
+      aliases = {"-r"},
+      metaVar = "USER",
+      usage = "users that should be removed from the group")
   private List<Account.Id> accountsToRemove = new ArrayList<>();
 
-  @Option(name = "--include", aliases = {"-i"}, metaVar = "GROUP", usage = "group that should be included as group member")
+  @Option(
+      name = "--include",
+      aliases = {"-i"},
+      metaVar = "GROUP",
+      usage = "group that should be included as group member")
   private List<AccountGroup.UUID> groupsToInclude = new ArrayList<>();
 
-  @Option(name = "--exclude", aliases = {"-e"}, metaVar = "GROUP", usage = "group that should be excluded from the group")
+  @Option(
+      name = "--exclude",
+      aliases = {"-e"},
+      metaVar = "GROUP",
+      usage = "group that should be excluded from the group")
   private List<AccountGroup.UUID> groupsToRemove = new ArrayList<>();
 
-  @Argument(index = 0, required = true, multiValued = true, metaVar = "GROUP", usage = "groups to modify")
+  @Argument(
+      index = 0,
+      required = true,
+      multiValued = true,
+      metaVar = "GROUP",
+      usage = "groups to modify")
   private List<AccountGroup.UUID> groups = new ArrayList<>();
 
-  @Inject
-  private AddMembers addMembers;
+  @Inject private AddMembers addMembers;
 
-  @Inject
-  private DeleteMembers deleteMembers;
+  @Inject private DeleteMembers deleteMembers;
 
-  @Inject
-  private AddIncludedGroups addIncludedGroups;
+  @Inject private AddIncludedGroups addIncludedGroups;
 
-  @Inject
-  private DeleteIncludedGroups deleteIncludedGroups;
+  @Inject private DeleteIncludedGroups deleteIncludedGroups;
 
-  @Inject
-  private GroupsCollection groupsCollection;
+  @Inject private GroupsCollection groupsCollection;
 
-  @Inject
-  private GroupCache groupCache;
+  @Inject private GroupCache groupCache;
 
-  @Inject
-  private AccountCache accountCache;
+  @Inject private AccountCache accountCache;
 
   @Override
   protected void run() throws UnloggedFailure, Failure, Exception {
     try {
       for (AccountGroup.UUID groupUuid : groups) {
         GroupResource resource =
-            groupsCollection.parse(TopLevelResource.INSTANCE,
-                IdString.fromUrl(groupUuid.get()));
+            groupsCollection.parse(TopLevelResource.INSTANCE, IdString.fromUrl(groupUuid.get()));
         if (!accountsToRemove.isEmpty()) {
           deleteMembers.apply(resource, fromMembers(accountsToRemove));
           reportMembersAction("removed from", resource, accountsToRemove);
@@ -112,58 +124,35 @@
     }
   }
 
-  private void reportMembersAction(String action, GroupResource group,
-      List<Account.Id> accountIdList) throws UnsupportedEncodingException,
-      IOException {
-    out.write(String.format(
-        "Members %s group %s: %s\n",
-        action,
-        group.getName(),
-        Joiner.on(", ").join(
-            Iterables.transform(accountIdList,
-                new Function<Account.Id, String>() {
-                  @Override
-                  public String apply(Account.Id accountId) {
-                    return MoreObjects.firstNonNull(accountCache.get(accountId)
-                        .getAccount().getPreferredEmail(), "n/a");
-                  }
-                }))).getBytes(ENC));
+  private void reportMembersAction(
+      String action, GroupResource group, List<Account.Id> accountIdList)
+      throws UnsupportedEncodingException, IOException {
+    String names =
+        accountIdList.stream()
+            .map(
+                accountId ->
+                    MoreObjects.firstNonNull(
+                        accountCache.get(accountId).getAccount().getPreferredEmail(), "n/a"))
+            .collect(joining(", "));
+    out.write(
+        String.format("Members %s group %s: %s\n", action, group.getName(), names).getBytes(ENC));
   }
 
-  private void reportGroupsAction(String action, GroupResource group,
-      List<AccountGroup.UUID> groupUuidList)
+  private void reportGroupsAction(
+      String action, GroupResource group, List<AccountGroup.UUID> groupUuidList)
       throws UnsupportedEncodingException, IOException {
-    out.write(String.format(
-        "Groups %s group %s: %s\n",
-        action,
-        group.getName(),
-        Joiner.on(", ").join(
-            Iterables.transform(groupUuidList,
-                new Function<AccountGroup.UUID, String>() {
-                  @Override
-                  public String apply(AccountGroup.UUID uuid) {
-                    return groupCache.get(uuid).getName();
-                  }
-                }))).getBytes(ENC));
+    String names =
+        groupUuidList.stream().map(uuid -> groupCache.get(uuid).getName()).collect(joining(", "));
+    out.write(
+        String.format("Groups %s group %s: %s\n", action, group.getName(), names).getBytes(ENC));
   }
 
   private AddIncludedGroups.Input fromGroups(List<AccountGroup.UUID> accounts) {
-    return AddIncludedGroups.Input.fromGroups(Lists.newArrayList(Iterables
-        .transform(accounts, new Function<AccountGroup.UUID, String>() {
-          @Override
-          public String apply(AccountGroup.UUID uuid) {
-            return uuid.toString();
-          }
-        })));
+    return AddIncludedGroups.Input.fromGroups(
+        accounts.stream().map(Object::toString).collect(toList()));
   }
 
   private AddMembers.Input fromMembers(List<Account.Id> accounts) {
-    return AddMembers.Input.fromMembers(Lists.newArrayList(Iterables.transform(
-        accounts, new Function<Account.Id, String>() {
-          @Override
-          public String apply(Account.Id id) {
-            return id.toString();
-          }
-        })));
+    return AddMembers.Input.fromMembers(accounts.stream().map(Object::toString).collect(toList()));
   }
 }
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 a4cef13..d126b16 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
@@ -25,7 +25,7 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Argument;
@@ -33,21 +33,24 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.IOException;
-
 @CommandMetaData(name = "set-project", description = "Change a project's settings")
 final class SetProjectCommand extends SshCommand {
-  private static final Logger log = LoggerFactory
-      .getLogger(SetProjectCommand.class);
+  private static final Logger log = LoggerFactory.getLogger(SetProjectCommand.class);
 
   @Argument(index = 0, required = true, metaVar = "NAME", usage = "name of the project")
   private ProjectControl projectControl;
 
-  @Option(name = "--description", aliases = {"-d"}, metaVar = "DESCRIPTION", usage = "description of project")
+  @Option(
+      name = "--description",
+      aliases = {"-d"},
+      metaVar = "DESCRIPTION",
+      usage = "description of project")
   private String projectDescription;
 
-  @Option(name = "--submit-type", aliases = {"-t"}, usage = "project submit type\n"
-      + "(default: MERGE_IF_NECESSARY)")
+  @Option(
+      name = "--submit-type",
+      aliases = {"-t"},
+      usage = "project submit type\n(default: MERGE_IF_NECESSARY)")
   private SubmitType submitType;
 
   @Option(name = "--contributor-agreements", usage = "if contributor agreement is required")
@@ -62,22 +65,34 @@
   @Option(name = "--change-id", usage = "if change-id is required")
   private InheritableBoolean requireChangeID;
 
-  @Option(name = "--use-contributor-agreements", aliases = {"--ca"}, usage = "if contributor agreement is required")
+  @Option(
+      name = "--use-contributor-agreements",
+      aliases = {"--ca"},
+      usage = "if contributor agreement is required")
   void setUseContributorArgreements(@SuppressWarnings("unused") boolean on) {
     contributorAgreements = InheritableBoolean.TRUE;
   }
 
-  @Option(name = "--no-contributor-agreements", aliases = {"--nca"}, usage = "if contributor agreement is not required")
+  @Option(
+      name = "--no-contributor-agreements",
+      aliases = {"--nca"},
+      usage = "if contributor agreement is not required")
   void setNoContributorArgreements(@SuppressWarnings("unused") boolean on) {
     contributorAgreements = InheritableBoolean.FALSE;
   }
 
-  @Option(name = "--use-signed-off-by", aliases = {"--so"}, usage = "if signed-off-by is required")
+  @Option(
+      name = "--use-signed-off-by",
+      aliases = {"--so"},
+      usage = "if signed-off-by is required")
   void setUseSignedOffBy(@SuppressWarnings("unused") boolean on) {
     signedOffBy = InheritableBoolean.TRUE;
   }
 
-  @Option(name = "--no-signed-off-by", aliases = {"--nso"}, usage = "if signed-off-by is not required")
+  @Option(
+      name = "--no-signed-off-by",
+      aliases = {"--nso"},
+      usage = "if signed-off-by is not required")
   void setNoSignedOffBy(@SuppressWarnings("unused") boolean on) {
     signedOffBy = InheritableBoolean.FALSE;
   }
@@ -87,32 +102,41 @@
     contentMerge = InheritableBoolean.TRUE;
   }
 
-  @Option(name = "--no-content-merge", usage = "don't allow automatic conflict resolving within files")
+  @Option(
+      name = "--no-content-merge",
+      usage = "don't allow automatic conflict resolving within files")
   void setNoContentMerge(@SuppressWarnings("unused") boolean on) {
     contentMerge = InheritableBoolean.FALSE;
   }
 
-  @Option(name = "--require-change-id", aliases = {"--id"}, usage = "if change-id is required")
+  @Option(
+      name = "--require-change-id",
+      aliases = {"--id"},
+      usage = "if change-id is required")
   void setRequireChangeId(@SuppressWarnings("unused") boolean on) {
     requireChangeID = InheritableBoolean.TRUE;
   }
 
-  @Option(name = "--no-change-id", aliases = {"--nid"}, usage = "if change-id is not required")
+  @Option(
+      name = "--no-change-id",
+      aliases = {"--nid"},
+      usage = "if change-id is not required")
   void setNoChangeId(@SuppressWarnings("unused") boolean on) {
     requireChangeID = InheritableBoolean.FALSE;
   }
 
-  @Option(name = "--project-state", aliases = {"--ps"}, usage = "project's visibility state")
+  @Option(
+      name = "--project-state",
+      aliases = {"--ps"},
+      usage = "project's visibility state")
   private ProjectState state;
 
   @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
   private String maxObjectSizeLimit;
 
-  @Inject
-  private MetaDataUpdate.User metaDataUpdateFactory;
+  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
 
-  @Inject
-  private ProjectCache projectCache;
+  @Inject private ProjectCache projectCache;
 
   @Override
   protected void run() throws Failure {
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 ac64803..aa060c5 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -28,36 +29,46 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @CommandMetaData(name = "set-reviewers", description = "Add or remove reviewers on a change")
 public class SetReviewersCommand extends SshCommand {
-  private static final Logger log =
-      LoggerFactory.getLogger(SetReviewersCommand.class);
+  private static final Logger log = LoggerFactory.getLogger(SetReviewersCommand.class);
 
   @Option(name = "--project", aliases = "-p", usage = "project containing the change")
   private ProjectControl projectControl;
 
-  @Option(name = "--add", aliases = {"-a"}, metaVar = "REVIEWER", usage = "user or group that should be added as reviewer")
+  @Option(
+      name = "--add",
+      aliases = {"-a"},
+      metaVar = "REVIEWER",
+      usage = "user or group that should be added as reviewer")
   private List<String> toAdd = new ArrayList<>();
 
-  @Option(name = "--remove", aliases = {"-r"}, metaVar = "REVIEWER", usage = "user that should be removed from the reviewer list")
+  @Option(
+      name = "--remove",
+      aliases = {"-r"},
+      metaVar = "REVIEWER",
+      usage = "user that should be removed from the reviewer list")
   void optionRemove(Account.Id who) {
     toRemove.add(who);
   }
 
-  @Argument(index = 0, required = true, multiValued = true, metaVar = "CHANGE", usage = "changes to modify")
+  @Argument(
+      index = 0,
+      required = true,
+      multiValued = true,
+      metaVar = "CHANGE",
+      usage = "changes to modify")
   void addChange(String token) {
     try {
       changeArgumentParser.addChange(token, changes, projectControl);
@@ -68,17 +79,13 @@
     }
   }
 
-  @Inject
-  private ReviewerResource.Factory reviewerFactory;
+  @Inject private ReviewerResource.Factory reviewerFactory;
 
-  @Inject
-  private PostReviewers postReviewers;
+  @Inject private PostReviewers postReviewers;
 
-  @Inject
-  private DeleteReviewer deleteReviewer;
+  @Inject private DeleteReviewer deleteReviewer;
 
-  @Inject
-  private ChangeArgumentParser changeArgumentParser;
+  @Inject private ChangeArgumentParser changeArgumentParser;
 
   private Set<Account.Id> toRemove = new HashSet<>();
 
@@ -111,12 +118,11 @@
       ReviewerResource rsrc = reviewerFactory.create(changeRsrc, reviewer);
       String error = null;
       try {
-        deleteReviewer.apply(rsrc, new DeleteReviewer.Input());
+        deleteReviewer.apply(rsrc, new DeleteReviewerInput());
       } catch (ResourceNotFoundException e) {
         error = String.format("could not remove %s: not found", reviewer);
       } catch (Exception e) {
-        error = String.format("could not remove %s: %s",
-            reviewer, e.getMessage());
+        error = String.format("could not remove %s: %s", reviewer, e.getMessage());
       }
       if (error != null) {
         ok = false;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 3e0cec3..abca0b8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -38,24 +38,24 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
 import com.google.inject.Inject;
-
-import org.apache.sshd.common.io.IoAcceptor;
-import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaSession;
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.text.SimpleDateFormat;
 import java.util.Collection;
 import java.util.Date;
 import java.util.Map;
 import java.util.Map.Entry;
+import org.apache.sshd.common.io.IoAcceptor;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.io.mina.MinaSession;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
 
 /** Show the current cache states. */
 @RequiresAnyCapability({VIEW_CACHES, MAINTAIN_SERVER})
-@CommandMetaData(name = "show-caches", description = "Display current cache statistics",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "show-caches",
+    description = "Display current cache statistics",
+    runsAt = MASTER_OR_SLAVE)
 final class ShowCaches extends SshCommand {
   private static volatile long serverStarted;
 
@@ -66,8 +66,7 @@
     }
 
     @Override
-    public void stop() {
-    }
+    public void stop() {}
   }
 
   @Option(name = "--gc", usage = "perform Java GC before printing memory stats")
@@ -79,20 +78,21 @@
   @Option(name = "--show-threads", usage = "show detailed thread counts")
   private boolean showThreads;
 
-  @Inject
-  private SshDaemon daemon;
+  @Inject private SshDaemon daemon;
 
-  @Inject
-  private ListCaches listCaches;
+  @Inject private ListCaches listCaches;
 
-  @Inject
-  private GetSummary getSummary;
+  @Inject private GetSummary getSummary;
 
-  @Inject
-  private CurrentUser self;
+  @Inject private CurrentUser self;
 
-  @Option(name = "--width", aliases = {"-w"}, metaVar = "COLS", usage = "width of output table")
+  @Option(
+      name = "--width",
+      aliases = {"-w"},
+      metaVar = "COLS",
+      usage = "width of output table")
   private int columns = 80;
+
   private int nw;
 
   @Override
@@ -117,31 +117,43 @@
         "Gerrit Code Review",
         Version.getVersion() != null ? Version.getVersion() : "",
         new SimpleDateFormat("HH:mm:ss   zzz").format(now));
-    stdout.format(
-        "%-25s %-20s   uptime %16s\n",
-        "", "",
-        uptime(now.getTime() - serverStarted));
+    stdout.format("%-25s %-20s   uptime %16s\n", "", "", uptime(now.getTime() - serverStarted));
     stdout.print('\n');
 
-    stdout.print(String.format(//
-        "%1s %-" + nw + "s|%-21s|  %-5s |%-9s|\n" //
-        , "" //
-        , "Name" //
-        , "Entries" //
-        , "AvgGet" //
-        , "Hit Ratio" //
-    ));
-    stdout.print(String.format(//
-        "%1s %-" + nw + "s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
-        , "" //
-        , "" //
-        , "Mem" //
-        , "Disk" //
-        , "Space" //
-        , "" //
-        , "Mem" //
-        , "Disk" //
-    ));
+    stdout.print(
+        String.format( //
+            "%1s %-" + nw + "s|%-21s|  %-5s |%-9s|\n" //
+            ,
+            "" //
+            ,
+            "Name" //
+            ,
+            "Entries" //
+            ,
+            "AvgGet" //
+            ,
+            "Hit Ratio" //
+            ));
+    stdout.print(
+        String.format( //
+            "%1s %-" + nw + "s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
+            ,
+            "" //
+            ,
+            "" //
+            ,
+            "Mem" //
+            ,
+            "Disk" //
+            ,
+            "Space" //
+            ,
+            "" //
+            ,
+            "Mem" //
+            ,
+            "Disk" //
+            ));
     stdout.print("--");
     for (int i = 0; i < nw; i++) {
       stdout.print('-');
@@ -157,8 +169,7 @@
     if (self.getCapabilities().canMaintainServer()) {
       sshSummary();
 
-      SummaryInfo summary =
-          getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
+      SummaryInfo summary = getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
       taskSummary(summary.taskSummary);
       memSummary(summary.memSummary);
       threadSummary(summary.threadSummary);
@@ -173,8 +184,7 @@
 
   private Collection<CacheInfo> getCaches() {
     @SuppressWarnings("unchecked")
-    Map<String, CacheInfo> caches =
-        (Map<String, CacheInfo>) listCaches.apply(new ConfigResource());
+    Map<String, CacheInfo> caches = (Map<String, CacheInfo>) listCaches.apply(new ConfigResource());
     for (Map.Entry<String, CacheInfo> entry : caches.entrySet()) {
       CacheInfo cache = entry.getValue();
       cache.name = entry.getKey();
@@ -207,17 +217,17 @@
   }
 
   private void printCache(CacheInfo cache) {
-    stdout.print(String.format(
-        "%1s %-" + nw + "s|%6s %6s %7s| %7s |%4s %4s|\n",
-        CacheType.DISK.equals(cache.type) ? "D" : "",
-        cache.name,
-        nullToEmpty(cache.entries.mem),
-        nullToEmpty(cache.entries.disk),
-        Strings.nullToEmpty(cache.entries.space),
-        Strings.nullToEmpty(cache.averageGet),
-        formatAsPercent(cache.hitRatio.mem),
-        formatAsPercent(cache.hitRatio.disk)
-      ));
+    stdout.print(
+        String.format(
+            "%1s %-" + nw + "s|%6s %6s %7s| %7s |%4s %4s|\n",
+            CacheType.DISK.equals(cache.type) ? "D" : "",
+            cache.name,
+            nullToEmpty(cache.entries.mem),
+            nullToEmpty(cache.entries.disk),
+            Strings.nullToEmpty(cache.entries.space),
+            Strings.nullToEmpty(cache.averageGet),
+            formatAsPercent(cache.hitRatio.mem),
+            formatAsPercent(cache.hitRatio.disk)));
   }
 
   private static String nullToEmpty(Long l) {
@@ -229,20 +239,17 @@
   }
 
   private void memSummary(MemSummaryInfo memSummary) {
-    stdout.format("Mem: %s total = %s used + %s free + %s buffers\n",
-        memSummary.total,
-        memSummary.used,
-        memSummary.free,
-        memSummary.buffers);
+    stdout.format(
+        "Mem: %s total = %s used + %s free + %s buffers\n",
+        memSummary.total, memSummary.used, memSummary.free, memSummary.buffers);
     stdout.format("     %s max\n", memSummary.max);
-    stdout.format("    %8d open files\n",
-        nullToZero(memSummary.openFiles));
+    stdout.format("    %8d open files\n", nullToZero(memSummary.openFiles));
     stdout.print('\n');
   }
 
   private void threadSummary(ThreadSummaryInfo threadSummary) {
-    stdout.format("Threads: %d CPUs available, %d threads\n",
-        threadSummary.cpus, threadSummary.threads);
+    stdout.format(
+        "Threads: %d CPUs available, %d threads\n", threadSummary.cpus, threadSummary.threads);
 
     if (showThreads) {
       stdout.print(String.format("  %22s", ""));
@@ -250,8 +257,7 @@
         stdout.print(String.format(" %14s", s.name()));
       }
       stdout.print('\n');
-      for (Entry<String, Map<Thread.State, Integer>> e :
-          threadSummary.counts.entrySet()) {
+      for (Entry<String, Map<Thread.State, Integer>> e : threadSummary.counts.entrySet()) {
         stdout.print(String.format("  %-22s", e.getKey()));
         for (Thread.State s : Thread.State.values()) {
           stdout.print(String.format(" %14d", nullToZero(e.getValue().get(s))));
@@ -287,29 +293,19 @@
 
     for (IoSession s : list) {
       if (s instanceof MinaSession) {
-        MinaSession minaSession = (MinaSession)s;
+        MinaSession minaSession = (MinaSession) s;
         oldest = Math.min(oldest, minaSession.getSession().getCreationTime());
       }
     }
 
     stdout.format(
-        "SSH:   %4d  users, oldest session started %s ago\n",
-        list.size(),
-        uptime(now - oldest));
+        "SSH:   %4d  users, oldest session started %s ago\n", list.size(), uptime(now - oldest));
   }
 
   private void jvmSummary(JvmSummaryInfo jvmSummary) {
-    stdout.format("JVM: %s %s %s\n",
-        jvmSummary.vmVendor,
-        jvmSummary.vmName,
-        jvmSummary.vmVersion);
-    stdout.format("  on %s %s %s\n",
-        jvmSummary.osName,
-        jvmSummary.osVersion,
-        jvmSummary.osArch);
-    stdout.format("  running as %s on %s\n",
-        jvmSummary.user,
-        Strings.nullToEmpty(jvmSummary.host));
+    stdout.format("JVM: %s %s %s\n", jvmSummary.vmVendor, jvmSummary.vmName, jvmSummary.vmVersion);
+    stdout.format("  on %s %s %s\n", jvmSummary.osName, jvmSummary.osVersion, jvmSummary.osArch);
+    stdout.format("  running as %s on %s\n", jvmSummary.user, Strings.nullToEmpty(jvmSummary.host));
     stdout.format("  cwd  %s\n", jvmSummary.currentWorkingDirectory);
     stdout.format("  site %s\n", jvmSummary.site);
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
index 5e4568f..d13a9a5 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -28,16 +28,6 @@
 import com.google.gerrit.sshd.SshDaemon;
 import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
-
-import org.apache.sshd.common.io.IoAcceptor;
-import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaAcceptor;
-import org.apache.sshd.common.io.mina.MinaSession;
-import org.apache.sshd.common.io.nio2.Nio2Acceptor;
-import org.apache.sshd.common.session.helpers.AbstractSession;
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
@@ -48,20 +38,35 @@
 import java.util.Comparator;
 import java.util.Date;
 import java.util.List;
+import org.apache.sshd.common.io.IoAcceptor;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.io.mina.MinaAcceptor;
+import org.apache.sshd.common.io.mina.MinaSession;
+import org.apache.sshd.common.io.nio2.Nio2Acceptor;
+import org.apache.sshd.common.session.helpers.AbstractSession;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
 
 /** Show the current SSH connections. */
 @RequiresCapability(GlobalCapability.VIEW_CONNECTIONS)
-@CommandMetaData(name = "show-connections", description = "Display active client SSH connections",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(
+    name = "show-connections",
+    description = "Display active client SSH connections",
+    runsAt = MASTER_OR_SLAVE)
 final class ShowConnections extends SshCommand {
-  @Option(name = "--numeric", aliases = {"-n"}, usage = "don't resolve names")
+  @Option(
+      name = "--numeric",
+      aliases = {"-n"},
+      usage = "don't resolve names")
   private boolean numeric;
 
-  @Option(name = "--wide", aliases = {"-w"}, usage = "display without line width truncation")
+  @Option(
+      name = "--wide",
+      aliases = {"-w"},
+      usage = "display without line width truncation")
   private boolean wide;
 
-  @Inject
-  private SshDaemon daemon;
+  @Inject private SshDaemon daemon;
 
   private int hostNameWidth;
   private int columns = 80;
@@ -77,7 +82,7 @@
       }
     }
     super.start(env);
- }
+  }
 
   @Override
   protected void run() throws Failure {
@@ -86,32 +91,33 @@
       throw new Failure(1, "fatal: sshd no longer running");
     }
 
-    final List<IoSession> list =
-        new ArrayList<>(acceptor.getManagedSessions().values());
-    Collections.sort(list, new Comparator<IoSession>() {
-      @Override
-      public int compare(IoSession arg0, IoSession arg1) {
-        if (arg0 instanceof MinaSession) {
-          MinaSession mArg0 = (MinaSession) arg0;
-          MinaSession mArg1 = (MinaSession) arg1;
-          if (mArg0.getSession().getCreationTime() < mArg1.getSession()
-              .getCreationTime()) {
-            return -1;
-          } else if (mArg0.getSession().getCreationTime() > mArg1.getSession()
-              .getCreationTime()) {
-            return 1;
+    final List<IoSession> list = new ArrayList<>(acceptor.getManagedSessions().values());
+    Collections.sort(
+        list,
+        new Comparator<IoSession>() {
+          @Override
+          public int compare(IoSession arg0, IoSession arg1) {
+            if (arg0 instanceof MinaSession) {
+              MinaSession mArg0 = (MinaSession) arg0;
+              MinaSession mArg1 = (MinaSession) arg1;
+              if (mArg0.getSession().getCreationTime() < mArg1.getSession().getCreationTime()) {
+                return -1;
+              } else if (mArg0.getSession().getCreationTime()
+                  > mArg1.getSession().getCreationTime()) {
+                return 1;
+              }
+            }
+            return (int) (arg0.getId() - arg1.getId());
           }
-        }
-        return (int) (arg0.getId() - arg1.getId());
-      }
-    });
+        });
 
     hostNameWidth = wide ? Integer.MAX_VALUE : columns - 9 - 9 - 10 - 32;
 
     if (getBackend().equals("mina")) {
       long now = TimeUtil.nowMs();
-      stdout.print(String.format("%-8s %8s %8s   %-15s %s\n",
-          "Session", "Start", "Idle", "User", "Remote Host"));
+      stdout.print(
+          String.format(
+              "%-8s %8s %8s   %-15s %s\n", "Session", "Start", "Idle", "User", "Remote Host"));
       stdout.print("--------------------------------------------------------------\n");
       for (final IoSession io : list) {
         checkState(io instanceof MinaSession, "expected MinaSession");
@@ -121,25 +127,25 @@
         AbstractSession s = AbstractSession.getSession(io, true);
         SshSession sd = s != null ? s.getAttribute(SshSession.KEY) : null;
 
-        stdout.print(String.format("%8s %8s %8s   %-15.15s %s\n",
-            id(sd),
-            time(now, start),
-            age(idle),
-            username(sd),
-            hostname(io.getRemoteAddress())));
+        stdout.print(
+            String.format(
+                "%8s %8s %8s   %-15.15s %s\n",
+                id(sd),
+                time(now, start),
+                age(idle),
+                username(sd),
+                hostname(io.getRemoteAddress())));
       }
     } else {
-      stdout.print(String.format("%-8s   %-15s %s\n",
-          "Session", "User", "Remote Host"));
+      stdout.print(String.format("%-8s   %-15s %s\n", "Session", "User", "Remote Host"));
       stdout.print("--------------------------------------------------------------\n");
       for (final IoSession io : list) {
         AbstractSession s = AbstractSession.getSession(io, true);
         SshSession sd = s != null ? s.getAttribute(SshSession.KEY) : null;
 
-        stdout.print(String.format("%8s   %-15.15s %s\n",
-            id(sd),
-            username(sd),
-            hostname(io.getRemoteAddress())));
+        stdout.print(
+            String.format(
+                "%8s   %-15.15s %s\n", id(sd), username(sd), hostname(io.getRemoteAddress())));
       }
     }
 
@@ -165,7 +171,7 @@
   }
 
   private static String time(final long now, final long time) {
-    if (time - now < 24 * 60 * 60 * 1000L) {
+    if (now - time < 24 * 60 * 60 * 1000L) {
       return new SimpleDateFormat("HH:mm:ss").format(new Date(time));
     }
     return new SimpleDateFormat("MMM-dd").format(new Date(time));
@@ -201,7 +207,6 @@
       }
 
       return "a/" + u.getAccountId().toString();
-
     }
     return "";
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 9a20ba8..4d1398d 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -31,37 +31,37 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Option;
-
 import java.io.IOException;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.List;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
 
 /** Display the current work queue. */
 @AdminHighPriorityCommand
-@CommandMetaData(name = "show-queue",
+@CommandMetaData(
+    name = "show-queue",
     description = "Display the background work queues",
     runsAt = MASTER_OR_SLAVE)
 final class ShowQueue extends SshCommand {
-  @Option(name = "--wide", aliases = {"-w"},
+  @Option(
+      name = "--wide",
+      aliases = {"-w"},
       usage = "display without line width truncation")
   private boolean wide;
 
-  @Option(name = "--by-queue", aliases = {"-q"},
+  @Option(
+      name = "--by-queue",
+      aliases = {"-q"},
       usage = "group tasks by queue and print queue info")
   private boolean groupByQueue;
 
-  @Inject
-  private ListTasks listTasks;
+  @Inject private ListTasks listTasks;
 
-  @Inject
-  private IdentifiedUser currentUser;
+  @Inject private IdentifiedUser currentUser;
 
-  @Inject
-  private WorkQueue workQueue;
+  @Inject private WorkQueue workQueue;
 
   private int columns = 80;
   private int maxCommandWidth;
@@ -82,10 +82,12 @@
   @Override
   protected void run() throws UnloggedFailure {
     maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
-    stdout.print(String.format("%-8s %-12s %-12s %-4s %s\n", //
-        "Task", "State", "StartTime", "", "Command"));
-    stdout.print("----------------------------------------------"
-        + "--------------------------------\n");
+    stdout.print(
+        String.format(
+            "%-8s %-12s %-12s %-4s %s\n", //
+            "Task", "State", "StartTime", "", "Command"));
+    stdout.print(
+        "------------------------------------------------------------------------------\n");
 
     List<TaskInfo> tasks;
     try {
@@ -116,8 +118,7 @@
     return byQueue;
   }
 
-  private void print(List<TaskInfo> tasks, long now, boolean viewAll,
-      int threadPoolSize) {
+  private void print(List<TaskInfo> tasks, long now, boolean viewAll, int threadPoolSize) {
     for (TaskInfo task : tasks) {
       String start;
       switch (task.state) {
@@ -136,24 +137,30 @@
 
       // Shows information about tasks depending on the user rights
       if (viewAll || task.projectName == null) {
-        String command = task.command.length() < maxCommandWidth
-            ? task.command
+        String command =
+            task.command.length() < maxCommandWidth
+                ? task.command
                 : task.command.substring(0, maxCommandWidth);
 
-        stdout.print(String.format("%8s %-12s %-12s %-4s %s\n",
-            task.id, start, startTime(task.startTime), "", command));
+        stdout.print(
+            String.format(
+                "%8s %-12s %-12s %-4s %s\n",
+                task.id, start, startTime(task.startTime), "", command));
       } else {
-        String remoteName = task.remoteName != null
-            ? task.remoteName + "/" + task.projectName
-                : task.projectName;
+        String remoteName =
+            task.remoteName != null ? task.remoteName + "/" + task.projectName : task.projectName;
 
-        stdout.print(String.format("%8s %-12s %-4s %s\n",
-            task.id, start, startTime(task.startTime),
-            MoreObjects.firstNonNull(remoteName, "n/a")));
+        stdout.print(
+            String.format(
+                "%8s %-12s %-4s %s\n",
+                task.id,
+                start,
+                startTime(task.startTime),
+                MoreObjects.firstNonNull(remoteName, "n/a")));
       }
     }
-    stdout.print("----------------------------------------------"
-        + "--------------------------------\n");
+    stdout.print(
+        "------------------------------------------------------------------------------\n");
     stdout.print("  " + tasks.size() + " tasks");
     if (threadPoolSize > 0) {
       stdout.print(", " + threadPoolSize + " worker threads");
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 f807074..75157b0 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
@@ -37,24 +37,21 @@
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.inject.Inject;
-
-import org.apache.sshd.server.Environment;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Future;
 import java.util.concurrent.LinkedBlockingQueue;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @RequiresCapability(GlobalCapability.STREAM_EVENTS)
 @CommandMetaData(name = "stream-events", description = "Monitor events occurring in real time")
 final class StreamEvents extends BaseCommand {
-  private static final Logger log =
-      LoggerFactory.getLogger(StreamEvents.class);
+  private static final Logger log = LoggerFactory.getLogger(StreamEvents.class);
 
   /** Maximum number of events that may be queued up for each connection. */
   private static final int MAX_EVENTS = 128;
@@ -62,23 +59,21 @@
   /** Number of events to write before yielding off the thread. */
   private static final int BATCH_SIZE = 32;
 
-  @Option(name = "--subscribe", aliases = {"-s"}, metaVar = "SUBSCRIBE",
+  @Option(
+      name = "--subscribe",
+      aliases = {"-s"},
+      metaVar = "SUBSCRIBE",
       usage = "subscribe to specific stream-events")
   private List<String> subscribedToEvents = new ArrayList<>();
 
-  @Inject
-  private IdentifiedUser currentUser;
+  @Inject private IdentifiedUser currentUser;
 
-  @Inject
-  private DynamicSet<UserScopedEventListener> eventListeners;
+  @Inject private DynamicSet<UserScopedEventListener> eventListeners;
 
-  @Inject
-  @StreamCommandExecutor
-  private WorkQueue.Executor pool;
+  @Inject @StreamCommandExecutor private WorkQueue.Executor pool;
 
   /** Queue of events to stream to the connected user. */
-  private final LinkedBlockingQueue<Event> queue =
-      new LinkedBlockingQueue<>(MAX_EVENTS);
+  private final LinkedBlockingQueue<Event> queue = new LinkedBlockingQueue<>(MAX_EVENTS);
 
   private Gson gson;
 
@@ -87,6 +82,7 @@
   /** Special event to notify clients they missed other events. */
   private static final class DroppedOutputEvent extends Event {
     private static final String TYPE = "dropped-output";
+
     DroppedOutputEvent() {
       super(TYPE);
     }
@@ -96,22 +92,23 @@
     EventTypes.register(DroppedOutputEvent.TYPE, DroppedOutputEvent.class);
   }
 
-  private final CancelableRunnable writer = new CancelableRunnable() {
-    @Override
-    public void run() {
-      writeEvents();
-    }
+  private final CancelableRunnable writer =
+      new CancelableRunnable() {
+        @Override
+        public void run() {
+          writeEvents();
+        }
 
-    @Override
-    public void cancel() {
-      onExit(0);
-    }
+        @Override
+        public void cancel() {
+          onExit(0);
+        }
 
-    @Override
-    public String toString() {
-      return "Stream Events (" + currentUser.getAccount().getUserName() + ")";
-    }
-  };
+        @Override
+        public String toString() {
+          return "Stream Events (" + currentUser.getAccount().getUserName() + ")";
+        }
+      };
 
   /** True if {@link DroppedOutputEvent} needs to be sent. */
   private volatile boolean dropped;
@@ -124,10 +121,9 @@
 
   /**
    * Currently scheduled task to spin out {@link #queue}.
-   * <p>
-   * This field is usually {@code null}, unless there is at least one object
-   * present inside of {@link #queue} ready for delivery. Tasks are only started
-   * when there are events to be sent.
+   *
+   * <p>This field is usually {@code null}, unless there is at least one object present inside of
+   * {@link #queue} ready for delivery. Tasks are only started when there are events to be sent.
    */
   private Future<?> task;
 
@@ -150,31 +146,37 @@
 
     stdout = toPrintWriter(out);
     eventListenerRegistration =
-        eventListeners.add(new UserScopedEventListener() {
-          @Override
-          public void onEvent(final Event event) {
-            if (subscribedToEvents.isEmpty()
-                || subscribedToEvents.contains(event.getType())) {
-              offer(event);
-            }
-          }
+        eventListeners.add(
+            new UserScopedEventListener() {
+              @Override
+              public void onEvent(final Event event) {
+                if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
+                  offer(event);
+                }
+              }
 
-          @Override
-          public CurrentUser getUser() {
-            return currentUser;
-          }
-        });
+              @Override
+              public CurrentUser getUser() {
+                return currentUser;
+              }
+            });
 
-    gson = new GsonBuilder()
-        .registerTypeAdapter(Supplier.class, new SupplierSerializer())
-        .registerTypeAdapter(
-            Project.NameKey.class, new ProjectNameKeySerializer())
-        .create();
+    gson =
+        new GsonBuilder()
+            .registerTypeAdapter(Supplier.class, new SupplierSerializer())
+            .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeySerializer())
+            .create();
+  }
+
+  private void removeEventListenerRegistration() {
+    if (eventListenerRegistration != null) {
+      eventListenerRegistration.remove();
+    }
   }
 
   @Override
   protected void onExit(final int rc) {
-    eventListenerRegistration.remove();
+    removeEventListenerRegistration();
 
     synchronized (taskLock) {
       done = true;
@@ -185,7 +187,7 @@
 
   @Override
   public void destroy() {
-    eventListenerRegistration.remove();
+    removeEventListenerRegistration();
 
     final boolean exit;
     synchronized (taskLock) {
@@ -233,7 +235,7 @@
         // destroy() above, or it closed the stream and is no longer
         // accepting output. Either way terminate this instance.
         //
-        eventListenerRegistration.remove();
+        removeEventListenerRegistration();
         flush();
         onExit(0);
         return;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
index 7e74ef0..a7d529b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitRuleCommand.java
@@ -24,8 +24,7 @@
 /** Command that allows testing of prolog submit-rules in a live instance. */
 @CommandMetaData(name = "rule", description = "Test prolog submit rules")
 final class TestSubmitRuleCommand extends BaseTestPrologCommand {
-  @Inject
-  private TestSubmitRule view;
+  @Inject private TestSubmitRule view;
 
   @Override
   protected RestModifyView<RevisionResource, TestSubmitRuleInput> createView() {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
index 3a885f9..ebe8925 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/TestSubmitTypeCommand.java
@@ -24,8 +24,7 @@
 
 @CommandMetaData(name = "type", description = "Test prolog submit type")
 final class TestSubmitTypeCommand extends BaseTestPrologCommand {
-  @Inject
-  private TestSubmitType view;
+  @Inject private TestSubmitType view;
 
   @Override
   protected RestModifyView<RevisionResource, TestSubmitRuleInput> createView() {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
index 181b0c6..67dfe96 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
@@ -28,65 +28,51 @@
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.List;
 import org.eclipse.jgit.transport.PostUploadHook;
 import org.eclipse.jgit.transport.PostUploadHookChain;
 import org.eclipse.jgit.transport.PreUploadHook;
 import org.eclipse.jgit.transport.PreUploadHookChain;
 import org.eclipse.jgit.transport.UploadPack;
 
-import java.io.IOException;
-import java.util.List;
-
 /** Publishes Git repositories over SSH using the Git upload-pack protocol. */
 final class Upload extends AbstractGitCommand {
-  @Inject
-  private ReviewDb db;
+  @Inject private ReviewDb db;
 
-  @Inject
-  private TransferConfig config;
+  @Inject private TransferConfig config;
 
-  @Inject
-  private TagCache tagCache;
+  @Inject private TagCache tagCache;
 
-  @Inject
-  private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
 
-  @Inject
-  @Nullable
-  private SearchingChangeCacheImpl changeCache;
+  @Inject @Nullable private SearchingChangeCacheImpl changeCache;
 
-  @Inject
-  private DynamicSet<PreUploadHook> preUploadHooks;
+  @Inject private DynamicSet<PreUploadHook> preUploadHooks;
 
-  @Inject
-  private DynamicSet<PostUploadHook> postUploadHooks;
+  @Inject private DynamicSet<PostUploadHook> postUploadHooks;
 
-  @Inject
-  private UploadValidators.Factory uploadValidatorsFactory;
+  @Inject private UploadValidators.Factory uploadValidatorsFactory;
 
-  @Inject
-  private SshSession session;
+  @Inject private SshSession session;
 
   @Override
   protected void runImpl() throws IOException, Failure {
     if (!projectControl.canRunUploadPack()) {
-        throw new Failure(1, "fatal: upload-pack not permitted on this server");
+      throw new Failure(1, "fatal: upload-pack not permitted on this server");
     }
 
     final UploadPack up = new UploadPack(repo);
     up.setAdvertiseRefsHook(
         new VisibleRefFilter(
-            tagCache, changeNotesFactory, changeCache, repo, projectControl, db,
-            true));
+            tagCache, changeNotesFactory, changeCache, repo, projectControl, db, true));
     up.setPackConfig(config.getPackConfig());
     up.setTimeout(config.getTimeout());
-    up.setPostUploadHook(
-        PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
+    up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
 
     List<PreUploadHook> allPreUploadHooks = Lists.newArrayList(preUploadHooks);
-    allPreUploadHooks.add(uploadValidatorsFactory.create(project, repo,
-        session.getRemoteAddressAsString()));
+    allPreUploadHooks.add(
+        uploadValidatorsFactory.create(project, repo, session.getRemoteAddressAsString()));
     up.setPreUploadHook(PreUploadHookChain.newChain(allPreUploadHooks));
     try {
       up.upload(in, out, err);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 0edba4f..cafde99 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -18,11 +18,16 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.AllowedFormats;
 import com.google.gerrit.server.change.ArchiveFormat;
-import com.google.gerrit.server.change.GetArchive;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.inject.Inject;
-
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.api.ArchiveCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -36,80 +41,84 @@
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
 
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Allows getting archives for Git repositories over SSH using the Git
- * upload-archive protocol.
- */
+/** Allows getting archives for Git repositories over SSH using the Git upload-archive protocol. */
 public class UploadArchive extends AbstractGitCommand {
   /**
    * Options for parsing Git commands.
-   * <p>
-   * These options are not passed on command line, but received through input
-   * stream in pkt-line format.
+   *
+   * <p>These options are not passed on command line, but received through input stream in pkt-line
+   * format.
    */
   static class Options {
-    @Option(name = "-f", aliases = {"--format"}, usage = "Format of the"
-        + " resulting archive: tar or zip... If this option is not given, and"
-        + " the output file is specified, the format is inferred from the"
-        + " filename if possible (e.g. writing to \"foo.zip\" makes the output"
-        + " to be in the zip format). Otherwise the output format is tar.")
+    @Option(
+        name = "-f",
+        aliases = {"--format"},
+        usage =
+            "Format of the"
+                + " resulting archive: tar or zip... If this option is not given, and"
+                + " the output file is specified, the format is inferred from the"
+                + " filename if possible (e.g. writing to \"foo.zip\" makes the output"
+                + " to be in the zip format). Otherwise the output format is tar.")
     private String format = "tar";
 
-    @Option(name = "--prefix",
-        usage = "Prepend <prefix>/ to each filename in the archive.")
+    @Option(name = "--prefix", usage = "Prepend <prefix>/ to each filename in the archive.")
     private String prefix;
 
     @Option(name = "-0", usage = "Store the files instead of deflating them.")
     private boolean level0;
+
     @Option(name = "-1")
     private boolean level1;
+
     @Option(name = "-2")
     private boolean level2;
+
     @Option(name = "-3")
     private boolean level3;
+
     @Option(name = "-4")
     private boolean level4;
+
     @Option(name = "-5")
     private boolean level5;
+
     @Option(name = "-6")
     private boolean level6;
+
     @Option(name = "-7")
     private boolean level7;
+
     @Option(name = "-8")
     private boolean level8;
-    @Option(name = "-9", usage = "Highest and slowest compression level. You "
-        + "can specify any number from 1 to 9 to adjust compression speed and "
-        + "ratio.")
+
+    @Option(
+        name = "-9",
+        usage =
+            "Highest and slowest compression level. You "
+                + "can specify any number from 1 to 9 to adjust compression speed and "
+                + "ratio.")
     private boolean level9;
 
-    @Argument(index = 0, required = true, usage = "The tree or commit to "
-        + "produce an archive for.")
+    @Argument(index = 0, required = true, usage = "The tree or commit to produce an archive for.")
     private String treeIsh = "master";
 
-    @Argument(index = 1, multiValued = true, usage =
-        "Without an optional path parameter, all files and subdirectories of "
-        + "the current working directory are included in the archive. If one "
-        + "or more paths are specified, only these are included.")
+    @Argument(
+        index = 1,
+        multiValued = true,
+        usage =
+            "Without an optional path parameter, all files and subdirectories of "
+                + "the current working directory are included in the archive. If one "
+                + "or more paths are specified, only these are included.")
     private List<String> path;
   }
 
-  @Inject
-  private GetArchive.AllowedFormats allowedFormats;
-  @Inject
-  private ReviewDb db;
+  @Inject private AllowedFormats allowedFormats;
+  @Inject private ReviewDb db;
   private Options options = new Options();
 
   /**
-   * Read and parse arguments from input stream.
-   * This method gets the arguments from input stream, in Pkt-line format,
-   * then parses them to fill the options object.
+   * Read and parse arguments from input stream. This method gets the arguments from input stream,
+   * in Pkt-line format, then parses them to fill the options object.
    */
   protected void readArguments() throws IOException, Failure {
     String argCmd = "argument ";
@@ -117,7 +126,7 @@
 
     // Read arguments in Pkt-Line format
     PacketLineIn packetIn = new PacketLineIn(in);
-    for (;;) {
+    for (; ; ) {
       String s = packetIn.readString();
       if (s == PacketLineIn.END) {
         break;
@@ -167,13 +176,13 @@
 
       // Verify the user has permissions to read the specified reference
       if (!projectControl.allRefsAreVisible() && !canRead(treeId)) {
-          throw new Failure(5, "fatal: cannot perform upload-archive operation");
+        throw new Failure(5, "fatal: cannot perform upload-archive operation");
       }
 
-        // The archive is sent in DATA sideband channel
+      // The archive is sent in DATA sideband channel
       try (SideBandOutputStream sidebandOut =
-            new SideBandOutputStream(SideBandOutputStream.CH_DATA,
-                SideBandOutputStream.MAX_BUF, out)) {
+          new SideBandOutputStream(
+              SideBandOutputStream.CH_DATA, SideBandOutputStream.MAX_BUF, out)) {
         new ArchiveCommand(repo)
             .setFormat(f.name())
             .setFormatOptions(getFormatOptions(f))
@@ -189,8 +198,8 @@
     } catch (Failure f) {
       // Report the error in ERROR sideband channel
       try (SideBandOutputStream sidebandError =
-          new SideBandOutputStream(SideBandOutputStream.CH_ERROR,
-              SideBandOutputStream.MAX_BUF, out)) {
+          new SideBandOutputStream(
+              SideBandOutputStream.CH_ERROR, SideBandOutputStream.MAX_BUF, out)) {
         sidebandError.write(f.getMessage().getBytes(UTF_8));
         sidebandError.flush();
       }
@@ -203,12 +212,21 @@
 
   private Map<String, Object> getFormatOptions(ArchiveFormat f) {
     if (f == ArchiveFormat.ZIP) {
-      int value = Arrays.asList(options.level0, options.level1, options.level2,
-          options.level3, options.level4, options.level5, options.level6,
-          options.level7, options.level8, options.level9).indexOf(true);
+      int value =
+          Arrays.asList(
+                  options.level0,
+                  options.level1,
+                  options.level2,
+                  options.level3,
+                  options.level4,
+                  options.level5,
+                  options.level6,
+                  options.level7,
+                  options.level8,
+                  options.level9)
+              .indexOf(true);
       if (value >= 0) {
-        return ImmutableMap.<String, Object> of(
-            "level", Integer.valueOf(value));
+        return ImmutableMap.<String, Object>of("level", Integer.valueOf(value));
       }
     }
     return Collections.emptyMap();
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/VersionCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/VersionCommand.java
index 50f880d..8fac979 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/VersionCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/VersionCommand.java
@@ -20,8 +20,7 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 
-@CommandMetaData(name = "version", description = "Display gerrit version",
-  runsAt = MASTER_OR_SLAVE)
+@CommandMetaData(name = "version", description = "Display gerrit version", runsAt = MASTER_OR_SLAVE)
 final class VersionCommand extends SshCommand {
 
   @Override
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
index 3f4ca61..b44f0fc 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
@@ -21,24 +21,20 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Argument;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.ArrayList;
-import java.util.List;
-
 public class LfsPluginAuthCommand extends SshCommand {
-  private static final Logger log =
-      LoggerFactory.getLogger(LfsPluginAuthCommand.class);
-  private static final String CONFIGURATION_ERROR = "Server configuration error:"
-      + " LFS auth over SSH is not properly configured.";
+  private static final Logger log = LoggerFactory.getLogger(LfsPluginAuthCommand.class);
+  private static final String CONFIGURATION_ERROR =
+      "Server configuration error: LFS auth over SSH is not properly configured.";
 
   public interface LfsSshPluginAuth {
-    String authenticate(CurrentUser user, List<String> args)
-        throws UnloggedFailure, Failure;
+    String authenticate(CurrentUser user, List<String> args) throws UnloggedFailure, Failure;
   }
 
   public static class Module extends CommandModule {
@@ -65,8 +61,7 @@
   private List<String> args = new ArrayList<>();
 
   @Inject
-  LfsPluginAuthCommand(DynamicItem<LfsSshPluginAuth> auth,
-      Provider<CurrentUser> user) {
+  LfsPluginAuthCommand(DynamicItem<LfsSshPluginAuth> auth, Provider<CurrentUser> user) {
     this.auth = auth;
     this.user = user;
   }
diff --git a/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java b/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
index ae2a0a0..777cb4f 100644
--- a/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
+++ b/gerrit-sshd/src/test/java/com/google/gerrit/sshd/commands/ProjectConfigParamParserTest.java
@@ -17,16 +17,15 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.extensions.api.projects.ConfigValue;
-
-import org.junit.Before;
-import org.junit.Test;
-
 import java.util.Collections;
 import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
 
 public class ProjectConfigParamParserTest {
 
   private CreateProjectCommand cmd;
+
   @Before
   public void setUp() {
     cmd = new CreateProjectCommand();
diff --git a/gerrit-test-util/BUCK b/gerrit-test-util/BUCK
new file mode 100644
index 0000000..b2f20a5
--- /dev/null
+++ b/gerrit-test-util/BUCK
@@ -0,0 +1,9 @@
+java_library(
+  name = 'test_util',
+  srcs = glob(['src/main/java/**/*.java']),
+  visibility = ['PUBLIC'],
+  deps = [
+    '//gerrit-extension-api:api',
+    '//lib:truth',
+  ],
+)
diff --git a/gerrit-test-util/BUILD b/gerrit-test-util/BUILD
new file mode 100644
index 0000000..0cf37fb
--- /dev/null
+++ b/gerrit-test-util/BUILD
@@ -0,0 +1,12 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "test_util",
+    testonly = 1,
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-extension-api:api",
+        "//lib:truth",
+    ],
+)
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/client/RangeSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/client/RangeSubject.java
new file mode 100644
index 0000000..70f5ec6
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/client/RangeSubject.java
@@ -0,0 +1,72 @@
+// 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.client;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+
+public class RangeSubject extends Subject<RangeSubject, Comment.Range> {
+
+  private static final SubjectFactory<RangeSubject, Comment.Range> RANGE_SUBJECT_FACTORY =
+      new SubjectFactory<RangeSubject, Comment.Range>() {
+        @Override
+        public RangeSubject getSubject(FailureStrategy failureStrategy, Comment.Range range) {
+          return new RangeSubject(failureStrategy, range);
+        }
+      };
+
+  public static RangeSubject assertThat(Comment.Range range) {
+    return assertAbout(RANGE_SUBJECT_FACTORY).that(range);
+  }
+
+  private RangeSubject(FailureStrategy failureStrategy, Comment.Range range) {
+    super(failureStrategy, range);
+  }
+
+  public IntegerSubject startLine() {
+    return Truth.assertThat(actual().startLine).named("startLine");
+  }
+
+  public IntegerSubject startCharacter() {
+    return Truth.assertThat(actual().startCharacter).named("startCharacter");
+  }
+
+  public IntegerSubject endLine() {
+    return Truth.assertThat(actual().endLine).named("endLine");
+  }
+
+  public IntegerSubject endCharacter() {
+    return Truth.assertThat(actual().endCharacter).named("endCharacter");
+  }
+
+  public void isValid() {
+    isNotNull();
+    if (!actual().isValid()) {
+      fail("is valid");
+    }
+  }
+
+  public void isInvalid() {
+    isNotNull();
+    if (actual().isValid()) {
+      fail("is invalid");
+    }
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/CommitInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/CommitInfoSubject.java
new file mode 100644
index 0000000..b2717af
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/CommitInfoSubject.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.truth.ListSubject;
+
+public class CommitInfoSubject extends Subject<CommitInfoSubject, CommitInfo> {
+
+  private static final SubjectFactory<CommitInfoSubject, CommitInfo> COMMIT_INFO_SUBJECT_FACTORY =
+      new SubjectFactory<CommitInfoSubject, CommitInfo>() {
+        @Override
+        public CommitInfoSubject getSubject(
+            FailureStrategy failureStrategy, CommitInfo commitInfo) {
+          return new CommitInfoSubject(failureStrategy, commitInfo);
+        }
+      };
+
+  public static CommitInfoSubject assertThat(CommitInfo commitInfo) {
+    return assertAbout(COMMIT_INFO_SUBJECT_FACTORY).that(commitInfo);
+  }
+
+  private CommitInfoSubject(FailureStrategy failureStrategy, CommitInfo commitInfo) {
+    super(failureStrategy, commitInfo);
+  }
+
+  public StringSubject commit() {
+    isNotNull();
+    CommitInfo commitInfo = actual();
+    return Truth.assertThat(commitInfo.commit).named("commit");
+  }
+
+  public ListSubject<CommitInfoSubject, CommitInfo> parents() {
+    isNotNull();
+    CommitInfo commitInfo = actual();
+    return ListSubject.assertThat(commitInfo.parents, CommitInfoSubject::assertThat)
+        .named("parents");
+  }
+
+  public GitPersonSubject committer() {
+    isNotNull();
+    CommitInfo commitInfo = actual();
+    return GitPersonSubject.assertThat(commitInfo.committer).named("committer");
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java
new file mode 100644
index 0000000..95b2158
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/EditInfoSubject.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.truth.OptionalSubject;
+import java.util.Optional;
+
+public class EditInfoSubject extends Subject<EditInfoSubject, EditInfo> {
+
+  private static final SubjectFactory<EditInfoSubject, EditInfo> EDIT_INFO_SUBJECT_FACTORY =
+      new SubjectFactory<EditInfoSubject, EditInfo>() {
+        @Override
+        public EditInfoSubject getSubject(FailureStrategy failureStrategy, EditInfo editInfo) {
+          return new EditInfoSubject(failureStrategy, editInfo);
+        }
+      };
+
+  public static EditInfoSubject assertThat(EditInfo editInfo) {
+    return assertAbout(EDIT_INFO_SUBJECT_FACTORY).that(editInfo);
+  }
+
+  public static OptionalSubject<EditInfoSubject, EditInfo> assertThat(
+      Optional<EditInfo> editInfoOptional) {
+    return OptionalSubject.assertThat(editInfoOptional, EditInfoSubject::assertThat);
+  }
+
+  private EditInfoSubject(FailureStrategy failureStrategy, EditInfo editInfo) {
+    super(failureStrategy, editInfo);
+  }
+
+  public CommitInfoSubject commit() {
+    isNotNull();
+    EditInfo editInfo = actual();
+    return CommitInfoSubject.assertThat(editInfo.commit).named("commit");
+  }
+
+  public StringSubject baseRevision() {
+    isNotNull();
+    EditInfo editInfo = actual();
+    return Truth.assertThat(editInfo.baseRevision).named("baseRevision");
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java
new file mode 100644
index 0000000..f798622
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixReplacementInfoSubject.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.client.RangeSubject;
+
+public class FixReplacementInfoSubject
+    extends Subject<FixReplacementInfoSubject, FixReplacementInfo> {
+
+  private static final SubjectFactory<FixReplacementInfoSubject, FixReplacementInfo>
+      FIX_REPLACEMENT_INFO_SUBJECT_FACTORY =
+          new SubjectFactory<FixReplacementInfoSubject, FixReplacementInfo>() {
+            @Override
+            public FixReplacementInfoSubject getSubject(
+                FailureStrategy failureStrategy, FixReplacementInfo fixReplacementInfo) {
+              return new FixReplacementInfoSubject(failureStrategy, fixReplacementInfo);
+            }
+          };
+
+  public static FixReplacementInfoSubject assertThat(FixReplacementInfo fixReplacementInfo) {
+    return assertAbout(FIX_REPLACEMENT_INFO_SUBJECT_FACTORY).that(fixReplacementInfo);
+  }
+
+  private FixReplacementInfoSubject(
+      FailureStrategy failureStrategy, FixReplacementInfo fixReplacementInfo) {
+    super(failureStrategy, fixReplacementInfo);
+  }
+
+  public StringSubject path() {
+    return Truth.assertThat(actual().path).named("path");
+  }
+
+  public RangeSubject range() {
+    return RangeSubject.assertThat(actual().range).named("range");
+  }
+
+  public StringSubject replacement() {
+    return Truth.assertThat(actual().replacement).named("replacement");
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfoSubject.java
new file mode 100644
index 0000000..9af4d1f
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FixSuggestionInfoSubject.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.truth.ListSubject;
+
+public class FixSuggestionInfoSubject extends Subject<FixSuggestionInfoSubject, FixSuggestionInfo> {
+
+  private static final SubjectFactory<FixSuggestionInfoSubject, FixSuggestionInfo>
+      FIX_SUGGESTION_INFO_SUBJECT_FACTORY =
+          new SubjectFactory<FixSuggestionInfoSubject, FixSuggestionInfo>() {
+            @Override
+            public FixSuggestionInfoSubject getSubject(
+                FailureStrategy failureStrategy, FixSuggestionInfo fixSuggestionInfo) {
+              return new FixSuggestionInfoSubject(failureStrategy, fixSuggestionInfo);
+            }
+          };
+
+  public static FixSuggestionInfoSubject assertThat(FixSuggestionInfo fixSuggestionInfo) {
+    return assertAbout(FIX_SUGGESTION_INFO_SUBJECT_FACTORY).that(fixSuggestionInfo);
+  }
+
+  private FixSuggestionInfoSubject(
+      FailureStrategy failureStrategy, FixSuggestionInfo fixSuggestionInfo) {
+    super(failureStrategy, fixSuggestionInfo);
+  }
+
+  public StringSubject fixId() {
+    return Truth.assertThat(actual().fixId).named("fixId");
+  }
+
+  public ListSubject<FixReplacementInfoSubject, FixReplacementInfo> replacements() {
+    return ListSubject.assertThat(actual().replacements, FixReplacementInfoSubject::assertThat)
+        .named("replacements");
+  }
+
+  public FixReplacementInfoSubject onlyReplacement() {
+    return replacements().onlyElement();
+  }
+
+  public StringSubject description() {
+    return Truth.assertThat(actual().description).named("description");
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/GitPersonSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/GitPersonSubject.java
new file mode 100644
index 0000000..9ef06dc
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/GitPersonSubject.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import java.sql.Timestamp;
+
+public class GitPersonSubject extends Subject<GitPersonSubject, GitPerson> {
+
+  private static final SubjectFactory<GitPersonSubject, GitPerson> GIT_PERSON_SUBJECT_FACTORY =
+      new SubjectFactory<GitPersonSubject, GitPerson>() {
+        @Override
+        public GitPersonSubject getSubject(FailureStrategy failureStrategy, GitPerson gitPerson) {
+          return new GitPersonSubject(failureStrategy, gitPerson);
+        }
+      };
+
+  public static GitPersonSubject assertThat(GitPerson gitPerson) {
+    return assertAbout(GIT_PERSON_SUBJECT_FACTORY).that(gitPerson);
+  }
+
+  private GitPersonSubject(FailureStrategy failureStrategy, GitPerson gitPerson) {
+    super(failureStrategy, gitPerson);
+  }
+
+  public ComparableSubject<?, Timestamp> creationDate() {
+    isNotNull();
+    GitPerson gitPerson = actual();
+    return Truth.assertThat(gitPerson.date).named("creationDate");
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/PathSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/PathSubject.java
new file mode 100644
index 0000000..307c19e
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/PathSubject.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import java.nio.file.Path;
+
+public class PathSubject extends Subject<PathSubject, Path> {
+  private static final SubjectFactory<PathSubject, Path> PATH_SUBJECT_FACTORY =
+      new SubjectFactory<PathSubject, Path>() {
+        @Override
+        public PathSubject getSubject(FailureStrategy failureStrategy, Path path) {
+          return new PathSubject(failureStrategy, path);
+        }
+      };
+
+  private PathSubject(FailureStrategy failureStrategy, Path path) {
+    super(failureStrategy, path);
+  }
+
+  public static PathSubject assertThat(Path path) {
+    return assertAbout(PATH_SUBJECT_FACTORY).that(path);
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfoSubject.java
new file mode 100644
index 0000000..afa1b9b
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfoSubject.java
@@ -0,0 +1,60 @@
+// 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.common;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.gerrit.truth.ListSubject;
+import java.util.List;
+
+public class RobotCommentInfoSubject extends Subject<RobotCommentInfoSubject, RobotCommentInfo> {
+
+  private static final SubjectFactory<RobotCommentInfoSubject, RobotCommentInfo>
+      ROBOT_COMMENT_INFO_SUBJECT_FACTORY =
+          new SubjectFactory<RobotCommentInfoSubject, RobotCommentInfo>() {
+            @Override
+            public RobotCommentInfoSubject getSubject(
+                FailureStrategy failureStrategy, RobotCommentInfo robotCommentInfo) {
+              return new RobotCommentInfoSubject(failureStrategy, robotCommentInfo);
+            }
+          };
+
+  public static ListSubject<RobotCommentInfoSubject, RobotCommentInfo> assertThatList(
+      List<RobotCommentInfo> robotCommentInfos) {
+    return ListSubject.assertThat(robotCommentInfos, RobotCommentInfoSubject::assertThat)
+        .named("robotCommentInfos");
+  }
+
+  public static RobotCommentInfoSubject assertThat(RobotCommentInfo robotCommentInfo) {
+    return assertAbout(ROBOT_COMMENT_INFO_SUBJECT_FACTORY).that(robotCommentInfo);
+  }
+
+  private RobotCommentInfoSubject(
+      FailureStrategy failureStrategy, RobotCommentInfo robotCommentInfo) {
+    super(failureStrategy, robotCommentInfo);
+  }
+
+  public ListSubject<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
+    return ListSubject.assertThat(actual().fixSuggestions, FixSuggestionInfoSubject::assertThat)
+        .named("fixSuggestions");
+  }
+
+  public FixSuggestionInfoSubject onlyFixSuggestion() {
+    return fixSuggestions().onlyElement();
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
new file mode 100644
index 0000000..989ab0f
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.PrimitiveByteArraySubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.truth.OptionalSubject;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Optional;
+
+public class BinaryResultSubject extends Subject<BinaryResultSubject, BinaryResult> {
+
+  private static final SubjectFactory<BinaryResultSubject, BinaryResult>
+      BINARY_RESULT_SUBJECT_FACTORY =
+          new SubjectFactory<BinaryResultSubject, BinaryResult>() {
+            @Override
+            public BinaryResultSubject getSubject(
+                FailureStrategy failureStrategy, BinaryResult binaryResult) {
+              return new BinaryResultSubject(failureStrategy, binaryResult);
+            }
+          };
+
+  public static BinaryResultSubject assertThat(BinaryResult binaryResult) {
+    return assertAbout(BINARY_RESULT_SUBJECT_FACTORY).that(binaryResult);
+  }
+
+  public static OptionalSubject<BinaryResultSubject, BinaryResult> assertThat(
+      Optional<BinaryResult> binaryResultOptional) {
+    return OptionalSubject.assertThat(binaryResultOptional, BinaryResultSubject::assertThat);
+  }
+
+  private BinaryResultSubject(FailureStrategy failureStrategy, BinaryResult binaryResult) {
+    super(failureStrategy, binaryResult);
+  }
+
+  public PrimitiveByteArraySubject bytes() throws IOException {
+    isNotNull();
+    // We shouldn't close the BinaryResult within this method as it might still
+    // be used afterwards. Besides, closing it doesn't have an effect for most
+    // implementations of a BinaryResult.
+    BinaryResult binaryResult = actual();
+    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+    binaryResult.writeTo(byteArrayOutputStream);
+    byte[] bytes = byteArrayOutputStream.toByteArray();
+    return Truth.assertThat(bytes);
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/truth/ListSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/truth/ListSubject.java
new file mode 100644
index 0000000..e7f1074
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/truth/ListSubject.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.truth;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import java.util.List;
+import java.util.function.Function;
+
+public class ListSubject<S extends Subject<S, E>, E> extends IterableSubject {
+
+  private final Function<E, S> elementAssertThatFunction;
+
+  @SuppressWarnings("unchecked")
+  public static <S extends Subject<S, E>, E> ListSubject<S, E> assertThat(
+      List<E> list, Function<E, S> elementAssertThatFunction) {
+    // The ListSubjectFactory always returns ListSubjects.
+    // -> Casting is appropriate.
+    return (ListSubject<S, E>)
+        assertAbout(new ListSubjectFactory<>(elementAssertThatFunction)).that(list);
+  }
+
+  private ListSubject(
+      FailureStrategy failureStrategy, List<E> list, Function<E, S> elementAssertThatFunction) {
+    super(failureStrategy, list);
+    this.elementAssertThatFunction = elementAssertThatFunction;
+  }
+
+  public S element(int index) {
+    checkArgument(index >= 0, "index(%s) must be >= 0", index);
+    // The constructor only accepts lists.
+    // -> Casting is appropriate.
+    @SuppressWarnings("unchecked")
+    List<E> list = (List<E>) actual();
+    isNotNull();
+    if (index >= list.size()) {
+      fail("has an element at index " + index);
+    }
+    return elementAssertThatFunction.apply(list.get(index));
+  }
+
+  public S onlyElement() {
+    isNotNull();
+    hasSize(1);
+    return element(0);
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public ListSubject<S, E> named(String s, Object... objects) {
+    // This object is returned which is of type ListSubject.
+    // -> Casting is appropriate.
+    return (ListSubject<S, E>) super.named(s, objects);
+  }
+
+  private static class ListSubjectFactory<S extends Subject<S, T>, T>
+      extends SubjectFactory<IterableSubject, Iterable<?>> {
+
+    private Function<T, S> elementAssertThatFunction;
+
+    ListSubjectFactory(Function<T, S> elementAssertThatFunction) {
+      this.elementAssertThatFunction = elementAssertThatFunction;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public ListSubject<S, T> getSubject(FailureStrategy failureStrategy, Iterable<?> objects) {
+      // The constructor of ListSubject only accepts lists.
+      // -> Casting is appropriate.
+      return new ListSubject<>(failureStrategy, (List<T>) objects, elementAssertThatFunction);
+    }
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/truth/OptionalSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/truth/OptionalSubject.java
new file mode 100644
index 0000000..49e91a8
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/truth/OptionalSubject.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.truth;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.DefaultSubject;
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import java.util.Optional;
+import java.util.function.Function;
+
+public class OptionalSubject<S extends Subject<S, ? super T>, T>
+    extends Subject<OptionalSubject<S, T>, Optional<T>> {
+
+  private final Function<? super T, ? extends S> valueAssertThatFunction;
+
+  public static <S extends Subject<S, ? super T>, T> OptionalSubject<S, T> assertThat(
+      Optional<T> optional, Function<? super T, ? extends S> elementAssertThatFunction) {
+    OptionalSubjectFactory<S, T> optionalSubjectFactory =
+        new OptionalSubjectFactory<>(elementAssertThatFunction);
+    return assertAbout(optionalSubjectFactory).that(optional);
+  }
+
+  public static OptionalSubject<DefaultSubject, ?> assertThat(Optional<?> optional) {
+    // Unfortunately, we need to cast to DefaultSubject as Truth.assertThat()
+    // only returns Subject<DefaultSubject, Object>. There shouldn't be a way
+    // for that method not to return a DefaultSubject because the generic type
+    // definitions of a Subject are quite strict.
+    Function<Object, DefaultSubject> valueAssertThatFunction =
+        value -> (DefaultSubject) Truth.assertThat(value);
+    return assertThat(optional, valueAssertThatFunction);
+  }
+
+  private OptionalSubject(
+      FailureStrategy failureStrategy,
+      Optional<T> optional,
+      Function<? super T, ? extends S> valueAssertThatFunction) {
+    super(failureStrategy, optional);
+    this.valueAssertThatFunction = valueAssertThatFunction;
+  }
+
+  public void isPresent() {
+    isNotNull();
+    Optional<T> optional = actual();
+    if (!optional.isPresent()) {
+      fail("has a value");
+    }
+  }
+
+  public void isAbsent() {
+    isNotNull();
+    Optional<T> optional = actual();
+    if (optional.isPresent()) {
+      fail("does not have a value");
+    }
+  }
+
+  public void isEmpty() {
+    isAbsent();
+  }
+
+  public S value() {
+    isNotNull();
+    isPresent();
+    Optional<T> optional = actual();
+    return valueAssertThatFunction.apply(optional.get());
+  }
+
+  private static class OptionalSubjectFactory<S extends Subject<S, ? super T>, T>
+      extends SubjectFactory<OptionalSubject<S, T>, Optional<T>> {
+
+    private Function<? super T, ? extends S> valueAssertThatFunction;
+
+    OptionalSubjectFactory(Function<? super T, ? extends S> valueAssertThatFunction) {
+      this.valueAssertThatFunction = valueAssertThatFunction;
+    }
+
+    @Override
+    public OptionalSubject<S, T> getSubject(FailureStrategy failureStrategy, Optional<T> optional) {
+      return new OptionalSubject<>(failureStrategy, optional, valueAssertThatFunction);
+    }
+  }
+}
diff --git a/gerrit-util-cli/BUCK b/gerrit-util-cli/BUCK
deleted file mode 100644
index 8cdc2dc..0000000
--- a/gerrit-util-cli/BUCK
+++ /dev/null
@@ -1,13 +0,0 @@
-java_library(
-  name = 'cli',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//lib:args4j',
-    '//lib:guava',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-  ],
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-util-cli/BUILD b/gerrit-util-cli/BUILD
index f3be5f3..3d31f18 100644
--- a/gerrit-util-cli/BUILD
+++ b/gerrit-util-cli/BUILD
@@ -1,13 +1,15 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'cli',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    '//gerrit-common:annotations',
-    '//gerrit-common:server',
-    '//lib:args4j',
-    '//lib:guava',
-    '//lib/guice:guice',
-    '//lib/guice:guice-assistedinject',
-  ],
-  visibility = ['//visibility:public'],
+    name = "cli",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+    ],
 )
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
index b5888b50..11f380d 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -35,12 +35,20 @@
 package com.google.gerrit.util.cli;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-
+import java.io.StringWriter;
+import java.io.Writer;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedElement;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.ResourceBundle;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.IllegalAnnotationError;
@@ -53,23 +61,12 @@
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Setter;
 
-import java.io.StringWriter;
-import java.io.Writer;
-import java.lang.annotation.Annotation;
-import java.lang.reflect.AnnotatedElement;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.ResourceBundle;
-
 /**
  * Extended command line parser which handles --foo=value arguments.
- * <p>
- * The args4j package does not natively handle --foo=value and instead prefers
- * to see --foo value on the command line. Many users are used to the GNU style
- * --foo=value long option, so we convert from the GNU style format to the
- * args4j style format prior to invoking args4j for parsing.
+ *
+ * <p>The args4j package does not natively handle --foo=value and instead prefers to see --foo value
+ * on the command line. Many users are used to the GNU style --foo=value long option, so we convert
+ * from the GNU style format to the args4j style format prior to invoking args4j for parsing.
  */
 public class CmdLineParser {
   public interface Factory {
@@ -83,16 +80,13 @@
   private Map<String, OptionHandler> options;
 
   /**
-   * Creates a new command line owner that parses arguments/options and set them
-   * into the given object.
+   * Creates a new command line owner that parses arguments/options and set them into the given
+   * object.
    *
-   * @param bean instance of a class annotated by
-   *        {@link org.kohsuke.args4j.Option} and
-   *        {@link org.kohsuke.args4j.Argument}. this object will receive
-   *        values.
-   *
-   * @throws IllegalAnnotationError if the option bean class is using args4j
-   *         annotations incorrectly.
+   * @param bean instance of a class annotated by {@link org.kohsuke.args4j.Option} and {@link
+   *     org.kohsuke.args4j.Argument}. this object will receive values.
+   * @throws IllegalAnnotationError if the option bean class is using args4j annotations
+   *     incorrectly.
    */
   @Inject
   public CmdLineParser(OptionHandlers handlers, @Assisted final Object bean)
@@ -222,9 +216,8 @@
     parser.parseArgument(tmp.toArray(new String[tmp.size()]));
   }
 
-  public void parseOptionMap(Map<String, String[]> parameters)
-      throws CmdLineException {
-    Multimap<String, String> map = LinkedHashMultimap.create();
+  public void parseOptionMap(Map<String, String[]> parameters) throws CmdLineException {
+    ListMultimap<String, String> map = MultimapBuilder.hashKeys().arrayListValues().build();
     for (Map.Entry<String, String[]> ent : parameters.entrySet()) {
       for (String val : ent.getValue()) {
         map.put(ent.getKey(), val);
@@ -233,8 +226,7 @@
     parseOptionMap(map);
   }
 
-  public void parseOptionMap(Multimap<String, String> params)
-      throws CmdLineException {
+  public void parseOptionMap(ListMultimap<String, String> params) throws CmdLineException {
     List<String> tmp = Lists.newArrayListWithCapacity(2 * params.size());
     for (final String key : params.keySet()) {
       String name = makeOption(key);
@@ -298,28 +290,33 @@
   }
 
   private boolean toBoolean(String name, String value) throws CmdLineException {
-    if ("true".equals(value) || "t".equals(value)
-        || "yes".equals(value) || "y".equals(value)
+    if ("true".equals(value)
+        || "t".equals(value)
+        || "yes".equals(value)
+        || "y".equals(value)
         || "on".equals(value)
         || "1".equals(value)
-        || value == null || "".equals(value)) {
+        || value == null
+        || "".equals(value)) {
       return true;
     }
 
-    if ("false".equals(value) || "f".equals(value)
-        || "no".equals(value) || "n".equals(value)
+    if ("false".equals(value)
+        || "f".equals(value)
+        || "no".equals(value)
+        || "n".equals(value)
         || "off".equals(value)
         || "0".equals(value)) {
       return false;
     }
 
-    throw new CmdLineException(parser, String.format(
-        "invalid boolean \"%s=%s\"", name, value));
+    throw new CmdLineException(parser, String.format("invalid boolean \"%s=%s\"", name, value));
   }
 
   private class MyParser extends org.kohsuke.args4j.CmdLineParser {
     @SuppressWarnings("rawtypes")
     private List<OptionHandler> optionsList;
+
     private HelpOption help;
 
     MyParser(final Object bean) {
@@ -329,8 +326,7 @@
 
     @SuppressWarnings({"unchecked", "rawtypes"})
     @Override
-    protected OptionHandler createOptionHandler(final OptionDef option,
-        final Setter setter) {
+    protected OptionHandler createOptionHandler(final OptionDef option, final Setter setter) {
       if (isHandlerSpecified(option) || isEnum(setter) || isPrimitive(setter)) {
         return add(super.createOptionHandler(option, setter));
       }
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/EndOfOptionsHandler.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/EndOfOptionsHandler.java
index 2c7f731..4a24ac9 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/EndOfOptionsHandler.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/EndOfOptionsHandler.java
@@ -23,8 +23,8 @@
 
 /** Typically used with {@code @Option(name="--")} to signal end of options. */
 public class EndOfOptionsHandler extends OptionHandler<Boolean> {
-  public EndOfOptionsHandler(CmdLineParser parser, OptionDef option,
-      Setter<? super Boolean> setter) {
+  public EndOfOptionsHandler(
+      CmdLineParser parser, OptionDef option, Setter<? super Boolean> setter) {
     super(parser, option, setter);
   }
 
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerFactory.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerFactory.java
index 8e99778..47477a2 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerFactory.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerFactory.java
@@ -20,6 +20,6 @@
 
 /** Creates an args4j OptionHandler through a Guice Injector. */
 public interface OptionHandlerFactory<T> {
-  OptionHandler<T> create(org.kohsuke.args4j.CmdLineParser cmdLineParser,
-      OptionDef optionDef, Setter<T> setter);
+  OptionHandler<T> create(
+      org.kohsuke.args4j.CmdLineParser cmdLineParser, OptionDef optionDef, Setter<T> setter);
 }
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java
index 1ea73cc..582bee2 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java
@@ -18,10 +18,8 @@
 import com.google.inject.Module;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.util.Types;
-
-import org.kohsuke.args4j.spi.OptionHandler;
-
 import java.lang.reflect.Type;
+import org.kohsuke.args4j.spi.OptionHandler;
 
 /** Utilities to support creating OptionHandler instances. */
 public class OptionHandlerUtil {
@@ -39,11 +37,8 @@
   }
 
   public static <T> Module moduleFor(final Class<T> type, Class<? extends OptionHandler<T>> impl) {
-    return new FactoryModuleBuilder()
-        .implement(handlerOf(type), impl)
-        .build(keyFor(type));
+    return new FactoryModuleBuilder().implement(handlerOf(type), impl).build(keyFor(type));
   }
 
-  private OptionHandlerUtil() {
-  }
+  private OptionHandlerUtil() {}
 }
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlers.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlers.java
index 50cad12..84a0809 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlers.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlers.java
@@ -23,15 +23,13 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
-
 import java.lang.reflect.ParameterizedType;
 import java.util.Map.Entry;
 
 @Singleton
 public class OptionHandlers {
   public static OptionHandlers empty() {
-    ImmutableMap<Class<?>, Provider<OptionHandlerFactory<?>>> m =
-        ImmutableMap.of();
+    ImmutableMap<Class<?>, Provider<OptionHandlerFactory<?>>> m = ImmutableMap.of();
     return new OptionHandlers(m);
   }
 
@@ -53,8 +51,7 @@
   }
 
   private static ImmutableMap<Class<?>, Provider<OptionHandlerFactory<?>>> build(Injector i) {
-    ImmutableMap.Builder<Class<?>, Provider<OptionHandlerFactory<?>>> map =
-        ImmutableMap.builder();
+    ImmutableMap.Builder<Class<?>, Provider<OptionHandlerFactory<?>>> map = ImmutableMap.builder();
     for (; i != null; i = i.getParent()) {
       for (Entry<Key<?>, Binding<?>> e : i.getBindings().entrySet()) {
         TypeLiteral<?> type = e.getKey().getTypeLiteral();
diff --git a/gerrit-util-http/BUCK b/gerrit-util-http/BUCK
deleted file mode 100644
index cfab096..0000000
--- a/gerrit-util-http/BUCK
+++ /dev/null
@@ -1,40 +0,0 @@
-java_library(
-  name = 'http',
-  srcs = glob(['src/main/java/**/*.java']),
-  provided_deps = ['//lib:servlet-api-3_1'],
-  visibility = ['PUBLIC'],
-)
-
-TESTUTIL_SRCS = glob(['src/test/**/testutil/**/*.java'])
-
-java_library(
-  name = 'testutil',
-  srcs = TESTUTIL_SRCS,
-  deps = [
-    '//gerrit-extension-api:api',
-    '//lib:guava',
-    '//lib:servlet-api-3_1',
-    '//lib/httpcomponents:httpclient',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-java_test(
-  name = 'http_tests',
-  srcs = glob(
-    ['src/test/java/**/*.java'],
-    excludes = TESTUTIL_SRCS,
-  ),
-  deps = [
-    ':http',
-    ':testutil',
-    '//lib:junit',
-    '//lib:servlet-api-3_1',
-    '//lib:truth',
-    '//lib/easymock:easymock',
-  ],
-  source_under_test = [':http'],
-  # TODO(sop) Remove after Buck supports Eclipse
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/gerrit-util-http/BUILD b/gerrit-util-http/BUILD
index 0e3ac0e..f82ebdb 100644
--- a/gerrit-util-http/BUILD
+++ b/gerrit-util-http/BUILD
@@ -1,39 +1,41 @@
-load('//tools/bzl:junit.bzl', 'junit_tests')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
 java_library(
-  name = 'http',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = ['//lib:servlet-api-3_1'],
-  visibility = ['//visibility:public'],
+    name = "http",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = ["//lib:servlet-api-3_1"],
 )
 
-TESTUTIL_SRCS = glob(['src/test/**/testutil/**/*.java'])
+TESTUTIL_SRCS = glob(["src/test/**/testutil/**/*.java"])
 
 java_library(
-  name = 'testutil',
-  srcs = TESTUTIL_SRCS,
-  deps = [
-    '//gerrit-extension-api:api',
-    '//lib:guava',
-    '//lib:servlet-api-3_1',
-    '//lib/httpcomponents:httpclient',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "testutil",
+    testonly = 1,
+    srcs = TESTUTIL_SRCS,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-extension-api:api",
+        "//lib:guava",
+        "//lib:servlet-api-3_1",
+        "//lib/httpcomponents:httpclient",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
 )
 
 junit_tests(
-  name = 'http_tests',
-  srcs = glob(
-    ['src/test/java/**/*.java'],
-    exclude = TESTUTIL_SRCS,
-  ),
-  deps = [
-    ':http',
-    ':testutil',
-    '//lib:junit',
-    '//lib:servlet-api-3_1-without-neverlink',
-    '//lib:truth',
-    '//lib/easymock:easymock',
-  ],
+    name = "http_tests",
+    srcs = glob(
+        ["src/test/java/**/*.java"],
+        exclude = TESTUTIL_SRCS,
+    ),
+    deps = [
+        ":http",
+        ":testutil",
+        "//lib:junit",
+        "//lib:servlet-api-3_1-without-neverlink",
+        "//lib:truth",
+        "//lib/easymock",
+    ],
 )
diff --git a/gerrit-util-http/src/main/java/com/google/gerrit/util/http/RequestUtil.java b/gerrit-util-http/src/main/java/com/google/gerrit/util/http/RequestUtil.java
index 888ad2f..2a359ca 100644
--- a/gerrit-util-http/src/main/java/com/google/gerrit/util/http/RequestUtil.java
+++ b/gerrit-util-http/src/main/java/com/google/gerrit/util/http/RequestUtil.java
@@ -31,8 +31,8 @@
   }
 
   /**
-   * @return the same value as {@link HttpServletRequest#getPathInfo()}, but
-   *     without decoding URL-encoded characters.
+   * @return the same value as {@link HttpServletRequest#getPathInfo()}, but without decoding
+   *     URL-encoded characters.
    */
   public static String getEncodedPathInfo(HttpServletRequest req) {
     // CS IGNORE LineLength FOR NEXT 3 LINES. REASON: URL.
@@ -41,8 +41,8 @@
     String servletPath = req.getServletPath();
     int servletPathLength = servletPath.length();
     String requestUri = req.getRequestURI();
-    String pathInfo = requestUri.substring(req.getContextPath().length())
-        .replaceAll("[/]{2,}", "/");
+    String pathInfo =
+        requestUri.substring(req.getContextPath().length()).replaceAll("[/]{2,}", "/");
     if (pathInfo.startsWith(servletPath)) {
       pathInfo = pathInfo.substring(servletPathLength);
       // Corner case: when servlet path & request path match exactly (without
@@ -56,6 +56,5 @@
     return pathInfo;
   }
 
-  private RequestUtil() {
-  }
+  private RequestUtil() {}
 }
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java
index e656e56..48b7b9c 100644
--- a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/RequestUtilTest.java
@@ -17,48 +17,46 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
-
 import org.junit.Test;
 
 public class RequestUtilTest {
   @Test
   public void emptyContextPath() {
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("", "/s", "/foo/bar"))).isEqualTo("/foo/bar");
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("", "/s", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar")))
+        .isEqualTo("/foo/bar");
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar")))
+        .isEqualTo("/foo%2Fbar");
   }
 
   @Test
   public void emptyServletPath() {
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("", "/c", "/foo/bar"))).isEqualTo("/foo/bar");
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("", "/c", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar")))
+        .isEqualTo("/foo/bar");
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar")))
+        .isEqualTo("/foo%2Fbar");
   }
 
   @Test
   public void trailingSlashes() {
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("/c", "/s", "/foo/bar/"))).isEqualTo("/foo/bar/");
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("/c", "/s", "/foo/bar///"))).isEqualTo("/foo/bar/");
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("/c", "/s", "/foo%2Fbar/"))).isEqualTo("/foo%2Fbar/");
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("/c", "/s", "/foo%2Fbar///"))).isEqualTo("/foo%2Fbar/");
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/")))
+        .isEqualTo("/foo/bar/");
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///")))
+        .isEqualTo("/foo/bar/");
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/")))
+        .isEqualTo("/foo%2Fbar/");
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
+        .isEqualTo("/foo%2Fbar/");
   }
 
   @Test
   public void emptyPathInfo() {
-    assertThat(RequestUtil.getEncodedPathInfo(
-        fakeRequest("/c", "/s", ""))).isNull();
+    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
   }
 
-  private FakeHttpServletRequest fakeRequest(String contextPath,
-      String servletPath, String pathInfo) {
-    FakeHttpServletRequest req = new FakeHttpServletRequest(
-        "gerrit.example.com", 80, contextPath, servletPath);
+  private FakeHttpServletRequest fakeRequest(
+      String contextPath, String servletPath, String pathInfo) {
+    FakeHttpServletRequest req =
+        new FakeHttpServletRequest("gerrit.example.com", 80, contextPath, servletPath);
     return req.setPathInfo(pathInfo);
   }
 }
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 3991b95..cb8ed39 100644
--- a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -18,27 +18,24 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Function;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.restapi.Url;
-
-import org.apache.http.client.utils.DateUtils;
-
 import java.io.BufferedReader;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.security.Principal;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Enumeration;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
-
 import javax.servlet.AsyncContext;
 import javax.servlet.DispatcherType;
 import javax.servlet.RequestDispatcher;
@@ -56,6 +53,8 @@
 /** Simple fake implementation of {@link HttpServletRequest}. */
 public class FakeHttpServletRequest implements HttpServletRequest {
   public static final String SERVLET_PATH = "/b";
+  public static final DateTimeFormatter rfcDateformatter =
+      DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
 
   private final Map<String, Object> attributes;
   private final ListMultimap<String, String> headers;
@@ -71,8 +70,7 @@
     this("gerrit.example.com", 80, "", SERVLET_PATH);
   }
 
-  public FakeHttpServletRequest(String hostName, int port, String contextPath,
-      String servletPath) {
+  public FakeHttpServletRequest(String hostName, int port, String contextPath, String servletPath) {
     this.hostName = checkNotNull(hostName, "hostName");
     checkArgument(port > 0);
     this.port = port;
@@ -143,18 +141,10 @@
     return Iterables.getFirst(parameters.get(name), null);
   }
 
-  private static final Function<Collection<String>, String[]> STRING_COLLECTION_TO_ARRAY =
-      new Function<Collection<String>, String[]>() {
-        @Override
-        public String[] apply(Collection<String> values) {
-          return values.toArray(new String[0]);
-        }
-      };
-
   @Override
   public Map<String, String[]> getParameterMap() {
     return Collections.unmodifiableMap(
-        Maps.transformValues(parameters.asMap(), STRING_COLLECTION_TO_ARRAY));
+        Maps.transformValues(parameters.asMap(), vs -> vs.toArray(new String[0])));
   }
 
   @Override
@@ -164,7 +154,7 @@
 
   @Override
   public String[] getParameterValues(String name) {
-    return STRING_COLLECTION_TO_ARRAY.apply(parameters.get(name));
+    return parameters.get(name).toArray(new String[0]);
   }
 
   public void setQueryString(String qs) {
@@ -172,7 +162,8 @@
     for (String entry : Splitter.on('&').split(qs)) {
       List<String> kv = Splitter.on('=').limit(2).splitToList(entry);
       try {
-        params.put(URLDecoder.decode(kv.get(0), UTF_8.name()),
+        params.put(
+            URLDecoder.decode(kv.get(0), UTF_8.name()),
             kv.size() == 2 ? URLDecoder.decode(kv.get(1), UTF_8.name()) : "");
       } catch (UnsupportedEncodingException e) {
         throw new IllegalArgumentException(e);
@@ -270,7 +261,7 @@
   @Override
   public long getDateHeader(String name) {
     String v = getHeader(name);
-    return v != null ? DateUtils.parseDate(v).getTime() : 0;
+    return v == null ? 0 : rfcDateformatter.parse(v, Instant::from).getEpochSecond();
   }
 
   @Override
@@ -476,8 +467,7 @@
   }
 
   @Override
-  public <T extends HttpUpgradeHandler> T upgrade(
-      Class<T> httpUpgradeHandlerClass) {
+  public <T extends HttpUpgradeHandler> T upgrade(Class<T> httpUpgradeHandlerClass) {
     throw new UnsupportedOperationException();
   }
 
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
index 442c474..f6b3e30 100644
--- a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
@@ -23,20 +23,18 @@
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.net.HttpHeaders;
-
-import org.eclipse.jgit.util.RawParseUtils;
-
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.nio.charset.Charset;
 import java.util.Collection;
 import java.util.Locale;
-
 import javax.servlet.ServletOutputStream;
 import javax.servlet.WriteListener;
 import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.util.RawParseUtils;
 
 /** Simple fake implementation of {@link HttpServletResponse}. */
 public class FakeHttpServletResponse implements HttpServletResponse {
@@ -48,8 +46,7 @@
   private ServletOutputStream outputStream;
   private PrintWriter writer;
 
-  public FakeHttpServletResponse() {
-  }
+  public FakeHttpServletResponse() {}
 
   @Override
   public synchronized void flushBuffer() throws IOException {
@@ -85,22 +82,23 @@
   public synchronized ServletOutputStream getOutputStream() {
     checkState(writer == null, "getWriter() already called");
     if (outputStream == null) {
-      outputStream = new ServletOutputStream() {
-        @Override
-        public void write(int c) throws IOException {
-          actualBody.write(c);
-        }
+      outputStream =
+          new ServletOutputStream() {
+            @Override
+            public void write(int c) throws IOException {
+              actualBody.write(c);
+            }
 
-        @Override
-        public boolean isReady() {
-          return true;
-        }
+            @Override
+            public boolean isReady() {
+              return true;
+            }
 
-        @Override
-        public void setWriteListener(WriteListener listener) {
-          throw new UnsupportedOperationException();
-        }
-      };
+            @Override
+            public void setWriteListener(WriteListener listener) {
+              throw new UnsupportedOperationException();
+            }
+          };
     }
     return outputStream;
   }
@@ -109,7 +107,7 @@
   public synchronized PrintWriter getWriter() {
     checkState(outputStream == null, "getOutputStream() already called");
     if (writer == null) {
-      writer = new PrintWriter(actualBody);
+      writer = new PrintWriter(new OutputStreamWriter(actualBody, UTF_8));
     }
     return writer;
   }
@@ -136,8 +134,7 @@
 
   @Override
   public void setCharacterEncoding(String name) {
-    checkArgument(UTF_8.equals(Charset.forName(name)),
-        "unsupported charset: %s", name);
+    checkArgument(UTF_8.equals(Charset.forName(name)), "unsupported charset: %s", name);
   }
 
   @Override
@@ -265,8 +262,7 @@
 
   @Override
   public String getHeader(String name) {
-    return Iterables.getFirst(
-        headers.get(checkNotNull(name.toLowerCase())), null);
+    return Iterables.getFirst(headers.get(checkNotNull(name.toLowerCase())), null);
   }
 
   @Override
diff --git a/gerrit-util-ssl/BUCK b/gerrit-util-ssl/BUCK
deleted file mode 100644
index 068f34c..0000000
--- a/gerrit-util-ssl/BUCK
+++ /dev/null
@@ -1,5 +0,0 @@
-java_library(
-  name = 'ssl',
-  srcs = glob(['src/main/java/**/*.java']),
-  visibility = ['PUBLIC'],
-)
diff --git a/gerrit-util-ssl/BUILD b/gerrit-util-ssl/BUILD
index 6333d45..616c8bf 100644
--- a/gerrit-util-ssl/BUILD
+++ b/gerrit-util-ssl/BUILD
@@ -1,5 +1,7 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'ssl',
-  srcs = glob(['src/main/java/**/*.java']),
-  visibility = ['//visibility:public'],
+    name = "ssl",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
 )
diff --git a/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java b/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
index b8af85e..171e059 100644
--- a/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
+++ b/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
@@ -21,7 +21,6 @@
 import java.security.GeneralSecurityException;
 import java.security.SecureRandom;
 import java.security.cert.X509Certificate;
-
 import javax.net.SocketFactory;
 import javax.net.ssl.SSLContext;
 import javax.net.ssl.SSLSocketFactory;
@@ -33,20 +32,19 @@
   private static final BlindSSLSocketFactory INSTANCE;
 
   static {
-    final X509TrustManager dummyTrustManager = new X509TrustManager() {
-      @Override
-      public X509Certificate[] getAcceptedIssuers() {
-        return null;
-      }
+    final X509TrustManager dummyTrustManager =
+        new X509TrustManager() {
+          @Override
+          public X509Certificate[] getAcceptedIssuers() {
+            return null;
+          }
 
-      @Override
-      public void checkClientTrusted(X509Certificate[] chain, String authType) {
-      }
+          @Override
+          public void checkClientTrusted(X509Certificate[] chain, String authType) {}
 
-      @Override
-      public void checkServerTrusted(X509Certificate[] chain, String authType) {
-      }
-    };
+          @Override
+          public void checkServerTrusted(X509Certificate[] chain, String authType) {}
+        };
 
     try {
       final SSLContext context = SSLContext.getInstance("SSL");
@@ -91,8 +89,7 @@
   }
 
   @Override
-  public Socket createSocket(String host, int port) throws IOException,
-      UnknownHostException {
+  public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
     return sslFactory.createSocket(host, port);
   }
 
@@ -102,14 +99,14 @@
   }
 
   @Override
-  public Socket createSocket(String host, int port, InetAddress localHost,
-      int localPort) throws IOException, UnknownHostException {
+  public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
+      throws IOException, UnknownHostException {
     return sslFactory.createSocket(host, port, localHost, localPort);
   }
 
   @Override
-  public Socket createSocket(InetAddress address, int port,
-      InetAddress localAddress, int localPort) throws IOException {
+  public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
+      throws IOException {
     return sslFactory.createSocket(address, port, localAddress, localPort);
   }
 }
diff --git a/gerrit-war/BUCK b/gerrit-war/BUCK
deleted file mode 100644
index 6d74a83..0000000
--- a/gerrit-war/BUCK
+++ /dev/null
@@ -1,76 +0,0 @@
-include_defs('//tools/git.defs')
-
-java_library(
-  name = 'init',
-  srcs = glob(['src/main/java/**/*.java']),
-  deps = [
-    '//gerrit-cache-h2:cache-h2',
-    '//gerrit-extension-api:api',
-    '//gerrit-gpg:gpg',
-    '//gerrit-httpd:httpd',
-    '//gerrit-lucene:lucene',
-    '//gerrit-oauth:oauth',
-    '//gerrit-openid:openid',
-    '//gerrit-pgm:http',
-    '//gerrit-pgm:init',
-    '//gerrit-pgm:init-api',
-    '//gerrit-pgm:util',
-    '//gerrit-reviewdb:server',
-    '//gerrit-server:server',
-    '//gerrit-server/src/main/prolog:common',
-    '//gerrit-sshd:sshd',
-    '//lib:guava',
-    '//lib:gwtorm',
-    '//lib/guice:guice',
-    '//lib/guice:guice-servlet',
-    '//lib/jgit/org.eclipse.jgit:jgit',
-    '//lib/log:api',
-  ],
-  provided_deps = ['//lib:servlet-api-3_1'],
-  visibility = [
-    '//:',
-    '//gerrit-gwtdebug:gwtdebug',
-    '//tools/eclipse:classpath',
-  ],
-)
-
-genrule(
-  name = 'webapp_assets',
-  cmd = 'cd src/main/webapp; zip -qr $OUT .',
-  srcs = glob(['src/main/webapp/**/*']),
-  out = 'webapp_assets.zip',
-  visibility = ['//:'],
-)
-
-genrule(
-  name = 'log4j-config__jar',
-  cmd = 'jar cf $OUT -C src/main/resources .',
-  srcs = ['src/main/resources/log4j.properties'],
-  out = 'log4j-config.jar',
-)
-
-prebuilt_jar(
-  name = 'log4j-config',
-  binary_jar = ':log4j-config__jar',
-  visibility = [
-    '//:',
-    '//tools/eclipse:classpath',
-  ],
-)
-
-prebuilt_jar(
-  name = 'version',
-  binary_jar = ':gen_version',
-  visibility = ['//:'],
-)
-
-genrule(
-  name = 'gen_version',
-  cmd = ';'.join([
-    'cd $TMP',
-    'mkdir -p com/google/gerrit/common',
-    'echo "%s" >com/google/gerrit/common/Version' % git_describe(),
-    'zip -9Dqr $OUT .',
-  ]),
-  out = 'version.jar',
-)
diff --git a/gerrit-war/BUILD b/gerrit-war/BUILD
new file mode 100644
index 0000000..9ea3676
--- /dev/null
+++ b/gerrit-war/BUILD
@@ -0,0 +1,73 @@
+load("@rules_java//java:defs.bzl", "java_import", "java_library")
+load("//tools/bzl:genrule2.bzl", "genrule2")
+
+java_library(
+    name = "init",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-cache-h2:cache-h2",
+        "//gerrit-cache-mem:mem",
+        "//gerrit-elasticsearch:elasticsearch",
+        "//gerrit-extension-api:api",
+        "//gerrit-gpg:gpg",
+        "//gerrit-httpd:httpd",
+        "//gerrit-lucene:lucene",
+        "//gerrit-oauth:oauth",
+        "//gerrit-openid:openid",
+        "//gerrit-pgm:http",
+        "//gerrit-pgm:init",
+        "//gerrit-pgm:init-api",
+        "//gerrit-pgm:util",
+        "//gerrit-reviewdb:server",
+        "//gerrit-server:server",
+        "//gerrit-server/src/main/prolog:common",
+        "//gerrit-sshd:sshd",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/guice",
+        "//lib/guice:guice-servlet",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
+
+genrule2(
+    name = "webapp_assets",
+    srcs = glob(["src/main/webapp/**/*"]),
+    outs = ["webapp_assets.zip"],
+    cmd = "cd gerrit-war/src/main/webapp; zip -qr $$ROOT/$@ .",
+    visibility = ["//visibility:public"],
+)
+
+java_import(
+    name = "log4j-config",
+    jars = [":log4j-config__jar"],
+    visibility = ["//visibility:public"],
+)
+
+genrule2(
+    name = "log4j-config__jar",
+    srcs = ["src/main/resources/log4j.properties"],
+    outs = ["log4j-config.jar"],
+    cmd = "cd gerrit-war/src/main/resources && zip -9Dqr $$ROOT/$@ .",
+)
+
+java_import(
+    name = "version",
+    jars = [":gen_version"],
+    visibility = ["//visibility:public"],
+)
+
+genrule2(
+    name = "gen_version",
+    outs = ["gen_version.jar"],
+    cmd = " && ".join([
+        "cd $$TMP",
+        "mkdir -p com/google/gerrit/common",
+        "cat $$ROOT/$(location //:version.txt) >com/google/gerrit/common/Version",
+        "zip -9Dqr $$ROOT/$@ .",
+    ]),
+    tools = ["//:version.txt"],
+)
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index a32669c..c885b1d 100644
--- a/gerrit-war/pom.xml
+++ b/gerrit-war/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.13.14</version>
+  <version>2.14.23-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -23,7 +23,10 @@
 
   <developers>
     <developer>
-      <name>Andrew Bonventre</name>
+      <name>Alice Kober-Sotzek</name>
+    </developer>
+    <developer>
+      <name>Ben Rohlfs</name>
     </developer>
     <developer>
       <name>Dave Borowitz</name>
@@ -38,16 +41,28 @@
       <name>Edwin Kempin</name>
     </developer>
     <developer>
+      <name>Han-Wen Nienhuys</name>
+    </developer>
+    <developer>
       <name>Hugo Arès</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
-      <name>Saša Živkov</name>
+      <name>Ole Rehmsen</name>
     </developer>
     <developer>
-      <name>Shawn Pearce</name>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
+      <name>Saša Živkov</name>
     </developer>
   </developers>
 
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java
index 86e7596..2693340 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java
@@ -18,15 +18,13 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-
 import javax.naming.InitialContext;
 import javax.naming.NamingException;
 import javax.sql.DataSource;
 
 /** Provides access to the {@code ReviewDb} DataSource. */
 @Singleton
-final class ReviewDbDataSourceProvider implements Provider<DataSource>,
-    LifecycleListener {
+final class ReviewDbDataSourceProvider implements Provider<DataSource>, LifecycleListener {
   private DataSource ds;
 
   @Override
@@ -38,8 +36,7 @@
   }
 
   @Override
-  public void start() {
-  }
+  public void start() {}
 
   @Override
   public synchronized void stop() {
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/SiteInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/SiteInitializer.java
index ea4a3ea..07e662b 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/SiteInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/SiteInitializer.java
@@ -16,10 +16,6 @@
 
 import com.google.gerrit.pgm.init.BaseInit;
 import com.google.gerrit.pgm.init.PluginsDistribution;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.sql.Connection;
@@ -27,18 +23,22 @@
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public final class SiteInitializer {
-  private static final Logger LOG = LoggerFactory
-      .getLogger(SiteInitializer.class);
+  private static final Logger LOG = LoggerFactory.getLogger(SiteInitializer.class);
 
   private final String sitePath;
   private final String initPath;
   private final PluginsDistribution pluginsDistribution;
   private final List<String> pluginsToInstall;
 
-  SiteInitializer(String sitePath, String initPath,
-      PluginsDistribution pluginsDistribution, List<String> pluginsToInstall) {
+  SiteInitializer(
+      String sitePath,
+      String initPath,
+      PluginsDistribution pluginsDistribution,
+      List<String> pluginsToInstall) {
     this.sitePath = sitePath;
     this.initPath = initPath;
     this.pluginsDistribution = pluginsDistribution;
@@ -61,8 +61,14 @@
         }
         if (site != null) {
           LOG.info("Initializing site at " + site.toRealPath().normalize());
-          new BaseInit(site, new ReviewDbDataSourceProvider(), false, false,
-              pluginsDistribution, pluginsToInstall).run();
+          new BaseInit(
+                  site,
+                  new ReviewDbDataSourceProvider(),
+                  false,
+                  false,
+                  pluginsDistribution,
+                  pluginsToInstall)
+              .run();
         }
       }
     } catch (Exception e) {
@@ -77,8 +83,7 @@
 
   private Path getSiteFromReviewDb(Connection conn) {
     try (Statement stmt = conn.createStatement();
-        ResultSet rs = stmt.executeQuery(
-          "SELECT site_path FROM system_config")) {
+        ResultSet rs = stmt.executeQuery("SELECT site_path FROM system_config")) {
       if (rs.next()) {
         return Paths.get(rs.getString(1));
       }
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java
index 5a97d20..e1eb9de 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/SitePathFromSystemConfigProvider.java
@@ -22,7 +22,6 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
@@ -42,8 +41,7 @@
     return path;
   }
 
-  private static Path read(SchemaFactory<ReviewDb> schemaFactory)
-      throws OrmException {
+  private static Path read(SchemaFactory<ReviewDb> schemaFactory) throws OrmException {
     try (ReviewDb db = schemaFactory.open()) {
       List<SystemConfig> all = db.systemConfig().all().toList();
       switch (all.size()) {
@@ -52,8 +50,8 @@
         case 0:
           throw new OrmException("system_config table is empty");
         default:
-          throw new OrmException("system_config must have exactly 1 row;"
-              + " found " + all.size() + " rows instead");
+          throw new OrmException(
+              "system_config must have exactly 1 row; found " + all.size() + " rows instead");
       }
     }
   }
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java
index d35f31d..ec92fba 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/UnzippedDistribution.java
@@ -19,15 +19,13 @@
 
 import com.google.gerrit.pgm.init.PluginsDistribution;
 import com.google.inject.Singleton;
-
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.List;
-
 import javax.servlet.ServletContext;
 
 @Singleton
@@ -46,9 +44,8 @@
     if (list != null) {
       for (File p : list) {
         String pluginJarName = p.getName();
-        String pluginName = pluginJarName.substring(0,
-                pluginJarName.length() - JAR.length());
-        try (InputStream in = new FileInputStream(p)) {
+        String pluginName = pluginJarName.substring(0, pluginJarName.length() - JAR.length());
+        try (InputStream in = Files.newInputStream(p.toPath())) {
           processor.process(pluginName, in);
         }
       }
@@ -61,8 +58,7 @@
     String[] list = getPluginsDir().list();
     if (list != null) {
       for (String pluginJarName : list) {
-        String pluginName = pluginJarName.substring(0,
-            pluginJarName.length() - JAR.length());
+        String pluginName = pluginJarName.substring(0, pluginJarName.length() - JAR.length());
         names.add(pluginName);
       }
     }
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index 1120e0d..4815366 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -19,6 +19,8 @@
 
 import com.google.common.base.Splitter;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
+import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
@@ -29,15 +31,19 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.util.LogFileCompressor;
-import com.google.gerrit.reviewdb.client.AuthType;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.StartupChecks;
 import com.google.gerrit.server.account.InternalAccountDirectory;
-import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.RestCacheAdminModule;
@@ -51,11 +57,13 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.mail.SmtpEmailSender;
+import com.google.gerrit.server.mail.receive.MailReceiver;
+import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
 import com.google.gerrit.server.notedb.ConfigNotesMigration;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
+import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
@@ -85,18 +93,12 @@
 import com.google.inject.servlet.GuiceServletContextListener;
 import com.google.inject.spi.Message;
 import com.google.inject.util.Providers;
-
-import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -107,12 +109,13 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.sql.DataSource;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Configures the web application environment for Gerrit Code Review. */
-public class WebAppInitializer extends GuiceServletContextListener
-    implements Filter {
-  private static final Logger log =
-      LoggerFactory.getLogger(WebAppInitializer.class);
+public class WebAppInitializer extends GuiceServletContextListener implements Filter {
+  private static final Logger log = LoggerFactory.getLogger(WebAppInitializer.class);
 
   private Path sitePath;
   private Injector dbInjector;
@@ -128,8 +131,8 @@
   private IndexType indexType;
 
   @Override
-  public void doFilter(ServletRequest req, ServletResponse res,
-      FilterChain chain) throws IOException, ServletException {
+  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
+      throws IOException, ServletException {
     filter.doFilter(req, res, chain);
   }
 
@@ -146,11 +149,15 @@
         if (installPlugins == null) {
           pluginsToInstall = null;
         } else {
-          pluginsToInstall = Splitter.on(",").trimResults().omitEmptyStrings()
-              .splitToList(installPlugins);
+          pluginsToInstall =
+              Splitter.on(",").trimResults().omitEmptyStrings().splitToList(installPlugins);
         }
-        new SiteInitializer(path, System.getProperty("gerrit.init_path"),
-            new UnzippedDistribution(servletContext), pluginsToInstall).init();
+        new SiteInitializer(
+                path,
+                System.getProperty("gerrit.init_path"),
+                new UnzippedDistribution(servletContext),
+                pluginsToInstall)
+            .init();
       }
 
       try {
@@ -176,8 +183,7 @@
 
       cfgInjector = createCfgInjector();
       initIndexType();
-      config = cfgInjector.getInstance(
-          Key.get(Config.class, GerritServerConfig.class));
+      config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
       sysInjector = createSysInjector();
       if (!sshdOff()) {
         sshInjector = createSshInjector();
@@ -200,9 +206,9 @@
       // injection here because the HTTP environment is not visible
       // to the core server modules.
       //
-      sysInjector.getInstance(HttpCanonicalWebUrlProvider.class)
-          .setHttpServletRequest(
-              webInjector.getProvider(HttpServletRequest.class));
+      sysInjector
+          .getInstance(HttpCanonicalWebUrlProvider.class)
+          .setHttpServletRequest(webInjector.getProvider(HttpServletRequest.class));
 
       filter = webInjector.getInstance(GuiceFilter.class);
       manager = new LifecycleManager();
@@ -225,58 +231,65 @@
     AbstractModule secureStore = createSecureStoreModule();
     modules.add(secureStore);
     if (sitePath != null) {
-      Module sitePathModule = new AbstractModule() {
-        @Override
-        protected void configure() {
-          bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-        }
-      };
+      Module sitePathModule =
+          new AbstractModule() {
+            @Override
+            protected void configure() {
+              bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+            }
+          };
       modules.add(sitePathModule);
 
       Module configModule = new GerritServerConfigModule();
       modules.add(configModule);
 
       Injector cfgInjector = Guice.createInjector(sitePathModule, configModule, secureStore);
-      Config cfg = cfgInjector.getInstance(Key.get(Config.class,
-          GerritServerConfig.class));
+      Config cfg = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
       String dbType = cfg.getString("database", null, "type");
 
-      final DataSourceType dst = Guice.createInjector(new DataSourceModule(),
-          configModule, sitePathModule, secureStore).getInstance(
-            Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
-      modules.add(new LifecycleModule() {
-        @Override
-        protected void configure() {
-          bind(DataSourceType.class).toInstance(dst);
-          bind(DataSourceProvider.Context.class).toInstance(
-              DataSourceProvider.Context.MULTI_USER);
-          bind(Key.get(DataSource.class, Names.named("ReviewDb"))).toProvider(
-              DataSourceProvider.class).in(SINGLETON);
-          listener().to(DataSourceProvider.class);
-        }
-      });
+      final DataSourceType dst =
+          Guice.createInjector(new DataSourceModule(), configModule, sitePathModule, secureStore)
+              .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
+      modules.add(
+          new LifecycleModule() {
+            @Override
+            protected void configure() {
+              bind(DataSourceType.class).toInstance(dst);
+              bind(DataSourceProvider.Context.class)
+                  .toInstance(DataSourceProvider.Context.MULTI_USER);
+              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
+                  .toProvider(DataSourceProvider.class)
+                  .in(SINGLETON);
+              listener().to(DataSourceProvider.class);
+            }
+          });
 
     } else {
-      modules.add(new LifecycleModule() {
-        @Override
-        protected void configure() {
-          bind(Key.get(DataSource.class, Names.named("ReviewDb"))).toProvider(
-              ReviewDbDataSourceProvider.class).in(SINGLETON);
-          listener().to(ReviewDbDataSourceProvider.class);
-        }
-      });
+      modules.add(
+          new LifecycleModule() {
+            @Override
+            protected void configure() {
+              bind(Key.get(DataSource.class, Names.named("ReviewDb")))
+                  .toProvider(ReviewDbDataSourceProvider.class)
+                  .in(SINGLETON);
+              listener().to(ReviewDbDataSourceProvider.class);
+            }
+          });
 
       // If we didn't get the site path from the system property
       // we need to get it from the database, as that's our old
       // method of locating the site path on disk.
       //
-      modules.add(new AbstractModule() {
-        @Override
-        protected void configure() {
-          bind(Path.class).annotatedWith(SitePath.class).toProvider(
-              SitePathFromSystemConfigProvider.class).in(SINGLETON);
-        }
-      });
+      modules.add(
+          new AbstractModule() {
+            @Override
+            protected void configure() {
+              bind(Path.class)
+                  .annotatedWith(SitePath.class)
+                  .toProvider(SitePathFromSystemConfigProvider.class)
+                  .in(SINGLETON);
+            }
+          });
       modules.add(new GerritServerConfigModule());
     }
     modules.add(new DatabaseModule());
@@ -307,41 +320,62 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
-    modules.add(new DefaultCacheFactory.Module());
+    modules.add(new DefaultMemoryCacheModule());
+    modules.add(new H2CacheModule());
+    modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     modules.add(new SmtpEmailSender.Module());
     modules.add(new SignedTokenEmailTokenVerifier.Module());
+
+    // Plugin module needs to be inserted *before* the index module.
+    // There is the concept of LifecycleModule, in Gerrit's own extension
+    // to Guice, which has these:
+    //  listener().to(SomeClassImplementingLifecycleListener.class);
+    // and the start() methods of each such listener are executed in the
+    // order they are declared.
+    // Makes sure that PluginLoader.start() is executed before the
+    // LuceneIndexModule.start() so that plugins get loaded and the respective
+    // Guice modules installed so that the on-line reindexing will happen
+    // with the proper classes (e.g. group backends, custom Prolog
+    // predicates) and the associated rules ready to be evaluated.
+    modules.add(new PluginModule());
     modules.add(new PluginRestApiModule());
+
     modules.add(new RestCacheAdminModule());
     modules.add(new GpgModule(config));
+    modules.add(new StartupChecks.Module());
 
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
 
     modules.add(new WorkQueue.Module());
-    modules.add(new CanonicalWebUrlModule() {
-      @Override
-      protected Class<? extends Provider<String>> provider() {
-        return HttpCanonicalWebUrlProvider.class;
-      }
-    });
+    modules.add(
+        new CanonicalWebUrlModule() {
+          @Override
+          protected Class<? extends Provider<String>> provider() {
+            return HttpCanonicalWebUrlProvider.class;
+          }
+        });
     modules.add(SshKeyCacheImpl.module());
-    modules.add(new AbstractModule() {
-      @Override
-      protected void configure() {
-        bind(GerritOptions.class)
-            .toInstance(new GerritOptions(config, false, false, false));
-      }
-    });
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(GerritOptions.class).toInstance(new GerritOptions(config, false, false, false));
+          }
+        });
     modules.add(new GarbageCollectionModule());
     modules.add(new ChangeCleanupRunner.Module());
-    return cfgInjector.createChildInjector(modules);
+    return cfgInjector.createChildInjector(
+        ModuleOverloader.override(modules, LibModuleLoader.loadModules(cfgInjector)));
   }
 
   private Module createIndexModule() {
     switch (indexType) {
       case LUCENE:
         return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+      case ELASTICSEARCH:
+        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -355,21 +389,22 @@
     final List<Module> modules = new ArrayList<>();
     modules.add(sysInjector.getInstance(SshModule.class));
     modules.add(new SshHostKeyModule());
-    modules.add(new DefaultCommandModule(false,
-        sysInjector.getInstance(DownloadConfig.class),
-        sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
-    if (indexType == IndexType.LUCENE) {
-      modules.add(new IndexCommandsModule());
-    }
+    modules.add(
+        new DefaultCommandModule(
+            false,
+            sysInjector.getInstance(DownloadConfig.class),
+            sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+    modules.add(new IndexCommandsModule(sysInjector));
     return sysInjector.createChildInjector(modules);
   }
 
   private Injector createWebInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(RequestContextFilter.module());
-    modules.add(AllRequestFilter.module());
     modules.add(RequestMetricsFilter.module());
+    modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(AllRequestFilter.module());
     modules.add(sysInjector.getInstance(WebModule.class));
     modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
     if (sshInjector != null) {
@@ -420,10 +455,10 @@
     return new AbstractModule() {
       @Override
       public void configure() {
-        String secureStoreClassName =
-            GerritServerConfigModule.getSecureStoreClassName(sitePath);
-        bind(String.class).annotatedWith(SecureStoreClassName.class).toProvider(
-            Providers.of(secureStoreClassName));
+        String secureStoreClassName = GerritServerConfigModule.getSecureStoreClassName(sitePath);
+        bind(String.class)
+            .annotatedWith(SecureStoreClassName.class)
+            .toProvider(Providers.of(secureStoreClassName));
       }
     };
   }
diff --git a/gerrit-war/src/main/resources/log4j.properties b/gerrit-war/src/main/resources/log4j.properties
index bc5e575..8bc9bb2 100644
--- a/gerrit-war/src/main/resources/log4j.properties
+++ b/gerrit-war/src/main/resources/log4j.properties
@@ -23,7 +23,7 @@
 log4j.logger.org.apache.mina=WARN
 log4j.logger.org.apache.sshd.common=WARN
 log4j.logger.org.apache.sshd.server=WARN
-log4j.logger.org.apache.sshd.common.keyprovider.AbstractFileKeyPairProvider=INFO
+log4j.logger.org.apache.sshd.common.keyprovider.FileKeyPairProvider=INFO
 log4j.logger.com.google.gerrit.sshd.GerritServerSession=WARN
 
 # Silence non-critical messages from mime-util.
diff --git a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml b/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
index 3ae9440..02aa1b9 100644
--- a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
+++ b/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
@@ -49,6 +49,10 @@
         <Set name="driverClassName">com.mysql.jdbc.Driver</Set>
         <Set name="url">jdbc:mysql://localhost/reviewdb?user=gerrit2&amp;password=secretkey</Set>
 -->
+<!--  MariaDB
+        <Set name="driverClassName">org.mariadb.jdbc.Driver</Set>
+        <Set name="url">jdbc:mariadb://localhost/reviewdb?user=gerrit2&amp;password=secretkey</Set>
+-->
 <!--  H2
         <Set name="driverClassName">org.h2.Driver</Set>
         <Set name="url">jdbc:h2:file:ReviewDb</Set>
diff --git a/gerrit-war/src/main/webapp/favicon.ico b/gerrit-war/src/main/webapp/favicon.ico
index 155217b..de50088 100644
--- a/gerrit-war/src/main/webapp/favicon.ico
+++ b/gerrit-war/src/main/webapp/favicon.ico
Binary files differ
diff --git a/lib/BUCK b/lib/BUCK
deleted file mode 100644
index 4664463..0000000
--- a/lib/BUCK
+++ /dev/null
@@ -1,275 +0,0 @@
-include_defs('//lib/maven.defs')
-include_defs('//lib/GUAVA_VERSION')
-
-define_license(name = 'antlr')
-define_license(name = 'Apache1.1')
-define_license(name = 'Apache2.0')
-define_license(name = 'args4j')
-define_license(name = 'asciidoctor')
-define_license(name = 'automaton')
-define_license(name = 'bouncycastle')
-define_license(name = 'CC-BY3.0-unported')
-define_license(name = 'clippy')
-define_license(name = 'codemirror-minified')
-define_license(name = 'codemirror-original')
-define_license(name = 'diffy')
-define_license(name = 'es6-promise')
-define_license(name = 'fetch')
-define_license(name = 'h2')
-define_license(name = 'highlightjs')
-define_license(name = 'jgit')
-define_license(name = 'jsch')
-define_license(name = 'MPL1.1')
-define_license(name = 'moment')
-define_license(name = 'OFL1.1')
-define_license(name = 'ow2')
-define_license(name = 'page.js')
-define_license(name = 'polymer')
-define_license(name = 'postgresql')
-define_license(name = 'prologcafe')
-define_license(name = 'promise-polyfill')
-define_license(name = 'protobuf')
-define_license(name = 'PublicDomain')
-define_license(name = 'silk_icons')
-define_license(name = 'slf4j')
-define_license(name = 'xz')
-
-define_license(name = 'DO_NOT_DISTRIBUTE')
-
-maven_jar(
-  name = 'gwtorm_client',
-  id = 'com.google.gerrit:gwtorm:1.15',
-  bin_sha1 = '26a2459f543ed78977535f92e379dc0d6cdde8bb',
-  src_sha1 = '9524088d6e46e299b12791cb1a63c4ba6a478b96',
-  license = 'Apache2.0',
-)
-
-java_library(
-  name = 'gwtorm',
-  exported_deps = [':gwtorm_client'],
-  deps = [':protobuf'],
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'gwtjsonrpc',
-  id = 'com.google.gerrit:gwtjsonrpc:1.10',
-  bin_sha1 = '25adea6ef102b761993688e80dfc7203e0f5edf0',
-  src_sha1 = '4401b5868976460f8fac504cf730425ed95481ff',
-  license = 'Apache2.0',
-)
-
-maven_jar(
-  name = 'gson',
-  id = 'com.google.code.gson:gson:2.7',
-  sha1 = '751f548c85fa49f330cecbb1875893f971b33c4e',
-  license = 'Apache2.0',
-)
-
-maven_jar(
-  name = 'guava',
-  id = 'com.google.guava:guava:' + GUAVA_VERSION,
-  sha1 = '6ce200f6b23222af3d8abb6b6459e6c44f4bb0e9',
-  license = 'Apache2.0',
-)
-
-maven_jar(
-  name = 'guava-retrying',
-  id = 'com.github.rholder:guava-retrying:2.0.0',
-  sha1 = '974bc0a04a11cc4806f7c20a34703bd23c34e7f4',
-  license = 'Apache2.0',
-  deps = [':jsr305'],
-)
-
-maven_jar(
-  name = 'jsr305',
-  id = 'com.google.code.findbugs:jsr305:3.0.1',
-  sha1 = 'f7be08ec23c21485b9b5a1cf1654c2ec8c58168d',
-  license = 'Apache2.0',
-  attach_source = False,
-  # Whitelist lib targets that have jsr305 as a dependency. Generally speaking
-  # Gerrit core should not depend on these annotations, and instead use
-  # equivalent annotations in com.google.gerrit.common.
-  visibility = ['//lib:guava-retrying'],
-)
-
-maven_jar(
-  name = 'velocity',
-  id = 'org.apache.velocity:velocity:1.7',
-  sha1 = '2ceb567b8f3f21118ecdec129fe1271dbc09aa7a',
-  license = 'Apache2.0',
-  deps = [
-    '//lib/commons:collections',
-    '//lib/commons:lang',
-    '//lib/commons:oro',
-  ],
-  exclude = ['META-INF/LICENSE', 'META-INF/NOTICE'],
-)
-
-maven_jar(
-  name = 'jsch',
-  id = 'com.jcraft:jsch:0.1.54',
-  sha1 = 'da3584329a263616e277e15462b387addd1b208d',
-  license = 'jsch',
-)
-
-maven_jar(
-  name = 'servlet-api-3_1',
-  id = 'org.apache.tomcat:tomcat-servlet-api:8.0.24',
-  sha1 = '5d9e2e895e3111622720157d0aa540066d5fce3a',
-  license = 'Apache2.0',
-  exclude = ['META-INF/NOTICE', 'META-INF/LICENSE'],
-)
-
-maven_jar(
-  name = 'args4j',
-  id = 'args4j:args4j:2.0.26',
-  sha1 = '01ebb18ebb3b379a74207d5af4ea7c8338ebd78b',
-  license = 'args4j',
-)
-
-maven_jar(
-  name = 'mime-util',
-  id = 'eu.medsea.mimeutil:mime-util:2.1.3',
-  sha1 = '0c9cfae15c74f62491d4f28def0dff1dabe52a47',
-  license = 'Apache2.0',
-  exclude = ['LICENSE.txt', 'README.txt'],
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'juniversalchardet',
-  id = 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3',
-  sha1 = 'cd49678784c46aa8789c060538e0154013bb421b',
-  license = 'MPL1.1',
-)
-
-maven_jar(
-  name = 'automaton',
-  id = 'dk.brics.automaton:automaton:1.11-8',
-  sha1 = '6ebfa65eb431ff4b715a23be7a750cbc4cc96d0f',
-  license = 'automaton',
-)
-
-maven_jar(
-  name = 'pegdown',
-  id = 'org.pegdown:pegdown:1.4.2',
-  sha1 = 'd96db502ed832df867ff5d918f05b51ba3879ea7',
-  license = 'Apache2.0',
-  deps = [':grappa'],
-)
-
-maven_jar(
-  name = 'grappa',
-  id = 'com.github.parboiled1:grappa:1.0.4',
-  sha1 = 'ad4b44b9c305dad7aa1e680d4b5c8eec9c4fd6f5',
-  license = 'Apache2.0',
-  deps = [
-    ':jitescript',
-    '//lib/ow2:ow2-asm',
-    '//lib/ow2:ow2-asm-analysis',
-    '//lib/ow2:ow2-asm-tree',
-    '//lib/ow2:ow2-asm-util',
-  ],
-)
-
-maven_jar(
-  name = 'jitescript',
-  id = 'me.qmx.jitescript:jitescript:0.4.0',
-  sha1 = '2e35862b0435c1b027a21f3d6eecbe50e6e08d54',
-  license = 'Apache2.0',
-  visibility = ['//lib:grappa'],
-)
-
-maven_jar(
-  name = 'derby',
-  id = 'org.apache.derby:derby:10.11.1.1',
-  sha1 = 'df4b50061e8e4c348ce243b921f53ee63ba9bbe1',
-  license = 'Apache2.0',
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'h2',
-  id = 'com.h2database:h2:1.3.176',
-  sha1 = 'fd369423346b2f1525c413e33f8cf95b09c92cbd',
-  license = 'h2',
-)
-
-maven_jar(
-  name = 'postgresql',
-  id = 'org.postgresql:postgresql:9.4.1211.jre7',
-  sha1 = '56b01e9e667f408818a6ef06a89598dbab80687d',
-  license = 'postgresql',
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'protobuf',
-  # Must match version in gwtorm/pom.xml.
-  id = 'com.google.protobuf:protobuf-java:2.5.0',
-  bin_sha1 = 'a10732c76bfacdbd633a7eb0f7968b1059a65dfa',
-  src_sha1 = '7a27a7fc815e481b367ead5df19b4a71ace4a419',
-  license = 'protobuf',
-)
-
-# Test-only dependencies below.
-
-maven_jar(
-  name = 'jimfs',
-  id = 'com.google.jimfs:jimfs:1.0',
-  sha1 = 'edd65a2b792755f58f11134e76485a928aab4c97',
-  license = 'DO_NOT_DISTRIBUTE',
-  deps = [':guava'],
-)
-
-maven_jar(
-  name = 'junit',
-  id = 'junit:junit:4.11',
-  sha1 = '4e031bb61df09069aeb2bffb4019e7a5034a4ee0',
-  license = 'DO_NOT_DISTRIBUTE',
-  exported_deps = [':hamcrest-core'],
-)
-
-maven_jar(
-  name = 'hamcrest-core',
-  id = 'org.hamcrest:hamcrest-core:1.3',
-  sha1 = '42a25dc3219429f0e5d060061f71acb49bf010a0',
-  license = 'DO_NOT_DISTRIBUTE',
-  visibility = ['//lib:junit'],
-)
-
-maven_jar(
-  name = 'truth',
-  id = 'com.google.truth:truth:0.28',
-  sha1 = '0a388c7877c845ff4b8e19689dda5ac9d34622c4',
-  license = 'DO_NOT_DISTRIBUTE',
-  exported_deps = [
-    ':guava',
-    ':junit',
-  ],
-)
-
-maven_jar(
-  name = 'tukaani-xz',
-  id = 'org.tukaani:xz:1.4',
-  sha1 = '18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3',
-  license = 'xz',
-  attach_source = False,
-  visibility = ['//gerrit-server:server'],
-)
-
-maven_jar(
-  name = 'javassist',
-  id = 'org.javassist:javassist:3.20.0-GA',
-  sha1 = 'a9cbcdfb7e9f86fbc74d3afae65f2248bfbf82a0',
-  license = 'DO_NOT_DISTRIBUTE',
-)
-
-maven_jar(
-  name = 'blame-cache',
-  id = 'com/google/gitiles:blame-cache:0.1-9',
-  sha1 = '51d35e6f8bbc2412265066cea9653dd758c95826',
-  license = 'Apache2.0',
-  repository = GERRIT,
-)
diff --git a/lib/BUILD b/lib/BUILD
index e89e63c..6ee7557 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -1,204 +1,313 @@
-java_library(
-  name = 'servlet-api-3_1',
-  neverlink = 1,
-  exports = ['@servlet_api_3_1//jar'],
-  visibility = ['//visibility:public'],
+load("@rules_java//java:defs.bzl", "java_library")
+
+exports_files(glob([
+    "LICENSE-*",
+]))
+
+filegroup(
+    name = "all-licenses",
+    srcs = glob(
+        ["LICENSE-*"],
+        exclude = ["LICENSE-DO_NOT_DISTRIBUTE"],
+    ),
+    visibility = ["//visibility:public"],
 )
 
 java_library(
-  name = 'servlet-api-3_1-without-neverlink',
-  exports = ['@servlet_api_3_1//jar'],
-  visibility = ['//visibility:public'],
+    name = "servlet-api-3_1",
+    data = ["//lib:LICENSE-Apache2.0"],
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = ["@servlet-api-3_1//jar"],
 )
 
 java_library(
-  name = 'gwtjsonrpc',
-  exports = ['@gwtjsonrpc//jar'],
-  visibility = ['//visibility:public'],
+    name = "servlet-api-3_1-without-neverlink",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@servlet-api-3_1//jar"],
 )
 
 java_library(
-  name = 'gwtjsonrpc_src',
-  exports = ['@gwtjsonrpc_src//jar'],
-  visibility = ['//visibility:public'],
+    name = "gwtjsonrpc",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@gwtjsonrpc//jar"],
 )
 
 java_library(
-  name = 'gson',
-  exports = ['@gson//jar'],
-  visibility = ['//visibility:public'],
+    name = "gwtjsonrpc_src",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@gwtjsonrpc//jar:src"],
 )
 
 java_library(
-  name = 'gwtorm_client',
-  exports = ['@gwtorm_client//jar'],
-  visibility = ['//visibility:public'],
+    name = "gson",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@gson//jar"],
 )
 
 java_library(
-  name = 'gwtorm_client_src',
-  exports = ['@gwtorm_client_src//jar'],
-  visibility = ['//visibility:public'],
+    name = "gwtorm-client",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@gwtorm-client//jar"],
 )
 
 java_library(
-  name = 'protobuf',
-  exports = ['@protobuf//jar'],
-  visibility = ['//visibility:public'],
+    name = "gwtorm-client_src",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@gwtorm-client//jar:src"],
 )
 
 java_library(
-  name = 'gwtorm',
-  exports = [':gwtorm_client'],
-  runtime_deps = [':protobuf'],
-  visibility = ['//visibility:public'],
+    name = "protobuf",
+    data = ["//lib:LICENSE-protobuf"],
+    visibility = ["//visibility:public"],
+    exports = ["@com_google_protobuf//:protobuf_java"],
 )
 
 java_library(
-  name = 'guava',
-  exports = ['@guava//jar'],
-  visibility = ['//visibility:public'],
+    name = "gwtorm",
+    visibility = ["//visibility:public"],
+    exports = [":gwtorm-client"],
+    runtime_deps = [":protobuf"],
 )
 
 java_library(
-  name = 'velocity',
-  exports = ['@velocity//jar'],
-  runtime_deps = [
-    '//lib/commons:collections',
-    '//lib/commons:lang',
-    '//lib/commons:oro',
-  ],
-  visibility = ['//visibility:public'],
+    name = "j2objc",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@j2objc//jar"],
 )
 
 java_library(
-  name = 'jsch',
-  exports = ['@jsch//jar'],
-  visibility = ['//visibility:public'],
+    name = "guava",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":j2objc",
+        "@guava//jar",
+    ],
 )
 
 java_library(
-  name = 'juniversalchardet',
-  exports = ['@juniversalchardet//jar'],
-  visibility = ['//visibility:public'],
+    name = "velocity",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@velocity//jar"],
+    runtime_deps = [
+        "//lib/commons:collections",
+        "//lib/commons:lang",
+        "//lib/commons:oro",
+    ],
 )
 
 java_library(
-  name = 'args4j',
-  exports = ['@args4j//jar'],
-  visibility = ['//visibility:public'],
+    name = "jsch",
+    data = ["//lib:LICENSE-jsch"],
+    visibility = ["//visibility:public"],
+    exports = ["@jsch//jar"],
 )
 
 java_library(
-  name = 'automaton',
-  exports = ['@automaton//jar'],
-  visibility = ['//visibility:public'],
+    name = "juniversalchardet",
+    data = ["//lib:LICENSE-MPL1.1"],
+    visibility = ["//visibility:public"],
+    exports = ["@juniversalchardet//jar"],
 )
 
 java_library(
-  name = 'pegdown',
-  exports = ['@pegdown//jar'],
-  runtime_deps = [':grappa'],
-  visibility = ['//visibility:public'],
+    name = "args4j",
+    data = ["//lib:LICENSE-args4j"],
+    visibility = ["//visibility:public"],
+    exports = ["@args4j//jar"],
 )
 
 java_library(
-  name = 'grappa',
-  exports = ['@grappa//jar'],
-  runtime_deps = [
-    ':jitescript',
-    '//lib/ow2:ow2-asm',
-    '//lib/ow2:ow2-asm-analysis',
-    '//lib/ow2:ow2-asm-tree',
-    '//lib/ow2:ow2-asm-util',
-  ],
-  visibility = ['//visibility:public'],
+    name = "automaton",
+    data = ["//lib:LICENSE-automaton"],
+    visibility = ["//visibility:public"],
+    exports = ["@automaton//jar"],
 )
 
 java_library(
-  name = 'jitescript',
-  exports = ['@jitescript//jar'],
-  visibility = ['//visibility:public'],
+    name = "pegdown",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@pegdown//jar"],
+    runtime_deps = [":grappa"],
 )
 
 java_library(
-  name = 'tukaani-xz',
-  exports = ['@tukaani_xz//jar'],
-  visibility = ['//visibility:public'],
+    name = "grappa",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@grappa//jar"],
+    runtime_deps = [
+        ":jitescript",
+        "//lib/ow2:ow2-asm",
+        "//lib/ow2:ow2-asm-analysis",
+        "//lib/ow2:ow2-asm-tree",
+        "//lib/ow2:ow2-asm-util",
+    ],
 )
 
 java_library(
-  name = 'mime-util',
-  exports = ['@mime_util//jar'],
-  visibility = ['//visibility:public'],
+    name = "jitescript",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jitescript//jar"],
 )
 
 java_library(
-  name = 'guava-retrying',
-  exports = ['@guava_retrying//jar'],
-  runtime_deps = [':jsr305'],
-  visibility = ['//visibility:public'],
+    name = "tukaani-xz",
+    data = ["//lib:LICENSE-xz"],
+    visibility = ["//visibility:public"],
+    exports = ["@tukaani-xz//jar"],
 )
 
 java_library(
-  name = 'jsr305',
-  exports = ['@jsr305//jar'],
+    name = "mime-util",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@mime-util//jar"],
 )
 
 java_library(
-  name = 'blame-cache',
-  exports = ['@blame_cache//jar'],
-  visibility = ['//visibility:public'],
+    name = "guava-retrying",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@guava-retrying//jar"],
+    runtime_deps = [":jsr305"],
 )
 
 java_library(
-  name = 'h2',
-  exports = ['@h2//jar'],
-  visibility = ['//visibility:public'],
-)
-
-
-java_library(
-  name = 'jimfs',
-  exports = ['@jimfs//jar'],
-  runtime_deps = [':guava'],
-  visibility = ['//visibility:public'],
+    name = "jsr305",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jsr305//jar"],
 )
 
 java_library(
-  name = 'junit',
-  exports = [
-    '@junit//jar',
-    ':hamcrest-core',
-  ],
-  runtime_deps = [':hamcrest-core'],
-  visibility = ['//visibility:public'],
+    name = "blame-cache",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@blame-cache//jar"],
 )
 
 java_library(
-  name = 'hamcrest-core',
-  exports = ['@hamcrest_core//jar'],
-  visibility = ['//visibility:public'],
+    name = "h2",
+    data = ["//lib:LICENSE-h2"],
+    visibility = ["//visibility:public"],
+    exports = ["@h2//jar"],
 )
 
 java_library(
-  name = 'truth',
-  exports = [
-    '@truth//jar',
-    ':guava',
-    ':junit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "jimfs",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@jimfs//jar"],
+    runtime_deps = [":guava"],
 )
 
 java_library(
-  name = 'javassist',
-  exports = ['@javassist//jar'],
-  visibility = ['//visibility:public'],
+    name = "junit",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":hamcrest-core",
+        "@junit//jar",
+    ],
+    runtime_deps = [":hamcrest-core"],
 )
 
 java_library(
-  name = 'derby',
-  exports = ['@derby//jar'],
-  visibility = ['//visibility:public'],
+    name = "hamcrest-core",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@hamcrest-core//jar"],
+)
+
+java_library(
+    name = "truth",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":guava",
+        ":junit",
+        "@truth//jar",
+    ],
+)
+
+java_library(
+    name = "truth-java8-extension",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":guava",
+        ":truth",
+        "@truth-java8-extension//jar",
+    ],
+)
+
+java_library(
+    name = "javassist",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@javassist//jar"],
+)
+
+java_library(
+    name = "derby",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@derby//jar"],
+)
+
+java_library(
+    name = "soy",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@soy//jar"],
+    runtime_deps = [
+        ":args4j",
+        ":gson",
+        ":guava",
+        ":html-types",
+        ":icu4j",
+        ":jsr305",
+        ":protobuf",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/guice:javax-inject",
+        "//lib/ow2:ow2-asm",
+        "//lib/ow2:ow2-asm-analysis",
+        "//lib/ow2:ow2-asm-commons",
+        "//lib/ow2:ow2-asm-util",
+    ],
+)
+
+java_library(
+    name = "html-types",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@html-types//jar"],
+)
+
+java_library(
+    name = "icu4j",
+    data = ["//lib:LICENSE-icu4j"],
+    visibility = ["//visibility:public"],
+    exports = ["@icu4j//jar"],
+)
+
+java_library(
+    name = "postgresql",
+    data = ["//lib:LICENSE-postgresql"],
+    visibility = ["//visibility:public"],
+    exports = ["@postgresql//jar"],
 )
diff --git a/lib/GUAVA_VERSION b/lib/GUAVA_VERSION
deleted file mode 100644
index f889e2b..0000000
--- a/lib/GUAVA_VERSION
+++ /dev/null
@@ -1,2 +0,0 @@
-GUAVA_VERSION = '19.0'
-GUAVA_DOC_URL = 'https://google.github.io/guava/releases/' + GUAVA_VERSION + '/api/docs/'
diff --git a/lib/JGIT_VERSION b/lib/JGIT_VERSION
deleted file mode 100644
index 50ac39f..0000000
--- a/lib/JGIT_VERSION
+++ /dev/null
@@ -1,6 +0,0 @@
-include_defs('//lib/maven.defs')
-
-REPO = MAVEN_CENTRAL # Leave here even if set to MAVEN_CENTRAL.
-VERS = '4.5.7.201904151645-r'
-DOC_VERS = VERS # Set to VERS unless using a snapshot
-JGIT_DOC_URL="http://download.eclipse.org/jgit/site/" + DOC_VERS + "/apidocs"
diff --git a/lib/LICENSE-CC0-1.0 b/lib/LICENSE-CC0-1.0
new file mode 100644
index 0000000..bb12792
--- /dev/null
+++ b/lib/LICENSE-CC0-1.0
@@ -0,0 +1,123 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
+
+For more information, please see https://creativecommons.org/publicdomain/zero/1.0/
diff --git a/lib/LICENSE-elasticsearch b/lib/LICENSE-elasticsearch
new file mode 100644
index 0000000..23cae9e
--- /dev/null
+++ b/lib/LICENSE-elasticsearch
@@ -0,0 +1,5 @@
+Elasticsearch
+Copyright 2009-2015 Elasticsearch
+
+This product includes software developed by The Apache Software
+Foundation (http://www.apache.org/).
diff --git a/lib/LICENSE-icu4j b/lib/LICENSE-icu4j
new file mode 100644
index 0000000..90be7cd
--- /dev/null
+++ b/lib/LICENSE-icu4j
@@ -0,0 +1,385 @@
+COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later)
+
+Copyright © 1991-2016 Unicode, Inc. All rights reserved.
+Distributed under the Terms of Use in http://www.unicode.org/copyright.html
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Unicode data files and any associated documentation
+(the "Data Files") or Unicode software and any associated documentation
+(the "Software") to deal in the Data Files or Software
+without restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, and/or sell copies of
+the Data Files or Software, and to permit persons to whom the Data Files
+or Software are furnished to do so, provided that either
+(a) this copyright and permission notice appear with all copies
+of the Data Files or Software, or
+(b) this copyright and permission notice appear in associated
+Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT OF THIRD PARTY RIGHTS.
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS
+NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL
+DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
+DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THE DATA FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder
+shall not be used in advertising or otherwise to promote the sale,
+use or other dealings in these Data Files or Software without prior
+written authorization of the copyright holder.
+
+---------------------
+
+Third-Party Software Licenses
+
+This section contains third-party software notices and/or additional
+terms for licensed third-party software components included within ICU
+libraries.
+
+1. ICU License - ICU 1.8.1 to ICU 57.1
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright (c) 1995-2016 International Business Machines Corporation and others
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, and/or sell copies of the Software, and to permit persons
+to whom the Software is furnished to do so, provided that the above
+copyright notice(s) and this permission notice appear in all copies of
+the Software and that both the above copyright notice(s) and this
+permission notice appear in supporting documentation.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY
+SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER
+RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
+CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder
+shall not be used in advertising or otherwise to promote the sale, use
+or other dealings in this Software without prior written authorization
+of the copyright holder.
+
+All trademarks and registered trademarks mentioned herein are the
+property of their respective owners.
+
+2. Chinese/Japanese Word Break Dictionary Data (cjdict.txt)
+
+ #     The Google Chrome software developed by Google is licensed under
+ # the BSD license. Other software included in this distribution is
+ # provided under other licenses, as set forth below.
+ #
+ #  The BSD License
+ #  http://opensource.org/licenses/bsd-license.php
+ #  Copyright (C) 2006-2008, Google Inc.
+ #
+ #  All rights reserved.
+ #
+ #  Redistribution and use in source and binary forms, with or without
+ # modification, are permitted provided that the following conditions are met:
+ #
+ #  Redistributions of source code must retain the above copyright notice,
+ # this list of conditions and the following disclaimer.
+ #  Redistributions in binary form must reproduce the above
+ # copyright notice, this list of conditions and the following
+ # disclaimer in the documentation and/or other materials provided with
+ # the distribution.
+ #  Neither the name of  Google Inc. nor the names of its
+ # contributors may be used to endorse or promote products derived from
+ # this software without specific prior written permission.
+ #
+ #
+ #  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ #
+ #
+ #  The word list in cjdict.txt are generated by combining three word lists
+ # listed below with further processing for compound word breaking. The
+ # frequency is generated with an iterative training against Google web
+ # corpora.
+ #
+ #  * Libtabe (Chinese)
+ #    - https://sourceforge.net/project/?group_id=1519
+ #    - Its license terms and conditions are shown below.
+ #
+ #  * IPADIC (Japanese)
+ #    - http://chasen.aist-nara.ac.jp/chasen/distribution.html
+ #    - Its license terms and conditions are shown below.
+ #
+ #  ---------COPYING.libtabe ---- BEGIN--------------------
+ #
+ #  /*
+ #   * Copyrighy (c) 1999 TaBE Project.
+ #   * Copyright (c) 1999 Pai-Hsiang Hsiao.
+ #   * All rights reserved.
+ #   *
+ #   * Redistribution and use in source and binary forms, with or without
+ #   * modification, are permitted provided that the following conditions
+ #   * are met:
+ #   *
+ #   * . Redistributions of source code must retain the above copyright
+ #   *   notice, this list of conditions and the following disclaimer.
+ #   * . Redistributions in binary form must reproduce the above copyright
+ #   *   notice, this list of conditions and the following disclaimer in
+ #   *   the documentation and/or other materials provided with the
+ #   *   distribution.
+ #   * . Neither the name of the TaBE Project nor the names of its
+ #   *   contributors may be used to endorse or promote products derived
+ #   *   from this software without specific prior written permission.
+ #   *
+ #   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ #   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ #   * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ #   * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ #   * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ #   * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ #   * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ #   * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ #   * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ #   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ #   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ #   * OF THE POSSIBILITY OF SUCH DAMAGE.
+ #   */
+ #
+ #  /*
+ #   * Copyright (c) 1999 Computer Systems and Communication Lab,
+ #   *                    Institute of Information Science, Academia
+ #       *                    Sinica. All rights reserved.
+ #   *
+ #   * Redistribution and use in source and binary forms, with or without
+ #   * modification, are permitted provided that the following conditions
+ #   * are met:
+ #   *
+ #   * . Redistributions of source code must retain the above copyright
+ #   *   notice, this list of conditions and the following disclaimer.
+ #   * . Redistributions in binary form must reproduce the above copyright
+ #   *   notice, this list of conditions and the following disclaimer in
+ #   *   the documentation and/or other materials provided with the
+ #   *   distribution.
+ #   * . Neither the name of the Computer Systems and Communication Lab
+ #   *   nor the names of its contributors may be used to endorse or
+ #   *   promote products derived from this software without specific
+ #   *   prior written permission.
+ #   *
+ #   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ #   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ #   * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ #   * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ #   * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ #   * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ #   * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ #   * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ #   * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ #   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ #   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ #   * OF THE POSSIBILITY OF SUCH DAMAGE.
+ #   */
+ #
+ #  Copyright 1996 Chih-Hao Tsai @ Beckman Institute,
+ #      University of Illinois
+ #  c-tsai4@uiuc.edu  http://casper.beckman.uiuc.edu/~c-tsai4
+ #
+ #  ---------------COPYING.libtabe-----END--------------------------------
+ #
+ #
+ #  ---------------COPYING.ipadic-----BEGIN-------------------------------
+ #
+ #  Copyright 2000, 2001, 2002, 2003 Nara Institute of Science
+ #  and Technology.  All Rights Reserved.
+ #
+ #  Use, reproduction, and distribution of this software is permitted.
+ #  Any copy of this software, whether in its original form or modified,
+ #  must include both the above copyright notice and the following
+ #  paragraphs.
+ #
+ #  Nara Institute of Science and Technology (NAIST),
+ #  the copyright holders, disclaims all warranties with regard to this
+ #  software, including all implied warranties of merchantability and
+ #  fitness, in no event shall NAIST be liable for
+ #  any special, indirect or consequential damages or any damages
+ #  whatsoever resulting from loss of use, data or profits, whether in an
+ #  action of contract, negligence or other tortuous action, arising out
+ #  of or in connection with the use or performance of this software.
+ #
+ #  A large portion of the dictionary entries
+ #  originate from ICOT Free Software.  The following conditions for ICOT
+ #  Free Software applies to the current dictionary as well.
+ #
+ #  Each User may also freely distribute the Program, whether in its
+ #  original form or modified, to any third party or parties, PROVIDED
+ #  that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear
+ #  on, or be attached to, the Program, which is distributed substantially
+ #  in the same form as set out herein and that such intended
+ #  distribution, if actually made, will neither violate or otherwise
+ #  contravene any of the laws and regulations of the countries having
+ #  jurisdiction over the User or the intended distribution itself.
+ #
+ #  NO WARRANTY
+ #
+ #  The program was produced on an experimental basis in the course of the
+ #  research and development conducted during the project and is provided
+ #  to users as so produced on an experimental basis.  Accordingly, the
+ #  program is provided without any warranty whatsoever, whether express,
+ #  implied, statutory or otherwise.  The term "warranty" used herein
+ #  includes, but is not limited to, any warranty of the quality,
+ #  performance, merchantability and fitness for a particular purpose of
+ #  the program and the nonexistence of any infringement or violation of
+ #  any right of any third party.
+ #
+ #  Each user of the program will agree and understand, and be deemed to
+ #  have agreed and understood, that there is no warranty whatsoever for
+ #  the program and, accordingly, the entire risk arising from or
+ #  otherwise connected with the program is assumed by the user.
+ #
+ #  Therefore, neither ICOT, the copyright holder, or any other
+ #  organization that participated in or was otherwise related to the
+ #  development of the program and their respective officials, directors,
+ #  officers and other employees shall be held liable for any and all
+ #  damages, including, without limitation, general, special, incidental
+ #  and consequential damages, arising out of or otherwise in connection
+ #  with the use or inability to use the program or any product, material
+ #  or result produced or otherwise obtained by using the program,
+ #  regardless of whether they have been advised of, or otherwise had
+ #  knowledge of, the possibility of such damages at any time during the
+ #  project or thereafter.  Each user will be deemed to have agreed to the
+ #  foregoing by his or her commencement of use of the program.  The term
+ #  "use" as used herein includes, but is not limited to, the use,
+ #  modification, copying and distribution of the program and the
+ #  production of secondary products from the program.
+ #
+ #  In the case where the program, whether in its original form or
+ #  modified, was distributed or delivered to or received by a user from
+ #  any person, organization or entity other than ICOT, unless it makes or
+ #  grants independently of ICOT any specific warranty to the user in
+ #  writing, such person, organization or entity, will also be exempted
+ #  from and not be held liable to the user for any such damages as noted
+ #  above as far as the program is concerned.
+ #
+ #  ---------------COPYING.ipadic-----END----------------------------------
+
+3. Lao Word Break Dictionary Data (laodict.txt)
+
+ #  Copyright (c) 2013 International Business Machines Corporation
+ #  and others. All Rights Reserved.
+ #
+ # Project: http://code.google.com/p/lao-dictionary/
+ # Dictionary: http://lao-dictionary.googlecode.com/git/Lao-Dictionary.txt
+ # License: http://lao-dictionary.googlecode.com/git/Lao-Dictionary-LICENSE.txt
+ #              (copied below)
+ #
+ #  This file is derived from the above dictionary, with slight
+ #  modifications.
+ #  ----------------------------------------------------------------------
+ #  Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell.
+ #  All rights reserved.
+ #
+ #  Redistribution and use in source and binary forms, with or without
+ #  modification,
+ #  are permitted provided that the following conditions are met:
+ #
+ #
+ # Redistributions of source code must retain the above copyright notice, this
+ #  list of conditions and the following disclaimer. Redistributions in
+ #  binary form must reproduce the above copyright notice, this list of
+ #  conditions and the following disclaimer in the documentation and/or
+ #  other materials provided with the distribution.
+ #
+ #
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ # OF THE POSSIBILITY OF SUCH DAMAGE.
+ #  --------------------------------------------------------------------------
+
+4. Burmese Word Break Dictionary Data (burmesedict.txt)
+
+ #  Copyright (c) 2014 International Business Machines Corporation
+ #  and others. All Rights Reserved.
+ #
+ #  This list is part of a project hosted at:
+ #    github.com/kanyawtech/myanmar-karen-word-lists
+ #
+ #  --------------------------------------------------------------------------
+ #  Copyright (c) 2013, LeRoy Benjamin Sharon
+ #  All rights reserved.
+ #
+ #  Redistribution and use in source and binary forms, with or without
+ #  modification, are permitted provided that the following conditions
+ #  are met: Redistributions of source code must retain the above
+ #  copyright notice, this list of conditions and the following
+ #  disclaimer.  Redistributions in binary form must reproduce the
+ #  above copyright notice, this list of conditions and the following
+ #  disclaimer in the documentation and/or other materials provided
+ #  with the distribution.
+ #
+ #    Neither the name Myanmar Karen Word Lists, nor the names of its
+ #    contributors may be used to endorse or promote products derived
+ #    from this software without specific prior written permission.
+ #
+ #  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ #  CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ #  INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ #  MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ #  DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
+ #  BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ #  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ #  TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ #  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ #  ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ #  TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+ #  THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ #  SUCH DAMAGE.
+ #  --------------------------------------------------------------------------
+
+5. Time Zone Database
+
+  ICU uses the public domain data and code derived from Time Zone
+Database for its time zone support. The ownership of the TZ database
+is explained in BCP 175: Procedure for Maintaining the Time Zone
+Database section 7.
+
+ # 7.  Database Ownership
+ #
+ #    The TZ database itself is not an IETF Contribution or an IETF
+ #    document.  Rather it is a pre-existing and regularly updated work
+ #    that is in the public domain, and is intended to remain in the
+ #    public domain.  Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do
+ #    not apply to the TZ Database or contributions that individuals make
+ #    to it.  Should any claims be made and substantiated against the TZ
+ #    Database, the organization that is providing the IANA
+ #    Considerations defined in this RFC, under the memorandum of
+ #    understanding with the IETF, currently ICANN, may act in accordance
+ #    with all competent court orders.  No ownership claims will be made
+ #    by ICANN or the IETF Trust on the database or the code.  Any person
+ #    making a contribution to the database or code waives all rights to
+ #    future claims in that contribution or in the TZ Database.
diff --git a/lib/LICENSE-jsoup b/lib/LICENSE-jsoup
new file mode 100644
index 0000000..9e15540
--- /dev/null
+++ b/lib/LICENSE-jsoup
@@ -0,0 +1,21 @@
+The MIT License
+
+© 2009-2016, Jonathan Hedley <jonathan@hedley.net>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/lib/LICENSE-testcontainers b/lib/LICENSE-testcontainers
new file mode 100644
index 0000000..5d60e93
--- /dev/null
+++ b/lib/LICENSE-testcontainers
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Richard North
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/lib/antlr/BUCK b/lib/antlr/BUCK
deleted file mode 100644
index edf153c..0000000
--- a/lib/antlr/BUCK
+++ /dev/null
@@ -1,48 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VERSION = '3.5.2'
-
-maven_jar(
-  name = 'java_runtime',
-  id = 'org.antlr:antlr-runtime:' + VERSION,
-  sha1 = 'cd9cd41361c155f3af0f653009dcecb08d8b4afd',
-  license = 'antlr',
-)
-
-java_binary(
-  name = 'antlr-tool',
-  main_class = 'org.antlr.Tool',
-  deps = [':tool'],
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'stringtemplate',
-  id = 'org.antlr:stringtemplate:4.0.2',
-  sha1 = 'e28e09e2d44d60506a7bcb004d6c23ff35c6ac08',
-  license = 'antlr',
-  attach_source = False,
-  visibility = [],
-)
-
-maven_jar(
-  name = 'tool',
-  id = 'org.antlr:antlr:' + VERSION,
-  sha1 = 'c4a65c950bfc3e7d04309c515b2177c00baf7764',
-  license = 'antlr',
-  deps = [
-    ':java_runtime',
-    ':stringtemplate',
-    ':antlr27',
-  ],
-  visibility = [],
-)
-
-maven_jar(
-  name = 'antlr27',
-  id = 'antlr:antlr:2.7.7',
-  sha1 = '83cd2cd674a217ade95a4bb83a8a14f351f48bd0',
-  license = 'antlr',
-  attach_source = False,
-  visibility = [],
-)
diff --git a/lib/antlr/BUILD b/lib/antlr/BUILD
index ede7665..36d945c 100644
--- a/lib/antlr/BUILD
+++ b/lib/antlr/BUILD
@@ -1,31 +1,35 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 
 [java_library(
-  name = n,
-  exports = ['@%s//jar' % n],
+    name = n,
+    data = ["//lib:LICENSE-antlr"],
+    exports = ["@%s//jar" % n],
 ) for n in [
-  'antlr27',
-  'stringtemplate',
+    "antlr27",
+    "stringtemplate",
 ]]
 
 java_library(
-  name = 'java_runtime',
-  exports = ['@java_runtime//jar'],
-  visibility = ['//visibility:public'],
+    name = "java-runtime",
+    data = ["//lib:LICENSE-antlr"],
+    visibility = ["//visibility:public"],
+    exports = ["@java-runtime//jar"],
 )
 
 java_binary(
-  name = 'antlr-tool',
-  main_class = 'org.antlr.Tool',
-  runtime_deps = [':tool'],
-  visibility = ['//gerrit-antlr:__pkg__'],
+    name = "antlr-tool",
+    main_class = "org.antlr.Tool",
+    visibility = ["//gerrit-antlr:__pkg__"],
+    runtime_deps = [":tool"],
 )
 
 java_library(
-  name = 'tool',
-  exports = ['@org_antlr//jar'],
-  runtime_deps = [
-    ':antlr27',
-    ':java_runtime',
-    ':stringtemplate',
-  ],
+    name = "tool",
+    data = ["//lib:LICENSE-antlr"],
+    exports = ["@org-antlr//jar"],
+    runtime_deps = [
+        ":antlr27",
+        ":java-runtime",
+        ":stringtemplate",
+    ],
 )
diff --git a/lib/asciidoctor/BUCK b/lib/asciidoctor/BUCK
deleted file mode 100644
index 733c670..0000000
--- a/lib/asciidoctor/BUCK
+++ /dev/null
@@ -1,61 +0,0 @@
-include_defs('//lib/maven.defs')
-
-java_binary(
-  name = 'asciidoc',
-  main_class = 'AsciiDoctor',
-  deps = [':asciidoc_lib'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'asciidoc_lib',
-  srcs = ['java/AsciiDoctor.java'],
-  deps = [
-    ':asciidoctor',
-    ':jruby',
-    '//lib:args4j',
-    '//lib:guava',
-    '//lib/log:api',
-    '//lib/log:nop',
-  ],
-  visibility = ['//tools/eclipse:classpath'],
-)
-
-java_binary(
-  name = 'doc_indexer',
-  main_class = 'DocIndexer',
-  deps = [':doc_indexer_lib'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'doc_indexer_lib',
-  srcs = ['java/DocIndexer.java'],
-  deps = [
-    ':asciidoc_lib',
-    '//gerrit-server:constants',
-    '//lib:args4j',
-    '//lib:guava',
-    '//lib/lucene:lucene-analyzers-common',
-    '//lib/lucene:lucene-core-and-backward-codecs',
-  ],
-  visibility = ['//tools/eclipse:classpath'],
-)
-
-maven_jar(
-  name = 'asciidoctor',
-  id = 'org.asciidoctor:asciidoctorj:1.5.4.1',
-  sha1 = 'f7ddfb2bbed2f8da3f9ad0d1a5514f04b4274a5a',
-  license = 'asciidoctor',
-  visibility = [],
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'jruby',
-  id = 'org.jruby:jruby-complete:1.7.25',
-  sha1 = '8eb234259ec88edc05eedab05655f458a84bfcab',
-  license = 'DO_NOT_DISTRIBUTE',
-  visibility = [],
-  attach_source = False,
-)
diff --git a/lib/asciidoctor/BUILD b/lib/asciidoctor/BUILD
new file mode 100644
index 0000000..4522450
--- /dev/null
+++ b/lib/asciidoctor/BUILD
@@ -0,0 +1,56 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+
+java_binary(
+    name = "asciidoc",
+    main_class = "AsciiDoctor",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":asciidoc_lib"],
+)
+
+java_library(
+    name = "asciidoc_lib",
+    srcs = ["java/AsciiDoctor.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":asciidoctor",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib/log:api",
+        "//lib/log:nop",
+    ],
+)
+
+java_binary(
+    name = "doc_indexer",
+    main_class = "DocIndexer",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":doc_indexer_lib"],
+)
+
+java_library(
+    name = "doc_indexer_lib",
+    srcs = ["java/DocIndexer.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":asciidoc_lib",
+        "//gerrit-server:constants",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib/lucene:lucene-analyzers-common",
+        "//lib/lucene:lucene-core-and-backward-codecs",
+    ],
+)
+
+java_library(
+    name = "asciidoctor",
+    data = ["//lib:LICENSE-asciidoctor"],
+    visibility = ["//visibility:public"],
+    exports = ["@asciidoctor//jar"],
+    runtime_deps = [":jruby"],
+)
+
+java_library(
+    name = "jruby",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    exports = ["@jruby//jar"],
+)
diff --git a/lib/asciidoctor/java/AsciiDoctor.java b/lib/asciidoctor/java/AsciiDoctor.java
index 8e18feb1..3f30643 100644
--- a/lib/asciidoctor/java/AsciiDoctor.java
+++ b/lib/asciidoctor/java/AsciiDoctor.java
@@ -13,7 +13,20 @@
 // limitations under the License.
 
 import com.google.common.io.ByteStreams;
-
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 import org.asciidoctor.Asciidoctor;
 import org.asciidoctor.AttributesBuilder;
 import org.asciidoctor.Options;
@@ -25,22 +38,11 @@
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
 
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.FilenameFilter;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-
 public class AsciiDoctor {
 
   private static final String DOCTYPE = "article";
   private static final String ERUBY = "erb";
+  private static final String REVNUMBER_NAME = "revnumber";
 
   @Option(name = "-b", usage = "set output format backend")
   private String backend = "html5";
@@ -60,15 +62,26 @@
   @Option(name = "--tmp", usage = "temporary output path")
   private File tmpdir;
 
-  @Option(name = "-a", usage =
-      "a list of attributes, in the form key or key=value pair")
+  @Option(name = "--mktmp", usage = "create a temporary output path")
+  private boolean mktmp;
+
+  @Option(name = "-a", usage = "a list of attributes, in the form key or key=value pair")
   private List<String> attributes = new ArrayList<>();
 
+  @Option(
+      name = "--bazel",
+      usage = "bazel mode: generate multiple output files instead of a single zip file")
+  private boolean bazel;
+
+  @Option(name = "--revnumber-file", usage = "the file contains revnumber string")
+  private File revnumberFile;
+
   @Argument(usage = "input files")
   private List<String> inputFiles = new ArrayList<>();
 
-  public static String mapInFileToOutFile(
-      String inFile, String inExt, String outExt) {
+  private String revnumber;
+
+  public static String mapInFileToOutFile(String inFile, String inExt, String outExt) {
     String basename = new File(inFile).getName();
     if (basename.endsWith(inExt)) {
       basename = basename.substring(0, basename.length() - inExt.length());
@@ -82,19 +95,22 @@
     return basename + outExt;
   }
 
-  private Options createOptions(File outputFile) {
+  private Options createOptions(File base, File outputFile) {
     OptionsBuilder optionsBuilder = OptionsBuilder.options();
 
-    optionsBuilder.backend(backend).docType(DOCTYPE).eruby(ERUBY)
-      .safe(SafeMode.UNSAFE).baseDir(basedir);
-    // XXX(fishywang): ideally we should just output to a string and add the
-    // content into zip. But asciidoctor will actually ignore all attributes if
-    // not output to a file. So we *have* to output to a file then read the
-    // content of the file into zip.
-    optionsBuilder.toFile(outputFile);
+    optionsBuilder
+        .backend(backend)
+        .docType(DOCTYPE)
+        .eruby(ERUBY)
+        .safe(SafeMode.UNSAFE)
+        .baseDir(base)
+        .toFile(outputFile);
 
     AttributesBuilder attributesBuilder = AttributesBuilder.attributes();
     attributesBuilder.attributes(getAttributes());
+    if (revnumber != null) {
+      attributesBuilder.attribute(REVNUMBER_NAME, revnumber);
+    }
     optionsBuilder.attributes(attributesBuilder.get());
 
     return optionsBuilder.get();
@@ -123,8 +139,7 @@
     try {
       parser.parseArgument(parameters);
       if (inputFiles.isEmpty()) {
-        throw new CmdLineException(parser,
-            "asciidoctor: FAILED: input file missing");
+        throw new CmdLineException(parser, "asciidoctor: FAILED: input file missing");
       }
     } catch (CmdLineException e) {
       System.err.println(e.getMessage());
@@ -133,49 +148,62 @@
       return;
     }
 
-    try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(zipFile))) {
-      for (String inputFile : inputFiles) {
-        if (!inputFile.endsWith(inExt)) {
-          // We have to use UNSAFE mode in order to make embedding work. But in
-          // UNSAFE mode we'll also need css file in the same directory, so we
-          // have to add css files into the SRCS.
-          continue;
-        }
-
-        String outName = mapInFileToOutFile(inputFile, inExt, outExt);
-        File out = new File(tmpdir, outName);
-        out.getParentFile().mkdirs();
-        Options options = createOptions(out);
-        renderInput(options, new File(inputFile));
-        zipFile(out, outName, zip);
+    if (revnumberFile != null) {
+      try (BufferedReader reader = new BufferedReader(new FileReader(revnumberFile))) {
+        revnumber = reader.readLine();
       }
+    }
 
-      File[] cssFiles = tmpdir.listFiles(new FilenameFilter() {
-        @Override
-        public boolean accept(File dir, String name) {
-          return name.endsWith(".css");
+    if (mktmp) {
+      tmpdir = Files.createTempDirectory("asciidoctor-").toFile();
+    }
+
+    if (bazel) {
+      renderFiles(inputFiles, null);
+    } else {
+      try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(Paths.get(zipFile)))) {
+        renderFiles(inputFiles, zip);
+
+        File[] cssFiles =
+            tmpdir.listFiles(
+                new FilenameFilter() {
+                  @Override
+                  public boolean accept(File dir, String name) {
+                    return name.endsWith(".css");
+                  }
+                });
+        for (File css : cssFiles) {
+          zipFile(css, css.getName(), zip);
         }
-      });
-      for (File css : cssFiles) {
-        zipFile(css, css.getName(), zip);
       }
     }
   }
 
-  public static void zipFile(File file, String name, ZipOutputStream zip)
-      throws IOException {
+  private void renderFiles(List<String> inputFiles, ZipOutputStream zip) throws IOException {
+    Asciidoctor asciidoctor = JRubyAsciidoctor.create();
+    for (String inputFile : inputFiles) {
+      String outName = mapInFileToOutFile(inputFile, inExt, outExt);
+      File out = bazel ? new File(outName) : new File(tmpdir, outName);
+      if (!bazel) {
+        out.getParentFile().mkdirs();
+      }
+      File input = new File(inputFile);
+      Options options = createOptions(basedir != null ? basedir : input.getParentFile(), out);
+      asciidoctor.renderFile(input, options);
+      if (zip != null) {
+        zipFile(out, outName, zip);
+      }
+    }
+  }
+
+  public static void zipFile(File file, String name, ZipOutputStream zip) throws IOException {
     zip.putNextEntry(new ZipEntry(name));
-    try (FileInputStream input = new FileInputStream(file)) {
+    try (InputStream input = Files.newInputStream(file.toPath())) {
       ByteStreams.copy(input, zip);
     }
     zip.closeEntry();
   }
 
-  private void renderInput(Options options, File inputFile) {
-    Asciidoctor asciidoctor = JRubyAsciidoctor.create();
-    asciidoctor.renderFile(inputFile, options);
-  }
-
   public static void main(String[] args) {
     try {
       new AsciiDoctor().invoke(args);
diff --git a/lib/asciidoctor/java/DocIndexer.java b/lib/asciidoctor/java/DocIndexer.java
index aa29d35..fbb7f94 100644
--- a/lib/asciidoctor/java/DocIndexer.java
+++ b/lib/asciidoctor/java/DocIndexer.java
@@ -15,7 +15,24 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.server.documentation.Constants;
-
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.UnsupportedEncodingException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 import org.apache.lucene.analysis.standard.StandardAnalyzer;
 import org.apache.lucene.analysis.util.CharArraySet;
 import org.apache.lucene.document.Document;
@@ -32,25 +49,6 @@
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
 
-import java.io.BufferedReader;
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.FileReader;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.UnsupportedEncodingException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.jar.JarEntry;
-import java.util.jar.JarOutputStream;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-
 public class DocIndexer {
   private static final Pattern SECTION_HEADER = Pattern.compile("^=+ (.*)");
 
@@ -83,10 +81,9 @@
       return;
     }
 
-    try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(outFile))) {
+    try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(Paths.get(outFile)))) {
       byte[] compressedIndex = zip(index());
-      JarEntry entry = new JarEntry(
-          String.format("%s/%s", Constants.PACKAGE, Constants.INDEX_ZIP));
+      JarEntry entry = new JarEntry(String.format("%s/%s", Constants.PACKAGE, Constants.INDEX_ZIP));
       entry.setSize(compressedIndex.length);
       jar.putNextEntry(entry);
       jar.write(compressedIndex);
@@ -94,11 +91,10 @@
     }
   }
 
-  private RAMDirectory index() throws IOException,
-      UnsupportedEncodingException, FileNotFoundException {
+  private RAMDirectory index()
+      throws IOException, UnsupportedEncodingException, FileNotFoundException {
     RAMDirectory directory = new RAMDirectory();
-    IndexWriterConfig config = new IndexWriterConfig(
-        new StandardAnalyzer(CharArraySet.EMPTY_SET));
+    IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer(CharArraySet.EMPTY_SET));
     config.setOpenMode(OpenMode.CREATE);
     config.setCommitOnClose(true);
     try (IndexWriter iwriter = new IndexWriter(directory, config)) {
@@ -109,8 +105,8 @@
         }
 
         String title;
-        try (BufferedReader titleReader = new BufferedReader(
-            new InputStreamReader(new FileInputStream(file), UTF_8))) {
+        try (BufferedReader titleReader =
+            new BufferedReader(new InputStreamReader(Files.newInputStream(file.toPath()), UTF_8))) {
           title = titleReader.readLine();
           if (title != null && title.startsWith("[[")) {
             // Generally the first line of the txt is the title. In a few cases the
@@ -123,13 +119,11 @@
           title = matcher.group(1);
         }
 
-        String outputFile = AsciiDoctor.mapInFileToOutFile(
-            inputFile, inExt, outExt);
+        String outputFile = AsciiDoctor.mapInFileToOutFile(inputFile, inExt, outExt);
         try (FileReader reader = new FileReader(file)) {
           Document doc = new Document();
           doc.add(new TextField(Constants.DOC_FIELD, reader));
-          doc.add(new StringField(
-            Constants.URL_FIELD, prefix + outputFile, Field.Store.YES));
+          doc.add(new StringField(Constants.URL_FIELD, prefix + outputFile, Field.Store.YES));
           doc.add(new TextField(Constants.TITLE_FIELD, title, Field.Store.YES));
           iwriter.addDocument(doc);
         }
diff --git a/lib/auto/BUCK b/lib/auto/BUCK
deleted file mode 100644
index 6197e34..0000000
--- a/lib/auto/BUCK
+++ /dev/null
@@ -1,9 +0,0 @@
-include_defs('//lib/maven.defs')
-
-maven_jar(
-  name = 'auto-value',
-  id = 'com.google.auto.value:auto-value:1.3-rc1',
-  sha1 = 'b764e0fb7e11353fbff493b22fd6e83bf091a179',
-  license = 'Apache2.0',
-  visibility = ['PUBLIC'],
-)
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index e07c36d..b60a101 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -1,21 +1,41 @@
+load("@rules_java//java:defs.bzl", "java_library", "java_plugin")
+
 java_plugin(
-  name = 'auto-annotation-plugin',
-  processor_class = 'com.google.auto.value.processor.AutoAnnotationProcessor',
-  deps = ['@auto_value//jar'],
+    name = "auto-annotation-plugin",
+    processor_class = "com.google.auto.value.processor.AutoAnnotationProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+    ],
 )
 
 java_plugin(
-  name = 'auto-value-plugin',
-  processor_class = 'com.google.auto.value.processor.AutoValueProcessor',
-  deps = ['@auto_value//jar'],
+    name = "auto-value-plugin",
+    processor_class = "com.google.auto.value.processor.AutoValueProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+    ],
 )
 
 java_library(
-  name = 'auto-value',
-  exported_plugins = [
-    ':auto-annotation-plugin',
-    ':auto-value-plugin',
-  ],
-  exports = ['@auto_value//jar'],
-  visibility = ['//visibility:public'],
+    name = "auto-value",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-annotation-plugin",
+        ":auto-value-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto-value//jar"],
+)
+
+java_library(
+    name = "auto-value-annotations",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-annotation-plugin",
+        ":auto-value-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto-value-annotations//jar"],
 )
diff --git a/lib/auto/auto_value.defs b/lib/auto/auto_value.defs
deleted file mode 100644
index 4405747..0000000
--- a/lib/auto/auto_value.defs
+++ /dev/null
@@ -1,21 +0,0 @@
-# NOTE: Do not use this file in your build rules; automatically supported by
-# our implementation of java_library.
-
-AUTO_VALUE_DEP = '//lib/auto:auto-value'
-
-# Annotation processor classpath requires transitive dependencies.
-# TODO(dborowitz): Clean this up when buck issue is closed and there is a
-# better supported interface:
-# https://github.com/facebook/buck/issues/85
-AUTO_VALUE_PROCESSOR_DEPS = [
-  '//lib:velocity',
-  '//lib/auto:auto-value',
-  '//lib/commons:collections',
-  '//lib/commons:lang',
-  '//lib/commons:oro',
-]
-
-AUTO_VALUE_PROCESSORS = [
-  'com.google.auto.value.processor.AutoAnnotationProcessor',
-  'com.google.auto.value.processor.AutoValueProcessor',
-]
diff --git a/lib/bouncycastle/BUCK b/lib/bouncycastle/BUCK
deleted file mode 100644
index 68fa006..0000000
--- a/lib/bouncycastle/BUCK
+++ /dev/null
@@ -1,28 +0,0 @@
-include_defs('//lib/maven.defs')
-
-# This version must match the version that also appears in
-# gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
-VERSION = '1.52'
-
-maven_jar(
-  name = 'bcprov',
-  id = 'org.bouncycastle:bcprov-jdk15on:' + VERSION,
-  sha1 = '88a941faf9819d371e3174b5ed56a3f3f7d73269',
-  license = 'DO_NOT_DISTRIBUTE', #'bouncycastle'
-)
-
-maven_jar(
-  name = 'bcpg',
-  id = 'org.bouncycastle:bcpg-jdk15on:' + VERSION,
-  sha1 = 'ff4665a4b5633ff6894209d5dd10b7e612291858',
-  license = 'DO_NOT_DISTRIBUTE', #'bouncycastle'
-  deps = [':bcprov'],
-)
-
-maven_jar(
-  name = 'bcpkix',
-  id = 'org.bouncycastle:bcpkix-jdk15on:' + VERSION,
-  sha1 = 'b8ffac2bbc6626f86909589c8cc63637cc936504',
-  license = 'DO_NOT_DISTRIBUTE', #'bouncycastle'
-  deps = [':bcprov'],
-)
diff --git a/lib/bouncycastle/BUILD b/lib/bouncycastle/BUILD
index 49c54ba..43ba6e1 100644
--- a/lib/bouncycastle/BUILD
+++ b/lib/bouncycastle/BUILD
@@ -1,38 +1,46 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'bcprov',
-  neverlink = 1,
-  exports = ['@bcprov//jar'],
-  visibility = ['//visibility:public'],
+    name = "bcprov",
+    data = ["//lib:LICENSE-bouncycastle"],
+    visibility = ["//visibility:public"],
+    exports = ["@bcprov//jar"],
 )
 
 java_library(
-  name = 'bcprov-without-neverlink',
-  exports = ['@bcprov//jar'],
-  visibility = ['//visibility:public'],
+    name = "bcpg",
+    data = ["//lib:LICENSE-bouncycastle"],
+    visibility = ["//visibility:public"],
+    exports = ["@bcpg//jar"],
 )
 
 java_library(
-  name = 'bcpg',
-  neverlink = 1,
-  exports = ['@bcpg//jar'],
-  visibility = ['//visibility:public'],
+    name = "bcpkix",
+    data = ["//lib:LICENSE-bouncycastle"],
+    visibility = ["//visibility:public"],
+    exports = ["@bcpkix//jar"],
 )
 
 java_library(
-  name = 'bcpg-without-neverlink',
-  exports = ['@bcpg//jar'],
-  visibility = ['//visibility:public'],
+    name = "bcprov-neverlink",
+    data = ["//lib:LICENSE-bouncycastle"],
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = ["@bcprov//jar"],
 )
 
 java_library(
-  name = 'bcpkix',
-  neverlink = 1,
-  exports = ['@bcpkix//jar'],
-  visibility = ['//visibility:public'],
+    name = "bcpg-neverlink",
+    data = ["//lib:LICENSE-bouncycastle"],
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = ["@bcpg//jar"],
 )
 
 java_library(
-  name = 'bcpkix-without-neverlink',
-  exports = ['@bcpkix//jar'],
-  visibility = ['//visibility:public'],
+    name = "bcpkix-neverlink",
+    data = ["//lib:LICENSE-bouncycastle"],
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = ["@bcpkix//jar"],
 )
diff --git a/lib/codemirror/BUCK b/lib/codemirror/BUCK
deleted file mode 100644
index a0e0e9a..0000000
--- a/lib/codemirror/BUCK
+++ /dev/null
@@ -1,141 +0,0 @@
-include_defs('//lib/maven.defs')
-include_defs('//lib/codemirror/cm.defs')
-
-VERSION = '5.17.0'
-TOP = 'META-INF/resources/webjars/codemirror/%s' % VERSION
-TOP_MINIFIED = 'META-INF/resources/webjars/codemirror-minified/%s' % VERSION
-
-maven_jar(
-  name = 'codemirror-minified',
-  id = 'org.webjars.npm:codemirror-minified:' + VERSION,
-  sha1 = '05ad901fc9be67eb7ba8997d896488093deb898e',
-  attach_source = False,
-  license = 'codemirror-minified',
-  visibility = [],
-)
-
-maven_jar(
-  name = 'codemirror-original',
-  id = 'org.webjars.npm:codemirror:' + VERSION,
-  sha1 = 'c025b8d9aca1061e26d1fa482bea0ecea1412e85',
-  attach_source = False,
-  license = 'codemirror-original',
-  visibility = [],
-)
-
-DIFF_MATCH_PATCH_VERSION = '20121119-1'
-DIFF_MATCH_PATCH_TOP = ('META-INF/resources/webjars/google-diff-match-patch/%s'
-    % DIFF_MATCH_PATCH_VERSION)
-
-maven_jar(
-  name = 'diff-match-patch',
-  id = 'org.webjars:google-diff-match-patch:' + DIFF_MATCH_PATCH_VERSION,
-  sha1 = '0cf1782dbcb8359d95070da9176059a5a9d37709',
-  license = 'Apache2.0',
-  attach_source = False,
-)
-
-for archive, suffix, top in [('codemirror-original', '', TOP), ('codemirror-minified', '_r', TOP_MINIFIED)]:
-  # Main JavaScript and addons
-  genrule(
-    name = 'cm' + suffix,
-    cmd = ';'.join([
-        "echo '/** @license' >$OUT",
-        'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
-        "echo '*/' >>$OUT",
-      ] +
-      ['unzip -p $(location :%s) %s/%s >>$OUT' % (archive, top, n) for n in CM_JS] +
-      ['unzip -p $(location :%s) %s/addon/%s >>$OUT' % (archive, top, n)
-       for n in CM_ADDONS]
-    ),
-    out = 'cm%s.js' % suffix,
-  )
-
-  # Main CSS
-  genrule(
-    name = 'css' + suffix,
-    cmd = ';'.join([
-        "echo '/** @license' >$OUT",
-        'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
-        "echo '*/' >>$OUT",
-      ] +
-      ['unzip -p $(location :%s) %s/%s >>$OUT' % (archive, top, n)
-       for n in CM_CSS]
-    ),
-    out = 'cm%s.css' % suffix,
-  )
-
-  # Modes
-  for n in CM_MODES:
-    genrule (
-      name = 'mode_%s%s' % (n, suffix),
-      cmd = ';'.join([
-          "echo '/** @license' >$OUT",
-          'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
-          "echo '*/' >>$OUT",
-          'unzip -p $(location :%s) %s/mode/%s/%s.js >>$OUT' % (archive, top, n, n),
-        ]
-      ),
-      out = 'mode_%s%s.js' % (n, suffix),
-    )
-
-  # Themes
-  for n in CM_THEMES:
-    genrule(
-      name = 'theme_%s%s' % (n, suffix),
-      cmd = ';'.join([
-          "echo '/** @license' >$OUT",
-          'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
-          "echo '*/' >>$OUT",
-          'unzip -p $(location :%s) %s/theme/%s.css >>$OUT' % (archive, top, n)
-        ]
-      ),
-      out = 'theme_%s%s.css' % (n, suffix),
-    )
-
-  # Merge Addon bundled with diff-match-patch
-  genrule(
-    name = 'addon_merge%s' % suffix,
-    cmd = ';'.join([
-        "echo '/** @license' >$OUT",
-        'unzip -p $(location :%s) %s/LICENSE >>$OUT' % (archive, top),
-        "echo '*/\n' >>$OUT",
-        "echo '// The google-diff-match-patch library is from https://google-diff-match-patch.googlecode.com/svn-history/r106/trunk/javascript/diff_match_patch.js\n' >> $OUT",
-        "echo '/** @license' >>$OUT",
-        'cat $(location //lib:LICENSE-Apache2.0) >>$OUT',
-        "echo '*/' >>$OUT",
-        'unzip -p $(location :diff-match-patch) %s/diff_match_patch.js >>$OUT' % DIFF_MATCH_PATCH_TOP,
-        "echo ';' >> $OUT",
-        'unzip -p $(location :%s) %s/addon/merge/merge.js >>$OUT' % (archive, top)
-      ]
-    ),
-    out = 'addon_merge%s.js' % suffix,
-  )
-
-  # Jar packaging
-  genrule(
-    name = 'jar' + suffix,
-    cmd = ';'.join([
-      'cd $TMP',
-      'mkdir -p net/codemirror/{addon,lib,mode,theme}',
-      'cp $(location :css%s) net/codemirror/lib/cm.css' % suffix,
-      'cp $(location :cm%s) net/codemirror/lib/cm.js' % suffix]
-      + ['cp $(location :mode_%s%s) net/codemirror/mode/%s.js' % (n, suffix, n)
-         for n in CM_MODES]
-      + ['cp $(location :theme_%s%s) net/codemirror/theme/%s.css' % (n, suffix, n)
-         for n in CM_THEMES]
-      + ['cp $(location :addon_merge%s) net/codemirror/addon/merge_bundled.js' % suffix]
-      + ['zip -qr $OUT net/codemirror/{addon,lib,mode,theme}']),
-    out = 'codemirror%s.jar' % suffix,
-  )
-
-  prebuilt_jar(
-    name = 'codemirror' + suffix,
-    binary_jar = ':jar%s' % suffix,
-    deps = [
-      ':jar' + suffix,
-      '//lib:LICENSE-' + archive,
-    ],
-    visibility = ['PUBLIC'],
-  )
-
diff --git a/lib/codemirror/BUILD b/lib/codemirror/BUILD
new file mode 100644
index 0000000..05ca920
--- /dev/null
+++ b/lib/codemirror/BUILD
@@ -0,0 +1,12 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//lib/codemirror:cm.bzl", "pkg_cm")
+
+# This library is only used to insert a license statement into
+# js_licenses.txt.
+java_library(
+    name = "diff-match-patch",
+    data = ["//lib:LICENSE-Apache2.0"],
+    runtime_deps = ["@diff-match-patch//jar"],
+)
+
+pkg_cm()
diff --git a/lib/codemirror/cm.bzl b/lib/codemirror/cm.bzl
new file mode 100644
index 0000000..bff5145
--- /dev/null
+++ b/lib/codemirror/cm.bzl
@@ -0,0 +1,372 @@
+load("@rules_java//java:defs.bzl", "java_import")
+load("//tools/bzl:genrule2.bzl", "genrule2")
+
+CM_CSS = [
+    "lib/codemirror.css",
+    "addon/dialog/dialog.css",
+    "addon/merge/merge.css",
+    "addon/scroll/simplescrollbars.css",
+    "addon/search/matchesonscrollbar.css",
+    "addon/lint/lint.css",
+]
+
+CM_JS = [
+    "lib/codemirror.js",
+    "mode/meta.js",
+    "keymap/emacs.js",
+    "keymap/sublime.js",
+    "keymap/vim.js",
+]
+
+CM_ADDONS = [
+    "dialog/dialog.js",
+    "edit/closebrackets.js",
+    "edit/matchbrackets.js",
+    "edit/trailingspace.js",
+    "scroll/annotatescrollbar.js",
+    "scroll/simplescrollbars.js",
+    "search/jump-to-line.js",
+    "search/matchesonscrollbar.js",
+    "search/searchcursor.js",
+    "search/search.js",
+    "selection/mark-selection.js",
+    "mode/multiplex.js",
+    "mode/overlay.js",
+    "mode/simple.js",
+    "lint/lint.js",
+]
+
+# Available themes must be enumerated here,
+# in gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Theme.java,
+# in gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
+CM_THEMES = [
+    "3024-day",
+    "3024-night",
+    "abcdef",
+    "ambiance",
+    "base16-dark",
+    "base16-light",
+    "bespin",
+    "blackboard",
+    "cobalt",
+    "colorforth",
+    "dracula",
+    "duotone-dark",
+    "duotone-light",
+    "eclipse",
+    "elegant",
+    "erlang-dark",
+    "hopscotch",
+    "icecoder",
+    "isotope",
+    "lesser-dark",
+    "liquibyte",
+    "material",
+    "mbo",
+    "mdn-like",
+    "midnight",
+    "monokai",
+    "neat",
+    "neo",
+    "night",
+    "paraiso-dark",
+    "paraiso-light",
+    "pastel-on-dark",
+    "railscasts",
+    "rubyblue",
+    "seti",
+    "solarized",
+    "the-matrix",
+    "tomorrow-night-bright",
+    "tomorrow-night-eighties",
+    "ttcn",
+    "twilight",
+    "vibrant-ink",
+    "xq-dark",
+    "xq-light",
+    "yeti",
+    "zenburn",
+]
+
+# Available modes must be enumerated here,
+# in gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java,
+# gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java,
+# and in CodeMirror's own mode/meta.js script.
+CM_MODES = [
+    "apl",
+    "asciiarmor",
+    "asn.1",
+    "asterisk",
+    "brainfuck",
+    "clike",
+    "clojure",
+    "cmake",
+    "cobol",
+    "coffeescript",
+    "commonlisp",
+    "crystal",
+    "css",
+    "cypher",
+    "d",
+    "dart",
+    "diff",
+    "django",
+    "dockerfile",
+    "dtd",
+    "dylan",
+    "ebnf",
+    "ecl",
+    "eiffel",
+    "elm",
+    "erlang",
+    "factor",
+    "fcl",
+    "forth",
+    "fortran",
+    "gas",
+    "gfm",
+    "gherkin",
+    "go",
+    "groovy",
+    "haml",
+    "handlebars",
+    "haskell-literate",
+    "haskell",
+    "haxe",
+    "htmlembedded",
+    "htmlmixed",
+    "http",
+    "idl",
+    "javascript",
+    "jinja2",
+    "jsx",
+    "julia",
+    "livescript",
+    "lua",
+    "markdown",
+    "mathematica",
+    "mbox",
+    "mirc",
+    "mllike",
+    "modelica",
+    "mscgen",
+    "mumps",
+    "nginx",
+    "nsis",
+    "ntriples",
+    "octave",
+    "oz",
+    "pascal",
+    "pegjs",
+    "perl",
+    "php",
+    "pig",
+    "powershell",
+    "properties",
+    "protobuf",
+    "pug",
+    "puppet",
+    "python",
+    "q",
+    "r",
+    "rpm",
+    "rst",
+    "ruby",
+    "rust",
+    "sas",
+    "sass",
+    "scheme",
+    "shell",
+    "sieve",
+    "slim",
+    "smalltalk",
+    "smarty",
+    "solr",
+    "soy",
+    "sparql",
+    "spreadsheet",
+    "sql",
+    "stex",
+    "stylus",
+    "swift",
+    "tcl",
+    "textile",
+    "tiddlywiki",
+    "tiki",
+    "toml",
+    "tornado",
+    "troff",
+    "ttcn-cfg",
+    "ttcn",
+    "turtle",
+    "twig",
+    "vb",
+    "vbscript",
+    "velocity",
+    "verilog",
+    "vhdl",
+    "vue",
+    "webidl",
+    "xml",
+    "xquery",
+    "yacas",
+    "yaml-frontmatter",
+    "yaml",
+    "z80",
+]
+
+CM_VERSION = "5.25.0"
+
+TOP = "META-INF/resources/webjars/codemirror/%s" % CM_VERSION
+
+TOP_MINIFIED = "META-INF/resources/webjars/codemirror-minified/%s" % CM_VERSION
+
+LICENSE = "//lib:LICENSE-codemirror-original"
+
+LICENSE_MINIFIED = "//lib:LICENSE-codemirror-minified"
+
+DIFF_MATCH_PATCH_VERSION = "20121119-1"
+
+DIFF_MATCH_PATCH_TOP = ("META-INF/resources/webjars/google-diff-match-patch/%s" %
+                        DIFF_MATCH_PATCH_VERSION)
+
+def pkg_cm():
+    for archive, suffix, top, license in [
+        ("@codemirror-original//jar", "", TOP, LICENSE),
+        ("@codemirror-minified//jar", "_r", TOP_MINIFIED, LICENSE_MINIFIED),
+    ]:
+        # Main JavaScript and addons
+        genrule2(
+            name = "cm" + suffix,
+            cmd = " && ".join(
+                [
+                    "echo '/** @license' >$@",
+                    "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                    "echo '*/' >>$@",
+                ] +
+                ["unzip -p $(location %s) %s/%s >>$@" % (archive, top, n) for n in CM_JS] +
+                [
+                    "unzip -p $(location %s) %s/addon/%s >>$@" % (archive, top, n)
+                    for n in CM_ADDONS
+                ],
+            ),
+            tools = [archive],
+            outs = ["cm%s.js" % suffix],
+        )
+
+        # Main CSS
+        genrule2(
+            name = "css" + suffix,
+            cmd = " && ".join(
+                [
+                    "echo '/** @license' >$@",
+                    "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                    "echo '*/' >>$@",
+                ] +
+                [
+                    "unzip -p $(location %s) %s/%s >>$@" % (archive, top, n)
+                    for n in CM_CSS
+                ],
+            ),
+            tools = [archive],
+            outs = ["cm%s.css" % suffix],
+        )
+
+        # Modes
+        for n in CM_MODES:
+            genrule2(
+                name = "mode_%s%s" % (n, suffix),
+                cmd = " && ".join(
+                    [
+                        "echo '/** @license' >$@",
+                        "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                        "echo '*/' >>$@",
+                        "unzip -p $(location %s) %s/mode/%s/%s.js >>$@" % (archive, top, n, n),
+                    ],
+                ),
+                tools = [archive],
+                outs = ["mode_%s%s.js" % (n, suffix)],
+            )
+
+        # Themes
+        for n in CM_THEMES:
+            genrule2(
+                name = "theme_%s%s" % (n, suffix),
+                cmd = " && ".join(
+                    [
+                        "echo '/** @license' >$@",
+                        "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                        "echo '*/' >>$@",
+                        "unzip -p $(location %s) %s/theme/%s.css >>$@" % (archive, top, n),
+                    ],
+                ),
+                tools = [archive],
+                outs = ["theme_%s%s.css" % (n, suffix)],
+            )
+
+        # Merge Addon bundled with diff-match-patch
+        genrule2(
+            name = "addon_merge_with_diff_match_patch%s" % suffix,
+            cmd = " && ".join(
+                [
+                    "echo '/** @license' >$@",
+                    "unzip -p $(location %s) %s/LICENSE >>$@" % (archive, top),
+                    "echo '*/\n' >>$@",
+                    "echo '// The google-diff-match-patch library is from https://repo1.maven.org/maven2/org/webjars/google-diff-match-patch/%s/google-diff-match-patch-%s.jar\n' >> $@" % (DIFF_MATCH_PATCH_VERSION, DIFF_MATCH_PATCH_VERSION),
+                    "echo '/** @license' >>$@",
+                    "echo 'LICENSE-Apache2.0' >>$@",
+                    "echo '*/' >>$@",
+                    "unzip -p $(location @diff-match-patch//jar) %s/diff_match_patch.js >>$@" % DIFF_MATCH_PATCH_TOP,
+                    "echo ';' >> $@",
+                    "unzip -p $(location %s) %s/addon/merge/merge.js >>$@" % (archive, top),
+                ],
+            ),
+            tools = [
+                "@diff-match-patch//jar",
+                # dependency just for license tracking.
+                ":diff-match-patch",
+                archive,
+                "//lib:LICENSE-Apache2.0",
+            ],
+            outs = ["addon_merge_with_diff_match_patch%s.js" % suffix],
+        )
+
+        # Jar packaging
+        genrule2(
+            name = "jar" + suffix,
+            cmd = " && ".join([
+                                  "cd $$TMP",
+                                  "mkdir -p net/codemirror/{addon,lib,mode,theme}",
+                                  "cp $$ROOT/$(location :css%s) net/codemirror/lib/cm.css" % suffix,
+                                  "cp $$ROOT/$(location :cm%s) net/codemirror/lib/cm.js" % suffix,
+                              ] +
+                              [
+                                  "cp $$ROOT/$(location :mode_%s%s) net/codemirror/mode/%s.js" % (n, suffix, n)
+                                  for n in CM_MODES
+                              ] +
+                              [
+                                  "cp $$ROOT/$(location :theme_%s%s) net/codemirror/theme/%s.css" % (n, suffix, n)
+                                  for n in CM_THEMES
+                              ] +
+                              ["cp $$ROOT/$(location :addon_merge_with_diff_match_patch%s) net/codemirror/addon/merge_bundled.js" % suffix] +
+                              ["zip -qr $$ROOT/$@ net/codemirror/{addon,lib,mode,theme}"]),
+            tools = [
+                ":addon_merge_with_diff_match_patch%s" % suffix,
+                ":cm%s" % suffix,
+                ":css%s" % suffix,
+            ] + [
+                ":mode_%s%s" % (n, suffix)
+                for n in CM_MODES
+            ] + [
+                ":theme_%s%s" % (n, suffix)
+                for n in CM_THEMES
+            ],
+            outs = ["codemirror%s.jar" % suffix],
+        )
+
+        java_import(
+            name = "codemirror" + suffix,
+            jars = [":jar%s" % suffix],
+            visibility = ["//visibility:public"],
+            data = [license],
+        )
diff --git a/lib/codemirror/cm.defs b/lib/codemirror/cm.defs
deleted file mode 100644
index baf2ce5..0000000
--- a/lib/codemirror/cm.defs
+++ /dev/null
@@ -1,211 +0,0 @@
-CM_CSS = [
-  'lib/codemirror.css',
-  'addon/dialog/dialog.css',
-  'addon/merge/merge.css',
-  'addon/scroll/simplescrollbars.css',
-  'addon/search/matchesonscrollbar.css',
-  'addon/lint/lint.css',
-]
-
-CM_JS = [
-  'lib/codemirror.js',
-  'mode/meta.js',
-  'keymap/emacs.js',
-  'keymap/sublime.js',
-  'keymap/vim.js',
-]
-
-CM_ADDONS = [
-  'dialog/dialog.js',
-  'edit/closebrackets.js',
-  'edit/matchbrackets.js',
-  'edit/trailingspace.js',
-  'scroll/annotatescrollbar.js',
-  'scroll/simplescrollbars.js',
-  'search/jump-to-line.js',
-  'search/matchesonscrollbar.js',
-  'search/searchcursor.js',
-  'search/search.js',
-  'selection/mark-selection.js',
-  'mode/multiplex.js',
-  'mode/overlay.js',
-  'mode/simple.js',
-  'lint/lint.js',
-]
-
-# Available themes must be enumerated here,
-# in gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Theme.java,
-# in gerrit-gwtui/src/main/java/net/codemirror/theme/Themes.java
-CM_THEMES = [
-  '3024-day',
-  '3024-night',
-  'abcdef',
-  'ambiance',
-  'base16-dark',
-  'base16-light',
-  'bespin',
-  'blackboard',
-  'cobalt',
-  'colorforth',
-  'dracula',
-  'eclipse',
-  'elegant',
-  'erlang-dark',
-  'hopscotch',
-  'icecoder',
-  'isotope',
-  'lesser-dark',
-  'liquibyte',
-  'material',
-  'mbo',
-  'mdn-like',
-  'midnight',
-  'monokai',
-  'neat',
-  'neo',
-  'night',
-  'paraiso-dark',
-  'paraiso-light',
-  'pastel-on-dark',
-  'railscasts',
-  'rubyblue',
-  'seti',
-  'solarized',
-  'the-matrix',
-  'tomorrow-night-bright',
-  'tomorrow-night-eighties',
-  'ttcn',
-  'twilight',
-  'vibrant-ink',
-  'xq-dark',
-  'xq-light',
-  'yeti',
-  'zenburn',
-]
-
-# Available modes must be enumerated here,
-# in gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java,
-# gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java,
-# and in CodeMirror's own mode/meta.js script.
-CM_MODES = [
-  'apl',
-  'asciiarmor',
-  'asn.1',
-  'asterisk',
-  'brainfuck',
-  'clike',
-  'clojure',
-  'cmake',
-  'cobol',
-  'coffeescript',
-  'commonlisp',
-  'crystal',
-  'css',
-  'cypher',
-  'd',
-  'dart',
-  'diff',
-  'django',
-  'dockerfile',
-  'dtd',
-  'dylan',
-  'ebnf',
-  'ecl',
-  'eiffel',
-  'elm',
-  'erlang',
-  'factor',
-  'fcl',
-  'forth',
-  'fortran',
-  'gas',
-  'gfm',
-  'gherkin',
-  'go',
-  'groovy',
-  'haml',
-  'handlebars',
-  'haskell-literate',
-  'haskell',
-  'haxe',
-  'htmlembedded',
-  'htmlmixed',
-  'http',
-  'idl',
-  'jade',
-  'javascript',
-  'jinja2',
-  'jsx',
-  'julia',
-  'livescript',
-  'lua',
-  'markdown',
-  'mathematica',
-  'mbox',
-  'mirc',
-  'mllike',
-  'modelica',
-  'mscgen',
-  'mumps',
-  'nginx',
-  'nsis',
-  'ntriples',
-  'octave',
-  'oz',
-  'pascal',
-  'pegjs',
-  'perl',
-  'php',
-  'pig',
-  'powershell',
-  'properties',
-  'protobuf',
-  'puppet',
-  'python',
-  'q',
-  'r',
-  'rpm',
-  'rst',
-  'ruby',
-  'rust',
-  'sas',
-  'sass',
-  'scheme',
-  'shell',
-  'sieve',
-  'slim',
-  'smalltalk',
-  'smarty',
-  'solr',
-  'soy',
-  'sparql',
-  'spreadsheet',
-  'sql',
-  'stex',
-  'stylus',
-  'swift',
-  'tcl',
-  'textile',
-  'tiddlywiki',
-  'tiki',
-  'toml',
-  'tornado',
-  'troff',
-  'ttcn-cfg',
-  'ttcn',
-  'turtle',
-  'twig',
-  'vb',
-  'vbscript',
-  'velocity',
-  'verilog',
-  'vhdl',
-  'vue',
-  'webidl',
-  'xml',
-  'xquery',
-  'yacas',
-  'yaml-frontmatter',
-  'yaml',
-  'z80',
-]
diff --git a/lib/commons/BUCK b/lib/commons/BUCK
deleted file mode 100644
index 7c27477..0000000
--- a/lib/commons/BUCK
+++ /dev/null
@@ -1,86 +0,0 @@
-include_defs('//lib/maven.defs')
-
-maven_jar(
-  name = 'codec',
-  id = 'commons-codec:commons-codec:1.4',
-  sha1 = '4216af16d38465bbab0f3dff8efa14204f7a399a',
-  license = 'Apache2.0',
-  exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
-)
-
-maven_jar(
-  name = 'collections',
-  id = 'commons-collections:commons-collections:3.2.2',
-  sha1 = '8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5',
-  license = 'Apache2.0',
-  exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'compress',
-  id = 'org.apache.commons:commons-compress:1.7',
-  sha1 = 'ab365c96ee9bc88adcc6fa40d185c8e15a31410d',
-  license = 'Apache2.0',
-  exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
-)
-
-maven_jar(
-  name = 'dbcp',
-  id = 'commons-dbcp:commons-dbcp:1.4',
-  sha1 = '30be73c965cc990b153a100aaaaafcf239f82d39',
-  license = 'Apache2.0',
-  deps = [':pool'],
-  exclude = [
-    'META-INF/LICENSE.txt',
-    'META-INF/NOTICE.txt',
-    'testpool.jocl'
-  ],
-)
-
-maven_jar(
-  name = 'lang',
-  id = 'commons-lang:commons-lang:2.6',
-  sha1 = '0ce1edb914c94ebc388f086c6827e8bdeec71ac2',
-  license = 'Apache2.0',
-  exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
-)
-
-maven_jar(
-  name = 'net',
-  id = 'commons-net:commons-net:3.5',
-  sha1 = '342fc284019f590e1308056990fdb24a08f06318',
-  license = 'Apache2.0',
-  exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
-)
-
-maven_jar(
-  name = 'pool',
-  id = 'commons-pool:commons-pool:1.5.5',
-  sha1 = '7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b',
-  license = 'Apache2.0',
-  attach_source = False,
-  exclude = ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt'],
-)
-
-maven_jar(
-  name = 'oro',
-  id = 'oro:oro:2.0.8',
-  sha1 = '5592374f834645c4ae250f4c9fbb314c9369d698',
-  license = 'Apache1.1',
-  attach_source = False,
-  exclude = ['META-INF/LICENSE'],
-)
-
-# When updating the version of commons-validator, also update the
-# list of supported TLDs in:
-#    gerrit-server/src/test/resources/com/google/gerrit/server/mail/tlds-alpha-by-domain.txt
-# from:
-#    http://data.iana.org/TLD/tlds-alpha-by-domain.txt
-maven_jar(
-  name = 'validator',
-  id = 'commons-validator:commons-validator:1.5.1',
-  sha1 = '86d05a46e8f064b300657f751b5a98c62807e2a0',
-  license = 'Apache2.0',
-)
-
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index 8c42e53f..cb1f08f 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -1,54 +1,64 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_visibility = ["//visibility:public"])
+
 java_library(
-  name = 'codec',
-  exports = ['@commons_codec//jar'],
-  visibility = ['//visibility:public'],
+    name = "codec",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-codec//jar"],
 )
 
 java_library(
-  name = 'collections',
-  exports = ['@commons_collections//jar'],
-  visibility = ['//visibility:public'],
+    name = "collections",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-collections//jar"],
 )
 
 java_library(
-  name = 'compress',
-  exports = ['@commons_compress//jar'],
-  visibility = ['//visibility:public'],
+    name = "compress",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-compress//jar"],
 )
 
 java_library(
-  name = 'lang',
-  exports = ['@commons_lang//jar'],
-  visibility = ['//visibility:public'],
+    name = "io",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-io//jar"],
 )
 
 java_library(
-  name = 'net',
-  exports = ['@commons_net//jar'],
-  visibility = ['//visibility:public'],
+    name = "lang",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-lang//jar"],
 )
 
 java_library(
-  name = 'dbcp',
-  exports = ['@commons_dbcp//jar'],
-  runtime_deps = [':pool'],
-  visibility = ['//visibility:public'],
+    name = "net",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-net//jar"],
 )
 
 java_library(
-  name = 'pool',
-  exports = ['@commons_pool//jar'],
-  visibility = ['//visibility:public'],
+    name = "dbcp",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-dbcp//jar"],
+    runtime_deps = [":pool"],
 )
 
 java_library(
-  name = 'oro',
-  exports = ['@commons_oro//jar'],
-  visibility = ['//visibility:public'],
+    name = "pool",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-pool//jar"],
 )
 
 java_library(
-  name = 'validator',
-  exports = ['@commons_validator//jar'],
-  visibility = ['//visibility:public'],
+    name = "oro",
+    data = ["//lib:LICENSE-Apache1.1"],
+    exports = ["@commons-oro//jar"],
+)
+
+java_library(
+    name = "validator",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@commons-validator//jar"],
 )
diff --git a/lib/dropwizard/BUCK b/lib/dropwizard/BUCK
deleted file mode 100644
index de73e13..0000000
--- a/lib/dropwizard/BUCK
+++ /dev/null
@@ -1,8 +0,0 @@
-include_defs('//lib/maven.defs')
-
-maven_jar(
-  name = 'dropwizard-core',
-  id = 'io.dropwizard.metrics:metrics-core:3.1.2',
-  sha1 = '224f03afd2521c6c94632f566beb1bb5ee32cf07',
-  license = 'Apache2.0',
-)
diff --git a/lib/dropwizard/BUILD b/lib/dropwizard/BUILD
index 9d4a8d3..174b7ad 100644
--- a/lib/dropwizard/BUILD
+++ b/lib/dropwizard/BUILD
@@ -1,5 +1,8 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'dropwizard-core',
-  exports = ['@dropwizard_core//jar'],
-  visibility = ['//visibility:public'],
+    name = "dropwizard-core",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@dropwizard-core//jar"],
 )
diff --git a/lib/easymock/BUCK b/lib/easymock/BUCK
deleted file mode 100644
index 93640a0..0000000
--- a/lib/easymock/BUCK
+++ /dev/null
@@ -1,31 +0,0 @@
-include_defs('//lib/maven.defs')
-
-maven_jar(
-  name = 'easymock',
-  id = 'org.easymock:easymock:3.4', # When bumping the version
-  # number, make sure to also move powermock to a compatible version
-  sha1 = '9fdeea183a399f25c2469497612cad131e920fa3',
-  license = 'DO_NOT_DISTRIBUTE',
-  deps = [
-    ':cglib-2_2',
-    ':objenesis',
-  ],
-)
-
-maven_jar(
-  name = 'cglib-2_2',
-  id = 'cglib:cglib-nodep:2.2.2',
-  sha1 = '00d456bb230c70c0b95c76fb28e429d42f275941',
-  license = 'DO_NOT_DISTRIBUTE',
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'objenesis',
-  id = 'org.objenesis:objenesis:2.2',
-  sha1 = '3fb533efdaa50a768c394aa4624144cf8df17845',
-  license = 'DO_NOT_DISTRIBUTE',
-  visibility = ['//lib/powermock:powermock-reflect'],
-  attach_source = False,
-)
-
diff --git a/lib/easymock/BUILD b/lib/easymock/BUILD
index df77128..90c9673 100644
--- a/lib/easymock/BUILD
+++ b/lib/easymock/BUILD
@@ -1,22 +1,26 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'easymock',
-  exports = ['@easymock//jar'],
-  runtime_deps = [
-    ':cglib-2_2',
-    ':objenesis',
-  ],
-  visibility = ['//visibility:public'],
+    name = "easymock",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@easymock//jar"],
+    runtime_deps = [
+        ":cglib-3_2",
+        ":objenesis",
+    ],
 )
 
 java_library(
-  name = 'cglib-2_2',
-  exports = ['@cglib_2_2//jar'],
-  visibility = ['//visibility:public'],
+    name = "cglib-3_2",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@cglib-3_2//jar"],
 )
 
 java_library(
-  name = 'objenesis',
-  exports = ['@objenesis//jar'],
-  visibility = ['//visibility:public'],
+    name = "objenesis",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@objenesis//jar"],
 )
-
diff --git a/lib/elasticsearch-rest-client/BUILD b/lib/elasticsearch-rest-client/BUILD
new file mode 100644
index 0000000..e323263
--- /dev/null
+++ b/lib/elasticsearch-rest-client/BUILD
@@ -0,0 +1,9 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_visibility = ["//visibility:public"])
+
+java_library(
+    name = "elasticsearch-rest-client",
+    data = ["//lib:LICENSE-elasticsearch"],
+    exports = ["@elasticsearch-rest-client//jar"],
+)
diff --git a/lib/fonts/BUCK b/lib/fonts/BUCK
deleted file mode 100644
index c5b78eb..0000000
--- a/lib/fonts/BUCK
+++ /dev/null
@@ -1,30 +0,0 @@
-# Source Code Pro. Version 2.010 Roman / 1.030 Italics
-# https://github.com/adobe-fonts/source-code-pro/releases/tag/2.010R-ro%2F1.030R-it
-genrule(
-  name = 'sourcecodepro',
-  cmd = 'zip -rq $OUT .',
-  srcs = [
-    'SourceCodePro-Regular.woff',
-    'SourceCodePro-Regular.woff2'
-  ],
-  out = 'sourcecodepro.zip',
-  license = 'OFL1.1',
-  visibility = ['PUBLIC'],
-)
-
-# Open Sans at Revision 53a5266 and converted using a Google woff file
-# converter (same one that Google Fonts uses).
-# https://github.com/google/fonts/tree/master/apache/opensans
-genrule(
-  name = 'opensans',
-  cmd = 'zip -rq $OUT .',
-  srcs = [
-    'OpenSans-Bold.woff',
-    'OpenSans-Bold.woff2',
-    'OpenSans-Regular.woff',
-    'OpenSans-Regular.woff2'
-  ],
-  out = 'opensans.zip',
-  license = 'Apache2.0',
-  visibility = ['PUBLIC'],
-)
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
new file mode 100644
index 0000000..d97b238
--- /dev/null
+++ b/lib/fonts/BUILD
@@ -0,0 +1,11 @@
+# Source Code Pro. Version 2.010 Roman / 1.030 Italics
+# https://github.com/adobe-fonts/source-code-pro/releases/tag/2.010R-ro%2F1.030R-it
+filegroup(
+    name = "sourcecodepro",
+    srcs = [
+        "SourceCodePro-Regular.woff",
+        "SourceCodePro-Regular.woff2",
+    ],
+    data = ["//lib:LICENSE-OFL1.1"],
+    visibility = ["//visibility:public"],
+)
diff --git a/lib/fonts/OpenSans-Bold.woff b/lib/fonts/OpenSans-Bold.woff
deleted file mode 100644
index 74c4086..0000000
--- a/lib/fonts/OpenSans-Bold.woff
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/OpenSans-Bold.woff2 b/lib/fonts/OpenSans-Bold.woff2
deleted file mode 100644
index 44d6c26..0000000
--- a/lib/fonts/OpenSans-Bold.woff2
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/OpenSans-Regular.woff b/lib/fonts/OpenSans-Regular.woff
deleted file mode 100644
index 882f7c9..0000000
--- a/lib/fonts/OpenSans-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/OpenSans-Regular.woff2 b/lib/fonts/OpenSans-Regular.woff2
deleted file mode 100644
index 52217ee..0000000
--- a/lib/fonts/OpenSans-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/lib/greenmail/BUILD b/lib/greenmail/BUILD
new file mode 100644
index 0000000..f2ac7c6
--- /dev/null
+++ b/lib/greenmail/BUILD
@@ -0,0 +1,9 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_visibility = ["//visibility:public"])
+
+java_library(
+    name = "greenmail",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@greenmail//jar"],
+)
diff --git a/lib/guava.bzl b/lib/guava.bzl
new file mode 100644
index 0000000..0cd3f38
--- /dev/null
+++ b/lib/guava.bzl
@@ -0,0 +1,5 @@
+GUAVA_VERSION = "24.1.1-jre"
+
+GUAVA_BIN_SHA1 = "2e3014320a8005e3f3c1800cb246ed42db8cab81"
+
+GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/guice/BUCK b/lib/guice/BUCK
deleted file mode 100644
index 867b521..0000000
--- a/lib/guice/BUCK
+++ /dev/null
@@ -1,65 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VERSION = '4.1.0'
-EXCLUDE = [
-  'META-INF/DEPENDENCIES',
-  'META-INF/LICENSE',
-  'META-INF/NOTICE',
-]
-
-java_library(
-  name = 'guice',
-  exported_deps = [
-    ':guice_library',
-    ':javax-inject',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'guice_library',
-  id = 'com.google.inject:guice:' + VERSION,
-  sha1 = 'eeb69005da379a10071aa4948c48d89250febb07',
-  license = 'Apache2.0',
-  deps = [':aopalliance'],
-  exclude_java_sources = True,
-  exclude = EXCLUDE + [
-    'META-INF/maven/com.google.guava/guava/pom.properties',
-    'META-INF/maven/com.google.guava/guava/pom.xml',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'guice-assistedinject',
-  id = 'com.google.inject.extensions:guice-assistedinject:' + VERSION,
-  sha1 = 'af799dd7e23e6fe8c988da12314582072b07edcb',
-  license = 'Apache2.0',
-  deps = [':guice'],
-  exclude = EXCLUDE,
-)
-
-maven_jar(
-  name = 'guice-servlet',
-  id = 'com.google.inject.extensions:guice-servlet:' + VERSION,
-  sha1 = '90ac2db772d9b85e2b05417b74f7464bcc061dcb',
-  license = 'Apache2.0',
-  deps = [':guice'],
-  exclude = EXCLUDE,
-)
-
-maven_jar(
-  name = 'aopalliance',
-  id = 'aopalliance:aopalliance:1.0',
-  sha1 = '0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8',
-  license = 'PublicDomain',
-  visibility = ['//lib/guice:guice'],
-)
-
-maven_jar(
-  name = 'javax-inject',
-  id = 'javax.inject:javax.inject:1',
-  sha1 = '6975da39a7040257bd51d21a231b76c915872d38',
-  license = 'Apache2.0',
-  visibility = ['PUBLIC'],
-)
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index acade50..8941346 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -1,39 +1,48 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'guice',
-  exports = [
-    ':guice_library',
-    ':javax-inject',
-  ],
-  visibility = ['//visibility:public'],
+    name = "guice",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":guice-library",
+        ":javax-inject",
+    ],
 )
 
 java_library(
-  name = 'guice_library',
-  exports = ['@guice_library//jar'],
-  runtime_deps = ['aopalliance'],
-  visibility = ['//visibility:public'],
+    name = "guice-library",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@guice-library//jar"],
+    runtime_deps = ["aopalliance"],
 )
 
 java_library(
-  name = 'guice-assistedinject',
-  exports = ['@guice_assistedinject//jar'],
-  runtime_deps = [':guice'],
-  visibility = ['//visibility:public'],
+    name = "guice-assistedinject",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@guice-assistedinject//jar"],
+    runtime_deps = [":guice"],
 )
 
 java_library(
-  name = 'guice-servlet',
-  exports = ['@guice_servlet//jar'],
-  runtime_deps = [':guice'],
-  visibility = ['//visibility:public'],
+    name = "guice-servlet",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@guice-servlet//jar"],
+    runtime_deps = [":guice"],
 )
 
 java_library(
-  name = 'aopalliance',
-  exports = ['@aopalliance//jar'],
+    name = "aopalliance",
+    data = ["//lib:LICENSE-PublicDomain"],
+    exports = ["@aopalliance//jar"],
 )
 
 java_library(
-  name = 'javax-inject',
-  exports = ['@javax_inject//jar'],
+    name = "javax-inject",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@javax_inject//jar"],
 )
diff --git a/lib/gwt/BUCK b/lib/gwt/BUCK
deleted file mode 100644
index 2be89d7..0000000
--- a/lib/gwt/BUCK
+++ /dev/null
@@ -1,82 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VERSION = '2.8.2'
-
-maven_jar(
-  name = 'user',
-  id = 'com.google.gwt:gwt-user:' + VERSION,
-  sha1 = 'a2b9be2c996a658c4e009ba652a9c6a81c88a797',
-  license = 'Apache2.0',
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'dev',
-  id = 'com.google.gwt:gwt-dev:' + VERSION,
-  sha1 = '7a87e060bbf129386b7ae772459fb9f87297c332',
-  license = 'Apache2.0',
-  attach_source = False,
-)
-
-maven_jar(
-  name = 'javax-validation',
-  id = 'javax.validation:validation-api:1.0.0.GA',
-  bin_sha1 = 'b6bd7f9d78f6fdaa3c37dae18a4bd298915f328e',
-  src_sha1 = '7a561191db2203550fbfa40d534d4997624cd369',
-  license = 'Apache2.0',
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'jsinterop-annotations',
-  id = 'com.google.jsinterop:jsinterop-annotations:1.0.0',
-  bin_sha1 = '23c3a3c060ffe4817e67673cc8294e154b0a4a95',
-  src_sha1 = '5d7c478efbfccc191430d7c118d7bd2635e43750',
-  license = 'Apache2.0',
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'ant',
-  id = 'ant:ant:1.6.5',
-  bin_sha1 = '7d18faf23df1a5c3a43613952e0e8a182664564b',
-  src_sha1 = '9e0a847494563f35f9b02846a1c1eb4aa2ee5a9a',
-  license = 'Apache2.0',
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'colt',
-  id = 'colt:colt:1.2.0',
-  attach_source = False,
-  bin_sha1 = '0abc984f3adc760684d49e0f11ddf167ba516d4f',
-  license = 'DO_NOT_DISTRIBUTE',
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'tapestry',
-  id = 'tapestry:tapestry:4.0.2',
-  attach_source = False,
-  bin_sha1 = 'e855a807425d522e958cbce8697f21e9d679b1f7',
-  license = 'Apache2.0',
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'w3c-css-sac',
-  id = 'org.w3c.css:sac:1.3',
-  attach_source = False,
-  bin_sha1 = 'cdb2dcb4e22b83d6b32b93095f644c3462739e82',
-  license = 'DO_NOT_DISTRIBUTE',
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'jsr305',
-  id = 'com.google.code.findbugs:jsr305:3.0.1',
-  sha1 = 'f7be08ec23c21485b9b5a1cf1654c2ec8c58168d',
-  license = 'Apache2.0',
-  attach_source = False,
-  visibility = ['PUBLIC'],
-)
diff --git a/lib/gwt/BUILD b/lib/gwt/BUILD
index 2168bb4..5606647 100644
--- a/lib/gwt/BUILD
+++ b/lib/gwt/BUILD
@@ -1,9 +1,47 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 [java_library(
-  name = n,
-  exports = ['@%s//jar' % n.replace("-", "_")],
-  visibility = ["//visibility:public"],
+    name = n,
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@%s//jar" % n],
 ) for n in [
-  'javax-validation',
-  'dev',
-  'user',
+    "ant",
+    "colt",
+    "dev",
+    "javax-validation",
+    "jsinterop-annotations",
+    "tapestry",
+    "user",
+    "w3c-css-sac",
 ]]
+
+java_library(
+    name = "user-neverlink",
+    data = ["//lib:LICENSE-Apache2.0"],
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = ["@user//jar"],
+)
+
+java_library(
+    name = "dev-neverlink",
+    data = ["//lib:LICENSE-Apache2.0"],
+    neverlink = 1,
+    visibility = ["//visibility:public"],
+    exports = ["@dev//jar"],
+)
+
+java_library(
+    name = "javax-validation_src",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@javax-validation//jar:src"],
+)
+
+java_library(
+    name = "jsinterop-annotations_src",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jsinterop-annotations//jar:src"],
+)
diff --git a/lib/highlightjs/BUCK b/lib/highlightjs/BUCK
deleted file mode 100644
index 9940136..0000000
--- a/lib/highlightjs/BUCK
+++ /dev/null
@@ -1,5 +0,0 @@
-export_file(
-  name = 'highlightjs',
-  src = 'highlight.min.js',
-  visibility = ['PUBLIC'],
-)
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
new file mode 100644
index 0000000..b10bc55
--- /dev/null
+++ b/lib/highlightjs/BUILD
@@ -0,0 +1,3 @@
+exports_files([
+    "highlight.min.js",
+])
diff --git a/lib/highlightjs/building.md b/lib/highlightjs/building.md
index 8cb9e8b..b35592f 100644
--- a/lib/highlightjs/building.md
+++ b/lib/highlightjs/building.md
@@ -30,22 +30,27 @@
           css \
           d \
           dart \
+          erb \
           go \
           haskell \
           java \
           javascript \
           json \
+          kotlin \
           lisp \
           lua \
           markdown \
           objectivec \
           ocaml \
           perl \
+          php \
           protobuf \
+          puppet \
           python \
           ruby \
           rust \
           scala \
+          shell \
           sql \
           swift \
           typescript \
diff --git a/lib/highlightjs/highlight.min.js b/lib/highlightjs/highlight.min.js
index cfc8c1c..069a018 100644
--- a/lib/highlightjs/highlight.min.js
+++ b/lib/highlightjs/highlight.min.js
@@ -1,105 +1,124 @@
-/*! highlight.js v9.5.0 | BSD3 License | git.io/hljslicense */
-(function(b){var p="object"===typeof window&&window||"object"===typeof self&&self;"undefined"!==typeof exports?b(exports):p&&(p.hljs=b({}),"function"===typeof define&&define.amd&&define([],function(){return p.hljs}))})(function(b){function p(a){return a.replace(/[&<>]/gm,function(a){return M[a]})}function C(a,c){var e=a&&a.exec(c);return e&&0===e.index}function v(a,c){var e,b={};for(e in a)b[e]=a[e];if(c)for(e in c)b[e]=c[e];return b}function H(a){var c=[];(function g(a,b){for(var k=a.firstChild;k;k=
-k.nextSibling)3===k.nodeType?b+=k.nodeValue.length:1===k.nodeType&&(c.push({event:"start",offset:b,node:k}),b=g(k,b),k.nodeName.toLowerCase().match(/br|hr|img|input/)||c.push({event:"stop",offset:b,node:k}));return b})(a,0);return c}function N(a,c,e){function b(){return a.length&&c.length?a[0].offset!==c[0].offset?a[0].offset<c[0].offset?a:c:"start"===c[0].event?a:c:a.length?a:c}function d(a){n+="<"+a.nodeName.toLowerCase()+I.map.call(a.attributes,function(a){return" "+a.nodeName+'="'+p(a.value)+
-'"'}).join("")+">"}function f(a){n+="</"+a.nodeName.toLowerCase()+">"}function k(a){("start"===a.event?d:f)(a.node)}for(var l=0,n="",m=[];a.length||c.length;){var h=b(),n=n+p(e.substr(l,h[0].offset-l)),l=h[0].offset;if(h===a){m.reverse().forEach(f);do k(h.splice(0,1)[0]),h=b();while(h===a&&h.length&&h[0].offset===l);m.reverse().forEach(d)}else"start"===h[0].event?m.push(h[0].node):m.pop(),k(h.splice(0,1)[0])}return n+p(e.substr(l))}function O(a){function c(a){return a&&a.source||a}function e(e,b){return new RegExp(c(e),
-"m"+(a.case_insensitive?"i":"")+(b?"g":""))}function b(d,f){if(!d.compiled){d.compiled=!0;d.keywords=d.keywords||d.beginKeywords;if(d.keywords){var k={},l=function(c,e){a.case_insensitive&&(e=e.toLowerCase());e.split(" ").forEach(function(a){a=a.split("|");k[a[0]]=[c,a[1]?Number(a[1]):1]})};"string"===typeof d.keywords?l("keyword",d.keywords):D(d.keywords).forEach(function(a){l(a,d.keywords[a])});d.keywords=k}d.lexemesRe=e(d.lexemes||/\w+/,!0);f&&(d.beginKeywords&&(d.begin="\\b("+d.beginKeywords.split(" ").join("|")+
-")\\b"),d.begin||(d.begin=/\B|\b/),d.beginRe=e(d.begin),d.end||d.endsWithParent||(d.end=/\B|\b/),d.end&&(d.endRe=e(d.end)),d.terminator_end=c(d.end)||"",d.endsWithParent&&f.terminator_end&&(d.terminator_end+=(d.end?"|":"")+f.terminator_end));d.illegal&&(d.illegalRe=e(d.illegal));null==d.relevance&&(d.relevance=1);d.contains||(d.contains=[]);var n=[];d.contains.forEach(function(a){a.variants?a.variants.forEach(function(c){n.push(v(a,c))}):n.push("self"===a?d:a)});d.contains=n;d.contains.forEach(function(a){b(a,
-d)});d.starts&&b(d.starts,f);var m=d.contains.map(function(a){return a.beginKeywords?"\\.?("+a.begin+")\\.?":a.begin}).concat([d.terminator_end,d.illegal]).map(c).filter(Boolean);d.terminators=m.length?e(m.join("|"),!0):{exec:function(){return null}}}}b(a)}function A(a,c,e,b){function d(a,c){if(C(a.endRe,c)){for(;a.endsParent&&a.parent;)a=a.parent;return a}if(a.endsWithParent)return d(a.parent,c)}function f(a,c,e,b){return'<span class="'+(b?"":t.classPrefix)+(a+'">')+c+(e?"":"</span>")}function k(){var a=
-r,c;if(null!=h.subLanguage)if((c="string"===typeof h.subLanguage)&&!w[h.subLanguage])c=p(q);else{var e=c?A(h.subLanguage,q,!0,u[h.subLanguage]):F(q,h.subLanguage.length?h.subLanguage:void 0);0<h.relevance&&(B+=e.relevance);c&&(u[h.subLanguage]=e.top);c=f(e.language,e.value,!1,!0)}else{var b;if(h.keywords){e="";b=0;h.lexemesRe.lastIndex=0;for(c=h.lexemesRe.exec(q);c;){e+=p(q.substr(b,c.index-b));b=h;var d=c,d=m.case_insensitive?d[0].toLowerCase():d[0];(b=b.keywords.hasOwnProperty(d)&&b.keywords[d])?
-(B+=b[1],e+=f(b[0],p(c[0]))):e+=p(c[0]);b=h.lexemesRe.lastIndex;c=h.lexemesRe.exec(q)}c=e+p(q.substr(b))}else c=p(q)}r=a+c;q=""}function l(a){r+=a.className?f(a.className,"",!0):"";h=Object.create(a,{parent:{value:h}})}function n(a,c){q+=a;if(null==c)return k(),0;var b;a:{b=h;var f,g;f=0;for(g=b.contains.length;f<g;f++)if(C(b.contains[f].beginRe,c)){b=b.contains[f];break a}b=void 0}if(b)return b.skip?q+=c:(b.excludeBegin&&(q+=c),k(),b.returnBegin||b.excludeBegin||(q=c)),l(b,c),b.returnBegin?0:c.length;
-if(b=d(h,c)){f=h;f.skip?q+=c:(f.returnEnd||f.excludeEnd||(q+=c),k(),f.excludeEnd&&(q=c));do h.className&&(r+="</span>"),h.skip||(B+=h.relevance),h=h.parent;while(h!==b.parent);b.starts&&l(b.starts,"");return f.returnEnd?0:c.length}if(!e&&C(h.illegalRe,c))throw Error('Illegal lexeme "'+c+'" for mode "'+(h.className||"<unnamed>")+'"');q+=c;return c.length||1}var m=x(a);if(!m)throw Error('Unknown language: "'+a+'"');O(m);var h=b||m,u={},r="";for(b=h;b!==m;b=b.parent)b.className&&(r=f(b.className,"",
-!0)+r);var q="",B=0;try{for(var y,v,z=0;;){h.terminators.lastIndex=z;y=h.terminators.exec(c);if(!y)break;v=n(c.substr(z,y.index-z),y[0]);z=y.index+v}n(c.substr(z));for(b=h;b.parent;b=b.parent)b.className&&(r+="</span>");return{relevance:B,value:r,language:a,top:h}}catch(E){if(E.message&&-1!==E.message.indexOf("Illegal"))return{relevance:0,value:p(c)};throw E;}}function F(a,c){c=c||t.languages||D(w);var b={relevance:0,value:p(a)},g=b;c.filter(x).forEach(function(c){var f=A(c,a,!1);f.language=c;f.relevance>
-g.relevance&&(g=f);f.relevance>b.relevance&&(g=b,b=f)});g.language&&(b.second_best=g);return b}function J(a){return t.tabReplace||t.useBR?a.replace(P,function(a,b){if(t.useBR&&"\n"===a)return"<br>";if(t.tabReplace)return b.replace(/\t/g,t.tabReplace)}):a}function K(a){var c,b,g,d,f;a:if(b=a.className+" ",b+=a.parentNode?a.parentNode.className:"",f=Q.exec(b))f=x(f[1])?f[1]:"no-highlight";else{b=b.split(/\s+/);f=0;for(d=b.length;f<d;f++)if(c=b[f],L.test(c)||x(c)){f=c;break a}f=void 0}L.test(f)||(t.useBR?
-(c=document.createElementNS("http://www.w3.org/1999/xhtml","div"),c.innerHTML=a.innerHTML.replace(/\n/g,"").replace(/<br[ \/]*>/g,"\n")):c=a,d=c.textContent,b=f?A(f,d,!0):F(d),c=H(c),c.length&&(g=document.createElementNS("http://www.w3.org/1999/xhtml","div"),g.innerHTML=b.value,b.value=N(c,H(g),d)),b.value=J(b.value),a.innerHTML=b.value,d=a.className,f=f?G[f]:b.language,c=[d.trim()],d.match(/\bhljs\b/)||c.push("hljs"),-1===d.indexOf(f)&&c.push(f),f=c.join(" ").trim(),a.className=f,a.result={language:b.language,
-re:b.relevance},b.second_best&&(a.second_best={language:b.second_best.language,re:b.second_best.relevance}))}function u(){if(!u.called){u.called=!0;var a=document.querySelectorAll("pre code");I.forEach.call(a,K)}}function x(a){a=(a||"").toLowerCase();return w[a]||w[G[a]]}var I=[],D=Object.keys,w={},G={},L=/^(no-?highlight|plain|text)$/i,Q=/\blang(?:uage)?-([\w-]+)\b/i,P=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,t={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},M={"&":"&amp;","<":"&lt;",">":"&gt;"};
-b.highlight=A;b.highlightAuto=F;b.fixMarkup=J;b.highlightBlock=K;b.configure=function(a){t=v(t,a)};b.initHighlighting=u;b.initHighlightingOnLoad=function(){addEventListener("DOMContentLoaded",u,!1);addEventListener("load",u,!1)};b.registerLanguage=function(a,c){var e=w[a]=c(b);e.aliases&&e.aliases.forEach(function(c){G[c]=a})};b.listLanguages=function(){return D(w)};b.getLanguage=x;b.inherit=v;b.IDENT_RE="[a-zA-Z]\\w*";b.UNDERSCORE_IDENT_RE="[a-zA-Z_]\\w*";b.NUMBER_RE="\\b\\d+(\\.\\d+)?";b.C_NUMBER_RE=
-"(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";b.BINARY_NUMBER_RE="\\b(0b[01]+)";b.RE_STARTERS_RE="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";b.BACKSLASH_ESCAPE={begin:"\\\\[\\s\\S]",relevance:0};b.APOS_STRING_MODE={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.QUOTE_STRING_MODE={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};
-b.PHRASAL_WORDS_MODE={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/};b.COMMENT=function(a,c,e){a=b.inherit({className:"comment",begin:a,end:c,contains:[]},e||{});a.contains.push(b.PHRASAL_WORDS_MODE);a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|XXX):",relevance:0});return a};b.C_LINE_COMMENT_MODE=b.COMMENT("//","$");b.C_BLOCK_COMMENT_MODE=b.COMMENT("/\\*","\\*/");b.HASH_COMMENT_MODE=b.COMMENT("#",
-"$");b.NUMBER_MODE={className:"number",begin:b.NUMBER_RE,relevance:0};b.C_NUMBER_MODE={className:"number",begin:b.C_NUMBER_RE,relevance:0};b.BINARY_NUMBER_MODE={className:"number",begin:b.BINARY_NUMBER_RE,relevance:0};b.CSS_NUMBER_MODE={className:"number",begin:b.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0};b.REGEXP_MODE={className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b.BACKSLASH_ESCAPE,{begin:/\[/,
-end:/\]/,relevance:0,contains:[b.BACKSLASH_ESCAPE]}]};b.TITLE_MODE={className:"title",begin:b.IDENT_RE,relevance:0};b.UNDERSCORE_TITLE_MODE={className:"title",begin:b.UNDERSCORE_IDENT_RE,relevance:0};b.METHOD_GUARD={begin:"\\.\\s*"+b.UNDERSCORE_IDENT_RE,relevance:0};b.registerLanguage("bash",function(a){var c={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},b={className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,c,{className:"variable",begin:/\$\(/,
-end:/\)/,contains:[a.BACKSLASH_ESCAPE]}]};return{aliases:["sh","zsh"],lexemes:/-?[a-z\.]+/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",
-_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},a.HASH_COMMENT_MODE,b,{className:"string",begin:/'/,end:/'/},c]}});b.registerLanguage("clojure",function(a){var c={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",relevance:0},b=a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),g=a.COMMENT(";","$",{relevance:0}),
-d={className:"literal",begin:/\b(true|false|nil)\b/},f={begin:"[\\[\\{]",end:"[\\]\\}]"},k={className:"comment",begin:"\\^[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},l=a.COMMENT("\\^\\{","\\}"),n={className:"symbol",begin:"[:]{1,2}[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},m={begin:"\\(",end:"\\)"},h={endsWithParent:!0,relevance:0},p={keywords:{"builtin-name":"def defonce cond apply if-not if-let if not not= = < > <= >= == + / * - rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit defmacro defn defn- macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy defstruct first rest cons defprotocol cast coll deftype defrecord last butlast sigs reify second ffirst fnext nfirst nnext defmulti defmethod meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"},
-lexemes:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",className:"name",begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",starts:h},r=[m,b,k,l,g,n,f,c,d,{begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",relevance:0}];m.contains=[a.COMMENT("comment",""),p,h];h.contains=r;f.contains=r;return{aliases:["clj"],illegal:/\S/,contains:[m,b,k,l,g,n,f,c,d]}});b.registerLanguage("cpp",function(a){var c={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},b={className:"string",variants:[{begin:'(u8?|U)?L?"',
-end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'(u8?|U)?R"',end:'"',contains:[a.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},g={className:"number",variants:[{begin:"\\b(0b[01'_]+)"},{begin:"\\b([\\d'_]+(\\.[\\d'_]*)?|\\.[\\d'_]+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9'_]+|(\\b[\\d'_]+(\\.[\\d'_]*)?|\\.[\\d'_]+)([eE][-+]?[\\d'_]+)?)"}],relevance:0},d={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"},
-contains:[{begin:/\\\n/,relevance:0},a.inherit(b,{className:"meta-string"}),{className:"meta-string",begin:"<",end:">",illegal:"\\n"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},f=a.IDENT_RE+"\\s*\\(",k={keyword:"int float while private char catch export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const struct for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using class asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignof constexpr decltype noexcept static_assert thread_local restrict _Bool complex _Complex _Imaginary atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return",
+/*
+ highlight.js v9.12.0 | BSD3 License | git.io/hljslicense */
+var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(b,g,l){b!=Array.prototype&&b!=Object.prototype&&(b[g]=l.value)};$jscomp.getGlobal=function(b){return"undefined"!=typeof window&&window===b?b:"undefined"!=typeof global&&null!=global?global:b};$jscomp.global=$jscomp.getGlobal(this);$jscomp.SYMBOL_PREFIX="jscomp_symbol_";
+$jscomp.initSymbol=function(){$jscomp.initSymbol=function(){};$jscomp.global.Symbol||($jscomp.global.Symbol=$jscomp.Symbol)};$jscomp.symbolCounter_=0;$jscomp.Symbol=function(b){return $jscomp.SYMBOL_PREFIX+(b||"")+$jscomp.symbolCounter_++};
+$jscomp.initSymbolIterator=function(){$jscomp.initSymbol();var b=$jscomp.global.Symbol.iterator;b||(b=$jscomp.global.Symbol.iterator=$jscomp.global.Symbol("iterator"));"function"!=typeof Array.prototype[b]&&$jscomp.defineProperty(Array.prototype,b,{configurable:!0,writable:!0,value:function(){return $jscomp.arrayIterator(this)}});$jscomp.initSymbolIterator=function(){}};$jscomp.arrayIterator=function(b){var g=0;return $jscomp.iteratorPrototype(function(){return g<b.length?{done:!1,value:b[g++]}:{done:!0}})};
+$jscomp.iteratorPrototype=function(b){$jscomp.initSymbolIterator();b={next:b};b[$jscomp.global.Symbol.iterator]=function(){return this};return b};$jscomp.iteratorFromArray=function(b,g){$jscomp.initSymbolIterator();b instanceof String&&(b+="");var l=0,k={next:function(){if(l<b.length){var m=l++;return{value:g(m,b[m]),done:!1}}k.next=function(){return{done:!0,value:void 0}};return k.next()}};k[Symbol.iterator]=function(){return k};return k};
+$jscomp.polyfill=function(b,g,l,k){if(g){l=$jscomp.global;b=b.split(".");for(k=0;k<b.length-1;k++){var m=b[k];m in l||(l[m]={});l=l[m]}b=b[b.length-1];k=l[b];g=g(k);g!=k&&null!=g&&$jscomp.defineProperty(l,b,{configurable:!0,writable:!0,value:g})}};$jscomp.polyfill("Array.prototype.keys",function(b){return b?b:function(){return $jscomp.iteratorFromArray(this,function(b){return b})}},"es6","es3");
+(function(b){var g="object"===typeof window&&window||"object"===typeof self&&self;"undefined"!==typeof exports?b(exports):g&&(g.hljs=b({}),"function"===typeof define&&define.amd&&define([],function(){return g.hljs}))})(function(b){function g(a){return a.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}function l(a,f){return(a=a&&a.exec(f))&&0===a.index}function k(a){var f,b={},e=Array.prototype.slice.call(arguments,1);for(f in a)b[f]=a[f];e.forEach(function(a){for(f in a)b[f]=a[f]});
+return b}function m(a){var f=[];(function e(a,b){for(a=a.firstChild;a;a=a.nextSibling)3===a.nodeType?b+=a.nodeValue.length:1===a.nodeType&&(f.push({event:"start",offset:b,node:a}),b=e(a,b),a.nodeName.toLowerCase().match(/br|hr|img|input/)||f.push({event:"stop",offset:b,node:a}));return b})(a,0);return f}function L(a,f,b){function d(){return a.length&&f.length?a[0].offset!==f[0].offset?a[0].offset<f[0].offset?a:f:"start"===f[0].event?a:f:a.length?a:f}function c(a){B+="<"+a.nodeName.toLowerCase()+H.map.call(a.attributes,
+function(a){return" "+a.nodeName+'="'+g(a.value).replace('"',"&quot;")+'"'}).join("")+">"}function q(a){B+="</"+a.nodeName.toLowerCase()+">"}function w(a){("start"===a.event?c:q)(a.node)}for(var r=0,B="",n=[];a.length||f.length;){var h=d();B+=g(b.substring(r,h[0].offset));r=h[0].offset;if(h===a){n.reverse().forEach(q);do w(h.splice(0,1)[0]),h=d();while(h===a&&h.length&&h[0].offset===r);n.reverse().forEach(c)}else"start"===h[0].event?n.push(h[0].node):n.pop(),w(h.splice(0,1)[0])}return B+g(b.substr(r))}
+function M(a){a.variants&&!a.cached_variants&&(a.cached_variants=a.variants.map(function(f){return k(a,{variants:null},f)}));return a.cached_variants||a.endsWithParent&&[k(a)]||[a]}function N(a){function f(a){return a&&a.source||a}function b(b,d){return new RegExp(f(b),"m"+(a.case_insensitive?"i":"")+(d?"g":""))}function e(c,d){if(!c.compiled){c.compiled=!0;c.keywords=c.keywords||c.beginKeywords;if(c.keywords){var q={},g=function(f,b){a.case_insensitive&&(b=b.toLowerCase());b.split(" ").forEach(function(a){a=
+a.split("|");q[a[0]]=[f,a[1]?Number(a[1]):1]})};"string"===typeof c.keywords?g("keyword",c.keywords):x(c.keywords).forEach(function(a){g(a,c.keywords[a])});c.keywords=q}c.lexemesRe=b(c.lexemes||/\w+/,!0);d&&(c.beginKeywords&&(c.begin="\\b("+c.beginKeywords.split(" ").join("|")+")\\b"),c.begin||(c.begin=/\B|\b/),c.beginRe=b(c.begin),c.end||c.endsWithParent||(c.end=/\B|\b/),c.end&&(c.endRe=b(c.end)),c.terminator_end=f(c.end)||"",c.endsWithParent&&d.terminator_end&&(c.terminator_end+=(c.end?"|":"")+
+d.terminator_end));c.illegal&&(c.illegalRe=b(c.illegal));null==c.relevance&&(c.relevance=1);c.contains||(c.contains=[]);c.contains=Array.prototype.concat.apply([],c.contains.map(function(a){return M("self"===a?c:a)}));c.contains.forEach(function(a){e(a,c)});c.starts&&e(c.starts,d);d=c.contains.map(function(a){return a.beginKeywords?"\\.?("+a.begin+")\\.?":a.begin}).concat([c.terminator_end,c.illegal]).map(f).filter(Boolean);c.terminators=d.length?b(d.join("|"),!0):{exec:function(){return null}}}}
+e(a)}function C(a,f,b,e){function c(a,b){if(l(a.endRe,b)){for(;a.endsParent&&a.parent;)a=a.parent;return a}if(a.endsWithParent)return c(a.parent,b)}function d(a,b,f,d){return'<span class="'+(d?"":t.classPrefix)+(a+'">')+b+(f?"":"</span>")}function w(){var a=v,b;if(null!=h.subLanguage)if((b="string"===typeof h.subLanguage)&&!y[h.subLanguage])b=g(p);else{var f=b?C(h.subLanguage,p,!0,m[h.subLanguage]):F(p,h.subLanguage.length?h.subLanguage:void 0);0<h.relevance&&(u+=f.relevance);b&&(m[h.subLanguage]=
+f.top);b=d(f.language,f.value,!1,!0)}else if(h.keywords){f="";var c=0;h.lexemesRe.lastIndex=0;for(b=h.lexemesRe.exec(p);b;){f+=g(p.substring(c,b.index));c=h;var e=b;e=n.case_insensitive?e[0].toLowerCase():e[0];(c=c.keywords.hasOwnProperty(e)&&c.keywords[e])?(u+=c[1],f+=d(c[0],g(b[0]))):f+=g(b[0]);c=h.lexemesRe.lastIndex;b=h.lexemesRe.exec(p)}b=f+g(p.substr(c))}else b=g(p);v=a+b;p=""}function r(a){v+=a.className?d(a.className,"",!0):"";h=Object.create(a,{parent:{value:h}})}function k(a,f){p+=a;if(null==
+f)return w(),0;a:{a=h;var d;var e=0;for(d=a.contains.length;e<d;e++)if(l(a.contains[e].beginRe,f)){a=a.contains[e];break a}a=void 0}if(a)return a.skip?p+=f:(a.excludeBegin&&(p+=f),w(),a.returnBegin||a.excludeBegin||(p=f)),r(a,f),a.returnBegin?0:f.length;if(a=c(h,f)){e=h;e.skip?p+=f:(e.returnEnd||e.excludeEnd||(p+=f),w(),e.excludeEnd&&(p=f));do h.className&&(v+="</span>"),h.skip||(u+=h.relevance),h=h.parent;while(h!==a.parent);a.starts&&r(a.starts,"");return e.returnEnd?0:f.length}if(!b&&l(h.illegalRe,
+f))throw Error('Illegal lexeme "'+f+'" for mode "'+(h.className||"<unnamed>")+'"');p+=f;return f.length||1}var n=z(a);if(!n)throw Error('Unknown language: "'+a+'"');N(n);var h=e||n,m={},v="";for(e=h;e!==n;e=e.parent)e.className&&(v=d(e.className,"",!0)+v);var p="",u=0;try{for(var A,x,D=0;;){h.terminators.lastIndex=D;A=h.terminators.exec(f);if(!A)break;x=k(f.substring(D,A.index),A[0]);D=A.index+x}k(f.substr(D));for(e=h;e.parent;e=e.parent)e.className&&(v+="</span>");return{relevance:u,value:v,language:a,
+top:h}}catch(E){if(E.message&&-1!==E.message.indexOf("Illegal"))return{relevance:0,value:g(f)};throw E;}}function F(a,f){f=f||t.languages||x(y);var b={relevance:0,value:g(a)},e=b;f.filter(z).forEach(function(f){var c=C(f,a,!1);c.language=f;c.relevance>e.relevance&&(e=c);c.relevance>b.relevance&&(e=b,b=c)});e.language&&(b.second_best=e);return b}function I(a){return t.tabReplace||t.useBR?a.replace(O,function(a,b){return t.useBR&&"\n"===a?"<br>":t.tabReplace?b.replace(/\t/g,t.tabReplace):""}):a}function J(a){var b,
+d;a:{var e=a.className+" ";e+=a.parentNode?a.parentNode.className:"";if(d=P.exec(e))d=z(d[1])?d[1]:"no-highlight";else{e=e.split(/\s+/);d=0;for(b=e.length;d<b;d++){var c=e[d];if(K.test(c)||z(c)){d=c;break a}}d=void 0}}if(!K.test(d)){t.useBR?(c=document.createElementNS("http://www.w3.org/1999/xhtml","div"),c.innerHTML=a.innerHTML.replace(/\n/g,"").replace(/<br[ \/]*>/g,"\n")):c=a;b=c.textContent;e=d?C(d,b,!0):F(b);c=m(c);if(c.length){var q=document.createElementNS("http://www.w3.org/1999/xhtml","div");
+q.innerHTML=e.value;e.value=L(c,m(q),b)}e.value=I(e.value);a.innerHTML=e.value;b=a.className;d=d?G[d]:e.language;c=[b.trim()];b.match(/\bhljs\b/)||c.push("hljs");-1===b.indexOf(d)&&c.push(d);d=c.join(" ").trim();a.className=d;a.result={language:e.language,re:e.relevance};e.second_best&&(a.second_best={language:e.second_best.language,re:e.second_best.relevance})}}function u(){if(!u.called){u.called=!0;var a=document.querySelectorAll("pre code");H.forEach.call(a,J)}}function z(a){a=(a||"").toLowerCase();
+return y[a]||y[G[a]]}var H=[],x=Object.keys,y={},G={},K=/^(no-?highlight|plain|text)$/i,P=/\blang(?:uage)?-([\w-]+)\b/i,O=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,t={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};b.highlight=C;b.highlightAuto=F;b.fixMarkup=I;b.highlightBlock=J;b.configure=function(a){t=k(t,a)};b.initHighlighting=u;b.initHighlightingOnLoad=function(){addEventListener("DOMContentLoaded",u,!1);addEventListener("load",u,!1)};b.registerLanguage=function(a,f){f=y[a]=f(b);f.aliases&&
+f.aliases.forEach(function(b){G[b]=a})};b.listLanguages=function(){return x(y)};b.getLanguage=z;b.inherit=k;b.IDENT_RE="[a-zA-Z]\\w*";b.UNDERSCORE_IDENT_RE="[a-zA-Z_]\\w*";b.NUMBER_RE="\\b\\d+(\\.\\d+)?";b.C_NUMBER_RE="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";b.BINARY_NUMBER_RE="\\b(0b[01]+)";b.RE_STARTERS_RE="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";b.BACKSLASH_ESCAPE=
+{begin:"\\\\[\\s\\S]",relevance:0};b.APOS_STRING_MODE={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.QUOTE_STRING_MODE={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[b.BACKSLASH_ESCAPE]};b.PHRASAL_WORDS_MODE={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/};b.COMMENT=function(a,f,d){a=b.inherit({className:"comment",begin:a,end:f,contains:[]},d||{});
+a.contains.push(b.PHRASAL_WORDS_MODE);a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|XXX):",relevance:0});return a};b.C_LINE_COMMENT_MODE=b.COMMENT("//","$");b.C_BLOCK_COMMENT_MODE=b.COMMENT("/\\*","\\*/");b.HASH_COMMENT_MODE=b.COMMENT("#","$");b.NUMBER_MODE={className:"number",begin:b.NUMBER_RE,relevance:0};b.C_NUMBER_MODE={className:"number",begin:b.C_NUMBER_RE,relevance:0};b.BINARY_NUMBER_MODE={className:"number",begin:b.BINARY_NUMBER_RE,relevance:0};b.CSS_NUMBER_MODE={className:"number",
+begin:b.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0};b.REGEXP_MODE={className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0,contains:[b.BACKSLASH_ESCAPE]}]};b.TITLE_MODE={className:"title",begin:b.IDENT_RE,relevance:0};b.UNDERSCORE_TITLE_MODE={className:"title",begin:b.UNDERSCORE_IDENT_RE,relevance:0};b.METHOD_GUARD={begin:"\\.\\s*"+b.UNDERSCORE_IDENT_RE,relevance:0};
+b.registerLanguage("bash",function(a){var b={className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)}/}]},d={className:"string",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE,b,{className:"variable",begin:/\$\(/,end:/\)/,contains:[a.BACKSLASH_ESCAPE]}]};return{aliases:["sh","zsh"],lexemes:/\b-?[a-z\._]+\b/,keywords:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",
+_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[{className:"meta",begin:/^#![^\n]+sh\s*$/,relevance:10},{className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0},a.HASH_COMMENT_MODE,d,{className:"string",begin:/'/,end:/'/},b]}});b.registerLanguage("clojure",function(a){var b={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",relevance:0},d=a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),e=a.COMMENT(";","$",{relevance:0}),
+c={className:"literal",begin:/\b(true|false|nil)\b/},q={begin:"[\\[\\{]",end:"[\\]\\}]"},g={className:"comment",begin:"\\^[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},r=a.COMMENT("\\^\\{","\\}"),k={className:"symbol",begin:"[:]{1,2}[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*"},n={begin:"\\(",end:"\\)"},h={endsWithParent:!0,relevance:0},l={keywords:{"builtin-name":"def defonce cond apply if-not if-let if not not= = < > <= >= == + / * - rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit defmacro defn defn- macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy defstruct first rest cons defprotocol cast coll deftype defrecord last butlast sigs reify second ffirst fnext nfirst nnext defmulti defmethod meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"},
+lexemes:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",className:"name",begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",starts:h},m=[n,d,g,r,e,k,q,b,c,{begin:"[a-zA-Z_\\-!.?+*=<>&#'][a-zA-Z_\\-!.?+*=<>&#'0-9/;:]*",relevance:0}];n.contains=[a.COMMENT("comment",""),l,h];h.contains=m;q.contains=m;r.contains=[q];return{aliases:["clj"],illegal:/\S/,contains:[n,d,g,r,e,k,q,b,c]}});b.registerLanguage("cpp",function(a){var b={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},d={className:"string",
+variants:[{begin:'(u8?|U)?L?"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'(u8?|U)?R"',end:'"',contains:[a.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},e={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"},
+contains:[{begin:/\\\n/,relevance:0},a.inherit(d,{className:"meta-string"}),{className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},q=a.IDENT_RE+"\\s*\\(",g={keyword:"int float while private char catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignof constexpr decltype noexcept static_assert thread_local restrict _Bool complex _Complex _Imaginary atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and or not",
 built_in:"std string cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap array shared_ptr abort abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr",
-literal:"true false nullptr NULL"},l=[c,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,g,b];return{aliases:"c cc h c++ h++ hpp".split(" "),keywords:k,illegal:"</",contains:l.concat([d,{begin:"\\b(deque|list|queue|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",end:">",keywords:k,contains:["self",c]},{begin:a.IDENT_RE+"::",keywords:k},{variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",
-end:/;/}],keywords:k,contains:l.concat([{begin:/\(/,end:/\)/,keywords:k,contains:l.concat(["self"]),relevance:0}]),relevance:0},{className:"function",begin:"("+a.IDENT_RE+"[\\*&\\s]+)+"+f,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:k,illegal:/[^\w\s\*&]/,contains:[{begin:f,returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:k,relevance:0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,g,c]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,
-d]}]),exports:{preprocessor:d,strings:b,keywords:k}}});b.registerLanguage("cs",function(a){var c={keyword:"abstract as base bool break byte case catch char checked const continue decimal dynamic default delegate do double else enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long when object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual volatile void while async nameof ascending descending from get group into join let orderby partial select set value var where yield",
-literal:"null false true"},b={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},g=a.inherit(b,{illegal:/\n/}),d={className:"subst",begin:"{",end:"}",keywords:c},f=a.inherit(d,{illegal:/\n/}),k={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},a.BACKSLASH_ESCAPE,f]},l={className:"string",begin:/\$@"/,end:'"',contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},d]},n=a.inherit(l,{illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},f]});d.contains=
-[l,k,b,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE];f.contains=[n,k,g,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.inherit(a.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];b={variants:[l,k,b,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]};g=a.IDENT_RE+"(<"+a.IDENT_RE+">)?(\\[\\])?";return{aliases:["csharp"],keywords:c,illegal:/::/,contains:[a.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{begin:"\x3c!--|--\x3e"},{begin:"</?",
-end:">"}]}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},b,a.C_NUMBER_MODE,{beginKeywords:"class interface",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.TITLE_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},
-{beginKeywords:"new return throw await",relevance:0},{className:"function",begin:"("+g+"\\s+)+"+a.IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:c,contains:[{begin:a.IDENT_RE+"\\s*\\(",returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:c,relevance:0,contains:[b,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}]}});b.registerLanguage("css",function(a){return{case_insensitive:!0,
-illegal:/[=\/|'\$]/,contains:[a.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"@(font-face|page)",lexemes:"[a-z-]+",keywords:"font-face page"},{begin:"@",end:"[{;]",illegal:/:/,contains:[{className:"keyword",begin:/\w+/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,contains:[a.APOS_STRING_MODE,
-a.QUOTE_STRING_MODE,a.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[a.C_BLOCK_COMMENT_MODE,{begin:/[A-Z\_\.\-]+\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}]},
-a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]}]}]}});b.registerLanguage("d",function(a){var b=a.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{lexemes:a.UNDERSCORE_IDENT_RE,keywords:{keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",
+literal:"true false nullptr NULL"},k=[b,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,e,d];return{aliases:"c cc h c++ h++ hpp".split(" "),keywords:g,illegal:"</",contains:k.concat([c,{begin:"\\b(deque|list|queue|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",end:">",keywords:g,contains:["self",b]},{begin:a.IDENT_RE+"::",keywords:g},{variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",
+end:/;/}],keywords:g,contains:k.concat([{begin:/\(/,end:/\)/,keywords:g,contains:k.concat(["self"]),relevance:0}]),relevance:0},{className:"function",begin:"("+a.IDENT_RE+"[\\*&\\s]+)+"+q,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:g,illegal:/[^\w\s\*&]/,contains:[{begin:q,returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:g,relevance:0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,e,b]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,
+c]},{className:"class",beginKeywords:"class struct",end:/[{;:]/,contains:[{begin:/</,end:/>/,contains:["self"]},a.TITLE_MODE]}]),exports:{preprocessor:c,strings:d,keywords:g}}});b.registerLanguage("cs",function(a){var b={keyword:"abstract as base bool break byte case catch char checked const continue decimal default delegate do double enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long nameof object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual void volatile while add alias ascending async await by descending dynamic equals from get global group into join let on orderby partial remove select set value var where yield",
+literal:"null false true"},d={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},e=a.inherit(d,{illegal:/\n/}),c={className:"subst",begin:"{",end:"}",keywords:b},g=a.inherit(c,{illegal:/\n/}),k={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},a.BACKSLASH_ESCAPE,g]},l={className:"string",begin:/\$@"/,end:'"',contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},c]},m=a.inherit(l,{illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},g]});c.contains=
+[l,k,d,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE];g.contains=[m,k,e,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,a.inherit(a.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];d={variants:[l,k,d,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]};e=a.IDENT_RE+"(<"+a.IDENT_RE+"(\\s*,\\s*"+a.IDENT_RE+")*>)?(\\[\\])?";return{aliases:["csharp"],keywords:b,illegal:/::/,contains:[a.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},
+{begin:"\x3c!--|--\x3e"},{begin:"</?",end:">"}]}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},d,a.C_NUMBER_MODE,{beginKeywords:"class interface",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.TITLE_MODE,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",end:/[{;=]/,illegal:/[^\s:]/,contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),
+a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"meta",begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{className:"meta-string",begin:/"/,end:/"/}]},{beginKeywords:"new return throw await else",relevance:0},{className:"function",begin:"("+e+"\\s+)+"+a.IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:b,contains:[{begin:a.IDENT_RE+"\\s*\\(",returnBegin:!0,contains:[a.TITLE_MODE],relevance:0},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
+keywords:b,relevance:0,contains:[d,a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}]}});b.registerLanguage("css",function(a){return{case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[a.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"@(font-face|page)",
+lexemes:"[a-z-]+",keywords:"font-face page"},{begin:"@",end:"[{;]",illegal:/:/,contains:[{className:"keyword",begin:/\w+/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[a.C_BLOCK_COMMENT_MODE,{begin:/[A-Z\_\.\-]+\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",
+excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]}]},a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]}]}]}});b.registerLanguage("d",function(a){var b=a.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{lexemes:a.UNDERSCORE_IDENT_RE,
+keywords:{keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",
 built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",literal:"false null true"},contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b,{className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},{className:"string",begin:'"',contains:[{begin:"\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",relevance:0}],
 end:'"[cwd]?'},{className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},{className:"number",begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))(i|[fF]i|Li))",
 relevance:0},{className:"number",begin:"\\b((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))(L|u|U|Lu|LU|uL|UL)?",relevance:0},{className:"string",begin:"'(\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};|.)",end:"'",illegal:"."},{className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",begin:"#(line)",end:"$",relevance:5},{className:"keyword",begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}});b.registerLanguage("markdown",
 function(a){return{aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$"},{begin:"^.+?\\n[=-]{2,}$"}]},{begin:"<",end:">",subLanguage:"xml",relevance:0},{className:"bullet",begin:"^([*+-]|(\\d+\\.))\\s+"},{className:"strong",begin:"[*_]{2}.+?[*_]{2}"},{className:"emphasis",variants:[{begin:"\\*.+?\\*"},{begin:"_.+?_",relevance:0}]},{className:"quote",begin:"^>\\s+",end:"$"},{className:"code",variants:[{begin:"^```w*s*$",end:"^```s*$"},{begin:"`.+?`"},{begin:"^( {4}|\t)",
 end:"$",relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},{begin:"\\[.+?\\][\\(\\[].*?[\\)\\]]",returnBegin:!0,contains:[{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0,relevance:0},{className:"link",begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}],relevance:10},{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",
-begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}});b.registerLanguage("dart",function(a){var b={className:"subst",begin:"\\$\\{",end:"}",keywords:"true false null this is new super"},e={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"},{begin:"'''",end:"'''",contains:[a.BACKSLASH_ESCAPE,b]},{begin:'"""',end:'"""',contains:[a.BACKSLASH_ESCAPE,b]},{begin:"'",end:"'",illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,
-b]},{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]}]};b.contains=[a.C_NUMBER_MODE,e];return{keywords:{keyword:"assert async await break case catch class const continue default do else enum extends false final finally for if in is new null rethrow return super switch sync this throw true try var void while with yield abstract as dynamic export external factory get implements import library operator part set static typedef",built_in:"print Comparable DateTime Duration Function Iterable Iterator List Map Match Null Object Pattern RegExp Set Stopwatch String StringBuffer StringSink Symbol Type Uri bool double int num document window querySelector querySelectorAll Element ElementList"},
-contains:[e,a.COMMENT("/\\*\\*","\\*/",{subLanguage:"markdown"}),a.COMMENT("///","$",{subLanguage:"markdown"}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}});b.registerLanguage("go",function(a){var b={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
+begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}});b.registerLanguage("dart",function(a){var b={className:"subst",begin:"\\$\\{",end:"}",keywords:"true false null this is new super"},d={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"},{begin:"'''",end:"'''",contains:[a.BACKSLASH_ESCAPE,b]},{begin:'"""',end:'"""',contains:[a.BACKSLASH_ESCAPE,b]},{begin:"'",end:"'",illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,
+b]},{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]}]};b.contains=[a.C_NUMBER_MODE,d];return{keywords:{keyword:"assert async await break case catch class const continue default do else enum extends false final finally for if in is new null rethrow return super switch sync this throw true try var void while with yield abstract as dynamic export external factory get implements import library operator part set static typedef",built_in:"print Comparable DateTime Duration Function Iterable Iterator List Map Match Null Object Pattern RegExp Set Stopwatch String StringBuffer StringSink Symbol Type Uri bool double int num document window querySelector querySelectorAll Element ElementList"},
+contains:[d,a.COMMENT("/\\*\\*","\\*/",{subLanguage:"markdown"}),a.COMMENT("///","$",{subLanguage:"markdown"}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}});b.registerLanguage("xml",function(a){var b={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",
+relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]};return{aliases:"html xhtml rss atom xjb xsd xsl plist".split(" "),case_insensitive:!0,contains:[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},a.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{begin:/<\?(php)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",
+end:"\\*/",skip:!0}]},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",keywords:{name:"style"},contains:[b],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[b],starts:{end:"\x3c/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"meta",variants:[{begin:/<\?xml/,end:/\?>/,relevance:10},{begin:/<\?\w+/,end:/\?>/}]},{className:"tag",begin:"</?",end:"/?>",
+contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},b]}]}});b.registerLanguage("ruby",function(a){var b={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},d={className:"doctag",begin:"@[A-Za-z]+"},e={begin:"#<",end:">"};d=[a.COMMENT("#","$",{contains:[d]}),a.COMMENT("^\\=begin",
+"^\\=end",{contains:[d],relevance:10}),a.COMMENT("^__END__","\\n$")];var c={className:"subst",begin:"#\\{",end:"}",keywords:b},g={className:"string",contains:[a.BACKSLASH_ESCAPE,c],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},
+{begin:/<<(-?)\w+$/,end:/^\s*\w+$/}]},k={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:b};a=[g,e,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+a.IDENT_RE+"::)?"+a.IDENT_RE}]}].concat(d)},{className:"function",beginKeywords:"def",end:"$|;",contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}),
+k].concat(d)},{begin:a.IDENT_RE+"::"},{className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[g,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}],relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:b},{begin:"("+a.RE_STARTERS_RE+
+"|unless)\\s*",keywords:"unless",contains:[e,{className:"regexp",contains:[a.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(d),relevance:0}].concat(d);c.contains=a;k.contains=a;return{aliases:["rb","gemspec","podspec","thor","irb"],keywords:b,illegal:/\/\*/,contains:d.concat([{begin:/^\s*=>/,starts:{end:"$",contains:a}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",
+starts:{end:"$",contains:a}}]).concat(a)}});b.registerLanguage("erb",function(a){return{subLanguage:"xml",contains:[a.COMMENT("<%#","%>"),{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0}]}});b.registerLanguage("go",function(a){var b={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
 literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{aliases:["golang"],keywords:b,illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[a.QUOTE_STRING_MODE,{begin:"'",end:"[^\\\\]'"},{begin:"`",end:"`"}]},{className:"number",variants:[{begin:a.C_NUMBER_RE+"[dflsi]",relevance:1},a.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",end:/\s*\{/,excludeEnd:!0,
-contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:b,illegal:/["']/}]}]}});b.registerLanguage("haskell",function(a){var b={variants:[a.COMMENT("--","$"),a.COMMENT("{-","-}",{contains:["self"]})]},e={className:"meta",begin:"{-#",end:"#-}"},g={className:"meta",begin:"^#",end:"$"},d={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},f={begin:"\\(",end:"\\)",illegal:'"',contains:[e,g,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},a.inherit(a.TITLE_MODE,{begin:"[_a-z][\\w']*"}),
-b]};return{aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[f,b],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[f,b],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",
-end:"where",keywords:"class family instance where",contains:[d,f,b]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[e,d,f,{begin:"{",end:"}",contains:f.contains},b]},{beginKeywords:"default",end:"$",contains:[d,f,b]},{beginKeywords:"infix infixl infixr",end:"$",contains:[a.C_NUMBER_MODE,b]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[d,a.QUOTE_STRING_MODE,
-b]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},e,g,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,d,a.inherit(a.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),b,{begin:"->|<-"}]}});b.registerLanguage("java",function(a){var b=a.UNDERSCORE_IDENT_RE+"(<"+a.UNDERSCORE_IDENT_RE+"(\\s*,\\s*"+a.UNDERSCORE_IDENT_RE+")*>)?";return{aliases:["jsp"],keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports",
-illegal:/<\/|#/,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"new throw return else",relevance:0},{className:"function",begin:"("+
-b+"\\s+)+"+a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports",contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,
-relevance:0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports",relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,
-a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0},{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("javascript",function(a){return{aliases:["js","jsx"],keywords:{keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",
+contains:[a.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:b,illegal:/["']/}]}]}});b.registerLanguage("haskell",function(a){var b={variants:[a.COMMENT("--","$"),a.COMMENT("{-","-}",{contains:["self"]})]},d={className:"meta",begin:"{-#",end:"#-}"},e={className:"meta",begin:"^#",end:"$"},c={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},g={begin:"\\(",end:"\\)",illegal:'"',contains:[d,e,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},a.inherit(a.TITLE_MODE,{begin:"[_a-z][\\w']*"}),
+b]};return{aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[g,b],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[g,b],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",
+end:"where",keywords:"class family instance where",contains:[c,g,b]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[d,c,g,{begin:"{",end:"}",contains:g.contains},b]},{beginKeywords:"default",end:"$",contains:[c,g,b]},{beginKeywords:"infix infixl infixr",end:"$",contains:[a.C_NUMBER_MODE,b]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[c,a.QUOTE_STRING_MODE,
+b]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},d,e,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,c,a.inherit(a.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),b,{begin:"->|<-"}]}});b.registerLanguage("java",function(a){return{aliases:["jsp"],keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",
+illegal:/<\/|#/,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"new throw return else",relevance:0},{className:"function",begin:"([\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*(<[\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*(\\s*,\\s*[\u00c0-\u02b8a-zA-Z_$][\u00c0-\u02b8a-zA-Z_$0-9]*)*>)?\\s+)+"+
+a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
+contains:[a.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:"false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",relevance:0,contains:[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,a.C_NUMBER_MODE,
+a.C_BLOCK_COMMENT_MODE]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]},{className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0},{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("javascript",function(a){var b={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",
 literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},
-contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},{className:"meta",begin:/^#!/,end:/$/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",
-contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{begin:/</,end:/(\/\w+|\w+\/)>/,subLanguage:"xml",contains:[{begin:/<\w+\s*\/>/,skip:!0},{begin:/<\w+/,end:/(\/\w+|\w+\/)>/,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}],
-illegal:/\[|%/},{begin:/\$[(.]/},a.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0}],illegal:/#(?!!)/}});b.registerLanguage("json",function(a){var b={literal:"true false null"},e=[a.QUOTE_STRING_MODE,a.C_NUMBER_MODE],g={end:",",endsWithParent:!0,excludeEnd:!0,contains:e,keywords:b},d={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,
-end:/"/,contains:[a.BACKSLASH_ESCAPE],illegal:"\\n"},a.inherit(g,{begin:/:/})],illegal:"\\S"};a={begin:"\\[",end:"\\]",contains:[a.inherit(g)],illegal:"\\S"};e.splice(e.length,0,d,a);return{contains:e,keywords:b,illegal:"\\S"}});b.registerLanguage("lisp",function(a){var b={className:"literal",begin:"\\b(t{1}|nil)\\b"},e={className:"number",variants:[{begin:"(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",relevance:0},{begin:"#(b|B)[0-1]+(/[0-1]+)?"},{begin:"#(o|O)[0-7]+(/[0-7]+)?"},
-{begin:"#(x|X)[0-9a-fA-F]+(/[0-9a-fA-F]+)?"},{begin:"#(c|C)\\((\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)? +(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",end:"\\)"}]},g=a.inherit(a.QUOTE_STRING_MODE,{illegal:null});a=a.COMMENT(";","$",{relevance:0});var d={begin:"\\*",end:"\\*"},f={className:"symbol",begin:"[:&][a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},k={begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*",
-relevance:0},l={contains:[e,g,d,f,{begin:"\\(",end:"\\)",contains:["self",b,g,e,k]},k],variants:[{begin:"['`]\\(",end:"\\)"},{begin:"\\(quote ",end:"\\)",keywords:{name:"quote"}},{begin:"'\\|[^]*?\\|"}]},n={variants:[{begin:"'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"#'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*(::[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*)*"}]},m={begin:"\\(\\s*",end:"\\)"},
-h={endsWithParent:!0,relevance:0};m.contains=[{className:"name",variants:[{begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"\\|[^]*?\\|"}]},h];h.contains=[l,n,m,b,e,g,a,d,f,{begin:"\\|[^]*?\\|"},k];return{illegal:/\S/,contains:[e,{className:"meta",begin:"^#!",end:"$"},b,g,a,l,n,m,k]}});b.registerLanguage("lua",function(a){var b={begin:"\\[=*\\[",end:"\\]=*\\]",contains:["self"]},e=[a.COMMENT("--(?!\\[=*\\[)","$"),a.COMMENT("--\\[=*\\[","\\]=*\\]",{contains:[b],
-relevance:10})];return{lexemes:a.UNDERSCORE_IDENT_RE,keywords:{keyword:"and break do else elseif end false for if in local nil not or repeat return then true until while",built_in:"_G _VERSION assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall coroutine debug io math os package string table"},contains:e.concat([{className:"function",beginKeywords:"function",
-end:"\\)",contains:[a.inherit(a.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:e}].concat(e)},a.C_NUMBER_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"\\[=*\\[",end:"\\]=*\\]",contains:[b],relevance:5}])}});b.registerLanguage("xml",function(a){var b={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",
-endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]};return{aliases:"html xhtml rss atom xjb xsd xsl plist".split(" "),case_insensitive:!0,contains:[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},a.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{begin:/<\?(php)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0}]},{className:"tag",begin:"<style(?=\\s|>|$)",
-end:">",keywords:{name:"style"},contains:[b],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[b],starts:{end:"\x3c/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"meta",variants:[{begin:/<\?xml/,end:/\?>/,relevance:10},{begin:/<\?\w+/,end:/\?>/}]},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},b]}]}});
-b.registerLanguage("objectivec",function(a){var b=/[a-zA-Z@][a-zA-Z0-9_]*/;return{aliases:["mm","objc","obj-c"],keywords:{keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",
+d={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},e={className:"subst",begin:"\\$\\{",end:"\\}",keywords:b,contains:[]},c={className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,e]};e.contains=[a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,c,d,a.REGEXP_MODE];e=e.contains.concat([a.C_BLOCK_COMMENT_MODE,a.C_LINE_COMMENT_MODE]);return{aliases:["js","jsx"],keywords:b,contains:[{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},
+{className:"meta",begin:/^#!/,end:/$/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,c,a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,d,{begin:/[{,]\s*/,relevance:0,contains:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*\\s*:",returnBegin:!0,relevance:0,contains:[{className:"attr",begin:"[A-Za-z$_][0-9A-Za-z$_]*",relevance:0}]}]},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|[A-Za-z$_][0-9A-Za-z$_]*)\\s*=>",
+returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:"[A-Za-z$_][0-9A-Za-z$_]*"},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:e}]}]},{begin:/</,end:/(\/\w+|\w+\/)>/,subLanguage:"xml",contains:[{begin:/<\w+\s*\/>/,skip:!0},{begin:/<\w+/,end:/(\/\w+|\w+\/)>/,skip:!0,contains:[{begin:/<\w+\s*\/>/,skip:!0},"self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),
+{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:e}],illegal:/\[|%/},{begin:/\$[(.]/},a.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0}],illegal:/#(?!!)/}});b.registerLanguage("json",function(a){var b={literal:"true false null"},d=[a.QUOTE_STRING_MODE,a.C_NUMBER_MODE],e={end:",",endsWithParent:!0,excludeEnd:!0,
+contains:d,keywords:b},c={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[a.BACKSLASH_ESCAPE],illegal:"\\n"},a.inherit(e,{begin:/:/})],illegal:"\\S"};a={begin:"\\[",end:"\\]",contains:[a.inherit(e)],illegal:"\\S"};d.splice(d.length,0,c,a);return{contains:d,keywords:b,illegal:"\\S"}});b.registerLanguage("kotlin",function(a){var b={keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit initinterface annotation data sealed internal infix operator out by constructor super trait volatile transient native default",
+built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",literal:"true false null"},d={className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"@"},e={className:"subst",begin:"\\${",end:"}",contains:[a.APOS_STRING_MODE,a.C_NUMBER_MODE]},c={className:"variable",begin:"\\$"+a.UNDERSCORE_IDENT_RE};e={className:"string",variants:[{begin:'"""',end:'"""',contains:[c,e]},{begin:"'",end:"'",illegal:/\n/,contains:[a.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,contains:[a.BACKSLASH_ESCAPE,c,
+e]}]};c={className:"meta",begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+a.UNDERSCORE_IDENT_RE+")?"};var g={className:"meta",begin:"@"+a.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,end:/\)/,contains:[a.inherit(e,{className:"meta-string"})]}]};return{keywords:b,contains:[a.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"keyword",begin:/\b(break|continue|return|this)\b/,
+starts:{contains:[{className:"symbol",begin:/@\w+/}]}},d,c,g,{className:"function",beginKeywords:"fun",end:"[(]|$",returnBegin:!0,excludeEnd:!0,keywords:b,illegal:/fun\s+(<.*>)?[^\s\(]+(\s+[^\s\(]+)\s*=/,relevance:5,contains:[{begin:a.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"type",begin:/</,end:/>/,keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:b,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,
+endsWithParent:!0,contains:[{className:"type",begin:a.UNDERSCORE_IDENT_RE},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],relevance:0},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,c,g,e,a.C_NUMBER_MODE]},a.C_BLOCK_COMMENT_MODE]},{className:"class",beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,illegal:"extends implements",contains:[{beginKeywords:"public protected internal private constructor"},a.UNDERSCORE_TITLE_MODE,{className:"type",begin:/</,end:/>/,excludeBegin:!0,excludeEnd:!0,
+relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/,excludeBegin:!0,returnEnd:!0},c,g]},e,{className:"meta",begin:"^#!/usr/bin/env",end:"$",illegal:"\n"},a.C_NUMBER_MODE]}});b.registerLanguage("lisp",function(a){var b={className:"literal",begin:"\\b(t{1}|nil)\\b"},d={className:"number",variants:[{begin:"(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",relevance:0},{begin:"#(b|B)[0-1]+(/[0-1]+)?"},{begin:"#(o|O)[0-7]+(/[0-7]+)?"},{begin:"#(x|X)[0-9a-fA-F]+(/[0-9a-fA-F]+)?"},
+{begin:"#(c|C)\\((\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)? +(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|\\-)?\\d+)?",end:"\\)"}]},e=a.inherit(a.QUOTE_STRING_MODE,{illegal:null});a=a.COMMENT(";","$",{relevance:0});var c={begin:"\\*",end:"\\*"},g={className:"symbol",begin:"[:&][a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},k={begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*",relevance:0},
+l={contains:[d,e,c,g,{begin:"\\(",end:"\\)",contains:["self",b,e,d,k]},k],variants:[{begin:"['`]\\(",end:"\\)"},{begin:"\\(quote ",end:"\\)",keywords:{name:"quote"}},{begin:"'\\|[^]*?\\|"}]},m={variants:[{begin:"'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"#'[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*(::[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*)*"}]},n={begin:"\\(\\s*",end:"\\)"},h={endsWithParent:!0,
+relevance:0};n.contains=[{className:"name",variants:[{begin:"[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#!]*"},{begin:"\\|[^]*?\\|"}]},h];h.contains=[l,m,n,b,d,e,a,c,g,{begin:"\\|[^]*?\\|"},k];return{illegal:/\S/,contains:[d,{className:"meta",begin:"^#!",end:"$"},b,e,a,l,m,n,k]}});b.registerLanguage("lua",function(a){var b={begin:"\\[=*\\[",end:"\\]=*\\]",contains:["self"]},d=[a.COMMENT("--(?!\\[=*\\[)","$"),a.COMMENT("--\\[=*\\[","\\]=*\\]",{contains:[b],relevance:10})];
+return{lexemes:a.UNDERSCORE_IDENT_RE,keywords:{literal:"true false nil",keyword:"and break do else elseif end for goto if in local not or repeat return then until while",built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstringmodule next pairs pcall print rawequal rawget rawset require select setfenvsetmetatable tonumber tostring type unpack xpcall arg selfcoroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"},
+contains:d.concat([{className:"function",beginKeywords:"function",end:"\\)",contains:[a.inherit(a.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:d}].concat(d)},a.C_NUMBER_MODE,a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"\\[=*\\[",end:"\\]=*\\]",contains:[b],relevance:5}])}});b.registerLanguage("objectivec",function(a){var b=/[a-zA-Z@][a-zA-Z0-9_]*/;return{aliases:["mm","objc","obj-c"],keywords:{keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",
 literal:"false true FALSE TRUE nil YES NO NULL",built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},lexemes:b,illegal:"</",contains:[{className:"built_in",begin:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+"},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.C_NUMBER_MODE,a.QUOTE_STRING_MODE,{className:"string",variants:[{begin:'@"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"}]},
 {className:"meta",begin:"#",end:"$",contains:[{className:"meta-string",variants:[{begin:'"',end:'"'},{begin:"<",end:">"}]}]},{className:"class",begin:"(@interface|@class|@protocol|@implementation)\\b",end:"({|$)",excludeEnd:!0,keywords:"@interface @class @protocol @implementation",lexemes:b,contains:[a.UNDERSCORE_TITLE_MODE]},{begin:"\\."+a.UNDERSCORE_IDENT_RE,relevance:0}]}});b.registerLanguage("ocaml",function(a){return{aliases:["ml"],keywords:{keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value",
 built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref",literal:"true false"},illegal:/\/\/|>>/,lexemes:"[a-z_]\\w*!?",contains:[{className:"literal",begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},a.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0},
 a.inherit(a.APOS_STRING_MODE,{className:"string",relevance:0}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null}),{className:"number",begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",relevance:0},{begin:/[-=]>/}]}});b.registerLanguage("perl",function(a){var b={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when"},
-e={begin:"->{",end:"}"},g={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},d=[a.BACKSLASH_ESCAPE,b,g];a=[g,a.HASH_COMMENT_MODE,a.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),e,{className:"string",contains:d,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",
+d={begin:"->{",end:"}"},e={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},c=[a.BACKSLASH_ESCAPE,b,e];a=[e,a.HASH_COMMENT_MODE,a.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),d,{className:"string",contains:c,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",
 end:"\\>",relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},{begin:"{\\w+}",contains:[],relevance:0},{begin:"-?\\w+\\s*\\=\\>",contains:[],relevance:0}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\/\\/|"+a.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",keywords:"split return print reverse grep",
 relevance:0,contains:[a.HASH_COMMENT_MODE,{className:"regexp",begin:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",relevance:10},{className:"regexp",begin:"(m|qr)?/",end:"/[a-z]*",contains:[a.BACKSLASH_ESCAPE],relevance:0}]},{className:"function",beginKeywords:"sub",end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[a.TITLE_MODE]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]}];b.contains=
-a;e.contains=a;return{aliases:["pl","pm"],lexemes:/[\w\.]+/,keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",
-contains:a}});b.registerLanguage("php",function(a){var b={begin:"\\$+[a-zA-Z_\u007f-\u00ff][a-zA-Z0-9_\u007f-\u00ff]*"},e={className:"meta",begin:/<\?(php)?|\?>/},g={className:"string",contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},d={variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]};return{aliases:["php3","php4","php5","php6"],case_insensitive:!0,keywords:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",
-contains:[a.HASH_COMMENT_MODE,a.COMMENT("//","$",{contains:[e]}),a.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler",lexemes:a.UNDERSCORE_IDENT_RE}),{className:"string",begin:/<<<['"]?\w+['"]?$/,end:/^\w+;?$/,contains:[a.BACKSLASH_ESCAPE,{className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]}]},e,{className:"keyword",begin:/\$this\b/},b,{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},
-{className:"function",beginKeywords:"function",end:/[;{]/,excludeEnd:!0,illegal:"\\$|\\[|%",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:["self",b,a.C_BLOCK_COMMENT_MODE,g,d]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[a.UNDERSCORE_TITLE_MODE]},
-{begin:"=>"},g,d]}});b.registerLanguage("protobuf",function(a){return{keywords:{keyword:"package import option optional required repeated group",built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes",literal:"true false"},contains:[a.QUOTE_STRING_MODE,a.NUMBER_MODE,a.C_LINE_COMMENT_MODE,{className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},
-{className:"function",beginKeywords:"rpc",end:/;/,excludeEnd:!0,keywords:"rpc returns"},{begin:/^\s*[A-Z_]+/,end:/\s*=/,excludeEnd:!0}]}});b.registerLanguage("python",function(a){var b={className:"meta",begin:/^(>>>|\.\.\.) /},e={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[b],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[b],relevance:10},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,
-end:/'/},{begin:/(b|br)"/,end:/"/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},g={className:"number",relevance:0,variants:[{begin:a.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:a.C_NUMBER_RE+"[lLjJ]?"}]};return{aliases:["py","gyp"],keywords:{keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},
-illegal:/(<\/|->|\?)/,contains:[b,g,e,a.HASH_COMMENT_MODE,{variants:[{className:"function",beginKeywords:"def",relevance:10},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,contains:["self",b,g,e]},{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}});b.registerLanguage("ruby",function(a){var b={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",
-literal:"true false nil"},e={className:"doctag",begin:"@[A-Za-z]+"},g={begin:"#<",end:">"},e=[a.COMMENT("#","$",{contains:[e]}),a.COMMENT("^\\=begin","^\\=end",{contains:[e],relevance:10}),a.COMMENT("^__END__","\\n$")],d={className:"subst",begin:"#\\{",end:"}",keywords:b},f={className:"string",contains:[a.BACKSLASH_ESCAPE,d],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",
-end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/}]},k={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:b};a=[f,g,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[a.inherit(a.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+a.IDENT_RE+"::)?"+a.IDENT_RE}]}].concat(e)},
-{className:"function",beginKeywords:"def",end:"$|;",contains:[a.inherit(a.TITLE_MODE,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}),k].concat(e)},{begin:a.IDENT_RE+"::"},{className:"symbol",begin:a.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[f,{begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"}],relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",
-relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:b},{begin:"("+a.RE_STARTERS_RE+")\\s*",contains:[g,{className:"regexp",contains:[a.BACKSLASH_ESCAPE,d],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(e),relevance:0}].concat(e);d.contains=a;k.contains=a;return{aliases:["rb","gemspec","podspec","thor","irb"],keywords:b,
-illegal:/\/\*/,contains:e.concat([{begin:/^\s*=>/,starts:{end:"$",contains:a}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",starts:{end:"$",contains:a}}]).concat(a)}});b.registerLanguage("rust",function(a){var b=a.inherit(a.C_BLOCK_COMMENT_MODE);b.contains.push("self");return{aliases:["rs"],keywords:{keyword:"alignof as be box break const continue crate do else enum extern false fn for if impl in let loop match mod mut offsetof once priv proc pub pure ref return self Self sizeof static struct super trait true type typeof unsafe unsized use virtual while where yield move default int i8 i16 i32 i64 isize uint u8 u32 u64 usize float f32 f64 str char bool",
-literal:"true false Some None Ok Err",built_in:"Copy Send Sized Sync Drop Fn FnMut FnOnce drop Box ToOwned Clone PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator Option Result SliceConcatExt String ToString Vec assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules!"},
-lexemes:a.IDENT_RE+"!?",illegal:"</",contains:[a.C_LINE_COMMENT_MODE,b,a.inherit(a.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{className:"string",variants:[{begin:/r(#*)".*?"\1(?!#)/},{begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol",begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{begin:"\\b0b([01_]+)([uif](8|16|32|64|size))?"},{begin:"\\b0o([0-7_]+)([uif](8|16|32|64|size))?"},{begin:"\\b0x([A-Fa-f0-9_]+)([uif](8|16|32|64|size))?"},{begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)([uif](8|16|32|64|size))?"}],
-relevance:0},{className:"function",beginKeywords:"fn",end:"(\\(|<)",excludeEnd:!0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"#\\!?\\[",end:"\\]",contains:[{className:"meta-string",begin:/"/,end:/"/}]},{className:"class",beginKeywords:"type",end:";",contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"\\S"},{className:"class",beginKeywords:"trait enum struct",end:"{",contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]"},{begin:a.IDENT_RE+
-"::",keywords:{built_in:"Copy Send Sized Sync Drop Fn FnMut FnOnce drop Box ToOwned Clone PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator Option Result SliceConcatExt String ToString Vec assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules!"}},
-{begin:"->"}]}});b.registerLanguage("scala",function(a){var b={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},e={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},g={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0};return{keywords:{literal:"true false null",keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},
-contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[b],relevance:10}]},{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},e,{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[g]},{className:"class",beginKeywords:"class object trait type",
-end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[e]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[e]},g]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("sql",function(a){var b=a.COMMENT("--","$");return{case_insensitive:!0,illegal:/[<>{}*#]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke",
-end:/;/,endsWithParent:!0,lexemes:/[\w\.]+/,keywords:{keyword:"abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias allocate allow alter always analyze ancillary and any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second section securefile security seed segment select self sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
+a;d.contains=a;return{aliases:["pl","pm"],lexemes:/[\w\.]+/,keywords:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",
+contains:a}});b.registerLanguage("php",function(a){var b={begin:"\\$+[a-zA-Z_\u007f-\u00ff][a-zA-Z0-9_\u007f-\u00ff]*"},d={className:"meta",begin:/<\?(php)?|\?>/},e={className:"string",contains:[a.BACKSLASH_ESCAPE,d],variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},a.inherit(a.APOS_STRING_MODE,{illegal:null}),a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},c={variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]};return{aliases:["php3","php4","php5","php6"],case_insensitive:!0,keywords:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",
+contains:[a.HASH_COMMENT_MODE,a.COMMENT("//","$",{contains:[d]}),a.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),a.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler",lexemes:a.UNDERSCORE_IDENT_RE}),{className:"string",begin:/<<<['"]?\w+['"]?$/,end:/^\w+;?$/,contains:[a.BACKSLASH_ESCAPE,{className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]}]},d,{className:"keyword",begin:/\$this\b/},b,{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},
+{className:"function",beginKeywords:"function",end:/[;{]/,excludeEnd:!0,illegal:"\\$|\\[|%",contains:[a.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",contains:["self",b,a.C_BLOCK_COMMENT_MODE,e,c]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[a.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[a.UNDERSCORE_TITLE_MODE]},
+{begin:"=>"},e,c]}});b.registerLanguage("protobuf",function(a){return{keywords:{keyword:"package import option optional required repeated group",built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes",literal:"true false"},contains:[a.QUOTE_STRING_MODE,a.NUMBER_MODE,a.C_LINE_COMMENT_MODE,{className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/,contains:[a.inherit(a.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},
+{className:"function",beginKeywords:"rpc",end:/;/,excludeEnd:!0,keywords:"rpc returns"},{begin:/^\s*[A-Z_]+/,end:/\s*=/,excludeEnd:!0}]}});b.registerLanguage("puppet",function(a){var b=a.COMMENT("#","$"),d=a.inherit(a.TITLE_MODE,{begin:"([A-Za-z_]|::)(\\w|::)*"}),e={className:"variable",begin:"\\$([A-Za-z_]|::)(\\w|::)*"},c={className:"string",contains:[a.BACKSLASH_ESCAPE,e],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/}]};return{aliases:["pp"],contains:[b,e,c,{beginKeywords:"class",end:"\\{|;",
+illegal:/=/,contains:[d,b]},{beginKeywords:"define",end:/\{/,contains:[{className:"section",begin:a.IDENT_RE,endsParent:!0}]},{begin:a.IDENT_RE+"\\s+\\{",returnBegin:!0,end:/\S/,contains:[{className:"keyword",begin:a.IDENT_RE},{begin:/\{/,end:/\}/,keywords:{keyword:"and case default else elsif false if in import enherits node or true undef unless main settings $string ",literal:"alias audit before loglevel noop require subscribe tag owner ensure group mode name|0 changes context force incl lens load_path onlyif provider returns root show_diff type_check en_address ip_address realname command environment hour monute month monthday special target weekday creates cwd ogoutput refresh refreshonly tries try_sleep umask backup checksum content ctime force ignore links mtime purge recurse recurselimit replace selinux_ignore_defaults selrange selrole seltype seluser source souirce_permissions sourceselect validate_cmd validate_replacement allowdupe attribute_membership auth_membership forcelocal gid ia_load_module members system host_aliases ip allowed_trunk_vlans description device_url duplex encapsulation etherchannel native_vlan speed principals allow_root auth_class auth_type authenticate_user k_of_n mechanisms rule session_owner shared options device fstype enable hasrestart directory present absent link atboot blockdevice device dump pass remounts poller_tag use message withpath adminfile allow_virtual allowcdrom category configfiles flavor install_options instance package_settings platform responsefile status uninstall_options vendor unless_system_user unless_uid binary control flags hasstatus manifest pattern restart running start stop allowdupe auths expiry gid groups home iterations key_membership keys managehome membership password password_max_age password_min_age profile_membership profiles project purge_ssh_keys role_membership roles salt shell uid baseurl cost descr enabled enablegroups exclude failovermethod gpgcheck gpgkey http_caching include includepkgs keepalive metadata_expire metalink mirrorlist priority protect proxy proxy_password proxy_username repo_gpgcheck s3_enabled skip_if_unavailable sslcacert sslclientcert sslclientkey sslverify mounted",
+built_in:"architecture augeasversion blockdevices boardmanufacturer boardproductname boardserialnumber cfkey dhcp_servers domain ec2_ ec2_userdata facterversion filesystems ldom fqdn gid hardwareisa hardwaremodel hostname id|0 interfaces ipaddress ipaddress_ ipaddress6 ipaddress6_ iphostnumber is_virtual kernel kernelmajversion kernelrelease kernelversion kernelrelease kernelversion lsbdistcodename lsbdistdescription lsbdistid lsbdistrelease lsbmajdistrelease lsbminordistrelease lsbrelease macaddress macaddress_ macosx_buildversion macosx_productname macosx_productversion macosx_productverson_major macosx_productversion_minor manufacturer memoryfree memorysize netmask metmask_ network_ operatingsystem operatingsystemmajrelease operatingsystemrelease osfamily partitions path physicalprocessorcount processor processorcount productname ps puppetversion rubysitedir rubyversion selinux selinux_config_mode selinux_config_policy selinux_current_mode selinux_current_mode selinux_enforced selinux_policyversion serialnumber sp_ sshdsakey sshecdsakey sshrsakey swapencrypted swapfree swapsize timezone type uniqueid uptime uptime_days uptime_hours uptime_seconds uuid virtual vlans xendomains zfs_version zonenae zones zpool_version"},
+relevance:0,contains:[c,b,{begin:"[a-zA-Z_]+\\s*=>",returnBegin:!0,end:"=>",contains:[{className:"attr",begin:a.IDENT_RE}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},e]}],relevance:0}]}});b.registerLanguage("python",function(a){var b={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",
+built_in:"Ellipsis NotImplemented"},d={className:"meta",begin:/^(>>>|\.\.\.) /},e={className:"subst",begin:/\{/,end:/\}/,keywords:b,illegal:/#/},c={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[d],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[d],relevance:10},{begin:/(fr|rf|f)'''/,end:/'''/,contains:[d,e]},{begin:/(fr|rf|f)"""/,end:/"""/,contains:[d,e]},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},
+{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},{begin:/(fr|rf|f)'/,end:/'/,contains:[e]},{begin:/(fr|rf|f)"/,end:/"/,contains:[e]},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE]},g={className:"number",relevance:0,variants:[{begin:a.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:a.C_NUMBER_RE+"[lLjJ]?"}]},k={className:"params",begin:/\(/,end:/\)/,contains:["self",d,g,c]};e.contains=[c,g,d];return{aliases:["py","gyp"],keywords:b,illegal:/(<\/|->|\?)|=>/,contains:[d,g,c,a.HASH_COMMENT_MODE,
+{variants:[{className:"function",beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,contains:[a.UNDERSCORE_TITLE_MODE,k,{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}});b.registerLanguage("rust",function(a){return{aliases:["rs"],keywords:{keyword:"alignof as be box break const continue crate do else enum extern false fn for if impl in let loop match mod mut offsetof once priv proc pub pure ref return self Self sizeof static struct super trait true type typeof unsafe unsized use virtual while where yield move default",
+literal:"true false Some None Ok Err",built_in:"drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!"},
+lexemes:a.IDENT_RE+"!?",illegal:"</",contains:[a.C_LINE_COMMENT_MODE,a.COMMENT("/\\*","\\*/",{contains:["self"]}),a.inherit(a.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{className:"string",variants:[{begin:/r(#*)"(.|\n)*?"\1(?!#)/},{begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol",begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{begin:"\\b0b([01_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},{begin:"\\b0o([0-7_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},{begin:"\\b0x([A-Fa-f0-9_]+)([ui](8|16|32|64|128|size)|f(32|64))?"},
+{begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)([ui](8|16|32|64|128|size)|f(32|64))?"}],relevance:0},{className:"function",beginKeywords:"fn",end:"(\\(|<)",excludeEnd:!0,contains:[a.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"#\\!?\\[",end:"\\]",contains:[{className:"meta-string",begin:/"/,end:/"/}]},{className:"class",beginKeywords:"type",end:";",contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"\\S"},{className:"class",beginKeywords:"trait enum struct union",end:"{",
+contains:[a.inherit(a.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]"},{begin:a.IDENT_RE+"::",keywords:{built_in:"drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!"}},
+{begin:"->"}]}});b.registerLanguage("scala",function(a){var b={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},d={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},e={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0};return{keywords:{literal:"true false null",keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[a.BACKSLASH_ESCAPE,b]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[b],relevance:10}]},{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},d,{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[e]},{className:"class",beginKeywords:"class object trait type",
+end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[d]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[d]},e]},a.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("shell",function(a){return{aliases:["console"],contains:[{className:"meta",begin:"^\\s{0,3}[\\w\\d\\[\\]()@-]*[>%$#]",starts:{end:"$",subLanguage:"bash"}}]}});b.registerLanguage("sql",
+function(a){var b=a.COMMENT("--","$");return{case_insensitive:!0,illegal:/[<>{}*#]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment",end:/;/,endsWithParent:!0,
+lexemes:/[\w\.]+/,keywords:{keyword:"abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias allocate allow alter always analyze ancillary and any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second section securefile security seed segment select self sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
 literal:"true false null",built_in:"array bigint binary bit blob boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text varchar varying void"},contains:[{className:"string",begin:"'",end:"'",contains:[a.BACKSLASH_ESCAPE,{begin:"''"}]},{className:"string",begin:'"',end:'"',contains:[a.BACKSLASH_ESCAPE,{begin:'""'}]},{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE]},a.C_NUMBER_MODE,a.C_BLOCK_COMMENT_MODE,b]},
-a.C_BLOCK_COMMENT_MODE,b]}});b.registerLanguage("swift",function(a){var b={keyword:"__COLUMN__ __FILE__ __FUNCTION__ __LINE__ as as! as? associativity break case catch class continue convenience default defer deinit didSet do dynamic dynamicType else enum extension fallthrough false final for func get guard if import in indirect infix init inout internal is lazy left let mutating nil none nonmutating operator optional override postfix precedence prefix private protocol Protocol public repeat required rethrows return right self Self set static struct subscript super switch throw throws true try try! try? Type typealias unowned var weak where while willSet",
+a.C_BLOCK_COMMENT_MODE,b]}});b.registerLanguage("swift",function(a){var b={keyword:"__COLUMN__ __FILE__ __FUNCTION__ __LINE__ as as! as? associativity break case catch class continue convenience default defer deinit didSet do dynamic dynamicType else enum extension fallthrough false fileprivate final for func get guard if import in indirect infix init inout internal is lazy left let mutating nil none nonmutating open operator optional override postfix precedence prefix private protocol Protocol public repeat required rethrows return right self Self set static struct subscript super switch throw throws true try try! try? Type typealias unowned var weak where while willSet",
 literal:"true false nil",built_in:"abs advance alignof alignofValue anyGenerator assert assertionFailure bridgeFromObjectiveC bridgeFromObjectiveCUnconditional bridgeToObjectiveC bridgeToObjectiveCUnconditional c contains count countElements countLeadingZeros debugPrint debugPrintln distance dropFirst dropLast dump encodeBitsAsWords enumerate equal fatalError filter find getBridgedObjectiveCType getVaList indices insertionSort isBridgedToObjectiveC isBridgedVerbatimToObjectiveC isUniquelyReferenced isUniquelyReferencedNonObjC join lazy lexicographicalCompare map max maxElement min minElement numericCast overlaps partition posix precondition preconditionFailure print println quickSort readLine reduce reflect reinterpretCast reverse roundUpToAlignment sizeof sizeofValue sort split startsWith stride strideof strideofValue swap toString transcode underestimateCount unsafeAddressOf unsafeBitCast unsafeDowncast unsafeUnwrap unsafeReflect withExtendedLifetime withObjectAtPlusZero withUnsafePointer withUnsafePointerToObject withUnsafeMutablePointer withUnsafeMutablePointers withUnsafePointer withUnsafePointers withVaList zip"},
-e=a.COMMENT("/\\*","\\*/",{contains:["self"]}),g={className:"subst",begin:/\\\(/,end:"\\)",keywords:b,contains:[]},d={className:"number",begin:"\\b([\\d_]+(\\.[\\deE_]+)?|0x[a-fA-F0-9_]+(\\.[a-fA-F0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b",relevance:0},f=a.inherit(a.QUOTE_STRING_MODE,{contains:[g,a.BACKSLASH_ESCAPE]});g.contains=[d];return{keywords:b,contains:[f,a.C_LINE_COMMENT_MODE,e,{className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},d,{className:"function",beginKeywords:"func",end:"{",excludeEnd:!0,
-contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{begin:/</,end:/>/},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:b,contains:["self",d,f,a.C_BLOCK_COMMENT_MODE,{begin:":"}],illegal:/["']/}],illegal:/\[|%/},{className:"class",beginKeywords:"struct protocol class extension enum",keywords:b,end:"\\{",excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/})]},{className:"meta",begin:"(@warn_unused_result|@exported|@lazy|@noescape|@NSCopying|@NSManaged|@objc|@convention|@required|@noreturn|@IBAction|@IBDesignable|@IBInspectable|@IBOutlet|@infix|@prefix|@postfix|@autoclosure|@testable|@available|@nonobjc|@NSApplicationMain|@UIApplicationMain)"},
-{beginKeywords:"import",end:/$/,contains:[a.C_LINE_COMMENT_MODE,e]}]}});b.registerLanguage("typescript",function(a){var b={keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const class public private protected get set super static implements enum export import declare type namespace abstract",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document any number boolean string void"};
+d=a.COMMENT("/\\*","\\*/",{contains:["self"]}),e={className:"subst",begin:/\\\(/,end:"\\)",keywords:b,contains:[]},c={className:"number",begin:"\\b([\\d_]+(\\.[\\deE_]+)?|0x[a-fA-F0-9_]+(\\.[a-fA-F0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b",relevance:0},g=a.inherit(a.QUOTE_STRING_MODE,{contains:[e,a.BACKSLASH_ESCAPE]});e.contains=[c];return{keywords:b,contains:[g,a.C_LINE_COMMENT_MODE,d,{className:"type",begin:"\\b[A-Z][\\w\u00c0-\u02b8']*",relevance:0},c,{className:"function",beginKeywords:"func",end:"{",
+excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{begin:/</,end:/>/},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:b,contains:["self",c,g,a.C_BLOCK_COMMENT_MODE,{begin:":"}],illegal:/["']/}],illegal:/\[|%/},{className:"class",beginKeywords:"struct protocol class extension enum",keywords:b,end:"\\{",excludeEnd:!0,contains:[a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/})]},{className:"meta",begin:"(@warn_unused_result|@exported|@lazy|@noescape|@NSCopying|@NSManaged|@objc|@convention|@required|@noreturn|@IBAction|@IBDesignable|@IBInspectable|@IBOutlet|@infix|@prefix|@postfix|@autoclosure|@testable|@available|@nonobjc|@NSApplicationMain|@UIApplicationMain)"},
+{beginKeywords:"import",end:/$/,contains:[a.C_LINE_COMMENT_MODE,d]}]}});b.registerLanguage("typescript",function(a){var b={keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const class public private protected get set super static implements enum export import declare type namespace abstract as from extends async await",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document any number boolean string void Promise"};
 return{aliases:["ts"],keywords:b,contains:[{className:"meta",begin:/^\s*['"]use strict['"]/},a.APOS_STRING_MODE,a.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",contains:[a.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]},a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:a.C_NUMBER_RE}],relevance:0},{begin:"("+a.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",
-contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE],relevance:0},{className:"function",begin:"function",end:/[\{;]/,excludeEnd:!0,keywords:b,contains:["self",a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],illegal:/["'\(]/}],illegal:/%/,relevance:0},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0},{begin:/module\./,keywords:{built_in:"module"},
-relevance:0},{beginKeywords:"module",end:/\{/,excludeEnd:!0},{beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:"interface extends"},{begin:/\$[(.]/},{begin:"\\."+a.IDENT_RE,relevance:0}]}});b.registerLanguage("yaml",function(a){var b={className:"attr",variants:[{begin:"^[ \\-]*[a-zA-Z_][\\w\\-]*:"},{begin:'^[ \\-]*"[a-zA-Z_][\\w\\-]*":'},{begin:"^[ \\-]*'[a-zA-Z_][\\w\\-]*':"}]},e={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/}],contains:[a.BACKSLASH_ESCAPE,
-{className:"template-variable",variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]};return{case_insensitive:!0,aliases:["yml","YAML","yaml"],contains:[b,{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>] *$",returnEnd:!0,contains:e.contains,end:b.variants[0].begin},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!!"+a.UNDERSCORE_IDENT_RE},{className:"meta",begin:"&"+a.UNDERSCORE_IDENT_RE+"$"},
-{className:"meta",begin:"\\*"+a.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"^ *-",relevance:0},e,a.HASH_COMMENT_MODE,a.C_NUMBER_MODE],keywords:{literal:"{ } true false yes no Yes No True False null"}}});return b});
+contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,a.REGEXP_MODE,{className:"function",begin:"(\\(.*?\\)|"+a.IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:a.IDENT_RE},{begin:/\(\s*\)/},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:["self",a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE]}]}]}],relevance:0},{className:"function",begin:"function",end:/[\{;]/,excludeEnd:!0,keywords:b,contains:["self",a.inherit(a.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),
+{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],illegal:/["'\(]/}],illegal:/%/,relevance:0},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0,contains:["self",{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:b,contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE],illegal:/["'\(]/}]},{begin:/module\./,keywords:{built_in:"module"},relevance:0},{beginKeywords:"module",end:/\{/,excludeEnd:!0},
+{beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:"interface extends"},{begin:/\$[(.]/},{begin:"\\."+a.IDENT_RE,relevance:0},{className:"meta",begin:"@[A-Za-z]+"}]}});b.registerLanguage("yaml",function(a){var b={className:"attr",variants:[{begin:"^[ \\-]*[a-zA-Z_][\\w\\-]*:"},{begin:'^[ \\-]*"[a-zA-Z_][\\w\\-]*":'},{begin:"^[ \\-]*'[a-zA-Z_][\\w\\-]*':"}]},d={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[a.BACKSLASH_ESCAPE,{className:"template-variable",
+variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]};return{case_insensitive:!0,aliases:["yml","YAML","yaml"],contains:[b,{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>] *$",returnEnd:!0,contains:d.contains,end:b.variants[0].begin},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!!"+a.UNDERSCORE_IDENT_RE},{className:"meta",begin:"&"+a.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+
+a.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"^ *-",relevance:0},a.HASH_COMMENT_MODE,{beginKeywords:"true false yes no null",keywords:{literal:"true false yes no null"}},a.C_NUMBER_MODE,d]}});return b});
diff --git a/lib/httpcomponents/BUCK b/lib/httpcomponents/BUCK
deleted file mode 100644
index 03669f2..0000000
--- a/lib/httpcomponents/BUCK
+++ /dev/null
@@ -1,41 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VERSION = '4.4.1'
-
-maven_jar(
-  name = 'fluent-hc',
-  id = 'org.apache.httpcomponents:fluent-hc:' + VERSION,
-  bin_sha1 = '96fb842b68a44cc640c661186828b60590c71261',
-  src_sha1 = '702515612b2b94ce3374ed5b579d38cbd308eb4f',
-  license = 'Apache2.0',
-  deps = [':httpclient']
-)
-
-maven_jar(
-  name = 'httpclient',
-  id = 'org.apache.httpcomponents:httpclient:' + VERSION,
-  bin_sha1 = '016d0bc512222f1253ee6b64d389c84e22f697f0',
-  src_sha1 = '30cb4791019c7280227e027b01814f4964a02482',
-  license = 'Apache2.0',
-  deps = [
-    '//lib/commons:codec',
-    ':httpcore',
-    '//lib/log:jcl-over-slf4j',
-  ],
-)
-
-maven_jar(
-  name = 'httpcore',
-  id = 'org.apache.httpcomponents:httpcore:' + VERSION,
-  bin_sha1 = 'f5aa318bda4c6c8d688c9d00b90681dcd82ce636',
-  src_sha1 = '9700be0d0a331691654a8e901943c9a74e33c5fc',
-  license = 'Apache2.0',
-)
-
-maven_jar(
-  name = 'httpmime',
-  id = 'org.apache.httpcomponents:httpmime:' + VERSION,
-  bin_sha1 = '2f8757f5ac5e38f46c794e5229d1f3c522e9b1df',
-  src_sha1 = '5394d3715181a87009032335a55b0a9789f6e26f',
-  license = 'Apache2.0',
-)
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
index 74ab00a..7ec5f29 100644
--- a/lib/httpcomponents/BUILD
+++ b/lib/httpcomponents/BUILD
@@ -1,29 +1,41 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_visibility = ["//visibility:public"])
+
 java_library(
-  name = 'fluent-hc',
-  exports = ['@fluent_hc//jar'],
-  runtime_deps = [':httpclient'],
-  visibility = ['//visibility:public'],
+    name = "fluent-hc",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@fluent-hc//jar"],
+    runtime_deps = [":httpclient"],
 )
 
 java_library(
-  name = 'httpclient',
-  exports = ['@httpclient//jar'],
-  runtime_deps = [
-    '//lib/commons:codec',
-    ':httpcore',
-    '//lib/log:jcl-over-slf4j',
-  ],
-  visibility = ['//visibility:public'],
+    name = "httpclient",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@httpclient//jar"],
+    runtime_deps = [
+        ":httpcore",
+        "//lib/commons:codec",
+        "//lib/log:jcl-over-slf4j",
+    ],
 )
 
 java_library(
-  name = 'httpcore',
-  exports = ['@httpcore//jar'],
-  visibility = ['//visibility:public'],
+    name = "httpcore",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@httpcore//jar"],
 )
 
 java_library(
-  name = 'httpmime',
-  exports = ['@httpmime//jar'],
-  visibility = ['//visibility:public'],
+    name = "httpasyncclient",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//gerrit-elasticsearch:__pkg__"],
+    exports = ["@httpasyncclient//jar"],
+)
+
+java_library(
+    name = "httpcore-nio",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//gerrit-elasticsearch:__pkg__"],
+    exports = ["@httpcore-nio//jar"],
 )
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
new file mode 100644
index 0000000..18d42f2
--- /dev/null
+++ b/lib/jackson/BUILD
@@ -0,0 +1,8 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "jackson-core",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//gerrit-elasticsearch:__pkg__"],
+    exports = ["@jackson-core//jar"],
+)
diff --git a/lib/jetty/BUCK b/lib/jetty/BUCK
deleted file mode 100644
index cc22b80..0000000
--- a/lib/jetty/BUCK
+++ /dev/null
@@ -1,95 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VERSION = '9.2.14.v20151106'
-EXCLUDE = ['about.html']
-
-maven_jar(
-  name = 'servlet',
-  id = 'org.eclipse.jetty:jetty-servlet:' + VERSION,
-  sha1 = '3a2cd4d8351a38c5d60e0eee010fee11d87483ef',
-  license = 'Apache2.0',
-  deps = [':security'],
-  exclude = EXCLUDE,
-)
-
-maven_jar(
-  name = 'security',
-  id = 'org.eclipse.jetty:jetty-security:' + VERSION,
-  sha1 = '2d36974323fcb31e54745c1527b996990835db67',
-  license = 'Apache2.0',
-  deps = [':server'],
-  exclude = EXCLUDE,
-  visibility = [],
-)
-
-maven_jar(
-  name = 'servlets',
-  id = 'org.eclipse.jetty:jetty-servlets:' + VERSION,
-  sha1 = 'a75c78a0ee544073457ca5ee9db20fdc6ed55225',
-  license = 'Apache2.0',
-  exclude = EXCLUDE,
-  visibility = [
-    '//tools/eclipse:classpath',
-    '//gerrit-gwtdebug:gwtdebug',
-  ],
-)
-
-maven_jar(
-  name = 'server',
-  id = 'org.eclipse.jetty:jetty-server:' + VERSION,
-  sha1 = '70b22c1353e884accf6300093362b25993dac0f5',
-  license = 'Apache2.0',
-  exported_deps = [
-    ':continuation',
-    ':http',
-  ],
-  exclude = EXCLUDE,
-)
-
-maven_jar(
-  name = 'jmx',
-  id = 'org.eclipse.jetty:jetty-jmx:' + VERSION,
-  sha1 = '617edc5e966b4149737811ef8b289cd94b831bab',
-  license = 'Apache2.0',
-  exported_deps = [
-    ':continuation',
-    ':http',
-  ],
-  exclude = EXCLUDE,
-)
-
-maven_jar(
-  name = 'continuation',
-  id = 'org.eclipse.jetty:jetty-continuation:' + VERSION,
-  sha1 = '8909d62fd7e28351e2da30de6fb4105539b949c0',
-  license = 'Apache2.0',
-  exclude = EXCLUDE,
-)
-
-maven_jar(
-  name = 'http',
-  id = 'org.eclipse.jetty:jetty-http:' + VERSION,
-  sha1 = '699ad1f2fa6fb0717e1b308a8c9e1b8c69d81ef6',
-  license = 'Apache2.0',
-  exported_deps = [':io'],
-  exclude = EXCLUDE,
-)
-
-maven_jar(
-  name = 'io',
-  id = 'org.eclipse.jetty:jetty-io:' + VERSION,
-  sha1 = 'dfa4137371a3f08769820138ca1a2184dacda267',
-  license = 'Apache2.0',
-  exported_deps = [':util'],
-  exclude = EXCLUDE,
-  visibility = [],
-)
-
-maven_jar(
-  name = 'util',
-  id = 'org.eclipse.jetty:jetty-util:' + VERSION,
-  sha1 = '0057e00b912ae0c35859ac81594a996007706a0b',
-  license = 'Apache2.0',
-  exclude = EXCLUDE,
-  visibility = [],
-)
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index da3af1c..1664a5c 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -1,67 +1,78 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'servlet',
-  exports = ['@jetty_servlet//jar'],
-  runtime_deps = [':security'],
-  visibility = ['//visibility:public'],
+    name = "servlet",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jetty-servlet//jar"],
+    runtime_deps = [":security"],
 )
 
 java_library(
-  name = 'security',
-  exports = ['@jetty_security//jar'],
-  runtime_deps = [':server'],
-  visibility = ['//visibility:public'],
+    name = "security",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jetty-security//jar"],
+    runtime_deps = [":server"],
 )
 
 java_library(
-  name = 'servlets',
-  exports = ['@jetty_servlets//jar'],
-  visibility = ['//visibility:public'],
+    name = "servlets",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jetty-servlets//jar"],
 )
 
 java_library(
-  name = 'server',
-  exports = [
-    '@jetty_server//jar',
-    ':continuation',
-    ':http',
-  ],
-  visibility = ['//visibility:public'],
+    name = "server",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":continuation",
+        ":http",
+        "@jetty-server//jar",
+    ],
 )
 
 java_library(
-  name = 'jmx',
-  exports = [
-    '@jetty_jmx//jar',
-    ':continuation',
-    ':http',
-  ],
-  visibility = ['//visibility:public'],
+    name = "jmx",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":continuation",
+        ":http",
+        "@jetty-jmx//jar",
+    ],
 )
 
 java_library(
-  name = 'continuation',
-  exports = ['@jetty_continuation//jar'],
-  visibility = ['//visibility:public'],
+    name = "continuation",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jetty-continuation//jar"],
 )
 
 java_library(
-  name = 'http',
-  exports = [
-    '@jetty_http//jar',
-    ':io',
-  ],
-  visibility = ['//visibility:public'],
+    name = "http",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":io",
+        "@jetty-http//jar",
+    ],
 )
 
 java_library(
-  name = 'io',
-  exports = [
-    '@jetty_io//jar',
-    ':util',
-  ],
+    name = "io",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = [
+        ":util",
+        "@jetty-io//jar",
+    ],
 )
 
 java_library(
-  name = 'util',
-  exports = ['@jetty_util//jar'],
+    name = "util",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@jetty-util//jar"],
 )
diff --git a/lib/jgit/BUILD b/lib/jgit/BUILD
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/jgit/BUILD
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
new file mode 100644
index 0000000..429db91
--- /dev/null
+++ b/lib/jgit/jgit.bzl
@@ -0,0 +1,75 @@
+load("//tools/bzl:maven_jar.bzl", "MAVEN_CENTRAL", "maven_jar")
+
+_JGIT_VERS = "4.7.9.201904161809-r"
+
+_DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
+
+JGIT_DOC_URL = "https://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
+
+_JGIT_REPO = MAVEN_CENTRAL  # Leave here even if set to MAVEN_CENTRAL.
+
+# set this to use a local version.
+# "/home/<user>/projects/jgit"
+LOCAL_JGIT_REPO = ""
+
+def jgit_repos():
+    if LOCAL_JGIT_REPO:
+        native.local_repository(
+            name = "jgit",
+            path = LOCAL_JGIT_REPO,
+        )
+        jgit_maven_repos_dev()
+    else:
+        jgit_maven_repos()
+
+def jgit_maven_repos_dev():
+    # Transitive dependencies from JGit's WORKSPACE.
+    maven_jar(
+        name = "hamcrest-library",
+        artifact = "org.hamcrest:hamcrest-library:1.3",
+        sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
+    )
+
+def jgit_maven_repos():
+    maven_jar(
+        name = "jgit-lib",
+        artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
+        repository = _JGIT_REPO,
+        sha1 = "14fb9628876e69d1921776c84c7343ddabe7db31",
+        src_sha1 = "6717cab511548f01f07db2442d104ba901402d49",
+        unsign = True,
+    )
+    maven_jar(
+        name = "jgit-servlet",
+        artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
+        repository = _JGIT_REPO,
+        sha1 = "4b9006c68e257e4397a34a6022c6729c657129d8",
+        unsign = True,
+    )
+    maven_jar(
+        name = "jgit-archive",
+        artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
+        repository = _JGIT_REPO,
+        sha1 = "41fb617b0d51afb2f6c1345e8ef57f3caece790a",
+    )
+    maven_jar(
+        name = "jgit-junit",
+        artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
+        repository = _JGIT_REPO,
+        sha1 = "10ad697deb13a90b957e462589fb92a5cf371909",
+        unsign = True,
+    )
+
+def jgit_dep(name):
+    mapping = {
+        "@jgit-archive//jar": "@jgit//org.eclipse.jgit.archive:jgit-archive",
+        "@jgit-junit//jar": "@jgit//org.eclipse.jgit.junit:junit",
+        "@jgit-lib//jar": "@jgit//org.eclipse.jgit:jgit",
+        "@jgit-lib//jar:src": "@jgit//org.eclipse.jgit:libjgit-src.jar",
+        "@jgit-servlet//jar": "@jgit//org.eclipse.jgit.http.server:jgit-servlet",
+    }
+
+    if LOCAL_JGIT_REPO:
+        return mapping[name]
+    else:
+        return name
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUCK b/lib/jgit/org.eclipse.jgit.archive/BUCK
deleted file mode 100644
index 094c239e..0000000
--- a/lib/jgit/org.eclipse.jgit.archive/BUCK
+++ /dev/null
@@ -1,16 +0,0 @@
-include_defs('//lib/maven.defs')
-include_defs('//lib/JGIT_VERSION')
-
-maven_jar(
-  name = 'jgit-archive',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.archive:' + VERS,
-  sha1 = '89d60d194dfb0818df49a00af59ab8c455e43864',
-  license = 'jgit',
-  repository = REPO,
-  deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
-  unsign = True,
-  exclude = [
-    'about.html',
-    'plugin.properties',
-  ],
- )
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUILD b/lib/jgit/org.eclipse.jgit.archive/BUILD
index 8fa94f2..151cd71 100644
--- a/lib/jgit/org.eclipse.jgit.archive/BUILD
+++ b/lib/jgit/org.eclipse.jgit.archive/BUILD
@@ -1,6 +1,10 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//lib/jgit:jgit.bzl", "jgit_dep")
+
 java_library(
-  name = 'jgit-archive',
-  exports = ['@jgit_archive//jar'],
-  runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
-  visibility = ['//visibility:public'],
+    name = "jgit-archive",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = [jgit_dep("@jgit-archive//jar")],
+    runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
 )
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUCK b/lib/jgit/org.eclipse.jgit.http.server/BUCK
deleted file mode 100644
index 6eddcce..0000000
--- a/lib/jgit/org.eclipse.jgit.http.server/BUCK
+++ /dev/null
@@ -1,16 +0,0 @@
-include_defs('//lib/maven.defs')
-include_defs('//lib/JGIT_VERSION')
-
-maven_jar(
-  name = 'jgit-servlet',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.http.server:' + VERS,
-  sha1 = '5e999808806bbda10f46b19cdf4fc39d39ca1efb',
-  license = 'jgit',
-  repository = REPO,
-  deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
-  unsign = True,
-  exclude = [
-    'about.html',
-    'plugin.properties',
-  ],
-)
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUILD b/lib/jgit/org.eclipse.jgit.http.server/BUILD
index 6a442cc..fd634a5 100644
--- a/lib/jgit/org.eclipse.jgit.http.server/BUILD
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUILD
@@ -1,6 +1,10 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//lib/jgit:jgit.bzl", "jgit_dep")
+
 java_library(
-  name = 'jgit-servlet',
-  exports = ['@jgit_servlet//jar'],
-  runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
-  visibility = ['//visibility:public'],
+    name = "jgit-servlet",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = [jgit_dep("@jgit-servlet//jar")],
+    runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
 )
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUCK b/lib/jgit/org.eclipse.jgit.junit/BUCK
deleted file mode 100644
index a9c6077..0000000
--- a/lib/jgit/org.eclipse.jgit.junit/BUCK
+++ /dev/null
@@ -1,12 +0,0 @@
-include_defs('//lib/maven.defs')
-include_defs('//lib/JGIT_VERSION')
-
-maven_jar(
-  name = 'junit',
-  id = 'org.eclipse.jgit:org.eclipse.jgit.junit:' + VERS,
-  sha1 = '47e1a1c0d0720611886b9ed675a1e9a2e8fd6a09',
-  license = 'DO_NOT_DISTRIBUTE',
-  repository = REPO,
-  unsign = True,
-  deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
-)
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
index d00b82c9..7d4b12d 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUILD
+++ b/lib/jgit/org.eclipse.jgit.junit/BUILD
@@ -1,6 +1,11 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//lib/jgit:jgit.bzl", "jgit_dep")
+
 java_library(
-  name = 'junit',
-  exports = ['@jgit_junit//jar'],
-  runtime_deps = ['//lib/jgit/org.eclipse.jgit:jgit'],
-  visibility = ['//visibility:public'],
+    name = "junit",
+    testonly = 1,
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [jgit_dep("@jgit-junit//jar")],
+    runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
 )
diff --git a/lib/jgit/org.eclipse.jgit/BUCK b/lib/jgit/org.eclipse.jgit/BUCK
deleted file mode 100644
index 6021155..0000000
--- a/lib/jgit/org.eclipse.jgit/BUCK
+++ /dev/null
@@ -1,25 +0,0 @@
-include_defs('//lib/maven.defs')
-include_defs('//lib/JGIT_VERSION')
-
-maven_jar(
-  name = 'jgit',
-  id = 'org.eclipse.jgit:org.eclipse.jgit:' + VERS,
-  bin_sha1 = 'e84008db495a7f48d65fb09afe53279b0df68c80',
-  src_sha1 = 'd7fe981cc76f11ad95872b2658344c47c6a29556',
-  license = 'jgit',
-  repository = REPO,
-  unsign = True,
-  deps = [':ewah'],
-  exclude = [
-    'META-INF/eclipse.inf',
-    'about.html',
-    'plugin.properties',
-  ],
-)
-
-maven_jar(
-  name = 'ewah',
-  id = 'com.googlecode.javaewah:JavaEWAH:0.7.9',
-  sha1 = 'eceaf316a8faf0e794296ebe158ae110c7d72a5a',
-  license = 'Apache2.0',
-)
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
index a1f9cad..d10b4b6 100644
--- a/lib/jgit/org.eclipse.jgit/BUILD
+++ b/lib/jgit/org.eclipse.jgit/BUILD
@@ -1,12 +1,23 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//lib/jgit:jgit.bzl", "jgit_dep")
+
 java_library(
-  name = 'jgit',
-  exports = ['@jgit//jar'],
-  runtime_deps = [':ewah'],
-  visibility = ['//visibility:public'],
+    name = "jgit",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = [jgit_dep("@jgit-lib//jar")],
+    runtime_deps = [":javaewah"],
+)
+
+alias(
+    name = "jgit-source",
+    actual = jgit_dep("@jgit-lib//jar:src"),
+    visibility = ["//visibility:public"],
 )
 
 java_library(
-  name = 'ewah',
-  exports = ['@ewah//jar'],
-  visibility = ['//visibility:public'],
+    name = "javaewah",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@javaewah//jar"],
 )
diff --git a/lib/joda/BUCK b/lib/joda/BUCK
deleted file mode 100644
index d78c456..0000000
--- a/lib/joda/BUCK
+++ /dev/null
@@ -1,25 +0,0 @@
-include_defs('//lib/maven.defs')
-
-EXCLUDE = [
-  'META-INF/LICENSE.txt',
-  'META-INF/NOTICE.txt',
-]
-
-maven_jar(
-  name = 'joda-time',
-  id = 'joda-time:joda-time:2.9.4',
-  sha1 = '1c295b462f16702ebe720bbb08f62e1ba80da41b',
-  deps = [':joda-convert'],
-  license = 'Apache2.0',
-  exclude = EXCLUDE,
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'joda-convert',
-  id = 'org.joda:joda-convert:1.8.1',
-  sha1 = '675642ac208e0b741bc9118dcbcae44c271b992a',
-  license = 'Apache2.0',
-  exclude = EXCLUDE,
-  visibility = ['//lib/joda:joda-time'],
-)
diff --git a/lib/joda/BUILD b/lib/joda/BUILD
index a673bf5..05e0502 100644
--- a/lib/joda/BUILD
+++ b/lib/joda/BUILD
@@ -1,11 +1,15 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'joda-time',
-  exports = ['@joda_time//jar'],
-  runtime_deps = ['joda-convert'],
-  visibility = ['//visibility:public'],
+    name = "joda-time",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@joda-time//jar"],
+    runtime_deps = ["joda-convert"],
 )
 
 java_library(
-  name = 'joda-convert',
-  exports = ['@joda_convert//jar'],
+    name = "joda-convert",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@joda-convert//jar"],
 )
diff --git a/lib/js.defs b/lib/js.defs
deleted file mode 100644
index c9a4256..0000000
--- a/lib/js.defs
+++ /dev/null
@@ -1,171 +0,0 @@
-# Copyright (C) 2015 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-NPMJS = 'NPMJS'
-GERRIT = 'GERRIT'
-
-# NOTE: npm_binary rules do not get their licenses checked by gen_licenses.py,
-# as we would have to cut too many edges. DO NOT include these binaries in
-# build outputs. Using them in the build _process_ is ok.
-def npm_binary(
-    name,
-    version,
-    sha1 = '',
-    repository = NPMJS,
-    visibility = ['PUBLIC']):
-
-  dir = '%s-%s' % (name, version)
-  filename = '%s.tgz' % dir
-  dest = '%s@%s.npm_binary.tgz' % (name, version)
-  if repository == GERRIT:
-    url = 'http://gerrit-maven.storage.googleapis.com/npm-packages/%s' % filename
-  elif repository == NPMJS:
-    url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
-  else:
-    raise ValueError('invalid repository: %s' % repository)
-  cmd = ['$(exe //tools:download_file)', '-o', '$OUT', '-u', url]
-  if sha1:
-    cmd.extend(['-v', sha1])
-  genrule(
-    name = name,
-    cmd = ' '.join(cmd),
-    out = dest,
-    visibility = visibility,
-  )
-
-
-def run_npm_binary(target):
-  return '$(location //tools/js:run_npm_binary) $(location %s)' % target
-
-
-def bower_component(
-    name,
-    package,
-    version,
-    license,
-    deps = [],
-    semver = None,
-    sha1 = '',
-    visibility = ['PUBLIC']):
-  download_name = '%s__download_bower' % name
-  genrule(
-    name = download_name,
-    cmd = ' '.join([
-      '$(exe //tools/js:download_bower)',
-      '-b', '"%s"' % run_npm_binary('//lib/js:bower'),
-      '-n', name,
-      '-p', package,
-      '-v', version,
-      '-s', sha1,
-      '-o', '$OUT',
-    ]),
-    out = '%s.zip' % download_name,
-    license = license,
-    visibility = [],
-  )
-
-  renamed_name = '%s__renamed' % name
-  genrule(
-    name = renamed_name,
-    cmd = ' && '.join([
-      'cd $TMP',
-      'mkdir bower_components',
-      'cd bower_components',
-      'unzip $(location :%s)' % download_name,
-      'cd ..',
-      'zip -r $OUT bower_components',
-    ]),
-    out = '%s.zip' % renamed_name,
-    visibility = [],
-  )
-
-  genrule(
-    name = name,
-    cmd = _combine_components([':%s' % renamed_name] + deps),
-    out = '%s-%s.zip' % (name, version),
-    visibility = visibility,
-  )
-
-  version_name = '%s__bower_version' % name
-  dep_version = semver if semver is not None else version
-  deps_json = '{"%s": "%s#%s"}' % (name, package, dep_version)
-  genrule(
-    name = version_name,
-    cmd = "echo '%s' > $OUT" % deps_json,
-    out = version_name,
-    visibility = visibility,
-  )
-
-
-def bower_components(
-    name,
-    deps,
-    visibility = ['PUBLIC']):
-  genrule(
-    name = name,
-    cmd = _combine_components(deps),
-    out = '%s.bower_components.zip' % name,
-    visibility = visibility,
-  )
-
-
-def _combine_components(deps):
-  cmds = ['cd $TMP']
-  for d in deps:
-    cmds.append('unzip -qo $(location %s)' % d)
-  cmds.append('zip -r $OUT bower_components')
-  return ' && '.join(cmds)
-
-
-VULCANIZE_FLAGS = [
-  '--inline-scripts',
-  '--inline-css',
-  '--strip-comments',
-]
-
-def vulcanize(
-    name,
-    app,
-    srcs,
-    components,
-    extra_flags = [],
-    visibility = ['PUBLIC']):
-  genrule(
-    name = '%s__vulcanized' % name,
-    cmd = ' '.join([
-      'unzip', '-qd', '$TMP', '$(location %s)' % components,
-      '&&', 'rm', '-rf', '$SRCDIR/bower_components',
-      '&&', 'ln', '-s', '-f', '$TMP/bower_components', '.',
-      '&&', run_npm_binary('//lib/js:vulcanize')
-    ] + VULCANIZE_FLAGS + extra_flags + [
-      '--out-html', '$OUT',
-      '$SRCDIR/%s' % app,
-    ]),
-    srcs = srcs,
-    out = '%s.vulcanized.html' % name,
-    visibility = visibility,
-  )
-
-  genrule(
-    name = name,
-    cmd = ' '.join([
-      'cd', '$TMP',
-      '&&', run_npm_binary('//lib/js:crisper'), '--always-write-script',
-      '--source', '$(location :%s__vulcanized)' % name,
-      '--html', '%s.html' % name,
-      '--js', '%s.js' % name,
-      '&&', 'zip', '$OUT', '%s.html' % name, '%s.js' % name,
-    ]),
-    out = '%s.vulcanized.zip' % name,
-  )
diff --git a/lib/js/BUCK b/lib/js/BUCK
deleted file mode 100644
index 9b71bc6..0000000
--- a/lib/js/BUCK
+++ /dev/null
@@ -1,427 +0,0 @@
-include_defs('//lib/js.defs')
-
-# WHEN REVIEWING NEW NPM_BINARY RULES:
-#
-# You must check licenses in the transitive closure of dependencies to ensure
-# they can be used by Gerrit. (npm binaries are not distributed with Gerrit
-# releases, so we are less restrictive in our selection of licenses, but we
-# still need to do a sanity check.)
-#
-# To do this:
-#   npm install -g license-checker
-#   mkdir /tmp/npmtmp
-#   cd /tmp/npmtmp
-#   npm install <package>@<version>
-#   license-checker
-# (Piping to grep -o 'licenses:.*' and/or sort -u may make the output saner.)
-
-npm_binary(
-  name = 'bower',
-  version = '1.8.2',
-  sha1 = 'adf53529c8d4af02ef24fb8d5341c1419d33e2f7',
-)
-
-npm_binary(
-  name = 'crisper',
-  version = '2.0.2',
-  sha1 = '7183c58cea33632fb036c91cefd1b43e390d22a2',
-  repository = GERRIT,
-)
-
-npm_binary(
-  name = 'vulcanize',
-  version = '1.14.8',
-  sha1 = '679107f251c19ab7539529b1e3fdd40829e6fc63',
-  repository = GERRIT,
-)
-
-# ## Adding Bower component dependencies
-#
-# 1. Add a dummy bower_component rule to this file, specifying the semantic
-#    version you want to use. The actual version will be filled in by Bower,
-#    after evaluating the full dependency tree.
-#
-#      bower_component(
-#        name = 'somepackage',
-#        package = 'someauthor/somepackage',
-#        version = 'TODO',
-#        semver = '~1.0.0',
-#        license = 'DO_NOT_DISTRIBUTE'
-#      )
-#
-# 2. Add your bower_component as a dep to a bower_components rule.
-#
-#      bower_components(
-#        name = 'polygerrit_components',
-#        deps = [
-#          '//lib/js:foo',
-#          '//lib/js:somepackage',  # NEW
-#        ],
-#      )
-#
-# 3. Run bower2buck.py.
-#
-#      buck run //tools/js:bower2buck -- -o /tmp/newbuck
-#
-# 4. Use your favorite diff tool to merge the output in newbuck with this file.
-#    bower2buck reevaluates semantic versions and may upgrade some packages, so
-#    you may need to make changes beyond the new component that was added.
-#
-#      meld /tmp/newbuck lib/js/BUCK
-#
-#
-# ## Updating Bower component dependencies
-#
-# Use the same procedure as for adding dependencies, except just change the
-# version number of the existing bower_component rather than adding a new rule.
-
-bower_component(
-  name = 'accessibility-developer-tools',
-  package = 'accessibility-developer-tools',
-  version = '2.10.0',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = 'bc1a5e56ff1bed7a7a6ef22a4b4e8300e4822aa5',
-)
-
-bower_component(
-  name = 'async',
-  package = 'async',
-  version = '1.5.2',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = '1ec975d3b3834646a7e3d4b7e68118b90ed72508',
-)
-
-bower_component(
-  name = 'chai',
-  package = 'chai',
-  version = '3.5.0',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = '849ad3ee7c77506548b7b5db603a4e150b9431aa',
-)
-
-bower_component(
-  name = 'es6-promise',
-  package = 'stefanpenner/es6-promise',
-  version = '3.3.0',
-  license = 'es6-promise',
-  sha1 = 'a3a797bb22132f1ef75f9a2556173f81870c2e53',
-)
-
-bower_component(
-  name = 'fetch',
-  package = 'fetch',
-  version = '1.0.0',
-  license = 'fetch',
-  sha1 = '1b05a2bb40c73232c2909dc196de7519fe4db7a9',
-)
-
-bower_component(
-  name = 'iron-a11y-announcer',
-  package = 'polymerelements/iron-a11y-announcer',
-  version = '1.0.4',
-  deps = [':polymer'],
-  license = 'polymer',
-  sha1 = '9a915711b35092fa2f86ff6e904c4f3e43aa5234',
-)
-
-bower_component(
-  name = 'iron-a11y-keys-behavior',
-  package = 'polymerelements/iron-a11y-keys-behavior',
-  version = '1.1.2',
-  deps = [':polymer'],
-  license = 'polymer',
-  sha1 = '57fd39ee153ce37ed719ba3f7a405afb987d54f9',
-)
-
-bower_component(
-  name = 'iron-autogrow-textarea',
-  package = 'polymerelements/iron-autogrow-textarea',
-  version = '1.0.12',
-  deps = [
-    ':iron-behaviors',
-    ':iron-flex-layout',
-    ':iron-form-element-behavior',
-    ':iron-validatable-behavior',
-    ':polymer',
-  ],
-  license = 'polymer',
-  sha1 = 'b9b6874c9a2b5be435557a827ff8bd6661672ee3',
-)
-
-bower_component(
-  name = 'iron-behaviors',
-  package = 'polymerelements/iron-behaviors',
-  version = '1.0.16',
-  deps = [
-    ':iron-a11y-keys-behavior',
-    ':polymer',
-  ],
-  license = 'polymer',
-  sha1 = 'bd70636a2c0a78c50d1a76f9b8ca1ffd815478a3',
-)
-
-bower_component(
-  name = 'iron-dropdown',
-  package = 'polymerelements/iron-dropdown',
-  version = '1.4.0',
-  deps = [
-    ':iron-a11y-keys-behavior',
-    ':iron-behaviors',
-    ':iron-overlay-behavior',
-    ':iron-resizable-behavior',
-    ':neon-animation',
-    ':polymer',
-  ],
-  license = 'polymer',
-  sha1 = '63e3d669a09edaa31c4f05afc76b53b919ef0595',
-)
-
-bower_component(
-  name = 'iron-fit-behavior',
-  package = 'polymerelements/iron-fit-behavior',
-  version = '1.2.2',
-  deps = [':polymer'],
-  license = 'polymer',
-  sha1 = 'bc53e9bab36b21f086ab8fac8c53cc7214aa1890',
-)
-
-bower_component(
-  name = 'iron-flex-layout',
-  package = 'polymerelements/iron-flex-layout',
-  version = '1.3.1',
-  deps = [':polymer'],
-  license = 'polymer',
-  sha1 = 'ba696394abff5e799fc06eb11bff4720129a1b52',
-)
-
-bower_component(
-  name = 'iron-form-element-behavior',
-  package = 'polymerelements/iron-form-element-behavior',
-  version = '1.0.6',
-  deps = [':polymer'],
-  license = 'polymer',
-  sha1 = '8d9e6530edc1b99bec1a5c34853911fba3701220',
-)
-
-bower_component(
-  name = 'iron-input',
-  package = 'polymerelements/iron-input',
-  version = '1.0.10',
-  deps = [
-    ':iron-a11y-announcer',
-    ':iron-validatable-behavior',
-    ':polymer',
-  ],
-  license = 'polymer',
-  sha1 = '9bc0c8e81de2527125383cbcf74dd9f27e7fa9ac',
-)
-
-bower_component(
-  name = 'iron-meta',
-  package = 'polymerelements/iron-meta',
-  version = '1.1.1',
-  deps = [':polymer'],
-  license = 'polymer',
-  sha1 = 'e06281b6ddb3355ceca44975a167381b1fd72ce5',
-)
-
-bower_component(
-  name = 'iron-overlay-behavior',
-  package = 'polymerelements/iron-overlay-behavior',
-  version = '1.7.6',
-  deps = [
-    ':iron-a11y-keys-behavior',
-    ':iron-fit-behavior',
-    ':iron-resizable-behavior',
-    ':polymer',
-  ],
-  license = 'polymer',
-  sha1 = '83181085fda59446ce74fd0d5ca30c223f38ee4a',
-)
-
-bower_component(
-  name = 'iron-resizable-behavior',
-  package = 'polymerelements/iron-resizable-behavior',
-  version = '1.0.3',
-  deps = [':polymer'],
-  license = 'polymer',
-  sha1 = '5982a3e19af7ed3e3de276a9b7bd266b3a144002',
-)
-
-bower_component(
-  name = 'iron-selector',
-  package = 'polymerelements/iron-selector',
-  version = '1.5.2',
-  deps = [':polymer'],
-  license = 'polymer',
-  sha1 = 'c57235dfda7fbb987c20ad0e97aac70babf1a1bf',
-)
-
-bower_component(
-  name = 'iron-test-helpers',
-  package = 'polymerelements/iron-test-helpers',
-  version = '1.2.5',
-  deps = [':polymer'],
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = '433b03b106f5ff32049b84150cd70938e18b67ac',
-)
-
-bower_component(
-  name = 'iron-validatable-behavior',
-  package = 'polymerelements/iron-validatable-behavior',
-  version = '1.1.1',
-  deps = [
-    ':iron-meta',
-    ':polymer',
-  ],
-  license = 'polymer',
-  sha1 = '480423380be0536f948735d91bc472f6e7ced5b4',
-)
-
-bower_component(
-  name = 'lodash',
-  package = 'lodash',
-  version = '3.10.1',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = '2f207a8293c4c554bf6cf071241f7a00dc513d3a',
-)
-
-bower_component(
-  name = 'mocha',
-  package = 'mocha',
-  version = '2.5.1',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = 'cb29bdd1047cfd9304659ecf10ec263f9c888c99',
-)
-
-bower_component(
-  name = 'moment',
-  package = 'moment/moment',
-  version = '2.13.0',
-  license = 'moment',
-  sha1 = 'fc8ce2c799bab21f6ced7aff928244f4ca8880aa',
-)
-
-bower_component(
-  name = 'neon-animation',
-  package = 'polymerelements/neon-animation',
-  version = '1.2.3',
-  deps = [
-    ':iron-meta',
-    ':iron-resizable-behavior',
-    ':iron-selector',
-    ':polymer',
-    ':web-animations-js',
-  ],
-  license = 'polymer',
-  sha1 = '71cc0d3e0afdf8b8563e87d2ff03a6fa19183bd9',
-)
-
-bower_component(
-  name = 'page',
-  package = 'visionmedia/page.js',
-  version = '1.7.1',
-  license = 'page.js',
-  sha1 = '51a05428dd4f68fae1df5f12d0e2b61ba67f7757',
-)
-
-bower_component(
-  name = 'polymer',
-  package = 'polymer/polymer',
-  version = '1.4.0',
-  deps = [':webcomponentsjs'],
-  license = 'polymer',
-  sha1 = 'b84725939ead7c7bdf9917b065f68ef8dc790d06',
-)
-
-bower_component(
-  name = 'promise-polyfill',
-  package = 'polymerlabs/promise-polyfill',
-  version = '1.0.0',
-  deps = [':polymer'],
-  license = 'promise-polyfill',
-  sha1 = 'a3b598c06cbd7f441402e666ff748326030905d6',
-)
-
-bower_component(
-  name = 'sinon-chai',
-  package = 'sinon-chai',
-  version = '2.8.0',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = '0464b5d944fdf8116bb23e0b02ecfbac945b3517',
-)
-
-bower_component(
-  name = 'sinonjs',
-  package = 'sinonjs',
-  version = '1.17.1',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = 'a26a6aab7358807de52ba738770f6ac709afd240',
-)
-
-bower_component(
-  name = 'stacky',
-  package = 'stacky',
-  version = '1.3.2',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = 'd6c07a0112ab2e9677fe085933744466a89232fb',
-)
-
-bower_component(
-  name = 'test-fixture',
-  package = 'polymerelements/test-fixture',
-  version = '1.1.1',
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = 'e373bd21c069163c3a754e234d52c07c77b20d3c',
-)
-
-bower_component(
-  name = 'web-animations-js',
-  package = 'web-animations/web-animations-js',
-  version = '2.2.1',
-  license = 'Apache2.0',
-  sha1 = '0e73b263a86aa6764ad35c273eb12055f83d7eda',
-)
-
-bower_component(
-  name = 'web-component-tester',
-  package = 'web-component-tester',
-  version = '4.2.2',
-  deps = [
-    ':accessibility-developer-tools',
-    ':async',
-    ':chai',
-    ':lodash',
-    ':mocha',
-    ':sinon-chai',
-    ':sinonjs',
-    ':stacky',
-    ':test-fixture',
-  ],
-  license = 'DO_NOT_DISTRIBUTE',
-  sha1 = '54556000c33d9ed7949aa546c1b4a1531491a5f0',
-)
-
-bower_component(
-  name = 'webcomponentsjs',
-  package = 'webcomponentsjs',
-  version = '0.7.22',
-  license = 'polymer',
-  sha1 = '8ba97a4a279ec6973a19b171c462a7b5cf454fb9',
-)
-
-# Zip highlightjs so that it can be imported as though it were a
-# bower_component and also attach the library license to the Buck dependency
-# graph.
-HLJS_DIR = 'bower_components/highlightjs'
-genrule(
-  name = 'highlightjs',
-  cmd = ' && '.join([
-    'mkdir -p %s' % HLJS_DIR,
-    'cp $(location //lib/highlightjs:highlightjs) %s/highlight.min.js' % HLJS_DIR,
-    'zip -r $OUT bower_components',
-  ]),
-  out = 'highlightjs.zip',
-  license = 'highlightjs',
-  visibility = ['PUBLIC'],
-)
diff --git a/lib/js/BUILD b/lib/js/BUILD
new file mode 100644
index 0000000..c19f9c0
--- /dev/null
+++ b/lib/js/BUILD
@@ -0,0 +1,36 @@
+load("//lib/js:bower_components.bzl", "define_bower_components")
+load("//tools/bzl:js.bzl", "js_component")
+
+package(default_visibility = ["//visibility:public"])
+
+# For importing new versions of existing bower packages,
+#
+# 1) edit the versions of 'seed' components in WORKSPACE as desired
+#
+# 2) Run: 'python tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl', to update dependency versions.
+#
+
+# For adding a new component as dependency to a bower_component_bundle
+#
+# 1) add a new bower_archive in WORKSPACE
+#
+# 2) add bower_component(name="my_new_dependency", seed=True) here
+#
+# 3) run bower2bazel (see above.)
+#
+# 4) remove bower_component(name="my_new_dependency", .. ) here
+#
+
+define_bower_components()
+
+js_component(
+    name = "highlightjs",
+    srcs = ["//lib/highlightjs:highlight.min.js"],
+    license = "//lib:LICENSE-highlightjs",
+)
+
+filegroup(
+    name = "highlightjs_files",
+    srcs = ["//lib/highlightjs:highlight.min.js"],
+    data = ["//lib:LICENSE-highlightjs"],
+)
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
new file mode 100644
index 0000000..1dcc76d
--- /dev/null
+++ b/lib/js/bower_archives.bzl
@@ -0,0 +1,129 @@
+# DO NOT EDIT
+# generated with the following command:
+#
+#   tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
+#
+
+load("//tools/bzl:js.bzl", "bower_archive")
+
+def load_bower_archives():
+    bower_archive(
+        name = "accessibility-developer-tools",
+        package = "accessibility-developer-tools",
+        version = "2.11.0",
+        sha1 = "792cb24b649dafb316e7e536f8ae65d0d7b52bab",
+    )
+    bower_archive(
+        name = "async",
+        package = "async",
+        version = "1.5.2",
+        sha1 = "1ec975d3b3834646a7e3d4b7e68118b90ed72508",
+    )
+    bower_archive(
+        name = "chai",
+        package = "chai",
+        version = "3.5.0",
+        sha1 = "849ad3ee7c77506548b7b5db603a4e150b9431aa",
+    )
+    bower_archive(
+        name = "iron-a11y-announcer",
+        package = "iron-a11y-announcer",
+        version = "1.0.5",
+        sha1 = "007902c041dd8863a1fe893f62450852f4d8c69b",
+    )
+    bower_archive(
+        name = "iron-a11y-keys-behavior",
+        package = "iron-a11y-keys-behavior",
+        version = "1.1.9",
+        sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465",
+    )
+    bower_archive(
+        name = "iron-behaviors",
+        package = "iron-behaviors",
+        version = "1.0.17",
+        sha1 = "47df7e1c2b97978dcafa13edb50fbdb702570acd",
+    )
+    bower_archive(
+        name = "iron-fit-behavior",
+        package = "iron-fit-behavior",
+        version = "1.2.5",
+        sha1 = "5938815cd227843fc77ebeac480b999600a76157",
+    )
+    bower_archive(
+        name = "iron-flex-layout",
+        package = "iron-flex-layout",
+        version = "1.3.1",
+        sha1 = "ba696394abff5e799fc06eb11bff4720129a1b52",
+    )
+    bower_archive(
+        name = "iron-form-element-behavior",
+        package = "iron-form-element-behavior",
+        version = "1.0.6",
+        sha1 = "8d9e6530edc1b99bec1a5c34853911fba3701220",
+    )
+    bower_archive(
+        name = "iron-meta",
+        package = "iron-meta",
+        version = "1.1.2",
+        sha1 = "dc22fe05e1cb5f94f30a7193d3433ca1808773b8",
+    )
+    bower_archive(
+        name = "iron-resizable-behavior",
+        package = "iron-resizable-behavior",
+        version = "1.0.5",
+        sha1 = "2ebe983377dceb3794dd335131050656e23e2beb",
+    )
+    bower_archive(
+        name = "iron-validatable-behavior",
+        package = "iron-validatable-behavior",
+        version = "1.1.1",
+        sha1 = "480423380be0536f948735d91bc472f6e7ced5b4",
+    )
+    bower_archive(
+        name = "lodash",
+        package = "lodash",
+        version = "3.10.1",
+        sha1 = "2f207a8293c4c554bf6cf071241f7a00dc513d3a",
+    )
+    bower_archive(
+        name = "mocha",
+        package = "mocha",
+        version = "3.2.0",
+        sha1 = "b77f23f7ad1f1363501bcae96f0f4f47745dad0f",
+    )
+    bower_archive(
+        name = "neon-animation",
+        package = "neon-animation",
+        version = "1.2.4",
+        sha1 = "e8ccbb930c4b7ff470b1450baa901618888a7fd3",
+    )
+    bower_archive(
+        name = "sinon-chai",
+        package = "sinon-chai",
+        version = "2.8.0",
+        sha1 = "0464b5d944fdf8116bb23e0b02ecfbac945b3517",
+    )
+    bower_archive(
+        name = "sinonjs",
+        package = "sinonjs",
+        version = "1.17.1",
+        sha1 = "a26a6aab7358807de52ba738770f6ac709afd240",
+    )
+    bower_archive(
+        name = "stacky",
+        package = "stacky",
+        version = "1.3.2",
+        sha1 = "d6c07a0112ab2e9677fe085933744466a89232fb",
+    )
+    bower_archive(
+        name = "web-animations-js",
+        package = "web-animations-js",
+        version = "2.2.2",
+        sha1 = "6276a9f227da7d4ccaf77c202b50e174dd11a2c2",
+    )
+    bower_archive(
+        name = "webcomponentsjs",
+        package = "webcomponentsjs",
+        version = "0.7.23",
+        sha1 = "3d62269e614175573b0a0f3039aab05d40f0a763",
+    )
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
new file mode 100644
index 0000000..14d8a04
--- /dev/null
+++ b/lib/js/bower_components.bzl
@@ -0,0 +1,222 @@
+# DO NOT EDIT
+# generated with the following command:
+#
+#   tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
+#
+
+load("//tools/bzl:js.bzl", "bower_component")
+
+def define_bower_components():
+    bower_component(
+        name = "accessibility-developer-tools",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "async",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "chai",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "es6-promise",
+        license = "//lib:LICENSE-es6-promise",
+        seed = True,
+    )
+    bower_component(
+        name = "fetch",
+        license = "//lib:LICENSE-fetch",
+        seed = True,
+    )
+    bower_component(
+        name = "iron-a11y-announcer",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-a11y-keys-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-autogrow-textarea",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-flex-layout",
+            ":iron-form-element-behavior",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-behaviors",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "iron-dropdown",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-behaviors",
+            ":iron-overlay-behavior",
+            ":iron-resizable-behavior",
+            ":neon-animation",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-fit-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-flex-layout",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-form-element-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-input",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-announcer",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-meta",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-overlay-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-fit-behavior",
+            ":iron-resizable-behavior",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-resizable-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+    )
+    bower_component(
+        name = "iron-selector",
+        license = "//lib:LICENSE-polymer",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-test-helpers",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-validatable-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "lodash",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "mocha",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "moment",
+        license = "//lib:LICENSE-moment",
+        seed = True,
+    )
+    bower_component(
+        name = "neon-animation",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":iron-resizable-behavior",
+            ":iron-selector",
+            ":polymer",
+            ":web-animations-js",
+        ],
+    )
+    bower_component(
+        name = "page",
+        license = "//lib:LICENSE-page.js",
+        seed = True,
+    )
+    bower_component(
+        name = "polymer",
+        license = "//lib:LICENSE-polymer",
+        deps = [":webcomponentsjs"],
+        seed = True,
+    )
+    bower_component(
+        name = "promise-polyfill",
+        license = "//lib:LICENSE-promise-polyfill",
+        deps = [":polymer"],
+        seed = True,
+    )
+    bower_component(
+        name = "sinon-chai",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "sinonjs",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "stacky",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+    )
+    bower_component(
+        name = "test-fixture",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        seed = True,
+    )
+    bower_component(
+        name = "web-animations-js",
+        license = "//lib:LICENSE-Apache2.0",
+    )
+    bower_component(
+        name = "web-component-tester",
+        license = "//lib:LICENSE-DO_NOT_DISTRIBUTE",
+        deps = [
+            ":accessibility-developer-tools",
+            ":async",
+            ":chai",
+            ":lodash",
+            ":mocha",
+            ":sinon-chai",
+            ":sinonjs",
+            ":stacky",
+            ":test-fixture",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "webcomponentsjs",
+        license = "//lib:LICENSE-polymer",
+    )
diff --git a/lib/jsoup/BUILD b/lib/jsoup/BUILD
new file mode 100644
index 0000000..7171901
--- /dev/null
+++ b/lib/jsoup/BUILD
@@ -0,0 +1,8 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "jsoup",
+    data = ["//lib:LICENSE-jsoup"],
+    visibility = ["//visibility:public"],
+    exports = ["@jsoup//jar"],
+)
diff --git a/lib/log/BUCK b/lib/log/BUCK
deleted file mode 100644
index a5201f3..0000000
--- a/lib/log/BUCK
+++ /dev/null
@@ -1,56 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VER = '1.7.7'
-
-maven_jar(
-  name = 'api',
-  id = 'org.slf4j:slf4j-api:' + VER,
-  sha1 = '2b8019b6249bb05d81d3a3094e468753e2b21311',
-  license = 'slf4j',
-)
-
-maven_jar(
-  name = 'nop',
-  id = 'org.slf4j:slf4j-nop:' + VER,
-  sha1 = '6cca9a3b999ff28b7a35ca762b3197cd7e4c2ad1',
-  license = 'slf4j',
-  deps = [':api'],
-)
-
-maven_jar(
-  name = 'impl_log4j',
-  id = 'org.slf4j:slf4j-log4j12:' + VER,
-  sha1 = '58f588119ffd1702c77ccab6acb54bfb41bed8bd',
-  license = 'slf4j',
-  deps = [':log4j'],
-)
-
-maven_jar(
-  name = 'jcl-over-slf4j',
-  id = 'org.slf4j:jcl-over-slf4j:' + VER,
-  sha1 = '56003dcd0a31deea6391b9e2ef2f2dc90b205a92',
-  license = 'slf4j',
-)
-
-maven_jar(
-  name = 'log4j',
-  id = 'log4j:log4j:1.2.17',
-  sha1 = '5af35056b4d257e4b64b9e8069c0746e8b08629f',
-  license = 'Apache2.0',
-  exclude = ['META-INF/LICENSE', 'META-INF/NOTICE'],
-)
-
-maven_jar(
-  name = 'jsonevent-layout',
-  id = 'net.logstash.log4j:jsonevent-layout:1.7',
-  sha1 = '507713504f0ddb75ba512f62763519c43cf46fde',
-  license = 'Apache2.0',
-  deps = [':json-smart', '//lib/commons:lang']
-)
-
-maven_jar(
-  name = 'json-smart',
-  id = 'net.minidev:json-smart:1.1.1',
-  sha1 = '24a2f903d25e004de30ac602c5b47f2d4e420a59',
-  license = 'Apache2.0',
-)
diff --git a/lib/log/BUILD b/lib/log/BUILD
index ac92ab6..607e914 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -1,47 +1,63 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'api',
-  exports = ['@log_api//jar'],
-  visibility = ['//visibility:public'],
+    name = "api",
+    data = ["//lib:LICENSE-slf4j"],
+    visibility = ["//visibility:public"],
+    exports = ["@log-api//jar"],
 )
 
 java_library(
-  name = 'nop',
-  exports = ['@log_nop//jar'],
-  runtime_deps = [':api'],
-  visibility = ['//visibility:public'],
+    name = "nop",
+    data = ["//lib:LICENSE-slf4j"],
+    visibility = ["//visibility:public"],
+    exports = ["@log-nop//jar"],
+    runtime_deps = [":api"],
 )
 
 java_library(
-  name = 'impl_log4j',
-  exports = ['@impl_log4j//jar'],
-  runtime_deps = [':log4j'],
-  visibility = ['//visibility:public'],
+    name = "ext",
+    data = ["//lib:LICENSE-slf4j"],
+    visibility = ["//visibility:public"],
+    exports = ["@log-ext//jar"],
 )
 
 java_library(
-  name = 'jcl-over-slf4j',
-  exports = ['@jcl_over_slf4j//jar'],
-  visibility = ['//visibility:public'],
+    name = "impl-log4j",
+    data = ["//lib:LICENSE-slf4j"],
+    visibility = ["//visibility:public"],
+    exports = ["@impl-log4j//jar"],
+    runtime_deps = [":log4j"],
 )
 
 java_library(
-  name = 'log4j',
-  exports = ['@log4j//jar'],
-  visibility = ['//visibility:public'],
+    name = "jcl-over-slf4j",
+    data = ["//lib:LICENSE-slf4j"],
+    visibility = ["//visibility:public"],
+    exports = ["@jcl-over-slf4j//jar"],
 )
 
 java_library(
-  name = 'jsonevent-layout',
-  exports = ['@jsonevent_layout//jar'],
-  runtime_deps = [
-    ':json-smart',
-    '//lib/commons:lang'
-  ],
-  visibility = ['//visibility:public'],
+    name = "log4j",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@log4j//jar"],
 )
 
 java_library(
-  name = 'json-smart',
-  exports = ['@json_smart//jar'],
-  visibility = ['//visibility:public'],
+    name = "jsonevent-layout",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jsonevent-layout//jar"],
+    runtime_deps = [
+        ":json-smart",
+        "//lib/commons:lang",
+    ],
+)
+
+java_library(
+    name = "json-smart",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@json-smart//jar"],
 )
diff --git a/lib/lucene/BUCK b/lib/lucene/BUCK
deleted file mode 100644
index c4a9872..0000000
--- a/lib/lucene/BUCK
+++ /dev/null
@@ -1,75 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VERSION = '5.5.0'
-
-# core and backward-codecs both provide
-# META-INF/services/org.apache.lucene.codecs.Codec, so they must be merged.
-merge_maven_jars(
-  name = 'lucene-core-and-backward-codecs',
-  srcs = [
-    ':backward-codecs_jar',
-    ':lucene-core',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'lucene-core',
-  id = 'org.apache.lucene:lucene-core:' + VERSION,
-  sha1 = 'a74fd869bb5ad7fe6b4cd29df9543a34aea81164',
-  license = 'Apache2.0',
-  exclude = [
-    'META-INF/LICENSE.txt',
-    'META-INF/NOTICE.txt',
-  ],
-  visibility = [],
-)
-
-maven_jar(
-  name = 'lucene-analyzers-common',
-  id = 'org.apache.lucene:lucene-analyzers-common:' + VERSION,
-  sha1 = '1e0e8243a4410be20c34683034fafa7bb52e55cc',
-  license = 'Apache2.0',
-  deps = [':lucene-core-and-backward-codecs'],
-  exclude = [
-    'META-INF/LICENSE.txt',
-    'META-INF/NOTICE.txt',
-  ],
-)
-
-maven_jar(
-  name = 'backward-codecs_jar',
-  id = 'org.apache.lucene:lucene-backward-codecs:' + VERSION,
-  sha1 = '68480974b2f54f519763632a7c1c5d51cbff3805',
-  license = 'Apache2.0',
-  deps = [':lucene-core'],
-  exclude = [
-    'META-INF/LICENSE.txt',
-    'META-INF/NOTICE.txt',
-  ],
-  visibility = [],
-)
-
-maven_jar(
-  name = 'lucene-misc',
-  id = 'org.apache.lucene:lucene-misc:' + VERSION,
-  sha1 = '504d855a1a38190622fdf990b2298c067e7d60ca',
-  license = 'Apache2.0',
-  deps = [':lucene-core-and-backward-codecs'],
-  exclude = [
-    'META-INF/LICENSE.txt',
-    'META-INF/NOTICE.txt',
-  ],
-)
-
-maven_jar(
-  name = 'lucene-queryparser',
-  id = 'org.apache.lucene:lucene-queryparser:' + VERSION,
-  sha1 = '0fddc49725b562fd48dff0cff004336ad2a090a4',
-  license = 'Apache2.0',
-  deps = [':lucene-core-and-backward-codecs'],
-  exclude = [
-    'META-INF/LICENSE.txt',
-    'META-INF/NOTICE.txt',
-  ],
-)
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD
index 679c9f0..b8b2457 100644
--- a/lib/lucene/BUILD
+++ b/lib/lucene/BUILD
@@ -1,33 +1,42 @@
-load('//tools/bzl:maven.bzl', 'merge_maven_jars')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:maven.bzl", "merge_maven_jars")
+
+package(default_visibility = ["//visibility:public"])
 
 # core and backward-codecs both provide
 # META-INF/services/org.apache.lucene.codecs.Codec, so they must be merged.
 merge_maven_jars(
-  name = 'lucene-core-and-backward-codecs',
-  srcs = [
-    '@backward_codecs//jar',
-    '@lucene_core//jar',
-  ],
-  visibility = ['//visibility:public'],
+    name = "lucene-core-and-backward-codecs",
+    srcs = [
+        "@backward-codecs//jar",
+        "@lucene-core//jar",
+    ],
+    data = ["//lib:LICENSE-Apache2.0"],
 )
 
 java_library(
-  name = 'lucene-analyzers-common',
-  exports = ['@lucene_analyzers_common//jar'],
-  runtime_deps = [':lucene-core-and-backward-codecs'],
-  visibility = ['//visibility:public'],
+    name = "lucene-analyzers-common",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@lucene-analyzers-common//jar"],
+    runtime_deps = [":lucene-core-and-backward-codecs"],
 )
 
 java_library(
-  name = 'lucene-misc',
-  exports = ['@lucene_misc//jar'],
-  runtime_deps = [':lucene-core-and-backward-codecs'],
-  visibility = ['//visibility:public'],
+    name = "lucene-core",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@lucene-core//jar"],
 )
 
 java_library(
-  name = 'lucene-queryparser',
-  exports = ['@lucene_queryparser//jar'],
-  runtime_deps = [':lucene-core-and-backward-codecs'],
-  visibility = ['//visibility:public'],
+    name = "lucene-misc",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@lucene-misc//jar"],
+    runtime_deps = [":lucene-core-and-backward-codecs"],
+)
+
+java_library(
+    name = "lucene-queryparser",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@lucene-queryparser//jar"],
+    runtime_deps = [":lucene-core-and-backward-codecs"],
 )
diff --git a/lib/mail/BUILD b/lib/mail/BUILD
new file mode 100644
index 0000000..489f544
--- /dev/null
+++ b/lib/mail/BUILD
@@ -0,0 +1,8 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "mail",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = ["@mail//jar"],
+)
diff --git a/lib/maven.defs b/lib/maven.defs
deleted file mode 100644
index fa24998..0000000
--- a/lib/maven.defs
+++ /dev/null
@@ -1,189 +0,0 @@
-# Copyright (C) 2013 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.
-
-ECLIPSE = 'ECLIPSE:'
-GERRIT = 'GERRIT:'
-GERRIT_API = 'GERRIT_API:'
-MAVEN_CENTRAL = 'MAVEN_CENTRAL:'
-MAVEN_LOCAL = 'MAVEN_LOCAL:'
-MAVEN_SNAPSHOT = 'MAVEN_SNAPSHOT:'
-
-def define_license(name):
-  n = 'LICENSE-' + name
-  genrule(
-    name = n,
-    cmd = 'ln -s $SRCS $OUT',
-    srcs = [n],
-    out = n,
-    visibility = ['PUBLIC'],
-  )
-
-def maven_jar(
-    name,
-    id,
-    license,
-    exclude = [],
-    exclude_java_sources = False,
-    unsign = False,
-    deps = [],
-    exported_deps = [],
-    sha1 = '', bin_sha1 = '', src_sha1 = '',
-    repository = MAVEN_CENTRAL,
-    attach_source = True,
-    visibility = ['PUBLIC'],
-    local_license = False):
-  from os import path
-
-  def maven_snapshot(parts):
-    if len(parts) != 4:
-      raise NameError('%s:\nexpected id="groupId:artifactId:version:snapshot]"'
-                      % id)
-    group, artifact, version, snapshot = parts
-    jar = path.join(name,
-      version + '-SNAPSHOT',
-      '-'.join([artifact.lower(), version, snapshot]))
-    url = '/'.join([
-      repository,
-      group.replace('.', '/'),
-      artifact,
-      version + '-SNAPSHOT',
-      '-'.join([artifact.lower(), version, snapshot])])
-    return jar, url
-
-  def maven_release(parts):
-    if len(parts) not in [3, 4]:
-      raise NameError('%s:\nexpected id="groupId:artifactId:version[:classifier]"'
-                      % id)
-    if len(parts) == 4:
-      group, artifact, version, classifier = parts
-      file_version = version + '-' + classifier
-    else:
-      group, artifact, version = parts
-      file_version = version
-
-    jar = path.join(name, artifact.lower() + '-' + file_version)
-    url = '/'.join([
-      repository,
-      group.replace('.', '/'),
-      artifact,
-      version,
-      artifact + '-' + file_version])
-
-    return jar, url
-
-  parts = id.split(':')
-  if repository.startswith(MAVEN_SNAPSHOT):
-    jar, url = maven_snapshot(parts)
-  else:
-    jar, url = maven_release(parts)
-
-  binjar = jar + '.jar'
-  binurl = url + '.jar'
-
-  srcjar = jar + '-src.jar'
-  srcurl = url + '-sources.jar'
-
-  cmd = ['$(exe //tools:download_file)', '-o', '$OUT', '-u', binurl]
-  if sha1:
-    cmd.extend(['-v', sha1])
-  elif bin_sha1:
-    cmd.extend(['-v', bin_sha1])
-  for x in exclude:
-    cmd.extend(['-x', x])
-  if exclude_java_sources:
-    cmd.append('--exclude_java_sources')
-  if unsign:
-    cmd.append('--unsign')
-
-  genrule(
-    name = '%s__download_bin' % name,
-    cmd = ' '.join(cmd),
-    out = binjar,
-  )
-  license = ':LICENSE-' + license
-  if not local_license:
-    license = '//lib' + license
-  license = [license]
-
-  if src_sha1 or attach_source:
-    cmd = ['$(exe //tools:download_file)', '-o', '$OUT', '-u', srcurl]
-    if src_sha1:
-      cmd.extend(['-v', src_sha1])
-    genrule(
-      name = '%s__download_src' % name,
-      cmd = ' '.join(cmd),
-      out = srcjar,
-    )
-    prebuilt_jar(
-      name = '%s_src' % name,
-      binary_jar = ':%s__download_src' % name,
-      deps = license,
-      visibility = visibility,
-    )
-  else:
-    srcjar = None
-    genrule(
-      name = '%s_src' % name,
-      cmd = ':>$OUT',
-      out = '__%s__no_src' % name,
-    )
-
-  if exported_deps:
-    prebuilt_jar(
-      name = '%s__jar' % name,
-      deps = deps + license,
-      binary_jar = ':%s__download_bin' % name,
-      source_jar = ':%s__download_src' % name if srcjar else None,
-    )
-    java_library(
-      name = name,
-      exported_deps = exported_deps + [':' + name + '__jar'],
-      visibility = visibility,
-    )
-  else:
-    prebuilt_jar(
-      name = name,
-      deps = deps + license,
-      binary_jar = ':%s__download_bin' % name,
-      source_jar = ':%s__download_src' % name if srcjar else None,
-      visibility = visibility,
-    )
-
-
-def merge_maven_jars(
-    name,
-    srcs,
-    visibility = []):
-
-  def cmd(jars):
-    return ('$(location //tools:merge_jars) $OUT '
-            + ' '.join(['$(location %s)' % j for j in jars]))
-
-  genrule(
-    name = '%s__merged_bin' % name,
-    cmd = cmd(['%s__download_bin' % s for s in srcs]),
-    out = '%s__merged.jar' % name,
-  )
-  genrule(
-    name = '%s__merged_src' % name,
-    cmd = cmd(['%s__download_src' % s for s in srcs]),
-    # tools/eclipse/project.py requires -src.jar suffix.
-    out = '%s__merged-src.jar' % name,
-  )
-  prebuilt_jar(
-    name = name,
-    binary_jar = ':%s__merged_bin' % name,
-    source_jar = ':%s__merged_src' % name,
-    visibility = visibility,
-  )
diff --git a/lib/mime4j/BUILD b/lib/mime4j/BUILD
new file mode 100644
index 0000000..577661d
--- /dev/null
+++ b/lib/mime4j/BUILD
@@ -0,0 +1,15 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "core",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@mime4j-core//jar"],
+)
+
+java_library(
+    name = "dom",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@mime4j-dom//jar"],
+)
diff --git a/lib/mina/BUCK b/lib/mina/BUCK
deleted file mode 100644
index 60e9655..0000000
--- a/lib/mina/BUCK
+++ /dev/null
@@ -1,26 +0,0 @@
-include_defs('//lib/maven.defs')
-
-EXCLUDE = [
-  'META-INF/DEPENDENCIES',
-  'META-INF/LICENSE',
-  'META-INF/NOTICE',
-]
-
-maven_jar(
-  name = 'sshd',
-  id = 'org.apache.sshd:sshd-core:1.4.0',
-  sha1 = 'c8f3d7457fc9979d1b9ec319f0229b89793c8e56',
-  src_sha1 = '7dbe0edbd2362b58778bbed77407f2e0ded08fcd',
-  license = 'Apache2.0',
-  deps = [':core'],
-  exclude = EXCLUDE,
-)
-
-maven_jar(
-  name = 'core',
-  id = 'org.apache.mina:mina-core:2.0.16',
-  sha1 = 'f720f17643eaa7b0fec07c1d7f6272972c02bba4',
-  src_sha1 = '660fb813ca1c8d8a936f894324091400a5ac128a',
-  license = 'Apache2.0',
-  exclude = EXCLUDE,
-)
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 52468a4..a9a6f71 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -1,12 +1,28 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'sshd',
-  exports = ['@sshd//jar'],
-  visibility = ['//visibility:public'],
-  runtime_deps = [':core'],
+    name = "sshd",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":eddsa",
+        "@sshd//jar",
+    ],
+    runtime_deps = [":core"],
 )
 
 java_library(
-  name = 'core',
-  exports = ['@mina_core//jar'],
-  visibility = ['//visibility:public'],
+    name = "eddsa",
+    data = ["//lib:LICENSE-CC0-1.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@eddsa//jar",
+    ],
+)
+
+java_library(
+    name = "core",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@mina-core//jar"],
 )
diff --git a/lib/openid/BUCK b/lib/openid/BUCK
deleted file mode 100644
index 728698b..0000000
--- a/lib/openid/BUCK
+++ /dev/null
@@ -1,35 +0,0 @@
-include_defs('//lib/maven.defs')
-
-maven_jar(
-  name = 'consumer',
-  id = 'org.openid4java:openid4java:0.9.8',
-  sha1 = 'de4f1b33d3b0f0b2ab1d32834ec1190b39db4160',
-  license = 'Apache2.0',
-  deps = [
-    ':nekohtml',
-    ':xerces',
-    '//lib/httpcomponents:httpclient',
-    '//lib/log:jcl-over-slf4j',
-    '//lib/guice:guice',
-  ],
-  visibility = ['PUBLIC'],
-)
-
-maven_jar(
-  name = 'nekohtml',
-  id = 'net.sourceforge.nekohtml:nekohtml:1.9.10',
-  sha1 = '14052461031a7054aa094f5573792feb6686d3de',
-  license = 'Apache2.0',
-  deps = [':xerces'],
-  attach_source = False,
-  visibility = [],
-)
-
-maven_jar(
-  name = 'xerces',
-  id = 'xerces:xercesImpl:2.8.1',
-  sha1 = '25101e37ec0c907db6f0612cbf106ee519c1aef1',
-  license = 'Apache2.0',
-  attach_source = False,
-  visibility = [],
-)
diff --git a/lib/openid/BUILD b/lib/openid/BUILD
index 7d97a86..c27e8ab 100644
--- a/lib/openid/BUILD
+++ b/lib/openid/BUILD
@@ -1,23 +1,28 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'consumer',
-  exports = ['@openid_consumer//jar'],
-  runtime_deps = [
-    ':nekohtml',
-    ':xerces',
-    '//lib/httpcomponents:httpclient',
-    '//lib/log:jcl-over-slf4j',
-    '//lib/guice:guice',
-  ],
-  visibility = ['//visibility:public'],
+    name = "consumer",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@openid-consumer//jar"],
+    runtime_deps = [
+        ":nekohtml",
+        ":xerces",
+        "//lib/guice",
+        "//lib/httpcomponents:httpclient",
+        "//lib/log:jcl-over-slf4j",
+    ],
 )
 
 java_library(
-  name = 'nekohtml',
-  exports = ['@nekohtml//jar'],
-  runtime_deps = [':xerces'],
+    name = "nekohtml",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@nekohtml//jar"],
+    runtime_deps = [":xerces"],
 )
 
 java_library(
-  name = 'xerces',
-  exports = ['@xerces//jar'],
+    name = "xerces",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@xerces//jar"],
 )
diff --git a/lib/ow2/BUCK b/lib/ow2/BUCK
deleted file mode 100644
index fabcb25..0000000
--- a/lib/ow2/BUCK
+++ /dev/null
@@ -1,40 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VERSION = '5.0.3'
-
-maven_jar(
-  name = 'ow2-asm',
-  id = 'org.ow2.asm:asm:' + VERSION,
-  sha1 = 'dcc2193db20e19e1feca8b1240dbbc4e190824fa',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-analysis',
-  id = 'org.ow2.asm:asm-analysis:' + VERSION,
-  sha1 = 'c7126aded0e8e13fed5f913559a0dd7b770a10f3',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-commons',
-  id = 'org.ow2.asm:asm-commons:' + VERSION,
-  sha1 = 'a7111830132c7f87d08fe48cb0ca07630f8cb91c',
-  deps = [':ow2-asm-tree'],
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-tree',
-  id = 'org.ow2.asm:asm-tree:' + VERSION,
-  sha1 = '287749b48ba7162fb67c93a026d690b29f410bed',
-  license = 'ow2',
-)
-
-maven_jar(
-  name = 'ow2-asm-util',
-  id = 'org.ow2.asm:asm-util:' + VERSION,
-  sha1 = '1512e5571325854b05fb1efce1db75fcced54389',
-  license = 'ow2',
-)
-
diff --git a/lib/ow2/BUILD b/lib/ow2/BUILD
index 0b99b6f..7fe7e2d 100644
--- a/lib/ow2/BUILD
+++ b/lib/ow2/BUILD
@@ -1,30 +1,37 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'ow2-asm',
-  exports = ['@ow2_asm//jar'],
-  visibility = ["//visibility:public"],
+    name = "ow2-asm",
+    data = ["//lib:LICENSE-ow2"],
+    visibility = ["//visibility:public"],
+    exports = ["@ow2-asm//jar"],
 )
 
 java_library(
-  name = 'ow2-asm-analysis',
-  exports = ['@ow2_asm_analysis//jar'],
-  visibility = ["//visibility:public"],
+    name = "ow2-asm-analysis",
+    data = ["//lib:LICENSE-ow2"],
+    visibility = ["//visibility:public"],
+    exports = ["@ow2-asm-analysis//jar"],
 )
 
 java_library(
-  name = 'ow2-asm-commons',
-  exports = ['@ow2_asm_commons//jar'],
-  runtime_deps = [':ow2-asm-tree'],
-  visibility = ["//visibility:public"],
+    name = "ow2-asm-commons",
+    data = ["//lib:LICENSE-ow2"],
+    visibility = ["//visibility:public"],
+    exports = ["@ow2-asm-commons//jar"],
+    runtime_deps = [":ow2-asm-tree"],
 )
 
 java_library(
-  name = 'ow2-asm-tree',
-  exports = ['@ow2_asm_tree//jar'],
-  visibility = ["//visibility:public"],
+    name = "ow2-asm-tree",
+    data = ["//lib:LICENSE-ow2"],
+    visibility = ["//visibility:public"],
+    exports = ["@ow2-asm-tree//jar"],
 )
 
 java_library(
-  name = 'ow2-asm-util',
-  exports = ['@ow2_asm_util//jar'],
-  visibility = ["//visibility:public"],
+    name = "ow2-asm-util",
+    data = ["//lib:LICENSE-ow2"],
+    visibility = ["//visibility:public"],
+    exports = ["@ow2-asm-util//jar"],
 )
diff --git a/lib/polymer_externs/BUILD b/lib/polymer_externs/BUILD
new file mode 100644
index 0000000..f07aa2f
--- /dev/null
+++ b/lib/polymer_externs/BUILD
@@ -0,0 +1,24 @@
+# Copyright (C) 2017 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
+
+package(default_visibility = ["//visibility:public"])
+
+closure_js_library(
+    name = "polymer_closure",
+    srcs = ["@polymer_closure//file"],
+    data = ["//lib:LICENSE-Apache2.0"],
+    no_closure_library = True,
+)
diff --git a/lib/powermock/BUCK b/lib/powermock/BUCK
deleted file mode 100644
index b642457..0000000
--- a/lib/powermock/BUCK
+++ /dev/null
@@ -1,73 +0,0 @@
-include_defs('//lib/maven.defs')
-
-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 = '8692eb1d9bb8eb1310ffe8a20c2da7ee6d1b5994',
-  license = 'DO_NOT_DISTRIBUTE',
-  deps = [
-    ':powermock-module-junit4-common',
-    '//lib:junit',
-  ],
-)
-
-maven_jar(
-  name = 'powermock-module-junit4-common',
-  id = 'org.powermock:powermock-module-junit4-common:' + VERSION,
-  sha1 = 'b0b578da443794ceb8224bd5f5f852aaf40f1b81',
-  license = 'DO_NOT_DISTRIBUTE',
-  deps = [
-    ':powermock-reflect',
-    '//lib:junit',
-  ],
-)
-
-maven_jar(
-  name = 'powermock-reflect',
-  id = 'org.powermock:powermock-reflect:' + VERSION,
-  sha1 = '5532f4e7c42db4bca4778bc9f1afcd4b0ee0b893',
-  license = 'DO_NOT_DISTRIBUTE',
-  deps = [
-    '//lib:junit',
-    '//lib/easymock:objenesis',
-  ],
-)
-
-maven_jar(
-  name = 'powermock-api-easymock',
-  id = 'org.powermock:powermock-api-easymock:' + VERSION,
-  sha1 = '5c385a0d8c13f84b731b75c6e90319c532f80b45',
-  license = 'DO_NOT_DISTRIBUTE',
-  deps = [
-    ':powermock-api-support',
-    '//lib/easymock:easymock',
-  ],
-)
-
-maven_jar(
-  name = 'powermock-api-support',
-  id = 'org.powermock:powermock-api-support:' + VERSION,
-  sha1 = '314daafb761541293595630e10a3699ebc07881d',
-  license = 'DO_NOT_DISTRIBUTE',
-  deps = [
-    ':powermock-core',
-    ':powermock-reflect',
-    '//lib:junit',
-  ],
-)
-
-maven_jar(
-  name = 'powermock-core',
-  id = 'org.powermock:powermock-core:' + VERSION,
-  sha1 = '85fb32e9ccba748d569fc36aef92e0b9e7f40b87',
-  license = 'DO_NOT_DISTRIBUTE',
-  deps = [
-    ':powermock-reflect',
-    '//lib:javassist',
-    '//lib:junit',
-  ],
-)
-
diff --git a/lib/powermock/BUILD b/lib/powermock/BUILD
index 8dc7d23..39df164 100644
--- a/lib/powermock/BUILD
+++ b/lib/powermock/BUILD
@@ -1,60 +1,69 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
 java_library(
-  name = 'powermock-module-junit4',
-  exports = [
-    '@powermock_module_junit4//jar',
-    ':powermock-module-junit4-common',
-    '//lib:junit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "powermock-module-junit4",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":powermock-module-junit4-common",
+        "//lib:junit",
+        "@powermock-module-junit4//jar",
+    ],
 )
 
 java_library(
-  name = 'powermock-module-junit4-common',
-  exports = [
-    '@powermock_module_junit4_common//jar',
-    ':powermock-reflect',
-    '//lib:junit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "powermock-module-junit4-common",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":powermock-reflect",
+        "//lib:junit",
+        "@powermock-module-junit4-common//jar",
+    ],
 )
 
 java_library(
-  name = 'powermock-reflect',
-  exports = [
-    '@powermock_reflect//jar',
-    '//lib:junit',
-    '//lib/easymock:objenesis',
-  ],
-  visibility = ['//visibility:public'],
+    name = "powermock-reflect",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        "//lib:junit",
+        "//lib/easymock:objenesis",
+        "@powermock-reflect//jar",
+    ],
 )
 
 java_library(
-  name = 'powermock-api-easymock',
-  exports = [
-    '@powermock_api_easymock//jar',
-    ':powermock-api-support',
-    '//lib/easymock:easymock',
-  ],
-  visibility = ['//visibility:public'],
+    name = "powermock-api-easymock",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":powermock-api-support",
+        "//lib/easymock",
+        "@powermock-api-easymock//jar",
+    ],
 )
 
 java_library(
-  name = 'powermock-api-support',
-  exports = [
-    '@powermock_api_support//jar',
-    ':powermock-core',
-    ':powermock-reflect',
-    '//lib:junit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "powermock-api-support",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":powermock-core",
+        ":powermock-reflect",
+        "//lib:junit",
+        "@powermock-api-support//jar",
+    ],
 )
 
 java_library(
-  name = 'powermock-core',
-  exports = [
-    ':powermock-reflect',
-    '//lib:javassist',
-    '//lib:junit',
-  ],
-  visibility = ['//visibility:public'],
+    name = "powermock-core",
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    visibility = ["//visibility:public"],
+    exports = [
+        ":powermock-reflect",
+        "//lib:javassist",
+        "//lib:junit",
+        "@powermock-core//jar",
+    ],
 )
diff --git a/lib/prolog/BUCK b/lib/prolog/BUCK
deleted file mode 100644
index 77fe5ac..0000000
--- a/lib/prolog/BUCK
+++ /dev/null
@@ -1,64 +0,0 @@
-include_defs('//lib/maven.defs')
-
-VERSION = '1.4.1'
-REPO = GERRIT
-
-maven_jar(
-  name = 'runtime',
-  id = 'com.googlecode.prolog-cafe:prolog-runtime:' + VERSION,
-  sha1 = 'c5d9f92e49c485969dcd424dfc0c08125b5f8246',
-  license = 'prologcafe',
-  repository = REPO,
-)
-
-maven_jar(
-  name = 'compiler',
-  id = 'com.googlecode.prolog-cafe:prolog-compiler:' + VERSION,
-  sha1 = 'ac24044c6ec166fdcb352b78b80d187ead3eff41',
-  license = 'prologcafe',
-  repository = REPO,
-  deps = [
-    ':io',
-    ':runtime',
-  ],
-)
-
-maven_jar(
-  name = 'io',
-  id = 'com.googlecode.prolog-cafe:prolog-io:' + VERSION,
-  sha1 = 'b072426a4b1b8af5e914026d298ee0358a8bb5aa',
-  license = 'prologcafe',
-  repository = REPO,
-  deps = [':runtime'],
-  visibility = [],
-)
-
-maven_jar(
-  name = 'cafeteria',
-  id = 'com.googlecode.prolog-cafe:prolog-cafeteria:' + VERSION,
-  sha1 = '8cbc3b0c19e7167c42d3f11667b21cb21ddec641',
-  license = 'prologcafe',
-  repository = REPO,
-  deps = [
-    ':io',
-    ':runtime',
-  ],
-  visibility = ['//gerrit-pgm:'],
-)
-
-java_binary(
-  name = 'compiler_bin',
-  main_class = 'BuckPrologCompiler',
-  deps = [':compiler_lib'],
-  visibility = ['PUBLIC'],
-)
-
-java_library(
-  name = 'compiler_lib',
-  srcs = ['java/BuckPrologCompiler.java'],
-  deps = [
-    ':compiler',
-    ':runtime',
-  ],
-  visibility = ['//tools/eclipse:classpath'],
-)
diff --git a/lib/prolog/BUILD b/lib/prolog/BUILD
index 74d8b80..fa55682 100644
--- a/lib/prolog/BUILD
+++ b/lib/prolog/BUILD
@@ -1,47 +1,60 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+
 java_library(
-  name = 'runtime',
-  exports = ['@prolog_runtime//jar'],
-  visibility = ['//visibility:public'],
+    name = "runtime",
+    data = ["//lib:LICENSE-prologcafe"],
+    visibility = ["//visibility:public"],
+    exports = ["@prolog-runtime//jar"],
 )
 
 java_library(
-  name = 'compiler',
-  exports = ['@prolog_compiler//jar'],
-  runtime_deps = [
-    ':io',
-    ':runtime',
-  ],
-  visibility = ['//visibility:public'],
+    name = "runtime-neverlink",
+    data = ["//lib:LICENSE-prologcafe"],
+    visibility = ["//visibility:public"],
+    exports = ["@prolog-runtime//jar:neverlink"],
 )
 
 java_library(
-  name = 'io',
-  exports = ['@prolog_io//jar'],
+    name = "compiler",
+    data = ["//lib:LICENSE-prologcafe"],
+    visibility = ["//visibility:public"],
+    exports = ["@prolog-compiler//jar"],
+    runtime_deps = [
+        ":io",
+        ":runtime",
+    ],
 )
 
 java_library(
-  name = 'cafeteria',
-  exports = ['@cafeteria//jar'],
-  runtime_deps = [
-    'io',
-    'runtime',
-  ],
-  visibility = ['//visibility:public'],
+    name = "io",
+    data = ["//lib:LICENSE-prologcafe"],
+    exports = ["@prolog-io//jar"],
+)
+
+java_library(
+    name = "cafeteria",
+    data = ["//lib:LICENSE-prologcafe"],
+    visibility = ["//visibility:public"],
+    exports = ["@cafeteria//jar"],
+    runtime_deps = [
+        "io",
+        "runtime",
+    ],
 )
 
 java_binary(
-  name = 'compiler_bin',
-  main_class = 'BuckPrologCompiler',
-  runtime_deps = [':compiler_lib'],
-  visibility = ['//visibility:public'],
+    name = "compiler-bin",
+    main_class = "BazelPrologCompiler",
+    visibility = ["//visibility:public"],
+    runtime_deps = [":compiler-lib"],
 )
 
 java_library(
-  name = 'compiler_lib',
-  srcs = ['java/BuckPrologCompiler.java'],
-  deps = [
-    ':compiler',
-    ':runtime',
-  ],
-  visibility = ['//visibility:public'],
+    name = "compiler-lib",
+    srcs = ["java/BazelPrologCompiler.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":compiler",
+        ":runtime",
+    ],
 )
diff --git a/lib/prolog/java/BazelPrologCompiler.java b/lib/prolog/java/BazelPrologCompiler.java
new file mode 100644
index 0000000..37ea696
--- /dev/null
+++ b/lib/prolog/java/BazelPrologCompiler.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2013 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.
+
+import com.googlecode.prolog_cafe.compiler.Compiler;
+import com.googlecode.prolog_cafe.exceptions.CompileException;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+public class BazelPrologCompiler {
+  private static File tmpdir;
+
+  public static void main(String[] argv) throws IOException, CompileException {
+    int i = 0;
+    tmpdir = new File(argv[i++]);
+    File out = new File(argv[i++]);
+    File java = tmpdir("java");
+    for (; i < argv.length; i++) {
+      new Compiler().prologToJavaSource(argv[i], java.getPath());
+    }
+    jar(out, java);
+  }
+
+  private static File tmpdir(String name) throws IOException {
+    File d = File.createTempFile(name + "_", "", tmpdir);
+    if (!d.delete() || !d.mkdir()) {
+      throw new IOException("Cannot mkdir " + d);
+    }
+    return d;
+  }
+
+  private static void jar(File jar, File classes) throws IOException {
+    File tmp = File.createTempFile("prolog", ".jar", tmpdir);
+    try {
+      try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(tmp.toPath()))) {
+        add(out, classes, "");
+      }
+      if (!tmp.renameTo(jar)) {
+        throw new IOException("Cannot create " + jar);
+      }
+    } finally {
+      tmp.delete();
+    }
+  }
+
+  private static void add(JarOutputStream out, File classes, String prefix) throws IOException {
+    String[] list = classes.list();
+    if (list == null) {
+      return;
+    }
+    for (String name : list) {
+      File f = new File(classes, name);
+      if (f.isDirectory()) {
+        add(out, f, prefix + name + "/");
+        continue;
+      }
+
+      JarEntry e = new JarEntry(prefix + name);
+      try (InputStream in = Files.newInputStream(f.toPath())) {
+        e.setTime(f.lastModified());
+        out.putNextEntry(e);
+        byte[] buf = new byte[16 << 10];
+        int n;
+        while (0 < (n = in.read(buf))) {
+          out.write(buf, 0, n);
+        }
+      } finally {
+        out.closeEntry();
+      }
+    }
+  }
+}
diff --git a/lib/prolog/java/BuckPrologCompiler.java b/lib/prolog/java/BuckPrologCompiler.java
deleted file mode 100644
index 6010be2..0000000
--- a/lib/prolog/java/BuckPrologCompiler.java
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (C) 2013 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.
-
-import com.googlecode.prolog_cafe.compiler.Compiler;
-import com.googlecode.prolog_cafe.exceptions.CompileException;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.util.jar.JarEntry;
-import java.util.jar.JarOutputStream;
-
-public class BuckPrologCompiler {
-  private static File tmpdir;
-
-  public static void main(String[] argv) throws IOException, CompileException {
-    int i = 0;
-    tmpdir = new File(argv[i++]);
-    File out = new File(argv[i++]);
-    File java = tmpdir("java");
-    for (; i < argv.length; i++) {
-      new Compiler().prologToJavaSource(argv[i], java.getPath());
-    }
-    jar(out, java);
-  }
-
-  private static File tmpdir(String name) throws IOException {
-    File d = File.createTempFile(name + "_", "", tmpdir);
-    if (!d.delete() || !d.mkdir()) {
-      throw new IOException("Cannot mkdir " + d);
-    }
-    return d;
-  }
-
-  private static void jar(File jar, File classes) throws IOException {
-    File tmp = File.createTempFile("prolog", ".jar", tmpdir);
-    try {
-      try (JarOutputStream out = new JarOutputStream(new FileOutputStream(tmp))) {
-        add(out, classes, "");
-      }
-      if (!tmp.renameTo(jar)) {
-        throw new IOException("Cannot create " + jar);
-      }
-    } finally {
-      tmp.delete();
-    }
-  }
-
-  private static void add(JarOutputStream out, File classes, String prefix)
-      throws IOException {
-    String[] list = classes.list();
-    if (list == null) {
-      return;
-    }
-    for (String name : list) {
-      File f = new File(classes, name);
-      if (f.isDirectory()) {
-        add(out, f, prefix + name + "/");
-        continue;
-      }
-
-      JarEntry e = new JarEntry(prefix + name);
-      try (FileInputStream in = new FileInputStream(f)) {
-        e.setTime(f.lastModified());
-        out.putNextEntry(e);
-        byte[] buf = new byte[16 << 10];
-        int n;
-        while (0 < (n = in.read(buf))) {
-          out.write(buf, 0, n);
-        }
-      } finally {
-        out.closeEntry();
-      }
-    }
-  }
-}
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index d4e9e08..528ca11 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -12,25 +12,26 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-load('//tools/bzl:genrule2.bzl', 'genrule2')
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:genrule2.bzl", "genrule2")
 
 def prolog_cafe_library(
-    name,
-    srcs,
-    deps = [],
-    visibility = []):
-  genrule2(
-    name = name + '__pl2j',
-    cmd = '$(location //lib/prolog:compiler_bin) ' +
-      '$$(dirname $@) $@ ' +
-      '$(SRCS)',
-    srcs = srcs,
-    tools = ['//lib/prolog:compiler_bin'],
-    out = name + '.srcjar',
-  )
-  native.java_library(
-    name = name,
-    srcs = [':' + name + '__pl2j'],
-    deps = ['//lib/prolog:runtime'] + deps,
-    visibility = visibility,
-  )
+        name,
+        srcs,
+        deps = [],
+        **kwargs):
+    genrule2(
+        name = name + "__pl2j",
+        cmd = "$(location //lib/prolog:compiler-bin) " +
+              "$$(dirname $@) $@ " +
+              "$(SRCS)",
+        srcs = srcs,
+        tools = ["//lib/prolog:compiler-bin"],
+        outs = [name + ".srcjar"],
+    )
+    java_library(
+        name = name,
+        srcs = [":" + name + "__pl2j"],
+        deps = ["//lib/prolog:runtime-neverlink"] + deps,
+        **kwargs
+    )
diff --git a/lib/prolog/prolog.defs b/lib/prolog/prolog.defs
deleted file mode 100644
index e74c21d..0000000
--- a/lib/prolog/prolog.defs
+++ /dev/null
@@ -1,42 +0,0 @@
-# Copyright (C) 2013 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.
-
-def prolog_cafe_library(
-    name,
-    srcs,
-    deps = [],
-    visibility = []):
-  genrule(
-    name = name + '__pl2j',
-    cmd = '$(exe //lib/prolog:compiler_bin)' +
-      ' $TMP $OUT ' +
-      ' '.join(srcs),
-    srcs = srcs,
-    out = name + '.src.zip',
-  )
-  java_library(
-    name = name + '__lib',
-    srcs = [':' + name + '__pl2j'],
-    deps = ['//lib/prolog:runtime'] + deps,
-  )
-  genrule(
-    name = name + '__ln',
-    cmd = 'ln -s $(location :%s__lib) $OUT' % name,
-    out = name + '.jar',
-  )
-  prebuilt_jar(
-    name = name,
-    binary_jar = ':%s__ln' % name,
-    visibility = visibility,
-  )
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
new file mode 100644
index 0000000..3c9b890
--- /dev/null
+++ b/lib/testcontainers/BUILD
@@ -0,0 +1,39 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "duct-tape",
+    testonly = True,
+    data = ["//lib:LICENSE-testcontainers"],
+    visibility = ["//visibility:public"],
+    exports = ["@duct-tape//jar"],
+)
+
+java_library(
+    name = "visible-assertions",
+    testonly = True,
+    data = ["//lib:LICENSE-testcontainers"],
+    visibility = ["//visibility:public"],
+    exports = ["@visible-assertions//jar"],
+)
+
+java_library(
+    name = "jna",
+    testonly = True,
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jna//jar"],
+)
+
+java_library(
+    name = "testcontainers",
+    testonly = True,
+    data = ["//lib:LICENSE-testcontainers"],
+    visibility = ["//visibility:public"],
+    exports = ["@testcontainers//jar"],
+    runtime_deps = [
+        ":duct-tape",
+        ":jna",
+        ":visible-assertions",
+        "//lib/log:ext",
+    ],
+)
diff --git a/plugins/BUCK b/plugins/BUCK
deleted file mode 100644
index c6bb7f1..0000000
--- a/plugins/BUCK
+++ /dev/null
@@ -1,42 +0,0 @@
-BASE = get_base_path()
-CORE = [
-  'commit-message-length-validator',
-  'download-commands',
-  'hooks',
-  'replication',
-  'reviewnotes',
-  'singleusergroup'
-]
-CUSTOM = [
-  # Add custom core plugins here
-]
-
-# buck audit parses and resolves all deps even if not reachable
-# from the root(s) passed to audit. Filter dependencies to only
-# the ones that currently exist to allow buck to parse cleanly.
-# TODO(sop): buck should more lazily resolve deps
-def core_plugins(names):
-  from os import path
-  h, n = [], []
-  for p in names:
-    if path.exists(path.join(BASE, p, 'BUCK')):
-      h.append(p)
-    else:
-      n.append(p)
-  return h, n
-HAVE, NEED = core_plugins(CORE + CUSTOM)
-
-genrule(
-  name = 'core',
-  cmd = '' +
-    ';'.join(['echo >&2 plugins/'+n+' is required.' for n in NEED]) +
-    (';echo >&2;exit 1;' if NEED else '') +
-    'mkdir -p $TMP/WEB-INF/plugins;' +
-    'for s in ' +
-    ' '.join(['$(location //%s/%s:%s)' % (BASE, n, n) for n in HAVE]) +
-    ';do ln -s $s $TMP/WEB-INF/plugins;done;' +
-    'cd $TMP;' +
-    'zip -qr $OUT .',
-  out = 'core.zip',
-  visibility = ['//:release'],
-)
diff --git a/plugins/BUILD b/plugins/BUILD
new file mode 100644
index 0000000..3253b98
--- /dev/null
+++ b/plugins/BUILD
@@ -0,0 +1,18 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load(
+    "//tools/bzl:plugins.bzl",
+    "CORE_PLUGINS",
+    "CUSTOM_PLUGINS",
+)
+
+genrule2(
+    name = "core",
+    srcs = ["//plugins/%s:%s.jar" % (n, n) for n in CORE_PLUGINS + CUSTOM_PLUGINS],
+    outs = ["core.zip"],
+    cmd = "mkdir -p $$TMP/WEB-INF/plugins;" +
+          "for s in $(SRCS) ; do " +
+          "ln -s $$ROOT/$$s $$TMP/WEB-INF/plugins;done;" +
+          "cd $$TMP;" +
+          "zip -qr $$ROOT/$@ .",
+    visibility = ["//visibility:public"],
+)
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 9b163e1..4f6b685 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 9b163e113de9f3a49219a02d388f7f46ea2559d3
+Subproject commit 4f6b685e12e34a4f583cf84ba1c58ccc2b75e8b0
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
deleted file mode 160000
index e291425..0000000
--- a/plugins/cookbook-plugin
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit e291425ca82cf8cb4bcd53b5f65e881fc04961c5
diff --git a/plugins/download-commands b/plugins/download-commands
index 5615076..55e0140 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 5615076bcf114723d1744f7d8944f0df72dbbf2b
+Subproject commit 55e0140f18349964077c78da0f6eb0eb592ba54b
diff --git a/plugins/external_plugin_deps.bzl b/plugins/external_plugin_deps.bzl
new file mode 100644
index 0000000..1f7c020
--- /dev/null
+++ b/plugins/external_plugin_deps.bzl
@@ -0,0 +1,2 @@
+def external_plugin_deps():
+    pass
diff --git a/plugins/hooks b/plugins/hooks
index dc8d1c1..8b71877 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit dc8d1c18b3d140dd1b2fc7ffe4f4a53d39a1cf28
+Subproject commit 8b7187734639e41707f142ca67c7ecf21b9cf3bd
diff --git a/plugins/replication b/plugins/replication
index 7b1df75..ae3fdcd 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 7b1df75e6efbdf4202628ec2e3f9117c559d170d
+Subproject commit ae3fdcd6df46a6b5076c2860b2a76ea3f0cee4a9
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index a52ea11..6fa8564 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit a52ea118b403e0f8ffc50c77e5471079e18c14d0
+Subproject commit 6fa85646a821e71195a4f1740ab1514a54700c6e
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 3ca1167..1568d77 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 3ca1167edda713f4bfdcecd9c0e2626797d7027f
+Subproject commit 1568d7755c70cdb26ddc865a7181c90f24480676
diff --git a/polygerrit-ui/BUCK b/polygerrit-ui/BUCK
deleted file mode 100644
index 80f9f29..0000000
--- a/polygerrit-ui/BUCK
+++ /dev/null
@@ -1,33 +0,0 @@
-include_defs('//lib/js.defs')
-
-bower_components(
-  name = 'polygerrit_components',
-  deps = [
-    '//lib/js:es6-promise',
-    '//lib/js:fetch',
-    '//lib/js:highlightjs',
-    '//lib/js:iron-autogrow-textarea',
-    '//lib/js:iron-dropdown',
-    '//lib/js:iron-input',
-    '//lib/js:iron-overlay-behavior',
-    '//lib/js:iron-selector',
-    '//lib/js:moment',
-    '//lib/js:page',
-    '//lib/js:polymer',
-    '//lib/js:promise-polyfill',
-  ],
-)
-
-genrule(
-  name = 'fonts',
-  cmd = ' && '.join([
-    'cd $TMP',
-    'for file in $SRCS; do unzip -q $file; done',
-    'zip -q $OUT *',
-  ]),
-  srcs = [
-    '//lib/fonts:sourcecodepro',
-  ],
-  out = 'fonts.zip',
-  visibility = ['PUBLIC'],
-)
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
new file mode 100644
index 0000000..f0994c1
--- /dev/null
+++ b/polygerrit-ui/BUILD
@@ -0,0 +1,40 @@
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:js.bzl", "bower_component_bundle")
+
+package(default_visibility = ["//visibility:public"])
+
+bower_component_bundle(
+    name = "polygerrit_components.bower_components",
+    deps = [
+        "//lib/js:es6-promise",
+        "//lib/js:fetch",
+        # TODO(hanwen): this is inserted separately in the UI zip. Do we need this here?
+        "//lib/js:highlightjs",
+        "//lib/js:iron-a11y-keys-behavior",
+        "//lib/js:iron-autogrow-textarea",
+        "//lib/js:iron-dropdown",
+        "//lib/js:iron-input",
+        "//lib/js:iron-overlay-behavior",
+        "//lib/js:iron-selector",
+        "//lib/js:moment",
+        "//lib/js:page",
+        "//lib/js:polymer",
+        "//lib/js:promise-polyfill",
+    ],
+)
+
+genrule2(
+    name = "fonts",
+    srcs = [
+        "//lib/fonts:sourcecodepro",
+    ],
+    outs = ["fonts.zip"],
+    cmd = " && ".join([
+        "mkdir -p $$TMP/fonts",
+        "cp $(SRCS) $$TMP/fonts/",
+        "cd $$TMP",
+        "find fonts/ -exec touch -t 198001010000 '{}' ';'",
+        "zip -qr $$ROOT/$@ fonts",
+    ]),
+    output_to_bindir = 1,
+)
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 383fb50..5035648 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -2,8 +2,10 @@
 
 ## Installing [Node.js](https://nodejs.org/en/download/)
 
+The minimum nodejs version supported is 6.x+
+
 ```sh
-# Debian/Ubuntu
+# Debian experimental
 sudo apt-get install nodejs-legacy
 
 # OS X with Homebrew
@@ -13,9 +15,21 @@
 All other platforms: [download from
 nodejs.org](https://nodejs.org/en/download/).
 
-## Optional: installing [go](https://golang.org/)
+## Installing [Bazel](https://bazel.build/)
 
-This is only required for running the ```run-server.sh``` script for testing. See below.
+Follow the instructions
+[here](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_installation)
+to get and install Bazel.
+
+## Local UI, Production Data
+
+This is a quick and easy way to test your local changes against real data.
+Unfortunately, you can't sign in, so testing certain features will require
+you to use the "test data" technique described below.
+
+### Installing [go](https://golang.org/)
+
+This is required for running the `run-server.sh` script below.
 
 ```sh
 # Debian/Ubuntu
@@ -27,18 +41,18 @@
 
 All other platforms: [download from golang.org](https://golang.org/)
 
-# Add [go] to your path
+Then add go to your path:
 
 ```
 PATH=$PATH:/usr/local/go/bin
 ```
 
-## Local UI, Production Data
+### Running the server
 
 To test the local UI against gerrit-review.googlesource.com:
 
 ```sh
-./polygerrit-ui/run-server.sh
+./run-server.sh
 ```
 
 Then visit http://localhost:8081
@@ -47,10 +61,8 @@
 
 One-time setup:
 
-1. [Install Buck](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_installation)
-   for building Gerrit.
-2. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_gerrit_development_war_file)
-   and set up a local test site. Docs
+1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
+2. Set up a local test site. Docs
    [here](https://gerrit-review.googlesource.com/Documentation/install-quick.html) and
    [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
 
@@ -58,9 +70,10 @@
 that serves PolyGerrit:
 
 ```sh
-buck build polygerrit && \
-java -jar buck-out/gen/polygerrit/polygerrit.war daemon --polygerrit-dev \
--d ../gerrit_testsite --console-log --show-stack-trace
+bazel build polygerrit && \
+  java -DsourceRoot=/path/to/my/checkout \
+  -jar bazel-bin/polygerrit.war daemon --polygerrit-dev \
+  -d ../gerrit_testsite --console-log --show-stack-trace
 ```
 
 ## Running Tests
@@ -78,19 +91,19 @@
 sudo npm install -g web-component-tester
 ```
 
+Note: it may be necessary to add the options `--unsafe-perm=true --allow-root`
+to the `npm install` command to avoid file permission errors.
+
 Run all web tests:
 
 ```sh
-buck test --no-results-cache --include web
+./polygerrit-ui/app/run_test.sh
 ```
 
-The `--no-results-cache` flag prevents flaky test failures from being
-cached.
-
 If you need to pass additional arguments to `wct`:
 
 ```sh
-WCT_ARGS='-p --some-flag="foo bar"' buck test --no-results-cache --include web
+WCT_ARGS='-p --some-flag="foo bar"' ./polygerrit-ui/app/run_test.sh
 ```
 
 For interactively working on a single test file, do the following:
@@ -101,6 +114,17 @@
 
 Then visit http://localhost:8081/elements/foo/bar_test.html
 
+To run Chrome tests in headless mode:
+
+```sh
+WCT_HEADLESS_MODE=1 ./polygerrit-ui/app/run_test.sh
+```
+
+Toolchain requirements for headless mode:
+
+* Chrome: 59+
+* web-component-tester: v6.5.0+
+
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
diff --git a/polygerrit-ui/app/.gitignore b/polygerrit-ui/app/.gitignore
new file mode 100644
index 0000000..375a75d
--- /dev/null
+++ b/polygerrit-ui/app/.gitignore
@@ -0,0 +1 @@
+/plugins/
diff --git a/polygerrit-ui/app/BUCK b/polygerrit-ui/app/BUCK
deleted file mode 100644
index d03acf2..0000000
--- a/polygerrit-ui/app/BUCK
+++ /dev/null
@@ -1,98 +0,0 @@
-include_defs('//lib/js.defs')
-
-WCT_TEST_PATTERNS = [
-  'test/*.js',
-  'test/*.html',
-  '**/*_test.html',
-]
-PY_TEST_PATTERNS = ['polygerrit_wct_tests.py']
-APP_SRCS = glob(
-  ['**'],
-  excludes = [
-    'BUCK',
-    'index.html',
-    'test/**',
-  ] + WCT_TEST_PATTERNS + PY_TEST_PATTERNS)
-
-# List libraries to be copied statically into the build. (i.e. Libraries not
-# expected to be Vulcanized.)
-WEB_JS_LIBS = [
-  ('bower_components/webcomponentsjs', 'webcomponents-lite.js'),
-  ('bower_components/highlightjs', 'highlight.min.js'),
-]
-
-# Map the static libraries to commands for the polygerrit_ui rule.
-JS_LIBS_MKDIR_CMDS = []
-JS_LIBS_UNZIP_CMDS = []
-for lib in WEB_JS_LIBS:
-  JS_LIBS_MKDIR_CMDS.append('mkdir -p ' + lib[0])
-  path = lib[0] + '/' + lib[1]
-  cmd = 'unzip -p $(location //polygerrit-ui:polygerrit_components) %s>%s' % (path, path)
-  JS_LIBS_UNZIP_CMDS.append(cmd)
-
-# TODO(dborowitz): Putting these rules in this package avoids having to handle
-# the app/ prefix like we would have to if this were in the parent directory.
-# The only reason for the app subdirectory in the first place was convenience
-# when witing server.go; when that goes away, we can just move all the files and
-# these rules up one directory.
-genrule(
-  name = 'polygerrit_ui',
-  cmd = ' && '.join([
-    'mkdir $TMP/polygerrit_ui',
-    'cd $TMP/polygerrit_ui',
-    'mkdir -p {fonts,elements}',
-    ' && '.join(JS_LIBS_MKDIR_CMDS),
-    'unzip -qd fonts $(location //polygerrit-ui:fonts)',
-    'unzip -qd elements $(location :gr-app)',
-    'cp -rp $SRCDIR/* .',
-    ' && '.join(JS_LIBS_UNZIP_CMDS),
-    'cd $TMP',
-    'zip -9qr $OUT .',
-  ]),
-  srcs = glob([
-    'favicon.ico',
-    'index.html',
-    'styles/**/*.css'
-  ]),
-  out = 'polygerrit_ui.zip',
-  visibility = ['PUBLIC'],
-)
-
-vulcanize(
-  name = 'gr-app',
-  app = 'elements/gr-app.html',
-  srcs = APP_SRCS,
-  components = '//polygerrit-ui:polygerrit_components',
-)
-
-bower_components(
-  name = 'test_components',
-  deps = [
-    '//polygerrit-ui:polygerrit_components',
-    '//lib/js:iron-test-helpers',
-    '//lib/js:test-fixture',
-    '//lib/js:web-component-tester',
-  ],
-)
-
-genrule(
-  name = 'test_resources',
-  cmd = ' && '.join([
-    'cd $TMP',
-    'unzip -q $(location :test_components)',
-    'cp -r $SRCDIR/* .',
-    'zip -r $OUT .',
-  ]),
-  srcs = APP_SRCS + glob(WCT_TEST_PATTERNS),
-  out = 'test_resources.zip',
-)
-
-python_test(
-  name = 'polygerrit_tests',
-  srcs = glob(PY_TEST_PATTERNS),
-  resources = [':test_resources'],
-  labels = [
-    'manual',
-    'web',
-  ],
-)
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
new file mode 100644
index 0000000..9d1059f
--- /dev/null
+++ b/polygerrit-ui/app/BUILD
@@ -0,0 +1,161 @@
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load(
+    "//tools/bzl:js.bzl",
+    "bower_component_bundle",
+    "vulcanize",
+)
+
+package(default_visibility = ["//visibility:public"])
+
+vulcanize(
+    name = "gr-app",
+    srcs = glob(
+        [
+            "**/*.html",
+            "**/*.js",
+        ],
+        exclude = [
+            "bower_components/**",
+            "index.html",
+            "test/**",
+            "**/*_test.html",
+        ],
+    ),
+    app = "elements/gr-app.html",
+    deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
+)
+
+closure_js_library(
+    name = "closure_lib",
+    srcs = ["gr-app.js"],
+    convention = "GOOGLE",
+    # TODO(davido): Clean up these issues: http://paste.openstack.org/show/608548
+    # and remove this supression
+    suppress = [
+        "JSC_JSDOC_MISSING_TYPE_WARNING",
+        "JSC_UNNECESSARY_ESCAPE",
+        "JSC_UNUSED_LOCAL_ASSIGNMENT",
+    ],
+    deps = [
+        "//lib/polymer_externs:polymer_closure",
+        "@io_bazel_rules_closure//closure/library",
+    ],
+)
+
+closure_js_binary(
+    name = "closure_bin",
+    # Known issue: Closure compilation not compatible with Polymer behaviors.
+    # See: https://github.com/google/closure-compiler/issues/2042
+    compilation_level = "WHITESPACE_ONLY",
+    defs = [
+        "--polymer_pass",
+        "--jscomp_off=duplicate",
+        "--force_inject_library=es6_runtime",
+    ],
+    language = "ECMASCRIPT5",
+    deps = [":closure_lib"],
+)
+
+filegroup(
+    name = "top_sources",
+    srcs = [
+        "favicon.ico",
+        "index.html",
+    ],
+)
+
+filegroup(
+    name = "css_sources",
+    srcs = glob(["styles/**/*.css"]),
+)
+
+filegroup(
+    name = "app_sources",
+    srcs = [
+        "closure_bin.js",
+        "gr-app.html",
+    ],
+)
+
+genrule2(
+    name = "polygerrit_ui",
+    srcs = [
+        "//lib/fonts:sourcecodepro",
+        "//lib/js:highlightjs_files",
+        ":top_sources",
+        ":css_sources",
+        ":app_sources",
+        # we extract from the zip, but depend on the component for license checking.
+        "@webcomponentsjs//:zipfile",
+        "//lib/js:webcomponentsjs",
+    ],
+    outs = ["polygerrit_ui.zip"],
+    cmd = " && ".join([
+        "mkdir -p $$TMP/polygerrit_ui/{styles,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
+        "for f in $(locations :app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/gr-app.$$ext; done",
+        "cp $(locations //lib/fonts:sourcecodepro) $$TMP/polygerrit_ui/fonts/",
+        "for f in $(locations :top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
+        "for f in $(locations :css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
+        "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
+        "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
+        "cd $$TMP",
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -qr $$ROOT/$@ *",
+    ]),
+)
+
+bower_component_bundle(
+    name = "test_components",
+    testonly = 1,
+    deps = [
+        "//lib/js:iron-test-helpers",
+        "//lib/js:test-fixture",
+        "//lib/js:web-component-tester",
+        "//polygerrit-ui:polygerrit_components.bower_components",
+    ],
+)
+
+filegroup(
+    name = "pg_code",
+    srcs = glob(
+        [
+            "**/*.html",
+            "**/*.js",
+        ],
+        exclude = [
+            "bower_components/**",
+        ],
+    ),
+)
+
+genrule2(
+    name = "pg_code_zip",
+    srcs = [":pg_code"],
+    outs = ["pg_code.zip"],
+    cmd = " && ".join([
+        ("tar -hcf- $(locations :pg_code) |" +
+         " tar --strip-components=2 -C $$TMP/ -xf-"),
+        "cd $$TMP",
+        "TZ=UTC",
+        "export TZ",
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -rq $$ROOT/$@ *",
+    ]),
+)
+
+sh_test(
+    name = "wct_test",
+    size = "large",
+    srcs = ["wct_test.sh"],
+    data = [
+        "test/index.html",
+        ":pg_code.zip",
+        ":test_components.zip",
+    ],
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+)
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
new file mode 100644
index 0000000..2ec8538
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
@@ -0,0 +1,37 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.BaseUrlBehavior */
+  var BaseUrlBehavior = {
+    getBaseUrl: function() {
+      return window.CANONICAL_PATH || '';
+    },
+
+    computeGwtUrl: function(path) {
+      var base = this.getBaseUrl();
+      var clientPath = path.substring(base.length);
+      return base + '/?polygerrit=0#' + clientPath;
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.BaseUrlBehavior = BaseUrlBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
new file mode 100644
index 0000000..1e277bc
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>keyboard-shortcut-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<script>
+  window.CANONICAL_PATH = '/r';
+</script>
+<link rel="import" href="base-url-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<test-fixture id="within-overlay">
+  <template>
+    <gr-overlay>
+      <test-element></test-element>
+    </gr-overlay>
+  </template>
+</test-fixture>
+
+<script>
+  suite('base-url-behavior tests', function() {
+    var element;
+    var overlay;
+
+    suiteSetup(function() {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [
+          Gerrit.BaseUrlBehavior,
+        ],
+      });
+    });
+
+    setup(function() {
+      element = fixture('basic');
+      overlay = fixture('within-overlay');
+    });
+
+    test('getBaseUrl', function() {
+      assert.deepEqual(element.getBaseUrl(), '/r');
+    });
+
+    test('computeGwtUrl', function() {
+      assert.deepEqual(
+        element.computeGwtUrl('/r/c/1/'),
+        '/r/?polygerrit=0#/c/1/'
+      );
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
new file mode 100644
index 0000000..ca955b3
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
@@ -0,0 +1,56 @@
+<!--
+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.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.ChangeTableBehavior */
+  var ChangeTableBehavior = {
+    properties: {
+      columnNames: {
+        type: Array,
+        value: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Project',
+          'Branch',
+          'Updated',
+          'Size',
+        ],
+        readOnly: true,
+      }
+    },
+
+    /**
+     * Returns the complement to the given column array
+     * @param {Array} columns
+     */
+    getComplementColumns: function(columns) {
+      return this.columnNames.filter(function(column) {
+        return columns.indexOf(column) === -1;
+      });
+    },
+
+    isColumnHidden: function(columnToCheck, columnsToDisplay) {
+      return columnsToDisplay.indexOf(columnToCheck) === -1;
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.ChangeTableBehavior = ChangeTableBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
new file mode 100644
index 0000000..42ea615
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
@@ -0,0 +1,106 @@
+<!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>keyboard-shortcut-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-table-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<test-fixture id="within-overlay">
+  <template>
+    <gr-overlay>
+      <test-element></test-element>
+    </gr-overlay>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-table-behavior tests', function() {
+    var element;
+    var overlay;
+
+    suiteSetup(function() {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [Gerrit.ChangeTableBehavior],
+      });
+    });
+
+    setup(function() {
+      element = fixture('basic');
+      overlay = fixture('within-overlay');
+    });
+
+    test('getComplementColumns', function() {
+      var columns = [
+        'Subject',
+        'Status',
+        'Owner',
+        'Project',
+        'Branch',
+        'Updated',
+        'Size',
+      ];
+      assert.deepEqual(element.getComplementColumns(columns), []);
+
+      columns = [
+        'Subject',
+        'Status',
+        'Project',
+        'Branch',
+        'Size',
+      ];
+      assert.deepEqual(element.getComplementColumns(columns),
+          ['Owner', 'Updated']);
+    });
+
+    test('isColumnHidden', function() {
+      var columnToCheck = 'Project';
+      var columnsToDisplay = [
+        'Subject',
+        'Status',
+        'Owner',
+        'Project',
+        'Branch',
+        'Updated',
+        'Size',
+      ];
+      assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
+
+      var columnsToDisplay = [
+        'Subject',
+        'Status',
+        'Owner',
+        'Branch',
+        'Updated',
+        'Size',
+      ];
+      assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
new file mode 100644
index 0000000..acf3a62
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -0,0 +1,44 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.PatchSetBehavior */
+  var PatchSetBehavior = {
+    /**
+     * Given an object of revisions, get a particular revision based on patch
+     * num.
+     *
+     * @param {Object} revisions The object of revisions given by the API
+     * @param {number|string} patchNum The number index of the revision
+     * @return {Object} The correspondent revision obj from {revisions}
+     */
+    getRevisionByPatchNum: function(revisions, patchNum) {
+      patchNum = parseInt(patchNum, 10);
+      for (var rev in revisions) {
+        if (revisions.hasOwnProperty(rev) &&
+            revisions[rev]._number === patchNum) {
+          return revisions[rev];
+        }
+      }
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.PatchSetBehavior = PatchSetBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
new file mode 100644
index 0000000..862c734
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -0,0 +1,38 @@
+<!--
+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.
+-->
+<!-- Polymer included for the html import polyfill. -->
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<title>gr-patch-set-behavior</title>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-patch-set-behavior.html">
+
+<script>
+  suite('gr-path-list-behavior tests', function() {
+    test('getRevisionByPatchNum', function() {
+      var get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
+      var revisions = [
+        {_number: 0},
+        {_number: 1},
+        {_number: 2},
+      ];
+      assert.deepEqual(get(revisions, '1'), revisions[1]);
+      assert.deepEqual(get(revisions, 2), revisions[2]);
+      assert.equal(get(revisions, '3'), undefined);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
new file mode 100644
index 0000000..f9c4a80
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
@@ -0,0 +1,70 @@
+<!--
+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.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.PathListBehavior */
+  var PathListBehavior = {
+    specialFilePathCompare: function(a, b) {
+      // The commit message always goes first.
+      var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+      if (a === COMMIT_MESSAGE_PATH) {
+        return -1;
+      }
+      if (b === COMMIT_MESSAGE_PATH) {
+        return 1;
+      }
+
+      // The merge list always comes next.
+      var MERGE_LIST_PATH = '/MERGE_LIST';
+      if (a === MERGE_LIST_PATH) {
+        return -1;
+      }
+      if (b === MERGE_LIST_PATH) {
+        return 1;
+      }
+
+      var aLastDotIndex = a.lastIndexOf('.');
+      var aExt = a.substr(aLastDotIndex + 1);
+      var aFile = a.substr(0, aLastDotIndex) || a;
+
+      var bLastDotIndex = b.lastIndexOf('.');
+      var bExt = b.substr(bLastDotIndex + 1);
+      var bFile = b.substr(0, bLastDotIndex) || b;
+
+      // Sort header files above others with the same base name.
+      var headerExts = ['h', 'hxx', 'hpp'];
+      if (aFile.length > 0 && aFile === bFile) {
+        if (headerExts.indexOf(aExt) !== -1 &&
+            headerExts.indexOf(bExt) !== -1) {
+          return a.localeCompare(b);
+        }
+        if (headerExts.indexOf(aExt) !== -1) {
+          return -1;
+        }
+        if (headerExts.indexOf(bExt) !== -1) {
+          return 1;
+        }
+      }
+      return aFile.localeCompare(bFile) || a.localeCompare(b);
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.PathListBehavior = PathListBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
new file mode 100644
index 0000000..2b42587
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -0,0 +1,40 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<!-- Polymer included for the html import polyfill. -->
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<title>gr-path-list-behavior</title>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-path-list-behavior.html">
+
+<script>
+  suite('gr-path-list-behavior tests', function() {
+    test('special sort', function() {
+      var sort = Gerrit.PathListBehavior.specialFilePathCompare;
+      var testFiles = [
+        '/a.h',
+        '/MERGE_LIST',
+        '/a.cpp',
+        '/COMMIT_MSG',
+        '/asdasd',
+        '/mrPeanutbutter.py'
+      ];
+      assert.deepEqual(testFiles.sort(sort),
+          ['/COMMIT_MSG', '/MERGE_LIST', '/a.h', '/a.cpp', '/asdasd', '/mrPeanutbutter.py']);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index 3702c84..e4c4d11 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -22,6 +22,12 @@
     properties: {
       hasTooltip: Boolean,
 
+      _isTouchDevice: {
+        type: Boolean,
+        value: function() {
+          return 'ontouchstart' in document.documentElement;
+        },
+      },
       _tooltip: Element,
       _titleText: String,
     },
@@ -29,18 +35,21 @@
     attached: function() {
       if (!this.hasTooltip) { return; }
 
-      this.addEventListener('mouseover', this._handleShowTooltip.bind(this));
-      this.addEventListener('mouseout', this._handleHideTooltip.bind(this));
-      this.addEventListener('focusin', this._handleShowTooltip.bind(this));
-      this.addEventListener('focusout', this._handleHideTooltip.bind(this));
+      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
+      this.addEventListener('mouseleave', this._handleHideTooltip.bind(this));
+      this.addEventListener('tap', this._handleHideTooltip.bind(this));
+
       this.listen(window, 'scroll', '_handleWindowScroll');
     },
 
     detached: function() {
+      this._handleHideTooltip();
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
     _handleShowTooltip: function(e) {
+      if (this._isTouchDevice) { return; }
+
       if (!this.hasAttribute('title') ||
           this.getAttribute('title') === '' ||
           this._tooltip) {
@@ -54,6 +63,7 @@
 
       var tooltip = document.createElement('gr-tooltip');
       tooltip.text = this._titleText;
+      tooltip.maxWidth = this.getAttribute('max-width');
 
       // Set visibility to hidden before appending to the DOM so that
       // calculations can be made based on the element’s size.
@@ -66,9 +76,11 @@
     },
 
     _handleHideTooltip: function(e) {
+      if (this._isTouchDevice) { return; }
       if (!this.hasAttribute('title') ||
-          this._titleText == null ||
-          this === document.activeElement) { return; }
+          this._titleText == null) {
+        return;
+      }
 
       this.setAttribute('title', this._titleText);
       if (this._tooltip && this._tooltip.parentNode) {
@@ -87,16 +99,22 @@
       var rect = this.getBoundingClientRect();
       var boxRect = tooltip.getBoundingClientRect();
       var parentRect = tooltip.parentElement.getBoundingClientRect();
-      var top = rect.top - parentRect.top - boxRect.height - BOTTOM_OFFSET;
-      var left =
-          rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+      var top = rect.top - parentRect.top;
+      var left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+      var right = parentRect.width - left - boxRect.width;
       if (left < 0) {
         tooltip.updateStyles({
           '--gr-tooltip-arrow-center-offset': left + 'px',
         });
+      } else if (right < 0) {
+        tooltip.updateStyles({
+          '--gr-tooltip-arrow-center-offset': (-0.5 * right) + 'px',
+        });
       }
       tooltip.style.left = Math.max(0, left) + 'px';
       tooltip.style.top = Math.max(0, top) + 'px';
+      tooltip.style.transform = 'translateY(calc(-100% - ' + BOTTOM_OFFSET +
+          'px))';
     },
   };
 
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
new file mode 100644
index 0000000..99bfc03
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -0,0 +1,122 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<title>tooltip-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-tooltip-behavior.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <tooltip-behavior-element></tooltip-behavior-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-tooltip-behavior tests', function() {
+    var element;
+    var sandbox;
+
+    function makeTooltip(tooltipRect, parentRect) {
+      return {
+        getBoundingClientRect: function() { return tooltipRect; },
+        updateStyles: sinon.stub(),
+        style: {left: 0, top: 0},
+        parentElement: {
+          getBoundingClientRect: function() { return parentRect; },
+        },
+      };
+    }
+
+    suiteSetup(function() {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'tooltip-behavior-element',
+        behaviors: [Gerrit.TooltipBehavior],
+      });
+    });
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('normal position', function() {
+      sandbox.stub(element, 'getBoundingClientRect', function() {
+        return {top: 100, left: 100, width: 200};
+      });
+      var tooltip = makeTooltip(
+          {height: 30, width: 50},
+          {top: 0, left: 0, width: 1000});
+
+      element._positionTooltip(tooltip);
+      assert.isFalse(tooltip.updateStyles.called);
+      assert.equal(tooltip.style.left, '175px');
+      assert.equal(tooltip.style.top, '100px');
+    });
+
+    test('left side position', function() {
+      sandbox.stub(element, 'getBoundingClientRect', function() {
+        return {top: 100, left: 10, width: 50};
+      });
+      var tooltip = makeTooltip(
+          {height: 30, width: 120},
+          {top: 0, left: 0, width: 1000});
+
+      element._positionTooltip(tooltip);
+      assert.isTrue(tooltip.updateStyles.called);
+      var offset = tooltip.updateStyles
+          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+      assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
+      assert.equal(tooltip.style.left, '0px');
+      assert.equal(tooltip.style.top, '100px');
+    });
+
+    test('right side position', function() {
+      sandbox.stub(element, 'getBoundingClientRect', function() {
+        return {top: 100, left: 950, width: 50};
+      });
+      var tooltip = makeTooltip(
+          {height: 30, width: 120},
+          {top: 0, left: 0, width: 1000});
+
+      element._positionTooltip(tooltip);
+      assert.isTrue(tooltip.updateStyles.called);
+      var offset = tooltip.updateStyles
+          .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+      assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+      assert.equal(tooltip.style.left, '915px');
+      assert.equal(tooltip.style.top, '100px');
+    });
+
+    test('hides tooltip when detached', function() {
+      sandbox.stub(element, '_handleHideTooltip');
+      element.remove();
+      flushAsynchronousOperations();
+      assert.isTrue(element._handleHideTooltip.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
new file mode 100644
index 0000000..b7d71fc
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
@@ -0,0 +1,42 @@
+<!--
+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.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.URLEncodingBehavior */
+  var URLEncodingBehavior = {
+    /**
+     * Pretty-encodes a URL. Double-encodes the string, and then replaces
+     *   benevolent characters for legibility.
+     */
+    encodeURL: function(url, replaceSlashes) {
+      // @see Issue 4255 regarding double-encoding.
+      var output = encodeURIComponent(encodeURIComponent(url));
+      // @see Issue 4577 regarding more readable URLs.
+      output = output.replace(/%253A/g, ':');
+      output = output.replace(/%2520/g, '+');
+      if (replaceSlashes) {
+        output = output.replace(/%252F/g, '/');
+      }
+      return output;
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.URLEncodingBehavior = URLEncodingBehavior;
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
deleted file mode 100644
index 17acac8..0000000
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior.html
+++ /dev/null
@@ -1,68 +0,0 @@
-<!--
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="../bower_components/polymer/polymer.html">
-<script>
-(function(window) {
-  'use strict';
-
-  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior */
-  var KeyboardShortcutBehavior = {
-    enabled: true,
-
-    properties: {
-      keyEventTarget: {
-        type: Object,
-        value: function() { return this; },
-      },
-
-      _boundKeyHandler: {
-        type: Function,
-        readonly: true,
-        value: function() { return this._handleKey.bind(this); },
-      },
-    },
-
-    attached: function() {
-      this.keyEventTarget.addEventListener('keydown', this._boundKeyHandler);
-    },
-
-    detached: function() {
-      this.keyEventTarget.removeEventListener('keydown', this._boundKeyHandler);
-    },
-
-    shouldSupressKeyboardShortcut: function(e) {
-      if (!KeyboardShortcutBehavior.enabled) { return true; }
-      var getModifierState = e.getModifierState ?
-          e.getModifierState.bind(e) :
-          function() { return false; };
-      var target = e.detail ? e.detail.keyboardEvent : e.target;
-      return getModifierState('Control') ||
-             getModifierState('Alt') ||
-             getModifierState('Meta') ||
-             getModifierState('Fn') ||
-             target.tagName == 'INPUT' ||
-             target.tagName == 'TEXTAREA' ||
-             target.tagName == 'SELECT' ||
-             target.tagName == 'BUTTON' ||
-             target.tagName == 'A' ||
-             target.tagName == 'GR-BUTTON';
-    },
-  };
-
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.KeyboardShortcutBehavior = KeyboardShortcutBehavior;
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
new file mode 100644
index 0000000..3d99cec
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -0,0 +1,54 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+
+<script>
+(function(window) {
+  'use strict';
+
+  var getKeyboardEvent = function(e) {
+    return Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
+  };
+
+  var KeyboardShortcutBehaviorImpl = {
+    modifierPressed: function(e) {
+      e = getKeyboardEvent(e);
+      // When e is a keyboardEvent, e.event is not null.
+      if (e.event) { e = e.event; }
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+    },
+
+    shouldSuppressKeyboardShortcut: function(e) {
+      e = getKeyboardEvent(e);
+      if (e.path[0].tagName === 'INPUT' || e.path[0].tagName === 'TEXTAREA') {
+        return true;
+      }
+      for (var i = 0; i < e.path.length; i++) {
+        if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
+      }
+      return false;
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior */
+  window.Gerrit.KeyboardShortcutBehavior = [
+    Polymer.IronA11yKeysBehavior,
+    KeyboardShortcutBehaviorImpl,
+  ];
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
new file mode 100644
index 0000000..9ede5d9
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -0,0 +1,131 @@
+<!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>keyboard-shortcut-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="keyboard-shortcut-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<test-fixture id="within-overlay">
+  <template>
+    <gr-overlay>
+      <test-element></test-element>
+    </gr-overlay>
+  </template>
+</test-fixture>
+
+<script>
+  suite('keyboard-shortcut-behavior tests', function() {
+    var element;
+    var overlay;
+    var sandbox;
+
+    suiteSetup(function() {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [Gerrit.KeyboardShortcutBehavior],
+        keyBindings: {
+          'k': '_handleKey'
+        },
+        _handleKey: function() {},
+      });
+    });
+
+    setup(function() {
+      element = fixture('basic');
+      overlay = fixture('within-overlay');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('doesn’t block kb shortcuts for non-whitelisted els', function(done) {
+      var divEl = document.createElement('div');
+      element.appendChild(divEl);
+      element._handleKey = function(e) {
+        assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(divEl, 75, null, 'k');
+    });
+
+    test('blocks kb shortcuts for input els', function(done) {
+      var inputEl = document.createElement('input');
+      element.appendChild(inputEl);
+      element._handleKey = function(e) {
+        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+    });
+
+    test('blocks kb shortcuts for textarea els', function(done) {
+      var textareaEl = document.createElement('textarea');
+      element.appendChild(textareaEl);
+      element._handleKey = function(e) {
+        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
+    });
+
+    test('blocks kb shortcuts for anything in a gr-overlay', function(done) {
+      var divEl = document.createElement('div');
+      var element = overlay.querySelector('test-element');
+      element.appendChild(divEl);
+      element._handleKey = function(e) {
+        assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+        done();
+      };
+      MockInteractions.keyDownOn(divEl, 75, null, 'k');
+    });
+
+    test('modifierPressed returns accurate values', function() {
+      var spy = sandbox.spy(element, 'modifierPressed');
+      element._handleKey = function(e) {
+        element.modifierPressed(e);
+      };
+      MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+      assert.isTrue(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+      assert.isTrue(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+      assert.isTrue(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+      assert.isTrue(spy.lastCall.returnValue);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior.html
deleted file mode 100644
index 4def9b2..0000000
--- a/polygerrit-ui/app/behaviors/rest-client-behavior.html
+++ /dev/null
@@ -1,133 +0,0 @@
-<!--
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<link rel="import" href="../bower_components/polymer/polymer.html">
-<script>
-(function(window) {
-  'use strict';
-
-  /** @polymerBehavior Gerrit.RESTClientBehavior */
-  var RESTClientBehavior = {
-    ChangeDiffType: {
-      ADDED: 'ADDED',
-      COPIED: 'COPIED',
-      DELETED: 'DELETED',
-      MODIFIED: 'MODIFIED',
-      RENAMED: 'RENAMED',
-      REWRITE: 'REWRITE',
-    },
-
-    ChangeStatus: {
-      ABANDONED: 'ABANDONED',
-      DRAFT: 'DRAFT',
-      MERGED: 'MERGED',
-      NEW: 'NEW',
-    },
-
-    // Must be kept in sync with the ListChangesOption enum and protobuf.
-    ListChangesOption: {
-      LABELS: 0,
-      DETAILED_LABELS: 8,
-
-      // Return information on the current patch set of the change.
-      CURRENT_REVISION: 1,
-      ALL_REVISIONS: 2,
-
-      // If revisions are included, parse the commit object.
-      CURRENT_COMMIT: 3,
-      ALL_COMMITS: 4,
-
-      // If a patch set is included, include the files of the patch set.
-      CURRENT_FILES: 5,
-      ALL_FILES: 6,
-
-      // If accounts are included, include detailed account info.
-      DETAILED_ACCOUNTS: 7,
-
-      // Include messages associated with the change.
-      MESSAGES: 9,
-
-      // Include allowed actions client could perform.
-      CURRENT_ACTIONS: 10,
-
-      // Set the reviewed boolean for the caller.
-      REVIEWED: 11,
-
-      // Include download commands for the caller.
-      DOWNLOAD_COMMANDS: 13,
-
-      // Include patch set weblinks.
-      WEB_LINKS: 14,
-
-      // Include consistency check results.
-      CHECK: 15,
-
-      // Include allowed change actions client could perform.
-      CHANGE_ACTIONS: 16,
-
-      // Include a copy of commit messages including review footers.
-      COMMIT_FOOTERS: 17,
-
-      // Include push certificate information along with any patch sets.
-      PUSH_CERTIFICATES: 18
-    },
-
-    listChangesOptionsToHex: function() {
-      var v = 0;
-      for (var i = 0; i < arguments.length; i++) {
-        v |= 1 << arguments[i];
-      }
-      return v.toString(16);
-    },
-
-    changeBaseURL: function(changeNum, patchNum) {
-      var v = '/changes/' + changeNum;
-      if (patchNum) {
-        v += '/revisions/' + patchNum;
-      }
-      return v;
-    },
-
-    changePath: function(changeNum) {
-      return '/c/' + changeNum;
-    },
-
-    changeIsOpen: function(status) {
-      return status === this.ChangeStatus.NEW ||
-          status === this.ChangeStatus.DRAFT;
-    },
-
-    changeStatusString: function(change) {
-      // "Closed" states should take precedence over "open" ones.
-      if (change.status === this.ChangeStatus.MERGED) {
-        return 'Merged';
-      }
-      if (change.status === this.ChangeStatus.ABANDONED) {
-        return 'Abandoned';
-      }
-      if (change.mergeable === false) {
-        return 'Merge Conflict';
-      }
-      if (change.status === this.ChangeStatus.DRAFT) {
-        return 'Draft';
-      }
-      return '';
-    },
-  };
-
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.RESTClientBehavior = RESTClientBehavior;
-})(window);
-</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
new file mode 100644
index 0000000..f71fe8f
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -0,0 +1,143 @@
+<!--
+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="../base-url-behavior/base-url-behavior.html">
+<script>
+(function(window) {
+  'use strict';
+
+  /** @polymerBehavior Gerrit.RESTClientBehavior */
+  var RESTClientBehavior = {
+    ChangeDiffType: {
+      ADDED: 'ADDED',
+      COPIED: 'COPIED',
+      DELETED: 'DELETED',
+      MODIFIED: 'MODIFIED',
+      RENAMED: 'RENAMED',
+      REWRITE: 'REWRITE',
+    },
+
+    ChangeStatus: {
+      ABANDONED: 'ABANDONED',
+      DRAFT: 'DRAFT',
+      MERGED: 'MERGED',
+      NEW: 'NEW',
+    },
+
+    // Must be kept in sync with the ListChangesOption enum and protobuf.
+    ListChangesOption: {
+      LABELS: 0,
+      DETAILED_LABELS: 8,
+
+      // Return information on the current patch set of the change.
+      CURRENT_REVISION: 1,
+      ALL_REVISIONS: 2,
+
+      // If revisions are included, parse the commit object.
+      CURRENT_COMMIT: 3,
+      ALL_COMMITS: 4,
+
+      // If a patch set is included, include the files of the patch set.
+      CURRENT_FILES: 5,
+      ALL_FILES: 6,
+
+      // If accounts are included, include detailed account info.
+      DETAILED_ACCOUNTS: 7,
+
+      // Include messages associated with the change.
+      MESSAGES: 9,
+
+      // Include allowed actions client could perform.
+      CURRENT_ACTIONS: 10,
+
+      // Set the reviewed boolean for the caller.
+      REVIEWED: 11,
+
+      // Include download commands for the caller.
+      DOWNLOAD_COMMANDS: 13,
+
+      // Include patch set weblinks.
+      WEB_LINKS: 14,
+
+      // Include consistency check results.
+      CHECK: 15,
+
+      // Include allowed change actions client could perform.
+      CHANGE_ACTIONS: 16,
+
+      // Include a copy of commit messages including review footers.
+      COMMIT_FOOTERS: 17,
+
+      // Include push certificate information along with any patch sets.
+      PUSH_CERTIFICATES: 18,
+
+      // Include change's reviewer updates.
+      REVIEWER_UPDATES: 19,
+
+      // Set the submittable boolean.
+      SUBMITTABLE: 20
+    },
+
+    listChangesOptionsToHex: function() {
+      var v = 0;
+      for (var i = 0; i < arguments.length; i++) {
+        v |= 1 << arguments[i];
+      }
+      return v.toString(16);
+    },
+
+    changeBaseURL: function(changeNum, patchNum) {
+      var v =  this.getBaseUrl() + '/changes/' + changeNum;
+      if (patchNum) {
+        v += '/revisions/' + patchNum;
+      }
+      return v;
+    },
+
+    changePath: function(changeNum) {
+      return this.getBaseUrl() + '/c/' + changeNum;
+    },
+
+    changeIsOpen: function(status) {
+      return status === this.ChangeStatus.NEW ||
+          status === this.ChangeStatus.DRAFT;
+    },
+
+    changeStatusString: function(change) {
+      // "Closed" states should take precedence over "open" ones.
+      if (change.status === this.ChangeStatus.MERGED) {
+        return 'Merged';
+      }
+      if (change.status === this.ChangeStatus.ABANDONED) {
+        return 'Abandoned';
+      }
+      if (change.mergeable === false) {
+        return 'Merge Conflict';
+      }
+      if (change.status === this.ChangeStatus.DRAFT) {
+        return 'Draft';
+      }
+      return '';
+    },
+  };
+
+  window.Gerrit = window.Gerrit || {};
+  window.Gerrit.RESTClientBehavior = [
+    Gerrit.BaseUrlBehavior,
+    RESTClientBehavior
+  ];
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
new file mode 100644
index 0000000..2b3e858
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>keyboard-shortcut-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script>
+  window.CANONICAL_PATH = '/r';
+</script>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../base-url-behavior/base-url-behavior.html">
+<link rel="import" href="rest-client-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<test-fixture id="within-overlay">
+  <template>
+    <gr-overlay>
+      <test-element></test-element>
+    </gr-overlay>
+  </template>
+</test-fixture>
+
+<script>
+  suite('rest-client-behavior tests', function() {
+    var element;
+    var overlay;
+
+    suiteSetup(function() {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [
+          Gerrit.BaseUrlBehavior,
+          Gerrit.RESTClientBehavior,
+        ],
+      });
+    });
+
+    setup(function() {
+      element = fixture('basic');
+      overlay = fixture('within-overlay');
+    });
+
+    test('changeBaseURL', function() {
+      assert.deepEqual(
+        element.changeBaseURL('1', '1'),
+        '/r/changes/1/revisions/1'
+      );
+    });
+
+    test('changePath', function() {
+      assert.deepEqual(element.changePath('1'), '/r/c/1');
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
new file mode 100644
index 0000000..527485d
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
@@ -0,0 +1,25 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-placeholder/gr-placeholder.html">
+
+<dom-module id="gr-admin-view">
+  <template>
+    <gr-placeholder title="Admin" path="[[path]]"></gr-placeholder>
+  </template>
+  <script src="gr-admin-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
new file mode 100644
index 0000000..cb248e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-admin-view',
+
+    properties: {
+      path: String,
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index 9126785..d50e0b3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -13,10 +13,12 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
@@ -25,7 +27,7 @@
   <template>
     <style>
       :host {
-        display: flex;
+        display: table-row;
         border-bottom: 1px solid #eee;
       }
       :host([selected]) {
@@ -34,12 +36,39 @@
       :host([needs-review]) {
         font-weight: bold;
       }
+      :host([assigned]) {
+        background-color: #fcfad6;
+      }
+      :host([selected][assigned]) {
+        background-color: #fcfaa6;
+      }
       .cell {
-        flex-shrink: 0;
         padding: .3em .5em;
       }
+      .container {
+        position: relative;
+      }
+      .content {
+        overflow: hidden;
+        position: absolute;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        width: 100%;
+      }
+      .content a {
+        display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        width: 100%;
+      }
+      .spacer {
+        height: 0;
+        overflow: hidden;
+      }
       a {
         color: var(--default-text-color);
+        display: block;
         text-decoration: none;
       }
       a:hover {
@@ -63,32 +92,70 @@
       .u-gray-background {
         background-color: #F5F5F5;
       }
+      @media only screen and (max-width: 50em) {
+        :host {
+          display: flex;
+        }
+      }
     </style>
     <style include="gr-change-list-styles"></style>
-    <span class="cell keyboard">
+    <td class="cell keyboard">
       <span class="positionIndicator">&#x25b6;</span>
-    </span>
-    <span class="cell star" hidden$="[[!showStar]]" hidden>
+    </td>
+    <td class="cell star" hidden$="[[!showStar]]" hidden>
       <gr-change-star change="{{change}}"></gr-change-star>
-    </span>
-    <a class="cell number" href$="[[changeURL]]" hidden$="[[!showNumber]]" hidden>
-      [[change._number]]
-    </a>
-    <a class="cell subject" href$="[[changeURL]]">[[change.subject]]</a>
-    <span class="cell status">[[changeStatusString(change)]]</span>
-    <span class="cell owner">
+    </td>
+    <td class="cell number" hidden$="[[!showNumber]]" hidden>
+      <a href$="[[changeURL]]"> [[change._number]]</a>
+    </td>
+    <td class="cell subject"
+        hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]">
+      <div class="container">
+        <div class="content">
+          <a title$="[[change.subject]]" href$="[[changeURL]]">
+            [[change.subject]]
+          </a>
+        </div>
+        <div class="spacer">
+           [[change.subject]]
+        </div>
+        <span>&nbsp;</span>
+      </div>
+    </td>
+    <td class="cell status"
+        hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]">
+      [[changeStatusString(change)]]
+    </td>
+    <td class="cell owner"
+        hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]">
       <gr-account-link account="[[change.owner]]"></gr-account-link>
-    </span>
-    <a class="cell project" href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
-    <a class="cell branch" href$="[[_computeProjectBranchURL(change.project, change.branch)]]">[[change.branch]]</a>
-    <gr-date-formatter class="cell updated" date-str="[[change.updated]]"></gr-date-formatter>
-    <span class="cell size u-monospace">
+    </td>
+    <td class="cell project"
+        hidden$="[[isColumnHidden('Project', visibleChangeTableColumns)]]">
+      <a href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
+    </td>
+    <td class="cell branch"
+        hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]">
+      <a href$="[[_computeProjectBranchURL(change.project, change.branch)]]">
+        [[change.branch]]
+      </a>
+    </td>
+    <td class="cell updated"
+        hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]">
+      <gr-date-formatter
+          has-tooltip
+          date-str="[[change.updated]]"></gr-date-formatter>
+    </td>
+    <td class="cell size u-monospace"
+        hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]">
       <span class="u-green"><span>+</span>[[change.insertions]]</span>,
       <span class="u-red"><span>-</span>[[change.deletions]]</span>
-    </span>
+    </td>
     <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-      <span title$="[[_computeLabelTitle(change, labelName)]]"
-          class$="[[_computeLabelClass(change, labelName)]]">[[_computeLabelValue(change, labelName)]]</span>
+      <td title$="[[_computeLabelTitle(change, labelName)]]"
+          class$="[[_computeLabelClass(change, labelName)]]">
+        [[_computeLabelValue(change, labelName)]]
+      </td>
     </template>
   </template>
   <script src="gr-change-list-item.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 90b2e1d..566dfe0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -18,16 +18,7 @@
     is: 'gr-change-list-item',
 
     properties: {
-      selected: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      needsReview: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
+      visibleChangeTableColumns: Array,
       labelNames: {
         type: Array,
       },
@@ -43,12 +34,15 @@
     },
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.ChangeTableBehavior,
       Gerrit.RESTClientBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     _computeChangeURL: function(changeNum) {
       if (!changeNum) { return ''; }
-      return '/c/' + changeNum + '/';
+      return this.getBaseUrl() + '/c/' + changeNum + '/';
     },
 
     _computeLabelTitle: function(change, labelName) {
@@ -108,11 +102,14 @@
     },
 
     _computeProjectURL: function(project) {
-      return '/q/status:open+project:' + project;
+      return this.getBaseUrl() + '/q/status:open+project:' +
+          this.encodeURL(project, false);
     },
 
     _computeProjectBranchURL: function(project, branch) {
-      return '/q/status:open+project:' + project + '+branch:' + branch;
+      // @see Issue 4255.
+      return this._computeProjectURL(project) +
+          '+branch:' + this.encodeURL(branch, false);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index b7c0853..ad76f10 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -22,8 +22,11 @@
 <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-change-list-item.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-change-list-item></gr-change-list-item>
@@ -35,13 +38,17 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       element = fixture('basic');
     });
 
     test('change status', function() {
       var getStatusForChange = function(change) {
         element.change = change;
-        return element.$$('.cell.status').textContent;
+        return element.$$('.cell.status').textContent.trim();
       };
 
       assert.equal(getStatusForChange({mergeable: true}), '');
@@ -120,12 +127,12 @@
       assert.equal(element._computeLabelValue(
           {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
 
-      assert.equal(element._computeProjectURL('combustible-stuff'),
-          '/q/status:open+project:combustible-stuff');
+      assert.equal(element._computeProjectURL('combustible/stuff'),
+          '/q/status:open+project:combustible%252Fstuff');
 
       assert.equal(element._computeProjectBranchURL(
-          'combustible-stuff', 'lemons'),
-          '/q/status:open+project:combustible-stuff+branch:lemons');
+          'combustible-stuff', 'le/mons'),
+          '/q/status:open+project:combustible-stuff+branch:le%252Fmons');
 
       element.change = {_number: 42};
       assert.equal(element.changeURL, '/c/42/');
@@ -133,5 +140,75 @@
       assert.equal(element.changeURL, '/c/43/');
     });
 
+    test('no hidden columns', function() {
+      element.visibleChangeTableColumns = [
+        'Subject',
+        'Status',
+        'Owner',
+        'Project',
+        'Branch',
+        'Updated',
+        'Size',
+      ];
+
+      flushAsynchronousOperations();
+
+      element.columnNames.forEach(function(column) {
+        var elementClass = '.' + column.toLowerCase();
+        assert.isFalse(element.$$(elementClass).hidden);
+      });
+    });
+
+    test('no hidden columns', function() {
+      element.visibleChangeTableColumns = [
+        'Subject',
+        'Status',
+        'Owner',
+        'Project',
+        'Branch',
+        'Updated',
+        'Size',
+      ];
+
+      flushAsynchronousOperations();
+
+      element.columnNames.forEach(function(column) {
+        var elementClass = '.' + column.toLowerCase();
+        assert.isFalse(element.$$(elementClass).hidden);
+      });
+    });
+
+    test('project column hidden', function() {
+      element.visibleChangeTableColumns = [
+        'Subject',
+        'Status',
+        'Owner',
+        'Branch',
+        'Updated',
+        'Size',
+      ];
+
+      flushAsynchronousOperations();
+
+      element.columnNames.forEach(function(column) {
+        var elementClass = '.' + column.toLowerCase();
+        if (column === 'Project') {
+          assert.isTrue(element.$$(elementClass).hidden);
+        } else {
+          assert.isFalse(element.$$(elementClass).hidden);
+        }
+      });
+    });
+
+    test('random column does not exist', function() {
+      element.visibleChangeTableColumns = [
+        'Bad',
+      ];
+
+      flushAsynchronousOperations();
+      var elementClass = '.bad';
+      assert.isNotOk(element.$$(elementClass));
+    });
+
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
index 1f06dff..afe0e38 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -14,6 +14,8 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-change-list/gr-change-list.html">
@@ -56,10 +58,12 @@
           selected-index="{{viewState.selectedChangeIndex}}"
           show-star="[[loggedIn]]"></gr-change-list>
       <nav>
-        <a href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
-           hidden$="[[_hidePrevArrow(_offset)]]" hidden>&larr; Prev</a>
-        <a href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
-           hidden$="[[_hideNextArrow(_loading, _changesPerPage)]]" hidden>
+        <a id="prevArrow"
+            href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
+            hidden$="[[_hidePrevArrow(_offset)]]" hidden>&larr; Prev</a>
+        <a id="nextArrow"
+            href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
+            hidden$="[[_hideNextArrow(_loading)]]" hidden>
           Next &rarr;</a>
       </nav>
     </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index 7fbe455..82d85ac 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -14,6 +14,11 @@
 (function() {
   'use strict';
 
+  var LookupQueryPatterns = {
+    CHANGE_ID: /^\s*i?[0-9a-f]{8,40}\s*$/i,
+    CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
+  };
+
   Polymer({
     is: 'gr-change-list-view',
 
@@ -23,6 +28,11 @@
      * @event title-change
      */
 
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
     properties: {
       /**
        * URL params passed from the router.
@@ -54,7 +64,10 @@
       /**
        * Currently active query.
        */
-      _query: String,
+      _query: {
+        type: String,
+        value: '',
+      },
 
       /**
        * Offset of currently visible query results.
@@ -75,6 +88,11 @@
       },
     },
 
+    listeners: {
+      'next-page': '_handleNextPage',
+      'previous-page': '_handlePreviousPage',
+    },
+
     attached: function() {
       this.fire('title-change', {title: this._query});
     },
@@ -98,6 +116,15 @@
         this._changesPerPage = prefs.changes_per_page;
         return this._getChanges();
       }.bind(this)).then(function(changes) {
+        if (this._query && changes.length === 1) {
+          for (var query in LookupQueryPatterns) {
+            if (LookupQueryPatterns.hasOwnProperty(query) &&
+                this._query.match(LookupQueryPatterns[query])) {
+              page.show('/c/' + changes[0]._number);
+              return;
+            }
+          }
+        }
         this._changes = changes;
         this._loading = false;
       }.bind(this));
@@ -116,7 +143,8 @@
       // Offset could be a string when passed from the router.
       offset = +(offset || 0);
       var newOffset = Math.max(0, offset + (changesPerPage * direction));
-      var href = '/q/' + query;
+      // Double encode URI component.
+      var href = this.getBaseUrl() + '/q/' + this.encodeURL(query, false);
       if (newOffset > 0) {
         href += ',' + newOffset;
       }
@@ -127,8 +155,21 @@
       return offset === 0;
     },
 
-    _hideNextArrow: function(loading, changesPerPage) {
-      return loading || !this._changes || this._changes.length < changesPerPage;
+    _hideNextArrow: function(loading) {
+      return loading || !this._changes || !this._changes.length ||
+          !this._changes[this._changes.length - 1]._more_changes;
+    },
+
+    _handleNextPage: function() {
+      if (this.$.nextArrow.hidden) { return; }
+      page.show(this._computeNavLink(
+          this._query, this._offset, 1, this._changesPerPage));
+    },
+
+    _handlePreviousPage: function() {
+      if (this.$.prevArrow.hidden) { return; }
+      page.show(this._computeNavLink(
+          this._query, this._offset, -1, this._changesPerPage));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
new file mode 100644
index 0000000..661dd2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -0,0 +1,215 @@
+<!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-change-list-view</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-list-view.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-list-view></gr-change-list-view>
+  </template>
+</test-fixture>
+
+<script>
+  var CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+  var COMMIT_HASH = '12345678';
+
+  suite('gr-change-list-view tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+        getChanges: function(num, query) {
+          return Promise.resolve([]);
+        },
+      });
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function(done) {
+      flush(function() {
+        sandbox.restore();
+        done();
+      });
+    });
+
+    test('url is properly encoded', function() {
+      assert.equal(element._computeNavLink(
+          'status:open project:platform/frameworks/base', 0, -1, 25),
+          '/q/status:open+project:platform%252Fframeworks%252Fbase'
+      );
+      assert.equal(element._computeNavLink(
+          'status:open project:platform/frameworks/base', 0, 1, 25),
+          '/q/status:open+project:platform%252Fframeworks%252Fbase,25'
+      );
+    });
+
+    test('_computeNavLink', function() {
+      var query = 'status:open';
+      var offset = 0;
+      var direction = 1;
+      var changesPerPage = 5;
+      assert.equal(
+          element._computeNavLink(query, offset, direction, changesPerPage),
+          '/q/status:open,5');
+      direction = -1;
+      assert.equal(
+          element._computeNavLink(query, offset, direction, changesPerPage),
+          '/q/status:open');
+      offset = 5;
+      direction = 1;
+      assert.equal(
+          element._computeNavLink(query, offset, direction, changesPerPage),
+          '/q/status:open,10');
+    });
+
+    test('_computeNavLink with path', function() {
+      window.CANONICAL_PATH = '/r';
+      var query = 'status:open';
+      var offset = 0;
+      var direction = 1;
+      var changesPerPage = 5;
+      assert.equal(
+          element._computeNavLink(query, offset, direction, changesPerPage),
+          '/r/q/status:open,5');
+      direction = -1;
+      assert.equal(
+          element._computeNavLink(query, offset, direction, changesPerPage),
+          '/r/q/status:open');
+      offset = 5;
+      direction = 1;
+      assert.equal(
+          element._computeNavLink(query, offset, direction, changesPerPage),
+          '/r/q/status:open,10');
+    });
+
+    test('_hidePrevArrow', function() {
+      var offset = 0;
+      assert.isTrue(element._hidePrevArrow(offset));
+      offset = 5;
+      assert.isFalse(element._hidePrevArrow(offset));
+    });
+
+    test('_hideNextArrow', function() {
+      var loading = true;
+      assert.isTrue(element._hideNextArrow(loading));
+      loading = false;
+      assert.isTrue(element._hideNextArrow(loading));
+      element._changes = [];
+      assert.isTrue(element._hideNextArrow(loading));
+      element._changes =
+          Array.apply(null, Array(5)).map(Object.prototype.valueOf, {});
+      assert.isTrue(element._hideNextArrow(loading));
+      element._changes =
+          Array.apply(null, Array(25)).map(Object.prototype.valueOf,
+          {_more_changes: true});
+      assert.isFalse(element._hideNextArrow(loading));
+      element._changes =
+          Array.apply(null, Array(25)).map(Object.prototype.valueOf, {});
+      assert.isTrue(element._hideNextArrow(loading));
+    });
+
+    test('_handleNextPage', function() {
+      var showStub = sandbox.stub(page, 'show');
+      element.$.nextArrow.hidden = true;
+      element._handleNextPage();
+      assert.isFalse(showStub.called);
+      element.$.nextArrow.hidden = false;
+      element._handleNextPage();
+      assert.isTrue(showStub.called);
+    });
+
+    test('_handlePreviousPage', function() {
+      var showStub = sandbox.stub(page, 'show');
+      element.$.prevArrow.hidden = true;
+      element._handlePreviousPage();
+      assert.isFalse(showStub.called);
+      element.$.prevArrow.hidden = false;
+      element._handlePreviousPage();
+      assert.isTrue(showStub.called);
+    });
+
+    suite('query based navigation', function() {
+      test('Searching for a change ID redirects to change', function(done) {
+        sandbox.stub(element, '_getChanges')
+            .returns(Promise.resolve([{_number: 1}]));
+        sandbox.stub(page, 'show', function(url) {
+          assert.equal(url, '/c/1');
+          done();
+        });
+
+        element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
+      });
+
+      test('Searching for a change num redirects to change', function(done) {
+        sandbox.stub(element, '_getChanges')
+            .returns(Promise.resolve([{_number: 1}]));
+        sandbox.stub(page, 'show', function(url) {
+          assert.equal(url, '/c/1');
+          done();
+        });
+
+        element.params = {view: 'gr-change-list-view', query: '1'};
+      });
+
+      test('Commit hash redirects to change', function(done) {
+        sandbox.stub(element, '_getChanges')
+            .returns(Promise.resolve([{_number: 1}]));
+        sandbox.stub(page, 'show', function(url) {
+          assert.equal(url, '/c/1');
+          done();
+        });
+
+        element.params = {view: 'gr-change-list-view', query: COMMIT_HASH};
+      });
+
+      test('Searching for an invalid change ID searches', function() {
+        sandbox.stub(element, '_getChanges')
+            .returns(Promise.resolve([]));
+        var stub = sandbox.stub(page, 'show');
+
+        element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
+        flushAsynchronousOperations();
+
+        assert.isFalse(stub.called);
+      });
+
+      test('Change ID with multiple search results searches', function() {
+        sandbox.stub(element, '_getChanges')
+            .returns(Promise.resolve([{}, {}]));
+        var stub = sandbox.stub(page, 'show');
+
+        element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
+        flushAsynchronousOperations();
+
+        assert.isFalse(stub.called);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index bab2014..8a95fa8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -14,9 +14,10 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-change-list-item/gr-change-list-item.html">
@@ -24,46 +25,66 @@
 <dom-module id="gr-change-list">
   <template>
     <style>
-      :host {
-        display: flex;
-        flex-direction: column;
+      #changeList {
+        border-collapse: collapse;
+        width: 100%;
+      }
+      .cell {
+        padding: .3em .5em;
+      }
+      th {
+        text-align: left;
       }
     </style>
     <style include="gr-change-list-styles"></style>
-    <div class="headerRow">
-      <span class="topHeader keyboard"></span> <!-- keyboard position indicator -->
-      <span class="topHeader star" hidden$="[[!showStar]]" hidden></span>
-      <span class="topHeader number" hidden$="[[!showNumber]]" hidden>#</span>
-      <span class="topHeader subject">Subject</span>
-      <span class="topHeader status">Status</span>
-      <span class="topHeader owner">Owner</span>
-      <span class="topHeader project">Project</span>
-      <span class="topHeader branch">Branch</span>
-      <span class="topHeader updated">Updated</span>
-      <span class="topHeader size">Size</span>
-      <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-        <span class="topHeader label" title$="[[labelName]]">
-          [[_computeLabelShortcut(labelName)]]
-        </span>
+    <table id="changeList">
+      <tr class="headerRow">
+        <th class="topHeader keyboard"></th>
+        <th class="topHeader star" hidden$="[[!showStar]]" hidden></th>
+        <th class="topHeader number" hidden$="[[!showNumber]]" hidden>#</th>
+        <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
+          <th class$="[[_lowerCase(item)]] topHeader"
+              hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]">
+            [[item]]
+          </th>
+        </template>
+        <template is="dom-repeat" items="[[labelNames]]" as="labelName">
+          <th class="topHeader label" title$="[[labelName]]">
+            [[_computeLabelShortcut(labelName)]]
+          </th>
+        </template>
+      </tr>
+      <template is="dom-repeat" items="[[groups]]" as="changeGroup"
+          index-as="groupIndex">
+        <template is="dom-if" if="[[_groupTitle(groupIndex)]]">
+          <tr class="groupHeader">
+            <td class="cell"
+                colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
+              [[_groupTitle(groupIndex)]]
+            </td>
+          </tr>
+        </template>
+        <template is="dom-if" if="[[!changeGroup.length]]">
+          <tr class="noChanges">
+            <td class="cell"
+                colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
+              No changes
+            </td>
+          </tr>
+        </template>
+        <template is="dom-repeat" items="[[changeGroup]]" as="change">
+          <gr-change-list-item
+              selected$="[[_computeItemSelected(index, groupIndex, selectedIndex)]]"
+              assigned$="[[_computeItemAssigned(account, change)]]"
+              needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
+              change="[[change]]"
+              visible-change-table-columns="[[visibleChangeTableColumns]]"
+              show-number="[[showNumber]]"
+              show-star="[[showStar]]"
+              label-names="[[labelNames]]"></gr-change-list-item>
+        </template>
       </template>
-    </div>
-    <template is="dom-repeat" items="{{groups}}" as="changeGroup" index-as="groupIndex">
-      <template is="dom-if" if="[[_groupTitle(groupIndex)]]">
-        <div class="groupHeader">[[_groupTitle(groupIndex)]]</div>
-      </template>
-      <template is="dom-if" if="[[!changeGroup.length]]">
-        <div class="noChanges">No changes</div>
-      </template>
-      <template is="dom-repeat" items="[[changeGroup]]" as="change">
-        <gr-change-list-item
-            selected$="[[_computeItemSelected(index, groupIndex, selectedIndex)]]"
-            needs-review="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
-            change="[[change]]"
-            show-number="[[showNumber]]"
-            show-star="[[showStar]]"
-            label-names="[[labelNames]]"></gr-change-list-item>
-      </template>
-    </template>
+    </table>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-change-list.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 4e17253..425ea76 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -14,9 +14,23 @@
 (function() {
   'use strict';
 
+  var NUMBER_FIXED_COLUMNS = 3;
+
   Polymer({
     is: 'gr-change-list',
 
+    /**
+     * Fired when next page key shortcut was pressed.
+     *
+     * @event next-page
+     */
+
+    /**
+     * Fired when previous page key shortcut was pressed.
+     *
+     * @event previous-page
+     */
+
     hostAttributes: {
       tabindex: 0,
     },
@@ -74,23 +88,41 @@
     },
 
     behaviors: [
+      Gerrit.ChangeTableBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
+    keyBindings: {
+      'j': '_handleJKey',
+      'k': '_handleKKey',
+      'n ]': '_handleNKey',
+      'o enter': '_handleEnterKey',
+      'p [': '_handlePKey',
+    },
+
     attached: function() {
       this._loadPreferences();
     },
 
+    _lowerCase: function(column) {
+      return column.toLowerCase();
+    },
+
     _loadPreferences: function() {
       return this._getLoggedIn().then(function(loggedIn) {
+        this.changeTableColumns = this.columnNames;
+
         if (!loggedIn) {
           this.showNumber = false;
+          this.visibleChangeTableColumns = this.columnNames;
           return;
         }
         return this._getPreferences().then(function(preferences) {
           this.showNumber = !!(preferences &&
               preferences.legacycid_in_change_table);
+          this.visibleChangeTableColumns = preferences.change_table.length > 0 ?
+              preferences.change_table : this.columnNames;
         }.bind(this));
       }.bind(this));
     },
@@ -103,6 +135,11 @@
       return this.$.restAPI.getPreferences();
     },
 
+    _computeColspan: function(changeTableColumns, labelNames) {
+      return changeTableColumns.length + labelNames.length +
+          NUMBER_FIXED_COLUMNS;
+    },
+
     _computeLabelNames: function(groups) {
       if (!groups) { return []; }
       var labels = [];
@@ -149,31 +186,59 @@
           account._account_id != change.owner._account_id;
     },
 
-    _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+    _computeItemAssigned: function(account, change) {
+      if (!change.assignee) { return false; }
+      return account._account_id === change.assignee._account_id;
+    },
 
-      if (this.groups == null) { return; }
+    _getAggregateGroupsLen: function(groups) {
+      groups = groups || [];
       var len = 0;
       this.groups.forEach(function(group) {
         len += group.length;
       });
-      switch (e.keyCode) {
-        case 74:  // 'j'
-          e.preventDefault();
-          if (this.selectedIndex == len - 1) { return; }
-          this.selectedIndex += 1;
-          break;
-        case 75:  // 'k'
-          e.preventDefault();
-          if (this.selectedIndex == 0) { return; }
-          this.selectedIndex -= 1;
-          break;
-        case 79:  // 'o'
-        case 13:  // 'enter'
-          e.preventDefault();
-          page.show(this._changeURLForIndex(this.selectedIndex));
-          break;
-      }
+      return len;
+    },
+
+    _handleJKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      var len = this._getAggregateGroupsLen(this.groups);
+      if (this.selectedIndex === len - 1) { return; }
+      this.selectedIndex += 1;
+    },
+
+    _handleKKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      if (this.selectedIndex === 0) { return; }
+      this.selectedIndex -= 1;
+    },
+
+    _handleEnterKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      page.show(this._changeURLForIndex(this.selectedIndex));
+    },
+
+    _handleNKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this.fire('next-page');
+    },
+
+    _handlePKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this.fire('previous-page');
     },
 
     _changeURLForIndex: function(index) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index aa77b77..1a1abff 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -21,11 +21,12 @@
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../bower_components/page/page.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-change-list.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-change-list></gr-change-list>
@@ -57,10 +58,10 @@
     }
 
     suite('test show change number not logged in', function() {
-      setup(function(done) {
+      setup(function() {
         return stubRestAPI(null).then(function() {
           element = fixture('basic');
-          element._loadPreferences().then(function() { done(); });
+          return element._loadPreferences();
         });
       });
 
@@ -70,12 +71,13 @@
     });
 
     suite('test show change number preference enabled', function() {
-      setup(function(done) {
-        return stubRestAPI(
-          {legacycid_in_change_table: true, time_format: 'HHMM_12'}
-        ).then(function() {
+      setup(function() {
+        return stubRestAPI({legacycid_in_change_table: true,
+           time_format: 'HHMM_12',
+           change_table: [],
+        }).then(function() {
           element = fixture('basic');
-          element._loadPreferences().then(function() { done(); });
+          return element._loadPreferences();
         });
       });
 
@@ -85,13 +87,12 @@
     });
 
     suite('test show change number preference disabled', function() {
-      setup(function(done) {
+      setup(function() {
         // legacycid_in_change_table is not set when false.
-        return stubRestAPI(
-          {time_format: 'HHMM_12'}
-        ).then(function() {
+        return stubRestAPI({time_format: 'HHMM_12', change_table: []}).then(
+            function() {
           element = fixture('basic');
-          element._loadPreferences().then(function() { done(); });
+          return element._loadPreferences();
         });
       });
 
@@ -118,6 +119,16 @@
           'Some-Special-Label-7'), 'SSL7');
     });
 
+    test('colspans', function() {
+      var thItemCount = Polymer.dom(element.root).querySelectorAll(
+          'th').length;
+
+      var changeTableColumns = [];
+      var labelNames = [];
+      assert.equal(thItemCount, element._computeColspan(
+          changeTableColumns, labelNames));
+    });
+
     test('keyboard shortcuts', function(done) {
       element.selectedIndex = 0;
       element.changes = [
@@ -131,26 +142,26 @@
       assert.equal(elementItems.length, 3);
 
       flush(function() {
-        assert.isTrue(elementItems[0].selected);
-        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+        assert.isTrue(elementItems[0].hasAttribute('selected'));
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
         var showStub = sinon.stub(page, 'show');
         assert.equal(element.selectedIndex, 2);
-        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWithExactly('/c/2/'),
             'Should navigate to /c/2/');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
+        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWithExactly('/c/1/'),
             'Should navigate to /c/1/');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
-        MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.selectedIndex, 0);
 
         showStub.restore();
@@ -236,6 +247,120 @@
           '.noChanges');
       assert.equal(noChangesMsg.length, 2);
     });
+
+    suite('empty column preference', function() {
+      var element;
+
+      setup(function() {
+        return stubRestAPI({
+          legacycid_in_change_table: true,
+            time_format: 'HHMM_12',
+            change_table: [],
+          }
+        ).then(function() {
+          element = fixture('basic');
+          return element._loadPreferences();
+        });
+      });
+
+      test('show number enabled', function() {
+        assert.isTrue(element.showNumber);
+      });
+
+      test('all columns visible', function() {
+        element.columnNames.forEach(function(column) {
+          var elementClass = '.' + element._lowerCase(column);
+          assert.isFalse(element.$$(elementClass).hidden);
+        });
+      });
+    });
+
+    suite('full column preference', function() {
+      var element;
+
+      setup(function() {
+        return stubRestAPI({
+            legacycid_in_change_table: true,
+            time_format: 'HHMM_12',
+            change_table: [
+              'Subject',
+              'Status',
+              'Owner',
+              'Project',
+              'Branch',
+              'Updated',
+              'Size',
+            ],
+          }).then(function() {
+          element = fixture('basic');
+          return element._loadPreferences();
+        });
+      });
+
+      test('all columns visible', function() {
+        element.changeTableColumns.forEach(function(column) {
+          var elementClass = '.' + element._lowerCase(column);
+          assert.isFalse(element.$$(elementClass).hidden);
+        });
+      });
+    });
+
+    suite('partial column preference', function() {
+      var element;
+
+      setup(function() {
+        return stubRestAPI({
+            legacycid_in_change_table: true,
+            time_format: 'HHMM_12',
+            change_table: [
+              'Subject',
+              'Status',
+              'Owner',
+              'Branch',
+              'Updated',
+              'Size',
+            ],
+          }).then(function() {
+          element = fixture('basic');
+          return element._loadPreferences();
+        });
+      });
+
+      test('all columns except project visible', function() {
+        element.changeTableColumns.forEach(function(column) {
+          var elementClass = '.' + column.toLowerCase();
+          if (column === 'Project') {
+            assert.isTrue(element.$$(elementClass).hidden);
+          } else {
+            assert.isFalse(element.$$(elementClass).hidden);
+          }
+        });
+      });
+    });
+
+    suite('random column does not exist', function() {
+      var element;
+
+      /* This would only exist if somebody manually updated the config
+      file. */
+      setup(function() {
+        return stubRestAPI({
+            legacycid_in_change_table: true,
+            time_format: 'HHMM_12',
+            change_table: [
+              'Bad',
+            ],
+          }).then(function() {
+          element = fixture('basic');
+          return element._loadPreferences();
+        });
+      });
+
+      test('bad column does not exist', function() {
+        var elementClass = '.bad';
+        assert.isNotOk(element.$$(elementClass));
+      });
+    });
   });
 
   suite('gr-change-list groups', function() {
@@ -296,5 +421,32 @@
       showStub.restore();
     });
 
+    test('assigned attribute set in each item', function() {
+      element.changes = [
+        {
+          _number: 0,
+          status: 'NEW',
+          owner: {_account_id: 0},
+        },
+        {
+          _number: 1,
+          status: 'DRAFT',
+          owner: {_account_id: 42},
+        },
+        {
+          _number: 2,
+          status: 'ABANDONED',
+          owner: {_account_id: 0},
+        },
+      ];
+      element.account = {_account_id: 42};
+      flushAsynchronousOperations();
+      var items = element._getListItems();
+      assert.equal(items.length, 3);
+      for (var i = 0; i < items.length; i++) {
+        assert.equal(items[i].hasAttribute('assigned'),
+            items[i]._account_id === element.account._account_id);
+      }
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 3ac6463..977552a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -29,6 +29,10 @@
         value: function() { return {}; },
       },
       viewState: Object,
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
 
       _results: Array,
       _groupTitles: {
@@ -51,14 +55,19 @@
 
     attached: function() {
       this.fire('title-change', {title: 'My Reviews'});
+    },
 
+    /**
+     * Allows a refresh if menu item is selected again.
+     */
+    _paramsChanged: function() {
       this._loading = true;
       this._getDashboardChanges().then(function(results) {
         this._results = results;
         this._loading = false;
       }.bind(this)).catch(function(err) {
         this._loading = false;
-        console.error(err.message);
+        console.warn(err.message);
       }.bind(this));
     },
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
new file mode 100644
index 0000000..718e59c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-dashboard-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-dashboard-view.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-dashboard-view></gr-dashboard-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-dashboard-view tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('content is refreshed with same dropdown selected twice', function() {
+      var getChangesStub = sinon.stub(element, '_getDashboardChanges',
+          function() {
+        return Promise.resolve();
+      });
+
+      element.params = {view: 'gr-dashboard-view'};
+
+      assert.equal(getChangesStub.callCount, 1);
+      element.params = {view: 'gr-dashboard-view'};
+      assert.equal(getChangesStub.callCount, 2);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
index c5827d0..46f2ed2 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -28,6 +28,16 @@
       change: Object,
       filter: Function,
       placeholder: String,
+      /**
+       * When true, account-entry uses the account suggest API endpoint, which
+       * suggests any account in that Gerrit instance (and does not suggest
+       * groups).
+       *
+       * When false/undefined, account-entry uses the suggest_reviewers API
+       * endpoint, which suggests any account or group in that Gerrit instance
+       * that is not already a reviewer (or is not CCed) on that change.
+       */
+      allowAnyUser: Boolean,
 
       suggestFrom: {
         type: Number,
@@ -59,29 +69,41 @@
     },
 
     _makeSuggestion: function(reviewer) {
+      var name;
+      var value;
+      var generateStatusStr = function(account) {
+        return account.status ? ' (' + account.status + ')' : '';
+      };
       if (reviewer.account) {
-        return {
-          name: reviewer.account.name + ' (' + reviewer.account.email + ')',
-          value: reviewer,
-        };
+        // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+        name = reviewer.account.name + ' <' + reviewer.account.email + '>' +
+            generateStatusStr(reviewer.account);
+        value = reviewer;
       } else if (reviewer.group) {
-        return {
-          name: reviewer.group.name + ' (group)',
-          value: reviewer,
-        };
+        // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+        name = reviewer.group.name + ' (group)';
+        value = reviewer;
+      } else if (reviewer._account_id) {
+        // Reviewer is an account suggestion from getSuggestedAccounts.
+        name = reviewer.name + ' <' + reviewer.email + '>' +
+            generateStatusStr(reviewer);
+        value = {account: reviewer, count: 1};
       }
+      return {name: name, value: value};
     },
 
     _getReviewerSuggestions: function(input) {
-      var xhr = this.$.restAPI.getChangeSuggestedReviewers(
-          this.change._number, input);
+      var api = this.$.restAPI;
+      var xhr = this.allowAnyUser ?
+          api.getSuggestedAccounts(input) :
+          api.getChangeSuggestedReviewers(this.change._number, input);
 
       return xhr.then(function(reviewers) {
         if (!reviewers) { return []; }
         if (!this.filter) { return reviewers.map(this._makeSuggestion); }
         return reviewers
             .filter(this.filter)
-            .map(this._makeSuggestion);
+            .map(this._makeSuggestion.bind(this));
       }.bind(this));
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
index 94db890..2948b4b 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -25,6 +25,8 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-account-entry.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-account-entry></gr-account-entry>
@@ -33,13 +35,15 @@
 
 <script>
   suite('gr-account-entry tests', function() {
+    var sandbox;
     var _nextAccountId = 0;
-    var makeAccount = function() {
+    var makeAccount = function(opt_status) {
       var accountId = ++_nextAccountId;
       return {
         _account_id: accountId,
         name: 'name ' + accountId,
         email: 'email ' + accountId,
+        status: opt_status,
       };
     };
 
@@ -72,49 +76,99 @@
           REVIEWER: [existingReviewer2],
         },
       };
-
-      stub('gr-rest-api-interface', {
-        getChangeSuggestedReviewers: function() {
-          var redundantSuggestion1 = {account: existingReviewer1};
-          var redundantSuggestion2 = {account: existingReviewer2};
-          var redundantSuggestion3 = {account: owner};
-          return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-        },
-      });
+      sandbox = sinon.sandbox.create();
     });
 
-    test('_makeSuggestion formats account or group accordingly', function() {
-      var account = makeAccount();
-      var suggestion = element._makeSuggestion({account: account});
-      assert.deepEqual(suggestion, {
-        name: account.name + ' (' + account.email + ')',
-        value: {account: account},
-      });
-
-      var group = {name: 'test'};
-      suggestion = element._makeSuggestion({group: group});
-      assert.deepEqual(suggestion, {
-        name: group.name + ' (group)',
-        value: {group: group},
-      });
+    teardown(function() {
+      sandbox.restore();
     });
 
-    test('_getReviewerSuggestions excludes owner+reviewers', function(done) {
-      element._getReviewerSuggestions().then(function(reviewers) {
-        // Default is no filtering.
-        assert.equal(reviewers.length, 6);
+    suite('stubbed values for _getReviewerSuggestions', function() {
+      setup(function() {
+        stub('gr-rest-api-interface', {
+          getChangeSuggestedReviewers: function() {
+            var redundantSuggestion1 = {account: existingReviewer1};
+            var redundantSuggestion2 = {account: existingReviewer2};
+            var redundantSuggestion3 = {account: owner};
+            return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
+                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+          },
+        });
+      });
 
-        // Set up filter that only accepts suggestion1.
-        var accountId = suggestion1.account._account_id;
-        element.filter = function(suggestion) {
-          return suggestion.account &&
-              suggestion.account._account_id === accountId;
-        };
+      test('_makeSuggestion formats account or group accordingly', function() {
+        var account = makeAccount();
+        var suggestion = element._makeSuggestion({account: account});
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '>',
+          value: {account: account},
+        });
 
+        var group = {name: 'test'};
+        suggestion = element._makeSuggestion({group: group});
+        assert.deepEqual(suggestion, {
+          name: group.name + ' (group)',
+          value: {group: group},
+        });
+
+        suggestion = element._makeSuggestion(account);
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '>',
+          value: {account: account, count: 1},
+        });
+
+        account = makeAccount('OOO');
+
+        suggestion = element._makeSuggestion({account: account});
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '> (OOO)',
+          value: {account: account},
+        });
+
+        suggestion = element._makeSuggestion(account);
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '> (OOO)',
+          value: {account: account, count: 1},
+        });
+      });
+
+      test('_getReviewerSuggestions excludes owner+reviewers', function(done) {
         element._getReviewerSuggestions().then(function(reviewers) {
-          assert.deepEqual(reviewers, [element._makeSuggestion(suggestion1)]);
-        }).then(done);
+          // Default is no filtering.
+          assert.equal(reviewers.length, 6);
+
+          // Set up filter that only accepts suggestion1.
+          var accountId = suggestion1.account._account_id;
+          element.filter = function(suggestion) {
+            return suggestion.account &&
+                suggestion.account._account_id === accountId;
+          };
+
+          element._getReviewerSuggestions().then(function(reviewers) {
+            assert.deepEqual(reviewers, [element._makeSuggestion(suggestion1)]);
+          }).then(done);
+        });
+      });
+    });
+
+    test('allowAnyUser', function(done) {
+      var suggestReviewerStub =
+          sandbox.stub(element.$.restAPI, 'getChangeSuggestedReviewers')
+          .returns(Promise.resolve([]));
+      var suggestAccountStub =
+          sandbox.stub(element.$.restAPI, 'getSuggestedAccounts')
+          .returns(Promise.resolve([]));
+
+      element._getReviewerSuggestions('').then(function() {
+        assert.isTrue(suggestReviewerStub.calledOnce);
+        assert.isFalse(suggestAccountStub.called);
+        element.allowAnyUser = true;
+
+        element._getReviewerSuggestions('').then(function() {
+          assert.isTrue(suggestReviewerStub.calledOnce);
+          assert.isTrue(suggestAccountStub.calledOnce);
+          done();
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
index 98f2b18..810658c 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
@@ -42,17 +42,21 @@
           account="[[account]]"
           class$="[[_computeChipClass(account)]]"
           data-account-id$="[[account._account_id]]"
-          removable="[[_computeRemovable(account)]]">
+          removable="[[_computeRemovable(account)]]"
+          on-keydown="_handleChipKeydown"
+          tabindex$="[[index]]">
       </gr-account-chip>
     </template>
     <gr-account-entry
         borderless
-        hidden$="[[readonly]]"
+        hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
         id="entry"
         change="[[change]]"
         filter="[[filter]]"
         placeholder="[[placeholder]]"
-        on-add="_handleAdd">
+        on-add="_handleAdd"
+        on-input-keydown="_handleInputKeydown"
+        allow-any-user="[[allowAnyUser]]">
     </gr-account-entry>
   </template>
   <script src="gr-account-list.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
index 87d7116..35311f9 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -21,6 +21,7 @@
       accounts: {
         type: Array,
         value: function() { return []; },
+        notify: true,
       },
       change: Object,
       filter: Function,
@@ -30,13 +31,42 @@
         value: null,
         notify: true,
       },
-      readonly: Boolean,
+      readonly: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * When true, the account-entry autocomplete uses the account suggest API
+       * endpoint, which suggests any account in that Gerrit instance (and does
+       * not suggest groups).
+       *
+       * When false/undefined, account-entry uses the suggest_reviewers API
+       * endpoint, which suggests any account or group in that Gerrit instance
+       * that is not already a reviewer (or is not CCed) on that change.
+       */
+      allowAnyUser: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * Array of values (groups/accounts) that are removable. When this prop is
+       * undefined, all values are removable.
+       */
+      removableValues: Array,
+      maxCount: {
+        type: Number,
+        value: 0,
+      },
     },
 
     listeners: {
       'remove': '_handleRemove',
     },
 
+    get accountChips() {
+      return Polymer.dom(this.root).querySelectorAll('gr-account-chip');
+    },
+
     get focusStart() {
       return this.$.entry.focusStart;
     },
@@ -81,11 +111,26 @@
     },
 
     _computeRemovable: function(account) {
-      return !this.readonly && !!account._pendingAdd;
+      if (this.readonly) { return false; }
+      if (this.removableValues) {
+        for (var i = 0; i < this.removableValues.length; i++) {
+          if (this.removableValues[i]._account_id === account._account_id) {
+            return true;
+          }
+        }
+        return !!account._pendingAdd;
+      }
+      return true;
     },
 
     _handleRemove: function(e) {
       var toRemove = e.detail.account;
+      this._removeAccount(toRemove);
+      this.$.entry.focus();
+    },
+
+    _removeAccount: function(toRemove) {
+      if (!toRemove || !this._computeRemovable(toRemove)) { return; }
       for (var i = 0; i < this.accounts.length; i++) {
         var matches;
         var account = this.accounts[i];
@@ -96,16 +141,70 @@
         }
         if (matches) {
           this.splice('accounts', i, 1);
-          this.$.entry.focus();
           return;
         }
       }
-      console.warn('received remove event for missing account',
-          e.detail.account);
+      console.warn('received remove event for missing account', toRemove);
+    },
+
+    _handleInputKeydown: function(e) {
+      var input = e.detail.input;
+      if (input.selectionStart !== input.selectionEnd ||
+          input.selectionStart !== 0) {
+        return;
+      }
+      switch (e.detail.keyCode) {
+        case 8: // Backspace
+          this._removeAccount(this.accounts[this.accounts.length - 1]);
+          break;
+        case 37: // Left arrow
+          var chips = this.accountChips;
+          if (chips[chips.length - 1]) {
+            chips[chips.length - 1].focus();
+          }
+          break;
+      }
+    },
+
+    _handleChipKeydown: function(e) {
+      var chip = e.target;
+      var chips = this.accountChips;
+      var index = chips.indexOf(chip);
+      switch (e.keyCode) {
+        case 8: // Backspace
+        case 13: // Enter
+        case 32: // Spacebar
+        case 46: // Delete
+          this._removeAccount(chip.account);
+          // Splice from this array to avoid inconsistent ordering of
+          // event handling.
+          chips.splice(index, 1);
+          if (index < chips.length) {
+            chips[index].focus();
+          } else if (index > 0) {
+            chips[index - 1].focus();
+          } else {
+            this.$.entry.focus();
+          }
+          break;
+        case 37: // Left arrow
+          if (index > 0) {
+            chip.blur();
+            chips[index - 1].focus();
+          }
+          break;
+        case 39: // Right arrow
+          chip.blur();
+          if (index < chips.length - 1) {
+            chips[index + 1].focus();
+          } else {
+            this.$.entry.focus();
+          }
+          break;
+      }
     },
 
     additions: function() {
-      var result = [];
       return this.accounts.filter(function(account) {
         return account._pendingAdd;
       }).map(function(account) {
@@ -115,7 +214,10 @@
           return {account: account};
         }
       });
-      return result;
+    },
+
+    _computeEntryHidden: function(maxCount, accountsRecord, readonly) {
+      return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
index bb55d08..b3c9e9e 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -20,11 +20,12 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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-account-list.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-account-list></gr-account-list>
@@ -49,6 +50,7 @@
 
     var existingReviewer1;
     var existingReviewer2;
+    var sandbox;
     var element;
 
     function getChips() {
@@ -56,17 +58,19 @@
     }
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
 
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       element = fixture('basic');
       element.accounts = [existingReviewer1, existingReviewer2];
+    });
 
-      stub('gr-rest-api-interface', {
-        getConfig: function() {
-          return Promise.resolve({});
-        },
-      });
+    teardown(function() {
+      sandbox.restore();
     });
 
     test('account entry only appears when editable', function() {
@@ -78,7 +82,7 @@
 
     test('addition and removal of account/group chips', function() {
       flushAsynchronousOperations();
-
+      sandbox.stub(element, '_computeRemovable').returns(true);
       // Existing accounts are listed.
       var chips = getChips();
       assert.equal(chips.length, 2);
@@ -155,9 +159,16 @@
       var newAccount = makeAccount();
       newAccount._pendingAdd = true;
       element.readonly = false;
+      element.removableValues = [];
       assert.isFalse(element._computeRemovable(existingReviewer1));
       assert.isTrue(element._computeRemovable(newAccount));
 
+
+      element.removableValues = [existingReviewer1];
+      assert.isTrue(element._computeRemovable(existingReviewer1));
+      assert.isTrue(element._computeRemovable(newAccount));
+      assert.isFalse(element._computeRemovable(existingReviewer2));
+
       element.readonly = true;
       assert.isFalse(element._computeRemovable(existingReviewer1));
       assert.isFalse(element._computeRemovable(newAccount));
@@ -232,5 +243,86 @@
         },
       ]);
     });
+
+    test('removeAccount fails if account is not removable', function() {
+      element.readonly = true;
+      var acct = makeAccount();
+      element.accounts = [acct];
+      element._removeAccount(acct);
+      assert.equal(element.accounts.length, 1);
+    });
+
+    test('max-count', function() {
+      element.maxCount = 1;
+      var acct = makeAccount();
+      element._handleAdd({
+        detail: {
+          value: {
+            account: acct,
+          },
+        },
+      });
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.entry.hasAttribute('hidden'));
+    });
+
+    suite('keyboard interactions', function() {
+
+      test('backspace at text input start removes last account', function() {
+        var input = element.$.entry.$.input;
+        sandbox.stub(element.$.entry, '_getReviewerSuggestions');
+        sandbox.stub(input, '_updateSuggestions');
+        sandbox.stub(element, '_computeRemovable').returns(true);
+        // Next line is a workaround for Firefix not moving cursor
+        // on input field update
+        assert.equal(input.$.input.selectionStart, 0);
+        input.text = 'test';
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        assert.equal(element.accounts.length, 2);
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
+        assert.equal(element.accounts.length, 2);
+        input.text = '';
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
+        assert.equal(element.accounts.length, 1);
+      });
+
+      test('arrow key navigation', function() {
+        var input = element.$.entry.$.input;
+        input.text = '';
+        element.accounts = [makeAccount(), makeAccount()];
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        var chips = element.accountChips;
+        var chipsOneSpy = sandbox.spy(chips[1], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+        assert.isTrue(chipsOneSpy.called);
+        var chipsZeroSpy = sandbox.spy(chips[0], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+        assert.isTrue(chipsZeroSpy.called);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+        assert.isTrue(chipsZeroSpy.calledOnce);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+        assert.isTrue(chipsOneSpy.calledTwice);
+      });
+
+      test('delete', function(done) {
+        element.accounts = [makeAccount(), makeAccount()];
+        flush(function() {
+          var chips = element.accountChips;
+          var focusSpy = sandbox.spy(element.accountChips[1], 'focus');
+          var removeSpy = sandbox.spy(element, '_removeAccount');
+          MockInteractions.pressAndReleaseKeyOn(
+              element.accountChips[0], 8); // Backspace
+          assert.isTrue(focusSpy.called);
+          assert.isTrue(removeSpy.calledOnce);
+
+          MockInteractions.pressAndReleaseKeyOn(
+              element.accountChips[1], 46); // Delete
+          assert.isTrue(removeSpy.calledTwice);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index b741784..71ccb04 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -14,11 +14,12 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -32,75 +33,94 @@
   <template>
     <style>
       :host {
-        display: block;
+        display: inline-block;
+        font-family: var(--font-family);
       }
       section {
-        margin-top: 1em;
+        display: inline-block;
       }
-      .groupLabel {
-        color: #666;
-        margin-bottom: .15em;
-        text-align: center;
-      }
-      gr-button {
-        display: block;
-        margin-bottom: .5em;
+      gr-button,
+      gr-dropdown {
+        margin-left: .5em;
       }
       gr-button:before {
         content: attr(data-label);
       }
-      gr-button[loading]:before {
-        content: attr(data-loading-label);
+      #actionLoadingMessage {
+        color: #777;
       }
       @media screen and (max-width: 50em) {
+        :host,
+        section,
+        gr-button,
+        gr-dropdown {
+          display: block;
+        }
+        gr-button,
+        gr-dropdown {
+          margin-bottom: .5em;
+          margin-left: 0;
+        }
         .confirmDialog {
           width: 90vw;
         }
+        #actionLoadingMessage {
+          display: block;
+          margin: .5em;
+          text-align: center;
+        }
       }
     </style>
     <div>
-      <section hidden$="[[!_actionCount(actions.*, _additionalActions.*)]]">
-        <div class="groupLabel">Change</div>
-        <template is="dom-repeat" items="[[_changeActionValues]]" as="action">
+      <span
+          id="actionLoadingMessage"
+          hidden$="[[!_actionLoadingMessage]]">
+        [[_actionLoadingMessage]]</span>
+      <gr-dropdown
+          id="moreActions"
+          down-arrow
+          vertical-offset="32"
+          horizontal-align="right"
+          on-tap-item-cherrypick="_handleCherrypickTap"
+          on-tap-item-delete="_handleDeleteTap"
+          hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
+          disabled-ids="[[_disabledMenuActions]]"
+          items="[[_menuActions]]">More</gr-dropdown>
+      <section hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
+        <template
+            is="dom-repeat"
+            items="[[_topLevelActions]]"
+            as="action">
           <gr-button title$="[[action.title]]"
               primary$="[[action.__primary]]"
-              hidden$="[[!action.enabled]]"
               data-action-key$="[[action.__key]]"
               data-action-type$="[[action.__type]]"
               data-label$="[[action.label]]"
-              data-loading-label$="[[_computeLoadingLabel(action.__key)]]"
+              disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
               on-tap="_handleActionTap"></gr-button>
         </template>
       </section>
-      <section hidden$="[[!_actionCount(_revisionActions.*, _additionalActions.*)]]">
-        <div class="groupLabel">Revision</div>
-        <template is="dom-repeat" items="[[_revisionActionValues]]" as="action">
-          <gr-button title$="[[action.title]]"
-              primary$="[[action.__primary]]"
-              disabled$="[[!action.enabled]]"
-              data-action-key$="[[action.__key]]"
-              data-action-type$="[[action.__type]]"
-              data-label$="[[action.label]]"
-              data-loading-label$="[[_computeLoadingLabel(action.__key)]]"
-              on-tap="_handleActionTap"></gr-button>
-        </template>
-      </section>
+      <gr-button hidden$="[[!_loading]]" disabled>Loading actions...</gr-button>
     </div>
     <gr-overlay id="overlay" with-backdrop>
       <gr-confirm-rebase-dialog id="confirmRebase"
           class="confirmDialog"
           on-confirm="_handleRebaseConfirm"
           on-cancel="_handleConfirmDialogCancel"
+          branch="[[change.branch]]"
+          has-parent="[[hasParent]]"
+          rebase-on-current="[[revisionActions.rebase.rebaseOnCurrent]]"
           hidden></gr-confirm-rebase-dialog>
       <gr-confirm-cherrypick-dialog id="confirmCherrypick"
           class="confirmDialog"
-          commit-info="[[commitInfo]]"
+          change-status="[[changeStatus]]"
+          commit-message="[[commitMessage]]"
+          commit-num="[[commitNum]]"
           on-confirm="_handleCherrypickConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-cherrypick-dialog>
       <gr-confirm-revert-dialog id="confirmRevertDialog"
           class="confirmDialog"
-          commit-info="[[commitInfo]]"
           on-confirm="_handleRevertDialogConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-revert-dialog>
@@ -109,6 +129,19 @@
           on-confirm="_handleAbandonDialogConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-abandon-dialog>
+      <gr-confirm-dialog
+          id="confirmDeleteDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          on-cancel="_handleConfirmDialogCancel"
+          on-confirm="_handleDeleteConfirm">
+        <div class="header">
+          Delete Change
+        </div>
+        <div class="main">
+          Do you really want to delete the change?
+        </div>
+      </gr-confirm-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 3445f4e..2b0916d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -14,6 +14,35 @@
 (function() {
   'use strict';
 
+  /**
+   * @enum {number}
+   */
+  var LabelStatus = {
+    /**
+     * This label provides what is necessary for submission.
+     */
+    OK: 'OK',
+    /**
+     * This label prevents the change from being submitted.
+     */
+    REJECT: 'REJECT',
+    /**
+     * The label may be set, but it's neither necessary for submission
+     * nor does it block submission if set.
+     */
+    MAY: 'MAY',
+    /**
+     * The label is required for submission, but has not been satisfied.
+     */
+    NEED: 'NEED',
+    /**
+     * The label is required for submission, but is impossible to complete.
+     * The likely cause is access has not been granted correctly by the
+     * project owner or site administrator.
+     */
+    IMPOSSIBLE: 'IMPOSSIBLE',
+  };
+
   // TODO(davido): Add the rest of the change actions.
   var ChangeActions = {
     ABANDON: 'abandon',
@@ -49,6 +78,24 @@
 
   var ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
 
+  var QUICK_APPROVE_ACTION = {
+    __key: 'review',
+    __type: 'change',
+    enabled: true,
+    key: 'review',
+    label: 'Quick Approve',
+    method: 'POST',
+  };
+
+  /**
+   * Keys for actions to appear in the overflow menu rather than the top-level
+   * set of action buttons.
+   */
+  var MENU_ACTION_KEYS = [
+    'cherrypick',
+    '/', // '/' is the key for the delete action.
+  ];
+
   Polymer({
     is: 'gr-change-actions',
 
@@ -58,6 +105,12 @@
      * @event reload-change
      */
 
+    /**
+     * Fired when an action is tapped.
+     *
+     * @event <action key>-tap
+     */
+
     properties: {
       change: Object,
       actions: {
@@ -73,32 +126,61 @@
           ];
         },
       },
+      _hasKnownChainState: {
+        type: Boolean,
+        value: false,
+      },
       changeNum: String,
+      changeStatus: String,
+      commitNum: String,
+      hasParent: {
+        type: Boolean,
+        observer: '_computeChainState',
+      },
       patchNum: String,
-      commitInfo: Object,
+      commitMessage: {
+        type: String,
+        value: '',
+      },
+      revisionActions: {
+        type: Object,
+        value: function() { return {}; },
+      },
 
       _loading: {
         type: Boolean,
         value: true,
       },
-      _revisionActions: {
-        type: Object,
-        value: function() { return {}; },
+      _actionLoadingMessage: {
+        type: String,
+        value: null,
       },
-      _revisionActionValues: {
+      _allActionValues: {
         type: Array,
-        computed: '_computeRevisionActionValues(_revisionActions.*, ' +
-            'primaryActionKeys.*, _additionalActions.*)',
+        computed: '_computeAllActions(actions.*, revisionActions.*,' +
+            'primaryActionKeys.*, _additionalActions.*, change)',
       },
-      _changeActionValues: {
+      _topLevelActions: {
         type: Array,
-        computed: '_computeChangeActionValues(actions.*, ' +
-            'primaryActionKeys.*, _additionalActions.*)',
+        computed: '_computeTopLevelActions(_allActionValues.*, ' +
+            '_hiddenActions.*)',
+      },
+      _menuActions: {
+        type: Array,
+        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*)',
       },
       _additionalActions: {
         type: Array,
         value: function() { return []; },
       },
+      _hiddenActions: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _disabledMenuActions: {
+        type: Array,
+        value: function() { return []; },
+      },
     },
 
     ActionType: ActionType,
@@ -110,11 +192,12 @@
     ],
 
     observers: [
-      '_actionsChanged(actions.*, _revisionActions.*, _additionalActions.*)',
+      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
     ],
 
     ready: function() {
       this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
+      this._loading = false;
     },
 
     reload: function() {
@@ -126,7 +209,7 @@
       return this._getRevisionActions().then(function(revisionActions) {
         if (!revisionActions) { return; }
 
-        this._revisionActions = revisionActions;
+        this.revisionActions = revisionActions;
         this._loading = false;
       }.bind(this)).catch(function(err) {
         alert('Couldn’t load revision actions. Check the console ' +
@@ -166,6 +249,19 @@
       ], value);
     },
 
+    setActionHidden: function(type, key, hidden) {
+      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+        throw Error('Invalid action type given: ' + type);
+      }
+
+      var idx = this._hiddenActions.indexOf(key);
+      if (hidden && idx === -1) {
+        this.push('_hiddenActions', key);
+      } else if (!hidden && idx !== -1) {
+        this.splice('_hiddenActions', idx, 1);
+      }
+    },
+
     _indexOfActionButtonWithKey: function(key) {
       for (var i = 0; i < this._additionalActions.length; i++) {
         if (this._additionalActions[i].__key === key) {
@@ -180,10 +276,8 @@
           this.patchNum);
     },
 
-    _actionCount: function(actionsChangeRecord, additionalActionsChangeRecord) {
-      var additionalActions = (additionalActionsChangeRecord &&
-          additionalActionsChangeRecord.base) || [];
-      return this._keyCount(actionsChangeRecord) + additionalActions.length;
+    _shouldHideActions: function(actions, loading) {
+      return loading || !actions || !actions.base || !actions.base.length;
     },
 
     _keyCount: function(changeRecord) {
@@ -197,6 +291,8 @@
       this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
           this._keyCount(revisionActionsChangeRecord) === 0 &&
               additionalActions.length === 0;
+      this._actionLoadingMessage = null;
+      this._disabledMenuActions = [];
     },
 
     _getValuesFor: function(obj) {
@@ -205,16 +301,80 @@
       });
     },
 
-    _computeRevisionActionValues: function(actionsChangeRecord,
-        primariesChangeRecord, additionalActionsChangeRecord) {
-      return this._getActionValues(actionsChangeRecord, primariesChangeRecord,
-          additionalActionsChangeRecord, 'revision');
+    _getLabelStatus: function(label) {
+      if (label.approved) {
+        return LabelStatus.OK;
+      } else if (label.rejected) {
+        return LabelStatus.REJECT;
+      } else if (label.optional) {
+        return LabelStatus.OPTIONAL;
+      } else {
+        return LabelStatus.NEED;
+      }
     },
 
-    _computeChangeActionValues: function(actionsChangeRecord,
-        primariesChangeRecord, additionalActionsChangeRecord) {
-      return this._getActionValues(actionsChangeRecord, primariesChangeRecord,
-          additionalActionsChangeRecord, 'change');
+    /**
+     * Get highest score for last missing permitted label for current change.
+     * Returns null if no labels permitted or more than one label missing.
+     *
+     * @return {{label: string, score: string}}
+     */
+    _getTopMissingApproval: function() {
+      if (!this.change ||
+          !this.change.labels ||
+          !this.change.permitted_labels) {
+        return null;
+      }
+      var result;
+      for (var label in this.change.labels) {
+        if (!(label in this.change.permitted_labels)) {
+          continue;
+        }
+        if (this.change.permitted_labels[label].length === 0) {
+          continue;
+        }
+        var status = this._getLabelStatus(this.change.labels[label]);
+        if (status === LabelStatus.NEED) {
+          if (result) {
+            // More than one label is missing, so it's unclear which to quick
+            // approve, return null;
+            return null;
+          }
+          result = label;
+        } else if (status === LabelStatus.REJECT ||
+                   status === LabelStatus.IMPOSSIBLE) {
+          return null;
+        }
+      }
+      if (result) {
+        var score = this.change.permitted_labels[result].slice(-1)[0];
+        var maxScore =
+            Object.keys(this.change.labels[result].values).slice(-1)[0];
+        if (score === maxScore) {
+          // Allow quick approve only for maximal score.
+          return {
+            label: result,
+            score: score,
+          };
+        }
+      }
+      return null;
+    },
+
+    _getQuickApproveAction: function() {
+      var approval = this._getTopMissingApproval();
+      if (!approval) {
+        return null;
+      }
+      var action = Object.assign({}, QUICK_APPROVE_ACTION);
+      action.label = approval.label + approval.score;
+      var review = {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {},
+      };
+      review.labels[approval.label] = approval.score;
+      action.payload = review;
+      return action;
     },
 
     _getActionValues: function(actionsChangeRecord, primariesChangeRecord,
@@ -231,6 +391,15 @@
         actions[a].__key = a;
         actions[a].__type = type;
         actions[a].__primary = primaryActionKeys.indexOf(a) !== -1;
+        if (actions[a].label === 'Delete') {
+          // This label is common within change and revision actions. Make it
+          // more explicit to the user.
+          if (type === ActionType.CHANGE) {
+            actions[a].label += ' Change';
+          } else if (type === ActionType.REVISION) {
+            actions[a].label += ' Revision';
+          }
+        }
         // Triggers a re-render by ensuring object inequality.
         // TODO(andybons): Polyfill for Object.assign.
         result.push(Object.assign({}, actions[a]));
@@ -254,12 +423,31 @@
     },
 
     _canSubmitChange: function() {
-      return this.$.jsAPI.canSubmitChange();
+      return this.$.jsAPI.canSubmitChange(this.change,
+          this._getRevision(this.change, this.patchNum));
+    },
+
+    _getRevision: function(change, patchNum) {
+      var num = window.parseInt(patchNum, 10);
+      for (var hash in change.revisions) {
+        var rev = change.revisions[hash];
+        if (rev._number === num) {
+          return rev;
+        }
+      }
+      return null;
     },
 
     _modifyRevertMsg: function() {
       return this.$.jsAPI.modifyRevertMsg(this.change,
-                                          this.$.confirmRevertDialog.message);
+          this.$.confirmRevertDialog.message, this.commitMessage);
+    },
+
+    showRevertDialog: function() {
+      this.$.confirmRevertDialog.populateRevertMessage(
+          this.commitMessage, this.change.current_revision);
+      this.$.confirmRevertDialog.message = this._modifyRevertMsg();
+      this._showActionDialog(this.$.confirmRevertDialog);
     },
 
     _handleActionTap: function(e) {
@@ -274,11 +462,15 @@
       if (type === ActionType.REVISION) {
         this._handleRevisionAction(key);
       } else if (key === ChangeActions.REVERT) {
-        this.$.confirmRevertDialog.populateRevertMessage();
-        this.$.confirmRevertDialog.message = this._modifyRevertMsg();
-        this._showActionDialog(this.$.confirmRevertDialog);
+        this.showRevertDialog();
       } else if (key === ChangeActions.ABANDON) {
         this._showActionDialog(this.$.confirmAbandonDialog);
+      } else if (key === QUICK_APPROVE_ACTION.key) {
+        var action = this._allActionValues.find(function(o) {
+          return o.key === key;
+        });
+        this._fireAction(
+            this._prependSlash(key), action, true, action.payload);
       } else {
         this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
@@ -289,17 +481,14 @@
         case RevisionActions.REBASE:
           this._showActionDialog(this.$.confirmRebase);
           break;
-        case RevisionActions.CHERRYPICK:
-          this._showActionDialog(this.$.confirmCherrypick);
-          break;
         case RevisionActions.SUBMIT:
           if (!this._canSubmitChange()) {
             return;
           }
-          /* falls through */ // required by JSHint
+        /* falls through */ // required by JSHint
         default:
           this._fireAction(this._prependSlash(key),
-              this._revisionActions[key], true);
+              this.revisionActions[key], true);
       }
     },
 
@@ -307,7 +496,27 @@
       return key === '/' ? key : '/' + key;
     },
 
+    /**
+     * Returns true if hasParent is defined (can be either true or false).
+     * returns false otherwise.
+     * @return {boolean} hasParent
+     */
+    _computeChainState: function(hasParent) {
+      this._hasKnownChainState = true;
+    },
+
+    _calculateDisabled: function(action, hasKnownChainState) {
+      if (action.__key === 'rebase' && hasKnownChainState === false) {
+        return true;
+      }
+      return !action.enabled;
+    },
+
     _handleConfirmDialogCancel: function() {
+      this._hideAllDialogs();
+    },
+
+    _hideAllDialogs: function() {
       var dialogEls =
           Polymer.dom(this.root).querySelectorAll('.confirmDialog');
       for (var i = 0; i < dialogEls.length; i++) {
@@ -317,22 +526,11 @@
     },
 
     _handleRebaseConfirm: function() {
-      var payload = {};
       var el = this.$.confirmRebase;
-      if (el.clearParent) {
-        // There is a subtle but important difference between setting the base
-        // to an empty string and omitting it entirely from the payload. An
-        // empty string implies that the parent should be cleared and the
-        // change should be rebased on top of the target branch. Leaving out
-        // the base implies that it should be rebased on top of its current
-        // parent.
-        payload.base = '';
-      } else if (el.base && el.base.length > 0) {
-        payload.base = el.base;
-      }
+      var payload = {base: el.base};
       this.$.overlay.close();
-      el.hidden = false;
-      this._fireAction('/rebase', this._revisionActions.rebase, true, payload);
+      el.hidden = true;
+      this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
     },
 
     _handleCherrypickConfirm: function() {
@@ -347,10 +545,10 @@
         return;
       }
       this.$.overlay.close();
-      el.hidden = false;
+      el.hidden = true;
       this._fireAction(
           '/cherrypick',
-          this._revisionActions.cherrypick,
+          this.revisionActions.cherrypick,
           true,
           {
             destination: el.branch,
@@ -362,7 +560,7 @@
     _handleRevertDialogConfirm: function() {
       var el = this.$.confirmRevertDialog;
       this.$.overlay.close();
-      el.hidden = false;
+      el.hidden = true;
       this._fireAction('/revert', this.actions.revert, false,
           {message: el.message});
     },
@@ -370,19 +568,36 @@
     _handleAbandonDialogConfirm: function() {
       var el = this.$.confirmAbandonDialog;
       this.$.overlay.close();
-      el.hidden = false;
+      el.hidden = true;
       this._fireAction('/abandon', this.actions.abandon, false,
           {message: el.message});
     },
 
+    _handleDeleteConfirm: function() {
+      this._fireAction('/', this.actions[ChangeActions.DELETE], false);
+    },
+
     _setLoadingOnButtonWithKey: function(key) {
+      this._actionLoadingMessage = this._computeLoadingLabel(key);
+
+      // If the action appears in the overflow menu.
+      if (MENU_ACTION_KEYS.indexOf(key) !== -1) {
+        this.push('_disabledMenuActions', key === '/' ? 'delete' : key);
+        return function() {
+          this._actionLoadingMessage = null;
+          this._disabledMenuActions = [];
+        }.bind(this);
+      }
+
+      // Otherwise it's a top-level action.
       var buttonEl = this.$$('[data-action-key="' + key + '"]');
       buttonEl.setAttribute('loading', true);
       buttonEl.disabled = true;
       return function() {
+        this._actionLoadingMessage = null;
         buttonEl.removeAttribute('loading');
         buttonEl.disabled = false;
-      };
+      }.bind(this);
     },
 
     _fireAction: function(endpoint, action, revAction, opt_payload) {
@@ -393,49 +608,160 @@
     },
 
     _showActionDialog: function(dialog) {
+      this._hideAllDialogs();
+
       dialog.hidden = false;
-      this.$.overlay.open();
+      this.$.overlay.open().then(function() {
+        if (dialog.resetFocus) {
+          dialog.resetFocus();
+        }
+      });
+    },
+
+    // TODO(rmistry): Redo this after
+    // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+    _setLabelValuesOnRevert: function(newChangeId) {
+      var labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+      if (labels) {
+        var url = '/changes/' + newChangeId + '/revisions/current/review';
+        this.$.restAPI.send(this.actions.revert.method, url, {labels: labels});
+      }
     },
 
     _handleResponse: function(action, response) {
+      if (!response) { return; }
       return this.$.restAPI.getResponseObject(response).then(function(obj) {
-        switch (action.__key) {
-          case ChangeActions.REVERT:
-          case RevisionActions.CHERRYPICK:
-            page.show(this.changePath(obj._number));
-            break;
-          case ChangeActions.DELETE:
-          case RevisionActions.DELETE:
-            if (action.__type === ActionType.CHANGE) {
-              page.show('/');
-            } else {
-              page.show(this.changePath(this.changeNum));
-            }
-            break;
-          default:
-            this.fire('reload-change', null, {bubbles: false});
-            break;
-        }
+          switch (action.__key) {
+            case ChangeActions.REVERT:
+              this._setLabelValuesOnRevert(obj.change_id);
+              /* falls through */
+            case RevisionActions.CHERRYPICK:
+              page.show(this.changePath(obj._number));
+              break;
+            case ChangeActions.DELETE:
+            case RevisionActions.DELETE:
+              if (action.__type === ActionType.CHANGE) {
+                page.show('/');
+              } else {
+                page.show(this.changePath(this.changeNum));
+              }
+              break;
+            default:
+              this.dispatchEvent(new CustomEvent('reload-change',
+                  {detail: {action: action.__key}, bubbles: false}));
+              break;
+          }
       }.bind(this));
     },
 
     _handleResponseError: function(response) {
-      if (response.ok) { return response; }
-
       return response.text().then(function(errText) {
-        alert('Could not perform action: ' + errText);
-        throw Error(errText);
-      });
+        this.fire('show-alert',
+            { message: 'Could not perform action: ' + errText });
+        if (errText.indexOf('Change is already up to date') !== 0) {
+          throw Error(errText);
+        }
+      }.bind(this));
     },
 
     _send: function(method, payload, actionEndpoint, revisionAction,
-        cleanupFn) {
+        cleanupFn, opt_errorFn) {
       var url = this.$.restAPI.getChangeActionURL(this.changeNum,
           revisionAction ? this.patchNum : null, actionEndpoint);
-      return this.$.restAPI.send(method, url, payload).then(function(response) {
-        cleanupFn.call(this);
-        return response;
-      }.bind(this)).then(this._handleResponseError.bind(this));
+      return this.$.restAPI.send(method, url, payload,
+          this._handleResponseError, this).then(function(response) {
+            cleanupFn.call(this);
+            return response;
+      }.bind(this));
+    },
+
+    _handleAbandonTap: function() {
+      this._showActionDialog(this.$.confirmAbandonDialog);
+    },
+
+    _handleCherrypickTap: function() {
+      this.$.confirmCherrypick.branch = '';
+      this._showActionDialog(this.$.confirmCherrypick);
+    },
+
+    _handleDeleteTap: function() {
+      this._showActionDialog(this.$.confirmDeleteDialog);
+    },
+
+    /**
+     * Merge sources of change actions into a single ordered array of action
+     * values.
+     * @param {splices} changeActionsRecord
+     * @param {splices} revisionActionsRecord
+     * @param {splices} primariesRecord
+     * @param {splices} additionalActionsRecord
+     * @param {Object} change The change object.
+     * @return {Array}
+     */
+    _computeAllActions: function(changeActionsRecord, revisionActionsRecord,
+        primariesRecord, additionalActionsRecord, change) {
+      var revisionActionValues = this._getActionValues(revisionActionsRecord,
+          primariesRecord, additionalActionsRecord, ActionType.REVISION);
+      var changeActionValues = this._getActionValues(changeActionsRecord,
+          primariesRecord, additionalActionsRecord, ActionType.CHANGE, change);
+      var quickApprove = this._getQuickApproveAction();
+      if (quickApprove) {
+        changeActionValues.unshift(quickApprove);
+      }
+      return revisionActionValues
+          .concat(changeActionValues)
+          .sort(this._actionComparator);
+    },
+
+    /**
+     * Sort comparator to define the order of change actions.
+     */
+    _actionComparator: function(actionA, actionB) {
+      // The code review action always appears first.
+      if (actionA.__key === 'review') {
+        return -1;
+      } else if (actionB.__key === 'review') {
+        return 1;
+      }
+
+      // Primary actions always appear last.
+      if (actionA.__primary) {
+        return 1;
+      } else if (actionB.__primary) {
+        return -1;
+      }
+
+      // Change actions appear before revision actions.
+     if (actionA.__type === 'change' && actionB.__type === 'revision') {
+        return 1;
+      } else if (actionA.__type === 'revision' && actionB.__type === 'change') {
+        return -1;
+      }
+
+      // Otherwise, sort by the button label.
+      return actionA.label > actionB.label ? 1 : -1;
+    },
+
+    _computeTopLevelActions: function(actionRecord, hiddenActionsRecord) {
+      var hiddenActions = hiddenActionsRecord.base || [];
+      return actionRecord.base.filter(function(a) {
+        return MENU_ACTION_KEYS.indexOf(a.__key) === -1 &&
+                hiddenActions.indexOf(a.__key) === -1;
+      });
+    },
+
+    _computeMenuActions: function(actionRecord, hiddenActionsRecord) {
+      var hiddenActions = hiddenActionsRecord.base || [];
+      return actionRecord.base
+          .filter(function(a) {
+            return MENU_ACTION_KEYS.indexOf(a.__key) !== -1 &&
+                hiddenActions.indexOf(a.__key) === -1;
+          })
+          .map(function(action) {
+            var key = action.__key;
+            if (key === '/') { key = 'delete'; }
+            return {name: action.label, id: key, };
+          });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 80aaf3b..83a1c7e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -25,6 +25,8 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-actions.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-change-actions></gr-change-actions>
@@ -38,23 +40,30 @@
       stub('gr-rest-api-interface', {
         getChangeRevisionActions: function() {
           return Promise.resolve({
+            '/': {
+              method: 'DELETE',
+              label: 'Delete',
+              title: 'Delete draft revision 2',
+              enabled: true,
+            },
             cherrypick: {
               method: 'POST',
               label: 'Cherry Pick',
               title: 'Cherry pick change to a different branch',
-              enabled: true
+              enabled: true,
             },
             rebase: {
               method: 'POST',
               label: 'Rebase',
-              title: 'Rebase onto tip of branch or parent change'
+              title: 'Rebase onto tip of branch or parent change',
+              enabled: true,
             },
             submit: {
               method: 'POST',
               label: 'Submit',
-              title: 'Submit patch set 1 into master',
-              enabled: true
-            }
+              title: 'Submit patch set 2 into master',
+              enabled: true,
+            },
           });
         },
         send: function(method, url, payload) {
@@ -77,21 +86,146 @@
       });
 
       element = fixture('basic');
+      element.change = {};
       element.changeNum = '42';
       element.patchNum = '2';
+      element.actions = {
+        '/': {
+          method: 'DELETE',
+          label: 'Delete',
+          title: 'Delete draft change 42',
+          enabled: true,
+        },
+      };
       return element.reload();
     });
 
-    test('submit, rebase, and cherry-pick buttons show', function(done) {
+    test('_shouldHideActions', function() {
+      assert.isTrue(element._shouldHideActions(undefined, true));
+      assert.isTrue(element._shouldHideActions({base: {}}, false));
+      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
+    });
+
+    test('hide revision action', function(done) {
       flush(function() {
-        var buttonEls = Polymer.dom(element.root).querySelectorAll('gr-button');
-        assert.equal(buttonEls.length, 3);
+        var buttonEl = element.$$('[data-action-key="submit"]');
+        assert.isOk(buttonEl);
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        flush(function() {
+          var buttonEl = element.$$('[data-action-key="submit"]');
+          assert.isNotOk(buttonEl);
+
+          element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, false);
+          flush(function() {
+            var buttonEl = element.$$('[data-action-key="submit"]');
+            assert.isOk(buttonEl);
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
+          });
+        });
+      });
+    });
+
+    test('hide menu action', function(done) {
+      flush(function() {
+        var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+        assert.isOk(buttonEl);
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.CHANGE,
+            element.ChangeActions.DELETE, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        element.setActionHidden(element.ActionType.CHANGE,
+            element.ChangeActions.DELETE, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        flush(function() {
+          var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+          assert.isNotOk(buttonEl);
+
+          element.setActionHidden(element.ActionType.CHANGE,
+            element.RevisionActions.DELETE, false);
+          flush(function() {
+            var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+            assert.isOk(buttonEl);
+            done();
+          });
+        });
+      });
+    });
+
+    test('buttons exist', function(done) {
+      element._loading = false;
+      flush(function() {
+        var buttonEls = Polymer.dom(element.root)
+            .querySelectorAll('gr-button');
+        var menuItems = element.$.moreActions.items;
+        assert.equal(buttonEls.length + menuItems.length, 6);
         assert.isFalse(element.hidden);
         done();
       });
     });
 
+    test('delete buttons have explicit labels', function(done) {
+      flush(function() {
+        var deleteItems = element.$.moreActions.items.filter(function(item) {
+          return item.id === 'delete';
+        });
+        assert.equal(deleteItems.length, 2);
+        assert.notEqual(deleteItems[0].name, deleteItems[1].name);
+        assert.isTrue(
+            deleteItems[0].name === 'Delete Revision' ||
+            deleteItems[0].name === 'Delete Change'
+        );
+        assert.isTrue(
+            deleteItems[1].name === 'Delete Revision' ||
+            deleteItems[1].name === 'Delete Change'
+        );
+        done();
+      });
+    });
+
+    test('get revision object from change', function() {
+      var revObj = {_number: 2, foo: 'bar'};
+      var change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: revObj,
+        },
+      };
+      assert.deepEqual(element._getRevision(change, '2'), revObj);
+    });
+
+    test('_actionComparator sort order', function() {
+      var actions = [
+        {label: '123', __type: 'change', __key: 'review'},
+        {label: 'abc', __type: 'revision'},
+        {label: 'abc', __type: 'change'},
+        {label: 'def', __type: 'change'},
+        {label: 'def', __type: 'change', __primary: true},
+      ];
+
+      var result = actions.slice();
+      result.reverse();
+      result.sort(element._actionComparator);
+
+      assert.deepEqual(result, actions);
+    });
+
     test('submit change', function(done) {
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.patchNum = '2';
+
       flush(function() {
         var submitButton = element.$$('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
@@ -120,6 +254,33 @@
       });
     });
 
+    test('chain state', function() {
+      assert.equal(element._hasKnownChainState, false);
+      element.hasParent = true;
+      assert.equal(element._hasKnownChainState, true);
+      element.hasParent = false;
+    });
+
+    test('_calculateDisabled', function() {
+      var hasKnownChainState = false;
+      var action = {__key: 'rebase', enabled: true};
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), true);
+
+      action.__key = 'delete';
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
+
+      action.__key = 'rebase';
+      hasKnownChainState = true;
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
+
+      action.enabled = false;
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), true);
+    });
+
     test('rebase change', function(done) {
       var fireActionStub = sinon.stub(element, '_fireAction');
       flush(function() {
@@ -129,22 +290,25 @@
           __key: 'rebase',
           __type: 'revision',
           __primary: false,
+          enabled: true,
           label: 'Rebase',
           method: 'POST',
           title: 'Rebase onto tip of branch or parent change',
         };
+        // rebase on other
         element.$.confirmRebase.base = '1234';
         element._handleRebaseConfirm();
         assert.deepEqual(fireActionStub.lastCall.args,
           ['/rebase', rebaseAction, true, {base: '1234'}]);
 
-        element.$.confirmRebase.base = '';
+        // rebase on parent
+        element.$.confirmRebase.base = null;
         element._handleRebaseConfirm();
         assert.deepEqual(fireActionStub.lastCall.args,
-          ['/rebase', rebaseAction, true, {}]);
+          ['/rebase', rebaseAction, true, {base: null}]);
 
-        element.$.confirmRebase.base = 'does not matter';
-        element.$.confirmRebase.clearParent = true;
+        // rebase on tip
+        element.$.confirmRebase.base = '';
         element._handleRebaseConfirm();
         assert.deepEqual(fireActionStub.lastCall.args,
           ['/rebase', rebaseAction, true, {base: ''}]);
@@ -154,6 +318,23 @@
       });
     });
 
+    test('two dialogs are not shown at the same time', function(done) {
+      element._hasKnownChainState = true;
+      flush(function() {
+        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+        assert.ok(rebaseButton);
+        MockInteractions.tap(rebaseButton);
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.confirmRebase.hidden);
+
+        element._handleCherrypickTap();
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.confirmRebase.hidden);
+        assert.isFalse(element.$.confirmCherrypick.hidden);
+        done();
+      });
+    });
+
     suite('cherry-pick', function() {
       var fireActionStub;
       var alertStub;
@@ -169,8 +350,7 @@
       });
 
       test('works', function() {
-        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
-        MockInteractions.tap(rebaseButton);
+        element._handleCherrypickTap();
         var action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -188,16 +368,31 @@
         element._handleCherrypickConfirm();
         assert.equal(fireActionStub.callCount, 0);  // Still needs a message.
 
-        element.$.confirmCherrypick.message = 'foo message';
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
         element._handleCherrypickConfirm();
 
+        assert.equal(element.$.confirmCherrypick.$.messageInput.value,
+            'foo message');
+
         assert.deepEqual(fireActionStub.lastCall.args, [
           '/cherrypick', action, true, {
             destination: 'master',
             message: 'foo message',
-          }
+          },
         ]);
       });
+
+      test('branch name cleared when re-open cherrypick', function() {
+        var emptyBranchName = '';
+        element.$.confirmCherrypick.branch = 'master';
+
+        element._handleCherrypickTap();
+        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+      });
     });
 
     test('custom actions', function(done) {
@@ -217,6 +412,37 @@
       });
     });
 
+    test('_setLoadingOnButtonWithKey top-level', function() {
+      var key = 'rebase';
+      var cleanup = element._setLoadingOnButtonWithKey(key);
+      assert.equal(element._actionLoadingMessage, 'Rebasing...');
+
+      var button = element.$$('[data-action-key="' + key + '"]');
+      assert.isTrue(button.hasAttribute('loading'));
+      assert.isTrue(button.disabled);
+
+      assert.isOk(cleanup);
+      assert.isFunction(cleanup);
+      cleanup();
+
+      assert.isFalse(button.hasAttribute('loading'));
+      assert.isFalse(button.disabled);
+      assert.isNotOk(element._actionLoadingMessage);
+    });
+
+    test('_setLoadingOnButtonWithKey overflow menu', function() {
+      var key = 'cherrypick';
+      var cleanup = element._setLoadingOnButtonWithKey(key);
+      assert.equal(element._actionLoadingMessage, 'Cherry-Picking...');
+      assert.include(element._disabledMenuActions, 'cherrypick');
+      assert.isFunction(cleanup);
+
+      cleanup();
+
+      assert.notOk(element._actionLoadingMessage);
+      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+    });
+
     suite('revert change', function() {
       var alertStub;
       var fireActionStub;
@@ -229,8 +455,8 @@
             method: 'POST',
             label: 'Revert',
             title: 'Revert the change',
-            enabled: true
-          }
+            enabled: true,
+          },
         };
         return element.reload();
       });
@@ -241,6 +467,9 @@
       });
 
       test('revert change with plugin hook', function(done) {
+        element.change = {
+          current_revision: 'abc1234',
+        };
         var newRevertMsg = 'Modified revert msg';
         var modifyRevertMsgStub = sinon.stub(element, '_modifyRevertMsg',
             function() { return newRevertMsg; });
@@ -260,6 +489,9 @@
       });
 
       test('works', function() {
+        element.change = {
+          current_revision: 'abc1234',
+        };
         var populateRevertMsgStub = sinon.stub(
             element.$.confirmRevertDialog, 'populateRevertMessage',
             function() { return 'original msg'; });
@@ -286,5 +518,215 @@
         populateRevertMsgStub.restore();
       });
     });
+
+    suite('delete change', function() {
+      var fireActionStub;
+      var deleteAction;
+
+      setup(function() {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        deleteAction = {
+          method: 'DELETE',
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        };
+        element.actions = {
+          '/': deleteAction,
+        };
+      });
+
+      teardown(function() {
+        fireActionStub.restore();
+      });
+
+      test('does not delete on action', function() {
+        element._handleDeleteTap();
+        assert.isFalse(fireActionStub.called);
+      });
+
+      test('shows confirm dialog', function() {
+        element._handleDeleteTap();
+        assert.isFalse(element.$$('#confirmDeleteDialog').hidden);
+        MockInteractions.tap(
+            element.$$('#confirmDeleteDialog').$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
+      });
+
+      test('hides delete confirm on cancel', function() {
+        element._handleDeleteTap();
+        MockInteractions.tap(
+            element.$$('#confirmDeleteDialog').$$('gr-button:not([primary])'));
+        flushAsynchronousOperations();
+        assert.isTrue(element.$$('#confirmDeleteDialog').hidden);
+        assert.isFalse(fireActionStub.called);
+      });
+    });
+
+    suite('quick approve', function() {
+      setup(function() {
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: ['-1', ' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+      });
+
+      test('added when can approve', function() {
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNotNull(approveButton);
+      });
+
+      test('is first in list of actions', function() {
+        var approveButton = element.$$('gr-button');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('not added when already approved', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              approved: {},
+              values: {},
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('not added when label not permitted', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {values: {}},
+          },
+          permitted_labels: {
+            bar: [],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approves when taped', function() {
+        var fireActionStub = sinon.stub(element, '_fireAction');
+        MockInteractions.tap(
+            element.$$('gr-button[data-action-key=\'review\']'));
+        flushAsynchronousOperations();
+        assert.isTrue(fireActionStub.called);
+        assert.isTrue(fireActionStub.calledWith('/review'));
+        var payload = fireActionStub.lastCall.args[3];
+        assert.deepEqual(payload.labels, {foo: '+1'});
+        fireActionStub.restore();
+      });
+
+      test('not added when multiple labels are required', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('button label for missing approval', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              values: {
+                ' 0': '',
+                '+1': '',
+              },
+            },
+            bar: {approved: {}, values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('no quick approve if score is not maximal for a label', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approving label with a non-max score', function() {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 8b51312..78bcb9a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -14,12 +14,14 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
 
@@ -36,18 +38,30 @@
       .title {
         color: #666;
         font-weight: bold;
+        white-space: nowrap;
+      }
+      gr-account-link {
+        max-width: 20ch;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        vertical-align: middle;
+        white-space: nowrap;
+      }
+      gr-editable-label {
+        max-width: 9em;
       }
       .labelValueContainer:not(:first-of-type) {
         margin-top: .25em;
       }
       .labelValueContainer .approved,
       .labelValueContainer .notApproved {
-        display: inline-block;
+        display: inline-flex;
         padding: .1em .3em;
         border-radius: 3px;
       }
       .labelValue {
         display: inline-block;
+        padding-right: .3em;
       }
       .approved {
         background-color: #d4ffd4;
@@ -55,6 +69,12 @@
       .notApproved {
         background-color: #ffd4d4;
       }
+      .labelStatus {
+        max-width: 9em;
+      }
+      .webLink {
+        display: block;
+      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         :host {
           display: table;
@@ -80,6 +100,7 @@
       <span class="title">Updated</span>
       <span class="value">
         <gr-date-formatter
+            has-tooltip
             date-str="[[change.updated]]"></gr-date-formatter>
       </span>
     </section>
@@ -91,6 +112,19 @@
     </section>
     <template is="dom-if" if="[[_showReviewersByState]]">
       <section>
+        <span class="title">Assignee</span>
+        <span class="value">
+          <gr-account-list
+              max-count="1"
+              id="assigneeValue"
+              placeholder="Add assignee..."
+              accounts="{{_assignee}}"
+              change="[[change]]"
+              readonly="[[!mutable]]"
+              allow-any-user></gr-account-list>
+        </span>
+      </section>
+      <section>
         <span class="title">Reviewers</span>
         <span class="value">
           <gr-reviewer-list
@@ -111,6 +145,19 @@
     </template>
     <template is="dom-if" if="[[!_showReviewersByState]]">
       <section>
+        <span class="title">Assignee</span>
+        <span class="value">
+          <gr-account-list
+              max-count="1"
+              id="assigneeValue"
+              placeholder="Add assignee..."
+              accounts="{{_assignee}}"
+              change="[[change]]"
+              readonly="[[!mutable]]"
+              allow-any-user></gr-account-list>
+        </span>
+      </section>
+      <section>
         <span class="title">Reviewers</span>
         <span class="value">
           <gr-reviewer-list
@@ -128,25 +175,22 @@
       <span class="value">[[change.branch]]</span>
     </section>
     <section>
-      <span class="title">Commit</span>
-      <span class="value">
-        <template is="dom-if" if="[[_showWebLink]]">
-          <a target="_blank"
-             href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
-        </template>
-        <template is="dom-if" if="[[!_showWebLink]]">
-          [[_computeShortHash(commitInfo)]]
-        </template>
-      </span>
-    </section>
-    <section>
       <span class="title">Topic</span>
       <span class="value">
-        <gr-editable-label
-            value="{{change.topic}}"
-            placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-            read-only="[[_topicReadOnly]]"
-            on-changed="_handleTopicChanged"></gr-editable-label>
+        <template is="dom-if" if="[[change.topic]]">
+          <gr-linked-chip
+              text="[[change.topic]]"
+              href="[[_computeTopicHref(change.topic)]]"
+              removable="[[!_topicReadOnly]]"
+              on-remove="_handleTopicRemoved"></gr-linked-chip>
+        </template>
+        <template is="dom-if" if="[[!change.topic]]">
+          <gr-editable-label
+              value="{{change.topic}}"
+              placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+              read-only="[[_topicReadOnly]]"
+              on-changed="_handleTopicChanged"></gr-editable-label>
+        </template>
       </span>
     </section>
     <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
@@ -159,7 +203,7 @@
         <span class="title">[[labelName]]</span>
         <span class="value">
           <template is="dom-repeat"
-              items="[[_computeLabelValues(labelName, change.labels)]]"
+              items="[[_computeLabelValues(labelName, change.labels.*)]]"
               as="label">
             <div class="labelValueContainer">
               <span class$="[[label.className]]">
@@ -169,13 +213,38 @@
                     class="labelValue">
                   [[label.value]]
                 </gr-label>
-                <gr-account-link account="[[label.account]]"></gr-account-link>
+                <gr-account-chip
+                    account="[[label.account]]"
+                    data-account-id$="[[label.account._account_id]]"
+                    label-name="[[labelName]]"
+                    removable="[[_computeCanDeleteVote(label.account, mutable)]]"
+                    transparent-background
+                    on-remove="_onDeleteVote"></gr-account-chip>
               </span>
             </div>
           </template>
         </span>
       </section>
     </template>
+    <template is="dom-if" if="[[_showLabelStatus]]">
+      <section>
+        <span class="title">Label Status</span>
+        <span class="value labelStatus">
+          [[_computeSubmitStatus(change.labels)]]
+        </span>
+      </section>
+    </template>
+    <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
+      <span class="title">Links</span>
+      <span class="value">
+        <template is="dom-repeat"
+            items="[[_computeWebLinks(commitInfo)]]" as="link">
+          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
+            [[link.name]]
+          </a>
+        </template>
+      </span>
+    </section>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-change-metadata.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index af19703..57e25f8 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -30,14 +30,6 @@
       commitInfo: Object,
       mutable: Boolean,
       serverConfig: Object,
-      _showWebLink: {
-        type: Boolean,
-        computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
-      },
-      _webLink: {
-        type: String,
-        computed: '_computeWebLink(change, commitInfo, serverConfig)',
-      },
       _topicReadOnly: {
         type: Boolean,
         computed: '_computeTopicReadOnly(mutable, change)',
@@ -46,48 +38,72 @@
         type: Boolean,
         computed: '_computeShowReviewersByState(serverConfig)',
       },
+      _showLabelStatus: {
+        type: Boolean,
+        computed: '_computeShowLabelStatus(change)',
+      },
+
+      _assignee: Array,
     },
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
-    _computeShowWebLink: function(change, commitInfo, serverConfig) {
-      var webLink = commitInfo.web_links && commitInfo.web_links.length;
-      var gitWeb = serverConfig.gitweb && serverConfig.gitweb.url &&
-          serverConfig.gitweb.type && serverConfig.gitweb.type.revision;
-      return webLink || gitWeb;
+    observers: [
+      '_changeChanged(change)',
+      '_assigneeChanged(_assignee.*)',
+    ],
+
+    _changeChanged: function(change) {
+      this._assignee = change.assignee ? [change.assignee] : [];
     },
 
-    _computeWebLink: function(change, commitInfo, serverConfig) {
-      if (!this._computeShowWebLink(change, commitInfo, serverConfig)) {
-        return;
+    _assigneeChanged: function(assigneeRecord) {
+      if (!this.change) { return; }
+      var assignee = assigneeRecord.base;
+      if (assignee.length) {
+        var acct = assignee[0];
+        if (this.change.assignee &&
+            acct._account_id === this.change.assignee._account_id) { return; }
+        this.set(['change', 'assignee'], acct);
+        this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+      } else {
+        if (!this.change.assignee) { return; }
+        this.set(['change', 'assignee'], undefined);
+        this.$.restAPI.deleteAssignee(this.change._number);
       }
-
-      if (serverConfig.gitweb && serverConfig.gitweb.url &&
-          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
-        return serverConfig.gitweb.url +
-            serverConfig.gitweb.type.revision
-                .replace('${project}', change.project)
-                .replace('${commit}', commitInfo.commit);
-      }
-
-      var webLink = commitInfo.web_links[0].url;
-      if (!/^https?\:\/\//.test(webLink)) {
-        webLink = '../../' + webLink;
-      }
-
-      return webLink;
-    },
-
-    _computeShortHash: function(commitInfo) {
-      return commitInfo.commit.slice(0, 7);
     },
 
     _computeHideStrategy: function(change) {
       return !this.changeIsOpen(change.status);
     },
 
+    /**
+     * This is a whitelist of web link types that provide direct links to
+     * the commit in the url property.
+     */
+    _isCommitWebLink: function(link) {
+      return link.name === 'gitiles' || link.name === 'gitweb';
+    },
+
+    /**
+     * @param {Object} commitInfo
+     * @return {?Array} If array is empty, returns null instead so
+     * an existential check can be used to hide or show the webLinks
+     * section.
+     */
+    _computeWebLinks: function(commitInfo) {
+      if (!commitInfo || !commitInfo.web_links) { return null }
+      // We are already displaying these types of links elsewhere,
+      // don't include in the metadata links section.
+      var webLinks = commitInfo.web_links.filter(
+          function(l) {return !this._isCommitWebLink(l); }.bind(this));
+
+      return webLinks.length ? webLinks : null;
+    },
+
     _computeStrategy: function(change) {
       return SubmitTypeLabel[change.submit_type];
     },
@@ -96,8 +112,9 @@
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues: function(labelName, labels) {
+    _computeLabelValues: function(labelName, _labels) {
       var result = [];
+      var labels = _labels.base;
       var t = labels[labelName];
       if (!t) { return result; }
       var approvals = t.all || [];
@@ -128,7 +145,7 @@
 
     _handleTopicChanged: function(e, topic) {
       if (!topic.length) { topic = null; }
-      this.$.restAPI.setChangeTopic(this.change.id, topic);
+      this.$.restAPI.setChangeTopic(this.change._number, topic);
     },
 
     _computeTopicReadOnly: function(mutable, change) {
@@ -142,5 +159,82 @@
     _computeShowReviewersByState: function(serverConfig) {
       return !!serverConfig.note_db_enabled;
     },
+
+    /**
+     * A user is able to delete a vote iff the mutable property is true and the
+     * reviewer that left the vote exists in the list of removable_reviewers
+     * received from the backend.
+     *
+     * @param {!Object} reviewer An object describing the reviewer that left the
+     *     vote.
+     * @param {boolean} mutable this.mutable describes whether the
+     *     change-metadata section is modifiable by the current user.
+     */
+    _computeCanDeleteVote: function(reviewer, mutable) {
+      if (!mutable) { return false; }
+      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
+        if (this.change.removable_reviewers[i]._account_id ===
+            reviewer._account_id) {
+          return true;
+        }
+      }
+      return false;
+    },
+
+    _onDeleteVote: function(e) {
+      e.preventDefault();
+      var target = Polymer.dom(e).rootTarget;
+      var labelName = target.labelName;
+      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      this._xhrPromise =
+          this.$.restAPI.deleteVote(this.change.id, accountID, labelName)
+          .then(function(response) {
+        if (!response.ok) { return response; }
+
+        var labels = this.change.labels[labelName].all || [];
+        for (var i = 0; i < labels.length; i++) {
+          if (labels[i]._account_id === accountID) {
+            this.splice(['change.labels', labelName, 'all'], i, 1);
+            break;
+          }
+        }
+      }.bind(this));
+    },
+
+    _computeShowLabelStatus: function(change) {
+      var isNewChange = change.status === this.ChangeStatus.NEW;
+      var hasLabels = Object.keys(change.labels).length > 0;
+      return isNewChange && hasLabels;
+    },
+
+    _computeSubmitStatus: function(labels) {
+      var missingLabels = [];
+      var output = '';
+      for (var label in labels) {
+        var obj = labels[label];
+        if (!obj.optional && !obj.approved) {
+          missingLabels.push(label);
+        }
+      }
+      if (missingLabels.length) {
+        output += 'Needs ';
+        output += missingLabels.join(' and ');
+        output += missingLabels.length > 1 ? ' labels' : ' label';
+      } else {
+        output = 'Ready to submit';
+      }
+      return output;
+    },
+
+    _computeTopicHref: function(topic) {
+      var encodedTopic = encodeURIComponent('\"' + topic + '\"');
+      return this.getBaseUrl() + '/q/topic:' + encodeURIComponent(encodedTopic) +
+          '+(status:open OR status:merged)';
+    },
+
+    _handleTopicRemoved: function() {
+      this.set(['change', 'topic'], '');
+      this.$.restAPI.setChangeTopic(this.change._number, null);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 01f0649..4eda281 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -18,13 +18,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../bower_components/page/page.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-metadata.html">
-<script src="../../../scripts/util.js"></script>
+
+<script>void(0);</script>
 
 <test-fixture id="basic">
   <template>
@@ -35,15 +35,22 @@
 <script>
   suite('gr-change-metadata tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         getLoggedIn: function() { return Promise.resolve(false); },
       });
 
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('computed fields', function() {
       assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
       assert.isFalse(element._computeHideStrategy({status: 'DRAFT'}));
@@ -68,79 +75,6 @@
       assert.isTrue(element.$$('.strategy').hasAttribute('hidden'));
     });
 
-    test('no web link when unavailable', function() {
-      element.commitInfo = {};
-      element.serverConfig = {};
-      element.change = {labels: []};
-
-      assert.isNotOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-    });
-
-    test('use web link when available', function() {
-      element.commitInfo = {web_links: [{url: 'link-url'}]};
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), '../../link-url');
-    });
-
-    test('does not relativize web links that begin with scheme', function() {
-      element.commitInfo = {web_links: [{url: 'https://link-url'}]};
-      element.serverConfig = {};
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'https://link-url');
-    });
-
-    test('use gitweb when available', function() {
-      element.commitInfo = {commit: 'commit-sha'};
-      element.serverConfig = {gitweb: {
-        url: 'url-base/',
-        type: {revision: 'xx ${project} xx ${commit} xx'},
-      }};
-      element.change = {
-        project: 'project-name',
-        labels: [],
-        current_revision: element.commitInfo.commit
-      };
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-
-      assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), 'url-base/xx project-name xx commit-sha xx');
-    });
-
-    test('prefer gitweb when both are available', function() {
-      element.commitInfo = {
-        commit: 'commit-sha',
-        web_links: [{url: 'link-url'}]
-      };
-      element.serverConfig = {gitweb: {
-        url: 'url-base/',
-        type: {revision: 'xx ${project} xx ${commit} xx'},
-      }};
-      element.change = {
-        project: 'project-name',
-        labels: [],
-        current_revision: element.commitInfo.commit
-      };
-
-      assert.isOk(element._computeShowWebLink(element.change,
-          element.commitInfo, element.serverConfig));
-
-      var link = element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig);
-
-      assert.equal(link, 'url-base/xx project-name xx commit-sha xx');
-      assert.notEqual(link, '../../link-url');
-    });
-
     test('show CC section when NoteDb enabled', function() {
       function hasCc() {
         return element._showReviewersByState;
@@ -152,5 +86,234 @@
       element.serverConfig = {note_db_enabled: true};
       assert.isTrue(hasCc());
     });
+
+    test('computes submit status', function() {
+      var labels = {};
+      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      labels = {test: {}};
+      assert.equal(element._computeSubmitStatus(labels), 'Needs test label');
+      labels.test.approved = true;
+      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      labels.test.approved = false;
+      labels.test.optional = true;
+      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
+      labels.test.optional = false;
+      labels.test2 = {};
+      assert.equal(element._computeSubmitStatus(labels),
+          'Needs test and test2 labels');
+    });
+
+    test('weblinks hidden when no weblinks', function() {
+      element.commitInfo = {};
+      flushAsynchronousOperations();
+      var webLinks = element.$.webLinks;
+      assert.isTrue(webLinks.hasAttribute('hidden'));
+    });
+
+    test('weblinks hidden when only gitiles weblink', function() {
+      element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
+      flushAsynchronousOperations();
+      var webLinks = element.$.webLinks;
+      assert.isTrue(webLinks.hasAttribute('hidden'));
+      assert.equal(element._computeWebLinks(element.commitInfo), null);
+    });
+
+    test('weblinks are visible when other weblinks', function() {
+      element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
+      flushAsynchronousOperations();
+      var webLinks = element.$.webLinks;
+      assert.isFalse(webLinks.hasAttribute('hidden'));
+      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+      // With two non-gitiles weblinks, there are two returned.
+      element.commitInfo = {
+        web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
+      assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
+    });
+
+    test('weblinks are visible when gitiles and other weblinks', function() {
+      element.commitInfo = {
+        web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
+      flushAsynchronousOperations();
+      var webLinks = element.$.webLinks;
+      assert.isFalse(webLinks.hasAttribute('hidden'));
+      // Only the non-gitiles weblink is returned.
+      assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+    });
+
+    suite('Topic removal', function() {
+      var change;
+      setup(function() {
+        change = {
+          _number: 'the number',
+          actions: {
+            topic: {enabled: false},
+          },
+          change_id: 'the id',
+          topic: 'the topic',
+          status: 'NEW',
+          submit_type: 'CHERRY_PICK',
+          labels: {
+            test: {
+              all: [{_account_id: 1, name: 'bojack', value: 1}],
+              default_value: 0,
+              values: [],
+            },
+          },
+          removable_reviewers: [],
+        };
+      });
+
+      test('_computeTopicReadOnly', function() {
+        var mutable = false;
+        assert.isTrue(element._computeTopicReadOnly(mutable, change));
+        mutable = true;
+        assert.isTrue(element._computeTopicReadOnly(mutable, change));
+        change.actions.topic.enabled = true;
+        assert.isFalse(element._computeTopicReadOnly(mutable, change));
+        mutable = false;
+        assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      });
+
+      test('topic read only hides delete button', function() {
+        element.mutable = false;
+        element.change = change;
+        flushAsynchronousOperations();
+        var button = element.$$('gr-linked-chip').$$('gr-button');
+        assert.isTrue(button.hasAttribute('hidden'));
+      });
+
+      test('topic not read only does not hide delete button', function() {
+        element.mutable = true;
+        change.actions.topic.enabled = true;
+        element.change = change;
+        flushAsynchronousOperations();
+        var button = element.$$('gr-linked-chip').$$('gr-button');
+        assert.isFalse(button.hasAttribute('hidden'));
+      });
+    });
+
+    suite('remove reviewer votes', function() {
+      setup(function() {
+        sandbox.stub(element, '_computeValueTooltip').returns('');
+        sandbox.stub(element, '_computeTopicReadOnly').returns(true);
+        element.change = {
+          _number: 'the number',
+          change_id: 'the id',
+          topic: 'the topic',
+          status: 'NEW',
+          submit_type: 'CHERRY_PICK',
+          labels: {
+            test: {
+              all: [{_account_id: 1, name: 'bojack', value: 1}],
+              default_value: 0,
+              values: [],
+            },
+          },
+          removable_reviewers: [],
+        };
+      });
+
+      test('_computeCanDeleteVote hides delete button', function() {
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        assert.isTrue(button.hasAttribute('hidden'));
+        element.mutable = true;
+        assert.isTrue(button.hasAttribute('hidden'));
+      });
+
+      test('_computeCanDeleteVote shows delete button', function() {
+        element.change.removable_reviewers = [
+          {
+            _account_id: 1,
+            name: 'bojack',
+          }
+        ];
+        element.mutable = true;
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        assert.isFalse(button.hasAttribute('hidden'));
+      });
+
+      test('deletes votes', function(done) {
+        sandbox.stub(element.$.restAPI, 'deleteVote')
+            .returns(Promise.resolve({'ok': true}));
+        element.change.removable_reviewers = [
+          {
+            _account_id: 1,
+            name: 'bojack',
+          }
+        ];
+        element.mutable = true;
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        MockInteractions.tap(button);
+        flushAsynchronousOperations();
+        var spliceStub = sinon.stub(element, 'splice',
+            function(path, index, length) {
+          assert.deepEqual(path, ['change.labels', 'test', 'all']);
+          assert.equal(index, 0);
+          assert.equal(length, 1);
+          spliceStub.restore();
+          done();
+        });
+      });
+
+      test('changing topic calls setChangeTopic', function() {
+        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic',
+            function() {});
+        element._handleTopicChanged({}, 'the new topic');
+        assert.isTrue(topicStub.calledWith('the number', 'the new topic'));
+      });
+
+      test('topic href has quotes', function() {
+        var hrefArr = element._computeTopicHref('test')
+            .split('%2522'); // Double-escaped quote.
+        assert.equal(hrefArr[1], 'test');
+      });
+
+      test('clicking x on topic chip removes topic', function() {
+        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic');
+        flushAsynchronousOperations();
+        var remove = element.$$('gr-linked-chip').$.remove;
+        MockInteractions.tap(remove);
+        assert.equal(element.change.topic, '');
+        assert.isTrue(topicStub.called);
+      });
+
+      suite('assignee field', function() {
+        var dummyAccount = {
+          _account_id: 1,
+          name: 'bojack',
+        };
+        var deleteStub;
+        var setStub;
+        setup(function() {
+          deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
+          setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
+        });
+
+        test('changing change recomputes _assignee', function() {
+          assert.isFalse(!!element._assignee.length);
+          var change = element.change;
+          change.assignee = dummyAccount;
+          element._changeChanged(change);
+          assert.deepEqual(element._assignee[0], dummyAccount);
+        });
+
+        test('modifying _assignee calls API', function() {
+          assert.isFalse(!!element._assignee.length);
+          element.set('_assignee', [dummyAccount]);
+          assert.isTrue(setStub.calledOnce);
+          assert.deepEqual(element.change.assignee, dummyAccount);
+          element.set('_assignee', [dummyAccount]);
+          assert.isTrue(setStub.calledOnce);
+          element.set('_assignee', []);
+          assert.isTrue(deleteStub.calledOnce);
+          assert.equal(element.change.assignee, undefined);
+          element.set('_assignee', []);
+          assert.isTrue(deleteStub.calledOnce);
+        });
+      });
+    });
   });
 </script>
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 e3f7fd2..8455053 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
@@ -14,14 +14,18 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
+<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
@@ -29,6 +33,7 @@
 
 <link rel="import" href="../gr-change-actions/gr-change-actions.html">
 <link rel="import" href="../gr-change-metadata/gr-change-metadata.html">
+<link rel="import" href="../gr-commit-info/gr-commit-info.html">
 <link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
 <link rel="import" href="../gr-file-list/gr-file-list.html">
 <link rel="import" href="../gr-messages-list/gr-messages-list.html">
@@ -45,18 +50,16 @@
         color: #666;
         padding: 1em var(--default-horizontal-margin);
       }
-      .headerContainer {
-        height: 4.1em;
-        margin-bottom: .5em;
-      }
       .header {
         align-items: center;
         background-color: var(--view-background-color);
-        border-bottom: 1px solid #ddd;
         display: flex;
-        padding: 1em var(--default-horizontal-margin);
+        padding: .65em var(--default-horizontal-margin);
         z-index: 99;  /* Less than gr-overlay's backdrop */
       }
+      .header .download {
+        margin-right: 1em;
+      }
       .header.pinned {
         border-bottom-color: transparent;
         box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
@@ -69,69 +72,67 @@
         flex: 1;
         font-size: 1.2em;
         font-weight: bold;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
       }
       gr-change-star {
         margin-right: .25em;
         vertical-align: -.425em;
       }
-      .download,
-      .patchSelectLabel {
-        margin-left: 1em;
-      }
-      .header select {
-        margin-left: .5em;
-      }
-      .header .reply {
-        margin-left: var(--default-horizontal-margin);
-      }
       gr-reply-dialog {
         width: 50em;
       }
       .changeStatus {
-        color: #999;
         text-transform: capitalize;
       }
-      section {
-        margin: 10px 0;
-        padding: 10px var(--default-horizontal-margin);
-      }
       /* Strong specificity here is needed due to
          https://github.com/Polymer/polymer/issues/2531 */
       .container section.changeInfo {
-        border-bottom: 1px solid #ddd;
         display: flex;
-        margin-top: 0;
-        padding-top: 0;
+        padding: 0 var(--default-horizontal-margin);
+      }
+      .changeId {
+        color: #666;
+        font-family: var(--font-family);
+        margin-top: 1em;
       }
       .changeInfo-column:not(:last-of-type) {
         margin-right: 1em;
         padding-right: 1em;
       }
       .changeMetadata {
-        border-right: 1px solid #ddd;
-        font-size: .9em;
+        font-size: .95em;
       }
-      gr-change-actions {
-        margin-top: 1em;
+      /* Prevent plugin text from overflowing. */
+      #change_plugins {
+        word-break: break-word;
       }
       .commitMessage {
         font-family: var(--monospace-font-family);
-        flex: 0 0 72ch;
-        margin-right: 2em;
+        max-width: 100ch;
+        margin-right: 1em;
         margin-bottom: 1em;
-        overflow-x: hidden;
-      }
-      .commitMessage h4 {
-        font-family: var(--font-family);
-        font-weight: bold;
-        margin-bottom: .25em;
       }
       .commitMessage gr-linked-text {
-        --linked-text-white-space: pre;
         overflow: auto;
+        word-break: break-all;
+      }
+      .editCommitMessage {
+        margin-top: 1em;
+      }
+      .commitActions {
+        border-bottom: 1px solid #ddd;
+        display: flex;
+        justify-content: space-between;
+        margin-bottom: .5em;
+        padding-bottom: .5em;
+      }
+      .reply {
+        margin-right: .5em;
+      }
+      .mainChangeInfo {
+        display: flex;
+        flex: 1;
+        flex-direction: column;
+        min-width: 0;
       }
       .commitAndRelated {
         align-content: flex-start;
@@ -139,18 +140,86 @@
         flex: 1;
         overflow-x: hidden;
       }
+      .collapseToggleButton {
+        text-decoration: none;
+      }
       .relatedChanges {
         flex: 1 1 auto;
-        font-size: .9em;
         overflow: hidden;
       }
+      .patchInfo {
+        border: 1px solid #ddd;
+        margin: 1em var(--default-horizontal-margin);
+      }
+      .patchInfo--oldPatchSet .patchInfo-header {
+        background-color: #fff9c4;
+      }
+      .patchInfo--oldPatchSet .latestPatchContainer {
+        display: initial;
+      }
+      .patchInfo-header,
       gr-file-list {
+        padding: .5em calc(var(--default-horizontal-margin) / 2);
+      }
+      .patchInfo-header {
+        background-color: #f6f6f6;
+        border-bottom: 1px solid #ebebeb;
+        display: flex;
+        justify-content: space-between;
+      }
+      .latestPatchContainer {
+        display: none;
+      }
+      .patchSetSelect {
+        max-width: 8em;
+      }
+      gr-editable-label.descriptionLabel {
+        max-width: 100%;
+      }
+      .mobile {
+        display: none;
+      }
+      .warning {
+        color: #d14836;
+      }
+      hr {
+        border: 0;
+        border-top: 1px solid #ddd;
+        height: 0;
         margin-bottom: 1em;
-        padding: 0 var(--default-horizontal-margin);
+      }
+      .patchInfo-header-wrapper {
+        width: 100%;
+      }
+      #commitMessage.collapsed {
+        max-height: 36em;
+        overflow: hidden;
+      }
+      #relatedChanges {
+        font-size: .9em;
+      }
+      #relatedChanges.collapsed {
+        margin-bottom: 1.1em;
+        max-height: var(--relation-chain-max-height, 2em);
+        overflow: hidden;
+      }
+      .commitContainer {
+        display: flex;
+        flex-direction: column;
+      }
+      .collapseToggleContainer {
+        display: flex;
+      }
+      .collapseToggleContainer gr-button {
+        display: block;
+      }
+      #relatedChangesToggle {
+        margin-left: 1em;
+        padding-top: var(--related-change-btn-top-padding, 0);
       }
       @media screen and (max-width: 50em) {
-        .headerContainer {
-          height: 5.15em;
+        .mobile {
+          display: block;
         }
         .header {
           align-items: flex-start;
@@ -163,30 +232,17 @@
         .header-title {
           font-size: 1.1em;
         }
-        .header-actions {
-          align-items: center;
-          display: flex;
-          justify-content: space-between;
-          margin-top: .5em;
-        }
         gr-reply-dialog {
           min-width: initial;
-          width: 90vw;
+          width: 100vw;
         }
-        .download {
+        .desktop {
           display: none;
         }
-        .patchSelectLabel {
-          margin-left: 0;
-          margin-right: .5em;
-        }
-        .header select {
-          margin-left: 0;
-          margin-right: .5em;
-        }
-        .header .reply {
-          margin-left: 0;
-          margin-right: .5em;
+        .reply {
+          display: block;
+          margin-right: 0;
+          margin-bottom: .5em;
         }
         .changeInfo-column:not(:last-of-type) {
           margin-right: 0;
@@ -207,43 +263,46 @@
           margin-top: .25em;
           max-width: none;
         }
+        .commitActions {
+          flex-direction: column;
+        }
         .commitMessage {
           flex: initial;
           margin-right: 0;
         }
+        .scrollable {
+          @apply(--layout-scroll);
+        }
       }
     </style>
-    <div class="container loading" hidden$="{{!_loading}}">Loading...</div>
+    <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
     <div class="container" hidden$="{{_loading}}">
-      <div class="headerContainer">
-        <div class="header">
-          <span class="header-title">
-            <gr-change-star change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
-            <a href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><span>:</span>
-            <span>[[_change.subject]]</span>
-            <span class="changeStatus">[[_computeChangeStatus(_change, _patchRange.patchNum)]]</span>
-          </span>
-          <span class="header-actions">
-            <gr-button hidden
-                class="reply"
-                primary$="[[_computeReplyButtonHighlighted(_diffDrafts.*)]]"
-                hidden$="[[!_loggedIn]]"
-                on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
-            <gr-button class="download" on-tap="_handleDownloadTap">Download</gr-button>
-            <span>
-              <label class="patchSelectLabel" for="patchSetSelect">Patch set</label>
-              <select id="patchSetSelect" on-change="_handlePatchChange">
-                <template is="dom-repeat" items="{{_allPatchSets}}" as="patchNumber">
-                  <option value$="[[patchNumber]]" selected$="[[_computePatchIndexIsSelected(index, _patchRange.patchNum)]]">
-                    <span>[[patchNumber]]</span>
-                    /
-                    <span>[[_computeLatestPatchNum(_allPatchSets)]]</span>
-                  </option>
-                </template>
-              </select>
-            </span>
-          </span>
-        </div>
+      <div class="header">
+        <span class="header-title">
+          <gr-change-star
+              id="changeStar"
+              change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
+          <a
+              aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
+              href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><!--
+       --><template is="dom-if" if="[[_changeStatus]]"><!--
+         --> (<!--
+         --><span
+                aria-label$="Change status: [[_changeStatus]]"
+                tabindex="0">[[_changeStatus]]</span><!--
+         --><template
+                is="dom-if"
+                if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]">
+              as
+              <gr-commit-info
+                  change="[[_change]]"
+                  commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
+                  server-config="[[serverConfig]]"></gr-commit-info><!--
+         --></template><!--
+         -->)<!--
+       --></template><!--
+       -->: [[_change.subject]]
+        </span>
       </div>
       <section class="changeInfo">
         <div class="changeInfo-column changeMetadata">
@@ -254,46 +313,158 @@
               mutable="[[_loggedIn]]"
               on-show-reply-dialog="_handleShowReplyDialog">
           </gr-change-metadata>
-          <gr-change-actions id="actions"
-              change="[[_change]]"
-              actions="[[_change.actions]]"
-              change-num="[[_changeNum]]"
-              patch-num="[[_patchRange.patchNum]]"
-              commit-info="[[_commitInfo]]"
-              on-reload-change="_handleReloadChange"></gr-change-actions>
+          <!-- Plugins insert content into following container.
+               Stop-gap until PolyGerrit plugins interface is ready.
+               This will not work with Shadow DOM. -->
+          <div id="change_plugins"></div>
         </div>
-        <div class="changeInfo-column commitAndRelated">
-          <div class="commitMessage">
-            <h4>
-              Commit message
-              <gr-button link
-                  on-tap="_handleEditCommitMessage"
-                  hidden$="[[_hideEditCommitMessage]]">Edit</gr-button>
-            </h4>
-            <gr-editable-content id="commitMessageEditor"
-                editing="[[_editingCommitMessage]]"
-                content="{{_commitInfo.message}}">
-              <gr-linked-text pre
-                  content="[[_commitInfo.message]]"
-                  config="[[_projectConfig.commentlinks]]"></gr-linked-text>
-            </gr-editable-content>
-          </div>
-          <div class="relatedChanges">
-            <gr-related-changes-list id="relatedChanges"
+        <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
+          <div class="commitActions" hidden$="[[!_loggedIn]]">
+            <gr-button
+                class="reply"
+                secondary
+                disabled="[[_replyDisabled]]"
+                on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+            <gr-change-actions id="actions"
                 change="[[_change]]"
-                patch-num="[[_patchRange.patchNum]]"></gr-related-changes-list>
+                has-parent="[[hasParent]]"
+                actions="[[_change.actions]]"
+                revision-actions="[[_currentRevisionActions]]"
+                change-num="[[_changeNum]]"
+                change-status="[[_change.status]]"
+                commit-num="[[_commitInfo.commit]]"
+                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+                commit-message="[[_latestCommitMessage]]"
+                on-reload-change="_handleReloadChange"></gr-change-actions>
+          </div>
+          <hr class="mobile">
+          <div class="commitAndRelated">
+            <div class="commitContainer">
+              <div
+                  id="commitMessage"
+                  class$="commitMessage [[_computeCommitClass(_commitCollapsed, _latestCommitMessage)]]">
+                <gr-editable-content id="commitMessageEditor"
+                    editing="[[_editingCommitMessage]]"
+                    content="{{_latestCommitMessage}}"
+                    remove-zero-width-space>
+                  <gr-linked-text pre
+                      content="[[_latestCommitMessage]]"
+                      config="[[_projectConfig.commentlinks]]"
+                      remove-zero-width-space></gr-linked-text>
+                </gr-editable-content>
+                <gr-button link
+                    class="editCommitMessage"
+                    on-tap="_handleEditCommitMessage"
+                    hidden$="[[_hideEditCommitMessage]]">Edit</gr-button>
+                <div class="changeId" hidden$="[[!_changeIdCommitMessageError]]">
+                  <hr>
+                  Change-Id:
+                  <span
+                      class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
+                      title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]">
+                    [[_change.change_id]]
+                  </span>
+                </div>
+              </div>
+              <div
+                  id="commitCollapseToggle"
+                  class="collapseToggleContainer"
+                  hidden$="[[_computeCommitToggleHidden(_latestCommitMessage)]]">
+                <gr-button
+                    link
+                    id="commitCollapseToggleButton"
+                    class="collapseToggleButton"
+                    on-tap="_toggleCommitCollapsed">
+                  [[_computeCollapseText(_commitCollapsed)]]
+                </gr-button>
+              </div>
+            </div>
+            <div class="relatedChanges">
+              <gr-related-changes-list id="relatedChanges"
+                  class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed, _relatedChangesLoading)]]"
+                  change="[[_change]]"
+                  has-parent="{{hasParent}}"
+                  loading="{{_relatedChangesLoading}}"
+                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]">
+              </gr-related-changes-list>
+              <div
+                  id="relatedChangesToggle"
+                  class="collapseToggleContainer"
+                  hidden$="[[_computeRelatedChangesToggleHidden(_relatedChangesLoading)]]">
+                <gr-button
+                    link
+                    id="relatedChangesToggleButton"
+                    class="collapseToggleButton"
+                    on-tap="_toggleRelatedChangesCollapsed">
+                  [[_computeCollapseText(_relatedChangesCollapsed)]]
+                </gr-button>
+              </div>
+            </div>
           </div>
         </div>
       </section>
-      <gr-file-list id="fileList"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          patch-range="[[_patchRange]]"
-          comments="[[_comments]]"
-          drafts="[[_diffDrafts]]"
-          revisions="[[_change.revisions]]"
-          projectConfig="[[_projectConfig]]"
-          selected-index="{{viewState.selectedFileIndex}}"></gr-file-list>
+      <section class$="patchInfo [[_computePatchInfoClass(_patchRange.patchNum,
+          _allPatchSets)]]">
+        <div class="patchInfo-header">
+          <div class="patchInfo-header-wrapper">
+            <label class="patchSelectLabel" for="patchSetSelect">
+              Patch set
+            </label>
+            <select
+                is="gr-select"
+                id="patchSetSelect"
+                bind-value="{{_selectedPatchSet}}"
+                class="patchSetSelect"
+                on-change="_handlePatchChange">
+              <template is="dom-repeat" items="[[_allPatchSets]]"
+                  as="patchNum">
+                <option value$="[[patchNum.num]]"
+                    disabled$="[[_computePatchSetDisabled(patchNum.num, _patchRange.basePatchNum)]]">
+                  [[patchNum.num]]
+                  /
+                  [[_computeLatestPatchNum(_allPatchSets)]]
+                  [[_computePatchSetDescription(_change, patchNum.num)]]
+                </option>
+              </template>
+            </select>
+            /
+            <gr-commit-info
+                change="[[_change]]"
+                server-config="[[serverConfig]]"
+                commit-info="[[_commitInfo]]"></gr-commit-info>
+            <span class="latestPatchContainer">
+              /
+              <a href$="[[getBaseUrl()]]/c/[[_change._number]]">Go to latest patch set</a>
+            </span>
+            <span class="downloadContainer desktop">
+              /
+              <gr-button link
+                  class="download"
+                  on-tap="_handleDownloadTap">Download</gr-button>
+            </span>
+            <span class="descriptionContainer">
+              /
+              <gr-editable-label
+                  id="descriptionLabel"
+                  class="descriptionLabel"
+                  value="[[_computePatchSetDescription(_change, _selectedPatchSet)]]"
+                  placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+                  read-only="[[_descriptionReadOnly]]"
+                  on-changed="_handleDescriptionChanged"></gr-editable-label>
+            </span>
+          </div>
+        </div>
+        <gr-file-list id="fileList"
+            change="[[_change]]"
+            change-num="[[_changeNum]]"
+            patch-range="{{_patchRange}}"
+            comments="[[_comments]]"
+            drafts="[[_diffDrafts]]"
+            revisions="[[_change.revisions]]"
+            project-config="[[_projectConfig]]"
+            selected-index="{{viewState.selectedFileIndex}}"
+            diff-view-mode="{{viewState.diffMode}}"></gr-file-list>
+      </section>
       <gr-messages-list id="messageList"
           change-num="[[_changeNum]]"
           messages="[[_change.messages]]"
@@ -305,6 +476,7 @@
     </div>
     <gr-overlay id="downloadOverlay" with-backdrop>
       <gr-download-dialog
+          id="downloadDialog"
           change="[[_change]]"
           logged-in="[[_loggedIn]]"
           patch-num="[[_patchRange.patchNum]]"
@@ -312,16 +484,17 @@
           on-close="_handleDownloadDialogClose"></gr-download-dialog>
     </gr-overlay>
     <gr-overlay id="replyOverlay"
+        class="scrollable"
+        no-cancel-on-outside-click
         on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
       <gr-reply-dialog id="replyDialog"
-          change="[[_change]]"
-          patch-num="[[_patchRange.patchNum]]"
-          revisions="[[_change.revisions]]"
-          labels="[[_change.labels]]"
+          change="{{_change}}"
+          patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
           permitted-labels="[[_change.permitted_labels]]"
           diff-drafts="[[_diffDrafts]]"
           server-config="[[serverConfig]]"
+          project-config="[[_projectConfig]]"
           on-send="_handleReplySent"
           on-cancel="_handleReplyCancel"
           on-autogrow="_handleReplyAutogrow"
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 14ac4d1..baaf0029 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
@@ -14,6 +14,19 @@
 (function() {
   'use strict';
 
+  var CHANGE_ID_ERROR = {
+    MISMATCH: 'mismatch',
+    MISSING: 'missing',
+  };
+  var CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
+  var COMMENT_SAVE = 'Saving... Try again after all comments are saved.';
+
+  var MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+
+  // Maximum length for patch set descriptions.
+  var PATCH_DESC_MAX_LENGTH = 500;
+  var REVIEWERS_REGEX = /^R=/gm;
+
   Polymer({
     is: 'gr-change-view',
 
@@ -42,18 +55,25 @@
         notify: true,
         value: function() { return {}; },
       },
+      backPage: String,
+      hasParent: Boolean,
       serverConfig: Object,
       keyEventTarget: {
         type: Object,
         value: function() { return document.body; },
       },
 
+      _account: {
+        type: Object,
+        value: {},
+      },
       _comments: Object,
       _change: {
         type: Object,
         observer: '_changeChanged',
       },
       _commitInfo: Object,
+      _files: Object,
       _changeNum: String,
       _diffDrafts: {
         type: Object,
@@ -66,30 +86,75 @@
       _hideEditCommitMessage: {
         type: Boolean,
         computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change.*, _patchRange.patchNum)',
+            '_editingCommitMessage, _change)',
       },
-      _patchRange: Object,
+      _latestCommitMessage: {
+        type: String,
+        value: '',
+      },
+      _lineHeight: Number,
+      _changeIdCommitMessageError: {
+        type: String,
+        computed:
+          '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+      },
+      _patchRange: {
+        type: Object,
+        observer: '_updateSelected',
+      },
+      _relatedChangesLoading: {
+        type: Boolean,
+        value: true,
+      },
+      _currentRevisionActions: Object,
       _allPatchSets: {
         type: Array,
-        computed: '_computeAllPatchSets(_change)',
+        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
       },
       _loggedIn: {
         type: Boolean,
         value: false,
       },
       _loading: Boolean,
-      _headerContainerEl: Object,
-      _headerEl: Object,
       _projectConfig: Object,
+      _rebaseOnCurrent: Boolean,
       _replyButtonLabel: {
         type: String,
         value: 'Reply',
         computed: '_computeReplyButtonLabel(_diffDrafts.*)',
       },
+      _selectedPatchSet: String,
+      _initialLoadComplete: {
+        type: Boolean,
+        value: false,
+      },
+      _descriptionReadOnly: {
+        type: Boolean,
+        computed: '_computeDescriptionReadOnly(_loggedIn, _change, _account)',
+      },
+      _replyDisabled: {
+        type: Boolean,
+        value: true,
+        computed: '_computeReplyDisabled(serverConfig)',
+      },
+      _changeStatus: {
+        type: String,
+        computed: '_computeChangeStatus(_change, _patchRange.patchNum)',
+      },
+      _commitCollapsed: {
+        type: Boolean,
+        value: true,
+      },
+      _relatedChangesCollapsed: {
+        type: Boolean,
+        value: true,
+      },
     },
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -98,13 +163,24 @@
       '_paramsAndChangeChanged(params, _change)',
     ],
 
-    ready: function() {
-      this._headerEl = this.$$('.header');
+    keyBindings: {
+      'shift+r': '_handleCapitalRKey',
+      'a': '_handleAKey',
+      'd': '_handleDKey',
+      's': '_handleSKey',
+      'u': '_handleUKey',
+      'x': '_handleXKey',
+      'z': '_handleZKey',
     },
 
     attached: function() {
       this._getLoggedIn().then(function(loggedIn) {
         this._loggedIn = loggedIn;
+        if (loggedIn) {
+          this.$.restAPI.getAccount().then(function(acct) {
+            this._account = acct;
+          }.bind(this));
+        }
       }.bind(this));
 
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
@@ -114,34 +190,11 @@
           this._handleCommitMessageSave.bind(this));
       this.addEventListener('editable-content-cancel',
           this._handleCommitMessageCancel.bind(this));
-      this.listen(window, 'scroll', '_handleBodyScroll');
+      this.listen(window, 'scroll', '_handleScroll');
     },
 
     detached: function() {
-      this.unlisten(window, 'scroll', '_handleBodyScroll');
-    },
-
-    _handleBodyScroll: function(e) {
-      var containerEl = this._headerContainerEl ||
-          this.$$('.headerContainer');
-
-      // Calculate where the header is relative to the window.
-      var top = containerEl.offsetTop;
-      for (var offsetParent = containerEl.offsetParent;
-           offsetParent;
-           offsetParent = offsetParent.offsetParent) {
-        top += offsetParent.offsetTop;
-      }
-      // The element may not be displayed yet, in which case do nothing.
-      if (top == 0) { return; }
-
-      this._headerEl.classList.toggle('pinned', window.scrollY >= top);
-    },
-
-    _resetHeaderEl: function() {
-      var el = this._headerEl || this.$$('.header');
-      this._headerEl = el;
-      el.classList.remove('pinned');
+      this.unlisten(window, 'scroll', '_handleScroll');
     },
 
     _handleEditCommitMessage: function(e) {
@@ -152,12 +205,14 @@
     _handleCommitMessageSave: function(e) {
       var message = e.detail.content;
 
+      this.$.jsAPI.handleCommitMessage(this._change, message);
+
       this.$.commitMessageEditor.disabled = true;
       this._saveCommitMessage(message).then(function(resp) {
         this.$.commitMessageEditor.disabled = false;
         if (!resp.ok) { return; }
 
-        this.set('_commitInfo.message', message);
+        this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
         this._editingCommitMessage = false;
         this._reloadWindow();
       }.bind(this)).catch(function(err) {
@@ -182,16 +237,8 @@
           }.bind(this));
     },
 
-    _computeHideEditCommitMessage: function(loggedIn, editing, changeRecord,
-        patchNum) {
-      if (!changeRecord || !loggedIn || editing) { return true; }
-
-      patchNum = parseInt(patchNum, 10);
-      if (isNaN(patchNum)) { return true; }
-
-      var change = changeRecord.base;
-      if (!change.current_revision) { return true; }
-      if (change.revisions[change.current_revision]._number !== patchNum) {
+    _computeHideEditCommitMessage: function(loggedIn, editing, change) {
+      if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) {
         return true;
       }
 
@@ -267,19 +314,7 @@
     },
 
     _handlePatchChange: function(e) {
-      var patchNum = e.target.value;
-      var currentPatchNum;
-      if (this._change.current_revision) {
-        currentPatchNum =
-            this._change.revisions[this._change.current_revision]._number;
-      } else {
-        currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
-      }
-      if (patchNum == currentPatchNum) {
-        page.show(this.changePath(this._changeNum));
-        return;
-      }
-      page.show(this.changePath(this._changeNum) + '/' + patchNum);
+      this._changePatchNum(parseInt(e.target.value, 10), true);
     },
 
     _handleReplyTap: function(e) {
@@ -289,7 +324,11 @@
 
     _handleDownloadTap: function(e) {
       e.preventDefault();
-      this.$.downloadOverlay.open();
+      this.$.downloadOverlay.open().then(function() {
+        this.$.downloadOverlay
+            .setFocusStops(this.$.downloadDialog.getFocusStops());
+        this.$.downloadDialog.focus();
+      }.bind(this));
     },
 
     _handleDownloadDialogClose: function(e) {
@@ -300,7 +339,11 @@
       var msg = e.detail.message.message;
       var quoteStr = msg.split('\n').map(
           function(line) { return '> ' + line; }).join('\n') + '\n\n';
-      this.$.replyDialog.draft += quoteStr;
+
+      if (quoteStr !== this.$.replyDialog.quote) {
+        this.$.replyDialog.draft = quoteStr;
+      }
+      this.$.replyDialog.quote = quoteStr;
       this._openReplyDialog();
     },
 
@@ -329,33 +372,89 @@
       this._openReplyDialog(target);
     },
 
-    _paramsChanged: function(value) {
-      if (value.view !== this.tagName.toLowerCase()) { return; }
+    _handleScroll: function() {
+      this.debounce('scroll', function() {
+        history.replaceState(
+            {
+              scrollTop: document.body.scrollTop,
+              path: location.pathname,
+            },
+            location.pathname);
+      }, 150);
+    },
 
-      this._changeNum = value.changeNum;
-      this._patchRange = {
+    _paramsChanged: function(value) {
+      if (value.view !== this.tagName.toLowerCase()) {
+        this._initialLoadComplete = false;
+        return;
+      }
+
+      var patchChanged = this._patchRange &&
+          (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
+          (this._patchRange.patchNum !== value.patchNum ||
+          this._patchRange.basePatchNum !== value.basePatchNum);
+
+      if (this._changeNum !== value.changeNum) {
+        this._initialLoadComplete = false;
+      }
+
+      var patchRange = {
         patchNum: value.patchNum,
         basePatchNum: value.basePatchNum || 'PARENT',
       };
 
+      if (this._initialLoadComplete && patchChanged) {
+        if (patchRange.patchNum == null) {
+          patchRange.patchNum = this._computeLatestPatchNum(this._allPatchSets);
+        }
+        this._patchRange = patchRange;
+        this._reloadPatchNumDependentResources().then(function() {
+          this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
+            change: this._change,
+            patchNum: patchRange.patchNum,
+          });
+        }.bind(this));
+        return;
+      }
+
+      this._changeNum = value.changeNum;
+      this._patchRange = patchRange;
+      this.$.relatedChanges.clear();
+
       this._reload().then(function() {
-        this.$.messageList.topMargin = this._headerEl.offsetHeight;
-        this.$.fileList.topMargin = this._headerEl.offsetHeight;
-
-        // Allow the message list to render before scrolling.
-        this.async(function() {
-          this._maybeScrollToMessage();
-        }.bind(this), 1);
-
-        this._maybeShowReplyDialog();
-
-        this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
-          change: this._change,
-          patchNum: this._patchRange.patchNum,
-        });
+        this._performPostLoadTasks();
       }.bind(this));
     },
 
+    _performPostLoadTasks: function() {
+      // Allow the message list and related changes to render before scrolling.
+      // Related changes are loaded here (after everything else) because they
+      // take the longest and are secondary information. Because the element may
+      // alter the total height of the page, the call to potentially scroll to
+      // a linked message is performed after related changes is fully loaded.
+      this.$.relatedChanges.reload().then(function() {
+        this.async(function() {
+          if (history.state && history.state.scrollTop) {
+            document.documentElement.scrollTop =
+                document.body.scrollTop = history.state.scrollTop;
+          } else {
+            this._maybeScrollToMessage();
+          }
+        }, 1);
+      }.bind(this));
+
+      this._maybeShowReplyDialog();
+
+      this._maybeShowRevertDialog();
+
+      this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
+        change: this._change,
+        patchNum: this._patchRange.patchNum,
+      });
+
+      this._initialLoadComplete = true;
+    },
+
     _paramsAndChangeChanged: function(value) {
       // If the change number or patch range is different, then reset the
       // selected file index.
@@ -375,6 +474,38 @@
       }
     },
 
+    _getLocationSearch: function() {
+      // Not inlining to make it easier to test.
+      return window.location.search;
+    },
+
+    _getUrlParameter: function(param) {
+      var pageURL = this._getLocationSearch().substring(1);
+      var vars = pageURL.split('&');
+      for (var i = 0; i < vars.length; i++) {
+        var name = vars[i].split('=');
+        if (name[0] == param) {
+          return name[0];
+        }
+      }
+      return null;
+    },
+
+    _maybeShowRevertDialog: function() {
+      Gerrit.awaitPluginsLoaded()
+        .then(this._getLoggedIn.bind(this))
+        .then(function(loggedIn) {
+          if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) {
+            // Do not display dialog if not logged-in or the change is not
+            // merged.
+            return;
+          }
+          if (!!this._getUrlParameter('revert')) {
+            this.$.actions.showRevertDialog();
+          }
+        }.bind(this));
+    },
+
     _maybeShowReplyDialog: function() {
       this._getLoggedIn().then(function(loggedIn) {
         if (!loggedIn) { return; }
@@ -389,6 +520,12 @@
 
     _resetFileListViewState: function() {
       this.set('viewState.selectedFileIndex', 0);
+      if (!!this.viewState.changeNum &&
+          this.viewState.changeNum !== this._changeNum) {
+        // Reset the diff mode to null when navigating from one change to
+        // another, so that the user's preference is restored.
+        this.set('viewState.diffMode', null);
+      }
       this.set('viewState.changeNum', this._changeNum);
       this.set('viewState.patchRange', this._patchRange);
     },
@@ -401,51 +538,139 @@
           this._patchRange.patchNum ||
               this._computeLatestPatchNum(this._allPatchSets));
 
+      this._updateSelected();
+
       var title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
       this.fire('title-change', {title: title});
     },
 
+    /**
+     * Change active patch to the provided patch num.
+     * @param {number} patchNum the patchn number to be viewed.
+     * @param {boolean} opt_forceParams When set to true, the resulting URL will
+     *     always include the patch range, even if the requested patchNum is
+     *     known to be the latest.
+     */
+    _changePatchNum: function(patchNum, opt_forceParams) {
+      if (!opt_forceParams) {
+        var currentPatchNum;
+        if (this._change.current_revision) {
+          currentPatchNum =
+              this._change.revisions[this._change.current_revision]._number;
+        } else {
+          currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
+        }
+        if (patchNum === currentPatchNum &&
+            this._patchRange.basePatchNum === 'PARENT') {
+          page.show(this.changePath(this._changeNum));
+          return;
+        }
+      }
+      var patchExpr = this._patchRange.basePatchNum === 'PARENT' ? patchNum :
+          this._patchRange.basePatchNum + '..' + patchNum;
+      page.show(this.changePath(this._changeNum) + '/' + patchExpr);
+    },
+
     _computeChangePermalink: function(changeNum) {
-      return '/' + changeNum;
+      return this.getBaseUrl() + '/' + changeNum;
     },
 
     _computeChangeStatus: function(change, patchNum) {
-      var statusString;
+      var statusString = this.changeStatusString(change);
       if (change.status === this.ChangeStatus.NEW) {
-        var rev = this._getRevisionNumber(change, patchNum);
+        var rev = this.getRevisionByPatchNum(change.revisions, patchNum);
         if (rev && rev.draft === true) {
           statusString = 'Draft';
         }
-      } else {
-        statusString = this.changeStatusString(change);
       }
-      return statusString ? '(' + statusString + ')' : '';
+      return statusString;
+    },
+
+    _computeShowCommitInfo: function(changeStatus, current_revision) {
+      return changeStatus === 'Merged' && current_revision;
+    },
+
+    _computeMergedCommitInfo: function(current_revision, revisions) {
+      var rev = revisions[current_revision];
+      if (!rev || !rev.commit) { return {}; }
+      // CommitInfo.commit is optional. Set commit in all cases to avoid error
+      // in <gr-commit-info>. @see Issue 5337
+      if (!rev.commit.commit) { rev.commit.commit = current_revision; }
+      return rev.commit;
+    },
+
+    _computeChangeIdClass: function(displayChangeId) {
+      return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+    },
+
+    _computeTitleAttributeWarning: function(displayChangeId) {
+      if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+        return 'Change-Id mismatch';
+      } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+        return 'No Change-Id in commit message';
+      }
+    },
+
+    _computeChangeIdCommitMessageError: function(commitMessage, change) {
+      if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
+
+      // Find the last match in the commit message:
+      var changeId;
+      var changeIdArr;
+
+      while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
+        changeId = changeIdArr[1];
+      }
+
+      if (changeId) {
+        // A change-id is detected in the commit message.
+
+        if (changeId === change.change_id) {
+          // The change-id found matches the real change-id.
+          return null;
+        }
+        // The change-id found does not match the change-id.
+        return CHANGE_ID_ERROR.MISMATCH;
+      }
+      // There is no change-id in the commit message.
+      return CHANGE_ID_ERROR.MISSING;
     },
 
     _computeLatestPatchNum: function(allPatchSets) {
-      return allPatchSets[allPatchSets.length - 1];
+      return allPatchSets[allPatchSets.length - 1].num;
+    },
+
+    _computePatchInfoClass: function(patchNum, allPatchSets) {
+      if (parseInt(patchNum, 10) ===
+          this._computeLatestPatchNum(allPatchSets)) {
+        return '';
+      }
+      return 'patchInfo--oldPatchSet';
+    },
+
+    /**
+     * Determines if a patch number should be disabled based on value of the
+     * basePatchNum from gr-file-list.
+     * @param {Number} patchNum Patch number available in dropdown
+     * @param {Number|String} basePatchNum Base patch number from file list
+     * @return {Boolean}
+     */
+    _computePatchSetDisabled: function(patchNum, basePatchNum) {
+      basePatchNum = basePatchNum === 'PARENT' ? 0 : basePatchNum;
+      return parseInt(patchNum, 10) <= parseInt(basePatchNum, 10);
     },
 
     _computeAllPatchSets: function(change) {
       var patchNums = [];
-      for (var rev in change.revisions) {
-        patchNums.push(change.revisions[rev]._number);
-      }
-      return patchNums.sort(function(a, b) {
-        return a - b;
-      });
-    },
-
-    _getRevisionNumber: function(change, patchNum) {
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum) {
-          return change.revisions[rev];
+      for (var commit in change.revisions) {
+        if (change.revisions.hasOwnProperty(commit)) {
+          patchNums.push({
+            num: change.revisions[commit]._number,
+            desc: change.revisions[commit].description,
+          });
         }
       }
-    },
-
-    _computePatchIndexIsSelected: function(index, patchNum) {
-      return this._allPatchSets[index] == patchNum;
+      return patchNums.sort(function(a, b) { return a.num - b.num; });
     },
 
     _computeLabelNames: function(labels) {
@@ -477,11 +702,6 @@
       return result;
     },
 
-    _computeReplyButtonHighlighted: function(changeRecord) {
-      var drafts = (changeRecord && changeRecord.base) || {};
-      return Object.keys(drafts).length > 0;
-    },
-
     _computeReplyButtonLabel: function(changeRecord) {
       var drafts = (changeRecord && changeRecord.base) || {};
       var draftCount = Object.keys(drafts).reduce(function(count, file) {
@@ -495,39 +715,114 @@
       return label;
     },
 
-    _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+    _handleAKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e) ||
+          !this._loggedIn) { return; }
 
-      switch (e.keyCode) {
-        case 65:  // 'a'
-          if (this._loggedIn && !e.shiftKey) {
-            e.preventDefault();
-            this._openReplyDialog();
+      e.preventDefault();
+      this._openReplyDialog();
+    },
+
+    _handleDKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.downloadOverlay.open();
+    },
+
+    _handleCapitalRKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      e.preventDefault();
+      page.show('/c/' + this._change._number);
+    },
+
+    _handleSKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.changeStar.toggleStar();
+    },
+
+    _handleUKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._determinePageBack();
+    },
+
+    _handleXKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.messageList.handleExpandCollapse(true);
+    },
+
+    _handleZKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.messageList.handleExpandCollapse(false);
+    },
+
+    _determinePageBack: function() {
+      // Default backPage to '/' if user came to change view page
+      // via an email link, etc.
+      page.show(this.backPage || '/');
+    },
+
+    _handleLabelRemoved: function(splices, path) {
+      for (var i = 0; i < splices.length; i++) {
+        var splice = splices[i];
+        for (var j = 0; j < splice.removed.length; j++) {
+          var removed = splice.removed[j];
+          var changePath = path.split('.');
+          var labelPath = changePath.splice(0, changePath.length - 2);
+          var labelDict = this.get(labelPath);
+          if (labelDict.approved &&
+              labelDict.approved._account_id === removed._account_id) {
+            this._reload();
+            return;
           }
-          break;
-        case 85:  // 'u'
-          e.preventDefault();
-          page.show('/');
-          break;
+        }
       }
     },
 
     _labelsChanged: function(changeRecord) {
       if (!changeRecord) { return; }
+      if (changeRecord.value.indexSplices) {
+        this._handleLabelRemoved(changeRecord.value.indexSplices,
+            changeRecord.path);
+      }
       this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
         change: this._change,
       });
     },
 
     _openReplyDialog: function(opt_section) {
+      if (this.$.restAPI.hasPendingDiffDrafts()) {
+        this.dispatchEvent(new CustomEvent('show-alert',
+            {detail: {message: COMMENT_SAVE}, bubbles: true}));
+        return;
+      }
       this.$.replyOverlay.open().then(function() {
         this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
         this.$.replyDialog.open(opt_section);
       }.bind(this));
     },
 
-    _handleReloadChange: function() {
-      page.show(this.changePath(this._changeNum));
+    _handleReloadChange: function(e) {
+      return this._reload().then(function() {
+        // If the change was rebased, we need to reload the page with the
+        // latest patch.
+        if (e.detail.action === 'rebase') {
+          page.show(this.changePath(this._changeNum));
+        }
+      }.bind(this));
     },
 
     _handleGetChangeDetailError: function(response) {
@@ -552,6 +847,22 @@
           }.bind(this));
     },
 
+    _updateRebaseAction: function(revisionActions) {
+      if (revisionActions && revisionActions.rebase) {
+        revisionActions.rebase.rebaseOnCurrent =
+            !!revisionActions.rebase.enabled;
+        revisionActions.rebase.enabled = true;
+      }
+      return revisionActions;
+    },
+
+    _prepareCommitMsgForLinkify: function(msg) {
+      // TODO(wyatta) switch linkify sequence, see issue 5526.
+      // This is a zero-with space. It is added to prevent the linkify library
+      // from including R= as part of the email address.
+      return msg.replace(REVIEWERS_REGEX, 'R=\u200B');
+    },
+
     _getChangeDetail: function() {
       return this.$.restAPI.getChangeDetail(this._changeNum,
           this._handleGetChangeDetailError.bind(this)).then(
@@ -561,7 +872,29 @@
                 if (!change.reviewer_updates) {
                   change.reviewer_updates = null;
                 }
+                var latestRevisionSha = this._getLatestRevisionSHA(change);
+                var currentRevision = change.revisions[latestRevisionSha];
+                if (currentRevision.commit && currentRevision.commit.message) {
+                  this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+                      currentRevision.commit.message);
+                } else {
+                  this._latestCommitMessage = null;
+                }
+                var lineHeight = getComputedStyle(this).lineHeight;
+                this._lineHeight = lineHeight.slice(0, lineHeight.length - 2);
+
                 this._change = change;
+                if (!this._patchRange || !this._patchRange.patchNum ||
+                    this._patchRange.patchNum === currentRevision._number) {
+                  // CommitInfo.commit is optional, and may need patching.
+                  if (!currentRevision.commit.commit) {
+                    currentRevision.commit.commit = latestRevisionSha;
+                  }
+                  this._commitInfo = currentRevision.commit;
+                  this._currentRevisionActions =
+                      this._updateRebaseAction(currentRevision.actions);
+                  // TODO: Fetch and process files.
+                }
               }.bind(this));
     },
 
@@ -572,6 +905,34 @@
           }.bind(this));
     },
 
+    _getLatestCommitMessage: function() {
+      return this.$.restAPI.getChangeCommitInfo(this._changeNum,
+          this._computeLatestPatchNum(this._allPatchSets)).then(
+              function(commitInfo) {
+                this._latestCommitMessage =
+                    this._prepareCommitMsgForLinkify(commitInfo.message);
+              }.bind(this));
+    },
+
+    _getLatestRevisionSHA: function(change) {
+      if (change.current_revision) {
+        return change.current_revision;
+      }
+      // current_revision may not be present in the case where the latest rev is
+      // a draft and the user doesn’t have permission to view that rev.
+      var latestRev = null;
+      var latestPatchNum = -1;
+      for (var rev in change.revisions) {
+        if (!change.revisions.hasOwnProperty(rev)) { continue; }
+
+        if (change.revisions[rev]._number > latestPatchNum) {
+          latestRev = rev;
+          latestPatchNum = change.revisions[rev]._number;
+        }
+      }
+      return latestRev;
+    },
+
     _getCommitInfo: function() {
       return this.$.restAPI.getChangeCommitInfo(
           this._changeNum, this._patchRange.patchNum).then(
@@ -591,6 +952,7 @@
 
     _reload: function() {
       this._loading = true;
+      this._relatedChangesCollapsed = true;
 
       this._getLoggedIn().then(function(loggedIn) {
         if (!loggedIn) { return; }
@@ -600,36 +962,183 @@
 
       var detailCompletes = this._getChangeDetail().then(function() {
         this._loading = false;
+        this._getProjectConfig();
       }.bind(this));
       this._getComments();
 
-      var reloadPatchNumDependentResources = function() {
-        return Promise.all([
-          this._getCommitInfo(),
-          this.$.actions.reload(),
-          this.$.fileList.reload(),
-        ]);
-      }.bind(this);
-      var reloadDetailDependentResources = function() {
-        if (!this._change) { return Promise.resolve(); }
-
-        return Promise.all([
-          this.$.relatedChanges.reload(),
-          this._getProjectConfig(),
-        ]);
-      }.bind(this);
-
-      this._resetHeaderEl();
-
       if (this._patchRange.patchNum) {
-        return reloadPatchNumDependentResources().then(function() {
-          return detailCompletes;
-        }).then(reloadDetailDependentResources);
+        return Promise.all([
+          this._reloadPatchNumDependentResources(),
+          detailCompletes,
+        ]).then(function() {
+          return this.$.actions.reload();
+        }.bind(this));
       } else {
         // The patch number is reliant on the change detail request.
-        return detailCompletes.then(reloadPatchNumDependentResources).then(
-            reloadDetailDependentResources);
+        return detailCompletes.then(function() {
+          this.$.fileList.reload();
+          if (!this._latestCommitMessage) {
+            this._getLatestCommitMessage();
+          }
+        }.bind(this));
       }
     },
+
+    /**
+     * Kicks off requests for resources that rely on the patch range
+     * (`this._patchRange`) being defined.
+     */
+    _reloadPatchNumDependentResources: function() {
+      return Promise.all([
+        this._getCommitInfo(),
+        this.$.fileList.reload(),
+      ]);
+    },
+
+    _updateSelected: function() {
+      this._selectedPatchSet = this._patchRange.patchNum;
+    },
+
+    _computePatchSetDescription: function(change, patchNum) {
+      var rev = this.getRevisionByPatchNum(change.revisions, patchNum);
+      return (rev && rev.description) ?
+          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+    },
+
+    _computeDescriptionPlaceholder: function(readOnly) {
+      return (readOnly ? 'No' : 'Add a') + ' patch set description';
+    },
+
+    _handleDescriptionChanged: function(e) {
+      var desc = e.detail.trim();
+      var rev = this.getRevisionByPatchNum(this._change.revisions,
+          this._selectedPatchSet);
+      var sha = this._getPatchsetHash(this._change.revisions, rev);
+      this.$.restAPI.setDescription(this._changeNum,
+          this._selectedPatchSet, desc)
+          .then(function(res) {
+            if (res.ok) {
+              this.set(['_change', 'revisions', sha, 'description'], desc);
+            }
+          }.bind(this));
+    },
+
+
+    /**
+     * @param {Object} revisions The revisions object keyed by revision hashes
+     * @param {Object} patchSet A revision already fetched from {revisions}
+     * @return {string} the SHA hash corresponding to the revision.
+     */
+    _getPatchsetHash: function(revisions, patchSet) {
+      for (var rev in revisions) {
+        if (revisions.hasOwnProperty(rev) &&
+            revisions[rev] === patchSet) {
+          return rev;
+        }
+      }
+    },
+
+    _computeDescriptionReadOnly: function(loggedIn, change, account) {
+      return !(loggedIn && (account._account_id === change.owner._account_id));
+    },
+
+    _computeReplyDisabled: function() { return false; },
+
+    _computeChangePermalinkAriaLabel: function(changeNum) {
+      return 'Change ' + changeNum;
+    },
+
+    _computeCommitClass: function(collapsed, commitMessage) {
+      if (this._computeCommitToggleHidden(commitMessage)) { return ''; }
+      return collapsed ? 'collapsed' : '';
+    },
+
+    _computeRelatedChangesClass: function(collapsed, loading) {
+      if (!loading && !this.customStyle['--relation-chain-max-height']) {
+        this._updateRelatedChangeMaxHeight();
+      }
+      return collapsed ? 'collapsed' : '';
+    },
+
+    _computeCollapseText: function(collapsed) {
+      // Symbols are up and down triangles.
+      return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
+    },
+
+    _toggleCommitCollapsed: function() {
+      this._commitCollapsed = !this._commitCollapsed;
+      if (this._commitCollapsed) {
+        window.scrollTo(0, 0);
+      }
+    },
+
+    _toggleRelatedChangesCollapsed: function() {
+      this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
+      if (this._relatedChangesCollapsed) {
+        window.scrollTo(0, 0);
+      }
+    },
+
+    _computeCommitToggleHidden: function(commitMessage) {
+      if (!commitMessage) { return true; }
+      return commitMessage.split('\n').length < MIN_LINES_FOR_COMMIT_COLLAPSE;
+    },
+
+    _getOffsetHeight: function(element) {
+      return element.offsetHeight;
+    },
+
+    _getScrollHeight: function(element) {
+      return element.scrollHeight;
+    },
+
+    /**
+     * Get the line height of an element to the nearest integer.
+     */
+    _getLineHeight: function(element) {
+      var lineHeightStr = getComputedStyle(element).lineHeight;
+      return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
+    },
+
+    /**
+     * New max height for the related changes section, shorter than the existing
+     * change info height.
+     */
+    _updateRelatedChangeMaxHeight: function() {
+      // Takes into account approximate height for the expand button and
+      // bottom margin
+      var extraHeight = 24;
+      var maxExistingHeight;
+      var hasCommitToggle =
+          !this._computeCommitToggleHidden(this._latestCommitMessage);
+      if (hasCommitToggle) {
+        // Make sure the content is lined up if both areas have buttons. If the
+        // commit message is not collapsed, instead use the change info hight.
+        maxExistingHeight = this._getOffsetHeight(this.$.commitMessage);
+      } else {
+        maxExistingHeight = this._getOffsetHeight(this.$.mainChangeInfo) -
+            extraHeight;
+      }
+
+      // Get the line height of related changes, and convert it to the nearest
+      // integer.
+      var lineHeight = this._getLineHeight(this.$.relatedChanges);
+
+      // Figure out a new height that is divisible by the rounded line height.
+      var remainder = maxExistingHeight % lineHeight;
+      var newHeight = maxExistingHeight - remainder;
+
+      // Update the max-height of the relation chain to this new height;
+      this.customStyle['--relation-chain-max-height'] = newHeight + 'px';
+      if (hasCommitToggle) {
+        this.customStyle['--related-change-btn-top-padding'] = remainder + 'px';
+      }
+      this.updateStyles();
+    },
+
+    _computeRelatedChangesToggleHidden: function() {
+      return this._getScrollHeight(this.$.relatedChanges) <=
+          this._getOffsetHeight(this.$.relatedChanges);
+    },
   });
 })();
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 c9a687b..28164cd 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
@@ -18,14 +18,15 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-view</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../bower_components/page/page.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-change-view.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-change-view></gr-change-view>
@@ -35,52 +36,276 @@
 <script>
   suite('gr-change-view tests', function() {
     var element;
+    var sandbox;
+    var showStub;
+    var TEST_SCROLL_TOP_PX = 100;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
+      showStub = sandbox.stub(page, 'show');
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         getAccount: function() { return Promise.resolve(null); },
       });
       element = fixture('basic');
     });
 
-    test('keyboard shortcuts', function() {
-      var showStub = sinon.stub(page, 'show');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
-      assert(showStub.lastCall.calledWithExactly('/'),
-          'Should navigate to /');
-      showStub.restore();
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
-      var overlayEl = element.$.replyOverlay;
-      assert.isFalse(overlayEl.opened);
-      element._loggedIn = true;
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
-      assert.isFalse(overlayEl.opened);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
-      assert.isTrue(overlayEl.opened);
-      overlayEl.close();
-      assert.isFalse(overlayEl.opened);
+    teardown(function(done) {
+      flush(function() {
+        sandbox.restore();
+        done();
+      });
     });
 
-    test('reply button is highlighted when there are drafts', function() {
+    suite('keyboard shortcuts', function() {
+      test('S should toggle the CL star', function() {
+        var starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
+        MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
+        assert(starStub.called);
+      });
+
+      test('U should navigate to / if no backPage set', function() {
+        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+        assert(showStub.lastCall.calledWithExactly('/'));
+      });
+
+      test('U should navigate to backPage if set', function() {
+        element.backPage = '/dashboard/self';
+        MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+        assert(showStub.lastCall.calledWithExactly('/dashboard/self'));
+      });
+
+      test('A should toggle overlay', function() {
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+        var overlayEl = element.$.replyOverlay;
+        assert.isFalse(overlayEl.opened);
+        element._loggedIn = true;
+
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+        assert.isFalse(overlayEl.opened);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+        assert.isTrue(overlayEl.opened);
+        overlayEl.close();
+        assert.isFalse(overlayEl.opened);
+      });
+
+      test('X should expand all messages', function() {
+        var handleExpand =
+            sandbox.stub(element.$.messageList, 'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
+        assert(handleExpand.calledWith(true));
+      });
+
+      test('Z should collapse all messages', function() {
+         var handleExpand =
+            sandbox.stub(element.$.messageList, 'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
+        assert(handleExpand.calledWith(false));
+      });
+
+      test('shift + R should fetch and navigate to the latest patch set',
+          function(done) {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: 'PARENT',
+          patchNum: 1,
+        };
+        element._change = {
+          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+          _number: 42,
+          revisions: {
+            rev1: {_number: 1},
+          },
+          current_revision: 'rev1',
+          status: 'NEW',
+          labels: {},
+          actions: {},
+        };
+
+        sandbox.stub(element.$.actions, 'reload');
+
+        showStub.restore();
+        showStub = sandbox.stub(page, 'show', function(arg) {
+          assert.equal(arg, '/c/42');
+          done();
+        });
+
+        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      });
+
+      test('d should open download overlay', function() {
+        var stub = sandbox.stub(element.$.downloadOverlay, 'open');
+        MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+        assert.isTrue(stub.called);
+      });
+    });
+
+    test('_computeDescriptionReadOnly', function() {
+      assert.equal(element._computeDescriptionReadOnly(false,
+          {owner: {_account_id: 1}}, {_account_id: 1}), true);
+      assert.equal(element._computeDescriptionReadOnly(true,
+          {owner: {_account_id: 0}}, {_account_id: 1}), true);
+      assert.equal(element._computeDescriptionReadOnly(true,
+          {owner: {_account_id: 1}}, {_account_id: 1}), false);
+    });
+
+    test('_computeDescriptionPlaceholder', function() {
+      assert.equal(element._computeDescriptionPlaceholder(true),
+          'No patch set description');
+      assert.equal(element._computeDescriptionPlaceholder(false),
+          'Add a patch set description');
+    });
+
+    test('_computePatchSetDisabled', function() {
+      var basePatchNum = 'PARENT';
+      var patchNum = 1;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          false);
+      basePatchNum = 1;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          true);
+      patchNum = 2;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          false);
+    });
+
+    test('_prepareCommitMsgForLinkify', function() {
+      var commitMessage = 'R=test@google.com';
+      var result = element._prepareCommitMsgForLinkify(commitMessage);
+      assert.equal(result, 'R=\u200Btest@google.com');
+
+      commitMessage = 'R=test@google.com\nR=test@google.com';
+      var result = element._prepareCommitMsgForLinkify(commitMessage);
+      assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+    }),
+
+    test('_handleDescriptionChanged', function() {
+      var putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
+          .returns(Promise.resolve({ok: true}));
+      sandbox.stub(element, '_computeDescriptionReadOnly');
+
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._selectedPatchNum = '1';
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        actions: {},
+        owner: {_account_id: 1},
+      };
+      element._account = {_account_id: 1};
+      element._loggedIn = true;
+
+      flushAsynchronousOperations();
+      var label = element.$.descriptionLabel;
+      assert.equal(label.value, 'test');
+      label.editing = true;
+      label._inputText = 'test2';
+      label._save();
+      flushAsynchronousOperations();
+      assert.isTrue(putDescStub.called);
+      assert.equal(putDescStub.args[0][2], 'test2');
+    });
+
+    test('_updateRebaseAction', function() {
+      var currentRevisionActions = {
+        cherrypick: {
+          enabled: true,
+          label: 'Cherry Pick',
+          method: 'POST',
+          title: 'cherrypick'
+        },
+        rebase: {
+          enabled: true,
+          label: 'Rebase',
+          method: 'POST',
+          title: 'Rebase onto tip of branch or parent change'
+        },
+      };
+
+      // Rebase enabled should always end up true.
+      // When rebase is enabled initially, rebaseOnCurrent should be set to
+      // true.
+      assert.equal(element._updateRebaseAction(currentRevisionActions),
+        currentRevisionActions);
+
+      assert.isTrue(currentRevisionActions.rebase.enabled);
+      assert.isTrue(currentRevisionActions.rebase.rebaseOnCurrent);
+
+      delete currentRevisionActions.rebase.enabled;
+
+      // When rebase is not enabled initially, rebaseOnCurrent should be set to
+      // false.
+      assert.equal(element._updateRebaseAction(currentRevisionActions),
+        currentRevisionActions);
+
+      assert.isTrue(currentRevisionActions.rebase.enabled);
+      assert.isFalse(currentRevisionActions.rebase.rebaseOnCurrent);
+    });
+
+    test('_reload is called when an approved label is removed', function() {
+      var vote = {_account_id: 1, name: 'bojack', value: 1};
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        current_revision: 'rev3',
+        status: 'NEW',
+        labels: {
+          test: {
+            all: [vote],
+            default_value: 0,
+            values: [],
+            approved: {},
+          },
+        },
+      };
+      flushAsynchronousOperations();
+      var reloadStub = sandbox.stub(element, '_reload');
+      element.splice('_change.labels.test.all', 0, 1);
+      assert.isFalse(reloadStub.called);
+      element._change.labels.test.all.push(vote);
+      element._change.labels.test.all.push(vote);
+      element._change.labels.test.approved = vote;
+      flushAsynchronousOperations();
+      element.splice('_change.labels.test.all', 0, 2);
+      assert.isTrue(reloadStub.called);
+      assert.isTrue(reloadStub.calledOnce);
+    });
+
+    test('reply button has updated count when there are drafts', function() {
       var replyButton = element.$$('gr-button.reply');
       assert.ok(replyButton);
-      assert.isFalse(replyButton.hasAttribute('primary'));
+      assert.equal(replyButton.textContent, 'Reply');
 
       element._diffDrafts = null;
-      assert.isFalse(replyButton.hasAttribute('primary'));
+      assert.equal(replyButton.textContent, 'Reply');
 
       element._diffDrafts = {};
-      assert.isFalse(replyButton.hasAttribute('primary'));
+      assert.equal(replyButton.textContent, 'Reply');
 
       element._diffDrafts = {
         'file1.txt': [{}],
         'file2.txt': [{}, {}],
       };
-      assert.isTrue(replyButton.hasAttribute('primary'));
       assert.equal(replyButton.textContent, 'Reply (3)');
     });
 
@@ -120,6 +345,36 @@
       assert.deepEqual(element._diffDrafts, {});
     });
 
+    test('change num change', function() {
+      element._changeNum = null;
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        labels: {},
+      };
+      element.viewState.changeNum = null;
+      element.viewState.diffMode = 'UNIFIED';
+      flushAsynchronousOperations();
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
+
+      element._changeNum = '1';
+      element.params = {changeNum: '1'};
+      element._change.newProp = '1';
+      flushAsynchronousOperations();
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
+      assert.equal(element.viewState.changeNum, '1');
+
+      element._changeNum = '2';
+      element.params = {changeNum: '2'};
+      element._change.newProp = '2';
+      flushAsynchronousOperations();
+      assert.isNull(element.viewState.diffMode);
+      assert.equal(element.viewState.changeNum, '2');
+    });
+
     test('patch num change', function(done) {
       element._changeNum = '42';
       element._patchRange = {
@@ -138,24 +393,23 @@
         status: 'NEW',
         labels: {},
       };
+      element.viewState.diffMode = 'UNIFIED';
       flushAsynchronousOperations();
-      var selectEl = element.$$('.header select');
-      assert.ok(selectEl);
-      var optionEls =
-          Polymer.dom(element.root).querySelectorAll('.header option');
-      assert.equal(optionEls.length, 4);
-      assert.isFalse(
-          element.$$('.header option[value="1"]').hasAttribute('selected'));
-      assert.isTrue(
-          element.$$('.header option[value="2"]').hasAttribute('selected'));
-      assert.isFalse(
-          element.$$('.header option[value="3"]').hasAttribute('selected'));
-      assert.equal(optionEls[3].value, 13);
 
-      var showStub = sinon.stub(page, 'show');
+      var selectEl = element.$$('.patchInfo-header select');
+      assert.ok(selectEl);
+      var optionEls = Polymer.dom(element.root).querySelectorAll(
+          '.patchInfo-header option');
+      assert.equal(optionEls.length, 4);
+      var select = element.$$('.patchInfo-header #patchSetSelect').bindValue;
+      assert.notEqual(select, 1);
+      assert.equal(select, 2);
+      assert.notEqual(select, 3);
+      assert.equal(optionEls[3].value, 13);
 
       var numEvents = 0;
       selectEl.addEventListener('change', function(e) {
+        assert.equal(element.viewState.diffMode, 'UNIFIED');
         numEvents++;
         if (numEvents == 1) {
           assert(showStub.lastCall.calledWithExactly('/c/42/1'),
@@ -163,9 +417,8 @@
           selectEl.value = '3';
           element.fire('change', {}, {node: selectEl});
         } else if (numEvents == 2) {
-          assert(showStub.lastCall.calledWithExactly('/c/42'),
-              'Should navigate to /c/42');
-          showStub.restore();
+          assert(showStub.lastCall.calledWithExactly('/c/42/3'),
+              'Should navigate to /c/42/3');
           done();
         }
       });
@@ -191,21 +444,19 @@
         labels: {},
       };
       flushAsynchronousOperations();
-      var selectEl = element.$$('.header select');
+      var selectEl = element.$$('.patchInfo-header select');
       assert.ok(selectEl);
-      var optionEls =
-          Polymer.dom(element.root).querySelectorAll('.header option');
+      var optionEls = Polymer.dom(element.root).querySelectorAll(
+          '.patchInfo-header option');
       assert.equal(optionEls.length, 4);
-      assert.isFalse(
-          element.$$('.header option[value="1"]').hasAttribute('selected'));
-      assert.isTrue(
-          element.$$('.header option[value="2"]').hasAttribute('selected'));
-      assert.isFalse(
-          element.$$('.header option[value="3"]').hasAttribute('selected'));
+      assert.notEqual(
+        element.$$('.patchInfo-header #patchSetSelect').bindValue, 1);
+      assert.equal(
+        element.$$('.patchInfo-header #patchSetSelect').bindValue, 2);
+      assert.notEqual(
+        element.$$('.patchInfo-header #patchSetSelect').bindValue, 3);
       assert.equal(optionEls[3].value, 13);
 
-      var showStub = sinon.stub(page, 'show');
-
       var numEvents = 0;
       selectEl.addEventListener('change', function(e) {
         numEvents++;
@@ -217,7 +468,6 @@
         } else if (numEvents == 2) {
           assert(showStub.lastCall.calledWithExactly('/c/42/3'),
               'Should navigate to /c/42/3');
-          showStub.restore();
           done();
         }
       });
@@ -225,6 +475,100 @@
       element.fire('change', {}, {node: selectEl});
     });
 
+    test('don’t reload entire page when patchRange changes', function() {
+      var reloadStub = sandbox.stub(element, '_reload',
+          function() { return Promise.resolve(); });
+      var reloadPatchDependentStub = sandbox.stub(element,
+          '_reloadPatchNumDependentResources',
+          function() { return Promise.resolve(); });
+      var relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
+
+      var value = {
+        view: 'gr-change-view',
+        patchNum: '1',
+      };
+      element._paramsChanged(value);
+      assert.isTrue(reloadStub.calledOnce);
+      assert.isTrue(relatedClearSpy.calledOnce);
+
+      element._initialLoadComplete = true;
+
+      value.basePatchNum = '1';
+      value.patchNum = '2';
+      element._paramsChanged(value);
+      assert.isFalse(reloadStub.calledTwice);
+      assert.isTrue(reloadPatchDependentStub.calledOnce);
+      assert.isTrue(relatedClearSpy.calledOnce);
+    });
+
+    test('reload entire page when patchRange doesnt change', function() {
+      var reloadStub = sandbox.stub(element, '_reload',
+          function() { return Promise.resolve(); });
+
+      var value = {
+        view: 'gr-change-view',
+      };
+      element._paramsChanged(value);
+      assert.isTrue(reloadStub.calledOnce);
+      element._initialLoadComplete = true;
+      element._paramsChanged(value);
+      assert.isTrue(reloadStub.calledTwice);
+    });
+
+    test('include base patch when not parent', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '2',
+        patchNum: '3',
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        status: 'NEW',
+        labels: {},
+      };
+
+      element._changePatchNum(13);
+      assert(showStub.lastCall.calledWithExactly('/c/42/2..13'));
+
+      element._patchRange.basePatchNum = 'PARENT';
+
+      element._changePatchNum(3);
+      assert(showStub.lastCall.calledWithExactly('/c/42/3'));
+    });
+
+    test('related changes are updated and new patch selected after rebase',
+        function(done) {
+      element._changeNum = '42';
+      sandbox.stub(element, '_computeLatestPatchNum', function() {
+        return 1;
+      });
+      sandbox.stub(element, '_reload',
+          function() { return Promise.resolve(); });
+      var e = {detail: {action: 'rebase'}};
+      element._handleReloadChange(e).then(function() {
+        assert.isTrue(showStub.lastCall.calledWithExactly('/c/42'));
+        done();
+      });
+    });
+
+    test('related changes are not updated after other action', function(done) {
+      sandbox.stub(element, '_reload',
+          function() { return Promise.resolve(); });
+      sandbox.stub(element, '_updateSelected');
+      sandbox.stub(element.$.relatedChanges, 'reload');
+      var e = {detail: {action: 'abandon'}};
+      element._handleReloadChange(e).then(function() {
+        assert.isFalse(showStub.called);
+        done();
+      });
+    });
+
     test('change status new', function() {
       element._changeNum = '1';
       element._patchRange = {
@@ -260,7 +604,46 @@
         labels: {},
       };
       var status = element._computeChangeStatus(element._change, '1');
-      assert.equal(status, '(Draft)');
+      assert.equal(status, 'Draft');
+    });
+
+    test('change status conflict', function() {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        mergeable: false,
+        status: 'NEW',
+        labels: {},
+      };
+      var status = element._computeChangeStatus(element._change, '1');
+      assert.equal(status, 'Merge Conflict');
+    });
+
+    test('change status merged', function() {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: element.ChangeStatus.MERGED,
+        labels: {},
+      };
+      var status = element._computeChangeStatus(element._change, '1');
+      assert.equal(status, 'Merged');
     });
 
     test('revision status draft', function() {
@@ -283,53 +666,440 @@
         labels: {},
       };
       var status = element._computeChangeStatus(element._change, '2');
-      assert.equal(status, '(Draft)');
+      assert.equal(status, 'Draft');
+    });
+
+    test('_computeMergedCommitInfo', function() {
+      var dummyRevs = {
+        1: {commit: {commit: 1}},
+        2: {commit: {}},
+      };
+      assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
+      assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
+          dummyRevs[1].commit);
+
+      // Regression test for issue 5337.
+      var commit = element._computeMergedCommitInfo(2, dummyRevs);
+      assert.notDeepEqual(commit, dummyRevs[2]);
+      assert.deepEqual(commit, {commit: 2});
+    });
+
+    test('get latest revision', function() {
+      var change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev3: {_number: 3},
+        },
+        current_revision: 'rev3',
+      };
+      assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+      change = {
+        revisions: {
+          rev1: {_number: 1},
+        },
+      };
+      assert.equal(element._getLatestRevisionSHA(change), 'rev1');
     });
 
     test('show commit message edit button', function() {
-      var changeRecord = {
-        base: {
-          revisions: {
-            rev1: {_number: 1},
-            rev2: {_number: 2},
-          },
-          current_revision: 'rev2',
-        },
+      var _change = {
+        status: element.ChangeStatus.MERGED,
       };
-      assert.isTrue(element._computeHideEditCommitMessage(
-          false, false, changeRecord, '2'));
-      assert.isTrue(element._computeHideEditCommitMessage(
-          true, true, changeRecord, '2'));
-      assert.isTrue(element._computeHideEditCommitMessage(
-          true, false, changeRecord, '1'));
-      assert.isFalse(element._computeHideEditCommitMessage(
-          true, false, changeRecord, '2'));
+      assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
+      assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
+      assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
+      assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
+      assert.isTrue(element._computeHideEditCommitMessage(true, false,
+          _change));
     });
 
-    test('topic is coalesced to null', function() {
-      sinon.stub(element, '_changeChanged');
-      sinon.stub(element.$.restAPI, 'getChangeDetail', function(num) {
-        return Promise.resolve({id: '123456789', labels: {}});
+    test('_computeChangeIdCommitMessageError', function() {
+      var commitMessage =
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
+      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+      assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+
+      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+      assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+
+      commitMessage = 'This is the greatest change.';
+      assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'missing');
+    });
+
+    test('multiple change Ids in commit message picks last', function() {
+      var commitMessage = [
+       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+      ].join('\n');
+      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+      assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+      assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+    });
+
+    test('does not count change Id that starts mid line', function() {
+      var commitMessage = [
+       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+      ].join(' and ');
+      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+      assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+      change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+      assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+    });
+
+    test('_computeTitleAttributeWarning', function() {
+      var changeIdCommitMessageError = 'missing';
+      assert.equal(
+          element._computeTitleAttributeWarning(changeIdCommitMessageError),
+          'No Change-Id in commit message');
+
+      var changeIdCommitMessageError = 'mismatch';
+      assert.equal(
+          element._computeTitleAttributeWarning(changeIdCommitMessageError),
+          'Change-Id mismatch');
+    });
+
+    test('_computeChangeIdClass', function() {
+      var changeIdCommitMessageError = 'missing';
+      assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), '');
+
+      var changeIdCommitMessageError = 'mismatch';
+      assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
+    });
+
+    test('topic is coalesced to null', function(done) {
+      sandbox.stub(element, '_changeChanged');
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
+        return Promise.resolve({
+          id: '123456789',
+          labels: {},
+          current_revision: 'foo',
+          revisions: {foo: {commit: {}}},
+        });
       });
 
       element._getChangeDetail().then(function() {
         assert.isNull(element._change.topic);
+        done();
+      });
+    });
+
+    test('commit sha is populated from getChangeDetail', function(done) {
+      sandbox.stub(element, '_changeChanged');
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
+        return Promise.resolve({
+          id: '123456789',
+          labels: {},
+          current_revision: 'foo',
+          revisions: {foo: {commit: {}}},
+        });
+      });
+
+      element._getChangeDetail().then(function() {
+        assert.equal('foo', element._commitInfo.commit);
+        done();
       });
     });
 
     test('reply dialog focus can be controlled', function() {
       var FocusTarget = element.$.replyDialog.FocusTarget;
-      var openSpy = sinon.spy(element, '_openReplyDialog');
+      var openStub = sandbox.stub(element, '_openReplyDialog');
 
       var e = {detail: {}};
       element._handleShowReplyDialog(e);
-      assert(openSpy.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
+      assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
           '_openReplyDialog should have been passed REVIEWERS');
 
       e.detail.value = {ccsOnly: true};
       element._handleShowReplyDialog(e);
-      assert(openSpy.lastCall.calledWithExactly(FocusTarget.CCS),
+      assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
           '_openReplyDialog should have been passed CCS');
     });
+
+    test('class is applied to file list on old patch set', function() {
+      var allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
+      assert.equal(element._computePatchInfoClass('1', allPatchSets),
+          'patchInfo--oldPatchSet');
+      assert.equal(element._computePatchInfoClass('2', allPatchSets),
+          'patchInfo--oldPatchSet');
+      assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+    });
+
+    test('getUrlParameter functionality', function() {
+      var locationStub = sandbox.stub(element, '_getLocationSearch');
+
+      locationStub.returns('?test');
+      assert.equal(element._getUrlParameter('test'), 'test');
+      locationStub.returns('?test2=12&test=3');
+      assert.equal(element._getUrlParameter('test'), 'test');
+      locationStub.returns('');
+      assert.isNull(element._getUrlParameter('test'));
+      locationStub.returns('?');
+      assert.isNull(element._getUrlParameter('test'));
+      locationStub.returns('?test2');
+      assert.isNull(element._getUrlParameter('test'));
+
+    });
+
+    test('revert dialog opened with revert param', function(done) {
+      sandbox.stub(element.$.restAPI, 'getLoggedIn', function() {
+        return Promise.resolve(true);
+      });
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded', function() {
+        return Promise.resolve();
+      });
+
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: element.ChangeStatus.MERGED,
+        labels: {},
+        actions: {},
+      };
+
+      var urlParamStub = sandbox.stub(element, '_getUrlParameter',
+          function(param) {
+            assert.equal(param, 'revert');
+            return param;
+          });
+
+      var revertDialogStub = sandbox.stub(element.$.actions, 'showRevertDialog',
+          done);
+
+      element._maybeShowRevertDialog();
+      assert.isTrue(Gerrit.awaitPluginsLoaded.called);
+    });
+
+    suite('scroll related tests', function() {
+      test('document scrolling calls function to set scroll height',
+          function(done) {
+            var originalHeight = document.body.scrollHeight;
+            var scrollStub = sandbox.stub(element, '_handleScroll',
+                function() {
+                  assert.isTrue(scrollStub.called);
+                  document.body.style.height =
+                      originalHeight + 'px';
+                  scrollStub.restore();
+                  done();
+                });
+            document.body.style.height = '10000px';
+            document.body.scrollTop = TEST_SCROLL_TOP_PX;
+            element._handleScroll();
+          });
+
+      test('history is loaded correctly', function() {
+        history.replaceState(
+            {
+              scrollTop: 100,
+              path: location.pathname,
+            },
+            location.pathname);
+
+        var reloadStub = sandbox.stub(element, '_reload',
+            function() {
+              // When element is reloaded, ensure that the history
+              // state has the scrollTop set earlier. This will then
+              // be reset.
+              assert.isTrue(history.state.scrollTop == 100);
+              return Promise.resolve({});
+            });
+
+        // simulate reloading component, which is done when route
+        // changes to match a regex of change view type.
+        element._paramsChanged({view: 'gr-change-view'});
+      });
+    });
+
+    suite('reply dialog tests', function() {
+      setup(function() {
+        sandbox.stub(element.$.replyDialog, '_draftChanged');
+      });
+
+      test('reply from comment adds quote text', function() {
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+
+      test('reply from comment replaces quote text', function() {
+        element.$.replyDialog.draft = '> old quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> old quote text\n\n';
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+
+      test('reply from same comment preserves quote text', function() {
+        element.$.replyDialog.draft = '> quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> quote text\n\n';
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft,
+            '> quote text\n\n some draft text');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+
+      test('reply from top of page contains previous draft', function() {
+        var div = document.createElement('div');
+        element.$.replyDialog.draft = '> quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> quote text\n\n';
+        var e = {target: div, preventDefault: sandbox.spy()};
+        element._handleReplyTap(e);
+        assert.equal(element.$.replyDialog.draft,
+            '> quote text\n\n some draft text');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+    });
+
+    test('reply button is disabled until server config is loaded', function() {
+      assert.isTrue(element._replyDisabled);
+      element.serverConfig = {};
+      assert.isFalse(element._replyDisabled);
+    });
+
+    suite('commit message expand/collapse', function() {
+      test('commitCollapseToggle hidden for short commit message', function() {
+        element._latestCommitMessage = '';
+        assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
+      });
+
+      test('commitCollapseToggle shown for long commit message', function() {
+        element._latestCommitMessage = _.times(31, String).join('\n');
+        assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
+      });
+
+      test('commitCollapseToggle functions', function() {
+        element._latestCommitMessage = _.times(31, String).join('\n');
+        assert.isTrue(element._commitCollapsed);
+        assert.isTrue(
+            element.$.commitMessage.classList.contains('collapsed'));
+        MockInteractions.tap(element.$.commitCollapseToggleButton);
+        assert.isFalse(element._commitCollapsed);
+        assert.isFalse(
+            element.$.commitMessage.classList.contains('collapsed'));
+      });
+    });
+
+    suite('related changes expand/collapse', function() {
+      var updateHeightSpy;
+      setup(function() {
+        updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
+      });
+
+      test('relatedChangesToggle shown height greater than changeInfo height',
+          function() {
+        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
+
+        sandbox.stub(element, '_getOffsetHeight', function() {
+          return 50;
+        });
+
+        sandbox.stub(element, '_getScrollHeight', function() {
+          return 60;
+        });
+        element._relatedChangesLoading = false;
+        assert.isFalse(element.$.relatedChangesToggle.hasAttribute('hidden'));
+        assert.equal(updateHeightSpy.callCount, 1);
+      });
+
+      test('relatedChangesToggle hidden height less than changeInfo height',
+            function() {
+        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
+        sandbox.stub(element, '_getOffsetHeight', function() {
+          return 50;
+        });
+
+        sandbox.stub(element, '_getScrollHeight', function() {
+          return 40;
+        });
+        element._relatedChangesLoading = false;
+        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
+        assert.equal(updateHeightSpy.callCount, 1);
+      });
+
+      test('relatedChangesToggle functions', function() {
+        sandbox.stub(element, '_getOffsetHeight', function() {
+          return 50;
+        });
+
+        sandbox.stub(element, '_getScrollHeight', function() {
+          return 60;
+        });
+        element._relatedChangesLoading = false;
+        assert.isTrue(element._relatedChangesCollapsed);
+        assert.isTrue(
+            element.$.relatedChanges.classList.contains('collapsed'));
+        MockInteractions.tap(element.$.relatedChangesToggleButton);
+        assert.isFalse(element._relatedChangesCollapsed);
+        assert.isFalse(
+            element.$.relatedChanges.classList.contains('collapsed'));
+      });
+
+      test('_updateRelatedChangeMaxHeight without commit toggle', function() {
+        sandbox.stub(element, '_getOffsetHeight', function() {
+          return 50;
+        });
+
+        sandbox.stub(element, '_getLineHeight', function() {
+          return 12;
+        });
+
+        // 50 (existing height) - 24 (extra height) = 26 (adjusted height).
+        // 50 (existing height)  % 12 (line height) = 2 (remainder).
+        // 26 (adjusted height) - 2 (remainder) = 24 (max height to set).
+
+        element._updateRelatedChangeMaxHeight();
+        assert.equal(element.customStyle['--relation-chain-max-height'],
+            '24px');
+        assert.equal(element.customStyle['--related-change-btn-top-padding'],
+            undefined);
+      });
+
+      test('_updateRelatedChangeMaxHeight with commit toggle', function() {
+        element._latestCommitMessage = _.times(31, String).join('\n');
+        sandbox.stub(element, '_getOffsetHeight', function() {
+          return 50;
+        });
+
+        sandbox.stub(element, '_getLineHeight', function() {
+          return 12;
+        });
+
+        // 50 (existing height) % 12 (line height) = 2 (remainder).
+        // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
+
+        element._updateRelatedChangeMaxHeight();
+        assert.equal(element.customStyle['--relation-chain-max-height'],
+            '48px');
+        assert.equal(element.customStyle['--related-change-btn-top-padding'],
+            '2px');
+      });
+    });
   });
 </script>
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 a7d99a7..a03f6cc 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
@@ -14,14 +14,17 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 
 <dom-module id="gr-comment-list">
   <template>
     <style>
       :host {
         display: block;
-        font-family: var(--monospace-font-family);
+        word-wrap: break-word;
       }
       .file {
         border-top: 1px solid #ddd;
@@ -34,18 +37,20 @@
         margin: 5px 0;
       }
       .lineNum {
-        margin-right: .35em;
-        min-width: 7em;
+        margin-right: .5em;
+        min-width: 10em;
+        text-align: right;
       }
       .message {
         flex: 1;
-        white-space: pre-wrap;
-        word-wrap: break-word;
+        max-width: 80ch;
       }
     </style>
     <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
       <div class="file">
-        <a href$="[[_computeFileDiffURL(file, changeNum, patchNum)]]">[[file]]</a>:
+        <a href$="[[_computeFileDiffURL(file, changeNum, patchNum)]]">
+          [[_computeFileDisplayName(file)]]
+        </a>:
       </div>
       <template is="dom-repeat"
                 items="[[_computeCommentsForFile(comments, file)]]" as="comment">
@@ -60,7 +65,11 @@
                File comment:
              </span>
           </a>
-          <div class="message">[[comment.message]]</div>
+          <gr-formatted-text
+              class="message"
+              no-trailing-margin
+              content="[[comment.message]]"
+              config="[[projectConfig.commentlinks]]"></gr-formatted-text>
         </div>
       </template>
     </template>
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 eaafc447..98a2508 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
@@ -14,28 +14,52 @@
 (function() {
   'use strict';
 
+  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+  var MERGE_LIST_PATH = '/MERGE_LIST';
+
   Polymer({
     is: 'gr-comment-list',
 
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.PathListBehavior,
+    ],
+
     properties: {
       changeNum: Number,
       comments: Object,
       patchNum: Number,
+      projectConfig: Object,
     },
 
     _computeFilesFromComments: function(comments) {
-      return Object.keys(comments || {}).sort();
+      var arr = Object.keys(comments || {});
+      return arr.sort(this.specialFilePathCompare);
     },
 
     _computeFileDiffURL: function(file, changeNum, patchNum) {
-      return '/c/' + changeNum + '/' + patchNum + '/' + file;
+      return this.getBaseUrl() + '/c/' + changeNum +
+        '/' + patchNum + '/' + file;
+    },
+
+    _computeFileDisplayName: function(path) {
+      if (path === COMMIT_MESSAGE_PATH) {
+        return 'Commit message';
+      } else if (path === MERGE_LIST_PATH) {
+        return 'Merge list';
+      }
+      return path;
+    },
+
+    _isOnParent: function(comment) {
+      return comment.side === 'PARENT';
     },
 
     _computeDiffLineURL: function(file, changeNum, patchNum, comment) {
       var diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
       if (comment.line) {
         diffURL += '#';
-        if (comment.side === 'PARENT') { diffURL += 'b'; }
+        if (this._isOnParent(comment)) { diffURL += 'b'; }
         diffURL += comment.line;
       }
       return diffURL;
@@ -48,13 +72,13 @@
     },
 
     _computePatchDisplayName: function(comment) {
-      if (comment.side == 'PARENT') {
+      if (this._isOnParent(comment)) {
         return 'Base, ';
       }
       if (comment.patch_set != this.patchNum) {
         return 'PS' + comment.patch_set + ', ';
       }
       return '';
-    }
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index 56a927b..e27bad0 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -18,10 +18,12 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-list</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="gr-comment-list.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-comment-list></gr-comment-list>
@@ -36,9 +38,21 @@
       element = fixture('basic');
     });
 
-    test('_computeFilesFromComments', function() {
-      var comments = {'file_b.html': [], 'file_c.css': [], 'file_a.js': []};
-      var expected = ['file_a.js', 'file_b.html', 'file_c.css'];
+    test('_computeFilesFromComments w/ special file path sorting', function() {
+      var comments = {
+        'file_b.html': [],
+        'file_c.css': [],
+        'file_a.js': [],
+        'test.cc': [],
+        'test.h': [],
+      };
+      var expected = [
+        'file_a.js',
+        'file_b.html',
+        'file_c.css',
+        'test.h',
+        'test.cc'
+      ];
       var actual = element._computeFilesFromComments(comments);
       assert.deepEqual(actual, expected);
 
@@ -51,8 +65,17 @@
       assert.equal(actual, expected);
     });
 
+    test('_computeFileDisplayName', function() {
+      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
+          'Commit message');
+      assert.equal(element._computeFileDisplayName('/MERGE_LIST'),
+          'Merge list');
+      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
+          '/foo/bar/baz');
+    });
+
     test('_computeDiffLineURL', function() {
-      var comment = {line: 123, side: 'REIVISION', patch_set: 10};
+      var comment = {line: 123, side: 'REVISION', patch_set: 10};
       var expected = '/c/<change>/<patch>/<file>#123';
       var actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
           comment);
@@ -67,7 +90,7 @@
     });
 
     test('_computePatchDisplayName', function() {
-      var comment = {line: 123, side: 'REIVISION', patch_set: 10};
+      var comment = {line: 123, side: 'REVISION', patch_set: 10};
 
       element.patchNum = 10;
       assert.equal(element._computePatchDisplayName(comment), '');
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
new file mode 100644
index 0000000..f1d02f2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
@@ -0,0 +1,35 @@
+<!--
+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-commit-info">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+      }
+    </style>
+    <template is="dom-if" if="[[_showWebLink]]">
+      <a target="_blank" rel="noopener"
+         href$="[[_webLink]]">[[_computeShortHash(commitInfo)]]</a>
+    </template>
+    <template is="dom-if" if="[[!_showWebLink]]">
+      [[_computeShortHash(commitInfo)]]
+    </template>
+  </template>
+  <script src="gr-commit-info.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
new file mode 100644
index 0000000..6ccf746
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -0,0 +1,94 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-commit-info',
+
+    properties: {
+      change: Object,
+      commitInfo: Object,
+      serverConfig: Object,
+      _showWebLink: {
+        type: Boolean,
+        computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
+      },
+      _webLink: {
+        type: String,
+        computed: '_computeWebLink(change, commitInfo, serverConfig)',
+      },
+    },
+
+    _isWebLink: function(link) {
+      // This is a whitelist of web link types that provide direct links to
+      // the commit in the url property.
+      return link.name === 'gitiles' || link.name === 'gitweb';
+    },
+
+    _computeShowWebLink: function(change, commitInfo, serverConfig) {
+      if (serverConfig.gitweb && serverConfig.gitweb.url &&
+          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
+        return true;
+      }
+
+      if (!commitInfo.web_links) {
+        return false;
+      }
+
+      for (var i = 0; i < commitInfo.web_links.length; i++) {
+        if (this._isWebLink(commitInfo.web_links[i])) {
+          return true;
+        }
+      }
+
+      return false;
+    },
+
+    _computeWebLink: function(change, commitInfo, serverConfig) {
+      if (!this._computeShowWebLink(change, commitInfo, serverConfig)) {
+        return;
+      }
+
+      if (serverConfig.gitweb && serverConfig.gitweb.url &&
+          serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
+        return serverConfig.gitweb.url +
+            serverConfig.gitweb.type.revision
+                .replace('${project}', change.project)
+                .replace('${commit}', commitInfo.commit);
+      }
+
+      var webLink = null;
+      for (var i = 0; i < commitInfo.web_links.length; i++) {
+        if (this._isWebLink(commitInfo.web_links[i])) {
+          webLink = commitInfo.web_links[i].url;
+          break;
+        }
+      }
+
+      if (!webLink) {
+        return;
+      }
+
+      return webLink;
+    },
+
+    _computeShortHash: function(commitInfo) {
+      if (!commitInfo || !commitInfo.commit) {
+        return;
+      }
+      return commitInfo.commit.slice(0, 7);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
new file mode 100644
index 0000000..10a51f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-commit-info</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-commit-info.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-commit-info></gr-commit-info>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-commit-info tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('no web link when unavailable', function() {
+      element.commitInfo = {};
+      element.serverConfig = {};
+      element.change = {labels: []};
+
+      assert.isNotOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+    });
+
+    test('use web link when available', function() {
+      element.commitInfo = {web_links: [{name: 'gitweb', url: 'link-url'}]};
+      element.serverConfig = {};
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'link-url');
+    });
+
+    test('does not relativize web links that begin with scheme', function() {
+      element.commitInfo = {
+        web_links: [{name: 'gitweb', url: 'https://link-url'}]
+      };
+      element.serverConfig = {};
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'https://link-url');
+    });
+
+    test('use gitweb when available', function() {
+      element.commitInfo = {commit: 'commit-sha'};
+      element.serverConfig = {gitweb: {
+        url: 'url-base/',
+        type: {revision: 'xx ${project} xx ${commit} xx'},
+      }};
+      element.change = {
+        project: 'project-name',
+        labels: [],
+        current_revision: element.commitInfo.commit
+      };
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'url-base/xx project-name xx commit-sha xx');
+    });
+
+    test('prefer gitweb when both are available', function() {
+      element.commitInfo = {
+        commit: 'commit-sha',
+        web_links: [{url: 'link-url'}]
+      };
+      element.serverConfig = {gitweb: {
+        url: 'url-base/',
+        type: {revision: 'xx ${project} xx ${commit} xx'},
+      }};
+      element.change = {
+        project: 'project-name',
+        labels: [],
+        current_revision: element.commitInfo.commit
+      };
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+
+      var link = element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig);
+
+      assert.equal(link, 'url-base/xx project-name xx commit-sha xx');
+      assert.notEqual(link, '../../link-url');
+    });
+
+    test('ignore web links that are neither gitweb nor gitiles', function() {
+      element.commitInfo = {
+        commit: 'commit-sha',
+        web_links: [
+          {
+            name: 'ignore',
+            url: 'ignore',
+          },
+          {
+            name: 'gitiles',
+            url: 'https://link-url',
+          }
+        ],
+      };
+      element.serverConfig = {};
+
+      assert.isOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.equal(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig), 'https://link-url');
+
+      // Remove gitiles link.
+      element.commitInfo.web_links.splice(1, 1);
+      assert.isNotOk(element._computeShowWebLink(element.change,
+          element.commitInfo, element.serverConfig));
+      assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
+          element.serverConfig));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index 7366d74..481b124 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -28,6 +28,11 @@
         opacity: .5;
         pointer-events: none;
       }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
       label {
         cursor: pointer;
         display: block;
@@ -54,6 +59,7 @@
         <iron-autogrow-textarea
             id="messageInput"
             class="message"
+            autocomplete="on"
             placeholder="<Insert reasoning here>"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index 0ce1cbb..e47f14f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -33,6 +33,10 @@
       message: String,
     },
 
+    resetFocus: function() {
+      this.$.messageInput.textarea.focus();
+    },
+
     _handleConfirmTap: function(e) {
       e.preventDefault();
       this.fire('confirm', {reason: this.message}, {bubbles: false});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index b21575b..ebc6533 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -35,6 +35,11 @@
       iron-autogrow-textarea {
         padding: 0;
       }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
       .main label,
       .main input[type="text"] {
         display: block;
@@ -66,6 +71,9 @@
         <iron-autogrow-textarea
             id="messageInput"
             class="message"
+            autocomplete="on"
+            rows="4"
+            max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index 97342d1..716e29c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -31,17 +31,23 @@
 
     properties: {
       branch: String,
+      changeStatus: String,
+      commitMessage: String,
+      commitNum: String,
       message: String,
-      commitInfo: {
-        type: Object,
-        readOnly: true,
-        observer: '_commitInfoChanged',
-      },
     },
 
-    _commitInfoChanged: function(commitInfo) {
-      // Pre-populate cherry-pick message for editing from commit info.
-      this.message = commitInfo.message;
+    observers: [
+      '_computeMessage(changeStatus, commitNum, commitMessage)',
+    ],
+
+    _computeMessage: function(changeStatus, commitNum, commitMessage) {
+      var newMessage = commitMessage;
+
+      if (changeStatus === 'MERGED') {
+        newMessage += '(cherry picked from commit ' + commitNum + ')';
+      }
+      this.message = newMessage;
     },
 
     _handleConfirmTap: function(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
new file mode 100644
index 0000000..3c1cf2b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -0,0 +1,75 @@
+<!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-confirm-cherrypick-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-confirm-cherrypick-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-cherrypick-dialog></gr-confirm-cherrypick-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-cherrypick-dialog tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('with merged change', function() {
+      element.changeStatus = 'MERGED';
+      element.commitMessage = 'message\n';
+      element.commitNum = '123';
+      element.branch = 'master';
+      flushAsynchronousOperations();
+      var expectedMessage = 'message\n(cherry picked from commit 123)';
+      assert.equal(element.message, expectedMessage);
+    });
+
+    test('with unmerged change', function() {
+      element.changeStatus = 'OPEN';
+      element.commitMessage = 'message\n';
+      element.commitNum = '123';
+      element.branch = 'master';
+      flushAsynchronousOperations();
+      var expectedMessage = 'message\n';
+      assert.equal(element.message, expectedMessage);
+    });
+
+    test('with updated commit message', function() {
+      element.changeStatus = 'OPEN';
+      element.commitMessage = 'message\n';
+      element.commitNum = '123';
+      element.branch = 'master';
+      var myNewMessage = 'updated commit message';
+      element.message = myNewMessage;
+      flushAsynchronousOperations();
+      var expectedMessage = 'message\n';
+      assert.equal(element.message, myNewMessage);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
index 3896ffa..b27e6ba 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -31,6 +31,9 @@
       label {
         cursor: pointer;
       }
+      .message {
+        font-style: italic;
+      }
       .parentRevisionContainer label,
       .parentRevisionContainer input[type="text"] {
         display: block;
@@ -40,7 +43,7 @@
       .parentRevisionContainer label {
         margin-bottom: .2em;
       }
-      .clearParentContainer {
+      .rebaseOption {
         margin: .5em 0;
       }
     </style>
@@ -50,24 +53,56 @@
         on-cancel="_handleCancelTap">
       <div class="header">Confirm rebase</div>
       <div class="main">
-        <div class="parentRevisionContainer">
-          <label for="parentInput">
-            Parent revision (optional)
+        <div id="rebaseOnParent" class="rebaseOption"
+            hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]">
+          <input id="rebaseOnParentInput"
+              name="rebaseOptions"
+              type="radio"
+              on-tap="_handleRebaseOnParent">
+          <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
+            Rebase on parent change
           </label>
+        </div>
+        <div id="parentUpToDateMsg" class="message"
+            hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]">
+          This change is up to date with its parent.
+        </div>
+        <div id="rebaseOnTip" class="rebaseOption"
+            hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]">
+          <input id="rebaseOnTipInput"
+              name="rebaseOptions"
+              type="radio"
+              disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
+              on-tap="_handleRebaseOnTip">
+          <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
+            Rebase on top of the [[branch]] branch<span hidden="[[!hasParent]]">
+              (breaks relation chain)
+            </span>
+          </label>
+        </div>
+        <div id="tipUpToDateMsg" class="message"
+            hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]">
+          Change is up to date with the target branch already ([[branch]])
+        </div>
+        <div id="rebaseOnOther" class="rebaseOption">
+          <input id="rebaseOnOtherInput"
+              name="rebaseOptions"
+              type="radio"
+              on-tap="_handleRebaseOnOther">
+          <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
+            Rebase on a specific change or ref <span hidden="[[!hasParent]]">
+              (breaks relation chain)
+            </span>
+          </label>
+        </div>
+        <div class="parentRevisionContainer">
           <input is="iron-input"
               type="text"
               id="parentInput"
               bind-value="{{base}}"
+              on-tap="_handleEnterChangeNumberTap"
               placeholder="Change number">
         </div>
-        <div class="clearParentContainer">
-          <input id="clearParent"
-              type="checkbox"
-              on-tap="_handleClearParentTap">
-          <label for="clearParent">
-            Rebase on top of current branch (clear parent revision).
-          </label>
-        </div>
       </div>
     </gr-confirm-dialog>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
index 42f2167..4ecb31f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -31,7 +31,25 @@
 
     properties: {
       base: String,
-      clearParent: Boolean,
+      branch: String,
+      hasParent: Boolean,
+      rebaseOnCurrent: Boolean,
+    },
+
+    observers: [
+      '_updateSelectedOption(rebaseOnCurrent, hasParent)',
+    ],
+
+    _displayParentOption: function(rebaseOnCurrent, hasParent) {
+      return hasParent && rebaseOnCurrent;
+    },
+
+    _displayParentUpToDateMsg: function(rebaseOnCurrent, hasParent) {
+      return hasParent && !rebaseOnCurrent;
+    },
+
+    _displayTipOption: function(rebaseOnCurrent, hasParent) {
+      return !(!rebaseOnCurrent && !hasParent);
     },
 
     _handleConfirmTap: function(e) {
@@ -44,13 +62,44 @@
       this.fire('cancel', null, {bubbles: false});
     },
 
-    _handleClearParentTap: function(e) {
-      var clear = Polymer.dom(e).rootTarget.checked;
-      if (clear) {
-        this.base = '';
+    _handleRebaseOnOther: function(e) {
+      this.$.parentInput.focus();
+    },
+
+    /**
+     * There is a subtle but important difference between setting the base to an
+     * empty string and omitting it entirely from the payload. An empty string
+     * implies that the parent should be cleared and the change should be
+     * rebased on top of the target branch. Leaving out the base implies that it
+     * should be rebased on top of its current parent.
+     */
+    _handleRebaseOnTip: function(e) {
+      this.base = '';
+    },
+
+    _handleRebaseOnParent: function(e) {
+      this.base = null;
+    },
+
+    _handleEnterChangeNumberTap: function(e) {
+      this.$.rebaseOnOtherInput.checked = true;
+    },
+
+    /**
+     * Sets the default radio button based on the state of the app and
+     * the corresponding value to be submitted.
+     */
+    _updateSelectedOption: function(rebaseOnCurrent, hasParent) {
+      if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
+        this.$.rebaseOnParentInput.checked = true;
+        this._handleRebaseOnParent();
+      } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
+        this.$.rebaseOnTipInput.checked = true;
+        this._handleRebaseOnTip();
+      } else {
+        this.$.rebaseOnOtherInput.checked = true;
+        this._handleRebaseOnOther();
       }
-      this.$.parentInput.disabled = clear;
-      this.clearParent = clear;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
index c02e11e..37eb812 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-rebase-dialog</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-confirm-rebase-dialog.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>
@@ -38,14 +40,48 @@
       element = fixture('basic');
     });
 
-    test('controls', function() {
-      assert.isFalse(element.$.parentInput.hasAttribute('disabled'));
-      assert.isFalse(element.$.clearParent.checked);
-      element.base = 'something great';
-      MockInteractions.tap(element.$.clearParent);
-      assert.isTrue(element.$.parentInput.hasAttribute('disabled'));
-      assert.isTrue(element.$.clearParent.checked);
-      assert.equal(element.base, '');
+    test('controls with parent and rebase on current available', function() {
+      element.rebaseOnCurrent = true;
+      element.hasParent = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.rebaseOnParentInput.checked);
+      assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
+      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    });
+
+    test('controls with parent rebase on current not available', function() {
+      element.rebaseOnCurrent = false;
+      element.hasParent = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.rebaseOnTipInput.checked);
+      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+      assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    });
+
+    test('controls without parent and rebase on current available', function() {
+      element.rebaseOnCurrent = true;
+      element.hasParent = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.rebaseOnTipInput.checked);
+      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+      assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+      assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    });
+
+    test('controls without parent rebase on current not available', function() {
+      element.rebaseOnCurrent = false;
+      element.hasParent = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.rebaseOnOtherInput.checked);
+      assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+      assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+      assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
+      assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index 979a06a..a38811f8 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -56,6 +56,8 @@
         <iron-autogrow-textarea
             id="messageInput"
             class="message"
+            autocomplete="on"
+            max-rows="15"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index b4baa26..8f621f0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -30,28 +30,22 @@
      */
 
     properties: {
-      branch: String,
       message: String,
-      commitInfo: Object,
     },
 
-    populateRevertMessage: function() {
+    populateRevertMessage: function(message, commitHash) {
       // Figure out what the revert title should be.
-      var originalTitle = this.commitInfo.message.split('\n')[0];
-      var revertTitle = 'Revert of ' + originalTitle;
-      if (originalTitle.startsWith('Revert of ')) {
-        revertTitle = 'Reland of ' +
-                      originalTitle.substring('Revert of '.length);
-      } else if (originalTitle.startsWith('Reland of ')) {
-        revertTitle = 'Revert of ' +
-                      originalTitle.substring('Reland of '.length);
+      var originalTitle = message.split('\n')[0];
+      var revertTitle = 'Revert "' + originalTitle + '"';
+      if (!commitHash) {
+        alert('Unable to find the commit hash of this change.');
+        return;
       }
-      // Add '> ' in front of the original commit text.
-      var originalCommitText = this.commitInfo.message.replace(/^/gm, '> ');
+      var revertCommitText = 'This reverts commit ' + commitHash + '.';
 
       this.message = revertTitle + '\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original issue\'s description:\n' + originalCommitText;
+                     revertCommitText + '\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
     },
 
     _handleConfirmTap: function(e) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
index 1d53eef..d5c459b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-revert-dialog</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-confirm-revert-dialog.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-confirm-revert-dialog></gr-confirm-revert-dialog>
@@ -38,27 +40,55 @@
       element = fixture('basic');
     });
 
+    test('no match', function() {
+      assert.isNotOk(element.message);
+      var alertStub = sinon.stub(window, 'alert');
+      element.populateRevertMessage('not a commitHash in sight', undefined);
+      assert.isTrue(alertStub.calledOnce);
+      alertStub.restore();
+    });
+
     test('single line', function() {
       assert.isNotOk(element.message);
-      element.commitInfo = {message: 'one line commit'};
-      assert.isNotOk(element.message);
-      element.populateRevertMessage();
-      var expected = 'Revert of one line commit\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original issue\'s description:\n' +
-                     '> one line commit';
+      element.populateRevertMessage(
+          'one line commit\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      var expected = 'Revert "one line commit"\n\n' +
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
     test('multi line', function() {
       assert.isNotOk(element.message);
-      element.commitInfo = {message: 'many lines\ncommit\n\nmessage\n'};
+      element.populateRevertMessage(
+          'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      var expected = 'Revert "many lines"\n\n' +
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('issue above change id', function() {
       assert.isNotOk(element.message);
-      element.populateRevertMessage();
-      var expected = 'Revert of many lines\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original issue\'s description:\n' +
-                     '> many lines\n> commit\n> \n> message\n> ';
+      element.populateRevertMessage(
+          'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+          'abcd123');
+      var expected = 'Revert "much lines"\n\n' +
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('revert a revert', function() {
+      assert.isNotOk(element.message);
+      element.populateRevertMessage(
+          'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      var expected = 'Revert "Revert "one line commit""\n\n' +
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index b1e5c01..0e97d36 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -14,9 +14,9 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
@@ -49,8 +49,6 @@
       input {
         font-family: var(--monospace-font-family);
         font-size: inherit;
-        margin-bottom: .5em;
-        width: 60em;
       }
       li[selected] gr-button {
         color: #000;
@@ -71,6 +69,19 @@
         justify-content: space-between;
         padding-top: .75em;
       }
+      .command {
+        display: flex;
+        flex-wrap: wrap;
+        margin-bottom: .5em;
+        width: 60em;
+      }
+      .command label {
+        flex: 0 0 100%;
+      }
+      .copyCommand {
+        flex-grow: 1;
+        margin-right: .3em;
+      }
       .closeButtonContainer {
         display: flex;
         flex: 1;
@@ -100,7 +111,9 @@
         </template>
       </ul>
       <span class="closeButtonContainer">
-        <gr-button link on-tap="_handleCloseTap">Close</gr-button>
+        <gr-button id="closeButton"
+            link
+            on-tap="_handleCloseTap">Close</gr-button>
       </span>
     </header>
     <main hidden$="[[!_schemes.length]]" hidden>
@@ -110,10 +123,14 @@
         <div class="command">
           <label>[[command.title]]</label>
           <input is="iron-input"
+              class="copyCommand"
               type="text"
               bind-value="[[command.command]]"
               on-tap="_handleInputTap"
               readonly>
+          <gr-button class="copyToClipboard" on-tap="_copyToClipboard">
+            copy
+          </gr-button>
         </div>
       </template>
     </main>
@@ -121,7 +138,7 @@
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
-          <a href$="[[_computeDownloadLink(change, patchNum)]]">
+          <a id="download" href$="[[_computeDownloadLink(change, patchNum)]]">
             [[_computeDownloadFilename(change, patchNum)]]
           </a>
           <a href$="[[_computeZipDownloadLink(change, patchNum)]]">
@@ -131,7 +148,7 @@
       </div>
       <div class="archivesContainer" hidden$="[[!config.archives.length]]" hidden>
         <label>Archive</label>
-        <div class="archives">
+        <div id="archives" class="archives">
           <template is="dom-repeat" items="[[config.archives]]" as="format">
             <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]">
               [[format]]
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 2f3e8e1..41f6792 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -30,6 +30,7 @@
       loggedIn: {
         type: Boolean,
         value: false,
+        observer: '_loggedInChanged',
       },
 
       _schemes: {
@@ -49,11 +50,28 @@
       Gerrit.RESTClientBehavior,
     ],
 
-    attached: function() {
-      if (!this.loggedIn) { return; }
+    focus: function() {
+      if (this._schemes.length) {
+        this.$$('.copyToClipboard').focus();
+      } else {
+        this.$.download.focus();
+      }
+    },
+
+    getFocusStops: function() {
+      var links = this.$$('#archives').querySelectorAll('a');
+      return {
+        start: this.$.closeButton,
+        end: links[links.length - 1],
+      };
+    },
+
+    _loggedInChanged: function(loggedIn) {
+      if (!loggedIn) { return; }
       this.$.restAPI.getPreferences().then(function(prefs) {
         if (prefs.download_scheme) {
-          this._selectedScheme = prefs.download_scheme;
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this._selectedScheme = prefs.download_scheme.toLowerCase();
         }
       }.bind(this));
     },
@@ -61,7 +79,8 @@
     _computeDownloadCommands: function(change, patchNum, _selectedScheme) {
       var commandObj;
       for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum) {
+        if (change.revisions[rev]._number == patchNum &&
+            change.revisions[rev].fetch.hasOwnProperty(_selectedScheme)) {
           commandObj = change.revisions[rev].fetch[_selectedScheme].commands;
           break;
         }
@@ -147,5 +166,13 @@
         this._selectedScheme = schemes.sort()[0];
       }
     },
+
+    _copyToClipboard: function(e) {
+      e.target.parentElement.querySelector('.copyCommand').select();
+      document.execCommand('copy');
+      getSelection().removeAllRanges();
+      e.target.textContent = 'done';
+      setTimeout(function() { e.target.textContent = 'copy'; }, 1000);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 70e934d..0635d6d 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -24,6 +24,8 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-download-dialog.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-download-dialog></gr-download-dialog>
@@ -97,6 +99,45 @@
     };
   }
 
+  function getChangeObjectNoFetch() {
+    return {
+      current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+      revisions: {
+        '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+          _number: 1,
+          fetch: {},
+        }
+      }
+    };
+  }
+
+  suite('gr-download-dialog tests with no fetch options', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+      element.change = getChangeObjectNoFetch();
+      element.patchNum = 1;
+      element.config = {
+        schemes: {
+          'anonymous http': {},
+          http: {},
+          repo: {},
+          ssh: {},
+        },
+        archives: ['tgz', 'tar', 'tbz2', 'txz'],
+      };
+    });
+
+    test('focuses on first download link if no copy links', function() {
+      flushAsynchronousOperations();
+      var focusStub = sinon.stub(element.$.download, 'focus');
+      element.focus();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+  });
+
   suite('gr-download-dialog tests', function() {
     var element;
 
@@ -115,6 +156,23 @@
       };
     });
 
+    test('focuses on first copy link', function() {
+      flushAsynchronousOperations();
+      var focusStub = sinon.stub(element.$$('.copyToClipboard'), 'focus');
+      element.focus();
+      flushAsynchronousOperations();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+
+    test('copy to clipboard', function() {
+      flushAsynchronousOperations();
+      var clipboardSpy = sinon.spy(element, '_copyToClipboard');
+      var copyBtn = element.$$('.copyToClipboard');
+      MockInteractions.tap(copyBtn);
+      assert.isTrue(clipboardSpy.called);
+    });
+
     test('element visibility', function() {
       assert.isFalse(element.$$('ul').hasAttribute('hidden'));
       assert.isFalse(element.$$('main').hasAttribute('hidden'));
@@ -155,6 +213,21 @@
       });
     });
 
+    test('loads scheme from preferences w/o initial login', function(done) {
+      stub('gr-rest-api-interface', {
+        getPreferences: function() {
+          return Promise.resolve({download_scheme: 'repo'});
+        },
+      });
+
+      element.loggedIn = true;
+
+      assert.isTrue(element.$.restAPI.getPreferences.called);
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
+        assert.equal(element._selectedScheme, 'repo');
+        done();
+      });
+    });
   });
 
   suite('gr-download-dialog tests', function() {
@@ -203,4 +276,23 @@
           firstSchemeButton.getAttribute('data-scheme'));
     });
   });
+
+  test('normalize scheme from preferences', function(done) {
+    stub('gr-rest-api-interface', {
+      getPreferences: function() {
+        return Promise.resolve({download_scheme: 'REPO'});
+      },
+    });
+    element = fixture('loggedIn');
+    element.change = getChangeObject();
+    element.patchNum = 1;
+    element.config = {
+      schemes: {'anonymous http': {}, http: {}, repo: {}, ssh: {}},
+      archives: ['tgz', 'tar', 'tbz2', 'txz'],
+    };
+    element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
+      assert.equal(element._selectedScheme, 'repo');
+      done();
+    });
+  });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index ef5ceed..e324078 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
@@ -14,12 +14,19 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.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-cursor-manager/gr-cursor-manager.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
 
 <dom-module id="gr-file-list">
   <template>
@@ -38,7 +45,13 @@
         margin-bottom: .5em;
       }
       .rightControls {
+        display: flex;
+        flex-wrap: wrap;
         font-weight: normal;
+        justify-content: flex-end;
+      }
+      .separator {
+        margin: 0 .25em;
       }
       .reviewed,
       .status {
@@ -51,10 +64,10 @@
         text-align: center;
         width: 1.5em;
       }
-      .row:not(.header):hover {
+      .file-row:hover {
         background-color: #f5fafd;
       }
-      .row[selected] {
+      .row.selected {
         background-color: #ebf5fb;
       }
       .path {
@@ -87,7 +100,8 @@
       .invisible {
         visibility: hidden;
       }
-      .row:not(.header) .stats {
+      .row:not(.header) .stats,
+      .total-stats {
         font-family: var(--monospace-font-family);
       }
       .added {
@@ -100,13 +114,60 @@
         color: #C62828;
         font-weight: bold;
       }
+      .show-hide {
+        margin-left: .4em;
+      }
+      .fileListButton {
+        margin: .5em;
+      }
+      .totalChanges {
+        justify-content: flex-end;
+        padding-right: 2.6em;
+        text-align: right;
+      }
+      .expandInline {
+        padding-right: .25em;
+      }
+      .warning {
+        color: #666;
+      }
+      input.show-hide {
+        display: none;
+      }
+      label.show-hide {
+        color: #00f;
+        cursor: pointer;
+        display: block;
+        font-size: .8em;
+        min-width: 2em;
+        margin-top: .1em;
+      }
       gr-diff {
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         display: block;
         margin: .25em 0 1em;
       }
+      .patchSetSelect {
+        max-width: 8em;
+      }
+      .truncatedFileName {
+        display: none;
+      }
+      .expanded .fullFileName {
+        white-space: normal;
+        word-wrap: break-word;
+      }
+      .mobile {
+        display: none;
+      }
       @media screen and (max-width: 50em) {
-        .row[selected] {
+        .desktop {
+          display: none;
+        }
+        .mobile {
+          display: block;
+        }
+        .row.selected {
           background-color: transparent;
         }
         .stats {
@@ -119,57 +180,139 @@
         .comments {
           min-width: initial;
         }
+        .expanded .fullFileName,
+        .truncatedFileName {
+          display: block;
+        }
+        .expanded .truncatedFileName,
+        .fullFileName {
+          display: none;
+        }
       }
     </style>
     <header>
       <div>Files</div>
       <div class="rightControls">
-        <gr-button link on-tap="_expandAllDiffs">Show diffs</gr-button>
-        /
-        <gr-button link on-tap="_collapseAllDiffs">Hide diffs</gr-button>
-        /
+        <template is="dom-if"
+            if="[[_fileListActionsVisible(_shownFiles.*, _maxFilesForBulkActions)]]">
+          <gr-button link on-tap="_expandAllDiffs">Show diffs</gr-button>
+          <span class="separator">/</span>
+          <gr-button link on-tap="_collapseAllDiffs">Hide diffs</gr-button>
+        </template>
+        <template is="dom-if"
+            if="[[!_fileListActionsVisible(_shownFiles.*, _maxFilesForBulkActions)]]">
+          <div class="warning">
+            Bulk actions disabled because there are too many files.
+          </div>
+        </template>
+        <span class="separator">/</span>
+        <select
+            id="modeSelect"
+            is="gr-select"
+            bind-value="{{diffViewMode}}">
+          <option value="SIDE_BY_SIDE">Side By Side</option>
+          <option value="UNIFIED_DIFF">Unified</option>
+        </select>
+        <span class="separator">/</span>
         <label>
           Diff against
-          <select on-change="_handlePatchChange">
+          <select id="patchChange" bind-value="{{_diffAgainst}}" is="gr-select"
+              class="patchSetSelect" on-change="_handlePatchChange">
             <option value="PARENT">Base</option>
-            <template is="dom-repeat" items="[[_computePatchSets(revisions, patchRange.*)]]" as="patchNum">
-              <option
-                  value$="[[patchNum]]"
-                  selected$="[[_computePatchSetSelected(patchNum, patchRange.basePatchNum)]]"
-                  disabled$="[[_computePatchSetDisabled(patchNum, patchRange.patchNum)]]">[[patchNum]]</option>
+            <template
+                is="dom-repeat"
+                items="[[_computePatchSets(revisions.*, patchRange.*)]]"
+                as="patchNum">
+              <option value$="[[patchNum.num]]"
+                  disabled$="[[_computePatchSetDisabled(patchNum.num, patchRange.patchNum)]]">
+                [[patchNum.num]]
+                [[patchNum.desc]]
+              </option>
             </template>
           </select>
         </label>
       </div>
     </header>
-    <template is="dom-repeat" items="[[_files]]" as="file">
-      <div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
+    <template is="dom-repeat"
+        items="[[_shownFiles]]"
+        as="file"
+        initial-count="[[_fileListIncrement]]">
+      <div class="file-row row">
         <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
-          <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]"
-              data-path$="[[file.__path]]" on-change="_handleReviewedChange">
+          <input type="checkbox" checked="[[file.isReviewed]]"
+              data-path$="[[file.__path]]" on-change="_handleReviewedChange"
+              class="reviewed" aria-label="Reviewed checkbox">
         </div>
-        <div class$="[[_computeClass('status', file.__path)]]">
+        <div class$="[[_computeClass('status', file.__path)]]"
+            tabindex="0"
+            aria-label$="[[_computeFileStatusLabel(file.status)]]">
           [[_computeFileStatus(file.status)]]
         </div>
-        <a class="path" href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]">
-          <div title$="[[_computeFileDisplayName(file.__path)]]">
+        <a class$="[[_computePathClass(file.__path, _expandedFilePaths.*)]]"
+            href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]"
+            on-tap="_handleFileTap">
+          <div title$="[[_computeFileDisplayName(file.__path)]]"
+              class="fullFileName">
             [[_computeFileDisplayName(file.__path)]]
           </div>
+          <div title$="[[_computeFileDisplayName(file.__path)]]"
+              class="truncatedFileName">
+            [[_computeTruncatedFileDisplayName(file.__path)]]
+          </div>
           <div class="oldPath" hidden$="[[!file.old_path]]" hidden
               title$="[[file.old_path]]">
             [[file.old_path]]
           </div>
         </a>
-        <div class="comments">
-          <span class="drafts">[[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]</span>
+        <div class="comments desktop">
+          <span class="drafts">
+            [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
+          </span>
           [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
+          [[_computeUnresolvedString(comments, drafts, patchRange.patchNum, file.__path)]]
+        </div>
+        <div class="comments mobile">
+          <span class="drafts">
+            [[_computeDraftsStringMobile(drafts, patchRange.patchNum,
+                file.__path)]]
+          </span>
+          [[_computeCommentsStringMobile(comments, patchRange.patchNum,
+              file.__path)]]
         </div>
         <div class$="[[_computeClass('stats', file.__path)]]">
-          <span class="added">+[[file.lines_inserted]]</span>
-          <span class="removed">-[[file.lines_deleted]]</span>
+          <span
+              class="added"
+              tabindex="0"
+              aria-label$="[[file.lines_inserted]] lines added"
+              hidden$=[[file.binary]]>
+            +[[file.lines_inserted]]
+          </span>
+          <span
+              class="removed"
+              tabindex="0"
+              aria-label$="[[file.lines_deleted]] lines removed"
+              hidden$=[[file.binary]]>
+            -[[file.lines_deleted]]
+          </span>
+          <span class$="[[_computeBinaryClass(file.size_delta)]]"
+              hidden$=[[!file.binary]]>
+            [[_formatBytes(file.size_delta)]]
+            [[_formatPercentage(file.size, file.size_delta)]]
+          </span>
+        </div>
+        <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
+          <label class="show-hide">
+            <input type="checkbox" class="show-hide"
+                checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+                data-path$="[[file.__path]]"
+                on-change="_handleHiddenChange">
+            [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
+          </label>
         </div>
       </div>
-      <gr-diff hidden
+      <gr-diff
+          no-auto-render
+          hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
           project="[[change.project]]"
           commit="[[change.current_revision]]"
           change-num="[[changeNum]]"
@@ -177,13 +320,60 @@
           path="[[file.__path]]"
           prefs="[[_diffPrefs]]"
           project-config="[[projectConfig]]"
-          view-mode="[[_userPrefs.diff_view]]"></gr-diff>
+          view-mode="[[_getDiffViewMode(diffViewMode, _userPrefs)]]"></gr-diff>
     </template>
+    <div class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]">
+      <div class="total-stats" hidden$="[[_hideChangeTotals]]">
+        <span
+            class="added"
+            tabindex="0"
+            aria-label$="[[_patchChange.inserted]] lines added">
+          +[[_patchChange.inserted]]
+        </span>
+        <span
+            class="removed"
+            tabindex="0"
+            aria-label$="[[_patchChange.deleted]] lines removed">
+          -[[_patchChange.deleted]]
+        </span>
+      </div>
+    </div>
+    <div class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]">
+      <div class="total-stats" hidden$="[[_hideBinaryChangeTotals]]">
+        <span class="added" aria-label="Total lines added">
+          [[_formatBytes(_patchChange.size_delta_inserted)]]
+          [[_formatPercentage(_patchChange.total_size,
+              _patchChange.size_delta_inserted)]]
+        </span>
+        <span class="removed" aria-label="Total lines removed">
+          [[_formatBytes(_patchChange.size_delta_deleted)]]
+          [[_formatPercentage(_patchChange.total_size,
+              _patchChange.size_delta_deleted)]]
+        </span>
+      </div>
+    </div>
+    <gr-button
+        class="fileListButton"
+        id="incrementButton"
+        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
+        link on-tap="_incrementNumFilesShown">
+      [[_computeIncrementText(_numFilesShown, _files)]]
+    </gr-button>
+    <gr-button
+        class="fileListButton"
+        id="showAllButton"
+        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
+        link on-tap="_showAllFiles">
+      [[_computeShowAllText(_files)]]
+    </gr-button>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
-    <gr-diff-cursor
-        id="cursor"
-        fold-offset-top="[[topMargin]]"></gr-diff-cursor>
+    <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
+    <gr-cursor-manager
+        id="fileCursor"
+        scroll-behavior="keep-visible"
+        cursor-target-class="selected"></gr-cursor-manager>
+    <gr-reporting id="reporting"></gr-reporting>
   </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 225d8b3..e91c28dd 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
@@ -14,20 +14,34 @@
 (function() {
   'use strict';
 
+  // Maximum length for patch set descriptions.
+  var PATCH_DESC_MAX_LENGTH = 500;
+
   var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+  var MERGE_LIST_PATH = '/MERGE_LIST';
+
+  var FileStatus = {
+    A: 'Added',
+    C: 'Copied',
+    D: 'Deleted',
+    R: 'Renamed',
+    W: 'Rewritten',
+  };
 
   Polymer({
     is: 'gr-file-list',
 
     properties: {
-      patchRange: Object,
+      patchRange: {
+        type: Object,
+        observer: '_updateSelected',
+      },
       patchNum: String,
       changeNum: String,
       comments: Object,
       drafts: Object,
       revisions: Object,
       projectConfig: Object,
-      topMargin: Number,
       selectedIndex: {
         type: Number,
         notify: true,
@@ -37,10 +51,14 @@
         value: function() { return document.body; },
       },
       change: Object,
-
+      diffViewMode: {
+        type: String,
+        notify: true,
+      },
       _files: {
         type: Array,
         observer: '_filesChanged',
+        value: function() { return []; },
       },
       _loggedIn: {
         type: Boolean,
@@ -50,23 +68,82 @@
         type: Array,
         value: function() { return []; },
       },
+      _diffAgainst: String,
       _diffPrefs: Object,
       _userPrefs: Object,
       _localPrefs: Object,
       _showInlineDiffs: Boolean,
+      _numFilesShown: {
+        type: Number,
+        value: 75,
+      },
+      _patchChange: {
+        type: Object,
+        computed: '_calculatePatchChange(_files)',
+      },
+      _fileListIncrement: {
+        type: Number,
+        readOnly: true,
+        value: 75,
+      },
+      _hideChangeTotals: {
+        type: Boolean,
+        computed: '_shouldHideChangeTotals(_patchChange)',
+      },
+      _hideBinaryChangeTotals: {
+        type: Boolean,
+        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+      },
+      _shownFiles: {
+        type: Array,
+        computed: '_computeFilesShown(_numFilesShown, _files.*)',
+      },
+      // Caps the number of files that can be shown and have the 'show diffs' /
+      // 'hide diffs' buttons still be functional.
+      _maxFilesForBulkActions: {
+        type: Number,
+        readOnly: true,
+        value: 225,
+      },
+      _expandedFilePaths: {
+        type: Array,
+        value: function() { return []; },
+      },
     },
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
+    observers: [
+      '_expandedPathsChanged(_expandedFilePaths.splices)',
+      '_setReviewedFiles(_shownFiles, _files, _reviewed.*, _loggedIn)',
+    ],
+
+    keyBindings: {
+      'shift+left': '_handleShiftLeftKey',
+      'shift+right': '_handleShiftRightKey',
+      'i': '_handleIKey',
+      'shift+i': '_handleCapitalIKey',
+      'down j': '_handleDownKey',
+      'up k': '_handleUpKey',
+      'c': '_handleCKey',
+      '[': '_handleLeftBracketKey',
+      ']': '_handleRightBracketKey',
+      'o enter': '_handleEnterKey',
+      'n': '_handleNKey',
+      'p': '_handlePKey',
+      'shift+a': '_handleCapitalAKey',
+    },
+
     reload: function() {
       if (!this.changeNum || !this.patchRange.patchNum) {
         return Promise.resolve();
       }
-
       this._collapseAllDiffs();
-
       var promises = [];
       var _this = this;
 
@@ -90,6 +167,9 @@
 
       promises.push(this._getPreferences().then(function(prefs) {
         this._userPrefs = prefs;
+        if (!this.diffViewMode) {
+          this.set('diffViewMode', prefs.default_diff_view);
+        }
       }.bind(this)));
     },
 
@@ -97,6 +177,31 @@
       return Polymer.dom(this.root).querySelectorAll('gr-diff');
     },
 
+    _calculatePatchChange: function(files) {
+      var filesNoCommitMsg = files.filter(function(files) {
+        return files.__path !== '/COMMIT_MSG';
+      });
+
+      return filesNoCommitMsg.reduce(function(acc, obj) {
+        var inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+        var deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+        var total_size = (obj.size && obj.binary) ? obj.size : 0;
+        var size_delta_inserted =
+            obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+        var size_delta_deleted =
+            obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+
+        return {
+          inserted: acc.inserted + inserted,
+          deleted: acc.deleted + deleted,
+          size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+          size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+          total_size: acc.total_size + total_size,
+        };
+      }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
+        size_delta_deleted: 0, total_size: 0});
+    },
+
     _getDiffPreferences: function() {
       return this.$.restAPI.getDiffPreferences();
     },
@@ -105,26 +210,48 @@
       return this.$.restAPI.getPreferences();
     },
 
-    _computePatchSets: function(revisions) {
+    _computePatchSets: function(revisionRecord) {
+      var revisions = revisionRecord.base;
       var patchNums = [];
       for (var commit in revisions) {
-        patchNums.push(revisions[commit]._number);
+        if (revisions.hasOwnProperty(commit)) {
+          patchNums.push({
+            num: revisions[commit]._number,
+            desc: revisions[commit].description,
+          });
+        }
       }
-      return patchNums.sort(function(a, b) { return a - b; });
+      return patchNums.sort(function(a, b) { return a.num - b.num; });
     },
 
     _computePatchSetDisabled: function(patchNum, currentPatchNum) {
       return parseInt(patchNum, 10) >= parseInt(currentPatchNum, 10);
     },
 
-    _computePatchSetSelected: function(patchNum, basePatchNum) {
-      return parseInt(patchNum, 10) === parseInt(basePatchNum, 10);
+    _handleHiddenChange: function(e) {
+      this._togglePathExpanded(e.model.file.__path);
+    },
+
+    _togglePathExpanded: function(path) {
+      // Is the path in the list of expanded diffs? IF so remove it, otherwise
+      // add it to the list.
+      var pathIndex = this._expandedFilePaths.indexOf(path);
+      if (pathIndex === -1) {
+        this.push('_expandedFilePaths', path);
+      } else {
+        this.splice('_expandedFilePaths', pathIndex, 1);
+      }
+    },
+
+    _togglePathExpandedByIndex: function(index) {
+      this._togglePathExpanded(this._files[index].__path);
     },
 
     _handlePatchChange: function(e) {
-      this.set('patchRange.basePatchNum', Polymer.dom(e).rootTarget.value);
-      page.show('/c/' + encodeURIComponent(this.changeNum) + '/' +
-          encodeURIComponent(this._patchRangeStr(this.patchRange)));
+      var patchRange = Object.assign({}, this.patchRange);
+      patchRange.basePatchNum = Polymer.dom(e).rootTarget.value;
+      page.show(this.encodeURL('/c/' + this.changeNum + '/' +
+          this._patchRangeStr(patchRange), true));
     },
 
     _forEachDiff: function(fn) {
@@ -136,24 +263,25 @@
 
     _expandAllDiffs: function(e) {
       this._showInlineDiffs = true;
-      this._forEachDiff(function(diff) {
-        diff.hidden = false;
-        diff.reload();
-      });
-      if (e && e.target) {
-        e.target.blur();
+
+      // Find the list of paths that are in the file list, but not in the
+      // expanded list.
+      var newPaths = [];
+      var path;
+      for (var i = 0; i < this._shownFiles.length; i++) {
+        path = this._shownFiles[i].__path;
+        if (this._expandedFilePaths.indexOf(path) === -1) {
+          newPaths.push(path);
+        }
       }
+
+      this.splice.apply(this, ['_expandedFilePaths', 0, 0].concat(newPaths));
     },
 
     _collapseAllDiffs: function(e) {
       this._showInlineDiffs = false;
-      this._forEachDiff(function(diff) {
-        diff.hidden = true;
-      });
-      this.$.cursor.handleDiffUpdate();
-      if (e && e.target) {
-        e.target.blur();
-      }
+      this._expandedFilePaths = [];
+      this.$.diffCursor.handleDiffUpdate();
     },
 
     _computeCommentsString: function(comments, patchNum, path) {
@@ -164,15 +292,70 @@
       return this._computeCountString(drafts, patchNum, path, 'draft');
     },
 
-    _computeCountString: function(comments, patchNum, path, noun) {
-      if (!comments) { return ''; }
+    _computeDraftsStringMobile: function(drafts, patchNum, path) {
+      var draftCount = this._computeCountString(drafts, patchNum, path);
+      return draftCount ? draftCount + 'd' : '';
+    },
 
-      var patchComments = (comments[path] || []).filter(function(c) {
+    _computeCommentsStringMobile: function(comments, patchNum, path) {
+      var commentCount = this._computeCountString(comments, patchNum, path);
+      return commentCount ? commentCount + 'c' : '';
+    },
+
+    _getCommentsForPath: function(comments, patchNum, path) {
+      return (comments[path] || []).filter(function(c) {
         return parseInt(c.patch_set, 10) === parseInt(patchNum, 10);
       });
+    },
+
+    _computeCountString: function(comments, patchNum, path, opt_noun) {
+      if (!comments) { return ''; }
+
+      var patchComments = this._getCommentsForPath(comments, patchNum, path);
       var num = patchComments.length;
       if (num === 0) { return ''; }
-      return num + ' ' + noun + (num > 1 ? 's' : '');
+      if (!opt_noun) { return num; }
+      var output = num + ' ' + opt_noun + (num > 1 ? 's' : '');
+      return output;
+    },
+
+    /**
+     * Computes a string counting the number of unresolved comment threads in a
+     * given file and path.
+     *
+     * @param {Object} comments
+     * @param {Object} drafts
+     * @param {number} patchNum
+     * @param {string} path
+     * @return {string}
+     */
+    _computeUnresolvedString: function(comments, drafts, patchNum, path) {
+      comments = this._getCommentsForPath(comments, patchNum, path);
+      drafts = this._getCommentsForPath(drafts, patchNum, path);
+      comments = comments.concat(drafts);
+
+      // Create an object where every comment ID is the key of an unresolved
+      // comment.
+
+      var idMap = comments.reduce(function(acc, comment) {
+        if (comment.unresolved) {
+          acc[comment.id] = true;
+        }
+        return acc;
+      }, {});
+
+      // Set false for the comments that are marked as parents.
+      comments.forEach(function(comment) {
+        idMap[comment.in_reply_to] = false;
+      });
+
+      // The unresolved comments are the comments that still have true.
+      var unresolvedLeaves = Object.keys(idMap).filter(function(key) {
+        return idMap[key];
+      });
+
+      return unresolvedLeaves.length === 0 ?
+          '' : '(' + unresolvedLeaves.length + ' unresolved)';
     },
 
     _computeReviewed: function(file, _reviewed) {
@@ -180,7 +363,10 @@
     },
 
     _handleReviewedChange: function(e) {
-      var path = Polymer.dom(e).rootTarget.getAttribute('data-path');
+      this._reviewFile(Polymer.dom(e).rootTarget.getAttribute('data-path'));
+    },
+
+    _reviewFile: function(path) {
       var index = this._reviewed.indexOf(path);
       var reviewed = index !== -1;
       if (reviewed) {
@@ -189,11 +375,7 @@
         this.push('_reviewed', path);
       }
 
-      this._saveReviewedState(path, !reviewed).catch(function(err) {
-        alert('Couldn’t change file review status. Check the console ' +
-            'and contact the PolyGerrit team for assistance.');
-        throw err;
-      }.bind(this));
+      this._saveReviewedState(path, !reviewed);
     },
 
     _saveReviewedState: function(path, reviewed) {
@@ -212,106 +394,157 @@
 
     _getFiles: function() {
       return this.$.restAPI.getChangeFilesAsSpeciallySortedArray(
-          this.changeNum, this.patchRange);
+          this.changeNum, this.patchRange).then(function(files) {
+            // Append UI-specific properties.
+            return files.map(function(file) {
+              return file;
+            });
+          });
     },
 
-    _handleKey: function(e) {
-      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();
-          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();
-          if (this._showInlineDiffs) {
-            this.$.cursor.moveUp();
-          } else {
-            this.selectedIndex = Math.max(0, this.selectedIndex - 1);
-            this._scrollToSelectedFile();
-          }
-          break;
-        case 67: // 'c'
-          var isRangeSelected = this.diffs.some(function(diff) {
-            return diff.isRangeSelected();
-          }, this);
-          if (this._showInlineDiffs && !isRangeSelected) {
-            e.preventDefault();
-            this._addDraftAtTarget();
-          }
-          break;
-        case 219:  // '['
-          e.preventDefault();
-          this._openSelectedFile(this._files.length - 1);
-          break;
-        case 221:  // ']'
-          e.preventDefault();
-          this._openSelectedFile(0);
-          break;
-        case 13:  // <enter>
-        case 79:  // 'o'
-          e.preventDefault();
-          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;
-        case 65:  // 'a'
-          if (e.shiftKey) { // Hide left diff.
-            e.preventDefault();
-            this._forEachDiff(function(diff) {
-              diff.toggleLeftDiff();
-            });
-          }
-          break;
+    _handleFileTap: function(e) {
+      // If the user prefers to expand inline diffs rather than opening the diff
+      // view, intercept the click event.
+      if (e.detail.sourceEvent.metaKey || e.detail.sourceEvent.ctrlKey) {
+          return;
       }
+      if (this._userPrefs && this._userPrefs.expand_inline_diffs) {
+        e.preventDefault();
+        this._handleHiddenChange(e);
+      }
+    },
+
+    _handleShiftLeftKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (!this._showInlineDiffs) { return; }
+
+      e.preventDefault();
+      this.$.diffCursor.moveLeft();
+    },
+
+    _handleShiftRightKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (!this._showInlineDiffs) { return; }
+
+      e.preventDefault();
+      this.$.diffCursor.moveRight();
+    },
+
+    _handleIKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e) ||
+          this.$.fileCursor.index === -1) { return; }
+
+      e.preventDefault();
+      this._togglePathExpandedByIndex(this.$.fileCursor.index);
+    },
+
+    _handleCapitalIKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._toggleInlineDiffs();
+    },
+
+    _handleDownKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      e.preventDefault();
+      if (this._showInlineDiffs) {
+        this.$.diffCursor.moveDown();
+      } else {
+        this.$.fileCursor.next();
+        this.selectedIndex = this.$.fileCursor.index;
+      }
+    },
+
+    _handleUpKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      if (this._showInlineDiffs) {
+        this.$.diffCursor.moveUp();
+      } else {
+        this.$.fileCursor.previous();
+        this.selectedIndex = this.$.fileCursor.index;
+      }
+    },
+
+    _handleCKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      var isRangeSelected = this.diffs.some(function(diff) {
+        return diff.isRangeSelected();
+      }, this);
+      if (this._showInlineDiffs && !isRangeSelected) {
+        e.preventDefault();
+        this._addDraftAtTarget();
+      }
+    },
+
+    _handleLeftBracketKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._openSelectedFile(this._files.length - 1);
+    },
+
+    _handleRightBracketKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._openSelectedFile(0);
+    },
+
+    _handleEnterKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      // Use native handling if an anchor is selected. @see Issue 5754
+      if (e.detail && e.detail.keyboardEvent && e.detail.keyboardEvent.target &&
+          e.detail.keyboardEvent.target.tagName === 'A') { return; }
+
+      e.preventDefault();
+      if (this._showInlineDiffs) {
+        this._openCursorFile();
+      } else {
+        this._openSelectedFile();
+      }
+    },
+
+    _handleNKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+      if (!this._showInlineDiffs) { return; }
+
+      e.preventDefault();
+      if (e.shiftKey) {
+        this.$.diffCursor.moveToNextCommentThread();
+      } else {
+        this.$.diffCursor.moveToNextChunk();
+      }
+    },
+
+    _handlePKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+      if (!this._showInlineDiffs) { return; }
+
+      e.preventDefault();
+      if (e.shiftKey) {
+        this.$.diffCursor.moveToPreviousCommentThread();
+      } else {
+        this.$.diffCursor.moveToPreviousChunk();
+      }
+    },
+
+    _handleCapitalAKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._forEachDiff(function(diff) {
+        diff.toggleLeftDiff();
+      });
     },
 
     _toggleInlineDiffs: function() {
@@ -323,45 +556,34 @@
     },
 
     _openCursorFile: function() {
-      var diff = this.$.cursor.getTargetDiffElement();
+      var diff = this.$.diffCursor.getTargetDiffElement();
       page.show(this._computeDiffURL(diff.changeNum, diff.patchRange,
           diff.path));
     },
 
     _openSelectedFile: function(opt_index) {
       if (opt_index != null) {
-        this.selectedIndex = opt_index;
+        this.$.fileCursor.setCursorAtIndex(opt_index);
       }
       page.show(this._computeDiffURL(this.changeNum, this.patchRange,
-          this._files[this.selectedIndex].__path));
+          this._files[this.$.fileCursor.index].__path));
     },
 
     _addDraftAtTarget: function() {
-      var diff = this.$.cursor.getTargetDiffElement();
-      var target = this.$.cursor.getTargetLineElement();
+      var diff = this.$.diffCursor.getTargetDiffElement();
+      var target = this.$.diffCursor.getTargetLineElement();
       if (diff && target) {
         diff.addDraftAtLine(target);
       }
     },
 
-    _scrollToSelectedFile: function() {
-      var el = this.$$('.row[selected]');
-      var top = 0;
-      for (var node = el; node; node = node.offsetParent) {
-        top += node.offsetTop;
-      }
-
-      // Don't scroll if it's already in view.
-      if (top > window.pageYOffset + this.topMargin &&
-          top < window.pageYOffset + window.innerHeight - el.clientHeight) {
-        return;
-      }
-
-      window.scrollTo(0, top - document.body.clientHeight / 2);
+    _shouldHideChangeTotals: function(_patchChange) {
+      return _patchChange.inserted === 0 && _patchChange.deleted === 0;
     },
 
-    _computeFileSelected: function(index, selectedIndex) {
-      return index === selectedIndex;
+    _shouldHideBinaryChangeTotals: function(_patchChange) {
+      return _patchChange.size_delta_inserted === 0 &&
+          _patchChange.size_delta_deleted === 0;
     },
 
     _computeFileStatus: function(status) {
@@ -369,12 +591,8 @@
     },
 
     _computeDiffURL: function(changeNum, patchRange, path) {
-      return '/c/' +
-          encodeURIComponent(changeNum) +
-          '/' +
-          encodeURIComponent(this._patchRangeStr(patchRange)) +
-          '/' +
-          path;
+      return this.encodeURL(this.getBaseUrl() + '/c/' + changeNum + '/' +
+          this._patchRangeStr(patchRange) + '/' + path, true);
     },
 
     _patchRangeStr: function(patchRange) {
@@ -384,25 +602,231 @@
     },
 
     _computeFileDisplayName: function(path) {
-      return path === COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+      if (path === COMMIT_MESSAGE_PATH) {
+        return 'Commit message';
+      } else if (path === MERGE_LIST_PATH) {
+        return 'Merge list';
+      }
+      return path;
+    },
+
+    _computeTruncatedFileDisplayName: function(path) {
+      return util.truncatePath(this._computeFileDisplayName(path));
+    },
+
+    _formatBytes: function(bytes) {
+      if (bytes == 0) return '+/-0 B';
+      var bits = 1024;
+      var decimals = 1;
+      var sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+      var exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+      var prepend = bytes > 0 ? '+' : '';
+      return prepend + parseFloat((bytes / Math.pow(bits, exponent))
+          .toFixed(decimals)) + ' ' + sizes[exponent];
+    },
+
+    _formatPercentage: function(size, delta) {
+      var oldSize = size - delta;
+
+      if (oldSize === 0) { return ''; }
+
+      var percentage = Math.round(Math.abs(delta * 100 / oldSize));
+      return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
+    },
+
+    _computeBinaryClass: function(delta) {
+      if (delta === 0) { return; }
+      return delta >= 0 ? 'added' : 'removed';
     },
 
     _computeClass: function(baseClass, path) {
       var classes = [baseClass];
-      if (path === COMMIT_MESSAGE_PATH) {
+      if (path === COMMIT_MESSAGE_PATH || path === MERGE_LIST_PATH) {
         classes.push('invisible');
       }
       return classes.join(' ');
     },
 
+    _computeExpandInlineClass: function(userPrefs) {
+      return userPrefs.expand_inline_diffs ? 'expandInline' : '';
+    },
+
+    _computePathClass: function(path, expandedFilesRecord) {
+      return this._isFileExpanded(path, expandedFilesRecord) ? 'path expanded' :
+          'path';
+    },
+
+    _computeShowHideText: function(path, expandedFilesRecord) {
+      return this._isFileExpanded(path, expandedFilesRecord) ? '▼' : '◀';
+    },
+
+    _computeFilesShown: function(numFilesShown, files) {
+      return files.base.slice(0, numFilesShown);
+    },
+
+    _setReviewedFiles: function(shownFiles, files, reviewedRecord, loggedIn) {
+      if (!loggedIn) { return; }
+      var reviewed = reviewedRecord.base;
+      var fileReviewed;
+      for (var i = 0; i < files.length; i++) {
+        fileReviewed = this._computeReviewed(files[i], reviewed);
+        this._files[i].isReviewed = fileReviewed;
+        if (i < shownFiles.length) {
+          this.set(['_shownFiles', i, 'isReviewed'], fileReviewed);
+        }
+      }
+    },
+
     _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));
+        this.$.diffCursor.splice.apply(this.$.diffCursor,
+            ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
+
+        var files = Polymer.dom(this.root).querySelectorAll('.file-row');
+        this.$.fileCursor.stops = files;
+        this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
       }.bind(this), 1);
     },
+
+    _incrementNumFilesShown: function() {
+      this._numFilesShown += this._fileListIncrement;
+    },
+
+    _computeFileListButtonHidden: function(numFilesShown, files) {
+      return numFilesShown >= files.length;
+    },
+
+    _computeIncrementText: function(numFilesShown, files) {
+      if (!files) { return ''; }
+      var text =
+          Math.min(this._fileListIncrement, files.length - numFilesShown);
+      return 'Show ' + text + ' more';
+    },
+
+    _computeShowAllText: function(files) {
+      if (!files) { return ''; }
+      return 'Show all ' + files.length + ' files';
+    },
+
+    _showAllFiles: function() {
+      this._numFilesShown = this._files.length;
+    },
+
+    _updateSelected: function(patchRange) {
+      this._diffAgainst = patchRange.basePatchNum;
+    },
+
+    /**
+     * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+     * the current state.
+     *
+     * The expected behavior is to use the mode specified in the user's
+     * preferences unless they have manually chosen the alternative view.
+     *
+     * Use side-by-side if there is no view mode or preferences.
+     *
+     * @return {String}
+     */
+    _getDiffViewMode: function(diffViewMode, userPrefs) {
+      if (diffViewMode) {
+        return diffViewMode;
+      } else if (userPrefs) {
+        return this.diffViewMode = userPrefs.default_diff_view;
+      }
+      return 'SIDE_BY_SIDE';
+    },
+
+    _fileListActionsVisible: function(shownFilesRecord,
+        maxFilesForBulkActions) {
+      return shownFilesRecord.base.length <= maxFilesForBulkActions;
+    },
+
+    _computePatchSetDescription: function(revisions, patchNum) {
+      var rev = this.getRevisionByPatchNum(revisions, patchNum);
+      return (rev && rev.description) ?
+          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+    },
+
+    _computeFileStatusLabel: function(status) {
+      var statusCode = this._computeFileStatus(status);
+      return FileStatus.hasOwnProperty(statusCode) ?
+          FileStatus[statusCode] : 'Status Unknown';
+    },
+
+    _isFileExpanded: function(path, expandedFilesRecord) {
+      return expandedFilesRecord.base.indexOf(path) !== -1;
+    },
+
+    /**
+     * Handle splices to the list of expanded file paths. If there are any new
+     * entries in the expanded list, then render each diff corresponding in
+     * order by waiting for the previous diff to finish before starting the next
+     * one.
+     * @param  {splice} record The splice record in the expanded paths list.
+     */
+    _expandedPathsChanged: function(record) {
+      if (!record) { return; }
+
+      // Find the paths introduced by the new index splices:
+      var newPaths = record.indexSplices
+          .map(function(splice) {
+            return splice.object.slice(splice.index,
+                splice.index + splice.addedCount);
+          })
+          .reduce(function(acc, paths) { return acc.concat(paths); }, []);
+
+      var timerName = 'Expand ' + newPaths.length + ' diffs';
+      this.$.reporting.time(timerName);
+
+      this._renderInOrder(newPaths, this.diffs, newPaths.length)
+          .then(function() {
+            this.$.reporting.timeEnd(timerName);
+            this.$.diffCursor.handleDiffUpdate();
+          }.bind(this));
+    },
+
+    /**
+     * Given an array of paths and a NodeList of diff elements, render the diff
+     * for each path in order, awaiting the previous render to complete before
+     * continung.
+     * @param  {!Array<!String>} paths
+     * @param  {!NodeList<!GrDiffElement>} diffElements
+     * @param  {Number} initialCount The total number of paths in the pass. This
+     *   is used to generate log messages.
+     * @return {!Promise}
+     */
+    _renderInOrder: function(paths, diffElements, initialCount) {
+      if (!paths.length) {
+        console.log('Finished expanding', initialCount, 'diff(s)');
+        return Promise.resolve();
+      }
+      console.log('Expanding diff', 1 + initialCount - paths.length, 'of',
+          initialCount, ':', paths[0]);
+      var diffElem = this._findDiffByPath(paths[0], diffElements);
+      var promises = [diffElem.reload()];
+      if (this._isLoggedIn) {
+        promises.push(this._reviewFile(paths[0]));
+      }
+      return Promise.all(promises).then(function() {
+        return this._renderInOrder(paths.slice(1), diffElements, initialCount);
+      }.bind(this));
+    },
+
+    /**
+     * In the given NodeList of diff elements, find the diff for the given path.
+     * @param  {!String} path
+     * @param  {!NodeList<!GrDiffElement>} diffElements
+     * @return {!GrDiffElement}
+     */
+    _findDiffByPath: function(path, diffElements) {
+      for (var i = 0; i < diffElements.length; i++) {
+        if (diffElements[i].path === path) {
+          return diffElements[i];
+        }
+      }
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index f61566a..bd4619f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -18,7 +18,7 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
@@ -26,25 +26,50 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-file-list.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-file-list></gr-file-list>
   </template>
 </test-fixture>
 
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-file-list tests', function() {
     var element;
+    var sandbox;
+    var saveStub;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn: function() { return Promise.resolve(true); },
+        getPreferences: function() { return Promise.resolve({}); },
+        fetchJSON: function() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat: function() { return Promise.resolve(''); },
+      });
+      stub('gr-diff', {
+        reload: function() { return Promise.resolve(); },
       });
       element = fixture('basic');
+      saveStub = sandbox.stub(element, '_saveReviewedState',
+          function() { return Promise.resolve(); });
+    });
+
+    teardown(function() {
+      sandbox.restore();
     });
 
     test('get file list', function(done) {
-      var getChangeFilesStub = sinon.stub(element.$.restAPI, 'getChangeFiles',
+      var getChangeFilesStub = sandbox.stub(element.$.restAPI, 'getChangeFiles',
           function() {
             return Promise.resolve({
               '/COMMIT_MSG': {lines_inserted: 9},
@@ -77,97 +102,413 @@
       });
     });
 
-    test('toggle left diff via shortcut', function() {
-      var toggleLeftDiffStub = sinon.stub();
-      sinon.stub(element, 'diffs', {get: function() {
-        return [{toggleLeftDiff: toggleLeftDiffStub}];
-      }});
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
-      assert.isTrue(toggleLeftDiffStub.calledOnce);
+    test('calculate totals for patch number', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with a commit message that isn't the first file.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with no commit message.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
+        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with files missing either lines_inserted or lines_deleted.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1},
+        {__path: 'myfile.txt', lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 1,
+        deleted: 1,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
     });
 
-    test('keyboard shortcuts', function() {
-      var toggleInlineDiffsStub = sinon.stub(element, '_toggleInlineDiffs');
-      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift');  // 'I'
-      assert.isTrue(toggleInlineDiffsStub.calledOnce);
-      toggleInlineDiffsStub.restore();
-
+    test('binary only files', function() {
       element._files = [
-        {__path: '/COMMIT_MSG'},
-        {__path: 'file_added_in_rev2.txt'},
-        {__path: 'myfile.txt'},
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
+        {__path: 'file_binary', binary: true, size_delta: -5, size: 120},
       ];
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
+      assert.deepEqual(element._patchChange, {
+        inserted: 0,
+        deleted: 0,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isTrue(element._hideChangeTotals);
+    });
+
+    test('binary and regular files', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
+        {__path: 'file_binary', binary: true, size_delta: -5, size: 120},
+        {__path: 'myfile.txt', lines_deleted: 5, size_delta: -10, size: 100},
+        {__path: 'myfile2.txt', lines_inserted: 10},
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 10,
+        deleted: 5,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('_formatBytes function', function() {
+      var table = {
+        64: '+64 B',
+        1023: '+1023 B',
+        1024: '+1 KiB',
+        4096: '+4 KiB',
+        1073741824: '+1 GiB',
+        '-64': '-64 B',
+        '-1023': '-1023 B',
+        '-1024': '-1 KiB',
+        '-4096': '-4 KiB',
+        '-1073741824': '-1 GiB',
+        0: '+/-0 B',
       };
-      element.selectedIndex = 0;
 
-      flushAsynchronousOperations();
-      var elementItems = Polymer.dom(element.root).querySelectorAll(
-          '.row:not(.header)');
-      assert.equal(elementItems.length, 3);
-      assert.isTrue(elementItems[0].hasAttribute('selected'));
-      assert.isFalse(elementItems[1].hasAttribute('selected'));
-      assert.isFalse(elementItems[2].hasAttribute('selected'));
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
+      for (var bytes in table) {
+        if (table.hasOwnProperty(bytes)) {
+          assert.equal(element._formatBytes(bytes), table[bytes]);
+        }
+      }
+    });
 
-      var showStub = sinon.stub(page, 'show');
-      assert.equal(element.selectedIndex, 2);
-      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'ENTER'
-      assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
-          'Should navigate to /c/42/2/myfile.txt');
+    test('_formatPercentage function', function() {
+      var table = [
+        { size: 100,
+          delta: 100,
+          display: '',
+        },
+        { size: 195060,
+          delta: 64,
+          display: '(+0%)',
+        },
+        { size: 195060,
+          delta: -64,
+          display: '(-0%)',
+        },
+        { size: 394892,
+          delta: -7128,
+          display: '(-2%)',
+        },
+        { size: 90,
+          delta: -10,
+          display: '(-10%)',
+        },
+        { size: 110,
+          delta: 10,
+          display: '(+10%)',
+        },
+      ];
 
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 79);  // 'O'
-      assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
-          'Should navigate to /c/42/2/file_added_in_rev2.txt');
+      table.forEach(function(item) {
+        assert.equal(element._formatPercentage(
+            item.size, item.delta), item.display);
+      });
+    });
 
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'K'
-      assert.equal(element.selectedIndex, 0);
+    suite('keyboard shortcuts', function() {
+      setup(function() {
+        element._files = [
+          {__path: '/COMMIT_MSG'},
+          {__path: 'file_added_in_rev2.txt'},
+          {__path: 'myfile.txt'},
+        ];
+        element.changeNum = '42';
+        element.patchRange = {
+          basePatchNum: 'PARENT',
+          patchNum: '2',
+        };
+        element.$.fileCursor.setCursorAtIndex(0);
+      });
 
-      showStub.restore();
+      test('toggle left diff via shortcut', function() {
+        var toggleLeftDiffStub = sandbox.stub();
+        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
+        // https://github.com/sinonjs/sinon/issues/781
+        var diffsStub = sinon.stub(element, 'diffs', {
+          get: function() {
+            return [{toggleLeftDiff: toggleLeftDiffStub}];
+          },
+        });
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+        assert.isTrue(toggleLeftDiffStub.calledOnce);
+        diffsStub.restore();
+      });
+
+      test('keyboard shortcuts', function() {
+        flushAsynchronousOperations();
+
+        var items = Polymer.dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = items;
+        element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(items.length, 3);
+        assert.isTrue(items[0].classList.contains('selected'));
+        assert.isFalse(items[1].classList.contains('selected'));
+        assert.isFalse(items[2].classList.contains('selected'));
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+
+        var showStub = sandbox.stub(page, 'show');
+        assert.equal(element.$.fileCursor.index, 2);
+        assert.equal(element.selectedIndex, 2);
+        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+        assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
+            'Should navigate to /c/42/2/myfile.txt');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
+        assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
+            'Should navigate to /c/42/2/file_added_in_rev2.txt');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 0);
+        assert.equal(element.selectedIndex, 0);
+      });
+
+      test('i key shows/hides selected inline diff', function() {
+        sandbox.stub(element, '_expandedPathsChanged');
+        flushAsynchronousOperations();
+        element.$.fileCursor.stops = element.diffs;
+        element.$.fileCursor.setCursorAtIndex(0);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.include(element._expandedFilePaths, element.diffs[0].path);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.notInclude(element._expandedFilePaths, element.diffs[0].path);
+        element.$.fileCursor.setCursorAtIndex(1);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.include(element._expandedFilePaths, element.diffs[1].path);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+        flushAsynchronousOperations();
+        for (var index in element.diffs) {
+          assert.include(element._expandedFilePaths, element.diffs[index].path);
+        }
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+        flushAsynchronousOperations();
+        for (var index in element.diffs) {
+          assert.notInclude(element._expandedFilePaths,
+              element.diffs[index].path);
+        }
+      });
+
+      test('_handleEnterKey navigates', function() {
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        sandbox.stub(element, 'modifierPressed').returns(false);
+        var expandStub = sandbox.stub(element, '_openCursorFile');
+        var navStub = sandbox.stub(element, '_openSelectedFile');
+        var e = new CustomEvent('fake-keyboard-event');
+        sinon.stub(e, 'preventDefault');
+        element._showInlineDiffs = false;
+        element._handleEnterKey(e);
+        assert.isTrue(e.preventDefault.called);
+        assert.isTrue(navStub.called);
+        assert.isFalse(expandStub.called);
+      });
+
+      test('_handleEnterKey expands', function() {
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        sandbox.stub(element, 'modifierPressed').returns(false);
+        var expandStub = sandbox.stub(element, '_openCursorFile');
+        var navStub = sandbox.stub(element, '_openSelectedFile');
+        var e = new CustomEvent('fake-keyboard-event');
+        sinon.stub(e, 'preventDefault');
+        element._showInlineDiffs = true;
+        element._handleEnterKey(e);
+        assert.isTrue(e.preventDefault.called);
+        assert.isFalse(navStub.called);
+        assert.isTrue(expandStub.called);
+      });
+
+      test('_handleEnterKey noop when anchor focused', function() {
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        sandbox.stub(element, 'modifierPressed').returns(false);
+        var e = new CustomEvent('fake-keyboard-event',
+            {detail: {keyboardEvent: {target: document.createElement('a')}}});
+        sinon.stub(e, 'preventDefault');
+        element._handleEnterKey(e);
+        assert.isFalse(e.preventDefault.called);
+      });
     });
 
     test('comment filtering', function() {
       var comments = {
         '/COMMIT_MSG': [
-          {patch_set: 1, message: 'Done'},
-          {patch_set: 1, message: 'oh hay'},
-          {patch_set: 2, message: 'hello'},
+          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
+          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
         ],
         'myfile.txt': [
-          {patch_set: 1, message: 'good news!'},
-          {patch_set: 2, message: 'wat!?'},
-          {patch_set: 2, message: 'hi'},
+          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
+          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 2,
+            message: 'wat!?',
+            updated: '2017-02-09 16:40:49',
+            id: '1',
+            unresolved: true,
+          },
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-10 16:40:49',
+            id: '2',
+            in_reply_to: '1',
+            unresolved: false,
+          },
+          {
+            patch_set: 2,
+            message: 'good news!',
+            updated: '2017-02-08 16:40:49',
+            id: '3',
+            unresolved: true,
+          },
+        ],
+      };
+      var drafts = {
+        'unresolved.file': [
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-11 16:40:49',
+            id: '4',
+            in_reply_to: '3',
+            unresolved: false,
+          },
         ],
       };
       assert.equal(
           element._computeCountString(comments, '1', '/COMMIT_MSG', 'comment'),
           '2 comments');
       assert.equal(
+          element._computeCommentsStringMobile(comments, '1', '/COMMIT_MSG'),
+          '2c');
+      assert.equal(
+          element._computeDraftsStringMobile(comments, '1', '/COMMIT_MSG'),
+          '2d');
+      assert.equal(
           element._computeCountString(comments, '1', 'myfile.txt', 'comment'),
           '1 comment');
       assert.equal(
+          element._computeCommentsStringMobile(comments, '1', 'myfile.txt'),
+          '1c');
+      assert.equal(
+          element._computeDraftsStringMobile(comments, '1', 'myfile.txt'),
+          '1d');
+      assert.equal(
           element._computeCountString(comments, '1',
-              'file_added_in_rev2.txt', 'comment'),
-          '');
+          'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsStringMobile(comments, '1',
+          'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(comments, '1',
+          'file_added_in_rev2.txt'), '');
       assert.equal(
           element._computeCountString(comments, '2', '/COMMIT_MSG', 'comment'),
           '1 comment');
       assert.equal(
+          element._computeCommentsStringMobile(comments, '2', '/COMMIT_MSG'),
+          '1c');
+      assert.equal(
+          element._computeDraftsStringMobile(comments, '2', '/COMMIT_MSG'),
+          '1d');
+      assert.equal(
           element._computeCountString(comments, '2', 'myfile.txt', 'comment'),
           '2 comments');
       assert.equal(
+          element._computeCommentsStringMobile(comments, '2', 'myfile.txt'),
+          '2c');
+      assert.equal(
+          element._computeDraftsStringMobile(comments, '2', 'myfile.txt'),
+          '2d');
+      assert.equal(
           element._computeCountString(comments, '2',
-              'file_added_in_rev2.txt', 'comment'),
-          '');
+          'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(element._computeCountString(comments, '2',
+          'unresolved.file', 'comment'), '3 comments');
+      assert.equal(
+          element._computeUnresolvedString(comments, [], 2, 'myfile.txt'), '');
+      assert.equal(
+          element._computeUnresolvedString(comments, [], 2, 'unresolved.file'),
+          '(1 unresolved)');
+      assert.equal(
+          element._computeUnresolvedString(comments, drafts, 2,
+          'unresolved.file'), '');
     });
 
     test('computed properties', function() {
@@ -183,6 +524,10 @@
       assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
       assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
           'clazz invisible');
+      assert.equal(element._computeExpandInlineClass(
+          {expand_inline_diffs: true}), 'expandInline');
+      assert.equal(element._computeExpandInlineClass(
+        {expand_inline_diffs: false}), '');
     });
 
     test('file review status', function() {
@@ -192,43 +537,53 @@
         {__path: 'myfile.txt'},
       ];
       element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
 
       flushAsynchronousOperations();
       var fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
-      var commitMsg = fileRows[0].querySelector('input[type="checkbox"]');
-      var fileAdded = fileRows[1].querySelector('input[type="checkbox"]');
-      var myFile = fileRows[2].querySelector('input[type="checkbox"]');
+      var commitMsg = fileRows[0].querySelector(
+          'input.reviewed[type="checkbox"]');
+      var fileAdded = fileRows[1].querySelector(
+          'input.reviewed[type="checkbox"]');
+      var myFile = fileRows[2].querySelector(
+          'input.reviewed[type="checkbox"]');
 
       assert.isTrue(commitMsg.checked);
       assert.isFalse(fileAdded.checked);
       assert.isTrue(myFile.checked);
 
-      var saveStub = sinon.stub(element, '_saveReviewedState',
-          function() { return Promise.resolve(); });
-
       MockInteractions.tap(commitMsg);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
       MockInteractions.tap(commitMsg);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
-
-      saveStub.restore();
     });
 
     test('patch set from revisions', function() {
+      var expected = [
+        {num: 1, desc: 'test'},
+        {num: 2, desc: 'test'},
+        {num: 3, desc: 'test'},
+        {num: 4, desc: 'test'},
+      ];
       var patchNums = element._computePatchSets({
-        rev3: {_number: 3},
-        rev1: {_number: 1},
-        rev4: {_number: 4},
-        rev2: {_number: 2},
+        base: {
+          rev3: {_number: 3, description: 'test'},
+          rev1: {_number: 1, description: 'test'},
+          rev4: {_number: 4, description: 'test'},
+          rev2: {_number: 2, description: 'test'},
+        },
       });
-      assert.deepEqual(patchNums, [1, 2, 3, 4]);
+      assert.equal(patchNums.length, expected.length);
+      for (var i = 0; i < expected.length; i++) {
+        assert.deepEqual(patchNums[i], expected[i]);
+      }
     });
 
     test('patch range string', function() {
@@ -241,7 +596,7 @@
     });
 
     test('diff against dropdown', function(done) {
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
@@ -253,7 +608,7 @@
         rev3: {_number: 3},
       };
       flush(function() {
-        var selectEl = element.$$('select');
+        var selectEl = element.$.patchChange;
         assert.equal(selectEl.value, 'PARENT');
         assert.isTrue(element.$$('option[value="3"]').hasAttribute('disabled'));
         selectEl.addEventListener('change', function() {
@@ -267,5 +622,286 @@
         element.fire('change', {}, {node: selectEl});
       });
     });
+
+    test('checkbox shows/hides diff inline', function() {
+      element._files = [
+        {__path: 'myfile.txt'},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+      sandbox.stub(element, '_expandedPathsChanged');
+      flushAsynchronousOperations();
+      var fileRows =
+          Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
+      var showHideCheck = fileRows[0].querySelector(
+          'input.show-hide[type="checkbox"]');
+      assert.isNotOk(showHideCheck.checked);
+      MockInteractions.tap(showHideCheck);
+      assert.isOk(showHideCheck.checked);
+      assert.notEqual(element._expandedFilePaths.indexOf('myfile.txt'), -1);
+    });
+
+    test('path should be properly escaped', function() {
+      element._files = [
+        {__path: 'foo bar/my+file.txt%'},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      flushAsynchronousOperations();
+      // Slashes should be preserved, and spaces should be translated to `+`.
+      // @see Issue 4255 regarding double-encoding.
+      // @see Issue 4577 regarding more readable URLs.
+      assert.equal(
+          element.$$('a').getAttribute('href'),
+          '/c/42/2/foo+bar/my%252Bfile.txt%2525');
+    });
+
+    test('diff mode correctly toggles the diffs', function() {
+      element._files = [
+        {__path: 'myfile.txt'},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+      flushAsynchronousOperations();
+      var diffDisplay = element.diffs[0];
+      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+      assert.equal(element.diffViewMode, 'SIDE_BY_SIDE');
+      assert.equal(diffDisplay.viewMode, 'SIDE_BY_SIDE');
+      element.set('diffViewMode', 'UNIFIED_DIFF');
+      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
+    });
+
+    test('diff mode selector initializes from preferences', function() {
+      var resolvePrefs;
+      var prefsPromise = new Promise(function(resolve) {
+        resolvePrefs = resolve;
+      });
+      sandbox.stub(element, '_getPreferences').returns(prefsPromise);
+
+      // Attach a new gr-file-list so we can intercept the preferences fetch.
+      var view = document.createElement('gr-file-list');
+      var select = view.$.modeSelect;
+      fixture('blank').appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({default_diff_view: 'UNIFIED'});
+      flushAsynchronousOperations();
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+      document.getElementById('blank').restore();
+    });
+
+    test('show/hide diffs disabled for large amounts of files', function(done) {
+      var computeSpy = sandbox.spy(element, '_fileListActionsVisible');
+      element._files = [];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+      flush(function() {
+        assert.isTrue(computeSpy.lastCall.returnValue);
+        var arr = [];
+        _.times(element._maxFilesForBulkActions + 1, function() {
+          arr.push({__path: 'myfile.txt'});
+        });
+        element._files = arr;
+        element._numFilesShown = arr.length;
+        assert.isFalse(computeSpy.lastCall.returnValue);
+        done();
+      });
+    });
+
+    test('expanded attribute not set on path when not expanded', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG'},
+      ];
+      assert.isNotOk(element.$$('.expanded'));
+    });
+
+    test('_getDiffViewMode', function() {
+      // No user prefs or diff view mode set.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      // User prefs but no diff view mode set.
+      element.diffViewMode = null;
+      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
+      assert.equal(
+          element._getDiffViewMode(null, element._userPrefs), 'UNIFIED_DIFF');
+      // User prefs and diff view mode set.
+      element.diffViewMode = 'SIDE_BY_SIDE';
+      assert.equal(element._getDiffViewMode(
+          element.diffViewMode, element._userPrefs), 'SIDE_BY_SIDE');
+    });
+    test('expand_inline_diffs user preference', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG'},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sandbox.stub(element, '_expandedPathsChanged');
+      flushAsynchronousOperations();
+      var commitMsgFile = Polymer.dom(element.root)
+          .querySelectorAll('.row:not(.header) a')[0];
+
+      // Remove href attribute so the app doesn't route to a diff view
+      commitMsgFile.removeAttribute('href');
+      var hiddenChangeSpy = sandbox.spy(element, '_handleHiddenChange');
+
+      MockInteractions.tap(commitMsgFile);
+      flushAsynchronousOperations();
+      assert(hiddenChangeSpy.notCalled, 'file is opened as diff view');
+      assert.isNotOk(element.$$('.expanded'));
+
+      element._userPrefs = {expand_inline_diffs: true};
+      flushAsynchronousOperations();
+      MockInteractions.tap(commitMsgFile);
+      flushAsynchronousOperations();
+      assert(hiddenChangeSpy.calledOnce, 'file is expanded');
+      assert.isOk(element.$$('.expanded'));
+    });
+
+    test('_togglePathExpanded', function() {
+      var path = 'path/to/my/file.txt';
+      element.files = [{__path: path}];
+      var renderStub = sandbox.stub(element, '_renderInOrder')
+          .returns(Promise.resolve());
+
+      assert.equal(element._expandedFilePaths.length, 0);
+      element._togglePathExpanded(path);
+      flushAsynchronousOperations();
+
+      assert.equal(renderStub.callCount, 1);
+      assert.include(element._expandedFilePaths, path);
+      element._togglePathExpanded(path);
+      flushAsynchronousOperations();
+
+      assert.equal(renderStub.callCount, 2);
+      assert.notInclude(element._expandedFilePaths, path);
+    });
+
+    test('_expandedPathsChanged', function(done) {
+      sandbox.stub(element, '_reviewFile');
+      var path = 'path/to/my/file.txt';
+      var diffs = [{
+        path: path,
+        reload: function() {
+          done();
+        },
+      }];
+      var diffsStub = sinon.stub(element, 'diffs', {
+        get: function() { return diffs; },
+      });
+      element.push('_expandedFilePaths', path);
+    });
+
+    suite('_handleFileTap', function() {
+      function testForModifier(modifier) {
+        var e = {preventDefault: function() {}};
+        e.detail = {sourceEvent: {}};
+        e.detail.sourceEvent[modifier] = true;
+
+        var hiddenChangeStub = sandbox.stub(element, '_handleHiddenChange');
+        element._userPrefs = { expand_inline_diffs: true };
+
+        element._handleFileTap(e);
+        assert.isFalse(hiddenChangeStub.called);
+
+        e.detail.sourceEvent[modifier] = false;
+        element._handleFileTap(e);
+        assert.equal(hiddenChangeStub.callCount, 1);
+
+        element._userPrefs = { expand_inline_diffs: false };
+        element._handleFileTap(e);
+        assert.equal(hiddenChangeStub.callCount, 1);
+      }
+
+      test('_handleFileTap meta', function() {
+        testForModifier('metaKey');
+      });
+
+      test('_handleFileTap ctrl', function() {
+        testForModifier('ctrlKey');
+      });
+    });
+
+    test('_renderInOrder', function(done) {
+      var reviewStub = sandbox.stub(element, '_reviewFile');
+      var callCount = 0;
+      var diffs = [{
+        path: 'p0',
+        reload: function() {
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        reload: function() {
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        reload: function() {
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
+        .then(function() {
+          assert.isFalse(reviewStub.called);
+          done();
+        });
+    });
+
+    test('_renderInOrder logged in', function(done) {
+      element._isLoggedIn = true;
+      var reviewStub = sandbox.stub(element, '_reviewFile');
+      var callCount = 0;
+      var diffs = [{
+        path: 'p0',
+        reload: function() {
+          assert.equal(reviewStub.callCount, 2);
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        reload: function() {
+          assert.equal(reviewStub.callCount, 1);
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        reload: function() {
+          assert.equal(reviewStub.callCount, 0);
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
+        .then(function() {
+          assert.equal(reviewStub.callCount, 3);
+          done();
+        });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 66254d0..831914e 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <link rel="import" href="../gr-comment-list/gr-comment-list.html">
@@ -30,19 +30,20 @@
         border-top: 1px solid #ddd;
         display: block;
         position: relative;
-      }
-      :host(:not([expanded])) {
         cursor: pointer;
       }
+      :host(.expanded) {
+        cursor: auto;
+      }
       gr-avatar {
         position: absolute;
         left: var(--default-horizontal-margin);
       }
       .collapsed .contentContainer {
+        align-items: baseline;
         color: #777;
+        display: flex;
         white-space: nowrap;
-        overflow-x: hidden;
-        text-overflow: ellipsis;
       }
       .showAvatar.expanded .contentContainer {
         margin-left: calc(var(--default-horizontal-margin) + 2.5em);
@@ -50,12 +51,15 @@
       }
       .showAvatar.collapsed .contentContainer {
         margin-left: calc(var(--default-horizontal-margin) + 1.75em);
-        padding: .75em 2em .75em 0;
       }
       .hideAvatar.collapsed .contentContainer,
       .hideAvatar.expanded .contentContainer {
         margin-left: 0;
-        padding: .75em 2em .75em 0;
+      }
+      .showAvatar.collapsed .contentContainer,
+      .hideAvatar.collapsed .contentContainer,
+      .hideAvatar.expanded .contentContainer {
+        padding: .75em 0;
       }
       .collapsed gr-avatar {
         top: .5em;
@@ -70,21 +74,43 @@
       .name {
         font-weight: bold;
       }
-      .content {
-        font-family: var(--monospace-font-family);
+      .message {
+        max-width: 80ch;
+      }
+      .collapsed .message {
+        max-width: none;
+        overflow: hidden;
+        text-overflow: ellipsis;
       }
       .collapsed .name,
       .collapsed .content,
       .collapsed .message,
+      .collapsed .updateCategory,
       gr-account-chip {
         display: inline;
       }
       .collapsed gr-comment-list,
-      .collapsed .replyContainer {
+      .collapsed .replyContainer,
+      .collapsed .hideOnCollapsed,
+      .hideOnOpen {
         display: none;
       }
+      .collapsed .hideOnOpen {
+        display: block;
+      }
+      .collapsed .content {
+        flex: 1;
+        margin-right: .25em;
+        min-width: 0;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .collapsed .date {
+        position: static;
+      }
       .collapsed .name {
         color: var(--default-text-color);
+        margin-right: .4em;
       }
       .expanded .name {
         cursor: pointer;
@@ -99,34 +125,51 @@
         padding: .5em 0 1em;
       }
     </style>
-    <div class$="[[_computeClass(expanded, showAvatar)]]">
+    <div class$="[[_computeClass(_expanded, showAvatar)]]">
       <gr-avatar account="[[author]]" image-size="100"></gr-avatar>
       <div class="contentContainer">
         <div class="name" on-tap="_handleNameTap">[[author.name]]</div>
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
-            <gr-linked-text
-                class="message"
-                pre="[[expanded]]"
+            <div class="message hideOnOpen">[[message.message]]</div>
+            <gr-formatted-text
+                class="message hideOnCollapsed"
                 content="[[message.message]]"
-                disabled="[[!expanded]]"
-                config="[[projectConfig.commentlinks]]"></gr-linked-text>
+                config="[[projectConfig.commentlinks]]"></gr-formatted-text>
             <gr-comment-list
                 comments="[[comments]]"
                 change-num="[[changeNum]]"
-                patch-num="[[message._revision_number]]"></gr-comment-list>
+                patch-num="[[message._revision_number]]"
+                project-config="[[projectConfig]]"></gr-comment-list>
           </div>
-          <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
-            <gr-date-formatter date-str="[[message.date]]"></gr-date-formatter>
-          </a>
         </template>
-        <template is="dom-if" if="[[message.reviewer]]">
-          set reviewer status for
-          <gr-account-chip account="[[message.reviewer]]">
-          </gr-account-chip>
-          to [[message.state]].
-          <gr-date-formatter class="date" date-str="[[message.updated]]">
-          </gr-date-formatter>
+        <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
+          <div class="content">
+            <template is="dom-repeat" items="[[message.updates]]" as="update">
+              <div class="updateCategory">
+                [[update.message]]
+                <template
+                    is="dom-repeat" items="[[update.reviewers]]" as="reviewer">
+                  <gr-account-chip account="[[reviewer]]">
+                  </gr-account-chip>
+                </template>
+              </div>
+            </template>
+          </div>
+        </template>
+        <template is="dom-if" if="[[!message.id]]">
+          <span class="date">
+            <gr-date-formatter
+                has-tooltip
+                date-str="[[message.date]]"></gr-date-formatter>
+          </span>
+        </template>
+        <template is="dom-if" if="[[message.id]]">
+          <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
+            <gr-date-formatter
+                has-tooltip
+                date-str="[[message.date]]"></gr-date-formatter>
+          </a>
         </template>
       </div>
       <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index c92ad07..e782943 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -45,26 +45,58 @@
         observer: '_commentsChanged',
       },
       config: Object,
-      expanded: {
+      hideAutomated: {
         type: Boolean,
-        value: true,
+        value: false,
+      },
+      hidden: {
+        type: Boolean,
+        computed: '_computeIsHidden(hideAutomated, isAutomated)',
         reflectToAttribute: true,
       },
+      isAutomated: {
+        type: Boolean,
+        computed: '_computeIsAutomated(message)',
+      },
       showAvatar: {
         type: Boolean,
         computed: '_computeShowAvatar(author, config)',
       },
       showReplyButton: {
         type: Boolean,
-        computed: '_computeShowReplyButton(message)',
+        computed: '_computeShowReplyButton(message, _loggedIn)',
       },
       projectConfig: Object,
+      // Computed property needed to trigger Polymer value observing.
+      _expanded: {
+        type: Object,
+        computed: '_computeExpanded(message.expanded)',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
     },
 
+    observers: [
+      '_updateExpandedClass(message.expanded)',
+    ],
+
     ready: function() {
       this.$.restAPI.getConfig().then(function(config) {
         this.config = config;
       }.bind(this));
+      this.$.restAPI.getLoggedIn().then(function(loggedIn) {
+        this._loggedIn = loggedIn;
+      }.bind(this));
+    },
+
+    _updateExpandedClass: function(expanded) {
+      if (expanded) {
+        this.classList.add('expanded');
+      } else {
+        this.classList.remove('expanded');
+      }
     },
 
     _computeAuthor: function(message) {
@@ -75,23 +107,48 @@
       return !!(author && config && config.plugin && config.plugin.has_avatars);
     },
 
-    _computeShowReplyButton: function(message) {
-      return !!message.message;
+    _computeShowReplyButton: function(message, loggedIn) {
+      return !!message.message && loggedIn;
     },
 
+    _computeExpanded: function(expanded) {
+      return expanded;
+    },
+
+    /**
+     * If there is no value set on the message object as to whether _expanded
+     * should be true or not, then _expanded is set to true if there are
+     * inline comments (otherwise false).
+     */
     _commentsChanged: function(value) {
-      this.expanded = Object.keys(value || {}).length > 0;
+      if (this.message && this.message.expanded === undefined) {
+        this.set('message.expanded', Object.keys(value || {}).length > 0);
+      }
     },
 
     _handleTap: function(e) {
-      if (this.expanded) { return; }
-      this.expanded = true;
+      if (this.message.expanded) { return; }
+      e.stopPropagation();
+      this.set('message.expanded', true);
     },
 
     _handleNameTap: function(e) {
-      if (!this.expanded) { return; }
+      if (!this.message.expanded) { return; }
       e.stopPropagation();
-      this.expanded = false;
+      this.set('message.expanded', false);
+    },
+
+    _computeIsAutomated: function(message) {
+      return !!(message.reviewer ||
+          (message.tag && message.tag.indexOf('autogenerated') === 0));
+    },
+
+    _computeIsHidden: function(hideAutomated, isAutomated) {
+      return hideAutomated && isAutomated;
+    },
+
+    _computeIsReviewerUpdate: function(event) {
+      return event.type === 'REVIEWER_UPDATE';
     },
 
     _computeClass: function(expanded, showAvatar) {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index c90f58a..89c7173 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -20,11 +20,12 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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-message.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-message></gr-message>
@@ -36,6 +37,9 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       element = fixture('basic');
     });
 
@@ -61,7 +65,7 @@
     });
 
     test('reviewer update', function() {
-      var updatedBy = {
+      var author = {
         _account_id: 1115495,
         name: 'Andrew Bonventre',
         email: 'andybons@chromium.org',
@@ -72,18 +76,74 @@
         email: 'barbar@chromium.org',
       };
       element.message = {
-        updated_by: updatedBy,
+        id: 0xDEADBEEF,
+        author: author,
         reviewer: reviewer,
-        state: 'CC',
-        updated: '2016-01-12 20:24:49.448000000',
+        date: '2016-01-12 20:24:49.448000000',
+        type: 'REVIEWER_UPDATE',
+        updates: [
+          {
+            message: 'Added to CC:',
+            reviewers: [reviewer],
+          }
+        ],
       };
       flushAsynchronousOperations();
       var content = element.$$('.contentContainer');
       assert.isOk(content);
-      assert.strictEqual(
-          content.querySelector('gr-account-chip').account, reviewer);
-      assert.equal(0, content.textContent.trim().indexOf(updatedBy.name));
+      assert.strictEqual(element.$$('gr-account-chip').account, reviewer);
+      assert.equal(author.name, element.$$('.name').textContent);
     });
 
+    test('autogenerated prefix hiding', function() {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('reviewer message treated as autogenerated', function() {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('tag that is not autogenerated prefix does not hide', function() {
+      element.message = {
+        tag: 'something',
+        updated: '2016-01-12 20:24:49.448000000',
+      };
+
+      assert.isFalse(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isFalse(element.hidden);
+    });
+
+    test('reply button hidden unless logged in', function() {
+      var message = {
+        'message': 'Uploaded patch set 1.',
+      };
+      assert.isFalse(element._computeShowReplyButton(message, false));
+      assert.isTrue(element._computeShowReplyButton(message, true));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 3ae6b44..14361f4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -15,13 +15,15 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../gr-message/gr-message.html">
 
 <dom-module id="gr-messages-list">
   <template>
     <style>
-      :host {
+      :host,
+      .messageListControls {
         display: block;
       }
       .header {
@@ -30,6 +32,7 @@
         margin-bottom: .35em;
       }
       .header,
+      #messageControlsContainer,
       gr-message {
         padding: 0 var(--default-horizontal-margin);
       }
@@ -40,26 +43,61 @@
         0% { background-color: #fff9c4; }
         100% { background-color: #fff; }
       }
+      #messageControlsContainer {
+        align-items: center;
+        background-color: #fef;
+        display: flex;
+        justify-content: center;
+      }
+      #messageControlsContainer gr-button {
+        padding: 0.4em;
+      }
     </style>
     <div class="header">
       <h3>Messages</h3>
-      <gr-button link on-tap="_handleExpandCollapseTap">
-        [[_computeExpandCollapseMessage(_expanded)]]
-      </gr-button>
+      <div class="messageListControls">
+        <gr-button id="collapse-messages" link
+            on-tap="_handleExpandCollapseTap">
+          [[_computeExpandCollapseMessage(_expanded)]]
+        </gr-button>
+        <span
+            id="automatedMessageToggleContainer"
+            hidden$="[[!_hasAutomatedMessages(messages)]]">
+          /
+          <gr-button id="automatedMessageToggle" link
+              on-tap="_handleAutomatedMessageToggleTap">
+            [[_computeAutomatedToggleText(_hideAutomated)]]
+          </gr-button>
+        </span>
+      </div>
     </div>
+    <span
+        id="messageControlsContainer"
+        hidden$="[[_computeShowHideTextHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
+      <gr-button id="oldMessagesBtn" link on-tap="_handleShowAllTap">
+          [[_computeNumMessagesText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
+      </gr-button>
+      /
+      <gr-button id="incrementMessagesBtn" link
+          on-tap="_handleIncrementShownMessages">
+        [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
+      </gr-button>
+    </span>
     <template
         is="dom-repeat"
-        items="[[_computeItems(messages, reviewerUpdates)]]"
+        items="[[_visibleMessages]]"
         as="message">
       <gr-message
           change-num="[[changeNum]]"
           message="[[message]]"
           comments="[[_computeCommentsForMessage(comments, message)]]"
+          hide-automated="[[_hideAutomated]]"
           project-config="[[projectConfig]]"
           show-reply-button="[[showReplyButtons]]"
           on-scroll-to="_handleScrollTo"
           data-message-id$="[[message.id]]"></gr-message>
     </template>
+    <gr-reporting id="reporting" category="message-list"></gr-reporting>
   </template>
   <script src="gr-messages-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index e7a0573..0d58d96 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -14,6 +14,14 @@
 (function() {
   'use strict';
 
+  var MAX_INITIAL_SHOWN_MESSAGES = 20;
+  var MESSAGES_INCREMENT = 5;
+
+  var ReportingEvent = {
+    SHOW_ALL: 'show-all-messages',
+    SHOW_MORE: 'show-more-messages',
+  };
+
   Polymer({
     is: 'gr-messages-list',
 
@@ -29,7 +37,6 @@
       },
       comments: Object,
       projectConfig: Object,
-      topMargin: Number,
       showReplyButtons: {
         type: Boolean,
         value: false,
@@ -38,24 +45,66 @@
       _expanded: {
         type: Boolean,
         value: false,
+        observer: '_expandedChanged',
+      },
+      _hideAutomated: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * The messages after processing and including merged reviewer updates.
+       */
+      _processedMessages: {
+        type: Array,
+        computed: '_computeItems(messages, reviewerUpdates)',
+        observer: '_processedMessagesChanged',
+      },
+      /**
+       * The subset of _processedMessages that is visible to the user.
+       */
+      _visibleMessages: {
+        type: Array,
+        value: function() { return []; },
       },
     },
 
     scrollToMessage: function(messageID) {
       var el = this.$$('[data-message-id="' + messageID + '"]');
-      if (!el) { return; }
+      // If the message is hidden, expand the hidden messages back to that
+      // point.
+      if (!el) {
+        for (var index = 0; index < this._processedMessages.length; index++) {
+          if (this._processedMessages[index].id === messageID) {
+            break;
+          }
+        }
+        if (index === this._processedMessages.length) { return; }
 
-      el.expanded = true;
+        var newMessages = this._processedMessages.slice(index,
+            -this._visibleMessages.length);
+        // Add newMessages to the beginning of _visibleMessages.
+        this.splice.apply(this, ['_visibleMessages', 0, 0].concat(newMessages));
+        // Allow the dom-repeat to stamp.
+        Polymer.dom.flush();
+        el = this.$$('[data-message-id="' + messageID + '"]');
+      }
+
+      el.set('message.expanded', true);
       var top = el.offsetTop;
       for (var offsetParent = el.offsetParent;
            offsetParent;
            offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
       }
-      window.scrollTo(0, top - this.topMargin);
+      window.scrollTo(0, top);
       this._highlightEl(el);
     },
 
+    _isAutomated: function(message) {
+      return !!(message.reviewer ||
+          (message.tag && message.tag.indexOf('autogenerated') === 0));
+    },
+
     _computeItems: function(messages, reviewerUpdates) {
       messages = messages || [];
       reviewerUpdates = reviewerUpdates || [];
@@ -67,6 +116,7 @@
       for (var i = 0; i < messages.length; i++) {
         messages[i]._index = i;
       }
+
       while (mi < messages.length || ri < reviewerUpdates.length) {
         if (mi >= messages.length) {
           result = result.concat(reviewerUpdates.slice(ri));
@@ -77,7 +127,7 @@
           break;
         }
         mDate = mDate || util.parseDate(messages[mi].date);
-        rDate = rDate || util.parseDate(reviewerUpdates[ri].updated);
+        rDate = rDate || util.parseDate(reviewerUpdates[ri].date);
         if (rDate < mDate) {
           result.push(reviewerUpdates[ri++]);
           rDate = null;
@@ -89,6 +139,15 @@
       return result;
     },
 
+    _expandedChanged: function(exp) {
+      for (var i = 0; i < this._processedMessages.length; i++) {
+        this._processedMessages[i].expanded = exp;
+        if (i < this._visibleMessages.length) {
+          this.set(['_visibleMessages', i, 'expanded'], exp);
+        }
+      }
+    },
+
     _highlightEl: function(el) {
       var highlightedEls =
           Polymer.dom(this.root).querySelectorAll('.highlighted');
@@ -103,41 +162,83 @@
       el.classList.add('highlighted');
     },
 
+    /**
+     * @param {boolean} expand
+     */
+    handleExpandCollapse: function(expand) {
+      this._expanded = expand;
+    },
+
     _handleExpandCollapseTap: function(e) {
       e.preventDefault();
-      this._expanded = !this._expanded;
-      var messageEls = Polymer.dom(this.root).querySelectorAll('gr-message');
-      for (var i = 0; i < messageEls.length; i++) {
-        messageEls[i].expanded = this._expanded;
-      }
+      this.handleExpandCollapse(!this._expanded);
+    },
+
+    _handleAutomatedMessageToggleTap: function(e) {
+      e.preventDefault();
+
+      this._hideAutomated = !this._hideAutomated;
     },
 
     _handleScrollTo: function(e) {
       this.scrollToMessage(e.detail.message.id);
     },
 
+    _hasAutomatedMessages: function(messages) {
+      for (var i = 0; messages && i < messages.length; i++) {
+        if (this._isAutomated(messages[i])) {
+          return true;
+        }
+      }
+      return false;
+    },
+
     _computeExpandCollapseMessage: function(expanded) {
       return expanded ? 'Collapse all' : 'Expand all';
     },
 
+    _computeAutomatedToggleText: function(hideAutomated) {
+      return hideAutomated ? 'Show all messages' : 'Show comments only';
+    },
+
+    /**
+     * Computes message author's file comments for change's message.
+     * Method uses this.messages to find next message and relies on messages
+     * to be sorted by date field descending.
+     * @param {!Object} comments Hash of arrays of comments, filename as key.
+     * @param {!Object} message
+     * @return {!Object} Hash of arrays of comments, filename as key.
+     */
     _computeCommentsForMessage: function(comments, message) {
       if (message._index === undefined || !comments || !this.messages) {
         return [];
       }
-      var index = message._index;
       var messages = this.messages || [];
-      var msgComments = {};
-      var mDate = util.parseDate(message.date);
+      var index = message._index;
+      var authorId = message.author && message.author._account_id;
+      var mDate = util.parseDate(message.date).getTime();
+      // NB: Messages array has oldest messages first.
       var nextMDate;
-      if (index < messages.length - 1) {
-        nextMDate = util.parseDate(messages[index + 1].date);
+      if (index > 0) {
+        for (var i = index - 1; i >= 0; i--) {
+          if (messages[i] && messages[i].author &&
+              messages[i].author._account_id === authorId) {
+            nextMDate = util.parseDate(messages[i].date).getTime();
+            break;
+          }
+        }
       }
+      var msgComments = {};
       for (var file in comments) {
         var fileComments = comments[file];
         for (var i = 0; i < fileComments.length; i++) {
-          var cDate = util.parseDate(fileComments[i].updated);
-          if (cDate >= mDate) {
-            if (nextMDate && cDate >= nextMDate) {
+          if (fileComments[i].author &&
+              fileComments[i].author._account_id !== authorId) {
+            continue;
+          }
+          var cDate = util.parseDate(fileComments[i].updated).getTime();
+          if (cDate <= mDate) {
+            if (nextMDate && cDate <= nextMDate) {
               continue;
             }
             msgComments[file] = msgComments[file] || [];
@@ -147,5 +248,84 @@
       }
       return msgComments;
     },
+
+    /**
+     * Returns the number of messages to splice to the beginning of
+     * _visibleMessages. This is the minimum of the total number of messages
+     * remaining in the list and the number of messages needed to display five
+     * more visible messages in the list.
+     */
+    _getDelta: function(visibleMessages, messages, hideAutomated) {
+      var delta = MESSAGES_INCREMENT;
+      var msgsRemaining = messages.length - visibleMessages.length;
+      if (hideAutomated) {
+        var counter = 0;
+        var i;
+        for (i = msgsRemaining; i > 0 && counter < MESSAGES_INCREMENT; i--) {
+          if (!this._isAutomated(messages[i - 1])) { counter++; }
+        }
+        delta = msgsRemaining - i;
+      }
+      return Math.min(msgsRemaining, delta);
+    },
+
+    /**
+     * Gets the number of messages that would be visible, but do not currently
+     * exist in _visibleMessages.
+     */
+    _numRemaining: function(visibleMessages, messages, hideAutomated) {
+      if (hideAutomated) {
+        return this._getHumanMessages(messages).length -
+            this._getHumanMessages(visibleMessages).length;
+      }
+      return messages.length - visibleMessages.length;
+    },
+
+    _computeIncrementText: function(visibleMessages, messages, hideAutomated) {
+      var delta = this._getDelta(visibleMessages, messages, hideAutomated);
+      delta = Math.min(
+          this._numRemaining(visibleMessages, messages, hideAutomated), delta);
+      return 'Show ' + Math.min(MESSAGES_INCREMENT, delta) + ' more';
+    },
+
+    _getHumanMessages: function(messages) {
+      return messages.filter(function(msg) {
+        return !this._isAutomated(msg);
+      }.bind(this));
+    },
+
+    _computeShowHideTextHidden: function(visibleMessages, messages,
+        hideAutomated) {
+      if (hideAutomated) {
+        messages = this._getHumanMessages(messages);
+        visibleMessages = this._getHumanMessages(visibleMessages);
+      }
+      return visibleMessages.length >= messages.length;
+    },
+
+    _handleShowAllTap: function() {
+      this._visibleMessages = this._processedMessages;
+      this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
+    },
+
+    _handleIncrementShownMessages: function() {
+      var delta = this._getDelta(this._visibleMessages, this._processedMessages,
+          this._hideAutomated);
+      var len = this._visibleMessages.length;
+      var newMessages = this._processedMessages.slice(-(len + delta), -len);
+      // Add newMessages to the beginning of _visibleMessages
+      this.splice.apply(this, ['_visibleMessages', 0, 0].concat(newMessages));
+      this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
+    },
+
+    _processedMessagesChanged: function(messages) {
+      this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
+    },
+
+    _computeNumMessagesText: function(visibleMessages, messages,
+        hideAutomated) {
+      var total = this._numRemaining(visibleMessages, messages, hideAutomated);
+      return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 3cda480..cdca365 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -20,11 +20,12 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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-messages-list.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-messages-list></gr-messages-list>
@@ -32,100 +33,467 @@
 </test-fixture>
 
 <script>
+
+  var randomMessage = function(opt_params) {
+    var params = opt_params || {};
+    var author1 = {
+      _account_id: 1115495,
+      name: 'Andrew Bonventre',
+      email: 'andybons@chromium.org',
+    };
+    return {
+      id: params.id || Math.random().toString(),
+      date: params.date || '2016-01-12 20:28:33.038000',
+      message: params.message || Math.random().toString(),
+      _revision_number: params._revision_number || 1,
+      author: params.author || author1,
+    };
+  };
+
+  var randomAutomated = function(opt_params) {
+    return Object.assign({tag: 'autogenerated:gerrit:replace'},
+        randomMessage(opt_params));
+  };
+
   suite('gr-messages-list tests', function() {
     var element;
+    var messages;
+    var sandbox;
+
+    var getMessages = function() {
+      return Polymer.dom(element.root).querySelectorAll('gr-message');
+    };
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element.messages = [
-        {
-          id: '47c43261_55aa2c41',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:24:49.448000000',
-          message: 'Uploaded patch set 1.',
-          _revision_number: 1
-        },
-        {
-          id: '47c43261_9593e420',
-          author: {
-            _account_id: 1115495,
-            name: 'Andrew Bonventre',
-            email: 'andybons@chromium.org',
-          },
-          date: '2016-01-12 20:28:33.038000000',
-          message: 'Patch Set 1:\n\n(1 comment)',
-          _revision_number: 1
-        },
-        {
-          id: '87b2aaf4_f73260c5',
-          author: {
-            _account_id: 1143760,
-            name: 'Mark Mentovai',
-            email: 'mark@chromium.org',
-          },
-          date: '2016-01-12 21:17:07.554000000',
-          message: 'Patch Set 1:\n\n(3 comments)',
-          _revision_number: 1
-        }
-      ];
+      messages = _.times(3, randomMessage);
+      element.messages = messages;
       flushAsynchronousOperations();
     });
 
-    test('expand/collapse all', function() {
-      var allMessageEls =
-          Polymer.dom(element.root).querySelectorAll('gr-message');
-      for (var i = 0; i < allMessageEls.length; i++) {
-        allMessageEls[i].expanded = false;
-      }
-      MockInteractions.tap(allMessageEls[1]);
-      assert.isTrue(allMessageEls[1].expanded);
+    teardown(function() {
+      sandbox.restore();
+    });
 
-      MockInteractions.tap(element.$$('.header gr-button'));
-      allMessageEls =
-          Polymer.dom(element.root).querySelectorAll('gr-message');
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isTrue(allMessageEls[i].expanded);
+    test('show some old messages', function() {
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+      element.messages = _.times(26, randomMessage);
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+      assert.equal(getMessages().length, 20);
+      assert.equal(element.$.incrementMessagesBtn.innerText,
+          'Show 5 more');
+      MockInteractions.tap(element.$.incrementMessagesBtn);
+      flushAsynchronousOperations();
+
+      assert.equal(getMessages().length, 25);
+      assert.equal(element.$.incrementMessagesBtn.innerText,
+          'Show 1 more');
+      MockInteractions.tap(element.$.incrementMessagesBtn);
+      flushAsynchronousOperations();
+
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+      assert.equal(getMessages().length, 26);
+    });
+
+    test('show all old messages', function() {
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+      element.messages = _.times(26, randomMessage);
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+      assert.equal(getMessages().length, 20);
+      assert.equal(element.$.oldMessagesBtn.innerText, 'Show all 6 messages');
+      MockInteractions.tap(element.$.oldMessagesBtn);
+      flushAsynchronousOperations();
+
+      assert.equal(getMessages().length, 26);
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+    });
+
+    test('message count respects automated', function() {
+      element.messages = _.times(10, randomAutomated)
+          .concat(_.times(11, randomMessage));
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.oldMessagesBtn.innerText, 'Show 1 message');
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+      MockInteractions.tap(element.$.automatedMessageToggle);
+      flushAsynchronousOperations();
+
+      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
+    });
+
+    test('message count still respects non-automated on toggle', function() {
+      element.messages = _.times(10, randomMessage)
+          .concat(_.times(11, randomAutomated));
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.oldMessagesBtn.innerText, 'Show 1 message');
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+      MockInteractions.tap(element.$.automatedMessageToggle);
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.oldMessagesBtn.innerText, 'Show 1 message');
+      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
+    });
+
+    test('show all messages respects expand', function() {
+      element.messages = _.times(10, randomAutomated)
+          .concat(_.times(11, randomMessage));
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.$$('#collapse-messages')); // Expand all.
+      flushAsynchronousOperations();
+
+      var messages = getMessages();
+      assert.equal(messages.length, 20);
+      for (var i = 0; i < messages.length; i++) {
+        assert.isTrue(messages[i]._expanded);
       }
 
-      MockInteractions.tap(element.$$('.header gr-button'));
-      allMessageEls =
-          Polymer.dom(element.root).querySelectorAll('gr-message');
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isFalse(allMessageEls[i].expanded);
+      MockInteractions.tap(element.$.oldMessagesBtn);
+      flushAsynchronousOperations();
+
+      messages = getMessages();
+      assert.equal(messages.length, 21);
+      for (var i = 0; i < messages.length; i++) {
+        assert.isTrue(messages[i]._expanded);
       }
     });
 
-    test('scroll to message', function() {
-      var allMessageEls =
-          Polymer.dom(element.root).querySelectorAll('gr-message');
-      for (var i = 0; i < allMessageEls.length; i++) {
-        allMessageEls[i].expanded = false;
+    test('show all messages respects collapse', function() {
+      element.messages = _.times(10, randomAutomated)
+          .concat(_.times(11, randomMessage));
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.$$('#collapse-messages')); // Expand all.
+      MockInteractions.tap(element.$$('#collapse-messages')); // Collapse all.
+      flushAsynchronousOperations();
+
+      var messages = getMessages();
+      assert.equal(messages.length, 20);
+      for (var i = 0; i < messages.length; i++) {
+        assert.isFalse(messages[i]._expanded);
       }
 
-      var scrollToStub = sinon.stub(window, 'scrollTo');
-      var highlightStub = sinon.stub(element, '_highlightEl');
+      MockInteractions.tap(element.$.oldMessagesBtn);
+      flushAsynchronousOperations();
+
+      messages = getMessages();
+      assert.equal(messages.length, 21);
+      for (var i = 0; i < messages.length; i++) {
+        assert.isFalse(messages[i]._expanded);
+      }
+    });
+
+    test('expand/collapse all', function() {
+      var allMessageEls = getMessages();
+      for (var i = 0; i < allMessageEls.length; i++) {
+        allMessageEls[i]._expanded = false;
+      }
+      MockInteractions.tap(allMessageEls[1]);
+      assert.isTrue(allMessageEls[1]._expanded);
+
+      MockInteractions.tap(element.$$('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isTrue(allMessageEls[i]._expanded);
+      }
+
+      MockInteractions.tap(element.$$('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isFalse(allMessageEls[i]._expanded);
+      }
+    });
+
+    test('expand/collapse from external keypress', function() {
+      MockInteractions.tap(element.$$('#collapse-messages'));
+      var allMessageEls = getMessages();
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isTrue(allMessageEls[i]._expanded);
+      }
+
+      // Expand/collapse all text also changes.
+      assert.equal(element.$$('#collapse-messages').textContent.trim(),
+          'Collapse all');
+
+      MockInteractions.tap(element.$$('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (var i = 0; i < allMessageEls.length; i++) {
+        assert.isFalse(allMessageEls[i]._expanded);
+      }
+      // Expand/collapse all text also changes.
+      assert.equal(element.$$('#collapse-messages').textContent.trim(),
+          'Expand all');
+    });
+
+    test('hide messages does not appear when no automated messages',
+        function() {
+      assert.isOk(element.$$('#automatedMessageToggleContainer[hidden]'));
+    });
+
+    test('scroll to message', function() {
+      var allMessageEls = getMessages();
+      for (var i = 0; i < allMessageEls.length; i++) {
+        allMessageEls[i].set('message.expanded', false);
+      }
+
+      var scrollToStub = sandbox.stub(window, 'scrollTo');
+      var highlightStub = sandbox.stub(element, '_highlightEl');
 
       element.scrollToMessage('invalid');
 
       for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isFalse(allMessageEls[i].expanded,
+        assert.isFalse(allMessageEls[i]._expanded,
             'expected gr-message ' + i + ' to not be expanded');
       }
 
-      var messageID = '47c43261_9593e420';
+      var messageID = messages[1].id;
       element.scrollToMessage(messageID);
       assert.isTrue(
-          element.$$('[data-message-id="' + messageID + '"]').expanded);
+          element.$$('[data-message-id="' + messageID + '"]')._expanded);
 
       assert.isTrue(scrollToStub.calledOnce);
       assert.isTrue(highlightStub.calledOnce);
+    });
 
-      scrollToStub.restore();
-      highlightStub.restore();
+    test('scroll to message offscreen', function() {
+      var scrollToStub = sandbox.stub(window, 'scrollTo');
+      var highlightStub = sandbox.stub(element, '_highlightEl');
+      element.messages = _.times(25, randomMessage);
+      flushAsynchronousOperations();
+      assert.isFalse(scrollToStub.called);
+      assert.isFalse(highlightStub.called);
+
+      var messageID = element.messages[1].id;
+      element.scrollToMessage(messageID);
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+      assert.equal(element._visibleMessages.length, 24);
+      assert.isTrue(
+          element.$$('[data-message-id="' + messageID + '"]')._expanded);
+    });
+
+    test('messages', function() {
+      var author = {
+        _account_id: 42,
+        name: 'Marvin the Paranoid Android',
+        email: 'marvin@sirius.org',
+      };
+      var comments = {
+        file1: [
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: '6505d749_f0bec0aa',
+            line: 62,
+            id: '6505d749_10ed44b2',
+            patch_set: 2,
+            author: {
+              email: 'some@email.com',
+              _account_id: 123,
+            },
+          },
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: 'c5912363_6b820105',
+            line: 42,
+            id: '450a935e_0f1c05db',
+            patch_set: 2,
+            author: author,
+          },
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: '6505d749_f0bec0aa',
+            line: 62,
+            id: '6505d749_10ed44b2',
+            patch_set: 2,
+            author: author,
+          },
+        ],
+        file2: [
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: 'c5912363_4b7d450a',
+            line: 132,
+            id: '450a935e_4f260d25',
+            patch_set: 2,
+            author: author,
+          },
+        ],
+      };
+      var messages = [].concat(
+          randomMessage(),
+          {
+            _index: 5,
+            _revision_number: 4,
+            message: 'Uploaded patch set 4.',
+            date: '2016-09-28 13:36:33.000000000',
+            author: author,
+            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+          },
+          {
+            _index: 6,
+            _revision_number: 4,
+            message: 'Patch Set 4:\n\n(6 comments)',
+            date: '2016-09-28 13:36:33.000000000',
+            author: author,
+            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
+          }
+      );
+      element.comments = comments;
+      element.messages = messages;
+      var isAuthor = function(author, message) {
+        return message.author._account_id === author._account_id;
+      };
+      var isMarvin = isAuthor.bind(null, author);
+      flushAsynchronousOperations();
+      var messageElements = getMessages();
+      assert.equal(messageElements.length, messages.length);
+      assert.deepEqual(messageElements[1].message, messages[1]);
+      assert.deepEqual(messageElements[2].message, messages[2]);
+      assert.deepEqual(messageElements[1].comments.file1,
+          comments.file1.filter(isMarvin));
+      assert.deepEqual(messageElements[1].comments.file2,
+          comments.file2.filter(isMarvin));
+      assert.deepEqual(messageElements[2].comments, {});
+    });
+
+    test('messages without author do not throw', function() {
+      var comments = {
+        file1: [
+          {
+            message: 'message text',
+            updated: '2016-09-27 00:18:03.000000000',
+            in_reply_to: '6505d749_f0bec0aa',
+            line: 62,
+            id: '6505d749_10ed44b2',
+            patch_set: 2,
+            author: {
+              email: 'some@email.com',
+              _account_id: 123,
+            },
+          },
+        ]};
+      var messages = [{
+        _index: 5,
+        _revision_number: 4,
+        message: 'Uploaded patch set 4.',
+        date: '2016-09-28 13:36:33.000000000',
+        id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+      }];
+      element.messages = messages;
+      element.comments = comments;
+      flushAsynchronousOperations();
+      var messageEls = getMessages();
+      assert.equal(messageEls.length, 1);
+      assert.equal(messageEls[0].message.message, messages[0].message);
+    });
+  });
+
+  suite('gr-messages-list automate tests', function() {
+    var element;
+    var messages;
+
+    var getMessages = function() {
+      return Polymer.dom(element.root).querySelectorAll('gr-message');
+    };
+    var getHiddenMessages = function() {
+      return Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
+    };
+
+    var randomMessageReviewer = {
+      reviewer: {},
+    };
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+      messages = _.times(2, randomAutomated);
+      messages.push(randomMessageReviewer);
+      element.messages = messages;
+      flushAsynchronousOperations();
+    });
+
+    test('hide autogenerated button is not hidden', function() {
+      assert.isNotOk(element.$$('#automatedMessageToggle[hidden]'));
+    });
+
+    test('autogenerated messages are not hidden initially', function() {
+      var allHiddenMessageEls = getHiddenMessages();
+
+      //There are no hidden messages.
+      assert.isFalse(!!allHiddenMessageEls.length);
+    });
+
+    test('autogenerated messages hidden after hide button tap', function() {
+      var allHiddenMessageEls = getHiddenMessages();
+
+      element._hideAutomated = false;
+      MockInteractions.tap(element.$.automatedMessageToggle);
+      flushAsynchronousOperations();
+      allMessageEls = getMessages();
+      allHiddenMessageEls = getHiddenMessages();
+
+      // Autogenerated messages are now hidden.
+      assert.equal(allHiddenMessageEls.length, allMessageEls.length);
+    });
+
+    test('autogenerated messages not hidden after show button tap', function() {
+      var allHiddenMessageEls = getHiddenMessages();
+
+      element._hideAutomated = true;
+      MockInteractions.tap(element.$.automatedMessageToggle);
+      allHiddenMessageEls = getHiddenMessages();
+
+      //Autogenerated messages are now hidden.
+      assert.isFalse(!!allHiddenMessageEls.length);
+    });
+
+    test('_getDelta', function() {
+      var messages = [randomMessage()];
+      assert.equal(element._getDelta([], messages, false), 1);
+      assert.equal(element._getDelta([], messages, true), 1);
+
+
+      messages = _.times(7, randomMessage);
+      assert.equal(element._getDelta([], messages, false), 5);
+      assert.equal(element._getDelta([], messages, true), 5);
+
+      messages = _.times(4, randomMessage)
+          .concat(_.times(2, randomAutomated))
+          .concat(_.times(3, randomMessage));
+
+      var dummyArr = _.times(2, randomMessage);
+      assert.equal(element._getDelta(dummyArr, messages, false), 5);
+      assert.equal(element._getDelta(dummyArr, messages, true), 7);
+    });
+
+    test('_getHumanMessages', function() {
+      assert.equal(
+          element._getHumanMessages(_.times(5, randomAutomated)).length, 0);
+      assert.equal(
+          element._getHumanMessages(_.times(5, randomMessage)).length, 5);
+
+      var messages = _.shuffle(_.times(5, randomMessage)
+          .concat(_.times(5, randomAutomated)));
+      messages = element._getHumanMessages(messages);
+      assert.equal(messages.length, 5);
+      assert.isFalse(element._hasAutomatedMessages(messages));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 5d2d20d..8eacd48 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -14,8 +14,9 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-related-changes-list">
@@ -28,7 +29,7 @@
         margin: .5em 0 0;
       }
       section {
-        margin-bottom: 1em;
+        margin-bottom: 1.4em; /* Same as line height for collapse purposes */
       }
       a {
         display: block;
@@ -45,8 +46,16 @@
       }
       .changeContainer.thisChange:before {
         content: '➔';
-        position: absolute;
-        transform: translateX(-1.2em);
+        width: 1.2em
+      }
+      h4,
+      section div {
+        display: flex
+      }
+      h4:before,
+      section div:before {
+        content: ' ';
+        width: 1.2em
       }
       .relatedChanges a {
         display: inline-block;
@@ -69,64 +78,87 @@
       .submittable {
         color: #1b5e20;
       }
-      .hidden {
+      .hidden,
+      .mobile {
         display: none;
       }
+       @media screen and (max-width: 50em) {
+        .mobile {
+          display: block;
+        }
+        hr {
+          border: 0;
+          border-top: 1px solid #ddd;
+          height: 0;
+          margin-bottom: 1em;
+        }
+      }
     </style>
-    <div hidden$="[[!_loading]]">Loading...</div>
-    <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
-      <h4>Relation chain</h4>
-      <template
-          is="dom-repeat"
-          items="[[_relatedResponse.changes]]"
-          as="related">
-        <div class$="[[_computeChangeContainerClass(change, related)]]">
-          <a href$="[[_computeChangeURL(related._change_number, related._revision_number)]]"
-              class$="[[_computeLinkClass(related)]]">
-            [[related.commit.subject]]
-          </a>
-          <span class$="[[_computeChangeStatusClass(related)]]">
-            ([[_computeChangeStatus(related)]])
-          </span>
-        </div>
-      </template>
-    </section>
-    <section hidden$="[[!_submittedTogether.length]]" hidden>
-      <h4>Submitted together</h4>
-      <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
-        <a href$="[[_computeChangeURL(change._number)]]"
-            class$="[[_computeLinkClass(change)]]">
-          [[change.project]]: [[change.branch]]: [[change.subject]]
-        </a>
-      </template>
-    </section>
-    <section hidden$="[[!_sameTopic.length]]" hidden>
-      <h4>Same topic</h4>
-      <template is="dom-repeat" items="[[_sameTopic]]" as="change">
-        <a href$="[[_computeChangeURL(change._number)]]"
-            class$="[[_computeLinkClass(change)]]">
-          [[change.project]]: [[change.branch]]: [[change.subject]]
-        </a>
-      </template>
-    </section>
-    <section hidden$="[[!_conflicts.length]]" hidden>
-      <h4>Merge conflicts</h4>
-      <template is="dom-repeat" items="[[_conflicts]]" as="change">
-        <a href$="[[_computeChangeURL(change._number)]]"
-            class$="[[_computeLinkClass(change)]]">
-          [[change.subject]]
-        </a>
-      </template>
-    </section>
-    <section hidden$="[[!_cherryPicks.length]]" hidden>
-      <h4>Cherry picks</h4>
-      <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
-        <a href$="[[_computeChangeURL(change._number)]]"
-            class$="[[_computeLinkClass(change)]]">
-          [[change.subject]]
-        </a>
-      </template>
-    </section>
+    <div hidden$="[[!loading]]">Loading...</div>
+    <div hidden$="[[loading]]">
+      <hr class="mobile">
+      <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
+        <h4>Relation chain</h4>
+        <template
+            is="dom-repeat"
+            items="[[_relatedResponse.changes]]"
+            as="related">
+          <div class$="rightIndent [[_computeChangeContainerClass(change, related)]]">
+            <a href$="[[_computeChangeURL(related._change_number, related._revision_number)]]"
+                class$="[[_computeLinkClass(related)]]">
+              [[related.commit.subject]]
+            </a>
+            <span class$="[[_computeChangeStatusClass(related)]]">
+              ([[_computeChangeStatus(related)]])
+            </span>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_submittedTogether.length]]" hidden>
+        <h4>Submitted together</h4>
+        <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
+          <div>
+            <a href$="[[_computeChangeURL(change._number)]]"
+                class$="[[_computeLinkClass(change)]]">
+              [[change.project]]: [[change.branch]]: [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_sameTopic.length]]" hidden>
+        <h4>Same topic</h4>
+        <template is="dom-repeat" items="[[_sameTopic]]" as="change">
+          <div>
+            <a href$="[[_computeChangeURL(change._number)]]"
+                class$="[[_computeLinkClass(change)]]">
+              [[change.project]]: [[change.branch]]: [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_conflicts.length]]" hidden>
+        <h4>Merge conflicts</h4>
+        <template is="dom-repeat" items="[[_conflicts]]" as="change">
+          <div>
+            <a href$="[[_computeChangeURL(change._number)]]"
+                class$="[[_computeLinkClass(change)]]">
+              [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_cherryPicks.length]]" hidden>
+        <h4>Cherry picks</h4>
+        <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
+          <div>
+            <a href$="[[_computeChangeURL(change._number)]]"
+                class$="[[_computeLinkClass(change)]]">
+              [[change.branch]]: [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+    </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-related-changes-list.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index f4ee53a..55a0bce 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -19,14 +19,21 @@
 
     properties: {
       change: Object,
+      hasParent: {
+        type: Boolean,
+        notify: true,
+      },
       patchNum: String,
+      parentChange: Object,
       hidden: {
         type: Boolean,
         value: false,
         reflectToAttribute: true,
       },
-
-      _loading: Boolean,
+      loading: {
+        type: Boolean,
+        notify: true,
+      },
       _connectedRevisions: {
         type: Array,
         computed: '_computeConnectedRevisions(change, patchNum, ' +
@@ -40,6 +47,7 @@
     },
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -48,27 +56,39 @@
           '_conflicts, _cherryPicks, _sameTopic)',
     ],
 
+    clear: function() {
+      this.loading = true;
+    },
+
     reload: function() {
       if (!this.change || !this.patchNum) {
         return Promise.resolve();
       }
-      this._loading = true;
+      this.loading = true;
       var promises = [
         this._getRelatedChanges().then(function(response) {
           this._relatedResponse = response;
+
+          this.hasParent = this._calculateHasParent(this.change.change_id,
+            response.changes);
+
         }.bind(this)),
         this._getSubmittedTogether().then(function(response) {
           this._submittedTogether = response;
         }.bind(this)),
-        this._getConflicts().then(function(response) {
-          this._conflicts = response;
-        }.bind(this)),
         this._getCherryPicks().then(function(response) {
           this._cherryPicks = response;
         }.bind(this)),
       ];
 
-      return this._getServerConfig().then(function(config) {
+      // Get conflicts if change is open and is mergeable.
+      if (this.changeIsOpen(this.change.status) && this.change.mergeable) {
+        promises.push(this._getConflicts().then(function(response) {
+          this._conflicts = response;
+        }.bind(this)));
+      }
+
+      promises.push(this._getServerConfig().then(function(config) {
         if (this.change.topic && !config.change.submit_whole_topic) {
           return this._getChangesWithSameTopic().then(function(response) {
             this._sameTopic = response;
@@ -77,11 +97,27 @@
           this._sameTopic = [];
         }
         return this._sameTopic;
-      }.bind(this)).then(Promise.all(promises)).then(function() {
-        this._loading = false;
+      }.bind(this)));
+
+      return Promise.all(promises).then(function() {
+        this.loading = false;
       }.bind(this));
     },
 
+    /**
+     * Determines whether or not the given change has a parent change. If there
+     * is a relation chain, and the change id is not the last item of the
+     * relation chain, there is a parent.
+     * @param  {Number} currentChangeId
+     * @param  {Array} relatedChanges
+     * @return {Boolean}
+     */
+    _calculateHasParent: function(currentChangeId, relatedChanges) {
+      return relatedChanges.length > 0 &&
+          relatedChanges[relatedChanges.length - 1].change_id !==
+          currentChangeId;
+    },
+
     _getRelatedChanges: function() {
       return this.$.restAPI.getRelatedChanges(this.change._number,
           this.patchNum);
@@ -109,7 +145,7 @@
     },
 
     _computeChangeURL: function(changeNum, patchNum) {
-      var urlStr = '/c/' + changeNum;
+      var urlStr = this.getBaseUrl() + '/c/' + changeNum;
       if (patchNum != null) {
         urlStr += '/' + patchNum;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index f7864ce..78c4cf4 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -24,6 +24,8 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-related-changes-list.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-related-changes-list></gr-related-changes-list>
@@ -33,9 +35,15 @@
 <script>
   suite('gr-related-changes-list tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
     });
 
     test('connected revisions', function() {
@@ -223,5 +231,87 @@
       assert.equal(element._computeChangeContainerClass(
           change1, change2).indexOf('thisChange'), -1);
     });
+
+    suite('get conflicts tests', function() {
+      var element;
+      var conflictsStub;
+
+      setup(function() {
+        element = fixture('basic');
+
+        sandbox.stub(element, '_getRelatedChanges',
+            function() {
+              return Promise.resolve({changes: []});
+            });
+        sandbox.stub(element, '_getSubmittedTogether',
+            function() { return Promise.resolve(); });
+        sandbox.stub(element, '_getCherryPicks',
+            function() { return Promise.resolve(); });
+        conflictsStub = sandbox.stub(element, '_getConflicts',
+            function() { return Promise.resolve(); });
+      });
+
+      test('request conflicts if open and mergeable', function() {
+        element.patchNum = 7;
+        element.change = {
+          change_id: 123,
+          status: 'NEW',
+          mergeable: true,
+        };
+        element.reload();
+        assert.isTrue(conflictsStub.called);
+      });
+
+      test('does not request conflicts if closed and mergeable', function() {
+        element.patchNum = 7;
+        element.change = {
+          change_id: 123,
+          status: 'MERGED',
+          mergeable: true,
+        };
+        element.reload();
+        assert.isFalse(conflictsStub.called);
+      });
+
+      test('does not request conflicts if open and not mergeable', function() {
+        element.patchNum = 7;
+        element.change = {
+          change_id: 123,
+          status: 'NEW',
+          mergeable: false,
+        };
+        element.reload();
+        assert.isFalse(conflictsStub.called);
+      });
+
+      test('does not request conflicts if closed and not mergeable',
+          function() {
+        element.patchNum = 7;
+        element.change = {
+          change_id: 123,
+          status: 'MERGED',
+          mergeable: false,
+        };
+        element.reload();
+        assert.isFalse(conflictsStub.called);
+      });
+    });
+
+    test('_calculateHasParent', function() {
+      var changeId = 123;
+      var relatedChanges = [];
+
+      assert.equal(element._calculateHasParent(changeId, relatedChanges),
+          false);
+
+      relatedChanges.push({change_id: 123});
+      assert.equal(element._calculateHasParent(changeId, relatedChanges),
+          false);
+
+      relatedChanges.push({change_id: 234});
+      assert.equal(element._calculateHasParent(changeId, relatedChanges),
+          true);
+
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index cec1e90..b1f95e6 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -14,12 +14,13 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -69,6 +70,7 @@
       gr-account-list {
         display: flex;
         flex-wrap: wrap;
+        flex: 1;
       }
       #reviewerConfirmationOverlay {
         padding: 1em;
@@ -93,27 +95,36 @@
         padding: 0;
         font-family: var(--monospace-font-family);
       }
+      .previewContainer gr-formatted-text {
+        background: #f6f6f6;
+        max-height: 20vh;
+        overflow-y: scroll;
+        padding: 1em;
+      }
       .message {
         border: none;
         width: 100%;
       }
-      .labelsNotShown {
-        color: #666;
-      }
       .labelContainer:not(:first-of-type) {
         margin-top: .5em;
       }
       .labelName {
         display: inline-block;
-        min-width: 7em;
         margin-right: .5em;
+        min-width: 7em;
+        text-align: right;
         white-space: nowrap;
+        width: 25%;
+      }
+      .labelMessage {
+        color: #666;
       }
       iron-selector {
         display: inline-flex;
       }
       iron-selector > gr-button {
         margin-right: .25em;
+        min-width: 3.5em;
       }
       iron-selector > gr-button:first-of-type {
         border-top-left-radius: 2px;
@@ -141,6 +152,14 @@
       .action:visited {
         color: #00e;
       }
+      @media screen and (max-width: 50em) {
+        :host {
+          max-height: none;
+        }
+        .container {
+          max-height: none;
+        }
+      }
     </style>
     <div class="container">
       <section class="peopleContainer">
@@ -155,7 +174,8 @@
           <div class="peopleListLabel">Reviewers</div>
           <gr-account-list
               id="reviewers"
-              accounts="[[_reviewers]]"
+              accounts="{{_reviewers}}"
+              removable-values="[[change.removable_reviewers]]"
               change="[[change]]"
               filter="[[filterReviewerSuggestion]]"
               pending-confirmation="{{_reviewerPendingConfirmation}}"
@@ -167,7 +187,7 @@
             <div class="peopleListLabel">CC</div>
             <gr-account-list
                 id="ccs"
-                accounts="[[_ccs]]"
+                accounts="{{_ccs}}"
                 change="[[change]]"
                 filter="[[filterReviewerSuggestion]]"
                 pending-confirmation="{{_ccPendingConfirmation}}"
@@ -182,11 +202,11 @@
           <div class="reviewerConfirmation">
             Group
             <span class="groupName">
-              {{_reviewerPendingConfirmation.group.name}}
+              [[_pendingConfirmationDetails.group.name]]
             </span>
             has
             <span class="groupSize">
-              {{_reviewerPendingConfirmation.count}}
+              [[_pendingConfirmationDetails.count]]
             </span>
             members.
             <br>
@@ -202,37 +222,44 @@
         <iron-autogrow-textarea
             id="textarea"
             class="message"
-            placeholder="Say something..."
+            autocomplete="on"
+            placeholder="Say something nice..."
             disabled="{{disabled}}"
             rows="4"
             max-rows="15"
             bind-value="{{draft}}"
-            on-bind-value-changed="_handleTextareaChanged">
+            on-bind-value-changed="_handleHeightChanged">
         </iron-autogrow-textarea>
       </section>
+      <section class="previewContainer">
+        <label>
+          <input type="checkbox" checked="{{_previewFormatting::change}}">
+          Preview formatting
+        </label>
+        <gr-formatted-text
+            content="[[draft]]"
+            hidden$="[[!_previewFormatting]]"
+            config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+      </section>
       <section class="labelsContainer">
-        <template is="dom-if" if="[[_computeShowLabels(patchNum, revisions)]]">
-          <template is="dom-repeat"
-              items="[[_computeLabelArray(permittedLabels)]]" as="label">
-            <div class="labelContainer">
-              <span class="labelName">[[label]]</span>
-              <iron-selector data-label$="[[label]]"
-                  selected="[[_computeIndexOfLabelValue(labels, permittedLabels, label, _account)]]">
-                <template is="dom-repeat"
-                    items="[[_computePermittedLabelValues(permittedLabels, label)]]"
-                    as="value">
-                  <gr-button has-tooltip data-value$="[[value]]"
-                      title$="[[_computeLabelValueTitle(labels, label, value)]]">[[value]]</gr-button>
-                </template>
-              </iron-selector>
-            </div>
-          </template>
-        </template>
-        <template is="dom-if" if="[[!_computeShowLabels(patchNum, revisions)]]">
-          <span class="labelsNotShown">
-            Labels are not shown because this is not the most recent patch set.
-            <a href$="/c/[[change._number]]">Go to the latest patch set.</a>
-          </span>
+        <template is="dom-repeat" items="[[_labels]]" as="label">
+          <div class="labelContainer">
+            <span class="labelName">[[label.name]]</span>
+            <iron-selector data-label$="[[label.name]]"
+                selected="[[_computeIndexOfLabelValue(change.labels, permittedLabels, label)]]"
+                hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+              <template is="dom-repeat"
+                  items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
+                  as="value">
+                <gr-button has-tooltip data-value$="[[value]]"
+                    title$="[[_computeLabelValueTitle(change.labels, label.name, value)]]">[[value]]</gr-button>
+              </template>
+            </iron-selector>
+            <span class="labelMessage"
+                hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+              You don't have permission to edit this label.
+            </span>
+          </div>
         </template>
       </section>
       <section class="draftsContainer" hidden$="[[_computeHideDraftList(diffDrafts)]]">
@@ -240,6 +267,7 @@
         <gr-comment-list
             comments="[[diffDrafts]]"
             change-num="[[change._number]]"
+            project-config="[[projectConfig]]"
             patch-num="[[patchNum]]"></gr-comment-list>
       </section>
       <section class="actionsContainer">
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 d2b279d..4c35f38 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
@@ -23,6 +23,11 @@
     REVIEWERS: 'reviewers',
   };
 
+  var ReviewerTypes = {
+    REVIEWER: 'REVIEWER',
+    CC: 'CC',
+  };
+
   Polymer({
     is: 'gr-reply-dialog',
 
@@ -48,7 +53,6 @@
     properties: {
       change: Object,
       patchNum: String,
-      revisions: Object,
       disabled: {
         type: Boolean,
         value: false,
@@ -59,6 +63,10 @@
         value: '',
         observer: '_draftChanged',
       },
+      quote: {
+        type: String,
+        value: '',
+      },
       diffDrafts: Object,
       filterReviewerSuggestion: {
         type: Function,
@@ -66,9 +74,9 @@
           return this._filterReviewerSuggestion.bind(this);
         },
       },
-      labels: Object,
       permittedLabels: Object,
       serverConfig: Object,
+      projectConfig: Object,
 
       _account: Object,
       _ccs: Array,
@@ -76,12 +84,29 @@
         type: Object,
         observer: '_reviewerPendingConfirmationUpdated',
       },
+      _labels: {
+        type: Array,
+        computed: '_computeLabels(change.labels.*, _account)',
+      },
       _owner: Object,
+      _pendingConfirmationDetails: Object,
       _reviewers: Array,
       _reviewerPendingConfirmation: {
         type: Object,
         observer: '_reviewerPendingConfirmationUpdated',
       },
+      _previewFormatting: {
+        type: Boolean,
+        value: false,
+        observer: '_handleHeightChanged',
+      },
+      _reviewersPendingRemove: {
+        type: Object,
+        value: {
+          CC: [],
+          REVIEWER: [],
+        },
+      },
     },
 
     FocusTarget: FocusTarget,
@@ -92,11 +117,13 @@
 
     observers: [
       '_changeUpdated(change.reviewers.*, change.owner, serverConfig)',
+      '_ccsChanged(_ccs.splices)',
+      '_reviewersChanged(_reviewers.splices)',
     ],
 
     attached: function() {
       this._getAccount().then(function(account) {
-        this._account = account;
+        this._account = account || {};
       }.bind(this));
     },
 
@@ -131,6 +158,82 @@
       selectorEl.selectIndex(selectorEl.indexOf(item));
     },
 
+    _ccsChanged: function(splices) {
+      if (splices && splices.indexSplices) {
+        this._processReviewerChange(splices.indexSplices, ReviewerTypes.CC);
+      }
+    },
+
+    _reviewersChanged: function(splices) {
+      if (splices && splices.indexSplices) {
+        this._processReviewerChange(splices.indexSplices,
+            ReviewerTypes.REVIEWER);
+      }
+    },
+
+    _processReviewerChange: function(indexSplices, type) {
+      indexSplices.forEach(function(splice) {
+        splice.removed.forEach(function(account) {
+          if (!this._reviewersPendingRemove[type]) {
+            console.err('Invalid type ' + type + ' for reviewer.');
+            return;
+          }
+          this._reviewersPendingRemove[type].push(account);
+        }.bind(this));
+      }.bind(this));
+    },
+
+    /**
+     * Resets the state of the _reviewersPendingRemove object, and removes
+     * accounts if necessary.
+     *
+     * @param {Boolean} isCancel true if the action is a cancel.
+     * @param {Object} opt_accountIdsTransferred map of account IDs that must
+     *     not be removed, because they have been readded in another state.
+     */
+    _purgeReviewersPendingRemove: function(isCancel,
+        opt_accountIdsTransferred) {
+      var reviewerArr;
+      var keep = opt_accountIdsTransferred || {};
+      for (var type in this._reviewersPendingRemove) {
+        if (this._reviewersPendingRemove.hasOwnProperty(type)) {
+          if (!isCancel) {
+            reviewerArr = this._reviewersPendingRemove[type];
+            for (var i = 0; i < reviewerArr.length; i++) {
+              if (!keep[reviewerArr[i]._account_id]) {
+                this._removeAccount(reviewerArr[i], type);
+              }
+            }
+          }
+          this._reviewersPendingRemove[type] = [];
+        }
+      }
+    },
+
+    /**
+     * Removes an account from the change, both on the backend and the client.
+     * Does nothing if the account is a pending addition.
+     *
+     * @param {Object} account
+     * @param {ReviewerTypes} type
+     */
+    _removeAccount: function(account, type) {
+      if (account._pendingAdd) { return; }
+
+      return this.$.restAPI.removeChangeReviewer(this.change._number,
+          account._account_id).then(function(response) {
+        if (!response.ok) { return response; }
+
+        var reviewers = this.change.reviewers[type] || [];
+        for (var i = 0; i < reviewers.length; i++) {
+          if (reviewers[i]._account_id == account._account_id) {
+            this.splice(['change', 'reviewers', type], i, 1);
+            break;
+          }
+        }
+      }.bind(this));
+    },
+
     _mapReviewer: function(reviewer) {
       var reviewerId;
       var confirmed;
@@ -148,25 +251,44 @@
         drafts: 'PUBLISH_ALL_REVISIONS',
         labels: {},
       };
+
       for (var label in this.permittedLabels) {
         if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
 
         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; }
+        // The user may have not voted on this label.
+        if (!selectorEl || !selectorEl.selectedItem) { continue; }
 
         var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
         selectedVal = parseInt(selectedVal, 10);
-        obj.labels[label] = selectedVal;
+
+        // Only send the selection if the user changed it.
+        var prevVal = this._getVoteForAccount(this.change.labels, label,
+            this._account);
+        if (prevVal !== null) {
+          prevVal = parseInt(prevVal, 10);
+        }
+        if (selectedVal !== prevVal) {
+          obj.labels[label] = selectedVal;
+        }
       }
       if (this.draft != null) {
         obj.message = this.draft;
       }
 
-      obj.reviewers = this.$.reviewers.additions().map(this._mapReviewer);
+      var accountAdditions = {};
+      obj.reviewers = this.$.reviewers.additions().map(function(reviewer) {
+        if (reviewer.account) {
+          accountAdditions[reviewer.account._account_id] = true;
+        }
+        return this._mapReviewer(reviewer);
+      }.bind(this));
       if (this.serverConfig.note_db_enabled) {
         this.$$('#ccs').additions().forEach(function(reviewer) {
+          if (reviewer.account) {
+            accountAdditions[reviewer.account._account_id] = true;
+          }
           reviewer = this._mapReviewer(reviewer);
           reviewer.state = 'CC';
           obj.reviewers.push(reviewer);
@@ -183,6 +305,7 @@
         this.disabled = false;
         this.draft = '';
         this.fire('send', null, {bubbles: false});
+        return accountAdditions;
       }.bind(this)).catch(function(err) {
         this.disabled = false;
         throw err;
@@ -207,7 +330,7 @@
 
     _chooseFocusTarget: function() {
       // If we are the owner and the reviewers field is empty, focus on that.
-      if (this._account && this.change.owner &&
+      if (this._account && this.change && this.change.owner &&
           this._account._account_id === this.change.owner._account_id &&
           (!this._reviewers || this._reviewers.length === 0)) {
         return FocusTarget.REVIEWERS;
@@ -259,16 +382,6 @@
       }.bind(this));
     },
 
-    _computeShowLabels: function(patchNum, revisions) {
-      var num = parseInt(patchNum, 10);
-      for (var rev in revisions) {
-        if (revisions[rev]._number > num) {
-          return false;
-        }
-      }
-      return true;
-    },
-
     _computeHideDraftList: function(drafts) {
       return Object.keys(drafts || {}).length == 0;
     },
@@ -287,31 +400,36 @@
       return labels[label] && labels[label].values[value];
     },
 
-    _computeLabelArray: function(labelsObj) {
-      return Object.keys(labelsObj).sort();
+    _computeLabels: function(labelRecord) {
+      var labelsObj = labelRecord.base;
+      if (!labelsObj) { return []; }
+      return Object.keys(labelsObj).sort().map(function(key) {
+        return {
+          name: key,
+          value: this._getVoteForAccount(labelsObj, key, this._account),
+        };
+      }.bind(this));
     },
 
-    _computeIndexOfLabelValue: function(
-        labels, permittedLabels, labelName, account) {
-      var t = labels[labelName];
-      if (!t) { return null; }
-      var labelValue = t.default_value;
-
-      // Is there an existing vote for the current user? If so, use that.
+    _getVoteForAccount: function(labels, labelName, account) {
       var votes = labels[labelName];
       if (votes.all && votes.all.length > 0) {
         for (var i = 0; i < votes.all.length; i++) {
           if (votes.all[i]._account_id == account._account_id) {
-            labelValue = votes.all[i].value;
-            break;
+            return votes.all[i].value;
           }
         }
       }
+      return null;
+    },
 
-      var len = permittedLabels[labelName] != null ?
-          permittedLabels[labelName].length : 0;
+    _computeIndexOfLabelValue: function(labels, permittedLabels, label) {
+      if (!labels[label.name]) { return null; }
+      var labelValue = label.value;
+      var len = permittedLabels[label.name] != null ?
+          permittedLabels[label.name].length : 0;
       for (var i = 0; i < len; i++) {
-        var val = parseInt(permittedLabels[labelName][i], 10);
+        var val = parseInt(permittedLabels[label.name][i], 10);
         if (val == labelValue) {
           return i;
         }
@@ -323,18 +441,26 @@
       return permittedLabels[label];
     },
 
+    _computeAnyPermittedLabelValues: function(permittedLabels, label) {
+      return permittedLabels.hasOwnProperty(label);
+    },
+
     _changeUpdated: function(changeRecord, owner, serverConfig) {
+      this._rebuildReviewerArrays(changeRecord.base, owner, serverConfig);
+    },
+
+    _rebuildReviewerArrays: function(change, owner, serverConfig) {
       this._owner = owner;
 
       var reviewers = [];
       var ccs = [];
 
-      for (var key in changeRecord.base) {
+      for (var key in change) {
         if (key !== 'REVIEWER' && key !== 'CC') {
           console.warn('unexpected reviewer state:', key);
           continue;
         }
-        changeRecord.base[key].forEach(function(entry) {
+        change[key].forEach(function(entry) {
           if (entry._account_id === owner._account_id) {
             return;
           }
@@ -392,11 +518,16 @@
     _cancelTapHandler: function(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
+      this._purgeReviewersPendingRemove(true);
+      this._rebuildReviewerArrays(this.change.reviewers, this._owner,
+          this.serverConfig);
     },
 
     _sendTapHandler: function(e) {
       e.preventDefault();
-      this.send();
+      this.send().then(function(keep) {
+        this._purgeReviewersPendingRemove(false, keep);
+      }.bind(this));
     },
 
     _saveReview: function(review, opt_errFn) {
@@ -408,6 +539,8 @@
       if (reviewer === null) {
         this.$.reviewerConfirmationOverlay.close();
       } else {
+        this._pendingConfirmationDetails =
+            this._ccPendingConfirmation || this._reviewerPendingConfirmation;
         this.$.reviewerConfirmationOverlay.open();
       }
     },
@@ -432,6 +565,8 @@
     },
 
     _getStorageLocation: function() {
+      // Tests trigger this method without setting change.
+      if (!this.change) { return {}; }
       return {
         changeNum: this.change._number,
         patchNum: this.patchNum,
@@ -457,7 +592,7 @@
       }, STORAGE_DEBOUNCE_INTERVAL_MS);
     },
 
-    _handleTextareaChanged: function(e) {
+    _handleHeightChanged: function(e) {
       // If the textarea resizes, we need to re-fit the overlay.
       this.debounce('autogrow', function() {
         this.fire('autogrow');
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 8fb4e45..5aa5848 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -18,13 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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-reply-dialog.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-reply-dialog></gr-reply-dialog>
@@ -42,6 +43,10 @@
     var setDraftCommentStub;
     var eraseDraftCommentStub;
 
+    var lastId = 0;
+    var makeAccount = function() { return {_account_id: lastId++}; };
+    var makeGroup = function() { return {id: lastId++}; };
+
     setup(function() {
       sandbox = sinon.sandbox.create();
 
@@ -49,43 +54,46 @@
       patchNum = 1;
 
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         getAccount: function() { return Promise.resolve({}); },
       });
 
       element = fixture('basic');
-      element.change = { _number: changeNum };
-      element.patchNum = patchNum;
-      element.labels = {
-        Verified: {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified'
+      element.change = {
+        _number: changeNum,
+        labels: {
+          Verified: {
+            values: {
+              '-1': 'Fails',
+              ' 0': 'No score',
+              '+1': 'Verified',
+            },
+            default_value: 0,
           },
-          default_value: 0
+          'Code-Review': {
+            values: {
+              '-2': 'Do not submit',
+              '-1': 'I would prefer that you didn\'t submit this',
+              ' 0': 'No score',
+              '+1': 'Looks good to me, but someone else must approve',
+              '+2': 'Looks good to me, approved',
+            },
+            default_value: 0,
+          },
         },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved'
-          },
-          default_value: 0
-        }
       };
+      element.patchNum = patchNum;
       element.permittedLabels = {
         'Code-Review': [
           '-1',
           ' 0',
-          '+1'
+          '+1',
         ],
         Verified: [
           '-1',
           ' 0',
-          '+1'
-        ]
+          '+1',
+        ],
       };
       element.serverConfig = {};
 
@@ -102,69 +110,73 @@
       sandbox.restore();
     });
 
+    test('changes in label score are reflected in the DOM', function() {
+      element._account = {_account_id: 1};
+      element.set(['change', 'labels', 'Verified', 'all'],
+          [{_account_id: 1, value: -1}]);
+      flushAsynchronousOperations();
+      var selector = element.$$('iron-selector[data-label="Verified"]');
+      assert.equal(selector.selected, 0); // Index 0, value -1
+      element.set(['change', 'labels', 'Verified', 'all'],
+         [{_account_id: 1, value: 1}]);
+      flushAsynchronousOperations();
+      assert.equal(selector.selected, 2); // Index 2, value 1
+    });
+
     test('cancel event', function(done) {
       element.addEventListener('cancel', function() { done(); });
       MockInteractions.tap(element.$$('.cancel'));
     });
 
-    test('show/hide labels', function() {
-      var revisions = {
-        rev1: {_number: 1},
-        rev2: {_number: 2},
-      };
-      assert.isFalse(element._computeShowLabels('1', revisions));
-      assert.isTrue(element._computeShowLabels('2', revisions));
-    });
-
     test('label picker', function(done) {
-      var showLabelsStub = sinon.stub(element, '_computeShowLabels',
-          function() { return true; });
       element.revisions = {};
       element.patchNum = '';
 
       // Async tick is needed because iron-selector content is distributed and
       // distributed content requires an observer to be set up.
+      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
       flush(function() {
-        for (var label in element.permittedLabels) {
-          assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
-              label);
-        }
-        element.draft = 'I wholeheartedly disapprove';
-        MockInteractions.tap(element.$$(
-            'iron-selector[data-label="Code-Review"] > ' +
-            'gr-button[data-value="-1"]'));
-        MockInteractions.tap(element.$$(
-            'iron-selector[data-label="Verified"] > ' +
-            'gr-button[data-value="-1"]'));
-
-        var saveReviewStub = sinon.stub(element, '_saveReview',
-            function(review) {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': -1,
-              'Verified': -1
-            },
-            message: 'I wholeheartedly disapprove',
-            reviewers: [],
-          });
-          return Promise.resolve({ok: true});
-        });
-
-        element.addEventListener('send', function() {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done sending reply.');
-          assert.equal(element.draft.length, 0);
-          saveReviewStub.restore();
-          showLabelsStub.restore();
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
         flush(function() {
-          MockInteractions.tap(element.$$('.send'));
-          assert.isTrue(element.disabled);
+          for (var label in element.permittedLabels) {
+            assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
+                label);
+          }
+          element.draft = 'I wholeheartedly disapprove';
+          MockInteractions.tap(element.$$(
+              'iron-selector[data-label="Code-Review"] > ' +
+              'gr-button[data-value="-1"]'));
+          MockInteractions.tap(element.$$(
+              'iron-selector[data-label="Verified"] > ' +
+              'gr-button[data-value="-1"]'));
+
+          var saveReviewStub = sinon.stub(element, '_saveReview',
+              function(review) {
+            assert.deepEqual(review, {
+              drafts: 'PUBLISH_ALL_REVISIONS',
+              labels: {
+                'Code-Review': -1,
+                'Verified': -1,
+              },
+              message: 'I wholeheartedly disapprove',
+              reviewers: [],
+            });
+            return Promise.resolve({ok: true});
+          });
+
+          element.addEventListener('send', function() {
+            assert.isFalse(element.disabled,
+                'Element should be enabled when done sending reply.');
+            assert.equal(element.draft.length, 0);
+            saveReviewStub.restore();
+            done();
+          });
+
+          // This is needed on non-Blink engines most likely due to the ways in
+          // which the dom-repeat elements are stamped.
+          flush(function() {
+            MockInteractions.tap(element.$$('.send'));
+            assert.isTrue(element.disabled);
+          });
         });
       });
     });
@@ -188,12 +200,14 @@
       });
     }
 
-    test('reviewer confirmation', function(done) {
+    function testConfirmationDialog(done, cc) {
       var yesButton =
           element.$$('.reviewerConfirmationButtons gr-button:first-child');
       var noButton =
           element.$$('.reviewerConfirmationButtons gr-button:last-child');
 
+      element.serverConfig = {note_db_enabled: true};
+      element._ccPendingConfirmation = null;
       element._reviewerPendingConfirmation = null;
       flushAsynchronousOperations();
       assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
@@ -203,15 +217,37 @@
       var group = {
         id: 'id',
         name: 'name',
-        count: 10,
       };
-      element._reviewerPendingConfirmation = {
-        group: group,
-      };
+      if (cc) {
+        element._ccPendingConfirmation = {
+          group: group,
+          count: 10,
+        };
+      } else {
+        element._reviewerPendingConfirmation = {
+          group: group,
+          count: 10,
+        };
+      }
+      flushAsynchronousOperations();
+
+      if (cc) {
+        assert.deepEqual(
+            element._ccPendingConfirmation,
+            element._pendingConfirmationDetails);
+      } else {
+        assert.deepEqual(
+            element._reviewerPendingConfirmation,
+            element._pendingConfirmationDetails);
+      }
 
       observer.then(function() {
         assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
         observer = overlayObserver('closed');
+        var expected = 'Group name has 10 members';
+        assert.notEqual(
+            element.$.reviewerConfirmationOverlay.innerText.indexOf(expected),
+            -1);
         MockInteractions.tap(noButton); // close the overlay
         return observer;
       }).then(function() {
@@ -220,30 +256,41 @@
         // We should be focused on account entry input.
         assert.equal(getActiveElement().id, 'input');
 
-        // No reviewer should have been added.
-        assert.deepEqual(element.$.reviewers.additions(), []);
+        // No reviewer/CC should have been added.
+        assert.equal(element.$$('#ccs').additions().length, 0);
+        assert.equal(element.$.reviewers.additions().length, 0);
 
         // Reopen confirmation dialog.
         observer = overlayObserver('opened');
-        element._reviewerPendingConfirmation = {
-          group: group,
-        };
+        if (cc) {
+          element._ccPendingConfirmation = {
+            group: group,
+            count: 10,
+          };
+        } else {
+          element._reviewerPendingConfirmation = {
+            group: group,
+            count: 10,
+          };
+        }
         return observer;
       }).then(function() {
         assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
         observer = overlayObserver('closed');
-        MockInteractions.tap(yesButton); // confirm the group
+        MockInteractions.tap(yesButton); // Confirm the group.
         return observer;
       }).then(function() {
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+        var additions = cc ?
+            element.$$('#ccs').additions() :
+            element.$.reviewers.additions();
         assert.deepEqual(
-            element.$.reviewers.additions(),
+            additions,
             [
               {
                 group: {
                   id: 'id',
                   name: 'name',
-                  count: 10,
                   confirmed: true,
                   _group: true,
                   _pendingAdd: true,
@@ -254,6 +301,14 @@
         // We should be focused on account entry input.
         assert.equal(getActiveElement().id, 'input');
       }).then(done);
+    };
+
+    test('cc confirmation', function(done) {
+      testConfirmationDialog(done, true);
+    });
+
+    test('reviewer confirmation', function(done) {
+      testConfirmationDialog(done, false);
     });
 
     test('_getStorageLocation', function() {
@@ -312,7 +367,10 @@
           assert.equal(body, 'first error, second error');
         });
       });
-      element.send().then(done);
+
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      flush(function() { element.send().then(done); });
     });
 
     test('ccs are displayed if NoteDb is enabled', function() {
@@ -329,14 +387,6 @@
     });
 
     test('filterReviewerSuggestion', function() {
-      var counter = 0;
-      function makeAccount() {
-        return {_account_id: counter++};
-      }
-      function makeGroup() {
-        return {id: counter++};
-      }
-
       var owner = makeAccount();
       var reviewer1 = makeAccount();
       var reviewer2 = makeGroup();
@@ -389,5 +439,189 @@
       assert.strictEqual(
           element._chooseFocusTarget(), element.FocusTarget.BODY);
     });
+
+    test('only send labels that have changed', function(done) {
+      flush(function() {
+        var saveReviewStub = sinon.stub(element, '_saveReview',
+            function(review) {
+          assert.deepEqual(review.labels, {Verified: -1});
+          return Promise.resolve({ok: true});
+        });
+
+        element.addEventListener('send', function() {
+          saveReviewStub.restore();
+          done();
+        });
+        // Without wrapping this test in flush(), the below two calls to
+        // MockInteractions.tap() cause a race in some situations in shadow DOM.
+        // The send button can be tapped before the others, causing the test to
+        // fail.
+        MockInteractions.tap(element.$$(
+            'iron-selector[data-label="Verified"] > ' +
+            'gr-button[data-value="-1"]'));
+        MockInteractions.tap(element.$$('.send'));
+      });
+    });
+
+    test('do not display tooltips on touch devices', function() {
+      element._account = {_account_id: 1};
+      element.set(['change', 'labels', 'Verified', 'all'],
+          [{_account_id: 1, value: -1}]);
+      element.labels = {
+        Verified: {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified'
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved'
+          },
+          default_value: 0,
+        },
+      };
+
+      flushAsynchronousOperations();
+
+      var verifiedBtn = element.$$(
+          'iron-selector[data-label="Verified"] > ' +
+          'gr-button[data-value="-1"]');
+
+      // On touch devices, tooltips should not be shown.
+      verifiedBtn._isTouchDevice = true;
+      verifiedBtn._handleShowTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+
+      // On other devices, tooltips should be shown.
+      verifiedBtn._isTouchDevice = false;
+      verifiedBtn._handleShowTooltip();
+      assert.isOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+    });
+
+    test('_processReviewerChange', function() {
+      var mockIndexSplices = function(toRemove) {
+        return [{
+          removed: [toRemove],
+        }];
+      };
+
+      element._processReviewerChange(
+          mockIndexSplices(makeAccount()), 'REVIEWER');
+      assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
+    });
+
+    test('_purgeReviewersPendingRemove', function() {
+      var removeStub = sandbox.stub(element, '_removeAccount');
+      var mock = function() {
+        element._reviewersPendingRemove = {
+          test: [makeAccount()],
+          test2: [makeAccount(), makeAccount()],
+        };
+      };
+      var checkObjEmpty = function(obj) {
+        for (var prop in obj) {
+          if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
+        }
+        return true;
+      };
+      mock();
+      element._purgeReviewersPendingRemove(true); // Cancel
+      assert.isFalse(removeStub.called);
+      assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
+
+      mock();
+      element._purgeReviewersPendingRemove(false); // Submit
+      assert.isTrue(removeStub.called);
+      assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
+    });
+
+    test('_removeAccount', function(done) {
+      sandbox.stub(element.$.restAPI, 'removeChangeReviewer')
+          .returns(Promise.resolve({ok: true}));
+      var arr = [makeAccount(), makeAccount()];
+      element.change.reviewers = {
+        REVIEWER: arr.slice(),
+      };
+
+      element._removeAccount(arr[1], 'REVIEWER').then(function() {
+        assert.equal(element.change.reviewers.REVIEWER.length, 1);
+        assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
+        done();
+      });
+    });
+
+    test('migrate reviewers between states', function(done) {
+      element.serverConfig = {note_db_enabled: true};
+      element._reviewersPendingRemove = {
+        CC: [],
+        REVIEWER: [],
+      };
+      flushAsynchronousOperations();
+      var reviewers = element.$.reviewers;
+      var ccs = element.$$('#ccs');
+      var reviewer1 = makeAccount();
+      var reviewer2 = makeAccount();
+      var cc1 = makeAccount();
+      var cc2 = makeAccount();
+      element._reviewers = [reviewer1, reviewer2];
+      element._ccs = [cc1, cc2];
+
+      var mutations = [];
+
+      var saveReviewStub = sandbox.stub(element, '_saveReview',
+          function(review) {
+        mutations.push.apply(mutations, review.reviewers);
+        return Promise.resolve({ok: true});
+      });
+
+      var removeAccountStub = sandbox.stub(element, '_removeAccount',
+          function(account, type) {
+        mutations.push({state: 'REMOVED', account: account});
+        return Promise.resolve();
+      });
+
+      // Remove and add to other field.
+      reviewers.fire('remove', {account: reviewer1});
+      ccs.$.entry.fire('add', {value: {account: reviewer1}});
+      ccs.fire('remove', {account: cc1});
+      reviewers.$.entry.fire('add', {value: {account: cc1}});
+
+      // Add to other field without removing from former field.
+      // (Currently not possible in UI, but this is a good consistency check).
+      reviewers.$.entry.fire('add', {value: {account: cc2}});
+      ccs.$.entry.fire('add', {value: {account: reviewer2}});
+      var mapReviewer = function(reviewer, opt_state) {
+        var result = {reviewer: reviewer._account_id, confirmed: undefined};
+        if (opt_state) {
+          result.state = opt_state;
+        }
+        return result;
+      };
+
+      // Send and purge and verify moves without deletions.
+      element.send()
+          .then(element._purgeReviewersPendingRemove.bind(element))
+          .then(function() {
+        assert.deepEqual(
+            mutations, [
+                mapReviewer(cc1),
+                mapReviewer(cc2),
+                mapReviewer(reviewer1, 'CC'),
+                mapReviewer(reviewer2, 'CC'),
+            ]);
+        done();
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 6c6125c..4542df8 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -18,13 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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-reviewer-list.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-reviewer-list></gr-reviewer-list>
@@ -40,6 +41,7 @@
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         removeChangeReviewer: function() {
           return Promise.resolve({ok: true});
         },
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index 1d31c12..7e358fd 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -15,23 +15,13 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-account-dropdown">
   <template>
     <style>
-      :host {
-        display: inline-block;
-      }
-      .dropdown-trigger {
-        text-decoration: none;
-      }
-      .dropdown-content {
-        background-color: #fff;
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
-      }
       button {
         background: none;
         border: none;
@@ -43,51 +33,16 @@
         width: 2em;
         vertical-align: middle;
       }
-      ul {
-        list-style: none;
-      }
-      ul .accountName {
-        font-weight: bold;
-      }
-      li .accountInfo,
-      li a {
-        display: block;
-        padding: .85em 1em;
-      }
-      li a:link,
-      li a:visited {
-        color: #00e;
-        text-decoration: none;
-      }
-      li a:hover {
-        background-color: #6B82D6;
-        color: #fff;
-      }
     </style>
-    <gr-button link class="dropdown-trigger" id="trigger"
-        on-tap="_showDropdownTapHandler">
-      <span hidden$="[[_hasAvatars]]" hidden>[[account.name]]</span>
-      <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
-          image-size="56"></gr-avatar>
-    </gr-button>
-    <iron-dropdown id="dropdown"
-        vertical-align="top"
-        vertical-offset="25"
+    <gr-dropdown
+        link
+        items=[[links]]
+        top-content=[[topContent]]
         horizontal-align="right">
-      <div class="dropdown-content">
-        <ul>
-          <li>
-            <div class="accountInfo">
-              <div class="accountName">[[account.name]]</div>
-              <div>[[account.email]]</div>
-            </div>
-          </li>
-          <li><a href$="[[_computeRelativeURL('/settings')]]">Settings</a></li>
-          <li><a href$="[[_computeRelativeURL('/switch-account')]]">Switch account</a></li>
-          <li><a href$="[[_computeRelativeURL('/logout')]]">Sign out</a></li>
-        </ul>
-      </div>
-    </iron-dropdown>
+        <span hidden$="[[_hasAvatars]]" hidden>[[_accountName(account, _anonymousName)]]</span>
+        <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
+            image-size="56" aria-label="Account avatar"></gr-avatar>
+    </gr-dropdown>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-account-dropdown.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index ad944dc..d1da829 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -12,7 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 (function() {
-  'use strict';
+  'use strict'
+
+   var ANONYMOUS_NAME = 'Anonymous';
 
   Polymer({
     is: 'gr-account-dropdown',
@@ -20,26 +22,49 @@
     properties: {
       account: Object,
       _hasAvatars: Boolean,
+      _anonymousName: {
+        type: String,
+        value: ANONYMOUS_NAME,
+      },
+      links: {
+        type: Array,
+        value: [
+          {name: 'Settings', url: '/settings'},
+          {name: 'Switch account', url: '/switch-account'},
+          {name: 'Sign out', url: '/logout'},
+        ],
+      },
+      topContent: {
+        type: Array,
+        computed: '_getTopContent(account, _anonymousName)',
+      },
     },
 
     attached: function() {
       this.$.restAPI.getConfig().then(function(cfg) {
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+        if (cfg && cfg.user &&
+            cfg.user.anonymous_coward_name &&
+            cfg.user.anonymous_coward_name !== 'Anonymous Coward') {
+          this._anonymousName = cfg.user.anonymous_coward_name;
+        }
       }.bind(this));
-
-      this.listen(this.$.dropdown, 'tap', '_handleDropdownTap');
     },
 
-    _handleDropdownTap: function(e) {
-      this.$.dropdown.close();
+    _getTopContent: function(account, _anonymousName) {
+      return [
+        {text: this._accountName(account, _anonymousName), bold: true},
+        {text: account.email ? account.email : ''},
+      ];
     },
 
-    _showDropdownTapHandler: function(e) {
-      this.$.dropdown.open();
-    },
-
-    _computeRelativeURL: function(path) {
-      return '//' + window.location.host + path;
+    _accountName: function(account, _anonymousName) {
+      if (account && account.name) {
+        return account.name;
+      } else if (account && account.email) {
+        return account.email;
+      }
+      return _anonymousName;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 3ae3b14..d7f09b8 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-dropdown</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-account-dropdown.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-account-dropdown></gr-account-dropdown>
@@ -35,14 +37,36 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       element = fixture('basic');
     });
 
-    test('tap on trigger opens menu', function() {
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(element.$.trigger);
-      assert.isTrue(element.$.dropdown.opened);
+    test('account information', function() {
+      element.account = {name: 'John Doe', email: 'john@doe.com'};
+      assert.deepEqual(element.topContent,
+          [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
     });
 
+    test('test for account without a name', function() {
+      element.account = {id: '0001'};
+      assert.deepEqual(element.topContent,
+          [{text: 'Anonymous', bold: true}, {text: ''}]);
+    });
+
+   test('test for account without a name but using config', function() {
+      element._anonymousName = 'WikiGerrit';
+      element.account = {id: '0001'};
+      assert.deepEqual(element.topContent,
+          [{text: 'WikiGerrit', bold: true}, {text: ''}]);
+    });
+
+   test('test for account name as an email', function() {
+      element._anonymousName = 'WikiGerrit';
+      element.account = {email: 'john@doe.com'};
+      assert.deepEqual(element.topContent,
+          [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index 80f293d..e3f2bbc 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-alert/gr-alert.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 7a9c4f9..d48d870 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -15,27 +15,58 @@
   'use strict';
 
   var HIDE_ALERT_TIMEOUT_MS = 5000;
-  var CHECK_SIGN_IN_INTERVAL_MS = 60000;
+  var CHECK_SIGN_IN_INTERVAL_MS = 60*1000;
+  var STALE_CREDENTIAL_THRESHOLD_MS = 10*60*1000;
   var SIGN_IN_WIDTH_PX = 690;
   var SIGN_IN_HEIGHT_PX = 500;
+  var TOO_MANY_FILES = 'too many files to find conflicts';
 
   Polymer({
     is: 'gr-error-manager',
 
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
     properties: {
+      /**
+       * The ID of the account that was logged in when the app was launched. If
+       * not set, then there was no account at launch.
+       */
+      knownAccountId: Number,
+
       _alertElement: Element,
       _hideAlertHandle: Number,
+      _refreshingCredentials: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * The time (in milliseconds) since the most recent credential check.
+       */
+      _lastCredentialCheck: {
+        type: Number,
+        value: function() { return Date.now(); },
+      }
     },
 
     attached: function() {
       this.listen(document, 'server-error', '_handleServerError');
       this.listen(document, 'network-error', '_handleNetworkError');
+      this.listen(document, 'show-alert', '_handleShowAlert');
+      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
     },
 
     detached: function() {
       this._clearHideAlertHandle();
       this.unlisten(document, 'server-error', '_handleServerError');
       this.unlisten(document, 'network-error', '_handleNetworkError');
+      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    },
+
+    _shouldSuppressError: function(msg) {
+      return msg.indexOf(TOO_MANY_FILES) > -1;
     },
 
     _handleServerError: function(e) {
@@ -49,11 +80,17 @@
         }.bind(this));
       } else {
         e.detail.response.text().then(function(text) {
-          this._showAlert('Server error: ' + text);
+          if (!this._shouldSuppressError(text)) {
+            this._showAlert('Server error: ' + text);
+          }
         }.bind(this));
       }
     },
 
+    _handleShowAlert: function(e) {
+      this._showAlert(e.detail.message);
+    },
+
     _handleNetworkError: function(e) {
       this._showAlert('Server unavailable');
       console.error(e.detail.error.message);
@@ -96,9 +133,7 @@
       this._alertElement.show('Auth error', 'Refresh credentials.');
       this.listen(this._alertElement, 'action', '_createLoginPopup');
 
-      if (typeof document.hidden !== undefined) {
-        this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-      }
+      this._refreshingCredentials = true;
       this._requestCheckLoggedIn();
       if (!document.hidden) {
         this._handleVisibilityChange();
@@ -112,8 +147,24 @@
     },
 
     _handleVisibilityChange: function() {
-      if (!document.hidden) {
+      // Ignore when the page is transitioning to hidden (or hidden is
+      // undefined).
+      if (document.hidden !== false) { return; }
+
+      // If we're currently in a credential refresh, flush the debouncer so that
+      // it can be checked immediately.
+      if (this._refreshingCredentials) {
         this.flushDebouncer('checkLoggedIn');
+        return;
+      }
+
+      // If the credentials are old, request them to confirm their validity or
+      // (display an auth toast if it fails).
+      var timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+      if (this.knownAccountId !== undefined &&
+          timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
+        this._lastCredentialCheck = Date.now();
+        this.$.restAPI.checkCredentials();
       }
     },
 
@@ -123,16 +174,32 @@
     },
 
     _checkSignedIn: function() {
-      this.$.restAPI.refreshCredentials().then(function(isLoggedIn) {
-        if (isLoggedIn) {
-          this._handleCredentialRefresh();
-        } else {
-          this._requestCheckLoggedIn();
+      this.$.restAPI.checkCredentials().then(function(account) {
+        var isLoggedIn = !!account;
+        this._lastCredentialCheck = Date.now();
+        if (this._refreshingCredentials) {
+          if (isLoggedIn) {
+
+            // If the credentials were refreshed but the account is different
+            // then reload the page completely.
+            if (account._account_id !== this.knownAccountId) {
+              this._reloadPage();
+              return;
+            }
+
+            this._handleCredentialRefreshed();
+          } else {
+            this._requestCheckLoggedIn();
+          }
         }
       }.bind(this));
     },
 
-    _createLoginPopup: function(e) {
+    _reloadPage: function() {
+      window.location.reload();
+    },
+
+    _createLoginPopup: function() {
       var left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
       var top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
       var options = [
@@ -141,11 +208,12 @@
         'left=' + left,
         'top=' + top,
       ];
-      window.open('/login/%3FcloseAfterLogin', '_blank', options.join(','));
+      window.open(this.getBaseUrl() +
+          '/login/%3FcloseAfterLogin', '_blank', options.join(','));
     },
 
-    _handleCredentialRefresh: function() {
-      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    _handleCredentialRefreshed: function() {
+      this._refreshingCredentials = false;
       this.unlisten(this._alertElement, 'action', '_createLoginPopup');
       this._hideAlert();
       this._showAlert('Credentials refreshed.');
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index f633a7e..2013686 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-error-manager</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-error-manager.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-error-manager></gr-error-manager>
@@ -70,6 +72,20 @@
       });
     });
 
+    test('suppress TOO_MANY_FILES error', function(done) {
+      var showAlertStub = sandbox.stub(element, '_showAlert');
+      var textSpy = sandbox.spy(function() {
+        return Promise.resolve('too many files to find conflicts');
+      });
+      element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+      assert.isTrue(textSpy.called);
+      textSpy.lastCall.returnValue.then(function() {
+        assert.isFalse(showAlertStub.called);
+        done();
+      });
+    });
+
     test('show network error', function(done) {
       var consoleErrorStub = sandbox.stub(console, 'error');
       var showAlertStub = sandbox.stub(element, '_showAlert');
@@ -85,7 +101,7 @@
     });
 
     test('show auth refresh toast', function(done) {
-      var refreshStub = sandbox.stub(element.$.restAPI, 'refreshCredentials',
+      var refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
           function() { return Promise.resolve(true); });
       var toastSpy = sandbox.spy(element, '_createToastAlert');
       var windowOpen = sandbox.stub(window, 'open');
@@ -103,6 +119,10 @@
         toast.fire('action');
         assert.isTrue(windowOpen.called);
 
+        // @see Issue 5822: noopener breaks closeAfterLogin
+        assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
+            -1);
+
         var hideToastSpy = sandbox.spy(toast, 'hide');
 
         assert.isTrue(refreshStub.called);
@@ -120,5 +140,93 @@
         });
       });
     });
+
+    test('show alert', function() {
+      sandbox.stub(element, '_showAlert');
+      element.fire('show-alert', {message: 'foo'});
+      assert.isTrue(element._showAlert.calledOnce);
+      assert.isTrue(element._showAlert.lastCall.calledWithExactly('foo'));
+    });
+
+    test('checks stale credentials on visibility change', function() {
+      var refreshStub = sandbox.stub(element.$.restAPI,
+          'checkCredentials');
+      sandbox.stub(Date, 'now').returns(999999);
+      element._lastCredentialCheck = 0;
+      element._handleVisibilityChange();
+
+      // Since there is no known account, it should not test credentials.
+      assert.isFalse(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 0);
+
+      element.knownAccountId = 123;
+      element._handleVisibilityChange();
+
+      // Should test credentials, since there is a known account.
+      assert.isTrue(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 999999);
+    });
+
+    test('refresh loop continues on credential fail', function(done) {
+      var accountPromise = Promise.resolve(null);
+      sandbox.stub(element.$.restAPI, 'checkCredentials')
+          .returns(accountPromise);
+      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      var handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      var reloadStub = sandbox.stub(element, '_reloadPage');
+
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      accountPromise.then(function() {
+        assert.isTrue(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+
+    test('refreshes with same credentials', function(done) {
+      var accountPromise = Promise.resolve({_account_id: 1234});
+      sandbox.stub(element.$.restAPI, 'checkCredentials')
+          .returns(accountPromise);
+      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      var handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      var reloadStub = sandbox.stub(element, '_reloadPage');
+
+      element.knownAccountId = 1234;
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      accountPromise.then(function() {
+        assert.isFalse(requestCheckStub.called);
+        assert.isTrue(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+
+    test('reloads when refreshed credentials differ', function(done) {
+      var accountPromise = Promise.resolve({_account_id: 1234});
+      sandbox.stub(element.$.restAPI, 'checkCredentials')
+          .returns(accountPromise);
+      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      var handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      var reloadStub = sandbox.stub(element, '_reloadPage');
+
+      element.knownAccountId = 4321; // Different from 1234
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      accountPromise.then(function() {
+        assert.isFalse(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isTrue(reloadStub.called);
+        done();
+      });
+    });
   });
 </script>
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 7291199..9a3a267 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
@@ -96,14 +96,7 @@
           </tr>
           <tr>
             <td><span class="key">u</span></td>
-            <td>Up to change list</td>
-          </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">i</span>
-            </td>
-            <td>Show/hide inline diffs</td>
+            <td>Up to dashboard</td>
           </tr>
         </tbody>
         <!-- Diff View -->
@@ -120,6 +113,20 @@
             <td>Show previous file</td>
           </tr>
           <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">j</span>
+            </td>
+            <td>Show next file that has comments</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">k</span>
+            </td>
+            <td>Show previous file that has comments</td>
+          </tr>
+          <tr>
             <td><span class="key">u</span></td>
             <td>Up to change</td>
           </tr>
@@ -127,8 +134,8 @@
       </table>
 
       <table>
-        <!-- Change List and Dashboard -->
-        <tbody hidden$="[[!_computeInChangeListView(view)]]" hidden>
+        <!-- Change List -->
+        <tbody hidden$="[[!_computeInView(view, 'gr-change-list-view')]]" hidden>
           <tr>
             <td></td><td class="header">Change list</td>
           </tr>
@@ -141,6 +148,35 @@
             <td>Show previous change</td>
           </tr>
           <tr>
+            <td><span class="key">n</span> or <span class="key">]</span></td>
+            <td>Go to next page</td>
+          </tr>
+          <tr>
+            <td><span class="key">p</span> or <span class="key">[</span></td>
+            <td>Go to previous page</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key">Enter</span> or
+              <span class="key">o</span>
+            </td>
+            <td>Show selected change</td>
+          </tr>
+        </tbody>
+        <!-- Dashboard -->
+        <tbody hidden$="[[!_computeInView(view, 'gr-dashboard-view')]]" hidden>
+          <tr>
+            <td></td><td class="header">Dashboard</td>
+          </tr>
+          <tr>
+            <td><span class="key">j</span></td>
+            <td>Select next change</td>
+          </tr>
+          <tr>
+            <td><span class="key">k</span></td>
+            <td>Show previous change</td>
+          </tr>
+          <tr>
             <td>
               <span class="key">Enter</span> or
               <span class="key">o</span>
@@ -155,7 +191,18 @@
           </tr>
           <tr>
             <td><span class="key">a</span></td>
-            <td>Review and publish comments</td>
+            <td>Open reply dialog to publish comments and add reviewers</td>
+          </tr>
+          <tr>
+            <td><span class="key">d</span></td>
+            <td>Open download overlay</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">r</span>
+            </td>
+            <td>Reload the change at the latest patch</td>
           </tr>
           <tr>
             <td></td><td class="header">File list</td>
@@ -173,6 +220,17 @@
             <td>Show selected file</td>
           </tr>
           <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">i</span>
+            </td>
+            <td>Show/hide all inline diffs</td>
+          </tr>
+          <tr>
+            <td><span class="key">i</span></td>
+            <td>Show/hide selected inline diff</td>
+          </tr>
+          <tr>
             <td></td><td class="header">Diffs</td>
           </tr>
           <tr>
@@ -206,6 +264,17 @@
             <td>Go to previous comment thread</td>
           </tr>
           <tr>
+            <td><span class="key">e</span></td>
+            <td>Expand all comment threads</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">e</span>
+            </td>
+            <td>Collapse all comment threads</td>
+          </tr>
+          <tr>
             <td>
               <span class="key modifier">Shift</span>
               <span class="key">←</span>
@@ -269,6 +338,17 @@
             <td>Show previous comment thread</td>
           </tr>
           <tr>
+            <td><span class="key">e</span></td>
+            <td>Expand all comment threads</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">e</span>
+            </td>
+            <td>Collapse all comment threads</td>
+          </tr>
+          <tr>
             <td>
               <span class="key modifier">Shift</span>
               <span class="key">←</span>
@@ -296,8 +376,19 @@
             <td>Draft new comment</td>
           </tr>
           <tr>
+            <td>
+              <span class="key modifier">Ctrl</span>
+              <span class="key">s</span><br/>
+              <span class="key modifier">Ctrl</span>
+              <span class="key">Enter</span><br/>
+              <span class="key modifier">Meta</span>
+              <span class="key">Enter</span>
+            </td>
+            <td>Save comment</td>
+          </tr>
+          <tr>
             <td><span class="key">a</span></td>
-            <td>Review and publish comments</td>
+            <td>Open reply dialog to publish comments and add reviewers</td>
           </tr>
           <tr>
             <td><span class="key">,</span></td>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index 7ed5012..1a286c7 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -32,12 +32,7 @@
     },
 
     _computeInView: function(currentView, view) {
-      return view == currentView;
-    },
-
-    _computeInChangeListView: function(currentView) {
-      return currentView == 'gr-change-list-view' ||
-          currentView == 'gr-dashboard-view';
+      return view === currentView;
     },
 
     _handleCloseTap: function(e) {
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 930c8cf..1e6596b 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
@@ -14,11 +14,12 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
 <link rel="import" href="../gr-account-dropdown/gr-account-dropdown.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../gr-search-bar/gr-search-bar.html">
 
 <dom-module id="gr-main-header">
@@ -42,65 +43,28 @@
       ul {
         list-style: none;
       }
-      .links {
-        margin-left: 1em;
-      }
-      .links ul {
-        display: none;
-      }
       .links > li {
         cursor: default;
         display: inline-block;
         margin-left: 1em;
-        padding: .4em 0;
+        padding: 0;
         position: relative;
       }
-      .links li:hover ul {
-        background-color: #fff;
-        box-shadow: 0 1px 1px rgba(0, 0, 0, .3);
-        display: block;
-        left: -.75em;
-        position: absolute;
-        top: 2em;
-        z-index: 1000;
-      }
-      .links li ul li a:link,
-      .links li ul li a:visited {
-        color: #00e;
-        display: block;
-        padding: .5em .75em;
-        text-decoration: none;
-        white-space: nowrap;
-      }
-      .links li ul li:hover a {
-        background-color: var(--selection-background-color);
-      }
       .linksTitle {
+        color: black;
         display: inline-block;
-        padding-right: 1em;
         position: relative;
       }
-      .downArrow {
-        border-left: .36em solid transparent;
-        border-right: .36em solid transparent;
-        border-top: .36em solid #ccc;
-        height: 0;
-        position: absolute;
-        right: 0;
-        top: calc(50% - .1em);
-        width: 0;
-      }
-      .links li:hover .downArrow {
-        border-top-color: #666;
-      }
       .rightItems {
+        align-items: center;
         display: flex;
         flex: 1;
         justify-content: flex-end;
       }
       gr-search-bar {
+        flex-grow: 1;
         margin-left: .5em;
-        width: 500px;
+        max-width: 500px;
       }
       .accountContainer:not(.loggedIn):not(.loggedOut) .loginButton,
       .accountContainer:not(.loggedIn):not(.loggedOut) gr-account-dropdown,
@@ -116,6 +80,13 @@
         overflow: hidden;
         text-overflow: ellipsis;
       }
+      .dropdown-trigger {
+        text-decoration: none;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
       @media screen and (max-width: 50em) {
         .bigTitle {
           font-size: 14px;
@@ -134,14 +105,15 @@
       <ul class="links">
         <template is="dom-repeat" items="[[_links]]" as="linkGroup">
           <li>
-            <span class="linksTitle">
-              [[linkGroup.title]] <i class="downArrow"></i>
+          <gr-dropdown
+              link
+              down-arrow
+              items = [[linkGroup.links]]
+              horizontal-align="left">
+            <span class="linksTitle" id="[[linkGroup.title]]">
+              [[linkGroup.title]]
             </span>
-            <ul>
-              <template is="dom-repeat" items="[[linkGroup.links]]" as="link">
-                <li><a href$="[[link.url]]">[[link.name]]</a></li>
-              </template>
-            </ul>
+          </gr-dropdown>
           </li>
         </template>
       </ul>
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 6fc3cc1..3e7c82a 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
@@ -14,6 +14,32 @@
 (function() {
   'use strict';
 
+  var ADMIN_LINKS = [
+    {
+      url: '/admin/groups',
+      name: 'Groups',
+    },
+    {
+      url: '/admin/create-group',
+      name: 'Create Group',
+      capability: 'createGroup'
+    },
+    {
+      url: '/admin/projects',
+      name: 'Projects',
+    },
+    {
+      url: '/admin/create-project',
+      name: 'Create Project',
+      capability: 'createProject',
+    },
+    {
+      url: '/admin/plugins',
+      name: 'Plugins',
+      capability: 'viewPlugins',
+    },
+  ];
+
   var DEFAULT_LINKS = [{
     title: 'Changes',
     links: [
@@ -32,11 +58,38 @@
     ],
   }];
 
+  var DOCUMENTATION_LINKS = [
+    {
+      url : '/index.html',
+      name : 'Table of Contents',
+    },
+    {
+      url : '/user-search.html',
+      name : 'Searching',
+    },
+    {
+      url : '/user-upload.html',
+      name : 'Uploading',
+    },
+    {
+      url : '/access-control.html',
+      name : 'Access Control',
+    },
+    {
+      url : '/rest-api.html',
+      name : 'REST API',
+    },
+    {
+      url : '/intro-project-owner.html',
+      name : 'Project Owner Guide',
+    }
+  ];
+
   Polymer({
     is: 'gr-main-header',
 
     hostAttributes: {
-      role: 'banner'
+      role: 'banner',
     },
 
     properties: {
@@ -46,15 +99,23 @@
       },
 
       _account: Object,
+      _adminLinks: {
+        type: Array,
+        value: function() { return []; },
+      },
       _defaultLinks: {
         type: Array,
         value: function() {
           return DEFAULT_LINKS;
         },
       },
+      _docBaseUrl: {
+        type: String,
+      },
       _links: {
         type: Array,
-        computed: '_computeLinks(_defaultLinks, _userLinks)',
+        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
+            '_docBaseUrl)',
       },
       _loginURL: {
         type: String,
@@ -66,12 +127,17 @@
       },
     },
 
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
     observers: [
       '_accountLoaded(_account)',
     ],
 
     attached: function() {
       this._loadAccount();
+      this._loadConfig();
       this.listen(window, 'location-change', '_handleLocationChange');
     },
 
@@ -79,18 +145,31 @@
       this.unlisten(window, 'location-change', '_handleLocationChange');
     },
 
+    reload: function() {
+      this._loadAccount();
+    },
+
     _handleLocationChange: function(e) {
-      this._loginURL = '/login/' + encodeURIComponent(
-          window.location.pathname +
-          window.location.search +
-          window.location.hash);
+      if (this.getBaseUrl()) {
+        // Strip the canonical path from the path since needing canonical in
+        // the path is uneeded and breaks the url.
+        this._loginURL = this.getBaseUrl() + '/login/' + encodeURIComponent(
+            '/' + window.location.pathname.substring(this.getBaseUrl().length) +
+            window.location.search +
+            window.location.hash);
+      } else {
+        this._loginURL = '/login/' + encodeURIComponent(
+            window.location.pathname +
+            window.location.search +
+            window.location.hash);
+      }
     },
 
     _computeRelativeURL: function(path) {
-      return '//' + window.location.host + path;
+      return '//' + window.location.host + this.getBaseUrl() + path;
     },
 
-    _computeLinks: function(defaultLinks, userLinks) {
+    _computeLinks: function(defaultLinks, userLinks, adminLinks, docBaseUrl) {
       var links = defaultLinks.slice();
       if (userLinks && userLinks.length > 0) {
         links.push({
@@ -98,9 +177,39 @@
           links: userLinks,
         });
       }
+      if (adminLinks && adminLinks.length > 0) {
+        links.push({
+          title: 'Admin',
+          links: adminLinks,
+        });
+      }
+      var docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+      if (docLinks.length) {
+        links.push({
+          title: 'Documentation',
+          links: docLinks,
+        });
+      }
       return links;
     },
 
+    _getDocLinks: function(docBaseUrl, docLinks) {
+      if (!docBaseUrl || !docLinks) {
+        return [];
+      }
+      return docLinks.map(function(link) {
+        var url = docBaseUrl;
+        if (url && url[url.length - 1] === '/') {
+          url = url.substring(0, url.length - 1);
+        }
+        return {
+          url: url + link.url,
+          name: link.name,
+          target: '_blank',
+        };
+      });
+    },
+
     _loadAccount: function() {
       this.$.restAPI.getAccount().then(function(account) {
         this._account = account;
@@ -109,19 +218,68 @@
       }.bind(this));
     },
 
+    _loadConfig: function() {
+      this.$.restAPI.getConfig().then(function(config) {
+        if (config && config.gerrit && config.gerrit.doc_url) {
+          this._docBaseUrl = config.gerrit.doc_url;
+        }
+        if (!this._docBaseUrl) {
+          return this._probeDocLink('/Documentation/index.html');
+        }
+      }.bind(this));
+    },
+
+    _probeDocLink: function(path) {
+      return this.$.restAPI.probePath(this.getBaseUrl() + path).then(function(ok) {
+        if (ok) {
+          this._docBaseUrl = this.getBaseUrl() + '/Documentation';
+        } else {
+          this._docBaseUrl = null;
+        }
+      }.bind(this));
+    },
+
     _accountLoaded: function(account) {
       if (!account) { return; }
 
       this.$.restAPI.getPreferences().then(function(prefs) {
         this._userLinks =
-            prefs.my.map(this._stripHashPrefix).filter(this._isSupportedLink);
+            prefs.my.map(this._fixMyMenuItem).filter(this._isSupportedLink);
+      }.bind(this));
+      this._loadAccountCapabilities();
+    },
+
+    _loadAccountCapabilities: function() {
+      var params = ['createProject', 'createGroup', 'viewPlugins'];
+      return this.$.restAPI.getAccountCapabilities(params)
+          .then(function(capabilities) {
+        this._adminLinks = ADMIN_LINKS.filter(function(link) {
+          return !link.capability ||
+              capabilities.hasOwnProperty(link.capability);
+        });
       }.bind(this));
     },
 
-    _stripHashPrefix: function(linkObj) {
+    _fixMyMenuItem: function(linkObj) {
+      // Normalize all urls to PolyGerrit style.
       if (linkObj.url.indexOf('#') === 0) {
         linkObj.url = linkObj.url.slice(1);
       }
+
+      // Delete target property due to complications of
+      // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+      //
+      // The server tries to guess whether URL is a view within the UI.
+      // If not, it sets target='_blank' on the menu item. The server
+      // makes assumptions that work for the GWT UI, but not PolyGerrit,
+      // so we'll just disable it altogether for now.
+      delete linkObj.target;
+
+      // Becasue the "my menu" links may be arbitrary URLs, we don't know
+      // whether they correspond to any client routes. Mark all such links as
+      // external.
+      linkObj.external = true;
+
       return linkObj;
     },
 
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index 0b40d87..bea5736 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-main-header</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-main-header.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-main-header></gr-main-header>
@@ -33,22 +35,34 @@
 <script>
   suite('gr-main-header tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        probePath: function(path) { return Promise.resolve(false); },
+      });
       stub('gr-main-header', {
         _loadAccount: function() {},
       });
       element = fixture('basic');
     });
 
-    test('strip hash prefix', function() {
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('fix my menu item', function() {
       assert.deepEqual([
         {url: '#/q/owner:self+is:draft'},
         {url: 'https://awesometown.com/#hashyhash'},
-      ].map(element._stripHashPrefix),
+        {url: 'url', target: '_blank'},
+      ].map(element._fixMyMenuItem),
       [
-        {url: '/q/owner:self+is:draft'},
-        {url: 'https://awesometown.com/#hashyhash'},
+        {url: '/q/owner:self+is:draft', external: true},
+        {url: 'https://awesometown.com/#hashyhash', external: true},
+        {url: 'url', external: true},
       ]);
     });
 
@@ -66,6 +80,30 @@
       ]);
     });
 
+    test('_loadAccountCapabilities admin', function(done) {
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', function() {
+        return Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        });
+      });
+      element._loadAccountCapabilities().then(function() {
+        assert.equal(element._adminLinks.length, 5);
+        done();
+      });
+    });
+
+    test('_loadAccountCapabilities non admin', function(done) {
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', function() {
+        return Promise.resolve({});
+      });
+      element._loadAccountCapabilities().then(function() {
+        assert.equal(element._adminLinks.length, 2);
+        done();
+      });
+    });
+
     test('user links', function() {
       var defaultLinks = [{
         title: 'Faves',
@@ -78,12 +116,48 @@
         name: 'Facebook',
         url: 'https://facebook.com',
       }];
-      assert.deepEqual(element._computeLinks(defaultLinks, []), defaultLinks);
-      assert.deepEqual(element._computeLinks(defaultLinks, userLinks),
+      var adminLinks = [{
+        url: '/admin/groups',
+        name: 'Groups',
+      }];
+
+      assert.deepEqual(
+          element._computeLinks(defaultLinks, [], []), defaultLinks);
+      assert.deepEqual(
+          element._computeLinks(defaultLinks, userLinks, adminLinks),
           defaultLinks.concat({
             title: 'Your',
             links: userLinks,
+          }, {
+            title: 'Admin',
+            links: adminLinks,
           }));
     });
+
+    test('documentation links', function() {
+      var docLinks = [
+        {
+          name: 'Table of Contents',
+          url: '/index.html',
+        },
+      ];
+
+      assert.deepEqual(element._getDocLinks(null, docLinks), []);
+      assert.deepEqual(element._getDocLinks('', docLinks), []);
+      assert.deepEqual(element._getDocLinks('base', null), []);
+      assert.deepEqual(element._getDocLinks('base', []), []);
+
+      assert.deepEqual(element._getDocLinks('base', docLinks), [{
+        name: 'Table of Contents',
+        target: '_blank',
+        url: 'base/index.html',
+      }]);
+
+      assert.deepEqual(element._getDocLinks('base/', docLinks), [{
+        name: 'Table of Contents',
+        target: '_blank',
+        url: 'base/index.html',
+      }]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
new file mode 100644
index 0000000..d5d33ab
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
@@ -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.
+-->
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-reporting">
+  <script src="gr-reporting.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
new file mode 100644
index 0000000..1f96014
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -0,0 +1,208 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  // Latency reporting constants.
+  var TIMING = {
+    TYPE: 'timing-report',
+    CATEGORY: 'UI Latency',
+    // Reported events - alphabetize below.
+    APP_STARTED: 'App Started',
+    PAGE_LOADED: 'Page Loaded',
+  };
+
+  // Navigation reporting constants.
+  var NAVIGATION = {
+    TYPE: 'nav-report',
+    CATEGORY: 'Location Changed',
+    PAGE: 'Page',
+  };
+
+  var ERROR = {
+    TYPE: 'error',
+    CATEGORY: 'exception',
+  };
+
+  var INTERACTION_TYPE = 'interaction';
+
+  var CHANGE_VIEW_REGEX = /^\/c\/\d+\/?\d*$/;
+  var DIFF_VIEW_REGEX = /^\/c\/\d+\/\d+\/.+$/;
+
+  var pending = [];
+
+  var onError = function(oldOnError, msg, url, line, column, error) {
+    if (oldOnError) {
+      oldOnError(msg, url, line, column, error);
+    }
+    if (error) {
+      line = line || error.lineNumber;
+      column = column || error.columnNumber;
+      msg = msg || error.toString();
+    }
+    var payload = {
+      url: url,
+      line: line,
+      column: column,
+      error: error,
+    };
+    GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
+    return true;
+  };
+
+  var catchErrors = function(opt_context) {
+    var context = opt_context || window;
+    context.onerror = onError.bind(null, context.onerror);
+  };
+  catchErrors();
+
+  var GrReporting = Polymer({
+    is: 'gr-reporting',
+
+    properties: {
+      category: String,
+
+      _baselines: {
+        type: Array,
+        value: function() { return {}; },
+      },
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
+    get performanceTiming() {
+      return window.performance.timing;
+    },
+
+    now: function() {
+      return Math.round(10 * window.performance.now()) / 10;
+    },
+
+    reporter: function() {
+      var report = (Gerrit._arePluginsLoaded() && !pending.length) ?
+        this.defaultReporter : this.cachingReporter;
+      report.apply(this, arguments);
+    },
+
+    defaultReporter: function(type, category, eventName, eventValue) {
+      var detail = {
+        type: type,
+        category: category,
+        name: eventName,
+        value: eventValue,
+      };
+      document.dispatchEvent(new CustomEvent(type, {detail: detail}));
+      if (type === ERROR.TYPE) {
+        console.error(eventValue.error || eventName);
+      } else {
+        console.log(eventName + (eventValue !== undefined ?
+            (': ' + eventValue) : ''));
+      }
+    },
+
+    cachingReporter: function(type, category, eventName, eventValue) {
+      if (type === ERROR.TYPE) {
+        console.error(eventValue.error || eventName);
+      }
+      if (Gerrit._arePluginsLoaded()) {
+        if (pending.length) {
+          pending.splice(0).forEach(function(args) {
+            this.reporter.apply(this, args);
+          }, this);
+        }
+        this.reporter(type, category, eventName, eventValue);
+      } else {
+        pending.push([type, category, eventName, eventValue]);
+      }
+    },
+
+    /**
+     * User-perceived app start time, should be reported when the app is ready.
+     */
+    appStarted: function() {
+      var startTime =
+          new Date().getTime() - this.performanceTiming.navigationStart;
+      this.reporter(
+          TIMING.TYPE, TIMING.CATEGORY, TIMING.APP_STARTED, startTime);
+    },
+
+    /**
+     * Page load time, should be reported at any time after navigation.
+     */
+    pageLoaded: function() {
+      if (this.performanceTiming.loadEventEnd === 0) {
+        console.error('pageLoaded should be called after window.onload');
+        this.async(this.pageLoaded, 100);
+      } else {
+        var loadTime = this.performanceTiming.loadEventEnd -
+            this.performanceTiming.navigationStart;
+        this.reporter(
+          TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
+      }
+    },
+
+    locationChanged: function() {
+      var page = '';
+      var pathname = this._getPathname();
+      if (pathname.indexOf('/q/') === 0) {
+        page = this.getBaseUrl() + '/q/';
+      } else if (pathname.match(CHANGE_VIEW_REGEX)) { // change view
+        page = this.getBaseUrl() + '/c/';
+      } else if (pathname.match(DIFF_VIEW_REGEX)) { // diff view
+        page = this.getBaseUrl() + '/c//COMMIT_MSG';
+      } else {
+        // Ignore other page changes.
+        return;
+      }
+      this.reporter(
+          NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
+    },
+
+    pluginsLoaded: function() {
+      this.timeEnd('PluginsLoaded');
+    },
+
+    _getPathname: function() {
+      return '/' + window.location.pathname.substring(this.getBaseUrl().length);
+    },
+
+    /**
+     * Reset named timer.
+     */
+    time: function(name) {
+      this._baselines[name] = this.now();
+    },
+
+    /**
+     * Finish named timer and report it to server.
+     */
+    timeEnd: function(name) {
+      var baseTime = this._baselines[name] || 0;
+      var time = this.now() - baseTime;
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY, name, time);
+      delete this._baselines[name];
+    },
+
+    reportInteraction: function(eventName, opt_msg) {
+      this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
+    },
+  });
+
+  window.GrReporting = GrReporting;
+  // Expose onerror installation so it would be accessible from tests.
+  window.GrReporting._catchErrors = catchErrors;
+
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
new file mode 100644
index 0000000..2720ebd
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -0,0 +1,210 @@
+<!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-reporting</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-reporting.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-reporting></gr-reporting>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reporting tests', function() {
+    var element;
+    var sandbox;
+    var clock;
+    var fakePerformance;
+
+    var NOW_TIME = 100;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      clock = sinon.useFakeTimers(NOW_TIME);
+      element = fixture('basic');
+      fakePerformance = {
+        navigationStart: 1,
+        loadEventEnd: 2,
+      };
+      sinon.stub(element, 'performanceTiming',
+          {get: function() {return fakePerformance;}});
+      sandbox.stub(element, 'reporter');
+    });
+    teardown(function() {
+      sandbox.restore();
+      clock.restore();
+    });
+
+    test('appStarted', function() {
+      element.appStarted();
+      assert.isTrue(
+          element.reporter.calledWithExactly(
+              'timing-report', 'UI Latency', 'App Started',
+              NOW_TIME - fakePerformance.navigationStart
+      ));
+    });
+
+    test('pageLoaded', function() {
+      element.pageLoaded();
+      assert.isTrue(
+          element.reporter.calledWithExactly(
+              'timing-report', 'UI Latency', 'Page Loaded',
+              fakePerformance.loadEventEnd - fakePerformance.navigationStart)
+      );
+    });
+
+    test('time and timeEnd', function() {
+      var nowStub = sandbox.stub(element, 'now').returns(0);
+      element.time('foo');
+      nowStub.returns(1);
+      element.time('bar');
+      nowStub.returns(2);
+      element.timeEnd('bar');
+      nowStub.returns(3.123);
+      element.timeEnd('foo');
+      assert.isTrue(element.reporter.calledWithExactly(
+          'timing-report', 'UI Latency', 'foo', 3.123
+      ));
+      assert.isTrue(element.reporter.calledWithExactly(
+          'timing-report', 'UI Latency', 'bar', 1
+      ));
+    });
+
+    suite('plugins', function() {
+      setup(function() {
+        element.reporter.restore();
+        sandbox.stub(element, 'defaultReporter');
+        sandbox.stub(Gerrit, '_arePluginsLoaded');
+      });
+
+      test('pluginsLoaded reports time', function() {
+        Gerrit._arePluginsLoaded.returns(true);
+        sandbox.stub(element, 'now').returns(42);
+        element.pluginsLoaded();
+        assert.isTrue(element.defaultReporter.calledWithExactly(
+            'timing-report', 'UI Latency', 'PluginsLoaded', 42
+        ));
+      });
+
+      test('caches reports if plugins are not loaded', function() {
+        Gerrit._arePluginsLoaded.returns(false);
+        element.timeEnd('foo');
+        assert.isFalse(element.defaultReporter.called);
+      });
+
+      test('reports if plugins are loaded', function() {
+        Gerrit._arePluginsLoaded.returns(true);
+        element.timeEnd('foo');
+        assert.isTrue(element.defaultReporter.called);
+      });
+
+      test('reports cached events preserving order', function() {
+        Gerrit._arePluginsLoaded.returns(false);
+        element.timeEnd('foo');
+        Gerrit._arePluginsLoaded.returns(true);
+        element.timeEnd('bar');
+        assert.isTrue(element.defaultReporter.firstCall.calledWith(
+            'timing-report', 'UI Latency', 'foo'
+        ));
+        assert.isTrue(element.defaultReporter.secondCall.calledWith(
+            'timing-report', 'UI Latency', 'bar'
+        ));
+      });
+    });
+
+    suite('location changed', function() {
+      var pathnameStub;
+      setup(function() {
+        pathnameStub = sinon.stub(element, '_getPathname');
+      });
+
+      teardown(function() {
+        pathnameStub.restore();
+      });
+
+      test('search', function() {
+        pathnameStub.returns('/q/foo');
+        element.locationChanged();
+        assert.isTrue(element.reporter.calledWithExactly(
+            'nav-report', 'Location Changed', 'Page', '/q/'));
+      });
+
+      test('change view', function() {
+        pathnameStub.returns('/c/42/');
+        element.locationChanged();
+        assert.isTrue(element.reporter.calledWithExactly(
+            'nav-report', 'Location Changed', 'Page', '/c/'));
+      });
+
+      test('change view', function() {
+        pathnameStub.returns('/c/41/2');
+        element.locationChanged();
+        assert.isTrue(element.reporter.calledWithExactly(
+            'nav-report', 'Location Changed', 'Page', '/c/'));
+      });
+
+      test('diff view', function() {
+        pathnameStub.returns('/c/41/2/file.txt');
+        element.locationChanged();
+        assert.isTrue(element.reporter.calledWithExactly(
+            'nav-report', 'Location Changed', 'Page', '/c//COMMIT_MSG'));
+      });
+    });
+
+    suite('exception logging', function() {
+      var fakeWindow;
+      var reporter;
+
+      var emulateThrow = function(msg, url, line, column, error) {
+        return fakeWindow.onerror(msg, url, line, column, error);
+      };
+
+      setup(function() {
+        reporter = sandbox.stub(GrReporting.prototype, 'reporter');
+        fakeWindow = {};
+        sandbox.stub(console, 'error');
+        window.GrReporting._catchErrors(fakeWindow);
+      });
+
+      test('is reported', function() {
+        var error = new Error('bar');
+        emulateThrow('bar', 'http://url', 4, 2, error);
+        assert.isTrue(
+            reporter.calledWith('error', 'exception', 'bar'));
+        var payload = reporter.lastCall.args[3];
+        assert.deepEqual(payload, {
+          url: 'http://url',
+          line: 4,
+          column: 2,
+          error: error,
+        });
+      });
+
+      test('prevent default event handler', function() {
+        assert.isTrue(emulateThrow());
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.html b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
index 2971ed2..5a494b1 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
@@ -13,8 +13,12 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-reporting/gr-reporting.html">
 
-<script src="../../../bower_components/page/page.js"></script>
-<script src="gr-router.js"></script>
+<dom-module id="gr-router">
+  <script src="../../../bower_components/page/page.js"></script>
+  <script src="gr-router.js"></script>
+</dom-module>
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 d11d438..cca5154 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -17,9 +17,35 @@
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
   var app = document.querySelector('#app');
-  var restAPI = document.createElement('gr-rest-api-interface');
+  if (!app) {
+    console.log('No gr-app found (running tests)');
+  }
+
+  var _reporting;
+  function getReporting() {
+    if (!_reporting) {
+      _reporting = document.createElement('gr-reporting');
+    }
+    return _reporting;
+  }
+
+  document.onload = function() {
+    getReporting().pageLoaded();
+  };
 
   window.addEventListener('WebComponentsReady', function() {
+    getReporting().timeEnd('WebComponentsReady');
+  });
+
+  function startRouter() {
+    var base = window.Gerrit.BaseUrlBehavior.getBaseUrl();
+    if (base) {
+      page.base(base);
+    }
+
+    var restAPI = document.createElement('gr-rest-api-interface');
+    var reporting = getReporting();
+
     // Middleware
     page(function(ctx, next) {
       document.body.scrollTop = 0;
@@ -27,7 +53,11 @@
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
       app.async(function() {
-        app.fire('location-change');
+        app.fire('location-change', {
+          hash: window.location.hash,
+          pathname: window.location.pathname,
+        });
+        reporting.locationChanged();
       }, 1);
       next();
     });
@@ -46,7 +76,17 @@
       }
       // For backward compatibility with GWT links.
       if (data.hash) {
-        page.redirect(data.hash);
+        // In certain login flows the server may redirect to a hash without
+        // a leading slash, which page.js doesn't handle correctly.
+        if (data.hash[0] !== '/') {
+          data.hash = '/' + data.hash;
+        }
+        var hash = data.hash;
+        var newUrl = base + hash;
+        if (hash.indexOf('/VE/') === 0) {
+          newUrl = base + '/settings' + data.hash;
+        }
+        page.redirect(newUrl);
         return;
       }
       restAPI.getLoggedIn().then(function(loggedIn) {
@@ -69,6 +109,17 @@
       });
     });
 
+    page('/admin/(.*)', loadUser, function(data) {
+      restAPI.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          data.params.view = 'gr-admin-view';
+          app.params = data.params;
+        } else {
+          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
     function queryHandler(data) {
       data.params.view = 'gr-change-list-view';
       app.params = data.params;
@@ -88,8 +139,8 @@
       }
     }
 
-    // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>].
-    page(/^\/c\/(\d+)\/?(((\d+)(\.\.(\d+))?))?$/, function(ctx) {
+    // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
+    page(/^\/c\/(\d+)\/?(((\d+)(\.\.(\d+))?))?\/?$/, function(ctx) {
       // Parameter order is based on the regex group number matched.
       var params = {
         changeNum: ctx.params[0],
@@ -124,18 +175,59 @@
       };
       // Don't allow diffing the same patch number against itself.
       if (params.basePatchNum === params.patchNum) {
+        // TODO(kaspern): Utilize gr-url-encoding-behavior.html when the router
+        // is replaced with a Polymer counterpart.
+        // @see Issue 4255 regarding double-encoding.
+        var path = encodeURIComponent(encodeURIComponent(params.path));
+        // @see Issue 4577 regarding more readable URLs.
+        path = path.replace(/%252F/g, '/');
+        path = path.replace(/%2520/g, '+');
+
         page.redirect('/c/' +
             encodeURIComponent(params.changeNum) +
             '/' +
             encodeURIComponent(params.patchNum) +
             '/' +
-            encodeURIComponent(params.path));
+            path);
         return;
       }
+
+      // Check if path has an '@' which indicates it was using GWT style line
+      // numbers. Even if the filename had an '@' in it, it would have already
+      // been URI encoded. Redirect to hash version of path.
+      if (ctx.path.indexOf('@') !== -1) {
+        page.redirect(ctx.path.replace('@', '#'));
+        return;
+      }
+
       normalizePatchRangeParams(params);
       app.params = params;
     });
 
+    page(/^\/settings\/(agreements|new-agreement)/, loadUser, function(data) {
+      restAPI.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          data.params.view = 'gr-cla-view';
+          app.params = data.params;
+        } else {
+          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
+    page(/^\/settings\/VE\/(\S+)/, function(data) {
+      restAPI.getLoggedIn().then(function(loggedIn) {
+        if (loggedIn) {
+          app.params = {
+            view: 'gr-settings-view',
+            emailToken: data.params[0],
+          };
+        } else {
+          page.show('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
     page(/^\/settings\/?/, function(data) {
       restAPI.getLoggedIn().then(function(loggedIn) {
         if (loggedIn) {
@@ -146,6 +238,21 @@
       });
     });
 
+    page(/^\/register(\/.*)?/, function(ctx) {
+      app.params = {justRegistered: true};
+      var path = ctx.params[0] || '/';
+      if (path[0] !== '/') { return; }
+      page.show(base + path);
+    });
+
     page.start();
+  }
+
+  Polymer({
+    is: 'gr-router',
+    start: function() {
+      if (!app) { return; }
+      startRouter();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index fecb376..7a63810 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -14,10 +14,13 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
 
 <dom-module id="gr-search-bar">
   <template>
@@ -51,8 +54,10 @@
           on-commit="_handleInputCommit"
           allowNonSuggestedValues
           multi
-          borderless></gr-autocomplete>
+          borderless
+          tab-complete-without-commit></gr-autocomplete>
       <gr-button id="searchButton">Search</gr-button>
+      <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     </form>
   </template>
   <script src="gr-search-bar.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 8e52f8f..4452b04 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -16,29 +16,31 @@
 
   // Possible static search options for auto complete.
   var SEARCH_OPERATORS = [
-    'added',
-    'age',
+    'added:',
+    'age:',
     'age:1week', // Give an example age
-    'author',
-    'branch',
-    'bug',
-    'change',
-    'comment',
-    'commentby',
-    'commit',
-    'committer',
-    'conflicts',
-    'deleted',
-    'delta',
-    'file',
-    'from',
-    'has',
+    'author:',
+    'branch:',
+    'bug:',
+    'cc:',
+    'cc:self',
+    'change:',
+    'comment:',
+    'commentby:',
+    'commit:',
+    'committer:',
+    'conflicts:',
+    'deleted:',
+    'delta:',
+    'file:',
+    'from:',
+    'has:',
     'has:draft',
     'has:edit',
     'has:star',
     'has:stars',
-    'intopic',
-    'is',
+    'intopic:',
+    'is:',
     'is:abandoned',
     'is:closed',
     'is:draft',
@@ -51,22 +53,22 @@
     'is:reviewer',
     'is:starred',
     'is:watched',
-    'label',
-    'message',
-    'owner',
-    'ownerin',
-    'parentproject',
-    'project',
-    'projects',
-    'query',
-    'ref',
-    'reviewedby',
-    'reviewer',
+    'label:',
+    'message:',
+    'owner:',
+    'ownerin:',
+    'parentproject:',
+    'project:',
+    'projects:',
+    'query:',
+    'ref:',
+    'reviewedby:',
+    'reviewer:',
     'reviewer:self',
-    'reviewerin',
-    'size',
-    'star',
-    'status',
+    'reviewerin:',
+    'size:',
+    'star:',
+    'status:',
     'status:abandoned',
     'status:closed',
     'status:draft',
@@ -74,21 +76,32 @@
     'status:open',
     'status:pending',
     'status:reviewed',
-    'topic',
-    'tr',
+    'topic:',
+    'tr:',
   ];
 
+  var SELF_EXPRESSION = 'self';
+
+  var MAX_AUTOCOMPLETE_RESULTS = 10;
+
+  var TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+
   Polymer({
     is: 'gr-search-bar',
 
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     listeners: {
       'searchButton.tap': '_preventDefaultAndNavigateToInputVal',
     },
 
+    keyBindings: {
+      '/': '_handleForwardSlashKey',
+    },
+
     properties: {
       value: {
         type: String,
@@ -117,55 +130,190 @@
       this._preventDefaultAndNavigateToInputVal(e);
     },
 
+    /**
+     * This function is called in a few different cases:
+     *   - e.target is the search button
+     *   - e.target is the gr-autocomplete widget (#searchInput)
+     *   - e.target is the input element wrapped within #searchInput
+     *
+     * @param {!Event} e
+     */
     _preventDefaultAndNavigateToInputVal: function(e) {
       e.preventDefault();
-      Polymer.dom(e).rootTarget.blur();
-      // @see Issue 4255.
-      page.show('/q/' + encodeURIComponent(encodeURIComponent(this._inputVal)));
-    },
-
-    // TODO(kaspern): Flesh this out better.
-    _makeSuggestion: function(str) {
-      return {
-        name: str,
-        value: str,
-      };
-    },
-
-    // TODO(kaspern): Expand support for more complicated autocomplete features.
-    _getSearchSuggestions: function(input) {
-      return Promise.resolve(SEARCH_OPERATORS).then(function(operators) {
-        if (!operators) { return []; }
-        var lowerCaseInput = input
-            .substring(input.lastIndexOf(' ') + 1)
-            .toLowerCase();
-        return operators
-            .filter(function(operator) {
-              // Disallow autocomplete values that exactly match the whole str.
-              var opContainsInput = operator.indexOf(lowerCaseInput) !== -1;
-              var inputContainsOp = lowerCaseInput.indexOf(operator) !== -1;
-              return opContainsInput && !inputContainsOp;
-            })
-            // Prioritize results that start with the input.
-            .sort(function(operator) {
-              return operator.indexOf(lowerCaseInput);
-            })
-            .map(this._makeSuggestion);
-      }.bind(this));
-    },
-
-    _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
-      switch (e.keyCode) {
-        case 191:  // '/' or '?' with shift key.
-          // TODO(andybons): Localization using e.key/keypress event.
-          if (e.shiftKey) { break; }
-          e.preventDefault();
-          var s = this.$.searchInput;
-          s.focus();
-          s.setSelectionRange(0, s.value.length);
-          break;
+      var target = Polymer.dom(e).rootTarget;
+      // If the target is the #searchInput or has a sub-input component, that
+      // is what holds the focus as opposed to the target from the DOM event.
+      if (target.$.input) {
+        target.$.input.blur();
+      } else {
+        target.blur();
       }
+      if (this._inputVal) {
+        page.show('/q/' + this.encodeURL(this._inputVal, false));
+      }
+    },
+
+    /**
+     * Fetch from the API the predicted accounts.
+     * @param {string} predicate - The first part of the search term, e.g.
+     *     'owner'
+     * @param {string} expression - The second part of the search term, e.g.
+     *     'kasp'
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _fetchAccounts: function(predicate, expression) {
+      if (expression.length === 0) { return Promise.resolve([]); }
+      return this.$.restAPI.getSuggestedAccounts(
+          expression,
+          MAX_AUTOCOMPLETE_RESULTS)
+          .then(function(accounts) {
+            if (!accounts) { return []; }
+            return accounts.map(function(acct) {
+              return predicate + ':"' + acct.name + ' <' + acct.email + '>"';
+            });
+          }).then(function(accounts) {
+            // When the expression supplied is a beginning substring of 'self',
+            // add it as an autocomplete option.
+            return SELF_EXPRESSION.indexOf(expression) === 0 ?
+                accounts.concat([predicate + ':' + SELF_EXPRESSION]) :
+                accounts;
+          });
+    },
+
+    /**
+     * Fetch from the API the predicted groups.
+     * @param {string} predicate - The first part of the search term, e.g.
+     *     'ownerin'
+     * @param {string} expression - The second part of the search term, e.g.
+     *     'polyger'
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _fetchGroups: function(predicate, expression) {
+      if (expression.length === 0) { return Promise.resolve([]); }
+      return this.$.restAPI.getSuggestedGroups(
+          expression,
+          MAX_AUTOCOMPLETE_RESULTS)
+          .then(function(groups) {
+            if (!groups) { return []; }
+            var keys = Object.keys(groups);
+            return keys.map(function(key) { return predicate + ':' + key; });
+          });
+    },
+
+    /**
+     * Fetch from the API the predicted projects.
+     * @param {string} predicate - The first part of the search term, e.g.
+     *     'project'
+     * @param {string} expression - The second part of the search term, e.g.
+     *     'gerr'
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _fetchProjects: function(predicate, expression) {
+      return this.$.restAPI.getSuggestedProjects(
+          expression,
+          MAX_AUTOCOMPLETE_RESULTS)
+          .then(function(projects) {
+            if (!projects) { return []; }
+            var keys = Object.keys(projects);
+            return keys.map(function(key) { return predicate + ':' + key; });
+          });
+    },
+
+    /**
+     * Determine what array of possible suggestions should be provided
+     *     to _getSearchSuggestions.
+     * @param {string} input - The full search term, in lowercase.
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _fetchSuggestions: function(input) {
+      // Split the input on colon to get a two part predicate/expression.
+      var splitInput = input.split(':');
+      var predicate = splitInput[0];
+      var expression = splitInput[1] || '';
+      // Switch on the predicate to determine what to autocomplete.
+      switch (predicate) {
+        case 'ownerin':
+        case 'reviewerin':
+          // Fetch groups.
+          return this._fetchGroups(predicate, expression);
+
+        case 'parentproject':
+        case 'project':
+          // Fetch projects.
+          return this._fetchProjects(predicate, expression);
+
+        case 'author':
+        case 'cc':
+        case 'commentby':
+        case 'committer':
+        case 'from':
+        case 'owner':
+        case 'reviewedby':
+        case 'reviewer':
+          // Fetch accounts.
+          return this._fetchAccounts(predicate, expression);
+
+        default:
+          return Promise.resolve(SEARCH_OPERATORS
+              .filter(function(operator) {
+                return operator.indexOf(input) !== -1;
+              }));
+      }
+    },
+
+    /**
+     * Get the sorted, pruned list of suggestions for the current search query.
+     * @param {string} input - The complete search query.
+     * @return {!Promise} This returns a promise that resolves to an array of
+     *     strings.
+     */
+    _getSearchSuggestions: function(input) {
+      // Allow spaces within quoted terms.
+      var tokens = input.match(TOKENIZE_REGEX);
+      var trimmedInput = tokens[tokens.length - 1].toLowerCase();
+
+      return this._fetchSuggestions(trimmedInput)
+          .then(function(operators) {
+            if (!operators || !operators.length) { return []; }
+            return operators
+                // Prioritize results that start with the input.
+                .sort(function(a, b) {
+                  var aContains = a.toLowerCase().indexOf(trimmedInput);
+                  var bContains = b.toLowerCase().indexOf(trimmedInput);
+                  if (aContains === bContains) {
+                    return a.localeCompare(b);
+                  }
+                  if (aContains === -1) {
+                    return 1;
+                  }
+                  if (bContains === -1) {
+                    return -1;
+                  }
+                  return aContains - bContains;
+                })
+                // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
+                .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
+                // Map to an object to play nice with gr-autocomplete.
+                .map(function(operator) {
+                  return {
+                    name: operator,
+                    value: operator,
+                  };
+                });
+          });
+    },
+
+    _handleForwardSlashKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.searchInput.focus();
+      this.$.searchInput.selectAll();
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index 0c16774..3ddc96b 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -18,7 +18,7 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-search-bar</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../bower_components/page/page.js"></script>
 
@@ -26,6 +26,8 @@
 <link rel="import" href="gr-search-bar.html">
 <script src="../../../scripts/util.js"></script>
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-search-bar></gr-search-bar>
@@ -58,6 +60,7 @@
         assert.notEqual(getActiveElement(), element.$.searchButton);
         done();
       });
+      element.value = 'test';
       MockInteractions.tap(element.$.searchButton);
     });
 
@@ -68,33 +71,144 @@
         assert.notEqual(getActiveElement(), element.$.searchButton);
         done();
       });
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      element.value = 'test';
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+          null, 'enter');
     });
 
     test('search query should be double-escaped', function() {
       var showStub = sinon.stub(page, 'show');
       element.$.searchInput.text = 'fate/stay';
-      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+          null, 'enter');
       assert.equal(showStub.lastCall.args[0], '/q/fate%252Fstay');
       showStub.restore();
     });
 
-    test('_getSearchSuggestions returns proper set of suggestions',
-        function(done) {
-      element._getSearchSuggestions('is:o')
-          .then(function(suggestions) {
-            assert.equal(suggestions[0].name, 'is:open');
-            assert.equal(suggestions[0].value, 'is:open');
-            assert.equal(suggestions[1].name, 'is:owner');
-            assert.equal(suggestions[1].value, 'is:owner');
-          })
-          .then(function() {
-            element._getSearchSuggestions('asdasdasdasd')
-                .then(function(suggestions) {
-                  assert.equal(suggestions.length, 0);
-                  done();
-                });
+    test('input blurred after commit', function() {
+      var showStub = sinon.stub(page, 'show');
+      var blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
+      element.$.searchInput.text = 'fate/stay';
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+          null, 'enter');
+      assert.isTrue(blurSpy.called);
+      showStub.restore();
+      blurSpy.restore();
+    });
+
+    test('empty search query does not trigger nav', function() {
+      var showSpy = sinon.spy(page, 'show');
+      element.value = '';
+      MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+          null, 'enter');
+      assert.isFalse(showSpy.called);
+    });
+
+    test('keyboard shortcuts', function() {
+      var focusSpy = sinon.spy(element.$.searchInput, 'focus');
+      var selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
+      MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+      assert.isTrue(focusSpy.called);
+      assert.isTrue(selectAllSpy.called);
+    });
+
+    suite('_getSearchSuggestions', function() {
+      setup(function() {
+        sinon.stub(element.$.restAPI, 'getSuggestedAccounts', function() {
+          return Promise.resolve([
+            {
+              name: 'fred',
+              email: 'fred@goog.co',
+            },
+          ]);
+        });
+        sinon.stub(element.$.restAPI, 'getSuggestedGroups', function() {
+          return Promise.resolve({
+            Polygerrit: 0,
+            gerrit: 0,
+            gerrittest: 0,
           });
+        });
+        sinon.stub(element.$.restAPI, 'getSuggestedProjects', function() {
+          return Promise.resolve({
+            Polygerrit: 0,
+          });
+        });
+      });
+
+      teardown(function() {
+        element.$.restAPI.getSuggestedAccounts.restore();
+        element.$.restAPI.getSuggestedGroups.restore();
+        element.$.restAPI.getSuggestedProjects.restore();
+      });
+
+      test('Autocompletes accounts', function(done) {
+        element._getSearchSuggestions('owner:fr').then(function(s) {
+          assert.equal(s[0].value, 'owner:"fred <fred@goog.co>"');
+          done();
+        });
+      });
+
+      test('Inserts self as option when valid', function(done) {
+        element._getSearchSuggestions('owner:s').then(function(s) {
+          assert.equal(s[0].value, 'owner:self');
+        }).then(function() {
+          element._getSearchSuggestions('owner:selfs').then(function(s) {
+            assert.notEqual(s[0].value, 'owner:self');
+            done();
+          });
+        });
+      });
+
+      test('Autocompletes groups', function(done) {
+        element._getSearchSuggestions('ownerin:pol').then(function(s) {
+          assert.equal(s[0].value, 'ownerin:Polygerrit');
+          done();
+        });
+      });
+
+      test('Autocompletes projects', function(done) {
+        element._getSearchSuggestions('project:pol').then(function(s) {
+          assert.equal(s[0].value, 'project:Polygerrit');
+          done();
+        });
+      });
+
+      test('Autocompletes simple searches', function(done) {
+        element._getSearchSuggestions('is:o').then(function(s) {
+          assert.equal(s[0].name, 'is:open');
+          assert.equal(s[0].value, 'is:open');
+          assert.equal(s[1].name, 'is:owner');
+          assert.equal(s[1].value, 'is:owner');
+          done();
+        });
+      });
+
+      test('Does not autocomplete with no match', function(done) {
+        element._getSearchSuggestions('asdasdasdasd').then(function(s) {
+          assert.equal(s.length, 0);
+          done();
+        });
+      });
+
+      test('Autocomplete doesnt override exact matches to input',
+          function(done) {
+        element._getSearchSuggestions('ownerin:gerrit').then(function(s) {
+          assert.equal(s[0].value, 'ownerin:gerrit');
+          done();
+        });
+      });
+
+      test('Autocomplete respects spaces', function(done) {
+        element._getSearchSuggestions('is:ope').then(function(s) {
+          assert.equal(s[0].name, 'is:open');
+          assert.equal(s[0].value, 'is:open');
+          element._getSearchSuggestions('is:ope ').then(function(s) {
+            assert.equal(s.length, 0);
+            done();
+          });
+        });
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 1cb8cc7..cbc00b8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -26,6 +26,9 @@
   GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group) {
     var sectionEl = this._createElement('tbody', 'section');
     sectionEl.classList.add(group.type);
+    if (this._isTotal(group)) {
+      sectionEl.classList.add('total');
+    }
     var pairs = group.getSideBySidePairs();
     for (var i = 0; i < pairs.length; i++) {
       sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
@@ -34,6 +37,29 @@
     return sectionEl;
   };
 
+  GrDiffBuilderSideBySide.prototype.addColumns = function(outputEl, fontSize) {
+    var width = fontSize * 4;
+    var colgroup = document.createElement('colgroup');
+
+    // Add left-side line number.
+    var col = document.createElement('col');
+    col.setAttribute('width', width);
+    colgroup.appendChild(col);
+
+    // Add left-side content.
+    colgroup.appendChild(document.createElement('col'));
+
+    // Add right-side line number.
+    col = document.createElement('col');
+    col.setAttribute('width', width);
+    colgroup.appendChild(col);
+
+    // Add right-side content.
+    colgroup.appendChild(document.createElement('col'));
+
+    outputEl.appendChild(colgroup);
+  };
+
   GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
       rightLine) {
     var row = this._createElement('tr');
@@ -58,9 +84,9 @@
       row.appendChild(action);
     } else {
       var textEl = this._createTextEl(line, side);
-      var threadEl = this._commentThreadForLine(line, side);
-      if (threadEl) {
-        textEl.appendChild(threadEl);
+      var threadGroupEl = this._commentThreadGroupForLine(line, side);
+      if (threadGroupEl) {
+        textEl.appendChild(threadGroupEl);
       }
       row.appendChild(textEl);
     }
@@ -69,7 +95,6 @@
   GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
       content, side) {
     var tr = content.parentElement.parentElement;
-    var content;
     while (tr = tr.nextSibling) {
       content = tr.querySelector(
           'td.content .contentText[data-side="' + side + '"]');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 960bf46..55a6bea 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -26,6 +26,9 @@
   GrDiffBuilderUnified.prototype.buildSectionElement = function(group) {
     var sectionEl = this._createElement('tbody', 'section');
     sectionEl.classList.add(group.type);
+    if (this._isTotal(group)) {
+      sectionEl.classList.add('total');
+    }
 
     for (var i = 0; i < group.lines.length; ++i) {
       sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
@@ -33,6 +36,26 @@
     return sectionEl;
   };
 
+  GrDiffBuilderUnified.prototype.addColumns = function(outputEl, fontSize) {
+    var width = fontSize * 4;
+    var colgroup = document.createElement('colgroup');
+
+    // Add left-side line number.
+    var col = document.createElement('col');
+    col.setAttribute('width', width);
+    colgroup.appendChild(col);
+
+    // Add right-side line number.
+    col = document.createElement('col');
+    col.setAttribute('width', width);
+    colgroup.appendChild(col);
+
+    // Add the content.
+    colgroup.appendChild(document.createElement('col'));
+
+    outputEl.appendChild(colgroup);
+  };
+
   GrDiffBuilderUnified.prototype._createRow = function(section, line) {
     var row = this._createElement('tr', line.type);
     var lineEl = this._createLineEl(line, line.beforeNumber,
@@ -50,9 +73,9 @@
       row.appendChild(action);
     } else {
       var textEl = this._createTextEl(line);
-      var threadEl = this._commentThreadForLine(line);
-      if (threadEl) {
-        textEl.appendChild(threadEl);
+      var threadGroupEl = this._commentThreadGroupForLine(line);
+      if (threadGroupEl) {
+        textEl.appendChild(threadGroupEl);
       }
       row.appendChild(textEl);
     }
@@ -62,7 +85,6 @@
   GrDiffBuilderUnified.prototype._getNextContentOnSide = function(
       content, side) {
     var tr = content.parentElement.parentElement;
-    var content;
     while (tr = tr.nextSibling) {
       if (tr.classList.contains('both') || (
           (side === 'left' && tr.classList.contains('remove')) ||
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index ec19a2d..a936934 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -14,10 +14,13 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
+<link rel="import" href="../gr-diff-comment-thread-group/gr-diff-comment-thread-group.html">
 <link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
 <link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
 <link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
+
 <dom-module id="gr-diff-builder">
   <template>
     <div class="contentWrapper">
@@ -32,6 +35,7 @@
     <gr-diff-processor
         id="processor"
         groups="{{_groups}}"></gr-diff-processor>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
@@ -55,15 +59,34 @@
         SYNTAX: 'Diff Syntax Render',
       };
 
+      // If any line of the diff is more than the character limit, then disable
+      // syntax highlighting for the entire file.
+      var SYNTAX_MAX_LINE_LENGTH = 500;
+
+      var TRAILING_WHITESPACE_PATTERN = /\s+$/;
+
       Polymer({
         is: 'gr-diff-builder',
 
         /**
+         * Fired when the diff begins rendering.
+         *
+         * @event render-start
+         */
+
+        /**
          * Fired when the diff is rendered.
          *
          * @event render
          */
 
+        /**
+         * Fired when the diff finishes rendering text content, but not
+         * necessarily syntax highlights.
+         *
+         * @event render-content
+         */
+
         properties: {
           diff: Object,
           viewMode: String,
@@ -74,6 +97,7 @@
           _builder: Object,
           _groups: Array,
           _layers: Array,
+          _showTabs: Boolean,
         },
 
         get diffElement() {
@@ -87,8 +111,10 @@
         attached: function() {
           // Setup annotation layers.
           this._layers = [
+            this._createTrailingWhitespaceLayer(),
             this.$.syntaxLayer,
             this._createIntralineLayer(),
+            this._createTabIndicatorLayer(),
             this.$.rangeLayer,
           ];
 
@@ -99,6 +125,8 @@
 
         render: function(comments, prefs) {
           this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
+          this._showTabs = !!prefs.show_tabs;
+          this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
 
           // Stop the processor (if it's running).
           this.$.processor.cancel();
@@ -110,20 +138,31 @@
           this.$.processor.keyLocations = this._getCommentLocations(comments);
 
           this._clearDiffContent();
+          this._builder.addColumns(this.diffElement, prefs.font_size);
 
-          console.time(TimingLabel.TOTAL);
-          console.time(TimingLabel.CONTENT);
+          var reporting = this.$.reporting;
+
+          reporting.time(TimingLabel.TOTAL);
+          reporting.time(TimingLabel.CONTENT);
+          this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
           return this.$.processor.process(this.diff.content).then(function() {
             if (this.isImageDiff) {
               this._builder.renderDiffImages();
             }
-            console.timeEnd(TimingLabel.CONTENT);
-            console.time(TimingLabel.SYNTAX);
-            this.$.syntaxLayer.process().then(function() {
-              console.timeEnd(TimingLabel.SYNTAX);
-              console.timeEnd(TimingLabel.TOTAL);
-            });
-            this.fire('render');
+            this.dispatchEvent(new CustomEvent('render-content',
+                {bubbles: true}));
+
+            if (this._anyLineTooLong()) {
+              this.$.syntaxLayer.enabled = false;
+            }
+
+            reporting.timeEnd(TimingLabel.CONTENT);
+            reporting.time(TimingLabel.SYNTAX);
+            return this.$.syntaxLayer.process().then(function() {
+              reporting.timeEnd(TimingLabel.SYNTAX);
+              reporting.timeEnd(TimingLabel.TOTAL);
+              this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
+            }.bind(this));
           }.bind(this));
         },
 
@@ -148,38 +187,6 @@
               parseInt(lineEl.getAttribute('data-value'), 10) : null;
         },
 
-        renderLineRange: function(startLine, endLine, opt_side) {
-          var groups =
-              this._builder.getGroupsByLineRange(startLine, endLine, opt_side);
-          groups.forEach(function(group) {
-            var newElement = this._builder.buildSectionElement(group);
-            var oldElement = group.element;
-
-            // Transfer comment threads from existing section to new one.
-            var threads = Polymer.dom(newElement).querySelectorAll(
-                'gr-diff-comment-thread');
-            threads.forEach(function(threadEl) {
-              var lineEl = this.getLineElByChild(threadEl, oldElement);
-              if (!lineEl) { // New comment thread.
-                return;
-              }
-              var side = this.getSideByLineEl(lineEl);
-              var line = lineEl.getAttribute('data-value');
-              var oldThreadEl =
-                  this.getCommentThreadByLine(line, side, oldElement);
-              threadEl.parentNode.replaceChild(oldThreadEl, threadEl);
-            }, this);
-
-            // Replace old group elements with new ones.
-            group.element.parentNode.replaceChild(newElement, group.element);
-            group.element = newElement;
-          }, this);
-
-          this.async(function() {
-            this.fire('render');
-          }, 1);
-        },
-
         getContentByLine: function(lineNumber, opt_side, opt_root) {
           return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
         },
@@ -204,27 +211,15 @@
           return result;
         },
 
-        getCommentThreadByLine: function(lineNumber, opt_side, opt_root) {
-          var content = this.getContentByLine(lineNumber, opt_side, opt_root);
-          return this.getCommentThreadByContentEl(content);
-        },
-
-        getCommentThreadByContentEl: function(contentEl) {
-          if (contentEl.classList.contains('contentText')) {
-            contentEl = contentEl.parentElement;
-          }
-          return contentEl.querySelector('gr-diff-comment-thread');
-        },
-
         getSideByLineEl: function(lineEl) {
           return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
               GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
         },
 
-        createCommentThread: function(changeNum, patchNum, path, side,
-            projectConfig) {
-          return this._builder.createCommentThread(changeNum, patchNum, path,
-              side, projectConfig);
+        createCommentThreadGroup: function(changeNum, patchNum, path,
+            isOnParent, commentSide, projectConfig) {
+          return this._builder.createCommentThreadGroup(changeNum, patchNum,
+              path, isOnParent, commentSide, projectConfig);
         },
 
         emitGroup: function(group, sectionEl) {
@@ -298,8 +293,6 @@
 
         _createIntralineLayer: function() {
           return {
-            addListener: function() {},
-
             // Take a DIV.contentText element and a line object with intraline
             // differences to highlight and apply them to the element as
             // annotations.
@@ -325,6 +318,53 @@
           };
         },
 
+        _createTabIndicatorLayer: function() {
+          var show = function() { return this._showTabs; }.bind(this);
+          return {
+            annotate: function(el, line) {
+              // If visible tabs are disabled, do nothing.
+              if (!show()) { return; }
+
+              // Find and annotate the locations of tabs.
+              var split = line.text.split('\t');
+              if (!split) { return; }
+              for (var i = 0, pos = 0; i < split.length - 1; i++) {
+                // Skip forward by the length of the content
+                pos += split[i].length;
+
+                GrAnnotation.annotateElement(el, pos, 1,
+                    'style-scope gr-diff tab-indicator');
+
+                // Skip forward by one tab character.
+                pos++;
+              }
+            },
+          };
+        },
+
+        _createTrailingWhitespaceLayer: function() {
+          var show = function() {
+            return this._showTrailingWhitespace;
+          }.bind(this);
+
+          return {
+            annotate: function(el, line) {
+              if (!show()) { return; }
+
+              var match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+              if (match) {
+                // Normalize string positions in case there is unicode before or
+                // within the match.
+                var index = GrAnnotation.getStringLength(
+                    line.text.substr(0, match.index));
+                var length = GrAnnotation.getStringLength(match[0]);
+                GrAnnotation.annotateElement(el, index, length,
+                    'style-scope gr-diff trailing-whitespace');
+              }
+            },
+          };
+        },
+
         /**
          * In pages with large diffs, creating the first comment thread can be
          * slow because nested Polymer elements (particularly
@@ -343,6 +383,18 @@
           Polymer.dom.flush();
           parent.removeChild(thread);
         },
+
+        /**
+         * @return {Boolean} whether any of the lines in _groups are longer
+         * than SYNTAX_MAX_LINE_LENGTH.
+         */
+        _anyLineTooLong: function() {
+          return this._groups.reduce(function(acc, group) {
+            return acc || group.lines.reduce(function(acc, line) {
+              return acc || line.text.length >= SYNTAX_MAX_LINE_LENGTH;
+            }, false);
+          }, false);
+        },
       });
     })();
   </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 2090e98..214454a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -14,6 +14,17 @@
 (function(window, GrDiffGroup, GrDiffLine) {
   'use strict';
 
+  var HTML_ENTITY_PATTERN = /[&<>"'`\/]/g;
+  var HTML_ENTITY_MAP = {
+    '&': '&amp;',
+    '<': '&lt;',
+    '>': '&gt;',
+    '"': '&quot;',
+    '\'': '&#39;',
+    '/': '&#x2F;',
+    '`': '&#96;',
+  };
+
   // Prevent redefinition.
   if (window.GrDiffBuilder) { return; }
 
@@ -29,7 +40,9 @@
     this.layers = layers || [];
 
     this.layers.forEach(function(layer) {
-      layer.addListener(this._handleLayerUpdate.bind(this));
+      if (layer.addListener) {
+        layer.addListener(this._handleLayerUpdate.bind(this));
+      }
     }.bind(this));
   }
 
@@ -65,7 +78,20 @@
 
   var PARTIAL_CONTEXT_AMOUNT = 10;
 
-  GrDiffBuilder.prototype.buildSectionElement = function(group) {
+  /**
+   * Abstract method
+   * @param {string} outputEl
+   * @param {number} fontSize
+   */
+  GrDiffBuilder.prototype.addColumns = function() {
+    throw Error('Subclasses must implement addColumns');
+  };
+
+  /**
+   * Abstract method
+   * @param {Object} group
+   */
+  GrDiffBuilder.prototype.buildSectionElement = function() {
     throw Error('Subclasses must implement buildGroupElement');
   };
 
@@ -163,8 +189,8 @@
   };
 
   /**
-   * Re-renders the DIV.contentText alement for the given side and range of diff
-   * content.
+   * Re-renders the DIV.contentText elements for the given side and range of
+   * diff content.
    */
   GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
     var lines = [];
@@ -289,6 +315,9 @@
     var rightComments =
         comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
 
+    leftComments.forEach(function(c) { c.__commentSide = 'left'; });
+    rightComments.forEach(function(c) { c.__commentSide = 'right'; });
+
     var result;
 
     switch (opt_side) {
@@ -306,41 +335,47 @@
     return result;
   };
 
-  GrDiffBuilder.prototype.createCommentThread = function(changeNum, patchNum,
-      path, side, projectConfig) {
-    var threadEl = document.createElement('gr-diff-comment-thread');
-    threadEl.changeNum = changeNum;
-    threadEl.patchNum = patchNum;
-    threadEl.path = path;
-    threadEl.side = side;
-    threadEl.projectConfig = projectConfig;
-    return threadEl;
+  GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum,
+      patchNum, path, isOnParent, projectConfig, range) {
+    var threadGroupEl =
+        document.createElement('gr-diff-comment-thread-group');
+    threadGroupEl.changeNum = changeNum;
+    threadGroupEl.patchForNewThreads = patchNum;
+    threadGroupEl.path = path;
+    threadGroupEl.isOnParent = isOnParent;
+    threadGroupEl.projectConfig = projectConfig;
+    threadGroupEl.range = range;
+    return threadGroupEl;
   };
 
-  GrDiffBuilder.prototype._commentThreadForLine = function(line, opt_side) {
+  GrDiffBuilder.prototype._commentThreadGroupForLine =
+      function(line, opt_side) {
     var comments = this._getCommentsForLine(this._comments, line, opt_side);
     if (!comments || comments.length === 0) {
       return null;
     }
 
     var patchNum = this._comments.meta.patchRange.patchNum;
-    var side = comments[0].side || 'REVISION';
+    var isOnParent = comments[0].side === 'PARENT' || false;
     if (line.type === GrDiffLine.Type.REMOVE ||
         opt_side === GrDiffBuilder.Side.LEFT) {
       if (this._comments.meta.patchRange.basePatchNum === 'PARENT') {
-        side = 'PARENT';
+        isOnParent = true;
       } else {
         patchNum = this._comments.meta.patchRange.basePatchNum;
       }
     }
-    var threadEl = this.createCommentThread(
+    var threadGroupEl = this.createCommentThreadGroup(
         this._comments.meta.changeNum,
         patchNum,
         this._comments.meta.path,
-        side,
+        isOnParent,
         this._comments.meta.projectConfig);
-    threadEl.comments = comments;
-    return threadEl;
+    threadGroupEl.comments = comments;
+    if (opt_side) {
+      threadGroupEl.setAttribute('data-side', opt_side);
+    }
+    return threadGroupEl;
   };
 
   GrDiffBuilder.prototype._createLineEl = function(line, number, type,
@@ -363,15 +398,16 @@
 
   GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
     var td = this._createElement('td');
+    var text = line.text;
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
     }
     td.classList.add(line.type);
-    var text = line.text;
-    var html = util.escapeHTML(text);
+    var html = this._escapeHTML(text);
     html = this._addTabWrappers(html, this._prefs.tab_size);
 
-    if (this._textLength(text, this._prefs.tab_size) >
+    if (!this._prefs.line_wrapping &&
+        this._textLength(text, this._prefs.tab_size) >
         this._prefs.line_length) {
       html = this._addNewlines(text, html);
     }
@@ -389,9 +425,6 @@
       contentText.innerHTML = html;
     }
 
-    td.classList.add(line.highlights.length > 0 ?
-        'lightHighlight' : 'darkHighlight');
-
     this.layers.forEach(function(layer) {
       layer.annotate(contentText, line);
     });
@@ -447,20 +480,38 @@
     return index + 1;
   };
 
+  GrDiffBuilder.prototype._advancePastTagClose = function(html, index) {
+    while (index < html.length &&
+           html.charCodeAt(index) !== GrDiffBuilder.GREATER_THAN_CODE) {
+      index++;
+    }
+    return index + 1;
+  };
+
   GrDiffBuilder.prototype._addNewlines = function(text, html) {
     var htmlIndex = 0;
     var indices = [];
     var numChars = 0;
+    var prevHtmlIndex = 0;
     for (var i = 0; i < text.length; i++) {
       if (numChars > 0 && numChars % this._prefs.line_length === 0) {
         indices.push(htmlIndex);
       }
       htmlIndex = this._advanceChar(html, htmlIndex);
       if (text[i] === '\t') {
+        // Advance past tab closing tag.
+        htmlIndex = this._advancePastTagClose(html, htmlIndex);
+        // ~~ is a faster Math.floor
+        if (~~(numChars / this._prefs.line_length) !==
+            ~~((numChars + this._prefs.tab_size) / this._prefs.line_length)) {
+          // Tab crosses line limit - push it to the next line.
+          indices.push(prevHtmlIndex);
+        }
         numChars += this._prefs.tab_size;
       } else {
         numChars++;
       }
+      prevHtmlIndex = htmlIndex;
     }
     var result = html;
     // Since the result string is being altered in place, start from the end
@@ -493,7 +544,7 @@
     for (var i = 0; i < split.length - 1; i++) {
       offset += split[i].length;
       width = tabSize - (offset % tabSize);
-      result += split[i] + this._getTabWrapper(width, this._prefs.show_tabs);
+      result += split[i] + this._getTabWrapper(width);
       offset += width;
     }
     if (split.length) {
@@ -503,7 +554,7 @@
     return result;
   };
 
-  GrDiffBuilder.prototype._getTabWrapper = function(tabSize, showTabs) {
+  GrDiffBuilder.prototype._getTabWrapper = function(tabSize) {
     // Force this to be a number to prevent arbitrary injection.
     tabSize = +tabSize;
     if (isNaN(tabSize)) {
@@ -511,9 +562,6 @@
     }
 
     var str = '<span class="style-scope gr-diff tab ';
-    if (showTabs) {
-      str += 'withIndicator';
-    }
     str += '" style="';
     // TODO(andybons): CSS tab-size is not supported in IE.
     str += 'tab-size:' + tabSize + ';';
@@ -552,5 +600,23 @@
     throw Error('Subclasses must implement _getNextContentOnSide');
   };
 
+  /**
+   * Determines whether the given group is either totally an addition or totally
+   * a removal.
+   * @param {GrDiffGroup} group
+   * @return {Boolean}
+   */
+  GrDiffBuilder.prototype._isTotal = function(group) {
+    return group.type === GrDiffGroup.Type.DELTA &&
+        (!group.adds.length || !group.removes.length) &&
+        !(!group.adds.length && !group.removes.length);
+  };
+
+  GrDiffBuilder.prototype._escapeHTML = function(str) {
+    return str.replace(HTML_ENTITY_PATTERN, function(s) {
+      return HTML_ENTITY_MAP[s];
+    });
+  };
+
   window.GrDiffBuilder = GrDiffBuilder;
 })(window, GrDiffGroup, GrDiffLine);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index e8b1453..591fa9c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -18,7 +18,7 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-builder</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../scripts/util.js"></script>
 <script src="../gr-diff/gr-diff-line.js"></script>
@@ -29,6 +29,8 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-diff-builder.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-diff-builder>
@@ -57,6 +59,9 @@
     var builder;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       var prefs = {
         line_length: 10,
         show_tabs: true,
@@ -94,7 +99,7 @@
       assert.equal(buttons[2].textContent, '+10↓');
     });
 
-    test('newlines', function() {
+    test('newlines 1', function() {
       var text = 'abcdef';
       assert.equal(builder._addNewlines(text, text), text);
       text = 'a'.repeat(20);
@@ -102,8 +107,10 @@
           'a'.repeat(10) +
           GrDiffBuilder.LINE_FEED_HTML +
           'a'.repeat(10));
+    });
 
-      text = '<span class="thumbsup">👍</span>';
+    test('newlines 2', function() {
+      var text = '<span class="thumbsup">👍</span>';
       var html = '&lt;span class=&quot;thumbsup&quot;&gt;👍&lt;&#x2F;span&gt;';
       assert.equal(builder._addNewlines(text, html),
           '&lt;span clas' +
@@ -113,14 +120,58 @@
           'p&quot;&gt;👍&lt;&#x2F;spa' +
           GrDiffBuilder.LINE_FEED_HTML +
           'n&gt;');
+    });
 
-      text = '01234\t56789';
-      assert.equal(builder._addNewlines(text, text),
-          '01234\t5' +
+    test('newlines 3', function() {
+      var text = '01234\t56789';
+      var html = '01234<span>\t</span>56789';
+      assert.equal(builder._addNewlines(text, html),
+          '01234<span>\t</span>5' +
           GrDiffBuilder.LINE_FEED_HTML +
           '6789');
     });
 
+    test('_addNewlines not called if line_wrapping is true', function(done) {
+      builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
+      var text = (new Array(52)).join('a');
+
+      var line = {text: text, highlights: []};
+      var newLineStub = sinon.stub(builder, '_addNewlines');
+      builder._createTextEl(line);
+      flush(function() {
+        assert.isFalse(newLineStub.called);
+        newLineStub.restore();
+        done();
+      });
+    });
+
+    test('_addNewlines called if line_wrapping is true and meets other ' +
+        'conditions', function(done) {
+      builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
+      var text = (new Array(52)).join('a');
+
+      var line = {text: text, highlights: []};
+      var newLineStub = sinon.stub(builder, '_addNewlines');
+      builder._createTextEl(line);
+
+      flush(function() {
+        assert.isTrue(newLineStub.called);
+        newLineStub.restore();
+        done();
+      });
+    });
+
+    test('_createTextEl linewrap with tabs', function() {
+      var text = _.times(7, _.constant('\t')).join('') + '!';
+      var line = {text: text, highlights: []};
+      var el = builder._createTextEl(line);
+      var tabEl = el.querySelector('.contentText > .br');
+      assert.isOk(tabEl);
+      assert.equal(
+          el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
+          tabEl);
+    });
+
     test('text length with tabs and unicode', function() {
       assert.equal(builder._textLength('12345', 4), 5);
       assert.equal(builder._textLength('\t\t12', 4), 10);
@@ -174,17 +225,23 @@
         ],
       };
       assert.deepEqual(builder._getCommentsForLine(comments, line),
-          [{id: 'l3', line: 3}, {id: 'r5', line: 5}]);
+          [{id: 'l3', line: 3, __commentSide: 'left'},
+          {id: 'r5', line: 5, __commentSide: 'right'}]);
       assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.LEFT), [{id: 'l3', line: 3}]);
+          GrDiffBuilder.Side.LEFT), [{id: 'l3', line: 3,
+          __commentSide: 'left'}]);
       assert.deepEqual(builder._getCommentsForLine(comments, line,
-          GrDiffBuilder.Side.RIGHT), [{id: 'r5', line: 5}]);
+          GrDiffBuilder.Side.RIGHT), [{id: 'r5', line: 5,
+          __commentSide: 'right'}]);
     });
 
-    test('comment thread creation', function() {
-      var l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000'};
-      var l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000'};
-      var r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000'};
+    test('comment thread group creation', function() {
+      var l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000',
+          __commentSide: 'left'};
+      var l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000',
+          __commentSide: 'left'};
+      var r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
+          __commentSide: 'right'};
 
       builder._comments = {
         meta: {
@@ -200,51 +257,87 @@
         right: [r5],
       };
 
-      function checkThreadProps(threadEl, patchNum, side, comments) {
-        assert.equal(threadEl.changeNum, '42');
-        assert.equal(threadEl.patchNum, patchNum);
-        assert.equal(threadEl.path, '/path/to/foo');
-        assert.equal(threadEl.side, side);
-        assert.deepEqual(threadEl.projectConfig, {foo: 'bar'});
-        assert.deepEqual(threadEl.comments, comments);
+      function checkThreadGroupProps(threadGroupEl, patchNum, isOnParent,
+          comments) {
+        assert.equal(threadGroupEl.changeNum, '42');
+        assert.equal(threadGroupEl.patchForNewThreads, patchNum);
+        assert.equal(threadGroupEl.path, '/path/to/foo');
+        assert.equal(threadGroupEl.isOnParent, isOnParent);
+        assert.deepEqual(threadGroupEl.projectConfig, {foo: 'bar'});
+        assert.deepEqual(threadGroupEl.comments, comments);
       }
 
       var line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = 5;
       line.afterNumber = 5;
-      var threadEl = builder._commentThreadForLine(line);
-      checkThreadProps(threadEl, '3', 'REVISION', [l5, r5]);
+      var threadGroupEl = builder._commentThreadGroupForLine(line);
+      checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
 
-      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.RIGHT);
-      checkThreadProps(threadEl, '3', 'REVISION', [r5]);
+      threadGroupEl =
+          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
+      checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
 
-      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.LEFT);
-      checkThreadProps(threadEl, '3', 'PARENT', [l5]);
+      threadGroupEl =
+          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
+      checkThreadGroupProps(threadGroupEl, '3', true, [l5]);
 
       builder._comments.meta.patchRange.basePatchNum = '1';
 
-      threadEl = builder._commentThreadForLine(line);
-      checkThreadProps(threadEl, '3', 'REVISION', [l5, r5]);
+      threadGroupEl = builder._commentThreadGroupForLine(line);
+      checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
 
-      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.LEFT);
-      checkThreadProps(threadEl, '1', 'REVISION', [l5]);
+      threadEl =
+          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
+      checkThreadGroupProps(threadEl, '1', false, [l5]);
 
-      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.RIGHT);
-      checkThreadProps(threadEl, '3', 'REVISION', [r5]);
+      threadGroupEl =
+          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
+      checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
 
       builder._comments.meta.patchRange.basePatchNum = 'PARENT';
 
       line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 5;
       line.afterNumber = 5;
-      threadEl = builder._commentThreadForLine(line);
-      checkThreadProps(threadEl, '3', 'PARENT', [l5, r5]);
+      threadGroupEl = builder._commentThreadGroupForLine(line);
+      checkThreadGroupProps(threadGroupEl, '3', true, [l5, r5]);
 
       line = new GrDiffLine(GrDiffLine.Type.ADD);
       line.beforeNumber = 3;
       line.afterNumber = 5;
-      threadEl = builder._commentThreadForLine(line);
-      checkThreadProps(threadEl, '3', 'REVISION', [l3, r5]);
+      threadGroupEl = builder._commentThreadGroupForLine(line);
+    checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
+    });
+
+    suite('_isTotal', function() {
+      test('is total for add', function() {
+        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (var idx = 0; idx < 10; idx++) {
+          group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
+        }
+        assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+      });
+
+      test('is total for remove', function() {
+        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (var idx = 0; idx < 10; idx++) {
+          group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
+        }
+        assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+      });
+
+      test('not total for empty', function() {
+        var group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+        assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+      });
+
+      test('not total for non-delta', function() {
+        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (var idx = 0; idx < 10; idx++) {
+          group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
+        }
+        assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+      });
     });
 
     suite('intraline differences', function() {
@@ -414,16 +507,224 @@
       });
     });
 
+    suite('tab indicators', function() {
+      var sandbox;
+      var element;
+      var layer;
+
+      setup(function() {
+        sandbox = sinon.sandbox.create();
+        element = fixture('basic');
+        element._showTabs = true;
+        layer = element._createTabIndicatorLayer();
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('does nothing with empty line', function() {
+        var line = {text: ''};
+        var el = document.createElement('div');
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('does nothing with no tabs', function() {
+        var str = 'lorem ipsum no tabs';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('annotates tab at beginning', function() {
+        var str = '\tlorem upsum';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.equal(annotateElementStub.callCount, 1);
+        var args = annotateElementStub.getCalls()[0].args;
+        assert.equal(args[0], el);
+        assert.equal(args[1], 0, 'offset of tab indicator');
+        assert.equal(args[2], 1, 'length of tab indicator');
+        assert.include(args[3], 'tab-indicator');
+      });
+
+      test('does not annotate when disabled', function() {
+        element._showTabs = false;
+
+        var str = '\tlorem upsum';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('annotates multiple in beginning', function() {
+        var str = '\t\tlorem upsum';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.equal(annotateElementStub.callCount, 2);
+
+        var args = annotateElementStub.getCalls()[0].args;
+        assert.equal(args[0], el);
+        assert.equal(args[1], 0, 'offset of tab indicator');
+        assert.equal(args[2], 1, 'length of tab indicator');
+        assert.include(args[3], 'tab-indicator');
+
+        args = annotateElementStub.getCalls()[1].args;
+        assert.equal(args[0], el);
+        assert.equal(args[1], 1, 'offset of tab indicator');
+        assert.equal(args[2], 1, 'length of tab indicator');
+        assert.include(args[3], 'tab-indicator');
+      });
+
+      test('annotates intermediate tabs', function() {
+        var str = 'lorem\tupsum';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+
+        layer.annotate(el, line);
+
+        assert.equal(annotateElementStub.callCount, 1);
+        var args = annotateElementStub.getCalls()[0].args;
+        assert.equal(args[0], el);
+        assert.equal(args[1], 5, 'offset of tab indicator');
+        assert.equal(args[2], 1, 'length of tab indicator');
+        assert.include(args[3], 'tab-indicator');
+      });
+    });
+
+    suite('trailing whitespace', function() {
+      var sandbox;
+      var element;
+      var layer;
+
+      setup(function() {
+        sandbox = sinon.sandbox.create();
+        element = fixture('basic');
+        element._showTrailingWhitespace = true;
+        layer = element._createTrailingWhitespaceLayer();
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('does nothing with empty line', function() {
+        var line = {text: ''};
+        var el = document.createElement('div');
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('does nothing with no trailing whitespace', function() {
+        var str = 'lorem ipsum blah blah';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isFalse(annotateElementStub.called);
+      });
+
+      test('annotates trailing spaces', function() {
+        var str = 'lorem ipsum   ';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isTrue(annotateElementStub.called);
+        assert.equal(annotateElementStub.lastCall.args[1], 11);
+        assert.equal(annotateElementStub.lastCall.args[2], 3);
+      });
+
+      test('annotates trailing tabs', function() {
+        var str = 'lorem ipsum\t\t\t';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isTrue(annotateElementStub.called);
+        assert.equal(annotateElementStub.lastCall.args[1], 11);
+        assert.equal(annotateElementStub.lastCall.args[2], 3);
+      });
+
+      test('annotates mixed trailing whitespace', function() {
+        var str = 'lorem ipsum\t \t';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isTrue(annotateElementStub.called);
+        assert.equal(annotateElementStub.lastCall.args[1], 11);
+        assert.equal(annotateElementStub.lastCall.args[2], 3);
+      });
+
+      test('unicode preceding trailing whitespace', function() {
+        var str = '💢\t';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isTrue(annotateElementStub.called);
+        assert.equal(annotateElementStub.lastCall.args[1], 1);
+        assert.equal(annotateElementStub.lastCall.args[2], 1);
+      });
+
+      test('does not annotate when disabled', function() {
+        element._showTrailingWhitespace = false;
+        var str = 'lorem upsum\t \t ';
+        var line = {text: str};
+        var el = document.createElement('div');
+        el.textContent = str;
+        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        layer.annotate(el, line);
+        assert.isFalse(annotateElementStub.called);
+      });
+    });
+
     suite('rendering', function() {
       var content;
       var outputEl;
+      var sandbox;
 
       setup(function(done) {
+        sandbox = sinon.sandbox.create();
         var prefs = {
           line_length: 10,
           show_tabs: true,
           tab_size: 4,
-          context: -1
+          context: -1,
+          syntax_highlighting: true,
         };
         content = [
           {
@@ -437,14 +738,16 @@
             ]
           },
         ];
+        stub('gr-reporting', {
+          time: sandbox.stub(),
+          timeEnd: sandbox.stub(),
+        });
         element = fixture('basic');
         outputEl = element.queryEffectiveChildren('#diffTable');
-        element.addEventListener('render', function() {
-          done();
-        });
-        sinon.stub(element, '_getDiffBuilder', function() {
+        sandbox.stub(element, '_getDiffBuilder', function() {
           var builder = new GrDiffBuilder(
               {content: content}, {left: [], right: []}, prefs, outputEl);
+          sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             var section = document.createElement('stub');
             section.textContent = group.lines.reduce(function(acc, line) {
@@ -455,7 +758,23 @@
           return builder;
         });
         element.diff = {content: content};
-        element.render({left: [], right: []}, prefs);
+        element.render({left: [], right: []}, prefs).then(done);
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('reporting', function(done) {
+        var timeStub = element.$.reporting.time;
+        var timeEndStub = element.$.reporting.timeEnd;
+        assert.isTrue(timeStub.calledWithExactly('Diff Total Render'));
+        assert.isTrue(timeStub.calledWithExactly('Diff Content Render'));
+        assert.isTrue(timeStub.calledWithExactly('Diff Syntax Render'));
+        assert.isTrue(timeEndStub.calledWithExactly('Diff Total Render'));
+        assert.isTrue(timeEndStub.calledWithExactly('Diff Content Render'));
+        assert.isTrue(timeEndStub.calledWithExactly('Diff Syntax Render'));
+        done();
       });
 
       test('renderSection', function() {
@@ -467,6 +786,11 @@
         assert.equal(section.innerHTML, prevInnerHTML);
       });
 
+      test('addColumns is called', function(done) {
+        element.render({left: [], right: []}, {}).then(done);
+        assert.isTrue(element._builder.addColumns.called);
+      });
+
       test('getSectionsByLineRange one line', function() {
         var section = outputEl.querySelector('stub:nth-of-type(2)');
         var sections = element._builder.getSectionsByLineRange(1, 1, 'left');
@@ -484,6 +808,39 @@
         assert.strictEqual(sections[0], section[0]);
         assert.strictEqual(sections[1], section[1]);
       });
+
+      test('render-start and render are fired', function(done) {
+        var dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        element.render({left: [], right: []}, {}).then(function() {
+          var firedEventTypes = dispatchEventStub.getCalls()
+              .map(function(c) { return c.args[0].type; });
+          assert.include(firedEventTypes, 'render-start');
+          assert.include(firedEventTypes, 'render-content');
+          assert.include(firedEventTypes, 'render');
+          done();
+        });
+      });
+
+      test('rendering normal-sized diff does not disable syntax', function() {
+        assert.isTrue(element.$.syntaxLayer.enabled);
+      });
+
+      test('rendering large diff disables syntax', function(done) {
+        // Before it renders, set the first diff line to 500 '*' characters.
+        element.diff.content[0].a = [new Array(501).join('*')];
+        element.addEventListener('render', function() {
+          assert.isFalse(element.$.syntaxLayer.enabled);
+          done();
+        });
+        var prefs = {
+          line_length: 10,
+          show_tabs: true,
+          tab_size: 4,
+          context: -1,
+          syntax_highlighting: true,
+        };
+        element.render({left: [], right: []}, prefs);
+      });
     });
 
     suite('mock-diff', function() {
@@ -624,6 +981,23 @@
           done();
         });
       });
+
+      test('_escapeHTML', function() {
+        var input = '<script>alert("XSS");<' + '/script>';
+        var expected = '&lt;script&gt;alert(&quot;XSS&quot;);' +
+            '&lt;&#x2F;script&gt;';
+        var result = GrDiffBuilder.prototype._escapeHTML(input);
+        assert.equal(result, expected);
+
+        input = '& < > " \' / `';
+
+        // \u0026 is an ampersand. This is being used here instead of &
+        // because of the gjslinter.
+        expected = '\u0026amp; \u0026lt; \u0026gt; \u0026quot;' +
+          ' \u0026#39; \u0026#x2F; \u0026#96;';
+        result = GrDiffBuilder.prototype._escapeHTML(input);
+        assert.equal(result, expected);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
new file mode 100644
index 0000000..30dfacf
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
@@ -0,0 +1,45 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
+
+<dom-module id="gr-diff-comment-thread-group">
+  <template>
+    <style>
+      :host {
+        display: block;
+        white-space: normal;
+      }
+      gr-diff-comment-thread + gr-diff-comment-thread {
+        margin-top: .2em;
+      }
+    </style>
+    <template is="dom-repeat" items="[[_threads]]"
+        as="thread">
+      <gr-diff-comment-thread
+          comments="[[thread.comments]]"
+          comment-side="[[thread.commentSide]]"
+          is-on-parent="[[isOnParent]]"
+          change-num="[[changeNum]]"
+          location-range="[[thread.locationRange]]"
+          patch-num="[[thread.patchNum]]"
+          path="[[path]]"
+          project-config="[[projectConfig]]"></gr-diff-comment-thread>
+    </template>
+  </template>
+  <script src="gr-diff-comment-thread-group.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
new file mode 100644
index 0000000..df75d52
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
@@ -0,0 +1,142 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-comment-thread-group',
+
+    properties: {
+      changeNum: String,
+      comments: {
+        type: Array,
+        value: function() { return []; },
+      },
+      patchForNewThreads: String,
+      projectConfig: Object,
+      range: Object,
+      isOnParent: {
+        type: Boolean,
+        value: false,
+      },
+      _threads: {
+        type: Array,
+        value: function() { return []; },
+      },
+    },
+
+    observers: [
+      '_commentsChanged(comments.*)',
+    ],
+
+    addNewThread: function(locationRange) {
+      this.push('_threads', {
+        comments: [],
+        locationRange: locationRange,
+        patchNum: this.patchForNewThreads,
+      });
+    },
+
+    removeThread: function(locationRange) {
+      for (var i = 0; i < this._threads.length; i++) {
+        if (this._threads[i].locationRange === locationRange) {
+          this.splice('_threads', i, 1);
+          return;
+        }
+      }
+    },
+
+    getThreadForRange: function(rangeToCheck) {
+      var threads = [].filter.call(
+          Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread'),
+          function(thread) {
+            return thread.locationRange === rangeToCheck;
+          });
+      if (threads.length === 1) {
+        return threads[0];
+      }
+    },
+
+    _commentsChanged: function() {
+      this._threads = this._getThreadGroups(this.comments);
+    },
+
+    _sortByDate: function(threadGroups) {
+      if (!threadGroups.length) { return; }
+      return threadGroups.sort(function(a, b) {
+        // If a comment is a draft, it doesn't have a start_datetime yet.
+        // Assume it is newer than the comment it is being compared to.
+        if (!a.start_datetime) {
+          return 1;
+        }
+        if (!b.start_datetime) {
+          return -1;
+        }
+        return util.parseDate(a.start_datetime) -
+            util.parseDate(b.start_datetime);
+      });
+    },
+
+    _calculateLocationRange: function(range, comment) {
+      return 'range-' + range.start_line + '-' +
+          range.start_character + '-' +
+          range.end_line + '-' +
+          range.end_character + '-' +
+          comment.__commentSide;
+    },
+
+    /**
+     * Determines what the patchNum of a thread should be. Use patchNum from
+     * comment if it exists, otherwise the property of the thread group.
+     * This is needed for switching between side-by-side and unified views when
+     * there are unsaved drafts.
+     */
+    _getPatchNum: function(comment) {
+      return comment.patchNum || this.patchForNewThreads;
+    },
+
+    _getThreadGroups: function(comments) {
+      var threadGroups = {};
+
+      comments.forEach(function(comment) {
+        var locationRange;
+        if (!comment.range) {
+          locationRange = 'line-' + comment.__commentSide;
+        } else {
+          locationRange = this._calculateLocationRange(comment.range, comment);
+        }
+
+        if (threadGroups[locationRange]) {
+          threadGroups[locationRange].comments.push(comment);
+        } else {
+          threadGroups[locationRange] = {
+            start_datetime: comment.updated,
+            comments: [comment],
+            locationRange: locationRange,
+            commentSide: comment.__commentSide,
+            patchNum: this._getPatchNum(comment),
+          };
+        }
+      }.bind(this));
+
+      var threadGroupArr = [];
+      var threadGroupKeys = Object.keys(threadGroups);
+      threadGroupKeys.forEach(function(threadGroupKey) {
+        threadGroupArr.push(threadGroups[threadGroupKey]);
+      });
+
+      return this._sortByDate(threadGroupArr);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
new file mode 100644
index 0000000..53a8e81
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
@@ -0,0 +1,257 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-comment-thread-group</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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-comment-thread-group.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-comment-thread-group></gr-diff-comment-thread-group>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-comment-thread-group tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('_getThreadGroups', function() {
+      element.patchForNewThreads = 3;
+      var comments = [
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000',
+          __commentSide: 'left',
+        }, {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          updated: '2015-12-24 15:00:20.396000000',
+          __commentSide: 'left',
+        },
+      ];
+
+      var expectedThreadGroups = [
+        {
+          start_datetime: '2015-12-23 15:00:20.396000000',
+          commentSide: 'left',
+          comments: [{
+              id: 'sallys_confession',
+              message: 'i like you, jack',
+              updated: '2015-12-23 15:00:20.396000000',
+              __commentSide: 'left',
+            }, {
+              id: 'jacks_reply',
+              message: 'i like you, too',
+              updated: '2015-12-24 15:00:20.396000000',
+              __commentSide: 'left',
+            }],
+          locationRange: 'line-left',
+          patchNum: 3
+        },
+      ];
+
+      assert.deepEqual(element._getThreadGroups(comments),
+          expectedThreadGroups);
+
+      // Patch num should get inherited from comment rather
+      comments.push({
+          id: 'betsys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:10.396000000',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          __commentSide: 'left',
+        });
+
+      expectedThreadGroups = [
+        {
+          start_datetime: '2015-12-23 15:00:20.396000000',
+          commentSide: 'left',
+          comments: [{
+              id: 'sallys_confession',
+              message: 'i like you, jack',
+              updated: '2015-12-23 15:00:20.396000000',
+              __commentSide: 'left',
+            }, {
+              id: 'jacks_reply',
+              message: 'i like you, too',
+              updated: '2015-12-24 15:00:20.396000000',
+              __commentSide: 'left',
+            }],
+          patchNum: 3,
+          locationRange: 'line-left',
+        },
+        {
+          start_datetime: '2015-12-24 15:00:10.396000000',
+          commentSide: 'left',
+          comments: [{
+            id: 'betsys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-24 15:00:10.396000000',
+            range: {
+              start_line: 1,
+              start_character: 1,
+              end_line: 1,
+              end_character: 2,
+            },
+            __commentSide: 'left',
+          }],
+          patchNum: 3,
+          locationRange: 'range-1-1-1-2-left',
+        },
+      ];
+
+      assert.deepEqual(element._getThreadGroups(comments),
+          expectedThreadGroups);
+    });
+
+    test('_sortByDate', function() {
+      var threadGroups = [
+        {
+          start_datetime: '2015-12-23 15:00:20.396000000',
+          comments: [],
+          locationRange: 'line',
+        },
+        {
+          start_datetime: '2015-12-22 15:00:10.396000000',
+          comments: [],
+          locationRange: 'range-1-1-1-2',
+        },
+      ];
+
+      var expectedResult = [
+        {
+          start_datetime: '2015-12-22 15:00:10.396000000',
+          comments: [],
+          locationRange: 'range-1-1-1-2',
+        },{
+          start_datetime: '2015-12-23 15:00:20.396000000',
+          comments: [],
+          locationRange: 'line',
+        },
+      ];
+
+      assert.deepEqual(element._sortByDate(threadGroups), expectedResult);
+
+      // When a comment doesn't have a date, the one without the date should be
+      // last.
+      var threadGroups = [
+        {
+          start_datetime: '2015-12-23 15:00:20.396000000',
+          comments: [],
+          locationRange: 'line',
+        },
+        {
+          comments: [],
+          locationRange: 'range-1-1-1-2',
+        },
+      ];
+
+      var expectedResult = [
+        {
+          start_datetime: '2015-12-23 15:00:20.396000000',
+          comments: [],
+          locationRange: 'line',
+        },
+        {
+          comments: [],
+          locationRange: 'range-1-1-1-2',
+        },
+      ];
+    });
+
+    test('_calculateLocationRange', function() {
+      var comment = {__commentSide: 'left'};
+      var range = {
+        start_line: 1,
+        start_character: 2,
+        end_line: 3,
+        end_character: 4,
+      };
+      assert.equal(
+        element._calculateLocationRange(range, comment), 'range-1-2-3-4-left');
+    });
+
+    test('thread groups are updated when comments change', function() {
+      var commentsChangedStub = sandbox.stub(element, '_commentsChanged');
+      element.comments = [];
+      element.comments.push({
+        id: 'sallys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000',
+      });
+      assert(commentsChangedStub.called);
+    });
+
+    test('addNewThread', function() {
+      var locationRange = 'range-1-2-3-4';
+      element._threads = [{locationRange: 'line'}];
+      element.addNewThread(locationRange);
+      assert(element._threads.length, 2);
+    });
+
+    test('_getPatchNum', function() {
+      element.patchForNewThreads = 3;
+      var comment = {
+        id: 'sallys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000',
+      };
+      assert.equal(element._getPatchNum(comment), 3);
+      comment.patchNum = 4;
+      assert.equal(element._getPatchNum(comment), 4);
+    });
+
+    test('removeThread', function() {
+      var locationRange = 'range-1-2-3-4';
+      element._threads = [
+        {locationRange: 'range-1-2-3-4', comments: []},
+        {locationRange: 'line', comments: []}
+      ];
+      flushAsynchronousOperations();
+      element.removeThread(locationRange);
+      flushAsynchronousOperations();
+      assert(element._threads.length, 1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index 25237b5..c19b643 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -16,33 +16,71 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../gr-diff-comment/gr-diff-comment.html">
 
 <dom-module id="gr-diff-comment-thread">
   <template>
     <style>
       :host {
-        border: 1px solid #ddd;
-        border-right: none;
+        border: 1px solid #bbb;
         display: block;
+        margin-bottom: 1px;
         white-space: normal;
       }
+      #actions {
+        padding: .5em .7em;
+      }
+      #container {
+        background-color: #fcfad6;
+      }
+      #container.unresolved {
+        background-color: #fcfaa6;
+      }
+      #commentInfoContainer {
+        border-top: 1px dotted #bbb;
+        display: flex;
+        justify-content: space-between;
+      }
+      #unresolvedLabel {
+        font-family: var(--font-family);
+        margin: auto 0 auto auto;
+        padding: .5em .7em;
+      }
     </style>
-    <div id="container">
-      <template id="commentList" is="dom-repeat" items="[[_orderedComments]]" as="comment">
+    <div id="container" class$="[[_computeHostClass(_unresolved)]]">
+      <template id="commentList" is="dom-repeat" items="[[_orderedComments]]"
+          as="comment">
         <gr-diff-comment
             comment="{{comment}}"
+            robot-button-disabled="[[_hideActions(_showActions, _lastComment)]]"
             change-num="[[changeNum]]"
             patch-num="[[patchNum]]"
             draft="[[comment.__draft]]"
             show-actions="[[_showActions]]"
+            comment-side="[[comment.__commentSide]]"
+            side="[[comment.side]]"
             project-config="[[projectConfig]]"
-            on-reply="_handleCommentReply"
-            on-comment-discard="_handleCommentDiscard"
-            on-done="_handleCommentDone"></gr-diff-comment>
+            on-create-fix-comment="_handleCommentFix"
+            on-comment-discard="_handleCommentDiscard"></gr-diff-comment>
       </template>
+      <div id="commentInfoContainer"
+          hidden$="[[_hideActions(_showActions, _lastComment)]]">
+        <div id="actions">
+          <gr-button id="replyBtn" class="action reply"
+              on-tap="_handleCommentReply">Reply</gr-button>
+          <gr-button id="quoteBtn" class="action quote"
+              on-tap="_handleCommentQuote">Quote</gr-button>
+          <gr-button id="ackBtn" class="action ack" on-tap="_handleCommentAck">
+            Ack</gr-button>
+          <gr-button id="doneBtn" class="action done" on-tap="_handleCommentDone">
+            Done</gr-button>
+        </div>
+        <span id="unresolvedLabel" hidden$="[[!_unresolved]]">Unresolved</span>
+      </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-diff-comment-thread.js"></script>
 </dom-module>
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 305c36a..be88e476 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
@@ -14,6 +14,9 @@
 (function() {
   'use strict';
 
+  var UNRESOLVED_EXPAND_COUNT = 5;
+  var NEWLINE_PATTERN = /\n/g;
+
   Polymer({
     is: 'gr-diff-comment-thread',
 
@@ -29,46 +32,74 @@
         type: Array,
         value: function() { return []; },
       },
+      locationRange: String,
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+      commentSide: String,
       patchNum: String,
       path: String,
       projectConfig: Object,
-      side: {
-        type: String,
-        value: 'REVISION',
+      isOnParent: {
+        type: Boolean,
+        value: false,
       },
 
       _showActions: Boolean,
+      _lastComment: Object,
       _orderedComments: Array,
+      _unresolved: {
+        type: Boolean,
+        notify: true,
+      },
     },
 
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
     listeners: {
       'comment-update': '_handleCommentUpdate',
     },
 
     observers: [
-      '_commentsChanged(comments.splices)',
+      '_commentsChanged(comments.*)',
     ],
 
+    keyBindings: {
+      'e shift+e': '_handleEKey',
+    },
+
     attached: function() {
       this._getLoggedIn().then(function(loggedIn) {
         this._showActions = loggedIn;
       }.bind(this));
+      this._setInitialExpandedState();
     },
 
-    addOrEditDraft: function(opt_lineNum) {
-      var lastComment = this.comments[this.comments.length - 1];
-      if (lastComment && lastComment.__draft) {
+    addOrEditDraft: function(opt_lineNum, opt_range) {
+      var lastComment = this.comments[this.comments.length - 1] || {};
+      if (lastComment.__draft) {
         var commentEl = this._commentElWithDraftID(
             lastComment.id || lastComment.__draftID);
         commentEl.editing = true;
+
+        // If the comment was collapsed, re-open it to make it clear which
+        // actions are available.
+        commentEl.collapsed = false;
       } else {
-        this.addDraft(opt_lineNum);
+        var range = opt_range ? opt_range :
+            lastComment ? lastComment.range : undefined;
+        var unresolved = lastComment ? lastComment.unresolved : undefined;
+        this.addDraft(opt_lineNum, range, unresolved);
       }
     },
 
-    addDraft: function(opt_lineNum, opt_range) {
+    addDraft: function(opt_lineNum, opt_range, opt_unresolved) {
       var draft = this._newDraft(opt_lineNum, opt_range);
       draft.__editing = true;
+      draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
       this.push('comments', draft);
     },
 
@@ -78,72 +109,137 @@
 
     _commentsChanged: function(changeRecord) {
       this._orderedComments = this._sortedComments(this.comments);
+      if (this._orderedComments.length) {
+        this._lastComment = this._getLastComment();
+        this._unresolved = this._lastComment.unresolved;
+      }
+    },
+
+    _hideActions: function(_showActions, _lastComment) {
+      return !_showActions || !_lastComment || !!_lastComment.__draft;
+    },
+
+    _getLastComment: function() {
+      return this._orderedComments[this._orderedComments.length - 1] || {};
+    },
+
+    _handleEKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      // Don’t preventDefault in this case because it will render the event
+      // useless for other handlers (other gr-diff-comment-thread elements).
+      if (e.detail.keyboardEvent.shiftKey) {
+        this._expandCollapseComments(true);
+      } else {
+        if (this.modifierPressed(e)) { return; }
+        this._expandCollapseComments(false);
+      }
+    },
+
+    _expandCollapseComments: function(actionIsCollapse) {
+      var comments =
+          Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+      comments.forEach(function(comment) {
+        comment.collapsed = actionIsCollapse;
+      });
+    },
+
+    /**
+     * Sets the initial state of the comment thread to have the last
+     * {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
+     * thread is unresolved.
+     */
+    _setInitialExpandedState: function() {
+      var comment;
+      if (this._orderedComments) {
+        for (var i = 0; i < this._orderedComments.length; i++) {
+          comment = this._orderedComments[i];
+          comment.collapsed =
+              this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT ||
+              !this._unresolved;
+        }
+      }
+
     },
 
     _sortedComments: function(comments) {
-      comments.sort(function(c1, c2) {
+      return comments.slice().sort(function(c1, c2) {
         var c1Date = c1.__date || util.parseDate(c1.updated);
         var c2Date = c2.__date || util.parseDate(c2.updated);
-        return c1Date - c2Date;
+        var dateCompare = c1Date - c2Date;
+        if (!c1.id || !c1.id.localeCompare) { return 0; }
+        // If same date, fall back to sorting by id.
+        return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
       });
-
-      var commentIDToReplies = {};
-      var topLevelComments = [];
-      for (var i = 0; i < comments.length; i++) {
-        var c = comments[i];
-        if (c.in_reply_to) {
-          if (commentIDToReplies[c.in_reply_to] == null) {
-            commentIDToReplies[c.in_reply_to] = [];
-          }
-          commentIDToReplies[c.in_reply_to].push(c);
-        } else {
-          topLevelComments.push(c);
-        }
-      }
-      var results = [];
-      for (var i = 0; i < topLevelComments.length; i++) {
-        this._visitComment(topLevelComments[i], commentIDToReplies, results);
-      }
-      for (var missingCommentId in commentIDToReplies) {
-        results = results.concat(commentIDToReplies[missingCommentId]);
-      }
-      return results;
     },
 
-    _visitComment: function(parent, commentIDToReplies, results) {
-      results.push(parent);
+    _createReplyComment: function(parent, content, opt_isEditing,
+        opt_unresolved) {
+      var reply = this._newReply(
+          this._orderedComments[this._orderedComments.length - 1].id,
+          parent.line,
+          content,
+          opt_unresolved,
+          parent.range);
 
-      var replies = commentIDToReplies[parent.id];
-      delete commentIDToReplies[parent.id];
-      if (!replies) { return; }
-      for (var i = 0; i < replies.length; i++) {
-        this._visitComment(replies[i], commentIDToReplies, results);
+      // If there is currently a comment in an editing state, add an attribute
+      // so that the gr-diff-comment knows not to populate the draft text.
+      for (var i = 0; i < this.comments.length; i++) {
+        if (this.comments[i].__editing) {
+          reply.__otherEditing = true;
+          break;
+        }
       }
+
+      if (opt_isEditing) {
+        reply.__editing = true;
+      }
+
+      this.push('comments', reply);
+
+      if (!opt_isEditing) {
+        // Allow the reply to render in the dom-repeat.
+        this.async(function() {
+          var commentEl = this._commentElWithDraftID(reply.__draftID);
+          commentEl.save();
+        }, 1);
+      }
+    },
+
+    _processCommentReply: function(opt_quote) {
+      var comment = this._lastComment;
+      var quoteStr;
+      if (opt_quote) {
+        var msg = comment.message;
+        quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+      }
+      this._createReplyComment(comment, quoteStr, true, comment.unresolved);
     },
 
     _handleCommentReply: function(e) {
-      var comment = e.detail.comment;
-      var quoteStr;
-      if (e.detail.quote) {
-        var msg = comment.message;
-        var quoteStr = msg.split('\n').map(
-            function(line) { return ' > ' + line; }).join('\n') + '\n\n';
-      }
-      var reply = this._newReply(comment.id, comment.line, quoteStr);
-      reply.__editing = true;
-      this.push('comments', reply);
+      this._processCommentReply();
+    },
+
+    _handleCommentQuote: function(e) {
+      this._processCommentReply(true);
+    },
+
+    _handleCommentAck: function(e) {
+      var comment = this._lastComment;
+      this._createReplyComment(comment, 'Ack', false, false);
     },
 
     _handleCommentDone: function(e) {
-      var comment = e.detail.comment;
-      var reply = this._newReply(comment.id, comment.line, 'Done');
-      this.push('comments', reply);
+      var comment = this._lastComment;
+      this._createReplyComment(comment, 'Done', false, false);
+    },
 
-      // Allow the reply to render in the dom-repeat.
-      this.async(function() {
-        var commentEl = this._commentElWithDraftID(reply.__draftID);
-        commentEl.save();
-      }.bind(this), 1);
+    _handleCommentFix: function(e) {
+      var comment = e.detail.comment;
+      var msg = comment.message;
+      var quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+      var response = quoteStr + 'Please Fix';
+      this._createReplyComment(comment, response, false, true);
     },
 
     _commentElWithDraftID: function(id) {
@@ -156,12 +252,17 @@
       return null;
     },
 
-    _newReply: function(inReplyTo, opt_lineNum, opt_message) {
+    _newReply: function(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
+          opt_range) {
       var d = this._newDraft(opt_lineNum);
       d.in_reply_to = inReplyTo;
+      d.range = opt_range;
       if (opt_message != null) {
         d.message = opt_message;
       }
+      if (opt_unresolved !== undefined) {
+        d.unresolved = opt_unresolved;
+      }
       return d;
     },
 
@@ -171,7 +272,9 @@
         __draftID: Math.random().toString(36),
         __date: new Date(),
         path: this.path,
-        side: this.side,
+        patchNum: this.patchNum,
+        side: this._getSide(this.isOnParent),
+        __commentSide: this.commentSide,
       };
       if (opt_lineNum) {
         d.line = opt_lineNum;
@@ -187,6 +290,11 @@
       return d;
     },
 
+    _getSide: function(isOnParent) {
+      if (isOnParent) { return 'PARENT'; }
+      return 'REVISION';
+    },
+
     _handleCommentDiscard: function(e) {
       var diffCommentEl = Polymer.dom(e).rootTarget;
       var comment = diffCommentEl.comment;
@@ -199,6 +307,21 @@
       if (this.comments.length == 0) {
         this.fire('thread-discard', {lastComment: comment});
       }
+
+      // Check to see if there are any other open comments getting edited and
+      // set the local storage value to its message value.
+      for (var i = 0; i < this.comments.length; i++) {
+        if (this.comments[i].__editing) {
+          var commentLocation = {
+            changeNum: this.changeNum,
+            patchNum: this.patchNum,
+            path: this.comments[i].path,
+            line: this.comments[i].line,
+          };
+          return this.$.storage.setDraftComment(commentLocation,
+              this.comments[i].message);
+        }
+      }
     },
 
     _handleCommentUpdate: function(e) {
@@ -206,10 +329,10 @@
       var index = this._indexOf(comment, this.comments);
       if (index === -1) {
         // This should never happen: comment belongs to another thread.
-        console.error('Comment update for another comment thread.');
+        console.warn('Comment update for another comment thread.');
         return;
       }
-      this.comments[index] = comment;
+      this.set(['comments', index], comment);
     },
 
     _indexOf: function(comment, arr) {
@@ -222,5 +345,9 @@
       }
       return -1;
     },
+
+    _computeHostClass: function(unresolved) {
+      return unresolved ? 'unresolved' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index 641dc0f..546308e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -25,6 +25,8 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-comment-thread.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-diff-comment-thread></gr-diff-comment-thread>
@@ -40,13 +42,21 @@
 <script>
   suite('gr-diff-comment-thread tests', function() {
     var element;
+    var sandbox;
+
     setup(function() {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn: function() { return Promise.resolve(false); },
       });
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('comments are sorted correctly', function() {
       var comments = [
         {
@@ -54,33 +64,28 @@
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_confession',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sally_to_dr_finklestein',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_defiance',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'dr_finklesteins_response',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000'
-        },
-        {
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
           id: 'sallys_mission',
           message: 'i have to find santa',
-          updated: '2015-12-24 21:00:20.396000000'
+          updated: '2015-12-24 15:00:20.396000000',
         }
       ];
       var results = element._sortedComments(comments);
@@ -89,37 +94,73 @@
           id: 'sally_to_dr_finklestein',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'dr_finklesteins_response',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000'
-        },
-        {
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
           id: 'sallys_defiance',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_confession',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
           id: 'jacks_reply',
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 21:00:20.396000000'
         }
       ]);
     });
+
+    test('addOrEditDraft w/ edit draft', function() {
+      element.comments = [{
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        in_reply_to: 'sallys_confession',
+        updated: '2015-12-25 15:00:20.396000000',
+        __draft: true,
+      }];
+      var commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          function() { return {}; });
+      var addDraftStub = sandbox.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isTrue(commentElStub.called);
+      assert.isFalse(addDraftStub.called);
+    });
+
+    test('addOrEditDraft w/o edit draft', function() {
+      element.comments = [];
+      var commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          function() { return {}; });
+      var addDraftStub = sandbox.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isFalse(commentElStub.called);
+      assert.isTrue(addDraftStub.called);
+    });
+
+    test('_hideActions', function() {
+      var showActions = true;
+      var lastComment = {};
+      assert.equal(element._hideActions(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      var showActions = true;
+      lastComment.__draft = true;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+    });
   });
 
   suite('comment action tests', function() {
@@ -138,7 +179,7 @@
                   line: 5,
                   in_reply_to: 'baf0414d_60047215',
                   updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done'
+                  message: 'Done',
                 }));
             },
           });
@@ -162,32 +203,86 @@
     test('reply', function(done) {
       var commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
-      commentEl.addEventListener('reply', function() {
-        var drafts = element._orderedComments.filter(function(c) {
-          return c.__draft == true;
-        });
-        assert.equal(drafts.length, 1);
-        assert.notOk(drafts[0].message, 'message should be empty');
-        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-        done();
+
+      var replyBtn = element.$.replyBtn;
+      MockInteractions.tap(replyBtn);
+      flushAsynchronousOperations();
+
+      var drafts = element._orderedComments.filter(function(c) {
+        return c.__draft == true;
       });
-      commentEl.fire('reply', {comment: commentEl.comment}, {bubbles: false});
+      assert.equal(drafts.length, 1);
+      assert.notOk(drafts[0].message, 'message should be empty');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      done();
     });
 
     test('quote reply', function(done) {
       var commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
-      commentEl.addEventListener('reply', function() {
-        var drafts = element._orderedComments.filter(function(c) {
+
+      var quoteBtn = element.$.quoteBtn;
+      MockInteractions.tap(quoteBtn);
+      flushAsynchronousOperations();
+
+      var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+      });
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      done();
+    });
+
+    test('quote reply multiline', function(done) {
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?\nIt might be!',
+        updated: '2015-12-08 19:48:33.843000000',
+      }];
+      flushAsynchronousOperations();
+
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+
+      var quoteBtn = element.$.quoteBtn;
+      MockInteractions.tap(quoteBtn);
+      flushAsynchronousOperations();
+
+      var drafts = element._orderedComments.filter(function(c) {
+        return c.__draft == true;
+      });
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message,
+          '> is this a crossover episode!?\n> It might be!\n\n');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      done();
+    });
+
+    test('ack', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+
+      var ackBtn = element.$.ackBtn;
+      MockInteractions.tap(ackBtn);
+      flush(function() {
+        var drafts = element.comments.filter(function(c) {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
-        assert.equal(drafts[0].message, ' > is this a crossover episode!?\n\n');
+        assert.equal(drafts[0].message, 'Ack');
         assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        assert.equal(drafts[0].unresolved, false);
         done();
       });
-      commentEl.fire('reply', {comment: commentEl.comment, quote: true},
-          {bubbles: false});
     });
 
     test('done', function(done) {
@@ -195,16 +290,39 @@
       element.patchNum = '1';
       var commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
-      commentEl.addEventListener('done', function() {
-        var drafts = element._orderedComments.filter(function(c) {
+
+      var doneBtn = element.$.doneBtn;
+      MockInteractions.tap(doneBtn);
+      flush(function() {
+        var drafts = element.comments.filter(function(c) {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
         assert.equal(drafts[0].message, 'Done');
         assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        assert.isFalse(drafts[0].unresolved);
         done();
       });
-      commentEl.fire('done', {comment: commentEl.comment}, {bubbles: false});
+    });
+
+    test('please fix', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('create-fix-comment', function() {
+        var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(
+            drafts[0].message, '> is this a crossover episode!?\n\nPlease Fix');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        assert.isTrue(drafts[0].unresolved);
+        done();
+      });
+      commentEl.fire('create-fix-comment', {comment: commentEl.comment},
+          {bubbles: false});
     });
 
     test('discard', function(done) {
@@ -230,6 +348,85 @@
       draftEl.fire('comment-discard', null, {bubbles: false});
     });
 
+    test('first editing comment does not add __otherEditing attribute',
+        function() {
+      var commentEl = element.$$('gr-diff-comment');
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+        __draft: true,
+      }];
+
+      var replyBtn = element.$.replyBtn;
+      MockInteractions.tap(replyBtn);
+      flushAsynchronousOperations();
+
+      var editing = element._orderedComments.filter(function(c) {
+        return c.__editing == true;
+      });
+      assert.equal(editing.length, 1);
+      assert.equal(!!editing[0].__otherEditing, false);
+    });
+
+    test('When not editing other comments, local storage not set after discard',
+        function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:31.843000000',
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        __draftID: '1',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'yes',
+        updated: '2015-12-08 19:48:32.843000000',
+        __draft: true,
+        __editing: true,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        __draftID: '2',
+        in_reply_to: 'baf0414d_60047215',
+        line: 5,
+        message: 'no',
+        updated: '2015-12-08 19:48:33.843000000',
+        __draft: true,
+      }];
+      var storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+      flushAsynchronousOperations();
+
+      var draftEl =
+          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+      assert.ok(draftEl);
+      draftEl.addEventListener('comment-discard', function() {
+        assert.isFalse(storageStub.called);
+        storageStub.restore();
+        done();
+      });
+      draftEl.fire('comment-discard', null, {bubbles: false});
+    });
+
     test('comment-update', function() {
       var commentEl = element.$$('gr-diff-comment');
       var updatedComment = {
@@ -240,33 +437,126 @@
       assert.strictEqual(element.comments[0], updatedComment);
     });
 
-    test('orphan replies', function() {
-      var comments = [
+    suite('jack and sally comment data test consolidation', function() {
+      var getComments = function() {
+        return Polymer.dom(element.root).querySelectorAll('gr-diff-comment');
+      };
+
+      setup(function() {
+        element.comments = [
         {
           id: 'jacks_reply',
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
+          unresolved: false,
+        }, {
           id: 'sallys_confession',
           in_reply_to: 'nonexistent_comment',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sally_to_dr_finklestein',
           in_reply_to: 'nonexistent_comment',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_defiance',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
         }];
-      element.comments = comments;
-      assert.equal(4, element._orderedComments.length);
+      });
+
+      test('orphan replies', function() {
+        assert.equal(4, element._orderedComments.length);
+      });
+
+      test('keyboard shortcuts', function() {
+        var expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
+        MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
+        assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+        MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+        assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+        expandCollapseStub.restore();
+      });
+
+      test('comment in_reply_to is either null or most recent comment id',
+          function() {
+        element._createReplyComment(element.comments[3], 'dummy', true);
+        flushAsynchronousOperations();
+        assert.equal(element._orderedComments.length, 5);
+        assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
+      });
+
+      test('resolvable comments', function() {
+        assert.isFalse(element._unresolved);
+        element._createReplyComment(element.comments[3], 'dummy', true, true);
+        flushAsynchronousOperations();
+        assert.isTrue(element._unresolved);
+      });
+
+      test('_setInitialExpandedState', function() {
+        element._unresolved = true;
+        element._setInitialExpandedState();
+        var comments = getComments();
+        for (var i = 0; i < element.comments.length; i++) {
+          assert.isFalse(element.comments[i].collapsed);
+        }
+        element._unresolved = false;
+        element._setInitialExpandedState();
+        var comments = getComments();
+        for (var i = 0; i < element.comments.length; i++) {
+          assert.isTrue(element.comments[i].collapsed);
+        }
+      });
+    });
+
+    test('_computeHostClass', function() {
+      assert.equal(element._computeHostClass(true), 'unresolved');
+      assert.equal(element._computeHostClass(false), '');
+    });
+
+    test('addDraft sets unresolved state correctly', function() {
+      var unresolved = true;
+      element.comments = [];
+      element.addDraft(null, null, unresolved);
+      assert.equal(element.comments[0].unresolved, true);
+
+      unresolved = false; // comment should get added as actually resolved.
+      element.comments = [];
+      element.addDraft(null, null, unresolved);
+      assert.equal(element.comments[0].unresolved, false);
+
+      element.comments = [];
+      element.addDraft();
+      assert.equal(element.comments[0].unresolved, true);
+    });
+
+    test('_newDraft', function() {
+      element.commentSide = 'left';
+      element.patchNum = 3;
+      var draft = element._newDraft();
+      assert.equal(draft.__commentSide, 'left');
+      assert.equal(draft.patchNum, 3);
+    });
+
+    test('new comment gets created', function() {
+      element.comments = [];
+      element.addOrEditDraft(1);
+      assert.equal(element.comments.length, 1);
+      // Mock a submitted comment.
+      element.comments[0].id = element.comments[0].__draftID;
+      element.comments[0].__draft = false;
+      element.addOrEditDraft(1);
+      assert.equal(element.comments.length, 2);
+    });
+
+    test('unresolved label', function() {
+      element._unresolved = false;
+      assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
+      element._unresolved = true;
+      assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index c3b6233..4c00132 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
@@ -18,41 +18,51 @@
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-formatted-text/gr-formatted-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">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+
 
 <dom-module id="gr-diff-comment">
   <template>
     <style>
       :host {
-        background-color: #ffd;
         display: block;
+        font-family: var(--font-family);
+        padding: .7em .7em;
         --iron-autogrow-textarea: {
           padding: 2px;
         };
       }
-      :host([disabled]) {
+      :host[disabled] {
         pointer-events: none;
       }
-      :host([disabled]) .container {
+      :host[disabled] .container {
         opacity: .5;
       }
-      .header,
-      .message,
-      .actions {
-        padding: .5em .7em;
+      :host[is-robot-comment] {
+        background-color: #cfe8fc;
       }
       .header {
+        cursor: pointer;
         display: flex;
-        padding-bottom: 0;
         font-family: 'Open Sans', sans-serif;
+        margin-bottom: 0.7em;
+        padding-bottom: 0;
       }
-      .headerLeft {
+      .container.collapsed .header {
+        margin: 0;
+      }
+      .headerMiddle {
+        color: #666;
         flex: 1;
+        overflow: hidden;
       }
       .authorName,
       .draftLabel {
+        display: block;
+        float: left;
         font-weight: bold;
       }
       .draftLabel {
@@ -62,6 +72,7 @@
       .date {
         justify-content: flex-end;
         margin-left: 5px;
+        white-space: nowrap;
       }
       a.date:link,
       a.date:visited {
@@ -74,24 +85,17 @@
       .action {
         margin-right: .5em;
       }
-      .danger {
-        display: flex;
-        flex: 1;
-        justify-content: flex-end;
-      }
       .editMessage {
         display: none;
-        margin: .5em .7em;
-        width: calc(100% - 1.4em - 2px);
+        margin: .5em 0;
+        width: 100%;
       }
-      .danger .action {
-        margin-right: 0;
-      }
-      .container:not(.draft) .actions :not(.reply):not(.quote):not(.done) {
+      .container:not(.draft) .actions .hideOnPublished {
         display: none;
       }
       .draft .reply,
       .draft .quote,
+      .draft .ack,
       .draft .done {
         display: none;
       }
@@ -99,57 +103,167 @@
         display: inline;
       }
       .draft:not(.editing) .save,
-      .draft:not(.editing) .cancel {
+      .draft:not(.editing) .cancel,
+      .draft:not(.editing) .resolve {
         display: none;
       }
       .editing .message,
       .editing .reply,
       .editing .quote,
+      .editing .ack,
       .editing .done,
-      .editing .edit {
+      .editing .edit,
+      .editing .unresolved {
         display: none;
       }
       .editing .editMessage {
         background-color: #fff;
         display: block;
       }
+      .show-hide {
+        margin-left: .4em;
+      }
+      .robotId {
+        color: #808080;
+        margin-bottom: .8em;
+        margin-top: -.4em;
+      }
+      .runIdInformation {
+        margin: 1em 0;
+      }
+      .robotRun {
+        margin-left: .5em;
+      }
+      .robotRunLink {
+        margin-left: .5em;
+      }
+      input.show-hide {
+        display: none;
+      }
+      label.show-hide {
+        color: #000;
+        cursor: pointer;
+        display: block;
+        font-size: .8em;
+        height: 1.1em;
+        margin-top: .1em;
+      }
+      #container .collapsedContent {
+        display: none;
+      }
+      #container.collapsed {
+        padding-bottom: 3px;
+      }
+      #container.collapsed .collapsedContent {
+        display: block;
+        overflow: hidden;
+        padding-left: 5px;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      #container.collapsed .actions,
+      #container.collapsed gr-formatted-text,
+      #container.collapsed iron-autogrow-textarea {
+        display: none;
+      }
+      .resolve,
+      .unresolved {
+        align-items: center;
+        display: flex;
+        flex: 1;
+        justify-content: flex-end;
+      }
+      .resolve label {
+        color: #333;
+        font-size: 12px;
+      }
     </style>
     <div id="container"
         class="container"
         on-mouseenter="_handleMouseEnter"
         on-mouseleave="_handleMouseLeave">
-      <div class="header" id="header">
+      <div class="header" id="header" on-click="_handleToggleCollapsed">
         <div class="headerLeft">
           <span class="authorName">[[comment.author.name]]</span>
-          <span class="draftLabel">DRAFT</span>
+          <gr-tooltip-content class="draftLabel"
+              has-tooltip
+              title="This draft is only visible to you. To publish drafts, click the red 'Reply' button at the top of the change or press the 'A' key."
+              max-width="20em"
+              show-icon>
+            DRAFT
+          </gr-tooltip-content>
+        </div>
+        <div class="headerMiddle">
+          <span class="collapsedContent">[[comment.message]]</span>
         </div>
         <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
-          <gr-date-formatter date-str="[[comment.updated]]"></gr-date-formatter>
+          <gr-date-formatter
+              has-tooltip
+              date-str="[[comment.updated]]"></gr-date-formatter>
         </a>
+        <div class="show-hide">
+          <label class="show-hide">
+            <input type="checkbox" class="show-hide"
+               checked$="[[collapsed]]"
+               on-change="_handleToggleCollapsed">
+            [[_computeShowHideText(collapsed)]]
+          </label>
+        </div>
       </div>
+      <template is="dom-if" if="[[comment.robot_id]]">
+        <div class="robotId" hidden$="[[collapsed]]">
+          [[comment.robot_id]]
+        </div>
+      </template>
       <iron-autogrow-textarea
           id="editTextarea"
           class="editMessage"
+          autocomplete="on"
           disabled="{{disabled}}"
           rows="4"
           bind-value="{{_messageText}}"
           on-keydown="_handleTextareaKeydown"></iron-autogrow-textarea>
-      <gr-linked-text class="message"
-          pre
+      <gr-formatted-text class="message"
           content="[[comment.message]]"
-          config="[[projectConfig.commentlinks]]"></gr-linked-text>
-      <div class="actions" hidden$="[[!showActions]]">
-        <gr-button class="action reply" on-tap="_handleReply">Reply</gr-button>
-        <gr-button class="action quote" on-tap="_handleQuote">Quote</gr-button>
-        <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(_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>
+          no-trailing-margin="[[!comment.__draft]]"
+          collapsed="[[collapsed]]"
+          config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+      <div hidden$="[[!comment.robot_run_id]]">
+        <div class="runIdInformation" hidden$="[[collapsed]]">
+          Run ID:
+          <a class="robotRunLink" href$="[[comment.url]]">
+            <span class="robotRun">[[comment.robot_run_id]]</span>
+          </a>
         </div>
       </div>
+      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
+        <gr-button class="action edit hideOnPublished" on-tap="_handleEdit">
+            Edit</gr-button>
+        <gr-button class="action save hideOnPublished" on-tap="_handleSave"
+            disabled$="[[_computeSaveDisabled(_messageText)]]">Save</gr-button>
+        <gr-button class="action cancel hideOnPublished"
+            on-tap="_handleCancel" hidden>Cancel</gr-button>
+        <gr-button class="action discard hideOnPublished"
+            on-tap="_handleDiscard">Discard</gr-button>
+        <div class="action resolve hideOnPublished">
+          <label>
+            <input type="checkbox"
+                checked$="[[resolved]]"
+                on-change="_handleToggleResolved">
+            Resolved
+          </label>
+        </div>
+        <div class="action unresolved hideOnPublished" hidden$="[[resolved]]">
+          Unresolved
+        </div>
+      </div>
+      <div class="actions robotActions" hidden$="[[!_showRobotActions]]">
+        <gr-button class="action fix"
+            on-tap="_handleFix"
+            disabled="[[robotButtonDisabled]]">
+          Please Fix
+        </gr-button>
+      </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
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 1b30bde..0791193 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
@@ -20,15 +20,9 @@
     is: 'gr-diff-comment',
 
     /**
-     * Fired when the Reply action is triggered.
+     * Fired when the create fix comment action is triggered.
      *
-     * @event reply
-     */
-
-    /**
-     * Fired when the Done action is triggered.
-     *
-     * @event done
+     * @event create-fix-comment
      */
 
     /**
@@ -64,6 +58,11 @@
         notify: true,
         observer: '_commentChanged',
       },
+      isRobotComment: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
       disabled: {
         type: Boolean,
         value: false,
@@ -79,9 +78,18 @@
         value: false,
         observer: '_editingChanged',
       },
+      hasChildren: Boolean,
       patchNum: String,
       showActions: Boolean,
+      _showHumanActions: Boolean,
+      _showRobotActions: Boolean,
+      collapsed: {
+        type: Boolean,
+        value: true,
+        observer: '_toggleCollapseClass',
+      },
       projectConfig: Object,
+      robotButtonDisabled: Boolean,
 
       _xhrPromise: Object,  // Used for testing.
       _messageText: {
@@ -89,27 +97,56 @@
         value: '',
         observer: '_messageTextChanged',
       },
+      commentSide: String,
+
+      resolved: {
+        type: Boolean,
+        observer: '_toggleResolved',
+      },
     },
 
     observers: [
       '_commentMessageChanged(comment.message)',
       '_loadLocalDraft(changeNum, patchNum, comment)',
+      '_isRobotComment(comment)',
+      '_calculateActionstoShow(showActions, isRobotComment)',
     ],
 
+    attached: function() {
+      if (this.editing) {
+        this.collapsed = false;
+      } else if (this.comment) {
+        this.collapsed = this.comment.collapsed;
+      }
+    },
+
     detached: function() {
       this.cancelDebouncer('fire-update');
     },
 
+    _computeShowHideText: function(collapsed) {
+      return collapsed ? '◀' : '▼';
+    },
+
+    _calculateActionstoShow: function(showActions, isRobotComment) {
+      this._showHumanActions = showActions && !isRobotComment;
+      this._showRobotActions = showActions && isRobotComment;
+    },
+
+    _isRobotComment: function(comment) {
+      this.isRobotComment = !!comment.robot_id;
+    },
+
+    isOnParent: function() {
+      return this.side === 'PARENT';
+    },
+
     save: function() {
       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._eraseDraftComment();
 
       this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
         this.disabled = false;
@@ -123,6 +160,7 @@
           if (this.comment.__draftID) {
             comment.__draftID = this.comment.__draftID;
           }
+          comment.__commentSide = this.commentSide;
           this.comment = comment;
           this.editing = false;
           this._fireSave();
@@ -134,8 +172,19 @@
       }.bind(this));
     },
 
+    _eraseDraftComment: function() {
+      this.$.storage.eraseDraftComment({
+        changeNum: this.changeNum,
+        patchNum: this._getPatchNum(),
+        path: this.comment.path,
+        line: this.comment.line,
+        range: this.comment.range,
+      });
+    },
+
     _commentChanged: function(comment) {
       this.editing = !!comment.__editing;
+      this.resolved = !comment.unresolved;
       if (this.editing) { // It's a new draft/reply, notify.
         this._fireUpdate();
       }
@@ -198,8 +247,34 @@
     },
 
     _handleTextareaKeydown: function(e) {
-      if (e.keyCode == 27) {  // 'esc'
-        this._handleCancel(e);
+      switch (e.keyCode) {
+        case 13: // 'enter'
+          if (this._messageText.length !== 0 && (e.metaKey || e.ctrlKey)) {
+            this._handleSave(e);
+          }
+          break;
+        case 27: // 'esc'
+          if (this._messageText.length === 0) {
+            this._handleCancel(e);
+          }
+          break;
+        case 83: // 's'
+          if (this._messageText.length !== 0 && e.ctrlKey) {
+            this._handleSave(e);
+          }
+          break;
+      }
+    },
+
+    _handleToggleCollapsed: function() {
+      this.collapsed = !this.collapsed;
+    },
+
+    _toggleCollapseClass: function(collapsed) {
+      if (collapsed) {
+        this.$.container.classList.add('collapsed');
+      } else {
+        this.$.container.classList.remove('collapsed');
       }
     },
 
@@ -210,14 +285,18 @@
     _messageTextChanged: function(newValue, oldValue) {
       if (!this.comment || (this.comment && this.comment.id)) { return; }
 
+      // Keep comment.message in sync so that gr-diff-comment-thread is aware
+      // of the current message in the case that another comment is deleted.
+      this.comment.message = this._messageText || '';
       this.debounce('store', function() {
         var message = this._messageText;
 
         var commentLocation = {
           changeNum: this.changeNum,
-          patchNum: this.patchNum,
+          patchNum: this._getPatchNum(),
           path: this.comment.path,
           line: this.comment.line,
+          range: this.comment.range,
         };
 
         if ((!this._messageText || !this._messageText.length) && oldValue) {
@@ -243,35 +322,51 @@
     },
 
     _handleReply: function(e) {
-      this._preventDefaultAndBlur(e);
-      this.fire('reply', this._getEventPayload(), {bubbles: false});
+      e.preventDefault();
+      this.fire('create-reply-comment', this._getEventPayload(),
+          {bubbles: false});
     },
 
     _handleQuote: function(e) {
-      this._preventDefaultAndBlur(e);
-      this.fire(
-          'reply', this._getEventPayload({quote: true}), {bubbles: false});
+      e.preventDefault();
+      this.fire('create-reply-comment', this._getEventPayload({quote: true}),
+          {bubbles: false});
+    },
+
+    _handleFix: function(e) {
+      e.preventDefault();
+      this.fire('create-fix-comment', this._getEventPayload({quote: true}),
+          {bubbles: false});
+    },
+
+    _handleAck: function(e) {
+      e.preventDefault();
+      this.fire('create-ack-comment', this._getEventPayload(),
+          {bubbles: false});
     },
 
     _handleDone: function(e) {
-      this._preventDefaultAndBlur(e);
-      this.fire('done', this._getEventPayload(), {bubbles: false});
+      e.preventDefault();
+      this.fire('create-done-comment', this._getEventPayload(),
+          {bubbles: false});
     },
 
     _handleEdit: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       this._messageText = this.comment.message;
       this.editing = true;
     },
 
     _handleSave: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
+      this.set('comment.__editing', false);
       this.save();
     },
 
     _handleCancel: function(e) {
-      this._preventDefaultAndBlur(e);
-      if (this.comment.message == null || this.comment.message.length == 0) {
+      e.preventDefault();
+      if (!this.comment.message ||
+          this.comment.message.trim().length === 0) {
         this._fireDiscard();
         return;
       }
@@ -285,12 +380,14 @@
     },
 
     _handleDiscard: function(e) {
-      this._preventDefaultAndBlur(e);
+      e.preventDefault();
       if (!this.comment.__draft) {
         throw Error('Cannot discard a non-draft comment.');
       }
       this.editing = false;
       this.disabled = true;
+      this._eraseDraftComment();
+
       if (!this.comment.id) {
         this.disabled = false;
         this._fireDiscard();
@@ -309,11 +406,6 @@
           }.bind(this));
     },
 
-    _preventDefaultAndBlur: function(e) {
-      e.preventDefault();
-      Polymer.dom(e).rootTarget.blur();
-    },
-
     _saveDraft: function(draft) {
       return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft);
     },
@@ -323,18 +415,27 @@
           draft);
     },
 
+    _getPatchNum: function() {
+      return this.isOnParent() ? 'PARENT' : this.patchNum;
+    },
+
     _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) {
+      //
+      // Don't get local draft if there is another comment that is currently
+      // in an editing state.
+      if (!comment || comment.id || comment.message || comment.__otherEditing) {
+        delete comment.__otherEditing;
         return;
       }
 
       var draft = this.$.storage.getDraftComment({
         changeNum: changeNum,
-        patchNum: patchNum,
+        patchNum: this._getPatchNum(),
         path: comment.path,
         line: comment.line,
+        range: comment.range,
       });
 
       if (draft) {
@@ -349,5 +450,14 @@
     _handleMouseLeave: function(e) {
       this.fire('comment-mouse-out', this._getEventPayload());
     },
+
+    _handleToggleResolved: function() {
+      this.resolved = !this.resolved;
+    },
+
+    _toggleResolved: function(resolved) {
+      this.comment.unresolved = !resolved;
+      this.fire('comment-update', this._getEventPayload());
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index fcf8b41..919a64f 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
@@ -26,6 +26,8 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-comment.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-diff-comment></gr-diff-comment>
@@ -39,8 +41,15 @@
 </test-fixture>
 
 <script>
+
+  function isVisible(el) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') !== 'none';
+  }
+
   suite('gr-diff-comment tests', function() {
     var element;
+    var sandbox;
     setup(function() {
       stub('gr-rest-api-interface', {
         getAccount: function() { return Promise.resolve(null); },
@@ -56,30 +65,39 @@
         message: 'is this a crossover episode!?',
         updated: '2015-12-08 19:48:33.843000000',
       };
+      sandbox = sinon.sandbox.create();
     });
 
-    test('proper event fires on reply', function(done) {
-      element.addEventListener('reply', function(e) {
-        assert.ok(e.detail.comment);
-        done();
-      });
-      MockInteractions.tap(element.$$('.reply'));
+    teardown(function() {
+      sandbox.restore();
     });
 
-    test('proper event fires on quote', function(done) {
-      element.addEventListener('reply', function(e) {
-        assert.ok(e.detail.comment);
-        assert.isTrue(e.detail.quote);
-        done();
-      });
-      MockInteractions.tap(element.$$('.quote'));
-    });
+    test('collapsible comments', function() {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
 
-    test('proper event fires on done', function(done) {
-      element.addEventListener('done', function(e) {
-        done();
-      });
-      MockInteractions.tap(element.$$('.done'));
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      // When the header row is clicked, the comment should expand
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
     });
 
     test('clicking on date link does not trigger nav', function() {
@@ -92,10 +110,149 @@
           'Should navigate to ' + dest + ' without triggering nav');
       showStub.restore();
     });
+
+    test('message is not retrieved from storage when other editing is true',
+        function(done) {
+      var storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      var loadSpy = sandbox.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+        __otherEditing: true,
+      };
+      flush(function() {
+        assert.isTrue(loadSpy.called);
+        assert.isFalse(storageStub.called);
+        done();
+      });
+    });
+
+    test('message is retrieved from storage when there is no other editing',
+        function(done) {
+      var storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      var loadSpy = sandbox.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+      };
+      flush(function() {
+        assert.isTrue(loadSpy.called);
+        assert.isTrue(storageStub.called);
+        done();
+      });
+    });
+
+    test('_getPatchNum', function() {
+      element.side = 'PARENT';
+      element.patchNum = 1;
+      assert.equal(element._getPatchNum(), 'PARENT');
+      element.side = 'REVISION';
+      assert.equal(element._getPatchNum(), 1);
+    });
+
+    test('comment expand and collapse', function() {
+      element.collapsed = true;
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is is not visible');
+    });
+
+    suite('while editing', function() {
+      setup(function() {
+        element.editing = true;
+        element._messageText = 'test';
+        sandbox.stub(element, '_handleCancel');
+        sandbox.stub(element, '_handleSave');
+        flushAsynchronousOperations();
+      });
+
+      suite('when text is empty', function() {
+        setup(function() {
+          element._messageText = '';
+        });
+
+        test('esc closes comment when text is empty', function() {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.$.editTextarea, 27); // esc
+          assert.isTrue(element._handleCancel.called);
+        });
+
+        test('ctrl+enter does not save', function() {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.$.editTextarea, 13, 'ctrl'); // ctrl + enter
+          assert.isFalse(element._handleSave.called);
+        });
+
+        test('meta+enter does not save', function() {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.$.editTextarea, 13, 'meta'); // meta + enter
+          assert.isFalse(element._handleSave.called);
+        });
+
+        test('ctrl+s does not save', function() {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.$.editTextarea, 83, 'ctrl'); // ctrl + s
+          assert.isFalse(element._handleSave.called);
+        });
+      });
+
+      test('esc does not close comment that has content', function() {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.$.editTextarea, 27); // esc
+        assert.isFalse(element._handleCancel.called);
+      });
+
+      test('ctrl+enter saves', function() {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.$.editTextarea, 13, 'ctrl'); // ctrl + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('meta+enter saves', function() {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.$.editTextarea, 13, 'meta'); // meta + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('ctrl+s saves', function() {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.$.editTextarea, 83, 'ctrl'); // ctrl + s
+        assert.isTrue(element._handleSave.called);
+      });
+    });
   });
 
   suite('gr-diff-comment draft tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
       stub('gr-rest-api-interface', {
@@ -128,41 +285,47 @@
       element.patchNum = 1;
       element.editing = false;
       element.comment = {
+        __commentSide: 'right',
         __draft: true,
         __draftID: 'temp_draft_id',
         path: '/path/to/file',
         line: 5,
       };
+      element.commentSide = 'right';
+      sandbox = sinon.sandbox.create();
     });
 
-    function isVisible(el) {
-      assert.ok(el);
-      return getComputedStyle(el).getPropertyValue('display') != 'none';
-    }
+    teardown(function() {
+      sandbox.restore();
+    });
 
     test('button visibility states', function() {
       element.showActions = false;
-      assert.isTrue(element.$$('.actions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+
       element.showActions = true;
-      assert.isFalse(element.$$('.actions').hasAttribute('hidden'));
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
 
       element.draft = true;
       assert.isTrue(isVisible(element.$$('.edit')), 'edit is visible');
       assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
       assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
       assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
-      assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
-      assert.isFalse(isVisible(element.$$('.quote')), 'quote is not visible');
-      assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
+      assert.isFalse(isVisible(element.$$('.resolve')),
+          'resolve is not visible');
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
 
       element.editing = true;
       assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
       assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
       assert.isTrue(isVisible(element.$$('.save')), 'save is visible');
       assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is visible');
-      assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
-      assert.isFalse(isVisible(element.$$('.quote')), 'quote is not visible');
-      assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
+      assert.isTrue(isVisible(element.$$('.resolve')), 'resolve is visible');
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
 
       element.draft = false;
       element.editing = false;
@@ -171,14 +334,87 @@
           'discard is not visible');
       assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
       assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
-      assert.isTrue(isVisible(element.$$('.reply')), 'reply is visible');
-      assert.isTrue(isVisible(element.$$('.quote')), 'quote is visible');
-      assert.isTrue(isVisible(element.$$('.done')), 'done is visible');
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
 
       element.comment.id = 'foo';
       element.draft = true;
       element.editing = true;
       assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
+      assert.isFalse(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
+
+      element.isRobotComment = true;
+      element.draft = true;
+      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
+
+      // It is not expected to see Robot comment drafts, but if they appear,
+      // they will behave the same as non-drafts.
+      element.draft = false;
+      assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
+    });
+
+    test('collapsible drafts', function() {
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is is not visible');
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isFalse(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      MockInteractions.tap(element.$.header);
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(isVisible(element.$$('gr-formatted-text')),
+          'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
     });
 
     test('draft creation/cancelation', function(done) {
@@ -187,6 +423,8 @@
       assert.isTrue(element.editing);
 
       element._messageText = '';
+      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+
       // Save should be disabled on an empty message.
       var disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
@@ -200,17 +438,42 @@
       var numDiscardEvents = 0;
       element.addEventListener('comment-discard', function(e) {
         numDiscardEvents++;
-        if (numDiscardEvents == 3) {
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
           assert.isFalse(updateStub.called);
           done();
         }
       });
       MockInteractions.tap(element.$$('.cancel'));
-      MockInteractions.tap(element.$$('.discard'));
       element.flushDebouncer('fire-update');
+      element._messageText = '';
       MockInteractions.pressAndReleaseKeyOn(element.$.editTextarea, 27); // esc
     });
 
+    test('draft discard removes message from storage', function(done) {
+      element._messageText = '';
+      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+
+      var numDiscardEvents = 0;
+      element.addEventListener('comment-discard', function(e) {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+      MockInteractions.tap(element.$$('.discard'));
+    });
+
+    test('ctrl+s saves comment', function(done) {
+      var stub = sinon.stub(element, 'save', function() {
+        assert.isTrue(stub.called);
+        stub.restore();
+        done();
+      });
+      element._messageText = 'is that the horse from horsing around??';
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.editTextarea.textarea,
+        83, 'ctrl');  // 'ctrl + s'
+    });
+
     test('draft saving/editing', function(done) {
       var fireStub = sinon.stub(element, 'fire');
 
@@ -224,11 +487,14 @@
       assert.deepEqual(fireStub.lastCall.args, [
         'comment-update', {
           comment: {
+            __commentSide: 'right',
             __draft: true,
             __draftID: 'temp_draft_id',
             __editing: true,
             line: 5,
             path: '/path/to/file',
+            message: 'good news, everyone!',
+            unresolved: false,
           },
           patchNum: 1,
         },
@@ -243,6 +509,7 @@
                'comment-save should be sent');
         assert.deepEqual(fireStub.lastCall.args[1], {
           comment: {
+            __commentSide: 'right',
             __draft: true,
             __draftID: 'temp_draft_id',
             __editing: false,
@@ -287,5 +554,20 @@
           'Should navigate to ' + dest + ' without triggering nav');
       showStub.restore();
     });
+
+    test('proper event fires on resolve', function(done) {
+      element.addEventListener('comment-update', function(e) {
+        assert.isTrue(e.detail.comment.unresolved);
+        done();
+      });
+      MockInteractions.tap(element.$$('.resolve input'));
+    });
+
+    test('resolved comment state indicated by checkbox', function() {
+      element.comment = {unresolved: false};
+      assert.isTrue(element.$$('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.$$('.resolve input').checked);
+    });
   });
 </script>
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
index 5a41709..2d0786a 100644
--- 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
@@ -21,9 +21,8 @@
   <template>
     <gr-cursor-manager
         id="cursorManager"
-        scroll="keep-visible"
+        scroll-behavior="[[_scrollBehavior]]"
         cursor-target-class="target-row"
-        fold-offset-top="[[foldOffsetTop]]"
         target="{{diffRow}}"></gr-cursor-manager>
   </template>
   <script src="gr-diff-cursor.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index 99a0b5c..e40ccf3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -24,6 +24,11 @@
     UNIFIED: 'UNIFIED_DIFF',
   };
 
+  var ScrollBehavior = {
+    KEEP_VISIBLE: 'keep-visible',
+    NEVER: 'never',
+  };
+
   var LEFT_SIDE_CLASS = 'target-side-left';
   var RIGHT_SIDE_CLASS = 'target-side-right';
 
@@ -54,11 +59,6 @@
         },
       },
 
-      foldOffsetTop: {
-        type: Number,
-        value: 0,
-      },
-
       /**
        * If set, the cursor will attempt to move to the line number (instead of
        * the first chunk) the next time the diff renders. It is set back to null
@@ -68,6 +68,18 @@
         type: Number,
         value: null,
       },
+
+      /**
+       * The scroll behavior for the cursor. Values are 'never' and
+       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+       * the viewport.
+       */
+      _scrollBehavior: {
+        type: String,
+        value: ScrollBehavior.KEEP_VISIBLE,
+      },
+
+      _listeningForScroll: Boolean,
     },
 
     observers: [
@@ -75,6 +87,15 @@
       '_diffsChanged(diffs.splices)',
     ],
 
+    attached: function() {
+      // Catch when users are scrolling as the view loads.
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'scroll', '_handleWindowScroll');
+    },
+
     moveLeft: function() {
       this.side = DiffSides.LEFT;
       if (this._isTargetBlank()) {
@@ -106,7 +127,10 @@
     },
 
     moveToNextChunk: function() {
-      this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this));
+      this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
+          function(target) {
+            return target.parentNode.scrollHeight;
+          });
       this._fixSide();
     },
 
@@ -174,12 +198,25 @@
       }
     },
 
+    _handleWindowScroll: function() {
+      if (this._listeningForScroll) {
+        this._scrollBehavior = ScrollBehavior.NEVER;
+        this._listeningForScroll = false;
+      }
+    },
+
     handleDiffUpdate: function() {
       this._updateStops();
 
       if (!this.diffRow) {
         this.reInitCursor();
       }
+      this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
+      this._listeningForScroll = false;
+    },
+
+    _handleDiffRenderStart: function() {
+      this._listeningForScroll = true;
     },
 
     /**
@@ -325,12 +362,15 @@
         for (i = splice.index;
             i < splice.index + splice.addedCount;
             i++) {
-          this.listen(this.diffs[i], 'render', 'handleDiffUpdate');
+          this.listen(this.diffs[i], 'render-start', '_handleDiffRenderStart');
+          this.listen(this.diffs[i], 'render-content', 'handleDiffUpdate');
         }
 
         for (i = 0;
             i < splice.removed && splice.removed.length;
             i++) {
+          this.unlisten(splice.removed[i],
+              'render-start', '_handleDiffRenderStart');
           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
index 5bdd138..a77c617 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -18,7 +18,7 @@
 <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/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../scripts/util.js"></script>
 
@@ -27,6 +27,8 @@
 <link rel="import" href="./gr-diff-cursor.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <mock-diff-response></mock-diff-response>
@@ -70,6 +72,10 @@
         return Promise.resolve({baseComments: [], comments: []});
       });
 
+      sinon.stub(diffElement, '_getDiffRobotComments', function() {
+        return Promise.resolve({baseComments: [], comments: []});
+      });
+
       var setupDone = function() {
         cursorElement.moveToFirstChunk();
         done();
@@ -98,6 +104,17 @@
       assert.equal(cursorElement.diffRow, firstDeltaRow);
     });
 
+    test('cursor scroll behavior', function() {
+      cursorElement._handleDiffRenderStart();
+      assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+
+      cursorElement._handleWindowScroll();
+      assert.equal(cursorElement._scrollBehavior, 'never');
+
+      cursorElement.handleDiffUpdate();
+      assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+    });
+
     suite('unified diff', function() {
 
       setup(function(done) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
index ec21fd1..bb5b938 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
@@ -32,7 +32,11 @@
      * @return {Number} The length of the text.
      */
     getLength: function(node) {
-      return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+      return this.getStringLength(node.textContent);
+    },
+
+    getStringLength: function(str) {
+      return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
index 27a684d..0a03539 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="gr-annotation.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
index 54294a1..814a760 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -37,5 +37,6 @@
     </div>
   </template>
   <script src="gr-annotation.js"></script>
+  <script src="gr-range-normalizer.js"></script>
   <script src="gr-diff-highlight.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index bfe103b..e5743a7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -95,6 +95,100 @@
     },
 
     /**
+     * Get current normalized selection.
+     * Merges multiple ranges, accounts for triple click, accounts for
+     * syntax highligh, convert native DOM Range objects to Gerrit concepts
+     * (line, side, etc).
+     * @return {{
+     *   start: {
+     *     node: Node,
+     *     side: string,
+     *     line: Number,
+     *     column: Number
+     *   },
+     *   end: {
+     *     node: Node,
+     *     side: string,
+     *     line: Number,
+     *     column: Number
+     *   }
+     * }}
+     */
+    _getNormalizedRange: function() {
+      var selection = window.getSelection();
+      var rangeCount = selection.rangeCount;
+      if (rangeCount === 0) {
+        return null;
+      } else if (rangeCount === 1) {
+        return this._normalizeRange(selection.getRangeAt(0));
+      } else {
+        var startRange = this._normalizeRange(selection.getRangeAt(0));
+        var endRange = this._normalizeRange(
+            selection.getRangeAt(rangeCount - 1));
+        return {
+          start: startRange.start,
+          end: endRange.end,
+        };
+      }
+    },
+
+    /**
+     * Normalize a specific DOM Range.
+     */
+    _normalizeRange: function(domRange) {
+      var range = GrRangeNormalizer.normalize(domRange);
+      return this._fixTripleClickSelection({
+        start: this._normalizeSelectionSide(
+            range.startContainer, range.startOffset),
+        end: this._normalizeSelectionSide(
+            range.endContainer, range.endOffset),
+      }, domRange);
+    },
+
+    /**
+     * Adjust triple click selection for the whole line.
+     * domRange.endContainer may be one of the following:
+     * 1) 0 offset at right column's line number cell, or
+     * 2) 0 offset at left column's line number at the next line.
+     * Case 1 means left column was triple clicked.
+     * Case 2 means right column or unified view triple clicked.
+     * @param {!Object} range Normalized range, ie column/line numbers
+     * @param {!Range} domRange DOM Range object
+     * @return {!Object} fixed normalized range
+     */
+    _fixTripleClickSelection: function(range, domRange) {
+      if (!range.start) {
+        // Selection outside of current diff.
+        return range;
+      }
+      var start = range.start;
+      var end = range.end;
+      var endsAtOtherSideLineNum =
+          domRange.endOffset === 0 &&
+          domRange.endContainer.nodeName === 'TD' &&
+          (domRange.endContainer.classList.contains('left') ||
+              domRange.endContainer.classList.contains('right'));
+      var endsOnOtherSideStart = endsAtOtherSideLineNum ||
+          end &&
+          end.column === 0 &&
+          end.line === start.line &&
+          end.side != start.side;
+      var content = domRange.cloneContents().querySelector('.contentText');
+      var lineLength = content && this._getLength(content) || 0;
+      if (lineLength && endsOnOtherSideStart || endsAtOtherSideLineNum) {
+        // Selection ends at the beginning of the next line.
+        // Move the selection to the end of the previous line.
+        range.end = {
+          node: start.node,
+          column: lineLength,
+          side: start.side,
+          line: start.line,
+        };
+      }
+      return range;
+    },
+
+    /**
      * Convert DOM Range selection to concrete numbers (line, column, side).
      * Moves range end if it's not inside td.content.
      * Returns null if selection end is not valid (outside of diff).
@@ -152,21 +246,17 @@
     },
 
     _handleSelection: function() {
-      var selection = window.getSelection();
-      if (selection.rangeCount != 1) {
+      var normalizedRange = this._getNormalizedRange();
+      if (!normalizedRange) {
         return;
       }
-      var range = selection.getRangeAt(0);
-      if (range.collapsed) {
-        return;
-      }
-      var start =
-          this._normalizeSelectionSide(range.startContainer, range.startOffset);
+      var domRange = window.getSelection().getRangeAt(0);
+      var start = normalizedRange.start;
+
       if (!start) {
         return;
       }
-      var end =
-          this._normalizeSelectionSide(range.endContainer, range.endOffset);
+      var end = normalizedRange.end;
       if (!end) {
         return;
       }
@@ -188,7 +278,7 @@
       };
       actionBox.side = start.side;
       if (start.line === end.line) {
-        actionBox.placeAbove(range);
+        actionBox.placeAbove(domRange);
       } else if (start.node instanceof Text) {
         actionBox.placeAbove(start.node.splitText(start.column));
         start.node.parentElement.normalize(); // Undo splitText from above.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 5f84e4f..c4c2993 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -18,45 +18,54 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-highlight</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-highlight.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
+    <style>
+      .tab-indicator:before {
+        color: #C62828;
+        /* >> character */
+        content: '\00BB';
+      }
+    </style>
     <gr-diff-highlight>
       <table id="diffTable">
 
         <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="138">138</td>
-            <td class="content both darkHighlight"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="119">119</td>
-            <td class="content both darkHighlight"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+            <td class="left lineNum" data-value="138"></td>
+            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+            <td class="right lineNum" data-value="119"></td>
+            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
           </tr>
         </tbody>
 
         <tbody class="section delta">
           <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140">140</td>
+            <td class="left lineNum" data-value="140"></td>
             <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove lightHighlight"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab withIndicator" style="tab-size:8;"></span></hl>udiam, <hl>quid</hl> sit, <span class="tab withIndicator" style="tab-size:8;"></span>quod <hl>Epicurum</hl></div><gr-diff-comment-thread>
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div><gr-diff-comment-thread>
                 [Yet another random diff thread content here]
               </gr-diff-comment-thread></td>
-            <td class="right lineNum" data-value="120">120</td>
+            <td class="right lineNum" data-value="120"></td>
             <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add lightHighlight"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab withIndicator" style="tab-size:8;"></span></hl> otiosum,  <span class="tab withIndicator" style="tab-size:8;"></span> audiam,  sit, quod</div></td>
+            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
           </tr>
         </tbody>
 
         <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
             <td class="left lineNum" data-value="141"></td>
-            <td class="content both darkHighlight"><div class="contentText">nam et<hl><span class="tab withIndicator" style="tab-size:8;">	</span></hl>complectitur<span class="tab withIndicator" style="tab-size:8;">	</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">	</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
             <td class="right lineNum" data-value="130"></td>
-            <td class="content both darkHighlight"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
           </tr>
         </tbody>
 
@@ -81,21 +90,21 @@
           </tr>
         </tbody>
 
-        <tbody class="section delta">
+        <tbody class="section delta total">
           <tr class="diff-row side-by-side" left-type="blank" right-type="add">
             <td class="left"></td>
-            <td class="blank darkHighlight"></td>
+            <td class="blank"></td>
             <td class="right lineNum" data-value="146"></td>
-            <td class="content add darkHighlight"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
           </tr>
         </tbody>
 
         <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
             <td class="left lineNum" data-value="165"></td>
-            <td class="content both darkHighlight"><div class="contentText">in physicis, quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+            <td class="content both"><div class="contentText">in physicis, quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
             <td class="right lineNum" data-value="147"></td>
-            <td class="content both darkHighlight"><div class="contentText">in physicis, <hl><span class="tab withIndicator" style="tab-size:8;">	</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
           </tr>
         </tbody>
 
@@ -121,7 +130,7 @@
 
     setup(function() {
       sandbox = sinon.sandbox.create();
-      element = fixture('basic');
+      element = fixture('basic')[1];
     });
 
     teardown(function() {
@@ -163,27 +172,10 @@
           getContentsByLineRange: sandbox.stub().returns([]),
           getLineElByChild: sandbox.stub().returns({}),
           getSideByLineEl: sandbox.stub().returns('other-side'),
-          renderLineRange: sandbox.stub(),
         };
         element._cachedDiffBuilder = builder;
       });
 
-      test('ignores thread discard for line comment', function(done) {
-        element.fire('thread-discard', {lastComment: {}});
-        flush(function() {
-          assert.isFalse(builder.renderLineRange.called);
-          done();
-        });
-      });
-
-      test('ignores comment discard for line comment', function(done) {
-        element.fire('comment-discard', {comment: {}});
-        flush(function() {
-          assert.isFalse(builder.renderLineRange.called);
-          done();
-        });
-      });
-
       test('comment-mouse-over from line comments is ignored', function() {
         sandbox.stub(element, 'set');
         element.fire('comment-mouse-over', {comment: {}});
@@ -307,11 +299,42 @@
           startLine: 119,
           startChar: 10,
           endLine: 120,
-          endChar: 34,
+          endChar: 36,
         });
         assert.equal(getActionSide(), 'right');
       });
 
+      test('multiple ranges aka firefox implementation', () => {
+        var startContent = stubContent(119, 'right');
+        var endContent = stubContent(120, 'right');
+
+        var startRange = document.createRange();
+        startRange.setStart(startContent.firstChild, 10);
+        startRange.setEnd(startContent.firstChild, 11);
+
+        var endRange = document.createRange();
+        endRange.setStart(endContent.lastChild, 6);
+        endRange.setEnd(endContent.lastChild, 7);
+
+        var getRangeAtStub = sandbox.stub();
+        getRangeAtStub
+            .onFirstCall().returns(startRange)
+            .onSecondCall().returns(endRange);
+        sandbox.stub(window, 'getSelection').returns({
+          rangeCount: 2,
+          getRangeAt: getRangeAtStub,
+          removeAllRanges: sandbox.stub(),
+        });
+        element._handleSelection();
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 119,
+          startChar: 10,
+          endLine: 120,
+          endChar: 36,
+        });
+      });
+
       test('multiline grow end highlight over tabs', function() {
         var startContent = stubContent(119, 'right');
         var endContent = stubContent(120, 'right');
@@ -369,7 +392,7 @@
           startLine: 140,
           startChar: 2,
           endLine: 140,
-          endChar: 60,
+          endChar: 61,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -378,7 +401,7 @@
         var contentText = stubContent(140, 'left');
         var contentTd = contentText.parentElement;
 
-        emulateSelection(contentTd.previousElementSibling.firstChild, 2,
+        emulateSelection(contentTd.previousElementSibling, 0,
             contentText.firstChild, 2);
         assert.isFalse(element.isRangeSelected());
       });
@@ -406,7 +429,7 @@
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
           startLine: 140,
-          startChar: 81,
+          startChar: 83,
           endLine: 141,
           endChar: 4,
         });
@@ -423,13 +446,14 @@
           startLine: 140,
           startChar: 4,
           endLine: 140,
-          endChar: 81,
+          endChar: 83,
         });
         assert.equal(getActionSide(), 'left');
       });
 
       test('starts in context element', function() {
-        var contextControl = diff.querySelector('.contextControl');
+        var contextControl =
+            diff.querySelector('.contextControl').querySelector('gr-button');
         var content = stubContent(146, 'right');
         emulateSelection(contextControl, 0, content.firstChild, 7);
         // TODO (viktard): Select nearest line.
@@ -437,9 +461,10 @@
       });
 
       test('ends in context element', function() {
-        var contextControl = diff.querySelector('.contextControl');
+        var contextControl =
+            diff.querySelector('.contextControl').querySelector('gr-button');
         var content = stubContent(141, 'left');
-        emulateSelection(content.firstChild, 2, contextControl, 0);
+        emulateSelection(content.firstChild, 2, contextControl, 1);
         // TODO (viktard): Select nearest line.
         assert.isFalse(element.isRangeSelected());
       });
@@ -476,22 +501,88 @@
         var content = stubContent(140, 'left');
         emulateSelection(
             content.querySelectorAll('hl')[3], 0,
-            content.querySelectorAll('span')[1], 0);
+            content.querySelectorAll('span')[1].nextSibling, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
           startLine: 140,
           startChar: 51,
           endLine: 140,
-          endChar: 68,
+          endChar: 71,
         });
         assert.equal(getActionSide(), 'left');
       });
 
+      test('properly accounts for syntax highlighting', function() {
+        var content = stubContent(140, 'left');
+        var spy = sinon.spy(element, '_normalizeRange');
+        emulateSelection(
+            content.querySelectorAll('hl')[3], 0,
+            content.querySelectorAll('span')[1], 0);
+        var spyCall = spy.getCall(0);
+        var range = window.getSelection().getRangeAt(0);
+        assert.notDeepEqual(spyCall.returnValue, range);
+      });
+
+      test('GrRangeNormalizer._getTextOffset computes text offset', function() {
+        var content = stubContent(140, 'left');
+        var child = content.lastChild.lastChild;
+        var result = GrRangeNormalizer._getTextOffset(content, child);
+        assert.equal(result, 75);
+        content = stubContent(146, 'right');
+        child = content.lastChild;
+        result = GrRangeNormalizer._getTextOffset(content, child);
+        assert.equal(result, 0);
+      });
+
       // TODO (viktard): Selection starts in line number.
       // TODO (viktard): Empty lines in selection start.
       // TODO (viktard): Empty lines in selection end.
       // TODO (viktard): Only empty lines selected.
       // TODO (viktard): Unified mode.
+
+      suite('triple click', function() {
+        test('_fixTripleClickSelection', function() {
+          var fakeRange = {
+            startContainer: '',
+            startOffset: '',
+            endContainer: '',
+            endOffset: ''
+          };
+          var fixedRange = {};
+          sandbox.stub(GrRangeNormalizer, 'normalize').returns(fakeRange);
+          sandbox.stub(element, '_normalizeSelectionSide');
+          sandbox.stub(element, '_fixTripleClickSelection').returns(fixedRange);
+          assert.strictEqual(element._normalizeRange({}), fixedRange);
+          assert.isTrue(element._fixTripleClickSelection.called);
+        });
+
+        test('left pane', function() {
+          var startNode = stubContent(138, 'left');
+          var endNode =
+              stubContent(119, 'right').parentElement.previousElementSibling;
+          builder.getLineNumberByChild.withArgs(endNode).returns(119);
+          emulateSelection(startNode, 0, endNode, 0);
+          assert.deepEqual(getActionRange(), {
+            startLine: 138,
+            startChar: 0,
+            endLine: 138,
+            endChar: 63,
+          });
+        });
+
+        test('right pane', function() {
+          var startNode = stubContent(119, 'right');
+          var endNode =
+              stubContent(140, 'left').parentElement.previousElementSibling;
+          emulateSelection(startNode, 0, endNode, 0);
+          assert.deepEqual(getActionRange(), {
+            startLine: 119,
+            startChar: 0,
+            endLine: 119,
+            endChar: 63,
+          });
+        });
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
new file mode 100644
index 0000000..e870169
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
@@ -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.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrRangeNormalizer) { return; }
+
+  // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+  var GrRangeNormalizer = {
+    /**
+     * Remap DOM range to whole lines of a diff if necessary. If the start or
+     * end containers are DOM elements that are singular pieces of syntax
+     * highlighting, the containers are remapped to the .contentText divs that
+     * contain the entire line of code.
+     *
+     * @param {Object} range - the standard DOM selector range.
+     * @return {Object} A modified version of the range that correctly accounts
+     *     for syntax highlighting.
+     */
+    normalize: function(range) {
+      var startContainer = this._getContentTextParent(range.startContainer);
+      var startOffset = range.startOffset + this._getTextOffset(startContainer,
+          range.startContainer);
+      var endContainer = this._getContentTextParent(range.endContainer);
+      var endOffset = range.endOffset + this._getTextOffset(endContainer,
+          range.endContainer);
+      return {
+        startContainer: startContainer,
+        startOffset: startOffset,
+        endContainer: endContainer,
+        endOffset: endOffset,
+      };
+    },
+
+    _getContentTextParent: function(target) {
+      var element = target;
+      if (element.nodeName === '#text') {
+        element = element.parentElement;
+      }
+      while (!element.classList.contains('contentText')) {
+        if (element.parentElement === null) {
+          return target;
+        }
+        element = element.parentElement;
+      }
+      return element;
+    },
+
+    /**
+     * Gets the character offset of the child within the parent.
+     * Performs a synchronous in-order traversal from top to bottom of the node
+     * element, counting the length of the syntax until child is found.
+     *
+     * @param {!Element} node The root DOM element to be searched through.
+     * @param {!Element} child The child element being searched for.
+     * @return {number}
+     */
+    _getTextOffset: function(node, child) {
+      var count = 0;
+      var stack = [node];
+      while (stack.length) {
+        var n = stack.pop();
+        if (n === child) {
+          break;
+        }
+        if (n.childNodes && n.childNodes.length !== 0) {
+          var arr = [];
+          for (var i = 0; i < n.childNodes.length; i++) {
+            arr.push(n.childNodes[i]);
+          }
+          arr.reverse();
+          stack = stack.concat(arr);
+        } else {
+          count += this._getLength(n);
+        }
+      }
+      return count;
+    },
+
+    /**
+     * The DOM API textContent.length calculation is broken when the text
+     * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+     * @param {Text} A text node.
+     * @return {Number} The length of the text.
+     */
+    _getLength: function(node) {
+      return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+    },
+  };
+
+  window.GrRangeNormalizer = GrRangeNormalizer;
+})(window);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index cbf63d6..fe57c43 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -87,7 +87,15 @@
         </select>
       </div>
       <div class="pref">
-        <label for="columnsInput">Columns</label>
+        <label for="lineWrappingInput">Fit to screen</label>
+        <input
+            is="iron-input"
+            type="checkbox"
+            id="lineWrappingInput"
+            on-tap="_handlelineWrappingTap">
+      </div>
+      <div class="pref" id="columnsPref" hidden$="[[_newPrefs.line_wrapping]]">
+        <label for="columnsInput">Diff width</label>
         <input is="iron-input" type="number" id="columnsInput"
             prevent-invalid-input
             allowed-pattern="[0-9]"
@@ -100,20 +108,32 @@
             allowed-pattern="[0-9]"
             bind-value="{{_newPrefs.tab_size}}">
       </div>
+      <div class="pref" hidden$="[[!_newPrefs.font_size]]">
+        <label for="fontSizeInput">Font size</label>
+        <input is="iron-input" type="number" id="fontSizeInput"
+               prevent-invalid-input
+               allowed-pattern="[0-9]"
+               bind-value="{{_newPrefs.font_size}}">
+      </div>
       <div class="pref">
         <label for="showTabsInput">Show tabs</label>
         <input is="iron-input" type="checkbox" id="showTabsInput"
             on-tap="_handleShowTabsTap">
       </div>
       <div class="pref">
+        <label for="showTrailingWhitespaceInput">Show trailing whitespace</label>
+        <input is="iron-input" type="checkbox" id="showTrailingWhitespaceInput"
+            on-tap="_handleShowTrailingWhitespaceTap">
+      </div>
+      <div class="pref">
         <label for="syntaxHighlightInput">Syntax highlighting</label>
         <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
             on-tap="_handleSyntaxHighlightTap">
       </div>
     </div>
     <div class="actions">
-      <gr-button primary on-tap="_handleSave">Save</gr-button>
-      <gr-button on-tap="_handleCancel">Cancel</gr-button>
+      <gr-button id="saveButton" primary on-tap="_handleSave">Save</gr-button>
+      <gr-button id="cancelButton" on-tap="_handleCancel">Cancel</gr-button>
     </div>
   </template>
   <script src="gr-diff-preferences.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
index 4103b2e..fd2a6f5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -53,6 +53,17 @@
       '_localPrefsChanged(localPrefs.*)',
     ],
 
+    getFocusStops: function() {
+      return {
+        start: this.$.contextSelect,
+        end: this.$.cancelButton,
+      };
+    },
+
+    resetFocus: function() {
+      this.$.contextSelect.focus();
+    },
+
     _prefsChanged: function(changeRecord) {
       var prefs = changeRecord.base;
       // TODO(andybons): This is not supported in IE. Implement a polyfill.
@@ -61,6 +72,8 @@
       this._newPrefs = Object.assign({}, prefs);
       this.$.contextSelect.value = prefs.context;
       this.$.showTabsInput.checked = prefs.show_tabs;
+      this.$.showTrailingWhitespaceInput.checked = prefs.show_whitespace_errors;
+      this.$.lineWrappingInput.checked = prefs.line_wrapping;
       this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
     },
 
@@ -79,11 +92,20 @@
       this.set('_newPrefs.show_tabs', Polymer.dom(e).rootTarget.checked);
     },
 
+    _handleShowTrailingWhitespaceTap: function(e) {
+      this.set('_newPrefs.show_whitespace_errors',
+          Polymer.dom(e).rootTarget.checked);
+    },
+
     _handleSyntaxHighlightTap: function(e) {
       this.set('_newPrefs.syntax_highlighting',
           Polymer.dom(e).rootTarget.checked);
     },
 
+    _handlelineWrappingTap: function(e) {
+      this.set('_newPrefs.line_wrapping', Polymer.dom(e).rootTarget.checked);
+    },
+
     _handleSave: function() {
       this.prefs = this._newPrefs;
       this.localPrefs = this._newLocalPrefs;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
index 0c40d9f..06f617a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-preferences</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-preferences.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-diff-preferences></gr-diff-preferences>
@@ -41,9 +43,11 @@
     test('model changes', function() {
       element.prefs = {
         context: 10,
+        font_size: 12,
         line_length: 100,
         show_tabs: true,
         tab_size: 8,
+        show_whitespace_errors: true,
         syntax_highlighting: true,
       };
       assert.deepEqual(element.prefs, element._newPrefs);
@@ -51,17 +55,43 @@
       element.$.contextSelect.value = '50';
       element.fire('change', {}, {node: element.$.contextSelect});
       element.$.columnsInput.bindValue = 80;
+      element.$.fontSizeInput.bindValue = 10;
       element.$.tabSizeInput.bindValue = 4;
       MockInteractions.tap(element.$.showTabsInput);
+      MockInteractions.tap(element.$.showTrailingWhitespaceInput);
       MockInteractions.tap(element.$.syntaxHighlightInput);
+      MockInteractions.tap(element.$.lineWrappingInput);
 
       assert.equal(element._newPrefs.context, 50);
+      assert.equal(element._newPrefs.font_size, 10);
       assert.equal(element._newPrefs.line_length, 80);
       assert.equal(element._newPrefs.tab_size, 4);
       assert.isFalse(element._newPrefs.show_tabs);
+      assert.isFalse(element._newPrefs.show_whitespace_errors);
+      assert.isTrue(element._newPrefs.line_wrapping);
       assert.isFalse(element._newPrefs.syntax_highlighting);
     });
 
+    test('clicking fit to screen hides line length input', function() {
+      element.prefs = {line_wrapping: false};
+
+      assert.isFalse(element.$.columnsPref.hidden);
+
+      MockInteractions.tap(element.$.lineWrappingInput);
+      assert.isTrue(element.$.columnsPref.hidden);
+
+      MockInteractions.tap(element.$.lineWrappingInput);
+      assert.isFalse(element.$.columnsPref.hidden);
+    });
+
+    test('clicking save button calls _handleSave function', function() {
+      var savePrefs = sinon.stub(element, '_handleSave');
+      MockInteractions.tap(element.$.saveButton);
+      flushAsynchronousOperations();
+      assert(savePrefs.calledOnce);
+      savePrefs.restore();
+    });
+
     test('events', function(done) {
       var savePromise = new Promise(function(resolve) {
         element.addEventListener('save', function() { resolve(); });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index 2a1e880..95ff5b7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -78,6 +78,23 @@
       },
 
       _nextStepHandle: Number,
+      _isScrolling: Boolean,
+    },
+
+    attached: function() {
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
+    detached: function() {
+      this.cancel();
+      this.unlisten(window, 'scroll', '_handleWindowScroll');
+    },
+
+    _handleWindowScroll: function() {
+      this._isScrolling = true;
+      this.debounce('resetIsScrolling', function() {
+        this._isScrolling = false;
+      }, 50);
     },
 
     /**
@@ -100,6 +117,11 @@
 
         var currentBatch = 0;
         var nextStep = function() {
+
+          if (this._isScrolling) {
+            this.async(nextStep, 100);
+            return;
+          }
           // If we are done, resolve the promise.
           if (state.sectionIndex >= content.length) {
             resolve(this.groups);
@@ -201,11 +223,11 @@
     /**
      * Take rows of a shared diff section and produce an array of corresponding
      * (potentially collapsed) groups.
-     * @param  {Array<String>} rows
-     * @param  {Number} context
-     * @param  {Number} startLineNumLeft
-     * @param  {Number} startLineNumRight
-     * @param  {String} opt_sectionEnd String representing whether this is the
+     * @param {Array<String>} rows
+     * @param {Number} context
+     * @param {Number} startLineNumLeft
+     * @param {Number} startLineNumRight
+     * @param {String} opt_sectionEnd String representing whether this is the
      *     first section or the last section or neither. Use the values 'first',
      *     'last' and null respectively.
      * @return {Array<GrDiffGroup>}
@@ -236,7 +258,7 @@
       }
 
       // If there is a range to hide.
-      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
+      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 1) {
         var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
         var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
         var linesAfterCtx = lines.slice(hiddenRange[1]);
@@ -264,10 +286,10 @@
     /**
      * Take the rows of a delta diff section and produce the corresponding
      * group.
-     * @param  {Array<String>} rowsAdded
-     * @param  {Array<String>} rowsRemoved
-     * @param  {Number} startLineNumLeft
-     * @param  {Number} startLineNumRight
+     * @param {Array<String>} rowsAdded
+     * @param {Array<String>} rowsRemoved
+     * @param {Number} startLineNumLeft
+     * @param {Number} startLineNumRight
      * @return {GrDiffGroup}
      */
     _deltaGroupFromRows: function(rowsAdded, rowsRemoved, startLineNumLeft,
@@ -325,7 +347,7 @@
      * In order to show comments out of the bounds of the selected context,
      * treat them as separate chunks within the model so that the content (and
      * context surrounding it) renders correctly.
-     * @param  {Object} content The diff content object.
+     * @param {Object} content The diff content object.
      * @return {Object} A new diff content object with regions split up.
      */
     _splitCommonGroupsWithComments: function(content) {
@@ -333,6 +355,22 @@
       var leftLineNum = 0;
       var rightLineNum = 0;
 
+      // If the context is set to "whole file", then break down the shared
+      // chunks so they can be rendered incrementally. Note: this is not enabled
+      // for any other context preference because manipulating the chunks in
+      // this way violates assumptions by the context grouper logic.
+      if (this.context === -1) {
+        var newContent = [];
+        content.forEach(function(group) {
+          if (group.ab) {
+            newContent.push.apply(newContent, this._breakdownGroup(group));
+          } else {
+            newContent.push(group);
+          }
+        }.bind(this));
+        content = newContent;
+      }
+
       // For each section in the diff.
       for (var i = 0; i < content.length; i++) {
 
@@ -462,6 +500,8 @@
         key = 'a';
       } else if (group.b && !group.a) {
         key = 'b';
+      } else if (group.ab) {
+        key = 'ab';
       }
 
       if (!key) { return [group]; }
@@ -477,8 +517,8 @@
     /**
      * Given an array and a size, return an array of arrays where no inner array
      * is larger than that size, preserving the original order.
-     * @param  {!Array<T>}
-     * @param  {number}
+     * @param {!Array<T>} array
+     * @param {number} size
      * @return {!Array<!Array<T>>}
      * @template T
      */
@@ -489,7 +529,7 @@
       var head = array.slice(0, array.length - size);
       var tail = array.slice(array.length - size);
 
-      return this._breakdown(head, size).concat([tail])
+      return this._breakdown(head, size).concat([tail]);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index 9d687ac..5429f52 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-processor test</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-processor.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-diff-processor></gr-diff-processor>
@@ -40,6 +42,15 @@
         'fugit assum per.';
 
     var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
 
     suite('not logged in', function() {
 
@@ -325,6 +336,28 @@
         ]);
       });
 
+      test('breaks-down shared chunks w/ whole-file', function() {
+        var lineNums = {left: 0, right: 0};
+        var content = [{
+          ab: _.times(75, function() { return '' + Math.random(); }),
+        }];
+        element.context = -1;
+        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        assert.equal(result.length, 2);
+        assert.deepEqual(result[0].ab, content[0].ab.slice(0, 5));
+        assert.deepEqual(result[1].ab, content[0].ab.slice(5));
+      });
+
+      test('does not break-down shared chunks w/ context', function() {
+        var lineNums = {left: 0, right: 0};
+        var content = [{
+          ab: _.times(75, function() { return '' + Math.random(); }),
+        }];
+        element.context = 4;
+        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        assert.deepEqual(result, content);
+      });
+
       test('intraline normalization', function() {
         // The content and highlights are in the format returned by the Gerrit
         // REST API.
@@ -409,6 +442,23 @@
         ]);
       });
 
+      test('scrolling pauses rendering', function() {
+        var contentRow = {
+          ab: [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ]
+        };
+        var content = _.times(200, _.constant(contentRow));
+        sandbox.stub(element, 'async');
+        element._isScrolling = true;
+        element.process(content);
+        assert.equal(element.groups.length, 1);
+        element._isScrolling = false;
+        element.process(content);
+        assert.equal(element.groups.length, 33);
+      });
+
       suite('gr-diff-processor helpers', function() {
         var rows;
 
@@ -485,6 +535,17 @@
           assert.equal(result[0].lines.length, rows.length);
         });
 
+        test('_sharedGroupsFromRows no single line collapse', function() {
+          rows = rows.slice(0, 7);
+          var context = 3;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100);
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.length, 1, 'Results in one group');
+          assert.equal(result[0].lines.length, rows.length);
+        });
+
         test('_deltaLinesFromRows', function() {
           var startLineNum = 10;
           var result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
@@ -512,23 +573,6 @@
       });
 
       suite('_breakdown*', function() {
-        var sandbox;
-        setup(function() {
-          sandbox = sinon.sandbox.create();
-        });
-
-        teardown(function() {
-          sandbox.restore();
-        });
-
-        test('_breakdownGroup ignores shared groups', function() {
-          sandbox.stub(element, '_breakdown');
-          var chunk = {ab: ['blah', 'blah', 'blah']};
-          var result = element._breakdownGroup(chunk);
-          assert.deepEqual(result, [chunk]);
-          assert.isFalse(element._breakdown.called);
-        });
-
         test('_breakdownGroup breaks down additions', function() {
           sandbox.spy(element, '_breakdown');
           var chunk = {b: ['blah', 'blah', 'blah']};
@@ -574,5 +618,12 @@
         });
       });
     });
+
+    test('detaching cancels', function() {
+      element = fixture('basic');
+      sandbox.stub(element, 'cancel');
+      element.detached();
+      assert(element.cancel.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
index 09cab0b..bfddf89 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
@@ -18,17 +18,21 @@
 <dom-module id="gr-diff-selection">
   <template>
     <style>
-      .contentWrapper ::content .content {
+      .contentWrapper ::content .content,
+      .contentWrapper ::content .contextControl {
         -webkit-user-select: none;
         -moz-user-select: none;
         -ms-user-select: none;
         user-select: none;
       }
 
-      :host.selected-right .contentWrapper ::content .right + .content,
-      :host.selected-left .contentWrapper ::content .left + .content,
-      :host.selected-right .contentWrapper ::content .unified .right ~ .content,
-      :host.selected-left .contentWrapper ::content .unified .left ~ .content {
+      :host-context(.selected-left:not(.selected-comment)) .contentWrapper ::content .side-by-side .left + .content .contentText,
+      :host-context(.selected-right:not(.selected-comment)) .contentWrapper ::content .side-by-side .right + .content .contentText,
+      :host-context(.selected-left:not(.selected-comment)) .contentWrapper ::content .unified .left.lineNum ~ .content:not(.both) .contentText,
+      :host-context(.selected-right:not(.selected-comment)) .contentWrapper ::content .unified .right.lineNum ~ .content .contentText,
+      :host-context(.selected-left.selected-comment) .contentWrapper ::content .side-by-side .left + .content .message,
+      :host-context(.selected-right.selected-comment) .contentWrapper ::content .side-by-side .right + .content .message :not(.collapsedContent),
+      :host-context(.selected-comment) .contentWrapper ::content .unified .message :not(.collapsedContent){
         -webkit-user-select: text;
         -moz-user-select: text;
         -ms-user-select: text;
@@ -39,5 +43,6 @@
       <content></content>
     </div>
   </template>
+  <script src="../gr-diff-highlight/gr-range-normalizer.js"></script>
   <script src="gr-diff-selection.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 7d0b7ea..fa1aeb2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -14,20 +14,41 @@
 (function() {
   'use strict';
 
+  /**
+   * Possible CSS classes indicating the state of selection. Dynamically added/
+   * removed based on where the user clicks within the diff.
+   */
+  var SelectionClass = {
+    COMMENT: 'selected-comment',
+    LEFT: 'selected-left',
+    RIGHT: 'selected-right',
+  };
+
+  var getNewCache = function() { return {left: null, right: null}; };
+
   Polymer({
     is: 'gr-diff-selection',
 
     properties: {
+      diff: Object,
       _cachedDiffBuilder: Object,
+      _linesCache: {
+        type: Object,
+        value: getNewCache(),
+      },
     },
 
+    observers: [
+      '_diffChanged(diff)',
+    ],
+
     listeners: {
       'copy': '_handleCopy',
       'down': '_handleDown',
     },
 
     attached: function() {
-      this.classList.add('selected-right');
+      this.classList.add(SelectionClass.RIGHT);
     },
 
     get diffBuilder() {
@@ -38,56 +59,226 @@
       return this._cachedDiffBuilder;
     },
 
+    _diffChanged: function() {
+      this._linesCache = getNewCache();
+    },
+
     _handleDown: function(e) {
       var lineEl = this.diffBuilder.getLineElByChild(e.target);
       if (!lineEl) {
         return;
       }
+      var commentSelected =
+          this._elementDescendedFromClass(e.target, 'gr-diff-comment');
       var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var targetClass = 'selected-' + side;
-      var alternateClass = 'selected-' + (side === 'left' ? 'right' : 'left');
+      var targetClasses = [];
+      targetClasses.push(side === 'left' ?
+          SelectionClass.LEFT :
+          SelectionClass.RIGHT);
 
-      if (this.classList.contains(alternateClass)) {
-        this.classList.remove(alternateClass);
+      if (commentSelected) {
+        targetClasses.push(SelectionClass.COMMENT);
       }
-      if (!this.classList.contains(targetClass)) {
-        this.classList.add(targetClass);
+      // Remove any selection classes that do not belong.
+      for (var key in SelectionClass) {
+        if (SelectionClass.hasOwnProperty(key)) {
+          var className = SelectionClass[key];
+          if (targetClasses.indexOf(className) === -1) {
+            this.classList.remove(SelectionClass[key]);
+          }
+        }
+      }
+      // Add new selection classes iff they are not already present.
+      for (var i = 0; i < targetClasses.length; i++) {
+        if (!this.classList.contains(targetClasses[i])) {
+          this.classList.add(targetClasses[i]);
+        }
       }
     },
 
-    _handleCopy: function(e) {
-      if (!e.target.classList.contains('content')) {
-        return;
+    _getCopyEventTarget: function(e) {
+      return Polymer.dom(e).rootTarget;
+    },
+
+    /**
+     * Utility function to determine whether an element is a descendant of
+     * another element with the particular className.
+     *
+     * @param {!Element} element
+     * @param {!string} className
+     * @return {boolean}
+     */
+    _elementDescendedFromClass: function(element, className) {
+      while (!element.classList.contains(className)) {
+        if (!element.parentElement ||
+            element === this.diffBuilder.diffElement) {
+          return false;
+        }
+        element = element.parentElement;
       }
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      return true;
+    },
+
+    _handleCopy: function(e) {
+      var commentSelected = false;
+      var target = this._getCopyEventTarget(e);
+      if (target.type === 'textarea') { return; }
+      if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
+      if (this.classList.contains(SelectionClass.COMMENT)) {
+        commentSelected = true;
+      }
+      var lineEl = this.diffBuilder.getLineElByChild(target);
       if (!lineEl) {
         return;
       }
       var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var text = this._getSelectedText(side);
-      e.clipboardData.setData('Text', text);
-      e.preventDefault();
+      var text = this._getSelectedText(side, commentSelected);
+      if (text) {
+        e.clipboardData.setData('Text', text);
+        e.preventDefault();
+      }
     },
 
-    _getSelectedText: function(opt_side) {
+    /**
+     * Get the text of the current window selection. If commentSelected is
+     * true, it returns only the text of comments within the selection.
+     * Otherwise it returns the text of the selected diff region.
+     *
+     * @param {!string} The side that is selected.
+     * @param {boolean} Whether or not a comment is selected.
+     * @return {string} The selected text.
+     */
+    _getSelectedText: function(side, commentSelected) {
       var sel = window.getSelection();
       if (sel.rangeCount != 1) {
         return; // No multi-select support yet.
       }
-      var range = sel.getRangeAt(0);
-      var fragment = range.cloneContents();
-      var selector = '.content,td.content:nth-of-type(1)';
-      if (opt_side) {
-        selector = '.' + opt_side + ' + ' + selector;
+      if (commentSelected) {
+        return this._getCommentLines(sel, side);
       }
-      var contentEls = Polymer.dom(fragment).querySelectorAll(selector);
-      if (contentEls.length === 0) {
-        return fragment.textContent;
+      var range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+      var startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
+      var endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+      var startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
+      var endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
+
+      return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
+          range.endOffset, side);
+    },
+
+    /**
+     * Query the diff object for the selected lines.
+     *
+     * @param {int} startLineNum
+     * @param {int} startOffset
+     * @param {int} endLineNum
+     * @param {int} endOffset
+     * @param {!string} side The side that is currently selected.
+     * @return {string} The selected diff text.
+     */
+    _getRangeFromDiff: function(startLineNum, startOffset, endLineNum,
+        endOffset, side) {
+      var lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+      if (lines.length) {
+        lines[lines.length - 1] = lines[lines.length - 1]
+            .substring(0, endOffset);
+        lines[0] = lines[0].substring(startOffset);
+      }
+      return lines.join('\n');
+    },
+
+    /**
+     * Query the diff object for the lines from a particular side.
+     *
+     * @param {!string} side The side that is currently selected.
+     * @return {Array.string} An array of strings indexed by line number.
+     */
+    _getDiffLines: function(side) {
+      if (this._linesCache[side]) {
+        return this._linesCache[side];
+      }
+      var lines = [];
+      var chunk;
+      var key = side === 'left' ? 'a' : 'b';
+      for (var chunkIndex = 0;
+          chunkIndex < this.diff.content.length;
+          chunkIndex++) {
+        chunk = this.diff.content[chunkIndex];
+        if (chunk.ab) {
+          lines = lines.concat(chunk.ab);
+        } else if (chunk[key]) {
+          lines = lines.concat(chunk[key]);
+        }
+      }
+      this._linesCache[side] = lines;
+      return lines;
+    },
+
+    /**
+     * Query the diffElement for comments and check whether they lie inside the
+     * selection range.
+     *
+     * @param {!Selection} sel The selection of the window.
+     * @param {!string} side The side that is currently selected.
+     * @return {string} The selected comment text.
+     */
+    _getCommentLines: function(sel, side) {
+      var range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+      var content = [];
+      // Query the diffElement for comments.
+      var messages = this.diffBuilder.diffElement.querySelectorAll(
+          '.side-by-side [data-side="' + side +
+          '"] .message *, .unified .message *');
+
+      for (var i = 0; i < messages.length; i++) {
+        var el = messages[i];
+        // Check if the comment element exists inside the selection.
+        if (sel.containsNode(el, true)) {
+          // Padded elements require newlines for accurate spacing.
+          if (el.parentElement.id === 'container' ||
+              el.parentElement.nodeName === 'BLOCKQUOTE') {
+            if (content.length && content[content.length - 1] !== '') {
+              content.push('');
+            }
+          }
+
+          if (el.id === 'output' &&
+              !this._elementDescendedFromClass(el, 'collapsed')) {
+            content.push(this._getTextContentForRange(el, sel, range));
+          }
+        }
       }
 
+      return content.join('\n');
+    },
+
+    /**
+     * Given a DOM node, a selection, and a selection range, recursively get all
+     * of the text content within that selection.
+     * Using a domNode that isn't in the selection returns an empty string.
+     *
+     * @param {Element} domNode The root DOM node.
+     * @param {Selection} sel The selection.
+     * @param {Range} range The normalized selection range.
+     * @return {string} The text within the selection.
+     */
+    _getTextContentForRange: function(domNode, sel, range) {
+      if (!sel.containsNode(domNode, true)) { return ''; }
+
       var text = '';
-      for (var i = 0; i < contentEls.length; i++) {
-        text += contentEls[i].textContent + '\n';
+      if (domNode instanceof Text) {
+        text = domNode.textContent;
+        if (domNode === range.endContainer) {
+          text = text.substring(0, range.endOffset);
+        }
+        if (domNode === range.startContainer) {
+          text = text.substring(range.startOffset);
+        }
+      } else {
+        for (var i = 0; i < domNode.childNodes.length; i++) {
+          text += this._getTextContentForRange(domNode.childNodes[i],
+              sel, range);
+        }
       }
       return text;
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index f99e373..3eeba90 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -18,33 +18,82 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-selection</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-selection.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-diff-selection>
-      <table>
-        <tr>
-          <td class="lineNum left">1</td>
-          <td class="content">ba ba</td>
-          <td class="lineNum right">1</td>
-          <td class="content">some other text</td>
+      <table id="diffTable" class="side-by-side">
+        <tr class="diff-row">
+          <td class="lineNum left" data-value="1">1</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ba ba</div>
+            <div data-side="left">
+              <div class="gr-diff-comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is a comment</span>
+                </div>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="1">1</td>
+          <td class="content">
+            <div class="contentText" data-side="right">some other text</div>
+          </td>
         </tr>
-        <tr>
-          <td class="lineNum left">2</td>
-          <td class="content">zin</td>
-          <td class="lineNum right">2</td>
-          <td class="content">more more more</td>
+        <tr class="diff-row">
+          <td class="lineNum left" data-value="2">2</td>
+          <td class="content">
+            <div class="contentText" data-side="left">zin</div>
+          </td>
+          <td class="lineNum right" data-value="2">2</td>
+          <td class="content">
+            <div class="contentText" data-side="right">more more more</div>
+            <div data-side="right">
+              <div class="gr-diff-comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is a comment on the right</span>
+                </div>
+              </div>
+            </div>
+          </td>
         </tr>
-        <tr>
-          <td class="lineNum left">2</td>
-          <td class="content">ga ga</td>
-          <td class="lineNum right">3</td>
-          <td class="other">some other text</td>
+        <tr class="diff-row">
+          <td class="lineNum left" data-value="3">3</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ga ga</div>
+            <div data-side="left">
+              <div class="gr-diff-comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
+                </div>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="3">3</td>
+        </tr>
+        <tr class="diff-row">
+          <td class="lineNum left" data-value="4">4</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ga ga</div>
+            <div data-side="left">
+              <div class="gr-diff-comment-thread">
+                <textarea data-side="right">test for textarea copying</textarea>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="4">4</td>
+        </tr>
+        <tr class="not-diff-row">
+          <td class="other">
+            <div class="contentText" data-side="right">some other text</div>
+          </td>
         </tr>
       </table>
     </gr-diff-selection>
@@ -54,25 +103,50 @@
 <script>
   suite('gr-diff-selection', function() {
     var element;
+    var sandbox;
 
     var emulateCopyOn = function(target) {
       var fakeEvent = {
         target: target,
-        preventDefault: sinon.stub(),
+        preventDefault: sandbox.stub(),
         clipboardData: {
-          setData: sinon.stub(),
+          setData: sandbox.stub(),
         },
       };
+      element._getCopyEventTarget.returns(target);
       element._handleCopy(fakeEvent);
       return fakeEvent;
     };
 
     setup(function() {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(element, '_getCopyEventTarget');
       element._cachedDiffBuilder = {
-        getLineElByChild: sinon.stub().returns({}),
-        getSideByLineEl: sinon.stub(),
+        getLineElByChild: sandbox.stub().returns({}),
+        getSideByLineEl: sandbox.stub(),
+        diffElement: element.querySelector('#diffTable'),
       };
+      element.diff = {
+        content: [
+          {
+            a: ['ba ba'],
+            b: ['some other text'],
+          },
+          {
+            a: ['zin'],
+            b: ['more more more'],
+          },
+          {
+            a: ['ga ga'],
+            b: ['some other text'],
+          },
+        ],
+      };
+    });
+
+    teardown(function() {
+      sandbox.restore();
     });
 
     test('applies selected-left on left side click', function() {
@@ -97,46 +171,168 @@
     });
 
     test('ignores copy for non-content Element', function() {
-      sinon.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('.other'));
+      sandbox.stub(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('.not-diff-row'));
       assert.isFalse(element._getSelectedText.called);
     });
 
-    test('asks for text for right side Elements', function() {
+    test('asks for text for left side Elements', function() {
       element._cachedDiffBuilder.getSideByLineEl.returns('left');
-      sinon.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('td.content'));
-      assert.deepEqual(['left'], element._getSelectedText.lastCall.args);
+      sandbox.stub(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('div.contentText'));
+      assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
     });
 
     test('reacts to copy for content Elements', function() {
-      sinon.stub(element, '_getSelectedText');
-      emulateCopyOn(element.querySelector('td.content'));
+      sandbox.stub(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('div.contentText'));
       assert.isTrue(element._getSelectedText.called);
     });
 
     test('copy event is prevented for content Elements', function() {
-      sinon.stub(element, '_getSelectedText');
-      var event = emulateCopyOn(element.querySelector('td.content'));
+      sandbox.stub(element, '_getSelectedText');
+      element._cachedDiffBuilder.getSideByLineEl.returns('left');
+      element._getSelectedText.returns('test');
+      var event = emulateCopyOn(element.querySelector('div.contentText'));
       assert.isTrue(event.preventDefault.called);
     });
 
     test('inserts text into clipboard on copy', function() {
-      sinon.stub(element, '_getSelectedText').returns('the text');
-      var event = emulateCopyOn(element.querySelector('td.content'));
+      sandbox.stub(element, '_getSelectedText').returns('the text');
+      var event = emulateCopyOn(element.querySelector('div.contentText'));
       assert.deepEqual(
           ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
     });
 
     test('copies content correctly', function() {
+      // Fetch the line number.
+      element._cachedDiffBuilder.getLineElByChild = function(child) {
+        while (!child.classList.contains('content') && child.parentElement) {
+          child = child.parentElement;
+        }
+        return child.previousElementSibling;
+      };
+
       element.classList.add('selected-left');
+      element.classList.remove('selected-right');
+
       var selection = window.getSelection();
+      selection.removeAllRanges();
       var range = document.createRange();
-      range.setStart(element.querySelector('td.content').firstChild, 3);
+      range.setStart(element.querySelector('div.contentText').firstChild, 3);
       range.setEnd(
-          element.querySelectorAll('td.content')[4].firstChild, 2);
+          element.querySelectorAll('div.contentText')[4].firstChild, 2);
       selection.addRange(range);
-      assert.equal('ba\nzin\nga\n', element._getSelectedText('left'));
+      assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+    });
+
+    test('copies comments', function() {
+      element.classList.add('selected-left');
+      element.classList.add('selected-comment');
+      element.classList.remove('selected-right');
+      var selection = window.getSelection();
+      selection.removeAllRanges();
+      var range = document.createRange();
+      range.setStart(
+          element.querySelector('.gr-formatted-text *').firstChild, 3);
+      range.setEnd(
+          element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
+      selection.addRange(range);
+      assert.equal('s is a comment\nThis is a differ',
+          element._getSelectedText('left', true));
+    });
+
+    test('respects astral chars in comments', function() {
+      element.classList.add('selected-left');
+      element.classList.add('selected-comment');
+      element.classList.remove('selected-right');
+      var selection = window.getSelection();
+      selection.removeAllRanges();
+      var range = document.createRange();
+      var nodes = element.querySelectorAll('.gr-formatted-text *');
+      range.setStart(nodes[2].childNodes[2], 13);
+      range.setEnd(nodes[2].childNodes[2], 23);
+      selection.addRange(range);
+      assert.equal('mment 💩 u',
+          element._getSelectedText('left', true));
+    });
+
+    test('defers to default behavior for textarea', function() {
+      element.classList.add('selected-left');
+      element.classList.remove('selected-right');
+      var selectedTextSpy = sandbox.spy(element, '_getSelectedText');
+      emulateCopyOn(element.querySelector('textarea'));
+      assert.isFalse(selectedTextSpy.called);
+    });
+
+    test('regression test for 4794', function() {
+      element._cachedDiffBuilder.getLineElByChild = function(child) {
+        while (!child.classList.contains('content') && child.parentElement) {
+          child = child.parentElement;
+        }
+        return child.previousElementSibling;
+      };
+
+      element.classList.add('selected-right');
+      element.classList.remove('selected-left');
+
+      var selection = window.getSelection();
+      selection.removeAllRanges();
+      var range = document.createRange();
+      range.setStart(
+          element.querySelectorAll('div.contentText')[1].firstChild, 4);
+      range.setEnd(
+          element.querySelectorAll('div.contentText')[1].firstChild, 10);
+      selection.addRange(range);
+      assert.equal(element._getSelectedText('right'), ' other');
+    });
+
+    suite('_getTextContentForRange', function() {
+      var selection;
+      var range;
+      var nodes;
+
+      setup(function() {
+        element.classList.add('selected-left');
+        element.classList.add('selected-comment');
+        element.classList.remove('selected-right');
+        selection = window.getSelection();
+        selection.removeAllRanges();
+        range = document.createRange();
+        nodes = element.querySelectorAll('.gr-formatted-text *');
+      });
+
+      test('multi level element contained in range', function() {
+        range.setStart(nodes[2].childNodes[0], 1);
+        range.setEnd(nodes[2].childNodes[2], 7);
+        selection.addRange(range);
+        assert.equal(element._getTextContentForRange(element, selection, range),
+            'his is a differ');
+      });
+
+
+      test('multi level element as startContainer of range', function() {
+        range.setStart(nodes[2].childNodes[1], 0);
+        range.setEnd(nodes[2].childNodes[2], 7);
+        selection.addRange(range);
+        assert.equal(element._getTextContentForRange(element, selection, range),
+            'a differ');
+      });
+
+      test('startContainer === endContainer', function() {
+        range.setStart(nodes[0].firstChild, 2);
+        range.setEnd(nodes[0].firstChild, 12);
+        selection.addRange(range);
+        assert.equal(element._getTextContentForRange(element, selection, range),
+            'is is a co');
+      });
+    });
+
+    test('cache is reset when diff changes', function() {
+      element._linesCache = {left: 'test', right: 'test'};
+      element.diff = {};
+      flushAsynchronousOperations();
+      assert.deepEqual(element._linesCache, {left: null, right: null});
     });
   });
 </script>
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 2573ad1..40f9812 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
@@ -14,9 +14,12 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -33,9 +36,25 @@
         background-color: var(--view-background-color);
         display: block;
       }
-      h3 {
+      header,
+      .subHeader {
+        align-items: center;
+        display: flex;
+        justify-content: space-between;
+      }
+      header {
         padding: .75em var(--default-horizontal-margin);
       }
+      .patchRangeLeft {
+        display: flex;
+      }
+      .navLink:not([href]),
+      .downloadLink:not([href]) {
+        color: #999;
+      }
+      .navLinks {
+        white-space: nowrap;
+      }
       .reviewed {
         display: inline-block;
         margin: 0 .25em;
@@ -44,7 +63,7 @@
       .jumpToFileContainer {
         display: inline-block;
       }
-      .mobileJumpToFileContainer {
+      .mobile {
         display: none;
       }
       .downArrow {
@@ -57,6 +76,9 @@
         cursor: pointer;
         padding: 0;
       }
+      iron-dropdown {
+        position: absolute;
+      }
       .dropdown-content {
         background-color: #fff;
         box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
@@ -97,84 +119,143 @@
         padding: 0 var(--default-horizontal-margin) 1em;
         color: #666;
       }
-      .header {
-        align-items: center;
-        display: flex;
-        justify-content: space-between;
+      .subHeader {
+        flex-wrap: wrap;
         margin: 0 var(--default-horizontal-margin) .75em;
       }
+      .subHeader > div {
+        margin-top: .25em;
+      }
       .prefsButton {
         text-align: right;
       }
-      #modeSelect {
-        margin-left: .5em;
+      .separator {
+        margin: 0 .25em;
       }
       @media screen and (max-width: 50em) {
+        header {
+          padding: .5em var(--default-horizontal-margin);
+        }
         .dash {
           display: none;
         }
+        .desktop {
+          display: none;
+        }
+        .fileNav {
+          align-items: flex-start;
+          display: flex;
+          margin: 0 .25em;
+        }
+        .fullFileName {
+          display: block;
+          font-size: .9em;
+          font-style: italic;
+          min-width: 50%;
+          padding: 0 .1em;
+          text-align: center;
+          width: 100%;
+          word-wrap: break-word;
+        }
         .reviewed {
           vertical-align: -.1em;
         }
-        .jumpToFileContainer {
-          display: none;
-        }
         .mobileJumpToFileContainer {
           display: block;
           width: 100%;
         }
+        .mobileJumpToFileContainer select {
+          width: 100%;
+        }
+        .mobileNavLink {
+          color: #000;
+          font-size: 1.5em;
+          font-weight: bold;
+          text-decoration: none;
+        }
+        .mobileNavLink:not([href]) {
+          color: #bbb;
+        }
       }
     </style>
-    <h3>
-      <a href$="[[_computeChangePath(_changeNum, _patchRange.*, _change.revisions)]]">
-        [[_changeNum]]</a><span>:</span>
-      <span>[[_change.subject]]</span>
-      <span class="dash">—</span>
-      <input id="reviewed"
-          class="reviewed"
-          type="checkbox"
-          on-change="_handleReviewedChange"
-          hidden$="[[!_loggedIn]]" hidden>
-      <div class="jumpToFileContainer">
-        <gr-button link class="dropdown-trigger" id="trigger" on-tap="_showDropdownTapHandler">
-          <span>[[_computeFileDisplayName(_path)]]</span>
-          <span class="downArrow">&#9660;</span>
-        </gr-button>
-        <iron-dropdown id="dropdown" vertical-align="top" vertical-offset="25">
-          <div class="dropdown-content">
+    <header>
+      <h3>
+        <a href$="[[_computeChangePath(_changeNum, _patchRange.*, _change.revisions)]]">
+          [[_changeNum]]</a><span>:</span>
+        <span>[[_change.subject]]</span>
+        <span class="dash">—</span>
+        <input id="reviewed"
+               class="reviewed"
+               type="checkbox"
+               on-change="_handleReviewedChange"
+               hidden$="[[!_loggedIn]]" hidden>
+        <div class="jumpToFileContainer desktop">
+          <gr-button link class="dropdown-trigger" id="trigger" on-tap="_showDropdownTapHandler">
+            <span>[[_computeFileDisplayName(_path)]]</span>
+            <span class="downArrow">&#9660;</span>
+          </gr-button>
+          <!-- *-align="" to disable iron-dropdown's element positioning. -->
+          <iron-dropdown id="dropdown"
+              allow-outside-scroll
+              vertical-align=""
+              horizontal-align="">
+            <div class="dropdown-content">
+              <template
+                  is="dom-repeat"
+                  items="[[_fileList]]"
+                  as="path"
+                  initial-count="75">
+                <a href$="[[_computeDiffURL(_changeNum, _patchRange.*, path)]]"
+                   selected$="[[_computeFileSelected(path, _path)]]"
+                   data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
+                   on-tap="_handleFileTap">[[_computeFileDisplayName(path)]]</a>
+              </template>
+            </div>
+          </iron-dropdown>
+        </div>
+        <div class="mobileJumpToFileContainer mobile">
+          <select on-change="_handleMobileSelectChange">
             <template is="dom-repeat" items="[[_fileList]]" as="path">
-              <a href$="[[_computeDiffURL(_changeNum, _patchRange.*, path)]]"
-                 selected$="[[_computeFileSelected(path, _path)]]"
-                 data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
-                 on-tap="_handleFileTap">
-                 [[_computeFileDisplayName(path)]]
-              </a>
+              <option
+                  value$="[[path]]"
+                  selected$="[[_computeFileSelected(path, _path)]]">
+                [[_computeTruncatedFileDisplayName(path)]]
+              </option>
             </template>
-          </div>
-        </iron-dropdown>
+          </select>
+        </div>
+      </h3>
+      <div class="navLinks desktop">
+        <a class="navLink"
+           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]">Prev</a>
+        /
+        <a class="navLink"
+           href$="[[_computeUpURL(_changeNum, _patchRange, _change, _change.revisions)]]">Up</a>
+        /
+        <a class="navLink"
+           href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">Next</a>
       </div>
-      <div class="mobileJumpToFileContainer">
-        <select on-change="_handleMobileSelectChange">
-          <template is="dom-repeat" items="[[_fileList]]" as="path">
-            <option
-                value$="[[path]]"
-                selected$="[[_computeFileSelected(path, _path)]]">
-              [[_computeFileDisplayName(path)]]
-            </option>
-          </template>
-        </select>
-      </div>
-    </h3>
+    </header>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
-      <div class="header">
-        <gr-patch-range-select
-            path="[[_path]]"
-            change-num="[[_changeNum]]"
-            patch-range="[[_patchRange]]"
-            files-weblinks="[[_filesWeblinks]]"
-            available-patches="[[_computeAvailablePatches(_change.revisions)]]">
-        </gr-patch-range-select>
+      <div class="subHeader">
+        <div class="patchRangeLeft">
+          <gr-patch-range-select
+              path="[[_path]]"
+              change-num="[[_changeNum]]"
+              patch-range="[[_patchRange]]"
+              files-weblinks="[[_filesWeblinks]]"
+              available-patches="[[_computeAvailablePatches(_change.revisions)]]"
+              revisions="[[_change.revisions]]">
+          </gr-patch-range-select>
+          <span class="download desktop">
+            <span class="separator">/</span>
+            <a class="downloadLink"
+               href$="[[_computeDownloadLink(_changeNum, _patchRange, _path)]]">
+              Download
+            </a>
+          </span>
+        </div>
         <div>
           <select
               id="modeSelect"
@@ -185,21 +266,32 @@
             <option value="UNIFIED_DIFF">Unified</option>
           </select>
           <span hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]">
-            <span
-                hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">/</span>
-            <gr-button link
-                class="prefsButton"
-                on-tap="_handlePrefsTap">Preferences</gr-button>
+            <span class="preferences desktop">
+              <span
+                  hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">/</span>
+              <gr-button link
+                  class="prefsButton"
+                  on-tap="_handlePrefsTap">Preferences</gr-button>
+            </span>
           </span>
         </div>
       </div>
       <gr-overlay id="prefsOverlay" with-backdrop>
         <gr-diff-preferences
+            id="diffPreferences"
             prefs="{{_prefs}}"
             local-prefs="{{_localPrefs}}"
             on-save="_handlePrefsSave"
             on-cancel="_handlePrefsCancel"></gr-diff-preferences>
       </gr-overlay>
+      <div class="fileNav mobile">
+        <a class="mobileNavLink"
+           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]">&lt;</a>
+        <div class="fullFileName mobile">[[_computeFileDisplayName(_path)]]
+        </div>
+        <a class="mobileNavLink"
+            href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">&gt;</a>
+      </div>
       <gr-diff
           id="diff"
           project="[[_change.project]]"
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 d6a3bc0..d1ac842 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
@@ -15,18 +15,16 @@
   'use strict';
 
   var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+  var MERGE_LIST_PATH = '/MERGE_LIST';
 
-  var DiffViewMode = {
-    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-    UNIFIED: 'UNIFIED_DIFF',
-  };
+  var COMMENT_SAVE = 'Try again when all comments have saved.';
 
   var DiffSides = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
-  var HASH_PATTERN = /^b?\d+$/;
+  var HASH_PATTERN = /^[ab]?\d+$/;
 
   Polymer({
     is: 'gr-diff-view',
@@ -37,6 +35,12 @@
      * @event title-change
      */
 
+    /**
+     * Fired when user tries to navigate away while comments are pending save.
+     *
+     * @event show-alert
+     */
+
     properties: {
       /**
        * URL params passed from the router.
@@ -85,18 +89,51 @@
       },
       _isImageDiff: Boolean,
       _filesWeblinks: Object,
+
+      /**
+       * Map of paths in the current change and patch range that have comments
+       * or drafts or robot comments.
+       */
+      _commentMap: Object,
+
+      /**
+       * Object to contain the path of the next and previous file in the current
+       * change and patch range that has comments.
+       */
+      _commentSkips: {
+        type: Object,
+        computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
+      },
     },
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.RESTClientBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     observers: [
-      '_getChangeDetail(_changeNum)',
       '_getProjectConfig(_change.project)',
       '_getFiles(_changeNum, _patchRange.*)',
     ],
 
+    keyBindings: {
+      'esc': '_handleEscKey',
+      'shift+left': '_handleShiftLeftKey',
+      'shift+right': '_handleShiftRightKey',
+      'up k': '_handleUpKey',
+      'down j': '_handleDownKey',
+      'c': '_handleCKey',
+      '[': '_handleLeftBracketKey',
+      ']': '_handleRightBracketKey',
+      'n shift+n': '_handleNKey',
+      'p shift+p': '_handlePKey',
+      'a shift+a': '_handleAKey',
+      'u': '_handleUKey',
+      ',': '_handleCommaKey',
+    },
+
     attached: function() {
       this._getLoggedIn().then(function(loggedIn) {
         this._loggedIn = loggedIn;
@@ -104,6 +141,12 @@
           this._setReviewed(true);
         }
       }.bind(this));
+      if (this.changeViewState.diffMode === null) {
+        // If screen size is small, always default to unified view.
+        this.$.restAPI.getPreferences().then(function(prefs) {
+          this.set('changeViewState.diffMode', prefs.default_diff_view);
+        }.bind(this));
+      }
 
       if (this._path) {
         this.fire('title-change',
@@ -113,11 +156,6 @@
       this.$.cursor.push('diffs', this.$.diff);
     },
 
-    detached: function() {
-      // Reset the diff mode to null so that it reverts to the user preference.
-      this.changeViewState.diffMode = null;
-    },
-
     _getLoggedIn: function() {
       return this.$.restAPI.getLoggedIn();
     },
@@ -152,6 +190,10 @@
       return this.$.restAPI.getPreferences();
     },
 
+    _getWindowWidth: function() {
+      return window.innerWidth;
+    },
+
     _handleReviewedChange: function(e) {
       this._setReviewed(Polymer.dom(e).rootTarget.checked);
     },
@@ -170,106 +212,224 @@
           this._patchRange.patchNum, this._path, reviewed);
     },
 
-    _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+    _handleEscKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(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'
-          if (!this.$.diff.isRangeSelected()) {
-            e.preventDefault();
-            var line = this.$.cursor.getTargetLineElement();
-            if (line) {
-              this.$.diff.addDraftAtLine(line);
-            }
-          }
-          break;
-        case 219:  // '['
-          e.preventDefault();
-          this._navToFile(this._fileList, -1);
-          break;
-        case 221:  // ']'
-          e.preventDefault();
-          this._navToFile(this._fileList, 1);
-          break;
-        case 78:  // 'n'
-          e.preventDefault();
-          if (e.shiftKey) {
-            this.$.cursor.moveToNextCommentThread();
-          } else {
-            this.$.cursor.moveToNextChunk();
-          }
-          break;
-        case 80:  // 'p'
-          e.preventDefault();
-          if (e.shiftKey) {
-            this.$.cursor.moveToPreviousCommentThread();
-          } else {
-            this.$.cursor.moveToPreviousChunk();
-          }
-          break;
-        case 65:  // 'a'
-          if (e.shiftKey) { // Hide left diff.
-            e.preventDefault();
-            this.$.diff.toggleLeftDiff();
-            break;
-          }
+      e.preventDefault();
+      this.$.diff.displayLine = false;
+    },
 
-          if (!this._loggedIn) { break; }
+    _handleShiftLeftKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
-          this.set('changeViewState.showReplyDialog', true);
-          /* falls through */ // required by JSHint
-        case 85:  // 'u'
-          if (this._changeNum && this._patchRange.patchNum) {
-            e.preventDefault();
-            page.show(this._getChangePath(
-                this._changeNum,
-                this._patchRange,
-                this._change && this._change.revisions));
-          }
-          break;
-        case 188:  // ','
-          e.preventDefault();
-          this.$.prefsOverlay.open();
-          break;
+      e.preventDefault();
+      this.$.cursor.moveLeft();
+    },
+
+    _handleShiftRightKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this.$.cursor.moveRight();
+    },
+
+    _handleUpKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (e.detail.keyboardEvent.shiftKey &&
+          e.detail.keyboardEvent.keyCode === 75) { // 'K'
+        this._moveToPreviousFileWithComment();
+        return;
+      }
+      if (this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.diff.displayLine = true;
+      this.$.cursor.moveUp();
+    },
+
+    _handleDownKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (e.detail.keyboardEvent.shiftKey &&
+          e.detail.keyboardEvent.keyCode === 74) { // 'J'
+        this._moveToNextFileWithComment();
+        return;
+      }
+      if (this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.diff.displayLine = true;
+      this.$.cursor.moveDown();
+    },
+
+    _moveToPreviousFileWithComment: function() {
+      if (this._commentSkips && this._commentSkips.previous) {
+        page.show(this._getDiffURL(this._changeNum, this._patchRange,
+            this._commentSkips.previous));
       }
     },
 
-    _navToFile: function(fileList, direction) {
-      if (fileList.length == 0) { return; }
+    _moveToNextFileWithComment: function() {
+      if (this._commentSkips && this._commentSkips.next) {
+        page.show(this._getDiffURL(this._changeNum, this._patchRange,
+            this._commentSkips.next));
+      }
+    },
 
-      var idx = fileList.indexOf(this._path) + direction;
-      if (idx < 0 || idx > fileList.length - 1) {
-        page.show(this._getChangePath(
-            this._changeNum,
-            this._patchRange,
-            this._change && this._change.revisions));
+    _handleCKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      if (this.$.diff.isRangeSelected()) { return; }
+      if (this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      var line = this.$.cursor.getTargetLineElement();
+      if (line) {
+        this.$.diff.addDraftAtLine(line);
+      }
+    },
+
+    _handleLeftBracketKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._navToFile(this._path, this._fileList, -1);
+    },
+
+    _handleRightBracketKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._navToFile(this._path, this._fileList, 1);
+    },
+
+    _handleNKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      if (e.detail.keyboardEvent.shiftKey) {
+        this.$.cursor.moveToNextCommentThread();
+      } else {
+        if (this.modifierPressed(e)) { return; }
+        this.$.cursor.moveToNextChunk();
+      }
+    },
+
+    _handlePKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      if (e.detail.keyboardEvent.shiftKey) {
+        this.$.cursor.moveToPreviousCommentThread();
+      } else {
+        if (this.modifierPressed(e)) { return; }
+        this.$.cursor.moveToPreviousChunk();
+      }
+    },
+
+    _handleAKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
+        e.preventDefault();
+        this.$.diff.toggleLeftDiff();
         return;
       }
-      page.show(this._getDiffURL(this._changeNum,
-                                 this._patchRange,
-                                 fileList[idx]));
+
+      if (this.modifierPressed(e)) { return; }
+
+      if (!this._loggedIn) { return; }
+      if (this.$.restAPI.hasPendingDiffDrafts()) {
+        this.dispatchEvent(new CustomEvent('show-alert',
+            {detail: {message: COMMENT_SAVE}, bubbles: true}));
+        return;
+      }
+
+      this.set('changeViewState.showReplyDialog', true);
+      e.preventDefault();
+      this._navToChangeView();
+    },
+
+    _handleUKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._navToChangeView();
+    },
+
+    _handleCommaKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._openPrefs();
+    },
+
+    _navToChangeView: function() {
+      if (!this._changeNum || !this._patchRange.patchNum) { return; }
+
+      page.show(this._getChangePath(
+          this._changeNum,
+          this._patchRange,
+          this._change && this._change.revisions));
+    },
+
+    _computeUpURL: function(changeNum, patchRange, change, changeRevisions) {
+      return this._getChangePath(
+          changeNum,
+          patchRange,
+          change && changeRevisions);
+    },
+
+    _navToFile: function(path, fileList, direction) {
+      var url = this._computeNavLinkURL(path, fileList, direction);
+      if (!url) { return; }
+
+      page.show(this._computeNavLinkURL(path, fileList, direction));
+    },
+
+    _openPrefs: function() {
+      this.$.prefsOverlay.open().then(function() {
+        var diffPreferences = this.$.diffPreferences;
+        var focusStops = diffPreferences.getFocusStops();
+        this.$.prefsOverlay.setFocusStops(focusStops);
+        this.$.diffPreferences.resetFocus();
+      }.bind(this));
+    },
+
+    /**
+     * @param {?string} path The path of the current file being shown.
+     * @param {Array.<string>} fileList The list of files in this change and
+     *     patch range.
+     * @param {number} direction Either 1 (next file) or -1 (prev file).
+     * @param {(number|boolean)} opt_noUp Whether to return to the change view
+     *     when advancing the file goes outside the bounds of fileList.
+     *
+     * @return {?string} The next URL when proceeding in the specified
+     *     direction.
+     */
+    _computeNavLinkURL: function(path, fileList, direction, opt_noUp) {
+      if (!path || fileList.length === 0) { return null; }
+
+      var idx = fileList.indexOf(path);
+      if (idx === -1) {
+        var file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
+        return this._getDiffURL(this._changeNum, this._patchRange, file);
+      }
+
+      idx += direction;
+      // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+      // outside the bounds of [0, fileList.length).
+      if (idx < 0 || idx > fileList.length - 1) {
+        if (opt_noUp) { return null; }
+        return this._getChangePath(
+            this._changeNum,
+            this._patchRange,
+            this._change && this._change.revisions);
+      }
+      return this._getDiffURL(this._changeNum, this._patchRange, fileList[idx]);
     },
 
     _paramsChanged: function(value) {
@@ -307,18 +467,23 @@
 
       promises.push(this._getChangeDetail(this._changeNum));
 
-      Promise.all(promises)
-          .then(function() { return this.$.diff.reload(); }.bind(this))
-          .then(function() { this._loading = false; }.bind(this));
+      Promise.all(promises).then(function() {
+        this._loading = false;
+        this.$.diff.reload();
+      }.bind(this));
+
+      this._loadCommentMap().then(function(commentMap) {
+        this._commentMap = commentMap;
+      }.bind(this));
     },
 
     /**
      * If the URL hash is a diff address then configure the diff cursor.
      */
     _loadHash: function(hash) {
-      var hash = hash.replace(/^#/, '');
+      hash = hash.replace(/^#/, '');
       if (!HASH_PATTERN.test(hash)) { return; }
-      if (hash[0] === 'b') {
+      if (hash[0] === 'a' || hash[0] === 'b') {
         this.$.cursor.side = DiffSides.LEFT;
         hash = hash.substring(1);
       } else {
@@ -339,8 +504,8 @@
     },
 
     _getDiffURL: function(changeNum, patchRange, path) {
-      return '/c/' + changeNum + '/' + this._patchRangeStr(patchRange) + '/' +
-          path;
+      return this.getBaseUrl() + '/c/' + changeNum + '/' +
+          this._patchRangeStr(patchRange) + '/' + this.encodeURL(path, true);
     },
 
     _computeDiffURL: function(changeNum, patchRangeRecord, path) {
@@ -365,7 +530,7 @@
     },
 
     _getChangePath: function(changeNum, patchRange, revisions) {
-      var base = '/c/' + changeNum + '/';
+      var base = this.getBaseUrl() + '/c/' + changeNum + '/';
 
       // The change may not have loaded yet, making revisions unavailable.
       if (!revisions) {
@@ -389,7 +554,16 @@
     },
 
     _computeFileDisplayName: function(path) {
-      return path == COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+      if (path === COMMIT_MESSAGE_PATH) {
+        return 'Commit message';
+      } else if (path === MERGE_LIST_PATH) {
+        return 'Merge list';
+      }
+      return path;
+    },
+
+    _computeTruncatedFileDisplayName: function(path) {
+      return util.truncatePath(this._computeFileDisplayName(path));
     },
 
     _computeFileSelected: function(path, currentPath) {
@@ -426,7 +600,7 @@
 
     _handlePrefsTap: function(e) {
       e.preventDefault();
-      this.$.prefsOverlay.open();
+      this._openPrefs();
     },
 
     _handlePrefsSave: function(e) {
@@ -458,9 +632,10 @@
      * the current state.
      *
      * The expected behavior is to use the mode specified in the user's
-     * preferences unless they have manually chosen the alternative view. If the
-     * user navigates up to the change view, it should clear this choice and
-     * revert to the preference the next time a diff is viewed.
+     * preferences unless they have manually chosen the alternative view or they
+     * are on a mobile device. If the user navigates up to the change view, it
+     * should clear this choice and revert to the preference the next time a
+     * diff is viewed.
      *
      * Use side-by-side if the user is not logged in.
      *
@@ -469,11 +644,12 @@
     _getDiffViewMode: function() {
       if (this.changeViewState.diffMode) {
         return this.changeViewState.diffMode;
-      } else if (this._userPrefs && this._userPrefs.diff_view) {
-        return this.changeViewState.diffMode = this._userPrefs.diff_view;
+      } else if (this._userPrefs) {
+        return this.changeViewState.diffMode =
+            this._userPrefs.default_diff_view;
+      } else {
+        return 'SIDE_BY_SIDE';
       }
-
-      return DiffViewMode.SIDE_BY_SIDE;
     },
 
     _computeModeSelectHidden: function() {
@@ -482,7 +658,75 @@
 
     _onLineSelected: function(e, detail) {
       this.$.cursor.moveToLineNumber(detail.number, detail.side);
-      history.pushState(null, null, '#' + this.$.cursor.getAddress());
+      history.replaceState(null, null, '#' + this.$.cursor.getAddress());
+    },
+
+    _computeDownloadLink: function(changeNum, patchRange, path) {
+      var url = this.changeBaseURL(changeNum, patchRange.patchNum);
+      url += '/patch?zip&path=' + encodeURIComponent(path);
+      return url;
+    },
+
+    /**
+     * Request all comments (and drafts and robot comments) for the current
+     * change and construct the map of file paths that have comments for the
+     * current patch range.
+     * @return {Promise} A promise that yields a comment map object.
+     */
+    _loadCommentMap: function() {
+      function filterByRange(comment) {
+        var patchNum = comment.patch_set + '';
+        return patchNum === this._patchRange.patchNum ||
+            patchNum === this._patchRange.basePatchNum;
+      };
+
+      return Promise.all([
+        this.$.restAPI.getDiffComments(this._changeNum),
+        this._getDiffDrafts(),
+        this.$.restAPI.getDiffRobotComments(this._changeNum),
+      ]).then(function(results) {
+        var commentMap = {};
+        results.forEach(function(response) {
+          for (var path in response) {
+            if (response.hasOwnProperty(path) &&
+                response[path].filter(filterByRange.bind(this)).length) {
+              commentMap[path] = true;
+            }
+          }
+        }.bind(this));
+        return commentMap;
+      }.bind(this));
+    },
+
+    _getDiffDrafts: function() {
+      return this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) { return Promise.resolve({}); }
+        return this.$.restAPI.getDiffDrafts(this._changeNum);
+      }.bind(this));
+    },
+
+    _computeCommentSkips: function(commentMap, fileList, path) {
+      var skips = {previous: null, next: null};
+      if (!fileList.length) { return skips; }
+      var pathIndex = fileList.indexOf(path);
+
+      // Scan backward for the previous file.
+      for (var i = pathIndex - 1; i >= 0; i--) {
+        if (commentMap[fileList[i]]) {
+          skips.previous = fileList[i];
+          break;
+        }
+      }
+
+      // Scan forward for the next file.
+      for (i = pathIndex + 1; i < fileList.length; i++) {
+        if (commentMap[fileList[i]]) {
+          skips.next = fileList[i];
+          break;
+        }
+      }
+
+      return skips;
     },
   });
 })();
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 0a4d6b6..d1bcd60 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
@@ -18,7 +18,7 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-view</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
@@ -26,17 +26,28 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-view.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-diff-view></gr-diff-view>
   </template>
 </test-fixture>
 
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-diff-view tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
+
       stub('gr-rest-api-interface', {
         getLoggedIn: function() { return Promise.resolve(false); },
         getProjectConfig: function() { return Promise.resolve({}); },
@@ -47,11 +58,14 @@
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('toggle left diff with a hotkey', function() {
-      var toggleLeftDiffStub = sinon.stub(element.$.diff, 'toggleLeftDiff');
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'a'
+      var toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
       assert.isTrue(toggleLeftDiffStub.calledOnce);
-      toggleLeftDiffStub.restore();
     });
 
     test('keyboard shortcuts', function() {
@@ -69,60 +83,82 @@
       element._path = 'glados.txt';
       element.changeViewState.selectedFileIndex = 1;
 
-      var showStub = sinon.stub(page, 'show');
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      var showStub = sandbox.stub(page, 'show');
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/'),
           'Should navigate to /c/42/');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(showStub.lastCall.calledWithExactly('/c/42/10/wheatley.md'),
           'Should navigate to /c/42/10/wheatley.md');
       element._path = 'wheatley.md';
       assert.equal(element.changeViewState.selectedFileIndex, 2);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/10/glados.txt'),
           'Should navigate to /c/42/10/glados.txt');
       element._path = 'glados.txt';
       assert.equal(element.changeViewState.selectedFileIndex, 1);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/10/chell.go'),
           'Should navigate to /c/42/10/chell.go');
       element._path = 'chell.go';
       assert.equal(element.changeViewState.selectedFileIndex, 0);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/'),
           'Should navigate to /c/42/');
       assert.equal(element.changeViewState.selectedFileIndex, 0);
 
-      var showPrefsStub = sinon.stub(element.$.prefsOverlay, 'open');
-      MockInteractions.pressAndReleaseKeyOn(element, 188);  // ','
+      var showPrefsStub = sandbox.stub(element.$.prefsOverlay, 'open',
+          function() { return Promise.resolve({}); });
+
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
-      var scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 78);  // 'n'
+      var scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
-      scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 80);  // 'p'
+      scrollStub = sandbox.stub(element.$.cursor, 'moveToPreviousChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
       assert(scrollStub.calledOnce);
-      scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, ['shift']);  // 'N'
+      scrollStub = sandbox.stub(element.$.cursor, 'moveToNextCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
       assert(scrollStub.calledOnce);
-      scrollStub.restore();
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, ['shift']);  // 'P'
+      scrollStub = sandbox.stub(element.$.cursor,
+          'moveToPreviousCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
-      scrollStub.restore();
 
-      showPrefsStub.restore();
-      showStub.restore();
+      var computeContainerClassStub = sandbox.stub(element.$.diff,
+          '_computeContainerClass');
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert(computeContainerClassStub.lastCall.calledWithExactly(
+          false, 'SIDE_BY_SIDE', true));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      assert(computeContainerClassStub.lastCall.calledWithExactly(
+          false, 'SIDE_BY_SIDE', false));
+    });
+
+    test('saving diff preferences', function() {
+      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
+      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
+      element.$.diffPreferences._handleSave();
+      assert(savePrefs.calledOnce);
+      assert(cancelPrefs.notCalled);
+    });
+
+    test('cancelling diff preferences', function() {
+      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
+      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
+      element.$.diffPreferences._handleCancel();
+      assert(cancelPrefs.calledOnce);
+      assert(savePrefs.notCalled);
     });
 
     test('keyboard shortcuts with patch range', function() {
@@ -139,45 +175,43 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
           'only work when the user is logged in.');
       assert.isNull(window.sessionStorage.getItem(
           'changeView.showReplyDialog'));
 
       element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
           'Should navigate to /c/42/5..10');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
           'Should navigate to /c/42/5..10');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/wheatley.md'),
           'Should navigate to /c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/glados.txt'),
           'Should navigate to /c/42/5..10/glados.txt');
       element._path = 'glados.txt';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/chell.go'),
           'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
           'Should navigate to /c/42/5..10');
-
-      showStub.restore();
     });
 
     test('keyboard shortcuts with old patch number', function() {
@@ -195,45 +229,43 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
           'only work when the user is logged in.');
       assert.isNull(window.sessionStorage.getItem(
           'changeView.showReplyDialog'));
 
       element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
           'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
           'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
           'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
-
-      showStub.restore();
     });
 
     test('go up to change via kb without change loaded', function() {
@@ -246,45 +278,43 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
           'only work when the user is logged in.');
       assert.isNull(window.sessionStorage.getItem(
           'changeView.showReplyDialog'));
 
       element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
           'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
           'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
           'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
-      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(showStub.lastCall.calledWithExactly('/c/42/1'),
           'Should navigate to /c/42/1');
-
-      showStub.restore();
     });
 
     test('jump to file dropdown', function() {
@@ -313,6 +343,8 @@
           '/foo/bar/baz');
       assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
           'Commit message');
+      assert.equal(element._computeFileDisplayName('/MERGE_LIST'),
+          'Merge list');
     });
 
     test('jump to file dropdown with patch range', function() {
@@ -338,6 +370,87 @@
       assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
     });
 
+    test('prev/up/next links', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '10',
+      };
+      element._change = {
+        revisions: {
+          a: {_number: 10},
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+      assert.equal(linkEls.length, 3);
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+      flushAsynchronousOperations();
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/glados.txt');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
+      assert.isFalse(linkEls[2].hasAttribute('href'));
+      element._path = 'chell.go';
+      flushAsynchronousOperations();
+      assert.isFalse(linkEls[0].hasAttribute('href'));
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/glados.txt');
+      element._path = 'not_a_real_file';
+      flushAsynchronousOperations();
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/wheatley.md');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/chell.go');
+    });
+
+    test('download link', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      assert.equal(element.$$('.downloadLink').getAttribute('href'),
+          '/changes/42/revisions/10/patch?zip&path=glados.txt');
+    });
+
+    test('prev/up/next links with patch range', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._change = {
+        revisions: {
+          a: {_number: 5},
+          b: {_number: 10},
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+      assert.equal(linkEls.length, 3);
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
+      element._path = 'wheatley.md';
+      flushAsynchronousOperations();
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/glados.txt');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
+      assert.isFalse(linkEls[2].hasAttribute('href'));
+      element._path = 'chell.go';
+      flushAsynchronousOperations();
+      assert.isFalse(linkEls[0].hasAttribute('href'));
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/glados.txt');
+    });
+
     test('file review status', function(done) {
       element._loggedIn = true;
       element._changeNum = '42';
@@ -347,7 +460,7 @@
       };
       element._fileList = ['/COMMIT_MSG'];
       element._path = '/COMMIT_MSG';
-      var saveReviewedStub = sinon.stub(element, '_saveReviewedState',
+      var saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
           function() { return Promise.resolve(); });
 
       flush(function() {
@@ -363,7 +476,6 @@
         assert.isTrue(commitMsg.checked);
         assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
 
-        saveReviewedStub.restore();
         done();
       });
     });
@@ -371,8 +483,7 @@
     test('diff mode selector correctly toggles the diff', function() {
       var select = element.$.modeSelect;
       var diffDisplay = element.$.diff;
-
-      element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
+      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
       assert.equal(element._getDiffViewMode(), select.value);
@@ -383,7 +494,6 @@
 
       // We will simulate a user change of the selected mode.
       var newMode = 'UNIFIED_DIFF';
-
       // Set the actual value of the select, and simulate the change event.
       select.value = newMode;
       element.fire('change', {}, {node: select});
@@ -394,6 +504,29 @@
       assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
     });
 
+    test('diff mode selector initializes from preferences', function() {
+      var resolvePrefs;
+      var prefsPromise = new Promise(function(resolve) {
+        resolvePrefs = resolve;
+      });
+      var getPreferencesStub = sandbox.stub(element.$.restAPI, 'getPreferences',
+          function() { return prefsPromise; });
+
+      // Attach a new gr-diff-view so we can intercept the preferences fetch.
+      var view = document.createElement('gr-diff-view');
+      var select = view.$.modeSelect;
+      fixture('blank').appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({default_diff_view: 'UNIFIED'});
+      flushAsynchronousOperations();
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+    });
+
     test('_loadHash', function() {
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
@@ -410,6 +543,169 @@
       element._loadHash('b345');
       assert.equal(element.$.cursor.initialLineNumber, 345);
       assert.equal(element.$.cursor.side, 'left');
+
+      // GWT-style base hash:
+      element._loadHash('a123');
+      assert.equal(element.$.cursor.initialLineNumber, 123);
+      assert.equal(element.$.cursor.side, 'left');
+    });
+
+    test('_shortenPath with long path should add ellipsis', function() {
+      var path =
+          'level1/level2/level3/level4/file.js';
+      var shortenedPath = util.truncatePath(path);
+      // The expected path is truncated with an ellipsis.
+      var expectedPath = '\u2026/file.js';
+      assert.equal(shortenedPath, expectedPath);
+
+      var path = 'level2/file.js';
+      var shortenedPath = util.truncatePath(path);
+      assert.equal(shortenedPath, expectedPath);
+    });
+
+    test('_shortenPath with short path should not add ellipsis', function() {
+      var path = 'file.js';
+      var expectedPath = 'file.js';
+      var shortenedPath = util.truncatePath(path);
+      assert.equal(shortenedPath, expectedPath);
+    });
+
+    test('_onLineSelected', function() {
+      var replaceStateStub = sandbox.stub(history, 'replaceState');
+      var moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
+
+      var e = {};
+      var detail = {number: 123, side: 'right'};
+
+      element._onLineSelected(e, detail);
+
+      assert.isTrue(moveStub.called);
+      assert.equal(moveStub.lastCall.args[0], detail.number);
+      assert.equal(moveStub.lastCall.args[1], detail.side);
+
+      assert.isTrue(replaceStateStub.called);
+    });
+
+    test('_getDiffURL encodes special characters', function() {
+      var changeNum = 123;
+      var patchRange = {basePatchNum: 123, patchNum: 456};
+      var path = 'c++/cpp.cpp';
+      assert.equal(element._getDiffURL(changeNum, patchRange, path),
+          '/c/123/123..456/c%252B%252B/cpp.cpp');
+    });
+
+    test('_getDiffViewMode', function() {
+      // No user prefs or change view state set.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      // User prefs but no change view state set.
+      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+
+      // User prefs and change view state set.
+      element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    suite('_loadCommentMap', function() {
+      test('empty', function(done) {
+        stub('gr-rest-api-interface', {
+          getDiffRobotComments: function() { return Promise.resolve({}); },
+          getDiffComments: function() { return Promise.resolve({}); },
+        });
+        element._loadCommentMap().then(function(map) {
+          assert.equal(Object.keys(map).length, 0);
+          done();
+        });
+      });
+
+      test('paths in patch range', function(done) {
+        stub('gr-rest-api-interface', {
+          getDiffRobotComments: function() { return Promise.resolve({}); },
+          getDiffComments: function() {
+            return Promise.resolve({
+              'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
+              'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
+            });
+          },
+        });
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: '3',
+          patchNum: '5',
+        };
+        element._loadCommentMap().then(function(map) {
+          assert.deepEqual(Object.keys(map),
+              ['path/to/file/one.cpp', 'path-to/file/two.py']);
+          done();
+        });
+      });
+
+      test('empty for paths outside patch range', function(done) {
+        stub('gr-rest-api-interface', {
+          getDiffRobotComments: function() { return Promise.resolve({}); },
+          getDiffComments: function() {
+            return Promise.resolve({
+              'path/to/file/one.cpp': [{patch_set: 'PARENT', message: 'lorem'}],
+              'path-to/file/two.py': [{patch_set: 2, message: 'ipsum'}],
+            });
+          },
+        });
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: '3',
+          patchNum: '5',
+        };
+        element._loadCommentMap().then(function(map) {
+          assert.equal(Object.keys(map).length, 0);
+          done();
+        });
+      });
+    });
+
+    suite('_computeCommentSkips', function() {
+      test('empty file list', function() {
+        var commentMap = {
+          'path/one.jpg': true,
+          'path/three.wav': true,
+        };
+        var path = 'path/two.m4v';
+        var fileList = [];
+        var result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.isNull(result.previous);
+        assert.isNull(result.next);
+      });
+
+      test('finds skips', function() {
+        var fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
+        var path = fileList[1];
+        var commentMap = {};
+        commentMap[fileList[0]] = true;
+        commentMap[fileList[1]] = false;
+        commentMap[fileList[2]] = true;
+
+        var result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[0]);
+        assert.equal(result.next, fileList[2]);
+
+        commentMap[fileList[1]] = true;
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[0]);
+        assert.equal(result.next, fileList[2]);
+
+        path = fileList[0];
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.isNull(result.previous);
+        assert.equal(result.next, fileList[1]);
+
+        path = fileList[2];
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[1]);
+        assert.isNull(result.next);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 46612a0..54e9c6e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -28,9 +28,10 @@
     <style>
       :host {
         --light-remove-highlight-color: #fee;
-        --dark-remove-highlight-color: #ffd4d4;
+        --dark-remove-highlight-color: rgba(255, 0, 0, 0.15);
         --light-add-highlight-color: #efe;
-        --dark-add-highlight-color: #d4ffd4;
+        --dark-add-highlight-color: rgba(0, 255, 0, 0.15);
+
       }
       :host.no-left .sideBySide ::content .left,
       :host.no-left .sideBySide ::content .left + td,
@@ -50,8 +51,8 @@
         border-collapse: collapse;
         border-right: 1px solid #ddd;
         table-layout: fixed;
-      }
-      table tbody {
+
+        /* Hint GPU acceleration */
         -webkit-transform: translateZ(0);
         -moz-transform: translateZ(0);
         -ms-transform: translateZ(0);
@@ -86,18 +87,20 @@
       .content {
         background-color: #fff;
       }
+      .full-width {
+        width: 100%;
+      }
+      .full-width .contentText {
+        white-space: pre-wrap;
+        word-wrap: break-word;
+      }
       .lineNum,
       .content {
+        /* Set font size based the user's diff preference. */
+        font-size: var(--font-size, 12px);
         vertical-align: top;
         white-space: pre;
       }
-      .contentText:empty:before {
-        /**
-         * Insert glyph to prevent empty diff content from collapsing.
-         * "\200B" is a 'ZERO WIDTH SPACE' (U+200B)
-         */
-        content: "\200B";
-      }
       .contextLineNum:before,
       .lineNum:before {
         display: inline-block;
@@ -119,21 +122,26 @@
            allows them to shrink. */
         max-width: var(--content-width, 80ch);
         min-width: var(--content-width, 80ch);
+        width: var(--content-width, 80ch);
       }
       .content.add .intraline,
-      .content.add.darkHighlight {
+      .delta.total .content.add {
         background-color: var(--dark-add-highlight-color);
       }
-      .content.add.lightHighlight {
+      .content.add {
         background-color: var(--light-add-highlight-color);
       }
       .content.remove .intraline,
-      .content.remove.darkHighlight {
+      .delta.total .content.remove {
         background-color: var(--dark-remove-highlight-color);
       }
-      .content.remove.lightHighlight {
+      .content.remove {
         background-color: var(--light-remove-highlight-color);
       }
+      .content .contentText:after {
+        /* Newline, to ensure all lines are one line-height tall. */
+        content: '\A';
+      }
       .contextControl {
         background-color: #fef;
         color: #849;
@@ -146,23 +154,30 @@
       .contextControl td:not(.lineNum) {
         text-align: center;
       }
+      .displayLine .diff-row.target-row {
+        border-bottom: 1px solid #bbb;
+      }
       .br:after {
         /* Line feed */
         content: '\A';
       }
       .tab {
         display: inline-block;
-        position: relative;
       }
-      .tab.withIndicator {
-        color: #D68E47;
-        text-decoration: line-through;
+      .tab-indicator:before {
+        color: #C62828;
+        /* >> character */
+        content: '\00BB';
+      }
+      .trailing-whitespace {
+        border-radius: .4em;
+        background-color: #FF9AD2;
       }
     </style>
     <style include="gr-theme-default"></style>
-    <div class$="[[_computeContainerClass(_loggedIn, viewMode)]]"
+    <div class$="[[_computeContainerClass(_loggedIn, viewMode, displayLine)]]"
         on-tap="_handleTap">
-      <gr-diff-selection>
+      <gr-diff-selection diff="[[_diff]]">
         <gr-diff-highlight
             id="highlights"
             logged-in="[[_loggedIn]]"
@@ -172,10 +187,11 @@
               comments="[[_comments]]"
               diff="[[_diff]]"
               view-mode="[[viewMode]]"
+              line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
               base-image="[[_baseImage]]"
               revision-image="[[_revisionImage]]">
-            <table id="diffTable"></table>
+            <table id="diffTable" class$="[[_diffTableClass]]"></table>
           </gr-diff-builder>
         </gr-diff-highlight>
       </gr-diff-selection>
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 dbcbb38..05a7f72 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -34,6 +34,10 @@
 
     properties: {
       changeNum: String,
+      noAutoRender: {
+        type: Boolean,
+        value: false,
+      },
       patchRange: Object,
       path: String,
       prefs: {
@@ -46,6 +50,10 @@
       },
       project: String,
       commit: String,
+      displayLine: {
+        type: Boolean,
+        value: false,
+      },
       isImageDiff: {
         type: Boolean,
         computed: '_computeIsImageDiff(_diff)',
@@ -56,17 +64,29 @@
         value: function() { return {}; },
         notify: true,
       },
-
+      hidden: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
       _loggedIn: {
         type: Boolean,
         value: false,
       },
+      lineWrapping: {
+        type: Boolean,
+        value: false,
+        observer: '_lineWrappingObserver',
+      },
       viewMode: {
         type: String,
         value: DiffViewMode.SIDE_BY_SIDE,
         observer: '_viewModeObserver',
       },
       _diff: Object,
+      _diffTableClass: {
+        type: String,
+        value: '',
+      },
       _comments: Object,
       _baseImage: Object,
       _revisionImage: Object,
@@ -84,6 +104,13 @@
       this._getLoggedIn().then(function(loggedIn) {
         this._loggedIn = loggedIn;
       }.bind(this));
+
+    },
+
+    ready: function() {
+      if (this._canRender()) {
+        this.reload();
+      }
     },
 
     reload: function() {
@@ -102,13 +129,14 @@
 
       return Promise.all(promises).then(function() {
         if (this.prefs) {
-          this._render();
+          return this._renderDiffTable();
         }
+        return Promise.resolve();
       }.bind(this));
     },
 
     getCursorStops: function() {
-      if (this.hidden) {
+      if (this.hidden && this.noAutoRender) {
         return [];
       }
 
@@ -141,11 +169,16 @@
       this.toggleClass('no-left');
     },
 
+    _canRender: function() {
+      return this.changeNum && this.patchRange && this.path &&
+          !this.noAutoRender;
+    },
+
     _getCommentThreads: function() {
       return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
     },
 
-    _computeContainerClass: function(loggedIn, viewMode) {
+    _computeContainerClass: function(loggedIn, viewMode, displayLine) {
       var classes = ['diffContainer'];
       switch (viewMode) {
         case DiffViewMode.UNIFIED:
@@ -160,6 +193,9 @@
       if (loggedIn) {
         classes.push('canComment');
       }
+      if (displayLine) {
+        classes.push('displayLine');
+      }
       return classes.join(' ');
     },
 
@@ -193,31 +229,62 @@
       var contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
       var contentEl = contentText.parentElement;
       var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
-      var side = this._getSideByLineAndContent(lineEl, contentEl);
-      var threadEl = this._getOrCreateThreadAtLine(contentEl, patchNum, side);
+      var isOnParent =
+          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
+      var threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
+          diffSide, isOnParent, range);
 
-      threadEl.addDraft(line, range);
+      threadEl.addOrEditDraft(line, range);
     },
 
     _addDraft: function(lineEl, opt_lineNum) {
       var contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
       var contentEl = contentText.parentElement;
       var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
-      var side = this._getSideByLineAndContent(lineEl, contentEl);
-      var threadEl = this._getOrCreateThreadAtLine(contentEl, patchNum, side);
+      var commentSide = this._getCommentSideByLineAndContent(lineEl, contentEl);
+      var isOnParent =
+          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
+      var threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
+          commentSide, isOnParent);
 
       threadEl.addOrEditDraft(opt_lineNum);
     },
 
-    _getOrCreateThreadAtLine: function(contentEl, patchNum, side) {
-      var threadEl = contentEl.querySelector('gr-diff-comment-thread');
+    _getThreadForRange: function(threadGroupEl, rangeToCheck) {
+      return threadGroupEl.getThreadForRange(rangeToCheck);
+    },
 
-      if (!threadEl) {
-        threadEl = this.$.diffBuilder.createCommentThread(
-            this.changeNum, patchNum, this.path, side, this.projectConfig);
-        contentEl.appendChild(threadEl);
+    _getThreadGroupForLine: function(contentEl) {
+      return contentEl.querySelector('gr-diff-comment-thread-group');
+    },
+
+    _getOrCreateThreadAtLineRange:
+        function(contentEl, patchNum, commentSide, isOnParent, range) {
+      var rangeToCheck = range ?
+          'range-' +
+          range.startLine + '-' +
+          range.startChar + '-' +
+          range.endLine + '-' +
+          range.endChar + '-' +
+          commentSide : 'line-' + commentSide;
+
+      // Check if thread group exists.
+      var threadGroupEl = this._getThreadGroupForLine(contentEl);
+      if (!threadGroupEl) {
+        threadGroupEl = this.$.diffBuilder.createCommentThreadGroup(
+          this.changeNum, patchNum, this.path, isOnParent,
+          this.projectConfig);
+        contentEl.appendChild(threadGroupEl);
       }
 
+      var threadEl = this._getThreadForRange(threadGroupEl, rangeToCheck);
+
+      if (!threadEl) {
+        threadGroupEl.addNewThread(rangeToCheck, commentSide);
+        Polymer.dom.flush();
+        threadEl = this._getThreadForRange(threadGroupEl, rangeToCheck);
+        threadEl.commentSide = commentSide;
+      }
       return threadEl;
     },
 
@@ -231,19 +298,28 @@
       return patchNum;
     },
 
-    _getSideByLineAndContent: function(lineEl, contentEl) {
-      var side = 'REVISION';
+    _getIsParentCommentByLineAndContent: function(lineEl, contentEl) {
+      var isOnParent = false;
       if ((lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) &&
           this.patchRange.basePatchNum === 'PARENT') {
-        side = 'PARENT';
+        isOnParent = true;
+      }
+      return isOnParent;
+    },
+
+    _getCommentSideByLineAndContent: function(lineEl, contentEl) {
+      var side = 'right';
+      if (lineEl.classList.contains(DiffSide.LEFT) ||
+          contentEl.classList.contains('remove')) {
+        side = 'left';
       }
       return side;
     },
 
     _handleThreadDiscard: function(e) {
       var el = Polymer.dom(e).rootTarget;
-      el.parentNode.removeChild(el);
+      el.parentNode.removeThread(el.locationRange);
     },
 
     _handleCommentDiscard: function(e) {
@@ -252,29 +328,20 @@
     },
 
     _removeComment: function(comment, opt_patchNum) {
-      var side = this._findCommentSide(comment, opt_patchNum);
+      var side = comment.__commentSide;
       this._removeCommentFromSide(comment, side);
     },
 
-    _findCommentSide: function(comment, opt_patchNum) {
-      if (comment.side === 'PARENT') {
-        return DiffSide.LEFT;
-      } else {
-        return this._comments.meta.patchRange.basePatchNum === opt_patchNum ?
-            DiffSide.LEFT : DiffSide.RIGHT;
-      }
-    },
-
     _handleCommentSave: function(e) {
       var comment = e.detail.comment;
-      var side = this._findCommentSide(comment, e.detail.patchNum);
+      var side = e.detail.comment.__commentSide;
       var idx = this._findDraftIndex(comment, side);
       this.set(['_comments', side, idx], comment);
     },
 
     _handleCommentUpdate: function(e) {
       var comment = e.detail.comment;
-      var side = this._findCommentSide(comment, e.detail.patchNum);
+      var side = e.detail.comment.__commentSide;
       var idx = this._findCommentIndex(comment, side);
       if (idx === -1) {
         idx = this._findDraftIndex(comment, side);
@@ -334,18 +401,35 @@
       this._prefsChanged(this.prefs);
     },
 
+    _lineWrappingObserver: function() {
+      this._prefsChanged(this.prefs);
+    },
+
     _prefsChanged: function(prefs) {
       if (!prefs) { return; }
-      this.customStyle['--content-width'] = prefs.line_length + 'ch';
+      if (prefs.line_wrapping) {
+        this._diffTableClass = 'full-width';
+        if (this.viewMode === 'SIDE_BY_SIDE') {
+          this.customStyle['--content-width'] = 'none';
+        }
+      } else {
+        this._diffTableClass = '';
+        this.customStyle['--content-width'] = prefs.line_length + 'ch';
+      }
+
+      if (!!prefs.font_size) {
+        this.customStyle['--font-size'] = prefs.font_size + 'px';
+      }
+
       this.updateStyles();
 
       if (this._diff && this._comments) {
-        this._render();
+        this._renderDiffTable();
       }
     },
 
-    _render: function() {
-      this.$.diffBuilder.render(this._comments, this.prefs);
+    _renderDiffTable: function() {
+      return this.$.diffBuilder.render(this._comments, this.prefs);
     },
 
     _clearDiffContent: function() {
@@ -353,6 +437,12 @@
     },
 
     _handleGetDiffError: function(response) {
+      // Loading the diff may respond with 409 if the file is too large. In this
+      // case, use a toast error..
+      if (response.status === 409) {
+        this.fire('server-error', {response: response});
+        return;
+      }
       this.fire('page-error', {response: response});
     },
 
@@ -363,12 +453,12 @@
           this.patchRange.patchNum,
           this.path,
           this._handleGetDiffError.bind(this)).then(function(diff) {
-               this.filesWeblinks = {
-                 meta_a: diff.meta_a && diff.meta_a.web_links,
-                 meta_b: diff.meta_b && diff.meta_b.web_links,
-               };
-               return diff;
-             }.bind(this));
+            this.filesWeblinks = {
+              meta_a: diff && diff.meta_a && diff.meta_a.web_links,
+              meta_b: diff && diff.meta_b && diff.meta_b.web_links,
+            };
+            return diff;
+          }.bind(this));
     },
 
     _getDiffComments: function() {
@@ -392,14 +482,24 @@
       }.bind(this));
     },
 
+    _getDiffRobotComments: function() {
+      return this.$.restAPI.getDiffRobotComments(
+          this.changeNum,
+          this.patchRange.basePatchNum,
+          this.patchRange.patchNum,
+          this.path);
+    },
+
     _getDiffCommentsAndDrafts: function() {
       var promises = [];
       promises.push(this._getDiffComments());
       promises.push(this._getDiffDrafts());
+      promises.push(this._getDiffRobotComments());
       return Promise.all(promises).then(function(results) {
         return Promise.resolve({
           comments: results[0],
           drafts: results[1],
+          robotComments: results[2],
         });
       }).then(this._normalizeDiffCommentsAndDrafts.bind(this));
     },
@@ -411,6 +511,9 @@
       }
       var baseDrafts = results.drafts.baseComments.map(markAsDraft);
       var drafts = results.drafts.comments.map(markAsDraft);
+
+      var baseRobotComments = results.robotComments.baseComments;
+      var robotComments = results.robotComments.comments;
       return Promise.resolve({
         meta: {
           path: this.path,
@@ -418,8 +521,10 @@
           patchRange: this.patchRange,
           projectConfig: this.projectConfig,
         },
-        left: results.comments.baseComments.concat(baseDrafts),
-        right: results.comments.comments.concat(drafts),
+        left: results.comments.baseComments.concat(baseDrafts)
+            .concat(baseRobotComments),
+        right: results.comments.comments.concat(drafts)
+            .concat(robotComments),
       });
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index c33eadb..34d6de21c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -18,13 +18,16 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-diff.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-diff></gr-diff>
@@ -34,9 +37,17 @@
 <script>
   suite('gr-diff tests', function() {
     var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
 
     suite('not logged in', function() {
-
       setup(function() {
         stub('gr-rest-api-interface', {
           getLoggedIn: function() { return Promise.resolve(false); },
@@ -51,20 +62,45 @@
         assert.isFalse(element.classList.contains('no-left'));
       });
 
+      test('view does not start with displayLine classList', function() {
+        assert.isFalse(
+            element.$$('.diffContainer').classList.contains('displayLine'));
+      });
+
+      test('displayLine class added called when displayLine is true',
+          function() {
+        var spy = sandbox.spy(element, '_computeContainerClass');
+        element.displayLine = true;
+        assert.isTrue(spy.called);
+        assert.isTrue(
+            element.$$('.diffContainer').classList.contains('displayLine'));
+      });
+
       test('get drafts', function(done) {
         element.patchRange = {basePatchNum: 0, patchNum: 0};
 
-        var getDraftsStub = sinon.stub(element.$.restAPI, 'getDiffDrafts');
+        var getDraftsStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts');
         element._getDiffDrafts().then(function(result) {
           assert.deepEqual(result, {baseComments: [], comments: []});
           sinon.assert.notCalled(getDraftsStub);
-          getDraftsStub.restore();
+          done();
+        });
+      });
+
+      test('get robot comments', function(done) {
+        element.patchRange = {basePatchNum: 0, patchNum: 0};
+
+        var getDraftsStub = sandbox.stub(element.$.restAPI,
+            'getDiffRobotComments');
+        element._getDiffDrafts().then(function(result) {
+          assert.deepEqual(result, {baseComments: [], comments: []});
+          sinon.assert.notCalled(getDraftsStub);
           done();
         });
       });
 
       test('loads files weblinks', function(done) {
-        var diffStub = sinon.stub(element.$.restAPI, 'getDiff').returns(
+        var diffStub = sandbox.stub(element.$.restAPI, 'getDiff').returns(
             Promise.resolve({
               meta_a: {
                 web_links: 'foo',
@@ -81,7 +117,6 @@
           });
           done();
         });
-        diffStub.restore();
       });
 
       test('remove comment', function() {
@@ -96,16 +131,16 @@
             projectConfig: {foo: 'bar'},
           },
           left: [
-            {id: 'bc1', side: 'PARENT'},
-            {id: 'bc2', side: 'PARENT'},
-            {id: 'bd1', __draft: true, side: 'PARENT'},
-            {id: 'bd2', __draft: true, side: 'PARENT'},
+            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
           ],
           right: [
-            {id: 'c1'},
-            {id: 'c2'},
-            {id: 'd1', __draft: true},
-            {id: 'd2', __draft: true},
+            {id: 'c1', __commentSide: 'right'},
+            {id: 'c2', __commentSide: 'right'},
+            {id: 'd1', __draft: true, __commentSide: 'right'},
+            {id: 'd2', __draft: true, __commentSide: 'right'},
           ],
         };
 
@@ -123,21 +158,22 @@
             projectConfig: {foo: 'bar'},
           },
           left: [
-            {id: 'bc1', side: 'PARENT'},
-            {id: 'bc2', side: 'PARENT'},
-            {id: 'bd1', __draft: true, side: 'PARENT'},
-            {id: 'bd2', __draft: true, side: 'PARENT'},
+            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
           ],
           right: [
-            {id: 'c1'},
-            {id: 'c2'},
-            {id: 'd1', __draft: true},
-            {id: 'd2', __draft: true},
+            {id: 'c1', __commentSide: 'right'},
+            {id: 'c2', __commentSide: 'right'},
+            {id: 'd1', __draft: true, __commentSide: 'right'},
+            {id: 'd2', __draft: true, __commentSide: 'right'},
           ],
         }));
 
-        element._removeComment({id: 'bc2', side: 'PARENT'});
-        assert.equal(JSON.stringify(element._comments), JSON.stringify({
+        element._removeComment({id: 'bc2', side: 'PARENT',
+            __commentSide: 'left'});
+        assert.deepEqual(element._comments, {
           meta: {
             changeNum: '42',
             patchRange: {
@@ -148,20 +184,20 @@
             projectConfig: {foo: 'bar'},
           },
           left: [
-            {id: 'bc1', side: 'PARENT'},
-            {id: 'bd1', __draft: true, side: 'PARENT'},
-            {id: 'bd2', __draft: true, side: 'PARENT'},
+            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
           ],
           right: [
-            {id: 'c1'},
-            {id: 'c2'},
-            {id: 'd1', __draft: true},
-            {id: 'd2', __draft: true},
+            {id: 'c1', __commentSide: 'right'},
+            {id: 'c2', __commentSide: 'right'},
+            {id: 'd1', __draft: true, __commentSide: 'right'},
+            {id: 'd2', __draft: true, __commentSide: 'right'},
           ],
-        }));
+        });
 
-        element._removeComment({id: 'd2'});
-        assert.deepEqual(JSON.stringify(element._comments), JSON.stringify({
+        element._removeComment({id: 'd2', __commentSide: 'right'});
+        assert.deepEqual(element._comments, {
           meta: {
             changeNum: '42',
             patchRange: {
@@ -172,16 +208,71 @@
             projectConfig: {foo: 'bar'},
           },
           left: [
-            {id: 'bc1', side: 'PARENT'},
-            {id: 'bd1', __draft: true, side: 'PARENT'},
-            {id: 'bd2', __draft: true, side: 'PARENT'},
+            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
           ],
           right: [
-            {id: 'c1'},
-            {id: 'c2'},
-            {id: 'd1', __draft: true},
+            {id: 'c1', __commentSide: 'right'},
+            {id: 'c2', __commentSide: 'right'},
+            {id: 'd1', __draft: true, __commentSide: 'right'},
           ],
-        }));
+        });
+      });
+
+      test('thread groups', function() {
+        var contentEl = document.createElement('div');
+        var rangeToCheck = 'line-left';
+        var commentSide = 'left';
+        var patchNum = 1;
+        var side = 'PARENT';
+        var range = {
+          startLine: 1,
+          startChar: 1,
+          endLine: 1,
+          endChar: 2,
+        };
+
+        element.changeNum = 123;
+        element.patchRange = {basePatchNum: 1, patchNum: 2};
+        element.path = 'file.txt';
+
+        sandbox.stub(element.$.diffBuilder, 'createCommentThreadGroup',
+            function() {
+          var threadGroup =
+              document.createElement('gr-diff-comment-thread-group');
+          threadGroup.patchForNewThreads = 1;
+          return threadGroup;
+        });
+
+        // No thread groups.
+        assert.isNotOk(element._getThreadGroupForLine(contentEl));
+
+        // A thread group gets created.
+        assert.isOk(element._getOrCreateThreadAtLineRange(contentEl,
+            patchNum, commentSide, side));
+
+        // Try to fetch a thread with a different range.
+        range = {
+          startLine: 1,
+          startChar: 1,
+          endLine: 1,
+          endChar: 3,
+        };
+
+        assert.isOk(element._getOrCreateThreadAtLineRange(
+            contentEl, patchNum, commentSide, side, range));
+        // The new thread group can be fetched.
+        assert.isOk(element._getThreadGroupForLine(contentEl));
+
+        assert.equal(contentEl.querySelectorAll(
+            'gr-diff-comment-thread-group').length, 1);
+
+        var threadGroup = contentEl.querySelector(
+            'gr-diff-comment-thread-group');
+        var threadLength = Polymer.dom(threadGroup.root).
+              querySelectorAll('gr-diff-comment-thread').length;
+        assert.equal(threadLength, 2);
       });
 
       test('renders image diffs', function(done) {
@@ -234,19 +325,19 @@
         var mockComments = {baseComments: [], comments: []};
 
         var stubs = [];
-        stubs.push(sinon.stub(element, '_getDiff',
+        stubs.push(sandbox.stub(element, '_getDiff',
             function() { return Promise.resolve(mockDiff); }));
-        stubs.push(sinon.stub(element.$.restAPI, 'getCommitInfo',
+        stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
             function() { return Promise.resolve(mockCommit); }));
-        stubs.push(sinon.stub(element.$.restAPI,
+        stubs.push(sandbox.stub(element.$.restAPI,
             'getCommitFileContents',
             function() { return Promise.resolve(mockFile1); }));
-        stubs.push(sinon.stub(element.$.restAPI,
+        stubs.push(sandbox.stub(element.$.restAPI,
             'getChangeFileContents',
             function() { return Promise.resolve(mockFile2); }));
-        stubs.push(sinon.stub(element.$.restAPI, '_getDiffComments',
+        stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
             function() { return Promise.resolve(mockComments); }));
-        stubs.push(sinon.stub(element.$.restAPI, 'getDiffDrafts',
+        stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
             function() { return Promise.resolve(mockComments); }));
 
         element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
@@ -270,7 +361,6 @@
 
           // Cleanup.
           element.removeEventListener('render', rendered);
-          stubs.forEach(function(stub) { stub.restore(); });
 
           done();
         };
@@ -312,28 +402,82 @@
         var content = document.createElement('div');
         var lineEl = document.createElement('div');
 
-        var selectStub = sinon.stub(element, '_selectLine');
-        var getLineStub = sinon.stub(element.$.diffBuilder, 'getLineElByChild',
-            function() { return lineEl; });
+        var selectStub = sandbox.stub(element, '_selectLine');
+        var getLineStub = sandbox.stub(element.$.diffBuilder,
+            'getLineElByChild', function() { return lineEl; });
 
         content.className = 'content';
         content.addEventListener('click', function(e) {
           element._handleTap(e);
           assert.isTrue(selectStub.called);
           assert.equal(selectStub.lastCall.args[0], lineEl);
-          selectStub.restore();
-          getLineStub.restore();
           done();
         });
         content.click();
       });
+
+      test('_getDiff handles null diff responses', function(done) {
+        stub('gr-rest-api-interface', {
+          getDiff: function() { return Promise.resolve(null); },
+        });
+        element.changeNum = 123;
+        element.patchRange = {basePatchNum: 1, patchNum: 2};
+        element.path = 'file.txt';
+        element._getDiff().then(done);
+      });
+
+      suite('getCursorStops', function() {
+
+        var setupDiff = function() {
+          var mock = document.createElement('mock-diff-response');
+          element._diff = mock.diffResponse;
+          element._comments = {
+            left: [],
+            right: [],
+          };
+          element.prefs = {
+            context: 10,
+            tab_size: 8,
+            font_size: 12,
+            line_length: 100,
+            cursor_blink_rate: 0,
+            line_wrapping: false,
+            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',
+          };
+
+          element._renderDiffTable();
+          flushAsynchronousOperations();
+        };
+
+        test('getCursorStops returns [] when hidden and noAutoRender are true',
+             function() {
+          element.noAutoRender = true;
+          setupDiff();
+          element.hidden = true;
+          assert.equal(element.getCursorStops().length, 0);
+        });
+
+        test('getCursorStops', function() {
+          setupDiff();
+          assert.equal(element.getCursorStops().length, 50);
+        });
+      });
     });
 
     suite('logged in', function() {
-
       setup(function() {
         stub('gr-rest-api-interface', {
           getLoggedIn: function() { return Promise.resolve(true); },
+          getPreferences: function() {
+            return Promise.resolve({time_format: 'HHMM_12'});
+          },
         });
         element = fixture('basic');
       });
@@ -344,11 +488,10 @@
           baseComments: [{id: 'foo'}],
           comments: [{id: 'bar'}],
         };
-        var getDraftsStub = sinon.stub(element.$.restAPI, 'getDiffDrafts',
+        var getDraftsStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts',
             function() { return Promise.resolve(draftsResponse); });
         element._getDiffDrafts().then(function(result) {
           assert.deepEqual(result, draftsResponse);
-          getDraftsStub.restore();
           done();
         });
       });
@@ -356,30 +499,46 @@
       test('get comments and drafts', function(done) {
         var comments = {
           baseComments: [
-            {id: 'bc1'},
-            {id: 'bc2'},
+            {id: 'bc1', __commentSide: 'left'},
+            {id: 'bc2', __commentSide: 'left'},
           ],
           comments: [
-            {id: 'c1'},
-            {id: 'c2'},
+            {id: 'c1', __commentSide: 'right'},
+            {id: 'c2', __commentSide: 'right'},
           ],
         };
-        var diffCommentsStub = sinon.stub(element, '_getDiffComments',
+        var diffCommentsStub = sandbox.stub(element, '_getDiffComments',
             function() { return Promise.resolve(comments); });
 
         var drafts = {
           baseComments: [
-            {id: 'bd1'},
-            {id: 'bd2'},
+            {id: 'bd1', __commentSide: 'left'},
+            {id: 'bd2', __commentSide: 'left'},
           ],
           comments: [
-            {id: 'd1'},
-            {id: 'd2'},
+            {id: 'd1', __commentSide: 'right'},
+            {id: 'd2', __commentSide: 'right'},
           ],
         };
-        var diffDraftsStub = sinon.stub(element, '_getDiffDrafts',
+
+        var diffDraftsStub = sandbox.stub(element, '_getDiffDrafts',
             function() { return Promise.resolve(drafts); });
 
+        var robotComments = {
+          baseComments: [
+            {id: 'br1', __commentSide: 'left'},
+            {id: 'br2', __commentSide: 'left'},
+          ],
+          comments: [
+            {id: 'r1', __commentSide: 'right'},
+            {id: 'r2', __commentSide: 'right'},
+          ],
+        };
+
+        var diffRobotCommentStub = sandbox.stub(element,
+            '_getDiffRobotComments', function() {
+          return Promise.resolve(robotComments); });
+
         element.changeNum = '42';
         element.patchRange = {
           basePatchNum: 'PARENT',
@@ -400,21 +559,23 @@
               projectConfig: {foo: 'bar'},
             },
             left: [
-              {id: 'bc1'},
-              {id: 'bc2'},
-              {id: 'bd1', __draft: true},
-              {id: 'bd2', __draft: true},
+              {id: 'bc1', __commentSide: 'left'},
+              {id: 'bc2', __commentSide: 'left'},
+              {id: 'bd1', __draft: true, __commentSide: 'left'},
+              {id: 'bd2', __draft: true, __commentSide: 'left'},
+              {id: 'br1', __commentSide: 'left'},
+              {id: 'br2', __commentSide: 'left'},
             ],
             right: [
-              {id: 'c1'},
-              {id: 'c2'},
-              {id: 'd1', __draft: true},
-              {id: 'd2', __draft: true},
+              {id: 'c1', __commentSide: 'right'},
+              {id: 'c2', __commentSide: 'right'},
+              {id: 'd1', __draft: true, __commentSide: 'right'},
+              {id: 'd2', __draft: true, __commentSide: 'right'},
+              {id: 'r1', __commentSide: 'right'},
+              {id: 'r2', __commentSide: 'right'},
             ],
           });
 
-          diffCommentsStub.restore();
-          diffDraftsStub.restore();
           done();
         });
       });
@@ -433,22 +594,23 @@
               projectConfig: {foo: 'bar'},
             },
             left: [
-              {id: 'bc1', side: 'PARENT'},
-              {id: 'bc2', side: 'PARENT'},
-              {id: 'bd1', __draft: true, side: 'PARENT'},
-              {id: 'bd2', __draft: true, side: 'PARENT'},
+              {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+              {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+              {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+              {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
             ],
             right: [
-              {id: 'c1'},
-              {id: 'c2'},
-              {id: 'd1', __draft: true},
-              {id: 'd2', __draft: true},
+              {id: 'c1', __commentSide: 'right'},
+              {id: 'c2', __commentSide: 'right'},
+              {id: 'd1', __draft: true, __commentSide: 'right'},
+              {id: 'd2', __draft: true, __commentSide: 'right'},
             ],
           };
         });
 
         test('creating a draft', function() {
-          var comment = {__draft: true, __draftID: 'tempID', side: 'PARENT'};
+          var comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
+              __commentSide: 'left'};
           element.fire('comment-update', {comment: comment});
           assert.include(element._comments.left, comment);
         });
@@ -457,9 +619,11 @@
           var draftID = 'tempID';
           var id = 'savedID';
           element._comments.left.push(
-              {__draft: true, __draftID: draftID, side: 'PARENT'});
+              {__draft: true, __draftID: draftID, side: 'PARENT',
+              __commentSide: 'left'});
           element.fire('comment-update', {comment:
-              {id: id, __draft: true, __draftID: draftID, side: 'PARENT'},
+              {id: id, __draft: true, __draftID: draftID, side: 'PARENT',
+              __commentSide: 'left'},
           });
           var drafts = element._comments.left.filter(function(item) {
             return item.__draftID === draftID;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index c496703..66e6ee7 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -14,7 +14,9 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
 
 <dom-module id="gr-patch-range-select">
   <template>
@@ -25,35 +27,51 @@
       .patchRange {
         display: inline-block;
       }
+      select {
+        max-width: 15em;
+      }
+      @media screen and (max-width: 50em) {
+        .filesWeblinks {
+          display: none;
+        }
+        select {
+          max-width: 5.25em;
+        }
+      }
     </style>
     Patch set:
     <span class="patchRange">
-      <select id="leftPatchSelect" on-change="_handlePatchChange">
-        <option value="PARENT"
-            selected$="[[_computeLeftSelected('PARENT', patchRange)]]">Base</option>
+      <select id="leftPatchSelect" bind-value="{{_leftSelected}}"
+          on-change="_handlePatchChange" is="gr-select">
+        <option value="PARENT">Base</option>
         <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
           <option value$="[[patchNum]]"
-              selected$="[[_computeLeftSelected(patchNum, patchRange)]]"
-              disabled$="[[_computeLeftDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
+              disabled$="[[_computeLeftDisabled(patchNum, patchRange)]]">
+            [[patchNum]]
+            [[_computePatchSetDescription(revisions, patchNum)]]
+          </option>
         </template>
       </select>
     </span>
-    <span is="dom-if" if="[[filesWeblinks.meta_a]]">
+    <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
       <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
-        <a target="_blank"
+        <a target="_blank" rel="noopener"
            href$="[[weblink.url]]">[[weblink.name]]</a>
       </template>
     </span>
     &rarr;
     <span class="patchRange">
-      <select id="rightPatchSelect" on-change="_handlePatchChange">
+      <select id="rightPatchSelect" bind-value="{{_rightSelected}}"
+          on-change="_handlePatchChange" is="gr-select">
         <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
           <option value$="[[patchNum]]"
-              selected$="[[_computeRightSelected(patchNum, patchRange)]]"
-              disabled$="[[_computeRightDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
+              disabled$="[[_computeRightDisabled(patchNum, patchRange)]]">
+            [[patchNum]]
+            [[_computePatchSetDescription(revisions, patchNum)]]
+          </option>
         </template>
       </select>
-      <span is="dom-if" if="[[filesWeblinks.meta_b]]">
+      <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
         <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
           <a target="_blank"
              href$="[[weblink.url]]">[[weblink.name]]</a>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 24d36c4..58d29bd 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -14,6 +14,9 @@
 (function() {
   'use strict';
 
+  // Maximum length for patch set descriptions.
+  var PATCH_DESC_MAX_LENGTH = 500;
+
   Polymer({
     is: 'gr-patch-range-select',
 
@@ -21,26 +24,32 @@
       availablePatches: Array,
       changeNum: String,
       filesWeblinks: Object,
-      patchRange: Object,
       path: String,
+      patchRange: {
+        type: Object,
+        observer: '_updateSelected',
+      },
+      revisions: Object,
+      _rightSelected: String,
+      _leftSelected: String,
+    },
+
+    behaviors: [Gerrit.PatchSetBehavior],
+
+    _updateSelected: function() {
+      this._rightSelected = this.patchRange.patchNum;
+      this._leftSelected = this.patchRange.basePatchNum;
     },
 
     _handlePatchChange: function(e) {
-      var leftPatch = this.$.leftPatchSelect.value;
-      var rightPatch = this.$.rightPatchSelect.value;
+      var leftPatch = this._leftSelected;
+      var rightPatch = this._rightSelected;
       var rangeStr = rightPatch;
       if (leftPatch != 'PARENT') {
         rangeStr = leftPatch + '..' + rangeStr;
       }
       page.show('/c/' + this.changeNum + '/' + rangeStr + '/' + this.path);
-    },
-
-    _computeLeftSelected: function(patchNum, patchRange) {
-      return patchNum == patchRange.basePatchNum;
-    },
-
-    _computeRightSelected: function(patchNum, patchRange) {
-      return patchNum == patchRange.patchNum;
+      e.target.blur();
     },
 
     _computeLeftDisabled: function(patchNum, patchRange) {
@@ -51,5 +60,24 @@
       if (patchRange.basePatchNum == 'PARENT') { return false; }
       return parseInt(patchNum, 10) <= parseInt(patchRange.basePatchNum, 10);
     },
+
+    // On page load, the dom-if for options getting added occurs after
+    // the value was set in the select. This ensures that after they
+    // are loaded, the correct value will get selected.  I attempted to
+    // debounce these, but because they are detecting two different
+    // events, sometimes the timing was off and one ended up missing.
+    _synchronizeSelectionRight: function() {
+      this.$.rightPatchSelect.value = this._rightSelected;
+    },
+
+    _synchronizeSelectionLeft: function() {
+      this.$.leftPatchSelect.value = this._leftSelected;
+    },
+
+    _computePatchSetDescription: function(revisions, patchNum) {
+      var rev = this.getRevisionByPatchNum(revisions, patchNum);
+      return (rev && rev.description) ?
+          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index c7e1196..00d73bf 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -18,13 +18,15 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-patch-range-select</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../../../bower_components/page/page.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-patch-range-select.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-patch-range-select auto></gr-patch-range-select>
@@ -63,9 +65,14 @@
       var showStub = sinon.stub(page, 'show');
       var leftSelectEl = element.$.leftPatchSelect;
       var rightSelectEl = element.$.rightPatchSelect;
+      var blurSpy = sinon.spy(leftSelectEl, 'blur');
       element.changeNum = '42';
       element.path = 'path/to/file.txt';
       element.availablePatches = ['1', '2', '3'];
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
       flushAsynchronousOperations();
 
       var numEvents = 0;
@@ -77,6 +84,7 @@
               'Should navigate to /c/42/3/path/to/file.txt');
           leftSelectEl.value = '1';
           element.fire('change', {}, {node: leftSelectEl});
+          assert(blurSpy.called, 'Dropdown should be blurred after selection');
         } else if (numEvents == 2) {
           assert(showStub.lastCall.calledWithExactly(
               '/c/42/1..3/path/to/file.txt'),
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
index 113e37f..ba6973b 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
@@ -15,7 +15,11 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <dom-module id="gr-ranged-comment-layer">
+  <template>
+    <gr-reporting id="reporting" category="comments"></gr-reporting>
+  </template>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
   <script src="gr-ranged-comment-layer.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 7496e59..1425a79 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -20,6 +20,8 @@
   var RANGE_HIGHLIGHT = 'range';
   var HOVER_HIGHLIGHT = 'rangeHighlight';
 
+  var NORMALIZE_RANGE_EVENT = 'normalize-range';
+
   Polymer({
     is: 'gr-ranged-comment-layer',
 
@@ -32,7 +34,7 @@
       _commentMap: {
         type: Object,
         value: function() { return {left: [], right: []}; },
-      }
+      },
     },
 
     observers: [
@@ -170,12 +172,24 @@
       var ranges = this.get(['_commentMap', side, lineNum]) || [];
       return ranges
           .map(function(range) {
-            return {
+            range = {
               start: range.start,
               end: range.end === -1 ? line.text.length : range.end,
               hovering: !!range.comment.__hovering,
             };
-          })
+
+            // Normalize invalid ranges where the start is after the end but the
+            // start still makes sense. Set the end to the end of the line.
+            // @see Issue 5744
+            if (range.start >= range.end && range.start < line.text.length) {
+              range.end = line.text.length;
+              this.$.reporting.reportInteraction(NORMALIZE_RANGE_EVENT,
+                  'Modified invalid comment range on l.' + lineNum +
+                  ' of the ' + side + ' side');
+            }
+
+            return range;
+          }.bind(this))
           .sort(function(a, b) {
             // Sort the ranges so that hovering highlights are on top.
             return a.hovering && !b.hovering ? 1 : 0;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index 68b7528..20fba4d 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -18,13 +18,15 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ranged-comment-layer</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../gr-diff/gr-diff-line.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-ranged-comment-layer.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-ranged-comment-layer></gr-ranged-comment-layer>
@@ -76,6 +78,16 @@
               start_character: 5,
               start_line: 100,
             },
+          }, {
+            id: '8675309',
+            line: 55,
+            message: 'nonsense range',
+            range: {
+              end_character: 2,
+              end_line: 55,
+              start_character: 32,
+              start_line: 55,
+            },
           },
         ],
       };
@@ -173,9 +185,6 @@
         line.beforeNumber = 36;
         el.setAttribute('data-side', 'right');
 
-        var expectedStart = 6;
-        var expectedLength = line.text.length - expectedStart;
-
         element.annotate(el, line);
 
         assert.isFalse(annotateElementStub.called);
@@ -304,7 +313,7 @@
       // on line 100.
       var rightKeys = [];
       for (i = 10; i <= 12; i++) { rightKeys.push('' + i); }
-      rightKeys.push('100');
+      rightKeys.push('55', '100');
       assert.deepEqual(Object.keys(element._commentMap.right).sort(),
           rightKeys.sort());
 
@@ -324,5 +333,17 @@
       assert.equal(element._commentMap.right[100][0].start, 5);
       assert.equal(element._commentMap.right[100][0].end, 15);
     });
+
+    test('_getRangesForLine normalizes invalid ranges', function() {
+      var line = {
+        afterNumber: 55,
+        text: '_getRangesForLine normalizes invalid ranges'
+      };
+      var ranges = element._getRangesForLine(line, 'right');
+      assert.equal(ranges.length, 1);
+      var range = ranges[0];
+      assert.isTrue(range.start < range.end, 'start and end are normalized');
+      assert.equal(range.end, line.text.length);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 9a8ea37..5f74f1f 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -15,32 +15,31 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 
 <dom-module id="gr-selection-action-box">
   <template>
     <style>
       :host {
-        --gr-arrow-size: .6em;
+        --gr-arrow-size: .65em;
 
-        background-color: #fff;
-        border: 1px solid #000;
-        border-radius: .5em;
+        background-color: rgba(22, 22, 22, .9);
+        border-radius: 3px;
+        color: #fff;
         cursor: pointer;
-        padding: .3em;
+        font-family: var(--font-family);
+        padding: .5em .75em;
         position: absolute;
         white-space: nowrap;
       }
       .arrow {
-        background: #fff;
-        border: var(--gr-arrow-size) solid #000;
-        border-width: 0 1px 1px 0;
-        height: var(--gr-arrow-size);
-        left: calc(50% - 1em);
-        margin-top: .05em;
+        border: var(--gr-arrow-size) solid transparent;
+        border-top: var(--gr-arrow-size) solid rgba(22, 22, 22, 0.9);
+        height: 0;
+        left: calc(50% - var(--gr-arrow-size));
+        margin-top: .5em;
         position: absolute;
-        transform: rotate(45deg);
-        width: var(--gr-arrow-size);
+        width: 0;
       }
     </style>
     Press <strong>C</strong> to comment.
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index d565a12..0f7f2f2 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -48,7 +48,11 @@
     ],
 
     listeners: {
-      'tap': '_handleTap',
+      'mousedown': '_handleMouseDown', // See https://crbug.com/gerrit/4767
+    },
+
+    keyBindings: {
+      'c': '_handleCKey',
     },
 
     placeAbove: function(el) {
@@ -74,15 +78,17 @@
       return rect;
     },
 
-    _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
-      if (e.keyCode === 67) { // 'c'
-        e.preventDefault();
-        this._fireCreateComment();
-      }
+    _handleCKey: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._fireCreateComment();
     },
 
-    _handleTap: function() {
+    _handleMouseDown: function(e) {
+      e.preventDefault();
+      e.stopPropagation();
       this._fireCreateComment();
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index adc8532..e9ac0a5 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-selection-action-box</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-selection-action-box.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <div>
@@ -49,12 +51,12 @@
     });
 
     test('ignores regular keys', function() {
-      MockInteractions.pressAndReleaseKeyOn(document.body, 27); // 'esc'
+      MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
       assert.isFalse(element.fire.called);
     });
 
     test('reacts to hotkey', function() {
-      MockInteractions.pressAndReleaseKeyOn(document.body, 67); // 'c'
+      MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
       assert.isTrue(element.fire.called);
     });
 
@@ -68,7 +70,7 @@
       };
       element.side = 'left';
       element.range = range;
-      MockInteractions.pressAndReleaseKeyOn(document.body, 67); // 'c'
+      MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
       assert(element.fire.calledWithExactly(
           'create-comment',
           {
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
index c5c9377..9c5d6bf 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.html
@@ -14,7 +14,12 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-syntax-lib-loader/gr-syntax-lib-loader.html">
+
 <dom-module id="gr-syntax-layer">
+  <template>
+    <gr-syntax-lib-loader id="libLoader"></gr-syntax-lib-loader>
+  </template>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
   <script src="gr-syntax-layer.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index 478bcc8..7dea848 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -18,6 +18,7 @@
     'application/dart': 'dart',
     'application/json': 'json',
     'application/typescript': 'typescript',
+    'application/x-erb': 'erb',
     'text/css': 'css',
     'text/html': 'html',
     'text/javascript': 'js',
@@ -31,23 +32,26 @@
     'text/x-go': 'go',
     'text/x-haskell': 'haskell',
     'text/x-java': 'java',
+    'text/x-kotlin': 'kotlin',
     'text/x-lua': 'lua',
     'text/x-markdown': 'markdown',
     'text/x-objectivec': 'objectivec',
     'text/x-ocaml': 'ocaml',
     'text/x-perl': 'perl',
+    'text/x-php': 'php',
     'text/x-protobuf': 'protobuf',
+    'text/x-puppet': 'puppet',
     'text/x-python': 'python',
     'text/x-ruby': 'ruby',
     'text/x-rustsrc': 'rust',
     'text/x-scala': 'scala',
+    'text/x-shell': 'shell',
     'text/x-sh': 'bash',
     'text/x-sql': 'sql',
     'text/x-swift': 'swift',
     'text/x-yaml': 'yaml',
   };
   var ASYNC_DELAY = 10;
-  var HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
 
   var CLASS_WHITELIST = {
     'gr-diff gr-syntax gr-syntax-literal': true,
@@ -65,7 +69,6 @@
     'gr-diff gr-syntax gr-syntax-string': true,
     'gr-diff gr-syntax gr-syntax-selector-id': true,
     'gr-diff gr-syntax gr-syntax-title': true,
-    'gr-diff gr-syntax gr-syntax-params': true,
     'gr-diff gr-syntax gr-syntax-comment': true,
     'gr-diff gr-syntax gr-syntax-meta': true,
     'gr-diff gr-syntax gr-syntax-meta-keyword': true,
@@ -79,6 +82,12 @@
     'gr-diff gr-syntax gr-syntax-selector-class': true,
   };
 
+  var CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
+  var CPP_WCHAR_PATTERN = /L\'.\'/g;
+  var JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
+  var GO_BACKSLASH_LITERAL = '\'\\\\\'';
+  var GLOBAL_LT_PATTERN = /</g;
+
   Polymer({
     is: 'gr-syntax-layer',
 
@@ -106,6 +115,7 @@
         value: function() { return []; },
       },
       _processHandle: Number,
+      _hljs: Object,
     },
 
     addListener: function(fn) {
@@ -205,7 +215,7 @@
               return;
             }
 
-            if (state.sectionIndex !== 0 && state.lineIndex % 100 === 0) {
+            if (state.lineIndex % 100 === 0) {
               this._notify(state);
               this._processHandle = this.async(nextStep, ASYNC_DELAY);
             } else {
@@ -254,13 +264,14 @@
         var nodeLength = GrAnnotation.getLength(node);
         // Note: HLJS may emit a span with class undefined when it thinks there
         // may be a syntax error.
-        if (node.tagName === 'SPAN' && node.className !== 'undefined' &&
-            CLASS_WHITELIST.hasOwnProperty(node.className)) {
-          result.push({
-            start: offset,
-            length: nodeLength,
-            className: node.className,
-          });
+        if (node.tagName === 'SPAN' && node.className !== 'undefined') {
+          if (CLASS_WHITELIST.hasOwnProperty(node.className)) {
+            result.push({
+              start: offset,
+              length: nodeLength,
+              className: node.className,
+            });
+          }
           if (node.children.length) {
             result = result.concat(this._rangesFromElement(node, offset));
           }
@@ -276,9 +287,8 @@
      * @param {!Object} state The processing state for the layer.
      */
     _processNextLine: function(state) {
-      var baseLine = undefined;
-      var revisionLine = undefined;
-      var hljs = this._getHighlightLib();
+      var baseLine;
+      var revisionLine;
 
       var section = this.diff.content[state.sectionIndex];
       if (section.ab) {
@@ -301,21 +311,90 @@
       var result;
 
       if (this._baseLanguage && baseLine !== undefined) {
-        result = hljs.highlight(this._baseLanguage, baseLine, true,
+        baseLine = this._workaround(this._baseLanguage, baseLine);
+        result = this._hljs.highlight(this._baseLanguage, baseLine, true,
             state.baseContext);
         this.push('_baseRanges', this._rangesFromString(result.value));
         state.baseContext = result.top;
       }
 
       if (this._revisionLanguage && revisionLine !== undefined) {
-        result = hljs.highlight(this._revisionLanguage, revisionLine, true,
-            state.revisionContext);
+        revisionLine = this._workaround(this._revisionLanguage, revisionLine);
+        result = this._hljs.highlight(this._revisionLanguage, revisionLine,
+            true, state.revisionContext);
         this.push('_revisionRanges', this._rangesFromString(result.value));
         state.revisionContext = result.top;
       }
     },
 
     /**
+     * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
+     * cases before sending them into HLJS so that they parse correctly.
+     *
+     * Important notes:
+     * * These tests should be as constrained as possible to avoid interfering
+     *   with code it shouldn't AND to avoid executing regexes as much as
+     *   possible.
+     * * These tests should document the issue clearly enough that the test can
+     *   be condidently removed when the issue is solved in HLJS.
+     * * These tests should rewrite the line of code to have the same number of
+     *   characters. This method rewrites the string that gets parsed, but NOT
+     *   the string that gets displayed and highlighted. Thus, the positions
+     *   must be consistent.
+     *
+     * @param {!string} language The name of the HLJS language plugin in use.
+     * @param {!string} line The line of code to potentially rewrite.
+     * @return {string} A potentially-rewritten line of code.
+     */
+    _workaround: function(language, line) {
+      if (language === 'cpp') {
+        /**
+         * Prevent confusing < and << operators for the start of a meta string
+         * by converting them to a different operator.
+         * {@see Issue 4864}
+         * {@see https://github.com/isagalaev/highlight.js/issues/1341}
+         */
+        if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
+          line = line.replace(GLOBAL_LT_PATTERN, '|');
+        }
+
+        /**
+         * Rewrite CPP wchar_t characters literals to wchar_t string literals
+         * because HLJS only understands the string form.
+         * {@see Issue 5242}
+         * {#see https://github.com/isagalaev/highlight.js/issues/1412}
+         */
+        if (CPP_WCHAR_PATTERN.test(line)) {
+          line = line.replace(CPP_WCHAR_PATTERN, 'L"."');
+        }
+
+        return line;
+      }
+
+      /**
+       * Prevent confusing the closing paren of a parameterized Java annotation
+       * being applied to a formal argument as the closing paren of the argument
+       * list. Rewrite the parens as spaces.
+       * {@see Issue 4776}
+       * {@see https://github.com/isagalaev/highlight.js/issues/1324}
+       */
+      if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
+        return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
+      }
+
+      /**
+       * HLJS misunderstands backslash character literals in Go.
+       * {@see Issue 5007}
+       * {#see https://github.com/isagalaev/highlight.js/issues/1411}
+       */
+      if (language === 'go' && line.indexOf(GO_BACKSLASH_LITERAL) !== -1) {
+        return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
+      }
+
+      return line;
+    },
+
+    /**
      * Tells whether the state has exhausted its current section.
      * @param {!Object} state
      * @return {boolean}
@@ -358,45 +437,10 @@
       });
     },
 
-    _getHighlightLib: function() {
-      return window.hljs;
-    },
-
-    _isHighlightLibLoaded: function() {
-      return !!this._getHighlightLib();
-    },
-
-    _configureHighlightLib: function() {
-      this._getHighlightLib().configure(
-          {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
-    },
-
-    _getLibRoot: function() {
-      if (this._cachedLibRoot) { return this._cachedLibRoot; }
-
-      return this._cachedLibRoot = document.head
-          .querySelector('link[rel=import][href$="gr-app.html"]')
-          .href
-          .match(/(.+\/)elements\/gr-app\.html/)[1];
-    },
-    _cachedLibRoot: null,
-
-    /**
-     * Load and configure the HighlightJS library. If the library is already
-     * loaded, then do nothing and resolve.
-     * @return {Promise}
-     */
     _loadHLJS: function() {
-      if (this._isHighlightLibLoaded()) { return Promise.resolve(); }
-      return new Promise(function(resolve) {
-        var script = document.createElement('script');
-        script.src = this._getLibRoot() + HLJS_PATH;
-        script.onload = function() {
-          this._configureHighlightLib();
-          resolve();
-        }.bind(this);
-        Polymer.dom(this.root).appendChild(script);
+      return this.$.libLoader.get().then(function(hljs) {
+        this._hljs = hljs;
       }.bind(this));
-    }
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index 5106671..eaa8d29 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-syntax-layer</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-syntax-layer.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-syntax-layer></gr-syntax-layer>
@@ -182,8 +184,8 @@
 
       var mockHLJS = getMockHLJS();
       var highlightSpy = sinon.spy(mockHLJS, 'highlight');
-      sandbox.stub(element, '_getHighlightLib',
-          function() { return mockHLJS; });
+      sandbox.stub(element.$.libLoader, 'get',
+          function() { return Promise.resolve(mockHLJS); });
       var processNextSpy = sandbox.spy(element, '_processNextLine');
       var processPromise = element.process();
 
@@ -370,6 +372,15 @@
       assert.equal(result[1].className, className);
     });
 
+    test('_rangesFromString whitelist allows recursion', function() {
+      var str = [
+          '<span class="non-whtelisted-class">',
+            '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
+          '</span>'].join('');
+      var result = element._rangesFromString(str);
+      assert.notEqual(result.length, 0);
+    });
+
     test('_isSectionDone', function() {
       var state = {sectionIndex: 0, lineIndex: 0};
       assert.isFalse(element._isSectionDone(state));
@@ -395,5 +406,71 @@
       state = {sectionIndex: 3, lineIndex: 4};
       assert.isTrue(element._isSectionDone(state));
     });
+
+    test('workaround CPP LT directive', function() {
+      // Does nothing to regular line.
+      var line = 'int main(int argc, char** argv) { return 0; }';
+      assert.equal(element._workaround('cpp', line), line);
+
+      // Does nothing to include directive.
+      line = '#include <stdio>';
+      assert.equal(element._workaround('cpp', line), line);
+
+      // Converts left-shift operator in #define.
+      line = '#define GiB (1ull << 30)';
+      var expected = '#define GiB (1ull || 30)';
+      assert.equal(element._workaround('cpp', line), expected);
+
+      // Converts less-than operator in #if.
+      line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
+      expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
+      assert.equal(element._workaround('cpp', line), expected);
+    });
+
+    test('workaround Java param-annotation', function() {
+      // Does nothing to regular line.
+      var line = 'public static void foo(int bar) { }';
+      assert.equal(element._workaround('java', line), line);
+
+      // Does nothing to regular annotation.
+      line = 'public static void foo(@Nullable int bar) { }';
+      assert.equal(element._workaround('java', line), line);
+
+      // Converts parameterized annotation.
+      line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
+      var expected = 'public static void foo(@SuppressWarnings "unused" ' +
+          ' int bar) { }';
+      assert.equal(element._workaround('java', line), expected);
+    });
+
+    test('workaround CPP whcar_t character literals', function() {
+      // Does nothing to regular line.
+      var line = 'int main(int argc, char** argv) { return 0; }';
+      assert.equal(element._workaround('cpp', line), line);
+
+      // Does nothing to wchar_t string.
+      line = 'wchar_t* sz = L"abc 123";';
+      assert.equal(element._workaround('cpp', line), line);
+
+      // Converts wchar_t character literal to string.
+      line = 'wchar_t myChar = L\'#\'';
+      var expected = 'wchar_t myChar = L"."';
+      assert.equal(element._workaround('cpp', line), expected);
+    });
+
+    test('workaround go backslash character literals', function() {
+      // Does nothing to regular line.
+      var line = 'func foo(in []byte) (lit []byte, n int, err error) {';
+      assert.equal(element._workaround('go', line), line);
+
+      // Does nothing to string with backslash literal
+      line = 'c := "\\\\"';
+      assert.equal(element._workaround('go', line), line);
+
+      // Converts backslash literal character to a string.
+      line = 'c := \'\\\\\'';
+      var expected = 'c := "\\\\"';
+      assert.equal(element._workaround('go', line), expected);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.html b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.html
new file mode 100644
index 0000000..fedd22a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.html
@@ -0,0 +1,20 @@
+<!--
+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-syntax-lib-loader">
+  <script src="gr-syntax-lib-loader.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
new file mode 100644
index 0000000..520f24d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
@@ -0,0 +1,93 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+  var LIB_ROOT_PATTERN = /(.+\/)elements\/gr-app\.html/;
+
+  Polymer({
+    is: 'gr-syntax-lib-loader',
+
+    properties: {
+      _state: {
+        type: Object,
+
+        // NOTE: intended singleton.
+        value: {
+          loaded: false,
+          loading: false,
+          callbacks: [],
+        },
+      }
+    },
+
+    get: function() {
+      return new Promise(function(resolve) {
+        // If the lib is totally loaded, resolve immediately.
+        if (this._state.loaded) {
+          resolve(this._getHighlightLib());
+          return;
+        }
+
+        // If the library is not currently being loaded, then start loading it.
+        if (!this._state.loading) {
+          this._state.loading = true;
+          this._loadHLJS().then(this._onLibLoaded.bind(this));
+        }
+
+        this._state.callbacks.push(resolve);
+      }.bind(this));
+    },
+
+    _onLibLoaded: function() {
+      var lib = this._getHighlightLib();
+      this._state.loaded = true;
+      this._state.loading = false;
+      this._state.callbacks.forEach(function(cb) { cb(lib); });
+      this._state.callbacks = [];
+    },
+
+    _getHighlightLib: function() {
+      return window.hljs;
+    },
+
+    _configureHighlightLib: function() {
+      this._getHighlightLib().configure(
+          {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+    },
+
+    _getLibRoot: function() {
+      if (this._cachedLibRoot) { return this._cachedLibRoot; }
+
+      return this._cachedLibRoot = document.head
+          .querySelector('link[rel=import][href$="gr-app.html"]')
+          .href
+          .match(LIB_ROOT_PATTERN)[1];
+    },
+    _cachedLibRoot: null,
+
+    _loadHLJS: function() {
+      return new Promise(function(resolve) {
+        var script = document.createElement('script');
+        script.src = this._getLibRoot() + HLJS_PATH;
+        script.onload = function() {
+          this._configureHighlightLib();
+          resolve();
+        }.bind(this);
+        Polymer.dom(document.head).appendChild(script);
+      }.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
new file mode 100644
index 0000000..985fc6d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
@@ -0,0 +1,97 @@
+<!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-syntax-lib-loader</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-syntax-lib-loader.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-syntax-lib-loader></gr-syntax-lib-loader>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-syntax-lib-loader tests', function() {
+    var element;
+    var resolveLoad;
+    var loadStub;
+
+    setup(function() {
+      element = fixture('basic');
+
+      loadStub = sinon.stub(element, '_loadHLJS', function() {
+        return new Promise(function(resolve) {
+          resolveLoad = resolve;
+        });
+      });
+
+      // Assert preconditions:
+      assert.isFalse(element._state.loaded);
+      assert.isFalse(element._state.loading);
+    });
+
+    teardown(function() {
+      if (window.hljs) {
+        delete window.hljs;
+      }
+      loadStub.restore();
+
+      // Because the element state is a singleton, clean it up.
+      element._state.loading = false;
+      element._state.loaded = false;
+      element._state.callbacks = [];
+    });
+
+    test('only load once', function(done) {
+      var firstCallHandler = sinon.stub();
+      element.get().then(firstCallHandler);
+
+      // It should now be in the loading state.
+      assert.isTrue(loadStub.called);
+      assert.isTrue(element._state.loading);
+      assert.isFalse(element._state.loaded);
+      assert.isFalse(firstCallHandler.called);
+
+      var secondCallHandler = sinon.stub();
+      element.get().then(secondCallHandler);
+
+      // No change in state.
+      assert.isTrue(element._state.loading);
+      assert.isFalse(element._state.loaded);
+      assert.isFalse(firstCallHandler.called);
+      assert.isFalse(secondCallHandler.called);
+
+      // Now load the library.
+      resolveLoad();
+      flush(function() {
+        // The state should be loaded and both handlers called.
+        assert.isFalse(element._state.loading);
+        assert.isTrue(element._state.loaded);
+        assert.isTrue(firstCallHandler.called);
+        assert.isTrue(secondCallHandler.called);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
index e2abc52..fabc347 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
@@ -19,66 +19,62 @@
       /**
        * @overview Highlight.js emits the following classes that do not have
        * styles here:
-       *    subst, symbol, class, function, doctag, meta-string, section,
-       *    builtin-name, bulletm, code, formula, quote, addition, deletion
+       *    subst, symbol, class, function, doctag, meta-string, section, name,
+       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
+       *    attribute
        * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
        */
 
-      .gr-syntax-literal,
-      .gr-syntax-keyword,
-      .gr-syntax-selector-tag {
+      .gr-syntax-meta {
+         color: #FF1717;
+      }
+      .gr-syntax-keyword {
+        color: #7F0055;
         font-weight: bold;
-        color: #00f;
+        line-height: 1em;
       }
-      .gr-syntax-built_in {
-        color: #555;
+      .gr-syntax-number,
+      .gr-syntax-selector-class {
+        color: #164;
       }
-      .gr-syntax-type,
-      .gr-syntax-selector-pseudo,
+      .gr-syntax-variable {
+        color: black;
+      }
       .gr-syntax-template-variable {
-        color: #ff00e7;
+        color: #0000C0;
       }
-      .gr-syntax-number {
-        color: violet;
-      }
-      .gr-syntax-regexp,
-      .gr-syntax-variable,
-      .gr-syntax-selector-attr,
-      .gr-syntax-template-tag {
-        color: #FA8602;
+      .gr-syntax-comment {
+        color: #3F7F5F;
       }
       .gr-syntax-string,
       .gr-syntax-selector-id {
-        color: #018846;
+        color: #2A00FF;
       }
-      .gr-syntax-title {
-        color: teal;
-      }
-      .gr-syntax-params {
-        color: red;
-      }
-      .gr-syntax-comment {
-        color: #af72a9;
-        font-style: italic;
-      }
-      .gr-syntax-meta {
-        color: #0091AD;
-      }
-      .gr-syntax-meta-keyword {
-        color: #00426b;
-        font-weight: bold;
+      .gr-syntax-built_in {
+        color: #30a;
       }
       .gr-syntax-tag {
-        color: #DB7C00;
+        color: #170;
       }
-      .gr-syntax-name { /* XML/HTML Tag Name */
-        color: brown;
+      .gr-syntax-link,
+      .gr-syntax-meta-keyword {
+        color: #219;
       }
-      .gr-syntax-attr { /* XML/HTML Attribute */
-        color: #8C7250;
+      .gr-syntax-type {
+        color: #00f;
       }
-      .gr-syntax-attribute { /* CSS Property */
-        color: #299596;
+      .gr-syntax-title {
+        color: #0000C0;
+      }
+      .gr-syntax-attr,
+      .gr-syntax-literal { /* XML/HTML Attribute */
+        color: #219;
+      }
+      .gr-syntax-selector-pseudo,
+      .gr-syntax-regexp,
+      .gr-syntax-selector-attr,
+      .gr-syntax-template-tag {
+        color: #FA8602;
       }
       .gr-syntax-emphasis {
         font-style: italic;
@@ -86,12 +82,6 @@
       .gr-syntax-strong {
         font-weight: bold;
       }
-      .gr-syntax-link {
-        color: blue;
-      }
-      .gr-syntax-selector-class {
-        color: #1F71FF;
-      }
     </style>
   </template>
 </dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index c20795b..c92610bf 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -14,19 +14,25 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../bower_components/polymer/polymer.html">
-<link rel="import" href="../behaviors/keyboard-shortcut-behavior.html">
 <link rel="import" href="../styles/app-theme.html">
 
+<link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
+
 <link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
 <link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
 <link rel="import" href="./core/gr-main-header/gr-main-header.html">
 <link rel="import" href="./core/gr-router/gr-router.html">
+<link rel="import" href="./core/gr-reporting/gr-reporting.html">
 
 <link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
 <link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
 <link rel="import" href="./change/gr-change-view/gr-change-view.html">
 <link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
+<link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
 <link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
 
 <link rel="import" href="./shared/gr-overlay/gr-overlay.html">
@@ -45,13 +51,16 @@
       gr-main-header,
       footer {
         color: var(--primary-text-color);
-        padding: .5rem var(--default-horizontal-margin);
       }
       gr-main-header {
         background-color: var(--header-background-color, #eee);
+        padding: 0 var(--default-horizontal-margin);
       }
       footer {
         background-color: var(--footer-background-color, #eee);
+        display: flex;
+        justify-content: space-between;
+        padding: .5rem var(--default-horizontal-margin);
       }
       main {
         flex: 1;
@@ -85,7 +94,8 @@
         color: #b71c1c;
       }
     </style>
-    <gr-main-header search-query="{{params.query}}"></gr-main-header>
+    <gr-main-header id="mainHeader" search-query="{{params.query}}">
+    </gr-main-header>
     <main>
       <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
         <gr-change-list-view
@@ -103,7 +113,8 @@
         <gr-change-view
             params="[[params]]"
             server-config="[[_serverConfig]]"
-            view-state="{{_viewState.changeView}}"></gr-change-view>
+            view-state="{{_viewState.changeView}}"
+            back-page="[[_lastSearchPage]]"></gr-change-view>
       </template>
       <template is="dom-if" if="[[_showDiffView]]" restamp="true">
         <gr-diff-view
@@ -111,7 +122,16 @@
             change-view-state="{{_viewState.changeView}}"></gr-diff-view>
       </template>
       <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-        <gr-settings-view></gr-settings-view>
+        <gr-settings-view
+            params="[[params]]"
+            on-account-detail-update="_handleAccountDetailUpdate">
+        </gr-settings-view>
+      </template>
+      <template is="dom-if" if="[[_showAdminView]]" restamp="true">
+        <gr-admin-view path="[[_path]]"></gr-admin-view>
+      </template>
+      <template is="dom-if" if="[[_showCLAView]]" restamp="true">
+        <gr-cla-view path="[[_path]]"></gr-cla-view>
       </template>
       <div id="errorView" class="errorView" hidden>
         <div class="errorEmoji">[[_lastError.emoji]]</div>
@@ -120,22 +140,37 @@
       </div>
     </main>
     <footer role="contentinfo">
-      Powered by <a href="https://www.gerritcodereview.com/" target="_blank">Gerrit Code Review</a>
-      ([[_version]])
-      |
-      <a class="feedback"
-          href="https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue"
-          target="_blank">
-        Report PolyGerrit Bug
-      </a>
+      <div>
+        Powered by <a href="https://www.gerritcodereview.com/" rel="noopener"
+        target="_blank">Gerrit Code Review</a>
+        ([[_version]])
+      </div>
+      <div>
+        <a class="feedback"
+            href="https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue"
+            rel="noopener" target="_blank">Send feedback</a>
+        <template is="dom-if" if="[[_computeShowGwtUiLink(_serverConfig)]]">
+          |
+          <a id="gwtLink" href$="[[computeGwtUrl(_path)]]" rel="external">Old UI</a>
+        </template>
+        | Press &ldquo;?&rdquo; for keyboard shortcuts
+      </div>
     </footer>
     <gr-overlay id="keyboardShortcuts" with-backdrop>
       <gr-keyboard-shortcuts-dialog
           view="[[params.view]]"
           on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
     </gr-overlay>
-    <gr-error-manager></gr-error-manager>
+    <gr-overlay id="registration" with-backdrop>
+      <gr-registration-dialog
+          on-account-detail-update="_handleAccountDetailUpdate"
+          on-close="_handleRegistrationDialogClose">
+      </gr-registration-dialog>
+    </gr-overlay>
+    <gr-error-manager id="errorManager"></gr-error-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-reporting id="reporting"></gr-reporting>
+    <gr-router id="router"></gr-router>
   </template>
-  <script src="gr-app.js"></script>
+  <script src="gr-app.js" crossorigin="anonymous"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 0833a72..d820bc7 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -14,6 +14,13 @@
 (function() {
   'use strict';
 
+  // Eagerly render Polymer components when backgrounded. (Skips
+  // requestAnimationFrame.)
+  // @see https://github.com/Polymer/polymer/issues/3851
+  // TODO: Reassess after Polymer 2.0 upgrade.
+  // @see Issue 4699
+  Polymer.RenderStatus._makeReady();
+
   Polymer({
     is: 'gr-app',
 
@@ -43,11 +50,14 @@
       _showSettingsView: Boolean,
       _viewState: Object,
       _lastError: Object,
+      _lastSearchPage: String,
+      _path: String,
     },
 
     listeners: {
       'page-error': '_handlePageError',
       'title-change': '_handleTitleChange',
+      'location-change': '_handleLocationChange',
     },
 
     observers: [
@@ -56,10 +66,17 @@
     ],
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
-    attached: function() {
+    keyBindings: {
+      '?': '_showKeyboardShortcuts',
+    },
+
+    ready: function() {
+      this.$.router.start();
+
       this.$.restAPI.getAccount().then(function(account) {
         this._account = account;
       }.bind(this));
@@ -69,9 +86,8 @@
       this.$.restAPI.getVersion().then(function(version) {
         this._version = version;
       }.bind(this));
-    },
 
-    ready: function() {
+      this.$.reporting.appStarted();
       this._viewState = {
         changeView: {
           changeNum: null,
@@ -92,9 +108,13 @@
     },
 
     _accountChanged: function(account) {
+      if (!account) { return; }
+
       // Preferences are cached when a user is logged in; warm them.
       this.$.restAPI.getPreferences();
       this.$.restAPI.getDiffPreferences();
+      this.$.errorManager.knownAccountId =
+        this._account && this._account._account_id || null;
     },
 
     _viewChanged: function(view) {
@@ -104,13 +124,20 @@
       this.set('_showChangeView', view === 'gr-change-view');
       this.set('_showDiffView', view === 'gr-diff-view');
       this.set('_showSettingsView', view === 'gr-settings-view');
+      this.set('_showAdminView', view === 'gr-admin-view');
+      this.set('_showCLAView', view === 'gr-cla-view');
+      if (this.params.justRegistered) {
+        this.$.registration.open();
+      }
     },
 
     _loadPlugins: function(plugins) {
+      Gerrit._setPluginsCount(plugins.length);
       for (var i = 0; i < plugins.length; i++) {
         var scriptEl = document.createElement('script');
         scriptEl.defer = true;
         scriptEl.src = '/' + plugins[i];
+        scriptEl.onerror = Gerrit._pluginInstalled;
         document.body.appendChild(scriptEl);
       }
     },
@@ -126,6 +153,11 @@
       return !!(account && Object.keys(account).length > 0);
     },
 
+    _computeShowGwtUiLink: function(config) {
+      return config.gerrit.web_uis &&
+          config.gerrit.web_uis.indexOf('GWT') !== -1;
+    },
+
     _handlePageError: function(e) {
       [
         '_showChangeListView',
@@ -152,6 +184,26 @@
       }
     },
 
+    _handleLocationChange: function(e) {
+      var hash = e.detail.hash.substring(1);
+      var pathname = e.detail.pathname;
+      if (pathname.indexOf('/c/') === 0 && parseInt(hash, 10) > 0) {
+        pathname += '@' + hash;
+      }
+      this.set('_path', pathname);
+      this._handleSearchPageChange();
+    },
+
+    _handleSearchPageChange: function() {
+      if (!this.params) {
+        return;
+      }
+      var viewsToCheck = ['gr-change-list-view', 'gr-dashboard-view'];
+      if (viewsToCheck.indexOf(this.params.view) !== -1) {
+        this.set('_lastSearchPage', location.pathname);
+      }
+    },
+
     _handleTitleChange: function(e) {
       if (e.detail.title) {
         document.title = e.detail.title + ' · Gerrit Code Review';
@@ -160,16 +212,25 @@
       }
     },
 
-    _handleKey: function(e) {
-      if (this.shouldSupressKeyboardShortcut(e)) { return; }
-
-      if (e.keyCode === 191 && e.shiftKey) {  // '/' or '?' with shift key.
-        this.$.keyboardShortcuts.open();
-      }
+    _showKeyboardShortcuts: function(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+      this.$.keyboardShortcuts.open();
     },
 
     _handleKeyboardShortcutDialogClose: function() {
       this.$.keyboardShortcuts.close();
     },
+
+    _handleAccountDetailUpdate: function(e) {
+      this.$.mainHeader.reload();
+      if (this.params.view === 'gr-settings-view') {
+        this.$$('gr-settings-view').reloadAccountDetail();
+      }
+    },
+
+    _handleRegistrationDialogClose: function(e) {
+      this.params.justRegistered = false;
+      this.$.registration.close();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
new file mode 100644
index 0000000..28251fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -0,0 +1,109 @@
+<!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-app</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-app.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-app id="app"></gr-app>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-app tests', function() {
+    var sandbox;
+    var element;
+
+    setup(function(done) {
+      sandbox = sinon.sandbox.create();
+      stub('gr-reporting', {
+        appStarted: sandbox.stub(),
+      });
+      stub('gr-account-dropdown', {
+        _getTopContent: sinon.stub(),
+      });
+      stub('gr-rest-api-interface', {
+        getAccount: function() { return Promise.resolve({}); },
+        getAccountCapabilities: function() { return Promise.resolve({}); },
+        getConfig: function() {
+          return Promise.resolve({
+            gerrit: {web_uis: ['GWT', 'POLYGERRIT']},
+            plugin: {js_resource_paths: []},
+          });
+        },
+        getVersion: function() { return Promise.resolve(42); },
+      });
+
+      element = fixture('basic');
+      flush(done);
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('reporting', function() {
+      assert.isTrue(element.$.reporting.appStarted.calledOnce);
+    });
+
+    test('location change updates gwt footer', function(done) {
+      element._path = '/test/path';
+      flush(function() {
+        var gwtLink = element.$$('#gwtLink');
+        assert.equal(
+          gwtLink.href,
+          'http://' + location.host + element.getBaseUrl() + '/?polygerrit=0#/test/path'
+        );
+        done();
+      });
+    });
+
+    test('_handleLocationChange handles hashes', function(done) {
+      var curLocation = {
+        pathname: '/c/1/1/testfile.txt',
+        hash: '#2',
+        host: location.host,
+      };
+      sandbox.stub(element, '_handleSearchPageChange');
+      element._handleLocationChange({detail: curLocation});
+
+      flush(function() {
+        var gwtLink = element.$$('#gwtLink');
+        assert.equal(
+          gwtLink.href,
+          'http://' + location.host + element.getBaseUrl() +
+            '/?polygerrit=0#/c/1/1/testfile.txt@2'
+        );
+        done();
+      });
+    });
+
+    test('sets plugins count', function() {
+      sandbox.stub(Gerrit, '_setPluginsCount');
+      element._loadPlugins([]);
+      assert.isTrue(Gerrit._setPluginsCount.calledWithExactly(0));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index b34925a..0c61998 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -37,6 +37,7 @@
         <span class="title">Registered</span>
         <span class="value">
           <gr-date-formatter
+              has-tooltip
               date-str="[[_account.registered_on]]"></gr-date-formatter>
         </span>
       </section>
@@ -45,7 +46,7 @@
         <span class="value">[[_account.username]]</span>
       </section>
       <section id="nameSection">
-        <span class="title">Full Name</span>
+        <span class="title">Full name</span>
         <span
             hidden$="[[mutable]]"
             class="value">[[_account.name]]</span>
@@ -54,11 +55,23 @@
             class="value">
           <input
               is="iron-input"
+              id="nameInput"
               disabled="[[_saving]]"
-              on-keydown="_handleNameKeydown"
+              on-keydown="_handleKeydown"
               bind-value="{{_account.name}}">
         </span>
       </section>
+      <section>
+        <span class="title">Status</span>
+        <span class="value">
+          <input
+              is="iron-input"
+              id="statusInput"
+              disabled="[[_saving]]"
+              on-keydown="_handleKeydown"
+              bind-value="{{_account.status}}">
+          </span>
+      </section>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 3930a78..91bc628 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -17,6 +17,12 @@
   Polymer({
     is: 'gr-account-info',
 
+    /**
+     * Fired when account details are changed.
+     *
+     * @event account-detail-update
+     */
+
     properties: {
       mutable: {
         type: Boolean,
@@ -26,9 +32,17 @@
       hasUnsavedChanges: {
         type: Boolean,
         notify: true,
-        value: false,
+        computed: '_computeHasUnsavedChanges(_hasNameChange, _hasStatusChange)',
       },
 
+      _hasNameChange: {
+        type: Boolean,
+        value: false,
+      },
+      _hasStatusChange: {
+        type: Boolean,
+        value: false,
+      },
       _loading: {
         type: Boolean,
         value: false,
@@ -43,6 +57,7 @@
 
     observers: [
       '_nameChanged(_account.name)',
+      '_statusChanged(_account.status)',
     ],
 
     loadData: function() {
@@ -64,27 +79,54 @@
     },
 
     save: function() {
-      if (!this.mutable || !this.hasUnsavedChanges) {
+      if (!this.hasUnsavedChanges) {
         return Promise.resolve();
       }
 
       this._saving = true;
-      return this.$.restAPI.setAccountName(this._account.name).then(function() {
-        this.hasUnsavedChanges = false;
-        this._saving = false;
-      }.bind(this));
+      // Set only the fields that have changed.
+      // Must be done in sequence to avoid race conditions (@see Issue 5721)
+      return this._maybeSetName()
+          .then(this._maybeSetStatus.bind(this))
+          .then(function() {
+            this._hasNameChange = false;
+            this._hasStatusChange = false;
+            this._saving = false;
+            this.fire('account-detail-update');
+          }.bind(this));
+    },
+
+    _maybeSetName: function() {
+      return this._hasNameChange && this.mutable ?
+                this.$.restAPI.setAccountName(this._account.name) :
+                Promise.resolve();
+    },
+
+    _maybeSetStatus: function() {
+      return this._hasStatusChange ?
+          this.$.restAPI.setAccountStatus(this._account.status) :
+          Promise.resolve();
+    },
+
+    _computeHasUnsavedChanges: function(name, status) {
+      return name || status;
     },
 
     _computeMutable: function(config) {
       return config.auth.editable_account_fields.indexOf('FULL_NAME') !== -1;
     },
 
-    _nameChanged: function() {
+    _statusChanged: function() {
       if (this._loading) { return; }
-      this.hasUnsavedChanges = true;
+      this._hasStatusChange = true;
     },
 
-    _handleNameKeydown: function(e) {
+    _nameChanged: function() {
+      if (this._loading) { return; }
+      this._hasNameChange = true;
+    },
+
+    _handleKeydown: function(e) {
       if (e.keyCode === 13) { // Enter
         e.stopPropagation();
         this.save();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index 9e1472d..cf35450 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-info</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-account-info.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-account-info></gr-account-info>
@@ -35,7 +37,7 @@
     var element;
     var account;
     var config;
-    var nameInput;
+    var sandbox;
 
     function valueOf(title) {
       var sections = Polymer.dom(element.root).querySelectorAll('section');
@@ -49,6 +51,7 @@
     }
 
     setup(function(done) {
+      sandbox = sinon.sandbox.create();
       account = {
         _account_id: 123,
         name: 'user name',
@@ -66,13 +69,14 @@
         },
       });
       element = fixture('basic');
-
-      nameInput = element.$.nameSection.querySelector('.value input');
-
       // Allow the element to render.
       element.loadData().then(function() { flush(done); });
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('basic account info render', function() {
       assert.isFalse(element._loading);
 
@@ -102,33 +106,153 @@
 
       assert.isTrue(element.mutable);
       assert.isTrue(displaySpan.hasAttribute('hidden'));
-      assert.equal(nameInput.bindValue, account.name);
+      assert.equal(element.$.nameInput.bindValue, account.name);
       assert.isFalse(inputSpan.hasAttribute('hidden'));
     });
 
-    test('account info edit', function(done) {
-      element.set('_serverConfig',
+    suite('account info edit', function() {
+      var nameChangedSpy;
+      var statusChangedSpy;
+      var nameStub;
+      var statusStub;
+
+      setup(function() {
+        nameChangedSpy = sandbox.spy(element, '_nameChanged');
+        statusChangedSpy = sandbox.spy(element, '_statusChanged');
+        element.set('_serverConfig',
           {auth: {editable_account_fields: ['FULL_NAME']}});
 
-      var setStub = sinon.stub(element.$.restAPI, 'setAccountName',
-          function(name) { return Promise.resolve(); });
+        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
+            function(name) { return Promise.resolve(); });
+        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+            function(status) { return Promise.resolve(); });
+      });
 
-      var nameChangedSpy = sinon.spy(element, '_nameChanged');
+      test('name', function(done) {
+        assert.isTrue(element.mutable);
+        assert.isFalse(element.hasUnsavedChanges);
 
-      assert.isTrue(element.mutable);
-      assert.isFalse(element.hasUnsavedChanges);
+        element.set('_account.name', 'new name');
 
-      element.set('_account.name', 'new name');
+        assert.isTrue(nameChangedSpy.called);
+        assert.isFalse(statusChangedSpy.called);
+        assert.isTrue(element.hasUnsavedChanges);
 
-      assert.isTrue(nameChangedSpy.called);
-      assert.isTrue(element.hasUnsavedChanges);
+        MockInteractions.pressAndReleaseKeyOn(element.$.nameInput, 13);
 
-      MockInteractions.pressAndReleaseKeyOn(nameInput, 13);
+        assert.isTrue(nameStub.called);
+        assert.isFalse(statusStub.called);
+        nameStub.lastCall.returnValue.then(function() {
+          assert.equal(nameStub.lastCall.args[0], 'new name');
+          done();
+        });
+      });
 
-      assert.isTrue(setStub.called);
-      setStub.lastCall.returnValue.then(function() {
-        assert.equal(setStub.lastCall.args[0], 'new name');
-        done();
+      test('status', function(done) {
+        assert.isTrue(element.mutable);
+        assert.isFalse(element.hasUnsavedChanges);
+
+        element.set('_account.status', 'new status');
+
+        assert.isFalse(nameChangedSpy.called);
+        assert.isTrue(statusChangedSpy.called);
+        assert.isTrue(element.hasUnsavedChanges);
+
+        element.save().then(function() {
+          assert.isTrue(statusStub.called);
+          assert.isFalse(nameStub.called);
+          statusStub.lastCall.returnValue.then(function() {
+            assert.equal(statusStub.lastCall.args[0], 'new status');
+            done();
+          });
+        });
+      });
+    });
+
+    suite('edit name and status', function() {
+      var nameChangedSpy;
+      var statusChangedSpy;
+      var nameStub;
+      var statusStub;
+
+      setup(function() {
+        nameChangedSpy = sandbox.spy(element, '_nameChanged');
+        statusChangedSpy = sandbox.spy(element, '_statusChanged');
+        element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
+            function(name) { return Promise.resolve(); });
+        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+            function(status) { return Promise.resolve(); });
+      });
+
+      test('set name and status', function(done) {
+        assert.isTrue(element.mutable);
+        assert.isFalse(element.hasUnsavedChanges);
+
+        element.set('_account.name', 'new name');
+
+        assert.isTrue(nameChangedSpy.called);
+
+        element.set('_account.status', 'new status');
+
+        assert.isTrue(statusChangedSpy.called);
+
+        assert.isTrue(element.hasUnsavedChanges);
+
+        element.save().then(function() {
+          assert.isTrue(statusStub.called);
+          assert.isTrue(nameStub.called);
+
+          assert.equal(nameStub.lastCall.args[0], 'new name');
+
+          assert.equal(statusStub.lastCall.args[0], 'new status');
+
+          done();
+        });
+      });
+    });
+
+    suite('set status but read name', function() {
+      var statusChangedSpy;
+      var statusStub;
+
+      setup(function() {
+        statusChangedSpy = sandbox.spy(element, '_statusChanged');
+        element.set('_serverConfig',
+          {auth: {editable_account_fields: []}});
+
+        statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
+            function(status) { return Promise.resolve(); });
+      });
+
+      test('read full name but set status', function(done) {
+        var section = element.$.nameSection;
+        var displaySpan = section.querySelectorAll('.value')[0];
+        var inputSpan = section.querySelectorAll('.value')[1];
+
+        assert.isFalse(element.mutable);
+
+        assert.isFalse(element.hasUnsavedChanges);
+
+        assert.isFalse(displaySpan.hasAttribute('hidden'));
+        assert.equal(displaySpan.textContent, account.name);
+        assert.isTrue(inputSpan.hasAttribute('hidden'));
+
+        element.set('_account.status', 'new status');
+
+        assert.isTrue(statusChangedSpy.called);
+
+        assert.isTrue(element.hasUnsavedChanges);
+
+        element.save().then(function() {
+          assert.isTrue(statusStub.called);
+          statusStub.lastCall.returnValue.then(function() {
+            assert.equal(statusStub.lastCall.args[0], 'new status');
+            done();
+          });
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
new file mode 100644
index 0000000..e2488f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
@@ -0,0 +1,70 @@
+<!--
+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="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../../../styles/gr-settings-styles.html">
+
+<dom-module id="gr-change-table-editor">
+  <template>
+    <style>
+      table {
+        margin-top: 1em;
+      }
+      th.nameHeader {
+        width: 11em;
+      }
+      td.checkboxContainer {
+        border: 1px solid #fff;
+        cursor: pointer;
+        text-align: center;
+      }
+      td.checkboxContainer:hover {
+        border: 1px solid #ddd;
+      }
+    </style>
+    <style include="gr-settings-styles"></style>
+    <div class="gr-settings-styles">
+      <table>
+        <thead>
+          <tr>
+            <th class="nameHeader">Column</th>
+            <th>Visible</th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[columnNames]]">
+            <tr>
+              <td>[[item]]</td>
+              <td class="checkboxContainer"
+                  on-tap="_handleTargetTap">
+                <input
+                    type="checkbox"
+                    name="[[item]]"
+                    checked$="[[!isColumnHidden(item, displayedColumns)]]">
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </div>
+  </template>
+  <script src="gr-change-table-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
new file mode 100644
index 0000000..6a83a46
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -0,0 +1,61 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-change-table-editor',
+
+    properties: {
+      displayedColumns: {
+        type: Array,
+        notify: true,
+      },
+    },
+
+    behaviors: [
+      Gerrit.ChangeTableBehavior,
+    ],
+
+    _getButtonText: function(isShown) {
+      return isShown ? 'Hide' : 'Show';
+    },
+
+    _updateDisplayedColumns: function(displayedColumns, name, checked) {
+      if (!checked) {
+        return displayedColumns.filter(function(column) {
+          return name.toLowerCase() !== column.toLowerCase();
+        });
+      } else {
+        return displayedColumns.concat([name]);
+      }
+    },
+
+    /**
+     * Handles tap on either the checkbox itself or the surrounding table cell.
+     */
+    _handleTargetTap: function(e) {
+      var checkbox = Polymer.dom(e.target).querySelector('input');
+      if (checkbox) {
+        checkbox.click();
+      } else {
+        // The target is the checkbox itself.
+        checkbox = Polymer.dom(e).rootTarget;
+      }
+      this.set('displayedColumns',
+          this._updateDisplayedColumns(
+              this.displayedColumns, checkbox.name, checkbox.checked));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
new file mode 100644
index 0000000..d4443ac
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
@@ -0,0 +1,154 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-change-table-editor.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-table-editor></gr-change-table-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-table-editor tests', function() {
+    var element;
+    var columns;
+    var sandbox;
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+
+      columns = [
+        'Subject',
+        'Status',
+        'Owner',
+        'Project',
+        'Branch',
+        'Updated',
+      ];
+
+      element.set('displayedColumns', columns);
+      flushAsynchronousOperations();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('renders', function() {
+      var rows = element.$$('tbody').querySelectorAll('tr');
+      var tds;
+
+      assert.equal(rows.length, element.columnNames.length);
+      for (var i = 0; i < columns.length; i++) {
+        tds = rows[i].querySelectorAll('td');
+        assert.equal(tds[0].textContent, columns[i]);
+      }
+    });
+
+    test('hide item', function() {
+      var checkbox = element.$$('table input');
+      var isChecked = checkbox.checked;
+      var displayedLength = element.displayedColumns.length;
+      assert.isTrue(isChecked);
+
+      MockInteractions.tap(checkbox);
+      flushAsynchronousOperations();
+
+      assert.equal(element.displayedColumns.length,
+          displayedLength - 1);
+    });
+
+    test('show item', function() {
+      element.set('displayedColumns', [
+        'Status',
+        'Owner',
+        'Project',
+        'Branch',
+        'Updated',
+      ]);
+      flushAsynchronousOperations();
+      var checkbox = element.$$('table input');
+      var isChecked = checkbox.checked;
+      var displayedLength = element.displayedColumns.length;
+      assert.isFalse(isChecked);
+      assert.equal(element.$$('table').style.display, '');
+
+      MockInteractions.tap(checkbox);
+      flushAsynchronousOperations();
+
+      assert.equal(element.displayedColumns.length,
+          displayedLength + 1);
+    });
+
+    test('_handleTargetTap', function() {
+      var checkbox = element.$$('table input');
+      var originalDisplayedColumns = element.displayedColumns;
+      var td = element.$$('table .checkboxContainer');
+      var displayedColumnStub =
+          sandbox.stub(element, '_updateDisplayedColumns');
+
+      MockInteractions.tap(checkbox);
+      assert.isTrue(displayedColumnStub.lastCall.calledWithExactly(
+          originalDisplayedColumns,
+          checkbox.name,
+          checkbox.checked));
+
+      originalDisplayedColumns = element.displayedColumns;
+      MockInteractions.tap(td);
+      assert.isTrue(displayedColumnStub.lastCall.calledWithExactly(
+          originalDisplayedColumns,
+          checkbox.name,
+          checkbox.checked));
+    });
+
+    test('_updateDisplayedColumns', function() {
+      var name = 'Subject';
+      var checked = false;
+      assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
+          [
+            'Status',
+            'Owner',
+            'Project',
+            'Branch',
+            'Updated',
+          ]);
+      name = 'Size';
+      checked = true;
+      assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
+          [
+            'Subject',
+            'Status',
+            'Owner',
+            'Project',
+            'Branch',
+            'Updated',
+            'Size',
+          ]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
new file mode 100644
index 0000000..b667d66
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
@@ -0,0 +1,25 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-placeholder/gr-placeholder.html">
+
+<dom-module id="gr-cla-view">
+  <template>
+    <gr-placeholder title="Agreements" path="[[path]]"></gr-placeholder>
+  </template>
+  <script src="gr-cla-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
new file mode 100644
index 0000000..71dc71b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-cla-view',
+
+    properties: {
+      path: String,
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
index 030b81c..b949643 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-email-editor</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-email-editor.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-email-editor></gr-email-editor>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
index d68cc33..303d836 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -39,7 +39,7 @@
           <tr>
             <th class="nameHeader">Name</th>
             <th class="descriptionHeader">Description</th>
-            <th>Visible to All</th>
+            <th>Visible to all</th>
           </tr>
         </thead>
         <tbody>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
index 56a476e..2abf797 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -18,11 +18,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="gr-group-list.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-group-list></gr-group-list>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
index e58f1f2..e01ab94 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -15,7 +15,9 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-settings-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-http-password">
@@ -24,39 +26,61 @@
       .password {
         font-family: var(--monospace-font-family);
       }
-      .noPassword {
-        color: #777;
+      #generatedPasswordOverlay {
+        padding: 2em;
+        width: 50em;
+      }
+      #generatedPasswordDisplay {
+        margin: 1em 0;
+      }
+      #generatedPasswordDisplay .value {
+        font-family: var(--monospace-font-family);
+      }
+      #passwordWarning {
         font-style: italic;
+        text-align: center;
+      }
+      .closeButton {
+        bottom: 2em;
+        position: absolute;
+        right: 2em;
       }
     </style>
     <style include="gr-settings-styles"></style>
     <div class="gr-settings-styles">
-      <section>
-        <span class="title">Username</span>
-        <span class="value">[[_username]]</span>
-      </section>
-      <section>
-        <span class="title">Password</span>
-        <span hidden$="[[!_hasPassword]]">
-          <span class="value" hidden$="[[_passwordVisible]]">
-            <gr-button
-                link
-                on-tap="_handleViewPasswordTap">Click to view</gr-button>
-          </span>
-          <span
-              class="value password"
-              hidden$="[[!_passwordVisible]]">[[_password]]</span>
-        </span>
-        <span class="value noPassword" hidden$="[[_hasPassword]]">(None)</span>
-      </section>
-      <gr-button
-          id="generateButton"
-          on-tap="_handleGenerateTap">Generate New Password</gr-button>
-      <gr-button
-          id="clearButton"
-          on-tap="_handleClearTap"
-          disabled="[[!_hasPassword]]">Clear Password</gr-button>
+      <div hidden$="[[_passwordUrl]]">
+        <section>
+          <span class="title">Username</span>
+          <span class="value">[[_username]]</span>
+        </section>
+        <gr-button
+            id="generateButton"
+            on-tap="_handleGenerateTap">Generate new password</gr-button>
+      </div>
+      <span hidden$="[[!_passwordUrl]]">
+        <a href="[[_passwordUrl]]" target="_blank" rel="noopener">
+          Obtain password</a>
+        (opens in a new tab)
+      </span>
     </div>
+    <gr-overlay
+        id="generatedPasswordOverlay"
+        on-iron-overlay-closed="_generatedPasswordOverlayClosed"
+        with-backdrop>
+      <div class="gr-settings-styles">
+        <section id="generatedPasswordDisplay">
+          <span class="title">New Password:</span>
+          <span class="value">[[_generatedPassword]]</span>
+        </section>
+        <section id="passwordWarning">
+          This password will not be displayed again.<br>
+          If you lose it, you will need to generate a new one.
+        </section>
+        <gr-button
+            class="closeButton"
+            on-tap="_closeOverlay">Close</gr-button>
+      </div>
+    </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-http-password.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
index 9248632..f4894e9 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -17,21 +17,10 @@
   Polymer({
     is: 'gr-http-password',
 
-    /**
-     * Fired when getting the password fails with non-404.
-     *
-     * @event network-error
-     */
-
     properties: {
-      _serverConfig: Object,
       _username: String,
-      _password: String,
-      _passwordVisible: {
-        type: Boolean,
-        value: false,
-      },
-      _hasPassword: Boolean,
+      _generatedPassword: String,
+      _passwordUrl: String,
     },
 
     loadData: function() {
@@ -41,41 +30,27 @@
         this._username = account.username;
       }.bind(this)));
 
-      promises.push(this.$.restAPI
-          .getAccountHttpPassword(this._handleGetPasswordError.bind(this))
-          .then(function(pass) {
-            this._password = pass;
-            this._hasPassword = !!pass;
-          }.bind(this)));
+      promises.push(this.$.restAPI.getConfig().then(function(info) {
+        this._passwordUrl = info.auth.http_password_url || null;
+      }.bind(this)));
 
       return Promise.all(promises);
     },
 
-    _handleGetPasswordError: function(response) {
-      if (response.status === 404) {
-        this._hasPassword = false;
-      } else {
-        this.fire('network-error', {response: response});
-      }
-    },
-
-    _handleViewPasswordTap: function() {
-      this._passwordVisible = true;
-    },
-
     _handleGenerateTap: function() {
+      this._generatedPassword = 'Generating...';
+      this.$.generatedPasswordOverlay.open();
       this.$.restAPI.generateAccountHttpPassword().then(function(newPassword) {
-        this._hasPassword = true;
-        this._passwordVisible = true;
-        this._password = newPassword;
+        this._generatedPassword = newPassword;
       }.bind(this));
     },
 
-    _handleClearTap: function() {
-      this.$.restAPI.deleteAccountHttpPassword().then(function() {
-        this._password = '';
-        this._hasPassword = false;
-      }.bind(this));
+    _closeOverlay: function() {
+      this.$.generatedPasswordOverlay.close();
+    },
+
+    _generatedPasswordOverlayClosed: function() {
+      this._generatedPassword = null;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
index 36c9abf..787c2c4 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-http-password.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-http-password></gr-http-password>
@@ -31,126 +33,63 @@
 </test-fixture>
 
 <script>
-  suite('gr-http-password tests (already has password)', function() {
+  suite('gr-http-password tests', function() {
     var element;
     var account;
     var password;
+    var config;
 
     setup(function(done) {
       account = {username: 'user name'};
+      config = {auth: {}};
       password = 'the password';
 
       stub('gr-rest-api-interface', {
         getAccount: function() { return Promise.resolve(account); },
-        getAccountHttpPassword: function() {
-          return Promise.resolve(password);
-        },
+        getConfig: function() { return Promise.resolve(config); },
       });
 
       element = fixture('basic');
       element.loadData().then(function() { flush(done); });
     });
 
-    test('loads data', function() {
-      assert.equal(element._username, 'user name');
-      assert.equal(element._password, 'the password');
-      assert.isFalse(element._passwordVisible);
-      assert.isTrue(element._hasPassword);
-    });
-
-    test('view password', function() {
-      var button = element.$$('.value gr-button');
-      assert.isFalse(element._passwordVisible);
-      MockInteractions.tap(button);
-      assert.isTrue(element._passwordVisible);
-    });
-
     test('generate password', function() {
       var button = element.$.generateButton;
       var nextPassword = 'the new password';
+      var generateResolve;
       var generateStub = sinon.stub(element.$.restAPI,
           'generateAccountHttpPassword', function() {
-            return Promise.resolve(nextPassword);
+            return new Promise(function(resolve) {
+              generateResolve = resolve;
+            });
           });
 
-      assert.isTrue(element._hasPassword);
-      assert.isFalse(element._passwordVisible);
-      assert.equal(element._password, 'the password');
+      assert.isNotOk(element._generatedPassword);
 
       MockInteractions.tap(button);
 
       assert.isTrue(generateStub.called);
+      assert.equal(element._generatedPassword, 'Generating...');
+
+      generateResolve(nextPassword);
+
       generateStub.lastCall.returnValue.then(function() {
-        assert.isTrue(element._passwordVisible);
-        assert.isTrue(element._hasPassword);
-        assert.equal(element._password, 'the new password');
+        assert.equal(element._generatedPassword, nextPassword);
       });
     });
 
-    test('clear password', function() {
-      var button = element.$.clearButton;
-      var clearStub = sinon.stub(element.$.restAPI, 'deleteAccountHttpPassword',
-          function() { return Promise.resolve(); });
+    test('without http_password_url', function() {
+      assert.isNull(element._passwordUrl);
+    });
 
-      assert.isTrue(element._hasPassword);
-      assert.equal(element._password, 'the password');
-
-      MockInteractions.tap(button);
-
-      assert.isTrue(clearStub.called);
-      clearStub.lastCall.returnValue.then(function() {
-        assert.isFalse(element._hasPassword);
-        assert.equal(element._password, '');
+    test('with http_password_url', function(done) {
+      config.auth.http_password_url = 'http://example.com/';
+      element.loadData().then(function() {
+        assert.isNotNull(element._passwordUrl);
+        assert.equal(element._passwordUrl, config.auth.http_password_url);
+        done();
       });
     });
   });
 
-  suite('gr-http-password tests (has no password)', function() {
-    var element;
-    var account;
-
-    setup(function(done) {
-      account = {username: 'user name'};
-
-      stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(account); },
-        getAccountHttpPassword: function(errFn) {
-          errFn({status: 404});
-          return Promise.resolve('');
-        },
-      });
-
-      element = fixture('basic');
-      element.loadData().then(function() { flush(done); });
-    });
-
-    test('loads data', function() {
-      assert.equal(element._username, 'user name');
-      assert.isNotOk(element._password);
-      assert.isFalse(element._passwordVisible);
-      assert.isFalse(element._hasPassword);
-    });
-
-    test('generate password', function() {
-      var button = element.$.generateButton;
-      var nextPassword = 'the new password';
-      var generateStub = sinon.stub(element.$.restAPI,
-          'generateAccountHttpPassword', function() {
-            return Promise.resolve(nextPassword);
-          });
-
-      assert.isFalse(element._hasPassword);
-      assert.isFalse(element._passwordVisible);
-      assert.isNotOk(element._password);
-
-      MockInteractions.tap(button);
-
-      assert.isTrue(generateStub.called);
-      generateStub.lastCall.returnValue.then(function() {
-        assert.isTrue(element._passwordVisible);
-        assert.isOk(element._hasPassword);
-        assert.equal(element._password, 'the new password');
-      });
-    });
-  });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
index 0eace7d..e603e8c 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -32,6 +32,9 @@
       tbody tr:last-of-type td .move-down-button {
         display: none;
       }
+      td.urlCell {
+        word-break: break-word;
+      }
       .newTitleInput {
         width: 10em;
       }
@@ -52,7 +55,7 @@
           <template is="dom-repeat" items="[[menuItems]]">
             <tr>
               <td>[[item.name]]</td>
-              <td>[[item.url]]</td>
+              <td class="urlCell">[[item.url]]</td>
               <td>
                 <gr-button
                     data-index="[[index]]"
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index 74e9c6a..a7078093 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-menu-editor.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-menu-editor></gr-menu-editor>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
new file mode 100644
index 0000000..ee358d5
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -0,0 +1,98 @@
+<!--
+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="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-registration-dialog">
+  <template>
+    <style include="gr-settings-styles"></style>
+    <style>
+      :host {
+        display: block;
+      }
+      main {
+        max-width: 46em;
+      }
+      hr {
+        margin-top: 1em;
+        margin-bottom: 1em;
+      }
+      header {
+        border-bottom: 1px solid #ddd;
+        font-weight: bold;
+      }
+      header,
+      main,
+      footer {
+        padding: .5em .65em;
+      }
+      footer {
+        display: flex;
+        justify-content: space-between;
+      }
+    </style>
+    <main class="gr-settings-styles">
+      <header>Please confirm your contact information</header>
+      <main>
+        <p>
+          The following contact information was automatically obtained when you
+          signed in to the site. This information is used to display who you are
+          to others, and to send updates to code reviews you have either started
+          or subscribed to.
+        </p>
+        <hr>
+        <section>
+          <div class="title">Full Name</div>
+          <input
+              is="iron-input"
+              id="name"
+              bind-value="{{_account.name}}"
+              disabled="[[_saving]]"
+              on-keydown="_handleNameKeydown">
+        </section>
+        <section>
+          <div class="title">Preferred Email</div>
+          <select
+              is="gr-select"
+              id="email"
+              bind-value="{{_account.email}}"
+              disabled="[[_saving]]">
+            <option value="[[_account.email]]">[[_account.email]]</option>
+            <template is="dom-repeat" items="[[_account.secondary_emails]]">
+              <option value="[[item]]">[[item]]</option>
+            </template>
+          </select>
+        </section>
+      </main>
+      <footer>
+        <gr-button
+            id="saveButton"
+            primary
+            disabled="[[_saving]]"
+            on-tap="_handleSave">Save</gr-button>
+        <gr-button
+            id="closeButton"
+            disabled="[[_saving]]"
+            on-tap="_handleClose">Close</gr-button>
+      </footer>
+    </main>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-registration-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
new file mode 100644
index 0000000..9acdba9
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-registration-dialog',
+
+    /**
+     * Fired when account details are changed.
+     *
+     * @event account-detail-update
+     */
+
+    /**
+     * Fired when the close button is pressed.
+     *
+     * @event close
+     */
+
+    properties: {
+      _account: Object,
+      _saving: Boolean,
+    },
+
+    hostAttributes: {
+      role: 'dialog',
+    },
+
+    attached: function() {
+      this.$.restAPI.getAccount().then(function(account) {
+        this._account = account;
+      }.bind(this));
+    },
+
+    _handleNameKeydown: function(e) {
+      if (e.keyCode === 13) { // Enter
+        e.stopPropagation();
+        this._save();
+      }
+    },
+
+    _save: function() {
+      this._saving = true;
+      var promises = [
+        this.$.restAPI.setAccountName(this.$.name.value),
+        this.$.restAPI.setPreferredAccountEmail(this.$.email.value),
+      ];
+      return Promise.all(promises).then(function() {
+        this._saving = false;
+        this.fire('account-detail-update');
+      }.bind(this));
+    },
+
+    _handleSave: function(e) {
+      e.preventDefault();
+      this._save().then(function() {
+        this.fire('close');
+      }.bind(this));
+    },
+
+    _handleClose: function(e) {
+      e.preventDefault();
+      this._saving = true; // disable buttons indefinitely
+      this.fire('close');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
new file mode 100644
index 0000000..ee5a206
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -0,0 +1,149 @@
+<!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-registration-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-registration-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-registration-dialog></gr-registration-dialog>
+  </template>
+</test-fixture>
+
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-registration-dialog tests', function() {
+    var element;
+    var account;
+    var _listeners;
+
+    setup(function(done) {
+      _listeners = {};
+
+      account = {
+        name: 'name',
+        email: 'email',
+        secondary_emails: [
+          'email2',
+          'email3',
+        ],
+      };
+
+      stub('gr-rest-api-interface', {
+        getAccount: function() {
+          // Once the account is resolved, we can let the test proceed.
+          flush(done);
+          return Promise.resolve(account);
+        },
+        setAccountName: function(name) {
+          account.name = name;
+          return Promise.resolve();
+        },
+        setPreferredAccountEmail: function(email) {
+          account.email = email;
+          return Promise.resolve();
+        },
+      });
+
+      element = fixture('basic');
+    });
+
+    teardown(function() {
+      for (var eventType in _listeners) {
+        if (_listeners.hasOwnProperty(eventType)) {
+          element.removeEventListener(eventType, _listeners[eventType]);
+        }
+      }
+    });
+
+    function listen(eventType) {
+      return new Promise(function(resolve) {
+        _listeners[eventType] = function() { resolve(); };
+        element.addEventListener(eventType, _listeners[eventType]);
+      });
+    }
+
+    function save(opt_action) {
+      var promise = listen('account-detail-update');
+      if (opt_action) {
+        opt_action();
+      } else {
+        MockInteractions.tap(element.$.saveButton);
+      }
+      return promise;
+    }
+
+    function close(opt_action) {
+      var promise = listen('close');
+      if (opt_action) {
+        opt_action();
+      } else {
+        MockInteractions.tap(element.$.closeButton);
+      }
+      return promise;
+    }
+
+    test('fires the close event on close', function(done) {
+      close().then(done);
+    });
+
+    test('fires the close event on save', function(done) {
+      close(function() {
+        MockInteractions.tap(element.$.saveButton);
+      }).then(done);
+    });
+
+    test('saves name and preferred email', function(done) {
+      flush(function() {
+        element.$.name.value = 'new name';
+        element.$.email.value = 'email3';
+
+        // Nothing should be committed yet.
+        assert.equal(account.name, 'name');
+        assert.equal(account.email, 'email');
+
+        // Save and verify new values are committed.
+        save().then(function() {
+          assert.equal(account.name, 'new name');
+          assert.equal(account.email, 'email3');
+        }).then(done);
+      });
+    });
+
+    test('pressing enter saves name', function(done) {
+      element.$.name.value = 'entered name';
+      save(function() {
+        MockInteractions.pressAndReleaseKeyOn(element.$.name, 13);  // 'enter'
+      }).then(function() {
+        assert.equal(account.name, 'entered name');
+      }).then(done);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index 4f1cb87..f485dd6 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -20,6 +20,7 @@
 <link rel="import" href="../gr-email-editor/gr-email-editor.html">
 <link rel="import" href="../gr-group-list/gr-group-list.html">
 <link rel="import" href="../gr-http-password/gr-http-password.html">
+<link rel="import" href="../gr-change-table-editor/gr-change-table-editor.html">
 <link rel="import" href="../gr-menu-editor/gr-menu-editor.html">
 <link rel="import" href="../gr-ssh-editor/gr-ssh-editor.html">
 <link rel="import" href="../gr-watched-projects-editor/gr-watched-projects-editor.html">
@@ -98,6 +99,8 @@
           <li><a href="#Profile">Profile</a></li>
           <li><a href="#Preferences">Preferences</a></li>
           <li><a href="#DiffPreferences">Diff Preferences</a></li>
+          <li><a href="#Menu">Menu</a></li>
+          <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
           <li><a href="#Notifications">Notifications</a></li>
           <li><a href="#EmailAddresses">Email Addresses</a></li>
           <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
@@ -115,19 +118,18 @@
         <fieldset id="profile">
           <gr-account-info
               id="accountInfo"
-              mutable="{{_accountInfoMutable}}"
+              mutable="{{_accountNameMutable}}"
               has-unsaved-changes="{{_accountInfoChanged}}"></gr-account-info>
           <gr-button
               on-tap="_handleSaveAccountInfo"
-              hidden$="[[!_accountInfoMutable]]"
-              disabled="[[!_accountInfoChanged]]">Save Changes</gr-button>
+              disabled="[[!_accountInfoChanged]]">Save changes</gr-button>
         </fieldset>
         <h2
             id="Preferences"
             class$="[[_computeHeaderClass(_prefsChanged)]]">Preferences</h2>
         <fieldset id="preferences">
           <section>
-            <span class="title">Changes Per Page</span>
+            <span class="title">Changes per page</span>
             <span class="value">
               <select
                   is="gr-select"
@@ -140,7 +142,7 @@
             </span>
           </section>
           <section>
-            <span class="title">Date/Time Format</span>
+            <span class="title">Date/time format</span>
             <span class="value">
               <select
                   is="gr-select"
@@ -160,33 +162,53 @@
             </span>
           </section>
           <section>
-            <span class="title">Email Notifications</span>
+            <span class="title">Email notifications</span>
             <span class="value">
               <select
                   is="gr-select"
                   bind-value="{{_localPrefs.email_strategy}}">
-                <option value="ENABLED">Enabled</option>
-                <option
-                    value="CC_ON_OWN_COMMENTS">CC Me On Comments I Write</option>
-                <option value="DISABLED">Disabled</option>
+                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                <option value="ENABLED">Only comments left by others</option>
+                <option value="DISABLED">None</option>
+              </select>
+            </span>
+          </section>
+          <section hidden$="[[!_localPrefs.email_format]]">
+            <span class="title">Email format</span>
+            <span class="value">
+              <select
+                  is="gr-select"
+                  bind-value="{{_localPrefs.email_format}}">
+                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                <option value="PLAINTEXT">Plaintext only</option>
               </select>
             </span>
           </section>
           <section>
-            <span class="title">Diff View</span>
+            <span class="title">Diff view</span>
             <span class="value">
               <select
                   is="gr-select"
                   bind-value="{{_localPrefs.diff_view}}">
-                <option value="SIDE_BY_SIDE">Side by Side</option>
-                <option value="UNIFIED_DIFF">Unified Diff</option>
+                <option value="SIDE_BY_SIDE">Side by side</option>
+                <option value="UNIFIED_DIFF">Unified diff</option>
               </select>
             </span>
           </section>
+          <section>
+            <span class="title">Expand inline diffs</span>
+            <span class="value">
+              <input
+                  id="expandInlineDiffs"
+                  type="checkbox"
+                  checked$="[[_localPrefs.expand_inline_diffs]]"
+                  on-change="_handleExpandInlineDiffsChanged">
+            </span>
+          </section>
           <gr-button
               id="savePrefs"
               on-tap="_handleSavePreferences"
-              disabled="[[!_prefsChanged]]">Save Changes</gr-button>
+              disabled="[[!_prefsChanged]]">Save changes</gr-button>
         </fieldset>
         <h2
             id="DiffPreferences"
@@ -211,7 +233,17 @@
             </span>
           </section>
           <section>
-            <span class="title">Columns</span>
+            <span class="title">Fit to screen</span>
+            <span class="value">
+              <input
+                  id="lineWrapping"
+                  type="checkbox"
+                  checked$="[[_diffPrefs.line_wrapping]]"
+                  on-change="_handleLineWrappingChanged">
+            </span>
+          </section>
+          <section id="columnsPref" hidden$="[[_diffPrefs.line_wrapping]]">
+            <span class="title">Diff width</span>
             <span class="value">
               <input
                   is="iron-input"
@@ -222,7 +254,7 @@
             </span>
           </section>
           <section>
-            <span class="title">Tab Width</span>
+            <span class="title">Tab width</span>
             <span class="value">
               <input
                   is="iron-input"
@@ -232,8 +264,19 @@
                   bind-value="{{_diffPrefs.tab_size}}">
             </span>
           </section>
+          <section hidden$="[[!_diffPrefs.font_size]]">
+            <span class="title">Font size</span>
+            <span class="value">
+              <input
+                  is="iron-input"
+                  type="number"
+                  prevent-invalid-input
+                  allowed-pattern="[0-9]"
+                  bind-value="{{_diffPrefs.font_size}}">
+            </span>
+          </section>
           <section>
-            <span class="title">Show Tabs</span>
+            <span class="title">Show tabs</span>
             <span class="value">
               <input
                   id="showTabs"
@@ -243,7 +286,17 @@
             </span>
           </section>
           <section>
-            <span class="title">Syntax Highlighting</span>
+            <span class="title">Show trailing whitespace</span>
+            <span class="value">
+              <input
+                  id="showTrailingWhitespace"
+                  type="checkbox"
+                  checked$="[[_diffPrefs.show_whitespace_errors]]"
+                  on-change="_handleShowTrailingWhitespaceChanged">
+            </span>
+          </section>
+          <section>
+            <span class="title">Syntax highlighting</span>
             <span class="value">
               <input
                   id="syntaxHighlighting"
@@ -255,15 +308,28 @@
           <gr-button
               id="saveDiffPrefs"
               on-tap="_handleSaveDiffPreferences"
-              disabled$="[[!_diffPrefsChanged]]">Save Changes</gr-button>
+              disabled$="[[!_diffPrefsChanged]]">Save changes</gr-button>
         </fieldset>
-        <h2 class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
+        <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
         <fieldset id="menu">
           <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
           <gr-button
               id="saveMenu"
               on-tap="_handleSaveMenu"
-              disabled="[[!_menuChanged]]">Save Changes</gr-button>
+              disabled="[[!_menuChanged]]">Save changes</gr-button>
+        </fieldset>
+        <h2 id="ChangeTableColumns"
+            class$="[[_computeHeaderClass(_changeTableChanged)]]">
+          Change Table Columns
+        </h2>
+        <fieldset id="changeTableColumns">
+          <gr-change-table-editor
+              displayed-columns="{{_localChangeTableColumns}}">
+          </gr-change-table-editor>
+          <gr-button
+              id="saveChangeTable"
+              on-tap="_handleSaveChangeTable"
+              disabled="[[!_changeTableChanged]]">Save changes</gr-button>
         </fieldset>
         <h2
             id="Notifications"
@@ -277,7 +343,7 @@
           <gr-button
               on-tap="_handleSaveWatchedProjects"
               disabled$="[[!_watchedProjectsChanged]]"
-              id="_handleSaveWatchedProjects">Save Changes</gr-button>
+              id="_handleSaveWatchedProjects">Save changes</gr-button>
         </fieldset>
         <h2
             id="EmailAddresses"
@@ -290,11 +356,11 @@
               has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
           <gr-button
               on-tap="_handleSaveEmails"
-              disabled$="[[!_emailsChanged]]">Save Changes</gr-button>
+              disabled$="[[!_emailsChanged]]">Save changes</gr-button>
         </fieldset>
         <fieldset id="newEmail">
           <section>
-            <span class="title">New Email Address</span>
+            <span class="title">New email address</span>
             <span class="value">
               <input
                   id="newEmailInput"
@@ -316,7 +382,7 @@
           </section>
           <gr-button
               disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-              on-tap="_handleAddEmailButton">Send Verification</gr-button>
+              on-tap="_handleAddEmailButton">Send verification</gr-button>
         </fieldset>
         <h2 id="HTTPCredentials">HTTP Credentials</h2>
         <fieldset>
@@ -325,7 +391,7 @@
         <div hidden$="[[!_serverConfig.sshd]]">
           <h2
               id="SSHKeys"
-              class$="[[_computeHeaderClass(_keysChanged)]]">SSH Keys</h2>
+              class$="[[_computeHeaderClass(_keysChanged)]]">SSH keys</h2>
           <gr-ssh-editor
               id="sshEditor"
               has-unsaved-changes="{{_keysChanged}}"></gr-ssh-editor>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 6c62408..4647a2d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -20,6 +20,8 @@
     'time_format',
     'email_strategy',
     'diff_view',
+    'expand_inline_diffs',
+    'email_format',
   ];
 
   Polymer({
@@ -31,18 +33,33 @@
      * @event title-change
      */
 
+    /**
+     * Fired with email confirmation text.
+     *
+     * @event show-alert
+     */
+
     properties: {
       prefs: {
         type: Object,
         value: function() { return {}; },
       },
-      _accountInfoMutable: Boolean,
+      params: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _accountNameMutable: Boolean,
       _accountInfoChanged: Boolean,
       _diffPrefs: Object,
+      _changeTableColumnsNotDisplayed: Array,
       _localPrefs: {
         type: Object,
         value: function() { return {}; },
       },
+      _localChangeTableColumns: {
+        type: Array,
+        value: function() { return []; },
+      },
       _localMenu: {
         type: Array,
         value: function() { return []; },
@@ -51,6 +68,10 @@
         type: Boolean,
         value: true,
       },
+      _changeTableChanged: {
+        type: Boolean,
+        value: false,
+      },
       _prefsChanged: {
         type: Boolean,
         value: false,
@@ -89,10 +110,15 @@
       _loadingPromise: Object,
     },
 
+    behaviors: [
+      Gerrit.ChangeTableBehavior,
+    ],
+
     observers: [
       '_handlePrefsChanged(_localPrefs.*)',
       '_handleDiffPrefsChanged(_diffPrefs.*)',
       '_handleMenuChanged(_localMenu.splices)',
+      '_handleChangeTableChanged(_localChangeTableColumns)',
     ],
 
     attached: function() {
@@ -101,7 +127,6 @@
       var promises = [
         this.$.accountInfo.loadData(),
         this.$.watchedProjectsEditor.loadData(),
-        this.$.emailEditor.loadData(),
         this.$.groupList.loadData(),
         this.$.httpPass.loadData(),
       ];
@@ -110,6 +135,7 @@
         this.prefs = prefs;
         this._copyPrefs('_localPrefs', 'prefs');
         this._cloneMenu();
+        this._cloneChangeTableColumns();
       }.bind(this)));
 
       promises.push(this.$.restAPI.getDiffPreferences().then(function(prefs) {
@@ -123,6 +149,18 @@
         }
       }.bind(this)));
 
+      if (this.params.emailToken) {
+        promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
+          function(message) {
+            if (message) {
+              this.fire('show-alert', {message: message});
+            }
+            this.$.emailEditor.loadData();
+          }.bind(this)));
+      } else {
+        promises.push(this.$.emailEditor.loadData());
+      }
+
       this._loadingPromise = Promise.all(promises).then(function() {
         this._loading = false;
       }.bind(this));
@@ -134,6 +172,13 @@
       this.unlisten(window, 'scroll', '_handleBodyScroll');
     },
 
+    reloadAccountDetail: function() {
+      Promise.all([
+        this.$.accountInfo.loadData(),
+        this.$.emailEditor.loadData(),
+      ]);
+    },
+
     _handleBodyScroll: function(e) {
       if (this._headerHeight === undefined) {
         var top = this.$.settingsNav.offsetTop;
@@ -172,6 +217,30 @@
       this._localMenu = menu;
     },
 
+    _cloneChangeTableColumns: function() {
+      var columns = this.prefs.change_table;
+
+      if (columns.length === 0) {
+        columns = this.columnNames;
+        this._changeTableColumnsNotDisplayed = [];
+      } else {
+        this._changeTableColumnsNotDisplayed = this.getComplementColumns(
+          this.prefs.change_table);
+      }
+      this._localChangeTableColumns = columns;
+    },
+
+    _formatChangeTableColumns: function(changeTableArray) {
+      return changeTableArray.map(function(item) {
+        return {column: item};
+      });
+    },
+
+    _handleChangeTableChanged: function() {
+      if (this._isLoading()) { return; }
+      this._changeTableChanged = true;
+    },
+
     _handlePrefsChanged: function(prefs) {
       if (this._isLoading()) { return; }
       this._prefsChanged = true;
@@ -182,6 +251,11 @@
       this._diffPrefsChanged = true;
     },
 
+    _handleExpandInlineDiffsChanged: function() {
+      this.set('_localPrefs.expand_inline_diffs',
+          this.$.expandInlineDiffs.checked);
+    },
+
     _handleMenuChanged: function() {
       if (this._isLoading()) { return; }
       this._menuChanged = true;
@@ -199,15 +273,32 @@
       }.bind(this));
     },
 
+    _handleLineWrappingChanged: function() {
+      this.set('_diffPrefs.line_wrapping', this.$.lineWrapping.checked);
+    },
+
     _handleShowTabsChanged: function() {
       this.set('_diffPrefs.show_tabs', this.$.showTabs.checked);
     },
 
+    _handleShowTrailingWhitespaceChanged: function() {
+      this.set('_diffPrefs.show_whitespace_errors',
+          this.$.showTrailingWhitespace.checked);
+    },
+
     _handleSyntaxHighlightingChanged: function() {
       this.set('_diffPrefs.syntax_highlighting',
           this.$.syntaxHighlighting.checked);
     },
 
+    _handleSaveChangeTable: function() {
+      this.set('prefs.change_table', this._localChangeTableColumns);
+      this._cloneChangeTableColumns();
+      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+        this._changeTableChanged = false;
+      }.bind(this));
+    },
+
     _handleSaveDiffPreferences: function() {
       return this.$.restAPI.saveDiffPreferences(this._diffPrefs)
           .then(function() {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index 4e98b43..cb471d4 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-settings-view.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-settings-view></gr-settings-view>
@@ -43,6 +45,7 @@
     var preferences;
     var diffPreferences;
     var config;
+    var sandbox;
 
     function valueOf(title, fieldsetid) {
       var sections = element.$[fieldsetid].querySelectorAll('section');
@@ -65,11 +68,12 @@
     }
 
     function stubAddAccountEmail(statusCode) {
-      return sinon.stub(element.$.restAPI, 'addAccountEmail',
+      return sandbox.stub(element.$.restAPI, 'addAccountEmail',
           function() { return Promise.resolve({status: statusCode}); });
     }
 
     setup(function(done) {
+      sandbox = sinon.sandbox.create();
       account = {
         _account_id: 123,
         name: 'user name',
@@ -83,17 +87,21 @@
         time_format: 'HHMM_12',
         diff_view: 'UNIFIED_DIFF',
         email_strategy: 'ENABLED',
+        email_format: 'HTML_PLAINTEXT',
 
         my: [
           {url: '/first/url', name: 'first name', target: '_blank'},
           {url: '/second/url', name: 'second name', target: '_blank'},
         ],
+        change_table: [],
       };
       diffPreferences = {
         context: 10,
         tab_size: 8,
+        font_size: 12,
         line_length: 100,
         cursor_blink_rate: 0,
+        line_wrapping: false,
         intraline_difference: true,
         show_line_endings: true,
         show_tabs: true,
@@ -118,7 +126,6 @@
         getAccountEmails: function() { return Promise.resolve(); },
         getConfig: function() { return Promise.resolve(config); },
         getAccountGroups: function() { return Promise.resolve([]); },
-        getAccountHttpPassword: function() { return Promise.resolve(''); },
       });
       element = fixture('basic');
 
@@ -126,8 +133,12 @@
       element._loadingPromise.then(done);
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('calls the title-change event', function() {
-      var titleChangedStub = sinon.stub();
+      var titleChangedStub = sandbox.stub();
 
       // Create a new view.
       var newElement = document.createElement('gr-settings-view');
@@ -146,25 +157,34 @@
 
     test('user preferences', function(done) {
       // Rendered with the expected preferences selected.
-      assert.equal(valueOf('Changes Per Page', 'preferences')
+      assert.equal(valueOf('Changes per page', 'preferences')
           .firstElementChild.bindValue, preferences.changes_per_page);
-      assert.equal(valueOf('Date/Time Format', 'preferences')
+      assert.equal(valueOf('Date/time format', 'preferences')
           .firstElementChild.bindValue, preferences.date_format);
-      assert.equal(valueOf('Date/Time Format', 'preferences')
+      assert.equal(valueOf('Date/time format', 'preferences')
           .lastElementChild.bindValue, preferences.time_format);
-      assert.equal(valueOf('Email Notifications', 'preferences')
+      assert.equal(valueOf('Email notifications', 'preferences')
           .firstElementChild.bindValue, preferences.email_strategy);
-      assert.equal(valueOf('Diff View', 'preferences')
+      assert.equal(valueOf('Email format', 'preferences')
+          .firstElementChild.bindValue, preferences.email_format);
+      assert.equal(valueOf('Diff view', 'preferences')
           .firstElementChild.bindValue, preferences.diff_view);
+      assert.equal(valueOf('Expand inline diffs', 'preferences')
+          .firstElementChild.checked, false);
 
       assert.isFalse(element._prefsChanged);
       assert.isFalse(element._menuChanged);
 
       // Change the diff view element.
-      var diffSelect = valueOf('Diff View', 'preferences').firstElementChild;
+      var diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
       diffSelect.bindValue = 'SIDE_BY_SIDE';
+
+      var expandInlineDiffs =
+          valueOf('Expand inline diffs', 'preferences').firstElementChild;
       diffSelect.fire('change');
 
+      MockInteractions.tap(expandInlineDiffs);
+
       assert.isTrue(element._prefsChanged);
       assert.isFalse(element._menuChanged);
 
@@ -172,6 +192,7 @@
         savePreferences: function(prefs) {
           assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
           assertMenusEqual(prefs.my, preferences.my);
+          assert.equal(prefs.expand_inline_diffs, true);
           return Promise.resolve();
         }
       });
@@ -188,16 +209,22 @@
       // Rendered with the expected preferences selected.
       assert.equal(valueOf('Context', 'diffPreferences')
           .firstElementChild.bindValue, diffPreferences.context);
-      assert.equal(valueOf('Columns', 'diffPreferences')
+      assert.equal(valueOf('Diff width', 'diffPreferences')
           .firstElementChild.bindValue, diffPreferences.line_length);
-      assert.equal(valueOf('Tab Width', 'diffPreferences')
+      assert.equal(valueOf('Tab width', 'diffPreferences')
           .firstElementChild.bindValue, diffPreferences.tab_size);
-      assert.equal(valueOf('Show Tabs', 'diffPreferences')
+      assert.equal(valueOf('Font size', 'diffPreferences')
+          .firstElementChild.bindValue, diffPreferences.font_size);
+      assert.equal(valueOf('Show tabs', 'diffPreferences')
           .firstElementChild.checked, diffPreferences.show_tabs);
+      assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.show_whitespace_errors);
+      assert.equal(valueOf('Fit to screen', 'diffPreferences')
+          .firstElementChild.checked, diffPreferences.line_wrapping);
 
       assert.isFalse(element._diffPrefsChanged);
 
-      var showTabsCheckbox = valueOf('Show Tabs', 'diffPreferences')
+      var showTabsCheckbox = valueOf('Show tabs', 'diffPreferences')
           .firstElementChild;
       showTabsCheckbox.checked = false;
       element._handleShowTabsChanged();
@@ -218,6 +245,16 @@
       });
     });
 
+    test('columns input is hidden with fit to scsreen is selected', function() {
+      assert.isFalse(element.$.columnsPref.hidden);
+
+      MockInteractions.tap(element.$.lineWrapping);
+      assert.isTrue(element.$.columnsPref.hidden);
+
+      MockInteractions.tap(element.$.lineWrapping);
+      assert.isFalse(element.$.columnsPref.hidden);
+    });
+
     test('menu', function(done) {
       assert.isFalse(element._menuChanged);
       assert.isFalse(element._prefsChanged);
@@ -314,5 +351,53 @@
         done();
       });
     });
+
+    test('emails are loaded without emailToken', function() {
+      sandbox.stub(element.$.emailEditor, 'loadData');
+      element.params = {};
+      element.attached();
+      assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+    });
+
+    suite('when email verification token is provided', function() {
+      var resolveConfirm;
+
+      setup(function() {
+        sandbox.stub(element.$.emailEditor, 'loadData');
+        sandbox.stub(element.$.restAPI, 'confirmEmail', function() {
+          return new Promise(function(resolve) { resolveConfirm = resolve; });
+        });
+        element.params = {emailToken: 'foo'};
+        element.attached();
+      });
+
+      test('it is used to confirm email via rest API', function() {
+        assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
+        assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
+      });
+
+      test('emails are not loaded initially', function() {
+        assert.isFalse(element.$.emailEditor.loadData.called);
+      });
+
+      test('user emails are loaded after email confirmed', function(done) {
+        element._loadingPromise.then(function() {
+          assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+          done();
+        });
+        resolveConfirm();
+      });
+
+      test('show-alert is fired when email is confirmed', function(done) {
+        sandbox.spy(element, 'fire');
+        element._loadingPromise.then(function() {
+          assert.isTrue(
+              element.fire.calledWith('show-alert', {message: 'bar'}));
+          done();
+        });
+        resolveConfirm('bar');
+      });
+
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
index 28de9d4..339c7a7 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -57,7 +57,7 @@
             <tr>
               <th class="commentHeader">Comment</th>
               <th class="statusHeader">Status</th>
-              <th class="keyHeader">Public Key</th>
+              <th class="keyHeader">Public key</th>
             </tr>
           </thead>
           <tbody>
@@ -87,7 +87,7 @@
               <span class="value">[[_keyToView.algorithm]]</span>
             </section>
             <section>
-              <span class="title">Public Key</span>
+              <span class="title">Public key</span>
               <span class="value publicKey">[[_keyToView.encoded_key]]</span>
             </section>
             <section>
@@ -101,14 +101,15 @@
         </gr-overlay>
         <gr-button
             on-tap="save"
-            disabled$="[[!hasUnsavedChanges]]">Save Changes</gr-button>
+            disabled$="[[!hasUnsavedChanges]]">Save changes</gr-button>
       </fieldset>
       <fieldset>
         <section>
-          <span class="title">New SSH Key</span>
+          <span class="title">New SSH key</span>
           <span class="value">
             <iron-autogrow-textarea
                 id="newKey"
+                autocomplete="on"
                 bind-value="{{_newKey}}"
                 placeholder="New SSH Key"></iron-autogrow-textarea>
           </span>
@@ -116,7 +117,7 @@
         <gr-button
             id="addButton"
             disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-            on-tap="_handleAddKey">Add New SSH Key</gr-button>
+            on-tap="_handleAddKey">Add new SSH key</gr-button>
       </fieldset>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
index b248029..7bb5528 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ssh-editor</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-ssh-editor.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-ssh-editor></gr-ssh-editor>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
index 66576a3..59e87b0 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-watched-projects-editor.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-watched-projects-editor></gr-watched-projects-editor>
@@ -138,7 +140,7 @@
       assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 1'));
       assert.isFalse(element._canAddProject({id: 'project b'}, 'filter 2'));
 
-      // Can add a projec that is in the list using a new filter.
+      // Can add a project that is in the list using a new filter.
       assert.isTrue(element._canAddProject({id: 'project b'}, 'filter 3'));
     });
 
@@ -181,10 +183,11 @@
 
     test('_handleRemoveProject', function() {
       assert.equal(element._projectsToRemove, 0);
-
       var button = element.$$('table tbody tr:nth-child(2) gr-button');
       MockInteractions.tap(button);
 
+      flushAsynchronousOperations();
+
       var rows = element.$$('table tbody').querySelectorAll('tr');
       assert.equal(rows.length, 3);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index 360c281..ddfdae7 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -44,21 +44,40 @@
       }
       gr-button.remove {
         background: #eee;
+        border: 0;
         color: #666;
         font-size: 1.7em;
         font-weight: normal;
         height: .6em;
         line-height: .6em;
         margin-left: .15em;
+        margin-top: -.05em;
         padding: 0;
         text-decoration: none;
       }
+      :host:focus {
+        border-color: transparent;
+        box-shadow: none;
+        outline: none;
+      }
+      :host:focus .container,
+      :host:focus gr-button {
+        background: #ccc;
+      }
+      .transparentBackground,
+      gr-button.transparentBackground {
+        background-color: transparent;
+      }
     </style>
-    <div class="container">
+    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <gr-account-link account="[[account]]"></gr-account-link>
       <gr-button
-          hidden$="[[!removable]]" hidden
-          class="remove" on-tap="_handleRemoveTap">×</gr-button>
+          id="remove"
+          hidden$="[[!removable]]"
+          hidden
+          aria-label="Remove"
+          class$="remove [[_getBackgroundClass(transparentBackground)]]"
+          on-tap="_handleRemoveTap">×</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 45bf8fe..33fc50e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -18,6 +18,19 @@
   Polymer({
     is: 'gr-account-chip',
 
+    /**
+     * Fired to indicate a key was pressed while this chip was focused.
+     *
+     * @event account-chip-keydown
+     */
+
+    /**
+     * Fired to indicate this chip should be removed, i.e. when the x button is
+     * clicked or when the remove function is called.
+     *
+     * @event remove
+     */
+
     properties: {
       account: Object,
       removable: {
@@ -28,6 +41,10 @@
         type: Boolean,
         reflectToAttribute: true,
       },
+      transparentBackground: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     ready: function() {
@@ -36,6 +53,10 @@
       }.bind(this));
     },
 
+    _getBackgroundClass: function(transparent) {
+      return transparent ? 'transparentBackground' : '';
+    },
+
     _handleRemoveTap: function(e) {
       e.preventDefault();
       this.fire('remove', {account: this.account});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index f136907..43721fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -42,10 +42,14 @@
       <span class="text">
         <span>[[account.name]]</span>
         <span hidden$="[[!_computeShowEmail(showEmail, account)]]">
-          ([[account.email]])
+          [[_computeEmailStr(account)]]
         </span>
+        <template is="dom-if" if="[[account.status]]">
+          <span>([[account.status]])</span>
+        </template>
       </span>
     </span>
   </template>
+  <script src="../../../scripts/util.js"></script>
   <script src="gr-account-label.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 98871cb..e9f18df 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -30,10 +30,13 @@
     },
 
     _computeAccountTitle: function(account) {
-      if (!account || !account.name) { return; }
-      var result = util.escapeHTML(account.name);
+      if (!account || (!account.name && !account.email)) { return; }
+      var result = '';
+      if (account.name) {
+        result += account.name;
+      }
       if (account.email) {
-        result += ' <' + util.escapeHTML(account.email) + '>';
+        result += ' <' + account.email + '>';
       }
       return result;
     },
@@ -41,5 +44,15 @@
     _computeShowEmail: function(showEmail, account) {
       return !!(showEmail && account && account.email);
     },
+
+    _computeEmailStr: function(account) {
+      if (!account || !account.email) {
+        return '';
+      }
+      if (account.name) {
+        return '(' + account.email + ')';
+      }
+      return account.email;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index eacd710..de7d6a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -24,6 +24,8 @@
 
 <link rel="import" href="gr-account-label.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-account-label></gr-account-label>
@@ -35,9 +37,23 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
       element = fixture('basic');
     });
 
+    test('null guard', function() {
+      assert.doesNotThrow(function() {
+        element.account = null;
+      });
+    });
+
+    test('missing email', function() {
+      assert.equal('', element._computeEmailStr({name: 'foo'}));
+    });
+
     test('computed fields', function() {
       assert.equal(element._computeAccountTitle(
           {
@@ -67,6 +83,10 @@
 
       assert.equal(element._computeShowEmail(
           false, undefined), false);
+
+      assert.equal(
+          element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
+      assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
     });
 
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
index d3585ef..20b6e3f 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-account-label/gr-account-label.html">
 
@@ -36,7 +37,8 @@
     <span>
       <a href$="[[_computeOwnerLink(account)]]">
         <gr-account-label account="[[account]]"
-            avatar-image-size="[[avatarImageSize]]"></gr-account-label>
+            avatar-image-size="[[avatarImageSize]]"
+            show-email="[[_computeShowEmail(account)]]"></gr-account-label>
       </a>
     </span>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index 058b27d..69beb78 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -25,10 +25,18 @@
       },
     },
 
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
     _computeOwnerLink: function(account) {
       if (!account) { return; }
       var accountID = account.email || account._account_id;
-      return '/q/owner:' + encodeURIComponent(accountID) + '+status:open';
+      return this.getBaseUrl() + '/q/owner:' + encodeURIComponent(accountID);
+    },
+
+    _computeShowEmail: function(account) {
+      return !!(account && !account.name);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index 2b5b831..5cc0600 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -20,10 +20,11 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-account-link.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-account-link></gr-account-link>
@@ -35,6 +36,9 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       element = fixture('basic');
     });
 
@@ -44,10 +48,14 @@
             _account_id: 123,
             email: 'andybons+gerrit@gmail.com'
           }),
-          '/q/owner:andybons%2Bgerrit%40gmail.com+status:open');
+          '/q/owner:andybons%2Bgerrit%40gmail.com');
 
       assert.equal(element._computeOwnerLink({_account_id: 42}),
-          '/q/owner:42+status:open');
+          '/q/owner:42');
+
+      assert.equal(element._computeShowEmail({name: 'asd'}), false);
+
+      assert.equal(element._computeShowEmail({}), true);
     });
 
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index cda2492..aeb7e5f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 
@@ -43,6 +44,9 @@
         cursor: pointer;
         padding: .5em .75em;
       }
+      li:focus {
+        outline: none;
+      }
       li.selected {
         background-color: #eee;
       }
@@ -54,16 +58,21 @@
         disabled$="[[disabled]]"
         bind-value="{{text}}"
         placeholder="[[placeholder]]"
-        on-keydown="_handleInputKeydown"
-        on-focus="_updateSuggestions"
+        on-keydown="_handleKeydown"
+        on-focus="_onInputFocus"
         autocomplete="off" />
     <div
         id="suggestions"
-        hidden$="[[_computeSuggestionsHidden(_suggestions)]]">
+        role="listbox"
+        hidden$="[[_computeSuggestionsHidden(_suggestions, _focused)]]">
       <ul>
         <template is="dom-repeat" items="[[_suggestions]]">
           <li
               data-index$="[[index]]"
+              tabindex="-1"
+              aria-label$="[[item.name]]"
+              on-keydown="_handleKeydown"
+              role="option"
               on-tap="_handleSuggestionTap">[[item.name]]</li>
         </template>
       </ul>
@@ -72,6 +81,8 @@
         id="cursor"
         index="{{_index}}"
         cursor-target-class="selected"
+        scroll-behavior="keep-visible"
+        focus-on-move
         stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
   </template>
   <script src="gr-autocomplete.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 0fc6b07..9ebb794 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  var TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+
   Polymer({
     is: 'gr-autocomplete',
 
@@ -29,6 +31,13 @@
      * @event cancel
      */
 
+    /**
+     * Fired on keydown to allow for custom hooks into autocomplete textbox
+     * behavior.
+     *
+     * @event input-keydown
+     */
+
     properties: {
 
       /**
@@ -76,6 +85,15 @@
         value: false,
       },
 
+      /**
+       * When true, tab key autocompletes but does not fire the commit event.
+       * See Issue 4556.
+       */
+      tabCompleteWithoutCommit: {
+        type: Boolean,
+        value: false,
+      },
+
       value: Object,
 
       /**
@@ -99,14 +117,19 @@
         value: false,
       },
 
+      _focused: {
+        type: Boolean,
+        value: false,
+      },
+
     },
 
     attached: function() {
-      this.listen(document.body, 'click', '_handleBodyClick');
+      this.listen(document.body, 'tap', '_handleBodyTap');
     },
 
     detached: function() {
-      this.unlisten(document.body, 'click', '_handleBodyClick');
+      this.unlisten(document.body, 'tap', '_handleBodyTap');
     },
 
     get focusStart() {
@@ -117,6 +140,10 @@
       this.$.input.focus();
     },
 
+    selectAll: function() {
+      this.$.input.setSelectionRange(0, this.$.input.value.length);
+    },
+
     clear: function() {
       this.text = '';
     },
@@ -131,6 +158,11 @@
       this._disableSuggestions = false;
     },
 
+    _onInputFocus: function() {
+      this._focused = true;
+      this._updateSuggestions();
+    },
+
     _updateSuggestions: function() {
       if (!this.text || this._disableSuggestions) { return; }
       if (this.text.length < this.threshold) {
@@ -153,8 +185,8 @@
       }.bind(this));
     },
 
-    _computeSuggestionsHidden: function(suggestions) {
-      return !suggestions.length;
+    _computeSuggestionsHidden: function(suggestions, focused) {
+      return !(suggestions.length && focused);
     },
 
     _computeClass: function(borderless) {
@@ -166,7 +198,12 @@
       return this.$.suggestions.querySelectorAll('li');
     },
 
-    _handleInputKeydown: function(e) {
+    /**
+     * _handleKeydown used for key handling in the this.$.input AND all child
+     * autocomplete options.
+     */
+    _handleKeydown: function(e) {
+      this._focused = true;
       switch (e.keyCode) {
         case 38: // Up
           e.preventDefault();
@@ -181,12 +218,21 @@
           this._cancel();
           break;
         case 9: // Tab
+          if (this._suggestions.length > 0) {
+            e.preventDefault();
+            this._commit(this.tabCompleteWithoutCommit);
+          }
+          break;
         case 13: // Enter
           e.preventDefault();
           this._commit();
-          this._suggestions = [];
           break;
+        default:
+          // For any normal keypress, return focus to the input to allow for
+          // unbroken user input.
+          this.$.input.focus();
       }
+      this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
     },
 
     _cancel: function() {
@@ -199,34 +245,45 @@
       var completed = suggestions[index].value;
       if (this.multi) {
         // Append the completed text to the end of the string.
-        var shortStr = this.text.substring(0, this.text.lastIndexOf(' ') + 1);
-        this.value = shortStr + completed;
+        // Allow spaces within quoted terms.
+        var tokens = this.text.match(TOKENIZE_REGEX);
+        tokens[tokens.length - 1] = completed;
+        this.value = tokens.join(' ');
       } else {
         this.value = completed;
       }
     },
 
-    _handleBodyClick: function(e) {
+    _handleBodyTap: function(e) {
       var eventPath = Polymer.dom(e).path;
       for (var i = 0; i < eventPath.length; i++) {
-        if (eventPath[i] == this) {
+        if (eventPath[i] === this) {
           return;
         }
       }
-      this._suggestions = [];
+      this._focused = false;
     },
 
     _handleSuggestionTap: function(e) {
+      e.stopPropagation();
       this.$.cursor.setCursor(e.target);
       this._commit();
+      this.focus();
     },
 
-    _commit: function() {
+    /**
+     * Commits the suggestion, optionally firing the commit event.
+     *
+     * @param {Boolean} silent Allows for silent committing of an autocomplete
+     *     suggestion in order to handle cases like tab-to-complete without
+     *     firing the commit event.
+     */
+    _commit: function(silent) {
       // Allow values that are not in suggestion list iff suggestions are empty.
       if (this._suggestions.length > 0) {
         this._updateValue(this._suggestions, this._index);
       } else {
-        this.value = this.text;
+        this.value = this.text || '';
       }
 
       var value = this.value;
@@ -242,7 +299,10 @@
         }
       }
 
-      this.fire('commit', {value: value});
+      this._suggestions = [];
+      if (!silent) {
+        this.fire('commit', {value: value});
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index f8b16b7..4234388 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-autocomplete.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-autocomplete></gr-autocomplete>
@@ -57,6 +59,7 @@
       element.text = 'blah';
 
       assert.isTrue(queryStub.called);
+      element._focused = true;
 
       promise.then(function() {
         assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
@@ -69,7 +72,6 @@
         }
 
         assert.notEqual(element.$.cursor.index, -1);
-
         done();
       });
     });
@@ -85,6 +87,7 @@
 
       assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
 
+      element._focused = true;
       element.text = 'blah';
 
       promise.then(function() {
@@ -93,8 +96,7 @@
         var cancelHandler = sinon.spy();
         element.addEventListener('cancel', cancelHandler);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27); // Esc
-
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
         assert.isTrue(cancelHandler.called);
         assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
 
@@ -117,7 +119,7 @@
 
       assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
       assert.equal(element.$.cursor.index, -1);
-
+      element._focused = true;
       element.text = 'blah';
 
       promise.then(function() {
@@ -128,19 +130,22 @@
 
         assert.equal(element.$.cursor.index, 0);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+            'down');
 
         assert.equal(element.$.cursor.index, 1);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+            'down');
 
         assert.equal(element.$.cursor.index, 2);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38); // Up
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
 
         assert.equal(element.$.cursor.index, 1);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
 
         assert.equal(element.value, 1);
         assert.isTrue(commitHandler.called);
@@ -163,7 +168,8 @@
         var commitHandler = sinon.spy();
         element.addEventListener('commit', commitHandler);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
 
         assert.isTrue(commitHandler.called);
         assert.equal(element.text, 'suggestion');
@@ -184,7 +190,8 @@
         var commitHandler = sinon.spy();
         element.addEventListener('commit', commitHandler);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
 
         assert.isTrue(commitHandler.called);
         assert.equal(element.text, '');
@@ -234,12 +241,77 @@
         var commitHandler = sinon.spy();
         element.addEventListener('commit', commitHandler);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
 
         assert.isTrue(commitHandler.called);
         assert.equal(element.text, 'blah 0');
         done();
       });
     });
+
+    test('tab key completes only when suggestions exist', function() {
+      var commitStub = sinon.stub(element, '_commit');
+      element._suggestions = [];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      assert.isFalse(commitStub.called);
+      element._suggestions = ['tunnel snakes rule!'];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      assert.isTrue(commitStub.called);
+      commitStub.restore();
+    });
+
+    test('tabCompleteWithoutCommit flag functions', function() {
+      var commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+      element._suggestions = ['tunnel snakes rule!'];
+      element.tabCompleteWithoutCommit = true;
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      assert.isFalse(commitHandler.called);
+      element.tabCompleteWithoutCommit = false;
+      element._suggestions = ['tunnel snakes rule!'];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      assert.isTrue(commitHandler.called);
+    });
+
+    test('_focused flag properly triggered', function(done) {
+      flush(function() {
+        assert.isFalse(element._focused);
+        var input = element.$$('input');
+        MockInteractions.focus(input);
+        assert.isTrue(element._focused);
+        done();
+      });
+    });
+
+    test('_focused flag shows/hides the suggestions', function() {
+      var suggestions = ['hello', 'its me'];
+      assert.isTrue(element._computeSuggestionsHidden(suggestions, false));
+      assert.isFalse(element._computeSuggestionsHidden(suggestions, true));
+    });
+
+    test('tap on suggestion commits and refocuses on input', function() {
+      var focusSpy = sinon.spy(element, 'focus');
+      var commitSpy = sinon.spy(element, '_commit');
+      element._focused = true;
+      element._suggestions = [{name: 'first suggestion'}];
+      assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+      MockInteractions.tap(element.$$('#suggestions li:first-child'));
+      flushAsynchronousOperations();
+      assert.isTrue(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.isTrue(element._focused);
+      focusSpy.restore();
+      commitSpy.restore();
+    });
+
+    test('input-keydown event fired', function() {
+      var listener = sinon.spy();
+      element.addEventListener('input-keydown', listener);
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      flushAsynchronousOperations();
+      assert.isTrue(listener.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
index 55655c0..9e5accd 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index 38e9924..88f7dfe 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -28,6 +28,10 @@
       },
     },
 
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
     created: function() {
       this.hidden = true;
     },
@@ -64,7 +68,8 @@
           return avatars[i].url;
         }
       }
-      return '/accounts/' + account._account_id + '/avatar?s=' + this.imageSize;
+      return this.getBaseUrl() + '/accounts/' + account._account_id +
+          '/avatar?s=' + this.imageSize;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
index ae514ba..fd05d62 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -23,6 +23,8 @@
 
 <link rel="import" href="gr-avatar.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-avatar></gr-avatar>
@@ -34,6 +36,9 @@
     var element;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
       element = fixture('basic');
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index c815ffd..2ec32d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -16,13 +16,13 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 
 <dom-module id="gr-button">
   <template strip-whitespace>
     <style>
       :host {
-        background-color: #fff;
+        background-color: #f5f5f5;
         border: 1px solid #d1d2d3;
         border-radius: 2px;
         box-sizing: border-box;
@@ -30,10 +30,10 @@
         cursor: pointer;
         display: inline-block;
         font-family: var(--font-family);
-        font-size: 13px;
+        font-size: 12px;
         font-weight: bold;
         outline-width: 0;
-        padding: .3em .65em;
+        padding: .4em .85em;
         position: relative;
         text-align: center;
         -moz-user-select: none;
@@ -44,10 +44,17 @@
       :host([hidden]) {
         display: none;
       }
+      :host([primary]),
+      :host([secondary]) {
+        color: #fff;
+      }
       :host([primary]) {
         background-color: #4d90fe;
         border-color: #3079ed;
-        color: #fff;
+      }
+      :host([secondary]) {
+        background-color: #d14836;
+        border-color: transparent;
       }
       :host([small]) {
         font-size: 12px;
@@ -68,38 +75,62 @@
       }
       :host([disabled]) {
         cursor: default;
-        pointer-events: none;
       }
       :host([loading]),
       :host([loading][disabled]) {
         cursor: wait;
       }
-      :host(:focus),
-      :host(:hover) {
-        border-color: #666;
+      :host:focus:not([link]),
+      :host:hover:not([link]) {
+        background-color: #f8f8f8;
+        border-color: #aaa;
       }
       :host(:active) {
         border-color: #d1d2d3;
         color: #aaa;
       }
-      :host([primary]:focus) {
-        border-color: #fff;
-        box-shadow: 0 0 1px #00f;
+      :host([primary]:focus),
+      :host([secondary]:focus),
+      :host([primary]:active),
+      :host([secondary]:active) {
+        color: #fff;
       }
-      :host([primary]:hover) {
+      :host([primary]:focus) {
+        box-shadow: 0 0 1px #00f;
+        background-color: #4d90fe;
+      }
+      :host([primary]:not([disabled]):hover) {
+        background-color: #4d90fe;
         border-color: #00F;
       }
+      :host([primary]:active),
+      :host([secondary]:active) {
+        box-shadow: none;
+      }
       :host([primary]:active) {
         border-color: #0c2188;
-        box-shadow: none;
-        color: #fff;
       }
-      :host([primary][loading]),
-      :host([primary][disabled]) {
+      :host([secondary]:focus) {
+        box-shadow: 0 0 1px #f00;
+        background-color: #d14836;
+      }
+      :host([secondary]:not([disabled]):hover) {
+        background-color: #c53727;
+        border: 1px solid #b0281a;
+      }
+      :host([secondary]:active) {
+        border-color: #941c0c;
+      }
+      :host([primary][loading]) {
         background-color: #7caeff;
         border-color: transparent;
         color: #fff;
       }
+      :host([primary][disabled]) {
+        background-color: #4d90fe;
+        color: #fff;
+        opacity: .5;
+      }
     </style>
     <content></content>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index e109896..800b1df 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -18,6 +18,10 @@
     is: 'gr-button',
 
     properties: {
+      link: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
       disabled: {
         type: Boolean,
         observer: '_disabledChanged',
@@ -29,6 +33,11 @@
       },
     },
 
+    listeners: {
+      'tap': '_handleAction',
+      'click': '_handleAction',
+    },
+
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.TooltipBehavior,
@@ -39,6 +48,17 @@
       tabindex: '0',
     },
 
+    keyBindings: {
+      'space enter': '_handleCommitKey',
+    },
+
+    _handleAction: function(e) {
+      if (this.disabled) {
+        e.preventDefault();
+        e.stopImmediatePropagation();
+      }
+    },
+
     _disabledChanged: function(disabled) {
       if (disabled) {
         this._enabledTabindex = this.getAttribute('tabindex');
@@ -46,13 +66,9 @@
       this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
     },
 
-    _handleKey: function(e) {
-      switch (e.keyCode) {
-        case 32:  // 'spacebar'
-        case 13:  // 'enter'
-          e.preventDefault();
-          this.click();
-      }
+    _handleCommitKey: function(e) {
+      e.preventDefault();
+      this.click();
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
new file mode 100644
index 0000000..c269cb5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -0,0 +1,95 @@
+<!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-button</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-button.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-button></gr-button>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-select tests', function() {
+    var element;
+    var sandbox;
+
+    var addSpyOn = function(eventName) {
+      var spy = sandbox.spy();
+      element.addEventListener(eventName, spy);
+      return spy;
+    };
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    ['tap', 'click'].forEach(function(eventName) {
+      test('dispatches ' + eventName + ' event', function() {
+        var spy = addSpyOn(eventName);
+        MockInteractions.tap(element);
+        assert.isTrue(spy.calledOnce);
+      });
+    });
+
+    // Keycodes: 32 for Space, 13 for Enter.
+    [32, 13].forEach(function(key) {
+      test('dispatches tap event on keycode ' + key, function() {
+        var tapSpy = sandbox.spy();
+        element.addEventListener('tap', tapSpy);
+        MockInteractions.pressAndReleaseKeyOn(element, key);
+        assert.isTrue(tapSpy.calledOnce);
+      })});
+
+    suite('disabled', function() {
+      setup(function() {
+        element.disabled = true;
+      });
+
+      ['tap', 'click'].forEach(function(eventName) {
+        test('stops ' + eventName + ' event', function() {
+          var spy = addSpyOn(eventName);
+          MockInteractions.tap(element);
+          assert.isFalse(spy.called);
+        });
+      });
+
+      // Keycodes: 32 for Space, 13 for Enter.
+      [32, 13].forEach(function(key) {
+        test('stops tap event on keycode ' + key, function() {
+          var tapSpy = sandbox.spy();
+          element.addEventListener('tap', tapSpy);
+          MockInteractions.pressAndReleaseKeyOn(element, key);
+          assert.isFalse(tapSpy.called);
+        })});
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
index 05fe10b..03be4bb 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
@@ -42,7 +42,10 @@
         fill: #ffac33;
       }
     </style>
-    <button class$="[[_computeStarClass(change.starred)]]" on-tap="_handleStarTap">
+    <button
+        class$="[[_computeStarClass(change.starred)]]"
+        aria-label="Change star"
+        on-tap="toggleStar">
       <!-- Public Domain image from the Noun Project: https://thenounproject.com/search/?q=star&i=25969 -->
       <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve"><path d="M26.439,95.601c-5.608,2.949-9.286,0.276-8.216-5.968l4.5-26.237L3.662,44.816c-4.537-4.423-3.132-8.746,3.137-9.657  l26.343-3.829L44.923,7.46c2.804-5.682,7.35-5.682,10.154,0l11.78,23.87l26.343,3.829c6.27,0.911,7.674,5.234,3.138,9.657  L77.277,63.397l4.501,26.237c1.07,6.244-2.608,8.916-8.216,5.968L50,83.215L26.439,95.601z"></path></svg>
     </button>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index 23c56b4..beb0ff1 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -34,7 +34,7 @@
       return classes.join(' ');
     },
 
-    _handleStarTap: function() {
+    toggleStar: function() {
       var newVal = !this.change.starred;
       this.set('change.starred', newVal);
       this._xhrPromise = this.$.restAPI.saveChangeStarred(this.change._number,
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
index 969f7dd..460d860 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -24,6 +24,8 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-star.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-change-star></gr-change-star>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
index d8fc1df..bec75ee 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
@@ -22,11 +22,23 @@
     <style>
       :host {
         display: block;
+        max-height: 90vh;
+      }
+      .container {
+        display: flex;
+        flex-direction: column;
+        max-height: 90vh;
       }
       header {
         border-bottom: 1px solid #ddd;
+        flex-shrink: 0;
         font-weight: bold;
       }
+      main {
+        display: flex;
+        flex-shrink: 1;
+        width: 100%;
+      }
       header,
       main,
       footer {
@@ -34,15 +46,20 @@
       }
       footer {
         display: flex;
+        flex-shrink: 0;
         justify-content: space-between;
       }
     </style>
-    <header><content select=".header"></content></header>
-    <main><content select=".main"></content></main>
-    <footer>
-      <gr-button primary on-tap="_handleConfirmTap">[[confirmLabel]]</gr-button>
-      <gr-button on-tap="_handleCancelTap">Cancel</gr-button>
-    </footer>
+    <div class="container">
+      <header><content select=".header"></content></header>
+      <main><content select=".main"></content></main>
+      <footer>
+        <gr-button primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
+          [[confirmLabel]]
+        </gr-button>
+        <gr-button on-tap="_handleCancelTap">Cancel</gr-button>
+      </footer>
+    </div>
   </template>
   <script src="gr-confirm-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
index 0f20e0a..dbddb04 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
@@ -33,7 +33,11 @@
       confirmLabel: {
         type: String,
         value: 'Confirm',
-      }
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     hostAttributes: {
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
index 812f32a..3eec979 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
@@ -18,12 +18,14 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-dialog</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-confirm-dialog.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-confirm-dialog></gr-confirm-dialog>
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 0d3ea3d..9bfdcfb 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
@@ -15,7 +15,6 @@
   'use strict';
 
   var ScrollBehavior = {
-    ALWAYS: 'always',
     NEVER: 'never',
     KEEP_VISIBLE: 'keep-visible',
   };
@@ -36,6 +35,10 @@
         notify: true,
         observer: '_scrollToTarget',
       },
+      /**
+       * The height of content intended to be included with the target.
+       */
+      _targetHeight: Number,
 
       /**
        * The index of the current target (if any). -1 otherwise.
@@ -55,22 +58,21 @@
       },
 
       /**
-       * The scroll behavior for the cursor. Values are 'never', 'always' and
+       * The scroll behavior for the cursor. Values are 'never' and
        * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
        * the viewport.
        */
-      scroll: {
+      scrollBehavior: {
         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.
+       * When true, will call element.focus() during scrolling.
        */
-      foldOffsetTop: {
-        type: Number,
-        value: 0,
+      focusOnMove: {
+        type: Boolean,
+        value: false,
       },
     },
 
@@ -78,8 +80,8 @@
       this.unsetCursor();
     },
 
-    next: function(opt_condition) {
-      this._moveCursor(1, opt_condition);
+    next: function(opt_condition, opt_getTargetHeight) {
+      this._moveCursor(1, opt_condition, opt_getTargetHeight);
     },
 
     previous: function(opt_condition) {
@@ -89,18 +91,29 @@
     /**
      * Set the cursor to an arbitrary element.
      * @param {DOMElement} element
+     * @param {boolean} opt_noScroll prevent any potential scrolling in response
+     *   setting the cursor.
      */
-    setCursor: function(element) {
+    setCursor: function(element, opt_noScroll) {
+      var behavior;
+      if (opt_noScroll) {
+        behavior = this.scrollBehavior;
+        this.scrollBehavior = ScrollBehavior.NEVER;
+      }
+
       this.unsetCursor();
       this.target = element;
       this._updateIndex();
       this._decorateTarget();
+
+      if (opt_noScroll) { this.scrollBehavior = behavior; }
     },
 
     unsetCursor: function() {
       this._unDecorateTarget();
       this.index = -1;
       this.target = null;
+      this._targetHeight = null;
     },
 
     isAtStart: function() {
@@ -117,6 +130,10 @@
       }
     },
 
+    setCursorAtIndex: function(index, opt_noScroll) {
+      this.setCursor(this.stops[index], opt_noScroll);
+    },
+
     /**
      * Move the cursor forward or backward by delta. Noop if moving past either
      * end of the stop list.
@@ -124,9 +141,12 @@
      * @param {Function} opt_condition Optional stop condition. If a condition
      *    is passed the cursor will continue to move in the specified direction
      *    until the condition is met.
+     * @param {Function} opt_getTargetHeight Optional function to calculate the
+     *    height of the target's 'section'. The height of the target itself is
+     *    sometimes different, used by the diff cursor.
      * @private
      */
-    _moveCursor: function(delta, opt_condition) {
+    _moveCursor: function(delta, opt_condition, opt_getTargetHeight) {
       if (!this.stops.length) {
         this.unsetCursor();
         return;
@@ -141,9 +161,17 @@
         newTarget = this.stops[newIndex];
       }
 
+      if (opt_getTargetHeight) {
+        this._targetHeight = opt_getTargetHeight(newTarget);
+      } else {
+        this._targetHeight = newTarget.scrollHeight;
+      }
+
       this.index = newIndex;
       this.target = newTarget;
 
+      if (this.focusOnMove) { this.target.focus(); }
+
       this._decorateTarget();
     },
 
@@ -182,6 +210,11 @@
 
       // If we failed to satisfy the condition:
       if (opt_condition && !opt_condition(this.stops[newIndex])) {
+        if (delta > 0) {
+          return this.stops.length - 1;
+        } else if (delta < 0) {
+          return 0;
+        }
         return this.index;
       }
 
@@ -202,27 +235,59 @@
       }
     },
 
-    _scrollToTarget: function() {
-      if (!this.target || this.scroll === ScrollBehavior.NEVER) { return; }
-
-      // Calculate where the element is relative to the window.
-      var top = this.target.offsetTop;
-      for (var offsetParent = this.target.offsetParent;
+    /**
+     * Calculate where the element is relative to the window.
+     * @param {object} target Target to scroll to.
+     * @return {number} Distance to top of the target.
+     */
+    _getTop: function(target) {
+      var top = target.offsetTop;
+      for (var offsetParent = target.offsetParent;
            offsetParent;
            offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
       }
+      return top;
+    },
 
-      if (this.scroll === ScrollBehavior.KEEP_VISIBLE &&
-          top > window.pageYOffset + this.foldOffsetTop &&
-          top < window.pageYOffset + window.innerHeight) { return; }
+    /**
+     * @return {boolean}
+     */
+    _targetIsVisible: function(top) {
+      return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
+          top > window.pageYOffset &&
+          top < window.pageYOffset + window.innerHeight;
+    },
+
+    _calculateScrollToValue: function(top, target) {
+      return top - (window.innerHeight / 3) + (target.offsetHeight / 2);
+    },
+
+    _scrollToTarget: function() {
+      if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
+        return;
+      }
+
+      var top = this._getTop(this.target);
+      var bottomIsVisible = this._targetHeight ?
+          this._targetIsVisible(top + this._targetHeight) : true;
+      var scrollToValue = this._calculateScrollToValue(top, this.target);
+
+      if (this._targetIsVisible(top)) {
+        // Don't scroll if either the bottom is visible or if the position that
+        // would get scrolled to is higher up than the current position. this
+        // woulld cause less of the target content to be displayed than is
+        // already.
+        if (bottomIsVisible || scrollToValue < window.scrollY) {
+          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
       // isn't.
-      window.scrollTo(0, top - (window.innerHeight / 3) +
-          (this.target.offsetHeight / 2));
+      window.scrollTo(0, scrollToValue);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
index 1ad014d..5d9af80 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -18,17 +18,17 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cursor-manager</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-cursor-manager.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
-    <gr-cursor-manager
-        cursor-stop-selector="li"
-        cursor-target-class="targeted"></gr-cursor-manager>
+    <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
     <ul>
       <li>A</li>
       <li>B</li>
@@ -40,15 +40,21 @@
 
 <script>
   suite('gr-cursor-manager tests', function() {
+    var sandbox;
     var element;
     var list;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       var fixtureElements = fixture('basic');
       element = fixtureElements[0];
       list = fixtureElements[1];
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('core cursor functionality', function() {
       // The element is initialized into the proper state.
       assert.isArray(element.stops);
@@ -120,5 +126,138 @@
       assert.isNotOk(element.target);
       assert.equal(element.index, -1);
     });
+
+
+    test('_moveCursor', function() {
+      // Initialize the cursor with its stops.
+      element.stops = list.querySelectorAll('li');
+      // Select the first stop.
+      element.setCursor(list.children[0]);
+      var getTargetHeight = sinon.stub();
+
+      // Move the cursor without an optional get target height function.
+      element._moveCursor(1);
+      assert.isFalse(getTargetHeight.called);
+
+      // Move the cursor with an optional get target height function.
+      element._moveCursor(1, null, getTargetHeight);
+      assert.isTrue(getTargetHeight.called);
+    });
+
+    test('opt_noScroll', function() {
+      sandbox.stub(element, '_targetIsVisible', function() { return false; });
+      var scrollStub = sandbox.stub(window, 'scrollTo');
+      element.stops = list.querySelectorAll('li');
+      element.scrollBehavior = 'keep-visible';
+
+      element.setCursorAtIndex(1, true);
+      assert.isFalse(scrollStub.called);
+
+      element.setCursorAtIndex(2);
+      assert.isTrue(scrollStub.called);
+    });
+
+    test('_getNextindex', function() {
+      var isLetterB = function(row) {
+        return row.textContent === 'B';
+      };
+      element.stops = list.querySelectorAll('li');
+      // Start cursor at the first stop.
+      element.setCursor(list.children[0]);
+
+      // Move forward to meet the next condition.
+      assert.equal(element._getNextindex(1, isLetterB), 1);
+      element.index = 1;
+
+      // Nothing else meets the condition, should be at last stop.
+      assert.equal(element._getNextindex(1, isLetterB), 3);
+      element.index = 3;
+
+      // Should stay at last stop if try to proceed.
+      assert.equal(element._getNextindex(1, isLetterB), 3);
+
+      // Go back to the previous condition met. Should be back at.
+      // stop 1.
+      assert.equal(element._getNextindex(-1, isLetterB), 1);
+      element.index = 1;
+
+      // Go back. No more meet the condition. Should be at stop 0.
+      assert.equal(element._getNextindex(-1, isLetterB), 0);
+    });
+
+    test('focusOnMove prop', function() {
+      var listEls = list.querySelectorAll('li');
+      for (var i = 0; i < listEls.length; i++) {
+        sandbox.spy(listEls[i], 'focus');
+      }
+      element.stops = listEls;
+      element.setCursor(list.children[0]);
+
+      element.focusOnMove = false;
+      element.next();
+      assert.isFalse(element.target.focus.called);
+
+      element.focusOnMove = true;
+      element.next();
+      assert.isTrue(element.target.focus.called);
+    });
+
+    suite('_scrollToTarget', function() {
+      var scrollStub;
+      setup(function() {
+        element.stops = list.querySelectorAll('li');
+        element.scrollBehavior = 'keep-visible';
+
+        // There is a target which has a targetNext
+        element.setCursor(list.children[0]);
+        element._moveCursor(1);
+        scrollStub = sandbox.stub(window, 'scrollTo');
+        window.innerHeight = 60;
+      });
+
+      test('Called when top and bottom not visible', function() {
+        sandbox.stub(element, '_targetIsVisible', function() {
+          return false;
+        });
+        element._scrollToTarget();
+        assert.isTrue(scrollStub.called);
+      });
+
+      test('Not called when top and bottom visible', function() {
+        sandbox.stub(element, '_targetIsVisible', function() {
+          return true;
+        });
+        element._scrollToTarget();
+        assert.isFalse(scrollStub.called);
+      });
+
+      test('Called when top is visible, bottom is not, and scroll is lower',
+          function() {
+        var visibleStub = sandbox.stub(element, '_targetIsVisible', function() {
+          return visibleStub.callCount == 2;
+        });
+        window.scrollY = 15;
+        sandbox.stub(element, '_calculateScrollToValue', function() {
+          return 20;
+        });
+        element._scrollToTarget();
+        assert.isTrue(scrollStub.called);
+        assert.equal(visibleStub.callCount, 2);
+      });
+
+      test('Called when top is visible, bottom is not, and scroll is higher',
+          function() {
+        var visibleStub = sandbox.stub(element, '_targetIsVisible', function() {
+          return visibleStub.callCount == 2;
+        });
+        window.scrollY = 25;
+        sandbox.stub(element, '_calculateScrollToValue', function() {
+          return 20;
+        });
+        element._scrollToTarget();
+        assert.isFalse(scrollStub.called);
+        assert.equal(visibleStub.callCount, 2);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
index 3d0cf5a..4d31241 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
@@ -15,6 +15,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <script src="../../../bower_components/moment/moment.js"></script>
@@ -26,8 +27,9 @@
         display: inline;
       }
     </style>
-    <span title$="[[_computeFullDateStr(dateStr, _timeFormat)]]"
-        >[[_computeDateStr(dateStr, _timeFormat, _relative)]]</span>
+    <span>
+      [[_computeDateStr(dateStr, _timeFormat, _relative)]]
+    </span>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-date-formatter.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 5b12c8f..6f4cd3d 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -21,7 +21,9 @@
 
   var TimeFormats = {
     TIME_12: 'h:mm A', // 2:14 PM
-    TIME_24: 'H:mm', // 14:14
+    TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
+    TIME_24: 'HH:mm', // 14:14
+    TIME_24_WITH_SEC: 'HH:mm:ss', // 14:14:00
     MONTH_DAY: 'MMM DD', // Aug 29
     MONTH_DAY_YEAR: 'MMM DD, YYYY', // Aug 29, 1997
   };
@@ -36,14 +38,37 @@
         notify: true,
       },
 
+      /**
+       * When true, the detailed date appears in a GR-TOOLTIP rather than in the
+       * native browser tooltip.
+       */
+      hasTooltip: Boolean,
+
+      /**
+       * The title to be used as the native tooltip or by the tooltip behavior.
+       */
+      title: {
+        type: String,
+        reflectToAttribute: true,
+        computed: '_computeFullDateStr(dateStr, _timeFormat)',
+      },
+
       _timeFormat: String, // No default value to prevent flickering.
       _relative: Boolean, // No default value to prevent flickering.
     },
 
+    behaviors: [
+      Gerrit.TooltipBehavior,
+    ],
+
     attached: function() {
       this._loadPreferences();
     },
 
+    _getUtcOffsetString: function() {
+      return ' UTC' + moment().format('Z');
+    },
+
     _loadPreferences: function() {
       return this._getLoggedIn().then(function(loggedIn) {
         if (!loggedIn) {
@@ -111,7 +136,12 @@
       var date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
       if (relative) {
-        return date.fromNow();
+        var dateFromNow = date.fromNow();
+        if (dateFromNow === 'a few seconds ago') {
+          return 'just now';
+        } else {
+          return dateFromNow;
+        }
       }
       var now = new Date();
       var format = TimeFormats.MONTH_DAY_YEAR;
@@ -123,11 +153,19 @@
       return date.format(format);
     },
 
+    _timeToSecondsFormat: function(timeFormat) {
+      return timeFormat === TimeFormats.TIME_12 ?
+          TimeFormats.TIME_12_WITH_SEC :
+          TimeFormats.TIME_24_WITH_SEC;
+    },
+
     _computeFullDateStr: function(dateStr, timeFormat) {
       if (!dateStr) { return ''; }
       var date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
-      return date.format(TimeFormats.MONTH_DAY_YEAR + ', ' + timeFormat);
+      var format = TimeFormats.MONTH_DAY_YEAR + ', ';
+      format += this._timeToSecondsFormat(timeFormat);
+      return date.format(format) + this._getUtcOffsetString();
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index d1886e7..4c6dfdf 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -24,6 +24,8 @@
 
 <link rel="import" href="gr-date-formatter.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
@@ -33,6 +35,15 @@
 <script>
   suite('gr-date-formatter tests', function() {
     var element;
+    var sandbox;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
 
     /**
      * Parse server-formatter date and normalize into current timezone.
@@ -47,13 +58,12 @@
       // Normalize and convert the date to mimic server response.
       dateStr = normalizedDate(dateStr)
           .toJSON().replace('T', ' ').slice(0, -1);
-      var clock = sinon.useFakeTimers(normalizedDate(nowStr).getTime());
+      sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
       element.dateStr = dateStr;
       flush(function() {
         var span = element.$$('span');
-        assert.equal(span.textContent, expected);
-        assert.equal(span.title, expectedTooltip);
-        clock.restore();
+        assert.equal(span.textContent.trim(), expected);
+        assert.equal(element.title, expectedTooltip);
         done();
       });
     }
@@ -69,12 +79,13 @@
     }
 
     suite('24 hours time format preference', function() {
-      setup(function(done) {
+      setup(function() {
         return stubRestAPI(
           {time_format: 'HHMM_24', relative_date_in_change_table: false}
         ).then(function() {
           element = fixture('basic');
-          element._loadPreferences().then(function() { done(); });
+          sandbox.stub(element, '_getUtcOffsetString').returns('');
+          return element._loadPreferences();
         });
       });
 
@@ -85,77 +96,79 @@
 
       test('Within 24 hours on same day', function(done) {
         testDates('2015-07-29 20:34:14.985000000',
-                  '2015-07-29 15:34:14.985000000',
-                  '15:34', 'Jul 29, 2015, 15:34', done);
+            '2015-07-29 15:34:14.985000000',
+            '15:34', 'Jul 29, 2015, 15:34:14', done);
       });
 
       test('Within 24 hours on different days', function(done) {
         testDates('2015-07-29 03:34:14.985000000',
-                  '2015-07-28 20:25:14.985000000',
-                  'Jul 28', 'Jul 28, 2015, 20:25', done);
+            '2015-07-28 20:25:14.985000000',
+            'Jul 28', 'Jul 28, 2015, 20:25:14', done);
       });
 
       test('More than 24 hours but less than six months', function(done) {
         testDates('2015-07-29 20:34:14.985000000',
-                  '2015-06-15 03:25:14.985000000',
-                  'Jun 15', 'Jun 15, 2015, 3:25', done);
+            '2015-06-15 03:25:14.985000000',
+            'Jun 15', 'Jun 15, 2015, 03:25:14', done);
       });
 
       test('More than six months', function(done) {
         testDates('2015-09-15 20:34:00.000000000',
-                  '2015-01-15 03:25:00.000000000',
-                  'Jan 15, 2015', 'Jan 15, 2015, 3:25', done);
+            '2015-01-15 03:25:00.000000000',
+            'Jan 15, 2015', 'Jan 15, 2015, 03:25:00', done);
       });
     });
 
     suite('12 hours time format preference', function() {
-      setup(function(done) {
+      setup(function() {
         // relative_date_in_change_table is not set when false.
         return stubRestAPI(
           {time_format: 'HHMM_12'}
         ).then(function() {
           element = fixture('basic');
-          element._loadPreferences().then(function() { done(); });
+          sandbox.stub(element, '_getUtcOffsetString').returns('');
+          return element._loadPreferences();
         });
       });
 
       test('Within 24 hours on same day', function(done) {
         testDates('2015-07-29 20:34:14.985000000',
-                  '2015-07-29 15:34:14.985000000',
-                  '3:34 PM', 'Jul 29, 2015, 3:34 PM', done);
+            '2015-07-29 15:34:14.985000000',
+            '3:34 PM', 'Jul 29, 2015, 3:34:14 PM', done);
       });
     });
 
     suite('relative date preference', function() {
-      setup(function(done) {
+      setup(function() {
         return stubRestAPI(
           {time_format: 'HHMM_12', relative_date_in_change_table: true}
         ).then(function() {
           element = fixture('basic');
-          element._loadPreferences().then(function() { done(); });
+          sandbox.stub(element, '_getUtcOffsetString').returns('');
+          return element._loadPreferences();
         });
       });
 
       test('Within 24 hours on same day', function(done) {
         testDates('2015-07-29 20:34:14.985000000',
-                  '2015-07-29 15:34:14.985000000',
-                  '5 hours ago', 'Jul 29, 2015, 3:34 PM', done);
+            '2015-07-29 15:34:14.985000000',
+            '5 hours ago', 'Jul 29, 2015, 3:34:14 PM', done);
       });
 
       test('More than six months', function(done) {
         testDates('2015-09-15 20:34:00.000000000',
-                  '2015-01-15 03:25:00.000000000',
-                  '8 months ago', 'Jan 15, 2015, 3:25 AM', done);
+            '2015-01-15 03:25:00.000000000',
+            '8 months ago', 'Jan 15, 2015, 3:25:00 AM', done);
       });
     });
 
     suite('logged in', function() {
-      setup(function(done) {
+      setup(function() {
         return stubRestAPI(
           {time_format: 'HHMM_12', relative_date_in_change_table: true}
         ).then(function() {
           element = fixture('basic');
-          element._loadPreferences().then(function() { done(); });
+          return element._loadPreferences();
         });
       });
 
@@ -166,15 +179,15 @@
     });
 
     suite('logged out', function() {
-      setup(function(done) {
+      setup(function() {
         return stubRestAPI(null).then(function() {
           element = fixture('basic');
-          element._loadPreferences().then(function() { done(); });
+          return element._loadPreferences();
         });
       });
 
       test('Default preferences are respected', function() {
-        assert.equal(element._timeFormat, 'H:mm');
+        assert.equal(element._timeFormat, 'HH:mm');
         assert.isFalse(element._relative);
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
new file mode 100644
index 0000000..e89bf05
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -0,0 +1,153 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-dropdown">
+  <template>
+    <style>
+      :host {
+        display: inline-block;
+      }
+      .dropdown-trigger {
+        text-decoration: none;
+        width: 100%;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
+      button {
+        background: none;
+        border: none;
+        font: inherit;
+        padding: .3em 0;
+      }
+      :host[down-arrow] .dropdown-trigger {
+        padding-right: 1.4em;
+      }
+      gr-avatar {
+        height: 2em;
+        width: 2em;
+        vertical-align: middle;
+      }
+      gr-button[link] {
+        padding: 1em 0;
+      }
+      ul {
+        list-style: none;
+      }
+      ul .accountName {
+        font-weight: bold;
+      }
+      li .accountInfo,
+      li .itemAction {
+        cursor: pointer;
+        display: block;
+        padding: .85em 1em;
+      }
+      li .itemAction.disabled {
+        color: #ccc;
+        cursor: default;
+      }
+      li .itemAction:link,
+      li .itemAction:visited {
+        color: #00e;
+        text-decoration: none;
+      }
+      li .itemAction:not(.disabled):hover {
+        background-color: #6B82D6;
+        color: #fff;
+      }
+      .topContent {
+        display: block;
+        padding: .85em 1em;
+      }
+      .bold-text {
+        font-weight: bold;
+      }
+      :host:not([down-arrow]) .downArrow { display: none; }
+      :host[down-arrow] .downArrow {
+        border-left: .36em solid transparent;
+        border-right: .36em solid transparent;
+        border-top: .36em solid #ccc;
+        height: 0;
+        position: absolute;
+        right: .3em;
+        top: calc(50% - .05em);
+        transition: border-top-color 200ms;
+        width: 0;
+      }
+      .dropdown-trigger:hover .downArrow {
+        border-top-color: #666;
+      }
+    </style>
+    <gr-button link="[[link]]" class="dropdown-trigger" id="trigger"
+        on-tap="_showDropdownTapHandler">
+      <content></content>
+       <i class="downArrow"></i>
+    </gr-button>
+    <iron-dropdown id="dropdown"
+        vertical-align="top"
+        vertical-offset="[[verticalOffset]]"
+        allow-outside-scroll="true"
+        horizontal-align="[[horizontalAlign]]"
+        on-tap="_handleDropdownTap">
+      <div class="dropdown-content">
+        <ul>
+          <template is="dom-if" if="[[topContent]]">
+            <div class="topContent">
+              <template
+                  is="dom-repeat"
+                  items="[[topContent]]"
+                  as="item"
+                  initial-count="75">
+                <div class$="[[_getClassIfBold(item.bold)]] top-item">
+                  [[item.text]]
+                </div>
+              </template>
+            </div>
+          </template>
+          <template
+              is="dom-repeat"
+              items="[[items]]"
+              as="link"
+              initial-count="75">
+            <li>
+              <span
+                  class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
+                  data-id$="[[link.id]]"
+                  on-tap="_handleItemTap"
+                  hidden$="[[link.url]]">[[link.name]]</span>
+              <a
+                  class="itemAction"
+                  href$="[[_computeLinkURL(link)]]"
+                  rel$="[[_computeLinkRel(link)]]"
+                  target$="[[link.target]]"
+                  hidden$="[[!link.url]]">[[link.name]]</a>
+            </li>
+          </template>
+        </ul>
+      </div>
+    </iron-dropdown>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-dropdown.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
new file mode 100644
index 0000000..bf587d7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-dropdown',
+
+    /**
+     * Fired when a non-link dropdown item with the given ID is tapped.
+     *
+     * @event tap-item-<id>
+     */
+
+    properties: {
+      items: Array,
+      topContent: Object,
+      horizontalAlign: {
+        type: String,
+        value: 'left',
+      },
+
+      /**
+       * Style the dropdown trigger as a link (rather than a button).
+       */
+      link: {
+        type: Boolean,
+        value: false,
+      },
+
+      verticalOffset: {
+        type: Number,
+        value: 40,
+      },
+
+      /**
+       * List the IDs of dropdown buttons to be disabled. (Note this only
+       * diisables bittons and not link entries.)
+       */
+      disabledIds: {
+        type: Array,
+        value: function() { return []; },
+      },
+
+      _hasAvatars: String,
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
+    attached: function() {
+      this.$.restAPI.getConfig().then(function(cfg) {
+        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+      }.bind(this));
+    },
+
+    _handleDropdownTap: function(e) {
+      this.$.dropdown.close();
+    },
+
+    _showDropdownTapHandler: function(e) {
+      this.$.dropdown.open();
+    },
+
+    _getClassIfBold: function(bold) {
+      return bold ? 'bold-text' : '';
+    },
+
+    _computeURLHelper: function(host, path) {
+      return '//' + host + this.getBaseUrl() + path;
+    },
+
+    _computeRelativeURL: function(path) {
+      var host = window.location.host;
+      return this._computeURLHelper(host, path);
+    },
+
+    _computeLinkURL: function(link) {
+      if (typeof link.url === 'undefined') {
+        return '';
+      }
+      if (link.target) {
+        return link.url;
+      }
+      return this._computeRelativeURL(link.url);
+    },
+
+    _computeLinkRel: function(link) {
+      return link.target ? 'noopener' : null;
+    },
+
+    _handleItemTap: function(e) {
+      var id = e.target.getAttribute('data-id');
+      if (id && this.disabledIds.indexOf(id) === -1) {
+        this.dispatchEvent(new CustomEvent('tap-item-' + id));
+      }
+    },
+
+    _computeDisabledClass: function(id, disabledIdsRecord) {
+      return disabledIdsRecord.base.indexOf(id) === -1 ? '' : 'disabled';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
new file mode 100644
index 0000000..74ad85e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -0,0 +1,113 @@
+<!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-dropdown</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-dropdown.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-dropdown></gr-dropdown>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-dropdown tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+    });
+
+    test('tap on trigger opens menu', function() {
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(element.$.trigger);
+      assert.isTrue(element.$.dropdown.opened);
+    });
+
+    test('_computeURLHelper', function() {
+      var path = '/test';
+      var host = 'http://www.testsite.com';
+      var computedPath = element._computeURLHelper(host, path);
+      assert.equal(computedPath, '//http://www.testsite.com/test');
+    });
+
+    test('link URLs', function() {
+      assert.equal(
+          element._computeLinkURL({url: '/test'}),
+          '//' + window.location.host + '/test');
+      assert.equal(
+          element._computeLinkURL({url: '/test', target: '_blank'}),
+          '/test');
+    });
+
+    test('link rel', function() {
+      assert.isNull(element._computeLinkRel({url: '/test'}));
+      assert.equal(
+          element._computeLinkRel({url: '/test', target: '_blank'}),
+          'noopener');
+    });
+
+    test('_getClassIfBold', function() {
+      var bold = true;
+      assert.equal(element._getClassIfBold(bold), 'bold-text');
+
+      bold = false;
+      assert.equal(element._getClassIfBold(bold), '');
+    });
+
+    test('Top text exists and is bolded correctly', function() {
+      element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
+      flushAsynchronousOperations();
+      var topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
+      assert.equal(topItems.length, 2);
+      assert.isTrue(topItems[0].classList.contains('bold-text'));
+      assert.isFalse(topItems[1].classList.contains('bold-text'));
+    });
+
+    test('non link items', function() {
+      element.items = [
+          {name: 'item one', id: 'foo'}, {name: 'item two', id: 'bar'}];
+      var stub = sinon.stub();
+      element.addEventListener('tap-item-foo', stub);
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.$$('.itemAction'));
+      assert.isTrue(stub.called);
+    });
+
+    test('disabled non link item', function() {
+      element.items = [{name: 'item one', id: 'foo'}];
+      element.disabledIds = ['foo'];
+
+      var stub = sinon.stub();
+      element.addEventListener('tap-item-foo', stub);
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.$$('.itemAction'));
+      assert.isFalse(stub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
index 5b49dcc..bd87db3 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
@@ -43,6 +43,7 @@
     </div>
     <div class="editor" hidden$="[[!editing]]">
       <iron-autogrow-textarea
+          autocomplete="on"
           bind-value="{{_newContent}}"
           disabled="[[disabled]]"></iron-autogrow-textarea>
       <div class="editButtons">
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index 77e2272..86211a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -44,6 +44,7 @@
         type: Boolean,
         value: false,
       },
+      removeZeroWidthSpace: Boolean,
       _saveDisabled: {
         computed: '_computeSaveDisabled(disabled, content, _newContent)',
         type: Boolean,
@@ -58,7 +59,10 @@
 
     _editingChanged: function(editing) {
       if (!editing) { return; }
-      this._newContent = this.content;
+
+      // TODO(wyatta) switch linkify sequence, see issue 5526.
+      this._newContent = this.removeZeroWidthSpace ?
+          this.content.replace(/^R=\u200B/gm, 'R=') : this.content;
     },
 
     _computeSaveDisabled: function(disabled, content, newContent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
index 999f171..b25e815 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -24,6 +24,8 @@
 
 <link rel="import" href="gr-editable-content.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-editable-content></gr-editable-content>
@@ -69,6 +71,13 @@
       assert.equal(element._newContent, 'stale content');
     });
 
+    test('zero width spaces are removed properly', function() {
+      element.removeZeroWidthSpace = true;
+      element.content = 'R=\u200Btest@google.com';
+      element.editing = true;
+      assert.equal(element._newContent, 'R=test@google.com');
+    });
+
     suite('editing', function() {
       setup(function() {
         element.content = 'current content';
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 76a9c77..32cff2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -18,14 +18,20 @@
 <dom-module id="gr-editable-label">
   <template>
     <style>
+      :host {
+        align-items: center;
+        display: inline-flex;
+      }
+      input,
+      label {
+        width: 100%;
+      }
       input {
         font: inherit;
-        max-width: 8em;
       }
       label {
         color: #777;
         display: inline-block;
-        max-width: 8em;
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap;
@@ -47,6 +53,7 @@
     <label
         hidden$="[[editing]]"
         class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
+        title$="[[_computeLabel(value, placeholder)]]"
         on-tap="_open">[[_computeLabel(value, placeholder)]]</label>
   </template>
   <script src="gr-editable-label.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index eb604f7..f3e83f9 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -45,6 +45,10 @@
       _inputText: String,
     },
 
+    hostAttributes: {
+      tabindex: '0',
+    },
+
     _usePlaceholder: function(value, placeholder) {
       return (!value || !value.length) && placeholder;
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
index a87e5f6..42770aa 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -24,6 +24,8 @@
 
 <link rel="import" href="gr-editable-label.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-editable-label
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
new file mode 100644
index 0000000..d719f70
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
@@ -0,0 +1,53 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-linked-text/gr-linked-text.html">
+
+<dom-module id="gr-formatted-text">
+  <template>
+    <style>
+      :host {
+        display: block;
+        font-family: var(--font-family);
+      }
+      p,
+      ul,
+      blockquote,
+      gr-linked-text.pre {
+        margin: 0 0 1.4em 0;
+      }
+      :host.noTrailingMargin p:last-child,
+      :host.noTrailingMargin ul:last-child,
+      :host.noTrailingMargin blockquote:last-child,
+      :host.noTrailingMargin gr-linked-text.pre:last-child {
+        margin: 0;
+      }
+      blockquote {
+        border-left: 1px solid #aaa;
+        padding: 0 .7em;
+      }
+      li {
+        margin-left: 1.4em;
+      }
+      gr-linked-text.pre {
+        font-family: var(--monospace-font-family);
+      }
+
+    </style>
+    <div id="container"></div>
+  </template>
+  <script src="gr-formatted-text.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
new file mode 100644
index 0000000..a2130b2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.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 QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
+
+  Polymer({
+    is: 'gr-formatted-text',
+
+    properties: {
+      content: {
+        type: String,
+        observer: '_contentChanged',
+      },
+      config: Object,
+      noTrailingMargin: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    observers: [
+      '_contentOrConfigChanged(content, config)',
+    ],
+
+    ready: function() {
+      if (this.noTrailingMargin) {
+        this.classList.add('noTrailingMargin');
+      }
+    },
+
+    /**
+     * Get the plain text as it appears in the generated DOM.
+     *
+     * This differs from the `content` property in that it will not include
+     * formatting markers such as > characters to make quotes or * and - markers
+     * to make list items.
+     *
+     * @return {string}
+     */
+    getTextContent: function() {
+      return this._blocksToText(this._computeBlocks(this.content));
+    },
+
+    _contentChanged: function(content) {
+      // In the case where the config may not be set (perhaps due to the
+      // request for it still being in flight), set the content anyway to
+      // prevent waiting on the config to display the text.
+      if (this.config) { return; }
+      this.$.container.textContent = content;
+    },
+
+    /**
+     * Given a source string, update the DOM inside #container.
+     */
+    _contentOrConfigChanged: function(content) {
+      var container = Polymer.dom(this.$.container);
+
+      // Remove existing content.
+      while (container.firstChild) {
+        container.removeChild(container.firstChild);
+      }
+
+      // Add new content.
+      this._computeNodes(this._computeBlocks(content))
+          .forEach(function(node) {
+        container.appendChild(node);
+      });
+    },
+
+    /**
+     * Given a source string, parse into an array of block objects. Each block
+     * has a `type` property which takes any of the follwoing values.
+     * * 'paragraph'
+     * * 'quote' (Block quote.)
+     * * 'pre' (Pre-formatted text.)
+     * * 'list' (Unordered list.)
+     *
+     * For blocks of type 'paragraph' and 'pre' there is a `text` property that
+     * maps to a string of the block's content.
+     *
+     * For blocks of type 'list', there is an `items` property that maps to a
+     * list of strings representing the list items.
+     *
+     * For blocks of type 'quote', there is a `blocks` property that maps to a
+     * list of blocks contained in the quote.
+     *
+     * NOTE: Strings appearing in all block objects are NOT escaped.
+     *
+     * @param {string} content
+     * @return {!Array<!Object>}
+     */
+    _computeBlocks: function(content) {
+      if (!content) { return []; }
+
+      var result = [];
+      var split = content.split('\n\n');
+      var p;
+
+      for (var i = 0; i < split.length; i++) {
+        p = split[i];
+        if (!p.length) { continue; }
+
+        if (this._isQuote(p)) {
+          result.push(this._makeQuote(p));
+        } else if (this._isPreFormat(p)) {
+          result.push({type: 'pre', text: p});
+        } else if (this._isList(p)) {
+          this._makeList(p, result);
+        } else {
+          result.push({type: 'paragraph', text: p});
+        }
+      }
+      return result;
+    },
+
+    /**
+     * Take a block of comment text that contains a list and potentially
+     * a paragraph (but does not contain blank lines), generate appropriate
+     * block objects and append them to the output list.
+     *
+     * In simple cases, this will generate a single list block. For example, on
+     * the following input.
+     *
+     *    * Item one.
+     *    * Item two.
+     *    * item three.
+     *
+     * However, if the list starts with a paragraph, it will need to also
+     * generate that paragraph. Consider the following input.
+     *
+     *    A bit of text describing the context of the list:
+     *    * List item one.
+     *    * List item two.
+     *    * Et cetera.
+     *
+     * In this case, `_makeList` generates a paragraph block object
+     * containing the non-bullet-prefixed text, followed by a list block.
+     *
+     * @param {!string} p The block containing the list (as well as a
+     *   potential paragraph).
+     * @param {!Array<!Object>} out The list of blocks to append to.
+     */
+    _makeList: function(p, out) {
+      var block = null;
+      var inList = false;
+      var inParagraph = false;
+      var lines = p.split('\n');
+      var line;
+
+      for (var i = 0; i < lines.length; i++) {
+        line = lines[i];
+
+        if (line[0] === '-' || line[0] === '*') {
+          // The next line looks like a list item. If not building a list
+          // already, then create one. Remove the list item marker (* or -) from
+          // the line.
+          if (!inList) {
+            if (inParagraph) {
+              // Add the finished paragraph block to the result.
+              inParagraph = false;
+              out.push(block);
+            }
+            inList = true;
+            block = {type: 'list', items: []};
+          }
+          line = line.substring(1).trim();
+        } else if (!inList) {
+          // Otherwise, if a list has not yet been started, but the next line
+          // does not look like a list item, then add the line to a paragraph
+          // block. If a paragraph block has not yet been started, then create
+          // one.
+          if (!inParagraph) {
+            inParagraph = true;
+            block = {type: 'paragraph', text: ''};
+          } else {
+            block.text += ' ';
+          }
+          block.text += line;
+          continue;
+        }
+        block.items.push(line);
+      }
+      if (block != null) {
+        out.push(block);
+      }
+    },
+
+    _makeQuote: function(p) {
+      var quotedLines = p
+          .split('\n')
+          .map(function(l) { return l.replace(/^[ ]?>[ ]?/, ''); })
+          .join('\n');
+      return {
+        type: 'quote',
+        blocks: this._computeBlocks(quotedLines),
+      };
+    },
+
+    _isQuote: function(p) {
+      return p.indexOf('> ') === 0 || p.indexOf(' > ') === 0;
+    },
+
+    _isPreFormat: function(p) {
+      return p.indexOf('\n ') !== -1 || p.indexOf('\n\t') !== -1 ||
+          p.indexOf(' ') === 0 || p.indexOf('\t') === 0;
+    },
+
+    _isList: function(p) {
+      return p.indexOf('\n- ') !== -1 || p.indexOf('\n* ') !== -1 ||
+          p.indexOf('- ') === 0 || p.indexOf('* ') === 0;
+    },
+
+    _makeLinkedText: function(content, isPre) {
+      var text = document.createElement('gr-linked-text');
+      text.config = this.config;
+      text.content = content;
+      text.pre = true;
+      if (isPre) {
+        text.classList.add('pre');
+      }
+      return text;
+    },
+
+    /**
+     * Map an array of block objects to an array of DOM nodes.
+     * @param  {!Array<!Object>} blocks
+     * @return {!Array<!HTMLElement>}
+     */
+    _computeNodes: function(blocks) {
+      return blocks.map(function(block) {
+        if (block.type === 'paragraph') {
+          var p = document.createElement('p');
+          p.appendChild(this._makeLinkedText(block.text));
+          return p;
+        }
+
+        if (block.type === 'quote') {
+          var bq = document.createElement('blockquote');
+          this._computeNodes(block.blocks).forEach(function(node) {
+            bq.appendChild(node);
+          });
+          return bq;
+        }
+
+        if (block.type === 'pre') {
+          return this._makeLinkedText(block.text, true);
+        }
+
+        if (block.type === 'list') {
+          var ul = document.createElement('ul');
+          block.items.forEach(function(item) {
+            var li = document.createElement('li');
+            li.appendChild(this._makeLinkedText(item));
+            ul.appendChild(li);
+          }.bind(this));
+          return ul;
+        }
+      }.bind(this));
+    },
+
+    _blocksToText: function(blocks) {
+      return blocks.map(function(block) {
+        if (block.type === 'paragraph' || block.type === 'pre') {
+          return block.text;
+        }
+        if (block.type === 'quote') {
+          return this._blocksToText(block.blocks);
+        }
+        if (block.type === 'list') {
+          return block.items.join('\n');
+        }
+      }.bind(this)).join('\n\n');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
new file mode 100644
index 0000000..5afe60a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
@@ -0,0 +1,383 @@
+<!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-editable-label</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-formatted-text.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-formatted-text></gr-formatted-text>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-formatted-text tests', function() {
+    var element;
+    var sandbox;
+
+    function assertBlock(result, index, type, text) {
+      assert.equal(result[index].type, type);
+      assert.equal(result[index].text, text);
+    }
+
+    function assertListBlock(result, resultIndex, itemIndex, text) {
+      assert.equal(result[resultIndex].type, 'list');
+      assert.equal(result[resultIndex].items[itemIndex], text);
+    }
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('parse null undefined and empty', function() {
+      assert.lengthOf(element._computeBlocks(null), 0);
+      assert.lengthOf(element._computeBlocks(undefined), 0);
+      assert.lengthOf(element._computeBlocks(''), 0);
+    });
+
+    test('parse simple', function() {
+      var comment = 'Para1';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'paragraph', comment);
+    });
+
+    test('parse multiline para', function() {
+      var comment = 'Para 1\nStill para 1';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'paragraph', comment);
+    });
+
+    test('parse para break', function() {
+      var comment = 'Para 1\n\nPara 2\n\nPara 3';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'Para 1');
+      assertBlock(result, 1, 'paragraph', 'Para 2');
+      assertBlock(result, 2, 'paragraph', 'Para 3');
+    });
+
+    test('parse quote', function() {
+      var comment = '> Quote text';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 1);
+      assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+    });
+
+    test('parse quote lead space', function() {
+      var comment = ' > Quote text';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 1);
+      assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+    });
+
+    test('parse excludes empty', function() {
+      var comment = 'Para 1\n\n\n\nPara 2';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'Para 1');
+      assertBlock(result, 1, 'paragraph', 'Para 2');
+    });
+
+    test('parse multiline quote', function() {
+      var comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 1);
+      assertBlock(result[0].blocks, 0, 'paragraph',
+          'Quote line 1\nQuote line 2\nQuote line 3\n');
+    });
+
+    test('parse pre', function() {
+      var comment = '    Four space indent.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'pre', comment);
+    });
+
+    test('parse one space pre', function() {
+      var comment = ' One space indent.\n Another line.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'pre', comment);
+    });
+
+    test('parse tab pre', function() {
+      var comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'pre', comment);
+    });
+
+    test('parse intermediate leading whitespace pre', function() {
+      var comment = 'No indent.\n\tNonzero indent.\nNo indent again.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertBlock(result, 0, 'pre', comment);
+    });
+
+    test('parse star list', function() {
+      var comment = '* Item 1\n* Item 2\n* Item 3';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertListBlock(result, 0, 0, 'Item 1');
+      assertListBlock(result, 0, 1, 'Item 2');
+      assertListBlock(result, 0, 2, 'Item 3');
+    });
+
+    test('parse dash list', function() {
+      var comment = '- Item 1\n- Item 2\n- Item 3';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertListBlock(result, 0, 0, 'Item 1');
+      assertListBlock(result, 0, 1, 'Item 2');
+      assertListBlock(result, 0, 2, 'Item 3');
+    });
+
+    test('parse mixed list', function() {
+      var comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assertListBlock(result, 0, 0, 'Item 1');
+      assertListBlock(result, 0, 1, 'Item 2');
+      assertListBlock(result, 0, 2, 'Item 3');
+      assertListBlock(result, 0, 3, 'Item 4');
+    });
+
+    test('parse mixed block types', function() {
+      var comment = 'Paragraph\nacross\na\nfew\nlines.' +
+          '\n\n' +
+          '> Quote\n> across\n> not many lines.' +
+          '\n\n' +
+          'Another paragraph' +
+          '\n\n' +
+          '* Series\n* of\n* list\n* items' +
+          '\n\n' +
+          'Yet another paragraph' +
+          '\n\n' +
+          '\tPreformatted text.' +
+          '\n\n' +
+          'Parting words.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 7);
+      assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.');
+
+      assert.equal(result[1].type, 'quote');
+      assert.lengthOf(result[1].blocks, 1);
+      assertBlock(result[1].blocks, 0, 'paragraph',
+          'Quote\nacross\nnot many lines.');
+
+      assertBlock(result, 2, 'paragraph', 'Another paragraph');
+      assertListBlock(result, 3, 0, 'Series');
+      assertListBlock(result, 3, 1, 'of');
+      assertListBlock(result, 3, 2, 'list');
+      assertListBlock(result, 3, 3, 'items');
+      assertBlock(result, 4, 'paragraph', 'Yet another paragraph');
+      assertBlock(result, 5, 'pre', '\tPreformatted text.');
+      assertBlock(result, 6, 'paragraph', 'Parting words.');
+    });
+
+    test('bullet list 1', function() {
+      var comment = 'A\n\n* line 1\n* 2nd line';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertListBlock(result, 1, 0, 'line 1');
+      assertListBlock(result, 1, 1, '2nd line');
+    });
+
+    test('bullet list 2', function() {
+      var comment = 'A\n\n* line 1\n* 2nd line\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertListBlock(result, 1, 0, 'line 1');
+      assertListBlock(result, 1, 1, '2nd line');
+      assertBlock(result, 2, 'paragraph', 'B');
+    });
+
+    test('bullet list 3', function() {
+      var comment = '* line 1\n* 2nd line\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertListBlock(result, 0, 0, 'line 1');
+      assertListBlock(result, 0, 1, '2nd line');
+      assertBlock(result, 1, 'paragraph', 'B');
+    });
+
+    test('bullet list 4', function() {
+      var comment = 'To see this bug, you have to:\n' +
+          '* Be on IMAP or EAS (not on POP)\n' +
+          '* Be very unlucky\n';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
+      assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+      assertListBlock(result, 1, 1, 'Be very unlucky');
+    });
+
+    test('bullet list 5', function() {
+      var comment = 'To see this bug,\n' +
+          'you have to:\n' +
+          '* Be on IMAP or EAS (not on POP)\n' +
+          '* Be very unlucky\n';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
+      assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+      assertListBlock(result, 1, 1, 'Be very unlucky');
+    });
+
+    test('dash list 1', function() {
+      var comment = 'A\n\n- line 1\n- 2nd line';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertListBlock(result, 1, 0, 'line 1');
+      assertListBlock(result, 1, 1, '2nd line');
+    });
+
+    test('dash list 2', function() {
+      var comment = 'A\n\n- line 1\n- 2nd line\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertListBlock(result, 1, 0, 'line 1');
+      assertListBlock(result, 1, 1, '2nd line');
+      assertBlock(result, 2, 'paragraph', 'B');
+    });
+
+    test('dash list 3', function() {
+      var comment = '- line 1\n- 2nd line\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertListBlock(result, 0, 0, 'line 1');
+      assertListBlock(result, 0, 1, '2nd line');
+      assertBlock(result, 1, 'paragraph', 'B');
+    });
+
+    test('pre format 1', function() {
+      var comment = 'A\n\n  This is pre\n  formatted';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+    });
+
+    test('pre format 2', function() {
+      var comment = 'A\n\n  This is pre\n  formatted\n\nbut this is not';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+      assertBlock(result, 2, 'paragraph', 'but this is not');
+    });
+
+    test('pre format 3', function() {
+      var comment = 'A\n\n  Q\n    <R>\n  S\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'A');
+      assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
+      assertBlock(result, 2, 'paragraph', 'B');
+    });
+
+    test('pre format 4', function() {
+      var comment = '  Q\n    <R>\n  S\n\nB';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
+      assertBlock(result, 1, 'paragraph', 'B');
+    });
+
+    test('quote 1', function() {
+      var comment = '> I\'m happy\n > with quotes!\n\nSee above.';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 2);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 1);
+      assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
+      assertBlock(result, 1, 'paragraph', 'See above.');
+    });
+
+    test('quote 2', function() {
+      var comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 3);
+      assertBlock(result, 0, 'paragraph', 'See this said:');
+      assert.equal(result[1].type, 'quote');
+      assert.lengthOf(result[1].blocks, 1);
+      assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
+      assertBlock(result, 2, 'paragraph', 'OK?');
+    });
+
+    test('nested quotes', function() {
+      var comment = ' > > prior\n > \n > next\n';
+      var result = element._computeBlocks(comment);
+      assert.lengthOf(result, 1);
+      assert.equal(result[0].type, 'quote');
+      assert.lengthOf(result[0].blocks, 2);
+      assert.equal(result[0].blocks[0].type, 'quote');
+      assert.lengthOf(result[0].blocks[0].blocks, 1);
+      assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
+      assertBlock(result[0].blocks, 1, 'paragraph', 'next\n');
+    });
+
+    test('getTextContent', function() {
+      var comment = 'Paragraph\n\n  pre\n\n* List\n* Of\n* Items\n\n> Quote';
+      element.content = comment;
+      var result = element.getTextContent();
+      var expected = 'Paragraph\n\n  pre\n\nList\nOf\nItems\n\nQuote';
+      assert.equal(result, expected);
+    });
+
+    test('_contentOrConfigChanged not called without config', function() {
+      var contentStub = sandbox.stub(element, '_contentChanged');
+      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+      element.content = 'some text';
+      assert.isTrue(contentStub.called);
+      assert.isFalse(contentConfigStub.called);
+    });
+
+    test('_contentOrConfigChanged called with config', function() {
+      var contentStub = sandbox.stub(element, '_contentChanged');
+      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+      element.content = 'some text';
+      element.config = {};
+      assert.isTrue(contentStub.called);
+      assert.isTrue(contentConfigStub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index f7c337b..72c7f6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -33,6 +33,11 @@
     });
   };
 
+  GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
+      hidden) {
+    return this._el.setActionHidden(type, key, hidden);
+  };
+
   GrChangeActionsInterface.prototype.add = function(type, label) {
     return this._el.addActionButton(type, label);
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 4919a5a..20b5dcb 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -18,7 +18,7 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions-js-api</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
@@ -28,6 +28,8 @@
 -->
 <link rel="import" href="../../change/gr-change-actions/gr-change-actions.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-change-actions></gr-change-actions>
@@ -49,6 +51,8 @@
 
     setup(function() {
       element = fixture('basic');
+      element.change = {};
+      element._hasKnownChainState = false;
       var plugin;
       Gerrit.install(function(p) { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
@@ -109,7 +113,7 @@
         var button = element.$$('[data-action-key="' + key + '"]');
         assert.isOk(button);
         assert.equal(button.getAttribute('data-label'), 'Bork!');
-        assert.isFalse(button.disabled);
+        assert.isNotOk(button.disabled);
         changeActions.setLabel(key, 'Yo');
         changeActions.setEnabled(key, false);
         flush(function() {
@@ -119,5 +123,21 @@
         });
       });
     });
+
+    test('hide action buttons', function(done) {
+      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(function() {
+        var button = element.$$('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.isFalse(button.hasAttribute('hidden'));
+        changeActions.setActionHidden(changeActions.ActionType.REVISION, key,
+            true);
+        flush(function() {
+          var button = element.$$('[data-action-key="' + key + '"]');
+          assert.isNotOk(button);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index 2e5aa56..d7d5cfe 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -18,16 +18,18 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-reply-js-api</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <!--
-This must refer to the element this interface is wrapping around. Otherwise
-breaking changes to gr-reply-dialog won’t be noticed.
--->
+     This must refer to the element this interface is wrapping around. Otherwise
+     breaking changes to gr-reply-dialog won’t be noticed.
+   -->
 <link rel="import" href="../../change/gr-reply-dialog/gr-reply-dialog.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-reply-dialog></gr-reply-dialog>
@@ -42,6 +44,7 @@
 
     setup(function() {
       stub('gr-rest-api-interface', {
+        getConfig: function() { return Promise.resolve({}); },
         getAccount: function() { return Promise.resolve(null); },
       });
       element = fixture('basic');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index 1967b80..5c0535b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-js-api-interface">
@@ -23,4 +24,3 @@
   <script src="gr-js-api-interface.js"></script>
   <script src="gr-public-js-api.js"></script>
 </dom-module>
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index 4dfcf48..34ca728 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -19,8 +19,10 @@
     LABEL_CHANGE: 'labelchange',
     SHOW_CHANGE: 'showchange',
     SUBMIT_CHANGE: 'submitchange',
+    COMMIT_MSG_EDIT: 'commitmsgedit',
     COMMENT: 'comment',
     REVERT: 'revert',
+    POST_REVERT: 'postrevert',
   };
 
   var Element = {
@@ -46,23 +48,26 @@
     EventType: EventType,
 
     handleEvent: function(type, detail) {
-      switch (type) {
-        case EventType.HISTORY:
-          this._handleHistory(detail);
-          break;
-        case EventType.SHOW_CHANGE:
-          this._handleShowChange(detail);
-          break;
-        case EventType.COMMENT:
-          this._handleComment(detail);
-          break;
-        case EventType.LABEL_CHANGE:
-          this._handleLabelChange(detail);
-          break;
-        default:
-          console.warn('handleEvent called with unsupported event type:', type);
-          break;
-      }
+      Gerrit.awaitPluginsLoaded().then(function() {
+        switch (type) {
+          case EventType.HISTORY:
+            this._handleHistory(detail);
+            break;
+          case EventType.SHOW_CHANGE:
+            this._handleShowChange(detail);
+            break;
+          case EventType.COMMENT:
+            this._handleComment(detail);
+            break;
+          case EventType.LABEL_CHANGE:
+            this._handleLabelChange(detail);
+            break;
+          default:
+            console.warn('handleEvent called with unsupported event type:',
+                type);
+            break;
+        }
+      }.bind(this));
     },
 
     addElement: function(key, el) {
@@ -80,11 +85,11 @@
       this._eventCallbacks[eventName].push(callback);
     },
 
-    canSubmitChange: function() {
+    canSubmitChange: function(change, revision) {
       var submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
       var cancelSubmit = submitCallbacks.some(function(callback) {
         try {
-          return callback() === false;
+          return callback(change, revision) === false;
         } catch (err) {
           console.error(err);
         }
@@ -129,6 +134,18 @@
       });
     },
 
+    handleCommitMessage: function(change, msg) {
+      this._getEventCallbacks(EventType.COMMIT_MSG_EDIT).forEach(
+          function(cb) {
+            try {
+              cb(change, msg);
+            } catch (err) {
+              console.error(err);
+            }
+          }
+      );
+    },
+
     _handleComment: function(detail) {
       this._getEventCallbacks(EventType.COMMENT).forEach(function(cb) {
         try {
@@ -149,15 +166,29 @@
       });
     },
 
-    modifyRevertMsg: function(change, msg) {
+    modifyRevertMsg: function(change, revertMsg, origMsg) {
       this._getEventCallbacks(EventType.REVERT).forEach(function(callback) {
         try {
-          msg = callback(change, msg);
+          revertMsg = callback(change, revertMsg, origMsg);
         } catch (err) {
           console.error(err);
         }
       });
-      return msg;
+      return revertMsg;
+    },
+
+    getLabelValuesPostRevert: function(change) {
+      var labels = {};
+      this._getEventCallbacks(EventType.POST_REVERT).forEach(
+          function(callback) {
+            try {
+              labels = callback(change);
+            } catch (err) {
+              console.error(err);
+            }
+          }
+      );
+      return labels;
     },
 
     _getEventCallbacks: function(type) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index 46a555a..d62bdd8 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -18,10 +18,12 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="gr-js-api-interface.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-js-api-interface></gr-js-api-interface>
@@ -33,26 +35,30 @@
     var element;
     var plugin;
     var errorStub;
+    var sandbox;
+
     var throwErrFn = function() {
       throw Error('Unfortunately, this handler has stopped');
     };
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getAccount: function() {
           return Promise.resolve({name: 'Judy Hopps'});
         },
       });
       element = fixture('basic');
-      errorStub = sinon.stub(console, 'error');
+      errorStub = sandbox.stub(console, 'error');
+      Gerrit._setPluginsCount(1);
       Gerrit.install(function(p) { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
     });
 
     teardown(function() {
+      sandbox.restore();
       element._removeEventCallbacks();
       plugin = null;
-      errorStub.restore();
     });
 
     test('url', function() {
@@ -75,10 +81,7 @@
     test('showchange event', function(done) {
       var testChange = {
         _number: 42,
-        revisions: {
-          def: {_number: 2},
-          abc: {_number: 1},
-        },
+        revisions: {def: {_number: 2}, abc: {_number: 1}},
       };
       plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
       plugin.on(element.EventType.SHOW_CHANGE, function(change, revision) {
@@ -91,6 +94,24 @@
           {change: testChange, patchNum: 1});
     });
 
+    test('handleEvent awaits plugins load', function(done) {
+      var testChange = {
+        _number: 42,
+        revisions: {def: {_number: 2}, abc: {_number: 1}},
+      };
+      var spy = sandbox.spy();
+      Gerrit._setPluginsCount(1);
+      plugin.on(element.EventType.SHOW_CHANGE, spy);
+      element.handleEvent(element.EventType.SHOW_CHANGE,
+          {change: testChange, patchNum: 1});
+      assert.isFalse(spy.called);
+      Gerrit._setPluginsCount(0);
+      flush(function() {
+        assert.isTrue(spy.called);
+        done();
+      });
+    });
+
     test('comment event', function(done) {
       var testCommentNode = {foo: 'bar'};
       plugin.on(element.EventType.COMMENT, throwErrFn);
@@ -102,25 +123,52 @@
       element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
     });
 
-    test('revert event', function(done) {
-      function appendToRevertMsg(c, msg) {
-        return msg + '\ninfo';
+    test('revert event', function() {
+      function appendToRevertMsg(c, revertMsg, originalMsg) {
+        return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
       }
-      done();
 
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
       assert.equal(errorStub.callCount, 0);
 
       plugin.on(element.EventType.REVERT, throwErrFn);
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test\ninfo');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+                   'test\n> origTest\ninfo');
       assert.isTrue(errorStub.calledOnce);
 
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test\ninfo\ninfo');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+                   'test\n> origTest\ninfo\n> origTest\ninfo');
       assert.isTrue(errorStub.calledTwice);
     });
 
+    test('postrevert event', function() {
+      function getLabels(c) {
+        return {'Code-Review': 1};
+      }
+
+      assert.deepEqual(element.getLabelValuesPostRevert(null), {});
+      assert.equal(errorStub.callCount, 0);
+
+      plugin.on(element.EventType.POST_REVERT, throwErrFn);
+      plugin.on(element.EventType.POST_REVERT, getLabels);
+      assert.deepEqual(
+          element.getLabelValuesPostRevert(null), {'Code-Review': 1});
+      assert.isTrue(errorStub.calledOnce);
+    });
+
+    test('commitmsgedit event', function(done) {
+      var testMsg = 'Test CL commit message';
+      plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
+      plugin.on(element.EventType.COMMIT_MSG_EDIT, function(change, msg) {
+        assert.deepEqual(msg, testMsg);
+        assert.isTrue(errorStub.calledOnce);
+        done();
+      });
+      element.handleCommitMessage(null, testMsg);
+    });
+
     test('labelchange event', function(done) {
       var testChange = {_number: 42};
       plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
@@ -144,7 +192,7 @@
     });
 
     test('versioning', function() {
-      var callback = sinon.spy();
+      var callback = sandbox.spy();
       Gerrit.install(callback, '0.0pre-alpha');
       assert(callback.notCalled);
     });
@@ -156,5 +204,65 @@
       });
     });
 
+    test('_setPluginsCount', function(done) {
+      stub('gr-reporting', {
+        pluginsLoaded: function() {
+          assert.equal(Gerrit._pluginsPending, 0);
+          done();
+        }
+      });
+      Gerrit._setPluginsCount(0);
+    });
+
+    test('_arePluginsLoaded', function() {
+      assert.isTrue(Gerrit._arePluginsLoaded());
+      Gerrit._setPluginsCount(1);
+      assert.isFalse(Gerrit._arePluginsLoaded());
+      Gerrit._setPluginsCount(0);
+      assert.isTrue(Gerrit._arePluginsLoaded());
+    });
+
+    test('_pluginInstalled', function(done) {
+      stub('gr-reporting', {
+        pluginsLoaded: function() {
+          assert.equal(Gerrit._pluginsPending, 0);
+          done();
+        }
+      });
+      Gerrit._setPluginsCount(2);
+      Gerrit._pluginInstalled();
+      assert.equal(Gerrit._pluginsPending, 1);
+      Gerrit._pluginInstalled();
+    });
+
+    test('install calls _pluginInstalled', function() {
+      sandbox.stub(Gerrit, '_pluginInstalled');
+      Gerrit.install(function(p) { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      assert.isTrue(Gerrit._pluginInstalled.calledOnce);
+    });
+
+    test('install calls _pluginInstalled on error', function() {
+      sandbox.stub(Gerrit, '_pluginInstalled');
+      Gerrit.install(function() {}, '0.0pre-alpha');
+      assert.isTrue(Gerrit._pluginInstalled.calledOnce);
+    });
+
+    test('installGwt calls _pluginInstalled', function() {
+      sandbox.stub(Gerrit, '_pluginInstalled');
+      Gerrit.installGwt();
+      assert.isTrue(Gerrit._pluginInstalled.calledOnce);
+    });
+
+    test('installGwt returns a stub object', function() {
+      var plugin = Gerrit.installGwt();
+      sandbox.stub(console, 'warn');
+      assert.isAbove(Object.keys(plugin).length, 0);
+      Object.keys(plugin).forEach(function(name) {
+        console.warn.reset();
+        plugin[name]();
+        assert.isTrue(console.warn.calledOnce);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 21d76f1..b3ae649 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -14,6 +14,16 @@
 (function(window) {
   'use strict';
 
+  var warnNotSupported = function(opt_name) {
+    console.warn('Plugin API method ' + (opt_name || '') + ' is not supported');
+  };
+
+  var stubbedMethods = ['_loadedGwt', 'screen', 'settingsScreen', 'panel'];
+  var GWT_PLUGIN_STUB = {};
+  stubbedMethods.forEach(function(name) {
+    GWT_PLUGIN_STUB[name] = warnNotSupported.bind(null, name);
+  });
+
   var API_VERSION = '0.1';
 
   // GWT JSNI uses $wnd to refer to window.
@@ -44,6 +54,10 @@
     return this._name;
   };
 
+  Plugin.prototype.getServerInfo = function() {
+    return document.createElement('gr-rest-api-interface').getConfig();
+  };
+
   Plugin.prototype.on = function(eventName, callback) {
     Plugin._sharedAPIElement.addEventCallback(eventName, callback);
   };
@@ -64,6 +78,9 @@
 
   var Gerrit = window.Gerrit || {};
 
+  // Number of plugins to initialize, -1 means 'not yet known'.
+  Gerrit._pluginsPending = -1;
+
   Gerrit.getPluginName = function() {
     console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
         'Please use self.getPluginName() instead.');
@@ -85,20 +102,68 @@
     if (opt_version && opt_version !== API_VERSION) {
       console.warn('Only version ' + API_VERSION +
           ' is supported in PolyGerrit. ' + opt_version + ' was given.');
+      Gerrit._pluginInstalled();
       return;
     }
 
     // TODO(andybons): Polyfill currentScript for IE10/11 (edge supports it).
     var src = opt_src || (document.currentScript && document.currentScript.src);
-    callback(new Plugin(src));
+    var plugin = new Plugin(src);
+    try {
+      callback(plugin);
+    } catch (e) {
+      console.warn(plugin.getPluginName() + ' install failed: ' +
+          e.name + ': ' + e.message);
+    }
+    Gerrit._pluginInstalled();
   };
 
   Gerrit.getLoggedIn = function() {
     return document.createElement('gr-rest-api-interface').getLoggedIn();
   };
 
+  /**
+   * Polyfill GWT API dependencies to avoid runtime exceptions when loading
+   * GWT-compiled plugins.
+   * @deprecated Not supported in PolyGerrit.
+   */
   Gerrit.installGwt = function() {
-    // NOOP since PolyGerrit doesn’t support GWT plugins.
+    Gerrit._pluginInstalled();
+    return GWT_PLUGIN_STUB;
+  };
+
+  Gerrit._allPluginsPromise = null;
+  Gerrit._resolveAllPluginsLoaded = null;
+
+  Gerrit.awaitPluginsLoaded = function() {
+    if (!Gerrit._allPluginsPromise) {
+      if (Gerrit._arePluginsLoaded()) {
+        Gerrit._allPluginsPromise = Promise.resolve();
+      } else {
+        Gerrit._allPluginsPromise = new Promise(function(resolve) {
+          Gerrit._resolveAllPluginsLoaded = resolve;
+        });
+      }
+    }
+    return Gerrit._allPluginsPromise;
+  };
+
+  Gerrit._setPluginsCount = function(count) {
+    Gerrit._pluginsPending = count;
+    if (Gerrit._arePluginsLoaded()) {
+      document.createElement('gr-reporting').pluginsLoaded();
+      if (Gerrit._resolveAllPluginsLoaded) {
+        Gerrit._resolveAllPluginsLoaded();
+      }
+    }
+  };
+
+  Gerrit._pluginInstalled = function() {
+    Gerrit._setPluginsCount(Gerrit._pluginsPending - 1);
+  };
+
+  Gerrit._arePluginsLoaded = function() {
+    return Gerrit._pluginsPending === 0;
   };
 
   window.Gerrit = Gerrit;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
new file mode 100644
index 0000000..5828e7b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -0,0 +1,68 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-button/gr-button.html">
+<dom-module id="gr-linked-chip">
+  <template>
+    <style>
+      :host {
+        display: block;
+        overflow: hidden;
+      }
+      .container {
+        align-items: center;
+        background: #eee;
+        border-radius: .75em;
+        display: inline-flex;
+        padding: 0 .5em;
+      }
+      gr-button.remove,
+      gr-button.remove:hover,
+      gr-button.remove:focus {
+        border-color: transparent;
+        color: #333;
+      }
+      gr-button.remove {
+        background: #eee;
+        border: 0;
+        color: #666;
+        font-size: 1.7em;
+        font-weight: normal;
+        height: .6em;
+        line-height: .6em;
+        margin-left: .15em;
+        margin-top: -.05em;
+        padding: 0;
+        text-decoration: none;
+      }
+      .transparentBackground,
+      gr-button.transparentBackground {
+        background-color: transparent;
+      }
+    </style>
+    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
+      <a href$="[[href]]">[[text]]</a>
+      <gr-button
+          id="remove"
+          hidden$="[[!removable]]"
+          hidden
+          class$="remove [[_getBackgroundClass(transparentBackground)]]"
+          on-tap="_handleRemoveTap">×</gr-button>
+    </div>
+  </template>
+  <script src="gr-linked-chip.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
new file mode 100644
index 0000000..c6a5e4e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -0,0 +1,42 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-linked-chip',
+
+    properties: {
+      href: String,
+      removable: {
+        type: Boolean,
+        value: false,
+      },
+      text: String,
+      transparentBackground: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    _getBackgroundClass: function(transparent) {
+      return transparent ? 'transparentBackground' : '';
+    },
+
+    _handleRemoveTap: function(e) {
+      e.preventDefault();
+      this.fire('remove');
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
new file mode 100644
index 0000000..eefc79d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -0,0 +1,58 @@
+<!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-linked-chip</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-linked-chip.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-linked-chip></gr-linked-chip>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-linked-chip tests', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('remove fired', function() {
+      var spy = sandbox.spy();
+      element.addEventListener('remove', spy);
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.$.remove);
+      assert.isTrue(spy.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index cb852fd..0f346f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -18,6 +18,7 @@
     is: 'gr-linked-text',
 
     properties: {
+      removeZeroWidthSpace: Boolean,
       content: {
         type: String,
         observer: '_contentChanged',
@@ -50,27 +51,27 @@
     _contentOrConfigChanged: function(content, config) {
       var output = Polymer.dom(this.$.output);
       output.textContent = '';
-      var parser = new GrLinkTextParser(config, function(text, href, html) {
+      var parser = new GrLinkTextParser(
+          config, function(text, href, fragment) {
         if (href) {
           var a = document.createElement('a');
           a.href = href;
           a.textContent = text;
           a.target = '_blank';
+          a.rel = 'noopener';
           output.appendChild(a);
-        } else if (html) {
-          var fragment = document.createDocumentFragment();
-          // Create temporary div to hold the nodes in.
-          var div = document.createElement('div');
-          div.innerHTML = html;
-          while (div.firstChild) {
-            fragment.appendChild(div.firstChild);
-          }
+        } else if (fragment) {
           output.appendChild(fragment);
-        } else {
-          output.appendChild(document.createTextNode(text));
         }
-      });
+      }, this.removeZeroWidthSpace);
       parser.parse(content);
-    }
+
+      // Ensure that links originating from HTML commentlink configs open in a
+      // new tab. @see Issue 5567
+      output.querySelectorAll('a').forEach(function(anchor) {
+        anchor.setAttribute('target', '_blank');
+        anchor.setAttribute('rel', 'noopener');
+      });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index 5203520..807278d 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -24,6 +24,8 @@
 
 <link rel="import" href="gr-linked-text.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-linked-text>
@@ -35,9 +37,11 @@
 <script>
   suite('gr-linked-text tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
       element.config = {
         ph: {
           match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
@@ -47,6 +51,10 @@
           match: '(I[0-9a-f]{8,40})',
           link: '#/q/$1'
         },
+        changeid2: {
+          match: 'Change-Id: +(I[0-9a-f]{8,40})',
+          link: '#/q/$1'
+        },
         googlesearch: {
           match: 'google:(.+)',
           link: 'https://bing.com/search?q=$1',  // html should supercede link.
@@ -64,12 +72,17 @@
       };
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('URL pattern was parsed and linked.', function() {
       // Reguar inline link.
       var url = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
       element.content = url;
       var linkEl = element.$.output.childNodes[0];
       assert.equal(linkEl.target, '_blank');
+      assert.equal(linkEl.rel, 'noopener');
       assert.equal(linkEl.href, url);
       assert.equal(linkEl.textContent, url);
     });
@@ -87,6 +100,7 @@
       element.content = 'Bug 3650';
       linkEl = element.$.output.childNodes[0];
       assert.equal(linkEl.target, '_blank');
+      assert.equal(linkEl.rel, 'noopener');
       assert.equal(linkEl.href, url);
       assert.equal(linkEl.textContent, 'Bug 3650');
     });
@@ -123,6 +137,34 @@
       assert.equal(linkEl2.textContent, 'Issue 3450');
     });
 
+    test('Change-Id pattern parsed before bug pattern', function() {
+      // "Change-Id:" pattern.
+      var changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+      var prefix = 'Change-Id: ';
+
+      // "Issue/Bug" pattern.
+      var bug = 'Issue 3650';
+
+      var changeUrl = '/q/' + changeID;
+      var bugUrl = 'https://code.google.com/p/gerrit/issues/detail?id=3650';
+
+      element.content = prefix + changeID + bug;
+
+      var textNode = element.$.output.childNodes[0];
+      var changeLinkEl = element.$.output.childNodes[1];
+      var bugLinkEl = element.$.output.childNodes[2];
+
+      assert.equal(textNode.textContent, prefix);
+
+      assert.equal(changeLinkEl.target, '_blank');
+      assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
+      assert.equal(changeLinkEl.textContent, changeID);
+
+      assert.equal(bugLinkEl.target, '_blank');
+      assert.equal(bugLinkEl.href, bugUrl);
+      assert.equal(bugLinkEl.textContent, 'Issue 3650');
+    });
+
     test('html field in link config', function() {
       element.content = 'google:do a barrel roll';
       var linkEl = element.$.output.childNodes[0];
@@ -143,5 +185,65 @@
       assert.equal(element.$.output.innerHTML, 'foo:baz');
     });
 
+    test('R=email labels link correctly', function() {
+      element.removeZeroWidthSpace = true;
+      element.content = 'R=\u200Btest@google.com';
+      assert.equal(element.$.output.textContent, 'R=test@google.com');
+      assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
+    });
+
+    test('overlapping links', function() {
+      element.config = {
+        b1: {
+          match: '(B:\\s*)(\\d+)',
+          html: '$1<a href="ftp://foo/$2">$2</a>',
+        },
+        b2: {
+          match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
+          html: '$1<a href="ftp://foo/$2">$2</a>',
+        },
+      };
+      element.content = '- B: 123, 45';
+      var links = Polymer.dom(element.root).querySelectorAll('a');
+
+      assert.equal(links.length, 2);
+      assert.equal(element.$$('span').textContent, '- B: 123, 45');
+
+      assert.equal(links[0].href, 'ftp://foo/123');
+      assert.equal(links[0].textContent, '123');
+
+      assert.equal(links[1].href, 'ftp://foo/45');
+      assert.equal(links[1].textContent, '45');
+    });
+
+    test('_contentOrConfigChanged called with config', function() {
+      var contentStub = sandbox.stub(element, '_contentChanged');
+      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+      element.content = 'some text';
+      assert.isTrue(contentStub.called);
+      assert.isTrue(contentConfigStub.called);
+    });
+  });
+
+  suite('gr-linked-text with null config', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('_contentOrConfigChanged not called without config', function() {
+      var contentStub = sandbox.stub(element, '_contentChanged');
+      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+      element.content = 'some text';
+      assert.isTrue(contentStub.called);
+      assert.isFalse(contentConfigStub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index b4b1678..e7ca50d 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -14,9 +14,10 @@
 
 'use strict';
 
-function GrLinkTextParser(linkConfig, callback) {
+function GrLinkTextParser(linkConfig, callback, opt_removeZeroWidthSpace) {
   this.linkConfig = linkConfig;
   this.callback = callback;
+  this.removeZeroWidthSpace = opt_removeZeroWidthSpace;
   Object.preventExtensions(this);
 }
 
@@ -27,17 +28,113 @@
   this.callback(text, href);
 };
 
-GrLinkTextParser.prototype.addHTML = function(html) {
-  this.callback(null, null, html);
+GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
+  this.sortArrayReverse(outputArray);
+  var fragment = document.createDocumentFragment();
+  var cursor = text.length;
+
+  // Start inserting linkified URLs from the end of the String. That way, the
+  // string positions of the items don't change as we iterate through.
+  outputArray.forEach(function(item) {
+    // Add any text between the current linkified item and the item added before
+    // if it exists.
+    if (item.position + item.length !== cursor) {
+      fragment.insertBefore(
+          document.createTextNode(
+              text.slice(item.position + item.length, cursor)),
+          fragment.firstChild);
+    }
+    fragment.insertBefore(item.html, fragment.firstChild);
+    cursor = item.position;
+  });
+
+  // Add the beginning portion at the end.
+  if (cursor !== 0) {
+    fragment.insertBefore(
+        document.createTextNode(text.slice(0, cursor)), fragment.firstChild);
+  }
+
+  this.callback(null, null, fragment);
+};
+
+GrLinkTextParser.prototype.sortArrayReverse = function(outputArray) {
+  outputArray.sort(function(a, b) {return b.position - a.position});
+};
+
+GrLinkTextParser.prototype.addItem =
+    function(text, href, html, position, length, outputArray) {
+  var htmlOutput = '';
+
+  if (href) {
+    var a = document.createElement('a');
+    a.href = href;
+    a.textContent = text;
+    a.target = '_blank';
+    a.rel = 'noopener';
+    htmlOutput = a;
+  } else if (html) {
+    var fragment = document.createDocumentFragment();
+    // Create temporary div to hold the nodes in.
+    var div = document.createElement('div');
+    div.innerHTML = html;
+    while (div.firstChild) {
+      fragment.appendChild(div.firstChild);
+    }
+    htmlOutput = fragment;
+  }
+
+  outputArray.push({
+    html: htmlOutput,
+    position: position,
+    length: length,
+  });
+};
+
+GrLinkTextParser.prototype.addLink =
+    function(text, href, position, length, outputArray) {
+  if (!text) {
+    return;
+  }
+  if (!this.hasOverlap(position, length, outputArray)) {
+    this.addItem(text, href, null, position, length, outputArray);
+  }
+};
+
+GrLinkTextParser.prototype.addHTML =
+    function(html, position, length, outputArray) {
+  if (!this.hasOverlap(position, length, outputArray)) {
+    this.addItem(null, null, html, position, length, outputArray);
+  }
+};
+
+GrLinkTextParser.prototype.hasOverlap =
+    function(position, length, outputArray) {
+  var endPosition = position + length;
+  for (var i = 0; i < outputArray.length; i++) {
+    var arrayItemStart = outputArray[i].position;
+    var arrayItemEnd = outputArray[i].position + outputArray[i].length;
+    if ((position >= arrayItemStart && position < arrayItemEnd) ||
+      (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
+      (position === arrayItemStart && position === arrayItemEnd)) {
+          return true;
+    }
+  }
+  return false;
 };
 
 GrLinkTextParser.prototype.parse = function(text) {
   linkify(text, {
-    callback: this.parseChunk.bind(this)
+    callback: this.parseChunk.bind(this),
   });
 };
 
 GrLinkTextParser.prototype.parseChunk = function(text, href) {
+  // TODO(wyatta) switch linkify sequence, see issue 5526.
+  if (this.removeZeroWidthSpace) {
+    // Remove the zero-width space added in gr-change-view.
+    text = text.replace(/^R=\u200B/gm, 'R=');
+  }
+
   if (href) {
     this.addText(text, href);
   } else {
@@ -46,6 +143,8 @@
 };
 
 GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
+  // The outputArray is used to store all of the matches found for all patterns.
+  var outputArray = [];
   for (var p in patterns) {
     if (patterns[p].enabled != null && patterns[p].enabled == false) {
       continue;
@@ -66,22 +165,44 @@
     var pattern = new RegExp(patterns[p].match, 'g');
 
     var match;
-    while ((match = pattern.exec(text)) != null) {
-      var before = text.substr(0, match.index);
-      this.addText(before);
-      text = text.substr(match.index + match[0].length);
+    var textToCheck = text;
+    var susbtrIndex = 0;
+
+    while ((match = pattern.exec(textToCheck)) != null) {
+      textToCheck = textToCheck.substr(match.index + match[0].length);
       var result = match[0].replace(pattern,
           patterns[p].html || patterns[p].link);
 
+      // Skip portion of replacement string that is equal to original.
+      for (var i = 0; i < result.length; i++) {
+        if (result[i] !== match[0][i]) {
+          break;
+        }
+      }
+      result = result.slice(i);
+
       if (patterns[p].html) {
-        this.addHTML(result);
+        this.addHTML(
+          result,
+          susbtrIndex + match.index + i,
+          match[0].length - i,
+          outputArray);
       } else if (patterns[p].link) {
-        this.addText(match[0], result);
+        this.addLink(
+          match[0],
+          result,
+          susbtrIndex + match.index + i,
+          match[0].length - i,
+          outputArray);
       } else {
         throw Error('linkconfig entry ' + p +
             ' doesn’t contain a link or html attribute.');
       }
+
+      // Update the substring location so we know where we are in relation to
+      // the initial full text string.
+      susbtrIndex = susbtrIndex + match.index + match[0].length;
     }
   }
-  this.addText(text);
+  this.processLinks(text, outputArray);
 };
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index 817d8c5..9aa80b5 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -16,7 +16,6 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
 
 <dom-module id="gr-overlay">
   <template>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index da28e49..9f271ed 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -24,29 +24,13 @@
       Polymer.IronOverlayBehavior,
     ],
 
-    detached: function() {
-      // For good measure.
-      Gerrit.KeyboardShortcutBehavior.enabled = true;
-    },
-
     open: function() {
       return new Promise(function(resolve) {
-        Gerrit.KeyboardShortcutBehavior.enabled = false;
         Polymer.IronOverlayBehaviorImpl.open.apply(this, arguments);
         this._awaitOpen(resolve);
       }.bind(this));
     },
 
-    close: function() {
-      Gerrit.KeyboardShortcutBehavior.enabled = true;
-      Polymer.IronOverlayBehaviorImpl.close.apply(this, arguments);
-    },
-
-    cancel: function() {
-      Gerrit.KeyboardShortcutBehavior.enabled = true;
-      Polymer.IronOverlayBehaviorImpl.cancel.apply(this, arguments);
-    },
-
     /**
      * Override the focus stops that iron-overlay-behavior tries to find.
      */
@@ -72,5 +56,9 @@
       }.bind(this);
       step.call(this);
     },
+
+    _id: function() {
+      return this.getAttribute('id') || 'global';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html b/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html
new file mode 100644
index 0000000..2c8be31a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html
@@ -0,0 +1,54 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-placeholder">
+  <template>
+    <style>
+      main {
+        margin: 2em auto;
+        max-width: 46em;
+      }
+      h1 {
+        margin-bottom: .1em;
+      }
+      @media only screen and (max-width: 67em) {
+        main {
+          margin: 2em 0 2em 15em;
+        }
+      }
+      @media only screen and (max-width: 53em) {
+        .loading {
+          padding: 0 var(--default-horizontal-margin);
+        }
+        main {
+          margin: 2em 1em;
+        }
+      }
+    </style>
+    <main>
+      <h1>[[title]]</h1>
+      <section>
+        This page is not yet implemented in PolyGerrit. View it in the
+        <a id="gwtLink" href$="[[computeGwtUrl(path)]]" rel="external">
+        Old UI</a>
+      </section>
+    </main>
+  </template>
+  <script src="gr-placeholder.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.js b/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.js
new file mode 100644
index 0000000..9b60061
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.js
@@ -0,0 +1,29 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-placeholder',
+
+    properties: {
+      path: String,
+      title: String,
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index 4980cba..1e5fdaa 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -14,11 +14,13 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <script src="../../../bower_components/es6-promise/dist/es6-promise.min.js"></script>
 <script src="../../../bower_components/fetch/fetch.js"></script>
 
 <dom-module id="gr-rest-api-interface">
   <script src="gr-rest-api-interface.js"></script>
+  <script src="gr-reviewer-updates-parser.js"></script>
 </dom-module>
-
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 2f109c9..d618c17 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
@@ -14,9 +14,18 @@
 (function() {
   'use strict';
 
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
   var JSON_PREFIX = ')]}\'';
+  var MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
   var PARENT_PATCH_NUM = 'PARENT';
 
+  var Requests = {
+    SEND_DIFF_DRAFT: 'sendDiffDraft',
+  };
+
   // Must be kept in sync with the ListChangesOption enum and protobuf.
   var ListChangesOption = {
     LABELS: 0,
@@ -62,12 +71,23 @@
     COMMIT_FOOTERS: 17,
 
     // Include push certificate information along with any patch sets.
-    PUSH_CERTIFICATES: 18
+    PUSH_CERTIFICATES: 18,
+
+    // Include change's reviewer updates.
+    REVIEWER_UPDATES: 19,
+
+    // Set the submittable boolean.
+    SUBMITTABLE: 20,
   };
 
   Polymer({
     is: 'gr-rest-api-interface',
 
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.PathListBehavior,
+    ],
+
     /**
      * Fired when an server error occurs.
      *
@@ -83,22 +103,29 @@
     properties: {
       _cache: {
         type: Object,
-        value: {},  // Intentional to share the object accross instances.
+        value: {},  // Intentional to share the object across instances.
       },
       _sharedFetchPromises: {
         type: Object,
-        value: {},  // Intentional to share the object accross instances.
+        value: {},  // Intentional to share the object across instances.
+      },
+      _pendingRequests: {
+        type: Object,
+        value: {},  // Intentional to share the object across instances.
       },
     },
 
     fetchJSON: function(url, opt_errFn, opt_cancelCondition, opt_params,
         opt_opts) {
       opt_opts = opt_opts || {};
-
+      // Issue 5715, This can be reverted back once
+      // iOS 10.3 and mac os 10.12.4 has the fetch api fix.
       var fetchOptions = {
-        credentials: 'same-origin',
-        headers: opt_opts.headers,
+        credentials: 'same-origin'
       };
+      if (opt_opts.headers !== undefined) {
+        fetchOptions['headers'] = opt_opts.headers;
+      }
 
       var urlWithParams = this._urlWithParams(url, opt_params);
       return fetch(urlWithParams, fetchOptions).then(function(response) {
@@ -129,7 +156,7 @@
     },
 
     _urlWithParams: function(url, opt_params) {
-      if (!opt_params) { return url; }
+      if (!opt_params) { return this.getBaseUrl() + url; }
 
       var params = [];
       for (var p in opt_params) {
@@ -144,7 +171,7 @@
             encodeURIComponent(values[i]));
         }
       }
-      return url + '?' + params.join('&');
+      return this.getBaseUrl() + url + '?' + params.join('&');
     },
 
     getResponseObject: function(response) {
@@ -185,9 +212,11 @@
           auto_hide_diff_table_header: true,
           context: 10,
           cursor_blink_rate: 0,
+          font_size: 12,
           ignore_whitespace: 'IGNORE_NONE',
           intraline_difference: true,
           line_length: 100,
+          line_wrapping: false,
           show_line_endings: true,
           show_tabs: true,
           show_whitespace_errors: true,
@@ -199,11 +228,19 @@
     },
 
     savePreferences: function(prefs, opt_errFn, opt_ctx) {
+      // Note (Issue 5142): normalize the download scheme with lower case before
+      // saving.
+      if (prefs.download_scheme) {
+        prefs.download_scheme = prefs.download_scheme.toLowerCase();
+      }
+
       return this.send('PUT', '/accounts/self/preferences', prefs, opt_errFn,
           opt_ctx);
     },
 
     saveDiffPreferences: function(prefs, opt_errFn, opt_ctx) {
+      // Invalidate the cache.
+      this._cache['/accounts/self/preferences.diff'] = undefined;
       return this.send('PUT', '/accounts/self/preferences.diff', prefs,
           opt_errFn, opt_ctx);
     },
@@ -232,37 +269,102 @@
 
     setPreferredAccountEmail: function(email, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/emails/' +
-          encodeURIComponent(email) + '/preferred', null, opt_errFn, opt_ctx);
+          encodeURIComponent(email) + '/preferred', null,
+          opt_errFn, opt_ctx).then(function() {
+        // If result of getAccountEmails is in cache, update it in the cache
+        // so we don't have to invalidate it.
+        var cachedEmails = this._cache['/accounts/self/emails'];
+        if (cachedEmails) {
+          var emails = cachedEmails.map(function(entry) {
+            if (entry.email === email) {
+              return {email: email, preferred: true};
+            } else {
+              return {email: email};
+            }
+          });
+          this._cache['/accounts/self/emails'] = emails;
+        }
+      }.bind(this));
     },
 
     setAccountName: function(name, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/name', {name: name}, opt_errFn,
-          opt_ctx);
+          opt_ctx).then(function(response) {
+            // If result of getAccount is in cache, update it in the cache
+            // so we don't have to invalidate it.
+            var cachedAccount = this._cache['/accounts/self/detail'];
+            if (cachedAccount) {
+              return this.getResponseObject(response).then(function(newName) {
+                // Replace object in cache with new object to force UI updates.
+                // TODO(logan): Polyfill for Object.assign in IE
+                this._cache['/accounts/self/detail'] = Object.assign(
+                    {}, cachedAccount, {name: newName});
+              }.bind(this));
+            }
+          }.bind(this));
+    },
+
+    setAccountStatus: function(status, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/status', {status: status},
+          opt_errFn, opt_ctx).then(function(response) {
+            // If result of getAccount is in cache, update it in the cache
+            // so we don't have to invalidate it.
+            var cachedAccount = this._cache['/accounts/self/detail'];
+            if (cachedAccount) {
+              return this.getResponseObject(response).then(function(newStatus) {
+                // Replace object in cache with new object to force UI updates.
+                // TODO(logan): Polyfill for Object.assign in IE
+                this._cache['/accounts/self/detail'] = Object.assign(
+                    {}, cachedAccount, {status: newStatus});
+              }.bind(this));
+            }
+          }.bind(this));
     },
 
     getAccountGroups: function() {
       return this._fetchSharedCacheURL('/accounts/self/groups');
     },
 
+    getAccountCapabilities: function(opt_params) {
+      var queryString = '';
+      if (opt_params) {
+        queryString = '?q=' + opt_params
+            .map(function(param) { return encodeURIComponent(param); })
+            .join('&q=');
+      }
+      return this._fetchSharedCacheURL('/accounts/self/capabilities' +
+          queryString);
+    },
+
     getLoggedIn: function() {
       return this.getAccount().then(function(account) {
         return account != null;
       });
     },
 
-    refreshCredentials: function() {
-      this._cache = {};
-      return this.getLoggedIn();
+    checkCredentials: function() {
+      // Skip the REST response cache.
+      return this.fetchJSON('/accounts/self/detail');
     },
 
     getPreferences: function() {
       return this.getLoggedIn().then(function(loggedIn) {
         if (loggedIn) {
-          return this._fetchSharedCacheURL('/accounts/self/preferences');
+          return this._fetchSharedCacheURL('/accounts/self/preferences').then(
+              function(res) {
+            if (this._isNarrowScreen()) {
+              res.default_diff_view = DiffViewMode.UNIFIED;
+            } else {
+              res.default_diff_view = res.diff_view;
+            }
+            return Promise.resolve(res);
+          }.bind(this));
         }
 
         return Promise.resolve({
           changes_per_page: 25,
+          default_diff_view: this._isNarrowScreen() ?
+              DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
           diff_view: 'SIDE_BY_SIDE',
         });
       }.bind(this));
@@ -307,11 +409,19 @@
       return this._sharedFetchPromises[url];
     },
 
+    _isNarrowScreen: function() {
+      return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
+    },
+
     getChanges: function(changesPerPage, opt_query, opt_offset) {
       var options = this._listChangesOptionsToHex(
           ListChangesOption.LABELS,
           ListChangesOption.DETAILED_ACCOUNTS
       );
+      // Issue 4524: respect legacy token with max sortkey.
+      if (opt_offset === 'n,z') {
+        opt_offset = 0;
+      }
       var params = {
         n: changesPerPage,
         O: options,
@@ -333,8 +443,9 @@
         O: options,
         q: [
           'is:open owner:self',
-          'is:open reviewer:self -owner:self',
-          'is:closed (owner:self OR reviewer:self) -age:4w limit:10',
+          'is:open ((reviewer:self -owner:self -star:ignore) OR assignee:self)',
+          'is:closed (owner:self OR reviewer:self OR assignee:self) -age:4w ' +
+            'limit:10',
         ],
       };
       return this.fetchJSON('/changes/', null, null, params);
@@ -346,12 +457,17 @@
 
     getChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
       var options = this._listChangesOptionsToHex(
+          ListChangesOption.ALL_COMMITS,
           ListChangesOption.ALL_REVISIONS,
           ListChangesOption.CHANGE_ACTIONS,
-          ListChangesOption.DOWNLOAD_COMMANDS
+          ListChangesOption.CURRENT_ACTIONS,
+          ListChangesOption.DOWNLOAD_COMMANDS,
+          ListChangesOption.SUBMITTABLE,
+          ListChangesOption.WEB_LINKS
       );
-      return this._getChangeDetail(changeNum, options, opt_errFn,
-          opt_cancelCondition);
+      return this._getChangeDetail(
+          changeNum, options, opt_errFn, opt_cancelCondition)
+            .then(GrReviewerUpdatesParser.parse);
     },
 
     getDiffChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
@@ -392,13 +508,13 @@
 
     getChangeFilePathsAsSpeciallySortedArray: function(changeNum, patchRange) {
       return this.getChangeFiles(changeNum, patchRange).then(function(files) {
-        return Object.keys(files).sort(this._specialFilePathCompare.bind(this));
+        return Object.keys(files).sort(this.specialFilePathCompare);
       }.bind(this));
     },
 
     _normalizeChangeFilesResponse: function(response) {
-      var paths = Object.keys(response).sort(
-          this._specialFilePathCompare.bind(this));
+      if (!response) { return []; }
+      var paths = Object.keys(response).sort(this.specialFilePathCompare);
       var files = [];
       for (var i = 0; i < paths.length; i++) {
         var info = response[paths[i]];
@@ -410,48 +526,14 @@
       return files;
     },
 
-    _specialFilePathCompare: function(a, b) {
-      var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-      // The commit message always goes first.
-      if (a === COMMIT_MESSAGE_PATH) {
-        return -1;
-      }
-      if (b === COMMIT_MESSAGE_PATH) {
-        return 1;
-      }
-
-      var aLastDotIndex = a.lastIndexOf('.');
-      var aExt = a.substr(aLastDotIndex + 1);
-      var aFile = a.substr(0, aLastDotIndex);
-
-      var bLastDotIndex = b.lastIndexOf('.');
-      var bExt = b.substr(bLastDotIndex + 1);
-      var bFile = a.substr(0, bLastDotIndex);
-
-      // Sort header files above others with the same base name.
-      var headerExts = ['h', 'hxx', 'hpp'];
-      if (aFile.length > 0 && aFile === bFile) {
-        if (headerExts.indexOf(aExt) !== -1 &&
-            headerExts.indexOf(bExt) !== -1) {
-          return a.localeCompare(b);
-        }
-        if (headerExts.indexOf(aExt) !== -1) {
-          return -1;
-        }
-        if (headerExts.indexOf(bExt) !== -1) {
-          return 1;
-        }
-      }
-
-      return a.localeCompare(b);
-    },
-
     getChangeRevisionActions: function(changeNum, patchNum) {
       return this.fetchJSON(
           this.getChangeActionURL(changeNum, patchNum, '/actions')).then(
               function(revisionActions) {
                 // The rebase button on change screen is always enabled.
                 if (revisionActions.rebase) {
+                  revisionActions.rebase.rebaseOnCurrent =
+                      !!revisionActions.rebase.enabled;
                   revisionActions.rebase.enabled = true;
                 }
                 return revisionActions;
@@ -467,8 +549,22 @@
       });
     },
 
-    getSuggestedProjects: function(inputVal, opt_errFn, opt_ctx) {
-      return this.fetchJSON('/projects/', opt_errFn, opt_ctx, {p: inputVal});
+    getSuggestedGroups: function(inputVal, opt_n, opt_errFn, opt_ctx) {
+      var params = {s: inputVal};
+      if (opt_n) { params.n = opt_n; }
+      return this.fetchJSON('/groups/', opt_errFn, opt_ctx, params);
+    },
+
+    getSuggestedProjects: function(inputVal, opt_n, opt_errFn, opt_ctx) {
+      var params = {p: inputVal};
+      if (opt_n) { params.n = opt_n; }
+      return this.fetchJSON('/projects/', opt_errFn, opt_ctx, params);
+    },
+
+    getSuggestedAccounts: function(inputVal, opt_n, opt_errFn, opt_ctx) {
+      var params = {q: inputVal, suggest: null};
+      if (opt_n) { params.n = opt_n; }
+      return this.fetchJSON('/accounts/', opt_errFn, opt_ctx, params);
     },
 
     addChangeReviewer: function(changeNum, reviewerID) {
@@ -531,7 +627,7 @@
       ].join(' ');
       var params = {
         O: options,
-        q: query
+        q: query,
       };
       return this.fetchJSON('/changes/', null, null, params);
     },
@@ -570,6 +666,58 @@
       return this.send('POST', url, review, opt_errFn, opt_ctx);
     },
 
+    getFileInChangeEdit: function(changeNum, path) {
+      return this.send('GET',
+          this.getChangeActionURL(changeNum, null,
+              '/edit/' + encodeURIComponent(path)
+          ));
+    },
+
+    rebaseChangeEdit: function(changeNum) {
+      return this.send('POST',
+          this.getChangeActionURL(changeNum, null,
+              '/edit:rebase'
+          ));
+    },
+
+    deleteChangeEdit: function(changeNum) {
+      return this.send('DELETE',
+          this.getChangeActionURL(changeNum, null,
+              '/edit'
+          ));
+    },
+
+    restoreFileInChangeEdit: function(changeNum, restore_path) {
+      return this.send('POST',
+          this.getChangeActionURL(changeNum, null, '/edit'),
+          {restore_path: restore_path}
+      );
+    },
+
+    renameFileInChangeEdit: function(changeNum, old_path, new_path) {
+      return this.send('POST',
+          this.getChangeActionURL(changeNum, null, '/edit'),
+          {old_path: old_path},
+          {new_path: new_path}
+      );
+    },
+
+    deleteFileInChangeEdit: function(changeNum, path) {
+      return this.send('DELETE',
+          this.getChangeActionURL(changeNum, null,
+              '/edit/' + encodeURIComponent(path)
+          ));
+    },
+
+    saveChangeEdit: function(changeNum, path, contents) {
+      return this.send('PUT',
+          this.getChangeActionURL(changeNum, null,
+              '/edit/' + encodeURIComponent(path)
+          ),
+          contents
+      );
+    },
+
     saveChangeCommitMessageEdit: function(changeNum, message) {
       var url = this.getChangeActionURL(changeNum, null, '/edit:message');
       return this.send('PUT', url, {message: message});
@@ -602,10 +750,10 @@
         }
         options.body = opt_body;
       }
-      return fetch(url, options).then(function(response) {
+      return fetch(this.getBaseUrl() + url, options).then(function(response) {
         if (!response.ok) {
           if (opt_errFn) {
-            opt_errFn.call(null, response);
+            opt_errFn.call(opt_ctx || null, response);
             return undefined;
           }
           this.fire('server-error', {response: response});
@@ -648,12 +796,41 @@
           opt_patchNum, opt_path);
     },
 
+    getDiffRobotComments: function(changeNum, basePatchNum, patchNum,
+        opt_path) {
+      return this._getDiffComments(changeNum, '/robotcomments', basePatchNum,
+          patchNum, opt_path);
+    },
+
     getDiffDrafts: function(changeNum, opt_basePatchNum, opt_patchNum,
         opt_path) {
       return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
           opt_patchNum, opt_path);
     },
 
+    _setRange: function(comments, comment) {
+      if (comment.in_reply_to && !comment.range) {
+        for (var i = 0; i < comments.length; i++) {
+          if (comments[i].id === comment.in_reply_to) {
+            comment.range = comments[i].range;
+            break;
+          }
+        }
+      }
+      return comment;
+    },
+
+    _setRanges: function(comments) {
+      comments = comments || [];
+      comments.sort(function(a, b) {
+        return util.parseDate(a.updated) - util.parseDate(b.updated);
+      });
+      comments.forEach(function(comment) {
+        this._setRange(comments, comment);
+      }.bind(this));
+      return comments;
+    },
+
     _getDiffComments: function(changeNum, endpoint, opt_basePatchNum,
         opt_patchNum, opt_path) {
       if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
@@ -672,6 +849,14 @@
           this._getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum);
       promises.push(this.fetchJSON(url).then(function(response) {
         comments = response[opt_path] || [];
+
+        // TODO(kaspern): Implement this on in the backend so this can be
+        // removed.
+
+        // Sort comments by date so that parent ranges can be propagated in a
+        // single pass.
+        comments = this._setRanges(comments);
+
         if (opt_basePatchNum == PARENT_PATCH_NUM) {
           baseComments = comments.filter(onlyParent);
           baseComments.forEach(setPath);
@@ -686,8 +871,11 @@
             opt_basePatchNum);
         promises.push(this.fetchJSON(baseURL).then(function(response) {
           baseComments = (response[opt_path] || []).filter(withoutParent);
+
+          baseComments = this._setRanges(baseComments);
+
           baseComments.forEach(setPath);
-        }));
+        }.bind(this)));
       }
 
       return Promise.all(promises).then(function() {
@@ -710,6 +898,10 @@
       return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
     },
 
+    hasPendingDiffDrafts: function() {
+      return !!this._pendingRequests[Requests.SEND_DIFF_DRAFT];
+    },
+
     _sendDiffDraftRequest: function(method, changeNum, patchNum, draft) {
       var url = this.getChangeActionURL(changeNum, patchNum, '/drafts');
       if (draft.id) {
@@ -720,7 +912,15 @@
         body = draft;
       }
 
-      return this.send(method, url, body);
+      if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
+        this._pendingRequests[Requests.SEND_DIFF_DRAFT] = 0;
+      }
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT]++;
+
+      return this.send(method, url, body).then(function(res) {
+        this._pendingRequests[Requests.SEND_DIFF_DRAFT]--;
+        return res;
+      }.bind(this));
     },
 
     _changeBaseURL: function(changeNum, opt_patchNum) {
@@ -763,7 +963,7 @@
     },
 
     _fetchB64File: function(url) {
-      return fetch(url).then(function(response) {
+      return fetch(this.getBaseUrl() + url, {credentials: 'same-origin'}).then(function(response) {
         var type = response.headers.get('X-FYI-Content-Type');
         return response.text()
           .then(function(text) {
@@ -841,11 +1041,6 @@
           '/topic', {topic: topic});
     },
 
-    getAccountHttpPassword: function(opt_errFn) {
-      return this._fetchSharedCacheURL('/accounts/self/password.http',
-          opt_errFn);
-    },
-
     deleteAccountHttpPassword: function() {
       return this.send('DELETE', '/accounts/self/password.http');
     },
@@ -877,5 +1072,44 @@
     deleteAccountSSHKey: function(id) {
       return this.send('DELETE', '/accounts/self/sshkeys/' + id);
     },
+
+    deleteVote: function(changeID, account, label) {
+      return this.send('DELETE', '/changes/' + changeID +
+          '/reviewers/' + account + '/votes/' + encodeURIComponent(label));
+    },
+
+    setDescription: function(changeNum, patchNum, desc) {
+      return this.send('PUT',
+          this.getChangeActionURL(changeNum, patchNum, '/description'),
+          {description: desc});
+    },
+
+    confirmEmail: function(token) {
+      return this.send('PUT', '/config/server/email.confirm', {token: token})
+          .then(function(response) {
+            if (response.status === 204) {
+              return 'Email confirmed successfully.';
+            }
+            return null;
+          });
+    },
+
+    setAssignee: function(changeNum, assignee) {
+      return this.send('PUT',
+          this.getChangeActionURL(changeNum, null, '/assignee'),
+          {assignee: assignee});
+    },
+
+    deleteAssignee: function(changeNum) {
+      return this.send('DELETE',
+          this.getChangeActionURL(changeNum, null, '/assignee'));
+    },
+
+    probePath: function(path) {
+      return fetch(new Request(path, {method: 'HEAD'}))
+        .then(function(response) {
+          return response.ok;
+        });
+    },
   });
 })();
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 8dda2ce..0ff162d 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
@@ -18,12 +18,15 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rest-api-interface</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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-rest-api-interface.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-rest-api-interface></gr-rest-api-interface>
@@ -38,6 +41,13 @@
     setup(function() {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
+      var testJSON = ')]}\'\n{"hello": "bonjour"}';
+      sandbox.stub(window, 'fetch').returns(Promise.resolve({
+        ok: true,
+        text: function() {
+          return Promise.resolve(testJSON);
+        },
+      }));
     });
 
     teardown(function() {
@@ -45,16 +55,6 @@
     });
 
     test('JSON prefix is properly removed', function(done) {
-      var testJSON = ')]}\'\n{"hello": "bonjour"}';
-
-      sandbox.stub(window, 'fetch', function() {
-        return Promise.resolve({
-          ok: true,
-          text: function() {
-            return Promise.resolve(testJSON);
-          },
-        });
-      });
       element.fetchJSON('/dummy/url').then(function(obj) {
         assert.deepEqual(obj, {hello: 'bonjour'});
         done();
@@ -112,13 +112,11 @@
 
     test('request callbacks can be canceled', function(done) {
       var cancelCalled = false;
-      sandbox.stub(window, 'fetch', function() {
-        return Promise.resolve({
-          body: {
-            cancel: function() { cancelCalled = true; }
-          },
-        });
-      });
+      window.fetch.returns(Promise.resolve({
+        body: {
+          cancel: function() { cancelCalled = true; },
+        },
+      }));
       element.fetchJSON('/dummy/url', null, function() { return true; }).then(
         function(obj) {
           assert.isUndefined(obj);
@@ -133,11 +131,13 @@
           '/COMMIT_MSG': [],
           'sieve.go': [
             {
+              updated: '2017-02-03 22:32:28.000000000',
               message: 'this isn’t quite right',
             },
             {
               side: 'PARENT',
               message: 'how did this work in the first place?',
+              updated: '2017-02-03 22:33:28.000000000',
             },
           ],
         });
@@ -149,16 +149,123 @@
             side: 'PARENT',
             message: 'how did this work in the first place?',
             path: 'sieve.go',
+            updated: '2017-02-03 22:33:28.000000000',
           });
           assert.equal(obj.comments.length, 1);
           assert.deepEqual(obj.comments[0], {
             message: 'this isn’t quite right',
             path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
           });
           done();
         });
     });
 
+    test('_setRange', function() {
+      var comments = [
+        {
+          id: 1,
+          side: 'PARENT',
+          message: 'how did this work in the first place?',
+          updated: '2017-02-03 22:32:28.000000000',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 2,
+            end_character: 1,
+          },
+        },
+        {
+          id: 2,
+          in_reply_to: 1,
+          message: 'this isn’t quite right',
+          updated: '2017-02-03 22:33:28.000000000',
+        },
+      ];
+      var expectedResult = {
+        id: 2,
+        in_reply_to: 1,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      };
+      var comment = comments[1];
+      assert.deepEqual(element._setRange(comments, comment), expectedResult);
+    });
+
+    test('_setRanges', function() {
+      var comments = [
+        {
+          id: 3,
+          in_reply_to: 2,
+          message: 'this isn’t quite right either',
+          updated: '2017-02-03 22:34:28.000000000',
+        },
+        {
+          id: 2,
+          in_reply_to: 1,
+          message: 'this isn’t quite right',
+          updated: '2017-02-03 22:33:28.000000000',
+        },
+        {
+          id: 1,
+          side: 'PARENT',
+          message: 'how did this work in the first place?',
+          updated: '2017-02-03 22:32:28.000000000',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 2,
+            end_character: 1,
+          },
+        },
+      ];
+      var expectedResult = [
+        {
+          id: 1,
+          side: 'PARENT',
+          message: 'how did this work in the first place?',
+          updated: '2017-02-03 22:32:28.000000000',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 2,
+            end_character: 1,
+          },
+        },
+        {
+          id: 2,
+          in_reply_to: 1,
+          message: 'this isn’t quite right',
+          updated: '2017-02-03 22:33:28.000000000',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 2,
+            end_character: 1,
+          },
+        },
+        {
+          id: 3,
+          in_reply_to: 2,
+          message: 'this isn’t quite right either',
+          updated: '2017-02-03 22:34:28.000000000',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 2,
+            end_character: 1,
+          },
+        },
+      ];
+      assert.deepEqual(element._setRanges(comments), expectedResult);
+    });
+
     test('differing patch diff comments are properly grouped', function(done) {
       sandbox.stub(element, 'fetchJSON', function(url) {
         if (url == '/changes/42/revisions/1') {
@@ -167,10 +274,12 @@
             'sieve.go': [
               {
                 message: 'this isn’t quite right',
+                updated: '2017-02-03 22:32:28.000000000',
               },
               {
                 side: 'PARENT',
                 message: 'how did this work in the first place?',
+                updated: '2017-02-03 22:33:28.000000000',
               },
             ],
           });
@@ -180,13 +289,16 @@
             'sieve.go': [
               {
                 message: 'What on earth are you thinking, here?',
+                updated: '2017-02-03 22:32:28.000000000',
               },
               {
                 side: 'PARENT',
                 message: 'Yeah not sure how this worked either?',
+                updated: '2017-02-03 22:33:28.000000000',
               },
               {
                 message: '¯\\_(ツ)_/¯',
+                updated: '2017-02-04 22:33:28.000000000',
               },
             ],
           });
@@ -198,15 +310,18 @@
           assert.deepEqual(obj.baseComments[0], {
             message: 'this isn’t quite right',
             path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
           });
           assert.equal(obj.comments.length, 2);
           assert.deepEqual(obj.comments[0], {
             message: 'What on earth are you thinking, here?',
             path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
           });
           assert.deepEqual(obj.comments[1], {
             message: '¯\\_(ツ)_/¯',
             path: 'sieve.go',
+            updated: '2017-02-04 22:33:28.000000000',
           });
           done();
         });
@@ -215,49 +330,90 @@
     test('special file path sorting', function() {
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['/COMMIT_MSG', '.a', '.b', 'file']);
 
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
 
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
 
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
 
       assert.deepEqual(
           ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
-              element._specialFilePathCompare),
+              element.specialFilePathCompare),
           ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
+
+      // Regression test for Issue 4448.
+      assert.deepEqual([
+          'minidump/minidump_memory_writer.cc',
+          'minidump/minidump_memory_writer.h',
+          'minidump/minidump_thread_writer.cc',
+          'minidump/minidump_thread_writer.h',
+          ]
+        .sort(element.specialFilePathCompare),
+          [
+            'minidump/minidump_memory_writer.h',
+            'minidump/minidump_memory_writer.cc',
+            'minidump/minidump_thread_writer.h',
+            'minidump/minidump_thread_writer.cc',
+          ]);
+
+      // Regression test for Issue 4545.
+      assert.deepEqual([
+          'task_test.go',
+          'task.go',
+          ]
+        .sort(element.specialFilePathCompare),
+          [
+            'task.go',
+            'task_test.go',
+          ]);
     });
 
-    test('rebase always enabled', function(done) {
+    suite('rebase action', function() {
       var resolveFetchJSON;
-      sandbox.stub(element, 'fetchJSON').returns(
-          new Promise(function(resolve) {
-            resolveFetchJSON = resolve;
-          }));
-      element.getChangeRevisionActions('42', '1337').then(
+      setup(function() {
+        sandbox.stub(element, 'fetchJSON').returns(
+            new Promise(function(resolve) {
+              resolveFetchJSON = resolve;
+            }));
+      });
+
+      test('no rebase on current', function(done) {
+        element.getChangeRevisionActions('42', '1337').then(
           function(response) {
             assert.isTrue(response.rebase.enabled);
+            assert.isFalse(response.rebase.rebaseOnCurrent);
             done();
           });
-      resolveFetchJSON({rebase: {}});
+        resolveFetchJSON({rebase: {}});
+      });
+
+      test('rebase on current', function(done) {
+        element.getChangeRevisionActions('42', '1337').then(
+          function(response) {
+            assert.isTrue(response.rebase.enabled);
+            assert.isTrue(response.rebase.rebaseOnCurrent);
+            done();
+          });
+        resolveFetchJSON({rebase: {enabled: true}});
+      });
     });
 
+
     test('server error', function(done) {
       var getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
-      sandbox.stub(window, 'fetch', function() {
-        return Promise.resolve({ok: false});
-      });
+      window.fetch.returns(Promise.resolve({ok: false}));
       var serverErrorEventPromise = new Promise(function(resolve) {
         element.addEventListener('server-error', function() { resolve(); });
       });
@@ -272,31 +428,192 @@
           });
     });
 
-    test('refreshCredentials', function(done) {
+    test('checkCredentials', function(done) {
       var responses = [
         {
           ok: false,
           status: 403,
-          text: function() { return Promise.resolve(); }
+          text: function() { return Promise.resolve(); },
         },
         {
           ok: true,
           status: 200,
-          text: function() { return Promise.resolve(')]}\'{}'); }
+          text: function() { return Promise.resolve(')]}\'{}'); },
         },
       ];
-      var fetchStub = sandbox.stub(window, 'fetch', function(url) {
+      window.fetch.restore();
+      sandbox.stub(window, 'fetch', function(url) {
         if (url === '/accounts/self/detail') {
           return Promise.resolve(responses.shift());
         }
       });
-      element.getLoggedIn().then(function(isLoggedIn) {
-        assert.isFalse(isLoggedIn);
-        element.refreshCredentials().then(function(isRefreshed) {
-          assert.isTrue(isRefreshed);
+
+      element.getLoggedIn().then(function(account) {
+        assert.isNotOk(account);
+        element.checkCredentials().then(function(account) {
+          assert.isOk(account);
           done();
         });
       });
     });
+
+    test('legacy n,z key in change url is replaced', function() {
+      var stub = sandbox.stub(element, 'fetchJSON');
+      element.getChanges(1, null, 'n,z');
+      assert.equal(stub.args[0][3].S, 0);
+    });
+
+    test('saveDiffPreferences invalidates cache line', function() {
+      var cacheKey = '/accounts/self/preferences.diff';
+      sandbox.stub(element, 'send');
+      element._cache[cacheKey] = {tab_size: 4};
+      element.saveDiffPreferences({tab_size: 8});
+      assert.isTrue(element.send.called);
+      assert.notOk(element._cache[cacheKey]);
+    });
+
+    var preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+      sandbox.stub(element, 'getLoggedIn', function() {
+        return Promise.resolve(loggedIn);
+      });
+      sandbox.stub(element, '_isNarrowScreen', function() {
+        return smallScreen;
+      });
+      sandbox.stub(element, '_fetchSharedCacheURL', function() {
+        return Promise.resolve(testJSON);
+      });
+    };
+
+    test('getPreferences returns correctly on small screens logged in',
+        function(done) {
+
+      var testJSON = {diff_view: 'SIDE_BY_SIDE'};
+      var loggedIn = true;
+      var smallScreen = true;
+
+      preferenceSetup(testJSON, loggedIn, smallScreen);
+
+      element.getPreferences().then(function(obj) {
+        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+        done();
+      });
+    });
+
+    test('getPreferences returns correctly on small screens not logged in',
+          function(done) {
+
+      var testJSON = {diff_view: 'SIDE_BY_SIDE'};
+      var loggedIn = false;
+      var smallScreen = true;
+
+      preferenceSetup(testJSON, loggedIn, smallScreen);
+      element.getPreferences().then(function(obj) {
+        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+        done();
+      });
+    });
+
+    test('getPreferences returns correctly on larger screens logged in',
+        function(done) {
+      var testJSON = {diff_view: 'UNIFIED_DIFF'};
+      var loggedIn = true;
+      var smallScreen = false;
+
+      preferenceSetup(testJSON, loggedIn, smallScreen);
+
+      element.getPreferences().then(function(obj) {
+        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+        assert.equal(obj.diff_view, 'UNIFIED_DIFF');
+        done();
+      });
+    });
+
+    test('getPreferences returns correctly on larger screens not logged in',
+        function(done) {
+      var testJSON = {diff_view: 'UNIFIED_DIFF'};
+      var loggedIn = false;
+      var smallScreen = false;
+
+      preferenceSetup(testJSON, loggedIn, smallScreen);
+
+      element.getPreferences().then(function(obj) {
+        assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
+        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+        done();
+      });
+    });
+
+    test('savPreferences normalizes download scheme', function() {
+      sandbox.stub(element, 'send');
+      element.savePreferences({download_scheme: 'HTTP'});
+      assert.isTrue(element.send.called);
+      assert.equal(element.send.lastCall.args[2].download_scheme, 'http');
+    });
+
+    test('confirmEmail', function() {
+      sandbox.spy(element, 'send');
+      element.confirmEmail('foo');
+      assert.isTrue(element.send.calledWith(
+          'PUT', '/config/server/email.confirm', {token: 'foo'}));
+    });
+
+    test('GrReviewerUpdatesParser.parse is used', function() {
+      sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
+          Promise.resolve('foo'));
+      return element.getChangeDetail(42).then(function(result) {
+        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
+        assert.equal(result, 'foo');
+      });
+    });
+
+    test('setAccountStatus', function(done) {
+      sandbox.stub(element, 'send').returns(Promise.resolve('OOO'));
+      sandbox.stub(element, 'getResponseObject')
+          .returns(Promise.resolve('OOO'));
+      element._cache['/accounts/self/detail'] = {};
+      element.setAccountStatus('OOO').then(function() {
+        assert.isTrue(element.send.calledWith('PUT', '/accounts/self/status',
+            {status: 'OOO'}));
+        assert.deepEqual(element._cache['/accounts/self/detail'],
+            {status: 'OOO'});
+        done();
+      });
+    });
+
+    test('_sendDiffDraft pending requests tracked', function(done) {
+      sandbox.stub(element, 'send', function() {
+        assert.equal(element._pendingRequests.sendDiffDraft, 1);
+        return Promise.resolve([]);
+      });
+      element.saveDiffDraft('', 1, 1).then(function() {
+        assert.equal(element._pendingRequests.sendDiffDraft, 0);
+        element.deleteDiffDraft('', 1, 1).then(function() {
+          assert.equal(element._pendingRequests.sendDiffDraft, 0);
+          done();
+        });
+      });
+    });
+
+    test('saveChangeEdit', function(done) {
+      var change_num = '1';
+      var file_name = 'index.php';
+      var file_contents = '<?php';
+      sandbox.stub(element, 'send').returns(
+          Promise.resolve([change_num, file_name, file_contents])
+      );
+      sandbox.stub(element, 'getResponseObject')
+          .returns(Promise.resolve([change_num, file_name, file_contents]));
+      element._cache['/changes/' + change_num + '/edit/' + file_name] = {};
+      element.saveChangeEdit(change_num, file_name, file_contents).then(
+          function() {
+            assert.isTrue(element.send.calledWith('PUT',
+                '/changes/' + change_num + '/edit/' + file_name,
+                file_contents));
+            done();
+          }
+      );
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
new file mode 100644
index 0000000..21a6bc6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
@@ -0,0 +1,193 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrReviewerUpdatesParser) { return; }
+
+  function GrReviewerUpdatesParser(change) {
+    // TODO (viktard): Polyfill Object.assign for IE.
+    this.result = Object.assign({}, change);
+    this._lastState = {};
+  };
+
+  GrReviewerUpdatesParser.parse = function(change) {
+    if (!change ||
+        !change.messages ||
+        !change.reviewer_updates ||
+        !change.reviewer_updates.length) {
+      return change;
+    }
+    var parser = new GrReviewerUpdatesParser(change);
+    parser._filterRemovedMessages();
+    parser._groupUpdates();
+    parser._formatUpdates();
+    return parser.result;
+  };
+
+  GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
+
+  GrReviewerUpdatesParser.prototype.result = null;
+  GrReviewerUpdatesParser.prototype._batch = null;
+  GrReviewerUpdatesParser.prototype._updateItems = null;
+  GrReviewerUpdatesParser.prototype._lastState = null;
+
+  /**
+   * Removes messages that describe removed reviewers, since reviewer_updates
+   * are used.
+   */
+  GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
+    this.result.messages = this.result.messages.filter(function(message) {
+      return message.tag !== 'autogenerated:gerrit:deleteReviewer';
+    });
+  };
+
+  /**
+   * Is a part of _groupUpdates(). Creates a new batch of updates.
+   * @param {Object} update instance of ReviewerUpdateInfo
+   */
+  GrReviewerUpdatesParser.prototype._startBatch = function(update) {
+    this._updateItems = [];
+    return {
+      author: update.updated_by,
+      date: update.updated,
+      type: 'REVIEWER_UPDATE',
+    };
+  };
+
+  /**
+   * Is a part of _groupUpdates(). Validates current batch:
+   * - filters out updates that don't change reviewer state.
+   * - updates current reviewer state.
+   * @param {Object} update instance of ReviewerUpdateInfo
+   */
+  GrReviewerUpdatesParser.prototype._completeBatch = function(update) {
+    var items = [];
+    for (var accountId in this._updateItems) {
+      if (!this._updateItems.hasOwnProperty(accountId)) continue;
+      var updateItem = this._updateItems[accountId];
+      if (this._lastState[accountId] !== updateItem.state) {
+        this._lastState[accountId] = updateItem.state;
+        items.push(updateItem);
+      }
+    }
+    if (items.length) {
+      this._batch.updates = items;
+    }
+  };
+
+  /**
+   * Groups reviewer updates. Sequential updates are grouped if:
+   * - They were performed within short timeframe (6 seconds)
+   * - Made by the same person
+   * - Non-change updates are discarded within a group
+   * - Groups with no-change updates are discarded (eg CC -> CC)
+   */
+  GrReviewerUpdatesParser.prototype._groupUpdates = function() {
+    var updates = this.result.reviewer_updates;
+    var newUpdates = updates.reduce(function(newUpdates, update) {
+      if (!this._batch) {
+        this._batch = this._startBatch(update);
+      }
+      var updateDate = util.parseDate(update.updated).getTime();
+      var batchUpdateDate = util.parseDate(this._batch.date).getTime();
+      var reviewerId = update.reviewer._account_id.toString();
+      if (updateDate - batchUpdateDate >
+          GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS ||
+          update.updated_by._account_id !== this._batch.author._account_id) {
+        // Next sequential update should form new group.
+        this._completeBatch();
+        if (this._batch.updates && this._batch.updates.length) {
+          newUpdates.push(this._batch);
+        }
+        this._batch = this._startBatch(update);
+      }
+      this._updateItems[reviewerId] = {
+        reviewer: update.reviewer,
+        state: update.state,
+      };
+      if (this._lastState[reviewerId]) {
+        this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
+      }
+      return newUpdates;
+    }.bind(this), []);
+    this._completeBatch();
+    if (this._batch.updates && this._batch.updates.length) {
+      newUpdates.push(this._batch);
+    }
+    this.result.reviewer_updates = newUpdates;
+  };
+
+  /**
+   * Generates update message for reviewer state change.
+   * @param {string} prev previous reviewer state.
+   * @param {string} state current reviewer state.
+   * @return {string}
+   */
+  GrReviewerUpdatesParser.prototype._getUpdateMessage = function(prev, state) {
+    if (prev === 'REMOVED' || !prev) {
+      return 'added to ' + state + ': ';
+    } else if (state === 'REMOVED') {
+      if (prev) {
+        return 'removed from ' + prev + ': ';
+      } else {
+        return 'removed : ';
+      }
+    } else {
+      return 'moved from ' + prev + ' to ' + state + ': ';
+    }
+  };
+
+  /**
+   * Groups updates for same category (eg CC->CC) into a hash arrays of
+   * reviewers.
+   * @param {!Array<!Object>} updates Array of ReviewerUpdateItemInfo.
+   * @return {!Object} Hash of arrays of AccountInfo, message as key.
+   */
+  GrReviewerUpdatesParser.prototype._groupUpdatesByMessage = function(updates) {
+    return updates.reduce(function(result, item) {
+      var message = this._getUpdateMessage(item.prev_state, item.state);
+      if (!result[message]) {
+        result[message] = [];
+      }
+      result[message].push(item.reviewer);
+      return result;
+    }.bind(this), {});
+  };
+
+  /**
+   * Generates text messages for grouped reviewer updates.
+   * Formats reviewer updates to a (not yet implemented) EventInfo instance.
+   * @see https://gerrit-review.googlesource.com/c/94490/
+   */
+  GrReviewerUpdatesParser.prototype._formatUpdates = function() {
+    this.result.reviewer_updates.forEach(function(update) {
+      var grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      var newUpdates = [];
+      for (var message in grouppedReviewers) {
+        if (grouppedReviewers.hasOwnProperty(message)) {
+          newUpdates.push({
+            message: message,
+            reviewers: grouppedReviewers[message],
+          });
+        }
+      }
+      update.updates = newUpdates;
+    }.bind(this));
+  };
+
+  window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
+
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
new file mode 100644
index 0000000..1ae04a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
@@ -0,0 +1,253 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reviewer-updates-parser</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+
+<script src="../../../scripts/util.js"></script>
+<script src="gr-reviewer-updates-parser.js"></script>
+
+<script>
+  suite('gr-reviewer-updates-parser tests', function() {
+    var sandbox;
+    var instance;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('ignores changes without messages', function() {
+      var change = {};
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_groupUpdates');
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_formatUpdates');
+      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._groupUpdates.called);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._formatUpdates.called);
+    });
+
+    test('ignores changes without reviewer updates', function() {
+      var change = {
+        messages: [],
+      };
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_groupUpdates');
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_formatUpdates');
+      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._groupUpdates.called);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._formatUpdates.called);
+    });
+
+    test('ignores changes with empty reviewer updates', function() {
+      var change = {
+        messages: [],
+        reviewer_updates: [],
+      };
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_groupUpdates');
+      sandbox.stub(
+          GrReviewerUpdatesParser.prototype, '_formatUpdates');
+      assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._groupUpdates.called);
+      assert.isFalse(
+          GrReviewerUpdatesParser.prototype._formatUpdates.called);
+    });
+
+    test('filter removed messages', function() {
+      var change = {
+          messages: [
+            {
+              message: 'msg1',
+              tag: 'autogenerated:gerrit:deleteReviewer',
+            },
+            {
+              message: 'msg2',
+              tag: 'foo',
+            }
+          ],
+      };
+      instance = new GrReviewerUpdatesParser(change);
+      instance._filterRemovedMessages();
+      assert.deepEqual(instance.result, {
+        messages: [{
+          message: 'msg2',
+          tag: 'foo',
+        }],
+      });
+    });
+
+    test('group reviewer updates', function() {
+      var reviewer1 = {_account_id: 1};
+      var reviewer2 = {_account_id: 2};
+      var date1 = '2017-01-26 12:11:50.000000000';
+      var date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
+      var date3 = '2017-01-26 12:33:50.000000000';
+      var date4 = '2017-01-26 12:44:50.000000000';
+      var makeItem = function(state, reviewer, opt_date, opt_author) {
+        return {
+          reviewer: reviewer,
+          updated: opt_date || date1,
+          updated_by: opt_author || reviewer1,
+          state: state,
+        };
+      };
+      var change = {
+        reviewer_updates: [
+          makeItem('REVIEWER', reviewer1), // New group.
+          makeItem('CC', reviewer2), // Appended.
+          makeItem('REVIEWER', reviewer2, date2), // Overrides previous one.
+
+          makeItem('CC', reviewer1, date2, reviewer2), // New group.
+
+          makeItem('REMOVED', reviewer2, date3), // Group has no state change.
+          makeItem('REVIEWER', reviewer2, date3),
+
+          makeItem('CC', reviewer1, date4), // No change, removed.
+          makeItem('REVIEWER', reviewer1, date4), // Forms new group
+          makeItem('REMOVED', reviewer2, date4), // Should be grouped.
+        ],
+      };
+
+      instance = new GrReviewerUpdatesParser(change);
+      instance._groupUpdates();
+      change = instance.result;
+
+      assert.equal(change.reviewer_updates.length, 3);
+      assert.equal(change.reviewer_updates[0].updates.length, 2);
+      assert.equal(change.reviewer_updates[1].updates.length, 1);
+      assert.equal(change.reviewer_updates[2].updates.length, 2);
+
+      assert.equal(change.reviewer_updates[0].date, date1);
+      assert.deepEqual(change.reviewer_updates[0].author, reviewer1);
+      assert.deepEqual(change.reviewer_updates[0].updates, [
+        {
+          reviewer: reviewer1,
+          state: 'REVIEWER',
+        },
+        {
+          reviewer: reviewer2,
+          state: 'REVIEWER',
+        },
+      ]);
+
+      assert.equal(change.reviewer_updates[1].date, date2);
+      assert.deepEqual(change.reviewer_updates[1].author, reviewer2);
+      assert.deepEqual(change.reviewer_updates[1].updates, [
+        {
+          reviewer: reviewer1,
+          state: 'CC',
+          prev_state: 'REVIEWER',
+        },
+      ]);
+
+      assert.equal(change.reviewer_updates[2].date, date4);
+      assert.deepEqual(change.reviewer_updates[2].author, reviewer1);
+      assert.deepEqual(change.reviewer_updates[2].updates, [
+        {
+          reviewer: reviewer1,
+          prev_state: 'CC',
+          state: 'REVIEWER',
+        },
+        {
+          reviewer: reviewer2,
+          prev_state: 'REVIEWER',
+          state: 'REMOVED',
+        },
+      ]);
+    });
+
+    test('format reviewer updates', function() {
+      var reviewer1 = {_account_id: 1};
+      var reviewer2 = {_account_id: 2};
+      var makeItem = function(prev, state, opt_reviewer) {
+        return {
+          reviewer: opt_reviewer || reviewer1,
+          prev_state: prev,
+          state: state,
+        };
+      };
+      var makeUpdate = function(items) {
+        return {
+          author: reviewer1,
+          updated: '',
+          updates: items,
+        };
+      };
+      var change = {
+          reviewer_updates: [
+            makeUpdate([
+              makeItem(undefined, 'CC'),
+              makeItem(undefined, 'CC', reviewer2)
+            ]),
+            makeUpdate([
+              makeItem('CC', 'REVIEWER'),
+              makeItem('REVIEWER', 'REMOVED'),
+              makeItem('REMOVED', 'REVIEWER'),
+              makeItem(undefined, 'REVIEWER', reviewer2),
+            ]),
+          ],
+      };
+
+      instance = new GrReviewerUpdatesParser(change);
+      instance._formatUpdates();
+
+      assert.equal(change.reviewer_updates.length, 2);
+      assert.equal(change.reviewer_updates[0].updates.length, 1);
+      assert.equal(change.reviewer_updates[1].updates.length, 3);
+
+      var items = change.reviewer_updates[0].updates;
+      assert.equal(items[0].message, 'added to CC: ');
+      assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
+
+      items = change.reviewer_updates[1].updates;
+      assert.equal(items[0].message, 'moved from CC to REVIEWER: ');
+      assert.deepEqual(items[0].reviewers, [reviewer1]);
+      assert.equal(items[1].message, 'removed from REVIEWER: ');
+      assert.deepEqual(items[1].reviewers, [reviewer1]);
+      assert.equal(items[2].message, 'added to REVIEWER: ');
+      assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index 9e14f08..bef260e9 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -16,40 +16,33 @@
 
   Polymer({
     is: 'gr-select',
-
     extends: 'select',
-
     properties: {
       bindValue: {
         type: String,
         notify: true,
+        observer: '_updateValue',
       },
     },
 
-    observers: [
-      '_valueChanged(bindValue)',
-    ],
+    listeners: {
+      change: '_valueChanged',
+      'dom-change': '_updateValue',
+    },
 
-    attached: function() {
-      this.addEventListener('change', function() {
-        this.bindValue = this.value;
-      });
+    _updateValue: function() {
+      if (this.bindValue) {
+        this.value = this.bindValue;
+      }
+    },
+
+    _valueChanged: function() {
+      this.bindValue = this.value;
     },
 
     ready: function() {
       // If not set via the property, set bind-value to the element value.
       if (!this.bindValue) { this.bindValue = this.value; }
     },
-
-    _valueChanged: function(bindValue) {
-      var options = Polymer.dom(this.root).querySelectorAll('option');
-      for (var i = 0; i < options.length; i++) {
-        if (options[i].getAttribute('value') === bindValue + '') {
-          options[i].setAttribute('selected', true);
-          this.value = bindValue;
-          break;
-        }
-      }
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
index abdff64..bd22505 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -18,11 +18,13 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-select</title>
 
-<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="gr-select.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <select is="gr-select">
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index ff41a74..77d1c05 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -31,6 +31,10 @@
           return window.localStorage;
         },
       },
+      _exceededQuota: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     getDraftComment: function(location) {
@@ -57,8 +61,15 @@
     },
 
     _getDraftKey: function(location) {
-      return ['draft', location.changeNum, location.patchNum, location.path,
+      var range = location.range ? location.range.start_line + '-' +
+          location.range.start_character + '-' + location.range.end_character +
+          '-' + location.range.end_line : null;
+      var key = ['draft', location.changeNum, location.patchNum, location.path,
           location.line || ''].join(':');
+      if (range) {
+        key = key + ':' + range;
+      }
+      return key;
     },
 
     _cleanupDrafts: function() {
@@ -87,7 +98,20 @@
     },
 
     _setObject: function(key, obj) {
-      this._storage.setItem(key, JSON.stringify(obj));
+      if (this._exceededQuota) { return; }
+      try {
+        this._storage.setItem(key, JSON.stringify(obj));
+      } catch (exc) {
+        // Catch for QuotaExceededError and disable writes on local storage the
+        // first time that it occurs.
+        if (exc.code === 22) {
+          this._exceededQuota = true;
+          console.warn('Local storage quota exceeded: disabling');
+          return;
+        } else {
+          throw exc;
+        }
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index 54e5577..6d77c55 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -17,11 +17,13 @@
 <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/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="gr-storage.html">
 
+<script>void(0);</script>
+
 <test-fixture id="basic">
   <template>
     <gr-storage></gr-storage>
@@ -31,19 +33,21 @@
 <script>
   suite('gr-storage tests', function() {
     var element;
-    var storage;
 
-    function cleanupStorage() {
-      // Make sure there are no entries in storage.
-      for (var key in window.localStorage) {
-        window.localStorage.removeItem(key);
-      }
+    function mockStorage(opt_quotaExceeded) {
+      return {
+        getItem: function(key) { return this[key]; },
+        removeItem: function(key) { delete this[key]; },
+        setItem: function(key, value) {
+          if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
+          this[key] = value;
+        },
+      };
     }
 
     setup(function() {
       element = fixture('basic');
-      storage = element._storage;
-      cleanupStorage();
+      element._storage = mockStorage();
     });
 
     test('storing, retrieving and erasing drafts', function() {
@@ -68,15 +72,14 @@
 
       // 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);
+      assert.isOk(element._storage.getItem(key));
+      assert.equal(JSON.parse(element._storage.getItem(key)).message,
+          'my comment');
+      assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
 
       // Erasing the draft removes the key.
       element.eraseDraftComment(location);
-      assert.isNotOk(storage.getItem(key));
-
-      cleanupStorage();
+      assert.isNotOk(element._storage.getItem(key));
     });
 
     test('automatically removes old drafts', function() {
@@ -90,6 +93,7 @@
         path: path,
         line: line,
       };
+
       var key = element._getDraftKey(location);
 
       // Make sure that the call to cleanup doesn't get throttled.
@@ -98,7 +102,7 @@
       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({
+      element._storage.setItem(key, JSON.stringify({
         message: 'old message',
         updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
       }));
@@ -108,10 +112,52 @@
 
       assert.isTrue(cleanupSpy.called);
       assert.isNotOk(draft);
-      assert.isNotOk(storage.getItem(key));
+      assert.isNotOk(element._storage.getItem(key));
 
       cleanupSpy.restore();
-      cleanupStorage();
+    });
+
+    test('_getDraftKey', 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 expectedResult = 'draft:1234:5:my_source_file.js:123';
+      assert.equal(element._getDraftKey(location), expectedResult);
+      location.range = {
+        start_character: 1,
+        start_line: 1,
+        end_character: 1,
+        end_line: 2,
+      };
+      expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
+      assert.equal(element._getDraftKey(location), expectedResult);
+    });
+
+    test('exceeded quota disables storage', function() {
+      element._storage = mockStorage(true);
+      assert.isFalse(element._exceededQuota);
+
+      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);
+      element.setDraftComment(location, 'my comment');
+      assert.isTrue(element._exceededQuota);
+      assert.isNotOk(element._storage.getItem(key));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
new file mode 100644
index 0000000..81e65e3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
@@ -0,0 +1,26 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
+
+<dom-module id="gr-tooltip-content">
+  <template>
+    <content></content>
+    <span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
+  </template>
+  <script src="gr-tooltip-content.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
new file mode 100644
index 0000000..26e1e2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
@@ -0,0 +1,39 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-tooltip-content',
+
+    properties: {
+      title: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      maxWidth: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      showIcon: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    behaviors: [
+      Gerrit.TooltipBehavior,
+    ],
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
new file mode 100644
index 0000000..aac2ea8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<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-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-tooltip-content.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-tooltip-content>
+    </gr-tooltip-content>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-tooltip-content tests', function() {
+    var element;
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('icon is not visible by default', function() {
+      assert.equal(Polymer.dom(element.root)
+          .querySelector('.arrow').hidden, true);
+    });
+
+    test('icon is visible with showIcon property', function() {
+      element.showIcon = true;
+      assert.equal(Polymer.dom(element.root)
+          .querySelector('.arrow').hidden, false);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
index 57a7272..2af9c86 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -30,6 +30,7 @@
         padding: .5em .85em;
         position: absolute;
         z-index: 1000;
+        max-width: var(--tooltip-max-width);
       }
       .arrow {
         border-left: var(--gr-tooltip-arrow-size) solid transparent;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
index 76372ba..4a5f631 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -19,6 +19,15 @@
 
     properties: {
       text: String,
+      maxWidth: {
+        type: String,
+        observer: '_updateWidth',
+      },
+    },
+
+    _updateWidth: function(maxWidth) {
+      this.customStyle['--tooltip-max-width'] = maxWidth;
+      this.updateStyles();
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
new file mode 100644
index 0000000..69a5b75
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<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-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-tooltip.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-tooltip>
+    </gr-tooltip>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-tooltip tests', function() {
+    var element;
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('max-width is respected if set', function() {
+      element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
+          ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
+      element.maxWidth = '50px';
+      assert.equal(getComputedStyle(element).width, '50px');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/favicon.ico b/polygerrit-ui/app/favicon.ico
index 155217b..de50088 100644
--- a/polygerrit-ui/app/favicon.ico
+++ b/polygerrit-ui/app/favicon.ico
Binary files differ
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
index 0e00f77..db9a1c5 100644
--- a/polygerrit-ui/app/index.html
+++ b/polygerrit-ui/app/index.html
@@ -20,9 +20,16 @@
 <meta name="description" content="Gerrit Code Review">
 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
 
+<!--
+SourceCodePro fonts are used in styles/fonts.css
+@see https://github.com/w3c/preload/issues/32 regarding crossorigin
+-->
+<link rel="preload" href="/fonts/SourceCodePro-Regular.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="/fonts/SourceCodePro-Regular.woff" as="font" type="font/woff" crossorigin>
 <link rel="stylesheet" href="/styles/fonts.css">
 <link rel="stylesheet" href="/styles/main.css">
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="preload" href="/elements/gr-app.js" as="script" crossorigin="anonymous">
 <link rel="import" href="/elements/gr-app.html">
 
 <body unresolved>
diff --git a/polygerrit-ui/app/polygerrit_wct_tests.py b/polygerrit-ui/app/polygerrit_wct_tests.py
deleted file mode 100644
index eb34fef..0000000
--- a/polygerrit-ui/app/polygerrit_wct_tests.py
+++ /dev/null
@@ -1,118 +0,0 @@
-# Copyright (C) 2015 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-from __future__ import print_function
-
-import atexit
-from distutils import spawn
-import json
-import os
-import pkg_resources
-import shlex
-import shutil
-import subprocess
-import sys
-import tempfile
-import unittest
-import zipfile
-
-
-def _write_wct_conf(root, exports):
-  with open(os.path.join(root, 'wct.conf.js'), 'w') as f:
-    f.write('module.exports = %s;\n' % json.dumps(exports))
-
-
-def _wct_cmd():
-  return ['wct'] + shlex.split(os.environ.get('WCT_ARGS', ''))
-
-
-class PolyGerritWctTests(unittest.TestCase):
-
-  # Should really be setUpClass/tearDownClass, but Buck's test runner doesn't
-  # produce sane stack traces from those methods. There's only one test method
-  # anyway, so just use setUp.
-
-  def _check_wct(self):
-    self.assertTrue(
-        spawn.find_executable('wct'),
-        msg='wct not found; try `npm install -g web-component-tester`')
-
-  def _extract_resources(self):
-    tmpdir = tempfile.mkdtemp()
-    atexit.register(lambda: shutil.rmtree(tmpdir))
-    root = os.path.join(tmpdir, 'polygerrit')
-    os.mkdir(root)
-
-    tr = 'test_resources.zip'
-    zip_path = os.path.join(tmpdir, tr)
-    s = pkg_resources.resource_stream(__name__, tr)
-    with open(zip_path, 'w') as f:
-      shutil.copyfileobj(s, f)
-
-    with zipfile.ZipFile(zip_path, 'r') as z:
-      z.extractall(root)
-
-    return tmpdir, root
-
-  def test_wct(self):
-    self._check_wct()
-    tmpdir, root = self._extract_resources()
-
-    cmd = _wct_cmd()
-    print('Running %s in %s' % (cmd, root), file=sys.stderr)
-
-    _write_wct_conf(root, {
-      'suites': ['test'],
-      'webserver': {
-        'pathMappings': [
-          {'/components/bower_components': 'bower_components'},
-        ],
-      },
-      'plugins': {
-        'local': {
-          # For some reason wct tries to install selenium into its node_modules
-          # directory on first run. If you've installed into /usr/local and
-          # aren't running wct as root, you're screwed. Turning this option off
-          # seems to still work, so there's that.
-          'skipSeleniumInstall': True,
-        },
-        'sauce': {
-          # Disabled by default in order to run local tests only.
-          # Run it with (saucelabs.com account required; free for open source):
-          # WCT_ARGS='--plugin sauce' buck test --no-results-cache --include web
-          'disabled': True,
-          'browsers': [
-            'OS X 10.11/chrome',
-            'Windows 10/chrome',
-            'Linux/firefox',
-            'OS X 10.11/safari',
-            'Windows 10/microsoftedge',
-          ],
-        },
-      },
-    })
-
-    p = subprocess.Popen(cmd, cwd=root,
-                         stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-    out, err = p.communicate()
-    sys.stdout.write(out)
-    sys.stderr.write(err)
-    self.assertEquals(0, p.returncode)
-
-    # Only remove tmpdir if successful, to allow debugging.
-    shutil.rmtree(tmpdir)
-
-
-if __name__ == '__main__':
-  unittest.main()
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
new file mode 100755
index 0000000..f450118
--- /dev/null
+++ b/polygerrit-ui/app/run_test.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+
+wct_bin=$(which wct)
+if [[ -z "$wct_bin" ]]; then
+    echo "WCT must be on the path."
+    exit 1
+fi
+
+npm_bin=$(which npm)
+if [[ -z "$npm_bin" ]]; then
+    echo "NPM must be on the path."
+    exit 1
+fi
+
+bazel_bin=$(which bazelisk 2>/dev/null)
+if [[ -z "$bazel_bin" ]]; then
+    echo "Warning: bazelisk is not installed; falling back to bazel."
+    bazel_bin=bazel
+fi
+
+# WCT tests are not hermetic, and need extra environment variables.
+# TODO(hanwen): does $DISPLAY even work on OSX?
+${bazel_bin} test \
+      --test_env="HOME=$HOME" \
+      --test_env="WCT=${wct_bin}" \
+      --test_env="WCT_ARGS=${WCT_ARGS}" \
+      --test_env="NPM=${npm_bin}" \
+      --test_env="DISPLAY=${DISPLAY}" \
+      --test_env="WCT_HEADLESS_MODE=${WCT_HEADLESS_MODE}" \
+      "$@" \
+      //polygerrit-ui/app:wct_test
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 13f3243..6c83905 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -24,22 +24,6 @@
     return new Date(dateStr.replace(' ', 'T') + 'Z');
   };
 
-  util.htmlEntityMap = {
-    '&': '&amp;',
-    '<': '&lt;',
-    '>': '&gt;',
-    '"': '&quot;',
-    '\'': '&#39;',
-    '/': '&#x2F;',
-    '`': '&#96;',
-  };
-
-  util.escapeHTML = function(str) {
-    return str.replace(/[&<>"'`\/]/g, function(s) {
-      return util.htmlEntityMap[s];
-    });
-  };
-
   util.getCookie = function(name) {
     var key = name + '=';
     var cookies = document.cookie.split(';');
@@ -55,5 +39,25 @@
     return '';
   };
 
+  /**
+   * Truncates URLs to display filename only
+   * Example
+   * // returns '.../text.html'
+   * util.truncatePath.('dir/text.html');
+   * Example
+   * // returns 'text.html'
+   * util.truncatePath.('text.html');
+   * @return {String} Returns the truncated value of a URL.
+   */
+  util.truncatePath = function(path) {
+    var pathPieces = path.split('/');
+
+    if (pathPieces.length < 2) {
+      return path;
+    }
+    // Character is an ellipsis.
+    return '\u2026/' + pathPieces[pathPieces.length - 1];
+  };
+
   window.util = util;
 })(window);
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
index ecf4ac6..773b341 100644
--- a/polygerrit-ui/app/styles/app-theme.html
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -20,12 +20,17 @@
   --selection-background-color: #ebf5fb;
   --default-text-color: #000;
   --view-background-color: #fff;
-  --default-horizontal-margin: 1.25rem;
-  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  --default-horizontal-margin: 1rem;
+  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   --monospace-font-family: 'Source Code Pro', Menlo, 'Lucida Console', Monaco, monospace;
 
   --iron-overlay-backdrop: {
     transition: none;
   };
 }
+@media screen and (max-width: 50em) {
+  :root {
+    --default-horizontal-margin: .7rem;
+  }
+}
 </style>
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index d367a75..d283aac 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -16,8 +16,8 @@
 <dom-module id="gr-change-list-styles">
   <template>
     <style>
-      .headerRow {
-        display: flex;
+      :host {
+        font-size: 13px;
       }
       .topHeader,
       .groupHeader {
@@ -27,7 +27,6 @@
       }
       .topHeader {
         background-color: #ddd;
-        flex-shrink: 0;
       }
       .noChanges {
         border-bottom: 1px solid #eee;
@@ -35,56 +34,33 @@
       }
       .keyboard,
       .star {
-        align-items: center;
-        display: flex;
-        justify-content: center;
         padding: 0;
-        width: 2em;
       }
-      .star {
-        padding-top: .05em;
+      gr-change-star {
+        vertical-align: middle;
       }
-      .number {
-        width: 4em;
-      }
-      .subject {
-        flex-grow: 1;
-        flex-shrink: 1;
-        word-break: break-word;
-      }
-      .status {
-        width: 9em;
-      }
-      .owner {
-        width: 15em;
-      }
-      .project,
-      .branch {
-        overflow: hidden;
-        white-space: nowrap;
-        text-overflow: ellipsis;
-      }
+      .keyboard,
+      .branch,
+      .star,
+      .label,
+      .number,
+      .owner,
+      .updated,
+      .size,
+      .status,
       .project {
-        width: 10em;
-      }
-      .branch {
-        width: 7em;
+        white-space: nowrap;
       }
       .updated {
-        width: 9em;
         text-align: right;
       }
-      .size {
-        width: 9em;
+      .size,
+      .updated {
         text-align: right;
       }
       .label {
-        width: 2.6em;
         text-align: center;
       }
-      :host {
-        font-size: 11px;
-      }
       @media only screen and (max-width: 50em) {
         :host {
           font-size: 14px;
@@ -107,13 +83,11 @@
           display: none;
         }
         .star {
-          align-items: flex-start;
           padding-left: .35em;
-          padding-top: .4em;
+          padding-top: .25em;
         }
         .subject {
           margin-bottom: .25em;
-          text-decoration: underline;
           width: calc(100% - 2em);
         }
         .owner,
@@ -121,20 +95,7 @@
           width: auto;
         }
       }
-      @media only screen and (min-width: 1240px) {
-        :host {
-          font-size: 12px;
-        }
-      }
-      @media only screen and (min-width: 1340px) {
-        :host {
-          font-size: 13px;
-        }
-      }
       @media only screen and (min-width: 1450px) {
-        :host {
-          font-size: 14px;
-        }
         .project {
           width: 20em;
         }
diff --git a/polygerrit-ui/app/styles/main.css b/polygerrit-ui/app/styles/main.css
index 39be0ff..6e48ae5 100644
--- a/polygerrit-ui/app/styles/main.css
+++ b/polygerrit-ui/app/styles/main.css
@@ -35,6 +35,6 @@
    * Work around this using font-size and font-family.
    */
   font-size: 13px;
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   line-height: 1.4;
 }
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index d3cb316..4dcc9a8 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -22,10 +22,13 @@
 <script src="../bower_components/web-component-tester/browser.js"></script>
 <script>
   var testFiles = [];
-  var basePath = '../elements/';
+  var elementsPath = '../elements/';
+  var behaviorsPath = '../behaviors/';
 
+  // Elements tests.
   [
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
+    'change-list/gr-change-list-view/gr-change-list-view_test.html',
     'change-list/gr-change-list/gr-change-list_test.html',
     'change/gr-account-entry/gr-account-entry_test.html',
     'change/gr-account-list/gr-account-list_test.html',
@@ -33,7 +36,10 @@
     'change/gr-change-metadata/gr-change-metadata_test.html',
     'change/gr-change-view/gr-change-view_test.html',
     'change/gr-comment-list/gr-comment-list_test.html',
+    'change/gr-commit-info/gr-commit-info_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
+    'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
+    'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
     'change/gr-download-dialog/gr-download-dialog_test.html',
     'change/gr-file-list/gr-file-list_test.html',
     'change/gr-message/gr-message_test.html',
@@ -44,13 +50,15 @@
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
+    'core/gr-reporting/gr-reporting_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
     'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
+    'diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html',
     'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
-    'diff/gr-diff-highlight/gr-diff-highlight_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
+    'diff/gr-diff-highlight/gr-diff-highlight_test.html',
     'diff/gr-diff-preferences/gr-diff-preferences_test.html',
     'diff/gr-diff-processor/gr-diff-processor_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
@@ -61,37 +69,61 @@
     'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
+    'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
+    'gr-app_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
+    'settings/gr-change-table-editor/gr-change-table-editor_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',
     'settings/gr-group-list/gr-group-list_test.html',
     'settings/gr-http-password/gr-http-password_test.html',
     'settings/gr-menu-editor/gr-menu-editor_test.html',
+    'settings/gr-registration-dialog/gr-registration-dialog_test.html',
     'settings/gr-settings-view/gr-settings-view_test.html',
     'settings/gr-ssh-editor/gr-ssh-editor_test.html',
     'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
-    'shared/gr-autocomplete/gr-autocomplete_test.html',
     'shared/gr-account-label/gr-account-label_test.html',
     'shared/gr-account-link/gr-account-link_test.html',
     'shared/gr-alert/gr-alert_test.html',
+    'shared/gr-autocomplete/gr-autocomplete_test.html',
     'shared/gr-avatar/gr-avatar_test.html',
+    'shared/gr-button/gr-button_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
     'shared/gr-confirm-dialog/gr-confirm-dialog_test.html',
     'shared/gr-cursor-manager/gr-cursor-manager_test.html',
     'shared/gr-date-formatter/gr-date-formatter_test.html',
     'shared/gr-editable-content/gr-editable-content_test.html',
     'shared/gr-editable-label/gr-editable-label_test.html',
+    'shared/gr-formatted-text/gr-formatted-text_test.html',
     'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+    'shared/gr-linked-chip/gr-linked-chip_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
+    'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
     'shared/gr-select/gr-select_test.html',
     'shared/gr-storage/gr-storage_test.html',
+    'shared/gr-tooltip/gr-tooltip_test.html',
+    'shared/gr-tooltip-content/gr-tooltip-content_test.html',
   ].forEach(function(file) {
-    file = basePath + file;
+    file = elementsPath + file;
     testFiles.push(file);
     testFiles.push(file + '?dom=shadow');
   });
 
+  // Behaviors tests.
+  [
+    'base-url-behavior/base-url-behavior_test.html',
+    'rest-client-behavior/rest-client-behavior_test.html',
+    'gr-change-table-behavior/gr-change-table-behavior_test.html',
+    'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
+    'gr-path-list-behavior/gr-path-list-behavior_test.html',
+    'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
+  ].forEach(function(file) {
+    // Behaviors do not utilize the DOM, so no shadow DOM test is necessary.
+    file = behaviorsPath + file;
+    testFiles.push(file);
+  });
+
   WCT.loadSuites(testFiles);
 </script>
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
new file mode 100755
index 0000000..4f2d50a
--- /dev/null
+++ b/polygerrit-ui/app/wct_test.sh
@@ -0,0 +1,68 @@
+#!/bin/sh
+
+set -ex
+
+t=$(mktemp -d || mktemp -d -t wct-XXXXXXXXXX)
+components=$TEST_SRCDIR/gerrit/polygerrit-ui/app/test_components.zip
+code=$TEST_SRCDIR/gerrit/polygerrit-ui/app/pg_code.zip
+
+echo $t
+unzip -qd $t $components
+unzip -qd $t $code
+mkdir -p $t/test
+cp $TEST_SRCDIR/gerrit/polygerrit-ui/app/test/index.html $t/test/
+
+if [ "${WCT_HEADLESS_MODE:-0}" != "0" ]; then
+    CHROME_OPTIONS=[\'start-maximized\',\'headless\',\'disable-gpu\',\'no-sandbox\']
+    # TODO(paladox): Fix Firefox support for headless mode
+    FIREFOX_OPTIONS=[\'\']
+else
+    CHROME_OPTIONS=[\'start-maximized\']
+    FIREFOX_OPTIONS=[\'\']
+fi
+
+# For some reason wct tries to install selenium into its node_modules
+# directory on first run. If you've installed into /usr/local and
+# aren't running wct as root, you're screwed. Turning this option off
+# through skipSeleniumInstall seems to still work, so there's that.
+
+# Sauce tests are disabled by default in order to run local tests
+# only.  Run it with (saucelabs.com account required; free for open
+# source): WCT_ARGS='--plugin sauce' ./polygerrit-ui/app/run_test.sh
+
+cat <<EOF > $t/wct.conf.js
+module.exports = {
+      'suites': ['test'],
+      'webserver': {
+        'pathMappings': [
+          {'/components/bower_components': 'bower_components'}
+        ]
+      },
+      'plugins': {
+        'local': {
+          'skipSeleniumInstall': true,
+          'browserOptions': {
+            'chrome': ${CHROME_OPTIONS},
+            'firefox': ${FIREFOX_OPTIONS}
+          }
+        },
+        'sauce': {
+          'disabled': true,
+          'browsers': [
+            'OS X 10.11/chrome',
+            'Windows 10/chrome',
+            'Linux/firefox',
+            'OS X 10.11/safari',
+            'Windows 10/microsoftedge'
+          ]
+        }
+      }
+    };
+EOF
+
+export PATH="$(dirname $WCT):$(dirname $NPM):$PATH"
+
+cd $t
+test -n "${WCT}"
+
+$(basename ${WCT}) ${WCT_ARGS}
diff --git a/polygerrit-ui/run-server.sh b/polygerrit-ui/run-server.sh
index e6d782f..cbe3563 100755
--- a/polygerrit-ui/run-server.sh
+++ b/polygerrit-ui/run-server.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 # Copyright (C) 2015 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -15,22 +15,22 @@
 
 set -eu
 
-while [[ ! -f .buckconfig && "$PWD" != / ]]; do
+while [[ ! -f WORKSPACE && "$PWD" != / ]]; do
   cd ..
 done
-if [[ ! -f .buckconfig ]]; then
+if [[ ! -f WORKSPACE ]]; then
   echo "$(basename "$0"): must be run from a gerrit checkout" 1>&2
   exit 1
 fi
 
-buck build \
+bazel build \
   //polygerrit-ui/app:test_components \
-  //polygerrit-ui:fonts
+  //polygerrit-ui:fonts.zip
 
 cd polygerrit-ui/app
 rm -rf bower_components
-unzip -q ../../buck-out/gen/polygerrit-ui/app/test_components/test_components.bower_components.zip
+unzip -q ../../bazel-bin/polygerrit-ui/app/test_components.zip
 rm -rf fonts
-unzip -q ../../buck-out/gen/polygerrit-ui/fonts/fonts.zip -d fonts
+unzip -q ../../bazel-bin/polygerrit-ui/fonts.zip -d fonts
 cd ..
 exec go run server.go "$@"
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index cb6d236..b19137e 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -32,6 +32,8 @@
 	restHost = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
 	port     = flag.String("port", ":8081", "Port to serve HTTP requests on")
 	prod     = flag.Bool("prod", false, "Serve production assets")
+	scheme   = flag.String("scheme", "https", "URL scheme")
+	plugins  = flag.String("plugins", "", "Path to local plugins folder")
 )
 
 func main() {
@@ -48,6 +50,11 @@
 	http.HandleFunc("/config/", handleRESTProxy)
 	http.HandleFunc("/projects/", handleRESTProxy)
 	http.HandleFunc("/accounts/self/detail", handleAccountDetail)
+	if len(*plugins) > 0 {
+		http.Handle("/plugins/", http.StripPrefix("/plugins/",
+			http.FileServer(http.Dir(*plugins))))
+		log.Println("Local plugins at", *plugins)
+	}
 	log.Println("Serving on port", *port)
 	log.Fatal(http.ListenAndServe(*port, &server{}))
 }
@@ -57,7 +64,7 @@
 	req := &http.Request{
 		Method: "GET",
 		URL: &url.URL{
-			Scheme:   "https",
+			Scheme:   *scheme,
 			Host:     *restHost,
 			Opaque:   r.URL.EscapedPath(),
 			RawQuery: r.URL.RawQuery,
diff --git a/tools/BUCK b/tools/BUCK
deleted file mode 100644
index 489dffc..0000000
--- a/tools/BUCK
+++ /dev/null
@@ -1,51 +0,0 @@
-python_binary(
-  name = 'download_file',
-  main = 'download_file.py',
-  deps = [':util'],
-  visibility = ['PUBLIC'],
-)
-
-python_binary(
-  name = 'merge_jars',
-  main = 'merge_jars.py',
-  visibility = ['PUBLIC'],
-)
-
-python_binary(
-  name = 'pack_war',
-  main = 'pack_war.py',
-  deps = [':util'],
-  visibility = ['PUBLIC'],
-)
-
-python_library(
-  name = 'util',
-  srcs = [
-    'util.py',
-    '__init__.py'
-  ],
-  visibility = ['PUBLIC'],
-)
-
-python_test(
-  name = 'util_test',
-  srcs = ['util_test.py'],
-  deps = [':util'],
-  visibility = ['PUBLIC'],
-)
-
-def shquote(s):
-  return s.replace("'", "'\\''")
-
-def os_path():
-  from os import environ
-  return environ.get('PATH')
-
-genrule(
-  name = 'buck',
-  cmd = 'echo buck=`which buck`>$OUT;' +
-    ("echo PATH=\''%s'\' >>$OUT;" % shquote(os_path())),
-  out = 'buck.properties',
-  visibility = ['PUBLIC'],
-)
-
diff --git a/tools/BUILD b/tools/BUILD
index ff64faa..1696d2b 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -1,6 +1,134 @@
+load(
+    "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
+    "JDK9_JVM_OPTS",
+    "default_java_toolchain",
+)
+load("@rules_java//java:defs.bzl", "java_package_configuration")
+load("@rules_python//python:defs.bzl", "py_binary")
+
 py_binary(
-  name = 'merge_jars',
-  srcs = ['merge_jars.py'],
-  main = 'merge_jars.py',
-  visibility = ['//visibility:public'],
+    name = "merge_jars",
+    srcs = ["merge_jars.py"],
+    main = "merge_jars.py",
+    visibility = ["//visibility:public"],
+)
+
+default_java_toolchain(
+    name = "error_prone_warnings_toolchain",
+    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
+    jvm_opts = JDK9_JVM_OPTS,
+    package_configuration = [
+        ":error_prone",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+# Error Prone errors enabled by default; see ../.bazelrc for how this is
+# enabled. This warnings list is originally based on:
+# https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
+# However, feel free to add any additional errors. Thus far they have all been pretty useful.
+java_package_configuration(
+    name = "error_prone",
+    javacopts = [
+        "-XepDisableWarningsInGeneratedCode",
+        "-Xep:MissingCasesInEnumSwitch:ERROR",
+        "-Xep:ReferenceEquality:WARN",
+        "-Xep:StringEquality:WARN",
+        "-Xep:WildcardImport:WARN",
+        "-Xep:AmbiguousMethodReference:WARN",
+        "-Xep:BadAnnotationImplementation:WARN",
+        "-Xep:BadComparable:WARN",
+        "-Xep:BoxedPrimitiveConstructor:ERROR",
+        "-Xep:CannotMockFinalClass:WARN",
+        "-Xep:ClassCanBeStatic:WARN",
+        "-Xep:ClassNewInstance:WARN",
+        "-Xep:DefaultCharset:ERROR",
+        "-Xep:DoubleCheckedLocking:WARN",
+        "-Xep:ElementsCountedInLoop:WARN",
+        "-Xep:EqualsHashCode:WARN",
+        "-Xep:EqualsIncompatibleType:WARN",
+        "-Xep:ExpectedExceptionChecker:ERROR",
+        "-Xep:Finally:WARN",
+        "-Xep:FloatingPointLiteralPrecision:WARN",
+        "-Xep:FragmentInjection:WARN",
+        "-Xep:FragmentNotInstantiable:WARN",
+        "-Xep:FunctionalInterfaceClash:WARN",
+        "-Xep:FutureReturnValueIgnored:WARN",
+        "-Xep:GetClassOnEnum:WARN",
+        "-Xep:ImmutableAnnotationChecker:WARN",
+        "-Xep:ImmutableEnumChecker:WARN",
+        "-Xep:IncompatibleModifiers:WARN",
+        "-Xep:InjectOnConstructorOfAbstractClass:WARN",
+        "-Xep:InputStreamSlowMultibyteRead:WARN",
+        "-Xep:IterableAndIterator:WARN",
+        "-Xep:JUnit3FloatingPointComparisonWithoutDelta:WARN",
+        "-Xep:JUnitAmbiguousTestClass:WARN",
+        "-Xep:LiteralClassName:WARN",
+        "-Xep:MissingFail:WARN",
+        "-Xep:MissingOverride:WARN",
+        "-Xep:MutableConstantField:WARN",
+        "-Xep:NarrowingCompoundAssignment:WARN",
+        "-Xep:NonAtomicVolatileUpdate:WARN",
+        "-Xep:NonOverridingEquals:WARN",
+        "-Xep:NullableConstructor:WARN",
+        "-Xep:NullablePrimitive:WARN",
+        "-Xep:NullableVoid:WARN",
+        "-Xep:OperatorPrecedence:WARN",
+        "-Xep:OverridesGuiceInjectableMethod:WARN",
+        "-Xep:PreconditionsInvalidPlaceholder:WARN",
+        "-Xep:ProtoFieldPreconditionsCheckNotNull:WARN",
+        "-Xep:ProtocolBufferOrdinal:WARN",
+        "-Xep:RequiredModifiers:WARN",
+        "-Xep:ShortCircuitBoolean:WARN",
+        "-Xep:SimpleDateFormatConstant:WARN",
+        "-Xep:StaticGuardedByInstance:WARN",
+        "-Xep:SynchronizeOnNonFinalField:WARN",
+        "-Xep:TruthConstantAsserts:WARN",
+        "-Xep:TypeParameterShadowing:WARN",
+        "-Xep:TypeParameterUnusedInFormals:WARN",
+        "-Xep:URLEqualsHashCode:WARN",
+        "-Xep:UnsynchronizedOverridesSynchronized:WARN",
+        "-Xep:WaitNotInLoop:WARN",
+    ],
+    packages = ["error_prone_packages"],
+)
+
+package_group(
+    name = "error_prone_packages",
+    packages = [
+        "//gerrit-acceptance-framework/...",
+        "//gerrit-acceptance-tests/...",
+        "//gerrit-cache-h2/...",
+        "//gerrit-cache-mem/...",
+        "//gerrit-common/...",
+        "//gerrit-elasticsearch/...",
+        "//gerrit-extension-api/...",
+        "//gerrit-gpg/...",
+        "//gerrit-httpd/...",
+        "//gerrit-launcher/...",
+        "//gerrit-lucene/...",
+        "//gerrit-main/...",
+        "//gerrit-oauth/...",
+        "//gerrit-openid/...",
+        "//gerrit-patch-commonsnet/...",
+        "//gerrit-patch-jgit/...",
+        "//gerrit-pgm/...",
+        "//gerrit-plugin-api/...",
+        "//gerrit-plugin-gwtui/...",
+        "//gerrit-prettify/...",
+        "//gerrit-reviewdb/...",
+        "//gerrit-server/...",
+        "//gerrit-sshd/...",
+        "//gerrit-test-util/...",
+        "//gerrit-util-cli/...",
+        "//gerrit-util-http/...",
+        "//gerrit-util-ssl/...",
+        "//gerrit-war/...",
+        "//plugins/commit-message-length-validator/...",
+        "//plugins/download-commands/...",
+        "//plugins/hooks/...",
+        "//plugins/replication/...",
+        "//plugins/reviewnotes/...",
+        "//plugins/singleusergroup/...",
+    ],
 )
diff --git a/tools/GoogleFormat.xml b/tools/GoogleFormat.xml
deleted file mode 100644
index 8062246..0000000
--- a/tools/GoogleFormat.xml
+++ /dev/null
@@ -1,267 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<profiles version="11">
-<profile kind="CodeFormatterProfile" name="Google Format" version="11">
-<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_before_root_tags" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_annotation" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_parameters" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_type_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_arguments" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_case" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_annotation_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_annotation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_while" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_annotation_type_member_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_prefix_operator" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_ellipsis" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_annotation_type_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_breaks_compare_to_cases" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiple_fields" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_for" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_binary_operator" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_wildcard" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_enum_constant" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_while" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_type_parameters" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_postfix_operator" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_arguments" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_superinterfaces" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_binary_operator" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
-<setting id="org.eclipse.jdt.core.compiler.source" value="1.7"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_constant_arguments" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_constructor_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_arguments" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_enum_declarations" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_block" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_invocation_arguments" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="0"/>
-<setting id="org.eclipse.jdt.core.formatter.align_type_members_on_columns" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_enum_constant" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_for" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_method_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_switch" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_unary_operator" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_case" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_switch" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_parameters" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_block_comment" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="80"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_if" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_between_brackets_in_array_type_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_parenthesized_expression" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_explicitconstructorcall_arguments" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_constructor_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
-<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_enum_constant" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="16"/>
-<setting id="org.eclipse.jdt.core.compiler.problem.assertIdentifier" value="error"/>
-<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_parameters" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_prefix_operator" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_body" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
-<setting id="org.eclipse.jdt.core.formatter.format_guardian_clause_on_one_line" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_for" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_cast" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_labeled_statement" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_method_invocation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_allocation_expression" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_enum_constant" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_annotation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_at_in_annotation_type_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_throws" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_if" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_throws" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_return" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_conditional" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_question_in_wildcard" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_allocation_expression" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_parenthesized_expression_in_throw" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_type_arguments" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.compiler.problem.enumIdentifier" value="error"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_ellipsis" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_inits" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.compact_else_if" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_array_initializer" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_increments" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_bracket_in_array_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.indent_root_tags" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_declarations" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_explicitconstructorcall_arguments" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_switch" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_superinterfaces" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_method_declaration_parameters" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_allocation_expression" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="2"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_type_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_brace_in_block" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_constant" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_arguments" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_constructor_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_if" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_throws" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.clear_blank_lines_in_javadoc_comment" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_assignment_operator" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_assignment_operator" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_empty_lines" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_synchronized" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_paren_in_cast" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_declaration_parameters" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="3"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_catch" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_constructor_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_method_invocation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_bracket_in_array_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_and_in_type_parameter" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_qualified_allocation_expression" value="16"/>
-<setting id="org.eclipse.jdt.core.compiler.compliance" value="1.7"/>
-<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_brackets_in_array_allocation_expression" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_at_in_annotation_type_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_cast" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_unary_operator" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_anonymous_type_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_enum_declaration" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_constructor_declaration_parameters" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_closing_angle_bracket_in_type_parameters" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_at_end_of_file_if_missing" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_for" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_labeled_statement" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_parameterized_type_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_binary_expression" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_while" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode" value="enabled"/>
-<setting id="org.eclipse.jdt.core.formatter.put_empty_statement_on_new_line" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_type_parameters" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_method_invocation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="80"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_import_groups" value="1"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_enum_constant_arguments" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.number_of_blank_lines_at_beginning_of_method_body" value="0"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_conditional" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_annotation_type_member_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.wrap_before_binary_operator" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_between_type_declarations" value="2"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_synchronized" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_statements_compare_to_block" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_enum_declaration" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_question_in_conditional" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_field_declarations" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_compact_if" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_for_inits" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_array_initializer" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_default" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_and_in_type_parameter" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_parens_in_constructor_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_assert" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="0"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.format_html" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_angle_bracket_in_type_parameters" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_bracket_in_array_allocation_expression" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_colon_in_conditional" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_for" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_postfix_operator" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.format_source_code" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_synchronized" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_allocation_expression" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_constructor_declaration_throws" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.compiler.codegen.targetPlatform" value="1.7"/>
-<setting id="org.eclipse.jdt.core.formatter.use_tabs_only_for_leading_indentations" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_member" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.format_header" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_enum_constant" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.alignment_for_enum_constants" value="16"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_parenthesized_expression" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_parenthesized_expression" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_catch" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_multiple_local_declarations" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_switch" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_comma_in_for_increments" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_invocation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_colon_in_assert" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_paren_in_method_declaration" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_semicolon_in_for" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_paren_in_catch" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_parameterized_type_reference" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_field_declarations" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_paren_in_annotation" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_parameterized_type_reference" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_method_invocation_arguments" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_comma_in_multiple_local_declarations" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_after_semicolon_in_for" value="insert"/>
-<setting id="org.eclipse.jdt.core.formatter.never_indent_line_comments_on_first_column" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_angle_bracket_in_type_arguments" value="do not insert"/>
-<setting id="org.eclipse.jdt.core.formatter.never_indent_block_comments_on_first_column" value="false"/>
-<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="false"/>
-</profile>
-</profiles>
diff --git a/tools/build.defs b/tools/build.defs
deleted file mode 100644
index 3ea506c..0000000
--- a/tools/build.defs
+++ /dev/null
@@ -1,80 +0,0 @@
-# Copyright (C) 2013 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.
-
-# These definitions support building a runnable version of Gerrit.
-
-DOCS_HTML = '//Documentation:html'
-DOCS_LIB = '//Documentation:index_lib'
-LIBS = [
-  '//gerrit-war:log4j-config',
-  '//gerrit-war:init',
-  '//lib:postgresql',
-  '//lib/log:impl_log4j',
-]
-PGMLIBS = ['//gerrit-pgm:pgm']
-
-def scan_plugins():
-  import os
-  deps = []
-  for n in os.listdir('plugins'):
-    if os.path.exists(os.path.join('plugins', n, 'BUCK')):
-      deps.append('//plugins/%s:%s__plugin' % (n, n))
-  return deps
-
-def war(
-    name,
-    libs = [],
-    pgmlibs = [],
-    context = [],
-    visibility = [],
-    docs = False
-    ):
-  cmd = ['$(exe //tools:pack_war)', '-o', '$OUT', '--tmp', '$TMP']
-  for l in libs:
-    cmd.extend(['--lib', '$(classpath %s)' % l])
-  for l in pgmlibs:
-    cmd.extend(['--pgmlib', '$(classpath %s)' % l])
-
-  if docs:
-    cmd.append('$(location %s)' % DOCS_HTML)
-    cmd.extend(['--lib', '$(classpath %s)' % DOCS_LIB])
-  if context:
-    for t in context:
-      cmd.append('$(location %s)' % t)
-
-  genrule(
-    name = name,
-    cmd = ' '.join(cmd),
-    out = name + '.war',
-    visibility = visibility,
-  )
-
-def gerrit_war(name, ui = 'ui_optdbg', context = [], docs = False, visibility = []):
-  ui_deps = []
-  if ui:
-    if ui == 'polygerrit' or ui == 'ui_optdbg' or ui == 'ui_optdbg_r':
-      ui_deps.append('//polygerrit-ui/app:polygerrit_ui')
-    if ui != 'polygerrit':
-      ui_deps.append('//gerrit-gwtui:%s' % ui)
-  war(
-    name = name,
-    libs = LIBS + ['//gerrit-war:version'],
-    pgmlibs = PGMLIBS,
-    context = ui_deps + context + [
-      '//gerrit-main:main_bin',
-      '//gerrit-war:webapp_assets',
-    ],
-    docs = docs,
-    visibility = visibility,
-  )
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
index e69de29..7febbac 100644
--- a/tools/bzl/BUILD
+++ b/tools/bzl/BUILD
@@ -0,0 +1,10 @@
+exports_files([
+    "license-map.py",
+    "test_empty.sh",
+    "test_license.sh",
+])
+
+sh_test(
+    name = "always_pass_test",
+    srcs = ["always_pass_test.sh"],
+)
diff --git a/tools/bzl/always_pass_test.sh b/tools/bzl/always_pass_test.sh
new file mode 100755
index 0000000..15c58ca
--- /dev/null
+++ b/tools/bzl/always_pass_test.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+#
+# Copyright (C) 2020 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 is a dummy test to put on the command line to avoid no tests
+# found outcome in `bazel test` command. See this upstream issue:
+# https://github.com/bazelbuild/bazel/issues/11465
+
+exit 0
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
new file mode 100644
index 0000000..f191bcf
--- /dev/null
+++ b/tools/bzl/asciidoc.bzl
@@ -0,0 +1,337 @@
+def documentation_attributes():
+    return [
+        "toc",
+        'newline="\\n"',
+        'asterisk="&#42;"',
+        'plus="&#43;"',
+        'caret="&#94;"',
+        'startsb="&#91;"',
+        'endsb="&#93;"',
+        'tilde="&#126;"',
+        "last-update-label!",
+        "source-highlighter=prettify",
+        "stylesheet=DEFAULT",
+        "linkcss=true",
+        "prettifydir=.",
+        # Just a placeholder, will be filled in asciidoctor java binary:
+        "revnumber=%s",
+    ]
+
+def release_notes_attributes():
+    return [
+        "toc",
+        'newline="\\n"',
+        'asterisk="&#42;"',
+        'plus="&#43;"',
+        'caret="&#94;"',
+        'startsb="&#91;"',
+        'endsb="&#93;"',
+        'tilde="&#126;"',
+        "last-update-label!",
+        "stylesheet=DEFAULT",
+        "linkcss=true",
+    ]
+
+def _replace_macros_impl(ctx):
+    cmd = [
+        ctx.file._exe.path,
+        "--suffix",
+        ctx.attr.suffix,
+        "-s",
+        ctx.file.src.path,
+        "-o",
+        ctx.outputs.out.path,
+    ]
+    if ctx.attr.searchbox:
+        cmd.append("--searchbox")
+    else:
+        cmd.append("--no-searchbox")
+    ctx.actions.run_shell(
+        inputs = [ctx.file._exe, ctx.file.src],
+        outputs = [ctx.outputs.out],
+        command = cmd,
+        use_default_shell_env = True,
+        progress_message = "Replacing macros in %s" % ctx.file.src.short_path,
+    )
+
+_replace_macros = rule(
+    attrs = {
+        "src": attr.label(
+            mandatory = True,
+            allow_single_file = [".txt"],
+        ),
+        "out": attr.output(mandatory = True),
+        "searchbox": attr.bool(default = True),
+        "suffix": attr.string(mandatory = True),
+        "_exe": attr.label(
+            default = Label("//Documentation:replace_macros.py"),
+            allow_single_file = True,
+        ),
+    },
+    implementation = _replace_macros_impl,
+)
+
+def _generate_asciidoc_args(ctx):
+    args = []
+    if ctx.attr.backend:
+        args.extend(["-b", ctx.attr.backend])
+    revnumber = False
+    for attribute in ctx.attr.attributes:
+        if attribute.startswith("revnumber="):
+            revnumber = True
+        else:
+            args.extend(["-a", attribute])
+    if revnumber:
+        args.extend([
+            "--revnumber-file",
+            ctx.file.version.path,
+        ])
+    for src in ctx.files.srcs:
+        args.append(src.path)
+    return args
+
+def _invoke_replace_macros(name, src, suffix, searchbox):
+    fn = src
+    if fn.startswith(":"):
+        fn = src[1:]
+
+    _replace_macros(
+        name = "macros_%s_%s" % (name, fn),
+        src = src,
+        out = fn + suffix,
+        suffix = suffix,
+        searchbox = searchbox,
+    )
+
+    return ":" + fn + suffix, fn.replace(".txt", ".html")
+
+def _asciidoc_impl(ctx):
+    args = [
+        "--bazel",
+        "--in-ext",
+        ".txt" + ctx.attr.suffix,
+        "--out-ext",
+        ".html",
+    ]
+    args.extend(_generate_asciidoc_args(ctx))
+    ctx.actions.run(
+        inputs = ctx.files.srcs + [ctx.file.version],
+        outputs = ctx.outputs.outs,
+        tools = [ctx.executable._exe],
+        executable = ctx.executable._exe,
+        arguments = args,
+        progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+    )
+
+_asciidoc_attrs = {
+    "srcs": attr.label_list(
+        mandatory = True,
+        allow_files = True,
+    ),
+    "attributes": attr.string_list(),
+    "backend": attr.string(),
+    "suffix": attr.string(mandatory = True),
+    "version": attr.label(
+        default = Label("//:version.txt"),
+        allow_single_file = True,
+    ),
+    "_exe": attr.label(
+        default = Label("//lib/asciidoctor:asciidoc"),
+        cfg = "host",
+        allow_files = True,
+        executable = True,
+    ),
+}
+
+_asciidoc = rule(
+    attrs = dict(_asciidoc_attrs.items() + {
+        "outs": attr.output_list(mandatory = True),
+    }.items()),
+    implementation = _asciidoc_impl,
+)
+
+def _genasciidoc_htmlonly(
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        **kwargs):
+    SUFFIX = "." + name + "_macros"
+    new_srcs = []
+    outs = ["asciidoctor.css"]
+
+    for src in srcs:
+        new_src, html_name = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+        new_srcs.append(new_src)
+        outs.append(html_name)
+
+    _asciidoc(
+        name = name + "_gen",
+        srcs = new_srcs,
+        suffix = SUFFIX,
+        backend = backend,
+        attributes = attributes,
+        outs = outs,
+    )
+
+    native.filegroup(
+        name = name,
+        data = outs,
+        **kwargs
+    )
+
+def genasciidoc(
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        resources = True,
+        **kwargs):
+    SUFFIX = "_htmlonly"
+
+    _genasciidoc_htmlonly(
+        name = name + SUFFIX if resources else name,
+        srcs = srcs,
+        attributes = attributes,
+        backend = backend,
+        searchbox = searchbox,
+        **kwargs
+    )
+
+    if resources:
+        htmlonly = ":" + name + SUFFIX
+        native.filegroup(
+            name = name,
+            srcs = [
+                htmlonly,
+                "//Documentation:resources",
+            ],
+            **kwargs
+        )
+
+def _asciidoc_html_zip_impl(ctx):
+    args = [
+        "--mktmp",
+        "-z",
+        ctx.outputs.out.path,
+        "--in-ext",
+        ".txt" + ctx.attr.suffix,
+        "--out-ext",
+        ".html",
+    ]
+    args.extend(_generate_asciidoc_args(ctx))
+    ctx.actions.run(
+        inputs = ctx.files.srcs + [ctx.file.version],
+        tools = [ctx.executable._exe],
+        outputs = [ctx.outputs.out],
+        executable = ctx.executable._exe,
+        arguments = args,
+        progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+    )
+
+_asciidoc_html_zip = rule(
+    attrs = _asciidoc_attrs,
+    outputs = {
+        "out": "%{name}.zip",
+    },
+    implementation = _asciidoc_html_zip_impl,
+)
+
+def _genasciidoc_htmlonly_zip(
+        name,
+        srcs = [],
+        attributes = [],
+        backend = None,
+        searchbox = True,
+        **kwargs):
+    SUFFIX = "." + name + "_expn"
+    new_srcs = []
+
+    for src in srcs:
+        new_src, _ = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+        new_srcs.append(new_src)
+
+    _asciidoc_html_zip(
+        name = name,
+        srcs = new_srcs,
+        suffix = SUFFIX,
+        backend = backend,
+        attributes = attributes,
+    )
+
+def _asciidoc_zip_impl(ctx):
+    tmpdir = ctx.outputs.out.path + "_tmpdir"
+    cmd = [
+        "p=$PWD",
+        "rm -rf %s" % tmpdir,
+        "mkdir -p %s/%s/" % (tmpdir, ctx.attr.directory),
+        "unzip -q %s -d %s/%s/" % (ctx.file.src.path, tmpdir, ctx.attr.directory),
+    ]
+    for r in ctx.files.resources:
+        if r.path == r.short_path:
+            cmd.append("tar -cf- %s | tar -C %s -xf-" % (r.short_path, tmpdir))
+        else:
+            parent = r.path[:-len(r.short_path)]
+            cmd.append(
+                "tar -C %s -cf- %s | tar -C %s -xf-" % (parent, r.short_path, tmpdir),
+            )
+    cmd.extend([
+        "cd %s" % tmpdir,
+        "zip -qr $p/%s *" % ctx.outputs.out.path,
+    ])
+    ctx.actions.run_shell(
+        inputs = [ctx.file.src] + ctx.files.resources,
+        outputs = [ctx.outputs.out],
+        command = " && ".join(cmd),
+        progress_message =
+            "Generating asciidoctor zip file %s" % ctx.outputs.out.short_path,
+    )
+
+_asciidoc_zip = rule(
+    attrs = {
+        "src": attr.label(
+            mandatory = True,
+            allow_single_file = [".zip"],
+        ),
+        "directory": attr.string(mandatory = True),
+        "resources": attr.label_list(
+            mandatory = True,
+            allow_files = True,
+        ),
+    },
+    outputs = {
+        "out": "%{name}.zip",
+    },
+    implementation = _asciidoc_zip_impl,
+)
+
+def genasciidoc_zip(
+        name,
+        srcs = [],
+        attributes = [],
+        directory = None,
+        backend = None,
+        searchbox = True,
+        resources = True,
+        **kwargs):
+    SUFFIX = "_htmlonly"
+
+    _genasciidoc_htmlonly_zip(
+        name = name + SUFFIX if resources else name,
+        srcs = srcs,
+        attributes = attributes,
+        backend = backend,
+        searchbox = searchbox,
+        **kwargs
+    )
+
+    if resources:
+        htmlonly = ":" + name + SUFFIX
+        _asciidoc_zip(
+            name = name,
+            src = htmlonly,
+            resources = ["//Documentation:resources"],
+            directory = directory,
+        )
diff --git a/tools/bzl/bazelisk_version.bzl b/tools/bzl/bazelisk_version.bzl
new file mode 100644
index 0000000..d8b3d10
--- /dev/null
+++ b/tools/bzl/bazelisk_version.bzl
@@ -0,0 +1,16 @@
+_template = """
+load("@bazel_skylib//lib:versions.bzl", "versions")
+
+def check_bazel_version():
+  versions.check(minimum_bazel_version = "{version}")
+""".strip()
+
+def _impl(repository_ctx):
+    repository_ctx.symlink(Label("@//:.bazelversion"), ".bazelversion")
+    bazelversion = repository_ctx.read(".bazelversion").strip()
+
+    repository_ctx.file("BUILD", executable = False)
+
+    repository_ctx.file("check.bzl", executable = False, content = _template.format(version = bazelversion))
+
+bazelisk_version = repository_rule(implementation = _impl)
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
new file mode 100644
index 0000000..682ee3c
--- /dev/null
+++ b/tools/bzl/classpath.bzl
@@ -0,0 +1,24 @@
+def _classpath_collector(ctx):
+    all = []
+    for d in ctx.attr.deps:
+        if JavaInfo in d:
+            all.append(d[JavaInfo].transitive_runtime_deps)
+            all.append(d[JavaInfo].compilation_info.runtime_classpath)
+        elif hasattr(d, "files"):
+            all.append(d.files)
+
+    as_strs = [c.path for c in depset(transitive = all).to_list()]
+    ctx.actions.write(
+        output = ctx.outputs.runtime,
+        content = "\n".join(sorted(as_strs)),
+    )
+
+classpath_collector = rule(
+    attrs = {
+        "deps": attr.label_list(),
+    },
+    outputs = {
+        "runtime": "%{name}.runtime_classpath",
+    },
+    implementation = _classpath_collector,
+)
diff --git a/tools/bzl/genrule2.bzl b/tools/bzl/genrule2.bzl
index e67ee30..d0b0969 100644
--- a/tools/bzl/genrule2.bzl
+++ b/tools/bzl/genrule2.bzl
@@ -15,15 +15,15 @@
 # Syntactic sugar for native genrule() rule:
 #   expose ROOT shell variable
 #   expose TMP shell variable
-#   accept single output
 
-def genrule2(out, cmd, **kwargs):
-  cmd = ' && '.join([
-    'ROOT=$$PWD',
-    'TMP=$$(mktemp -d)',
-    '(' + cmd + ')',
-  ])
-  native.genrule(
-    cmd = cmd,
-    outs = [out],
-    **kwargs)
+def genrule2(cmd, **kwargs):
+    cmd = " && ".join([
+        "ROOT=$$PWD",
+        "TMP=$$(mktemp -d || mktemp -d -t bazel-tmp)",
+        "(" + cmd + ")",
+        "rm -rf $$TMP",
+    ])
+    native.genrule(
+        cmd = cmd,
+        **kwargs
+    )
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index d16cecd..c13d927 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -12,17 +12,302 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# GWT Rules Skylark rules for building [GWT](http://www.gwtproject.org/)
-# modules using Bazel.
-load('//tools/bzl:java.bzl', 'java_library2')
+# Port of Buck native gwt_binary() rule. See discussion in context of
+# https://github.com/facebook/buck/issues/109
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:java.bzl", "java_library2")
 
-def gwt_module(gwt_xml=None, resources=[], srcs=[], **kwargs):
-  if gwt_xml:
-    resources = resources + [gwt_xml]
-  if srcs:
-    resources = resources + srcs
+jar_filetype = [".jar"]
 
-  java_library2(
-    srcs = srcs,
-    resources = resources,
-    **kwargs)
+BROWSERS = [
+    "chrome",
+    "firefox",
+    "gecko1_8",
+    "safari",
+    "msie",
+    "ie8",
+    "ie9",
+    "ie10",
+    "edge",
+]
+
+ALIASES = {
+    "chrome": "safari",
+    "edge": "gecko1_8",
+    "firefox": "gecko1_8",
+    "msie": "ie10",
+}
+
+MODULE = "com.google.gerrit.GerritGwtUI"
+
+GWT_COMPILER = "com.google.gwt.dev.Compiler"
+
+GWT_JVM_ARGS = ["-Xmx512m"]
+
+GWT_COMPILER_ARGS = [
+    "-XdisableClassMetadata",
+]
+
+GWT_COMPILER_ARGS_RELEASE_MODE = GWT_COMPILER_ARGS + [
+    "-XdisableCastChecking",
+]
+
+GWT_PLUGIN_DEPS_NEVERLINK = [
+    "//gerrit-plugin-gwtui:gwtui-api-lib-neverlink",
+    "//lib/gwt:user-neverlink",
+]
+
+GWT_PLUGIN_DEPS = [
+    "//gerrit-plugin-gwtui:gwtui-api-lib",
+]
+
+GWT_TRANSITIVE_DEPS = [
+    "//lib:jsr305",
+    "//lib/gwt:ant",
+    "//lib/gwt:colt",
+    "//lib/gwt:javax-validation",
+    "//lib/gwt:javax-validation_src",
+    "//lib/gwt:jsinterop-annotations",
+    "//lib/gwt:jsinterop-annotations_src",
+    "//lib/gwt:tapestry",
+    "//lib/gwt:w3c-css-sac",
+    "//lib/ow2:ow2-asm",
+    "//lib/ow2:ow2-asm-analysis",
+    "//lib/ow2:ow2-asm-commons",
+    "//lib/ow2:ow2-asm-tree",
+    "//lib/ow2:ow2-asm-util",
+]
+
+DEPS = GWT_TRANSITIVE_DEPS + [
+    "//gerrit-gwtexpui:CSS",
+    "//lib:gwtjsonrpc",
+    "//lib/gwt:dev",
+    "//lib/jgit/org.eclipse.jgit:jgit-source",
+]
+
+USER_AGENT_XML = """<module rename-to='gerrit_ui'>
+<inherits name='%s'/>
+<set-property name='user.agent' value='%s'/>
+<set-property name='locale' value='default'/>
+</module>
+"""
+
+def gwt_module(gwt_xml = None, resources = [], srcs = [], **kwargs):
+    if gwt_xml:
+        resources = resources + [gwt_xml]
+
+    java_library2(
+        srcs = srcs,
+        resources = resources,
+        **kwargs
+    )
+
+def _gwt_user_agent_module(ctx):
+    """Generate user agent specific GWT module."""
+    if not ctx.attr.user_agent:
+        return None
+
+    ua = ctx.attr.user_agent
+    impl = ua
+    if ua in ALIASES:
+        impl = ALIASES[ua]
+
+    # intermediate artifact: user agent speific GWT xml file
+    gwt_user_agent_xml = ctx.actions.declare_file(ctx.label.name + "_gwt.xml")
+    ctx.actions.write(
+        output = gwt_user_agent_xml,
+        content = USER_AGENT_XML % (MODULE, impl),
+    )
+
+    # intermediate artifact: user agent specific zip with GWT module
+    gwt_user_agent_zip = ctx.actions.declare_file(ctx.label.name + "_gwt.zip")
+    gwt = "%s_%s.gwt.xml" % (MODULE.replace(".", "/"), ua)
+    dir = gwt_user_agent_zip.path + ".dir"
+    cmd = " && ".join([
+        "p=$PWD",
+        "mkdir -p %s" % dir,
+        "cd %s" % dir,
+        "mkdir -p $(dirname %s)" % gwt,
+        "cp $p/%s %s" % (gwt_user_agent_xml.path, gwt),
+        "$p/%s cC $p/%s $(find . | sed 's|^./||')" % (ctx.executable._zip.path, gwt_user_agent_zip.path),
+    ])
+    ctx.actions.run_shell(
+        inputs = [gwt_user_agent_xml],
+        outputs = [gwt_user_agent_zip],
+        tools = ctx.files._zip,
+        command = cmd,
+        mnemonic = "GenerateUserAgentGWTModule",
+    )
+
+    return struct(
+        zip = gwt_user_agent_zip,
+        module = MODULE + "_" + ua,
+    )
+
+def _gwt_binary_impl(ctx):
+    module = ctx.attr.module[0]
+    output_zip = ctx.outputs.output
+    output_dir = output_zip.path + ".gwt_output"
+    deploy_dir = output_zip.path + ".gwt_deploy"
+
+    deps = _get_transitive_closure(ctx)
+
+    paths = [dep.path for dep in deps.to_list()]
+
+    gwt_user_agent_modules = []
+    ua = _gwt_user_agent_module(ctx)
+    if ua:
+        paths.append(ua.zip.path)
+        gwt_user_agent_modules.append(ua.zip)
+        module = ua.module
+
+    cmd = "%s %s -Dgwt.normalizeTimestamps=true -cp %s %s -war %s -deploy %s " % (
+        ctx.attr._jdk[java_common.JavaRuntimeInfo].java_executable_exec_path,
+        " ".join(ctx.attr.jvm_args),
+        ":".join(paths),
+        GWT_COMPILER,
+        output_dir,
+        deploy_dir,
+    )
+
+    # TODO(davido): clean up command concatenation
+    cmd += " ".join([
+        "-style %s" % ctx.attr.style,
+        "-optimize %s" % ctx.attr.optimize,
+        "-strict",
+        " ".join(ctx.attr.compiler_args),
+        module + "\n",
+        "rm -rf %s/gwt-unitCache\n" % output_dir,
+        "root=`pwd`\n",
+        "cd %s; $root/%s Cc ../%s $(find .)\n" % (
+            output_dir,
+            ctx.executable._zip.path,
+            output_zip.basename,
+        ),
+    ])
+
+    ctx.actions.run_shell(
+        inputs = depset(direct = gwt_user_agent_modules, transitive = [deps]),
+        outputs = [output_zip],
+        tools = ctx.files._jdk + ctx.files._zip,
+        mnemonic = "GwtBinary",
+        progress_message = "GWT compiling " + output_zip.short_path,
+        command = "set -e\n" + cmd,
+    )
+
+def _get_transitive_closure(ctx):
+    deps = []
+    for dep in ctx.attr.module_deps:
+        deps.append(dep[JavaInfo].transitive_runtime_deps)
+        deps.append(dep[JavaInfo].transitive_source_jars)
+    for dep in ctx.attr.deps:
+        if JavaInfo in dep:
+            deps.append(dep[JavaInfo].transitive_runtime_deps)
+        elif hasattr(dep, "files"):
+            deps.append(dep.files)
+
+    return depset(transitive = deps)
+
+gwt_binary = rule(
+    attrs = {
+        "compiler_args": attr.string_list(),
+        "jvm_args": attr.string_list(),
+        "module": attr.string_list(default = [MODULE]),
+        "module_deps": attr.label_list(allow_files = jar_filetype),
+        "optimize": attr.string(default = "9"),
+        "style": attr.string(default = "OBF"),
+        "user_agent": attr.string(),
+        "deps": attr.label_list(allow_files = jar_filetype),
+        "_jdk": attr.label(
+            default = Label("@bazel_tools//tools/jdk:current_java_runtime"),
+            cfg = "host",
+        ),
+        "_zip": attr.label(
+            default = Label("@bazel_tools//tools/zip:zipper"),
+            cfg = "host",
+            executable = True,
+            allow_single_file = True,
+        ),
+    },
+    outputs = {
+        "output": "%{name}.zip",
+    },
+    implementation = _gwt_binary_impl,
+)
+
+def gwt_genrule(suffix = ""):
+    dbg = "ui_dbg" + suffix
+    opt = "ui_opt" + suffix
+    module_dep = ":ui_module" + suffix
+    args = GWT_COMPILER_ARGS_RELEASE_MODE if suffix == "_r" else GWT_COMPILER_ARGS
+
+    genrule2(
+        name = "ui_optdbg" + suffix,
+        srcs = [
+            ":" + dbg,
+            ":" + opt,
+        ],
+        cmd = "cd $$TMP;" +
+              "unzip -q $$ROOT/$(location :%s);" % dbg +
+              "mv" +
+              " gerrit_ui/gerrit_ui.nocache.js" +
+              " gerrit_ui/dbg_gerrit_ui.nocache.js;" +
+              "unzip -qo $$ROOT/$(location :%s);" % opt +
+              "mkdir -p $$(dirname $@);" +
+              "zip -qrD $$ROOT/$@ .",
+        outs = ["ui_optdbg" + suffix + ".zip"],
+        visibility = ["//visibility:public"],
+    )
+
+    gwt_binary(
+        name = opt,
+        module = [MODULE],
+        module_deps = [module_dep],
+        deps = DEPS,
+        compiler_args = args,
+        jvm_args = GWT_JVM_ARGS,
+    )
+
+    gwt_binary(
+        name = dbg,
+        style = "PRETTY",
+        optimize = "0",
+        module_deps = [module_dep],
+        deps = DEPS,
+        compiler_args = GWT_COMPILER_ARGS,
+        jvm_args = GWT_JVM_ARGS,
+    )
+
+def gen_ui_module(name, suffix = ""):
+    gwt_module(
+        name = name + suffix,
+        srcs = native.glob(["src/main/java/**/*.java"]),
+        gwt_xml = "src/main/java/%s.gwt.xml" % MODULE.replace(".", "/"),
+        resources = native.glob(
+            ["src/main/java/**/*"],
+            exclude = ["src/main/java/**/*.java"] +
+                      ["src/main/java/%s.gwt.xml" % MODULE.replace(".", "/")],
+        ),
+        deps = [
+            "//gerrit-gwtui-common:diffy_logo",
+            "//gerrit-gwtui-common:client",
+            "//gerrit-gwtexpui:CSS",
+            "//lib/codemirror:codemirror" + suffix,
+            "//lib/gwt:user",
+        ],
+        visibility = ["//visibility:public"],
+    )
+
+def gwt_user_agent_permutations():
+    for ua in BROWSERS:
+        gwt_binary(
+            name = "ui_%s" % ua,
+            user_agent = ua,
+            style = "PRETTY",
+            optimize = "0",
+            module = [MODULE],
+            module_deps = [":ui_module"],
+            deps = DEPS,
+            compiler_args = GWT_COMPILER_ARGS,
+            jvm_args = GWT_JVM_ARGS,
+        )
diff --git a/tools/bzl/java.bzl b/tools/bzl/java.bzl
index 5fca724..8996b69 100644
--- a/tools/bzl/java.bzl
+++ b/tools/bzl/java.bzl
@@ -15,11 +15,14 @@
 # Syntactic sugar for native java_library() rule:
 #   accept exported_deps attributes
 
-def java_library2(deps=[], exported_deps=[], exports=[], **kwargs):
-  if exported_deps:
-    deps = deps + exported_deps
-    exports = exports + exported_deps
-  native.java_library(
-    deps = deps,
-    exports = exports,
-    **kwargs)
+load("@rules_java//java:defs.bzl", "java_library")
+
+def java_library2(deps = [], exported_deps = [], exports = [], **kwargs):
+    if exported_deps:
+        deps = deps + exported_deps
+        exports = exports + exported_deps
+    java_library(
+        deps = deps,
+        exports = exports,
+        **kwargs
+    )
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
new file mode 100644
index 0000000..77c2d4a
--- /dev/null
+++ b/tools/bzl/javadoc.bzl
@@ -0,0 +1,78 @@
+# 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.
+
+# Javadoc rule.
+
+def _impl(ctx):
+    zip_output = ctx.outputs.zip
+
+    transitive_jars = depset(transitive = [j[JavaInfo].transitive_deps for j in ctx.attr.libs])
+
+    # TODO(davido): Remove list to depset conversion on source_jars, when this issue is fixed:
+    # https://github.com/bazelbuild/bazel/issues/4221
+    source_jars = depset(transitive = [depset(j[JavaInfo].source_jars) for j in ctx.attr.libs])
+
+    transitive_jar_paths = [j.path for j in transitive_jars.to_list()]
+    dir = ctx.outputs.zip.path + ".dir"
+    source = ctx.outputs.zip.path + ".source"
+    external_docs = ["https://docs.oracle.com/javase/8/docs/api"] + ctx.attr.external_docs
+    cmd = [
+        "TZ=UTC",
+        "export TZ",
+        "rm -rf %s" % source,
+        "mkdir %s" % source,
+        " && ".join(["unzip -qud %s %s" % (source, j.path) for j in source_jars.to_list()]),
+        "rm -rf %s" % dir,
+        "mkdir %s" % dir,
+        " ".join([
+            "%s/bin/javadoc" % ctx.attr._jdk[java_common.JavaRuntimeInfo].java_home,
+            "-Xdoclint:-missing",
+            "-protected",
+            "-encoding UTF-8",
+            "-charset UTF-8",
+            "-notimestamp",
+            "-quiet",
+            "-windowtitle '%s'" % ctx.attr.title,
+            " ".join(["-link %s" % url for url in external_docs]),
+            "-sourcepath %s" % source,
+            "-subpackages ",
+            ":".join(ctx.attr.pkgs),
+            " -classpath ",
+            ":".join(transitive_jar_paths),
+            "-d %s" % dir,
+        ]),
+        "find %s -exec touch -t 198001010000 '{}' ';'" % dir,
+        "(cd %s && zip -Xqr ../%s *)" % (dir, ctx.outputs.zip.basename),
+    ]
+    ctx.actions.run_shell(
+        inputs = transitive_jars.to_list() + source_jars.to_list() + ctx.files._jdk,
+        outputs = [zip_output],
+        command = " && ".join(cmd),
+    )
+
+java_doc = rule(
+    attrs = {
+        "external_docs": attr.string_list(),
+        "libs": attr.label_list(allow_files = False),
+        "pkgs": attr.string_list(),
+        "title": attr.string(),
+        "_jdk": attr.label(
+            default = Label("@bazel_tools//tools/jdk:current_java_runtime"),
+            allow_files = True,
+            providers = [java_common.JavaRuntimeInfo],
+        ),
+    },
+    outputs = {"zip": "%{name}.zip"},
+    implementation = _impl,
+)
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
new file mode 100644
index 0000000..17acc00
--- /dev/null
+++ b/tools/bzl/js.bzl
@@ -0,0 +1,432 @@
+NPMJS = "NPMJS"
+
+GERRIT = "GERRIT:"
+
+NPM_VERSIONS = {
+    "bower": "1.8.8",
+    "crisper": "2.0.2",
+    "vulcanize": "1.14.8",
+}
+
+NPM_SHA1S = {
+    "bower": "82544be34a33aeae7efb8bdf9905247b2cffa985",
+    "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
+    "vulcanize": "679107f251c19ab7539529b1e3fdd40829e6fc63",
+}
+
+def _npm_tarball(name):
+    return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
+
+def _npm_binary_impl(ctx):
+    """rule to download a NPM archive."""
+    name = ctx.name
+    version = NPM_VERSIONS[name]
+    sha1 = NPM_VERSIONS[name]
+
+    dir = "%s-%s" % (name, version)
+    filename = "%s.tgz" % dir
+    base = "%s@%s.npm_binary.tgz" % (name, version)
+    dest = ctx.path(base)
+    repository = ctx.attr.repository
+    if repository == GERRIT:
+        url = "https://gerrit-maven.storage.googleapis.com/npm-packages/%s" % filename
+    elif repository == NPMJS:
+        url = "https://registry.npmjs.org/%s/-/%s" % (name, filename)
+    else:
+        fail("repository %s not in {%s,%s}" % (repository, GERRIT, NPMJS))
+
+    python = ctx.which("python")
+    script = ctx.path(ctx.attr._download_script)
+
+    sha1 = NPM_SHA1S[name]
+    args = [python, script, "-o", dest, "-u", url, "-v", sha1]
+    out = ctx.execute(args)
+    if out.return_code:
+        fail("failed %s: %s" % (args, out.stderr))
+    ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
+
+npm_binary = repository_rule(
+    attrs = {
+        "repository": attr.string(default = NPMJS),
+        # Label resolves within repo of the .bzl file.
+        "_download_script": attr.label(default = Label("//tools:download_file.py")),
+    },
+    local = True,
+    implementation = _npm_binary_impl,
+)
+
+ComponentInfo = provider()
+
+# for use in repo rules.
+def _run_npm_binary_str(ctx, tarball, args):
+    python_bin = ctx.which("python")
+    return " ".join([
+        str(python_bin),
+        str(ctx.path(ctx.attr._run_npm)),
+        str(ctx.path(tarball)),
+    ] + args)
+
+def _bower_archive(ctx):
+    """Download a bower package."""
+    download_name = "%s__download_bower.zip" % ctx.name
+    renamed_name = "%s__renamed.zip" % ctx.name
+    version_name = "%s__version.json" % ctx.name
+
+    cmd = [
+        ctx.which("python"),
+        ctx.path(ctx.attr._download_bower),
+        "-b",
+        "%s" % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
+        "-n",
+        ctx.name,
+        "-p",
+        ctx.attr.package,
+        "-v",
+        ctx.attr.version,
+        "-s",
+        ctx.attr.sha1,
+        "-o",
+        download_name,
+    ]
+
+    out = ctx.execute(cmd)
+    if out.return_code:
+        fail("failed %s: %s" % (cmd, out.stderr))
+
+    _bash(ctx, " && ".join([
+        "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
+        "TZ=UTC",
+        "export UTC",
+        "cd $TMP",
+        "mkdir bower_components",
+        "cd bower_components",
+        "unzip %s" % ctx.path(download_name),
+        "cd ..",
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -Xr %s bower_components" % renamed_name,
+        "cd ..",
+        "rm -rf ${TMP}",
+    ]))
+
+    dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
+    ctx.file(
+        version_name,
+        '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version),
+    )
+    ctx.file(
+        "BUILD",
+        "\n".join([
+            "package(default_visibility=['//visibility:public'])",
+            "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
+            "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
+        ]),
+        False,
+    )
+
+def _bash(ctx, cmd):
+    cmd_list = ["bash", "-c", cmd]
+    out = ctx.execute(cmd_list)
+    if out.return_code:
+        fail("failed %s: %s" % (cmd_list, out.stderr))
+
+bower_archive = repository_rule(
+    _bower_archive,
+    attrs = {
+        "package": attr.string(mandatory = True),
+        "semver": attr.string(),
+        "sha1": attr.string(mandatory = True),
+        "version": attr.string(mandatory = True),
+        "_bower_archive": attr.label(default = Label("@bower//:%s" % _npm_tarball("bower"))),
+        "_download_bower": attr.label(default = Label("//tools/js:download_bower.py")),
+        "_run_npm": attr.label(default = Label("//tools/js:run_npm_binary.py")),
+    },
+)
+
+def _bower_component_impl(ctx):
+    transitive_zipfiles = depset(
+        direct = [ctx.file.zipfile],
+        transitive = [d[ComponentInfo].transitive_zipfiles for d in ctx.attr.deps],
+    )
+
+    transitive_licenses = depset(
+        direct = [ctx.file.license],
+        transitive = [d[ComponentInfo].transitive_licenses for d in ctx.attr.deps],
+    )
+
+    transitive_versions = depset(
+        direct = ctx.files.version_json,
+        transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps],
+    )
+
+    return [
+        ComponentInfo(
+            transitive_licenses = transitive_licenses,
+            transitive_versions = transitive_versions,
+            transitive_zipfiles = transitive_zipfiles,
+        ),
+    ]
+
+_common_attrs = {
+    "deps": attr.label_list(providers = [ComponentInfo]),
+}
+
+def _js_component(ctx):
+    dir = ctx.outputs.zip.path + ".dir"
+    name = ctx.outputs.zip.basename
+    if name.endswith(".zip"):
+        name = name[:-4]
+    dest = "%s/%s" % (dir, name)
+    cmd = " && ".join([
+        "TZ=UTC",
+        "export TZ",
+        "mkdir -p %s" % dest,
+        "cp %s %s/" % (" ".join([s.path for s in ctx.files.srcs]), dest),
+        "cd %s" % dir,
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -Xqr ../%s *" % ctx.outputs.zip.basename,
+    ])
+
+    ctx.actions.run_shell(
+        inputs = ctx.files.srcs,
+        outputs = [ctx.outputs.zip],
+        command = cmd,
+        mnemonic = "GenBowerZip",
+    )
+
+    licenses = []
+    if ctx.file.license:
+        licenses.append(ctx.file.license)
+
+    return [
+        ComponentInfo(
+            transitive_licenses = depset(licenses),
+            transitive_versions = depset(),
+            transitive_zipfiles = list([ctx.outputs.zip]),
+        ),
+    ]
+
+js_component = rule(
+    _js_component,
+    attrs = dict(_common_attrs.items() + {
+        "srcs": attr.label_list(allow_files = [".js"]),
+        "license": attr.label(allow_single_file = True),
+    }.items()),
+    outputs = {
+        "zip": "%{name}.zip",
+    },
+)
+
+_bower_component = rule(
+    _bower_component_impl,
+    attrs = dict(_common_attrs.items() + {
+        "license": attr.label(allow_single_file = True),
+
+        # If set, define by hand, and don't regenerate this entry in bower2bazel.
+        "seed": attr.bool(default = False),
+        "version_json": attr.label(allow_files = [".json"]),
+        "zipfile": attr.label(allow_single_file = [".zip"]),
+    }.items()),
+)
+
+# TODO(hanwen): make license mandatory.
+def bower_component(name, license = None, **kwargs):
+    prefix = "//lib:LICENSE-"
+    if license and not license.startswith(prefix):
+        license = prefix + license
+    _bower_component(
+        name = name,
+        license = license,
+        zipfile = "@%s//:zipfile" % name,
+        version_json = "@%s//:version_json" % name,
+        **kwargs
+    )
+
+def _bower_component_bundle_impl(ctx):
+    """A bunch of bower components zipped up."""
+    zips = depset()
+    for d in ctx.attr.deps:
+        files = d[ComponentInfo].transitive_zipfiles
+
+        # TODO(davido): Make sure the field always contains a depset
+        if type(files) == "list":
+            files = depset(files)
+        zips = depset(transitive = [zips, files])
+
+    versions = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
+
+    licenses = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
+
+    out_zip = ctx.outputs.zip
+    out_versions = ctx.outputs.version_json
+
+    ctx.actions.run_shell(
+        inputs = zips.to_list(),
+        outputs = [out_zip],
+        command = " && ".join([
+            "p=$PWD",
+            "TZ=UTC",
+            "export TZ",
+            "rm -rf %s.dir" % out_zip.path,
+            "mkdir -p %s.dir/bower_components" % out_zip.path,
+            "cd %s.dir/bower_components" % out_zip.path,
+            "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips.to_list()])),
+            "cd ..",
+            "find . -exec touch -t 198001010000 '{}' ';'",
+            "zip -Xqr $p/%s bower_components/*" % out_zip.path,
+        ]),
+        mnemonic = "BowerCombine",
+    )
+
+    ctx.actions.run_shell(
+        inputs = versions.to_list(),
+        outputs = [out_versions],
+        mnemonic = "BowerVersions",
+        command = "(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions.to_list()]), out_versions.path),
+    )
+
+    return [
+        ComponentInfo(
+            transitive_licenses = licenses,
+            transitive_versions = versions,
+            transitive_zipfiles = zips,
+        ),
+    ]
+
+bower_component_bundle = rule(
+    _bower_component_bundle_impl,
+    attrs = _common_attrs,
+    outputs = {
+        "version_json": "%{name}-versions.json",
+        "zip": "%{name}.zip",
+    },
+)
+"""Groups a set of bower components together in a zip file.
+
+Outputs:
+  NAME-versions.json:
+    a JSON file containing a PKG-NAME => PKG-NAME#VERSION mapping for the
+    transitive dependencies.
+  NAME.zip:
+    a zip file containing the transitive dependencies for this bundle.
+"""
+
+def _vulcanize_impl(ctx):
+    # intermediate artifact.
+    vulcanized = ctx.actions.declare_file(
+        ctx.outputs.html.path + ".vulcanized.html",
+    )
+    destdir = ctx.outputs.html.path + ".dir"
+    zips = [z for d in ctx.attr.deps for z in d[ComponentInfo].transitive_zipfiles.to_list()]
+
+    hermetic_npm_binary = " ".join([
+        "python",
+        "$p/" + ctx.file._run_npm.path,
+        "$p/" + ctx.file._vulcanize_archive.path,
+        "--inline-scripts",
+        "--inline-css",
+        "--strip-comments",
+        "--out-html",
+        "$p/" + vulcanized.path,
+        ctx.file.app.path,
+    ])
+
+    pkg_dir = ctx.attr.pkg.lstrip("/")
+    cmd = " && ".join([
+        # unpack dependencies.
+        "export PATH",
+        "p=$PWD",
+        "rm -rf %s" % destdir,
+        "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
+        "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
+            " ".join([z.path for z in zips]),
+            destdir,
+            pkg_dir,
+        ),
+        "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
+        "cd %s" % destdir,
+        hermetic_npm_binary,
+    ])
+
+    # Node/NPM is not (yet) hermeticized, so we have to get the binary
+    # from the environment, and it may be under $HOME, so we can't run
+    # in the sandbox.
+    node_tweaks = dict(
+        use_default_shell_env = True,
+        execution_requirements = {"local": "1"},
+    )
+    ctx.actions.run_shell(
+        mnemonic = "Vulcanize",
+        inputs = [
+            ctx.file._run_npm,
+            ctx.file.app,
+            ctx.file._vulcanize_archive,
+        ] + list(zips) + ctx.files.srcs,
+        outputs = [vulcanized],
+        command = cmd,
+        **node_tweaks
+    )
+
+    hermetic_npm_command = "export PATH && " + " ".join([
+        "python",
+        ctx.file._run_npm.path,
+        ctx.file._crisper_archive.path,
+        "--always-write-script",
+        "--source",
+        vulcanized.path,
+        "--html",
+        ctx.outputs.html.path,
+        "--js",
+        ctx.outputs.js.path,
+    ])
+
+    ctx.actions.run_shell(
+        mnemonic = "Crisper",
+        inputs = [
+            ctx.file._run_npm,
+            ctx.file.app,
+            ctx.file._crisper_archive,
+            vulcanized,
+        ],
+        outputs = [ctx.outputs.js, ctx.outputs.html],
+        command = hermetic_npm_command,
+        **node_tweaks
+    )
+
+_vulcanize_rule = rule(
+    _vulcanize_impl,
+    attrs = {
+        "srcs": attr.label_list(allow_files = [
+            ".js",
+            ".html",
+            ".txt",
+            ".css",
+            ".ico",
+        ]),
+        "app": attr.label(
+            mandatory = True,
+            allow_single_file = True,
+        ),
+        "pkg": attr.string(mandatory = True),
+        "deps": attr.label_list(providers = [ComponentInfo]),
+        "_crisper_archive": attr.label(
+            default = Label("@crisper//:%s" % _npm_tarball("crisper")),
+            allow_single_file = True,
+        ),
+        "_run_npm": attr.label(
+            default = Label("//tools/js:run_npm_binary.py"),
+            allow_single_file = True,
+        ),
+        "_vulcanize_archive": attr.label(
+            default = Label("@vulcanize//:%s" % _npm_tarball("vulcanize")),
+            allow_single_file = True,
+        ),
+    },
+    outputs = {
+        "html": "%{name}.html",
+        "js": "%{name}.js",
+    },
+)
+
+def vulcanize(*args, **kwargs):
+    """Vulcanize runs vulcanize and crisper on a set of sources."""
+    _vulcanize_rule(pkg = native.package_name(), *args, **kwargs)
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 19974a7..5e28b8a 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -18,6 +18,8 @@
 
 # See https://github.com/bazelbuild/bazel/issues/1017 for background.
 
+load("@rules_java//java:defs.bzl", "java_test")
+
 _OUTPUT = """import org.junit.runners.Suite;
 import org.junit.runner.RunWith;
 
@@ -28,14 +30,14 @@
 
 _PREFIXES = ("org", "com", "edu")
 
-def _SafeIndex(l, val):
-    for i, v in enumerate(l):
+def _SafeIndex(j, val):
+    for i, v in enumerate(j):
         if val == v:
             return i
     return -1
 
 def _AsClassName(fname):
-    fname = [x.path for x in fname.files][0]
+    fname = [x.path for x in fname.files.to_list()][0]
     toks = fname[:-5].split("/")
     findex = -1
     for s in _PREFIXES:
@@ -43,17 +45,19 @@
         if findex != -1:
             break
     if findex == -1:
-        fail("%s does not contain any of %s",
-                         fname, _PREFIXES)
+        fail("%s does not contain any of %s" % (fname, _PREFIXES))
     return ".".join(toks[findex:]) + ".class"
 
 def _impl(ctx):
     classes = ",".join(
-        [_AsClassName(x) for x in ctx.attr.srcs])
-    ctx.file_action(output=ctx.outputs.out, content=_OUTPUT % (
-            classes, ctx.attr.outname))
+        [_AsClassName(x) for x in ctx.attr.srcs],
+    )
+    ctx.actions.write(output = ctx.outputs.out, content = _OUTPUT % (
+        classes,
+        ctx.attr.outname,
+    ))
 
-_GenSuite = rule(
+_gen_suite = rule(
     attrs = {
         "srcs": attr.label_list(allow_files = True),
         "outname": attr.string(),
@@ -63,11 +67,15 @@
 )
 
 def junit_tests(name, srcs, **kwargs):
-    s_name = name + "TestSuite"
-    _GenSuite(name = s_name,
-              srcs = srcs,
-              outname = s_name)
-    native.java_test(name = name,
-                     test_class = s_name,
-                     srcs = srcs + [":"+s_name],
-                     **kwargs)
+    s_name = name.replace("-", "_") + "TestSuite"
+    _gen_suite(
+        name = s_name,
+        srcs = srcs,
+        outname = s_name,
+    )
+    java_test(
+        name = name,
+        test_class = s_name,
+        srcs = srcs + [":" + s_name],
+        **kwargs
+    )
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
new file mode 100644
index 0000000..4856726
--- /dev/null
+++ b/tools/bzl/license-map.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python
+
+# reads bazel query XML files, to join target names with their licenses.
+
+from __future__ import print_function
+
+import argparse
+from collections import defaultdict
+from shutil import copyfileobj
+from sys import stdout, stderr
+import xml.etree.ElementTree as ET
+
+
+DO_NOT_DISTRIBUTE = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
+
+LICENSE_PREFIX = "//lib:LICENSE-"
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--asciidoctor", action="store_true")
+parser.add_argument("xmls", nargs="+")
+args = parser.parse_args()
+
+entries = defaultdict(list)
+graph = defaultdict(list)
+handled_rules = []
+
+for xml in args.xmls:
+  tree = ET.parse(xml)
+  root = tree.getroot()
+
+  for child in root:
+    rule_name = child.attrib["name"]
+    if rule_name in handled_rules:
+      # already handled in other xml files
+      continue
+
+    handled_rules.append(rule_name)
+    for c in list(child):
+      if c.tag != "rule-input":
+        continue
+
+      license_name = c.attrib["name"]
+      if LICENSE_PREFIX in license_name:
+        entries[rule_name].append(license_name)
+        graph[license_name].append(rule_name)
+
+if len(graph[DO_NOT_DISTRIBUTE]):
+  print("DO_NOT_DISTRIBUTE license found in:", file=stderr)
+  for target in graph[DO_NOT_DISTRIBUTE]:
+    print(target, file=stderr)
+  exit(1)
+
+if args.asciidoctor:
+  print(
+# We don't want any blank line before "= Gerrit Code Review - Licenses"
+"""= Gerrit Code Review - Licenses
+
+Gerrit open source software is licensed under the <<Apache2_0,Apache
+License 2.0>>.  Executable distributions also include other software
+components that are provided under additional licenses.
+
+[[cryptography]]
+== Cryptography Notice
+
+This distribution includes cryptographic software.  The country
+in which you currently reside may have restrictions on the import,
+possession, use, and/or re-export to another country, of encryption
+software.  BEFORE using any encryption software, please check
+your country's laws, regulations and policies concerning the
+import, possession, or use, and re-export of encryption software,
+to see if this is permitted.  See the
+link:http://www.wassenaar.org/[Wassenaar Arrangement]
+for more information.
+
+The U.S. Government Department of Commerce, Bureau of Industry
+and Security (BIS), has classified this software as Export
+Commodity Control Number (ECCN) 5D002.C.1, which includes
+information security software using or performing cryptographic
+functions with asymmetric algorithms.  The form and manner of
+this distribution makes it eligible for export under the License
+Exception ENC Technology Software Unrestricted (TSU) exception
+(see the BIS Export Administration Regulations, Section 740.13)
+for both object code and source code.
+
+Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
+uploads of changes directly from `git push` command line clients.
+
+Gerrit includes an SSH client (JSch), to support authenticated
+replication of changes to remote systems, such as for automatic
+updates of mirror servers, or realtime backups.
+
+== Licenses
+""")
+
+for n in sorted(graph.keys()):
+  if len(graph[n]) == 0:
+    continue
+
+  name = n[len(LICENSE_PREFIX):]
+  safename = name.replace(".", "_")
+  print()
+  print("[[%s]]" % safename)
+  print(name)
+  print()
+  for d in sorted(graph[n]):
+    if d.startswith("//lib:") or d.startswith("//lib/"):
+      p = d[len("//lib:"):]
+    else:
+      p = d[d.index(":")+1:].lower()
+    if "__" in p:
+      p = p[:p.index("__")]
+    print("* " + p)
+  print()
+  print("[[%s_license]]" % safename)
+  print("----")
+  filename = n[2:].replace(":", "/")
+  try:
+    with open(filename, errors='ignore') as fd:
+      copyfileobj(fd, stdout)
+  except TypeError:
+    with open(filename) as fd:
+      copyfileobj(fd, stdout)
+  print()
+  print("----")
+  print()
+
+if args.asciidoctor:
+  print(
+"""
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+""")
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
new file mode 100644
index 0000000..d059216
--- /dev/null
+++ b/tools/bzl/license.bzl
@@ -0,0 +1,57 @@
+def normalize_target_name(target):
+    return target.replace("//", "").replace("/", "__").replace(":", "___")
+
+def license_map(name, targets = [], opts = [], **kwargs):
+    """Generate XML for all targets that depend directly on a LICENSE file"""
+    xmls = []
+    tools = ["//tools/bzl:license-map.py", "//lib:all-licenses"]
+    for target in targets:
+        subname = name + "_" + normalize_target_name(target) + ".xml"
+        xmls.append("$(location :%s)" % subname)
+        tools.append(subname)
+        native.genquery(
+            name = subname,
+            scope = [target],
+
+            # Find everything that depends on a license file, but remove
+            # the license files themselves from this list.
+            expression = 'rdeps(%s, filter("//lib:LICENSE.*", deps(%s)),1) - filter("//lib:LICENSE.*", deps(%s))' % (target, target, target),
+
+            # We are interested in the edges of the graph ({java_library,
+            # license-file} tuples).  'query' provides this in the XML output.
+            opts = ["--output=xml"],
+        )
+
+    # post process the XML into our favorite format.
+    native.genrule(
+        name = "gen_license_txt_" + name,
+        cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
+        outs = [name + ".txt"],
+        tools = tools,
+        **kwargs
+    )
+
+def license_test(name, target):
+    """Make sure a target doesn't depend on DO_NOT_DISTRIBUTE license"""
+    txt = name + "-forbidden.txt"
+
+    # fully qualify target name.
+    if target[0] not in ":/":
+        target = ":" + target
+    if target[0] != "/":
+        target = "//" + native.package_name() + target
+
+    forbidden = "//lib:LICENSE-DO_NOT_DISTRIBUTE"
+    native.genquery(
+        name = txt,
+        scope = [target, forbidden],
+        # Find everything that depends on a license file, but remove
+        # the license files themselves from this list.
+        expression = 'rdeps(%s, "%s", 1) - rdeps(%s, "%s", 0)' % (target, forbidden, target, forbidden),
+    )
+    native.sh_test(
+        name = name,
+        srcs = ["//tools/bzl:test_license.sh"],
+        args = ["$(location :%s)" % txt],
+        data = [txt],
+    )
diff --git a/tools/bzl/maven.bzl b/tools/bzl/maven.bzl
index ce2f483..36e3084e 100644
--- a/tools/bzl/maven.bzl
+++ b/tools/bzl/maven.bzl
@@ -14,22 +14,21 @@
 
 # Merge maven files
 
-def cmd(jars):
-  return ('$(location //tools:merge_jars) $@ '
-          + ' '.join(['$(location %s)' % j for j in jars]))
+load("@rules_java//java:defs.bzl", "java_import")
 
-def merge_maven_jars(
-    name,
-    srcs,
-    visibility = []):
-  native.genrule(
-    name = '%s__merged_bin' % name,
-    cmd = cmd(srcs),
-    tools = srcs + ['//tools:merge_jars'],
-    outs = ['%s__merged.jar' % name],
-  )
-  native.java_import(
-    name = name,
-    jars = [':%s__merged_bin' % name],
-    visibility = visibility,
-  )
+def cmd(jars):
+    return ("$(location //tools:merge_jars) $@ " +
+            " ".join(["$(location %s)" % j for j in jars]))
+
+def merge_maven_jars(name, srcs, **kwargs):
+    native.genrule(
+        name = "%s__merged_bin" % name,
+        cmd = cmd(srcs),
+        tools = srcs + ["//tools:merge_jars"],
+        outs = ["%s__merged.jar" % name],
+    )
+    java_import(
+        name = name,
+        jars = [":%s__merged_bin" % name],
+        **kwargs
+    )
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
new file mode 100644
index 0000000..0bad778
--- /dev/null
+++ b/tools/bzl/maven_jar.bzl
@@ -0,0 +1,181 @@
+GERRIT = "GERRIT:"
+
+GERRIT_API = "GERRIT_API:"
+
+MAVEN_CENTRAL = "MAVEN_CENTRAL:"
+
+MAVEN_LOCAL = "MAVEN_LOCAL:"
+
+ECLIPSE = "ECLIPSE:"
+
+def _maven_release(ctx, parts):
+    """induce jar and url name from maven coordinates."""
+    if len(parts) not in [3, 4]:
+        fail('%s:\nexpected id="groupId:artifactId:version[:classifier]"' %
+             ctx.attr.artifact)
+    if len(parts) == 4:
+        group, artifact, version, classifier = parts
+        file_version = version + "-" + classifier
+    else:
+        group, artifact, version = parts
+        file_version = version
+
+    jar = artifact.lower() + "-" + file_version
+    url = "/".join([
+        ctx.attr.repository,
+        group.replace(".", "/"),
+        artifact,
+        version,
+        artifact + "-" + file_version,
+    ])
+
+    return jar, url
+
+# Creates a struct containing the different parts of an artifact's FQN
+def _create_coordinates(fully_qualified_name):
+    parts = fully_qualified_name.split(":")
+    packaging = None
+    classifier = None
+
+    if len(parts) == 3:
+        group_id, artifact_id, version = parts
+    elif len(parts) == 4:
+        group_id, artifact_id, version, packaging = parts
+    elif len(parts) == 5:
+        group_id, artifact_id, version, packaging, classifier = parts
+    else:
+        fail("Invalid fully qualified name for artifact: %s" % fully_qualified_name)
+
+    return struct(
+        fully_qualified_name = fully_qualified_name,
+        group_id = group_id,
+        artifact_id = artifact_id,
+        packaging = packaging,
+        classifier = classifier,
+        version = version,
+    )
+
+def _format_deps(attr, deps):
+    formatted_deps = ""
+    if deps:
+        if len(deps) == 1:
+            formatted_deps += "%s = [\'%s\']," % (attr, deps[0])
+        else:
+            formatted_deps += "%s = [\n" % attr
+            for dep in deps:
+                formatted_deps += "        \'%s\',\n" % dep
+            formatted_deps += "    ],"
+    return formatted_deps
+
+def _generate_build_files(ctx, binjar, srcjar):
+    header = "# DO NOT EDIT: automatically generated BUILD file for maven_jar rule %s" % ctx.name
+    srcjar_attr = ""
+    if srcjar:
+        srcjar_attr = 'srcjar = "%s",' % srcjar
+    contents = """
+{header}
+package(default_visibility = ['//visibility:public'])
+java_import(
+    name = 'jar',
+    jars = ['{binjar}'],
+    {srcjar_attr}
+    {deps}
+    {exports}
+)
+java_import(
+    name = 'neverlink',
+    jars = ['{binjar}'],
+    neverlink = 1,
+    {deps}
+    {exports}
+)
+\n""".format(
+        srcjar_attr = srcjar_attr,
+        header = header,
+        binjar = binjar,
+        deps = _format_deps("deps", ctx.attr.deps),
+        exports = _format_deps("exports", ctx.attr.exports),
+    )
+    if srcjar:
+        contents += """
+java_import(
+    name = 'src',
+    jars = ['{srcjar}'],
+)
+""".format(srcjar = srcjar)
+    ctx.file("%s/BUILD" % ctx.path("jar"), contents, False)
+
+    # Compatibility layer for java_import_external from rules_closure
+    contents = """
+{header}
+package(default_visibility = ['//visibility:public'])
+
+alias(
+    name = "{rule_name}",
+    actual = "@{rule_name}//jar",
+)
+\n""".format(rule_name = ctx.name, header = header)
+    ctx.file("BUILD", contents, False)
+
+def _maven_jar_impl(ctx):
+    """rule to download a Maven archive."""
+    coordinates = _create_coordinates(ctx.attr.artifact)
+
+    name = ctx.name
+    sha1 = ctx.attr.sha1
+
+    parts = ctx.attr.artifact.split(":")
+
+    # TODO(davido): Only releases for now, implement handling snapshots
+    jar, url = _maven_release(ctx, parts)
+
+    binjar = jar + ".jar"
+    binjar_path = ctx.path("/".join(["jar", binjar]))
+    binurl = url + ".jar"
+
+    python = ctx.which("python")
+    script = ctx.path(ctx.attr._download_script)
+
+    args = [python, script, "-o", binjar_path, "-u", binurl]
+    if ctx.attr.sha1:
+        args.extend(["-v", sha1])
+    if ctx.attr.unsign:
+        args.append("--unsign")
+    for x in ctx.attr.exclude:
+        args.extend(["-x", x])
+
+    out = ctx.execute(args)
+
+    if out.return_code:
+        fail("failed %s: %s" % (args, out.stderr))
+
+    srcjar = None
+    if ctx.attr.src_sha1 or ctx.attr.attach_source:
+        srcjar = jar + "-src.jar"
+        srcurl = url + "-sources.jar"
+        srcjar_path = ctx.path("jar/" + srcjar)
+        args = [python, script, "-o", srcjar_path, "-u", srcurl]
+        if ctx.attr.src_sha1:
+            args.extend(["-v", ctx.attr.src_sha1])
+        out = ctx.execute(args)
+        if out.return_code:
+            fail("failed %s: %s" % (args, out.stderr))
+
+    _generate_build_files(ctx, binjar, srcjar)
+
+maven_jar = repository_rule(
+    attrs = {
+        "artifact": attr.string(mandatory = True),
+        "attach_source": attr.bool(default = True),
+        "exclude": attr.string_list(),
+        "repository": attr.string(default = MAVEN_CENTRAL),
+        "sha1": attr.string(),
+        "src_sha1": attr.string(),
+        "unsign": attr.bool(default = False),
+        "exports": attr.string_list(),
+        "deps": attr.string_list(),
+        "_download_script": attr.label(default = Label("//tools:download_file.py")),
+    },
+    local = True,
+    implementation = _maven_jar_impl,
+)
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
new file mode 100644
index 0000000..140f8e3
--- /dev/null
+++ b/tools/bzl/pkg_war.bzl
@@ -0,0 +1,160 @@
+# 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.
+
+# War packaging.
+
+jar_filetype = [".jar"]
+
+LIBS = [
+    "//gerrit-war:init",
+    "//gerrit-war:log4j-config",
+    "//gerrit-war:version",
+    "//lib:postgresql",
+    "//lib/bouncycastle:bcpkix",
+    "//lib/bouncycastle:bcprov",
+    "//lib/bouncycastle:bcpg",
+    "//lib/log:impl-log4j",
+]
+
+PGMLIBS = [
+    "//gerrit-pgm:pgm",
+]
+
+def _add_context(in_file, output):
+    input_path = in_file.path
+    return [
+        "unzip -qd %s %s" % (output, input_path),
+    ]
+
+def _add_file(in_file, output):
+    output_path = output
+    input_path = in_file.path
+    short_path = in_file.short_path
+    n = in_file.basename
+
+    if short_path.startswith("gerrit-"):
+        n = short_path.split("/")[0] + "-" + n
+
+    output_path += n
+    return [
+        "test -L %s || ln -s $(pwd)/%s %s" % (output_path, input_path, output_path),
+    ]
+
+def _make_war(input_dir, output):
+    return "(%s)" % " && ".join([
+        "root=$(pwd)",
+        "TZ=UTC",
+        "export TZ",
+        "cd %s" % input_dir,
+        "find . -exec touch -t 198001010000 '{}' ';' 2> /dev/null",
+        "zip -X -9qr ${root}/%s ." % (output.path),
+    ])
+
+def _war_impl(ctx):
+    war = ctx.outputs.war
+    build_output = war.path + ".build_output"
+    inputs = []
+
+    # Create war layout
+    cmd = [
+        "set -e;rm -rf " + build_output,
+        "mkdir -p " + build_output,
+        "mkdir -p %s/WEB-INF/lib" % build_output,
+        "mkdir -p %s/WEB-INF/pgm-lib" % build_output,
+    ]
+
+    # Add lib
+    transitive_libs = []
+    for j in ctx.attr.libs:
+        if JavaInfo in j:
+            transitive_libs.append(j[JavaInfo].transitive_runtime_deps)
+        elif hasattr(j, "files"):
+            transitive_libs.append(j.files)
+
+    transitive_lib_deps = depset(transitive = transitive_libs)
+    for dep in transitive_lib_deps.to_list():
+        cmd += _add_file(dep, build_output + "/WEB-INF/lib/")
+        inputs.append(dep)
+
+    # Add pgm lib
+    transitive_pgmlibs = []
+    for j in ctx.attr.pgmlibs:
+        transitive_pgmlibs.append(j[JavaInfo].transitive_runtime_deps)
+
+    transitive_pgmlib_deps = depset(transitive = transitive_pgmlibs)
+    for dep in transitive_pgmlib_deps.to_list():
+        if dep not in inputs:
+            cmd += _add_file(dep, build_output + "/WEB-INF/pgm-lib/")
+            inputs.append(dep)
+
+    # Add context
+    transitive_context_libs = []
+    if ctx.attr.context:
+        for jar in ctx.attr.context:
+            if JavaInfo in jar:
+                transitive_context_libs.append(jar[JavaInfo].transitive_runtime_deps)
+            elif hasattr(jar, "files"):
+                transitive_context_libs.append(jar.files)
+
+    transitive_context_deps = depset(transitive = transitive_context_libs)
+    for dep in transitive_context_deps.to_list():
+        cmd += _add_context(dep, build_output)
+        inputs.append(dep)
+
+    # Add zip war
+    cmd.append(_make_war(build_output, war))
+
+    ctx.actions.run_shell(
+        inputs = inputs,
+        outputs = [war],
+        mnemonic = "WAR",
+        command = "\n".join(cmd),
+        use_default_shell_env = True,
+    )
+
+# context: go to the root directory
+# libs: go to the WEB-INF/lib directory
+# pgmlibs: go to the WEB-INF/pgm-lib directory
+_pkg_war = rule(
+    attrs = {
+        "context": attr.label_list(allow_files = True),
+        "libs": attr.label_list(allow_files = jar_filetype),
+        "pgmlibs": attr.label_list(allow_files = False),
+    },
+    outputs = {"war": "%{name}.war"},
+    implementation = _war_impl,
+)
+
+def pkg_war(name, ui = "ui_optdbg", context = [], doc = False, **kwargs):
+    doc_ctx = []
+    doc_lib = []
+    ui_deps = []
+    if ui == "polygerrit" or ui == "ui_optdbg" or ui == "ui_optdbg_r":
+        ui_deps.append("//polygerrit-ui/app:polygerrit_ui")
+    if ui and ui != "polygerrit":
+        ui_deps.append("//gerrit-gwtui:%s" % ui)
+    if doc:
+        doc_ctx.append("//Documentation:html")
+        doc_lib.append("//Documentation:index")
+
+    _pkg_war(
+        name = name,
+        libs = LIBS + doc_lib,
+        pgmlibs = PGMLIBS,
+        context = doc_ctx + context + ui_deps + [
+            "//gerrit-main:main_bin_deploy.jar",
+            "//gerrit-war:webapp_assets",
+        ],
+        **kwargs
+    )
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
new file mode 100644
index 0000000..b8a01d2
--- /dev/null
+++ b/tools/bzl/plugin.bzl
@@ -0,0 +1,100 @@
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load(
+    "//tools/bzl:gwt.bzl",
+    "GWT_COMPILER_ARGS",
+    "GWT_JVM_ARGS",
+    "GWT_PLUGIN_DEPS_NEVERLINK",
+    "GWT_TRANSITIVE_DEPS",
+    "gwt_binary",
+    _gwt_plugin_deps = "GWT_PLUGIN_DEPS",
+)
+
+GWT_PLUGIN_DEPS = _gwt_plugin_deps
+PLUGIN_DEPS = ["//gerrit-plugin-api:lib"]
+PLUGIN_DEPS_NEVERLINK = ["//gerrit-plugin-api:lib-neverlink"]
+
+PLUGIN_TEST_DEPS = [
+    "//gerrit-acceptance-framework:lib",
+    "//lib/bouncycastle:bcpg",
+    "//lib/bouncycastle:bcpkix",
+    "//lib/bouncycastle:bcprov",
+]
+
+def gerrit_plugin(
+        name,
+        deps = [],
+        provided_deps = [],
+        srcs = [],
+        gwt_module = [],
+        resources = [],
+        manifest_entries = [],
+        target_suffix = "",
+        **kwargs):
+    java_library(
+        name = name + "__plugin",
+        srcs = srcs,
+        resources = resources,
+        deps = provided_deps + deps + GWT_PLUGIN_DEPS_NEVERLINK + PLUGIN_DEPS_NEVERLINK,
+        visibility = ["//visibility:public"],
+        **kwargs
+    )
+
+    static_jars = []
+    if gwt_module:
+        static_jars = [":%s-static" % name]
+    java_binary(
+        name = "%s__non_stamped" % name,
+        deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
+        main_class = "Dummy",
+        runtime_deps = [
+            ":%s__plugin" % name,
+        ] + static_jars,
+        visibility = ["//visibility:public"],
+        **kwargs
+    )
+
+    if gwt_module:
+        java_library(
+            name = name + "__gwt_module",
+            resources = depset(srcs + resources).to_list(),
+            runtime_deps = deps + GWT_PLUGIN_DEPS,
+            visibility = ["//visibility:public"],
+            **kwargs
+        )
+        genrule2(
+            name = "%s-static" % name,
+            cmd = " && ".join([
+                "mkdir -p $$TMP/static",
+                "unzip -qd $$TMP/static $(location %s__gwt_application)" % name,
+                "cd $$TMP",
+                "zip -qr $$ROOT/$@ .",
+            ]),
+            tools = [":%s__gwt_application" % name],
+            outs = ["%s-static.jar" % name],
+        )
+        gwt_binary(
+            name = name + "__gwt_application",
+            module = [gwt_module],
+            deps = GWT_PLUGIN_DEPS + GWT_TRANSITIVE_DEPS + ["//lib/gwt:dev"],
+            module_deps = [":%s__gwt_module" % name],
+            compiler_args = GWT_COMPILER_ARGS,
+            jvm_args = GWT_JVM_ARGS,
+        )
+
+    # TODO(davido): Remove manual merge of manifest file when this feature
+    # request is implemented: https://github.com/bazelbuild/bazel/issues/2009
+    genrule2(
+        name = name + target_suffix,
+        stamp = 1,
+        srcs = ["%s__non_stamped_deploy.jar" % name],
+        cmd = " && ".join([
+            "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % name.upper(),
+            "cd $$TMP",
+            "unzip -q $$ROOT/$<",
+            "echo \"Implementation-Version: $$GEN_VERSION\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF",
+            "zip -qr $$ROOT/$@ .",
+        ]),
+        outs = ["%s%s.jar" % (name, target_suffix)],
+        visibility = ["//visibility:public"],
+    )
diff --git a/tools/bzl/plugins.bzl b/tools/bzl/plugins.bzl
new file mode 100644
index 0000000..149dbb5
--- /dev/null
+++ b/tools/bzl/plugins.bzl
@@ -0,0 +1,16 @@
+CORE_PLUGINS = [
+    "commit-message-length-validator",
+    "download-commands",
+    "hooks",
+    "replication",
+    "reviewnotes",
+    "singleusergroup",
+]
+
+CUSTOM_PLUGINS = [
+    # Add custom core plugins here
+]
+
+CUSTOM_PLUGINS_TEST_DEPS = [
+    # Add custom core plugins with tests deps here
+]
diff --git a/tools/bzl/test_empty.sh b/tools/bzl/test_empty.sh
new file mode 100755
index 0000000..0d4398d
--- /dev/null
+++ b/tools/bzl/test_empty.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+if test -s $1
+then
+    echo "$1 not empty:"
+    cat $1
+    exit 1
+fi
diff --git a/tools/bzl/test_license.sh b/tools/bzl/test_license.sh
new file mode 100755
index 0000000..5461b41
--- /dev/null
+++ b/tools/bzl/test_license.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+if test -s $1
+then
+  echo "$1 not empty:"
+  cat "$1"
+  exit 1
+fi
diff --git a/tools/default.defs b/tools/default.defs
deleted file mode 100644
index 191dfe5..0000000
--- a/tools/default.defs
+++ /dev/null
@@ -1,225 +0,0 @@
-# Copyright (C) 2013 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.
-
-# Rule definitions loaded by default into every BUCK file.
-
-include_defs('//lib/auto/auto_value.defs')
-include_defs('//tools/gwt-constants.defs')
-include_defs('//tools/java_doc.defs')
-include_defs('//tools/java_sources.defs')
-include_defs('//tools/git.defs')
-import copy
-import traceback
-import os
-from multiprocessing import cpu_count
-
-# Set defaults on java rules:
-#  - Add AutoValue annotation processing support.
-#  - Treat source files as UTF-8.
-
-_buck_java_library = java_library
-def java_library(*args, **kwargs):
-  _munge_args(kwargs)
-  _buck_java_library(*args, **kwargs)
-
-_buck_java_test = java_test
-def java_test(*args, **kwargs):
-  _munge_args(kwargs)
-  _buck_java_test(*args, **kwargs)
-
-
-# Munge kwargs to set Gerrit-specific defaults.
-def _munge_args(kwargs):
-  _set_auto_value(kwargs)
-  _set_extra_arguments(kwargs)
-
-def _set_extra_arguments(kwargs):
-  ext = 'extra_arguments'
-  if ext not in kwargs:
-    kwargs[ext] = []
-  extra_args = kwargs[ext]
-
-  for arg in extra_args:
-    if arg.startswith('-encoding'):
-      return
-
-  extra_args.extend(['-encoding', 'UTF-8'])
-
-def _set_auto_value(kwargs):
-  apk = 'annotation_processors'
-  if apk not in kwargs:
-    kwargs[apk] = []
-  aps = kwargs.get(apk, [])
-
-  apdk = 'annotation_processor_deps'
-  if apdk not in kwargs:
-    kwargs[apdk] = []
-  apds = kwargs.get(apdk, [])
-
-  all_deps = kwargs.get('deps', []) + kwargs.get('exported_deps', [])
-  if AUTO_VALUE_DEP in all_deps:
-    aps.extend(AUTO_VALUE_PROCESSORS)
-    apds.extend(AUTO_VALUE_PROCESSOR_DEPS)
-
-
-# Add 'license' argument to genrule.
-_buck_genrule = genrule
-def genrule(*args, **kwargs):
-  license = kwargs.pop('license', None)
-  if license:
-    license = '//lib:LICENSE-%s' % license
-    # genrule has no deps attribute, but locations listed in the command show
-    # up as deps of the target with buck audit.
-    kwargs['cmd'] = 'true $(location %s); %s' % (license, kwargs['cmd'])
-  _buck_genrule(*args, **kwargs)
-
-
-def genantlr(
-    name,
-    srcs,
-    out):
-  genrule(
-    name = name,
-    srcs = srcs,
-    cmd = '$(exe //lib/antlr:antlr-tool) -o $TMP $SRCS;' +
-      'cd $TMP;' +
-      'zip -qr $OUT .',
-    out = out,
-  )
-
-def gwt_module(gwt_xml=None, **kwargs):
-  kw = copy.deepcopy(kwargs)
-  if 'resources' not in kw:
-    kw['resources'] = []
-  if gwt_xml:
-    kw['resources'] += [gwt_xml]
-  if 'srcs' in kw:
-    kw['resources'] += kw['srcs']
-
-  # Buck does not accept duplicate resources. Callers may have
-  # included gwt_xml or srcs as part of resources, so de-dupe.
-  kw['resources'] = list(set(kw['resources']))
-
-  java_library(**kw)
-
-def gerrit_extension(
-    name,
-    deps = [],
-    provided_deps = [],
-    srcs = [],
-    resources = [],
-    manifest_file = None,
-    manifest_entries = [],
-    visibility = ['PUBLIC']):
-  gerrit_plugin(
-    name = name,
-    deps = deps,
-    provided_deps = provided_deps,
-    srcs = srcs,
-    resources = resources,
-    manifest_file = manifest_file,
-    manifest_entries = manifest_entries,
-    type = 'extension',
-    visibility = visibility,
-  )
-
-def gerrit_plugin(
-    name,
-    deps = [],
-    provided_deps = [],
-    srcs = [],
-    resources = [],
-    gwt_module = None,
-    manifest_file = None,
-    manifest_entries = [],
-    type = 'plugin',
-    visibility = ['PUBLIC'],
-    target_suffix = ''):
-  tb = traceback.extract_stack()
-  calling_BUCK_file = tb[-2][0]
-  calling_BUCK_dir = os.path.abspath(os.path.dirname(calling_BUCK_file))
-  mf_cmd = 'v=%s;' % git_describe(calling_BUCK_dir)
-  if manifest_file:
-    mf_src = [manifest_file]
-    mf_cmd += 'sed "s:@VERSION@:$v:g" $SRCS >$OUT'
-  else:
-    mf_src = []
-    mf_cmd += 'echo "Manifest-Version: 1.0" >$OUT;'
-    mf_cmd += 'echo "Gerrit-ApiType: %s" >>$OUT;' % type
-    mf_cmd += 'echo "Implementation-Version: $v" >>$OUT;'
-    mf_cmd += 'echo "Implementation-Vendor: Gerrit Code Review" >>$OUT'
-    for line in manifest_entries:
-      line = line.replace('$', '\$')
-      mf_cmd += ';echo "%s" >> $OUT' % line
-  genrule(
-    name = name + '__manifest',
-    cmd = mf_cmd,
-    srcs = mf_src,
-    out = 'MANIFEST.MF',
-  )
-  static_jars = []
-  if gwt_module:
-    static_jars = [':%s-static-jar' % name]
-  java_library(
-    name = name + '__plugin',
-    srcs = srcs,
-    resources = resources,
-    deps = deps,
-    provided_deps = ['//gerrit-%s-api:lib' % type] +
-      provided_deps +
-      GWT_PLUGIN_DEPS,
-    visibility = ['PUBLIC'],
-  )
-  if gwt_module:
-    java_library(
-      name = name + '__gwt_module',
-      srcs = [],
-      resources = list(set(srcs + resources)),
-      deps = deps,
-      provided_deps = GWT_PLUGIN_DEPS,
-      visibility = ['PUBLIC'],
-    )
-    prebuilt_jar(
-      name = '%s-static-jar' % name,
-      binary_jar = ':%s-static' % name,
-    )
-    genrule(
-      name = '%s-static' % name,
-      cmd = 'mkdir -p $TMP/static' +
-        ';unzip -qd $TMP/static $(location %s)' %
-        ':%s__gwt_application' % name +
-        ';cd $TMP' +
-        ';zip -qr $OUT .',
-      out = '%s-static.zip' % name,
-    )
-    gwt_binary(
-      name = name + '__gwt_application',
-      modules = [gwt_module],
-      deps = GWT_PLUGIN_DEPS + GWT_TRANSITIVE_DEPS + ['//lib/gwt:dev'],
-      module_deps = [':%s__gwt_module' % name],
-      local_workers = cpu_count(),
-      strict = True,
-      experimental_args = GWT_COMPILER_ARGS,
-      vm_args = GWT_JVM_ARGS,
-    )
-
-  java_binary(
-    name = name + target_suffix,
-    manifest_file = ':%s__manifest' % name,
-    merge_manifests = False,
-    deps = [
-      ':%s__plugin' % name,
-    ] + static_jars,
-    visibility = visibility,
-  )
diff --git a/tools/download_all.py b/tools/download_all.py
deleted file mode 100755
index 58316ca..0000000
--- a/tools/download_all.py
+++ /dev/null
@@ -1,44 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2013 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.
-
-from optparse import OptionParser
-import re
-from subprocess import check_call, CalledProcessError, Popen, PIPE
-
-MAIN = ['//tools/eclipse:classpath']
-PAT = re.compile(r'"(//.*?)" -> "//tools:download_file"')
-
-opts = OptionParser()
-opts.add_option('--src', action='store_true')
-args, _ = opts.parse_args()
-
-targets = set()
-
-p = Popen(['buck', 'audit', 'classpath', '--dot'] + MAIN, stdout = PIPE)
-for line in p.stdout:
-  m = PAT.search(line)
-  if m:
-    n = m.group(1)
-    if args.src and n.endswith('__download_bin'):
-      n = n[:-13] + 'src'
-    targets.add(n)
-r = p.wait()
-if r != 0:
-  exit(r)
-
-try:
-  check_call(['buck', 'build'] + sorted(targets))
-except CalledProcessError as err:
-  exit(1)
diff --git a/tools/download_file.py b/tools/download_file.py
index bd67b50..26671f0 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -25,11 +25,7 @@
 from zipfile import ZipFile, BadZipfile, LargeZipFile
 
 GERRIT_HOME = path.expanduser('~/.gerritcodereview')
-CACHE_DIR = path.join(GERRIT_HOME, 'buck-cache', 'downloaded-artifacts')
-# LEGACY_CACHE_DIR is only used to allow existing workspaces to move already
-# downloaded files to the new cache directory.
-# Please remove after 3 months (2015-10-07).
-LEGACY_CACHE_DIR = path.join(GERRIT_HOME, 'buck-cache')
+CACHE_DIR = path.join(GERRIT_HOME, 'bazel-cache', 'downloaded-artifacts')
 LOCAL_PROPERTIES = 'local.properties'
 
 
@@ -78,16 +74,6 @@
   name = '%s-%s' % (path.basename(args.o), h)
   return path.join(CACHE_DIR, name)
 
-# Please remove after 3 months (2015-10-07). See LEGACY_CACHE_DIR above.
-def legacy_cache_entry(args):
-  if args.v:
-    h = args.v
-  else:
-    h = sha1(args.u.encode('utf-8')).hexdigest()
-  name = '%s-%s' % (path.basename(args.o), h)
-  return path.join(LEGACY_CACHE_DIR, name)
-
-
 opts = OptionParser()
 opts.add_option('-o', help='local output file')
 opts.add_option('-u', help='URL to download')
@@ -98,26 +84,15 @@
 args, _ = opts.parse_args()
 
 root_dir = args.o
-while root_dir:
+while root_dir and path.dirname(root_dir) != root_dir:
   root_dir, n = path.split(root_dir)
-  if n == 'buck-out':
+  if n == 'WORKSPACE':
     break
 
 redirects = download_properties(root_dir)
 cache_ent = cache_entry(args)
-legacy_cache_ent = legacy_cache_entry(args)
 src_url = resolve_url(args.u, redirects)
 
-# Please remove after 3 months (2015-10-07). See LEGACY_CACHE_DIR above.
-if not path.exists(cache_ent) and path.exists(legacy_cache_ent):
-  try:
-    safe_mkdirs(path.dirname(cache_ent))
-  except OSError as err:
-    print('error creating directory %s: %s' %
-          (path.dirname(cache_ent), err), file=stderr)
-    exit(1)
-  shutil.move(legacy_cache_ent, cache_ent)
-
 if not path.exists(cache_ent):
   try:
     safe_mkdirs(path.dirname(cache_ent))
@@ -128,7 +103,7 @@
 
   print('Download %s' % src_url, file=stderr)
   try:
-    check_call(['curl', '--proxy-anyauth', '-ksfo', cache_ent, src_url])
+    check_call(['curl', '--proxy-anyauth', '-ksSfLo', cache_ent, src_url])
   except OSError as err:
     print('could not invoke curl: %s\nis curl installed?' % err, file=stderr)
     exit(1)
diff --git a/tools/eclipse/BUCK b/tools/eclipse/BUCK
deleted file mode 100644
index a8b3f01..0000000
--- a/tools/eclipse/BUCK
+++ /dev/null
@@ -1,37 +0,0 @@
-include_defs('//tools/build.defs')
-
-java_library(
-  name = 'classpath',
-  deps = LIBS + PGMLIBS + [
-    '//gerrit-acceptance-tests:lib',
-    '//gerrit-gpg:gpg_tests',
-    '//gerrit-gwtdebug:gwtdebug',
-    '//gerrit-gwtui:ui_module',
-    '//gerrit-gwtui:ui_tests',
-    '//gerrit-httpd:httpd_tests',
-    '//gerrit-main:main_lib',
-    '//gerrit-patch-jgit:jgit_patch_tests',
-    '//gerrit-plugin-gwtui:gwtui-api-lib',
-    '//gerrit-reviewdb:client_tests',
-    '//gerrit-server:server',
-    '//gerrit-server:server_tests',
-    '//lib/asciidoctor:asciidoc_lib',
-    '//lib/asciidoctor:doc_indexer_lib',
-    '//lib/auto:auto-value',
-    '//lib/bouncycastle:bcprov',
-    '//lib/bouncycastle:bcpg',
-    '//lib/bouncycastle:bcpkix',
-    '//lib/gwt:ant',
-    '//lib/gwt:colt',
-    '//lib/gwt:javax-validation',
-    '//lib/gwt:javax-validation_src',
-    '//lib/gwt:jsinterop-annotations',
-    '//lib/gwt:jsinterop-annotations_src',
-    '//lib/gwt:tapestry',
-    '//lib/gwt:w3c-css-sac',
-    '//lib/jetty:servlets',
-    '//lib/prolog:compiler_lib',
-    '//polygerrit-ui:polygerrit_components',
-    '//Documentation:index_lib',
-  ] + scan_plugins(),
-)
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
new file mode 100644
index 0000000..caa0886
--- /dev/null
+++ b/tools/eclipse/BUILD
@@ -0,0 +1,66 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:classpath.bzl", "classpath_collector")
+load("//tools/bzl:pkg_war.bzl", "LIBS", "PGMLIBS")
+load(
+    "//tools/bzl:plugins.bzl",
+    "CORE_PLUGINS",
+    "CUSTOM_PLUGINS",
+    "CUSTOM_PLUGINS_TEST_DEPS",
+)
+
+TEST_DEPS = [
+    "//gerrit-gpg:gpg_tests",
+    "//gerrit-gwtui:ui_tests",
+    "//gerrit-httpd:httpd_tests",
+    "//gerrit-patch-jgit:jgit_patch_tests",
+    "//gerrit-reviewdb:client_tests",
+    "//gerrit-server:server_tests",
+]
+
+DEPS = [
+    "//gerrit-acceptance-tests:lib",
+    "//gerrit-gwtdebug:gwtdebug",
+    "//gerrit-gwtui:ui_module",
+    "//gerrit-main:main_lib",
+    "//gerrit-plugin-gwtui:gwtui-api-lib",
+    "//gerrit-server:server",
+    "//lib/asciidoctor:asciidoc_lib",
+    "//lib/asciidoctor:doc_indexer_lib",
+    "//lib/auto:auto-value",
+    "//lib/gwt:ant",
+    "//lib/gwt:colt",
+    "//lib/gwt:javax-validation",
+    "//lib/gwt:javax-validation_src",
+    "//lib/gwt:jsinterop-annotations",
+    "//lib/gwt:jsinterop-annotations_src",
+    "//lib/gwt:tapestry",
+    "//lib/gwt:w3c-css-sac",
+    "//lib/jetty:servlets",
+    "//lib/prolog:compiler-lib",
+    # TODO(davido): I do not understand why it must be on the Eclipse classpath
+    #'//Documentation:index',
+]
+
+java_library(
+    name = "classpath",
+    testonly = 1,
+    runtime_deps = LIBS + PGMLIBS + DEPS,
+)
+
+classpath_collector(
+    name = "main_classpath_collect",
+    testonly = 1,
+    deps = LIBS + PGMLIBS + DEPS + TEST_DEPS +
+           ["//plugins/%s:%s__plugin" % (n, n) for n in CORE_PLUGINS + CUSTOM_PLUGINS] +
+           ["//plugins/%s:%s__plugin_test_deps" % (n, n) for n in CUSTOM_PLUGINS_TEST_DEPS],
+)
+
+classpath_collector(
+    name = "gwt_classpath_collect",
+    deps = ["//gerrit-gwtui:ui_module"],
+)
+
+classpath_collector(
+    name = "autovalue_classpath_collect",
+    deps = ["//lib/auto:auto-value"],
+)
diff --git a/tools/eclipse/gerrit_daemon.launch b/tools/eclipse/gerrit_daemon.launch
index cbc6204..9495884 100644
--- a/tools/eclipse/gerrit_daemon.launch
+++ b/tools/eclipse/gerrit_daemon.launch
@@ -13,5 +13,5 @@
 <stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="Main"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="daemon --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="gerrit"/>
-<stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Dgerrit.plugin-classes=${resource_loc:/gerrit/buck-out}/eclipse/plugins"/>
+<stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Dgerrit.plugin-classes=${resource_loc:/gerrit/eclipse-out}/plugins"/>
 </launchConfiguration>
diff --git a/tools/eclipse/gerrit_gwt_debug.launch b/tools/eclipse/gerrit_gwt_debug.launch
index b2ab320..9f2bf2b 100644
--- a/tools/eclipse/gerrit_gwt_debug.launch
+++ b/tools/eclipse/gerrit_gwt_debug.launch
@@ -16,7 +16,7 @@
 </listAttribute>
 <booleanAttribute key="org.eclipse.jdt.launching.DEFAULT_CLASSPATH" value="false"/>
 <stringAttribute key="org.eclipse.jdt.launching.MAIN_TYPE" value="com.google.gerrit.gwtdebug.GerritGwtDebugLauncher"/>
-<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-strict -noprecompile -src ${resource_loc:/gerrit} -workDir ${resource_loc:/gerrit}/buck-out/gen/gerrit-gwtui com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
+<stringAttribute key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="-strict -noprecompile -src ${resource_loc:/gerrit} -workDir ${resource_loc:/gerrit}/.gwt_work_dir com.google.gerrit.GerritGwtUI -src ${resource_loc:/gerrit}/gerrit-plugin-gwtui/src/main/java -- --console-log --show-stack-trace -d ${resource_loc:/gerrit}/../gerrit_testsite"/>
 <stringAttribute key="org.eclipse.jdt.launching.PROJECT_ATTR" value="gerrit"/>
 <stringAttribute key="org.eclipse.jdt.launching.VM_ARGUMENTS" value="-Xmx1024M&#10;-XX:MaxPermSize=256M&#10;-Dgerrit.disable-gwtui-recompile=true"/>
 </launchConfiguration>
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 46f5680..5fd6126 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-# Copyright (C) 2013 The Android Open Source Project
+# Copyright (C) 2016 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -12,58 +12,98 @@
 # 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.
-#
-# TODO(sop): Remove hack after Buck supports Eclipse
 
 from __future__ import print_function
-from optparse import OptionParser
-from os import path
-from subprocess import Popen, PIPE, CalledProcessError, check_call
-from xml.dom import minidom
+import argparse
+import os
+import subprocess
+import xml.dom.minidom
 import re
 import sys
 
-MAIN = ['//tools/eclipse:classpath']
-GWT = ['//gerrit-gwtui:ui_module']
+MAIN = '//tools/eclipse:classpath'
+GWT = '//gerrit-gwtui:ui_module'
+AUTO = '//lib/auto:auto-value'
 JRE = '/'.join([
   'org.eclipse.jdt.launching.JRE_CONTAINER',
   'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
-  'JavaSE-1.7',
+  'JavaSE-1.8',
 ])
+# Map of targets to corresponding classpath collector rules
+cp_targets = {
+  AUTO: '//tools/eclipse:autovalue_classpath_collect',
+  GWT: '//tools/eclipse:gwt_classpath_collect',
+  MAIN: '//tools/eclipse:main_classpath_collect',
+}
 
-ROOT = path.abspath(__file__)
-while not path.exists(path.join(ROOT, '.buckconfig')):
-  ROOT = path.dirname(ROOT)
+ROOT = os.path.abspath(__file__)
+while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')):
+  ROOT = os.path.dirname(ROOT)
 
-opts = OptionParser()
-opts.add_option('--src', action='store_true',
-                help='(deprecated) attach sources')
-opts.add_option('--no-src', dest='no_src', action='store_true',
-                help='do not attach sources')
-opts.add_option('--plugins', help='create eclipse projects for plugins',
-                action='store_true')
-opts.add_option('--name', help='name of the generated project',
-                action='store', default='gerrit', dest='project_name')
-args, _ = opts.parse_args()
+opts = argparse.ArgumentParser("Create Eclipse Project")
+opts.add_argument('--plugins', help='create eclipse projects for plugins',
+                  action='store_true')
+opts.add_argument('--name', help='name of the generated project',
+                  action='store', default='gerrit', dest='project_name')
+opts.add_argument('--bazel',
+                  help=('name of the bazel executable. Defaults to using'
+                        ' bazelisk if found, or bazel if bazelisk is not'
+                        ' found.'),
+                  action='store', default=None, dest='bazel_exe')
 
-def _query_classpath(targets):
+def find_bazel():
+  if args.bazel_exe:
+    try:
+      return subprocess.check_output(
+        ['which', args.bazel_exe]).strip().decode('UTF-8')
+    except subprocess.CalledProcessError:
+      print('Bazel command: %s not found' % args.bazel_exe, file=sys.stderr)
+      sys.exit(1)
+  try:
+    return subprocess.check_output(
+      ['which', 'bazelisk']).strip().decode('UTF-8')
+  except subprocess.CalledProcessError:
+    try:
+      return subprocess.check_output(
+        ['which', 'bazel']).strip().decode('UTF-8')
+    except subprocess.CalledProcessError:
+      print("Neither bazelisk nor bazel found. Please see"
+            " Documentation/dev-bazel for instructions on installing"
+            " one of them.")
+      sys.exit(1)
+
+args = opts.parse_args()
+bazel_exe = find_bazel()
+
+def retrieve_ext_location():
+  return subprocess.check_output(
+      [bazel_exe, 'info', 'output_base']).strip()
+
+def gen_bazel_path():
+  bazel = subprocess.check_output(
+      ['which', bazel_exe]).strip().decode('UTF-8')
+  with open(os.path.join(ROOT, ".bazel_path"), 'w') as fd:
+    fd.write("bazel=%s\n" % bazel)
+    fd.write("PATH=%s\n" % os.environ["PATH"])
+
+def _query_classpath(target):
   deps = []
-  p = Popen(['buck', 'audit', 'classpath'] + targets, stdout=PIPE)
-  for line in p.stdout:
-    deps.append(line.strip())
-  s = p.wait()
-  if s != 0:
-    exit(s)
+  t = cp_targets[target]
+  try:
+    subprocess.check_call([bazel_exe, 'build', t])
+  except subprocess.CalledProcessError:
+    exit(1)
+  name = 'bazel-bin/tools/eclipse/' + t.split(':')[1] + '.runtime_classpath'
+  deps = [line.rstrip('\n') for line in open(name)]
   return deps
 
-
 def gen_project(name='gerrit', root=ROOT):
-  p = path.join(root, '.project')
+  p = os.path.join(root, '.project')
   with open(p, 'w') as fd:
     print("""\
 <?xml version="1.0" encoding="UTF-8"?>
 <projectDescription>
-  <name>""" + name + """</name>
+  <name>%(name)s</name>
   <buildSpec>
     <buildCommand>
       <name>org.eclipse.jdt.core.javabuilder</name>
@@ -73,34 +113,38 @@
     <nature>org.eclipse.jdt.core.javanature</nature>
   </natures>
 </projectDescription>\
-""", file=fd)
+    """ % {"name": name}, file=fd)
 
 def gen_plugin_classpath(root):
-  p = path.join(root, '.classpath')
+  p = os.path.join(root, '.classpath')
   with open(p, 'w') as fd:
-    if path.exists(path.join(root, 'src', 'test', 'java')):
+    if os.path.exists(os.path.join(root, 'src', 'test', 'java')):
       testpath = """
-  <classpathentry kind="src" path="src/test/java"\
+  <classpathentry excluding="**/BUILD" kind="src" path="src/test/java"\
  out="eclipse-out/test"/>"""
     else:
       testpath = ""
     print("""\
 <?xml version="1.0" encoding="UTF-8"?>
 <classpath>
-  <classpathentry kind="src" path="src/main/java"/>%(testpath)s
+  <classpathentry excluding="**/BUILD" kind="src" path="src/main/java"/>%(testpath)s
   <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
   <classpathentry combineaccessrules="false" kind="src" path="/gerrit"/>
   <classpathentry kind="output" path="eclipse-out/classes"/>
 </classpath>""" % {"testpath": testpath}, file=fd)
 
-def gen_classpath():
+def gen_classpath(ext):
   def make_classpath():
-    impl = minidom.getDOMImplementation()
+    impl = xml.dom.minidom.getDOMImplementation()
     return impl.createDocument(None, 'classpath', None)
 
   def classpathentry(kind, path, src=None, out=None, exported=None):
     e = doc.createElement('classpathentry')
     e.setAttribute('kind', kind)
+    # Excluding the BUILD file, to avoid the Eclipse warnings:
+    # "The resource is a duplicate of ..."
+    if kind == 'src':
+      e.setAttribute('excluding', '**/BUILD')
     e.setAttribute('path', path)
     if src:
       e.setAttribute('sourcepath', src)
@@ -118,24 +162,35 @@
   plugins = set()
 
   # Classpath entries are absolute for cross-cell support
-  java_library = re.compile('.*/buck-out/gen/(.*)/lib__[^/]+__output/[^/]+[.]jar$')
+  java_library = re.compile('bazel-out/.*?-fastbuild/bin/(.*)/[^/]+[.]jar$')
+  srcs = re.compile('(.*/external/[^/]+)/jar/(.*)[.]jar')
   for p in _query_classpath(MAIN):
     if p.endswith('-src.jar'):
       # gwt_module() depends on -src.jar for Java to JavaScript compiles.
-      gwt_lib.add(p)
-      continue
-
-    if 'buck-out/gen/lib/gwt/' in p:
-      # gwt_module() depends on huge shaded GWT JARs that import
-      # incorrect versions of classes for Gerrit. Collect into
-      # a private grouping for later use.
+      if p.startswith("external"):
+        p = os.path.join(ext, p)
       gwt_lib.add(p)
       continue
 
     m = java_library.match(p)
     if m:
       src.add(m.group(1))
+      # Exceptions: both source and lib
+      if p.endswith('libquery_parser.jar') or \
+         p.endswith('prolog/libcommon.jar') or \
+         p.endswith('com_google_protobuf/libprotobuf_java.jar') or \
+         p.endswith('lucene-core-and-backward-codecs__merged.jar'):
+        lib.add(p)
+      # JGit dependency from external repository
+      if 'gerrit-' not in p and 'jgit' in p:
+        lib.add(p)
     else:
+      # Don't mess up with Bazel internal test runner dependencies.
+      # When we use Eclipse we rely on it for running the tests
+      if p.endswith("external/bazel_tools/tools/jdk/TestRunner_deploy.jar"):
+        continue
+      if p.startswith("external"):
+        p = os.path.join(ext, p)
       lib.add(p)
 
   for p in _query_classpath(GWT):
@@ -154,8 +209,8 @@
         continue
       out = 'eclipse-out/' + s
 
-    p = path.join(s, 'java')
-    if path.exists(p):
+    p = os.path.join(s, 'java')
+    if os.path.exists(p):
       classpathentry('src', p, out=out)
       continue
 
@@ -167,36 +222,49 @@
         o = 'eclipse-out/test'
 
       for srctype in ['java', 'resources']:
-        p = path.join(s, 'src', env, srctype)
-        if path.exists(p):
+        p = os.path.join(s, 'src', env, srctype)
+        if os.path.exists(p):
           classpathentry('src', p, out=o)
 
   for libs in [lib, gwt_lib]:
     for j in sorted(libs):
       s = None
-      if j.endswith('.jar'):
-        s = j[:-4] + '_src.jar'
-        if not path.exists(s):
-          s = None
+      m = srcs.match(j)
+      if m:
+        prefix = m.group(1)
+        suffix = m.group(2)
+        p = os.path.join(prefix, "jar", "%s-src.jar" % suffix)
+        if os.path.exists(p):
+          s = p
       if args.plugins:
         classpathentry('lib', j, s, exported=True)
       else:
+        # Filter out the source JARs that we pull through transitive closure of
+        # GWT plugin API (we add source directories themself).  Exception is
+        # libEdit-src.jar, that is needed for GWT SDM to work.
+        m = java_library.match(j)
+        if m:
+          if m.group(1).startswith("gerrit-") and \
+              j.endswith("-src.jar") and \
+              not j.endswith("libEdit-src.jar"):
+            continue
         classpathentry('lib', j, s)
+
   for s in sorted(gwt_src):
-    p = path.join(ROOT, s, 'src', 'main', 'java')
-    if path.exists(p):
+    p = os.path.join(ROOT, s, 'src', 'main', 'java')
+    if os.path.exists(p):
       classpathentry('lib', p, out='eclipse-out/gwtsrc')
 
   classpathentry('con', JRE)
   classpathentry('output', 'eclipse-out/classes')
 
-  p = path.join(ROOT, '.classpath')
+  p = os.path.join(ROOT, '.classpath')
   with open(p, 'w') as fd:
     doc.writexml(fd, addindent='\t', newl='\n', encoding='UTF-8')
 
   if args.plugins:
     for plugin in plugins:
-      plugindir = path.join(ROOT, plugin)
+      plugindir = os.path.join(ROOT, plugin)
       try:
         gen_project(plugin.replace('plugins/', ""), plugindir)
         gen_plugin_classpath(plugindir)
@@ -204,35 +272,38 @@
         print('error generating project for %s: %s' % (plugin, err),
               file=sys.stderr)
 
-def gen_factorypath():
-  doc = minidom.getDOMImplementation().createDocument(None, 'factorypath', None)
-  for jar in _query_classpath(['//lib/auto:auto-value']):
+def gen_factorypath(ext):
+  doc = xml.dom.minidom.getDOMImplementation().createDocument(
+      None, 'factorypath', None)
+  for jar in _query_classpath(AUTO):
     e = doc.createElement('factorypathentry')
     e.setAttribute('kind', 'EXTJAR')
-    e.setAttribute('id', path.join(ROOT, jar))
+    e.setAttribute('id', os.path.join(ext, jar))
     e.setAttribute('enabled', 'true')
     e.setAttribute('runInBatchMode', 'false')
     doc.documentElement.appendChild(e)
 
-  p = path.join(ROOT, '.factorypath')
+  p = os.path.join(ROOT, '.factorypath')
   with open(p, 'w') as fd:
     doc.writexml(fd, addindent='\t', newl='\n', encoding='UTF-8')
 
 try:
-  if not args.no_src:
-    try:
-      check_call([path.join(ROOT, 'tools', 'download_all.py'), '--src'])
-    except CalledProcessError as err:
-      exit(1)
-
+  ext_location = retrieve_ext_location().decode("utf-8")
   gen_project(args.project_name)
-  gen_classpath()
-  gen_factorypath()
+  gen_classpath(ext_location)
+  gen_factorypath(ext_location)
+  gen_bazel_path()
+
+  # TODO(davido): Remove this when GWT gone
+  gwt_working_dir = ".gwt_work_dir"
+  if not os.path.isdir(gwt_working_dir):
+    os.makedirs(os.path.join(ROOT, gwt_working_dir))
 
   try:
-    targets = ['//tools:buck'] + MAIN + GWT
-    check_call(['buck', 'build', '--deep'] + targets)
-  except CalledProcessError as err:
+    subprocess.check_call([
+        bazel_exe, 'build', MAIN, GWT,
+        '//gerrit-patch-jgit:libEdit-src.jar'])
+  except subprocess.CalledProcessError:
     exit(1)
 except KeyboardInterrupt:
   print('Interrupted by user', file=sys.stderr)
diff --git a/tools/gerrit.importorder b/tools/gerrit.importorder
deleted file mode 100644
index 398130e..0000000
--- a/tools/gerrit.importorder
+++ /dev/null
@@ -1,12 +0,0 @@
-#Organize Import Order
-#Mon Mar 23 17:27:34 PDT 2015
-9=javax
-8=java
-7=org
-6=net
-5=junit
-4=eu
-3=dk
-2=com
-1=com.google
-0=\#
diff --git a/tools/git.defs b/tools/git.defs
deleted file mode 100644
index 859f173..0000000
--- a/tools/git.defs
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright (C) 2013 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.
-
-def git_describe(directory = None):
-  import subprocess
-  cmd = ['git', 'describe', '--always', '--match', 'v[0-9].*', '--dirty']
-  if not directory:
-    p = subprocess.Popen(cmd, stdout = subprocess.PIPE)
-  else:
-    p = subprocess.Popen(cmd, stdout = subprocess.PIPE, cwd = directory)
-  v = p.communicate()[0].strip()
-  r = p.returncode
-  if r != 0:
-    raise subprocess.CalledProcessError(r, ' '.join(cmd))
-  return v
diff --git a/tools/gwt-constants.defs b/tools/gwt-constants.defs
deleted file mode 100644
index cb2c1c1..0000000
--- a/tools/gwt-constants.defs
+++ /dev/null
@@ -1,31 +0,0 @@
-GWT_JVM_ARGS = ['-Xmx512m']
-
-GWT_COMPILER_ARGS = [
-  '-XdisableClassMetadata',
-]
-
-GWT_COMPILER_ARGS_RELEASE_MODE = GWT_COMPILER_ARGS + [
-  '-XdisableCastChecking',
-]
-
-GWT_PLUGIN_DEPS = [
-  '//gerrit-plugin-gwtui:gwtui-api-lib',
-  '//lib/gwt:user',
-]
-
-GWT_TRANSITIVE_DEPS = [
-  '//lib/gwt:ant',
-  '//lib/gwt:colt',
-  '//lib/gwt:javax-validation',
-  '//lib/gwt:javax-validation_src',
-  '//lib/gwt:jsinterop-annotations',
-  '//lib/gwt:jsinterop-annotations_src',
-  '//lib/gwt:jsr305',
-  '//lib/gwt:tapestry',
-  '//lib/gwt:w3c-css-sac',
-  '//lib/ow2:ow2-asm',
-  '//lib/ow2:ow2-asm-analysis',
-  '//lib/ow2:ow2-asm-commons',
-  '//lib/ow2:ow2-asm-tree',
-  '//lib/ow2:ow2-asm-util',
-]
diff --git a/tools/intellij/copyright/Gerrit_Copyright.xml b/tools/intellij/copyright/Gerrit_Copyright.xml
new file mode 100644
index 0000000..5609cdc
--- /dev/null
+++ b/tools/intellij/copyright/Gerrit_Copyright.xml
@@ -0,0 +1,6 @@
+<component name="CopyrightManager">
+  <copyright>
+    <option name="myName" value="Gerrit Copyright" />
+    <option name="notice" value="Copyright (C) &amp;#36;today.year The Android Open Source Project&#10;&#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;&#10;http://www.apache.org/licenses/LICENSE-2.0&#10;&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;limitations under the License." />
+  </copyright>
+</component>
\ No newline at end of file
diff --git a/tools/intellij/copyright/profiles_settings.xml b/tools/intellij/copyright/profiles_settings.xml
new file mode 100644
index 0000000..dfb94d5
--- /dev/null
+++ b/tools/intellij/copyright/profiles_settings.xml
@@ -0,0 +1,7 @@
+<component name="CopyrightManager">
+  <settings default="Gerrit Copyright">
+    <LanguageOptions name="__TEMPLATE__">
+      <option name="block" value="false" />
+    </LanguageOptions>
+  </settings>
+</component>
\ No newline at end of file
diff --git a/tools/intellij/gerrit_daemon.xml b/tools/intellij/gerrit_daemon.xml
new file mode 100644
index 0000000..85dc6a7
--- /dev/null
+++ b/tools/intellij/gerrit_daemon.xml
@@ -0,0 +1,16 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="gerrit_daemon" type="Application" factoryName="Application" singleton="true">
+    <extension name="coverage" enabled="false" merge="false" sample_coverage="true" runner="idea" />
+    <option name="MAIN_CLASS_NAME" value="Main" />
+    <option name="PROGRAM_PARAMETERS" value="daemon --console-log --show-stack-trace -d ${GERRIT_TESTSITE}" />
+    <option name="WORKING_DIRECTORY" value="file://$MODULE_DIR$" />
+    <option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
+    <option name="ALTERNATIVE_JRE_PATH" />
+    <option name="ENABLE_SWING_INSPECTOR" value="false" />
+    <option name="ENV_VARIABLES" />
+    <option name="PASS_PARENT_ENVS" value="true" />
+    <module name=".workspace" />
+    <envs />
+    <method />
+  </configuration>
+</component>
diff --git a/tools/java_doc.defs b/tools/java_doc.defs
deleted file mode 100644
index 41a8730..0000000
--- a/tools/java_doc.defs
+++ /dev/null
@@ -1,40 +0,0 @@
-def java_doc(
-    name,
-    title,
-    pkgs,
-    paths,
-    srcs = [],
-    deps = [],
-    visibility = [],
-    do_it_wrong = False,
-    external_docs = [],
-  ):
-  if do_it_wrong:
-    sourcepath = paths
-  else:
-    sourcepath = ['$SRCDIR/' + n for n in paths]
-  external_docs.insert(0, 'http://docs.oracle.com/javase/7/docs/api')
-  genrule(
-    name = name,
-    cmd = ' '.join([
-      'while ! test -f .buckconfig; do cd ..; done;',
-      'javadoc',
-      '-quiet',
-      '-protected',
-      '-encoding UTF-8',
-      '-charset UTF-8',
-      '-notimestamp',
-      '-windowtitle "' + title + '"',
-      ' '.join(['-link %s' % url for url in external_docs]),
-      '-subpackages ',
-      ':'.join(pkgs),
-      '-sourcepath ',
-      ':'.join(sourcepath),
-      ' -classpath ',
-      ':'.join(['$(classpath %s)' % n for n in deps]),
-      '-d $TMP',
-    ]) + ';jar cf $OUT -C $TMP .',
-    srcs = srcs,
-    out = name + '.jar',
-    visibility = visibility,
-)
diff --git a/tools/java_sources.defs b/tools/java_sources.defs
deleted file mode 100644
index 0b3974e..0000000
--- a/tools/java_sources.defs
+++ /dev/null
@@ -1,10 +0,0 @@
-def java_sources(
-    name,
-    srcs,
-    visibility = []
-  ):
-  java_library(
-    name = name,
-    resources = srcs,
-    visibility = visibility,
-  )
diff --git a/tools/jgit-snapshot-deploy-pom.diff b/tools/jgit-snapshot-deploy-pom.diff
new file mode 100644
index 0000000..01f50e4
--- /dev/null
+++ b/tools/jgit-snapshot-deploy-pom.diff
@@ -0,0 +1,43 @@
+diff --git a/pom.xml b/pom.xml
+index d256bbb..7e523fd 100644
+--- a/pom.xml
++++ b/pom.xml
+@@ -226,6 +226,10 @@
+ 
+   <pluginRepositories>
+     <pluginRepository>
++      <id>gerrit-maven</id>
++      <url>https://gerrit-maven.commondatastorage.googleapis.com</url>
++    </pluginRepository>
++    <pluginRepository>
+       <id>repo.eclipse.org.cbi-releases</id>
+       <url>https://repo.eclipse.org/content/repositories/cbi-releases/</url>
+     </pluginRepository>
+@@ -236,6 +240,13 @@
+   </pluginRepositories>
+ 
+   <build>
++    <extensions>
++      <extension>
++        <groupId>com.googlesource.gerrit</groupId>
++        <artifactId>gs-maven-wagon</artifactId>
++        <version>3.3</version>
++      </extension>
++    </extensions>
+     <pluginManagement>
+       <plugins>
+         <plugin>
+@@ -649,9 +660,10 @@
+ 
+   <distributionManagement>
+     <repository>
+-      <id>repo.eclipse.org</id>
+-      <name>JGit Maven Repository - Releases</name>
+-      <url>https://repo.eclipse.org/content/repositories/jgit-releases/</url>
++      <id>gerrit-maven-repository</id>
++      <name>Gerrit Maven Repository</name>
++      <url>gs://gerrit-maven</url>
++      <uniqueVersion>true</uniqueVersion>
+     </repository>
+     <snapshotRepository>
+       <id>repo.eclipse.org</id>
diff --git a/tools/js/BUCK b/tools/js/BUCK
deleted file mode 100644
index ba4f19c..0000000
--- a/tools/js/BUCK
+++ /dev/null
@@ -1,20 +0,0 @@
-python_binary(
-  name = 'bower2buck',
-  main = 'bower2buck.py',
-  deps = ['//tools:util'],
-  visibility = ['PUBLIC'],
-)
-
-python_binary(
-  name = 'download_bower',
-  main = 'download_bower.py',
-  deps = ['//tools:util'],
-  visibility = ['PUBLIC'],
-)
-
-python_binary(
-  name = 'run_npm_binary',
-  main = 'run_npm_binary.py',
-  deps = ['//tools:util'],
-  visibility = ['PUBLIC'],
-)
diff --git a/tools/js/BUILD b/tools/js/BUILD
new file mode 100644
index 0000000..fedaf7f
--- /dev/null
+++ b/tools/js/BUILD
@@ -0,0 +1 @@
+exports_files(["run_npm_binary.py"])
diff --git a/tools/js/bower2bazel.py b/tools/js/bower2bazel.py
new file mode 100755
index 0000000..0415e26
--- /dev/null
+++ b/tools/js/bower2bazel.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python
+# 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.
+
+"""Suggested call sequence:
+
+python tools/js/bower2bazel.py -w lib/js/bower_archives.bzl -b lib/js/bower_components.bzl
+"""
+
+from __future__ import print_function
+
+import collections
+import json
+import hashlib
+import optparse
+import os
+import subprocess
+import sys
+import tempfile
+import glob
+import bowerutil
+
+# list of licenses for packages that don't specify one in their bower.json file.
+package_licenses = {
+  "es6-promise": "es6-promise",
+  "fetch": "fetch",
+  "iron-a11y-announcer": "polymer",
+  "iron-a11y-keys-behavior": "polymer",
+  "iron-autogrow-textarea": "polymer",
+  "iron-behaviors": "polymer",
+  "iron-dropdown": "polymer",
+  "iron-fit-behavior": "polymer",
+  "iron-flex-layout": "polymer",
+  "iron-form-element-behavior": "polymer",
+  "iron-input": "polymer",
+  "iron-meta": "polymer",
+  "iron-overlay-behavior": "polymer",
+  "iron-resizable-behavior": "polymer",
+  "iron-selector": "polymer",
+  "iron-validatable-behavior": "polymer",
+  "moment": "moment",
+  "neon-animation": "polymer",
+  "page": "page.js",
+  "polymer": "polymer",
+  "promise-polyfill": "promise-polyfill",
+  "web-animations-js": "Apache2.0",
+  "webcomponentsjs": "polymer",
+}
+
+
+def build_bower_json(version_targets, seeds):
+  """Generate bower JSON file, return its path.
+
+  Args:
+    version_targets: bazel target names of the versions.json file.
+    seeds: an iterable of bower package names of the seed packages, ie.
+      the packages whose versions we control manually.
+  """
+  bower_json = collections.OrderedDict()
+  bower_json['name'] = 'bower2bazel-output'
+  bower_json['version'] = '0.0.0'
+  bower_json['description'] = 'Auto-generated bower.json for dependency management'
+  bower_json['private'] = True
+  bower_json['dependencies'] = {}
+
+  seeds = set(seeds)
+  for v in version_targets:
+    path = os.path.join("bazel-out/*-fastbuild/bin", v.lstrip("/").replace(":", "/"))
+    fs = glob.glob(path)
+    assert len(fs) == 1, '%s: file not found or multiple files found: %s' % (path, fs)
+    with open(fs[0]) as f:
+      j = json.load(f)
+      if "" in j:
+        # drop dummy entries.
+        del j[""]
+
+      trimmed = {}
+      for k, v in j.items():
+        if k in seeds:
+          trimmed[k] = v
+
+      bower_json['dependencies'].update(trimmed)
+
+  tmpdir = tempfile.mkdtemp()
+  ret = os.path.join(tmpdir, 'bower.json')
+  with open(ret, 'w') as f:
+    json.dump(bower_json, f, indent=2)
+  return ret
+
+def decode(input):
+  try:
+    return input.decode("utf-8")
+  except TypeError:
+    return input
+
+def bower_command(args):
+  base = subprocess.check_output(["bazel", "info", "output_base"]).strip()
+  exp = os.path.join(decode(base), "external", "bower", "*npm_binary.tgz")
+  fs = sorted(glob.glob(exp))
+  assert len(fs) == 1, "bower tarball not found or have multiple versions %s" % fs
+  return ["python", os.getcwd() + "/tools/js/run_npm_binary.py", sorted(fs)[0]] + args
+
+
+def main(args):
+  opts = optparse.OptionParser()
+  opts.add_option('-w', help='.bzl output for WORKSPACE')
+  opts.add_option('-b', help='.bzl output for //lib:BUILD')
+  opts, args = opts.parse_args()
+
+  target_str = subprocess.check_output([
+    "bazel", "query", "kind(bower_component_bundle, //polygerrit-ui/...)"])
+  seed_str = subprocess.check_output([
+    "bazel", "query", "attr(seed, 1, kind(bower_component, deps(//polygerrit-ui/...)))"])
+  targets = [s for s in decode(target_str).split('\n') if s]
+  seeds = [s for s in decode(seed_str).split('\n') if s]
+  prefix = "//lib/js:"
+  non_seeds = [s for s in seeds if not s.startswith(prefix)]
+  assert not non_seeds, non_seeds
+  seeds = set([s[len(prefix):] for s in seeds])
+
+  version_targets = [t + "-versions.json" for t in targets]
+  subprocess.check_call(['bazel', 'build'] + version_targets)
+  bower_json_path = build_bower_json(version_targets, seeds)
+  dir = os.path.dirname(bower_json_path)
+  cmd = bower_command(["install"])
+
+  build_out = sys.stdout
+  if opts.b:
+    build_out = open(opts.b + ".tmp", 'w')
+
+  ws_out = sys.stdout
+  if opts.b:
+    ws_out = open(opts.w + ".tmp", 'w')
+
+  header = """# DO NOT EDIT
+# generated with the following command:
+#
+#   %s
+#
+
+""" % ' '.join(sys.argv)
+
+  ws_out.write(header)
+  build_out.write(header)
+
+  oldwd = os.getcwd()
+  os.chdir(dir)
+  subprocess.check_call(cmd)
+
+  interpret_bower_json(seeds, ws_out, build_out)
+  ws_out.close()
+  build_out.close()
+
+  os.chdir(oldwd)
+  os.rename(opts.w + ".tmp", opts.w)
+  os.rename(opts.b + ".tmp", opts.b)
+
+
+def dump_workspace(data, seeds, out):
+  out.write('load("//tools/bzl:js.bzl", "bower_archive")\n\n')
+  out.write('def load_bower_archives():\n')
+
+  for d in data:
+    if d["name"] in seeds:
+      continue
+    out.write("""  bower_archive(
+    name = "%(name)s",
+    package = "%(normalized-name)s",
+    version = "%(version)s",
+    sha1 = "%(bazel-sha1)s")
+""" % d)
+
+
+def dump_build(data, seeds, out):
+  out.write('load("//tools/bzl:js.bzl", "bower_component")\n\n')
+  out.write('def define_bower_components():\n')
+  for d in data:
+    out.write("  bower_component(\n")
+    out.write("    name = \"%s\",\n" % d["name"])
+    out.write("    license = \"//lib:LICENSE-%s\",\n" % d["bazel-license"])
+    deps = sorted(d.get("dependencies", {}).keys())
+    if deps:
+      if len(deps) == 1:
+        out.write("    deps = [ \":%s\" ],\n" % deps[0])
+      else:
+        out.write("    deps = [\n")
+        for dep in deps:
+          out.write("      \":%s\",\n" % dep)
+        out.write("    ],\n")
+    if d["name"] in seeds:
+      out.write("    seed = True,\n")
+    out.write("  )\n")
+  # done
+
+
+def interpret_bower_json(seeds, ws_out, build_out):
+  out = subprocess.check_output(["find", "bower_components/", "-name", ".bower.json"])
+
+  data = []
+  for f in sorted(decode(out).split('\n')):
+    if not f:
+      continue
+    pkg = json.load(open(f))
+    pkg_name = pkg["name"]
+
+    pkg["bazel-sha1"] = bowerutil.hash_bower_component(
+      hashlib.sha1(), os.path.dirname(f)).hexdigest()
+    license = package_licenses.get(pkg_name, "DO_NOT_DISTRIBUTE")
+
+    pkg["bazel-license"] = license
+
+    # TODO(hanwen): bower packages can also have 'fully qualified'
+    # names, ("PolymerElements/iron-ajax") as well as short names
+    # ("iron-ajax").  It is possible for bower.json files to refer to
+    # long names as their dependencies. If any package does this, we
+    # will have to either 1) strip off the prefix (typically github
+    # user?), or 2) build a map of short name <=> fully qualified
+    # name. For now, we just ignore the problem.
+    pkg["normalized-name"] = pkg["name"]
+    data.append(pkg)
+
+  dump_workspace(data, seeds, ws_out)
+  dump_build(data, seeds, build_out)
+
+
+if __name__ == '__main__':
+  main(sys.argv[1:])
diff --git a/tools/js/bower2buck.py b/tools/js/bower2buck.py
deleted file mode 100755
index 81072da..0000000
--- a/tools/js/bower2buck.py
+++ /dev/null
@@ -1,214 +0,0 @@
-#!/usr/bin/env python
-# 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.
-
-from __future__ import print_function
-
-import atexit
-import collections
-import json
-import hashlib
-import optparse
-import os
-import shutil
-import subprocess
-import sys
-import tempfile
-
-from tools import util
-
-
-# This script is run with `buck run`, but needs to shell out to buck; this is
-# only possible if we avoid buckd.
-BUCK_ENV = dict(os.environ)
-BUCK_ENV['NO_BUCKD'] = '1'
-
-HEADER = """\
-include_defs('//lib/js.defs')
-
-# AUTOGENERATED BY BOWER2BUCK
-#
-# This file should be merged with an existing BUCK file containing these rules.
-#
-# This comment SHOULD NOT be copied to the existing BUCK file, and you should
-# leave alone any non-bower_component contents of the file.
-#
-# Generally, the following attributes SHOULD be copied from this file to the
-# existing BUCK file:
-#  - package: the normalized package name
-#  - version: the exact version number
-#  - deps: direct dependencies of the package
-#  - sha1: a hash of the package contents
-#
-# The following fields SHOULD NOT be copied to the existing BUCK file:
-#  - semver: manually-specified semantic version, not included in autogenerated
-#    output.
-#
-# The following fields require SPECIAL HANDLING:
-#  - license: all licenses in this file are specified as TODO. You must replace
-#    this text with one of the existing licenses defined in lib/BUCK, or
-#    define a new one if necessary. Leave existing licenses alone.
-
-"""
-
-
-def usage():
-  print(('Usage: %s -o <outfile> [//path/to:bower_components_rule...]'
-         % sys.argv[0]),
-        file=sys.stderr)
-  return 1
-
-
-class Rule(object):
-  def __init__(self, bower_json_path):
-    with open(bower_json_path) as f:
-      bower_json = json.load(f)
-    self.name = bower_json['name']
-    self.version = bower_json['version']
-    self.deps = bower_json.get('dependencies', {})
-    self.license = bower_json.get('license', 'NO LICENSE')
-    self.sha1 = util.hash_bower_component(
-        hashlib.sha1(), os.path.dirname(bower_json_path)).hexdigest()
-
-  def to_rule(self, packages):
-    if self.name not in packages:
-      raise ValueError('No package name found for %s' % self.name)
-
-    lines = [
-        'bower_component(',
-        "  name = '%s'," % self.name,
-        "  package = '%s'," % packages[self.name],
-        "  version = '%s'," % self.version,
-        ]
-    if self.deps:
-      if len(self.deps) == 1:
-        lines.append("  deps = [':%s']," % next(self.deps.iterkeys()))
-      else:
-        lines.append('  deps = [')
-        lines.extend("    ':%s'," % d for d in sorted(self.deps.iterkeys()))
-        lines.append('  ],')
-    lines.extend([
-        "  license = 'TODO: %s'," % self.license,
-        "  sha1 = '%s'," % self.sha1,
-        ')'])
-    return '\n'.join(lines)
-
-
-def build_bower_json(targets, buck_out):
-  bower_json = collections.OrderedDict()
-  bower_json['name'] = 'bower2buck-output'
-  bower_json['version'] = '0.0.0'
-  bower_json['description'] = 'Auto-generated bower.json for dependency management'
-  bower_json['private'] = True
-  bower_json['dependencies'] = {}
-
-  deps = subprocess.check_output(
-      ['buck', 'query', '-v', '0',
-       "filter('__download_bower', deps(%s))" % '+'.join(targets)],
-      env=BUCK_ENV)
-  deps = deps.replace('__download_bower', '__bower_version').split()
-  subprocess.check_call(['buck', 'build'] + deps, env=BUCK_ENV)
-
-  for dep in deps:
-    dep = dep.replace(':', '/').lstrip('/')
-    depout = os.path.basename(dep)
-    version_json = os.path.join(buck_out, 'gen', dep, depout)
-    with open(version_json) as f:
-      bower_json['dependencies'].update(json.load(f))
-
-  tmpdir = tempfile.mkdtemp()
-  atexit.register(lambda: shutil.rmtree(tmpdir))
-  ret = os.path.join(tmpdir, 'bower.json')
-  with open(ret, 'w') as f:
-    json.dump(bower_json, f, indent=2)
-  return ret
-
-
-def get_package_name(name, package_version):
-  v = package_version.lower()
-  if '#' in v:
-    return v[:v.find('#')]
-  return name
-
-
-def get_packages(path):
-  with open(path) as f:
-    bower_json = json.load(f)
-  return dict((n, get_package_name(n, v))
-              for n, v in bower_json.get('dependencies', {}).iteritems())
-
-
-def collect_rules(packages):
-  # TODO(dborowitz): Use run_npm_binary instead of system bower.
-  rules = {}
-  subprocess.check_call(['bower', 'install'])
-  for dirpath, dirnames, filenames in os.walk('.', topdown=True):
-    if '.bower.json' not in filenames:
-      continue
-    del dirnames[:]
-    rule = Rule(os.path.join(dirpath, '.bower.json'))
-    rules[rule.name] = rule
-
-    # Oddly, the package name referred to in the deps section of dependents,
-    # e.g. 'PolymerElements/iron-ajax', is not found anywhere in this
-    # bower.json, which only contains 'iron-ajax'. Build up a map of short name
-    # to package name so we can resolve them later.
-    # TODO(dborowitz): We can do better:
-    #  - Infer 'user/package' from GitHub URLs (i.e. a simple subset of Bower's package
-    #    resolution logic).
-    #  - Resolve aliases using https://bower.herokuapp.com/packages/shortname
-    #    (not currently biting us but it might in the future.)
-    for n, v in rule.deps.iteritems():
-      p = get_package_name(n, v)
-      old = packages.get(n)
-      if old is not None and old != p:
-        raise ValueError('multiple packages named %s: %s != %s' % (n, p, old))
-      packages[n] = p
-
-  return rules
-
-
-def find_buck_out():
-  dir = os.getcwd()
-  while not os.path.isfile(os.path.join(dir, '.buckconfig')):
-    dir = os.path.dirname(dir)
-  return os.path.join(dir, 'buck-out')
-
-
-def main(args):
-  opts = optparse.OptionParser()
-  opts.add_option('-o', help='output file location')
-  opts, args = opts.parse_args()
-
-  if not opts.o or not all(a.startswith('//') for a in args):
-    return usage()
-  outfile = os.path.abspath(opts.o)
-  buck_out = find_buck_out()
-
-  targets = args if args else ['//polygerrit-ui/...']
-  bower_json_path = build_bower_json(targets, buck_out)
-  os.chdir(os.path.dirname(bower_json_path))
-  packages = get_packages(bower_json_path)
-  rules = collect_rules(packages)
-
-  with open(outfile, 'w') as f:
-    f.write(HEADER)
-    for _, r in sorted(rules.iteritems()):
-      f.write('\n\n%s' % r.to_rule(packages))
-
-  print('Wrote bower_components rules to:\n  %s' % outfile)
-
-
-if __name__ == '__main__':
-  main(sys.argv[1:])
diff --git a/tools/js/bowerutil.py b/tools/js/bowerutil.py
new file mode 100644
index 0000000..c2e11cd
--- /dev/null
+++ b/tools/js/bowerutil.py
@@ -0,0 +1,46 @@
+# Copyright (C) 2013 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.
+
+import os
+
+
+def hash_bower_component(hash_obj, path):
+  """Hash the contents of a bower component directory.
+
+  This is a stable hash of a directory downloaded with `bower install`, minus
+  the .bower.json file, which is autogenerated each time by bower. Used in lieu
+  of hashing a zipfile of the contents, since zipfiles are difficult to hash in
+  a stable manner.
+
+  Args:
+    hash_obj: an open hash object, e.g. hashlib.sha1().
+    path: path to the directory to hash.
+
+  Returns:
+    The passed-in hash_obj.
+  """
+  if not os.path.isdir(path):
+    raise ValueError('Not a directory: %s' % path)
+
+  path = os.path.abspath(path)
+  for root, dirs, files in os.walk(path):
+    dirs.sort()
+    for f in sorted(files):
+      if f == '.bower.json':
+        continue
+      p = os.path.join(root, f)
+      hash_obj.update(p[len(path)+1:].encode("utf-8"))
+      hash_obj.update(open(p, "rb").read())
+
+  return hash_obj
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
old mode 100644
new mode 100755
index bcc417c..3db39d5
--- a/tools/js/download_bower.py
+++ b/tools/js/download_bower.py
@@ -23,11 +23,10 @@
 import subprocess
 import sys
 
-from tools import util
-
+import bowerutil
 
 CACHE_DIR = os.path.expanduser(os.path.join(
-    '~', '.gerritcodereview', 'buck-cache', 'downloaded-artifacts'))
+    '~', '.gerritcodereview', 'bazel-cache', 'downloaded-artifacts'))
 
 
 def bower_cmd(bower, *args):
@@ -39,16 +38,20 @@
 def bower_info(bower, name, package, version):
   cmd = bower_cmd(bower, '-l=error', '-j',
                   'info', '%s#%s' % (package, version))
-  p = subprocess.Popen(cmd , stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+  try:
+    p = subprocess.Popen(cmd , stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+  except:
+    sys.stderr.write("error executing: %s\n" % ' '.join(cmd))
+    raise
   out, err = p.communicate()
   if p.returncode:
     sys.stderr.write(err)
-    raise OSError('Command failed: %s' % cmd)
+    raise OSError('Command failed: %s' % ' '.join(cmd))
 
   try:
     info = json.loads(out)
   except ValueError:
-    raise ValueError('invalid JSON from %s:\n%s' % (cmd, out))
+    raise ValueError('invalid JSON from %s:\n%s' % (" ".join(cmd), out))
   info_name = info.get('name')
   if info_name != name:
     raise ValueError('expected package name %s, got: %s' % (name, info_name))
@@ -65,7 +68,7 @@
   deps = info.get('dependencies')
   if deps:
     with open(os.path.join('.bowerrc'), 'w') as f:
-      json.dump({'ignoredDependencies': deps.keys()}, f)
+      json.dump({'ignoredDependencies': list(deps.keys())}, f)
 
 
 def cache_entry(name, package, version, sha1):
@@ -82,7 +85,11 @@
   opts.add_option('-v', help='version number')
   opts.add_option('-s', help='expected content sha1')
   opts.add_option('-o', help='output file location')
-  opts, _ = opts.parse_args()
+  opts, args_ = opts.parse_args(args)
+
+  assert opts.p
+  assert opts.v
+  assert opts.n
 
   cwd = os.getcwd()
   outzip = os.path.join(cwd, opts.o)
@@ -100,7 +107,7 @@
 
     if opts.s:
       path = os.path.join(bc, opts.n)
-      sha1 = util.hash_bower_component(hashlib.sha1(), path).hexdigest()
+      sha1 = bowerutil.hash_bower_component(hashlib.sha1(), path).hexdigest()
       if opts.s != sha1:
         print((
           '%s#%s:\n'
diff --git a/tools/js/npm_pack.py b/tools/js/npm_pack.py
index 9eb6e34..f611eaf 100755
--- a/tools/js/npm_pack.py
+++ b/tools/js/npm_pack.py
@@ -36,7 +36,7 @@
 def bundle_dependencies():
   with open('package.json') as f:
     package = json.load(f)
-  package['bundledDependencies'] = package['dependencies'].keys()
+  package['bundledDependencies'] = list(package['dependencies'].keys())
   with open('package.json', 'w') as f:
     json.dump(package, f)
 
@@ -48,7 +48,7 @@
 
   name, version = args
   filename = '%s-%s.tgz' % (name, version)
-  url = 'http://registry.npmjs.org/%s/-/%s' % (name, filename)
+  url = 'https://registry.npmjs.org/%s/-/%s' % (name, filename)
 
   tmpdir = tempfile.mkdtemp();
   tgz = os.path.join(tmpdir, filename)
diff --git a/tools/js/run_npm_binary.py b/tools/js/run_npm_binary.py
index d76eff5..d769b98 100644
--- a/tools/js/run_npm_binary.py
+++ b/tools/js/run_npm_binary.py
@@ -25,8 +25,6 @@
 import tarfile
 import tempfile
 
-from tools import util
-
 
 def extract(path, outdir, bin):
   if os.path.exists(os.path.join(outdir, bin)):
@@ -59,19 +57,21 @@
     # finished.
     extract_one(tar.getmember(bin))
 
-
 def main(args):
   path = args[0]
   suffix = '.npm_binary.tgz'
   tgz = os.path.basename(path)
+
   parts = tgz[:-len(suffix)].split('@')
 
   if not tgz.endswith(suffix) or len(parts) != 2:
     print('usage: %s <path/to/npm_binary>' % sys.argv[0], file=sys.stderr)
     return 1
 
-  name, version = parts
-  sha1 = util.hash_file(hashlib.sha1(), path).hexdigest()
+  name, _ = parts
+
+  # Avoid importing from gerrit because we don't want to depend on the right CWD.
+  sha1 = hashlib.sha1(open(path, 'rb').read()).hexdigest()
   outdir = '%s-%s' % (path[:-len(suffix)], sha1)
   rel_bin = os.path.join('package', 'bin', name)
   bin = os.path.join(outdir, rel_bin)
diff --git a/tools/maven/BUCK b/tools/maven/BUCK
deleted file mode 100644
index 322b5a2..0000000
--- a/tools/maven/BUCK
+++ /dev/null
@@ -1,33 +0,0 @@
-include_defs('//VERSION')
-include_defs('//tools/maven/package.defs')
-include_defs('//tools/maven/repository.defs')
-
-if GERRIT_VERSION.endswith('-SNAPSHOT'):
-  URL = MAVEN_SNAPSHOT_URL
-else:
-  URL = MAVEN_RELEASE_URL
-
-maven_package(
-  repository = MAVEN_REPOSITORY,
-  url = URL,
-  version = GERRIT_VERSION,
-  jar = {
-    'gerrit-acceptance-framework': '//gerrit-acceptance-framework:acceptance-framework',
-    'gerrit-extension-api': '//gerrit-extension-api:extension-api',
-    'gerrit-plugin-api': '//gerrit-plugin-api:plugin-api',
-    'gerrit-plugin-gwtui': '//gerrit-plugin-gwtui:gwtui-api',
-  },
-  src = {
-    'gerrit-acceptance-framework': '//gerrit-acceptance-framework:acceptance-framework-src',
-    'gerrit-extension-api': '//gerrit-extension-api:extension-api-src',
-    'gerrit-plugin-api': '//gerrit-plugin-api:plugin-api-src',
-    'gerrit-plugin-gwtui': '//gerrit-plugin-gwtui:gwtui-api-src',
-  },
-  doc = {
-    'gerrit-acceptance-framework': '//gerrit-acceptance-framework:acceptance-framework-javadoc',
-    'gerrit-extension-api': '//gerrit-extension-api:extension-api-javadoc',
-    'gerrit-plugin-api': '//gerrit-plugin-api:plugin-api-javadoc',
-    'gerrit-plugin-gwtui': '//gerrit-plugin-gwtui:gwtui-api-javadoc',
-  },
-  war = {'gerrit-war': '//:release'},
-)
diff --git a/tools/maven/BUILD b/tools/maven/BUILD
new file mode 100644
index 0000000..d46a954
--- /dev/null
+++ b/tools/maven/BUILD
@@ -0,0 +1,31 @@
+load("//:version.bzl", "GERRIT_VERSION")
+load("//tools/maven:package.bzl", "maven_package")
+
+MAVEN_REPOSITORY = "sonatype-nexus-staging"
+
+URL = "https://oss.sonatype.org/content/repositories/snapshots" if GERRIT_VERSION.endswith("-SNAPSHOT") else "https://oss.sonatype.org/service/local/staging/deploy/maven2"
+
+maven_package(
+    src = {
+        "gerrit-acceptance-framework": "//gerrit-acceptance-framework:liblib-src.jar",
+        "gerrit-extension-api": "//gerrit-extension-api:libapi-src.jar",
+        "gerrit-plugin-api": "//gerrit-plugin-api:plugin-api-sources_deploy.jar",
+        "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api-source_deploy.jar",
+    },
+    doc = {
+        "gerrit-acceptance-framework": "//gerrit-acceptance-framework:acceptance-framework-javadoc",
+        "gerrit-extension-api": "//gerrit-extension-api:extension-api-javadoc",
+        "gerrit-plugin-api": "//gerrit-plugin-api:plugin-api-javadoc",
+        "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api-javadoc",
+    },
+    jar = {
+        "gerrit-acceptance-framework": "//gerrit-acceptance-framework:acceptance-framework_deploy.jar",
+        "gerrit-extension-api": "//gerrit-extension-api:extension-api_deploy.jar",
+        "gerrit-plugin-api": "//gerrit-plugin-api:plugin-api_deploy.jar",
+        "gerrit-plugin-gwtui": "//gerrit-plugin-gwtui:gwtui-api_deploy.jar",
+    },
+    repository = MAVEN_REPOSITORY,
+    url = URL,
+    version = GERRIT_VERSION,
+    war = {"gerrit-war": "//:release"},
+)
diff --git a/tools/maven/api.sh b/tools/maven/api.sh
index c7ce65e..c6049bf 100755
--- a/tools/maven/api.sh
+++ b/tools/maven/api.sh
@@ -1,4 +1,4 @@
-#!/bin/bash -e
+#!/usr/bin/env bash
 
 # Copyright (C) 2015 The Android Open Source Project
 #
@@ -14,10 +14,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-if [[ "$#" == "0" ]] ; then
+set -e
+
+if [[ "$#" -lt "1" ]] ; then
   cat <<EOF
-Usage: run "$0 COMMAND" from the top of your workspace, where
-COMMAND is one of
+Usage: run "$0 COMMAND [build_args...]" from the top of your workspace,
+where COMMAND is one of
 
   install
   deploy
@@ -34,7 +36,6 @@
 set -o errexit
 set -o nounset
 
-
 case "$1" in
 install)
     command="api_install"
@@ -53,17 +54,19 @@
     exit 1
     ;;
 esac
+shift
 
 if [[ "${VERBOSE:-x}" != "x" ]]; then
   set -o xtrace
 fi
 
-buck build //tools/maven:gen_${command} || \
-  { echo "buck failed to build gen_${command}. Use VERBOSE=1 for more info" ; exit 1 ; }
+if [[ `which bazelisk` ]]; then
+  BAZEL_CMD=bazelisk
+else
+  BAZEL_CMD=bazel
+fi
 
-script="./buck-out/gen/tools/maven/gen_${command}/${command}.sh"
+${BAZEL_CMD} build //tools/maven:gen_${command} "$@" || \
+  { echo "${BAZEL_CMD} failed to build gen_${command}. Use VERBOSE=1 for more info" ; exit 1 ; }
 
-# The PEX wrapper does some funky exit handling, so even if the script
-# does "exit(0)", the return status is '1'. So we can't tell if the
-# following invocation was successful.
-${script}
+./bazel-bin/tools/maven/${command}.sh
diff --git a/tools/maven/mvn.py b/tools/maven/mvn.py
index 4011d71..a093916 100755
--- a/tools/maven/mvn.py
+++ b/tools/maven/mvn.py
@@ -16,7 +16,7 @@
 from __future__ import print_function
 from optparse import OptionParser
 from os import path, environ
-from subprocess import check_output
+from subprocess import check_output, CalledProcessError
 from sys import stderr
 
 opts = OptionParser()
@@ -33,7 +33,7 @@
   exit(1)
 
 root = path.abspath(__file__)
-while not path.exists(path.join(root, '.buckconfig')):
+while not path.exists(path.join(root, 'WORKSPACE')):
   root = path.dirname(root)
 
 if 'install' == args.a:
@@ -67,6 +67,8 @@
   except Exception as e:
     print('%s command failed: %s\n%s' % (args.a, ' '.join(exe), e),
       file=stderr)
+    if environ.get('VERBOSE') and isinstance(e, CalledProcessError):
+      print('Command output\n%s' % e.output, file=stderr)
     exit(1)
 
 
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl
new file mode 100644
index 0000000..ce60db9
--- /dev/null
+++ b/tools/maven/package.bzl
@@ -0,0 +1,118 @@
+# 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.
+
+sh_bang_template = (" && ".join([
+    "echo '#!/usr/bin/env bash' > $@",
+    "echo \"# this script should run from the root of your workspace.\" >> $@",
+    "echo \"set -e\" >> $@",
+    "echo \"\" >> $@",
+    "echo 'function bazel_cmd() {' >> $@",
+    "echo '  if [[ `which bazelisk` ]]; then' >> $@",
+    "echo '    bazelisk \"$$@\"' >> $@",
+    "echo '  else' >> $@",
+    "echo '    bazel \"$$@\"' >> $@",
+    "echo '  fi' >> $@",
+    "echo '}' >> $@",
+    "echo \"\" >> $@",
+    "echo 'if [[ \"$$VERBOSE\" ]]; then set -x ; fi' >> $@",
+    "echo \"\" >> $@",
+    "echo %s >> $@",
+    "echo \"\" >> $@",
+    "echo %s >> $@",
+]))
+
+def maven_package(
+        version,
+        repository = None,
+        url = None,
+        jar = {},
+        src = {},
+        doc = {},
+        war = {}):
+    build_cmd = ["bazel_cmd", "build"]
+    mvn_cmd = ["python", "tools/maven/mvn.py", "-v", version]
+    api_cmd = mvn_cmd[:]
+    api_targets = []
+    for type, d in [("jar", jar), ("java-source", src), ("javadoc", doc)]:
+        for a, t in sorted(d.items()):
+            api_cmd.append("-s %s:%s:$(location %s)" % (a, type, t))
+            api_targets.append(t)
+
+    native.genrule(
+        name = "gen_api_install",
+        cmd = sh_bang_template % (
+            " ".join(build_cmd + api_targets),
+            " ".join(api_cmd + ["-a", "install"]),
+        ),
+        srcs = api_targets,
+        outs = ["api_install.sh"],
+        executable = True,
+        testonly = 1,
+    )
+
+    if repository and url:
+        native.genrule(
+            name = "gen_api_deploy",
+            cmd = sh_bang_template % (
+                " ".join(build_cmd + api_targets),
+                " ".join(api_cmd + [
+                    "-a",
+                    "deploy",
+                    "--repository",
+                    repository,
+                    "--url",
+                    url,
+                ]),
+            ),
+            srcs = api_targets,
+            outs = ["api_deploy.sh"],
+            executable = True,
+            testonly = 1,
+        )
+
+    war_cmd = mvn_cmd[:]
+    war_targets = []
+    for a, t in sorted(war.items()):
+        war_cmd.append("-s %s:war:$(location %s)" % (a, t))
+        war_targets.append(t)
+
+    native.genrule(
+        name = "gen_war_install",
+        cmd = sh_bang_template % (
+            " ".join(build_cmd + war_targets),
+            " ".join(war_cmd + ["-a", "install"]),
+        ),
+        srcs = war_targets,
+        outs = ["war_install.sh"],
+        executable = True,
+    )
+
+    if repository and url:
+        native.genrule(
+            name = "gen_war_deploy",
+            cmd = sh_bang_template % (
+                " ".join(build_cmd + war_targets),
+                " ".join(war_cmd + [
+                    "-a",
+                    "deploy",
+                    "--repository",
+                    repository,
+                    "--url",
+                    url,
+                ]),
+            ),
+            srcs = war_targets,
+            outs = ["war_deploy.sh"],
+            executable = True,
+        )
diff --git a/tools/maven/package.defs b/tools/maven/package.defs
deleted file mode 100644
index c412ebd..0000000
--- a/tools/maven/package.defs
+++ /dev/null
@@ -1,95 +0,0 @@
-# Copyright (C) 2013 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.
-
-sh_bang_template = (' && '.join([
-  "echo '#!/bin/bash -eu' > $OUT",
-  'echo "# this script should run from the root of your workspace." >> $OUT',
-  'echo "" >> $OUT',
-  "echo 'if [[ -n \"$${VERBOSE:-}\" ]]; then set -x ; fi' >> $OUT",
-  'echo "" >> $OUT',
-  'echo %s >> $OUT',
-  'echo "" >> $OUT',
-  'echo %s >> $OUT',
-  # This is supposed to be handled by executable=True, but it doesn't
-  # work. Bug?
-  'chmod +x $OUT' ]))
-
-def maven_package(
-    version,
-    repository = None,
-    url = None,
-    jar = {},
-    src = {},
-    doc = {},
-    war = {}):
-
-  build_cmd = ['buck', 'build']
-
-  # This is not using python_binary() to avoid the baggage and bugs
-  # that PEX brings along.
-  mvn_cmd = ['python', 'tools/maven/mvn.py', '-v', version]
-  api_cmd = mvn_cmd[:]
-  api_targets = []
-  for type,d in [('jar', jar), ('java-source', src), ('javadoc', doc)]:
-    for a,t in sorted(d.iteritems()):
-      api_cmd.append('-s %s:%s:$(location %s)' % (a,type,t))
-      api_targets.append(t)
-
-  genrule(
-    name = 'gen_api_install',
-    cmd = sh_bang_template % (
-      ' '.join(build_cmd + api_targets),
-      ' '.join(api_cmd + ['-a', 'install'])),
-    out = 'api_install.sh',
-    executable = True,
-  )
-
-  if repository and url:
-    genrule(
-      name = 'gen_api_deploy',
-      cmd = sh_bang_template % (
-        ' '.join(build_cmd + api_targets),
-        ' '.join(api_cmd + ['-a', 'deploy',
-                            '--repository', repository,
-                            '--url', url])),
-      out = 'api_deploy.sh',
-      executable = True,
-    )
-
-  war_cmd = mvn_cmd[:]
-  war_targets = []
-  for a,t in sorted(war.iteritems()):
-    war_cmd.append('-s %s:war:$(location %s)' % (a,t))
-    war_targets.append(t)
-
-  genrule(
-    name = 'gen_war_install',
-    cmd = sh_bang_template % (' '.join(build_cmd + war_targets),
-                              ' '.join(war_cmd + ['-a', 'install'])),
-    out = 'war_install.sh',
-    executable = True,
-  )
-
-  if repository and url:
-    genrule(
-      name = 'gen_war_deploy',
-      cmd = sh_bang_template % (
-          ' '.join(build_cmd + war_targets),
-          ' '.join(war_cmd + [
-        '-a', 'deploy',
-        '--repository', repository,
-        '--url', url])),
-      out = 'war_deploy.sh',
-      executable = True,
-    )
diff --git a/tools/maven/repository.defs b/tools/maven/repository.defs
deleted file mode 100644
index c4e8fbf..0000000
--- a/tools/maven/repository.defs
+++ /dev/null
@@ -1,3 +0,0 @@
-MAVEN_REPOSITORY = 'sonatype-nexus-staging'
-MAVEN_SNAPSHOT_URL = 'https://oss.sonatype.org/content/repositories/snapshots'
-MAVEN_RELEASE_URL = 'https://oss.sonatype.org/service/local/staging/deploy/maven2'
diff --git a/tools/merge_jars.py b/tools/merge_jars.py
index 46016c0..89e83ca 100755
--- a/tools/merge_jars.py
+++ b/tools/merge_jars.py
@@ -39,12 +39,12 @@
             continue
           elif n.startswith(SERVICES):
             # Concatenate all provider configuration files.
-            services[n] += inzip.read(n)
+            services[n] += inzip.read(n).decode("UTF-8")
             continue
           outzip.writestr(info, inzip.read(n))
           seen.add(n)
 
-    for n, v in services.iteritems():
+    for n, v in list(services.items()):
       outzip.writestr(n, v)
 except Exception as err:
   exit('Failed to merge jars: %s' % err)
diff --git a/tools/pack_war.py b/tools/pack_war.py
deleted file mode 100755
index ca21790..0000000
--- a/tools/pack_war.py
+++ /dev/null
@@ -1,55 +0,0 @@
-#!/usr/bin/env python
-# Copyright (C) 2013 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.
-
-from __future__ import print_function
-from optparse import OptionParser
-from os import makedirs, path, symlink
-from subprocess import check_call
-import sys
-
-opts = OptionParser()
-opts.add_option('-o', help='path to write WAR to')
-opts.add_option('--lib', action='append', help='target for WEB-INF/lib')
-opts.add_option('--pgmlib', action='append', help='target for WEB-INF/pgm-lib')
-opts.add_option('--tmp', help='temporary directory')
-args, ctx = opts.parse_args()
-
-war = args.tmp
-jars = set()
-
-def prune(l):
-  return [j for e in l for j in e.split(':')]
-
-def link_jars(libs, directory):
-  makedirs(directory)
-  for j in libs:
-    if j not in jars:
-      jars.add(j)
-      n = path.basename(j)
-      if j.find('buck-out/gen/gerrit-') > 0:
-        n = j[j.find('buck-out'):].split('/')[2] + '-' + n
-      symlink(j, path.join(directory, n))
-
-if args.lib:
-  link_jars(prune(args.lib), path.join(war, 'WEB-INF', 'lib'))
-if args.pgmlib:
-  link_jars(prune(args.pgmlib), path.join(war, 'WEB-INF', 'pgm-lib'))
-try:
-  for s in ctx:
-    check_call(['unzip', '-q', '-d', war, s])
-  check_call(['zip', '-9qr', args.o, '.'], cwd=war)
-except KeyboardInterrupt:
-  print('Interrupted by user', file=sys.stderr)
-  exit(1)
diff --git a/tools/plugin_archetype_deploy.sh b/tools/plugin_archetype_deploy.sh
deleted file mode 100755
index b16ce95..0000000
--- a/tools/plugin_archetype_deploy.sh
+++ /dev/null
@@ -1,89 +0,0 @@
-#!/usr/bin/env bash
-# Copyright (C) 2014 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-function help
-{
-  cat <<'eof'
-Usage: plugin_archetype_deploy [option]
-
-Deploys Gerrit plugin Maven archetypes to Maven Central
-
-Valid options:
-  --help                     show this message.
-  --dry-run                  don't execute commands, just print them.
-
-eof
-exit
-}
-
-function getver
-{
-  grep "$1" $root/VERSION | sed "s/.*'\(.*\)'/\1/"
-}
-
-function instroot
-{
-  bindir=${0%/*}
-
-  case $bindir in
-  ./*) bindir=$PWD/$bindir ;;
-  esac
-
-  cd $bindir/..
-  pwd
-}
-
-function doIt
-{
-  case $dryRun in
-    true) echo "$@" ;;
-    *) "$@" ;;
-  esac
-}
-
-function build_and_deploy
-{
-  module=${PWD##*/}
-  doIt mvn package gpg:sign-and-deploy-file \
-    -Durl=$url \
-    -DrepositoryId=sonatype-nexus-staging \
-    -DpomFile=pom.xml \
-    -Dfile=target/$module-$ver.jar
-}
-
-function run
-{
-  test ${dryRun:-'false'} == 'false'
-  root=$(instroot)
-  cd "$root"
-  ver=$(getver GERRIT_VERSION)
-  [[ $ver == *-SNAPSHOT ]] \
-    && url="https://oss.sonatype.org/content/repositories/snapshots" \
-    || url="https://oss.sonatype.org/service/local/staging/deploy/maven2"
-
-  for d in gerrit-plugin-archetype \
-           gerrit-plugin-js-archetype \
-           gerrit-plugin-gwt-archetype ; do
-    (cd "$d"; build_and_deploy)
-  done
-}
-
-if [ "$1" == "--dry-run" ]; then
-  dryRun=true && run
-elif [ -z "$1" ]; then
-  run
-else
-  help
-fi
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
new file mode 100644
index 0000000..826be1d
--- /dev/null
+++ b/tools/remote-bazelrc
@@ -0,0 +1,104 @@
+# Copyright 2016 The Bazel Authors. All rights reserved.
+#
+# 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 file is auto-generated from release/bazelrc.tpl and should not be
+# modified directly.
+
+# This .bazelrc file contains all of the flags required for the provided
+# toolchain with Remote Build Execution.
+#
+# This .bazelrc file also contains all of the flags required for the local
+# docker sandboxing.
+
+# Depending on how many machines are in the remote execution instance, setting
+# this higher can make builds faster by allowing more jobs to run in parallel.
+# Setting it too high can result in jobs that timeout, however, while waiting
+# for a remote machine to execute them.
+build:remote --jobs=50
+build:remote --disk_cache=
+
+# Set several flags related to specifying the platform, toolchain and java
+# properties.
+# These flags are duplicated rather than imported from (for example)
+# %workspace%/configs/ubuntu16_04_clang/1.2/toolchain.bazelrc to make this
+# bazelrc a standalone file that can be copied more easily.
+# These flags should only be used as is for the rbe-ubuntu16-04 container
+# and need to be adapted to work with other toolchain containers.
+build:remote --host_javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
+build:remote --javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
+build:remote --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8
+build:remote --java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8
+build:remote --crosstool_top=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/default:toolchain
+build:remote --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
+# Platform flags:
+# The toolchain container used for execution is defined in the target indicated
+# by "extra_execution_platforms", "host_platform" and "platforms".
+# If you are using your own toolchain container, you need to create a platform
+# target with "constraint_values" that allow for the toolchain specified with
+# "extra_toolchains" to be selected (given constraints defined in
+# "exec_compatible_with").
+# More about platforms: https://docs.bazel.build/versions/master/platforms.html
+build:remote --extra_toolchains=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/cpp:cc-toolchain-clang-x86_64-default
+build:remote --extra_execution_platforms=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
+build:remote --host_platform=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
+build:remote --platforms=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:rbe_ubuntu1604
+
+# Set various strategies so that all actions execute remotely. Mixing remote
+# and local execution will lead to errors unless the toolchain and remote
+# machine exactly match the host machine.
+build:remote --spawn_strategy=remote
+build:remote --strategy=Javac=remote
+build:remote --strategy=Closure=remote
+build:remote --strategy=Genrule=remote
+build:remote --define=EXECUTOR=remote
+
+# Enable the remote cache so action results can be shared across machines,
+# developers, and workspaces.
+build:remote --remote_cache=remotebuildexecution.googleapis.com
+
+# Enable remote execution so actions are performed on the remote systems.
+build:remote --remote_executor=remotebuildexecution.googleapis.com
+
+# Set a higher timeout value, just in case.
+build:remote --remote_timeout=3600
+
+# Enable authentication. This will pick up application default credentials by
+# default. You can use --auth_credentials=some_file.json to use a service
+# account credential instead.
+build:remote --auth_enabled=true
+
+# The following flags are only necessary for local docker sandboxing
+# with the rbe-ubuntu16-04 container. Use of these flags is still experimental.
+build:docker-sandbox --host_javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
+build:docker-sandbox --javabase=@bazel_toolchains//configs/ubuntu16_04_clang/1.2:jdk8
+build:docker-sandbox --crosstool_top=@bazel_toolchains//configs/ubuntu16_04_clang/1.2/bazel_0.25.0/default:toolchain
+build:docker-sandbox --experimental_docker_image=gcr.io/cloud-marketplace/google/rbe-ubuntu16-04@sha256:da0f21c71abce3bbb92c3a0c44c3737f007a82b60f8bd2930abc55fe64fc2729
+build:docker-sandbox --spawn_strategy=docker
+build:docker-sandbox --strategy=Javac=docker
+build:docker-sandbox --strategy=Closure=docker
+build:docker-sandbox --strategy=Genrule=docker
+build:docker-sandbox --define=EXECUTOR=remote
+build:docker-sandbox --experimental_docker_verbose
+build:docker-sandbox --experimental_enable_docker_sandbox
+
+# The following flags enable the remote cache so action results can be shared
+# across machines, developers, and workspaces.
+build:remote-cache --remote_cache=remotebuildexecution.googleapis.com
+build:remote-cache --tls_enabled=true
+build:remote-cache --remote_timeout=3600
+build:remote-cache --auth_enabled=true
+build:remote-cache --spawn_strategy=standalone
+build:remote-cache --strategy=Javac=standalone
+build:remote-cache --strategy=Closure=standalone
+build:remote-cache --strategy=Genrule=standalone
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh
new file mode 100755
index 0000000..119f9af
--- /dev/null
+++ b/tools/setup_gjf.sh
@@ -0,0 +1,95 @@
+#!/bin/bash
+#
+# Copyright (C) 2017 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eu
+
+# Keep this version in sync with dev-contributing.txt.
+VERSION=${1:-1.7}
+
+case "$VERSION" in
+1.3)
+    SHA1="a73cfe6f9af01bd6ff150c0b50c9d620400f784c"
+    ;;
+1.5)
+    SHA1="b1f79e4d39a3c501f07c0ce7e8b03ac6964ed1f1"
+    ;;
+1.6)
+    SHA1="02b3e84e52d2473e2c4868189709905a51647d03"
+    ;;
+1.7)
+    SHA1="b6d34a51e579b08db7c624505bdf9af4397f1702"
+    ;;
+*)
+    echo "unknown google-java-format version: $VERSION"
+    exit 1
+    ;;
+esac
+
+root="$(git rev-parse --show-toplevel)"
+if [[ -z "$root" ]]; then
+  echo "google-java-format setup requires a git working tree"
+  exit 1
+fi
+
+dir="$root/tools/format"
+mkdir -p "$dir"
+
+name="google-java-format-$VERSION-all-deps.jar"
+url="https://github.com/google/google-java-format/releases/download/google-java-format-$VERSION/$name"
+"$root/tools/download_file.py" -o "$dir/$name" -u "$url" -v "$SHA1"
+
+launcher="$dir/google-java-format-$VERSION"
+cat > "$launcher" <<EOF
+#!/bin/bash
+#
+# Copyright (C) 2017 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+function abs_script_dir_path {
+    SOURCE=\${BASH_SOURCE[0]}
+    while [ -h "\$SOURCE" ]; do
+      DIR=\$( cd -P \$( dirname "\$SOURCE") && pwd )
+      SOURCE=\$(readlink "\$SOURCE")
+      [[ \$SOURCE != /* ]] && SOURCE="\$DIR/\$SOURCE"
+    done
+    DIR=\$( cd -P \$( dirname "\$SOURCE" ) && pwd )
+    echo \$DIR
+}
+
+set -e
+
+dir="\$(abs_script_dir_path "\$0")"
+exec java -jar "\$dir/$name" "\$@"
+EOF
+
+chmod +x "$launcher"
+
+cat <<EOF
+Installed launcher script at $launcher
+To set up an alias, add the following to your ~/.bashrc or equivalent:
+  alias google-java-format='$launcher'
+EOF
diff --git a/tools/util.py b/tools/util.py
index 573651d..5b9d455 100644
--- a/tools/util.py
+++ b/tools/util.py
@@ -12,14 +12,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import os
 from os import path
 
 REPO_ROOTS = {
   'ECLIPSE': 'https://repo.eclipse.org/content/groups/releases',
-  'GERRIT': 'http://gerrit-maven.storage.googleapis.com',
+  'GERRIT': 'https://gerrit-maven.storage.googleapis.com',
   'GERRIT_API': 'https://gerrit-api.commondatastorage.googleapis.com/release',
-  'MAVEN_CENTRAL': 'http://repo1.maven.org/maven2',
+  'MAVEN_CENTRAL': 'https://repo1.maven.org/maven2',
   'MAVEN_LOCAL': 'file://' + path.expanduser('~/.m2/repository'),
   'MAVEN_SNAPSHOT': 'https://oss.sonatype.org/content/repositories/snapshots',
 }
@@ -71,34 +70,3 @@
         break
       hash_obj.update(b)
   return hash_obj
-
-
-def hash_bower_component(hash_obj, path):
-  """Hash the contents of a bower component directory.
-
-  This is a stable hash of a directory downloaded with `bower install`, minus
-  the .bower.json file, which is autogenerated each time by bower. Used in lieu
-  of hashing a zipfile of the contents, since zipfiles are difficult to hash in
-  a stable manner.
-
-  Args:
-    hash_obj: an open hash object, e.g. hashlib.sha1().
-    path: path to the directory to hash.
-
-  Returns:
-    The passed-in hash_obj.
-  """
-  if not os.path.isdir(path):
-    raise ValueError('Not a directory: %s' % path)
-
-  path = os.path.abspath(path)
-  for root, dirs, files in os.walk(path):
-    dirs.sort()
-    for f in sorted(files):
-      if f == '.bower.json':
-        continue
-      p = os.path.join(root, f)
-      hash_obj.update(p[len(path)+1:])
-      hash_file(hash_obj, p)
-
-  return hash_obj
diff --git a/tools/util_test.py b/tools/util_test.py
index 30647ba..7c0689f 100644
--- a/tools/util_test.py
+++ b/tools/util_test.py
@@ -23,21 +23,21 @@
 
   def testKnown(self):
     url = resolve_url('GERRIT:foo.jar', {})
-    self.assertEqual(url, 'http://gerrit-maven.storage.googleapis.com/foo.jar')
+    self.assertEqual(url, 'https://gerrit-maven.storage.googleapis.com/foo.jar')
 
   def testKnownRedirect(self):
     url = resolve_url('MAVEN_CENTRAL:foo.jar',
-                      {'MAVEN_CENTRAL': 'http://my.company.mirror/maven2'})
-    self.assertEqual(url, 'http://my.company.mirror/maven2/foo.jar')
+                      {'MAVEN_CENTRAL': 'https://my.company.mirror/maven2'})
+    self.assertEqual(url, 'https://my.company.mirror/maven2/foo.jar')
 
   def testCustom(self):
-    url = resolve_url('http://maven.example.com/release/foo.jar', {})
-    self.assertEqual(url, 'http://maven.example.com/release/foo.jar')
+    url = resolve_url('https://maven.example.com/release/foo.jar', {})
+    self.assertEqual(url, 'https://maven.example.com/release/foo.jar')
 
   def testCustomRedirect(self):
     url = resolve_url('MAVEN_EXAMPLE:foo.jar',
-                      {'MAVEN_EXAMPLE': 'http://maven.example.com/release'})
-    self.assertEqual(url, 'http://maven.example.com/release/foo.jar')
+                      {'MAVEN_EXAMPLE': 'https://maven.example.com/release'})
+    self.assertEqual(url, 'https://maven.example.com/release/foo.jar')
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/tools/version.py b/tools/version.py
index 9f03a59..2603829 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -46,14 +46,13 @@
 src_pattern = re.compile(r'^(\s*<version>)([-.\w]+)(</version>\s*)$',
                          re.MULTILINE)
 for project in ['gerrit-acceptance-framework', 'gerrit-extension-api',
-                'gerrit-plugin-api', 'gerrit-plugin-archetype',
-                'gerrit-plugin-gwt-archetype', 'gerrit-plugin-gwtui',
-                'gerrit-plugin-js-archetype', 'gerrit-war']:
+                'gerrit-plugin-api', 'gerrit-plugin-gwtui',
+                'gerrit-war']:
   pom = os.path.join(project, 'pom.xml')
   replace_in_file(pom, src_pattern)
 
-src_pattern = re.compile(r"^(GERRIT_VERSION = ')([-.\w]+)(')$", re.MULTILINE)
-replace_in_file('VERSION', src_pattern)
+src_pattern = re.compile(r'^(GERRIT_VERSION = ")([-.\w]+)(")$', re.MULTILINE)
+replace_in_file('version.bzl', src_pattern)
 
 src_pattern = re.compile(r'^(\s*-DarchetypeVersion=)([-.\w]+)(\s*\\)$',
                          re.MULTILINE)
diff --git a/tools/workspace_status.py b/tools/workspace_status.py
new file mode 100644
index 0000000..86df519
--- /dev/null
+++ b/tools/workspace_status.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+
+# This script will be run by bazel when the build process starts to
+# generate key-value information that represents the status of the
+# workspace. The output should be like
+#
+# KEY1 VALUE1
+# KEY2 VALUE2
+#
+# If the script exits with non-zero code, it's considered as a failure
+# and the output will be discarded.
+
+from __future__ import print_function
+import os
+import subprocess
+import sys
+
+ROOT = os.path.abspath(__file__)
+while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')):
+    ROOT = os.path.dirname(ROOT)
+CMD = ['git', 'describe', '--always', '--match', 'v[0-9].*', '--dirty']
+
+
+def revision(directory, parent):
+    try:
+        os.chdir(directory)
+        return subprocess.check_output(CMD).strip().decode("utf-8")
+    except OSError as err:
+        print('could not invoke git: %s' % err, file=sys.stderr)
+        sys.exit(1)
+    except subprocess.CalledProcessError as err:
+        # ignore "not a git repository error" to report unknown version
+        return None
+    finally:
+        os.chdir(parent)
+
+
+print("STABLE_BUILD_GERRIT_LABEL %s" % revision(ROOT, ROOT))
+for d in os.listdir(os.path.join(ROOT, 'plugins')):
+    p = os.path.join('plugins', d)
+    if os.path.isdir(p):
+        v = revision(p, ROOT)
+        print('STABLE_BUILD_%s_LABEL %s' % (os.path.basename(p).upper(),
+                                            v if v else 'unknown'))
diff --git a/version.bzl b/version.bzl
new file mode 100644
index 0000000..30a5f0e
--- /dev/null
+++ b/version.bzl
@@ -0,0 +1,5 @@
+# Maven style API version (e.g. '2.x-SNAPSHOT').
+# Used by :api_install and :api_deploy targets
+# when talking to the destination repository.
+#
+GERRIT_VERSION = "2.14.23-SNAPSHOT"
diff --git a/website/releases/index.html b/website/releases/index.html
index 582b495..ab3fcd6 100644
--- a/website/releases/index.html
+++ b/website/releases/index.html
@@ -44,7 +44,7 @@
   var doc = document;
   var frg = doc.createDocumentFragment();
   var rx = /^gerrit(?:-full)?-([0-9.]+(?:-rc[0-9]+)?)[.]war/;
-  var dl = 'https://www.gerritcodereview.com/download/';
+  var dl = 'https://gerrit-releases.storage.googleapis.com/';
   var docs = 'https://gerrit-documentation.storage.googleapis.com/';
   var src = 'https://gerrit.googlesource.com/gerrit/+/'